soft3/foculus/audit/fold-acc-overlap-gap.md

fold_acc overlap gap

date: 2026-09-23 · revision: 177fad4 (origin/master) · property #5

specs/fold-mining.md states the fold accumulator is "a commutative monoid under fold: the order of accumulation does not affect the result" and "the monoid counts each canonical (id, n) pair once," with a re-submitted nonce "detected at fold time and discarded."

src/tickets.rs::fold_acc detects overlap (left.seen ∩ right.seen) but does not discard it: the overlap branch's own comment says "prefer left's sums when fully overlapping," yet the code below re-sums left.sum_m[i] + right.sum_m[i] unconditionally, identical to the disjoint branch. k is correctly recomputed from out.seen.len(), but sum_m is not, so fold_acc(x, x) returns sum_m = 2·x.sum_m with k = x.k unchanged — mean_shares then returns double the true per-neuron mean. Any fold-tree assembly that folds two accumulators sharing even one ticket corrupts the Shapley-share estimate rewards.rs mints against, silently and without error.

measurement

fold_acc_overlap_doubles_the_mean_a_monoid_law_violation (src/tickets.rs) folds a self-accumulator with itself and asserts the existing fold_acc reproduces exactly this doubling — a live demonstration of the bug on real ticket data (3 winning settlement tickets, 2 contributors), not a synthetic case.

fix

fold_acc_checked is added alongside fold_acc (unchanged, so no existing caller is affected): it computes the same seen-intersection check and, on overlap, returns Err(FoldOverlap::NonDisjoint(n)) instead of mis-summing. A compact ClusterAcc retains no raw per-ticket marginals, so there is no way to compute the correct sum on overlap — surfacing the overlap to the caller is the only sound move. In honest fold-tree assembly, accumulators folded together come from disjoint leaves, so Ok is the expected path; NonDisjoint signals a replayed ticket or a broken tree construction and should abort the fold step rather than mint against a corrupted mean.

fold_acc_checked_matches_fold_acc_when_disjoint confirms the two functions agree exactly whenever there is no overlap, so switching a caller from fold_acc to fold_acc_checked is a drop-in change on the honest path.

remains

try_fold_ticket, grind_fold, and assemble_fold_tree still call fold_acc (unchecked). Wiring them to fold_acc_checked — and deciding what a fold miner does on Err (skip the pair, slash, or fall back to a different pair) — is the next slice; it touches the fold-mining lottery's public signatures and is out of scope here.

Graph