From b7995d533acaecd30a1623008682c6f94a80b38b Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 7 Sep 2026 13:57:22 -0300 Subject: [PATCH 1/4] test(lfm): gate the per-table epoch-verifier wrap on every suite run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only non-ignored epoch-verifier wrap prove test was `the_fixture_epoch_wraps_batched`, and its inner epoch is batched. All four per-table equivalents are `#[ignore]`d, so a suite run never PROVED the per-table assembled epoch verifier — only executed it, which by method rule 2 says nothing about the chips. With the batched format on its way out that arm would have been the untested one. `the_fixture_epoch_wraps` is its per-table twin, not ignored: the min-preset fibonacci fixture epoch, `epoch_program` with legs, `lfm_prove`, `verify_against`, the epoch's own published oracles, and one tamper arm (a flipped main root must make the wrap unbuildable, in execution rather than in the prover). Both hash axes are NAMED, per HASH-PINNING.md's classification rule: the program is built at `WrapHash::production()`, so under an algebraic pin it emits `Instr::Hash` and consults the socket. The site therefore takes its artifacts from `build_artifacts_with_hasher(.., BLOCK_HASHER)` and its cell counts from `lfm_cell_counts_with_hasher(.., BLOCK_HASHER)` rather than either defaulting form — inert under a byte pin, load-bearing the moment the pin moves. The `lfm/airs.rs` allowlist reason records that pairing so the call site is not simplified back to the defaulting census. A lean body rather than `wrap_run_from`: the measurement harness emits the spine a second time for the closed-form check, walks the census and the row-cliff panel, and runs three falsification arms. None of that is what this gate claims, so the gate does not pay for it. --- prover/src/lfm/wrap_tests.rs | 273 +++++++++++++++++++++++ prover/src/tests/hash_pin_enumeration.rs | 17 +- 2 files changed, 288 insertions(+), 2 deletions(-) diff --git a/prover/src/lfm/wrap_tests.rs b/prover/src/lfm/wrap_tests.rs index aa18551f7..1496a206e 100644 --- a/prover/src/lfm/wrap_tests.rs +++ b/prover/src/lfm/wrap_tests.rs @@ -372,6 +372,279 @@ fn the_wrap_proves_and_verifies() { wrap_run(super::proof_fixture::fixture_options()); } +/// The wrap PROOF's own query count for [`the_fixture_epoch_wraps`]. +/// +/// `None` — the default — is [`wrap_options`]' framework count (219 at blowup 2, +/// the 128-bit target), so the gate proves under the same options every leg +/// suite proved under and a wrap cost stays comparable with a leg cost. +/// +/// `Some(n)` trades the WRAP proof's own soundness margin for wall time and +/// nothing else. The emitted program, its sub-proof count, its cells and every +/// chip it fills are properties of the INNER epoch and this does not touch one +/// of them — so a reduced count still proves every chip under the pin. It is +/// the same honest partial [`the_wrap_proves_at_blowup_8_geometry`] makes about +/// the inner query count, made on the outer side instead: the CHIPS are proved, +/// the wrap's own query COUNT is not. A run under `Some(n)` is **not** a +/// security parameter set, and the run's own label says so. +/// +/// ⚠ It is the ONLY knob this gate has. `MIN_PROOF_OPTIONS` is already blowup 2 +/// at one query, so the inner preset cannot shrink, and the leg set is the +/// epoch's own sub-proof count rather than a choice. +const WRAP_QUERIES: Option = None; + +/// [`wrap_options`] with [`WRAP_QUERIES`] applied. +fn gate_wrap_options() -> ProofOptions { + match WRAP_QUERIES { + Some(q) => ProofOptions { + fri_number_of_queries: q, + ..wrap_options() + }, + None => wrap_options(), + } +} + +/// ★★ THE SUITE-GATED PER-TABLE WRAP — the per-table twin of +/// [`the_fixture_epoch_wraps_batched`], and NOT `#[ignore]`d. +/// +/// # The gap it closes +/// +/// Every other per-table epoch-verifier wrap is `#[ignore]`d +/// ([`the_wrap_proves_and_verifies`], [`the_real_block_epoch_wraps`], +/// [`the_from_proof_final_epoch_wraps`], +/// [`the_real_block_proves_and_wraps_end_to_end`]), so the only assembled epoch +/// verifier a suite run ever PROVED was the batched one. The per-table proof +/// FORMAT was covered — the leg suites prove it, and the batched wrap's own +/// proof goes through [`lfm_prove`] — but the per-table epoch verifier PROGRAM +/// was not. This is the arm that keeps it from being the untested one. +/// +/// # What proving adds to an execution +/// +/// [`super::epoch_verify_tests::the_assembled_epoch_verifier_runs`] already +/// EXECUTES this exact program on every suite run, and by the method's rule 2 +/// that says nothing about the chips: where the executor mirrors a computation +/// the chip also does, only a prove+verify test sees the chip. So the delta here +/// is the whole LFM machine — traces, AIRs, commitments and verifier — over the +/// per-table assembled epoch verifier. +/// +/// # The pin, NAMED rather than implied +/// +/// `epoch_tests::epoch_program` builds at [`WrapHash::production()`]. Under an +/// ALGEBRAIC pin that lowers the emitter's Merkle work to `Instr::Hash` and +/// consults the `LFM_HASH` socket, so `HASH-PINNING.md`'s classification rule +/// puts this site in the class that must be proved under +/// [`crate::hash_pin::BLOCK_HASHER`] — which it names twice over, through +/// `build_artifacts_with_hasher` for the artifacts and +/// `lfm_cell_counts_with_hasher` for the numbers, so neither the proof nor the +/// census can be taken at `HasherKind::default()`. +/// +/// ⚠ **On a BYTE pin that naming is inert, and that is exactly the trap.** +/// `ByteWrapHash` lowers to the dedicated KECCAK / `LFM_BLAKE3` chips and emits +/// no `Instr::Hash` at all, so a defaulted socket hasher is free and correct +/// here and becomes wrong only when the pin moves. Written pinned now so the +/// flip is not a bug-hunt later — which is what it was the last three times +/// (`f6ca405c`, then `fri_tests` and `join_tests` at v10). +/// +/// # The shape +/// +/// The smallest shape that still exercises every leg: the min preset +/// ([`super::proof_fixture::fixture_options`] — blowup 2, ONE query) over the +/// fibonacci fixture epoch ([`EpochInputs::fixture`], `FIXTURE_EPOCH_LOG2`). +/// `EpochInputs::fixture` rather than `from_env`, deliberately and exactly as +/// the batched twin does it: a measurement run's `LFM_CENSUS_*` variables must +/// not be able to turn a suite gate into a real-block run. +/// +/// ⚠ **This is the ceiling, not a comfortable shape.** Slice 0 is the same +/// epoch at the same preset and its measured point is recorded in +/// [`MEASURED_BYTES_PER_CELL`] — 481,327,124 base-field-equivalent cells at +/// 15.1 GiB of peak RSS. The run prints the projection for its own census +/// before it proves, so the wall time it then reports is read against a +/// prediction rather than against nothing. +#[test] +fn the_fixture_epoch_wraps() { + fixture_wrap_run( + super::proof_fixture::fixture_options(), + EpochInputs::fixture(), + ); +} + +/// [`the_fixture_epoch_wraps`]' body: build the inner epoch, emit the per-table +/// assembled verifier, prove it under the pin, verify it, and run one tamper +/// arm. +/// +/// Deliberately NOT [`wrap_run_from`], which is the MEASUREMENT harness: that +/// one emits the program a second time (the spine, for the closed-form +/// permutation check), walks the chip census and the row-cliff panel, and runs +/// three falsification arms of which one re-enters `lfm_prove`. Every one of +/// those earns its cost in a measurement run and none of them is what this gate +/// claims, so the gate pays for none of them. +fn fixture_wrap_run(inner: ProofOptions, inputs: EpochInputs) { + let t = Instant::now(); + let e = super::epoch_tests::real_epoch_from(inner.clone(), inputs); + let profile = epoch_profile(&e); + println!( + "per-table inner epoch: {} sub-proofs, blowup {}, {} quer{} per table, \ + grinding {} — built and HOST-VERIFIED in {:.1}s", + e.legs.len(), + 1 << e.tables[0].shape.log2_blowup, + e.legs[0].verify.num_queries, + if e.legs[0].verify.num_queries == 1 { + "y" + } else { + "ies" + }, + e.tables[0].shape.grinding_factor, + t.elapsed().as_secs_f64() + ); + + let t = Instant::now(); + let program = super::epoch_tests::epoch_program(&e, true); + let arenas = super::epoch_tests::epoch_arena_words(&e, true); + println!( + " emitted the assembled PER-TABLE verifier in {:.1}s", + t.elapsed().as_secs_f64() + ); + report_program("THE PER-TABLE WRAPPED PROGRAM", &profile, &program); + + // ---- the prediction, registered BEFORE the measurement. + // + // Counted at the pin's own permutation, not at `HasherKind::default()`: the + // `LFM_HASH` chip's width is tenant-dependent (Poseidon 612 value columns, + // RPO 436, RPX 316), so a defaulted census does not report a smaller number + // under an algebraic pin — it reports the wrong chip's. + let (main, aux) = + super::airs::lfm_cell_counts_with_hasher(&program, crate::hash_pin::BLOCK_HASHER); + println!( + " census at {:?}: {main} main + {aux} aux ext = {} base-field equivalents; \ + PROJECTED peak RSS {:.1} GiB (a projection from slice 0's coefficient, \ + not a measurement of this run)", + crate::hash_pin::BLOCK_HASHER, + main + 3 * aux, + projected_peak_bytes(main, aux) / (1u64 << 30) as f64, + ); + + // ---- the artifacts, at the PINNED socket permutation. + let opts = gate_wrap_options(); + let artifacts = super::registry::build_artifacts_with_hasher( + &program, + &opts, + crate::hash_pin::BLOCK_HASHER, + ); + println!( + " wrap options: blowup {}, {} queries{}, grinding {}\n chip log-heights: {:?}", + opts.blowup_factor, + opts.fri_number_of_queries, + if WRAP_QUERIES.is_some() { + " (REDUCED — not a security parameter set)" + } else { + " (the framework's 128-bit count)" + }, + opts.grinding_factor, + artifacts.log_heights + ); + + // ---- PROVE. + let t = Instant::now(); + let proved = lfm_prove(&program, &artifacts, &arenas, &opts) + .expect("the per-table fixture wrap must prove"); + let prove_secs = t.elapsed().as_secs_f64(); + let size = rkyv::to_bytes::(&proved.proof) + .expect("the wrap proof must serialize") + .len(); + + // ---- VERIFY. + let t = Instant::now(); + assert!( + verify_against( + &artifacts.roots, + &artifacts.program_id, + artifacts.keccak_rnd_chunks, + &proved.proof, + &proved.public_words, + &opts, + artifacts.hasher, + artifacts.chip_set, + ), + "the per-table fixture wrap proof must verify" + ); + let verify_secs = t.elapsed().as_secs_f64(); + println!( + "\n★ PER-TABLE WRAP PROVED AND VERIFIED (inner epoch {profile}, blowup {}, \ + {} quer{})\n prove {prove_secs:.1}s / verify {verify_secs:.2}s / \ + proof {size} bytes / {} published words / {} sub-proofs", + inner.blowup_factor, + inner.fri_number_of_queries, + if inner.fri_number_of_queries == 1 { + "y" + } else { + "ies" + }, + proved.public_words.len(), + proved.proof.proofs.len(), + ); + + // ---- the PROVED run published the epoch's own oracles, so "it proved" is + // "it proved the right thing" rather than "some program proved". Three + // reads of `public_words`, which cost nothing next to the prove. + let pub_ext = + |i: usize| super::word::word_as_ext(&proved.public_words[i].1).expect("an ext challenge"); + assert_eq!(pub_ext(0), e.z_alpha.0, "the proved run publishes z"); + assert_eq!(pub_ext(1), e.z_alpha.1, "the proved run publishes alpha"); + assert_eq!( + super::word::word_as_ext(&proved.public_words[proved.public_words.len() - 1].1) + .expect("the bus total is ext"), + e.expected_bus_balance, + "the proved run reaches production's own COMMIT-bus target" + ); + + // ---- THE TAMPER ARM: a flipped MAIN ROOT must fail. + // + // Arena 2 is the epoch's main roots — statement, then the ELF-dependent + // preprocessed roots, then these (declaration order IS absorb order in + // `epoch_tests::epoch_arena_words`). The index is asserted against the + // shape rather than trusted, because a declaration-order change would + // otherwise leave this tampering some other arena and still reporting a + // pass, which is how `arena_index`' own first version came to tamper an + // empty arena. + const MAIN_ROOTS_ARENA: usize = 2; + assert!( + MAIN_ROOTS_ARENA < super::epoch_tests::num_epoch_wide_arenas(&e), + "the main roots are an EPOCH-WIDE arena" + ); + assert_eq!( + arenas[MAIN_ROOTS_ARENA].len(), + super::proof_arena::words_per_root() * e.tables.len(), + "arena {MAIN_ROOTS_ARENA} must be the main roots — one root per sub-proof, \ + at the production wrap hash's root width" + ); + + let mut tampered = arenas.clone(); + // Assigned rather than incremented: a root word is a packed u32 half on the + // byte arm, so `+= 1` on a lane holding `u32::MAX` would be refused for its + // RANGE instead of for the root mismatch this arm is about. + tampered[MAIN_ROOTS_ARENA][0] = super::word::base_word(FE::from(999_999u64)); + assert_ne!( + tampered[MAIN_ROOTS_ARENA][0], arenas[MAIN_ROOTS_ARENA][0], + "the tamper must actually move the root word — an arm that tampers \ + nothing passes while asserting nothing" + ); + + // UNBUILDABLE, not unverifiable: every check is an assert inside a + // straight-line program, so a false statement has no execution at all and + // `lfm_prove` fails in `execute` before a trace exists. A failure in the + // PROVER instead would mean the machine admitted the forgery and the + // rejection came from somewhere else, so the two are distinguished. + match lfm_prove(&program, &artifacts, &tampered, &opts) { + Err(LfmProveError::Exec(err)) => println!( + " TAMPERED main root of sub-proof 0: the per-table wrap is \ + UNBUILDABLE ({err:?})" + ), + Err(LfmProveError::Prover(err)) => { + panic!("a tampered main root must fail in EXECUTION, not in the prover: {err:?}") + } + Ok(_) => panic!("a tampered main root must not produce a wrap proof"), + } +} + /// ★ SLICE 0's GPU-dispatch census (`thoughts/shared/gpu-recursion/EXPLORATION.md`, /// Stage 0). The min-preset wrap proved once, with the process-global GPU call /// counters reset right before `lfm_prove` — after the inner epoch is built, diff --git a/prover/src/tests/hash_pin_enumeration.rs b/prover/src/tests/hash_pin_enumeration.rs index 61ae84871..3f358d5c2 100644 --- a/prover/src/tests/hash_pin_enumeration.rs +++ b/prover/src/tests/hash_pin_enumeration.rs @@ -52,8 +52,21 @@ const BLESSED: &[(&str, &str)] = &[ "`lfm_chip_census` / `lfm_cell_counts` / `LfmAirs::new` default the \ socket hasher. ✓ VERIFIED test-only: the census pair counts cells and \ proves nothing, and `LfmAirs::new` has exactly one caller \ - (`wrap_tests.rs`). Production builds its AIR set through \ - `LfmAirs::new_with_hasher`.", + (`wrap_tests::the_census_agrees_with_the_traces_the_prover_builds`, \ + over `programs::keccak_chain_program` — which pins keccak on its own \ + builder, so it emits no `Instr::Hash` and never consults the socket). \ + Production builds its AIR set through `LfmAirs::new_with_hasher`. \ + ⚠ AND the defaulted census pair must stay OFF the block path's \ + NON-IGNORED prove sites, which is a narrower claim than `test-only`: \ + `wrap_tests::the_fixture_epoch_wraps` proves the assembled PER-TABLE \ + epoch verifier against artifacts that follow the pin on every suite \ + run, so it names `lfm_cell_counts_with_hasher(.., BLOCK_HASHER)` and \ + `build_artifacts_with_hasher(.., BLOCK_HASHER)` rather than either \ + defaulting form. Pairing this default with a pinned non-default is the \ + failure the list exists for — the `LFM_HASH` chip's width is \ + tenant-dependent, so a defaulted census under an algebraic pin reports \ + the WRONG chip rather than a smaller number. Do not simplify that call \ + site back to the defaulting pair.", ), ( "lfm/trace.rs", From b5ad16ffe11ddf89cd3dd2062340bb7a4386fb0a Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 7 Sep 2026 15:37:58 -0300 Subject: [PATCH 2/4] test(lfm): record the per-table wrap gate's measured shape, and drop its query knob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate was written with a const that could trade the WRAP proof's own query count for wall time, against a worry that the shape would not fit a suite run. The measurement says otherwise: 210,782 instructions, 82,059,828 base-field-equivalent cells, and 9.20s for the whole test on a 48-core box, of which 3.8s is the prove and 0.16s the verify. There is nothing to buy, so the knob is removed rather than left dormant — a setting that quietly weakens a suite gate is worse than no setting, and the gate now proves under the same unreduced options every leg suite proved under. The measured shape goes in the test's doc as a table, because the neighbouring slice-0 doc quotes ~2.25M instructions for nominally this same epoch and preset and that is an order of magnitude high for it. Slice 0's own figure is left alone: which shape it belongs to is for whoever re-runs it to establish, not something to infer from a measurement of a different test. Also recorded: the 46 MB wrap proof is the framework query count over 15 sub-proofs rather than anything about the inner epoch, and it is fine for a gate that builds, verifies and drops it in-process. --- prover/src/lfm/wrap_tests.rs | 86 ++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 48 deletions(-) diff --git a/prover/src/lfm/wrap_tests.rs b/prover/src/lfm/wrap_tests.rs index 1496a206e..026fe4f4d 100644 --- a/prover/src/lfm/wrap_tests.rs +++ b/prover/src/lfm/wrap_tests.rs @@ -372,37 +372,6 @@ fn the_wrap_proves_and_verifies() { wrap_run(super::proof_fixture::fixture_options()); } -/// The wrap PROOF's own query count for [`the_fixture_epoch_wraps`]. -/// -/// `None` — the default — is [`wrap_options`]' framework count (219 at blowup 2, -/// the 128-bit target), so the gate proves under the same options every leg -/// suite proved under and a wrap cost stays comparable with a leg cost. -/// -/// `Some(n)` trades the WRAP proof's own soundness margin for wall time and -/// nothing else. The emitted program, its sub-proof count, its cells and every -/// chip it fills are properties of the INNER epoch and this does not touch one -/// of them — so a reduced count still proves every chip under the pin. It is -/// the same honest partial [`the_wrap_proves_at_blowup_8_geometry`] makes about -/// the inner query count, made on the outer side instead: the CHIPS are proved, -/// the wrap's own query COUNT is not. A run under `Some(n)` is **not** a -/// security parameter set, and the run's own label says so. -/// -/// ⚠ It is the ONLY knob this gate has. `MIN_PROOF_OPTIONS` is already blowup 2 -/// at one query, so the inner preset cannot shrink, and the leg set is the -/// epoch's own sub-proof count rather than a choice. -const WRAP_QUERIES: Option = None; - -/// [`wrap_options`] with [`WRAP_QUERIES`] applied. -fn gate_wrap_options() -> ProofOptions { - match WRAP_QUERIES { - Some(q) => ProofOptions { - fri_number_of_queries: q, - ..wrap_options() - }, - None => wrap_options(), - } -} - /// ★★ THE SUITE-GATED PER-TABLE WRAP — the per-table twin of /// [`the_fixture_epoch_wraps_batched`], and NOT `#[ignore]`d. /// @@ -453,12 +422,31 @@ fn gate_wrap_options() -> ProofOptions { /// the batched twin does it: a measurement run's `LFM_CENSUS_*` variables must /// not be able to turn a suite gate into a real-block run. /// -/// ⚠ **This is the ceiling, not a comfortable shape.** Slice 0 is the same -/// epoch at the same preset and its measured point is recorded in -/// [`MEASURED_BYTES_PER_CELL`] — 481,327,124 base-field-equivalent cells at -/// 15.1 GiB of peak RSS. The run prints the projection for its own census -/// before it proves, so the wall time it then reports is read against a -/// prediction rather than against nothing. +/// # What it costs, MEASURED +/// +/// One run of this test, 48-core box, at the default (BLAKE3) pin: +/// +/// | | | +/// |---|---| +/// | inner epoch | 25 sub-proofs, trace lengths (log2) `[2 x18, 3, 4 x2, 5 x2, 7, 20]` | +/// | emitted program | **210,782 instructions**, 16,461 arena words | +/// | census at the pin | 42,096,912 main + 13,320,972 aux ext = 82,059,828 base-field equivalents | +/// | wall | 9.20s for the whole test: 3.1s to build and host-verify the epoch, 3.8s to prove, 0.16s to verify | +/// | wrap proof | 45,953,352 bytes over 15 sub-proofs | +/// +/// ⚠ **210,782 instructions, not the ~2.25M this file's slice-0 doc quotes for +/// the same epoch and preset.** That figure was recorded against a different +/// shape and is an order of magnitude high for this one; it is left alone above +/// rather than re-blessed here, because which shape it belongs to is a question +/// for whoever re-runs slice 0, not something to guess from a neighbouring +/// measurement. Read the number in this table for THIS test and nothing else. +/// +/// The 46 MB proof is a consequence of the WRAP proving at +/// [`wrap_options`]' framework query count over 15 sub-proofs, not of anything +/// about the inner epoch, whose own preset is one query. It is fine for a gate: +/// the proof is built, verified and dropped inside the test and is never +/// serialized to disk or carried anywhere. A wrap proof meant to be SHIPPED is +/// the aggregator's problem and is sized by different levers. #[test] fn the_fixture_epoch_wraps() { fixture_wrap_run( @@ -523,23 +511,25 @@ fn fixture_wrap_run(inner: ProofOptions, inputs: EpochInputs) { ); // ---- the artifacts, at the PINNED socket permutation. - let opts = gate_wrap_options(); + // + // `wrap_options` UNREDUCED, and deliberately with no knob to reduce it. The + // wrap's own query count is the one lever that would buy wall time without + // touching the emitted program, and at 9.2s for the whole test there is + // nothing to buy — so the gate proves under the same options every leg suite + // proved under, which is what keeps a wrap cost comparable with a leg cost. + // A dormant setting that quietly weakens a suite gate is worse than no + // setting; if this shape ever does need trimming, the honest lever is the + // one `the_wrap_proves_at_blowup_8_geometry` documents, named per run. + let opts = wrap_options(); let artifacts = super::registry::build_artifacts_with_hasher( &program, &opts, crate::hash_pin::BLOCK_HASHER, ); println!( - " wrap options: blowup {}, {} queries{}, grinding {}\n chip log-heights: {:?}", - opts.blowup_factor, - opts.fri_number_of_queries, - if WRAP_QUERIES.is_some() { - " (REDUCED — not a security parameter set)" - } else { - " (the framework's 128-bit count)" - }, - opts.grinding_factor, - artifacts.log_heights + " wrap options: blowup {}, {} queries (the framework's 128-bit count), \ + grinding {}\n chip log-heights: {:?}", + opts.blowup_factor, opts.fri_number_of_queries, opts.grinding_factor, artifacts.log_heights ); // ---- PROVE. From 35c00aa6e98ae014be63b8a08a8b0c7a3a63439d Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 7 Sep 2026 15:57:35 -0300 Subject: [PATCH 3/4] docs(lfm): the slice-0 figures are UNVERIFIED, not "a different shape" The previous commit claimed the ~2.25M-instruction figure "was recorded against a different shape". That is not supported by the code and should not have been asserted. `wrap_run` passes `EpochInputs::from_env`, which with no `LFM_CENSUS_*` set is byte-for-byte `EpochInputs::fixture`, so in a clean environment slice 0 and `the_fixture_epoch_wraps` build the same epoch through the same `real_epoch_from` and emit the same `epoch_program(&e, true)`. One program, two figures an order of magnitude apart. So the warning moves to where the doubtful numbers live rather than sitting on the new test. Slice 0's signature now says both its figures, and the 481,327,124-cell point `MEASURED_BYTES_PER_CELL` attributes to it, are unverified for the shape that signature describes; that an `LFM_CENSUS_*`- overridden run is the likely explanation, marked as the hypothesis it is; and that the fix is one `--ignored` run with a clean environment, not a rewrite. `MEASURED_BYTES_PER_CELL`'s value is deliberately unchanged. A ratio of two numbers taken together on one run is a valid coefficient whichever shape that run was, and re-deriving it from a shape whose RSS nobody measured would swap a misfiled observation for a fabricated one. Only its provenance line is annotated. Also records the CI-class expectation the reviewer asked for: 30-60s on a 2-4 vCPU runner under `--test-threads=1`, marked inferred, scaled from the box wall rather than measured on a runner. --- prover/src/lfm/wrap_tests.rs | 55 ++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/prover/src/lfm/wrap_tests.rs b/prover/src/lfm/wrap_tests.rs index 026fe4f4d..aa0cf31a1 100644 --- a/prover/src/lfm/wrap_tests.rs +++ b/prover/src/lfm/wrap_tests.rs @@ -364,6 +364,31 @@ pub(super) fn epoch_profile(e: &super::epoch_tests::RealEpoch) -> String { /// in this suite and the run is minutes of CPU and tens of gigabytes. It is the /// wrap run's own harness, not a test the suite can afford on every PR. /// +/// ⚠⚠ **BOTH FIGURES ARE UNVERIFIED FOR THE SHAPE THIS SIGNATURE DESCRIBES, +/// and the same doubt covers [`MEASURED_BYTES_PER_CELL`]'s 481,327,124-cell +/// point, which is attributed to this test.** +/// +/// [`wrap_run`] passes [`EpochInputs::from_env`], and with no `LFM_CENSUS_*` +/// set that is byte-for-byte [`EpochInputs::fixture`] — so in a CLEAN +/// ENVIRONMENT this test and [`the_fixture_epoch_wraps`] build the same epoch +/// through the same `real_epoch_from` and emit the same +/// `epoch_program(&e, true)`. They are ONE program. That program measures +/// **210,782 instructions** and 82,059,828 base-field-equivalent cells, an +/// order of magnitude under both numbers above. +/// +/// ⚖ The likely explanation is that these were recorded under `LFM_CENSUS_*` +/// overrides — this test reads them, and a real-block run through this harness +/// is what they exist for — but that is a HYPOTHESIS and is written as one. It +/// has not been checked, and "the shape shrank since" is not excluded. The +/// numbers are left in place rather than corrected, because correcting them +/// would mean inventing replacements: what they need is one `--ignored` run of +/// this test in a clean environment, after which all three move together or +/// none do. +/// +/// ⛔ Until that run exists, do not size a wrap run from anything on this +/// signature. [`the_fixture_epoch_wraps`] carries measured numbers for the +/// clean-environment shape. +/// /// Run with: /// `cargo test --release -p lambda-vm-prover --lib lfm::wrap_tests::the_wrap_proves_and_verifies -- --ignored --nocapture` #[test] @@ -434,12 +459,20 @@ fn the_wrap_proves_and_verifies() { /// | wall | 9.20s for the whole test: 3.1s to build and host-verify the epoch, 3.8s to prove, 0.16s to verify | /// | wrap proof | 45,953,352 bytes over 15 sub-proofs | /// -/// ⚠ **210,782 instructions, not the ~2.25M this file's slice-0 doc quotes for -/// the same epoch and preset.** That figure was recorded against a different -/// shape and is an order of magnitude high for this one; it is left alone above -/// rather than re-blessed here, because which shape it belongs to is a question -/// for whoever re-runs slice 0, not something to guess from a neighbouring -/// measurement. Read the number in this table for THIS test and nothing else. +/// ⚠ On CI, expect **30-60s** rather than 9.2s: the runners are 2-4 vCPU and +/// the suite runs `--test-threads=1`, so almost none of the box's parallelism +/// is there. `? INFERRED` — scaled from the box wall, not measured on a runner. +/// The batched twin already pays a comparable bill today and is being deleted, +/// so the steady state is one test of this class, not two. +/// +/// ⚠ These numbers, and NOT the `~2.25M` instructions the slice-0 doc quotes, +/// describe this shape. In a clean environment the two tests are the SAME +/// PROGRAM: `wrap_run` passes `EpochInputs::from_env`, which with no +/// `LFM_CENSUS_*` set is byte-for-byte [`EpochInputs::fixture`]. Why slice 0's +/// doc carries a figure an order of magnitude higher for one program is an open +/// question and is written up on [`the_wrap_proves_and_verifies`]; it is not +/// settled here, and this table is not evidence about which environment that +/// number came from. /// /// The 46 MB proof is a consequence of the WRAP proving at /// [`wrap_options`]' framework query count over 15 sub-proofs, not of anything @@ -1238,6 +1271,16 @@ fn the_wrap_census() { /// times the size (allocator behaviour, and the fact that a bigger program is /// bigger in different chips), so it is a projection and is labelled as one /// wherever it is printed. +/// +/// ⚠ **The PROVENANCE line is in doubt, the RATIO is not.** Slice 0 in a clean +/// environment is the same program as [`the_fixture_epoch_wraps`], which +/// measures 82,059,828 cells — so the 481,327,124 above cannot be that run, and +/// "the measured point is slice 0" is under-specified about which environment +/// slice 0 was in. See [`the_wrap_proves_and_verifies`] for what is and is not +/// known. The value is deliberately UNCHANGED: a ratio of two numbers taken +/// together on one run stays a valid coefficient whichever shape that run was, +/// and re-deriving it from a shape nobody measured the RSS of would replace a +/// misfiled observation with a fabricated one. const MEASURED_BYTES_PER_CELL: f64 = 16_228_499_456.0 / 481_327_124.0; fn projected_peak_bytes(main: u64, aux: u64) -> f64 { From fee4e770ec3f705cabfdc080661891013eab7a86 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 7 Sep 2026 16:04:16 -0300 Subject: [PATCH 4/4] test(lfm): second falsification arm, and state the pin rule instead of citing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review nits on the per-table wrap gate. The classification rule was cited as `HASH-PINNING.md`, which does not exist on this branch — the document lands with the RPX pin. A doc pointer that dangles is worse than no pointer, so the rule is quoted inline instead and the doc is self-contained under either pin. The gate had one falsification arm and the batched twin has two. The missing one matters more than its cost suggests: a flipped root makes the wrap UNBUILDABLE and never reaches the verifier at all, so `verify_against`'s reject path was untouched by the gate. It proved the machine will not lie without ever proving the verifier catches a lie. The moved-claimed-word arm hands the verifier the real proof under a claim it does not answer and asserts rejection, for about 0.16s against a 9s test. The harness doc no longer claims `wrap_run_from`'s third arm is unnecessary work in general: the moved PROGRAM DIGEST is the registry premise rather than this program's, and `machine_tests::verify_against_artifacts_agrees_with_the_registry_path` (machine_tests.rs:5403) already holds it on a trivial program. Kept as a separate commit rather than folded into 35c00aa6, which is already pushed; amending it would mean force-pushing a SHA the reviewer has seen. --- prover/src/lfm/wrap_tests.rs | 67 +++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/prover/src/lfm/wrap_tests.rs b/prover/src/lfm/wrap_tests.rs index aa0cf31a1..127cbbba0 100644 --- a/prover/src/lfm/wrap_tests.rs +++ b/prover/src/lfm/wrap_tests.rs @@ -422,12 +422,17 @@ fn the_wrap_proves_and_verifies() { /// /// # The pin, NAMED rather than implied /// -/// `epoch_tests::epoch_program` builds at [`WrapHash::production()`]. Under an -/// ALGEBRAIC pin that lowers the emitter's Merkle work to `Instr::Hash` and -/// consults the `LFM_HASH` socket, so `HASH-PINNING.md`'s classification rule -/// puts this site in the class that must be proved under -/// [`crate::hash_pin::BLOCK_HASHER`] — which it names twice over, through -/// `build_artifacts_with_hasher` for the artifacts and +/// The classification rule, stated here rather than cited — the document that +/// carries it lands with the RPX pin and does not exist on this branch: +/// +/// > A program built at `WrapHash::production()` emits `Instr::Hash` and must +/// > be proved under `BLOCK_HASHER`. A program that pins a byte hash on its own +/// > builder emits none, never consults the socket, and is correct at the +/// > registry's blessed default under every pin. +/// +/// `epoch_tests::epoch_program` builds at [`WrapHash::production()`], so this +/// site is in the first class and must name [`crate::hash_pin::BLOCK_HASHER`]. +/// It does, twice over: `build_artifacts_with_hasher` for the artifacts and /// `lfm_cell_counts_with_hasher` for the numbers, so neither the proof nor the /// census can be taken at `HasherKind::default()`. /// @@ -489,15 +494,24 @@ fn the_fixture_epoch_wraps() { } /// [`the_fixture_epoch_wraps`]' body: build the inner epoch, emit the per-table -/// assembled verifier, prove it under the pin, verify it, and run one tamper -/// arm. +/// assembled verifier, prove it under the pin, verify it, and run the two +/// falsification arms. +/// +/// **Two, and they are not one check twice.** A flipped root makes the wrap +/// UNBUILDABLE and never reaches the verifier at all; a moved claimed word +/// leaves the proof untouched and must be REJECTED by it. Only the second +/// exercises `verify_against`'s reject path, so a gate carrying only the first +/// would show that the machine will not lie without ever showing that the +/// verifier catches a lie. /// /// Deliberately NOT [`wrap_run_from`], which is the MEASUREMENT harness: that /// one emits the program a second time (the spine, for the closed-form -/// permutation check), walks the chip census and the row-cliff panel, and runs -/// three falsification arms of which one re-enters `lfm_prove`. Every one of -/// those earns its cost in a measurement run and none of them is what this gate -/// claims, so the gate pays for none of them. +/// permutation check) and walks the chip census and the row-cliff panel. Both +/// earn their cost in a measurement run and neither is what this gate claims, +/// so the gate pays for neither. Its third arm, the moved PROGRAM DIGEST, is +/// the registry premise rather than this program's, and +/// `machine_tests::verify_against_artifacts_agrees_with_the_registry_path` +/// already holds it on a trivial program at a fraction of the cost. fn fixture_wrap_run(inner: ProofOptions, inputs: EpochInputs) { let t = Instant::now(); let e = super::epoch_tests::real_epoch_from(inner.clone(), inputs); @@ -666,6 +680,35 @@ fn fixture_wrap_run(inner: ProofOptions, inputs: EpochInputs) { } Ok(_) => panic!("a tampered main root must not produce a wrap proof"), } + + // ---- ARM 2: the honest proof against a MOVED claimed statement must be + // REJECTED. + // + // The other half of the pair, and it is not redundant with arm 1 — the two + // exercise different machinery. Arm 1 never reaches the verifier at all: a + // false statement has no execution, so `verify_against`'s REJECT path is + // untouched by it, and a gate with only that arm would prove the machine + // refuses to lie without ever showing that the verifier catches one. This + // arm hands the verifier the REAL proof under a claim it does not answer; + // `absorb_lfm_statement` binds the proof to its published words, so it must + // reject. Costs one verify (~0.16s against a 9s test), which is why the + // batched twin carries it and why there was no case for leaving it out. + let mut moved = proved.public_words.clone(); + moved[0].1[0] += FE::one(); + assert!( + !verify_against( + &artifacts.roots, + &artifacts.program_id, + artifacts.keccak_rnd_chunks, + &proved.proof, + &moved, + &opts, + artifacts.hasher, + artifacts.chip_set, + ), + "a moved claimed public word must make the wrap proof UNVERIFIABLE" + ); + println!(" MOVED claimed public word 0: the wrap proof is UNVERIFIABLE"); } /// ★ SLICE 0's GPU-dispatch census (`thoughts/shared/gpu-recursion/EXPLORATION.md`,