From 525bab2d621b70bba22f6dad0893b3e5796f23ef Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 6 Jul 2026 16:18:50 -0300 Subject: [PATCH 1/2] refactor(stark): derive the FRI fold layout in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early-termination fold layout (clamp, total_folds, num_committed, terminal_len, effective_k) was computed independently in three places — `commit_phase_from_evaluations` (CPU prover, from LDE size), `try_fri_commit_gpu` (GPU prover, from n0), and `fri_termination_params` (verifier, from trace bits) — with two different parameterizations whose equivalence lived only in comments. The verifier's own doc comment claimed the arithmetic was centralized "to prevent silent drift", but it was not: an edit to the clamp in one copy would break all proofs, or (worse) only GPU-produced ones, whose parity is not exercised in CI. Introduce `FriFoldLayout::new(lde_log, blowup_log, k)` in fri/terminal.rs and have all three callers derive the layout from it. The single formulation (`terminal_log = min(blowup_log + k, lde_log)`) is also overflow-safe by construction, removing the hand-rolled shift-overflow guards. As part of the same cleanup, the CPU prover now derives the terminal coset offset once as `coset_offset^(2^total_folds)` (matching the GPU and verifier) instead of tracking it by incremental squaring through the fold loop. Wire-identical: full stark prove/verify suite, FRI early-termination soundness tests, and multi-table roundtrip all pass; GPU path compiles under --features cuda. --- crypto/stark/src/fri/mod.rs | 57 +++++++++++++------------------- crypto/stark/src/fri/terminal.rs | 55 +++++++++++++++++++++++++++--- crypto/stark/src/gpu_lde.rs | 29 ++++++---------- crypto/stark/src/verifier.rs | 57 ++++++++++++++------------------ 4 files changed, 109 insertions(+), 89 deletions(-) diff --git a/crypto/stark/src/fri/mod.rs b/crypto/stark/src/fri/mod.rs index 0655363d2..8f1172524 100644 --- a/crypto/stark/src/fri/mod.rs +++ b/crypto/stark/src/fri/mod.rs @@ -72,30 +72,17 @@ where } } - // Determine how many total folds are needed to reach the terminal codeword. - // terminal_len = 2^(blowup_log + k), clamped to initial_len for tiny inputs. - let initial_len = evals.len(); - let k = final_poly_log_degree as usize; - // Compute `min(2^(blowup_log + k), initial_len)` without materializing the - // shift: an out-of-range `k` (prover self-misconfiguration) would make - // `2^(blowup_log + k)` overflow to 0, and the `initial_len / terminal_len` - // below would then divide by zero. Clamping to `initial_len` first degrades - // gracefully to no early termination, mirroring the verifier's own - // `expected_k = min(k, root_order)` clamp. - let terminal_shift = blowup_log as usize + k; - let terminal_len = if terminal_shift >= initial_len.trailing_zeros() as usize { - initial_len - } else { - 1usize << terminal_shift - }; - let total_folds = (initial_len / terminal_len).trailing_zeros() as usize; - let num_committed = total_folds.saturating_sub(1); + // Fold layout, shared with the GPU prover and the verifier — see `FriFoldLayout`. + let layout = crate::fri::terminal::FriFoldLayout::new( + evals.len().trailing_zeros(), + blowup_log, + final_poly_log_degree, + ); + let num_committed = layout.num_committed; // Inverse twiddle factors for evaluation-form folding. let mut inv_twiddles = compute_coset_twiddles_inv(coset_offset, domain_size); let mut fri_layer_list = Vec::with_capacity(num_committed); - // Track the coset offset as it squares with each fold (needed for iFFT in terminal). - let mut terminal_offset = coset_offset.clone(); // Commit `num_committed` folded layers to the transcript. for _ in 0..num_committed { @@ -118,36 +105,38 @@ where // >>>> Send commitment: [pₖ] transcript.append_bytes(&root); - // Update twiddles and offset for the next level. + // Update twiddles for the next level. update_twiddles_in_place(&mut inv_twiddles); - terminal_offset = terminal_offset.square(); } // One final fold to reach the terminal codeword (size terminal_len), unless // already there (total_folds == 0 means initial_len == terminal_len). - if total_folds > 0 { + if layout.total_folds > 0 { // <<<< Receive challenge: 𝜁_final let zeta = transcript.sample_field_element(); fold_evaluations_in_place(&mut evals, &zeta, &inv_twiddles); - terminal_offset = terminal_offset.square(); } - debug_assert_eq!(evals.len(), terminal_len, "terminal codeword size mismatch"); + debug_assert_eq!( + evals.len(), + layout.terminal_len, + "terminal codeword size mismatch" + ); // Recover the low-degree polynomial coefficients from the terminal codeword // and send them to the verifier. // - // The number of coefficients is determined by the *actual* terminal codeword, - // not the requested `final_poly_log_degree`: for tiny inputs `terminal_len` - // is clamped to `initial_len`, so the terminal polynomial has degree - // < terminal_len / 2^blowup_log = 2^(log2(terminal_len) - blowup_log). Using - // this clamped exponent keeps the coefficient count in lockstep with what the - // verifier reconstructs (`expected_k = min(k, trace_bits)`); passing the raw - // `final_poly_log_degree` would over-pad with zeros and break the round-trip. - let effective_log_degree = terminal_len.trailing_zeros() - blowup_log; + // The coefficient count follows the *actual* terminal codeword via + // `layout.effective_k` (`min(k, trace_bits)`), not the requested + // `final_poly_log_degree`: for tiny inputs the codeword is clamped to the + // full LDE, so passing the raw `k` would over-pad with zeros and break the + // round-trip against the verifier's own `expected_k` reconstruction. + // The terminal coset offset is `coset_offset^(2^total_folds)` — the offset + // after `total_folds` squarings (matches the GPU prover and the verifier). + let terminal_offset = coset_offset.pow(1u64 << layout.total_folds); let final_poly_coeffs = crate::fri::terminal::coeffs_from_terminal_codeword::( &evals, &terminal_offset, - effective_log_degree, + layout.effective_k, ); for c in &final_poly_coeffs { transcript.append_field_element(c); diff --git a/crypto/stark/src/fri/terminal.rs b/crypto/stark/src/fri/terminal.rs index f47875556..716fbcf3d 100644 --- a/crypto/stark/src/fri/terminal.rs +++ b/crypto/stark/src/fri/terminal.rs @@ -1,14 +1,59 @@ -//! Conversion helpers between a FRI terminal codeword and the coefficients of -//! the low-degree polynomial it encodes. -//! -//! These are pure, self-contained helpers — no transcript, no FRI logic. -//! They are used by the prover (`commit_phase_from_evaluations`) and verifier FRI step. +//! Shared, pure FRI early-termination helpers used by both the prover +//! (`commit_phase_from_evaluations`, `try_fri_commit_gpu`) and the verifier +//! (`step_3_verify_fri`): the fold layout (`FriFoldLayout`) and the conversion +//! between a terminal codeword and the coefficients of the low-degree +//! polynomial it encodes. No transcript, no FRI protocol state. use math::fft::bit_reversing::{in_place_bit_reverse_permute, reverse_index}; use math::field::element::FieldElement; use math::field::traits::{IsFFTField, IsField, IsSubFieldOf}; use math::polynomial::Polynomial; +/// The FRI early-termination fold layout. +/// +/// Derived identically by the CPU prover (`commit_phase_from_evaluations`), the +/// GPU prover (`try_fri_commit_gpu`), and the verifier (`fri_termination_params`). +/// Keeping the arithmetic in one place is load-bearing: the three callers must +/// agree exactly or proofs fail to verify, and a CPU/GPU disagreement would +/// surface only on GPU machines. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct FriFoldLayout { + /// Folds from the LDE codeword down to the terminal codeword. + pub(crate) total_folds: u32, + /// Committed (Merkle-rooted) FRI layers = `total_folds - 1`, or 0 when there + /// is no fold or only a single final fold. + pub(crate) num_committed: usize, + /// Terminal codeword length = `2^(blowup_log + effective_k)`. + pub(crate) terminal_len: usize, + /// Terminal polynomial log-degree bound actually used, `min(k, trace_bits)`. + /// This is the verifier's `expected_k` and the prover's `effective_log_degree`. + pub(crate) effective_k: u32, +} + +impl FriFoldLayout { + /// Derive the layout from the LDE codeword size. + /// + /// * `lde_log` — log2 of the LDE (deep-composition) codeword length. + /// * `blowup_log` — log2 of the LDE blowup factor. + /// * `k` — requested `fri_final_poly_log_degree`. + /// + /// Folding stops once the codeword encodes a polynomial of degree `< 2^k`, + /// i.e. at codeword length `2^(blowup_log + k)`, clamped to the full LDE + /// size for traces too small to fold that far (the `.min(lde_log)`). + /// Computing `blowup_log + k` in `u32` (both small) sidesteps the + /// `1 << (blowup_log + k)` overflow an out-of-range `k` would otherwise cause. + pub(crate) fn new(lde_log: u32, blowup_log: u32, k: u32) -> Self { + let terminal_log = (blowup_log + k).min(lde_log); + let total_folds = lde_log - terminal_log; + Self { + total_folds, + num_committed: total_folds.saturating_sub(1) as usize, + terminal_len: 1usize << terminal_log, + effective_k: terminal_log - blowup_log, + } + } +} + /// Prover side: given a FRI terminal codeword in **bit-reversed** order, /// recover the `2^final_poly_log_degree` coefficients of the underlying /// low-degree polynomial. diff --git a/crypto/stark/src/gpu_lde.rs b/crypto/stark/src/gpu_lde.rs index ac11b63dc..e07fb8ec6 100644 --- a/crypto/stark/src/gpu_lde.rs +++ b/crypto/stark/src/gpu_lde.rs @@ -1629,27 +1629,21 @@ where // produced had this dispatch never been called. let transcript_snapshot = transcript.clone(); - // Early-termination schedule (mirrors commit_phase_from_evaluations): - // terminal_len = 2^(blowup_log + final_poly_log_degree), clamped to n0. - // Clamp without materializing the shift so an out-of-range `k` cannot - // overflow `terminal_len` to 0 (see `commit_phase_from_evaluations`). - let k = final_poly_log_degree as usize; - let terminal_shift = blowup_log as usize + k; - let terminal_len = if terminal_shift >= n0.trailing_zeros() as usize { - n0 - } else { - 1usize << terminal_shift - }; - let total_folds = (n0 / terminal_len).trailing_zeros() as usize; + // Fold layout, shared with the CPU prover and the verifier — see `FriFoldLayout`. + let layout = crate::fri::terminal::FriFoldLayout::new( + n0.trailing_zeros(), + blowup_log, + final_poly_log_degree, + ); // The GPU path only runs above gpu_lde_threshold(). Two cases fall back to // the CPU path (which handles both correctly): tiny clamped traces // (total_folds == 0), and terminal_len == 1 (blowup_log + k == 0), whose // final fold would reach n_out == 1 and trip `fold_and_commit_layer`'s // `n_out >= 2` assert. The final fold below is therefore always n_out >= 2. - if total_folds == 0 || terminal_len < 2 { + if layout.total_folds == 0 || layout.terminal_len < 2 { return None; } - let num_committed = total_folds - 1; + let num_committed = layout.num_committed; let mut fri_layer_list: Vec>> = Vec::with_capacity(num_committed); @@ -1696,16 +1690,15 @@ where return None; } }; - debug_assert_eq!(terminal_evals_u64.len(), terminal_len * 3); + debug_assert_eq!(terminal_evals_u64.len(), layout.terminal_len * 3); let terminal_codeword = u64_to_ext3_vec::(&terminal_evals_u64); // CPU-side coefficient extraction, identical to commit_phase_from_evaluations. - let terminal_offset = coset_offset.pow(1u64 << total_folds); - let effective_log_degree = terminal_len.trailing_zeros() - blowup_log; + let terminal_offset = coset_offset.pow(1u64 << layout.total_folds); let final_poly_coeffs = crate::fri::terminal::coeffs_from_terminal_codeword::( &terminal_codeword, &terminal_offset, - effective_log_degree, + layout.effective_k, ); // >>>> Send the final polynomial coefficients. diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index cd0f73639..7f32e7750 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -236,24 +236,23 @@ pub trait IsStarkVerifier< composition_poly_claimed_ood_evaluation == composition_poly_ood_evaluation } - /// FRI termination params derived from options + domain: `(blowup_log, expected_k, total_folds)`. + /// The FRI fold layout for this proof, derived from options + domain. /// - /// * `blowup_log` - log2 of the LDE blowup factor. - /// * `expected_k` - clamped final-poly log-degree: `min(k, trace_bits)`. - /// * `total_folds` - number of FRI folds performed: `trace_bits - expected_k`. - /// - /// Both the commit phase (prover) and the Fiat-Shamir replay (verifier) must use - /// the same computation; having it in one place prevents silent drift between the two - /// callers that would break all proofs. + /// Delegates to the shared [`crate::fri::terminal::FriFoldLayout`] so the + /// verifier's Fiat-Shamir replay and structural checks use exactly the same + /// arithmetic as the CPU and GPU provers; drift between them would break all + /// proofs. `VerifierDomain.lde_length` is the codeword size and + /// `lde_length / trace_length` the blowup factor. + // `FriFoldLayout` is a crate-internal helper type returned from a default method + // of this public trait; the exposure is intentional (internal helper). + #[allow(private_interfaces)] fn fri_termination_params( air: &dyn AIR, domain: &VerifierDomain, - ) -> (u32, u32, u32) { + ) -> crate::fri::terminal::FriFoldLayout { let k = air.options().fri_final_poly_log_degree as u32; let blowup_log = (domain.lde_length / domain.trace_length).trailing_zeros(); - let expected_k = k.min(domain.root_order); - let total_folds = domain.root_order - expected_k; - (blowup_log, expected_k, total_folds) + crate::fri::terminal::FriFoldLayout::new(domain.lde_length.trailing_zeros(), blowup_log, k) } /// Reconstructs the Deep composition polynomial evaluations at the challenge indices values using the provided @@ -280,21 +279,19 @@ pub trait IsStarkVerifier< // ---- Reconstruct the FRI terminal codeword from the final-poly coeffs ---- // The prover folds the deep composition codeword down to a terminal - // codeword of length `terminal_len = 2^(blowup_log + expected_k)` and sends the - // `2^expected_k` coefficients of the low-degree polynomial it encodes. - // `VerifierDomain.root_order` is `log2(trace_length)` (trace bits), and the - // LDE blowup adds `blowup_log` bits. - let (blowup_log, expected_k, total_folds) = Self::fri_termination_params(air, domain); - let num_committed = total_folds.saturating_sub(1) as usize; + // codeword of length `terminal_len = 2^(blowup_log + effective_k)` and sends + // the `2^effective_k` coefficients of the low-degree polynomial it encodes. + let layout = Self::fri_termination_params(air, domain); + let num_committed = layout.num_committed; // Structural check: number of committed FRI layers must equal // `num_committed` (zero when no fold or a single final fold happened). if proof.fri_layers_merkle_roots.len() != num_committed { return false; } - // Structural check: the final polynomial must have exactly `2^expected_k` + // Structural check: the final polynomial must have exactly `2^effective_k` // coefficients; otherwise the reconstruction below is ill-defined. - if proof.fri_final_poly_coeffs.len() != (1usize << expected_k) { + if proof.fri_final_poly_coeffs.len() != (1usize << layout.effective_k) { return false; } // Structural check: every per-query FRI decommitment must carry exactly @@ -312,14 +309,13 @@ pub trait IsStarkVerifier< return false; } - let terminal_len = (1usize << blowup_log) << expected_k; - let terminal_offset = domain.coset_offset.pow(1u64 << total_folds); - let terminal_codeword = crate::fri::terminal::terminal_codeword_from_coeffs::< - Field, - FieldExtension, - >( - &proof.fri_final_poly_coeffs, &terminal_offset, terminal_len - ); + let terminal_offset = domain.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, + ); // verify FRI let mut evaluation_point_inverse = challenges @@ -1098,10 +1094,7 @@ pub trait IsStarkVerifier< // actually folds past the committed layers. For tiny traces (the clamp // case) no fold happens, so no challenge is drawn. This must mirror the // prover's `commit_phase_from_evaluations` exactly. - // - // `VerifierDomain.root_order` is `log2(trace_length)` (trace bits). The - // number of total folds equals `trace_bits - min(k, trace_bits)`. - let (_, _, total_folds) = Self::fri_termination_params(air, domain); + let total_folds = Self::fri_termination_params(air, domain).total_folds; // >>>> Send final-fold challenge 𝜁_final (only when folding occurs) if total_folds > 0 { From 7c39c7b5ab3ff3ebe7422a7158625cf8a3605db7 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Mon, 6 Jul 2026 16:19:41 -0300 Subject: [PATCH 2/2] refactor(stark): hoist the FRI terminal-codeword check out of the fold loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify_query_and_sym_openings` checked each query against the reconstructed terminal codeword in two places — a dedicated early return for the single-fold (`total_folds == 1`) regime, and the last-iteration `else` arm inside the fold loop for the multi-fold regime. Two hand-synced copies of a soundness-critical comparison (the last-iteration arm being exactly where the padded-decommitment bypass the PR guards against would land) is a drift risk. After the fold loop, `v` and `index` already hold the query's terminal-layer value and position in every regime, so a single check hoisted after the loop covers both — deleting the `is_empty()` early return and the `i < len - 1` last-iteration branch. The per-query decommitment length checks in `step_3_verify_fri` (which pin the fold count) make this behaviour-identical. Verified by the full FRI early-termination soundness suite (empty/padded/ truncated/over-length decommitment rejection across the no-fold, single-fold, and multi-fold regimes) and the prove/verify roundtrips. --- crypto/stark/src/verifier.rs | 101 +++++++++++++++++------------------ 1 file changed, 48 insertions(+), 53 deletions(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 7f32e7750..64c01b17a 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -558,61 +558,56 @@ pub trait IsStarkVerifier< (p0_eval + p0_eval_sym) + evaluation_point_inv * &zetas[0] * (p0_eval - p0_eval_sym); let mut index = iota; - // Handle case with 0 committed FRI layers but a single final fold - // (`total_folds == 1`). The fold loop below doesn't iterate, so we compare - // the folded value `v` against the reconstructed terminal codeword at the - // query's terminal-layer position (`index == iota`) directly. - if fri_layers_merkle_roots.is_empty() { - return terminal_codeword.get(index).is_some_and(|t| &v == t); - } + // Fold through every committed layer: use the proof to verify the openings + // of pᵢ(−𝜐^(2ⁱ)) (given by the prover) and pᵢ(𝜐^(2ⁱ)) (computed on the + // previous iteration), then obtain pᵢ₊₁(𝜐^(2ⁱ⁺¹)). When there are no + // committed layers (`total_folds == 1`, a single final fold) this fold is + // empty and `v`/`index` already hold the terminal-layer value/position. + let openings_ok = + fri_layers_merkle_roots + .iter() + .enumerate() + .zip(&fri_decommitment.layers_auth_paths) + .zip(&fri_decommitment.layers_evaluations_sym) + .zip(evaluation_point_vec) + .fold( + true, + |result, + ( + (((i, merkle_root), auth_path_sym), evaluation_sym), + evaluation_point_inv, + )| { + // Verify opening Open(pᵢ(Dₖ), −𝜐^(2ⁱ)) and Open(pᵢ(Dₖ), 𝜐^(2ⁱ)). + // `v` is pᵢ(𝜐^(2ⁱ)). + // `evaluation_sym` is pᵢ(−𝜐^(2ⁱ)). + let openings_ok = Self::verify_fri_layer_openings( + merkle_root, + auth_path_sym, + &v, + evaluation_sym, + index, + ); + + // Update `v` with next value pᵢ₊₁(𝜐^(2ⁱ⁺¹)). + v = (&v + evaluation_sym) + + evaluation_point_inv * &zetas[i + 1] * (&v - evaluation_sym); + + // Update index for next iteration. The index of the squares in the next layer + // is obtained by halving the current index. This is due to the bit-reverse + // ordering of the elements in the Merkle tree. + index >>= 1; - // For each FRI layer, starting from the layer 1: use the proof to verify the validity of values pᵢ(−𝜐^(2ⁱ)) (given by the prover) and - // pᵢ(𝜐^(2ⁱ)) (computed on the previous iteration by the verifier). Then use them to obtain pᵢ₊₁(𝜐^(2ⁱ⁺¹)). - // Finally, check that the final value coincides with the given by the prover. - fri_layers_merkle_roots - .iter() - .enumerate() - .zip(&fri_decommitment.layers_auth_paths) - .zip(&fri_decommitment.layers_evaluations_sym) - .zip(evaluation_point_vec) - .fold( - true, - |result, - ( - (((i, merkle_root), auth_path_sym), evaluation_sym), - evaluation_point_inv, - )| { - // Verify opening Open(pᵢ(Dₖ), −𝜐^(2ⁱ)) and Open(pᵢ(Dₖ), 𝜐^(2ⁱ)). - // `v` is pᵢ(𝜐^(2ⁱ)). - // `evaluation_sym` is pᵢ(−𝜐^(2ⁱ)). - let openings_ok = Self::verify_fri_layer_openings( - merkle_root, - auth_path_sym, - &v, - evaluation_sym, - index, - ); - - // Update `v` with next value pᵢ₊₁(𝜐^(2ⁱ⁺¹)). - v = (&v + evaluation_sym) + evaluation_point_inv * &zetas[i + 1] * (&v - evaluation_sym); - - // Update index for next iteration. The index of the squares in the next layer - // is obtained by halving the current index. This is due to the bit-reverse - // ordering of the elements in the Merkle tree. - index >>= 1; - - if i < fri_decommitment.layers_evaluations_sym.len() - 1 { result & openings_ok - } else { - // Last committed layer: `v` is now the folded value at the - // terminal layer and `index` (after the final `index >>= 1`) - // is its FRI-order position there. Check it against the - // reconstructed terminal codeword. - let terminal_ok = terminal_codeword.get(index).is_some_and(|t| &v == t); - result & terminal_ok & openings_ok - } - }, - ) + }, + ); + + // After folding through all committed layers, `v` is the query's value at + // the terminal layer and `index` its FRI-order position there. Check it + // against the reconstructed terminal codeword. This single check covers + // both the single-fold (`total_folds == 1`, empty fold above) and + // multi-fold regimes; `.get()` fails closed on an out-of-range index. + let terminal_ok = terminal_codeword.get(index).is_some_and(|t| &v == t); + openings_ok & terminal_ok } fn reconstruct_deep_composition_poly_evaluations_for_all_queries(