Skip to content

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

Closed
MauroToscano wants to merge 1067 commits into
mainfrom
whir/recursion-rpx
Closed

MauroToscano wants to merge 1067 commits into
mainfrom
whir/recursion-rpx

Conversation

@MauroToscano

@MauroToscano MauroToscano commented Sep 22, 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;
  • main, merged.

Block 25368371 proves in 60.20 s.

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,
d1dc45514, the two default arms of the last ABBA read 59.8 s and 60.6 s (mean 60.20 s). Host peak 19.6 GiB,
device peak 26.9 GiB (27,570 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 → this head's defaults 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 %)

In the last 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 (#985) 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 this head
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 #985'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 #985)

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.

What is in the branch

  • Per-table GPU recursion (Per-table GPU recursion (RPX) with ZisK-style proof formats: block 25368371 in 78.80 s #985): 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 six
    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.
    • 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.
    • 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 (Per-table GPU recursion (RPX) with ZisK-style proof formats: block 25368371 in 78.80 s #985).
    • 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: this head.
    • 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.
  • 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. The pipeline minimum is unchanged at 128.946 bits, set
      by the query phase of every LFM proof.
  • 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. Where the grinds sit and how many bits they take are unchanged in this PR (see Open decisions); 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 batch was gated at this head, 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 this head, 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 Per-table GPU recursion (RPX) with ZisK-style proof formats: block 25368371 in 78.80 s #985 carry the same code and differ in two defaults: one_row (off here, auto in Per-table GPU recursion (RPX) with ZisK-style proof formats: block 25368371 in 78.80 s #985) and
    the LFM_HASH split (off here, on in Per-table GPU recursion (RPX) with ZisK-style proof formats: block 25368371 in 78.80 s #985). A per-pipeline default would let one PR carry both.
  2. Grinding only before the queries (P2-W).
    • Measured −9.65 s at stack 25, where the chains ground 2,999 times; with P2-W they ground 1,059 times.
    • At stack 27 the base and global chains run 423 rounds instead of 964, so P2-W would drop about 40 % as many grinds.
    • At this head those grinds take about 3.4 s in all (base chains 2.7 s, global 0.75 s), against 6.4 s at the
      previous head: K4 and K5 halved them. P2-W would save about −1.5 to −2 s here [estimate, scaled by the grind
      time].
    • Under the accounting above it costs no proven bits, since the fold grind earns no credit as placed.
    • The unread nonce fields would have to be dropped or required to be zero.
    • A security decision.
  3. 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).
    No code changes; the decision is which configuration to report.
  4. 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,634 MiB of the card's 32,607 MiB. Worth a run on
    a heavier block before relying on 27 there.
  5. Batched WHIR openings (not built). Their batch cap must be re-derived from the unground fold bits: at 27, K ≤ 5
    keeps 128 bits.
  6. Protocol changes. W3 (WHIR query carry-over) and W4 (the WHIR paper's rate schedule) are analysed, not built.
  7. An LFM lookup chip would let larger caps pay.
  8. RV64 proof bytes are not reproducible across processes, because six table builders order rows by HashMap
    iteration.

The cap on the genesis leg is a budget, and the quantity that decides
whether a real block fits under it is a function of the ELF and the
touched page list alone — no proving. This counts it, per page and in
one line, so the number can be read off a box run rather than argued.

It prints the split as well as the total: many pages with a handful of
entries each is a different situation from one dense page, and a total
alone cannot tell them apart.
…esidue

Two gates whose absence was the same defect in two places — a check
reading a quantity that cannot move under the failure it is for.

The cross-epoch wrap's published set is now compared word for word
against the roots the DRIVER took from the host verification's own
return, not against anything this program derived. The emitter reaches
the same roots by accumulating `num_polys()` over the group layouts it
built, so the two index arithmetics have to agree — which is what a
bookend root published for the wrong epoch would break, and what nothing
else in the suite could see, because such a program executes perfectly.
It carries its own anti-vacuity arm: two epochs whose bookends were
committed under equal roots would make a transposition invisible, so the
roots are asserted pairwise distinct first.

The global statement's pad identity was pinned at ONE shape, where the
pad is zero and both sides of the comparison are zero with it. A form
wrong by a multiple of eight would have agreed. The sweep asserts the
identity at every residue and asserts that it REACHES every residue,
because a sweep that only visited pad-0 shapes is the same vacuous check
with more iterations.
Beside the OFFSET ramp's gates and on the same terms, because the two
legs make the same kind of claim and fail in the same two ways.

The value arm runs the EMITTED leg against a real page's INIT column —
`page::preprocessed_columns`' own column 1, not a hand-built vector —
and compares it against the host's 2^18 fold and against the host closed
form, three derivations with no two sharing an author. Its anti-vacuity
assert is on the ANSWER: a leg whose coefficient loop emitted nothing
returns the interned zero, and over a column with support the true value
at a random point is not zero.

The bit order gets its own arm because a convention agreed with itself
is not pinned: the reversed point must give a different value AND must
disagree with the fold, so the claim is checkable rather than shared.

The row form is a DELTA between the leg and a control that publishes the
same one word, which cancels the hints, the publish and the compiler's
overhead. Three column shapes, including the empty one, where the leg
costs its complements and nothing else.
… is really for

The genesis cap is a budget and the quantity that decides whether a real
block fits under it has never been measured. This is the arm that
measures it: a block bundle, the census printed BEFORE the program is
built — so that if the census is over the cap, the refusal lands in a
log that already carries the number instead of as a panic with no
context — then the program, its arena and its F1 decomposition.

Nothing it asserts is a count. The block's shapes move with the posture
and pinning them would pin the posture; what it asserts is structural
and holds for any program. It SKIPS on a missing ELF and says so on its
own line, so a laptop run cannot be read as a block run, and it prints
the ELF's full 64-hex sha256 rather than a prefix.
…-epoch plan

`whir_real_global.rs` is byte-identical to 2f07293's again. The page
configs the route table reads are built where they are used — in the
cross-epoch plan, from one call of `global_memory_configs` with the
arguments `global_airs_for` itself passes.

The emitter now NAMES its ELF and refuses a different one. That is
better than the field it replaces, not just less intrusive: the genesis
binding is the ELF, so taking it explicitly and asserting its digest
against the one the harvest recorded makes 'emitted against the ELF the
proof was verified against' a build failure instead of a trust. An
emitter handed another ELF would intern another page's genesis bytes and
refuse hundreds of thousands of rows later, with nothing naming why.

The census is prove-free and card-free, as it should always have been:
the touched page list is an EXECUTION fact, so it comes from
`block_page_census` — the guest run with every prove and trace build
omitted — and the census needs no proof, no AIR and no bundle. Its box
arm refuses to run unless the caller states the ELF's and the input's
full sha256, because the page list is a function of both and a census
quoted against the wrong input is this campaign's own recurring defect.

The refusal arms are rewritten around inputs rather than a derived flag:
one restates `num_private_input_pages`, the other rebuilds the AIR set
through the verifier's own helper with a different count. Both are
disagreements a real caller could produce.

The cap is renamed MAX_SPARSE_INIT_ENTRIES and documented as a
PLACEHOLDER owed a census, with the fallback named where it is refused —
a host commitment absorbed in the roots block, opened through a Prepared
that carries a table list — so the choice is made against the number.

And the statement the interned genesis bytes owe is written where they
are interned: not 'a root equals compute_precomputed_commitment's
inputs' but 'the interned entries are the ELF's genesis bytes at those
page bases'.
…he caller

`verify_global_bookends` read the process knob for itself, which meant
nobody could ask it the one question that matters: was the bundle in
front of it proven under this hash? `whir_hash_knob::selected()` says
what the process proves under, never what a proof was made with. The
agreement is the verification — the transcript's sponge is part of the
configuration, so a bundle proven under the other hash diverges from the
first squeeze — and a function that chooses for itself cannot be pointed
at it.

So the bookend form takes `H` and the dispatch moves out to its two
callers, which is the arrangement the epoch half already had at
`verify_epochs_bookends`. Nothing about what is checked changes: both
entry points pick the same `H` from the same knob the macro read one
level down. The byte gate and the transcript pin pair are the proof of
that, and both are unmoved — keccak 0bc7b999…a60ee25e, rpx
fcfbf8fe…6d044682, IDENTITY-LEN 6904 under both; prove (585155, 186256,
3251) and verify (585307, 186286, 3251).

It is also `pub(crate)` now, because a driver that could only reach the
`bool` form had to re-derive the bookend roots from `stacks` — a second
spelling of the group split, which is the drift this lineage keeps
finding.

The new test is the cross-epoch sibling of the epoch's hash-refusal arm,
and it could not have been written before: it asserts that EXACTLY ONE
of the two hashes accepts, which is at once the control and the refusal.
The shape is knob-independent on purpose, since the bundle is proven
under whatever the suite was started with, and it prints which direction
the run exercised rather than leaving a reader to infer it.

Two more things this file's suite was missing.

Every refusal test now asserts its untampered bundle VERIFIES through
the same call first. Without that a verifier broken in any way keeps
them green, because one that rejects everything rejects the tampered
bundle too; on the restated-page-set arm it carries more weight still,
since that arm accepts `Err` as well as `Ok(false)`. Breaking the
cross-epoch AIR set reddened one of nineteen tests before these
controls.

And `a_swapped_bookend_root_is_caught_by_the_binding` reaches the
comparison the file is named for. Nothing else did: the arm that moves a
root inside an epoch's own proof is refused by the epoch half, which
returns before the cross-epoch half runs at all, so its claim that "both
halves still verify on their own" was false for the tamper it performs
and is withdrawn here. Swapping two epochs' roots inside the CROSS-epoch
proof leaves every root a valid commitment and only the order wrong,
which is the one tamper `proved == chained` exists to catch.
…urface

`real_global_from_whir_continuation_under::<H>` now exists and means
something: the verifier's bookend form takes `H`, so the driver can ask
whether the bundle was proven under the hash it was told, exactly as the
level-0 driver does. The dispatching wrapper above it stays the
production entry point.

The bookend roots come from that verification instead of being
re-derived here. The earlier draft rebuilt them from `stacks` because
only the `bool` form was reachable, and a second spelling of the group
split is the thing the AIR set was extracted to prevent — the same
argument, applied to the values the published set carries.

The module-scoped `dead_code` allow is gone. Its condition was a
production reader that does not exist yet, so the honest remedy is to
publish the surface rather than suppress the reports: the module, the
struct's fields and its accessors are `pub`, and `WhirGlobalAirs` with
`refs()` and `groups()` are `pub` beside them, since the accessor
returning one cannot be public while the type is not. Measured, not
asserted: `cargo clippy -p lambda-vm-prover --lib` reads 0 errors and 0
dead_code mentions with the allow removed, where it read three errors
with it needed. The level-0 driver's allow is untouched; its condition
is documented and it has its own reader coming.

Each refusal arm here gained the honest-path control too — the
untampered bundle must harvest through the same call before the refusal
below it proves anything.
`zeroing_the_nonces_does_not_make_a_ground_proof_reproducible` was
intermittently red — three green of four full-suite runs on one tree,
eight of eight in isolation, on a path no recent commit touches.

Its early return guarded on ALL the nonces while the assertion below
sees only what `after_the_grind` captures: `final_value` per chain and
`(next_root, ood_value)` per round. A chain's LAST round has both of
those `None`, so a run whose only differing nonce is there passes the
guard and then compares two equal lists. ? INFERRED from the failing
output, whose per-round tuples were identical and whose third entry was
`(None, None)`, rather than from instrumenting the search:

    left:  [(.., [(Some([118, 57, ..]), Some(..)), (Some([99, 165, ..]), Some(..)), (None, None)])]
    right: [(.., [(Some([118, 57, ..]), Some(..)), (Some([99, 165, ..]), Some(..)), (None, None)])]

So the guard now reads the nonces of the rounds that have a successor,
and a probe makes the narrowing observable rather than asserted: it
clones the proof, flips a nonce in every successorless round, and
asserts the round exists at all, that the probe really moved a nonce,
that the narrowed guard does NOT see it, and that nothing the assertion
compares moved either. Widening the guard back turns the third of those
red without waiting for the flake to recur.

The doc sentence claiming a failure here "would fail LOUDLY and name the
reason — which is the correct outcome, not a flake" is withdrawn in
place. The observed failure named nothing; a guard that admits a state
its assertion cannot satisfy is a check that cannot pass, not a report
about the prover.
…r/lfm-l0

Thirteen of seventeen overlapping files conflicted. Every resolution below is
semantic; none is "take one side" except where stated, and the reasons are here
rather than in a note because the next reader of this history is the one who
needs them.

## The encoding: two branches that both reached the same version number

DOMAIN_TAG was V4 on this lineage (it appended TableCounts::blake3) and V4 on
main (it appended the six accelerator counts). Two different encodings, one
suffix — exactly what the tag exists to prevent. Every tag, with what it was on
each side and what it is now:

  LAMBDAVM_STARK_STATEMENT              lineage V4 | main V4 -> V5
  LAMBDAVM_CONTINUATION_EPOCH           lineage V3 | main V4 -> V5
  LAMBDAVM_MULTILINEAR_STATEMENT        V1               -> V2
  LAMBDAVM_MULTILINEAR_CONTINUATION_EPOCH_V1                unmoved
  LAMBDAVM_CONTINUATION_GLOBAL_V2                           unmoved
  LAMBDAVM_MULTILINEAR_CONTINUATION_GLOBAL_V1               unmoved

The three that moved are the three whose statement absorbs the shared per-table
count list, which grew by six. The two GLOBAL tags bind no per-table counts, so
nothing in their encoding moved. Every suffix is the same byte length as the one
it replaces, so no length-derived constant moves with them.

⚠ MULTILINEAR_CONTINUATION_EPOCH is the one to read twice: its statement DOES
absorb the count list, through the same shared helper. It is left unmoved
deliberately — the WHIR epoch encoding is not this port's to version, and the
one thing that would force it (binding is_final there, as the STARK statement
now does) is explicitly out of scope and recorded as an owed parity question.
Whoever takes that question up bumps this tag with it.

RECURSION_INPUT_VERSION had the same collision and it is the dangerous one.
The prefix version IS checked — recursion_archive_bytes refuses a number it does
not recognise before the guest's in-place read — so an OLD blob fails legibly.
What that check cannot catch is two DIFFERENT layouts sharing one number, which
is what we had: the lineage's v3 is the one-epoch-proof format, main's v3 is the
six accelerator fields, neither archive can read the other, and both claimed 3.
Keeping either suffix would have been a silent misread at the wrong offsets. The
merged format carries both changes and is v4, so every recursion guest ELF must
be rebuilt; one built before the bump refuses a v4 blob rather than misreading
it.

## The count list

The merged count list is TWENTY-ONE, and that number was read rather than
assumed: the two sides' additions are disjoint. Main's TableCounts has no
blake3 field, the lineage's has no accelerator fields, and the fourteen split
families are shared — 14 + 6 + 1 = 21. Each of the twenty-one reads a distinct
source in Traces::table_counts (twenty Vec lengths and num_blake3_ops), so no
table is counted twice. Getting this wrong in the other direction would not have
failed a test: both sides of the transcript pin derive the term from
NUM_TABLE_KINDS, so a double-absorbed blake3 would have moved the pin and still
passed it.

statement.rs keeps this lineage's shared helper (absorb_table_counts /
table_count_values) rather than main's inlined loop, and the six accelerator
counts are added to it. That is the whole reason the WHIR statements pick the
change up for free: multilinear_continuation::absorb_epoch and
multilinear_prove both call the same helper. NUM_TABLE_KINDS 15 -> 21, and the
exhaustive destructure that makes a new TableCounts field a compile error moves
with it.

lfm/statement_replay.rs carries a second, independent copy of that number
(NUM_TABLE_COUNTS) for the guest-side replay: 15 -> 21, plus the trailing
is_final byte the host now appends. Nothing in the type system ties the two
constants together; what catches a drift is the host-versus-machine challenge
equality in lfm/algebraic_transcript.rs and its hand-written array literal,
which fails to compile if one moves without the other.

## AIR sets and table counts

FIXED_TABLE_COUNT 11 -> 5. The lineage's eleven minus main's six accelerators is
exactly main's five (BITWISE, DECODE, HALT, KECCAK_RC, REGISTER), so the two
lists agree with no residue. BLAKE3 stays a counted chip and joins main's
accelerators in TableCounts::validate's at-most-one list, where its own upper
bound used to sit alone.

VmAirs takes main's counted vectors for the six accelerators and keeps this
lineage's conditional BLAKE3. Git line-merged both models into a body that
pushed the singular AIRs and then looped over the vectors; that could not have
compiled, and it is why this file was read rather than merged. BLAKE3 now closes
the accelerator group instead of keeping its old slot between KECCAK_RC and
ECSM, because that slot no longer exists — air_trace_pairs and air_refs make the
identical choice, which is the only obligation, since those two orders are the
proof's sub-proof layout.

## Two of main's hunks were NOT taken, and one of its fixes was

Not taken: main builds keccak_rc and register with with_preprocessed (the
commitment alone) where this lineage builds both with with_preprocessed_columns
(commitment plus the column generator). Taking main's would have reintroduced
the defect 5e3df0c fixed — the multilinear verifier checks the precomputed
columns' claimed openings, and an empty column list is checked in zero
iterations, which left REGISTER's INIT and FINI prover-chosen on the WHIR path.
Git reported this as an ordinary content conflict with nothing to say that one
side was a soundness fix.

ANY FUTURE MERGE OF MAIN MUST MAKE THE SAME CHOICE. On the production VM path
every preprocessed AIR takes the columns form — bitwise, keccak_rc, register,
the page AIRs, and continuation.rs's. A main-side hunk that reverts one of them
to the commitment alone is a soundness regression wearing the clothes of a
whitespace conflict.

Not taken: prove_elfs_tests' bus harness keeps hash_pin's pinned transcript and
BlockVerifier, where main's hunk used a plain DefaultTranscript and Verifier.
Main's improvement to that harness IS taken — a prover failure now panics
instead of returning false, so a negative test cannot pass because proving fell
over. Main's target-moved recheck is rewired to the same pinned transcript as
the accepting arm; on a plain one the two arms would have differed by hash as
well as by target, and a rejection would no longer have been evidence about the
target.

Taken: main's missing count_merkle call in the field_element Merkle backend.
merkle_nodes was NOT a subset of merkle on this lineage's byte path — the
algebraic path's count_merkle_node_direct bumps both counters, the byte path's
hash_new_parent bumped only the node one. The printed merkle figure therefore
moves by exactly merkle_nodes on keccak runs. No pin reads it.

## hash_metrics: the same module, written twice

An add/add conflict. Both branches independently wrote crypto's hash_metrics
with the same feature name, the same Counts type and the same closing doc
paragraph. Resolved as a union: this lineage's transcript counters (which is
where the WHIR transcript pin is read from) plus main's perms counter and
count_finalize(nbytes). Both count_finalize and count_total survive on purpose —
only the byte sponges can report a byte count, so the algebraic backend keeps
count_total and leaves perms alone, which is right, because perms counts
keccak-f permutations.

crypto/stark/src/grinding.rs takes this lineage's re-export shim. Main's two
count_grinding calls are not lost: the implementation moved to crypto's own
grinding.rs, where both calls already sit in the same two functions main
instruments. Both Cargo.toml conflicts were the same hash-metrics feature
declared identically on both sides.

## Consequences this merge is expected to have

The STARK wraps' programs move, and in both directions. Empty legs are elided,
and separately the LFM statement's fixed part goes from 215 to 264 bytes, so its
trailing residue moves from 3 to 0 mod 4 and the Phase-A shift-3 splice
statement_replay's own doc complained about should disappear. The WHIR
transcript absorb totals move by six per epoch statement; the pin derives that
term from NUM_TABLE_KINDS rather than carrying it as a literal, so the expected
value tracks. recursion_smoke_test's keccak_permute census numbers predate this
merge and are a print rather than a gate; they are flagged in place.
…s, not during

E0506: `airs()` borrows the whole driver and the lie this arm tells is a
field of it, so the precondition read and the restatement could not both
hold the same value. The precondition now reads in its own scope and the
AIR set is re-taken after the field moves.

Nothing about what the arm proves changes. It still restates an INPUT —
a bundle whose page is a private-input page saying the run had none —
against an AIR set that is untouched, so the route expects OFFSET and
INIT from a table presenting OFFSET alone.
THE REFUSAL WAS A CONFIGURATION MISMATCH, not a defect in the emitter,
and the gate log said so on a line above the failures: the process ran
under LAMBDA_VM_WHIR_HASH=keccak256. The machine's transcript is the
ALGEBRAIC sponge — `whir_transcript.rs`' own header says the mirror is
`DefaultTranscript<E, RpxTranscriptHash>` — while the cross-epoch prover
and verifier both dispatch on that process-wide knob. So the bundle
carried a keccak challenge stream, the machine replayed an RPX one,
every challenge diverged from the first squeeze and the honest proof
refused at its first table. The level-0 suite documents this exact
failure at `driver_bundle` and solves it by re-proving its epochs under
a literal RpxWhir; that door is shut here, because `prove_global` and
`verify_global` take no hash parameter and re-implementing either in a
test would be a second derivation of the cross-epoch prover.

So the three executing arms are `#[ignore]`d with the knob named in the
reason AND refuse when run in the wrong posture. Both halves: the ignore
keeps a default run from going red over a configuration it never set,
and the refusal stops a deliberate run in the wrong posture from
producing a DivByZero nobody can place. A silent skip would have been
neither — green in every default run, and unable to fail.

A refusal now names its site. `locate_addr` maps the reported address to
the instruction that wrote the cell and its neighbours, because a
DivByZero is always a failing equality and the address always names the
difference.

THE POOL is asserted as what is provable and pinned as what is not. Two
emitters intern words no cost form reports — `emit_newton_step`'s
interpolation weights, and the chains' own constants, which
`own_constants` says in its doc it does not name. So the words the form
NAMES must all be interned, which is exact and is what a deleted leg
breaks, and the unnamed remainder is pinned at the measured 24 with its
source named. The total assert is gone: `ops` is defined as the total
minus the other three, so given their asserts it had two sides that
could not differ.

Also: clone_on_copy on two Copy field elements, and rustfmt.
W1g's reading — that the INIT leg cannot be gated on
`test_private_input_xpage` at all — is right, and the answer is the
second fixture rather than a comment claiming coverage. So the route
table now names, per arm, the run that reaches it and what that run
cannot show.

The part worth writing down is the third row: NEITHER fixture shows the
MIX. A block carries both kinds of page at once under a sixteen-group
split, while at fixture scale the page group is a singleton and
shape-identical to a bookend's. So the route table's behaviour on a
mixed set rests on the block instrument, and saying that is the
difference between a stated gap and a false guard.
THE ARM COULD NOT HAVE FAILED, AND ITS MUTATION COULD NOT HAVE FIRED.
It ran on the `data_page_touch` bundle, which reaches ONE epoch — the
box read its published set as `publics 6`, which is `2 + 1 x 4`. With
one epoch the pairwise-distinctness guard iterates zero times, and
reversing a one-element published order is the identity, so the
transposition mutation the arm exists for was a no-op against a check
that was vacuous. Two of this campaign's catalogued failures from one
fixture choice.

`test_private_input_xpage` reaches three epochs, so both become real,
and the arm now refuses a bundle of fewer than two rather than leaving
the next reader to notice.
Its closing assert compared `census.len()` against the touched page
count, and the comment above it called that "a count that can disagree".
It cannot: the census maps one entry per config and
`global_memory_configs` maps one config per base, so the identity holds
by construction and no input reaching this arm could make it false. A
check that cannot fail, with a comment asserting the opposite.

It now checks relations between fields the census reads SEPARATELY,
which is where a wrong column would actually show: a page that loads no
genesis bytes must have no nonzero entries, a page cannot hold more
nonzero genesis than it loads, and a private-input page — whose genesis
the verifier never recomputes — must be charged none. The structural
identity is stated as an argument instead of dressed as a check.
W1g's `whir/lfm-global-airs` @ 1f7a0cd into the cross-epoch program's
branch: the verifier generic over the hash with the dispatch moved to
its callers, `real_global_from_whir_continuation_under::<H>` beside the
knob-dispatching form, the driver's fields and accessors `pub`, and the
identity test guarded on the nonces its assertion can see.

Clean over six files, none of them this branch's, because the route
decision was moved onto `GlobalPlan` precisely so `whir_real_global.rs`
would stay byte-identical while W1g worked on it.

⛔ IT DOES NOT RETIRE THE EXECUTION ARMS' `#[ignore]`. The new
`_under::<H>` names a hash for the VERIFY; `prove_global` still takes no
hash parameter and dispatches on the process knob inside itself, so in a
default process the bundle is still PROVED under keccak and the
machine's algebraic transcript still cannot replay it. The posture guard
stays until the prover moves too.
THE FORM EXISTED AND COULD NOT BE USED. `sumcheck_round_consts` has
always counted the pairs `(1/(j+1), -j/(j+1))` a degree-d round
interns, and a count is exactly what a program-level pool cannot
consume: the builder interns on the canonical word, so a degree-13 round
and a degree-3 round SHARE their first two steps, and adding their
counts charges four words twice. There is no scalar that composes. That
is why the cross-epoch pool carried an unnamed remainder, and why
`StackedCost::own_constants` could only disclaim the chains' constants
rather than name them — they are these, reached through the chains' own
sumcheck rounds.

THE LAW that makes one call enough: the pairs NEST in the degree, so the
union over every round of every leg is the set for the LARGEST degree
among them. `sumcheck_round_constants(D)` is therefore a whole
program's Newton pool, with D read off the shapes — the GKR ladder's 3,
the reduce's 2, the chain's 2, and the tables' own
`sumcheck_degree()` — and never off a proof.

It returns a deduplicated SET, not a count, because these are field
elements and nothing forbids two pairs colliding in Goldilocks; a
collision would make the true pool smaller, and the identity against the
count form is asserted over a range so that such a collision is
discovered as a finding rather than as an unexplained gap.

The emitter and the form now share one derivation, so the pool a program
pays and the pool a form predicts are the same expression. The gate
against the EMITTER therefore computes its oracle a second way, in the
extension field throughout, because a test whose oracle shares the
function under test agrees with itself.

The cross-epoch F1 asserts its pool BY VALUE in both directions and the
pinned remainder is deleted.
The harness stage below builds against `real_global_from_whir_continuation_under::<H>`
and `WhirRealGlobal`'s published fields, neither of which exists at this branch's
base. This is the merge that brings them.
The cross-epoch F1 found the interpolation weights interned by every
sumcheck leg and named by no form. The epoch program has the identical
gap and has never had it measured — no F1 there compares a pool at all,
only staged row deltas — so this reads the number off the assembled
program and prints it for the ladder note to aim at.

It asserts what is exact and prints the rest: the Newton set through the
degree the program reaches must be interned, which can fail, and the
remaining constants are reported rather than pinned, because naming them
is other forms' work.

`interned_newton_degree` reads that degree out of a pool, which gives
the cross-epoch F1 a SECOND SOURCE for a number it otherwise derives
from the shapes alone. The two must agree, and a disagreement is a real
finding: a leg running at a degree no shape predicts, or a form naming
one no leg reaches.
…ree harness

Level 0 is followed by the WHIR GLOBAL stage and the interior by the ROOT over
`fan_in + 1` children, so the WHIR driver composes a block artifact rather than
stopping at a closed interior and printing that it is not one.

`prove_whir_global_child` is a SIBLING of `prove_global_child`, not a
generalisation, and the reason is a type: that one takes the STARK
`continuation::ContinuationProof` and returns `Option<(RealGlobal, RealChild)>`,
while the WHIR bundle is `multilinear_continuation::ContinuationProof` and its
harvest is `WhirRealGlobal`. What it produces is a plain `RealChild` — that type
holds the harvest of an LFM PROOF and knows nothing about what the proof
verified, which is why one child type serves both families.

Four differences from the STARK stage, each with its reason in the code:

- it returns `Result<_, String>` and never an `Option` the caller turns into a
  `return`. There is no sizing arm here and nothing to stop for, so a stage that
  cannot build REFUSES WITH THE REASON and the caller panics with it. Inheriting
  the STARK shape would end the run green having composed nothing;
- no slicing, no cache and no parent: at k = 1 the published layout IS
  `GlobalLayout`, which is the field the driver already carries, so
  `GlobalPublishes` and `SlicePartition` do not appear on this path;
- ONE host verify. The STARK stage verifies explicitly and then `real_child`
  verifies again; its three reasons are all inapplicable here, and the third is
  answered by taking the verify through `real_child_timed` so the stage PRINTS
  its seconds instead of hiding it inside a harvest;
- the `WhirRealGlobal` is dropped before the interior runs. It owns a clone of
  the cross-epoch proof and the whole cross-epoch AIR set; what survives into
  the root is the child and two `usize`s.

The root is NAMED, not inferred: `LFM_TREE_PROVE_ROOT=1` with
`LFM_TREE_ROOT_OPTION=A|B`, which carries no default. Unlike the STARK arm it
needs no cache directory — this driver caches nothing, so it proves base, level
0, the global child, the interior and the root in one run. The interior stops at
`RootOption::child_level(top)`, the same named rule the STARK driver reads, and
the child count is checked in the driver where the levels are still in view
rather than inside `emit_l2g_compare`'s refold guard a root emission later.

The knob refusals now carry one reason each. A blanket message would have
survived this change and gone on saying "the cross-epoch WHIR program is not
written" about knobs whose real objection is that the wrap is unsliced or that
nothing here caches.

The fixture arm is renamed for what it now proves: it runs the global stage, the
interior at option A's child level and the root, and its "interior closes to one
proof" assert becomes the option's own count guard plus the artifact's width.
Two limits are stated in its doc: at `test_private_input_xpage` the cross-epoch
shape is three bookends and one PRIVATE page, so the OFFSET+INIT route 30 of the
block's 35 pages take has no table and no gate here; and only option A is
emitted at fixture scale.
… closes

THE SAME DEFECT, TWICE. `fold_coset_consts` has always counted what
`emit_fold_coset` interns — `two_inv`, the `pow_bits` factors
`g^(2^i)`, and each level's stride — and a count is exactly what a
program-level pool cannot take, because counts ADD where values MERGE.
That is why 24 words survived the cross-epoch F1's `unnamed` assert
while this very fold suite was green on their number.

⚠ AND THEY DO NOT NEST, which is the difference from the sumcheck
round's Newton pairs. Those nest in the degree, so one call at a
program's maximum is its whole pool. These are `g^e`: round r folds over
the base domain squared once per scheduled variable, so the same
exponent set under a different generator gives different field elements.
A program's fold pool is a genuine UNION over its distinct domains, and
a form taking a maximum here would be wrong in a way the other is not.

The values form also retires an assumption the count had to make. The
count's own doc calls its last term 'assumed distinct from every g^e …
an assumption about a discrete log, not a proof'. Deduplicating by value
does not need it.

The exponent set is extracted so the count and the values share ONE
derivation, the chain-level union advances the domain by k squarings a
round exactly as the emitter advances it, and the fold suite's existing
count gate gains a VALUES arm in both directions — a word named but not
interned would make a pool over-count, and a word interned but unnamed
is the same gap one level down.

Why the values looked like powers of two: in Goldilocks 2^48 is -1, so
the small-order roots of unity are clean powers of two and 2^48, p-2^24,
2^39, p-2^60 are generator powers, not weights.
…column that is not the first

The cross-epoch INIT hybrid needs a prepared commitment over the dense genesis
pages' INIT columns. Those columns live one per GLOBAL_MEMORY page table, each
claimed at that table's own reduced point, and INIT is preprocessed column 1 of
`[OFFSET, INIT]` — so neither "which table" nor "which column" survives the
`(table index, leading count)` shape `Prepared` and `PreparedCheck` had.

Both now carry `at: &[PreparedColumn]`, one entry per stacked column, naming a
table and one of its preprocessed columns. DECODE is the single-table prefix
case and reaches it through `leading_columns`, so nothing about what DECODE
means moves.

No change below `crypto/stark`: `Claimed::PerColumn` has always resolved the
point per column — it is what every group opening in `multi_prove` uses — so the
generality was in `stacked_eval` all along. A single-table commitment produces
the same weight shares under `PerColumn` as under the `Shared` it replaces,
because `Claimed::point` hands back that one point for every column and
`Claimed` reaches nothing but the weight; the epoch byte gate is the assertion
of that rather than this paragraph.

`check_preprocessed` takes the set of settled indices instead of a leading
count, and `multi_verify` asserts the set is distinct — the distinctness a
count got for free, and without which one stacked column could stand in for two
skipped checks.

The claim a prepared column is settled against is resolved through
`preprocessed_source`, the same `slot_of` -> `kinds` -> `source.column` chain
`check_preprocessed` walks, rather than by assuming preprocessed column `c` is
value `c`. The identity does hold today — `LeafLayout::build_live_over`
registers main columns first and in index order — but the opening and the
skipped check must agree about which claim they mean, and one derivation is how
they cannot disagree.

Tests: a two-table stack over EQ's column 1 and LT's column 1, settled at the
two points those arguments reduced to; the honest round trip first, then the two
columns swapped at the verifier, then one column named twice. The two stacked
columns are asserted to differ before anything is proven, so the swap arm cannot
be a no-op.
…lter that must precede it

Which cross-epoch genesis pages a prepared opening carries, as one closed form
that the prover, the verifier and the in-guest emitter each evaluate from their
own inputs. They must reach the same set: the stack's root is absorbed in the
roots block, so two sides that disagree about it diverge at `z` and the proof
dies with nothing in it that names why.

A page joins when its sparse leg alone would cost more than the ENTIRE prepared
leg could. That is conservative on purpose — every page that joins pays for the
whole stack by itself — and it is a pure function of that page's own nonzero
count, so the answer never depends on which other pages joined or in what order
they were considered. The marginal-plus-fixed alternative is cheaper by a few
tens of thousands of rows and buys an order-dependent set; on the block the
choice is moot, because the three dense pages are 12x to 24x over the budget and
the 27 zero pages are four orders of magnitude under it.

`PREPARED_LEG_ROWS` is a constant and not a call because this module sits below
`crate::lfm`, where the chain cost forms live. A routing rule the protocol
depends on must not move with the machine's row accounting. Its provenance is
V1i's measured 175,066 chain rows at 24 variables — larger than the block's
20-variable stack, so the budget errs toward leaving pages sparse — and is to be
asserted where it can be computed rather than restated here.

The private-input pages are filtered out FIRST, and not as an optimisation. The
prover builds their configs with the private bytes and the verifier with an
empty vec, so a threshold that read those bytes would select different sets on
the two sides. The filter is on `is_private_input`, which both derive from
`page_base` and `num_private_input_pages` — values the cross-epoch statement
already binds — and the reason is that a private page presents no INIT
preprocessed column to settle at all.

Tests reproduce the box census of block 25368371 to the row: the three dense
pages' legs plus 27 interned zeros is 10,249,056, the figure `cens2` read. The
selection is pre-registered as exactly `0x0`, `0x40000` and `0x280000`, with the
existing fixture's 112-entry page asserted sparse — the fixture gap, stated
rather than hidden. A private page holding 20,000 nonzero bytes is asserted not
stacked, and the prover's and verifier's views of that page are asserted to plan
identically.
… prover can be asked which one

`verify_global_bookends` stopped reading `whir_hash_knob::selected()` for itself
at 1f7a0cd; `prove_global` still did, and that is what kept the cross-epoch
execution arms `#[ignore]`d behind a posture guard. A function that reads the
process knob can only be asked what THIS PROCESS proves under, never what hash a
bundle in front of it was argued with, so a test that wanted a bundle under a
named hash had to re-implement the prover.

It is now `prove_global<H>` with the dispatch at its two callers, which is the
arrangement `prove_epoch` and `verify_global_bookends` already have. The second
reason is coming: the genesis stack is a `StackedCommitment<F, H>`, whose type
names the hash, so it cannot be built outside a dispatch and handed in — the
same argument that made `prove_epoch` generic so DECODE's commitment could
outlive one call.

In `prove_continuation` the epochs and the cross-epoch proof now share one
dispatch. They read the same knob before, in two places that happened to agree;
a bundle whose two halves were argued under different sponges is no longer
spellable.

The test this makes possible asserts the agreement in BOTH directions and
without the knob: each proof is built under a named hash and checked under both,
so the diagonal accepts and the off-diagonal refuses. W1g's sibling could only
say that exactly one of the two accepts, and its own doc records why — under a
default keccak suite a verifier dispatch pinned to keccak is indistinguishable
from a correct one. The diagonal is asserted first, as the control a refusal
needs, and the two proofs are asserted to differ so the four readings are not
about one object.

The boundaries come from `for_each_epoch` with a closure that proves nothing,
which is the same walk `prove_continuation` uses and costs the execution alone.
Re-deriving them would have been a second spelling of the epoch split.

This retires the reason for V1j's three `#[ignore]`s on the executing
cross-epoch arms. Those tests are V1j's and are untouched here.
… dense genesis pages

The protocol half of the INIT hybrid. `prove_global` commits the dense pages'
INIT columns as one stacked polynomial and hands it to `multi_prove`, whose
roots block absorbs its root before `z`; `verify_global_bookends` recomputes the
same commitment from the page configs its AIR set was built from and settles the
opening through a `PreparedCheck`. Each column is settled at its own page
table's reduced point, which is what the multi-table `Prepared` exists for.

`WhirGlobalAirs` now carries the configs its page AIRs were built from. The
verifier needs them to decide the stack's membership, and calling
`global_memory_configs` a second time to get them is precisely what that
struct's own doc forbids — two call sites that agree today is the shape that let
REGISTER's preprocessed columns and its root describe different tables. The plan
comes from `WhirGlobalAirs::genesis_stack`, so the table index a stacked column
names is derived from that set's own bookend count rather than restated by a
caller.

★ NOTHING IN FLIGHT MOVES. `genesis_prepared_for` returns `None` when no page is
dense enough, and every existing fixture is entirely sparse — the worst is
`data_page_touch`'s 112 nonzero entries against a threshold of 9,725. Those
proofs absorb no extra root and are byte for byte the ones they were. Only a
bundle with a dense genesis page changes, and none exists yet outside the block.

So a fixture was written rather than an assertion added:
`dense_data_page_touch.s` is `data_page_touch` with its touched cell surrounded
by 32 KiB of non-zero bytes on each side. The fill is on BOTH sides because
where `.data` starts inside its page is the linker's business — with `n` bytes
either side the counter's own page holds at least `n` of them at any offset,
while a single trailing fill would leave a counter near the end of a page with
almost none. The floor is 32,768 nonzero entries, 3.4x the threshold, so the
fixture is not sized to just barely qualify.

⚠ There is deliberately no `agrees_with` on the genesis stack. DECODE asserts
its commitment's blowup and folding against each epoch's config because it is
built once per ELF at a config of its own shape; this one is built per proof
from the very config the same call hands `multi_prove`, so a comparison between
them could not fail. The note says what a per-ELF cache would have to bring back
with it, and states the cost this pays instead: one commitment over
`n_dense * 2^18` elements per prove and per verify, against 10,249,056 rows of
in-guest sparse evaluation.

Tests: the dense fixture's plan is printed page by page and asserted to put
exactly one page over the threshold BEFORE anything is proven — without that
precondition the test would take the `None` path and pass while checking
nothing — then the proof is asserted to carry an opening and to verify. The
sparse control asserts the opposite for `test_private_input_xpage`: no opening,
because a route that fired everywhere would make the "nothing moves" claim
false.
…ld budget asserted where it can be computed

TWO THINGS THE ROUTE OWED.

★ The provenance of the fourth per-ELF pin. The in-guest verifier cannot
recompute the genesis stack's commitment, so it interns the root as program
text exactly as it interns DECODE's, and the statement owed out of band is
"this root is the commitment to the ELF's genesis bytes at the dense page
bases, under this blowup and folding". Nothing inside the program checks it;
this is the evidence for it.

The test rebuilds the stacked columns from the ELF's PT_LOAD segments byte by
byte and compares the roots. ⚠ The second derivation is the whole value: built
through `build_initial_image_paged` or `page::preprocessed_columns` — the
functions the production path uses — it would compare a value with itself and
pass for any pair of agreeing bugs. It also asserts each rebuilt column carries
more nonzero entries than the threshold that put its page in the stack, because
two all-zero stacks match and say nothing.

The comment says in as many words that these are NOT the 35 univariate roots
`recursion::precomputed_commitments` builds. Those are Merkle roots over each
page's LDE codeword and are what `program_id` folds; no multilinear verifier
compares one, and a stacked WHIR commitment has no per-page subtree to match
against them.

★ `PREPARED_LEG_ROWS` is asserted in `lfm`, where the cost form lives. The
constant sits in `genesis_stack` below `lfm`, because a routing rule the
prover, the verifier and the emitter must agree on cannot depend on the
emitter's row accounting — but a constant nothing checks is a number that
drifts. The assertion is a BAND: the budget must be at least the 20-variable
stack the block uses and at most the 24-variable one it was read from, which is
the conservatism the threshold's own doc claims. An equality would redden on any
schedule change, which is a different finding from a drifted routing rule.

⚠ PRE-REGISTERED AS A READING THAT MAY GO EITHER WAY. V1i measured 175,066 at
24 variables "at the harness's own posture", and this asserts at `config(112,
20)` — blowup 2, fold 4, the production figures' shape. If the two postures
disagree, this goes red and the finding is that the constant's provenance is at
a posture the production path does not use, which is worth knowing and is the
reason to assert rather than assume.

A second test makes the insensitivity argument against the cost form rather
than the constant: across every stack width from 18 to 25 variables the chain
figure stays between the block's dearest sparse page (18 rows) and its cheapest
dense one (2,100,474), so the same three pages are selected at any of them.
…pied from a log

`genesis_stack`'s unit tests encode the block's census as numbers transcribed
from a box log. That checks the closed form's arithmetic and nothing else: a
routing rule whose pre-registration rests on a transcription is a rule nobody
has run against the program it routes.

This arm executes the block guest once — no proving, no card, the same shape as
the census arm it sits beside — rebuilds the page configs from the ELF,
evaluates the plan on them, and asserts the dense set is EXACTLY `0x0`,
`0x40000`, `0x280000`.

⚠ The assertion is the SET AND ITS ORDER, not a count: three pages of the wrong
three would satisfy a count, and the stack's column order IS page-base order, so
the order is part of what the opening means. A second assertion requires the
pages left sparse to cost under a hundredth of the pages stacked, because the
hybrid's whole claim is that the genesis bytes are concentrated.

It REFUSES rather than skips when the two shas are unstated, and SKIPS with its
own line when the ELF is simply absent. Two outcomes with two meanings: a
routing quoted as a fact about one program and one input must not be producible
from an unnamed pair, while a missing build product is not a failure of the
check.
…fix contract stays put

The lead ruled against generalising `check_preprocessed` from a leading count to
a set of column indices, on V1j's recommendation and for the smaller review
surface. This puts that contract back and shapes the prepared commitment to fit
it instead.

What generalised is the TABLE LIST — the cross-epoch genesis stack covers one
GLOBAL_MEMORY table per dense page, each settled at its own reduced point, which
is the part DECODE's single-table `Prepared` could not describe. What did not
generalise is which columns of a table an opening may settle: still its leading
`n`, still a count, and `verify`/`check_preprocessed` are byte-identical to what
they were.

So a cross-epoch page's stack carries BOTH its preprocessed columns rather than
INIT alone. The page loses its OFFSET ramp, which on the block is 51 rows lost
against 282 gained by three more stack columns — about 230 rows on a hybrid
costing order 10^5, in exchange for a contract in `crypto/stark` not moving.

`prefix_at` is where the two meet, and it refuses rather than reinterprets. A
`PreparedColumn` list CAN describe `{1}` or `{1, 0}` or two tables interleaved,
none of which a count can express — so a commitment shaped that way is an error,
not a silent reinterpretation that would have the host skip columns the opening
never settled while every value gate stayed green.

`prepared_runs` is the one walk that enforces it and also pins the stack's COLUMN
ORDER: `at` visits each table once, in the order the stack's columns were
committed, so run `k` settles table `k`'s claims. A reordering would settle one
table's columns against another's commitment — the failure the AIR set's
single-derivation rule exists to prevent, one level down.

The shared `slot_of` resolver goes with the set change, per the same ruling. The
gather survives because a stack spanning several tables still needs one, but each
run is contiguous and is that table's own leading columns, which is exactly the
old single-table behaviour extended.

Tests rewritten to the new shape: the honest two-table round trip first, then the
two tables swapped at the verifier, then a non-prefix, then two tables
interleaved. The two tables' stacked columns are asserted to differ before
anything is proven, so the swap arm cannot be a no-op.
…e emitter gets what the verification consumed

Three rulings, applied together because they touch the same objects.

THE SPLIT LIVES IN `continuation.rs`, not in a module of its own above it. Three
parties evaluate it — the cross-epoch prover, its verifier, and the in-guest
emitter — and they must reach the same answer or the stack's root differs and
the transcript dies at `z` with nothing naming the cause. `crate::lfm` depends
on `continuation` and not the reverse, so a rule the prover and verifier
evaluate cannot live there or call anything that does. Its tariff constants sit
beside it, and `lfm::whir_chain_tests` pins them against the cost form.

A DENSE PAGE STACKS BOTH ITS PREPROCESSED COLUMNS. `check_preprocessed` skips a
prefix, and INIT alone is not one, so `{0, 1}` it is: the page loses its OFFSET
ramp and the stack gains a column. About 230 rows on the block against a hybrid
costing order 10^5, in exchange for the prefix contract not moving. A side
effect worth having: two columns means one prefix bit even at a single dense
page, so a P=1 fixture already exercises the stacked layout's prefix indicator
rather than degenerating to a flat stack.

THE EMITTER GETS THE OBJECTS THE VERIFICATION CONSUMED. `verify_global_bookends`
now answers a `GlobalVerdict` carrying both the bookend roots and the genesis
stack, and `WhirRealGlobal.prepared_roots` becomes `prepared: Option<GlobalPrepared>`
holding the roots, the destinations, and the `StackedLayout` and `Domain` CLONED
off the commitment. Handing over only roots would have the emitter rebuild those
two from the column heights and the config — a second derivation of the object
the host actually committed, which is the defect the single-derivation rule
exists to prevent, one level down.

`GlobalPrepared`'s doc keeps two obligations in two sentences on purpose: its
roots are the fourth owed per-ELF pin for the DENSE pages, while the pages left
sparse owe something else entirely — their nonzero genesis entries interned as
program constants, bound by the program id. Written as one sentence, whichever
is actually unchecked would look covered by the other.

The stack's COLUMN ORDER is asserted where the columns and their destinations
meet, not left to the fact that two walks of the same list agree today. Column
`k` is settled at `at[k]`, so a length mismatch or a reorder would settle one
page's values against another page's commitment with every individual value gate
still green.

`agrees_with` returns, on both the committed and the published form, with its
condition stated: it cannot fire while the commitment is built from the very
config the same call hands `multi_prove`, and it fires the day this becomes the
per-ELF cache it obviously should be. That is the difference between a check
that cannot fail and a check whose caller has not arrived yet.
…order is the caller's

Two findings handed over by the gate lane, both in this module.

⛔ `stacks[..num_epochs]` PANICKED where the contract is to reject. Under a
mutation it produced "range end index 3 out of range for slice of length 2" — a
prover-side panic on a path whose whole job is to answer `Ok(None)` or an error,
which is the no-prod-panic policy's exact shape. It is now an
`InvalidTableCounts` naming both numbers.

It is not attacker-reachable as written: `global_groups` builds `sizes` from the
same `num_epochs` two lines above, so the length is right by construction. That
is the reason it survived, and it is not a reason to leave it — a panic that is
unreachable today and one that is unreachable by construction are different
things, and only the second survives someone rewriting the construction.

⚠ AND THE "canonical page-base order `global_memory_configs` hands back" was an
unsupported attribution. ✓ That function is a one-to-one `map` over the page
bases it is given, with no sort, dedup or filter, so it PRESERVES an order
rather than imposing one. Canonicality comes from `touched_page_bases`, which
collects through a `BTreeSet` — and on the VERIFIER side the list arrives in the
bundle, where it is a claim rather than a fact, bound by `absorb_global` and by
the GlobalMemory bus. The doc now says that, because the old wording would have
left a reader believing a duplicate base was impossible on the path where it is
merely caught.
Two independent rules decide a genesis page's fate. `continuation::is_dense`
decides whether the prepared opening carries it; `MAX_SPARSE_INIT_ENTRIES`
decides whether the sparse leg is willing to emit it, and refuses above the cap
because a column that dense has no cheap closed form.

A page the threshold leaves sparse and the cap then refuses would have NO ROUTE
AT ALL: too dense to emit, not dense enough to stack, and the refusal would fire
on a program nobody could fix by moving either constant alone. So the two must
overlap with the threshold strictly tighter, and this asserts it — 9,724 entries
at the densest page left sparse, against a 60,000-entry cap, 6.2x of margin.

★ That is the state the block was actually in. V1j's block bundle arm refused at
that cap — 116,692 nonzero entries on page `0x0` against a cap of 60,000 —
because the prepared route did not exist and every genesis page went to the
sparse leg. ⛔ Once the threshold routes the dense pages to the opening, the cap
should never fire again, and a refusal from it after this lands is not a page
needing a bigger cap: it is these two constants having drifted apart. The test
says so where a reader meets the cap.
The lean fold the lane measured behind LAMBDA_VM_GAP_R4 becomes the
emission: three LFM_XALU rows a folded value and no base row, where the
classic fold spent five XALU rows, a Div and a step Mul. The folds were
57 % of a WHIR wrap's chain rows. Measured on block 25368371 (one RTX 5090,
ABBA at d379cef): WHIR -2.85 s (control spread 0.10 s), level-0
instructions -8.36 M (-20 %), census -154.7 M cells, LFM_HASH rows
unchanged in every wrap. The fold is verifier arithmetic inside the
emitted program, so WHIR wrap and global-wrap program ids move and no proof
format does; both emissions compute the same field value.

LAMBDA_VM_WHIR_FOLD_CLASSIC=1 keeps the classic emission, instruction for
instruction, as the A/B arm and rollback switch. It parses like the other
switches (0 or 1, anything else stops the run), the emission is named on
stderr either way, and with_fold_emission still lets a test build either.

Tests. Figures derived by hand for the classic fold keep their values and
now say so, run under an explicit Classic override: the census quote
(185,509 rows), the auto-cap and first-fold derivations, and the genesis
budget bands. PREPARED_LEG_ROWS stays 175,066, the classic 24-variable
chain it was read from: it is a routing constant the prover, verifier and
emitter share, not a figure to re-tune with the emitter. Under the lean
fold the stacks cost less (20 variables 137,569 -> 108,469 rows, 24
variables 175,066 -> 140,146), so the budget over-charges them, the
conservative direction, and every band test now also asserts it still
covers the 20-variable stack under the lean fold. The production default
chain (first6, cap auto, Q = 112) is pinned under both emissions: rows
203,426 -> 150,811 (shape rows 202,690 -> 150,075) for the default, the
classic figures beside them, permutations 16,443 under both; the emitted
twin checks both. The lean-only chain tests now cover both emissions:
execution on accepted proofs, the five tamper sites, and the F1 closed
forms. New: the fold setting's default and its malformed value.
The split the lane measured behind LAMBDA_VM_GAP_R2 (LFM_HASH as two
power-of-two instances where one table would be mostly padding) pays on one
pipeline and loses on the other, so it becomes a policy with a
per-pipeline default rather than a knob or an unconditional rule. Measured
on block 25368371 (one RTX 5090, ABBA at d379cef, on top of the BITWISE
drop):

- STARK: the wraps carry 276-299k hash rows in a 2^19 table and split to
  2^18 + 2^15, 74.5 M census cells off each; the block ran 1.90 s faster
  (level 0 -1.80 s, card time at level 0 -1.94 s).
- WHIR: the wraps carry 172-195k rows in 2^18 and a split removes only
  2^16 rows, 21.3 M cells, less than the extra sub-proof costs (card time
  at level 0 +0.44 s). Each split child also costs its parent one more
  sub-proof to re-verify, which took four of the five fan-in-3 level-1 nodes
  across a power of two in LFM_XALU (+45.1 M cells each): the block ran
  2.80 s slower, interior +2.30 s.

chunking::HASH_SPLIT_DEFAULT is false here, the WHIR pipeline's best; the
STARK pipeline's branch flips that one constant. LAMBDA_VM_LFM_HASH_SPLIT
(0 or 1) overrides it either way and anything else stops the run; the
process names its setting and its source on stderr. Off, every program is
one table and no digest moves, so nothing is re-blessed here.

The GAP labels leave the split's code and docs. HASH_SPLIT_MIN_SAVING_LOG2's
doc now states the counter-pressure the block measured (15-20k more hash
rows and 185-271k more instructions in the parent per split child) instead
of an estimate. wrap_tests::report_census counts a split LFM_HASH as one
more sub-proof, as verify does, so the census check holds under either
setting. New tests: the setting's default and override (without stating the
default's value, which differs per branch) and its malformed value.
The BITWISE, WHIR-fold and hash-split settings name themselves on stderr
once per process so a log states which programs it proved. eprintln! writes
each piece of its format separately to the unbuffered stderr, and box logs
merge both streams: a whole stdout line (a census panel line, say) can land
between two pieces and gain the banner's first half as a prefix, which a
reader that finds a line by its start then misses. lfm::airs::announce
writes the finished line in one call; the three banners go through it.
LAMBDA_VM_LFM_KEEP_BITWISE=1 claims the machine as it was, digest for
digest. Pinned directly: keeping BITWISE in TrivialV0@2 and FriToyV0@2, the
two registry programs that send it nothing, reproduces the program_ids
their rows carried before the re-bless (ffaff6ee..., 55b64ced...). The test
reads the process setting to check which mask the build took, so it holds
with the opt-out set as well as without.
… drop BITWISE, the WHIR coset fold is emitted lean, the LFM_HASH split is a named policy

R1: a recursion program carries BITWISE only when one of its chips sends it
a lookup (opt-out LAMBDA_VM_LFM_KEEP_BITWISE=1, which reproduces today's
programs; TrivialV0 and FriToyV0 re-blessed). R4: the WHIR coset fold is
emitted in about three rows a value (opt-out LAMBDA_VM_WHIR_FOLD_CLASSIC=1).
R2: the LFM_HASH split is LAMBDA_VM_LFM_HASH_SPLIT, off by default on this
pipeline (it costs the WHIR block +2.80 s); the STARK pipeline turns it on
with a separate one-line commit. Each setting names itself on stderr in one
write. Measured on WHIR (jobs 52, 53): R1 -4.70 s, R4 -2.85 s, in separate
ABBAs. rec-int carries the harness commit f81f0a8, merged above.
… default 25

The WHIR stack cap, how wide one stacked polynomial may get, was the
constant MAX_STACK_VARS = 25 in the stark crate. It is now a field of the
chain format, `ChainFormat::stack` (`StackVars`, 1..=27), carried in the
`ChainConfig` both sides already build from: `commit_grouped` and the
prepared commitments on the prover's side, `stacks()` in the host verifier
and in the LFM's WHIR emitters. Nothing reads it from a proof.

The ZF format parses it from LAMBDA_VM_ZF_WHIR_STACK (25 | 26 | 27, the
stacks proved, verified and measured to fit on the 32 GiB card) and prints
it in the banner (`whir_stack=…`) and on the WHIR schedule line. The
default stays 25: every production proof is unchanged. The default moves to
27 in its own commit once the stack's ABBA is read.

A prepared commitment (DECODE, the genesis stack) records the cap its
layout was built under, and `agrees_with` refuses a reuse under another.

The query count is now charged the tallest stacked polynomial as well as
the widest table: a group of narrow tables can stack taller than any one
of them, and `stack_height` over all of a proof's shapes bounds every
group's. At every production shape the charged height does not change (the
widest table already stands at or above the cap) and Q stays 112. It can
move only for a proof whose stack crosses a round edge that no single
table reaches, which only small test programs do.

A proof stacked under one cap is refused, as an error, by a verifier at
another, in both directions (caps 5 and 7 on three small tables, with the
honest controls).
… cap

The stack cap now decides the layout a prepared commitment (DECODE, the
genesis stack) is handed, and `agrees_with` compares it. This is the
negative test for that comparison: a DECODE commitment built under 27 is
refused by an epoch at 25 and the reverse, each as an error naming the
stack. DECODE fits one polynomial under either cap here, so the roots are
equal and only the config check can refuse. With the stack left out of the
comparison, the test fails.
…ACK=25 rolls back

Measured on block 25368371 in an ABBA (job 152, one RTX 5090, one binary
for every arm, arms A B B A): stack 27 against 25 is −13.80 s WHOLE RUN
(99.20 → 85.40; A spread 0.60 s, B spread 0.40 s).
- By stage: base −6.75 s, level 0 −7.10 s, interior +0.05 s.
- By mechanism: the openings the wider stack removes are −9.38 s (50 base
  chains and 331 rounds instead of 145 and 866), against +1.98 s of commits.
- Both B arms proved and verified with the identities, the census and the
  per-phase ledger peaks registered from E2c. No fallback, no host argument,
  no refused room turn.
- The argument's reserved peak is 24,218 of a 25,688 MiB budget; the device
  peak 30,576 and 30,736 MiB.
This needs the NTT grid split and the WHIR room park and resize, both in the
base.

What moves, and why. Every WHIR base proof whose groups hold more than 2^25
cells (every real epoch) now stacks into 2–3 polynomials of 2^27 instead of
8–11 of 2^25. With it, every WHIR recursion program id moves (the wraps, the
nodes, the global wrap and the root): the wrap programs are emitted from the
inner layout. No STARK proof moves, because only the WHIR layouts read the
stack. Q stays 112.

Re-derived for that cause:
- the production-default chain config pins stack 27;
- the prepared leg's one-chain pricing limit moves from 64 to 256 genesis
  pages, because 2 · pages · 2^18 cells must fit 2^27 (the test is renamed
  for the new limit);
- the marginal walk now covers every single-chain bracket up to the
  production stack, 24 to 27, and the form holds exactly at the two new
  ones (115 at 26, 117 at 27);
- the banner reads `whir_stack=27`, and the schedule line runs to n=27;
- `StackVars::WIDEST` quotes the largest device peak of the three runs at 27.
The transcript pins were measured at the legacy WHIR format and skip at any
other, printing how to reach it. With the stack at 27 by default, reaching
the legacy format also needs LAMBDA_VM_ZF_WHIR_STACK=25, so the line says so.
…ap is 27 by default

The WHIR stack cap becomes a ZF format lever, LAMBDA_VM_ZF_WHIR_STACK
(25 | 26 | 27), 27 by default; LAMBDA_VM_ZF_WHIR_STACK=25 rolls back to
today's bytes. Only the WHIR layouts read it; STARK proofs do not move.
The cap travels in the chain config both sides build from, and a
prepared commitment built under one cap is refused under another.
Measured on WHIR (ABBA27, job 152, with the kernels and the room):
-13.80 s whole run, 145 -> 50 base chains.
…d of the prover thread

Both bases did each epoch's host preparation on the prover thread, just before
its prove: the bitwise multiplicities, the AIR set, the epoch-local L2G trace,
and on WHIR also materializing every table's main columns and checking the
preprocessed ones. The global proof's preparation ran after the last epoch, in
front of its prove. None of it touches the card, and the prover thread is the
critical path.

By default that work now runs ahead of the prover thread. STARK: a trace
builder prepares each epoch after building it (`prep_epoch`), and the producer
prepares the global proof once it has handed over the last epoch
(`prep_global`); the prover only proves (`prove_prepped_epoch`,
`prove_prepped_global`). The pipeline scope still spawns exactly the producer,
the builders and the prover, and the global PROVE still runs after the scope
has joined, so it never overlaps an epoch prove. WHIR: the producer prepares
each epoch before the hand-off (`for_each_epoch_overlapped_prepped`,
`prep_epoch_ahead`) and the global proof after the last one
(`prep_global_ahead`).

The proofs are the same either way; only which thread does the host work, and
when, changes. `LAMBDA_VM_BASE_PREP_ON_PROVER=1` keeps the preparation on the
prover thread; the setting is read once and named on stderr (`BASE PREP: ...`).
`prove_continuation_scheduled` takes the schedule as a parameter, and a test
per pipeline proves one run under both and compares every root that does not
follow HashMap order, the statement values and the verified output.

Also: `prove_continuation_keeping_decode` (both pipelines) returns the DECODE
derivations the base made alongside the bundle, for a caller that reconstructs
every epoch afterwards; `prove_continuation` is a wrapper that drops them.

Measured on block 25368371 (FAST, RTX 5090, ABBA palindromes at 109b705, the
preparation behind a temporary knob): WHIR 106.95 -> 101.55 s (-5.40), the
prover thread's preparation 6.73 -> 0.00 s; STARK 121.45 -> 119.70 s (-1.75),
the global prove starting 0.07 s after the last epoch prove instead of 0.98 s.
Program identities and the census unchanged.
…iving them again

Both production tree drivers proved the base, dropped the DECODE derivations
it had made from the ELF, and derived the same values again in level 0's
lead-in before the first wrap could start, with nothing on the card: the
univariate DECODE commitment (STARK `EpochConstants::load`, WHIR's root) and,
on WHIR, the prepared opening.

The drivers now prove the base with `prove_continuation_keeping_decode` and
hand its derivations to level 0 (STARK `EpochConstants::load(.., Some(c))`,
WHIR `whir_level_zero(.., Some(&BaseDecode))`); the WHIR fixture tree does the
same. They are the same function of the same ELF and options, so no program
identity moves. A base loaded from a cache has none to hand over, and the
lead-in derives them as before. `LFM_TREE_REDERIVE_DECODE=1` restores the
second derivation; the setting is read once and named on stderr
(`L0 DECODE: ...`), and the lead-in's own lines say which ran.

Measured on block 25368371 (FAST, RTX 5090, ABBA palindromes at 109b705,
behind a temporary knob): WHIR 106.95 -> 105.45 s (-1.50), the lead-in
3.13 -> 1.81 s, DECODE derivations 1.31 -> 0.00 s; STARK 121.45 -> 121.00 s
(-0.45, inside the A pair's 0.50 s spread), `EpochConstants::load`
1.24 -> 0.00 s, the lead-in 5.57 -> 3.93 s. Program identities unchanged.
… of the prover thread, level 0 reuses the base's DECODE

I4: each base epoch and the global proof are prepared ahead of the prover
thread (the STARK trace builders or the WHIR producer); opt-out
LAMBDA_VM_BASE_PREP_ON_PROVER=1, named once on stderr (BASE PREP: ...).
I2: level 0 takes the base's DECODE derivations instead of deriving them
again, in both production tree drivers; opt-out LFM_TREE_REDERIVE_DECODE=1
(L0 DECODE: ...). No proof byte moves. Measured on the pre-K1 base (job
151): WHIR I4 -5.40 s, I2 -1.50 s; STARK I4 -1.75 s, I2 -0.45 s. I1, I3
and I5 are not carried.
…d pair of its own

The per-table scheduler's driver threads are not rayon workers, so every one
of them staged through the shared slot-0 pinned slab. Uploads went one
single-buffered chunk at a time, and a driver copying a retained LDE out held
the slab's mutex through the whole host copy while the others queued with the
card idle. The slab also grew to the largest retained LDE's next power of two
and stayed pinned for the rest of the process.

By default the row-major commit's trace upload and its retained-LDE download
go through a pair of 32 MiB pinned buffers lent to that one transfer. That
covers both upload sites, the row-major expansion and the column-major
engine's.
- htod_staged: the host fills one buffer while the previous chunk's DMA drains
  the other. It returns once the last chunk is queued; each buffer's event
  guards it for the next borrower.
- dtoh_staged_into: two chunks in flight, each landed chunk copied straight
  into the Vec's spare capacity with no zero fill, and the in-place transpose
  queued right behind the last chunk's read.
- At most 8 pairs (512 MiB pinned, 16 allocations), made on demand and never
  grown or freed; a transfer beyond that waits for a pair.

LAMBDA_VM_STAGING_SHARED_SLAB=1 keeps the shared slab. The setting is read
once and named on stderr (`[gpu] transfer staging: ...`). Both tree drivers
print the staging counters after the base and at the end: bytes and host
seconds per path, pairs, waits, and the shared slabs' pinned footprint.

Measured on block 25368371 (FAST, RTX 5090), ABBA palindromes at 5cbdf06,
on the pre-engine base 169b668 behind a temporary knob. STARK: 121.30 ->
115.50 s (-5.80, A spread 1.20); the base 48.3 -> 44.0 s; under nsys the
base's card idle 9.89 -> 5.32 s; host peak -4.41 GiB, which is the slab:
4.01 GiB after the base against 0.13. WHIR: 107.60 -> 105.50 s (-2.10);
level 0 -1.3 s. Program identities unchanged. On this base the column-major
engine's upload takes the same pairs; that site is new here and not in the
measurement above.

Tests. Device: exact round trips across chunk boundaries, the closure
contract, the buffer-reuse hazard, 12 threads over 8 pairs, and root / host
LDE / handle parity of the base, ext3, split-tree and column-major engine
commits through either staging, with the staged path's bytes counted.
Card-free: the slab footprint and the staging line's shape.
Level 0 opened with host work alone: every first-round wrap's prologue at
once (reconstruct, emit, arenas, and on WHIR the epoch harvest), with
nothing on the card. None of it needs the card or the global proof, only the
ELF and the epoch proofs the base finished long before.

By default the tree drivers now start a lead-in before the base. Its helpers
(two by default) wait until the base reports its epoch count, then build the
prologues of wraps 0..want (want = level 0's first pool round) from copies of
the leading epoch proofs, with the same functions the pool calls, so the
programs are the pool's own. Level 0 takes each prologue instead of building
it. A prologue no helper started is built by the pool as before, and a
panicking one is handed back. Nothing in the lead-in takes the card permit
or holds device memory of its own. It uses the base's DECODE derivations as
the base shares them: the STARK commitment, and on WHIR the root and the
prepared opening, whose derivation is a device commit.

The base reports through an EpochObserver installed for the calling thread
(with_epoch_observer): the epoch count from the producer as soon as the
final epoch is executed, each proved epoch, and the DECODE work. That holds
on both preparation schedules and both pipelines; with no observer
installed, the pipeline is unchanged. Level 0 still takes its own DECODE
derivations from the base, and LFM_TREE_REDERIVE_DECODE=1 still re-derives
them there; the lead-in's copies are only for its prologues. BaseDecode now
holds the Arc the base shares. An I4 schedule test reads epoch positions
through the slice-based epoch_chain_position.

LFM_TREE_PROLOGUES_AT_LEVEL0=1 builds the prologues at level 0's start
instead; LFM_TREE_TAIL_PROLOGUES and LFM_TREE_TAIL_HELPERS size the lead-in.
The driver prints `L0 PROLOGUES: ...` either way, and at level 0's start how
many prologues were ready.

Measured on block 25368371 (FAST, RTX 5090), ABBA palindromes at 5cbdf06,
on 169b668 behind a temporary knob, before the base-prep and DECODE-handoff
changes. WHIR: 107.60 -> 104.40 s (-3.20, A spread 1.20); the lead-in
3.19 -> 0.07 s; level 0 -3.1 s; base unchanged; device peak +592 MiB, from
the harvest's MLE evaluations running unreserved beside the base's tail.
STARK: 121.30 -> 118.65 s (-2.65); the lead-in 5.54 -> 0.22 s and level 0
-6.1 s, but the base +3.4 s, because the two helpers slow the base's epoch
proofs. Program identities unchanged. On this base the DECODE handoff
already removes part of the lead-in, so the gain here is smaller than above.

Tests. Card-free: the hand-off (order, the count gate, handing back an
unstarted, failed or context-failed prologue, waiting on one in progress,
close). Fixture scale, on both bases: the observer sees the count once and
every epoch byte for byte, and a prologue built from the leading epochs emits
the pool's own program and arenas.
…te does

Every staged_transfers test forces its path with the thread override, and
only the block-scale tree drivers read the lead-in's setting, so no card-free
test read either setting from the environment. One test each now does, and
prints the line that names it:
- staged_transfers: staging_pairs_enabled() is !LAMBDA_VM_STAGING_SHARED_SLAB,
  with its `[gpu] transfer staging: ...` line;
- the tree tests: lead_in_enabled() is !LFM_TREE_PROLOGUES_AT_LEVEL0, with an
  `L0 PROLOGUES setting: ...` line.
The gate runs each with --nocapture under the default and under the opt-out
and counts the named lines.
@MauroToscano MauroToscano changed the title WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 100.9 s WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 70.65 s Sep 27, 2026
…nned staging, level 0's first wrap prologues built in the base's tail

I6: each row-major commit transfer is staged through a pinned pair of its
own instead of the worker's shared slab; opt-out
LAMBDA_VM_STAGING_SHARED_SLAB=1, named once on stderr ([gpu] transfer
staging: ...). I7: level 0's first wrap prologues are built by helpers in
the base's tail; opt-out LFM_TREE_PROLOGUES_AT_LEVEL0=1, sized by
LFM_TREE_TAIL_PROLOGUES / LFM_TREE_TAIL_HELPERS (L0 PROLOGUES: ...). No
proof byte moves. Measured on the lane's base (IDLE-B box2): I6 STARK
-5.80 s, WHIR -2.10 s; I7 WHIR -3.20 s, STARK -2.65 s net.
…xes K3/K4/K5

Brings the HASH lane's six commits onto candidate C2 (7d41668): the
half-warp Merkle tops (K3), the work-queue grind (K4), the limb-multiply
permutation variants (K5), each still behind its LAMBDA_VM_GAP_* knob,
their parity tests and host KATs, the serialised grind-counter tests and
the queue grid's context fix. No conflict; the next commits make the
three fixes the defaults.
…imb permutation by default

The three RPX device fixes measured EFFECTIVE on the WHIR block (job 160,
wt300-307: K3 -3.90 s, K4 -6.45 s, K5 variant 5 -10.05 s against 107.15 s,
identities identical in every arm) are now the defaults. Each keeps an
opt-out, and its default lives in one constant in `rpx_paths`, so a
pipeline that needs one off flips one line:

- LAMBDA_VM_RPX_WARP_MERKLE (WARP_MERKLE_DEFAULT): narrow levels and the
  tail on rpx_merkle_level_warp / rpx_merkle_tail_warp; =0 walks a
  thread per parent (rpx_merkle_level, rpx_merkle_tail).
- LAMBDA_VM_RPX_GRIND_QUEUE (GRIND_QUEUE_DEFAULT): rpx_grind_search_queue
  on a card-filling grid; =0 is rpx_grind_search on LAMBDA_VM_GRIND_GRID.
- LAMBDA_VM_RPX_LIMB_PERMUTE (LIMB_PERMUTE_DEFAULT): every RPX kernel from
  rpx_v5.cubin (32-bit limb multiply, square_n unrolled by four); =0
  loads rpx_v0.cubin, the 64-bit multiply.

Each variable takes 0 or 1 (anything else aborts) and each switch prints
one line on first use, "[gpu] RPX Merkle: ...", "[gpu] RPX grind: ...",
"[gpu] RPX permutation: ...", naming the path and whether it came from
the pipeline default or the variable. The queue's grid line becomes
"[gpu] RPX grind queue: grid ...". build.rs now builds rpx.cu twice
(variants 0 and 5) instead of five times.

The LAMBDA_VM_GAP_* knobs and the gap_hash module are gone. The parity
tests move to prover/tests/rpx_device_paths.rs, named for what they
compare (the per-parent walk, the stride grind), and the temporary
wording leaves the kernels, the host KATs and the docs.
The PROVE SPLIT line's "r4_grind (n/airs on device)" took its delta of
gpu_lde::gpu_grind_calls(), the keccak arm's counter. An RPX grind that
runs on the device counts in gpu_grind_calls_rpx(), so under RPX every
line read 0/airs while every table ground on the card: on the WHIR block
(job 160) the root proof printed 0/11 beside the harness's own count of
11 RPX device grinds for it.

device_grinds_now() sums both arms and report() takes its delta of that.
rpx_grind_device gains a test that reads it around one RPX device grind
(the keccak-only count reads 0 there and fails).
The result lines still carried the campaign's fix ids (K3, K4, K5) and
called the old paths "shipped", which stopped meaning anything once the
new paths became the defaults. They now say what is compared: the warp
walk against the per-parent walk, the queue grind against the stride
grind, the limb primitives and the permutation variants. Output only;
every check is unchanged and both binaries still pass.
…er half-warp, a queue grind, the limb permutation

K3: a Merkle level and the tail compress one permutation per half-warp
(opt-out LAMBDA_VM_RPX_WARP_MERKLE=0). K4: the device grind claims nonces
from a work queue (LAMBDA_VM_RPX_GRIND_QUEUE=0). K5: the whole RPX module
runs the limb-multiply permutation variant (LAMBDA_VM_RPX_LIMB_PERMUTE=0).
Each opt-out accepts 0 or 1 and names itself once on first use. Every
digest, root and grind nonce search is byte-identical to the previous
kernels (host known-answer tests and device parity). The prove split now
counts RPX device grinds. Measured on the pre-K1 base: WHIR K3 -3.90 s,
K4 -6.45 s, K5 -10.05 s; STARK K3 -1.00 s, K4 -0.50 s, K5 -7.95 s.
The DEEP and out-of-domain denominators were inverted by a global
Montgomery scan: compute_denoms plus five scan kernels, each a full pass
over the domain with prefix and suffix scratch, although every row needs
only its own few inverses. By default now:

- compute_and_invert_denoms_ext3_dev runs one kernel,
  invert_denoms_rowwise_ext3_k{1..8}: each thread builds its row's
  denominators and inverts them in registers with one base-field
  inversion (adjugate over norm, the norms batched by Montgomery's
  trick, kernels/ext3_inv.cuh). More than 8 per row keep the scan.
- the fully resident R4 DEEP inverts its own row's 1 + K denominators
  (deep_composition_ext3_fused_m{1..4}) and needs no inverse buffer; it
  falls back to the buffered kernel above 3 points or on any
  precondition miss.
- the single-point OOD sums (the R3 composition parts: 1-2 columns, so
  1-2 blocks on the card) run on the row-chunked multi kernel, and the
  multi kernels' chunk count loses its 64 cap.

The values are the same field elements; raw limbs may differ by p, which
nothing downstream observes. Measured on block 25368371 on one RTX 5090,
ABBA behind a switch on 169b668: STARK (one_row=auto) -0.70 s whole
run against a 0.50 s A spread and -2.35 GiB device peak; WHIR kernels
-49.8 % with the wall inside the noise.

LAMBDA_VM_DEEP_INV_LEGACY=1 restores all three (the scan, the buffered
DEEP, one block per OOD column), read once per process with a banner,
as LAMBDA_VM_LDE_LEGACY does for the LDE. The legacy paths stay public
for tests/deep_inv_parity.rs, whose tests name both paths per call;
tests/deep_inv_setting.rs checks that the process setting is followed.
gpu_fused_deep_calls() counts the fused dispatch, and
cuda_path_integration asserts it follows the setting: a table that
silently fell back to the buffered kernel would still verify.
@MauroToscano MauroToscano changed the title WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 70.65 s WHIR recursion on GPU (RPX) with ZisK-style proof formats: block 25368371 in 60.20 s Sep 28, 2026
recompute_lde_produces_byte_identical_proofs compares the bytes of two
proves of one instance, one per residency mode, at the test options'
grinding factor of 1. Under `parallel` the CPU nonce search is rayon's
find_any (crypto::grinding::generate_nonce), so the two proves can
return different valid nonces; the nonce is absorbed before the queries
are drawn, and every opening after it moves. The test fails whenever the
two searches disagree, whichever residency mode runs: on a laptop it
failed 11/20 at d1dc455 and 15/20 at 7d41668, and 20/20 passed with
LAMBDA_VM_DETERMINISTIC_GRIND=1 (the smallest nonce) or without
`parallel` (a sequential find).

The residency tests now prove at grinding factor 0, as zf_golden_tests
already does for the same reason. The residency mode acts on the main
LDE, which the grind never reads, so the comparison loses nothing it
could catch.
@MauroToscano

Copy link
Copy Markdown
Contributor Author

Superseded by #1010: the same commit (0428c39) on the branch renamed whir-recursion-rpx. Renaming the head branch closed this PR.

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