Skip to content

WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 40.20 s - #1010

Draft
MauroToscano wants to merge 1134 commits into
mainfrom
whir-recursion-rpx
Draft

MauroToscano wants to merge 1134 commits into
mainfrom
whir-recursion-rpx

Conversation

@MauroToscano

@MauroToscano MauroToscano commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Draft. The WHIR pipeline's best configuration, complete on top of main. It contains:

  • the per-table GPU recursion;
  • the WHIR recursion, with its three optimisation rounds;
  • the ZisK-style proof-format levers;
  • the column-major LDE engine;
  • a batch of fixes to the gap against ZisK: a 27-variable WHIR stack, two WHIR memory kernels, leaner recursion
    programs, less idle time around the base, three faster RPX kernel paths and row-wise DEEP/OOD inversion;
  • grinding only before the queries in the WHIR chains (P2-W);
  • the argue's short, wide tables valued on the GPU (A1);
  • the argue's challenge tables built on the GPU (A2+A3);
  • pure WHIR recursion: every recursion proof is a WHIR proof, and level 1 verifies the base epochs directly;
  • the argue's big zerocheck batches run their programs on demand (N1′), and the RPX MDS compiles the same way in
    every build;
  • the card permit taken after the host prep;
  • the pure-WHIR tree at fan-in 5, and blocks of two to five epochs, which used to stop at the root;
  • the WHIR base's DECODE root computed beside epoch 0 (head ahead), and the GKR tree's refused reservations counted
    as device fallbacks;
  • main, merged.

Block 25368371 proves in 40.20 s.

What made it fast, ranked

These are the optimizations that took block 25368371 from 104.2 minutes to 40.20 s, ranked by the speedup each
measured when it landed. Each row is its own before/after at that time, so the rows do not add up.

# optimization landed measured speedup
1 proving on the GPU, one STARK per table, instead of the batched CPU pipeline 7–11 Sep 104.2 min → 21.8 min¹ 4.8×
2 the gap fixes: WHIR stack 27, the RPX limb permutation, the work-queue grind, base prep ahead of the prover thread, BITWISE only where used, Merkle tops per half-warp, the level-0 lead-in, and eight smaller 27–28 Sep WHIR 99.85 → 60.20 s · STARK 107.55 → 78.80 s 1.66× · 1.36×
3 a tree level's sibling proofs proved concurrently 14 Sep 418.5 → 252.7 s 1.66×
4 the proof-of-work grind on the GPU 11 Sep 21.8 → 13.6 min² 1.60×
5 FRI folds by 2^d per committed layer, one challenge each, with a verifier-side schedule (Haböck, eprint 2022/1216, Protocol 1): the recursion's FRI proofs lose about a third of their cells 24 Sep STARK 158.65 → 129.80 s · WHIR 128.20 → 120.85 s 1.22× · 1.06×
6 three WHIR tuning rounds: the VRAM budget read from the driver, evictable leaf-layer retention, tree fan-in 3 18–21 Sep 150.8 → 127.9 s 1.18×
7 less idle card in the STARK base (F-SIDLE), and the wraps attesting their program host-side (R1b) 29 Sep STARK 76.90 → 66.65 s 1.15×
8 the column-major LDE engine 24 Sep WHIR 106.80 → 100.65 s · STARK 118.60 → 103.90 s 1.06× · 1.14×
9 pure WHIR recursion: every recursion proof a WHIR proof, level 1 verifying the epochs directly 29 Sep 51.40 → 45.80 s 1.12×
10 the argue on the GPU: challenge tables (A2+A3), short wide tables (A1), the big batches' rounds on demand (N1′) 28–29 Sep 59.65 → 54.45 s · −1.00 s · −1.15 s 1.10× · 1.02× · 1.03×
11 the RPX MDS compiled the same way in every build, and NICE v2 (STARK) 29 Sep STARK 79.30 → 73.35 s, then 73.75 → 72.20 s · WHIR −0.50 s 1.08× · 1.02× · 1.01×
12 a six-variable first WHIR fold, schedule [6,4,4,4,4,3]: one round and three grinds fewer per chain, so fewer base commits, rebuilds and grinds 24 Sep WHIR 126.85 → 117.55 s 1.08×
13 one-row openings with a committed FRI input (STARK only; +3.20 s on WHIR, so off there), and Merkle caps (c ≤ 3) on the STARK and WHIR trees 24–25 Sep one-row: STARK 157.45 → 149.45 s · caps: WHIR −1.85 s (STARK trees) and −0.95 s (WHIR trees) 1.05× · 1.01× each
14 the last scheduling and shape levers: grinding only before the queries (P2-W), fan-in 5, the card permit after the host prep with fan-in 4, the base's head ahead 28–29 Sep −2.30 · −1.85 · −1.15 · −0.70 s 1.02–1.05× each

¹ The move also changed the hardware, from a CPU box to one RTX 5090 on a Ryzen 9950X host.
² On a Ryzen 9950X + RTX 5090 box. The base alone fell from 156.3 to 67.9 s.

  • The proof formats together (rows 5, 12 and 13, each against the legacy format on one binary): WHIR 128.00 →
    107.45 s, STARK 157.45 → 121.65 s. On STARK the caps add no wall on top of the 2^d folds, though they remove
    another 1.4 M permutations.
  • WHIR against STARK: the WHIR pipeline was 1.07× faster than the STARK one at its first version (150.8 against
    161.4 s, 18 Sep), and is 1.52× faster today (40.20 against 60.95 s). Rows 6, 9, 10 and 12, and most of 14, are
    WHIR-only; row 7 and the one-row openings are STARK-only.
  • Where this PR stands: 7.5× ZisK (5.37 s) and 4.5× SP1 (8.92 s, one compressed proof) on the same card, from
    11.2× and 6.7× on 28 Sep.

The number

Block 25368371 on the FAST box (Ryzen 9 9950X, RTX 5090 32 GB), RPX commitments, 15 epochs at 2^21. At this head,
33232d688 (this head, 9e2728955, has the same tree), the two default arms of the last ABBA read 40.1 s and
40.3 s
(mean 40.20 s). Host peak 17.3 GiB,
device peak 27.1 GiB (27,794 MiB).

Each step below is its own ABBA on one binary: two arms per setting, alternated.

step before after Δ
legacy format → default format (the format levers) 128.00 s (127.8, 128.2), 32.5 GiB, 10.27 M permutations 107.45 s (107.1, 107.8), 23.8 GiB, 6.49 M permutations −20.55 s (−16.1 %)
per-level LDE → column-major LDE engine 106.80 s (106.5, 107.1), 24.1 GiB 100.65 s (100.8, 100.5), 23.3 GiB −6.15 s (−5.8 %)
every gap fix's opt-out set → the defaults at d1dc45514 99.85 s (99.6, 100.1), 23.5 GiB 60.20 s (59.8, 60.6), 19.6 GiB −39.65 s (−39.7 %)
grind before all three challenges → before the queries only (P2-W) 60.10 s (60.3, 59.9), 19.9 GiB 57.80 s (58.2, 57.4), 19.9 GiB −2.30 s (−3.8 %)
the argue's short, wide tables valued on the host → on the card (A1) 57.55 s (57.4, 57.7), 20.0 GiB 56.55 s (56.3, 56.8), 19.7 GiB −1.00 s (−1.7 %)
the argue's challenge tables built on the host → on the card (A2+A3)¹ 59.65 s (59.5, 59.8), 19.6 GiB 54.45 s (54.4, 54.5), 19.6 GiB −5.20 s (−8.7 %)
the per-table STARK recursion → pure WHIR recursion 51.40 s (51.7, 51.1), 19.6 GiB 45.80 s (45.7, 45.9), 16.1 GiB −5.60 s (−10.9 %)
the RPX MDS as a closure → over a compile-time matrix² 45.65 s (45.7, 45.6) 45.15 s (45.2, 45.1) −0.50 s (−1.1 %)
the argue's big batches' rounds held → on demand (N1′), at 5f15641b9 45.00 s (45.0, 45.0), 16.3 GiB 43.85 s (43.8, 43.9), 16.1 GiB −1.15 s (−2.6 %)
the permit before the prep at fan-in 3 → after the prep at fan-in 4, at 4ab853c6c 43.70 s (43.7, 43.7), 16.2 GiB 42.55 s (42.4, 42.7), 18.6 GiB −1.15 s (−2.6 %)
the pure-WHIR tree at fan-in 4 → fan-in 5, at b682091a3 42.85 s (42.7, 43.0), 18.9 GiB 41.00 s (40.8, 41.2), 16.2 GiB −1.85 s (−4.3 %)
the base's DECODE root before epoch 0 → beside it (head ahead), at this head 40.90 s (40.9, 40.9), 16.2 GiB 40.20 s (40.3, 40.1), 17.3 GiB −0.70 s (−1.7 %)

¹ Measured on its stage branch (34c17603b), before P2-W and A1.
² Two builds, alternated X XF X XF, at 6a6e26611: the MDS fix has no knob.

In the third row's A arms, every fix in the next table is switched off by its opt-out. Their program ids and census equal
those before the batch (668a89a4c). The one change with no opt-out of its own, the WHIR encoding through the engine,
is in both arms. A fifth arm, the defaults with only the level-0 lead-in off, read 61.4 s, so the lead-in is worth
−1.20 s here. The STARK PR (#1009) measured the same batch at −28.75 s (107.55 → 78.80 s).

The gap fixes

Each fix was measured first in its own ABBA, mostly on the base before the engine. Those rows do not add up to the
cumulative −39.65 s; the last row above is the measurement.

fix what changes opt-out its own ABBA
WHIR stack 27 a stacked polynomial may have 27 variables instead of 25: 50 base chains (331 rounds) instead of 145 (866) LAMBDA_VM_ZF_WHIR_STACK=25 −13.80 s (engine base, kernels and room on)
RPX limb permutation (K5) every RPX kernel runs the permutation with 32-bit limb multiplies and squarings unrolled by four, instead of the 64-bit multiply LAMBDA_VM_RPX_LIMB_PERMUTE=0 −10.05 s
RPX work-queue grind (K4) the proof-of-work search claims nonces 32 at a time from a work queue on a card-filling grid, instead of striding over a fixed grid; it finds the same smallest nonce LAMBDA_VM_RPX_GRIND_QUEUE=0 −6.45 s
base prep ahead of the prover thread each epoch's host preparation, and the global proof's, runs on the producer thread LAMBDA_VM_BASE_PREP_ON_PROVER=1 −5.40 s
BITWISE only where used recursion programs whose chips send BITWISE no lookup drop the fixed 2^20-row table, 26.2 M cells a proof LAMBDA_VM_LFM_KEEP_BITWISE=1 −4.70 s
RPX Merkle tops per half-warp (K3) a Merkle level of up to 16,384 pairs hashes one parent per half-warp, and the last 64 pairs run in one block LAMBDA_VM_RPX_WARP_MERKLE=0 −3.90 s
level-0 lead-in (I7) two helpers build level 0's first wrap prologues in the base's tail LFM_TREE_PROLOGUES_AT_LEVEL0=1 −3.20 s; −1.20 s at d1dc45514
lean WHIR coset fold the wraps emit the WHIR fold as (a − c)·w + c: three rows a value instead of seven LAMBDA_VM_WHIR_FOLD_CLASSIC=1 −2.85 s
per-transfer pinned staging (I6) each row-major commit transfer is staged through a pinned pair of its own, instead of a shared slab whose mutex serialised uploads and downloads LAMBDA_VM_STAGING_SHARED_SLAB=1 −2.10 s
WHIR memory kernels a round's six fold levels in one launch; the first six opening rounds read the shares instead of a materialised stack LAMBDA_VM_NO_WHIR_FUSED_FOLD=1, LAMBDA_VM_WHIR_LEAN_ROUNDS=0 −2.05 s
level 0 reuses the base's DECODE level 0 takes the DECODE commitment and prepared opening the base already derived LFM_TREE_REDERIVE_DECODE=1 −1.50 s
WHIR encoding through the engine the base commit's encoding goes through the column-major engine LAMBDA_VM_LDE_LEGACY=1, which also reverts every other LDE −0.65 s, device −1.0 GiB at stack 25
row-wise DEEP/OOD inversion (K6) the DEEP and OOD denominators are inverted row-wise, the DEEP kernel inverts its own, and the single-point OOD sums are row-chunked LAMBDA_VM_DEEP_INV_LEGACY=1 −0.30 s, inside the noise (its kernels −50 %); on by default because it measured −0.70 s and −2.35 GiB of device peak on #1009's pipeline
the room, parked and turn-sized a group's VRAM room is given back during the argument and taken back sized to the turn it covers LAMBDA_VM_NO_WHIR_ROOM_PARK=1, LAMBDA_VM_NO_WHIR_ROOM_RESIZE=1 wall-neutral at stack 25; −1.8 GiB of device ledger, which is what lets stack 27 fit
LFM_HASH split a recursion program's hash table is split in two when that saves ≥ 2^15 padded rows off here; LAMBDA_VM_LFM_HASH_SPLIT=1 turns it on +2.80 s on this pipeline, so off (on in #1009)

Also in the batch, with no knob:

  • The NTT and Möbius tile grids split past CUDA's grid.y limit, which stack 27's commits need.
  • Device commit and tree errors are logged and counted.
  • Each recursion census panel is printed in one write.

Grinding only before the queries (P2-W)

What changed. Each round of a WHIR base chain used to grind 20 bits before three challenges: the first folding
challenge, the out-of-domain batching challenge γ and the query positions. It now grinds before the query positions
only.

  • That is one grind a round instead of three (two on the last round): 518 grinds over the block's 82 chains instead of
    1,472.
  • The query count stays 112, because it reads the query grind alone.
  • The switch is a seventh ZF lever, whir_grind, default query; the banner reads … whir_stack=27 whir_grind=query.

The proof carries only the nonces it spends (NonceLayout::Spent).

  • In the recursion's input, the wrap's arena, an unspent nonce has no word: one nonce word a round instead of three,
    1,036 fewer a block.
  • The host proof struct keeps its three nonce fields, so the old format keeps its bytes. The host verifier refuses a
    nonzero value in a field the format does not carry.

Measured on block 25368371 (FAST, one binary, arms A B B A, wt820–823):

wall base host peak level 0 + global census
A: LAMBDA_VM_ZF_WHIR_GRIND=all 60.10 s (60.3, 59.9) 41.65 s 19.9 GiB the previous program ids and census, exactly
B: the default 57.80 s (58.2, 57.4) 39.15 s 19.9 GiB −49,174 instructions, −2,862 hash permutations, cells unchanged
Δ −2.30 s (−3.8 %) −2.50 s 0.0 GiB the wraps verify 954 fewer grinds
  • The chain grind time, summed over the base's 16 proofs, fell from 3.45 s to 1.20 s. That is ×0.347, against ×0.352
    predicted from the grind count.
  • Level 0 and the interior stayed within noise (0.00 s, +0.15 s), and so did the device peak (−112 MiB).

Soundness: no proven bits are lost. Of a round's three grinds, only the query grind raises the proven minimum as
placed:

  • The folding grind sits before the round's first sumcheck message. A cheating prover redraws the first folding
    challenge by varying that message, without grinding again, so this grind earned no credit.
  • The out-of-domain grind comes after the out-of-domain point. The only challenge it guards is γ, which has 176.95 bits
    with no grind at all.
  • The query grind sits right before the positions. It stays, and so do the 112 queries.

Every phase keeps its bits:

  • The WHIR chain minimum is 130.393 bits at stack 27, set by the first fold, which was already unground.
  • The pipeline minimum stays 128.946 bits, set by the LFM query phase (BCHKS25 Thm 4.2, Johnson regime, the calculator
    security/zisk_calc.py).
  • The grind bits are verifier-side constants absorbed in the statement ([0,0,20] against [20,20,20]), so a proof
    ground one way does not verify the other.
  • Tests:
    • a wrong query nonce is refused in every round, on the host and in the wrap program;
    • a set unspent nonce is refused by the host and has no way into the wrap's arena.
    • Mutations that remove the host's refusal, or give the unspent nonces their arena words back, turn those tests
      red.

Opt-out. LAMBDA_VM_ZF_WHIR_GRIND=all restores the grinds before all three challenges and the three-nonce format:
the previous proofs, program ids and census, byte for byte. Golden tests on the chain programs, arenas and host proof
bytes pin it, and so does the A arms' match above.

The argue's short, wide tables on the GPU (A1)

What changed. In the WHIR base's argue, a table whose columns are already resident on the card is now valued there
once it holds 2^16 cells (width × rows). Before, each column needed 2^16 rows. The few columns left on the host are
walked across the thread pool.

  • 68 tables move to the card, the largest the 1,480-wide precompile table at 2^15 rows. The columns valued on the host
    fall from 24,195 to 3,478 a block.
  • A table that is not resident keeps the old rule, since it would pay an upload.

Measured on block 25368371 (FAST, one binary, arms A B B A):

A: LAMBDA_VM_ARGUE_DEVICE_COLUMNS=0 B: the default Δ
the stage's own ABBA, before P2 (93a2b5643, wt831–834) 59.75 s (59.8, 59.7) 58.70 s (58.7, 58.7) −1.05 s
at its landing head (8930490e5, wt850–853) 57.55 s (57.4, 57.7) 56.55 s (56.3, 56.8) −1.00 s

The whole saving is in the base (41.90 → 40.95 s in the stage's ABBA); level 0 and the interior stay within noise.

Soundness: the proof does not change. The card computes each column's multilinear value exactly, the same field
element the host computes, so the transcript, every challenge and the serialized proof are identical, and so are the
program ids and census.

  • A cross-check arm (LAMBDA_VM_ARGUE_XCHECK=1) re-evaluated every card value on the host: 30,086 of 30,086 matched.
  • Tests: card against host from 2^1 to 2^15 rows and up to 2,000 columns; the whole argument's bytes with the columns
    on the card; a wrong card value is refused.

Opt-out. LAMBDA_VM_ARGUE_DEVICE_COLUMNS=0 restores the height rule and the one-at-a-time host walk, line for line.

The argue's challenge tables on the GPU (A2+A3)

What changed. The WHIR base's argue built its challenge-dependent tables on the host and uploaded them: the
zerocheck's eq(r) and eq(row) weights, and the claim reduce's shift tables and batched columns. It now builds them
on the card from the columns already resident there.

  • Each shift table is two device-to-device copies of one eq(α) table; each batched column is one kernel over the
    resident columns.
  • On the head's trace this removes the host builders' pool waits on the argue thread (3.56 s) and about 20 GB of
    pageable uploads a block (1.16 s of copies).

Measured on its stage branch (34c17603b, before P2-W and A1; wt836–839): A 59.65 s (59.5, 59.8) → B 54.45 s
(54.4, 54.5), −5.20 s. The base fell 41.65 → 36.65 s and the argue 19.7 → 14.7 s; the card built 1,364 tables an
arm.

Soundness: the proof does not change. The card builds the same field elements the host built, so the transcript,
every challenge and the serialized proof are identical.

  • A cross-check arm (LAMBDA_VM_ARGUE_XCHECK=1) compared every card table with the host's before its first round.
  • A corrupted card table is refused by the claim reduce (the first check that reads it), and a mutation that makes
    the fault inert fails that test.

Opt-out. LAMBDA_VM_ARGUE_DEVICE_TABLES=0 builds the tables on the host and uploads them, as before.

Pure WHIR recursion

What changed. The recursion's LFM proofs (the global wrap, the nodes and the block-artifact root) are proved by the
base's own stacked-WHIR prover instead of one STARK per table. Each parent verifies a child with the WHIR verifier the
wraps already run, over the child's LFM statement.

  • Level 1 verifies its epochs directly. A level-1 node checks three base epochs in one program: the wrap's verifier
    three times, then the node's own bindings and publishes. The 15 wraps and their proofs are gone. With three epochs a
    node, the tree's fan-in, the node schema, the root's L2G fold and every level above are unchanged.
  • Each table's instruction columns are committed once per program in a prepared stack, and opened at the table's
    own point. The main stack holds the value columns only.
  • The level-0 lead-in builds the level-1 nodes' programs in the base's tail, as it built the wraps': three epochs'
    harvests and the emission.
  • The recursion shrinks: cells from 3,233.7 M to 1,638.6 M (−49 %), hash permutations from 4.51 M to 2.00 M. The
    global wrap's program is the same one.

Measured on block 25368371 (FAST, one binary per row, arms A B B A):

A: the per-table STARK recursion B: the default Δ
the decision ABBA, before P2-W and A1 (4150afab6, wt860–863) 60.70 s (60.4, 61.0) 56.65 s (56.7, 56.6) −4.05 s
at this head (6a6e26611, wt880–883; A = LAMBDA_VM_LFM_PROVER=stark) 51.40 s (51.7, 51.1) 45.80 s (45.7, 45.9) −5.60 s
  • Where the time goes (at this head):
    • the base is unchanged, 32.9 s in both arms;
    • the recursion falls from 18.1 / 17.5 s to 12.4 s: level 1 (five level-1 nodes and the global wrap) takes 9.6 s
      against the wraps' 7.4 s, the interior 1.8 s against 9.0 s, the root 1.0 s against 1.4 s.
  • Host peak: 19.5–19.7 GiB → 16.1 GiB. Failures 0; every root proved and verified at 180 words.
  • One pre-registered row missed at this head. The lead-in had 2 of its 5 programs ready at level 1's start, against
    ≥ 4. The base is 9 s faster than when that row was set, which leaves the two helpers a 4.7 s tail. The property the
    count stood for held: the GPU's first hold came 0.17 s after level 1 started.

Soundness: every phase keeps ≥ 128 proven bits, and the recursion's minimum rises.

  • The chains: each recursion proof's chains use the base's own format: rate 1/4, 112 queries, 20-bit query grind,
    stacks of 2^24–2^27. Their minimum is 130.393 bits, the n = 27 first fold, unground, the same as the base's. It
    replaces the STARK recursion's 128.946 bits, which came from its LFM query phase. Measured by the calculator on each
    run's own chain lines.
  • The instruction columns bind the program. An LFM AIR declares its preprocessed columns by count only, so a
    statement built from the AIR's column list would count zero and bind nothing: a forged program would verify.
    • The WHIR recursion's statement takes each table's count from the AIR.
    • The verifier refuses a counted prefix that no prepared opening settles.
    • The prepared roots are program constants, folded into the program id.
  • Public words: the in-guest verifier reads each public word as four base felts and absorbs them into the
    statement. The sponge receives each as a base token, so a non-canonical upper lane is unprovable.
  • The level-1 node binds its epochs with the node's own code: one attestation id, each epoch's FINI against the
    next one's INIT, each epoch at its tree position. It reads these from what the wraps would have published. Its L2G
    item is the fold of the epochs' bookend roots, the fold a node over those wraps takes.
  • Tests:
    • The count trap, both ways: the forged program verifies under a count-zero statement and is refused under the
      AIR's count. A forged instruction column is refused by the prepared opening. A deleted opening, a restated table
      height, a tampered or reordered public word and a prepared root absorbed after z are each refused.
    • The main stack without the prefix: a forged prefix, a proof read under the other layout and a left-out prefix
      that nothing settles are each refused.
    • The in-guest verifier executes an honest child and refuses seven arena mutations.
    • The level-1 node: it publishes an L1 node's schema. A broken register chain, swapped positions and a
      disagreeing attestation id are each refused, each beside a control without the bindings that executes.
    • Cost forms: they equal the emitted verifier exactly, per operation kind.
  • Instead of deletion mutations, the count check is shown load-bearing by those paired tests: the same forged proof
    verifies with the count at zero and is refused with the AIR's count.

Opt-outs.

  • LAMBDA_VM_LFM_PROVER=stark restores the per-table STARK recursion. The A arms above print 8930490e5's 24 program
    ids byte for byte.
  • LAMBDA_VM_LFM_WHIR_PREP=both also keeps each table's instruction columns in the main stack.
  • LAMBDA_VM_LFM_WIDE=off keeps the wraps under the WHIR recursion.

Narrow sumcheck rounds on demand (N1′)

What changed. In the WHIR argue's zerocheck, a big batch's device rounds now walk its program on demand.

  • Each step is emitted where the step that uses it first needs it, in Sethi–Ullman order: of two operands, the one
    needing more values goes first.

  • Every read, a column's value or a constant, is emitted again at each use instead of once and held.

  • The steps are the same operations on the same operands. What changes is how many values a thread holds at once,
    which sizes the round kernel's per-thread slot file and so how many threads a round can run.

  • The slot file is also sized for every interpolation node from the first round.

  • A batch is big when its program holds more than 341 values a thread, which leaves a round under 64 k threads at the
    512 MiB slot budget. Five batches are big:

    batch values a thread threads a round
    KECCAK_RND (the head's widest) 2,763 → 207 8,096 → 108,065
    ECSM 1,782 → 48 12,553 → 466,033
    ECDAS 1,291 → 159 17,327 → 140,689
    KECCAK 859 → 14 26,041 → 1,048,576
    LFM_HASH, in each of the nine W-LFM recursion proofs 365 → 34 61,286 → 657,930
    • KECCAK_RND's first rounds used to run 8,096 threads at 94 ms a launch.
  • Every other batch keeps its program as it was, the byte gate's EQ fixture (26 values) included.

Measured on block 25368371 (FAST, one binary):

A: LAMBDA_VM_ARGUE_LEAN_PROGRAM=0 B: the default Δ
the stage's own ABBA, STARK recursion (965e13de2, wt890–894) 51.75 s (51.7, 51.8) 50.40 s (50.5, 50.3) −1.35 s
at its landing head, pure WHIR (a28ad36af, wt910–914) 45.80 s (45.8, 45.8) 44.40 s (44.4, 44.4) −1.40 s

Where the gain lands: the base.

  • At the landing head the base fell 1.35 s. The big batches' early device rounds went 1,528 → 440 ms, and the argue
    fell 1.28 s.
  • Level 1, the interior and the root each moved +0.00 s. The late rounds held in both runs.
  • LFM_HASH's rounds in the W-LFM proofs went 1,007 → 799 ms of card time, but level 1 is not card-bound. At its start
    2 of 5 programs are ready, and the card waits in the gaps between them, so the saving does not reach the wall.
  • LFM_HASH gains less than the VM's batches because re-reading turns its rounds bandwidth-bound. Its walk reads 1,454
    column values instead of 331, over up to ≈ 4 GB of columns.

Soundness: the proof does not change. The program on demand is the same steps on the same operands in another
order, so every round's values are the same field elements.

  • The transcript, every challenge and the proof's canonical bytes are therefore identical, and so are the program ids
    and the census: equal in every arm of both runs.
  • A cross-check arm (LAMBDA_VM_ARGUE_XCHECK=1) walked the old program in a shadow session over the same card-resident
    values. It compared every big session's rounds, round by round, in the base and in every W-LFM proof, and every one
    matched.
  • Tests:
    • host parity for every VM and W-LFM batch;
    • card parity, round by round, on the five big batches;
    • the whole argument's bytes with the knob off and on, alone and with every other argue knob;
    • a program with every constant off by one is refused by the verifier (BatchMismatch), and by the cross-check
      before a proof exists (DeviceFailed);
    • two mutations fail those checks: one makes the fault inert, the other the comparison.

Opt-out. LAMBDA_VM_ARGUE_LEAN_PROGRAM=0 keeps every batch's program as before and sizes the slot file for one
thread an index, line for line.

The RPX MDS compiled the same way in every build

What changed. Both RPX implementations compute the MDS over a compile-time circulant with plain loops, instead of a
core::array::from_fn closure. The closure's wrapper was inlined only when rustc's codegen-unit partitioning placed it
in mds's own unit; otherwise each output lane was an out-of-line call. That made the host's hashing about 20 % slower
in some builds than in others, decided by unrelated edits.

Measured at 6a6e26611 (two builds, X XF X XF): −0.50 s (45.65 → 45.15 s). The executor's hashing runs at
0.81 of its old time per permutation; most of the gain is in the base (−0.30 s). The STARK PR (#1009), where host
hashing sits on more of the critical path, measured −5.95 s.

Soundness. The same values: the RPO and RPX known-answer vectors, the two implementations' agreement test and a new
test against the circulant's definition pin it, and transposing the matrix fails six of them. No knob.

The card permit after the host prep, and fan-in 4

What changed.

  • The W-LFM prove takes the card permit after its host prep. The prep (the plan's layouts, the columns moved into
    them, the prefix check) is host-only work. It used to run inside the exclusive permit, with the card idle and locked:
    1.68 s of level 1's card holds at job 222. It now runs while another proof holds the card.
  • The pure-WHIR tree went to fan-in 4. Fifteen epochs make four wide level-1 nodes (4 / 4 / 4 / 3 epochs), and
    the block-artifact root takes them directly beside the global wrap.
    • The interior level (2 nodes, 1.8 s) is gone.
    • The root grows from 119 M to 271 M cells, because its HASH table steps to 2^19. It costs +1.2 s: +0.9 s in its
      prove and +0.3 s in its emission, census and build.
  • The other trees keep their arity. The WHIR trees without a wide level 1 (LAMBDA_VM_LFM_PROVER=stark,
    LAMBDA_VM_LFM_WIDE=off) keep fan-in 3, and the STARK tree keeps 2.

Measured on block 25368371 (FAST, one binary per row, arms A B B A):

A/B A B Δ
the permit after the prep, at fan-in 3 (533a22926, wt960–963) 43.70 s (43.7, 43.7) 43.05 s (42.9, 43.2) −0.65 s
fan-in 4 (5f15641b9, wt970–973) 43.95 s (44.0, 43.9) 43.25 s (43.3, 43.2) −0.70 s
both (533a22926, wt980–983) 43.65 s (43.6, 43.7) 42.75 s (43.1, 42.4) −0.90 s
  • Where the time goes with both levers (arm means, from each log's timestamps):
    • level 1 −0.54 s;
    • the interior −1.79 s;
    • the root +1.21 s;
    • the base and the gaps between stages +0.22 s, within the arms' noise.
  • Host peak. The permit after the prep raises it: proves waiting for the card now hold their prepared tables.
    • At fan-in 3: 16.3 → 19.8 GiB.
    • With both levers: 16.41 / 16.21 → 18.63 / 18.78 GiB, ≈ +2.4 GiB.
    • Either way it stays under the 33.5 GiB gate.
  • The ruling.
    • All three A/Bs read MECHANISM MISS · WALL BEYOND SPREAD, so none met the EFFECTIVE rule on its own. Both levers land
      because the wall replicated: the whole-run row hit its pre-registered band in all three, each beyond its A arms'
      spread (0.20 s).
    • Every miss was a secondary prediction:
      • the device work and the prep under contention (+6 … +8.5 % and +31 %, first A/B);
      • the root's census (270.9 M against [140, 245], second A/B);
      • one arm's prep in the combined A/B (−1.5 % against +10 … +50 %).
    • The levers' own mechanisms held in every run: the holds lose the prep, the card's working share of level 1 rises,
      and the tree takes the fan-in-4 shape.

Soundness: nothing a proof commits to changes. The tree's shape does change, and the verifier already takes any
arity.

  • The permit moves no byte. The same programs, proved with the permit before and after the prep, serially and
    three at a time, give the same proofs (card_after_prep_tests). The prep never enters the device layer: every entry
    into it is counted. The first A/B's program ids are equal across A and B.
  • Fan-in 4 is a shape the verifier already handles. Each level-1 node verifies its epochs as before, and the
    root's L2G fold regroups the global wrap's roots exactly as the interior did. The root gates now include pure WHIR's
    root (15 epochs at fan-in 4) in:
    • the honest control;
    • the fixed-size schema;
    • both L2G tamper tests: a moved root, and epochs swapped within and across groups.
  • Proven bits: every chain stays in the analysed family, minimum 130.393 bits, unchanged.
  • VRAM: the W-LFM argue's reservation peaks at 21,754 MiB of the 25,688 MiB budget at fan-in 4, with no
    fallbacks. The root still publishes 180 words.

Opt-outs.

  • LFM_CARD_AFTER_PREP=0 takes the permit before the prep. The proofs are the same.
  • LFM_CENSUS_FAN_IN=3 restores the previous pure-WHIR tree and its nine program ids.

Fan-in 5

What changed. The wide pure-WHIR tree defaults to fan-in 5.

  • Fifteen epochs make three wide level-1 nodes of five epochs each, and the block-artifact root takes them directly
    beside the global wrap.
  • Only the WHIR driver's LFM_CENSUS_FAN_IN bound widens, to 2..=5. The STARK drivers keep 2..=4: nothing above four
    has been costed on them.
  • The stark opt-out and LAMBDA_VM_LFM_WIDE=off keep fan-in 3, and the STARK tree keeps 2.

Measured on block 25368371 (FAST, one binary per row, arms A B B A):

A/B A: fan-in 4 B: fan-in 5 Δ
the decision A/B (8b2896c59, wt1000–1003) 42.55 s (42.7, 42.4) 41.00 s (40.9, 41.1) −1.55 s
at this head (b682091a3, wt1010–1013) 42.85 s (42.7, 43.0) 41.00 s (40.8, 41.2) −1.85 s
  • Where the time goes (the decision A/B): level 1 −0.76 s, the root −0.70 s. At this head the root stage
    falls from 1.9 to 1.2 s.
  • Level 1's census falls from 1,052.8 M to 852.6 M cells, exactly as pre-registered.
    • A wide node's rows are its epochs' wrap rows plus a term linear in its epoch count.
    • Five epochs grow the nodes by only 7 %: every table but LANES stays inside its power of two.
  • The root takes 3 nodes instead of 4. Its hash table (253,082 rows) stays under 2^18, so the root falls from
    270.9 M to 149.6 M cells.
  • Host peak falls from 18.6 to 16.2 GiB (18.9 to 16.2 at this head).
  • The card has 1,346 MiB (1.3 GiB) of margin.
    • The W-LFM argue's reservation peaked at 24,342 MiB of the ledger's 25,688 MiB budget (21,754 MiB at fan-in 4).
      The device peak after the base was 25,586 MiB.
    • A block with heavier epochs would push the reservation over. The refused work then falls back to the host:
      slower, never wrong.
    • The fallback counters cover the commit path and every argue site. The GKR tree's refused reservation has been
      counted since 7650b53c7 (a declined prefetch is not a fallback). The production tree at fan-in 5 reads 0
      refusals and 0 fallbacks.
    • Measure a heavier block before relying on five. LFM_CENSUS_FAN_IN=4 is the opt-out.

Soundness: nothing a proof commits to changes except the tree's shape, which the verifier takes at any arity.

  • Proven bits: every chain stays in the analysed family, minimum 130.393 bits.
  • The artifact keeps its 180 published words.
  • The root gates now include the fan-in-5 root (15 epochs, three nodes) in:
    • the honest control;
    • the fixed-size schema;
    • both L2G tamper tests: a moved root, and epochs swapped within and across groups of five.
  • Small blocks compose at five. One to six epochs, each to a verified root on the fixture: one epoch, one wide
    node at two to five, two nodes at six.

Opt-outs.

  • LFM_CENSUS_FAN_IN=4 restores the fan-in-4 tree and its six program ids.
  • LFM_CENSUS_FAN_IN=3 restores the fan-in-3 tree and its nine program ids.

The WHIR base's head ahead

What changed. The WHIR base computes DECODE's univariate root on a helper thread, beside epoch 0, instead of
before the pipeline starts.

  • The serial head. Before the producer started, the head committed the root on the host (an FFT, a 4× LDE and RPX
    leaves over DECODE's columns), then the prepared DECODE commitment on the device. Only then did epoch 0 execute.
  • Epoch 0 needs neither value to execute.
    • Its preparation reads the root, and now waits for it there.
    • Its prove reads the prepared commitment, which keeps its place on the calling thread. So CUDA still comes up on
      the thread that proves, and the DECODE derivation count stays on that thread.
  • The observer now hears the shared DECODE work from the consumer, just before the first epoch it proves. Before,
    it heard it before the pipeline. The level-1 lead-in reads it at its first claim, in the base's tail.
  • The head is stamped under LAMBDA_VM_BASE_SPLIT=1: its start, the root, the prepared commitment and epoch 0's
    wait for the root.
  • The STARK base's head is untouched. STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s #1009 has its own switch.

Measured on block 25368371 (FAST, arms A B B A; all rows but the last are the decision A/B at d117ffedd,
wt1030–1033):

A/B A: serial head B: head ahead Δ
whole run 40.65 s (40.6, 40.7) 40.10 s (40.1, 40.1) −0.55 s
base 31.30 s (31.3, 31.3) 30.65 s (30.6, 30.7) −0.65 s
epoch 0 starts executing 1.47 s (1.49, 1.45) 0.44 s (0.44, 0.44) −1.03 s
first card commit 2.58 s (2.61, 2.55) 1.85 s (1.87, 1.84) −0.73 s
level 1 7.75 s (7.7, 7.8) 7.75 s (7.8, 7.7) 0.00 s
whole run at this head (33232d688, wt1060–1063; A = LAMBDA_VM_WHIR_HEAD_AHEAD=0) 40.90 s (40.9, 40.9) 40.20 s (40.3, 40.1) −0.70 s
  • Where the time goes:
    • The serial head spent 1.09 s on the root, then 0.24–0.29 s on the prepared commitment, all before epoch 0
      executed.
    • Ahead, the root took 1.22 s beside epoch 0 and was done before epoch 0's preparation reached it: the preparation
      waited 0.00 s.
  • Why the first commit gained 0.73 s, not the whole head: epoch 0's pipeline fill took 1.40–1.42 s instead of
    1.10–1.11 s. Its collect, sharing the CPU with the root, grew from 0.32 to 0.52–0.54 s.
  • The gain carries to the end of the base. From epoch 1 on the prover is the bottleneck, so the whole prover
    chain starts earlier.
  • Level 1 does not move. The lead-in starts when the last epoch executes, and that moves with the base.
  • All pre-registered bands held.

Soundness: the proofs are byte-identical by construction.

  • The same values, only earlier. The root is the same pure host function of the ELF and the options
    (commitment_from_elf), computed on another thread. The prepared commitment is unchanged and committed in the same
    place. Epoch 0's preparation receives the same root. Only where and when the root is computed changes, never what
    any proof commits to.

  • The test. the_head_ahead_proves_what_the_serial_head_proves proves a run serial and ahead, under both
    preparation schedules. It compares:

    • the DECODE root and the prepared roots;
    • every epoch's bookend root, shapes, output and register fini;
    • the cross-epoch roots and the touched pages.

    It checks that the ahead bundle verifies, and that the observer hears the shared DECODE work once, with the serial
    head's values, before any epoch is proved. It passed on the CPU build and on FAST's device build.

  • What the test does not compare: an epoch's first group root. Its rows follow HashMap order, so it differs
    between any two proves. The existing preparation-schedule test skips it for the same reason.

  • The tree: the 5 program ids are equal in all four arms and equal to the fan-in-5 landing's.

Opt-out. LAMBDA_VM_WHIR_HEAD_AHEAD=0 restores the serial head, with the same program ids. Any other value stops
the run.

What is in the branch

  • Per-table GPU recursion (STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s #1009): per-table STARK proofs of each epoch on the device, LFM wraps and nodes, one root
    for the block.
  • WHIR recursion: WHIR base proofs, the WHIR-verifier wrap, the global wrap and the interior on the device.
    • The first full version proved the block in 148.9 s, already including the VRAM budget read from the driver
      (−8.3 s).
    • Evictable leaf-layer retention on the card saved −9.1 s. Fan-in 3 in the interior, plus the global child proved
      inside level 0's pool, saved −11.9 s. Together they took the block to 128.3 s.
  • Proof-format levers, taken from ZisK's recursion. One ZfFormat (prover/src/zf_format.rs) parses seven
    LAMBDA_VM_ZF_* knobs once and prints one ZF FORMAT: banner. The default is cap=auto whir_cap=auto fri=dp one_row=0 whir_folds=first6 whir_stack=27 whir_grind=query.
    • Merkle caps on every STARK and WHIR tree. Paths stop at a verifier-chosen height c ≤ 3, and the cap rides at the
      end of each tree's first path, so the proof structs are unchanged.
    • FRI folds by 2^d per committed layer, one challenge each, with a verifier-side DP schedule.
    • A six-variable first WHIR fold, schedule [6,4,4,4,4,3] at 25 variables: one round and three grinds fewer per
      chain.
    • The WHIR stack cap, 25 | 26 | 27, default 27: an epoch's WHIR base stacks 2–3 polynomials of 2^27 instead of
      8–11 of 2^25.
    • Grinding only before the queries (P2-W), whir_grind, default query: one grind and one nonce a round in
      the WHIR chains; see "Grinding only before the queries" above.
    • One-row openings with a committed FRI input (LAMBDA_VM_ZF_ONE_ROW=auto) are built on host, on the GPU and
      in-guest, but are off here: they cost +3.2 s on this pipeline. They are on in the STARK pipeline's PR (STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s #1009).
    • Every knob keeps its off value, and ZfFormat::LEGACY stays pinned by a golden test. The RV64 recursion guest
      verifies only the legacy format.
  • Column-major LDE engine (crypto/math-cuda/src/lde_cm.rs, kernels/ntt_cm.cu).
    • A device LDE used to take about 33 whole-matrix DRAM passes: spread, per-level NTT tiles, bit reversal, weights,
      zero fill, and a transpose before a row-major commit.
    • The engine computes the coset LDE of as many columns as fit in 60 % of L2 at a time, 4 to 8 butterfly levels per
      launch in registers and shared memory, so a 2^22 transform is three passes. The coset spread is fused into the
      first pass, and the output is column-major, so the commits lose their transpose.
    • Every field value, leaf and root is the legacy one.
    • The STARK main, preprocessed, auxiliary, composition and batch LDEs go through it, and so does the WHIR base
      commit's encoding. LDEs that keep a host copy (below 2^19 rows) stay on the old path.
    • LAMBDA_VM_LDE_LEGACY=1 sends every LDE back to the per-level pipeline.
  • The gap-fix batch, the fixes in the table above, merged in three rounds:
    • gap-fix/ntt (da2da9d93), gap-fix/wbatch-int (7e3eac501), gap-fix/harness (f81f0a80f),
      gap-fix/rec-int (c679a771b), gap-fix/stack-int (8c2450ff6) and gap-fix/idle-a-int (d3c76d2ed), each
      a signed merge;
    • gap-fix/idle-b-int (bdb2d37b6) and gap-fix/hash-int (e041e9fb0), each a signed merge;
    • gap-fix/kern-int, one commit (d1dc45514).
    • Every fix keeps a named opt-out that reproduces the previous program set.
    • Only the three that change the recursion programs or the WHIR layout move program ids: BITWISE, the lean fold and
      the stack.
  • Grinding only before the queries (P2-W): 9cea599a3 (the nonce layout, default off), ce292de3e (the default)
    and b4506b719 (comments).
  • The argue's short, wide tables on the GPU (A1): 93a2b5643 (behind its knob), merged as c7228f310, and
    8930490e5 (the default).
  • The argue's challenge tables on the GPU (A2+A3): 34c17603b, merged as 7364d1292, and b9698b05d (the
    default).
  • Pure WHIR recursion: whir/full-recursion (4150afab6), merged as 3722e7376; 70cdb3719 (its pins under P2-W)
    and 6a6e26611 (the default).
  • Narrow sumcheck rounds on demand (N1′): 1177d5a13 (the census) and 965e13de2 (behind its knob), merged as
    06d2d48e8; 26adbf501 (the default) and a28ad36af (the W-LFM parity tests). The merge also carries two argue knobs
    whose A/Bs read MECHANISM-ONLY, LAMBDA_VM_ARGUE_LEAN_READS and LAMBDA_VM_ARGUE_LEAN_TAIL, both off.
  • The RPX MDS fix (d61a3c729) and deterministic whir_chain grind tests (d6648e653, merged as 5f15641b9).
  • The card permit after the host prep, and fan-in 4: 1e3c39d5e (a device-entry counter), 533a22926 (behind its
    knob), 78781f7cf (the default) and 4ab853c6c (the pure-WHIR tree's default fan-in 4).
  • Small blocks: 529589d9d (a tree of one wide level runs root option A as option B) and 61b025b7d (a spin guest and
    a test that proves 1 to 6 epochs to a verified root). A block of 2 to fan-in epochs used to stop at the root's
    child-count check; block 25368371's tree is unchanged.
  • Fan-in 5: e783f29d5 (the WHIR driver takes fan-in 5 from LFM_CENSUS_FAN_IN) and b682091a3 (the default).
  • The WHIR base's head ahead: d117ffedd (behind its knob) and eb20fe04f (the default). The GKR tree's
    refusals counted
    : fd3146a0b (the fan-in-5 margin quoted in MiB), 7650b53c7 (the counter), a28690b7f (its
    forced-refusal test) and 4593752a7 (the test's feature note). Merged as 33232d688.
  • The wide lead-in's early claim, landed and reverted: deb9726b3, reverted by 9e2728955 (this head, whose tree
    equals 33232d688's). Its A/B read −0.50 s (job 244), but at the landed head it measured no effect: 40.30 s with the
    claim and 40.30 s without (job 245). Level 1 gained ≈ 0.2 s and the base paid it back.
  • main: perf(alloc): compile jemalloc's never-purge policy into the binary #996 (jemalloc never-purge compiled into the CLI).

Soundness

Query counts and blowup are unchanged.

The format levers

  • Caps. The root is still the commitment. The cap is hashed to the root once per tree, and each path must reach the
    cap node the query index selects. Path lengths are checked exactly, including at c = 0.
  • FRI folds by 2^d. This is Haböck (eprint 2022/1216) Protocol 1 / Theorem 2 with reduction factors 2^d. Only Σaᵢ
    changes, in a term that stays more than 50 bits below the dominant one.
  • One-row openings. This is batched FRI with the DEEP codeword committed before the first fold challenge, the
    layout Plonky3 uses. The query index is uniform over the whole domain.
  • WHIR first fold. Only the grouping of variables into rounds changes. Every error term is invariant or shrinks with
    fewer rounds, and queries stay 112 per round.

The gap fixes

  • The WHIR stack cap is a verifier-side constant. The prover, the host verifier and the recursion's WHIR emitters
    all take the layout from global_layout(shapes, cap), never from a proof.
    • A proof stacked under one cap is refused under another, and so is a prepared commitment.
    • The query count is now charged for the tallest stacked polynomial of the proof's layouts, not the widest single
      table. It stays 112 at every production shape.
    • Proven bits, per phase (BCHKS25 Thm 4.2 in the Johnson regime, the calculator security/zisk_calc.py): the WHIR
      chain minimum is 130.393 bits at 27, against 130.926 at 25. With the per-table STARK recursion
      (LAMBDA_VM_LFM_PROVER=stark) the pipeline minimum is 128.946 bits, set by the query phase of every LFM proof; with
      the default pure WHIR recursion it is 130.393 bits (see "Pure WHIR recursion").
  • The per-round WHIR fold proof-of-work earns no credit as placed. It is ground before each round's first sumcheck
    message, so a cheating prover can re-draw α₁ by varying h₁ without grinding again. The bits above are the unground
    ones. P2-W drops the folding and out-of-domain grinds (see "Grinding only before the queries"); K4 changes only how
    the card searches for the nonce.
  • BITWISE only where used. BITWISE only receives lookups, with prover-chosen multiplicities. In a program with no
    sender, its honest multiplicities are all zero and the table constrains nothing.
    • The dangerous direction, a sender without its receiver, cannot be built. The mask is derived from every
      instantiated chip's interactions, stored in the artifacts and folded into program_id, and the verifier re-checks
      it against the mask it was handed. No proof supplies it.
    • Tests refuse a forged mask and a drop under a byte-lookup family.
    • LAMBDA_VM_LFM_KEEP_BITWISE=1 reproduces the legacy registry digests.
  • The lean coset fold changes verifier arithmetic inside the emitted wrap program, not the proof format. Both
    emissions compute the same field value on accepted and on tampered chains (tests). The WHIR wrap program ids move.
  • The LFM_HASH split (off here) is program shape: committed per chunk, bound into program_id and never read from a
    proof. Tests refuse a forged tail root, the single-table door and a wrong chunk root.
  • Everything else is byte-identical:
    • the memory kernels: raw-identical to the per-level kernels, by a host known-answer test and device parity;
    • the room: ledger only;
    • the engine's WHIR encoding: the same codeword, tree and proofs;
    • the base prep and DECODE reuse: the same derivations, on another thread or reused;
    • the grid split: the same launches up to stack 26;
    • the staging (I6): the same bytes through another pinned buffer, by round trips across chunk boundaries and commit
      parity through either staging, on the card;
    • the lead-in (I7): the same prologue, built earlier; a lead-in prologue equals the one built from the bundle (test);
    • the RPX kernels (K3, K4, K5): the same digests, roots and smallest grind nonce;
    • the DEEP/OOD inversion (K6): the same field elements, by parity of each part against the legacy path and a CPU
      reference, and the fault suite under both settings.
  • K5 is a different implementation of the same permutation: 32-bit limb multiplies with carry chains instead of
    the 64-bit multiply. Its bytes were shown equal three ways:
    • a host known-answer test runs every permutation variant against the RPX oracle (raw states and chained probes,
      with a failing control). It also replays the cooperative kernels lane by lane (the half-warp Merkle kernels and
      the queue grind) against the shipped ones. CI runs it on every PR;
    • on the card, each switch's two paths agree byte for byte (nodes, nonces, raw permutation states), path against
      path and, where cheap, against the host oracle (rpx_device_paths);
    • in its own ABBAs, every setting proved the same program ids and census.

Fixed along the way

  • One-row verify. The verifier's Phase-A transcript replay absorbed the row-pair root of one-row preprocessed
    tables, which rejected honest proofs that publish values.
  • A WHIR commit past CUDA's grid limit. At stack 27 the first NTT tile asked for 65,536 blocks in y. The launch
    failed, the error was dropped, and every such commit fell back to the host; the base took 647 s. The grid now splits
    into z, and device commit and tree errors are logged and counted.
  • A dropped leaf layer returns its bytes to the room it grew. Before, a fold's layer stayed promised until its
    source codeword dropped.
  • Concurrent census panels no longer interleave in a log. Each panel is printed in one write.
  • The prove split's device-grind count now includes RPX grinds. Under RPX it read 0 on every table.
  • Device byte-parity tests now run on a card. S3/S2 vector proofs and LFM proofs are byte-identical between CPU and
    GPU.
  • Comments are self-contained. The format code's comments point at nothing outside the repository.

Gate and CI

The head ahead and the refusal counter were gated at this head, 33232d688, on the FAST2 box: 14 steps, all green
(the lib suite 1,634 / 0 / 96). Besides the standard steps:

  • the head's three tests on the device build, and every small block (1 to 6 epochs) with the head ahead;
  • the production tree at the default and under LAMBDA_VM_WHIR_HEAD_AHEAD=0, each with the fan-in-5 landing's 5
    program ids;
  • the refusal counter's call-site censuses, and a forced refusal on the card beside its control.

A supplementary gate ran the per-table argument's suite on the card, 5 steps, all green. An audit found that the
suite's earlier gate line (-p stark --features cuda) ran multilinear's host paths only: stark's cuda does not
turn on multilinear/cuda. The supplement names both features and requires every device arm's own output line.

Fan-in 5 was gated at b682091a3, on the FAST2 box: 15 steps, all green (the lib suite 1,631 / 0 / 96).
Besides the standard steps: the root, tree-shape and knob suites; 1 to 6 epochs proved to a verified root at the
default (fan-in 5), under LAMBDA_VM_LFM_WIDE=off and under stark; the fixture tree to a block artifact; and the
production tree four times, at the default (its 5 program ids), under LFM_CENSUS_FAN_IN=4 (4ab853c6c's 6), under
LFM_CENSUS_FAN_IN=3 (5f15641b9's 9) and under LAMBDA_VM_LFM_PROVER=stark (the STARK recursion's 24).

Small blocks was gated at 61b025b7d, on the FAST2 box: 12 steps, all green (the lib suite 1,631 / 0 / 96),
including 1 to 6 epochs proved to a verified root at the default, under LAMBDA_VM_LFM_WIDE=off and under stark, and
the production tree's 6 program ids unchanged.

The permit after the prep and fan-in 4 were gated at 4ab853c6c, on the FAST2 box: 14 steps, all green (the
lib suite 1,628 / 0 / 95). Besides the standard steps: the permit's byte gate and device-entry gate, the fixture tree at
the new defaults and under LFM_CARD_AFTER_PREP=0, and the production tree three times, at the defaults (its 6 program
ids), under LFM_CENSUS_FAN_IN=3 (5f15641b9's 9) and under LAMBDA_VM_LFM_PROVER=stark (the STARK recursion's 24).

N1′, the MDS fix and the grind tests were gated at 5f15641b9, on the FAST2 box: 21 steps, all green (the
lib suite 1,625 / 0 / 94): the standard six, N1′'s 13 (card parity on the five big batches, the whole argument's
identity and its negative control, the slot-budget pin, multilinear's lib at the default and under the opt-out, and the
byte gate for both hashes and under the opt-out) and the MDS fix's 2 (the RPX suites).

Pure WHIR was gated at 6a6e26611, on the FAST2 box: 14 steps, all green (the lib suite 1,622 / 0 / 92). These are the standard steps,
plus the WHIR recursion's prover, verifier, leg, wide-node, switch and lead-in suites on the card with their negative
tests, the fixture tree at the default and under the opt-out, and the opt-out's byte gate: the production tree under
LAMBDA_VM_LFM_PROVER=stark prints 8930490e5's 24 program ids.

A2+A3 was gated at b9698b05d on FAST2: 17 steps, all green.

A1 was gated at 8930490e5, on the FAST2 box (the second RTX 5090): 14 steps, all green. These are the
standard steps, plus A1's device tests, the whole argument's identity and its negative control, the multilinear suite at
the new default and under the opt-out, and the WHIR byte gate on both hashes and under the opt-out.

P2-W was gated at b4506b719 on the FAST box: 11 steps, all green. These are the standard steps, plus the
multilinear suite, the WHIR chain gates with their production-shape emissions, the WHIR byte gate on both hashes, and
the WHIR epoch verifier programs under the opt-out.

The batch was gated at d1dc45514 on the FAST box: 81 steps, every one at its exact pre-registered count.
The standard steps:

  • guest artifacts 266 / 266
  • math-cuda 268 / 0 / 16
  • RPX device parity 11
  • stark 397 / 0 / 6
  • crypto 163
  • the lib suite 1578 / 0 / 90

The 75 targeted lines cover:

  • the engine and its legacy opt-out, the grid limits at stacks 27 and 28, the error counters, and the WHIR kernels and
    the room on the card;
  • the recursion-shape and registry tests, the stack lever at 25, 26 and 27, and the base-prep and DECODE schedule
    tests;
  • the staging round trips and the lead-in suites;
  • the RPX host known-answer tests, each RPX switch's path parity, and the old paths;
  • the DEEP/OOD parity suites and the fault suite, under both settings.

For each switch of the last two rounds, a gate line runs each setting and reads the banner its process printed. The
previous candidate without K6 (41549ebad) passed its own 70-step gate. The cumulative ABBA in the first table ran
after the gate.

In CI at d1dc45514, these pass: lint, the host known-answer tests (including the RPX lane-by-lane replay), the prover
test build, the stark cuda-feature tests, and the CLI and executor tests. The spec structure check fails on a key the
spec tooling does not know (spec/src/blake3.toml: constants). The prover shards were still running when this was
written.

Open decisions

  1. Merging. This PR and STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s #1009 share the code up to P2-W (b4506b719). Since then this PR added the WHIR-side
    fixes (A1, A2+A3, pure WHIR, N1′, the permit after the prep, small blocks, fan-in 5) and STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s #1009 the STARK-side ones
    (R1b, F-SIDLE, NICE v2); both carry the MDS fix. The shared defaults differ in the recursion prover (WHIR here, STARK
    in STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s #1009), one_row (off here, auto in STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s #1009) and the LFM_HASH split (off here, on in STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s #1009). A per-pipeline default
    would let one PR carry both.
  2. Reporting configuration (I1). The record launchers export LAMBDA_VM_MEMPOOL_RELEASE_MB=0, which releases the
    device memory pool; the code's default retains it. Retaining measured −4.50 s on this pipeline before the engine, but
    only −0.45 s at the current head (inside noise), so this PR keeps reporting with the release. The STARK PR (STARK recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.95 s #1009)
    measured −2.00 s and reports with the retain.
  3. Stack 27 on a heavier block. On this block the heaviest epochs' argument sits within 2 GiB of the device ledger's
    budget at 27. A block that adds one polynomial to such an epoch moves that argument's reservation to the host:
    counted, proof unchanged, slower. The device peak at this head is 27,794 MiB (the highest of the last ABBA's four
    arms) of the card's 32,607 MiB. Worth a run on a heavier block before relying on 27 there.
    Fan-in 5 leaves level 1's argue 1,346 MiB under the same budget; the same run would check it.
  4. Batched WHIR openings (not built). Their batch cap must be re-derived from the unground fold bits: at 27, K ≤ 5
    keeps 128 bits.
  5. Protocol changes. W3 (WHIR query carry-over) and W4 (the WHIR paper's rate schedule) are analysed, not built.
  6. An LFM lookup chip would let larger caps pay.
  7. RV64 proof bytes are not reproducible across processes, because six table builders order rows by HashMap
    iteration.

`the_process_wide_counter_tracks_the_same_passes` asserted that a tree and a
leaf pass are the same event -- `tree_builds() == leaf_hash_calls()`, and that
an opening moves the global counter by one. With the leaf layer retained they
are no longer the same event by design, so this test would have reddened on the
box for the one reason the gate was not looking for: an invariant that expired
when the code under it changed, in a file whose other tests were rewritten and
this one was not.

It now asserts the relationship that replaced it, in deltas because the three
counters are process-wide and diverge on purpose: a commit assembles one tree
and pays one pass; an opening assembles a tree and pays NOTHING, recording one
saving instead; and over any window, trees == passes + savings. That identity
fails in both directions -- a tree that skipped its pass without recording a
saving breaks it, and so does a saving recorded for a tree never assembled --
where the old equality could only fail in one.
`run_grind` declared its result `uint64_t` and handed the address to the kernel
as `volatile unsigned long long *`. Those are the same type on Darwin/arm64 and
different types of the same width on LP64 glibc, so on Linux the cast
type-punned; with `#include "rpx.cu"` putting the whole kernel in this
translation unit, GCC 13.3 at -O2 was free under TBAA to assume a write through
`unsigned long long *` could not touch an `unsigned long`, and to keep `result`
in a register across the inlined call.

It did. `run_grind` returned UINT64_MAX for every input, so layer 8's two checks
per vector that expect the SENTINEL passed VACUOUSLY while the two that expect a
found nonce failed. Six rows, at every sha back to the gated base c00342c, on
a target that passes on a clang/arm64 laptop where the two types coincide.

Measured on the box at this sha:
  -O2                        6 FAILURE(S)
  -O2 -fno-strict-aliasing   ALL HOST KAT CHECKS PASS
  -O0                        ALL HOST KAT CHECKS PASS

None of it was ever a statement about the device grind. This file is a HOST
replay of the kernel source through `cuda_host_shim.h` — no nvcc, no cubin, no
device — so the defect was in the harness holding the result, not in the kernel
it tests. The production path re-validates every device nonce with the host
predicate, and the block pins read `host fallbacks 0` throughout.

`crypto/math-cuda/tests/host_kat/` holds exactly one instance of the pattern and
this is it: the shim's `atomicMin` takes `unsigned long long *` as a parameter,
and the kernels' casts there only drop `volatile` from an already-matching type.

Test-only; no production code and no proof bytes move. It also unblocks this
lineage's CI `host-kat` job, which has been failing since the device grind
landed.
`a_group_holds_only_its_codewords_before_any_open` took `free_before` AFTER a
`drain_and_trim()` and `free_after` without one. `free_vram_bytes()` is the
driver's count and the device pool is configured to retain all freed blocks, so
that difference measured the code PLUS every transient the four commits made. A
delta between two samples is about the code only if both are taken at the same
pool state, and the test's own comment says exactly that about the first drain.

It read 301,989,888 B — nine codewords on a bound of nine, where the model says
eight are held. The old one-sided bound was `8 x codeword` against four
codewords held, so it carried 128 MiB of margin the pool had been living in
unnoticed; the leaf-layer retention did not add pool retention, it consumed that
margin. The slack was never sized against the transients either: `build_tree`
allocates `(2L-1)*32` = 64 MiB, twice the bound's 32.

The mutation arm settles which it is. Holding a whole node array instead of a
layer moved the measurement to 480 MiB where the code then holds 384 - an excess
of 96 against the honest run's 32, tripling while the holding grew by half. No
"the code holds one more object" form fits both: 4*(cw+tree)+tree = 448,
+cw = 416, and 5*(cw+tree) = 480 fits that run exactly but dies on the honest
one, where 5*(cw+leaf) = 320 != 288. Every peak-demand model misses on BOTH
sides (320 and 448 predicted). That is driver suballocation, not an accounting
this tree keeps.

So the driver's count is sampled at the same pool state on both sides, and the
assertion messages now carry the CODE's own number beside it: the delta of
`Backend::reserved_bytes()` across the window, the pool's share as the
difference, and each held codeword's `reserved_bytes()`/`retained_leaf_bytes()`.
A future failure says which of the two it is instead of posing the question.

Pre-registered for the next card run: the honest case falls 288 -> ~256 and the
whole-tree mutation 480 -> ~384. If the honest case still reads 288, the ninth
block is the CODE and this reasoning is wrong.

The assertion is not weakened: drained on both sides, four kept layers read ~256
below the 288 bound and four kept TREES still read ~384 above it, so the 128 MiB
of separation the bound was designed around is restored rather than spent.

Test-only.
The ledger the previous commit added is built inside the two assertion
messages, so it appears only when the test FAILS. On the passing path its
numbers were inferred rather than read, and what a pass alone establishes is
`driver < bound` — pool share under one codeword — and nothing narrower.

That is not enough for this guard. The mutation run that keeps a whole node
array reads `driver 448 MiB · promised 383 MiB · pool share 64 MiB` even after
the symmetric drain: a fragmentation floor of one largest transient, because a
best-effort pool trim cannot release a chunk still backing a live allocation. If
the honest path sits anywhere near that floor, this test has a margin of a few
MiB and will flake, and a green run would never say so. 0 MiB and 31 MiB are the
same observation today.

So the line is printed unconditionally, under --nocapture, which is how the box
gate runs this suite. Once its honest value is known a ceiling can be asserted
against it, which is a gate change rather than a test change and is not made
here.

`promised` is worth having in the log for its own sake: at the mutation it read
383 MiB against a model of 4 x 96.00, exact to the MiB, which is what settled
that the excess is the pool rather than a ninth object the code holds.

Test-only; no assertion changes, no production code.
…olumns)

The only fallback number the WHIR campaign read was
`multilinear::gpu::host_fallbacks()`, which has exactly one caller — the
COMMIT path in `whir_chain.rs` — so every `host fallbacks 0` certified that no
commitment fell back and said nothing about the per-table argument. wt16 read
as a slot-level win for exactly that blind spot: the leaf-layer retention grew
`be.reserved`, argue's `reserve` then refused, and its work moved to the host
uncounted while `tree_rebuild` alone showed the saving.

Add a process-wide counter in `device.rs` beside `reserve`
(`device_fallbacks` / `reset_device_fallbacks` / `note_device_fallback`),
always compiled and reading zero on a non-cuda build, bumped at the five
argue-side `reserve`->`None` sites in math-cuda: `sumcheck.rs` (x3),
`gkr.rs` (x1), `columns.rs` (x1). The doc states this scope precisely and
records the multilinear residual (`gpu.rs:177`/`:1572`) as a follow-up that
needs each caller traced, and why `gpu.rs:1551` — a speculative reserve whose
`None` selects a lazy on-device path — must never be counted.

Tests: a card test drives the cheapest site (`DeviceColumns::upload`) with the
budget fully reserved (an atomic bump, no device memory) and asserts the
counter reads one; a card-free unit test covers the counter API; a card-free
source-count test asserts the call appears at exactly the five sites, so the
four undriven sites fail without four card fixtures.
…e-run block

The WHIR tree harness printed no fallback line at all, so the launcher had
nothing to gate on and (v9) voided every run by reading a `host fallbacks` line
that only the lb-class grind harness prints. Print BOTH fallback surfaces,
whole-run scope, at the WHOLE-RUN block of the production-tree test: `commit
fallbacks` (`multilinear::gpu::host_fallbacks`, the commit path) and `device
fallbacks` (`math_cuda::device::device_fallbacks`, the argue surface added in
the previous commit).

The launcher (A-tree-whir.v10) refuses a block number unless both read zero and
refuses loudly if either line is absent — closing the blind spot wt16 read as a
slot-level win, where argue's per-table work fell to the host uncounted.
…er instruments

The evictable retention (round 2's fix) and any honest reading of the leaf-layer
lever need two numbers the code lacked.

RETAIN_BYTES_LIVE: the SIMULTANEOUS retained device footprint — fetch_add on
admit, fetch_sub in a new Drop on RetainedLeaves — plus its per-run peak. Unlike
the cumulative RETAIN_BYTES_ADMITTED (39,057 MiB in wt16), this rises and falls
with the live layers, so it is the ~2 GiB that actually contends with argue for
the budget. The peak is printed on the retention line; the instantaneous count is
~0 by the time the base readback prints (the codewords have dropped).

reserved_high_water: the peak be.reserved, updated by note_reserved on every rise
in Backend::reserve and DeviceReservation::grow. This is the reservation quantity
argue's reserve is checked against, which the raw device trace cannot report —
never-purge inflates the raw peak above the budget. Printed at the whole-run
block.

Tests: a card test asserts the live footprint rises with a held layer, the peak
and high-water bound it, and — the balance the eviction relies on — the footprint
returns to baseline when the codeword drops. Card-free unit tests cover the
high-water's monotone max.
…e bytes instead of falling to host

wt16 read the leaf-layer retention net-negative at the block: it grew be.reserved
during commit and the openings, argue's per-table reserve then refused and fell to
the host uncounted (+11.3 s argue vs the -9.45 s tree_rebuild saved). The cause was
a single byte budget raced between the retention and argue.

Make the retention SUBORDINATE to argue by eviction, so it holds only genuinely
spare bytes and gives them back the instant a real caller needs them:

- Backend::reserve, on a budget miss, consults an evictor ONCE before returning
  None and retries after it frees. grow (the retention's own capture) never evicts,
  so the retention can only ever yield to argue, never displace it.
- whir installs the evictor (a fn pointer, via OnceLock) and keeps a registry of
  Weak handles to each codeword's (leaves mutex, reservation), registered once at
  first capture and pruned when the codeword drops. Eviction takes a layer out
  under its own mutex (a concurrent serve sees None and rebuilds -> no UAF; the
  evicted nodes' free is stream-ordered on the codeword's own stream, cited to
  cudarc core.rs:776-795 in the code), drops it, and shrinks the reservation --
  freeing the BUDGET while never-purge keeps the raw bytes pooled. The evictor
  never calls reserve/grow (no reentrancy); lock order is registry -> one layer at
  a time, be.reserved lock-free -- acyclic.

LFM_WHIR_RETENTION=0 disables capture entirely, so the ABBA control arm runs off
the same binary as the retention arm. RETAIN_EVICTED / RETAIN_BYTES_EVICTED and the
live-footprint peak are on the retention print.

Tests: a card test fills the budget below a held layer's size and asserts the next
reserve SUCCEEDS by evicting it (the layer reads None after) -- the arm that fails
when the evictor is disabled.
…counted twin

Round 3's poll fix is blocked on one bench reading: does the grind's
stale-poll overrun FALL, RISE, or stay FLAT as the poll rate is lowered?
That sign — not a magnitude — decides whether the "poll less" family of
kernel fixes lives. O2 falsified the scan-factor lever; O4 read the SASS and
closed the cache-qualifier lever (the poll is already LDG.E.64.STRONG.SYS).
The one surviving hypothesis is CONTENTION on the single *result address.

rpx_grind_search_counted (the diagnostic twin, on no proving path) gains a
poll_period launch parameter: it polls *result for the early exit only every
poll_period-th iteration, STAGGERED by thread via (n + tid) & (poll_period-1)
so at any one iteration only 1/poll_period of the resident threads issue the
system-scope load — average AND peak request rate both fall by poll_period.
poll_period == 1 (mask 0) reproduces the shipped every-iteration poll
exactly; the production rpx_grind_search is untouched. n is the thread's own
loop counter (not i/stride, whose identity holds only while tid < stride).

search_counted threads poll_period through to the launch (arg LAST) behind a
zero/not-power-of-two guard, and echoes it back in GrindCounts so a report
quotes the knob as read on the path. The rewritten rpx_grind_counted test
sweeps k = 1/4/16/64 at scan 8 / grid 1024 over O3's 256 seeds, paired and
rotated, and reads the overrun vs k. Controls: k=1 admissibility against the
shipped kernel; the winning nonce identical across every k — the soundness
control, since atomicMin only ever lowers *result, so a staler poll can only
delay an exit, never change the nonce a launch returns. The verdict prints a
granularity-adjusted (overrun - (k-1)/2) column to isolate the contention
component from the poll-coarseness term.
…gister sweep of the RPX permutation

The poll k-sweep falsified contention (POLL FAMILY DEAD), so round 3's lever
must speed the RPX permutation itself: grind + two hashing passes all share it,
and the file's cost model shows it is compute-heavy (inverse S-box = 95% of the
field mults). The open question that picks the lever: is the permutation
latency-bound (raising occupancy speeds it) or compute/issue-bound (it does
not)? The card runs RPX at ~2/3 occupancy, register-limited.

This adds the discriminator, card-free authored, as a whole-cubin register cap
rather than __launch_bounds__: the SHIPPED grind kernel — which the sweep must
move — is off-limits for a per-kernel annotation, and launch-bounds variants
would need new kernels loaded in the off-limits device.rs. A -maxrregcount cap
moves the untouched production kernels at build time.

- build.rs: opt-in LAMBDA_VM_RPX_MAXRREGCOUNT ⇒ nvcc -maxrregcount, mirroring the
  existing -lineinfo knob; empty/unset ⇒ no cap ⇒ production cubins byte-stable.
- math-cuda::rpx::permute_probe_sweep: times the pure permutation probe (htod
  once, so the cross-build delta is kernel time) and reports the ACHIEVED regs /
  blocks-per-SM read back from the loaded function.
- math-cuda::grinding::grind_occupancy: the shipped grind kernel's achieved
  regs / blocks-per-SM.
- prover/tests/rpx_occupancy_sweep.rs: one point per build — probe ns/permutation
  and grind mean ms at the achieved occupancy, one OCCSWEEP line to collect
  across the caps.

The sign is read ACROSS builds (unset/85/51/42 ⇒ ~8/6/10/12 blocks/SM): time
FALLS as occupancy rises ⇒ latency-bound ⇒ build the register-pressure lever;
RISES/FLAT ⇒ compute-bound ⇒ occupancy and LDL.64 are dead, only a
KAT-preserving arithmetic lever remains or round 3 lands at the arithmetic
floor. A register cap cannot change a kernel result, so there is no soundness
question; permutation parity stays pinned by rpx_device_parity. No default and
no shipped kernel change.
…ytes probe (STEP 2, ncu-free)

The occupancy sweep put the RPX permutation at its arithmetic floor, so round
3's only remaining stage is argue (the WHIR field-argument: sumcheck + gkr +
columns, ~16.6s). STEP 1 (the O5 device-fallback counter, read from the round-2
logs) established argue does NOT host-fall-back at the tip — it is device-side
and stable. This is STEP 2's instrument: does argue's device work hit the HBM
roofline (memory-bound ⇒ an MLE layout/reuse lever exists) or run far below it
(compute-bound ⇒ near a floor, like the permutation)?

ncu is unavailable on the box (ERR_NVGPUCTRPERM), so the roofline is read by
ARITHMETIC: a new `math_cuda::argue_probe` records, per surface, the DEVICE-path
bytes reserved and the op count, at the same five reserve sites the fallback
counter already guards (sumcheck.rs x3, gkr.rs, columns.rs). Divided by the
`argue` wall time the harness already prints, the total bytes give an achieved
HBM bandwidth. Reserved bytes ≈ the HBM working set (a resident buffer re-read
within an op is L2/L1, not HBM), and it is additive — unlike a wall-timer at
these sites, which would double-count when gkr's layer sumcheck nests inside a
reserve; a per-surface time / device-host split is STEP 2b, only if the byte
roofline is borderline.

- crypto/math-cuda/src/argue_probe.rs: per-surface {bytes, calls} atomics +
  note_device / surface_totals / reset (mirrors the DEVICE_FALLBACKS pattern).
- the five reserve-SUCCESS sites call note_device beside the else's
  note_device_fallback.
- per_table_aggregator_tests.rs prints `argue probe: sumcheck B/ops · gkr · columns
  · total` beside the existing `device fallbacks` line.

Diagnostic only, on no production decision path; a reservation is unchanged, so
this cannot move a proof. No default and no shipped kernel change.
… argue discriminator

The argue census put the WHIR argument at ~2% of the HBM roofline and single-digit-%
of compute on LARGE kernels — GPU-under-utilized, not floored. ncu is driver-locked
on our box (ERR_NVGPUCTRPERM), but Mauro has a profiler-capable server. This gives
him one representative launch to profile.

`ncu_sumcheck_round_block_scale` (an #[ignore] test in tests/sumcheck.rs) builds a
block-scale sumcheck — num_vars=21 (KECCAK_RND scale, the census byte-leader), a
Builder-lowered program at the tested (width 4, degree 5) shape — and runs a handful
of round + fold launches so Nsight Compute can profile sumcheck_round_ext3 (and
sum_partials_ext3 / sumcheck_fold_ext3) at realistic dims. It reuses the file's
existing factor()/program() helpers, so the program is a valid lowering, not a
hand-fabricated node stream. No host cross-check (a 2^21 host round is far too slow)
and no correctness claim — parity is device_rounds_match_the_host_sumcheck's job;
this exists only to hand the profiler a launch. On no production path.

⚠ ncu -c 1 profiles ONE kernel = that kernel's own occupancy/SoL, NOT the inter-round
host-sync idle (round() synchronizes every round to cross the transcript answer to
the host — sumcheck.rs's own note: "more time than the rounds themselves"), which is
a timeline property for nsys. So it answers "is the round kernel itself
under-occupied?" — the complement to the host-sync finding.

Build + profile (WHIR branch only — the argue stack is not on main):
  cargo test -p math-cuda --release --test sumcheck --no-run
  ncu --set full --section SpeedOfLight --section WarpStateStats --section Occupancy \
      --section MemoryWorkloadAnalysis -k regex:'sumcheck_round_ext3' -c 1 \
      <target/release/deps/sumcheck-*> ncu_sumcheck_round_block_scale --exact --ignored
…n discriminator)

Round 3's concurrency lever is soundness-dead: the whole argue is one sequential
Fiat-Shamir chain (run_rounds is a per-round chain; multilinear_table.rs:444 argues
all tables in one transcript), so no sumcheck round can overlap another — the
challenge order is the proof. Argue's ~2% GPU utilization is therefore mostly
INHERENT per-round host-sync latency, not a recoverable inefficiency.

This sizes it, on our own box (ncu is driver-locked): a gated, opt-in device-busy
probe measures how many seconds the sumcheck round kernels are actually executing,
so idle = argue wall − device-busy is the total gap. It is not a recoverable
ceiling by itself — the recoverable-without-a-rewrite part is only what
transcript-independent prefetch can fill.

- argue_probe: DEVICE_BUSY_NS + add_device_busy_ns/device_busy_ns, and
  busy_probe_enabled() (reads LAMBDA_VM_ARGUE_BUSY_PROBE once, cached).
- sumcheck.rs round(): when enabled, a reusable TIMING-enabled CUDA event pair
  (thread-local, created ONCE — a mid-prove cuEventCreate convoys the driver lock)
  brackets the round kernels; the elapsed is read after the EXISTING per-round
  synchronize(), so no extra sync and no perf change. Off by default: production
  round() only reads a cached bool and skips it.
- per_table_aggregator_tests.rs: prints `argue device-busy (round kernels): X s`
  beside the argue probe line.

Round kernels only (fold + setup excluded), so idle = wall − this slightly
OVER-estimates. Diagnostic; no default and no shipped-kernel behavior change when
the env is unset.
The per-table argue is a serial Fiat-Shamir chain: each sumcheck round
syncs to the host for the next challenge, leaving the GPU idle ~9.5s of
the 16.6s argue wall. A table's fraction tree depends only on the global
(z, alpha) and the table's trace — not on the transcript at that table's
turn — so it can be built ahead, during the previous table's argue, to
fill those host round-trip gaps.

- gpu.rs: DeviceTree's output fraction becomes a lazily-read OnceLock.
  input_layer_tree_deferred builds the tree WITHOUT the one output()
  sync (the fold kernels stay in flight); the read happens at the
  consume site (FractionTree::from_device), before GKR spends any level.
  Gated by a non-evicting headroom check (vram_budget - reserved, which
  already includes the round-2 retention) so a second tree never
  displaces argue or the retained leaves — reserve-or-skip. math-cuda
  untouched.
- logup.rs: resident_tree_deferred / PrefetchedTree wrap that path;
  into_tree() finalizes (reads output) at the consume site.
- multilinear_table.rs: prove() takes an optional prebuilt tree;
  multi_prove runs a depth-1 pipeline behind LFM_WHIR_PREFETCH.

LFM_WHIR_PREFETCH unset/0 is byte-for-byte the current serial path
(prove builds its own tree eagerly). =1 enables the pipeline for a
one-binary A/B/B/A. Prefetch is strictly subordinate: argue > retention
> prefetch, reserve-or-skip, never an eviction of round-2 leaves. The
overlap depends on the residency handing consecutive tables distinct
streams and on non-evicting headroom; where neither holds the pipeline
skips and the wall is unchanged, which the A/B settles empirically.
…iminator)

Round 3 measured the WHIR BASE at two floors (the RPX permutation's
arithmetic floor; argue's Fiat-Shamir sequencing floor). The ~55s ABOVE
the base — level 0's epoch wraps, the cross-epoch GLOBAL child, the
interior fold levels, the block-artifact root — was never examined for
concurrency slack, and three of its four stages print no occupancy
number at all.

The serialising resource there is the card PERMIT, not SM utilization:
device_permit is mutual exclusion over a proof's two device phases
(build_artifacts' per-dispatch admission has no running total; each
multi_prove builds its own full-budget VramGate), so even a card idle
INSIDE a device phase cannot be filled by another sibling. The ceiling
on every concurrency lever in this phase is therefore the permit-held
total, and held/stage-wall is what separates "the card is the wall"
(a floor) from "the host is the wall" (a lever).

What was missing, and why:
- The interior's permit line is printed only from compose_interior_levels'
  BARRIER loop. The production harness runs LFM_TREE_LEVEL_POOL=1 with
  LFM_TREE_LEVEL_POOL_FROM at its default 1, so barrier_levels is 0 and
  the whole interior goes through the POOLED span, which calls no
  take_stats. The reading is absent on the configuration that runs.
- The GLOBAL child and the ROOT run with the permit armed at 1, where
  hold() returns before a guard exists and Drop accumulates nothing into
  HELD_NANOS. Their device-phase time appears in no summary.
- reserved_high_water is whole-run, so the base's reservation is the only
  number any tree stage could report.

- tree_probe: the probe's own per-label held/queued counters, a device
  commit dispatch total, and a running maximum of the reservation
  high-water. It never touches HELD_NANOS, ACQUISITIONS or take_stats,
  so every line the drivers already print keeps its exact meaning.
- device_permit: CardPermit carries the phase and its queue time when the
  probe is on, and Drop feeds tree_probe OUTSIDE the guard branch — which
  is what lets the K=1 stages be priced.
- commit.rs: a host stopwatch around try_commit_row_major, the only
  device work inside the build_artifacts hold. NO CUDA event and NO added
  synchronize: the call already blocks until it has a root. The total is
  WORKER-SECONDS (both walks go through map_maybe_parallel), and the
  banner prints LFM_ARTIFACT_PARALLEL and LFM_ARTIFACT_GROUPS_IN_FLIGHT
  read on the path so a ratio above 100% reads as concurrency.
- per_table_aggregator_tests: one TREE PROBE line per tree stage, the
  interior's missing permit line, and the reservation high-water split at
  each stage boundary. Taken in the WHIR driver, so
  compose_interior_levels and the STARK driver are untouched.

LAMBDA_VM_TREE_BUSY_PROBE unset, empty or 0 is byte-for-byte the current
behaviour: no counter moves, no line prints, no counter is cut, and the
only cost anywhere is one cached OnceLock read per card hold. On, the
reservation high-water is cut per stage, so the harness's whole-run line
reads the last stage — the probe prints the run maximum beside it and
says so in the same line.
… answer

The free read of wt27 and wt28 landed before this probe was ever built, and it
answered most of what the probe was for. Three shipped instruments already
partition the tree phase: the per-hold CARD HOLD lines (62 of them, with held
seconds and epoch brackets), the per-node LFM PROVE line (execute / fill /
multi_prove / permit wait), and the per-prove PROVE SPLIT line. Summed by hand
they give every stage's permit-held fraction, and the 10 Hz nvidia-smi trace the
harness already keeps splits device memory by phase.

So two parts of this probe were measuring settled quantities and one of them had
a cost. Removed:

- The per-stage device reservation high-water. Splitting it meant CUTTING a
  process-wide counter at every stage boundary, which cost the harness's own
  whole-run `reserved high-water` line its meaning. The number did not buy
  that: the whole-run figure is 25,003 MiB against a 32,607 MiB card, no tree
  stage is near the budget, and the sampler already splits device memory by
  phase. A measurement that degrades an existing one has to buy more.
  ⇒ the probe now has NO side effect on any line the harness already prints,
  on or off.
- The interior's extra `device_permit::take_stats()` line. The interior's
  permit-held fraction is settled at 0.92 of its wall from the CARD HOLD lines
  of both runs — a floor under the mutual-exclusion permit — and reading that
  counter CLEARS it, which belongs to the lines the drivers already own.

Kept, because nothing else prints them: the per-label held/queued accounting for
the stages that run with the permit armed at 1 (the WHIR global child and the
block-artifact root, where `hold` returns before a guard exists and `Drop`
accumulates nothing), the four stage lines that partition the tree phase, and
the device commit dispatch total inside the build_artifacts hold.

The four stage lines stay even where they decide nothing: `take` clears, so a
stage that printed nothing would fold its holds into the next stage's line.

LAMBDA_VM_TREE_BUSY_PROBE unset, empty or 0 remains byte-for-byte the current
behaviour.
… its mover

The lineage's first PR CI (run 35533170457, PR #999) turned up four red prover
tests. All four are pre-existing at the base c00342c, all four have ONE
mover, and hypothesis B — a posture or env dependence, the campaign's
LAMBDA_VM_MAX_ROWS_LOG2=21 or LAMBDA_VM_WHIR_HASH=rpx — is ruled out for every
one of them: the registry path reads no env (REGISTRY_HASHER is a const and
`build_artifacts` passes it explicitly), and the one knob that IS read,
`max_rows_log2_override`, points the other way, since CI's bare env uses the
LARGER production caps.

THE MOVER is `FIXED_TABLE_COUNT` 11 -> 5, which arrived with 892c7d1 (main's
c2ac5d5, #977). COMMIT, KECCAK, KECCAK_RND, ECSM, ECDAS and HINT stopped
being always-on and became `TableCounts` fields — a run that never reaches one
carries no sub-proof for it. That has two arms, and every red pin sits on one:

  (i) THE TABLE SET SHRANK. `air_trace_pairs` pushes only BITWISE, DECODE,
      KECCAK_RC and REGISTER unconditionally; everything else comes from a
      `Vec<VmAir>` that is empty when the count is zero.

 (ii) THE STATEMENT ENCODING GREW 49 BYTES. The six counts joined the absorbed
      epoch statement and #977 bound `is_final` into it as the last byte:
      `NUM_TABLE_COUNTS` 15 -> 21 and a trailing `+ 1` on
      `EpochStatementShape::byte_len`. Recomputed from the code rather than
      from the prose, with a 30-byte CONTINUATION_EPOCH_TAG:
      30+32+8+8*15+8+1+8+8 = 215, and 30+32+8+8*21+8+1+8+8+1 = 264.

Each re-blessed assertion names 892c7d1 and its arm, so the next reader can
trace it without the thread this came from.

None of these is a regression. Each pin caught a deliberate production change
that was ported without re-running it, which is the pin working. The port DID
update everything that fails to COMPILE — algebraic_transcript's
`[u64; NUM_TABLE_COUNTS]` literal, logup_tests' `[...; FIXED_TABLE_COUNT - 1]`
census, whir_statement_tests, statement_replay's own derivation and module doc
— and missed every pin that fails only at runtime. These test files exist on no
other branch and the lineage had no PR CI until #999, so nothing ever ran them
against #977. Confirmed independently: the four names appear in none of the box
gate logs either, and every earlier box `cargo test -p lambda-vm-prover` run was
a filtered `--exact` run of a single unrelated test.

LFM_REGISTRY, regenerated. StatementReplayV0 is the one registered program that
replays the epoch statement, so arm (ii) moved its script by one Blake3Chain
compression and seven of its fifteen roots with `program_id`. Regenerated with
`compute_lfm_registry`; `compute_static_commitments` was not re-run and did not
need to be, this being no hash-pin change.

EXACTLY ONE ENTRY MOVED, checked rather than assumed: the other five programs
build no epoch statement, and every root, log-height, `keccak_rnd_chunks`,
`hasher` and `chip_set` of each came back byte-identical — slots 13 and 14, the
statics, included. `log_heights[11]` did not move even for StatementReplayV0,
9 and 10 compressions both padding to the 16-row group. And the regenerated
`roots[11]` equals the value the pin computes from the program
(`7a 3b 86 1b ...`), which is what says the paste closes the gap the pin found
instead of moving the pin to meet the paste.

The runtime pins:

- blake3_chip_tests, arm (ii): STATEMENT_REPLAY_BLAKE3_ROWS 9 -> 10. 49 bytes
  is under one 64-byte Blake3Chain block, so it is worth exactly one more
  compression. TRANSCRIPT_REPLAY_BLAKE3_ROWS stays 8 — TranscriptReplayV0
  replays no statement, and that is what keeps the two an oracle rather than
  two literals: a change that moved BOTH would not be this one.

- epoch_verify_tests, arm (i): SUB_PROOFS 25 -> 16, CHALLENGES_AT_MIN_PRESET
  115 -> 79. Six of the nine that left are the accelerators; the pre-#977 25
  was 14 split families + 10 intermediate fixed + 1 L2G_MEMORY, and the fixed
  term is now 4, which lands at 19. The remaining three are split families this
  fixture leaves empty and are NOT named, because the measurement does not name
  them. 79 falls out twice: from the stated model, 111 + 4*(16-24); and from the
  loop's own counter, 2 + 16*4 + 13, the 13 zetas being 115 - 2 - 25*4. The
  `checked` assertion is the real oracle and fails if the four-challenge model
  stops describing the move.

  The accounting identity had to be respelled, not just re-blessed. SUB_PROOFS
  is now BELOW the 24 it is measured against, and `SUB_PROOFS - 24` on a usize
  is an underflow rather than a failed assertion. Both sides are positive now,
  so it still fails in either direction.

- machine_tests, arm (ii): `epoch_statement_cursor_is_three_plus_output_len`
  was never red on CI because nextest cancelled its shard first, and it is red
  — 215 + L + 16R against an actual 264 + L + 16R, 261 versus 310 at the
  acceptance shape. Renamed to `..._is_the_output_len_alone`: the constant
  term's own shift is gone, so Phase A inherits `L mod 4` where it inherited
  `(3 + L) mod 4`. The rider asking for a one-byte pad at the end of the
  statement encoding got it from #977 without anyone aiming there, tag bump
  included, and is marked resolved.

- constraint_artifact_tests, arm (i): `continuation_epoch_constraint_leg`
  asserted `fixed_final.len() == FIXED_TABLE_COUNT`, i.e. 11 == 5. Also never
  reached by CI. The list splits into `always_on` (five, the census target) and
  `accelerators` (six), which stay in the COST sum because that sum is the
  figure a recursion budget has to clear. So (25, 26) survives, as a CEILING
  rather than as the composition — 19/20 is the accelerator-free floor.

Swept by VALUE and not only by test name — 215/264, 261/310, 207/223, 11/5,
15/21, 24/25/26, 79/111/115/119, the moved root and program_id prefixes, and
the spelled-out counts — across prover/ and crypto/, tests and non-tests. Two
hits beyond the six were pins in substance:

- SOUNDNESS.md's `T = 24`. Two corrections, and the bound survives both. `T` is
  now smaller (16 on the fibonacci fixture), and since `E = 4 + sum_t (3 + L_t)`
  is increasing in `T` and `P <= 3E * 2^-32` in `E`, a smaller `T` only lowers
  `P`: the quoted `P ~ 2.5e-7` stays a valid, now conservative, upper bound, and
  the `T ~ 60` figure that matters for a real block is untouched. The second
  correction is sharper: the test the paragraph cites as the measurement,
  `arena_filler_reads_real_committed_roots`, asserts `tables > 0` and that no
  root is all-zero — never a count. "Measured, not assumed" was true of the run
  somebody did and false of the suite, so nothing would have failed when `T`
  moved, and nothing did. `T` for that two-epoch continuation fixture is
  unmeasured since #977 and is now read against the 25/26 ceiling.

- logup_tests' "all 25 sub-proofs declare interactions". Corrected without
  spelling a count: the loop walks `census`, so a literal there would be a
  second, driftable copy of a length the loop already holds.

Judged NOT pins, and left alone: `FE::new(215)` (fri_tests, a polynomial
coefficient); 215 squeezes (crypto's transcript_counters, a different
quantity); 16,777,215 (math-cuda, 2^24-1); 261 (ecdas column index); 1,261 and
1,397 (blake3 interaction counts); 223,380 and 229,290 (continuation page
census); BLOCK_MARGINAL = 111 (continuation page cost); `rejects >= 20`
(machine_tests, a rejection count); `i >= 26` (blake3_socket_tests); the
blake3 KAT "all ten vectors"; and every `FIXED_TABLE_COUNT`/`NUM_TABLE_KINDS`
use that names the constant instead of a literal, since those adapt.

Left for a measurement rather than an edit: wrap_tests' measured record table
(25 sub-proofs, 210,782 instructions, 82,059,828 cells, 45,953,352 proof bytes)
and epoch_verify_tests' `[pinned: see the run output]` figures. Re-blessing a
measured record without a measurement is the thing the rules forbid.

STILL OPEN: multilinear_table_tests' three `>= 20` table-set assertions, which
want the argued set BY NAME rather than a count, and therefore one run first.
…gued

`prove_and_verify_all_tables` returned a count, and its three callers asserted
`>= 20` against it. Two of the three are red on the lineage since 892c7d1
(#977) elided empty tables, and between them they produced one usable diagnostic
and one that said nothing at all:

    every_live_table_is_proved_and_verified   "expected the full table set, argued 10"
    the_whole_instruction_set_..._verified    "assertion failed: ... >= 20"

The second is a bare `assert!`, so neither CI nor the box could say what it
argued — which is why a red gate on the lineage base needed a second run to
diagnose at all.

The helper now returns the argued tables BY NAME, in `air_trace_pairs` order,
and each of the three assertions prints the whole list. Captured from `pairs`
rather than from the AIR set, so it is the proof's own sub-proof order, and
pinned against `tables.len()` so a census that described a different proof
cannot pass.

This is a diagnostic step, taken separately on purpose: the `>= 20` bound
itself is what wants replacing, and it wants the measured sets first. Since
#977 the set is workload-dependent — an empty table is elided rather than
padded — so "how many" stopped being the question and "which ones" became it. A
count cannot tell a table that vanished from one that gained a chunk while
another vanished, which is exactly the class of move #977 made. The bound is
left at `>= 20` here and is expected to stay red for two of the three; the
named-set pins land once the three lists are in hand.
`make fmt` from the repo root. One line, in the `fixed_final` construction the
re-bless added: the `always_on.iter().chain(accelerators.iter())` chain sat at
93 columns, which rustfmt breaks onto one method per line.

Nothing else moved, which is the useful part. The registry regeneration in
8cedf2e hand-reproduced rustfmt's 14/14/4 wrapping for eight `[u8; 32]`
literals rather than running the formatter over them, and rustfmt touched none
of the eight — so the reconstruction was exact. Had any been wrong,
`cargo fmt --check --all` would have failed as `make lint`'s first step instead
of passing.
…hild as task 0 of level 0's pool)

The WHIR global child runs ALONE between level 0 and the interior. Measured
from wt27's and wt28's 62 CARD HOLD brackets, its stage is 6.48 s of which only
2.09 s is time inside a device phase: 4.4 s of host work — the cross-epoch
harvest and verify (1.1 s), the emit and arena build (1.2 s), its census, its
own host verify — performed with NO other proof on the card, because nothing
else is scheduled.

The refusal this replaces said so itself: "overlapping it is an optimisation
round's item, not an unset knob's". This is that round.

- whir_level_zero gains `fan_in` and `top_overlap` and returns an
  Option<WhirGlobalChild>. Under the knob it runs the global child as task 0 of
  its existing pool via PoolOut / split_pool_out / in_index_order — the shape
  the STARK level 0 has run since the overlap landed there, and the shape its
  four unit tests already cover (split_pool_out_keeps_index_order_when_completion_is_reversed,
  the_overlap_does_not_move_a_single_wrap, a_panicking_global_task_re_raises_with_its_message,
  a_missing_global_task_is_caught_at_the_drain).
- The global task is called from INSIDE whir_level_zero rather than handed in as
  a closure, for the same `H` that makes level 0 a function at all:
  prove_whir_global_child is generic in H too, so calling it there reuses the
  level's own H. The return type still names no H — WhirGlobalChild is a
  RealChild plus a GlobalLayout.
- The consume site becomes `match overlapped_global { Some(done) => done, None =>
  prove it here }`, so the stage is consumed at the same point in the program on
  both arms and nothing downstream can tell which produced it.
- ONE MORE TASK, NOT ONE MORE WORKER: at most LFM_TREE_SIBLINGS_L0 working sets
  are ever live and one of them is the global child INSTEAD of a wrap. The card
  is unaffected — the global task takes the same permit every wrap takes, so
  `max holders 1` still holds and still asserts.
- The fixture-scale driver passes `false` explicitly and asserts it got no
  overlapped child, so what the card-free test covers does not depend on the
  caller's shell.

WHY THIS CANNOT REORDER A TRANSCRIPT APPEND

Not "the appends are still in order" but "there is no shared order to disturb":

1. Every LFM proof builds its OWN transcript. prove_traces_with_hasher opens
   `block_transcript(&[])` fresh per call and absorbs that proof's own
   program_id and public words. So the 15 wraps and the global child construct
   15 + 1 independent transcripts; no append by one is ever observed by another,
   and their relative timing is not a quantity the proofs can see.
2. The global child reads nothing level 0 produced. Its signature takes the
   bundle, the ELF bytes, the options, the ceiling and fan_in; its own doc says
   "it reads NOTHING any level-0 wrap produced". The register chain between
   siblings is bound by emit_chain_bindings and the memory chain closes at the
   ROOT.
3. The ROOT's transcript is unmoved. It absorbs the interior children's arenas
   and then the global child's, a plain concatenation in declaration order, and
   the driver still performs that declaration in the same order — the overlap
   changes when the global child was PROVED, not where its bytes sit.
4. The drain is by INDEX, not by completion. split_pool_out preserves index
   order and asserts the global ran exactly when the knob is on, so the wraps
   arrive as the serial loop built them and a knob that silently stopped
   scheduling the task fails at the drain rather than at a root with no child.

Unset or 0 is byte-for-byte today's behaviour: l0_offset is 0, the pool is the
same 15 tasks, and the global stage runs where it always ran.

REPORTING CHANGE, ON THE `=1` ARM ONLY, stated because a reader could mistake it
for a regression: the global child's prints — including `MARK AFTER the WHIR
global child` — are emitted by a worker DURING level 0, so slicing phases by
that MARK gives level 0 and the global child as ONE window. Its TREE PROBE line
relabels itself to say its cost is inside level 0 rather than reporting ~0 s as
though the stage had vanished.
… gap

The three multilinear table-set cases asserted `>= 20` argued tables. Two went
red when the main-sync merge `892c7d1bc` (main's `c2ac5d546`, #977) took
`FIXED_TABLE_COUNT` 11 -> 5 and made COMMIT, KECCAK, KECCAK_RND, ECSM, ECDAS
and HINT counted: an empty table is ELIDED from the proof now rather than padded
into it, so the argued set is workload-dependent and "the full table set"
stopped being something a bound could describe.

A count is the wrong instrument for that move, twice over. It cannot tell a
table that VANISHED from one that gained a chunk while another vanished, and it
says nothing about ORDER — which `air_trace_pairs` calls the proof's sub-proof
layout in as many words, since `air_refs` has to reproduce it. So the bound is
replaced by the ordered census, MEASURED on the box at 788f36a, CPU-only, no
campaign env:

    sub                   10  BITWISE DECODE KECCAK_RC REGISTER HALT
                              CPU[0] LT[0] MEMW_A[0] PAGE:0x0 MEMW_R[0]
    all_instructions_64   15  ... + SHIFT[0] MUL[0] DVRM[0] BYTEWISE[0] CPU32[0]

The first five of each are exactly `FIXED_TABLE_COUNT`'s five, in
`air_trace_pairs` order, which is a third independent confirmation of the 11->5
move — after the epoch's 25 -> 16 sub-proofs and the statement's +49 bytes.
`ALWAYS_ON_TABLES` holds them once and `the_always_on_prefix_is_the_constants_own`
pins its length against the constant, so the shared half of both censuses cannot
drift from the machine the way `constraint_artifact_tests`' sibling list did
(it named eleven while the constant said five).

⚠ `all_instructions_64` IS NOT THE FULL TABLE SET, and the old bound is what hid
that. Fifteen argued, and six counted families absent: MEMW (the unaligned one —
only MEMW_A and MEMW_R appear), LOAD, STORE, BRANCH, EQ and COMMIT, plus every
accelerator. Since #977 an absent table is a table with NO ROWS, so the fixture
executes no branch, no load, no store and no `eq` — a narrower program than its
name suggests. Not fixed here: widening it edits `executor/programs/asm`, and
this census is the thing that would notice. Pinned, with a non-vacuity check
that the five tables it does add over `sub` are actually present, so the two
censuses cannot decay into one test written twice.

The precompile case keeps its bound and is the only one without an ordered
census, deliberately: it PASSED the measuring run, so its assertion never fired
and never printed its set, and pinning a list nobody has read would be a literal
invented to fit a bound. It gains instead the checks its doc has always claimed
and never made — that KECCAK_RND and COMMIT are argued at all, which is the
whole reason it sits beside the two asm cases. Since #977 those are counted
tables, so a workload that stopped reaching the precompile would drop them
silently and still clear any bound. Matched as "equal, or followed by `[`" so a
bare prefix cannot let KECCAK_RC answer for the family. Every assertion prints
the full set, so the run that fails one is the run that supplies the census.
…al-nonce control

⛔ DROPPABLE, AND NOT THIS LANE'S CODE. `prover/tests/rpx_grind_counted.rs` is
O6's grind poll k-sweep (27be22c) and this is its cross-k IDENTICAL-NONCE
soundness control — the check that witnesses that a poll period never changed a
winning nonce. Nothing else is touched.

`make lint` has been red on the whir/lfm-o6-grind lineage since that commit: the
FIRST clippy pass (`cargo clippy --workspace --all-targets -- -D warnings`)
fails with two `needless_range_loop` errors at :264 and :266, so passes 2-4
never run and `make lint` exits 2. It was missed because that lane's recorded
gate was per-package — `cargo clippy -p math-cuda` — which does not reach
lambda-vm-prover's test targets. The repo rule is the opposite: do not
substitute per-package clippy for the Makefile target.

The rewrite keeps the comparisons, the failure message and the bounds exactly:
`nonces` is `vec![Vec::with_capacity(RUNS); n_arms]` and the seed loop pushes
once per (seed, arm) with no early exit, so `nonces.len() == n_arms` and every
row is `RUNS` long. Iterating `nonces[0]` IS the old `0..RUNS`, and
`take(n_arms).skip(1)` IS the old `1..n_arms`.

⚠ And the comment now records why clippy's own suggestion must not be applied:
it offers `take(n_arms).skip(1)` for the OUTER loop as well, which drops the `s`
index. The body compares `nonces[a][s]` against `nonces[0][s]` and needs both,
so that suggestion would have compared whole arms instead of per-seed nonces —
turning a control that can fail into one that cannot.
… that proved the wrong guest

The full lib suite at 413b3a3 (plain libtest, every test regardless of
failures) found four red. One was mine to expect; three were pre-existing at the
lineage base and invisible to nextest's fail-fast. Three are fixed here. The
fourth is a MEASUREMENT and is left red on purpose — see the end.

⛔ THE PRECOMPILE TEST HAS BEEN PROVING THE WRONG GUEST.

`a_program_using_a_precompile_is_proved_and_verified` loaded `keccak.elf`, which
is `executor/programs/rust/keccak`: a guest whose Cargo.toml depends on
`tiny-keccak` and whose `main` hashes in SOFTWARE, issuing no syscall but
`commit`. It has never touched a precompile. The accelerator guest is
`keccak_precompile`, whose `main` calls `lambda_vm_syscalls::keccak::keccak256`
— the `keccak_permute` ecall — over five padding edge cases.

★ And the doc's claim was false BEFORE #977 too; it was merely unfalsifiable.
KECCAK and KECCAK_RND were always-on then, so they sat in the table set of every
workload whether reached or not, and "which brings KECCAK, KECCAK_RND and
KECCAK_RC in" described MACHINE shape while reading as program behaviour.
`892c7d1bc` (main's `c2ac5d546`, #977) made them counted and turned a latent
falsehood into a visible one — the same shape as SOUNDNESS.md's `T = 24`: a
claim no assertion defended. The census that finally said so carries COMMIT[0]
and no keccak table at all.

Worse, `keccak_precompile.elf` is built by the Makefile's `RUST_PROGRAM_DIRS`
wildcard and the prover proved it NOWHERE — its only reference in the tree is
`executor/tests/rust.rs`, which runs it in the executor. So the suite had no
precompile coverage on the multilinear path at all.

Both halves land rather than one:

- the precompile case now loads `keccak_precompile.elf` and asserts KECCAK,
  KECCAK_RND and COMMIT are argued. By PRESENCE, not an ordered census: this
  guest has never been proved on this path, so there is no measured set, and
  inventing one would repeat the mistake above. Its prove cost is likewise
  unmeasured. KECCAK_RC is deliberately not in that list — it is always-on and
  covered by the prefix, so matching it would let a run reaching no precompile
  satisfy a check named for one, which is exactly how the software guest passed
  for as long as it did.

- `a_software_hash_guest_argues_the_widest_table_set` keeps the software guest
  and pins its MEASURED 21-table census, because it is the widest set in the
  suite: it reaches MEMW, LOAD, STORE, BRANCH and EQ, the five families
  `the_whole_instruction_set_is_proved_and_verified` does not despite its name,
  plus public output and two PAGE tables including the stack page. A Rust guest
  doing ordinary work exercises more of the VM than the asm fixture named for
  the instruction set.

`state_depends_on_every_table_count` read 21 against a literal 20. ✓ VERIFIED
cause: #977 moved COMMIT into `TableCounts` — `pub commit: usize` is absent at
`892c7d1bc^1` and present now — so `each_count_mut` grew a field and the literal
did not. The fix is not 21. It is `statement::NUM_TABLE_KINDS`, the same length
`table_count_values` returns as `[u64; NUM_TABLE_KINDS]` and the guest absorbs
as `statement_replay::NUM_TABLE_COUNTS`; a bare literal there says nothing about
WHICH count is missing and is a second copy of a number the crate already holds.

`no_call_site_outside_the_pin_reaches_a_default_alias` flagged
`lfm/algebraic_commit.rs`, and it is a FALSE POSITIVE — but not blessed away as
one. Both matches are in `#[test] the_host_search_finds_a_valid_nonce_under_rpx`
(added by `80d746321`): `GrindingDigest<RpxStarkHash>` and
`GrindingDigest<Blake3StarkHash>`. Neither reaches a DEFAULT; both name their
hash, which is what §6.7 asks for. The second is a cross-hash CONTROL — BLAKE3
work must not satisfy the RPX predicate — and deleting it makes the test a
tautology.

The two halves are treated differently on purpose, because the allowlist's own
doc says it holds hash-AGNOSTIC items:
- `GrindingDigest` joins CONFIG_ALLOWED. Generic over the tag, selects nothing.
- `Blake3StarkHash` does NOT. The file joins BLESSED instead, so the concrete
  tag keeps flagging everywhere else — a site naming it on the block path under
  an RPX pin is precisely what this gate is for.

The BLESSED entry answers the reachability question the list demands rather than
stopping at "test-only", which that doc calls one scope too wide: the consumers
are `stark::grinding::generate_nonce::<T>` and `is_valid_nonce::<T>`, generic
over the tag passed at the call site, so no global is read; and the value never
leaves the test body, so the paired-default failure the list exists to catch has
no subject here.

⚠ LEFT RED, DELIBERATELY: `the_blake3_tenant_socket_matches_the_record`.
`BLAKE3_TENANT_SOCKET` is `Test` and `BLOCK_HASHER` has been `Rpx` since
`603c1e155` (2026-09-08), while the census it is a ratio against is
`bench_cache/optladder_2026-08-21/TIP/tip-wrappt-24.stdout` — eighteen days
older. The instrument is working: it says the module measures a shape nothing
proves. Moving the constant to `Rpx` is not a fix, because `RECORDED = (28, 3)`
is the *Test* socket's width pair, and that number's own doc says it moves the
headline ratio by a third in the FLATTERING direction if wrong. It needs a
re-recorded CHIP CENSUS under the current pin, which is a run, not an edit.
…ames its hasher

`the_blake3_tenant_socket_matches_the_record` is red and is right to be:
`BLAKE3_TENANT_SOCKET` is `Test`, `BLOCK_HASHER` has been `Rpx` since
`603c1e155` (2026-09-08), and the record this whole census is a ratio against is
`bench_cache/optladder_2026-08-21/TIP/tip-wrappt-24.stdout` — eighteen days
older than the pin. The instrument is doing its job: it says the module may be
measuring a shape nothing proves.

It could not SAY what the shape is, though. The socket assertion fires before
any tenant is built, so a red run yielded no number at all — and the obvious
cheap run does not yield one either. `wrap_tests::the_wrap_census` reaches
`lfm_chip_census`, which is `lfm_chip_census_with_hasher(program,
HasherKind::default())`, and `HasherKind::default()` is `Test` (`hash.rs:251`,
`#[default] Test = 0`). It reports this very pair under ANY pin: a confirmation
that cannot fail, on the one number whose error is flattering. That symbol is
itself one of the three `IMPLIED_HASH_SYMBOLS` the §6.7 gate hunts for, which is
what a census defaulting its hasher looks like from the other side.

So the test now prints every tenant's MEASURED `LFM_HASH` width before it
asserts anything, one line per tenant, each naming its `HasherKind` and flagging
the one that IS `BLOCK_HASHER`. A width without the tag that produced it is the
mistake this finding is about, so no line omits it. All eight tenants are
walked; both halves already go through `tenant.airs` + `tenant_tables` in the
algebraic and non-algebraic loops elsewhere in the module, so this adds no new
construction.

Nothing is re-blessed. `RECORDED` keeps its value and the assertions keep their
polarity, because the replacement pair has not been measured yet — that is one
cheap CPU run away, and this commit is what makes that run informative.

⛔ AND THE 2^24 REAL-BLOCK RUN IS NOT NEEDED, which the doc comment now says so
nobody schedules it later. The quantities split, and only the first is in
question:

  socket WIDTH (main, aux)        depends on the HasherKind alone   static per tag
  idle socket HEIGHT (4 rows)     the group floor                   static
  non-hash heights, 670,468,916   the workload                      socket-independent
  the 0.774x lever-0 ratio        arithmetic over the above         recompute

`tenant_log_heights` overrides `h[HASH_SLOT]` only for ALGEBRAIC tenants and
every non-hash height is socket-independent, so `RECORDED_WRAP_LOG_HEIGHTS`
survives the pin move intact. What is stale is one width pair, the socket
identity, and the ratio that is arithmetic over them.

The stakes are in the doc too, because the direction matters: `wrap_tests`
already puts the socket at 436 columns for RPO against the 28 of an idle `Test`,
and this module's own note says mistaking the idle chip for a live one moves
lever 0 from 0.774x to 0.632x — bigger, i.e. the flattering way.
…ve pin

MEASURED at c8c7c03 on the box, CPU-only, env clean — `LFM_HASH` main columns
by socket: Test 28, Rpx 316, Rpo 436, Poseidon 612, 3 ext aux throughout. Two
things fall out that nobody had: the pinned socket is 316, not the 436 the RPO
figure in `wrap_tests.rs` would have implied, and it is 11x the idle 28 rather
than the 106x a BLAKE3 socket would have been.

⛔ AND NOTHING NEEDS RE-BLESSING. `the_blake3_tenant_socket_matches_the_record`
was red because its assertion was wrong, not because its numbers were stale.
Both obvious repairs would have been mistakes:

- Re-bless the pair. No: `RECORDED = (28, 3)` is a faithful record of the BLAKE3
  tenant, and that tenant did not change when the block path's pin did.

- Point `BLAKE3_TENANT_SOCKET` at `BLOCK_HASHER`. No, and this is the one worth
  writing down. A tenant's socket width is a property of the build that PRODUCED
  the wrap proof, not of the build reading it. A byte-arm program emits no
  `Instr::Hash` and never consults the socket, so a BLAKE3-committing build has
  no reason to instantiate an algebraic one — `Test` is that build's own pin.
  Giving this tenant a 316-column socket would price a build nobody would ship,
  a socket paid for and never used, and would move the lever-0 baseline on the
  way past. The 0.774x stands, unrecomputed, because its inputs did not move.

So the constant and the pair both keep their values and the assertion is
replaced. It required

    assert_eq!(BLAKE3_TENANT_SOCKET, hash_pin::BLOCK_HASHER)

which ties a COUNTERFACTUAL tenant to the CURRENT build — a category error a
pin move was always going to expose, and `603c1e155` (2026-09-08) exposed it.

What it was reaching for is checkable, and is now two assertions that can each
fail. ✓ VERIFIED by reading: `hash_pin::BlockStarkHash = RpxStarkHash`, so
`BLOCK_COMMITMENT_HASH` is `Rpx256`, and `WrapHash::production()` maps
Rpo256/Rpx256/Poseidon to `Algebraic` — the production wrap EMITS `Instr::Hash`
and USES the socket, so it is the RPX tenant and the BLAKE3 tenant is the
comparison arm lever 0 is a ratio against.

  - `WrapHash::production() == WrapHash::Algebraic`. A commitment pin moved to a
    byte hash makes production a BYTE tenant: baseline and subject swap places
    and every ratio in this module needs re-reading before it is quoted.
  - some tenant is algebraic AND at `BLOCK_HASHER`. If the TENANTS table stops
    covering the pin, the census prices only builds nobody ships. The message
    prints the pin and the algebraic tenants so the failure names its own cause.

The panel from c8c7c03 stays and keeps printing before any assertion, so a
future red run yields numbers rather than a bare mismatch. Its measured widths
are now in the doc comment, as is the reason the obvious cheap census cannot
answer this question: `lfm_chip_census` is
`lfm_chip_census_with_hasher(program, HasherKind::default())` and that default
is `Test`, so it reports 28 under ANY pin.

⚠ The fork is recorded rather than hidden, because it is a modelling judgement
with a campaign number attached: taking the other reading is two lines,
`BLAKE3_TENANT_SOCKET = BLOCK_HASHER` and `RECORDED = (316, 3)`, and would put
lever 0 near 0.757x — ? INFERRED, from a linear fit of the doc's own two points
(28 cols -> 16 blocks/query, 2,980 -> 1,201) and then the algebra of its two
stated ratios, never measured. The module's own gate recomputes that number from
the tenant bills and prints it, so quote the gate and not this estimate. Either
way it clears the gate's thresholds (> 0.4255, within 0.30..0.95).
`run_grind` declared its result `uint64_t` and handed the address to the kernel
as `volatile unsigned long long *`. Those are the same type on Darwin/arm64 and
different types of the same width on LP64 glibc, so on Linux the cast
type-punned; with `#include "rpx.cu"` putting the whole kernel in this
translation unit, GCC 13.3 at -O2 was free under TBAA to assume a write through
`unsigned long long *` could not touch an `unsigned long`, and to keep `result`
in a register across the inlined call.

It did. `run_grind` returned UINT64_MAX for every input, so layer 8's two checks
per vector that expect the SENTINEL passed VACUOUSLY while the two that expect a
found nonce failed. Six rows, at every sha back to the gated base c00342c, on
a target that passes on a clang/arm64 laptop where the two types coincide.

Measured on the box at this sha:
  -O2                        6 FAILURE(S)
  -O2 -fno-strict-aliasing   ALL HOST KAT CHECKS PASS
  -O0                        ALL HOST KAT CHECKS PASS

None of it was ever a statement about the device grind. This file is a HOST
replay of the kernel source through `cuda_host_shim.h` — no nvcc, no cubin, no
device — so the defect was in the harness holding the result, not in the kernel
it tests. The production path re-validates every device nonce with the host
predicate, and the block pins read `host fallbacks 0` throughout.

`crypto/math-cuda/tests/host_kat/` holds exactly one instance of the pattern and
this is it: the shim's `atomicMin` takes `unsigned long long *` as a parameter,
and the kernels' casts there only drop `volatile` from an already-matching type.

Test-only; no production code and no proof bytes move. It also unblocks this
lineage's CI `host-kat` job, which has been failing since the device grind
landed.

(cherry picked from commit 90a38ec)
A WHIR run with no environment knobs set now reproduces the campaign's
landed number (128.3 s on block 25368371) instead of a slower shape the
launcher had to remember to correct. Two defaults move, both on the WHIR
driver alone.

FAN-IN 3, VIA A SECOND CONSTANT. `FAN_IN`'s doc has said since it was
written that "the measured host peak decides; until it has, the
conservative value stands". On the WHIR tree that peak now exists: the
arity-3 tree reads 31.1-31.5 GiB against a 33.5 GiB gate with `device
fallbacks 0` and `commit fallbacks 0` on every arm, and three is worth
-8.05 s of interior card time (wt29-32, A/B/B/A). The constant's own
criterion is met, so the default follows it.

It follows it in a NEW constant rather than in `FAN_IN` itself. Both tree
drivers read their arity as the fallback for `LFM_CENSUS_FAN_IN`, so
moving the shared value would silently re-base every STARK tree number on
record. `WHIR_FAN_IN = 3` confines the change; `FAN_IN` stays 2 and now
says why it stays for a CARD reason rather than a host one — the STARK
arity-3 interior node does not fit the device (ds15/ds16), which is a
constraint the host peak says nothing about. The cost is that "one place
to change" becomes one place per tree.

TOP OVERLAP ON. `tree_top_overlap` takes the caller's default instead of
hard-coding one, so the parse, the explicit `0`, the explicit `1` and the
panic on anything else are still written once. The WHIR driver passes
`true` — the lever measured -3.85 s alone (wt33-36) and -11.85 s beside
fan-in 3 (wt37-38), with the 15 wrap `program_id`s and the `whir global
IDENTITY` unmoved on both arms. The STARK driver passes `false`, which is
what an unset knob already selected there, so its behaviour is unchanged.

`LFM_CENSUS_FAN_IN` and `LFM_TREE_TOP_OVERLAP` still override on both
drivers, with the same parse and the same 2..=4 bound. `0` remains the
explicit off and is now the way back to the WHIR control arm, so a
level-0 wall from this binary stays comparable to every earlier WHIR run
by naming one variable. The WHIR level-0 banner prints on both arms for
that reason; the on-arm line still begins `★ TOP OVERLAP:` exactly, so
the gate that greps for it is unchanged.

Scheduling and tree shape only. Nothing here touches a transcript or a
challenge.
…the pure-WHIR landing

Merges fix2/lean-program @ 965e13d into land/pure-whir @ 6a6e266. S1a read
EFFECTIVE on FAST (job 223): the whole run 1.35 s faster, the big batches' early
device rounds 1,527 -> 442 ms, the argue 1.43 s faster, every arm proved and
verified with equal identities.

The merge brings S1a's ancestry from gfs/a45 with it, every knob default off:
A4 (LAMBDA_VM_ARGUE_LEAN_READS), A5 (LAMBDA_VM_ARGUE_LEAN_TAIL), the device GKR
layer timers under LAMBDA_VM_BASE_SPLIT, and the S0 census test.

One conflict, in stark's multilinear_table tests: both sides added tests at the
same place (the preprocessed-prefix tests here, the argue-knob tests there).
Both are kept.
LAMBDA_VM_ARGUE_LEAN_PROGRAM is now on unless set to 0, which is the opt-out
and the old path exactly. Its WHIR A/B on the block (FAST, job 223, at
965e13d) read EFFECTIVE:
- the whole run 1.35 s faster (band [-1.6, -0.5]);
- the big batches' early device rounds 1,527 -> 442 ms, the argue 1.43 s
  faster, the other batches' rounds +8 ms, and the late rounds held (no S1b);
- every arm proved and verified on the record's identities and census, and
  the untimed cross-check arm, which walks today's program beside every big
  session round by round, clean.

The proof does not change. The program on demand is the same steps on the
same operands in another order, so every round's values are the same field
elements, and so are the transcript and every challenge. The stark identity
test, the device parity test on the four big VM batches and the cross-check
arm showed it. The round sums of a big batch are added over another launch
shape, so their raw representatives, and a device proof's rkyv bytes, may
differ, as between any two launch shapes.

No pin moves. Only a batch whose lowered program holds more than 341 values
a thread takes the path, and only on a device: the WHIR byte gate builds
without cuda, and its EQ fixture holds 26. The W-LFM pins count the W-leg's
permutations, operations and hints, which no argue path changes. No STARK
path reaches the argue.

A unit test pins that the knob reads its variable the default-on way (unset
on, 0 off). A mutation restoring the old reading fails it.
@MauroToscano MauroToscano changed the title WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 56.55 s WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 45.80 s Sep 29, 2026
Under pure WHIR the recursion proves its LFM programs with the base's argue,
so the lean program's gate reaches their zerocheck batches too. A census of
the W-LFM chips (the WHIR recursion chip set at the recursion's hasher, as
whir_lfm_airs builds it) finds one big batch: LFM_HASH holds 365 values a
thread today, a 61,286-thread ceiling, and 34 on demand, 657,930, at 41 %
more steps. LFM_BITDEC holds 233 and stays under the gate.

- every_w_lfm_batch_on_demand_is_the_same_program: host parity on every
  W-LFM batch, and LFM_HASH as the only big one.
- the device parity test now finds its batches by the gate, over the VM's
  and the W-LFM's AIRs, and pins the five.
- the_w_lfm_zerocheck_programs_and_their_live_sets: the printing census,
  ignored.
Both RPX implementations (prover lfm::rpo::Rpo256::mds, used by the block
path's RpxStarkHash, and crypto::hash::rpx::mds) built each output lane with
a core::array::from_fn closure. The closure's generic from_fn wrapper is
placed in a codegen unit of rustc's choosing and is inlined into mds only
when that unit happens to be mds's own. When it is not, every lane is an
out-of-line call that recomputes (j - i) mod 12 with a 64-bit multiply per
term: about a fifth more instructions per permutation.

That is the two-speed host verify on the STARK tree. A Linux x86-64 cross
build of the prover test crate at 8934b59, ef6d4be and 3fd644e shows
mds inlined (2499 B, no calls) only at ef6d4be, the one FAST build, and
twelve closure calls at the other two, the SLOW builds. Crypto's mds makes
the twelve calls in its current partitioning too.

Loops over a precomputed circulant compile the same way in every build. The
permutation's values are unchanged: the RPO and RPX known-answer vectors, the
two-implementation agreement test and a new test against the circulant
definition all pass.
Under `parallel` the grind is `find_any`. It returns any valid nonce, 0
included, and the nonce it returns is absorbed, so every later state varies
from run to run. Two tests assumed otherwise and were flaky there:
- a_ground_proof_verifies asserted every spent nonce was non-zero, but nonce
  0 passes an 8-bit PoW with probability 2^-8.
- a_query_only_chain_checks_its_query_nonce_in_every_round accepted only Ok
  or GrindingRejected for a flipped query nonce. A flip that still passes
  the PoW is absorbed, moves the transcript and fails elsewhere.

The tests now read the verifier's grind checks through a logging transcript
that records each check's state and nonce:
- A ground proof's checks are exactly its spent slots, in order, and each
  nonce passes the PoW at its own state.
- A forged nonce is the first value above the honest one that fails the PoW
  at that state, so the check itself must refuse it with GrindingRejected.

The three a_forged_*_nonce_is_rejected tests forged nonce + 1 and carried
the same latent flake; they now take the same forgery.

Tests only.
Two P2-W tests read a nonce that grinding picks with find_any under the
parallel feature: one asserted it non-zero, the other expected a flipped
query nonce that still passes the proof of work to verify. Both now assert
what the proof of work guarantees. Tests only.
Every device entry point calls device::backend() before it touches the card.
A process-wide, monotone count of those calls (backend_entries()) lets a test
bracket a phase and show it never reached the device. The first user is the
W-LFM prove's host prep, which LFM_CARD_AFTER_PREP=1 moves outside the card
permit.

This is one relaxed atomic add per backend() call, with no other change.
…REP, default off)

prove_traces_whir_opening takes the card permit as its first statement, so
the card is held idle, and closed to every other proof, while the prove builds
its tables on the host. At job 222 that prep was 2.05 s of the pure-WHIR
recursion's 9.89 s of W-LFM holds, 1.68 s of it at level 1.

The prep moves unchanged into prep_whir_tables: the plan's layouts, each
table's main columns moved into its layout, and the prefix check. It takes no
device handle.

LFM_CARD_AFTER_PREP=1 takes the permit after that function instead of before.
Unset, empty or 0 keeps today's order; any other value panics. The setting is
read once and named once on stdout. The split line's wall stays prep + the
held part in both settings, so the wait for the card is in neither.

Tests:
- Laptop: the knob parse and the test override.
- Box, #[ignore]: the fixture's wraps and a wide node, proved with the setting
  off and on, serially and three at a time with the permit armed, under the
  deterministic grind. Every proof must equal the default's bytes.
- Box, cuda: device::backend_entries() must not move across prep_whir_tables
  for any job. The paired control requires that it moves across the prove that
  follows.
@MauroToscano MauroToscano changed the title WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 45.80 s WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 43.85 s Sep 29, 2026
The W-LFM prove now takes the card permit after prep_whir_tables, so its
host prep runs while another proof holds the card instead of holding the
card idle. LFM_CARD_AFTER_PREP=0 restores the permit before the prep.
Only the lock moves: card_after_prep_tests proves the same programs under
both settings, serially and three at a time, to the same bytes.

Measured on block 25368371 (FAST, A B B A): -0.65 s at fan-in 3 (job 234)
and -0.90 s together with fan-in 4 (job 236).
An unset LFM_CENSUS_FAN_IN now selects WHIR_WIDE_FAN_IN (4) when level 1
is wide, which it is by default under the W-LFM prover. Fifteen epochs
then make four wide level-1 nodes (4/4/4/3), and the block-artifact root
takes them directly beside the global wrap, so the interior level is gone.

The WHIR trees without a wide level 1 (LAMBDA_VM_LFM_PROVER=stark and
LAMBDA_VM_LFM_WIDE=off) keep WHIR_FAN_IN (3), and the STARK tree keeps
FAN_IN (2), so their shapes and program ids do not move.
LFM_CENSUS_FAN_IN=3 restores the previous pure-WHIR tree.

Measured on block 25368371 (FAST, A B B A): -0.70 s alone (job 235) and
-0.90 s with the card permit after the host prep (job 236).

The tree-shape and root tests gain fan-in-4 rows. The production root
(15 epochs at fan-in 4) joins the honest control, the fixed-size schema
and both L2G tamper tests.
On a wide tree, level 1 verifies epochs rather than proofs. With two to
fan-in epochs there is one wide node and no proofs below it, so root
option A, which takes level top-1's output, would hand the root that one
node against a fold shape expecting one digest per epoch; the drivers'
child-count assert then refuses the block. RootOption::for_tree maps A to
B there, so the root sits above the single node. One epoch needs no
mapping: its one node folds a single root to itself.

Tests: for 1..=40 epochs at fan-in 2..=4 the children a wide tree hands
the root equal what the option's fold shape refolds to, and A as named
fails exactly at 2..=fan-in epochs; the root over every small wide tree
(1..=6 epochs at fan-in 4) executes at the artifact's fixed width; the
moved-L2G tamper test covers one epoch and one node of three.
Both WHIR tree drivers now run the root option through
RootOption::for_tree, so a block of at most fan-in epochs composes under
the default wide tree. Such blocks failed the root's child-count assert:
up to 3 epochs at fan-in 3, up to 4 at fan-in 4. The 15-epoch block's
tree is unchanged.

The fixture tree becomes a body over a block (guest, private input,
epoch size, arity and the epoch count the run must reach); the
three-epoch fixture runs as before. the_whir_fixture_tree_proves_every_
small_block (box tier) proves 1..=6 epochs of a new guest,
test_private_input_spin, whose private input sets its spin count and so
its cycle count, each to a verified root at the default arity.
the_spin_guest_lands_every_small_epoch_count executes the guest on the
host and checks each input reaches its epoch count.
@MauroToscano MauroToscano changed the title WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 43.85 s WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 42.55 s Sep 29, 2026
The WHIR production driver's arity bound becomes 2..=5 so that fan-in 5
can be measured: LFM_CENSUS_FAN_IN=5 puts 15 epochs into three wide
level-1 nodes of five, and the root takes them beside the global wrap.
The default stays 4. The two STARK drivers keep 2..=4: nothing above four
has been costed on them, and their arity-3 node did not fit the card.

The tree-shape test gains the fan-in-5 row (15 epochs: 2 levels, 4
nodes); the root's fold-shape tests run at fan-in 5 as well, and the
fan-in-5 root (15 epochs, 3 nodes) joins the root gates' shapes.
WHIR_WIDE_FAN_IN becomes 5: fifteen epochs make three wide level-1
nodes of five, and the root takes them beside the global wrap.
LFM_CENSUS_FAN_IN=4 or 3 opts out; the stark opt-out and
LAMBDA_VM_LFM_WIDE=off keep fan-in 3, and the STARK tree keeps 2.

Measured on block 25368371 (FAST, A B B A, job 238) against fan-in 4:
-1.55 s (level 1 -0.76, root -0.70). Level 1's census falls from
1,052.8 M to 852.6 M cells and the root's from 270.9 M to 149.6 M (its
hash table stays under 2^18). The W-LFM argue's reservation peaked at
24.3 GiB of the 25.7 GiB budget: 1.4 GiB of margin, so a block with
heavier epochs should be measured before relying on five.

The small-block fixture test runs at the new default: one epoch, one
node of two to five epochs, and two nodes at six. The root tests gain
the fan-in-5 root in the tamper arms and the small-tree execution test.
@MauroToscano MauroToscano changed the title WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 42.55 s WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 41.00 s Sep 29, 2026
The WHIR_WIDE_FAN_IN doc comment, and b682091's commit message, read the
RESERVED HW figures, which the tree log prints in MiB, as GiB by dividing
by 1000. The W-LFM argue's reservation peaked at 24,342 MiB (23.8 GiB) of
a 25,688 MiB (25.1 GiB) budget at fan-in 5: 1,346 MiB of margin, not
1.4 GiB. Fan-in 4 peaked at 21,754 MiB. The ratio, and so the claim,
stands. Comment only.
When the card refuses a GKR fraction tree's carry and handing it back
saves enough to be worth it, input_layer_tree_impl asks for one promise
for the whole tree. If the budget refuses that too on the consume path,
logup::resident_tree gets None and the per-table argue builds the
table's factors and whole tree on the host: slower, never wrong, and
until now uncounted. The refusal is now counted where it happens, in
multilinear::gpu::gkr_tree_refusals and in math-cuda's argue-surface
device_fallbacks, and logged as it happens. A prefetch refused the same
way is not counted: it only means no prefetch, and the consume site asks
again.

The WHIR production tree prints "gkr tree refusals N" beside "device
fallbacks N", which now includes it. DEVICE_FALLBACKS' doc names its six
sites by function instead of line numbers that had moved, and says why
the other argue-side reservations in multilinear::gpu are not fallbacks:
the carry is speculative, a prefetch is optional, and reserve_room's two
callers keep the work on the card.

A test-only hand-back threshold (set_hand_back_threshold_for_tests) lets
a table smaller than the widest precompiles reach the whole-tree
promise. multilinear's call-site census pins the one counted site;
math-cuda's keeps its five.
A box test (cuda, ignored, run alone) on an ADD table of 2^12 rows,
whose factors go to the card. At the normal budget the card builds the
tree and nothing is counted. With every carry handed back and the rest
of the budget held by the test, a prefetch is declined uncounted, and
the consume path's refusal reads one GKR tree refusal and one argue-side
device fallback. The host's tree, the path the refusal hands the table
to, has the card's output fraction.
stark's cuda feature does not turn on multilinear's, and without it
upload_factors_from_columns is the host stub, so the table's factors
never go to the card and the test cannot reach the tree. The first gate
run failed on exactly that precondition. The test's doc, ignore reason
and expect message now name the feature set to run it with:
--features cuda,multilinear/cuda.
…_WHIR_HEAD_AHEAD)

Before the WHIR base's producer starts, the head commits DECODE's
univariate root on the host (about a second on the block ELF) and then its
prepared commitment on the device. On FAST job 239's default arms, epoch 0
began executing 1.50 / 1.42 s into the base and the first card commit came
at 2.62 / 2.55 s, with the card idle the whole time.

Epoch 0 needs neither value to execute. Its preparation reads the root and
its prove reads the prepared commitment. With LAMBDA_VM_WHIR_HEAD_AHEAD=1:
- a helper thread computes the root while epoch 0 executes, collects and
  builds, and the preparation waits for it at its first read;
- the prepared commitment keeps its place on the calling thread, so CUDA
  still comes up on the thread that proves, and the DECODE derivation
  count stays on that thread;
- the observer hears the shared DECODE work from the consumer, before the
  first epoch it proves, instead of before the pipeline.

Unset or 0 is the serial head, today's schedule. Anything else stops the
run. Every committed value is the same: the root comes from the same host
function of the ELF and the options, computed on another thread.

Both settings stamp the head under LAMBDA_VM_BASE_SPLIT=1: its start, the
root and the prepared commitment. Ahead mode also stamps epoch 0's wait for
the root, so an A/B can split the head.

A test proves a run serial and ahead under both preparation schedules and
compares:
- the DECODE root and prepared roots;
- every epoch's bookend root, shapes, output and register fini;
- the cross-epoch roots and the touched pages.
It checks that the ahead bundle verifies, and that the observer hears the
shared DECODE once, with the serial values, before any epoch is proved.
The WHIR base now computes DECODE's univariate root on a helper beside
epoch 0 by default. LAMBDA_VM_WHIR_HEAD_AHEAD unset, empty or 1 runs the
head ahead. 0 is the named opt-out: it runs the serial head, exactly as
before.

FAST job 241 (wt1030-1033, two arms each against the serial head):
- epoch 0 began executing 1.03 s earlier;
- the first card commit came 0.73 s earlier;
- the base took 0.65 s less and the block 0.55 s less (40.65 -> 40.10 s);
- level 1 was unchanged, and the program ids were unchanged;
- epoch 0's preparation never waited for the root.

The switch's test now pins the new reading, ahead unless exactly 0. The
byte-identity test still proves both heads through the explicit
parameter.
The WHIR base's DECODE root ahead by default (eb20fe0), with the GKR tree's
refused promise counted as a device fallback and the fan-in-5 VRAM margin
quoted in MiB (4593752).
@MauroToscano MauroToscano changed the title WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 41.00 s WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 40.20 s Sep 29, 2026
…default

The wide lead-in's helpers claimed a node only once the base reported its
epoch count, which it does when it executes its last epoch (t0+24 s at
fan-in 5, 15 epochs). Nodes 0 and 1 had their epochs by t0+10 and t0+19,
yet their prologues ran in the base's tail and finished after the base.

A node whose epochs have landed AND whose next epoch has landed cannot be
the run's last, so none of its epochs is final and its range is its full
span. Those are the only things a prologue reads from the count. The
helpers now claim such a node before the count and hand the builder the
epochs known to exist, (k + 1)·span + 1; the builder then builds exactly
what it would build with the count. The last node, and any run of at most
fan-in epochs, never qualifies and still waits for the count.

LFM_TREE_EARLY_CLAIM unset or 1 is the default; 0 waits for the count as
before. The driver prints which one ran, and the lead-in prints a line
per early claim.

Measured on block 25368371 (FAST job 244, arms A B C C B A): the claim
alone, against the default, whole run -0.50 s, level 1 -0.60 s, base
+0.10 s; the 5 program ids unchanged.

Card-free tests: the switch, the claim at the next epoch's landing, and
the boundary (the last node and a one-node run wait for the count; without
the claim nothing is built before the count). Box tier:
the_early_claim_moves_no_node_byte proves the fixture's wide level 1 with
the count and with the claim, and compares every node's program id,
heights, published words and proof bytes.
…unt, by default"

This reverts commit deb9726. Its A/B read
-0.50 s (job 244), but at the landed head it measured no effect: 40.30 s with
the claim and 40.30 s without it (job 245). The code stays on
land/1010-early-claim and fix2/1010-early-claim.
MauroToscano added a commit that referenced this pull request Sep 30, 2026
Fills G6-LEDGER.md section 4 from job 249 (tags wt1200-1203) and sizes the
SOTA levers c.3 (GPU trace generation) and c.5 (jagged PCS, real-row argue)
against their measured terms.

- The base card sits idle 7.50 s of 31.54 s (nsys arm). The idle splits as
  head 1.85, argue 4.27, open 0.53, commit 0.27 and global 0.29 s.
- The trace upload is 35.6 GB of pageable H2D, 97 % of it serialized with
  no kernel alongside. The producer waits 9.6-10.6 s per run, so it is not
  on the critical path after the head.
- Padding, corrected for a DECODE join error in the census: 13.62 % of the
  tables' cells, 27.43 % of the stack. The argue padded share is 17.04 %.
- c.3 caps at 2.57 s. c.5 caps at 3.8-4.3 s gross, and only with stack
  chunks of 2^24 or smaller.
- Triages the census's 17 consistency findings: 15 benign (an empty L2G
  trace built per epoch), 2 a DECODE join error, corrected in the note.

Adds the box driver, the three analysis tools and the job's text output
(secrets-scanned).
MauroToscano added a commit that referenced this pull request Sep 30, 2026
The process pool kept its two 3.22 GB slots for the life of the process. On
#1010's production tree the host peak is in level 1 (16.2 GiB against the
base's 9.6), where no epoch needs a slot, so kept slots would add all 6.4 GB
of them to the whole run's peak. D-TRACE-1B §3.4 sized the base's peak only.

SlotPool::release_free frees every block nobody holds; a later fill makes
them again, and until then a lease misses as not ready. The WHIR base calls it
on a helper as soon as the last epoch is proved, beside the cross-epoch proof,
and joins the helper before it returns (PINNED SLOTS: released … line).

Tests: releasing frees only unheld blocks, a held block survives and comes
back, the next fill remakes them (heap blocks, laptop); the one-slot pipeline
test now asserts the slot is back and released after the run.
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