From 28ae169fbf20fac04ceac8d65887bcf166e6c2d0 Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Wed, 15 Jul 2026 18:24:35 -0300 Subject: [PATCH 1/7] perf(stark): fuse and hoist deep-composition reconstruction for both FRI points reconstruct_deep_composition_poly_evaluation walked the OOD table and trace-term coefficients, and inverted denominators, independently for the regular and symmetric evaluation points, then recomputed the point-invariant OOD/gamma sums from scratch on every one of the ~80 FRI queries per proof. Rewriting coeff*(base-ood)*denom as denom*(coeff*base - coeff*ood) isolates the point-independent coeff*ood term (identical between the two points) from coeff*base, so both points can share the OOD walk and a single batch-inverse. The point-invariant ood_row_sum/z_pow/h_sum_zpow sums are now computed once per proof in compute_query_invariant_deep_terms instead of once per query. Multi-query recursion profile: 1,325,335,927 -> 916,824,543 cycles (-30.8%); step 3 (FRI) 635,418,644 -> 233,869,693 cycles (-63.2%). --- crypto/stark/src/verifier.rs | 269 ++++++++++++++++++++++++----------- 1 file changed, 188 insertions(+), 81 deletions(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 75387d395..a4eb0fd39 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -84,6 +84,22 @@ where pub type DeepPolynomialEvaluations = (Vec>, Vec>); +/// Deep-composition sums that are identical across all FRI queries of a +/// single proof (see `compute_query_invariant_deep_terms`). +pub struct QueryInvariantDeepTerms +where + FieldExtension: Send + Sync + IsField, +{ + /// `ood_row_sum[row] = sum_col trace_term_coeffs[col][row] * ood(row, col)`. + ood_row_sum: Vec>, + /// Derived from `proof.composition_poly_parts_ood_evaluation().len()`. + number_of_parts: usize, + /// `challenges.z.pow(number_of_parts)`. + z_pow: FieldElement, + /// `sum_j composition_poly_parts_ood_evaluation[j] * challenges.gammas[j]`. + h_sum_zpow: FieldElement, +} + // The verifier reads proofs in place from their rkyv archive; archived field // elements are viewed as native ones, which is only valid on little-endian. #[cfg(not(target_endian = "little"))] @@ -649,6 +665,60 @@ pub trait IsStarkVerifier< openings_ok & terminal_ok } + /// Sums that depend only on `challenges` and proof-level OOD/gamma data — + /// identical for every FRI query — computed once instead of once per + /// query. + fn compute_query_invariant_deep_terms( + challenges: &Challenges, + proof: StarkProofView<'_, Field, FieldExtension, PI>, + ) -> Option> { + let trace_ood_evaluations = proof.trace_ood_evaluations(); + let ood_evaluations_table_height = trace_ood_evaluations.height(); + let ood_evaluations_table_width = trace_ood_evaluations.width(); + let ood_data = trace_ood_evaluations.row_major_data(); + let trace_term_coeffs = &challenges.trace_term_coeffs; + + if trace_term_coeffs.is_empty() + || trace_term_coeffs.len() * trace_term_coeffs[0].len() + != ood_evaluations_table_height * ood_evaluations_table_width + { + return None; + } + + let mut ood_row_sum = Vec::with_capacity(ood_evaluations_table_height); + for row_idx in 0..ood_evaluations_table_height { + let ood_row = &ood_data[row_idx * ood_evaluations_table_width + ..(row_idx + 1) * ood_evaluations_table_width]; + let mut sum = FieldElement::::zero(); + for col_idx in 0..ood_evaluations_table_width { + sum += &trace_term_coeffs[col_idx][row_idx] * &ood_row[col_idx]; + } + ood_row_sum.push(sum); + } + + let composition_parts_ood = proof.composition_poly_parts_ood_evaluation(); + let number_of_parts = composition_parts_ood.len(); + let z_pow = challenges.z.pow(number_of_parts); + + // A malformed proof/challenge set can advertise more composition + // parts than sampled gammas; reject rather than silently truncate + // the sum below. + if challenges.gammas.len() < number_of_parts { + return None; + } + let mut h_sum_zpow = FieldElement::::zero(); + for (h_i_zpower, gamma) in composition_parts_ood.iter().zip(challenges.gammas.iter()) { + h_sum_zpow += h_i_zpower * gamma; + } + + Some(QueryInvariantDeepTerms { + ood_row_sum, + number_of_parts, + z_pow, + h_sum_zpow, + }) + } + fn reconstruct_deep_composition_poly_evaluations_for_all_queries( challenges: &Challenges, domain: &VerifierDomain, @@ -676,6 +746,8 @@ pub trait IsStarkVerifier< let primitive_root = &Field::get_primitive_root_of_unity(domain.root_order as u64) .expect("verifier domain root_order is a valid power of two"); + let query_invariant_terms = Self::compute_query_invariant_deep_terms(challenges, proof)?; + for (i, iota) in challenges.iotas.iter().enumerate() { let opening = proof.deep_poly_opening(i); @@ -694,19 +766,6 @@ pub trait IsStarkVerifier< .map(|a| a.evaluations()) .unwrap_or(&[]); - let evaluation_point = Self::query_challenge_to_evaluation_point(*iota, false, domain); - deep_poly_evaluations.push(Self::reconstruct_deep_composition_poly_evaluation( - proof, - &evaluation_point, - primitive_root, - challenges, - lde_precomputed, - lde_main, - lde_aux, - opening.composition_poly().evaluations(), - )?); - - // Mirror for the symmetric query point. let lde_precomputed_sym: &[FieldElement] = opening .precomputed_trace_polys() .map(|p| p.evaluations_sym()) @@ -718,43 +777,62 @@ pub trait IsStarkVerifier< .map(|a| a.evaluations_sym()) .unwrap_or(&[]); - let evaluation_point = Self::query_challenge_to_evaluation_point(*iota, true, domain); - deep_poly_evaluations_sym.push(Self::reconstruct_deep_composition_poly_evaluation( - proof, - &evaluation_point, - primitive_root, - challenges, - lde_precomputed_sym, - lde_main_sym, - lde_aux_sym, - opening.composition_poly().evaluations_sym(), - )?); + let evaluation_point = Self::query_challenge_to_evaluation_point(*iota, false, domain); + let evaluation_point_sym = + Self::query_challenge_to_evaluation_point(*iota, true, domain); + let (evaluation, evaluation_sym) = + Self::reconstruct_deep_composition_poly_evaluation_pair( + proof, + &evaluation_point, + &evaluation_point_sym, + primitive_root, + challenges, + &query_invariant_terms, + lde_precomputed, + lde_main, + lde_aux, + opening.composition_poly().evaluations(), + lde_precomputed_sym, + lde_main_sym, + lde_aux_sym, + opening.composition_poly().evaluations_sym(), + )?; + deep_poly_evaluations.push(evaluation); + deep_poly_evaluations_sym.push(evaluation_sym); } Some((deep_poly_evaluations, deep_poly_evaluations_sym)) } + /// Reconstructs the deep composition polynomial evaluation at a query's + /// point and its symmetric counterpart together. Rewriting the per-element + /// trace term `coeff*(base-ood)*denom` as `denom*(coeff*base - coeff*ood)` + /// isolates `coeff*ood` (identical for both points, hoisted into + /// `query_invariant_terms`) from `coeff*base` (per-point), so both points + /// share the OOD walk and a single batch-inverse for their denominators. #[allow(clippy::too_many_arguments)] - fn reconstruct_deep_composition_poly_evaluation<'b>( + fn reconstruct_deep_composition_poly_evaluation_pair<'b>( proof: StarkProofView<'_, Field, FieldExtension, PI>, evaluation_point: &FieldElement, + evaluation_point_sym: &FieldElement, primitive_root: &FieldElement, challenges: &Challenges, + query_invariant_terms: &QueryInvariantDeepTerms, lde_trace_precomputed_evaluations: &'b [FieldElement], lde_trace_main_evaluations: &'b [FieldElement], lde_trace_aux_evaluations: &[FieldElement], lde_composition_poly_parts_evaluation: &[FieldElement], - ) -> Option> { - let trace_ood_evaluations = proof.trace_ood_evaluations(); - let ood_evaluations_table_height = trace_ood_evaluations.height(); - let ood_evaluations_table_width = trace_ood_evaluations.width(); - // Hot loop below: resolve the OOD data to one flat slice once instead - // of re-deriving a row slice per element. - let ood_data = trace_ood_evaluations.row_major_data(); + lde_trace_precomputed_evaluations_sym: &'b [FieldElement], + lde_trace_main_evaluations_sym: &'b [FieldElement], + lde_trace_aux_evaluations_sym: &[FieldElement], + lde_composition_poly_parts_evaluation_sym: &[FieldElement], + ) -> Option<(FieldElement, FieldElement)> { + let ood_evaluations_table_height = query_invariant_terms.ood_row_sum.len(); + let ood_evaluations_table_width = proof.trace_ood_evaluations().width(); let trace_term_coeffs = &challenges.trace_term_coeffs; // Base columns are supplied as two slices (precomputed ‖ main) that the - // prover concatenated in this order; `num_base` is their combined width - // and `base_at` indexes into them as if concatenated, without allocating. + // prover concatenated in this order; `num_base`/`base_at` index into + // them as if concatenated, without allocating. let num_precomputed = lde_trace_precomputed_evaluations.len(); let num_base = num_precomputed + lde_trace_main_evaluations.len(); let base_at = move |col: usize| -> &'b FieldElement { @@ -764,68 +842,97 @@ pub trait IsStarkVerifier< &lde_trace_main_evaluations[col - num_precomputed] } }; + let num_precomputed_sym = lde_trace_precomputed_evaluations_sym.len(); + let num_base_sym = num_precomputed_sym + lde_trace_main_evaluations_sym.len(); + let base_at_sym = move |col: usize| -> &'b FieldElement { + if col < num_precomputed_sym { + &lde_trace_precomputed_evaluations_sym[col] + } else { + &lde_trace_main_evaluations_sym[col - num_precomputed_sym] + } + }; - // Runtime guard: a malformed proof may supply opening evaluations whose - // column count does not match the OOD table width, or whose composition - // poly parts count does not match the proof's `composition_poly_parts_ood_evaluation`. - // Without these checks the indexing below would panic in release builds. - if num_base + lde_trace_aux_evaluations.len() != ood_evaluations_table_width { + // Runtime guards: a malformed proof may supply opening evaluations + // whose column count does not match the OOD table width, or whose + // regular/symmetric base-column split disagree. Without these checks + // the indexing below would panic in release builds. + if num_base != num_base_sym { return None; } - if trace_term_coeffs.is_empty() - || trace_term_coeffs.len() * trace_term_coeffs[0].len() - != ood_evaluations_table_height * ood_evaluations_table_width + if num_base + lde_trace_aux_evaluations.len() != ood_evaluations_table_width + || num_base + lde_trace_aux_evaluations_sym.len() != ood_evaluations_table_width { return None; } - let mut denoms_trace = Vec::with_capacity(ood_evaluations_table_height); + // Build both denominator sets (regular, then symmetric) and invert + // them together in a single batch. + let mut denoms = Vec::with_capacity(2 * ood_evaluations_table_height); + let mut current_z = challenges.z.clone(); + for _ in 0..ood_evaluations_table_height { + denoms.push(evaluation_point - ¤t_z); + current_z = primitive_root * ¤t_z; + } let mut current_z = challenges.z.clone(); for _ in 0..ood_evaluations_table_height { - denoms_trace.push(evaluation_point - ¤t_z); + denoms.push(evaluation_point_sym - ¤t_z); current_z = primitive_root * ¤t_z; } // A malformed proof can land an OOD evaluation point on the LDE coset, reject. - FieldElement::inplace_batch_inverse(&mut denoms_trace).ok()?; - - let trace_term = (0..ood_evaluations_table_width) - .zip(&challenges.trace_term_coeffs) - .fold(FieldElement::zero(), |trace_terms, (col_idx, coeff_row)| { - let trace_i = (0..ood_evaluations_table_height).zip(coeff_row).fold( - FieldElement::zero(), - |trace_t, (row_idx, coeff)| { - let ood_val = &ood_data[row_idx * ood_evaluations_table_width + col_idx]; - // Stay in base when we can: F: IsSubFieldOf gives F - E -> E. - let diff: FieldElement = if col_idx < num_base { - base_at(col_idx) - ood_val - } else { - &lde_trace_aux_evaluations[col_idx - num_base] - ood_val - }; - let poly_evaluation = diff * &denoms_trace[row_idx]; - trace_t + &poly_evaluation * coeff - }, - ); - trace_terms + trace_i - }); + FieldElement::inplace_batch_inverse(&mut denoms).ok()?; + let (denoms_trace, denoms_trace_sym) = denoms.split_at(ood_evaluations_table_height); + + let mut trace_term = FieldElement::::zero(); + let mut trace_term_sym = FieldElement::::zero(); + for row_idx in 0..ood_evaluations_table_height { + let ood_row_sum = &query_invariant_terms.ood_row_sum[row_idx]; + let mut base_row_sum = FieldElement::::zero(); + let mut base_row_sum_sym = FieldElement::::zero(); + for (col_idx, coeff_col) in trace_term_coeffs.iter().enumerate() { + let coeff = &coeff_col[row_idx]; + if col_idx < num_base { + // F: IsSubFieldOf gives the cheap asymmetric F * E -> E product. + base_row_sum += base_at(col_idx) * coeff; + base_row_sum_sym += base_at_sym(col_idx) * coeff; + } else { + let aux_idx = col_idx - num_base; + base_row_sum += coeff * &lde_trace_aux_evaluations[aux_idx]; + base_row_sum_sym += coeff * &lde_trace_aux_evaluations_sym[aux_idx]; + } + } + trace_term += &denoms_trace[row_idx] * &(&base_row_sum - ood_row_sum); + trace_term_sym += &denoms_trace_sym[row_idx] * &(&base_row_sum_sym - ood_row_sum); + } - let composition_parts_ood = proof.composition_poly_parts_ood_evaluation(); - let number_of_parts = lde_composition_poly_parts_evaluation.len(); - let z_pow = &challenges.z.pow(number_of_parts); - - // A malformed proof can make evaluation_point == z^N, reject. - let denom_composition = (evaluation_point - z_pow).inv().ok()?; - let mut h_terms = FieldElement::zero(); - for (j, h_i_upsilon) in lde_composition_poly_parts_evaluation.iter().enumerate() { - // Bounds-check via `.get(j)?`: a malformed opening may have more - // parts than the proof header advertises. - let h_i_zpower = composition_parts_ood.get(j)?; - let gamma = challenges.gammas.get(j)?; - let h_i_term = (h_i_upsilon - h_i_zpower) * gamma; - h_terms += h_i_term; + let number_of_parts = query_invariant_terms.number_of_parts; + // Also rejects a per-query opening length that disagrees with the + // proof-level `number_of_parts`, not just a regular/symmetric mismatch. + if lde_composition_poly_parts_evaluation.len() != number_of_parts + || lde_composition_poly_parts_evaluation_sym.len() != number_of_parts + { + return None; + } + let z_pow = &query_invariant_terms.z_pow; + + // A malformed proof can make evaluation_point == z_pow, reject. + let mut denom_composition_pair = [evaluation_point - z_pow, evaluation_point_sym - z_pow]; + FieldElement::inplace_batch_inverse(&mut denom_composition_pair).ok()?; + let [denom_composition, denom_composition_sym] = denom_composition_pair; + + let mut h_sum = FieldElement::::zero(); + let mut h_sum_sym = FieldElement::::zero(); + for j in 0..number_of_parts { + let h_i_upsilon = &lde_composition_poly_parts_evaluation[j]; + let h_i_upsilon_sym = &lde_composition_poly_parts_evaluation_sym[j]; + let gamma = &challenges.gammas[j]; + h_sum += h_i_upsilon * gamma; + h_sum_sym += h_i_upsilon_sym * gamma; } - h_terms *= denom_composition; + let h_terms = (&h_sum - &query_invariant_terms.h_sum_zpow) * denom_composition; + let h_terms_sym = + (&h_sum_sym - &query_invariant_terms.h_sum_zpow) * denom_composition_sym; - Some(trace_term + h_terms) + Some((trace_term + h_terms, trace_term_sym + h_terms_sym)) } /// Verifies one or more STARK proofs with their corresponding AIRs. From 2af841676533f7272115610ee8f261b1c91de5eb Mon Sep 17 00:00:00 2001 From: Mario Rugiero Date: Wed, 15 Jul 2026 18:30:05 -0300 Subject: [PATCH 2/7] fmt --- crypto/stark/src/verifier.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index a4eb0fd39..f78bf6e34 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -929,8 +929,7 @@ pub trait IsStarkVerifier< h_sum_sym += h_i_upsilon_sym * gamma; } let h_terms = (&h_sum - &query_invariant_terms.h_sum_zpow) * denom_composition; - let h_terms_sym = - (&h_sum_sym - &query_invariant_terms.h_sum_zpow) * denom_composition_sym; + let h_terms_sym = (&h_sum_sym - &query_invariant_terms.h_sum_zpow) * denom_composition_sym; Some((trace_term + h_terms, trace_term_sym + h_terms_sym)) } From bbb3d4b42e2ad48f4e1c3603e879179c3b79816a Mon Sep 17 00:00:00 2001 From: MauroFab Date: Thu, 16 Jul 2026 12:25:36 -0300 Subject: [PATCH 3/7] test(stark): pin the deep-composition base/aux and composition-part guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fused deep-composition reconstruction made two guards in reconstruct_deep_composition_poly_evaluation_pair the sole protection against a release-mode out-of-bounds panic on a malformed proof, and both run in step 3 — before step 4's Merkle openings could reject the tampering: - num_base != num_base_sym: the base-column branch is bounded by the regular num_base but resolves symmetric columns via base_at_sym, which indexes the symmetric slices. - the composition-part length check: the part loop is bounded by the proof-level number_of_parts and indexes both per-query slices with it. Neither was covered. Both tests use the LogUp read-only-memory RAP, which has auxiliary columns (trace_layout = (5, 1)) and two composition parts, so they reach the base/aux split and the multi-part loop rather than the degenerate one-part case. Verified by removing each guard in turn: both tests then fail with an index out of bounds, not merely a false verdict. --- crypto/stark/src/tests/small_trace_tests.rs | 109 ++++++++++++++++++- crypto/stark/src/tests/trace_test_helpers.rs | 50 +++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/crypto/stark/src/tests/small_trace_tests.rs b/crypto/stark/src/tests/small_trace_tests.rs index e4a48a0d9..c5f4aad9d 100644 --- a/crypto/stark/src/tests/small_trace_tests.rs +++ b/crypto/stark/src/tests/small_trace_tests.rs @@ -11,10 +11,11 @@ use crate::{ }, proof::options::ProofOptions, prover::{IsStarkProver, Prover}, - tests::trace_test_helpers::make_valid_simple_proof, + tests::trace_test_helpers::{make_valid_logup_proof, make_valid_simple_proof}, traits::AIR, verifier::{IsStarkVerifier, Verifier}, }; +use math::field::extensions_goldilocks::Degree3GoldilocksExtensionField; type Felt = FieldElement; @@ -382,6 +383,112 @@ fn test_verify_rejects_opening_column_count_mismatch() { ); } +/// A malformed proof whose symmetric opening splits its columns between the +/// base and auxiliary segments differently from the regular opening, while +/// keeping the same total column count. +/// +/// `reconstruct_deep_composition_poly_evaluation_pair` bounds its base-column +/// branch by the REGULAR `num_base` but resolves the symmetric column through +/// `base_at_sym`, which indexes the SYMMETRIC slices. The +/// `num_base != num_base_sym` guard is the only thing standing between this +/// proof and an out-of-bounds index panic in release builds, and it runs in +/// step 3 — before step 4's Merkle openings could reject the tampering. +/// +/// The total width is deliberately preserved (one column moved from the +/// symmetric main trace to the symmetric aux trace) so that the width guards +/// cannot fire: only the base/aux split guard can reject this. +#[test_log::test] +fn test_verify_rejects_asymmetric_base_aux_split() { + let (air, mut proof) = make_valid_logup_proof(); + + // Sanity: the unmodified proof must verify first. + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: valid LogUp proof must verify" + ); + + let opening = proof + .deep_poly_openings + .first_mut() + .expect("test precondition: a valid proof has at least one deep poly opening"); + let aux = opening + .aux_trace_polys + .as_mut() + .expect("test precondition: the LogUp RAP has an auxiliary trace segment"); + + // Move one column from the symmetric main trace into the symmetric aux + // trace: `num_base_sym` drops by one while `num_base_sym + aux_sym.len()` + // still equals the OOD table width. + let moved = opening + .main_trace_polys + .evaluations_sym + .pop() + .expect("test precondition: the main trace opening has at least one column"); + aux.evaluations_sym.push(moved.to_extension()); + + assert!( + !Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier must reject when the regular and symmetric base-column counts disagree" + ); +} + +/// A malformed proof whose symmetric composition-poly opening is shorter than +/// the proof-level `number_of_parts`. +/// +/// The composition loop in `reconstruct_deep_composition_poly_evaluation_pair` +/// is bounded by `number_of_parts` (derived from +/// `composition_poly_parts_ood_evaluation`) and indexes both the regular and +/// symmetric per-query slices with it, so a short symmetric slice would index +/// out of bounds and panic in release builds. The length guard is the sole +/// protection and runs before step 4's Merkle openings. +/// +/// Uses the LogUp RAP (`composition_poly_degree_bound == 2 * trace_length`) so +/// the multi-part loop is exercised rather than the degenerate one-part case. +#[test_log::test] +fn test_verify_rejects_truncated_symmetric_composition_opening() { + let (air, mut proof) = make_valid_logup_proof(); + + // Sanity: the unmodified proof must verify first. + assert!( + Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "precondition: valid LogUp proof must verify" + ); + assert!( + proof.composition_poly_parts_ood_evaluation.len() > 1, + "test precondition: the LogUp RAP must have more than one composition part, \ + so the multi-part loop is covered" + ); + + // Drop one symmetric part so the per-query opening is shorter than the + // proof-level part count the loop is bounded by. + proof.deep_poly_openings[0] + .composition_poly + .evaluations_sym + .pop() + .expect("test precondition: the composition opening has at least one part"); + + assert!( + !Verifier::verify( + &proof, + &air, + &mut DefaultTranscript::::new(&[]) + ), + "Verifier must reject when a symmetric composition opening is shorter than number_of_parts" + ); +} + // --------------------------------------------------------------------------- // Helpers shared by the FRI early-termination soundness tests below. // --------------------------------------------------------------------------- diff --git a/crypto/stark/src/tests/trace_test_helpers.rs b/crypto/stark/src/tests/trace_test_helpers.rs index 4ef6455b3..f93485be8 100644 --- a/crypto/stark/src/tests/trace_test_helpers.rs +++ b/crypto/stark/src/tests/trace_test_helpers.rs @@ -1,3 +1,6 @@ +use crate::examples::read_only_memory_logup::{ + LogReadOnlyPublicInputs, LogReadOnlyRAP, read_only_logup_trace, +}; use crate::examples::simple_addition::{ SimpleAdditionAIR, SimpleAdditionPublicInputs, simple_addition_trace, }; @@ -10,6 +13,7 @@ use crypto::fiat_shamir::default_transcript::DefaultTranscript; use itertools::Itertools; use math::field::{ element::FieldElement, + extensions_goldilocks::Degree3GoldilocksExtensionField, goldilocks::GoldilocksField, traits::{IsField, IsSubFieldOf}, }; @@ -46,6 +50,52 @@ pub fn make_valid_simple_proof() -> ( (air, proof) } +/// Builds a valid 8-row `LogReadOnlyRAP` proof. Unlike +/// [`make_valid_simple_proof`], this AIR has both auxiliary trace columns +/// (`trace_layout() == (5, 1)`) and more than one composition part +/// (`composition_poly_degree_bound() == 2 * trace_length`), which the deep +/// composition rejection tests in `small_trace_tests` need in order to reach +/// the base/aux split and the multi-part composition loop. +pub type LogupProof = crate::proof::stark::StarkProof< + GoldilocksField, + Degree3GoldilocksExtensionField, + LogReadOnlyPublicInputs, +>; + +pub fn make_valid_logup_proof() -> ( + LogReadOnlyRAP, + LogupProof, +) { + let address_col: Vec> = [3u64, 2, 2, 3, 4, 5, 1, 3] + .iter() + .map(|a| (*a).into()) + .collect(); + let value_col: Vec> = [30u64, 20, 20, 30, 40, 50, 10, 30] + .iter() + .map(|v| (*v).into()) + .collect(); + + let pub_inputs = LogReadOnlyPublicInputs { + a0: FieldElement::from(3u64), + v0: FieldElement::from(30u64), + a_sorted_0: FieldElement::from(1u64), + v_sorted_0: FieldElement::from(10u64), + m0: FieldElement::from(1u64), + }; + let mut trace = read_only_logup_trace(address_col, value_col); + let proof_options = ProofOptions::default_test_options(); + let air = + LogReadOnlyRAP::::new(&proof_options); + let proof = Prover::prove( + &air, + &mut trace, + &pub_inputs, + &mut DefaultTranscript::::new(&[]), + ) + .expect("prover must succeed on the LogUp read-only-memory RAP"); + (air, proof) +} + /// Reference Horner-based trace-evaluation used as an oracle by the prover /// tests (`tests::prover_tests`). The production prover uses the LDE-based /// barycentric `get_trace_evaluations_from_lde`; the two are From 7342c5eeb8c497d7f8b8dfa0ba09ff7a17aaae2e Mon Sep 17 00:00:00 2001 From: MauroFab Date: Thu, 16 Jul 2026 12:27:06 -0300 Subject: [PATCH 4/7] fix(verifier): check the symmetric width guard against num_base_sym The second guard's symmetric arm reused the regular num_base, so it was only correct because the num_base != num_base_sym guard above had already run. Check num_base_sym instead, making the two guards independently correct rather than order-dependent. No behavior change: the guard above forces num_base == num_base_sym before this arm is reached, so the two forms agree on every input that gets here. --- crypto/stark/src/verifier.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index f78bf6e34..084fc225b 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -860,7 +860,7 @@ pub trait IsStarkVerifier< return None; } if num_base + lde_trace_aux_evaluations.len() != ood_evaluations_table_width - || num_base + lde_trace_aux_evaluations_sym.len() != ood_evaluations_table_width + || num_base_sym + lde_trace_aux_evaluations_sym.len() != ood_evaluations_table_width { return None; } From c933d5b2a8174f48afba3f33a10894f0cf966e32 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Thu, 16 Jul 2026 12:27:39 -0300 Subject: [PATCH 5/7] fix(verifier): guard the trace_term_coeffs shape, not the dimension product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compute_query_invariant_deep_terms and reconstruct_deep_composition_poly_evaluation_pair direct-index trace_term_coeffs[col][row] for col < ood_width, row < ood_height. The product guard (len * [0].len() != height * width) accepts shapes that transpose those bounds, and only inspected column 0's length; it was sufficient only via a three-link chain across files: #815's OOD-table guard in multi_verify_views pins the width, replay_rounds_after_round_1 builds trace_term_coeffs by exact division, and every AIR happens to satisfy the unasserted trace_columns == trace_layout.0 + trace_layout.1 convention. Check the shape the code actually needs instead, so the two functions are locally panic-free. The is_empty() check goes away because the new form never indexes [0]: an empty trace_term_coeffs is rejected by the length compare whenever width > 0, and the degenerate width == 0 case indexes nothing (#815 already rejects height == 0). Runs once per proof, integer compares only — no per-query cost, no field operations. --- crypto/stark/src/verifier.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 084fc225b..c804cbb98 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -678,9 +678,20 @@ pub trait IsStarkVerifier< let ood_data = trace_ood_evaluations.row_major_data(); let trace_term_coeffs = &challenges.trace_term_coeffs; - if trace_term_coeffs.is_empty() - || trace_term_coeffs.len() * trace_term_coeffs[0].len() - != ood_evaluations_table_height * ood_evaluations_table_width + // Check the exact shape the code relies on rather than the product of + // the dimensions: this function and + // `reconstruct_deep_composition_poly_evaluation_pair` both + // direct-index `trace_term_coeffs[col][row]` for every `col < width`, + // `row < height`, and a product check accepts shapes that transpose + // those bounds. Checking the shape here keeps their panic-freedom + // local, rather than resting on #815's OOD-table guard in + // `multi_verify_views` plus the unasserted `trace_columns == + // trace_layout.0 + trace_layout.1` convention. Runs once per proof and + // is integer compares only — no per-query cost. + if trace_term_coeffs.len() != ood_evaluations_table_width + || trace_term_coeffs + .iter() + .any(|coeff_col| coeff_col.len() != ood_evaluations_table_height) { return None; } From 77197ca9e1d194cdc6e2fd5be1b4f09ac8aa9a6c Mon Sep 17 00:00:00 2001 From: MauroFab Date: Thu, 16 Jul 2026 12:28:14 -0300 Subject: [PATCH 6/7] refactor(verifier): rename base_row_sum to lde_row_sum, fix two stale comments base_row_sum/base_row_sum_sym accumulate base AND aux columns, while num_base/base_at in the same function mean "base-field, not aux". The name asserted a partition the code does not implement, right at the identity under review. Pure rename, no codegen impact; the fn doc-comment's coeff*base becomes coeff*lde to match. The comment above primitive_root still described the pre-fuse code: the per-element base_at(col) - ood_val subtraction is now base_at(col) * coeff, a Mul. F->E subtractions via IsSubFieldOf do still exist in the helper (the evaluation_point - current_z denominators), so the reworded comment only claims the Mul for the trace-term path. --- crypto/stark/src/verifier.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index c804cbb98..3c2ea46c2 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -752,8 +752,9 @@ pub trait IsStarkVerifier< let mut deep_poly_evaluations_sym = Vec::with_capacity(num_queries); // Build the base-field LDE evaluations as concatenated slice (precomputed + main) - // without lifting to the extension field. The helper now subtracts directly via - // the F: IsSubFieldOf Sub impl, so we avoid a per-query base->extension lift. + // without lifting to the extension field. The helper multiplies each base column + // straight into its extension-field coefficient via the F: IsSubFieldOf Mul + // impl, so we avoid a per-query base->extension lift. let primitive_root = &Field::get_primitive_root_of_unity(domain.root_order as u64) .expect("verifier domain root_order is a valid power of two"); @@ -816,9 +817,9 @@ pub trait IsStarkVerifier< /// Reconstructs the deep composition polynomial evaluation at a query's /// point and its symmetric counterpart together. Rewriting the per-element - /// trace term `coeff*(base-ood)*denom` as `denom*(coeff*base - coeff*ood)` + /// trace term `coeff*(lde-ood)*denom` as `denom*(coeff*lde - coeff*ood)` /// isolates `coeff*ood` (identical for both points, hoisted into - /// `query_invariant_terms`) from `coeff*base` (per-point), so both points + /// `query_invariant_terms`) from `coeff*lde` (per-point), so both points /// share the OOD walk and a single batch-inverse for their denominators. #[allow(clippy::too_many_arguments)] fn reconstruct_deep_composition_poly_evaluation_pair<'b>( @@ -897,22 +898,22 @@ pub trait IsStarkVerifier< let mut trace_term_sym = FieldElement::::zero(); for row_idx in 0..ood_evaluations_table_height { let ood_row_sum = &query_invariant_terms.ood_row_sum[row_idx]; - let mut base_row_sum = FieldElement::::zero(); - let mut base_row_sum_sym = FieldElement::::zero(); + let mut lde_row_sum = FieldElement::::zero(); + let mut lde_row_sum_sym = FieldElement::::zero(); for (col_idx, coeff_col) in trace_term_coeffs.iter().enumerate() { let coeff = &coeff_col[row_idx]; if col_idx < num_base { // F: IsSubFieldOf gives the cheap asymmetric F * E -> E product. - base_row_sum += base_at(col_idx) * coeff; - base_row_sum_sym += base_at_sym(col_idx) * coeff; + lde_row_sum += base_at(col_idx) * coeff; + lde_row_sum_sym += base_at_sym(col_idx) * coeff; } else { let aux_idx = col_idx - num_base; - base_row_sum += coeff * &lde_trace_aux_evaluations[aux_idx]; - base_row_sum_sym += coeff * &lde_trace_aux_evaluations_sym[aux_idx]; + lde_row_sum += coeff * &lde_trace_aux_evaluations[aux_idx]; + lde_row_sum_sym += coeff * &lde_trace_aux_evaluations_sym[aux_idx]; } } - trace_term += &denoms_trace[row_idx] * &(&base_row_sum - ood_row_sum); - trace_term_sym += &denoms_trace_sym[row_idx] * &(&base_row_sum_sym - ood_row_sum); + trace_term += &denoms_trace[row_idx] * &(&lde_row_sum - ood_row_sum); + trace_term_sym += &denoms_trace_sym[row_idx] * &(&lde_row_sum_sym - ood_row_sum); } let number_of_parts = query_invariant_terms.number_of_parts; From 0e59ace04df24d1f7454b0c041804d4576e0b3fd Mon Sep 17 00:00:00 2001 From: MauroFab Date: Thu, 16 Jul 2026 12:30:46 -0300 Subject: [PATCH 7/7] refactor(verifier): pass the opening view instead of eight derived slices reconstruct_deep_composition_poly_evaluation_pair took 14 arguments, eight of which the caller derived from a single Copy DeepPolynomialOpeningView. Pass the view and resolve the slices in the callee, and move ood_width into QueryInvariantDeepTerms so the proof param drops too: 14 args -> 6, and #[allow(clippy::too_many_arguments)] is deleted rather than inherited. The inner row/column accumulation is textually unchanged. Slice resolution happens once per query either way, only on the other side of the call, and the per-query proof.trace_ood_evaluations().width() call is now a struct read. This commit is cosmetic and can be dropped independently if the signature change is unwelcome in the hot loop. --- crypto/stark/src/verifier.rs | 82 +++++++++++++++--------------------- 1 file changed, 34 insertions(+), 48 deletions(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 3c2ea46c2..67a5028e1 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -92,6 +92,9 @@ where { /// `ood_row_sum[row] = sum_col trace_term_coeffs[col][row] * ood(row, col)`. ood_row_sum: Vec>, + /// `proof.trace_ood_evaluations().width()`, checked against the + /// `trace_term_coeffs` shape below. + ood_width: usize, /// Derived from `proof.composition_poly_parts_ood_evaluation().len()`. number_of_parts: usize, /// `challenges.z.pow(number_of_parts)`. @@ -724,6 +727,7 @@ pub trait IsStarkVerifier< Some(QueryInvariantDeepTerms { ood_row_sum, + ood_width: ood_evaluations_table_width, number_of_parts, z_pow, h_sum_zpow, @@ -761,53 +765,17 @@ pub trait IsStarkVerifier< let query_invariant_terms = Self::compute_query_invariant_deep_terms(challenges, proof)?; for (i, iota) in challenges.iotas.iter().enumerate() { - let opening = proof.deep_poly_opening(i); - - // Base-field portion as two borrowed slices in commit order — - // precomputed columns FIRST, then main trace columns. The callee - // resolves a base column via `base_at`, so there is no per-query - // concat allocation. - let lde_precomputed: &[FieldElement] = opening - .precomputed_trace_polys() - .map(|p| p.evaluations()) - .unwrap_or(&[]); - let lde_main = opening.main_trace_polys().evaluations(); - - let lde_aux: &[FieldElement] = opening - .aux_trace_polys() - .map(|a| a.evaluations()) - .unwrap_or(&[]); - - let lde_precomputed_sym: &[FieldElement] = opening - .precomputed_trace_polys() - .map(|p| p.evaluations_sym()) - .unwrap_or(&[]); - let lde_main_sym = opening.main_trace_polys().evaluations_sym(); - - let lde_aux_sym: &[FieldElement] = opening - .aux_trace_polys() - .map(|a| a.evaluations_sym()) - .unwrap_or(&[]); - let evaluation_point = Self::query_challenge_to_evaluation_point(*iota, false, domain); let evaluation_point_sym = Self::query_challenge_to_evaluation_point(*iota, true, domain); let (evaluation, evaluation_sym) = Self::reconstruct_deep_composition_poly_evaluation_pair( - proof, + proof.deep_poly_opening(i), &evaluation_point, &evaluation_point_sym, primitive_root, challenges, &query_invariant_terms, - lde_precomputed, - lde_main, - lde_aux, - opening.composition_poly().evaluations(), - lde_precomputed_sym, - lde_main_sym, - lde_aux_sym, - opening.composition_poly().evaluations_sym(), )?; deep_poly_evaluations.push(evaluation); deep_poly_evaluations_sym.push(evaluation_sym); @@ -821,25 +789,43 @@ pub trait IsStarkVerifier< /// isolates `coeff*ood` (identical for both points, hoisted into /// `query_invariant_terms`) from `coeff*lde` (per-point), so both points /// share the OOD walk and a single batch-inverse for their denominators. - #[allow(clippy::too_many_arguments)] fn reconstruct_deep_composition_poly_evaluation_pair<'b>( - proof: StarkProofView<'_, Field, FieldExtension, PI>, + opening: DeepPolynomialOpeningView<'b, Field, FieldExtension>, evaluation_point: &FieldElement, evaluation_point_sym: &FieldElement, primitive_root: &FieldElement, challenges: &Challenges, query_invariant_terms: &QueryInvariantDeepTerms, - lde_trace_precomputed_evaluations: &'b [FieldElement], - lde_trace_main_evaluations: &'b [FieldElement], - lde_trace_aux_evaluations: &[FieldElement], - lde_composition_poly_parts_evaluation: &[FieldElement], - lde_trace_precomputed_evaluations_sym: &'b [FieldElement], - lde_trace_main_evaluations_sym: &'b [FieldElement], - lde_trace_aux_evaluations_sym: &[FieldElement], - lde_composition_poly_parts_evaluation_sym: &[FieldElement], ) -> Option<(FieldElement, FieldElement)> { + // Base-field portion as two borrowed slices in commit order — + // precomputed columns FIRST, then main trace columns. A base column is + // resolved via `base_at` below, so there is no per-query concat + // allocation. + let lde_trace_precomputed_evaluations: &'b [FieldElement] = opening + .precomputed_trace_polys() + .map(|p| p.evaluations()) + .unwrap_or(&[]); + let lde_trace_main_evaluations = opening.main_trace_polys().evaluations(); + let lde_trace_aux_evaluations: &[FieldElement] = opening + .aux_trace_polys() + .map(|a| a.evaluations()) + .unwrap_or(&[]); + let lde_composition_poly_parts_evaluation = opening.composition_poly().evaluations(); + + let lde_trace_precomputed_evaluations_sym: &'b [FieldElement] = opening + .precomputed_trace_polys() + .map(|p| p.evaluations_sym()) + .unwrap_or(&[]); + let lde_trace_main_evaluations_sym = opening.main_trace_polys().evaluations_sym(); + let lde_trace_aux_evaluations_sym: &[FieldElement] = opening + .aux_trace_polys() + .map(|a| a.evaluations_sym()) + .unwrap_or(&[]); + let lde_composition_poly_parts_evaluation_sym = + opening.composition_poly().evaluations_sym(); + let ood_evaluations_table_height = query_invariant_terms.ood_row_sum.len(); - let ood_evaluations_table_width = proof.trace_ood_evaluations().width(); + let ood_evaluations_table_width = query_invariant_terms.ood_width; let trace_term_coeffs = &challenges.trace_term_coeffs; // Base columns are supplied as two slices (precomputed ‖ main) that the