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" - ); - } - } -} 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, 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/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/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/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..678ed51f7 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 @@ -1334,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(), ) } @@ -1495,2012 +1379,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..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; @@ -77,8 +75,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 +96,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 +109,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/proof.rs b/prover/src/lfm/proof.rs index b57bc1976..08ae5fa42 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; @@ -247,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, @@ -428,80 +424,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 +445,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) -} 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(), - ); -} 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;