# Conflicts: # context/test-evidence/zset.md
28 KiB
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.
-
Clause 1 lists a
Circuitand more operators; step 1 built the pure part, and "The circuit" below built the rest (improvise-bw4p.1). The ADR's list isdelay,integrate,differentiate,map,filter,flat_map,index_by,join_indexed,distinctandaggregate, run by a synchronousCircuit, 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_mapismapinto several images and has no user yet either. -
integrateanddifferentiateare slice functions, not circuit nodes.integrate(&[d_0, d_1, ..])is the running sumsd_0 + .. + d_i;differentiateis its inverse,s_0thens_i - s_(i-1). The state before the first element is zero, as in DBSP.delayis not provided: over a slice it is a shift, and a circuit gives it meaning. -
distinctis 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 makesdistinctidempotent. -
Operators are methods on
&self, group operations arestd::opstraits.K: Cloneis required only where a result copies keys (filter,distinct,scale, the borrowed operators);mapand the by-value operators need onlyOrd. -
Overflow. Neither clause 1 nor clause 8 states a policy for weights (clause 8 is about
f64drift, which wrapping integers do not have). The crate currently useschecked_*on every weight operation, for the reason given below, and panics withzset: weight overflow in <op>, in debug and in release (checked_*does not depend onoverflow-checks). The operations areadd(includinginsert,Extend,FromIterator),sub,neg(soi64::MINpanics when it has to be negated) andscale. Scaling by zero cannot overflow and returns the zero Z-set. A panic, not aResult:Resultdoes not belong in the majority of the code (AGENTS.md), and a weight neari64::MAXis 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
i64weights and no wrapping, the laws hold wherever no weight, intermediate ones included, leavesi64; outside that, an operation panics rather than return a different value. So associativity is order-dependent at the boundary:(MAX + 1) + (-1)panics whereMAX + (1 + (-1))isMAX. 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 == wwould 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 + 1storesi64::MIN, sodistinctdrops a key whose true weight is positive andweight()has the wrong sign. A true weight of 2^64 stores as 0 (by the add path; ascaleproduct of 2^64 would store a zero too, which breaks canonical form), so the key vanishes andis_zero,lenand==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, andi128is partial too. Which one is a ruling for the user, not for this step; the crate currently panics, andoverflow_depends_on_the_order_of_evaluation_and_never_gives_a_wrong_valuestates it.subis checked directly against the held weight, soa - ais zero for everya,i64::MINincluded, and only an absent key's0 - wcan overflow (wisi64::MIN). -
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 = ..).
The circuit (improvise-bw4p.1)
Clause 1's deferred scope, built without ADR 008. The ADR is not edited; this is how the crate reads it.
| item | what it is |
|---|---|
Circuit |
creation-order list of boxed nodes; step() runs each once |
Input<K>, Stream<ZSet<K>>, Output<K> |
push a delta in; the value on an edge; a sink that sums every step's delta until take() |
delay, integrate, differentiate, plus |
the stream operators as nodes |
map, filter, flat_map, index_by |
linear, per-element |
distinct |
incremental, state is the integrated input (DistinctState) |
join_indexed, join_indexed_in |
bilinear delta join of two ZSet<(K, _)> (JoinState) |
aggregate, aggregate_in |
group-by, re-folds only touched keys (AggregateState) |
Trace, MemTrace |
operator state behind lookup, range, apply |
Rebuild, Progress |
a snapshot loaded as a resumable job |
begin, commit, rollback |
an undo-log transaction over the circuit's state (below) |
Decisions, where the ADR left a choice:
-
Wiring is apart from logic.
circuit.rsis the only file with the arena andBox<dyn Node>.ops.rsholds each stateful operator as a plain struct with a purestep(delta) -> delta, testable and usable without a circuit. -
Scheduling is creation order. A node is built from streams that already exist, so creation order is a topological order, and the circuit has no cycles: the ADR has no recursion operator, and
integrateis its own stateful node, not a delayed feedback loop.delayis a node with one held Z-set.Send, notSync: one thread steps a circuit at a time (clause 1; "Threads" below). -
Incremental means the integral of the output is the operator of the integral of the input. That is the property every operator is tested against, over random delta streams.
distinctandaggregateare not linear, so they keep the integrated input and emit the difference of the operator at the touched keys only. -
index_byismapto(key, element). A keyed stream is aZSet<(K, V)>; there is no separate indexed collection type, so the join and the aggregate read the same shape. -
The join is
da x b_prev + a_prev x db + da x db, computed asdaagainst the right trace,daapplied to the left, thendbagainst the updated left (which holds the cross term), thendbapplied. Output weights are products and panic oni64overflow like every weight (interpretation 5).f(&K, &A, &B) -> Obuilds the output key. -
aggregate's fold gets the key and the group's(value, weight)pairs (owned, because a paged trace cannot lend references) and returnsOption<R>;Nonemeans the group has no row (an empty group, or aMinof nothing). Sum, count, min and max are closures; text unanimity and the ADR'sExactSumbelong to the caller (clause 4, 8), not this crate. -
The trace interface is
apply(k, v, w),lookup(k)andrange(lo, hi), returning owned vectors.MemTraceis aBTreeMap<K, ZSet<V>>; the_inconstructors take anyTrace, which is the seam for paging or compression. An empty or inverted range reads nothing (BTreeMap::rangewould panic). -
The resumable step takes a predicate, not an
Instant, and a load is one logical tick.Rebuild::step(circuit, expired)loadschunkkeys at a time and stops whenexpired()is true, always running at least one chunk first, so every call progresses. It reportsDoneas soon as the snapshot is exhausted, whateverexpired()would say.Instant::now()panics on wasm32 (web-timeis a dependency this crate refuses), so the host closes over its own clock. The first chunk isCircuit::step; the rest arestep_continue, which does not advance the clock. A circuit tick that arrives in pieces is exact for the incremental operators because their outputs are deltas. For the temporal ones the pieces are shaped so the outputs still sum to the one-step output: a continueddelayemits nothing and adds to what it holds, a continuedintegrateemits only its piece. An empty snapshot still takes its one tick; a finished job takes none. Deltas pushed to the same input between calls are absorbed into the next piece. Buffering live deltas during a rebuild (clause 7) is the host's.current()is the whole tick. A stream keeps the latest piece, which downstream operators read, and the whole value of the tick, whichCircuit::current(&s)reads: pieces within one tick are absorbed into it, socurrentof an integrate stream after a chunked load is the full integral and a continueddelaystill reads the previous tick's input.Outputsums pieces as before.A pending rebuild owns the clock.
Circuit::stepcounts ticks, and aRebuildrecords the count after its first chunk. If aCircuit::stepruns before the job is Done (the host stepped between calls, or two jobs interleaved), the nextRebuild::steppanics with "the rebuild was interrupted";Rebuild::interruptedasks without panicking, so a host can refuse first.step_continuecalls made by another job are not counted and are not detected. The state a load leaves for the next tick is tested by pushing another delta after Done and stepping twice. -
Not built:
Circuitdoes not own a job runner (ADR 008's), and nothing here is serialised (clause 9,improvise-protocol).
The tests are crates/zset/tests/circuit.rs; the rivals and where they failed are in
context/test-evidence/zset.md.
Transactions (improvise-bw4p.5)
A panic inside step (a weight overflow in an operator, say) leaves nodes ahead of the panic
updated and the rest not. ADR 009 clause 11 says each command snapshots the circuit for
rollback; Circuit::begin, commit and rollback are that, and the ADR is not amended (the
clause's wording stands; this is how the crate reads it).
- An undo log, not a snapshot. The ADR budgets memory, and a clone of the circuit's state
is the size of the circuit.
begininstead makes every stateful node start logging, and the log holds what the steps touch: the cost of a transaction is the keys a step touches, not the circuit's size (a_transaction_costs_the_keys_a_step_touches_and_not_the_size_of_the_circuitmeasures it in key clones: 16 extra forbegin; step; commit, 22 forrollback, the same at 10 and at 300 keys of state, with anintegratein the pipeline). Outside a transaction nothing is logged and nothing extra is cloned. Two costs are the circuit's size, and are limits, not features:- A split tick. A
step_continuethat is the first publish of an edge in a transaction clones that edge's whole value (2009 key clones against 1006 outside one, at N=1000;a_split_tick_in_a_transaction_costs_the_size_of_the_edge). - Memory held until
commit. Every edge's previous value is moved into the slot's undo record and kept, so anintegrate -> delaypipeline holds 4022 live keys during a transaction against 2014 without one (N=1000). An integrate edge is back to three copies of its integral for the length of the transaction.
- A split tick. A
- What is logged, per node. Each integrated Z-set (
integrate,distinct, a sink's sum) keeps(key, weight)for every addition it applied; each trace (the join's two, the aggregate's groups) keeps(key, value, weight)for everyapply, throughUndoable, a wrapper over the caller'sTrace; aDelaykeeps one clone of what it holds on its first eval, which is the previous tick's input; aSourcekeeps a clone of what was pending atbegin; an edge (Slot) keeps the(piece, whole)its first publish replaced, moved and not cloned, except for a continued piece, which clones the edge once.ticksis restored. - Rollback replays newest first. Each undo is the exact inverse of an addition that
succeeded, so it never leaves
i64, but only in this order: a key atMAXlowered and raised again would reachMAX + 1undone oldest first (rollback_undoes_newest_first_so_no_undo_leaves_i64).i64::MINhas no negation; a trace undoes it asMAXthen1(the weight before was non-negative, so neither leavesi64), and a Z-set undoes it byadjust(.., subtract). - An entry is built before its change and logged after it. An overflow panics before a
key changes (
insertis one checked addition), and the entry's clones are made first, so a panickingClonechanges nothing either (c1_a_clone_panic_anywhere_in_a_step_is_rolled_backsweeps a panic over every key clone in a step). A step that dies half way has therefore logged exactly the keys it applied. A customTracemust do the same: a panickingapplyleaves the trace as it was.MemTracedoes, since it makes every clone before it touches its map (a_panicking_value_clone_leaves_a_memtrace_unchanged). - The edge's two copies. An integrate stream's piece is also its output, so a second copy
(the whole-tick value) only exists while a tick is split.
Slot::wholeisNoneotherwise andCircuit::currentreads the piece. An eval reads its input's piece in the arena by reference rather than cloning it (improvise-bw4p.11). An integrate stream now holds its integral twice (the node'saccand the stream's piece), not three times; sharing those two needs shared ownership in the slot and was not done. - What a transaction does not undo.
- A closure's captured state (a
mapfunction that counts, an FFI callback's side effects). Operators that keep state keep it in the crate's own types for this reason. - A
Rebuild: it owns an iterator over its snapshot, so a rolled-back chunk leaves the job ahead of the circuit. The clock cannot tell:rollbackrewindsticks, which can make a dead job's recorded tick current again. So the circuit also counts the rollbacks that undid a step or piece (undone, never rewound), and a job records the count when it first runs, before its first chunk can panic. The chunk'spushis marked as a step too, since it can overflow the pending delta before any step runs (p1_a_chunk_whose_push_panics_in_a_txn_is_counted). A later count makesinterruptedtrue for good, finished or not (rollback_rebuild.rs: begun before the transaction and continued inside, finished inside, a first chunk that panicked, and a dead job revived by a later step). - A
Circuit::takeof an output made inside the transaction: a rollback subtracts the step's output from the sink, which atakehas emptied. Take aftercommit;zset-ffirefuses to take while a transaction is open.
- A closure's captured state (a
- Pushes.
Circuit::pushis not logged.rollbackputs the pending delta back to what it was atbegin, so a push made afterbeginis dropped, and one made before it is pending again. Operators built afterbeginstart logging at once and go back to empty; they are not removed. - One transaction at a time, and misuse panics like an overflow does:
beginwhile open,commitorrollbackwith none open.zset-ffianswers with a code instead.
The tests are tests/rollback.rs (the property: a step that panics half way, then a rollback,
leaves every stream and sink as before and the later ticks equal to a circuit that never ran
the step) and tests/cost.rs. A step that panics outside a transaction still leaves the
circuit unusable: drop it and build another.
Rebuilds and circuits (improvise-bw4p.12)
A Rebuild takes its circuit's identity from its Input: every handle a circuit makes holds the
circuit's token (a process-wide counter since the arena, Rc::ptr_eq before), so
Rebuild::belongs_to(&circuit) is a comparison. Rebuild::step on another circuit panics, where it used to report
Done and land its chunk in that circuit's next tick. An interrupted job (decision 8 above) can
never finish: interrupted stays true however many steps follow, because a split tick cannot
be mended and a rollback that took a chunk is counted forever (decision 6 above). Drop it and start another job from the same snapshot; the host keeps the
snapshot.
Threads (user ruling 2026-10-11; ADR 009 clause 1 and section 9)
The graph is network-agnostic and multi-threaded from the start, and multi-threading is
scheduling: a circuit stays synchronous and single-threaded, and a host moves whole circuits
between threads. Shared-nothing: one circuit per runnable unit, joined only by edges (the edge
adapter, a later PR, is the one thing that crosses threads). Splitting one circuit's keys across
threads (data parallelism) is not done and not precluded: its exchange step would be the same
edge. This is a ruling recorded here, not an ADR amendment. Native threads are assumed; wasm
threads are out of scope, and nothing in zset spawns one. Send and mpsc compile on wasm32.
CircuitisSendand notSync, with no lock anywhere in it. It moves between threads, one at a time (tests/threads.rs: a circuit built on one thread, stepped on a new thread each tick, equals a circuit that never moved; a transaction begun on one thread is rolled back on another). NotSyncis pinned bya_circuit_is_not_sync, an ordinary test using the ambiguous-impl trick: it fails to build (E0283) ifSyncis ever implemented.- The
Rc<RefCell>graph is an arena. What made the circuit!Sendwas the sharing between the circuit and the handles it hands out. A circuit now owns every edge's slot, every input's pending delta and every sink's sum in oneVec<Box<dyn Any + Send>>; a node holds typed ids, andeval,begin,commitandrollbacktake the arena. A downcast on every access is the price. Measured on an M-series Mac under load, a release build of a 1001-node chain (map, filter, distinct, delay) over 3000 ticks, best of 5, alternating with main: main 127k-131k ticks/s, this 118k-122k, so 7-9% slower per tick (about 1 ns per node per tick). The reviewer saw 10-25% on a more loaded run. Not reduced. - Handles are plain ids, used through the circuit.
Stream,InputandOutputareCopy,SendandSync, and carry their circuit's token. The calls moved onto the circuit:Circuit::current(&stream),Circuit::push(&input, delta),Circuit::take(&output). A handle from another circuit panics ("handle from another circuit"), where before it was quietly wrong. A consequence: a closure can no longer read a stream during a step, since it has no circuit; the oldb1_...case does not compile, so it is gone. The handle types are invariant in their element (PhantomData<fn(T) -> T>), as main'sRc<RefCell<T>>was, so a stream offor<'a> fn(&'a u8)cannot be coerced into a stream offn(&'static u8), wherecurrentwould panic on a failed downcast. Nine call sites check the token, andevery_call_that_takes_a_handle_refuses_a_foreign_onehas one case for each. - User closures are
Send + 'static; keys and values areSend. A customTracehanded tojoin_indexed_inoraggregate_inmust beSend.PiecewiserequiresSend + 'static. - The sink does not clone. Its node reads the input edge and writes the sum through
Arena::read_write, two disjoint entries (the read one is always allocated first).a_sink_adds_its_input_with_one_clone_per_keypins it overCountedkeys. - Handing a circuit to a thread is the host's business (
std::threadlives in host crates). Whoever owns the circuit pushes, steps and takes; a handle is a number and needs no thread.
Edges (user ruling 2026-10-11; ADR 009 section 9)
crates/zset/src/edge.rs, std only. An edge joins two circuits that share nothing: the
producer's Output feeds an EdgeSink, the consumer's Input is fed by an EdgeSource, and a
Pipe carries Frames between them. A channel and a network are the same kind of edge: the
channel edge is Direct over ChannelPipe, a network edge is a codec over a socket pipe.
Nothing here spawns a thread; whoever owns each circuit calls pump and poll.
- The clock is a counter the sender increments per committed command.
Stamp { epoch, seq }:epochnames the sender's incarnation and is passed in, never read from a clock (the host uses a random id, asHost::with_instancedoes).delivery's(host instance, document_generation, publication)maps to(epoch, seq)with the first two folded intoepoch; a delta is a notice with its(from, to]range. Delivery is not changed. - Only committed deltas cross.
EdgeSink::committed_from(circuit, &output)takes the output afterCircuit::commitand panics inside a transaction (Circuit::in_transaction). A rolled back command is never taken, so it consumes no stamp (a_rolled_back_command_does_not_cross). An empty command is still a stamp, as in delivery. - Backpressure coalesces exactly. The sink keeps one waiting delta
(sent, seq]. A command adds to it (Z-set sum) and moves itsto;pumpsends it and, if the pipe saysFull, leaves it to grow. Nothing is dropped and the range has no gap (a_full_pipe_coalesces_exactly_and_keeps_the_range). - Order and gaps. The receiver applies a delta only if it is in the epoch it joined and
from == watermark.to <= watermarkis a duplicate and changes nothing. Anything else (a gap, an overlap, an unjoined epoch, a frame that does not decode) setsstale, andstalemakes the nextpollsend aRequest. - A receiver always joins by correction, never by a delta. A new
EdgeSourceis stale. The sender answers a request withCorrection { epoch, through: seq, target: integral }and the receiver pushestarget - held(delivery's rule), so the sender keeps its integral and the receiver keeps what it has applied: two copies of the edge's state, one at each end. There is no shared log as in delivery, so every answer is a full correction. Joining by a first delta withfrom == 0was tried first, and the differential property found it wrong: a sender that restarted with a non-empty integral atseq 0looks like a fresh one (two_circuits_on_an_edge_equal_one_circuit, shrunk to aRestartSinkafter a rollback-free history). - A restart is a new epoch.
EdgeSink::restart(epoch, seq, integral)keeps the pipe and codec. The receiver sees a delta from an epoch it has not joined, goes stale, and the answer moves it onto the new epoch, adding the old one toleft(the newest 16). Frames from a left epoch change nothing, so a late reply from a dead sender cannot regress the receiver. A receiver that restarts (EdgeSource::restart) is stale again. - A lost final message is invisible to the receiver.
connection_lost()is the host's timeout: it marks the receiver stale and lets it ask again. The host calls it on its own local monotonic timeout, never by comparing times across machines. It is also what retries a request whose answer was lost. Requests carry the receiver's stamp and this sender does not read it. - The wire form is a hook.
EdgeCodec<K>turns aFrame<K>intoWireand back, withCodecErrorfor what does not decode;Directis the identity.improvise-protocolis to implement it for its own key types in the lossless text of ADR 005, andzsetstays free of serde. The tests run every history through a hand-written text codec (the_same_histories_through_a_text_codec). - Threads.
EdgeSinkandEdgeSourceareSendwhen the pipe and codec are. The real two-thread test steps a producer on one thread and a consumer on another with a capacity-2 channel (a_circuit_on_each_of_two_threads_equals_one_circuit). The property test simulates arbitrary interleavings, losses and restarts by choosing the order ofcommit,pump,polland step. Native threads are assumed;mpscandSendcompile on wasm32 and nothing runs there. - Not built. A sink with a log to answer a watermark with only the moved keys (delivery's
Movedcorrection). A pipe that blocks or notifies, becausetry_sendandtry_recvare polling. Key-sharded data parallelism inside a circuit: its exchange step would be this same edge, and nothing here forbids it.