From 7f40e4d37bf9e5e31438a453bd11eb5f27fa8091 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 7 Sep 2026 18:26:18 -0300 Subject: [PATCH 1/7] refactor(math-cuda): delete the mixed-height MMCS device build The batched multi-Merkle-tree proof format is retired, and the device MMCS existed only for it: `MmcsGroupHasher` was the device twin of the host's `StreamingMmcsBuilder`, and `build_mmcs_tree_on_device` built the one tree per epoch that the per-table prover does not have. Removed: `src/mmcs.rs`, its module declaration, the five backend handles and their loads in `src/device.rs`, the five kernels in `kernels/keccak.cu` (`mmcs_states_init`, `mmcs_absorb_row_pair_row_major`, `mmcs_absorb_row_pair_ext3_slabs`, `mmcs_states_finalize`, `keccak_mmcs_level`), and `tests/mmcs_tree_parity.rs`, whose subject was device-vs-host root parity for that tree. `kernels/blake3.cu` had no MMCS twin to remove. `merkle_gather_paths` and `keccak_launch_cfg` are hash- and format-agnostic and stay: the per-table path is their caller. --- crypto/math-cuda/kernels/keccak.cu | 235 ------------------ crypto/math-cuda/src/blake3.rs | 4 +- crypto/math-cuda/src/device.rs | 16 -- crypto/math-cuda/src/lib.rs | 1 - crypto/math-cuda/src/mmcs.rs | 274 --------------------- crypto/math-cuda/tests/mmcs_tree_parity.rs | 201 --------------- 6 files changed, 1 insertion(+), 730 deletions(-) delete mode 100644 crypto/math-cuda/src/mmcs.rs delete mode 100644 crypto/math-cuda/tests/mmcs_tree_parity.rs diff --git a/crypto/math-cuda/kernels/keccak.cu b/crypto/math-cuda/kernels/keccak.cu index 70948f836..2762d7469 100644 --- a/crypto/math-cuda/kernels/keccak.cu +++ b/crypto/math-cuda/kernels/keccak.cu @@ -597,238 +597,3 @@ extern "C" __global__ void keccak256_leaves_base_row_major_row_pair_range( } finalize_keccak256(st, rate_pos, hashed_leaves_out + tid * 32); } - -// --------------------------------------------------------------------------- -// Mixed-height MMCS (batched commitments). -// -// One tree over ALL of an epoch's matrices — see `crypto/stark/src/fri/mmcs.rs` -// for the layout that is the single source of truth. The device build mirrors -// the HOST STREAMING BUILDER (`StreamingMmcsBuilder`), not `MixedMmcs::commit`, -// and that choice is the whole point: a height group's leaf is one Keccak over -// every matrix's concatenated row pair, so hashing it in one pass needs every -// matrix of that height resident at once. On a real epoch the tallest group is -// most of the tables, which is the memory the batching exists to remove. -// -// So the sponge state lives in device memory, one per leaf, and matrices are -// absorbed into it ONE AT A TIME: -// -// mmcs_states_init(states, rate_pos, num_leaves) -// for each matrix m of this height, in INPUT order: -// -// mmcs_absorb_row_pair_row_major(...) // or the ext3 slab variant -// -// mmcs_states_finalize(states, rate_pos, num_leaves, digests_out) -// -// Retained state is 204 bytes per leaf (25 lanes + the rate cursor), i.e. -// ~214 MB at 2^20 leaves, against one full LDE per matrix in the group. -// -// The absorbed byte stream is identical to the host's: each matrix contributes -// row `2k` then row `2k+1`, both bit-reversed, canonical big-endian, in column -// order. `keccak256_leaves_base_row_major_row_pair` is the single-matrix case -// of exactly this loop, which is why a one-matrix MMCS is byte-identical to the -// existing per-table tree on device as well as on the host. -// -// Node layout is the STANDARD heap array (`nodes[0..leaves_len-1]` inner, root -// at 0, leaves at `[leaves_len-1..]`), not one array per MMCS layer. That is -// deliberate: in that layout the sibling at level L of a query is the node -// `merkle_gather_paths` already walks to, so the batched path gather is the -// existing hash-agnostic kernel unchanged, with no second index convention to -// keep in step. -// --------------------------------------------------------------------------- - -// Per-leaf sponge state, zeroed. `states` is `num_leaves * 25` u64s and -// `rate_pos` is `num_leaves` u32s. -extern "C" __global__ void mmcs_states_init( - uint64_t *states, - uint32_t *rate_pos, - uint64_t num_leaves) -{ - uint64_t tid = (uint64_t)blockIdx.x * blockDim.x + threadIdx.x; - if (tid >= num_leaves) return; - uint64_t *st = states + tid * 25; - #pragma unroll - for (int i = 0; i < 25; ++i) st[i] = 0; - rate_pos[tid] = 0; -} - -// Absorb one ROW-MAJOR matrix's row pair into every leaf's running sponge. -// -// `data` is the matrix's row-major LDE (`num_rows` rows of `m` u64s). Columns -// `[col_start, col_end)` are absorbed while `m` stays the full row stride, so -// a preprocessed table's precomputed and multiplicity ranges over one buffer -// are two absorptions rather than two buffers. -// -// Base field: `m` = column count, `col_start`/`col_end` in columns. Ext3: an -// element's three components are consecutive, so `m` = 3 * column count and -// the range is in components — the same convention -// `keccak256_leaves_base_row_major_row_pair` documents. -// -// `num_leaves` is the TREE's leaf count, which for a matrix shorter than the -// tallest is its own `2^(log_height-1)` — this kernel is launched per height -// group, so `num_rows` and `num_leaves` always belong to the same matrix. -extern "C" __global__ void mmcs_absorb_row_pair_row_major( - uint64_t *states, - uint32_t *rate_pos, - const uint64_t *data, - uint64_t m, - uint64_t col_start, - uint64_t col_end, - uint64_t num_rows, - uint64_t log_num_rows, - uint64_t num_leaves) -{ - uint64_t tid = (uint64_t)blockIdx.x * blockDim.x + threadIdx.x; - if (tid >= num_leaves) return; - - uint64_t br_0 = __brevll(2 * tid) >> (64 - log_num_rows); - uint64_t br_1 = __brevll(2 * tid + 1) >> (64 - log_num_rows); - const uint64_t *row_0 = data + br_0 * m; - const uint64_t *row_1 = data + br_1 * m; - - // Load the running state into registers: the absorb loop touches it once - // per column and a global round-trip per lane would dominate the hash. - uint64_t st[25]; - uint64_t *st_g = states + tid * 25; - #pragma unroll - for (int i = 0; i < 25; ++i) st[i] = st_g[i]; - uint32_t rp = rate_pos[tid]; - - for (uint64_t c = col_start; c < col_end; ++c) { - absorb_lane(st, rp, bswap64(goldilocks::canonical(row_0[c]))); - } - for (uint64_t c = col_start; c < col_end; ++c) { - absorb_lane(st, rp, bswap64(goldilocks::canonical(row_1[c]))); - } - - #pragma unroll - for (int i = 0; i < 25; ++i) st_g[i] = st[i]; - rate_pos[tid] = rp; -} - -// Absorb one COLUMN-MAJOR ext3 slab matrix's row pair — the composition-poly -// LDE layout (`GpuLdeExt3`): component `k` of column `c` at -// `(c*3 + k) * col_stride`. Same absorbed byte order as -// `keccak_comp_poly_leaves_ext3`, which is this kernel's single-matrix case. -extern "C" __global__ void mmcs_absorb_row_pair_ext3_slabs( - uint64_t *states, - uint32_t *rate_pos, - const uint64_t *parts_base_ptr, - uint64_t col_stride, - uint64_t num_parts, - uint64_t num_rows, - uint64_t log_num_rows, - uint64_t num_leaves) -{ - uint64_t tid = (uint64_t)blockIdx.x * blockDim.x + threadIdx.x; - if (tid >= num_leaves) return; - - uint64_t br_0 = __brevll(2 * tid) >> (64 - log_num_rows); - uint64_t br_1 = __brevll(2 * tid + 1) >> (64 - log_num_rows); - - uint64_t st[25]; - uint64_t *st_g = states + tid * 25; - #pragma unroll - for (int i = 0; i < 25; ++i) st[i] = st_g[i]; - uint32_t rp = rate_pos[tid]; - - for (uint64_t p = 0; p < num_parts; ++p) { - #pragma unroll - for (int k = 0; k < 3; ++k) { - uint64_t v = parts_base_ptr[(p * 3 + (uint64_t)k) * col_stride + br_0]; - absorb_lane(st, rp, bswap64(goldilocks::canonical(v))); - } - } - for (uint64_t p = 0; p < num_parts; ++p) { - #pragma unroll - for (int k = 0; k < 3; ++k) { - uint64_t v = parts_base_ptr[(p * 3 + (uint64_t)k) * col_stride + br_1]; - absorb_lane(st, rp, bswap64(goldilocks::canonical(v))); - } - } - - #pragma unroll - for (int i = 0; i < 25; ++i) st_g[i] = st[i]; - rate_pos[tid] = rp; -} - -// Pad and squeeze every leaf's sponge into a 32-byte digest. -extern "C" __global__ void mmcs_states_finalize( - const uint64_t *states, - const uint32_t *rate_pos, - uint64_t num_leaves, - uint8_t *digests_out) -{ - uint64_t tid = (uint64_t)blockIdx.x * blockDim.x + threadIdx.x; - if (tid >= num_leaves) return; - - uint64_t st[25]; - const uint64_t *st_g = states + tid * 25; - #pragma unroll - for (int i = 0; i < 25; ++i) st[i] = st_g[i]; - - finalize_keccak256(st, rate_pos[tid], digests_out + tid * 32); -} - -// One climb level of the mixed-height tree. -// -// `parent = C(left, right)`, and where a shorter height group injects at this -// level, `parent = C(parent, inject[j])`. `inject` is that group's `n_pairs` -// finalized leaf digests, or `nullptr` when no matrix has the level's height — -// the two arms are `keccak_merkle_level` and its injecting counterpart, kept in -// one kernel so the node layout has one writer. -// -// Node indexing matches `hash_merkle_parent`: children of parent `parent_begin -// + tid` are at `parent_begin + n_pairs + 2*tid` and `+ 1`. -extern "C" __global__ void keccak_mmcs_level( - uint8_t *nodes, - uint64_t parent_begin, - uint64_t n_pairs, - const uint8_t *inject, - uint32_t has_inject) -{ - uint64_t tid = (uint64_t)blockIdx.x * blockDim.x + threadIdx.x; - if (tid >= n_pairs) return; - - // Children of parent `parent_begin + tid`, same indexing as - // `hash_merkle_parent`. Nodes sit at 32-byte-aligned offsets (cuMemAlloc is - // 256-aligned), so the u64 view is safe. - const uint64_t *left = reinterpret_cast( - nodes + (parent_begin + n_pairs + 2 * tid) * 32); - const uint64_t *right = reinterpret_cast( - nodes + (parent_begin + n_pairs + 2 * tid + 1) * 32); - - uint8_t parent[32]; - { - uint64_t st[25]; - #pragma unroll - for (int i = 0; i < 25; ++i) st[i] = 0; - uint32_t rate_pos = 0; - #pragma unroll - for (int i = 0; i < 4; ++i) absorb_lane(st, rate_pos, left[i]); - #pragma unroll - for (int i = 0; i < 4; ++i) absorb_lane(st, rate_pos, right[i]); - finalize_keccak256(st, rate_pos, parent); - } - - uint8_t *out = nodes + (parent_begin + tid) * 32; - if (!has_inject) { - #pragma unroll - for (int i = 0; i < 32; ++i) out[i] = parent[i]; - return; - } - - // One more fixed-shape 64-byte compression against this level's injected - // group digest, so an injected level costs exactly one extra permutation - // per node. - uint64_t st[25]; - #pragma unroll - for (int i = 0; i < 25; ++i) st[i] = 0; - uint32_t rate_pos = 0; - const uint64_t *p = reinterpret_cast(parent); - #pragma unroll - for (int i = 0; i < 4; ++i) absorb_lane(st, rate_pos, p[i]); - const uint64_t *inj = reinterpret_cast(inject + tid * 32); - #pragma unroll - for (int i = 0; i < 4; ++i) absorb_lane(st, rate_pos, inj[i]); - finalize_keccak256(st, rate_pos, out); -} diff --git a/crypto/math-cuda/src/blake3.rs b/crypto/math-cuda/src/blake3.rs index 15747baa8..49a8e9fbd 100644 --- a/crypto/math-cuda/src/blake3.rs +++ b/crypto/math-cuda/src/blake3.rs @@ -42,9 +42,7 @@ //! //! All seven leaf kernels, both tree compressors and the wrapper twins are //! here; the device-side launchers (`launch_*`) are what the dispatch sites in -//! [`crate::lde`] and [`crate::fri`] call. The streaming mixed-MMCS builder -//! ([`crate::mmcs`]) has no BLAKE3 twin: it has no production caller on any -//! hash yet. +//! [`crate::lde`] and [`crate::fri`] call. use cudarc::driver::{CudaSlice, CudaStream, CudaViewMut, LaunchConfig, PushKernelArg}; use std::sync::Arc; diff --git a/crypto/math-cuda/src/device.rs b/crypto/math-cuda/src/device.rs index cee3f304b..a55ff9c4b 100644 --- a/crypto/math-cuda/src/device.rs +++ b/crypto/math-cuda/src/device.rs @@ -204,15 +204,6 @@ pub struct Backend { pub keccak_merkle_level: CudaFunction, pub keccak_merkle_tail: CudaFunction, pub merkle_gather_paths: CudaFunction, - // Mixed-height MMCS (batched commitments). The per-leaf sponge is kept in - // device memory and matrices are absorbed into it one at a time, so a height - // group never needs all its LDEs resident — see `kernels/keccak.cu`. - pub mmcs_states_init: CudaFunction, - pub mmcs_absorb_row_pair_row_major: CudaFunction, - pub mmcs_absorb_row_pair_ext3_slabs: CudaFunction, - pub mmcs_states_finalize: CudaFunction, - pub keccak_mmcs_level: CudaFunction, - // blake3.cubin — the leaf kernels, the Merkle level/tail compressors, and // the parity-harness probes that are the only host-visible handle on the // device compression function, byte serialization and chain construction @@ -552,13 +543,6 @@ impl Backend { keccak_merkle_level: keccak.load_function("keccak_merkle_level")?, keccak_merkle_tail: keccak.load_function("keccak_merkle_tail")?, merkle_gather_paths: keccak.load_function("merkle_gather_paths")?, - mmcs_states_init: keccak.load_function("mmcs_states_init")?, - mmcs_absorb_row_pair_row_major: keccak - .load_function("mmcs_absorb_row_pair_row_major")?, - mmcs_absorb_row_pair_ext3_slabs: keccak - .load_function("mmcs_absorb_row_pair_ext3_slabs")?, - mmcs_states_finalize: keccak.load_function("mmcs_states_finalize")?, - keccak_mmcs_level: keccak.load_function("keccak_mmcs_level")?, blake3_leaves_base_row_major_row_pair: blake3 .load_function("blake3_leaves_base_row_major_row_pair")?, blake3_leaves_base_row_major_row_pair_range: blake3 diff --git a/crypto/math-cuda/src/lib.rs b/crypto/math-cuda/src/lib.rs index e0af3781f..b4867cbf6 100644 --- a/crypto/math-cuda/src/lib.rs +++ b/crypto/math-cuda/src/lib.rs @@ -18,7 +18,6 @@ pub mod inverse; pub mod lde; pub mod logup; pub mod merkle; -pub mod mmcs; pub mod ntt; pub mod nvtx; pub mod rpx; diff --git a/crypto/math-cuda/src/mmcs.rs b/crypto/math-cuda/src/mmcs.rs deleted file mode 100644 index b6e20f17e..000000000 --- a/crypto/math-cuda/src/mmcs.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! Device build of the mixed-height MMCS — one tree over all of an epoch's -//! matrices. -//! -//! The host contract this must reproduce byte for byte lives in -//! `crypto/stark/src/fri/mmcs.rs`: leaf `k` of a height group is Keccak-256 over -//! the concatenation, in INPUT order, of each matrix's bit-reversed rows `2k` and -//! `2k+1`; the climb compresses pairs and, where a shorter group's height matches -//! the halved layer, compresses the parent again with that group's leaf digest. -//! -//! # Why this mirrors the streaming builder, not `commit` -//! -//! `MixedMmcs::commit` hashes a group's leaf in one pass, which needs every -//! matrix of that height readable at once. The tallest group is most of a real -//! epoch's tables, so that is `O(N)` LDE resident at the base layer — the memory -//! the batching exists to remove. [`MmcsGroupHasher`] is the device twin of the -//! host's `StreamingMmcsBuilder`: the per-leaf sponge lives in VRAM and matrices -//! are absorbed into it one at a time, so the caller produces one matrix's LDE on -//! device, absorbs it, and frees it. -//! -//! Sponge state is 204 bytes per leaf (25 lanes plus the rate cursor): -//! ~214 MiB at 2^20 leaves, against one full LDE per matrix in the group. -//! -//! # Node layout, and why the path gather is unchanged -//! -//! [`build_mmcs_tree_on_device`] writes the STANDARD heap array — inner nodes at -//! `[0, leaves_len-1)` with the root at 0, leaves at `[leaves_len-1, ..)` — the -//! same layout [`crate::merkle::build_merkle_tree_on_device`] produces. In that -//! layout the sibling a query needs at MMCS level `L` is exactly the node -//! [`crate::merkle::gather_merkle_paths_dev`] already walks to, so the batched -//! path gather is that kernel unchanged. Keeping one layout is what stops a -//! second index convention existing to drift from the first. - -use cudarc::driver::{CudaSlice, CudaStream, PushKernelArg}; -use std::sync::Arc; - -use crate::Result; -use crate::device::backend; -use crate::merkle::keccak_launch_cfg; - -/// One height group's per-leaf sponges, live on device between absorptions. -/// -/// Construct once per height group, [`Self::absorb_row_major`] / -/// [`Self::absorb_ext3_slabs`] once per matrix at that height IN INPUT ORDER -/// (the leaf concatenation binds that order), then [`Self::finalize`]. -pub struct MmcsGroupHasher { - states: CudaSlice, - rate_pos: CudaSlice, - num_leaves: u64, - /// `log2` of the group's row count — every matrix absorbed here must have it, - /// since they share the leaves. - log_num_rows: u64, - absorbed: usize, -} - -impl MmcsGroupHasher { - /// Zeroed sponges for a height group of `2^log_num_rows` rows, i.e. - /// `2^(log_num_rows - 1)` leaves. - pub fn new(stream: &Arc, log_num_rows: u64) -> Result { - assert!( - log_num_rows >= 1, - "row-pair leaves need at least 2 rows (log_num_rows >= 1)" - ); - let be = backend()?; - let num_leaves = 1u64 << (log_num_rows - 1); - let mut states = stream.alloc_zeros::((num_leaves * 25) as usize)?; - let mut rate_pos = stream.alloc_zeros::(num_leaves as usize)?; - - // `alloc_zeros` already gives the state we want; the kernel runs anyway so - // the zeroing is this module's own statement rather than an allocator - // property a future change could quietly take away. - let cfg = keccak_launch_cfg(num_leaves); - unsafe { - stream - .launch_builder(&be.mmcs_states_init) - .arg(&mut states) - .arg(&mut rate_pos) - .arg(&num_leaves) - .launch(cfg)?; - } - - Ok(Self { - states, - rate_pos, - num_leaves, - log_num_rows, - absorbed: 0, - }) - } - - /// Absorb one row-major matrix's row pair into every leaf. Columns - /// `[col_start, col_end)` are absorbed while `row_stride` stays the full row - /// width, so a preprocessed table's two column ranges over one buffer are two - /// absorptions rather than two buffers. - /// - /// Base field: `row_stride` and the range are in columns. Ext3: an element's - /// three components are consecutive, so both are in components — the same - /// convention `keccak256_leaves_base_row_major_row_pair` documents. - /// - /// The caller may free `data` as soon as this returns on `stream`. - #[allow(clippy::too_many_arguments)] - pub fn absorb_row_major( - &mut self, - stream: &Arc, - data: &CudaSlice, - row_stride: u64, - col_start: u64, - col_end: u64, - ) -> Result<()> { - assert!( - col_start <= col_end && col_end <= row_stride, - "column range [{col_start}, {col_end}) does not fit a stride of {row_stride}" - ); - let be = backend()?; - let num_rows = 1u64 << self.log_num_rows; - let cfg = keccak_launch_cfg(self.num_leaves); - unsafe { - stream - .launch_builder(&be.mmcs_absorb_row_pair_row_major) - .arg(&mut self.states) - .arg(&mut self.rate_pos) - .arg(data) - .arg(&row_stride) - .arg(&col_start) - .arg(&col_end) - .arg(&num_rows) - .arg(&self.log_num_rows) - .arg(&self.num_leaves) - .launch(cfg)?; - } - self.absorbed += 1; - Ok(()) - } - - /// Absorb one column-major ext3 slab matrix — the composition-poly LDE - /// layout, component `k` of column `c` at `(c*3 + k) * col_stride`. - pub fn absorb_ext3_slabs( - &mut self, - stream: &Arc, - parts: &CudaSlice, - col_stride: u64, - num_parts: u64, - ) -> Result<()> { - let be = backend()?; - let num_rows = 1u64 << self.log_num_rows; - let cfg = keccak_launch_cfg(self.num_leaves); - unsafe { - stream - .launch_builder(&be.mmcs_absorb_row_pair_ext3_slabs) - .arg(&mut self.states) - .arg(&mut self.rate_pos) - .arg(parts) - .arg(&col_stride) - .arg(&num_parts) - .arg(&num_rows) - .arg(&self.log_num_rows) - .arg(&self.num_leaves) - .launch(cfg)?; - } - self.absorbed += 1; - Ok(()) - } - - /// Pad and squeeze every leaf. Panics if nothing was absorbed: an empty - /// group's digests would be the hash of nothing, which is a leaf no verifier - /// can rebuild from an opening. - pub fn finalize(self, stream: &Arc) -> Result> { - assert!( - self.absorbed > 0, - "a height group must absorb at least one matrix before it is finalized" - ); - let be = backend()?; - let mut digests = stream.alloc_zeros::((self.num_leaves * 32) as usize)?; - let cfg = keccak_launch_cfg(self.num_leaves); - unsafe { - stream - .launch_builder(&be.mmcs_states_finalize) - .arg(&self.states) - .arg(&self.rate_pos) - .arg(&self.num_leaves) - .arg(&mut digests) - .launch(cfg)?; - } - Ok(digests) - } - - pub fn num_leaves(&self) -> u64 { - self.num_leaves - } -} - -/// Build the mixed-height tree from each height group's finalized leaf digests. -/// -/// `group_digests[h]` is `Some(device digests)` when some matrix has -/// `log_height == h`, each `2^(h-1)` digests of 32 bytes; index `h_max` must be -/// present. Returns the standard heap node buffer -/// (`(2 * 2^(h_max-1) - 1) * 32` bytes) resident on device. -pub fn build_mmcs_tree_on_device( - stream: &Arc, - group_digests: &[Option>], -) -> Result> { - let h_max = group_digests.len() - 1; - assert!( - h_max >= 1 && group_digests[h_max].is_some(), - "the tallest height group must be present" - ); - let be = backend()?; - let leaves_len = 1u64 << (h_max - 1); - - let mut nodes = stream.alloc_zeros::(((2 * leaves_len - 1) * 32) as usize)?; - // Base layer into the leaf tail of the heap array. - let base = group_digests[h_max] - .as_ref() - .expect("checked immediately above"); - let mut leaf_tail = nodes.slice_mut(((leaves_len - 1) * 32) as usize..); - stream.memcpy_dtod(base, &mut leaf_tail)?; - - // Climb. Level `i` produces the layer whose codeword height is - // `h_max - 1 - i`, which is where a group of that height injects — the same - // schedule `MixedMmcs::from_group_digests` walks. - let mut level_begin: u64 = leaves_len - 1; - let mut i = 0usize; - while level_begin != 0 { - let new_begin = level_begin / 2; - let n_pairs = level_begin - new_begin; - let inject_h = h_max - 1 - i; - let injected = group_digests.get(inject_h).and_then(Option::as_ref); - let has_inject: u32 = u32::from(injected.is_some()); - - // `keccak_mmcs_level` reads `inject` only when `has_inject` is set, so a - // level with no injection still needs a pointer argument. Reuse the - // node buffer's own base rather than allocating a dummy: it is a valid - // device pointer that the kernel provably never dereferences. - let cfg = keccak_launch_cfg(n_pairs); - match injected { - Some(digests) => unsafe { - stream - .launch_builder(&be.keccak_mmcs_level) - .arg(&mut nodes) - .arg(&new_begin) - .arg(&n_pairs) - .arg(digests) - .arg(&has_inject) - .launch(cfg)?; - }, - None => { - let empty = stream.alloc_zeros::(32)?; - unsafe { - stream - .launch_builder(&be.keccak_mmcs_level) - .arg(&mut nodes) - .arg(&new_begin) - .arg(&n_pairs) - .arg(&empty) - .arg(&has_inject) - .launch(cfg)?; - } - } - } - - level_begin = new_begin; - i += 1; - } - - Ok(nodes) -} - -/// The MMCS root — node 0 of the heap array. -pub fn read_mmcs_root(stream: &Arc, nodes: &CudaSlice) -> Result<[u8; 32]> { - let head = nodes.slice(0..32); - let bytes = stream.clone_dtoh(&head)?; - let mut root = [0u8; 32]; - root.copy_from_slice(&bytes); - Ok(root) -} diff --git a/crypto/math-cuda/tests/mmcs_tree_parity.rs b/crypto/math-cuda/tests/mmcs_tree_parity.rs deleted file mode 100644 index e6364031a..000000000 --- a/crypto/math-cuda/tests/mmcs_tree_parity.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! The device mixed-height MMCS must build the SAME tree as the host -//! `stark::fri::mmcs::MixedMmcs` — same root, same authentication paths, so a -//! proof committed on GPU is opened and verified by the same verifier as one -//! committed on CPU. -//! -//! ⚠ **This file has never been executed.** It was written on a machine with no -//! GPU and no nvcc, where `math-cuda` compiles against empty cubin stubs and -//! every device call falls back or fails. It compiles and it lints; nothing here -//! is evidence that the kernels are correct. Run it on a rented box — the exact -//! commands are in `RESUME-MMCS-INT.md` — before any claim that the batched GPU -//! path works. -//! -//! What each test is FOR, so a failure says something: -//! -//! - `single_matrix_mmcs_root_matches_the_per_table_tree` — the degenerate case. -//! A one-matrix MMCS is the existing row-pair tree, so this failing means the -//! absorb kernel's byte order or bit-reversal is wrong, independently of -//! anything mixed-height. -//! - `mixed_height_root_matches_the_host` — the climb with injection. This is -//! the kernel that has no CPU counterpart to have been debugged against. -//! - `absorption_order_is_bound` — the leaf concatenates matrices in INPUT -//! order; two matrices absorbed the other way round must give a different root. -//! Without this, an order bug is invisible whenever the widths happen to match. -//! - `paths_match_the_host_at_every_query` — the reason the device tree uses the -//! standard heap layout at all: `merkle_gather_paths` unchanged must return -//! the host's `MixedOpening::proof`. - -use math::field::element::FieldElement; -use math::field::goldilocks::GoldilocksField; -use stark::config::KeccakStarkHash; -use stark::fri::mmcs::{LeafSource, MixedMmcs}; - -type Fp = FieldElement; -type Mmcs = MixedMmcs; - -/// Bit-reversed row-major matrices, the layout the MMCS commits and the layout -/// `mmcs_absorb_row_pair_row_major` reads (the kernel bit-reverses internally, so -/// the device buffer holds the matrix in NATURAL order). -struct Matrices { - /// `(natural-order row-major data, log_height, width)`. - mats: Vec<(Vec, usize, usize)>, -} - -impl LeafSource for Matrices { - fn num_matrices(&self) -> usize { - self.mats.len() - } - fn log_height(&self, m: usize) -> usize { - self.mats[m].1 - } - fn width(&self, m: usize) -> usize { - self.mats[m].2 - } - fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec) { - let (data, log_height, width) = &self.mats[m]; - let natural = math::fft::bit_reversing::reverse_index(bitrev_row, 1u64 << log_height); - out.extend_from_slice(&data[natural * width..(natural + 1) * width]); - } -} - -fn matrix(log_height: usize, width: usize, seed: u64) -> (Vec, usize, usize) { - let num_rows = 1usize << log_height; - let data = (0..num_rows * width) - .map(|i| Fp::from(seed.wrapping_mul(1_000_003).wrapping_add(i as u64) | 1)) - .collect(); - (data, log_height, width) -} - -fn raw(data: &[Fp]) -> Vec { - data.iter().map(|x| *x.value()).collect() -} - -/// Build the tree on device from `specs`, absorbing matrices in input order and -/// freeing each matrix's device buffer before the next — the residency policy the -/// whole design exists for. -fn device_tree(mats: &Matrices) -> ([u8; 32], Vec) { - let be = math_cuda::device::backend().expect("a GPU box: no backend means nothing to test"); - let stream = be.next_stream(); - - let h_max = (0..mats.num_matrices()) - .map(|m| mats.log_height(m)) - .max() - .expect("non-empty"); - - let mut group_digests: Vec>> = - (0..=h_max).map(|_| None).collect(); - - for (h, slot) in group_digests.iter_mut().enumerate().skip(1) { - let group: Vec = (0..mats.num_matrices()) - .filter(|&m| mats.log_height(m) == h) - .collect(); - if group.is_empty() { - continue; - } - let mut hasher = math_cuda::mmcs::MmcsGroupHasher::new(&stream, h as u64) - .expect("group sponge allocation"); - for &m in &group { - let (data, _, width) = &mats.mats[m]; - let dev = stream.clone_htod(&raw(data)).expect("H2D"); - hasher - .absorb_row_major(&stream, &dev, *width as u64, 0, *width as u64) - .expect("absorb"); - // The point of the streaming build: this matrix is done with. - drop(dev); - } - *slot = Some(hasher.finalize(&stream).expect("finalize")); - } - - let nodes = math_cuda::mmcs::build_mmcs_tree_on_device(&stream, &group_digests) - .expect("device tree build"); - let root = math_cuda::mmcs::read_mmcs_root(&stream, &nodes).expect("root readback"); - let all = stream.clone_dtoh(&nodes).expect("node readback"); - (root, all) -} - -#[test] -fn single_matrix_mmcs_root_matches_the_per_table_tree() { - let mats = Matrices { - mats: vec![matrix(6, 5, 7)], - }; - let (device_root, _) = device_tree(&mats); - assert_eq!( - device_root, - Mmcs::commit(&mats).root(), - "a one-matrix MMCS must be the existing row-pair tree, byte for byte" - ); -} - -#[test] -fn mixed_height_root_matches_the_host() { - // Two matrices at the tallest height (so the base group batches), one - // injected mid-climb, one injected near the terminal. - let mats = Matrices { - mats: vec![ - matrix(7, 3, 11), - matrix(7, 6, 23), - matrix(5, 2, 41), - matrix(2, 4, 59), - ], - }; - let (device_root, _) = device_tree(&mats); - assert_eq!( - device_root, - Mmcs::commit(&mats).root(), - "the device climb with injection must reproduce the host tree" - ); -} - -#[test] -fn absorption_order_is_bound() { - let forward = Matrices { - mats: vec![matrix(6, 3, 11), matrix(6, 3, 23)], - }; - let reversed = Matrices { - mats: vec![matrix(6, 3, 23), matrix(6, 3, 11)], - }; - let (forward_root, _) = device_tree(&forward); - let (reversed_root, _) = device_tree(&reversed); - - assert_eq!(forward_root, Mmcs::commit(&forward).root()); - assert_eq!(reversed_root, Mmcs::commit(&reversed).root()); - assert_ne!( - forward_root, reversed_root, - "two same-shape matrices absorbed in the other order must commit a \ - different tree — input order is part of the commitment" - ); -} - -#[test] -fn paths_match_the_host_at_every_query() { - let mats = Matrices { - mats: vec![matrix(6, 3, 11), matrix(6, 2, 23), matrix(4, 5, 41)], - }; - let host = Mmcs::commit(&mats); - let (device_root, _) = device_tree(&mats); - assert_eq!(device_root, host.root()); - - let be = math_cuda::device::backend().expect("a GPU box"); - let stream = be.next_stream(); - let (_, nodes_host) = device_tree(&mats); - let nodes = stream.clone_htod(&nodes_host).expect("H2D nodes"); - - let leaves_len = 1usize << (host.h_max() - 1); - let positions: Vec = (0..leaves_len as u32).collect(); - let depth = host.h_max() - 1; - let paths = math_cuda::merkle::gather_merkle_paths_dev(&nodes, leaves_len, &positions, &stream) - .expect("path gather"); - - for iota in 0..leaves_len { - let expected = host.open_batch(iota, &mats).proof.merkle_path; - for (level, node) in expected.iter().enumerate() { - let start = (iota * depth + level) * 32; - assert_eq!( - &paths[start..start + 32], - &node[..], - "query {iota}, level {level}: the device path must be the host path — \ - `merkle_gather_paths` is reused precisely because the layouts agree" - ); - } - } -} From 7fadfbc071d4f7d87f3dc910b89e585349bb4126 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 7 Sep 2026 23:46:13 -0300 Subject: [PATCH 2/7] test(lfm): drop the batched-format tests, keep the global leg as its own module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched proof format is retired, so every test whose SUBJECT is that format goes. Deletions, by file: - `aggregator_tests.rs` (2,664 lines, 10 tests) — the module is typed end to end on `BatchedMultiProof`. Its GLOBAL leg is not: the cross-epoch global memory proof is per-table under either format, so `RealGlobal`, `real_global`, `GlobalTableArenas`, `global_verifier_program`, `global_arena_words` and `the_global_verifier_leg_runs_and_rejects_tampers` move verbatim into `per_table_aggregator_tests.rs`. One substitution: the fixture bundle now comes from `prove_continuation` instead of `prove_continuation_batched` — identical signature, and `real_global` already took a plain `ContinuationProof`. - `group_leaf_tests.rs` (295 lines, 3 tests) — the mixed-height group leaf. - `epoch_tests.rs` — the batched harness and its 7 tests, plus `the_real_block_base_epoch_ab`, whose subject was per-table VS batched and which cannot be a one-armed A/B. That test had absorbed `real_epoch_with`'s doc block (no blank line between them); deleting it returns the doc to `real_epoch_with`. - `wrap_tests.rs` — the batched wrap section, its 3 tests and the P2 batched block driver. That driver's doc had likewise absorbed `the_real_blocks_page_census`'s; the census doc is reattached. - `epoch_verify_tests.rs` — the batched leg-input builders. - `machine_tests.rs` — the three batched LFM epoch/wire tests. - `algebraic_commit.rs` — `the_mixed_group_leaf_equals_the_hosts_group_hash`. A group leaf over several matrices is the mixed-height construction; the one-matrix case it degenerates to is `the_emitted_leaf_and_parent_equal_the_hosts_backend`'s subject and stays gated. `StarkHash::Batched` is the batch-commitment leaf backend, not the format, and is untouched. Two tests are EDITED rather than deleted, because their per-table half is the claim and the batched half was an arm of it: - `blake3_chip_tests::chunked_blake3_proves_and_verifies` loses its batched arm; the per-table prove/verify and the host-chain digest equality stay. - `algebraic_transcript::phase_a_absorbs_derive_the_hosts_shared_pair` loses the shape histogram, both sides of it. What the gate is actually for survives intact: that a preprocessed root reaching the transcript as program TEXT (`append_const_bytes`) and as proof-carried cells (`RootCells::constant` + `absorb_misaligned`) derive the same challenge, i.e. that `bytes_to_cell` and `commitment_to_digest` agree. That property is Phase A's on either format and is invisible under a byte hash. --- prover/src/lfm/aggregator_tests.rs | 2667 ------------------ prover/src/lfm/algebraic_commit.rs | 182 +- prover/src/lfm/algebraic_transcript.rs | 45 +- prover/src/lfm/blake3_chip_tests.rs | 27 +- prover/src/lfm/epoch_tests.rs | 2139 +------------- prover/src/lfm/epoch_verify_tests.rs | 152 - prover/src/lfm/group_leaf_tests.rs | 295 -- prover/src/lfm/machine_tests.rs | 118 - prover/src/lfm/mod.rs | 6 +- prover/src/lfm/per_table_aggregator_tests.rs | 464 +++ prover/src/lfm/wrap_tests.rs | 496 +--- 11 files changed, 511 insertions(+), 6080 deletions(-) delete mode 100644 prover/src/lfm/aggregator_tests.rs delete mode 100644 prover/src/lfm/group_leaf_tests.rs create mode 100644 prover/src/lfm/per_table_aggregator_tests.rs diff --git a/prover/src/lfm/aggregator_tests.rs b/prover/src/lfm/aggregator_tests.rs deleted file mode 100644 index 6183a5f3c..000000000 --- a/prover/src/lfm/aggregator_tests.rs +++ /dev/null @@ -1,2667 +0,0 @@ -//! The aggregation layer's building block: a batched-LFM VERIFY LEG — the -//! emitted verifier of one batched-format wrap proof. -//! -//! The aggregation program is N of these legs (one per wrap) plus the binding -//! legs and the final attestation. A leg is the first emitted verifier whose -//! TARGET is an LFM-machine proof rather than a VM epoch: the spine replays -//! [`super::statement::absorb_lfm_statement`] byte for byte (the wrap's -//! program id is an EMIT-TIME CONSTANT — the aggregator is compiled for five -//! named wrap identities, which fold into its own program identity), the -//! preprocessed roots absorb from the AIR set as constants, and the LogUp -//! closure's target is the LFM_PUBLIC balance recomputed from the wrap's -//! claimed public words — the machine twin of -//! `verify_against_batched`'s `expected_public_balance`. -//! -//! Everything soundness-critical is the SAME emission machinery the wrap -//! program already gates: `emit_batched_epoch_challenges` for the spine, -//! `emit_mixed_verify_batch` / `emit_group_authentication` for the walks, -//! `emit_analyzed` / `emit_quotient` / `emit_deep_*` for the legs, -//! `emit_query_mix` / `emit_batched_query_fri` / the standalone terminal -//! checks for FRI. This module contributes no new cryptographic arithmetic — -//! only the LFM-shaped statement, the public-word hinting (canonicity-guarded -//! halves), and the balance target. - -use stark::batched::proof::BatchedMultiProof; -use stark::batched::shape::{EpochFriParams, EpochShape}; -use stark::batched::verifier::{EpochChallenges, replay_epoch_transcript}; -use stark::config::Commitment; - -use crate::tables::types::{FE, FEE, GoldilocksExtension, GoldilocksField}; - -use super::airs::LfmAirs; -use super::builder::{Cell, Ext, Felt, LfmBuilder}; -use super::compiler::{LfmProgram, compile}; -use super::edsl; -use super::epoch::RootCells; -use super::executor::execute; -use super::instr::ArenaId; -use super::proof::{BatchedLfmProof, aggregation_wrap_options, verify_against_batched}; -use super::registry::{LfmArtifacts, build_artifacts_with_hasher}; -use super::statement::{LFM_MACHINE_VERSION, LFM_STATEMENT_TAG, absorb_lfm_statement}; -use super::transcript_replay::{Candidate, TranscriptReplay, assert_canonical, candidate_to_felt}; -use super::word::{LfmWord, base_word, ext_word}; - -type Gl = GoldilocksField; -type Ext3 = GoldilocksExtension; - -/// One wrap proof, production-accepted, with everything its emitted verify -/// leg needs — the LFM sibling of `RealBatchedEpoch`, minus the VM statement -/// machinery it has no use for. -pub(super) struct RealBatchedLfm { - pub(super) opts: crate::ProofOptions, - pub(super) artifacts: LfmArtifacts, - pub(super) proof: BatchedMultiProof, - pub(super) public_words: Vec<(u32, LfmWord)>, - pub(super) shape: EpochShape, - pub(super) fri_params: EpochFriParams, - /// Production's own challenge replay — the differential oracle. - pub(super) challenges: EpochChallenges, -} - -impl RealBatchedLfm { - /// The chip AIR set in slot order — rebuilt on demand exactly as - /// `verify_against_batched` rebuilds it (the AIRs borrow the airs value, - /// so the set is materialized per use rather than stored). - pub(super) fn airs(&self) -> LfmAirs { - LfmAirs::new_with_hasher( - &self.artifacts.roots, - &self.opts, - self.artifacts.keccak_rnd_chunks, - self.artifacts.hasher, - self.artifacts.chip_set, - ) - } -} - -/// Build the harness from a production-accepted wrap. Panics loudly on a wrap -/// production would reject — nothing downstream may read one. -pub(super) fn real_batched_lfm( - artifacts: LfmArtifacts, - opts: crate::ProofOptions, - wrap: &BatchedLfmProof, -) -> RealBatchedLfm { - assert!( - verify_against_batched(&artifacts, &wrap.proof, &wrap.public_words, &opts), - "the harness only reads wraps production accepts" - ); - let airs = LfmAirs::new_with_hasher( - &artifacts.roots, - &opts, - artifacts.keccak_rnd_chunks, - artifacts.hasher, - artifacts.chip_set, - ); - let refs = airs.air_refs(); - let mut t = crate::hash_pin::block_transcript(&[]); - absorb_lfm_statement( - &mut t, - &artifacts.program_id, - &wrap.public_words, - opts.fri_final_poly_log_degree, - ); - let (shape, fri_params, challenges) = - replay_epoch_transcript(&refs, &wrap.proof, &mut t).expect("an accepted wrap replays"); - RealBatchedLfm { - opts, - artifacts, - proof: wrap.proof.clone(), - public_words: wrap.public_words.clone(), - shape, - fri_params, - challenges, - } -} - -// ======================= arena serializers (T1) ========================== - -/// The wrap-leg's opening arena — `batched_opening_arena`'s body over an LFM -/// proof (no carve; the closed-form word count is the shared -/// `batched_opening_words_per_query`). -pub(super) fn lfm_opening_arena(e: &RealBatchedLfm) -> Vec { - use stark::fri::mmcs::MixedOpening; - fn push_mixed_base(out: &mut Vec, o: &MixedOpening) { - for m in &o.per_matrix { - out.extend(m.evaluations.iter().map(|v| base_word(*v))); - out.extend(m.evaluations_sym.iter().map(|v| base_word(*v))); - } - out.extend(super::proof_arena::commitments_to_arena( - &o.proof.merkle_path, - )); - } - fn push_mixed_ext(out: &mut Vec, o: &MixedOpening) { - for m in &o.per_matrix { - out.extend(m.evaluations.iter().map(ext_word)); - out.extend(m.evaluations_sym.iter().map(ext_word)); - } - out.extend(super::proof_arena::commitments_to_arena( - &o.proof.merkle_path, - )); - } - - let mut out = Vec::new(); - for q in &e.proof.queries { - for p in &q.prep { - out.extend(p.evaluations.iter().map(|v| base_word(*v))); - out.extend(p.evaluations_sym.iter().map(|v| base_word(*v))); - out.extend(super::proof_arena::commitments_to_arena( - &p.proof.merkle_path, - )); - } - assert!(q.carved_main.is_none(), "an LFM wrap has no carved table"); - push_mixed_base(&mut out, &q.main); - if let Some(aux) = &q.aux { - push_mixed_ext(&mut out, aux); - } - push_mixed_ext(&mut out, &q.parts); - } - assert_eq!( - out.len(), - e.proof.queries.len() - * super::epoch_verify_tests::batched_opening_words_per_query(&e.shape), - "the leg's opening arena must fill exactly what the shape declares" - ); - out -} - -/// The wrap-leg's FRI arena — `batched_fri_arena`'s body over an LFM proof. -pub(super) fn lfm_fri_arena(e: &RealBatchedLfm) -> Vec { - let mut out = Vec::new(); - for q in &e.proof.queries { - assert_eq!( - q.fri.layers_evaluations_sym.len(), - q.fri.layers_auth_paths.len(), - "every committed layer opens a symmetric evaluation AND a path" - ); - for (sym, path) in q - .fri - .layers_evaluations_sym - .iter() - .zip(&q.fri.layers_auth_paths) - { - out.push(ext_word(sym)); - out.extend(super::proof_arena::commitments_to_arena(&path.merkle_path)); - } - } - assert_eq!( - out.len(), - e.proof.queries.len() - * super::epoch_verify_tests::batched_fri_words_per_query(&e.shape, &e.fri_params), - "the leg's FRI arena must fill exactly what the shape declares" - ); - out -} - -/// The wrap's public words as the leg's arena expects them: per word, the -/// four lanes each as `[low32, high32]` halves — eight halves per word, in -/// the wrap's own publish order. -pub(super) fn lfm_publics_arena(words: &[(u32, LfmWord)]) -> Vec { - let mut out = Vec::new(); - for (_, word) in words { - for lane in word { - let v: u64 = lane.canonical(); - out.push(base_word(FE::from(v & 0xFFFF_FFFF))); - out.push(base_word(FE::from(v >> 32))); - } - } - out -} - -// ==================== the emitted statement + publics ==================== - -/// One hinted public word: the emit-time-constant index, the eight hinted -/// halves (absorbed by the statement), and the four lanes reassembled as -/// CANONICITY-GUARDED felts (consumed by the balance and the binding legs). -/// `u32` halves one lane's canonical `u64` occupies — the unit -/// `absorb_lfm_statement` appends a lane in. -const HALVES_PER_LANE: usize = 2; - -pub(super) struct HintedPublicWord { - pub(super) index: u32, - pub(super) halves: Vec, - pub(super) lanes: Vec, -} - -/// Hint the wrap's public words from `arena` (eight halves per word, the -/// serializer's layout) and reassemble each lane under the canonicity guard — -/// the same `(lo, hi)` predicate the transcript replay's own digest-to-felt -/// path enforces, so a hinted half pair CANNOT alias a second representation -/// of the same felt while absorbing different bytes. -pub(super) fn hint_public_words( - b: &mut LfmBuilder, - arena: ArenaId, - words: &[(u32, LfmWord)], -) -> Vec { - let mut cursor = 0u32; - words - .iter() - .map(|(index, _)| { - let mut halves = Vec::with_capacity(8); - let mut lanes = Vec::with_capacity(4); - for _ in 0..4 { - let lo = b.hint_felt(arena, cursor); - let hi = b.hint_felt(arena, cursor + 1); - cursor += 2; - let c = Candidate { lo, hi }; - assert_canonical(b, c); - lanes.push(candidate_to_felt(b, c)); - halves.push(lo); - halves.push(hi); - } - HintedPublicWord { - index: *index, - halves, - lanes, - } - }) - .collect() -} - -/// Emits [`absorb_lfm_statement`] byte for byte: the tag, the wrap's program -/// id (a PROGRAM CONSTANT — verdict condition 3's pinning), the machine -/// version, the word count, each word's emit-time-constant index and hinted -/// lane halves, and the FRI terminal byte. -pub(super) fn emit_lfm_statement( - t: &mut TranscriptReplay, - program_id: &Commitment, - words: &[HintedPublicWord], - fri_final_poly_log_degree: u8, -) { - t.append_const_bytes(LFM_STATEMENT_TAG); - t.append_const_bytes(program_id); - t.append_const_bytes(&LFM_MACHINE_VERSION.to_le_bytes()); - t.append_const_bytes(&(words.len() as u64).to_le_bytes()); - for word in words { - t.append_const_bytes(&word.index.to_le_bytes()); - // ⚠ ONE CALL PER LANE, not one for the word. `absorb_lfm_statement` - // appends each lane's canonical `u64` separately, so a word is FIVE - // host calls — the index and four lanes — not two. A byte transcript - // concatenates and cannot tell the difference, which is why this stood; - // an algebraic one length-prefixes every call, so absorbing the eight - // halves in one go is a DIFFERENT transcript, and since the statement - // is absorbed first that means every challenge downstream. - // See `transcript_replay::Append`. - for lane in word.halves.chunks(HALVES_PER_LANE) { - t.append_halves_misaligned(lane); - } - } - t.append_const_bytes(&[fri_final_poly_log_degree]); -} - -/// The LFM_PUBLIC balance the leg's LogUp closure must reach — -/// `expected_public_balance`'s machine twin: -/// `Σ_i 1/(z − (LfmPublic + index_i·α + Σ_l lane_l·α^{2+l}))`, with each -/// division by an interned one so a fingerprint collision with `z` is -/// unprovable rather than `0/0 = 1`. -pub(super) fn emit_public_balance( - b: &mut LfmBuilder, - words: &[HintedPublicWord], - z: Ext, - alpha: Ext, -) -> Ext { - let bus = b.ext_const(&FEE::from(crate::tables::types::BusId::LfmPublic as u64)); - let one = b.ext_const(&FEE::one()); - // α¹..α⁵ — index takes α, lane l takes α^{2+l}. - let mut powers = Vec::with_capacity(5); - powers.push(alpha); - for i in 1..5 { - let next = b.emul(powers[i - 1], alpha); - powers.push(next); - } - let mut acc: Option = None; - for word in words { - let idx = b.felt_const(FE::from(word.index as u64)); - let idx_term = b.emul_base(powers[0], idx); - let mut linear = b.eadd(bus, idx_term); - for (l, lane) in word.lanes.iter().enumerate() { - let term = b.emul_base(powers[1 + l], *lane); - linear = b.eadd(linear, term); - } - let fingerprint = b.esub(z, linear); - let term = b.ediv(one, fingerprint); - acc = Some(match acc { - None => term, - Some(a) => b.eadd(a, term), - }); - } - acc.unwrap_or_else(|| b.ext_const(&FEE::zero())) -} - -// =========================== the verify leg ============================== - -/// The leg's arenas, declared in ABSORB ORDER — the caller declares one set -/// per wrap, in wrap order, before emitting any leg, so the aggregation -/// program's declaration order is its absorb order end to end. -pub(super) struct LfmLegArenas { - publics: ArenaId, - main_root: ArenaId, - aux_root: Option, - contrib: Vec>, - ood: Vec<(ArenaId, ArenaId, ArenaId)>, - parts_root: ArenaId, - standalone: Vec>, - fri_roots: ArenaId, - fri_coeffs: ArenaId, - nonce: Option, - openings: Option, - fri_legs: Option, -} - -pub(super) fn declare_lfm_leg_arenas( - b: &mut LfmBuilder, - e: &RealBatchedLfm, - with_openings: bool, -) -> LfmLegArenas { - let has_aux = !e.shape.aux.dims.is_empty(); - LfmLegArenas { - publics: b.declare_arena(8 * e.public_words.len() as u32), - main_root: b.declare_arena(super::edsl::digest_words(b)), - aux_root: has_aux.then(|| b.declare_arena(super::edsl::digest_words(b))), - contrib: e - .airs() - .air_refs() - .iter() - .map(|air| air.has_aux_trace().then(|| b.declare_arena(1))) - .collect(), - ood: e - .proof - .tables - .iter() - .map(|t| { - ( - b.declare_arena( - (t.trace_ood_evaluations.width * t.trace_ood_evaluations.height) as u32, - ), - b.declare_arena( - (t.trace_ood_next_evaluations.width * t.trace_ood_next_evaluations.height) - as u32, - ), - b.declare_arena(t.composition_poly_parts_ood_evaluation.len() as u32), - ) - }) - .collect(), - parts_root: b.declare_arena(super::edsl::digest_words(b)), - standalone: { - let fri = super::batched_epoch::BatchedFriShape::new( - &e.shape.heights, - e.fri_params.blowup_log, - e.fri_params.final_poly_log_degree, - ); - (0..e.proof.tables.len()) - .map(|t| { - fri.plan.standalone.contains(&t).then(|| { - b.declare_arena( - 1u32 << (e.shape.heights[t] as u32 - e.fri_params.blowup_log), - ) - }) - }) - .collect() - }, - fri_roots: b - .declare_arena(super::edsl::digest_words(b) * e.proof.fri_layer_roots.len() as u32), - fri_coeffs: b.declare_arena(e.proof.fri_final_poly_coeffs.len() as u32), - nonce: (e.fri_params.grinding_factor > 0).then(|| b.declare_arena(1)), - openings: with_openings.then(|| { - b.declare_arena( - (e.proof.queries.len() - * super::epoch_verify_tests::batched_opening_words_per_query(&e.shape)) - as u32, - ) - }), - fri_legs: with_openings.then(|| { - b.declare_arena( - (e.proof.queries.len() - * super::epoch_verify_tests::batched_fri_words_per_query( - &e.shape, - &e.fri_params, - )) as u32, - ) - }), - } -} - -/// What a leg hands the aggregator's binding layer: the wrap's hinted public -/// words (index + canonicity-guarded lanes — byte-compare material) and the -/// challenge cells (diagnostic publishes for the gates). -pub(super) struct LfmLegCells { - pub(super) publics: Vec, - pub(super) lookup: (Ext, Ext), - pub(super) betas: Vec, - pub(super) zs: Vec, - pub(super) gammas: Vec, - pub(super) alpha: Ext, - pub(super) zetas: Vec, - pub(super) iota_bits: Vec>, -} - -/// Emit ONE wrap's complete verification: statement, spine, LogUp closure -/// against the public balance, and every opening walk — the batched wrap -/// program's own structure with the LFM statement and prep-as-constants in -/// place of the VM epoch's statement and provenance machinery. -pub(super) fn emit_lfm_leg( - b: &mut LfmBuilder, - e: &RealBatchedLfm, - a: &LfmLegArenas, -) -> LfmLegCells { - use super::batched_epoch::{ - BatchedEpochAbsorbs, BatchedEpochShape, BatchedFriShape, BatchedPrepRoot, BatchedTableOod, - BatchedTableShape, emit_batched_epoch_challenges, - }; - use super::batched_epoch_verify::{ - MixedMatrixOpening, emit_mixed_verify_batch, reduce_iota_bits, - }; - use super::deep::DeepOpening; - use super::sub_proof::{GroupCommitment, GroupOpening, GroupShape}; - - let airs = e.airs(); - let refs = airs.air_refs(); - let n = e.proof.tables.len(); - - // ---- the emitted shape (the leg's compile-time truth) ---- - let tables: Vec = e - .proof - .tables - .iter() - .zip(&refs) - .map(|(t, air)| BatchedTableShape { - log2_trace_length: t.trace_length.trailing_zeros(), - has_contribution: air.has_aux_trace(), - ood_current_dims: ( - t.trace_ood_evaluations.width, - t.trace_ood_evaluations.height, - ), - ood_next_dims: ( - t.trace_ood_next_evaluations.width, - t.trace_ood_next_evaluations.height, - ), - num_parts: t.composition_poly_parts_ood_evaluation.len(), - }) - .collect(); - let shape = BatchedEpochShape { - tables, - heights: e.shape.heights.clone(), - total_widths: e.shape.total_widths(), - log2_blowup: e.fri_params.blowup_log, - coset_offset: FE::from(e.fri_params.coset_offset), - has_aux: !e.shape.aux.dims.is_empty(), - carved_main: None, - fri: BatchedFriShape::new( - &e.shape.heights, - e.fri_params.blowup_log, - e.fri_params.final_poly_log_degree, - ), - grinding_factor: e.fri_params.grinding_factor, - num_queries: e.fri_params.num_queries, - }; - - // ---- the statement ---- - let publics = hint_public_words(b, a.publics, &e.public_words); - let mut t = TranscriptReplay::new(&[]); - emit_lfm_statement( - &mut t, - &e.artifacts.program_id, - &publics, - e.opts.fri_final_poly_log_degree, - ); - - // ---- preprocessed roots: EMIT-TIME CONSTANTS from the AIR set ---- - let prep_consts: Vec> = refs - .iter() - .map(|air| air.is_preprocessed().then(|| air.precomputed_commitment())) - .collect(); - let prep_cells: Vec> = prep_consts - .iter() - .map(|c| c.as_ref().map(|c| RootCells::constant(b, c))) - .collect(); - let prep_slots: Vec>> = prep_consts - .iter() - .map(|c| c.as_ref().map(BatchedPrepRoot::Constant)) - .collect(); - - // ---- the proof-carried cells ---- - let main_cells = RootCells::hint(b, a.main_root, 0); - let aux_cells = a.aux_root.map(|id| RootCells::hint(b, id, 0)); - let contribs: Vec> = a - .contrib - .iter() - .map(|id| id.map(|id| b.hint_word(id, 0).as_ext())) - .collect(); - let ood_cells: Vec<(Vec, Vec, Vec)> = shape - .tables - .iter() - .zip(&a.ood) - .map(|(t, (ac, an, ap))| { - ( - (0..(t.ood_current_dims.0 * t.ood_current_dims.1) as u32) - .map(|k| b.hint_word(*ac, k).as_ext()) - .collect(), - (0..(t.ood_next_dims.0 * t.ood_next_dims.1) as u32) - .map(|k| b.hint_word(*an, k).as_ext()) - .collect(), - (0..t.num_parts as u32) - .map(|k| b.hint_word(*ap, k).as_ext()) - .collect(), - ) - }) - .collect(); - let parts_cells = RootCells::hint(b, a.parts_root, 0); - let standalone_cells: Vec>> = a - .standalone - .iter() - .enumerate() - .map(|(t, id)| { - id.map(|id| { - (0..1u32 << (e.shape.heights[t] as u32 - e.fri_params.blowup_log)) - .map(|k| b.hint_word(id, k).as_ext()) - .collect() - }) - }) - .collect(); - let fri_root_cells: Vec = (0..e.proof.fri_layer_roots.len()) - .map(|k| { - RootCells::hint( - b, - a.fri_roots, - super::proof_arena::words_per_root() as u32 * k as u32, - ) - }) - .collect(); - let coeff_cells: Vec = (0..e.proof.fri_final_poly_coeffs.len() as u32) - .map(|k| b.hint_word(a.fri_coeffs, k).as_ext()) - .collect(); - let nonce = a.nonce.map(|id| b.hint_felt(id, 0)); - - // ---- the ONE-transcript spine ---- - let oods: Vec> = ood_cells - .iter() - .map(|(c, x, p)| BatchedTableOod { - current: c, - next: x, - parts: p, - }) - .collect(); - let ch = emit_batched_epoch_challenges( - b, - &mut t, - &shape, - &BatchedEpochAbsorbs { - prep_roots: &prep_slots, - carved_root: None, - main_root: &main_cells, - aux_root: aux_cells.as_ref(), - contributions: &contribs, - parts_root: &parts_cells, - ood: &oods, - standalone_coeffs: &standalone_cells, - fri_roots: &fri_root_cells, - fri_coeffs: &coeff_cells, - nonce, - }, - ); - - // ---- the LogUp closure against the PUBLIC balance ---- - let contributions: Vec = contribs.iter().copied().flatten().collect(); - let target = emit_public_balance(b, &publics, ch.lookup.0, ch.lookup.1); - let lshape = super::logup::LogUpShape { - num_contributing_tables: contributions.len(), - num_output_bytes: 0, - }; - super::logup::emit_bus_closure(b, &lshape, &contributions, target); - - // ---- the opening walks (the wrap program's own skeleton, no carve) ---- - let h_max_fri = e.shape.heights.iter().copied().max().expect("chips"); - let prep_pos: Vec> = (0..n) - .map(|t| e.shape.prep.tables.iter().position(|&x| x == t)) - .collect(); - let main_pos: Vec> = (0..n) - .map(|t| e.shape.main.tables.iter().position(|&x| x == t)) - .collect(); - let aux_pos: Vec> = (0..n) - .map(|t| e.shape.aux.tables.iter().position(|&x| x == t)) - .collect(); - let parts_pos: Vec = (0..n) - .map(|t| { - e.shape - .parts - .tables - .iter() - .position(|&x| x == t) - .expect("every chip has a parts matrix") - }) - .collect(); - - struct Leg { - deep: super::deep::DeepShape, - analysis: super::constraints::Analysis, - quotient: super::constraints::QuotientShape, - main_width: usize, - num_alpha_powers: usize, - } - let legs: Vec = refs - .iter() - .zip(&e.proof.tables) - .map(|(air, data)| { - use stark::verifier::{IsStarkVerifier, Verifier}; - let layout = Verifier::::ood_layout(*air); - let artifact = stark::constraint_ir::ConstraintArtifact::capture(*air); - let (main_width, aux_width) = air.trace_layout(); - let num_total_cols = main_width + aux_width; - let has_aux = air.has_aux_trace(); - Leg { - deep: super::deep::DeepShape { - 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(), - num_composition_parts: data.composition_poly_parts_ood_evaluation.len(), - log2_trace_length: data.trace_length.trailing_zeros(), - }, - analysis: super::constraints::analyze(&artifact), - quotient: super::constraints::QuotientShape { - log2_trace_length: data.trace_length.trailing_zeros(), - num_composition_parts: data.composition_poly_parts_ood_evaluation.len(), - boundary: super::epoch_verify::boundary_terms(has_aux, num_total_cols), - }, - main_width, - num_alpha_powers: if has_aux { - artifact.shape.max_bus_elements as usize - } else { - 0 - }, - } - }) - .collect(); - - let dinvs: Vec = (0..n) - .map(|t_i| { - let leg = &legs[t_i]; - let grid = super::epoch::emit_reconstruct_ood( - b, - &leg.deep, - &ood_cells[t_i].0, - &ood_cells[t_i].1, - ); - let alpha_powers = if leg.num_alpha_powers > 0 { - super::constraints::emit_alpha_powers(b, ch.lookup.1, leg.num_alpha_powers) - } else { - Vec::new() - }; - let table_offset = match contribs[t_i] { - Some(l) => { - super::constraints::emit_table_offset(b, l, leg.quotient.log2_trace_length) - } - None => b.felt_const(FE::zero()).as_ext(), - }; - let steps = super::epoch_verify::frame_step_view(&grid, leg.deep.step_size); - let ood_ops = super::constraints::OodOperands { - steps, - main_width: leg.main_width, - rap_challenges: vec![ch.lookup.0, ch.lookup.1], - alpha_powers, - table_offset, - }; - let evals = super::constraints::emit_analyzed(b, &leg.analysis, &ood_ops); - let q = super::constraints::emit_quotient( - b, - &leg.quotient, - &ood_ops, - ch.zs[t_i], - ch.betas[t_i], - &evals, - &ood_cells[t_i].2, - ); - b.assert_eq_ext(q.claimed, q.composition); - super::deep::emit_deep_invariants( - b, - &leg.deep, - ch.gammas[t_i], - ch.zs[t_i], - &grid, - &ood_cells[t_i].2, - ) - }) - .collect(); - let fri_layer_commitments: Vec = fri_root_cells - .iter() - .map(|c| super::fri::LayerCommitment { - root_lanes: c.lanes.clone(), - }) - .collect(); - - let mut cursor: u32 = 0; - let mut fri_cursor: u32 = 0; - let (a_open, a_fri) = match (a.openings, a.fri_legs) { - (Some(o), Some(f)) => (o, f), - _ => { - return LfmLegCells { - publics, - lookup: ch.lookup, - betas: ch.betas, - zs: ch.zs, - gammas: ch.gammas, - alpha: ch.alpha, - zetas: ch.zetas, - iota_bits: ch.iota_bits, - }; - } - }; - for bits in &ch.iota_bits { - // ---- preprocessed walks (roots are program constants) ---- - let mut prep_values: Vec> = Vec::new(); - for (slot, &(h, w)) in e.shape.prep.tables.iter().zip(e.shape.prep.dims.iter()) { - let cells = prep_cells[*slot] - .as_ref() - .expect("a preprocessed chip has root cells"); - let values = hint_run(b, a_open, &mut cursor, 2 * w); - let siblings = hint_digests(b, a_open, &mut cursor, h - 1); - let tbits = reduce_iota_bits(bits, h_max_fri, h); - super::sub_proof::emit_group_authentication( - b, - &GroupCommitment::from_lanes( - cells.lanes.clone(), - GroupShape { - num_columns: w, - is_ext: false, - }, - ), - &GroupOpening { - values: values.clone(), - siblings, - }, - tbits, - ); - prep_values.push(values); - } - - // ---- the three mixed rounds ---- - let mut round_values: Vec>> = Vec::new(); - let mut rounds: Vec<(&stark::batched::shape::RoundShape, &RootCells, bool)> = - vec![(&e.shape.main, &main_cells, false)]; - if let Some(aux) = aux_cells.as_ref() { - rounds.push((&e.shape.aux, aux, true)); - } - rounds.push((&e.shape.parts, &parts_cells, true)); - for (round, root, is_ext) in rounds { - let h_round = round.h_max().expect("a committed round is non-empty"); - let per_values: Vec> = round - .dims - .iter() - .map(|&(_, w)| hint_run(b, a_open, &mut cursor, 2 * w)) - .collect(); - let siblings = hint_digests(b, a_open, &mut cursor, h_round - 1); - let matrices: Vec> = round - .dims - .iter() - .zip(&per_values) - .map(|(&(h, w), values)| MixedMatrixOpening { - shape: GroupShape { - num_columns: w, - is_ext, - }, - log_height: h, - values, - }) - .collect(); - let rbits = reduce_iota_bits(bits, h_max_fri, h_round); - emit_mixed_verify_batch(b, root, &matrices, &siblings, rbits); - round_values.push(per_values); - } - let main_values = &round_values[0]; - let aux_values = aux_cells.as_ref().map(|_| &round_values[1]); - let parts_values = round_values.last().expect("the parts round"); - - // ---- the crossing ---- - let mut points: Vec<(Felt, Felt)> = Vec::with_capacity(n); - let mut deep_pairs: Vec<(Ext, Ext)> = Vec::with_capacity(n); - for t_i in 0..n { - let h_t = e.shape.heights[t_i]; - let rbits = reduce_iota_bits(bits, h_max_fri, h_t); - let (point, point_sym) = - super::sub_proof::emit_points_from_bits(b, h_t as u32, shape.coset_offset, rbits); - - let mut trace = Vec::with_capacity(legs[t_i].deep.num_total_cols); - let mut trace_sym = Vec::with_capacity(legs[t_i].deep.num_total_cols); - if let Some(m) = prep_pos[t_i] { - let w = e.shape.prep.dims[m].1; - let vals = &prep_values[m]; - trace.extend((0..w).map(|c| vals[c].as_ext())); - trace_sym.extend((0..w).map(|c| vals[w + c].as_ext())); - } - let m = main_pos[t_i].expect("every LFM chip has a main matrix"); - { - let w = e.shape.main.dims[m].1; - let vals = &main_values[m]; - trace.extend((0..w).map(|c| vals[c].as_ext())); - trace_sym.extend((0..w).map(|c| vals[w + c].as_ext())); - } - if let Some(m) = aux_pos[t_i] { - let w = e.shape.aux.dims[m].1; - let vals = &aux_values.expect("an aux position implies an aux round")[m]; - trace.extend((0..w).map(|c| vals[c].as_ext())); - trace_sym.extend((0..w).map(|c| vals[w + c].as_ext())); - } - assert_eq!( - trace.len(), - legs[t_i].deep.num_total_cols, - "the crossing must cover exactly the DEEP column set" - ); - let m = parts_pos[t_i]; - let w = e.shape.parts.dims[m].1; - assert_eq!( - w, legs[t_i].deep.num_composition_parts, - "the parts matrix is one column per composition part" - ); - let vals = &parts_values[m]; - let parts: Vec = (0..w).map(|c| vals[c].as_ext()).collect(); - let parts_sym: Vec = (0..w).map(|c| vals[w + c].as_ext()).collect(); - - let regular = DeepOpening { - point, - trace, - parts, - }; - let symmetric = DeepOpening { - point: point_sym, - trace: trace_sym, - parts: parts_sym, - }; - deep_pairs.push(( - super::deep::emit_deep_point( - b, - &legs[t_i].deep, - ch.gammas[t_i], - &dinvs[t_i], - ®ular, - ), - super::deep::emit_deep_point( - b, - &legs[t_i].deep, - ch.gammas[t_i], - &dinvs[t_i], - &symmetric, - ), - )); - points.push((point, point_sym)); - } - - // ---- the mix, the batched instance, the standalone class ---- - let (p0, p0_sym, buckets) = super::batched_epoch_verify::emit_query_mix( - b, - &shape.fri.plan.batched, - &e.shape.heights, - h_max_fri, - ch.alpha, - &deep_pairs, - bits, - ); - let tallest = e - .shape - .heights - .iter() - .position(|&h| h == h_max_fri) - .expect("a tallest chip exists"); - let fri_openings_q: Vec = (0..shape.fri.num_committed()) - .map(|i| { - let sym = { - let c = b.hint_word(a_fri, fri_cursor); - fri_cursor += 1; - c.as_ext() - }; - let siblings = hint_digests(b, a_fri, &mut fri_cursor, h_max_fri - i - 2); - super::fri::LayerOpening { sym, siblings } - }) - .collect(); - super::batched_epoch_verify::emit_batched_query_fri( - b, - &shape.fri.layout, - h_max_fri, - &fri_layer_commitments, - &ch.zetas, - &coeff_cells, - bits, - points[tallest].0, - points[tallest].1, - p0, - p0_sym, - &buckets, - &fri_openings_q, - ); - for &t_i in &shape.fri.plan.standalone { - let coeffs = standalone_cells[t_i] - .as_ref() - .expect("a standalone chip has terminal cells"); - super::batched_epoch_verify::emit_standalone_terminal_check( - b, - coeffs, - points[t_i].0, - points[t_i].1, - deep_pairs[t_i].0, - deep_pairs[t_i].1, - ); - } - } - - LfmLegCells { - publics, - lookup: ch.lookup, - betas: ch.betas, - zs: ch.zs, - gammas: ch.gammas, - alpha: ch.alpha, - zetas: ch.zetas, - iota_bits: ch.iota_bits, - } -} - -fn hint_run(b: &mut LfmBuilder, arena: ArenaId, cursor: &mut u32, count: usize) -> Vec { - (0..count) - .map(|_| { - let c = b.hint_word(arena, *cursor); - *cursor += 1; - c - }) - .collect() -} - -fn hint_digests( - b: &mut LfmBuilder, - arena: ArenaId, - cursor: &mut u32, - count: usize, -) -> Vec { - (0..count) - .map(|_| { - // The stride is the DIGEST's width, not a literal two. - let d = super::edsl::hint_digest(b, arena, *cursor); - *cursor += super::edsl::digest_words(b); - d - }) - .collect() -} - -// ========================= the aggregation program ======================= - -/// Where each schema field sits in a carved wrap's published words — pure -/// arithmetic over the wrap's shape, every term an emit-time constant. The -/// publish order is the wrap program's own: the LogUp pair, the attestation -/// id, β/z/γ per table, the DEEP α, the fold ζs, the ι felts, the bus total, -/// then the carved schema — register init and fini vectors, the epoch label -/// halves, the output bytes, the carved L2G root halves. -pub(super) struct WrapPublicLayout { - pub(super) n_tables: usize, - pub(super) n_zetas: usize, - pub(super) n_iotas: usize, - pub(super) num_reg: usize, - pub(super) out_bytes: usize, - /// Words the carved L2G root occupies at the END of the schema — four per - /// digest cell, so EIGHT on a byte hash and FOUR on an algebraic one. - /// - /// ⚠ The one count here that is a function of the HASH rather than of the - /// inner epoch, which is why it is a field: `of_inner` reads it from - /// `proof_arena::lanes_per_root` and the assert prints it, so a - /// configuration whose emitter and reader disagree names the number instead - /// of leaving a bare arithmetic mismatch. - /// - /// ⛔ NOT the attestation id's width. That is `emit_program_id`'s output, - /// which is PINNED KECCAK on every arm (`programs::emit_program_id` names - /// `keccak256` deliberately, because the id identifies a program to - /// consumers rather than being part of the commitment layer), so it is two - /// published words under every configuration and stays a literal. - pub(super) l2g_lanes: usize, -} - -impl WrapPublicLayout { - /// The layout comes from the INNER epoch the wrap program verifies — the - /// published words are the wrap PROGRAM's outputs, so every count here is - /// the inner epoch's (its table count, its committed FRI layers, its - /// query count), never the wrap proof's own. The caller builds it where - /// the wrap program was emitted; `assert_covers` then pins it against - /// the wrap's actual published length, so a level confusion is a loud - /// failure at assembly time rather than a silent mis-binding. - pub(super) fn of_inner(e: &super::epoch_tests::RealBatchedEpoch) -> Self { - Self { - n_tables: e.proof.tables.len(), - n_zetas: e.challenges.fri.betas.len(), - n_iotas: e.fri_params.num_queries, - num_reg: crate::tables::register::NUM_REGISTER_ADDRESSES, - out_bytes: e.statement.public_output_len, - l2g_lanes: super::proof_arena::lanes_per_root(), - } - } - fn total(&self) -> usize { - self.schema_start() + 2 * self.num_reg + 2 + self.out_bytes + self.l2g_lanes - } - fn assert_covers(&self, wrap: &RealBatchedLfm) { - assert_eq!( - self.total(), - wrap.public_words.len(), - "the layout must cover the wrap's published words exactly \ - (n={}, zetas={}, iotas={}, num_reg={}, out={}, l2g_lanes={})", - self.n_tables, - self.n_zetas, - self.n_iotas, - self.num_reg, - self.out_bytes, - self.l2g_lanes, - ); - } - fn id(&self, half: usize) -> usize { - 2 + half - } - fn schema_start(&self) -> usize { - 2 + 2 + 3 * self.n_tables + 1 + self.n_zetas + self.n_iotas + 1 - } - fn reg_init(&self, r: usize) -> usize { - self.schema_start() + r - } - fn reg_fini(&self, r: usize) -> usize { - self.schema_start() + self.num_reg + r - } - fn label(&self, half: usize) -> usize { - self.schema_start() + 2 * self.num_reg + half - } - fn out_byte(&self, i: usize) -> usize { - self.schema_start() + 2 * self.num_reg + 2 + i - } - fn l2g_half(&self, h: usize) -> usize { - self.schema_start() + 2 * self.num_reg + 2 + self.out_bytes + h - } -} - -/// Assert two hinted public words carry the same value, lane by lane. -fn assert_words_equal(b: &mut LfmBuilder, x: &HintedPublicWord, y: &HintedPublicWord) { - for (xl, yl) in x.lanes.iter().zip(&y.lanes) { - let xe = xl.as_ext(); - let ye = yl.as_ext(); - b.assert_eq_ext(xe, ye); - } -} - -/// Assert a hinted public word's base value equals a program constant (lanes -/// 1..4 must be zero — a base publish). -fn assert_word_is_const(b: &mut LfmBuilder, x: &HintedPublicWord, v: u64) { - let c = b.ext_const(&FEE::from(v)); - let x0 = x.lanes[0].as_ext(); - b.assert_eq_ext(x0, c); - let zero = b.ext_const(&FEE::zero()); - for lane in &x.lanes[1..] { - let le = lane.as_ext(); - b.assert_eq_ext(le, zero); - } -} - -/// The cross-wrap binding legs (verdict conditions: the chain is a CHECK on -/// published words, never a trust): one shared attestation id across every -/// wrap, each wrap's register fini vector equal to the next wrap's init -/// vector, and each wrap's epoch label pinned to its chain position as an -/// emit-time constant. -fn emit_wrap_chain_bindings( - b: &mut LfmBuilder, - legs: &[LfmLegCells], - layouts: &[WrapPublicLayout], - labels: &[u64], -) { - assert_eq!(legs.len(), layouts.len()); - assert_eq!(legs.len(), labels.len()); - for k in 1..legs.len() { - for half in 0..2 { - assert_words_equal( - b, - &legs[0].publics[layouts[0].id(half)], - &legs[k].publics[layouts[k].id(half)], - ); - } - } - for k in 0..legs.len() - 1 { - for r in 0..layouts[k].num_reg { - assert_words_equal( - b, - &legs[k].publics[layouts[k].reg_fini(r)], - &legs[k + 1].publics[layouts[k + 1].reg_init(r)], - ); - } - } - for (k, &label) in labels.iter().enumerate() { - assert_word_is_const( - b, - &legs[k].publics[layouts[k].label(0)], - label & 0xFFFF_FFFF, - ); - assert_word_is_const(b, &legs[k].publics[layouts[k].label(1)], label >> 32); - } -} - -/// The assembled aggregation program — the block proof's statement: -/// -/// SIX uniform batched-LFM verify legs (the five epoch wraps + the wrap of -/// the global-verifier program), the chain bindings (one shared attestation -/// id, register fini→init across every seam, labels pinned to chain -/// positions), the ★ L2G byte-compare (each epoch wrap's published carved -/// root equals the global wrap's published re-commit root for that epoch — -/// the root-equality binding, in-VM), and the ★ final attestation: the -/// num_pages > 0 program-id fold over the hinted (elf, pc, decode) — joined -/// to every wrap's published id through the num_pages = 0 fold of the SAME -/// cells — plus the block's genesis page commitments. -/// -/// Published words, in order (the block artifact's own schema): -/// the final attestation id (2 words), wrap 0's register init vector, the -/// final wrap's register fini vector, the final wrap's output bytes, each -/// epoch's L2G root halves (8 per epoch), each folded page's base halves -/// (2 per page), the private-input page count, and the touched-page-base -/// list (count then bases, as constants of this block's program). -pub(super) struct BlockContext<'a> { - pub(super) num_l2g: usize, - pub(super) pages: usize, - pub(super) touched_pages: &'a [u64], - pub(super) num_private_input_pages: usize, -} - -pub(super) fn aggregator_program( - wraps: &[RealBatchedLfm], - layouts: &[WrapPublicLayout], - labels: &[u64], - global_wrap: &RealBatchedLfm, - ctx: &BlockContext<'_>, -) -> LfmProgram { - let BlockContext { - num_l2g, - pages, - touched_pages, - num_private_input_pages, - } = *ctx; - assert!(!wraps.is_empty()); - assert_eq!(wraps.len(), num_l2g, "one epoch wrap per L2G re-commit"); - let mut b = LfmBuilder::new().with_wrap_hash(super::edsl::WrapHash::production()); - let arenas: Vec = wraps - .iter() - .map(|e| declare_lfm_leg_arenas(&mut b, e, true)) - .collect(); - let g_arena = declare_lfm_leg_arenas(&mut b, global_wrap, true); - // The attestation fold's inputs, LAST in declaration order: the ELF - // digest, the entry point, the DECODE root, then per folded page a u64 - // base and a 32-byte commitment (the epoch program's own page layout). - let a_att = b.declare_arena(8 + 2 + 8 + 10 * pages as u32); - - let legs: Vec = wraps - .iter() - .zip(&arenas) - .map(|(e, a)| emit_lfm_leg(&mut b, e, a)) - .collect(); - let g_leg = emit_lfm_leg(&mut b, global_wrap, &g_arena); - for (layout, wrap) in layouts.iter().zip(wraps) { - layout.assert_covers(wrap); - } - let l2g_lanes = super::proof_arena::lanes_per_root(); - assert_eq!( - global_wrap.public_words.len(), - 2 + l2g_lanes * num_l2g, - "the global wrap publishes its pair and one root per epoch" - ); - emit_wrap_chain_bindings(&mut b, &legs, layouts, labels); - - // ---- ★ the L2G root-equality binding, in-VM: epoch wrap k's published - // carved root == the global wrap's published re-commit root k ---- - for (k, (leg, layout)) in legs.iter().zip(layouts).enumerate() { - for h in 0..l2g_lanes { - assert_words_equal( - &mut b, - &leg.publics[layout.l2g_half(h)], - &g_leg.publics[2 + l2g_lanes * k + h], - ); - } - } - - // ---- ★ the attestation join and the final fold ---- - let elf_digest: Vec = (0..8).map(|i| b.hint_felt(a_att, i)).collect(); - let pc_start: Vec = (0..2).map(|i| b.hint_felt(a_att, 8 + i)).collect(); - let decode: Vec = (0..8).map(|i| b.hint_felt(a_att, 10 + i)).collect(); - let page_halves: Vec<(Vec, Vec)> = (0..pages) - .map(|k| { - let base = 18 + 10 * k as u32; - ( - (0..2).map(|j| b.hint_felt(a_att, base + j)).collect(), - (0..8).map(|j| b.hint_felt(a_att, base + 2 + j)).collect(), - ) - }) - .collect(); - let id0 = super::programs::emit_program_id( - &mut b, - super::programs::ProgramIdShape { num_pages: 0 }, - &elf_digest, - &pc_start, - &decode, - &[], - ); - let id0_cells = RootCells::from_digest(&mut b, id0); - // One (elf, pc, decode) triple answers for EVERY wrap: the fold of the - // hinted cells must equal each wrap's published attestation id. - for (leg, layout) in legs.iter().zip(layouts) { - for (w, lanes) in id0_cells.lanes.iter().enumerate() { - let hinted = &leg.publics[layout.id(w)]; - for (l, lane) in lanes.iter().enumerate() { - let computed = lane.as_ext(); - let claimed = hinted.lanes[l].as_ext(); - b.assert_eq_ext(computed, claimed); - } - } - } - let page_refs: Vec<(&[Felt], &[Felt])> = page_halves - .iter() - .map(|(base, root)| (&base[..], &root[..])) - .collect(); - let id_final = super::programs::emit_program_id( - &mut b, - super::programs::ProgramIdShape { num_pages: pages }, - &elf_digest, - &pc_start, - &decode, - &page_refs, - ); - - // ---- the block artifact's published words ---- - b.public(id_final[0]); - b.public(id_final[1]); - let first = &legs[0]; - let last = legs.last().expect("nonempty"); - let l_first = &layouts[0]; - let l_last = layouts.last().expect("nonempty"); - for r in 0..l_first.num_reg { - b.public(first.publics[l_first.reg_init(r)].lanes[0].as_cell()); - } - for r in 0..l_last.num_reg { - b.public(last.publics[l_last.reg_fini(r)].lanes[0].as_cell()); - } - for i in 0..l_last.out_bytes { - b.public(last.publics[l_last.out_byte(i)].lanes[0].as_cell()); - } - for (leg, layout) in legs.iter().zip(layouts.iter()) { - for h in 0..l2g_lanes { - b.public(leg.publics[layout.l2g_half(h)].lanes[0].as_cell()); - } - } - for (base, _) in &page_halves { - for half in base { - b.public(half.as_cell()); - } - } - let npriv = b.felt_const(FE::from(num_private_input_pages as u64)); - b.public(npriv.as_cell()); - let count = b.felt_const(FE::from(touched_pages.len() as u64)); - b.public(count.as_cell()); - for base in touched_pages { - let lo = b.felt_const(FE::from(*base & 0xFFFF_FFFF)); - b.public(lo.as_cell()); - let hi = b.felt_const(FE::from(*base >> 32)); - b.public(hi.as_cell()); - } - compile(b.finish()) -} - -// ==================== the global-verifier leg (option 3) ================== - -/// The cross-epoch global memory proof, production-accepted, harvested for -/// emission: per-table shapes and challenges (the per-table machinery's own -/// harvest), the Phase-A prep constants (page genesis commitments — AIR-set -/// constants at emit time), and the statement bytes (every field an -/// emit-time constant of the block). -pub(super) struct RealGlobal { - /// ⚠ ONE ENTRY PER HOST `append_bytes` CALL, not one flat run. - /// `absorb_continuation_global_statement` makes a separate call for the - /// tag, the ELF digest, the epoch count, the private-page count, the FRI - /// byte, the page-base count and each page base. A byte transcript - /// concatenates and cannot tell one long field from that sequence; an - /// ALGEBRAIC one length-prefixes every call and can, so a flattened - /// statement is a different chain. - pub(super) statement_appends: Vec>, - pub(super) tables: Vec, - pub(super) legs: Vec, - pub(super) num_l2g: usize, - pub(super) z_alpha: (FEE, FEE), -} - -/// Harvest the bundle's global proof. Panics loudly on a proof production -/// rejects. Mirrors `verify_global`'s AIR reconstruction exactly (the -/// no-supplied-roots arm: data-page genesis recomputed from the ELF). -pub(super) fn real_global( - elf_bytes: &[u8], - bundle: &crate::continuation::ContinuationProof, - opts: &crate::ProofOptions, -) -> RealGlobal { - use crypto::fiat_shamir::is_transcript::IsTranscript; - use executor::elf::Elf; - use stark::verifier::IsStarkVerifier; - - let elf = Elf::load(elf_bytes).expect("the ELF must load"); - let num_epochs = bundle.num_epochs(); - let npriv = bundle.num_private_pages(); - let page_bases: Vec = { - let mut b: Vec = bundle.touched_pages().to_vec(); - b.sort_unstable(); - b.dedup(); - b - }; - let l2g_airs: Vec<_> = (0..num_epochs) - .map(|i| { - crate::continuation::l2g_global_air( - opts, - crate::tables::local_to_global::epoch_label(i as u64), - ) - }) - .collect(); - let gm_configs = crate::continuation::global_memory_configs(&page_bases, &elf, npriv); - let gm_airs: Vec<_> = gm_configs - .iter() - .map(|config| crate::continuation::global_memory_air(opts, config, None)) - .collect(); - let mut refs: Vec< - &dyn stark::traits::AIR, - > = l2g_airs - .iter() - .map(|a| a as &dyn stark::traits::AIR) - .collect(); - for air in &gm_airs { - refs.push(air); - } - - // The statement, byte for byte — `absorb_continuation_global_statement`'s - // encoding over emit-time constants, pinned by the harness differential - // (the seed below absorbs through the production function; the leg's - // emitted challenges must then match the harvested ones, which fails if - // this local encoding ever drifts). - let mut statement_appends: Vec> = vec![ - crate::statement::CONTINUATION_GLOBAL_TAG.to_vec(), - crate::statement::elf_digest(elf_bytes).to_vec(), - (num_epochs as u64).to_le_bytes().to_vec(), - (npriv as u64).to_le_bytes().to_vec(), - vec![opts.fri_final_poly_log_degree], - (page_bases.len() as u64).to_le_bytes().to_vec(), - ]; - for base in &page_bases { - statement_appends.push(u64::to_le_bytes(*base).to_vec()); - } - - let seed = || { - let mut t = crate::hash_pin::block_transcript(&[]); - crate::statement::absorb_continuation_global_statement( - &mut t, - elf_bytes, - num_epochs, - npriv, - opts.fri_final_poly_log_degree, - &page_bases, - ); - t - }; - let view = bundle.global_proof_view(); - assert_eq!(refs.len(), view.len(), "one AIR per global sub-proof"); - assert!( - crate::hash_pin::BlockVerifier::::multi_verify_views( - &refs, - view, - &mut seed(), - &FEE::zero() - ), - "production's verifier must accept the global proof" - ); - - // Phase A + the shared LogUp pair, transcribed as the epoch harvest does. - let mut transcript = seed(); - for (idx, air) in refs.iter().enumerate() { - let v = view.get(idx); - if air.is_preprocessed() { - transcript.append_bytes(&air.precomputed_commitment()); - } - transcript.append_bytes(v.lde_trace_main_merkle_root()); - } - let lookup: Vec = (0..stark::lookup::LOGUP_NUM_CHALLENGES) - .map(|_| transcript.sample_field_element()) - .collect(); - let z_alpha = (lookup[0], lookup[1]); - - let num_tables = refs.len(); - let tables: Vec = refs - .iter() - .enumerate() - .map(|(idx, air)| { - let v = view.get(idx); - let mut fork = transcript.clone(); - if num_tables > 1 { - fork.append_bytes(&(idx as u64).to_le_bytes()); - } - if let Some(root) = v.lde_trace_aux_merkle_root() { - fork.append_bytes(root); - } - if let Some(c) = v.bus_table_contribution() { - fork.append_field_element(&c); - } - super::epoch_tests::host_table_forked(*air, v, idx, num_tables, &mut fork, &lookup) - }) - .collect(); - let legs = refs - .iter() - .enumerate() - .map(|(idx, air)| super::epoch_verify_tests::build_table_legs(*air, view.get(idx), &lookup)) - .collect(); - - RealGlobal { - statement_appends, - tables, - legs, - num_l2g: num_epochs, - z_alpha, - } -} - -/// Per-table arena set of the global-verifier program, in declaration order. -struct GlobalTableArenas { - aux_root: Option, - contribution: Option, - composition_root: ArenaId, - ood_current: ArenaId, - ood_next: ArenaId, - parts: ArenaId, - fri_roots: ArenaId, - fri_coeffs: ArenaId, - nonce: Option, - legs: super::epoch_verify::TableQueryArenas, -} - -/// The emitted verifier of the global proof — the per-table program's own -/// structure (statement, Phase A, one fork per table, full verification -/// legs, the LogUp closure) with the global statement as one constant run, -/// every preprocessed root an AIR-set constant, and the bus target ZERO -/// (`verify_global`'s own expected balance). PUBLISHES: the shared pair, -/// then each epoch's L2G re-commit main root (eight halves each, epoch -/// order) — the byte-compare material the aggregator binds against the five -/// wraps' published carved roots. -pub(super) fn global_verifier_program(g: &RealGlobal) -> LfmProgram { - use super::epoch::{TableAbsorbs, fork_table}; - use super::statement_replay::{PhaseAPreprocessed, PhaseATable, replay_phase_a}; - - let mut b = LfmBuilder::new().with_wrap_hash(super::edsl::WrapHash::production()); - let n = g.tables.len(); - - // ---- arenas, declaration order = absorb order ---- - let a_main_roots = b.declare_arena(super::edsl::digest_words(&b) * n as u32); - let per_table: Vec = g - .tables - .iter() - .zip(&g.legs) - .map(|(h, leg)| GlobalTableArenas { - aux_root: h - .shape - .has_aux_root - .then(|| b.declare_arena(super::edsl::digest_words(&b))), - contribution: h.shape.has_contribution.then(|| b.declare_arena(1)), - composition_root: b.declare_arena(super::edsl::digest_words(&b)), - ood_current: b - .declare_arena((h.shape.ood_current_dims.0 * h.shape.ood_current_dims.1) as u32), - ood_next: b.declare_arena((h.shape.ood_next_dims.0 * h.shape.ood_next_dims.1) as u32), - parts: b.declare_arena(h.shape.num_parts as u32), - fri_roots: b - .declare_arena(super::edsl::digest_words(&b) * h.shape.fri.num_committed() as u32), - fri_coeffs: b.declare_arena(h.shape.fri.num_terminal_coeffs() as u32), - nonce: (h.shape.grinding_factor > 0).then(|| b.declare_arena(1)), - legs: super::epoch_verify::declare_table_arenas(&mut b, &leg.verify), - }) - .collect(); - - // ---- the statement: ONE APPEND PER HOST CALL, see `statement_appends` ---- - let mut t = TranscriptReplay::new(&[]); - for append in &g.statement_appends { - t.append_const_bytes(append); - } - - // ---- Phase A: prep constants, hinted main roots ---- - let main_cells: Vec = (0..n) - .map(|i| { - RootCells::hint( - &mut b, - a_main_roots, - super::proof_arena::words_per_root() as u32 * i as u32, - ) - }) - .collect(); - // ⚠ `byte_halves`, not `lanes_flat`: Phase A absorbs a root through - // `append_halves_misaligned`, whose byte length is `4 · halves.len()`, and - // the host absorbs the root's THIRTY-TWO bytes in one `append_bytes`. On an - // algebraic arm `lanes_flat` is four FULL FELTS, so that call would declare - // sixteen bytes where the host declared thirty-two — a different length - // ⚠ The DIGEST's felts, not the root's bytes. `replay_phase_a` absorbs - // through `absorb_root_felts`, which declares the host's 32 bytes on both - // arms and packs the algebraic arm's four felts into the one digest cell - // they already are — so the root absorb CANCELS there rather than paying a - // byte regrouping. `byte_halves` is for `program_id`, which is deliberately - // keccak-over-bytes; handing it here would regroup felts the host never - // serialised. - let main_halves: Vec> = main_cells.iter().map(RootCells::lanes_flat).collect(); - let prep_cells: Vec> = g - .tables - .iter() - .map(|h| { - h.precomputed_root - .as_ref() - .map(|c| RootCells::constant(&mut b, c)) - }) - .collect(); - let phase_a: Vec = g - .tables - .iter() - .enumerate() - .map(|(i, h)| PhaseATable { - preprocessed_root: h - .precomputed_root - .as_ref() - .map(PhaseAPreprocessed::Constant), - main_root: &main_halves[i][..], - }) - .collect(); - let (z, alpha) = replay_phase_a(&mut t, &mut b, &phase_a); - b.public(z.as_cell()); - b.public(alpha.as_cell()); - // The aggregator's byte-compare material: each epoch's L2G re-commit - // root, the very cells Phase A absorbed. - for cells in main_cells.iter().take(g.num_l2g) { - for half in cells.lanes_flat() { - b.public(half.as_cell()); - } - } - - // ---- one fork per table, with the full verification legs ---- - let mut contributions: Vec = Vec::new(); - for (i, h) in g.tables.iter().enumerate() { - let a = &per_table[i]; - let aux = a.aux_root.map(|id| RootCells::hint(&mut b, id, 0)); - let contribution = a.contribution.map(|id| b.hint_word(id, 0).as_ext()); - let composition = RootCells::hint(&mut b, a.composition_root, 0); - let ood_current: Vec = (0..(h.shape.ood_current_dims.0 * h.shape.ood_current_dims.1) - as u32) - .map(|k| b.hint_word(a.ood_current, k).as_ext()) - .collect(); - let ood_next: Vec = (0..(h.shape.ood_next_dims.0 * h.shape.ood_next_dims.1) as u32) - .map(|k| b.hint_word(a.ood_next, k).as_ext()) - .collect(); - let parts: Vec = (0..h.shape.num_parts as u32) - .map(|k| b.hint_word(a.parts, k).as_ext()) - .collect(); - let fri_roots: Vec = (0..h.shape.fri.num_committed()) - .map(|k| { - RootCells::hint( - &mut b, - a.fri_roots, - super::proof_arena::words_per_root() as u32 * k as u32, - ) - }) - .collect(); - let fri_coeffs: Vec = (0..h.shape.fri.num_terminal_coeffs() as u32) - .map(|k| b.hint_word(a.fri_coeffs, k).as_ext()) - .collect(); - let nonce = a.nonce.map(|id| b.hint_felt(id, 0)); - if let Some(c) = contribution { - contributions.push(c); - } - let mut fork = fork_table(&t, h.shape.index, h.shape.num_tables); - let absorbs = TableAbsorbs { - aux_root: aux.as_ref(), - contribution, - composition_root: &composition, - ood_current: &ood_current, - ood_next: &ood_next, - parts: &parts, - fri_roots: &fri_roots, - fri_coeffs: &fri_coeffs, - nonce, - }; - let ch = super::epoch::emit_table_challenges(&mut b, &mut fork, &h.shape, &absorbs); - let leg = &g.legs[i]; - super::epoch_verify::emit_table_verification( - &mut b, - &leg.verify, - &leg.analysis, - &ch, - &absorbs, - &super::epoch_verify::TableInputs { - precomputed_root: prep_cells[i].as_ref(), - main_root: &main_cells[i], - rap_challenges: &[z, alpha], - }, - &a.legs, - ); - } - - // ---- the closure: the global bus balances to ZERO ---- - let shape = super::logup::LogUpShape { - num_contributing_tables: contributions.len(), - num_output_bytes: 0, - }; - let target = b.ext_const(&FEE::zero()); - super::logup::emit_bus_closure(&mut b, &shape, &contributions, target); - - compile(b.finish()) -} - -/// The global program's arenas, in its declaration order. -pub(super) fn global_arena_words(g: &RealGlobal) -> Vec> { - let mut arenas: Vec> = Vec::new(); - arenas.push(super::proof_arena::commitments_to_arena( - &g.tables.iter().map(|h| h.main_root).collect::>(), - )); - for (h, leg) in g.tables.iter().zip(&g.legs) { - if let Some(root) = &h.aux_root { - arenas.push(super::proof_arena::commitments_to_arena(&[*root])); - } - if let Some(c) = &h.contribution { - arenas.push(vec![ext_word(c)]); - } - arenas.push(super::proof_arena::commitments_to_arena(&[ - h.composition_root - ])); - 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(&h.fri_roots)); - arenas.push(h.fri_coeffs.iter().map(ext_word).collect()); - if let Some(nonce) = h.nonce { - arenas.push(vec![base_word(FE::from(nonce))]); - } - arenas.push(leg.opening_arena()); - arenas.push(leg.fri_arena()); - } - arenas -} - -// ============================ the gates ================================== - -/// The fixture wrap at the aggregation preset, and its leg program that -/// publishes every challenge (the differential surface). -fn fixture_leg() -> (RealBatchedLfm, LfmProgram) { - use super::programs::trivial_program; - use super::proof::lfm_prove_batched; - - let opts = aggregation_wrap_options(); - let program = trivial_program(); - let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER); - let arenas: Vec> = vec![ - (0..4u64) - .map(|i| core::array::from_fn(|j| FE::from(1_000 * (i + 1) + j as u64))) - .collect(), - ]; - let proved = lfm_prove_batched(&program, &artifacts, &arenas, &opts) - .expect("the fixture wrap must prove at the aggregation preset"); - let e = real_batched_lfm(artifacts, opts, &proved); - - let mut b = LfmBuilder::new().with_wrap_hash(super::edsl::WrapHash::production()); - let a = declare_lfm_leg_arenas(&mut b, &e, true); - let cells = emit_lfm_leg(&mut b, &e, &a); - b.public(cells.lookup.0.as_cell()); - b.public(cells.lookup.1.as_cell()); - for v in cells.betas.iter().chain(&cells.zs).chain(&cells.gammas) { - b.public(v.as_cell()); - } - b.public(cells.alpha.as_cell()); - for zeta in &cells.zetas { - b.public(zeta.as_cell()); - } - for bits in &cells.iota_bits { - let felt = edsl::bits_to_felt(&mut b, bits); - b.public(felt.as_cell()); - } - (e, compile(b.finish())) -} - -/// The leg's arenas for one wrap, in the declaration order above. -fn leg_arena_words(e: &RealBatchedLfm) -> Vec> { - let mut arenas: Vec> = Vec::new(); - arenas.push(lfm_publics_arena(&e.public_words)); - arenas.push(super::proof_arena::commitments_to_arena(&[e - .proof - .main_root])); - if !e.shape.aux.dims.is_empty() { - arenas.push(super::proof_arena::commitments_to_arena(&[e - .proof - .aux_root - .expect("an aux shape has an aux root")])); - } - for t in &e.proof.tables { - if let Some(bus) = &t.bus_public_inputs { - arenas.push(vec![ext_word(&bus.table_contribution)]); - } - } - let block_words = |block: &stark::table::Table| -> Vec { - (0..block.height) - .flat_map(|r| block.get_row(r).iter().map(ext_word).collect::>()) - .collect() - }; - for t in &e.proof.tables { - arenas.push(block_words(&t.trace_ood_evaluations)); - arenas.push(block_words(&t.trace_ood_next_evaluations)); - arenas.push( - t.composition_poly_parts_ood_evaluation - .iter() - .map(ext_word) - .collect(), - ); - } - arenas.push(super::proof_arena::commitments_to_arena(&[e - .proof - .parts_root])); - for t in &e.proof.tables { - if let Some(coeffs) = &t.standalone_final_poly_coeffs { - arenas.push(coeffs.iter().map(ext_word).collect()); - } - } - arenas.push(super::proof_arena::commitments_to_arena( - &e.proof.fri_layer_roots, - )); - arenas.push(e.proof.fri_final_poly_coeffs.iter().map(ext_word).collect()); - if let Some(nonce) = e.proof.nonce { - arenas.push(vec![base_word(FE::from(nonce))]); - } - arenas.push(lfm_opening_arena(e)); - arenas.push(lfm_fri_arena(e)); - arenas -} - -/// ★ THE LEG RUNS — and its challenges are production's own. Executing the -/// leg proves every emitted assert held: the statement bytes matched the -/// spine's absorbs, the LogUp closure reached the PUBLIC balance recomputed -/// from the hinted words, every walk authenticated against the absorbed -/// roots, every quotient identity held, and FRI folded to the terminal. The -/// published challenges are then differentialled against -/// `replay_epoch_transcript`'s on the same wrap. -#[test] -fn the_lfm_wrap_leg_runs_and_matches_the_host_replay() { - let (e, program) = fixture_leg(); - let arenas = leg_arena_words(&e); - let exec = - execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER).expect("the leg must execute"); - - let pub_ext = |i: usize| super::word::word_as_ext(&exec.public_words[i].1).expect("an ext"); - assert_eq!(pub_ext(0), e.challenges.lookup[0], "z"); - assert_eq!(pub_ext(1), e.challenges.lookup[1], "alpha"); - let n = e.proof.tables.len(); - for (i, beta) in e.challenges.betas.iter().enumerate() { - assert_eq!(pub_ext(2 + i), *beta, "beta[{i}]"); - } - for (i, z) in e.challenges.zs.iter().enumerate() { - assert_eq!(pub_ext(2 + n + i), *z, "z[{i}]"); - } - for (i, g) in e.challenges.deep_gammas.iter().enumerate() { - assert_eq!(pub_ext(2 + 2 * n + i), *g, "gamma[{i}]"); - } - assert_eq!(pub_ext(2 + 3 * n), e.challenges.fri.alpha, "DEEP alpha"); - for (i, zeta) in e.challenges.fri.betas.iter().enumerate() { - assert_eq!(pub_ext(2 + 3 * n + 1 + i), *zeta, "fold beta[{i}]"); - } - let iota_base = 2 + 3 * n + 1 + e.challenges.fri.betas.len(); - for (i, iota) in e.challenges.fri.iotas.iter().enumerate() { - let got = - super::word::word_as_base(&exec.public_words[iota_base + i].1).expect("an iota felt"); - assert_eq!(got, FE::from(*iota as u64), "iota[{i}]"); - } -} - -/// A tampered wrap is UNPROVABLE through the leg: flip one opened main-round -/// value and the walk's authentication cannot reach the absorbed root. -#[test] -fn the_lfm_wrap_leg_rejects_a_tampered_proof() { - let (e, program) = fixture_leg(); - let mut tampered_proof = e.proof.clone(); - tampered_proof.queries[0].main.per_matrix[0].evaluations[0] += FE::one(); - let tampered = RealBatchedLfm { - proof: tampered_proof, - ..e - }; - let arenas = leg_arena_words(&tampered); - assert!( - execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a tampered opening must make the leg unprovable" - ); -} - -/// And a moved PUBLIC WORD is unprovable too — the balance target moves, the -/// closure's assert fails. This is the aggregator's claimed-public binding. -#[test] -fn the_lfm_wrap_leg_rejects_a_moved_public_word() { - let (e, program) = fixture_leg(); - let mut words = e.public_words.clone(); - let w = words.first_mut().expect("the fixture publishes words"); - w.1[0] += FE::one(); - let moved = RealBatchedLfm { - public_words: words, - ..e - }; - let arenas = leg_arena_words(&moved); - assert!( - execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a moved public word must make the leg unprovable" - ); -} - -/// The whole fixture pipeline below the aggregator: a batched-carved -/// continuation bundle, EVERY epoch wrapped from proofs alone in the BATCHED -/// format at the AGGREGATION preset, plus the chain-position labels. -#[allow(clippy::type_complexity)] -fn fixture_wraps() -> ( - Vec, - Vec, - Vec, - crate::continuation::ContinuationProof, - Vec, -) { - use super::proof::lfm_prove_batched; - - let elf_bytes = super::proof_fixture::read_inner_elf(); - let inner = super::proof_fixture::fixture_options(); - let bundle = crate::continuation::prove_continuation_batched( - &elf_bytes, - &[], - super::proof_fixture::FIXTURE_EPOCH_LOG2, - &inner, - ) - .expect("the fixture continuation must prove batched"); - let n = bundle.num_epochs(); - assert!(n >= 2, "the aggregate needs a chain"); - - let opts = aggregation_wrap_options(); - let mut wraps = Vec::with_capacity(n); - let mut layouts = Vec::with_capacity(n); - let mut labels = Vec::with_capacity(n); - for k in 0..n { - let e = super::epoch_tests::real_batched_epoch_from_continuation( - &inner, &elf_bytes, &bundle, k, None, - ) - .expect("every epoch must reconstruct from proofs alone"); - labels.push(e.epoch_label); - layouts.push(WrapPublicLayout::of_inner(&e)); - let program = super::epoch_tests::batched_epoch_program_with(&e, true, false); - let mut arenas = super::epoch_tests::batched_epoch_arenas(&e); - arenas.push(super::epoch_verify_tests::batched_opening_arena(&e)); - arenas.push(super::epoch_verify_tests::batched_fri_arena(&e)); - let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER); - let proved = match lfm_prove_batched(&program, &artifacts, &arenas, &opts) { - Ok(p) => p, - Err(e) => { - // ★ A `DivByZero` is always a failing equality assert with the - // numerator's address, so the address names the assert — print - // the instruction that wrote it rather than leaving a bare - // number for someone to bisect. - if let super::proof::LfmProveError::Exec( - super::executor::LfmExecError::DivByZero { addr }, - ) = &e - { - eprintln!("{}", super::executor::locate_addr(&program, *addr)); - } - panic!("the epoch's wrap must prove batched at the aggregation preset: {e:?}"); - } - }; - wraps.push(real_batched_lfm(artifacts, opts.clone(), &proved)); - } - (wraps, layouts, labels, bundle, elf_bytes) -} - -/// Everything the six-leg fixture aggregate needs beyond the epoch wraps: -/// the global wrap and the attestation inputs, from the SAME bundle. -struct FixtureAggregate { - wraps: Vec, - layouts: Vec, - labels: Vec, - global_wrap: RealBatchedLfm, - elf_digest: [u8; 32], - pc_start: u64, - decode_root: stark::config::Commitment, - pages: Vec<(u64, stark::config::Commitment)>, - touched: Vec, - npriv: usize, -} - -fn fixture_aggregate() -> FixtureAggregate { - use super::proof::lfm_prove_batched; - use executor::elf::Elf; - - let (wraps, layouts, labels, bundle, elf_bytes) = fixture_wraps(); - let inner = super::proof_fixture::fixture_options(); - let opts = aggregation_wrap_options(); - - let g = real_global(&elf_bytes, &bundle, &inner); - let program = global_verifier_program(&g); - let arenas = global_arena_words(&g); - let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER); - let proved = lfm_prove_batched(&program, &artifacts, &arenas, &opts) - .expect("the global wrap must prove batched at the aggregation preset"); - let global_wrap = real_batched_lfm(artifacts, opts, &proved); - - let elf = Elf::load(&elf_bytes).expect("the ELF must load"); - let (decode_root, mut pages) = - crate::continuation::continuation_precomputed_commitments(&elf_bytes, &bundle, &inner) - .expect("the consumer recompute must run"); - pages.sort_by_key(|(base, _)| *base); - FixtureAggregate { - wraps, - layouts, - labels, - global_wrap, - elf_digest: crate::statement::elf_digest(&elf_bytes), - pc_start: elf.entry_point, - decode_root, - pages, - touched: bundle.touched_pages().to_vec(), - npriv: bundle.num_private_pages(), - } -} - -/// The attestation arena's words: elf digest, entry point, DECODE root, then -/// per folded page the base and commitment — all as u32 halves. -fn attestation_arena_words(f: &FixtureAggregate) -> Vec { - fn root_halves(out: &mut Vec, root: &[u8; 32]) { - for c in root.chunks(4) { - out.push(base_word(FE::from( - u32::from_le_bytes(c.try_into().expect("4 bytes")) as u64, - ))); - } - } - let mut out = Vec::new(); - root_halves(&mut out, &f.elf_digest); - out.push(base_word(FE::from(f.pc_start & 0xFFFF_FFFF))); - out.push(base_word(FE::from(f.pc_start >> 32))); - root_halves(&mut out, &f.decode_root); - for (base, root) in &f.pages { - out.push(base_word(FE::from(*base & 0xFFFF_FFFF))); - out.push(base_word(FE::from(*base >> 32))); - root_halves(&mut out, root); - } - out -} - -/// ★ THE AGGREGATE RUNS — THE BLOCK STATEMENT AT FIXTURE SCALE: every epoch -/// of a batched-carved chain wrapped at the aggregation preset, the global -/// proof wrapped the same way, and ONE emitted program verifies all of them -/// plus the bindings — the chain (id, registers, labels), the in-VM L2G -/// root-equality against the global wrap, and the attestation join with the -/// final num_pages > 0 fold. The published id is then checked against the -/// CONSUMER'S OWN recompute (`program_id_from_digest` over -/// `continuation_precomputed_commitments`) — the contract's compare, run -/// here as the gate's oracle. -#[test] -fn the_assembled_aggregator_runs_on_the_fixture_chain() { - let f = fixture_aggregate(); - let program = aggregator_program( - &f.wraps, - &f.layouts, - &f.labels, - &f.global_wrap, - &BlockContext { - num_l2g: f.wraps.len(), - pages: f.pages.len(), - touched_pages: &f.touched, - num_private_input_pages: f.npriv, - }, - ); - let mut arenas: Vec> = f.wraps.iter().flat_map(leg_arena_words).collect(); - arenas.extend(leg_arena_words(&f.global_wrap)); - arenas.push(attestation_arena_words(&f)); - let exec = execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER) - .expect("the aggregate must execute"); - - // The consumer's own recompute is the oracle for the published id. - let expected = crate::recursion::program_id_from_digest( - &f.elf_digest, - f.pc_start, - &f.decode_root, - &f.pages, - ); - for w in 0..2 { - let got = exec.public_words[w].1; - let want: Vec = expected[16 * w..16 * (w + 1)] - .chunks(4) - .map(|c| FE::from(u32::from_le_bytes(c.try_into().expect("4 bytes")) as u64)) - .collect(); - // A digest word carries four u32 lanes. - assert_eq!(got.to_vec(), want, "published id word {w}"); - } - println!( - "★ six-leg aggregate over {} epoch wraps + the global wrap: {} instructions, {} published words; the published id MATCHES the consumer recompute", - f.wraps.len(), - program.instrs.len(), - exec.public_words.len() - ); -} - -/// The chain bindings DISCRIMINATE: a fini→init mismatch at a seam makes the -/// aggregate unprovable. -#[test] -fn the_aggregator_rejects_a_broken_register_chain() { - let f = fixture_aggregate(); - let program = aggregator_program( - &f.wraps, - &f.layouts, - &f.labels, - &f.global_wrap, - &BlockContext { - num_l2g: f.wraps.len(), - pages: f.pages.len(), - touched_pages: &f.touched, - num_private_input_pages: f.npriv, - }, - ); - let mut arenas: Vec> = f.wraps.iter().flat_map(leg_arena_words).collect(); - arenas.extend(leg_arena_words(&f.global_wrap)); - arenas.push(attestation_arena_words(&f)); - let word_index = f.layouts[0].reg_fini(0); - arenas[0][8 * word_index] = base_word( - super::word::word_as_base(&arenas[0][8 * word_index]).expect("a half") + FE::one(), - ); - assert!( - execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a broken register chain must make the aggregate unprovable" - ); -} - -/// The attestation join DISCRIMINATES: a flipped DECODE half in the fold's -/// arena makes the num_pages = 0 fold disagree with every wrap's published -/// id — unprovable, and nothing else about the proofs changed. -#[test] -fn the_aggregator_rejects_a_forged_attestation_input() { - let f = fixture_aggregate(); - let program = aggregator_program( - &f.wraps, - &f.layouts, - &f.labels, - &f.global_wrap, - &BlockContext { - num_l2g: f.wraps.len(), - pages: f.pages.len(), - touched_pages: &f.touched, - num_private_input_pages: f.npriv, - }, - ); - let mut arenas: Vec> = f.wraps.iter().flat_map(leg_arena_words).collect(); - arenas.extend(leg_arena_words(&f.global_wrap)); - let mut att = attestation_arena_words(&f); - att[10][0] += FE::one(); // the DECODE root's first half - arenas.push(att); - assert!( - execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a forged attestation input must make the aggregate unprovable" - ); -} - -/// The L2G binding DISCRIMINATES through the global side: a flipped root -/// half in the GLOBAL wrap's publics arena breaks its own leg's statement — -/// and would break the root-equality compare even if it did not. -#[test] -fn the_aggregator_rejects_a_moved_global_root() { - let f = fixture_aggregate(); - let program = aggregator_program( - &f.wraps, - &f.layouts, - &f.labels, - &f.global_wrap, - &BlockContext { - num_l2g: f.wraps.len(), - pages: f.pages.len(), - touched_pages: &f.touched, - num_private_input_pages: f.npriv, - }, - ); - let mut arenas: Vec> = f.wraps.iter().flat_map(leg_arena_words).collect(); - let g_base = arenas.len(); - arenas.extend(leg_arena_words(&f.global_wrap)); - arenas.push(attestation_arena_words(&f)); - // The global wrap's publics arena is its leg's first: word 2 is root 0 - // half 0 (after the pair), eight halves per word. - arenas[g_base][8 * 2] = - base_word(super::word::word_as_base(&arenas[g_base][8 * 2]).expect("a half") + FE::one()); - assert!( - execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a moved global L2G root must make the aggregate unprovable" - ); -} - -/// ★ THE GLOBAL LEG RUNS: the emitted verifier of a REAL fixture bundle's -/// cross-epoch global proof — per-table verification of the L2G re-commits -/// and one GLOBAL_MEMORY table per touched page behind one constant-run -/// statement, closing the GlobalMemory bus at ZERO — and publishes each -/// epoch's L2G re-commit root. Differentialled against the harvest's own -/// production challenges via the published pair; tampered via a flipped -/// L2G main root (Phase A absorbs it, so the walk cannot reach it). -#[test] -fn the_global_verifier_leg_runs_and_rejects_tampers() { - let elf_bytes = super::proof_fixture::read_inner_elf(); - let inner = super::proof_fixture::fixture_options(); - let bundle = crate::continuation::prove_continuation_batched( - &elf_bytes, - &[], - super::proof_fixture::FIXTURE_EPOCH_LOG2, - &inner, - ) - .expect("the fixture continuation must prove batched"); - let g = real_global(&elf_bytes, &bundle, &inner); - let program = global_verifier_program(&g); - let arenas = global_arena_words(&g); - let exec = execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER) - .expect("the global leg must execute"); - - let pub_ext = |i: usize| super::word::word_as_ext(&exec.public_words[i].1).expect("an ext"); - assert_eq!(pub_ext(0), g.z_alpha.0, "the global z"); - assert_eq!(pub_ext(1), g.z_alpha.1, "the global alpha"); - // The published L2G re-commit roots equal the harvested main roots. - // - // ⚠ Compared through `proof_arena::commitment_lanes`, NOT by re-spelling - // the byte rendering here. The program publishes `RootCells::lanes_flat`, - // which is `u32` halves on a byte hash and FULL FELTS on an algebraic one; - // `commitment_lanes` is the flattened `commitment_words` the arena was - // written from, so the two agree by construction on either arm instead of - // this test carrying a second copy of one arm's layout. - let l2g_lanes = super::proof_arena::lanes_per_root(); - for k in 0..g.num_l2g { - let want = super::proof_arena::commitment_lanes(&g.tables[k].main_root); - assert_eq!(want.len(), l2g_lanes, "a root's published lane count"); - for (h, want) in want.into_iter().enumerate() { - let got = super::word::word_as_base(&exec.public_words[2 + l2g_lanes * k + h].1) - .expect("a root lane"); - assert_eq!(got, want, "L2G root {k} lane {h}"); - } - } - println!( - "★ global leg: {} tables ({} L2G + {} pages), {} instructions, {} published words", - g.tables.len(), - g.num_l2g, - g.tables.len() - g.num_l2g, - program.instrs.len(), - exec.public_words.len() - ); - - // Tamper: flip one byte of one L2G main root in the arena — Phase A then - // absorbs a root the walks cannot authenticate against. - let mut tampered = global_arena_words(&g); - tampered[0][0][0] += FE::one(); - assert!( - execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a flipped L2G re-commit root must make the global leg unprovable" - ); -} - -/// ★ The aggregate's QUERY CENSUS, per leg: the walks' wrap-hash -/// permutations are exactly the in-code closed form -/// (`batched_query_permutations_for` over the WRAP PROOF's shape), measured -/// as the delta between the with-walks and spine-only single-leg programs — -/// absolute, and hash-aware (the other hash's delta must be zero). The same -/// discipline the VM epoch census gate pins, generalized to the LFM legs the -/// aggregator is made of; the plan-level census rides this formula. -#[test] -fn the_aggregate_leg_census_matches_the_closed_form() { - use super::programs::trivial_program; - use super::proof::lfm_prove_batched; - - let opts = aggregation_wrap_options(); - let program = trivial_program(); - let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER); - let arenas: Vec> = vec![ - (0..4u64) - .map(|i| core::array::from_fn(|j| FE::from(1_000 * (i + 1) + j as u64))) - .collect(), - ]; - let proved = lfm_prove_batched(&program, &artifacts, &arenas, &opts) - .expect("the fixture wrap must prove"); - let e = real_batched_lfm(artifacts, opts, &proved); - - let build = |with: bool| -> LfmProgram { - let mut b = LfmBuilder::new().with_wrap_hash(super::edsl::WrapHash::production()); - let a = declare_lfm_leg_arenas(&mut b, &e, with); - let _ = emit_lfm_leg(&mut b, &e, &a); - compile(b.finish()) - }; - // ★ THE CLOSED FORM COUNTS PERMUTATIONS, and each of the three chips is - // exactly ONE permutation per instruction — `Instr::KeccakF` and - // `Instr::Blake3` on the byte arms, `Instr::Hash` on the algebraic socket - // (every `HashMode` is one permutation of the same socket). So this counter - // is arm-agnostic and the closed form needs no algebraic variant: the - // hash-dependence lives entirely inside `epoch_verify::blocks_for`, whose - // Algebraic arm is pinned width by width by - // `rpo_chip_tests::the_rate_eight_census_is_hash_invariant`. - // - // ⚠ Counting only the two BYTE chips is what this test used to do, and on - // an algebraic pin it reports ZERO against a nonzero closed form. That is a - // defect in the TEST's bookkeeping, not in the closed form or in the - // emitter — do not "fix" it by touching either. - let count = |p: &LfmProgram, hash: super::edsl::WrapHash| -> usize { - use super::edsl::WrapHash; - use super::instr::Instr; - p.instrs - .iter() - .filter(|i| { - matches!( - (i, hash), - (Instr::KeccakF(_), WrapHash::Keccak) - | (Instr::Blake3(_), WrapHash::Blake3) - | (Instr::Hash { .. }, WrapHash::Algebraic) - ) - }) - .count() - }; - let spine = build(false); - let full = build(true); - let hash = super::edsl::WrapHash::production(); - let per_query = - super::batched_epoch_verify::batched_query_permutations_for(&e.shape, &e.fri_params, hash); - let wrap_delta = count(&full, hash) - count(&spine, hash); - assert_eq!( - wrap_delta, - e.proof.queries.len() * per_query, - "an aggregator leg's walks must hash exactly the census closed form" - ); - for other in [ - super::edsl::WrapHash::Keccak, - super::edsl::WrapHash::Blake3, - super::edsl::WrapHash::Algebraic, - ] { - if other == hash { - continue; - } - assert_eq!( - count(&full, other) - count(&spine, other), - 0, - "the walks hash under the wrap hash alone, not {other:?}" - ); - } - - // ★ The closed form is CHUNK-INVARIANT, and that is a property, not an - // accident. `LFM_BLAKE3` chunking redistributes the chip's rows over AIR - // instances AFTER compilation, so the instruction stream this census counts - // is the same program either way. Asserted rather than argued: a chunking - // that reached back into emission would move the census silently, and the - // aggregation program is exactly where chunking gets switched on. - // - // ⚠ Runs only where there ARE `LFM_BLAKE3` rows to split. Under a keccak or - // an algebraic pin the leg emits none, so `from_compressions` has nothing to - // chunk and the control would assert against a single empty chunk — a - // vacuous failure about the chip's absence, not about the census. - if full.groups.blake3.real_rows > 0 { - let per = full.groups.blake3.real_rows.div_ceil(3).max(1); - let chunked = - full.with_blake3_chunking(super::chunking::Blake3Chunking::from_compressions(per)); - assert!( - chunked.blake3_chunk_count() > 1, - "the control needs a real split, got {} chunks of {per}", - chunked.blake3_chunk_count() - ); - assert_eq!( - count(&chunked, hash) - count(&spine, hash), - wrap_delta, - "chunking must not move the leg's wrap-hash census" - ); - } - - eprintln!( - "aggregate leg census: {per_query} wrap permutations/query over {} chips at the aggregation preset", - e.proof.tables.len() - ); -} - -/// ★★★ THE BLOCK DRIVER — ONE PROOF FOR THE BLOCK, end to end in one -/// process. Box-tier; the same env contract as the P1/P2 drivers. -/// -/// ```text -/// LFM_CENSUS_ELF=/path/to/ethrex.elf \ -/// LFM_CENSUS_INPUT=/path/to/ethrex_mainnet_25368371.bin \ -/// LFM_CENSUS_EPOCH_LOG2=24 LAMBDA_VM_MAX_ROWS_LOG2=24 \ -/// cargo test --release -p lambda-vm-prover --lib \ -/// lfm::aggregator_tests::the_real_block_aggregates_end_to_end -- --ignored --exact --nocapture -/// ``` -/// -/// Phases, each timed and printed: the batched-carved base (5 epochs + -/// global proof) → full bundle verification → 5 epoch wraps + the global -/// wrap, all batched at the aggregation preset, from proofs alone → the -/// aggregation program (six legs + bindings + attestation) → ★ THE -/// AGGREGATION PROVE → its complete verification → ★ THE CONSUMER RITUAL -/// (the contract's steps: the pinned verify just ran; recompute the -/// expected id from the trusted ELF + the artifact's published page data; -/// byte-compare against the published id; read the outputs) — timed, its -/// cost named in the record. -#[test] -#[ignore] -fn the_real_block_aggregates_end_to_end() { - use super::proof::lfm_prove_batched; - use executor::elf::Elf; - use std::time::Instant; - - for var in ["LFM_CENSUS_ELF", "LFM_CENSUS_INPUT"] { - assert!( - std::env::var(var).is_ok(), - "{var} must name a file: this driver proves the REAL block" - ); - } - let inputs = super::epoch_tests::EpochInputs::from_env(); - let inner = crate::recursion::Preset::Blowup4.options(); - let agg_opts = aggregation_wrap_options(); - println!( - "★ P3 BLOCK RUN: guest {}, {} input bytes, 2^{} cycles/epoch, inner blowup {} / {} q, \ - wrap+aggregation blowup {} / {} q / fp{}", - inputs.label, - inputs.private_input.len(), - inputs.epoch_log2, - inner.blowup_factor, - inner.fri_number_of_queries, - agg_opts.blowup_factor, - agg_opts.fri_number_of_queries, - agg_opts.fri_final_poly_log_degree, - ); - let t_total = Instant::now(); - - // The artifact cache: with P3_ARTIFACT_DIR set, the bundle and all six - // wrap proofs persist to disk after production (the rkyv wire), and a - // relaunch LOADS them — an aggregation attempt never re-pays the base - // and wrap proves. Programs and artifacts are re-emitted either way - // (minutes, deterministic); only the PROVES are cached. - let art_dir = std::env::var("P3_ARTIFACT_DIR").ok(); - let cache_path = |name: &str| art_dir.as_ref().map(|d| std::path::Path::new(d).join(name)); - let bundle_cached = cache_path("bundle.rkyv").is_some_and(|p| p.exists()); - - // ---- base ---- - let t = Instant::now(); - let bundle = if bundle_cached { - let bytes = std::fs::read(cache_path("bundle.rkyv").expect("cache path")) - .expect("the cached bundle must read"); - let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(bytes.len()); - aligned.extend_from_slice(&bytes); - rkyv::from_bytes::(&aligned) - .expect("the cached bundle must deserialize") - } else { - crate::continuation::prove_continuation_batched( - &inputs.elf_bytes, - &inputs.private_input, - inputs.epoch_log2, - &inner, - ) - .expect("the block must prove batched") - }; - let n = bundle.num_epochs(); - if let (false, Some(dir)) = (bundle_cached, &art_dir) { - std::fs::create_dir_all(dir).expect("the artifact dir must create"); - let bytes = - rkyv::to_bytes::(&bundle).expect("the bundle must serialize"); - std::fs::write(cache_path("bundle.rkyv").expect("cache path"), &bytes) - .expect("the bundle must persist"); - } - println!( - " base: {n} epochs + global proof in {:.1}s ({}), peak RSS {:?} GiB", - t.elapsed().as_secs_f64(), - if bundle_cached { - "LOADED from cache" - } else { - "proved" - }, - super::wrap_tests::peak_rss_gib(), - ); - let t = Instant::now(); - let out = crate::continuation::verify_continuation(&inputs.elf_bytes, &bundle, &inner) - .expect("the bundle must verify"); - assert!(out.is_some(), "the bundle must verify completely"); - println!(" host verify: {:.1}s", t.elapsed().as_secs_f64()); - - // ---- the six wraps ---- - let t = Instant::now(); - let mut wraps = Vec::with_capacity(n); - let mut layouts = Vec::with_capacity(n); - let mut labels = Vec::with_capacity(n); - for k in 0..n { - let tk = Instant::now(); - let e = super::epoch_tests::real_batched_epoch_from_continuation( - &inner, - &inputs.elf_bytes, - &bundle, - k, - None, - ) - .expect("every epoch must reconstruct from proofs alone"); - labels.push(e.epoch_label); - layouts.push(WrapPublicLayout::of_inner(&e)); - let program = super::epoch_tests::batched_epoch_program_with(&e, true, false); - let mut arenas = super::epoch_tests::batched_epoch_arenas(&e); - arenas.push(super::epoch_verify_tests::batched_opening_arena(&e)); - arenas.push(super::epoch_verify_tests::batched_fri_arena(&e)); - let artifacts = - build_artifacts_with_hasher(&program, &agg_opts, crate::hash_pin::BLOCK_HASHER); - let wrap_file = format!("wrap_{k}.rkyv"); - let cached = cache_path(&wrap_file).is_some_and(|p| p.exists()); - let tp = Instant::now(); - let proved = if cached { - let bytes = std::fs::read(cache_path(&wrap_file).expect("cache path")) - .expect("the cached wrap must read"); - let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(bytes.len()); - aligned.extend_from_slice(&bytes); - rkyv::from_bytes::(&aligned) - .expect("the cached wrap must deserialize") - } else { - let proved = lfm_prove_batched(&program, &artifacts, &arenas, &agg_opts) - .expect("the epoch wrap must prove"); - if let Some(p) = cache_path(&wrap_file) { - let bytes = rkyv::to_bytes::(&proved) - .expect("the wrap must serialize"); - std::fs::write(p, &bytes).expect("the wrap must persist"); - } - proved - }; - println!( - " epoch {k}: construct {:.1}s, wrap prove {:.1}s ({}), {} program instrs", - tk.elapsed().as_secs_f64() - tp.elapsed().as_secs_f64(), - tp.elapsed().as_secs_f64(), - if cached { "LOADED" } else { "proved" }, - program.instrs.len(), - ); - wraps.push(real_batched_lfm(artifacts, agg_opts.clone(), &proved)); - } - let tg = Instant::now(); - let g = real_global(&inputs.elf_bytes, &bundle, &inner); - let g_program = global_verifier_program(&g); - let g_arenas = global_arena_words(&g); - let g_artifacts = - build_artifacts_with_hasher(&g_program, &agg_opts, crate::hash_pin::BLOCK_HASHER); - let g_cached = cache_path("global_wrap.rkyv").is_some_and(|p| p.exists()); - let tp = Instant::now(); - let g_proved = if g_cached { - let bytes = std::fs::read(cache_path("global_wrap.rkyv").expect("cache path")) - .expect("the cached global wrap must read"); - let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(bytes.len()); - aligned.extend_from_slice(&bytes); - rkyv::from_bytes::(&aligned) - .expect("the cached global wrap must deserialize") - } else { - let proved = lfm_prove_batched(&g_program, &g_artifacts, &g_arenas, &agg_opts) - .expect("the global wrap must prove"); - if let Some(p) = cache_path("global_wrap.rkyv") { - let bytes = - rkyv::to_bytes::(&proved).expect("the wrap must serialize"); - std::fs::write(p, &bytes).expect("the global wrap must persist"); - } - proved - }; - println!( - " global: construct {:.1}s, wrap prove {:.1}s, {} tables, {} program instrs", - tg.elapsed().as_secs_f64() - tp.elapsed().as_secs_f64(), - tp.elapsed().as_secs_f64(), - g.tables.len(), - g_program.instrs.len(), - ); - let global_wrap = real_batched_lfm(g_artifacts, agg_opts.clone(), &g_proved); - println!(" wraps total: {:.1}s", t.elapsed().as_secs_f64()); - - // ---- the aggregation ---- - let elf = Elf::load(&inputs.elf_bytes).expect("the ELF must load"); - // Timed on its own line: this native FFT+Merkle pass is the consumer - // ritual's expensive half (design-review condition 1 asked for its - // price; run 4 left it inside a ~401 s unaccounted gap). - let t = Instant::now(); - let (decode_root, mut pages) = crate::continuation::continuation_precomputed_commitments( - &inputs.elf_bytes, - &bundle, - &inner, - ) - .expect("the consumer recompute must run"); - println!( - " consumer precompute (decode_root + pages, native FFT+Merkle): {:.1}s", - t.elapsed().as_secs_f64() - ); - pages.sort_by_key(|(base, _)| *base); - let elf_digest = crate::statement::elf_digest(&inputs.elf_bytes); - let t = Instant::now(); - // ★ LFM_BLAKE3 chunking, chosen at EMISSION time. The aggregation program is - // where the chip's ~1.39M compressions land in ONE table, whose blowup-2 LDE - // is a single ~102 GB allocation; `LFM_BLAKE3_MAX_CHUNK_ROWS_LOG2=k` splits - // it into 2^k-row tables. Applied HERE and nowhere else: the wraps are cached - // artifacts at the census point, and re-chunking them would invalidate them. - // The chunk shape is bound into `program_id`, so the aggregation identity - // moves with the knob — which is fine, and is what the consumer contract - // pins. - let blake3_chunking = super::chunking::Blake3Chunking::from_env(); - let mut program = aggregator_program( - &wraps, - &layouts, - &labels, - &global_wrap, - &BlockContext { - num_l2g: n, - pages: pages.len(), - touched_pages: bundle.touched_pages(), - num_private_input_pages: bundle.num_private_pages(), - }, - ); - if let Some(chunking) = blake3_chunking { - program = program.with_blake3_chunking(chunking); - println!( - " aggregation LFM_BLAKE3 chunking: {} compressions/chunk -> {} chunks of {:?} rows \ - ({} compressions) ({})", - chunking.compressions_per_chunk(), - program.blake3_chunk_count(), - super::airs::blake3_chunk_rows(&program), - program.groups.blake3.real_rows, - super::chunking::BLAKE3_MAX_CHUNK_ROWS_LOG2_ENV, - ); - } - let program = program; - let mut arenas: Vec> = wraps.iter().flat_map(leg_arena_words).collect(); - arenas.extend(leg_arena_words(&global_wrap)); - let f = FixtureAggregate { - wraps, - layouts, - labels, - global_wrap, - elf_digest, - pc_start: elf.entry_point, - decode_root, - pages: pages.clone(), - touched: bundle.touched_pages().to_vec(), - npriv: bundle.num_private_pages(), - }; - arenas.push(attestation_arena_words(&f)); - println!( - " aggregation program: {} instructions, emitted in {:.1}s", - program.instrs.len(), - t.elapsed().as_secs_f64() - ); - - // The TERMINAL layer's own options, decoupled from the wrap layer's: the - // wraps and the aggregation PROGRAM keep Design A's blowup4/110q (the - // census point — cached wrap proofs stay valid), while the aggregation - // prove itself may take a smaller blowup. P3_AGG_TERMINAL_BLOWUP=2 halves - // every LDE term — the 483 GiB box OOM-killed three straight attempts at - // blowup 4 (P3-OOM-REPORT.md). The query count re-derives from the same - // 128-bit Johnson target by construction (`with_blowup`), so 2 -> 219 q; - // the FRI terminal stays at the aggregation preset's fp8. - let terminal_opts = match std::env::var("P3_AGG_TERMINAL_BLOWUP") { - Ok(b) => { - let blowup: u8 = b - .parse() - .expect("P3_AGG_TERMINAL_BLOWUP must be a power-of-two u8"); - let mut o = stark::proof::options::GoldilocksCubicProofOptions::with_blowup(blowup) - .expect("P3_AGG_TERMINAL_BLOWUP must be a valid blowup"); - o.fri_final_poly_log_degree = agg_opts.fri_final_poly_log_degree; - println!( - " aggregation TERMINAL options: blowup {} / {} q / fp{} (P3_AGG_TERMINAL_BLOWUP)", - o.blowup_factor, o.fri_number_of_queries, o.fri_final_poly_log_degree - ); - o - } - Err(_) => agg_opts.clone(), - }; - let t = Instant::now(); - let agg_artifacts = - build_artifacts_with_hasher(&program, &terminal_opts, crate::hash_pin::BLOCK_HASHER); - println!( - " aggregation artifacts built in {:.1}s", - t.elapsed().as_secs_f64() - ); - // The aggregation prove's own residency posture, decoupled from the wrap - // proves': P3_AGG_RESIDENCY=recompute trades ~2× prove time for the LDE - // peak (the first real-scale Retain attempt OOM-killed a 483 GiB box). - // env::set_var is process-global and this driver is single-threaded by - // contract (--test-threads=1); unsafe per the 2024 edition's signature. - if let Ok(residency) = std::env::var("P3_AGG_RESIDENCY") { - println!(" aggregation residency: {residency} (P3_AGG_RESIDENCY)"); - unsafe { std::env::set_var("LAMBDA_VM_RESIDENCY", residency) }; - } - let agg_artifacts_ = &agg_artifacts; - let t = Instant::now(); - let final_proof = lfm_prove_batched(&program, agg_artifacts_, &arenas, &terminal_opts) - .expect("★ THE AGGREGATION MUST PROVE"); - let agg_prove_s = t.elapsed().as_secs_f64(); - let final_proof_bytes = rkyv::to_bytes::(&final_proof) - .expect("the block proof must serialize"); - let final_bytes = final_proof_bytes.len(); - // THE deliverable persists: run 4 proved the block and saved nothing - // but a byte count. Same cache dir as the inputs; the record run's - // proof is the artifact of record. - if let Some(path) = cache_path("block_proof.rkyv") { - std::fs::write(&path, &final_proof_bytes).expect("the block proof must persist"); - println!(" block proof persisted: {}", path.display()); - } - println!( - " ★ AGGREGATION PROVE: {agg_prove_s:.1}s, THE BLOCK PROOF = {final_bytes} bytes, \ - peak RSS {:?} GiB", - super::wrap_tests::peak_rss_gib(), - ); - - // ---- verification + THE CONSUMER RITUAL ---- - let t = Instant::now(); - assert!( - verify_against_batched( - &agg_artifacts, - &final_proof.proof, - &final_proof.public_words, - &terminal_opts - ), - "the block proof must verify against the pinned aggregator identity" - ); - let verify_s = t.elapsed().as_secs_f64(); - let t = Instant::now(); - let expected = crate::recursion::program_id_from_digest( - &elf_digest, - elf.entry_point, - &decode_root, - &pages, - ); - for w in 0..2 { - let got = final_proof.public_words[w].1; - let want: Vec = expected[16 * w..16 * (w + 1)] - .chunks(4) - .map(|c| FE::from(u32::from_le_bytes(c.try_into().expect("4 bytes")) as u64)) - .collect(); - assert_eq!( - got.to_vec(), - want, - "★ THE CONSUMER RITUAL: the published id must equal the recompute" - ); - } - let ritual_s = t.elapsed().as_secs_f64(); - println!( - " verify {verify_s:.2}s; consumer ritual (expected-id recompute + compare) {ritual_s:.2}s" - ); - println!( - "★★★ ONE PROOF FOR THE BLOCK: {final_bytes} bytes, total wall {:.1}s ({:.1} min), \ - peak RSS {:?} GiB — {} published words", - t_total.elapsed().as_secs_f64(), - t_total.elapsed().as_secs_f64() / 60.0, - super::wrap_tests::peak_rss_gib(), - final_proof.public_words.len(), - ); -} diff --git a/prover/src/lfm/algebraic_commit.rs b/prover/src/lfm/algebraic_commit.rs index 7630183ae..7c702390a 100644 --- a/prover/src/lfm/algebraic_commit.rs +++ b/prover/src/lfm/algebraic_commit.rs @@ -899,184 +899,6 @@ mod tests { assert_eq!(unique.len(), tags.len(), "every commitment tag is distinct"); } - /// ★★ **THE MIXED-GROUP LEAF GATE** — the one construction on the wrap - /// path that had no differential covering it. - /// - /// `the_emitted_leaf_and_parent_equal_the_host_backend` covers the leaf and - /// parent PRIMITIVES; this covers their COMPOSITION over a height group's - /// several matrices, which is a different claim. A leaf hash that is right - /// for one matrix can still be fed the wrong felts, in the wrong order, or - /// with the wrong padding flag, when several matrices are concatenated. - /// - /// ⚠ **The expectation is the host's own backend, not a reimplementation of - /// it.** `hash_data` over the concatenated values IS - /// `mmcs.rs::hash_group_openings` — for each matrix, all `evaluations` then - /// all `evaluations_sym`, flat, one hash — so a shared misunderstanding - /// between the two sides cannot make this pass. That is what A2 established - /// as the difference between a differential and a tautology. - /// - /// ⚠ The shapes vary on the axes that could hide a break rather than on one - /// convenient instance: several matrices of differing widths, the felt count - /// straddling the rate boundary in BOTH directions and landing on it exactly - /// (the padding flag `len mod 8` is the one part of the duplex that is not - /// identical on every block), and the single-matrix degenerate case. - /// - /// Groups are homogeneous in field, matching production: a round is base - /// (main) or extension (aux, parts), and the round is what groups by height. - #[test] - fn the_mixed_group_leaf_equals_the_hosts_group_hash() { - use crate::lfm::batched_epoch_verify::{MixedMatrixOpening, emit_group_leaf_hash}; - use crate::lfm::builder::{Cell, LfmBuilder}; - use crate::lfm::compiler::compile; - use crate::lfm::edsl::WrapHash; - use crate::lfm::proof::lfm_prove_with_hasher; - use crate::lfm::registry::build_artifacts_with_hasher; - use crate::lfm::sub_proof::GroupShape; - use crate::lfm::word::{base_word, ext_word}; - use stark::proof::options::GoldilocksCubicProofOptions; - - // (name, widths). A group's felt count is `2 · Σwidth` for a base group - // and `6 · Σwidth` for an extension one — RATE_FELTS is 8, so these - // straddle it in both directions and land on it exactly. - let base_cases: [(&str, &[usize]); 6] = [ - ("base, single matrix, degenerate", &[1]), // 2 felts - ("base, under the rate", &[3]), // 6 - ("base, exactly one rate block", &[4]), // 8 - ("base, one over the rate", &[1, 4]), // 10 - ("base, differing widths", &[1, 3, 2]), // 12 - ("base, several blocks", &[5, 2, 4, 3]), // 28 - ]; - let ext_cases: [(&str, &[usize]); 4] = [ - ("ext, single matrix, degenerate", &[1]), // 6 felts - ("ext, straddling the rate", &[2]), // 12 - ("ext, differing widths", &[1, 2]), // 18 - ("ext, several blocks", &[3, 1, 2]), // 36 - ]; - - fn widths_to_shapes(widths: &[usize], is_ext: bool) -> Vec { - widths - .iter() - .map(|&num_columns| GroupShape { - num_columns, - is_ext, - }) - .collect() - } - - // Distinct, non-trivial values, so a dropped or reordered element moves - // the digest rather than colliding with its neighbour. - let base_at = |i: usize| FE::from(0x51ED_2C7B_0000_0001u64 + i as u64 * 0x9E37_79B9); - let ext_at = |i: usize| FEE::new([base_at(3 * i), base_at(3 * i + 1), base_at(3 * i + 2)]); - - for hasher in [HasherKind::Rpo, HasherKind::Rpx, HasherKind::Poseidon] { - let opts = GoldilocksCubicProofOptions::with_blowup(2).expect("options"); - - for (name, widths) in base_cases.iter().copied() { - let shapes = widths_to_shapes(widths, false); - let counts: Vec = shapes.iter().map(GroupShape::num_values).collect(); - let total: usize = counts.iter().sum(); - - // HOST: `hash_group_openings`' buffer is every matrix's values - // concatenated in round input order, hashed once. - let values: Vec = (0..total).map(base_at).collect(); - let want = match hasher { - HasherKind::Rpo => as IsMerkleTreeBackend>::hash_data(&values), - HasherKind::Rpx => as IsMerkleTreeBackend>::hash_data(&values), - _ => as IsMerkleTreeBackend>::hash_data(&values), - }; - - // MACHINE: one arena word per value, grouped back into matrices. - let mut b = LfmBuilder::new().with_wrap_hash(WrapHash::Algebraic); - let arena = b.declare_arena(total as u32); - let cells: Vec = (0..total).map(|i| b.hint_word(arena, i as u32)).collect(); - let mut cursor = 0usize; - let per_matrix: Vec> = counts - .iter() - .map(|&n| { - let slice = cells[cursor..cursor + n].to_vec(); - cursor += n; - slice - }) - .collect(); - let openings: Vec> = shapes - .iter() - .zip(per_matrix.iter()) - .map(|(shape, vals)| MixedMatrixOpening { - shape: *shape, - log_height: 10, - values: vals, - }) - .collect(); - let refs: Vec<&MixedMatrixOpening<'_>> = openings.iter().collect(); - let d = emit_group_leaf_hash(&mut b, &refs); - assert_eq!(d.len(), 1, "{name}: an algebraic digest is ONE cell"); - b.public(d[0]); - let program = compile(b.finish()); - - let arena_words: Vec = values.iter().map(|v| base_word(*v)).collect(); - let artifacts = build_artifacts_with_hasher(&program, &opts, hasher); - let proved = - lfm_prove_with_hasher(&program, &artifacts, &[arena_words], &opts, hasher) - .expect("the group-leaf program must prove"); - assert_eq!( - digest_to_commitment(&proved.public_words[0].1), - want, - "{hasher:?} / {name}: the emitted group leaf must be the host's" - ); - } - - for (name, widths) in ext_cases.iter().copied() { - let shapes = widths_to_shapes(widths, true); - let counts: Vec = shapes.iter().map(GroupShape::num_values).collect(); - let total: usize = counts.iter().sum(); - - let values: Vec = (0..total).map(ext_at).collect(); - let want = match hasher { - HasherKind::Rpo => as IsMerkleTreeBackend>::hash_data(&values), - HasherKind::Rpx => as IsMerkleTreeBackend>::hash_data(&values), - _ => as IsMerkleTreeBackend>::hash_data(&values), - }; - - let mut b = LfmBuilder::new().with_wrap_hash(WrapHash::Algebraic); - let arena = b.declare_arena(total as u32); - let cells: Vec = (0..total).map(|i| b.hint_word(arena, i as u32)).collect(); - let mut cursor = 0usize; - let per_matrix: Vec> = counts - .iter() - .map(|&n| { - let slice = cells[cursor..cursor + n].to_vec(); - cursor += n; - slice - }) - .collect(); - let openings: Vec> = shapes - .iter() - .zip(per_matrix.iter()) - .map(|(shape, vals)| MixedMatrixOpening { - shape: *shape, - log_height: 10, - values: vals, - }) - .collect(); - let refs: Vec<&MixedMatrixOpening<'_>> = openings.iter().collect(); - let d = emit_group_leaf_hash(&mut b, &refs); - b.public(d[0]); - let program = compile(b.finish()); - - let arena_words: Vec = values.iter().map(ext_word).collect(); - let artifacts = build_artifacts_with_hasher(&program, &opts, hasher); - let proved = - lfm_prove_with_hasher(&program, &artifacts, &[arena_words], &opts, hasher) - .expect("the group-leaf program must prove"); - assert_eq!( - digest_to_commitment(&proved.public_words[0].1), - want, - "{hasher:?} / {name}: the emitted group leaf must be the host's" - ); - } - } - } - // ---- the grinding leg: shared fixtures ---- /// The factor the leg tests grind at: small enough to grind in a unit @@ -1502,8 +1324,8 @@ mod tests { // ★ EXTENSION leaves — the decomposition the call sites rely on. // - // `sub_proof::emit_leaf_hash` and `batched_epoch_verify` absorb an - // Fp3 value as `unpack(cell)[..3]`, deleting the byte serialisation. + // `sub_proof::emit_leaf_hash` absorbs an Fp3 value as + // `unpack(cell)[..3]`, deleting the byte serialisation. // That is correct only if the host's own decomposition agrees: // `write_bytes_be` for an Fp3 element writes components 0, 1, 2 in // order. Verified by reading, and gated here so it stays true. diff --git a/prover/src/lfm/algebraic_transcript.rs b/prover/src/lfm/algebraic_transcript.rs index 6a790ca70..4fbaba127 100644 --- a/prover/src/lfm/algebraic_transcript.rs +++ b/prover/src/lfm/algebraic_transcript.rs @@ -557,23 +557,17 @@ mod tests { /// ★★★ **THE PHASE A GATE** — the absorbs between the statement and the /// first challenge, which is where the spine's `z` diverges. /// - /// `the_batched_epoch_challenge_spine_matches_production` under an algebraic - /// pin executes cleanly and then disagrees about the shared LogUp `z`. That - /// challenge is drawn after exactly two things: the statement absorb, which - /// its own gate covers, and Phase A. This is Phase A, beside the spine - /// rather than instrumented inside it — an instrument inside it perturbs the - /// program and produced a `DivByZero` of its own when tried. + /// The shared LogUp `z` is drawn after exactly two things: the statement + /// absorb, which its own gate covers, and Phase A. This is Phase A, beside + /// the spine rather than instrumented inside it — an instrument inside it + /// perturbs the program and produced a `DivByZero` of its own when tried. /// - /// The host side is `crypto/stark/src/batched/verifier.rs:127-151` driven - /// through its OWN `absorb_shape_histogram`, not a restatement of it: the - /// histogram, then every preprocessed root from the AIR set, then the carved - /// root when the shape has one, then the single batched main root, then the - /// pair. Synthetic roots, because what is under test is the SEQUENCE and the - /// encoding, and controlling both sides is what makes a disagreement - /// attributable. + /// The sequence is every preprocessed root from the AIR set, then the main + /// root, then the pair. Synthetic roots, because what is under test is the + /// SEQUENCE and the ENCODING, and controlling both sides is what makes a + /// disagreement attributable. #[test] fn phase_a_absorbs_derive_the_hosts_shared_pair() { - use crate::lfm::batched_epoch::emit_shape_histogram; use crate::lfm::builder::LfmBuilder; use crate::lfm::compiler::compile; use crate::lfm::edsl::WrapHash; @@ -581,12 +575,7 @@ mod tests { use crate::lfm::proof::lfm_prove_with_hasher; use crate::lfm::registry::build_artifacts_with_hasher; use crate::lfm::transcript_replay::TranscriptReplay; - use stark::fri::batched::absorb_shape_histogram; - // A histogram with repeated and distinct heights, and widths that are - // not a function of them — a transposed pair has to move the transcript. - let heights: Vec = vec![10, 10, 8, 8, 5]; - let widths: Vec = vec![4, 7, 2, 3, 1]; // Two preprocessed roots and one main root, distinct and non-canonical // in their high bytes so a reduction would show. let root_at = @@ -595,24 +584,22 @@ mod tests { let main = root_at(0x33); for hasher in ALGEBRAIC { - // HOST: production's own histogram helper, then the roots. + // HOST: the roots, through the production transcript object. let mut host = AlgebraicTranscript::with_seed(hasher, SEED); - absorb_shape_histogram::(&mut host, &heights, &widths); for p in &preps { host.append_bytes(p); } host.append_bytes(&main); let want = host.sample_field_element(); - // MACHINE: the emitter's own histogram, then the roots as program - // constants — `RootCells::constant`'s provenance, which is what a - // preprocessed root from the AIR set is. + // MACHINE: the roots as program constants — + // `RootCells::constant`'s provenance, which is what a preprocessed + // root from the AIR set is. let mut b = LfmBuilder::new().with_wrap_hash(WrapHash::Algebraic); let mut t = TranscriptReplay::new(SEED); - emit_shape_histogram(&mut t, &heights, &widths); // ⚠ BOTH production constructions, on the same host call. A - // preprocessed root reaches the transcript one of two ways - // (`batched_epoch.rs:338-341`): program TEXT goes through + // preprocessed root reaches the transcript one of two ways: program + // TEXT goes through // `append_const_bytes` as literal bytes, proof-carried cells through // `RootCells::absorb`. Under a byte hash those are the same 32 // bytes; under an algebraic one they are a byte cellification and a @@ -636,8 +623,8 @@ mod tests { assert_eq!( [got[0], got[1], got[2]], *want.value(), - "{hasher:?}: Phase A must derive the host's shared pair — the histogram's \ - 1 + 2n calls, then one call per root, then the pair" + "{hasher:?}: Phase A must derive the host's shared pair — one call per \ + root, then the pair" ); } } diff --git a/prover/src/lfm/blake3_chip_tests.rs b/prover/src/lfm/blake3_chip_tests.rs index 8f639f4e0..726b6ac88 100644 --- a/prover/src/lfm/blake3_chip_tests.rs +++ b/prover/src/lfm/blake3_chip_tests.rs @@ -1519,10 +1519,9 @@ fn both_blake3_surfaces_in_one_machine_balance_bitwise() { // One row per compression at 3,056 value columns, so the chip's matrix is WIDE: // the aggregation program's ~1.39M compressions are a 2^21 x 3,056 table whose // blowup-2 LDE is a single ~102 GB allocation. These tests cover the split — the -// shape it produces, that a multi-chunk program proves and verifies on both the -// per-table and the batched path, that it proves the SAME thing, and the two -// ways the split itself can be wrong (a corrupted non-first chunk, a chunk count -// that does not match the proof). +// shape it produces, that a multi-chunk program proves and verifies, that it +// proves the SAME thing, and the two ways the split itself can be wrong (a +// corrupted non-first chunk, a chunk count that does not match the proof). use super::chunking::Blake3Chunking; @@ -1646,11 +1645,10 @@ fn the_blake3_chunk_arithmetic_is_the_group_split() { } /// ★ The acceptance test: a program needing three `LFM_BLAKE3` chunks proves and -/// verifies COMPLETELY, on both the per-table and the batched path, and its -/// digest is still the host chain's. +/// verifies COMPLETELY, and its digest is still the host chain's. #[test] fn chunked_blake3_proves_and_verifies() { - use super::proof::{lfm_prove_batched, verify_against_artifacts, verify_against_batched}; + use super::proof::verify_against_artifacts; let opts = options(); let msg = message(CHUNKED_CHAIN_LEN); @@ -1673,21 +1671,6 @@ fn chunked_blake3_proves_and_verifies() { verify_against_artifacts(&artifacts, &proved.proof, &proved.public_words, &opts), "a three-chunk LFM_BLAKE3 proof must verify" ); - - // The batched path is the one the aggregation layer proves on, so it is the - // one that has to carry chunking; verifying only the per-table path would - // leave the real consumer untested. - let batched = lfm_prove_batched(&program, &artifacts, &sponge_arenas(&msg), &opts) - .expect("the chunked program must prove batched"); - assert_eq!( - digest_bytes(&batched.public_words), - blake3_chain(&msg), - "the batched chunked proof must hash the same" - ); - assert!( - verify_against_batched(&artifacts, &batched.proof, &batched.public_words, &opts), - "a three-chunk batched proof must verify completely" - ); } /// ★ Chunking is a prover-side layout choice, not a semantic one: the same diff --git a/prover/src/lfm/epoch_tests.rs b/prover/src/lfm/epoch_tests.rs index ae5aaec16..539fda391 100644 --- a/prover/src/lfm/epoch_tests.rs +++ b/prover/src/lfm/epoch_tests.rs @@ -25,9 +25,6 @@ //! [`the_legs_consume_the_replayed_challenges`]'s job, and the whole-epoch //! composition (25 sub-proofs behind one statement) is not built here. -use stark::batched::proof::{BatchedMultiProof, BatchedProveStats}; -use stark::batched::shape::{EpochFriParams, EpochShape}; -use stark::batched::verifier::EpochChallenges; use stark::config::Commitment; use stark::proof::stark::MultiProof; use stark::proof::view::StarkProofView; @@ -722,125 +719,15 @@ impl EpochInputs { /// is the fibonacci fixture unless a measurement run overrode it — so two runs /// at different options stay comparable, and assembly ledger entry 10 still /// holds: the trace-length profile travels with every number. -/// ★ THE BASE-LAYER A/B — the real block's epoch 0 proved per-table vs -/// BATCHED-MMCS, one arm per process. -/// -/// `AB_MODE` selects the arm (`per_table` | `batched`); `LAMBDA_VM_RESIDENCY` -/// moves BOTH arms through the same lever, so a residency difference between -/// them cannot be an artifact of two code paths reading two knobs. Peak anon -/// is a process-lifetime high-water mark, measured by the harness around the -/// process — two arms sharing a process would each report the larger of the -/// two and the comparison would be vacuous. -/// -/// The epoch construction IS the census harness's ([`EpochFront`], the same -/// call [`real_epoch_from`] makes): same executor slice, same traces, same -/// L2G bookend, same statement-seeded transcript. Only the prove call -/// differs. -/// -/// ⚠ NEITHER arm verifies here, deliberately. Each arm's construction is -/// production-accepted by its own gate elsewhere — the per-table one every -/// time `the_real_block_epoch_wraps` runs, the batched one by -/// [`a_batched_vm_epoch_host_verifies_end_to_end`] (per-table preprocessed -/// binding made `multi_verify_batched` a complete verification for the VM -/// AIR set too) — and a verify inside a memory instrument would smear its -/// own footprint over the number being measured. This instrument measures -/// the PROVE. -#[test] -#[ignore] -fn the_real_block_base_epoch_ab() { - use stark::prover::IsStarkProver; - - for var in ["LFM_CENSUS_ELF", "LFM_CENSUS_INPUT"] { - assert!( - std::env::var(var).is_ok(), - "{var} must name a file: this A/B measures a REAL block epoch" - ); - } - let mode = std::env::var("AB_MODE").expect("AB_MODE must be per_table or batched"); - let residency = match std::env::var("LAMBDA_VM_RESIDENCY").as_deref() { - Ok("recompute") => stark::residency_mode::ResidencyMode::RecomputeLde, - _ => stark::residency_mode::ResidencyMode::Retain, - }; - - let inputs = EpochInputs::from_env(); - let opts = crate::recursion::Preset::Blowup4.options(); - let mut inner = opts; - if let Ok(v) = std::env::var("LFM_WRAP_QUERIES") { - inner.fri_number_of_queries = v.parse().expect("LFM_WRAP_QUERIES must be an integer"); - } - let opts = inner; - println!( - "★ BASE A/B ARM: mode={mode} residency={residency:?} guest {}, \ - 2^{} cycles/epoch, blowup {} / {} queries", - inputs.label, inputs.epoch_log2, opts.blowup_factor, opts.fri_number_of_queries, - ); - - let mut front = EpochFront::build(opts, inputs); - let mut transcript = front.seed(); - let pairs = front.pairs(); - - // ---- THE MEASURED PROVE. Everything above is identical shared setup. - let t = std::time::Instant::now(); - match mode.as_str() { - "per_table" => { - let proof = crate::hash_pin::BlockProver::::multi_prove( - pairs, - &mut transcript, - #[cfg(feature = "disk-spill")] - stark::storage_mode::StorageMode::Ram, - residency, - ) - .expect("the epoch must prove"); - let prove_secs = t.elapsed().as_secs_f64(); - let size = rkyv::to_bytes::(&proof) - .expect("the epoch proof must serialize") - .len(); - println!( - "★ BASE A/B RESULT mode=per_table PROVE_SECS={prove_secs:.2} \ - SUB_PROOFS={} PROOF_BYTES={size}", - proof.proofs.len(), - ); - } - "batched" => { - let (proof, stats) = stark::batched::prover::multi_prove_batched::< - Gl, - Ext3, - (), - crate::hash_pin::BlockStarkHash, - crate::hash_pin::BlockProver, - >( - pairs, - &mut transcript, - #[cfg(feature = "disk-spill")] - stark::storage_mode::StorageMode::Ram, - residency, - ) - .expect("the batched epoch must prove"); - let prove_secs = t.elapsed().as_secs_f64(); - println!( - "★ BASE A/B RESULT mode=batched PROVE_SECS={prove_secs:.2} \ - TABLES={} QUERIES={} FRI_LAYERS={} PREP_TABLES={}", - proof.tables.len(), - proof.queries.len(), - proof.fri_layer_roots.len(), - proof.queries.first().map_or(0, |q| q.prep.len()), - ); - println!(" BATCHED_STATS {stats:?}"); - } - other => panic!("AB_MODE must be per_table or batched, not {other}"), - } -} - pub(super) fn real_epoch_with(opts: crate::ProofOptions) -> RealEpoch { real_epoch_from(opts, EpochInputs::from_env()) } /// The statement-seeded transcript every prover and every verifier of one -/// epoch starts from. One function rather than per-harness closures so the -/// per-table and batched harnesses CANNOT drift on the absorb — a drift here -/// would fail neither harness's own gate; it would just make their proofs -/// answer different statements, which is exactly the failure a per-table vs -/// batched comparison cannot detect from inside. +/// epoch starts from. One function rather than per-harness closures so no two +/// harnesses can drift on the absorb — a drift here would fail neither +/// harness's own gate; it would just make their proofs answer different +/// statements, which is a failure no harness can detect from inside. pub(super) fn epoch_seed( epoch_label: u64, elf_bytes: &[u8], @@ -863,13 +750,11 @@ pub(super) fn epoch_seed( t } -/// The census-env epoch-0 construction, shared BY STRUCTURE between the -/// per-table harness ([`real_epoch_from`]), the base-layer A/B and the -/// batched harness ([`real_batched_epoch_from`]): same executor slice, same -/// traces, same L2G bookend, same statement-seeded transcript. The premise of -/// every per-table vs batched comparison this file hosts is "same epoch, -/// different prove", and sharing this front is what makes the premise -/// structural rather than by-inspection. +/// The census-env epoch-0 construction the per-table harness +/// ([`real_epoch_from`]) is built on: same executor slice, same traces, same +/// L2G bookend, same statement-seeded transcript. Sharing this front is what +/// makes "same epoch, different prove" structural rather than by-inspection +/// for any two runs this file hosts. /// /// Only epoch 0 is built: the boundary starts from genesis provenance and the /// label is `epoch_label(0)`, so a later epoch would need the previous one's @@ -1495,2012 +1380,6 @@ fn the_from_proof_constructor_rejects_a_tampered_bundle() { ); } -/// The batched-path analogue of [`RealEpoch`] — the host half of the M-8 -/// full-recursion campaign: the SAME construction ([`EpochFront`]), proved -/// through `multi_prove_batched`, host-verified COMPLETELY before anything -/// downstream reads it. -/// -/// The struct keeps the AIR set alive because every verification — -/// the constructor's gate and every tamper arm — rebuilds `refs()` from it, -/// and T2's shape derivations read program shape from the AIRs rather than -/// the proof. The traces are dropped at the end of construction: holders pay -/// for the proof, not the epoch's tables. -// The fields nothing reads yet are the emitter's contract (handoff T2): the -// spine reads statement/challenges/prep provenance/register files, the legs -// read shape/fri_params, the census reads prove_stats. Removing one because -// it is currently unread would just re-derive it worse there. -#[allow(dead_code)] -pub(super) struct RealBatchedEpoch { - pub(super) opts: crate::ProofOptions, - pub(super) statement: super::statement_replay::EpochStatementShape, - pub(super) elf_bytes: Vec, - pub(super) elf_digest: [u8; 32], - pub(super) public_output: Vec, - pub(super) epoch_label: u64, - pub(super) table_counts: crate::TableCounts, - pub(super) runtime_page_ranges: Vec, - airs: crate::VmAirs, - l2g_air: Box>, - pub(super) register_init: Vec, - pub(super) reg_fini: Vec, - pub(super) pc_start: u64, - pub(super) expected_program_id: [u8; 32], - /// Per table in sub-proof order: preprocessed provenance when the AIR is - /// preprocessed — [`prep_source`]'s taxonomy, unchanged, because the - /// batched proof binds the same per-table roots the per-table path does, - /// so the wrap's binding machinery is the existing one. There are no - /// per-table main roots to pair these with: the shared mixed roots live - /// on the proof itself. - pub(super) prep_sources: Vec>, - pub(super) proof: BatchedMultiProof, - pub(super) shape: EpochShape, - pub(super) fri_params: EpochFriParams, - /// Every challenge the batched transcript derives, recovered through - /// `replay_epoch_transcript` — the oracle T2's emitted spine - /// differentials against. - pub(super) challenges: EpochChallenges, - /// The carried commit index — `reg_init[X254_INDEX]`, same meaning as - /// [`RealEpoch::start_index`]. - pub(super) start_index: u64, - /// The COMMIT-bus target derived from the REPLAYED shared pair, exactly - /// as `verify_against_batched` derives it: the batched path has no - /// per-table Phase A to walk. - pub(super) expected_bus_balance: FEE, - pub(super) prove_stats: BatchedProveStats, -} - -impl RealBatchedEpoch { - /// [`epoch_seed`] over this epoch's statement, with the claimed output - /// substitutable so a tamper arm can ask the question it means: "does - /// THIS proof answer for THAT output?". - fn seed_for(&self, public_output: &[u8]) -> crate::hash_pin::BlockTranscript { - epoch_seed( - self.epoch_label, - &self.elf_bytes, - public_output, - &self.table_counts, - &self.runtime_page_ranges, - self.opts.fri_final_poly_log_degree, - ) - } - - /// The AIR set in sub-proof order — the VM tables then the L2G bookend, - /// the same order [`EpochFront::pairs`] proved in. - pub(super) fn refs( - &self, - ) -> Vec<&dyn AIR> { - let mut r = self.airs.air_refs(); - r.push(&*self.l2g_air); - r - } - - /// The COMPLETE host verification of `proof` against this epoch's AIR - /// set and the given claimed output, mirroring `verify_against_batched`: - /// the challenges replayed on a fork of the statement seed, the expected - /// COMMIT-bus balance from the replayed shared pair, then - /// `multi_verify_batched`. `false` on any tamper; never panics on proof - /// data. - pub(super) fn host_verifies_for( - &self, - proof: &BatchedMultiProof, - claimed_output: &[u8], - ) -> bool { - // One code path for both formats: the carve configuration rides in - // the shape this epoch was replayed under (None ≡ uncarved). - let carved = self.shape.carved_main.map(|c| c.table); - let refs = self.refs(); - let mut replay = self.seed_for(claimed_output); - let Some((_, _, challenges)) = stark::batched::verifier::replay_epoch_transcript_carved( - &refs, - proof, - &mut replay, - carved, - ) else { - return false; - }; - let [z, alpha] = challenges.lookup.as_slice() else { - return false; - }; - let Some(expected) = - crate::compute_commit_bus_offset(claimed_output, self.start_index, z, alpha) - else { - return false; - }; - let mut transcript = self.seed_for(claimed_output); - stark::batched::verifier::multi_verify_batched_carved::< - Gl, - Ext3, - (), - crate::hash_pin::BlockStarkHash, - crate::hash_pin::BlockVerifier, - _, - >(&refs, proof, &mut transcript, &expected, carved) - } - - /// [`RealBatchedEpoch::host_verifies_for`] at this epoch's own output. - pub(super) fn host_verifies(&self, proof: &BatchedMultiProof) -> bool { - self.host_verifies_for(proof, &self.public_output) - } -} - -pub(super) fn real_batched_epoch_with(opts: crate::ProofOptions) -> RealBatchedEpoch { - real_batched_epoch_from(opts, EpochInputs::from_env()) -} - -/// [`real_epoch_from`]'s batched sibling. Panics — loudly, this is a harness -/// — if the proof does not host-verify: nothing downstream may read an epoch -/// production would reject. -pub(super) fn real_batched_epoch_from( - opts: crate::ProofOptions, - inputs: EpochInputs, -) -> RealBatchedEpoch { - real_batched_epoch_from_with_carve(opts, inputs, false) -} - -/// [`real_batched_epoch_from`] with the L2G table CARVED — the continuation -/// batched format ([`crate::continuation::prove_continuation_batched`]'s -/// per-epoch shape), for the carved emitter gates. -pub(super) fn real_batched_epoch_carved_from( - opts: crate::ProofOptions, - inputs: EpochInputs, -) -> RealBatchedEpoch { - real_batched_epoch_from_with_carve(opts, inputs, true) -} - -fn real_batched_epoch_from_with_carve( - opts: crate::ProofOptions, - inputs: EpochInputs, - carve_l2g: bool, -) -> RealBatchedEpoch { - let mut front = EpochFront::build(opts, inputs); - - let (proof, prove_stats, carved_index) = { - let mut transcript = front.seed(); - let t = std::time::Instant::now(); - let pairs = front.pairs(); - // The L2G bookend is the LAST pair — the carved table, when carving. - let carved_index = carve_l2g.then(|| pairs.len() - 1); - let (proof, stats) = stark::batched::prover::multi_prove_batched_carved::< - Gl, - Ext3, - (), - crate::hash_pin::BlockStarkHash, - crate::hash_pin::BlockProver, - >( - pairs, - &mut transcript, - #[cfg(feature = "disk-spill")] - stark::storage_mode::StorageMode::Ram, - stark::residency_mode::ResidencyMode::Retain, - carved_index, - ) - .expect("the batched epoch must prove"); - eprintln!( - "batched inner epoch: {}, 2^{} cycles, {} cycles executed, \ - {} tables, proved in {:.1}s", - front.guest_label, - front.epoch_log2, - front.cycles_executed, - proof.tables.len(), - t.elapsed().as_secs_f64() - ); - (proof, stats, carved_index) - }; - - // The traces fed the prove; drop them here — what follows reads the PROOF. - let EpochFront { - opts, - elf_bytes, - elf, - airs, - l2g_air, - register_init, - reg_fini, - table_counts, - public_output, - runtime_page_ranges, - label, - decode_root, - .. - } = front; - - harvest_real_batched_epoch( - opts, - elf_bytes, - &elf, - airs, - l2g_air, - register_init, - reg_fini, - table_counts, - public_output, - runtime_page_ranges, - label, - decode_root, - proof, - carved_index, - prove_stats, - ) - .expect("the session-built batched epoch must harvest") -} - -/// Everything downstream of a batched epoch's PROOF: the replay, the COMMIT -/// target, the preprocessed provenances, the statement shape — and the -/// complete host verification as the acceptance gate. Shared by the session -/// harness above and the from-continuation constructor below, so the two -/// reconstructions cannot diverge (the P1 discipline, batched). -#[allow(clippy::too_many_arguments)] -fn harvest_real_batched_epoch( - opts: crate::ProofOptions, - elf_bytes: Vec, - elf: &executor::elf::Elf, - airs: crate::VmAirs, - l2g_air: Box>, - register_init: Vec, - reg_fini: Vec, - table_counts: crate::TableCounts, - public_output: Vec, - runtime_page_ranges: Vec, - label: u64, - decode_root: Commitment, - proof: BatchedMultiProof, - carved_index: Option, - prove_stats: BatchedProveStats, -) -> Result { - use crate::tables::register; - - let refs = { - let mut r = airs.air_refs(); - r.push(&*l2g_air); - r - }; - - // ---- the replay: every challenge, and the shape both sides derive. - let mut replay = epoch_seed( - label, - &elf_bytes, - &public_output, - &table_counts, - &runtime_page_ranges, - opts.fri_final_poly_log_degree, - ); - let Some((shape, fri_params, challenges)) = - stark::batched::verifier::replay_epoch_transcript_carved( - &refs, - &proof, - &mut replay, - carved_index, - ) - else { - return Err("the batched epoch's transcript rejects: it does not replay".to_string()); - }; - let [z, alpha] = challenges.lookup.as_slice() else { - return Err("an epoch uses LogUp, so the shared pair must be exactly (z, α)".to_string()); - }; - let start_index = register_init[register::X254_INDEX] as u64; - let Some(expected) = crate::compute_commit_bus_offset(&public_output, start_index, z, alpha) - else { - return Err("the COMMIT bus target rejects: it does not compute".to_string()); - }; - - let prep_sources = refs - .iter() - .map(|air| { - air.is_preprocessed().then(|| { - prep_source( - air.precomputed_commitment(), - &opts, - elf, - ®ister_init, - ®_fini, - ) - }) - }) - .collect(); - drop(refs); - - let e = RealBatchedEpoch { - statement: super::statement_replay::EpochStatementShape { - public_output_len: public_output.len(), - table_counts: [ - table_counts.cpu as u64, - table_counts.lt as u64, - table_counts.memw as u64, - table_counts.memw_aligned as u64, - table_counts.load as u64, - table_counts.mul as u64, - table_counts.dvrm as u64, - table_counts.shift as u64, - table_counts.branch as u64, - table_counts.memw_register as u64, - table_counts.eq as u64, - table_counts.bytewise as u64, - table_counts.store as u64, - table_counts.cpu32 as u64, - table_counts.blake3 as u64, - ], - num_private_input_pages: 0, - fri_final_poly_log_degree: opts.fri_final_poly_log_degree, - page_ranges: runtime_page_ranges - .iter() - .map(|r| (r.base, r.count)) - .collect(), - }, - elf_digest: crate::statement::elf_digest(&elf_bytes), - expected_program_id: crate::recursion::program_id_from_digest( - &crate::statement::elf_digest(&elf_bytes), - elf.entry_point, - &decode_root, - &[], - ), - pc_start: elf.entry_point, - opts, - elf_bytes, - public_output, - epoch_label: label, - table_counts, - runtime_page_ranges, - airs, - l2g_air, - register_init, - reg_fini, - prep_sources, - proof, - shape, - fri_params, - challenges, - start_index, - expected_bus_balance: expected, - prove_stats, - }; - - // ---- production-shaped acceptance, or nothing above describes a real - // epoch. `host_verifies` is the complete check — the same derivation the - // per-table harness's `multi_verify_views` gate plays on its side; the - // tamper arms in `a_batched_vm_epoch_host_verifies_end_to_end` keep it - // discriminating. - if !e.host_verifies(&e.proof) { - return Err("production's batched verifier rejects this epoch".to_string()); - } - Ok(e) -} - -/// [`RealBatchedEpoch`] for epoch `epoch_index` of an EXISTING continuation -/// bundle proven BATCHED — the from-proof path, mirroring -/// [`real_epoch_from_continuation`]: the AIR set and statement values from -/// [`crate::continuation::reconstruct_epoch_airs`] (the SAME reconstruction -/// `verify_epoch` runs), the chain position from -/// [`crate::continuation::epoch_chain_position`], the harvest shared with the -/// session path. The epoch's proof is the bundle's batched body, its L2G main -/// matrix carved (always the LAST table); production's complete batched -/// verify inside the harvest is the acceptance gate. -pub(super) fn real_batched_epoch_from_continuation( - opts: &crate::ProofOptions, - elf_bytes: &[u8], - bundle: &crate::continuation::ContinuationProof, - epoch_index: usize, - decode_commitment: Option, -) -> Result { - use executor::elf::Elf; - - let elf = Elf::load(elf_bytes).map_err(|e| format!("the inner ELF must load: {e}"))?; - let position = crate::continuation::epoch_chain_position(bundle, &elf, epoch_index) - .map_err(|e| format!("chain position for epoch {epoch_index}: {e:?}"))? - .ok_or_else(|| format!("epoch {epoch_index} is out of range or the bundle is malformed"))?; - let view = bundle.epoch_view(epoch_index); - let recon = crate::continuation::reconstruct_epoch_airs( - &elf, - view, - &position.register_init, - position.is_final, - position.label, - opts, - decode_commitment, - ) - .map_err(|e| format!("reconstructing epoch {epoch_index}: {e:?}"))? - .ok_or_else(|| format!("epoch {epoch_index} is structurally invalid"))?; - let decode_root = match decode_commitment { - Some(c) => c, - None => crate::tables::decode::commitment_from_elf(&elf, opts) - .map_err(|e| format!("DECODE commitment from ELF: {e}"))?, - }; - let proof = view - .batched_proof() - .ok_or_else(|| { - format!( - "epoch {epoch_index} is per-table; the batched constructor reads batched bundles" - ) - })? - .materialize() - .map_err(|e| format!("materializing epoch {epoch_index}'s batched proof: {e:?}"))? - .into_owned(); - let carved_index = Some(proof.tables.len() - 1); - harvest_real_batched_epoch( - opts.clone(), - elf_bytes.to_vec(), - &elf, - recon.airs, - recon.l2g_air, - position.register_init, - recon.reg_fini, - recon.table_counts, - view.public_output().to_vec(), - recon.runtime_page_ranges, - position.label, - decode_root, - proof, - carved_index, - BatchedProveStats::default(), - ) -} - -/// ★ THE H2 GATE: the same construction the per-table harness proves is -/// proved through the BATCHED path and host-verified COMPLETELY — then -/// tampered, so acceptance is discrimination, not a verifier that stopped -/// checking. -/// -/// Arms: a preprocessed opening value (the per-table critical check, on the -/// batched proof's own per-query openings), a main-round mixed opening value -/// (the shared-tree authentication), and the claimed output (the statement + -/// COMMIT-bus binding; the fixture's output is empty, so the arm EXTENDS it -/// rather than moving a byte — length is bound either way). -#[test] -fn a_batched_vm_epoch_host_verifies_end_to_end() { - let e = real_batched_epoch_with(super::proof_fixture::fixture_options()); - - // The replayed challenge lists are per table in table order, and the - // proof's per-query preprocessed openings are one per preprocessed AIR — - // the alignments every T2 consumer will assume, asserted where the - // harness is built. - assert_eq!(e.challenges.betas.len(), e.proof.tables.len()); - assert_eq!(e.challenges.zs.len(), e.proof.tables.len()); - assert_eq!(e.challenges.deep_gammas.len(), e.proof.tables.len()); - let num_preprocessed = e.prep_sources.iter().filter(|s| s.is_some()).count(); - for q in &e.proof.queries { - assert_eq!(q.prep.len(), num_preprocessed); - } - - // The arena serializers fill EXACTLY what the shape-derived closed forms - // declare — the asserts live inside them; called here so the discipline - // gates on the same proof the tampers gate on. - let opening = super::epoch_verify_tests::batched_opening_arena(&e); - let fri = super::epoch_verify_tests::batched_fri_arena(&e); - assert!( - !opening.is_empty(), - "every epoch opens at least the main round" - ); - eprintln!( - "batched arenas: {} opening words, {} FRI words over {} queries", - opening.len(), - fri.len(), - e.proof.queries.len() - ); - - let mut tampered = e.proof.clone(); - tampered.queries[0] - .prep - .first_mut() - .expect("a VM epoch has preprocessed tables") - .evaluations[0] += FE::one(); - assert!( - !e.host_verifies(&tampered), - "a tampered preprocessed opening must be rejected" - ); - - let mut tampered = e.proof.clone(); - tampered.queries[0].main.per_matrix[0].evaluations[0] += FE::one(); - assert!( - !e.host_verifies(&tampered), - "a tampered main-round opening must be rejected" - ); - - let mut moved = e.public_output.clone(); - match moved.first_mut() { - Some(byte) => *byte ^= 1, - None => moved.push(1), - } - assert!( - !e.host_verifies_for(&e.proof, &moved), - "a moved claimed output must be rejected" - ); -} - -/// The batched spine's program shape, host-derived from the harness — each -/// field the value the emitter reads off the AIR set and the options, never -/// off the proof (the OOD dims come from the proof's blocks exactly as the -/// per-table `TableChallengeShape` takes them, blessed as program shape for -/// the same reason: the program is emitted for one epoch shape). -fn batched_shape_of(e: &RealBatchedEpoch) -> super::batched_epoch::BatchedEpochShape { - use super::batched_epoch::{BatchedEpochShape, BatchedFriShape, BatchedTableShape}; - - let refs = e.refs(); - let tables: Vec = e - .proof - .tables - .iter() - .zip(&refs) - .map(|(t, air)| BatchedTableShape { - log2_trace_length: t.trace_length.trailing_zeros(), - has_contribution: air.has_aux_trace(), - ood_current_dims: ( - t.trace_ood_evaluations.width, - t.trace_ood_evaluations.height, - ), - ood_next_dims: ( - t.trace_ood_next_evaluations.width, - t.trace_ood_next_evaluations.height, - ), - num_parts: t.composition_poly_parts_ood_evaluation.len(), - }) - .collect(); - BatchedEpochShape { - tables, - heights: e.shape.heights.clone(), - total_widths: e.shape.total_widths(), - log2_blowup: e.fri_params.blowup_log, - coset_offset: FE::from(e.fri_params.coset_offset), - has_aux: !e.shape.aux.is_empty(), - carved_main: e.shape.carved_main.map(|c| (c.table, c.width)), - fri: BatchedFriShape::new( - &e.shape.heights, - e.fri_params.blowup_log, - e.fri_params.final_poly_log_degree, - ), - grinding_factor: e.fri_params.grinding_factor, - num_queries: e.fri_params.num_queries, - } -} - -/// The batched epoch's spine program — statement, prep provenance, the -/// attestation join, the ONE-transcript batched challenge replay, and the -/// LogUp closure. The batched sibling of [`epoch_program`]'s spine half. -pub(super) fn batched_epoch_program(e: &RealBatchedEpoch) -> LfmProgram { - batched_epoch_program_with(e, false, false) -} - -/// One table's leg shapes, from the AIR set and the proof's trace lengths — -/// the same derivations [`build_table_legs`] makes, minus the per-table view -/// the batched proof does not have. -/// -/// [`build_table_legs`]: super::epoch_verify_tests::build_table_legs -struct BatchedTableLeg { - deep: super::deep::DeepShape, - analysis: super::constraints::Analysis, - quotient: super::constraints::QuotientShape, - main_width: usize, - num_alpha_powers: usize, -} - -fn batched_leg_shapes(e: &RealBatchedEpoch) -> Vec { - use stark::verifier::{IsStarkVerifier, Verifier}; - - e.refs() - .iter() - .zip(&e.proof.tables) - .map(|(air, data)| { - let layout = Verifier::::ood_layout(*air); - let artifact = stark::constraint_ir::ConstraintArtifact::capture(*air); - let (main_width, aux_width) = air.trace_layout(); - let num_total_cols = main_width + aux_width; - let has_aux = air.has_aux_trace(); - BatchedTableLeg { - deep: super::deep::DeepShape { - 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(), - num_composition_parts: data.composition_poly_parts_ood_evaluation.len(), - log2_trace_length: data.trace_length.trailing_zeros(), - }, - analysis: super::constraints::analyze(&artifact), - quotient: super::constraints::QuotientShape { - log2_trace_length: data.trace_length.trailing_zeros(), - num_composition_parts: data.composition_poly_parts_ood_evaluation.len(), - boundary: super::epoch_verify::boundary_terms(has_aux, num_total_cols), - }, - main_width, - num_alpha_powers: if has_aux { - artifact.shape.max_bus_elements as usize - } else { - 0 - }, - } - }) - .collect() -} - -/// Hint `count` consecutive words of `arena`, advancing `cursor` — the -/// walk over the batched opening arena's declared order. -fn hint_run( - b: &mut LfmBuilder, - arena: super::instr::ArenaId, - cursor: &mut u32, - count: usize, -) -> Vec { - (0..count) - .map(|_| { - let c = b.hint_word(arena, *cursor); - *cursor += 1; - c - }) - .collect() -} - -/// Hint `count` digests (two words each) of `arena`, advancing `cursor`. -fn hint_digests( - b: &mut LfmBuilder, - arena: super::instr::ArenaId, - cursor: &mut u32, - count: usize, -) -> Vec { - (0..count) - .map(|_| { - // The stride is the DIGEST's width, not a literal two. - let d = super::edsl::hint_digest(b, arena, *cursor); - *cursor += super::edsl::digest_words(b); - d - }) - .collect() -} - -/// [`batched_epoch_program`] with the OPENING AUTHENTICATION legs hung off -/// the spine (`with_openings`), and with the deliberately WRONG index -/// reduction (`wrong_reduction`) — the machine port of -/// `short_round_low_bit_convention_is_exercised`: keeping the LOW bits of -/// the shared index instead of the high ones is self-consistent host-side, -/// and here it must make an honest proof's walk UNPROVABLE, because the -/// spine's roots were computed over the other convention. -pub(super) fn batched_epoch_program_with( - e: &RealBatchedEpoch, - with_openings: bool, - wrong_reduction: bool, -) -> LfmProgram { - use super::batched_epoch::{ - BatchedEpochAbsorbs, BatchedPrepRoot, BatchedTableOod, emit_batched_epoch_challenges, - }; - use super::statement_replay::{EpochStatementVars, absorb_epoch_statement}; - - assert!( - !wrong_reduction || with_openings, - "the reduction control is a property of the walks" - ); - - let mut b = LfmBuilder::new().with_wrap_hash(super::edsl::WrapHash::production()); - let shape = batched_shape_of(e); - - // ---- arenas, declaration order = absorb order ---- - let stmt_halves = 8 + e.statement.public_output_len.div_ceil(4) + 2; - let a_stmt = b.declare_arena(stmt_halves as u32); - let num_arena_prep = e - .prep_sources - .iter() - .filter(|p| p.is_some_and(PrepSource::is_arena)) - .count(); - let a_prep_roots = - b.declare_arena(super::proof_arena::words_per_root() as u32 * num_arena_prep as u32); - // The carved root's arena sits between the prep roots and main_root — - // declaration order is absorb order, and that is its transcript slot. - let a_carved_root = shape - .carved_main - .map(|_| b.declare_arena(super::proof_arena::words_per_root() as u32)); - let a_main_root = b.declare_arena(super::proof_arena::words_per_root() as u32); - let num_reg = crate::tables::register::NUM_REGISTER_ADDRESSES as u32; - let a_reg_init = b.declare_arena(num_reg); - let a_reg_fini = b.declare_arena(num_reg); - let a_pc_start = b.declare_arena(2); - let a_aux_root = shape - .has_aux - .then(|| b.declare_arena(super::proof_arena::words_per_root() as u32)); - let a_contrib: Vec> = shape - .tables - .iter() - .map(|t| t.has_contribution.then(|| b.declare_arena(1))) - .collect(); - let a_ood: Vec<( - super::instr::ArenaId, - super::instr::ArenaId, - super::instr::ArenaId, - )> = shape - .tables - .iter() - .map(|t| { - ( - b.declare_arena((t.ood_current_dims.0 * t.ood_current_dims.1) as u32), - b.declare_arena((t.ood_next_dims.0 * t.ood_next_dims.1) as u32), - b.declare_arena(t.num_parts as u32), - ) - }) - .collect(); - let a_parts_root = b.declare_arena(super::proof_arena::words_per_root() as u32); - // The standalone class's terminal polynomials — per table, sized by the - // trace-length degree bound, absorbed in round 4 and evaluated by the - // standalone terminal checks. - let a_standalone: Vec> = (0..shape.tables.len()) - .map(|t| { - shape - .fri - .plan - .standalone - .contains(&t) - .then(|| b.declare_arena(1u32 << (shape.heights[t] as u32 - shape.log2_blowup))) - }) - .collect(); - let a_fri_roots = b.declare_arena( - super::proof_arena::words_per_root() as u32 * shape.fri.num_committed() as u32, - ); - let a_fri_coeffs = b.declare_arena(shape.fri.num_terminal_coeffs() as u32); - let a_nonce = (shape.grinding_factor > 0).then(|| b.declare_arena(1)); - // The opening arena, LAST and exactly the T1 serializer's size — the - // program declares what `batched_opening_arena` fills, in its order. - let a_openings = with_openings.then(|| { - b.declare_arena( - (e.proof.queries.len() - * super::epoch_verify_tests::batched_opening_words_per_query(&e.shape)) - as u32, - ) - }); - let a_fri_legs = with_openings.then(|| { - b.declare_arena( - (e.proof.queries.len() - * super::epoch_verify_tests::batched_fri_words_per_query(&e.shape, &e.fri_params)) - as u32, - ) - }); - - // ---- the statement ---- - let stmt: Vec<_> = (0..stmt_halves as u32) - .map(|i| b.hint_felt(a_stmt, i)) - .collect(); - let out_halves = e.statement.public_output_len.div_ceil(4); - let (elf_digest, rest) = stmt.split_at(8); - let (public_output, epoch_label) = rest.split_at(out_halves); - - let mut t = TranscriptReplay::new(&[]); - absorb_epoch_statement( - &mut t, - &e.statement, - &EpochStatementVars { - elf_digest, - public_output, - epoch_label, - }, - ); - - // ---- registers, and the preprocessed roots from their provenances ---- - let reg_init: Vec<_> = (0..num_reg).map(|r| b.hint_felt(a_reg_init, r)).collect(); - let reg_fini: Vec<_> = (0..num_reg).map(|r| b.hint_felt(a_reg_fini, r)).collect(); - for cell in reg_init.iter().chain(®_fini) { - super::epoch::assert_u32(&mut b, *cell); - } - let reg_shape = super::programs::RegisterDerivationShape { - blowup: e.opts.blowup_factor as usize, - coset_offset: e.opts.coset_offset, - }; - - let mut next_arena_prep = 0usize; - let mut decode_cells: Option = None; - let prep_cells: Vec> = e - .prep_sources - .iter() - .map(|prep| match prep { - None => None, - // Interned: the legs compare against these lanes; the ABSORB still - // goes through `BatchedPrepRoot::Constant`'s literal bytes (the - // splice economy). Both views are the same program text. - Some(PrepSource::Constant(c)) => Some(RootCells::constant(&mut b, c)), - Some(PrepSource::Register(_)) => { - let digest = super::programs::emit_register_commitment( - &mut b, reg_shape, ®_init, ®_fini, - ); - Some(RootCells::from_digest(&mut b, digest)) - } - Some(PrepSource::ElfDependent(_)) => { - let cells = RootCells::hint( - &mut b, - a_prep_roots, - super::proof_arena::words_per_root() as u32 * next_arena_prep as u32, - ); - next_arena_prep += 1; - assert!( - decode_cells.is_none(), - "a continuation epoch has one ELF-dependent preprocessed root" - ); - decode_cells = Some(cells.clone()); - Some(cells) - } - }) - .collect(); - assert_eq!(next_arena_prep, num_arena_prep); - - let prep_slots: Vec>> = e - .prep_sources - .iter() - .zip(&prep_cells) - .map(|(prep, cells)| match (prep, cells) { - (None, _) => None, - (Some(PrepSource::Constant(c)), _) => Some(BatchedPrepRoot::Constant(c)), - (_, Some(cells)) => Some(BatchedPrepRoot::Cells(cells)), - _ => unreachable!("a non-constant prep source has cells"), - }) - .collect(); - - // ---- the proof-carried cells ---- - let carved_cells = a_carved_root.map(|id| RootCells::hint(&mut b, id, 0)); - let main_cells = RootCells::hint(&mut b, a_main_root, 0); - let aux_cells = a_aux_root.map(|id| RootCells::hint(&mut b, id, 0)); - let contribs: Vec> = a_contrib - .iter() - .map(|id| id.map(|id| b.hint_word(id, 0).as_ext())) - .collect(); - let ood_cells: Vec<(Vec<_>, Vec<_>, Vec<_>)> = shape - .tables - .iter() - .zip(&a_ood) - .map(|(t, (ac, an, ap))| { - ( - (0..(t.ood_current_dims.0 * t.ood_current_dims.1) as u32) - .map(|k| b.hint_word(*ac, k).as_ext()) - .collect(), - (0..(t.ood_next_dims.0 * t.ood_next_dims.1) as u32) - .map(|k| b.hint_word(*an, k).as_ext()) - .collect(), - (0..t.num_parts as u32) - .map(|k| b.hint_word(*ap, k).as_ext()) - .collect(), - ) - }) - .collect(); - let parts_cells = RootCells::hint(&mut b, a_parts_root, 0); - let standalone_cells: Vec>> = a_standalone - .iter() - .enumerate() - .map(|(t, id)| { - id.map(|id| { - (0..1u32 << (shape.heights[t] as u32 - shape.log2_blowup)) - .map(|k| b.hint_word(id, k).as_ext()) - .collect() - }) - }) - .collect(); - let fri_root_cells: Vec<_> = (0..shape.fri.num_committed()) - .map(|k| { - RootCells::hint( - &mut b, - a_fri_roots, - super::proof_arena::words_per_root() as u32 * k as u32, - ) - }) - .collect(); - let coeff_cells: Vec<_> = (0..shape.fri.num_terminal_coeffs() as u32) - .map(|k| b.hint_word(a_fri_coeffs, k).as_ext()) - .collect(); - let nonce = a_nonce.map(|id| b.hint_felt(id, 0)); - - // ---- the ONE-transcript spine ---- - let oods: Vec> = ood_cells - .iter() - .map(|(c, x, p)| BatchedTableOod { - current: c, - next: x, - parts: p, - }) - .collect(); - let ch = emit_batched_epoch_challenges( - &mut b, - &mut t, - &shape, - &BatchedEpochAbsorbs { - prep_roots: &prep_slots, - carved_root: carved_cells.as_ref(), - main_root: &main_cells, - aux_root: aux_cells.as_ref(), - contributions: &contribs, - parts_root: &parts_cells, - ood: &oods, - standalone_coeffs: &standalone_cells, - fri_roots: &fri_root_cells, - fri_coeffs: &coeff_cells, - nonce, - }, - ); - - // ---- publishes: the pair, then the attestation, then every challenge ---- - b.public(ch.lookup.0.as_cell()); - b.public(ch.lookup.1.as_cell()); - { - let pc_start: Vec<_> = (0..2).map(|i| b.hint_felt(a_pc_start, i)).collect(); - let decode = decode_cells - .as_ref() - .expect("a continuation epoch has a DECODE sub-proof") - .byte_halves(&mut b); - let id = super::programs::emit_program_id( - &mut b, - super::programs::ProgramIdShape { num_pages: 0 }, - elf_digest, - &pc_start, - &decode, - &[], - ); - b.public(id[0]); - b.public(id[1]); - } - for v in ch.betas.iter().chain(&ch.zs).chain(&ch.gammas) { - b.public(v.as_cell()); - } - b.public(ch.alpha.as_cell()); - for zeta in &ch.zetas { - b.public(zeta.as_cell()); - } - for bits in &ch.iota_bits { - let felt = edsl::bits_to_felt(&mut b, bits); - b.public(felt.as_cell()); - } - - // ---- the LogUp closure, on the cells the spine absorbed ---- - let contributions: Vec = contribs.iter().copied().flatten().collect(); - let lshape = super::logup::LogUpShape { - num_contributing_tables: contributions.len(), - num_output_bytes: e.statement.public_output_len, - }; - let start = reg_init[crate::tables::register::X254_INDEX]; - let bytes = super::epoch::emit_output_bytes(&mut b, public_output, lshape.num_output_bytes); - let target = super::logup::emit_commit_bus_target( - &mut b, - &lshape, - ch.lookup.0, - ch.lookup.1, - start, - &bytes, - ); - let total = super::logup::emit_bus_closure(&mut b, &lshape, &contributions, target); - b.public(total.as_cell()); - - // ---- the aggregator-facing published-word schema (carved programs only - // — the continuation batched format). P3's aggregator byte-compares - // these across the five wraps and against the global proof; publishing - // them here is what makes cross-epoch chaining a check on PUBLISHED - // words instead of a trust. Order of record: register boundary vectors - // (init then fini), the epoch label, the epoch's output bytes, then the - // carved L2G root. Every cell is already program state (hinted once, - // bound by the walks/statement above) — publishing adds no arena words - // and no wrap-hash permutations. - if shape.carved_main.is_some() { - for cell in reg_init.iter().chain(®_fini) { - b.public(cell.as_cell()); - } - for half in epoch_label { - b.public(half.as_cell()); - } - for byte in &bytes { - b.public(byte.as_cell()); - } - let carved = carved_cells - .as_ref() - .expect("a carved epoch has carved root cells"); - for half in carved.lanes_flat() { - b.public(half.as_cell()); - } - } - - // ---- the opening walks: every round authenticated at the REDUCED shared - // index, against the very root cells the spine absorbed ---- - if let Some(a_open) = a_openings { - use super::batched_epoch_verify::{ - MixedMatrixOpening, emit_mixed_verify_batch, reduce_iota_bits, - }; - use super::sub_proof::{GroupCommitment, GroupOpening, GroupShape}; - - use super::deep::DeepOpening; - - let n = e.proof.tables.len(); - let h_max_fri = e.shape.heights.iter().copied().max().expect("tables"); - - // Every table's matrix position in each round — the crossing's read, - // the same one `table_deep_pairs` makes. - let prep_pos: Vec> = (0..n) - .map(|t| e.shape.prep.tables.iter().position(|&x| x == t)) - .collect(); - // `None` exactly for a carved table, whose main row pair comes from - // its standalone walk instead of the mixed main round. - let main_pos: Vec> = (0..n) - .map(|t| e.shape.main.tables.iter().position(|&x| x == t)) - .collect(); - let aux_pos: Vec> = (0..n) - .map(|t| e.shape.aux.tables.iter().position(|&x| x == t)) - .collect(); - let parts_pos: Vec = (0..n) - .map(|t| { - e.shape - .parts - .tables - .iter() - .position(|&x| x == t) - .expect("every table has a parts matrix") - }) - .collect(); - - // Per table, hoisted across queries: the OOD grid rebuilt from the - // very cells the spine absorbed (no second copy for a prover to - // disagree with), the CONSTRAINT identity and the quotient check at - // this table's z — production's own evaluators, reached through - // emit_analyzed/emit_quotient exactly as the per-table program does — - // and the DEEP invariants over the same grid and the same parts. - let legs = batched_leg_shapes(e); - let dinvs: Vec = (0..n) - .map(|t_i| { - let leg = &legs[t_i]; - let grid = super::epoch::emit_reconstruct_ood( - &mut b, - &leg.deep, - &ood_cells[t_i].0, - &ood_cells[t_i].1, - ); - - // The LogUp uniforms, DERIVED: α powers from the one shared α - // the spine sampled, and the per-row offset from the one `L` - // it absorbed — the same cell the closure sums. - let alpha_powers = if leg.num_alpha_powers > 0 { - super::constraints::emit_alpha_powers(&mut b, ch.lookup.1, leg.num_alpha_powers) - } else { - Vec::new() - }; - let table_offset = match contribs[t_i] { - Some(l) => super::constraints::emit_table_offset( - &mut b, - l, - leg.quotient.log2_trace_length, - ), - None => b.felt_const(FE::zero()).as_ext(), - }; - let steps = super::epoch_verify::frame_step_view(&grid, leg.deep.step_size); - let ood_ops = super::constraints::OodOperands { - steps, - main_width: leg.main_width, - rap_challenges: vec![ch.lookup.0, ch.lookup.1], - alpha_powers, - table_offset, - }; - let evals = super::constraints::emit_analyzed(&mut b, &leg.analysis, &ood_ops); - let q = super::constraints::emit_quotient( - &mut b, - &leg.quotient, - &ood_ops, - ch.zs[t_i], - ch.betas[t_i], - &evals, - &ood_cells[t_i].2, - ); - b.assert_eq_ext(q.claimed, q.composition); - - super::deep::emit_deep_invariants( - &mut b, - &leg.deep, - ch.gammas[t_i], - ch.zs[t_i], - &grid, - &ood_cells[t_i].2, - ) - }) - .collect(); - let fri_layer_commitments: Vec = fri_root_cells - .iter() - .map(|c| super::fri::LayerCommitment { - root_lanes: c.lanes.clone(), - }) - .collect(); - - let mut cursor: u32 = 0; - let mut fri_cursor: u32 = 0; - for bits in &ch.iota_bits { - // ---- the walks: preprocessed tables, then the mixed rounds ---- - let mut prep_values: Vec> = Vec::new(); - for (slot, &(h, w)) in e.shape.prep.tables.iter().zip(e.shape.prep.dims.iter()) { - let cells = prep_cells[*slot] - .as_ref() - .expect("a preprocessed table has root cells"); - let values = hint_run(&mut b, a_open, &mut cursor, 2 * w); - let siblings = hint_digests(&mut b, a_open, &mut cursor, h - 1); - let tbits = if wrong_reduction && h < h_max_fri { - // ★ THE BROKEN CONTROL: keep the LOW bits instead of the - // high ones — same length, wrong index space. - &bits[..h - 1] - } else { - reduce_iota_bits(bits, h_max_fri, h) - }; - super::sub_proof::emit_group_authentication( - &mut b, - &GroupCommitment::from_lanes( - cells.lanes.clone(), - GroupShape { - num_columns: w, - is_ext: false, - }, - ), - &GroupOpening { - values: values.clone(), - siblings, - }, - tbits, - ); - prep_values.push(values); - } - - // ---- the carved table's standalone walk: the preprocessed - // pattern with the root PROOF-CARRIED — authenticated against the - // very cells the spine absorbed, at the reduced shared index. The - // wrong-reduction control covers this walk exactly as it covers - // the others (verdict condition 4's emitted side). - let mut carved_values: Option> = None; - if let Some((ct, cw)) = shape.carved_main { - let cells = carved_cells - .as_ref() - .expect("a carved epoch has carved root cells"); - let h = e.shape.heights[ct]; - let values = hint_run(&mut b, a_open, &mut cursor, 2 * cw); - let siblings = hint_digests(&mut b, a_open, &mut cursor, h - 1); - let tbits = if wrong_reduction && h < h_max_fri { - &bits[..h - 1] - } else { - reduce_iota_bits(bits, h_max_fri, h) - }; - super::sub_proof::emit_group_authentication( - &mut b, - &GroupCommitment::from_lanes( - cells.lanes.clone(), - GroupShape { - num_columns: cw, - is_ext: false, - }, - ), - &GroupOpening { - values: values.clone(), - siblings, - }, - tbits, - ); - carved_values = Some(values); - } - - // The three mixed rounds: per-matrix row pairs in round INPUT - // order, then the round's ONE shared path. The value cells are - // KEPT — the crossing below reads the cells the walks - // authenticated, never a second copy. - let mut round_values: Vec>> = Vec::new(); - let mut rounds: Vec<(&stark::batched::shape::RoundShape, &RootCells, bool)> = - vec![(&e.shape.main, &main_cells, false)]; - if let Some(aux) = aux_cells.as_ref() { - rounds.push((&e.shape.aux, aux, true)); - } - rounds.push((&e.shape.parts, &parts_cells, true)); - for (round, root, is_ext) in rounds { - let h_round = round.h_max().expect("a committed round is non-empty"); - let per_values: Vec> = round - .dims - .iter() - .map(|&(_, w)| hint_run(&mut b, a_open, &mut cursor, 2 * w)) - .collect(); - let siblings = hint_digests(&mut b, a_open, &mut cursor, h_round - 1); - let matrices: Vec> = round - .dims - .iter() - .zip(&per_values) - .map(|(&(h, w), values)| MixedMatrixOpening { - shape: GroupShape { - num_columns: w, - is_ext, - }, - log_height: h, - values, - }) - .collect(); - let rbits = if wrong_reduction && h_round < h_max_fri { - &bits[..h_round - 1] - } else { - reduce_iota_bits(bits, h_max_fri, h_round) - }; - emit_mixed_verify_batch(&mut b, root, &matrices, &siblings, rbits); - round_values.push(per_values); - } - let main_values = &round_values[0]; - let aux_values = aux_cells.as_ref().map(|_| &round_values[1]); - let parts_values = round_values.last().expect("the parts round"); - - // ---- the crossing: per table, the authenticated cells re-read - // by POINT, folded to the DEEP pair at the reduced index ---- - let mut points: Vec<(super::builder::Felt, super::builder::Felt)> = - Vec::with_capacity(n); - let mut deep_pairs: Vec<(super::builder::Ext, super::builder::Ext)> = - Vec::with_capacity(n); - for t_i in 0..n { - let h_t = e.shape.heights[t_i]; - let rbits = reduce_iota_bits(bits, h_max_fri, h_t); - let (point, point_sym) = super::sub_proof::emit_points_from_bits( - &mut b, - h_t as u32, - shape.coset_offset, - rbits, - ); - - let mut trace = Vec::with_capacity(legs[t_i].deep.num_total_cols); - let mut trace_sym = Vec::with_capacity(legs[t_i].deep.num_total_cols); - if let Some(m) = prep_pos[t_i] { - let w = e.shape.prep.dims[m].1; - let vals = &prep_values[m]; - trace.extend((0..w).map(|c| vals[c].as_ext())); - trace_sym.extend((0..w).map(|c| vals[w + c].as_ext())); - } - match main_pos[t_i] { - Some(m) => { - let w = e.shape.main.dims[m].1; - let vals = &main_values[m]; - trace.extend((0..w).map(|c| vals[c].as_ext())); - trace_sym.extend((0..w).map(|c| vals[w + c].as_ext())); - } - None => { - let (ct, cw) = shape - .carved_main - .expect("only a carved table lacks a main matrix"); - assert_eq!(ct, t_i, "the carved table is the one without a main slot"); - let vals = carved_values - .as_ref() - .expect("the carved walk authenticated this query"); - trace.extend((0..cw).map(|c| vals[c].as_ext())); - trace_sym.extend((0..cw).map(|c| vals[cw + c].as_ext())); - } - } - if let Some(m) = aux_pos[t_i] { - let w = e.shape.aux.dims[m].1; - let vals = &aux_values.expect("an aux position implies an aux round")[m]; - trace.extend((0..w).map(|c| vals[c].as_ext())); - trace_sym.extend((0..w).map(|c| vals[w + c].as_ext())); - } - assert_eq!( - trace.len(), - legs[t_i].deep.num_total_cols, - "the crossing must cover exactly the DEEP column set" - ); - let m = parts_pos[t_i]; - let w = e.shape.parts.dims[m].1; - assert_eq!( - w, legs[t_i].deep.num_composition_parts, - "the parts matrix is one column per composition part" - ); - let vals = &parts_values[m]; - let parts: Vec = (0..w).map(|c| vals[c].as_ext()).collect(); - let parts_sym: Vec = - (0..w).map(|c| vals[w + c].as_ext()).collect(); - - let regular = DeepOpening { - point, - trace, - parts, - }; - let symmetric = DeepOpening { - point: point_sym, - trace: trace_sym, - parts: parts_sym, - }; - deep_pairs.push(( - super::deep::emit_deep_point( - &mut b, - &legs[t_i].deep, - ch.gammas[t_i], - &dinvs[t_i], - ®ular, - ), - super::deep::emit_deep_point( - &mut b, - &legs[t_i].deep, - ch.gammas[t_i], - &dinvs[t_i], - &symmetric, - ), - )); - points.push((point, point_sym)); - } - - // ---- the mix, the batched instance, the standalone class ---- - let (p0, p0_sym, buckets) = super::batched_epoch_verify::emit_query_mix( - &mut b, - &shape.fri.plan.batched, - &e.shape.heights, - h_max_fri, - ch.alpha, - &deep_pairs, - bits, - ); - // υ in the TALLEST domain is the tallest table's own point — its - // reduction is the identity, so reusing the cell adds no second - // derivation. - let tallest = e - .shape - .heights - .iter() - .position(|&h| h == h_max_fri) - .expect("a tallest table exists"); - let fri_openings_q: Vec = (0..shape.fri.num_committed()) - .map(|i| { - let sym = { - let c = b.hint_word( - a_fri_legs.expect("the FRI arena exists with the legs"), - fri_cursor, - ); - fri_cursor += 1; - c.as_ext() - }; - let siblings = hint_digests( - &mut b, - a_fri_legs.expect("the FRI arena exists with the legs"), - &mut fri_cursor, - h_max_fri - i - 2, - ); - super::fri::LayerOpening { sym, siblings } - }) - .collect(); - super::batched_epoch_verify::emit_batched_query_fri( - &mut b, - &shape.fri.layout, - h_max_fri, - &fri_layer_commitments, - &ch.zetas, - &coeff_cells, - bits, - points[tallest].0, - points[tallest].1, - p0, - p0_sym, - &buckets, - &fri_openings_q, - ); - for &t_i in &shape.fri.plan.standalone { - let coeffs = standalone_cells[t_i] - .as_ref() - .expect("a standalone table has terminal cells"); - super::batched_epoch_verify::emit_standalone_terminal_check( - &mut b, - coeffs, - points[t_i].0, - points[t_i].1, - deep_pairs[t_i].0, - deep_pairs[t_i].1, - ); - } - } - assert_eq!( - cursor as usize, - e.proof.queries.len() - * super::epoch_verify_tests::batched_opening_words_per_query(&e.shape), - "the walks must consume exactly the declared opening arena" - ); - assert_eq!( - fri_cursor as usize, - e.proof.queries.len() - * super::epoch_verify_tests::batched_fri_words_per_query(&e.shape, &e.fri_params), - "the FRI legs must consume exactly the declared arena" - ); - } - - let program = compile(b.finish()); - validate(&program).expect("the batched epoch spine must be admissible"); - program -} - -/// The arenas [`batched_epoch_program`] declares, in the same order, filled -/// from the harness's proof. -pub(super) fn batched_epoch_arenas(e: &RealBatchedEpoch) -> Vec> { - let mut stmt: Vec = Vec::new(); - let halves = |bytes: &[u8]| -> Vec { - bytes - .chunks(4) - .map(|c| { - let mut w = [0u8; 4]; - w[..c.len()].copy_from_slice(c); - FE::from(u32::from_le_bytes(w) as u64) - }) - .collect() - }; - stmt.extend(halves(&e.elf_digest)); - stmt.extend(halves(&e.public_output)); - stmt.extend(halves(&e.epoch_label.to_le_bytes())); - - let prep: Vec = e - .prep_sources - .iter() - .filter_map(|p| match p { - Some(PrepSource::ElfDependent(c)) => Some(*c), - _ => None, - }) - .collect(); - let reg = |v: &[u32]| -> Vec { - assert_eq!( - v.len(), - crate::tables::register::NUM_REGISTER_ADDRESSES, - "a register boundary vector is one word per register word address" - ); - v.iter() - .map(|w| base_word(FE::from(u64::from(*w)))) - .collect() - }; - - let mut out = vec![ - stmt.iter().map(|h| base_word(*h)).collect(), - super::proof_arena::commitments_to_arena(&prep), - ]; - // The carved root's arena sits between the prep roots and main_root — - // the program's declaration order is the absorb order. - if e.shape.carved_main.is_some() { - out.push(super::proof_arena::commitments_to_arena(&[e - .proof - .carved_main_root - .expect("a carved epoch proof carries its carved root")])); - } - out.extend([ - super::proof_arena::commitments_to_arena(&[e.proof.main_root]), - reg(&e.register_init), - reg(&e.reg_fini), - super::keccak_host::pack_stream(&e.pc_start.to_le_bytes()) - .into_iter() - .map(base_word) - .collect(), - ]); - if let Some(root) = e.proof.aux_root.as_ref() { - out.push(super::proof_arena::commitments_to_arena(&[*root])); - } - for table in &e.proof.tables { - if let Some(bpi) = table.bus_public_inputs.as_ref() { - out.push(vec![ext_word(&bpi.table_contribution)]); - } - } - for table in &e.proof.tables { - let block_words = |block: &stark::table::Table| -> Vec { - (0..block.height) - .flat_map(|r| block.get_row(r).to_vec()) - .map(|v| ext_word(&v)) - .collect() - }; - out.push(block_words(&table.trace_ood_evaluations)); - out.push(block_words(&table.trace_ood_next_evaluations)); - out.push( - table - .composition_poly_parts_ood_evaluation - .iter() - .map(ext_word) - .collect(), - ); - } - out.push(super::proof_arena::commitments_to_arena(&[e - .proof - .parts_root])); - for table in &e.proof.tables { - if let Some(coeffs) = table.standalone_final_poly_coeffs.as_ref() { - out.push(coeffs.iter().map(ext_word).collect()); - } - } - out.push(super::proof_arena::commitments_to_arena( - &e.proof.fri_layer_roots, - )); - out.push(e.proof.fri_final_poly_coeffs.iter().map(ext_word).collect()); - if let Some(nc) = e.proof.nonce { - out.push(vec![base_word(FE::from(nc))]); - } - out -} - -/// ★ THE RUN: the BATCHED epoch's Fiat-Shamir spine, executed against a real -/// batched epoch proof the host verification accepts, and differentialled -/// against `replay_epoch_transcript`'s own challenges — every β, z, γ, the -/// shared pair, the shared α, every ζ, every shared iota, the attestation -/// program_id, and the COMMIT-bus closure. -#[test] -fn the_batched_epoch_challenge_spine_matches_production() { - let e = real_batched_epoch_with(super::proof_fixture::fixture_options()); - let program = batched_epoch_program(&e); - let arenas = batched_epoch_arenas(&e); - let exec = execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER) - .expect("the batched epoch spine must execute"); - - // Vacuity guard: the fixture must exercise BOTH instance classes, or the - // standalone-terminal absorb and the class split are dead paths here. - assert!( - !e.challenges.fri.plan.standalone.is_empty() && !e.challenges.fri.plan.batched.is_empty(), - "the fixture epoch must have both batched and standalone tables" - ); - - let pub_ext = |i: usize| word_as_ext(&exec.public_words[i].1).expect("an ext challenge"); - let [z, alpha] = e.challenges.lookup.as_slice() else { - panic!("the shared pair is (z, α)"); - }; - assert_eq!(pub_ext(0), *z, "the shared LogUp z"); - assert_eq!(pub_ext(1), *alpha, "the shared LogUp alpha"); - assert_eq!( - published_digest(&exec.public_words, 2), - e.expected_program_id, - "the attestation program_id must match production's" - ); - - let n = e.proof.tables.len(); - let mut cursor = 4usize; - for (i, want) in e.challenges.betas.iter().enumerate() { - assert_eq!(pub_ext(cursor + i), *want, "beta of table {i}"); - } - cursor += n; - for (i, want) in e.challenges.zs.iter().enumerate() { - assert_eq!(pub_ext(cursor + i), *want, "z of table {i}"); - } - cursor += n; - for (i, want) in e.challenges.deep_gammas.iter().enumerate() { - assert_eq!(pub_ext(cursor + i), *want, "gamma of table {i}"); - } - cursor += n; - assert_eq!(pub_ext(cursor), e.challenges.fri.alpha, "the shared DEEP α"); - cursor += 1; - for (k, want) in e.challenges.fri.betas.iter().enumerate() { - assert_eq!(pub_ext(cursor + k), *want, "zeta {k}"); - } - cursor += e.challenges.fri.betas.len(); - for (q, want) in e.challenges.fri.iotas.iter().enumerate() { - let w = exec.public_words[cursor + q].1; - let got = super::word::word_as_base(&w).expect("an index is a base felt"); - assert_eq!(got, FE::from(*want as u64), "shared iota {q}"); - } - cursor += e.challenges.fri.iotas.len(); - assert_eq!( - word_as_ext(&exec.public_words[cursor].1).expect("the bus total is ext"), - e.expected_bus_balance, - "the LogUp closure must reach production's COMMIT-bus target" - ); - cursor += 1; - assert_eq!( - cursor, - exec.public_words.len(), - "every published word must be checked" - ); -} - -/// ★ THE RUN: the whole ASSEMBLED BATCHED epoch verifier — spine and legs — -/// on a real continuation epoch proved through the batched path. -/// -/// What executing proves, stated precisely. Every check is an assert inside -/// the program, so reaching the end means: all 25 constraint identities and -/// quotient checks held at the batched spine's own z and β; every round's -/// opened row pairs hashed — tallest matrices batched, shorter height groups -/// INJECTED — into the roots the ONE transcript absorbed, at the reduced -/// shared index; every preprocessed table authenticated against the AIR-set -/// root its provenance admits; the per-table DEEP crossings fed the α-mixed -/// injected FRI fold to the batched terminal; every standalone table's -/// transcript-BOUND polynomial matched its DEEP pair at its own reduced -/// points; and the LogUp closure reached production's COMMIT-bus target. The -/// tamper arms and the wrong-reduction control show what does NOT execute, -/// which is what turns "it executed" into evidence. -#[test] -fn the_assembled_batched_epoch_verifier_runs() { - let e = real_batched_epoch_with(super::proof_fixture::fixture_options()); - let program = batched_epoch_program_with(&e, true, false); - let mut arenas = batched_epoch_arenas(&e); - arenas.push(super::epoch_verify_tests::batched_opening_arena(&e)); - arenas.push(super::epoch_verify_tests::batched_fri_arena(&e)); - execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER) - .expect("every opening of an honest batched epoch must authenticate"); - - // A moved opening VALUE is unprovable (the first arena word is the first - // preprocessed table's first evaluation) — and since the crossing folds - // the SAME cell, a value that somehow re-authenticated would still move - // the DEEP pair and die at the FRI terminal. - let open_idx = arenas.len() - 2; - let fri_idx = arenas.len() - 1; - let mut tampered = arenas.clone(); - tampered[open_idx][0] = base_word(FE::from(999_999u64)); - assert!( - execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a tampered opening value must not authenticate" - ); - - // A moved SIBLING is unprovable (the last arena word is path data — every - // round ends with its shared path). - let mut tampered = arenas.clone(); - let last = tampered[open_idx].len() - 1; - tampered[open_idx][last] = base_word(FE::from(999_999u64)); - assert!( - execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a tampered sibling must not authenticate" - ); - - // A moved value in an INJECTED matrix — a main-round matrix SHORTER than - // the round's tallest, so both the injection compress in the walk and the - // α-mix bucket in the FRI join read it. - { - let mut off = 0usize; - for &(h, w) in &e.shape.prep.dims { - off += 2 * w + 2 * (h - 1); - } - let h_main = e.shape.main.h_max().expect("the main round is non-empty"); - match e.shape.main.dims.iter().position(|&(h, _)| h < h_main) { - Some(m) => { - for &(_, w) in &e.shape.main.dims[..m] { - off += 2 * w; - } - let mut tampered = arenas.clone(); - tampered[open_idx][off] = base_word(FE::from(999_999u64)); - assert!( - execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a tampered injected-matrix value must not verify" - ); - } - None => eprintln!("injected-matrix arm skipped: all main matrices are tallest"), - } - } - - // A moved FRI layer value (the first FRI arena word is layer 0's - // symmetric evaluation) must fail its layer walk — or, had it somehow - // re-authenticated, the fold chain's terminal. - if !arenas[fri_idx].is_empty() { - let mut tampered = arenas.clone(); - tampered[fri_idx][0] = base_word(FE::from(999_999u64)); - assert!( - execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a tampered FRI layer opening must not verify" - ); - } - - // A moved STANDALONE terminal coefficient shifts the transcript (it is - // absorbed — the binding the campaign's soundness fix added) AND the - // polynomial the standalone check evaluates; both directions kill it. - if !e.challenges.fri.plan.standalone.is_empty() { - // Position: statement, prep, main_root, reg_init, reg_fini, pc_start, - // [aux_root], per-RAP-table contribution, per-table (ood_c, ood_n, - // parts), parts_root, THEN the standalone arenas. Count forward. - let n = e.proof.tables.len(); - let num_contrib = e - .proof - .tables - .iter() - .filter(|t| t.bus_public_inputs.is_some()) - .count(); - let idx = 6 + usize::from(e.proof.aux_root.is_some()) + num_contrib + 3 * n + 1; - // The arm must point at what it claims to tamper — pinned by size, - // since tampering ANY absorbed arena also fails and would mask a - // wrong index. - let first_standalone = e.challenges.fri.plan.standalone[0]; - assert_eq!( - arenas[idx].len(), - 1usize << (e.shape.heights[first_standalone] as u32 - e.fri_params.blowup_log), - "the tamper arm must point at the first standalone terminal arena" - ); - let mut tampered = arenas.clone(); - tampered[idx][0] = ext_word(&FEE::from(999_999u64)); - assert!( - execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a tampered standalone terminal coefficient must not verify" - ); - } - - // ★ The index-reduction DIRECTION (`fri/mmcs.rs`'s convention section): - // keeping the low bits instead of the high ones is self-consistent - // host-side, so nothing there rejects it; against real roots the walk - // must be unprovable. Discrimination is checked, not hoped for: the arm - // only proves something when some short walk's two slices actually - // differ for this proof's drawn indices. - let h_max_fri = e.shape.heights.iter().copied().max().expect("tables"); - let discriminates = e.challenges.fri.iotas.iter().any(|&iota| { - e.shape.prep.dims.iter().any(|&(h, _)| { - h < h_max_fri && (iota >> (h_max_fri - h)) != (iota & ((1usize << (h - 1)) - 1)) - }) - }); - if discriminates { - let wrong = batched_epoch_program_with(&e, true, true); - assert!( - execute(&wrong, &arenas, &crate::hash_pin::BLOCK_HASHER).is_err(), - "the wrong reduction direction must not authenticate an honest epoch" - ); - } else { - // Astronomically unlikely (every short walk's high and low slices - // coincide at every query), but a vacuous control must say so rather - // than pass silently. - eprintln!("wrong-reduction control skipped: the drawn indices do not discriminate"); - } -} - -/// Arena words the BATCHED epoch program MUST declare, as arithmetic over the -/// epoch's shapes — `expected_arena_words`' discipline on the batched schema. -/// Every term comes from the harness's host data (the proof the host -/// verification accepted, the replayed shape and params), never from the -/// emitter, so the comparison against the compiled program is absolute. -fn expected_batched_arena_words(e: &RealBatchedEpoch, with_legs: bool) -> usize { - let num_reg = crate::tables::register::NUM_REGISTER_ADDRESSES; - // A root's width in arena words (see `expected_arena_words`). - let dw = super::proof_arena::words_per_root(); - let mut total = 8 + e.statement.public_output_len.div_ceil(4) + 2; - total += dw - * e.prep_sources - .iter() - .filter(|p| p.is_some_and(PrepSource::is_arena)) - .count(); - total += dw * usize::from(e.shape.carved_main.is_some()); // the carved root - total += dw; // main_root — ONE, which is the whole batched economy - total += 2 * num_reg; - total += 2; // pc_start - total += dw * usize::from(e.proof.aux_root.is_some()); - total += e - .proof - .tables - .iter() - .filter(|t| t.bus_public_inputs.is_some()) - .count(); - for t in &e.proof.tables { - total += t.trace_ood_evaluations.width * t.trace_ood_evaluations.height; - total += t.trace_ood_next_evaluations.width * t.trace_ood_next_evaluations.height; - total += t.composition_poly_parts_ood_evaluation.len(); - } - total += dw; // parts_root - for t in &e.proof.tables { - if let Some(coeffs) = t.standalone_final_poly_coeffs.as_ref() { - total += coeffs.len(); - } - } - total += dw * e.proof.fri_layer_roots.len(); - total += e.proof.fri_final_poly_coeffs.len(); - total += usize::from(e.fri_params.grinding_factor > 0); - if with_legs { - total += e.proof.queries.len() - * (super::epoch_verify_tests::batched_opening_words_per_query(&e.shape) - + super::epoch_verify_tests::batched_fri_words_per_query(&e.shape, &e.fri_params)); - } - total -} - -/// ★ The two ABSOLUTE structural guards, on the BATCHED program — the same -/// pair that closes the two-consumer class for the per-table one -/// ([`the_spine_hints_each_proof_value_once`] and -/// [`the_assembled_verifier_declares_exactly_the_shape_words`]): no arena -/// word is read twice, every declared word is read, and the schema is -/// exactly the epoch's shapes — a surplus word is where a second copy of a -/// joined value (a root, a contribution, a standalone terminal) would hide. -#[test] -fn the_batched_verifier_declares_and_hints_exactly_the_shape_words() { - use std::collections::HashMap; - - let e = real_batched_epoch_with(super::proof_fixture::fixture_options()); - for with_legs in [false, true] { - let program = batched_epoch_program_with(&e, with_legs, false); - let declared: usize = program.arena_schema.lens.iter().map(|l| *l as usize).sum(); - assert_eq!( - declared, - expected_batched_arena_words(&e, with_legs), - "with_legs = {with_legs}: the batched arena schema must be exactly \ - the epoch's shapes and nothing more" - ); - - let mut hints: HashMap<(super::instr::ArenaId, u32), usize> = HashMap::new(); - for instr in &program.instrs { - if let super::instr::Instr::Hint { arena, index, .. } = instr { - *hints.entry((*arena, *index)).or_default() += 1; - } - } - let doubled: Vec<_> = hints.iter().filter(|(_, n)| **n > 1).collect(); - assert!( - doubled.is_empty(), - "with_legs = {with_legs}: these arena words are hinted more than \ - once, which is the two-consumer hazard: {doubled:?}" - ); - assert_eq!( - hints.len(), - declared, - "with_legs = {with_legs}: every declared arena word must be read \ - exactly once" - ); - } -} - -/// ★ The batched query CENSUS: the emitted legs hash exactly what the shape -/// closed form declares — `batched_query_permutations_for`, checked as the -/// delta between the with-legs and spine-only programs, so the count is -/// absolute (the spine's own hashing subtracts out) and hash-aware (the -/// other hash's delta must be zero: the legs hash under the wrap hash -/// alone). This is the formula the campaign's wrap-side prediction rides -/// on: paths per ROUND plus the small prep trees, not per table per group. -#[test] -fn the_batched_query_census_matches_the_closed_form() { - let e = real_batched_epoch_with(super::proof_fixture::fixture_options()); - let spine = batched_epoch_program(&e); - let full = batched_epoch_program_with(&e, true, false); - let count = |p: &LfmProgram, keccak: bool| -> usize { - p.instrs - .iter() - .filter(|i| match i { - super::instr::Instr::KeccakF(_) => keccak, - super::instr::Instr::Blake3(_) => !keccak, - _ => false, - }) - .count() - }; - let hash = super::edsl::WrapHash::production(); - let per_query = - super::batched_epoch_verify::batched_query_permutations_for(&e.shape, &e.fri_params, hash); - let is_keccak = matches!(hash, super::edsl::WrapHash::Keccak); - let wrap_delta = count(&full, is_keccak) - count(&spine, is_keccak); - let other_delta = count(&full, !is_keccak) - count(&spine, !is_keccak); - assert_eq!( - wrap_delta, - e.proof.queries.len() * per_query, - "the legs' wrap-hash permutations must be exactly the census closed form" - ); - assert_eq!(other_delta, 0, "the legs hash under the wrap hash alone"); - eprintln!( - "batched query census: {per_query} wrap permutations/query over {} tables", - e.proof.tables.len() - ); -} - -/// ★★ The CARVED emitter gates — D1's emitted side, on the continuation -/// batched format (the L2G main matrix carved standalone). One test, five -/// arms: the assembled verifier RUNS on an honest carved epoch; the census -/// closed form and the schema words match the carved program exactly -/// (structural); a tampered carved ROOT, opening VALUE and SIBLING are each -/// unprovable; and the wrong-reduction control fires on the carved walk -/// (verdict condition 4's emitted side), discrimination checked, not hoped -/// for. -#[test] -fn the_assembled_carved_batched_epoch_verifier_runs() { - let e = real_batched_epoch_carved_from( - super::proof_fixture::fixture_options(), - EpochInputs::from_env(), - ); - let c = e - .shape - .carved_main - .expect("the harness carved the L2G table"); - assert_eq!( - c.table, - e.proof.tables.len() - 1, - "the carve is the L2G bookend, the last table" - ); - let h_carved = e.shape.heights[c.table]; - let h_max_fri = e.shape.h_max(); - - let program = batched_epoch_program_with(&e, true, false); - let mut arenas = batched_epoch_arenas(&e); - arenas.push(super::epoch_verify_tests::batched_opening_arena(&e)); - arenas.push(super::epoch_verify_tests::batched_fri_arena(&e)); - execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER) - .expect("an honest carved batched epoch must run end to end"); - - // Structural: the census closed form and the schema words, on the CARVED - // shape — the same absolute guards the uncarved program carries. - let spine = batched_epoch_program_with(&e, false, false); - let count = |p: &LfmProgram, keccak: bool| -> usize { - p.instrs - .iter() - .filter(|i| match i { - super::instr::Instr::KeccakF(_) => keccak, - super::instr::Instr::Blake3(_) => !keccak, - _ => false, - }) - .count() - }; - let hash = super::edsl::WrapHash::production(); - let per_query = - super::batched_epoch_verify::batched_query_permutations_for(&e.shape, &e.fri_params, hash); - let is_keccak = matches!(hash, super::edsl::WrapHash::Keccak); - assert_eq!( - count(&program, is_keccak) - count(&spine, is_keccak), - e.proof.queries.len() * per_query, - "the carved legs' wrap-hash permutations must be exactly the closed form" - ); - for with_legs in [false, true] { - let p = batched_epoch_program_with(&e, with_legs, false); - let declared: usize = p.arena_schema.lens.iter().map(|l| *l as usize).sum(); - assert_eq!( - declared, - expected_batched_arena_words(&e, with_legs), - "the carved program's schema must be exactly the shape's words" - ); - } - - // Tamper: the carved ROOT — its arena is index 2, right after the - // statement and the prep roots (declaration order = absorb order). - let open_idx = arenas.len() - 2; - let mut tampered = arenas.clone(); - tampered[2][0] = base_word(FE::from(999_999u64)); - assert!( - execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a tampered carved root must not verify" - ); - - // Tamper: the carved opening VALUE and a carved path SIBLING — the - // carved block sits after the prep openings in the opening arena. - let mut off = 0usize; - for &(h, w) in &e.shape.prep.dims { - off += 2 * w + 2 * (h - 1); - } - let mut tampered = arenas.clone(); - tampered[open_idx][off] = base_word(FE::from(999_999u64)); - assert!( - execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a tampered carved opening value must not verify" - ); - let mut tampered = arenas.clone(); - tampered[open_idx][off + 2 * c.width] = base_word(FE::from(999_999u64)); - assert!( - execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), - "a tampered carved sibling must not verify" - ); - - // The wrong-reduction control, on the carved walk specifically. - if h_carved < h_max_fri { - let discriminates = e.challenges.fri.iotas.iter().any(|&iota| { - (iota >> (h_max_fri - h_carved)) != (iota & ((1usize << (h_carved - 1)) - 1)) - }); - if discriminates { - let wrong = batched_epoch_program_with(&e, true, true); - assert!( - execute(&wrong, &arenas, &crate::hash_pin::BLOCK_HASHER).is_err(), - "the wrong reduction direction must not authenticate the carved walk" - ); - } else { - eprintln!("carved wrong-reduction arm skipped: the drawn indices do not discriminate"); - } - } else { - eprintln!("carved wrong-reduction arm skipped: the carved table is the tallest"); - } -} - -/// ★★ P2's from-proof gates, batched: a continuation proven BATCHED wraps -/// from the bundle alone. Epoch 0 AND the FINAL epoch reconstruct through -/// [`real_batched_epoch_from_continuation`] (production's complete batched -/// verify inside the harvest is the acceptance gate), each emits its CARVED -/// program, the arenas fill to the schema, and the assembled verifier RUNS. -/// A tampered bundle is rejected by the constructor's production verify — -/// the P1 gate pair, on the batched format. -#[test] -fn the_batched_from_proof_constructor_runs_a_continuation_epoch() { - let elf_bytes = super::proof_fixture::read_inner_elf(); - let opts = super::proof_fixture::fixture_options(); - let bundle = crate::continuation::prove_continuation_batched( - &elf_bytes, - &[], - super::proof_fixture::FIXTURE_EPOCH_LOG2, - &opts, - ) - .expect("the fixture continuation must prove batched"); - assert!( - bundle.num_epochs() >= 2, - "the fixture continuation must have a second (final) epoch" - ); - - for (epoch, name) in [(0usize, "genesis"), (bundle.num_epochs() - 1, "FINAL")] { - let e = real_batched_epoch_from_continuation(&opts, &elf_bytes, &bundle, epoch, None) - .unwrap_or_else(|err| panic!("epoch {epoch} ({name}) must reconstruct: {err}")); - assert!( - e.shape.carved_main.is_some(), - "a continuation batched epoch is carved" - ); - let program = batched_epoch_program_with(&e, true, false); - let mut arenas = batched_epoch_arenas(&e); - arenas.push(super::epoch_verify_tests::batched_opening_arena(&e)); - arenas.push(super::epoch_verify_tests::batched_fri_arena(&e)); - execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER).unwrap_or_else(|err| { - panic!("epoch {epoch} ({name})'s carved program must run: {err:?}") - }); - eprintln!( - "★ P2 GATE: the {name} epoch's carved program ran ({} instrs)", - program.instrs.len() - ); - } - - // Tamper: a corrupted bound reg_fini is rejected by the constructor's - // production verify, exactly as on the per-table path. - let mut tampered = bundle; - tampered.corrupt_epoch_reg_fini_for_tests(1); - let err = match real_batched_epoch_from_continuation(&opts, &elf_bytes, &tampered, 1, None) { - Err(e) => e, - Ok(_) => panic!("a corrupted reg_fini must not reconstruct"), - }; - assert!( - err.contains("rejects"), - "the corruption is caught by the production verify inside the constructor: {err}" - ); -} - /// [`host_table`] for a sub-proof inside a multi-table epoch: the fork is /// already positioned (separator, aux root and `L` absorbed), so the oracle /// comes from `replay_rounds_after_round_1` on THAT transcript. diff --git a/prover/src/lfm/epoch_verify_tests.rs b/prover/src/lfm/epoch_verify_tests.rs index a49f02ab7..d56326ad2 100644 --- a/prover/src/lfm/epoch_verify_tests.rs +++ b/prover/src/lfm/epoch_verify_tests.rs @@ -36,11 +36,8 @@ //! commitment problem (ledger entry 7), which is about where a root COMES from //! and not about what is done with it. -use stark::batched::shape::{EpochFriParams, EpochShape}; use stark::config::Commitment; use stark::constraint_ir::ConstraintArtifact; -use stark::fri::batched::{BatchedFriLayout, FriInstancePlan}; -use stark::fri::mmcs::MixedOpening; use stark::proof::view::StarkProofView; use stark::traits::AIR; use stark::verifier::{IsStarkVerifier, Verifier}; @@ -49,7 +46,6 @@ use crate::tables::types::{FE, FEE, GoldilocksExtension, GoldilocksField}; use super::constraints::{Analysis, BoundaryTerm, QuotientShape, analyze}; use super::deep::DeepShape; -use super::epoch_tests::RealBatchedEpoch; use super::epoch_verify::{TableVerifyShape, boundary_terms}; use super::executor::execute; use super::fri::FriShape; @@ -341,154 +337,6 @@ impl TableLegs { } } -// ================== the batched epoch's leg inputs (T1) ================== - -/// Arena words ONE query of the batched epoch's trace openings occupies, as -/// arithmetic over the epoch shape — the AIR-set-derived closed form, never a -/// count of what a serializer happened to produce (`expected_arena_words`'s -/// discipline, ported ahead of the emitter so the schema is pinned from the -/// AIR set rather than from the emitter's own opinion of itself). -/// -/// Per query: each preprocessed table's row pair and OWN path (a standard -/// per-table tree at that table's LDE height), then per mixed round — main, -/// aux, parts — every matrix's row pair in round INPUT order and the round's -/// ONE shared path (`h_max − 1` levels, two words per sibling digest). -pub(super) fn batched_opening_words_per_query(shape: &EpochShape) -> usize { - // ⚠ The sibling stride is the DIGEST's width, not a literal two — the - // opening VALUES' `2 *` is the row pair and is unrelated. - let sib = super::proof_arena::words_per_root(); - let mut words = 0; - for &(h, width) in &shape.prep.dims { - words += 2 * width + sib * (h - 1); - } - // The carved table's standalone opening: a preprocessed table's layout — - // the row pair then its own path at the carved height. - if let Some(c) = &shape.carved_main { - words += 2 * c.width + sib * (shape.heights[c.table] - 1); - } - for round in [&shape.main, &shape.aux, &shape.parts] { - let Some(h_max) = round.h_max() else { continue }; - words += round.dims.iter().map(|&(_, w)| 2 * w).sum::(); - words += sib * (h_max - 1); - } - words -} - -/// Arena words ONE query of the batched FRI instance occupies: per committed -/// layer the symmetric evaluation and its path. Layer `i`'s codeword is -/// `2^(h_max−i−1)` long and its leaves are pairs, so its tree is -/// `h_max − i − 2` deep — `FriShape::layer_path_len`'s arithmetic at the -/// BATCHED CLASS's `h_max`. The layout is production's own -/// ([`FriInstancePlan`] + [`BatchedFriLayout`]), not a re-derivation; -/// standalone tables carry no layers at all, and every terminal coefficient -/// is spine data. -pub(super) fn batched_fri_words_per_query(shape: &EpochShape, params: &EpochFriParams) -> usize { - let plan = FriInstancePlan::new( - &shape.heights, - params.blowup_log, - params.final_poly_log_degree, - ) - .expect("a real epoch's heights partition"); - let layout = BatchedFriLayout::new( - plan.h_max, - plan.h_min, - params.blowup_log, - params.final_poly_log_degree, - ); - // The `1` is the symmetric evaluation; the rest is the path, whose stride is - // the DIGEST's width rather than a literal two. - let sib = super::proof_arena::words_per_root(); - (0..layout.num_committed) - .map(|i| 1 + sib * (plan.h_max - i - 2)) - .sum() -} - -/// The batched analogue of [`TableLegs::opening_arena`]: per query — each -/// preprocessed table's opening, then the main, aux and parts rounds, each as -/// its per-matrix row pairs in round INPUT order followed by the ONE shared -/// path. NO index word, for the same reason as the per-table arena: the -/// assembled verifier's index is the transcript's own bits. -pub(super) fn batched_opening_arena(e: &RealBatchedEpoch) -> Vec { - fn push_mixed_base(out: &mut Vec, o: &MixedOpening) { - for m in &o.per_matrix { - out.extend(m.evaluations.iter().map(|v| base_word(*v))); - out.extend(m.evaluations_sym.iter().map(|v| base_word(*v))); - } - out.extend(super::proof_arena::commitments_to_arena( - &o.proof.merkle_path, - )); - } - fn push_mixed_ext(out: &mut Vec, o: &MixedOpening) { - for m in &o.per_matrix { - out.extend(m.evaluations.iter().map(ext_word)); - out.extend(m.evaluations_sym.iter().map(ext_word)); - } - out.extend(super::proof_arena::commitments_to_arena( - &o.proof.merkle_path, - )); - } - - let mut out = Vec::new(); - for q in &e.proof.queries { - for p in &q.prep { - out.extend(p.evaluations.iter().map(|v| base_word(*v))); - out.extend(p.evaluations_sym.iter().map(|v| base_word(*v))); - out.extend(super::proof_arena::commitments_to_arena( - &p.proof.merkle_path, - )); - } - if let Some(o) = &q.carved_main { - out.extend(o.evaluations.iter().map(|v| base_word(*v))); - out.extend(o.evaluations_sym.iter().map(|v| base_word(*v))); - out.extend(super::proof_arena::commitments_to_arena( - &o.proof.merkle_path, - )); - } - push_mixed_base(&mut out, &q.main); - if let Some(aux) = &q.aux { - push_mixed_ext(&mut out, aux); - } - push_mixed_ext(&mut out, &q.parts); - } - assert_eq!( - out.len(), - e.proof.queries.len() * batched_opening_words_per_query(&e.shape), - "the batched opening arena must fill exactly what the shape declares" - ); - out -} - -/// The batched analogue of [`TableLegs::fri_arena`]: per query, per committed -/// layer of the ONE shared instance — the symmetric evaluation then its path. -pub(super) fn batched_fri_arena(e: &RealBatchedEpoch) -> Vec { - let mut out = Vec::new(); - for q in &e.proof.queries { - // `zip` is not a length check; the closed-form assert below only sees - // totals, and a sym missing its path could hide behind a path missing - // its sym. - assert_eq!( - q.fri.layers_evaluations_sym.len(), - q.fri.layers_auth_paths.len(), - "every committed layer opens a symmetric evaluation AND a path" - ); - for (sym, path) in q - .fri - .layers_evaluations_sym - .iter() - .zip(&q.fri.layers_auth_paths) - { - out.push(ext_word(sym)); - out.extend(super::proof_arena::commitments_to_arena(&path.merkle_path)); - } - } - assert_eq!( - out.len(), - e.proof.queries.len() * batched_fri_words_per_query(&e.shape, &e.fri_params), - "the batched FRI arena must fill exactly what the shape declares" - ); - out -} - /// ★ THE RUN: the whole epoch verifier — spine AND legs — on a real /// continuation epoch proof that production accepts. /// diff --git a/prover/src/lfm/group_leaf_tests.rs b/prover/src/lfm/group_leaf_tests.rs deleted file mode 100644 index dbca149c5..000000000 --- a/prover/src/lfm/group_leaf_tests.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! ★★★ **THE GROUP-LEAF FELT-SEQUENCE DIFFERENTIAL** — the one construction on -//! the batched wrap path with no differential covering it. -//! -//! The host builds a mixed round's leaf in `stark::fri::mmcs`: for each matrix -//! of the height group, all of `evaluations` then all of `evaluations_sym`, -//! matrices in round INPUT order, flat, one hash. The machine re-derives it in -//! [`super::batched_epoch_verify::emit_group_leaf_hash`]. If the two feed -//! different felts the walk reconstructs nothing, and the leg fails as a -//! `DivByZero` deep in a query walk that names neither the hash nor the site. -//! -//! ⚠ **Why this compares SEQUENCES and not only digests.** A digest -//! differential says THAT the two disagree. It cannot say whether the -//! disagreement is the order across matrices, the split between a matrix's two -//! rows, the decomposition of one extension element, or the padding of the -//! last block — and those have different fixes. Both sides therefore expose the -//! felt run they actually absorb ([`stark::fri::mmcs::group_opening_felts`] and -//! [`super::batched_epoch_verify::group_leaf_felts`], each split out of its own -//! only production caller), and the assertion names the first index at which -//! they part. -//! -//! ⚠ **Neither side restates the convention.** The expectation is not a rule -//! written out here for both implementations to be checked against — that would -//! pass whenever this file and the code share a misunderstanding. The host -//! sequence comes from the host's own production function, its base-felt -//! decomposition from the host's own -//! [`super::algebraic_commit::element_felts`], and the machine sequence is read -//! out of an EXECUTED program's memory. What this file chooses is only the -//! shapes. -//! -//! The shapes are chosen so that ordering and padding cannot both hide: -//! several matrices of differing widths, groups whose felt count lands under, -//! exactly on, and over the rate-8 boundary (the padding flag `len mod 8` is -//! the one part of the construction that is not identical on every block), and -//! the single-matrix degenerate case. - -use stark::config::StarkHash; -use stark::fri::mmcs::{group_opening_felts, hash_group_openings}; -use stark::proof::stark::PolynomialOpenings; - -use super::algebraic_commit::{ - AlgebraicHasher, PoseidonCommit, PoseidonStarkHash, RpoCommit, RpoStarkHash, RpxCommit, - RpxStarkHash, digest_to_commitment, element_felts, -}; -use super::batched_epoch_verify::{MixedMatrixOpening, emit_group_leaf_hash, group_leaf_felts}; -use super::builder::{Cell, LfmBuilder}; -use super::compiler::compile; -use super::edsl::WrapHash; -use super::executor::execute; -use super::sub_proof::GroupShape; -use super::word::{LfmWord, base_word, ext_word}; -use crate::tables::types::{FE, FEE, GoldilocksExtension, GoldilocksField}; - -/// The three tenants that HAVE a commitment configuration, as the pair this -/// file needs: the permutation tag the machine's socket proves, and the -/// `StarkHash` the host commits under. They are the same hash by construction — -/// see `algebraic_commit`'s note — and passing both is what lets one body drive -/// the host and the machine at once. -/// -/// `HasherKind::Test` is absent for the reason `algebraic_commit`'s own tenant -/// macro gives: it is a permutation without a commitment configuration, so -/// there is no host side to differentiate against. -macro_rules! for_each_tenant { - ($body:ident) => { - $body::("Rpo"); - $body::("Rpx"); - $body::("Poseidon"); - }; -} - -/// An empty authentication path — a per-matrix opening's own `proof` is always -/// empty here, exactly as `MixedOpening`'s doc says: the group's one path is -/// the authenticator, and the leaf hash never reads it. -fn no_proof() -> crypto::merkle_tree::proof::Proof { - crypto::merkle_tree::proof::Proof { - merkle_path: Vec::new(), - } -} - -/// Distinct, matrix- and position-dependent values, so a swap of any two felts -/// anywhere in the sequence is visible. Non-zero throughout, which a -/// zero-padding bug could otherwise mask. -fn base_val(matrix: usize, i: usize) -> FE { - FE::from(1000 * (matrix as u64 + 1) + i as u64 + 1) -} - -fn ext_val(matrix: usize, i: usize) -> FEE { - let b = 1000 * (matrix as u64 + 1) + 3 * i as u64; - FEE::new([FE::from(b + 1), FE::from(b + 2), FE::from(b + 3)]) -} - -/// Every matrix in one height group sits at one height, so the leaf is -/// independent of it; a fixed value keeps the shape honest without implying -/// otherwise. -const GROUP_HEIGHT: usize = 4; - -/// Name the first index at which two felt runs part, rather than only that they -/// do — the whole reason this is a sequence differential. -fn assert_sequence(tenant: &str, case: &str, host: &[FE], machine: &[FE]) { - if let Some(i) = (0..host.len().min(machine.len())).find(|&i| host[i] != machine[i]) { - panic!( - "{tenant}/{case}: felt sequences part at index {i} of {} (host) / {} (machine):\n \ - host = {:?}\n machine = {:?}", - host.len(), - machine.len(), - &host[i.saturating_sub(2)..(i + 3).min(host.len())], - &machine[i.saturating_sub(2)..(i + 3).min(machine.len())], - ); - } - assert_eq!( - host.len(), - machine.len(), - "{tenant}/{case}: felt sequences agree on their common prefix but not in LENGTH" - ); -} - -/// Emit the machine's group leaf over `runs`, execute it, and return the felt -/// sequence it absorbed (as values) together with the digest it produced. -/// -/// The felts are read out of final memory: every felt the machine absorbs is a -/// base-valued word, so lane 0 is its value — an unpacked lane is written as -/// `base_word(lane)` and a hinted base cell holds `base_word(v)`. -fn machine_run( - arena: Vec, - widths: &[usize], - is_ext: bool, -) -> (Vec, [u8; 32]) { - let mut b = LfmBuilder::new().with_wrap_hash(WrapHash::Algebraic); - let a = b.declare_arena(arena.len() as u32); - let cells: Vec = (0..arena.len()).map(|i| b.hint_word(a, i as u32)).collect(); - - let mut runs: Vec> = Vec::new(); - let mut at = 0usize; - for &w in widths { - runs.push(cells[at..at + 2 * w].to_vec()); - at += 2 * w; - } - assert_eq!(at, cells.len(), "the runs cover the arena exactly"); - - let matrices: Vec> = widths - .iter() - .zip(&runs) - .map(|(&w, values)| MixedMatrixOpening { - shape: GroupShape { - num_columns: w, - is_ext, - }, - log_height: GROUP_HEIGHT, - values, - }) - .collect(); - let group: Vec<&MixedMatrixOpening<'_>> = matrices.iter().collect(); - - // The sequence and the digest come from the SAME program: the collection - // under test, then production's own leaf over it. - let felts = group_leaf_felts(&mut b, &group); - let digest = emit_group_leaf_hash(&mut b, &group); - assert_eq!(digest.len(), 1, "an algebraic digest is ONE cell"); - b.public(digest[0]); - - let program = compile(b.finish()); - let exec = execute(&program, &[arena], &H::KIND).expect("the leaf program must execute"); - - let values = felts - .iter() - .map(|f| { - exec.memory[f.addr().0 as usize].expect("an absorbed felt must have been written")[0] - }) - .collect(); - // ⚠ `digest_to_commitment`, NOT `word::pack_digest`: the two disagree on - // endianness (big vs little), and the host's `Commitment` is the former's. - // Restating the byte order here is exactly the mistake this file's header - // warns about, so the machine's digest goes through the host's own function. - (values, digest_to_commitment(&exec.public_words[0].1)) -} - -/// ★ BASE matrices — the `main` round's shape. -fn check_base(tenant: &str, case: &str, widths: &[usize]) { - let openings: Vec> = widths - .iter() - .enumerate() - .map(|(m, &w)| PolynomialOpenings { - proof: no_proof(), - evaluations: (0..w).map(|c| base_val(m, c)).collect(), - evaluations_sym: (0..w).map(|c| base_val(m, w + c)).collect(), - }) - .collect(); - let group: Vec<&PolynomialOpenings> = openings.iter().collect(); - - let mut host: Vec = Vec::new(); - for e in &group_opening_felts(&group) { - element_felts(e, &mut host); - } - let want = hash_group_openings::(&group); - - // The arena in the machine's layout: per matrix, its `2 · w` opened cells - // in leaf order — which is what the caller of `emit_mixed_verify_batch` - // hints from the proof arena. - let arena: Vec = widths - .iter() - .enumerate() - .flat_map(|(m, &w)| (0..2 * w).map(move |i| base_word(base_val(m, i)))) - .collect(); - - let (machine, got) = machine_run::(arena, widths, false); - assert_sequence(tenant, case, &host, &machine); - assert_eq!(got, want, "{tenant}/{case}: leaf digests must agree"); -} - -/// ★ EXTENSION matrices — the `aux` and `parts` rounds' shape, where each value -/// contributes THREE felts and the decomposition order is load-bearing. -fn check_ext(tenant: &str, case: &str, widths: &[usize]) { - let openings: Vec> = widths - .iter() - .enumerate() - .map(|(m, &w)| PolynomialOpenings { - proof: no_proof(), - evaluations: (0..w).map(|c| ext_val(m, c)).collect(), - evaluations_sym: (0..w).map(|c| ext_val(m, w + c)).collect(), - }) - .collect(); - let group: Vec<&PolynomialOpenings> = openings.iter().collect(); - - let mut host: Vec = Vec::new(); - for e in &group_opening_felts(&group) { - element_felts(e, &mut host); - } - let want = hash_group_openings::(&group); - - let arena: Vec = widths - .iter() - .enumerate() - .flat_map(|(m, &w)| (0..2 * w).map(move |i| ext_word(&ext_val(m, i)))) - .collect(); - - let (machine, got) = machine_run::(arena, widths, true); - assert_sequence(tenant, case, &host, &machine); - assert_eq!(got, want, "{tenant}/{case}: leaf digests must agree"); -} - -/// ★★★ The gate. Base groups: felt count is `2 · Σw`, so the rate-8 boundary is -/// crossed in both directions and landed on exactly. -#[test] -fn the_machine_group_leaf_absorbs_the_host_felt_sequence_base() { - fn check(tenant: &str) { - // (case name, widths) — felt counts 2, 6, 8, 10, 20, 8, 32. - check_base::(tenant, "single-w1 (2 felts, degenerate)", &[1]); - check_base::(tenant, "single-w3 (6 felts, under rate)", &[3]); - check_base::(tenant, "single-w4 (8 felts, exactly rate)", &[4]); - check_base::(tenant, "single-w5 (10 felts, over rate)", &[5]); - check_base::(tenant, "multi-2,3,5 (20 felts, mixed widths)", &[2, 3, 5]); - check_base::( - tenant, - "multi-1,1,1,1 (8 felts, exactly rate)", - &[1, 1, 1, 1], - ); - check_base::(tenant, "multi-7,9 (32 felts)", &[7, 9]); - } - for_each_tenant!(check); -} - -/// ★★★ The gate, extension side. Felt count is `6 · Σw`, so w=4 lands exactly -/// on a rate multiple and w=1, 2, 3 do not. -#[test] -fn the_machine_group_leaf_absorbs_the_host_felt_sequence_ext() { - fn check(tenant: &str) { - check_ext::(tenant, "single-w1 (6 felts, under rate)", &[1]); - check_ext::(tenant, "single-w2 (12 felts, over rate)", &[2]); - check_ext::(tenant, "single-w3 (18 felts)", &[3]); - check_ext::(tenant, "single-w4 (24 felts, exact multiple)", &[4]); - check_ext::(tenant, "multi-1,2,4 (42 felts, mixed widths)", &[1, 2, 4]); - check_ext::(tenant, "multi-2,2 (24 felts, exact multiple)", &[2, 2]); - } - for_each_tenant!(check); -} - -/// ⚠ **The differential's own control.** A gate that compares two sequences is -/// worth nothing if it would pass on sequences that differ, and the failure -/// this whole file exists to catch is precisely an ORDER disagreement — which a -/// length check and a digest check can both miss. So: perturb the machine's -/// arena by swapping two felts that a wrong matrix order would swap, and -/// require the gate's own comparison to reject it. -#[test] -fn the_differential_rejects_a_reordered_sequence() { - let a: Vec = (0..6u64).map(FE::from).collect(); - let mut b = a.clone(); - b.swap(1, 4); - let out = std::panic::catch_unwind(|| assert_sequence("ctl", "swap", &a, &b)); - assert!(out.is_err(), "a swapped sequence must be rejected"); - - let short = &a[..5]; - let out = std::panic::catch_unwind(|| assert_sequence("ctl", "short", &a, short)); - assert!(out.is_err(), "a truncated sequence must be rejected"); - - assert_sequence("ctl", "identical", &a, &a); -} diff --git a/prover/src/lfm/machine_tests.rs b/prover/src/lfm/machine_tests.rs index 45bf1fde5..cc7b48a5d 100644 --- a/prover/src/lfm/machine_tests.rs +++ b/prover/src/lfm/machine_tests.rs @@ -5255,124 +5255,6 @@ fn the_width_compaction_follows_table_order_not_slot_order() { ); } -/// ★ M-7 + M-8's round half, end to end: a batched LFM epoch PROVES and now -/// VERIFIES. -/// -/// This is the DELIBERATE FLIP of `a_batched_lfm_epoch_is_refused_for_the_ -/// round_coverage_gap`, executed exactly as that test's own doc mandated. The -/// old refusal's cause was the fused prep round covering twelve slots while a -/// real epoch's round had matrices outside them (`BITWISE` always). The fused -/// round is gone: preprocessed chips are bound PER TABLE against the AIR set's -/// own roots — `artifacts.roots[slot]` plus the production `KECCAK_RC`/ -/// `BITWISE` pins — which covers every preprocessed AIR, so no gap remains to -/// refuse over. -/// -/// The tamper arm keeps the flip honest: acceptance must be discrimination, -/// not a verifier that stopped checking. -#[test] -fn a_batched_lfm_epoch_verifies_end_to_end() { - use crate::lfm::proof::{lfm_prove_batched, verify_against_batched}; - - let opts = options(); - let program = trivial_program(); - let artifacts = build_artifacts(&program, &opts); - - let proved = lfm_prove_batched(&program, &artifacts, &arenas(), &opts) - .expect("a batched LFM epoch must prove"); - - assert!( - verify_against_batched(&artifacts, &proved.proof, &proved.public_words, &opts), - "a batched LFM epoch must verify end to end against its own artifacts" - ); - - // Tamper arm: one preprocessed value moved in one opening must reject. - let mut tampered = proved.proof.clone(); - let prep0 = tampered.queries[0] - .prep - .first_mut() - .expect("the LFM machine has preprocessed chips"); - prep0.evaluations[0] += crate::tables::types::FE::one(); - assert!( - !verify_against_batched(&artifacts, &tampered, &proved.public_words, &opts), - "a tampered preprocessed opening must be rejected" - ); - - // And a moved public word must reject — the claimed-public binding is the - // batched path's COMMIT-bus check, same as the per-table one. - let mut moved = proved.public_words.clone(); - if let Some(w) = moved.first_mut() { - w.0 ^= 1; - } - assert!( - !verify_against_batched(&artifacts, &proved.proof, &moved, &opts), - "a moved claimed public word must be rejected" - ); -} - -/// The batched LFM path proves and verifies at the AGGREGATION wrap preset -/// (blowup 4 / 110 queries / terminal 2^8) — the options the block's wraps -/// carry into the aggregator. Pins the derived query count and terminal so a -/// drive-by change to the options builder moves this test, not the block -/// record; the tamper arm keeps acceptance discriminating at the new preset. -#[test] -fn a_batched_lfm_epoch_verifies_at_the_aggregation_preset() { - use crate::lfm::proof::{aggregation_wrap_options, lfm_prove_batched, verify_against_batched}; - - let opts = aggregation_wrap_options(); - assert_eq!(opts.blowup_factor, 4, "the decided A point is blowup 4"); - assert_eq!( - opts.fri_number_of_queries, 110, - "blowup 4 at the 128-bit Johnson-bound target is 110 queries" - ); - assert_eq!(opts.fri_final_poly_log_degree, 8, "the adopted terminal"); - - let program = trivial_program(); - let artifacts = build_artifacts(&program, &opts); - let proved = lfm_prove_batched(&program, &artifacts, &arenas(), &opts) - .expect("a batched LFM epoch must prove at the aggregation preset"); - assert!( - verify_against_batched(&artifacts, &proved.proof, &proved.public_words, &opts), - "a batched LFM epoch must verify at the aggregation preset" - ); - - let mut tampered = proved.proof.clone(); - tampered.queries[0].main.per_matrix[0].evaluations[0] += crate::tables::types::FE::one(); - assert!( - !verify_against_batched(&artifacts, &tampered, &proved.public_words, &opts), - "a tampered opening must be rejected at the aggregation preset" - ); -} - -/// A batched LFM proof survives the rkyv wire and still verifies — the -/// shipping property the aggregation layer stands on: a block's wraps travel -/// as bytes, and what arrives must be exactly what proves. The deserialized -/// proof AND its public words go back through the complete verifier, so a -/// wire layout that silently reordered or dropped anything fails here, not -/// at the aggregator. -#[test] -fn a_batched_lfm_proof_round_trips_the_wire() { - use crate::lfm::proof::{BatchedLfmProof, lfm_prove_batched, verify_against_batched}; - - let opts = options(); - let program = trivial_program(); - let artifacts = build_artifacts(&program, &opts); - let proved = lfm_prove_batched(&program, &artifacts, &arenas(), &opts) - .expect("a batched LFM epoch must prove"); - - let bytes = rkyv::to_bytes::(&proved).expect("the wrap must serialize"); - let back = rkyv::from_bytes::(&bytes) - .expect("the wrap must deserialize"); - - assert_eq!( - back.public_words, proved.public_words, - "the public words must survive the wire byte for byte" - ); - assert!( - verify_against_batched(&artifacts, &back.proof, &back.public_words, &opts), - "the deserialized batched wrap must verify completely" - ); -} - /// The shape a batched verifier reads back must be the shape the round was /// built with. Two derivations of the same thing are how the LDE-vs-trace /// height distinction gets lost: `prep_round_dims` is one function with two diff --git a/prover/src/lfm/mod.rs b/prover/src/lfm/mod.rs index d7fcfae80..9d67c2721 100644 --- a/prover/src/lfm/mod.rs +++ b/prover/src/lfm/mod.rs @@ -77,8 +77,6 @@ pub use transcript_replay::{Candidate, TranscriptReplay}; pub use validator::{LfmViolation, validate}; pub use word::{LfmWord, base_word, ext_word, pack_digest, unpack_digest}; -#[cfg(test)] -mod aggregator_tests; // The algebraic `StarkHash` configurations this differential drives the host // with are `#[cfg(not(feature = "cuda"))]` — inexpressible under cuda, by // design — so the gate follows them rather than failing to compile there. @@ -100,8 +98,6 @@ mod epoch_verify_tests; mod framework_probe; #[cfg(test)] mod fri_tests; -#[cfg(all(test, not(feature = "cuda")))] -mod group_leaf_tests; #[cfg(test)] mod join_tests; #[cfg(test)] @@ -115,6 +111,8 @@ mod logup_tests; #[cfg(test)] mod machine_tests; #[cfg(test)] +mod per_table_aggregator_tests; +#[cfg(test)] mod per_table_census_tests; #[cfg(test)] mod poseidon_chip_tests; diff --git a/prover/src/lfm/per_table_aggregator_tests.rs b/prover/src/lfm/per_table_aggregator_tests.rs new file mode 100644 index 000000000..b5af6b284 --- /dev/null +++ b/prover/src/lfm/per_table_aggregator_tests.rs @@ -0,0 +1,464 @@ +//! The emitted verifier of a continuation's cross-epoch GLOBAL memory proof. +//! +//! The global proof is per-table: one sub-proof per epoch's L2G re-commit plus +//! one GLOBAL_MEMORY table per touched page, behind one statement, closing the +//! GlobalMemory bus at ZERO. This module harvests a real fixture bundle's +//! global proof ([`real_global`]), emits its verifier +//! ([`global_verifier_program`]) and fills the arenas that program declares +//! ([`global_arena_words`]). +//! +//! It is the per-table verification path at `WrapHash::production()` against a +//! real per-table `MultiProof`, differentialled against the harvest's own +//! production challenges through the published shared pair and tampered through +//! a flipped L2G main root. Every emission primitive it drives — the spine's +//! `fork_table`, the per-table verification legs, the LogUp closure — is the +//! same machinery an aggregator over per-table wrap proofs needs, which is why +//! the leg is worth gating on its own rather than only inside a larger program. + +use crate::tables::types::{FE, FEE, GoldilocksExtension, GoldilocksField}; + +use super::builder::{Ext, Felt, LfmBuilder}; +use super::compiler::{LfmProgram, compile}; +use super::epoch::RootCells; +use super::executor::execute; +use super::instr::ArenaId; +use super::transcript_replay::TranscriptReplay; +use super::word::{LfmWord, base_word, ext_word}; + +type Gl = GoldilocksField; +type Ext3 = GoldilocksExtension; + +/// The cross-epoch global memory proof, production-accepted, harvested for +/// emission: per-table shapes and challenges (the per-table machinery's own +/// harvest), the Phase-A prep constants (page genesis commitments — AIR-set +/// constants at emit time), and the statement bytes (every field an +/// emit-time constant of the block). +pub(super) struct RealGlobal { + /// ⚠ ONE ENTRY PER HOST `append_bytes` CALL, not one flat run. + /// `absorb_continuation_global_statement` makes a separate call for the + /// tag, the ELF digest, the epoch count, the private-page count, the FRI + /// byte, the page-base count and each page base. A byte transcript + /// concatenates and cannot tell one long field from that sequence; an + /// ALGEBRAIC one length-prefixes every call and can, so a flattened + /// statement is a different chain. + pub(super) statement_appends: Vec>, + pub(super) tables: Vec, + pub(super) legs: Vec, + pub(super) num_l2g: usize, + pub(super) z_alpha: (FEE, FEE), +} + +/// Harvest the bundle's global proof. Panics loudly on a proof production +/// rejects. Mirrors `verify_global`'s AIR reconstruction exactly (the +/// no-supplied-roots arm: data-page genesis recomputed from the ELF). +pub(super) fn real_global( + elf_bytes: &[u8], + bundle: &crate::continuation::ContinuationProof, + opts: &crate::ProofOptions, +) -> RealGlobal { + use crypto::fiat_shamir::is_transcript::IsTranscript; + use executor::elf::Elf; + use stark::verifier::IsStarkVerifier; + + let elf = Elf::load(elf_bytes).expect("the ELF must load"); + let num_epochs = bundle.num_epochs(); + let npriv = bundle.num_private_pages(); + let page_bases: Vec = { + let mut b: Vec = bundle.touched_pages().to_vec(); + b.sort_unstable(); + b.dedup(); + b + }; + let l2g_airs: Vec<_> = (0..num_epochs) + .map(|i| { + crate::continuation::l2g_global_air( + opts, + crate::tables::local_to_global::epoch_label(i as u64), + ) + }) + .collect(); + let gm_configs = crate::continuation::global_memory_configs(&page_bases, &elf, npriv); + let gm_airs: Vec<_> = gm_configs + .iter() + .map(|config| crate::continuation::global_memory_air(opts, config, None)) + .collect(); + let mut refs: Vec< + &dyn stark::traits::AIR, + > = l2g_airs + .iter() + .map(|a| a as &dyn stark::traits::AIR) + .collect(); + for air in &gm_airs { + refs.push(air); + } + + // The statement, byte for byte — `absorb_continuation_global_statement`'s + // encoding over emit-time constants, pinned by the harness differential + // (the seed below absorbs through the production function; the leg's + // emitted challenges must then match the harvested ones, which fails if + // this local encoding ever drifts). + let mut statement_appends: Vec> = vec![ + crate::statement::CONTINUATION_GLOBAL_TAG.to_vec(), + crate::statement::elf_digest(elf_bytes).to_vec(), + (num_epochs as u64).to_le_bytes().to_vec(), + (npriv as u64).to_le_bytes().to_vec(), + vec![opts.fri_final_poly_log_degree], + (page_bases.len() as u64).to_le_bytes().to_vec(), + ]; + for base in &page_bases { + statement_appends.push(u64::to_le_bytes(*base).to_vec()); + } + + let seed = || { + let mut t = crate::hash_pin::block_transcript(&[]); + crate::statement::absorb_continuation_global_statement( + &mut t, + elf_bytes, + num_epochs, + npriv, + opts.fri_final_poly_log_degree, + &page_bases, + ); + t + }; + let view = bundle.global_proof_view(); + assert_eq!(refs.len(), view.len(), "one AIR per global sub-proof"); + assert!( + crate::hash_pin::BlockVerifier::::multi_verify_views( + &refs, + view, + &mut seed(), + &FEE::zero() + ), + "production's verifier must accept the global proof" + ); + + // Phase A + the shared LogUp pair, transcribed as the epoch harvest does. + let mut transcript = seed(); + for (idx, air) in refs.iter().enumerate() { + let v = view.get(idx); + if air.is_preprocessed() { + transcript.append_bytes(&air.precomputed_commitment()); + } + transcript.append_bytes(v.lde_trace_main_merkle_root()); + } + let lookup: Vec = (0..stark::lookup::LOGUP_NUM_CHALLENGES) + .map(|_| transcript.sample_field_element()) + .collect(); + let z_alpha = (lookup[0], lookup[1]); + + let num_tables = refs.len(); + let tables: Vec = refs + .iter() + .enumerate() + .map(|(idx, air)| { + let v = view.get(idx); + let mut fork = transcript.clone(); + if num_tables > 1 { + fork.append_bytes(&(idx as u64).to_le_bytes()); + } + if let Some(root) = v.lde_trace_aux_merkle_root() { + fork.append_bytes(root); + } + if let Some(c) = v.bus_table_contribution() { + fork.append_field_element(&c); + } + super::epoch_tests::host_table_forked(*air, v, idx, num_tables, &mut fork, &lookup) + }) + .collect(); + let legs = refs + .iter() + .enumerate() + .map(|(idx, air)| super::epoch_verify_tests::build_table_legs(*air, view.get(idx), &lookup)) + .collect(); + + RealGlobal { + statement_appends, + tables, + legs, + num_l2g: num_epochs, + z_alpha, + } +} + +/// Per-table arena set of the global-verifier program, in declaration order. +struct GlobalTableArenas { + aux_root: Option, + contribution: Option, + composition_root: ArenaId, + ood_current: ArenaId, + ood_next: ArenaId, + parts: ArenaId, + fri_roots: ArenaId, + fri_coeffs: ArenaId, + nonce: Option, + legs: super::epoch_verify::TableQueryArenas, +} + +/// The emitted verifier of the global proof — the per-table program's own +/// structure (statement, Phase A, one fork per table, full verification +/// legs, the LogUp closure) with the global statement as one constant run, +/// every preprocessed root an AIR-set constant, and the bus target ZERO +/// (`verify_global`'s own expected balance). PUBLISHES: the shared pair, +/// then each epoch's L2G re-commit main root (eight halves each, epoch +/// order) — the byte-compare material the aggregator binds against the five +/// wraps' published carved roots. +pub(super) fn global_verifier_program(g: &RealGlobal) -> LfmProgram { + use super::epoch::{TableAbsorbs, fork_table}; + use super::statement_replay::{PhaseAPreprocessed, PhaseATable, replay_phase_a}; + + let mut b = LfmBuilder::new().with_wrap_hash(super::edsl::WrapHash::production()); + let n = g.tables.len(); + + // ---- arenas, declaration order = absorb order ---- + let a_main_roots = b.declare_arena(super::edsl::digest_words(&b) * n as u32); + let per_table: Vec = g + .tables + .iter() + .zip(&g.legs) + .map(|(h, leg)| GlobalTableArenas { + aux_root: h + .shape + .has_aux_root + .then(|| b.declare_arena(super::edsl::digest_words(&b))), + contribution: h.shape.has_contribution.then(|| b.declare_arena(1)), + composition_root: b.declare_arena(super::edsl::digest_words(&b)), + ood_current: b + .declare_arena((h.shape.ood_current_dims.0 * h.shape.ood_current_dims.1) as u32), + ood_next: b.declare_arena((h.shape.ood_next_dims.0 * h.shape.ood_next_dims.1) as u32), + parts: b.declare_arena(h.shape.num_parts as u32), + fri_roots: b + .declare_arena(super::edsl::digest_words(&b) * h.shape.fri.num_committed() as u32), + fri_coeffs: b.declare_arena(h.shape.fri.num_terminal_coeffs() as u32), + nonce: (h.shape.grinding_factor > 0).then(|| b.declare_arena(1)), + legs: super::epoch_verify::declare_table_arenas(&mut b, &leg.verify), + }) + .collect(); + + // ---- the statement: ONE APPEND PER HOST CALL, see `statement_appends` ---- + let mut t = TranscriptReplay::new(&[]); + for append in &g.statement_appends { + t.append_const_bytes(append); + } + + // ---- Phase A: prep constants, hinted main roots ---- + let main_cells: Vec = (0..n) + .map(|i| { + RootCells::hint( + &mut b, + a_main_roots, + super::proof_arena::words_per_root() as u32 * i as u32, + ) + }) + .collect(); + // ⚠ `byte_halves`, not `lanes_flat`: Phase A absorbs a root through + // `append_halves_misaligned`, whose byte length is `4 · halves.len()`, and + // the host absorbs the root's THIRTY-TWO bytes in one `append_bytes`. On an + // algebraic arm `lanes_flat` is four FULL FELTS, so that call would declare + // sixteen bytes where the host declared thirty-two — a different length + // ⚠ The DIGEST's felts, not the root's bytes. `replay_phase_a` absorbs + // through `absorb_root_felts`, which declares the host's 32 bytes on both + // arms and packs the algebraic arm's four felts into the one digest cell + // they already are — so the root absorb CANCELS there rather than paying a + // byte regrouping. `byte_halves` is for `program_id`, which is deliberately + // keccak-over-bytes; handing it here would regroup felts the host never + // serialised. + let main_halves: Vec> = main_cells.iter().map(RootCells::lanes_flat).collect(); + let prep_cells: Vec> = g + .tables + .iter() + .map(|h| { + h.precomputed_root + .as_ref() + .map(|c| RootCells::constant(&mut b, c)) + }) + .collect(); + let phase_a: Vec = g + .tables + .iter() + .enumerate() + .map(|(i, h)| PhaseATable { + preprocessed_root: h + .precomputed_root + .as_ref() + .map(PhaseAPreprocessed::Constant), + main_root: &main_halves[i][..], + }) + .collect(); + let (z, alpha) = replay_phase_a(&mut t, &mut b, &phase_a); + b.public(z.as_cell()); + b.public(alpha.as_cell()); + // The aggregator's byte-compare material: each epoch's L2G re-commit + // root, the very cells Phase A absorbed. + for cells in main_cells.iter().take(g.num_l2g) { + for half in cells.lanes_flat() { + b.public(half.as_cell()); + } + } + + // ---- one fork per table, with the full verification legs ---- + let mut contributions: Vec = Vec::new(); + for (i, h) in g.tables.iter().enumerate() { + let a = &per_table[i]; + let aux = a.aux_root.map(|id| RootCells::hint(&mut b, id, 0)); + let contribution = a.contribution.map(|id| b.hint_word(id, 0).as_ext()); + let composition = RootCells::hint(&mut b, a.composition_root, 0); + let ood_current: Vec = (0..(h.shape.ood_current_dims.0 * h.shape.ood_current_dims.1) + as u32) + .map(|k| b.hint_word(a.ood_current, k).as_ext()) + .collect(); + let ood_next: Vec = (0..(h.shape.ood_next_dims.0 * h.shape.ood_next_dims.1) as u32) + .map(|k| b.hint_word(a.ood_next, k).as_ext()) + .collect(); + let parts: Vec = (0..h.shape.num_parts as u32) + .map(|k| b.hint_word(a.parts, k).as_ext()) + .collect(); + let fri_roots: Vec = (0..h.shape.fri.num_committed()) + .map(|k| { + RootCells::hint( + &mut b, + a.fri_roots, + super::proof_arena::words_per_root() as u32 * k as u32, + ) + }) + .collect(); + let fri_coeffs: Vec = (0..h.shape.fri.num_terminal_coeffs() as u32) + .map(|k| b.hint_word(a.fri_coeffs, k).as_ext()) + .collect(); + let nonce = a.nonce.map(|id| b.hint_felt(id, 0)); + if let Some(c) = contribution { + contributions.push(c); + } + let mut fork = fork_table(&t, h.shape.index, h.shape.num_tables); + let absorbs = TableAbsorbs { + aux_root: aux.as_ref(), + contribution, + composition_root: &composition, + ood_current: &ood_current, + ood_next: &ood_next, + parts: &parts, + fri_roots: &fri_roots, + fri_coeffs: &fri_coeffs, + nonce, + }; + let ch = super::epoch::emit_table_challenges(&mut b, &mut fork, &h.shape, &absorbs); + let leg = &g.legs[i]; + super::epoch_verify::emit_table_verification( + &mut b, + &leg.verify, + &leg.analysis, + &ch, + &absorbs, + &super::epoch_verify::TableInputs { + precomputed_root: prep_cells[i].as_ref(), + main_root: &main_cells[i], + rap_challenges: &[z, alpha], + }, + &a.legs, + ); + } + + // ---- the closure: the global bus balances to ZERO ---- + let shape = super::logup::LogUpShape { + num_contributing_tables: contributions.len(), + num_output_bytes: 0, + }; + let target = b.ext_const(&FEE::zero()); + super::logup::emit_bus_closure(&mut b, &shape, &contributions, target); + + compile(b.finish()) +} + +/// The global program's arenas, in its declaration order. +pub(super) fn global_arena_words(g: &RealGlobal) -> Vec> { + let mut arenas: Vec> = Vec::new(); + arenas.push(super::proof_arena::commitments_to_arena( + &g.tables.iter().map(|h| h.main_root).collect::>(), + )); + for (h, leg) in g.tables.iter().zip(&g.legs) { + if let Some(root) = &h.aux_root { + arenas.push(super::proof_arena::commitments_to_arena(&[*root])); + } + if let Some(c) = &h.contribution { + arenas.push(vec![ext_word(c)]); + } + arenas.push(super::proof_arena::commitments_to_arena(&[ + h.composition_root + ])); + 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(&h.fri_roots)); + arenas.push(h.fri_coeffs.iter().map(ext_word).collect()); + if let Some(nonce) = h.nonce { + arenas.push(vec![base_word(FE::from(nonce))]); + } + arenas.push(leg.opening_arena()); + arenas.push(leg.fri_arena()); + } + arenas +} + +/// ★ THE GLOBAL LEG RUNS: the emitted verifier of a REAL fixture bundle's +/// cross-epoch global proof — per-table verification of the L2G re-commits +/// and one GLOBAL_MEMORY table per touched page behind one constant-run +/// statement, closing the GlobalMemory bus at ZERO — and publishes each +/// epoch's L2G re-commit root. Differentialled against the harvest's own +/// production challenges via the published pair; tampered via a flipped +/// L2G main root (Phase A absorbs it, so the walk cannot reach it). +#[test] +fn the_global_verifier_leg_runs_and_rejects_tampers() { + let elf_bytes = super::proof_fixture::read_inner_elf(); + let inner = super::proof_fixture::fixture_options(); + let bundle = crate::continuation::prove_continuation( + &elf_bytes, + &[], + super::proof_fixture::FIXTURE_EPOCH_LOG2, + &inner, + ) + .expect("the fixture continuation must prove"); + let g = real_global(&elf_bytes, &bundle, &inner); + let program = global_verifier_program(&g); + let arenas = global_arena_words(&g); + let exec = execute(&program, &arenas, &crate::hash_pin::BLOCK_HASHER) + .expect("the global leg must execute"); + + let pub_ext = |i: usize| super::word::word_as_ext(&exec.public_words[i].1).expect("an ext"); + assert_eq!(pub_ext(0), g.z_alpha.0, "the global z"); + assert_eq!(pub_ext(1), g.z_alpha.1, "the global alpha"); + // The published L2G re-commit roots equal the harvested main roots. + // + // ⚠ Compared through `proof_arena::commitment_lanes`, NOT by re-spelling + // the byte rendering here. The program publishes `RootCells::lanes_flat`, + // which is `u32` halves on a byte hash and FULL FELTS on an algebraic one; + // `commitment_lanes` is the flattened `commitment_words` the arena was + // written from, so the two agree by construction on either arm instead of + // this test carrying a second copy of one arm's layout. + let l2g_lanes = super::proof_arena::lanes_per_root(); + for k in 0..g.num_l2g { + let want = super::proof_arena::commitment_lanes(&g.tables[k].main_root); + assert_eq!(want.len(), l2g_lanes, "a root's published lane count"); + for (h, want) in want.into_iter().enumerate() { + let got = super::word::word_as_base(&exec.public_words[2 + l2g_lanes * k + h].1) + .expect("a root lane"); + assert_eq!(got, want, "L2G root {k} lane {h}"); + } + } + println!( + "★ global leg: {} tables ({} L2G + {} pages), {} instructions, {} published words", + g.tables.len(), + g.num_l2g, + g.tables.len() - g.num_l2g, + program.instrs.len(), + exec.public_words.len() + ); + + // Tamper: flip one byte of one L2G main root in the arena — Phase A then + // absorbs a root the walks cannot authenticate against. + let mut tampered = global_arena_words(&g); + tampered[0][0][0] += FE::one(); + assert!( + execute(&program, &tampered, &crate::hash_pin::BLOCK_HASHER).is_err(), + "a flipped L2G re-commit root must make the global leg unprovable" + ); +} diff --git a/prover/src/lfm/wrap_tests.rs b/prover/src/lfm/wrap_tests.rs index b2f119611..3f01cd4ba 100644 --- a/prover/src/lfm/wrap_tests.rs +++ b/prover/src/lfm/wrap_tests.rs @@ -403,19 +403,18 @@ fn the_wrap_proves_and_verifies() { wrap_run(super::proof_fixture::fixture_options()); } -/// ★★ THE SUITE-GATED PER-TABLE WRAP — the per-table twin of -/// [`the_fixture_epoch_wraps_batched`], and NOT `#[ignore]`d. +/// ★★ THE SUITE-GATED WRAP — the one assembled epoch verifier a suite run +/// PROVES, and NOT `#[ignore]`d. /// /// # The gap it closes /// -/// Every other per-table epoch-verifier wrap is `#[ignore]`d +/// Every other epoch-verifier wrap is `#[ignore]`d /// ([`the_wrap_proves_and_verifies`], [`the_real_block_epoch_wraps`], /// [`the_from_proof_final_epoch_wraps`], -/// [`the_real_block_proves_and_wraps_end_to_end`]), so the only assembled epoch -/// verifier a suite run ever PROVED was the batched one. The per-table proof -/// FORMAT was covered — the leg suites prove it, and the batched wrap's own -/// proof goes through [`lfm_prove`] — but the per-table epoch verifier PROGRAM -/// was not. This is the arm that keeps it from being the untested one. +/// [`the_real_block_proves_and_wraps_end_to_end`]). The proof FORMAT is covered +/// without this test — the leg suites prove it — but the assembled epoch +/// verifier PROGRAM is not. This is the arm that keeps it from being the +/// untested one. /// /// # What proving adds to an execution /// @@ -454,9 +453,9 @@ fn the_wrap_proves_and_verifies() { /// The smallest shape that still exercises every leg: the min preset /// ([`super::proof_fixture::fixture_options`] — blowup 2, ONE query) over the /// fibonacci fixture epoch ([`EpochInputs::fixture`], `FIXTURE_EPOCH_LOG2`). -/// `EpochInputs::fixture` rather than `from_env`, deliberately and exactly as -/// the batched twin does it: a measurement run's `LFM_CENSUS_*` variables must -/// not be able to turn a suite gate into a real-block run. +/// `EpochInputs::fixture` rather than `from_env`, deliberately: a measurement +/// run's `LFM_CENSUS_*` variables must not be able to turn a suite gate into a +/// real-block run. /// /// # What it costs, MEASURED /// @@ -473,8 +472,7 @@ fn the_wrap_proves_and_verifies() { /// ⚠ On CI, expect **30-60s** rather than 9.2s: the runners are 2-4 vCPU and /// the suite runs `--test-threads=1`, so almost none of the box's parallelism /// is there. `? INFERRED` — scaled from the box wall, not measured on a runner. -/// The batched twin already pays a comparable bill today and is being deleted, -/// so the steady state is one test of this class, not two. +/// This is the only test of its class, so that bill is paid once per suite run. /// /// ⚠ These numbers, and NOT the `~2.25M` instructions the slice-0 doc quotes, /// describe this shape. In a clean environment the two tests are the SAME @@ -697,8 +695,8 @@ fn fixture_wrap_run(inner: ProofOptions, inputs: EpochInputs) { // refuses to lie without ever showing that the verifier catches one. This // arm hands the verifier the REAL proof under a claim it does not answer; // `absorb_lfm_statement` binds the proof to its published words, so it must - // reject. Costs one verify (~0.16s against a 9s test), which is why the - // batched twin carries it and why there was no case for leaving it out. + // reject. Costs one verify (~0.16s against a 9s test), so there was no case + // for leaving it out. let mut moved = proved.public_words.clone(); moved[0].1[0] += FE::one(); assert!( @@ -1723,293 +1721,6 @@ fn the_row_cliff_panel_reproduces_the_artifacts_measured_headroom() { } } -// ===================== the BATCHED wrap (M-8 / T3) ===================== - -/// The batched sibling of [`wrap_run_from`]: the same census-env epoch proved -/// through `multi_prove_batched`, its ASSEMBLED BATCHED verifier emitted, and -/// that program PROVED on the per-table LFM prover — batching the wrap itself -/// is out of scope; the wrap-side economy under measurement is the verifier -/// program's, not the wrap prover's. -fn batched_wrap_run_from(inner: ProofOptions, inputs: EpochInputs) { - let t_epoch = Instant::now(); - let e = super::epoch_tests::real_batched_epoch_from(inner.clone(), inputs); - let n = e.proof.tables.len(); - let h_min = e.shape.heights.iter().copied().min().expect("tables"); - let h_max = e.shape.heights.iter().copied().max().expect("tables"); - let profile = format!( - "{n} tables, LDE 2^{h_min}..2^{h_max}, batched {}/standalone {}", - e.challenges.fri.plan.batched.len(), - e.challenges.fri.plan.standalone.len(), - ); - println!( - "batched inner epoch: {profile}, blowup {}, {} queries, grinding {} — built and \ - HOST-VERIFIED in {:.1}s", - inner.blowup_factor, - e.fri_params.num_queries, - e.fri_params.grinding_factor, - t_epoch.elapsed().as_secs_f64() - ); - - let t = Instant::now(); - let program = super::epoch_tests::batched_epoch_program_with(&e, true, false); - let mut arenas = super::epoch_tests::batched_epoch_arenas(&e); - arenas.push(super::epoch_verify_tests::batched_opening_arena(&e)); - arenas.push(super::epoch_verify_tests::batched_fri_arena(&e)); - println!( - " emitted the assembled BATCHED verifier in {:.1}s", - t.elapsed().as_secs_f64() - ); - report_program("THE BATCHED WRAPPED PROGRAM", &profile, &program); - let (main, aux) = report_census(&format!("assembled batched verifier, {profile}"), &program); - - // ---- the spine/legs split, against the batched CLOSED FORM — the number - // the campaign predicts: leg hashing collapses to ~one mixed path per - // round per query plus the small prep trees. - let spine = super::epoch_tests::batched_epoch_program(&e); - let wrap_hash = WrapHash::production(); - let leg_hash_ops = hash_ops(&program, wrap_hash) - hash_ops(&spine, wrap_hash); - let per_query = super::batched_epoch_verify::batched_query_permutations_for( - &e.shape, - &e.fri_params, - wrap_hash, - ); - assert_eq!( - leg_hash_ops, - e.proof.queries.len() * per_query, - "the emitted leg {wrap_hash:?} operations must equal the batched closed form" - ); - println!( - " spine {} instr / {} {:?} ops / {} words legs {} / {} / {} \ - per query: {per_query} ops ({} queries, closed form checked)", - spine.instrs.len(), - hash_ops(&spine, wrap_hash), - wrap_hash, - arena_words(&spine), - program.instrs.len() - spine.instrs.len(), - leg_hash_ops, - arena_words(&program) - arena_words(&spine), - e.proof.queries.len(), - ); - println!( - " projected peak RSS for this run: {:.1} GiB", - projected_peak_bytes(main, aux) / (1u64 << 30) as f64 - ); - - let opts = wrap_options(); - let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER); - println!( - " wrap options: blowup {}, {} queries, grinding {}\n chip log-heights: {:?}", - opts.blowup_factor, opts.fri_number_of_queries, opts.grinding_factor, artifacts.log_heights - ); - - // ---- PROVE (the per-table LFM prover, deliberately). - let t = Instant::now(); - let proved = - lfm_prove(&program, &artifacts, &arenas, &opts).expect("the batched wrap must prove"); - let prove_secs = t.elapsed().as_secs_f64(); - let size = rkyv::to_bytes::(&proved.proof) - .expect("the wrap proof must serialize") - .len(); - - // ---- VERIFY. - let t = Instant::now(); - assert!( - verify_against( - &artifacts.roots, - &artifacts.program_id, - artifacts.keccak_rnd_chunks, - &proved.proof, - &proved.public_words, - &opts, - artifacts.hasher, - artifacts.chip_set, - ), - "the batched wrap proof must verify" - ); - let verify_secs = t.elapsed().as_secs_f64(); - println!( - "\n★ BATCHED WRAP PROVED AND VERIFIED ({profile}, inner blowup {}, {} queries)\n \ - prove {prove_secs:.1}s / verify {verify_secs:.2}s / proof {size} bytes / \ - {} published words / {} sub-proofs\n cells {main} main + {aux} aux ext", - inner.blowup_factor, - e.fri_params.num_queries, - proved.public_words.len(), - proved.proof.proofs.len(), - ); - - // ---- the published words are the execution's own, so the spine's - // differential holds of the PROVED run: the shared pair, the attestation, - // and the closure, by value against the harness's oracles. - let pub_ext = - |i: usize| super::word::word_as_ext(&proved.public_words[i].1).expect("an ext challenge"); - let [z, alpha] = e.challenges.lookup.as_slice() else { - panic!("the shared pair is (z, alpha)"); - }; - assert_eq!(pub_ext(0), *z, "the proved run publishes z"); - assert_eq!(pub_ext(1), *alpha, "the proved run publishes alpha"); - assert_eq!( - super::word::word_as_ext(&proved.public_words[proved.public_words.len() - 1].1) - .expect("the bus total is ext"), - e.expected_bus_balance, - "the proved run reaches production's own COMMIT-bus target" - ); - - // ---- FALSIFICATION 1: a tampered inner opening makes the wrap - // UNBUILDABLE (the checks are asserts in a straight-line program; a false - // statement has no execution at all). - let open_idx = arenas.len() - 2; - let mut tampered = arenas.clone(); - tampered[open_idx][0][0] += FE::one(); - match lfm_prove(&program, &artifacts, &tampered, &opts) { - Err(LfmProveError::Exec(err)) => { - println!(" TAMPERED opening word 0: the batched wrap is UNBUILDABLE ({err:?})") - } - Err(LfmProveError::Prover(err)) => { - panic!("a tampered inner proof must fail in execution, not in the prover: {err:?}") - } - Ok(_) => panic!("a tampered opened value must not produce a wrap proof"), - } - - // ---- FALSIFICATION 2: the honest proof against a MOVED claimed statement - // must reject at verification. - let mut moved = proved.public_words.clone(); - moved[0].1[0] += FE::one(); - assert!( - !verify_against( - &artifacts.roots, - &artifacts.program_id, - artifacts.keccak_rnd_chunks, - &proved.proof, - &moved, - &opts, - artifacts.hasher, - artifacts.chip_set, - ), - "a moved claimed word must be rejected" - ); - println!(" MOVED claimed word 0: rejected"); -} - -/// ★ GATE B's batched sibling — a REAL Ethereum-block epoch, proved through -/// the BATCHED base layer and wrapped. Same env contract as -/// [`the_real_block_epoch_wraps`]; run both on the same box for the T3 -/// comparison the campaign exists to make — memory first, at 2^16 and at the -/// 2^24 posture. -#[test] -#[ignore] -fn the_real_block_epoch_wraps_batched() { - for var in ["LFM_CENSUS_ELF", "LFM_CENSUS_INPUT"] { - assert!( - std::env::var(var).is_ok(), - "{var} must name a file: this test wraps a REAL block epoch" - ); - } - let inputs = EpochInputs::from_env(); - let mut inner = crate::recursion::Preset::Blowup4.options(); - if let Ok(v) = std::env::var("LFM_WRAP_QUERIES") { - inner.fri_number_of_queries = v.parse().expect("LFM_WRAP_QUERIES must be an integer"); - } - println!( - "★ REAL-BLOCK BATCHED WRAP: guest {}, {} bytes of private input, 2^{} cycles/epoch, \ - inner blowup {} / {} queries{}", - inputs.label, - inputs.private_input.len(), - inputs.epoch_log2, - inner.blowup_factor, - inner.fri_number_of_queries, - if inner.fri_number_of_queries < 110 { - " (REDUCED — not a security parameter set)" - } else { - " (the secure preset)" - }, - ); - batched_wrap_run_from(inner, inputs); -} - -/// ★ The P2 DRIVER'S FLOW at the fixture, not ignored: a batched-carved -/// continuation bundle's FINAL epoch reconstructs from proofs alone, its -/// CARVED program wraps end to end, and the wrap PUBLISHES the carved L2G -/// root — byte-compared against the bundle's claimed root, exactly the check -/// P3's aggregator makes. Gated on every suite run, so the block driver's box -/// run cannot be the first execution of any of it. -#[test] -fn the_fixture_continuation_epoch_wraps_batched_from_proofs() { - let elf_bytes = super::proof_fixture::read_inner_elf(); - let inner = super::proof_fixture::fixture_options(); - let bundle = crate::continuation::prove_continuation_batched( - &elf_bytes, - &[], - super::proof_fixture::FIXTURE_EPOCH_LOG2, - &inner, - ) - .expect("the fixture continuation must prove batched"); - let n = bundle.num_epochs(); - assert!(n >= 2, "the fixture continuation must have a final epoch"); - - let e = super::epoch_tests::real_batched_epoch_from_continuation( - &inner, - &elf_bytes, - &bundle, - n - 1, - None, - ) - .expect("the final epoch must reconstruct from proofs alone"); - let program = super::epoch_tests::batched_epoch_program_with(&e, true, false); - let mut arenas = super::epoch_tests::batched_epoch_arenas(&e); - arenas.push(super::epoch_verify_tests::batched_opening_arena(&e)); - arenas.push(super::epoch_verify_tests::batched_fri_arena(&e)); - let opts = wrap_options(); - let artifacts = build_artifacts_with_hasher(&program, &opts, crate::hash_pin::BLOCK_HASHER); - - let proved = - lfm_prove(&program, &artifacts, &arenas, &opts).expect("the carved wrap must prove"); - assert!( - verify_against( - &artifacts.roots, - &artifacts.program_id, - artifacts.keccak_rnd_chunks, - &proved.proof, - &proved.public_words, - &opts, - artifacts.hasher, - artifacts.chip_set, - ), - "the carved wrap of the final epoch must verify" - ); - - // The published-word schema's aggregator-facing check: the last - // `lanes_per_root()` words are the carved L2G root — eight byte halves on a - // byte hash, four felts on an algebraic one — equal to the bundle's claimed - // root as the host publishes it. - let root = bundle.epoch_view(n - 1).l2g_root(); - let lanes = super::proof_arena::lanes_per_root(); - let published_root: Vec = proved.public_words[proved.public_words.len() - lanes..] - .iter() - .map(|w| super::word::word_as_base(&w.1).expect("a root lane is a base word")) - .collect(); - let expected_root: Vec = super::proof_arena::commitment_lanes(&root); - assert_eq!( - published_root, expected_root, - "the wrap must publish the carved L2G root it verified under" - ); - println!( - "★ P2 fixture driver flow: FINAL carved epoch wrapped, verified, and its published L2G root matches the bundle's claim ({} published words)", - proved.public_words.len() - ); -} - -/// The batched wrap at the FIXTURE, not ignored — the whole T3 instrument's -/// flow (batched inner, emitted verifier, per-table LFM prove, verify, both -/// falsification arms) gated on every suite run, so the box run cannot be the -/// first execution of any of it. -#[test] -fn the_fixture_epoch_wraps_batched() { - batched_wrap_run_from( - super::proof_fixture::fixture_options(), - EpochInputs::fixture(), - ); -} - /// ★ GATE B (P1) — a from-proof epoch wraps end to end, and it is the FINAL /// epoch of its continuation (HALT on board): the shape the real block's last /// epoch has, which the session harness cannot build. The epoch reaches the @@ -2252,15 +1963,6 @@ fn the_real_block_proves_and_wraps_end_to_end() { ); } -/// ★★★ THE P2 BLOCK DRIVER — [`the_real_block_proves_and_wraps_end_to_end`] -/// on the BATCHED format: every epoch proven as one mixed-MMCS proof with the -/// L2G main matrix carved standalone (`prove_continuation_batched`), the -/// bundle completely host-verified (epochs, global proof, the root-equality -/// binding reading the carved roots), then every epoch wrapped from the -/// proofs alone through the batched from-proof constructor and the CARVED -/// emitted verifier. One process; the epoch proves are `Retain`; the wrap -/// proves are `Retain`. Same env contract as the per-table driver; run both -/// on the same box for the P2 comparison the campaign exists to make. /// /// The real block's cross-epoch PAGE CENSUS — execution and collection only, /// nothing proven. Prints the numbers the aggregator's closed-form census @@ -2301,175 +2003,3 @@ fn the_real_blocks_page_census() { ); println!(" page-table height histogram (log2 padded rows -> pages): {hist:?}"); } - -/// Run at the 2^24 posture: -/// ```text -/// LFM_CENSUS_ELF=/path/to/ethrex.elf \ -/// LFM_CENSUS_INPUT=/path/to/ethrex_mainnet_25368371.bin \ -/// LFM_CENSUS_EPOCH_LOG2=24 LAMBDA_VM_MAX_ROWS_LOG2=24 \ -/// cargo test --release -p lambda-vm-prover --lib \ -/// lfm::wrap_tests::the_real_block_proves_and_wraps_end_to_end_batched -- --ignored --exact --nocapture -/// ``` -#[test] -#[ignore] -fn the_real_block_proves_and_wraps_end_to_end_batched() { - for var in ["LFM_CENSUS_ELF", "LFM_CENSUS_INPUT"] { - assert!( - std::env::var(var).is_ok(), - "{var} must name a file: this test proves a REAL block, and without \ - it the harness would build the fibonacci fixture and report it \ - under this test's name" - ); - } - let inputs = EpochInputs::from_env(); - let mut inner = crate::recursion::Preset::Blowup4.options(); - if let Ok(v) = std::env::var("LFM_WRAP_QUERIES") { - inner.fri_number_of_queries = v.parse().expect("LFM_WRAP_QUERIES must be an integer"); - } - println!( - "★ P2 BLOCK RUN (batched): guest {}, {} bytes of private input, 2^{} cycles/epoch, \ - inner blowup {} / {} queries{} — epoch residency Retain, wrap residency Retain", - inputs.label, - inputs.private_input.len(), - inputs.epoch_log2, - inner.blowup_factor, - inner.fri_number_of_queries, - if inner.fri_number_of_queries < 110 { - " (REDUCED — not a security parameter set)" - } else { - " (the secure preset)" - }, - ); - - let t_total = Instant::now(); - - // ---- the base layer: every epoch BATCHED-CARVED + the (per-table) - // global proof, production's path. - let t = Instant::now(); - let bundle = crate::continuation::prove_continuation_batched( - &inputs.elf_bytes, - &inputs.private_input, - inputs.epoch_log2, - &inner, - ) - .expect("the block must prove batched"); - let base_secs = t.elapsed().as_secs_f64(); - let n = bundle.num_epochs(); - let bundle_bytes = rkyv::to_bytes::(&bundle) - .expect("the bundle must serialize") - .len(); - println!( - " base (batched): {n} epochs + global proof in {base_secs:.1}s \ - ({bundle_bytes} bundle bytes), peak RSS so far {:?} GiB", - peak_rss_gib(), - ); - - // ---- full host verification: every epoch's carved batched verify, the - // global proof, and the binding view reading the carved roots. - let t = Instant::now(); - let out = crate::continuation::verify_continuation(&inputs.elf_bytes, &bundle, &inner) - .expect("the bundle must be well-formed"); - assert!( - out.is_some(), - "the batched block bundle must host-verify (epochs + global + L2G root binding)" - ); - let host_verify_secs = t.elapsed().as_secs_f64(); - println!(" host verify (epochs + global + binding): {host_verify_secs:.1}s"); - - // ---- every epoch, wrapped from the proofs alone: the CARVED program. - let elf = executor::elf::Elf::load(&inputs.elf_bytes).expect("the inner ELF must load"); - let decode = crate::tables::decode::commitment_from_elf(&elf, &inner) - .expect("the DECODE commitment must compute"); - let wrap_opts = wrap_options(); - let (mut construct_secs, mut wrap_prove_secs, mut wrap_verify_secs) = (0f64, 0f64, 0f64); - let mut wrap_sizes = Vec::new(); - for i in 0..n { - let t = Instant::now(); - let e = super::epoch_tests::real_batched_epoch_from_continuation( - &inner, - &inputs.elf_bytes, - &bundle, - i, - Some(decode), - ) - .unwrap_or_else(|err| panic!("epoch {i} must reconstruct from the bundle: {err}")); - let program = super::epoch_tests::batched_epoch_program_with(&e, true, false); - let mut arenas = super::epoch_tests::batched_epoch_arenas(&e); - arenas.push(super::epoch_verify_tests::batched_opening_arena(&e)); - arenas.push(super::epoch_verify_tests::batched_fri_arena(&e)); - let artifacts = - build_artifacts_with_hasher(&program, &wrap_opts, crate::hash_pin::BLOCK_HASHER); - let c = t.elapsed().as_secs_f64(); - construct_secs += c; - - let t = Instant::now(); - let proved = lfm_prove(&program, &artifacts, &arenas, &wrap_opts) - .unwrap_or_else(|err| panic!("epoch {i}'s wrap must prove: {err:?}")); - let p = t.elapsed().as_secs_f64(); - wrap_prove_secs += p; - - let t = Instant::now(); - assert!( - verify_against( - &artifacts.roots, - &artifacts.program_id, - artifacts.keccak_rnd_chunks, - &proved.proof, - &proved.public_words, - &wrap_opts, - artifacts.hasher, - artifacts.chip_set, - ), - "epoch {i}'s wrap must verify" - ); - let v = t.elapsed().as_secs_f64(); - wrap_verify_secs += v; - - // The published-word schema: the wrap's last 8 published words are - // the carved L2G root's halves — byte-compare them against the - // bundle's claimed root, exactly the check P3's aggregator makes. - let root = bundle.epoch_view(i).l2g_root(); - let published_root: Vec = proved.public_words[proved.public_words.len() - 8..] - .iter() - .map(|w| super::word::word_as_base(&w.1).expect("a root half is a base word")) - .collect(); - let expected_root: Vec = root - .chunks(4) - .map(|c: &[u8]| { - FE::from(u32::from_le_bytes(c.try_into().expect("a root is 32 bytes")) as u64) - }) - .collect(); - assert_eq!( - published_root, expected_root, - "epoch {i}: the wrap must publish its carved L2G root" - ); - - let size = rkyv::to_bytes::(&proved.proof) - .expect("the wrap proof must serialize") - .len(); - wrap_sizes.push(size); - println!( - " epoch {i}: reconstruct+emit {c:.1}s, wrap prove {p:.1}s, verify {v:.2}s, \ - {size} bytes, {} sub-proofs, L2G root published", - proved.proof.proofs.len(), - ); - } - - let total = t_total.elapsed().as_secs_f64(); - println!( - "\n★★★ P2 BLOCK RECORD (batched): {n} epochs @2^{} cycles, inner blowup {} / {}q, \ - wrap blowup {} / {}q, residency Retain both layers\n \ - base prove {base_secs:.1}s + host verify {host_verify_secs:.1}s + wrap constructs \ - {construct_secs:.1}s + wrap proves {wrap_prove_secs:.1}s + wrap verifies \ - {wrap_verify_secs:.1}s\n TOTAL WALL {total:.1}s ({:.1} min)\n \ - proofs: bundle {bundle_bytes} B, wraps {wrap_sizes:?} B\n \ - peak RSS (VmHWM): {:?} GiB", - inputs.epoch_log2, - inner.blowup_factor, - inner.fri_number_of_queries, - wrap_opts.blowup_factor, - wrap_opts.fri_number_of_queries, - total / 60.0, - peak_rss_gib(), - ); -} From c4c53dcefff19b2266e1db6c7589934d1c1001b6 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 7 Sep 2026 23:51:33 -0300 Subject: [PATCH 3/7] refactor(lfm): delete the batched epoch emitters and prove/verify entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `batched_epoch.rs` (the shape histogram and the one-shared-query-set challenge spine) and `batched_epoch_verify.rs` (the mixed-height MMCS walk, the query mix, the batched FRI leg and the standalone terminal check) emit a verifier for a proof type that no longer exists. Their per-table counterparts are `epoch.rs` (`fork_table` + `emit_table_challenges`) and `epoch_verify.rs` + `sub_proof.rs`, both untouched. `proof.rs` loses `lfm_prove_batched`, `BatchedLfmProof`, `lfm_verify_batched` and `verify_against_batched`. `lfm_prove` / `lfm_verify` / `verify_against_artifacts` / `verify_against` were already the default everywhere and are unchanged; `expected_public_balance` and `decide_lfm_residency` are shared with the per-table path and stay. `aggregation_wrap_options` stays: blowup 4 / 110 queries with the FRI terminal at 2^8 is the wrap-to-aggregator preset, priced on the aggregator's per-query Merkle-walk volume, and is a property of the wrap layer rather than of a proof format. It has no caller in-tree at this commit. Note `epoch_verify.rs::group_leaf_felts` is a DIFFERENT function from the deleted `batched_epoch_verify.rs::group_leaf_felts` — same name, per-table subject, many callers — and is untouched. --- prover/src/lfm/batched_epoch.rs | 449 ---------------------- prover/src/lfm/batched_epoch_verify.rs | 505 ------------------------- prover/src/lfm/mod.rs | 2 - prover/src/lfm/proof.rs | 175 +-------- 4 files changed, 1 insertion(+), 1130 deletions(-) delete mode 100644 prover/src/lfm/batched_epoch.rs delete mode 100644 prover/src/lfm/batched_epoch_verify.rs diff --git a/prover/src/lfm/batched_epoch.rs b/prover/src/lfm/batched_epoch.rs deleted file mode 100644 index 9b0d2f823..000000000 --- a/prover/src/lfm/batched_epoch.rs +++ /dev/null @@ -1,449 +0,0 @@ -//! Assembly — the BATCHED epoch's challenge replay, the M-8 spine. -//! -//! The batched counterpart of [`super::epoch`]: the same discipline — one cell -//! per value, every consumer reads it, challenges are the transcript's and -//! never the arena's — over a different walk. The order authority is -//! `stark::batched::verifier::replay_epoch_transcript`, which is itself pinned -//! to the prover's ENDING TRANSCRIPT STATE by -//! `replay_matches_the_provers_ending_state`; this module replays exactly that -//! sequence: -//! -//! - the SHAPE HISTOGRAM, before the first root (Recommendation S: the epoch -//! commits to what it is before any challenge is drawn); -//! - every preprocessed table's root FROM THE AIR SET, per table in table -//! order — the same [`super::epoch::RootCells`] + `PrepSource` provenance -//! machinery the per-table program uses, verbatim; the DECODE cells feed the -//! attestation join unchanged; -//! - `main_root`; the shared LogUp pair; `aux_root`; every table's `L`; -//! - ALL constraint-batching `β`s consecutively; `parts_root`; -//! - per table: `z` (drawn once and constrained outside both domains — the -//! [`super::epoch::emit_z_ood`] disposition), both OOD blocks COLUMN-major, -//! then the claimed parts; -//! - ALL DEEP `γ`s consecutively; -//! - round 4 (`derive_batched_fri_challenges`): the histogram A SECOND TIME, -//! one shared DEEP-mix `α`, per committed layer `ζ` sampled THEN the root -//! absorbed, the final `ζ` iff the codeword folds, the terminal -//! coefficients, grinding, and ONE shared query-index set — `h_max − 1` -//! bits per query in the TALLEST domain, which every shorter consumer -//! REDUCES (`fri/mmcs.rs`'s index convention) rather than re-draws. -//! -//! **No forks, no index separators.** `fork_table` is dead on this path: the -//! whole epoch is one transcript, which is the wrap-side economy the campaign -//! is after — one path per round per query instead of one per table per round. - -use crate::tables::types::FE; - -use stark::config::Commitment; -use stark::fri::batched::{BatchedFriLayout, FriInstancePlan}; - -use super::builder::{Bit, Ext, Felt, LfmBuilder}; -use super::epoch::RootCells; -use super::transcript_replay::TranscriptReplay; - -/// The batched FRI's program shape: production's own layout and partition, -/// captured at emit time so the instance-class split UNROLLS into -/// straight-line code — there is deliberately no second in-machine derivation -/// of either. -/// -/// `total_folds` comes from the batched class's `h_max`, the terminal length -/// and `effective_k` from its `h_min` ([`BatchedFriLayout::new`]'s floor); -/// standalone tables keep terminal-only instances and appear in -/// [`FriInstancePlan::standalone`]. -#[derive(Clone, Debug)] -pub struct BatchedFriShape { - pub layout: BatchedFriLayout, - pub plan: FriInstancePlan, -} - -impl BatchedFriShape { - /// Derive from the epoch's LDE heights — the same call the host verifier - /// makes, so the two cannot disagree about the partition. - pub fn new(heights: &[usize], blowup_log: u32, final_poly_log_degree: u32) -> Self { - let plan = FriInstancePlan::new(heights, blowup_log, final_poly_log_degree) - .expect("the epoch's heights must partition"); - let layout = - BatchedFriLayout::new(plan.h_max, plan.h_min, blowup_log, final_poly_log_degree); - Self { layout, plan } - } - - pub fn num_committed(&self) -> usize { - self.layout.num_committed - } - - pub fn num_terminal_coeffs(&self) -> usize { - 1usize << self.layout.effective_k - } - - /// Bits one shared query index carries — `sample_u64(2^(h_max − 1))` in - /// the TALLEST domain. - pub fn index_bits(&self) -> usize { - self.plan.h_max - 1 - } -} - -/// One table's slice of the batched spine — every field program shape. -#[derive(Clone, Debug)] -pub struct BatchedTableShape { - /// `log2` of the trace length; with the epoch blowup this is the table's - /// LDE height, the `z`-guard's domain and the histogram's `h`. - pub log2_trace_length: u32, - /// Whether the table carries a bus contribution `L`. - pub has_contribution: bool, - /// `(width, height)` of the current-row OOD block. - pub ood_current_dims: (usize, usize), - /// `(width, height)` of the pruned next-row OOD block. - pub ood_next_dims: (usize, usize), - /// Composition-poly parts. - pub num_parts: usize, -} - -/// The whole batched epoch's spine shape. -#[derive(Clone, Debug)] -pub struct BatchedEpochShape { - pub tables: Vec, - /// `log2` LDE height per table, table order — the histogram's heights and - /// the FRI's index space. - pub heights: Vec, - /// Total committed width per table (main + aux + parts columns), the - /// histogram's widths — `EpochShape::total_widths`, precomputed host-side. - pub total_widths: Vec, - pub log2_blowup: u32, - pub coset_offset: FE, - /// Whether ANY table has a RAP — fixes the aux root's and the shared - /// LogUp draw's presence together. - pub has_aux: bool, - /// The carved table `(index, main width)`, when the epoch commits one - /// table's main matrix standalone (the L2G carve — - /// `stark::batched::shape::CarvedMain`). Program shape: fixes the carved - /// root's absorb slot and the carved walk's presence. - pub carved_main: Option<(usize, usize)>, - pub fri: BatchedFriShape, - pub grinding_factor: u8, - pub num_queries: usize, -} - -impl BatchedEpochShape { - fn check(&self) { - assert_eq!(self.tables.len(), self.heights.len()); - assert_eq!(self.tables.len(), self.total_widths.len()); - assert!(!self.tables.is_empty(), "an epoch has tables"); - for (t, h) in self.tables.iter().zip(&self.heights) { - assert_eq!( - t.log2_trace_length + self.log2_blowup, - *h as u32, - "a table's histogram height IS its LDE height" - ); - assert!(t.num_parts > 0, "a composition polynomial has parts"); - } - assert_eq!( - self.has_aux, - self.tables.iter().any(|t| t.has_contribution), - "the aux round exists exactly when some table contributes" - ); - } -} - -/// A preprocessed root as the spine absorbs it — the same three provenances -/// as the per-table program's Phase A, with the same absorb economies: a -/// program-text root absorbs as literal bytes (no splice arithmetic), a -/// derived or hinted one as its cells. -pub enum BatchedPrepRoot<'a> { - /// BITWISE / KECCAK_RC / PAGE zero-init: a function of the options alone. - Constant(&'a Commitment), - /// REGISTER (derived in-machine) or DECODE (hinted, attestation-joined). - Cells(&'a RootCells), -} - -/// The proof-carried cells the batched spine absorbs — the caller's cells, -/// hinted once and handed here, never re-hinted. This is the assembly join -/// surface: the same values go on to the constraint legs, the DEEP crossing, -/// the mixed walks and the LogUp closure. -pub struct BatchedEpochAbsorbs<'a> { - /// Per table in table order: the preprocessed root, `Some` exactly when - /// the AIR is preprocessed. - pub prep_roots: &'a [Option>], - /// The carved table's standalone main root — PROOF-CARRIED cells, present - /// exactly when [`BatchedEpochShape::carved_main`] is. Absorbed after - /// every preprocessed root, before `main_root` — the slot production's - /// `replay_epoch_transcript_carved` pins. - pub carved_root: Option<&'a RootCells>, - pub main_root: &'a RootCells, - /// Present exactly when [`BatchedEpochShape::has_aux`]. - pub aux_root: Option<&'a RootCells>, - /// Per table: the bus contribution `L`, `Some` exactly when the table's - /// shape says so. The LogUp closure sums THESE cells. - pub contributions: &'a [Option], - pub parts_root: &'a RootCells, - /// Per table: the OOD data, row-major as the proof carries it. - pub ood: &'a [BatchedTableOod<'a>], - /// Per table: the STANDALONE class's terminal polynomial, `Some` exactly - /// for `plan.standalone`. Absorbed right after the shared DEEP-mix `α`, - /// before the first `ζ` — the binding that keeps a standalone polynomial - /// from being chosen after the query indices are known (see - /// `derive_batched_fri_challenges`' doc). The standalone terminal checks - /// evaluate THESE cells. - pub standalone_coeffs: &'a [Option>], - /// The batched instance's committed layer roots, fold order. - pub fri_roots: &'a [RootCells], - /// The batched instance's terminal coefficients, low-to-high. - pub fri_coeffs: &'a [Ext], - /// The grinding nonce, present exactly when `grinding_factor > 0`. - pub nonce: Option, -} - -/// One table's OOD cells. -pub struct BatchedTableOod<'a> { - pub current: &'a [Ext], - pub next: &'a [Ext], - pub parts: &'a [Ext], -} - -/// The batched epoch's challenges, as cells. -pub struct BatchedEpochChallenges { - /// The shared LogUp pair `(z, α)`. - pub lookup: (Ext, Ext), - /// One constraint-batching `β` per table, table order. - pub betas: Vec, - /// One OOD point per table, table order. - pub zs: Vec, - /// One DEEP `γ` per table, table order. - pub gammas: Vec, - /// The shared DEEP-mix `α` — powers are assigned by `plan.batched` - /// POSITION, not table index. - pub alpha: Ext, - /// `ζ₀ .. ζ_C` of the ONE batched instance. - pub zetas: Vec, - /// Per query: the SHARED index bits, low-to-high, `h_max − 1` of them in - /// the tallest domain. Every shorter round/table REDUCES by dropping low - /// bits; nothing re-draws. - pub iota_bits: Vec>, -} - -/// The canonical shape-histogram binding (`absorb_shape_histogram`) — every -/// height and width is program shape, so all of it is constant. Production -/// absorbs it twice (the spine's head and round 4), and so does the machine. -/// -/// ⚠ `1 + 2n` appends, matching `absorb_shape_histogram`'s `append_bytes` calls -/// one for one, and NOT the single concatenated run this used to emit. The two -/// are the same bytes and the same digest under a byte transcript, which is why -/// the run was correct and why it stayed correct silently; an algebraic -/// transcript length-prefixes each call, so the run would absorb one long field -/// where the host absorbed `1 + 2n` short ones. See `transcript_replay::Append`. -pub fn emit_shape_histogram(t: &mut TranscriptReplay, heights: &[usize], widths: &[usize]) { - assert_eq!( - heights.len(), - widths.len(), - "the shape histogram needs one width per height" - ); - t.append_const_bytes(&(heights.len() as u64).to_le_bytes()); - for (h, w) in heights.iter().zip(widths) { - t.append_const_bytes(&(*h as u64).to_le_bytes()); - t.append_const_bytes(&(*w as u64).to_le_bytes()); - } -} - -/// Replay the whole batched epoch transcript. `t` must be positioned right -/// after the statement absorb — there is no Phase A and no fork on this path. -pub fn emit_batched_epoch_challenges( - b: &mut LfmBuilder, - t: &mut TranscriptReplay, - shape: &BatchedEpochShape, - absorbs: &BatchedEpochAbsorbs<'_>, -) -> BatchedEpochChallenges { - shape.check(); - let n = shape.tables.len(); - assert_eq!(absorbs.prep_roots.len(), n, "one prep slot per table"); - assert_eq!( - absorbs.contributions.len(), - n, - "one contribution slot per table" - ); - assert_eq!(absorbs.ood.len(), n, "one OOD bundle per table"); - assert_eq!( - absorbs.aux_root.is_some(), - shape.has_aux, - "the aux root's presence is shape" - ); - for (table, (t_shape, l)) in shape.tables.iter().zip(absorbs.contributions).enumerate() { - assert_eq!( - l.is_some(), - t_shape.has_contribution, - "table {table}: the contribution's presence is shape" - ); - } - for (table, (t_shape, ood)) in shape.tables.iter().zip(absorbs.ood).enumerate() { - assert_eq!( - ood.current.len(), - t_shape.ood_current_dims.0 * t_shape.ood_current_dims.1, - "table {table}: the current-row OOD block must match its dims" - ); - assert_eq!( - ood.next.len(), - t_shape.ood_next_dims.0 * t_shape.ood_next_dims.1, - "table {table}: the next-row OOD block must match its dims" - ); - assert_eq!( - ood.parts.len(), - t_shape.num_parts, - "table {table}: one cell per part" - ); - } - assert_eq!( - absorbs.standalone_coeffs.len(), - n, - "one standalone slot per table" - ); - for (table, coeffs) in absorbs.standalone_coeffs.iter().enumerate() { - assert_eq!( - coeffs.is_some(), - shape.fri.plan.standalone.contains(&table), - "table {table}: a standalone terminal exists exactly for the \ - standalone class" - ); - if let Some(coeffs) = coeffs { - assert_eq!( - coeffs.len(), - 1usize << (shape.heights[table] as u32 - shape.log2_blowup), - "table {table}: the standalone degree bound is the trace length" - ); - } - } - assert_eq!( - absorbs.fri_roots.len(), - shape.fri.num_committed(), - "one root per committed layer" - ); - assert_eq!( - absorbs.fri_coeffs.len(), - shape.fri.num_terminal_coeffs(), - "the terminal coefficient count is shape" - ); - assert_eq!( - absorbs.nonce.is_some(), - shape.grinding_factor > 0, - "a nonce exists exactly when grinding is on" - ); - - // ---- Recommendation S: the histogram, before the first root. - emit_shape_histogram(t, &shape.heights, &shape.total_widths); - - // ---- every preprocessed root, from the AIR set, table order. - // - // Misaligned appends, same as `replay_phase_a` and for the same reason: - // the statement leaves the first segment's cursor at shift 3 - // (`statement_replay`'s module doc prices this), and the histogram — - // 8 + 16·n bytes, ≡ 0 (mod 4) — does not move it. Every segment after - // the first sample starts with the 32-byte reversed digest, so all the - // downstream absorbs are aligned. - for root in absorbs.prep_roots.iter().flatten() { - match root { - BatchedPrepRoot::Constant(bytes) => t.append_const_bytes(&bytes[..]), - BatchedPrepRoot::Cells(cells) => cells.absorb_misaligned(b, t), - } - } - assert_eq!( - absorbs.carved_root.is_some(), - shape.carved_main.is_some(), - "the carved root's presence is shape" - ); - if let Some(root) = absorbs.carved_root { - root.absorb_misaligned(b, t); - } - absorbs.main_root.absorb_misaligned(b, t); - - // ---- the shared LogUp pair. - let lookup = (t.sample_ext(b), t.sample_ext(b)); - - // ---- aux root, then every table's L. - if let Some(root) = absorbs.aux_root { - root.absorb(b, t); - } - for l in absorbs.contributions.iter().flatten() { - super::epoch::append_ext_cell(b, t, *l); - } - - // ---- ALL betas, consecutively. - let betas: Vec = (0..n).map(|_| t.sample_ext(b)).collect(); - - absorbs.parts_root.absorb(b, t); - - // ---- per table: z, both OOD blocks COLUMN-major, parts. - let mut zs = Vec::with_capacity(n); - for (t_shape, ood) in shape.tables.iter().zip(absorbs.ood) { - let z = t.sample_ext(b); - super::epoch::assert_z_outside_domains_raw( - b, - z, - t_shape.log2_trace_length, - shape.log2_blowup, - shape.coset_offset, - ); - for (dims, block) in [ - (t_shape.ood_current_dims, ood.current), - (t_shape.ood_next_dims, ood.next), - ] { - let (width, height) = dims; - for col in 0..width { - for row in 0..height { - super::epoch::append_ext_cell(b, t, block[row * width + col]); - } - } - } - for part in ood.parts { - super::epoch::append_ext_cell(b, t, *part); - } - zs.push(z); - } - - // ---- ALL gammas, consecutively. - let gammas: Vec = (0..n).map(|_| t.sample_ext(b)).collect(); - - // ---- round 4: the histogram again, α, the standalone terminals, then - // ζ-then-root, the batched terminal, grinding, and the ONE shared - // query-index set. - emit_shape_histogram(t, &shape.heights, &shape.total_widths); - let alpha = t.sample_ext(b); - - // The standalone class's terminal polynomials, bound before any ζ or - // query index can depend on them — per table ascending, coefficients in - // order, matching `commit_batched_fri` / `derive_batched_fri_challenges`. - for coeffs in absorbs.standalone_coeffs.iter().flatten() { - for c in coeffs { - super::epoch::append_ext_cell(b, t, *c); - } - } - - let mut zetas = Vec::with_capacity(shape.fri.num_committed() + 1); - for root in absorbs.fri_roots { - // Sample FIRST, absorb SECOND — a ζ drawn after its own layer root is - // a challenge the prover answers rather than one that binds them. - zetas.push(t.sample_ext(b)); - root.absorb(b, t); - } - if shape.fri.layout.total_folds > 0 { - zetas.push(t.sample_ext(b)); - } - for c in absorbs.fri_coeffs { - super::epoch::append_ext_cell(b, t, *c); - } - - if let Some(nonce) = absorbs.nonce { - let seed = t.state(b); - super::epoch::emit_grinding_check(b, seed, nonce, shape.grinding_factor); - t.append_felt(b, nonce); - } - - let iota_bits = (0..shape.num_queries) - .map(|_| t.sample_u64_pow2(b, shape.fri.index_bits())) - .collect(); - - BatchedEpochChallenges { - lookup, - betas, - zs, - gammas, - alpha, - zetas, - iota_bits, - } -} diff --git a/prover/src/lfm/batched_epoch_verify.rs b/prover/src/lfm/batched_epoch_verify.rs deleted file mode 100644 index 3b70284ac..000000000 --- a/prover/src/lfm/batched_epoch_verify.rs +++ /dev/null @@ -1,505 +0,0 @@ -//! The batched epoch's verification legs — the mixed-height MMCS walk. -//! -//! The batched counterpart of [`super::sub_proof`]'s authentication half. -//! The order authority is `stark::fri::mmcs::MixedMmcs::verify_batch` -//! (fri/mmcs.rs' "Tree layout" section is the contract): ONE path -//! authenticates every matrix of a round — the tallest matrices batch into -//! the base leaf, and each shorter height group is INJECTED where the climb -//! reaches its layer, as one extra compression. -//! -//! Heights and widths are program shape, so the injection schedule UNROLLS at -//! emit time: the emitted walk is straight-line — per level one -//! compress-with-sibling (two `Select`s on the shared index bit) and, iff -//! some matrix sits at that level's injection height, one further compress -//! with that height group's leaf hash. No branch, no `Select` beyond the -//! sibling ordering, exactly as [`super::edsl::WrapHash::merkle_walk`]'s doc -//! anticipated ("a batched path that injects at mixed heights extends this -//! rather than replacing it"). -//! -//! ## The index convention, in cells -//! -//! The machine's shared query index is a BIT VECTOR (low-to-high, -//! `h_max_fri − 1` bits, drawn once by the spine). `fri/mmcs.rs`' index -//! reduction — `iota_round = iota_fri >> (h_max_fri − h_max_round)` — is -//! [`reduce_iota_bits`]: DROP THE LOW BITS, keep the high ones. In LFM the -//! reduction is free (slicing a cell vector emits nothing), but the DIRECTION -//! is still the soundness-relevant choice: host-side a wrong shift is -//! self-consistent between prover and verifier and fails silently, which is -//! why `the_batched_openings_authenticate_against_the_spine_roots` ports the -//! `short_round_low_bit_convention_is_exercised` control to the machine. - -use super::builder::{Bit, Cell, Felt, LfmBuilder}; -use super::edsl::{self, WrapDigest}; -use super::epoch::RootCells; -use super::sub_proof::GroupShape; - -/// One matrix of a mixed round, as the walk consumes it — its shape (columns -/// and element kind), its height (the injection schedule's key), and its -/// opened row pair as the caller's CELLS. There is deliberately no -/// constructor that hints: the values are whatever the caller already holds, -/// which is what makes the authentication and the folds share them. -pub struct MixedMatrixOpening<'a> { - pub shape: GroupShape, - /// `log2` of the matrix's LDE height — where in the climb it enters. - pub log_height: usize, - /// `evaluations ‖ evaluations_sym` in leaf order — `2 · num_columns` - /// cells. - pub values: &'a [Cell], -} - -/// The leaf hash of one HEIGHT GROUP's row pairs — `hash_group_openings`' -/// layout: every matrix's `evaluations ‖ evaluations_sym`, in round INPUT -/// order, flat, one hash. Each element renders exactly as the per-table leaf -/// does ([`super::sub_proof::emit_leaf_hash`]): a base element as its eight -/// big-endian bytes, an extension element as its three components, each eight -/// big-endian bytes. Lane 3 of an extension cell is NOT hashed — production -/// hashes three components — and the same caveat applies as there: every ext -/// value a query opens is also an ext operand of the DEEP crossing, which is -/// what pins lane 3 to zero. -pub fn emit_group_leaf_hash(b: &mut LfmBuilder, group: &[&MixedMatrixOpening<'_>]) -> WrapDigest { - use super::keccak_host::BYTES_PER_HALF; - use super::transcript_replay::felt_be_halves; - - assert!(!group.is_empty(), "a group leaf covers at least one matrix"); - - // ★ The ALGEBRAIC path absorbs the felts — same reasoning as - // `sub_proof::emit_leaf_hash`: the byte stream below is a serialisation of - // field elements that exists only for a byte-oriented hash. - let Some(byte_hash) = b.wrap_hash().byte_hash() else { - let felts = group_leaf_felts(b, group); - return edsl::wrap_leaf_hash(b, &felts); - }; - - let mut stream: Vec = Vec::new(); - for m in group { - assert_eq!( - m.values.len(), - m.shape.num_values(), - "a matrix's opening covers its whole row pair" - ); - for v in m.values { - if m.shape.is_ext { - let lanes = b.unpack(*v); - for lane in lanes.iter().take(3) { - stream.extend(felt_be_halves(b, *lane)); - } - } else { - stream.extend(felt_be_halves(b, Felt(v.addr()))); - } - } - } - let len_bytes = BYTES_PER_HALF * stream.len(); - edsl::wrap_hash_bytes(b, byte_hash, &stream, len_bytes) -} - -/// ★ The FELT SEQUENCE the algebraic arm of [`emit_group_leaf_hash`] absorbs, -/// in absorption order — the machine's counterpart of -/// `stark::fri::mmcs::group_opening_felts`. -/// -/// Split out of its only production caller so a differential can compare the -/// SEQUENCE the machine feeds against the sequence the host feeds, rather than -/// only the digests they disagree on. The disagreement this path fails with is -/// a `DivByZero` deep in a query walk, which names neither the site nor the -/// felt; a sequence differential names the index. -/// -/// A base value is ONE felt (the cell's own lane 0); an extension value is its -/// three components, lanes 0, 1 and 2 of the unpacked word — lane 3 is not -/// absorbed, for the reason [`emit_group_leaf_hash`] states. -pub fn group_leaf_felts(b: &mut LfmBuilder, group: &[&MixedMatrixOpening<'_>]) -> Vec { - let mut felts: Vec = Vec::new(); - for m in group { - assert_eq!( - m.values.len(), - m.shape.num_values(), - "a matrix's opening covers its whole row pair" - ); - for v in m.values { - if m.shape.is_ext { - let lanes = b.unpack(*v); - felts.extend_from_slice(&lanes[..3]); - } else { - felts.push(Felt(v.addr())); - } - } - } - felts -} - -/// Authenticate one mixed round's openings against its committed root — the -/// injecting walk, `MixedMmcs::verify_batch` emitted. -/// -/// `matrices` in round INPUT order; `siblings` leaf level first, -/// `h_max − 1` of them; `bits` the REDUCED shared index, low-to-high, -/// `h_max − 1` of them ([`reduce_iota_bits`]). The final assert against the -/// root's lanes is the binding: the root cells are the SAME cells the spine -/// absorbed, so there is no second copy for a prover to disagree with. -pub fn emit_mixed_verify_batch( - b: &mut LfmBuilder, - root: &RootCells, - matrices: &[MixedMatrixOpening<'_>], - siblings: &[WrapDigest], - bits: &[Bit], -) { - let h_max = matrices - .iter() - .map(|m| m.log_height) - .max() - .expect("a round has at least one matrix"); - assert!(h_max >= 1, "a row-pair tree needs at least two rows"); - assert_eq!(siblings.len(), h_max - 1, "one sibling per level"); - assert_eq!( - bits.len(), - h_max - 1, - "the reduced index has h_max − 1 bits" - ); - for m in matrices { - assert!( - (1..=h_max).contains(&m.log_height), - "a matrix's height sits inside its round's climb" - ); - } - - // Base node: every tallest matrix's row pair, one leaf hash. - let base: Vec<&MixedMatrixOpening<'_>> = - matrices.iter().filter(|m| m.log_height == h_max).collect(); - let mut acc = emit_group_leaf_hash(b, &base); - - for (level, (bit, sibling)) in bits.iter().zip(siblings).enumerate() { - // Both halves of the digest must swap on the SAME bit; bit = 0 means - // the current node is the LEFT child, as in every walk here. - // ★ Every cell swaps on the SAME bit — a loop, so a one-cell algebraic - // digest costs ONE select per level where a byte digest costs two. - debug_assert_eq!( - acc.len(), - sibling.len(), - "node and sibling widths must match" - ); - let n = acc.len(); - let mut left = [acc[0]; edsl::MAX_DIGEST_CELLS]; - let mut right = [acc[0]; edsl::MAX_DIGEST_CELLS]; - for k in 0..n { - let (l, r) = b.select(*bit, acc[k], sibling[k]); - left[k] = l; - right[k] = r; - } - let mut parent = edsl::wrap_hash_pair( - b, - edsl::WrapDigest::from_cells(&left[..n]), - edsl::WrapDigest::from_cells(&right[..n]), - ); - - // The injection, unrolled: heights are shape, so whether a group - // enters here is decided now, not by an emitted branch. - let inject_h = h_max - 1 - level; - let group: Vec<&MixedMatrixOpening<'_>> = matrices - .iter() - .filter(|m| m.log_height == inject_h) - .collect(); - if !group.is_empty() { - let inj = emit_group_leaf_hash(b, &group); - parent = edsl::wrap_hash_pair(b, parent, inj); - } - acc = parent; - } - - edsl::assert_digest_eq_lanes(b, acc, &root.lanes); -} - -/// Reduce the SHARED query-index bits to a round (or per-table tree) whose -/// own tallest height is `h_max_round` — `reduce_iota_to_round`'s -/// `iota >> (h_max_fri − h_max_round)`, on a low-to-high bit vector: drop -/// the LOW `h_max_fri − h_max_round` bits, keep the high `h_max_round − 1`. -/// -/// Free — slicing emits nothing — but direction-critical; see the module doc. -pub fn reduce_iota_bits(bits: &[Bit], h_max_fri: usize, h_max_round: usize) -> &[Bit] { - assert!( - h_max_round <= h_max_fri, - "no round is taller than the FRI's domain" - ); - assert_eq!( - bits.len(), - h_max_fri - 1, - "the shared index has h_max_fri − 1 bits" - ); - &bits[(h_max_fri - h_max_round)..] -} - -// ================= the DEEP mix and the batched FRI leg ================= - -/// α-mix one query's per-table DEEP pairs into the tallest-domain pair `p0` -/// and the per-height injection buckets — `verify_epoch_fri`'s loop, emitted. -/// -/// ★ Powers of α go by `plan_batched` POSITION, not table index and not -/// position within a height group — the three orders coincide on a same-height -/// epoch and diverge on a real one (`batched/verifier.rs`' warning). A short -/// table contributes ONE value, chosen from its pair by the injection -/// position's low bit — which is bit `h_max − h − 1` of the SHARED index, so -/// the choice is a `Select` on a bit the transcript drew, never a hint. -/// -/// `deep_pairs` is indexed by TABLE; entries outside the batched class are -/// not read. -pub fn emit_query_mix( - b: &mut LfmBuilder, - plan_batched: &[usize], - heights: &[usize], - h_max: usize, - alpha: super::builder::Ext, - deep_pairs: &[(super::builder::Ext, super::builder::Ext)], - bits: &[Bit], -) -> ( - super::builder::Ext, - super::builder::Ext, - Vec>, -) { - assert!(!plan_batched.is_empty(), "the batched class is never empty"); - assert_eq!(bits.len(), h_max - 1, "the shared index has h_max − 1 bits"); - - let mut p0: Option<(super::builder::Ext, super::builder::Ext)> = None; - let mut buckets: Vec> = vec![None; h_max]; - let mut power: Option = None; - for &table in plan_batched { - let (d, d_sym) = deep_pairs[table]; - let h = heights[table]; - assert!(h <= h_max, "no batched table is taller than the instance"); - // α^pos — position in plan.batched. pos 0 multiplies by nothing. - let scale = |b: &mut LfmBuilder, v: super::builder::Ext| match power { - None => v, - Some(p) => b.emul(p, v), - }; - if h == h_max { - let sd = scale(b, d); - let sds = scale(b, d_sym); - p0 = Some(match p0 { - None => (sd, sds), - Some((a, s)) => (b.eadd(a, sd), b.eadd(s, sds)), - }); - } else { - // `injected_value_at_query`: the injection position's low bit is - // bit `h_max − h − 1` of the shared index; 0 picks the regular - // value, 1 the symmetric one — `select` at 0 returns its first - // argument first, so `.0` IS that conditional. - let (chosen, _) = b.select(bits[h_max - h - 1], d.as_cell(), d_sym.as_cell()); - let sv = scale(b, chosen.as_ext()); - buckets[h] = Some(match buckets[h].take() { - None => sv, - Some(acc) => b.eadd(acc, sv), - }); - } - power = Some(match power { - None => alpha, - Some(p) => b.emul(p, alpha), - }); - } - let (p0, p0_sym) = p0.expect("the tallest table is always batched"); - (p0, p0_sym, buckets) -} - -/// One query of the BATCHED FRI instance: the fold-with-injection recursion, -/// every committed layer's opening authenticated at the shared index, and the -/// terminal check — `verify_batched_fri_query`, emitted. -/// -/// The per-table [`super::fri::emit_query_fri`]'s shape with two additions: -/// after EVERY fold (the uncommitted first one included) the height the -/// running codeword just reached may have a bucket, injected as -/// `v += ζ² · bucket` — the schedule is program shape and UNROLLS — and the -/// terminal Horner runs at `υ^(2^total_folds)` of the TALLEST domain, whose -/// coset offset the caller already folded into `point`. -#[allow(clippy::too_many_arguments)] -pub fn emit_batched_query_fri( - b: &mut LfmBuilder, - layout: &stark::fri::batched::BatchedFriLayout, - h_max: usize, - layers: &[super::fri::LayerCommitment], - zetas: &[super::builder::Ext], - coeffs: &[super::builder::Ext], - bits: &[Bit], - point: Felt, - point_sym: Felt, - p0: super::builder::Ext, - p0_sym: super::builder::Ext, - buckets: &[Option], - openings: &[super::fri::LayerOpening], -) -> super::builder::Ext { - use super::edsl::horner_ext; - use super::fri::FRI_LEAF_GROUP; - use crate::tables::types::FE; - - let c = layout.num_committed; - assert_eq!(bits.len(), h_max - 1, "the shared index has h_max − 1 bits"); - assert_eq!(layers.len(), c, "one commitment per committed layer"); - assert_eq!(openings.len(), c, "one opening per committed layer"); - assert_eq!( - coeffs.len(), - 1usize << layout.effective_k, - "the terminal polynomial carries 2^effective_k coefficients" - ); - assert_eq!(buckets.len(), h_max, "one bucket slot per height"); - - if layout.total_folds == 0 { - // The codeword never folds: the terminal IS the tallest codeword and - // no bucket can exist (`h_min == h_max` is what makes folds zero). - assert!(zetas.is_empty(), "a codeword that never folds draws no ζ"); - assert!( - buckets.iter().all(Option::is_none), - "no injection exists below a terminal-height instance" - ); - let at = horner_ext(b, point.as_ext(), coeffs); - b.assert_eq_ext(at, p0); - let at_sym = horner_ext(b, point_sym.as_ext(), coeffs); - b.assert_eq_ext(at_sym, p0_sym); - return p0; - } - assert_eq!(zetas.len(), c + 1, "folds exceed committed layers by one"); - - let inject = |b: &mut LfmBuilder, - v: super::builder::Ext, - zeta: super::builder::Ext, - height: usize| - -> super::builder::Ext { - match buckets.get(height).and_then(|o| o.as_ref()) { - None => v, - Some(bucket) => { - let zeta_sq = b.emul(zeta, zeta); - let term = b.emul(zeta_sq, *bucket); - b.eadd(v, term) - } - } - }; - - let one = b.felt_const(FE::one()); - let inv = b.div(one, point); - - // Fold 0 consumes the mixed DEEP pair and authenticates nothing; the - // height just below joins before the first committed layer, exactly as - // `batched_commit_phase` injects before it commits. - let mut v = super::edsl::fri_fold(b, p0, p0_sym, zetas[0], inv); - v = inject(b, v, zetas[0], h_max - 1); - - let mut inv_pow = inv; - for (i, opening) in openings.iter().enumerate() { - let (first, second) = b.select(bits[i], v.as_cell(), opening.sym.as_cell()); - let leaf = super::sub_proof::emit_leaf_hash(b, FRI_LEAF_GROUP, &[first, second]); - let root = super::edsl::wrap_merkle_walk(b, leaf, &bits[i + 1..], &opening.siblings); - super::edsl::assert_digest_eq_lanes(b, root, &layers[i].root_lanes); - - inv_pow = b.mul(inv_pow, inv_pow); - v = super::edsl::fri_fold(b, v, opening.sym, zetas[i + 1], inv_pow); - if let Some(height) = (h_max - 1).checked_sub(i + 1) { - v = inject(b, v, zetas[i + 1], height); - } - } - - // `υ^(2^total_folds)` — the terminal codeword's own point at the reduced - // position, coset offset included by construction (the point already - // carries it, so raising it raises the offset too: - // `terminal_offset = coset_offset^(2^total_folds)`). - let mut x = point; - for _ in 0..layout.total_folds { - x = b.mul(x, x); - } - let at = horner_ext(b, x.as_ext(), coeffs); - b.assert_eq_ext(at, v); - v -} - -/// One query of a STANDALONE table's terminal-only instance: the sent -/// polynomial (the ARENA CELLS the spine absorbed — one cell, two consumers) -/// evaluated at the table's own reduced pair must equal its DEEP pair — -/// `verify_standalone_fri_query`, emitted. Nothing folds and nothing walks. -pub fn emit_standalone_terminal_check( - b: &mut LfmBuilder, - coeffs: &[super::builder::Ext], - point: Felt, - point_sym: Felt, - deep: super::builder::Ext, - deep_sym: super::builder::Ext, -) { - let at = super::edsl::horner_ext(b, point.as_ext(), coeffs); - b.assert_eq_ext(at, deep); - let at_sym = super::edsl::horner_ext(b, point_sym.as_ext(), coeffs); - b.assert_eq_ext(at_sym, deep_sym); -} - -// ======================= the batched query census ======================= - -/// Wrap-hash permutations ONE query of the batched epoch costs, from shape -/// alone — the batched counterpart of -/// [`super::epoch_verify::query_permutations_for`], and the campaign's -/// wrap-side economy as one formula: authentication paths per ROUND (plus -/// each preprocessed table's own small tree), never per table per group. -/// -/// Per query: each preprocessed table's leaf and path; per mixed round the -/// FUSED base leaf (every tallest matrix in one absorption), the ONE shared -/// path, and per injected height group one leaf plus ONE extra compression; -/// then the batched FRI instance's layer leaves and path steps. Standalone -/// tables cost NO hashing at all — their check is polynomial evaluation. -/// -/// A closed form over the shapes (the layout and partition are production's -/// own), so comparing it against the emitted count is an absolute check. -pub fn batched_query_permutations_for( - shape: &stark::batched::shape::EpochShape, - params: &stark::batched::shape::EpochFriParams, - hash: super::edsl::WrapHash, -) -> usize { - use super::epoch_verify::{FRI_LEAF_FELTS, blocks_for}; - use stark::fri::batched::{BatchedFriLayout, FriInstancePlan}; - - let mut per_query = 0usize; - - for &(h, w) in &shape.prep.dims { - per_query += blocks_for(2 * w, hash); - per_query += h - 1; - } - - // The carved table's standalone main tree: exactly a preprocessed table's - // cost shape — one row-pair leaf and its own path at the carved height — - // with the root proof-carried instead of AIR-owned. - if let Some(c) = &shape.carved_main { - per_query += blocks_for(2 * c.width, hash); - per_query += shape.heights[c.table] - 1; - } - - for (round, ext) in [ - (&shape.main, false), - (&shape.aux, true), - (&shape.parts, true), - ] { - let Some(h_max) = round.h_max() else { continue }; - let per_value = if ext { 3 } else { 1 }; - let group_felts = |height: usize| -> usize { - round - .dims - .iter() - .filter(|&&(h, _)| h == height) - .map(|&(_, w)| 2 * w * per_value) - .sum() - }; - per_query += blocks_for(group_felts(h_max), hash); - per_query += h_max - 1; - for h in 1..h_max { - let felts = group_felts(h); - if felts > 0 { - per_query += blocks_for(felts, hash) + 1; - } - } - } - - let plan = FriInstancePlan::new( - &shape.heights, - params.blowup_log, - params.final_poly_log_degree, - ) - .expect("a real epoch's heights partition"); - let layout = BatchedFriLayout::new( - plan.h_max, - plan.h_min, - params.blowup_log, - params.final_poly_log_degree, - ); - per_query += layout.num_committed * blocks_for(FRI_LEAF_FELTS, hash); - per_query += (0..layout.num_committed) - .map(|i| plan.h_max - i - 2) - .sum::(); - - per_query -} diff --git a/prover/src/lfm/mod.rs b/prover/src/lfm/mod.rs index 9d67c2721..4104cabff 100644 --- a/prover/src/lfm/mod.rs +++ b/prover/src/lfm/mod.rs @@ -18,8 +18,6 @@ pub mod airs; pub mod algebraic_commit; pub mod algebraic_transcript; -pub mod batched_epoch; -pub mod batched_epoch_verify; pub mod blake3; pub mod blake3_chip; pub mod blake3_socket; diff --git a/prover/src/lfm/proof.rs b/prover/src/lfm/proof.rs index b57bc1976..f0c5a3a04 100644 --- a/prover/src/lfm/proof.rs +++ b/prover/src/lfm/proof.rs @@ -9,9 +9,6 @@ use math::field::element::FieldElement; use math::field::traits::IsPrimeField; -use stark::batched::proof::BatchedMultiProof; -use stark::batched::prover::multi_prove_batched; -use stark::batched::verifier::{multi_verify_batched, replay_epoch_transcript}; use stark::config::Commitment; use stark::proof::options::ProofOptions; use stark::proof::stark::MultiProof; @@ -428,80 +425,8 @@ fn expected_public_balance( } // =========================================================================== -// The batched path (M-7) +// Presets // =========================================================================== -// -// The per-table entry points above stay the default everywhere: `lfm_prove` / -// `lfm_verify` go through `multi_prove` / `multi_verify_views` under keccak, and -// nothing below changes that. These are siblings, not a mode switch, because a -// batched epoch proof is a DIFFERENT wire type (`BatchedMultiProof`) rather than -// the same proof verified differently — an `Option` on the existing signatures -// would have been a lie about what varies. - -/// Proves an LFM program as ONE batched epoch. -/// -/// Preprocessed binding is per table: the prover builds each preprocessed -/// chip's own tree and fails unless its root equals the AIR's supplied one — -/// which for this machine is `artifacts.roots[slot]`, so a stale registry -/// entry fails the prove with the per-table path's own error. -pub fn lfm_prove_batched( - program: &LfmProgram, - artifacts: &LfmArtifacts, - arenas: &[Vec], - options: &ProofOptions, -) -> Result { - let hasher = artifacts.hasher; - let exec = execute(program, arenas, &hasher).map_err(LfmProveError::Exec)?; - let mut traces = build_traces_with_hasher(program, &exec.records, hasher); - - let airs = LfmAirs::new_chunked( - &artifacts.roots, - &artifacts.blake3_chunk_roots, - options, - artifacts.keccak_rnd_chunks, - hasher, - artifacts.chip_set, - ); - let mut transcript = crate::hash_pin::block_transcript(&[]); - absorb_lfm_statement( - &mut transcript, - &artifacts.program_id, - &exec.public_words, - options.fri_final_poly_log_degree, - ); - - let (proof, _stats) = multi_prove_batched::< - F, - E, - (), - crate::hash_pin::BlockStarkHash, - crate::hash_pin::BlockProver, - >( - airs.air_trace_pairs(&mut traces), - &mut transcript, - #[cfg(feature = "disk-spill")] - crate::auto_storage::decide_lfm(), - decide_lfm_residency(), - ) - .map_err(LfmProveError::Prover)?; - - Ok(BatchedLfmProof { - proof, - public_words: exec.public_words, - }) -} - -/// An LFM epoch proved through the batched commitment path. -/// -/// Carries the rkyv wire derives because this IS a shipping artifact: the -/// aggregation layer consumes batched-format wraps as serialized inputs, and -/// a block's wrap set travels between processes and machines as bytes. The -/// round trip is gated by `a_batched_lfm_proof_round_trips_the_wire`. -#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -pub struct BatchedLfmProof { - pub proof: BatchedMultiProof, - pub public_words: Vec<(u32, LfmWord)>, -} /// The wrap layer's options when the wrap feeds the AGGREGATOR: blowup 4 /// (110 queries at the 128-bit Johnson-bound target) with the FRI terminal at @@ -521,101 +446,3 @@ pub fn aggregation_wrap_options() -> ProofOptions { opts.fri_final_poly_log_degree = 8; opts } - -/// [`lfm_verify`] for a batched epoch proof. -/// -/// `Err` = registry miss (the hard, no-fallback path, same as `lfm_verify`). -/// `Ok(false)` = invalid proof, claimed-public mismatch, **or a preprocessed -/// round this program's pin does not cover** — see [`verify_against_batched`], -/// which is where that last case is decided and why it is currently the -/// answer for every real LFM epoch. -pub fn lfm_verify_batched( - kind: LfmProgramKind, - proof: &BatchedMultiProof, - claimed_public: &[(u32, LfmWord)], - options: &ProofOptions, -) -> Result { - let entry = resolve(kind, options.blowup_factor)?; - Ok(verify_against_batched( - &entry.artifacts(), - proof, - claimed_public, - options, - )) -} - -/// Verifies a batched epoch against supplied artifacts — a COMPLETE -/// verification. -/// -/// # The preprocessed binding, and why the old refusal is gone -/// -/// Preprocessed chips are bound PER TABLE inside the batched proof: each -/// root is the AIR's own supplied value (`artifacts.roots[slot]`, and the -/// production pins for `KECCAK_RC`/`BITWISE`), absorbed by both sides from -/// the AIR set and authenticated per query at the reduced per-table index. -/// That covers every preprocessed AIR — including the two the old fused -/// round's `PREP_ROUND_SLOTS` did not — so the round-coverage refusal this -/// function used to return has no cause left. The old refusal test flipped -/// deliberately, exactly as its own doc mandated. -pub fn verify_against_batched( - artifacts: &LfmArtifacts, - proof: &BatchedMultiProof, - claimed_public: &[(u32, LfmWord)], - options: &ProofOptions, -) -> bool { - // The chunk count and the mask must agree, same rule as `verify_against`: - // with the keccak family present zero chunks would drop KECCAK_RND from a - // set that still contains LFM_KECCAK's sends; with the family absent, zero - // is the only correct count. - if artifacts.chip_set.keccak != (artifacts.keccak_rnd_chunks > 0) { - return false; - } - let airs = LfmAirs::new_chunked( - &artifacts.roots, - &artifacts.blake3_chunk_roots, - options, - artifacts.keccak_rnd_chunks, - artifacts.hasher, - artifacts.chip_set, - ); - let refs = airs.air_refs(); - if refs.len() - != artifacts - .chip_set - .num_airs(artifacts.keccak_rnd_chunks, artifacts.blake3_chunks()) - { - return false; - } - - let mut transcript = crate::hash_pin::block_transcript(&[]); - absorb_lfm_statement( - &mut transcript, - &artifacts.program_id, - claimed_public, - options.fri_final_poly_log_degree, - ); - // The batched transcript draws the shared LogUp challenges itself, after the - // shape histogram and the prep/main roots, so they are recovered by - // replaying the EPOCH on a fork — not by the per-table Phase A walk, which - // absorbs per-table roots this path never sends. `LOGUP_NUM_CHALLENGES == 2` - // and they are `(z, alpha)`, the same pair the per-table path samples. - let mut replay = transcript.clone(); - let Some((_, _, challenges)) = replay_epoch_transcript(&refs, proof, &mut replay) else { - return false; - }; - let [z, alpha] = challenges.lookup.as_slice() else { - return false; - }; - let Some(expected) = expected_public_balance(claimed_public, z, alpha) else { - return false; - }; - - multi_verify_batched::< - F, - E, - (), - crate::hash_pin::BlockStarkHash, - crate::hash_pin::BlockVerifier, - _, - >(&refs, proof, &mut transcript, &expected) -} From 1c486236b4ec2efb6d62c52e0d4f7b416da136f5 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Tue, 8 Sep 2026 00:10:53 -0300 Subject: [PATCH 4/7] refactor(continuation): one epoch proof format, per-table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EpochProofBody` and `EpochProofFormat` collapse to the per-table variant, so `EpochProof.proof` is now a plain `MultiProof`. This is a WIRE FORMAT change: the rkyv-archived enum discriminant leaves `EpochProof`, so bundles do not round-trip across this commit. Accepted — one format end to end is the point. What goes with it: `prove_continuation_batched`, the `prove_continuation_with_format` indirection and `prove_epoch`'s `format` parameter (the per-table arm is now the whole function), `verify_epoch`'s batched arm, `BatchedEpochProofRef` and its `materialize` (the archived-side deserialization only the batched verifier needed), and `EpochProofView::batched_proof`. `per_table_proof` returns `MultiProofView` rather than `Option`, which drops the panic in `ArchivedContinuationProof::epoch_proof` and the `.expect` at its one other call site (`lfm::epoch_tests`): with one format there is no bundle shape the per-table arena can be handed and refuse. `num_sub_proofs` reads `proof.proofs.len()` directly. The GLOBAL memory proof and the cross-epoch binding are untouched — they were per-table under either format, which is why `verify_l2g_commitment_binding_view` needs no change. Tests: `the_batched_continuation_matches_the_per_table_l2g_roots` and `a_tampered_batched_continuation_is_rejected` go. Their per-table halves are already gated — `test_split_verify_rejects_tampered_register_fini` for the `reg_fini` arm, `lfm::epoch_tests` for the corrupted L2G root — and `corrupt_epoch_l2g_root_for_tests` keeps that caller. The `proof_diff` diagnostic loses its format match and diffs the one body. --- prover/src/continuation.rs | 436 +++++----------------------------- prover/src/lfm/epoch_tests.rs | 3 +- 2 files changed, 63 insertions(+), 376 deletions(-) diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 0bf0d8777..888a47157 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -538,35 +538,12 @@ struct BuildJob { /// Note: continuation epochs use the L2G memory bookend, so PAGE is skipped and the /// per-epoch page config set is empty — the verifier builds the AIRs with no PAGE /// tables rather than trusting any prover-supplied page config. -/// One epoch's proof body — the per-table format or the batched one. -/// -/// Both arms prove the SAME AIR set (the VM tables + the epoch-local L2G -/// sub-table last) under the same statement seed. In the batched arm the L2G -/// table's main matrix is CARVED into a standalone tree -/// (`stark::batched::shape::CarvedMain`) whose root is byte-identical to the -/// per-table L2G tree's — which is what lets `verify_l2g_commitment_binding_view` -/// read the same commitment out of either format. -#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -pub(crate) enum EpochProofBody { - PerTable(MultiProof), - Batched(Box>), -} - -/// Which format each epoch of a continuation proves in. The GLOBAL memory -/// proof is per-table in both cases; only the epoch proofs change format. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum EpochProofFormat { - /// One `StarkProof` per table (`multi_prove`). - PerTable, - /// One mixed-MMCS proof for the whole epoch - /// (`multi_prove_batched_carved`), the L2G main matrix carved standalone. - Batched, -} - #[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] pub(crate) struct EpochProof { - /// The epoch's STARK proof (its tables + the epoch-local L2G sub-table last). - proof: EpochProofBody, + /// The epoch's STARK proof: one `StarkProof` per table, the epoch-local L2G + /// sub-table last. Its main root is the commitment + /// `verify_l2g_commitment_binding_view` ties to the global proof. + proof: MultiProof, /// Bytes this epoch committed — the COMMIT-bus receiver reference. public_output: Vec, /// Statement values the epoch transcript is seeded with (re-derived on verify). @@ -718,20 +695,10 @@ impl ArchivedContinuationProof { self.epochs.len() } - /// Epoch `i`'s PER-TABLE STARK proof (its tables, epoch-local L2G - /// sub-table last), as the same view the verifier reads in place. - /// - /// The per-table proof arena serves per-table bundles only; a batched - /// epoch's wrap reads the batched proof through its own filler. Feeding a - /// batched bundle here is a caller bug, not a proof defect, hence the - /// panic rather than a rejection. + /// Epoch `i`'s STARK proof (its tables, epoch-local L2G sub-table last), + /// as the same view the verifier reads in place. pub(crate) fn epoch_proof(&self, i: usize) -> MultiProofView<'_, F, E, ()> { - match &self.epochs[i].proof { - ArchivedEpochProofBody::PerTable(p) => MultiProofView::Archived(p), - ArchivedEpochProofBody::Batched(_) => { - panic!("the per-table proof arena was fed a batched epoch bundle") - } - } + MultiProofView::Archived(&self.epochs[i].proof) } /// Bytes epoch `i` committed. @@ -776,84 +743,23 @@ pub(crate) enum EpochProofView<'a> { Archived(&'a ArchivedEpochProof), } -/// A batched epoch proof, borrowed from either bundle representation. The -/// batched verifier consumes plain data, so the archived arm deserializes on -/// demand ([`BatchedEpochProofRef::materialize`]) — a host-side cost the -/// per-table path does not pay, accepted because batched bundles are verified -/// host-side (their recursive verification goes through the emitted batched -/// program, never through the in-place bundle walk). -pub(crate) enum BatchedEpochProofRef<'a> { - Owned(&'a stark::batched::proof::BatchedMultiProof), - Archived(&'a as rkyv::Archive>::Archived), -} - -impl<'a> BatchedEpochProofRef<'a> { - /// The proof as plain data: a borrow on the owned side, a deserialization - /// on the archived side. - pub(crate) fn materialize( - &self, - ) -> Result>, Error> - { - match self { - Self::Owned(p) => Ok(std::borrow::Cow::Borrowed(p)), - Self::Archived(p) => rkyv::deserialize::< - stark::batched::proof::BatchedMultiProof, - rkyv::rancor::Error, - >(*p) - .map(std::borrow::Cow::Owned) - .map_err(|err| { - Error::Execution(format!( - "rkyv deserialize batched epoch proof failed: {err}" - )) - }), - } - } -} - impl<'a> EpochProofView<'a> { - /// The epoch's PER-TABLE proof (its tables + the epoch-local L2G sub-table - /// last), as a [`MultiProofView`] — never materialized into an owned - /// `MultiProof` on the archived side. `None` for a batched epoch. - pub(crate) fn per_table_proof(&self) -> Option> { + /// The epoch's proof (its tables + the epoch-local L2G sub-table last), as + /// a [`MultiProofView`] — never materialized into an owned `MultiProof` on + /// the archived side. + pub(crate) fn per_table_proof(&self) -> MultiProofView<'a, F, E, ()> { match self { - Self::Owned(e) => match &e.proof { - EpochProofBody::PerTable(p) => Some(MultiProofView::Owned(p)), - EpochProofBody::Batched(_) => None, - }, - Self::Archived(e) => match &e.proof { - ArchivedEpochProofBody::PerTable(p) => Some(MultiProofView::Archived(p)), - ArchivedEpochProofBody::Batched(_) => None, - }, + Self::Owned(e) => MultiProofView::Owned(&e.proof), + Self::Archived(e) => MultiProofView::Archived(&e.proof), } } - /// The epoch's BATCHED proof. `None` for a per-table epoch. - pub(crate) fn batched_proof(&self) -> Option> { - match self { - Self::Owned(e) => match &e.proof { - EpochProofBody::PerTable(_) => None, - EpochProofBody::Batched(p) => Some(BatchedEpochProofRef::Owned(p.as_ref())), - }, - Self::Archived(e) => match &e.proof { - ArchivedEpochProofBody::PerTable(_) => None, - ArchivedEpochProofBody::Batched(p) => Some(BatchedEpochProofRef::Archived(p)), - }, - } - } - - /// Sub-proof count: per-table proofs per table, or the batched proof's - /// table count — the SAME number for the same AIR set, which is what + /// Sub-proof count — one per table, which is what /// [`reconstruct_epoch_airs`]'s structural check needs. pub(crate) fn num_sub_proofs(&self) -> usize { match self { - Self::Owned(e) => match &e.proof { - EpochProofBody::PerTable(p) => p.proofs.len(), - EpochProofBody::Batched(p) => p.tables.len(), - }, - Self::Archived(e) => match &e.proof { - ArchivedEpochProofBody::PerTable(p) => p.proofs.len(), - ArchivedEpochProofBody::Batched(p) => p.tables.len(), - }, + Self::Owned(e) => e.proof.proofs.len(), + Self::Archived(e) => e.proof.proofs.len(), } } @@ -1023,7 +929,6 @@ fn prove_epoch( boundary: &[CellBoundary], opts: &ProofOptions, decode_commitment: Commitment, - format: EpochProofFormat, ) -> Result { // Count this L2G table's range-check lookups into the BITWISE table so its // AreBytes/IsHalfword multiplicities balance the range-check senders. @@ -1083,58 +988,22 @@ fn prove_epoch( let mut pairs = airs.air_trace_pairs(&mut traces); pairs.push((&l2g_air, &mut l2g_trace, &())); - let (proof, l2g_root) = match format { - EpochProofFormat::PerTable => { - let proof = crate::hash_pin::BlockProver::::multi_prove( - pairs, - &mut seed(), - #[cfg(feature = "disk-spill")] - stark::storage_mode::StorageMode::Ram, - stark::residency_mode::ResidencyMode::Retain, - ) - .map_err(|e| Error::Prover(format!("{e:?}")))?; - - let l2g_root = proof - .proofs - .last() - .ok_or_else(|| { - Error::ContinuationInvariant( - "epoch proof is missing the L2G sub-table".to_string(), - ) - })? - .lde_trace_main_merkle_root; - (EpochProofBody::PerTable(proof), l2g_root) - } - EpochProofFormat::Batched => { - // The L2G table is the LAST pair — the carved one. Its standalone - // tree is byte-identical to the per-table L2G tree, so the carved - // root plays exactly the role the last sub-proof's main root plays - // above. - let l2g_index = pairs.len() - 1; - let (proof, _stats) = stark::batched::prover::multi_prove_batched_carved::< - F, - E, - (), - crate::hash_pin::BlockStarkHash, - crate::hash_pin::BlockProver, - >( - pairs, - &mut seed(), - #[cfg(feature = "disk-spill")] - stark::storage_mode::StorageMode::Ram, - stark::residency_mode::ResidencyMode::Retain, - Some(l2g_index), - ) - .map_err(|e| Error::Prover(format!("{e:?}")))?; + let proof = crate::hash_pin::BlockProver::::multi_prove( + pairs, + &mut seed(), + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + stark::residency_mode::ResidencyMode::Retain, + ) + .map_err(|e| Error::Prover(format!("{e:?}")))?; - let l2g_root = proof.carved_main_root.ok_or_else(|| { - Error::ContinuationInvariant( - "batched epoch proof is missing the carved L2G root".to_string(), - ) - })?; - (EpochProofBody::Batched(Box::new(proof)), l2g_root) - } - }; + let l2g_root = proof + .proofs + .last() + .ok_or_else(|| { + Error::ContinuationInvariant("epoch proof is missing the L2G sub-table".to_string()) + })? + .lde_trace_main_merkle_root; Ok(EpochProof { proof, @@ -1284,82 +1153,33 @@ fn verify_epoch( .copied() .unwrap_or(0) as u64; - if let Some(proof) = epoch.per_table_proof() { - let expected = match compute_expected_commit_bus_balance_view( - &refs, - proof, - public_output, - commit_start_index, - &mut seed(), - ) { - Some(expected) => expected, - None => return Ok(false), - }; - - stark::profile_markers::step_marker::< - { stark::profile_markers::STEP_AIRS_AND_BUS_BALANCE_DONE }, - >(); - - if !crate::hash_pin::BlockVerifier::::multi_verify_views( - &refs, - proof, - &mut seed(), - &expected, - ) { - return Ok(false); - } - - // The claimed L2G root must be the one this proof actually committed (it is - // what verify_l2g_commitment_binding_view later ties to the global proof). - return Ok(proof.last().map(|p| *p.lde_trace_main_merkle_root()) == Some(epoch.l2g_root())); - } - - // The batched arm: one mixed-MMCS proof, the L2G main matrix carved - // standalone (always the LAST air — the same position the per-table path - // appends it at). The COMPLETE verification mirrors the per-table arm: - // challenges replayed on a fork of the statement seed, the expected - // COMMIT-bus balance from the replayed shared pair, the full batched - // verify, then the claimed-vs-committed L2G root equality — here the - // proof-carried carved root, byte-identical to the per-table tree's. - let Some(proof_ref) = epoch.batched_proof() else { - return Ok(false); - }; - let proof = proof_ref.materialize()?; - let l2g_index = refs.len() - 1; - - let Some((_, _, challenges)) = stark::batched::verifier::replay_epoch_transcript_carved( + let proof = epoch.per_table_proof(); + let expected = match compute_expected_commit_bus_balance_view( &refs, - &proof, + proof, + public_output, + commit_start_index, &mut seed(), - Some(l2g_index), - ) else { - return Ok(false); - }; - let [z, alpha] = challenges.lookup.as_slice() else { - return Ok(false); - }; - let Some(expected) = - crate::compute_commit_bus_offset(public_output, commit_start_index, z, alpha) - else { - return Ok(false); + ) { + Some(expected) => expected, + None => return Ok(false), }; stark::profile_markers::step_marker::<{ stark::profile_markers::STEP_AIRS_AND_BUS_BALANCE_DONE }>( ); - if !stark::batched::verifier::multi_verify_batched_carved::< - F, - E, - (), - crate::hash_pin::BlockStarkHash, - crate::hash_pin::BlockVerifier, - _, - >(&refs, &proof, &mut seed(), &expected, Some(l2g_index)) - { + if !crate::hash_pin::BlockVerifier::::multi_verify_views( + &refs, + proof, + &mut seed(), + &expected, + ) { return Ok(false); } - Ok(proof.carved_main_root == Some(epoch.l2g_root())) + // The claimed L2G root must be the one this proof actually committed (it is + // what verify_l2g_commitment_binding_view later ties to the global proof). + Ok(proof.last().map(|p| *p.lde_trace_main_merkle_root()) == Some(epoch.l2g_root())) } /// Build the cross-epoch global memory proof: every epoch's L2G sub-table on the @@ -1530,19 +1350,6 @@ fn verify_global( ) } -/// Prove a full continuation and return a self-contained [`ContinuationProof`] -/// (prove half only — no verification). Splits the execution into `2^epoch_size_log2` -/// cycle epochs, proves each, and proves the one cross-epoch global-memory linkage. -/// -/// Intermediate epochs run exactly `2^epoch_size_log2` cycles, so their CPU tables -/// have power-of-two row counts and therefore zero padding rows — important because -/// CPU padding rows participate in the inline-PC `memory` chain (carrying pc=1) -/// which is only anchored by the HALT chip's emit_pc/consume_pc, and intermediate -/// epochs exclude HALT. With padding rows present and no HALT their pc=1 tokens -/// dangle and the Memory bus fails to balance; zero padding rows sidestep that. The -/// final epoch keeps its remainder and its HALT, so its padding chain is anchored as -/// usual. A program that fits in one epoch runs as a single final (monolithic-style) -/// epoch. /// The text of a panic payload, for the pipeline error that reports it in /// place of the panic (`panic!` with a string literal or a formatted message /// covers every abort the device layer raises). @@ -1556,47 +1363,24 @@ fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { } } +/// Prove a full continuation and return a self-contained [`ContinuationProof`] +/// (prove half only — no verification). Splits the execution into `2^epoch_size_log2` +/// cycle epochs, proves each, and proves the one cross-epoch global-memory linkage. +/// +/// Intermediate epochs run exactly `2^epoch_size_log2` cycles, so their CPU tables +/// have power-of-two row counts and therefore zero padding rows — important because +/// CPU padding rows participate in the inline-PC `memory` chain (carrying pc=1) +/// which is only anchored by the HALT chip's emit_pc/consume_pc, and intermediate +/// epochs exclude HALT. With padding rows present and no HALT their pc=1 tokens +/// dangle and the Memory bus fails to balance; zero padding rows sidestep that. The +/// final epoch keeps its remainder and its HALT, so its padding chain is anchored as +/// usual. A program that fits in one epoch runs as a single final (monolithic-style) +/// epoch. pub fn prove_continuation( elf_bytes: &[u8], private_inputs: &[u8], epoch_size_log2: u32, opts: &ProofOptions, -) -> Result { - prove_continuation_with_format( - elf_bytes, - private_inputs, - epoch_size_log2, - opts, - EpochProofFormat::PerTable, - ) -} - -/// As [`prove_continuation`], with every epoch proven in the BATCHED format -/// (one mixed-MMCS proof per epoch, the L2G main matrix carved standalone). -/// The global memory proof and the cross-epoch binding are unchanged: the -/// carved root is byte-identical to the per-table L2G tree's, so -/// `verify_l2g_commitment_binding_view` reads the same commitment. -pub fn prove_continuation_batched( - elf_bytes: &[u8], - private_inputs: &[u8], - epoch_size_log2: u32, - opts: &ProofOptions, -) -> Result { - prove_continuation_with_format( - elf_bytes, - private_inputs, - epoch_size_log2, - opts, - EpochProofFormat::Batched, - ) -} - -fn prove_continuation_with_format( - elf_bytes: &[u8], - private_inputs: &[u8], - epoch_size_log2: u32, - opts: &ProofOptions, - format: EpochProofFormat, ) -> Result { if epoch_size_log2 < 2 { return Err(Error::InvalidContinuationEpochSize( @@ -1735,7 +1519,6 @@ fn prove_continuation_with_format( &prepared.boundary, opts, decode_commitment, - format, ) })); match outcome { @@ -2453,15 +2236,7 @@ mod tests { let b = load(&std::env::var("PROOF_B").unwrap()); assert_eq!(a.epochs.len(), b.epochs.len(), "epoch count"); for (e, (ea, eb)) in a.epochs.iter().zip(b.epochs.iter()).enumerate() { - match (&ea.proof, &eb.proof) { - (EpochProofBody::PerTable(pa), EpochProofBody::PerTable(pb)) => { - diff_multi(&format!("epoch {e}"), pa, pb) - } - (EpochProofBody::Batched(_), EpochProofBody::Batched(_)) => { - println!("epoch {e}: batched bodies (field diff not implemented)") - } - _ => println!("epoch {e}: FORMAT differs (per-table vs batched)"), - } + diff_multi(&format!("epoch {e}"), &ea.proof, &eb.proof); if ea.public_output != eb.public_output { println!("epoch {e}: public_output differs"); } @@ -2829,93 +2604,6 @@ mod tests { assert_eq!(out.as_deref(), Some(&[0xAA, 0xBB, 0xCC, 0xDD][..])); } - /// ★★ THE D1 DIFFERENTIAL GATE: the same execution proven per-table and - /// batched yields BYTE-EQUAL L2G roots for every epoch — the carved tree - /// IS the per-table tree — and the batched bundle passes the COMPLETE - /// host verification (`verify_epoch`'s batched arm, the global proof, - /// `verify_l2g_commitment_binding_view`) end to end, through the rkyv - /// wire format. Verdict condition 5 rides along: the epochs must span at - /// least two distinct L2G heights, so the carve's index reduction is - /// exercised at more than one shift. - #[test] - fn the_batched_continuation_matches_the_per_table_l2g_roots() { - let _ = env_logger::builder().is_test(true).try_init(); - let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let opts = ProofOptions::default_test_options(); - - let per_table = prove_continuation(&elf_bytes, &[], 3, &opts).unwrap(); - let batched = prove_continuation_batched(&elf_bytes, &[], 3, &opts).unwrap(); - assert!(batched.num_epochs() > 1, "the fixture must split"); - assert_eq!(per_table.num_epochs(), batched.num_epochs()); - - let mut l2g_heights = std::collections::BTreeSet::new(); - for i in 0..per_table.num_epochs() { - assert_eq!( - per_table.epoch_view(i).l2g_root(), - batched.epoch_view(i).l2g_root(), - "epoch {i}: the carved root must be the per-table root, byte for byte" - ); - let proof = batched - .epoch_view(i) - .batched_proof() - .expect("a batched bundle holds batched bodies") - .materialize() - .unwrap(); - assert_eq!( - proof.carved_main_root, - Some(batched.epoch_view(i).l2g_root()), - "epoch {i}: the claimed root is the committed carved root" - ); - l2g_heights.insert(proof.tables.last().unwrap().trace_length); - } - assert!( - l2g_heights.len() >= 2, - "the differential must span ≥2 distinct L2G heights (got {l2g_heights:?}); \ - pick a fixture/epoch size that varies the boundary size" - ); - - // The complete host verification, through the wire format. - let bytes = rkyv::to_bytes::(&batched).unwrap(); - let restored: ContinuationProof = - rkyv::from_bytes::<_, rkyv::rancor::Error>(&bytes).unwrap(); - let out = verify_continuation(&elf_bytes, &restored, &opts).unwrap(); - assert!(out.is_some(), "an honest batched continuation must verify"); - } - - /// D1 tamper arms at the continuation level: a flipped claimed L2G root - /// and a flipped bound `reg_fini` are both rejected on the batched arm, - /// exactly as on the per-table one. - #[test] - fn a_tampered_batched_continuation_is_rejected() { - let _ = env_logger::builder().is_test(true).try_init(); - let elf_bytes = asm_elf_bytes("all_loadstore_32"); - let opts = ProofOptions::default_test_options(); - - let bundle = prove_continuation_batched(&elf_bytes, &[], 3, &opts).unwrap(); - assert!(bundle.num_epochs() > 1, "the fixture must split"); - - let mut flipped_root = rkyv::from_bytes::( - &rkyv::to_bytes::(&bundle).unwrap(), - ) - .unwrap(); - flipped_root.corrupt_epoch_l2g_root_for_tests(1); - assert!( - verify_continuation(&elf_bytes, &flipped_root, &opts) - .unwrap() - .is_none(), - "a flipped claimed L2G root must be rejected" - ); - - let mut flipped_fini = bundle; - flipped_fini.corrupt_epoch_reg_fini_for_tests(1); - assert!( - verify_continuation(&elf_bytes, &flipped_fini, &opts) - .unwrap() - .is_none(), - "a flipped reg_fini must be rejected through the REGISTER binding" - ); - } - // Negative: dropping the final (halting) epoch must be rejected — the new last // epoch is non-halting but the verifier builds it as `is_final` (HALT included), // so it can't verify. Guards completeness / no-truncation. diff --git a/prover/src/lfm/epoch_tests.rs b/prover/src/lfm/epoch_tests.rs index 539fda391..678ed51f7 100644 --- a/prover/src/lfm/epoch_tests.rs +++ b/prover/src/lfm/epoch_tests.rs @@ -1219,8 +1219,7 @@ pub(super) fn real_epoch_from_continuation( recon.runtime_page_ranges, position.label, decode_root, - view.per_table_proof() - .expect("the per-table wrap constructor reads per-table bundles"), + view.per_table_proof(), ) } From 69708d4e3e7ee4a9aaf153bc0cba6a2a38b97396 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Tue, 8 Sep 2026 01:07:39 -0300 Subject: [PATCH 5/7] refactor(stark): delete the batched prover, verifier and mixed-height FRI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched multi-Merkle-tree format is retired. Removed: `batched/proof.rs`, `batched/prover.rs`, `batched/round4.rs`, `batched/verifier.rs`, `fri/batched.rs` (height combination, the batched commit phase and the shared challenge derivation), and the two suites whose subject they are — `tests/batched_mmcs_soundness_tests.rs` and `tests/batched_prover_tests.rs`. The standalone-terminal binding and the two-class epsilon_C split go with round 4; the shape-binding argument goes with the format. `prover::multi_prove` and `verifier::multi_verify` are untouched and were already the default. `batched/shape.rs` and `fri/mmcs.rs` STAY for now, and only because production code outside this crate still reads them: `prover/src/lfm/registry.rs` takes a `&RoundShape` in `pinned_prep_widths`, and `prover/src/lfm/commit.rs`'s `PrepRoundBuilder` is a `StreamingMmcsBuilder`. Both die with the preprocessed round root in the registry commit, which is sequenced after the pin regeneration. `batched/mod.rs` is reduced to the surviving module and the doc links into the deleted ones are gone. Two rationale paragraphs lose their second caller rather than their claim: `prover::compute_composition_parts` and `verifier::step_2_verify_claimed_composition_polynomial` still exist for the reason their signatures show, so the docs now say what they are instead of who else called them. --- crypto/stark/src/batched/mod.rs | 30 +- crypto/stark/src/batched/proof.rs | 204 -- crypto/stark/src/batched/prover.rs | 1214 ------------ crypto/stark/src/batched/round4.rs | 901 --------- crypto/stark/src/batched/shape.rs | 75 +- crypto/stark/src/batched/verifier.rs | 1079 ----------- crypto/stark/src/fri/batched.rs | 1135 ------------ crypto/stark/src/fri/mmcs.rs | 4 +- crypto/stark/src/fri/mod.rs | 1 - crypto/stark/src/prover.rs | 11 +- .../src/tests/batched_mmcs_soundness_tests.rs | 1645 ----------------- .../stark/src/tests/batched_prover_tests.rs | 1218 ------------ crypto/stark/src/tests/mod.rs | 2 - crypto/stark/src/verifier.rs | 5 +- 14 files changed, 47 insertions(+), 7477 deletions(-) delete mode 100644 crypto/stark/src/batched/proof.rs delete mode 100644 crypto/stark/src/batched/prover.rs delete mode 100644 crypto/stark/src/batched/round4.rs delete mode 100644 crypto/stark/src/batched/verifier.rs delete mode 100644 crypto/stark/src/fri/batched.rs delete mode 100644 crypto/stark/src/tests/batched_mmcs_soundness_tests.rs delete mode 100644 crypto/stark/src/tests/batched_prover_tests.rs diff --git a/crypto/stark/src/batched/mod.rs b/crypto/stark/src/batched/mod.rs index 1260dd962..c3c880238 100644 --- a/crypto/stark/src/batched/mod.rs +++ b/crypto/stark/src/batched/mod.rs @@ -1,27 +1,9 @@ -//! The batched-commitment proving path: one mixed-height MMCS per round and one -//! FRI instance per epoch, instead of one tree and one FRI instance per table. +//! Shape derivation for a multi-matrix preprocessed round. //! -//! This is an OPT-IN path. The per-table prover and verifier -//! ([`crate::prover::IsStarkProver::multi_prove`], -//! [`crate::verifier::IsStarkVerifier::multi_verify`]) are untouched and produce -//! byte-identical proofs; nothing here is reachable from them. -//! -//! The primitives live one level down — [`crate::fri::mmcs`] (the mixed-height -//! tree) and [`crate::fri::batched`] (height combination, the batched commit -//! phase, and the shared challenge derivation). This module is the wiring: it -//! fixes the transcript sequence, the query-index convention and the per-query -//! fold-with-injection recursion that the prover and the verifier must agree on. -//! -//! - [`shape`] — which table contributes which matrix to which round, derived -//! from the AIR set on both sides and never read from a proof. -//! - [`round4`] — the round-4 transcript sequence and the per-query FRI check. -//! - [`proof`] — what a batched epoch proof carries. -//! - [`prover`] — the phase architecture the barriers force. -//! - [`verifier`] — the transcript replay, and ⛔ only the commitment half of a -//! verification. Read its header before assuming otherwise. +//! [`shape`] records which table contributes which matrix to which round, +//! derived from the AIR set rather than read from a proof. The LFM registry +//! pins a preprocessed round root over several slots' matrices and needs that +//! description to say which widths the round covers +//! (`prover/src/lfm/registry.rs::pinned_prep_widths`). -pub mod proof; -pub mod prover; -pub mod round4; pub mod shape; -pub mod verifier; diff --git a/crypto/stark/src/batched/proof.rs b/crypto/stark/src/batched/proof.rs deleted file mode 100644 index 62341e346..000000000 --- a/crypto/stark/src/batched/proof.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! What a batched epoch proof carries. -//! -//! These types live here and NOT in [`crate::proof`] on purpose: the per-table -//! `StarkProof` / `MultiProof` rkyv layouts are the production wire format, and -//! the batched path is opt-in. Keeping its types in this module makes the -//! default path byte-identical by construction rather than by test — -//! `git diff ..HEAD -- crypto/stark/src/proof/` stays empty. -//! -//! # What is NOT here, and why -//! -//! No per-table Merkle roots, no per-table FRI layer roots, no per-table query -//! list. One epoch commits four mixed-height MMCS roots (preprocessed, main, -//! aux, composition parts) and runs ONE FRI instance, so a query costs one -//! authentication path per round instead of one per table per round. That is the -//! proof-size win; everything per-table that survives is data the verifier -//! cannot derive — OOD evaluations, bus sums, public inputs. - -use math::field::element::FieldElement; -use math::field::traits::IsField; - -use crate::config::Commitment; -use crate::fri::fri_decommit::FriDecommitment; -use crate::fri::mmcs::MixedOpening; -use crate::lookup::BusPublicInputs; -use crate::proof::stark::PolynomialOpenings; -use crate::table::Table; - -/// The per-table data a batched epoch proof still has to carry. -#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -pub struct BatchedTableData { - /// This table's interpolation-domain size. The verifier derives the table's - /// height — and therefore every index reduction — from this, so it is bound - /// into the round-4 shape histogram before any challenge depends on it. - pub trace_length: usize, - /// tⱼ(z·gᵏ): the current-row block (all columns). - pub trace_ood_evaluations: Table, - /// tⱼ(z·gᵏ): the pruned next-row block (masked columns only). - pub trace_ood_next_evaluations: Table, - /// Hᵢ(z^N). - pub composition_poly_parts_ood_evaluation: Vec>, - /// LogUp bus sums, when the table has a RAP. - pub bus_public_inputs: Option>, - /// Public inputs for the boundary constraints. - pub public_inputs: PI, - /// A table excluded from the batched FRI class keeps a terminal-only - /// instance of its own: its DEEP codeword IS its terminal codeword, sent as - /// the `2^(h - blowup_log)` coefficients of the polynomial it evaluates. - /// `None` for a table in the batched class. - /// - /// See [`crate::fri::batched::FriInstancePlan`]: the partition is derived - /// from the shape by both sides and is never sent, so this field's presence - /// is checked against the derived plan rather than trusted. - pub standalone_final_poly_coeffs: Option>>, -} - -/// One query's openings: one authentication path per batched round, plus the -/// FRI layer decommitment. -#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -pub struct BatchedQueryOpening { - /// Preprocessed openings, ONE PER PREPROCESSED TABLE in AIR order — each a - /// standard row-pair opening against that table's own precomputed tree. - /// - /// ★ Deliberately NOT a round of the mixed MMCS (this is #768's - /// arrangement, kept for the same reason): the per-table precomputed trees - /// are exactly the ones `air.precomputed_commitment()` pins, so the - /// verifier absorbs and compares roots it already owns — the per-table - /// path's critical soundness check, verbatim — and a recursive verifier - /// binds each root with the provenance machinery that already exists - /// (interned constant / derived in-machine / ELF-attested). A fused - /// mixed-height prep root has no in-machine binding story: its provenance - /// classes are mixed into one digest, which is the M-8 blocker this layout - /// dissolves. Empty when the epoch has no preprocessed table. - pub prep: Vec>, - /// Main round — always present; every table contributes a matrix. - pub main: MixedOpening, - /// Auxiliary round. `None` when no table has a RAP. - pub aux: Option>, - /// Composition-parts round — always present. - pub parts: MixedOpening, - /// The carved table's main row pair, a standard row-pair opening against - /// [`BatchedMultiProof::carved_main_root`] at the REDUCED index - /// (`reduce_iota_to_round(iota, h_max, h_carved)`). Present iff the epoch - /// is carved ([`crate::batched::shape::CarvedMain`]); the verifier rejects - /// a stray or missing one. - pub carved_main: Option>, - /// The batched FRI instance's per-layer openings for this query. - pub fri: FriDecommitment, -} - -/// One epoch, one proof. -#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] -pub struct BatchedMultiProof { - pub tables: Vec>, - /// ★ There is deliberately NO `prep_root` here. Preprocessed matrices are - /// committed per table and their roots are `air.precomputed_commitment()` - /// — absorbed by both sides FROM THE AIR SET, never from the proof, - /// exactly as the per-table path's Phase A does. The proof carries only - /// the per-query openings ([`BatchedQueryOpening::prep`]). - pub main_root: Commitment, - /// The carved table's standalone main-tree root — PROOF-CARRIED (absorbed - /// from the proof, like `main_root`), unlike the preprocessed roots, which - /// absorb from the AIR set. It is absorbed after the preprocessed roots and - /// before `main_root`, so every challenge is drawn after it. Present iff - /// the epoch is carved; whether the epoch IS carved is verifier-owned - /// configuration, never read from the proof. For a continuation epoch this - /// root is the L2G commitment `verify_l2g_commitment_binding_view` compares - /// against the global proof — byte-identical to the per-table L2G tree. - pub carved_main_root: Option, - pub aux_root: Option, - pub parts_root: Commitment, - /// The batched FRI instance's committed layer roots. - pub fri_layer_roots: Vec, - /// The batched FRI instance's terminal polynomial. - pub fri_final_poly_coeffs: Vec>, - pub nonce: Option, - pub queries: Vec>, -} - -/// What the batched prove cost in residency and in recomputation. -/// -/// Returned rather than logged because it is the number the campaign's -/// projection is missing (MMCS-PLAN §1.1 prices the commitment work but not the -/// LDE rebuilds the phase barriers force). A test can assert on it, which is -/// what keeps "the batched builder does not hold every table's LDE" falsifiable -/// at the PROVER level instead of only at the primitive's. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct BatchedProveStats { - /// Highest number of bytes of MAIN and AUX LDE simultaneously alive across - /// the whole prove, counted as the buffers are created and dropped. - /// - /// ★ This is the number the acceptance test asserts on, and the reason it is - /// reported separately from the parts. Under `ResidencyMode::RecomputeLde` - /// it must be flat in the table count — bounded by the widest single table, - /// not by the epoch. If the streaming builder were bypassed, or if any phase - /// quietly retained what it read, this would grow with `N` instead, which is - /// precisely the failure MMCS-PLAN §3.3 warns gives the win back inside the - /// same commit. - pub peak_trace_lde_bytes: usize, - /// Bytes of composition parts held at the peak. These are `O(N)` BY DESIGN — - /// recomputing them is a second constraint evaluation — so they are counted - /// apart from the trace LDEs rather than allowed to mask their behaviour. - pub retained_parts_bytes: usize, - /// The two above at the moment either was highest. Reported for budgeting; - /// the falsifiable claim lives in `peak_trace_lde_bytes`. - pub peak_lde_bytes: usize, - /// How many times a main LDE was expanded from a trace. One per table is the - /// floor (the commit itself); every phase barrier that follows costs another - /// forward NTT per table under `RecomputeLde`. - pub main_lde_expansions: usize, - /// Same for the auxiliary LDE. - pub aux_lde_expansions: usize, - /// Size-`n` coset evaluations (main), phase 4's cheap materialization - /// under `RecomputeLde` — about 37% of a full expansion's work and a - /// quarter of its bytes, counted apart so the full-expansion budget - /// stays an honest number. - pub main_coset_evals: usize, - /// Same for aux. - pub aux_coset_evals: usize, - /// How many times a table's composition parts were computed. Recomputing - /// these means re-running constraint evaluation, so the batched prover - /// retains them instead; this counter exists to make that visible if it ever - /// stops being true. - pub parts_computations: usize, - /// Wall clock per phase, indices 0..6 = phases 1..6 (main commit, aux - /// commit, composition parts, OOD, DEEP+FRI, openings). A latency - /// breakdown of the whole prove: the six entries plus the pre-phase - /// setup sum to the call's wall time. Returned in the stats — not logged — - /// for the same reason the residency numbers are: the A/B harness prints - /// the struct, so every box run carries its own phase profile. - pub phase_wall: [core::time::Duration; 6], - /// Wall clock spent inside LDE expansions (main and aux, all phases) — - /// the recompute traffic itself, separated from what the phases do with - /// the buffers. Under `RecomputeLde` this is the price of the residency - /// mode; under `Retain` it is the floor (one main + one aux per table). - pub lde_expansion_wall: core::time::Duration, -} - -/// Running account of live LDE bytes, so [`BatchedProveStats`] reports what the -/// prover actually held rather than what its comments claim. -#[derive(Debug, Default)] -pub(crate) struct ResidencyLedger { - live: usize, - peak: usize, -} - -impl ResidencyLedger { - pub(crate) fn alloc(&mut self, bytes: usize) { - self.live += bytes; - self.peak = self.peak.max(self.live); - } - - pub(crate) fn free(&mut self, bytes: usize) { - self.live = self.live.saturating_sub(bytes); - } - - pub(crate) fn peak(&self) -> usize { - self.peak - } -} - -/// Bytes a row-major LDE buffer of `len` field elements occupies. -pub(crate) fn lde_bytes(len: usize) -> usize { - len * core::mem::size_of::>() -} diff --git a/crypto/stark/src/batched/prover.rs b/crypto/stark/src/batched/prover.rs deleted file mode 100644 index d85010529..000000000 --- a/crypto/stark/src/batched/prover.rs +++ /dev/null @@ -1,1214 +0,0 @@ -//! The batched prover: four mixed-height MMCS roots and ONE FRI instance per -//! epoch. -//! -//! # The phase architecture, and why it is not `multi_prove` with a different -//! commit call -//! -//! `multi_prove` forks the transcript per table after the LogUp challenges and -//! then runs aux-build → aux-commit → rounds 2-4 FUSED per table, so a table -//! never waits on another. Batching cannot keep that: a batched root cannot be -//! absorbed until every contributing matrix exists, so each batched commitment -//! is a phase BARRIER. What survives of the fork is nothing — every challenge -//! here is drawn from the one shared transcript, in a fixed table order, and the -//! verifier replays that order exactly. -//! -//! ```text -//! shape histogram <- bound BEFORE the first root -//! per table: main LDE -> per-table prep tree + main MMCS builder [barrier] -//! per-table prep roots (from the AIR set), main_root -//! LogUp challenges -//! per table: aux trace + aux LDE -> aux MMCS builder [barrier] -//! aux_root -//! per table: bus contribution -//! per table: beta_t, composition parts -> parts builder [barrier] -//! parts_root -//! per table: z_t, OOD evaluations -//! per table: gamma_t -//! ONE batched FRI: alpha, (beta, layer root)*, terminal, grinding, iotas -//! openings, one table at a time -//! ``` -//! -//! # ★ The cost the plan does not price: the barriers force LDE rebuilds -//! -//! MMCS-PLAN §3.3 makes one memory argument — stream the tree build so a height -//! group's LDEs are not simultaneously resident — and [`StreamingMmcsBuilder`] -//! delivers it. But the tree build is not the only consumer of a table's LDE. -//! Constraint evaluation (round 2), the OOD evaluations (round 3), the DEEP -//! codeword (round 4) and the query openings all read it, and a barrier sits -//! between every pair of those: `beta` cannot be drawn before `aux_root` is -//! absorbed, `z` cannot be drawn before `parts_root` is, `alpha` cannot be drawn -//! before every table's OOD values are, and the query indices do not exist until -//! the FRI is over. -//! -//! So a table's main and aux LDEs are needed in FIVE phases that cannot be -//! merged, and the prover must either hold them (`O(N)`, which is what batching -//! was supposed to remove) or rebuild them (one forward NTT each, per phase). -//! [`ResidencyMode`] selects, exactly as it does in `multi_prove`, and -//! [`BatchedProveStats`] reports what it cost — `main_lde_expansions` and -//! `aux_lde_expansions` are the honest budget, not an estimate. -//! -//! The composition parts are the exception and are ALWAYS retained: recomputing -//! them means re-running constraint evaluation, which is the dominant cost of a -//! prove. `parts_computations` stays at one per table, and the counter is there -//! so that stops being silent if it ever changes. -//! -//! # What is deliberately absent -//! -//! No device paths. The GPU mixed-height MMCS exists (`crypto/math-cuda/`) and -//! is box-gated; wiring it in is a separate step, and a batched prover that -//! silently fell back between host and device arms would make the residency -//! numbers above unreproducible. Under `--features cuda` this path compiles and -//! runs on the host. - -use math::fft::bit_reversing::in_place_bit_reverse_permute_row_major; -use math::field::element::FieldElement; -use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; -use math::traits::AsBytes; - -use crate::batched::proof::{ - BatchedMultiProof, BatchedProveStats, BatchedQueryOpening, BatchedTableData, ResidencyLedger, - lde_bytes, -}; -use crate::batched::round4::commit_batched_fri; -use crate::batched::shape::{EpochShape, RoundShape, ShapeError}; -use crate::config::StarkHash; -use crate::domain::Domain; -use crate::fri::batched::HeightCombiner; -use crate::fri::mmcs::{BorrowedMatrix, LeafSource, MixedMmcs, MixedOpening, StreamingMmcsBuilder}; -use crate::fri::terminal::coeffs_from_terminal_codeword; -use crate::lookup::{BusPublicInputs, LOGUP_NUM_CHALLENGES}; -use crate::proof::stark::PolynomialOpenings; -use crate::prover::{IsStarkProver, ProvingError, domain_and_twiddles}; -use crate::residency_mode::ResidencyMode; -#[cfg(feature = "disk-spill")] -use crate::storage_mode::StorageMode; -use crate::trace::{LDETraceTable, TraceTable}; -use crate::traits::AIR; -use crypto::merkle_tree::merkle::MerkleTree; - -use crypto::fiat_shamir::is_transcript::IsStarkTranscript; - -impl From for ProvingError { - fn from(e: ShapeError) -> Self { - ProvingError::WrongParameter(format!("batched epoch shape: {e}")) - } -} - -/// The AIR, its trace and its public inputs, as `multi_prove` takes them. -pub type BatchedAirTracePair<'a, Field, FieldExtension, PI> = ( - &'a dyn AIR, - &'a mut TraceTable, - &'a PI, -); - -/// A retention slot for one table's main LDE: `Some` while the buffer is being -/// held between phases, `None` while it is out on loan or was dropped. -type MainSlots<'a, Field> = &'a mut [Option<(Vec>, usize)>]; -/// The same for the auxiliary LDE. -type AuxSlots<'a, FieldExtension> = &'a mut [Option<(Vec>, usize)>]; -/// A preprocessed table's own row-pair tree — `None` for a table with no -/// preprocessed columns. -type PrepTreeSlot = Option>>; - -/// A table's LDE buffers, alive only for as long as the current phase needs -/// them, and accounted for while they are. -struct LdePair { - main: (Vec>, usize), - aux: (Vec>, usize), - bytes: usize, -} - -/// Prove one epoch with batched commitments. -/// -/// Preprocessed matrices are committed per table (the trees -/// `air.precomputed_commitment()` pins), so the per-table path's stale-constant -/// guard runs here unconditionally: a built prep tree that disagrees with the -/// AIR's own root fails the prove with the same error the per-table prover -/// raises. -#[allow(clippy::too_many_arguments)] -pub fn multi_prove_batched( - air_trace_pairs: Vec>, - transcript: &mut (impl IsStarkTranscript + Clone + Send), - #[cfg(feature = "disk-spill")] storage_mode: StorageMode, - residency: ResidencyMode, -) -> Result< - ( - BatchedMultiProof, - BatchedProveStats, - ), - ProvingError, -> -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + Copy + 'static, - FieldExtension: IsField + Send + Sync + Copy + 'static, - FieldElement: AsBytes + math::traits::ByteConversion + Sync + Send, - FieldElement: AsBytes + math::traits::ByteConversion + Sync + Send, - PI: Send + Sync + Clone, - H: StarkHash, - P: IsStarkProver + ?Sized, - ::BaseType: math::spill_safe::SpillSafe, - ::BaseType: math::spill_safe::SpillSafe, -{ - multi_prove_batched_carved::( - air_trace_pairs, - transcript, - #[cfg(feature = "disk-spill")] - storage_mode, - residency, - None, - ) -} - -/// As [`multi_prove_batched`], with one table's main matrix carved into a -/// standalone row-pair tree ([`crate::batched::shape::CarvedMain`]). -/// -/// The carved tree is built by `commit_rows_bit_reversed_subset` over the FULL -/// committed-main range of the same LDE expansion — the identical call the -/// per-table prover makes for a non-preprocessed table — so the carved root is -/// byte-identical to the root a per-table prove of the same trace commits. -/// The root is absorbed after the preprocessed roots and before `main_root`, -/// ahead of every challenge draw. -#[allow(clippy::too_many_arguments)] -pub fn multi_prove_batched_carved( - mut air_trace_pairs: Vec>, - transcript: &mut (impl IsStarkTranscript + Clone + Send), - #[cfg(feature = "disk-spill")] storage_mode: StorageMode, - residency: ResidencyMode, - carved_main: Option, -) -> Result< - ( - BatchedMultiProof, - BatchedProveStats, - ), - ProvingError, -> -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + Copy + 'static, - FieldExtension: IsField + Send + Sync + Copy + 'static, - FieldElement: AsBytes + math::traits::ByteConversion + Sync + Send, - FieldElement: AsBytes + math::traits::ByteConversion + Sync + Send, - PI: Send + Sync + Clone, - H: StarkHash, - P: IsStarkProver + ?Sized, - // The same two bounds `multi_prove` carries: under `disk-spill` the aux - // trace is spilled through an mmap backing, which only a field whose - // `BaseType` is plain data can be laid out in. - ::BaseType: math::spill_safe::SpillSafe, - ::BaseType: math::spill_safe::SpillSafe, -{ - let num_tables = air_trace_pairs.len(); - let mut stats = BatchedProveStats::default(); - // Two accounts, because they behave differently on purpose: the trace LDEs - // must stay flat in the table count, the retained parts must not. - let mut ledger = ResidencyLedger::default(); - let mut parts_ledger = ResidencyLedger::default(); - - // ===================================================================== - // Phase 0 — domains, shape, and the shape binding - // ===================================================================== - let mut domains = Vec::with_capacity(num_tables); - let mut twiddles = Vec::with_capacity(num_tables); - for (air, trace, _) in &*air_trace_pairs { - let (domain, tw) = domain_and_twiddles(*air, trace.num_rows()); - domains.push(domain); - twiddles.push(tw); - } - - let airs: Vec<&dyn AIR> = - air_trace_pairs.iter().map(|(air, _, _)| *air).collect(); - let trace_lengths: Vec = domains - .iter() - .map(|d| d.interpolation_domain_size) - .collect(); - let (shape, params) = EpochShape::derive_carved(&airs, &trace_lengths, carved_main)?; - let h_max = shape.h_max(); - let coset_offset = FieldElement::::from(params.coset_offset); - - // ★ Addendum A's recommendation S, adopted. `commit_batched_fri` binds the - // shape again in round 4, which is where the batched FRI's own challenges - // need it; binding it HERE, before the first root, is what turns "no - // rounds-1-3 challenge is shape-exploitable" from a collision-resistance - // argument into a transcript-ordering one. Two field-sized absorptions per - // table, and every later challenge inherits the binding. - crate::fri::batched::absorb_shape_histogram::( - transcript, - &shape.heights, - &shape.total_widths(), - ); - - // ===================================================================== - // Phase 1 — the preprocessed and main rounds, one main LDE pass per table - // ===================================================================== - let t_phase = std::time::Instant::now(); - // Both builders are fed from the SAME expansion: a preprocessed table's - // precomputed columns and its multiplicity columns are two column ranges of - // one row-major main LDE, exactly as `commit_main_trace` splits them. - // ★ Per-table preprocessed trees — #768's arrangement, kept for the same - // reason (see `BatchedQueryOpening::prep`): each preprocessed table keeps - // its OWN row-pair tree, the one `air.precomputed_commitment()` pins, and - // both sides absorb that root FROM THE AIR SET, never from the proof — - // the per-table path's critical soundness check, verbatim. The trees are - // process-cached by root, so continuation epochs stop re-committing the - // execution-independent tables (DECODE, BITWISE, ...), exactly as the - // per-table prover does. - let mut prep_trees: Vec>> = - (0..num_tables).map(|_| None).collect(); - let mut main_builder = StreamingMmcsBuilder::::new(&shape.main.dims); - let mut retained_main: Vec>, usize)>> = - (0..num_tables).map(|_| None).collect(); - // The carved table's standalone main tree and its root. Built inside the - // phase-1 loop from the same expansion every other table commits from; the - // root is absorbed AFTER the loop (after every preprocessed root) and - // before `main_root`. - let mut carved_tree: Option>> = None; - let mut carved_root: Option = None; - - for table in 0..num_tables { - let (air, trace, _) = &air_trace_pairs[table]; - let (main_data, total_cols) = P::expand_main_lde_row_major( - trace, - &domains[table], - &twiddles[table], - #[cfg(feature = "disk-spill")] - storage_mode, - ); - stats.main_lde_expansions += 1; - let bytes = lde_bytes::(main_data.len()); - ledger.alloc(bytes); - - let height = shape.heights[table]; - let is_carved = shape.carved_main.map(|c| c.table) == Some(table); - let num_precomputed = if is_carved { - // `derive_carved` rejects a preprocessed carved table, so the - // carved matrix is the full main range. - 0 - } else { - total_cols - matrix_width(&shape.main, table) - }; - - if num_precomputed > 0 { - // The root every verifier will absorb is the AIR's own; building a - // tree that disagrees with it is a stale constant or a wrong LDE, - // and the per-table path's error is the honest name for both. - let expected = air.precomputed_commitment(); - let tree = - match crate::prover::precomputed_tree_cache_get::>(&expected) { - Some(tree) => tree, - None => { - let (tree, root) = P::commit_rows_bit_reversed_subset::( - &main_data, - total_cols, - 0, - num_precomputed, - ) - .ok_or(ProvingError::PrecomputedCommitmentMismatch)?; - if root != expected { - return Err(ProvingError::PrecomputedCommitmentMismatch); - } - let tree = std::sync::Arc::new(tree); - crate::prover::precomputed_tree_cache_put( - expected, - std::sync::Arc::clone(&tree), - ); - tree - } - }; - transcript.append_bytes(&expected); - prep_trees[table] = Some(tree); - } - if is_carved { - // The carve: the identical committer call the per-table prover - // makes for a non-preprocessed table (`commit_rows_bit_reversed` = - // the subset call over the full range), on the identical - // expansion — the root is byte-identical to the per-table tree's. - let (tree, root) = - P::commit_rows_bit_reversed_subset::(&main_data, total_cols, 0, total_cols) - .ok_or_else(|| { - ProvingError::WrongParameter( - "the carved table's main matrix has no committable rows".to_string(), - ) - })?; - carved_tree = Some(tree); - carved_root = Some(root); - } else { - let src = vec![BorrowedMatrix::RowMajorNatural { - data: &main_data, - stride: total_cols, - col_start: num_precomputed, - width: total_cols - num_precomputed, - log_height: height, - }]; - main_builder.absorb(&src, 0); - } - - // The root is what Fiat-Shamir needs; the buffer is not. Under - // `RecomputeLde` it dies here and every later phase rebuilds it. - match residency { - ResidencyMode::Retain => retained_main[table] = Some((main_data, total_cols)), - ResidencyMode::RecomputeLde => { - drop(main_data); - ledger.free(bytes); - } - } - } - - // The carved root's transcript slot: after every preprocessed root, before - // `main_root` — so every challenge (LogUp, beta, z, gamma, alpha, iotas) is - // drawn after it. Proof-carried on the verifier's side, absorbed here from - // the tree just built. - if let Some(root) = carved_root.as_ref() { - transcript.append_bytes(root); - } - - let main_mmcs = main_builder.finish(); - let main_root = main_mmcs.root(); - transcript.append_bytes(&main_root); - - // ===================================================================== - // Phase 2 — LogUp challenges, then the auxiliary round - // ===================================================================== - stats.phase_wall[0] = t_phase.elapsed(); - let t_phase = std::time::Instant::now(); - let needs_lookup = airs.iter().any(|air| air.has_aux_trace()); - let lookup_challenges: Vec> = if needs_lookup { - (0..LOGUP_NUM_CHALLENGES) - .map(|_| transcript.sample_field_element()) - .collect() - } else { - Vec::new() - }; - - // The aux round expands its LDE from the host trace columns - // (`expand_aux_lde_row_major` below); the device-resident aux build - // returns the columns device-side only and leaves the host trace - // unwritten, so it is disabled for every table here — the same switch - // the per-table prover throws under disk-spill and `RecomputeLde`. - #[cfg(feature = "cuda")] - for (_, trace, _) in air_trace_pairs.iter_mut() { - trace.set_resident_aux_ok(false); - } - - let mut bus_public_inputs: Vec>> = - (0..num_tables).map(|_| None).collect(); - let mut aux_builder = (!shape.aux.is_empty()) - .then(|| StreamingMmcsBuilder::::new(&shape.aux.dims)); - let mut retained_aux: Vec>, usize)>> = - (0..num_tables).map(|_| None).collect(); - - for table in 0..num_tables { - let (air, trace, _) = &mut air_trace_pairs[table]; - if !air.has_aux_trace() { - continue; - } - bus_public_inputs[table] = air.build_auxiliary_trace(trace, &lookup_challenges); - - #[cfg(feature = "disk-spill")] - if storage_mode == StorageMode::Disk { - trace - .spill_aux_to_disk() - .map_err(|e| ProvingError::DiskSpill(format!("aux trace: {e}")))?; - } - - let Some(builder) = aux_builder.as_mut() else { - continue; - }; - let (aux_data, aux_cols) = P::expand_aux_lde_row_major( - trace, - &domains[table], - &twiddles[table], - #[cfg(feature = "disk-spill")] - storage_mode, - ); - stats.aux_lde_expansions += 1; - let bytes = lde_bytes::(aux_data.len()); - ledger.alloc(bytes); - let src = vec![BorrowedMatrix::RowMajorNatural { - data: &aux_data, - stride: aux_cols, - col_start: 0, - width: aux_cols, - log_height: shape.heights[table], - }]; - builder.absorb(&src, 0); - match residency { - ResidencyMode::Retain => retained_aux[table] = Some((aux_data, aux_cols)), - ResidencyMode::RecomputeLde => { - drop(aux_data); - ledger.free(bytes); - } - } - } - - let aux_mmcs = aux_builder.map(StreamingMmcsBuilder::finish); - let aux_root = aux_mmcs.as_ref().map(MixedMmcs::root); - if let Some(root) = aux_root { - transcript.append_bytes(&root); - } - - // ===================================================================== - // Phase 3 — bus contributions, beta per table, the composition-parts round - // ===================================================================== - stats.phase_wall[1] = t_phase.elapsed(); - let t_phase = std::time::Instant::now(); - for bpi in bus_public_inputs.iter().flatten() { - transcript.append_field_element(&bpi.table_contribution); - } - - let mut parts_builder = StreamingMmcsBuilder::::new(&shape.parts.dims); - let mut retained_parts: Vec>>> = - (0..num_tables).map(|_| Vec::new()).collect(); - - for table in 0..num_tables { - let beta: FieldElement = transcript.sample_field_element(); - let (air, _, pub_inputs) = &air_trace_pairs[table]; - let domain = &domains[table]; - - let num_transition_constraints = air.context().num_transition_constraints; - let num_boundary_constraints = air - .boundary_constraints( - pub_inputs, - &lookup_challenges, - bus_public_inputs[table].as_ref(), - domain.interpolation_domain_size, - ) - .constraints - .len(); - let mut coefficients: Vec> = - core::iter::successors(Some(FieldElement::one()), |x| Some(x * beta)) - .take(num_boundary_constraints + num_transition_constraints) - .collect(); - let transition_coefficients: Vec<_> = - coefficients.drain(..num_transition_constraints).collect(); - let boundary_coefficients = coefficients; - - let ldes = materialize_ldes::( - table, - &air_trace_pairs, - &domains, - &twiddles, - &shape, - &mut retained_main, - &mut retained_aux, - &mut stats, - &mut ledger, - residency, - #[cfg(feature = "disk-spill")] - storage_mode, - ); - let (mut lde_trace, carried_bytes) = - lde_trace_take(ldes, air.step_size(), domain.blowup_factor); - - let computed = P::compute_composition_parts( - *air, - pub_inputs, - domain, - &twiddles[table], - &mut lde_trace, - &lookup_challenges, - bus_public_inputs[table].as_ref(), - &transition_coefficients, - &boundary_coefficients, - )?; - stats.parts_computations += 1; - let parts = computed.parts; - - let parts_bytes: usize = parts - .iter() - .map(|p| lde_bytes::(p.len())) - .sum(); - parts_ledger.alloc(parts_bytes); - let src = vec![BorrowedMatrix::ColMajorNatural { - cols: &parts, - log_height: shape.heights[table], - }]; - parts_builder.absorb(&src, 0); - - // Parts are RETAINED: rebuilding them is a second constraint evaluation. - retained_parts[table] = parts; - release_ldes( - ldes_from_trace(lde_trace, carried_bytes), - &mut retained_main, - &mut retained_aux, - table, - &mut ledger, - residency, - ); - } - - let parts_mmcs = parts_builder.finish(); - let parts_root = parts_mmcs.root(); - transcript.append_bytes(&parts_root); - - // ===================================================================== - // Phase 4 — z per table, OOD evaluations - // ===================================================================== - stats.phase_wall[2] = t_phase.elapsed(); - let t_phase = std::time::Instant::now(); - let mut zs = Vec::with_capacity(num_tables); - let mut round3s = Vec::with_capacity(num_tables); - let mut ood_blocks = Vec::with_capacity(num_tables); - - for table in 0..num_tables { - let (air, _, _) = &air_trace_pairs[table]; - let domain = &domains[table]; - // `sample_z_ood_with_domain_params` rather than `sample_z_ood`: the - // verifier has the trace length and the blowup but not the domain - // vectors, so naming the routine both sides can reach is what makes the - // two agree by construction instead of by two call sites coinciding. - let z = transcript.sample_z_ood_with_domain_params( - domain.interpolation_domain_size, - domain.interpolation_domain_size * domain.blowup_factor, - &coset_offset, - ); - - // Phase 4 reads the trace ONLY at stride `blowup` — the size-`n` - // coset evaluation. Under `Retain` the full LDE is already on hand - // and the strided read is free; under `RecomputeLde` a full 4n - // expansion here would be paid just to subsample it, so the - // recompute arm materializes the n-sized evaluation directly - // (bit-identical values, ~37% of the work, a quarter of the bytes) - // and hands `round_3` a blowup-1 table, whose OWN stride the trace - // reads follow. - let round3 = if retained_main[table].is_some() { - let ldes = materialize_ldes::( - table, - &air_trace_pairs, - &domains, - &twiddles, - &shape, - &mut retained_main, - &mut retained_aux, - &mut stats, - &mut ledger, - residency, - #[cfg(feature = "disk-spill")] - storage_mode, - ); - let (mut lde_trace, carried_bytes) = - lde_trace_take(ldes, air.step_size(), domain.blowup_factor); - let round3 = P::round_3_evaluate_polynomials_in_out_of_domain_element( - *air, - domain, - &mut lde_trace, - &mut retained_parts[table], - &z, - ); - release_ldes( - ldes_from_trace(lde_trace, carried_bytes), - &mut retained_main, - &mut retained_aux, - table, - &mut ledger, - residency, - ); - round3 - } else { - let (_, trace, _) = &air_trace_pairs[table]; - let t_expand = std::time::Instant::now(); - let main = P::expand_main_coset_eval_row_major(trace, domain, &twiddles[table]); - let aux = if matrix_index(&shape.aux, table).is_some() { - let aux = P::expand_aux_coset_eval_row_major(trace, domain, &twiddles[table]); - stats.aux_coset_evals += 1; - aux - } else { - (Vec::new(), 0) - }; - stats.lde_expansion_wall += t_expand.elapsed(); - stats.main_coset_evals += 1; - let bytes = lde_bytes::(main.0.len()) + lde_bytes::(aux.0.len()); - ledger.alloc(bytes); - let mut lde_trace = - LDETraceTable::from_row_major(main.0, main.1, aux.0, aux.1, air.step_size(), 1); - let round3 = P::round_3_evaluate_polynomials_in_out_of_domain_element( - *air, - domain, - &mut lde_trace, - &mut retained_parts[table], - &z, - ); - drop(lde_trace); - ledger.free(bytes); - round3 - }; - - let (block0, block1) = P::ood_layout(*air).split_full(&round3.trace_ood_evaluations); - for block in [&block0, &block1] { - for col in block.columns().iter() { - for elem in col.iter() { - transcript.append_field_element(elem); - } - } - } - for element in round3.composition_poly_parts_ood_evaluation.iter() { - transcript.append_field_element(element); - } - - zs.push(z); - ood_blocks.push((block0, block1)); - round3s.push(round3); - } - - // ===================================================================== - // Phase 5 — gamma per table, then ONE batched FRI - // ===================================================================== - stats.phase_wall[3] = t_phase.elapsed(); - let t_phase = std::time::Instant::now(); - let gammas: Vec> = (0..num_tables) - .map(|_| transcript.sample_field_element()) - .collect(); - - let commit = { - let air_trace_pairs = &air_trace_pairs; - let domains = &domains; - let twiddles = &twiddles; - let shape = &shape; - // `&mut`: the DEEP host loop repopulates a table's part evals from the - // resident handle when the device-only gate left them empty. - let retained_parts = &mut retained_parts; - let round3s = &round3s; - let zs = &zs; - let gammas = &gammas; - let retained_main = &mut retained_main; - let retained_aux = &mut retained_aux; - let stats = &mut stats; - let ledger = &mut ledger; - let coset_offset_ref = &coset_offset; - - commit_batched_fri::( - transcript, - &shape.heights, - &shape.total_widths(), - move |alpha, plan| { - // The standalone class's terminal polynomials, handed back so - // `commit_batched_fri` binds them into the transcript and the - // wire carries the very coefficients that were bound. - let mut standalone_coeffs: Vec>>> = - (0..num_tables).map(|_| None).collect(); - let mut combiner = HeightCombiner::new(*alpha); - // Ascending table order, which is also `plan.batched`'s order — - // absorption order is what defines the alpha powers, so the two - // must not be allowed to drift apart. - for table in 0..num_tables { - let (air, _, _) = &air_trace_pairs[table]; - let domain = &domains[table]; - let ldes = materialize_ldes::( - table, - air_trace_pairs, - domains, - twiddles, - shape, - retained_main, - retained_aux, - stats, - ledger, - residency, - #[cfg(feature = "disk-spill")] - storage_mode, - ); - let (mut lde_trace, carried_bytes) = - lde_trace_take(ldes, air.step_size(), domain.blowup_factor); - let mut deep = deep_codeword::( - *air, - domain, - &mut lde_trace, - &mut retained_parts[table], - &round3s[table], - &zs[table], - &gammas[table], - ); - release_ldes( - ldes_from_trace(lde_trace, carried_bytes), - retained_main, - retained_aux, - table, - ledger, - residency, - ); - // Row-major variant at one column = the parallel path; the - // serial swap loop was pure wall time, 27 times per epoch. - in_place_bit_reverse_permute_row_major(&mut deep, 1); - - if plan.batched.contains(&table) { - combiner.absorb(&deep, shape.heights[table]); - } else { - // A standalone table's terminal codeword IS this - // codeword; the proof carries the polynomial it - // evaluates, at its own degree bound. - let log_degree = (shape.heights[table] as u32) - params.blowup_log; - standalone_coeffs[table] = Some(coeffs_from_terminal_codeword::< - Field, - FieldExtension, - >( - &deep, coset_offset_ref, log_degree - )); - } - } - (combiner.finish(), standalone_coeffs) - }, - &coset_offset, - params.blowup_log, - params.final_poly_log_degree, - params.grinding_factor, - params.num_queries, - ) - }; - - // ===================================================================== - // Phase 6 — openings, one table at a time - // ===================================================================== - stats.phase_wall[4] = t_phase.elapsed(); - let t_phase = std::time::Instant::now(); - let iotas = commit.iotas.clone(); - let fri_decommitments = crate::fri::query_phase::(&commit.layers, &iotas); - - // Per-query, per-prep-table standard openings (prep-table order = - // `shape.prep.tables`, which is AIR order). - let mut prep_openings: Vec>> = - (0..iotas.len()).map(|_| Vec::new()).collect(); - let mut main_openings = empty_openings::(&iotas, shape.main.tables.len()); - let mut aux_openings = empty_openings::(&iotas, shape.aux.tables.len()); - let mut parts_openings = empty_openings::(&iotas, shape.parts.tables.len()); - let mut carved_openings: Vec>> = - (0..iotas.len()).map(|_| None).collect(); - - // ★ Each round is read in ITS OWN index space, and the reduction happens - // exactly once, here. Doing it inside the read would be wrong twice over: a - // round shorter than the FRI would be asked for a leaf it does not have - // (the prep round's `h_max` is below the FRI's whenever the tallest - // preprocessed table is not the tallest table), and a round that reduced - // again on the way out would land somewhere else entirely. - let main_iotas = reduced_iotas(&iotas, h_max, main_mmcs.h_max()); - let aux_iotas = aux_mmcs - .as_ref() - .map(|mmcs| reduced_iotas(&iotas, h_max, mmcs.h_max())); - let parts_iotas = reduced_iotas(&iotas, h_max, parts_mmcs.h_max()); - - for table in 0..num_tables { - let (air, _, _) = &air_trace_pairs[table]; - let ldes = materialize_ldes::( - table, - &air_trace_pairs, - &domains, - &twiddles, - &shape, - &mut retained_main, - &mut retained_aux, - &mut stats, - &mut ledger, - residency, - #[cfg(feature = "disk-spill")] - storage_mode, - ); - let _ = air; - let height = shape.heights[table]; - let (main_data, total_cols) = &ldes.main; - let is_carved = shape.carved_main.map(|c| c.table) == Some(table); - let num_precomputed = if is_carved { - 0 - } else { - total_cols - matrix_width(&shape.main, table) - }; - - if is_carved { - let tree = carved_tree - .as_ref() - .expect("the carved tree was built in phase 1"); - // The carved tree lives in the TABLE's own index space, exactly - // like a preprocessed tree: reduce the shared FRI index once. - let table_iotas = reduced_iotas(&iotas, h_max, height); - for (q, &idx) in table_iotas.iter().enumerate() { - carved_openings[q] = Some(P::open_polys_with(&domains[table], tree, idx, |row| { - main_data[row * total_cols..(row + 1) * total_cols].to_vec() - })); - } - } - - if let Some(tree) = prep_trees[table].as_ref() { - // The per-table tree lives in the TABLE's own index space; reduce - // the shared FRI index by the height difference once, here. - let table_iotas = reduced_iotas(&iotas, h_max, height); - for (q, &idx) in table_iotas.iter().enumerate() { - prep_openings[q].push(P::open_polys_with(&domains[table], tree, idx, |row| { - main_data[row * total_cols..row * total_cols + num_precomputed].to_vec() - })); - } - } - if let Some(m) = matrix_index(&shape.main, table) { - let src = vec![BorrowedMatrix::RowMajorNatural { - data: main_data, - stride: *total_cols, - col_start: num_precomputed, - width: total_cols - num_precomputed, - log_height: height, - }]; - fill_openings(&main_mmcs, m, &src, &main_iotas, &mut main_openings); - } - if let (Some(mmcs), Some(m)) = (aux_mmcs.as_ref(), matrix_index(&shape.aux, table)) { - let (aux_data, aux_cols) = &ldes.aux; - let src = vec![BorrowedMatrix::RowMajorNatural { - data: aux_data, - stride: *aux_cols, - col_start: 0, - width: *aux_cols, - log_height: height, - }]; - let indices = aux_iotas.as_ref().expect("the aux MMCS exists here"); - fill_openings(mmcs, m, &src, indices, &mut aux_openings); - } - if let Some(m) = matrix_index(&shape.parts, table) { - let src = vec![BorrowedMatrix::ColMajorNatural { - cols: &retained_parts[table], - log_height: height, - }]; - fill_openings(&parts_mmcs, m, &src, &parts_iotas, &mut parts_openings); - } - - release_ldes( - ldes, - &mut retained_main, - &mut retained_aux, - table, - &mut ledger, - residency, - ); - } - - let queries = (0..iotas.len()) - .map(|q| BatchedQueryOpening { - prep: std::mem::take(&mut prep_openings[q]), - main: assemble(&main_mmcs, main_iotas[q], &mut main_openings, q) - .expect("the main round was opened at these very indices"), - aux: aux_mmcs.as_ref().map(|mmcs| { - let indices = aux_iotas.as_ref().expect("the aux MMCS exists here"); - assemble(mmcs, indices[q], &mut aux_openings, q) - .expect("the aux round was opened at these very indices") - }), - parts: assemble(&parts_mmcs, parts_iotas[q], &mut parts_openings, q) - .expect("the parts round was opened at these very indices"), - carved_main: carved_openings[q].take(), - fri: fri_decommitments[q].clone(), - }) - .collect(); - - let tables = (0..num_tables) - .map(|table| { - let (block0, block1) = ood_blocks[table].clone(); - BatchedTableData { - trace_length: trace_lengths[table], - trace_ood_evaluations: block0, - trace_ood_next_evaluations: block1, - composition_poly_parts_ood_evaluation: round3s[table] - .composition_poly_parts_ood_evaluation - .clone(), - bus_public_inputs: bus_public_inputs[table].clone(), - public_inputs: air_trace_pairs[table].2.clone(), - standalone_final_poly_coeffs: commit.standalone_coeffs[table].clone(), - } - }) - .collect(); - - stats.phase_wall[5] = t_phase.elapsed(); - stats.peak_trace_lde_bytes = ledger.peak(); - stats.retained_parts_bytes = parts_ledger.peak(); - stats.peak_lde_bytes = stats.peak_trace_lde_bytes + stats.retained_parts_bytes; - - Ok(( - BatchedMultiProof { - tables, - main_root, - carved_main_root: carved_root, - aux_root, - parts_root, - fri_layer_roots: commit.layer_roots, - fri_final_poly_coeffs: commit.final_poly_coeffs, - nonce: commit.nonce, - queries, - }, - stats, - )) -} - -/// Matrix index of `table` inside `round`, or `None` when it does not -/// contribute one. -fn matrix_index(round: &RoundShape, table: usize) -> Option { - round.tables.iter().position(|&t| t == table) -} - -/// The width `table` contributes to `round`. Zero when it contributes nothing. -fn matrix_width(round: &RoundShape, table: usize) -> usize { - matrix_index(round, table).map_or(0, |m| round.dims[m].1) -} - -#[allow(clippy::type_complexity)] -fn empty_openings( - iotas: &[usize], - num_matrices: usize, -) -> Vec>>> { - iotas - .iter() - .map(|_| (0..num_matrices).map(|_| None).collect()) - .collect() -} - -/// Read one matrix's row pair at every query, so a table's openings are -/// harvested while its LDE is alive and never after. -fn fill_openings( - mmcs: &MixedMmcs, - matrix: usize, - source: &S, - iotas: &[usize], - out: &mut [Vec>>], -) where - E: IsField + 'static, - H: StarkHash, - S: LeafSource, - FieldElement: AsBytes + Sync + Send, -{ - // `iotas` are in THIS round's index space already (see `reduced_iotas`). - // Passing the FRI's raw indices here does not corrupt anything quietly: a - // shorter round rejects them as out of range and produces no opening at - // all, which is what `the_preprocessed_round_is_committed_and_authenticates` - // caught the first time this was written the other way round. - for (q, &iota) in iotas.iter().enumerate() { - let Some(leaf) = mmcs.row_pair_leaf(iota, matrix) else { - continue; - }; - let mut evaluations = Vec::new(); - source.append_row(0, 2 * leaf, &mut evaluations); - let mut evaluations_sym = Vec::new(); - source.append_row(0, 2 * leaf + 1, &mut evaluations_sym); - out[q][matrix] = Some(PolynomialOpenings { - proof: crypto::merkle_tree::proof::Proof { - merkle_path: Vec::new(), - }, - evaluations, - evaluations_sym, - }); - } -} - -/// Reduce every FRI query index into one round's index space. -/// -/// `h_max_round <= h_max_fri` always holds for a round of this epoch — a round -/// commits a subset of the epoch's tables, so its tallest matrix cannot exceed -/// the epoch's — which is why this is infallible here and -/// `reduce_iota_to_round` returns an `Option` on the verifier's path, where the -/// heights are proof-supplied. -fn reduced_iotas(iotas: &[usize], h_max_fri: usize, h_max_round: usize) -> Vec { - iotas - .iter() - .map(|&iota| { - crate::batched::round4::reduce_iota_to_round(iota, h_max_fri, h_max_round) - .expect("a round of this epoch is never taller than the epoch") - }) - .collect() -} - -/// Turn one query's per-matrix rows into a [`MixedOpening`] by attaching the -/// round's shared authentication path. -/// -/// `iota` is already in this round's index space — see [`reduced_iotas`]. -fn assemble( - mmcs: &MixedMmcs, - iota: usize, - openings: &mut [Vec>>], - query: usize, -) -> Option> -where - E: IsField + 'static, - H: StarkHash, - FieldElement: AsBytes + Sync + Send, -{ - let proof = mmcs.auth_path(iota)?; - let per_matrix = openings[query] - .iter_mut() - .map(|slot| slot.take()) - .collect::>>()?; - Some(MixedOpening { proof, per_matrix }) -} - -/// Build (or take back) a table's main and aux LDEs for the phase about to read -/// them. -#[allow(clippy::too_many_arguments)] -fn materialize_ldes( - table: usize, - air_trace_pairs: &[BatchedAirTracePair<'_, Field, FieldExtension, PI>], - domains: &[std::sync::Arc>], - twiddles: &[std::sync::Arc>], - shape: &EpochShape, - retained_main: MainSlots<'_, Field>, - retained_aux: AuxSlots<'_, FieldExtension>, - stats: &mut BatchedProveStats, - ledger: &mut ResidencyLedger, - residency: ResidencyMode, - #[cfg(feature = "disk-spill")] storage_mode: StorageMode, -) -> LdePair -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + Copy + 'static, - FieldExtension: IsField + Send + Sync + Copy + 'static, - FieldElement: AsBytes + math::traits::ByteConversion + Sync + Send, - FieldElement: AsBytes + math::traits::ByteConversion + Sync + Send, - H: StarkHash, - P: IsStarkProver + ?Sized, -{ - let (_, trace, _) = &air_trace_pairs[table]; - let mut bytes = 0usize; - - let main = match retained_main[table].take() { - Some(lde) => lde, - None => { - let t_expand = std::time::Instant::now(); - let lde = P::expand_main_lde_row_major( - trace, - &domains[table], - &twiddles[table], - #[cfg(feature = "disk-spill")] - storage_mode, - ); - stats.lde_expansion_wall += t_expand.elapsed(); - stats.main_lde_expansions += 1; - let b = lde_bytes::(lde.0.len()); - ledger.alloc(b); - bytes += b; - lde - } - }; - - let aux = if matrix_index(&shape.aux, table).is_some() { - match retained_aux[table].take() { - Some(lde) => lde, - None => { - let t_expand = std::time::Instant::now(); - let lde = P::expand_aux_lde_row_major( - trace, - &domains[table], - &twiddles[table], - #[cfg(feature = "disk-spill")] - storage_mode, - ); - stats.lde_expansion_wall += t_expand.elapsed(); - stats.aux_lde_expansions += 1; - let b = lde_bytes::(lde.0.len()); - ledger.alloc(b); - bytes += b; - lde - } - } - } else { - (Vec::new(), 0) - }; - - let _ = residency; - LdePair { main, aux, bytes } -} - -/// Give a table's LDEs back to the retention slots, or drop them. -fn release_ldes( - ldes: LdePair, - retained_main: MainSlots<'_, Field>, - retained_aux: AuxSlots<'_, FieldExtension>, - table: usize, - ledger: &mut ResidencyLedger, - residency: ResidencyMode, -) { - match residency { - ResidencyMode::Retain => { - retained_main[table] = Some(ldes.main); - if ldes.aux.1 > 0 { - retained_aux[table] = Some(ldes.aux); - } - } - ResidencyMode::RecomputeLde => { - drop(ldes.main); - drop(ldes.aux); - ledger.free(ldes.bytes); - } - } -} - -/// Move a table's LDE buffers into the trace view the phase reads — no copy. -/// The phases never mutate the buffers on the host path (the one bulk writer, -/// the cuda `set_host_data`, only FILLS deliberately-empty buffers), so the -/// same allocation flows phase → view → [`ldes_from_trace`] → retention, and -/// the transient double-residency the old clone created — one table's whole -/// main+aux LDE, invisible to the ledger — is gone. -fn lde_trace_take( - ldes: LdePair, - step_size: usize, - blowup_factor: usize, -) -> (LDETraceTable, usize) -where - Field: IsFFTField + IsSubFieldOf, - FieldExtension: IsField, -{ - let LdePair { main, aux, bytes } = ldes; - ( - LDETraceTable::from_row_major(main.0, main.1, aux.0, aux.1, step_size, blowup_factor), - bytes, - ) -} - -/// Take the buffers back out of the trace view for release or retention — -/// the inverse of [`lde_trace_take`], carrying the byte account through. -fn ldes_from_trace( - lde_trace: LDETraceTable, - bytes: usize, -) -> LdePair -where - Field: IsFFTField + IsSubFieldOf, - FieldExtension: IsField, -{ - LdePair { - main: (lde_trace.main_data, lde_trace.num_main_cols), - aux: (lde_trace.aux_data, lde_trace.num_aux_cols), - bytes, - } -} - -/// One table's DEEP composition codeword, in NATURAL order. -#[allow(clippy::too_many_arguments)] -fn deep_codeword( - air: &dyn AIR, - domain: &Domain, - // `&mut` to match `compute_deep_composition_poly_evaluations`, whose host - // loop downloads the resident trace and part evals in place when the - // device-only gate left them empty. - lde_trace: &mut LDETraceTable, - composition_parts: &mut [Vec>], - round3: &crate::prover::Round3, - z: &FieldElement, - gamma: &FieldElement, -) -> Vec> -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + Copy + 'static, - FieldExtension: IsField + Send + Sync + Copy + 'static, - FieldElement: AsBytes + math::traits::ByteConversion + Sync + Send, - FieldElement: AsBytes + math::traits::ByteConversion + Sync + Send, - H: StarkHash, - P: IsStarkProver + ?Sized, -{ - let n_terms_composition_poly = composition_parts.len(); - let layout = P::ood_layout(air); - let num_terms_trace = layout.num_surviving(); - - let mut deep_composition_coefficients: Vec> = - core::iter::successors(Some(FieldElement::one()), |x| Some(x * gamma)) - .take(n_terms_composition_poly + num_terms_trace) - .collect(); - let trace_term_powers: Vec<_> = deep_composition_coefficients - .drain(..num_terms_trace) - .collect(); - let trace_term_coeffs = layout.build_trace_term_coeffs(&trace_term_powers); - let gammas = deep_composition_coefficients; - - P::compute_deep_composition_poly_evaluations( - lde_trace, - composition_parts, - round3, - z, - domain, - &domain.trace_primitive_root, - &gammas, - &trace_term_coeffs, - ) -} diff --git a/crypto/stark/src/batched/round4.rs b/crypto/stark/src/batched/round4.rs deleted file mode 100644 index 586dbc9b3..000000000 --- a/crypto/stark/src/batched/round4.rs +++ /dev/null @@ -1,901 +0,0 @@ -//! Round 4 of the batched path: ONE FRI instance over the epoch's height-combined -//! DEEP codewords. -//! -//! # The transcript sequence, and why it has one owner -//! -//! ```text -//! shape histogram → α → standalone terminals → (β, layer root)* → β_final → terminal coeffs → grinding → iotas -//! ``` -//! -//! [`commit_batched_fri`] walks it on the prover's side; -//! [`crate::fri::batched::derive_batched_fri_challenges`] walks it on the -//! verifier's. The two are pinned to each other by -//! `prover_commit_matches_verifier_derivation`, not by review of two call sites. -//! α is sampled AFTER the shape is absorbed and BEFORE any codeword is combined, -//! which is why this function takes a `combine` closure rather than the codewords: -//! the prover cannot mix with α until the transcript has produced it, and the -//! closure is where a caller streams table by table (see -//! [`crate::fri::batched::HeightCombiner`]). -//! -//! # ★ TWO instance classes, and the index rule between them -//! -//! Not every table belongs in the batch. A table whose own FRI commits ZERO -//! layers gains nothing from being batched — there is no layer for the batch to -//! share — while it pays the full lift to the tallest domain, which is where the -//! proximity-gaps term's `|D0|^2` lives. At the measured epoch that is 13 of 28 -//! legs carrying 92% of the batch's width. [`FriInstancePlan`] partitions them, -//! and the excluded tables keep a terminal-only instance -//! ([`verify_standalone_fri_query`]) that costs one polynomial and no layers. -//! -//! The MMCS is untouched by this split — it still commits every table, so the -//! one-shared-authentication-path win survives whole. What differs is the index -//! SPACE: the batched class reads `iota` directly, a standalone table at height -//! `h` reads `iota >> (h_max - h)`. Both classes need a tamper control, since a -//! control that only touched the batched one would pass under any convention for -//! the other. -//! -//! # Query indices and the injection convention -//! -//! One `iota` per query, drawn from `[0, 2^(h_max-1))` — a row-PAIR index in the -//! TALLEST codeword's domain. Every shorter object is located by shifting it -//! down, which is what makes "one index, shared across all tables" true rather -//! than aspirational: -//! -//! - a matrix of height `h` in a round whose own tallest matrix is `h_max_round` -//! is opened at MMCS leaf `iota >> (h_max_fri - h)` — but note that -//! [`crate::fri::mmcs::MixedMmcs::verify_batch`] wants an index in ITS OWN -//! space, so a round whose `h_max_round` is below the FRI's must first reduce -//! (see that module's index-convention section, and [`reduce_iota_to_round`]). -//! - the codeword bucket at height `h` is read at position -//! [`injection_position`], which is exactly one of the two rows of the pair the -//! MMCS opened. That coincidence is not luck: both are the same row-pair -//! layout, which is why a single opening serves both the authentication and the -//! FRI join. -//! -//! # What "injection" costs the verifier -//! -//! The prover's [`crate::fri::batched::batched_commit_phase`] folds, then adds -//! `β² · bucket_h` to the running codeword before committing the layer. So the -//! verifier's per-query recursion adds the same term to the value it computed by -//! folding — and only to that value. The symmetric value at each layer comes from -//! the proof and is Merkle-authenticated against the layer root, so it already -//! carries its own injection; re-adding one would double it. - -use crypto::fiat_shamir::is_transcript::{IsStarkTranscript, IsTranscript}; -use crypto::merkle_tree::proof::verify_merkle_path; -use math::field::element::FieldElement; -use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; -use math::traits::AsBytes; - -use crate::config::{Commitment, StarkHash}; -use crate::fri::batched::{ - BatchedFriLayout, FriInstancePlan, absorb_shape_histogram, batched_commit_phase, - derive_batched_fri_challenges, -}; -use crate::fri::fri_commitment::FriLayer; -use crate::fri::fri_decommit::FriDecommitment; -use crate::grinding; - -/// What the prover produced in the batched round 4, plus the challenges it drew -/// on the way. The layers are kept so the caller can run the query phase over -/// them; everything else is what goes on the wire. -pub struct BatchedFriCommit -where - FieldElement: AsBytes + Sync + Send, -{ - pub layers: Vec>>, - pub layer_roots: Vec, - pub final_poly_coeffs: Vec>, - pub layout: BatchedFriLayout, - /// The grinding nonce, `None` when `grinding_factor == 0`. - pub nonce: Option, - /// Row-pair indices in the tallest domain, one per query. - pub iotas: Vec, - /// The mixing challenge the codewords were combined with. Kept because the - /// query phase needs it to rebuild each table's contribution. - pub alpha: FieldElement, - /// Which tables this instance carries, and which keep a terminal-only - /// instance of their own. See [`FriInstancePlan`]. - pub plan: FriInstancePlan, - /// Per table: the standalone class's terminal polynomial, `Some` exactly - /// for `plan.standalone`. Produced by `combine`, ABSORBED here (right - /// after α, before the first ζ — see `derive_batched_fri_challenges` for - /// why that absorb is load-bearing), and returned so the caller puts the - /// very coefficients the transcript bound onto the wire. - pub standalone_coeffs: Vec>>>, -} - -/// Prover side of the batched round-4 sequence. -/// -/// `heights[t]` is `log2` of table `t`'s LDE length and `widths[t]` its committed -/// column count, both in the epoch's canonical table order — the same order the -/// verifier rebuilds from the AIR set, and the same order `combine` must absorb -/// codewords in, since absorption order is what defines the α powers. -/// -/// `combine` receives α and returns the per-height buckets (see -/// [`crate::fri::batched::HeightCombiner::finish`]) TOGETHER WITH the -/// standalone class's terminal polynomials, per table (`Some` exactly for -/// `plan.standalone`). It is a closure rather than a materialized `Vec` so a -/// caller can produce one table's DEEP codeword, absorb it and drop it: -/// holding all of them at once is the memory cost batching exists to remove. -#[allow(clippy::too_many_arguments)] -pub fn commit_batched_fri( - transcript: &mut T, - heights: &[usize], - widths: &[usize], - combine: C, - coset_offset: &FieldElement, - blowup_log: u32, - final_poly_log_degree: u32, - grinding_factor: u8, - num_queries: usize, -) -> BatchedFriCommit -where - F: IsFFTField + IsSubFieldOf + 'static, - E: IsField + 'static + Send + Sync, - T: IsStarkTranscript + Clone, - H: StarkHash, - C: FnOnce( - &FieldElement, - &FriInstancePlan, - ) -> ( - Vec>>>, - Vec>>>, - ), - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, -{ - // Derived from the shape, exactly as the verifier derives it — the partition - // is never sent. The tables whose own FRI commits no layer are left out of the - // batch: they gain nothing from it and pay the full lift to the tallest - // domain, which is where the proximity-gaps term's `|D0|^2` lives. - let plan = FriInstancePlan::new(heights, blowup_log, final_poly_log_degree) - .expect("commit_batched_fri: the epoch's shape is the prover's own"); - let h_max = plan.h_max; - - absorb_shape_histogram::(transcript, heights, widths); - let alpha = transcript.sample_field_element(); - - let (combined, standalone_coeffs) = combine(&alpha, &plan); - - // Bind the standalone class's terminal polynomials BEFORE the first ζ — - // the same walk `derive_batched_fri_challenges` replays, and the reason it - // does (its doc): a polynomial not bound here could be chosen after the - // query indices are known. - for (table, coeffs) in standalone_coeffs.iter().enumerate() { - assert_eq!( - coeffs.is_some(), - plan.standalone.contains(&table), - "the standalone terminals exist for exactly the standalone class" - ); - if let Some(coeffs) = coeffs { - for c in coeffs.iter() { - transcript.append_field_element(c); - } - } - } - - let (final_poly_coeffs, layers) = batched_commit_phase::( - combined, - transcript, - coset_offset, - blowup_log, - final_poly_log_degree, - ); - let layer_roots: Vec = layers.iter().map(|layer| layer.merkle_tree.root).collect(); - - // Grinding runs on the CONFIGURATION's transcript hash, not a hard-wired - // one — the same rule the unbatched `prover.rs` follows. `H` names both the - // commitment family and the Fiat-Shamir hash, so a batched proof committed - // with BLAKE3 grinds with BLAKE3 and one committed with keccak grinds with - // keccak, without either side being told twice. - let nonce = (grinding_factor > 0).then(|| { - let value = grinding::generate_nonce::>( - &transcript.state(), - grinding_factor, - ) - .expect("nonce not found"); - transcript.append_bytes(&value.to_be_bytes()); - value - }); - - let iotas = (0..num_queries) - .map(|_| transcript.sample_u64(1u64 << (h_max - 1)) as usize) - .collect(); - - BatchedFriCommit { - layers, - layer_roots, - final_poly_coeffs, - layout: BatchedFriLayout::new(plan.h_max, plan.h_min, blowup_log, final_poly_log_degree), - nonce, - iotas, - alpha, - plan, - standalone_coeffs, - } -} - -/// Verify one query against a STANDALONE table's terminal-only instance. -/// -/// A table whose own FRI commits no layer has a terminal codeword that IS its -/// deep-composition codeword, so there is nothing to fold and nothing to -/// authenticate: the check is that the value the query opened is the value the -/// sent terminal polynomial encodes at that position. -/// -/// ★ `iota` is the SHARED batched query index and is reduced here — the two -/// instance classes read the same index in different spaces (see -/// [`FriInstancePlan`]). `deep` is the table's own deep-composition pair at its -/// reduced row pair, which the caller reconstructs from authenticated openings. -/// -/// Returns `false` on every malformed input; it never panics. -pub fn verify_standalone_fri_query( - iota: usize, - h_max_fri: usize, - h_table: usize, - deep: (&FieldElement, &FieldElement), - terminal_codeword: &[FieldElement], -) -> bool -where - E: IsField + 'static, -{ - let Some(reduced) = reduce_iota_to_round(iota, h_max_fri, h_table) else { - return false; - }; - terminal_codeword - .get(reduced * 2) - .is_some_and(|t| deep.0 == t) - && terminal_codeword - .get(reduced * 2 + 1) - .is_some_and(|t| deep.1 == t) -} - -/// Position, inside the codeword of height `h`, that query `iota` reads. -/// -/// `iota` is a row-pair index in the tallest domain (height `h_max`); the layer -/// whose codeword has height `h` is reached after `h_max - h` folds, and the -/// query's position there is `iota >> (h_max - h - 1)`. Both rows of the pair a -/// height-`h` MMCS opening returns — leaf `iota >> (h_max - h)`, i.e. LDE rows -/// `2k` and `2k+1` — are candidates, and the low bit of this position picks -/// between them; see [`injected_value_at_query`]. -/// -/// Not defined at `h == h_max`: the tallest codeword is the FRI's layer 0, which -/// the query reads as a PAIR (`2·iota`, `2·iota+1`) rather than at one position. -#[inline] -pub fn injection_position(iota: usize, h_max: usize, h: usize) -> usize { - debug_assert!( - h < h_max, - "the tallest codeword is read as a pair, not at a position" - ); - iota >> (h_max - h - 1) -} - -/// The value a height-`h` matrix contributes to its injection layer, chosen from -/// the row pair its MMCS opening returned. -/// -/// `evaluation` is the opening's row `2k` and `evaluation_sym` its row `2k+1`, -/// with `k = iota >> (h_max - h)`. The pair straddles the injection position, so -/// the choice is exactly that position's low bit. -#[inline] -pub fn injected_value_at_query<'a, E: IsField>( - iota: usize, - h_max: usize, - h: usize, - evaluation: &'a FieldElement, - evaluation_sym: &'a FieldElement, -) -> &'a FieldElement { - if injection_position(iota, h_max, h) & 1 == 0 { - evaluation - } else { - evaluation_sym - } -} - -/// Reduce a FRI query index to the index space of a round whose tallest matrix -/// is shorter than the FRI's. -/// -/// [`crate::fri::mmcs::MixedMmcs::verify_batch`] walks its path with the LOW bits -/// of the index it is given, while it locates a short matrix inside the tree by -/// the HIGH bits — consistent only when the index comes from that tree's own -/// `h_max`. The batched preprocessed round is the case that breaks it (its -/// tallest matrix sits below the FRI's), so every caller reduces here rather than -/// each writing the shift out. Returns `None` when the round claims to be TALLER -/// than the FRI, which no honest shape can be. -#[inline] -pub fn reduce_iota_to_round(iota: usize, h_max_fri: usize, h_max_round: usize) -> Option { - (h_max_round <= h_max_fri).then(|| iota >> (h_max_fri - h_max_round)) -} - -/// Verify one query of the batched FRI: the fold-with-injection recursion, every -/// committed layer's opening, and the terminal check. -/// -/// `p0` is the query's pair of values in the tallest codeword — the α-mixed DEEP -/// evaluations of the tables at height `h_max`, at LDE positions `2·iota` and -/// `2·iota + 1`. `bucket_at_height[h]` is `Some(v)` when at least one table has -/// height `h < h_max`, with `v` that height group's α-mixed value at -/// [`injection_position`]; `None` when no table sits at `h`. Both are the -/// caller's to reconstruct from authenticated openings — this function does no -/// authentication of trace data, only of FRI layers. -/// -/// Returns `false` on every malformed input; it never panics. -#[allow(clippy::too_many_arguments)] -pub fn verify_batched_fri_query( - layer_roots: &[Commitment], - betas: &[FieldElement], - layout: &BatchedFriLayout, - h_max: usize, - iota: usize, - decommitment: &FriDecommitment, - evaluation_point_inv: &FieldElement, - p0: (&FieldElement, &FieldElement), - bucket_at_height: &[Option>], - terminal_codeword: &[FieldElement], -) -> bool -where - F: IsFFTField + IsSubFieldOf + 'static, - E: IsField + 'static, - H: StarkHash, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, -{ - // The decommitment vectors are prover-supplied and are NOT bound into the - // transcript, so their lengths are pinned here before anything zips them — - // the same reason `step_3_verify_fri` pins them in the unbatched path. A - // short vector would make the fold loop run fewer rounds and accept the query - // without ever reaching the terminal. - if layer_roots.len() != layout.num_committed - || decommitment.layers_auth_paths.len() != layout.num_committed - || decommitment.layers_evaluations_sym.len() != layout.num_committed - || betas.len() != layout.num_committed + usize::from(layout.total_folds > 0) - { - return false; - } - if h_max == 0 || h_max >= usize::BITS as usize || iota >= 1usize << (h_max - 1) { - return false; - } - if bucket_at_height.len() < h_max { - return false; - } - - // No-fold case: the codeword never folds, so the terminal IS the tallest - // codeword and the query's two points sit at `2·iota` and `2·iota + 1`. No - // bucket can exist below `h_max` here — `h_min == h_max` is what makes - // `total_folds` zero — so there is nothing to inject. - if layout.total_folds == 0 { - return terminal_codeword.get(iota * 2).is_some_and(|t| p0.0 == t) - && terminal_codeword - .get(iota * 2 + 1) - .is_some_and(|t| p0.1 == t); - } - - // First fold: layer 0 (the tallest codeword) is not committed, so this fold - // consumes `p0` rather than an authenticated opening. Then the height just - // below joins, exactly as `batched_commit_phase` does before it commits. - let mut point_inv = evaluation_point_inv.clone(); - let mut v = (p0.0 + p0.1) + &point_inv * &betas[0] * (p0.0 - p0.1); - let mut index = iota; - inject(&mut v, &betas[0], bucket_at_height, h_max - 1); - - let mut openings_ok = true; - for i in 0..layout.num_committed { - let evaluation_sym = &decommitment.layers_evaluations_sym[i]; - openings_ok &= verify_layer_opening::( - &layer_roots[i], - decommitment.layers_auth_paths[i].merkle_path.as_slice(), - &v, - evaluation_sym, - index, - ); - - point_inv = point_inv.square(); - v = (&v + evaluation_sym) + &point_inv * &betas[i + 1] * (&v - evaluation_sym); - index >>= 1; - // The injection height descends with the running codeword. `checked_sub` - // rather than `h_max - 2 - i`: `layout`'s fields are only consistent with - // `h_max` when the layout was DERIVED from the same heights, and this - // function is on the verifier's path, where an overflow panic is not a - // rejection. An inconsistent layout simply injects nothing and fails at - // the terminal. - if let Some(height) = (h_max - 1).checked_sub(i + 1) { - inject(&mut v, &betas[i + 1], bucket_at_height, height); - } - } - - // `v` is now the query's value in the terminal codeword and `index` its - // position there. `.get` fails closed on an out-of-range index. - openings_ok & terminal_codeword.get(index).is_some_and(|t| &v == t) -} - -/// `running += β² · bucket_h` for the height the running codeword has just -/// reached. A no-op when no table sits at that height, and when the height is -/// below the terminal (`bucket_at_height` is indexed by height, so a fold that -/// runs past index 0 has nothing to read). -fn inject( - value: &mut FieldElement, - beta: &FieldElement, - bucket_at_height: &[Option>], - height: usize, -) { - if let Some(Some(contribution)) = bucket_at_height.get(height) { - *value = &*value + &(beta.square() * contribution); - } -} - -/// Authenticate a committed FRI layer's row pair against its root. `index` is the -/// query's position in that layer; the leaf is the pair at `index >> 1`, ordered -/// by `index`'s low bit — the same convention the unbatched -/// `verify_fri_layer_openings` uses, and the same one `query_phase` opens with. -fn verify_layer_opening( - root: &Commitment, - auth_path: &[Commitment], - evaluation: &FieldElement, - evaluation_sym: &FieldElement, - index: usize, -) -> bool -where - E: IsField + 'static, - H: StarkHash, - FieldElement: AsBytes + Sync + Send, -{ - let leaf = if index % 2 == 1 { - vec![evaluation_sym.clone(), evaluation.clone()] - } else { - vec![evaluation.clone(), evaluation_sym.clone()] - }; - verify_merkle_path::>(auth_path, root, index >> 1, &leaf) -} - -/// Replay the batched round-4 transcript sequence and return the challenges, -/// or `None` when the proof's shape contradicts the epoch's. -/// -/// A thin alias for [`derive_batched_fri_challenges`], re-exported here so the -/// verifier reaches the sequence through the same module the prover's -/// [`commit_batched_fri`] lives in — the two are one protocol, and splitting them -/// across modules is how they drift. -#[allow(clippy::too_many_arguments)] -pub fn replay_batched_fri( - transcript: &mut T, - heights: &[usize], - widths: &[usize], - layer_roots: &[Commitment], - final_poly_coeffs: &[FieldElement], - standalone_coeffs: &[Option<&[FieldElement]>], - blowup_log: u32, - final_poly_log_degree: u32, - grinding_factor: u8, - nonce: Option, - num_queries: usize, -) -> Option> -where - E: IsField, - T: IsTranscript, -{ - derive_batched_fri_challenges( - transcript, - heights, - widths, - layer_roots, - final_poly_coeffs, - standalone_coeffs, - blowup_log, - final_poly_log_degree, - grinding_factor, - nonce, - num_queries, - ) -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use crate::config::DefaultStarkHash; - use crate::fri::batched::{HeightCombiner, combine_by_height}; - use crate::fri::terminal::terminal_codeword_from_coeffs; - use crypto::fiat_shamir::default_transcript::DefaultTranscript; - use math::fft::bit_reversing::{in_place_bit_reverse_permute, reverse_index}; - use math::field::goldilocks::GoldilocksField; - use math::polynomial::Polynomial; - - pub(crate) type F = GoldilocksField; - pub(crate) type FE = FieldElement; - pub(crate) type Transcript = DefaultTranscript; - - pub(crate) const BLOWUP_LOG: u32 = 1; - pub(crate) const FINAL_POLY_LOG_DEGREE: u32 = 1; - pub(crate) const COSET_OFFSET: u64 = 3; - - /// One synthetic table: a genuinely low-degree codeword at its own height. - pub(crate) struct FakeTable { - pub height: usize, - pub width: usize, - pub codeword: Vec, - } - - /// A codeword of height `h` that IS a Reed-Solomon word of rate `2^-BLOWUP_LOG` - /// on the coset the batched FRI will read it at. - /// - /// The coset matters and is the one thing easy to get wrong here: folding - /// squares the offset, so the layer a height-`h` bucket is injected into lives - /// on `offset^(2^(h_max-h))·⟨ω⟩`, not on `offset·⟨ω⟩`. A word built on the - /// wrong coset is still low degree — the map is a rescaling of the argument — - /// so it would pass a degree check while making the terminal reconstruction - /// disagree, which is exactly the failure the honest-path test has to be able - /// to see. - pub(crate) fn low_degree_codeword(h: usize, h_max: usize, seed: u64) -> Vec { - let num_coeffs = 1usize << (h as u32 - BLOWUP_LOG); - let coeffs: Vec = (0..num_coeffs) - .map(|i| FE::from(seed.wrapping_mul(97).wrapping_add(i as u64 * 31 + 1))) - .collect(); - let offset = FE::from(COSET_OFFSET).pow(1u64 << (h_max - h)); - let mut natural = Polynomial::evaluate_offset_fft::( - &Polynomial::new(&coeffs), - 1usize << BLOWUP_LOG, - Some(num_coeffs), - &offset, - ) - .expect("coset evaluation"); - in_place_bit_reverse_permute(&mut natural); - natural - } - - /// Four tables over three heights, the shape the batched path has to handle: - /// several tables sharing the tallest height (so the base group batches), one - /// at an intermediate height (so an injection lands on a committed layer) and - /// one at the terminal height (so the FINAL fold's injection is exercised — - /// the case #768's loop missed). - pub(crate) fn fixture() -> Vec { - let h_max = 5; - vec![ - FakeTable { - height: 5, - width: 3, - codeword: low_degree_codeword(5, h_max, 11), - }, - FakeTable { - height: 4, - width: 2, - codeword: low_degree_codeword(4, h_max, 23), - }, - FakeTable { - height: 5, - width: 7, - codeword: low_degree_codeword(5, h_max, 41), - }, - FakeTable { - height: 2, - width: 1, - codeword: low_degree_codeword(2, h_max, 59), - }, - ] - } - - pub(crate) fn heights_of(tables: &[FakeTable]) -> Vec { - tables.iter().map(|t| t.height).collect() - } - - pub(crate) fn widths_of(tables: &[FakeTable]) -> Vec { - tables.iter().map(|t| t.width).collect() - } - - /// The per-table standalone slices a replay call takes, off a commit. - pub(crate) fn standalone_refs( - commit: &BatchedFriCommit, - ) -> Vec> { - commit - .standalone_coeffs - .iter() - .map(|c| c.as_deref()) - .collect() - } - - /// Run the prover's batched round 4 over `tables`, streaming the codewords - /// into the combiner one at a time — the shape a real prover uses. - pub(crate) fn commit_fixture( - tables: &[FakeTable], - transcript: &mut Transcript, - grinding_factor: u8, - num_queries: usize, - ) -> BatchedFriCommit { - let heights = heights_of(tables); - let widths = widths_of(tables); - commit_batched_fri::( - transcript, - &heights, - &widths, - |alpha, plan| { - // Only the batched class is mixed in, and in the plan's order — - // absorption order is what defines the alpha powers, so a caller - // that absorbed the standalone tables too would shift every - // power and agree with no verifier. The standalone tables hand - // back their terminal polynomials instead, exactly as the real - // prover does. - let mut combiner = HeightCombiner::new(*alpha); - for &t in &plan.batched { - combiner.absorb(&tables[t].codeword, tables[t].height); - } - let standalone = tables - .iter() - .enumerate() - .map(|(t, table)| { - plan.standalone.contains(&t).then(|| { - crate::fri::terminal::coeffs_from_terminal_codeword::( - &table.codeword, - &FE::from(COSET_OFFSET), - table.height as u32 - BLOWUP_LOG, - ) - }) - }) - .collect(); - (combiner.finish(), standalone) - }, - &FE::from(COSET_OFFSET), - BLOWUP_LOG, - FINAL_POLY_LOG_DEGREE, - grinding_factor, - num_queries, - ) - } - - /// υ⁻¹ for query `iota`: the inverse of the tallest coset's element at - /// FRI-order position `2·iota`, matching the unbatched verifier's - /// `query_challenge_to_evaluation_point`. - pub(crate) fn evaluation_point_inv(iota: usize, h_max: usize) -> FE { - let n = 1usize << h_max; - let omega = F::get_primitive_root_of_unity(h_max as u64).expect("root of unity"); - let point = FE::from(COSET_OFFSET) * omega.pow(reverse_index(iota * 2, n as u64)); - point.inv().expect("query point is never zero") - } - - /// What the verifier must reconstruct from authenticated openings: the α-mixed - /// value of every height group at this query's position. Here it is read - /// straight off the combined buckets, which is the oracle — `combine_by_height` - /// has its own tests, and the point of this one is the fold recursion. - pub(crate) fn query_inputs( - tables: &[FakeTable], - alpha: &FE, - iota: usize, - ) -> ((FE, FE), Vec>) { - let plan = FriInstancePlan::new(&heights_of(tables), BLOWUP_LOG, FINAL_POLY_LOG_DEGREE) - .expect("the fixture's shape partitions"); - let h_max = plan.h_max; - let inputs: Vec<(Vec, usize)> = plan - .batched - .iter() - .map(|&t| (tables[t].codeword.clone(), tables[t].height)) - .collect(); - let combined = combine_by_height(&inputs, alpha); - - let tallest = combined[h_max].as_ref().expect("tallest bucket exists"); - let p0 = (tallest[iota * 2], tallest[iota * 2 + 1]); - - let buckets = (0..h_max) - .map(|h| { - combined - .get(h) - .and_then(|slot| slot.as_ref()) - .map(|codeword| codeword[injection_position(iota, h_max, h)]) - }) - .collect(); - (p0, buckets) - } - - /// Verify one query end to end against the committed layers. - #[allow(clippy::too_many_arguments)] - pub(crate) fn verify_one_query( - commit: &BatchedFriCommit, - betas: &[FE], - h_max: usize, - iota: usize, - decommitment: &FriDecommitment, - p0: (&FE, &FE), - buckets: &[Option], - layer_roots: &[Commitment], - final_poly_coeffs: &[FE], - ) -> bool { - let terminal_offset = FE::from(COSET_OFFSET).pow(1u64 << commit.layout.total_folds); - let terminal = terminal_codeword_from_coeffs::( - final_poly_coeffs, - &terminal_offset, - commit.layout.terminal_len, - ); - verify_batched_fri_query::( - layer_roots, - betas, - &commit.layout, - h_max, - iota, - decommitment, - &evaluation_point_inv(iota, h_max), - p0, - buckets, - &terminal, - ) - } - - /// The prover's inline sequence and the verifier's replay are ONE protocol; - /// this is what pins them together. Every challenge, not only the iotas — - /// α gates the height combination and the βs gate every fold, so an - /// agreement that held only at the query indices would still be a broken - /// proof system. - #[test] - fn prover_commit_matches_verifier_derivation() { - let tables = fixture(); - let mut prover_transcript = Transcript::new(b"batched_round4"); - let mut verifier_transcript = prover_transcript.clone(); - - let commit = commit_fixture(&tables, &mut prover_transcript, 4, 6); - - let replay = replay_batched_fri::( - &mut verifier_transcript, - &heights_of(&tables), - &widths_of(&tables), - &commit.layer_roots, - &commit.final_poly_coeffs, - &standalone_refs(&commit), - BLOWUP_LOG, - FINAL_POLY_LOG_DEGREE, - 4, - commit.nonce, - 6, - ) - .expect("an honest shape must derive"); - - assert_eq!(replay.alpha, commit.alpha, "α must agree"); - assert_eq!(replay.layout, commit.layout, "the fold layout must agree"); - assert_eq!(replay.iotas, commit.iotas, "the query indices must agree"); - assert_eq!( - replay.betas.len(), - commit.layout.num_committed + 1, - "one β per committed layer plus the final fold" - ); - assert!( - crate::grinding::is_valid_nonce::>( - &replay.grinding_seed, - commit.nonce.expect("grinding was requested"), - 4 - ), - "the replayed grinding seed must accept the prover's nonce" - ); - assert_eq!( - prover_transcript.state(), - verifier_transcript.state(), - "both sides must end in the same transcript state" - ); - } - - /// The honest path, and it is not vacuous: the fixture spans three heights, - /// so this exercises the base group, an injection into a committed layer and - /// an injection at the final fold. If the injection convention or the - /// position derivation were wrong, the terminal check would fail. - #[test] - fn honest_batched_queries_verify() { - let tables = fixture(); - let h_max = 5; - let mut transcript = Transcript::new(b"batched_round4"); - let commit = commit_fixture(&tables, &mut transcript, 0, 8); - - let decommitments = - crate::fri::query_phase::(&commit.layers, &commit.iotas); - - let mut verifier_transcript = Transcript::new(b"batched_round4"); - let replay = replay_batched_fri::( - &mut verifier_transcript, - &heights_of(&tables), - &widths_of(&tables), - &commit.layer_roots, - &commit.final_poly_coeffs, - &standalone_refs(&commit), - BLOWUP_LOG, - FINAL_POLY_LOG_DEGREE, - 0, - None, - 8, - ) - .expect("an honest shape must derive"); - - assert!(commit.layout.num_committed >= 1, "the fixture must fold"); - for (query, &iota) in commit.iotas.iter().enumerate() { - let (p0, buckets) = query_inputs(&tables, &replay.alpha, iota); - assert!( - verify_one_query( - &commit, - &replay.betas, - h_max, - iota, - &decommitments[query], - (&p0.0, &p0.1), - &buckets, - &commit.layer_roots, - &commit.final_poly_coeffs, - ), - "honest query {query} (iota {iota}) must verify" - ); - } - } - - /// The MMCS row pair a query opens at height `h` and the FRI position the - /// injection reads must be the SAME two rows. That coincidence is what lets - /// one opening serve both the authentication and the FRI join, and it is a - /// property of the two index derivations, so it is worth pinning exhaustively - /// rather than sampling. - #[test] - fn injection_position_lands_inside_the_mmcs_row_pair() { - let h_max = 6; - for iota in 0..(1usize << (h_max - 1)) { - for h in 1..h_max { - let position = injection_position(iota, h_max, h); - let mmcs_leaf = iota >> (h_max - h); - assert_eq!( - position >> 1, - mmcs_leaf, - "height {h}, iota {iota}: the injection position must sit in the opened leaf" - ); - assert!( - position < (1usize << h), - "height {h}, iota {iota}: position must stay inside the codeword" - ); - } - } - } - - /// `reduce_iota_to_round` is the documented remedy for the one case where a - /// round's tallest matrix is below the FRI's. Pin both that it is the shift - /// the MMCS wants and that it refuses the impossible direction rather than - /// shifting by a negative amount. - #[test] - fn reduce_iota_to_round_matches_the_mmcs_index_space() { - let h_max_fri = 6; - for iota in 0..(1usize << (h_max_fri - 1)) { - for h_max_round in 1..=h_max_fri { - let reduced = - reduce_iota_to_round(iota, h_max_fri, h_max_round).expect("round is shorter"); - assert!( - reduced < (1usize << (h_max_round - 1)), - "the reduced index must land in the round's own leaf range" - ); - } - } - assert!( - reduce_iota_to_round(0, 4, 5).is_none(), - "a round taller than the FRI is not a shape any honest epoch has" - ); - } - - /// A width the epoch did not commit to moves α, and therefore every fold and - /// every query index. This is the shape binding doing its job one level up - /// from the leaf: the leaf header binds a mis-parse, this binds a mis-shaped - /// epoch. - #[test] - fn a_tampered_shape_moves_the_derived_challenges() { - let tables = fixture(); - let mut prover_transcript = Transcript::new(b"batched_round4"); - let commit = commit_fixture(&tables, &mut prover_transcript, 0, 4); - - let mut widths = widths_of(&tables); - widths[1] += 1; - let mut verifier_transcript = Transcript::new(b"batched_round4"); - let replay = replay_batched_fri::( - &mut verifier_transcript, - &heights_of(&tables), - &widths, - &commit.layer_roots, - &commit.final_poly_coeffs, - &standalone_refs(&commit), - BLOWUP_LOG, - FINAL_POLY_LOG_DEGREE, - 0, - None, - 4, - ) - .expect("the shape is still structurally consistent"); - - assert_ne!( - replay.alpha, commit.alpha, - "a width the prover did not commit to must move α" - ); - assert_ne!( - replay.iotas, commit.iotas, - "a width the prover did not commit to must move the query indices" - ); - } -} diff --git a/crypto/stark/src/batched/shape.rs b/crypto/stark/src/batched/shape.rs index 2f53da0f3..ab0249eb8 100644 --- a/crypto/stark/src/batched/shape.rs +++ b/crypto/stark/src/batched/shape.rs @@ -1,21 +1,20 @@ -//! The epoch's committed shape — which table contributes a matrix to which -//! batched round, at what height and width. +//! A round's committed shape — which table contributes a matrix to it, at what +//! height and width. //! //! Every number here is derived from the AIR set and the per-table trace -//! lengths, never read out of a proof. That is what lets the verifier rebuild -//! the shape it must pass to [`crate::fri::mmcs::MixedMmcs::verify_batch`] and -//! to [`crate::fri::batched::absorb_shape_histogram`] instead of trusting the -//! prover's word for it (`fri/mmcs.rs`, "Width binding"). +//! lengths, never read out of a proof. That is what lets a verifier rebuild the +//! shape it must pass to [`crate::fri::mmcs::MixedMmcs::verify_batch`] instead +//! of trusting the prover's word for it (`fri/mmcs.rs`, "Width binding"). //! -//! # Why one type and not four lists +//! # Why one type and not a list per round //! -//! Four rounds are batched (preprocessed, main, aux, composition parts) and each -//! has a DIFFERENT participation list: only preprocessed tables contribute a -//! preprocessed matrix, only tables with a RAP contribute an aux matrix. The -//! index a matrix has inside its round is therefore NOT its table index, and the -//! two are easy to confuse — a confusion that shows up as an opening -//! authenticated at the wrong leaf rather than as a compile error. [`RoundShape`] -//! keeps the mapping in one place so both sides read it from the same code. +//! Each round (preprocessed, main, aux, composition parts) has a DIFFERENT +//! participation list: only preprocessed tables contribute a preprocessed +//! matrix, only tables with a RAP contribute an aux matrix. The index a matrix +//! has inside its round is therefore NOT its table index, and the two are easy +//! to confuse — a confusion that shows up as an opening authenticated at the +//! wrong leaf rather than as a compile error. [`RoundShape`] keeps the mapping +//! in one place so every reader takes it from the same code. use crate::config::Commitment; use crate::traits::AIR; @@ -25,30 +24,28 @@ use crate::traits::AIR; /// /// # Why the widths travel with the root /// -/// Under the per-table scheme a group's width is implied by its own root plus -/// its AIR. Under one batched tree the widths decide how each leaf is *parsed*, -/// so a comparison of roots alone is only equivalent to the per-table -/// comparisons it replaces if the parse is pinned too (MMCS-PLAN §3.1 item 3, -/// §3.3's closing warning). They are carried here rather than derived at the +/// Under a per-slot scheme a group's width is implied by its own root plus its +/// AIR. Under one tree over several matrices the widths decide how each leaf is +/// *parsed*, so a comparison of roots alone is only equivalent to the per-slot +/// comparisons it replaces if the parse is pinned too. They are carried here +/// rather than derived at the /// comparison site so that a caller holding entry A but an AIR set built for /// entry B is rejected as a width disagreement rather than as an unexplained /// root mismatch. /// -/// # The two sides dispose of `None` differently, on purpose +/// # Absence means different things to a producer and a checker /// -/// Both [`crate::batched::prover::multi_prove_batched`] and -/// [`crate::batched::verifier::multi_verify_batched`] take this as an `Option`, -/// and they do NOT mean the same thing by the absence: +/// Held as an `Option`, and the two sides do NOT mean the same thing by the +/// absence: /// -/// - **Prover — permissive.** `None` is how the root is generated in the first +/// - **Producer — permissive.** `None` is how the root is generated in the first /// place (registry regeneration has nothing to compare against yet). Supplying -/// it buys a fail-fast: a stale preprocessed constant is caught at prove time -/// rather than by every future verifier. -/// - **Verifier — fails closed.** `None` is accepted only for an epoch whose AIR -/// set has no preprocessed table at all. An epoch that HAS a preprocessed -/// round and no pinned root is rejected, because the only root left to check -/// against would be the proof's own — which the prover chose along with the -/// matrices it commits. +/// it buys a fail-fast: a stale preprocessed constant is caught at build time +/// rather than by every future checker. +/// - **Checker — fails closed.** `None` is accepted only for an AIR set with no +/// preprocessed table at all. A set that HAS a preprocessed round and no +/// pinned root is rejected, because the only root left to check against would +/// be the one whoever built the matrices chose. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PinnedPrep<'a> { pub root: &'a Commitment, @@ -353,17 +350,13 @@ impl EpochShape { self.heights.iter().copied().max().unwrap_or(0) } - /// The widths the round-4 shape histogram binds: one per table, in table - /// order, summing every matrix that table contributes across all four rounds. + /// One width per table, in table order, summing every matrix that table + /// contributes across all four rounds. /// - /// Summing rather than listing per round is deliberate. The histogram's job - /// is to make two epochs with different shapes produce different challenges, - /// and `absorb_shape_histogram` takes one `(height, width)` pair per entry. - /// A table's total committed width moves whenever ANY of its four matrices - /// changes width, so the sum separates exactly the epochs the four separate - /// lists would — while staying one entry per table, which is what keeps the - /// prover's and the verifier's histograms the same length without either - /// having to agree on a round ordering. + /// Summing rather than listing per round is deliberate: a table's total + /// committed width moves whenever ANY of its four matrices changes width, so + /// the sum separates exactly the shapes four separate lists would, while + /// staying one entry per table and needing no agreed round ordering. pub fn total_widths(&self) -> Vec { let mut widths = vec![0usize; self.heights.len()]; for round in [&self.prep, &self.main, &self.aux, &self.parts] { diff --git a/crypto/stark/src/batched/verifier.rs b/crypto/stark/src/batched/verifier.rs deleted file mode 100644 index 3c4425098..000000000 --- a/crypto/stark/src/batched/verifier.rs +++ /dev/null @@ -1,1079 +0,0 @@ -//! The batched epoch verifier. -//! -//! [`multi_verify_batched`] is the counterpart of -//! `crate::verifier::IsStarkVerifier::multi_verify` for the batched path, and it -//! is a COMPLETE verification: transcript replay, opening authentication against -//! all four mixed-height MMCS roots, the constraint identity at every table's -//! `z`, the epoch's LogUp bus balance, and the DEEP/FRI join across both -//! instance classes. It is assembled from four pieces, each independently -//! testable and each returning a plain `bool`/`Option` — nothing on this path -//! panics, because every input is prover-supplied. -//! -//! | piece | what it decides | -//! |---|---| -//! | [`replay_epoch_transcript`] | every challenge, and every structural fact the transcript binds | -//! | [`verify_epoch_commitments`] | every preprocessed table's opening authenticates against `air.precomputed_commitment()` (the per-table critical check), and the batched rounds' openings are the rows the roots bind at the derived indices | -//! | [`verify_epoch_constraints`] | the claimed composition polynomial, and the bus balance | -//! | [`verify_epoch_fri`] | those rows fold to the terminal polynomial the proof sent | -//! -//! ⚠ Calling a piece on its own is not a verification. `verify_epoch_commitments` -//! in particular shows only that a proof opened the rows its own roots bind, -//! which an adversary controlling the trace can always arrange. The names are -//! `verify_epoch_*` rather than `verify_*` for that reason; the one function -//! that decides validity is [`multi_verify_batched`]. -//! -//! # Shared with the per-table verifier, not reimplemented -//! -//! Three checks are the same mathematics in both paths, and all three are -//! reached through `crate::verifier`'s own functions rather than copied: -//! `step_2_verify_claimed_composition_polynomial`, -//! `compute_query_invariant_deep_terms` and -//! `reconstruct_deep_composition_poly_evaluation_pair`. The first two took an -//! rkyv `StarkProofView` (#845's zero-copy layer) and now take plain data — a -//! batched epoch proof is not a per-table `StarkProof` and has no such view. -//! That refactor is deliberate: a second constraint evaluator written for the -//! batched path is the one thing that would let the two paths disagree about -//! what a valid trace is (PA-PLAN §1.4). -//! -//! # The one protocol, pinned -//! -//! [`replay_epoch_transcript`] walks exactly the sequence -//! `crate::batched::prover::multi_prove_batched` walks, and -//! `replay_matches_the_provers_ending_state` pins the two on the ENDING -//! TRANSCRIPT STATE. No per-challenge comparison substitutes for it: a -//! divergence anywhere — a root absorbed out of order, a challenge one side -//! samples and the other does not, an OOD block walked differently — lands -//! there, whereas comparing individual challenges only catches it if you -//! compared the right one. - -use math::field::element::FieldElement; -use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; -use math::traits::AsBytes; - -use crate::batched::proof::BatchedMultiProof; -use crate::batched::round4::reduce_iota_to_round; -use crate::batched::shape::{EpochFriParams, EpochShape, RoundShape}; -use crate::config::{Commitment, GrindingDigest, StarkHash}; -use crate::fri::batched::{BatchedFriChallenges, absorb_shape_histogram}; -use crate::fri::mmcs::{MixedMmcs, MixedOpening}; -use crate::lookup::LOGUP_NUM_CHALLENGES; -use crate::traits::AIR; - -use crypto::fiat_shamir::is_transcript::IsStarkTranscript; - -/// Every challenge a batched epoch derives, in the order the transcript -/// produces them. -#[derive(Debug, Clone)] -pub struct EpochChallenges { - /// The shared LogUp challenges. Empty when no table has a RAP. - pub lookup: Vec>, - /// One constraint-batching challenge per table, in table order. - pub betas: Vec>, - /// One out-of-domain point per table, in table order. - pub zs: Vec>, - /// One DEEP-batching challenge per table, in table order. - pub deep_gammas: Vec>, - /// The batched FRI instance's challenges, including the query indices and - /// the instance-class partition. - pub fri: BatchedFriChallenges, -} - -/// Replay a batched epoch's transcript and recover every challenge. -/// -/// Returns `None` on any structural disagreement between the proof and the -/// shape the AIR set implies. Every input here is prover-supplied, so every -/// disagreement is a rejection; this function does not panic. -pub fn replay_epoch_transcript( - airs: &[&dyn AIR], - proof: &BatchedMultiProof, - transcript: &mut T, -) -> Option<(EpochShape, EpochFriParams, EpochChallenges)> -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + 'static, - FieldExtension: IsField + Send + Sync + 'static, - T: IsStarkTranscript, -{ - replay_epoch_transcript_carved(airs, proof, transcript, None) -} - -/// As [`replay_epoch_transcript`], for an epoch with a carved main matrix -/// ([`crate::batched::shape::CarvedMain`]). -/// -/// `carved_main` is VERIFIER-OWNED configuration, like the AIR set — never -/// read from the proof. The carved root itself IS proof-carried: it is -/// absorbed from `proof.carved_main_root` after the preprocessed roots and -/// before `main_root`, so every challenge is drawn after it. A proof whose -/// carve state disagrees with the configuration is rejected. -pub fn replay_epoch_transcript_carved( - airs: &[&dyn AIR], - proof: &BatchedMultiProof, - transcript: &mut T, - carved_main: Option, -) -> Option<(EpochShape, EpochFriParams, EpochChallenges)> -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + 'static, - FieldExtension: IsField + Send + Sync + 'static, - T: IsStarkTranscript, -{ - if airs.len() != proof.tables.len() || airs.is_empty() { - return None; - } - let trace_lengths: Vec = proof.tables.iter().map(|t| t.trace_length).collect(); - let (shape, params) = EpochShape::derive_carved(airs, &trace_lengths, carved_main).ok()?; - - // Recommendation S: the shape is bound before the first root, so every - // challenge below — not only round 4's — is drawn after the epoch has - // committed to what it is. - absorb_shape_histogram::(transcript, &shape.heights, &shape.total_widths()); - - // ★ Preprocessed roots are absorbed FROM THE AIR SET, never from the - // proof — per table, in table order, exactly as the per-table path's - // Phase A does. A prover that committed different preprocessed content - // walked a different transcript and diverges from here on. - for air in airs { - if air.is_preprocessed() { - transcript.append_bytes(&air.precomputed_commitment()); - } - } - - // The carved root, PROOF-CARRIED, in its pinned slot: after every - // preprocessed root, before `main_root`. Presence must match the - // verifier-owned carve configuration exactly. - match (&shape.carved_main, proof.carved_main_root.as_ref()) { - (Some(_), Some(root)) => transcript.append_bytes(root), - (None, None) => {} - _ => return None, - } - - transcript.append_bytes(&proof.main_root); - - let needs_lookup = airs.iter().any(|air| air.has_aux_trace()); - let lookup: Vec> = if needs_lookup { - (0..LOGUP_NUM_CHALLENGES) - .map(|_| transcript.sample_field_element()) - .collect() - } else { - Vec::new() - }; - - if shape.aux.is_empty() != proof.aux_root.is_none() { - return None; - } - if let Some(root) = proof.aux_root.as_ref() { - transcript.append_bytes(root); - } - - // Which tables carry a bus contribution is a property of the AIR set, not - // of the proof. Absorbing whatever the proof happened to send would let a - // prover move the whole transcript by adding or omitting one. - for (air, table) in airs.iter().zip(proof.tables.iter()) { - match (air.has_aux_trace(), table.bus_public_inputs.as_ref()) { - (true, Some(bpi)) => transcript.append_field_element(&bpi.table_contribution), - (false, None) => {} - _ => return None, - } - } - - let betas: Vec> = (0..airs.len()) - .map(|_| transcript.sample_field_element()) - .collect(); - - transcript.append_bytes(&proof.parts_root); - - let coset_offset = FieldElement::::from(params.coset_offset); - let mut zs = Vec::with_capacity(airs.len()); - for (index, table) in proof.tables.iter().enumerate() { - let lde_length = table.trace_length.checked_shl(params.blowup_log)?; - // `sample_z_ood_with_domain_params` is the routine the prover reaches - // through `sample_z_ood`, so the two agree by naming one function - // rather than by two call sites coinciding. - let z = transcript.sample_z_ood_with_domain_params( - table.trace_length, - lde_length, - &coset_offset, - ); - - let air = airs.get(index)?; - // Shape-check the two OOD blocks before absorbing them: they are - // proof-supplied, and the prover's absorption walked blocks the AIR's - // layout defines. A block of the wrong width would otherwise absorb a - // different number of field elements and desynchronise the transcript - // rather than being rejected. - if !ood_blocks_well_formed(*air, table) { - return None; - } - for block in [ - &table.trace_ood_evaluations, - &table.trace_ood_next_evaluations, - ] { - for col in block.columns().iter() { - for elem in col.iter() { - transcript.append_field_element(elem); - } - } - } - for element in table.composition_poly_parts_ood_evaluation.iter() { - transcript.append_field_element(element); - } - zs.push(z); - } - - let deep_gammas: Vec> = (0..airs.len()) - .map(|_| transcript.sample_field_element()) - .collect(); - - let standalone_coeffs: Vec]>> = proof - .tables - .iter() - .map(|t| t.standalone_final_poly_coeffs.as_deref()) - .collect(); - let fri = crate::fri::batched::derive_batched_fri_challenges::( - transcript, - &shape.heights, - &shape.total_widths(), - &proof.fri_layer_roots, - &proof.fri_final_poly_coeffs, - &standalone_coeffs, - params.blowup_log, - params.final_poly_log_degree, - params.grinding_factor, - proof.nonce, - params.num_queries, - )?; - - Some(( - shape, - params, - EpochChallenges { - lookup, - betas, - zs, - deep_gammas, - fri, - }, - )) -} - -/// The two OOD blocks must have the shape the AIR's layout defines. -/// -/// This is `crate::verifier`'s `ood_blocks_well_formed`, restated against the -/// batched proof's owned tables rather than an rkyv view. It is not cosmetic: -/// the blocks are absorbed element by element, so a block of the wrong width -/// would desynchronise the transcript instead of being rejected, and the -/// verifier would go on to derive challenges from a sequence the prover never -/// walked. -fn ood_blocks_well_formed( - air: &dyn AIR, - table: &crate::batched::proof::BatchedTableData, -) -> bool -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + 'static, - FieldExtension: IsField + Send + Sync + 'static, -{ - let step_size = air.step_size(); - let num_eval_points = air.context().transition_offsets.len() * step_size; - let expected_next_width = air.trace_ood_next_row_columns().len(); - let expected_next_height = if expected_next_width == 0 { - 0 - } else { - num_eval_points.saturating_sub(step_size) - }; - let current = &table.trace_ood_evaluations; - let next = &table.trace_ood_next_evaluations; - - current.width == air.trace_layout().0 + air.num_auxiliary_rap_columns() - && current.height == step_size - && next.width == expected_next_width - && next.height == expected_next_height -} - -/// Authenticate every query's openings against every batched round's root, and -/// check the epoch-level structural facts the transcript binds. -/// -/// ⛔ See the module header: this is NOT a complete verification. It is the -/// commitment half. -pub fn verify_epoch_commitments( - airs: &[&dyn AIR], - proof: &BatchedMultiProof, - shape: &EpochShape, - params: &EpochFriParams, - challenges: &EpochChallenges, -) -> bool -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + 'static, - FieldExtension: IsField + Send + Sync + 'static, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, - H: StarkHash, -{ - // The query count is not implied by anything the transcript already - // checked: a prover that sent fewer openings would simply be checked less. - if proof.queries.len() != params.num_queries || challenges.fri.iotas.len() != params.num_queries - { - return false; - } - - if params.grinding_factor > 0 { - let Some(nonce) = proof.nonce else { - return false; - }; - if !crate::grinding::is_valid_nonce::>( - &challenges.fri.grinding_seed, - nonce, - params.grinding_factor, - ) { - return false; - } - } - - // The instance-class partition is DERIVED, never sent, so the proof's - // terminal polynomials must be present for exactly the standalone tables - // and of exactly the length that class's degree bound implies. - for (table, data) in proof.tables.iter().enumerate() { - let standalone = challenges.fri.plan.standalone.contains(&table); - match (&data.standalone_final_poly_coeffs, standalone) { - (Some(coeffs), true) => { - let Some(&height) = shape.heights.get(table) else { - return false; - }; - let Some(log_degree) = (height as u32).checked_sub(params.blowup_log) else { - return false; - }; - if coeffs.len() != 1usize << log_degree { - return false; - } - } - (None, false) => {} - _ => return false, - } - } - - let h_max = shape.h_max(); - for (query, iota) in challenges.fri.iotas.iter().copied().enumerate() { - let opening = &proof.queries[query]; - if !round_authenticates::( - &proof.main_root, - &opening.main, - &shape.main, - iota, - h_max, - ) { - return false; - } - if !round_authenticates::( - &proof.parts_root, - &opening.parts, - &shape.parts, - iota, - h_max, - ) { - return false; - } - // ★ Per-table preprocessed authentication — the per-table path's - // critical soundness check, verbatim: each opening authenticates - // against `air.precomputed_commitment()`, a root the VERIFIER owns. - // Width and count are bound by the AIR set, not the proof. - if opening.prep.len() != shape.prep.tables.len() { - return false; - } - for (k, &t) in shape.prep.tables.iter().enumerate() { - let Some(air) = airs.get(t) else { - return false; - }; - let Some(&height) = shape.heights.get(t) else { - return false; - }; - let Some(leaf) = reduce_iota_to_round(iota, h_max, height) else { - return false; - }; - let o = &opening.prep[k]; - let width = air.num_precomputed_columns(); - if o.evaluations.len() != width || o.evaluations_sym.len() != width { - return false; - } - let leaf_hash = as crypto::merkle_tree::traits::IsStreamingLeafBackend>::hash_data_from_slices( - &o.evaluations, - &o.evaluations_sym, - ); - if !crypto::merkle_tree::proof::verify_merkle_path_from_leaf_hash::>( - &o.proof.merkle_path, - &air.precomputed_commitment(), - leaf, - leaf_hash, - ) { - return false; - } - } - // ★ The carved table's standalone main opening: authenticated against - // the PROOF-CARRIED root (`carved_main_root`) at the reduced index, - // exactly the mechanics of a preprocessed opening with the root's - // provenance moved from the AIR set to the proof — the transcript slot - // (before every challenge) is what binds it. Present iff the epoch is - // carved; a stray or missing opening is a rejection. - match ( - &shape.carved_main, - proof.carved_main_root.as_ref(), - opening.carved_main.as_ref(), - ) { - (Some(c), Some(root), Some(o)) => { - let Some(&height) = shape.heights.get(c.table) else { - return false; - }; - let Some(leaf) = reduce_iota_to_round(iota, h_max, height) else { - return false; - }; - if o.evaluations.len() != c.width || o.evaluations_sym.len() != c.width { - return false; - } - let leaf_hash = - as crypto::merkle_tree::traits::IsStreamingLeafBackend< - Field, - >>::hash_data_from_slices( - &o.evaluations, &o.evaluations_sym - ); - if !crypto::merkle_tree::proof::verify_merkle_path_from_leaf_hash::>( - &o.proof.merkle_path, - root, - leaf, - leaf_hash, - ) { - return false; - } - } - (None, None, None) => {} - _ => return false, - } - - match (proof.aux_root.as_ref(), opening.aux.as_ref()) { - (Some(root), Some(o)) => { - if !round_authenticates::(root, o, &shape.aux, iota, h_max) { - return false; - } - } - (None, None) => {} - _ => return false, - } - } - - true -} - -/// Authenticate one round at one query, reducing the shared FRI index into the -/// round's own index space first. -/// -/// The reduction is the whole reason this is a named function rather than four -/// inline calls: the preprocessed and auxiliary rounds can have an `h_max` -/// below the FRI's, and passing the un-reduced index is not a loud error — -/// prover and verifier share the routine, so a wrong convention is -/// self-consistent (`fri/mmcs.rs`, "Index convention"). -fn round_authenticates( - root: &Commitment, - opening: &MixedOpening, - round: &RoundShape, - iota_fri: usize, - h_max_fri: usize, -) -> bool -where - C: IsField + 'static, - H: StarkHash, - FieldElement: AsBytes + Sync + Send, -{ - let Some(h_max_round) = round.h_max() else { - return false; - }; - let Some(iota) = reduce_iota_to_round(iota_fri, h_max_fri, h_max_round) else { - return false; - }; - MixedMmcs::::verify_batch(root, iota, opening, &round.heights(), &round.widths()) -} - -// =========================================================================== -// The DEEP / FRI join — M-5's core -// =========================================================================== - -/// Verify the batched FRI instance and the terminal-only instances, at every -/// query. -/// -/// This is the check that gives the authenticated openings their meaning. Up to -/// here a proof has shown that the rows it opened are the rows its roots bind; -/// this shows that those rows evaluate to a codeword the FRI folds to a -/// low-degree polynomial — that the committed trace really does satisfy the -/// DEEP relation at `z`. -/// -/// # The two index spaces, again -/// -/// Both instance classes are opened at the SAME query indices and read them -/// differently ([`crate::fri::batched::FriInstancePlan`]): the batched class -/// uses `iota` directly because it is an index in the tallest domain, a -/// standalone table at height `h` uses `iota >> (h_max - h)`. A table's OWN -/// row pair also lives at its reduced leaf, which is why the evaluation point -/// each table's DEEP quotient is reconstructed at is derived from the reduced -/// index and not from `iota`. -/// -/// # Mixing -/// -/// [`crate::fri::batched::HeightCombiner`] scales the `i`-th absorbed codeword -/// by `alpha^i`, counting in absorption order and NOT per height, and the -/// prover absorbs in `plan.batched` order. So the power a table's DEEP value -/// carries here is its position in `plan.batched` — not its table index, and -/// not its position within its height group. Getting that wrong produces a -/// verifier that rejects every honest proof, which is the benign direction, but -/// it is worth stating because the three orders coincide on a same-height -/// epoch. -/// -/// Returns `false` on every malformed input; it never panics. -pub fn verify_epoch_fri( - airs: &[&dyn AIR], - proof: &BatchedMultiProof, - shape: &EpochShape, - params: &EpochFriParams, - challenges: &EpochChallenges, -) -> bool -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + 'static, - FieldExtension: IsField + Send + Sync + 'static, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, - Field::BaseType: math::field::element::NativeArchived, - FieldExtension::BaseType: math::field::element::NativeArchived, - PI: rkyv::Archive + Clone, - ::Archived: rkyv::Deserialize, - H: StarkHash, - V: crate::verifier::IsStarkVerifier + ?Sized, -{ - let h_max = shape.h_max(); - let layout = &challenges.fri.layout; - let coset_offset = FieldElement::::from(params.coset_offset); - - // Structural checks before anything is reconstructed. The terminal helper - // panics on a coefficient count that does not divide the codeword length, - // so the length check is not optional — it is what keeps this path - // rejection-only. Same reasoning as `step_3_verify_fri`. - if proof.fri_layer_roots.len() != layout.num_committed - || proof.fri_final_poly_coeffs.len() != (1usize << layout.effective_k) - { - return false; - } - for query in proof.queries.iter() { - if query.fri.layers_auth_paths.len() != layout.num_committed - || query.fri.layers_evaluations_sym.len() != layout.num_committed - { - return false; - } - } - - let terminal_offset = coset_offset.pow(1u64 << layout.total_folds); - let terminal_codeword = - crate::fri::terminal::terminal_codeword_from_coeffs::( - &proof.fri_final_poly_coeffs, - &terminal_offset, - layout.terminal_len, - ); - - // Per table: the DEEP value pair at every query, in this table's own - // (reduced) index space. - let mut deep_pairs: Vec, FieldElement)>> = - Vec::with_capacity(airs.len()); - for (table, air) in airs.iter().enumerate() { - match table_deep_pairs::( - table, *air, proof, shape, params, challenges, - ) { - Some(pairs) => deep_pairs.push(pairs), - None => return false, - } - } - - for (query, iota) in challenges.fri.iotas.iter().copied().enumerate() { - let mut p0 = ( - FieldElement::::zero(), - FieldElement::::zero(), - ); - let mut buckets: Vec>> = vec![None; h_max]; - let mut power = FieldElement::::one(); - - for &table in challenges.fri.plan.batched.iter() { - let (Some(&height), Some(pairs)) = (shape.heights.get(table), deep_pairs.get(table)) - else { - return false; - }; - let Some((evaluation, evaluation_sym)) = pairs.get(query) else { - return false; - }; - if height == h_max { - p0.0 = &p0.0 + &(&power * evaluation); - p0.1 = &p0.1 + &(&power * evaluation_sym); - } else { - let chosen = crate::batched::round4::injected_value_at_query( - iota, - h_max, - height, - evaluation, - evaluation_sym, - ); - let scaled = &power * chosen; - buckets[height] = Some(match buckets[height].take() { - Some(acc) => acc + scaled, - None => scaled, - }); - } - power = &power * &challenges.fri.alpha; - } - - // υ⁻¹ in the TALLEST domain — the batched instance's layer 0. - let lde_length = 1usize << h_max; - let Some(lde_root) = Field::get_primitive_root_of_unity(h_max as u64).ok() else { - return false; - }; - let point = &coset_offset - * lde_root.pow(math::fft::bit_reversing::reverse_index( - iota * 2, - lde_length as u64, - )); - let Ok(point_inv) = point.inv() else { - return false; - }; - - if !crate::batched::round4::verify_batched_fri_query::( - &proof.fri_layer_roots, - &challenges.fri.betas, - layout, - h_max, - iota, - &proof.queries[query].fri, - &point_inv, - (&p0.0, &p0.1), - &buckets, - &terminal_codeword, - ) { - return false; - } - - // The other class. A table whose own FRI commits no layer has a - // terminal codeword that IS its deep-composition codeword, so the check - // is that the value its opening produced is the value the sent - // polynomial encodes at the reduced position. - for &table in challenges.fri.plan.standalone.iter() { - let (Some(&height), Some(pairs), Some(data)) = ( - shape.heights.get(table), - deep_pairs.get(table), - proof.tables.get(table), - ) else { - return false; - }; - let (Some((evaluation, evaluation_sym)), Some(coeffs)) = - (pairs.get(query), data.standalone_final_poly_coeffs.as_ref()) - else { - return false; - }; - let codeword_len = 1usize << height; - if coeffs.is_empty() - || !coeffs.len().is_power_of_two() - || coeffs.len() > codeword_len - || !codeword_len.is_multiple_of(coeffs.len()) - { - return false; - } - let standalone_terminal = crate::fri::terminal::terminal_codeword_from_coeffs::< - Field, - FieldExtension, - >(coeffs, &coset_offset, codeword_len); - if !crate::batched::round4::verify_standalone_fri_query( - iota, - h_max, - height, - (evaluation, evaluation_sym), - &standalone_terminal, - ) { - return false; - } - } - } - - true -} - -/// One table's DEEP composition value pair at every query, reconstructed from -/// the authenticated openings. -/// -/// The base columns are handed over as two slices in COMMIT order — the -/// preprocessed round's row first, then the main round's — because that is the -/// order the prover concatenated them in and the order the OOD grid and the -/// trace-term coefficients are indexed by. A non-preprocessed table passes an -/// empty first slice, which is exactly what the per-table path does. -fn table_deep_pairs( - table: usize, - air: &dyn AIR, - proof: &BatchedMultiProof, - shape: &EpochShape, - params: &EpochFriParams, - challenges: &EpochChallenges, -) -> Option, FieldElement)>> -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + 'static, - FieldExtension: IsField + Send + Sync + 'static, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, - Field::BaseType: math::field::element::NativeArchived, - FieldExtension::BaseType: math::field::element::NativeArchived, - PI: rkyv::Archive + Clone, - ::Archived: rkyv::Deserialize, - H: StarkHash, - V: crate::verifier::IsStarkVerifier + ?Sized, -{ - let data = proof.tables.get(table)?; - let &height = shape.heights.get(table)?; - let h_max = shape.h_max(); - let z = challenges.zs.get(table)?; - let gamma = challenges.deep_gammas.get(table)?; - - let domain = crate::domain::new_verifier_domain(air, data.trace_length); - let step_size = air.step_size(); - let ood_layout = crate::ood::OodLayout::new( - air.context().trace_columns, - air.context().transition_offsets.len() * step_size, - step_size, - air.trace_ood_next_row_columns(), - ); - let ood_full = ood_layout.reconstruct_full( - data.trace_ood_evaluations.row_major_data(), - data.trace_ood_evaluations.width, - data.trace_ood_next_evaluations.row_major_data(), - ); - - // The DEEP coefficients, derived exactly as the prover derives them: the - // first `num_surviving` powers of gamma are the trace terms, the rest the - // composition parts. Splitting them the other way round would be a verifier - // that rejects every honest proof. - let num_terms_trace = ood_layout.num_surviving(); - let num_parts = data.composition_poly_parts_ood_evaluation.len(); - let mut powers: Vec> = - core::iter::successors(Some(FieldElement::one()), |x| Some(x * gamma)) - .take(num_parts + num_terms_trace) - .collect(); - if powers.len() < num_terms_trace { - return None; - } - let trace_term_powers: Vec<_> = powers.drain(..num_terms_trace).collect(); - let trace_term_coeffs = ood_layout.build_trace_term_coeffs(&trace_term_powers); - let gammas = powers; - - let table_challenges = crate::verifier::Challenges { - z: z.clone(), - boundary_coeffs: Vec::new(), - transition_coeffs: Vec::new(), - trace_term_coeffs, - gammas, - zetas: Vec::new(), - iotas: Vec::new(), - rap_challenges: challenges.lookup.clone(), - grinding_seed: [0u8; 32], - }; - - let terms = V::query_invariant_deep_terms_from_parts( - &table_challenges, - &data.composition_poly_parts_ood_evaluation, - &ood_full, - ood_layout.next_row_cols(), - step_size, - )?; - let primitive_root = Field::get_primitive_root_of_unity(domain.root_order as u64).ok()?; - - let prep_matrix = shape.prep.tables.iter().position(|&t| t == table); - // A carved table has no main-round matrix: its main row pair comes from the - // standalone carved opening instead (authenticated against the - // proof-carried root by `verify_epoch_commitments`). - let is_carved = shape.carved_main.map(|c| c.table) == Some(table); - let main_matrix = if is_carved { - None - } else { - Some(shape.main.tables.iter().position(|&t| t == table)?) - }; - let aux_matrix = shape.aux.tables.iter().position(|&t| t == table); - let parts_matrix = shape.parts.tables.iter().position(|&t| t == table)?; - - let mut pairs = Vec::with_capacity(challenges.fri.iotas.len()); - for (query, iota) in challenges.fri.iotas.iter().copied().enumerate() { - let opening = proof.queries.get(query)?; - // This table's OWN row pair: the reduced leaf, in its own domain. - let leaf = crate::batched::round4::reduce_iota_to_round(iota, h_max, height)?; - let point = domain.lde_coset_element(math::fft::bit_reversing::reverse_index( - leaf * 2, - domain.lde_length as u64, - )); - let point_sym = domain.lde_coset_element(math::fft::bit_reversing::reverse_index( - leaf * 2 + 1, - domain.lde_length as u64, - )); - - let empty_base: &[FieldElement] = &[]; - let empty_ext: &[FieldElement] = &[]; - let (prep, prep_sym) = match prep_matrix { - Some(m) => { - let o = opening.prep.get(m)?; - (o.evaluations.as_slice(), o.evaluations_sym.as_slice()) - } - None => (empty_base, empty_base), - }; - let (main_evals, main_evals_sym) = match main_matrix { - Some(m) => { - let o = opening.main.per_matrix.get(m)?; - (o.evaluations.as_slice(), o.evaluations_sym.as_slice()) - } - None => { - let o = opening.carved_main.as_ref()?; - (o.evaluations.as_slice(), o.evaluations_sym.as_slice()) - } - }; - let (aux, aux_sym) = match aux_matrix { - Some(m) => { - let o = opening.aux.as_ref()?.per_matrix.get(m)?; - (o.evaluations.as_slice(), o.evaluations_sym.as_slice()) - } - None => (empty_ext, empty_ext), - }; - let parts = opening.parts.per_matrix.get(parts_matrix)?; - - let pair = V::reconstruct_deep_composition_poly_evaluation_pair( - &point, - &point_sym, - &primitive_root, - &table_challenges, - &terms, - ood_layout.next_row_cols(), - step_size, - prep, - main_evals, - aux, - &parts.evaluations, - prep_sym, - main_evals_sym, - aux_sym, - &parts.evaluations_sym, - )?; - pairs.push(pair); - } - let _ = params; - Some(pairs) -} - -// =========================================================================== -// The constraint identity, the bus balance, and the whole verification -// =========================================================================== - -/// Check every table's claimed composition polynomial at its own `z`, and the -/// epoch's LogUp bus balance. -/// -/// The constraint check is `crate::verifier`'s -/// `step_2_verify_claimed_composition_polynomial`, unchanged — that function now -/// takes plain data instead of an rkyv view precisely so this caller can reach -/// it. Writing a second constraint evaluator for the batched path is the one -/// thing that would make the two paths able to disagree about what a valid -/// trace is. -/// -/// ⚠ `public_inputs` are read from the proof, exactly as the per-table path -/// reads them from `StarkProof`. Checking that they are the inputs the caller -/// meant is the caller's job in both paths. -pub fn verify_epoch_constraints( - airs: &[&dyn AIR], - proof: &BatchedMultiProof, - challenges: &EpochChallenges, - expected_bus_balance: &FieldElement, -) -> bool -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + 'static, - FieldExtension: IsField + Send + Sync + 'static, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, - Field::BaseType: math::field::element::NativeArchived, - FieldExtension::BaseType: math::field::element::NativeArchived, - PI: rkyv::Archive + Clone, - ::Archived: rkyv::Deserialize, - H: StarkHash, - V: crate::verifier::IsStarkVerifier + ?Sized, -{ - // Bus balance: Σ table_contribution = expected. This is the cross-table - // statement no per-table check can make, and it is why the contributions are - // absorbed before any constraint challenge. - let mut total = FieldElement::::zero(); - for table in proof.tables.iter() { - if let Some(bpi) = table.bus_public_inputs.as_ref() { - total += bpi.table_contribution.clone(); - } - } - if total != *expected_bus_balance { - return false; - } - - for (table, air) in airs.iter().enumerate() { - let (Some(data), Some(z), Some(beta)) = ( - proof.tables.get(table), - challenges.zs.get(table), - challenges.betas.get(table), - ) else { - return false; - }; - - let step_size = air.step_size(); - let ood_layout = crate::ood::OodLayout::new( - air.context().trace_columns, - air.context().transition_offsets.len() * step_size, - step_size, - air.trace_ood_next_row_columns(), - ); - let ood_full = ood_layout.reconstruct_full( - data.trace_ood_evaluations.row_major_data(), - data.trace_ood_evaluations.width, - data.trace_ood_next_evaluations.row_major_data(), - ); - let domain = crate::domain::new_verifier_domain(*air, data.trace_length); - - // The constraint-batching coefficients, split exactly as the prover - // splits them: transitions first, then boundaries. - let bus_public_inputs = data.bus_public_inputs.clone(); - let num_transition_constraints = air.context().num_transition_constraints; - let num_boundary_constraints = air - .boundary_constraints( - &data.public_inputs, - &challenges.lookup, - bus_public_inputs.as_ref(), - data.trace_length, - ) - .constraints - .len(); - let mut coefficients: Vec> = - core::iter::successors(Some(FieldElement::one()), |x| Some(x * beta)) - .take(num_boundary_constraints + num_transition_constraints) - .collect(); - if coefficients.len() < num_transition_constraints { - return false; - } - let transition_coeffs: Vec<_> = coefficients.drain(..num_transition_constraints).collect(); - let boundary_coeffs = coefficients; - - let table_challenges = crate::verifier::Challenges { - z: z.clone(), - boundary_coeffs, - transition_coeffs, - trace_term_coeffs: Vec::new(), - gammas: Vec::new(), - zetas: Vec::new(), - iotas: Vec::new(), - rap_challenges: challenges.lookup.clone(), - grinding_seed: [0u8; 32], - }; - - if !V::step_2_verify_claimed_composition_polynomial( - *air, - data.trace_length, - data.bus_public_inputs - .as_ref() - .map(|b| b.table_contribution.clone()), - data.trace_ood_evaluations.get_row(0), - &data.composition_poly_parts_ood_evaluation, - &data.public_inputs, - &domain, - &table_challenges, - &ood_full, - step_size, - ) { - return false; - } - } - - true -} - -/// Verify a batched epoch proof: replay, commitments, constraint identity, bus -/// balance, DEEP/FRI join. -/// -/// This is the counterpart of `crate::verifier::IsStarkVerifier::multi_verify` -/// for the batched path, and unlike the pieces above it is a COMPLETE -/// verification — every check the per-table path makes has a counterpart here, -/// reached through the same functions where the check is shared. -/// -/// Preprocessed binding needs no caller-side pin: every preprocessed table's -/// root is `air.precomputed_commitment()` — the verifier's own value, absorbed -/// and compared per table exactly as the per-table path does. -/// -/// Returns `false` on every malformed proof; it never panics. -pub fn multi_verify_batched( - airs: &[&dyn AIR], - proof: &BatchedMultiProof, - transcript: &mut T, - expected_bus_balance: &FieldElement, -) -> bool -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + 'static, - FieldExtension: IsField + Send + Sync + 'static, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, - Field::BaseType: math::field::element::NativeArchived, - FieldExtension::BaseType: math::field::element::NativeArchived, - PI: rkyv::Archive + Clone, - ::Archived: rkyv::Deserialize, - H: StarkHash, - V: crate::verifier::IsStarkVerifier + ?Sized, - T: IsStarkTranscript, -{ - multi_verify_batched_carved::( - airs, - proof, - transcript, - expected_bus_balance, - None, - ) -} - -/// As [`multi_verify_batched`], for an epoch with a carved main matrix. -/// -/// `carved_main` is verifier-owned configuration (which table, if any, commits -/// its main matrix standalone) — the same value the prover was called with, -/// supplied by the CALLER, never read from the proof. Everything else about -/// the carve is checked: the proof-carried root's transcript slot -/// ([`replay_epoch_transcript_carved`]), the per-query opening's -/// authentication and width ([`verify_epoch_commitments`]), and the opened -/// row pair's participation in the DEEP/FRI join ([`verify_epoch_fri`]). -pub fn multi_verify_batched_carved( - airs: &[&dyn AIR], - proof: &BatchedMultiProof, - transcript: &mut T, - expected_bus_balance: &FieldElement, - carved_main: Option, -) -> bool -where - Field: IsSubFieldOf + IsFFTField + Send + Sync + 'static, - FieldExtension: IsField + Send + Sync + 'static, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, - Field::BaseType: math::field::element::NativeArchived, - FieldExtension::BaseType: math::field::element::NativeArchived, - PI: rkyv::Archive + Clone, - ::Archived: rkyv::Deserialize, - H: StarkHash, - V: crate::verifier::IsStarkVerifier + ?Sized, - T: IsStarkTranscript, -{ - let Some((shape, params, challenges)) = - replay_epoch_transcript_carved(airs, proof, transcript, carved_main) - else { - return false; - }; - verify_epoch_commitments::( - airs, - proof, - &shape, - ¶ms, - &challenges, - ) && verify_epoch_constraints::( - airs, - proof, - &challenges, - expected_bus_balance, - ) && verify_epoch_fri::( - airs, - proof, - &shape, - ¶ms, - &challenges, - ) -} diff --git a/crypto/stark/src/fri/batched.rs b/crypto/stark/src/fri/batched.rs deleted file mode 100644 index 7e5ef013e..000000000 --- a/crypto/stark/src/fri/batched.rs +++ /dev/null @@ -1,1135 +0,0 @@ -//! Batched FRI: one FRI instance over an epoch's DEEP codewords instead of one -//! per table. -//! -//! Codewords are bucketed by height, mixed within a bucket with powers of a -//! single `alpha`, and then folded from the tallest bucket downward, each -//! shorter bucket being *injected* into the running codeword at the layer whose -//! length matches it. One set of query indices, drawn from the tallest domain, -//! tests the whole chain. -//! -//! # Termination -//! -//! Folding stops at the same terminal the unbatched -//! [`crate::fri::commit_phase_from_evaluations`] stops at — the codeword that -//! encodes a polynomial of degree `< 2^fri_final_poly_log_degree` — and sends -//! that polynomial's coefficients, rather than folding all the way down to a -//! scalar. [`BatchedFriLayout`] derives the fold count through the shared -//! [`FriFoldLayout`], with one batched-only floor: the terminal may not sit -//! above the SHORTEST injected codeword, or that codeword would never reach the -//! running word. So the early stop is `min(blowup_log + k, h_min)`. - -use crypto::fiat_shamir::is_transcript::{IsStarkTranscript, IsTranscript}; -use crypto::merkle_tree::merkle::MerkleTree; -use math::field::element::FieldElement; -use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; -use math::traits::AsBytes; -#[cfg(feature = "parallel")] -use rayon::prelude::*; - -use crate::config::StarkHash; -use crate::fri::fri_commitment::FriLayer; -use crate::fri::fri_functions::{ - compute_coset_twiddles_inv, fold_evaluations_in_place, update_twiddles_in_place, -}; -use crate::fri::terminal::{FriFoldLayout, coeffs_from_terminal_codeword}; - -/// Accumulates DEEP codewords into per-height buckets as they are produced, -/// mixing the `i`-th absorbed codeword with `alpha^i`. -/// -/// The point of absorbing one codeword at a time is memory: a caller that -/// produces a table's quotient, absorbs it and drops it retains only one bucket -/// per distinct height (`O(2^h_max)` in total), where handing -/// [`combine_by_height`] a fully-materialized `Vec` of every table's codeword -/// retains `O(N_tables · 2^h)`. The result is identical either way — absorption -/// order defines the `alpha` powers, so the caller must absorb in the same -/// canonical per-epoch order the verifier assumes. -pub struct HeightCombiner { - buckets: Vec>>>, - alpha: FieldElement, - /// `alpha^i` for the next codeword to be absorbed. - next_power: FieldElement, -} - -impl HeightCombiner { - pub fn new(alpha: FieldElement) -> Self { - Self { - buckets: Vec::new(), - alpha, - next_power: FieldElement::one(), - } - } - - /// Absorb one codeword of length `2^height`, scaled by the next power of - /// `alpha`. - pub fn absorb(&mut self, codeword: &[FieldElement], height: usize) { - let expected_len = 1usize << height; - assert_eq!( - codeword.len(), - expected_len, - "codeword has length {} but height {height} expects {expected_len}", - codeword.len() - ); - - if self.buckets.len() <= height { - self.buckets.resize_with(height + 1, || None); - } - let scaled = &self.next_power; - // Data-parallel under `parallel`: the scale and the scale-accumulate - // are elementwise over up to 2^h_max elements, and this loop has no - // per-table overlap to hide behind — it was serial wall time once per - // absorbed table. Same arithmetic in both arms, identical result. - #[cfg(feature = "parallel")] - match &mut self.buckets[height] { - None => { - self.buckets[height] = Some( - codeword - .par_iter() - .map(|x| scaled * x) - .collect::>>(), - ); - } - Some(acc) => { - acc.par_iter_mut() - .zip(codeword.par_iter()) - .for_each(|(a, x)| { - *a = &*a + &(scaled * x); - }); - } - } - #[cfg(not(feature = "parallel"))] - match &mut self.buckets[height] { - None => { - self.buckets[height] = Some(codeword.iter().map(|x| scaled * x).collect()); - } - Some(acc) => { - for (a, x) in acc.iter_mut().zip(codeword.iter()) { - *a = &*a + &(scaled * x); - } - } - } - self.next_power = &self.next_power * &self.alpha; - } - - /// The per-height buckets. Index `h` is `Some(combined)` when at least one - /// codeword of height `h` was absorbed, `None` otherwise; the `Vec` is - /// `max_absorbed_height + 1` long, or empty if nothing was absorbed. - pub fn finish(self) -> Vec>>> { - self.buckets - } -} - -/// Combine DEEP polynomial codewords by their FRI height for batched FRI. -/// -/// Each element of `inputs` is a pair `(codeword, height)` where `height` is -/// the log₂ of the codeword length (i.e. `codeword.len() == 2^height`). -/// The global index `i` into `inputs` is used to derive the mixing power -/// `alpha^i` (index 0 → alpha^0 = 1, index 1 → alpha^1, …). -/// -/// Returns a `Vec` of length `max_height + 1`. Index `h` contains -/// `Some(combined)` where `combined[j] = Σ_{i : height_i == h} alpha^i * codeword_i[j]`, -/// or `None` when no input has height `h`. -/// -/// This is [`HeightCombiner`] with every codeword already materialized. Prefer -/// the combiner in the prover, where holding all of them at once is the whole -/// memory cost the batching is meant to remove. -pub fn combine_by_height( - inputs: &[(Vec>, usize)], - alpha: &FieldElement, -) -> Vec>>> -where - E: IsField, -{ - let mut combiner = HeightCombiner::new(alpha.clone()); - for (codeword, height) in inputs { - combiner.absorb(codeword, *height); - } - combiner.finish() -} - -/// How far a batched FRI instance folds, and what it sends at the end. -/// -/// Mirrors [`FriFoldLayout`] — same early stop, same terminal codeword, same -/// coefficient count — with the one difference batching forces: the terminal is -/// additionally floored at the SHORTEST injected codeword's height, since a -/// bucket below the terminal would never be folded into the running word. In a -/// real epoch the shortest table is normally well above `blowup_log + k`, so the -/// floor is inert and the layout is exactly the unbatched one. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct BatchedFriLayout { - /// Folds from the tallest bucket down to the terminal codeword. - pub total_folds: u32, - /// Committed (Merkle-rooted) FRI layers. - pub num_committed: usize, - /// Terminal codeword length. - pub terminal_len: usize, - /// `log2` of the terminal polynomial's degree bound — the number of - /// coefficients sent is `2^effective_k`. - pub effective_k: u32, -} - -impl BatchedFriLayout { - /// Derive the layout from the epoch's codeword heights. - /// - /// * `h_max` / `h_min` — the tallest and shortest codeword heights present. - /// * `blowup_log` — log2 of the LDE blowup factor. - /// * `final_poly_log_degree` — the requested `fri_final_poly_log_degree`. - /// - /// Panics if `h_min < blowup_log` (a codeword shorter than the blowup is not - /// a Reed-Solomon word of any positive rate) or if `h_min > h_max`. - pub fn new(h_max: usize, h_min: usize, blowup_log: u32, final_poly_log_degree: u32) -> Self { - assert!(h_min <= h_max, "h_min {h_min} exceeds h_max {h_max}"); - assert!( - h_min as u32 >= blowup_log, - "codeword height {h_min} is below the blowup {blowup_log}" - ); - // Deriving at `h_min` is what applies the floor: `FriFoldLayout` clamps - // the terminal to its `lde_log` argument, so the terminal comes out at - // `min(blowup_log + k, h_min)`. Its terminal_len / effective_k are then - // exactly what the unbatched prover would send for that codeword. - let shortest = FriFoldLayout::new(h_min as u32, blowup_log, final_poly_log_degree); - let terminal_log = shortest.terminal_len.trailing_zeros(); - // The running codeword starts at h_max, not h_min, so the fold count is - // re-derived from where folding actually begins. - let total_folds = h_max as u32 - terminal_log; - Self { - total_folds, - num_committed: total_folds.saturating_sub(1) as usize, - terminal_len: shortest.terminal_len, - effective_k: shortest.effective_k, - } - } -} - -/// Which of an epoch's tables enter the ONE batched FRI instance, and which keep -/// a terminal-only instance of their own. -/// -/// # Why there are two classes -/// -/// A table whose own FRI would commit ZERO layers gains nothing from being -/// batched — there is no layer for the batch to share — while it pays the full -/// cost of being lifted to the tallest domain, which is where the proximity-gaps -/// term's `|D0|²` lives. At the measured epoch that is 13 of 28 legs carrying 92% -/// of the batch's width, so excluding them is a correction rather than a -/// compromise: it recovers ~3.6 bits of soundness AND removes work. -/// -/// A zero-layer table's FRI is degenerate in the useful sense — its terminal -/// codeword IS its deep-composition codeword — so its "own instance" is one -/// terminal polynomial and no layers at all. -/// -/// # ★ The index rule BETWEEN the classes — a hard precondition -/// -/// Both classes are opened at the SAME query indices, because the mixed-height -/// MMCS is unaffected by this split: it still commits every table, and the point -/// of one shared authentication path survives whole. What differs is the index -/// SPACE each class reads them in: -/// -/// ```text -/// batched class: iota, used directly (it is an index in the tallest domain) -/// standalone table: iota >> (h_max - h_t) -/// ``` -/// -/// This is the same reduction [`crate::fri::mmcs`]'s index-convention section -/// documents for a short round, and it fails the same silent way: prover and -/// verifier derive it from the shape, so a wrong shift is self-consistent — -/// honest proofs still verify while the short tables end up checked at positions -/// the FRI join never reaches. `each_instance_class_is_tamper_checked` is the -/// control, and it tampers a table of EACH class, because a control that only -/// touched the batched class would pass under any convention for the other. -/// -/// # Determinism -/// -/// The plan is a pure function of `(heights, blowup_log, final_poly_log_degree)`, -/// all of which the transcript has bound before any challenge that depends on it. -/// Prover and verifier therefore derive the SAME partition without it being sent, -/// which is why the split adds nothing to the wire and nothing to the shape -/// binding. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FriInstancePlan { - /// Table indices whose codewords are mixed into the batched instance, in - /// input order — the order that defines the `alpha` powers. - pub batched: Vec, - /// Table indices that keep a terminal-only instance, in input order. - pub standalone: Vec, - /// Tallest and shortest height WITHIN the batched class — the layout is - /// derived from these, not from the whole epoch. - pub h_max: usize, - pub h_min: usize, -} - -impl FriInstancePlan { - /// Partition an epoch's tables. `None` when `heights` is empty or carries a - /// height that cannot be a codeword length — both are proof-supplied, so both - /// are rejections rather than panics. - /// - /// The TALLEST table is always batched, even if it would classify as - /// standalone on its own. That keeps the batched class non-empty, so the - /// layout is always well defined; an epoch whose tallest table folds nothing - /// degenerates to a single terminal-only instance, which is what it should be. - pub fn new(heights: &[usize], blowup_log: u32, final_poly_log_degree: u32) -> Option { - if heights.is_empty() { - return None; - } - let &h_max_epoch = heights.iter().max()?; - if h_max_epoch == 0 || h_max_epoch >= u32::BITS as usize { - return None; - } - let tallest = heights.iter().position(|h| *h == h_max_epoch)?; - - let mut batched = Vec::with_capacity(heights.len()); - let mut standalone = Vec::new(); - for (t, &h) in heights.iter().enumerate() { - if h < blowup_log as usize { - return None; - } - let folds_a_layer = - FriFoldLayout::new(h as u32, blowup_log, final_poly_log_degree).num_committed > 0; - if folds_a_layer || t == tallest { - batched.push(t); - } else { - standalone.push(t); - } - } - - let h_max = batched.iter().map(|&t| heights[t]).max()?; - let h_min = batched.iter().map(|&t| heights[t]).min()?; - Some(Self { - batched, - standalone, - h_max, - h_min, - }) - } -} - -/// FRI commit phase over the bucketed output of [`combine_by_height`] / -/// [`HeightCombiner::finish`]. -/// -/// `combined[h]` is `Some(codeword)` when there are DEEP contributions at height -/// `h` (codeword length `2^h`), or `None` otherwise. -/// -/// Folding starts from the tallest bucket. After each fold to height `h`, the -/// bucket at `combined[h]` is injected into the running codeword with -/// coefficient `β²` (β being the fold challenge just used), before the layer is -/// committed. Termination follows [`BatchedFriLayout`]: the running codeword is -/// folded to the terminal length and the terminal polynomial's coefficients are -/// appended to the transcript, exactly as -/// [`crate::fri::commit_phase_from_evaluations`] does — not folded down to a -/// single scalar. -/// -/// Layer trees are built with `H::Pair`, the same commitment configuration the -/// unbatched [`crate::fri::commit_phase_from_evaluations`] uses — so a batched -/// prover and the verifier that authenticates its openings through `H::Batched` -/// agree on the hash by naming one configuration, not by two call sites -/// coinciding. -#[allow(clippy::type_complexity)] -pub fn batched_commit_phase( - mut combined: Vec>>>, - transcript: &mut T, - coset_offset: &FieldElement, - blowup_log: u32, - final_poly_log_degree: u32, -) -> (Vec>, Vec>>) -where - F: IsFFTField + IsSubFieldOf + 'static, - E: IsField + 'static + Send + Sync, - T: IsStarkTranscript + Clone, - H: StarkHash, - FieldElement: AsBytes + Sync + Send, - FieldElement: AsBytes + Sync + Send, -{ - let (h_min, h_max) = bucket_height_range(&combined) - .expect("batched_commit_phase: combined must have at least one Some entry"); - - // Take the starting codeword — NOT committed; it plays the role of layer 0. - let mut running = combined[h_max] - .take() - .expect("combined[h_max] is Some by construction"); - - let domain_size = 1usize << h_max; - debug_assert_eq!( - running.len(), - domain_size, - "starting codeword length must equal 2^h_max" - ); - - let layout = BatchedFriLayout::new(h_max, h_min, blowup_log, final_poly_log_degree); - - // Inverse twiddle factors for the initial domain size. - let mut inv_twiddles = compute_coset_twiddles_inv(coset_offset, domain_size); - - let mut fri_layer_list = Vec::with_capacity(layout.num_committed); - - for _ in 0..layout.num_committed { - // <<<< Receive challenge β - let beta = transcript.sample_field_element(); - - // Fold evaluations in-place; running halves in length. - fold_evaluations_in_place(&mut running, &beta, &inv_twiddles); - inject_bucket(&mut running, &mut combined, &beta); - - // Build the row-pair Merkle tree over the current running codeword. - let leaves: Vec<[FieldElement; 2]> = running - .chunks_exact(2) - .map(|chunk| [chunk[0].clone(), chunk[1].clone()]) - .collect(); - let merkle_tree = MerkleTree::>::build(&leaves) - .expect("FRI batched commit: Merkle tree construction must succeed"); - let root = merkle_tree.root; - fri_layer_list.push(FriLayer::new(&running, merkle_tree)); - - // >>>> Send commitment: append root to transcript. - transcript.append_bytes(&root); - - // Update twiddles for the next (halved) level. - update_twiddles_in_place(&mut inv_twiddles); - } - - // One final fold to reach the terminal codeword, unless already there. The - // bucket AT the terminal height is injected here: it is the last one that can - // still enter the running word, which is why the layout floors the terminal - // at the shortest height rather than at `blowup_log + k` alone. - if layout.total_folds > 0 { - let beta = transcript.sample_field_element(); - fold_evaluations_in_place(&mut running, &beta, &inv_twiddles); - inject_bucket(&mut running, &mut combined, &beta); - } - debug_assert_eq!( - running.len(), - layout.terminal_len, - "terminal codeword size mismatch" - ); - debug_assert!( - combined.iter().all(Option::is_none), - "every bucket must have been injected before the terminal" - ); - - // Recover the terminal polynomial's coefficients and send them, mirroring - // `commit_phase_from_evaluations`: the coefficient count follows - // `layout.effective_k` (the actual terminal), and the terminal coset offset - // is `coset_offset^(2^total_folds)`. - let terminal_offset = coset_offset.pow(1u64 << layout.total_folds); - let final_poly_coeffs = - coeffs_from_terminal_codeword::(&running, &terminal_offset, layout.effective_k); - for c in &final_poly_coeffs { - transcript.append_field_element(c); - } - - (final_poly_coeffs, fri_layer_list) -} - -/// The `(h_min, h_max)` of the occupied buckets, or `None` when none are. -fn bucket_height_range( - combined: &[Option>>], -) -> Option<(usize, usize)> { - let mut occupied = combined - .iter() - .enumerate() - .filter_map(|(h, slot)| slot.as_ref().map(|_| h)); - let first = occupied.next()?; - Some((first, occupied.next_back().unwrap_or(first))) -} - -/// `running += β² · combined[h]` for the running codeword's current height `h`, -/// consuming that bucket. A no-op when the bucket is empty. -fn inject_bucket( - running: &mut [FieldElement], - combined: &mut [Option>>], - beta: &FieldElement, -) { - let h = running.len().trailing_zeros() as usize; - let Some(bucket) = combined.get_mut(h).and_then(Option::take) else { - return; - }; - debug_assert_eq!( - bucket.len(), - running.len(), - "a bucket at height {h} must match the running codeword's length" - ); - let beta_sq = beta.square(); - for (val, contribution) in running.iter_mut().zip(bucket.iter()) { - *val = &*val + &(&beta_sq * contribution); - } -} - -/// Canonical, order-deterministic absorption of an epoch's table-SHAPE histogram -/// into the transcript. Single source of truth for the structural binding. -/// -/// The multiset of `lde_log_height`s across an epoch's tables fully determines -/// the fold order and injection points of the batched FRI (arity is uniformly -/// 2), so binding the heights binds the whole injection schedule. The widths are -/// bound alongside them because they are what makes the mixed-height MMCS leaf -/// parse unambiguous (see [`crate::fri::mmcs`]'s width-binding section) — the -/// verifier derives widths from the AIR set rather than the proof, so this is -/// defence in depth rather than the primary binding, and it costs one field per -/// table. -/// -/// Encoding (fixed-width, length-prefixed, order-preserving): -/// `u64::to_le_bytes(len)` followed by `u64::to_le_bytes(h)`, `u64::to_le_bytes(w)` -/// for each `(h, w)` pair, in the exact order given. Caller (prover and verifier -/// alike) must pass the shape in the same canonical per-epoch table order — this -/// function does not sort or deduplicate. -/// -/// Panics if `heights` and `widths` differ in length; both sides construct them -/// from the same table list. -pub fn absorb_shape_histogram(transcript: &mut T, heights: &[usize], widths: &[usize]) -where - E: IsField, - T: IsTranscript, -{ - assert_eq!( - heights.len(), - widths.len(), - "the shape histogram needs one width per height" - ); - transcript.append_bytes(&(heights.len() as u64).to_le_bytes()); - for (h, w) in heights.iter().zip(widths.iter()) { - transcript.append_bytes(&(*h as u64).to_le_bytes()); - transcript.append_bytes(&(*w as u64).to_le_bytes()); - } -} - -/// Challenges derived from replaying the shared batched round-4 transcript -/// sequence. See [`derive_batched_fri_challenges`]. -#[derive(Debug, Clone)] -pub struct BatchedFriChallenges { - /// Sampled once after the shape histogram (and, at the call site, after all - /// per-table OOD evaluations have been absorbed). - pub alpha: FieldElement, - /// One per committed layer, plus one for the final fold when there is one: - /// `betas.len() == layout.num_committed + (layout.total_folds > 0) as usize`. - pub betas: Vec>, - /// The layout the betas and the terminal were derived under. - pub layout: BatchedFriLayout, - /// Transcript state right before the grinding nonce bytes are appended. - /// All-zero when `grinding_factor == 0` or `nonce` is `None`. - pub grinding_seed: [u8; 32], - /// One `sample_u64(2^(h_max - 1))` draw per query — a row-PAIR index in the - /// tallest domain. A round whose own `h_max` is lower must reduce these; see - /// [`crate::fri::mmcs`]'s index-convention section. - pub iotas: Vec, - /// Which tables the batched instance carries and which keep a terminal-only - /// instance of their own. Derived from the shape, never sent. - pub plan: FriInstancePlan, -} - -/// Replays the shared batched round-4 transcript sequence (shape histogram, -/// alpha, per-layer beta/root, final beta, terminal coefficients, grinding, query -/// iotas) and returns the derived challenges. The one routine the prover and the -/// verifier both call, so they provably derive identical challenges. -/// -/// `standalone_coeffs[t]` is table `t`'s terminal-only polynomial, `Some` -/// exactly for the standalone class — presence is checked against the derived -/// plan and every coefficient is ABSORBED, right after `α` and before the -/// first `ζ`. That absorb is load-bearing: the standalone check evaluates the -/// sent polynomial at the query indices drawn BELOW, so a polynomial that -/// were not bound here could be chosen after the indices are known, and each -/// query's proximity test would bind nothing until the queries saturate the -/// table's domain. The unbatched path absorbs its terminal before sampling -/// queries for the same reason; this keeps the batched path's binding equal. -/// -/// Returns `None` when the proof's layer-root count disagrees with the layout the -/// epoch's shape implies, when the terminal coefficient count is wrong, or when -/// a standalone polynomial is present for the wrong class — all prover-supplied, -/// all rejections, not panics. -#[allow(clippy::too_many_arguments)] -pub fn derive_batched_fri_challenges( - transcript: &mut T, - heights: &[usize], - widths: &[usize], - layer_roots: &[[u8; 32]], - final_poly_coeffs: &[FieldElement], - standalone_coeffs: &[Option<&[FieldElement]>], - blowup_log: u32, - final_poly_log_degree: u32, - grinding_factor: u8, - nonce: Option, - num_queries: usize, -) -> Option> -where - E: IsField, - T: IsTranscript, -{ - // The partition is derived, not sent: it is a pure function of the shape the - // histogram below binds, so both sides reach the same one. `None` on any - // height that cannot be a codeword length — heights come from proof-supplied - // trace lengths, so a bogus one is a rejection, never a panic on the - // verifier's path. - let plan = FriInstancePlan::new(heights, blowup_log, final_poly_log_degree)?; - let (h_max, h_min) = (plan.h_max, plan.h_min); - let layout = BatchedFriLayout::new(h_max, h_min, blowup_log, final_poly_log_degree); - if layer_roots.len() != layout.num_committed - || final_poly_coeffs.len() != 1usize << layout.effective_k - || standalone_coeffs.len() != heights.len() - { - return None; - } - - absorb_shape_histogram(transcript, heights, widths); - - let alpha = transcript.sample_field_element(); - - // The standalone class's terminal polynomials, bound before any query can - // depend on them — per table ascending, each coefficient in order. The - // length pin (`2^(h_t − blowup_log)`, exactly) stays with - // `verify_epoch_commitments`. - for (table, coeffs) in standalone_coeffs.iter().enumerate() { - if coeffs.is_some() != plan.standalone.contains(&table) { - return None; - } - if let Some(coeffs) = coeffs { - for c in coeffs.iter() { - transcript.append_field_element(c); - } - } - } - - let mut betas = Vec::with_capacity(layout.num_committed + 1); - for root in layer_roots { - let beta = transcript.sample_field_element(); - transcript.append_bytes(root); - betas.push(beta); - } - - if layout.total_folds > 0 { - betas.push(transcript.sample_field_element()); - } - for c in final_poly_coeffs { - transcript.append_field_element(c); - } - - let mut grinding_seed = [0u8; 32]; - if grinding_factor > 0 - && let Some(nonce_value) = nonce - { - grinding_seed = transcript.state(); - transcript.append_bytes(&nonce_value.to_be_bytes()); - } - - let iotas = (0..num_queries) - .map(|_| transcript.sample_u64(1u64 << (h_max - 1)) as usize) - .collect(); - - Some(BatchedFriChallenges { - alpha, - betas, - layout, - grinding_seed, - iotas, - plan, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::DefaultStarkHash; - use crate::fri::commit_phase_from_evaluations; - use crate::fri::fri_functions::{compute_coset_twiddles_inv, fold_evaluations_in_place}; - use crypto::fiat_shamir::default_transcript::DefaultTranscript; - use crypto::fiat_shamir::is_transcript::IsTranscript; - use math::field::element::FieldElement; - use math::field::goldilocks::GoldilocksField; - - type FE = FieldElement; - type Transcript = DefaultTranscript; - - #[test] - fn combine_by_height_two_height3_one_height2() { - // Three codewords: indices 0, 1 have height 3 (length 8); - // index 2 has height 2 (length 4). - let cw0: Vec = (1u64..=8).map(FE::from).collect(); - let cw1: Vec = (10u64..=17).map(FE::from).collect(); - let cw2: Vec = (100u64..=103).map(FE::from).collect(); - - let alpha = FE::from(7u64); - - let inputs: Vec<(Vec, usize)> = - vec![(cw0.clone(), 3), (cw1.clone(), 3), (cw2.clone(), 2)]; - - let out = combine_by_height(&inputs, &alpha); - - // Output vec length = max_height + 1 = 4 (indices 0..=3 only). - assert_eq!(out.len(), 4, "output length should be max_height+1 = 4"); - - // Heights 0 and 1 have no inputs. - assert!(out[0].is_none(), "height 0 should be None"); - assert!(out[1].is_none(), "height 1 should be None"); - - // Height 3: combined[j] = alpha^0 * cw0[j] + alpha^1 * cw1[j] - let alpha0 = FE::one(); - let alpha1 = alpha; - let expected3: Vec = cw0 - .iter() - .zip(cw1.iter()) - .map(|(a, b)| &(&alpha0 * a) + &(&alpha1 * b)) - .collect(); - - let got3 = out[3].as_ref().expect("height 3 should be Some"); - assert_eq!( - got3.len(), - 8, - "height-3 combined codeword should have length 8" - ); - assert_eq!(got3, &expected3, "height-3 combined values mismatch"); - - // Height 2: combined[j] = alpha^2 * cw2[j] - let alpha2 = &alpha * α - let expected2: Vec = cw2.iter().map(|x| &alpha2 * x).collect(); - - let got2 = out[2].as_ref().expect("height 2 should be Some"); - assert_eq!( - got2.len(), - 4, - "height-2 combined codeword should have length 4" - ); - assert_eq!(got2, &expected2, "height-2 combined values mismatch"); - } - - /// Absorbing codewords one at a time — the shape a prover uses so it never - /// holds every table's quotient at once — must land on the same buckets as - /// handing them all over materialized. - #[test] - fn streaming_absorption_matches_materialized_combine() { - let inputs: Vec<(Vec, usize)> = vec![ - ((1u64..=16).map(FE::from).collect(), 4), - ((50u64..=57).map(FE::from).collect(), 3), - ((90u64..=105).map(FE::from).collect(), 4), - ((200u64..=203).map(FE::from).collect(), 2), - ((300u64..=307).map(FE::from).collect(), 3), - ]; - let alpha = FE::from(11u64); - - let eager = combine_by_height(&inputs, &alpha); - - let mut combiner = HeightCombiner::new(alpha); - for (codeword, height) in &inputs { - combiner.absorb(codeword, *height); - } - assert_eq!( - combiner.finish(), - eager, - "streaming absorption must equal the materialized combine" - ); - } - - /// After the first fold in `batched_commit_phase`, the committed layer[0] - /// evaluation must equal `fold(combined[4], β₀) + β₀² · combined[3]`. - #[test] - fn batched_commit_phase_first_layer_matches_manual_fold_and_inject() { - // Build synthetic codewords for h=4 (len 16) and h=3 (len 8). - let data_h4: Vec = (1u64..=16).map(FE::from).collect(); - let data_h3: Vec = (101u64..=108).map(FE::from).collect(); - - // combined = [None, None, None, Some(data_h3), Some(data_h4)] - let combined: Vec>> = vec![ - None, - None, - None, - Some(data_h3.clone()), - Some(data_h4.clone()), - ]; - - let coset_offset = FE::from(3u64); - let (blowup_log, k) = (1u32, 1u32); - - // Create transcript; clone before mutating so we can replay independently. - let mut transcript = Transcript::new(b"batched_fri_test"); - let mut transcript_check = transcript.clone(); - - let (_coeffs, layers) = batched_commit_phase::<_, _, _, DefaultStarkHash>( - combined, - &mut transcript, - &coset_offset, - blowup_log, - k, - ); - - // Terminal at min(blowup_log + k, h_min) = min(2, 3) = 2, so folds run - // 4 -> 2: two folds, one committed layer. - let layout = BatchedFriLayout::new(4, 3, blowup_log, k); - assert_eq!(layout.total_folds, 2); - assert_eq!( - layers.len(), - layout.num_committed, - "committed layers must follow the layout" - ); - - // --- Independent recomputation of layer[0] --- - let beta_0 = transcript_check.sample_field_element(); - - let inv_twiddles_h4 = compute_coset_twiddles_inv::(&coset_offset, 16); - let mut expected = data_h4.clone(); - fold_evaluations_in_place(&mut expected, &beta_0, &inv_twiddles_h4); - // expected now has length 8 (height 3) - - // Inject combined[3]: expected[j] += beta_0² · data_h3[j] - let beta_0_sq = beta_0.square(); - for (j, val) in data_h3.iter().enumerate() { - expected[j] = &expected[j] + &(&beta_0_sq * val); - } - - assert_eq!( - layers[0].evaluation, expected, - "layer[0] evaluation does not match manual fold+inject" - ); - } - - /// ★ M-12: the batched commit phase must terminate where the unbatched one - /// does. With a single bucket the two are the same protocol, so they must - /// agree on the committed-layer count, the terminal coefficients, and the - /// resulting transcript state — pinning that batching did not silently switch - /// to folding all the way to a scalar (which for this input would commit - /// `h_max - 1 = 9` layers instead of 4). - #[test] - fn single_bucket_terminal_matches_the_unbatched_commit_phase() { - let h = 10usize; - let (blowup_log, k) = (1u32, 5u32); - let coset_offset = FE::from(3u64); - let evals: Vec = (0..(1u64 << h)).map(|i| FE::from(i * 7 + 1)).collect(); - let inv_twiddles = compute_coset_twiddles_inv::(&coset_offset, 1 << h); - - let mut t_unbatched = Transcript::new(b"terminal_parity"); - let (unbatched_coeffs, unbatched_layers) = commit_phase_from_evaluations::< - GoldilocksField, - GoldilocksField, - Transcript, - DefaultStarkHash, - >( - evals.clone(), - &mut t_unbatched, - &coset_offset, - 1 << h, - blowup_log, - k, - &inv_twiddles, - ); - - let mut combined: Vec>> = vec![None; h + 1]; - combined[h] = Some(evals); - let mut t_batched = Transcript::new(b"terminal_parity"); - let (batched_coeffs, batched_layers) = batched_commit_phase::<_, _, _, DefaultStarkHash>( - combined, - &mut t_batched, - &coset_offset, - blowup_log, - k, - ); - - // total_folds = 10 - (1 + 5) = 4, so 3 committed layers — not h_max-1 = 9. - assert_eq!(unbatched_layers.len(), 3); - assert_eq!( - batched_layers.len(), - unbatched_layers.len(), - "batched and unbatched must commit the same number of layers" - ); - assert_eq!( - batched_coeffs.len(), - 1usize << k, - "the terminal polynomial must carry 2^k coefficients" - ); - assert_eq!( - batched_coeffs, unbatched_coeffs, - "batched and unbatched must send the same terminal polynomial" - ); - for (b, u) in batched_layers.iter().zip(unbatched_layers.iter()) { - assert_eq!(b.merkle_tree.root, u.merkle_tree.root); - } - assert_eq!( - t_batched.state(), - t_unbatched.state(), - "the two commit phases must leave the transcript in the same state" - ); - } - - /// The batched-only floor: the terminal may not sit above the shortest - /// injected codeword, or that bucket would never enter the running word. - #[test] - fn terminal_is_floored_at_the_shortest_codeword() { - let (blowup_log, k) = (1u32, 5u32); - - // Shortest codeword above blowup_log + k = 6: the floor is inert and the - // layout is the unbatched one for h_max. - let inert = BatchedFriLayout::new(10, 8, blowup_log, k); - assert_eq!(inert.total_folds, 4, "10 -> 6"); - assert_eq!(inert.effective_k, k); - - // Shortest codeword BELOW blowup_log + k: folding must continue down to - // it, and the terminal polynomial shrinks accordingly. - let floored = BatchedFriLayout::new(10, 4, blowup_log, k); - assert_eq!(floored.total_folds, 6, "10 -> 4"); - assert_eq!(floored.effective_k, 3, "terminal_log 4 - blowup_log 1"); - - // And the commit phase really does consume that low bucket. - let coset_offset = FE::from(3u64); - let mut combined: Vec>> = vec![None; 8]; - combined[7] = Some((0..128u64).map(|i| FE::from(i + 1)).collect()); - combined[4] = Some((0..16u64).map(|i| FE::from(i * 3 + 5)).collect()); - let mut transcript = Transcript::new(b"floor_test"); - let (coeffs, layers) = batched_commit_phase::<_, _, _, DefaultStarkHash>( - combined, - &mut transcript, - &coset_offset, - blowup_log, - k, - ); - let layout = BatchedFriLayout::new(7, 4, blowup_log, k); - assert_eq!(layers.len(), layout.num_committed); - assert_eq!(coeffs.len(), 1usize << layout.effective_k); - } - - /// The prover, by hand, runs exactly the round-4 sequence; the shared replay - /// routine must reproduce byte-identical outputs from the same start state. - #[test] - fn batched_round4_prover_inline_matches_verifier_replay() { - let heights: Vec = vec![10, 10, 8, 8, 8, 7]; - let widths: Vec = vec![3, 5, 2, 2, 9, 1]; - let (blowup_log, k) = (1u32, 5u32); - // total_folds = 10 - 6 = 4 -> 3 committed layers, 4 betas. - let layout = BatchedFriLayout::new(10, 7, blowup_log, k); - assert_eq!((layout.num_committed, layout.total_folds), (3, 4)); - - let layer_roots: Vec<[u8; 32]> = (0u8..3).map(|i| [i; 32]).collect(); - let final_poly_coeffs: Vec = (0..(1u64 << layout.effective_k)).map(FE::from).collect(); - // Height 7 folds no layer at these parameters, so table 5 is standalone - // and its terminal polynomial is part of the round-4 sequence. - let standalone_terminal: Vec = (0..(1u64 << (7 - blowup_log))).map(FE::from).collect(); - - let grinding_factor: u8 = 4; - let num_queries = 3; - - let seed_transcript = Transcript::new(b"batched_round4_test"); - let mut transcript_a = seed_transcript.clone(); - let mut transcript_b = seed_transcript.clone(); - - // --- Clone A: prover-inline sequence, by hand --- - absorb_shape_histogram(&mut transcript_a, &heights, &widths); - let alpha_a = transcript_a.sample_field_element(); - for c in &standalone_terminal { - transcript_a.append_field_element(c); - } - - let mut betas_a = Vec::with_capacity(layer_roots.len() + 1); - for root in &layer_roots { - let beta = transcript_a.sample_field_element(); - transcript_a.append_bytes(root); - betas_a.push(beta); - } - betas_a.push(transcript_a.sample_field_element()); - for c in &final_poly_coeffs { - transcript_a.append_field_element(c); - } - assert_eq!( - betas_a.len(), - layout.total_folds as usize, - "one beta per fold, matching batched_commit_phase" - ); - - let grinding_seed_a = transcript_a.state(); - // Test-only: derive a real PoW nonce so the grinding step is exercised - // identically by both sides (the nonce search itself is not under test). - let nonce = crate::grinding::generate_nonce::< - crate::config::GrindingDigest, - >(&grinding_seed_a, grinding_factor) - .expect("a valid grinding nonce exists for this small grinding_factor"); - transcript_a.append_bytes(&nonce.to_be_bytes()); - - let iotas_a: Vec = (0..num_queries) - .map(|_| transcript_a.sample_u64(1u64 << 9) as usize) - .collect(); - - // --- Clone B: shared replay routine --- - let standalone: Vec> = vec![ - None, - None, - None, - None, - None, - Some(standalone_terminal.as_slice()), - ]; - let result = derive_batched_fri_challenges( - &mut transcript_b, - &heights, - &widths, - &layer_roots, - &final_poly_coeffs, - &standalone, - blowup_log, - k, - grinding_factor, - Some(nonce), - num_queries, - ) - .expect("a well-formed layer-root and coefficient count"); - - assert_eq!(result.alpha, alpha_a, "alpha mismatch"); - assert_eq!(result.betas, betas_a, "beta vector mismatch"); - assert_eq!(result.layout, layout, "layout mismatch"); - assert_eq!( - result.grinding_seed, grinding_seed_a, - "grinding seed mismatch" - ); - assert_eq!(result.iotas, iotas_a, "iotas mismatch"); - assert!( - result.iotas.iter().all(|&i| i < 1usize << 9), - "iotas must be row-pair indices in the tallest domain" - ); - } - - /// A layer-root or coefficient count that disagrees with the shape's layout is - /// prover-supplied, so it is a rejection rather than a panic. - #[test] - fn derive_rejects_a_layer_count_that_contradicts_the_shape() { - let heights: Vec = vec![10, 8]; - let widths: Vec = vec![2, 3]; - let (blowup_log, k) = (1u32, 5u32); - let layout = BatchedFriLayout::new(10, 8, blowup_log, k); - let coeffs: Vec = vec![FE::one(); 1usize << layout.effective_k]; - let roots: Vec<[u8; 32]> = vec![[0u8; 32]; layout.num_committed]; - - let no_standalone: Vec> = vec![None; heights.len()]; - let mut ok = Transcript::new(b"reject"); - assert!( - derive_batched_fri_challenges( - &mut ok, - &heights, - &widths, - &roots, - &coeffs, - &no_standalone, - blowup_log, - k, - 0, - None, - 1 - ) - .is_some() - ); - - let mut too_few = Transcript::new(b"reject"); - assert!( - derive_batched_fri_challenges( - &mut too_few, - &heights, - &widths, - &roots[..roots.len() - 1], - &coeffs, - &no_standalone, - blowup_log, - k, - 0, - None, - 1 - ) - .is_none(), - "one fewer layer root than the shape implies must be rejected" - ); - - let mut bad_coeffs = Transcript::new(b"reject"); - assert!( - derive_batched_fri_challenges( - &mut bad_coeffs, - &heights, - &widths, - &roots, - &coeffs[..coeffs.len() - 1], - &no_standalone, - blowup_log, - k, - 0, - None, - 1 - ) - .is_none(), - "a short terminal polynomial must be rejected" - ); - } - - /// `heights` comes from proof-supplied trace lengths, so every out-of-range - /// value is a rejection rather than a shift overflow or a layout assert. - #[test] - fn derive_rejects_out_of_range_heights_without_panicking() { - let widths = vec![2usize, 3]; - let (blowup_log, k) = (1u32, 5u32); - let coeffs: Vec = vec![FE::one(); 1usize << k]; - let roots: Vec<[u8; 32]> = vec![[0u8; 32]; 3]; - - let derive = |heights: &[usize]| { - let no_standalone: Vec> = vec![None; heights.len()]; - derive_batched_fri_challenges( - &mut Transcript::new(b"range"), - heights, - &widths, - &roots, - &coeffs, - &no_standalone, - blowup_log, - k, - 0, - None, - 1, - ) - .is_some() - }; - - assert!(derive(&[10, 8]), "a well-formed shape is accepted"); - assert!(!derive(&[0, 0]), "a zero height must be rejected"); - assert!( - !derive(&[10, 0]), - "a height below the blowup must be rejected" - ); - assert!( - !derive(&[u32::BITS as usize, 8]), - "a height at the shift width must be rejected" - ); - assert!( - !derive(&[usize::MAX, 8]), - "an absurd height must be rejected, not wrapped by the u32 cast" - ); - let empty: [usize; 0] = []; - assert!(!derive(&empty), "an empty epoch must be rejected"); - } - - /// Tampering the shape histogram (without changing anything else) must change - /// the derived batching challenge α — the structural binding that protects the - /// fold/injection schedule. Heights and widths are both bound (M-13a), so a - /// change to either alone must move α. - #[test] - fn absorb_shape_histogram_binds_heights_and_widths_into_alpha() { - let heights: Vec = vec![10, 10, 8, 8, 8, 5]; - let widths: Vec = vec![4, 4, 2, 2, 2, 1]; - - let alpha_of = |h: &[usize], w: &[usize]| { - let mut t = Transcript::new(b"histogram_binding_test"); - absorb_shape_histogram(&mut t, h, w); - t.sample_field_element() - }; - - let base = alpha_of(&heights, &widths); - - let mut other_height = heights.clone(); - other_height[5] = 6; - assert_ne!( - base, - alpha_of(&other_height, &widths), - "different height histograms must yield different alpha" - ); - - let mut other_width = widths.clone(); - other_width[5] = 2; - assert_ne!( - base, - alpha_of(&heights, &other_width), - "different width histograms must yield different alpha" - ); - - // The length prefix plus fixed-width fields make the encoding injective: - // swapping a (height, width) pair between tables also moves alpha. - let swapped_h = vec![10, 10, 8, 8, 5, 8]; - let swapped_w = vec![4, 4, 2, 2, 1, 2]; - assert_ne!( - base, - alpha_of(&swapped_h, &swapped_w), - "table order must be bound, not just the multiset" - ); - } -} diff --git a/crypto/stark/src/fri/mmcs.rs b/crypto/stark/src/fri/mmcs.rs index 8e8437a8d..3f8074037 100644 --- a/crypto/stark/src/fri/mmcs.rs +++ b/crypto/stark/src/fri/mmcs.rs @@ -106,9 +106,7 @@ //! the AIR set rather than read out of the proof. //! //! `heights` and `widths` must ALSO be bound into the Fiat-Shamir transcript by -//! the consumer, before any challenge that depends on the epoch's shape — see -//! [`crate::fri::batched::absorb_shape_histogram`], which is the canonical -//! encoding of that binding. +//! the consumer, before any challenge that depends on the shape. //! //! # Determinism //! diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index ea125cd95..2241a79c0 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -1,4 +1,3 @@ -pub mod batched; pub mod fri_commitment; pub mod fri_decommit; pub(crate) mod fri_functions; diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 06b44ce46..a5610565d 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -2032,13 +2032,10 @@ pub trait IsStarkProver< /// The evaluations of the composition-polynomial parts over the LDE domain, /// and nothing else — no commitment. /// - /// This is the half of round 2 that the batched path shares with the - /// per-table one. Round 2 commits each table's parts to its own Merkle tree; - /// [`crate::batched::prover::multi_prove_batched`] streams every table's - /// parts into one mixed-height MMCS instead. Both need the same parts, and - /// the arm selection (`number_of_parts` 1 / 2 / d>2, the device paths and - /// their fallbacks) is intricate enough that a second copy would drift — so - /// there is one function, and the commitment is what differs. + /// Split out of round 2's commitment step. The arm selection + /// (`number_of_parts` 1 / 2 / d>2, the device paths and their fallbacks) is + /// intricate enough that a second copy of it would drift, so producing the + /// parts and committing them are separate functions. #[allow(clippy::too_many_arguments)] fn compute_composition_parts( air: &dyn AIR, diff --git a/crypto/stark/src/tests/batched_mmcs_soundness_tests.rs b/crypto/stark/src/tests/batched_mmcs_soundness_tests.rs deleted file mode 100644 index e5392c5f4..000000000 --- a/crypto/stark/src/tests/batched_mmcs_soundness_tests.rs +++ /dev/null @@ -1,1645 +0,0 @@ -//! Soundness negatives for the batched-commitment primitives — the mixed-height -//! MMCS ([`crate::fri::mmcs`]) and the batched-FRI transcript -//! ([`crate::fri::batched`]). -//! -//! Each test builds one honest commitment over a small mixed-height epoch, then -//! tampers a single component and asserts rejection. The honest opening is -//! re-asserted in every test, so a false-reject regression cannot make the -//! negatives pass vacuously. -//! -//! Scope grows with the integration. The first section reaches only what the -//! primitives decide; the per-query batched-FRI section below arrived with the -//! round-4 wiring ([`crate::batched::round4`]), which is what made a tampered -//! layer evaluation, a mis-sized decommitment and a wrong injection expressible. -//! The forgeries that still need the full prover/verifier integration — an OOD -//! value, the bus balance, the query count, the grinding nonce — belong with it. - -use crypto::fiat_shamir::default_transcript::DefaultTranscript; -use crypto::fiat_shamir::is_transcript::IsTranscript; -use math::fft::bit_reversing::reverse_index; -use math::field::element::FieldElement; -use math::field::goldilocks::GoldilocksField; - -use crate::batched::round4::BatchedFriCommit; -use crate::batched::round4::tests as round4_tests; -use crate::config::DefaultStarkHash; -use crate::fri::batched::{ - BatchedFriLayout, absorb_shape_histogram, derive_batched_fri_challenges, -}; -use crate::fri::fri_decommit::FriDecommitment; -use crate::fri::mmcs::{LeafSource, MixedMmcs, MixedOpening}; - -type F = GoldilocksField; -type FE = FieldElement; -type Mmcs = MixedMmcs; -type Transcript = DefaultTranscript; - -/// Bit-reversed row-major matrices, in the layout the MMCS commits. -struct Matrices { - /// `(bit-reversed row-major data, log_height, width)`. - mats: Vec<(Vec, usize, usize)>, -} - -impl LeafSource for Matrices { - fn num_matrices(&self) -> usize { - self.mats.len() - } - fn log_height(&self, m: usize) -> usize { - self.mats[m].1 - } - fn width(&self, m: usize) -> usize { - self.mats[m].2 - } - fn append_row(&self, m: usize, bitrev_row: usize, out: &mut Vec) { - let (data, _, width) = &self.mats[m]; - out.extend_from_slice(&data[bitrev_row * width..(bitrev_row + 1) * width]); - } -} - -fn matrix(log_height: usize, width: usize, seed: u64) -> (Vec, usize, usize) { - let num_rows = 1usize << log_height; - let mut data = vec![FE::from(0u64); num_rows * width]; - for (r, chunk) in data.chunks_exact_mut(width).enumerate() { - let br = reverse_index(r, num_rows as u64); - for (c, slot) in chunk.iter_mut().enumerate() { - *slot = FE::from(seed.wrapping_mul(31) + (c as u64) * 1009 + (br as u64) * 7 + 1); - } - } - (data, log_height, width) -} - -/// A four-matrix epoch: two tall (base group), one injected, one injected lower. -/// Heights {5, 5, 4, 2}, widths {3, 3, 2, 4}. Two of the tall matrices share a -/// width so the "swap two openings" forgery below is a pure reordering. -fn epoch() -> (Matrices, Vec, Vec) { - let mats = Matrices { - mats: vec![ - matrix(5, 3, 11), - matrix(5, 3, 22), - matrix(4, 2, 33), - matrix(2, 4, 44), - ], - }; - let heights = vec![5, 5, 4, 2]; - let widths = vec![3, 3, 2, 4]; - (mats, heights, widths) -} - -const IOTA: usize = 9; - -fn honest() -> ([u8; 32], MixedOpening, Vec, Vec) { - let (mats, heights, widths) = epoch(); - let mmcs = Mmcs::commit(&mats); - let opening = mmcs.open_batch(IOTA, &mats); - (mmcs.root(), opening, heights, widths) -} - -/// Sanity anchor: the untampered opening verifies. -#[test] -fn honest_batched_opening_verifies() { - let (root, opening, heights, widths) = honest(); - assert!( - Mmcs::verify_batch(&root, IOTA, &opening, &heights, &widths), - "an honest mixed-height opening must verify" - ); -} - -/// Tampering any matrix's opened row breaks the one shared authentication path — -/// including the SHORT matrices, which are bound through injection rather than -/// through the base leaf. -#[test] -fn rejects_a_tampered_row_in_every_height_group() { - let (root, opening, heights, widths) = honest(); - for m in 0..opening.per_matrix.len() { - let mut tampered = opening.clone(); - tampered.per_matrix[m].evaluations[0] = - &tampered.per_matrix[m].evaluations[0] + &FE::from(1u64); - assert!( - !Mmcs::verify_batch(&root, IOTA, &tampered, &heights, &widths), - "a tampered row of matrix {m} (height {}) must be rejected", - heights[m] - ); - - let mut tampered_sym = opening.clone(); - tampered_sym.per_matrix[m].evaluations_sym[0] = - &tampered_sym.per_matrix[m].evaluations_sym[0] + &FE::from(1u64); - assert!( - !Mmcs::verify_batch(&root, IOTA, &tampered_sym, &heights, &widths), - "a tampered symmetric row of matrix {m} must be rejected" - ); - } -} - -/// Tampering the shared authentication path itself. -#[test] -fn rejects_a_tampered_authentication_path() { - let (root, opening, heights, widths) = honest(); - for level in 0..opening.proof.merkle_path.len() { - let mut tampered = opening.clone(); - tampered.proof.merkle_path[level][0] ^= 1; - assert!( - !Mmcs::verify_batch(&root, IOTA, &tampered, &heights, &widths), - "a tampered sibling at level {level} must be rejected" - ); - } - // Truncating or padding the path is a shape error, not a hash mismatch. - let mut short = opening.clone(); - short.proof.merkle_path.pop(); - assert!(!Mmcs::verify_batch(&root, IOTA, &short, &heights, &widths)); - let mut long = opening.clone(); - long.proof.merkle_path.push([0u8; 32]); - assert!(!Mmcs::verify_batch(&root, IOTA, &long, &heights, &widths)); -} - -/// An honest opening replayed at a different query index must be rejected: the -/// path is position-dependent, so one opening does not authenticate every leaf. -#[test] -fn rejects_an_opening_replayed_at_another_index() { - let (root, opening, heights, widths) = honest(); - let n0 = 1usize << (5 - 1); - for iota in 0..n0 { - let accepted = Mmcs::verify_batch(&root, iota, &opening, &heights, &widths); - assert_eq!( - accepted, - iota == IOTA, - "the opening at {IOTA} must verify at {IOTA} and nowhere else (index {iota})" - ); - } - // And past the tree's leaf range — the index-convention guard. - assert!(!Mmcs::verify_batch(&root, n0, &opening, &heights, &widths)); -} - -/// INPUT ORDER is part of the commitment: swapping two same-height, same-width -/// matrices' openings changes the flat concatenation the group leaf hashes, so -/// the tree no longer reproduces. Without order-dependence a prover could serve -/// one table's rows in another's slot. -#[test] -fn rejects_swapped_openings_within_a_height_group() { - let (root, opening, heights, widths) = honest(); - assert_eq!( - (heights[0], widths[0]), - (heights[1], widths[1]), - "matrices 0 and 1 must share a shape for this to be a pure reordering" - ); - let mut swapped = opening.clone(); - swapped.per_matrix.swap(0, 1); - assert_ne!( - swapped.per_matrix[0].evaluations, opening.per_matrix[0].evaluations, - "the two matrices must carry different data" - ); - assert!( - !Mmcs::verify_batch(&root, IOTA, &swapped, &heights, &widths), - "reordering two same-shape matrices must be rejected" - ); -} - -/// The verifier's `heights` fix the injection schedule. Relabelling a matrix's -/// height — claiming the height-4 matrix is height 3, so it is injected a level -/// later — must not reproduce the root, or a prover could move a table to a -/// layer where its rows are checked against a different query position. -#[test] -fn rejects_a_relabelled_injection_height() { - let (root, opening, heights, widths) = honest(); - let mut relabelled = heights.clone(); - relabelled[2] = 3; - assert!( - !Mmcs::verify_batch(&root, IOTA, &opening, &relabelled, &widths), - "moving a matrix to another injection level must be rejected" - ); - - // Promoting a short matrix into the base group is likewise rejected. - let mut promoted = heights.clone(); - promoted[3] = 5; - assert!(!Mmcs::verify_batch( - &root, IOTA, &opening, &promoted, &widths - )); -} - -/// Widths are verifier-supplied and length-checked, so a width that does not -/// match the opening is rejected before any hashing — the guard that closes the -/// leaf-boundary shift. -#[test] -fn rejects_widths_that_disagree_with_the_opening() { - let (root, opening, heights, widths) = honest(); - for m in 0..widths.len() { - let mut wrong = widths.clone(); - wrong[m] += 1; - assert!( - !Mmcs::verify_batch(&root, IOTA, &opening, &heights, &wrong), - "a width disagreeing with matrix {m}'s opening must be rejected" - ); - } -} - -/// A root committed over a different epoch shape does not authenticate this -/// opening, even where the tree depth coincides. -#[test] -fn rejects_a_root_from_another_epoch_shape() { - let (_, opening, heights, widths) = honest(); - let other = Matrices { - mats: vec![ - matrix(5, 3, 11), - matrix(5, 3, 22), - matrix(4, 2, 33), - // Same height and width, different data. - matrix(2, 4, 99), - ], - }; - let other_root = Mmcs::commit(&other).root(); - assert!( - !Mmcs::verify_batch(&other_root, IOTA, &opening, &heights, &widths), - "an opening must not verify against another epoch's root" - ); -} - -/// The round-4 transcript binds the shape and every committed FRI layer, so -/// tampering a layer root or a terminal coefficient moves the query indices the -/// prover must answer at. This is what stops a prover from choosing its FRI -/// commitments after seeing the queries. -#[test] -fn tampering_the_fri_transcript_moves_the_query_indices() { - let heights = vec![10usize, 10, 8, 7]; - let widths = vec![4usize, 2, 3, 1]; - let (blowup_log, k) = (1u32, 5u32); - let layout = BatchedFriLayout::new(10, 7, blowup_log, k); - let roots: Vec<[u8; 32]> = (0u8..layout.num_committed as u8).map(|i| [i; 32]).collect(); - let coeffs: Vec = (0..(1u64 << layout.effective_k)).map(FE::from).collect(); - - // Height 7 folds no layer at these parameters, so table 3 is standalone - // and its terminal polynomial is transcript-bound alongside the rest. - let standalone: Vec>> = vec![ - None, - None, - None, - Some((0..(1u64 << (7 - blowup_log))).map(FE::from).collect()), - ]; - let derive = |roots: &[[u8; 32]], - coeffs: &[FE], - heights: &[usize], - widths: &[usize], - standalone: &[Option>]| { - let standalone_refs: Vec> = standalone.iter().map(|c| c.as_deref()).collect(); - derive_batched_fri_challenges( - &mut Transcript::new(b"batched_soundness"), - heights, - widths, - roots, - coeffs, - &standalone_refs, - blowup_log, - k, - 0, - None, - 16, - ) - .expect("a well-formed layer-root and coefficient count") - .iotas - }; - - let base = derive(&roots, &coeffs, &heights, &widths, &standalone); - assert!(!base.is_empty()); - - let mut other_root = roots.clone(); - other_root[0][0] ^= 1; - assert_ne!( - base, - derive(&other_root, &coeffs, &heights, &widths, &standalone), - "a tampered FRI layer root must move the query indices" - ); - - let mut other_coeffs = coeffs.clone(); - other_coeffs[0] = &other_coeffs[0] + &FE::from(1u64); - assert_ne!( - base, - derive(&roots, &other_coeffs, &heights, &widths, &standalone), - "a tampered terminal coefficient must move the query indices" - ); - - let mut other_heights = heights.clone(); - other_heights[2] = 9; - assert_ne!( - base, - derive(&roots, &coeffs, &other_heights, &widths, &standalone), - "a tampered height must move the query indices" - ); - - let mut other_widths = widths.clone(); - other_widths[2] = 4; - assert_ne!( - base, - derive(&roots, &coeffs, &heights, &other_widths, &standalone), - "a tampered width must move the query indices" - ); - - // ★ The standalone class's terminal polynomial is transcript-bound too — - // the whole point of the absorb: a polynomial the indices did not depend - // on could be chosen AFTER them, and each query's proximity test against - // it would bind nothing until the queries saturate the table's domain. - let mut other_standalone = standalone.clone(); - if let Some(cs) = other_standalone[3].as_mut() { - cs[0] = &cs[0] + &FE::from(1u64); - } - assert_ne!( - base, - derive(&roots, &coeffs, &heights, &widths, &other_standalone), - "a tampered standalone terminal must move the query indices" - ); -} - -/// The shape histogram's encoding is injective: no two distinct epoch shapes -/// absorb the same bytes. A collision would let a prover present one shape to -/// the transcript and another to the opening parse. -#[test] -fn the_shape_encoding_separates_distinct_epochs() { - let absorbed = |heights: &[usize], widths: &[usize]| { - let mut t = Transcript::new(b"shape"); - absorb_shape_histogram(&mut t, heights, widths); - t.state() - }; - - // The classic ambiguity a length prefix and fixed-width fields must close: - // one table of shape (h, w) against two tables whose fields interleave to the - // same sequence. - let one = absorbed(&[3, 4], &[4, 5]); - let two = absorbed(&[3], &[4]); - let three = absorbed(&[3, 4, 5], &[4, 5, 6]); - assert_ne!(one, two); - assert_ne!(one, three); - assert_ne!(two, three); - - // Swapping height and width within a table is a different epoch. - assert_ne!(absorbed(&[3, 4], &[4, 3]), absorbed(&[4, 3], &[3, 4])); -} - -// --------------------------------------------------------------------------- -// Per-query batched FRI (M-3). These need the round-4 wiring, not only the -// primitives, so they were deferred when the primitives landed. -// --------------------------------------------------------------------------- - -/// One honest batched round 4 plus everything a verifier needs to check a query. -struct Round4Fixture { - tables: Vec, - commit: BatchedFriCommit, - betas: Vec, - decommitments: Vec>, - alpha: FE, - h_max: usize, - plan: crate::fri::batched::FriInstancePlan, -} - -impl Round4Fixture { - fn build() -> Self { - let tables = round4_tests::fixture(); - let mut transcript = round4_tests::Transcript::new(b"batched_soundness_r4"); - let commit = round4_tests::commit_fixture(&tables, &mut transcript, 0, 6); - let decommitments = - crate::fri::query_phase::(&commit.layers, &commit.iotas); - - let mut verifier_transcript = round4_tests::Transcript::new(b"batched_soundness_r4"); - let replay = crate::batched::round4::replay_batched_fri::( - &mut verifier_transcript, - &round4_tests::heights_of(&tables), - &round4_tests::widths_of(&tables), - &commit.layer_roots, - &commit.final_poly_coeffs, - &round4_tests::standalone_refs(&commit), - round4_tests::BLOWUP_LOG, - round4_tests::FINAL_POLY_LOG_DEGREE, - 0, - None, - 6, - ) - .expect("an honest shape must derive"); - - Self { - tables, - betas: replay.betas, - alpha: replay.alpha, - h_max: replay.plan.h_max, - plan: replay.plan, - decommitments, - commit, - } - } - - /// Verify query `q` with every input honest except what `mutate` changes. - fn check_query_with(&self, q: usize, mutate: M) -> bool - where - M: FnOnce(&mut FriDecommitment, &mut (FE, FE), &mut Vec>, &mut Vec), - { - let iota = self.commit.iotas[q]; - let (mut p0, mut buckets) = round4_tests::query_inputs(&self.tables, &self.alpha, iota); - let mut decommitment = self.decommitments[q].clone(); - let mut coeffs = self.commit.final_poly_coeffs.clone(); - mutate(&mut decommitment, &mut p0, &mut buckets, &mut coeffs); - round4_tests::verify_one_query( - &self.commit, - &self.betas, - self.h_max, - iota, - &decommitment, - (&p0.0, &p0.1), - &buckets, - &self.commit.layer_roots, - &coeffs, - ) - } - - fn check_query(&self, q: usize) -> bool { - self.check_query_with(q, |_, _, _, _| {}) - } -} - -/// The honest-path control for every negative below. Also pins that the fixture -/// is not degenerate: it must actually commit layers, or the fold loop the -/// negatives target would never run. -#[test] -fn honest_batched_fri_queries_verify() { - let f = Round4Fixture::build(); - assert!( - f.commit.layout.num_committed >= 1, - "the fixture must commit at least one FRI layer" - ); - assert!( - f.commit.layout.total_folds as usize > f.commit.layout.num_committed, - "the fixture must exercise the final fold" - ); - for q in 0..f.commit.iotas.len() { - assert!(f.check_query(q), "honest query {q} must verify"); - } -} - -/// A per-query FRI layer evaluation is prover-supplied and NOT in the -/// transcript; only the layer's Merkle root binds it. -#[test] -fn a_tampered_fri_layer_evaluation_is_rejected() { - let f = Round4Fixture::build(); - for q in 0..f.commit.iotas.len() { - assert!(f.check_query(q), "honest control for query {q}"); - for layer in 0..f.commit.layout.num_committed { - assert!( - !f.check_query_with(q, |d, _, _, _| { - d.layers_evaluations_sym[layer] = - &d.layers_evaluations_sym[layer] + &FE::from(1u64); - }), - "query {q}: a tampered evaluation at layer {layer} must be rejected" - ); - } - } -} - -/// The authentication path is what carries the layer opening to the root. -#[test] -fn a_tampered_fri_layer_auth_path_is_rejected() { - let f = Round4Fixture::build(); - for layer in 0..f.commit.layout.num_committed { - assert!(f.check_query(0), "honest control"); - assert!( - !f.check_query_with(0, |d, _, _, _| { - d.layers_auth_paths[layer].merkle_path[0][0] ^= 1; - }), - "a tampered sibling at layer {layer} must be rejected" - ); - } -} - -/// The decommitment vectors are not bound by Fiat-Shamir, so their lengths have -/// to be pinned before anything iterates them: a short one would end the fold -/// early and accept without reaching the terminal, a long one would run past it. -#[test] -fn a_mis_sized_fri_decommitment_is_rejected() { - let f = Round4Fixture::build(); - assert!(f.check_query(0), "honest control"); - - assert!( - !f.check_query_with(0, |d, _, _, _| { - d.layers_auth_paths.pop(); - d.layers_evaluations_sym.pop(); - }), - "a truncated decommitment must be rejected" - ); - assert!( - !f.check_query_with(0, |d, _, _, _| { - let path = d.layers_auth_paths[0].clone(); - let evaluation = d.layers_evaluations_sym[0]; - d.layers_auth_paths.push(path); - d.layers_evaluations_sym.push(evaluation); - }), - "a padded decommitment must be rejected" - ); - assert!( - !f.check_query_with(0, |d, _, _, _| { - d.layers_auth_paths.clear(); - d.layers_evaluations_sym.clear(); - }), - "an empty decommitment must be rejected, not accepted vacuously" - ); -} - -/// The terminal polynomial is where FRI's low-degree claim is finally cashed in. -#[test] -fn a_tampered_terminal_coefficient_is_rejected() { - let f = Round4Fixture::build(); - assert!(f.check_query(0), "honest control"); - for i in 0..f.commit.final_poly_coeffs.len() { - assert!( - !f.check_query_with(0, |_, _, _, coeffs| { - coeffs[i] = &coeffs[i] + &FE::from(1u64); - }), - "a tampered terminal coefficient {i} must be rejected" - ); - } -} - -/// The tallest tables enter FRI as layer 0, which is never committed — the only -/// thing binding them is that the fold has to land on the terminal. -#[test] -fn a_tampered_layer_zero_value_is_rejected() { - let f = Round4Fixture::build(); - assert!(f.check_query(0), "honest control"); - assert!( - !f.check_query_with(0, |_, p0, _, _| { p0.0 = &p0.0 + &FE::from(1u64) }), - "a tampered p0 must be rejected" - ); - assert!( - !f.check_query_with(0, |_, p0, _, _| { p0.1 = &p0.1 + &FE::from(1u64) }), - "a tampered p0 symmetric value must be rejected" - ); - assert!( - !f.check_query_with(0, |_, p0, _, _| { core::mem::swap(&mut p0.0, &mut p0.1) }), - "swapping the layer-0 pair must be rejected — the two are not interchangeable" - ); -} - -/// The injected buckets are the whole point of a mixed-height batch: a short -/// table is bound ONLY by the value it contributes at its injection layer. Three -/// ways to get that wrong, all of which leave the tall tables untouched and so -/// would pass a control that only tampered the base group. -#[test] -fn a_wrong_injection_is_rejected() { - let f = Round4Fixture::build(); - // The BATCHED class's short heights — the standalone class is not injected at - // all, and asking for its bucket would be asking about a codeword that is not - // in this instance. - let injected_heights: Vec = f - .plan - .batched - .iter() - .map(|&t| f.tables[t].height) - .filter(|h| *h < f.h_max) - .collect(); - assert!( - !injected_heights.is_empty(), - "the fixture must have at least one injected height" - ); - - for q in 0..f.commit.iotas.len() { - assert!(f.check_query(q), "honest control for query {q}"); - for &h in &injected_heights { - assert!( - !f.check_query_with(q, |_, _, buckets, _| { - let value = buckets[h].take().expect("the height is occupied"); - buckets[h] = Some(&value + &FE::from(1u64)); - }), - "query {q}: a tampered injection at height {h} must be rejected" - ); - assert!( - !f.check_query_with(q, |_, _, buckets, _| { buckets[h] = None }), - "query {q}: dropping the injection at height {h} must be rejected" - ); - } - } -} - -/// The injection position is derived, not sent, and prover and verifier derive it -/// separately — so a control that only tampers the VALUE would pass under a wrong -/// derivation. Reading the other row of the same opened pair is the mistake a -/// off-by-one in `injection_position` would make, so it is the one to pin. -#[test] -fn an_injection_read_at_the_sibling_row_is_rejected() { - let f = Round4Fixture::build(); - let mut exercised = 0usize; - for (q, &iota) in f.commit.iotas.iter().enumerate() { - assert!(f.check_query(q), "honest control for query {q}"); - for &t in f.plan.batched.iter() { - let h = f.tables[t].height; - if h == f.h_max { - continue; - } - let position = crate::batched::round4::injection_position(iota, f.h_max, h); - let sibling = position ^ 1; - let inputs: Vec<(Vec, usize)> = f - .plan - .batched - .iter() - .map(|&b| (f.tables[b].codeword.clone(), f.tables[b].height)) - .collect(); - let combined = crate::fri::batched::combine_by_height(&inputs, &f.alpha); - let bucket = combined[h].as_ref().expect("the height is occupied"); - // A degenerate codeword whose two rows coincide would make this - // vacuous; skip rather than assert a rejection that means nothing. - if bucket[position] == bucket[sibling] { - continue; - } - exercised += 1; - let sibling_value = bucket[sibling]; - assert!( - !f.check_query_with(q, |_, _, buckets, _| { - buckets[h] = Some(sibling_value); - }), - "query {q}: reading height {h}'s injection at the sibling row must be rejected" - ); - } - } - assert!( - exercised > 0, - "no non-degenerate sibling pair was exercised — the test proved nothing" - ); -} - -/// Everything on the verifier's path is prover-supplied, so it must fail closed -/// on shapes that cannot occur honestly rather than panic on them. -#[test] -fn malformed_batched_fri_inputs_are_rejected_without_panicking() { - let f = Round4Fixture::build(); - let iota = f.commit.iotas[0]; - let (p0, buckets) = round4_tests::query_inputs(&f.tables, &f.alpha, iota); - let terminal_offset = - FE::from(round4_tests::COSET_OFFSET).pow(1u64 << f.commit.layout.total_folds); - let terminal = crate::fri::terminal::terminal_codeword_from_coeffs::( - &f.commit.final_poly_coeffs, - &terminal_offset, - f.commit.layout.terminal_len, - ); - let point_inv = round4_tests::evaluation_point_inv(iota, f.h_max); - - let run = |layer_roots: &[[u8; 32]], - betas: &[FE], - h_max: usize, - iota: usize, - buckets: &[Option], - terminal: &[FE]| { - crate::batched::round4::verify_batched_fri_query::( - layer_roots, - betas, - &f.commit.layout, - h_max, - iota, - &f.decommitments[0], - &point_inv, - (&p0.0, &p0.1), - buckets, - terminal, - ) - }; - - assert!( - run( - &f.commit.layer_roots, - &f.betas, - f.h_max, - iota, - &buckets, - &terminal - ), - "honest control" - ); - assert!( - !run(&[], &f.betas, f.h_max, iota, &buckets, &terminal), - "a missing layer-root vector must be rejected" - ); - assert!( - !run( - &f.commit.layer_roots, - &[], - f.h_max, - iota, - &buckets, - &terminal - ), - "a missing beta vector must be rejected" - ); - assert!( - !run( - &f.commit.layer_roots, - &f.betas, - 0, - iota, - &buckets, - &terminal - ), - "h_max = 0 must be rejected, not shifted by" - ); - assert!( - !run( - &f.commit.layer_roots, - &f.betas, - f.h_max, - 1usize << (f.h_max - 1), - &buckets, - &terminal - ), - "an iota from a taller domain must be rejected" - ); - assert!( - !run( - &f.commit.layer_roots, - &f.betas, - f.h_max, - iota, - &buckets[..1], - &terminal - ), - "a bucket vector too short to cover every height must be rejected" - ); - assert!( - !run( - &f.commit.layer_roots, - &f.betas, - f.h_max, - iota, - &buckets, - &[] - ), - "an empty terminal codeword must be rejected" - ); -} - -/// ★ The control the two-class split requires: a table of EACH class must be -/// tamper-checked. -/// -/// The classes read the same query index in different spaces — the batched class -/// uses `iota` directly, a standalone table uses `iota >> (h_max - h)`. Prover -/// and verifier both derive that shift from the shape, so a wrong convention is -/// self-consistent and honest proofs keep verifying; the failure is that the -/// standalone tables end up checked at positions nothing else reaches. A control -/// that only tampered the batched class would pass under ANY convention for the -/// other, which is exactly how consolidating a per-table check loses coverage. -#[test] -fn each_instance_class_is_tamper_checked() { - let f = Round4Fixture::build(); - assert!( - !f.plan.standalone.is_empty(), - "the fixture must exercise BOTH classes, or this control proves nothing \ - about the split" - ); - assert!( - f.plan.batched.len() > 1, - "the batched class must carry more than the tallest table" - ); - - // --- Batched class: covered by the fold recursion. --- - assert!(f.check_query(0), "honest control"); - assert!( - !f.check_query_with(0, |_, p0, _, _| { p0.0 = &p0.0 + &FE::from(1u64) }), - "a tampered batched-class value must be rejected" - ); - - // --- Standalone class: its own terminal-only instance. --- - for &t in &f.plan.standalone { - let table = &f.tables[t]; - // A zero-layer table's terminal codeword IS its deep-composition - // codeword — nothing folds — so the honest terminal is the codeword. - let terminal = &table.codeword; - for (q, &iota) in f.commit.iotas.iter().enumerate() { - let reduced = crate::batched::round4::reduce_iota_to_round(iota, f.h_max, table.height) - .expect("a standalone table is never taller than the FRI"); - let honest = (terminal[reduced * 2], terminal[reduced * 2 + 1]); - assert!( - crate::batched::round4::verify_standalone_fri_query::( - iota, - f.h_max, - table.height, - (&honest.0, &honest.1), - terminal, - ), - "query {q}: the honest standalone opening must verify" - ); - let tampered = &honest.0 + &FE::from(1u64); - assert!( - !crate::batched::round4::verify_standalone_fri_query::( - iota, - f.h_max, - table.height, - (&tampered, &honest.1), - terminal, - ), - "query {q}: a tampered standalone value must be rejected" - ); - // The index rule itself: reading the table at the UNREDUCED batched - // index is the mistake the two-class split makes possible, and it is - // silent unless something rejects it. - if iota != reduced && iota * 2 + 1 < terminal.len() { - assert!( - !crate::batched::round4::verify_standalone_fri_query::( - iota, - f.h_max, - table.height, - (&terminal[iota * 2], &terminal[iota * 2 + 1]), - terminal, - ), - "query {q}: the un-reduced index must not authenticate a \ - standalone table" - ); - } - } - } -} - -/// A standalone table must not be reachable through the batched instance's -/// injection path: it contributes no bucket, so a prover that manufactured one -/// is claiming a codeword this instance never mixed. -#[test] -fn a_standalone_table_contributes_no_injection() { - let f = Round4Fixture::build(); - assert!( - !f.plan.standalone.is_empty(), - "the fixture needs both classes" - ); - for &t in &f.plan.standalone { - let h = f.tables[t].height; - assert!( - !f.plan.batched.iter().any(|&b| f.tables[b].height == h), - "the fixture's standalone height must be unique to that class" - ); - for q in 0..f.commit.iotas.len() { - assert!(f.check_query(q), "honest control for query {q}"); - assert!( - !f.check_query_with(q, |_, _, buckets, _| { - buckets[h] = Some(FE::from(7u64)); - }), - "query {q}: a bucket manufactured at a standalone height must be \ - rejected" - ); - } - } -} - -// =========================================================================== -// EPOCH-LEVEL NEGATIVES — the items M-2 deferred "with the integration" -// =========================================================================== -// -// Everything above decides what the PRIMITIVES can decide: a tampered row, a -// mis-sized path, a replayed index, a swapped opening. These need a whole -// epoch, so they arrive with `multi_prove_batched` and -// `batched::verifier::replay_epoch_transcript`. -// -// ⚠ What is covered and what is not. Query count, the grinding nonce, the OOD -// values and the bus-contribution BINDING are covered. Bus BALANCE — that the -// per-table contributions sum to the expected value across the epoch — is NOT, -// and cannot be until the batched verifier grows the constraint half; see -// `batched/verifier.rs`'s header for why that is blocked and on what. -// -// Every negative below has an honest-path control beside it. Without one, a -// rejection proves only that the checker rejects, not that it discriminates. -mod epoch { - use super::*; - use crate::batched::verifier::{replay_epoch_transcript, verify_epoch_commitments}; - use crate::config::DefaultStarkHash; - use crate::residency_mode::ResidencyMode; - use crate::tests::batched_prover_tests::{Air, E, F, folding_options, prove_repeated}; - use crate::traits::AIR; - use crypto::fiat_shamir::default_transcript::DefaultTranscript; - use math::field::element::FieldElement; - - type Proof = crate::batched::proof::BatchedMultiProof; - - fn air_refs(airs: &[Air]) -> Vec<&dyn AIR> { - airs.iter() - .map(|a| a as &dyn AIR) - .collect() - } - - /// Replay `proof` and run the commitment checks. `None` when the replay - /// itself rejects, so a test can tell "rejected structurally" from - /// "rejected on the openings". - fn replay_and_check(airs: &[Air], proof: &Proof) -> Option { - let refs = air_refs(airs); - let (shape, params, challenges) = - replay_epoch_transcript(&refs, proof, &mut DefaultTranscript::::new(&[]))?; - Some(verify_epoch_commitments::( - &refs, - proof, - &shape, - ¶ms, - &challenges, - )) - } - - fn honest() -> (Vec, Proof) { - let (airs, proof, _, _) = prove_repeated(1, &folding_options(), ResidencyMode::Retain); - (airs, proof) - } - - /// ★ The strongest oracle available for "the prover and the verifier are one - /// protocol": not that some challenge agrees, but that the two transcripts - /// END in the same state. A divergence anywhere in the sequence — a root - /// absorbed in the wrong order, a challenge sampled that the other side does - /// not sample, an OOD block walked differently — lands here, where comparing - /// individual challenges would only catch it if you happened to compare the - /// right one. - #[test_log::test] - fn replay_matches_the_provers_ending_state() { - let mut prover_transcript = DefaultTranscript::::new(&[]); - let (airs, proof, _, _) = crate::tests::batched_prover_tests::prove_repeated_with( - 1, - &folding_options(), - ResidencyMode::Retain, - &mut prover_transcript, - ); - - let mut verifier_transcript = DefaultTranscript::::new(&[]); - let refs = air_refs(&airs); - replay_epoch_transcript(&refs, &proof, &mut verifier_transcript) - .expect("an honest epoch must replay"); - - assert_eq!( - prover_transcript.state(), - verifier_transcript.state(), - "prover and verifier must end the epoch in the same transcript state" - ); - } - - /// The honest-path control every negative below leans on. - #[test_log::test] - fn an_honest_epoch_passes_the_commitment_checks() { - let (airs, proof) = honest(); - assert_eq!( - replay_and_check(&airs, &proof), - Some(true), - "an honest epoch must replay and authenticate" - ); - } - - /// Query count. Nothing the transcript has already checked implies it: a - /// prover that sent fewer openings would simply be checked less often. - #[test_log::test] - fn a_short_query_list_is_rejected() { - let (airs, mut proof) = honest(); - assert!( - proof.queries.len() > 1, - "the fixture must have queries to drop" - ); - proof.queries.pop(); - assert_eq!( - replay_and_check(&airs, &proof), - Some(false), - "dropping a query must be rejected" - ); - } - - /// Grinding. The nonce is absorbed, so a forged one moves every later - /// challenge AND fails its own proof-of-work check; either rejection is - /// correct and the test asserts the outcome, not the route. - #[test_log::test] - fn a_forged_grinding_nonce_is_rejected() { - let (airs, mut proof) = honest(); - let nonce = proof.nonce.expect("the fixture grinds"); - proof.nonce = Some(nonce.wrapping_add(1)); - assert_ne!( - replay_and_check(&airs, &proof), - Some(true), - "a nonce the prover did not grind must be rejected" - ); - } - - /// A missing nonce where the epoch's grinding factor demands one. - #[test_log::test] - fn an_absent_grinding_nonce_is_rejected() { - let (airs, mut proof) = honest(); - proof.nonce = None; - assert_ne!( - replay_and_check(&airs, &proof), - Some(true), - "an epoch with a positive grinding factor must carry a nonce" - ); - } - - /// OOD values. They are absorbed before alpha, so tampering one must move - /// the query indices — which is what makes the openings, honestly produced - /// at the honest indices, stop authenticating. - #[test_log::test] - fn a_tampered_ood_value_is_rejected() { - let (airs, honest_proof) = honest(); - let refs = air_refs(&airs); - let honest_iotas = - replay_epoch_transcript(&refs, &honest_proof, &mut DefaultTranscript::::new(&[])) - .expect("honest replay") - .2 - .fri - .iotas; - - let mut proof = honest_proof.clone(); - proof.tables[0].composition_poly_parts_ood_evaluation[0] += FieldElement::::one(); - let (_, _, tampered) = - replay_epoch_transcript(&refs, &proof, &mut DefaultTranscript::::new(&[])) - .expect("the shape is still structurally consistent"); - assert_ne!( - tampered.fri.iotas, honest_iotas, - "an OOD value the prover did not commit must move the query indices" - ); - assert_eq!( - replay_and_check(&airs, &proof), - Some(false), - "and the openings must then fail to authenticate" - ); - } - - /// A trace OOD value, tampered in the other block, must behave the same — - /// the two blocks are absorbed separately and a control on only one would - /// miss a verifier that walked just that one. - #[test_log::test] - fn a_tampered_trace_ood_value_is_rejected() { - let (airs, mut proof) = honest(); - let table = &mut proof.tables[0]; - let value = *table.trace_ood_evaluations.get(0, 0); - table - .trace_ood_evaluations - .set(0, 0, value + FieldElement::::one()); - assert_eq!( - replay_and_check(&airs, &proof), - Some(false), - "a trace OOD value the prover did not commit must be rejected" - ); - } - - /// Bus-contribution BINDING (not balance): which tables carry one is a fact - /// about the AIR set, so dropping one must be a structural rejection rather - /// than a transcript that quietly absorbs one element fewer. - #[test_log::test] - fn a_dropped_bus_contribution_is_rejected() { - let (airs, mut proof) = honest(); - assert!( - proof.tables[0].bus_public_inputs.is_some(), - "the fixture's tables all have a RAP" - ); - proof.tables[0].bus_public_inputs = None; - assert_eq!( - replay_and_check(&airs, &proof), - None, - "a table whose AIR has a RAP must carry a bus contribution" - ); - } - - /// A tampered bus contribution is absorbed, so it moves the challenges. - #[test_log::test] - fn a_tampered_bus_contribution_is_rejected() { - let (airs, mut proof) = honest(); - if let Some(bpi) = proof.tables[0].bus_public_inputs.as_mut() { - bpi.table_contribution += FieldElement::::one(); - } - assert_eq!( - replay_and_check(&airs, &proof), - Some(false), - "a bus contribution the prover did not commit must be rejected" - ); - } - - /// A whole round's root, dropped. The AIR set says the aux round exists, so - /// its absence is a structural rejection — without this a prover could - /// remove a round's binding entirely. - #[test_log::test] - fn a_dropped_round_root_is_rejected() { - let (airs, mut proof) = honest(); - proof.aux_root = None; - assert_eq!( - replay_and_check(&airs, &proof), - None, - "the aux round exists in this epoch, so its root cannot be absent" - ); - } - - /// An opening the epoch does not have. This fixture has no preprocessed - /// table, so a query carrying a preprocessed opening must reject: the - /// count is bound by the AIR set, never by what the proof sends. - #[test_log::test] - fn an_invented_prep_opening_is_rejected() { - let (airs, mut proof) = honest(); - assert!( - proof.queries[0].prep.is_empty(), - "the fixture has no preprocessed table" - ); - proof.queries[0] - .prep - .push(crate::proof::stark::PolynomialOpenings { - proof: crypto::merkle_tree::proof::Proof { - merkle_path: Vec::new(), - }, - evaluations: Vec::new(), - evaluations_sym: Vec::new(), - }); - assert_eq!( - replay_and_check(&airs, &proof), - Some(false), - "a query carrying a preprocessed opening the AIR set does not declare \ - must be rejected" - ); - } - - /// The instance-class partition is derived from the shape and never sent, so - /// a terminal polynomial present for a batched table — or of the wrong - /// length for a standalone one — must be rejected. - #[test_log::test] - fn a_misplaced_standalone_terminal_polynomial_is_rejected() { - let (airs, honest_proof) = honest(); - - let mut invented = honest_proof.clone(); - let batched_table = invented - .tables - .iter() - .position(|t| t.standalone_final_poly_coeffs.is_none()) - .expect("the tallest table is always batched"); - invented.tables[batched_table].standalone_final_poly_coeffs = - Some(vec![FieldElement::::one(); 2]); - assert_eq!( - replay_and_check(&airs, &invented), - None, - "a batched table must not carry a terminal-only polynomial — and the \ - refusal now lands at the TRANSCRIPT REPLAY: presence is bound with \ - the standalone absorb, before any challenge is drawn" - ); - - if let Some(standalone_table) = honest_proof - .tables - .iter() - .position(|t| t.standalone_final_poly_coeffs.is_some()) - { - let mut truncated = honest_proof.clone(); - let coeffs = truncated.tables[standalone_table] - .standalone_final_poly_coeffs - .as_mut() - .expect("just checked"); - coeffs.pop(); - assert_eq!( - replay_and_check(&airs, &truncated), - Some(false), - "a standalone terminal polynomial of the wrong degree bound must be rejected" - ); - } - } - - /// The width the openings are authenticated under is the verifier's, and a - /// table whose declared trace length disagrees with the epoch it was proved - /// for moves the whole shape — heights, histogram, every challenge. - #[test_log::test] - fn a_tampered_trace_length_is_rejected() { - let (airs, mut proof) = honest(); - proof.tables[1].trace_length *= 2; - assert_ne!( - replay_and_check(&airs, &proof), - Some(true), - "a trace length the prover did not commit must be rejected" - ); - } -} - -// =========================================================================== -// The DEEP / FRI join (M-5 core) -// =========================================================================== -// -// These are the tests that give the authenticated openings meaning. Everything -// in `epoch` above shows the proof opened the rows its roots bind; these show -// those rows evaluate to a codeword the batched FRI folds to the terminal -// polynomial it sent. -// -// The honest path is unusually load-bearing here: it can only pass if the DEEP -// reconstruction, the alpha mixing in `plan.batched` order, the per-table index -// reduction, the injection convention (value chosen from the opened row pair by -// `injection_position`'s low bit) and the coset relabelling are ALL right at -// once. Any one of them wrong and the terminal check fails. -mod fri_join { - use crate::batched::verifier::{replay_epoch_transcript, verify_epoch_fri}; - use crate::config::DefaultStarkHash; - use crate::residency_mode::ResidencyMode; - use crate::tests::batched_prover_tests::{Air, E, F, folding_options, prove_repeated}; - use crate::traits::AIR; - use crate::verifier::GenericVerifier; - use crypto::fiat_shamir::default_transcript::DefaultTranscript; - use math::field::element::FieldElement; - - type Proof = crate::batched::proof::BatchedMultiProof; - type V = GenericVerifier; - - fn join_holds(airs: &[Air], proof: &Proof) -> Option { - let refs: Vec<&dyn AIR> = airs - .iter() - .map(|a| a as &dyn AIR) - .collect(); - let (shape, params, challenges) = - replay_epoch_transcript(&refs, proof, &mut DefaultTranscript::::new(&[]))?; - Some(verify_epoch_fri::( - &refs, - proof, - &shape, - ¶ms, - &challenges, - )) - } - - fn honest() -> (Vec, Proof) { - let (airs, proof, _, _) = prove_repeated(1, &folding_options(), ResidencyMode::Retain); - (airs, proof) - } - - /// ★ The honest path — and passing it is the joint statement listed above. - #[test_log::test] - fn the_batched_fri_join_verifies_an_honest_epoch() { - let (airs, proof) = honest(); - assert_eq!( - join_holds(&airs, &proof), - Some(true), - "an honest epoch's opened rows must fold to the terminal polynomial it sent" - ); - } - - /// It must also hold at the degenerate shape, where every table terminates - /// immediately and the batched instance folds nothing — the branch - /// `verify_batched_fri_query` handles with `total_folds == 0` and the one - /// that puts every other table in the standalone class. - #[test_log::test] - fn the_join_verifies_a_no_fold_epoch() { - let (airs, proof, _, _) = prove_repeated( - 1, - &crate::proof::options::ProofOptions::default_test_options(), - ResidencyMode::Retain, - ); - assert_eq!( - join_holds(&airs, &proof), - Some(true), - "an epoch whose tables all terminate immediately must still verify" - ); - } - - /// The FRI layer openings are NOT absorbed into the transcript — the - /// structural length check and this recursion are the only things pinning - /// them. Tampering one leaves every Merkle root and every challenge intact, - /// so it is caught here or nowhere. - #[test_log::test] - fn a_tampered_fri_layer_evaluation_breaks_the_join() { - let (airs, honest_proof) = honest(); - assert_eq!( - join_holds(&airs, &honest_proof), - Some(true), - "honest-path control" - ); - - let layers = honest_proof.queries[0].fri.layers_evaluations_sym.len(); - assert!(layers > 0, "the folding fixture must commit a layer"); - let mut proof = honest_proof.clone(); - proof.queries[0].fri.layers_evaluations_sym[0] += FieldElement::::one(); - assert_eq!( - join_holds(&airs, &proof), - Some(false), - "a FRI layer value the prover did not commit must break the fold" - ); - } - - /// A truncated decommitment would make the fold loop run fewer rounds and - /// accept the query without ever reaching the terminal. The length check - /// runs before the loop for exactly that reason. - #[test_log::test] - fn a_truncated_fri_decommitment_is_rejected() { - let (airs, mut proof) = honest(); - proof.queries[0].fri.layers_evaluations_sym.pop(); - assert_eq!( - join_holds(&airs, &proof), - Some(false), - "a short decommitment must be rejected, not folded fewer times" - ); - } - - /// An opened trace row that the MMCS would accept only if the roots moved - /// with it: tampering one must break the DEEP value it feeds, so the join - /// fails independently of the Merkle check. - #[test_log::test] - fn a_tampered_opened_row_breaks_the_join() { - let (airs, mut proof) = honest(); - proof.queries[0].main.per_matrix[0].evaluations[0] += FieldElement::::one(); - assert_eq!( - join_holds(&airs, &proof), - Some(false), - "a tampered trace row must change the DEEP value and fail the fold" - ); - } - - /// A standalone table's terminal polynomial is checked by the OTHER class's - /// routine, so it needs its own control: a tampered coefficient must be - /// caught even though the batched instance is untouched. - #[test_log::test] - fn a_tampered_standalone_terminal_polynomial_breaks_the_join() { - let (airs, honest_proof) = honest(); - let Some(table) = honest_proof - .tables - .iter() - .position(|t| t.standalone_final_poly_coeffs.is_some()) - else { - // The fixture's shape put every table in the batched class; nothing - // to test, and saying so beats a silently vacuous pass. - return; - }; - let mut proof = honest_proof.clone(); - proof.tables[table] - .standalone_final_poly_coeffs - .as_mut() - .expect("just checked")[0] += FieldElement::::one(); - assert_ne!( - join_holds(&airs, &proof), - Some(true), - "a standalone terminal polynomial the prover did not commit must be rejected" - ); - } -} - -// =========================================================================== -// `multi_verify_batched` — the complete verification -// =========================================================================== -mod full_verify { - use crate::batched::verifier::multi_verify_batched; - use crate::config::DefaultStarkHash; - use crate::residency_mode::ResidencyMode; - use crate::tests::batched_prover_tests::{Air, E, F, folding_options, prove_repeated}; - use crate::traits::AIR; - use crate::verifier::GenericVerifier; - use crypto::fiat_shamir::default_transcript::DefaultTranscript; - use math::field::element::FieldElement; - - type Proof = crate::batched::proof::BatchedMultiProof; - type V = GenericVerifier; - - fn verifies(airs: &[Air], proof: &Proof) -> bool { - let refs: Vec<&dyn AIR> = airs - .iter() - .map(|a| a as &dyn AIR) - .collect(); - multi_verify_batched::( - &refs, - proof, - &mut DefaultTranscript::::new(&[]), - &FieldElement::zero(), - ) - } - - fn honest() -> (Vec, Proof) { - let (airs, proof, _, _) = prove_repeated(1, &folding_options(), ResidencyMode::Retain); - (airs, proof) - } - - /// ★★ Completeness: a proof this repository's batched prover produced is - /// accepted by this repository's batched verifier, end to end — replay, - /// commitments, constraint identity at every `z`, bus balance, and the - /// DEEP/FRI join across both instance classes. - #[test_log::test] - fn an_honest_batched_epoch_verifies_end_to_end() { - let (airs, proof) = honest(); - assert!( - verifies(&airs, &proof), - "an honest batched epoch must verify" - ); - } - - /// The tall fixture is a well-formed epoch on the host path — the - /// baseline the cuda arm below is compared against. - #[test_log::test] - fn a_tall_batched_epoch_verifies_end_to_end() { - let (airs, proof, _) = crate::tests::batched_prover_tests::prove_tall( - 4, - &folding_options(), - ResidencyMode::Retain, - ); - assert!( - verifies(&airs, &proof), - "the tall fixture must verify on the host path" - ); - } - - /// A cuda build must produce the same accepting proof a non-cuda build - /// does. The GPU LogUp aux build becomes eligible at 2^10 rows; at - /// 2^13/2^12-row tables the aux round only verifies if the host trace - /// columns its LDE expands from are actually written — a device-resident - /// aux build that skips them makes this fail at the OOD composition - /// check. - #[cfg(feature = "cuda")] - #[test_log::test] - fn a_tall_batched_epoch_verifies_under_cuda() { - let (airs, proof, _) = crate::tests::batched_prover_tests::prove_tall( - 1 << 10, - &folding_options(), - ResidencyMode::Retain, - ); - assert!( - verifies(&airs, &proof), - "the cuda build's batched aux round must match the host build's" - ); - } - - /// The same at the degenerate shape, where nothing folds. - #[test_log::test] - fn an_honest_no_fold_epoch_verifies_end_to_end() { - let (airs, proof, _, _) = prove_repeated( - 1, - &crate::proof::options::ProofOptions::default_test_options(), - ResidencyMode::Retain, - ); - assert!( - verifies(&airs, &proof), - "an epoch whose tables all terminate immediately must verify" - ); - } - - /// Residency is a performance choice, so it must be invisible to a verifier - /// as well as to the roots. - #[test_log::test] - fn both_residency_modes_produce_verifying_proofs() { - for mode in [ResidencyMode::Retain, ResidencyMode::RecomputeLde] { - let (airs, proof, _, _) = prove_repeated(1, &folding_options(), mode); - assert!( - verifies(&airs, &proof), - "{mode:?} must produce a valid proof" - ); - } - } - - /// The bus balance is the one cross-table statement, and no per-table check - /// can make it. Verifying against a balance the epoch does not have must - /// fail — with the honest expectation as the control. - #[test_log::test] - fn a_wrong_expected_bus_balance_is_rejected() { - let (airs, proof) = honest(); - let refs: Vec<&dyn AIR> = airs - .iter() - .map(|a| a as &dyn AIR) - .collect(); - assert!( - multi_verify_batched::( - &refs, - &proof, - &mut DefaultTranscript::::new(&[]), - &FieldElement::zero(), - ), - "honest-path control: the epoch balances at zero" - ); - assert!( - !multi_verify_batched::( - &refs, - &proof, - &mut DefaultTranscript::::new(&[]), - &FieldElement::one(), - ), - "an expected balance the epoch does not have must be rejected" - ); - } - - /// The constraint identity. A composition-parts OOD value the trace does not - /// justify must fail — this is the check whose absence would let the batched - /// path accept a proof of a false statement while every root and every - /// opening stayed consistent. - #[test_log::test] - fn a_claimed_composition_value_the_trace_does_not_justify_is_rejected() { - let (airs, mut proof) = honest(); - proof.tables[0].composition_poly_parts_ood_evaluation[0] += FieldElement::::one(); - assert!( - !verifies(&airs, &proof), - "a composition OOD value the trace does not justify must be rejected" - ); - } - - /// Every negative already covered piecewise must also be rejected by the - /// whole verifier — a check that exists but is never reached is not a check. - #[test_log::test] - fn the_whole_verifier_rejects_what_the_pieces_reject() { - let (airs, honest_proof) = honest(); - assert!(verifies(&airs, &honest_proof), "honest-path control"); - - let mut short_queries = honest_proof.clone(); - short_queries.queries.pop(); - assert!(!verifies(&airs, &short_queries), "short query list"); - - let mut bad_nonce = honest_proof.clone(); - bad_nonce.nonce = Some(bad_nonce.nonce.expect("the fixture grinds").wrapping_add(1)); - assert!(!verifies(&airs, &bad_nonce), "forged grinding nonce"); - - let mut bad_row = honest_proof.clone(); - bad_row.queries[0].main.per_matrix[0].evaluations[0] += FieldElement::::one(); - assert!(!verifies(&airs, &bad_row), "tampered opened row"); - - let mut bad_layer = honest_proof.clone(); - bad_layer.queries[0].fri.layers_evaluations_sym[0] += FieldElement::::one(); - assert!(!verifies(&airs, &bad_layer), "tampered FRI layer value"); - - let mut no_aux_root = honest_proof.clone(); - no_aux_root.aux_root = None; - assert!(!verifies(&airs, &no_aux_root), "dropped aux root"); - } -} - -// =========================================================================== -// The preprocessed binding, end to end -// =========================================================================== -// -// Preprocessed tables are bound PER TABLE: each root is -// `air.precomputed_commitment()`, absorbed by both sides from the AIR set and -// authenticated per query at the reduced per-table index — the per-table -// path's critical soundness check, unchanged in kind. There is no pinned -// fused round and no caller-side pin: the old `PinnedPrep` width tests have -// no analogue because widths come from the AIR set on both sides, and the -// fail-closed `None` arm has no analogue because there is nothing to omit. -// What this module still owes §3.3 is the per-matrix quantifier through the -// WHOLE verifier, and the wrong-root rejection — both kept below. -mod prep_binding { - use crate::batched::verifier::multi_verify_batched; - use crate::config::DefaultStarkHash; - use crate::tests::batched_prover_tests::{Air, E, F, PREP_WIDTHS, prove_preprocessed}; - use crate::traits::AIR; - use crate::verifier::GenericVerifier; - use crypto::fiat_shamir::default_transcript::DefaultTranscript; - use math::field::element::FieldElement; - - type Proof = crate::batched::proof::BatchedMultiProof; - type V = GenericVerifier; - - fn verifies(airs: &[Air], proof: &Proof) -> bool { - let refs: Vec<&dyn AIR> = airs - .iter() - .map(|a| a as &dyn AIR) - .collect(); - multi_verify_batched::( - &refs, - proof, - &mut DefaultTranscript::::new(&[]), - &FieldElement::zero(), - ) - } - - fn honest() -> (Vec, Proof) { - let (airs, proof, _) = prove_preprocessed().expect("an honest preprocessed epoch"); - (airs, proof) - } - - /// ★★ The honest path. A preprocessed epoch verifies end to end against - /// the AIR set's own pinned roots — every other test in this module is a - /// rejection, and without this one they would all be satisfied by a - /// verifier that rejected everything. - #[test_log::test] - fn an_honest_preprocessed_epoch_verifies_end_to_end() { - let (airs, proof) = honest(); - assert!( - verifies(&airs, &proof), - "an honest preprocessed epoch must verify against the AIR set's roots" - ); - } - - /// ★ The check the per-table binding exists for. A verifier whose AIR set - /// pins a DIFFERENT preprocessed root must reject the proof: the roots are - /// the verifier's own, so a prover cannot substitute preprocessed content - /// and stay self-consistent. - #[test_log::test] - fn a_prep_root_the_program_does_not_pin_is_rejected() { - use crate::examples::multi_table_lookup::{ - new_add_air_with_lookup, new_cpu_air_with_lookup, new_mul_air_with_lookup, - }; - use crate::tests::batched_prover_tests::folding_options; - - let (airs, proof) = honest(); - let options = folding_options(); - let mut wrong_root = airs[1].precomputed_commitment(); - wrong_root[0] ^= 0xff; - let wrong_airs = vec![ - new_cpu_air_with_lookup(&options), - new_add_air_with_lookup(&options).with_preprocessed(wrong_root, PREP_WIDTHS[0]), - new_mul_air_with_lookup(&options) - .with_preprocessed(airs[2].precomputed_commitment(), PREP_WIDTHS[1]), - ]; - assert!( - !verifies(&wrong_airs, &proof), - "a proof whose preprocessed content is not the verifier's pinned one \ - must be rejected" - ); - } - - /// ★ The per-matrix quantifier, reached through the WHOLE verifier rather - /// than through the opening check alone. §3.3's requirement survives the - /// per-table layout: the verification must fail if ANY one table's - /// preprocessed value is wrong. - #[test_log::test] - fn a_tampered_prep_matrix_is_rejected_per_matrix_end_to_end() { - let (airs, honest_proof) = honest(); - assert!(verifies(&airs, &honest_proof), "honest-path control"); - - let matrices = honest_proof.queries[0].prep.len(); - assert_eq!( - matrices, - PREP_WIDTHS.len(), - "the fixture must contribute one opening per preprocessed table" - ); - - for matrix in 0..matrices { - let mut tampered = honest_proof.clone(); - tampered.queries[0].prep[matrix].evaluations[0] += FieldElement::::one(); - assert!( - !verifies(&airs, &tampered), - "prep table {matrix}: a tampered precomputed value must be rejected \ - by the whole verifier" - ); - } - } -} diff --git a/crypto/stark/src/tests/batched_prover_tests.rs b/crypto/stark/src/tests/batched_prover_tests.rs deleted file mode 100644 index d74769728..000000000 --- a/crypto/stark/src/tests/batched_prover_tests.rs +++ /dev/null @@ -1,1218 +0,0 @@ -//! `multi_prove_batched` — the openings it produces, and the residency claim -//! MMCS-PLAN §3.3 asks to be made falsifiable at the PROVER level. -//! -//! The primitive-level access-window test -//! (`streaming_builder_serves_the_base_group_without_holding_it`, in -//! `fri/mmcs.rs`) shows that [`crate::fri::mmcs::StreamingMmcsBuilder`] CAN be -//! driven with peak residency one. It cannot show that the prover drives it that -//! way, because at the time it was written there was no batched prover. These -//! tests close that gap from the other side. - -use crypto::fiat_shamir::default_transcript::DefaultTranscript; -use math::field::element::FieldElement; -use math::field::{ - extensions_goldilocks::Degree3GoldilocksExtensionField, goldilocks::GoldilocksField, -}; - -use crate::batched::proof::{BatchedMultiProof, BatchedProveStats}; -use crate::batched::prover::multi_prove_batched; -use crate::batched::shape::{EpochShape, RoundShape}; -use crate::config::DefaultStarkHash; -use crate::examples::multi_table_lookup::{ - new_add_air_with_lookup, new_cpu_air_with_lookup, new_mul_air_with_lookup, -}; -use crate::fri::mmcs::{MixedMmcs, MixedOpening}; -use crate::proof::options::ProofOptions; -use crate::prover::{GenericProver, IsStarkProver}; -use crate::residency_mode::ResidencyMode; -use crate::trace::TraceTable; -use crate::traits::AIR; - -pub(crate) type F = GoldilocksField; -pub(crate) type E = Degree3GoldilocksExtensionField; -type FE = FieldElement; -pub(crate) type Air = crate::lookup::AirWithBuses< - F, - E, - crate::lookup::NullBoundaryConstraintBuilder, - (), - crate::constraints::builder::EmptyConstraints, ->; - -/// Small `fri_final_poly_log_degree` so the tiny fixture below actually FOLDS. -/// At the default (7) every table in an 8-row epoch terminates immediately and -/// the batched FRI degenerates to one terminal polynomial — a real case, and -/// covered by `batched_prove_openings_authenticate` under the default options, -/// but not the one that exercises the injection recursion. -pub(crate) fn folding_options() -> ProofOptions { - ProofOptions { - blowup_factor: 2, - fri_number_of_queries: 4, - coset_offset: 3, - grinding_factor: 4, - fri_final_poly_log_degree: 1, - } -} - -/// The bus-balanced CPU/ADD/MUL instance from the completeness tests, with the -/// CPU table one height above the other two so the epoch is genuinely mixed — -/// a same-height epoch would exercise neither the injection nor the index -/// reduction. -fn traces() -> (TraceTable, TraceTable, TraceTable) { - let cpu = TraceTable::from_columns_main( - vec![ - vec![ - FE::one(), - FE::zero(), - FE::one(), - FE::zero(), - FE::one(), - FE::one(), - FE::zero(), - FE::zero(), - ], - vec![ - FE::zero(), - FE::one(), - FE::zero(), - FE::one(), - FE::zero(), - FE::zero(), - FE::one(), - FE::one(), - ], - (1..=8).map(FE::from).collect(), - (1..=8).map(|i| FE::from(i * 10)).collect(), - vec![ - FE::from(11), - FE::from(40), - FE::from(33), - FE::from(160), - FE::from(55), - FE::from(66), - FE::from(490), - FE::from(640), - ], - ], - 1, - ); - let add = TraceTable::from_columns_main( - vec![ - vec![FE::from(1), FE::from(3), FE::from(5), FE::from(6)], - vec![FE::from(10), FE::from(30), FE::from(50), FE::from(60)], - vec![FE::from(11), FE::from(33), FE::from(55), FE::from(66)], - vec![FE::one(); 4], - ], - 1, - ); - let mul = TraceTable::from_columns_main( - vec![ - vec![FE::from(2), FE::from(4), FE::from(7), FE::from(8)], - vec![FE::from(20), FE::from(40), FE::from(70), FE::from(80)], - vec![FE::from(40), FE::from(160), FE::from(490), FE::from(640)], - vec![FE::one(); 4], - ], - 1, - ); - (cpu, add, mul) -} - -/// The 8/4/4-row fixture tiled `k` times vertically: every column repeated -/// end to end, so each bus send still meets its receive `k`-for-`k` and the -/// epoch stays balanced at `k`× the height. Heights at or above 2^10 rows are -/// where the GPU LogUp aux build becomes eligible, which is what the -/// cfg-invariance test needs. -pub(crate) fn tall_traces(k: usize) -> (TraceTable, TraceTable, TraceTable) { - let tile = |t: &TraceTable| { - let cols: Vec> = t - .columns_main() - .iter() - .map(|col| { - let mut tall = Vec::with_capacity(col.len() * k); - for _ in 0..k { - tall.extend_from_slice(col); - } - tall - }) - .collect(); - TraceTable::from_columns_main(cols, 1) - }; - let (cpu, add, mul) = traces(); - (tile(&cpu), tile(&add), tile(&mul)) -} - -/// One epoch of the tall fixture ([`tall_traces`]), proved batched. -pub(crate) fn prove_tall( - k: usize, - options: &ProofOptions, - residency: ResidencyMode, -) -> (Vec, BatchedMultiProof, BatchedProveStats) { - let (mut cpu, mut add, mut mul) = tall_traces(k); - let airs = vec![ - new_cpu_air_with_lookup(options), - new_add_air_with_lookup(options), - new_mul_air_with_lookup(options), - ]; - let unit = (); - let pairs: Vec<_> = airs - .iter() - .zip([&mut cpu, &mut add, &mut mul]) - .map(|(air, trace)| { - ( - air as &dyn AIR, - trace, - &unit, - ) - }) - .collect(); - let (proof, stats) = multi_prove_batched::< - F, - E, - (), - DefaultStarkHash, - GenericProver, - >( - pairs, - &mut DefaultTranscript::::new(&[]), - #[cfg(feature = "disk-spill")] - crate::storage_mode::StorageMode::Ram, - residency, - ) - .expect("the tall fixture is a well-shaped epoch"); - (airs, proof, stats) -} - -/// Prove `repeats` copies of the fixture as one epoch. `repeats == 1` is the -/// three-table epoch; higher values are how the residency claim is put on a -/// curve instead of a threshold. -pub(crate) fn prove_repeated( - repeats: usize, - options: &ProofOptions, - residency: ResidencyMode, -) -> ( - Vec, - BatchedMultiProof, - BatchedProveStats, - Vec, -) { - prove_repeated_with( - repeats, - options, - residency, - &mut DefaultTranscript::::new(&[]), - ) -} - -/// As [`prove_repeated`], but against a caller-owned transcript — so a test can -/// read the state the PROVER ended in and compare it with the verifier's. -pub(crate) fn prove_repeated_with( - repeats: usize, - options: &ProofOptions, - residency: ResidencyMode, - transcript: &mut DefaultTranscript, -) -> ( - Vec, - BatchedMultiProof, - BatchedProveStats, - Vec, -) { - let mut airs = Vec::new(); - let mut all_traces = Vec::new(); - for _ in 0..repeats { - let (cpu, add, mul) = traces(); - airs.push(new_cpu_air_with_lookup(options)); - airs.push(new_add_air_with_lookup(options)); - airs.push(new_mul_air_with_lookup(options)); - all_traces.push(cpu); - all_traces.push(add); - all_traces.push(mul); - } - - let unit = (); - let pairs: Vec<_> = airs - .iter() - .zip(all_traces.iter_mut()) - .map(|(air, trace)| { - ( - air as &dyn AIR, - trace, - &unit, - ) - }) - .collect(); - - let trace_lengths: Vec = (0..repeats).flat_map(|_| [8usize, 4, 4]).collect(); - let (proof, stats) = multi_prove_batched::< - F, - E, - (), - DefaultStarkHash, - GenericProver, - >( - pairs, - transcript, - #[cfg(feature = "disk-spill")] - crate::storage_mode::StorageMode::Ram, - residency, - ) - .expect("the fixture is a well-shaped epoch"); - - (airs, proof, stats, trace_lengths) -} - -pub(crate) fn shape_of(airs: &[Air], trace_lengths: &[usize]) -> EpochShape { - let refs: Vec<&dyn AIR> = airs - .iter() - .map(|a| a as &dyn AIR) - .collect(); - EpochShape::derive(&refs, trace_lengths) - .expect("the fixture is a well-shaped epoch") - .0 -} - -/// Authenticate one round's opening the way a verifier must: reduce the shared -/// FRI index into the round's own index space first. -fn round_verifies( - root: &crate::config::Commitment, - opening: &MixedOpening, - round: &RoundShape, - iota_fri: usize, - h_max_fri: usize, -) -> bool -where - FieldElement: math::traits::AsBytes + Sync + Send, -{ - let Some(h_max_round) = round.h_max() else { - return false; - }; - let Some(iota) = crate::batched::round4::reduce_iota_to_round(iota_fri, h_max_fri, h_max_round) - else { - return false; - }; - MixedMmcs::::verify_batch( - root, - iota, - opening, - &round.heights(), - &round.widths(), - ) -} - -/// The honest path. Every query's opening of every batched round authenticates -/// against that round's root, under the index reduction the two different -/// `h_max` values force. -#[test_log::test] -fn batched_prove_openings_authenticate() { - for options in [ProofOptions::default_test_options(), folding_options()] { - let (airs, proof, _stats, lengths) = prove_repeated(1, &options, ResidencyMode::Retain); - let shape = shape_of(&airs, &lengths); - let h_max = shape.h_max(); - assert_eq!(proof.queries.len(), options.fri_number_of_queries); - let iotas = recover_iotas(&proof, &shape, h_max); - - for (q, query) in proof.queries.iter().enumerate() { - assert!( - round_verifies(&proof.main_root, &query.main, &shape.main, iotas[q], h_max), - "query {q}: main round must authenticate" - ); - assert!( - round_verifies( - &proof.parts_root, - &query.parts, - &shape.parts, - iotas[q], - h_max - ), - "query {q}: parts round must authenticate" - ); - let (Some(root), Some(opening)) = (proof.aux_root, query.aux.as_ref()) else { - panic!("the fixture's tables all have a RAP, so the aux round exists"); - }; - assert!( - round_verifies(&root, opening, &shape.aux, iotas[q], h_max), - "query {q}: aux round must authenticate" - ); - } - } -} - -/// The query indices, recovered from the proof rather than read off the -/// prover's own state. -/// -/// Deliberately NOT via `replay_epoch_transcript`, even though that exists: an -/// opening authenticates at exactly one leaf, so scanning the (tiny) index -/// space for the one that verifies is a derivation INDEPENDENT of the -/// transcript. These tests are then not circular — they do not check the -/// openings against indices produced by the same code path that has to be -/// right for the openings to mean anything. The epoch-level tests in -/// `batched_mmcs_soundness_tests::epoch` use the replay, so both derivations -/// are exercised and are pinned to each other by the honest path passing under -/// each. -fn recover_iotas( - proof: &BatchedMultiProof, - shape: &EpochShape, - h_max: usize, -) -> Vec { - proof - .queries - .iter() - .map(|query| { - (0..(1usize << (h_max - 1))) - .find(|&candidate| { - round_verifies(&proof.main_root, &query.main, &shape.main, candidate, h_max) - }) - .expect("an honest opening authenticates at its own index") - }) - .collect() -} - -/// ★ The acceptance test MMCS-PLAN §3.3 asks for, at the prover level. -/// -/// Doubling the epoch must not double the trace-LDE residency. The assertion is -/// a SCALING one rather than a threshold: a threshold can be met by a prover -/// that holds everything for a small epoch, while the curve cannot. The retained -/// arm is the control — it proves the measurement can see growth, so a flat -/// recompute arm means the streaming discipline held, not that the ledger is -/// blind. -#[test_log::test] -fn streaming_prover_trace_residency_is_flat_in_the_table_count() { - let options = folding_options(); - - let (_, _, small_recompute, _) = prove_repeated(1, &options, ResidencyMode::RecomputeLde); - let (_, _, large_recompute, _) = prove_repeated(2, &options, ResidencyMode::RecomputeLde); - let (_, _, small_retain, _) = prove_repeated(1, &options, ResidencyMode::Retain); - let (_, _, large_retain, _) = prove_repeated(2, &options, ResidencyMode::Retain); - - assert_eq!( - small_recompute.peak_trace_lde_bytes, large_recompute.peak_trace_lde_bytes, - "streaming the commitment rounds must make the trace-LDE peak independent of \ - how many tables the epoch has; it grew from {} to {} bytes", - small_recompute.peak_trace_lde_bytes, large_recompute.peak_trace_lde_bytes - ); - - // The control. Without it a ledger that simply never counted anything would - // pass the assertion above. - assert!( - large_retain.peak_trace_lde_bytes > small_retain.peak_trace_lde_bytes, - "the retaining arm must show the growth the recomputing arm avoids \ - ({} vs {} bytes) — otherwise the measurement cannot see residency at all", - small_retain.peak_trace_lde_bytes, - large_retain.peak_trace_lde_bytes - ); - assert!( - large_retain.peak_trace_lde_bytes > large_recompute.peak_trace_lde_bytes, - "at the same epoch the retaining arm must hold more than the recomputing one" - ); - - // The parts are `O(N)` by design and are accounted separately, so the claim - // above is about the trace LDEs and is not quietly absorbing them. - assert!( - large_recompute.retained_parts_bytes > small_recompute.retained_parts_bytes, - "the composition parts are retained per table and must be seen to grow" - ); -} - -/// The recompute budget, stated as a test so it cannot drift silently. Six -/// tables, five phases that read a trace LDE — the commit, constraint -/// evaluation, the OOD evaluations, the DEEP codeword and the query openings — -/// and no barrier between them can be removed, so `RecomputeLde` pays one -/// forward NTT per table per phase. -#[test_log::test] -fn the_recompute_budget_is_five_expansions_per_table() { - let options = folding_options(); - let (_, _, recompute, _) = prove_repeated(2, &options, ResidencyMode::RecomputeLde); - let (_, _, retain, _) = prove_repeated(2, &options, ResidencyMode::Retain); - - let tables = 6; - assert_eq!( - recompute.main_lde_expansions, - 4 * tables, - "main LDE: one FULL expansion per table per phase that reads the whole \ - LDE — phase 4 reads only the stride subsample and materializes the \ - size-n coset evaluation instead" - ); - assert_eq!( - recompute.aux_lde_expansions, - 4 * tables, - "aux LDE: every table in this fixture has a RAP, so the same four phases" - ); - assert_eq!( - recompute.main_coset_evals, tables, - "phase 4's cheap materialization, once per table" - ); - assert_eq!(recompute.aux_coset_evals, tables, "and its aux side"); - assert_eq!( - retain.main_lde_expansions, tables, - "retaining pays the floor: one expansion per table" - ); - assert_eq!( - retain.aux_lde_expansions, tables, - "retaining pays the floor for aux too" - ); - assert_eq!( - (retain.main_coset_evals, retain.aux_coset_evals), - (0, 0), - "retention serves phase 4 from the full LDE; the n-sized path is the \ - recompute arm's" - ); - for stats in [recompute, retain] { - assert_eq!( - stats.parts_computations, tables, - "composition parts are computed ONCE per table under either mode — \ - recomputing them would be a second constraint evaluation" - ); - } -} - -/// Residency is a performance choice and must not be a protocol one: the two -/// modes differ in when buffers exist, never in what is committed. -#[test_log::test] -fn residency_mode_does_not_move_any_batched_root() { - let options = folding_options(); - let (_, retained, _, _) = prove_repeated(1, &options, ResidencyMode::Retain); - let (_, recomputed, _, _) = prove_repeated(1, &options, ResidencyMode::RecomputeLde); - - assert_eq!(retained.main_root, recomputed.main_root); - assert_eq!(retained.aux_root, recomputed.aux_root); - assert_eq!(retained.parts_root, recomputed.parts_root); - assert_eq!(retained.fri_layer_roots, recomputed.fri_layer_roots); - assert_eq!( - retained.fri_final_poly_coeffs, - recomputed.fri_final_poly_coeffs - ); - // The NONCE is deliberately not compared: under `parallel` the grinding - // search races and any valid nonce may win, so it is nondeterministic - // between runs of the SAME mode — the per-table residency oracle excludes - // it for the same reason ("everything the grinding nonce cannot reach"). - // The transcript state the nonce grinds on IS compared, via every root - // and coefficient above. - #[cfg(not(feature = "parallel"))] - assert_eq!(retained.nonce, recomputed.nonce); -} - -/// A tampered row is rejected in EVERY round and at EVERY matrix, not only the -/// tallest one. A control that touched one matrix would pass even if the shorter -/// matrices were authenticated at the wrong leaf — which is precisely the silent -/// failure the index convention has. -#[test_log::test] -fn a_tampered_row_in_any_matrix_of_any_round_is_rejected() { - let options = folding_options(); - let (airs, proof, _, lengths) = prove_repeated(1, &options, ResidencyMode::Retain); - let shape = shape_of(&airs, &lengths); - let h_max = shape.h_max(); - let iota_0 = recover_iotas(&proof, &shape, h_max)[0]; - let query = &proof.queries[0]; - - for matrix in 0..shape.main.tables.len() { - let mut tampered = query.main.clone(); - tampered.per_matrix[matrix].evaluations[0] += FE::one(); - assert!( - !round_verifies(&proof.main_root, &tampered, &shape.main, iota_0, h_max), - "main round, matrix {matrix}: a tampered row must be rejected" - ); - } - for matrix in 0..shape.parts.tables.len() { - let mut tampered = query.parts.clone(); - tampered.per_matrix[matrix].evaluations_sym[0] += FieldElement::::one(); - assert!( - !round_verifies(&proof.parts_root, &tampered, &shape.parts, iota_0, h_max), - "parts round, matrix {matrix}: a tampered symmetric row must be rejected" - ); - } - let aux_root = proof.aux_root.expect("the fixture has a RAP"); - for matrix in 0..shape.aux.tables.len() { - let mut tampered = query.aux.clone().expect("the fixture has a RAP"); - tampered.per_matrix[matrix].evaluations[0] += FieldElement::::one(); - assert!( - !round_verifies(&aux_root, &tampered, &shape.aux, iota_0, h_max), - "aux round, matrix {matrix}: a tampered row must be rejected" - ); - } -} - -/// The shape a round is verified under is the verifier's, not the proof's. -/// Feeding a width the epoch did not commit must reject — this is the -/// boundary-shift forgery `fri/mmcs.rs`'s width binding closes, reached through -/// the prover for the first time. -#[test_log::test] -fn a_width_the_epoch_did_not_commit_is_rejected() { - let options = folding_options(); - let (airs, proof, _, lengths) = prove_repeated(1, &options, ResidencyMode::Retain); - let mut shape = shape_of(&airs, &lengths); - let h_max = shape.h_max(); - let iota_0 = recover_iotas(&proof, &shape, h_max)[0]; - - shape.main.dims[0].1 += 1; - assert!( - !round_verifies( - &proof.main_root, - &proof.queries[0].main, - &shape.main, - iota_0, - h_max - ), - "a main matrix width the epoch did not commit must be rejected" - ); -} - -// =========================================================================== -// The PREPROCESSED tables (per-table trees inside the batched proof) -// =========================================================================== -// -// Preprocessed matrices are NOT a round of the mixed MMCS: each preprocessed -// table keeps its own row-pair tree — the one `air.precomputed_commitment()` -// pins — and both sides absorb that root from the AIR set. What still does -// real index work is the per-table reduction: a preprocessed table shorter -// than the FRI is opened at `reduce_iota_to_round(iota, h_max, height)`, and -// `fri/mmcs.rs`'s warning stands — a wrong convention is self-consistent, so -// the un-reduced control below is what makes the reduction load-bearing. - -/// ADD and MUL, both declared preprocessed, at the SAME height but DIFFERENT -/// widths (2 and 3 precomputed columns). Each of those three facts is doing a -/// job: -/// -/// - **two tables**, so "per matrix" in the tamper control below is a real -/// quantifier rather than a loop that runs once; -/// - **different widths**, so the width binding (from the AIR set, never the -/// proof) is exercised at two distinct values; -/// - **both below CPU's height**, so the per-table reduction keeps doing real -/// work. -/// -/// ★ The per-AIR `precomputed_commitment()` values ARE read on the batched -/// path — that is the point of the per-table arrangement: the prover builds -/// each preprocessed table's own tree and fails the prove unless its root -/// equals the AIR's pinned value, and the verifier absorbs and compares those -/// same roots. The fixture therefore pins the REAL roots, computed by the same -/// routine the prover uses. -pub(crate) const PREP_WIDTHS: [usize; 2] = [2, 3]; - -/// The row-pair subset root over the first `width` columns of `trace`'s main -/// LDE — the value `air.precomputed_commitment()` must pin for the fixture to -/// prove. -fn real_prep_root(air: &Air, trace: &TraceTable, width: usize) -> crate::config::Commitment { - let (domain, twiddles) = crate::prover::domain_and_twiddles( - air as &dyn AIR, - trace.num_rows(), - ); - let (data, total_cols) = GenericProver::::expand_main_lde_row_major( - trace, - &domain, - &twiddles, - #[cfg(feature = "disk-spill")] - crate::storage_mode::StorageMode::Ram, - ); - GenericProver::::commit_rows_bit_reversed_subset::( - &data, total_cols, 0, width, - ) - .expect("the fixture trace has rows") - .1 -} - -fn preprocessed_epoch(options: &ProofOptions) -> (Vec, Vec>) { - let (cpu, add, mul) = traces(); - let add_air = new_add_air_with_lookup(options); - let mul_air = new_mul_air_with_lookup(options); - let add_root = real_prep_root(&add_air, &add, PREP_WIDTHS[0]); - let mul_root = real_prep_root(&mul_air, &mul, PREP_WIDTHS[1]); - let airs = vec![ - new_cpu_air_with_lookup(options), - add_air.with_preprocessed(add_root, PREP_WIDTHS[0]), - mul_air.with_preprocessed(mul_root, PREP_WIDTHS[1]), - ]; - (airs, vec![cpu, add, mul]) -} - -/// What a preprocessed-epoch prove hands back: the AIRs (borrowed by the shape -/// derivation), the proof, and the trace lengths the verifier would read off it. -pub(crate) type PreprocessedProve = (Vec, BatchedMultiProof, Vec); - -pub(crate) fn prove_preprocessed() -> Result { - let options = folding_options(); - let (airs, mut all_traces) = preprocessed_epoch(&options); - let unit = (); - let pairs: Vec<_> = airs - .iter() - .zip(all_traces.iter_mut()) - .map(|(air, trace)| { - ( - air as &dyn AIR, - trace, - &unit, - ) - }) - .collect(); - let (proof, _) = multi_prove_batched::< - F, - E, - (), - DefaultStarkHash, - GenericProver, - >( - pairs, - &mut DefaultTranscript::::new(&[]), - #[cfg(feature = "disk-spill")] - crate::storage_mode::StorageMode::Ram, - ResidencyMode::Retain, - )?; - Ok((airs, proof, vec![8, 4, 4])) -} - -/// Per-table authentication of one preprocessed opening — the verifier's own -/// three steps (width bind, leaf hash, path walk), restated so the tamper -/// controls can drive them one matrix and one column at a time. -fn prep_table_verifies( - root: &crate::config::Commitment, - o: &crate::proof::stark::PolynomialOpenings, - leaf: usize, - width: usize, -) -> bool { - use crate::config::StarkHash; - use crypto::merkle_tree::traits::IsStreamingLeafBackend; - o.evaluations.len() == width && o.evaluations_sym.len() == width && { - let leaf_hash = <::Batched as IsStreamingLeafBackend< - F, - >>::hash_data_from_slices(&o.evaluations, &o.evaluations_sym); - crypto::merkle_tree::proof::verify_merkle_path_from_leaf_hash::< - ::Batched, - >(&o.proof.merkle_path, root, leaf, leaf_hash) - } -} - -/// Honest path, plus the facts that make the rest of this section meaningful: -/// both preprocessed tables authenticate against the AIR's own pinned roots at -/// the reduced per-table index, the widths differ, and at least one table sits -/// strictly below the FRI so the reduction is non-trivial. -#[test_log::test] -fn the_preprocessed_tables_are_committed_and_authenticate() { - let (airs, proof, lengths) = prove_preprocessed().expect("an honest preprocessed epoch"); - let shape = shape_of(&airs, &lengths); - let h_max = shape.h_max(); - - assert_eq!( - shape.prep.widths(), - PREP_WIDTHS, - "two preprocessed tables at different widths" - ); - let prep_h_max = shape - .prep - .h_max() - .expect("the fixture has preprocessed tables"); - assert!( - prep_h_max < h_max, - "the reduction must be non-trivial (prep {prep_h_max}, fri {h_max})" - ); - - for (q, iota) in recover_iotas(&proof, &shape, h_max).into_iter().enumerate() { - let opening = &proof.queries[q]; - assert_eq!(opening.prep.len(), shape.prep.tables.len()); - for (k, &t) in shape.prep.tables.iter().enumerate() { - let leaf = crate::batched::round4::reduce_iota_to_round(iota, h_max, shape.heights[t]) - .expect("prep heights are a subset of table heights"); - assert!( - prep_table_verifies( - &airs[t].precomputed_commitment(), - &opening.prep[k], - leaf, - airs[t].num_precomputed_columns(), - ), - "query {q}, prep table {t}: must authenticate against the AIR's own root" - ); - } - } -} - -/// ★ The control the index convention needs. Reading a shorter preprocessed -/// table at the UN-reduced FRI index must fail — otherwise the reduction is -/// decoration and a prover free to pick either convention would be believed -/// under both. -#[test_log::test] -fn the_un_reduced_index_does_not_authenticate_a_preprocessed_table() { - let (airs, proof, lengths) = prove_preprocessed().expect("an honest preprocessed epoch"); - let shape = shape_of(&airs, &lengths); - let h_max = shape.h_max(); - - let mut any_differed = false; - for (q, iota) in recover_iotas(&proof, &shape, h_max).into_iter().enumerate() { - for (k, &t) in shape.prep.tables.iter().enumerate() { - let height = shape.heights[t]; - let reduced = crate::batched::round4::reduce_iota_to_round(iota, h_max, height) - .expect("prep heights are a subset of table heights"); - if reduced == iota { - continue; - } - any_differed = true; - assert!( - !prep_table_verifies( - &airs[t].precomputed_commitment(), - &proof.queries[q].prep[k], - iota, - airs[t].num_precomputed_columns(), - ), - "query {q}, prep table {t}: the un-reduced FRI index must not authenticate" - ); - } - } - assert!( - any_differed, - "at least one query must have a reduced index different from the raw one, \ - or this test never exercised the convention it exists for" - ); -} - -/// Per-matrix, per-column tamper control. The per-table arrangement must fail -/// if ANY single table's preprocessed value is wrong — the same quantifier the -/// fused-round design owed §3.3, kept under the new layout. -#[test_log::test] -fn a_tampered_precomputed_row_is_rejected_per_matrix() { - let (airs, proof, lengths) = prove_preprocessed().expect("an honest preprocessed epoch"); - let shape = shape_of(&airs, &lengths); - let h_max = shape.h_max(); - let iota_0 = recover_iotas(&proof, &shape, h_max)[0]; - - for (k, &t) in shape.prep.tables.iter().enumerate() { - let leaf = crate::batched::round4::reduce_iota_to_round(iota_0, h_max, shape.heights[t]) - .expect("prep heights are a subset of table heights"); - let root = airs[t].precomputed_commitment(); - let width = airs[t].num_precomputed_columns(); - let honest = &proof.queries[0].prep[k]; - assert!( - prep_table_verifies(&root, honest, leaf, width), - "honest-path control: prep table {t} must authenticate untampered" - ); - for column in 0..width { - let mut tampered = honest.clone(); - tampered.evaluations[column] += FE::one(); - assert!( - !prep_table_verifies(&root, &tampered, leaf, width), - "prep table {t}, column {column}: a tampered precomputed value \ - must be rejected" - ); - } - } -} - -/// A stale preprocessed constant fails the PROVE, not just every future -/// verify — the property the per-table path gets from `commit_main_trace`, -/// now unconditional on the batched path: the prover builds each preprocessed -/// tree and compares its root against the AIR's pinned value. (The old -/// registry-pin width tests have no analogue: widths come from the AIR set on -/// both sides, so there is no positionally-swappable width list left to pin.) -#[test_log::test] -fn a_stale_precomputed_constant_fails_the_prove() { - let options = folding_options(); - let (cpu, add, mul) = traces(); - let mul_air = new_mul_air_with_lookup(&options); - let mul_root = real_prep_root(&mul_air, &mul, PREP_WIDTHS[1]); - let airs = [ - new_cpu_air_with_lookup(&options), - // The stale constant: a root the trace's columns cannot reproduce. - new_add_air_with_lookup(&options).with_preprocessed([7u8; 32], PREP_WIDTHS[0]), - mul_air.with_preprocessed(mul_root, PREP_WIDTHS[1]), - ]; - let mut all_traces = [cpu, add, mul]; - let unit = (); - let pairs: Vec<_> = airs - .iter() - .zip(all_traces.iter_mut()) - .map(|(air, trace)| { - ( - air as &dyn AIR, - trace, - &unit, - ) - }) - .collect(); - let result = multi_prove_batched::< - F, - E, - (), - DefaultStarkHash, - GenericProver, - >( - pairs, - &mut DefaultTranscript::::new(&[]), - #[cfg(feature = "disk-spill")] - crate::storage_mode::StorageMode::Ram, - ResidencyMode::Retain, - ); - assert!( - matches!( - result, - Err(crate::prover::ProvingError::PrecomputedCommitmentMismatch) - ), - "a pinned root the trace cannot reproduce must fail the prove" - ); -} - -/// ★ The preprocessed round can NEVER be taller than the FRI, so -/// `reduce_iota_to_round`'s shift is never negative and no supplementary index -/// derivation is needed for it. -/// -/// This is a structural invariant of `EpochShape::derive`, not a property of any -/// fixture: a table's preprocessed matrix is pushed with the SAME `h` that goes -/// into `heights`, in the same loop iteration, so `prep.dims`'s heights are a -/// SUBSET of `heights` — and `EpochShape::h_max` is the max over all of -/// `heights`. A prep matrix at height H therefore implies a TABLE at height H, -/// which puts the FRI's `h_max` at H or above. -/// -/// Worth pinning because the obvious worry is wrong in an expensive direction. -/// A preprocessed table can be enormous — the LFM machine's BITWISE is 2^20 rows -/// in every registry entry — and it looks as though widening a preprocessed -/// round to include it could push the round above a small epoch's FRI. It cannot: -/// a preprocessed matrix only ever enters through a table that is itself in the -/// epoch at that height. `reduce_iota_to_round` fails closed on the inverted -/// case, so had this invariant not held, batched mode would have died on every -/// affected epoch rather than gone wrong quietly. -#[test_log::test] -fn the_preprocessed_round_is_never_taller_than_the_fri() { - let options = folding_options(); - - // The preprocessed fixture, where the round is strictly SHORTER. - let (airs, _proof, lengths) = prove_preprocessed().expect("an honest preprocessed epoch"); - let shape = shape_of(&airs, &lengths); - let prep_h = shape.prep.h_max().expect("non-empty"); - assert!( - prep_h < shape.h_max(), - "this fixture is the strictly-shorter case (prep {prep_h}, fri {})", - shape.h_max() - ); - assert!( - crate::batched::round4::reduce_iota_to_round(0, shape.h_max(), prep_h).is_some(), - "the reduction must be defined" - ); - - // ★ The equal case, which is the one a widened round produces: make the - // TALLEST table preprocessed. The round then reaches the FRI's own h_max and - // the shift is exactly zero — never negative. - let (cpu, add, mul) = traces(); - let tall_airs = vec![ - new_cpu_air_with_lookup(&options).with_preprocessed([5u8; 32], 2), - new_add_air_with_lookup(&options), - new_mul_air_with_lookup(&options), - ]; - let _ = (cpu, add, mul); - let tall = shape_of(&tall_airs, &[8, 4, 4]); - let tall_prep_h = tall.prep.h_max().expect("CPU is preprocessed"); - assert_eq!( - tall_prep_h, - tall.h_max(), - "a preprocessed tallest table puts the round AT the FRI's h_max" - ); - assert_eq!( - crate::batched::round4::reduce_iota_to_round(7, tall.h_max(), tall_prep_h), - Some(7), - "and the reduction is then the identity, not a negative shift" - ); - - // The invariant itself, over both shapes. - for s in [&shape, &tall] { - assert!( - s.prep.h_max().is_none_or(|h| h <= s.h_max()), - "prep heights are a subset of table heights, so the round can never \ - exceed the FRI" - ); - } -} - -// =========================================================================== -// The carved main matrix (the L2G carve-out's stark layer) -// =========================================================================== -mod carved { - use super::{Air, E, F, folding_options, traces}; - use crate::batched::proof::BatchedMultiProof; - use crate::batched::prover::multi_prove_batched_carved; - use crate::batched::verifier::{ - multi_verify_batched, multi_verify_batched_carved, replay_epoch_transcript_carved, - }; - use crate::config::DefaultStarkHash; - use crate::prover::{GenericProver, IsStarkProver}; - use crate::residency_mode::ResidencyMode; - use crate::traits::AIR; - use crate::verifier::GenericVerifier; - use crypto::fiat_shamir::default_transcript::DefaultTranscript; - use crypto::fiat_shamir::is_transcript::IsStarkTranscript; - use math::field::element::FieldElement; - - type P = GenericProver; - type V = GenericVerifier; - type Proof = BatchedMultiProof; - - /// The carved table of every test here: ADD (index 1), 4 rows — SHORTER - /// than the 8-row CPU, so `h_carved < h_max` and the index reduction on the - /// carved tree is exercised for real, never as the identity. - const CARVED: usize = 1; - - fn airs() -> Vec { - let options = folding_options(); - vec![ - super::new_cpu_air_with_lookup(&options), - super::new_add_air_with_lookup(&options), - super::new_mul_air_with_lookup(&options), - ] - } - - fn refs(airs: &[Air]) -> Vec<&dyn AIR> { - airs.iter() - .map(|a| a as &dyn AIR) - .collect() - } - - fn prove_carved() -> (Vec, Proof) { - let (mut cpu, mut add, mut mul) = traces(); - let airs = airs(); - let unit = (); - let pairs: Vec<_> = airs - .iter() - .zip([&mut cpu, &mut add, &mut mul]) - .map(|(air, trace)| { - ( - air as &dyn AIR, - trace, - &unit, - ) - }) - .collect(); - let (proof, _) = multi_prove_batched_carved::( - pairs, - &mut DefaultTranscript::::new(&[]), - #[cfg(feature = "disk-spill")] - crate::storage_mode::StorageMode::Ram, - ResidencyMode::Retain, - Some(CARVED), - ) - .expect("the carved fixture is a well-shaped epoch"); - (airs, proof) - } - - fn verifies_carved(airs: &[Air], proof: &Proof, carved: Option) -> bool { - multi_verify_batched_carved::( - &refs(airs), - proof, - &mut DefaultTranscript::::new(&[]), - &FieldElement::zero(), - carved, - ) - } - - /// ★★ Completeness: a carved epoch round-trips end to end — replay with the - /// proof-carried root in its slot, carved-opening authentication at the - /// reduced index, the carved row pair feeding the DEEP/FRI join. - #[test_log::test] - fn an_honest_carved_epoch_verifies_end_to_end() { - let (airs, proof) = prove_carved(); - assert!( - proof.carved_main_root.is_some(), - "the carve produced a root" - ); - assert!( - proof.queries.iter().all(|q| q.carved_main.is_some()), - "every query carries a carved opening" - ); - assert!(verifies_carved(&airs, &proof, Some(CARVED))); - } - - /// ★★ The differential gate — the property the L2G binding rests on: the - /// carved root is BYTE-IDENTICAL to the root the PER-TABLE prover commits - /// for the same table. (Main commitments precede every challenge, so the - /// two paths' different transcripts cannot make the roots differ; equality - /// here means the tree — blowup, leaf layout, row-pair order, hash — is - /// the same tree.) - #[test_log::test] - fn the_carved_root_is_byte_identical_to_the_per_table_tree() { - let (_, batched_proof) = prove_carved(); - - let (mut cpu, mut add, mut mul) = traces(); - let airs = airs(); - let unit = (); - let pairs: Vec<_> = airs - .iter() - .zip([&mut cpu, &mut add, &mut mul]) - .map(|(air, trace)| { - ( - air as &dyn AIR, - trace, - &unit, - ) - }) - .collect(); - let per_table = P::multi_prove( - pairs, - &mut DefaultTranscript::::new(&[]), - #[cfg(feature = "disk-spill")] - crate::storage_mode::StorageMode::Ram, - ResidencyMode::Retain, - ) - .expect("the fixture proves per-table"); - - let carved_root = batched_proof - .carved_main_root - .expect("the carve produced a root"); - assert_eq!( - per_table.proofs[CARVED].lde_trace_main_merkle_root, carved_root, - "the carved tree must be the per-table tree, byte for byte" - ); - // Sanity that the equality is discriminating, not vacuous: the OTHER - // tables' per-table roots are different trees. - assert_ne!(per_table.proofs[0].lde_trace_main_merkle_root, carved_root); - assert_ne!(per_table.proofs[2].lde_trace_main_merkle_root, carved_root); - } - - /// Tamper arm 1: one flipped byte of the proof-carried root is rejected. - #[test_log::test] - fn a_tampered_carved_root_is_rejected() { - let (airs, proof) = prove_carved(); - let mut tampered = proof.clone(); - tampered - .carved_main_root - .as_mut() - .expect("the carve produced a root")[0] ^= 1; - assert!(!verifies_carved(&airs, &tampered, Some(CARVED))); - } - - /// Tamper arm 2: one flipped element of an opened carved row is rejected. - #[test_log::test] - fn a_tampered_carved_opening_is_rejected() { - let (airs, proof) = prove_carved(); - let mut tampered = proof.clone(); - let o = tampered.queries[0] - .carved_main - .as_mut() - .expect("every query carries a carved opening"); - o.evaluations[0] += FieldElement::::one(); - assert!(!verifies_carved(&airs, &tampered, Some(CARVED))); - } - - /// The carve state is verifier-owned configuration: a carved proof checked - /// as uncarved is rejected, and an uncarved proof checked as carved is - /// rejected — in BOTH directions at the replay, before any challenge is - /// trusted. - #[test_log::test] - fn the_carve_state_must_match_the_verifiers_configuration() { - let (airs, carved_proof) = prove_carved(); - assert!( - !multi_verify_batched::( - &refs(&airs), - &carved_proof, - &mut DefaultTranscript::::new(&[]), - &FieldElement::zero(), - ), - "a carved proof must not pass an uncarved verifier" - ); - - let (uncarved_airs, uncarved_proof, _, _) = - super::prove_repeated(1, &folding_options(), ResidencyMode::Retain); - assert!( - !verifies_carved(&uncarved_airs, &uncarved_proof, Some(CARVED)), - "an uncarved proof must not pass a carved verifier" - ); - } - - /// The absorb-before-draw pin: the first challenge drawn after the roots - /// depends on the carved root's SLOT. Replaying the prefix with the carved - /// root moved after `main_root` produces a different challenge — the - /// transcript-ordering fact the whole carve rests on, demonstrated on the - /// transcript itself rather than asserted. - #[test_log::test] - fn the_carved_absorb_slot_is_load_bearing() { - let (airs, proof) = prove_carved(); - let refs = refs(&airs); - let trace_lengths: Vec = proof.tables.iter().map(|t| t.trace_length).collect(); - let (shape, _) = - crate::batched::shape::EpochShape::derive_carved(&refs, &trace_lengths, Some(CARVED)) - .expect("the fixture derives"); - let carved_root = proof.carved_main_root.expect("the carve produced a root"); - - // A fresh transcript, the histogram, the two roots in the given order, - // one draw. Generic over the transcript so the trait's methods resolve - // with both field parameters fixed. - fn draw_after>( - transcript: &mut T, - heights: &[usize], - widths: &[usize], - first: &crate::config::Commitment, - second: &crate::config::Commitment, - ) -> FieldElement { - crate::fri::batched::absorb_shape_histogram::(transcript, heights, widths); - transcript.append_bytes(first); - transcript.append_bytes(second); - transcript.sample_field_element() - } - - let challenge_in_order = draw_after( - &mut DefaultTranscript::::new(&[]), - &shape.heights, - &shape.total_widths(), - &carved_root, - &proof.main_root, - ); - let challenge_swapped = draw_after( - &mut DefaultTranscript::::new(&[]), - &shape.heights, - &shape.total_widths(), - &proof.main_root, - &carved_root, - ); - - assert_ne!( - challenge_in_order, challenge_swapped, - "moving the carved absorb after main_root must move every draw" - ); - } - - /// ★ The index-reduction CONVENTION pin, against independently computed - /// values: the carved opening at every query is the row pair - /// `(br(2·leaf), br(2·leaf + 1))` of the carved table's OWN main LDE at - /// `leaf = reduce(iota)` — recomputed here from the trace with none of the - /// verifier's shared reduction code in the loop. A self-consistent wrong - /// shift on both sides would authenticate and verify; THIS is the check - /// that fails it. - #[test_log::test] - fn the_carved_opening_is_the_reduced_leafs_row_pair() { - let (airs, proof) = prove_carved(); - let refs = refs(&airs); - let (shape, _, challenges) = replay_epoch_transcript_carved( - &refs, - &proof, - &mut DefaultTranscript::::new(&[]), - Some(CARVED), - ) - .expect("an honest carved proof replays"); - - // The carved table's main LDE, expanded independently. - let (_, add, _) = traces(); - let carved_air: &dyn AIR = &airs[CARVED]; - let (domain, twiddles) = crate::prover::domain_and_twiddles(carved_air, add.num_rows()); - let (lde, cols) = P::expand_main_lde_row_major( - &add, - &domain, - &twiddles, - #[cfg(feature = "disk-spill")] - crate::storage_mode::StorageMode::Ram, - ); - - let h_max = shape.h_max(); - let h_carved = shape.heights[CARVED]; - let lde_len = 1u64 << h_carved; - for (q, &iota) in challenges.fri.iotas.iter().enumerate() { - // The convention, written out literally: drop the low bits the - // taller domain has and the carved one does not. - let leaf = iota >> (h_max - h_carved); - let row = math::fft::bit_reversing::reverse_index(leaf * 2, lde_len); - let row_sym = math::fft::bit_reversing::reverse_index(leaf * 2 + 1, lde_len); - let opening = proof.queries[q] - .carved_main - .as_ref() - .expect("every query carries a carved opening"); - assert_eq!( - opening.evaluations, - lde[row * cols..(row + 1) * cols].to_vec(), - "query {q}: the opened row must be the reduced leaf's row" - ); - assert_eq!( - opening.evaluations_sym, - lde[row_sym * cols..(row_sym + 1) * cols].to_vec(), - "query {q}: the symmetric row must be the reduced leaf's pair" - ); - } - } -} diff --git a/crypto/stark/src/tests/mod.rs b/crypto/stark/src/tests/mod.rs index 10e2b4cbf..f2520e2c4 100644 --- a/crypto/stark/src/tests/mod.rs +++ b/crypto/stark/src/tests/mod.rs @@ -1,7 +1,5 @@ pub mod air_tests; pub mod aux_opening_width_tests; -pub mod batched_mmcs_soundness_tests; -pub mod batched_prover_tests; pub mod blake3_stark_roundtrip_tests; #[cfg(feature = "debug-checks")] pub mod bus_debug_tests; diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index e877acacd..344093233 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -281,9 +281,8 @@ pub trait IsStarkVerifier< } /// The three proof-derived inputs are passed as plain data rather than read - /// off a `StarkProofView`, because the batched epoch verifier - /// ([`crate::batched::verifier`]) has to run this identical check against a - /// proof that has no such view. One constraint check, two callers. + /// off a `StarkProofView`, so the check does not require its caller to hold + /// one. One constraint check, whatever the caller reads it from. #[allow(clippy::too_many_arguments)] fn step_2_verify_claimed_composition_polynomial( air: &dyn AIR, From 0556f34cea7245807c3c0df4a81d39feee6c9887 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Tue, 8 Sep 2026 02:27:57 -0300 Subject: [PATCH 6/7] fix(recursion): bump the input wire version to 3 for the one-format epoch proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsing `EpochProofBody` to a plain `MultiProof` changed the archived layout of `ContinuationGuestInput`: a v2 blob carries an enum discriminant at an offset a reader built after that commit does not expect. The version field is the DESIGNED guard for exactly that — its own doc records v1 to v2 as "32-bit offsets and are incompatible" — and leaving it at 2 meant the guard did not move with the format it guards. The visible cost was 17 failures in the `--lib` suite, all at `proof_fixture.rs:231` (`fixture blob must validate`). `proof_fixture::load_or_generate` returns the cached blob whenever `prefix_is_readable` passes, and that checks only the magic and the version, so a blob written by a pre-collapse build was handed back instead of regenerated and rkyv's validator rejected it downstream. At 3 the prefix check misses, the cache prints "not readable by this build — regenerating" and rewrites itself atomically, so it self-heals on every branch and every box with no manual eviction. The real reason is not the cache. `recursion_archive_bytes` exists so a wrong-format blob halts with a legible error "instead of surfacing whatever bytecheck makes of old-format bytes" — and the guest calls it before its unsafe in-place read. At version 2 a v2 blob passed that check and reached `rkyv::access`; at 3 it is refused where the refusal is legible. `cache_format_key` is deliberately NOT changed, though folding the format in would also have fixed the 17 (it keys `machine_tests::fixture_cache`'s filename). Its job is the axes that move the bytes WITHOUT moving the version — the statement domain tag and the commitment hash, both pin-level. A change that moves the version is already separated by the prefix check, so keying on it too would state one fact on two axes that can later drift apart. No literal 2 existed anywhere: every site reads the constant. --- prover/src/lib.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 8a9f12893..7cc4f4e31 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -293,9 +293,14 @@ pub struct GuestInput { /// 4-byte magic identifying a lambda-vm recursion input blob ("LVMR"). pub const RECURSION_INPUT_MAGIC: [u8; 4] = *b"LVMR"; -/// Wire-format version of the recursion input blob. v2: rkyv pointer_width_64 -/// (64-bit rel-ptrs) — v1 archives use 32-bit offsets and are incompatible. -pub const RECURSION_INPUT_VERSION: u32 = 2; +/// Wire-format version of the recursion input blob. +/// +/// - v1: rkyv 32-bit rel-ptrs. +/// - v2: rkyv pointer_width_64 (64-bit rel-ptrs) — v1 archives are incompatible. +/// - v3: one epoch proof format. `EpochProof::proof` is a `MultiProof` where v2 +/// had an `EpochProofBody` enum, so every v2 archive carries a discriminant at +/// an offset a v3 reader does not expect. +pub const RECURSION_INPUT_VERSION: u32 = 3; /// Required alignment (bytes) of the archive's first byte in guest memory. pub const RECURSION_INPUT_ALIGN: usize = 16; From 8531e2ef839204eea48a766bba9138e4597140ac Mon Sep 17 00:00:00 2001 From: MauroFab Date: Tue, 8 Sep 2026 14:35:30 -0300 Subject: [PATCH 7/7] docs(lfm): say what leaves prep_root unchecked, now that it is nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify_against_artifacts`'s "What it does NOT do yet" paragraph explained the gap by pointing at the other proof format: the preprocessed round was a commitment "only a verifier reading a `BatchedMultiProof` can use (`stark::batched::verifier`)", unchecked "until the machine switches paths". With that format gone from this branch there is no such proof, no such verifier, and no second path to switch to, so the sentence explained a situation that no longer exists — and a reader was told `prep_root` is plumbing ahead of a consumer that is never coming. The claim reads as current-state, so leaving it standing is worse than a dangling name: it misdescribes why a verification does not cover a field. The clause now states the standing fact — `prep_root` is a second commitment over the same preprocessed matrices and nothing reads it — and the trailing sentence stops promising a future consumer. The rest of the paragraph is untouched: it describes `prep_root`, which is still here, and goes with it when the registry's preprocessed round root goes. --- prover/src/lfm/proof.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/prover/src/lfm/proof.rs b/prover/src/lfm/proof.rs index f0c5a3a04..08ae5fa42 100644 --- a/prover/src/lfm/proof.rs +++ b/prover/src/lfm/proof.rs @@ -244,13 +244,12 @@ pub fn lfm_verify( /// /// It does not check `prep_root`. The LFM machine proves and verifies a /// per-table [`MultiProof`], whose openings are authenticated against the -/// per-slot `roots`; the batched preprocessed round is a commitment to the same -/// matrices that only a verifier reading a `BatchedMultiProof` can use -/// (`stark::batched::verifier`). Until the machine switches paths, this is -/// plumbing ahead of its consumer, and saying otherwise would overstate what a -/// passing verification means. +/// per-slot `roots`; `prep_root` is a second commitment over those same +/// preprocessed matrices, gathered into one multi-matrix round, and nothing +/// reads it. It is plumbing with no consumer, and saying otherwise would +/// overstate what a passing verification means. /// -/// The shape that consumer will need is [`LfmArtifacts::prep_round_shape`]. +/// The shape it commits to is [`LfmArtifacts::prep_round_shape`]. pub fn verify_against_artifacts( artifacts: &LfmArtifacts, proof: &MultiProof,