From cd6daf31f32330a399852479de8548fdfda28dea Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 21 Oct 2026 09:51:56 -0300 Subject: [PATCH 01/11] Open every next-row column an AIR reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AirWithBuses hardcoded its next-row openings at g·z to the LogUp accumulator, so any main-column constraint reading the next row was checked against zero and the proof failed to verify. Derive the set in AirWithBuses::new from the wrapped constraint set with a NextRowReads builder (every main(1, col) it evaluates), plus the accumulator when the AIR has interactions. Only single-row steps get the derived set: multi-row steps keep the accumulator alone, so the step-size ceiling guarded by the_prover_cannot_prove_a_step_size_two_air stays. The VM's tables read only the current row, so their openings do not change; the Field VM's transition constraints need the new columns. Soundness tests cover a next-row constraint that verifies honestly and rejects a tampered next row. --- crypto/stark/src/constraints/builder.rs | 63 ++++++++++++++++ crypto/stark/src/lookup.rs | 35 ++++++--- .../src/tests/bus_tests/soundness_tests.rs | 71 +++++++++++++++++++ 3 files changed, 159 insertions(+), 10 deletions(-) diff --git a/crypto/stark/src/constraints/builder.rs b/crypto/stark/src/constraints/builder.rs index 983c8ef3d..ff53e25ca 100644 --- a/crypto/stark/src/constraints/builder.rs +++ b/crypto/stark/src/constraints/builder.rs @@ -463,6 +463,69 @@ impl ConstraintBuilder for MetaBuilder { } } +// ============================================================================= +// NextRowReads — the columns a body reads at a later row +// ============================================================================= + +/// Records the trace columns a [`ConstraintSet`] body reads at frame offset +/// `≥ 1`, which the verifier must open at `g·z`. Like [`MetaBuilder`] it does +/// no field work, so it also runs where IR capture does not (the guest). +#[derive(Default)] +pub struct NextRowReads { + main: core::cell::RefCell>, + aux: core::cell::RefCell>, +} + +impl NextRowReads { + pub fn new() -> Self { + Self::default() + } + + /// Full-width `[main | aux]` indices, sorted. + pub fn into_columns(self, main_width: usize) -> Vec { + let main = self.main.into_inner(); + let aux = self.aux.into_inner(); + main.into_iter() + .chain(aux.into_iter().map(|c| main_width + c)) + .collect() + } +} + +impl ConstraintBuilder for NextRowReads { + type Expr = Nil; + type ExprE = Nil; + + fn main(&self, offset: usize, col: usize) -> Nil { + if offset > 0 { + self.main.borrow_mut().insert(col); + } + Nil + } + fn aux(&self, offset: usize, col: usize) -> Nil { + if offset > 0 { + self.aux.borrow_mut().insert(col); + } + Nil + } + fn challenge(&self, _idx: usize) -> Nil { + Nil + } + fn alpha_pow(&self, _idx: usize) -> Nil { + Nil + } + fn table_offset(&self) -> Nil { + Nil + } + fn const_base(&self, _v: u64) -> Nil { + Nil + } + fn const_signed(&self, _v: i64) -> Nil { + Nil + } + fn emit_base_rows(&mut self, _constraint_idx: usize, _rows: RowDomain, _e: Nil) {} + fn emit_ext_rows(&mut self, _constraint_idx: usize, _rows: RowDomain, _e: Nil) {} +} + // ============================================================================= // Shared AIR plumbing: run a ConstraintSet through the folders // ============================================================================= diff --git a/crypto/stark/src/lookup.rs b/crypto/stark/src/lookup.rs index 664c0510f..7390002a8 100644 --- a/crypto/stark/src/lookup.rs +++ b/crypto/stark/src/lookup.rs @@ -992,6 +992,8 @@ pub struct AirWithBuses< /// Maximum number of bus elements across all interactions. /// Used to compute the correct number of alpha powers. max_bus_elements: usize, + /// Full-width columns some transition constraint reads at the next row. + next_row_columns: Vec, } /// Cloning an `AirWithBuses` copies its derived artifacts — the MetaBuilder-run @@ -1025,6 +1027,7 @@ impl< precomputed_columns: self.precomputed_columns.clone(), name: self.name.clone(), max_bus_elements: self.max_bus_elements, + next_row_columns: self.next_row_columns.clone(), } } } @@ -1088,6 +1091,22 @@ impl< .max() .unwrap_or(0); + // The verifier opens only these columns at `g·z`: the wrapped set's + // next-row reads plus the LogUp accumulator. Multi-row steps keep the + // accumulator alone (see `the_prover_cannot_prove_a_step_size_two_air`). + let mut next_row_columns = if step_size == 1 { + let mut reads = crate::constraints::builder::NextRowReads::new(); + constraint_set.eval(&mut reads); + reads.into_columns(trace_layout.0) + } else { + Vec::new() + }; + if num_interactions > 0 { + next_row_columns.push(trace_layout.0 + logup.acc_column_idx); + } + next_row_columns.sort_unstable(); + next_row_columns.dedup(); + // Create context let context = AirContext { proof_options: proof_options.clone(), @@ -1113,6 +1132,7 @@ impl< precomputed_columns: None, name: None, max_bus_elements, + next_row_columns, } } @@ -1275,16 +1295,11 @@ where } fn trace_ood_next_row_columns(&self) -> Vec { - // The only transition constraint that reads the next row is the circular - // LogUp accumulator, and after forward accumulation it reads only the - // accumulated column there (all committed terms and absorbed operands - // read the current row). Its full-width index is the main width plus the - // accumulated column's aux index. No interactions => no next-row reads. - if self.auxiliary_trace_build_data.interactions.is_empty() { - Vec::new() - } else { - vec![self.trace_layout.0 + self.logup.acc_column_idx] - } + // Derived in `new` from the wrapped constraint set, plus the LogUp + // accumulator: after forward accumulation it is the only LogUp column + // read at the next row (committed terms and absorbed operands read the + // current row). + self.next_row_columns.clone() } fn has_trace_interaction(&self) -> bool { diff --git a/crypto/stark/src/tests/bus_tests/soundness_tests.rs b/crypto/stark/src/tests/bus_tests/soundness_tests.rs index 157802cdf..9b2165004 100644 --- a/crypto/stark/src/tests/bus_tests/soundness_tests.rs +++ b/crypto/stark/src/tests/bus_tests/soundness_tests.rs @@ -1203,6 +1203,77 @@ fn test_trace_ood_next_row_window_matches_captured_ir() { assert_ood_window_matches_ir(&busless, true, "busless"); } +/// `col0' = col0 + 1` on every row but the last. +#[derive(Clone)] +struct Increment; + +impl crate::constraints::builder::ConstraintSet for Increment { + fn eval>(&self, b: &mut B) { + let e = b.main(1, 0) - b.main(0, 0) - b.one(); + b.emit_base_rows(0, crate::constraints::builder::RowDomain::except_last(1), e); + } +} + +/// A wrapped constraint set that reads a main column at the next row gets that +/// column opened at `g·z`, next to the accumulator, and its proofs verify. +#[test_log::test] +fn test_next_row_main_reads_are_opened_and_verify() { + let opts = ProofOptions::default_test_options(); + for with_bus in [false, true] { + let interactions = if with_bus { + vec![ + BusInteraction::sender( + TEST_BUS, + Multiplicity::Column(1), + Packing::Direct.columns(&[0]), + ), + BusInteraction::receiver( + TEST_BUS, + Multiplicity::Column(1), + Packing::Direct.columns(&[0]), + ), + ] + } else { + vec![] + }; + let air = AirWithBuses::::new( + 2, + AuxiliaryTraceBuildData { interactions }, + &opts, + 1, + Increment, + ); + let (main, aux) = air.trace_layout(); + let expected = if with_bus { + vec![0, main + aux - 1] + } else { + vec![0] + }; + assert_eq!(air.trace_ood_next_row_columns(), expected); + assert_ood_window_matches_ir(&air, true, "increment"); + + let n = 16u64; + let mut trace = TraceTable::from_columns_main( + vec![(0..n).map(FE::from).collect(), vec![FE::one(); n as usize]], + 1, + ); + let proof = multi_prove_ram( + vec![(&air, &mut trace, &())], + &mut DefaultTranscript::::new(&[]), + ) + .expect("proving"); + assert!( + Verifier::multi_verify( + &[&air], + &proof, + &mut DefaultTranscript::::new(&[]), + &FieldElement::zero() + ), + "with_bus={with_bus}" + ); + } +} + /// The g·z pruning actually shrinks the proof: a LogUp table opens every column /// at z (the current-row block) but only the accumulator at the next row. #[test_log::test] From 890b89b75376bddc69cee5741d56080af26f7f9d Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 21 Oct 2026 09:54:02 -0300 Subject: [PATCH 02/11] Add P1W16 and FP3MAC ecalls to the executor Two precompiles for option B of split recursion, where the hash half of a leaf verifier (v_bin) runs as a native RISC-V guest: - P1W16Permute (u64::MAX-5) applies the width-16 Poseidon1 permutation in place to a [u64; 16] state, rejecting non-canonical inputs. It is the same permutation the LFM's p1w16 chip constrains. - Fp3Mac (u64::MAX-7) computes acc*a + x over Fp[t]/(t^3-2) in place, one Horner step of the checksum that binds v_bin to the Field VM. Both have syscalls wrappers, executor unit tests (known answers, non-canonical rejection) and a guest program each. The CLI counts them as non-tracked accelerators. --- Cargo.lock | 2 + bin/cli/src/main.rs | 2 +- executor/Cargo.toml | 4 + .../rust/fp3mac_precompile/.cargo/config.toml | 5 + .../rust/fp3mac_precompile/Cargo.lock | 251 ++++++++++++++++++ .../rust/fp3mac_precompile/Cargo.toml | 9 + .../rust/fp3mac_precompile/src/main.rs | 34 +++ .../rust/p1w16_precompile/.cargo/config.toml | 5 + .../programs/rust/p1w16_precompile/Cargo.lock | 251 ++++++++++++++++++ .../programs/rust/p1w16_precompile/Cargo.toml | 9 + .../rust/p1w16_precompile/src/main.rs | 21 ++ executor/src/tests/fp3mac_tests.rs | 110 ++++++++ executor/src/tests/mod.rs | 2 + executor/src/tests/p1w16_tests.rs | 81 ++++++ executor/src/vm/instruction/execution.rs | 126 +++++++++ syscalls/src/syscalls.rs | 51 ++++ 16 files changed, 962 insertions(+), 1 deletion(-) create mode 100644 executor/programs/rust/fp3mac_precompile/.cargo/config.toml create mode 100644 executor/programs/rust/fp3mac_precompile/Cargo.lock create mode 100644 executor/programs/rust/fp3mac_precompile/Cargo.toml create mode 100644 executor/programs/rust/fp3mac_precompile/src/main.rs create mode 100644 executor/programs/rust/p1w16_precompile/.cargo/config.toml create mode 100644 executor/programs/rust/p1w16_precompile/Cargo.lock create mode 100644 executor/programs/rust/p1w16_precompile/Cargo.toml create mode 100644 executor/programs/rust/p1w16_precompile/src/main.rs create mode 100644 executor/src/tests/fp3mac_tests.rs create mode 100644 executor/src/tests/p1w16_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 2a33cafb4..c0e45d8a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -604,9 +604,11 @@ dependencies = [ name = "executor" version = "0.1.0" dependencies = [ + "crypto", "ecsm", "k256", "lambda-vm-syscalls", + "math", "rustc-demangle", "serde", "serde_json", diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index d65abf60c..6ccb70dd8 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -931,7 +931,7 @@ fn cmd_execute( Some(Accelerator::Keccak) => keccak_calls += 1, Some(Accelerator::Blake3) => blake3_calls += 1, Some(Accelerator::Ecsm) => ecsm_calls += 1, - None => {} + Some(Accelerator::P1w16 | Accelerator::Fp3Mac) | None => {} } } if cycle_budget.is_some_and(|budget| cycle_count >= budget) { diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 91ae64ae9..204b79f42 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -12,6 +12,10 @@ ecsm = { path = "../crypto/ecsm" } # `Hint` ecall — same k256 arithmetic the guest verifies against. Production code: # `compute_hint` runs in every proving execution of a hint-using guest. k256 = { version = "0.13", default-features = false, features = ["arithmetic", "expose-field"] } +# Host-side width-16 Poseidon1 for the `P1w16Permute` ecall: the same +# permutation the prover's chip constrains. +crypto = { path = "../crypto/crypto" } +math = { path = "../crypto/math" } [dev-dependencies] # Test-only: the guest-side syscall crate re-declares the `hint` selectors as `usize` diff --git a/executor/programs/rust/fp3mac_precompile/.cargo/config.toml b/executor/programs/rust/fp3mac_precompile/.cargo/config.toml new file mode 100644 index 000000000..ca99a3f45 --- /dev/null +++ b/executor/programs/rust/fp3mac_precompile/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] diff --git a/executor/programs/rust/fp3mac_precompile/Cargo.lock b/executor/programs/rust/fp3mac_precompile/Cargo.lock new file mode 100644 index 000000000..63e9295cb --- /dev/null +++ b/executor/programs/rust/fp3mac_precompile/Cargo.lock @@ -0,0 +1,251 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "fp3mac_precompile" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/fp3mac_precompile/Cargo.toml b/executor/programs/rust/fp3mac_precompile/Cargo.toml new file mode 100644 index 000000000..658bc02ab --- /dev/null +++ b/executor/programs/rust/fp3mac_precompile/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "fp3mac_precompile" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/fp3mac_precompile/src/main.rs b/executor/programs/rust/fp3mac_precompile/src/main.rs new file mode 100644 index 000000000..f64c003c4 --- /dev/null +++ b/executor/programs/rust/fp3mac_precompile/src/main.rs @@ -0,0 +1,34 @@ +use lambda_vm_syscalls::syscalls; + +/// Cells the Horner runs over. +const CELLS: usize = 48; + +// Exercises the cubic-extension multiply-add ecall: a Horner evaluation +// `acc ← acc·al + x` over pseudo-random canonical cells (edge values first), +// committing the three words of `acc`. +pub fn main() { + const P: u64 = 0xFFFF_FFFF_0000_0001; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = || { + seed = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = seed; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + (z ^ (z >> 31)) % P + }; + let mut state: [u64; 6] = [0, 0, 0, next(), next(), next()]; + let edges: [[u64; 3]; 3] = [[0, 1, P - 1], [P - 1, P - 1, P - 1], [0xFFFF_FFFF, 0, 1]]; + for k in 0..CELLS { + let x = if k < edges.len() { + edges[k] + } else { + [next(), next(), next()] + }; + syscalls::fp3mac(&mut state, &x); + } + let mut output = Vec::with_capacity(3 * 8); + for w in &state[..3] { + output.extend_from_slice(&w.to_le_bytes()); + } + syscalls::commit(&output); +} diff --git a/executor/programs/rust/p1w16_precompile/.cargo/config.toml b/executor/programs/rust/p1w16_precompile/.cargo/config.toml new file mode 100644 index 000000000..ca99a3f45 --- /dev/null +++ b/executor/programs/rust/p1w16_precompile/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] diff --git a/executor/programs/rust/p1w16_precompile/Cargo.lock b/executor/programs/rust/p1w16_precompile/Cargo.lock new file mode 100644 index 000000000..9eb4587f5 --- /dev/null +++ b/executor/programs/rust/p1w16_precompile/Cargo.lock @@ -0,0 +1,251 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "p1w16_precompile" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/executor/programs/rust/p1w16_precompile/Cargo.toml b/executor/programs/rust/p1w16_precompile/Cargo.toml new file mode 100644 index 000000000..caa44245c --- /dev/null +++ b/executor/programs/rust/p1w16_precompile/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "p1w16_precompile" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/p1w16_precompile/src/main.rs b/executor/programs/rust/p1w16_precompile/src/main.rs new file mode 100644 index 000000000..782b02459 --- /dev/null +++ b/executor/programs/rust/p1w16_precompile/src/main.rs @@ -0,0 +1,21 @@ +use lambda_vm_syscalls::syscalls; + +// Exercises the width-16 Poseidon1 ecall: a chain of permutations from a +// state with edge values (0, 1, p − 1), committing every lane of the last. +pub fn main() { + const P: u64 = 0xFFFF_FFFF_0000_0001; + let mut state: [u64; 16] = core::array::from_fn(|i| match i % 4 { + 0 => 0, + 1 => 1, + 2 => P - 1, + _ => (i as u64) * 0x0123_4567_89AB_CDEF % P, + }); + for _ in 0..5 { + syscalls::p1w16_permute(&mut state); + } + let mut output = Vec::with_capacity(16 * 8); + for lane in state { + output.extend_from_slice(&lane.to_le_bytes()); + } + syscalls::commit(&output); +} diff --git a/executor/src/tests/fp3mac_tests.rs b/executor/src/tests/fp3mac_tests.rs new file mode 100644 index 000000000..751b863c0 --- /dev/null +++ b/executor/src/tests/fp3mac_tests.rs @@ -0,0 +1,110 @@ +//! Tests for the cubic-extension multiply-add syscall. + +use crate::vm::instruction::decoding::Instruction; +use crate::vm::instruction::execution::{ExecutionError, FP3MAC_SYSCALL_NUMBER, fp3mac}; +use crate::vm::memory::Memory; +use crate::vm::registers::Registers; + +const STATE_ADDR: u64 = 0x1000; +const CELL_ADDR: u64 = 0x2000; +const P: u64 = math::field::goldilocks::GOLDILOCKS_PRIME; + +fn run(memory: &mut Memory, state: u64, cell: u64) -> Result<(), ExecutionError> { + let mut pc = 0; + let mut registers = Registers::default(); + registers.write(17, FP3MAC_SYSCALL_NUMBER).unwrap(); + registers.write(10, state).unwrap(); + registers.write(11, cell).unwrap(); + Instruction::EcallEbreak + .run(&mut pc, &mut registers, memory) + .map(|_| ()) +} + +fn store(memory: &mut Memory, addr: u64, words: &[u64]) { + for (i, &w) in words.iter().enumerate() { + memory.store_doubleword(addr + 8 * i as u64, w).unwrap(); + } +} + +fn load(memory: &Memory, addr: u64) -> [u64; N] { + core::array::from_fn(|i| memory.load_doubleword(addr + 8 * i as u64).unwrap()) +} + +#[test] +fn test_fp3mac_syscall_multiplies_and_adds_in_place() { + let state: [u64; 6] = core::array::from_fn(|i| (i as u64 + 3) * 0x0123_4567_89AB_CDEF % P); + let x = [P - 1, 7, 0x8000_0000_0000_0001 % P]; + let mut memory = Memory::default(); + store(&mut memory, STATE_ADDR, &state); + store(&mut memory, CELL_ADDR, &x); + run(&mut memory, STATE_ADDR, CELL_ADDR).unwrap(); + let out: [u64; 6] = load(&memory, STATE_ADDR); + assert_eq!(out[..3], fp3mac(state, x)); + assert_eq!(out[3..], state[3..], "al is left alone"); + assert_eq!(load::<3>(&memory, CELL_ADDR), x, "the cell is left alone"); +} + +/// `t·t² = t³ = 2`: the extension is `Fp[t]/(t³ − 2)`. +#[test] +fn test_fp3mac_reduces_by_t_cubed_minus_two() { + assert_eq!(fp3mac([0, 1, 0, 0, 0, 1], [0, 0, 0]), [2, 0, 0]); + assert_eq!(fp3mac([0, 0, 1, 0, 0, 1], [5, 0, 0]), [5, 2, 0]); +} + +#[test] +fn test_fp3mac_syscall_rejects_non_canonical_input() { + for k in 0..9 { + let mut words = [1u64; 9]; + words[k] = P; + let mut memory = Memory::default(); + store(&mut memory, STATE_ADDR, &words[..6]); + store(&mut memory, CELL_ADDR, &words[6..]); + let err = run(&mut memory, STATE_ADDR, CELL_ADDR).unwrap_err(); + assert!( + matches!(err, ExecutionError::Fp3MacNonCanonicalInput(v) if v == P), + "word {k}" + ); + } +} + +#[test] +fn test_fp3mac_syscall_rejects_unaligned_addresses() { + let mut memory = Memory::default(); + assert!(matches!( + run(&mut memory, 0x1001, CELL_ADDR).unwrap_err(), + ExecutionError::UnalignedFp3MacAddress(0x1001) + )); + assert!(matches!( + run(&mut memory, STATE_ADDR, 0x2004).unwrap_err(), + ExecutionError::UnalignedFp3MacAddress(0x2004) + )); +} + +#[test] +fn test_fp3mac_syscall_rejects_overflowing_ranges() { + let mut memory = Memory::default(); + let addr = u64::MAX - 39; + assert!(matches!( + run(&mut memory, addr, CELL_ADDR).unwrap_err(), + ExecutionError::Fp3MacAddressOverflow(a) if a == addr + )); + let cell = u64::MAX - 15; + assert!(matches!( + run(&mut memory, STATE_ADDR, cell).unwrap_err(), + ExecutionError::Fp3MacAddressOverflow(a) if a == cell + )); +} + +#[test] +fn test_fp3mac_syscall_rejects_overlapping_operands() { + let mut memory = Memory::default(); + assert!(matches!( + run(&mut memory, STATE_ADDR, STATE_ADDR + 40).unwrap_err(), + ExecutionError::Fp3MacOverlap(..) + )); + assert!(matches!( + run(&mut memory, STATE_ADDR + 16, STATE_ADDR).unwrap_err(), + ExecutionError::Fp3MacOverlap(..) + )); + run(&mut memory, STATE_ADDR, STATE_ADDR + 48).unwrap(); +} diff --git a/executor/src/tests/mod.rs b/executor/src/tests/mod.rs index 662a116d5..3ac97e4f7 100644 --- a/executor/src/tests/mod.rs +++ b/executor/src/tests/mod.rs @@ -1,6 +1,8 @@ pub mod blake3_tests; pub mod ecsm_tests; pub mod flamegraph_tests; +pub mod fp3mac_tests; pub mod hint_tests; pub mod keccak_tests; pub mod memory_tests; +pub mod p1w16_tests; diff --git a/executor/src/tests/p1w16_tests.rs b/executor/src/tests/p1w16_tests.rs new file mode 100644 index 000000000..6f13f603a --- /dev/null +++ b/executor/src/tests/p1w16_tests.rs @@ -0,0 +1,81 @@ +//! Tests for the width-16 Poseidon1 permutation syscall. + +use crate::vm::instruction::decoding::Instruction; +use crate::vm::instruction::execution::{ExecutionError, P1W16_SYSCALL_NUMBER}; +use crate::vm::memory::Memory; +use crate::vm::registers::Registers; + +const STATE_ADDR: u64 = 0x1000; +const P: u64 = math::field::goldilocks::GOLDILOCKS_PRIME; + +fn run_p1w16(memory: &mut Memory, addr: u64) -> Result<(), ExecutionError> { + let mut pc = 0; + let mut registers = Registers::default(); + registers.write(17, P1W16_SYSCALL_NUMBER).unwrap(); + registers.write(10, addr).unwrap(); + Instruction::EcallEbreak + .run(&mut pc, &mut registers, memory) + .map(|_| ()) +} + +fn store_state(memory: &mut Memory, state: &[u64; 16]) { + for (i, &w) in state.iter().enumerate() { + memory + .store_doubleword(STATE_ADDR + 8 * i as u64, w) + .unwrap(); + } +} + +fn load_state(memory: &Memory) -> [u64; 16] { + core::array::from_fn(|i| memory.load_doubleword(STATE_ADDR + 8 * i as u64).unwrap()) +} + +#[test] +fn test_p1w16_syscall_permutes_in_place() { + let input: [u64; 16] = core::array::from_fn(|i| (i as u64 + 1) * 0x0123_4567_89AB_CDEF % P); + let mut memory = Memory::default(); + store_state(&mut memory, &input); + run_p1w16(&mut memory, STATE_ADDR).unwrap(); + + use crypto::hash::poseidon1_w16::{Fp, permute}; + let expected = permute(input.map(Fp::from)).map(|v| v.canonical()); + assert_eq!(load_state(&memory), expected); + assert_ne!(expected, input); +} + +#[test] +fn test_p1w16_syscall_accepts_the_largest_canonical_word() { + let mut input = [0u64; 16]; + input[15] = P - 1; + let mut memory = Memory::default(); + store_state(&mut memory, &input); + run_p1w16(&mut memory, STATE_ADDR).unwrap(); +} + +#[test] +fn test_p1w16_syscall_rejects_non_canonical_input() { + let mut input = [0u64; 16]; + input[15] = P; + let mut memory = Memory::default(); + store_state(&mut memory, &input); + let err = run_p1w16(&mut memory, STATE_ADDR).unwrap_err(); + assert!(matches!(err, ExecutionError::P1w16NonCanonicalInput(v) if v == P)); +} + +#[test] +fn test_p1w16_syscall_rejects_unaligned_state_addr() { + let mut memory = Memory::default(); + let err = run_p1w16(&mut memory, 0x1001).unwrap_err(); + assert!(matches!( + err, + ExecutionError::UnalignedP1w16StateAddress(0x1001) + )); +} + +#[test] +fn test_p1w16_syscall_rejects_overflowing_state_range() { + let mut memory = Memory::default(); + let addr = u64::MAX - 87; + let err = run_p1w16(&mut memory, addr).unwrap_err(); + assert!(matches!(err, ExecutionError::P1w16StateAddressOverflow(a) if a == addr)); +} diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index aa0c5e9bf..b7147c664 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -17,6 +17,10 @@ pub enum SyscallNumbers { // Placeholder discriminant. The actual syscall value is // BLAKE3_ABSORB_SYSCALL_NUMBER. Blake3Absorb = 4, + // Placeholder discriminant. The actual syscall value is P1W16_SYSCALL_NUMBER. + P1w16Permute = 5, + // Placeholder discriminant. The actual syscall value is FP3MAC_SYSCALL_NUMBER. + Fp3Mac = 7, Commit = 64, Halt = 93, // Placeholder discriminant. The actual syscall value is ECSM_SYSCALL_NUMBER. @@ -32,6 +36,27 @@ pub enum SyscallNumbers { pub const KECCAK_SYSCALL_NUMBER: u64 = u64::MAX - 1; const KECCAK_STATE_BYTES: u64 = 25 * 8; +/// Syscall number for the width-16 Poseidon1 permutation accelerator +/// (u64::MAX - 5 = 0xFFFF_FFFF_FFFF_FFFA). +/// +/// ABI: `x10` points to an 8-byte-aligned `[u64; 16]` Goldilocks state, permuted +/// in place. Every input word must be canonical (`< p`): the chip range-checks +/// the input and output encodings, so a non-canonical word would make the trace +/// unprovable and is rejected here up front. +pub const P1W16_SYSCALL_NUMBER: u64 = u64::MAX - 5; +const P1W16_STATE_BYTES: u64 = 16 * 8; + +/// Syscall number for the cubic-extension multiply-add accelerator +/// (u64::MAX - 7 = 0xFFFF_FFFF_FFFF_FFF8). +/// +/// ABI: `x10` points to an 8-byte-aligned `[acc0, acc1, acc2, al0, al1, al2]` +/// and `x11` to an 8-byte-aligned `[x0, x1, x2]`, Goldilocks words. In +/// `Fp3 = Fp[t]/(t³ − 2)` it computes `acc ← acc·al + x` and writes the three +/// `acc` words back in place. Every word read must be canonical (`< p`). +pub const FP3MAC_SYSCALL_NUMBER: u64 = u64::MAX - 7; +const FP3MAC_STATE_BYTES: u64 = 6 * 8; +const FP3MAC_CELL_BYTES: u64 = 3 * 8; + /// Syscall number for the BLAKE3 6-round compression accelerator /// (u64::MAX - 2 = 0xFFFF_FFFF_FFFF_FFFD). /// @@ -190,6 +215,31 @@ const _: () = { /// largest per-access offset is added: the 32-byte operands reach offset +31 (last byte). const LOW_LIMB: u64 = 1 << 32; +/// The Goldilocks prime, the bound every Poseidon1 state word must sit below. +const GOLDILOCKS_P: u64 = math::field::goldilocks::GOLDILOCKS_PRIME; + +/// Width-16 Poseidon1 over canonical Goldilocks words, the host side of the +/// accelerator: the permutation the LFM's `Hash16` rows use. +pub fn p1w16_permute(state: [u64; 16]) -> [u64; 16] { + use crypto::hash::poseidon1_w16::{Fp, permute}; + permute(state.map(Fp::from)).map(|v| v.canonical()) +} + +/// `acc·al + x` in `Fp[t]/(t³ − 2)` over canonical Goldilocks words, the host +/// side of the FP3MAC accelerator: the prover's cubic extension. +pub fn fp3mac(state: [u64; 6], x: [u64; 3]) -> [u64; 3] { + use math::field::element::FieldElement; + use math::field::extensions_goldilocks::Degree3GoldilocksExtensionField; + use math::field::goldilocks::GoldilocksField; + type Fp = FieldElement; + type Fp3 = FieldElement; + let ext = |w: [u64; 3]| Fp3::new(w.map(Fp::from)); + let acc = ext([state[0], state[1], state[2]]); + let al = ext([state[3], state[4], state[5]]); + let out = acc * al + ext(x); + (*out.value()).map(|v| v.canonical()) +} + impl TryFrom for SyscallNumbers { type Error = (); fn try_from(value: u64) -> Result { @@ -201,6 +251,8 @@ impl TryFrom for SyscallNumbers { v if v == KECCAK_SYSCALL_NUMBER => Ok(SyscallNumbers::KeccakPermute), v if v == BLAKE3_SYSCALL_NUMBER => Ok(SyscallNumbers::Blake3Compress), v if v == BLAKE3_ABSORB_SYSCALL_NUMBER => Ok(SyscallNumbers::Blake3Absorb), + v if v == P1W16_SYSCALL_NUMBER => Ok(SyscallNumbers::P1w16Permute), + v if v == FP3MAC_SYSCALL_NUMBER => Ok(SyscallNumbers::Fp3Mac), v if v == ECSM_SYSCALL_NUMBER => Ok(SyscallNumbers::Ecsm), v if v == HINT_SYSCALL_NUMBER => Ok(SyscallNumbers::Hint), _ => Err(()), @@ -214,6 +266,8 @@ pub enum Accelerator { Keccak, Blake3, Ecsm, + P1w16, + Fp3Mac, } impl SyscallNumbers { @@ -229,6 +283,8 @@ impl SyscallNumbers { // calls" that no longer track BLAKE3 rows. SyscallNumbers::Blake3Absorb => Some(Accelerator::Blake3), SyscallNumbers::Ecsm => Some(Accelerator::Ecsm), + SyscallNumbers::P1w16Permute => Some(Accelerator::P1w16), + SyscallNumbers::Fp3Mac => Some(Accelerator::Fp3Mac), SyscallNumbers::Print | SyscallNumbers::Panic | SyscallNumbers::Commit @@ -637,6 +693,62 @@ impl Instruction { } src2_val = state_addr; } + SyscallNumbers::P1w16Permute => { + let state_addr = registers.read(10)?; + if !state_addr.is_multiple_of(8) { + return Err(ExecutionError::UnalignedP1w16StateAddress(state_addr)); + } + state_addr + .checked_add(P1W16_STATE_BYTES - 1) + .ok_or(ExecutionError::P1w16StateAddressOverflow(state_addr))?; + + let mut state = [0u64; 16]; + for (i, lane) in state.iter_mut().enumerate() { + *lane = memory.load_doubleword(state_addr + (i as u64) * 8)?; + if *lane >= GOLDILOCKS_P { + return Err(ExecutionError::P1w16NonCanonicalInput(*lane)); + } + } + for (i, lane) in p1w16_permute(state).iter().enumerate() { + memory.store_doubleword(state_addr + (i as u64) * 8, *lane)?; + } + src2_val = state_addr; + } + SyscallNumbers::Fp3Mac => { + let state_addr = registers.read(10)?; + let cell_addr = registers.read(11)?; + for (addr, bytes) in [ + (state_addr, FP3MAC_STATE_BYTES), + (cell_addr, FP3MAC_CELL_BYTES), + ] { + if !addr.is_multiple_of(8) { + return Err(ExecutionError::UnalignedFp3MacAddress(addr)); + } + addr.checked_add(bytes - 1) + .ok_or(ExecutionError::Fp3MacAddressOverflow(addr))?; + } + // The chip accesses all nine words at one timestamp. + if state_addr < cell_addr + FP3MAC_CELL_BYTES + && cell_addr < state_addr + FP3MAC_STATE_BYTES + { + return Err(ExecutionError::Fp3MacOverlap(state_addr, cell_addr)); + } + let mut state = [0u64; 6]; + for (i, w) in state.iter_mut().enumerate() { + *w = memory.load_doubleword(state_addr + (i as u64) * 8)?; + } + let mut x = [0u64; 3]; + for (i, w) in x.iter_mut().enumerate() { + *w = memory.load_doubleword(cell_addr + (i as u64) * 8)?; + } + if let Some(&w) = state.iter().chain(&x).find(|&&w| w >= GOLDILOCKS_P) { + return Err(ExecutionError::Fp3MacNonCanonicalInput(w)); + } + for (i, w) in fp3mac(state, x).iter().enumerate() { + memory.store_doubleword(state_addr + (i as u64) * 8, *w)?; + } + src2_val = state_addr; + } SyscallNumbers::Blake3Compress => { // BLAKE3 6-round compression on a 176-byte region at the // address in x10 (layout: see BLAKE3_SYSCALL_NUMBER docs). @@ -1017,6 +1129,20 @@ pub enum ExecutionError { UnalignedKeccakStateAddress(u64), #[error("Keccak state address range overflows: {0:#018x}")] KeccakStateAddressOverflow(u64), + #[error("Unaligned Poseidon1 state address: {0:#018x}")] + UnalignedP1w16StateAddress(u64), + #[error("Poseidon1 state address range overflows: {0:#018x}")] + P1w16StateAddressOverflow(u64), + #[error("Poseidon1 input word is not a canonical Goldilocks element: {0:#018x}")] + P1w16NonCanonicalInput(u64), + #[error("Unaligned FP3MAC operand address: {0:#018x}")] + UnalignedFp3MacAddress(u64), + #[error("FP3MAC operand address range overflows: {0:#018x}")] + Fp3MacAddressOverflow(u64), + #[error("FP3MAC input word is not a canonical Goldilocks element: {0:#018x}")] + Fp3MacNonCanonicalInput(u64), + #[error("FP3MAC state {0:#018x} and cell {1:#018x} overlap")] + Fp3MacOverlap(u64, u64), #[error("Unaligned BLAKE3 state address: {0:#018x}")] UnalignedBlake3StateAddress(u64), #[error("BLAKE3 state address range overflows: {0:#018x}")] diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 9579b19de..a86a9e4a7 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -38,6 +38,16 @@ const BLAKE3_SYSCALL_NUMBER: usize = usize::MAX - 2; #[cfg(target_arch = "riscv64")] const BLAKE3_ABSORB_SYSCALL_NUMBER: usize = usize::MAX - 3; +/// Syscall number for the width-16 Poseidon1 permutation (u64::MAX - 5). +/// Must match `executor::...::execution::P1W16_SYSCALL_NUMBER`. +#[cfg(target_arch = "riscv64")] +const P1W16_SYSCALL_NUMBER: usize = usize::MAX - 5; + +/// Syscall number for the cubic-extension multiply-add (u64::MAX - 7). +/// Must match `executor::...::execution::FP3MAC_SYSCALL_NUMBER`. +#[cfg(target_arch = "riscv64")] +const FP3MAC_SYSCALL_NUMBER: usize = usize::MAX - 7; + /// Syscall number for the ECSM secp256k1 scalar-multiply accelerator (-11 as usize). #[cfg(target_arch = "riscv64")] const ECSM_SYSCALL_NUMBER: usize = usize::MAX - 10; @@ -184,6 +194,47 @@ pub fn keccak_permute(_state: &mut [u64; 25]) { unimplemented!("syscalls are only implemented for riscv64 targets"); } +#[cfg(target_arch = "riscv64")] +/// Apply the width-16 Poseidon1 permutation to a Goldilocks state in place. +/// Every word must be a canonical Goldilocks element (`< p`), or the ecall +/// faults. +pub fn p1w16_permute(state: &mut [u64; 16]) { + unsafe { + asm!( + "ecall", + in("a0") state.as_mut_ptr(), + in("a7") P1W16_SYSCALL_NUMBER, + ) + } +} + +#[cfg(not(target_arch = "riscv64"))] +/// Apply the width-16 Poseidon1 permutation to a Goldilocks state in place. +pub fn p1w16_permute(_state: &mut [u64; 16]) { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + +#[cfg(target_arch = "riscv64")] +/// `acc ← acc·al + x` in `Fp[t]/(t³ − 2)`, with `state = [acc0, acc1, acc2, +/// al0, al1, al2]` updated in place. Every word must be a canonical +/// Goldilocks element (`< p`), or the ecall faults. +pub fn fp3mac(state: &mut [u64; 6], x: &[u64; 3]) { + unsafe { + asm!( + "ecall", + in("a0") state.as_mut_ptr(), + in("a1") x.as_ptr(), + in("a7") FP3MAC_SYSCALL_NUMBER, + ) + } +} + +#[cfg(not(target_arch = "riscv64"))] +/// `acc ← acc·al + x` in `Fp[t]/(t³ − 2)`, with `state` updated in place. +pub fn fp3mac(_state: &mut [u64; 6], _x: &[u64; 3]) { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + #[cfg(target_arch = "riscv64")] /// BLAKE3 **6-round** compression via the accelerator (internal variant — NOT /// standard 7-round BLAKE3; see `thoughts/blake3/blake3-chip/DESIGN.md`). From 3177779a814e3804c6995bc19ac48386cf1c34aa Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 21 Oct 2026 09:54:14 -0300 Subject: [PATCH 03/11] Prove P1W16 and FP3MAC as VM tables Give the two new ecalls their own tables so the block WHIR prover proves v_bin with them: - P1W16: one row per permutation, 663 columns, the Compact rounds of lfm/p1w16_chip (its Affine/mds_affine helpers become pub(crate)). - FP3MAC: one row per Fp3 multiply-accumulate, 163 columns. CPU sends each ecall on its own bus; the trace builder, windowed and finish paths and the oracle collect the calls. TableCounts, VmAirs and the statement grow by two counts (NUM_TABLE_KINDS 21 -> 23, statement and continuation-epoch tags V5 -> V7), so the LFM statement replay and its registry are regenerated. NUM_PRODUCTION_AIRS goes to 31 and the constraint census records both tables' emitter counts. Block tests prove and verify each guest and refuse a proof claiming the table is absent. --- prover/src/lfm/algebraic_transcript.rs | 4 +- prover/src/lfm/constraint_tests.rs | 2 + prover/src/lfm/machine_tests.rs | 8 +- prover/src/lfm/p1w16_chip.rs | 16 +- prover/src/lfm/programs.rs | 5 +- prover/src/lfm/registry.rs | 24 +- prover/src/lfm/statement_replay.rs | 2 +- prover/src/lfm/whir_block_tree.rs | 2 + prover/src/lfm/whir_p1_census_tests.rs | 2 + prover/src/lfm/whir_statement_tests.rs | 2 + prover/src/lib.rs | 49 +- prover/src/statement.rs | 11 +- prover/src/tables/cpu.rs | 24 + prover/src/tables/fp3mac.rs | 528 +++++++++++++++++ prover/src/tables/mod.rs | 2 + prover/src/tables/p1w16.rs | 551 ++++++++++++++++++ prover/src/tables/trace_builder.rs | 280 +++++++++ prover/src/tables/trace_builder/finish.rs | 69 ++- .../src/tables/trace_builder/finish_oracle.rs | 30 + prover/src/tables/trace_builder/windowed.rs | 24 +- prover/src/test_utils.rs | 34 +- prover/src/tests/block_whir_tests.rs | 68 +++ prover/src/tests/multilinear_bench_tests.rs | 2 + prover/src/tests/prove_elfs_tests.rs | 140 +++++ prover/src/tests/skip_empty_tables_tests.rs | 7 + prover/src/tests/statement_alignment_tests.rs | 2 + prover/src/tests/statement_tests.rs | 6 + 27 files changed, 1858 insertions(+), 36 deletions(-) create mode 100644 prover/src/tables/fp3mac.rs create mode 100644 prover/src/tables/p1w16.rs diff --git a/prover/src/lfm/algebraic_transcript.rs b/prover/src/lfm/algebraic_transcript.rs index e73a503dc..ab90cefce 100644 --- a/prover/src/lfm/algebraic_transcript.rs +++ b/prover/src/lfm/algebraic_transcript.rs @@ -705,13 +705,15 @@ mod tests { hint: 1, commit: 1, blake3: 1, + p1w16: 1, + fp3mac: 1, }; // ⚠ Written out rather than taken from `table_count_values`, on purpose: // the point of this test is that the MACHINE side reproduces the host // encoding, so the machine's array must be an independent statement of // it. Sharing the helper would compare the host with itself. let count_array: [u64; NUM_TABLE_COUNTS] = [ - 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 1, 1, 1, 1, 1, 1, 1, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 1, 1, 1, 1, 1, 1, 1, 1, 1, ]; const PAGES: usize = 6; const FPLD: u8 = 3; diff --git a/prover/src/lfm/constraint_tests.rs b/prover/src/lfm/constraint_tests.rs index bda7e7414..c1b9468eb 100644 --- a/prover/src/lfm/constraint_tests.rs +++ b/prover/src/lfm/constraint_tests.rs @@ -368,6 +368,8 @@ const DESIGN_INSTR: &[(&str, usize)] = &[ ("L2G_GLOBAL", 27), ("L2G_MEMORY", 65), ("GLOBAL_MEMORY", 25), + ("P1W16", 10_177), // not in §8.1: the emitter's own count. + ("FP3MAC", 1_658), // not in §8.1: the emitter's own count. ]; /// ★ What the emitter actually costs per AIR, against the design's table. diff --git a/prover/src/lfm/machine_tests.rs b/prover/src/lfm/machine_tests.rs index f7cde9092..f5a47e10f 100644 --- a/prover/src/lfm/machine_tests.rs +++ b/prover/src/lfm/machine_tests.rs @@ -2261,6 +2261,8 @@ fn host_statement_challenges(f: &StatementFixture) -> (ExtFE, ExtFE) { hint: c[18], commit: c[19], blake3: c[20], + p1w16: 0, + fp3mac: 0, }; let ranges: Vec = shape .page_ranges @@ -2342,9 +2344,11 @@ fn epoch_statement_cursor_is_the_output_len_alone() { // fourteen-count one: six accelerator counts (+48) and the trailing // `is_final` byte (+1). Both terms are named in the doc above, because 264 // is the sum of a change that moves NO shift and one that moves it by one. - assert_eq!(shape.byte_len(), 264 + STMT_PUBLIC_OUTPUT_LEN + 16 * r); + // 272 since `TableCounts::p1w16` appended one more count (+8, no shift), + // 280 since `TableCounts::fp3mac` did the same. + assert_eq!(shape.byte_len(), 280 + STMT_PUBLIC_OUTPUT_LEN + 16 * r); for l in 0..8usize { - let total = 264 + l + 16 * r; + let total = 280 + l + 16 * r; assert_eq!( total % keccak_host::BYTES_PER_HALF, l % keccak_host::BYTES_PER_HALF, diff --git a/prover/src/lfm/p1w16_chip.rs b/prover/src/lfm/p1w16_chip.rs index 9a704070e..c667f0e0d 100644 --- a/prover/src/lfm/p1w16_chip.rs +++ b/prover/src/lfm/p1w16_chip.rs @@ -279,33 +279,33 @@ pub fn fill_row(layout: Layout, mds: Mds, mode: Mode, input: [FE; STATE_FELTS]) /// An affine form over the row: `Σ col[c]·main(c) + m_coeff·m`, with `m` the /// mode sum. Dense over the columns; the chip is under a thousand wide. #[derive(Clone)] -struct Affine { - col: Vec, - m: FE, +pub(crate) struct Affine { + pub(crate) col: Vec, + pub(crate) m: FE, } impl Affine { - fn zero(width: usize) -> Self { + pub(crate) fn zero(width: usize) -> Self { Self { col: vec![FE::zero(); width], m: FE::zero(), } } - fn column(width: usize, c: usize) -> Self { + pub(crate) fn column(width: usize, c: usize) -> Self { let mut a = Self::zero(width); a.col[c] = FE::one(); a } - fn add_scaled(&mut self, other: &Affine, k: FE) { + pub(crate) fn add_scaled(&mut self, other: &Affine, k: FE) { for (x, y) in self.col.iter_mut().zip(&other.col) { *x += k * y; } self.m += k * other.m; } - fn expr>(&self, b: &B, m: &B::Expr) -> B::Expr { + pub(crate) fn expr>(&self, b: &B, m: &B::Expr) -> B::Expr { let mut acc = b.const_base(canon(&self.m)) * m.clone(); for (c, k) in self.col.iter().enumerate() { if *k != FE::zero() { @@ -321,7 +321,7 @@ fn canon(x: &FE) -> u64 { } /// The MDS over affine forms: `out_o = Σ_i M[o][i]·f_i`. -fn mds_affine(mds: Mds, f: &[Affine]) -> Vec { +pub(crate) fn mds_affine(mds: Mds, f: &[Affine]) -> Vec { let width = f[0].col.len(); (0..STATE_FELTS) .map(|o| { diff --git a/prover/src/lfm/programs.rs b/prover/src/lfm/programs.rs index c47941cd3..e90e71941 100644 --- a/prover/src/lfm/programs.rs +++ b/prover/src/lfm/programs.rs @@ -539,9 +539,10 @@ pub fn stmt_arena_halves() -> u32 { pub fn epoch_statement_shape() -> super::statement_replay::EpochStatementShape { super::statement_replay::EpochStatementShape { public_output_len: STMT_PUBLIC_OUTPUT_LEN, - // Twenty-one: fourteen split families, six accelerators, BLAKE3. + // Twenty-three: fourteen split families, six accelerators, BLAKE3, + // P1W16, FP3MAC. table_counts: [ - 3, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 3, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, ], num_private_input_pages: 2, fri_final_poly_log_degree: 7, diff --git a/prover/src/lfm/registry.rs b/prover/src/lfm/registry.rs index e61a100b1..ae7356260 100644 --- a/prover/src/lfm/registry.rs +++ b/prover/src/lfm/registry.rs @@ -808,7 +808,11 @@ pub fn resolve( // cargo run --bin compute_lfm_registry --release // and paste the output below. Drift tests recompute and compare on every PR. // -// ★ LAST REGENERATION, and what forced it: the main-sync merge `892c7d1bc` +// ★ LAST REGENERATION: `TableCounts::fp3mac` grew the statement by one u64 +// (`NUM_TABLE_COUNTS` 22 -> 23), as `TableCounts::p1w16` did before it +// (21 -> 22). Only StatementReplayV0 moved. +// +// The one before, and what forced it: the main-sync merge `892c7d1bc` // (main's `c2ac5d546`, #977) grew the absorbed continuation-epoch statement by // 49 bytes — six accelerator counts joined it when `FIXED_TABLE_COUNT` went // 11 -> 5 (`NUM_TABLE_COUNTS` 15 -> 21, +48) and `is_final` was appended as its @@ -1348,9 +1352,9 @@ pub static LFM_REGISTRY: &[LfmRegistryEntry] = &[ blowup_factor: 2, roots: [ [ - 0xc0, 0x80, 0x4f, 0xab, 0xb9, 0xa1, 0x41, 0xe8, 0xcf, 0xda, 0x76, 0xea, 0xca, 0x45, - 0x0d, 0xdc, 0x21, 0xf1, 0xb3, 0x32, 0xe0, 0x91, 0xc8, 0xce, 0xa5, 0x96, 0x96, 0xa3, - 0x30, 0x36, 0x09, 0x97, + 0xf7, 0xa3, 0x9a, 0xee, 0xe4, 0x01, 0x33, 0xe1, 0x8d, 0x40, 0x65, 0xc5, 0x3d, 0x71, + 0x0a, 0x9e, 0xa7, 0xf9, 0x6a, 0xbf, 0x55, 0x07, 0x44, 0xd5, 0x70, 0x77, 0xa1, 0x16, + 0x63, 0xc9, 0xc8, 0xa0, ], [ 0xfb, 0xca, 0xf1, 0x74, 0xa1, 0xad, 0xf6, 0x3d, 0x24, 0x92, 0x44, 0x80, 0xc8, 0x64, @@ -1383,9 +1387,9 @@ pub static LFM_REGISTRY: &[LfmRegistryEntry] = &[ 0xff, 0x31, 0xec, 0xb2, ], [ - 0x8f, 0xad, 0x35, 0x4f, 0xa7, 0x06, 0x90, 0xd9, 0x49, 0x80, 0x19, 0xcc, 0x81, 0xfd, - 0xaf, 0xdd, 0x5f, 0x32, 0x13, 0xc8, 0xfc, 0xf9, 0xae, 0xcc, 0x0f, 0xfa, 0x67, 0x16, - 0xd8, 0x6e, 0x06, 0x20, + 0x15, 0x1f, 0xd1, 0x8a, 0x9d, 0x07, 0x32, 0xb4, 0xfa, 0x33, 0x6a, 0x42, 0x2a, 0x7e, + 0xcc, 0x98, 0x87, 0x49, 0xe6, 0xaf, 0x6d, 0x5c, 0x08, 0x0d, 0x83, 0xf0, 0xcd, 0x46, + 0xd9, 0x4b, 0xda, 0x5e, ], [ 0x8a, 0xc9, 0x0a, 0xc6, 0x8d, 0x5c, 0x71, 0xf7, 0x0d, 0x60, 0x13, 0x14, 0x21, 0xcc, @@ -1432,9 +1436,9 @@ pub static LFM_REGISTRY: &[LfmRegistryEntry] = &[ bitwise: true, }, program_id: [ - 0x5f, 0x89, 0x18, 0xf9, 0x16, 0xd3, 0xc5, 0xb8, 0x38, 0xfe, 0x73, 0x6e, 0x5f, 0xc0, - 0x30, 0x62, 0xf6, 0x4a, 0x72, 0xa4, 0xd6, 0x12, 0x27, 0x98, 0x8b, 0x2c, 0x24, 0xa4, - 0xcf, 0x6e, 0x28, 0x21, + 0xfd, 0x62, 0x21, 0x27, 0x60, 0x47, 0x18, 0x19, 0x81, 0x6f, 0xdc, 0xc7, 0x9c, 0xa7, + 0x4f, 0x37, 0x64, 0xc1, 0x71, 0x68, 0xb9, 0x99, 0xc1, 0x39, 0x41, 0x52, 0x6b, 0x1c, + 0xd5, 0xd5, 0x82, 0xef, ], }, ]; diff --git a/prover/src/lfm/statement_replay.rs b/prover/src/lfm/statement_replay.rs index 32b38f70a..6cbcde357 100644 --- a/prover/src/lfm/statement_replay.rs +++ b/prover/src/lfm/statement_replay.rs @@ -60,7 +60,7 @@ use super::transcript_replay::TranscriptReplay; /// challenge equality in `algebraic_transcript`'s replay test, plus the /// hand-written `[u64; NUM_TABLE_COUNTS]` literal it builds — which fails to /// compile when this number moves and the literal does not. -pub const NUM_TABLE_COUNTS: usize = 21; +pub const NUM_TABLE_COUNTS: usize = 23; /// The shape-static half of the statement — emitted as program constants. #[derive(Debug, Clone)] diff --git a/prover/src/lfm/whir_block_tree.rs b/prover/src/lfm/whir_block_tree.rs index a8239128b..f7623d529 100644 --- a/prover/src/lfm/whir_block_tree.rs +++ b/prover/src/lfm/whir_block_tree.rs @@ -2337,6 +2337,8 @@ mod tests { hint: 0, commit: 1, blake3: 0, + p1w16: 0, + fp3mac: 0, }; let statement = block_whir::OwnedBlockStatement { table_num_vars: vec![21, 16, 6], diff --git a/prover/src/lfm/whir_p1_census_tests.rs b/prover/src/lfm/whir_p1_census_tests.rs index efeb25174..f7551fa66 100644 --- a/prover/src/lfm/whir_p1_census_tests.rs +++ b/prover/src/lfm/whir_p1_census_tests.rs @@ -84,6 +84,8 @@ pub(super) fn median_statement( hint: 0, commit: 0, blake3: 0, + p1w16: 0, + fp3mac: 0, }; let mut rows: Vec = vec![32; crate::FIXED_TABLE_COUNT]; for &(kind, runs) in MEDIAN_MIX { diff --git a/prover/src/lfm/whir_statement_tests.rs b/prover/src/lfm/whir_statement_tests.rs index 3738bdc3e..1e9c1971d 100644 --- a/prover/src/lfm/whir_statement_tests.rs +++ b/prover/src/lfm/whir_statement_tests.rs @@ -71,6 +71,8 @@ fn counts() -> TableCounts { hint: 1, commit: 1, blake3: 1, + p1w16: 0, + fp3mac: 0, } } diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 79f61f183..7e31164de 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -81,10 +81,11 @@ use crate::tables::types::BusId; use crate::test_utils::{ E, F, VmAir, create_bitwise_air, create_blake3_air, create_branch_air, create_bytewise_air, create_commit_air, create_cpu_air, create_cpu32_air, create_decode_air, create_dvrm_air, - create_ecdas_air, create_ecsm_air, create_eq_air, create_halt_air, create_hint_air, - create_keccak_air, create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, - create_memw_air, create_memw_aligned_air, create_memw_register_air, create_mul_air, - create_page_air, create_register_air, create_shift_air, create_store_air, + create_ecdas_air, create_ecsm_air, create_eq_air, create_fp3mac_air, create_halt_air, + create_hint_air, create_keccak_air, create_keccak_rc_air, create_keccak_rnd_air, + create_load_air, create_lt_air, create_memw_air, create_memw_aligned_air, + create_memw_register_air, create_mul_air, create_p1w16_air, create_page_air, + create_register_air, create_shift_air, create_store_air, }; // Re-exported for downstream hosts and verifier guests (e.g. the in-VM @@ -206,6 +207,11 @@ pub struct TableCounts { /// an attacker nothing, but an unbounded count would let a malformed proof /// make the verifier build arbitrarily many AIRs before any other check. pub blake3: usize, + /// P1W16 tables (width-16 Poseidon1 permutations): 0 or 1, like the + /// accelerators above, by the same Ecall-bus argument — its receiver is + /// the only one carrying `P1W16_SYSCALL_NUMBER`. + pub p1w16: usize, + pub fp3mac: usize, } impl TableCounts { @@ -239,6 +245,8 @@ impl TableCounts { self.hint, self.commit, self.blake3, + self.p1w16, + self.fp3mac, ] .into_iter() .try_fold(0usize, usize::checked_add) @@ -307,6 +315,8 @@ impl TableCounts { ("hint", self.hint), ("commit", self.commit), ("blake3", self.blake3), + ("p1w16", self.p1w16), + ("fp3mac", self.fp3mac), ]; for (name, count) in at_most_one { if count > 1 { @@ -712,6 +722,8 @@ pub(crate) struct VmAirs { pub ecsms: Vec, pub ecdases: Vec, pub hints: Vec, + pub p1w16s: Vec, + pub fp3macs: Vec, pub register: VmAir, pub pages: Vec, pub memw_registers: Vec, @@ -782,6 +794,8 @@ impl VmAirs { self.ecsms.len(), self.ecdases.len(), self.hints.len(), + self.p1w16s.len(), + self.fp3macs.len(), self.pages.len(), self.memw_registers.len(), self.eqs.len(), @@ -805,6 +819,8 @@ impl VmAirs { traces.ecsms.len(), traces.ecdases.len(), traces.hints.len(), + traces.p1w16s.len(), + traces.fp3macs.len(), traces.pages.len(), traces.memw_registers.len(), traces.eqs.len(), @@ -841,6 +857,12 @@ impl VmAirs { for (air, trace) in self.hints.iter().zip(traces.hints.iter_mut()) { pairs.push((air.as_ref(), trace, &())); } + for (air, trace) in self.p1w16s.iter().zip(traces.p1w16s.iter_mut()) { + pairs.push((air.as_ref(), trace, &())); + } + for (air, trace) in self.fp3macs.iter().zip(traces.fp3macs.iter_mut()) { + pairs.push((air.as_ref(), trace, &())); + } // ★ BLAKE3 closes the accelerator group rather than keeping the slot it // held between KECCAK_RC and ECSM. That slot no longer exists: the six // accelerators stopped being always-on tables in the leading block and @@ -938,6 +960,12 @@ impl VmAirs { for air in &self.hints { refs.push(air.as_ref()); } + for air in &self.p1w16s { + refs.push(air.as_ref()); + } + for air in &self.fp3macs { + refs.push(air.as_ref()); + } // The same slot as `air_trace_pairs`: these two orders ARE the proof's // layout, and must move together. if self.include_blake3 { @@ -1162,6 +1190,17 @@ impl VmAirs { Box::new(create_hint_air(proof_options).with_name(&format!("HINT[{i}]"))) as VmAir }) .collect(); + let p1w16s: Vec<_> = (0..table_counts.p1w16) + .map(|i| { + Box::new(create_p1w16_air(proof_options).with_name(&format!("P1W16[{i}]"))) as VmAir + }) + .collect(); + let fp3macs: Vec<_> = (0..table_counts.fp3mac) + .map(|i| { + Box::new(create_fp3mac_air(proof_options).with_name(&format!("FP3MAC[{i}]"))) + as VmAir + }) + .collect(); let register: VmAir = if let Some(RegisterPreprocessed { commitment, init, @@ -1318,6 +1357,8 @@ impl VmAirs { ecsms, ecdases, hints, + p1w16s, + fp3macs, register, pages, memw_registers, diff --git a/prover/src/statement.rs b/prover/src/statement.rs index e703f057e..22b72f268 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -29,7 +29,7 @@ use crate::{RuntimePageRange, TableCounts}; /// called V4, so keeping either suffix would have let proofs from the two /// branches share a transcript prefix — the exact thing this tag prevents. The /// merged encoding appends all seven counts and is V5. -pub(crate) const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V5"; +pub(crate) const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V7"; /// Canonical full-ELF identity digest — exactly what [`absorb_statement`] binds /// into the transcript. The recursion attestation folds the same digest into @@ -267,7 +267,7 @@ pub(crate) fn absorb_statement_padding( /// exhaustive destructure in [`table_count_values`] fails when a field is added /// to [`TableCounts`], and that function's return type fails when the new field /// is pushed into the array without bumping this constant. -pub(crate) const NUM_TABLE_KINDS: usize = 21; +pub(crate) const NUM_TABLE_KINDS: usize = 23; /// Every per-table count, in declared order. /// @@ -297,6 +297,8 @@ pub(crate) fn table_count_values(table_counts: &TableCounts) -> [u64; NUM_TABLE_ hint, commit, blake3, + p1w16, + fp3mac, } = table_counts; [ cpu as u64, @@ -326,6 +328,9 @@ pub(crate) fn table_count_values(table_counts: &TableCounts) -> [u64; NUM_TABLE_ // binding it is what stops prover and verifier building different AIR // sets from the same bytes (see `TableCounts::blake3`). blake3 as u64, + // 0 or 1: the width-16 Poseidon1 table (`TableCounts::p1w16`). + p1w16 as u64, + fp3mac as u64, ] } @@ -366,7 +371,7 @@ pub(crate) const MULTILINEAR_BLOCK_TAG: &[u8] = b"LAMBDAVM_MULTILINEAR_BLOCK_STA /// different encodings of the same statement, and the merged one appends both /// additions. The GLOBAL tag stays at V2 — the cross-epoch statement binds no /// per-table counts, so nothing in its encoding moved. -pub(crate) const CONTINUATION_EPOCH_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_EPOCH_V5"; +pub(crate) const CONTINUATION_EPOCH_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_EPOCH_V7"; pub(crate) const CONTINUATION_GLOBAL_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_GLOBAL_V2"; /// Statement bound into the cross-epoch **global** proof's transcript before diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index 2158eb7db..a325a3490 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -314,6 +314,29 @@ impl<'a> CpuOp<'a> { } } + /// Whether this ECALL is a P1w16Permute syscall. + #[inline] + pub fn ecall_p1w16(&self) -> bool { + self.ecall_of(executor::vm::instruction::execution::P1W16_SYSCALL_NUMBER) + } + + /// For P1w16Permute ECALLs: state address from x10. + #[inline] + pub fn p1w16_state_addr(&self) -> u64 { + if self.ecall_p1w16() { + self.ecall_args[0] + } else { + 0 + } + } + + /// Whether this ECALL is an Fp3Mac syscall. Its operands (x10, x11) are + /// recovered from the register state in the trace builder, like HINT's. + #[inline] + pub fn ecall_fp3mac(&self) -> bool { + self.ecall_of(executor::vm::instruction::execution::FP3MAC_SYSCALL_NUMBER) + } + /// Whether this ECALL is a Blake3Compress syscall. #[inline] pub fn ecall_blake3(&self) -> bool { @@ -425,6 +448,7 @@ impl CpuOperation { [log.src2_val, log.dst_val] } else if log.src1_val == executor::vm::instruction::execution::KECCAK_SYSCALL_NUMBER || log.src1_val == executor::vm::instruction::execution::BLAKE3_SYSCALL_NUMBER + || log.src1_val == executor::vm::instruction::execution::P1W16_SYSCALL_NUMBER { [log.src2_val, 0] } else { diff --git a/prover/src/tables/fp3mac.rs b/prover/src/tables/fp3mac.rs new file mode 100644 index 000000000..bb003d2d2 --- /dev/null +++ b/prover/src/tables/fp3mac.rs @@ -0,0 +1,528 @@ +//! FP3MAC core chip — one cubic-extension multiply-add per `Fp3Mac` ecall. +//! +//! `x10` points to `[acc0, acc1, acc2, al0, al1, al2]`, `x11` to `[x0, x1, x2]`; +//! the row proves `acc ← acc·al + x` in `Fp3 = Fp[t]/(t³ − 2)` (the prover's +//! `Degree3GoldilocksExtensionField`) and writes `acc` back in place. Like +//! KECCAK (`keccak.rs`) and P1W16 (`p1w16.rs`): an `Ecall` receiver, `Memw` +//! reads of x10 and x11, and one combined read+write `Memw` per word — `acc` +//! with `old` = input and `value` = output, `al` and `x` with both the input. +//! +//! Each word's felt is `lo + 2^32·hi` over its eight range-checked bytes, held +//! canonical by one inverse column (`d·(1 − d·inv) = 0`, `(1 − d·inv)·lo = 0`, +//! `d = hi − (2^32 − 1)`), as in `p1w16.rs`. The product is three degree-2 +//! constraints with no constants, so an all-zero padding row satisfies them. +//! +//! ## Column layout +//! +//! | Group | Size | Description | +//! |-----------|------|------------------------------------------------| +//! | timestamp | 2 | DWordWL | +//! | addr | 8 | State address (x10) as DWordBL | +//! | caddr | 8 | Cell address (x11) as DWordBL | +//! | input | 72 | `acc`, `al`, `x` words as bytes [9][8] | +//! | output | 24 | `acc` out as bytes [3][8] | +//! | ptr | 36 | Per-word DWordHL addresses [9][4] | +//! | canon_inv | 12 | Canonicity inverses, input words then output | +//! | mu | 1 | Multiplicity flag | + +use executor::vm::instruction::execution::FP3MAC_SYSCALL_NUMBER; +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; +use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; +use stark::trace::TraceTable; + +use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable, alu_op}; +use crate::constraints::templates::{AddOperand, INV_SHIFT_32}; + +/// Words the row reads: `acc` (3), `al` (3), `x` (3). +pub const WORDS: usize = 9; +/// Words the state holds at x10 (`acc`, `al`); the rest are the cell at x11. +pub const STATE_WORDS: usize = 6; + +pub mod cols { + use super::WORDS; + + pub const TIMESTAMP_0: usize = 0; + pub const TIMESTAMP_1: usize = 1; + pub const ADDR: usize = 2; + pub const CADDR: usize = ADDR + 8; + pub const INPUT: usize = CADDR + 8; + pub const OUTPUT: usize = INPUT + 8 * WORDS; + pub const PTR: usize = OUTPUT + 8 * 3; + /// One inverse per felt: input words `0..9`, output words `9..12`. + pub const CANON_INV: usize = PTR + 4 * WORDS; + pub const MU: usize = CANON_INV + WORDS + 3; + pub const NUM_COLUMNS: usize = MU + 1; + + #[inline] + pub const fn addr(byte: usize) -> usize { + ADDR + byte + } + + #[inline] + pub const fn caddr(byte: usize) -> usize { + CADDR + byte + } + + #[inline] + pub const fn input(word: usize, byte: usize) -> usize { + INPUT + word * 8 + byte + } + + #[inline] + pub const fn output(word: usize, byte: usize) -> usize { + OUTPUT + word * 8 + byte + } + + #[inline] + pub const fn ptr(word: usize, hw: usize) -> usize { + PTR + word * 4 + hw + } + + #[inline] + pub const fn canon_inv(f: usize) -> usize { + CANON_INV + f + } + + /// First byte of felt `f` (`f < 9` input word, else output word). + #[inline] + pub const fn felt(f: usize) -> usize { + if f < WORDS { + input(f, 0) + } else { + output(f - WORDS, 0) + } + } +} + +#[derive(Debug, Clone)] +pub struct Fp3MacOperation { + pub timestamp: u64, + pub state_addr: u64, + pub cell_addr: u64, + /// `acc`, `al`, `x`. + pub input: [u64; WORDS], + /// `acc·al + x`. + pub output: [u64; 3], +} + +impl Fp3MacOperation { + /// The address of read word `w`. + pub fn word_addr(&self, w: usize) -> u64 { + let (base, i) = if w < STATE_WORDS { + (self.state_addr, w) + } else { + (self.cell_addr, w - STATE_WORDS) + }; + base.checked_add(i as u64 * 8) + .expect("fp3mac operand ranges must be validated by the executor") + } + + /// The value word `w` holds after the ecall. + pub fn word_out(&self, w: usize) -> u64 { + if w < 3 { self.output[w] } else { self.input[w] } + } +} + +pub fn generate_fp3mac_trace( + ops: &[Fp3MacOperation], +) -> TraceTable { + let n = ops.len(); + let num_rows = n.next_power_of_two().max(4); + let mut trace = TraceTable::new_main( + crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let table = &mut trace.main_table; + + for (row, op) in ops.iter().enumerate() { + table.set_dword_wl(row, cols::TIMESTAMP_0, op.timestamp); + table.set_dword_bl(row, cols::addr(0), op.state_addr); + table.set_dword_bl(row, cols::caddr(0), op.cell_addr); + for w in 0..WORDS { + table.set_dword_bl(row, cols::input(w, 0), op.input[w]); + table.set_dword_hl(row, cols::ptr(w, 0), op.word_addr(w)); + } + for (k, &v) in op.output.iter().enumerate() { + table.set_dword_bl(row, cols::output(k, 0), v); + } + for (f, w) in op.input.iter().chain(&op.output).enumerate() { + table.set_fe(row, cols::canon_inv(f), canon_inverse((w >> 32) as u32)); + } + table.set_fe(row, cols::MU, FE::one()); + } + + // Padding rows are all zero except the canonicity inverses (`hi = 0`). + let pad_inv = canon_inverse(0); + for row in n..num_rows { + for f in 0..WORDS + 3 { + table.set_fe(row, cols::canon_inv(f), pad_inv); + } + } + trace +} + +/// `1/(hi − (2^32 − 1))`, or zero when `hi` is all ones. +fn canon_inverse(hi: u32) -> FE { + let d = FE::from(hi as u64) - FE::from(u32::MAX as u64); + d.inv().unwrap_or(FE::zero()) +} + +fn packed(column: usize) -> BusValue { + BusValue::Packed { + start_column: column, + packing: Packing::Direct, + } +} + +/// `Σ col[start + i]·step^i` over `n` columns. +fn linear(start: usize, n: usize, step: i64) -> BusValue { + BusValue::linear( + (0..n) + .map(|i| LinearTerm::Column { + coefficient: step.pow(i as u32), + column: start + i, + }) + .collect(), + ) +} + +/// A `Memw` read of register `reg` holding the 8-byte value at `start`. +fn register_read(reg: u64, start: usize) -> Vec { + let lo = linear(start, 4, 256); + let hi = linear(start + 4, 4, 256); + let mut values = Vec::with_capacity(24); + values.push(lo.clone()); + values.push(hi.clone()); + values.extend((2..8).map(|_| BusValue::constant(0))); + values.push(BusValue::constant(1)); + values.push(BusValue::constant(2 * reg)); + values.push(BusValue::constant(0)); + values.push(lo); + values.push(hi); + values.extend((2..8).map(|_| BusValue::constant(0))); + values.push(packed(cols::TIMESTAMP_0)); + values.push(packed(cols::TIMESTAMP_1)); + values.extend([1, 0, 0].map(BusValue::constant)); + values +} + +pub fn bus_interactions() -> Vec { + let mu = || Multiplicity::Column(cols::MU); + let mut interactions = Vec::new(); + + // 1. Ecall receiver: (ts_lo, ts_hi, syscall_lo32, syscall_hi32). + interactions.push(BusInteraction::receiver( + BusId::Ecall, + mu(), + vec![ + packed(cols::TIMESTAMP_0), + packed(cols::TIMESTAMP_1), + BusValue::constant(FP3MAC_SYSCALL_NUMBER & 0xFFFF_FFFF), + BusValue::constant(FP3MAC_SYSCALL_NUMBER >> 32), + ], + )); + + // 2. Memw reads of x10 and x11. + for (reg, start) in [(10, cols::ADDR), (11, cols::CADDR)] { + interactions.push(BusInteraction::sender( + BusId::Memw, + mu(), + register_read(reg, start), + )); + } + + // 3. IsHalfword on every word pointer halfword. + for w in 0..WORDS { + for hw in 0..4 { + interactions.push(BusInteraction::sender( + BusId::IsHalfword, + mu(), + vec![packed(cols::ptr(w, hw))], + )); + } + } + + // 4. Alignment: addr[0] & 7 = 0 and caddr[0] & 7 = 0. + for start in [cols::ADDR, cols::CADDR] { + interactions.push(BusInteraction::sender( + BusId::ByteAlu, + mu(), + vec![ + BusValue::constant(alu_op::AND as u64), + packed(start), + BusValue::constant(7), + BusValue::constant(0), + ], + )); + } + + // 5. AreBytes on the addresses, inputs and outputs, in adjacent pairs. + let byte_ranges = [ + (cols::ADDR, 16), + (cols::INPUT, 8 * WORDS), + (cols::OUTPUT, 8 * 3), + ]; + for (start, len) in byte_ranges { + for i in (0..len).step_by(2) { + interactions.push(BusInteraction::sender( + BusId::AreBytes, + mu(), + vec![packed(start + i), packed(start + i + 1)], + )); + } + } + + // 6. One combined read+write Memw per word: `acc` old = in, value = out; + // `al` and `x` old = value = in. + for w in 0..WORDS { + let out = |b: usize| { + if w < 3 { + cols::output(w, b) + } else { + cols::input(w, b) + } + }; + let mut values = Vec::with_capacity(24); + values.extend((0..8).map(|b| packed(cols::input(w, b)))); + values.push(BusValue::constant(0)); + values.push(linear(cols::ptr(w, 0), 2, 65536)); + values.push(linear(cols::ptr(w, 2), 2, 65536)); + values.extend((0..8).map(|b| packed(out(b)))); + values.push(packed(cols::TIMESTAMP_0)); + values.push(packed(cols::TIMESTAMP_1)); + values.extend([0, 0, 1].map(BusValue::constant)); + interactions.push(BusInteraction::sender(BusId::Memw, mu(), values)); + } + + interactions +} + +/// The ADD pairs take `0..2·WORDS`; then the two no-overflow constraints. +const NO_OVERFLOW: usize = 2 * WORDS; +/// First canonicity constraint; felt `f` owns `CANONICITY + 2f` and `+ 1`. +pub const CANONICITY: usize = NO_OVERFLOW + 2; +/// The three product constraints. +pub const PRODUCT: usize = CANONICITY + 2 * (WORDS + 3); +pub const NUM_CONSTRAINTS: usize = PRODUCT + 3; + +#[derive(Clone, Copy)] +pub struct Fp3MacConstraints; + +impl ConstraintSet for Fp3MacConstraints { + fn max_degree(&self) -> usize { + 3 + } + + fn eval>(&self, b: &mut B) { + use crate::constraints::templates::emit_add_pair; + + // ptr[w] = base + 8·i, gated on μ: the state words off x10, the cell's + // off x11. + for w in 0..WORDS { + let (base, i) = if w < STATE_WORDS { + (cols::ADDR, w) + } else { + (cols::CADDR, w - STATE_WORDS) + }; + emit_add_pair( + b, + w * 2, + &[cols::MU], + &AddOperand::from_dword_bl(base), + &AddOperand::constant((i * 8) as i64), + &AddOperand::from_dword_hl(cols::ptr(w, 0)), + ); + } + + let word = |b: &mut B, start: usize, n: usize, step: u64| { + (0..n) + .map(|i| b.main(0, start + i) * b.const_base(step.pow(i as u32))) + .reduce(|x, y| x + y) + .expect("non-empty") + }; + // μ · carry_1 = 0 on each range's top word: no wrap. + let mu = b.main(0, cols::MU); + for (k, (base, top, offset)) in [ + (cols::ADDR, STATE_WORDS - 1, 8 * (STATE_WORDS - 1)), + (cols::CADDR, WORDS - 1, 8 * (WORDS - STATE_WORDS - 1)), + ] + .into_iter() + .enumerate() + { + let addr_lo = word(b, base, 4, 256); + let addr_hi = word(b, base + 4, 4, 256); + let ptr_lo = word(b, cols::ptr(top, 0), 2, 65536); + let ptr_hi = word(b, cols::ptr(top, 2), 2, 65536); + let inv_2_32 = b.const_base(INV_SHIFT_32); + let off = b.const_base(offset as u64); + let carry_0 = (addr_lo + off - ptr_lo) * inv_2_32.clone(); + let carry_1 = (addr_hi + carry_0 - ptr_hi) * inv_2_32; + b.emit_base(NO_OVERFLOW + k, mu.clone() * carry_1); + } + + // Canonicity of the twelve felts. + let mut felts = Vec::with_capacity(WORDS + 3); + for f in 0..WORDS + 3 { + let start = cols::felt(f); + let lo = word(b, start, 4, 256); + let hi = word(b, start + 4, 4, 256); + let all_ones = b.const_base(u32::MAX as u64); + let d = hi.clone() - all_ones; + let not_inv = b.one() - d.clone() * b.main(0, cols::canon_inv(f)); + b.emit_base(CANONICITY + 2 * f, d * not_inv.clone()); + b.emit_base(CANONICITY + 2 * f + 1, not_inv * lo.clone()); + let two_32 = b.const_base(1 << 32); + felts.push(lo + hi * two_32); + } + + // out = acc·al + x with t³ = 2. + let (a, c, x, out) = (&felts[0..3], &felts[3..6], &felts[6..9], &felts[9..12]); + let two = b.const_base(2); + let p = |i: usize, j: usize| a[i].clone() * c[j].clone(); + let prod = [ + p(0, 0) + (p(1, 2) + p(2, 1)) * two.clone(), + p(0, 1) + p(1, 0) + p(2, 2) * two.clone(), + p(0, 2) + p(1, 1) + p(2, 0), + ]; + for (k, pk) in prod.into_iter().enumerate() { + b.emit_base(PRODUCT + k, out[k].clone() - (pk + x[k].clone())); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use math::field::element::FieldElement; + use stark::constraints::builder::ProverEvalFolder; + use stark::frame::Frame; + use stark::table::TableView; + use stark::traits::TransitionEvaluationContext; + + const P: u64 = math::field::goldilocks::GOLDILOCKS_PRIME; + + fn eval_row(trace: &TraceTable, row: usize) -> Vec { + let main: Vec = (0..cols::NUM_COLUMNS) + .map(|c| *trace.main_table.get(row, c)) + .collect(); + let frame = Frame::::new(vec![TableView::new( + vec![main], + vec![vec![]], + )]); + let no_e: Vec> = vec![]; + let offset_e = FieldElement::::zero(); + let ctx = + TransitionEvaluationContext::new_prover(frame.as_row_frame(), &no_e, &no_e, &offset_e); + let mut base = vec![FE::zero(); NUM_CONSTRAINTS]; + let mut ext = vec![FieldElement::::zero(); NUM_CONSTRAINTS]; + let mut folder = ProverEvalFolder::new(&ctx, &mut base, &mut ext); + Fp3MacConstraints.eval(&mut folder); + base + } + + fn failing(trace: &TraceTable, row: usize) -> Vec { + eval_row(trace, row) + .iter() + .enumerate() + .filter(|(_, v)| **v != FE::zero()) + .map(|(i, _)| i) + .collect() + } + + /// Canonical words: edge values and a splitmix64 stream reduced mod p. + fn words(n: usize) -> Vec<[u64; WORDS]> { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = || { + seed = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = seed; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + (z ^ (z >> 31)) % P + }; + let mut v = vec![ + [0; WORDS], + [P - 1; WORDS], + core::array::from_fn(|i| [0, 1, P - 1, 0xFFFF_FFFF, 0xFFFF_FFFE_FFFF_FFFF][i % 5]), + ]; + v.extend((0..n).map(|_| core::array::from_fn(|_| next()))); + v + } + + fn honest(input: [u64; WORDS]) -> Fp3MacOperation { + let state: [u64; 6] = core::array::from_fn(|i| input[i]); + let x = [input[6], input[7], input[8]]; + Fp3MacOperation { + timestamp: 0x1234_5678_9ABC, + state_addr: 0x0000_0001_FFFF_FFC0, + cell_addr: 0x0000_0002_0000_0040, + input, + output: executor::vm::instruction::execution::fp3mac(state, x), + } + } + + #[test] + fn the_constraint_count_matches_what_eval_emits() { + assert_eq!(Fp3MacConstraints.meta().len(), NUM_CONSTRAINTS); + assert_eq!(cols::NUM_COLUMNS, 163); + } + + /// The executor's helper is the prover's `FEE` arithmetic. + #[test] + fn the_executor_agrees_with_the_extension_field() { + use crate::tables::types::FEE; + for w in words(16) { + let ext = |i: usize| FEE::new([FE::from(w[i]), FE::from(w[i + 1]), FE::from(w[i + 2])]); + let want = ext(0) * ext(3) + ext(6); + let got = executor::vm::instruction::execution::fp3mac( + core::array::from_fn(|i| w[i]), + [w[6], w[7], w[8]], + ); + assert_eq!(got.map(FE::from), *want.value()); + } + } + + #[test] + fn honest_rows_and_padding_satisfy_every_constraint() { + let ops: Vec = words(4).into_iter().map(honest).collect(); + let trace = generate_fp3mac_trace(&ops); + assert!(trace.num_rows() > ops.len(), "the trace has padding rows"); + for row in 0..trace.num_rows() { + assert_eq!(failing(&trace, row), Vec::::new(), "row {row}"); + } + } + + /// Any other output fails, and only the product. + #[test] + fn a_wrong_output_fails_the_product() { + let mut o = honest(words(4)[4]); + o.output[1] = (o.output[1] + 1) % P; + let trace = generate_fp3mac_trace(&[o]); + assert_eq!(failing(&trace, 0), vec![PRODUCT + 1]); + } + + /// An input word stored as `7 + p`: the product reads it as 7, so only its + /// canonicity objects. + #[test] + fn a_non_canonical_input_fails_only_its_canonicity() { + let mut input = words(3)[3]; + input[4] = 7; + let mut o = honest(input); + o.input[4] += P; + let trace = generate_fp3mac_trace(&[o]); + assert_eq!(failing(&trace, 0), vec![CANONICITY + 2 * 4 + 1]); + } + + /// An output word written as `v + p` for a `v < 2^32 − 1`: only its + /// canonicity objects. + #[test] + fn a_non_canonical_output_fails_only_its_canonicity() { + let mut input = [0u64; WORDS]; + input[6] = 3; + let mut o = honest(input); + assert_eq!(o.output, [3, 0, 0]); + o.output[0] += P; + let trace = generate_fp3mac_trace(&[o]); + assert_eq!(failing(&trace, 0), vec![CANONICITY + 2 * WORDS + 1]); + } +} diff --git a/prover/src/tables/mod.rs b/prover/src/tables/mod.rs index 197116d33..a4aa4e48c 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -35,6 +35,7 @@ pub mod dvrm; pub mod ecdas; pub mod ecsm; pub mod eq; +pub mod fp3mac; pub mod global_memory; pub mod halt; pub mod hint; @@ -48,6 +49,7 @@ pub mod memw; pub mod memw_aligned; pub mod memw_register; pub mod mul; +pub mod p1w16; pub mod page; pub mod register; pub mod shift; diff --git a/prover/src/tables/p1w16.rs b/prover/src/tables/p1w16.rs new file mode 100644 index 000000000..f3804da1d --- /dev/null +++ b/prover/src/tables/p1w16.rs @@ -0,0 +1,551 @@ +//! P1W16 core chip — one width-16 Poseidon1 permutation per `P1w16Permute` +//! ecall. +//! +//! Reads and writes the 16-felt state in place at `x10`, like KECCAK +//! (`keccak.rs`): an `Ecall` receiver, a `Memw` read of x10, and one combined +//! read+write `Memw` per lane with `old` = input bytes and `value` = output +//! bytes. +//! +//! Each lane's felt is the linear combination `lo + 2^32·hi` of its eight +//! range-checked bytes, so the 32 felts cost no columns. Two things make it +//! the felt the executor saw: +//! +//! - **canonicity**: with `d = hi − (2^32 − 1)` and one inverse column, +//! `d·(1 − d·inv) = 0` and `(1 − d·inv)·lo = 0` force `lo = 0` exactly when +//! `hi` is all ones, so the bytes encode a value below p; +//! - **rounds**: the LFM chip's compact layout (`lfm::p1w16_chip`, `x³ = a³` +//! and `x⁷ = (x³)²·a` per S-box, the state between rounds carried as affine +//! forms) with `μ` scaling the round constants, so an all-zero padding row +//! satisfies it. +//! +//! ## Column layout +//! +//! | Group | Size | Description | +//! |-----------|------|----------------------------------------| +//! | timestamp | 2 | DWordWL | +//! | addr | 8 | State address as DWordBL | +//! | input | 128 | Input lanes as bytes [16][8] | +//! | output | 128 | Output lanes as bytes [16][8] | +//! | state_ptr | 64 | Per-lane DWordHL addresses [16][4] | +//! | canon_inv | 32 | Canonicity inverses, input then output | +//! | rounds | 300 | `x³`, `x⁷` per S-box (8 full, 22 partial) | +//! | mu | 1 | Multiplicity flag | + +use crypto::hash::poseidon1_w16::{NUM_ROUNDS, STATE_FELTS, constants::ROUND_CONSTANTS}; +use executor::vm::instruction::execution::P1W16_SYSCALL_NUMBER; +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; +use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; +use stark::trace::TraceTable; + +use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable, alu_op}; +use crate::constraints::templates::{AddOperand, INV_SHIFT_32}; +use crate::lfm::p1w16_chip::{Affine, Mds, mds_affine, sboxed_lanes}; + +/// Felts in the permuted state. +pub const LANES: usize = STATE_FELTS; + +pub mod cols { + use super::{LANES, NUM_ROUNDS, sboxed_lanes}; + + pub const TIMESTAMP_0: usize = 0; + pub const TIMESTAMP_1: usize = 1; + pub const ADDR: usize = 2; + pub const INPUT: usize = ADDR + 8; + pub const OUTPUT: usize = INPUT + 8 * LANES; + pub const STATE_PTR: usize = OUTPUT + 8 * LANES; + /// One inverse per felt: input lanes `0..16`, output lanes `16..32`. + pub const CANON_INV: usize = STATE_PTR + 4 * LANES; + pub const ROUNDS: usize = CANON_INV + 2 * LANES; + pub const MU: usize = block(NUM_ROUNDS); + pub const NUM_COLUMNS: usize = MU + 1; + + /// First column of round `r`'s block: `x³` then `x⁷` per S-boxed lane. + pub const fn block(r: usize) -> usize { + let mut off = ROUNDS; + let mut i = 0; + while i < r { + off += 2 * sboxed_lanes(i); + i += 1; + } + off + } + + pub const fn x3(r: usize, lane: usize) -> usize { + block(r) + lane + } + + pub const fn x7(r: usize, lane: usize) -> usize { + block(r) + sboxed_lanes(r) + lane + } + + #[inline] + pub const fn addr(byte: usize) -> usize { + ADDR + byte + } + + #[inline] + pub const fn input(lane: usize, byte: usize) -> usize { + INPUT + lane * 8 + byte + } + + #[inline] + pub const fn output(lane: usize, byte: usize) -> usize { + OUTPUT + lane * 8 + byte + } + + #[inline] + pub const fn state_ptr(lane: usize, hw: usize) -> usize { + STATE_PTR + lane * 4 + hw + } + + /// Canonicity inverse of felt `f` (`f < 16` input lane, else output lane). + #[inline] + pub const fn canon_inv(f: usize) -> usize { + CANON_INV + f + } + + /// First byte of felt `f`. + #[inline] + pub const fn felt(f: usize) -> usize { + if f < LANES { + input(f, 0) + } else { + output(f - LANES, 0) + } + } +} + +#[derive(Debug, Clone)] +pub struct P1w16Operation { + pub timestamp: u64, + pub state_addr: u64, + pub input: [u64; LANES], + pub output: [u64; LANES], +} + +pub fn generate_p1w16_trace( + ops: &[P1w16Operation], +) -> TraceTable { + let n = ops.len(); + let num_rows = n.next_power_of_two().max(4); + let mut trace = TraceTable::new_main( + crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let table = &mut trace.main_table; + + for (row, op) in ops.iter().enumerate() { + table.set_dword_wl(row, cols::TIMESTAMP_0, op.timestamp); + table.set_dword_bl(row, cols::addr(0), op.state_addr); + for lane in 0..LANES { + table.set_dword_bl(row, cols::input(lane, 0), op.input[lane]); + table.set_dword_bl(row, cols::output(lane, 0), op.output[lane]); + let ptr = op + .state_addr + .checked_add(lane as u64 * 8) + .expect("p1w16 state address range must be validated by the executor"); + table.set_dword_hl(row, cols::state_ptr(lane, 0), ptr); + } + table.set_fe(row, cols::MU, FE::one()); + fill_rounds(table, row, &op.input, &op.output); + } + + // Padding rows are all zero except the canonicity inverses: `hi = 0` there, + // and `(1 − d·inv)·lo = 0` holds for any inverse, but `d·(1 − d·inv) = 0` + // needs the real one. + let pad_inv = canon_inverse(0); + for row in n..num_rows { + for f in 0..2 * LANES { + table.set_fe(row, cols::canon_inv(f), pad_inv); + } + } + + trace +} + +/// `1/(hi − (2^32 − 1))`, or zero when `hi` is all ones. +fn canon_inverse(hi: u32) -> FE { + let d = FE::from(hi as u64) - FE::from(u32::MAX as u64); + d.inv().unwrap_or(FE::zero()) +} + +/// The canonicity inverses and the S-box witness for one row. `output` only +/// feeds the inverses: the last constraints tie it to the rounds. +fn fill_rounds(table: &mut T, row: usize, input: &[u64; LANES], output: &[u64; LANES]) { + for (f, w) in input.iter().chain(output).enumerate() { + table.set_fe(row, cols::canon_inv(f), canon_inverse((w >> 32) as u32)); + } + let mut s: [FE; LANES] = input.map(FE::from); + for (r, rc) in ROUND_CONSTANTS.iter().enumerate() { + let a: [FE; LANES] = core::array::from_fn(|i| s[i] + FE::from(rc[i])); + let mut f = a; + for lane in 0..sboxed_lanes(r) { + let x3 = a[lane] * a[lane] * a[lane]; + let x7 = x3 * x3 * a[lane]; + table.set_fe(row, cols::x3(r, lane), x3); + table.set_fe(row, cols::x7(r, lane), x7); + f[lane] = x7; + } + s = crypto::hash::poseidon1_w16::mds(&f); + } +} + +fn packed(column: usize) -> BusValue { + BusValue::Packed { + start_column: column, + packing: Packing::Direct, + } +} + +/// `Σ col[start + i]·step^i` over `n` columns. +fn linear(start: usize, n: usize, step: i64) -> BusValue { + BusValue::linear( + (0..n) + .map(|i| LinearTerm::Column { + coefficient: step.pow(i as u32), + column: start + i, + }) + .collect(), + ) +} + +pub fn bus_interactions() -> Vec { + let mu = || Multiplicity::Column(cols::MU); + let mut interactions = Vec::new(); + + // 1. Ecall receiver: (ts_lo, ts_hi, syscall_lo32, syscall_hi32). + interactions.push(BusInteraction::receiver( + BusId::Ecall, + mu(), + vec![ + packed(cols::TIMESTAMP_0), + packed(cols::TIMESTAMP_1), + BusValue::constant(P1W16_SYSCALL_NUMBER & 0xFFFF_FFFF), + BusValue::constant(P1W16_SYSCALL_NUMBER >> 32), + ], + )); + + // 2. Memw read of x10 (base address 20), old = value = addr. + { + let addr_lo = linear(cols::addr(0), 4, 256); + let addr_hi = linear(cols::addr(4), 4, 256); + let mut values = Vec::with_capacity(24); + values.push(addr_lo.clone()); + values.push(addr_hi.clone()); + values.extend((2..8).map(|_| BusValue::constant(0))); + values.push(BusValue::constant(1)); + values.push(BusValue::constant(20)); + values.push(BusValue::constant(0)); + values.push(addr_lo); + values.push(addr_hi); + values.extend((2..8).map(|_| BusValue::constant(0))); + values.push(packed(cols::TIMESTAMP_0)); + values.push(packed(cols::TIMESTAMP_1)); + values.extend([1, 0, 0].map(BusValue::constant)); + interactions.push(BusInteraction::sender(BusId::Memw, mu(), values)); + } + + // 3. IsHalfword on every state_ptr halfword. + for lane in 0..LANES { + for hw in 0..4 { + interactions.push(BusInteraction::sender( + BusId::IsHalfword, + mu(), + vec![packed(cols::state_ptr(lane, hw))], + )); + } + } + + // 4. Alignment: addr[0] & 7 = 0. + interactions.push(BusInteraction::sender( + BusId::ByteAlu, + mu(), + vec![ + BusValue::constant(alu_op::AND as u64), + packed(cols::addr(0)), + BusValue::constant(7), + BusValue::constant(0), + ], + )); + + // 5. AreBytes on addr, input and output bytes, in adjacent pairs. + let byte_ranges = [ + (cols::ADDR, 8), + (cols::INPUT, 8 * LANES), + (cols::OUTPUT, 8 * LANES), + ]; + for (start, len) in byte_ranges { + for i in (0..len).step_by(2) { + interactions.push(BusInteraction::sender( + BusId::AreBytes, + mu(), + vec![packed(start + i), packed(start + i + 1)], + )); + } + } + + // 6. One combined read+write Memw per lane: old = input, value = output. + for lane in 0..LANES { + let mut values = Vec::with_capacity(24); + values.extend((0..8).map(|b| packed(cols::input(lane, b)))); + values.push(BusValue::constant(0)); + values.push(linear(cols::state_ptr(lane, 0), 2, 65536)); + values.push(linear(cols::state_ptr(lane, 2), 2, 65536)); + values.extend((0..8).map(|b| packed(cols::output(lane, b)))); + values.push(packed(cols::TIMESTAMP_0)); + values.push(packed(cols::TIMESTAMP_1)); + values.extend([0, 0, 1].map(BusValue::constant)); + interactions.push(BusInteraction::sender(BusId::Memw, mu(), values)); + } + + interactions +} + +/// Index of the top-lane no-overflow constraint; the ADD pairs take `0..`. +const NO_OVERFLOW: usize = 2 * LANES; +/// First canonicity constraint; felt `f` owns `CANONICITY + 2f` and `+ 1`. +pub const CANONICITY: usize = NO_OVERFLOW + 1; +/// First round constraint. +pub const ROUNDS: usize = CANONICITY + 4 * LANES; +/// Two per S-box, then one per output lane. +pub const NUM_CONSTRAINTS: usize = ROUNDS + 2 * (8 * LANES + 22) + LANES; + +#[derive(Clone, Copy)] +pub struct P1w16Constraints; + +impl ConstraintSet for P1w16Constraints { + fn max_degree(&self) -> usize { + 3 + } + + fn eval>(&self, b: &mut B) { + use crate::constraints::templates::emit_add_pair; + + // state_ptr[lane] = addr + 8·lane, gated on μ. + for lane in 0..LANES { + emit_add_pair( + b, + lane * 2, + &[cols::MU], + &AddOperand::from_dword_bl(cols::ADDR), + &AddOperand::constant((lane * 8) as i64), + &AddOperand::from_dword_hl(cols::state_ptr(lane, 0)), + ); + } + + // μ · carry_1 = 0: addr + 120 does not wrap. + let top = LANES - 1; + let word = |b: &mut B, start: usize, n: usize, step: u64| { + (0..n) + .map(|i| b.main(0, start + i) * b.const_base(step.pow(i as u32))) + .reduce(|x, y| x + y) + .expect("non-empty") + }; + let addr_lo = word(b, cols::addr(0), 4, 256); + let addr_hi = word(b, cols::addr(4), 4, 256); + let ptr_lo = word(b, cols::state_ptr(top, 0), 2, 65536); + let ptr_hi = word(b, cols::state_ptr(top, 2), 2, 65536); + let inv_2_32 = b.const_base(INV_SHIFT_32); + let offset = b.const_base((top * 8) as u64); + let carry_0 = (addr_lo + offset - ptr_lo) * inv_2_32.clone(); + let carry_1 = (addr_hi + carry_0 - ptr_hi) * inv_2_32; + let mu = b.main(0, cols::MU); + b.emit_base(NO_OVERFLOW, mu.clone() * carry_1); + + // Canonicity of the 32 felts. + for f in 0..2 * LANES { + let start = cols::felt(f); + let lo = word(b, start, 4, 256); + let hi = word(b, start + 4, 4, 256); + let all_ones = b.const_base(u32::MAX as u64); + let d = hi - all_ones; + let not_inv = b.one() - d.clone() * b.main(0, cols::canon_inv(f)); + b.emit_base(CANONICITY + 2 * f, d * not_inv.clone()); + b.emit_base(CANONICITY + 2 * f + 1, not_inv * lo); + } + + // The rounds, over affine forms in the columns and μ: a felt is its + // eight bytes at 256^i. + let width = cols::NUM_COLUMNS; + let felt = |start: usize| { + let mut a = Affine::zero(width); + for i in 0..8 { + a.col[start + i] = FE::from(1u64 << (8 * i)); + } + a + }; + let mut state: Vec = (0..LANES).map(|l| felt(cols::input(l, 0))).collect(); + let mut idx = ROUNDS; + for (r, rc_row) in ROUND_CONSTANTS.iter().enumerate() { + let a: Vec = state + .iter() + .zip(rc_row) + .map(|(s, rc)| { + let mut x = s.clone(); + x.m += FE::from(*rc); + x + }) + .collect(); + let mut f = a.clone(); + for lane in 0..sboxed_lanes(r) { + let a_e = a[lane].expr(b, &mu); + let x3 = b.main(0, cols::x3(r, lane)); + let x7 = b.main(0, cols::x7(r, lane)); + b.emit_base(idx, x3.clone() - a_e.clone() * a_e.clone() * a_e.clone()); + b.emit_base(idx + 1, x7 - x3.clone() * x3 * a_e); + f[lane] = Affine::column(width, cols::x7(r, lane)); + idx += 2; + } + state = mds_affine(Mds::Circulant, &f); + } + for (lane, s) in state.iter().enumerate() { + let out = felt(cols::output(lane, 0)).expr(b, &mu); + b.emit_base(idx, out - s.expr(b, &mu)); + idx += 1; + } + debug_assert_eq!(idx, NUM_CONSTRAINTS); + let _ = NUM_ROUNDS; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use math::field::element::FieldElement; + use stark::constraints::builder::ProverEvalFolder; + use stark::frame::Frame; + use stark::table::TableView; + use stark::traits::TransitionEvaluationContext; + + const P: u64 = math::field::goldilocks::GOLDILOCKS_PRIME; + + fn eval_row(trace: &TraceTable, row: usize) -> Vec { + let main: Vec = (0..cols::NUM_COLUMNS) + .map(|c| *trace.main_table.get(row, c)) + .collect(); + let frame = Frame::::new(vec![TableView::new( + vec![main], + vec![vec![]], + )]); + let no_e: Vec> = vec![]; + let offset_e = FieldElement::::zero(); + let ctx = + TransitionEvaluationContext::new_prover(frame.as_row_frame(), &no_e, &no_e, &offset_e); + let mut base = vec![FE::zero(); NUM_CONSTRAINTS]; + let mut ext = vec![FieldElement::::zero(); NUM_CONSTRAINTS]; + let mut folder = ProverEvalFolder::new(&ctx, &mut base, &mut ext); + P1w16Constraints.eval(&mut folder); + base + } + + /// Indices of the constraints that fail on `row`. + fn failing(trace: &TraceTable, row: usize) -> Vec { + eval_row(trace, row) + .iter() + .enumerate() + .filter(|(_, v)| **v != FE::zero()) + .map(|(i, _)| i) + .collect() + } + + /// Canonical states: edge values and a splitmix64 stream reduced mod p. + fn states() -> Vec<[u64; LANES]> { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = || { + seed = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = seed; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + (z ^ (z >> 31)) % P + }; + let mut v = vec![ + [0; LANES], + [P - 1; LANES], + core::array::from_fn(|i| [0, 1, P - 1, 0xFFFF_FFFF, 0xFFFF_FFFE_FFFF_FFFF][i % 5]), + ]; + v.extend((0..4).map(|_| core::array::from_fn(|_| next()))); + v + } + + fn op(input: [u64; LANES], output: [u64; LANES]) -> P1w16Operation { + P1w16Operation { + timestamp: 0x1234_5678_9ABC, + state_addr: 0x0000_0001_FFFF_FFC0, + input, + output, + } + } + + fn honest(input: [u64; LANES]) -> P1w16Operation { + op( + input, + executor::vm::instruction::execution::p1w16_permute(input), + ) + } + + #[test] + fn the_constraint_count_matches_what_eval_emits() { + assert_eq!(P1w16Constraints.meta().len(), NUM_CONSTRAINTS); + assert_eq!(cols::NUM_COLUMNS, 663); + } + + /// The executor's permutation and the LFM's `Hash16` permutation agree. + #[test] + fn the_executor_and_lfm_permutations_agree() { + for input in states() { + let exec = executor::vm::instruction::execution::p1w16_permute(input); + let felts = input.map(FE::from); + assert_eq!( + exec.map(FE::from), + crypto::hash::poseidon1_w16::permute(felts) + ); + } + } + + #[test] + fn honest_rows_and_padding_satisfy_every_constraint() { + let ops: Vec = states().into_iter().map(honest).collect(); + let trace = generate_p1w16_trace(&ops); + assert!(trace.num_rows() > ops.len(), "the trace has padding rows"); + for row in 0..trace.num_rows() { + assert_eq!(failing(&trace, row), Vec::::new(), "row {row}"); + } + } + + /// Any output other than the permutation fails, and only in the rounds. + #[test] + fn a_wrong_output_fails_the_rounds() { + let input = states()[3]; + let mut out = executor::vm::instruction::execution::p1w16_permute(input); + out[7] = (out[7] + 1) % P; + let trace = generate_p1w16_trace(&[op(input, out)]); + let bad = failing(&trace, 0); + assert!(!bad.is_empty()); + assert!(bad.iter().all(|&i| i >= ROUNDS), "{bad:?}"); + } + + /// An input lane stored as `7 + p`: the rounds read it as 7, the witness + /// is the honest one for 7, and only that felt's canonicity can object. + #[test] + fn a_non_canonical_input_fails_only_its_canonicity() { + let mut input = states()[2]; + input[4] = 7; + let mut o = honest(input); + o.input[4] += P; + let trace = generate_p1w16_trace(&[o]); + assert_eq!(failing(&trace, 0), vec![CANONICITY + 2 * 4 + 1]); + } + + /// An output lane written as `3 + p`: its canonicity pair fires. + #[test] + fn a_non_canonical_output_fails_its_canonicity() { + let mut o = honest(states()[3]); + o.output[2] = 3 + P; + let trace = generate_p1w16_trace(&[o]); + let bad = failing(&trace, 0); + let canon = CANONICITY + 2 * (LANES + 2) + 1; + assert!(bad.contains(&canon), "{bad:?}"); + assert!(bad.iter().all(|&i| i == canon || i >= ROUNDS), "{bad:?}"); + } +} diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 6350a5ae1..4016bbad3 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -53,6 +53,7 @@ use super::dvrm::{self, DvrmOperation}; use super::ecdas; use super::ecsm; use super::eq; +use super::fp3mac::{self, Fp3MacOperation}; use super::gpack::TraceForm; use super::halt; use super::hint; @@ -66,6 +67,7 @@ use super::memw::{self, MemwOperation}; use super::memw_aligned; use super::memw_register::{self, RegRow}; use super::mul::{self, MulOperation}; +use super::p1w16::{self, P1w16Operation}; use super::page::{self, PageConfig}; use super::register::{self, FinalRegisterStateMap, FinalRegisterWordState}; use super::shift::{self, ShiftOperation}; @@ -758,6 +760,8 @@ fn collect_ops_from_cpu( Vec, Vec, Vec, + Vec, + Vec, ) { let mut out = WalkOutputs::with_capacity(cpu_ops.len()); collect_ops_from_cpu_into( @@ -782,6 +786,8 @@ fn collect_ops_from_cpu( ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, } = out; ( memw, @@ -797,6 +803,8 @@ fn collect_ops_from_cpu( ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, ) } @@ -818,6 +826,8 @@ pub(crate) struct WalkOutputs { ecsm_ops: Vec, ecdas_ops: Vec, hint_ops: Vec, + p1w16_ops: Vec, + fp3mac_ops: Vec, } impl WalkOutputs { @@ -843,6 +853,8 @@ impl WalkOutputs { ecsm_ops: Vec::new(), ecdas_ops: Vec::new(), hint_ops: Vec::new(), + p1w16_ops: Vec::new(), + fp3mac_ops: Vec::new(), } } @@ -887,6 +899,8 @@ impl WalkOutputs { .sum::() + vec_heap_bytes(&self.ecdas_ops) + vec_heap_bytes(&self.hint_ops) + + vec_heap_bytes(&self.p1w16_ops) + + vec_heap_bytes(&self.fp3mac_ops) } } @@ -982,6 +996,8 @@ fn walk_cpu_ops( ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, } = out; // This call's commit rows start here; earlier calls' are already in the list. let commit_ops_before = commit_ops.len(); @@ -1176,6 +1192,104 @@ fn walk_cpu_ops( } } + // Collect P1w16Permute ECALL operations: the 16-lane state, in place. + if op.ecall_p1w16() { + let state_addr = op.p1w16_state_addr(); + let mut input = [0u64; p1w16::LANES]; + for (i, lane) in input.iter_mut().enumerate() { + let addr = state_addr + .checked_add(i as u64 * 8) + .expect("p1w16 state address range must be validated by the executor"); + let mut val = 0u64; + for b in 0..8 { + let byte_addr = addr + .checked_add(b as u64) + .expect("p1w16 state address range must be validated by the executor"); + let (byte_val, _ts) = memory_state.read_byte(byte_addr); + val |= (byte_val as u64) << (b * 8); + } + *lane = val; + } + let output = executor::vm::instruction::execution::p1w16_permute(input); + memw.extend_ops(collect_lane_memw_ops( + op.timestamp, + state_addr, + &input, + &output, + memory_state, + register_state, + )); + if REST { + p1w16_ops.push(P1w16Operation { + timestamp: op.timestamp, + state_addr, + input, + output, + }); + } + } + + // Collect Fp3Mac ECALL operations: x10 and x11 read, then the nine + // words at one timestamp, `acc` rewritten in place. + if op.ecall_fp3mac() { + let t = op.timestamp; + let state_addr = register_state.read(10).0; + let cell_addr = register_state.read(11).0; + let mut fop = Fp3MacOperation { + timestamp: t, + state_addr, + cell_addr, + input: [0; fp3mac::WORDS], + output: [0; 3], + }; + for w in 0..fp3mac::WORDS { + let addr = fop.word_addr(w); + fop.input[w] = (0..8).fold(0u64, |v, b| { + v | (memory_state.read_byte(addr + b as u64).0 as u64) << (8 * b) + }); + } + fop.output = executor::vm::instruction::execution::fp3mac( + core::array::from_fn(|i| fop.input[i]), + [fop.input[6], fop.input[7], fop.input[8]], + ); + for (reg, value) in [(10u8, state_addr), (11, cell_addr)] { + let reg_value = pack_register_value(value); + let (_old_val, old_ts) = register_state.read(reg); + memw.extend_ops([ + MemwOperation::new(true, 2 * reg as u64, reg_value, t, 2, true) + .with_old(reg_value, [old_ts, old_ts, 0, 0, 0, 0, 0, 0]), + ]); + register_state.write(reg, value, t); + } + let mut word_ops = Vec::with_capacity(fp3mac::WORDS); + for w in 0..fp3mac::WORDS { + let addr = fop.word_addr(w); + let (old, new) = (fop.input[w], fop.word_out(w)); + let mut old_bytes = [0u32; 8]; + let mut old_timestamps = [0u64; 8]; + let mut value_bytes = [0u32; 8]; + for b in 0..8 { + old_bytes[b] = ((old >> (8 * b)) & 0xFF) as u32; + value_bytes[b] = ((new >> (8 * b)) & 0xFF) as u32; + old_timestamps[b] = memory_state.read_byte(addr + b as u64).1; + } + word_ops.push( + MemwOperation::new(false, addr, value_bytes, t, 8, true) + .with_old(old_bytes, old_timestamps), + ); + } + for w in 0..fp3mac::WORDS { + let (addr, new) = (fop.word_addr(w), fop.word_out(w)); + for b in 0..8 { + memory_state.write_byte(addr + b as u64, ((new >> (8 * b)) & 0xFF) as u8, t); + } + } + memw.extend_ops(word_ops); + if REST { + fp3mac_ops.push(fop); + } + } + // --- ALU chip dispatch (no state tracking) --- // Word (`*W`) instructions are delegated to CPU32 (which itself drives // the ALU chips); the main CPU does not send the ALU bus for them, so we @@ -2004,6 +2118,126 @@ fn collect_keccak_memw_ops( memw_ops } +/// The MEMW operations of an in-place lane-state ecall (P1W16): the x10 read, +/// then one combined read+write per 8-byte lane at `ts` (old = `input`, value = +/// `output`), exactly as [`collect_keccak_memw_ops`] does for KECCAK's 25 lanes. +fn collect_lane_memw_ops( + ts: u64, + state_addr: u64, + input: &[u64], + output: &[u64], + memory_state: &mut MemoryState, + register_state: &mut RegisterState, +) -> Vec { + let mut memw_ops = Vec::with_capacity(1 + input.len()); + { + let reg_value = pack_register_value(state_addr); + let reg_addr = 2 * 10u64; + let (_old_val, old_ts) = register_state.read(10); + let old_timestamps = [old_ts, old_ts, 0, 0, 0, 0, 0, 0]; + let memw_op = MemwOperation::new(true, reg_addr, reg_value, ts, 2, true) + .with_old(reg_value, old_timestamps); + memw_ops.push(memw_op); + register_state.write(10, state_addr, ts); + } + for (lane_idx, (&in_lane, &out_lane)) in input.iter().zip(output).enumerate() { + let lane_addr = state_addr + .checked_add(lane_idx as u64 * 8) + .expect("lane state address range must be validated by the executor"); + let mut old_bytes = [0u32; 8]; + let mut old_timestamps = [0u64; 8]; + for b in 0..8 { + old_bytes[b] = ((in_lane >> (b * 8)) & 0xFF) as u32; + let (_old_val, old_ts) = memory_state.read_byte(lane_addr + b as u64); + old_timestamps[b] = old_ts; + } + let mut value_bytes = [0u32; 8]; + for (b, byte) in value_bytes.iter_mut().enumerate() { + *byte = ((out_lane >> (b * 8)) & 0xFF) as u32; + } + memw_ops.push( + MemwOperation::new(false, lane_addr, value_bytes, ts, 8, true) + .with_old(old_bytes, old_timestamps), + ); + for (b, &val) in value_bytes.iter().enumerate() { + memory_state.write_byte(lane_addr + b as u64, val as u8, ts); + } + } + memw_ops +} + +/// BITWISE lookups sent by the P1W16 table, in `p1w16::bus_interactions`'s +/// pairing: the alignment AND, IS_HALF on the 64 pointer halfwords, and +/// ARE_BYTES on the address, input and output bytes in adjacent pairs. +pub(crate) fn collect_bitwise_from_p1w16(ops: &[P1w16Operation]) -> Vec { + let mut out = Vec::with_capacity(ops.len() * (1 + 4 * p1w16::LANES + 4 + 8 * p1w16::LANES)); + for op in ops { + out.push(BitwiseOperation::byte_op( + BitwiseOperationType::ByteAluAnd, + (op.state_addr & 0xFF) as u8, + 7, + )); + for lane in 0..p1w16::LANES { + let ptr = op + .state_addr + .checked_add(lane as u64 * 8) + .expect("p1w16 state address range must be validated by the executor"); + for shift in [0, 16, 32, 48] { + out.push(is_half_op(((ptr >> shift) & 0xFFFF) as u16)); + } + } + let mut bytes = Vec::with_capacity(8 + 16 * p1w16::LANES); + bytes.extend(op.state_addr.to_le_bytes()); + for w in op.input.iter().chain(&op.output) { + bytes.extend(w.to_le_bytes()); + } + for pair in bytes.chunks_exact(2) { + out.push(BitwiseOperation::byte_op( + BitwiseOperationType::AreBytes, + pair[0], + pair[1], + )); + } + } + out +} + +/// BITWISE lookups sent by the FP3MAC table, in `fp3mac::bus_interactions`'s +/// pairing: the two alignment ANDs, IS_HALF on the 36 pointer halfwords, and +/// ARE_BYTES on the addresses, input and output bytes in adjacent pairs. +pub(crate) fn collect_bitwise_from_fp3mac(ops: &[Fp3MacOperation]) -> Vec { + let mut out = Vec::with_capacity(ops.len() * (2 + 4 * fp3mac::WORDS + 56)); + for op in ops { + for addr in [op.state_addr, op.cell_addr] { + out.push(BitwiseOperation::byte_op( + BitwiseOperationType::ByteAluAnd, + (addr & 0xFF) as u8, + 7, + )); + } + for w in 0..fp3mac::WORDS { + let ptr = op.word_addr(w); + for shift in [0, 16, 32, 48] { + out.push(is_half_op(((ptr >> shift) & 0xFFFF) as u16)); + } + } + let mut bytes = Vec::with_capacity(16 + 8 * (fp3mac::WORDS + 3)); + bytes.extend(op.state_addr.to_le_bytes()); + bytes.extend(op.cell_addr.to_le_bytes()); + for w in op.input.iter().chain(&op.output) { + bytes.extend(w.to_le_bytes()); + } + for pair in bytes.chunks_exact(2) { + out.push(BitwiseOperation::byte_op( + BitwiseOperationType::AreBytes, + pair[0], + pair[1], + )); + } + } + out +} + #[cfg(test)] thread_local! { /// ★ TEST-ONLY — suppresses BLAKE3's *side* contributions to the shared tables. @@ -3950,6 +4184,11 @@ pub struct Traces { /// makes no hint ecall. pub hints: Vec>, + /// P1W16 table (one row per width-16 Poseidon1 ecall). Empty when the run + /// makes no such call. + pub p1w16s: Vec>, + pub fp3macs: Vec>, + /// MEMW_R register-only fast-path traces (split into chunks of max_rows::MEMW_R) pub memw_registers: Vec>, /// Local-to-global boundary table for continuation epochs. Empty unless the @@ -4005,6 +4244,9 @@ struct CollectedOps { ecdas_ops: BlockVec, // Non-constraining hint ecall. hint_ops: BlockVec, + // Width-16 Poseidon1 ecall. + p1w16_ops: BlockVec, + fp3mac_ops: BlockVec, } /// How many leading chunks of each streamed table a windowed build already @@ -5266,6 +5508,8 @@ fn collect_all_ops( ecsm_ops: Vec, ecdas_ops: Vec, hint_ops: Vec, + p1w16_ops: Vec, + fp3mac_ops: Vec, register_state: &mut RegisterState, is_final: bool, ) -> CollectedOps { @@ -5347,6 +5591,8 @@ fn collect_all_ops( .collect(), ), hint_ops: BlockVec::from_vec(hint_ops), + p1w16_ops: BlockVec::from_vec(p1w16_ops), + fp3mac_ops: BlockVec::from_vec(fp3mac_ops), } } @@ -5815,6 +6061,8 @@ impl Traces { ecsms, ecdases, hints, + p1w16s, + fp3macs, memw_registers, local_to_global, touched_memory_cells: _, @@ -5840,6 +6088,8 @@ impl Traces { ecsms, ecdases, hints, + p1w16s, + fp3macs, memw_registers, eqs, bytewises, @@ -5962,6 +6212,7 @@ impl Traces { use super::ecdas::cols::NUM_COLUMNS as ECDAS_COLS; use super::ecsm::cols::NUM_COLUMNS as ECSM_COLS; use super::eq::cols::NUM_COLUMNS as EQ_COLS; + use super::fp3mac::cols::NUM_COLUMNS as FP3MAC_COLS; use super::halt::cols::NUM_COLUMNS as HALT_COLS; use super::hint::cols::NUM_COLUMNS as HINT_COLS; use super::keccak::cols::NUM_COLUMNS as KECCAK_COLS; @@ -5974,6 +6225,7 @@ impl Traces { use super::memw_aligned::cols::NUM_COLUMNS as MEMW_A_COLS; use super::memw_register::cols::NUM_COLUMNS as MEMW_R_COLS; use super::mul::cols::NUM_COLUMNS as MUL_COLS; + use super::p1w16::cols::NUM_COLUMNS as P1W16_COLS; use super::page::NUM_PREPROCESSED_COLS as PAGE_PREPROCESSED; use super::page::cols::NUM_COLUMNS as PAGE_COLS; use super::register::NUM_PREPROCESSED_COLS as REGISTER_PREPROCESSED; @@ -6005,6 +6257,8 @@ impl Traces { ecsms, ecdases, hints, + p1w16s, + fp3macs, memw_registers, eqs, bytewises, @@ -6090,6 +6344,12 @@ impl Traces { for t in hints { total += (t.num_rows() * HINT_COLS) as u64; } + for t in p1w16s { + total += (t.num_rows() * P1W16_COLS) as u64; + } + for t in fp3macs { + total += (t.num_rows() * FP3MAC_COLS) as u64; + } total } @@ -6133,6 +6393,8 @@ impl Traces { let n_ecsm = aux_cols(super::ecsm::bus_interactions().len()); let n_ecdas = aux_cols(super::ecdas::bus_interactions().len()); let n_hint = aux_cols(super::hint::bus_interactions().len()); + let n_p1w16 = aux_cols(super::p1w16::bus_interactions().len()); + let n_fp3mac = aux_cols(super::fp3mac::bus_interactions().len()); let Traces { cpus, @@ -6158,6 +6420,8 @@ impl Traces { ecsms, ecdases, hints, + p1w16s, + fp3macs, memw_registers, eqs, bytewises, @@ -6241,6 +6505,12 @@ impl Traces { for t in hints { total += (t.num_rows() * n_hint) as u64; } + for t in p1w16s { + total += (t.num_rows() * n_p1w16) as u64; + } + for t in fp3macs { + total += (t.num_rows() * n_fp3mac) as u64; + } total } @@ -6266,6 +6536,8 @@ impl Traces { ecsm: self.ecsms.len(), ecdas: self.ecdases.len(), hint: self.hints.len(), + p1w16: self.p1w16s.len(), + fp3mac: self.fp3macs.len(), commit: self.commits.len(), // 0 or 1: the table is carried only when the workload used it. The // six above read their own `Vec` length; this one cannot, because a @@ -6578,6 +6850,8 @@ impl Traces { ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, ) = collect_ops_from_cpu( &cpu_ops, &artifacts.decode, @@ -6606,6 +6880,8 @@ impl Traces { ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, &mut register_state, is_final, ); @@ -6707,6 +6983,8 @@ impl Traces { ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, ) = collect_ops_from_cpu( &cpu_ops, &decode_table, @@ -6730,6 +7008,8 @@ impl Traces { ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, &mut register_state, true, ); diff --git a/prover/src/tables/trace_builder/finish.rs b/prover/src/tables/trace_builder/finish.rs index 588c44a30..837f77e9f 100644 --- a/prover/src/tables/trace_builder/finish.rs +++ b/prover/src/tables/trace_builder/finish.rs @@ -31,7 +31,7 @@ impl RestHeader { } /// [`crate::TableCounts`] field by field, to compare two. -pub(super) fn counts_of(c: &crate::TableCounts) -> [usize; 21] { +pub(super) fn counts_of(c: &crate::TableCounts) -> [usize; 23] { [ c.cpu, c.lt, @@ -54,6 +54,8 @@ pub(super) fn counts_of(c: &crate::TableCounts) -> [usize; 21] { c.hint, c.commit, c.blake3, + c.p1w16, + c.fp3mac, ] } @@ -156,6 +158,8 @@ pub(super) struct FinishPlan<'a> { ecsm_ops: BlockVec, ecdas_ops: BlockVec, hint_ops: BlockVec, + p1w16_ops: BlockVec, + fp3mac_ops: BlockVec, bitwise_histogram: bitwise::BitwiseHistogram, num_padding_rows: usize, halt_timestamp: u64, @@ -209,6 +213,8 @@ impl<'a> FinishPlan<'a> { ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, } = ops; // ===================================================================== @@ -522,6 +528,8 @@ impl<'a> FinishPlan<'a> { }), Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops.whole()))), Box::new(|h| h.add_ops(&collect_bitwise_from_hint(&hint_ops.whole()))), + Box::new(|h| h.add_ops(&collect_bitwise_from_p1w16(&p1w16_ops.whole()))), + Box::new(|h| h.add_ops(&collect_bitwise_from_fp3mac(&fp3mac_ops.whole()))), Box::new(|h| add_padding_byte_checks(h, num_padding_rows)), ]); if let Some(image) = initial_image @@ -707,6 +715,8 @@ impl<'a> FinishPlan<'a> { ecsm: accelerator(ecsm_ops.len(), skip.ecsm_rows), ecdas: accelerator(ecdas_ops.len(), skip.ecdas_rows), hint: usize::from(!hint_ops.is_empty()), + p1w16: usize::from(!p1w16_ops.is_empty()), + fp3mac: usize::from(!fp3mac_ops.is_empty()), commit: usize::from(!commit_ops.is_empty()), blake3: usize::from(num_blake3_ops > 0), }; @@ -742,6 +752,8 @@ impl<'a> FinishPlan<'a> { ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, bitwise_histogram, num_padding_rows, halt_timestamp, @@ -803,6 +815,8 @@ impl<'a> FinishPlan<'a> { ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, bitwise_histogram, num_padding_rows, halt_timestamp, @@ -1165,6 +1179,23 @@ impl<'a> FinishPlan<'a> { storage_mode, ) }; + // P1W16 table. Absent for programs that make no width-16 Poseidon1 ecall. + let gen_p1w16s = || { + generate_optional( + &p1w16_ops.whole(), + packed_by(pack, p1w16::generate_p1w16_trace), + #[cfg(feature = "disk-spill")] + storage_mode, + ) + }; + let gen_fp3macs = || { + generate_optional( + &fp3mac_ops.whole(), + packed_by(pack, fp3mac::generate_fp3mac_trace), + #[cfg(feature = "disk-spill")] + storage_mode, + ) + }; let (mut cpus_slot, mut memws_slot, mut memw_aligneds_slot, mut memw_registers_slot) = (None, None, None, None); @@ -1180,6 +1211,8 @@ impl<'a> FinishPlan<'a> { (None, None, None, None); let (mut ecsms_slot, mut ecdases_slot) = (None, None); let mut hints_slot = None; + let mut p1w16s_slot = None; + let mut fp3macs_slot = None; #[cfg(feature = "disk-spill")] let sequential = storage_mode == StorageMode::Disk || cfg!(not(feature = "parallel")); @@ -1226,6 +1259,8 @@ impl<'a> FinishPlan<'a> { spawn_into!(ecsms_slot, gen_ecsms); spawn_into!(ecdases_slot, gen_ecdases); spawn_into!(hints_slot, gen_hints); + spawn_into!(p1w16s_slot, gen_p1w16s); + spawn_into!(fp3macs_slot, gen_fp3macs); }); } else { cpus_slot = Some(gen_cpus()); @@ -1255,6 +1290,8 @@ impl<'a> FinishPlan<'a> { ecsms_slot = Some(gen_ecsms()); ecdases_slot = Some(gen_ecdases()); hints_slot = Some(gen_hints()); + p1w16s_slot = Some(gen_p1w16s()); + fp3macs_slot = Some(gen_fp3macs()); } build_stamps::mark("p5 end"); @@ -1291,6 +1328,8 @@ impl<'a> FinishPlan<'a> { let ecsms = ecsms_slot.expect(PHASE5_RAN)?; let ecdases = ecdases_slot.expect(PHASE5_RAN)?; let hints = hints_slot.expect(PHASE5_RAN)?; + let p1w16s = p1w16s_slot.expect(PHASE5_RAN)?; + let fp3macs = fp3macs_slot.expect(PHASE5_RAN)?; // Fixed-size and per-page tables aren't built through `chunk_and_generate`, // so spill them here before returning. @@ -1362,6 +1401,8 @@ impl<'a> FinishPlan<'a> { ecsms, ecdases, hints, + p1w16s, + fp3macs, memw_registers, local_to_global, touched_memory_cells, @@ -1419,7 +1460,7 @@ pub(crate) struct Emitted { /// The rest's families in AIR order (`VmAirs::air_refs`), each with its /// columns at eight bytes a cell: what a family's first table is estimated /// at, before one of them has been built. -const FAMILIES: [(&str, usize); 27] = [ +const FAMILIES: [(&str, usize); 29] = [ ("BITWISE", bitwise::cols::NUM_COLUMNS), ("DECODE", decode::cols::NUM_COLUMNS), ("KECCAK_RC", keccak_rc::cols::NUM_COLUMNS), @@ -1431,6 +1472,8 @@ const FAMILIES: [(&str, usize); 27] = [ ("ECSM", ecsm::cols::NUM_COLUMNS), ("ECDAS", ecdas::cols::NUM_COLUMNS), ("HINT", hint::cols::NUM_COLUMNS), + ("P1W16", p1w16::cols::NUM_COLUMNS), + ("FP3MAC", fp3mac::cols::NUM_COLUMNS), ("BLAKE3", blake3::cols::NUM_COLUMNS), ("CPU", cpu::cols::NUM_COLUMNS), ("LT", lt::cols::NUM_COLUMNS), @@ -1636,6 +1679,8 @@ impl<'a> FinishPlan<'a> { ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, bitwise_histogram, num_padding_rows, halt_timestamp, @@ -1795,6 +1840,24 @@ impl<'a> FinishPlan<'a> { )) }) })); + let p1w16_ops = Arc::new(p1w16_ops); + units.extend(optional_unit(family("P1W16"), p1w16_ops.len(), || { + Box::new(move || { + Ok(packed_if( + pack, + p1w16::generate_p1w16_trace(&p1w16_ops.whole()), + )) + }) + })); + let fp3mac_ops = Arc::new(fp3mac_ops); + units.extend(optional_unit(family("FP3MAC"), fp3mac_ops.len(), || { + Box::new(move || { + Ok(packed_if( + pack, + fp3mac::generate_fp3mac_trace(&fp3mac_ops.whole()), + )) + }) + })); // BLAKE3 is a table of the proof only when it has ops (`VmAirs`' // `include_blake3` from the counts), whatever the build makes of it // otherwise. Read off the ops, so a miscounted header is refused below. @@ -2277,6 +2340,8 @@ fn check_units(units: &[Unit<'_>], counts: &crate::TableCounts) -> Result<(), Er ecsm: of("ECSM"), ecdas: of("ECDAS"), hint: of("HINT"), + p1w16: of("P1W16"), + fp3mac: of("FP3MAC"), commit: of("COMMIT"), blake3: of("BLAKE3"), }; diff --git a/prover/src/tables/trace_builder/finish_oracle.rs b/prover/src/tables/trace_builder/finish_oracle.rs index 50dc1dc37..18f295078 100644 --- a/prover/src/tables/trace_builder/finish_oracle.rs +++ b/prover/src/tables/trace_builder/finish_oracle.rs @@ -81,6 +81,8 @@ pub(super) fn build_traces_oracle( ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, } = ops; // ===================================================================== @@ -392,6 +394,8 @@ pub(super) fn build_traces_oracle( }), Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops.whole()))), Box::new(|h| h.add_ops(&collect_bitwise_from_hint(&hint_ops.whole()))), + Box::new(|h| h.add_ops(&collect_bitwise_from_p1w16(&p1w16_ops.whole()))), + Box::new(|h| h.add_ops(&collect_bitwise_from_fp3mac(&fp3mac_ops.whole()))), Box::new(|h| add_padding_byte_checks(h, num_padding_rows)), ]); if let Some(image) = initial_image @@ -873,6 +877,22 @@ pub(super) fn build_traces_oracle( storage_mode, ) }; + let gen_p1w16s = || { + generate_optional( + &p1w16_ops.whole(), + packed_by(pack, p1w16::generate_p1w16_trace), + #[cfg(feature = "disk-spill")] + storage_mode, + ) + }; + let gen_fp3macs = || { + generate_optional( + &fp3mac_ops.whole(), + packed_by(pack, fp3mac::generate_fp3mac_trace), + #[cfg(feature = "disk-spill")] + storage_mode, + ) + }; let (mut cpus_slot, mut memws_slot, mut memw_aligneds_slot, mut memw_registers_slot) = (None, None, None, None); @@ -887,6 +907,8 @@ pub(super) fn build_traces_oracle( (None, None, None, None); let (mut ecsms_slot, mut ecdases_slot) = (None, None); let mut hints_slot = None; + let mut p1w16s_slot = None; + let mut fp3macs_slot = None; #[cfg(feature = "disk-spill")] let sequential = storage_mode == StorageMode::Disk || cfg!(not(feature = "parallel")); @@ -933,6 +955,8 @@ pub(super) fn build_traces_oracle( spawn_into!(ecsms_slot, gen_ecsms); spawn_into!(ecdases_slot, gen_ecdases); spawn_into!(hints_slot, gen_hints); + spawn_into!(p1w16s_slot, gen_p1w16s); + spawn_into!(fp3macs_slot, gen_fp3macs); }); } else { cpus_slot = Some(gen_cpus()); @@ -962,6 +986,8 @@ pub(super) fn build_traces_oracle( ecsms_slot = Some(gen_ecsms()); ecdases_slot = Some(gen_ecdases()); hints_slot = Some(gen_hints()); + p1w16s_slot = Some(gen_p1w16s()); + fp3macs_slot = Some(gen_fp3macs()); } build_stamps::mark("p5 end"); @@ -998,6 +1024,8 @@ pub(super) fn build_traces_oracle( let ecsms = ecsms_slot.expect(PHASE5_RAN)?; let ecdases = ecdases_slot.expect(PHASE5_RAN)?; let hints = hints_slot.expect(PHASE5_RAN)?; + let p1w16s = p1w16s_slot.expect(PHASE5_RAN)?; + let fp3macs = fp3macs_slot.expect(PHASE5_RAN)?; // Fixed-size and per-page tables aren't built through `chunk_and_generate`, // so spill them here before returning. @@ -1064,6 +1092,8 @@ pub(super) fn build_traces_oracle( ecsms, ecdases, hints, + p1w16s, + fp3macs, memw_registers, local_to_global, touched_memory_cells, diff --git a/prover/src/tables/trace_builder/windowed.rs b/prover/src/tables/trace_builder/windowed.rs index 4d64548a5..cdbd3705f 100644 --- a/prover/src/tables/trace_builder/windowed.rs +++ b/prover/src/tables/trace_builder/windowed.rs @@ -1444,6 +1444,8 @@ pub(super) struct KeptRest { /// Held compact (their carries in 16 bits), widened as each is read. ecdas_ops: BlockVec, hint_ops: BlockVec, + p1w16_ops: BlockVec, + fp3mac_ops: BlockVec, } /// ECDAS rows a block of the kept ECDAS ops holds: the block's ECDAS cut @@ -1466,6 +1468,8 @@ impl KeptRest { ecsm_ops: list(usize::MAX), ecdas_ops: BlockVec::new(ECDAS_BLOCK_ROWS), hint_ops: list(usize::MAX), + p1w16_ops: list(usize::MAX), + fp3mac_ops: list(usize::MAX), } } @@ -1487,12 +1491,14 @@ impl KeptRest { .collect(), ), hint_ops: BlockVec::from_vec(std::mem::take(&mut walk.hint_ops)), + p1w16_ops: BlockVec::from_vec(std::mem::take(&mut walk.p1w16_ops)), + fp3mac_ops: BlockVec::from_vec(std::mem::take(&mut walk.fp3mac_ops)), } } /// Each list's (name, heap bytes, largest single allocation). ECSM's /// counts the steps' buffer each op's witness owns as well. - fn sizes(&self) -> [(&'static str, usize, usize); 9] { + fn sizes(&self) -> [(&'static str, usize, usize); 11] { fn of(name: &'static str, list: &BlockVec) -> (&'static str, usize, usize) { (name, list.heap_bytes(), list.largest_bytes()) } @@ -1515,6 +1521,8 @@ impl KeptRest { ecsm, of("ecdas", &self.ecdas_ops), of("hint", &self.hint_ops), + of("p1w16", &self.p1w16_ops), + of("fp3mac", &self.fp3mac_ops), ] } } @@ -1621,6 +1629,8 @@ impl Kept { ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, } = walk; self.cpu.push(cpu_ops); self.register_rows.push(memw.register_rows); @@ -1640,6 +1650,8 @@ impl Kept { rest.ecdas_ops .extend(ecdas_ops.iter().map(super::ecdas::CompactEcdasOp::from_op)); rest.hint_ops.extend(hint_ops); + rest.p1w16_ops.extend(p1w16_ops); + rest.fp3mac_ops.extend(fp3mac_ops); } /// The run's lists with the handed-out ops left out (the streamed tables' @@ -1929,6 +1941,8 @@ fn concatenate(windows: Vec) -> (Vec, WalkOut let mut ecsm = Vec::with_capacity(n); let mut ecdas = Vec::with_capacity(n); let mut hint = Vec::with_capacity(n); + let mut p1w16 = Vec::with_capacity(n); + let mut fp3mac = Vec::with_capacity(n); for WalkedWindow { cpu_ops, walk } in windows { cpu.push(cpu_ops); register_rows.push(walk.memw.register_rows); @@ -1946,6 +1960,8 @@ fn concatenate(windows: Vec) -> (Vec, WalkOut ecsm.push(walk.ecsm_ops); ecdas.push(walk.ecdas_ops); hint.push(walk.hint_ops); + p1w16.push(walk.p1w16_ops); + fp3mac.push(walk.fp3mac_ops); } let ((cpu, (register_rows, aligned)), ((general, bitwise), (load, (lt, shift)))) = join( || (cat(cpu), join(|| cat(register_rows), || cat(aligned))), @@ -1974,6 +1990,8 @@ fn concatenate(windows: Vec) -> (Vec, WalkOut ecsm_ops: cat(ecsm), ecdas_ops: cat(ecdas), hint_ops: cat(hint), + p1w16_ops: cat(p1w16), + fp3mac_ops: cat(fp3mac), }; (cpu, walk) } @@ -2111,6 +2129,8 @@ fn assemble( ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, } = rest; let MemwBuckets { register_rows: mut memw_register_rows, @@ -2177,6 +2197,8 @@ fn assemble( ecsm_ops, ecdas_ops, hint_ops, + p1w16_ops, + fp3mac_ops, } } diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index 2fc20bc79..b2b6d24f9 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -1054,6 +1054,34 @@ pub fn create_ecsm_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { + build_air( + crate::tables::p1w16::cols::NUM_COLUMNS, + crate::tables::p1w16::bus_interactions(), + proof_options, + 1, + crate::tables::p1w16::P1w16Constraints, + "P1W16", + ) +} + +/// Create FP3MAC AIR (one cubic-extension multiply-add per row). +pub fn create_fp3mac_air( + proof_options: &ProofOptions, +) -> ConcreteVmAir { + build_air( + crate::tables::fp3mac::cols::NUM_COLUMNS, + crate::tables::fp3mac::bus_interactions(), + proof_options, + 1, + crate::tables::fp3mac::Fp3MacConstraints, + "FP3MAC", + ) +} + pub fn create_ecdas_air(proof_options: &ProofOptions) -> ConcreteVmAir { build_air( ecdas_cols::NUM_COLUMNS, @@ -1141,6 +1169,8 @@ pub fn production_airs(proof_options: &ProofOptions) -> Vec { ("KECCAK_RC", Box::new(create_keccak_rc_air(proof_options))), ("ECSM", Box::new(create_ecsm_air(proof_options))), ("ECDAS", Box::new(create_ecdas_air(proof_options))), + ("P1W16", Box::new(create_p1w16_air(proof_options))), + ("FP3MAC", Box::new(create_fp3mac_air(proof_options))), ("HINT", Box::new(create_hint_air(proof_options))), // ---- continuation-only tables (no monolithic proof contains these) ---- ( @@ -1179,7 +1209,7 @@ pub fn create_global_memory_air( crate::continuation::global_memory_air(proof_options, &config, Some([0u8; 32])) } -/// The number of production table AIRs [`production_airs`] yields: 26 monolithic +/// The number of production table AIRs [`production_airs`] yields: 28 monolithic /// plus 3 continuation-only. /// /// Every suite that iterates the list asserts against this. That assert is the @@ -1188,4 +1218,4 @@ pub fn create_global_memory_air( /// per-table suite at once and silently — which is exactly what happened to the /// three continuation tables. Bump it deliberately when a table is genuinely /// added or removed. -pub const NUM_PRODUCTION_AIRS: usize = 29; +pub const NUM_PRODUCTION_AIRS: usize = 31; diff --git a/prover/src/tests/block_whir_tests.rs b/prover/src/tests/block_whir_tests.rs index d68071a24..3ab9e3718 100644 --- a/prover/src/tests/block_whir_tests.rs +++ b/prover/src/tests/block_whir_tests.rs @@ -1127,6 +1127,72 @@ fn a_group_proved_on_another_fork_is_refused() { /// bus balance, which is checked once over every table of the block. The /// chunk left out holds real rounds (the first): the last one of a small /// program can be padding alone, which the bus does not see. +/// The `p1w16_precompile` guest (five width-16 Poseidon1 ecalls). +fn p1w16_guest_elf() -> Vec { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .join("executor/program_artifacts/rust/p1w16_precompile.elf"); + std::fs::read(&root).expect("p1w16_precompile.elf not found — run `make compile-programs-rust`") +} + +/// ★ The P1W16 table on the block path: a guest calling the width-16 +/// Poseidon1 ecall proves and verifies in one group and across several. +#[test] +fn a_p1w16_guest_proves_and_verifies_as_a_block() { + let elf = p1w16_guest_elf(); + for format in [one_group(), many_groups()] { + let proof = prove(&elf, &format, &options(MaxRowsConfig::small(), 16)); + assert_eq!(proof.table_counts.p1w16, 1); + assert!(verify(&proof, &elf, &format)); + } +} + +/// A block proof that drops its P1W16 table from the counts is refused. +#[test] +fn a_block_claiming_no_p1w16_table_is_refused() { + let elf = p1w16_guest_elf(); + let format = many_groups(); + let mut proof = prove(&elf, &format, &options(MaxRowsConfig::small(), 16)); + assert!(verify(&proof, &elf, &format)); + proof.table_counts.p1w16 = 0; + assert!(!verify(&proof, &elf, &format)); +} + +/// The `fp3mac_precompile` guest (a Horner over 48 cells with the +/// cubic-extension multiply-add ecall). +fn fp3mac_guest_elf() -> Vec { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .join("executor/program_artifacts/rust/fp3mac_precompile.elf"); + std::fs::read(&root) + .expect("fp3mac_precompile.elf not found — run `make compile-programs-rust`") +} + +/// The FP3MAC table on the block path: a guest calling the multiply-add ecall +/// proves and verifies in one group and across several. +#[test] +fn a_fp3mac_guest_proves_and_verifies_as_a_block() { + let elf = fp3mac_guest_elf(); + for format in [one_group(), many_groups()] { + let proof = prove(&elf, &format, &options(MaxRowsConfig::default(), 16)); + assert_eq!(proof.table_counts.fp3mac, 1); + assert!(verify(&proof, &elf, &format)); + } +} + +/// A block proof that drops its FP3MAC table from the counts is refused. +#[test] +fn a_block_claiming_no_fp3mac_table_is_refused() { + let elf = fp3mac_guest_elf(); + let format = many_groups(); + let mut proof = prove(&elf, &format, &options(MaxRowsConfig::default(), 16)); + assert!(verify(&proof, &elf, &format)); + proof.table_counts.fp3mac = 0; + assert!(!verify(&proof, &elf, &format)); +} + #[test] fn a_block_missing_an_instance_is_refused_on_the_bus() { let elf = asm_elf_bytes("test_keccak"); @@ -2660,6 +2726,8 @@ fn chunked_counts() -> crate::TableCounts { hint: 1, commit: 1, blake3: 0, + p1w16: 0, + fp3mac: 0, } } diff --git a/prover/src/tests/multilinear_bench_tests.rs b/prover/src/tests/multilinear_bench_tests.rs index d3687b570..74d2a488a 100644 --- a/prover/src/tests/multilinear_bench_tests.rs +++ b/prover/src/tests/multilinear_bench_tests.rs @@ -1878,6 +1878,8 @@ fn the_per_branch_term_is_the_table_kind_count() { hint: 0, commit: 0, blake3: 0, + p1w16: 0, + fp3mac: 0, }; let kinds = crate::statement::table_count_values(&zero).len(); assert_eq!( diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 301663a55..d75c23113 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1649,6 +1649,138 @@ fn test_prove_hint_min_inconsistent_output_rejected() { ); } +/// The `p1w16_precompile` guest's ELF. +fn p1w16_guest_elf() -> Vec { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + std::fs::read(workspace_root.join("executor/program_artifacts/rust/p1w16_precompile.elf")) + .expect("p1w16_precompile.elf not found — run `make compile-programs-rust`") +} + +/// The width-16 Poseidon1 ecall end to end: the P1W16 table proves the guest's +/// five chained permutations, and the committed lanes are the host's. +#[test] +fn test_prove_p1w16_precompile_rust_guest() { + let elf_bytes = p1w16_guest_elf(); + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "p1w16_precompile rust guest should verify" + ); + assert_eq!(proof.table_counts.p1w16, 1); + const P: u64 = 0xFFFF_FFFF_0000_0001; + let mut state: [u64; 16] = core::array::from_fn(|i| match i % 4 { + 0 => 0, + 1 => 1, + 2 => P - 1, + _ => (i as u64) * 0x0123_4567_89AB_CDEF % P, + }); + for _ in 0..5 { + state = executor::vm::instruction::execution::p1w16_permute(state); + } + let expected: Vec = state.iter().flat_map(|w| w.to_le_bytes()).collect(); + assert_eq!(proof.public_output, expected); +} + +/// Soundness: an output byte of a P1W16 row changed after the build is refused +/// (the rounds no longer reach it, and its Memw write no longer balances). +#[test] +fn test_prove_p1w16_forged_output_rejected() { + use crate::tables::p1w16::cols as p1w16_cols; + let elf_bytes = p1w16_guest_elf(); + let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); + let result = Executor::new(&elf, vec![]) + .expect("Failed to create executor") + .run() + .expect("Failed to run program"); + let mut traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + assert_eq!(traces.p1w16s.len(), 1); + let orig = *traces.p1w16s[0].main_table.get(0, p1w16_cols::output(3, 0)); + let forged = orig + FieldElement::::one(); + traces.p1w16s[0] + .main_table + .set(0, p1w16_cols::output(3, 0), forged); + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a forged P1W16 output byte" + ); +} + +/// The `fp3mac_precompile` guest's ELF. +fn fp3mac_guest_elf() -> Vec { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + std::fs::read(workspace_root.join("executor/program_artifacts/rust/fp3mac_precompile.elf")) + .expect("fp3mac_precompile.elf not found — run `make compile-programs-rust`") +} + +/// The cubic-extension multiply-add ecall end to end: the FP3MAC table proves +/// the guest's Horner, and the committed accumulator is the host's. +#[test] +fn test_prove_fp3mac_precompile_rust_guest() { + let elf_bytes = fp3mac_guest_elf(); + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "fp3mac_precompile rust guest should verify" + ); + assert_eq!(proof.table_counts.fp3mac, 1); + const P: u64 = 0xFFFF_FFFF_0000_0001; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = || { + seed = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = seed; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + (z ^ (z >> 31)) % P + }; + let mut state: [u64; 6] = [0, 0, 0, next(), next(), next()]; + let edges: [[u64; 3]; 3] = [[0, 1, P - 1], [P - 1, P - 1, P - 1], [0xFFFF_FFFF, 0, 1]]; + for k in 0..48 { + let x = if k < edges.len() { + edges[k] + } else { + [next(), next(), next()] + }; + let acc = executor::vm::instruction::execution::fp3mac(state, x); + state[..3].copy_from_slice(&acc); + } + let expected: Vec = state[..3].iter().flat_map(|w| w.to_le_bytes()).collect(); + assert_eq!(proof.public_output, expected); +} + +/// Soundness: an output byte of an FP3MAC row changed after the build is +/// refused (the product no longer holds, and its Memw write no longer balances). +#[test] +fn test_prove_fp3mac_forged_output_rejected() { + use crate::tables::fp3mac::cols as fp3mac_cols; + let elf_bytes = fp3mac_guest_elf(); + let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); + let result = Executor::new(&elf, vec![]) + .expect("Failed to create executor") + .run() + .expect("Failed to run program"); + let mut traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + assert_eq!(traces.fp3macs.len(), 1); + let orig = *traces.fp3macs[0] + .main_table + .get(5, fp3mac_cols::output(1, 0)); + let forged = orig + FieldElement::::one(); + traces.fp3macs[0] + .main_table + .set(5, fp3mac_cols::output(1, 0), forged); + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a forged FP3MAC output byte" + ); +} + /// Load `hint_min` and build its minimal traces (for the operand-forgery tests below). fn hint_min_traces() -> (Elf, Traces) { let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -3067,6 +3199,8 @@ fn test_verify_rejects_zero_table_counts() { hint: 0, commit: 0, blake3: 0, + p1w16: 0, + fp3mac: 0, }, ..vm_proof }; @@ -3161,6 +3295,8 @@ fn test_crafted_zero_count_proof_must_not_verify() { hint: 0, commit: 0, blake3: 0, + p1w16: 0, + fp3mac: 0, }; let airs = VmAirs::new( &elf, @@ -4485,6 +4621,8 @@ fn the_blake3_count_is_bound_into_the_statement() { hint: 1, commit: 1, blake3: 1, + p1w16: 0, + fp3mac: 0, }; let challenge_for = |counts: &crate::TableCounts| { @@ -4549,6 +4687,8 @@ fn a_blake3_count_above_one_is_rejected() { hint: 1, commit: 1, blake3: 1, + p1w16: 0, + fp3mac: 0, }; assert!(counts.validate().is_ok(), "1 is the honest maximum"); counts.blake3 = 0; diff --git a/prover/src/tests/skip_empty_tables_tests.rs b/prover/src/tests/skip_empty_tables_tests.rs index e93f681f2..a0058805b 100644 --- a/prover/src/tests/skip_empty_tables_tests.rs +++ b/prover/src/tests/skip_empty_tables_tests.rs @@ -101,6 +101,8 @@ fn every_table_participates_in_the_bus() { hint: 1, commit: 1, blake3: 1, + p1w16: 0, + fp3mac: 0, }; let airs = VmAirs::new( &elf, @@ -160,6 +162,8 @@ fn droppable_air_names(counts: &TableCounts) -> Vec<&'static str> { hint, commit, blake3, + p1w16, + fp3mac, } = counts; // ⛔ BLAKE3 IS DROPPABLE AND IS DELIBERATELY NOT IN THIS LIST — a // COVERAGE GAP, not an exemption. It belongs here on exactly the @@ -175,6 +179,9 @@ fn droppable_air_names(counts: &TableCounts) -> Vec<&'static str> { // silent, and it is a property of the TEST SET only: the production // elision reads `TableCounts`, never this list. let _ = blake3; + // P1W16 likewise: no fixture here calls its syscall. + let _ = p1w16; + let _ = fp3mac; [ ("CPU", cpu), ("LT", lt), diff --git a/prover/src/tests/statement_alignment_tests.rs b/prover/src/tests/statement_alignment_tests.rs index beabe47eb..8a297a895 100644 --- a/prover/src/tests/statement_alignment_tests.rs +++ b/prover/src/tests/statement_alignment_tests.rs @@ -351,6 +351,8 @@ fn counts() -> TableCounts { hint: 1, commit: 1, blake3: 1, + p1w16: 0, + fp3mac: 0, } } diff --git a/prover/src/tests/statement_tests.rs b/prover/src/tests/statement_tests.rs index 31068ebff..0543a26bd 100644 --- a/prover/src/tests/statement_tests.rs +++ b/prover/src/tests/statement_tests.rs @@ -30,6 +30,8 @@ fn sample_counts() -> TableCounts { hint: 1, commit: 1, blake3: 1, + p1w16: 0, + fp3mac: 0, } } @@ -100,6 +102,8 @@ fn each_count_mut(counts: &mut TableCounts) -> Vec<(&'static str, &mut usize)> { hint, commit, blake3, + p1w16, + fp3mac, } = counts; vec![ ("cpu", cpu), @@ -123,6 +127,8 @@ fn each_count_mut(counts: &mut TableCounts) -> Vec<(&'static str, &mut usize)> { ("hint", hint), ("commit", commit), ("blake3", blake3), + ("p1w16", p1w16), + ("fp3mac", fp3mac), ] } From 7ba8ee14980804d626d0f01730faeef1192c2082 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 21 Oct 2026 09:54:15 -0300 Subject: [PATCH 04/11] Add function scope markers to LFM emitters A test-only fn_scope recorder marks where an emitted LFM program enters and leaves a named region (each table of the batched argue, each p1 grinding check). The Field VM translation uses the marks to find repeated verifier functions and outline them. Markers are cfg(test); production emission is unchanged. --- prover/src/lfm/epoch.rs | 4 +++ prover/src/lfm/fn_scope.rs | 58 ++++++++++++++++++++++++++++++++++++ prover/src/lfm/mod.rs | 2 ++ prover/src/lfm/whir_batch.rs | 4 +++ 4 files changed, 68 insertions(+) create mode 100644 prover/src/lfm/fn_scope.rs diff --git a/prover/src/lfm/epoch.rs b/prover/src/lfm/epoch.rs index f94a5bb38..67d2bf3fd 100644 --- a/prover/src/lfm/epoch.rs +++ b/prover/src/lfm/epoch.rs @@ -710,6 +710,8 @@ fn emit_p1_grinding_check( use super::p1w16_emit::{Lane, w8_permute_lanes}; assert_eq!(seed.len(), 1, "a Poseidon1 transcript state is ONE cell"); + #[cfg(test)] + super::fn_scope::begin(b, format!("p1_grind_{factor}")); let [s0, s1, s2, s3] = b.unpack(seed[0]); let prefix = FE::from(u64::from_be_bytes(GRINDING_PREFIX)); let fac = FE::from(u64::from(factor) << 56); @@ -739,6 +741,8 @@ fn emit_p1_grinding_check( for bit in &bits[64 - factor as usize..] { b.assert_eq(Felt(bit.addr()), zero); } + #[cfg(test)] + super::fn_scope::end(b); } fn emit_algebraic_grinding_check( diff --git a/prover/src/lfm/fn_scope.rs b/prover/src/lfm/fn_scope.rs new file mode 100644 index 000000000..be261bd02 --- /dev/null +++ b/prover/src/lfm/fn_scope.rs @@ -0,0 +1,58 @@ +//! Test only: regions the emitter marks as one call of a named function, as +//! instruction ranges of the builder, for the Field VM translator to outline. +//! Recording only: nothing is emitted, and nothing is kept outside [`record`]. +//! Only outermost regions are recorded. + +use std::cell::RefCell; + +use super::builder::LfmBuilder; + +/// One call: instructions `start..end` of the program, named `name`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Scope { + pub name: String, + pub start: usize, + pub end: usize, +} + +#[derive(Default)] +struct Recorder { + open: Vec<(String, usize)>, + done: Vec, +} + +std::thread_local! { + static SCOPES: RefCell> = const { RefCell::new(None) }; +} + +pub fn begin(b: &LfmBuilder, name: impl Into) { + SCOPES.with(|s| { + if let Some(r) = s.borrow_mut().as_mut() { + r.open.push((name.into(), b.instrs().len())); + } + }); +} + +pub fn end(b: &LfmBuilder) { + SCOPES.with(|s| { + if let Some(r) = s.borrow_mut().as_mut() { + let (name, start) = r.open.pop().expect("an open scope"); + if r.open.is_empty() { + r.done.push(Scope { + name, + start, + end: b.instrs().len(), + }); + } + } + }); +} + +/// Runs `f` and returns the scopes its emission left, in order. +pub fn record(f: impl FnOnce() -> R) -> (R, Vec) { + let previous = SCOPES.with(|s| s.replace(Some(Recorder::default()))); + let out = f(); + let rec = SCOPES.with(|s| s.replace(previous)).unwrap_or_default(); + assert!(rec.open.is_empty(), "every scope ends"); + (out, rec.done) +} diff --git a/prover/src/lfm/mod.rs b/prover/src/lfm/mod.rs index 5105fbb45..4801333b6 100644 --- a/prover/src/lfm/mod.rs +++ b/prover/src/lfm/mod.rs @@ -173,6 +173,8 @@ mod epoch_verify_tests; #[cfg(test)] mod exec_identity_tests; #[cfg(test)] +pub(crate) mod fn_scope; +#[cfg(test)] mod framework_probe; #[cfg(test)] mod fri_group_tests; diff --git a/prover/src/lfm/whir_batch.rs b/prover/src/lfm/whir_batch.rs index a8b028a5d..318fe9426 100644 --- a/prover/src/lfm/whir_batch.rs +++ b/prover/src/lfm/whir_batch.rs @@ -235,6 +235,8 @@ pub fn emit_batched_argue( let betas = emit_challenge_powers(b, beta, max_roots); let mut rebuilt: Option = None; for (t, shape) in shapes.iter().enumerate() { + #[cfg(test)] + super::fn_scope::begin(b, "table"); let num_vars = shape.num_vars; let at = &r[..num_vars]; let (weight_r, weight_z) = weight_slots(shape.kinds.len()); @@ -269,6 +271,8 @@ pub fn emit_batched_argue( Some(padding) if num_vars < plan.num_vars => b.emul(value, padding), _ => value, }; + #[cfg(test)] + super::fn_scope::end(b); rebuilt = Some(match rebuilt { None => term, Some(acc) => b.emul_add(weights[t], term, acc), From 7134c8684f5933a9cb92631e78c2fcc724c570f4 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 21 Oct 2026 09:54:15 -0300 Subject: [PATCH 05/11] Generalize the LFM table verifier to any AIR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LFM verifier legs assumed the VM's tables: unit public inputs, no end-exempt transitions and only the LogUp accumulator read at g·z. The Field VM breaks all three: - emit_quotient multiplies each transition constraint by its end-exemption factor prod_j (zeta - g^(N-j)) before the shared division, from the artifact's constraints_meta. - TableVerifyShape takes the next-row columns from the AIR's own trace_ood_next_row_columns, as the STARK verifier's OodLayout does. - TableChallengeShape, TableVerifyShape and the harvest helpers are generic over the AIR's public inputs (DynVmAir). VM verification is unchanged: its tables have no exemptions and the same next-row set. The block-path scan follows the renamed generic try_child_arena_words. --- prover/src/lfm/constraint_tests.rs | 10 +++ prover/src/lfm/constraints.rs | 40 ++++++++-- prover/src/lfm/epoch.rs | 16 ++-- prover/src/lfm/epoch_verify.rs | 25 ++++-- prover/src/lfm/harvest.rs | 98 +++++++++++++++++++----- prover/src/lfm/per_table_census_tests.rs | 5 ++ prover/src/tests/block_whir_p1_tests.rs | 2 +- 7 files changed, 160 insertions(+), 36 deletions(-) diff --git a/prover/src/lfm/constraint_tests.rs b/prover/src/lfm/constraint_tests.rs index c1b9468eb..59d24737b 100644 --- a/prover/src/lfm/constraint_tests.rs +++ b/prover/src/lfm/constraint_tests.rs @@ -1058,6 +1058,11 @@ pub(super) fn open_sub_proof( quotient: QuotientShape { log2_trace_length: trace_length.trailing_zeros(), num_composition_parts: claimed_parts.len(), + transition_end_exemptions: artifact + .constraints_meta() + .iter() + .map(|m| m.end_exemptions) + .collect(), boundary, }, artifact, @@ -1427,6 +1432,11 @@ fn quotient_rows(artifact: &ConstraintArtifact, log2_trace_length: u32) -> usize let quotient = QuotientShape { log2_trace_length, num_composition_parts: num_parts, + transition_end_exemptions: artifact + .constraints_meta() + .iter() + .map(|m| m.end_exemptions) + .collect(), // Every table with bus interactions carries the framework's single // acc[0] = 0 constraint on the last aux column, and no production table // declares any other boundary constraint. diff --git a/prover/src/lfm/constraints.rs b/prover/src/lfm/constraints.rs index f76eea7b8..6090207b7 100644 --- a/prover/src/lfm/constraints.rs +++ b/prover/src/lfm/constraints.rs @@ -761,6 +761,10 @@ pub struct QuotientShape { /// composition evaluation is split into. Also shape: the verifier rejects a /// proof whose part count disagrees with the AIR. pub num_composition_parts: usize, + /// End-row exemptions in transition-constraint index order. The STARK + /// zerofier for constraint `i` is `(ζ^N - 1)^-1` times the product of + /// `(ζ - g^(N-j))` for each exempted final row `j`. + pub transition_end_exemptions: Vec, /// The AIR's boundary constraints. pub boundary: Vec, } @@ -780,13 +784,12 @@ pub struct QuotientEval { /// /// # One division, not one per constraint /// -/// Every production constraint applies to every row (`RowDomain::ALL`, measured -/// across all 28 tables), so `end_exemptions` is zero everywhere and all of an -/// AIR's constraints share the zerofier `Z = ζ^N − 1`. That lets the division -/// factor out of the β-power sum: +/// Constraints with no end exemptions share the zerofier `Z = ζ^N − 1`. For +/// each constraint with exemptions, its exemption polynomial is applied before +/// the common division: /// /// ```text -/// Σ_c β^c·C_c/Z = (Σ_c β^c·C_c)/Z +/// Σ_c β^c·C_c·P_c(ζ)/Z = (Σ_c β^c·C_c·P_c(ζ))/Z /// ``` /// /// one division per AIR rather than one per constraint. The boundary terms do @@ -833,6 +836,11 @@ pub fn emit_quotient( "the supplied parts must match the AIR's part count, which is shape and \ never read off the proof" ); + assert_eq!( + constraint_evals.len(), + shape.transition_end_exemptions.len(), + "transition exemption metadata must match the AIR constraint count" + ); let one = b.ext_const(&FEE::one()); @@ -865,7 +873,27 @@ pub fn emit_quotient( let quotient = b.emul(numerator, den_inv); terms.push(b.emul(quotient, zerofier)); } - terms.extend(constraint_evals.iter().rev().copied()); + let generator = + ::get_primitive_root_of_unity( + shape.log2_trace_length as u64, + ) + .expect("trace length has a primitive root"); + for (eval, &exemptions) in constraint_evals + .iter() + .zip(&shape.transition_end_exemptions) + .rev() + { + let mut corrected = *eval; + let decrement = generator.pow((1u64 << shape.log2_trace_length) - 1); + let mut root = decrement; + for _ in 0..exemptions { + let root_ext = b.felt_const(root).as_ext(); + let factor = b.esub(zeta, root_ext); + corrected = b.emul(corrected, factor); + root *= decrement; + } + terms.push(corrected); + } let mut acc = terms[0]; for t in &terms[1..] { diff --git a/prover/src/lfm/epoch.rs b/prover/src/lfm/epoch.rs index 67d2bf3fd..7e96f788b 100644 --- a/prover/src/lfm/epoch.rs +++ b/prover/src/lfm/epoch.rs @@ -56,10 +56,10 @@ use super::transcript_replay::{ByteString, TranscriptReplay}; /// A sub-proof's AIR as the shape derivations read it: the block's VM AIRs and /// the LFM chips alike. -pub type DynVmAir<'a> = dyn stark::traits::AIR< +pub type DynVmAir<'a, PI = ()> = dyn stark::traits::AIR< Field = crate::tables::types::GoldilocksField, FieldExtension = GoldilocksExtension, - PublicInputs = (), + PublicInputs = PI, > + 'a; /// The grinding prefix, `crypto/stark/src/grinding.rs`'s `PREFIX`. @@ -312,12 +312,16 @@ impl TableChallengeShape { /// `ood_blocks_well_formed`, the `L` presence to `has_trace_interaction`. /// /// `trace_length` must be a power of two; the caller bounds it. - pub fn derive( - air: &DynVmAir<'_>, + pub fn derive( + air: &DynVmAir<'_, PI>, index: usize, num_tables: usize, trace_length: usize, - ) -> Self { + ) -> Self + where + PI: rkyv::Archive + Clone, + ::Archived: rkyv::Deserialize, + { use stark::verifier::IsStarkVerifier; debug_assert!(trace_length.is_power_of_two(), "a power-of-two trace"); let opts = air.options(); @@ -326,7 +330,7 @@ impl TableChallengeShape { let layout = stark::verifier::Verifier::< crate::tables::types::GoldilocksField, GoldilocksExtension, - (), + PI, >::ood_layout(air); Self { index, diff --git a/prover/src/lfm/epoch_verify.rs b/prover/src/lfm/epoch_verify.rs index b5ad15171..229be52c7 100644 --- a/prover/src/lfm/epoch_verify.rs +++ b/prover/src/lfm/epoch_verify.rs @@ -191,11 +191,15 @@ impl TableVerifyShape { /// `trees` (its arity and arity-4 cap, `TreeGeometry::of::()`): the /// tree heights and path shapes that configuration's prover lays out, and /// the host's `StarkCaps` under it as the cross-check. - pub fn derive_with( - air: &super::epoch::DynVmAir<'_>, + pub fn derive_with( + air: &super::epoch::DynVmAir<'_, PI>, trace_length: usize, trees: stark::config::TreeGeometry, - ) -> Result<(Self, Analysis), String> { + ) -> Result<(Self, Analysis), String> + where + PI: rkyv::Archive + Clone, + ::Archived: rkyv::Deserialize, + { use stark::verifier::IsStarkVerifier; if !trace_length.is_power_of_two() { return Err(format!("trace length {trace_length} is not a power of two")); @@ -204,8 +208,14 @@ impl TableVerifyShape { let layout = stark::verifier::Verifier::< crate::tables::types::GoldilocksField, crate::tables::types::GoldilocksExtension, - (), + PI, >::ood_layout(air); + // Keep the machine-side DEEP shape tied to the AIR's declared read set + // at offset 1, exactly as the STARK verifier's OodLayout is. In + // particular, Field VM AIRs read main columns at `g·z`; those openings + // are authenticated by DEEP along with the LogUp accumulator. + let next_row_columns = air.trace_ood_next_row_columns(); + debug_assert_eq!(layout.next_row_cols(), next_row_columns.as_slice()); let artifact = stark::constraint_ir::ConstraintArtifact::capture(air); let (main_width, aux_width) = air.trace_layout(); @@ -241,7 +251,7 @@ impl TableVerifyShape { step_size: layout.step_size(), num_eval_points: artifact.shape.transition_offsets.len() * layout.step_size(), num_total_cols, - next_row_cols: layout.next_row_cols().to_vec(), + next_row_cols: next_row_columns, num_composition_parts: num_parts, log2_trace_length, }; @@ -270,6 +280,11 @@ impl TableVerifyShape { quotient: QuotientShape { log2_trace_length, num_composition_parts: num_parts, + transition_end_exemptions: artifact + .constraints_meta() + .iter() + .map(|m| m.end_exemptions) + .collect(), boundary: boundary_terms(has_aux_trace, num_total_cols), }, fri: FriShape::for_layout(opts, log2_lde_length, leaf_layout).with_trees(trees), diff --git a/prover/src/lfm/harvest.rs b/prover/src/lfm/harvest.rs index c80b7d3da..9c2c484e4 100644 --- a/prover/src/lfm/harvest.rs +++ b/prover/src/lfm/harvest.rs @@ -100,6 +100,34 @@ pub(crate) fn host_table_forked_under( fork: &mut Fam::Transcript, lookup_challenges: &[FEE], ) -> Result { + host_table_forked_with_inputs::( + air, + view, + &(), + index, + num_tables, + fork, + lookup_challenges, + ) +} + +/// [`host_table_forked_under`] for any proof public-input type. The inputs are +/// passed through to the production verifier replay, so FVM boundary claims +/// participate in the same proof-view path as unit-input LFM proofs. +pub(crate) fn host_table_forked_with_inputs( + air: &dyn AIR, + view: StarkProofView<'_, Gl, Ext3, PI>, + public_inputs: &PI, + index: usize, + num_tables: usize, + fork: &mut Fam::Transcript, + lookup_challenges: &[FEE], +) -> Result +where + Fam: crate::hash_pin::ProofFamily, + PI: rkyv::Archive + Clone, + ::Archived: rkyv::Deserialize, +{ use stark::domain::new_verifier_domain; use stark::verifier::IsStarkVerifier; type Verifier = @@ -107,11 +135,11 @@ pub(crate) fn host_table_forked_under( let trace_length = view.trace_length(); let domain = new_verifier_domain(air, trace_length); - let layout = Verifier::::ood_layout(air); - let challenges = Verifier::::replay_rounds_after_round_1( + let layout = Verifier::::ood_layout(air); + let challenges = Verifier::::replay_rounds_after_round_1( air, view, - &(), + public_inputs, &domain, fork, lookup_challenges.to_vec(), @@ -272,6 +300,24 @@ pub(crate) fn build_table_legs_with( iotas: Option<&[usize]>, wrap: super::edsl::WrapHash, ) -> Result { + build_table_legs_with_inputs(air, view, &(), rap_challenges, trees, iotas, wrap) +} + +/// [`build_table_legs_with`] for any proof public-input type. Table legs are +/// shaped by the AIR and proof; PI affects only the Stark view's type. +pub(crate) fn build_table_legs_with_inputs( + air: &dyn AIR, + view: StarkProofView<'_, Gl, Ext3, PI>, + public_inputs: &PI, + rap_challenges: &[FEE], + trees: stark::config::TreeGeometry, + iotas: Option<&[usize]>, + wrap: super::edsl::WrapHash, +) -> Result +where + PI: rkyv::Archive + Clone, + ::Archived: rkyv::Deserialize, +{ // The shapes, from the AIR and the trace length alone; the proof's blocks // are then checked against them, never read into them. let trace_length = view.trace_length(); @@ -399,7 +445,7 @@ pub(crate) fn build_table_legs_with( .map_err(|_| "a power-of-two trace length has a root of unity")?; let production_boundary = air .boundary_constraints( - &(), + public_inputs, rap_challenges, bus_public_inputs.as_ref(), trace_length, @@ -623,7 +669,7 @@ impl TableLegs { /// StarkProofView)`, so the same two functions read a wrap proof, a node proof /// and a VM epoch proof alike. That is what makes one node emitter serve every /// level. -pub(crate) struct HarvestedChild { +pub(crate) struct HarvestedChild { /// The child's proof family's wrap hash: the node reading the child runs /// it, and every digest of the child's arenas is laid at its width. pub(crate) wrap: super::edsl::WrapHash, @@ -641,6 +687,8 @@ pub(crate) struct HarvestedChild { /// `NodePublishSet::Diagnostic`. #[cfg_attr(not(test), allow(dead_code))] pub(crate) z_alpha: (FEE, FEE), + /// The proof's public-input type; arena layout is shared across PI types. + pub(crate) _public_inputs: std::marker::PhantomData, } /// [`harvest_child_under`] at the legacy pin, after verifying the proof against @@ -772,15 +820,29 @@ pub(crate) fn harvest_child_under( tables, legs, z_alpha: (lookup[0], lookup[1]), + _public_inputs: std::marker::PhantomData, }) } /// The child's arenas, in `declare_leg_arenas`' declaration order. -pub(crate) fn try_child_arena_words(c: &HarvestedChild) -> Result>, String> { +pub(crate) fn try_child_arena_words( + c: &HarvestedChild, +) -> Result>, String> { + try_child_arenas_for::(c.wrap, &c.public_words, &c.tables, &c.legs) +} + +/// Serialize a child's proof arenas for any Stark public-input type. Public +/// words are the child's published statement cells; table and leg arenas are +/// read from the same generic proof-view harvest above. +pub(crate) fn try_child_arenas_for( + wrap: super::edsl::WrapHash, + public_words: &[(u32, LfmWord)], + tables: &[HostTable], + legs: &[TableLegs], +) -> Result>, String> { let mut arenas: Vec> = Vec::new(); - // The published words, eight halves each — the statement's own layout. - let mut publics = Vec::with_capacity(8 * c.public_words.len()); - for (_, word) in &c.public_words { + let mut publics = Vec::with_capacity(8 * public_words.len()); + for (_, word) in public_words { for lane in word { let v: u64 = lane.canonical(); publics.push(base_word(FE::from(v & 0xFFFF_FFFF))); @@ -789,29 +851,26 @@ pub(crate) fn try_child_arena_words(c: &HarvestedChild) -> Result>(), - c.wrap, + &tables.iter().map(|h| h.main_root).collect::>(), + wrap, )); - for (h, leg) in c.tables.iter().zip(&c.legs) { + for (h, leg) in tables.iter().zip(legs) { if let Some(root) = &h.aux_root { - arenas.push(super::proof_arena::commitments_to_arena_for( - &[*root], - c.wrap, - )); + arenas.push(super::proof_arena::commitments_to_arena_for(&[*root], wrap)); } if let Some(l) = &h.contribution { arenas.push(vec![ext_word(l)]); } arenas.push(super::proof_arena::commitments_to_arena_for( &[h.composition_root], - c.wrap, + wrap, )); arenas.push(h.ood_current.iter().map(ext_word).collect()); arenas.push(h.ood_next.iter().map(ext_word).collect()); arenas.push(h.parts.iter().map(ext_word).collect()); arenas.push(super::proof_arena::commitments_to_arena_for( &h.fri_roots, - c.wrap, + wrap, )); arenas.push(h.fri_coeffs.iter().map(ext_word).collect()); if let Some(nonce) = h.nonce { @@ -821,5 +880,8 @@ pub(crate) fn try_child_arena_words(c: &HarvestedChild) -> Result; Ok(arenas) } diff --git a/prover/src/lfm/per_table_census_tests.rs b/prover/src/lfm/per_table_census_tests.rs index f63f67b9a..bb222e042 100644 --- a/prover/src/lfm/per_table_census_tests.rs +++ b/prover/src/lfm/per_table_census_tests.rs @@ -443,6 +443,11 @@ fn table_shape( quotient: QuotientShape { log2_trace_length, num_composition_parts: num_parts, + transition_end_exemptions: artifact + .constraints_meta() + .iter() + .map(|m| m.end_exemptions) + .collect(), boundary: boundary_terms(has_aux_trace, num_total_cols), }, fri, diff --git a/prover/src/tests/block_whir_p1_tests.rs b/prover/src/tests/block_whir_p1_tests.rs index ddf73d72e..c741855f6 100644 --- a/prover/src/tests/block_whir_p1_tests.rs +++ b/prover/src/tests/block_whir_p1_tests.rs @@ -738,7 +738,7 @@ mod scan { anchors: &[ "fn build_table_legs_with(", "fn harvest_child_under<", - "fn try_child_arena_words(", + "fn try_child_arena_words<", ], sites: &[], // The child's arenas are laid at its family's digest width, From c9bacb8239a9e7f6fcc9f7edb488ec15e5c59fa6 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 21 Oct 2026 09:54:15 -0300 Subject: [PATCH 06/11] Add the Field VM and its LFM translation The 3MI Field VM (#971 spec): one FMA instruction over Fp[t]/(t^3-2) with DECODE, MEM, PUBLIC and STATE buses (BusId 40-43), proved as a standalone STARK. Ported from field-vm/3mi-lfm onto the #1014 base, with a selector column, segment state on the FieldVmState bus and, in LFM mode, a second public sender at addr + 2^39 for lane 3 of each cell. lfm_translate turns an LFM verifier program into a Field VM program. On top of the port it outlines repeated verifier functions (found with fn_scope, grouped into shape classes) into CALL/RET bodies with frames, rerolls periodic blocks into loops (widened periods, FVM_REROLL_MIN; blocks that use the frame register are skipped) and decomposes bits with a Horner fold (FVM_BITDEC_HORNER). hash_side proves the hash half of a translated leaf with the LFM's chips. Features fvm-d3/d4/narrow/n1 select the AIR variant. --- prover/Cargo.toml | 8 + prover/src/field_vm/air.rs | 702 +++++++++++++ prover/src/field_vm/asm.rs | 195 ++++ prover/src/field_vm/bridge.rs | 135 +++ prover/src/field_vm/decode.rs | 142 +++ prover/src/field_vm/executor.rs | 403 ++++++++ prover/src/field_vm/hash_side.rs | 868 ++++++++++++++++ prover/src/field_vm/isa.rs | 184 ++++ prover/src/field_vm/lfm_compare.rs | 319 ++++++ prover/src/field_vm/lfm_translate.rs | 1417 ++++++++++++++++++++++++++ prover/src/field_vm/mem.rs | 223 ++++ prover/src/field_vm/mod.rs | 26 + prover/src/field_vm/mux.rs | 186 ++++ prover/src/field_vm/prove.rs | 505 +++++++++ prover/src/field_vm/prove_tests.rs | 651 ++++++++++++ prover/src/field_vm/tests.rs | 318 ++++++ prover/src/lib.rs | 1 + prover/src/tables/types.rs | 21 + 18 files changed, 6304 insertions(+) create mode 100644 prover/src/field_vm/air.rs create mode 100644 prover/src/field_vm/asm.rs create mode 100644 prover/src/field_vm/bridge.rs create mode 100644 prover/src/field_vm/decode.rs create mode 100644 prover/src/field_vm/executor.rs create mode 100644 prover/src/field_vm/hash_side.rs create mode 100644 prover/src/field_vm/isa.rs create mode 100644 prover/src/field_vm/lfm_compare.rs create mode 100644 prover/src/field_vm/lfm_translate.rs create mode 100644 prover/src/field_vm/mem.rs create mode 100644 prover/src/field_vm/mod.rs create mode 100644 prover/src/field_vm/mux.rs create mode 100644 prover/src/field_vm/prove.rs create mode 100644 prover/src/field_vm/prove_tests.rs create mode 100644 prover/src/field_vm/tests.rs diff --git a/prover/Cargo.toml b/prover/Cargo.toml index 6d6b59744..d55505719 100644 --- a/prover/Cargo.toml +++ b/prover/Cargo.toml @@ -22,6 +22,14 @@ instruments = ["stark/instruments"] nvtx = ["cuda", "instruments", "stark/nvtx"] profile-markers = ["stark/profile-markers"] disk-spill = ["stark/disk-spill"] +# Field VM at constraint degree 3, proved at blowup 2. +fvm-d3 = [] +# Field VM at constraint degree 4, proved at blowup 4. +fvm-d4 = [] +# Field VM with 2 general-purpose registers. +fvm-narrow = [] +# Field VM with a single general-purpose register. +fvm-n1 = [] # The BLAKE3 round count, for the `LFM_HASH` arm and — since the P-a flip — for # the commitment backends the whole prover commits under. Off = 7 rounds # (standard BLAKE3, externally anchored); on = the 6-round A6R variant, which is diff --git a/prover/src/field_vm/air.rs b/prover/src/field_vm/air.rs new file mode 100644 index 000000000..c253f4cf2 --- /dev/null +++ b/prover/src/field_vm/air.rs @@ -0,0 +1,702 @@ +//! The `FIELD_VM` chip: one row per state. +//! +//! Spec: `spec/src/field_vm.toml`. Every `ExtField` variable takes three base +//! columns, and every `ExtField` constraint is emitted as three base +//! constraints with the `x³ = 2` reduction. + +use core::ops::{Add, Mul, Sub}; + +use stark::constraints::boundary::BoundaryConstraint; +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet, RowDomain}; +use stark::lookup::{ + BoundaryConstraintBuilder, BusInteraction, BusValue, LinearTerm, Multiplicity, Packing, +}; +use stark::trace::TraceTable; + +use super::executor::{Execution, State, Step}; +use super::isa::{Instr, N, NUM_REGS, Program}; +use super::mux::{D, MuxTables, Split, T, split_powers}; +use crate::tables::types::{BusId, FE, FEE, GoldilocksExtension, GoldilocksField, zeroed_fe_vec}; + +type F = GoldilocksField; +type E = GoldilocksExtension; + +pub mod cols { + use super::super::isa::N; + use super::super::mux::T; + + pub const PC: usize = 0; + /// `argument_registers[4]`, for `d, a, b, c`. + pub const ARG_REG: usize = 1; + /// `argument_scalars[4]`. + pub const ARG_SCALE: usize = ARG_REG + 4; + /// `argument_offsets[4]`. + pub const ARG_OFFSET: usize = ARG_SCALE + 4; + /// `mem_flags[4]`. + pub const MEM_FLAG: usize = ARG_OFFSET + 4; + /// `hint_input[N]`. + pub const HINT_IN: usize = MEM_FLAG + 4; + pub const HINT_OUT: usize = HINT_IN + N; + /// `registers[N]`, three columns each. + pub const REGS: usize = HINT_OUT + 1; + pub const ZERO: usize = REGS + 3 * N; + pub const OUT_INV: usize = ZERO + 1; + /// `arg_reg_pows_computed[4][T]`. + pub const POWS: usize = OUT_INV + 3; + /// The base address of each argument that reads memory (the spec's + /// `args_premem`, kept only where it differs from `args`). + pub const ADDR: usize = POWS + 4 * T; + pub const ARGS: usize = ADDR + 4; + pub const SEL: usize = ARGS + 12; + pub const NUM_COLUMNS: usize = SEL + 1; + + pub const fn reg(g: usize) -> usize { + REGS + 3 * g + } + /// `x_i^{split_exponent(k)}` for `k = 1..=T`. + pub const fn pow(i: usize, k: usize) -> usize { + POWS + i * T + (k - 1) + } + pub const fn addr(i: usize) -> usize { + ADDR + i + } + pub const fn arg(i: usize) -> usize { + ARGS + 3 * i + } +} + +/// Bit `i` of the packed flags: `mem_flags` (0..4), `hint_output` (4), `hint_input` (5..). +pub const FLAG_HINT_OUT: u32 = 4; +pub const FLAG_HINT_IN: u32 = 5; +/// Bits per register index in the packed argument registers. +pub const REG_BITS: u32 = 8; + +const _: () = assert!(FLAG_HINT_IN as usize + N <= 64 && NUM_REGS <= 1 << REG_BITS); + +/// The eleven base values of one `FIELD_VM_DECODE` tuple: `pc`, the leftover +/// `imm0`/`imm1` of `c`, the other `imm0`s, the other `imm1`s, the flags and +/// the packed register indices. +pub fn decode_tuple(pc: u64, instr: &Instr) -> [FE; 11] { + let args = instr.args(); + let mut flags = 0u64; + for (i, a) in args.iter().enumerate() { + flags |= (a.mem as u64) << i; + } + flags |= (instr.hint_out as u64) << FLAG_HINT_OUT; + for g in 0..N { + flags |= (instr.hint_in[g] as u64) << (FLAG_HINT_IN as usize + g); + } + let regs = args.iter().enumerate().fold(0u64, |acc, (i, a)| { + acc | (a.reg as u64) << (REG_BITS as usize * i) + }); + [ + FE::from(pc), + args[3].scale, + args[3].offset, + args[0].scale, + args[1].scale, + args[2].scale, + args[0].offset, + args[1].offset, + args[2].offset, + FE::from(flags), + FE::from(regs), + ] +} + +// ========================================================================= +// Trace generation +// ========================================================================= + +fn set_ext(row: &mut [FE], col: usize, v: &FEE) { + row[col..col + 3].copy_from_slice(v.value()); +} + +/// One FIELD_VM row; `out_inv` is `args[0]⁻¹`, or zero when it is zero. +fn fill_row(row: &mut [FE], program: &Program, step: &Step, out_inv: &FEE) { + let State { zero, pc, regs } = step.state; + let instr = &program.instrs[pc as usize]; + row[cols::PC] = FE::from(pc); + for (i, a) in instr.args().iter().enumerate() { + let x = FE::from(a.reg as u64); + row[cols::ARG_REG + i] = x; + row[cols::ARG_SCALE + i] = a.scale; + row[cols::ARG_OFFSET + i] = a.offset; + row[cols::MEM_FLAG + i] = FE::from(a.mem as u64); + let pows = split_powers(x); + for (k, p) in pows.iter().enumerate().skip(1) { + row[cols::pow(i, k)] = *p; + } + if a.mem { + row[cols::addr(i)] = step.args_premem[i].value()[0]; + } + set_ext(row, cols::arg(i), &step.args[i]); + } + for (g, r) in regs.iter().enumerate() { + row[cols::HINT_IN + g] = FE::from(instr.hint_in[g] as u64); + set_ext(row, cols::reg(g), r); + } + row[cols::HINT_OUT] = FE::from(instr.hint_out as u64); + row[cols::ZERO] = FE::from(zero as u64); + set_ext(row, cols::OUT_INV, out_inv); +} + +/// Rows per block of the parallel fill; each block inverts its `args[0]` in +/// one batch. +const FILL_BLOCK: usize = 4096; + +/// A FIELD_VM table of `rows` rows whose row `r` holds `step(r)`. +fn fill_table<'a>( + program: &Program, + rows: usize, + step: impl Fn(usize) -> &'a Step + Sync, +) -> TraceTable { + let width = cols::NUM_COLUMNS; + let mut data = zeroed_fe_vec(rows * width); + let fill = |(b, block): (usize, &mut [FE])| { + let first = b * FILL_BLOCK; + let n = block.len() / width; + let mut inv: Vec = (0..n) + .map(|r| { + let d = step(first + r).args[0]; + if d == FEE::zero() { FEE::one() } else { d } + }) + .collect(); + FEE::inplace_batch_inverse(&mut inv).expect("no zero left to invert"); + for (r, row) in block.chunks_mut(width).enumerate() { + let st = step(first + r); + let out_inv = if st.args[0] == FEE::zero() { + FEE::zero() + } else { + inv[r] + }; + fill_row(row, program, st, &out_inv); + } + }; + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + data.par_chunks_mut(FILL_BLOCK * width) + .enumerate() + .for_each(fill); + } + #[cfg(not(feature = "parallel"))] + data.chunks_mut(FILL_BLOCK * width) + .enumerate() + .for_each(fill); + TraceTable::new_main(data, width, 1) +} + +/// The padding share (in percent) a single power-of-two table may waste +/// before it is split: `FVM_SEGMENT_WASTE`, default 25. Shared with DECODE. +pub fn max_waste_percent() -> usize { + std::env::var("FVM_SEGMENT_WASTE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(25) +} + +/// Whether `rows` padded rows over `used` real ones stay within the waste bound. +pub fn within_waste(used: usize, rows: usize) -> bool { + (rows - used) * 100 <= rows * max_waste_percent() +} + +/// A split must save at least this share (in percent) of the whole table's +/// rows to pay for the extra table: `FVM_SPLIT_MIN_SAVE`, default 0. +pub fn min_save_percent() -> usize { + std::env::var("FVM_SPLIT_MIN_SAVE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0) +} + +/// Whether splitting `left` into `half` and the rest saves enough of `total`. +pub fn split_pays(left: usize, half: usize, total: usize, min_rows: usize) -> bool { + let full = left.next_power_of_two().max(min_rows); + let split = half + (left - half).next_power_of_two().max(min_rows); + full.saturating_sub(split) * 100 >= total * min_save_percent() +} + +/// At most this many FIELD_VM segments when no cap forces more. +pub const MAX_SEGMENTS: usize = 4; + +/// `(real steps, rows)` per segment. A segment that is not the last holds +/// `rows − 1` steps and repeats the next segment's first row as its last, so +/// the transition out of its last step is checked inside it. A single +/// power-of-two table is used when it wastes at most a quarter of its rows. +/// `max_rows` caps every segment (tests use it to force long chains). +pub fn segment_plan(steps: usize, min_rows: usize, max_rows: Option) -> Vec<(usize, usize)> { + // `FVM_ONE_SEGMENT`: one segment, so the boundary states are the start + // and the halt, both program text. + if max_rows.is_none() && std::env::var_os("FVM_ONE_SEGMENT").is_some() { + return vec![(steps, steps.next_power_of_two().max(min_rows))]; + } + let cap = max_rows.unwrap_or(usize::MAX); + let mut plan = Vec::new(); + let mut left = steps; + loop { + let full = left.next_power_of_two().max(min_rows); + let rows = (full / 2).min(cap).max(min_rows); + let fits = full <= cap; + let last_allowed = max_rows.is_none() && plan.len() + 1 == MAX_SEGMENTS; + let pays = max_rows.is_some() || split_pays(left, rows - 1, steps, min_rows); + if fits && (within_waste(left, full) || last_allowed || rows > left || !pays) { + plan.push((left, full)); + return plan; + } + plan.push((rows - 1, rows)); + left -= rows - 1; + } +} + +pub struct Segments { + pub traces: Vec>, + pub public: Vec, + /// DECODE multiplicities, padding and repeated rows included. + pub decode_mult: Vec, + /// MEM multiplicities, repeated rows included. + pub mem_mult: Vec, +} + +/// The FIELD_VM segments of `exec`, the last padded with halt steps. +pub fn generate_segments( + program: &Program, + exec: &Execution, + min_rows: usize, + max_rows: Option, +) -> Segments { + let halt = *exec + .steps + .last() + .expect("an execution ends with a halt step"); + let plan = segment_plan(exec.steps.len(), min_rows, max_rows); + let mut decode_mult = exec.decode_mult.clone(); + let mut mem_mult = exec.mem_mult.clone(); + let (mut traces, mut public) = (Vec::new(), Vec::new()); + let mut start = 0; + for (k, &(real, rows)) in plan.iter().enumerate() { + let last = k + 1 == plan.len(); + let mut trace = fill_table(program, rows, |row| { + if row < real { + &exec.steps[start + row] + } else if last { + &halt + } else { + &exec.steps[start + real] + } + }); + // The state bus is signed: each segment contributes +1 at its first + // state and -1 at its final state. The transition constraint below + // leaves just these two endpoint rows available for nonzero SEL. + trace.main_table.set(0, cols::SEL, FE::one()); + trace.main_table.set(rows - 1, cols::SEL, -FE::one()); + if last { + decode_mult[halt.state.pc as usize] += (rows - real) as u64; + } else { + // The repeated row looks up the program and memory once more. + let repeat = &exec.steps[start + real]; + decode_mult[repeat.state.pc as usize] += 1; + let instr = &program.instrs[repeat.state.pc as usize]; + for (i, a) in instr.args().iter().enumerate() { + if a.mem { + mem_mult[repeat.args_premem[i].value()[0].canonical() as usize] += 1; + } + } + } + public.push(FvmPublicInputs { + last_row: rows - 1, + first: state_words(&exec.steps[start].state), + last: if last { + halt_words() + } else { + state_words(&exec.steps[start + real].state) + }, + }); + traces.push(trace); + start += real; + } + Segments { + traces, + public, + decode_mult, + mem_mult, + } +} + +// ========================================================================= +// Bus interactions +// ========================================================================= + +fn direct(c: usize) -> BusValue { + BusValue::Packed { + start_column: c, + packing: Packing::Direct, + } +} + +/// - **Sends** `FIELD_VM_DECODE[decode_tuple]` once per row. +/// - **Sends** `FIELD_VM_MEM[addr[i], 0, 0] -> args[i]` when `mem_flags[i]`. +pub fn bus_interactions() -> Vec { + let mut flags: Vec = (0..4) + .map(|i| LinearTerm::Column { + coefficient: 1 << i, + column: cols::MEM_FLAG + i, + }) + .collect(); + flags.push(LinearTerm::Column { + coefficient: 1 << FLAG_HINT_OUT, + column: cols::HINT_OUT, + }); + flags.extend((0..N).map(|g| LinearTerm::Column { + coefficient: 1 << (FLAG_HINT_IN as usize + g), + column: cols::HINT_IN + g, + })); + let regs = (0..4) + .map(|i| LinearTerm::Column { + coefficient: 1 << (REG_BITS as usize * i), + column: cols::ARG_REG + i, + }) + .collect(); + + let mut decode = vec![ + direct(cols::PC), + direct(cols::ARG_SCALE + 3), + direct(cols::ARG_OFFSET + 3), + ]; + decode.extend((0..3).map(|i| direct(cols::ARG_SCALE + i))); + decode.extend((0..3).map(|i| direct(cols::ARG_OFFSET + i))); + decode.push(BusValue::linear(flags)); + decode.push(BusValue::linear(regs)); + + let mut interactions = vec![BusInteraction::sender( + BusId::FieldVmDecode, + Multiplicity::One, + decode, + )]; + interactions.push(BusInteraction::sender( + BusId::FieldVmState, + Multiplicity::Column(cols::SEL), + [cols::PC, cols::ZERO] + .into_iter() + .chain(cols::REGS..cols::REGS + 3 * N) + .map(direct) + .collect(), + )); + for i in 0..4 { + let values = (0..3) + .map(|k| { + if k == 0 { + direct(cols::addr(i)) + } else { + BusValue::constant(0) + } + }) + .chain((0..3).map(|k| direct(cols::arg(i) + k))) + .collect(); + interactions.push(BusInteraction::sender( + BusId::FieldVmMem, + Multiplicity::Column(cols::MEM_FLAG + i), + values, + )); + } + interactions +} + +// ========================================================================= +// Constraints +// ========================================================================= + +type Ext3 = [X; 3]; + +fn ext_add>(a: &Ext3, b: &Ext3) -> Ext3 { + core::array::from_fn(|k| a[k].clone() + b[k].clone()) +} + +fn ext_sub>(a: &Ext3, b: &Ext3) -> Ext3 { + core::array::from_fn(|k| a[k].clone() - b[k].clone()) +} + +fn ext_scale>(s: &X, a: &Ext3) -> Ext3 { + core::array::from_fn(|k| s.clone() * a[k].clone()) +} + +/// `a · b` in `F[w]/(w³ − 2)`. +fn ext_mul + Mul>( + a: &Ext3, + b: &Ext3, + two: &X, +) -> Ext3 { + let m = |i: usize, j: usize| a[i].clone() * b[j].clone(); + [ + m(0, 0) + two.clone() * (m(1, 2) + m(2, 1)), + m(0, 1) + m(1, 0) + two.clone() * m(2, 2), + m(0, 2) + m(1, 1) + m(2, 0), + ] +} + +#[derive(Clone, Default)] +pub struct FieldVmConstraints { + tables: MuxTables, +} + +impl FieldVmConstraints { + pub fn new() -> Self { + Self::default() + } +} + +struct Emitter { + idx: usize, +} + +impl Emitter { + fn all>(&mut self, b: &mut B, e: B::Expr) { + b.emit_base(self.idx, e); + self.idx += 1; + } + + fn next>(&mut self, b: &mut B, e: B::Expr) { + b.emit_base_rows(self.idx, RowDomain::except_last(1), e); + self.idx += 1; + } +} + +fn ext_col>(b: &B, off: usize, col: usize) -> Ext3 { + core::array::from_fn(|k| b.main(off, col + k)) +} + +fn all_regs>(b: &B, off: usize) -> Vec> { + let mut regs = vec![ + [b.main(off, cols::ZERO), b.zero(), b.zero()], + [b.main(off, cols::PC), b.zero(), b.zero()], + ]; + regs.extend((0..N).map(|g| ext_col(b, off, cols::reg(g)))); + regs +} + +/// `Σ_k pow_{i,k} Σ_l s[k][l] x_i^l`, skipping zero coefficients so the +/// captured degree is the real one. +fn eval_split>(b: &B, s: &Split, i: usize) -> B::Expr { + let x = b.main(0, cols::ARG_REG + i); + let mut acc: Option = None; + for (k, split) in s.iter().enumerate() { + let mut inner: Option = None; + let mut x_pow: Option = None; + for c in split.iter() { + if *c != FE::zero() { + let coeff = b.const_base(c.canonical()); + let term = match &x_pow { + None => coeff, + Some(p) => coeff * p.clone(), + }; + inner = Some(match inner { + None => term, + Some(acc) => acc + term, + }); + } + x_pow = Some(match x_pow { + None => x.clone(), + Some(p) => p * x.clone(), + }); + } + let Some(inner) = inner else { continue }; + let term = if k == 0 { + inner + } else { + b.main(0, cols::pow(i, k)) * inner + }; + acc = Some(match acc { + None => term, + Some(a) => a + term, + }); + } + acc.unwrap_or_else(|| b.zero()) +} + +impl ConstraintSet for FieldVmConstraints { + fn max_degree(&self) -> usize { + D + } + + fn eval>(&self, b: &mut B) { + let mut em = Emitter { idx: 0 }; + let one = b.one(); + let two = b.const_base(2); + let bit = |b: &B, c: usize| { + let x = b.main(0, c); + x.clone() * (x - b.one()) + }; + + // SEL is supported only at rows 0 and h-1. On transition rows + // 0..h-3 this zeros SEL at the next row, covering rows 1..h-2; + // the first and final rows remain the segment boundary claims. + b.emit_base_rows(em.idx, RowDomain::except_last(2), b.main(1, cols::SEL)); + em.idx += 1; + + // decode + for i in 0..4 { + let e = bit(b, cols::MEM_FLAG + i); + em.all(b, e); + } + for g in 0..N { + let e = bit(b, cols::HINT_IN + g); + em.all(b, e); + } + let e = bit(b, cols::HINT_OUT); + em.all(b, e); + for i in 0..4 { + let e = eval_split(b, &self.tables.range, i); + em.all(b, e); + } + + // mux + for i in 0..4 { + let x = b.main(0, cols::ARG_REG + i); + let mut prev: Option = None; + for k in (1..).take(T) { + let step = if k == 1 { D - 1 } else { D - 2 }; + let mut p = prev.clone().unwrap_or_else(|| b.one()); + for _ in 0..step { + p = p * x.clone(); + } + let e = b.main(0, cols::pow(i, k)) - p; + em.all(b, e); + prev = Some(b.main(0, cols::pow(i, k))); + } + } + let premem = |b: &B, i: usize, off: usize| -> Ext3 { + let regs = all_regs(b, off); + let mut sum: Ext3 = [b.zero(), b.zero(), b.zero()]; + for (j, r) in regs.iter().enumerate() { + let sel = eval_split(b, &self.tables.mux[j], i); + sum = ext_add(&sum, &ext_scale(&sel, r)); + } + let scaled = ext_scale(&b.main(0, cols::ARG_SCALE + i), &sum); + [ + scaled[0].clone() + b.main(0, cols::ARG_OFFSET + i), + scaled[1].clone(), + scaled[2].clone(), + ] + }; + // memory: before the lookup an argument is its base address when it + // reads memory, and its value otherwise. + let before_lookup = |b: &B, i: usize| -> Ext3 { + let mem = b.main(0, cols::MEM_FLAG + i); + let not_mem = b.one() - mem.clone(); + let arg = ext_col(b, 0, cols::arg(i)); + [ + mem * b.main(0, cols::addr(i)) + not_mem.clone() * arg[0].clone(), + not_mem.clone() * arg[1].clone(), + not_mem * arg[2].clone(), + ] + }; + for i in 1..4 { + for e in ext_sub(&before_lookup(b, i), &premem(b, i, 0)) { + em.all(b, e); + } + } + for e in ext_sub(&before_lookup(b, 0), &premem(b, 0, 1)) { + em.next(b, e); + } + + // fma + let args: Vec> = (0..4).map(|i| ext_col(b, 0, cols::arg(i))).collect(); + let rhs = ext_add(&ext_mul(&args[1], &args[2], &two), &args[3]); + for e in ext_sub(&args[0], &rhs) { + em.all(b, e); + } + + // transition + let not_hint_out = one.clone() - b.main(0, cols::HINT_OUT); + for g in 0..N { + let not_hinted_next = one.clone() - b.main(1, cols::HINT_IN + g); + let not_out_reg = one.clone() - eval_split(b, &self.tables.mux[g + 2], 0); + let diff = ext_sub(&ext_col(b, 1, cols::reg(g)), &ext_col(b, 0, cols::reg(g))); + for e in diff.iter() { + em.next( + b, + not_hinted_next.clone() * not_hint_out.clone() * e.clone(), + ); + } + for e in diff { + em.next(b, not_hinted_next.clone() * not_out_reg.clone() * e); + } + } + let pc_step = b.main(1, cols::PC) - b.main(0, cols::PC) - one.clone(); + em.next(b, not_hint_out * pc_step.clone()); + let not_out_pc = one.clone() - eval_split(b, &self.tables.mux[1], 0); + em.next(b, not_out_pc * pc_step); + let e = bit(b, cols::ZERO); + em.all(b, e); + let zero_next = b.main(1, cols::ZERO); + for e in args[0].iter() { + em.next(b, zero_next.clone() * e.clone()); + } + let not_zero_next = one.clone() - zero_next; + let prod = ext_mul(&args[0], &ext_col(b, 0, cols::OUT_INV), &two); + let target = [one.clone(), b.zero(), b.zero()]; + for e in ext_sub(&prod, &target) { + em.next(b, not_zero_next.clone() * e); + } + } +} + +// ========================================================================= +// Boundary constraints +// ========================================================================= + +/// The state a boundary row pins: `pc`, `ZERO`, then every register +/// component, as canonical words. +pub const STATE_WORDS: usize = 2 + 3 * N; + +pub fn state_words(state: &State) -> Vec { + let mut w = Vec::with_capacity(STATE_WORDS); + w.push(state.pc); + w.push(state.zero as u64); + for r in &state.regs { + w.extend(r.value().iter().map(|c| c.canonical())); + } + w +} + +/// The start state: `PC = 1`, `ZERO = 0`, registers `0`. +pub fn start_words() -> Vec { + state_words(&State::initial()) +} + +/// The halt state: `PC = 0`, `ZERO = 1`, registers `0`. +pub fn halt_words() -> Vec { + let mut w = vec![0; STATE_WORDS]; + w[1] = 1; + w +} + +#[derive( + Clone, Debug, Default, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, +)] +pub struct FvmPublicInputs { + /// Index of the last FIELD_VM row; checked against the proof's trace length. + pub last_row: usize, + /// The segment's first-row state ([`state_words`]). + pub first: Vec, + /// The segment's last-row state: the next segment's first, or the halt. + pub last: Vec, +} + +/// First and last rows pinned to the segment's boundary states. +pub struct FieldVmBoundary; + +impl BoundaryConstraintBuilder for FieldVmBoundary { + fn boundary_constraints(pi: &FvmPublicInputs, _: &[FEE]) -> Vec> { + let columns = [cols::PC, cols::ZERO] + .into_iter() + .chain(cols::REGS..cols::REGS + 3 * N); + let mut out = Vec::with_capacity(2 * STATE_WORDS); + for (step, words) in [(0, &pi.first), (pi.last_row, &pi.last)] { + for (c, w) in columns.clone().zip(words) { + out.push(BoundaryConstraint::new_main(c, step, FEE::from(*w))); + } + } + out + } +} diff --git a/prover/src/field_vm/asm.rs b/prover/src/field_vm/asm.rs new file mode 100644 index 000000000..eeb8196c3 --- /dev/null +++ b/prover/src/field_vm/asm.rs @@ -0,0 +1,195 @@ +//! Assembler for the Field VM, with the pseudoinstructions of the spec's +//! "Instruction notation" table and its calling convention. + +use super::isa::{Arg, ENTRY_PC, Instr, Program, REG_PC, REG_ZERO, gpr_index}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Label(usize); + +#[derive(Clone, Copy, Debug)] +enum Fixup { + /// `FMA PC == target`. + Jump, + /// `FMA PC == ZERO * (-PC + (target - 1)) + (PC + 1)`. + JumpZero, + /// `FMA PC == ZERO * (PC - target) + (ZERO + target)`. + JumpNotZero, +} + +#[derive(Default)] +pub struct Asm { + body: Vec, + labels: Vec>, + fixups: Vec<(usize, Label, Fixup)>, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum AsmError { + UnboundLabel(Label), + Invalid { pc: usize, reason: &'static str }, +} + +impl Asm { + pub fn new() -> Self { + Self::default() + } + + /// Address of the next emitted instruction. + pub fn here(&self) -> u64 { + ENTRY_PC + self.body.len() as u64 + } + + pub fn label(&mut self) -> Label { + self.labels.push(None); + Label(self.labels.len() - 1) + } + + pub fn bind(&mut self, l: Label) { + assert!(self.labels[l.0].is_none(), "label bound twice"); + self.labels[l.0] = Some(self.here()); + } + + pub fn emit(&mut self, instr: Instr) -> &mut Self { + self.body.push(instr); + self + } + + /// Marks general-purpose registers as input-hinted by the last instruction. + pub fn hint_in(&mut self, regs: &[u8]) -> &mut Self { + let last = self.body.last_mut().expect("no instruction to hint"); + for &r in regs { + let g = gpr_index(r).expect("only general-purpose registers can be input-hinted"); + last.hint_in[g] = true; + } + self + } + + /// `FMA d == a * b + c`. + pub fn fma(&mut self, d: Arg, a: Arg, b: Arg, c: Arg) -> &mut Self { + self.emit(Instr::new(d, a, b, c, false)) + } + + /// `FMA d == a * b + c, hint out`. + pub fn fma_out(&mut self, d: Arg, a: Arg, b: Arg, c: Arg) -> &mut Self { + self.emit(Instr::new(d, a, b, c, true)) + } + + pub fn add(&mut self, d: Arg, a: Arg, b: Arg) -> &mut Self { + self.fma_out(d, Arg::imm(1), a, b) + } + + /// `d = a - b`. + pub fn sub(&mut self, d: Arg, a: Arg, b: Arg) -> &mut Self { + self.fma_out(d, Arg::imm(-1), b, a) + } + + pub fn mul(&mut self, d: Arg, a: Arg, b: Arg) -> &mut Self { + self.fma_out(d, a, b, Arg::imm(0)) + } + + pub fn mov(&mut self, d: Arg, a: Arg) -> &mut Self { + self.fma_out(d, Arg::imm(0), Arg::imm(0), a) + } + + /// Asserts `MEM[addr] == v`, fixing the cell if it is still free. + pub fn store(&mut self, addr: Arg, v: Arg) -> &mut Self { + assert!(addr.mem); + self.fma(addr, Arg::imm(0), Arg::imm(0), v) + } + + /// `reg = a^{-1}` as `FMA reg == (a + 1) * reg - 1, hint reg`. + /// `a` must not dereference memory nor use `reg`. + pub fn inv(&mut self, reg: u8, a: Arg) -> &mut Self { + assert!( + !a.mem && a.reg != reg, + "INV: `a` must be a register expression not using `reg`" + ); + let a1 = Arg { + offset: a.offset + math::field::element::FieldElement::one(), + ..a + }; + self.fma(Arg::reg(reg), a1, Arg::reg(reg), Arg::imm(-1)) + .hint_in(&[reg]) + } + + fn jump_kind(&mut self, target: Label, kind: Fixup) -> &mut Self { + self.fixups.push((self.body.len(), target, kind)); + self.fma_out(Arg::reg(REG_PC), Arg::imm(0), Arg::imm(0), Arg::imm(0)) + } + + pub fn jump(&mut self, target: Label) -> &mut Self { + self.jump_kind(target, Fixup::Jump) + } + + /// Jumps when the previous instruction's `d` was zero. + pub fn jump_zero(&mut self, target: Label) -> &mut Self { + self.jump_kind(target, Fixup::JumpZero) + } + + /// Jumps when the previous instruction's `d` was nonzero. + pub fn jump_not_zero(&mut self, target: Label) -> &mut Self { + self.jump_kind(target, Fixup::JumpNotZero) + } + + /// `FMA PC == 0, hint out`: jump into the halt loop. + pub fn halt(&mut self) -> &mut Self { + self.fma_out(Arg::reg(REG_PC), Arg::imm(0), Arg::imm(0), Arg::imm(0)) + } + + /// The spec's `CALL`. The new frame pointer is an output hint the oracle + /// answers. `saved` registers go to `MEM[fp - 1 - i]`, `args` (evaluated + /// with the new `fp`) to `MEM[fp + 2 + j]`; the callee leaves its result at + /// `MEM[fp + 2 + args.len()]`, which lands in `ret` before the caller's + /// registers and `fp` are restored. + pub fn call( + &mut self, + target: Label, + fp: u8, + saved: &[u8], + args: &[Arg], + ret: Option, + ) -> &mut Self { + self.fma_out(Arg::mem(fp, 0), Arg::imm(0), Arg::imm(0), Arg::reg(fp)); + for (i, &r) in saved.iter().enumerate() { + self.store(Arg::mem(fp, -1 - i as i64), Arg::reg(r)); + } + for (j, &a) in args.iter().enumerate() { + self.store(Arg::mem(fp, 2 + j as i64), a); + } + self.store(Arg::mem(fp, 1), Arg::lin(REG_PC, 1, 2)); + self.jump(target); + if let Some(r) = ret { + self.mov(Arg::reg(r), Arg::mem(fp, 2 + args.len() as i64)); + } + for (i, &r) in saved.iter().enumerate() { + self.mov(Arg::reg(r), Arg::mem(fp, -1 - i as i64)); + } + self.mov(Arg::reg(fp), Arg::mem(fp, 0)) + } + + /// `FMA PC == MEM[fp + 1], hint out`. + pub fn ret(&mut self, fp: u8) -> &mut Self { + self.fma_out(Arg::reg(REG_PC), Arg::imm(0), Arg::imm(0), Arg::mem(fp, 1)) + } + + pub fn finish(mut self) -> Result { + for &(idx, label, kind) in &self.fixups { + let t = self.labels[label.0].ok_or(AsmError::UnboundLabel(label))? as i64; + let instr = &mut self.body[idx]; + match kind { + Fixup::Jump => instr.c = Arg::imm(t), + Fixup::JumpZero => { + instr.a = Arg::reg(REG_ZERO); + instr.b = Arg::lin(REG_PC, -1, t - 1); + instr.c = Arg::lin(REG_PC, 1, 1); + } + Fixup::JumpNotZero => { + instr.a = Arg::reg(REG_ZERO); + instr.b = Arg::lin(REG_PC, 1, -t); + instr.c = Arg::lin(REG_ZERO, 1, t); + } + } + } + Program::new(self.body).map_err(|(pc, reason)| AsmError::Invalid { pc, reason }) + } +} diff --git a/prover/src/field_vm/bridge.rs b/prover/src/field_vm/bridge.rs new file mode 100644 index 000000000..65d04afce --- /dev/null +++ b/prover/src/field_vm/bridge.rs @@ -0,0 +1,135 @@ +//! The `FIELD_VM_BRIDGE` chip: the cells that cross between the Field VM's +//! memory and the LFM hash-side chips' `LfmMem` bus. +//! +//! One preprocessed row per crossing cell. Every row reads the cell from MEM +//! (`FIELD_VM_MEM` sender, like any Field VM read); an outgoing row then +//! sends it on `LfmMem` with the program's read count, an incoming row +//! receives the hash side's write of it. MEM holds three lanes, `LfmMem` +//! four: the fourth is a witness column, pinned to the preprocessed `L3C` +//! unless the row is a word (a hint, or an incoming cell). + +use stark::config::Commitment; +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; +use stark::lookup::{BusInteraction, BusValue, Multiplicity, Packing}; +use stark::proof::options::ProofOptions; +use stark::trace::TraceTable; + +use crate::tables::types::{BusId, FE, GoldilocksExtension, GoldilocksField, zeroed_fe_vec}; + +type F = GoldilocksField; +type E = GoldilocksExtension; + +pub mod cols { + /// The cell's Field VM memory address. + pub const FADDR: usize = 0; + /// The cell's LFM address. + pub const LADDR: usize = 1; + pub const MULT_OUT: usize = 2; + pub const IS_IN: usize = 3; + pub const IS_WORD: usize = 4; + pub const L3C: usize = 5; + pub const IS_REAL: usize = 6; + pub const NUM_PRECOMPUTED_COLS: usize = 7; + pub const V0: usize = NUM_PRECOMPUTED_COLS; + pub const NUM_COLUMNS: usize = V0 + 4; +} + +#[derive(Clone, Debug)] +pub struct BridgeRow { + pub faddr: u64, + pub laddr: u64, + /// Hash-side reads of an outgoing cell; zero for an incoming one. + pub mult_out: u64, + pub is_in: bool, + pub is_word: bool, + /// Lane 3 of a non-word outgoing cell. + pub l3c: FE, +} + +fn fill_prep(rows: &[BridgeRow], min_rows: usize) -> TraceTable { + let height = rows.len().next_power_of_two().max(min_rows); + let mut trace = TraceTable::new_main( + zeroed_fe_vec(height * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let t = &mut trace.main_table; + for (i, r) in rows.iter().enumerate() { + t.set(i, cols::FADDR, FE::from(r.faddr)); + t.set(i, cols::LADDR, FE::from(r.laddr)); + t.set(i, cols::MULT_OUT, FE::from(r.mult_out)); + t.set(i, cols::IS_IN, FE::from(r.is_in as u64)); + t.set(i, cols::IS_WORD, FE::from(r.is_word as u64)); + t.set(i, cols::L3C, r.l3c); + t.set(i, cols::IS_REAL, FE::one()); + } + trace +} + +/// `value(i)` is the four-lane word of row `i`. +pub fn generate_trace( + rows: &[BridgeRow], + value: impl Fn(usize) -> [FE; 4], + min_rows: usize, +) -> TraceTable { + let mut trace = fill_prep(rows, min_rows); + let t = &mut trace.main_table; + for i in 0..rows.len() { + for (k, v) in value(i).into_iter().enumerate() { + t.set(i, cols::V0 + k, v); + } + } + trace +} + +/// The Merkle root of the preprocessed columns. +pub fn commitment(rows: &[BridgeRow], options: &ProofOptions, min_rows: usize) -> Commitment { + let trace = fill_prep(rows, min_rows); + let columns: Vec> = (0..cols::NUM_PRECOMPUTED_COLS) + .map(|c| { + (0..trace.num_rows()) + .map(|r| *trace.main_table.get(r, c)) + .collect() + }) + .collect(); + crate::field_vm::prove::commit_prep(&columns, options) +} + +pub fn bus_interactions() -> Vec { + let direct = |c| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + let word = || { + let mut v = vec![direct(cols::LADDR)]; + v.extend((0..4).map(|k| direct(cols::V0 + k))); + v + }; + vec![ + BusInteraction::sender( + BusId::FieldVmMem, + Multiplicity::Column(cols::IS_REAL), + vec![ + direct(cols::FADDR), + BusValue::constant(0), + BusValue::constant(0), + direct(cols::V0), + direct(cols::V0 + 1), + direct(cols::V0 + 2), + ], + ), + BusInteraction::sender(BusId::LfmMem, Multiplicity::Column(cols::MULT_OUT), word()), + BusInteraction::receiver(BusId::LfmMem, Multiplicity::Column(cols::IS_IN), word()), + ] +} + +#[derive(Clone, Copy, Default)] +pub struct BridgeConstraints; + +impl ConstraintSet for BridgeConstraints { + fn eval>(&self, b: &mut B) { + let free = b.one() - b.main(0, cols::IS_WORD); + let lane3 = b.main(0, cols::V0 + 3) - b.main(0, cols::L3C); + b.emit_base(0, free * lane3); + } +} diff --git a/prover/src/field_vm/decode.rs b/prover/src/field_vm/decode.rs new file mode 100644 index 000000000..a4e515be3 --- /dev/null +++ b/prover/src/field_vm/decode.rs @@ -0,0 +1,142 @@ +//! The `FIELD_VM_DECODE` chip: the program, as preprocessed columns, plus the +//! per-proof multiplicity. +//! +//! Spec: `spec/src/field_vm_decode.toml`. The four packed `ExtField` columns +//! of the spec are eleven base columns here (the `X²` part of the flags +//! column is always zero, so it is not materialized). +//! +//! Padding rows repeat the halt entry, so they add no instruction to the program. + +use stark::config::Commitment; +use stark::lookup::{BusInteraction, BusValue, Multiplicity, Packing}; +use stark::proof::options::ProofOptions; +use stark::trace::TraceTable; + +use super::air::decode_tuple; +use super::isa::{HALT_PC, Program}; +use crate::tables::types::{BusId, FE, GoldilocksExtension, GoldilocksField, zeroed_fe_vec}; + +pub const NUM_PRECOMPUTED_COLS: usize = 11; +pub const MU: usize = NUM_PRECOMPUTED_COLS; +pub const NUM_COLUMNS: usize = NUM_PRECOMPUTED_COLS + 1; + +/// At most this many DECODE tables. +pub const MAX_CHUNKS: usize = 4; + +/// `(first pc, instructions, rows)` per DECODE table. One power-of-two table +/// when it wastes at most a quarter of its rows, otherwise the largest power of +/// two that fits and the rest. `FVM_DECODE_ONE_CHUNK`: always one table, so +/// the table count and heights grow monotonically with the program (a +/// recursion's shape fixed point needs that). +pub fn chunks(program: &Program, min_rows: usize) -> Vec<(usize, usize, usize)> { + let mut plan = Vec::new(); + let (mut start, mut left) = (0, program.len()); + if std::env::var_os("FVM_DECODE_ONE_CHUNK").is_some() { + return vec![(0, left, left.next_power_of_two().max(min_rows))]; + } + loop { + let full = left.next_power_of_two().max(min_rows); + let half = full / 2; + if super::air::within_waste(left, full) + || plan.len() + 1 == MAX_CHUNKS + || half < min_rows + || half >= left + || !super::air::split_pays(left, half, program.len(), min_rows) + { + plan.push((start, left, full)); + return plan; + } + plan.push((start, half, half)); + start += half; + left -= half; + } +} + +/// One trace per chunk; `mult[pc]` executions of each address. Padding rows +/// repeat the halt entry, with zero multiplicity. +pub fn generate_traces( + program: &Program, + mult: &[u64], + min_rows: usize, +) -> Vec> { + let halt = decode_tuple(HALT_PC, &program.instrs[HALT_PC as usize]); + chunks(program, min_rows) + .into_iter() + .map(|(start, len, rows)| { + let mut trace = TraceTable::new_main(zeroed_fe_vec(rows * NUM_COLUMNS), NUM_COLUMNS, 1); + let t = &mut trace.main_table; + for row in 0..rows { + let pc = start + row; + let tuple = if row < len { + decode_tuple(pc as u64, &program.instrs[pc]) + } else { + halt + }; + for (c, v) in tuple.into_iter().enumerate() { + t.set(row, c, v); + } + if row < len { + t.set(row, MU, FE::from(mult.get(pc).copied().unwrap_or(0))); + } + } + trace + }) + .collect() +} + +pub fn bus_interactions() -> Vec { + vec![BusInteraction::receiver( + BusId::FieldVmDecode, + Multiplicity::Column(MU), + (0..NUM_PRECOMPUTED_COLS) + .map(|c| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }) + .collect(), + )] +} + +/// The program id: per DECODE table, the Merkle root over the LDE of its +/// preprocessed columns, committed the way the prover commits the trace. +pub fn program_commitment( + program: &Program, + options: &ProofOptions, + min_rows: usize, +) -> Vec { + generate_traces(program, &[], min_rows) + .iter() + .map(|trace| { + let columns: Vec> = (0..NUM_PRECOMPUTED_COLS) + .map(|c| { + (0..trace.num_rows()) + .map(|r| *trace.main_table.get(r, c)) + .collect() + }) + .collect(); + crate::field_vm::prove::commit_prep(&columns, options) + }) + .collect() +} + +/// [`program_commitment`] under the trace trees' leaf `layout`. +pub fn program_commitment_for( + program: &Program, + options: &ProofOptions, + min_rows: usize, + layout: stark::leaf_layout::LeafLayout, +) -> Vec { + generate_traces(program, &[], min_rows) + .iter() + .map(|trace| { + let columns: Vec> = (0..NUM_PRECOMPUTED_COLS) + .map(|c| { + (0..trace.num_rows()) + .map(|r| *trace.main_table.get(r, c)) + .collect() + }) + .collect(); + crate::field_vm::prove::commit_prep_with(&columns, options, layout) + }) + .collect() +} diff --git a/prover/src/field_vm/executor.rs b/prover/src/field_vm/executor.rs new file mode 100644 index 000000000..36345d7fe --- /dev/null +++ b/prover/src/field_vm/executor.rs @@ -0,0 +1,403 @@ +//! Field VM executor. +//! +//! Produces one [`Step`] per trace row. Row `i` holds the state instruction +//! `pc_i` acts on, *after* that instruction's input hints were applied; the +//! instruction's `d` argument reads row `i + 1`. +//! +//! `MEM` is read-only for the proof, but its contents are chosen by the prover: +//! a `d` argument that dereferences a cell nobody has fixed yet defines it as +//! `a * b + c`. Any other read of an unfixed cell is an error. +//! +//! Hints are resolved in this order: +//! - output hint, `d` a register expression with `imm0 != 0`: solved for the +//! register; the halt loop's input hints are all zero; +//! - a single input hint whose instruction is linear in it (e.g. `INV`): solved; +//! - anything else: asked to the [`HintOracle`]. + +use super::isa::{ + Arg, HALT_PC, Instr, N, NUM_REGS, Program, REG_PC, REG_ZERO, START_PC, gpr, gpr_index, +}; +use crate::tables::types::{FE, FEE}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct State { + pub zero: bool, + pub pc: u64, + pub regs: [FEE; N], +} + +impl State { + pub fn initial() -> Self { + Self { + zero: false, + pc: START_PC, + regs: [FEE::zero(); N], + } + } + + /// All registers in register-index order. + pub fn all_regs(&self) -> [FEE; NUM_REGS] { + let mut all = [FEE::zero(); NUM_REGS]; + all[REG_ZERO as usize] = if self.zero { FEE::one() } else { FEE::zero() }; + all[REG_PC as usize] = FEE::from(self.pc); + all[2..].copy_from_slice(&self.regs); + all + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Step { + pub state: State, + pub args_premem: [FEE; 4], + pub args: [FEE; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HintKind { + Output, + Input, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HintRequest { + pub kind: HintKind, + pub pc: u64, + pub reg: u8, + /// For an output hint, the value `a * b + c` that `d` must equal. + pub target: Option, +} + +pub trait HintOracle { + fn hint(&mut self, req: &HintRequest) -> Option; +} + +impl Option> HintOracle for F { + fn hint(&mut self, req: &HintRequest) -> Option { + self(req) + } +} + +pub struct NoHints; + +impl HintOracle for NoHints { + fn hint(&mut self, _: &HintRequest) -> Option { + None + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExecError { + PcOutOfRange { pc: u64, target: FEE }, + BadAddress { pc: u64, addr: FEE }, + UninitializedRead { pc: u64, addr: u64 }, + FmaViolation { pc: u64 }, + HintCollision { pc: u64, reg: u8 }, + UnresolvedHint { pc: u64, reg: u8 }, + StepLimit, +} + +#[derive(Clone, Debug)] +pub struct Memory { + cells: Vec>, + mult: Vec, +} + +impl Memory { + pub fn new(size: usize) -> Self { + Self { + cells: vec![None; size], + mult: vec![0; size], + } + } + + /// A memory whose first cells are `init` (e.g. the proof being verified). + pub fn with_init(size: usize, init: &[FEE]) -> Self { + assert!(init.len() <= size); + let mut mem = Self::new(size); + for (cell, v) in mem.cells.iter_mut().zip(init) { + *cell = Some(*v); + } + mem + } + + pub fn len(&self) -> usize { + self.cells.len() + } + + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } + + pub fn get(&self, addr: usize) -> Option { + self.cells[addr] + } + + fn addr(&self, pc: u64, v: &FEE) -> Result { + let [c0, c1, c2] = v.value(); + let a = c0.canonical(); + if *c1 != FE::zero() || *c2 != FE::zero() || a >= self.cells.len() as u64 { + return Err(ExecError::BadAddress { pc, addr: *v }); + } + Ok(a as usize) + } + + fn peek(&self, pc: u64, addr: &FEE) -> Result, ExecError> { + Ok(self.cells[self.addr(pc, addr)?]) + } + + fn read(&mut self, pc: u64, addr: &FEE) -> Result { + let a = self.addr(pc, addr)?; + let v = self.cells[a].ok_or(ExecError::UninitializedRead { pc, addr: a as u64 })?; + self.mult[a] += 1; + Ok(v) + } + + /// Reads `MEM[addr]`, defining it as `v` if nothing fixed it yet. + fn read_or_define(&mut self, pc: u64, addr: &FEE, v: FEE) -> Result { + let a = self.addr(pc, addr)?; + let cell = *self.cells[a].get_or_insert(v); + self.mult[a] += 1; + Ok(cell) + } +} + +#[derive(Clone, Debug)] +pub struct Execution { + /// One step per executed instruction, ending with the first halt step. + pub steps: Vec, + /// Final memory contents; cells never fixed read as zero. + pub mem: Vec, + /// Number of lookups into each memory cell. + pub mem_mult: Vec, + /// Number of executed steps per program address. + pub decode_mult: Vec, +} + +fn arg_value( + pc: u64, + arg: &Arg, + regs: &[FEE; NUM_REGS], + mem: &mut Memory, +) -> Result<(FEE, FEE), ExecError> { + let premem = arg.premem(regs); + let v = if arg.mem { + mem.read(pc, &premem)? + } else { + premem + }; + Ok((premem, v)) +} + +/// `d - (a * b + c)` for `instr` with general-purpose register `g` set to `x`, +/// assuming no output hint (the future state keeps `x`). `None` when a memory +/// argument is not fixed, or its address depends on `x`. +fn residual_at(instr: &Instr, state: &State, g: usize, x: FEE, mem: &Memory) -> Option { + let mut cur = *state; + cur.regs[g] = x; + let regs = cur.all_regs(); + let mut next = cur; + next.pc += 1; + let next_regs = next.all_regs(); + let value = |arg: &Arg, regs: &[FEE; NUM_REGS]| -> Option { + let premem = arg.premem(regs); + if !arg.mem { + return Some(premem); + } + if gpr_index(arg.reg) == Some(g) && arg.scale != FE::zero() { + return None; + } + mem.peek(state.pc, &premem).ok().flatten() + }; + if instr.d.reg == REG_ZERO && instr.d.scale != FE::zero() { + return None; + } + let d = value(&instr.d, &next_regs)?; + let a = value(&instr.a, ®s)?; + let b = value(&instr.b, ®s)?; + let c = value(&instr.c, ®s)?; + Some(d - (a * b + c)) +} + +/// Solves the single input-hinted register `g` of `instr` when the +/// constraint is linear in it. +fn solve_input_hint(instr: &Instr, state: &State, g: usize, mem: &Memory) -> Option { + if instr.hint_out { + return None; + } + let f0 = residual_at(instr, state, g, FEE::zero(), mem)?; + let f1 = residual_at(instr, state, g, FEE::one(), mem)?; + let f2 = residual_at(instr, state, g, FEE::from(2u64), mem)?; + let two = FEE::from(2u64); + let quad = (f2 - f1 * two + f0) * two.inv().ok()?; + if quad != FEE::zero() { + return None; + } + let lin = f1 - f0; + Some(-f0 * lin.inv().ok()?) +} + +fn instr_at(program: &Program, pc: u64, target: FEE) -> Result<&Instr, ExecError> { + program + .instrs + .get(pc as usize) + .ok_or(ExecError::PcOutOfRange { pc, target }) +} + +/// Applies `instr`'s input hints to `state`, which it is about to act on. +fn apply_input_hints( + instr: &Instr, + state: &mut State, + mem: &Memory, + oracle: &mut dyn HintOracle, +) -> Result<(), ExecError> { + if !instr.hint_in.contains(&true) { + return Ok(()); + } + let hinted: Vec = (0..N).filter(|&g| instr.hint_in[g]).collect(); + if state.pc == HALT_PC { + state.regs = [FEE::zero(); N]; + return Ok(()); + } + let solved = match hinted.as_slice() { + [g] => solve_input_hint(instr, state, *g, mem).map(|x| (*g, x)), + _ => None, + }; + for g in hinted { + let x = match solved { + Some((sg, x)) if sg == g => x, + _ => oracle + .hint(&HintRequest { + kind: HintKind::Input, + pc: state.pc, + reg: gpr(g), + target: None, + }) + .ok_or(ExecError::UnresolvedHint { + pc: state.pc, + reg: gpr(g), + })?, + }; + state.regs[g] = x; + } + Ok(()) +} + +pub fn execute( + program: &Program, + mem: Memory, + oracle: &mut dyn HintOracle, + max_steps: usize, +) -> Result { + run(program, mem, oracle, max_steps, None) +} + +/// [`execute`] going on past FMA violations, with the PC of each: a witness +/// that breaks constraints, for proving it anyway. +#[cfg(test)] +pub(crate) fn execute_violating( + program: &Program, + mem: Memory, + oracle: &mut dyn HintOracle, + max_steps: usize, +) -> Result<(Execution, Vec), ExecError> { + let mut violations = Vec::new(); + let exec = run(program, mem, oracle, max_steps, Some(&mut violations))?; + Ok((exec, violations)) +} + +fn run( + program: &Program, + mut mem: Memory, + oracle: &mut dyn HintOracle, + max_steps: usize, + mut violations: Option<&mut Vec>, +) -> Result { + let mut decode_mult = vec![0u64; program.len()]; + let mut steps = Vec::new(); + let mut cur = State::initial(); + + loop { + if steps.len() >= max_steps { + return Err(ExecError::StepLimit); + } + let pc = cur.pc; + let instr = *instr_at(program, pc, FEE::from(pc))?; + let regs = cur.all_regs(); + + let (a_pre, a) = arg_value(pc, &instr.a, ®s, &mut mem)?; + let (b_pre, b) = arg_value(pc, &instr.b, ®s, &mut mem)?; + let (c_pre, c) = arg_value(pc, &instr.c, ®s, &mut mem)?; + let target = a * b + c; + + let mut next = cur; + next.pc = pc + 1; + let d = instr.d; + if instr.hint_out { + let solved = if !d.mem && d.scale != FE::zero() { + Some( + (target - d.offset.to_extension()) + * d.scale.inv().expect("nonzero").to_extension(), + ) + } else { + oracle.hint(&HintRequest { + kind: HintKind::Output, + pc, + reg: d.reg, + target: Some(target), + }) + } + .ok_or(ExecError::UnresolvedHint { pc, reg: d.reg })?; + match gpr_index(d.reg) { + Some(g) => next.regs[g] = solved, + None => { + let [c0, c1, c2] = solved.value(); + if *c1 != FE::zero() || *c2 != FE::zero() { + return Err(ExecError::PcOutOfRange { pc, target: solved }); + } + next.pc = c0.canonical(); + } + } + } + + let next_instr = *instr_at(program, next.pc, FEE::from(next.pc))?; + if let Some(g) = gpr_index(d.reg) + && next_instr.hint_in[g] + { + return Err(ExecError::HintCollision { pc, reg: d.reg }); + } + apply_input_hints(&next_instr, &mut next, &mem, oracle)?; + + let d_pre = d.premem(&next.all_regs()); + let d_val = if d.mem { + mem.read_or_define(pc, &d_pre, target)? + } else { + d_pre + }; + if d_val != target { + match violations.as_deref_mut() { + Some(v) => v.push(pc), + None => return Err(ExecError::FmaViolation { pc }), + } + } + next.zero = d_val == FEE::zero(); + + decode_mult[pc as usize] += 1; + steps.push(Step { + state: cur, + args_premem: [d_pre, a_pre, b_pre, c_pre], + args: [d_val, a, b, c], + }); + if pc == HALT_PC { + break; + } + cur = next; + } + + let mem_vals = mem.cells.iter().map(|c| c.unwrap_or(FEE::zero())).collect(); + Ok(Execution { + steps, + mem: mem_vals, + mem_mult: mem.mult, + decode_mult, + }) +} diff --git a/prover/src/field_vm/hash_side.rs b/prover/src/field_vm/hash_side.rs new file mode 100644 index 000000000..1123c0c0a --- /dev/null +++ b/prover/src/field_vm/hash_side.rs @@ -0,0 +1,868 @@ +//! The Field VM with the LFM's hash-side chips: `LFM_HASH`, `LFM_LANES` and +//! `LFM_BITDEC` proved in the same `multi_prove`, joined to MEM by +//! `FIELD_VM_BRIDGE`. +//! +//! The LFM program splits in two. The field half (constants, ALU, selects, +//! hints, publics) is translated to the Field VM as before; the hash half +//! (hash, pack, unpack, bit decomposition) is compiled as an LFM program of +//! its own, where every cell it reads from the field half is a hint +//! placeholder. The placeholders are not proved: `FIELD_VM_BRIDGE` sends +//! those cells from MEM instead, with the multiplicities the compiler gave +//! the placeholders, and receives the hash half's writes that the field half +//! reads or publishes (the compiler counts one extra read of each). + +use stark::lookup::{AirWithBuses, AuxiliaryTraceBuildData, BusInteraction}; +use stark::proof::options::ProofOptions; +use stark::trace::TraceTable; + +use super::air::FvmPublicInputs; +use super::bridge::{self, BridgeConstraints, BridgeRow}; +use super::prove::ExtraAir; +use crate::lfm::builder::{ArenaSchema, LfmProgramSource}; +use crate::lfm::chips::{balu, bitdec, const_, hash, keccak, lanes, select, xalu}; +use crate::lfm::compiler::{LfmProgram, compile}; +use crate::lfm::executor::LfmExecution; +use crate::lfm::hash::HasherKind; +use crate::lfm::instr::{Addr, Instr}; +use crate::lfm::layout; +use crate::lfm::registry::LfmArtifacts; +use crate::lfm::word::LfmWord; +use crate::tables::types::{FE, GoldilocksExtension, GoldilocksField}; +use crate::tables::{bitwise, keccak_rc, keccak_rnd}; +use stark::constraints::builder::{ConstraintSet, EmptyConstraints}; +use stark::lookup::NullBoundaryConstraintBuilder; + +type F = GoldilocksField; +type E = GoldilocksExtension; + +/// Selects move words (Merkle path order), whose fourth lane the Field VM +/// does not hold, so they go with the hash half too. +/// `FVM_HYBRID_ALL` moves everything but publics: the LFM's chips alone, +/// as a control. +pub fn is_hash_side(i: &Instr) -> bool { + if std::env::var_os("FVM_HYBRID_ALL").is_some() { + return !matches!(i, Instr::Public { .. }); + } + matches!( + i, + Instr::Hash { .. } + | Instr::Hash16(_) + | Instr::Select { .. } + | Instr::Pack { .. } + | Instr::Unpack { .. } + | Instr::BitDec { .. } + | Instr::KeccakF(_) + | Instr::Blake3(_) + ) +} + +/// A cell the field half hands to the hash half. +#[derive(Clone, Debug)] +pub struct OutCell { + pub addr: u64, + pub is_word: bool, + pub l3c: FE, +} + +pub struct HashSide { + /// The hash half, placeholders first. + pub sub: LfmProgram, + /// The program's arenas, then the placeholders' values. + pub arenas: Vec>, + /// In placeholder order. + pub out: Vec, + /// Hash-half writes the field half reads or publishes. + pub back: Vec, + /// Whether [`renumber`] moved the hash half onto MEM's addresses, which + /// retires BRIDGE: MEM itself sends and receives the crossing cells. + pub merged: bool, +} + +impl HashSide { + /// The LFM addresses the Field VM's memory must keep. + pub fn crossing(&self) -> Vec { + self.out + .iter() + .map(|c| c.addr) + .chain(self.back.iter().copied()) + .collect() + } +} + +/// With `FVM_HYBRID_HINTS`, hints the hash half reads stay LFM hints +/// (`LFM_HINT`) instead of crossing from MEM. +pub fn split(program: &LfmProgram, exec: &LfmExecution, arenas: &[Vec]) -> HashSide { + split_with(program, arenas, &|a| { + exec.memory.get(Addr(a)).expect("an executed cell") + }) +} + +/// [`split`] with the hash half's input words from `word`: the split's shape +/// is the program's alone. +pub fn split_with( + program: &LfmProgram, + arenas: &[Vec], + word: &dyn Fn(u64) -> LfmWord, +) -> HashSide { + let n = program.num_addrs as usize; + let mut hash_reads = vec![0u64; n]; + let mut buf = Vec::new(); + for i in program.instrs.iter().filter(|i| is_hash_side(i)) { + buf.clear(); + i.reads_into(&mut buf); + for a in &buf { + hash_reads[a.0 as usize] += 1; + } + } + let move_hints = std::env::var_os("FVM_HYBRID_HINTS").is_some(); + let moves = |i: &Instr| { + is_hash_side(i) + || (move_hints + && match i { + Instr::Hint { out, .. } => hash_reads[out.0 as usize] > 0, + Instr::Const { out, value, .. } => { + hash_reads[out.0 as usize] > 0 && value[3] != FE::zero() + } + _ => false, + }) + }; + let mut hash_written = vec![false; n]; + let mut field_writer: Vec> = vec![None; n]; + let mut field_read = vec![false; n]; + for i in &program.instrs { + let hs = moves(i); + buf.clear(); + i.writes_into(&mut buf); + for a in &buf { + if hs { + hash_written[a.0 as usize] = true; + } else { + field_writer[a.0 as usize] = Some(i); + } + } + if !hs { + buf.clear(); + i.reads_into(&mut buf); + for a in &buf { + field_read[a.0 as usize] = true; + } + } + } + let mut out = Vec::new(); + let mut back = Vec::new(); + for a in 0..n { + if hash_written[a] { + if field_read[a] { + back.push(a as u64); + } + } else if hash_reads[a] > 0 { + let (is_word, l3c) = match field_writer[a] { + Some(Instr::Hint { .. }) => (true, FE::zero()), + Some(Instr::Const { value, .. }) => (false, value[3]), + Some(_) => (false, FE::zero()), + None => panic!("hash half reads unwritten cell {a}"), + }; + out.push(OutCell { + addr: a as u64, + is_word, + l3c, + }); + } + } + let mut read_counts = hash_reads.clone(); + for &a in &back { + read_counts[a as usize] += 1; + } + let placeholder_arena = arenas.len() as u32; + let mut instrs: Vec = out + .iter() + .enumerate() + .map(|(k, c)| Instr::Hint { + arena: placeholder_arena, + index: k as u32, + out: Addr(c.addr), + mult: 0, + }) + .collect(); + instrs.extend(program.instrs.iter().filter(|i| moves(i)).cloned()); + let mut arenas = arenas.to_vec(); + arenas.push(out.iter().map(|c| word(c.addr)).collect()); + let mut lens = program.arena_schema.lens.clone(); + lens.push(out.len() as u32); + let sub = compile(LfmProgramSource { + instrs, + num_addrs: program.num_addrs, + read_counts, + arena_schema: ArenaSchema { lens }, + public_len: 0, + }); + HashSide { + sub, + arenas, + out, + back, + merged: false, + } +} + +fn remap(i: &mut Instr, f: &dyn Fn(Addr) -> Addr) { + let m = |a: &mut Addr| *a = f(*a); + match i { + Instr::Const { out, .. } | Instr::Hint { out, .. } => m(out), + Instr::BaseAlu { out, a, b, c, .. } | Instr::ExtAlu { out, a, b, c, .. } => { + [out, a, b, c].into_iter().for_each(m) + } + Instr::Select { + bit, + out_l, + out_r, + in_l, + in_r, + .. + } => [bit, out_l, out_r, in_l, in_r].into_iter().for_each(m), + Instr::BitDec { + input, + bits, + halves, + } => { + m(input); + bits.iter_mut().for_each(|(a, _)| m(a)); + if let Some(hs) = halves { + hs.iter_mut().for_each(|(a, _)| m(a)); + } + } + Instr::Hash { ins, outs, .. } => ins.iter_mut().chain(outs.iter_mut()).for_each(m), + Instr::Hash16(h) => h.ins.iter_mut().chain(h.outs.iter_mut()).for_each(m), + Instr::Pack { lanes, out, .. } => { + lanes.iter_mut().for_each(m); + m(out) + } + Instr::Unpack { input, outs, .. } => { + m(input); + outs.iter_mut().for_each(m) + } + Instr::KeccakF(k) => { + k.ins + .iter_mut() + .chain(k.block.iter_mut()) + .chain(k.outs.iter_mut()) + .for_each(m); + if let Some(rev) = &mut k.rev { + rev.outs.iter_mut().for_each(m); + } + } + Instr::Blake3(k) => { + k.ins.iter_mut().chain(k.outs.iter_mut()).for_each(m); + if let Some(rev) = &mut k.rev { + rev.outs.iter_mut().for_each(m); + } + } + Instr::Public { addr, .. } => m(addr), + } +} + +/// Renumbers the hash half so every crossing cell takes its Field VM address +/// and every other cell an address past MEM's `mem_len`. +pub fn renumber(side: &mut HashSide, faddr: &dyn Fn(u64) -> u64, mem_len: u64) { + assert!( + side.out.iter().all(|c| !c.is_word && c.l3c == FE::zero()), + "MEM sends a zero fourth lane" + ); + let n = side.sub.num_addrs as usize; + let mut crossing = vec![false; n]; + for a in side.crossing() { + crossing[a as usize] = true; + } + let mut next = mem_len; + let map: Vec = (0..n) + .map(|a| { + if crossing[a] { + faddr(a as u64) + } else { + next += 1; + next - 1 + } + }) + .collect(); + let f = |a: Addr| Addr(map[a.0 as usize]); + let mut read_counts = vec![0u64; next as usize]; + let mut buf = Vec::new(); + let mut instrs = side.sub.instrs.clone(); + for i in instrs.iter_mut() { + buf.clear(); + i.reads_into(&mut buf); + for a in &buf { + read_counts[map[a.0 as usize] as usize] += 1; + } + remap(i, &f); + } + for &a in &side.back { + read_counts[map[a as usize] as usize] += 1; + } + side.sub = compile(LfmProgramSource { + instrs, + num_addrs: next, + read_counts, + arena_schema: side.sub.arena_schema.clone(), + public_len: 0, + }); + for c in side.out.iter_mut() { + c.addr = map[c.addr as usize]; + } + for a in side.back.iter_mut() { + *a = map[*a as usize]; + } + side.merged = true; +} + +/// The bridge rows, with each cell's Field VM address from `faddr`. +pub fn bridge_rows(side: &HashSide, faddr: impl Fn(u64) -> u64) -> Vec { + let mults: Vec = side.sub.instrs[..side.out.len()] + .iter() + .map(|i| match i { + Instr::Hint { mult, .. } => *mult, + _ => unreachable!("placeholders lead the hash half"), + }) + .collect(); + side.out + .iter() + .zip(mults) + .map(|(c, m)| BridgeRow { + faddr: faddr(c.addr), + laddr: c.addr, + mult_out: m, + is_in: false, + is_word: c.is_word, + l3c: c.l3c, + }) + .chain(side.back.iter().map(|&a| BridgeRow { + faddr: faddr(a), + laddr: a, + mult_out: 0, + is_in: true, + is_word: true, + l3c: FE::zero(), + })) + .collect() +} + +/// `LFM_HINT` over the hints the hash half keeps (placeholders excluded), +/// values from `values` or zero. +fn hint_table(side: &HashSide, values: Option<&[LfmWord]>) -> TraceTable { + use crate::lfm::chips::hint::cols; + let hints: Vec<(u64, u64)> = side.sub.instrs[side.out.len()..] + .iter() + .filter_map(|i| match i { + Instr::Hint { out, mult, .. } => Some((out.0, *mult)), + _ => None, + }) + .collect(); + let height = hints.len().next_power_of_two().max(super::prove::MIN_ROWS); + let mut trace = TraceTable::new_main( + crate::tables::types::zeroed_fe_vec(height * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let t = &mut trace.main_table; + for (r, (addr, mult)) in hints.iter().enumerate() { + t.set(r, cols::OUT_ADDR, FE::from(*addr)); + t.set(r, cols::MULT, FE::from(*mult)); + if let Some(v) = values { + for (k, x) in v[side.out.len() + r].iter().enumerate() { + t.set(r, cols::V0 + k, *x); + } + } + } + trace +} + +#[allow(clippy::too_many_arguments)] +fn air + 'static>( + name: &str, + num_columns: usize, + interactions: Vec, + cs: CS, + root: stark::config::Commitment, + prep: usize, + one_row: Option, + options: &ProofOptions, +) -> ExtraAir { + let a = AirWithBuses::::new( + num_columns, + AuxiliaryTraceBuildData { interactions }, + options, + 1, + cs, + ) + .with_name(name) + .with_preprocessed(root, prep); + Box::new(match one_row { + Some(r) => a.with_one_row_commitment(Some(r)), + None => a, + }) +} + +/// `(first row, rows)` per chunk of a table with `real` rows: with +/// `FVM_HYBRID_CHUNK`, the largest power of two and the rest when that saves +/// an eighth of the rows. The chips split have no row-to-row constraints. +fn chunk_plan(real: usize, height: usize) -> Vec<(usize, usize)> { + let first = height / 2; + let min = super::prove::MIN_ROWS; + if std::env::var_os("FVM_HYBRID_CHUNK").is_some() && real > first && first >= min { + let rest = (real - first).next_power_of_two().max(min); + if first + rest + height / 8 <= height { + return vec![(0, first), (first, rest)]; + } + } + vec![(0, height)] +} + +/// `t` cut per [`chunk_plan`]; a chunk's padding repeats `t`'s first padding row. +fn chunks(t: &TraceTable, real: usize) -> Vec> { + let plan = chunk_plan(real, t.num_rows()); + if plan.len() == 1 { + return vec![t.clone()]; + } + let w = t.main_table.width; + plan.into_iter() + .map(|(start, rows)| { + let mut data = crate::tables::types::zeroed_fe_vec(rows * w); + for r in 0..rows { + let src = if start + r < real { start + r } else { real }; + if src < t.num_rows() { + for c in 0..w { + data[r * w + c] = *t.main_table.get(src, c); + } + } + } + TraceTable::new_main(data, w, 1) + }) + .collect() +} + +fn prep_roots( + chunks: &[TraceTable], + prep: usize, + options: &ProofOptions, +) -> Vec { + chunks + .iter() + .map(|t| { + let columns: Vec> = (0..prep) + .map(|c| (0..t.num_rows()).map(|r| *t.main_table.get(r, c)).collect()) + .collect(); + crate::field_vm::prove::commit_prep(&columns, options) + }) + .collect() +} + +fn group_roots( + g: &crate::lfm::compiler::ColumnGroup, + options: &ProofOptions, +) -> Vec { + if g.real_rows == 0 { + return Vec::new(); + } + let t = TraceTable::new_main(g.data.clone(), g.width, 1); + prep_roots(&chunks(&t, g.real_rows), g.width, options) +} + +/// The verifier's half of the statement: the preprocessed roots. +#[derive(Clone)] +pub struct HashSideId { + pub bridge: Option, + pub hash: Vec, + /// One root per chunk ([`chunk_plan`]); empty when the chip has no rows. + pub lanes: Vec, + pub bitdec: Option, + pub select: Vec, + pub hint: Vec, + pub const_: Option, + pub balu: Vec, + pub xalu: Vec, + /// `LFM_KECCAK` and `KECCAK_RC` roots and the `KECCAK_RND` chunk count. + pub keccak: Option<(stark::config::Commitment, stark::config::Commitment, usize)>, + pub bitwise: Option, + pub hasher: HasherKind, + /// The one-row roots of the preprocessed tables, in [`airs`] order; + /// empty for row-pair leaves only. + pub one_row: Vec, +} + +pub fn side_id( + side: &HashSide, + rows: &[BridgeRow], + artifacts: &LfmArtifacts, + options: &ProofOptions, +) -> HashSideId { + let g = &side.sub.groups; + assert_eq!(g.blake3.real_rows, 0, "no BLAKE3 on the hash half"); + let hash = if g.hash.real_rows == 0 { + Vec::new() + } else if artifacts.hash_chunk_roots.is_empty() { + vec![artifacts.roots[crate::lfm::airs::HASH_SLOT]] + } else { + artifacts.hash_chunk_roots.clone() + }; + HashSideId { + bridge: (!side.merged).then(|| bridge::commitment(rows, options, super::prove::MIN_ROWS)), + hash, + lanes: group_roots(&g.lanes, options), + bitdec: (g.bitdec.real_rows > 0).then_some(artifacts.roots[4]), + select: group_roots(&g.select, options), + const_: (g.const_.real_rows > 0).then_some(artifacts.roots[0]), + balu: group_roots(&g.balu, options), + xalu: group_roots(&g.xalu, options), + hint: if g.hint.real_rows > side.out.len() { + prep_roots( + &chunks(&hint_table(side, None), g.hint.real_rows - side.out.len()), + layout::hint::PREP_WIDTH, + options, + ) + } else { + Vec::new() + }, + keccak: artifacts.chip_set.keccak.then_some(( + artifacts.roots[crate::lfm::airs::KECCAK_SLOT], + artifacts.roots[crate::lfm::airs::KECCAK_RC_SLOT], + artifacts.keccak_rnd_chunks, + )), + bitwise: artifacts + .chip_set + .bitwise + .then_some(artifacts.roots[crate::lfm::airs::BITWISE_SLOT]), + hasher: artifacts.hasher, + one_row: Vec::new(), + } +} + +/// The `log2` heights of [`traces`]' tables, in its order, from the hash +/// half's shape alone: each chip's table at its group's height in +/// `artifacts`, cut per [`chunk_plan`]. +#[cfg_attr(not(feature = "parallel"), allow(dead_code))] +pub fn heights( + side: &HashSide, + rows: &[BridgeRow], + id: &HashSideId, + artifacts: &LfmArtifacts, +) -> Result, String> { + use crate::lfm::airs::{BITWISE_SLOT, HASH_SLOT}; + let min = super::prove::MIN_ROWS; + let log2 = |rows: usize| rows.trailing_zeros() as u8; + let lh = &artifacts.log_heights; + let cut = |real: usize, slot: usize| -> Vec { + chunk_plan(real, 1 << lh[slot]) + .into_iter() + .map(|(_, rows)| log2(rows)) + .collect() + }; + let g = &side.sub.groups; + let mut v = Vec::new(); + if id.bridge.is_some() { + v.push(log2(rows.len().next_power_of_two().max(min))); + } + if !id.hash.is_empty() { + if artifacts.hash_chunk_log_heights.is_empty() { + v.push(lh[HASH_SLOT]); + } else { + v.extend(&artifacts.hash_chunk_log_heights); + } + } + if !id.lanes.is_empty() { + v.extend(cut(g.lanes.real_rows, 7)); + } + if id.bitdec.is_some() { + v.push(lh[4]); + } + if !id.select.is_empty() { + v.extend(cut(g.select.real_rows, 3)); + } + if id.const_.is_some() { + v.push(lh[0]); + } + if !id.balu.is_empty() { + v.extend(cut(g.balu.real_rows, 1)); + } + if !id.xalu.is_empty() { + v.extend(cut(g.xalu.real_rows, 2)); + } + if !id.hint.is_empty() { + let hints = side.sub.instrs[side.out.len()..] + .iter() + .filter(|i| matches!(i, Instr::Hint { .. })) + .count(); + let height = hints.next_power_of_two().max(min); + v.extend( + chunk_plan(g.hint.real_rows - side.out.len(), height) + .into_iter() + .map(|(_, rows)| log2(rows)), + ); + } + if id.keccak.is_some() { + return Err("a keccak hash half has no shape-only heights".into()); + } + if id.bitwise.is_some() { + v.push(lh[BITWISE_SLOT]); + } + Ok(v) +} + +/// BRIDGE, the `LFM_HASH` chunks, LANES, BITDEC, SELECT, CONST, HINT, then the keccak family +/// (`LFM_KECCAK`, the `KECCAK_RND` chunks, `KECCAK_RC`) and `BITWISE`. +pub fn airs(id: &HashSideId, options: &ProofOptions) -> Vec { + let mut v = Vec::new(); + let mut rows = id.one_row.iter().copied(); + if let Some(root) = id.bridge { + v.push(air( + "FIELD_VM_BRIDGE", + bridge::cols::NUM_COLUMNS, + bridge::bus_interactions(), + BridgeConstraints, + root, + bridge::cols::NUM_PRECOMPUTED_COLS, + rows.next(), + options, + )); + } + for root in &id.hash { + v.push(air( + "LFM_HASH", + hash::num_columns(id.hasher), + hash::bus_interactions(id.hasher), + hash::HashConstraints { kind: id.hasher }, + *root, + layout::hash::PREP_WIDTH, + rows.next(), + options, + )); + } + for &root in &id.lanes { + v.push(air( + "LFM_LANES", + lanes::cols::NUM_COLUMNS, + lanes::bus_interactions(), + EmptyConstraints, + root, + layout::lanes::PREP_WIDTH, + rows.next(), + options, + )); + } + if let Some(root) = id.bitdec { + v.push(air( + "LFM_BITDEC", + bitdec::cols::NUM_COLUMNS, + bitdec::bus_interactions(), + bitdec::BitDecConstraints, + root, + layout::bitdec::PREP_WIDTH, + rows.next(), + options, + )); + } + for &root in &id.select { + v.push(air( + "LFM_SELECT", + select::cols::NUM_COLUMNS, + select::bus_interactions(), + select::SelectConstraints, + root, + layout::select::PREP_WIDTH, + rows.next(), + options, + )); + } + if let Some(root) = id.const_ { + v.push(air( + "LFM_CONST", + const_::cols::NUM_COLUMNS, + const_::bus_interactions(), + EmptyConstraints, + root, + layout::const_::PREP_WIDTH, + rows.next(), + options, + )); + } + for &root in &id.balu { + v.push(air( + "LFM_BALU", + balu::cols::NUM_COLUMNS, + balu::bus_interactions(), + balu::BaluConstraints, + root, + layout::balu::PREP_WIDTH, + rows.next(), + options, + )); + } + for &root in &id.xalu { + v.push(air( + "LFM_XALU", + xalu::cols::NUM_COLUMNS, + xalu::bus_interactions(), + xalu::XaluConstraints, + root, + layout::xalu::PREP_WIDTH, + rows.next(), + options, + )); + } + for &root in &id.hint { + v.push(air( + "LFM_HINT", + crate::lfm::chips::hint::cols::NUM_COLUMNS, + crate::lfm::chips::hint::bus_interactions(), + EmptyConstraints, + root, + layout::hint::PREP_WIDTH, + rows.next(), + options, + )); + } + if let Some((adapter, rc, chunks)) = id.keccak { + v.push(air( + "LFM_KECCAK", + keccak::cols::NUM_COLUMNS, + keccak::bus_interactions(), + keccak::KeccakAdapterConstraints, + adapter, + layout::keccak::PREP_WIDTH, + rows.next(), + options, + )); + for _ in 0..chunks { + v.push(Box::new( + AirWithBuses::::new( + keccak_rnd::cols::NUM_COLUMNS, + AuxiliaryTraceBuildData { + interactions: keccak_rnd::bus_interactions(), + }, + options, + 1, + keccak_rnd::KeccakRndConstraints, + ) + .with_name("KECCAK_RND"), + )); + } + v.push(air( + "KECCAK_RC", + keccak_rc::cols::NUM_COLUMNS, + keccak_rc::bus_interactions(), + EmptyConstraints, + rc, + keccak_rc::NUM_PRECOMPUTED_COLS, + rows.next(), + options, + )); + } + if let Some(root) = id.bitwise { + v.push(air( + "BITWISE", + bitwise::cols::NUM_COLUMNS, + bitwise::bus_interactions(), + EmptyConstraints, + root, + bitwise::NUM_PRECOMPUTED_COLS, + rows.next(), + options, + )); + } + v +} + +/// The traces for [`airs`], and the cells MEM shares on `LfmMem` when merged; +/// `mem` is the Field VM's final memory. +pub fn traces( + side: &HashSide, + rows: &[BridgeRow], + id: &HashSideId, + mem: &[crate::tables::types::FEE], +) -> (Vec>, Vec) { + let exec = crate::lfm::executor::execute(&side.sub, &side.arenas, &id.hasher) + .expect("the hash half executes"); + let word = |i: usize| -> [FE; 4] { + let r = &rows[i]; + let lfm = exec.memory.get(Addr(r.laddr)).expect("a crossing cell"); + let fvm = mem[r.faddr as usize].value(); + debug_assert_eq!( + &lfm[..3], + fvm, + "cell {} disagrees across the bridge", + r.laddr + ); + [fvm[0], fvm[1], fvm[2], lfm[3]] + }; + let mut t = crate::lfm::trace::build_traces_with_hasher(&side.sub, &exec.records, id.hasher); + let mut v = Vec::new(); + let mut cells = Vec::new(); + if id.bridge.is_some() { + v.push(bridge::generate_trace(rows, word, super::prove::MIN_ROWS)); + } else { + cells = rows + .iter() + .enumerate() + .map(|(i, r)| (r.faddr, r.mult_out, r.is_in as u64, word(i)[3])) + .collect(); + } + if !id.hash.is_empty() { + v.push(std::mem::replace( + &mut t.hash, + TraceTable::new_main(Vec::new(), 1, 1), + )); + v.append(&mut t.hash_tail); + } + let g = &side.sub.groups; + if !id.lanes.is_empty() { + v.extend(chunks(&t.lanes, g.lanes.real_rows)); + } + if id.bitdec.is_some() { + v.push(std::mem::replace( + &mut t.bitdec, + TraceTable::new_main(Vec::new(), 1, 1), + )); + } + if !id.select.is_empty() { + v.extend(chunks(&t.select, g.select.real_rows)); + } + if id.const_.is_some() { + v.push(std::mem::replace( + &mut t.const_, + TraceTable::new_main(Vec::new(), 1, 1), + )); + } + if !id.balu.is_empty() { + v.extend(chunks(&t.balu, g.balu.real_rows)); + } + if !id.xalu.is_empty() { + v.extend(chunks(&t.xalu, g.xalu.real_rows)); + } + if !id.hint.is_empty() { + v.extend(chunks( + &hint_table(side, Some(&exec.records.hint)), + g.hint.real_rows - side.out.len(), + )); + } + if let Some((.., chunks)) = id.keccak { + assert_eq!(t.keccak_rnd.len(), chunks, "KECCAK_RND chunking"); + v.push(std::mem::replace( + &mut t.keccak, + TraceTable::new_main(Vec::new(), 1, 1), + )); + v.append(&mut t.keccak_rnd); + v.push(std::mem::replace( + &mut t.keccak_rc, + TraceTable::new_main(Vec::new(), 1, 1), + )); + } + if id.bitwise.is_some() { + if t.bitwise.num_rows() == 0 { + t.bitwise = bitwise::generate_bitwise_trace(); + } + v.push(std::mem::replace( + &mut t.bitwise, + TraceTable::new_main(Vec::new(), 1, 1), + )); + } + (v, cells) +} diff --git a/prover/src/field_vm/isa.rs b/prover/src/field_vm/isa.rs new file mode 100644 index 000000000..dff848e94 --- /dev/null +++ b/prover/src/field_vm/isa.rs @@ -0,0 +1,184 @@ +//! Field VM instruction set. +//! +//! Spec: `spec/chapters/field_vm.typ` (#971, `spec/recursion.vm` @ `d55a0f122`). +//! +//! The only instruction is the constraint `FMA d == a * b + c` over the cubic +//! extension. Each argument is `imm0 * reg + imm1`, optionally dereferenced as +//! `MEM[imm0 * reg + imm1]`; `d` reads its register from the future state. +//! Registers are indexed `ZERO = 0`, `PC = 1`, then `N` general-purpose ones. + +use crate::tables::types::{FE, FEE}; + +/// Number of general-purpose registers. +#[cfg(not(any(feature = "fvm-narrow", feature = "fvm-n1")))] +pub const N: usize = 5; +#[cfg(all(feature = "fvm-narrow", not(feature = "fvm-n1")))] +pub const N: usize = 2; +#[cfg(feature = "fvm-n1")] +pub const N: usize = 1; +/// `ZERO`, `PC` and the `N` general-purpose registers. +pub const NUM_REGS: usize = N + 2; + +pub const REG_ZERO: u8 = 0; +pub const REG_PC: u8 = 1; + +/// Index of general-purpose register `i`. +pub const fn gpr(i: usize) -> u8 { + assert!(i < N); + (i + 2) as u8 +} + +/// `Some(i)` when `reg` is general-purpose register `i`. +pub const fn gpr_index(reg: u8) -> Option { + if reg >= 2 && (reg as usize) < NUM_REGS { + Some(reg as usize - 2) + } else { + None + } +} + +/// The halting self-loop lives at `PC = 0`. +pub const HALT_PC: u64 = 0; +/// Execution starts at `PC = 1`, which holds a hint-free `FMA 0 == 0`. +pub const START_PC: u64 = 1; +/// First address of the user program. +pub const ENTRY_PC: u64 = 2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Arg { + pub reg: u8, + pub scale: FE, + pub offset: FE, + pub mem: bool, +} + +impl Arg { + /// `scale * reg + offset`. + pub fn lin(reg: u8, scale: i64, offset: i64) -> Self { + Self { + reg, + scale: FE::from(scale), + offset: FE::from(offset), + mem: false, + } + } + + pub fn reg(reg: u8) -> Self { + Self::lin(reg, 1, 0) + } + + /// The base-field immediate `v`. + pub fn imm(v: i64) -> Self { + Self::lin(REG_ZERO, 0, v) + } + + pub fn imm_fe(v: FE) -> Self { + Self { + reg: REG_ZERO, + scale: FE::zero(), + offset: v, + mem: false, + } + } + + /// `MEM[addr]`. + pub fn mem_abs(addr: u64) -> Self { + Self::imm_fe(FE::from(addr)).deref() + } + + /// `MEM[reg + offset]`. + pub fn mem(reg: u8, offset: i64) -> Self { + Self::lin(reg, 1, offset).deref() + } + + /// The same address expression, dereferenced. + pub fn deref(self) -> Self { + Self { mem: true, ..self } + } + + /// The value before a potential memory lookup, given all register values. + pub fn premem(&self, regs: &[FEE; NUM_REGS]) -> FEE { + self.scale * regs[self.reg as usize] + self.offset.to_extension() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Instr { + pub d: Arg, + pub a: Arg, + pub b: Arg, + pub c: Arg, + pub hint_out: bool, + pub hint_in: [bool; N], +} + +impl Instr { + pub fn new(d: Arg, a: Arg, b: Arg, c: Arg, hint_out: bool) -> Self { + Self { + d, + a, + b, + c, + hint_out, + hint_in: [false; N], + } + } + + pub fn args(&self) -> [Arg; 4] { + [self.d, self.a, self.b, self.c] + } + + /// `FMA PC == PC, hint out, hint 2, ..., hint (N + 1)`. + pub fn halt() -> Self { + Self { + d: Arg::reg(REG_PC), + a: Arg::imm(0), + b: Arg::imm(0), + c: Arg::reg(REG_PC), + hint_out: true, + hint_in: [true; N], + } + } + + /// `FMA 0 == 0`, no hinting. + pub fn nop() -> Self { + Self::new(Arg::imm(0), Arg::imm(0), Arg::imm(0), Arg::imm(0), false) + } + + pub fn validate(&self) -> Result<(), &'static str> { + if self.args().iter().any(|a| a.reg as usize >= NUM_REGS) { + return Err("register index out of range"); + } + if self.hint_out && self.d.reg == REG_ZERO { + return Err("the ZERO register cannot be output-hinted"); + } + Ok(()) + } +} + +/// A program: the halt loop at `PC = 0`, the start `nop` at `PC = 1`, then the body. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Program { + pub(crate) instrs: Vec, +} + +impl Program { + pub fn new(body: Vec) -> Result { + let mut instrs = Vec::with_capacity(body.len() + 2); + instrs.push(Instr::halt()); + instrs.push(Instr::nop()); + instrs.extend(body); + for (pc, instr) in instrs.iter().enumerate() { + instr.validate().map_err(|e| (pc, e))?; + } + Ok(Self { instrs }) + } + + pub fn len(&self) -> usize { + self.instrs.len() + } + + pub fn is_empty(&self) -> bool { + self.instrs.is_empty() + } +} diff --git a/prover/src/field_vm/lfm_compare.rs b/prover/src/field_vm/lfm_compare.rs new file mode 100644 index 000000000..04d0ab85a --- /dev/null +++ b/prover/src/field_vm/lfm_compare.rs @@ -0,0 +1,319 @@ +//! Field VM against the LFM on the same kernel: Horner over the cubic +//! extension, coefficients supplied as inputs. +//! +//! `cargo test --release -p lambda-vm-prover --lib field_vm::lfm_compare -- --ignored --nocapture --test-threads=1` + +use std::time::Instant; + +use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions}; +use stark::proof::stark::MultiProof; + +use super::asm::Asm; +use super::executor::{Memory, NoHints, execute}; +use super::isa::{Arg, Program, gpr}; +use super::prove::{generate_traces, program_id, prove_traces, public_cells, verify}; +use crate::lfm::builder::LfmBuilder; +use crate::lfm::compiler::compile; +use crate::lfm::edsl::horner_ext; +use crate::lfm::proof::lfm_prove; +use crate::lfm::registry::build_artifacts; +use crate::tables::types::{FE, FEE, GoldilocksExtension, GoldilocksField}; + +/// Repetitions: `FVM_COMPARE_RUNS`, default 5. +fn runs() -> usize { + std::env::var("FVM_COMPARE_RUNS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5) +} + +fn fvm_options() -> ProofOptions { + super::prove::default_options() +} + +fn options() -> ProofOptions { + GoldilocksCubicProofOptions::with_blowup(4).expect("blowup 4 is valid") +} + +fn inputs(len: usize) -> Vec { + (0..=len as u64) + .map(|k| FEE::new([FE::from(k + 3), FE::from(2 * k + 1), FE::from(k * k + 7)])) + .collect() +} + +/// `(Σ rows, Σ rows × committed columns)`, an extension column counting as one. +fn census(proof: &MultiProof) -> (usize, usize) { + proof.proofs.iter().fold((0, 0), |(r, c), p| { + ( + r + p.trace_length, + c + p.trace_length * p.trace_ood_evaluations.width, + ) + }) +} + +fn median(mut v: Vec) -> f64 { + v.sort_by(f64::total_cmp); + v[v.len() / 2] +} + +fn fvm_loop(len: usize) -> Program { + let (acc, i) = (gpr(0), gpr(1)); + let mut asm = Asm::new(); + asm.mov(Arg::reg(acc), Arg::imm(0)) + .mov(Arg::reg(i), Arg::imm(len as i64)); + let top = asm.label(); + asm.bind(top); + asm.fma_out( + Arg::reg(acc), + Arg::reg(acc), + Arg::mem_abs(0), + Arg::mem(i, 0), + ) + .sub(Arg::reg(i), Arg::reg(i), Arg::imm(1)) + .jump_not_zero(top) + .store(Arg::mem_abs(len as u64 + 1), Arg::reg(acc)) + .halt(); + asm.finish().unwrap() +} + +/// Straight-line, one row per coefficient, like the LFM's `horner_ext`. +fn fvm_unrolled(len: usize) -> Program { + let acc = gpr(0); + let mut asm = Asm::new(); + asm.mov(Arg::reg(acc), Arg::mem_abs(len as u64)); + for k in (1..len as u64).rev() { + asm.fma_out( + Arg::reg(acc), + Arg::reg(acc), + Arg::mem_abs(0), + Arg::mem_abs(k), + ); + } + asm.store(Arg::mem_abs(len as u64 + 1), Arg::reg(acc)) + .halt(); + asm.finish().unwrap() +} + +fn report_fvm(label: &str, program: &Program, len: usize) { + let opts = fvm_options(); + let exec = execute( + program, + Memory::with_init(len + 2, &inputs(len)), + &mut NoHints, + 1 << 24, + ) + .unwrap(); + let id = program_id(program, &opts); + // Only the result is public, as `LFM` publishes only its `public` word. + let public = [len as u64 + 1]; + let cells = public_cells(&exec, &public); + let (mut prove_ms, mut verify_ms, mut shape) = (Vec::new(), Vec::new(), (0, 0)); + for _ in 0..runs() { + let mut traces = generate_traces(program, &exec, &public); + let t = Instant::now(); + let proof = prove_traces(&id, &cells, &mut traces, &opts).unwrap(); + prove_ms.push(t.elapsed().as_secs_f64() * 1e3); + let t = Instant::now(); + assert!(verify(&id, &cells, &proof, &opts)); + verify_ms.push(t.elapsed().as_secs_f64() * 1e3); + shape = census(&proof); + } + eprintln!( + "{label:>12} len={len:>6} program={:>6} rows={:>7} cells={:>9} prove_ms={:>8.1} verify_ms={:>6.1}", + program.len(), + shape.0, + shape.1, + median(prove_ms), + median(verify_ms) + ); +} + +fn report_lfm(len: usize) { + let opts = options(); + let mut b = LfmBuilder::new(); + let arena = b.declare_arena(len as u32 + 1); + let x = b.hint_word(arena, 0).as_ext(); + let coeffs: Vec<_> = (1..=len as u32) + .map(|k| b.hint_word(arena, k).as_ext()) + .collect(); + let r = horner_ext(&mut b, x, &coeffs); + b.public(r.as_cell()); + let program = compile(b.finish()); + let artifacts = build_artifacts(&program, &opts); + let words = inputs(len) + .iter() + .map(|v| { + let [a, b, c] = *v.value(); + [a, b, c, FE::zero()] + }) + .collect(); + let arenas = vec![words]; + let (mut prove_ms, mut shape) = (Vec::new(), (0, 0)); + for _ in 0..runs() { + let t = Instant::now(); + let proof = lfm_prove(&program, &artifacts, &arenas, &opts).unwrap(); + prove_ms.push(t.elapsed().as_secs_f64() * 1e3); + shape = census(&proof.proof); + if std::env::var_os("FVM_COMPARE_TABLES").is_some() && prove_ms.len() == 1 { + let tables: Vec<_> = proof + .proof + .proofs + .iter() + .map(|p| (p.trace_length, p.trace_ood_evaluations.width)) + .collect(); + eprintln!("lfm tables (rows, cols): {tables:?}"); + } + } + eprintln!( + "{:>12} len={len:>6} program={:>6} rows={:>7} cells={:>9} prove_ms={:>8.1}", + "lfm", + program.instrs.len(), + shape.0, + shape.1, + median(prove_ms) + ); +} + +/// Cost of building each machine's program identity (the preprocessed roots). +#[test] +#[ignore] +fn program_build_times() { + let opts = options(); + let fvm_opts = fvm_options(); + for len in [1usize << 10, 1 << 14, 1 << 16] { + let (loop_p, unrolled) = (fvm_loop(len), fvm_unrolled(len)); + let t = Instant::now(); + program_id(&loop_p, &fvm_opts); + let fvm_loop_ms = t.elapsed().as_secs_f64() * 1e3; + let t = Instant::now(); + program_id(&unrolled, &fvm_opts); + let fvm_unrolled_ms = t.elapsed().as_secs_f64() * 1e3; + let mut b = LfmBuilder::new(); + let arena = b.declare_arena(len as u32 + 1); + let x = b.hint_word(arena, 0).as_ext(); + let coeffs: Vec<_> = (1..=len as u32) + .map(|k| b.hint_word(arena, k).as_ext()) + .collect(); + let r = horner_ext(&mut b, x, &coeffs); + b.public(r.as_cell()); + let program = compile(b.finish()); + let t = Instant::now(); + build_artifacts(&program, &opts); + let lfm_ms = t.elapsed().as_secs_f64() * 1e3; + eprintln!( + "build len={len:>6} fvm-loop={fvm_loop_ms:>8.1}ms fvm-unrolled={fvm_unrolled_ms:>8.1}ms lfm={lfm_ms:>8.1}ms" + ); + } +} + +#[test] +#[ignore] +fn lfm_tables() { + for len in [1usize << 10, 1 << 16] { + report_lfm(len); + } +} + +#[test] +#[ignore] +fn horner_field_vm_vs_lfm() { + for len in [1usize << 10, 1 << 14, 1 << 16] { + report_fvm("fvm-loop", &fvm_loop(len), len); + report_fvm("fvm-unrolled", &fvm_unrolled(len), len); + report_lfm(len); + } +} + +fn fri_toy() -> ( + crate::lfm::compiler::LfmProgram, + Vec>, + crate::lfm::executor::LfmExecution, +) { + let program = crate::lfm::programs::fri_toy_program(); + let inner = crate::lfm::fixture::fixture_prove(); + let arenas = vec![inner.commitments.clone(), inner.openings.clone()]; + let exec = + crate::lfm::executor::execute(&program, &arenas, &crate::lfm::hash::HasherKind::Test) + .expect("fri_toy executes"); + (program, arenas, exec) +} + +/// Every translated instruction is an assertion over the LFM's own memory, so +/// executing the translation checks it. +#[test] +fn fri_toy_translation_executes() { + let (program, _, exec) = fri_toy(); + for bit_dec in [false, true] { + let t = super::lfm_translate::translate(&program, &exec, bit_dec); + let run = execute( + &t.program, + Memory::with_init(t.mem.len(), &t.mem), + &mut NoHints, + 1 << 24, + ); + assert!(run.is_ok(), "bit_dec={bit_dec}: {:?}", run.err()); + } +} + +/// `fri_toy` (Milestone C: transcript replay, two Merkle-authenticated opening +/// sets, Horner, two folds, terminal check) on both machines. +#[test] +#[ignore] +fn fri_toy_field_vm_vs_lfm() { + let opts = options(); + let fvm_opts = fvm_options(); + let (program, arenas, exec) = fri_toy(); + for bit_dec in [false, true] { + let t = super::lfm_translate::translate(&program, &exec, bit_dec); + eprintln!( + "bit_dec={bit_dec} lfm instrs={:?} skipped={:?} public={}", + t.lfm_counts, + t.skipped, + t.public.len() + ); + let run = execute( + &t.program, + Memory::with_init(t.mem.len(), &t.mem), + &mut NoHints, + 1 << 24, + ) + .unwrap(); + let id = program_id(&t.program, &fvm_opts); + let cells = public_cells(&run, &t.public); + let (mut prove_ms, mut verify_ms, mut shape) = (Vec::new(), Vec::new(), (0, 0)); + for _ in 0..runs() { + let mut traces = generate_traces(&t.program, &run, &t.public); + let s = Instant::now(); + let proof = prove_traces(&id, &cells, &mut traces, &fvm_opts).unwrap(); + prove_ms.push(s.elapsed().as_secs_f64() * 1e3); + let s = Instant::now(); + assert!(verify(&id, &cells, &proof, &fvm_opts)); + verify_ms.push(s.elapsed().as_secs_f64() * 1e3); + shape = census(&proof); + } + eprintln!( + " fvm bit_dec={bit_dec} program={} steps={} rows={} cells={} prove_ms={:.1} verify_ms={:.1}", + t.program.len(), + run.steps.len(), + shape.0, + shape.1, + median(prove_ms), + median(verify_ms) + ); + } + let artifacts = build_artifacts(&program, &opts); + let (mut prove_ms, mut shape) = (Vec::new(), (0, 0)); + for _ in 0..runs() { + let s = Instant::now(); + let proof = lfm_prove(&program, &artifacts, &arenas, &opts).unwrap(); + prove_ms.push(s.elapsed().as_secs_f64() * 1e3); + shape = census(&proof.proof); + } + eprintln!( + " lfm program={} rows={} cells={} prove_ms={:.1}", + program.instrs.len(), + shape.0, + shape.1, + median(prove_ms) + ); +} diff --git a/prover/src/field_vm/lfm_translate.rs b/prover/src/field_vm/lfm_translate.rs new file mode 100644 index 000000000..cbf2083a2 --- /dev/null +++ b/prover/src/field_vm/lfm_translate.rs @@ -0,0 +1,1417 @@ +//! Translates an executed LFM program into a Field VM program over the same +//! memory. +//! +//! `MEM` holds the LFM's final memory (lanes 0..3 of each word), so every +//! translated instruction is an `FMA` assertion over cells that are already +//! fixed; executing the result checks the translation. What the 3MI split +//! gives to the RISC-V half (hashing and the byte glue around it) is skipped +//! and counted: its outputs are in `MEM` like any other cell. + +use std::collections::BTreeMap; + +use super::asm::Asm; +use super::isa::{Arg, ENTRY_PC, Instr as FInstr, Program, REG_ZERO, gpr}; +use crate::lfm::compiler::LfmProgram; +use crate::lfm::executor::LfmExecution; +pub use crate::lfm::fn_scope::Scope; +use crate::lfm::instr::{Addr, BaseOp, ExtOp, Instr}; +use crate::tables::types::{FE, FEE}; + +pub struct Translation { + pub program: Program, + pub mem: Vec, + /// Addresses the LFM program publishes and its extension constants. + pub public: Vec, + /// LFM instructions per kind, and how many were skipped. + pub lfm_counts: BTreeMap<&'static str, usize>, + pub skipped: BTreeMap<&'static str, usize>, + /// The memory address of each cell asked to be kept, in request order. + pub kept: Vec, + /// `(publish index, memory address)` of each word the program publishes, + /// in program order. + #[cfg_attr(not(feature = "parallel"), allow(dead_code))] + pub published: Vec<(u32, u64)>, + /// The public cells that are the program's constants, with their values. + #[cfg(all(test, feature = "parallel"))] + pub consts: Vec<(u64, FEE)>, + /// Lane 3 of constant cells, which the Field VM's extension memory omits. + pub const_lane3: Vec<(u64, FE)>, + /// The LFM instruction each program address was translated from; + /// `usize::MAX` for the halt loop, the start `nop` and loop control. + pub origin: Vec, +} + +fn cell(a: &Addr) -> Arg { + Arg::mem_abs(a.0) +} + +fn word_to_ext(w: [FE; 4]) -> FEE { + FEE::new([w[0], w[1], w[2]]) +} + +/// An ALU instruction as `(is_ext, op, out, a, b, c)`. +fn alu(i: &Instr) -> Option<(bool, AluOp, u64, u64, u64, u64)> { + match i { + Instr::BaseAlu { + op, out, a, b, c, .. + } => Some((false, AluOp::from_base(*op), out.0, a.0, b.0, c.0)), + Instr::ExtAlu { + op, out, a, b, c, .. + } => Some((true, AluOp::from_ext(*op), out.0, a.0, b.0, c.0)), + _ => None, + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum AluOp { + Add, + Sub, + Mul, + MulAdd, + Div, +} + +impl AluOp { + fn from_base(op: BaseOp) -> Self { + match op { + BaseOp::Add => Self::Add, + BaseOp::Sub => Self::Sub, + BaseOp::Mul => Self::Mul, + BaseOp::MulAdd => Self::MulAdd, + BaseOp::Div => Self::Div, + } + } + + fn from_ext(op: ExtOp) -> Self { + match op { + ExtOp::Add => Self::Add, + ExtOp::Sub => Self::Sub, + ExtOp::Mul | ExtOp::MulBase => Self::Mul, + ExtOp::MulAdd => Self::MulAdd, + ExtOp::Div => Self::Div, + } + } +} + +/// How an ALU instruction's result is produced. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Plan { + Plain, + /// Folded into its single consumer's row. + Fused, + /// Written to the accumulator register for the next instruction. + Chained, +} + +/// Single-use results that fold into their consumer's FMA, or stay in a +/// register when the consumer comes right after. +fn plan(program: &LfmProgram) -> Vec { + let mut produced: std::collections::HashMap = + std::collections::HashMap::new(); + for (i, instr) in program.instrs.iter().enumerate() { + match instr { + Instr::BaseAlu { out, mult, .. } | Instr::ExtAlu { out, mult, .. } => { + produced.insert(out.0, (i, *mult)); + } + _ => {} + } + } + let single = |addr: u64| { + produced + .get(&addr) + .filter(|(_, m)| *m == 1) + .map(|(i, _)| *i) + }; + let mut plan = vec![Plan::Plain; program.instrs.len()]; + // Rows whose result lands outside the `d` slot (a Div, a fused Sub). + let mut out_not_d = vec![false; program.instrs.len()]; + for (j, instr) in program.instrs.iter().enumerate() { + let Some((ext, op, _, a, b, _)) = alu(instr) else { + continue; + }; + let is = |p: usize, want: AluOp| { + alu(&program.instrs[p]).is_some_and(|(pe, pop, ..)| pe == ext && pop == want) + }; + match op { + // (x − y) / c ⇒ [x] == [c]·[q] + [y] + AluOp::Div => { + out_not_d[j] = true; + if let Some(p) = single(a).filter(|&p| is(p, AluOp::Sub)) { + plan[p] = Plan::Fused; + } + } + // z − x·y ⇒ [z] == [x]·[y] + [q] + AluOp::Sub => { + if let Some(p) = single(b).filter(|&p| is(p, AluOp::Mul)) { + plan[p] = Plan::Fused; + out_not_d[j] = true; + } + } + // x·y + z ⇒ [q] == [x]·[y] + [z] + AluOp::Add => { + if let Some(p) = single(a).filter(|&p| is(p, AluOp::Mul)) { + plan[p] = Plan::Fused; + } else if let Some(p) = single(b).filter(|&p| is(p, AluOp::Mul)) { + plan[p] = Plan::Fused; + } + } + _ => {} + } + } + for c in 1..program.instrs.len() { + let p = c - 1; + let (Some((_, _, out, ..)), Some((_, cop, _, a, b, cc))) = + (alu(&program.instrs[p]), alu(&program.instrs[c])) + else { + continue; + }; + let reads = a == out || b == out || (cop == AluOp::MulAdd && cc == out); + if plan[p] == Plan::Plain + && plan[c] != Plan::Fused + && !out_not_d[p] + && reads + && single(out) == Some(p) + { + plan[p] = Plan::Chained; + } + } + plan +} + +/// `bit_dec` decomposes with FMA rows (a bit check and an accumulation per +/// bit); otherwise it is skipped as RISC-V-half work. `FVM_TRANSLATE_OPT` +/// turns on the planner above and moves base constants to public cells. +pub fn translate(program: &LfmProgram, exec: &LfmExecution, bit_dec: bool) -> Translation { + translate_keeping(program, exec, bit_dec, &[], &|_, _| false) +} + +/// [`translate`] keeping the cells at `keep` in memory whether or not the +/// program reads them, and skipping the instructions `skip` names by index. +pub fn translate_keeping( + program: &LfmProgram, + exec: &LfmExecution, + bit_dec: bool, + keep: &[u64], + skip: &dyn Fn(usize, &Instr) -> bool, +) -> Translation { + translate_outlined(program, exec, bit_dec, keep, skip, &[]) +} + +/// [`translate_keeping`] with the regions `scopes` names outlined +/// (`FVM_OUTLINE`): each function's body is emitted once and every region +/// that matches it becomes a call ([`outline`]). +pub fn translate_outlined( + program: &LfmProgram, + exec: &LfmExecution, + bit_dec: bool, + keep: &[u64], + skip: &dyn Fn(usize, &Instr) -> bool, + scopes: &[Scope], +) -> Translation { + translate_outlined_mode( + program, + exec, + bit_dec, + keep, + skip, + scopes, + std::env::var_os("FVM_CANONICAL_PUBLICS").is_some(), + ) +} + +/// [`translate_outlined`] with deterministic public ordering: `Public` +/// instructions first in publish order, followed by constant inputs. +pub fn translate_outlined_canonical( + program: &LfmProgram, + exec: &LfmExecution, + bit_dec: bool, + keep: &[u64], + skip: &dyn Fn(usize, &Instr) -> bool, + scopes: &[Scope], +) -> Translation { + translate_outlined_mode(program, exec, bit_dec, keep, skip, scopes, true) +} + +fn translate_outlined_mode( + program: &LfmProgram, + exec: &LfmExecution, + bit_dec: bool, + keep: &[u64], + skip: &dyn Fn(usize, &Instr) -> bool, + scopes: &[Scope], + canonical: bool, +) -> Translation { + let program_lfm = program; + let n = program.num_addrs; + let mut mem: Vec = (0..n) + .map(|a| { + exec.memory + .get(Addr(a)) + .map(word_to_ext) + .unwrap_or(FEE::zero()) + }) + .collect(); + let scratch = |mem: &mut Vec, v: FEE| { + mem.push(v); + mem.len() as u64 - 1 + }; + // A select never meets a chain (chains feed only the next ALU + // instruction), so it borrows the accumulator; the second register is + // left free for loop indices (`FVM_REROLL`). + let (acc, tmp) = (gpr(0), gpr(0)); + let opt = std::env::var_os("FVM_TRANSLATE_OPT").is_some(); + let mut plan = if opt { + plan(program) + } else { + vec![Plan::Plain; program.instrs.len()] + }; + // An outlined region keeps the accumulator dead at both ends: no chain + // into its first instruction or out of its last. + if std::env::var_os("FVM_OUTLINE").is_some() { + for sc in scopes { + for edge in [sc.start, sc.end] { + if let Some(p) = edge.checked_sub(1) + && plan.get(p) == Some(&Plan::Chained) + { + plan[p] = Plan::Plain; + } + } + } + } + let producer: std::collections::HashMap = program + .instrs + .iter() + .enumerate() + .filter_map(|(i, instr)| alu(instr).map(|(_, _, out, ..)| (out, i))) + .collect(); + let fused = |x: u64| { + producer + .get(&x) + .filter(|&&p| plan[p] == Plan::Fused) + .and_then(|&p| alu(&program.instrs[p])) + }; + // The address the accumulator holds for the current instruction. + let mut in_acc: Option = None; + let mut asm = Asm::new(); + let mut public = Vec::new(); + let mut consts = Vec::new(); + let mut const_lane3 = Vec::new(); + // `FVM_CANONICAL_PUBLICS`: every constant is program text (a base one by + // immediate, an extension one from the public cell `w = (0, 1, 0)`), and + // the program's own public cells come first in memory, in publish order. + let mut published = Vec::new(); + let mut publish_index = Vec::new(); + let mut w_cells: Option<(u64, u64)> = None; + let mut lfm_counts = BTreeMap::new(); + let mut skipped = BTreeMap::new(); + + let mut origin: Vec = Vec::new(); + for (idx, instr) in program.instrs.iter().enumerate() { + origin.resize((asm.here() - ENTRY_PC) as usize, idx.wrapping_sub(1)); + let held = in_acc.take(); + let arg = |x: u64| { + if held == Some(x) { + Arg::reg(acc) + } else { + Arg::mem_abs(x) + } + }; + let kind = match instr { + Instr::Const { .. } => "const", + Instr::BaseAlu { .. } => "base_alu", + Instr::ExtAlu { .. } => "ext_alu", + Instr::Select { .. } => "select", + Instr::BitDec { .. } => "bit_dec", + Instr::Hash { .. } => "hash", + Instr::Hash16(_) => "hash16", + Instr::Hint { .. } => "hint", + Instr::Pack { .. } => "pack", + Instr::Unpack { .. } => "unpack", + Instr::KeccakF(_) => "keccak", + Instr::Blake3(_) => "blake3", + Instr::Public { .. } => "public", + }; + *lfm_counts.entry(kind).or_insert(0) += 1; + if skip(idx, instr) { + *skipped.entry(kind).or_insert(0) += 1; + continue; + } + match instr { + Instr::Const { out, value, .. } if canonical => { + const_lane3.push((out.0, value[3])); + if value[1] == FE::zero() && value[2] == FE::zero() { + asm.store(cell(out), Arg::imm_fe(value[0])); + } else { + let (w, w2) = *w_cells.get_or_insert_with(|| { + let w = scratch(&mut mem, FEE::new([FE::zero(), FE::one(), FE::zero()])); + let w2 = scratch(&mut mem, FEE::new([FE::zero(), FE::zero(), FE::one()])); + public.push(w); + consts.push((w, mem[w as usize])); + asm.fma( + Arg::mem_abs(w2), + Arg::mem_abs(w), + Arg::mem_abs(w), + Arg::imm(0), + ); + (w, w2) + }); + let t = scratch(&mut mem, FEE::new([value[0], FE::zero(), value[2]])); + asm.fma( + Arg::mem_abs(t), + Arg::imm_fe(value[2]), + Arg::mem_abs(w2), + Arg::imm_fe(value[0]), + ) + .fma( + cell(out), + Arg::imm_fe(value[1]), + Arg::mem_abs(w), + Arg::mem_abs(t), + ); + } + } + Instr::Const { out, value, .. } => { + const_lane3.push((out.0, value[3])); + if value[1] == FE::zero() && value[2] == FE::zero() && !opt { + asm.store(cell(out), Arg::imm_fe(value[0])); + } else { + public.push(out.0); + consts.push((out.0, word_to_ext(*value))); + } + } + Instr::BaseAlu { .. } | Instr::ExtAlu { .. } => { + let (_, op, out, a, b, c) = alu(instr).expect("an ALU instruction"); + if plan[idx] == Plan::Fused { + *skipped.entry("fused").or_insert(0) += 1; + continue; + } + let (d, hint) = if plan[idx] == Plan::Chained { + in_acc = Some(out); + (Arg::reg(acc), true) + } else { + (Arg::mem_abs(out), false) + }; + let (fd, fa, fb, fc) = match op { + AluOp::Add => match (fused(a), fused(b)) { + (Some((.., x, y, _)), _) => (d, arg(x), arg(y), arg(b)), + (_, Some((.., x, y, _))) => (d, arg(x), arg(y), arg(a)), + _ => (d, Arg::imm(1), arg(a), arg(b)), + }, + AluOp::Sub => match fused(b) { + Some((.., x, y, _)) => (arg(a), arg(x), arg(y), Arg::mem_abs(out)), + None => (d, Arg::imm(-1), arg(b), arg(a)), + }, + AluOp::Mul => (d, arg(a), arg(b), Arg::imm(0)), + AluOp::MulAdd => (d, arg(a), arg(b), arg(c)), + AluOp::Div => match fused(a) { + Some((.., x, y, _)) => (arg(x), arg(b), Arg::mem_abs(out), arg(y)), + None => (arg(a), arg(b), Arg::mem_abs(out), Arg::imm(0)), + }, + }; + if hint { + asm.fma_out(fd, fa, fb, fc); + } else { + asm.fma(fd, fa, fb, fc); + } + } + Instr::Select { + bit, + out_l, + out_r, + in_l, + in_r, + .. + } => { + // r = in_l − in_r; out_l = in_l − bit·r; out_r = in_r + bit·r. + asm.fma_out(Arg::reg(tmp), Arg::imm(-1), cell(in_r), cell(in_l)) + .fma(cell(out_l), cell(bit), Arg::lin(tmp, -1, 0), cell(in_l)) + .fma(cell(out_r), cell(bit), Arg::reg(tmp), cell(in_r)); + } + Instr::BitDec { + input, + bits, + halves, + } => { + if !bit_dec { + *skipped.entry(kind).or_insert(0) += 1; + continue; + } + let x = mem[input.0 as usize].value()[0].canonical(); + let cells: [u64; 64] = core::array::from_fn(|i| match bits.get(i) { + Some((a, _)) => a.0, + None => scratch(&mut mem, FEE::from((x >> i) & 1)), + }); + if std::env::var_os("FVM_BITDEC_HORNER").is_some() { + canonical_bit_dec_horner(&mut asm, &mut mem, cell(input), &cells, acc); + } else { + canonical_bit_dec(&mut asm, &mut mem, cell(input), &cells, acc); + } + // The halves are byte-swapped `u32`s for the transcript: byte + // glue, which the 3MI split leaves to the RISC-V half. + if halves.is_some() { + *skipped.entry("bit_dec_halves").or_insert(0) += 1; + } + } + Instr::Public { addr, index } => { + public.push(addr.0); + published.push(addr.0); + publish_index.push(*index); + } + Instr::Hint { .. } => {} + _ => *skipped.entry(kind).or_insert(0) += 1, + } + } + origin.resize((asm.here() - ENTRY_PC) as usize, program.instrs.len() - 1); + asm.halt(); + origin.push(usize::MAX); + let mut origin: Vec = [usize::MAX; ENTRY_PC as usize] + .into_iter() + .chain(origin) + .collect(); + if canonical { + let mut ordered = Vec::with_capacity(public.len()); + let mut seen = std::collections::HashSet::with_capacity(public.len()); + for a in published.iter().copied().chain(public.iter().copied()) { + if seen.insert(a) { + ordered.push(a); + } + } + public = ordered; + } else { + public.sort_unstable(); + public.dedup(); + } + let mut program = asm.finish().expect("translated program"); + let mut kept = keep.to_vec(); + let mut frames: Option<(u64, usize, u64)> = None; + if !scopes.is_empty() && std::env::var_os("FVM_OUTLINE").is_some() { + let protected: std::collections::HashSet = + public.iter().chain(keep).copied().collect(); + let o = outline(&program, &origin, &mut mem, scopes, &protected); + eprintln!( + " outline: {} functions, {} calls, {} regions left inline, body {} instrs, program {} -> {}", + o.functions, + o.calls, + o.inline, + o.body_len, + program.len(), + o.program.len() + ); + program = o.program; + origin = o.origin; + frames = Some((o.frames_from, o.main_start, o.frame_cells)); + } + if std::env::var_os("FVM_MEM_COMPACT").is_some() { + let first: Vec = if canonical { + published + .iter() + .copied() + .chain(w_cells.map(|(w, _)| w)) + .collect() + } else { + Vec::new() + }; + // `FVM_COMPACT_BITDEC_LAST`: bit decompositions place their cells + // last, so the bits the per-query code reads land in its order. + let late: Vec = if std::env::var_os("FVM_COMPACT_BITDEC_LAST").is_some() { + origin + .iter() + .map(|&o| matches!(program_lfm.instrs.get(o), Some(Instr::BitDec { .. }))) + .collect() + } else { + Vec::new() + }; + let kept_and_published: Vec = keep.iter().chain(&published).copied().collect(); + (mem, public, kept, consts, const_lane3) = compact( + &mut program, + &mem, + &public, + &kept_and_published, + &consts, + &const_lane3, + &first, + frames.map(|(f, ..)| f), + &late, + canonical, + ); + published = kept.split_off(keep.len()); + } + if std::env::var_os("FVM_REROLL").is_some() { + let from; + (program, from) = reroll(&program, frames.map_or(ENTRY_PC as usize, |(_, m, _)| m)); + origin = from + .into_iter() + .map(|i| origin.get(i).copied().unwrap_or(usize::MAX)) + .collect(); + } + if let Some((_, main_start, frame_cells)) = frames { + set_return_addresses(&program, &mut mem, frame_cells, main_start); + } + Translation { + program, + mem, + public, + lfm_counts, + origin, + skipped, + kept, + published: publish_index.into_iter().zip(published).collect(), + #[cfg(all(test, feature = "parallel"))] + consts, + const_lane3, + } +} + +/// Asserts `input == Σ 2^i · MEM[bits[i]]` over boolean bits, with the +/// LFM's canonicality rule: when the high 32 bits are all ones the low 32 are +/// zero, so a value has one decomposition (not also that of `x + p`). The +/// witness (the low half and `w`) is derived from the bits in `mem`. Clobbers +/// `acc`. +pub(super) fn canonical_bit_dec( + asm: &mut Asm, + mem: &mut Vec, + input: Arg, + bits: &[u64; 64], + acc: u8, +) { + let bit = |i: usize| mem[bits[i] as usize].value()[0].canonical(); + let half = |h: usize| (0..32).fold(0u64, |v, i| v | (bit(32 * h + i) << i)); + let (lo, hi) = (half(0), half(1)); + let w = (FE::from(u32::MAX as u64) - FE::from(hi)) + .inv() + .map_or(FE::zero(), |d| FE::from(lo) * d); + mem.push(FEE::from(lo)); + let lo_cell = mem.len() as u64 - 1; + mem.push(w.to_extension()); + let w_cell = mem.len() as u64 - 1; + for half in 0..2 { + asm.mov(Arg::reg(acc), Arg::imm(0)); + for (j, &a) in bits[32 * half..32 * (half + 1)].iter().enumerate() { + let b = Arg::mem_abs(a); + asm.fma(b, b, b, Arg::imm(0)).fma_out( + Arg::reg(acc), + Arg::imm_fe(FE::from(1u64 << j)), + b, + Arg::reg(acc), + ); + } + if half == 0 { + asm.store(Arg::mem_abs(lo_cell), Arg::reg(acc)); + } + } + // lo == ((2^32 − 1) − hi) · w: lo = 0 when hi = 2^32 − 1. + asm.fma( + input, + Arg::imm_fe(FE::from(1u64 << 32)), + Arg::reg(acc), + Arg::mem_abs(lo_cell), + ) + .fma( + Arg::mem_abs(lo_cell), + Arg::lin(acc, -1, u32::MAX as i64), + Arg::mem_abs(w_cell), + Arg::imm(0), + ); +} + +/// [`canonical_bit_dec`] with every bit's row the same instruction, so +/// `FVM_REROLL` rolls it: Horner from the top bit, `acc = 2·acc + b`, after +/// each bit's booleanity. The high half is stored on the way down, `x` is +/// checked whole, and `lo = x − 2^32·hi` meets the same canonicality rule: +/// `lo = (2^32 − 1)·w − hi·w`. +pub(super) fn canonical_bit_dec_horner( + asm: &mut Asm, + mem: &mut Vec, + input: Arg, + bits: &[u64; 64], + acc: u8, +) { + let bit = |i: usize| mem[bits[i] as usize].value()[0].canonical(); + let half = |h: usize| (0..32).fold(0u64, |v, i| v | (bit(32 * h + i) << i)); + let (lo, hi) = (half(0), half(1)); + let w = (FE::from(u32::MAX as u64) - FE::from(hi)) + .inv() + .map_or(FE::zero(), |d| FE::from(lo) * d); + let mut cell = |v: FE| { + mem.push(v.to_extension()); + mem.len() as u64 - 1 + }; + let hi_cell = cell(FE::from(hi)); + let lo_cell = cell(FE::from(lo)); + let w_cell = cell(w); + let hw_cell = cell(FE::from(hi) * w); + let ww_cell = cell(FE::from(u32::MAX as u64) * w); + asm.mov(Arg::reg(acc), Arg::imm(0)); + for i in (0..64).rev() { + let b = Arg::mem_abs(bits[i]); + asm.fma(b, b, b, Arg::imm(0)) + .fma_out(Arg::reg(acc), Arg::imm(2), Arg::reg(acc), b); + if i == 32 { + asm.store(Arg::mem_abs(hi_cell), Arg::reg(acc)); + } + } + // x == acc; lo = x − 2^32·hi; (2^32 − 1)·w = lo + hi·w. + asm.fma(input, Arg::imm(1), Arg::reg(acc), Arg::imm(0)) + .fma( + Arg::mem_abs(lo_cell), + Arg::imm_fe(-FE::from(1u64 << 32)), + Arg::mem_abs(hi_cell), + input, + ) + .fma( + Arg::mem_abs(hw_cell), + Arg::mem_abs(hi_cell), + Arg::mem_abs(w_cell), + Arg::imm(0), + ) + .fma( + Arg::mem_abs(ww_cell), + Arg::imm_fe(FE::from(u32::MAX as u64)), + Arg::mem_abs(w_cell), + Arg::imm(0), + ) + .fma( + Arg::mem_abs(ww_cell), + Arg::imm(1), + Arg::mem_abs(lo_cell), + Arg::mem_abs(hw_cell), + ); +} + +/// Keeps only the cells the program reads or publishes, renumbered densely in +/// first-use order. Every memory argument of a translation is an absolute +/// address, so renumbering is rewriting offsets. +type Compacted = ( + Vec, + Vec, + Vec, + Vec<(u64, FEE)>, + Vec<(u64, FE)>, +); + +#[allow(clippy::too_many_arguments)] +fn compact( + program: &mut Program, + mem: &[FEE], + public: &[u64], + keep: &[u64], + consts: &[(u64, FEE)], + const_lane3: &[(u64, FE)], + first: &[u64], + frames_from: Option, + late: &[bool], + canonical: bool, +) -> Compacted { + let mut remap: std::collections::HashMap = std::collections::HashMap::new(); + let mut dense = Vec::new(); + let mut slot = |old: u64, dense: &mut Vec| { + *remap.entry(old).or_insert_with(|| { + dense.push(mem[old as usize]); + dense.len() as u64 - 1 + }) + }; + for &a in first { + slot(a, &mut dense); + } + // Two passes: the rows `late` names take their cells only after every + // other row has, so a cell they share with the rest is placed where the + // rest first reads it. + for pass in [false, true] { + for (pc, instr) in program.instrs.iter_mut().enumerate() { + if late.get(pc).copied().unwrap_or(false) != pass { + continue; + } + for arg in [&mut instr.d, &mut instr.a, &mut instr.b, &mut instr.c] { + if arg.mem && arg.reg == FP { + continue; + } + if arg.mem { + assert!( + arg.reg == REG_ZERO && arg.scale == FE::zero(), + "translations address memory absolutely" + ); + arg.offset = FE::from(slot(arg.offset.canonical(), &mut dense)); + } + } + } + } + let mut public: Vec = public.iter().map(|&a| slot(a, &mut dense)).collect(); + if !canonical { + public.sort_unstable(); + } + let kept = keep.iter().map(|&a| slot(a, &mut dense)).collect(); + let consts = consts + .iter() + .map(|&(a, v)| (slot(a, &mut dense), v)) + .collect(); + let const_lane3 = const_lane3 + .iter() + .map(|&(a, v)| (slot(a, &mut dense), v)) + .collect(); + if let Some(from) = frames_from { + // Frames stay one block, after every other cell; each call's frame + // pointer moves with it. + let to = dense.len() as u64; + dense.extend_from_slice(&mem[from as usize..]); + for instr in program.instrs.iter_mut() { + if let Some(base) = frame_base(instr) { + instr.c = Arg::imm_fe(FE::from(base - from + to)); + } + } + } + (dense, public, kept, consts, const_lane3) +} + +/// Shortest loop body worth its two rows of per-iteration overhead. +const MIN_LOOP_BODY: usize = 32; + +/// Re-rolls repeated straight-line blocks into loops over the second +/// register. A block qualifies when it repeats at least three times with the +/// same instruction shapes and every memory offset moving by one common stride +/// per repetition, or not at all. Each loop is +/// +/// ```text +/// j = −reps·s +/// top: body, strided offsets as MEM[j + base + reps·s] +/// j = j + s // ZERO once j reaches 0 +/// JNZ top +/// ``` +/// +/// Registers are only ever the accumulator inside a body and the index `j`, +/// which the body does not touch, so a chain that crosses from one repetition +/// into the next still finds the accumulator it left. +// The register check is on a per-build constant, but only fires when loops +// are asked for. +#[allow(clippy::assertions_on_constants)] +/// Returns the program and, for each of its addresses, the source address it +/// came from (`usize::MAX` for loop control). +fn reroll(program: &Program, skip: usize) -> (Program, Vec) { + use super::isa::{Instr, REG_PC}; + use std::collections::HashMap; + assert!( + super::isa::N >= 2, + "loops need a second register for their index" + ); + let j = gpr(1); + let src = &program.instrs; + // `FVM_REROLL_MIN`: a shorter body (a bit decomposition's eight bits are + // sixteen rows) for two more rows of loop control a repetition. + let min_body = std::env::var("FVM_REROLL_MIN") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(MIN_LOOP_BODY); + let shape = |i: &Instr| { + let mut h = format!("{}{:?}", i.hint_out as u8, i.hint_in); + for a in i.args() { + h.push_str(&format!( + "|{}:{}:{}", + a.reg, + a.scale.canonical(), + a.mem as u8 + )); + if !a.mem { + h.push_str(&format!(":{}", a.offset.canonical())); + } + } + h + }; + let ids: Vec = { + let mut map: HashMap = HashMap::new(); + src.iter() + .map(|i| { + let n = map.len() as u32; + *map.entry(shape(i)).or_insert(n) + }) + .collect() + }; + // Candidate periods: the commonest distances between repeats of a window. + const K: usize = 32; + let mut last: HashMap<&[u32], usize> = HashMap::new(); + let mut dist: HashMap = HashMap::new(); + for i in 0..src.len().saturating_sub(K) { + if let Some(&q) = last.get(&ids[i..i + K]) { + *dist.entry(i - q).or_insert(0) += 1; + } + last.insert(&ids[i..i + K], i); + } + // A short period (a uniform run of rows) counts for its first multiple + // long enough to be a body: the run's nearest repeat is always the short one. + let mut widened: HashMap = HashMap::new(); + for (p, c) in dist { + *widened.entry(p * min_body.div_ceil(p)).or_insert(0) += c; + } + let mut periods: Vec<(usize, usize)> = widened + .into_iter() + .filter(|&(p, _)| p >= min_body) + .collect(); + periods.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + periods.truncate(16); + let offsets = + |i: &Instr| -> [Option; 4] { i.args().map(|a| a.mem.then(|| a.offset.canonical())) }; + // The common stride between repetition 0 and repetition `k` at `i`. + let stride = |i: usize, p: usize, k: usize| -> Option { + let mut s: Option = None; + // An outlined call's rows address its frame through the index + // register: a loop over them would overwrite the frame pointer. + if (i..i + (k + 1) * p).any(|q| src[q].args().iter().any(|a| a.reg == j)) { + return None; + } + for t in 0..p { + if ids[i + t] != ids[i + t + k * p] { + return None; + } + for (x, y) in offsets(&src[i + t]) + .into_iter() + .zip(offsets(&src[i + t + k * p])) + { + if let (Some(x), Some(y)) = (x, y) { + let d = y as i128 - x as i128; + if d != 0 { + match s { + None => s = Some(d), + Some(v) if v == d => {} + _ => return None, + } + } + } + } + } + Some(s.unwrap_or(0)) + }; + let mut out: Vec = src[..skip].to_vec(); + let mut from: Vec = (0..skip).collect(); + let mut i = skip; + while i < src.len() { + let mut best: Option<(usize, usize, i128)> = None; + for &(p, _) in &periods { + if i + 3 * p > src.len() { + continue; + } + let Some(s) = stride(i, p, 1).filter(|&s| s != 0) else { + continue; + }; + let mut reps = 2; + while i + (reps + 1) * p <= src.len() && stride(i, p, reps) == Some(s * reps as i128) { + reps += 1; + } + if reps >= 3 && best.is_none_or(|(bp, br, _)| reps * p > bp * br) { + best = Some((p, reps, s)); + } + } + let Some((p, reps, s)) = best else { + out.push(src[i]); + from.push(i); + i += 1; + continue; + }; + let span = s * reps as i128; + out.push(Instr::new( + Arg::reg(j), + Arg::imm(0), + Arg::imm(0), + Arg::imm_fe(FE::from(-span as i64)), + true, + )); + from.push(usize::MAX); + let top = out.len() as i64; + for t in 0..p { + let mut instr = src[i + t]; + let moved = offsets(&src[i + t + p]); + for (k, arg) in [&mut instr.d, &mut instr.a, &mut instr.b, &mut instr.c] + .into_iter() + .enumerate() + { + if let (Some(x), Some(y)) = (offsets(&src[i + t])[k], moved[k]) + && y != x + { + *arg = Arg::mem(j, x as i64 + span as i64); + } + } + out.push(instr); + from.push(i + t); + } + out.push(Instr::new( + Arg::reg(j), + Arg::imm(1), + Arg::reg(j), + Arg::imm_fe(FE::from(s as i64)), + true, + )); + // JNZ top: PC' = ZERO·(PC − top) + (ZERO + top). + out.push(Instr::new( + Arg::reg(REG_PC), + Arg::reg(REG_ZERO), + Arg::lin(REG_PC, 1, -top), + Arg::lin(REG_ZERO, 1, top), + true, + )); + from.extend([usize::MAX; 2]); + i += reps * p; + } + debug_assert_eq!(from.len(), out.len()); + (Program { instrs: out }, from) +} + +/// Frame pointer of outlined calls. Translations use only the accumulator, and +/// `FVM_REROLL` takes this register for its loop index after outlining, where +/// no call sits in a loop: a call's frame base is an immediate of its own. +const FP: u8 = 3; // gpr(1) + +/// `fp = base`, a call's first instruction: its frame base. +fn frame_base(i: &FInstr) -> Option { + let imm = |a: &Arg| !a.mem && a.reg == REG_ZERO && a.scale == FE::zero(); + (i.hint_out && !i.d.mem && i.d.reg == FP && i.d.scale == FE::one() && imm(&i.a) && imm(&i.c)) + .then(|| i.c.offset.canonical()) +} + +/// Writes each call's return address into its frame, from the call's final +/// position: `MEM[fp + ra] == PC + 2` stores it at run time. A call's frame +/// lies in the last `frame_cells` cells and its store is followed by the jump +/// into a body, below `main_start`; `FVM_REROLL`'s loop control also sets +/// this register and is not a call. +fn set_return_addresses(program: &Program, mem: &mut [FEE], frame_cells: u64, main_start: usize) { + use super::isa::REG_PC; + let frames = mem.len() as u64 - frame_cells..mem.len() as u64; + let into_body = |i: &FInstr| { + i.hint_out + && !i.d.mem + && i.d.reg == REG_PC + && !i.c.mem + && i.c.reg == REG_ZERO + && i.c.offset.canonical() < main_start as u64 + }; + let mut base = None; + for (pc, i) in program.instrs.iter().enumerate() { + if let Some(b) = frame_base(i) { + base = frames.contains(&b).then_some(b); + } else if let Some(b) = base + && program.instrs.get(pc + 1).is_some_and(into_body) + && i.d.mem + && i.d.reg == FP + && !i.c.mem + && i.c.reg == REG_PC + && i.c.offset == FE::from(2u64) + { + let ra = b + i.d.offset.canonical(); + mem[ra as usize] = FEE::from(pc as u64 + 2); + } + } +} + +struct Outlined { + program: Program, + origin: Vec, + /// First memory cell of the frames, which come after every other cell. + frames_from: u64, + /// First address after the bodies, where the main code starts. + main_start: usize, + /// Cells the frames take, at the end of memory. + frame_cells: u64, + functions: usize, + calls: usize, + inline: usize, + body_len: usize, +} + +/// Outlines the regions `scopes` names: a function is the first region of a +/// name, its memory arguments rewritten as `MEM[fp + slot]`, and every region +/// of that name with the same instruction shapes and the same pattern of +/// addresses becomes a call +/// +/// ```text +/// fp = frame // a fresh block of cells +/// MEM[fp + s] == MEM[x_s] // each slot whose cell lives outside +/// MEM[fp + ra] == PC + 2 +/// PC = body // ... body ... PC = MEM[fp + ra] +/// ``` +/// +/// An address the same in every region stays absolute (constants). The +/// equalities tie each frame cell to the cell it stands for, so the body +/// constrains the same values the region did; cells only the region touches +/// move into the frame. A region whose accumulator is live across either end, +/// or whose shapes differ, stays inline. Bodies sit after the start `nop`, +/// behind one jump to the main code. +#[allow(clippy::type_complexity)] +fn outline( + program: &Program, + origin: &[usize], + mem: &mut Vec, + scopes: &[Scope], + protected: &std::collections::HashSet, +) -> Outlined { + use super::isa::{REG_PC, gpr_index}; + use std::collections::{BTreeMap, HashMap}; + let src = &program.instrs; + let frames_from = mem.len() as u64; + let acc = gpr(0); + let entry = ENTRY_PC as usize; + let last = src.len() - 1; // the closing halt + // Program range of each scope: origins are nondecreasing over the body. + let range = |sc: &Scope| -> (usize, usize) { + let body = &origin[entry..last]; + let lo = entry + body.partition_point(|&o| o < sc.start); + let hi = entry + body.partition_point(|&o| o < sc.end); + (lo, hi) + }; + // Whether the accumulator's value on entry to `pc` is read before written. + let mut live = vec![false; src.len() + 1]; + for pc in (entry..src.len()).rev() { + let i = &src[pc]; + let reads = [i.a, i.b, i.c] + .iter() + .any(|a| a.reg == acc && a.scale != FE::zero()) + || (i.d.reg == acc && i.d.scale != FE::zero() && (i.d.mem || !i.hint_out)); + let writes = i.hint_out && !i.d.mem && i.d.reg == acc; + let hinted = gpr_index(acc).is_some_and(|g| i.hint_in[g]); + live[pc] = !hinted && (reads || (live[pc + 1] && !writes)); + } + let shape_eq = |x: &FInstr, y: &FInstr| { + x.hint_out == y.hint_out + && x.hint_in == y.hint_in + && x.args().iter().zip(y.args()).all(|(a, b)| { + a.reg == b.reg + && a.scale == b.scale + && a.mem == b.mem + && (a.mem || a.offset == b.offset) + }) + }; + let addrs = |i: &FInstr| -> [Option; 4] { + i.args().map(|a| { + a.mem.then(|| { + assert!( + a.reg == REG_ZERO && a.scale == FE::zero(), + "absolute before outlining" + ); + a.offset.canonical() + }) + }) + }; + let mut access: HashMap = HashMap::new(); + for i in &src[entry..] { + for a in addrs(i).into_iter().flatten() { + *access.entry(a).or_default() += 1; + } + } + + // Regions by name, valid ones only. + let mut by_name: BTreeMap<&str, Vec<(usize, usize)>> = BTreeMap::new(); + let mut inline = 0; + for sc in scopes { + let (lo, hi) = range(sc); + if hi <= lo { + continue; + } + let clean = !live[lo] + && !live[hi] + && src[lo..hi] + .iter() + .all(|i| i.args().iter().all(|a| a.reg != FP && a.reg != REG_PC)); + if clean { + by_name.entry(sc.name.as_str()).or_default().push((lo, hi)); + } else { + if std::env::var_os("FVM_OUTLINE_DEBUG").is_some() { + eprintln!( + " outline: {} at {lo}..{hi} inline (live in {}, live out {}, regs {})", + sc.name, + live[lo], + live[hi], + !src[lo..hi] + .iter() + .all(|i| i.args().iter().all(|a| a.reg != FP && a.reg != REG_PC)) + ); + } + inline += 1; + } + } + + struct Func { + body: Vec, + body_origin: Vec, + slots: usize, + } + // A call: (region start, region end, function, frame base, copies (slot, cell)). + let mut calls: Vec<(usize, usize, usize, u64, Vec<(usize, u64)>)> = Vec::new(); + let mut funcs: Vec = Vec::new(); + // Regions of one name split into classes of equal shapes: the first + // region of a name can differ from the rest (its results are shared + // with them, so the translator keeps them in memory). + let mut classes: Vec> = Vec::new(); + for regions in by_name.values() { + let mut mine: Vec> = Vec::new(); + for &(lo, hi) in regions { + let same = |&(r0, r1): &(usize, usize)| { + r1 - r0 == hi - lo && (0..hi - lo).all(|t| shape_eq(&src[r0 + t], &src[lo + t])) + }; + match mine.iter_mut().find(|c| same(&c[0])) { + Some(c) => c.push((lo, hi)), + None => mine.push(vec![(lo, hi)]), + } + } + classes.extend(mine); + } + for regions in &classes { + let (d0, d1) = regions[0]; + let len = d1 - d0; + let mut members: Vec<(usize, usize)> = regions + .iter() + .copied() + .filter(|&(lo, hi)| { + hi - lo == len && (0..len).all(|t| shape_eq(&src[d0 + t], &src[lo + t])) + }) + .collect(); + inline += regions.len() - members.len(); + if std::env::var_os("FVM_OUTLINE_DEBUG").is_some() { + let lens: Vec = regions.iter().map(|(lo, hi)| hi - lo).collect(); + eprintln!( + " outline: {} regions, lengths {:?}, {} match the first", + regions.len(), + &lens[..lens.len().min(8)], + members.len() + ); + if let Some(&(lo, _)) = regions.get(1) { + let (d0, _) = regions[0]; + let diffs: Vec = (0..lens[0].min(lens[1])) + .filter(|&t| !shape_eq(&src[d0 + t], &src[lo + t])) + .collect(); + eprintln!( + " outline: {} differing places, first {:?}", + diffs.len(), + &diffs[..diffs.len().min(5)] + ); + for &t in diffs.iter().take(3) { + eprintln!(" {:?}\n {:?}", src[d0 + t], src[lo + t]); + } + } + } + loop { + if members.len() < 2 { + inline += members.len(); + break; + } + // An address of the definition stays absolute when every member + // has it at every place the definition does. + let mut places: HashMap> = HashMap::new(); + for t in 0..len { + for (k, a) in addrs(&src[d0 + t]).into_iter().enumerate() { + if let Some(a) = a { + places.entry(a).or_default().push((t, k)); + } + } + } + let absolute: std::collections::HashSet = places + .iter() + .filter(|(a, ps)| { + members.iter().all(|&(lo, _)| { + ps.iter().all(|&(t, k)| addrs(&src[lo + t])[k] == Some(**a)) + }) + }) + .map(|(a, _)| *a) + .collect(); + let mut slot_of: HashMap = HashMap::new(); + for t in 0..len { + for a in addrs(&src[d0 + t]).into_iter().flatten() { + if !absolute.contains(&a) { + let n = slot_of.len(); + slot_of.entry(a).or_insert(n); + } + } + } + let slots = slot_of.len(); + // Each member's cell per slot, or None when its pattern differs. + let cells = |lo: usize| -> Option> { + let mut cell: Vec> = vec![None; slots]; + for t in 0..len { + for (a, b) in addrs(&src[d0 + t]).into_iter().zip(addrs(&src[lo + t])) { + let (Some(a), Some(b)) = (a, b) else { continue }; + match slot_of.get(&a) { + None if a != b => return None, + None => {} + Some(&s) => match cell[s] { + None => cell[s] = Some(b), + Some(c) if c != b => return None, + _ => {} + }, + } + } + } + cell.into_iter().collect() + }; + let resolved: Vec<_> = members.iter().map(|&(lo, _)| cells(lo)).collect(); + if resolved.iter().any(Option::is_none) { + let before = members.len(); + members = members + .into_iter() + .zip(&resolved) + .filter(|(_, c)| c.is_some()) + .map(|(m, _)| m) + .collect(); + inline += before - members.len(); + if members.first() != Some(&(d0, d1)) { + inline += members.len(); + break; + } + continue; + } + let f = funcs.len(); + let body = src[d0..d1] + .iter() + .map(|i| { + let mut i = *i; + for arg in [&mut i.d, &mut i.a, &mut i.b, &mut i.c] { + if arg.mem + && let Some(&s) = slot_of.get(&arg.offset.canonical()) + { + *arg = Arg::mem(FP, s as i64); + } + } + i + }) + .collect(); + funcs.push(Func { + body, + body_origin: origin[d0..d1].to_vec(), + slots, + }); + for (&(lo, hi), cells) in members.iter().zip(resolved) { + let cells = cells.expect("resolved"); + let mut inside: HashMap = HashMap::new(); + for i in &src[lo..hi] { + for a in addrs(i).into_iter().flatten() { + *inside.entry(a).or_default() += 1; + } + } + let base = mem.len() as u64; + let values: Vec = cells.iter().map(|&x| mem[x as usize]).collect(); + mem.extend(values); + mem.push(FEE::zero()); // the return address, set once placed + let copies = cells + .iter() + .enumerate() + .filter(|&(_, x)| protected.contains(x) || access[x] > inside[x]) + .map(|(s, &x)| (s, x)) + .collect(); + calls.push((lo, hi, f, base, copies)); + } + break; + } + } + calls.sort_unstable_by_key(|c| c.0); + + let mut out: Vec = src[..entry].to_vec(); + let mut from: Vec = vec![usize::MAX; entry]; + let jump = |target: usize| { + FInstr::new( + Arg::reg(REG_PC), + Arg::imm(0), + Arg::imm(0), + Arg::imm(target as i64), + true, + ) + }; + out.push(jump(0)); // patched below + from.push(usize::MAX); + let mut starts = Vec::with_capacity(funcs.len()); + let mut body_len = 0; + for f in &funcs { + starts.push(out.len()); + out.extend_from_slice(&f.body); + from.extend_from_slice(&f.body_origin); + out.push(FInstr::new( + Arg::reg(REG_PC), + Arg::imm(0), + Arg::imm(0), + Arg::mem(FP, f.slots as i64), + true, + )); + from.push(usize::MAX); + body_len += f.body.len() + 1; + } + let main_start = out.len(); + out[entry] = jump(main_start); + let mut next_call = 0; + let mut pc = entry; + while pc < src.len() { + if next_call < calls.len() && calls[next_call].0 == pc { + let (_, hi, f, base, ref copies) = calls[next_call]; + out.push(FInstr::new( + Arg::reg(FP), + Arg::imm(0), + Arg::imm(0), + Arg::imm(base as i64), + true, + )); + for &(s, x) in copies { + out.push(FInstr::new( + Arg::mem(FP, s as i64), + Arg::imm(0), + Arg::imm(0), + Arg::mem_abs(x), + false, + )); + } + out.push(FInstr::new( + Arg::mem(FP, funcs[f].slots as i64), + Arg::imm(0), + Arg::imm(0), + Arg::lin(REG_PC, 1, 2), + false, + )); + out.push(jump(starts[f])); + from.resize(out.len(), usize::MAX); + next_call += 1; + pc = hi; + continue; + } + out.push(src[pc]); + from.push(origin[pc]); + pc += 1; + } + Outlined { + program: Program { instrs: out }, + origin: from, + frame_cells: mem.len() as u64 - frames_from, + frames_from, + main_start, + functions: funcs.len(), + calls: calls.len(), + inline, + body_len, + } +} + +#[cfg(test)] +mod reroll_tests { + use super::*; + + /// One Horner bit decomposition over fresh cells rolls into loops: its + /// sixty-four bit rows are one instruction shape over strided cells. + #[test] + fn a_horner_bit_decomposition_rerolls() { + let x: u64 = 0x0123_4567_89AB_CDEF; + let mut mem: Vec = vec![FEE::from(x)]; + let bits: [u64; 64] = core::array::from_fn(|i| { + mem.push(FEE::from((x >> i) & 1)); + mem.len() as u64 - 1 + }); + let mut asm = Asm::new(); + for _ in 0..3 { + canonical_bit_dec_horner(&mut asm, &mut mem, Arg::mem_abs(0), &bits, gpr(0)); + } + asm.halt(); + let program = asm.finish().expect("a program"); + // SAFETY: test-local flag, read once by `reroll`. + unsafe { std::env::set_var("FVM_REROLL_MIN", "16") }; + let (rolled, _) = reroll(&program, ENTRY_PC as usize); + eprintln!( + "bit decomposition: {} -> {} instructions", + program.len(), + rolled.len() + ); + assert!( + rolled.len() < program.len() / 2, + "{} -> {}", + program.len(), + rolled.len() + ); + } +} diff --git a/prover/src/field_vm/mem.rs b/prover/src/field_vm/mem.rs new file mode 100644 index 000000000..3ee446f17 --- /dev/null +++ b/prover/src/field_vm/mem.rs @@ -0,0 +1,223 @@ +//! The `FIELD_VM_MEM` chip: read-only memory, one row per address. +//! +//! Not in the spec (see `FIELD_VM_SPEC_GAPS.md` §1). `addr` is pinned to the +//! row index (`addr₀ = 0`, `addr' = addr + 1`) so no address can be served with +//! two values. +//! +//! Cells flagged `pub` are also sent on `FIELD_VM_PUBLIC`, which the verifier +//! closes against the `(addr, value)` pairs it is given. Addresses being +//! unique, the flagged cells are then exactly the claimed ones, whatever their +//! position: that binds the inputs the program reads and the outputs it leaves. + +use stark::constraints::boundary::BoundaryConstraint; +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet, RowDomain}; +use stark::lookup::{ + BoundaryConstraintBuilder, BusInteraction, BusValue, LinearTerm, Multiplicity, Packing, +}; +use stark::trace::TraceTable; + +use super::air::FvmPublicInputs; +use crate::tables::types::{BusId, FE, FEE, GoldilocksExtension, GoldilocksField, zeroed_fe_vec}; + +type F = GoldilocksField; +type E = GoldilocksExtension; + +pub mod cols { + pub const ADDR: usize = 0; + pub const VALUE: usize = 1; + pub const MU: usize = 4; + pub const PUB: usize = 5; + pub const NUM_COLUMNS: usize = 6; + /// With the LFM hash chips: the fourth lane of a cell they write, and how + /// many times MEM sends (`M_OUT`) and receives (`M_IN`) the cell on `LfmMem`. + pub const V3: usize = 6; + pub const M_OUT: usize = 7; + pub const M_IN: usize = 8; + pub const NUM_COLUMNS_LFM: usize = 9; +} + +/// A cell MEM shares with the LFM hash chips: `(addr, sends, receives, lane 3)`. +pub type LfmCell = (u64, u64, u64, FE); + +/// [`generate_trace`] with the `LfmMem` columns. +pub fn generate_trace_lfm( + mem: &[FEE], + mult: &[u64], + public: &[u64], + min_rows: usize, + lfm: &[LfmCell], +) -> TraceTable { + let base = generate_trace(mem, mult, public, min_rows); + let rows = base.num_rows(); + let mut trace = TraceTable::new_main( + zeroed_fe_vec(rows * cols::NUM_COLUMNS_LFM), + cols::NUM_COLUMNS_LFM, + 1, + ); + let t = &mut trace.main_table; + for r in 0..rows { + for c in 0..cols::NUM_COLUMNS { + t.set(r, c, *base.main_table.get(r, c)); + } + } + for &(addr, out, inn, v3) in lfm { + let r = addr as usize; + t.set(r, cols::M_OUT, FE::from(out)); + t.set(r, cols::M_IN, FE::from(inn)); + t.set(r, cols::V3, v3); + } + trace +} + +/// [`bus_interactions`] plus the `LfmMem` sender `(addr, value, 0)` and +/// receiver `(addr, value, v3)`. Addresses are unique, so the witness +/// multiplicities can only match the hash chips' fixed reads and writes. +pub fn bus_interactions_lfm() -> Vec { + let direct = |c| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + let mut v = bus_interactions(); + let token = |lane3: BusValue| { + vec![ + direct(cols::ADDR), + direct(cols::VALUE), + direct(cols::VALUE + 1), + direct(cols::VALUE + 2), + lane3, + ] + }; + v.push(BusInteraction::sender( + BusId::LfmMem, + Multiplicity::Column(cols::M_OUT), + token(BusValue::constant(0)), + )); + v.push(BusInteraction::receiver( + BusId::LfmMem, + Multiplicity::Column(cols::M_IN), + token(direct(cols::V3)), + )); + // A Field VM public cell carries only the first three lanes. In LFM mode, + // publish lane 3 as a second, domain-separated public tuple so the LFM + // node can bind complete four-lane words. + v.push(BusInteraction::sender( + BusId::FieldVmPublic, + Multiplicity::Column(cols::PUB), + vec![ + BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::ADDR, + }, + LinearTerm::Constant(1i64 << 39), + ]), + direct(cols::V3), + BusValue::constant(0), + BusValue::constant(0), + ], + )); + v +} + +/// `public` are the addresses whose cells are flagged; each must be `< mem.len()`. +pub fn generate_trace( + mem: &[FEE], + mult: &[u64], + public: &[u64], + min_rows: usize, +) -> TraceTable { + let rows = mem.len().next_power_of_two().max(min_rows); + let mut trace = TraceTable::new_main( + zeroed_fe_vec(rows * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let t = &mut trace.main_table; + for row in 0..rows { + t.set(row, cols::ADDR, FE::from(row as u64)); + } + for (row, (v, m)) in mem.iter().zip(mult).enumerate() { + for (k, c) in v.value().iter().enumerate() { + t.set(row, cols::VALUE + k, *c); + } + t.set(row, cols::MU, FE::from(*m)); + } + for &addr in public { + assert!( + (addr as usize) < mem.len(), + "public cell {addr} outside memory" + ); + t.set(addr as usize, cols::PUB, FE::one()); + } + trace +} + +/// - **Receives** `FIELD_VM_MEM[addr, 0, 0] -> value` with multiplicity `μ`. +/// - **Sends** `FIELD_VM_PUBLIC[addr, value]` when `pub`. +pub fn bus_interactions() -> Vec { + let direct = |c| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + vec![ + BusInteraction::receiver( + BusId::FieldVmMem, + Multiplicity::Column(cols::MU), + vec![ + direct(cols::ADDR), + BusValue::constant(0), + BusValue::constant(0), + direct(cols::VALUE), + direct(cols::VALUE + 1), + direct(cols::VALUE + 2), + ], + ), + BusInteraction::sender( + BusId::FieldVmPublic, + Multiplicity::Column(cols::PUB), + vec![ + direct(cols::ADDR), + direct(cols::VALUE), + direct(cols::VALUE + 1), + direct(cols::VALUE + 2), + ], + ), + ] +} + +/// Drops the relay check from AIRs built while set: the tamper tests' control. +#[cfg(test)] +pub(crate) static NO_RELAY_CHECK: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// `lfm`: a row either sends or receives on `LfmMem`, so MEM cannot relay a +/// hash-chip cell with its fourth lane zeroed. +#[derive(Clone, Copy, Default)] +pub struct MemConstraints { + pub lfm: bool, +} + +impl ConstraintSet for MemConstraints { + fn eval>(&self, b: &mut B) { + let e = b.main(1, cols::ADDR) - b.main(0, cols::ADDR) - b.one(); + b.emit_base_rows(0, RowDomain::except_last(1), e); + let p = b.main(0, cols::PUB); + b.emit_base(1, p.clone() * (p - b.one())); + #[cfg(test)] + let relay_check = !NO_RELAY_CHECK.load(std::sync::atomic::Ordering::SeqCst); + #[cfg(not(test))] + let relay_check = true; + if self.lfm && relay_check { + let both = b.main(0, cols::M_OUT) * b.main(0, cols::M_IN); + b.emit_base(2, both); + } + } +} + +pub struct MemBoundary; + +impl BoundaryConstraintBuilder for MemBoundary { + fn boundary_constraints(_: &FvmPublicInputs, _: &[FEE]) -> Vec> { + vec![BoundaryConstraint::new_main(cols::ADDR, 0, FEE::zero())] + } +} diff --git a/prover/src/field_vm/mod.rs b/prover/src/field_vm/mod.rs new file mode 100644 index 000000000..096ae9785 --- /dev/null +++ b/prover/src/field_vm/mod.rs @@ -0,0 +1,26 @@ +//! The 3MI Field VM: a single-instruction (`FMA` over the cubic extension) +//! machine for the field half of split recursion verification. +//! +//! Spec: `spec/chapters/field_vm.typ`, `spec/src/field_vm.toml` and +//! `spec/src/field_vm_decode.toml` (#971). + +pub mod air; +pub mod asm; +pub mod bridge; +pub mod decode; +pub mod executor; +pub mod isa; +pub mod mem; +pub mod mux; +pub mod prove; + +#[cfg(test)] +pub(crate) mod hash_side; +#[cfg(test)] +mod lfm_compare; +#[cfg(test)] +pub(crate) mod lfm_translate; +#[cfg(test)] +mod prove_tests; +#[cfg(test)] +mod tests; diff --git a/prover/src/field_vm/mux.rs b/prover/src/field_vm/mux.rs new file mode 100644 index 000000000..90a98e563 --- /dev/null +++ b/prover/src/field_vm/mux.rs @@ -0,0 +1,186 @@ +//! The register multiplexer's constant coefficients. +//! +//! `f_j` is the Lagrange basis polynomial on `0..=N+1` with `f_j(j) = 1`. It is +//! degree-split as `f_j = Σ_k x^{e_k} f_{j,k}` with `e_0 = 0`, +//! `e_k = (D − 1) + (k − 1)(D − 2)`, `deg f_{j,0} ≤ D − 2` and +//! `deg f_{j,k} ≤ D − 3` for `k ≥ 1`, so that `imm0 · reg · f_j(x)` stays of +//! degree `D` once `x^{e_k}` is a committed column. `g(x) = Π (x − j)` range-checks +//! register indices using one extra coefficient in the last split polynomial. + +use super::isa::NUM_REGS; +use crate::tables::types::FE; + +/// Maximum constraint degree. +#[cfg(not(any(feature = "fvm-d3", feature = "fvm-d4")))] +pub const D: usize = 5; +#[cfg(feature = "fvm-d3")] +pub const D: usize = 3; +#[cfg(all(feature = "fvm-d4", not(feature = "fvm-d3")))] +pub const D: usize = 4; +/// Number of split polynomials after the first. +/// The fewest splits that fit the Lagrange basis on `NUM_REGS` points: +/// `NUM_REGS ≤ (T + 1)(D − 2) + 1`. +pub const T: usize = (NUM_REGS - 1).div_ceil(D - 2) - 1; + +const _: () = assert!(D >= 3); +const _: () = assert!( + NUM_REGS <= (D - 1) + T * (D - 2), + "too many registers for (D, T)" +); + +/// The exponent of `x` that multiplies split polynomial `k`. +pub const fn split_exponent(k: usize) -> usize { + if k == 0 { + 0 + } else { + (D - 1) + (k - 1) * (D - 2) + } +} + +/// Coefficients as `[k][l]`: the coefficient of `x^{split_exponent(k) + l}`. +pub type Split = [[FE; D]; T + 1]; + +// `D - 2` is 1 at degree 3, where the modulo is always zero. +#[allow(clippy::modulo_one)] +fn split(coeffs: &[FE]) -> Split { + let mut out = [[FE::zero(); D]; T + 1]; + for (e, c) in coeffs.iter().enumerate() { + // The range polynomial's one extra coefficient goes on top of the + // last split polynomial. + let extra = if T == 0 { + D - 1 + } else { + split_exponent(T) + (D - 2) + }; + let (k, l) = if e == extra { + (T, if T == 0 { D - 1 } else { D - 2 }) + } else if e <= D - 2 { + (0, e) + } else { + let r = e - (D - 1); + (1 + r / (D - 2), r % (D - 2)) + }; + assert!( + k < out.len(), + "polynomial of degree {} does not fit the split", + coeffs.len() - 1 + ); + out[k][l] = *c; + } + out +} + +fn poly_mul_linear(p: &[FE], root: FE) -> Vec { + let mut out = vec![FE::zero(); p.len() + 1]; + for (i, c) in p.iter().enumerate() { + out[i + 1] += *c; + out[i] = out[i] - *c * root; + } + out +} + +fn lagrange(j: usize) -> Vec { + let mut num = vec![FE::one()]; + let mut den = FE::one(); + for m in (0..NUM_REGS).filter(|&m| m != j) { + num = poly_mul_linear(&num, FE::from(m as u64)); + den *= FE::from(j as u64) - FE::from(m as u64); + } + let inv = den.inv().expect("distinct points"); + num.into_iter().map(|c| c * inv).collect() +} + +#[derive(Clone, Debug)] +pub struct MuxTables { + /// `mux[j]`: the split of `f_j`. + pub mux: [Split; NUM_REGS], + /// The split of `g`. + pub range: Split, +} + +impl MuxTables { + pub fn new() -> Self { + let mux = core::array::from_fn(|j| split(&lagrange(j))); + let mut g = vec![FE::one()]; + for m in 0..NUM_REGS { + g = poly_mul_linear(&g, FE::from(m as u64)); + } + Self { + mux, + range: split(&g), + } + } +} + +impl Default for MuxTables { + fn default() -> Self { + Self::new() + } +} + +/// The powers `x^{split_exponent(k)}` for `k = 0..=T`. +pub fn split_powers(x: FE) -> [FE; T + 1] { + core::array::from_fn(|k| x.pow(split_exponent(k) as u64)) +} + +/// Evaluates a split polynomial at `x` the way the constraints do. +pub fn eval_split(s: &Split, x: FE) -> FE { + let pows = split_powers(x); + let mut acc = FE::zero(); + for (p, split) in pows.iter().zip(s) { + for (l, c) in split.iter().enumerate() { + acc += *p * c * x.pow(l as u64); + } + } + acc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mux_selects_one_register() { + let t = MuxTables::new(); + for j in 0..NUM_REGS { + for x in 0..NUM_REGS { + let expected = if j == x { FE::one() } else { FE::zero() }; + assert_eq!( + eval_split(&t.mux[j], FE::from(x as u64)), + expected, + "f_{j}({x})" + ); + } + } + } + + #[test] + fn mux_split_respects_degree_budget() { + let t = MuxTables::new(); + for s in &t.mux { + assert!(s[0][D - 1] == FE::zero()); + for split in &s[1..] { + assert!(split[D - 2] == FE::zero() && split[D - 1] == FE::zero()); + } + } + // With t = 0 the range polynomial's extra coefficient is x^{D-1}. + if T != 0 { + assert!(t.range[0][D - 1] == FE::zero()); + for split in t.range.iter().take(T).skip(1) { + assert!(split[D - 2] == FE::zero() && split[D - 1] == FE::zero()); + } + assert!(t.range[T][D - 1] == FE::zero()); + } + } + + #[test] + fn range_vanishes_exactly_on_register_indices() { + let t = MuxTables::new(); + for x in 0..NUM_REGS as u64 { + assert_eq!(eval_split(&t.range, FE::from(x)), FE::zero()); + } + for x in [NUM_REGS as u64, NUM_REGS as u64 + 1, 1000, u64::MAX - 100] { + assert_ne!(eval_split(&t.range, FE::from(x)), FE::zero(), "g({x})"); + } + } +} diff --git a/prover/src/field_vm/prove.rs b/prover/src/field_vm/prove.rs new file mode 100644 index 000000000..6435ce0b5 --- /dev/null +++ b/prover/src/field_vm/prove.rs @@ -0,0 +1,505 @@ +//! Proving and verifying a Field VM execution: `FIELD_VM`, `FIELD_VM_DECODE` +//! and `FIELD_VM_MEM` in one `multi_prove`. +//! +//! The statement is the program id and the public memory cells. Every bus +//! closes internally except `FIELD_VM_PUBLIC`, whose balance the verifier +//! recomputes from the claimed cells. + +use crypto::fiat_shamir::is_transcript::IsTranscript; +use stark::config::Commitment; +use stark::constraints::builder::EmptyConstraints; +use stark::lookup::{AirWithBuses, AuxiliaryTraceBuildData, NullBoundaryConstraintBuilder}; +use stark::proof::options::{GoldilocksCubicProofOptions, ProofOptions}; +use stark::proof::stark::MultiProof; +use stark::prover::{IsStarkProver, ProvingError}; +#[cfg(feature = "disk-spill")] +use stark::storage_mode::StorageMode; +use stark::trace::TraceTable; +use stark::traits::AIR; +use stark::verifier::IsStarkVerifier; + +use math::field::element::FieldElement; + +use super::air::{self, FieldVmBoundary, FieldVmConstraints, FvmPublicInputs}; +use super::decode; +use super::executor::Execution; +use super::isa::Program; +use super::mem::{self, MemBoundary, MemConstraints}; +use crate::tables::types::{BusId, FE, FEE, GoldilocksExtension, GoldilocksField}; + +type F = GoldilocksField; +type E = GoldilocksExtension; + +pub type FvmProof = MultiProof; + +/// Minimum height of every table. +pub const MIN_ROWS: usize = 8; + +/// A multiple of four bytes, so every half of the statement lands on a 4-byte +/// boundary of the transcript stream: a verifier replaying it in the LFM then +/// absorbs the hinted public cells as they are, not byte by byte. +pub(crate) const DOMAIN_TAG: &[u8] = b"lambda-vm/field-vm/v1\0\0\0"; +const _: () = assert!(DOMAIN_TAG.len().is_multiple_of(4)); + +/// The proof family the Field VM proves under: the block's, so its proofs sit +/// beside the block tree's LFM proofs. +type Family = crate::hash_pin::BlockFamily; +type FvmTranscript = ::Transcript; +type FvmStark = ::Stark; + +/// A preprocessed root under the family's commitment, as the prover rebuilds it. +pub(crate) fn commit_prep(columns: &[Vec], options: &ProofOptions) -> Commitment { + commit_prep_with(columns, options, stark::leaf_layout::LeafLayout::RowPair) +} + +/// [`commit_prep`] under an explicit leaf layout. +pub(crate) fn commit_prep_with( + columns: &[Vec], + options: &ProofOptions, + layout: stark::leaf_layout::LeafLayout, +) -> Commitment { + crate::lfm::commit::commit_lde_columns_under::( + &crate::lfm::commit::lde_columns(columns, options), + layout, + ) +} + +/// A public memory cell: an input the program reads or an output it leaves. +pub type PublicCell = (u64, FEE); + +/// The smallest blowup the constraint degree allows: 4 at the spec's degree 5. +pub fn default_options() -> ProofOptions { + let blowup = (super::mux::D - 1).next_power_of_two().max(2) as u8; + production_format(GoldilocksCubicProofOptions::with_blowup(blowup).expect("valid blowup")) +} + +/// With `FVM_ZF_FORMAT` set, the recursion pipeline's proof format: its +/// Merkle caps and FRI fold schedule, and the terminal at degree 2^8, as the +/// LFM wraps are proved (`lfm::proof::aggregation_wrap_options`). +pub fn production_format(mut options: ProofOptions) -> ProofOptions { + if std::env::var_os("FVM_ZF_FORMAT").is_some() { + options.fri_final_poly_log_degree = 8; + options = crate::zf_format::ZfFormat::global().options(options); + } + options +} + +/// A table proved alongside the Field VM's own, sharing its buses. +pub type ExtraAir = Box>; + +pub(crate) struct Airs { + /// One per FIELD_VM segment. + fvm: Vec>, + /// One per DECODE chunk, each preprocessed under its program-id root. + decode: + Vec>, + mem: AirWithBuses, + extra: Vec, +} + +impl Airs { + pub(crate) fn new( + program_id: &[Commitment], + options: &ProofOptions, + segments: usize, + extra: Vec, + mem_lfm: bool, + ) -> Self { + let aux = |interactions| AuxiliaryTraceBuildData { interactions }; + let fvm = AirWithBuses::new( + air::cols::NUM_COLUMNS, + aux(air::bus_interactions()), + options, + 1, + FieldVmConstraints::new(), + ); + Self { + fvm: (0..segments) + .map(|k| fvm.clone().with_name(&format!("FIELD_VM[{k}]"))) + .collect(), + decode: program_id + .iter() + .enumerate() + .map(|(k, root)| { + AirWithBuses::new( + decode::NUM_COLUMNS, + aux(decode::bus_interactions()), + options, + 1, + EmptyConstraints, + ) + .with_name(&format!("FIELD_VM_DECODE[{k}]")) + .with_preprocessed(*root, decode::NUM_PRECOMPUTED_COLS) + }) + .collect(), + mem: AirWithBuses::new( + if mem_lfm { + mem::cols::NUM_COLUMNS_LFM + } else { + mem::cols::NUM_COLUMNS + }, + aux(if mem_lfm { + mem::bus_interactions_lfm() + } else { + mem::bus_interactions() + }), + options, + 1, + MemConstraints { lfm: mem_lfm }, + ) + .with_name("FIELD_VM_MEM"), + extra, + } + } + + /// The segments, then the DECODE chunks, then MEM, then the extra tables. + pub(crate) fn refs( + &self, + ) -> Vec<&dyn AIR> { + let mut refs: Vec<&dyn AIR> = + self.fvm.iter().map(|a| a as _).collect(); + refs.extend( + self.decode.iter().map(|a| { + a as &dyn AIR + }), + ); + refs.push(&self.mem); + refs.extend(self.extra.iter().map(|a| a.as_ref())); + refs + } +} + +/// The statement: program id, public cells and every segment boundary state. +pub(crate) fn transcript( + program_id: &[Commitment], + public: &[PublicCell], + segments: &[FvmPublicInputs], +) -> FvmTranscript { + let mut t = ::transcript(DOMAIN_TAG); + absorb_statement(&mut t, program_id, public, segments); + t +} + +/// Appends the statement [`transcript`] starts from to `t`. +pub(crate) fn absorb_statement( + t: &mut impl IsTranscript, + program_id: &[Commitment], + public: &[PublicCell], + segments: &[FvmPublicInputs], +) { + t.append_bytes(&(program_id.len() as u64).to_le_bytes()); + for root in program_id { + t.append_bytes(root); + } + t.append_bytes(&(public.len() as u64).to_le_bytes()); + for (addr, value) in public { + t.append_bytes(&addr.to_le_bytes()); + for c in value.value() { + t.append_bytes(&c.canonical().to_le_bytes()); + } + } + t.append_bytes(&(segments.len() as u64).to_le_bytes()); + for pi in segments { + t.append_bytes(&(pi.last_row as u64).to_le_bytes()); + for w in pi.first.iter().chain(&pi.last) { + t.append_bytes(&w.to_le_bytes()); + } + } +} + +/// The cells at `addrs` after `exec`, for [`generate_traces`] and [`verify`]. +pub fn public_cells(exec: &Execution, addrs: &[u64]) -> Vec { + addrs.iter().map(|&a| (a, exec.mem[a as usize])).collect() +} + +/// `Σ 1/(z − (FIELD_VM_PUBLIC + addr·α + Σ_k v_k·α^{2+k}))`, the fingerprint of +/// the MEM table's public sender. +pub(crate) fn expected_public_balance(public: &[PublicCell], z: &FEE, alpha: &FEE) -> Option { + let bus = FEE::from(BusId::FieldVmPublic as u64); + let powers: Vec = (1..=4).map(|k| alpha.pow(k as u64)).collect(); + let mut fingerprints: Vec = public + .iter() + .map(|(addr, value)| { + let mut acc = bus + FEE::from(*addr) * powers[0]; + for (k, c) in value.value().iter().enumerate() { + acc += c.to_extension::() * powers[1 + k]; + } + *z - acc + }) + .collect(); + FieldElement::inplace_batch_inverse(&mut fingerprints).ok()?; + Some(fingerprints.iter().fold(FEE::zero(), |acc, t| acc + t)) +} + +/// The state bus is closed by the public start and halt states. Its sign is +/// start (+1) minus halt (-1), matching the segment SEL multiplicities. +pub(crate) fn expected_state_balance(z: &FEE, alpha: &FEE) -> Option { + fn fingerprint(words: &[u64], z: &FEE, alpha: &FEE) -> FEE { + let bus = FEE::from(BusId::FieldVmState as u64); + let mut power = *alpha; + let mut token = bus; + for &word in words { + token += FEE::from(word) * power; + power *= alpha; + } + *z - token + } + let start = fingerprint(&air::start_words(), z, alpha); + let halt = fingerprint(&air::halt_words(), z, alpha); + let mut terms = [start, halt]; + FieldElement::inplace_batch_inverse(&mut terms).ok()?; + Some(terms[0] - terms[1]) +} + +/// Replays the prover's Phase A (main commitments) to recover the LogUp +/// challenges `(z, α)`. +fn logup_challenges(airs: &Airs, proof: &FvmProof, transcript: &mut FvmTranscript) -> (FEE, FEE) { + for (air, p) in airs.refs().into_iter().zip(&proof.proofs) { + if air.is_preprocessed() { + transcript.append_bytes(&air.precomputed_commitment()); + } + transcript.append_bytes(&p.lde_trace_main_merkle_root); + } + ( + transcript.sample_field_element(), + transcript.sample_field_element(), + ) +} + +/// One Merkle root per DECODE chunk. +pub type ProgramId = Vec; + +pub fn program_id(program: &Program, options: &ProofOptions) -> ProgramId { + decode::program_commitment(program, options, MIN_ROWS) +} + +/// [`program_id`] under the trace trees' leaf `layout`. +pub fn program_id_for( + program: &Program, + options: &ProofOptions, + layout: stark::leaf_layout::LeafLayout, +) -> ProgramId { + decode::program_commitment_for(program, options, MIN_ROWS, layout) +} + +/// The traces of one execution: the FIELD_VM segments, DECODE and MEM. +pub struct FvmTraces { + pub fvm: Vec>, + pub segments: Vec, + pub decode: Vec>, + pub mem: TraceTable, +} + +/// `public` are the addresses of the public cells. +pub fn generate_traces(program: &Program, exec: &Execution, public: &[u64]) -> FvmTraces { + generate_traces_with(program, exec, public, None) +} + +/// [`generate_traces`] with every FIELD_VM segment capped at `max_rows`. +pub fn generate_traces_with( + program: &Program, + exec: &Execution, + public: &[u64], + max_rows: Option, +) -> FvmTraces { + generate_traces_reading(program, exec, public, max_rows, &[]) +} + +/// [`generate_traces_with`] where MEM also serves one read of each address in +/// `extra_reads` to a table outside the Field VM. +pub fn generate_traces_reading( + program: &Program, + exec: &Execution, + public: &[u64], + max_rows: Option, + extra_reads: &[u64], +) -> FvmTraces { + generate_traces_lfm(program, exec, public, max_rows, extra_reads, None) +} + +/// [`generate_traces_reading`] with MEM on `LfmMem` for `lfm` cells. +pub fn generate_traces_lfm( + program: &Program, + exec: &Execution, + public: &[u64], + max_rows: Option, + extra_reads: &[u64], + lfm: Option<&[mem::LfmCell]>, +) -> FvmTraces { + let mut seg = air::generate_segments(program, exec, MIN_ROWS, max_rows); + for &a in extra_reads { + let a = a as usize; + if seg.mem_mult.len() <= a { + seg.mem_mult.resize(a + 1, 0); + } + seg.mem_mult[a] += 1; + } + FvmTraces { + fvm: seg.traces, + segments: seg.public, + decode: decode::generate_traces(program, &seg.decode_mult, MIN_ROWS), + mem: match lfm { + Some(cells) => { + mem::generate_trace_lfm(&exec.mem, &seg.mem_mult, public, MIN_ROWS, cells) + } + None => mem::generate_trace(&exec.mem, &seg.mem_mult, public, MIN_ROWS), + }, + } +} + +pub fn prove_traces( + program_id: &[Commitment], + public: &[PublicCell], + traces: &mut FvmTraces, + options: &ProofOptions, +) -> Result { + prove_traces_with( + program_id, + public, + traces, + Vec::new(), + &mut [], + false, + options, + ) +} + +/// [`prove_traces`] with `extra` tables, one trace each, after MEM. +pub fn prove_traces_with( + program_id: &[Commitment], + public: &[PublicCell], + traces: &mut FvmTraces, + extra: Vec, + extra_traces: &mut [TraceTable], + mem_lfm: bool, + options: &ProofOptions, +) -> Result { + assert_eq!(extra.len(), extra_traces.len(), "one trace per extra table"); + let none = FvmPublicInputs::default(); + let n = traces.fvm.len(); + let airs = Airs::new(program_id, options, n, extra, mem_lfm); + let refs = airs.refs(); + let mut pairs: Vec<_> = refs + .iter() + .zip(traces.fvm.iter_mut()) + .zip(&traces.segments) + .map(|((air, trace), pi)| (*air, trace, pi)) + .collect(); + let chunks = traces.decode.len(); + for (air, trace) in refs[n..n + chunks].iter().zip(traces.decode.iter_mut()) { + pairs.push((*air, trace, &none)); + } + pairs.push((refs[n + chunks], &mut traces.mem, &none)); + for (air, trace) in refs[n + chunks + 1..].iter().zip(extra_traces.iter_mut()) { + pairs.push((*air, trace, &none)); + } + stark::prover::GenericProver::::multi_prove( + pairs, + &mut transcript(program_id, public, &traces.segments), + #[cfg(feature = "disk-spill")] + StorageMode::Ram, + stark::residency_mode::ResidencyMode::default(), + ) +} + +/// Proves `exec` with the cells at `public` as the public inputs and outputs. +pub fn prove( + program: &Program, + exec: &Execution, + public: &[u64], + options: &ProofOptions, +) -> Result { + prove_traces( + &program_id(program, options), + &public_cells(exec, public), + &mut generate_traces(program, exec, public), + options, + ) +} + +/// The segments of segment proofs `trace_lengths` tall form the chain: it +/// starts at the start state, each segment ends where the next begins, and the +/// last ends in the halt state. +pub(crate) fn chained(trace_lengths: &[usize], segments: &[FvmPublicInputs]) -> bool { + let well_formed = trace_lengths.len() == segments.len() + && trace_lengths.iter().zip(segments).all(|(&len, pi)| { + pi.last_row + 1 == len + && pi.first.len() == air::STATE_WORDS + && pi.last.len() == air::STATE_WORDS + }); + well_formed + && segments.windows(2).all(|w| w[0].last == w[1].first) + && segments + .first() + .is_some_and(|s| s.first == air::start_words()) + && segments.last().is_some_and(|s| s.last == air::halt_words()) +} + +/// Public cells at canonical field-element addresses, strictly increasing. +pub(crate) fn public_well_formed(public: &[PublicCell]) -> bool { + public + .iter() + .all(|(addr, _)| *addr < math::field::goldilocks::GOLDILOCKS_PRIME) + && public.windows(2).all(|w| w[0].0 < w[1].0) +} + +/// Verifies that `program_id` halts with memory holding `public`, whose +/// addresses must be canonical field elements and strictly increasing. +pub fn verify( + program_id: &[Commitment], + public: &[PublicCell], + proof: &FvmProof, + options: &ProofOptions, +) -> bool { + verify_with(program_id, public, proof, Vec::new(), false, options) +} + +/// [`verify`] for a proof carrying `extra` tables after MEM. +pub fn verify_with( + program_id: &[Commitment], + public: &[PublicCell], + proof: &FvmProof, + extra: Vec, + mem_lfm: bool, + options: &ProofOptions, +) -> bool { + if !public_well_formed(public) { + return false; + } + let Some(n) = proof + .proofs + .len() + .checked_sub(program_id.len() + 1 + extra.len()) + .filter(|&n| n >= 1) + else { + return false; + }; + let (fvm, rest) = proof.proofs.split_at(n); + if rest + .iter() + .any(|p| p.public_inputs != FvmPublicInputs::default()) + { + return false; + } + let segments: Vec = fvm.iter().map(|p| p.public_inputs.clone()).collect(); + let lengths: Vec = fvm.iter().map(|p| p.trace_length).collect(); + if !chained(&lengths, &segments) { + return false; + } + let airs = Airs::new(program_id, options, n, extra, mem_lfm); + let mut t = transcript(program_id, public, &segments); + let (z, alpha) = logup_challenges(&airs, proof, &mut t.clone()); + let Some(public_balance) = expected_public_balance(public, &z, &alpha) else { + return false; + }; + let Some(state_balance) = expected_state_balance(&z, &alpha) else { + return false; + }; + let expected = public_balance + state_balance; + stark::verifier::GenericVerifier::::multi_verify( + &airs.refs(), + proof, + &mut t, + &expected, + ) +} diff --git a/prover/src/field_vm/prove_tests.rs b/prover/src/field_vm/prove_tests.rs new file mode 100644 index 000000000..18ffdbeba --- /dev/null +++ b/prover/src/field_vm/prove_tests.rs @@ -0,0 +1,651 @@ +use super::air::cols; +use super::asm::Asm; +#[cfg_attr(any(feature = "fvm-narrow", feature = "fvm-n1"), allow(unused_imports))] +use super::executor::{Execution, HintRequest, Memory, NoHints, execute}; +use super::isa::{Arg, Program, gpr}; +use super::mem::cols as mem_cols; +use super::prove::{ + FvmTraces, PublicCell, default_options, generate_traces, generate_traces_with, program_id, + prove, prove_traces, public_cells, verify, +}; +use crate::tables::types::{FE, FEE}; + +fn fee(a: u64, b: u64, c: u64) -> FEE { + FEE::new([FE::from(a), FE::from(b), FE::from(c)]) +} + +fn power_program(n: i64) -> Program { + if super::isa::N == 1 { + // One register: the power as a straight-line chain. + let acc = gpr(0); + let mut asm = Asm::new(); + asm.mov(Arg::reg(acc), Arg::imm(1)); + for _ in 0..n { + asm.mul(Arg::reg(acc), Arg::reg(acc), Arg::mem_abs(0)); + } + asm.store(Arg::mem_abs(1), Arg::reg(acc)).halt(); + return asm.finish().unwrap(); + } + let (acc, cnt) = (gpr(0), gpr(1)); + let mut asm = Asm::new(); + asm.mov(Arg::reg(acc), Arg::imm(1)) + .mov(Arg::reg(cnt), Arg::imm(n)); + let top = asm.label(); + asm.bind(top); + asm.mul(Arg::reg(acc), Arg::reg(acc), Arg::mem_abs(0)) + .sub(Arg::reg(cnt), Arg::reg(cnt), Arg::imm(1)) + .jump_not_zero(top) + .store(Arg::mem_abs(1), Arg::reg(acc)) + .halt(); + asm.finish().unwrap() +} + +fn prove_and_verify(program: &Program, exec: &Execution, public: &[u64]) -> bool { + let options = default_options(); + let proof = prove(program, exec, public, &options).expect("proving"); + verify( + &program_id(program, &options), + &public_cells(exec, public), + &proof, + &options, + ) +} + +#[test] +fn power_loop_proves_and_verifies() { + let program = power_program(13); + let exec = execute( + &program, + Memory::with_init(2, &[fee(2, 1, 9)]), + &mut NoHints, + 1 << 12, + ) + .unwrap(); + assert!(prove_and_verify(&program, &exec, &[0, 1])); +} + +#[cfg(not(feature = "fvm-n1"))] +#[test] +fn inv_and_straight_line_prove_and_verify() { + let (r, a) = (gpr(0), gpr(1)); + let mut asm = Asm::new(); + asm.mov(Arg::reg(a), Arg::mem_abs(0)) + .inv(r, Arg::reg(a)) + .fma(Arg::mem_abs(1), Arg::reg(r), Arg::mem_abs(0), Arg::imm(0)) + .halt(); + let program = asm.finish().unwrap(); + let exec = execute( + &program, + Memory::with_init(2, &[fee(5, 6, 7), FEE::one()]), + &mut NoHints, + 64, + ) + .unwrap(); + assert!(prove_and_verify(&program, &exec, &[0, 1])); +} + +#[cfg(not(any(feature = "fvm-narrow", feature = "fvm-n1")))] +#[test] +fn recursive_calls_prove_and_verify() { + let (fp, n, t, res) = (gpr(0), gpr(1), gpr(2), gpr(3)); + let mut asm = Asm::new(); + let fact = asm.label(); + asm.mov(Arg::reg(fp), Arg::imm(8)) + .call(fact, fp, &[], &[Arg::imm(4)], Some(res)) + .store(Arg::mem_abs(0), Arg::reg(res)) + .halt(); + asm.bind(fact); + let base = asm.label(); + asm.mov(Arg::reg(n), Arg::mem(fp, 2)).jump_zero(base); + asm.sub(Arg::reg(t), Arg::reg(n), Arg::imm(1)) + .call(fact, fp, &[n], &[Arg::reg(t)], Some(res)) + .mul(Arg::reg(res), Arg::reg(res), Arg::reg(n)) + .store(Arg::mem(fp, 3), Arg::reg(res)) + .ret(fp); + asm.bind(base); + asm.store(Arg::mem(fp, 3), Arg::imm(1)).ret(fp); + let program = asm.finish().unwrap(); + let mut next = 16u64; + let mut frames = |_: &HintRequest| { + next += 16; + Some(FEE::from(next)) + }; + let exec = execute(&program, Memory::new(128), &mut frames, 1 << 12).unwrap(); + assert_eq!(exec.mem[0], FEE::from(24u64)); + assert!(prove_and_verify(&program, &exec, &[0])); +} + +fn power_exec() -> (Program, Execution) { + let program = power_program(3); + let exec = execute( + &program, + Memory::with_init(2, &[fee(2, 1, 9)]), + &mut NoHints, + 1 << 12, + ) + .unwrap(); + (program, exec) +} + +/// The power program's input `x` and its output `x^3`. +const POWER_PUBLIC: [u64; 2] = [0, 1]; + +/// Proves `exec` after `tamper` edits its traces, with [`POWER_PUBLIC`] +/// flagged; `true` iff the proof verifies against `claimed`. +fn verifies_with( + program: &Program, + exec: &Execution, + claimed: &[PublicCell], + tamper: impl FnOnce(&mut FvmTraces), +) -> bool { + let options = default_options(); + let id = program_id(program, &options); + let proved = public_cells(exec, &POWER_PUBLIC); + let mut traces = generate_traces(program, exec, &POWER_PUBLIC); + tamper(&mut traces); + match prove_traces(&id, &proved, &mut traces, &options) { + Ok(proof) => verify(&id, claimed, &proof, &options), + Err(_) => false, + } +} + +fn verifies_after( + program: &Program, + exec: &Execution, + tamper: impl FnOnce(&mut FvmTraces), +) -> bool { + verifies_with(program, exec, &public_cells(exec, &POWER_PUBLIC), tamper) +} + +fn bump( + t: &mut stark::table::Table, + row: usize, + col: usize, +) { + let v = *t.get(row, col); + t.set(row, col, v + FE::one()); +} + +#[test] +fn untampered_traces_verify() { + let (program, exec) = power_exec(); + assert!(verifies_after(&program, &exec, |_| {})); +} + +#[cfg(not(any(feature = "fvm-narrow", feature = "fvm-n1")))] +#[test] +fn register_changed_without_a_hint_is_rejected() { + let (program, exec) = power_exec(); + // General-purpose register 4 is never hinted by this program. + assert!(!verifies_after(&program, &exec, |t| bump( + &mut t.fvm[0].main_table, + 3, + cols::reg(4) + ))); +} + +#[test] +fn pc_skipping_without_a_hint_is_rejected() { + let (program, exec) = power_exec(); + assert!(!verifies_after(&program, &exec, |t| bump( + &mut t.fvm[0].main_table, + 2, + cols::PC + ))); +} + +#[test] +fn lying_zero_flag_is_rejected() { + let (program, exec) = power_exec(); + assert!(!verifies_after(&program, &exec, |t| { + let z = *t.fvm[0].main_table.get(4, cols::ZERO); + t.fvm[0].main_table.set(4, cols::ZERO, FE::one() - z); + })); +} + +#[test] +fn wrong_fma_output_is_rejected() { + let (program, exec) = power_exec(); + assert!(!verifies_after(&program, &exec, |t| bump( + &mut t.fvm[0].main_table, + 1, + cols::arg(0) + ))); +} + +#[test] +fn out_of_range_register_index_is_rejected() { + let (program, exec) = power_exec(); + assert!(!verifies_after(&program, &exec, |t| { + t.fvm[0] + .main_table + .set(1, cols::ARG_REG + 1, FE::from(super::isa::NUM_REGS as u64)); + })); +} + +#[test] +fn forged_memory_value_is_rejected() { + let (program, exec) = power_exec(); + assert!(!verifies_after(&program, &exec, |t| bump( + &mut t.mem.main_table, + 0, + mem_cols::VALUE + ))); +} + +#[test] +fn duplicate_memory_address_is_rejected() { + let (program, exec) = power_exec(); + assert!(!verifies_after(&program, &exec, |t| t.mem.main_table.set( + 1, + mem_cols::ADDR, + FE::zero() + ))); +} + +#[test] +fn tampered_program_column_is_rejected() { + let (program, exec) = power_exec(); + assert!(!verifies_after(&program, &exec, |t| bump( + &mut t.decode[0].main_table, + 5, + 1 + ))); +} + +#[test] +fn execution_that_never_halts_is_rejected() { + let (program, mut exec) = power_exec(); + exec.steps.truncate(exec.steps.len() - 2); + assert!(!verifies_after(&program, &exec, |_| {})); +} + +#[test] +fn proof_does_not_verify_for_another_program() { + let (program, exec) = power_exec(); + let options = default_options(); + let proof = prove(&program, &exec, &POWER_PUBLIC, &options).unwrap(); + assert!(!verify( + &program_id(&power_program(4), &options), + &public_cells(&exec, &POWER_PUBLIC), + &proof, + &options + )); +} + +#[test] +fn lying_last_row_is_rejected() { + let (program, exec) = power_exec(); + let options = default_options(); + let mut proof = prove(&program, &exec, &POWER_PUBLIC, &options).unwrap(); + proof.proofs[0].public_inputs.last_row -= 1; + assert!(!verify( + &program_id(&program, &options), + &public_cells(&exec, &POWER_PUBLIC), + &proof, + &options + )); +} + +fn with_value(cells: &[PublicCell], i: usize, v: FEE) -> Vec { + let mut cells = cells.to_vec(); + cells[i].1 = v; + cells +} + +#[test] +fn wrong_public_output_is_rejected() { + let (program, exec) = power_exec(); + let cells = public_cells(&exec, &POWER_PUBLIC); + let forged = with_value(&cells, 1, cells[1].1 + FEE::one()); + assert!(!verifies_with(&program, &exec, &forged, |_| {})); +} + +#[test] +fn wrong_public_input_is_rejected() { + let (program, exec) = power_exec(); + let cells = public_cells(&exec, &POWER_PUBLIC); + let forged = with_value(&cells, 0, fee(2, 1, 8)); + assert!(!verifies_with(&program, &exec, &forged, |_| {})); +} + +#[test] +fn missing_or_extra_public_cells_are_rejected() { + let (program, exec) = power_exec(); + let cells = public_cells(&exec, &POWER_PUBLIC); + assert!(!verifies_with(&program, &exec, &cells[..1], |_| {})); + assert!(!verifies_with(&program, &exec, &[], |_| {})); + let mut extra = cells.clone(); + extra.push((5, FEE::zero())); + assert!(!verifies_with(&program, &exec, &extra, |_| {})); +} + +#[test] +fn unsorted_or_repeated_public_claims_are_rejected() { + let (program, exec) = power_exec(); + let cells = public_cells(&exec, &POWER_PUBLIC); + let reversed: Vec<_> = cells.iter().rev().copied().collect(); + assert!(!verifies_with(&program, &exec, &reversed, |_| {})); + assert!(!verifies_with( + &program, + &exec, + &[cells[0], cells[0]], + |_| {} + )); +} + +#[test] +fn non_canonical_public_address_is_rejected() { + let (program, exec) = power_exec(); + let cells = public_cells(&exec, &POWER_PUBLIC); + let p = math::field::goldilocks::GOLDILOCKS_PRIME; + let aliased = [cells[0], (cells[1].0 + p, cells[1].1)]; + let options = default_options(); + let id = program_id(&program, &options); + let mut traces = generate_traces(&program, &exec, &POWER_PUBLIC); + let proof = prove_traces(&id, &aliased, &mut traces, &options).unwrap(); + assert!(!verify(&id, &aliased, &proof, &options)); +} + +#[test] +fn programs_start_with_the_halt_loop_and_start_nop() { + let program = power_program(3); + assert_eq!(program.instrs[0], super::isa::Instr::halt()); + assert_eq!(program.instrs[1], super::isa::Instr::nop()); +} + +#[test] +fn flagging_an_unclaimed_cell_is_rejected() { + let (program, exec) = power_exec(); + assert!(!verifies_after(&program, &exec, |t| { + t.mem.main_table.set(5, mem_cols::PUB, FE::one()) + })); +} + +#[test] +fn non_bit_public_flag_is_rejected() { + let (program, exec) = power_exec(); + assert!(!verifies_after(&program, &exec, |t| { + t.mem.main_table.set(1, mem_cols::PUB, FE::from(2u64)) + })); +} + +#[cfg(not(any(feature = "fvm-narrow", feature = "fvm-n1")))] +fn horner_program(len: usize) -> Program { + let (acc, i, x) = (gpr(0), gpr(1), gpr(2)); + let mut asm = Asm::new(); + asm.mov(Arg::reg(x), Arg::mem_abs(0)) + .mov(Arg::reg(acc), Arg::imm(0)) + .mov(Arg::reg(i), Arg::imm(len as i64)); + let top = asm.label(); + asm.bind(top); + asm.fma_out(Arg::reg(acc), Arg::reg(acc), Arg::reg(x), Arg::mem(i, 0)) + .sub(Arg::reg(i), Arg::reg(i), Arg::imm(1)) + .jump_not_zero(top) + .store(Arg::mem_abs(len as u64 + 1), Arg::reg(acc)) + .halt(); + asm.finish().unwrap() +} + +/// Sizes and timings of a Horner evaluation (3 rows per coefficient). +/// `cargo test --release -p lambda-vm-prover --lib field_vm::prove_tests::report_horner -- --ignored --nocapture` +#[cfg(not(any(feature = "fvm-narrow", feature = "fvm-n1")))] +#[test] +#[ignore] +fn report_horner() { + use std::time::Instant; + let options = default_options(); + for len in [1usize << 10, 1 << 14, 1 << 16] { + let program = horner_program(len); + let init: Vec = (0..=len as u64) + .map(|k| fee(k + 3, 2 * k + 1, k * k + 7)) + .collect(); + let exec = execute( + &program, + Memory::with_init(len + 2, &init), + &mut NoHints, + 1 << 22, + ) + .unwrap(); + let id = program_id(&program, &options); + let public: Vec = (0..=len as u64 + 1).collect(); + let cells_pub = public_cells(&exec, &public); + let traces = generate_traces(&program, &exec, &public); + let rows = [ + traces.fvm.iter().map(|t| t.num_rows()).sum::(), + traces.decode.iter().map(|t| t.num_rows()).sum::(), + traces.mem.num_rows(), + ]; + // Main width + 3 base columns per LogUp aux column (⌈interactions / 2⌉). + let widths = [ + cols::NUM_COLUMNS + 3 * 3, + super::decode::NUM_COLUMNS + 3, + mem_cols::NUM_COLUMNS + 3, + ]; + let cells: usize = rows.iter().zip(widths).map(|(r, w)| r * w).sum(); + let mut prove_ms = Vec::new(); + let mut verify_ms = Vec::new(); + let mut size = 0; + for _ in 0..5 { + let mut t = generate_traces(&program, &exec, &public); + let start = Instant::now(); + let proof = prove_traces(&id, &cells_pub, &mut t, &options).unwrap(); + prove_ms.push(start.elapsed().as_secs_f64() * 1e3); + let start = Instant::now(); + assert!(verify(&id, &cells_pub, &proof, &options)); + verify_ms.push(start.elapsed().as_secs_f64() * 1e3); + size = rkyv::to_bytes::(&proof).unwrap().len(); + } + prove_ms.sort_by(f64::total_cmp); + verify_ms.sort_by(f64::total_cmp); + eprintln!( + "len={len} steps={} rows(fvm,dec,mem)={rows:?} cells={cells} prove_ms(min/med/max)={:.1}/{:.1}/{:.1} verify_ms(med)={:.1} proof_bytes={size}", + exec.steps.len(), + prove_ms[0], + prove_ms[2], + prove_ms[4], + verify_ms[2] + ); + } +} + +/// The power program at `n = 13`: long enough for an 8-row cap to split it +/// into many segments. +fn long_power_exec() -> (Program, Execution) { + let program = power_program(13); + let exec = execute( + &program, + Memory::with_init(2, &[fee(2, 1, 9)]), + &mut NoHints, + 1 << 12, + ) + .unwrap(); + (program, exec) +} + +/// Proves `exec` with FIELD_VM split into segments of at most `max_rows`. +fn segmented_proof( + program: &Program, + exec: &Execution, + max_rows: usize, +) -> (super::prove::FvmProof, Vec) { + let options = default_options(); + let cells = public_cells(exec, &POWER_PUBLIC); + let mut traces = generate_traces_with(program, exec, &POWER_PUBLIC, Some(max_rows)); + assert!(traces.fvm.len() > 2, "the cap must force several segments"); + let proof = prove_traces( + &program_id(program, &options), + &cells, + &mut traces, + &options, + ) + .unwrap(); + (proof, cells) +} + +#[test] +fn segmented_execution_proves_and_verifies() { + let (program, exec) = long_power_exec(); + let options = default_options(); + let (proof, cells) = segmented_proof(&program, &exec, 8); + assert!(verify( + &program_id(&program, &options), + &cells, + &proof, + &options + )); +} + +#[test] +fn dropped_or_reordered_segments_are_rejected() { + let (program, exec) = long_power_exec(); + let options = default_options(); + let id = program_id(&program, &options); + let (proof, cells) = segmented_proof(&program, &exec, 8); + let mut dropped = proof.clone(); + dropped.proofs.remove(1); + assert!(!verify(&id, &cells, &dropped, &options)); + let mut swapped = proof.clone(); + swapped.proofs.swap(0, 1); + assert!(!verify(&id, &cells, &swapped, &options)); +} + +#[test] +fn forged_segment_boundary_is_rejected() { + let (program, exec) = long_power_exec(); + let options = default_options(); + let id = program_id(&program, &options); + let (proof, cells) = segmented_proof(&program, &exec, 8); + // Consistent on both sides of the boundary, so only the traces can object. + let mut forged = proof.clone(); + forged.proofs[0].public_inputs.last[2] += 1; + forged.proofs[1].public_inputs.first[2] += 1; + assert!(!verify(&id, &cells, &forged, &options)); + // Inconsistent across the boundary. + let mut broken = proof; + broken.proofs[1].public_inputs.first[0] += 1; + assert!(!verify(&id, &cells, &broken, &options)); +} + +#[test] +fn segment_plan_covers_every_step() { + for steps in [1usize, 7, 8, 9, 100, 4851, 19_320, 21_234, 538_706] { + for cap in [None, Some(8), Some(64)] { + let plan = super::air::segment_plan(steps, 8, cap); + assert_eq!( + plan.iter().map(|(real, _)| real).sum::(), + steps, + "{steps} {cap:?}" + ); + for (k, &(real, rows)) in plan.iter().enumerate() { + assert!(rows.is_power_of_two() && rows >= 8); + if k + 1 < plan.len() { + assert_eq!(real + 1, rows); + } else { + assert!(real <= rows); + } + if let Some(c) = cap { + assert!(rows <= c.max(8)); + } + } + if cap.is_none() { + assert!(plan.len() <= super::air::MAX_SEGMENTS); + } + } + } + // The default bound; `FVM_SEGMENT_WASTE` moves the plan. + if std::env::var_os("FVM_SEGMENT_WASTE").is_none() { + assert_eq!( + super::air::segment_plan(538_706, 8, None), + vec![(524_287, 524_288), (14_419, 16_384)] + ); + } +} + +#[test] +fn decode_chunks_cover_the_program() { + for len in [2usize, 9, 100, 5000, 16_385, 538_706] { + let program = super::isa::Program::new(vec![super::isa::Instr::nop(); len - 2]).unwrap(); + let plan = super::decode::chunks(&program, 8); + assert!(plan.len() <= super::decode::MAX_CHUNKS); + let mut next = 0; + for &(start, n, rows) in &plan { + assert_eq!(start, next); + assert!(rows.is_power_of_two() && n <= rows && rows >= 8); + next += n; + } + assert_eq!(next, len); + } +} + +/// With a single register the translation still borrows it for selects and +/// chains; a one-register program proves, and a forged register is rejected. +#[test] +fn one_register_program_proves_and_rejects_forgery() { + let (program, exec) = power_exec(); + let options = default_options(); + let proof = prove(&program, &exec, &POWER_PUBLIC, &options).unwrap(); + let cells = public_cells(&exec, &POWER_PUBLIC); + assert!(verify( + &program_id(&program, &options), + &cells, + &proof, + &options + )); + assert!(!verifies_after(&program, &exec, |t| bump( + &mut t.fvm[0].main_table, + 2, + cols::reg(0) + ))); +} + +/// The translated bit decomposition (option B's query bits) proves only the +/// canonical bits of `x`: those of `x + p`, which sum to `x` too, are proved +/// from that witness and rejected, and only its canonicality row breaks. +#[test] +fn non_canonical_bit_decomposition_is_rejected() { + const P: u64 = 0xFFFF_FFFF_0000_0001; + let x = 5u64; + let run = |v: u64| { + let mut mem = vec![FEE::from(x)]; + let bits: [u64; 64] = core::array::from_fn(|i| { + mem.push(FEE::from((v >> i) & 1)); + i as u64 + 1 + }); + let mut asm = Asm::new(); + super::lfm_translate::canonical_bit_dec(&mut asm, &mut mem, Arg::mem_abs(0), &bits, gpr(0)); + asm.halt(); + let program = asm.finish().unwrap(); + let (exec, violated) = super::executor::execute_violating( + &program, + Memory::with_init(mem.len(), &mem), + &mut NoHints, + 1 << 12, + ) + .unwrap(); + (program, exec, violated) + }; + let public: Vec = (0..=64).collect(); + let verifies = |program: &Program, exec: &Execution| { + let options = default_options(); + prove(program, exec, &public, &options).is_ok_and(|proof| { + verify( + &program_id(program, &options), + &public_cells(exec, &public), + &proof, + &options, + ) + }) + }; + let (program, honest, violated) = run(x); + assert!(violated.is_empty()); + assert!(verifies(&program, &honest), "the canonical bits"); + let (program, forged, violated) = run(x + P); + assert_ne!( + public_cells(&forged, &public), + public_cells(&honest, &public), + "the bits of x + p differ" + ); + assert!(!verifies(&program, &forged), "the bits of x + p"); + assert_eq!(violated.len(), 1, "only the canonicality row breaks"); +} diff --git a/prover/src/field_vm/tests.rs b/prover/src/field_vm/tests.rs new file mode 100644 index 000000000..2b2c59b5b --- /dev/null +++ b/prover/src/field_vm/tests.rs @@ -0,0 +1,318 @@ +use super::asm::{Asm, AsmError}; +#[cfg_attr(any(feature = "fvm-narrow", feature = "fvm-n1"), allow(unused_imports))] +use super::executor::{ExecError, Execution, HintKind, HintRequest, Memory, NoHints, execute}; +use super::isa::{Arg, HALT_PC, Instr, N, Program, REG_PC, REG_ZERO, gpr}; +use crate::tables::types::{FE, FEE}; + +const MAX_STEPS: usize = 1 << 16; + +fn fee(a: u64, b: u64, c: u64) -> FEE { + FEE::new([FE::from(a), FE::from(b), FE::from(c)]) +} + +fn run(program: &Program, mem: Memory) -> Result { + execute(program, mem, &mut NoHints, MAX_STEPS) +} + +fn assert_bookkeeping(program: &Program, exec: &Execution) { + assert_eq!( + exec.decode_mult.iter().sum::(), + exec.steps.len() as u64 + ); + let mem_accesses: u64 = exec + .steps + .iter() + .map(|s| { + program.instrs[s.state.pc as usize] + .args() + .iter() + .filter(|a| a.mem) + .count() as u64 + }) + .sum(); + assert_eq!(exec.mem_mult.iter().sum::(), mem_accesses); + + let last = exec.steps.last().unwrap(); + assert_eq!(last.state.pc, HALT_PC); + assert!(last.state.zero); + assert_eq!(last.state.regs, [FEE::zero(); N]); + assert_eq!(last.args, [FEE::zero(); 4]); +} + +#[test] +fn straight_line_fma_matches_native() { + let (x, y, z) = (fee(3, 5, 7), fee(11, 13, 17), fee(19, 23, 29)); + let mut asm = Asm::new(); + asm.fma( + Arg::mem_abs(3), + Arg::mem_abs(0), + Arg::mem_abs(1), + Arg::mem_abs(2), + ) + .halt(); + let program = asm.finish().unwrap(); + let exec = run(&program, Memory::with_init(8, &[x, y, z])).unwrap(); + assert_eq!(exec.mem[3], x * y + z); + assert_eq!(exec.mem_mult[..4], [1, 1, 1, 1]); + assert_bookkeeping(&program, &exec); +} + +#[cfg(not(any(feature = "fvm-narrow", feature = "fvm-n1")))] +#[test] +fn loop_computes_power() { + let (acc, cnt, x) = (gpr(0), gpr(1), gpr(2)); + let base = fee(2, 1, 9); + let n = 37; + let mut asm = Asm::new(); + asm.mov(Arg::reg(acc), Arg::imm(1)) + .mov(Arg::reg(x), Arg::mem_abs(0)) + .mov(Arg::reg(cnt), Arg::imm(n)); + let top = asm.label(); + asm.bind(top); + asm.mul(Arg::reg(acc), Arg::reg(acc), Arg::reg(x)) + .sub(Arg::reg(cnt), Arg::reg(cnt), Arg::imm(1)) + .jump_not_zero(top) + .store(Arg::mem_abs(1), Arg::reg(acc)) + .halt(); + let program = asm.finish().unwrap(); + let exec = run(&program, Memory::with_init(2, &[base])).unwrap(); + assert_eq!(exec.mem[1], base.pow(n as u64)); + assert_eq!(exec.steps.len(), 2 + 3 + 3 * n as usize + 2); + assert_bookkeeping(&program, &exec); +} + +#[test] +fn jump_zero_takes_and_skips() { + for (v, expected) in [(0, 1), (5, 2)] { + let r = gpr(0); + let mut asm = Asm::new(); + let taken = asm.label(); + asm.mov(Arg::reg(r), Arg::imm(v)).jump_zero(taken); + asm.store(Arg::mem_abs(0), Arg::imm(2)).halt(); + asm.bind(taken); + asm.store(Arg::mem_abs(0), Arg::imm(1)).halt(); + let program = asm.finish().unwrap(); + let exec = run(&program, Memory::new(1)).unwrap(); + assert_eq!(exec.mem[0], FEE::from(expected as u64)); + assert_bookkeeping(&program, &exec); + } +} + +#[cfg(not(any(feature = "fvm-narrow", feature = "fvm-n1")))] +#[test] +fn horner_over_indexed_memory() { + let (acc, i, x) = (gpr(0), gpr(1), gpr(2)); + let coeffs: Vec = (0..20u64).map(|k| fee(k + 1, 3 * k, k * k)).collect(); + let point = fee(7, 0, 1); + // MEM[0] = x, MEM[1..=20] = coeffs (low to high), result at MEM[21]. + let mut init = vec![point]; + init.extend(&coeffs); + let mut asm = Asm::new(); + asm.mov(Arg::reg(x), Arg::mem_abs(0)) + .mov(Arg::reg(acc), Arg::imm(0)) + .mov(Arg::reg(i), Arg::imm(coeffs.len() as i64)); + let top = asm.label(); + asm.bind(top); + asm.fma_out(Arg::reg(acc), Arg::reg(acc), Arg::reg(x), Arg::mem(i, 0)) + .sub(Arg::reg(i), Arg::reg(i), Arg::imm(1)) + .jump_not_zero(top) + .store(Arg::mem_abs(21), Arg::reg(acc)) + .halt(); + let program = asm.finish().unwrap(); + let exec = run(&program, Memory::with_init(22, &init)).unwrap(); + let expected = coeffs + .iter() + .rev() + .fold(FEE::zero(), |acc, c| acc * point + c); + assert_eq!(exec.mem[21], expected); + assert_bookkeeping(&program, &exec); +} + +#[cfg(not(feature = "fvm-n1"))] +#[test] +fn inv_is_solved_from_the_constraint() { + let (r, a) = (gpr(0), gpr(1)); + let v = fee(123, 456, 789); + let mut asm = Asm::new(); + asm.mov(Arg::reg(a), Arg::mem_abs(0)) + .inv(r, Arg::reg(a)) + .store(Arg::mem_abs(1), Arg::reg(r)) + .halt(); + let program = asm.finish().unwrap(); + let exec = run(&program, Memory::with_init(2, &[v])).unwrap(); + assert_eq!(exec.mem[1] * v, FEE::one()); + assert_bookkeeping(&program, &exec); +} + +#[cfg(not(feature = "fvm-n1"))] +#[test] +fn inv_of_zero_is_unresolved() { + let (r, a) = (gpr(0), gpr(1)); + let mut asm = Asm::new(); + asm.mov(Arg::reg(a), Arg::imm(0)).inv(r, Arg::reg(a)).halt(); + let program = asm.finish().unwrap(); + let pc = 3; + assert_eq!( + run(&program, Memory::new(0)).unwrap_err(), + ExecError::UnresolvedHint { pc, reg: r } + ); +} + +/// Recursive factorial through `CALL`/`RET`, with fresh frames from the oracle. +#[cfg(not(any(feature = "fvm-narrow", feature = "fvm-n1")))] +#[test] +fn call_ret_recursive_factorial() { + let (fp, n, t, res) = (gpr(0), gpr(1), gpr(2), gpr(3)); + let mut asm = Asm::new(); + let fact = asm.label(); + asm.mov(Arg::reg(fp), Arg::imm(8)) + .call(fact, fp, &[], &[Arg::imm(6)], Some(res)) + .store(Arg::mem_abs(0), Arg::reg(res)) + .halt(); + + // fact: n = MEM[fp + 2]; MEM[fp + 3] = n == 0 ? 1 : n * fact(n - 1). + asm.bind(fact); + let base = asm.label(); + asm.mov(Arg::reg(n), Arg::mem(fp, 2)).jump_zero(base); + asm.sub(Arg::reg(t), Arg::reg(n), Arg::imm(1)) + .call(fact, fp, &[n], &[Arg::reg(t)], Some(res)) + .mul(Arg::reg(res), Arg::reg(res), Arg::reg(n)) + .store(Arg::mem(fp, 3), Arg::reg(res)) + .ret(fp); + asm.bind(base); + asm.store(Arg::mem(fp, 3), Arg::imm(1)).ret(fp); + let program = asm.finish().unwrap(); + + let mut next_frame = 16u64; + let mut frames = |req: &HintRequest| { + assert_eq!((req.kind, req.reg), (HintKind::Output, fp)); + next_frame += 16; + Some(FEE::from(next_frame)) + }; + let exec = execute(&program, Memory::new(256), &mut frames, MAX_STEPS).unwrap(); + assert_eq!(exec.mem[0], FEE::from(720u64)); + assert_bookkeeping(&program, &exec); +} + +#[test] +fn false_assertion_is_an_fma_violation() { + let mut asm = Asm::new(); + asm.fma(Arg::imm(1), Arg::imm(0), Arg::imm(0), Arg::imm(0)) + .halt(); + let program = asm.finish().unwrap(); + assert_eq!( + run(&program, Memory::new(0)).unwrap_err(), + ExecError::FmaViolation { pc: 2 } + ); +} + +#[test] +fn storing_a_different_value_twice_is_an_fma_violation() { + let mut asm = Asm::new(); + asm.store(Arg::mem_abs(0), Arg::imm(1)) + .store(Arg::mem_abs(0), Arg::imm(1)) + .store(Arg::mem_abs(0), Arg::imm(2)) + .halt(); + let program = asm.finish().unwrap(); + assert_eq!( + run(&program, Memory::new(1)).unwrap_err(), + ExecError::FmaViolation { pc: 4 } + ); +} + +#[test] +fn reading_a_free_cell_fails() { + let mut asm = Asm::new(); + asm.mov(Arg::reg(gpr(0)), Arg::mem_abs(3)).halt(); + let program = asm.finish().unwrap(); + assert_eq!( + run(&program, Memory::new(4)).unwrap_err(), + ExecError::UninitializedRead { pc: 2, addr: 3 } + ); +} + +#[cfg(not(feature = "fvm-n1"))] +#[test] +fn bad_addresses_fail() { + let mut asm = Asm::new(); + asm.mov(Arg::reg(gpr(0)), Arg::mem_abs(4)).halt(); + let program = asm.finish().unwrap(); + assert!(matches!( + run(&program, Memory::new(4)).unwrap_err(), + ExecError::BadAddress { pc: 2, .. } + )); + + // An extension-field address. + let r = gpr(0); + let mut asm = Asm::new(); + asm.mov(Arg::reg(r), Arg::mem_abs(0)) + .mov(Arg::reg(gpr(1)), Arg::mem(r, 0)) + .halt(); + let program = asm.finish().unwrap(); + assert!(matches!( + run(&program, Memory::with_init(4, &[fee(1, 1, 0)])).unwrap_err(), + ExecError::BadAddress { pc: 3, .. } + )); +} + +#[cfg(not(feature = "fvm-n1"))] +#[test] +fn output_hint_followed_by_input_hint_is_a_collision() { + let (r, a) = (gpr(0), gpr(1)); + let mut asm = Asm::new(); + asm.mov(Arg::reg(a), Arg::imm(3)) + .mov(Arg::reg(r), Arg::imm(5)) + .inv(r, Arg::reg(a)) + .halt(); + let program = asm.finish().unwrap(); + assert_eq!( + run(&program, Memory::new(0)).unwrap_err(), + ExecError::HintCollision { pc: 3, reg: r } + ); +} + +#[test] +fn jump_out_of_the_program_fails() { + let mut asm = Asm::new(); + asm.fma_out(Arg::reg(REG_PC), Arg::imm(0), Arg::imm(0), Arg::imm(1000)); + let program = asm.finish().unwrap(); + assert!(matches!( + run(&program, Memory::new(0)).unwrap_err(), + ExecError::PcOutOfRange { pc: 1000, .. } + )); +} + +#[test] +fn infinite_loop_hits_the_step_limit() { + let mut asm = Asm::new(); + let top = asm.label(); + asm.bind(top); + asm.jump(top); + let program = asm.finish().unwrap(); + assert_eq!( + execute(&program, Memory::new(0), &mut NoHints, 100).unwrap_err(), + ExecError::StepLimit + ); +} + +#[test] +fn invalid_programs_are_rejected() { + let mut asm = Asm::new(); + asm.fma_out(Arg::reg(REG_ZERO), Arg::imm(0), Arg::imm(0), Arg::imm(0)); + assert!(matches!(asm.finish(), Err(AsmError::Invalid { pc: 2, .. }))); + + let mut asm = Asm::new(); + asm.emit(Instr::new( + Arg::reg(9), + Arg::imm(0), + Arg::imm(0), + Arg::imm(0), + false, + )); + assert!(matches!(asm.finish(), Err(AsmError::Invalid { pc: 2, .. }))); + + let mut asm = Asm::new(); + let l = asm.label(); + asm.jump(l); + assert_eq!(asm.finish().unwrap_err(), AsmError::UnboundLabel(l)); +} diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 7e31164de..89f5b0349 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -23,6 +23,7 @@ pub mod constraints; pub mod continuation; #[cfg(feature = "debug-checks")] mod debug_report; +pub mod field_vm; pub mod hash_pin; #[cfg(feature = "instruments")] pub mod instruments; diff --git a/prover/src/tables/types.rs b/prover/src/tables/types.rs index be0252f4a..5ccff19f7 100644 --- a/prover/src/tables/types.rs +++ b/prover/src/tables/types.rs @@ -381,6 +381,19 @@ pub enum BusId { /// ID 35 rather than 29: 29 is a gap left by a removed bus and reusing it /// would make a stale artifact decode as this one. Blake3Absorb = 35, + + // ========================================================================= + // Field VM (`crate::field_vm`) + // ========================================================================= + /// `FIELD_VM_DECODE[pc, imm..., flags, registers]`: the program lookup. + FieldVmDecode = 40, + /// `FIELD_VM_MEM[addr] -> value`: the read-only memory lookup. + FieldVmMem = 41, + /// `FIELD_VM_PUBLIC[addr, value]`: the public memory cells, closed by a + /// verifier-computed balance (the COMMIT-bus pattern). + FieldVmPublic = 42, + /// `FIELD_VM_STATE[pc, zero, registers]`: segment starts and ends. + FieldVmState = 43, } impl BusId { @@ -414,6 +427,10 @@ impl BusId { BusId::LfmRange => "LfmRange", BusId::LfmPublic => "LfmPublic", BusId::Blake3Absorb => "Blake3Absorb", + BusId::FieldVmDecode => "FieldVmDecode", + BusId::FieldVmMem => "FieldVmMem", + BusId::FieldVmPublic => "FieldVmPublic", + BusId::FieldVmState => "FieldVmState", } } } @@ -450,6 +467,10 @@ impl TryFrom for BusId { 33 => Ok(BusId::LfmRange), 34 => Ok(BusId::LfmPublic), 35 => Ok(BusId::Blake3Absorb), + 40 => Ok(BusId::FieldVmDecode), + 41 => Ok(BusId::FieldVmMem), + 42 => Ok(BusId::FieldVmPublic), + 43 => Ok(BusId::FieldVmState), other => Err(other), } } From 8c27e0ee84000093c90ea33ca532bedf9d581310 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 21 Oct 2026 09:54:15 -0300 Subject: [PATCH 07/11] Prove the Field VM with WHIR, bound by checksum prove_whir/verify_whir prove Field VM traces with the block WHIR prover (BlockWhirHash, Poseidon1), with the ADDR ramp as a preprocessed column; prove_whir_block uses the block proof format. The _bound variants add the FIELD_VM_CKSUM table: alpha = H(c_bin || c_fld), where c_bin digests the crossing cells on the v_bin side and c_fld is a Poseidon1 sponge ("CKRT") over the non-CKSUM table roots. CKSUM folds the crossing values with Horner in Fp3 and exposes s, which must equal v_bin's FP3MAC result. alpha lives in three CKSUM columns held constant down the table and is published as a public cell, so the verifier program does not depend on the witness; the last group must be exactly [CKSUM], so alpha binds every other root. --- prover/src/field_vm/cksum.rs | 410 +++++++++ prover/src/field_vm/mod.rs | 2 + prover/src/field_vm/whir.rs | 1558 ++++++++++++++++++++++++++++++++++ 3 files changed, 1970 insertions(+) create mode 100644 prover/src/field_vm/cksum.rs create mode 100644 prover/src/field_vm/whir.rs diff --git a/prover/src/field_vm/cksum.rs b/prover/src/field_vm/cksum.rs new file mode 100644 index 000000000..21c146eb9 --- /dev/null +++ b/prover/src/field_vm/cksum.rs @@ -0,0 +1,410 @@ +//! The `FIELD_VM_CKSUM` chip: option B's binding of the cells that cross +//! between the halves, when the two halves are two proofs. +//! +//! One preprocessed row per crossing cell, in the order both halves agree on. +//! Every real row reads its cell from MEM (`FIELD_VM_MEM`, like any Field VM +//! read) and folds it into a Horner accumulator over Fp³, +//! +//! ```text +//! acc_0 = x_0, acc_{i+1} = acc_i·α + x_{i+1}, s = acc_{n−1}, +//! ``` +//! +//! padding rows keeping it, and the last row sends `(CKSUM_ADDR, s)` on +//! `FIELD_VM_PUBLIC`, so the verifier closes `s` as a public cell. +//! +//! `α` sits in three columns, one value down the table, and the last row +//! sends `(CKSUM_ALPHA_ADDR, α)` too: the verifier closes it at the `α` it +//! draws outside both proofs, from the guest's hash of the crossing cells and +//! the roots committed before this table ([`binding_alpha`]), so the AIR +//! itself does not depend on the proof. The guest +//! computes the same Horner sum over its copies, and the outer verifier checks +//! the two agree: two different vectors agree at a random `α` with +//! probability at most `n / p³`. + +use stark::config::Commitment; +use stark::constraints::builder::{ConstraintBuilder, ConstraintSet, RowDomain}; +use stark::lookup::{BusInteraction, BusValue, Multiplicity, Packing}; +use stark::proof::options::ProofOptions; +use stark::trace::TraceTable; + +use crate::tables::types::{BusId, FE, FEE, GoldilocksExtension, GoldilocksField, zeroed_fe_vec}; + +type F = GoldilocksField; +type E = GoldilocksExtension; + +/// The public-cell address `s` is published under: above every memory address. +pub const CKSUM_ADDR: u64 = 1 << 40; +/// The public-cell address `α` is published under. +pub const CKSUM_ALPHA_ADDR: u64 = CKSUM_ADDR + 1; + +pub mod cols { + /// The cell's Field VM memory address. + pub const FADDR: usize = 0; + pub const IS_REAL: usize = 1; + pub const IS_FIRST: usize = 2; + pub const IS_LAST: usize = 3; + pub const NUM_PRECOMPUTED_COLS: usize = 4; + pub const V0: usize = NUM_PRECOMPUTED_COLS; + pub const ACC0: usize = V0 + 3; + pub const AL0: usize = ACC0 + 3; + pub const NUM_COLUMNS: usize = AL0 + 3; +} + +fn height(n: usize, min_rows: usize) -> usize { + n.next_power_of_two().max(min_rows) +} + +fn fill_prep(faddr: &[u64], min_rows: usize) -> TraceTable { + assert!(!faddr.is_empty(), "a binding covers at least one cell"); + let h = height(faddr.len(), min_rows); + let mut trace = + TraceTable::new_main(zeroed_fe_vec(h * cols::NUM_COLUMNS), cols::NUM_COLUMNS, 1); + let t = &mut trace.main_table; + for (i, &a) in faddr.iter().enumerate() { + t.set(i, cols::FADDR, FE::from(a)); + t.set(i, cols::IS_REAL, FE::one()); + } + t.set(0, cols::IS_FIRST, FE::one()); + t.set(h - 1, cols::IS_LAST, FE::one()); + trace +} + +/// The verifier-side columns for the multilinear path, which cannot rely on +/// the legacy preprocessed root alone to bind these addresses. +pub fn preprocessed_columns(faddr: &[u64], min_rows: usize) -> Vec> { + let trace = fill_prep(faddr, min_rows); + (0..cols::NUM_PRECOMPUTED_COLS) + .map(|c| { + (0..trace.num_rows()) + .map(|r| *trace.main_table.get(r, c)) + .collect() + }) + .collect() +} + +/// `acc·α` in Fp³ = Fp[t]/(t³ − 2), coordinates low first. +fn mul_alpha(a: [X; 3], al: [X; 3], two: X) -> [X; 3] +where + X: Clone + core::ops::Add + core::ops::Mul, +{ + let p = |i: usize, j: usize| a[i].clone() * al[j].clone(); + [ + p(0, 0) + two.clone() * (p(1, 2) + p(2, 1)), + p(0, 1) + p(1, 0) + two * p(2, 2), + p(0, 2) + p(1, 1) + p(2, 0), + ] +} + +/// The trace for the cells at `faddr` holding `values`, and `s`. +pub fn generate_trace( + faddr: &[u64], + values: &[FEE], + alpha: FEE, + min_rows: usize, +) -> (TraceTable, FEE) { + assert_eq!(faddr.len(), values.len(), "one value per crossing cell"); + let mut trace = fill_prep(faddr, min_rows); + let s = horner(values, alpha); + let h = trace.num_rows(); + let t = &mut trace.main_table; + let mut acc = FEE::zero(); + for r in 0..h { + if let Some(v) = values.get(r) { + acc = if r == 0 { *v } else { acc * alpha + v }; + for (k, c) in v.value().iter().enumerate() { + t.set(r, cols::V0 + k, *c); + } + } + for (k, c) in acc.value().iter().enumerate() { + t.set(r, cols::ACC0 + k, *c); + } + for (k, c) in alpha.value().iter().enumerate() { + t.set(r, cols::AL0 + k, *c); + } + } + (trace, s) +} + +/// `Σ_i x_i·α^{n−1−i}`. +pub fn horner(values: &[FEE], alpha: FEE) -> FEE { + values.iter().fold(FEE::zero(), |acc, v| acc * alpha + v) +} + +/// The Merkle root of the preprocessed columns: the verifier rebuilds it from +/// the crossing cells' addresses. +pub fn commitment(faddr: &[u64], options: &ProofOptions, min_rows: usize) -> Commitment { + let trace = fill_prep(faddr, min_rows); + let columns: Vec> = (0..cols::NUM_PRECOMPUTED_COLS) + .map(|c| { + (0..trace.num_rows()) + .map(|r| *trace.main_table.get(r, c)) + .collect() + }) + .collect(); + super::prove::commit_prep(&columns, options) +} + +pub fn bus_interactions() -> Vec { + let direct = |c| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + vec![ + BusInteraction::sender( + BusId::FieldVmMem, + Multiplicity::Column(cols::IS_REAL), + vec![ + direct(cols::FADDR), + BusValue::constant(0), + BusValue::constant(0), + direct(cols::V0), + direct(cols::V0 + 1), + direct(cols::V0 + 2), + ], + ), + BusInteraction::sender( + BusId::FieldVmPublic, + Multiplicity::Column(cols::IS_LAST), + vec![ + BusValue::constant(CKSUM_ADDR), + direct(cols::ACC0), + direct(cols::ACC0 + 1), + direct(cols::ACC0 + 2), + ], + ), + BusInteraction::sender( + BusId::FieldVmPublic, + Multiplicity::Column(cols::IS_LAST), + vec![ + BusValue::constant(CKSUM_ALPHA_ADDR), + direct(cols::AL0), + direct(cols::AL0 + 1), + direct(cols::AL0 + 2), + ], + ), + ] +} + +/// The Horner steps under the `α` of the `AL` columns, which stay one value. +#[derive(Clone, Copy)] +pub struct CksumConstraints; + +impl ConstraintSet for CksumConstraints { + fn eval>(&self, b: &mut B) { + let col = |b: &B, row: usize, c: usize| -> [B::Expr; 3] { + core::array::from_fn(|k| b.main(row, c + k)) + }; + let acc = col(b, 0, cols::ACC0); + let v = col(b, 0, cols::V0); + let first = b.main(0, cols::IS_FIRST); + // The first row starts the sum at its cell. + for k in 0..3 { + b.emit_base(k, first.clone() * (acc[k].clone() - v[k].clone())); + } + // acc' = real'·(acc·α + v') + (1 − real')·acc. + let al = col(b, 0, cols::AL0); + let next_al = col(b, 1, cols::AL0); + let two = b.const_base(2); + let step = mul_alpha(acc.clone(), al.clone(), two); + let next_acc = col(b, 1, cols::ACC0); + let next_v = col(b, 1, cols::V0); + let real = b.main(1, cols::IS_REAL); + let idle = b.one() - real.clone(); + for k in 0..3 { + let want = real.clone() * (step[k].clone() + next_v[k].clone()) + + idle.clone() * acc[k].clone(); + b.emit_base_rows(3 + k, RowDomain::except_last(1), next_acc[k].clone() - want); + } + for k in 0..3 { + b.emit_base_rows( + 6 + k, + RowDomain::except_last(1), + next_al[k].clone() - al[k].clone(), + ); + } + } +} + +/// The guest's hash of the crossing cells, region by region as its memory +/// holds them: width-16 Poseidon1 as an overwrite sponge, the IN and BACK +/// cell counts in lanes 12 and 13, then IN's cells and BACK's, four cells +/// (twelve felts) a permutation, each region's last chunk zero-padded; the +/// digest is lanes 0..4. +pub fn crossing_digest(in_cells: &[[u64; 3]], back_cells: &[[u64; 3]]) -> [u64; 4] { + use crypto::hash::poseidon1_w16::{Fp, permute}; + let mut state = [Fp::zero(); 16]; + state[12] = Fp::from(in_cells.len() as u64); + state[13] = Fp::from(back_cells.len() as u64); + for region in [in_cells, back_cells] { + for chunk in region.chunks(4) { + for lane in &mut state[..12] { + *lane = Fp::zero(); + } + for (i, cell) in chunk.iter().enumerate() { + for (k, x) in cell.iter().enumerate() { + state[3 * i + k] = Fp::from(*x); + } + } + state = permute(state); + } + } + core::array::from_fn(|i| state[i].canonical()) +} + +/// `α` for a binding: Poseidon1 over the guest's digest and MEM's main root +/// (four little-endian words, reduced), under a domain tag; lanes 0..3. +pub fn binding_alpha(c_bin: &[u64; 4], c_fld: &Commitment) -> FEE { + use crypto::hash::poseidon1_w16::{Fp, permute}; + let mut state = [Fp::zero(); 16]; + for (i, w) in c_bin.iter().enumerate() { + state[i] = Fp::from(*w); + } + for (i, chunk) in c_fld.chunks_exact(8).enumerate() { + state[4 + i] = Fp::from(u64::from_le_bytes(chunk.try_into().expect("eight bytes"))); + } + state[12] = Fp::from(u32::from_le_bytes(*b"CKSM") as u64); + let out = permute(state); + FEE::new([out[0], out[1], out[2]]) +} + +/// The domain tag [`field_roots_digest`] starts its sponge with. +pub const ROOTS_TAG: u64 = u32::from_le_bytes(*b"CKRT") as u64; + +/// Digest the ordered roots of the non-CKSUM WHIR groups into the 32-byte +/// commitment input used by [`binding_alpha`]: width-16 Poseidon1 as an +/// overwrite sponge, the tag and the root count in lanes 12 and 13, three +/// roots (each four big-endian words, reduced: the LFM's digest of a +/// commitment) a permutation, the last chunk zero-padded; the digest is lanes +/// 0..4, little-endian. The recursion recomputes it with the LFM's `Hash16`. +pub fn field_roots_digest(roots: &[Commitment]) -> Commitment { + use crypto::hash::poseidon1_w16::{Fp, permute}; + let mut state = [Fp::zero(); 16]; + state[12] = Fp::from(ROOTS_TAG); + state[13] = Fp::from(roots.len() as u64); + for chunk in roots.chunks(3) { + for lane in &mut state[..12] { + *lane = Fp::zero(); + } + for (i, root) in chunk.iter().enumerate() { + for (k, word) in root.chunks_exact(8).enumerate() { + state[4 * i + k] = + Fp::from(u64::from_be_bytes(word.try_into().expect("eight bytes"))); + } + } + state = permute(state); + } + let mut out = [0u8; 32]; + for (lane, bytes) in state.iter().zip(out.chunks_exact_mut(8)) { + bytes.copy_from_slice(&lane.canonical().to_le_bytes()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use math::field::element::FieldElement; + use stark::constraints::builder::ProverEvalFolder; + use stark::frame::Frame; + use stark::table::TableView; + use stark::traits::TransitionEvaluationContext; + + const NUM_CONSTRAINTS: usize = 9; + + fn failing(trace: &TraceTable, row: usize) -> Vec { + let h = trace.num_rows(); + let get = |r: usize| -> Vec { + (0..cols::NUM_COLUMNS) + .map(|c| *trace.main_table.get(r % h, c)) + .collect() + }; + let frame = Frame::::new(vec![ + TableView::new(vec![get(row)], vec![vec![]]), + TableView::new(vec![get(row + 1)], vec![vec![]]), + ]); + let no_e: Vec> = vec![]; + let offset_e = FieldElement::::zero(); + let ctx = + TransitionEvaluationContext::new_prover(frame.as_row_frame(), &no_e, &no_e, &offset_e); + let mut base = vec![FE::zero(); NUM_CONSTRAINTS]; + let mut ext = vec![FieldElement::::zero(); NUM_CONSTRAINTS]; + let mut folder = ProverEvalFolder::new(&ctx, &mut base, &mut ext); + CksumConstraints.eval(&mut folder); + let last = row + 1 == h; + base.iter() + .enumerate() + .filter(|(i, v)| **v != FE::zero() && !(last && *i >= 3)) + .map(|(i, _)| i) + .collect() + } + + fn sample(n: usize) -> (Vec, Vec, FEE) { + let values: Vec = (0..n as u64) + .map(|i| { + FEE::new([ + FE::from(3 * i + 1), + FE::from(i * i + 7), + FE::from(5 * i + 2), + ]) + }) + .collect(); + let alpha = FEE::new([FE::from(11u64), FE::from(13u64), FE::from(17u64)]); + ((0..n as u64).map(|i| 100 + 3 * i).collect(), values, alpha) + } + + #[test] + fn honest_rows_and_padding_satisfy_every_constraint() { + let (faddr, values, alpha) = sample(5); + let (trace, s) = generate_trace(&faddr, &values, alpha, 8); + assert_eq!(s, horner(&values, alpha)); + let last = trace.num_rows() - 1; + let acc: FEE = FEE::new(core::array::from_fn(|k| { + *trace.main_table.get(last, cols::ACC0 + k) + })); + assert_eq!(acc, s, "the last row holds s"); + for row in 0..trace.num_rows() { + assert_eq!(failing(&trace, row), Vec::::new(), "row {row}"); + } + } + + /// A changed cell value with the honest accumulator fails the step that + /// reads it; a changed accumulator fails too. + #[test] + fn a_changed_value_or_accumulator_fails() { + let (faddr, values, alpha) = sample(5); + let (mut trace, _) = generate_trace(&faddr, &values, alpha, 8); + let v = *trace.main_table.get(2, cols::V0); + trace.main_table.set(2, cols::V0, v + FE::one()); + assert!(!failing(&trace, 1).is_empty()); + let (mut trace, _) = generate_trace(&faddr, &values, alpha, 8); + let a = *trace.main_table.get(3, cols::ACC0 + 1); + trace.main_table.set(3, cols::ACC0 + 1, a + FE::one()); + assert!(!failing(&trace, 2).is_empty()); + } + + /// `α` changing down the table fails the step into the changed row. + #[test] + fn a_changed_alpha_fails() { + let (faddr, values, alpha) = sample(5); + let (mut trace, _) = generate_trace(&faddr, &values, alpha, 8); + let a = *trace.main_table.get(6, cols::AL0 + 2); + trace.main_table.set(6, cols::AL0 + 2, a + FE::one()); + assert!(failing(&trace, 5).contains(&8)); + } + + #[test] + fn the_constraint_count_matches_what_eval_emits() { + assert_eq!(CksumConstraints.meta().len(), NUM_CONSTRAINTS); + } + + #[test] + fn the_digest_depends_on_every_cell_and_the_count() { + let cells: Vec<[u64; 3]> = (0..9u64).map(|i| [i, i + 1, i + 2]).collect(); + let d = crossing_digest(&cells[..5], &cells[5..]); + let mut other = cells.clone(); + other[8][2] += 1; + assert_ne!(d, crossing_digest(&other[..5], &other[5..])); + assert_ne!(d, crossing_digest(&cells[..4], &cells[4..])); + assert_ne!(d, crossing_digest(&cells[..5], &cells[5..8])); + } +} diff --git a/prover/src/field_vm/mod.rs b/prover/src/field_vm/mod.rs index 096ae9785..af92a16b2 100644 --- a/prover/src/field_vm/mod.rs +++ b/prover/src/field_vm/mod.rs @@ -7,12 +7,14 @@ pub mod air; pub mod asm; pub mod bridge; +pub mod cksum; pub mod decode; pub mod executor; pub mod isa; pub mod mem; pub mod mux; pub mod prove; +pub mod whir; #[cfg(test)] pub(crate) mod hash_side; diff --git a/prover/src/field_vm/whir.rs b/prover/src/field_vm/whir.rs new file mode 100644 index 000000000..53f4722ba --- /dev/null +++ b/prover/src/field_vm/whir.rs @@ -0,0 +1,1558 @@ +//! WHIR/multilinear proof path for the Field VM. +//! +//! Unlike the STARK AIR boundary system, this path binds segment endpoints +//! through the signed `FieldVmState` LogUp bus. Program and public statement +//! data are absorbed before the table roots; the verifier then closes public +//! memory and state buses at the LogUp challenges. + +/// Runs `$body` with `$h` the block path's WHIR hash (`hash_pin::BlockWhirHash`, +/// Poseidon1), so the Field VM proves under the same family as the block. +macro_rules! with_block_hash { + (|$h:ident| $body:block) => {{ + #[allow(non_camel_case_types)] + type $h = crate::hash_pin::BlockWhirHash; + $body + }}; +} + +use std::sync::Arc; + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use crypto::fiat_shamir::is_transcript::IsTranscript; +use math::field::element::FieldElement; +use multilinear::mle::Mle; +use stark::constraints::builder::EmptyConstraints; +use stark::lookup::{AirWithBuses, AuxiliaryTraceBuildData, NullBoundaryConstraintBuilder}; +use stark::multilinear_air::Uniforms; +use stark::multilinear_block::{self, ArgueDeviation, BlockCommitted}; +use stark::multilinear_table::{ + self, BatchedArgue, CommittedTable, CommittedTables, MultiProof, TableLayout, TableStatement, +}; +use stark::proof::options::ProofOptions; +use stark::trace::TraceTable; +use stark::traits::AIR; + +use super::air::{self, FieldVmConstraints, FvmPublicInputs}; +use super::cksum::{self, CksumConstraints}; +use super::decode; +use super::isa::Program; +use super::mem::{self, MemBoundary, MemConstraints}; +use super::prove::{self, ExtraAir, FvmTraces, PublicCell}; +use crate::tables::types::{BusId, FE, FEE, GoldilocksExtension, GoldilocksField}; + +type F = GoldilocksField; +type E = GoldilocksExtension; +pub(crate) type WhirAir = dyn AIR; +pub(crate) type AirBox = Box; +type Multi = MultiProof; + +/// A Field VM multilinear proof and the public endpoint claims needed because +/// multilinear AIRs deliberately do not run STARK boundary constraints. +#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub struct FvmWhirProof { + pub proof: Multi, + /// Height in variables, in table order: segments, decode chunks, MEM, + /// then any extra tables. + pub table_num_vars: Vec, + pub segments: Vec, +} + +/// A Field VM proof in the block recursion's grouped format. A recursion leaf +/// emitter can verify this once it has a frame derived from the Field VM AIRs: +/// AIR refs in table order, `(width, vars)` shapes, the groups and their table +/// order, per-group stacked layouts and domains, and the corresponding chain +/// configuration. It also needs the segment chain and public-cell statement +/// values to build the owed bus balance. DECODE's program-derived columns are +/// the prepared stack openings carried here. +#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] +pub struct FvmWhirBlockProof { + pub proof: Multi, + pub table_num_vars: Vec, + pub segments: Vec, + pub groups: Vec>, + pub prepared: Vec>, + pub argues: Vec>, +} + +pub(crate) struct WhirAirs { + pub(crate) airs: Vec, +} + +impl WhirAirs { + pub(crate) fn new( + program: &Program, + roots: &[stark::config::Commitment], + segment_count: usize, + mem_rows: usize, + options: &ProofOptions, + extra: Vec, + ) -> Self { + let aux = |interactions| AuxiliaryTraceBuildData { interactions }; + let mut airs: Vec = Vec::new(); + let fvm = AirWithBuses::< + F, + E, + NullBoundaryConstraintBuilder, + FvmPublicInputs, + FieldVmConstraints, + >::new( + air::cols::NUM_COLUMNS, + aux(air::bus_interactions()), + options, + 1, + FieldVmConstraints::new(), + ); + airs.extend( + (0..segment_count) + .map(|k| Box::new(fvm.clone().with_name(&format!("FIELD_VM[{k}]"))) as AirBox), + ); + + let decode_preprocessed = decode::generate_traces(program, &[], prove::MIN_ROWS); + assert_eq!( + roots.len(), + decode_preprocessed.len(), + "one program root per decode chunk" + ); + for (k, (root, prep)) in roots.iter().zip(decode_preprocessed).enumerate() { + let columns = (0..decode::NUM_PRECOMPUTED_COLS) + .map(|c| { + (0..prep.num_rows()) + .map(|r| *prep.main_table.get(r, c)) + .collect() + }) + .collect::>>(); + let generator = Arc::new(move || columns.clone()); + let a = AirWithBuses::< + F, + E, + NullBoundaryConstraintBuilder, + FvmPublicInputs, + EmptyConstraints, + >::new( + decode::NUM_COLUMNS, + aux(decode::bus_interactions()), + options, + 1, + EmptyConstraints, + ) + .with_name(&format!("FIELD_VM_DECODE[{k}]")) + .with_preprocessed_columns(*root, decode::NUM_PRECOMPUTED_COLS, generator); + airs.push(Box::new(a)); + } + + // ADDR is generated from the row index and checked as a preprocessed + // column by WHIR. Its trace is the unique address ramp. + let ramp = (0..mem_rows) + .map(|r| FE::from(r as u64)) + .collect::>(); + let mem_prep = Arc::new(move || vec![ramp.clone()]); + let mem_a = AirWithBuses::::new( + mem::cols::NUM_COLUMNS, + aux(mem::bus_interactions()), + options, + 1, + MemConstraints { lfm: false }, + ) + .with_name("FIELD_VM_MEM") + .with_preprocessed_columns([0; 32], 1, mem_prep); + airs.push(Box::new(mem_a)); + airs.extend(extra); + Self { airs } + } + + fn refs(&self) -> Vec<&WhirAir> { + self.airs.iter().map(|a| a.as_ref()).collect() + } +} + +/// The tables whose preprocessed columns a block proof opens as prepared +/// stacks: the DECODE chunks and every extra that carries some (CKSUM's +/// addresses, too tall for a recursion leaf to evaluate as constants). MEM's +/// ramp has a closed form. +pub(crate) fn prepared_tables( + refs: &[&WhirAir], + segments: usize, + decode_count: usize, +) -> Vec<(usize, Vec>)> { + (segments..segments + decode_count) + .chain( + (segments + decode_count + 1..refs.len()) + .filter(|&i| refs[i].num_precomputed_columns() > 0), + ) + .map(|i| (i, refs[i].precomputed_columns())) + .collect() +} + +fn layout_of<'a>( + air: &'a WhirAir, + width: usize, + num_vars: usize, +) -> Result, String> { + TableLayout::::new( + air.constraint_program(), + air.constraints_meta(), + air.bus_interactions(), + width, + num_vars, + Uniforms::default(), + ) + .map_err(|e| format!("{}: {e:?}", air.name())) +} + +fn preprocessed(air: &WhirAir) -> Result>, String> { + air.precomputed_columns() + .into_iter() + .map(|v| Mle::new(v).map_err(|e| format!("{}: {e:?}", air.name()))) + .collect() +} + +fn committed_tables<'a>( + pairs: &[(&'a WhirAir, &'a TraceTable)], + shapes: &[(usize, usize)], +) -> Result>, String> { + if pairs.len() != shapes.len() { + return Err("table and shape counts differ".into()); + } + pairs + .iter() + .zip(shapes) + .map(|(&(air, trace), &(width, vars))| { + let layout = layout_of(air, width, vars)?; + let columns = trace.columns_main(); + for (i, expected) in air.precomputed_columns().iter().enumerate() { + if columns.get(i) != Some(expected) { + return Err(format!("{}: preprocessed column {i} mismatch", air.name())); + } + } + CommittedTable::from_layout(layout, |c| columns[c as usize].clone()) + .map_err(|e| format!("{e:?}")) + }) + .collect() +} + +/// Build the optional CKSUM extra table with its crossing-cell columns +/// explicitly checked by WHIR. +pub fn cksum_air( + root: stark::config::Commitment, + faddr: Vec, + options: &ProofOptions, + min_rows: usize, +) -> ExtraAir { + let columns = cksum::preprocessed_columns(&faddr, min_rows); + let generator = Arc::new(move || columns.clone()); + let constraints = CksumConstraints; + let air = AirWithBuses::::new( + cksum::cols::NUM_COLUMNS, + AuxiliaryTraceBuildData { interactions: cksum::bus_interactions() }, + options, + 1, + constraints, + ) + .with_name("FIELD_VM_CKSUM") + .with_preprocessed_columns(root, cksum::cols::NUM_PRECOMPUTED_COLS, generator); + Box::new(air) +} + +fn expected( + public: &[PublicCell], + segments: &[FvmPublicInputs], + z: &FEE, + alpha: &FEE, +) -> Option { + let pub_balance = prove::expected_public_balance(public, z, alpha)?; + // Endpoint claims are checked for chain/start/halt before this function; + // use the first/last stated tuples so any changed claim also changes the + // balance closed by LogUp. + fn fp(bus: BusId, words: &[u64], z: &FEE, alpha: &FEE) -> FEE { + let mut power = *alpha; + let mut token = FEE::from(bus as u64); + for &word in words { + token += FEE::from(word) * power; + power *= alpha; + } + *z - token + } + let first = &segments.first()?.first; + let last = &segments.last()?.last; + let mut inverses = [ + fp(BusId::FieldVmState, first, z, alpha), + fp(BusId::FieldVmState, last, z, alpha), + ]; + FieldElement::inplace_batch_inverse(&mut inverses).ok()?; + Some(pub_balance + inverses[0] - inverses[1]) +} + +fn well_formed(segments: &[FvmPublicInputs], heights: &[u8]) -> bool { + if segments.is_empty() || segments.len() > heights.len() { + return false; + } + segments.iter().enumerate().all(|(i, s)| { + let h = 1usize.checked_shl(heights[i] as u32); + s.last_row.checked_add(1) == h + && s.first.len() == air::STATE_WORDS + && s.last.len() == air::STATE_WORDS + }) && segments.windows(2).all(|p| p[0].last == p[1].first) + && segments + .first() + .is_some_and(|s| s.first == air::start_words()) + && segments.last().is_some_and(|s| s.last == air::halt_words()) +} + +/// Prove traces with WHIR. `program_id` is the same DECODE root list used by +/// the STARK prover; `extra_traces` follows `extra` after MEM. +pub fn prove_whir( + program_id: &[stark::config::Commitment], + program: &Program, + public: &[PublicCell], + traces: &mut FvmTraces, + extra: Vec, + extra_traces: &mut [TraceTable], + options: &ProofOptions, +) -> Result { + if extra.len() != extra_traces.len() { + return Err("one trace per extra AIR".into()); + } + if program_id.len() != decode::chunks(program, prove::MIN_ROWS).len() { + return Err("one program root per decode chunk".into()); + } + if !prove::public_well_formed(public) { + return Err("malformed public cells".into()); + } + let airs = WhirAirs::new( + program, + program_id, + traces.fvm.len(), + traces.mem.num_rows(), + options, + extra, + ); + let refs = airs.refs(); + let n = traces.fvm.len(); + let d = traces.decode.len(); + let mut pairs: Vec<(&WhirAir, &mut TraceTable)> = Vec::new(); + for (a, t) in refs[..n].iter().zip(traces.fvm.iter_mut()) { + pairs.push((*a, t)); + } + for (a, t) in refs[n..n + d].iter().zip(traces.decode.iter_mut()) { + pairs.push((*a, t)); + } + pairs.push((refs[n + d], &mut traces.mem)); + for (a, t) in refs[n + d + 1..].iter().zip(extra_traces.iter_mut()) { + pairs.push((*a, t)); + } + let shapes: Vec<(usize, usize)> = pairs + .iter() + .map(|(a, t)| (a.trace_layout().0, t.num_rows().trailing_zeros() as usize)) + .collect(); + let config = crate::multilinear_prove::chain_config(&shapes); + let mut committed = Vec::with_capacity(pairs.len()); + for ((air, trace), &(width, vars)) in pairs.iter_mut().zip(&shapes) { + let layout = layout_of(*air, width, vars)?; + let mut columns = trace.columns_main(); + for (i, expected) in air.precomputed_columns().iter().enumerate() { + if columns.get(i) != Some(expected) { + return Err(format!("{}: preprocessed column {i} mismatch", air.name())); + } + } + committed.push( + CommittedTable::from_layout(layout, |c| core::mem::take(&mut columns[c as usize])) + .map_err(|e| format!("{e:?}"))?, + ); + } + let table_num_vars: Vec = shapes.iter().map(|s| s.1 as u8).collect(); + let proof = with_block_hash!(|H| { + let mut transcript = DefaultTranscript::< + E, + ::Transcript, + >::new(b"lambda-vm/field-vm/whir/v1\0\0\0"); + // Rebuild statement bytes with the selected transcript hash type. + absorb_statement( + &mut transcript, + program_id, + public, + &traces.segments, + &table_num_vars, + ); + let committed = + CommittedTables::<_, _, H>::commit(committed, &config).map_err(|e| format!("{e:?}"))?; + multilinear_table::multi_prove(&committed, &config, &mut transcript, None) + .map_err(|e| format!("{e:?}")) + })?; + Ok(FvmWhirProof { + proof, + table_num_vars, + segments: traces.segments.clone(), + }) +} + +/// Prove in the same grouped, prepared-opening format used by block WHIR. +/// Groups are derived from the block's production stack cap and polynomial +/// budget; tables are committed by group and each group uses the configured +/// batched argue. +pub fn prove_whir_block( + program_id: &[stark::config::Commitment], + program: &Program, + public: &[PublicCell], + traces: &mut FvmTraces, + extra: Vec, + extra_traces: &mut [TraceTable], + options: &ProofOptions, +) -> Result { + if extra.len() != extra_traces.len() { + return Err("one trace per extra AIR".into()); + } + if program_id.len() != decode::chunks(program, prove::MIN_ROWS).len() { + return Err("one program root per decode chunk".into()); + } + if !prove::public_well_formed(public) { + return Err("malformed public cells".into()); + } + let airs = WhirAirs::new( + program, + program_id, + traces.fvm.len(), + traces.mem.num_rows(), + options, + extra, + ); + let refs = airs.refs(); + let n = traces.fvm.len(); + let d = traces.decode.len(); + let mut pairs: Vec<(&WhirAir, &mut TraceTable)> = Vec::new(); + for (a, t) in refs[..n].iter().zip(traces.fvm.iter_mut()) { + pairs.push((*a, t)); + } + for (a, t) in refs[n..n + d].iter().zip(traces.decode.iter_mut()) { + pairs.push((*a, t)); + } + pairs.push((refs[n + d], &mut traces.mem)); + for (a, t) in refs[n + d + 1..].iter().zip(extra_traces.iter_mut()) { + pairs.push((*a, t)); + } + let shapes: Vec<(usize, usize)> = pairs + .iter() + .map(|(a, t)| (a.trace_layout().0, t.num_rows().trailing_zeros() as usize)) + .collect(); + let format = crate::block_whir::BlockFormat::production(); + let config = format.chain_config(&shapes); + let mut sizes = + stark::multilinear_block::block_groups(&shapes, config.format.stack, format.group_polys) + .map_err(|e| format!("{e:?}"))?; + // A checksum-bound proof keeps its CKSUM table in a final singleton group, + // so its challenge is bound to exactly the preceding groups' roots. + if refs.last().is_some_and(|a| a.name() == "FIELD_VM_CKSUM") && !sizes.is_empty() { + let last = sizes.pop().unwrap(); + if last > 1 { + sizes.push(last - 1); + } + sizes.push(1); + } + if sizes.len() > format.max_groups { + return Err(format!( + "{} groups exceed block format limit {}", + sizes.len(), + format.max_groups + )); + } + let groups: Vec> = sizes + .iter() + .scan(0usize, |start, &size| { + let group = (*start..*start + size).map(|i| i as u32).collect(); + *start += size; + Some(group) + }) + .collect(); + let table_num_vars: Vec = shapes.iter().map(|s| s.1 as u8).collect(); + let mut tables = Vec::with_capacity(pairs.len()); + for ((air, trace), &(width, vars)) in pairs.iter_mut().zip(&shapes) { + let layout = layout_of(*air, width, vars)?; + let mut columns = trace.columns_main(); + for (i, expected) in air.precomputed_columns().iter().enumerate() { + if columns.get(i) != Some(expected) { + return Err(format!("{}: preprocessed column {i} mismatch", air.name())); + } + } + tables.push( + CommittedTable::from_layout(layout, |c| core::mem::take(&mut columns[c as usize])) + .map_err(|e| format!("{e:?}"))?, + ); + } + let mut table_iter = tables.into_iter(); + let grouped_tables: Vec> = sizes + .iter() + .map(|&size| table_iter.by_ref().take(size).collect()) + .collect(); + let committed = with_block_hash!(|H| { + BlockCommitted::commit_groups::( + grouped_tables, + &config, + crate::block_whir::tree_drop_from_env(), + multilinear_block::Narrowing::Wide, + false, + ) + .map_err(|e| format!("{e:?}")) + })?; + + let prepared_columns = prepared_tables(&refs, n, d); + let prepared_groups = + crate::block_whir::group_stacks(prepared_columns, &groups).map_err(|e| format!("{e:?}"))?; + let prepared = with_block_hash!(|H| { + crate::block_whir::commit_groups::(prepared_groups, &config) + .map_err(|e| format!("{e:?}")) + })?; + let borrowed: Vec>> = prepared + .iter() + .map(|p| multilinear::stacking::borrow(&p.columns)) + .collect(); + let openings: Vec> = prepared + .iter() + .zip(&borrowed) + .map(|(p, columns)| multilinear_block::BlockPrepared { + group: p.group, + tables: p.tables.clone(), + commitment: &p.commitment, + columns, + }) + .collect(); + let mut transcript = + with_block_hash!(|H| { ::sponge() }); + absorb_block_statement( + &mut transcript, + program_id, + public, + &traces.segments, + &table_num_vars, + &groups, + &config, + ); + let (proof, argues, prepared_openings, _) = with_block_hash!(|H| { + multilinear_block::block_prove_on_forks::( + committed, + &config, + &mut transcript, + &openings, + &[], + &|g| g, + &ArgueDeviation::default(), + ) + .map_err(|e| format!("{e:?}")) + })?; + Ok(FvmWhirBlockProof { + proof, + table_num_vars, + segments: traces.segments.clone(), + groups, + prepared: prepared_openings, + argues, + }) +} + +/// Prove a checksum-bound block-format proof. The preliminary proof supplies +/// the ordered non-CKSUM roots for the binding challenge; the final proof puts +/// CKSUM in its own trailing group so those roots remain an exact prefix. +#[allow(clippy::too_many_arguments)] +pub fn prove_whir_block_bound( + program_id: &[stark::config::Commitment], + program: &Program, + public: &[PublicCell], + traces: &mut FvmTraces, + c_bin: &[u64; 4], + faddr: &[u64], + values: &[FEE], + options: &ProofOptions, +) -> Result<(FvmWhirBlockProof, FEE), String> { + if !prove::public_well_formed(public) + || faddr.is_empty() + || faddr.len() != values.len() + || public.iter().any(|(addr, _)| *addr >= cksum::CKSUM_ADDR) + { + return Err("malformed public cells or checksum inputs".into()); + } + let preview_roots = preview_whir_block_roots(program_id, program, traces, options)?; + let alpha = cksum::binding_alpha(c_bin, &cksum::field_roots_digest(&preview_roots)); + let (trace, checksum) = cksum::generate_trace(faddr, values, alpha, 8); + let root = cksum::commitment(faddr, options, 8); + let air = cksum_air(root, faddr.to_vec(), options, 8); + let mut bound_public = public.to_vec(); + bound_public.push((cksum::CKSUM_ADDR, checksum)); + bound_public.push((cksum::CKSUM_ALPHA_ADDR, alpha)); + bound_public.sort_by_key(|cell| cell.0); + let proof = prove_whir_block( + program_id, + program, + &bound_public, + traces, + vec![air], + &mut [trace], + options, + )?; + let vars = *proof.table_num_vars.last().ok_or("missing CKSUM table")? as usize; + let format = crate::block_whir::BlockFormat::production(); + let mut shapes: Vec<_> = proof + .table_num_vars + .iter() + .take(proof.table_num_vars.len().saturating_sub(1)) + .enumerate() + .map(|(i, &v)| { + let width = if i < proof.segments.len() { + air::cols::NUM_COLUMNS + } else if i < proof.segments.len() + decode::chunks(program, prove::MIN_ROWS).len() { + decode::NUM_COLUMNS + } else { + mem::cols::NUM_COLUMNS + }; + (width, v as usize) + }) + .collect(); + shapes.push((cksum::cols::NUM_COLUMNS, vars)); + let config = format.chain_config(&shapes); + let checksum_polys = + multilinear_table::global_layout(&[(cksum::cols::NUM_COLUMNS, vars)], config.format.stack) + .map_err(|e| format!("{e:?}"))? + .num_polys(); + let prefix_len = proof + .proof + .roots + .len() + .checked_sub(checksum_polys) + .ok_or("missing checksum roots")?; + if proof.proof.roots[..prefix_len] != preview_roots[..] { + return Err("non-CKSUM group roots changed while adding CKSUM".into()); + } + Ok((proof, alpha)) +} + +/// Phase A only, for the checksum binding preview. It deliberately shares the +/// same shapes, grouping and committed-table constructors as the final proof. +fn preview_whir_block_roots( + program_id: &[stark::config::Commitment], + program: &Program, + traces: &mut FvmTraces, + options: &ProofOptions, +) -> Result, String> { + let airs = WhirAirs::new( + program, + program_id, + traces.fvm.len(), + traces.mem.num_rows(), + options, + Vec::new(), + ); + let refs = airs.refs(); + let n = traces.fvm.len(); + let d = traces.decode.len(); + let mut pairs: Vec<(&WhirAir, &mut TraceTable)> = Vec::new(); + for (a, t) in refs[..n].iter().zip(traces.fvm.iter_mut()) { + pairs.push((*a, t)); + } + for (a, t) in refs[n..n + d].iter().zip(traces.decode.iter_mut()) { + pairs.push((*a, t)); + } + pairs.push((refs[n + d], &mut traces.mem)); + let shapes: Vec<_> = pairs + .iter() + .map(|(a, t)| (a.trace_layout().0, t.num_rows().trailing_zeros() as usize)) + .collect(); + let format = crate::block_whir::BlockFormat::production(); + let config = format.chain_config(&shapes); + let sizes = + stark::multilinear_block::block_groups(&shapes, config.format.stack, format.group_polys) + .map_err(|e| format!("{e:?}"))?; + let mut tables = Vec::with_capacity(pairs.len()); + for ((air, trace), &(width, vars)) in pairs.iter_mut().zip(&shapes) { + let layout = layout_of(*air, width, vars)?; + let mut columns = trace.columns_main(); + for (i, expected) in air.precomputed_columns().iter().enumerate() { + if columns.get(i) != Some(expected) { + return Err(format!("{}: preprocessed column {i} mismatch", air.name())); + } + } + tables.push( + CommittedTable::from_layout(layout, |c| core::mem::take(&mut columns[c as usize])) + .map_err(|e| format!("{e:?}"))?, + ); + } + let mut iter = tables.into_iter(); + let grouped = sizes + .iter() + .map(|&size| iter.by_ref().take(size).collect()) + .collect::>>(); + with_block_hash!(|H| { + let committed = BlockCommitted::commit_groups::( + grouped, + &config, + crate::block_whir::tree_drop_from_env(), + multilinear_block::Narrowing::Wide, + false, + ) + .map_err(|e| format!("{e:?}"))?; + Ok(committed.roots().to_vec()) + }) +} + +/// WHIR proof with the crossing-cell checksum bound to the complete ordered +/// root list of the non-CKSUM group. The preview and final non-CKSUM commits +/// use the same shapes, chain config, and single-group layout. +#[allow(clippy::too_many_arguments)] +pub fn prove_whir_bound( + program_id: &[stark::config::Commitment], + program: &Program, + public: &[PublicCell], + traces: &mut FvmTraces, + c_bin: &[u64; 4], + faddr: &[u64], + values: &[FEE], + options: &ProofOptions, +) -> Result<(FvmWhirProof, FEE), String> { + if !prove::public_well_formed(public) || faddr.is_empty() || faddr.len() != values.len() { + return Err("malformed public cells or checksum inputs".into()); + } + if program_id.len() != decode::chunks(program, prove::MIN_ROWS).len() { + return Err("one program root per decode chunk".into()); + } + let non_airs = WhirAirs::new( + program, + program_id, + traces.fvm.len(), + traces.mem.num_rows(), + options, + Vec::new(), + ); + let non_refs = non_airs.refs(); + let mut non_pairs: Vec<(&WhirAir, &mut TraceTable)> = Vec::new(); + let n = traces.fvm.len(); + let d = traces.decode.len(); + for (a, t) in non_refs[..n].iter().zip(traces.fvm.iter_mut()) { + non_pairs.push((*a, t)); + } + for (a, t) in non_refs[n..n + d].iter().zip(traces.decode.iter_mut()) { + non_pairs.push((*a, t)); + } + non_pairs.push((non_refs[n + d], &mut traces.mem)); + let non_shapes: Vec<(usize, usize)> = non_pairs + .iter() + .map(|(a, t)| (a.trace_layout().0, t.num_rows().trailing_zeros() as usize)) + .collect(); + let cksum_rows = faddr.len().next_power_of_two().max(8); + let cksum_vars = cksum_rows.trailing_zeros() as usize; + let cksum_root = cksum::commitment(faddr, options, 8); + let all_shapes = non_shapes + .iter() + .copied() + .chain([(cksum::cols::NUM_COLUMNS, cksum_vars)]) + .collect::>(); + let config = crate::multilinear_prove::chain_config(&all_shapes); + + // Build the non-CKSUM tables once, then use identical table descriptions + // for preview and final commits. `columns_main` owns a transpose copy, so + // the execution traces remain available for the real proof. + let preview_pairs: Vec<(&WhirAir, &TraceTable)> = non_pairs + .iter() + .map(|(air, trace)| (*air, &**trace)) + .collect(); + let preview_roots = with_block_hash!(|H| { + let preview_tables = committed_tables(&preview_pairs, &non_shapes)?; + CommittedTables::<_, _, H>::commit_grouped(preview_tables, &[non_shapes.len()], &config) + .map(|c| c.roots().to_vec()) + .map_err(|e| format!("{e:?}")) + })?; + drop(preview_pairs); + let c_fld = cksum::field_roots_digest(&preview_roots); + let alpha = cksum::binding_alpha(c_bin, &c_fld); + let (mut cksum_trace, s) = cksum::generate_trace(faddr, values, alpha, 8); + let cksum_air = cksum_air(cksum_root, faddr.to_vec(), options, 8); + let mut bound_public = public.to_vec(); + if bound_public + .iter() + .any(|(addr, _)| *addr >= cksum::CKSUM_ADDR) + { + return Err("checksum address is already public".into()); + } + bound_public.push((cksum::CKSUM_ADDR, s)); + bound_public.push((cksum::CKSUM_ALPHA_ADDR, alpha)); + bound_public.sort_by_key(|c| c.0); + + let mut all_pairs = non_pairs; + all_pairs.push((&*cksum_air, &mut cksum_trace)); + let all_ref_pairs: Vec<(&WhirAir, &TraceTable)> = all_pairs + .iter() + .map(|(air, trace)| (*air, &**trace)) + .collect(); + let committed = committed_tables(&all_ref_pairs, &all_shapes)?; + let table_num_vars: Vec = all_shapes.iter().map(|s| s.1 as u8).collect(); + let proof = with_block_hash!(|H| { + let mut transcript = DefaultTranscript::< + E, + ::Transcript, + >::new(b"lambda-vm/field-vm/whir/v1\0\0\0"); + absorb_statement( + &mut transcript, + program_id, + &bound_public, + &traces.segments, + &table_num_vars, + ); + let committed = + CommittedTables::<_, _, H>::commit_grouped(committed, &[non_shapes.len(), 1], &config) + .map_err(|e| format!("{e:?}"))?; + assert_eq!( + &committed.roots()[..preview_roots.len()], + preview_roots.as_slice(), + "non-CKSUM roots changed between preview and real commit" + ); + multilinear_table::multi_prove(&committed, &config, &mut transcript, None) + .map_err(|e| format!("{e:?}")) + })?; + Ok(( + FvmWhirProof { + proof, + table_num_vars, + segments: traces.segments.clone(), + }, + alpha, + )) +} + +/// The statement's `append_bytes` calls, in order: what a recursion leaf +/// replays call by call (the sponge binds each call's length). +fn statement_calls( + roots: &[stark::config::Commitment], + public: &[PublicCell], + segments: &[FvmPublicInputs], + table_num_vars: &[u8], + big_endian: bool, +) -> Vec> { + let mut calls: Vec> = Vec::new(); + calls.push((roots.len() as u64).to_le_bytes().to_vec()); + for r in roots { + calls.push(r.to_vec()); + } + calls.push((public.len() as u64).to_le_bytes().to_vec()); + for (addr, value) in public { + calls.push(addr.to_le_bytes().to_vec()); + for c in value.value() { + let c = c.canonical(); + calls.push( + if big_endian { + c.to_be_bytes() + } else { + c.to_le_bytes() + } + .to_vec(), + ); + } + } + calls.push((segments.len() as u64).to_le_bytes().to_vec()); + for pi in segments { + calls.push((pi.last_row as u64).to_le_bytes().to_vec()); + for w in pi.first.iter().chain(&pi.last) { + calls.push(w.to_le_bytes().to_vec()); + } + } + calls.push((table_num_vars.len() as u64).to_le_bytes().to_vec()); + calls.push(table_num_vars.to_vec()); + calls +} + +fn absorb_statement( + t: &mut impl IsTranscript, + roots: &[stark::config::Commitment], + public: &[PublicCell], + segments: &[FvmPublicInputs], + table_num_vars: &[u8], +) { + for call in statement_calls(roots, public, segments, table_num_vars, false) { + t.append_bytes(&call); + } +} + +/// [`statement_calls`] of the block format: its tag, then the groups and the +/// WHIR parameters. +pub(crate) fn block_statement_calls( + roots: &[stark::config::Commitment], + public: &[PublicCell], + segments: &[FvmPublicInputs], + table_num_vars: &[u8], + groups: &[Vec], + config: &multilinear::whir_chain::ChainConfig, +) -> Vec> { + let mut calls = vec![b"lambda-vm/field-vm/whir-block/v2\0".to_vec()]; + calls.extend(statement_calls( + roots, + public, + segments, + table_num_vars, + true, + )); + calls.push((groups.len() as u64).to_le_bytes().to_vec()); + for group in groups { + calls.push((group.len() as u64).to_le_bytes().to_vec()); + for index in group { + calls.push(u64::from(*index).to_le_bytes().to_vec()); + } + } + for value in [ + config.log_blowup as u64, + config.fold_word(), + config.num_queries as u64, + ] { + calls.push(value.to_le_bytes().to_vec()); + } + for bits in [config.grind.folding, config.grind.ood, config.grind.query] { + calls.push(vec![bits]); + } + calls +} + +/// The index among [`block_statement_calls`] of coordinate `coord` of public +/// cell `cell`, under `roots` program-id roots. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn block_statement_value_call(roots: usize, cell: usize, coord: usize) -> usize { + 3 + roots + 4 * cell + 1 + coord +} + +fn absorb_block_statement( + t: &mut impl IsTranscript, + roots: &[stark::config::Commitment], + public: &[PublicCell], + segments: &[FvmPublicInputs], + table_num_vars: &[u8], + groups: &[Vec], + config: &multilinear::whir_chain::ChainConfig, +) { + for call in block_statement_calls(roots, public, segments, table_num_vars, groups, config) { + t.append_bytes(&call); + } +} + +/// Verify a grouped Field VM WHIR proof using the production block format. +pub fn verify_whir_block( + program_id: &[stark::config::Commitment], + program: &Program, + public: &[PublicCell], + proof: &FvmWhirBlockProof, + extra: Vec, + options: &ProofOptions, +) -> bool { + if !prove::public_well_formed(public) || !well_formed(&proof.segments, &proof.table_num_vars) { + return false; + } + let decode_count = decode::chunks(program, prove::MIN_ROWS).len(); + if program_id.len() != decode_count { + return false; + } + let mem_index = proof.segments.len() + decode_count; + let Some(&mem_vars) = proof.table_num_vars.get(mem_index) else { + return false; + }; + let Some(mem_rows) = 1usize.checked_shl(mem_vars as u32) else { + return false; + }; + let airs = WhirAirs::new( + program, + program_id, + proof.segments.len(), + mem_rows, + options, + extra, + ); + let refs = airs.refs(); + if refs.len() != proof.table_num_vars.len() { + return false; + } + let shapes: Vec<_> = refs + .iter() + .zip(&proof.table_num_vars) + .map(|(a, &v)| (a.trace_layout().0, v as usize)) + .collect(); + let format = crate::block_whir::BlockFormat::production(); + let config = format.chain_config(&shapes); + let Ok(order) = crate::block_whir::validate_groups(&proof.groups, shapes.len()) else { + return false; + }; + let sizes: Vec<_> = proof.groups.iter().map(Vec::len).collect(); + if sizes.len() > format.max_groups || proof.groups.iter().any(Vec::is_empty) { + return false; + } + let ordered_shapes: Vec<_> = order.iter().map(|&i| shapes[i]).collect(); + let Ok((stack_layouts, domains)) = + crate::multilinear_prove::stacks(&ordered_shapes, &sizes, &config) + else { + return false; + }; + if stack_layouts + .iter() + .zip(&sizes) + .any(|(layout, &size)| layout.num_polys() > format.group_polys && size > 1) + { + return false; + } + let layouts: Vec> = match refs + .iter() + .zip(&shapes) + .map(|(a, &(w, v))| layout_of(*a, w, v)) + .collect() + { + Ok(v) => v, + Err(_) => return false, + }; + let pre: Vec>> = match refs.iter().map(|a| preprocessed(*a)).collect() { + Ok(v) => v, + Err(_) => return false, + }; + let statements: Vec> = order + .iter() + .map(|&i| layouts[i].statement_with_preprocessed(&pre[i])) + .collect(); + let prepared_columns = prepared_tables(&refs, proof.segments.len(), decode_count); + let Ok(prepared_stacks) = crate::block_whir::group_stacks(prepared_columns, &proof.groups) + else { + return false; + }; + let Ok(prepared): Result>, _> = + with_block_hash!(|H| { crate::block_whir::commit_groups::(prepared_stacks, &config) }) + else { + return false; + }; + if proof.prepared.len() != prepared.len() { + return false; + } + let checks: Vec<_> = prepared + .iter() + .map(|p| multilinear_block::BlockPreparedCheck { + group: p.group, + tables: p.tables.clone(), + roots: &p.roots, + layout: p.commitment.layout(), + domain: p.commitment.domain(), + }) + .collect(); + let derived: Vec<_> = checks + .iter() + .flat_map(|p| p.roots.iter().copied()) + .collect(); + with_block_hash!(|H| { + let mut transcript = ::sponge(); + absorb_block_statement( + &mut transcript, + program_id, + public, + &proof.segments, + &proof.table_num_vars, + &proof.groups, + &config, + ); + let mut probe = transcript.clone(); + multilinear_table::absorb_roots::(&mut probe, &proof.proof.roots, &derived); + let z: FEE = probe.sample_field_element(); + let alpha: FEE = probe.sample_field_element(); + let Some(owed) = expected(public, &proof.segments, &z, &alpha) else { + return false; + }; + multilinear_block::block_verify_with::( + &proof.proof, + &proof.argues, + &proof.prepared, + &checks, + &statements, + &stack_layouts, + &domains, + &sizes, + &owed, + &config, + &mut transcript, + false, + stark::multilinear_table::batched::VerifierChecks::ALL, + ) + .is_ok() + }) +} + +/// Verify the checksum-bound block proof, recomputing its binding challenge +/// from every root before the trailing CKSUM singleton group. +#[allow(clippy::too_many_arguments)] +pub fn verify_whir_block_bound( + c_bin: &[u64; 4], + s: &FEE, + program_id: &[stark::config::Commitment], + program: &Program, + public: &[PublicCell], + faddr: &[u64], + proof: &FvmWhirBlockProof, + options: &ProofOptions, +) -> bool { + let decode_count = decode::chunks(program, prove::MIN_ROWS).len(); + if faddr.is_empty() + || proof.groups.last().map(Vec::as_slice) + != Some(&[proof.table_num_vars.len().saturating_sub(1) as u32][..]) + || program_id.len() != decode_count + || proof.table_num_vars.len() != proof.segments.len() + decode_count + 2 + || !well_formed(&proof.segments, &proof.table_num_vars) + || public.iter().any(|(addr, _)| *addr >= cksum::CKSUM_ADDR) + { + return false; + } + let root = cksum::commitment(faddr, options, 8); + let dummy = cksum_air(root, faddr.to_vec(), options, 8); + let airs = WhirAirs::new( + program, + program_id, + proof.segments.len(), + 1usize + .checked_shl( + *proof + .table_num_vars + .get(proof.segments.len() + decode_count) + .unwrap_or(&0) as u32, + ) + .unwrap_or(8), + options, + vec![dummy], + ); + let refs = airs.refs(); + let shapes: Vec<_> = refs + .iter() + .zip(&proof.table_num_vars) + .map(|(a, &v)| (a.trace_layout().0, v as usize)) + .collect(); + let format = crate::block_whir::BlockFormat::production(); + let config = format.chain_config(&shapes); + let Some(&vars) = proof.table_num_vars.last() else { + return false; + }; + let Ok(layout) = multilinear_table::global_layout( + &[(cksum::cols::NUM_COLUMNS, vars as usize)], + config.format.stack, + ) else { + return false; + }; + let Some(prefix_len) = proof.proof.roots.len().checked_sub(layout.num_polys()) else { + return false; + }; + let alpha = cksum::binding_alpha( + c_bin, + &cksum::field_roots_digest(&proof.proof.roots[..prefix_len]), + ); + let air = cksum_air(root, faddr.to_vec(), options, 8); + let mut bound_public = public.to_vec(); + bound_public.push((cksum::CKSUM_ADDR, *s)); + bound_public.push((cksum::CKSUM_ALPHA_ADDR, alpha)); + bound_public.sort_by_key(|cell| cell.0); + verify_whir_block( + program_id, + program, + &bound_public, + proof, + vec![air], + options, + ) +} + +/// Verify a WHIR proof against the program, public cells and supplied extras. +pub fn verify_whir( + program_id: &[stark::config::Commitment], + program: &Program, + public: &[PublicCell], + proof: &FvmWhirProof, + extra: Vec, + options: &ProofOptions, +) -> bool { + if !prove::public_well_formed(public) || !well_formed(&proof.segments, &proof.table_num_vars) { + return false; + } + let decode_count = decode::chunks(program, prove::MIN_ROWS).len(); + if program_id.len() != decode_count { + return false; + } + let mem_vars_index = proof.segments.len() + decode_count; + let Some(&mem_vars) = proof.table_num_vars.get(mem_vars_index) else { + return false; + }; + let Some(mem_rows) = 1usize.checked_shl(mem_vars as u32) else { + return false; + }; + let airs = WhirAirs::new( + program, + program_id, + proof.segments.len(), + mem_rows, + options, + extra, + ); + let refs = airs.refs(); + if refs.len() != proof.table_num_vars.len() || refs.len() != proof.proof.tables.len() { + return false; + } + let shapes: Vec<(usize, usize)> = refs + .iter() + .zip(&proof.table_num_vars) + .map(|(a, &v)| (a.trace_layout().0, v as usize)) + .collect(); + let config = crate::multilinear_prove::chain_config(&shapes); + let layouts: Vec> = match refs + .iter() + .zip(&shapes) + .map(|(a, &(w, n))| layout_of(*a, w, n)) + .collect() + { + Ok(v) => v, + Err(_) => return false, + }; + let pre: Vec>> = match refs.iter().map(|a| preprocessed(*a)).collect() { + Ok(v) => v, + Err(_) => return false, + }; + let statements: Vec> = layouts + .iter() + .zip(&pre) + .map(|(l, p)| l.statement_with_preprocessed(p)) + .collect(); + let sizes = [shapes.len()]; + let Ok((stacked, domains)) = crate::multilinear_prove::stacks(&shapes, &sizes, &config) else { + return false; + }; + with_block_hash!(|H| { + let mut transcript = DefaultTranscript::< + E, + ::Transcript, + >::new(b"lambda-vm/field-vm/whir/v1\0\0\0"); + absorb_statement( + &mut transcript, + program_id, + public, + &proof.segments, + &proof.table_num_vars, + ); + let mut probe = transcript.clone(); + multilinear_table::absorb_roots::(&mut probe, &proof.proof.roots, &[]); + let z: FEE = probe.sample_field_element(); + let alpha: FEE = probe.sample_field_element(); + let Some(owed) = expected(public, &proof.segments, &z, &alpha) else { + return false; + }; + multilinear_table::multi_verify::<_, _, _, H>( + &proof.proof, + &statements, + &stacked, + &domains, + &sizes, + &owed, + &config, + &mut transcript, + None, + ) + .is_ok() + }) +} + +/// Verify a checksum-bound WHIR proof. `s` is the checksum value expected by +/// the guest; the verifier also closes that same value at `CKSUM_ADDR` on the +/// public bus. The checksum AIR challenge is recomputed from the ordered roots +/// of the proof's non-CKSUM group. +#[allow(clippy::too_many_arguments)] +pub fn verify_whir_bound( + c_bin: &[u64; 4], + s: &FEE, + program_id: &[stark::config::Commitment], + program: &Program, + public: &[PublicCell], + faddr: &[u64], + proof: &FvmWhirProof, + options: &ProofOptions, +) -> bool { + if !prove::public_well_formed(public) + || !well_formed(&proof.segments, &proof.table_num_vars) + || faddr.is_empty() + { + return false; + } + let decode_count = decode::chunks(program, prove::MIN_ROWS).len(); + if program_id.len() != decode_count || proof.proof.columns.len() != 2 { + return false; + } + let non_root_count = proof.proof.columns[0].polys.len(); + let checksum_root_count = proof.proof.columns[1].polys.len(); + if non_root_count == 0 || proof.proof.roots.len() != non_root_count + checksum_root_count { + return false; + } + let mem_vars_index = proof.segments.len() + decode_count; + let Some(&mem_vars) = proof.table_num_vars.get(mem_vars_index) else { + return false; + }; + let Some(mem_rows) = 1usize.checked_shl(mem_vars as u32) else { + return false; + }; + let c_fld = cksum::field_roots_digest(&proof.proof.roots[..non_root_count]); + let alpha = cksum::binding_alpha(c_bin, &c_fld); + let non_airs = WhirAirs::new( + program, + program_id, + proof.segments.len(), + mem_rows, + options, + Vec::new(), + ); + let root = cksum::commitment(faddr, options, 8); + let checksum = cksum_air(root, faddr.to_vec(), options, 8); + let airs = non_airs; + let refs = airs.refs(); + let mut owned_refs = refs; + owned_refs.push(checksum.as_ref()); + if owned_refs.len() != proof.table_num_vars.len() + || owned_refs.len() != proof.proof.tables.len() + { + return false; + } + let shapes: Vec<(usize, usize)> = owned_refs + .iter() + .zip(&proof.table_num_vars) + .map(|(a, &v)| (a.trace_layout().0, v as usize)) + .collect(); + let config = crate::multilinear_prove::chain_config(&shapes); + let layouts: Vec> = match owned_refs + .iter() + .zip(&shapes) + .map(|(a, &(w, n))| layout_of(*a, w, n)) + .collect() + { + Ok(v) => v, + Err(_) => return false, + }; + let pre: Vec>> = match owned_refs.iter().map(|a| preprocessed(*a)).collect() { + Ok(v) => v, + Err(_) => return false, + }; + let statements: Vec> = layouts + .iter() + .zip(&pre) + .map(|(l, p)| l.statement_with_preprocessed(p)) + .collect(); + let sizes = [shapes.len() - 1, 1]; + let Ok((stacked, domains)) = crate::multilinear_prove::stacks(&shapes, &sizes, &config) else { + return false; + }; + let mut bound_public = public.to_vec(); + if bound_public + .iter() + .any(|(addr, _)| *addr >= cksum::CKSUM_ADDR) + { + return false; + } + bound_public.push((cksum::CKSUM_ADDR, *s)); + bound_public.push((cksum::CKSUM_ALPHA_ADDR, alpha)); + bound_public.sort_by_key(|c| c.0); + with_block_hash!(|H| { + let mut transcript = DefaultTranscript::< + E, + ::Transcript, + >::new(b"lambda-vm/field-vm/whir/v1\0\0\0"); + absorb_statement( + &mut transcript, + program_id, + &bound_public, + &proof.segments, + &proof.table_num_vars, + ); + let mut probe = transcript.clone(); + multilinear_table::absorb_roots::(&mut probe, &proof.proof.roots, &[]); + let z: FEE = probe.sample_field_element(); + let transcript_alpha: FEE = probe.sample_field_element(); + let Some(owed) = expected(&bound_public, &proof.segments, &z, &transcript_alpha) else { + return false; + }; + multilinear_table::multi_verify::<_, _, _, H>( + &proof.proof, + &statements, + &stacked, + &domains, + &sizes, + &owed, + &config, + &mut transcript, + None, + ) + .is_ok() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::field_vm::asm::Asm; + use crate::field_vm::executor::{Memory, NoHints, execute}; + use crate::field_vm::isa::{Arg, gpr}; + use crate::field_vm::prove::{self, default_options}; + + fn run() -> (Program, super::super::executor::Execution) { + let r = gpr(0); + let mut asm = Asm::new(); + asm.mov(Arg::reg(r), Arg::imm(1)); + for _ in 0..10 { + asm.add(Arg::reg(r), Arg::reg(r), Arg::imm(1)); + } + asm.store(Arg::mem_abs(0), Arg::reg(r)).halt(); + let program = asm.finish().unwrap(); + let exec = execute(&program, Memory::new(1), &mut NoHints, 1024).unwrap(); + (program, exec) + } + + #[test] + fn field_vm_whir_statement_and_segment_chain() { + let (program, exec) = run(); + let options = default_options(); + let roots = prove::program_id(&program, &options); + let public = prove::public_cells(&exec, &[0]); + + let mut one_segment = prove::generate_traces(&program, &exec, &[0]); + let one_proof = prove_whir( + &roots, + &program, + &public, + &mut one_segment, + Vec::new(), + &mut [], + &options, + ) + .unwrap(); + assert!(verify_whir( + &roots, + &program, + &public, + &one_proof, + Vec::new(), + &options + )); + + let mut traces = prove::generate_traces_with(&program, &exec, &[0], Some(8)); + assert!(traces.fvm.len() > 1); + let proof = prove_whir( + &roots, + &program, + &public, + &mut traces, + Vec::new(), + &mut [], + &options, + ) + .unwrap(); + assert!(verify_whir( + &roots, + &program, + &public, + &proof, + Vec::new(), + &options + )); + + let mut changed_public = public.clone(); + changed_public[0].1 += FEE::one(); + assert!(!verify_whir( + &roots, + &program, + &changed_public, + &proof, + Vec::new(), + &options + )); + + let mut changed_state = proof.clone(); + changed_state.segments.last_mut().unwrap().last[0] += 1; + assert!(!verify_whir( + &roots, + &program, + &public, + &changed_state, + Vec::new(), + &options + )); + } + + #[test] + fn field_vm_whir_block_statement_and_groups() { + let (program, exec) = run(); + let options = default_options(); + let roots = prove::program_id(&program, &options); + let public = prove::public_cells(&exec, &[0]); + let mut traces = prove::generate_traces(&program, &exec, &[0]); + let proof = prove_whir_block( + &roots, + &program, + &public, + &mut traces, + Vec::new(), + &mut [], + &options, + ) + .unwrap(); + assert!(verify_whir_block( + &roots, + &program, + &public, + &proof, + Vec::new(), + &options + )); + let mut changed_public = public; + changed_public[0].1 += FEE::one(); + assert!(!verify_whir_block( + &roots, + &program, + &changed_public, + &proof, + Vec::new(), + &options + )); + } + + #[test] + fn whir_checksum_binding_accepts_and_rejects_statement_changes() { + let (program, exec) = run(); + let options = default_options(); + let roots = prove::program_id(&program, &options); + let public_addrs = [0]; + let public = prove::public_cells(&exec, &public_addrs); + let faddr = [0u64]; + let values = [exec.mem[0]]; + let c_bin = [7, 11, 13, 17]; + let mut traces = + prove::generate_traces_reading(&program, &exec, &public_addrs, None, &faddr); + let (proof, alpha) = prove_whir_bound( + &roots, + &program, + &public, + &mut traces, + &c_bin, + &faddr, + &values, + &options, + ) + .unwrap(); + let s = cksum::horner(&values, alpha); + assert!(verify_whir_bound( + &c_bin, &s, &roots, &program, &public, &faddr, &proof, &options + )); + + assert!(!verify_whir_bound( + &c_bin, + &(s + FEE::one()), + &roots, + &program, + &public, + &faddr, + &proof, + &options, + )); + let mut changed_c_bin = c_bin; + changed_c_bin[0] += 1; + assert!(!verify_whir_bound( + &changed_c_bin, + &s, + &roots, + &program, + &public, + &faddr, + &proof, + &options, + )); + } +} From 5a18633aebc0ac238003907544ff8f9363e0bf08 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 21 Oct 2026 09:54:16 -0300 Subject: [PATCH 08/11] Add a shape-independent Field VM fold kernel generic_fold is one Field VM program that performs a WHIR fold for any round shape, reading the shape as data instead of unrolling it. It costs 1.06-1.26x the rows of the shape-specific code and is a step toward a constant program id (L102). Tests are off under fvm-narrow. --- prover/src/field_vm/generic_fold.rs | 466 ++++++++++++++++++++++++++++ prover/src/field_vm/mod.rs | 1 + 2 files changed, 467 insertions(+) create mode 100644 prover/src/field_vm/generic_fold.rs diff --git a/prover/src/field_vm/generic_fold.rs b/prover/src/field_vm/generic_fold.rs new file mode 100644 index 000000000..7e1d9e0e0 --- /dev/null +++ b/prover/src/field_vm/generic_fold.rs @@ -0,0 +1,466 @@ +//! Shape-independent Field VM driver for WHIR's lean coset fold. + +use super::asm::{Asm, Label}; +use super::isa::{Arg, Program, REG_PC, gpr}; +use crate::tables::types::{FE, FEE, GoldilocksField}; + +const ALPHAS: usize = 8; +const FACTORS: i64 = 14; +const ETAS: i64 = 46; +const ROUND_STRIDE: usize = ETAS as usize + 6 * 32; +const QUERY_BITS: usize = 32; +const OUTPUT_CELLS: usize = 6 * 64; +const WORK_CELLS: usize = OUTPUT_CELLS; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FoldRound { + pub domain_log: usize, + pub block: usize, + /// The lean fold's `half_alpha` values, one per level. + pub alphas: Vec, + pub queries: Vec<(u64, Vec, FEE)>, +} + +/// Data memory for [`kernel`]. The returned prefix is initialized; the trailing +/// workspaces must be left uninitialized in `Memory`, since the VM defines them. +pub fn layout(rounds: &[FoldRound]) -> Vec { + validate_rounds(rounds); + let mut mem = vec![FEE::zero(); 2 + rounds.len() * ROUND_STRIDE]; + for r in rounds { + for (index, values, _) in &r.queries { + assert_eq!(values.len(), r.block); + assert!( + (*index >> r.domain_log) == 0, + "query index exceeds domain bits" + ); + } + } + let headers_end = mem.len(); + let mut query_bases = Vec::with_capacity(rounds.len()); + let mut all_queries = Vec::new(); + for r in rounds { + query_bases.push(mem.len()); + let stride = QUERY_BITS + r.block + 1; + for (index, values, claimed) in &r.queries { + for i in 0..QUERY_BITS { + mem.push(if i < r.domain_log && ((index >> i) & 1) != 0 { + FEE::one() + } else { + FEE::zero() + }); + } + mem.extend(values.iter().copied()); + mem.push(*claimed); + } + all_queries.push((stride, r.queries.len())); + } + let mut workspace_base = mem.len(); + for (i, r) in rounds.iter().enumerate() { + let h = 2 + i * ROUND_STRIDE; + let (record_stride, qcount) = all_queries[i]; + mem[h] = FEE::from(query_bases[i] as u64); + mem[h + 1] = FEE::from(qcount as u64); + mem[h + 2] = FEE::from(record_stride as u64); + mem[h + 3] = FEE::from(workspace_base as u64); + mem[h + 4] = FEE::from(WORK_CELLS as u64); + mem[h + 5] = FEE::from(r.block as u64); + mem[h + 6] = FEE::from(r.domain_log as u64); + mem[h + 7] = FE::from(2u64) + .inv() + .expect("2 is invertible") + .to_extension(); + for (level, alpha) in r.alphas.iter().enumerate() { + mem[h + ALPHAS + level] = *alpha; + } + + let generator = domain_generator(r.domain_log); + let generator_inv = generator.inv().expect("domain generator is nonzero"); + for i in 0..32 { + let f = if i < r.domain_log { + generator_inv.pow(1u64 << i) + } else { + FE::one() + }; + mem[h + FACTORS as usize + i] = (f - FE::one()).to_extension(); + } + let mut current_len = r.block; + for level in 0..r.alphas.len() { + let eta_inv = generator_inv.pow((1usize << r.domain_log) as u64 / current_len as u64); + for t in 0..32 { + let eta_t = if t < current_len / 2 { + eta_inv.pow(t as u64).to_extension() + } else { + FEE::zero() + }; + mem[h + ETAS as usize + level * 32 + t] = eta_t; + } + current_len /= 2; + } + workspace_base += r.queries.len() * WORK_CELLS; + } + mem[0] = FEE::from(headers_end as u64); + mem +} + +/// A single program supports all blocks 2..=64 (powers of two), domain logs +/// through 32, any number of rounds, and any query count. +pub fn kernel() -> Program { + build_kernel() +} + +fn build_kernel() -> Program { + // Generate fixed code for every supported block. The only runtime-varying + // values (alphas, roots, bits, queries) are read from the round header/data. + let mut a = Asm::new(); + // Keep only the three long-lived pointers and two working values in + // registers. The fifth GPR is the sole scratch register. Unused workspace + // cells hold the query end and per-level inverse-point values. + let (rp, qp, wp, x, tmp) = (gpr(0), gpr(1), gpr(2), gpr(3), gpr(4)); + let round = a.label(); + let query = a.label(); + let query_done = a.label(); + let round_done = a.label(); + let halt = a.label(); + a.mov(Arg::reg(rp), Arg::imm(2)); + a.bind(round); + a.sub(Arg::reg(x), Arg::reg(rp), Arg::mem_abs(0)); + a.jump_zero(halt); + a.mov(Arg::reg(qp), Arg::mem(rp, 0)); + // Workspace cell 383 is beyond every fold output. Copy the round's + // one-past-the-end query pointer into each query's workspace as we advance. + // Skip this for empty rounds, which have no workspace. + a.sub(Arg::reg(x), Arg::mem(rp, 1), Arg::imm(0)); + a.jump_zero(round_done); + a.mov(Arg::reg(wp), Arg::mem(rp, 3)); + a.fma( + Arg::mem(wp, 383), + Arg::mem(rp, 1), + Arg::mem(rp, 2), + Arg::mem(rp, 0), + ); + a.bind(query); + + let dispatch = a.here(); + a.mov(Arg::reg(x), Arg::mem(rp, 5)); + let table_pc = dispatch + 2; + a.fma_out( + Arg::reg(REG_PC), + Arg::imm(0), + Arg::imm(0), + Arg::lin(x, 1, table_pc as i64), + ); + let table: Vec