feat(zset): the Z-set group, map, filter, distinct and the stream operators (improvise-tk51)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
committed by
Edward Langley
parent
5f8c3f8c82
commit
e15b285822
Generated
+7
@@ -2725,3 +2725,10 @@ name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zset"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/improvise-wasm-client", "crates/improvise-ws-server", "crates/improvise-worker-server", "crates/gp-tree", "crates/improvise-gp", "crates/numtower"]
|
||||
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-protocol", "crates/improvise-io", "crates/improvise-command", "crates/improvise-wasm-client", "crates/improvise-ws-server", "crates/improvise-worker-server", "crates/gp-tree", "crates/improvise-gp", "crates/numtower", "crates/zset"]
|
||||
|
||||
[package]
|
||||
name = "improvise"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "zset"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
description = "Z-sets: finite maps from keys to non-zero integer weights, an abelian group, with the stream operators incremental evaluation needs."
|
||||
keywords = ["z-set", "dbsp", "incremental", "group"]
|
||||
categories = ["data-structures"]
|
||||
|
||||
# ADR 009 clause 1: no dependencies beyond std, so it is wasm32-clean and serde-free.
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Z-sets: finite maps from keys to non-zero `i64` weights, forming an abelian group
|
||||
//! under pointwise addition (ADR 009 clause 1).
|
||||
//!
|
||||
//! Reasoning, the interpretations the ADR left open and the overflow policy are in
|
||||
//! `context/code/zset.md`. Evidence for each test is in `context/test-evidence/zset.md`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::btree_map::Entry;
|
||||
use std::iter::FromIterator;
|
||||
use std::ops::{Add, AddAssign, Mul, Neg, Sub, SubAssign};
|
||||
|
||||
/// A map from `K` to a non-zero `i64` weight.
|
||||
///
|
||||
/// The representation is canonical: a key with weight zero is never stored, so two
|
||||
/// Z-sets are `==` exactly when they assign every key the same weight. Iteration is in
|
||||
/// ascending key order.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ZSet<K> {
|
||||
weights: BTreeMap<K, i64>,
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn overflow(op: &str, a: i64, b: i64) -> ! {
|
||||
panic!("zset: weight overflow in {op}: {a} and {b} do not combine within i64")
|
||||
}
|
||||
|
||||
fn plus(a: i64, b: i64) -> i64 {
|
||||
a.checked_add(b).unwrap_or_else(|| overflow("add", a, b))
|
||||
}
|
||||
|
||||
fn negated(a: i64) -> i64 {
|
||||
a.checked_neg().unwrap_or_else(|| overflow("neg", a, 0))
|
||||
}
|
||||
|
||||
fn times(a: i64, n: i64) -> i64 {
|
||||
a.checked_mul(n).unwrap_or_else(|| overflow("scale", a, n))
|
||||
}
|
||||
|
||||
impl<K> Default for ZSet<K> {
|
||||
fn default() -> Self {
|
||||
ZSet {
|
||||
weights: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord> ZSet<K> {
|
||||
/// The zero of the group: no key has a weight.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// One key at `weight`; a zero weight gives the zero Z-set.
|
||||
pub fn singleton(key: K, weight: i64) -> Self {
|
||||
let mut z = Self::new();
|
||||
z.insert(key, weight);
|
||||
z
|
||||
}
|
||||
|
||||
/// The weight of `key`, zero if it is absent.
|
||||
pub fn weight(&self, key: &K) -> i64 {
|
||||
self.weights.get(key).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.weights.is_empty()
|
||||
}
|
||||
|
||||
/// The number of keys with a non-zero weight.
|
||||
pub fn len(&self) -> usize {
|
||||
self.weights.len()
|
||||
}
|
||||
|
||||
/// Keys with their weights, in ascending key order. No weight is zero.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&K, i64)> {
|
||||
self.weights.iter().map(|(k, w)| (k, *w))
|
||||
}
|
||||
|
||||
/// Adds `weight` to `key`'s weight, dropping the key if the sum is zero.
|
||||
///
|
||||
/// Panics if the sum does not fit an `i64`.
|
||||
pub fn insert(&mut self, key: K, weight: i64) {
|
||||
match self.weights.entry(key) {
|
||||
Entry::Vacant(e) => {
|
||||
if weight != 0 {
|
||||
e.insert(weight);
|
||||
}
|
||||
}
|
||||
Entry::Occupied(mut e) => {
|
||||
let sum = plus(*e.get(), weight);
|
||||
if sum == 0 {
|
||||
e.remove();
|
||||
} else {
|
||||
*e.get_mut() = sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps every key through `f`; the weights of keys with one image sum.
|
||||
pub fn map<L: Ord>(&self, mut f: impl FnMut(&K) -> L) -> ZSet<L> {
|
||||
let mut out = ZSet::new();
|
||||
for (k, w) in &self.weights {
|
||||
out.insert(f(k), *w);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Keeps the keys `keep` accepts, with their weights. It looks at the key only,
|
||||
/// which is what makes it linear.
|
||||
pub fn filter(&self, mut keep: impl FnMut(&K) -> bool) -> Self
|
||||
where
|
||||
K: Clone,
|
||||
{
|
||||
self.weights
|
||||
.iter()
|
||||
.filter(|(k, _)| keep(k))
|
||||
.map(|(k, w)| (k.clone(), *w))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Weight 1 for every key whose weight is positive; every other key is dropped.
|
||||
pub fn distinct(&self) -> Self
|
||||
where
|
||||
K: Clone,
|
||||
{
|
||||
self.weights
|
||||
.iter()
|
||||
.filter(|(_, w)| **w > 0)
|
||||
.map(|(k, _)| (k.clone(), 1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every weight times `n`; zero gives the zero Z-set.
|
||||
///
|
||||
/// Panics if a product does not fit an `i64`.
|
||||
pub fn scale(&self, n: i64) -> Self
|
||||
where
|
||||
K: Clone,
|
||||
{
|
||||
if n == 0 {
|
||||
return Self::new();
|
||||
}
|
||||
let weights = self
|
||||
.weights
|
||||
.iter()
|
||||
.map(|(k, w)| (k.clone(), times(*w, n)))
|
||||
.collect();
|
||||
ZSet { weights }
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord> FromIterator<(K, i64)> for ZSet<K> {
|
||||
/// Weights of repeated keys sum.
|
||||
fn from_iter<I: IntoIterator<Item = (K, i64)>>(iter: I) -> Self {
|
||||
let mut z = ZSet::new();
|
||||
z.extend(iter);
|
||||
z
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord> Extend<(K, i64)> for ZSet<K> {
|
||||
fn extend<I: IntoIterator<Item = (K, i64)>>(&mut self, iter: I) {
|
||||
for (k, w) in iter {
|
||||
self.insert(k, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord> IntoIterator for ZSet<K> {
|
||||
type Item = (K, i64);
|
||||
type IntoIter = std::collections::btree_map::IntoIter<K, i64>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.weights.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord> AddAssign for ZSet<K> {
|
||||
fn add_assign(&mut self, other: Self) {
|
||||
self.extend(other);
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord + Clone> AddAssign<&ZSet<K>> for ZSet<K> {
|
||||
fn add_assign(&mut self, other: &ZSet<K>) {
|
||||
self.extend(other.iter().map(|(k, w)| (k.clone(), w)));
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord> Add for ZSet<K> {
|
||||
type Output = ZSet<K>;
|
||||
|
||||
fn add(mut self, other: Self) -> Self {
|
||||
self += other;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord + Clone> Add for &ZSet<K> {
|
||||
type Output = ZSet<K>;
|
||||
|
||||
fn add(self, other: Self) -> ZSet<K> {
|
||||
let mut z = self.clone();
|
||||
z += other;
|
||||
z
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord> Neg for ZSet<K> {
|
||||
type Output = ZSet<K>;
|
||||
|
||||
fn neg(self) -> Self {
|
||||
let weights = self
|
||||
.weights
|
||||
.into_iter()
|
||||
.map(|(k, w)| (k, negated(w)))
|
||||
.collect();
|
||||
ZSet { weights }
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord + Clone> Neg for &ZSet<K> {
|
||||
type Output = ZSet<K>;
|
||||
|
||||
fn neg(self) -> ZSet<K> {
|
||||
self.clone().neg()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord> Sub for ZSet<K> {
|
||||
type Output = ZSet<K>;
|
||||
|
||||
fn sub(mut self, other: Self) -> Self {
|
||||
self -= other;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord + Clone> Sub for &ZSet<K> {
|
||||
type Output = ZSet<K>;
|
||||
|
||||
fn sub(self, other: Self) -> ZSet<K> {
|
||||
self.clone() - other.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord> SubAssign for ZSet<K> {
|
||||
fn sub_assign(&mut self, other: Self) {
|
||||
self.extend(other.into_iter().map(|(k, w)| (k, negated(w))));
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Ord + Clone> Mul<i64> for &ZSet<K> {
|
||||
type Output = ZSet<K>;
|
||||
|
||||
fn mul(self, n: i64) -> ZSet<K> {
|
||||
self.scale(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// The running sums of a stream of deltas: element `i` is `d_0 + ... + d_i`.
|
||||
pub fn integrate<K: Ord + Clone>(deltas: &[ZSet<K>]) -> Vec<ZSet<K>> {
|
||||
let mut state = ZSet::new();
|
||||
deltas
|
||||
.iter()
|
||||
.map(|d| {
|
||||
state += d;
|
||||
state.clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The differences of a stream of states: element 0 is `s_0` (the state before it is
|
||||
/// zero), and element `i` is `s_i - s_(i-1)`.
|
||||
pub fn differentiate<K: Ord + Clone>(states: &[ZSet<K>]) -> Vec<ZSet<K>> {
|
||||
let mut previous = ZSet::new();
|
||||
states
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let d = s - &previous;
|
||||
previous = s.clone();
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_zero_weight_is_never_stored() {
|
||||
let z = ZSet::from_iter([("a", 2), ("a", -2), ("b", 0), ("c", 1)]);
|
||||
assert_eq!(z.len(), 1);
|
||||
assert_eq!(z.weight(&"a"), 0);
|
||||
assert_eq!(z, ZSet::singleton("c", 1));
|
||||
assert!(ZSet::singleton("x", 0).is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_clamps_positive_weights_and_drops_the_rest() {
|
||||
let z = ZSet::from_iter([("a", 5), ("b", -3), ("c", 1)]);
|
||||
assert_eq!(z.distinct(), ZSet::from_iter([("a", 1), ("c", 1)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_sums_colliding_images() {
|
||||
let z = ZSet::from_iter([(1, 2), (2, -2), (3, 4)]);
|
||||
assert_eq!(z.map(|k| k % 2), ZSet::from_iter([(0, -2), (1, 6)]));
|
||||
assert!(ZSet::from_iter([(1, 2), (2, -2)]).map(|_| 0).is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_differentiate_of_a_stream_starts_from_zero() {
|
||||
let s = vec![ZSet::singleton(1, 1), ZSet::singleton(2, 1)];
|
||||
let d = differentiate(&s);
|
||||
assert_eq!(d[0], s[0]);
|
||||
assert_eq!(d[1], ZSet::from_iter([(1, -1), (2, 1)]));
|
||||
assert_eq!(integrate(&d), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "weight overflow in add")]
|
||||
fn adding_past_i64_max_panics() {
|
||||
let _ = ZSet::singleton(1, i64::MAX) + ZSet::singleton(1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "weight overflow in add")]
|
||||
fn adding_past_i64_min_panics() {
|
||||
let _ = ZSet::singleton(1, i64::MIN) + ZSet::singleton(1, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "weight overflow in neg")]
|
||||
fn negating_i64_min_panics() {
|
||||
let _ = -ZSet::singleton(1, i64::MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "weight overflow in scale")]
|
||||
fn scaling_past_i64_panics() {
|
||||
let _ = ZSet::singleton(1, i64::MAX).scale(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_by_zero_never_overflows() {
|
||||
assert!(ZSet::singleton(1, i64::MAX).scale(0).is_zero());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
//! Property tests for `zset`. Each property is a checker that takes the implementation as
|
||||
//! a parameter: the property's own test runs it on `ZSet`, and a `_catches_` companion runs
|
||||
//! the same checker on a rival with one injected fault and requires that the failure carry
|
||||
//! the property's tag. The evidence is in `context/test-evidence/zset.md`.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use proptest::test_runner::{TestError, TestRunner};
|
||||
use zset::{ZSet, differentiate, integrate};
|
||||
|
||||
type Pairs = Vec<(u8, i64)>;
|
||||
|
||||
/// The operations under test, over a representation the implementation chooses.
|
||||
trait Alg {
|
||||
type Z: Clone + std::fmt::Debug;
|
||||
fn from_pairs(&self, p: &[(u8, i64)]) -> Self::Z;
|
||||
fn add(&self, a: &Self::Z, b: &Self::Z) -> Self::Z;
|
||||
fn neg(&self, a: &Self::Z) -> Self::Z;
|
||||
fn sub(&self, a: &Self::Z, b: &Self::Z) -> Self::Z;
|
||||
fn scale(&self, a: &Self::Z, n: i64) -> Self::Z;
|
||||
/// Images are `k % m`, so keys collide.
|
||||
fn map(&self, a: &Self::Z, m: u8) -> Self::Z;
|
||||
/// Keeps the keys whose `k % 3` is not `drop`.
|
||||
fn filter(&self, a: &Self::Z, drop: u8) -> Self::Z;
|
||||
fn distinct(&self, a: &Self::Z) -> Self::Z;
|
||||
fn integrate(&self, d: &[Self::Z]) -> Vec<Self::Z>;
|
||||
fn differentiate(&self, s: &[Self::Z]) -> Vec<Self::Z>;
|
||||
/// What an iteration yields, in the order it yields it.
|
||||
fn stored(&self, a: &Self::Z) -> Vec<(u8, i64)>;
|
||||
/// Whether two values are the same set of weights, however each is stored.
|
||||
fn same(&self, a: &Self::Z, b: &Self::Z) -> bool;
|
||||
fn zero(&self) -> Self::Z {
|
||||
self.from_pairs(&[])
|
||||
}
|
||||
}
|
||||
|
||||
struct Real;
|
||||
|
||||
impl Alg for Real {
|
||||
type Z = ZSet<u8>;
|
||||
fn from_pairs(&self, p: &[(u8, i64)]) -> ZSet<u8> {
|
||||
p.iter().copied().collect()
|
||||
}
|
||||
fn add(&self, a: &ZSet<u8>, b: &ZSet<u8>) -> ZSet<u8> {
|
||||
a + b
|
||||
}
|
||||
fn neg(&self, a: &ZSet<u8>) -> ZSet<u8> {
|
||||
-a
|
||||
}
|
||||
fn sub(&self, a: &ZSet<u8>, b: &ZSet<u8>) -> ZSet<u8> {
|
||||
a - b
|
||||
}
|
||||
fn scale(&self, a: &ZSet<u8>, n: i64) -> ZSet<u8> {
|
||||
a * n
|
||||
}
|
||||
fn map(&self, a: &ZSet<u8>, m: u8) -> ZSet<u8> {
|
||||
a.map(|k| k % m)
|
||||
}
|
||||
fn filter(&self, a: &ZSet<u8>, drop: u8) -> ZSet<u8> {
|
||||
a.filter(|k| k % 3 != drop)
|
||||
}
|
||||
fn distinct(&self, a: &ZSet<u8>) -> ZSet<u8> {
|
||||
a.distinct()
|
||||
}
|
||||
fn integrate(&self, d: &[ZSet<u8>]) -> Vec<ZSet<u8>> {
|
||||
integrate(d)
|
||||
}
|
||||
fn differentiate(&self, s: &[ZSet<u8>]) -> Vec<ZSet<u8>> {
|
||||
differentiate(s)
|
||||
}
|
||||
fn stored(&self, a: &ZSet<u8>) -> Vec<(u8, i64)> {
|
||||
a.iter().map(|(k, w)| (*k, w)).collect()
|
||||
}
|
||||
fn same(&self, a: &ZSet<u8>, b: &ZSet<u8>) -> bool {
|
||||
a == b
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum Fault {
|
||||
None,
|
||||
KeepZero,
|
||||
AddKeepsLeft,
|
||||
AddClamps,
|
||||
AddIntersects,
|
||||
NegForgetsAKey,
|
||||
SubFlipped,
|
||||
ScaleAbs,
|
||||
MapOverwrites,
|
||||
FilterReadsWeight,
|
||||
DistinctNoClamp,
|
||||
InsertionOrder,
|
||||
IntegrateExcludesCurrent,
|
||||
DifferentiateDropsFirst,
|
||||
}
|
||||
|
||||
/// A second implementation over a plain vector, with at most one fault injected.
|
||||
struct Model(Fault);
|
||||
|
||||
type V = Vec<(u8, i64)>;
|
||||
|
||||
impl Model {
|
||||
fn normal(&self, v: V) -> V {
|
||||
let mut out: V = Vec::new();
|
||||
for (k, w) in v {
|
||||
match out.iter_mut().find(|(k2, _)| *k2 == k) {
|
||||
Some(e) => e.1 += w,
|
||||
None => out.push((k, w)),
|
||||
}
|
||||
}
|
||||
if self.0 != Fault::KeepZero {
|
||||
out.retain(|(_, w)| *w != 0);
|
||||
}
|
||||
if self.0 != Fault::InsertionOrder {
|
||||
out.sort_by_key(|(k, _)| *k);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Alg for Model {
|
||||
type Z = V;
|
||||
fn from_pairs(&self, p: &[(u8, i64)]) -> V {
|
||||
self.normal(p.to_vec())
|
||||
}
|
||||
fn add(&self, a: &V, b: &V) -> V {
|
||||
if self.0 == Fault::AddKeepsLeft {
|
||||
let mut out = a.clone();
|
||||
for (k, w) in b {
|
||||
if !out.iter().any(|(k2, _)| k2 == k) {
|
||||
out.push((*k, *w));
|
||||
}
|
||||
}
|
||||
return self.normal(out);
|
||||
}
|
||||
if self.0 == Fault::AddIntersects {
|
||||
let both = |k: &u8, v: &V| v.iter().any(|(k2, _)| k2 == k);
|
||||
let kept = a.iter().chain(b).filter(|(k, _)| both(k, a) && both(k, b));
|
||||
return self.normal(kept.copied().collect());
|
||||
}
|
||||
let sum = self.normal(a.iter().chain(b).copied().collect());
|
||||
if self.0 == Fault::AddClamps {
|
||||
return sum.into_iter().map(|(k, w)| (k, w.clamp(-10, 10))).collect();
|
||||
}
|
||||
sum
|
||||
}
|
||||
fn neg(&self, a: &V) -> V {
|
||||
let skip = usize::from(self.0 == Fault::NegForgetsAKey);
|
||||
self.normal(a.iter().skip(skip).map(|(k, w)| (*k, -w)).collect())
|
||||
}
|
||||
fn sub(&self, a: &V, b: &V) -> V {
|
||||
let (x, y) = if self.0 == Fault::SubFlipped { (b, a) } else { (a, b) };
|
||||
self.add(x, &self.neg(y))
|
||||
}
|
||||
fn scale(&self, a: &V, n: i64) -> V {
|
||||
let n = if self.0 == Fault::ScaleAbs { n.abs() } else { n };
|
||||
self.normal(a.iter().map(|(k, w)| (*k, w * n)).collect())
|
||||
}
|
||||
fn map(&self, a: &V, m: u8) -> V {
|
||||
if self.0 == Fault::MapOverwrites {
|
||||
let mut out: Vec<(u8, i64)> = Vec::new();
|
||||
for (k, w) in a {
|
||||
out.retain(|(k2, _)| *k2 != k % m);
|
||||
out.push((k % m, *w));
|
||||
}
|
||||
return self.normal(out);
|
||||
}
|
||||
self.normal(a.iter().map(|(k, w)| (k % m, *w)).collect())
|
||||
}
|
||||
fn filter(&self, a: &V, drop: u8) -> V {
|
||||
let weighted = self.0 == Fault::FilterReadsWeight;
|
||||
self.normal(
|
||||
a.iter()
|
||||
.filter(|(k, w)| k % 3 != drop && (!weighted || *w > 0))
|
||||
.copied()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
fn distinct(&self, a: &V) -> V {
|
||||
let clamp = if self.0 == Fault::DistinctNoClamp { None } else { Some(1) };
|
||||
self.normal(
|
||||
a.iter()
|
||||
.filter(|(_, w)| *w > 0)
|
||||
.map(|(k, w)| (*k, clamp.unwrap_or(*w)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
fn integrate(&self, d: &[V]) -> Vec<V> {
|
||||
let mut state = self.zero();
|
||||
let mut out = Vec::new();
|
||||
for x in d {
|
||||
if self.0 == Fault::IntegrateExcludesCurrent {
|
||||
out.push(state.clone());
|
||||
state = self.add(&state, x);
|
||||
} else {
|
||||
state = self.add(&state, x);
|
||||
out.push(state.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn differentiate(&self, s: &[V]) -> Vec<V> {
|
||||
let mut prev = self.zero();
|
||||
let mut out = Vec::new();
|
||||
for (i, x) in s.iter().enumerate() {
|
||||
let d = self.sub(x, &prev);
|
||||
out.push(if i == 0 && self.0 == Fault::DifferentiateDropsFirst { self.zero() } else { d });
|
||||
prev = x.clone();
|
||||
}
|
||||
out
|
||||
}
|
||||
fn stored(&self, a: &V) -> Vec<(u8, i64)> {
|
||||
a.clone()
|
||||
}
|
||||
fn same(&self, a: &V, b: &V) -> bool {
|
||||
let sorted = |v: &V| {
|
||||
let mut v = v.clone();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
sorted(a) == sorted(b)
|
||||
}
|
||||
}
|
||||
|
||||
fn weight() -> impl Strategy<Value = i64> {
|
||||
prop_oneof![
|
||||
3 => -3i64..=3,
|
||||
1 => Just(0i64),
|
||||
2 => -(1i64 << 40)..=(1i64 << 40),
|
||||
]
|
||||
}
|
||||
|
||||
/// Keys from a small range, so colliding keys and cancelling pairs are the common case.
|
||||
fn pairs() -> impl Strategy<Value = Pairs> {
|
||||
prop::collection::vec((0u8..6, weight()), 0..8)
|
||||
}
|
||||
|
||||
fn scalar() -> impl Strategy<Value = i64> {
|
||||
prop_oneof![-3i64..=3, -(1i64 << 10)..=(1i64 << 10)]
|
||||
}
|
||||
|
||||
fn stream() -> impl Strategy<Value = Vec<Pairs>> {
|
||||
prop::collection::vec(pairs(), 0..6)
|
||||
}
|
||||
|
||||
fn caught<T: std::fmt::Debug>(
|
||||
strategy: impl Strategy<Value = T>,
|
||||
tag: &str,
|
||||
check: impl Fn(&T) -> Result<(), TestCaseError>,
|
||||
) {
|
||||
let mut runner = TestRunner::deterministic();
|
||||
match runner.run(&strategy, |v| check(&v)) {
|
||||
Err(TestError::Fail(reason, _)) => {
|
||||
let message = reason.message().to_string();
|
||||
assert!(message.contains(tag), "failed, but not on {tag}: {message}");
|
||||
}
|
||||
other => panic!("the rival was not caught on {tag}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok<T>(strategy: impl Strategy<Value = T>, check: impl Fn(&T) -> Result<(), TestCaseError>)
|
||||
where
|
||||
T: std::fmt::Debug,
|
||||
{
|
||||
let mut runner = TestRunner::deterministic();
|
||||
runner.run(&strategy, |v| check(&v)).unwrap();
|
||||
}
|
||||
|
||||
fn associativity<A: Alg>(x: &A, (a, b, c): &(Pairs, Pairs, Pairs)) -> Result<(), TestCaseError> {
|
||||
let (a, b, c) = (x.from_pairs(a), x.from_pairs(b), x.from_pairs(c));
|
||||
let left = x.add(&x.add(&a, &b), &c);
|
||||
let right = x.add(&a, &x.add(&b, &c));
|
||||
prop_assert!(x.same(&left, &right), "[associativity] {left:?} != {right:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commutativity<A: Alg>(x: &A, (a, b): &(Pairs, Pairs)) -> Result<(), TestCaseError> {
|
||||
let (a, b) = (x.from_pairs(a), x.from_pairs(b));
|
||||
let (ab, ba) = (x.add(&a, &b), x.add(&b, &a));
|
||||
prop_assert!(x.same(&ab, &ba), "[commutativity] {ab:?} != {ba:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn identity<A: Alg>(x: &A, a: &Pairs) -> Result<(), TestCaseError> {
|
||||
let a = x.from_pairs(a);
|
||||
let zero = x.zero();
|
||||
prop_assert!(x.same(&x.add(&a, &zero), &a), "[identity] a + 0 != a for {a:?}");
|
||||
prop_assert!(x.same(&x.add(&zero, &a), &a), "[identity] 0 + a != a for {a:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inverse<A: Alg>(x: &A, a: &Pairs) -> Result<(), TestCaseError> {
|
||||
let a = x.from_pairs(a);
|
||||
let sum = x.add(&a, &x.neg(&a));
|
||||
prop_assert!(x.same(&sum, &x.zero()), "[inverse] a + (-a) = {sum:?} for {a:?}");
|
||||
let diff = x.sub(&a, &a);
|
||||
prop_assert!(x.same(&diff, &x.zero()), "[inverse] a - a = {diff:?} for {a:?}");
|
||||
prop_assert!(x.same(&x.neg(&x.neg(&a)), &a), "[inverse] --a != a for {a:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn subtraction<A: Alg>(x: &A, (a, b): &(Pairs, Pairs)) -> Result<(), TestCaseError> {
|
||||
let (a, b) = (x.from_pairs(a), x.from_pairs(b));
|
||||
let (d, e) = (x.sub(&a, &b), x.add(&a, &x.neg(&b)));
|
||||
prop_assert!(x.same(&d, &e), "[subtraction] a - b = {d:?} but a + (-b) = {e:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_zero<A: Alg>(x: &A, (a, b, n, m): &(Pairs, Pairs, i64, u8)) -> Result<(), TestCaseError> {
|
||||
let (a, b) = (x.from_pairs(a), x.from_pairs(b));
|
||||
let made = [
|
||||
a.clone(),
|
||||
x.add(&a, &b),
|
||||
x.add(&a, &x.neg(&a)),
|
||||
x.neg(&a),
|
||||
x.sub(&a, &b),
|
||||
x.scale(&a, *n),
|
||||
x.scale(&a, 0),
|
||||
x.map(&a, *m),
|
||||
x.filter(&a, 1),
|
||||
x.distinct(&a),
|
||||
];
|
||||
for z in &made {
|
||||
let zeros: Vec<_> = x.stored(z).into_iter().filter(|(_, w)| *w == 0).collect();
|
||||
prop_assert!(zeros.is_empty(), "[canonical-zero] stored zero weights {zeros:?} in {z:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_equality<A: Alg>(x: &A, (p, seed): &(Pairs, usize)) -> Result<(), TestCaseError> {
|
||||
let mut shuffled = p.clone();
|
||||
if !shuffled.is_empty() {
|
||||
let n = shuffled.len();
|
||||
shuffled.rotate_left(seed % n);
|
||||
shuffled.reverse();
|
||||
}
|
||||
let (a, b) = (x.from_pairs(p), x.from_pairs(&shuffled));
|
||||
prop_assert!(x.same(&a, &b), "[canonical-equality] {a:?} != {b:?}");
|
||||
// The same set built in two steps, with a cancelling pair spliced in.
|
||||
let mut padded = p.clone();
|
||||
padded.push((9, 5));
|
||||
padded.push((9, -5));
|
||||
let c = x.from_pairs(&padded);
|
||||
prop_assert!(x.same(&a, &c), "[canonical-equality] a cancelled pair changed {a:?} into {c:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scalar_multiplication<A: Alg>(
|
||||
x: &A,
|
||||
(a, b, m, n): &(Pairs, Pairs, i64, i64),
|
||||
) -> Result<(), TestCaseError> {
|
||||
let (a, b) = (x.from_pairs(a), x.from_pairs(b));
|
||||
let (l, r) = (x.scale(&x.add(&a, &b), *n), x.add(&x.scale(&a, *n), &x.scale(&b, *n)));
|
||||
prop_assert!(x.same(&l, &r), "[scale] (a+b)*n = {l:?} but a*n + b*n = {r:?}");
|
||||
// The scalar side: (m + n) * a, with m, n small enough that m + n cannot wrap.
|
||||
let (l, r) = (x.scale(&a, m + n), x.add(&x.scale(&a, *m), &x.scale(&a, *n)));
|
||||
prop_assert!(x.same(&l, &r), "[scale] a*(m+n) = {l:?} but a*m + a*n = {r:?}");
|
||||
prop_assert!(x.same(&x.scale(&a, -1), &x.neg(&a)), "[scale] a * -1 != -a for {a:?}");
|
||||
prop_assert!(x.same(&x.scale(&a, 1), &a), "[scale] a * 1 != a for {a:?}");
|
||||
prop_assert!(x.same(&x.scale(&a, 0), &x.zero()), "[scale] a * 0 != zero for {a:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_homomorphism<A: Alg>(x: &A, (a, b, m): &(Pairs, Pairs, u8)) -> Result<(), TestCaseError> {
|
||||
let (a, b) = (x.from_pairs(a), x.from_pairs(b));
|
||||
let (l, r) = (x.map(&x.add(&a, &b), *m), x.add(&x.map(&a, *m), &x.map(&b, *m)));
|
||||
prop_assert!(x.same(&l, &r), "[map] map(a+b) = {l:?} but map(a)+map(b) = {r:?}");
|
||||
let (l, r) = (x.map(&x.neg(&a), *m), x.neg(&x.map(&a, *m)));
|
||||
prop_assert!(x.same(&l, &r), "[map] map(-a) = {l:?} but -map(a) = {r:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn filter_linearity<A: Alg>(x: &A, (a, b, d): &(Pairs, Pairs, u8)) -> Result<(), TestCaseError> {
|
||||
let (a, b) = (x.from_pairs(a), x.from_pairs(b));
|
||||
let (l, r) = (x.filter(&x.add(&a, &b), *d), x.add(&x.filter(&a, *d), &x.filter(&b, *d)));
|
||||
prop_assert!(x.same(&l, &r), "[filter] filter(a+b) = {l:?} but filter(a)+filter(b) = {r:?}");
|
||||
let (l, r) = (x.filter(&x.neg(&a), *d), x.neg(&x.filter(&a, *d)));
|
||||
prop_assert!(x.same(&l, &r), "[filter] filter(-a) = {l:?} but -filter(a) = {r:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn distinct_semantics<A: Alg>(x: &A, a: &Pairs) -> Result<(), TestCaseError> {
|
||||
let a = x.from_pairs(a);
|
||||
let d = x.distinct(&a);
|
||||
let expected: Vec<_> = x.stored(&a).into_iter().filter(|(_, w)| *w > 0).map(|(k, _)| (k, 1)).collect();
|
||||
prop_assert!(
|
||||
x.stored(&d) == expected,
|
||||
"[distinct] distinct({a:?}) = {d:?}, expected weight 1 on exactly the positive keys"
|
||||
);
|
||||
prop_assert!(x.same(&x.distinct(&d), &d), "[distinct] not idempotent on {a:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn iteration_order<A: Alg>(x: &A, (p, seed): &(Pairs, usize)) -> Result<(), TestCaseError> {
|
||||
let a = x.from_pairs(p);
|
||||
let order = x.stored(&a);
|
||||
prop_assert!(
|
||||
order.windows(2).all(|w| w[0].0 < w[1].0),
|
||||
"[order] iteration is not strictly ascending by key: {order:?}"
|
||||
);
|
||||
let mut shuffled = p.clone();
|
||||
if !shuffled.is_empty() {
|
||||
let n = shuffled.len();
|
||||
shuffled.rotate_left(seed % n);
|
||||
shuffled.reverse();
|
||||
}
|
||||
let again = x.stored(&x.from_pairs(&shuffled));
|
||||
prop_assert!(order == again, "[order] insertion order shows through: {order:?} vs {again:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn correction<A: Alg>(x: &A, (held, target): &(Pairs, Pairs)) -> Result<(), TestCaseError> {
|
||||
let (held, target) = (x.from_pairs(held), x.from_pairs(target));
|
||||
let delta = x.sub(&target, &held);
|
||||
let got = x.add(&held, &delta);
|
||||
prop_assert!(x.same(&got, &target), "[correction] held + (target - held) = {got:?}, not {target:?}");
|
||||
let nothing = x.sub(&target, &target);
|
||||
prop_assert!(x.same(¬hing, &x.zero()), "[correction] a correct state needs {nothing:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn streams<A: Alg>(x: &A, s: &Vec<Pairs>) -> Result<(), TestCaseError> {
|
||||
let deltas: Vec<_> = s.iter().map(|p| x.from_pairs(p)).collect();
|
||||
let states = x.integrate(&deltas);
|
||||
prop_assert!(states.len() == deltas.len(), "[stream] integrate changed the length");
|
||||
let mut running = x.zero();
|
||||
for (i, d) in deltas.iter().enumerate() {
|
||||
running = x.add(&running, d);
|
||||
prop_assert!(x.same(&states[i], &running), "[stream] integrate[{i}] = {:?}, not {running:?}", states[i]);
|
||||
}
|
||||
let back = x.differentiate(&states);
|
||||
prop_assert!(back.len() == deltas.len(), "[stream] differentiate changed the length");
|
||||
for (i, d) in deltas.iter().enumerate() {
|
||||
prop_assert!(x.same(&back[i], d), "[stream] differentiate(integrate(d))[{i}] = {:?}, not {d:?}", back[i]);
|
||||
}
|
||||
// The other direction, from arbitrary states.
|
||||
let again = x.integrate(&x.differentiate(&deltas));
|
||||
for (i, d) in deltas.iter().enumerate() {
|
||||
prop_assert!(x.same(&again[i], d), "[stream] integrate(differentiate(s))[{i}] = {:?}, not {d:?}", again[i]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
macro_rules! property {
|
||||
($name:ident, $catches:ident, $strategy:expr, $check:ident, $tag:literal, $($rival:ident => $test:ident),+) => {
|
||||
proptest! {
|
||||
#[test]
|
||||
fn $name(input in $strategy) {
|
||||
$check(&Real, &input)?;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn $catches() {
|
||||
// The same checker run on a second, correct implementation passes.
|
||||
ok($strategy, |v| $check(&Model(Fault::None), v));
|
||||
}
|
||||
|
||||
$(
|
||||
#[test]
|
||||
fn $test() {
|
||||
caught($strategy, concat!("[", $tag, "]"), |v| $check(&Model(Fault::$rival), v));
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
property!(
|
||||
addition_is_associative,
|
||||
associativity_holds_on_the_model,
|
||||
(pairs(), pairs(), pairs()),
|
||||
associativity,
|
||||
"associativity",
|
||||
AddClamps => associativity_catches_an_add_that_clamps_weights
|
||||
);
|
||||
|
||||
property!(
|
||||
addition_is_commutative,
|
||||
commutativity_holds_on_the_model,
|
||||
(pairs(), pairs()),
|
||||
commutativity,
|
||||
"commutativity",
|
||||
AddKeepsLeft => commutativity_catches_an_add_that_keeps_the_left_weight
|
||||
);
|
||||
|
||||
property!(
|
||||
zero_is_the_identity,
|
||||
identity_holds_on_the_model,
|
||||
pairs(),
|
||||
identity,
|
||||
"identity",
|
||||
AddIntersects => identity_catches_an_add_that_keeps_only_shared_keys
|
||||
);
|
||||
|
||||
property!(
|
||||
every_set_has_an_inverse,
|
||||
inverse_holds_on_the_model,
|
||||
pairs(),
|
||||
inverse,
|
||||
"inverse",
|
||||
KeepZero => inverse_catches_an_add_that_keeps_zero_weights,
|
||||
NegForgetsAKey => inverse_catches_a_neg_that_forgets_a_key
|
||||
);
|
||||
|
||||
property!(
|
||||
subtraction_is_adding_the_negation,
|
||||
subtraction_holds_on_the_model,
|
||||
(pairs(), pairs()),
|
||||
subtraction,
|
||||
"subtraction",
|
||||
SubFlipped => subtraction_catches_a_flipped_sub
|
||||
);
|
||||
|
||||
property!(
|
||||
no_zero_weight_is_ever_stored,
|
||||
canonical_zero_holds_on_the_model,
|
||||
(pairs(), pairs(), scalar(), 1u8..5),
|
||||
canonical_zero,
|
||||
"canonical-zero",
|
||||
KeepZero => canonical_zero_catches_an_add_that_keeps_zero_weights
|
||||
);
|
||||
|
||||
property!(
|
||||
equal_sets_compare_equal,
|
||||
canonical_equality_holds_on_the_model,
|
||||
(pairs(), any::<usize>()),
|
||||
canonical_equality,
|
||||
"canonical-equality",
|
||||
KeepZero => canonical_equality_catches_an_add_that_keeps_zero_weights
|
||||
);
|
||||
|
||||
property!(
|
||||
scalar_multiplication_distributes,
|
||||
scale_holds_on_the_model,
|
||||
(pairs(), pairs(), scalar(), scalar()),
|
||||
scalar_multiplication,
|
||||
"scale",
|
||||
ScaleAbs => scale_catches_a_scale_that_takes_the_absolute_value,
|
||||
KeepZero => scale_catches_a_scale_by_zero_that_keeps_zero_weights
|
||||
);
|
||||
|
||||
property!(
|
||||
map_is_a_homomorphism,
|
||||
map_holds_on_the_model,
|
||||
(pairs(), pairs(), 1u8..5),
|
||||
map_homomorphism,
|
||||
"map",
|
||||
MapOverwrites => map_catches_a_map_that_overwrites_colliding_keys
|
||||
);
|
||||
|
||||
property!(
|
||||
filter_is_linear,
|
||||
filter_holds_on_the_model,
|
||||
(pairs(), pairs(), 0u8..3),
|
||||
filter_linearity,
|
||||
"filter",
|
||||
FilterReadsWeight => filter_catches_a_filter_that_reads_the_weight
|
||||
);
|
||||
|
||||
property!(
|
||||
distinct_clamps_positive_weights_to_one,
|
||||
distinct_holds_on_the_model,
|
||||
pairs(),
|
||||
distinct_semantics,
|
||||
"distinct",
|
||||
DistinctNoClamp => distinct_catches_a_distinct_without_clamping
|
||||
);
|
||||
|
||||
property!(
|
||||
iteration_is_ascending_and_independent_of_insertion_order,
|
||||
order_holds_on_the_model,
|
||||
(pairs(), any::<usize>()),
|
||||
iteration_order,
|
||||
"order",
|
||||
InsertionOrder => order_catches_insertion_order_iteration
|
||||
);
|
||||
|
||||
property!(
|
||||
held_plus_target_minus_held_is_target,
|
||||
correction_holds_on_the_model,
|
||||
(pairs(), pairs()),
|
||||
correction,
|
||||
"correction",
|
||||
SubFlipped => correction_catches_a_flipped_sub
|
||||
);
|
||||
|
||||
property!(
|
||||
integrate_and_differentiate_invert_each_other,
|
||||
stream_holds_on_the_model,
|
||||
stream(),
|
||||
streams,
|
||||
"stream",
|
||||
IntegrateExcludesCurrent => stream_catches_an_integrate_that_excludes_the_current_delta,
|
||||
DifferentiateDropsFirst => stream_catches_a_differentiate_that_drops_the_first_state
|
||||
);
|
||||
Reference in New Issue
Block a user