Skip to content

Per-table GPU recursion, end to end: one root for block 25368371 - #985

Draft
MauroToscano wants to merge 569 commits into
mainfrom
per-table-gpu
Draft

Per-table GPU recursion, end to end: one root for block 25368371#985
MauroToscano wants to merge 569 commits into
mainfrom
per-table-gpu

Conversation

@MauroToscano

@MauroToscano MauroToscano commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Draft: the per-table GPU recursion, end to end — block 25368371 compressed into one root proof, and three optimization passes.

What this is

The full per-table pipeline on the GPU: the epoch base proofs, the epoch wraps, the interior aggregation tree, the block-wide memory argument (as slices and a parent), and the block-artifact root. Every stage is proved on the card; the root is proved and verified. This branch is for sharing the work — not for merging yet.

Block 25368371, 19 epochs at 2^21 rows, fan-in 2 → 19 wraps, 21 interior nodes in 5 levels, 2 global slices, their parent, one root.

Times

Ryzen 9 9950X + RTX 5090, TABLE_PARALLELISM=4, one arm per process, nodes within a level proved serially. "Before" is the first end-to-end of the pipeline; "pass 1" (8a59fa1d) and "pass 2" (4fcb3341) are the tips of the first two optimization passes; "pass 3" is this branch's tip — each measured end to end on the same box from a fresh cache, every stage proved.

stage before pass 1 pass 2 pass 3 change, total
base proving, 19 epochs (per-table STARKs, GPU) 158.8 s 67.9 s 67.5 s 67.8 s −57%
19 epoch wraps 603.7 s 390.2 s 205.7 s 81.3 s −87%
interior levels 1–4 (10 + 5 + 3 + 2 nodes) 508.0 s 336.1 s 125.1 s 84.1 s −83%
global slices (k = 2) + parent 27.6 s 12.8 s 13.0 s 13.0 s −53%
block-artifact root, option A (prove + verify) 11.0 s 6.7 s 6.4 s 6.5 s −41%
block, end to end 1,309 s (21.8 min) 814 s (13.6 min) 418 s (7.0 min) 253 s (4.2 min) −81%

The record was reproduced within 0.1% on a second fresh-cache run (252.5 s), and the executor housekeeping at this branch's tip reads 251.6 s with the level-1 host peak at 46.6 GiB.

On a Ryzen 7950X + RTX 5090 (16 cores, 120 GiB host) the same commit runs in 9.3 min serial, 6.3 min at two wrap and two interior siblings, and 5.4 min at four wrap and three interior siblings — the third interior sibling peaks at 54.4 GiB of host memory, above the 9950X box's 52 GiB stop, so it is a 120 GiB configuration. Device peaks and host peaks are the same on both hosts to within 1%, wall times are not — memory bands transfer across hosts, time bands never do. Recursion overhead — everything after the base proofs — is now ≈ 2.7× the base time (was 11× after pass 1, 5.2× after pass 2). The harness re-verifies every child and every proof it makes as a test precondition; measured in the serial run that is ≈ 66 s of host work (2.6 s per wrap, 0.7 s per node), and under concurrency it overlaps other work, so no production-equivalent subtraction is made for pass 3.

Where the time went, measured phase by phase before anything was changed (the profile is in the campaign's state file): the recursion was host-CPU-bound — the GPU read 0% in 91% of samples through the wraps — and the largest single phase was the preprocessed-column commit (build_artifacts, an LDE + Merkle pass on the host, 43% of the block), followed by LFM program execution + trace fill (15%), the per-epoch reconstruction in the harvest (9%), and the STARK prover itself at 13% running at a healthy 57% device utilization.

What changed in pass 1

  • The LFM chip trace fill is parallel (prover/src/lfm/trace.rs): the per-chip row loop walks par_chunks_mut on rayon's global pool; every fill closure was read and is row-local; a cell-for-cell identity test against the serial reference covers all chips under RPX and BLAKE3 dispatch. 6.5× on the hash chip on an 11-thread laptop; on the box the per-node prove component fell 13.6 → 9.7 s with the artifact phase unchanged.
  • The artifact build's column loops are parallel and fused (prover/src/lfm/commit.rs): lde_columns runs an explicit par_iter over columns with interpolate→expand fused (one fewer copy of each group's coefficients); the group loop is windowed (LFM_ARTIFACT_GROUPS_IN_FLIGHT, default 4) with the memory reasoning kept. Roots are pinned byte-identical to the serial build by the registry_drift_* tests. Per node: build_artifacts 21.9 → 15.2 s.
  • One ELF parse and one DECODE commitment per epoch walk: the level-0 harvest recomputed the DECODE preprocessed commitment twice per epoch (38 times per run for one distinct value); it is computed once and passed through.
  • A program census, not a cache: the driver prints, per level, proofs vs distinct programs. Sibling proofs are distinct programs by construction (every child label is a program constant), so there is nothing to cache — the census asserts N/N and says why.
  • Five stale instruction-count pins fixed: the RPX pin (603c1e15) made byte-hash counts structurally zero; the register-derivation instrument now follows the block pin in all three places; the FRI-leg byteswap counts carry a hash width.
  • Proof-of-work grinding on the device under RPX (crypto/math-cuda/kernels/rpx.cu, crypto/stark/src/grinding.rs): the 2^20-trial search ran on the host per table because the device arm accepted only Keccak — about two-thirds of each base epoch's CPU time (RPX costs 2,368 ns per permutation on the host) and ~1.4 s of every recursion proof. An RPX kernel loops the existing device permutation over a nonce range and returns the smallest valid nonce (atomicMin, deterministic), dispatched by the configuration's commitment hash; pinned by a no-GPU known-answer layer replaying the kernel against stark::grinding::is_valid_nonce (four kernel mutations each red) and three device gates, one of which asserts the dispatch actually reached the device. Same factor, same predicate — no security change. Base 156 → 68 s. This cost is specific to the RPX pin; main grinds a Keccak-class digest and is unaffected.

What changed in pass 2

  • The preprocessed columns are committed on the device (prover/src/lfm/commit.rs, crypto/stark): the per-proof artifact build — coset LDE, leaf hashing and Merkle tree for every preprocessed column group of an LFM program — ran on the host, and after the RPX pin its Merkle half was RPX permutations on ~26 cores (56% of the phase). A named public entry point in stark (try_commit_row_major) exposes the device's fused LDE + leaf-hash + Merkle for one row-major group, which is the layout ColumnGroup already holds, so the host's strided transpose is skipped too; the host pass remains the fallback below the device floor (padded_rows × blowup ≥ 2^14, a row count) and under LFM_DEVICE_ARTIFACTS=0. Per proof the phase went 14.4 → 0.4 s on interior nodes and 9.8 → 0.3 s on wraps, with prove and harvest unchanged (5.9 s and 0.7 s per node before and after). Device peaks did not move: the artifact commit runs while the card is otherwise empty, and the device tree is freed before multi_prove builds its own.
  • Gates for the device commit: the six registry_drift_* root pins (which reach the device exactly once per fixture, on the one program-independent group above the floor — evidence, not coverage), a device_parity test that commits a group above the floor both ways and compares roots at the production width extremes (1, 20 and 134 columns) after asserting its own premise, and the fixture-scale root test, which under cuda now refuses to pass if every group fell back to the host. The census line reports how many groups took the device path per level (groups D/T on device; a production wrap prints 8 of 12, a level-1 node 8 of 12) and the device set the build asked the card for. On every production proof multi_prove rebuilds the precomputed tree on the device and refuses on a root mismatch — the strongest witness, and it stayed silent on all 44 proofs of the record run.
  • The controlled delta: the host pass and the device pass were measured on the same binary in a paired host–device–host sequence (LFM_DEVICE_ARTIFACTS=0 as the control): wraps 388.9 s host vs 206.2 s device; level 1 215.7 s host vs 75.5 s device.
  • The two remaining red instrument tests are green (prover/src/lfm/proof_fixture.rs, bench_vs/lambda/continuation-fixture): the closure test's fixture guest committed at the very end of its run, so the "output half" of the check had no subject — a sibling fixture guest commits in an intermediate epoch and then does bounded work, and the fixture asserts its commit-to-boundary, boundary-to-halt and halt-to-end margins in the guest's own step currency (the epoch count never moved when this broke, so the two-epoch canary never rang). The R1d transcript-replay instrument is a byte sponge that names its hash directly, per the hash-pin carve-out; its count now reads the same named constant as its builder.

What changed in pass 3

  • Sibling proofs run concurrently under one card permit (prover/src/lfm/per_table_aggregator_tests.rs driver, prover/src/lfm/commit.rs, prover/src/lfm/proof.rs): after pass 2 the top of the block was four sequential host walks per proof (LFM program execution, epoch reconstruction, program emission, harvest — none of them parallel) with the device prover in between; the driver proved siblings one at a time. It now proves K siblings of a level at once, with a single mutual-exclusion permit taken around each proof's two device phases (the artifact commit and multi_prove) and released between them, so one proof's host walks overlap the other's card time while the card never holds two proofs (the two-VramGates trap, closed at the callers by Fix A, is not reopened one level up: neither admission path keeps a cross-proof total, so the permit is exclusion, not a byte budget). K is a per-level knob (LFM_TREE_SIBLINGS for the interior, LFM_TREE_SIBLINGS_L0 for the wraps, both default 1 = the serial control); the census window enrols workers explicitly so a stray builder cannot inflate a level; the level line reports acquisitions, max holders, and the card's held share; each node prints its program identity (the digest a parent absorbs), heights and published words on one line, so byte identity between schedules is a diff.
  • Measured on one binary, paired: level 2 serial 27.6 / K=2 19.5 / K=2 19.4 / serial 27.7 s; level 1 75.3 → 50.4 s; the interior levels 1–4 in a full run 124.3 → 84.4 s; wraps 207.5 → 116.1 s at K=2, 94.9 s at K=3 and 81.3 s at K=4 (the record configuration: four wrap siblings, two interior siblings). Max holders read 1 at every level of every arm; VRAM peaks did not move; every identity line matched across schedules (40 per tree, wraps and nodes). The host peak is the binding constraint: a second live interior proof costs ≈ 4.5 GiB, a live wrap ≈ 5 GiB while two workers' host phases coincide (the permit staggers them, so later footprints rarely overlap), retention grows ≈ 0.9 GiB per harvested wrap regardless of the worker count, and the full record run peaks at 48.2 GiB of 57.5, set by level 1 with two siblings (serial: 43.8; level 0 with four siblings peaks at 35.6). Host contention on the single-threaded executor is +4% with two live wraps, +15% with three and +22% with four — the walls still fall because the busier worker carries fewer wraps. A saturation rule falls out of the timing fields — the card saturates only when one worker's whole cycle fits inside the others' turns on it, K_min = ceil(1 + host/device) — and it reproduces the measured card share at both interior levels (75% and 68% at K=2); a wrap wants four workers on time grounds and the host peak decides how many it gets.
  • Executor housekeeping and a lower host peak (prover/src/lfm/executor.rs, proof.rs): the executor's record vectors are sized from the census the compiler already holds (one record per instruction per chip, exact), the write-once memory cell drops from 40 to 32 bytes with a bit-per-address written map (a read-before-write test now exists on the executor, not only the validator), and the executor's memory and records are freed before the card phase. Byte-identical (the identity lines diff clean); the executor is 3% faster per wrap and the level-1 host peak, the number that bounds a third interior sibling, falls from 48.2 to 46.6 GiB. The executor itself is 83% software RPX permutations (one full permutation per hash instruction, 644,250 per wrap at 2,356 ns each on the 9950X), so it is not a lever for layout work; the levers left are running those permutations on the parallel side or making one permutation faster, both being priced before any code.
  • Finer timing prints, no behaviour change: the prove field splits into execute · fill · multi_prove (recorded in a per-thread cell, printed by the driver), every wrap prints its own TIMING line and census (level 0 printed one number for nineteen wraps), the harness-only verification is timed rather than banded, and the permit wait is its own field. On an arity-2 level-1 node: executor 2.25 s, fill 0.27 s, device prover 3.4 s.

The root

A B
children 2 interior + the global child = 33 sub-proofs 1 interior + the global child = 22
census 468,784,896 cells 437,589,760 cells
LFM_HASH height 781,518 / 2^20 (25.5% headroom) 531,162 / 2^20 (49.3%)
published words 180 = root_schema_words(67, 40, AssertOnly) 180
device peak 20,560–20,746 MiB on three hosts 19,242 MiB

A is the default: 7% more root cells than B, but it retires the top node (one proof fewer, one level less latency). The option is an input (LFM_TREE_ROOT_OPTION=A|B, refused when unset), not a ruling.

Gates

At the arm's commit, in a box --lib run: 27 unit gates (the parent's 12 — z/α agreement, L2G-root agreement and republish, partials summing to zero, partition tiling, gap / overlap / stop-short, slice count both ways — and the root's 15, including the five pre-registered tamper arms), the honest control (two real slices and their parent, summed to zero), and the fixture-scale root proved over real children under both options. Mutation-tested: each tamper arm fires on exactly the check it names. The trace-fill identity test and the six registry_drift_* root pins gate pass 1's changes; pass 2 adds the device_parity gate and the fixture root test's device assertion (both cuda-only, because without a card both sides are the host pass and the check could not fail).

Caveats, by design

  • recursion::check_attestation binds the proof to the block host-side, against an ELF the consumer trusts; the guest uses the supplied roots verbatim. "One proof for this block" terminates there.
  • The two-posture byte-identity check is refused by name, not performed: it needs the same block at two distinct postures and only 2^21 exists at this encoding. The artifact's width is pinned by signature; its value is not yet shown posture-independent.
  • The harness re-verifies every child as a precondition (2.4–6.2% of the measured time); a production driver would not.

Running it

LFM_CENSUS_ELF=… LFM_CENSUS_INPUT=… LFM_CENSUS_EPOCH_LOG2=21 A_CACHE_DIR=… \
LFM_TREE_PROVE_ROOT=1 LFM_TREE_ROOT_OPTION=A LFM_TREE_GLOBAL_K=2 LFM_TREE_GLOBAL_MODE=load LFM_TREE_PARENT_MODE=load \
LAMBDA_VM_VRAM_BUDGET_MB=16000 TABLE_PARALLELISM=4 \
cargo test --release -p lambda-vm-prover --features cuda --lib \
  lfm::per_table_aggregator_tests::the_production_tree_composes_to_a_root -- --ignored --exact --nocapture

LFM_TREE_LEVELS proves or loads the interior levels; the global stage and the parent have their own prove|load modes; Prove refuses to overwrite a cached entry. Knobs: LFM_TREE_SIBLINGS=K and LFM_TREE_SIBLINGS_L0=K prove K siblings at once in the interior / at level 0 (default 1, the serial control; pass 3's record runs both at 2); LFM_DEVICE_ARTIFACTS=0 forces the host artifact build (pass 2's control); LFM_ARTIFACT_PARALLEL=0 reproduces the pre-pass-1 host build exactly; LFM_ARTIFACT_GROUPS_IN_FLIGHT=N bounds host residency.

Not in this branch

The VRAM admission/scheduler decoupling and the barrier levers (pt/barrier-levers) are separate; 2^22 needs the epoch wrap sliced the same way and is not attempted here. Next levers, sized at this tip: a third sibling where the host peak allows it (level 1 is the binding level at 47.9 GiB with two), the sequential LFM executor (≈20% of the block, single-threaded), and running the two boxes as one tree. Mixed fan-in is worth ≈ −30 s, not the −130 s once estimated: emission, commit and prove are linear in legs; only the flat per-proof harvest is saved on a removed node.

…lt gadget is gone

felt_be_halves rendered every opened field element for the hash as one
BitDec row plus 64 base-ALU recomposition rows. At the real epoch that
gadget was 99.9% of all BALU rows, 99.99% of BITDEC reads, and 77% of the
whole wrap program's main cells (1.18B of 1.53B, measured 2026-08-21) —
the wrap was a byte-marshalling program with a verifier attached.

The two big-endian u32 halves are fixed LINEAR FORMS over the 64 bit
columns the BitDec row already carries and constrains, so the chip now
SENDS them on the memory bus — the keccak REV_ADDR pattern: two
interactions, four preprocessed columns (HALF*_ADDR/MULT, prep width
130 → 134), no value columns, no new constraint. Booleanity and the
canonicity witnesses already pin the bits; the receiver already forces
them to recompose the input; the sends are functions of constrained
columns. Identical bytes reach the hash: same digests, same transcript,
same wire format.

bit_dec_be_halves emits one BitDec with two half outputs and NO bit
cells (the bits stay as witness columns); felt_be_halves delegates, so
every consumer — both wrap formats, keccak and blake3 leaves, the
statement absorb — collapses at once. Executor computes byteswapped
halves; compiler assigns their mults and fills the new preprocessed
columns; validator checks the outputs like any other.

Expected at the real epoch: LFM_BALU 2^27 → ~2^17 rows (−536M main
cells, −35% of the wrap), instructions 113M → ~6M. To be measured on the
box, one change at a time, per the ladder.

The LFM machine AIR changed, so the registry is deliberately re-blessed
(compute_lfm_registry): exactly one group root per entry moved — slot 4,
BITDEC's preprocessed group — everything else byte-identical, which is
the drift tests' own proof the change is surgical. The three pinned-cost
tests are re-pinned to the new contract, with the zero direction asserted
so a reintroduced per-felt ALU cost fails loudly.
…e whole LDE

Round 3 reads the trace LDE only at stride blowup — the size-n coset
evaluation. Under RecomputeLde the batched phase 4 was paying a full 4n
expansion (iFFT n → scale → FFT 4n) per table to then subsample a quarter
of it. The recompute arm now materializes the n-sized evaluation directly
(iFFT n → scale → FFT n, via a lazily-built size-n forward twiddle set):
bit-identical values — both compute the same DFT in exact arithmetic, and
the residency-equality tests are the proof — at ~37% of the work and a
quarter of the transient bytes. Retention still serves phase 4 from the
full LDE it already holds.

get_trace_evaluations_from_lde takes its read stride from the TABLE's own
blowup instead of the domain's — coincident for every existing caller,
and what lets a blowup-1 table mean 'the subsample, already taken'. The
parts stride stays the domain's (retained parts are full-LDE-sized).

The recompute budget moves from 5+5 to 4+4 full expansions per table plus
one cheap coset evaluation per side, counted in new stats fields so the
full-expansion number stays honest; the budget pin asserts both.
# Conflicts:
#	crypto/math-cuda/build.rs
#	crypto/stark/src/prover.rs
#	crypto/stark/src/trace.rs
…e proofs alone (P1)

verify_epoch's AIR reconstruction moves into reconstruct_epoch_airs and the
wrap flow's new RealEpoch-from-proof constructor calls the same function, so
the verifier and the wrap emitter cannot diverge on what an epoch IS. The
replay harvest is extracted from real_epoch_from and shared; a wrap can now
be emitted for ANY epoch of a continuation bundle — the final epoch (HALT on
board) included — with no live proving session and no prove_continuation
changes.

Gates: session-vs-from-proof wrap programs byte-identical through the
registry artifacts; the final epoch wraps end to end from proofs alone;
bundle tamper rejected at its two layers (reg_fini by the constructor's
production-verify, l2g_root by verify_continuation). The block driver
the_real_block_proves_and_wraps_end_to_end runs prove_continuation, full
bundle verification, and all per-epoch wraps in one process and reports
block-level numbers.

The gates prove their fixtures at blowup-4 diagnostic options: the per-table
replay indexes each table's own LDE pairs (index_bits = log2(h*blowup) - 1),
which a height-1 fixture table makes impossible at MIN's blowup 2.
…cuda

Under cuda builds with a live device, build_auxiliary_trace's resident-aux
arm builds the LogUp columns on device and skips the host writes; the
batched prover expands the aux LDE from the host trace, so every table
above the GPU-logup eligibility height committed an aux round built from
unwritten columns — aux and composition-parts roots diverged from the
non-cuda build and the verifier's OOD composition check rejected the
proof (main root identical; a_batched_vm_epoch_host_verifies_end_to_end
red under cuda, green with LAMBDA_VM_NO_GPU_LOGUP=1).

The batched prover now throws the same switch the per-table prover uses
under disk-spill and RecomputeLde: resident aux disabled per table before
the aux round. Device consumption of the aux LDE belongs to the
RoundCommit device path.

Covered by a tall (2^13/2^12-row) tiling of the CPU/ADD/MUL fixture:
host-path completeness unconditionally, and the same epoch under cuda —
eligible for the GPU arm — must host-verify. The VM-epoch harness itself
is a plain #[test], so the merge-group GPU CI prover suite runs it as
soon as this branch line merges.
…the batched format

The L2G carve-out's stark layer (D1). A batched epoch can commit ONE
table's main matrix as its own row-pair tree instead of a matrix of the
shared main round: the tree is built by the same committer the per-table
prover uses over the same expansion, so its root is byte-identical to the
per-table table's — which is what lets the cross-epoch root-equality
binding read the same root from either format.

The root is PROOF-CARRIED and absorbed after the preprocessed roots,
before main_root, ahead of every challenge draw. Which table (if any) is
carved is verifier-owned configuration, never read from the proof; the
carved opening authenticates per query at the reduced index and feeds the
DEEP/FRI join in place of the table's main-round row.

Gates: end-to-end round trip at mixed heights (the carved table shorter
than h_max, so the reduction is real); per-table byte-identity
differential; tamper on the root and on an opened row; carve-state
configuration mismatch both directions; the absorb-slot order pin on the
transcript itself; the index-reduction convention pinned against
literally-computed row pairs.
…d-MMCS proof with the L2G main matrix carved standalone (D1)

prove_continuation_batched proves every epoch of a continuation in the
batched format: one proof per epoch, the L2G table (last air, as always)
carved into its own standalone tree whose root is byte-identical to the
per-table L2G tree's. The claimed l2g_root is the proof-carried carved
root, so verify_l2g_commitment_binding_view reads the same commitment out
of either format and the binding code does not fork. The global memory
proof is per-table in both arms.

EpochProof's body is now an enum (per-table | batched, rkyv on both);
verify_epoch dispatches on it — the batched arm mirrors the per-table one
exactly: challenges replayed on a fork of the statement seed, the
expected COMMIT-bus balance from the replayed shared pair, the complete
carved batched verification, then the claimed-vs-committed L2G root
equality. The batched proof types join the rkyv wire format.

Gates: the D1 differential — the same execution proven per-table AND
batched yields byte-equal L2G roots for every epoch, across at least two
distinct L2G heights, and the batched bundle passes the complete host
verification (epochs, global proof, binding view) through the rkyv round
trip; tamper arms — a flipped claimed l2g_root and a flipped reg_fini are
both rejected on the batched arm.
…d root absorb, standalone walk, census (D1 emitted side)

The M-8 batched-verifier emitter learns the carve: the emitted program
absorbs the proof-carried carved root in its pinned slot (after the
preprocessed roots, before main_root — arena declaration order is absorb
order), authenticates the carved table's row pair per query against those
very cells at the reduced shared index (the preprocessed walk pattern with
the root's provenance moved to the proof), and the DEEP crossing consumes
the carved walk's cells in place of a main-round matrix row. The census
closed form, the opening serializer and the schema words all carry the
carved term; an uncarved epoch's program is byte-identical to before —
every existing batched gate passes unchanged.

The harness gains the carved sibling (real_batched_epoch_carved_from —
the L2G bookend carved, the continuation batched format), with host
verification threaded through the carve configuration one code path both
ways.

Gate (one test, five arms): the assembled carved verifier RUNS on an
honest proof; census and schema match the carved closed forms; a tampered
carved root, opening value and path sibling are each unprovable; and the
wrong-reduction control fires on the carved walk — verdict condition 4's
emitted side.
…aggregator-facing word schema (P2 wrap side)

Three pieces, one landing:

1. real_batched_epoch_from_continuation — the batched sibling of the P1
   constructor: any epoch of a batched continuation reconstructs from the
   bundle (chain position + reconstruct_epoch_airs, the same reconstruction
   verify_epoch runs) into a RealBatchedEpoch whose harvest — replay, COMMIT
   target, provenances, statement shape, and production's complete carved
   verify as the acceptance gate — is one function shared with the session
   harness, so the two paths cannot diverge.

2. The published-word schema (verdict condition 2), on CARVED programs only:
   after the bus total, the wrap publishes the register boundary vectors
   (init then fini), the epoch label, the epoch's output bytes, and the
   carved L2G root — the words P3's aggregator byte-compares across wraps
   and against the global proof. Every published cell is existing program
   state: zero new arena words, zero wrap-hash permutations (both asserted
   by the census and schema gates), ~135 publish instructions on a ~200k
   program, no height boundary crossed. Uncarved programs are untouched.

3. The P2 block driver (the_real_block_proves_and_wraps_end_to_end_batched,
   box-tier) and its suite-gated fixture twin: prove_continuation_batched →
   complete host verify → every epoch wrapped from proofs alone through the
   carved program — with the wrap's published L2G root byte-compared against
   the bundle's claim, exactly the aggregator's check.

Gates: genesis AND final epochs of a batched fixture continuation
reconstruct, emit, and RUN; a tampered reg_fini is rejected inside the
constructor; the fixture driver flow proves + verifies the final epoch's
carved wrap and its published root matches the claim. Suite 997/1-standing;
make lint/fmt green.
…he aggregation wrap preset (blowup4/110q, terminal 2^8)

The aggregation layer consumes batched-format wraps as serialized bytes;
BatchedLfmProof now carries the rkyv wire derives, gated by a round trip
through the complete verifier. aggregation_wrap_options() pins the decided
A-point for the wrap layer — blowup 4 (110 queries at the Johnson-bound
target) with the FRI terminal at degree 2^8 — chosen for the wrap's
VERIFIER: the aggregator pays per query per wrap, and the terminal trade
swaps one committed layer's 110 openings for 128 absorbed coefficients.
Inner epochs are untouched: the wrap program is a function of the inner
proof's options, so no program identity moves.
…ed wraps verified in ONE emitted program (P3 core)

The batched-LFM verify leg is the first emitted verifier whose target is an
LFM-machine proof: the spine replays absorb_lfm_statement byte for byte with
the wrap's program id as an emit-time constant, preprocessed roots absorb
from the AIR set as constants, and the LogUp closure's target is the
LFM_PUBLIC balance recomputed in-machine from the wrap's claimed words
(canonicity-guarded halves). Every soundness-critical emission is the wrap
program's own machinery — the leg adds no new cryptographic arithmetic.

aggregator_program assembles N legs (arena declaration order = absorb order,
leg by leg) and the chain bindings: one shared attestation id, register
fini→init across every seam, each epoch label pinned to its chain position
as a program constant. The aggregate publishes the id, the block's register
boundary vectors, the final output bytes, and every wrap's carved L2G root.

Gates: the leg runs and reproduces production's replay challenge for
challenge on a real wrap; a tampered opening and a moved public word are
each unprovable. The assembled aggregate verifies EVERY epoch of a
batched-carved fixture chain wrapped at the aggregation preset in one
execution; a broken register chain and a swapped wrap order are each
unprovable. A page-census probe (execution only, no proving) reports the
global proof's real shape for the aggregation census.
…obe, drop redundant casts

The previous commit pushed with make lint red (a shell-flow slip: the
compound command continued past the lint failure); this restores the
lint-before-push invariant. No behavior change: unused-import removal,
same-type-cast removal, and cfg(test) on the page-census probe, which has
no production caller by design.
…, the L2G binding in-VM, the attestation joined and folded (P3 assembly)

The global-verifier program is the per-table machinery pointed at the
cross-epoch global proof: one constant-run statement, Phase A over the L2G
re-commits and the per-page GLOBAL_MEMORY tables (genesis roots as AIR-set
constants), full verification legs per table, the GlobalMemory bus closed at
zero — publishing each epoch's L2G re-commit root. Proven as a sixth batched
wrap, it makes the aggregator six uniform batched-LFM legs.

The aggregation program adds, over the legs and the chain bindings: the
in-VM L2G root-equality (each epoch wrap's published carved root
byte-compared against the global wrap's published re-commit root), the
attestation join (one hinted (elf, pc, decode) triple whose num_pages = 0
fold must equal EVERY wrap's published id) and the final attestation
(ProgramIdShape num_pages > 0 — its first caller — folding the genesis page
commitments), and the block artifact's published words: the final id, the
register boundary vectors, the output bytes, the five L2G roots, the folded
page bases, and the touched-page list with the private-input count as
program constants (the consumer contract's data availability).

Gates: the global leg runs on a real fixture bundle's global proof and
rejects a flipped re-commit root; the six-leg aggregate runs on the fixture
chain with its published id equal to the CONSUMER'S OWN recompute
(program_id_from_digest over continuation_precomputed_commitments — the
contract's compare as the gate's oracle); a broken register chain, a forged
attestation input and a moved global root are each unprovable; and the leg
census gate pins the walks to the in-code closed form
(batched_query_permutations_for), delta-measured, hash-aware. The box
driver (the_real_block_aggregates_end_to_end) runs the whole pipeline to
ONE PROOF in one process, consumer ritual included and timed. SOUNDNESS.md
carries the applied η re-tune note (base ≈114-117; the class-split floor
tracks it 1:1 — the ε_C lift carries no η — settling the review's
unresolved point) and the query-sweep boundaries. Suite 1008/1-standing;
lint/fmt green.
…n residency posture

The first real-scale aggregation attempt was OOM-killed at the 483 GiB
cgroup under Retain, after ~62 minutes of base and wrap proving that the
retry would have re-paid. The driver now persists the bundle and all six
wrap proofs over the rkyv wire when P3_ARTIFACT_DIR is set and loads them
on relaunch — programs and artifacts re-emit deterministically in minutes;
only the proves are cached. P3_AGG_RESIDENCY selects the aggregation
prove's own residency (recompute trades ~2x prove time for the LDE peak)
without touching the wrap proves' measured Retain posture.
…a pipe-swallowed exit code pushed red

The same failure class this campaign has now hit three times (exit codes
laundered through pipes); the previous commit's lint run went through
tail and its Error 101 never stopped the chain. Verified green with the
exit code read directly this time.
…G_TERMINAL_BLOWUP)

Three straight OOM kills at 483 GiB (P3-OOM-REPORT.md): the blowup-4
LDE terms of the 2^21x3056 aggregation trace do not fit the box under
either residency posture. The terminal layer's options are separable
from the wrap layer's — the wraps and the aggregation program keep
Design A's blowup4/110q and the cached wrap proofs stay valid — so the
driver now lets the aggregation prove itself take a smaller blowup.
P3_AGG_TERMINAL_BLOWUP=2 halves every LDE term; the query count
re-derives from the same 128-bit Johnson target by construction
(with_blowup: 2 -> 219 q), and the FRI terminal stays at the preset's
fp8. Unset, nothing changes.
…nsumer precompute

Run 4 proved the block (terminal blowup2/219q, 34.7 min, 336.8 GiB
peak) and saved nothing but a byte count — the proof died with the
process. The driver now writes block_proof.rkyv into P3_ARTIFACT_DIR
next to the inputs it loads. The consumer ritual's expensive half
(continuation_precomputed_commitments, a native FFT+Merkle pass) and
build_artifacts each get their own timing line — run 4 hid them inside
a ~401 s unaccounted gap, and design-review condition 1 asks for the
ritual's price by name.
The chip is one row per compression at 3,056 value columns, so its matrix is
WIDE rather than tall: the aggregation program's ~1.39M compressions land in a
2^21 x 3,056 table whose blowup-2 LDE is a single ~102 GB allocation, and that
allocation is the aggregation prove's measured spike. Blake3Chunking splits the
rows over several instances, exactly as KeccakChunking already splits KECCAK_RND
and for the same reason its header gives: the chip has no row-to-row constraint
at all (every b.main() reads offset 0), every bus interaction is a within-row
token gated by MU or a per-word multiplicity, and a compression's inputs and
outputs travel on LfmMem by address matching over PREPROCESSED addresses. LogUp
cannot tell which instance a row lived in. BITWISE stays one shared receiver fed
the whole record list, as it must.

One thing differs from KECCAK_RND, and it runs through the whole change: this
chip HAS a preprocessed instruction group, so a chunk is its own committed
matrix with its own Merkle root and its own height. Chunk 0's root is the roots
array's slot-11 entry — which is what makes a single-chunk program bit-identical
to an unchunked one — and chunks 1.. ride LfmArtifacts, are absorbed by the
batched preprocessed round in slot order, and are folded into program_id as a
tagged, length-prefixed TAIL. An unsplit program absorbs no tail, so all six
blessed LFM_REGISTRY digests are the byte-identical values they were blessed at.

The policy defaults to a single unbounded table, so nothing moves until a caller
asks. LfmRegistryEntry gains no column: every registered program is a fixture at
the default, LfmRegistryEntry::artifacts derives the shape, and
every_registry_entry_is_a_single_blake3_table pins that premise so blessing a
chunked program fails loudly rather than resolving the wrong shape.

verify_against keeps its signature as the single-LFM_BLAKE3 door; chunked
callers go through verify_against_artifacts / verify_against_chunked, and a
chunked proof handed to the old door rejects on the AIR count.
…3_MAX_CHUNK_ROWS_LOG2)

Read at PROGRAM EMISSION time and applied to the AGGREGATION program alone, in
the P3_AGG_TERMINAL_BLOWUP / P3_AGG_RESIDENCY pattern: the six wraps are cached
artifacts at the census point, and re-chunking them would invalidate the cache
for no gain — the ~1.39M compressions that make the spike are the aggregation
program's own. Unset is one table, i.e. today's shape. When set, the driver
prints the chunk geometry beside the terminal options it already prints.

The chunk shape is bound into program_id, so a chunked aggregation is a new
emit-time identity. That is fine here and nowhere else in the block run: the
layer builds fresh artifacts and the consumer contract pins the identity.

The aggregate leg's query census gets its chunk-invariance stated as a control
rather than assumed. Chunking redistributes rows over AIR instances after
compilation, so the instruction stream the closed form counts is the same
program; a chunking that reached back into emission would move the census
silently, and the aggregation program is exactly where chunking is switched on.
…er permutation

Rescue-Prime Optimized behind the frozen LFM_HASH tuple contract, as the
socket's first production-candidate tenant. Poseidon is off the table
(2026/306, 2026/1692) and RPO is the algebraic candidate the break class does
not structurally reach: no partial rounds, and no cheap algebraic direction in
either composition order.

Parameter provenance is two independent sources checked against each other.
The spec's own SHAKE256 generator ("RPO(p,12,4,128)", nine-byte little-endian
chunks) was re-run outside this repository and reproduces miden-crypto's 168
ARK1/ARK2 constants exactly; the MDS row is the spec's get_mds(12) and miden's
first row, identically. The oracle is miden's own hash_elements table — all
nineteen vectors replay through this permutation.

The lane convention turned out to be a gift rather than a choice. miden's
RPO256 puts the rate at lanes 0..8, the capacity at 8..12 and the digest at
0..4, which IS the socket's own layout, so no lane permutation is needed
anywhere and a Compress row with the zero capacity is literally Rpo256::merge —
checkable against miden by someone who has never seen this codebase.

Layout W, one row per permutation: 28 shared prefix + 7x48 ladder columns +
6x12 inter-round state = 436 value columns, 429 constraints, max degree 3. The
inverse S-box is verified as the FORWARD power, (y3)^2*y = v — the spec's own
4.3 folding trick — so a ~2^63 exponent costs one ladder rather than a degree
explosion. Padding by zero survives in both S-box directions, so blowup 2 is
untouched.

Domain separation lands here rather than later because it costs zero cells and
the S8 copy constraint is the hook: the capacity copy is now PER MODE, carrying
miden's merge_in_domain construction (tag in capacity lane 9, padding flag
reserved in lane 8, security argument in the RPX spec's Appendix C). A
transcript step and a Merkle parent over the same two cells are different
functions, and the AIR rejects a row carrying another mode's capacity.

That change found a live bug in the trait rather than only in RPO: transcript_out
and leaf_out routed through compress_out, which hardcodes compress_iv(), so a
hasher separating its domains through the CAPACITY would have had its overrides
silently dropped on the host while the chip constrained the separated version.
The defaults now route through one permute_two_cells(a, b, iv) helper and each
mode passes its own IV. Test, Poseidon and Blake3 are unchanged.

No existing root moves: the preprocessed group is hasher-independent, so RPO is
a new program identity through the hasher tag and nothing needs re-blessing.

Measured against the scoping doc: 436 columns as predicted, 445 cells per
permutation against a predicted ~448, and 429 constraints against a predicted
433 — the doc assumed eight unread-IN pins where there are four, the leaf RATE
having emptied input slot 1's set. 11.1x cheaper per compression than slot-11
BLAKE3, 1.40x cheaper than the Poseidon reference.
…and the chain is not the reason

The scoping doc's second-riskiest unknown was host RPO commit throughput,
estimated at "20-60x slower than BLAKE3 per byte scalar". Measured on an
M-series laptop, single thread, release:

  RPO256 permutation            17,180 ns   (58.2k perms/s)
    of which the inverse S-box  14,887 ns   = 87%
  Goldilocks FieldElement mul       2.64 ns
  BLAKE3 64-byte parent            61 ns
  RPO / BLAKE3 per compression    280x

Both hashes absorb 64 bytes per invocation, so 280x per compression is 280x
per byte. The estimate was 5-14x optimistic.

The attribution is the point, and it exonerates the implementation: 72
multiplies at 2.64 ns predicts 190 ns for one inverse S-box against 177 ns
measured, and ~8.4k multiplies predicts ~22 us for the permutation against
17 us measured. The chain is running at the field multiply's speed limit and
there is nothing in it to tune. The headroom is SIMD across the twelve lanes,
which is exactly what miden-crypto ships hand-vectorized kernels for (ARM SVE,
AVX2, AVX512, scalar as fallback) — RPO's S-box layers are embarrassingly
parallel across lanes.

Consequence for the projection, at aggregation scale on 11 cores: RPO does
4.4x FEWER compressions than BLAKE3 (2.8B cells against 12.2B) and still pays
~36 minutes of commitment hashing against BLAKE3's ~34 seconds. The doc's
~8 min prove was extrapolated cells-linearly from a BLAKE3 prove in which
commitment hashing was a rounding error, and that assumption does not survive
the swap. The cell and memory halves stand — 445 cells/perm is measured, so
~2.8B cells and the 128 GiB envelope claim are unaffected, since throughput
costs time and not residency.

So the honest headline is that RPO buys memory at a price in time, and the SIMD
kernels move from nice-to-have to required for Stage 1 rather than Stage 2.

Kept as an #[ignore]d test rather than a criterion bench: it prints the numbers
and asserts only a floor loose enough that no honest machine trips it.
… and a single-reduction MDS

Two changes, both gated by the same miden known-answer table the module has
always been pinned to, so correctness is not the question and speed is the
whole point. 17,180 ns per permutation to 4,712 ns; RPO against a BLAKE3
64-byte parent falls from 280x to 74x, which is back inside the scoping doc's
original 20-60x estimate.

The inverse S-box moves from per-element to WHOLE-STATE. Its addition chain is
72 multiplications each depending on the last, so a single lane is
latency-bound: ~2.6 ns of multiply latency times 72 is the entire cost and the
multiplier pipeline idles between them. The twelve lanes are independent, so
running them in lockstep interleaves twelve chains and fills the pipeline. That
is 5.3x the serial rate, measured, and it is the same shape miden's scalar
fallback has and the shape its SVE/AVX2/AVX512 kernels vectorize from. Per lane:
177 ns to 35 ns.

The MDS becomes one u128 accumulation and one reduction per lane instead of
twelve field multiplications. Every constant is at most 26, so a term fits 70
bits and the twelve-term row sum fits 73 — comfortably inside a u128 — and the
row reduces once at the end via 2^64 = EPSILON (mod p), with the carry small
enough that its correction needs no reduction of its own. Both facts are
asserted rather than trusted to the comment. 180 ns to 94 ns.

The remaining breakdown, which is the useful handoff for whoever ports the SIMD
kernels: inverse S-box 63%, MDS 28%, forward S-box 5%.

At aggregation scale on 11 cores this takes the host commitment phase from
~36 minutes to ~10, i.e. the prove roughly doubles rather than quintuples.
…mn needs no closed form of its own

The census decomposition is "absorption is rate-sensitive, compression is not".
RPO's rate is 8 felts and a BLAKE3 block is 8 felts, and both share the
no-spurious-final-block rule — an exact multiple of the rate emits no extra
invocation, unlike keccak's pad10*1. A digest is four felts on both sides, so a
Merkle parent is one invocation under both.

The consequence is worth stating as a test rather than a comment: blocks_for
and query_permutations_for need NO RPO arm, because the aggregator's already
measured BLAKE3 compression count IS its RPO permutation count. Only the
cells-per-invocation moves, from 4,946 to the measured 445. That is what lets
the RPO column of the comparison table be computed on the same instrument that
measured the BLAKE3 one, instead of on a second closed form nobody has
validated.

Keccak is included as the control: its padding DOES spend a trailing block on
an exact multiple, so the invariance is a property of these two hashes and not
of the closed form.

The test also records what it assumes — the rate-8 overwrite duplex of spec
2.6, not the socket's as-built rate-4 leaf chain. Under the chain the absorb
terms double and the invariance fails, which is the open leaf-convention fork.
This pins the arithmetic of the good branch, not that the branch was taken.
… twelve lanes

The commitment workload has a billion INDEPENDENT permutations, so the obvious
follow-on to the whole-state rewrite was to interleave two or four of them and
widen the same trick that already won 5.3x — a portable speedup needing no
SIMD, no unsafe, and no architecture-specific code.

It does not work. Cost per lane rises monotonically with the independent-chain
width, measured on an M-series laptop: 12 lanes 46.2 ns, 24 lanes 51.1, 48
lanes 54.2, 96 lanes 53.7. Twelve chains already saturate the multiplier
pipeline, and past that the working set — a width-24 state is 24 u64 plus seven
addition-chain temporaries — exceeds the general-purpose register file and
spills.

This is the load-bearing NEGATIVE result behind the lane's SIMD verdict: there
is no portable instruction-level parallelism left to extract, so any further
speedup has to come from real vector instructions. Kept as a test rather than
deleted, and asserting its conclusion rather than only printing it, so that if
wider batching ever does start winning, the verdict gets revisited instead of
silently standing on a measurement nobody re-ran.

Worth one run on Zen2 before anyone writes a batching layer there: the
conclusion rests on a register-file argument and that box has a different file.
…, and the program text is hash-generic already

A host FRI commitment-opening proof whose every hash — Merkle leaves, tree
nodes, and the Fiat-Shamir transcript — goes through RPO, verified by the
machine: sponge replay, Merkle-authenticated openings, alpha-combination, two
unnormalized folds, terminal check. Plus the tamper gates that make it mean
something (a tampered opened row must break its Merkle path, a tampered
commitment must break the transcript replay) and the cross-hasher rejection
(an RPO-committed proof must not be provable under Poseidon or Test).

The finding is what did NOT have to change. `programs::fri_toy_program` is
emitted once and unchanged, and nothing in it is RPO-specific: it speaks only
the frozen LFM_HASH socket ops, and which permutation those rows prove is
HasherKind, chosen at AIR-build time. The same program text is a Test-verifier,
a Poseidon-verifier and an RPO-verifier.

That matters for the three-way comparison the campaign wants. The socket-native
world — edsl::merkle_walk, SpongeVar, compress/leaf/transcript_step, with the
host mirror in fixture.rs already parameterised by HasherKind — is a complete,
tested, field-native emitter and commitment pair. It is not something this lane
has to build. A third algebraic candidate joins it by supplying a permutation
and a chip arm, with zero emitter work.

What remains genuinely unbuilt is the PRODUCTION path: crypto/stark commits
under byte-oriented keccak/blake3 with 32-byte two-cell digests, and taking a
real epoch or wrap proof to RPO is where WrapDigest has to become
hash-dependent. That piece is unchanged in scope; this one turned out already
done.
…ation and 2.37x RPO's host speed

Rescue-Prime eXtended (XHash12, eprint 2023/1045) is a round-function swap on
RPO's geometry, not a redesign: same state width 12, same rate 8 and capacity 4,
same four-felt digest, same MDS, and literally the same ARK1/ARK2 tables, which
this module imports from rpo rather than re-deriving so the two cannot drift.
The schedule is what changes — FB E FB E FB E M, where the three FB rounds are
RPO's round verbatim, the three E rounds raise four lane-triples to the seventh
power in the degree-3 extension field with no linear layer at all, and the final
round is MDS plus constants and nothing else.

That attacks precisely the term this lane measured as dominant. The inverse
S-box is 60% of an RPO permutation on the box; RPX runs three inverse layers
where RPO runs seven. Measured on one machine in one run (hash_ladder_throughput,
which times all three candidates and BLAKE3 together so the RATIOS are
machine-independent even where the absolutes are not): RPX 2503 ns against RPO
5922, a 2.37x speedup, and 41x a BLAKE3 parent against RPO's 96x.

It is also narrower in the AIR: 316 value columns and 309 constraints against
RPO's 436 and 429, because an E round commits two extension intermediates per
triple where an FB round commits two ladders per lane, and the M round commits
nothing. Measured cells per permutation, on the same census instrument as every
other column: RPX 325, RPO 445, Poseidon 621.

Degree stays exactly 3 in all three round kinds. The extension seventh power
lowers the same way the base-field one does — commit t2 = x*x and t3 = t2*x,
then write the output as (t3)^2 * x — with each extension operation stating
three base-field coefficients, which is why an E round costs 36 columns rather
than 12. Padding by zero survives all three kinds, so blowup 2 is untouched.

The per-mode capacity prefix is now emitted once and shared by both tenants
rather than restated per arm: RPO and RPX have identical socket geometry, so
that prefix was the one place their AIRs could silently disagree about domain
separation.

PROVENANCE IS WEAKER THAN RPO'S AND THE MODULE SAYS SO. miden publishes no RPX
known-answer table — its tests are structural only. So the anchors are (a) the
shared constants, MDS and FB round, externally anchored through RPO's nineteen
vectors, and (b) the new extension arithmetic, pinned against naive polynomial
multiplication mod x^3 - x - 1 and against generic exponentiation — different
algorithms for the same functions, not a second transcription. A deployment
decision should treat "no published KAT" as a real cost.

NOT XHash8: its extra speed comes from a partial S-box layer, and a partial
layer is one of the three structural footholds this project's own break analysis
identified in the 2026 Poseidon collapse. Flagged in the module header rather
than adopted quietly.
…the variance was the story

The ladder reported RPO at 5,922 ns where this lane's own rpo::throughput
reported 4,712 for the same code on the same machine. Two numbers for one
quantity is exactly the confusion the campaign's per-number labelling rule
exists to prevent, so it was worth finding out which was right.

Neither, quite. Monomorphising the timing helper over the concrete hasher
instead of dispatching through HasherKind moved it only 5,922 to 5,647 — so the
enum was not the cause. The cause is run-to-run variance: rpo::throughput itself
produced 4,712, 5,685 and 6,576 across earlier runs, roughly plus or minus 20%,
which is larger than the gap between two candidates would need to be to matter.
Quoting 4,712 as though it were precise was reading a lucky sample.

So the ladder now takes the best of five runs at 50,000 permutations each. The
minimum is the standard robust estimator for a throughput microbenchmark —
noise only ever adds time — and it reproduces: two consecutive runs gave an
RPX/RPO ratio of 0.56 and 0.55, and Poseidon's absolute agreed to 0.04%.

The correction that matters is which output is load-bearing. Absolutes on this
laptop are not; RATIOS measured within one run are, because the candidates share
conditions. That is why the ladder times all of them together, and it is what
the box column should be scaled by rather than by a laptop absolute.

It also moves the Poseidon reading: best-of-five puts it around 1.4x SLOWER than
RPO, where a single sample had shown the two roughly equal.
… a hash swap as it stands

Every RPO/RPX/Poseidon figure this lane has produced is a proxy: measured chip
cells and measured host ns, extrapolated cells-linearly off BLAKE3's single
measured full-scale prove. The obvious cheap de-risk is the fixture, which
already commits end to end under any tenant — so the question is whether it can
produce one genuinely measured algebraic data point.

As it stands, no. The FRI fixture verifier's LFM_HASH chip is 0.4% of its 15.9M
cells (RPO 56,192; RPX 40,832; Poseidon 78,720), because the program is
dominated by FIXED-height lookup tables that do not scale with its workload.
Swapping RPO for RPX therefore moves the total by about 0.1%, far under this
laptop's own run-to-run variance of roughly 20%.

That is the opposite mix from the aggregator, whose hash table is ~85% of 12.2B
cells and whose fixed floor is a rounding error, and it is the number that
decides how much work a fixture-based de-risk actually is. The sizing it feeds
is in HASH-SWAP-DESIGN.md section F.

Asserting the finding rather than only printing it, so that if the fixture is
ever scaled to where a swap IS measurable, this fails and the sizing gets
revisited instead of standing on a measurement nobody re-ran.
…gainst-machine (A2)

The one open correctness question in the algebraic swap. A wrap program verifies
a proof the host produced, so the in-VM transcript replay must re-derive exactly
the challenges the host derived. Get the encoding wrong and Fiat-Shamir does not
fail loudly: the walk reconstructs nothing, a difference that should have been
non-zero is inverted, and the executor reports DivByZero at an address that
names neither the hash nor the site.

Three facts made it tractable, each checked rather than assumed. The transcript
is a caller-supplied parameter — prove and verify take
`&mut impl IsStarkTranscript` — so this is a new type and nothing the byte path
uses is edited. IsTranscript is already felt-native where it matters:
append_field_element and sample_field_element speak FieldElement, and only
append_bytes and state() are byte-typed. And append_bytes has a tiny regular
call surface: across the whole STARK core it takes exactly two things, 32-byte
Merkle roots and 8-byte integers, and a 32-byte root under an algebraic hash IS
four felts, which is exactly one SpongeVar cell.

The convention: state is one cell, zero-initialised, every step one transcript-
domain LFM_HASH step, identical to SpongeVar because matching it is the point.
append_bytes absorbs a length cell then the payload in 32-byte cells, as
DIGESTS. append_field_element absorbs the three Fp3 coefficients as one DATA
cell through the leaf encoding — a different hash domain, so a program that
absorbs a root cannot claim it absorbed a field element. state() serialises the
four state felts canonically for grinding. sample_u64 masks rather than
rejection-samples, so it consumes exactly one cell per draw: every STARK call
site passes a power of two, where the incumbent's rejection threshold is zero
and its loop never rejects, and a straight-line machine cannot emit a loop whose
trip count depends on a sampled value.

The length prefix is the injectivity argument, not decoration: without it a
32-byte root whose tail is zero and an 8-byte integer holding the same leading
bytes absorb identically. There is a test for exactly that collision, with the
control showing the payload cells really are the same and the prefix is the only
thing separating them. The rule is uniform rather than special-cased on 32 and
8, because a conditional encoding is how a third call shape introduces a
collision later.

The gate is a differential: a program drives SpongeVar through the same sequence
written from the CONVENTION rather than from this implementation, is proved, and
its published challenges are compared against the host's — under Test, Poseidon,
RPO and RPX. If the two sides ever disagree about the encoding they disagree
about the challenges, and that is a failing test rather than a DivByZero.

The type is parameterised by HasherKind, so all four tenants share one
implementation and a fifth costs nothing here.
…ng it

An `#[ignore]`d measurement that prints the artifact build's wall at this host's
rayon width, so the branch is not pushed with an unmeasured claim: run it twice,
once with `LFM_ARTIFACT_PARALLEL=0`, and the two walls bracket the change.

⚠ AND IT CARRIES ITS OWN WARNING, because this lane was misled by it. The
fixtures available to a laptop concentrate almost all their committed felts in
ONE NARROW group, and a `par_iter` over columns can only spread across the
columns that exist — so they are close to the WORST case for exactly the change
they are measuring. A 27 MiB scaled sponge read serial 1.547 s against parallel
1.447 s and I reported that as "a wash, the parallel build is not the lever". The
box then measured **−31% on `build_artifacts`, −21% on the wraps, −20% on level
1**. The conclusion was a statement about the fixture; the measurement was fine.

⇒ The harness now prints the WIDEST GROUP'S COLUMN COUNT beside each wall, and
says in the same breath that lane P measures `emit_lde` at 445% CPU on a real
node — 4.5 of 30.7 cores — which is where the headroom is. A number that cannot
be read wrong is better than a number with a caveat somewhere else.
…st reproduce

Lane K phase 3, derived before any code so it is not re-derived: the host
grinding predicate over AlgebraicDigest<RpxCommit> is the LEAF sponge over
felts_from_bytes of inner_hash || nonce.to_be_bytes() — five big-endian u64
felts (FE::from reduces a raw value >= p once; the inner felts are already
canonical, the nonce may not be), one permutation of
[f0..f3, nonce, 0, 0, 0 | 5, LFML, 0, 0], and the head is the canonical
lane 0 read back big-endian, so on device valid <=> s[0] < limit. The
kernel shape (twin of keccak.cu grind_search, one __noinline__ permute per
candidate), the launcher policy, the pin-decided dispatch key and the PTX,
register and timing predictions are stated with it.
The device grind has been keccak-only, so under the RPX pin every table's
~2^20-trial nonce search ran on the host: one RPX permutation per trial,
2,368 ns each on the 9950X, ~41.9M permutations per base epoch.

`rpx_grind_search` is the twin of keccak.cu's `grind_search` — grid-stride
over `[base, base+count)`, one hash per candidate, `atomicMin` of the first
hit so the launch returns the globally smallest valid nonce in its block.
What differs is the hash, and the mapping it has to reproduce is lane K's
derivation, kept with the kernel: the outer 40 bytes are exactly five
big-endian felts, `f0..f3` the inner hash and `f4` the nonce, sponged as ONE
leaf permutation with capacity `[5, LFML, 0, 0]`, and the host's
`from_be_bytes(digest[..8])` IS canonical lane 0 — so the predicate on device
is `digest[0] < limit`.

The five absorbs go through `rpx::Sponge` rather than writing the twelve
lanes out, so the capacity rule keeps one statement on device and a change to
it cannot leave the grind behind. The nonce lane is `goldilocks::canonical`,
which is `FE::from` exactly; it is a no-op for every reachable nonce, and
absorbing it makes that true by inspection instead of by an argument about
reachability.

The launcher's range walk is now written once and shared by both arms: same
min-factor gate, same block sizing, same sentinel loop. The RPX arm launches
at 128 threads, what every other RPX kernel uses for a twelve-lane state.

The host shim grows `gridDim` and `atomicMin` because `rpx_host_kat.cpp`
includes this file, plus a single-thread driver for grid-stride kernels —
`CUDA_HOST_FOR_EACH_THREAD` leaves a zero stride, which is an unterminated
loop.
…tion's hash

The dispatch keyed the device search on `TypeId == PlatformKeccak256` and sent
everything else to the host. The RPX arm cannot be keyed that way: its digest
is `prover::lfm::algebraic_commit::AlgebraicDigest<RpxCommit>`, and `prover`
depends on this crate rather than the reverse, so there is no type here to
name. The key is `H::COMMITMENT_HASH`, read at the call site.

★ `H`'s constant, NOT the global `config::COMMITMENT_HASH`. The global names
the aliases' hash and stays BLAKE3 under the RPX pin — `hash_pin` pins
`BlockStarkHash` separately, on purpose, so the two can differ on a branch.
A dispatch keyed on the global would read BLAKE3 while the block proved under
RPX and the arm would be dead code that still compiled. `config.rs` says as
much about which of the two constants a call site may read; this is that rule
applied.

What the key does not settle — that a configuration naming `Rpx256` also
transcripts with RPX — is left to the unconditional host validation that was
already there: a mismatch costs one device search per table and falls back
loudly, and can never append an unverifiable nonce to the transcript. The
keccak arm keeps its `TypeId` check on top of the key, so its behaviour is
unchanged.

`inner_hash_felts` is the big-endian reading of the inner hash, next to the
little-endian `inner_hash_lanes` keccak needs and named separately rather than
flagged: the two read the same bytes in opposite orders, and crossing them
compiles, runs, and silently searches for a nonce under a message the host
never hashes.

Bycatch: the grinding factor is read into a local called `grinding_factor`.
It was `security_bits`, which it is not.
… with no GPU

The grind kernel is the one RPX kernel whose correctness is a claim about a
HOST predicate rather than about the permutation: it must find the nonces
`stark::grinding::is_valid_nonce` accepts over `AlgebraicDigest<RpxCommit>`.
The two reach the sponge by different routes — the host through a byte buffer
and `felts_from_bytes`, the kernel by building the five felts directly — so
the agreement is the thing to pin, and per-PR CI has no GPU to pin it on.

Table 5 of the oracle is that agreement. The generator takes the smallest
valid nonce through the production predicate and asserts, over the whole
scanned range, that an explicit five-felt block answers identically; a row
that passed only one of the two routes would not print. Layer 8 of the
host-compiled harness then replays `rpx_grind_search` single-threaded over
those rows: the nonce, its minimality (nothing in `[0, nonce)`, which also
exercises the not-found path the launcher's range walk depends on), that
`base` participates, and the endianness control — the little-endian reading
of the same inner hash, the one keccak uses, finds NOTHING where the correct
reading finds the nonce.

Not vacuous: four mutations of the kernel were each rejected — padding flag
5 → 4 (7 failures), the first two absorbs swapped (6), the head read from
lane 1 (7), and the loop bound off by one (3).

Tables 2-4 regenerate byte-identical.
… reaching it

The kernel's arithmetic is pinned without a GPU by the host harness. What
needs one, and is here: that a real parallel launch reduces to the same
answer, and that the production dispatch actually reaches the device.

The first two tests assert validity under `is_valid_nonce` — never a proof
byte, and never a comparison with the CPU search, whose `find_any` does not
agree with itself between runs. At factor 14 the returned nonce must also be
the smallest, which is the probe of search completeness plain validity cannot
make: a stride or bounds defect still returns a *valid* nonce, just not the
first one. It is deterministic despite the parallel grid because `atomicMin`
is order-independent — the property the single-threaded host harness cannot
exercise and this test exists for.

The third is the one that can fail for the reason this feature can fail. Both
dispatch arms return a valid nonce whatever happens, because the CPU fallback
is correct, so validity alone would pass with the device never touched.
`gpu_grind_calls` counts only device searches whose nonce passed the host
check, so the deltas are the claim: +1 under `Rpx256`, and unchanged under
`Blake3`, the control that makes the first delta mean something.

Run on the box with

  cargo test -p lambda-vm-prover --release --features cuda --test rpx_device_parity -- --nocapture
…ating a felt Vec

Every `AlgebraicDigest` finalize built a `Vec<FE>` through `felts_from_bytes`
and handed it to `sponge_leaf`, which consumes the felts one rate block at a
time and never looks back. The felt count the capacity needs is `ceil(len/8)`,
known from the length alone, so the Vec bought nothing. Under the algebraic
pin it was one of the two heap allocations every proof-of-work grinding trial
paid, and grinding is ~2^20 trials per table.

MEASURED, paired ABBA over 2.4M calls on the 40-byte grinding message, three
runs: 691 / 322 / 269 ns per trial, 8.6% / 5.5% / 4.3% of the trial's hash.
The laptop is under contention from other lanes, which is why the pairing is
not optional — unpaired runs moved the untouched `permute` by 45% between
them.

⚠ The OTHER allocation is deliberately left: `AlgebraicDigest`'s own `buf`
Vec. Removing it means reusing one digest instance across the rayon search,
and the same paired A/B puts the prize at −60 to −75 ns, i.e. indistinguishable
from zero and on the wrong side of it. It is not worth restructuring
`find_any` for.

`sponge_leaf_bytes` is equivalent to the two-step form BY TEST, not by
construction: the test walks every length across two rate blocks, for all
three tenants, and the trailing group's zero-extension side is exactly the
thing that is easy to get backwards — extending on the high side instead of
the low fails it at one byte.

The host search gets its own gate too. It is the arm every non-GPU build,
device error and `LAMBDA_VM_NO_GPU_GRIND` falls back to, and it now runs on
the changed code path. A break that moved the search and the verifier's check
TOGETHER would leave a self-consistent prover, so the cross-hash control —
BLAKE3 work must not satisfy the RPX predicate — is what makes it more than a
tautology.
…nd-trip test

Inserting two tests above it displaced them onto the first of the new ones,
which left `a_digest_round_trips_through_its_commitment_bytes` without a
`#[test]` — silently not run — and gave a neighbour two. `make lint` catches
the duplicate; nothing catches the orphan, so the test count is checked
against the base: 14 before, 16 after, and the only new functions are the
three this branch adds.
…evice

`gpu_lde` already does, for the main trace, exactly what the LFM artifact build
does on the host for every program's instruction column groups: coset LDE, leaf
hash and Merkle tree. Lane P measured the host version at 12.6 s per epoch wrap
and 22.0 s per interior node — the largest phase of the recursion pipeline —
with the card at 1 MiB and 0% throughout. The only thing standing between the
two was that every entry point in the module is `pub(crate)` and the caller is in
another crate.

`try_commit_row_major` is that entry point. A named `pub` wrapper rather than a
visibility change on the dispatch layer, so the caller sees one function with one
contract instead of the module's internals.

It returns the ROOT and nothing else, because that is all the artifact build
wants: the root goes into `lfm_program_id` and into the AIR's declared
commitment, and the nodes have no host reader. The tree stays resident on the
device in the handle, which is dropped here, and `retain_host_lde = false` skips
the row-major device-to-host copy entirely.

⚠ It deliberately does NOT feed the process-wide precomputed-tree cache.
`precomputed_tree_cache_put` takes a FULL host tree because `multi_prove` opens
against it, and a root-only tree in that cache would be one that cannot answer a
query — a silent failure, not a loud one. Nothing is lost: `multi_prove` already
builds and caches its own precomputed tree on the device
(`try_expand_split_trees_row_major_keep` with `build_precomputed`).

⛔ THE WEIGHTS ARE NOW ONE DERIVATION, and that is the load-bearing part of this
commit. `coset_weights` — `[n_inv, n_inv·g, n_inv·g², …]`, the iFFT normalization
folded with the coset generator's powers — was written inline inside
`LdeTwiddles::new`. It now has two readers: a table's weights for the prove, and
a preprocessed group's weights for a commit taken outside any prove. Left inline
in both, a change to the normalization would move one path's roots and not the
other's — and the two are REQUIRED to agree bit for bit, because a proof declares
the root the artifact build produced.

★ That agreement is not a hope; it is already a production invariant.
`multi_prove` rebuilds the precomputed tree on the device from the row-major main
trace and REFUSES the proof when its root differs from the one the AIR declares
(`ProvingError::PrecomputedCommitmentMismatch`); for an LFM proof the declared
root is exactly what the host artifact build produced, and every GPU recursion
proof passes that check today. So the device leaf convention and
`commit_bit_reversed_with(.., ROWS_PER_LEAF)` already agree on this class of
matrix.

⚠ NOT MEASURED HERE, and it cannot be on this machine: there is no local CUDA, so
this is verified by reading plus a `--features cuda` compile plus the non-cuda
build. The roots gate is the six `registry_drift_*` tests, which must be run on
the box against a cuda build before any number is claimed.
`build_artifacts_with_hasher` ran the whole preprocessed commit pass on the host
— interpolate, coset-expand, Merkle — even on a cuda build, because
`commit_lde_columns` has no device path. Lane P measured the result: 12.6 s per
epoch wrap and 22.0 s per interior node, the largest phase of the recursion
pipeline, with the card at 1 MiB and 0% throughout. The identical operation on
the main trace has run on the GPU inside `r1_main_commit` all along.

`commit_group_device_or_host` sends each group to `stark::gpu_lde`'s fused call
and falls back to the host pass when there is no device, when the field is not
one the kernels take, or when admission declines the shape.

★ THE GROUP IS ALREADY IN THE RIGHT LAYOUT, which is what makes this small.
`ColumnGroup.data` is row-major `padded_rows × width` — exactly what the fused
commit takes — so the device path skips `group_columns` entirely. That transpose
is a strided gather over the whole group and exists only to feed the host's
column-major pipeline.

⚠ WHAT THIS DOES NOT DO. It does not put anything in the process-wide
precomputed-tree cache. That cache holds FULL host trees because `multi_prove`
opens against them, and the fused call deliberately returns a root-only host tree
with the nodes resident on the card. Nothing is lost: `multi_prove` already builds
and caches its own precomputed tree on the device. The tree cache-put is
therefore NOT folded in here, and was sized at 1.3–1.6% separately.

THE DEVICE SET IS REPORTED, NOT GUESSED. `device_artifact_peak_bytes` carries the
largest working set any artifact commit asked for, taken from
`stark::device_set`'s own term-by-term accounting — one LDE buffer, the
trace-domain snapshot, one full Merkle node buffer, the scratch — which is the
number admission decides on. ⓘ It over-states this caller by the snapshot term:
the fused call sizes with `snapshot = true` because the main commit needs the
pre-NTT column-major copy for the LogUp fingerprint kernel, and an artifact
commit has no such reader. That is `padded_rows · width · 8` of head-room, not a
leak, and narrowing it means a `snapshot` parameter on the shared entry point.

`LFM_DEVICE_ARTIFACTS=0` forces the host pass and is the A/B control.

⛔ NOT MEASURED, AND NOT MEASURABLE HERE. This machine has no CUDA, so the device
path has never executed: what is verified is a `--features cuda` compile of
`stark` and `prover` (lib and tests), a non-cuda compile, and the host-path gates
— 9/9 including the six `registry_drift_*` pins, which still exercise the HOST
branch because that is the branch this build takes.

⇒ The roots gate has to run on the box, against a cuda build, BEFORE any timing
is claimed: `registry_drift_*` rebuilds every registered program's artifacts and
compares roots, heights and `program_id` against blessed constants, so a device
leaf convention that differed fails there immediately. If it passes, the
pre-registered band is per-node `build_artifacts` 15.2 → 5–9 s and per wrap
9.8 → 3–5 s, with under 30% off `build_artifacts` counting as the hypothesis
failing.
`make lint` caught what `cargo check -p stark --features cuda --lib` could not:
the `what` parameter added one commit ago has a second caller in the cuda-gated
admission box test, and it takes the function past clippy's seven-argument bound.

⚠ The lesson is the check, not the fix. `--lib` does not build test targets, and
the cuda test module only exists under that feature, so the two arms I ran —
non-cuda lib+tests and cuda lib — between them never compiled the one file that
broke. `cargo clippy -p stark --features cuda --all-targets` is what would have,
and it is what `make lint` runs.

The test gets a `what` of its own rather than inheriting the main commit's, so an
abort from the admission box says where it came from. The `allow` matches the
split-trees call beside it, which carries the same one for the same reason.
…sked for

O1 sends the preprocessed commits to the card, and "what did that cost the
device" should not be a question anyone has to go and ask. The per-level line
already exists and is where a box run is read, so the number goes there:

    level 0: 19 proofs, 19 distinct programs, artifacts 4.5s · device set 2.31 GiB (N/N …)

⛔ It is what the build ASKED FOR, not a sampler reading, and the distinction is
the whole value of it. The figure is `stark::device_set`'s own term-by-term
accounting — one LDE buffer, the trace-domain snapshot, one full Merkle node
buffer, the scratch — which is the number `admit_commit` decides on. So a run
that was DECLINED and fell back to the host is explained by this line, where a
sampler would only show the card idle. What the process actually held is the
sampler's answer, and the two are worth reading together.

A process maximum rather than the level's own: the commits are sequential and
the card is released between them, so the largest single set is what the build
ever needed.

Zero on a host build — every non-cuda build, and any cuda build where admission
declined every group — and the line then omits it entirely rather than printing
"0.00 GiB", so a host run reads exactly as it did before.
…the gate that does

Asked whether the green `registry_drift` run under `--features cuda` exercised
the device branch at all, or compared a host root with a host root. The answer is
neither, exactly, and the measurement is now in the tree.

THE DECISION, as written: `gpu_lde` admits on
`lde_size = padded_rows · blowup >= 2^14` — a ROW count, not bytes and not
columns (`DEFAULT_GPU_LDE_THRESHOLD`, and the note beside it explains that a
cells floor would degenerate at FRI's width-1 re-derivation). Below it the commit
falls back to the host.

✓ MEASURED over the registered fixtures, at both blowup 2 (what the drift tests
use) and blowup 4, printed by `the_fixture_groups_against_the_device_floor`:

  EXACTLY ONE group per fixture clears the floor, and it is the same group in all
  six — slot 10, `LFM_RANGE`, 65,536 rows × 1 column, which is
  program-INDEPENDENT. Every program-dependent group is far below it; the largest
  is `statement_replay`'s slot 1 at 4,096 rows (lde 8,192 at blowup 2). Most are
  at the 4-row pad floor.

⇒ So the gate DID run the device path and its root DID match a blessed constant —
but it is ONE device observation repeated six times, at the narrowest shape the
machine has, on a group no program can change. It is evidence, and it is nowhere
near coverage: no program-dependent group, no realistic width, has been committed
on a device and checked.

THE GATE THAT DOES REACH IT: `the_device_commit_matches_the_host_commit_above_the_floor`
commits a group ABOVE the floor both ways and compares roots, at the production
width extremes — 1 (`LFM_RANGE`), 20 (`LFM_BLAKE3`), 134 (`LFM_BITDEC`, the
widest prep group there is). It asserts its own premise first, so a shape that
silently fell below the floor fails loudly instead of passing as a host-host
tautology.

⛔ It is `#[cfg(feature = "cuda")]`. Without that feature both sides ARE the host
pass and the test cannot fail — and a check that cannot fail is worse than no
check, because it reads like coverage. It exists only where it bites.

Values are position-dependent (a multiply-xor of the flat index) so a transposed
or mis-strided read cannot land on the same root by symmetry.
O1's first box number was a wrap saving of 9.7 s against a pre-change artifact
build of 9.8 s — the whole phase, not the 3-5 s residual that was pre-registered.
A saving that large is only explicable if almost every committed group left the
host, and nothing in the run said how many did.

`gpu_lde` admits on `padded_rows · blowup >= 2^14`, so a program's SHORT groups
stay on the host whatever the card is doing. Without the split, "the artifact
build got faster" cannot distinguish "the big groups moved and the small ones
stayed" from "everything moved", and the two imply different residual host time
— which is exactly the quantity in question.

The level line now carries it:

    level 0: 19 proofs, 19 distinct programs, artifacts 0.1s · device set 2.31 GiB · groups 7/11 on device

⇒ A reader can now check the saving against the shapes instead of inferring it.
Process totals, like the peak, because the floor is a process constant and the
groups repeat per proof.
A gate that commits on the host and compares a host root with a host root is
green and evidence of nothing. That is not hypothetical — it is what the six
`registry_drift_*` pins nearly are: ✓ MEASURED, exactly ONE of their eleven
groups clears `gpu_lde`'s `padded_rows · blowup >= 2^14` floor, and it is
`LFM_RANGE` at 65,536 × 1, program-INDEPENDENT and identical in all six.

`the_block_root_proves_over_real_children` is the opposite case: its groups are
program-dependent and tall. ✓ VERIFIED that the mapping from a chip's trace
height to its group height is the identity — `chip_trace` opens `rows =
group.padded_rows` and copies the group into the first `group.width` columns of
each row (`prover/src/lfm/trace.rs:100,183`), which is also why the AIR's
declared precomputed root IS the artifact root.

⇒ So under cuda this gate CAN assert what it exercises, and now does. The
message names the two things that would make it fire — a fixture that shrank
below the floor, or `LFM_DEVICE_ARTIFACTS=0` left set — because a bare "0 device
groups" would send the reader hunting.

`#[cfg(feature = "cuda")]`: on a host build the count is legitimately zero and
the assert would be a false alarm.

⚠ This pins the device path in the tree, but it is a 44-minute box run.
`the_device_commit_matches_the_host_commit_above_the_floor` pins the same claim
at production widths in seconds, and is the gate to run first.
…he closure's output half has a subject

`epoch_tests::the_closure_rejects_a_moved_index_or_output` has been failing on its
own anti-vacuity guard — "the fixture epoch must actually commit output, or this
proves nothing" — since `c150dbc8` re-measured the fixture's epoch size on
2026-08-15. The guard was right and the test was never the problem, so this moves
the FIXTURE and leaves every assertion alone.

The shape it was stuck in is structural, not a mis-tuned constant.
`EpochFront::build` asserts `!is_final`: it harvests an INTERMEDIATE epoch on
purpose. `bench_vs/lambda/fibonacci` commits once, immediately before `halt()`.
Those two together put the public output in an epoch that harness cannot reach, at
EVERY epoch size — the run is 15 cycles with the commit at 11, so 8-cycle epochs
leave the commit in epoch 1 (final) and 16-cycle ones swallow the whole run into a
single final epoch. The test passed originally only because the then-current ELF
ran a little over 16 cycles and the commit fell one cycle inside epoch 0. A private
input does not rescue it either: the commit stays at the end, wherever the end is.

So the guest has to commit and then keep going, and `fibonacci` cannot be that
guest. `bench_vs/run.sh` builds it as the Lambda VM arm of a cross-prover benchmark
against `bench_vs/sp1/fibonacci`; a tail loop that one arm runs and the other does
not stops the two measuring the same program. `continuation-fixture` is its sibling
plus a bounded tail, added to `RECURSION_GUESTS` and named by `FIXTURE_INNER_ELF`.

Measured by counting logs under `Executor::resume_with_limit`: the commit lands at
cycle 13 and the run is 48 cycles, one tail step costing two (10 steps gave 36, 16
gave 48). `FIXTURE_EPOCH_LOG2` moves 3 -> 5, putting the boundary at 32 with three
margins — 19 cycles commit-to-boundary, 16 boundary-to-halt, 16 halt-to-64 — so
epoch 0 carries the output, epoch 0 is intermediate, and there are exactly two
epochs. Ten steps would also "work", with four cycles to spare; that is the state
this change exists to get out of, since the old constant was a four-cycle question
and the answer moved under it when the toolchain rebuilt the ELF shorter.

★ And the epoch COUNT did not move when that happened, which is why
`continuation_fixture_generates_two_epochs` never rang. The count is necessary and
was never sufficient — a run can split in two with its commit on either side of the
boundary. `proof_fixture::tests::the_fixture_guest_commits_in_an_intermediate_epoch`
asserts all three margins in the currency the guest's `TAIL_STEPS` is denominated
in, so the property is checked rather than recorded in a comment.

⚠ The tail's arithmetic is more fibonacci rather than anything with a constant in
it, and that is not a style choice — it cost a red test to learn.
`recursion::precomputed_commitments` lists every ELF page whose
`init_values.is_some()`, a pure function of the BINARY and not of the run, and
`program_id_matches_production_on_the_real_fixture` asserts the fixture carries
none. A 64-bit LCG multiplier is not materialisable inline on rv64, so LLVM spills
it to a constant pool and the guest grows a `.rodata` section `fibonacci` does not
have. Wrapping adds of values already in registers keep the section table identical
to `fibonacci`'s: `.text` and nothing else, verified with `llvm-readelf`. The
volatile store stays, because the sink is never read back and anything weaker is
dead code the optimiser may delete — deleting it would silently restore the shape
this guest exists to avoid. It targets a stack local, which is not an ELF page and
so not in that accounting.

⚠ For the next reader: the blob cache keys on the ELF NAME and the epoch size, not
on the ELF's contents, so editing the guest source and rebuilding leaves the stale
blob in place and the page assert keeps failing against it. Delete
`$TMPDIR/lfm-r1f-continuation-fixture-*` when the guest changes.

Blob size at the new shape: 596,828 bytes, against 947,340 recorded for the old
one. Epoch 0 now proves in ~25s rather than ~20s, four times the cycles.
…y is ABOUT

`machine_tests::transcript_replay_cell_counts` reads 0 compressions against a
pinned 8. Same family as `558faa5d` and the same mover — `603c1e15` took
`WrapHash::production()` to `Algebraic` — but it lands on the opposite side of the
carve-out, and the difference is the whole fix.

The REGISTER derivation had to follow the pin, because the root it builds has to
equal one production committed under it. This program must not. It is the R1d
instrument, and `WrapHash::production`'s own doc names it: the R1b/R1c/R1d
instruments spell their hash directly and must keep doing so. Its script is a BYTE
sponge — the table over `transcript_replay_program_source` is denominated in 32-
and 136-byte segments and in which rate block each of five squeezes lands in, with
one segment deliberately spanning two. None of that is a claim an algebraic sponge
can be right or wrong about; there is no re-derived count for that arm, only an
absence.

So the program keeps its hash and the MEASUREMENT stops following the pin.
`TRANSCRIPT_REPLAY_WRAP_HASH` names it once, in `programs.rs`, and the builder and
the count both read it — a single source, so the two cannot drift the way they
just did. `wrap_hash_rows_at` is `wrap_hash_rows` against a named hash;
`wrap_hash_rows` is now that function applied to the pin, so nothing else moves.

The pinned 8 is unchanged and needed no re-derivation: it was already the BLAKE3
number, re-derived when that arm arrived because a 136-byte keccak rate does not
divide this script the way a 64-byte block does. What the pin had lost was only
the ability to say which hash it was counting.
…ounts twelve

Two reporting defects the box found, neither affecting a root.

1. THE COUNTERS WERE PROCESS-CUMULATIVE AND THE LINE READ AS PER-LEVEL. Level 0
printed `152/228`, level 1 printed `256/384` — totals, so a reader wanting level
1's own had to subtract two lines by hand. The window now snapshots the counters
when it opens and reports the DELTA. The process totals stay in `commit`, where a
running count against a process-constant floor is the useful thing to keep.

⚠ And the peak does NOT become per-level, because it cannot: it is an atomic
maximum and a window cannot un-see one. So the line now says which scope each
figure has — `groups D/T on device` is this level's, `device set <= X GiB
(process max)` is not. Two scopes on one line is fine; two scopes that both look
per-level is not.

2. THE FLOOR DIAGNOSTIC WALKED ELEVEN GROUPS AND THE BUILD COMMITS TWELVE.
`build_artifacts_with_hasher` commits `program_groups` (10) plus `LFM_RANGE`, and
THEN one group per `LFM_BLAKE3` chunk in a second loop. Slot 11 is committed
whether or not the family is used — the digest binds it either way, and the
registry says so at the chunk-roots comment. A walk that stopped at `range`
undercounted by the chunk count, which is exactly how this lane predicted "8 of
11" for a production wrap when the box read 8 of 12.

✓ The on-device 8 was right; the miscount was the denominator, and it was mine to
catch before the box did. The diagnostic now prints slot 11 with the rest:
4 rows × 20 columns for every fixture that never compresses, far under the floor.

⇒ The 12th group is `LFM_BLAKE3` chunk 0, and it is a host group in a production
wrap for a reason that is verifiable rather than incidental: lane P's per-table
panel lists FOURTEEN tables for a wrap and `LFM_BLAKE3` is not among them, so the
chip mask dropped it; `ChipSet::for_program` sets `blake3` from
`groups.blake3.real_rows > 0`, so masked means the group is empty, which pads to
4 rows and cannot clear a 2^14 lde floor at any blowup.

ⓘ `blake3_chunk_group`'s doc still says "the aggregation program's group is
~1.39M rows x 20 columns". That was true when `LFM_HASH` was BLAKE3; under the
RPX pin an aggregation program compresses nothing. Left alone here — it is a
one-line doc fix in a file this commit has no other business in — but it is
stale, and it is the sentence that would talk the next reader out of the
paragraph above.
… their phases

Four timers, no behaviour change and no feature gate. Together they close the
block with no gap wide enough to hide a lever in.

(a) `lfm_prove_with_residency` records `execute · fill · multi_prove`. The
driver's TIMING line prints this function as ONE number, and the three
statements inside it are three different machines — a single-threaded
interpreter, a parallel trace fill, and the only one that reaches the card.
Recorded in a per-thread cell rather than printed, for three reasons: this file
is a production path and the module's one existing diagnostic is a `log::info!`,
not a `println!`; a stage LOADED from cache did not prove, so the driver stays
silent instead of reprinting the previous stage's numbers; and a per-thread cell
stays attributable when sibling proofs run concurrently, which a bare print in a
shared function does not.

(b) The level-0 loop prints a per-wrap TIMING line. Level 0 is 49% of the block
and printed ONE number for nineteen wraps, so every per-phase figure ever
published for a wrap was derived by subtracting an assumed term from a level
wall. It now prints the same five fields the interior node has printed all
along, plus the wrap wall, so the account is closed and the residual is visible.

(c) `harvest_real_epoch` separates its two halves: the complete host STARK
verify of the base epoch's sub-proofs, whose only use is to refuse, from the
Phase A replay and per-table forks a driver genuinely needs. Reported as one
`reconstruct` figure they are indistinguishable, which is why the block's
production-equivalent time has only ever been quotable as a band.

(d) `real_child`'s opening assert is timed, so `harvest` reads
`(verify + replay)`. Same reason: one owner is the harness, the other is the
pipeline, and the sum prices a phase no real pipeline has.

⛔ Both asserts are TIMED, never skipped, and no knob skips them. An epoch
nothing verified is not a faster epoch, it is a different experiment.

Gated by a unit test that fails without the recording, verified by removing the
recording and watching it fail: the split must be present after a prove, must
cover the prove rather than a corner of it, and must be consumed exactly once.
…ever builds

Step 2's prerequisite, on its own so the scheduling commit that follows changes
no counting.

The level window is process-global behind a `Mutex`, and membership in it was
ambient: any thread calling `build_artifacts_counted` while a window was open
was counted as part of that level. That already shipped a defect — at
`--test-threads=2` the counting test read 5 proofs where it had made 3, and the
first run that happened to serialize them passed. The fix available then was to
serialize the builders. It is not available to a level that proves its siblings
concurrently, because concurrent builders are the point.

⇒ Membership is now EXPLICIT. `begin_level` enrols the calling thread; a worker
takes an `Enrolment` for as long as it is part of the level; `record` ignores a
build from any thread that holds neither. A stray builder cannot inflate a level
no matter what it builds or when — unreachable rather than unlikely, and the
same guarantee covers the serial path, which is where it actually bit.

`Enrolment::drop` does not use the panicking lock. It runs during unwinding when
a worker panics, and a panic inside a `Drop` that is already unwinding aborts
the process. A counter is not worth that.

Second, `artifacts Xs` stops being a wall the moment a level runs concurrently:
two overlapping 9 s builds sum to 18 s inside a 9 s window. Serial they are the
same number, which is why this needs saying BEFORE anything runs concurrently —
a figure that has been a wall for the whole campaign would quietly become
something else and read as a regression. The window now keeps its own elapsed
time and the level line names the sum as a sum when the two disagree. While
serial the line is byte-identical to every one already in the campaign's logs,
and there is a test for that too.

Three gates, each verified to fail without the code it gates: an un-enrolled
thread's build must not be counted; an enrolled worker's build must be; and the
line must acquire the clause only when the sum exceeds its window.
…ady does

The level-0 loop now calls `census_and_panel`, so each wrap prints its cells,
its instruction count, the empty-machine floor and the chip panel — the lines
every node has printed since the census went in.

Why it is worth two lines. With only a clock, a wrap can be priced but not
explained: nothing says whether it is dear because of its instruction count or
because of its cells, and those point at different levers — the executor in the
first case, the prover in the second. The arm at `94540566` measured a wrap's
prove at 4.75 s and an arity-2 level-1 node's at 6.05 s, and the node's census
is the only reason the second number can be turned into a coefficient. Level 0
is 49% of the block and was the half without one.

`fan_in` is 1: a wrap consumes ONE epoch, so the panel's step line reads as "at
twice this epoch size", which is the posture question actually asked of a wrap.

Placed OUTSIDE the five timed fields, exactly as the node's census sits outside
its TIMING line, so every field stays comparable to the arm that measured them.
The cost lands in `wall`, and the residual comment now names it.
…proof

Step 2's gate, on its own and inert. Nothing arms it yet, so no run changes.

A proof reaches the card TWICE and neither entry can see the other.
`commit_group_device_or_host` dispatches to `gpu_lde` from inside
`build_artifacts`, outside `multi_prove` and so outside every `VramGate`, and
its only check is `device_set::admit_bytes` — a `const fn` comparing ONE
dispatch's bytes against the WHOLE card budget, with no running total. And each
`multi_prove` builds a fresh `VramGate` with the full budget, keeping a total
over the tables of its own prove and nothing else. Two proofs in flight can
therefore ask the card for twice its budget from two directions, which is the
two-VramGates condition that caused the production base aborts, one level up.

So the permit is MUTUAL EXCLUSION and cannot be a byte budget: a byte-budget
permit would admit a second holder because its bytes fit, against a budget the
first is already spending.

⛔ TAKEN TWICE, NOT HELD ACROSS BOTH PHASES. A node runs host, build_artifacts,
host, multi_prove, host — the two device phases are not adjacent. On the
measured arity-2 level-1 node the executor and fill between them are 2.53 s of
7.66 s, so holding across both covers 6.33 s and floors a level at 83% of its
serial wall, while releasing between them covers 3.80 s and floors it at half.
Overlapping the executor IS the lever. Mutual exclusion is unaffected: at every
instant at most one proof is inside a device phase.

Releasing there is safe because the artifact commit keeps nothing:
`try_commit_row_major` binds `(tree, _handle, _lde)` and returns the root, and
`_handle` — the GpuLdeBase holding the device tree and buffers — drops at
function exit.

Two hazards are named rather than suffered. A second hold on a thread that
already holds one PANICS instead of parking, because a silent park is how the
census memo cost twelve minutes of a test binary at 0% CPU. And the acquire
asserts that it is the only holder, so "two holders at once" fails at the
instant it happens rather than being inferred afterwards from a VRAM abort.

The level line reports occupancy, not just safety: acquisitions, max holders,
and the held time against the level's wall — which is what says whether a level
was bound by the card or by the host.

⚠ THE FIRST VERSION OF THE FALSIFIER TEST COULD NOT FAIL. It spun two workers
through twenty fast acquisitions and asserted the peak was one; with the
exclusion removed it still passed, because a nanosecond critical section never
overlapped. The overlap is now forced: the first worker keeps the card, the
second is released only once the first is inside and then times its own
acquire. Verified against the same control, it now fails.
…at a time

The lever. `LFM_TREE_SIBLINGS` proofs of one interior level run at once, each
taking the shared card permit across each of its two device phases and
releasing it between them, so one proof's LFM executor and trace fill overlap
another's time on the card.

Sized from the arm at `94540566`, not from a model. An arity-2 level-1 node is
device 3.80 s against host 3.86 s, so a level is device-bound at two siblings
and the floor is the device sum: level 1 is 36.3 s of its 75.5 s serial wall.
A wrap is device 2.85 s against host 7.90 s, one to 2.8 — a different machine,
which is why this is armed for the INTERIOR only and whoever extends it to
level 0 has to re-derive the count rather than inherit it.

⛔ UNSET OR 1 IS THE CONTROL AND RUNS THE ORIGINAL PATH: no threads, no permit,
no sampler change. An A/B whose control is the parallel code with one worker
measures the scheduler against itself and hides a constant cost in both arms.
The permit is likewise armed immediately before the interior levels and
disarmed immediately after, so the base and the epoch wraps are untouched and a
level-0 number from this binary is comparable to one from any earlier tip.

⚠ NOT `TABLE_PARALLELISM`, which is how many TABLES one prove puts on the card
and is governed by that prove's own VramGate. This is how many PROOFS are in
flight. The two multiply: at TABLE_PARALLELISM=4 and SIBLINGS=2 the card still
sees one proof's four tables, because the permit admits one proof at a time.

Scheduling is invisible to the bytes, and `in_index_order` is where that is
enforced. A level's children, layouts and label runs are three parallel vectors
and the level above takes contiguous subslices of each — which is what makes
contiguity across sibling subtrees a consequence of the label pins rather than
a check of its own. Drain them in completion order and the pins still verify,
one subtree at a time, while the tree they describe is not the tree that was
built. So results land in per-index slots and are drained by index. The test
forces the completion order to the reverse of the index order, and a
completion-order drain fails it.

Three things the levels now print. Their own host peak, because the per-node
figures become process-wide readings inside overlapping windows the moment
siblings run together, and that peak is what the 52 GiB stop is about — the
per-node line says so on itself rather than in a note. The permit's max holders,
which must read 1 and is the falsifier's own evidence rather than an inference
from a VRAM abort. And the permit's held time against the level wall, which is
what says whether the card or the host was the wall, and therefore whether a
higher sibling count would buy anything.

A worker panic is re-raised on the caller's thread with its payload intact.
`std::thread::scope` otherwise propagates the fixed string "a scoped thread
panicked" and libtest's global hook drops a spawned thread's own message, which
cost the prover eleven anonymous failures in one suite run.
…either name

Two things the A/B needs that did not exist.

★ THE IDENTITY LINE. Nothing printed a node's program identity, its group
heights, or its published word count — grep the driver for `program_id` or
`log_heights` and there are no prints at all. So "the bytes did not move" could
only be argued from walls and census shapes, neither of which binds a committed
root. Each node now prints its `program_id` prefix, its per-chip log heights,
its LFM_BLAKE3 chunk heights and its published word count, so a serial arm and
a concurrent one are compared by diffing their IDENTITY lines and an EMPTY DIFF
IS THE PROOF.

`program_id` is the right fingerprint because it is what a PARENT absorbs: a
digest over every group root, the chunk-root tail, the heights, the chip set
and the hasher. Move any committed felt and it moves. The heights are printed
beside it because when the digest does move, they say WHICH shape did, and a
bare digest mismatch prices no debugging.

★ EITHER SPELLING OF THE KNOB. `LFM_TREE_K` and `LFM_TREE_SIBLINGS` name the
same thing, and set to different values the run refuses. This is not
indecision: a launch line naming a knob the driver does not read fails
SILENTLY — the run goes serial, reports no speed-up, and the lever is filed as
refuted by an arm that never armed it. Four lines removes that whole class of
result.

And the driver PRINTS the resolved count before it arms anything, so the log
says what the run did rather than what the launcher meant. A knob that never
reached the process is otherwise indistinguishable from a lever that did not
work, and the second reading is the one that gets written down.

The resolution is a pure function of two `Option<&str>`, so the contradiction,
the empty-is-unset spelling, a non-integer and a zero count are all tested
without mutating process state.
…e worker and two

The level-2 A/B exposed it: the same node's `prove` field read 4.8 and 4.1
serially and 5.0 and 7.1 at two siblings. None of that is work. A queued
worker's wait for the card lands inside whatever phase was running when it
asked, so `prove` absorbed it and the two arms stopped being comparable —
which is the one thing a scheduling A/B needs them to be.

`multi_prove` is now reported NET of the wait, and the wait is its own field on
both lines. The node's TIMING line says explicitly that the wait is CONTAINED in
`build_artifacts` and `prove` rather than additional to them, and splits it
between the two, because those are wall times and a reader cannot otherwise tell
which phase queued.

That also closes the level's accounting. At K workers a level spends K × wall
worker-seconds, of which the permit's `held` is the card, the per-node waits are
the queue, and the remainder is host work. Level 2 measured `held 13.3s of
19.5s` with no way to price the other 25.7 worker-seconds; now there is one.

The counter is MONOTONE and per-thread, read-only, never cleared. A
take-and-clear counter couples every reader to every other one: whoever samples
first silently steals the wait from whoever samples next. Callers bracket the
span they care about and subtract, which composes — the driver does it around
the artifact build and `lfm_prove_with_residency` does it around `multi_prove`.

Both new clauses are ABSENT when the wait is zero, so a serial line stays
byte-identical to every one already in the campaign's logs and the arms diff on
their numbers rather than on their shape.

Gated on the waiting thread itself: the blocked worker's self-reported wait must
be at least half the hold and must agree with the wait observed from outside to
within 50 ms. And the prove-split coverage floor now counts the wait, without
which it would start failing in the one regime it most needs to hold in.
… order

The level-2 ABBA caught it: the second lever arm printed L2N1 before L2N0,
because a worker prints when it FINISHES. A raw diff of those lines therefore
files a scheduling order as a byte difference, which is a false red on the one
gate that exists to rule byte differences in or out.

Sorting both sides is a correct remedy and remains sound — each line begins
with its own node label, so a permuted tree still sorts differently and the
check keeps its teeth. But it is a step a reader has to remember, and the reader
who forgets reports a failure that did not happen. The join already holds every
child in index order and `RealChild` carries its own artifacts, so printing
there costs nothing and needs no procedure.

The line also now carries cells and instructions, so it subsumes the census for
this purpose and ONE grep is the whole gate rather than two that must be sorted
and compared separately.

⚠ For logs already produced at `7a8f3e96`, sort both sides before diffing —
that commit prints on the worker.
…nstrumented

⛔ NOT PUSHED until the e2e pair reports. Written so the numbers can be read
against a design rather than the other way round.

Nineteen epoch wraps now prove `LFM_TREE_SIBLINGS_L0` at a time, through the
same card permit and the same index-ordered join as the interior.

A SECOND KNOB, defaulting to 1 rather than to the interior's value. The
falsifiers are different: the interior's binding constraint is the card, level
0's is the HOST PEAK, and one knob would let a safe interior setting arm the
risky level — discovered by exhausting 57.53 GiB two hundred seconds in. They
are also different machines: measured, a level-1 node is device 3.80 s against
host 3.86, one to one, while a wrap is 2.85 against 7.90, one to 2.8. The count
that saturates one starves the other. And unset, level 0 runs exactly as it did,
so every existing e2e number stays comparable. Deliberately not a list on the
existing knob: that spelling makes the common case need punctuation and lets a
typo arm level 0 silently, which is the one thing this must never do.

THREE LINES THAT DID NOT EXIST, and the first is why this commit is more than a
scheduler.

Per-wrap host peak. The count is chosen on what a second live wrap transient
costs, and nothing measured it. The interior prints a per-node peak and that is
how its retention per node was read; level 0 printed none, so the figure could
only be scaled from a node, which is a different machine. The serial arm now
measures it directly.

The level's own host peak, which is the figure the 52 GiB stop is about — the
per-wrap peaks become process-wide readings inside overlapping windows the
moment wraps run together, and the line says so on itself.

A per-wrap IDENTITY line at the join, the same shape the interior carries, so
one grep covers the whole tree and the wraps get the same byte gate the nodes
have.

The permit's own line prints for level 0 too, so `max holders 1` is asserted
there and `held` against the level wall says whether the card or the host was
the wall — which is the evidence for whether a higher count would buy anything.
…te flag out of the memory cell

Two allocation changes inside `execute`. Neither changes what is executed,
recorded or proved: the bytes are identical.

RECORDS ARE SIZED, NOT GROWN. Ten record vectors were built by `push`
doubling while the compiler already held every one of their final lengths.
`ColumnGroup::real_rows` is not an upper bound on a record count — it IS the
count: pass 2 opens exactly one column-group row per instruction and this
executor pushes exactly one record per instruction of the same chip. The
trace fill already depends on that identity, filling rows `0..real_rows` by
indexing the record vector, so a short vector is a panic there today. Sizing
from it is therefore a use of an invariant the code already relies on, not a
new assumption. Doubling instead re-allocated and copied every vector
~log2(rows) times per proof — a few hundred MB of memcpy and ~20 large
reallocations, on each concurrent worker at once.

THE OCCUPANCY FLAG LEAVES THE CELL. `LfmWord` is `[F; 4]` = 32 bytes and a
Goldilocks felt has no niche, so `Option<LfmWord>` is 40: the flag cost 8
bytes per address AND pushed the stride off the cache line, so a cell spanned
two lines at half of all addresses. It becomes one bit per address in a
separate array — `num_addrs / 8` bytes, which stays cache-resident where the
value array cannot — and the value array keeps a 32-byte stride and shrinks
20%.

THE WRITE-ONCE CHECKS ARE UNCHANGED, AND NOW EACH HAS A TEST. A second write
is still `DoubleWrite`, a read of an unwritten address is still
`ReadBeforeWrite`, and an address past the end still reports differently in
the two directions. `DoubleWrite` already had a test;
`ReadBeforeWrite` had none from the executor — only from the validator — and
that gap matters more after this change than before it. The value array is
zero-filled, so the bit is now the ONLY thing separating "never written"
from "written zero", where `Option` carried that distinction inside the cell:
dropping the check would hand out zeros instead of failing. Both halves are
asserted — a written zero reads back as a value, an unwritten address does
not — together with both out-of-range directions.

Two more pins, because both of these are claims about layout that a later
edit could quietly cost. `the_memory_cell_divides_the_cache_line` fixes the
32-byte stride the argument above rests on. `the_records_are_sized_from_the_census`
asserts `capacity == real_rows` per chip on a fixture whose row counts are
deliberately not powers of two, so push doubling overshoots and says so; the
same identity is asserted for every program under `debug_assert` at the end
of `execute`, where the emitter and the executor arm are written.
…hase

`lfm_prove_with_residency` held the whole `LfmExecution` — the final
write-once memory and all ten record vectors — to the end of its scope, so
both stayed live across the fill AND the whole `multi_prove` card phase. On
a wrap that is a few hundred MB of memory plus a few hundred MB of records
kept for nothing, on every concurrent sibling at once.

`memory` has no consumer on the proving path at all: it is a diagnostic
surface the tests read. `records` are consumed by the fill and dead the
moment it returns — the traces it produced are the live set from there on.
So `execute`'s result is destructured, `memory` dropped straight after it,
and `records` dropped straight after the fill.

TIME-NEUTRAL BY CONSTRUCTION. Both drops sit AFTER the `elapsed()` read of
the phase they follow, so `execute` and `fill` keep measuring exactly what
they measured before and stay comparable across this change; only the wall
absorbs the free, which is a handful of `munmap`s.

The value is not the clock. It is the host PEAK, which is the quantity that
fenced a third concurrent interior sibling: interior 3 measured 54.36 GiB on
the 120 GiB host, past the 52 GiB stop on the 57.5 GiB one. This is a
straight subtraction from every live proof's footprint through the phase
where the peak is taken.
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