Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e394f28
fix(lfm): the block path names its hasher at the call site, not in bu…
MauroToscano Sep 1, 2026
e6a4e27
refactor(lfm): build_artifacts defaults to REGISTRY_HASHER by name, n…
MauroToscano Sep 1, 2026
40d2f09
fix(lfm): the two proves-and-verifies legs name the pin, closing a C-…
MauroToscano Sep 2, 2026
a36f0c6
fix(lfm): every test that names a hash takes it from one source
MauroToscano Sep 1, 2026
00f1838
fix(lfm,tests): the prover and verifier aliases ARE the default hash …
MauroToscano Sep 1, 2026
8c8a487
style(tests): drop the transcript imports the alias sweep orphaned
MauroToscano Sep 7, 2026
0024ed1
fix(lfm): the Merkle BACKEND aliases are the default hash too
MauroToscano Sep 1, 2026
a655d8b
test(lfm): the gate stops enumerating spellings of the default
MauroToscano Sep 1, 2026
25d56ec
test(lfm): the gate's config allowlist admits DeviceTreeBackend
MauroToscano Sep 7, 2026
2cffabd
fix(lfm): the arena stride is the builder's digest width, not the con…
MauroToscano Sep 7, 2026
39eba79
style(tests): clippy trim_split_whitespace in the gate's config_items
MauroToscano Sep 7, 2026
603c1e1
feat(lfm): pin per-table-gpu's block path to RPX256
MauroToscano Sep 7, 2026
a7aadc9
docs(lfm): HASH-PINNING for the RPX pin, and the four static families…
MauroToscano Sep 7, 2026
f530795
feat(lfm): both regenerations under the RPX pin, reproduced control-f…
MauroToscano Sep 7, 2026
9a9d924
fix(lfm): regenerate the registry AFTER the statics it embeds
MauroToscano Sep 8, 2026
92968a2
fix(tests): the bitwise soundness control commits under the pinned pr…
MauroToscano Sep 8, 2026
d2e6362
fix(lfm,tests): the remaining digest strides follow the digest's width
MauroToscano Sep 8, 2026
8290093
fix(lfm): a host serializer at an explicit wrap hash's digest width
MauroToscano Sep 8, 2026
5b31faf
test(decode): re-bless the sub ELF's offline decode commitment under …
MauroToscano Sep 8, 2026
a0f1edb
fix(tests): the merkle-opening and L2G production twins run under the…
MauroToscano Sep 8, 2026
6ea2cfc
docs(lfm): regeneration order is statics first, registry second
MauroToscano Sep 8, 2026
b71f0e6
fix(tests): the join control's sibling walk and the split-cell surplu…
MauroToscano Sep 8, 2026
789c04c
test(continuation): headroom on the mid-pipeline error liveness guard
MauroToscano Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 267 additions & 0 deletions HASH-PINNING.md

Large diffs are not rendered by default.

19 changes: 12 additions & 7 deletions prover/src/bin/compute_lfm_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@
//! output over the generated block in `prover/src/lfm/registry.rs`. Drift
//! tests recompute and compare on every PR; a drift failure is investigated,
//! never re-blessed (the `compute_static_commitments` policy).
//!
//! ⚠ ORDER: on a hash-pin change run this AFTER `compute_static_commitments`
//! has been run and its output pasted. Slots 13 and 14 of every entry are
//! `keccak_rc` and `bitwise`'s `preprocessed_commitment`, which return the
//! BLESSED static constants in the tree rather than recomputing, and
//! `program_id` folds every root — so a table generated before the statics
//! embeds the outgoing hash's constants, and `machine_tests::registry_drift_*`
//! fires at exactly those two slots.

use lambda_vm_prover::GoldilocksCubicProofOptions;
use lambda_vm_prover::lfm::hash::HasherKind;
use lambda_vm_prover::lfm::programs::{
KECCAK_SPONGE_LEN, fri_toy_program, keccak_chain_program, keccak_sponge_program,
statement_replay_program, transcript_replay_program, trivial_program,
Expand All @@ -18,12 +25,10 @@ use lambda_vm_prover::lfm::validate;
/// other presets come online).
const REGISTRY_BLOWUP_FACTORS: &[u8] = &[2];

/// The `LFM_HASH` permutation the v0 registry is generated under.
///
/// Bound into every digest below, so changing it here is a re-blessing of the
/// whole table, not a re-run. A second hasher becomes additional rows, never a
/// silent replacement of these.
const REGISTRY_HASHER: HasherKind = HasherKind::Test;
// The permutation this table is blessed under is `registry::REGISTRY_HASHER` —
// a property of the TABLE rather than of this generator, and the same constant
// `build_artifacts` defaults to, so the two cannot drift apart.
use lambda_vm_prover::lfm::registry::REGISTRY_HASHER;

fn fmt_bytes(bytes: &[u8; 32]) -> String {
let inner = bytes
Expand Down
21 changes: 15 additions & 6 deletions prover/src/bin/compute_static_commitments.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
//! Prints static `(bitwise, keccak_rc, zero_page)` preprocessed-table commitments
//! for a fixed set of `blowup_factor` values. The output is pasted into the
//! Prints the static preprocessed-table commitments — FOUR families: `bitwise`,
//! `keccak_rc`, and `page`'s zero-page and private-page (OFFSET-only) constants
//! — for a fixed set of `blowup_factor` values. The output is pasted into the
//! `static_commitment` match bodies in `prover/src/tables/{bitwise,keccak_rc}.rs`
//! and the `static_zero_page_commitment` match body in `prover/src/tables/page.rs`.
//! and the `static_zero_page_commitment` / `static_private_page_commitment`
//! match bodies in `prover/src/tables/page.rs`.
//! The `static_commitments_tests` test suite pins the values so any drift in
//! the AIR or FFT pipeline is caught at test time.
//!
//! Run with:
//! cargo run --bin compute_static_commitments --release
//!
//! ⚠ On a hash-pin change run this FIRST and paste before `compute_lfm_registry`:
//! the registry embeds these constants (slots 13 and 14 of every entry, and
//! `program_id` folds them), so a registry generated before the paste carries
//! the outgoing hash's statics and the drift gate catches it.
//!
//! ⚠️ Do not run this just to silence a failing drift test — see the
//! "Regenerating" section on `static_commitment` in `bitwise.rs` /
//! `keccak_rc.rs` and `static_zero_page_commitment` in `page.rs` for when
//! it's actually appropriate to bless new bytes.
//! `keccak_rc.rs` and the two `page.rs` constants for when it's actually
//! appropriate to bless new bytes. A hash-pin change is one such time, and it
//! regenerates all four families together (`prover/src/hash_pin.rs`).

use lambda_vm_prover::tables::{STATIC_BLOWUP_FACTORS, bitwise, keccak_rc, page};
use stark::config::Commitment;
Expand All @@ -37,7 +45,8 @@ fn main() {
println!(
"// Paste these match arms into the `static_commitment` match bodies\n\
// in `prover/src/tables/{{bitwise,keccak_rc}}.rs` and the\n\
// `static_zero_page_commitment` match body in `prover/src/tables/page.rs`.\n"
// `static_zero_page_commitment` / `static_private_page_commitment`\n\
// match bodies in `prover/src/tables/page.rs`.\n"
);

let zero_page_config = page::PageConfig::zero_init(0);
Expand Down
10 changes: 9 additions & 1 deletion prover/src/continuation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2489,7 +2489,15 @@ mod tests {
let _ = done_tx.send(r.map(|_| ()));
});
let result = done_rx
.recv_timeout(std::time::Duration::from_secs(300))
// 1800 s of headroom rather than 300. This is a liveness guard — the
// regression it catches wedges the pipeline FOREVER, so any finite
// bound still catches it — and the bound has to clear an honest run
// under load: an algebraic hash pin doubles this test's own proving
// work (alone, three runs each: 7.3-7.6 s at the BLAKE3 default,
// 15.7-15.9 s under RPX), and inside the full `--lib` suite's
// parallel load the old 300 s fired while the test was still making
// progress.
.recv_timeout(std::time::Duration::from_secs(1800))
.expect("prove_continuation wedged: the pipeline did not shut down on error");
let err = result.expect_err("the injected fault must surface as Err");
assert!(
Expand Down
57 changes: 35 additions & 22 deletions prover/src/hash_pin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,32 +56,46 @@
//!
//! # `cuda` on an algebraic pin
//!
//! Compiles, and still cannot prove under the wrong hash. The algebraic
//! backends are `DeviceTreeBackend`s carrying their own `CommitmentHash` as the
//! device dispatch key, and `math-cuda` has no kernels for those permutations
//! yet, so a GPU run under an algebraic pin aborts at its first device commit
//! Compiles, and cannot prove under the wrong hash. The algebraic backends are
//! `DeviceTreeBackend`s carrying their own `CommitmentHash` as the device
//! dispatch key, so a device tree is built by the kernels of the hash it is
//! named for or not built at all. RPX256 has those kernels (`math_cuda::rpx`),
//! so a GPU run under this pin commits on the device; RPO256 and Poseidon do
//! not yet, and a GPU run under one of them aborts at its first device commit
//! with `unimplemented!` naming the hash. ⛔ Neither a `compile_error!` nor a
//! byte-hash fallback belongs here: the first hides the cuda lint arm from the
//! branch, the second is exactly the silent wrong-hash build this module exists
//! to make impossible. Proving a block under an algebraic pin on GPU means
//! landing the kernels, and nothing less.
//! to make impossible.
//!
//! # ⚠ TWO regenerations, not one
//! # ⚠ TWO regenerations, not one — in THIS order, plus one stray constant
//!
//! A pin change is **not** complete until every root blessed under the old hash
//! is regenerated, and there are two families of them:
//! is regenerated. There are two families of them and the order is load-bearing:
//!
//! 1. **`LFM_REGISTRY`** — the hasher is folded into every `program_id`.
//! `cargo run --bin compute_lfm_registry --release`.
//! 2. **The static preprocessed commitments** — `bitwise`, `keccak_rc` and
//! `page` each return a BLESSED CONSTANT from `preprocessed_commitment`
//! 1. **The static preprocessed commitments — FIRST.** FOUR families: `bitwise`,
//! `keccak_rc`, and `page`'s zero-page AND private-page constants, at blowup
//! 2/4/8. Each returns a BLESSED CONSTANT from `preprocessed_commitment`
//! rather than recomputing, so under a new pin the prover recomputes an
//! RPO root, compares it against a BLAKE3 constant, and fails with
//! algebraic root, compares it against a BLAKE3 constant, and fails with
//! `ProvingError::PrecomputedCommitmentMismatch`.
//! `cargo run --bin compute_static_commitments --release`.
//!
//! ✓ VERIFIED (2) empirically: it is exactly how the trial flip failed, and it
//! is the correct failure — loud, at prove time, naming the cause. `registry.rs`
//! `cargo run --bin compute_static_commitments --release`, then paste.
//! 2. **`LFM_REGISTRY` — SECOND, only once the statics are in the tree.**
//! `registry.rs` fills slots 13 and 14 of every entry from `keccak_rc` and
//! `bitwise`'s `preprocessed_commitment` — the blessed constants above, not a
//! recomputation — and `lfm_program_id` folds every root. A registry generated
//! before the statics were pasted therefore embeds the OUTGOING hash's
//! constants, and `machine_tests::registry_drift_*` fires at exactly those two
//! slots. The control-first re-run under the outgoing pin cannot see this:
//! both tables are self-consistent there.
//! `cargo run --bin compute_lfm_registry --release`.
//! 3. **`SUB_DECODE_COMMITMENT_BLOWUP_2`** in `tests/decode_tests.rs` — a
//! test-local blessed constant outside both generators, regenerated by the
//! `#[ignore]` test `print_decode_commitment_for_sub`.
//!
//! ✓ VERIFIED (1) empirically: it is exactly how the trial flip failed, and it
//! is the correct failure — loud, at prove time, naming the cause. ✓ VERIFIED
//! (2) empirically too: the first RPX regeneration ran the registry before the
//! statics and all six drift tests fired at slots 13 and 14. `registry.rs`
//! governs both: a drift failure is investigated, never re-blessed to silence
//! the test, and neither table is ever hand-edited.

Expand All @@ -90,14 +104,13 @@
/// Every `multi_prove` / `multi_verify` instantiation in this crate names this
/// rather than `stark::config::DefaultStarkHash`, so the two can differ on a
/// branch without the workspace default moving.
pub type BlockStarkHash = stark::config::DefaultStarkHash;
pub type BlockStarkHash = crate::lfm::algebraic_commit::RpxStarkHash;

/// The Fiat–Shamir transcript OBJECT the block path builds.
///
/// See the module header for why this is pinned separately from
/// [`BlockStarkHash`] rather than derived from it.
pub type BlockTranscript =
stark::config::DefaultStarkTranscript<crate::tables::types::GoldilocksExtension>;
pub type BlockTranscript = crate::lfm::algebraic_transcript::AlgebraicTranscript;

/// A fresh block-path transcript over `seed`.
///
Expand All @@ -106,7 +119,7 @@ pub type BlockTranscript =
/// algebraic one absorbs it as its first `append_bytes` call. Callers should not
/// have to know which.
pub fn block_transcript(seed: &[u8]) -> BlockTranscript {
BlockTranscript::new(seed)
BlockTranscript::with_seed(BLOCK_HASHER, seed)
}

/// The prover the block path drives, at [`BlockStarkHash`].
Expand Down Expand Up @@ -145,7 +158,7 @@ pub type BlockVerifier<Field, FieldExtension, PI> =
/// Every `execute` and prove call on the block path names this rather than a
/// literal, so the two axes cannot drift apart in a test harness while
/// production stays correct.
pub const BLOCK_HASHER: crate::lfm::hash::HasherKind = crate::lfm::hash::HasherKind::Test;
pub const BLOCK_HASHER: crate::lfm::hash::HasherKind = crate::lfm::hash::HasherKind::Rpx;

/// The [`CommitmentHash`] the block path's roots may be called by.
///
Expand Down
19 changes: 11 additions & 8 deletions prover/src/lfm/aggregator_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ use super::epoch::RootCells;
use super::executor::execute;
use super::instr::ArenaId;
use super::proof::{BatchedLfmProof, aggregation_wrap_options, verify_against_batched};
use super::registry::{LfmArtifacts, build_artifacts};
use super::registry::{LfmArtifacts, build_artifacts_with_hasher};
use super::statement::{LFM_MACHINE_VERSION, LFM_STATEMENT_TAG, absorb_lfm_statement};
use super::transcript_replay::{Candidate, TranscriptReplay, assert_canonical, candidate_to_felt};
use super::word::{LfmWord, base_word, ext_word};
Expand Down Expand Up @@ -1679,7 +1679,7 @@ fn fixture_leg() -> (RealBatchedLfm, LfmProgram) {

let opts = aggregation_wrap_options();
let program = trivial_program();
let artifacts = build_artifacts(&program, &opts);
let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER);
let arenas: Vec<Vec<LfmWord>> = vec![
(0..4u64)
.map(|i| core::array::from_fn(|j| FE::from(1_000 * (i + 1) + j as u64)))
Expand Down Expand Up @@ -1877,7 +1877,7 @@ fn fixture_wraps() -> (
let mut arenas = super::epoch_tests::batched_epoch_arenas(&e);
arenas.push(super::epoch_verify_tests::batched_opening_arena(&e));
arenas.push(super::epoch_verify_tests::batched_fri_arena(&e));
let artifacts = build_artifacts(&program, &opts);
let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER);
let proved = match lfm_prove_batched(&program, &artifacts, &arenas, &opts) {
Ok(p) => p,
Err(e) => {
Expand Down Expand Up @@ -1925,7 +1925,7 @@ fn fixture_aggregate() -> FixtureAggregate {
let g = real_global(&elf_bytes, &bundle, &inner);
let program = global_verifier_program(&g);
let arenas = global_arena_words(&g);
let artifacts = build_artifacts(&program, &opts);
let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER);
let proved = lfm_prove_batched(&program, &artifacts, &arenas, &opts)
.expect("the global wrap must prove batched at the aggregation preset");
let global_wrap = real_batched_lfm(artifacts, opts, &proved);
Expand Down Expand Up @@ -2195,7 +2195,7 @@ fn the_aggregate_leg_census_matches_the_closed_form() {

let opts = aggregation_wrap_options();
let program = trivial_program();
let artifacts = build_artifacts(&program, &opts);
let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER);
let arenas: Vec<Vec<LfmWord>> = vec![
(0..4u64)
.map(|i| core::array::from_fn(|j| FE::from(1_000 * (i + 1) + j as u64)))
Expand Down Expand Up @@ -2420,7 +2420,8 @@ fn the_real_block_aggregates_end_to_end() {
let mut arenas = super::epoch_tests::batched_epoch_arenas(&e);
arenas.push(super::epoch_verify_tests::batched_opening_arena(&e));
arenas.push(super::epoch_verify_tests::batched_fri_arena(&e));
let artifacts = build_artifacts(&program, &agg_opts);
let artifacts =
build_artifacts_with_hasher(&program, &agg_opts, crate::hash_pin::BLOCK_HASHER);
let wrap_file = format!("wrap_{k}.rkyv");
let cached = cache_path(&wrap_file).is_some_and(|p| p.exists());
let tp = Instant::now();
Expand Down Expand Up @@ -2454,7 +2455,8 @@ fn the_real_block_aggregates_end_to_end() {
let g = real_global(&inputs.elf_bytes, &bundle, &inner);
let g_program = global_verifier_program(&g);
let g_arenas = global_arena_words(&g);
let g_artifacts = build_artifacts(&g_program, &agg_opts);
let g_artifacts =
build_artifacts_with_hasher(&g_program, &agg_opts, crate::hash_pin::BLOCK_HASHER);
let g_cached = cache_path("global_wrap.rkyv").is_some_and(|p| p.exists());
let tp = Instant::now();
let g_proved = if g_cached {
Expand Down Expand Up @@ -2583,7 +2585,8 @@ fn the_real_block_aggregates_end_to_end() {
Err(_) => agg_opts.clone(),
};
let t = Instant::now();
let agg_artifacts = build_artifacts(&program, &terminal_opts);
let agg_artifacts =
build_artifacts_with_hasher(&program, &terminal_opts, crate::hash_pin::BLOCK_HASHER);
println!(
" aggregation artifacts built in {:.1}s",
t.elapsed().as_secs_f64()
Expand Down
15 changes: 8 additions & 7 deletions prover/src/lfm/blake3_chip_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,8 @@ fn tampering_with_the_blake3_witness_is_not_accepted() {
let program = blake3_sponge_program(65);
let artifacts = build_artifacts(&program, &opts);
let exec = execute(&program, &sponge_arenas(&msg), &TestPermutation).expect("execute");
let mut traces = super::trace::build_traces(&program, &exec.records);
let mut traces =
super::trace::build_traces_with_hasher(&program, &exec.records, artifacts.hasher);

// One output byte of the first compression.
let col = cols::out_word(0, 0);
Expand Down Expand Up @@ -720,10 +721,9 @@ fn the_merkle_constructions_agree_with_the_host_under_both_hashes() {
b.public(root[1]);
let program = compile(b.finish());

let arena_words: Vec<LfmWord> = leaves
.iter()
.flat_map(super::proof_arena::commitment_words)
.collect();
// At THIS program's width (a byte hash, chosen on its builder above),
// not the configuration's: under an algebraic pin the two differ.
let arena_words: Vec<LfmWord> = super::proof_arena::commitments_to_arena_for(&leaves, hash);
let exec = execute(&program, &[arena_words], &TestPermutation)
.unwrap_or_else(|e| panic!("{hash:?}: the tree build must execute: {e:?}"));
assert_eq!(
Expand Down Expand Up @@ -1596,7 +1596,7 @@ fn blake3_chunking_splits_the_chain_into_uneven_chunks() {
&TestPermutation,
)
.expect("honest execution");
let traces = super::trace::build_traces(&program, &exec.records);
let traces = super::trace::build_traces_with_hasher(&program, &exec.records, artifacts.hasher);
assert_eq!(traces.blake3.len(), 3, "one LFM_BLAKE3 trace per chunk");
assert_eq!(
traces
Expand Down Expand Up @@ -1780,7 +1780,8 @@ fn a_tampered_non_first_blake3_chunk_rejects() {
let artifacts = build_artifacts(&program, &opts);
let exec = execute(&program, &sponge_arenas(&msg), &TestPermutation).expect("execute");

let mut traces = super::trace::build_traces(&program, &exec.records);
let mut traces =
super::trace::build_traces_with_hasher(&program, &exec.records, artifacts.hasher);
assert_eq!(traces.blake3.len(), 3);
// One output byte of the LAST chunk's first compression — the eleventh of
// the twelve, which no other chunk carries.
Expand Down
19 changes: 11 additions & 8 deletions prover/src/lfm/blake3_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@
//! The permutation count comes from wave 8's rate-parameterised closed form
//! and is inherited, not re-established here.
//! - **Anything cryptographic about the 6-round variant** (assumption A6R).

use crypto::fiat_shamir::default_transcript::DefaultTranscript;
use crypto::fiat_shamir::is_transcript::IsTranscript;
use stark::config::Commitment;
use stark::constraints::builder::{
Expand All @@ -39,10 +37,10 @@ use stark::lookup::{
};
use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions};
use stark::proof::view::MultiProofView;
use stark::prover::{IsStarkProver, Prover};
use stark::prover::IsStarkProver;
use stark::trace::TraceTable;
use stark::traits::AIR;
use stark::verifier::{IsStarkVerifier, Verifier};
use stark::verifier::IsStarkVerifier;

use crate::tables::bitwise;
use crate::tables::types::{BusId, FE, FEE, GoldilocksExtension, GoldilocksField, VmTable};
Expand Down Expand Up @@ -70,8 +68,8 @@ fn options() -> ProofOptions {
GoldilocksCubicProofOptions::with_blowup(2).expect("probe options")
}

fn transcript() -> DefaultTranscript<E> {
let mut t = DefaultTranscript::<E>::new(&[]);
fn transcript() -> crate::hash_pin::BlockTranscript {
let mut t = crate::hash_pin::block_transcript(&[]);
t.append_bytes(PROBE_TAG);
t
}
Expand Down Expand Up @@ -268,7 +266,7 @@ fn prove_traces(
let pairs: Vec<(DynAir, &mut TraceTable<F, E>, &())> =
vec![(chip, t0, &()), (&mirror, t1, &()), (&bw_air, t2, &())];
let mut t = transcript();
Prover::multi_prove(
crate::hash_pin::BlockProver::multi_prove(
pairs,
&mut t,
#[cfg(feature = "disk-spill")]
Expand All @@ -289,7 +287,12 @@ fn verify_proof(
);
let refs: Vec<DynAir> = vec![chip, &mirror, &bw_air];
let mut vt = transcript();
Verifier::multi_verify_views(&refs, MultiProofView::Owned(proof), &mut vt, &FEE::zero())
crate::hash_pin::BlockVerifier::multi_verify_views(
&refs,
MultiProofView::Owned(proof),
&mut vt,
&FEE::zero(),
)
}

/// Prove + verify, optionally corrupting the chip trace in between.
Expand Down
Loading
Loading