Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
21 KiB
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:
insertstores 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
insertthat stores the sum without removing a zero one also failsaddition_is_commutative,every_set_has_an_inverseandzero_is_the_identity; negskips its first key:every_set_has_an_inverse,filter_is_linear,map_is_a_homomorphism,scalar_multiplication_distributes,subtraction_is_adding_the_negation;mapoverwrites:map_is_a_homomorphism;distinctdoes not clamp:distinct_clamps_positive_weights_to_one;filteralso reads the weight:filter_is_linear;subflipped:held_plus_target_minus_held_is_target,integrate_and_differentiate_invert_each_other,subtraction_is_adding_the_negation;integrateexcludes 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 = an + bn, a*(m + n) = am + an, 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.
crates/zset/tests/circuit.rs: the circuit
Each operator is checked against recomputing from scratch: a random stream of deltas goes in,
one circuit step each, and after every step the sum of the outputs so far must equal the
operator applied to the sum of the inputs so far. The failure message carries [incremental],
[join], [rebuild] and so on. Keys come from a range of six (four by three for the keyed
ones) and weights from -3..=3, so cancelling deltas and re-insertions are the common case.
Injected in the source, one at a time, each reverted and the suite rerun. The failing
tests, by name (a build that printed Running was confirmed each time; the first attempt at
the delay fault did not compile and was redone):
DistinctStatedoes not update its held set:distinct_matches_recompute,a_composed_pipeline_matches_recompute,a_resumable_rebuild_equals_an_uninterrupted_step;- the join applies
dato the left trace after reading it fordb(drops theda x dbterm):join_matches_recompute,join_is_bilinear,the_join_reads_state_through_the_trace_it_was_given; - the join drops
da x b_prev:join_matches_recompute,join_is_bilinear; aggregatedoes not retract the old result:aggregate_sum_matches_recompute,aggregate_max_matches_recompute,aggregate_state_refolds_only_the_touched_keys;delaypasses the current input:delay_is_the_previous_step, and both identities;integrateforgets history (acc = d):integrate_after_differentiate_is_the_identity,differentiate_after_integrate_is_the_identity;Rebuildstops after the first chunk:a_rebuild_that_is_out_of_time_reports_pending_after_one_chunk,a_resumable_rebuild_equals_an_uninterrupted_step;Rebuildchecks the deadline before running any chunk (no progress is possible): the same two;MemTrace::rangeignores the upper bound, andMemTracekeeps an emptied key:a_trace_reads_what_was_applied;flat_mapgives every image weight 1:flat_map_matches_recompute.
map_matches_recompute
map, filter, index_by and the pipeline test below are linear or compositions of
linear and distinct; they pin the circuit's wiring (the input node, the output sink,
creation-order scheduling) as much as the operators, which are ZSet methods tested in
properties.rs. No fault in them separates from the wiring faults above, and none was
needed.
join_is_bilinear
Three tags. [bilinear-left] and [bilinear-right] check the reference join is bilinear, so
the oracle itself is a legitimate one; [bilinear-circuit] checks the incremental join: one
step of a1 + a2 and two steps a1 then a2 (same b) sum to the same output. A join that
drops the da x db term fails the first-step, one-delta case, which is why the cross term is
exercised at all.
a_resumable_rebuild_equals_an_uninterrupted_step
The snapshot enters a pipeline (map then distinct) in chunks of 1 to 3, and the deadline
predicate expires after 1 to 3 chunks per call, so the job is interrupted at varied points.
The sum of the outputs must equal one uninterrupted step over the whole snapshot. The loop is
bounded at 100 calls, so a job that never finishes is the named failure [rebuild], not a hang.
a_rebuild_that_is_out_of_time_reports_pending_after_one_chunk
A predicate that is always expired still lets exactly one chunk through (two of six keys),
then Pending; the rest completes on the next call; an exhausted job answers Done even
when out of time. This is the progress guarantee the property cannot see, because its budget
is never zero.
aggregate_state_refolds_only_the_touched_keys
Fifty groups are loaded, then one delta touches one key: f is called twice (before and
after), not once per group. It is the O(delta) claim, in the one place a count can show it.
an_unbounded_range_that_is_empty_reads_nothing
range(Excluded(1), Included(1)) and an inverted range are empty. BTreeMap::range panics
on those; the trace guards it first. Not from the property, whose lo > hi draws are
covered by the same guard, but the equal-bounds corner is rare there.
the_join_reads_state_through_the_trace_it_was_given
A counting Trace handed to join_indexed_in is looked up, so the join's state really is
behind the trace interface and not a private map beside it.
crates/zset/tests/circuit.rs: review of #345
The Opus review of PR #345 at ba17f92b found four defects; each test below failed before its fix.
Injected afterwards, one at a time: Rebuild calling step for every chunk fails
a_chunked_rebuild_is_one_logical_step_for_delay_integrate_and_differentiate and
a_chunked_temporal_rebuild_equals_one_step; a continued integrate re-emitting its integral
fails the same two; a continued delay emitting what it holds fails the first; folding empty
groups (before or after) fails the two aggregate tests below; asking the deadline before
checking the job is done fails the_last_chunk_is_done_even_when_the_deadline_has_expired.
a_chunked_rebuild_is_one_logical_step_for_delay_integrate_and_differentiate
Snapshot {1, 2} after one prior tick {9}, loaded in chunks of one with a deadline always
expired, against chunks of five. The three sinks (delay, integrate, differentiate) must sum
equal. Before the fix delay gave {1:1} where {9:1} was due, integrate {1:2, 2:1}.
a_chunked_temporal_rebuild_equals_one_step
The same property over random prior ticks, snapshots, chunk sizes and budgets, through a
pipeline that combines all three temporal operators and ends in distinct.
an_empty_snapshot_is_still_one_tick
An empty snapshot ticks the clock once (a delay shows the prior input), and a finished job
does not tick again.
the_last_chunk_is_done_even_when_the_deadline_has_expired
One chunk exhausts the snapshot with an always-expired deadline: Done, not Pending.
a_group_that_empties_leaves_no_phantom_row_for_a_count
A count closure, insert then delete then insert another: before the fix the delete step gave
phantom (7, 0) rows for the empty group, e.g. {(7,0):-1, (7,1):1} where {(7,1):1} was due.
an_aggregate_that_returns_some_on_empty_input_matches_recompute
The same closure through the circuit against recompute-from-scratch. The earlier aggregate
properties used closures that return None on empty input, which could not see the defect.
aggregate_state_refolds_only_the_touched_keys now asserts both steps' fold counts and outputs.
crates/zset/tests/rebuild.rs: delta review of #345 (0849981a)
Injected, one at a time: a continued delay that replaces what it holds fails
every_operator_chunked_equals_one_step_and_so_do_the_ticks_after; a continued integrate
that does not accumulate fails that and a_chunked_temporal_rebuild_equals_one_step (which
now pushes another delta after Done and steps twice, so the state left for later ticks is
compared); a current() that reads the last piece fails
current_after_a_chunked_rebuild_is_the_whole_ticks_value; an undetected split tick, or a
step that does not count ticks, fails both should-panic tests below. Before these, the
first two faults passed the whole suite.
every_operator_chunked_equals_one_step_and_so_do_the_ticks_after
One pipeline with every public operator (delay, integrate, differentiate, index_by, join,
aggregate, distinct, flat_map), a prior tick, a chunked load, then two more ticks. The
load tick [rebuild-tick], the next tick [next-tick] and the one after [tick-after] must
equal the single-step run. From the reviewer's property.
current_after_a_chunked_rebuild_is_the_whole_ticks_value
integrate.current() after a load in chunks of one equals the one-chunk load.
a_host_step_between_rebuild_calls_is_refused_loudly
A Circuit::step between two Rebuild::step calls makes the second panic.
two_interleaved_rebuilds_are_refused_loudly
A second job's first chunk is a tick, so the first job's next call panics.
a_finished_rebuild_and_an_interrupted_check_do_not_trip_on_later_ticks
A finished job is never interrupted, and steps afterwards do not make it panic.