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:
Claude (adr009-zset)
2026-10-09 22:57:39 -07:00
committed by Edward Langley
parent 5f8c3f8c82
commit e15b285822
5 changed files with 967 additions and 1 deletions
Generated
+7
View File
@@ -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
View File
@@ -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"
+14
View File
@@ -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"
+351
View File
@@ -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());
}
}
+594
View File
@@ -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(&nothing, &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
);