Skip to content
This repository was archived by the owner on Sep 12, 2026. It is now read-only.

Recursive aggregation - #9

Merged
TomWambsgans merged 10 commits into
mainfrom
recursive-aggregation
Aug 13, 2026
Merged

TomWambsgans merged 10 commits into
mainfrom
recursive-aggregation

Conversation

@TomWambsgans

Copy link
Copy Markdown
Contributor

No description provided.

TomWambsgans and others added 10 commits August 12, 2026 22:55
Replace the recursion over a phony inner program with the real thing: one
guest (`guests/aggregate.py`) serving every node of an aggregation tree,
verifying raw XMSS signatures and sub-proofs of itself against one shared
(message, epoch), and publishing the sorted deduplicated union of their
signer sets. Modelled on leanVM's single-message path.

The bytecode is compiled to a fixed point on its own size: the recursion
placeholders depend on the inner bytecode's log size, and the inner bytecode
is now this one. Its digest needs no fixed point, riding the statement
instead of the code, which is also what keeps a whole tree on one bytecode.

Coverage carries the security claim. A write-once slot per declared signer
is written once by each raw signature and each child key, with the running
count as the value so a repeated slot is a conflict, and a final count makes
the cover exact. Every declared signer is therefore backed by a real
signature or a verified child.

Each node exports one deferred claim per fixed polynomial (the stacked
bytecode and flock's A0/B0) and batches its children's carried claims with
the fresh ones its verifications raise, 2n down to one; only the root's are
discharged natively. A carried matrix claim enters as a plain eq weight
beside the fresh quirky-eq/lincheck one.

Two protocol gaps this uncovered, both invisible to the phony program:

- The ring-switch weight assumed the q_flock claim was no longer than the
  WHIR fold rounds. That is false for any BLAKE2s-dominated inner proof,
  i.e. every real aggregation. Its top coordinates now continue into the
  residual challenges, with rs_nover hinted and pinned as the point claims
  pin theirs.
- A child must commit at least 2^MU_MIN for the parent to have an opening
  arm. Program::min_log_committed grows the SET table through the existing
  fill blocks until a small run clears the floor. Paying it in fill rows
  rather than in a padded commitment keeps the committed size a function of
  the announced table heights, so neither the verifier nor the guest gains a
  parameter to certify.

Also: runtime-bounded range checks in the zkDSL (`assert log x < log n`,
same three instructions, one hoisted MUL for the bound cell); Ord on
XmssPublicKey; and a signer cache that no longer repeats every 256 signers,
which silently shrank any batch above that once the union deduplicates.

Adversarially reviewed. The statement digest, the coverage bijection and the
batching all hold; the review turned up a non-converging floor search, a
terminal-weight cross-check that was dead in release and too weak to catch
correlated drift, a parser that accepted more bounds as runtime than it
should, and a leaked sponge trace on a failed child. `aggregate_hints_bind`
tampers sixteen witness streams and requires each to be rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The claim batching was the one part of an aggregation node that ran
single-threaded, and it cost more than half as much as proving. 462 ms to
49 ms, and a 2->1 step over 900-signature leaves from 1.37 s to 0.96 s.

The flock reduction contracted the R1CS matrices once per claim, walking
tens of millions of nonzeros each time. Now one pass serves every claim,
parallel over rows, with the claim weights interleaved so a nonzero costs
one fetch of n adjacent weights rather than n fetches into n tables. The
row contraction is a scatter, so workers accumulate private column vectors
and the dispatcher adds them. The matrices are fixed, so they are cached in
CSR with 16-bit column indices.

The bytecode reduction exploited nothing about its polynomial. Nine
encoding columns sit inside sixteen stacking slots, so nearly half the
table is structurally zero, and folding LSB-first pairs entries within a
slot, which keeps that window aligned all the way down: the row rounds now
run on the populated part alone and the slot rounds take their weights from
a closed form. Round zero runs against the K-valued table itself, so the
bytecode never materializes as 2^23 extension elements. The batching
weights are built as an outer product of two half-length eq tables instead
of one full table per claim summed together.

What is left is at the instruction-throughput floor for visiting every
nonzero: a cached CSR with quarter-size indices changed nothing, and four
times the claims costs 1.47x the time, so neither memory traffic nor
per-claim work binds it. Going further means visiting fewer nonzeros, which
the rank-1 structure of every claim weight would allow.

Safe to tune aggressively: the guest verifies both sumchecks in circuit, so
an error here fails a test rather than passing quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Proving cost is the sum of each table padded to a power of two, so cycles
only matter when they cross one. Two boundaries were within reach of the
aggregation loops in main. DEREF sits at 2^18.25 and the whole signer path
holds 54k of them, so 2^18 is unreachable; but memory sat at 2^20.04 and
needed only 42k cells to drop log_mem from 21 to 20, which halves the
committed memory. That is the one taken.

Memory is dominated by loop frames rather than by buffers: every mul_range
iteration allocates one, and a frame costs far more cells than the body it
holds. So both key loops, the node's own set digest and each child's
reconstruction, now take two keys per iteration.

The chain is untouched: still one compression per key, two of them per
frame. pubkeys_hash needs no change, the statement is unchanged, and an
aggregate built before this still verifies. The only new witness is a
parity, hinted as (half, odd) and pinned by half*half*odd == n_keys with odd
range-checked below 2, whose unique solution is half = n/2 and odd = n % 2;
the odd key out is absorbed in a one-shot branch, both arms writing the
digest cells for the join to read.

2->1 over 900-signature leaves: prove 778 ms to 549 ms, the whole step 942
ms to 731 ms, committed 2^25.05 to 2^24.86, proof 289 to 279 KiB.

The margin is thin. mem_used is 1,043k against a 1,048,576 ceiling, about
thirty more signers, and past that log_mem returns to 21 and the win goes
with it. Widening it means the per-signer buffers (all_pubkeys, pk_chain,
cover and sub_chain are seven cells a signer between them), not the frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verification was 50 ms, nearly all of it one thing: `mle_eval` over the
2^23-entry stacked bytecode is a fully sequential fold, `fold_low_k` then
`fold_ladder`, about 12.6M dependent operations. It is paid twice, once by
`cpu::verify` for the proof's own bytecode claim and once by the deferred
recompute at a different point.

`mle_eval_par` fans both folds out; `mle_eval` stays scalar because it is
also called from inside a dispatch (`leaf.rs`, the per-column claim walk),
where nesting deadlocks. Two entry points rather than a runtime
"am I in a task" flag: the choice is a property of the call site, and the
pool's nesting assert stays a live canary instead of being consulted.

Three sites are outermost and large enough to want it: `bytecode_claim`,
reached from both `prove_balance` and `verify_balance` after their own
dispatches have returned; `decompose_formula`'s public-column arm, the nine
encoding columns; and `DeferredClaim::recompute`. The ladder folds out of
place while rounds are big, since folding in place reads `cur[2i]` where
another task writes `cur[i]`.

Node verification 50 ms to 17 ms, leaf 37 ms to 14 ms, and proving picks up
a little on the way. Worth recording what this was not: the flock matrix
walk, the obvious suspect at 89M nonzeros between A0 and B0, is 164 us.

Also adapts the recursion section of the doc, which still described one
claim per sub-proof and called carrying claims forward unexposed, and
refreshes the README benchmarks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rename of recursion.rs to aggregation.rs defeated git's rename
detection, so main's three edits are ported by hand: the lincheck rounds
carry two coefficients rather than three evaluations, `raw.merkle_openings`
becomes `raw.merkle`, and the LAG4_INV placeholders go, the table sumcheck
no longer interpolating a cubic. The guest merged on its own, the rename to
aggregate.py being detected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tweak table and the Merkle bits were absorbed two cells a block, half
of every block going to re-inject the chaining state. Both loops are
unrolled, so the byte counter is a compile-time immediate and a plain
BLAKE2s is expressible here as it was for the statement: the label takes
the leading block and the two tables follow, four cells at a time.

Each block is hashed out of the frame it was hinted into. Routing the
cells through the heap first, which reads more naturally, costs 370 DEREF
and 270 cells: a blake2s operand is addressed as `fp · g^immediate`, so a
heap-resident one is loaded back a DEREF per cell. The hint entries widen
from two cells to four to match, which is what pins both tables to whole
blocks, asserted.

Measured on a 50-signature leaf: BLAKE2S 2^12.864 to 2^12.846, cycles
86,888 to 86,800, memory unchanged. With this the signer-set chain and the
Fiat-Shamir sponge are the only long hashes left on a re-injected state,
and neither can stream: their lengths are runtime, and the counter is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TomWambsgans
TomWambsgans force-pushed the recursive-aggregation branch from 5105d6e to 34559ce Compare August 13, 2026 15:49
@TomWambsgans
TomWambsgans merged commit 057f9de into main Aug 13, 2026
3 checks passed
@TomWambsgans
TomWambsgans deleted the recursive-aggregation branch August 13, 2026 15:50
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant