Skip to content

feat(lfm): the per-table aggregation node — step 9, a tree that composes - #975

Merged
MauroToscano merged 17 commits into
per-table-gpufrom
pt/aggregator-tree
Sep 9, 2026
Merged

feat(lfm): the per-table aggregation node — step 9, a tree that composes#975
MauroToscano merged 17 commits into
per-table-gpufrom
pt/aggregator-tree

Conversation

@MauroToscano

@MauroToscano MauroToscano commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Step 9 of the per-table redo: the aggregation node — the emitted verifier of a child
LFM proof, the cross-child bindings, and the two changes to the epoch wrap that make either possible.

Full suite green at fdd2e0d1: prover --lib 1091 passed / 8 failed / 46 ignored = 1145, and the eight failures are by name the same eight #973 and #974 both gated — nothing added, nothing retired. stark 271/0/3, math-cuda 171/0, both matching #974. --list confirms 1145 independently. make lint exit 0 over all three feature sets; make fmt a no-op.

Against per-table-gpu @ 1d92fea0 (both deletion phases merged). Sixteen signed commits.

What a node is, in four steps

A leg is the machine twin of proof::verify_against_chunked, which is four steps and no more:
absorb the LFM statement, fork and replay Phase A for the shared LogUp pair, recompute the
LfmPublic balance from the claimed public words, and verify every sub-proof against it.
per_table_aggregator::emit_leg emits exactly those four, so the leg and the host verifier are two
renderings of one contract rather than two implementations of one idea.

That is why this PR adds almost no cryptographic arithmetic. The spine is
epoch::emit_table_challenges, the per-sub-proof verification is
epoch_verify::emit_table_verification, Phase A is statement_replay::replay_phase_a, the closure
is logup::emit_bus_closure — all already gated by the epoch wrap and the global-memory leg. New
here: the LFM-shaped statement, the canonicity-guarded public-word hinting, the balance target, and
the bindings.

One transcript per child. Each child was produced against a transcript seeded by its own
statement, so a node runs one replay per child: N Phase A's, N challenge pairs, N closures. A shared
spine that re-indexed every sub-proof over the union of both children would be verifying one proof
with 2n tables — a different statement, and not one either child ever made.
(per_table_census_tests::tenant_node_program is shaped that way deliberately, as a census
instrument; it is not a template, and this is noted where someone would otherwise copy it.)

Two changes to the epoch wrap, and why they are not optional

R1 — the wrap publishes the block-binding schema

A node sees exactly two things about a child: its program_id, an emit-time constant of the parent,
and its published words. The per-table wrap published neither the register boundary vectors, nor
the epoch label, nor the public-output bytes, nor the L2G re-commit root — so the bindings a node
must check (register fini→init across every seam, the label pinned to a chain position, the L2G
root equality against the global proof) were not weak, they were unbuildable.

In the batched format those four were the carved schema, published by the carve rather than by the
program. The carve went with the format.

Publishing costs no soundness: each value is already bound. The register vectors derive the REGISTER
preprocessed commitment Phase A absorbs; the label and the output halves are absorbed by
absorb_epoch_statement; the L2G root is a Phase A absorb.

R2 — the wrap gets a publish profile, and it is the largest lever in the node

A node pays per published word: eight hinted halves, four canonicity guards, four
recombinations, thirty-six bytes of statement absorb and one extension-field inverse. So what a
child publishes is the size of the layer above it.

At the production posture the wrap publishes 10,507 words; the binding set is ~145. The
other ~10,400 have no consumer above: composition is already asserted equal to the claimed
Horner inside emit_table_verification, the DEEP invariants are documented as exposed for tests,
and the β/z/γ/ζ/ι runs are challenges the machine derives for itself. They are this crate's
differential surface against production's replay — evidence, not binding.

Publishes::Diagnostic stays the default and every existing gate keeps exactly the words it had;
Publishes::Aggregation is chosen per emission by the aggregator.

What R2 bought, on the MEASURED leaf node (fixture scale — a production node is a different
object): the fan-in-2 leaf is 497.6M cells / 39.1 GiB. Under Diagnostic its two children would
have added 227M cells, taking it to ~725M ≈ 42.1 GiB; at fan-in 3 the addition is 341M,
taking a leaf from ~733M ≈ 42.6 GiB to ~1,074M ≈ 61.2 GiB. So R2 is worth ~12.4 GiB at fan-in
2 and ~18.6 GiB at fan-in 3
— against a publish term it reduces to 1.73M cells per child, 0.7%
of a leg
. It is the largest single lever in the node, and it costs nothing.

The L2G fold — what makes the tree compose

The batched aggregator compared each epoch's published L2G root against the global proof's
re-commit root for that epoch, and could do it as a local assert because all six legs sat in one
program. A tree splits them: the epoch wraps are in leaf nodes and the global wrap rides at the
root, so the compare must defer to their common ancestor.

Re-publishing a subtree's roots as a list would grow the node schema with the subtree, making
every level's published width a function of the block's epoch count — and therefore the parent's leg
shape too. Folding them with the production hash's two-to-one compression keeps the node schema
fixed-size at every level; the root recomputes the same fold over the global wrap's published
roots and compares one digest. The fold rule (left fold, tree order, identity on a singleton) is
documented where the root must replicate it.

SchemaLayout has a wrap and a node constructor rather than a flag, because the differences are
real: a node has N Phase A's and so no single (z, α), and it covers a label range rather than
one label. Publishing the range's two ends is what gives a parent contiguity across siblings for
free — pin both ends to constants and a child covering the wrong range cannot satisfy the pins.

The inner node, and the bug building it found

emit_node needed no change to serve a node whose children are node proofs: a child is a plain
per-table MultiProof whichever level produced it, and real_child reads a node's proof exactly as
it reads a wrap's. That is what "the same emitter serves every level" means operationally — the
emitter is shared, the programs are not, because each level compiles it against different
program_id constants and shapes.

★ Building that arm found a real bug in the leaf work. A node published its L2G fold as digest
cells (four lanes in one word) where a wrap publishes its root as lanes (one lane per word),
and emit_node_publishes reads lanes[0] of each published l2g word — correct for a wrap, one felt
where four are needed for a node child. The inner node could not have been built, and no leaf gate
could ever have caught it, because a leaf's children are always wraps.
Fixed by publishing the fold
in the wrap's own shape, so both layouts read alike; the asymmetry is removed rather than
parameterised around.

⛔ A found defect: the emitted verifier could not verify a legal epoch

Two degenerate shapes, one story, both surfaced by the leaf gate and neither caused by it —
emit_table_challenges, sample_u64_pow2 and SubProofShape::check are untouched by every other
commit here. A one-row trace at blowup 2 is a legal continuation-epoch sub-proof (epoch 1 of the
fixture bundle carries one at index #10), and the emitted per-table epoch verifier refused it twice
over.

1. The query sampler refused zero index bits. index_bits() = log2_trace + log2_blowup − 1 is
zero for a two-leaf LDE, and sample_u64_pow2 asserted 1..=32. ⚠ The obvious fix would have been
the bug: production does not skip the draw. The byte arm's threshold = 1u64.wrapping_neg() % 1
is 0, so next_sample_u64() runs once and advances out_pos; the pinned algebraic arm calls
squeeze_cell() and only then masks with & (upper_bound − 1). An emitter that skipped would be one
squeeze short for that table and every challenge after it in that fork would diverge. Both emitted
arms already consume before reading nbits, so letting zero through is the whole change.

A desync there is invisible to a value differential: every index is 0 whether the draw happened
or not, and iota_bits is the last thing sampled in that fork. So the gate samples an extension
element after the zero-bit draw on both sides and compares against the host's — the only
observable that moves if the squeeze is missing.

2. The shape check refused a one-leaf Merkle tree. With one row pair the tree has no levels and
the leaf hash is the root; SubProofShape::check asserted merkle_depth >= 1. The walk at depth 0
is not a no-op — emit_group_authentication hashes the leaf, walks zero levels, and still asserts
the result equals the committed root, which is the entire binding. The host degenerates identically:
verify_merkle_path_from_leaf_hash loops over an empty path and returns root_hash == hashed_value.
So the assert refused a shape the code beneath it verifies correctly. Its gate's rejection arm is
the load-bearing half — had the relaxation let the walk skip its root comparison, the honest arm would
still pass.

The rest of that path was swept for asserts predicated on a non-empty tree: sub_proof.rs:364
compares bits.len() against merkle_depth (0 == 0), and the remaining > 0 asserts are about query
counts, composition parts and frame step size. That sweep also settled the FRI side by construction —
terminal_log = (blowup_log + final_poly_log_degree).min(log2_lde_length) clamps to 1, so
total_folds is 0 and the FRI walk is vacuous.

Evidence

The node cost model — measured, emission only

Three columns kept apart on purpose: instructions, hash ops and cells are three different
measurements, and collapsing them is how F(1) = 2,886 / F(2) = 5,771 came to be quoted as cells
when they are compressions.

(a) THE PER-PUBLISHED-WORD BILL, at Algebraic
      words        instrs      hash ops           cells  cells/word
          0            34            13        26482828        0.00
         64         19803           653        27255940    12079.88
        128         39579          1293        28035204    12127.94
        256         79131          2573        29593732    12151.97
        512        158234          5133        32710788    12163.98
        153         47304          1543        28214404    11317.49
      10655       3292421        106563       140050564    10658.63
   marginal 0->64 12079.88 cells/word, 64->512 12176.00 cells/word (+0.8%)

(b) THE BINDING LEGS (schema = 153 words/child)
   children       Δinstrs     Δhash ops          Δcells
          2           585             0           18360
          3          1154             0           36792

The rows in (a) are not legs. publics_only_program is statement + hint + balance with Phase
A over zero tables, so the 0-word row's 26,482,828 cells is the empty machine's chip-padding
floor, which any LFM program pays before doing anything. Separating it out: the publish term is
1.73M cells at 153 words and 113.6M at 10,655 — R2 saves 111.8M cells per child ≈ 6.11
GiB
on the corrected 58.7 B/cell line.

The coefficient does not extrapolate as a constant. Cells are not additive across programs:
work below a chip's padding floor is free until it crosses a power of two, so 12,176 cells/word
measured at low occupancy need not hold inside a full leg whose chips are already past their floors.
Order of magnitude robust; exact figure not.

The bindings are free — ~10k cells per child, zero hash ops, ~0.01% of a leg. Everything
lane C established about the inter-leg glue holds; what made it look otherwise was the publish term,
which R2 removes.

What a node costs — measured

The leaf gate's phase marks, on the fixture, one run:

RSS high-water AFTER the wrap proves:      3.688 GiB
RSS high-water AFTER the diagnostic arm:   3.688 GiB   ← the gate-only arm adds ZERO
RSS high-water BEFORE the node prove:      5.344 GiB
RSS high-water AFTER build_artifacts:      7.597 GiB   ← +2.25, in 40.9 s
RSS high-water AFTER lfm_prove:           39.102 GiB   ← +31.5, in 148.1 s
node census: 497,620,736 cells, floor 26,482,828 (5%), verification work 471,137,908
  • The cost is the prove, not the commitment pass. build_artifacts_with_hasher is +2.25 GiB
    against lfm_prove's +31.5.
  • The affine RSS law holds on a node. 497,620,736 cells predicts 29.70 GiB on the corrected
    blowup-4 line (2.5 GiB + 58.7 B/cell) against 33.76 GiB measured for the node's own
    artifacts+prove delta — 1.14×, inside the law's own stated ~15% cross-program transfer (1.32×
    against the 39.10 whole-process peak).
  • The machine's padding floor is 5.3% of a real node. It dominates only programs near its own
    size — the cost instrument's 0-word row.

Two census lines measure different programs, 14× apart, and reading the wrong one produced two
confident and wrong findings before this was traced: wrap_tests::fixture_wrap_run prints a census
of epoch_program (35,244,192, the epoch WRAP) and the aggregator gate prints one of the NODE
program (497,620,736). Trace a census to the program it was computed on before dividing anything
by it.

Phase 2's prep-round deletion makes node proves 2.34× faster

A one-change arm: the same sixteen commits at two bases, b433674f against 1d92fea0 (deletion
phases 1 and 2). Pre-registered before the run — 600–640 s if the rebase is the cause, under ~350 s
to falsify.

                   pre-rebase (f1e5f10d)   rebased (68aae7e8)    ratio
leaf gate wall           613.35 s              261.78 s          2.34×
build_artifacts          346.6  s               40.9  s          8.5×
lfm_prove                166.7  s              148.1  s          1.13×
node census           497,620,736           497,620,736       IDENTICAL

The mechanism — and it is not the obvious one. The speedup is concentrated in
build_artifacts_with_hasher (8.5×) and absent from lfm_prove (1.13×, run-to-run variance at
n=1), which is where the deleted code lived. ⚠ But "a second commitment pass over every chip group"
naively predicts ~2×, not 8.5× — and the deletion removed the round's absorb, not the
expansion: both arms still expand every group exactly once. The gap is in how that pass was
implemented
, and it is two things in the deleted StreamingMmcsBuilder
(99d25bcf:crypto/stark/src/fri/mmcs.rs), both read rather than inferred:

  1. new() eagerly allocates 2^(h-1) leaf hashers for every distinct height in the round
    (:731-739), in the constructor, before a byte of data arrives, and holds them until that
    height's last matrix is absorbed. Under an algebraic hash each is a sponge state, so the
    round's tallest matrix sets a large up-front allocation that lives across the whole of
    build_artifacts. The per-slot commit path has no counterpart, and this is paid on construction
    regardless of absorb cost.
  2. absorb heap-allocates inside the parallel inner loopVec::with_capacity(2 * width) per
    leaf, per matrix (:777). Twelve slots plus the BLAKE3 chunks is twelve-plus allocations per
    leaf where the one-shot path pays one.

★ The comment directly above that loop reads "the same shape, and the same cost, as commit's
one-shot group hash."
Same shape; not the same cost — the 8.5× is the measurement that
disproves it.

This is a property of that implementation, not of mixed-height MMCS as a concept. A
mixed-height MMCS is not inherently 8.5×; allocating lazily per height on first absorb and hoisting
the row-pair buffer per thread would remove both terms. ⇒ Phase 1's "Where a new proof format
attaches" table names fri/mmcs.rs as what a future format would reuse — whoever picks it up should
know it cost 8.5× on build_artifacts and why
, or the cost is reimported with the file.

n = 1 per side. One run at each base, not a paired series. The two components above account for
~324 s of the 351.6 s delta, leaving ~27 s (8%) unexplained — other phases, or run-to-run
variance. Both 2.34× and 8.5× should be quoted with that caveat attached.

⇒ A measured payoff for a deletion made as dead-structure cleanup, on a path neither lane had
connected to it.

The tree does not grow per level — measured within one run

STRUCK 2026-09-09 — THIS SECTION'S MEMORY CLAIM IS UNSUPPORTED (found by lane M; struck in
STATE.md §3). The three figures below are one process-wide VmHWM sampled at three times, after a
base continuation and four wraps in the same process, with no mark before leaf 0 — so they attribute
no cost to any level, level 1 included. The instruction counts stand; the GiB column does not.
The published #975 body carries this heading uncorrected. Kept in place, not deleted, so nobody
re-derives it.

AFTER proving leaf 0 (level 1):          37.169 GiB   (6,714,341 instructions)
AFTER proving leaf 1 (level 1):          37.169 GiB   (6,714,967 instructions)
AFTER proving the INNER node (level 2):  37.169 GiB   (3,757,992 instructions)

Same run, same epoch size, same process. ⛔ The sentence that stood here — "leaf 0 set the mark and
nothing after it exceeded it: level 2 ≤ level 1" — assumed leaf 0 set the mark. Nothing in the run
shows that; there is no mark before leaf 0, and the base plus four wraps ran first. What survives:
the inner node runs 56% of a leaf's instructions.

⚠ This has to be a within-run comparison. The leaf and inner gates run at different epoch sizes
(FIXTURE_EPOCH_LOG2 and −1), so comparing one gate's peak against the other's compares different
workloads — an earlier cross-gate reading was withdrawn for exactly that reason, and the identical
144-word schema hides the difference because the schema is dominated by the register vectors, which
do not move with epoch size, while the sub-proof shapes do.

Gates

gate what it holds
the_aggregation_publish_profile_drops_only_diagnostics the binding head is identical cell for cell across profiles; each profile's last word reaches production's own COMMIT-bus target; what was dropped is counted from the epoch's own shapes
the_assembled_epoch_verifier_runs R1's blast radius — walks every published word and accounts for all of them
the_epoch_challenge_spine_matches_production the spine's challenges against production's replay
the_global_verifier_leg_runs_and_rejects_tampers lane X's moved global leg, untouched here
the_node_cost_model_is_measured the table above
the_leaf_node_verifies_and_binds_two_wraps the assembly: a differential of every leg's (z, α) against its child's own, then prove + verify, then one tamper arm per binding leg
the_inner_node_verifies_two_leaf_nodes a node over NODE proofs — the node layout, a label RANGE per child, and the fold composing; closes with the composition property, that a node's published schema does not change with its level
a_zero_bit_query_draw_consumes_what_the_host_does emitter-host consumption agreement at a zero-bit draw — the only place a missing squeeze shows
a_depth_zero_walk_still_binds_leaf_to_root a one-leaf tree authenticates against its own leaf hash, and rejects a moved root

⚠ The suite was gated on the failure-NAME SET, not the counts — and twice that was the only thing that saw the state

This branch went red twice on the way to that green, and neither red was visible in a total:

run counts what actually happened
1 1090 / 9 / 46 = 1145 a stale should_panic string — the guard still rejected nbits = 33 at the same line, only the message had moved
2 1090 / 9 / 46 = 1145 byte-identical counts, one failure retired and a different one appeared
3 1091 / 8 / 46 = 1145 green; the eight, by name

⇒ A count is a checksum over the wrong thing. One test failing more is arithmetically
indistinguishable from one added as ignored, so run 1's regression hid inside a matching total;
run 2 then produced the same three numbers as run 1 while measuring a different reality. --list
read 1145 in every case and CI's total agreed throughout — three instruments, all blind, all
agreeing. Only the set of failure names distinguished them. Pre-register the set.

Three tamper arms rather than one, because "both children verified" is exactly what a broken binding
looks like: each moves a single published half in one child's arena and nothing else, so what fails
is named by which arm failed.

Open, and stated rather than buried

  • Both node gates are green on the rebased branch — leaf 261.78 s, inner node 640.86 s,
    ten of ten in one run. A samplability pre-flight remains in both, naming any offending sub-proof and
    printing the shallowest shape on the inner-epoch and wrap-child sides, so a future degenerate shape
    is attributed rather than reported as a bare panic. That is how both of the defects above were
    localised.
  • The inner-node gate needs FAN_IN² epochs and asserts it, naming the number and the remedies if
    the fixture is short. Padding was rejected: a pad child belongs to no epoch and breaks the register
    chain and the label pin. ✓ It gets its epochs by passing FIXTURE_EPOCH_LOG2 - 1 locally
    epoch_size_log2 is a parameter of prove_continuation (floor 2), not a constant — so the shared
    fixture is untouched and the other gates keep their baseline.
  • The tree composes, measured. Wraps → leaf nodes → an inner node over NODE proofs, every
    binding leg tamper-tested and the (z, α) differential holding at every level. The schema is
    fixed-size across levels
    : leaf 0 and leaf 1 publish 144 words over 11 sub-proofs, and the inner
    node over two node proofs publishes 144 words over 11 sub-proofs — identical. That is the L2G
    fold's purpose landing as a measurement rather than a design claim; had a node re-published its
    subtree's roots as a list, the inner node's schema would have grown with the subtree.
  • STRUCK 2026-09-09: Node cost does not grow per level — measured within one run — the instrument was a VmHWM that never moved, which is an upper bound on the WHOLE process (base + wraps + leaves + inner node), not a per-level cost; there is no mark before leaf 0, so level 1 is unsupported by the same argument. See the struck section above. What stands: the inner node ran 56% of a leaf's instructions (3,757,992 vs 6,714,341). Level-2 memory is re-measured with one arm per process and VmRSS sampled before any per-level claim is made. Level 2 set no new high-water
    mark and ran 3.76M instructions against a leaf's 6.71M (56%). ⚠ It had to be a within-run
    comparison: the two gates run at different epoch sizes, so an earlier cross-gate reading
    (40.578 GiB leaf against 37.559 GiB inner) was withdrawn as comparing different workloads. The
    identical 144-word schema hides that, because the schema is dominated by the register vectors, which
    do not move with epoch size, while the sub-proof shapes do.
  • No production host-peak number, deliberately. The pre-registration this PR was written
    against — 322M cells → 20.1 GiB at fan-in 2 — was right in its law and 1.47× low in its cell
    count
    : implied per-leg here is ~236M against a 161M point estimate, inside the pre-registered
    80–240M band at its very top. So the METHOD stands — the fan-in question reduces to predicting a
    node's cell count, and the law converts it — but a fixture node is not a production node, and
    no production node has been measured. Whether a tree fits box A's 120.6 GiB is still open.
  • FAN_IN is a default, not an assumption — every emitter takes a slice, so the arity is the
    caller's and the measurement still decides it.
  • Each level is its own program, and that is forced rather than chosen: a leg absorbs its child's
    program_id as an emit-time constant, and program_id is derived from the compiled program, so a
    program verifying its own shape would need its own id inside its own instruction stream. It costs
    zero registry entriesLfmProgramKind blesses six toy programs and the wrap path never
    resolves — so nothing here reaches step 8b.

An aggregation node over epoch wraps sees exactly two things about a child:
its `program_id`, an emit-time constant of the parent, and its PUBLISHED
WORDS. The per-table wrap published neither the register boundary vectors,
nor the epoch label, nor the public-output bytes, nor the L2G re-commit root
— so the cross-wrap bindings an aggregator must CHECK (register fini->init
across every seam, the label pinned to a chain position, the L2G root
equality against the global proof) were not weak, they were unbuildable.

In the batched format these four were the CARVED schema, published by the
carve rather than by the program. The carve went with the format.

Publishing costs no soundness: each value is already bound elsewhere. The
register vectors derive the REGISTER preprocessed commitment Phase A
absorbs, the label and the output halves are absorbed by
`absorb_epoch_statement`, and the L2G root is a Phase A absorb.

The L2G bookend's position (the epoch's last sub-proof) is asserted rather
than assumed: publishing another sub-proof's root would bind an
aggregator's L2G compare to an unrelated table and still look like a
passing gate.

`schema_words` names the run's width so a reader walks past it by name.
`the_assembled_epoch_verifier_runs` uses it: with a literal, that gate
would start checking `beta of table 0` against a register slot the next
time the schema moves, and would report a pass while doing it.
A wrap's published words are the only thing an aggregation node can read
about it, and a node pays per word: eight hinted halves, four canonicity
guards, four recombinations, thirty-six bytes of statement absorb and one
extension-field inverse in the LfmPublic balance — per word, per child. So
what a wrap publishes is the size of the layer above it.

At the production posture the wrap publishes 10,507 words; the binding set
is around eighty. The other ~10,400 have no consumer above: `composition`
is already asserted equal to the claimed Horner inside
`emit_table_verification`, the DEEP invariants are documented as exposed
for tests, and the beta/z/gamma/zeta/iota runs are challenges the machine
derives for itself. They are this crate's differential surface against
production's replay, not a binding — a node that hinted them would pay to
re-read numbers it can recompute.

`Publishes::Diagnostic` stays the default and every existing gate keeps
exactly the words it had; `Publishes::Aggregation` is chosen per emission
by the aggregator. Dropping the differential surface globally would trade
the evidence that this machine derives production's challenges for the
saving, so the lever is local.

The new gate asserts the containment rather than assuming it: the binding
head is identical cell for cell, the bus total still ends the list, and
what was dropped is counted from the epoch's own shapes — so a diagnostic
added later fails here naming the count instead of silently widening what
every node above pays for.
The emitted verifier of one child LFM proof, and the cross-child checks a
tree of them needs.

A leg is the machine twin of `verify_against_chunked`, which is four steps
and no more: absorb the LFM statement, fork and replay Phase A for the
shared LogUp pair, recompute the LfmPublic balance from the CLAIMED public
words, and verify every sub-proof against it. So this reuses the spine the
epoch wrap and the global-memory leg already gate — `emit_table_challenges`,
`emit_table_verification`, `replay_phase_a`, `emit_bus_closure` — and adds
only the LFM-shaped statement, the canonicity-guarded public-word hinting,
the balance target and the bindings. There is no new cryptographic
arithmetic here.

⚠ ONE TRANSCRIPT PER CHILD. Each child was produced against a transcript
seeded by its own statement, so a node runs one replay per child: two Phase
A's, two challenge pairs, two closures. A shared spine that re-indexed every
sub-proof over the union of both children would be verifying one proof with
2n tables — a different statement, and not one either child ever made.
`per_table_census_tests::tenant_node_program` is shaped that way on purpose,
as a census instrument, and is not a template.

`SchemaLayout` is the reader of the schema the wrap now publishes, and
`assert_covers` pins it against the child's actual published count so a
layout applied to the wrong level fails at assembly instead of mis-binding
fifty asserts later.

The L2G compare is a separate entry point rather than part of the chain
bindings: in a tree the epoch wrap and the global proof are not generally in
the same node, so it is emitted where both sides are in scope and nowhere
else.
Three emission-only measurements, in the order the per-table format makes
correct rather than the order lane C's narrowing implies.

C narrowed the fan-in question to the binding legs, and did so under the
batched assumption that a child publishes ~285 words. The per-table
diagnostic wrap publishes 10,507, and a leg pays LINEARLY per published
word — eight hints, four canonicity guards, four recombinations, thirty-six
bytes of statement absorb and one extension-field inverse. So the term that
actually grew by 37x is the per-word one, and it is measured first.

(a) is sampled at seven counts rather than fitted from two: the statement's
sponge absorbs in rate-sized blocks, so the hash term is a step function
whose average is linear, and a two-point fit would hide the step. The
linearity is asserted with a tolerance that admits the step and nothing
wider.

(b) is the bindings as a with/without delta over the same hinted words, so
what is left is the bindings alone.

(c) needs no second emission. This leg is `tenant_node_program` plus the
statement, the balance and the bindings; the balance is field arithmetic and
hashes nothing, so in COMPRESSIONS the only term added to C's F is the
statement's — which is (a)'s hash column.

⚠ C's F(1) = 2,886 and F(2) = 5,771 are COMPRESSIONS over a glue delta, not
cells. The instrument prints all three units side by side so the two are not
compared by accident.
One emitter for every level: declare each child's arenas in absorb order,
verify each child, bind them, publish the node's own schema. A child is a
plain per-table MultiProof whether a wrap or another node produced it, so
nothing here is level-specific — the harvest proves it, since
`host_table_forked` and `build_table_legs` take (&dyn AIR, StarkProofView)
and read a wrap proof exactly as they read a VM epoch's.

★ THE L2G FOLD. The batched aggregator compared each epoch's published L2G
root against the global proof's re-commit root for that epoch, and could do
it as a local assert because all six legs sat in ONE program. A tree splits
them — the epoch wraps are in leaf nodes and the global wrap rides at the
root — so the compare must defer to their common ancestor. Re-publishing the
subtree's roots as a LIST would grow the node schema with the subtree and
make every level's published width a function of the block's epoch count.
Folding them with the production hash's two-to-one compression keeps the
node schema FIXED SIZE at every level; the root recomputes the same fold
over the global wrap's published roots and compares one digest. The fold
rule is documented where the root must replicate it.

`SchemaLayout` now covers a wrap child and a node child. They differ in
their head (a node has FAN_IN Phase A's and no single pair), in their labels
(a node covers a RANGE and publishes both ends, which is what gives a parent
contiguity across siblings for free) and in their L2G item. That is a
constructor apiece rather than a flag, because the differences are real.

`digest_from_lanes` is the both-arms inverse of `RootCells::lanes_flat`,
written rather than reused: `from_halves` asserts eight halves and is
byte-arm-only by construction.

FAN_IN is a default, not an assumption — every emitter takes a slice, so the
arity is the caller's and the measured host peak still decides it.

The gate carries one tamper arm per binding leg, and three rather than one
because "both children verified" is exactly what a broken binding looks
like: each arm moves one published word in one child's arena and nothing
else, so what fails is named by which arm failed.
…pair

Closes the evidence gap named when the node was built. A node under the
aggregation publish set publishes no challenges, so the only thing saying
its legs derived their CHILDREN's challenges was that the node executes at
all — a leg on different challenges cannot authenticate the child's walks,
so execution implies agreement.

Implication is weaker than what every other emitted verifier in this crate
is held to. The epoch wrap and the global leg both publish their pair and
compare it against production's own replay; the node was the one that did
not, and only because its publish set had no room for it.

`NodePublishSet::Diagnostic` publishes each child's pair AFTER the schema,
so no schema index moves and `SchemaLayout::node` reads both variants
identically. It is a gate shape and never a child — nothing verifies a
diagnostic node.

The oracle is the pair `verify_against_chunked`'s own Phase A recovers
host-side from the same proof, which is why `RealChild` keeps `z_alpha`
again: it now has a consumer, where before it would have been a dead field.
The re-tune holds q=110 by moving security_bits 128 -> 120 at blowup 4
rather than by taking the count to 119, so the cost instrument's 10,507
sample point is current rather than a value that may go stale. The
coefficient the instrument produces was never a function of it.
The assertion read `assert_eq!(diag[last], agg[last])`, and a published word
is an `(index, value)` pair. The two indices cannot be equal — making the
lists different lengths is the entire point of R2 — so the gate asserted the
negation of the property it exists to prove.

Box A caught it with the four field elements matching exactly and only the
indices differing, 307 against 144: the emitter doing precisely what it
should, failed by a test asserting something it never meant. 144 is also the
predicted Aggregation size (5 + schema_words), so the profile is doing its
job at fixture scale.

The replacement checks each profile on its own against production's COMMIT
bus balance, which is a stronger oracle than "the other profile agrees with
me": the last published word must be the closure's total AND reach the
target production computed. Position is asserted separately, as what "ends
the list" actually means for an auto-incrementing index.
`the_epoch_challenge_spine_matches_production` failed on box A with "beta of
table 0" reading an all-zero challenge. It is not an underived challenge and
it is not a leak: `epoch_challenge_program` is `epoch_program(e, false)` —
the SAME emitter with the legs off, as its own doc says and says why — so it
gained R1's schema like every other caller, and a literal cursor of 4 landed
on register INIT slot 0. x0 is hard-wired zero, so the mismatch printed
zeroes and read like a derivation failure.

This is the exact failure the R1 commit message described, at a second site
that commit missed. The audit that missed it read the function's NAME and
concluded "different emitter" without opening its two-line body — a claim
about what code does, made from a name. The re-audit walked every caller of
`epoch_program`/`epoch_challenge_program`/`epoch_program_with` and every
positional read of `public_words`: this was the last one. The rest read
index 0, 1, 2 or the final word, all of which sit outside the inserted run.

Also adds a samplability pre-flight to the leaf gate. `emit_table_challenges`
samples each query index with `sample_u64_pow2(index_bits())`, and a one-row
trace at blowup 2 makes that zero; the sampler's assert then fires deep
inside emission naming neither the table nor which side of the tree it came
from, which is how it reached box A as a bare "got 0". The pre-flight names
the sub-proof and prints the shallowest shape on both the inner-epoch and
the wrap-child side, so the next run attributes it instead of reporting it.
It is a diagnostic, not a fix: if it fires the shape is real.
The emitted verifier could not be built over a legal continuation epoch. A
one-row trace at blowup 2 has a two-leaf LDE, so production samples
`sample_u64(domain_size >> 1)` = `sample_u64(1)` for each of that table's
queries, and `sample_u64_pow2` refused the corresponding `nbits = 0`
outright. Found by the leaf-node gate at inner epoch 1's sub-proof #10;
`emit_table_challenges` and `sample_u64_pow2` are untouched by every other
commit on this branch, so this is a latent defect on the tip.

The obvious fix would have been the bug. "One index, so skip the sample" is
wrong: production does NOT skip it. The byte arm's threshold is
`1u64.wrapping_neg() % 1 = 0`, so `next_sample_u64()` runs once and advances
`out_pos`; the pinned algebraic arm squeezes a cell and only then masks with
`& (upper_bound - 1)`. An emitter that skipped would be one squeeze short
for that table and every challenge after it in that fork would diverge.

Both emitted arms already consume before reading `nbits` — `squeeze_bits`
calls `squeeze_cell` first, the byte arm calls `next_candidate` first — so
letting zero through is the whole change. The lower bound was unjustified:
the assert message and the doc paragraph both justify only `<= 32`.

Every downstream consumer degenerates correctly, checked rather than
assumed: `point_factors(1)` is empty and matches the empty bit vector,
`pow_bits` over no bits returns the coset offset (which IS the query point
for a two-leaf domain), and `merkle_depth + 1 == log2_lde_length` makes the
depth zero, so the leaf hash is the root — correct for a one-pair tree.

⚠ The gate is not "the panic is gone". A consumption desync here is
invisible to a value differential: every index is 0 whether the draw
happened or not, and `iota_bits` is the last thing sampled in that fork, so
nothing later disagrees either. The new test samples an extension element
AFTER the zero-bit draw on both sides and compares against the HOST's — the
only observable that moves if the squeeze is missing.
A node whose children are NODE proofs. The emitter needed no change — a
child is a plain per-table MultiProof whichever level produced it, so
`real_child` reads a node's proof exactly as it reads a wrap's — which is
what "the same emitter serves every level" means operationally.

★ Building the arm found a real bug in the leaf work. A node published its
L2G fold as digest CELLS (four lanes in one word) while a wrap publishes its
root as lanes (one lane per word), and `emit_node_publishes` reads `lanes[0]`
of each published l2g word. A node child would have handed ONE felt to
`digest_from_lanes` where four are required — the inner node could not have
been built. Fixed by publishing the fold in the wrap's own shape, so both
layouts read alike; the asymmetry is removed rather than parameterised.

Three things the leaf gate could not reach are exercised here: the node
layout rather than the wrap layout, a label RANGE per child (each leaf
carries its subtree's two ends, and pinning both is what makes contiguity
across sibling subtrees a consequence of the pins), and the fold COMPOSING —
each leaf folded its own wraps' roots and the inner node folds those folds.
A single-child node folds to identity, so this needs two real leaves.

The epoch requirement is asserted, not worked around: a two-level tree at
fan-in N needs N^2 epochs, and padding would mean a pad child with no epoch
to belong to, breaking the register chain and the label pin — the same trade
already rejected when the tree shape was priced.

The closing assertion is the composition property itself: an inner node's
published schema has the same total as its children's, which is what lets
the level above use the identical emitter.
The panic moved downstream exactly as predicted: relaxing the query
sampler's guard let emission reach `SubProofShape::check`, whose
`merkle_depth >= 1` refused the same one-row table. A one-pair domain has a
single leaf, so the tree has no levels and the LEAF HASH IS THE ROOT.

Settled before touching the assert, and it is the reason this is safe:
BOTH SIDES already handle depth 0 without a special case. The host's
`verify_merkle_path_from_leaf_hash` loops over an empty path and returns
`root_hash == hashed_value`; `emit_group_authentication` hashes the leaf,
walks zero levels through `wrap_merkle_walk`, and asserts the result equals
the committed root. So the walk at depth 0 is NOT a no-op — it is exactly
the binding — and the assert refused a shape the code verifies correctly.

Swept the rest of the path for other asserts predicated on a non-empty tree,
as the last fix's lesson demands: `sub_proof.rs:364` compares bits against
merkle_depth (0 == 0), and the remaining `> 0` asserts are about query
counts, composition parts and frame step size, none of which depend on
depth. This was the only one.

That also closes the FRI question left open by reasoning: `terminal_log` is
`(blowup_log + final_poly_log_degree).min(log2_lde_length)`, so at
log2_lde = 1 it clamps to 1, `total_folds` is 0, and the FRI walk is vacuous
by construction rather than by luck.

⚠ The gate's REJECTION arm is the load-bearing one. If relaxing the shape
check had let the walk skip its root comparison, the honest arm would still
pass and only the rejection arm would fail. The root it compares against is
the emitter's own leaf hash, whose agreement with the host backend is gated
separately by algebraic_commit's leaf/parent differential — composing the
two is what makes this emitter-versus-host rather than emitter-versus-itself.
Two changes, both about not letting one number stand for several things.

The inner-node gate needs FAN_IN^2 epochs and the shared FIXTURE_EPOCH_LOG2
selects two. `epoch_size_log2` is a PARAMETER of `prove_continuation` with a
floor of 2, so this passes a smaller one locally instead of shrinking the
shared constant. The nine gates that already run green against that constant
keep their baseline; nothing is re-baselined to give one test more epochs.
Smaller epochs mean shallower tables, which is exactly where the two
degenerate shapes were found, so the cheap place to find a third is here.

And the leaf gate now prints the RSS high-water mark at each phase boundary.
`peak_rss_gib` is VmHWM: a PROCESS mark that only rises, so a single figure
at the end spans the wrap proves, the diagnostic arm and the node prove
alike — three proves and a 6.7M-instruction program that production never
emits, in one process. The run's 40.6 GiB is that conflation, not the node's
peak. Per-boundary readings bound each phase instead: the node's own prove
costs at most the rise from the last mark before it.
The single "before the node prove" mark was taken before
`build_artifacts_with_hasher`, which is not bookkeeping: it runs
`lde_columns` + `commit_lde_columns` over every chip group and builds the
prep round, a full commitment pass over the program. So the +35.25 GiB that
run attributed to "the node prove" actually brackets the artifact build AND
the prove together and cannot say which costs what. Three marks now split
artifacts, prove and verify.

And the census is printed beside the measurement so the two are never quoted
apart. The empty LFM machine costs 26,482,828 base-field-equivalent cells —
the 0-word row of the cost instrument — against this node's 35,244,192. So
75% of a fixture node's census is the machine's padding FLOOR and only 25%
is verification work, which is why a fixture node and a production node are
not on the same part of the curve and why the affine RSS law reading 8x
optimistic here does not transfer to either direction without a node
measured where the linear term dominates.
One gate's peak against another's cannot settle whether node cost grows per
level. The leaf gate runs at FIXTURE_EPOCH_LOG2 and the inner gate at
FIXTURE_EPOCH_LOG2 - 1, so their nodes sit over different-sized epochs and
their peaks describe different workloads. Only levels measured inside ONE
run are comparable, and `prove_node_as_child` is where every level's prove
happens, so the mark goes there and every node reports its own.
@MauroToscano
MauroToscano marked this pull request as draft September 8, 2026 21:57
`sample_u64_pow2_rejects_more_than_32_bits` pinned the old combined text
"nbits must be in 1..=32". The continuation-epoch fix did not split that
assert in two — it REMOVED the lower bound, leaving one `nbits <= 32` check
with a new message. The guard still guards: 33 is rejected at the same site
with the same effect; only the wording moved.

The new expected string names "at most 32" rather than anything a future
lower-bound assert could also produce. A substring loose enough to match any
assert in the function would pass on a reintroduced lower bound as readily
as on this one, which is the failure this test exists to catch.

And the doc names its counterpart, because the pair is the contract: the
UPPER bound is pinned here, and that nbits = 0 is accepted AND still
consumes a draw is pinned by
`per_table_aggregator_tests::a_zero_bit_query_draw_consumes_what_the_host_does`.
Widening a bound and keeping only the old rejection test would leave the
widening itself unguarded.
@MauroToscano
MauroToscano marked this pull request as ready for review September 9, 2026 17:59
@MauroToscano
MauroToscano merged commit 4abe8c5 into per-table-gpu Sep 9, 2026
11 of 15 checks passed
@MauroToscano
MauroToscano deleted the pt/aggregator-tree branch September 9, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant