Merge pull request #341 from fiddlerwoaroof/adr009/zset

feat(zset): ADR 009 step 1, the Z-set crate (improvise-tk51)
This commit is contained in:
Ed Langley
2026-10-10 12:43:54 -07:00
committed by GitHub
10 changed files with 1564 additions and 2 deletions
Generated
+7
View File
@@ -2761,3 +2761,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"
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR 009 — Incremental evaluation: an in-house DBSP core, plugged in behind a trait
**Status:** proposed. ·
**Status:** accepted. ·
**Ticket:** improvise-felo.4 (the record of this ADR; implementation goes to its own
epic) ·
**Relates to:** ADR 001 (the basis a write claims), ADR 002 (surrogates on the wire),
+110
View File
@@ -0,0 +1,110 @@
# `zset`: the reasoning
`crates/zset` is ADR 009 step 1 (improvise-tk51, epic improvise-bw4p): the Z-set group, first
and alone. Nothing depends on it yet, so it changes no behaviour. The decisions are the
ADR's (`context/adr/009-incremental-evaluation.md`, clause 1). This file records how the
crate reads them, where the ADR left a choice open, and the evidence discipline. The
evidence for each test is in `context/test-evidence/zset.md`.
## The shape
| item | what it is |
|---|---|
| `ZSet<K: Ord>` | a map from `K` to a non-zero `i64` weight; an abelian group within `i64` (interpretation 5) |
| `+`, `-`, unary `-`, `* i64`, `scale` | the group operations and scalar multiplication |
| `new`, `singleton`, `insert`, `FromIterator`, `Extend` | construction; weights of equal keys sum |
| `weight`, `is_zero`, `len`, `iter`, `IntoIterator` | reads; iteration is ascending by key |
| `map`, `filter`, `distinct` | the three per-element operators |
| `integrate`, `differentiate` | the stream operators, over a slice |
There are no dependencies: not serde (clause 9 keeps the wire form in `improvise-protocol`),
not even the workspace's own crates. The crate is wasm32-clean by construction and is in
`nix/ci.nix`'s `wasmCrates`.
## Canonical form
A key with weight zero is never stored. `insert` removes a key whose sum reaches zero, and
`singleton(k, 0)` is the zero Z-set. Two Z-sets are therefore `==` exactly when every key has
the same weight, so the derived `PartialEq`, `Hash` and `Ord` are the group's equality. The
clause 9 correction `target - held` relies on it: a correct state gives `==` the zero Z-set,
and `is_zero` is `len() == 0`, not a scan.
The representation is a `BTreeMap`. Iteration order is the key order, the same in every
process and for every insertion order, which clause 3 needs (eager folds in `CellKey` order)
and which a `HashMap` would give up (`context/code/core.md`, gotcha 18's argument).
## Interpretations
The ADR is one paragraph on the crate, and says more about what it is for than what it is.
These are the readings this step takes.
1. **Clause 1 lists a `Circuit` and more operators; this step builds the pure part.** The
ADR's list is `delay`, `integrate`, `differentiate`, `map`, `filter`, `flat_map`,
`index_by`, `join_indexed`, `distinct` and `aggregate`, run by a synchronous `Circuit`,
plus resumable rebuilds and a trace interface. Step 1 as ticketed is the group and the
operators that are functions of a Z-set or of a stream of them: `map`, `filter`,
`distinct`, `integrate`, `differentiate`. The rest needs the circuit (a clock, state held
between steps), or a second key type (`index_by`, `join_indexed`, `aggregate`), and is
left to the steps that use it rather than designed without a caller. `flat_map` is
`map` into several images and has no user yet either.
2. **`integrate` and `differentiate` are slice functions, not circuit nodes.**
`integrate(&[d_0, d_1, ..])` is the running sums `d_0 + .. + d_i`; `differentiate` is
its inverse, `s_0` then `s_i - s_(i-1)`. The state before the first element is zero, as in
DBSP. `delay` is not provided: over a slice it is a shift, and a circuit gives it meaning.
3. **`distinct` is not linear, and is not a group homomorphism.** It keeps weight 1 on the
keys whose weight is positive and drops every other key, negative included. That is the
set-semantics operator of DBSP. The ADR says nothing about negative weights; dropping
them is the standard reading, and the one that makes `distinct` idempotent.
4. **Operators are methods on `&self`, group operations are `std::ops` traits.** `K: Clone`
is required only where a result copies keys (`filter`, `distinct`, `scale`, the borrowed
operators); `map` and the by-value operators need only `Ord`.
5. **Overflow.** Neither clause 1 nor clause 8 states a policy for weights (clause 8 is about
`f64` drift, which wrapping integers do not have). The crate currently uses `checked_*`
on every weight operation, for the reason given below, and **panics** with `zset: weight overflow in <op>`, in debug
and in release (`checked_*` does not depend on `overflow-checks`). The operations are
`add` (including `insert`, `Extend`, `FromIterator`), `sub`, `neg` (so `i64::MIN` panics
when it has to be negated) and `scale`. Scaling by zero cannot overflow and returns the
zero Z-set. A panic, not a `Result`: `Result` does not belong in the majority of the code
(`AGENTS.md`), and a weight near `i64::MAX` is a defect in the caller, not an input to
recover from. The weights clause 3 produces are small (one per cell).
**The group is partial, and that is the stated reading.** The ADR calls a Z-set an abelian
group. With `i64` weights and no wrapping, the laws hold wherever no weight, intermediate
ones included, leaves `i64`; outside that, an operation panics rather than return a
different value. So associativity is order-dependent at the boundary:
`(MAX + 1) + (-1)` panics where `MAX + (1 + (-1))` is `MAX`. The result is never wrong,
only sometimes refused. Wrapping arithmetic would make the type a true group (the integers mod 2^64), and
`(w + x) - x == w` would still hold exactly, so a retraction does cancel its insertion
under wrapping. (An earlier version of this paragraph said otherwise; that was wrong.) What
wrapping breaks is the intermediate reads. `MAX + 1` stores `i64::MIN`, so `distinct`
drops a key whose true weight is positive and `weight()` has the wrong sign. A true weight
of 2^64 stores as 0 (by the add path; a `scale` product of 2^64 would store a zero too, which
breaks canonical form), so the key vanishes and `is_zero`, `len` and `==` report a set that
does not exist. Those are wrong answers returned silently, which is why the crate panics
instead. A wider weight (`i128`) moves the boundary and does not remove it. The ADR's
"abelian group" wording needs amending whichever policy is chosen: checked-and-panic gives
a group only within range, wrapping gives a group whose reads can be wrong, and `i128` is
partial too. Which one is a ruling for the user, not for this step; the crate currently
panics, and
`overflow_depends_on_the_order_of_evaluation_and_never_gives_a_wrong_value` states it.
`sub` is checked directly against the held weight, so `a - a` is zero for every `a`, `i64::MIN`
included, and only an absent key's `0 - w` can overflow (`w` is `i64::MIN`).
6. **Stable error text.** The panic message names the operation and both operands, so a
failure in a long-running host is diagnosable from the log.
## What the tests are
`crates/zset/tests/properties.rs` holds one checker per property, parameterised by an `Alg`
trait. The property's test runs it on `ZSet`. A companion runs it on `Model(Fault::None)`, a
second implementation over a sorted vector, so the checker is shown not to reject a correct
implementation that is not `ZSet`. Each rival is `Model(Fault::X)`, run through the same
checker with a deterministic seed, and the failure message must carry the property's tag
(`[inverse]`, `[map]`, ..). A rival that fails for another reason does not count. The group
laws, canonical form, scalar distributivity, the `map` homomorphism, `filter` linearity,
`distinct`'s definition, iteration order, the clause 9 correction identity and the
`integrate`/`differentiate` inverses are all properties; each runs at the default 256 cases
over keys from a range of six, so collisions and cancelling pairs are the common case.
Weights come from `-3..=3`, an explicit zero, and `±2^40`, so sums of three cannot wrap.
The overflow policy is not a property over the generators (they cannot reach it by design)
and is tested by example in `src/lib.rs`, with `should_panic(expected = ..)`.
+280
View File
@@ -0,0 +1,280 @@
# Test evidence: `zset`
What each test guards, which rival it was run against, and where that rival failed. The
entries are keyed by exact test name, and a rename orphans its entry, which
`scripts/check-comment-runs.py --check-evidence` catches. The reasoning these tests defend
is in `context/code/zset.md`. The decisions are ADR 009 clause 1's.
**How the rivals are kept.** Every property in `crates/zset/tests/properties.rs` is a
checker that takes the implementation as a parameter (the `Alg` trait). The property's own
test runs it on `ZSet`. A `<property>_holds_on_the_model` test runs it on `Model(Fault::None)`,
a second, correct implementation over a sorted vector, so a checker that rejects every
implementation but `ZSet` is seen. A `<property>_catches_<rival>` test runs the same checker
on a `Model` with one injected fault, with a deterministic seed, and asserts two things:
- the run fails;
- the failure message carries **the property's own tag**, so a rival that fails in setup,
panics, or fails some other property does not count.
So each rival is watched failing on every run of the suite, not once by hand.
**Injected in the source.** Eight faults were also put into `crates/zset/src/lib.rs` one at
a time, each run against the whole suite, each reverted and followed by a full rerun. The
failing tests, by name:
- `insert` stores a zero weight: `addition_is_associative`, `equal_sets_compare_equal`,
`held_plus_target_minus_held_is_target`, `integrate_and_differentiate_invert_each_other`,
`iteration_is_ascending_and_independent_of_insertion_order`, `map_is_a_homomorphism`,
`no_zero_weight_is_ever_stored`, `scalar_multiplication_distributes`;
- an `insert` that stores the sum without removing a zero one also fails
`addition_is_commutative`, `every_set_has_an_inverse` and `zero_is_the_identity`;
- `neg` skips its first key: `every_set_has_an_inverse`, `filter_is_linear`,
`map_is_a_homomorphism`, `scalar_multiplication_distributes`,
`subtraction_is_adding_the_negation`;
- `map` overwrites: `map_is_a_homomorphism`;
- `distinct` does not clamp: `distinct_clamps_positive_weights_to_one`;
- `filter` also reads the weight: `filter_is_linear`;
- `sub` flipped: `held_plus_target_minus_held_is_target`,
`integrate_and_differentiate_invert_each_other`, `subtraction_is_adding_the_negation`;
- `integrate` excludes the current delta: `integrate_and_differentiate_invert_each_other`.
The unit tests in `src/lib.rs` (the zero-weight, `map`, `distinct` and differentiate examples
and the five overflow tests) also failed under the faults they cover. They are examples, so
they carry no entry of their own, except the three at the end, which answer review findings.
**Overflow is tested by example**, not by property: the generators cannot reach it (sums of
three weights stay below 2^43). The `should_panic` tests assert the message names the
operation, so a panic from somewhere else does not pass them.
## `crates/zset/tests/properties.rs`
### addition_is_associative
(a + b) + c = a + (b + c) over three independently generated sets. Tag: `[associativity]`.
### associativity_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### associativity_catches_an_add_that_clamps_weights
`add` clamps every result weight to +-10. A clamp is a saturating add, which is not associative: `10 + 10 + (-10)` is 0 one way and 10 the other. The weights near +-3 and +-2^40 reach it at once.
### addition_is_commutative
a + b = b + a. Tag: `[commutativity]`.
### commutativity_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### commutativity_catches_an_add_that_keeps_the_left_weight
`add` is a left-biased union: a key present on both sides keeps the left weight. It is associative, so it escaped `associativity` when first tried there (the run reported the rival not caught, which is the failure the `_catches_` shape exists to make loud), and the rival moved. It is caught here.
### zero_is_the_identity
a + 0 = a and 0 + a = a, with zero built by `build(&[])`, the empty iterator, not by a constructor the rival could share. Tag: `[identity]`.
### identity_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### identity_catches_an_add_that_keeps_only_shared_keys
`add` keeps only keys present on both sides. Then `a + 0` is empty, not `a`. The first rival tried here, an add that keeps zero weights, passes `identity` (a canonical `a` has none to keep), so it was replaced; keeping zeros is caught by `every_set_has_an_inverse` and `no_zero_weight_is_ever_stored`.
### every_set_has_an_inverse
a + (-a) = 0, a - a = 0 and --a = a. The sets include cancelling pairs and colliding keys. Tag: `[inverse]`.
### inverse_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### inverse_catches_an_add_that_keeps_zero_weights
`add` and construction leave a key with weight 0 in the map. `a + (-a)` is then a map of zeros, not the empty one, and the equality says so.
### inverse_catches_a_neg_that_forgets_a_key
`neg` skips the first key. `a + (-a)` keeps that key.
### subtraction_is_adding_the_negation
a - b = a + (-b). Tag: `[subtraction]`.
### subtraction_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### subtraction_catches_a_flipped_sub
`sub(a, b)` computes `b - a`.
### no_zero_weight_is_ever_stored
After construction, `+`, `-`, unary `-`, `scale` (including by zero), `map`, `filter` and `distinct`, no stored weight is zero. It reads what the iterator yields, so a rival that hides a zero from the equality is still seen. Tag: `[canonical-zero]`.
### canonical_zero_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### canonical_zero_catches_an_add_that_keeps_zero_weights
`add` and construction keep zero weights.
### equal_sets_compare_equal
The same pairs in another order, and with a cancelling pair spliced in, build equal sets. Tag: `[canonical-equality]`.
### canonical_equality_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### canonical_equality_catches_an_add_that_keeps_zero_weights
The spliced pair `(9, 5), (9, -5)` leaves a stored `(9, 0)`, so the set is no longer equal to the one without it.
### scalar_multiplication_distributes
(a + b) * n = a*n + b*n, a*(m + n) = a*m + a*n, a * -1 = -a, a * 1 = a, a * 0 = 0. Tag: `[scale]`.
### scale_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### scale_catches_a_scale_that_takes_the_absolute_value
`scale` multiplies by `|n|`. `a*(m+n)` differs from `a*m + a*n` as soon as `m` and `n` have different signs, and `a * -1` is `a`.
### scale_catches_a_scale_by_zero_that_keeps_zero_weights
`scale(a, 0)` keeps every key at weight 0.
### map_is_a_homomorphism
map(a + b) = map(a) + map(b) and map(-a) = -map(a), with images `k % m`, so keys collide (m from 1 to 4). Tag: `[map]`.
### map_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### map_catches_a_map_that_overwrites_colliding_keys
`map` inserts each image with `=`, so the last of several colliding keys wins instead of the weights summing.
### filter_is_linear
filter(a + b) = filter(a) + filter(b) and filter(-a) = -filter(a). The predicate reads the key only. Tag: `[filter]`.
### filter_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### filter_catches_a_filter_that_reads_the_weight
`filter` also requires a positive weight. That is not linear: a negated set loses everything, and a key whose weights cancel in `a + b` is judged on the sum.
### distinct_clamps_positive_weights_to_one
`distinct(a)` is exactly the keys with a positive weight, each at weight 1, in the same order; and it is idempotent. Tag: `[distinct]`.
### distinct_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### distinct_catches_a_distinct_without_clamping
`distinct` keeps the positive keys at their own weight.
### iteration_is_ascending_and_independent_of_insertion_order
Iteration is strictly ascending by key, and the same pairs inserted in another order iterate identically. Tag: `[order]`.
### order_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### order_catches_insertion_order_iteration
The rival stores keys in first-seen order. Its equality compares sorted copies, so every group law passes on it, and only this property sees the order.
### held_plus_target_minus_held_is_target
ADR 009 clause 9: `held + (target - held) = target`, and correcting a state that is already correct is the zero Z-set. Tag: `[correction]`.
### correction_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### correction_catches_a_flipped_sub
`sub(a, b)` computes `b - a`, so the correction applied to `held` gives `2*held - target`.
### integrate_and_differentiate_invert_each_other
`integrate` gives the running sums, `differentiate(integrate(d)) = d` and `integrate(differentiate(s)) = s`, element by element, including the empty stream. Tag: `[stream]`.
### stream_holds_on_the_model
The same checker on the correct second implementation. It passes; it exists to show the checker is not specific to `ZSet`.
### stream_catches_an_integrate_that_excludes_the_current_delta
Element `i` is the sum of the deltas before `i`, so the stream is shifted by one.
### stream_catches_a_differentiate_that_drops_the_first_state
Element 0 is zero instead of `s_0`.
## `crates/zset/src/lib.rs`
### tests::subtracting_a_set_from_itself_at_i64_min_is_zero
Found by Copilot on PR #341. `SubAssign` negated every right-hand weight before consulting
the left, so `a - a` panicked for a weight of `i64::MIN`, as did `differentiate` over two
equal states. **Reproduced before the fix**: the test failed at the `sub` call with
`weight overflow in neg`. The fix subtracts a held weight with `checked_sub` and negates only
the weight of an absent key. The test also checks the borrowed operator and `differentiate`.
### tests::subtracting_i64_min_from_nothing_panics
The control for the one above: `0 - i64::MIN` has no `i64` value, so it must still panic. A
fix that made `a - a` pass by swallowing the overflow would fail here.
### tests::overflow_depends_on_the_order_of_evaluation_and_never_gives_a_wrong_value
Found by ChatGPT Codex on PR #341: checked arithmetic makes the group partial, so
`(MAX + 1) + (-1)` panics where `MAX + (1 + (-1))` is `MAX`. The behaviour is the intended
policy, not a defect, and the test states it: one order gives the exact value, the other
panics, and neither gives a different number. The argument is in `context/code/zset.md`,
interpretation 5. A wrapping implementation would fail the `catch_unwind` half.
### tests::subtracting_past_i64_max_panics
Found by the Opus review of PR #341: `checked_sub` in `minus` could be replaced with
`wrapping_sub` and the suite stayed green, because no example reached `sub`'s overflow
branch. **Injected**: that replacement. These two (`..._max_` and `tests::subtracting_past_i64_min_panics`)
and `near_the_limit_an_operation_is_exact_or_it_panics` failed; the rest passed. Reverted, and
the suite rerun.
### tests::subtracting_past_i64_min_panics
The negative-side twin of the one above; it fails under the same injection.
### tests::scaling_i64_min_by_minus_one_panics
`i64::MIN * -1` is the one product of a small scalar that no generator reaches. **Injected**:
`checked_mul` replaced by `wrapping_mul`. This test, `tests::scaling_past_i64_panics` and
`near_the_limit_an_operation_is_exact_or_it_panics` failed.
## `crates/zset/tests/properties.rs`: the boundary
### near_the_limit_an_operation_is_exact_or_it_panics
The other properties keep weights at +-2^40 so the laws can be tested without overflow, which
left the boundary to examples. This one draws weights from `MAX-3..=MAX`, `MIN..=MIN+3` and
`-3..=3`, and compares `+`, `-`, `scale` and unary `-` on a single key with an `i128` oracle:
the result must equal the exact value when it fits `i64`, and the operation must panic when it
does not. **Injected**, one at a time, `wrapping_add`, `wrapping_sub`, `wrapping_mul` and
`wrapping_neg` in place of the checked calls: this test failed under every one, and the
example tests named above failed under the matching one.
+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"
+406
View File
@@ -0,0 +1,406 @@
//! Z-sets: maps from keys to non-zero `i64` weights (ADR 009 clause 1). An abelian group
//! wherever no weight leaves `i64`; past that it panics. See `context/code/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. A zero weight is never stored, so `==`
/// is the group's equality; 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 minus(a: i64, b: i64) -> i64 {
a.checked_sub(b).unwrap_or_else(|| overflow("sub", 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()
}
pub fn is_empty(&self) -> bool {
self.is_zero()
}
/// 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, dropping the key at zero. Panics on `i64` overflow.
pub fn insert(&mut self, key: K, weight: i64) {
self.adjust(key, weight, false);
}
fn adjust(&mut self, key: K, weight: i64, subtract: bool) {
match self.weights.entry(key) {
Entry::Vacant(e) => {
let from_zero = if subtract { negated(weight) } else { weight };
if from_zero != 0 {
e.insert(from_zero);
}
}
Entry::Occupied(mut e) => {
let held = *e.get();
let sum = if subtract {
minus(held, weight)
} else {
plus(held, 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 on `i64` overflow.
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) {
for (k, w) in other {
self.adjust(k, w, true);
}
}
}
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 subtracting_a_set_from_itself_at_i64_min_is_zero() {
let a = ZSet::singleton(1, i64::MIN);
assert!(a.clone().sub(a.clone()).is_zero());
assert!((&a - &a).is_zero());
assert!(differentiate(&[a.clone(), a]).pop().unwrap().is_zero());
}
#[test]
#[should_panic(expected = "weight overflow in neg")]
fn subtracting_i64_min_from_nothing_panics() {
let _ = ZSet::<u8>::new() - ZSet::singleton(1, i64::MIN);
}
#[test]
fn overflow_depends_on_the_order_of_evaluation_and_never_gives_a_wrong_value() {
let (max, one, minus) = (
ZSet::singleton(1, i64::MAX),
ZSet::singleton(1, 1),
ZSet::singleton(1, -1),
);
let right = max.clone() + (one.clone() + minus.clone());
assert_eq!(right, max);
let left = std::panic::catch_unwind(|| (max.clone() + one.clone()) + minus.clone());
assert!(left.is_err());
}
#[test]
#[should_panic(expected = "weight overflow in sub")]
fn subtracting_past_i64_max_panics() {
let _ = ZSet::singleton(1, i64::MAX) - ZSet::singleton(1, -1);
}
#[test]
#[should_panic(expected = "weight overflow in sub")]
fn subtracting_past_i64_min_panics() {
let _ = ZSet::singleton(1, i64::MIN) - ZSet::singleton(1, 1);
}
#[test]
#[should_panic(expected = "weight overflow in scale")]
fn scaling_i64_min_by_minus_one_panics() {
let _ = ZSet::singleton(1, i64::MIN).scale(-1);
}
#[test]
fn scaling_by_zero_never_overflows() {
assert!(ZSet::singleton(1, i64::MAX).scale(0).is_zero());
}
}
+741
View File
@@ -0,0 +1,741 @@
//! Property checkers over an `Alg`; each runs on `ZSet`, and on rivals (`_catches_`).
//! Evidence: `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 build(&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.build(&[])
}
}
struct Real;
impl Alg for Real {
type Z = ZSet<u8>;
fn build(&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 build(&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.build(a), x.build(b), x.build(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.build(a), x.build(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.build(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.build(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.build(a), x.build(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.build(a), x.build(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.build(p), x.build(&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.build(&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.build(a), x.build(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.build(a), x.build(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.build(a), x.build(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.build(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.build(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.build(&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.build(held), x.build(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: &[Pairs]) -> Result<(), TestCaseError> {
let deltas: Vec<_> = s.iter().map(|p| x.build(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
);
fn near_limit() -> impl Strategy<Value = i64> {
prop_oneof![
(0i64..4).prop_map(|d| i64::MAX - d),
(0i64..4).prop_map(|d| i64::MIN + d),
-3i64..=3,
]
}
fn probe(f: &dyn Fn() -> ZSet<u8>, wide: i128, what: &str) -> Result<(), TestCaseError> {
let got = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
match (i64::try_from(wide).ok(), got) {
(Some(w), Ok(z)) => prop_assert_eq!(z.weight(&1), w, "{} gave the wrong weight", what),
(None, Err(_)) => {}
(Some(_), Err(_)) => prop_assert!(false, "{} panicked within range", what),
(None, Ok(z)) => prop_assert!(false, "{} returned {:?} past i64", what, z),
}
Ok(())
}
proptest! {
#[test]
fn near_the_limit_an_operation_is_exact_or_it_panics(
(a, b, n) in (near_limit(), near_limit(), -3i64..=3)
) {
let (x, y) = (ZSet::singleton(1u8, a), ZSet::singleton(1u8, b));
probe(&|| &x + &y, i128::from(a) + i128::from(b), "add")?;
probe(&|| &x - &y, i128::from(a) - i128::from(b), "sub")?;
probe(&|| x.scale(n), i128::from(a) * i128::from(n), "scale")?;
probe(&|| -&x, -i128::from(a), "neg")?;
}
}
+2
View File
@@ -48,6 +48,8 @@
"improvise-gp"
# ADR 005 clause 14: nothing depends on it yet, and improvise-core will.
"numtower"
# ADR 009 clause 1: std-only; nothing depends on it yet.
"zset"
];
pFlags = crates: lib.concatMapStringsSep " " (c: "-p ${c}") crates;
+2
View File
@@ -68,6 +68,7 @@ const SECTIONS = [
["context/code/cli.md", "code/cli", "The headless CLI"],
["context/code/gp.md", "code/gp", "gp-tree and improvise-gp"],
["context/code/numtower.md", "code/numtower", "numtower, the numeric tower"],
["context/code/zset.md", "code/zset", "zset, the Z-set group"],
["context/code/kb-tool.md", "code/kb-tool", "The kb tool"],
["context/code/tooling.md", "code/tooling", "The repository's own checkers"],
],
@@ -87,6 +88,7 @@ const SECTIONS = [
["context/test-evidence/cli.md", "test-evidence/cli", "The headless CLI"],
["context/test-evidence/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"],
["context/test-evidence/numtower.md", "test-evidence/numtower", "numtower"],
["context/test-evidence/zset.md", "test-evidence/zset", "zset"],
["context/test-evidence/tooling.md", "test-evidence/tooling", "The repository's own gates"],
["context/test-evidence/layout-cost.md", "test-evidence/layout-cost", "Layout and keypress cost"],
["context/test-evidence/scale-bisect.md", "test-evidence/scale-bisect", "Scale bisect"],