diff --git a/crypto/stark/src/ood.rs b/crypto/stark/src/ood.rs index a779d7851..250569b5a 100644 --- a/crypto/stark/src/ood.rs +++ b/crypto/stark/src/ood.rs @@ -180,6 +180,135 @@ pub fn reconstruct_ood_full( Table::new(data, width) } +/// The pruned-OOD trace-opening layout, derived once from public AIR shape +/// metadata and shared by every site that used to recompute it. Every field is +/// a pure function of the AIR (`trace_columns`, `step_size`, the +/// transition-offset count, and the next-row column set), so the prover and the +/// verifier build the identical layout without trusting any proof dimension +/// (invariant I3). This struct only bundles those values and forwards to the +/// free functions above; it adds no new arithmetic. +/// +/// It stays decoupled from the `AIR` trait: callers that have an AIR in scope +/// read the four raw values once (see the `ood_layout` helpers in the verifier +/// and prover) and pass them to [`OodLayout::new`]. +#[derive(Clone, Debug)] +pub struct OodLayout { + /// Total trace columns (`main + aux`), i.e. the full current-row block width. + num_total_cols: usize, + /// Rows in the full OOD grid: `num_transition_offsets * step_size`. + num_eval_points: usize, + /// Rows per offset block. + step_size: usize, + /// Full-width column indices opened at the next row (the transition window). + next_row_cols: Vec, +} + +impl OodLayout { + /// Build from raw AIR-metadata values. `num_eval_points` is + /// `num_transition_offsets * step_size`; keeping it a plain argument lets the + /// single AIR-reading expression live in the verifier/prover, not here. + pub fn new( + num_total_cols: usize, + num_eval_points: usize, + step_size: usize, + next_row_cols: Vec, + ) -> Self { + Self { + num_total_cols, + num_eval_points, + step_size, + next_row_cols, + } + } + + /// Rows per offset block. + pub fn step_size(&self) -> usize { + self.step_size + } + + /// Full-width column indices opened at the next row (the transition window), + /// in the order the DEEP reconstruction sums them. + pub fn next_row_cols(&self) -> &[usize] { + &self.next_row_cols + } + + /// Width of the pruned next-row proof block: one column per transition-window + /// column (the current-row block always keeps every column). + pub fn expected_next_width(&self) -> usize { + self.next_row_cols.len() + } + + /// Height of the pruned next-row proof block: the non-current rows, or 0 when + /// the AIR reads no next-row column (then the block is empty). + pub fn expected_next_height(&self) -> usize { + if self.next_row_cols.is_empty() { + 0 + } else { + self.num_eval_points.saturating_sub(self.step_size) + } + } + + /// Number of surviving trace openings under g·z pruning; see + /// [`num_surviving_trace_openings`]. + pub fn num_surviving(&self) -> usize { + num_surviving_trace_openings( + self.num_total_cols, + self.num_eval_points, + self.step_size, + self.next_row_cols.len(), + ) + } + + /// Per-column next-row open flags for a table of `grid_width` columns; see + /// [`next_row_col_flags`]. The width is that of the table being indexed — the + /// reconstructed OOD grid, whose width is the current-row block's width — and + /// need not equal `num_total_cols`; the free function ignores any next-row + /// index that falls outside `grid_width`. + pub fn flags(&self, grid_width: usize) -> Vec { + next_row_col_flags(grid_width, &self.next_row_cols) + } + + /// Build the rectangular DEEP trace-term coefficient grid; see + /// [`build_pruned_trace_term_coeffs`]. + pub fn build_trace_term_coeffs( + &self, + powers: &[FieldElement], + ) -> Vec>> { + build_pruned_trace_term_coeffs( + powers, + self.num_total_cols, + self.num_eval_points, + self.step_size, + &self.next_row_cols, + ) + } + + /// Split a full prover OOD table into the two pruned proof blocks; see + /// [`split_ood_blocks`]. + pub fn split_full(&self, full: &Table) -> (Table, Table) { + split_ood_blocks(full, self.step_size, &self.next_row_cols) + } + + /// Rebuild the full OOD grid from the two pruned proof blocks; see + /// [`reconstruct_ood_full`]. `current_width` is the (proof-supplied) + /// current-row block width and becomes the reconstructed grid's width. + pub fn reconstruct_full( + &self, + current_block: &[FieldElement], + current_width: usize, + next_block: &[FieldElement], + ) -> Table { + reconstruct_ood_full( + current_block, + current_width, + next_block, + self.num_eval_points, + self.step_size, + &self.next_row_cols, + ) + } +} + #[cfg(test)] mod tests { use super::*; @@ -258,4 +387,49 @@ mod tests { assert_eq!(coeffs[0][1], Fe::zero()); // pruned assert_eq!(coeffs[2][1], Fe::zero()); // pruned } + + #[test] + fn ood_layout_delegates_to_free_functions() { + // W=3 cols, num_eval_points=2 (step_size 1, 2 offsets), next-row mask {1}. + let layout = OodLayout::new(3, 2, 1, vec![1]); + + assert_eq!(layout.step_size(), 1); + assert_eq!(layout.expected_next_width(), 1); + assert_eq!(layout.expected_next_height(), 1); + assert_eq!( + layout.num_surviving(), + num_surviving_trace_openings(3, 2, 1, 1) + ); + + // Empty next-row mask => empty next-row block. + let empty = OodLayout::new(3, 2, 1, vec![]); + assert_eq!(empty.expected_next_width(), 0); + assert_eq!(empty.expected_next_height(), 0); + + // flags(), build_trace_term_coeffs(), split_full() and reconstruct_full() + // must be bit-identical to the free functions they forward to. + assert_eq!(layout.flags(3), next_row_col_flags(3, &[1])); + let powers: Vec = (1..=4).map(fe).collect(); + assert_eq!( + layout.build_trace_term_coeffs(&powers), + build_pruned_trace_term_coeffs(&powers, 3, 2, 1, &[1]) + ); + + let full = Table::new(vec![fe(10), fe(11), fe(12), fe(20), fe(21), fe(22)], 3); + let (lb0, lb1) = layout.split_full(&full); + let (fb0, fb1) = split_ood_blocks(&full, 1, &[1]); + assert_eq!(lb0.row_major_data(), fb0.row_major_data()); + assert_eq!(lb1.row_major_data(), fb1.row_major_data()); + + let recon = layout.reconstruct_full(lb0.row_major_data(), lb0.width, lb1.row_major_data()); + let free_recon = reconstruct_ood_full( + fb0.row_major_data(), + fb0.width, + fb1.row_major_data(), + 2, + 1, + &[1], + ); + assert_eq!(recon.row_major_data(), free_recon.row_major_data()); + } } diff --git a/crypto/stark/src/prover.rs b/crypto/stark/src/prover.rs index 69fab1619..8c44c42a9 100644 --- a/crypto/stark/src/prover.rs +++ b/crypto/stark/src/prover.rs @@ -1438,6 +1438,23 @@ pub trait IsStarkProver< } } + /// The pruned-OOD layout for this AIR — the single place in the prover that + /// reads the shape metadata (`trace_columns`, `step_size`, the + /// transition-offset count, and the next-row column set). The round-3 block + /// split and the round-4 DEEP-coefficient assignment both derive from the + /// returned [`crate::ood::OodLayout`], which the verifier rebuilds identically + /// (invariant I3). + fn ood_layout( + air: &dyn AIR, + ) -> crate::ood::OodLayout { + crate::ood::OodLayout::new( + air.context().trace_columns, + air.context().transition_offsets.len() * air.step_size(), + air.step_size(), + air.trace_ood_next_row_columns(), + ) + } + /// Returns the result of the fourth round of the STARK Prove protocol. fn round_4_compute_and_run_fri_on_the_deep_composition_polynomial( air: &dyn AIR, @@ -1458,16 +1475,10 @@ pub trait IsStarkProver< let gamma = transcript.sample_field_element(); let n_terms_composition_poly = round_2_result.lde_composition_poly_evaluations.len(); - let num_eval_points = air.context().transition_offsets.len() * air.step_size(); - let next_row_cols = air.trace_ood_next_row_columns(); // g·z pruning: only the current-row block (all columns) plus the masked // next-row columns get an opening / DEEP coefficient. - let num_terms_trace = crate::ood::num_surviving_trace_openings( - air.context().trace_columns, - num_eval_points, - air.step_size(), - next_row_cols.len(), - ); + let layout = Self::ood_layout(air); + let num_terms_trace = layout.num_surviving(); // <<<< Receive challenges: 𝛾, 𝛾' let mut deep_composition_coefficients: Vec<_> = @@ -1481,13 +1492,7 @@ pub trait IsStarkProver< // Rectangular W×num_eval_points grid with the sampled powers at surviving // positions and zeros at pruned next-row positions, so the DEEP loop // below (and the GPU path) stay unchanged — zero-coefficient terms vanish. - let trace_term_coeffs = crate::ood::build_pruned_trace_term_coeffs( - &trace_term_powers, - air.context().trace_columns, - num_eval_points, - air.step_size(), - &next_row_cols, - ); + let trace_term_coeffs = layout.build_trace_term_coeffs(&trace_term_powers); // <<<< Receive challenges: 𝛾ⱼ, 𝛾ⱼ' let gammas = deep_composition_coefficients; @@ -3122,12 +3127,8 @@ pub trait IsStarkProver< // the current-row block (all columns) and the pruned next-row block // (masked columns only), and absorb only the surviving values — the // verifier absorbs the identical two blocks in the same order. - let ood_next_row_cols = air.trace_ood_next_row_columns(); - let (ood_block0, ood_block1) = crate::ood::split_ood_blocks( - &round_3_result.trace_ood_evaluations, - air.step_size(), - &ood_next_row_cols, - ); + let (ood_block0, ood_block1) = + Self::ood_layout(air).split_full(&round_3_result.trace_ood_evaluations); for block in [&ood_block0, &ood_block1] { for col in block.columns().iter() { for elem in col.iter() { diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index be0313bd5..ae26afbe9 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -141,6 +141,22 @@ pub trait IsStarkVerifier< .collect::>() } + /// The pruned-OOD layout for this AIR — the single place in the verifier that + /// reads the shape metadata (`trace_columns`, `step_size`, the + /// transition-offset count, and the next-row column set). Everything that used + /// to recompute these values now derives them from the returned + /// [`crate::ood::OodLayout`]. Pure AIR metadata, never a proof dimension. + fn ood_layout( + air: &dyn AIR, + ) -> crate::ood::OodLayout { + crate::ood::OodLayout::new( + air.context().trace_columns, + air.context().transition_offsets.len() * air.step_size(), + air.step_size(), + air.trace_ood_next_row_columns(), + ) + } + /// Checks whether the purported evaluations of the composition polynomial parts and the trace /// polynomials at the out-of-domain challenge are consistent. /// See https://lambdaclass.github.io/lambdaworks/starks/protocol.html#step-2-verify-claimed-composition-polynomial @@ -186,6 +202,13 @@ pub trait IsStarkVerifier< public_inputs: &PI, domain: &VerifierDomain, challenges: &Challenges, + // The full current+next-row OOD grid, shape-checked and reconstructed once + // by the caller (after `ood_blocks_well_formed`) and shared with + // `step_3_verify_fri`. Its pruned next-row entries are zero — those are + // never read by any constraint. `step_size` accompanies it for the frame + // split below. + ood_full: &Table, + step_size: usize, ) -> bool { crate::profile_markers::step_marker::< { crate::profile_markers::STEP_VERIFY_CLAIMED_COMPOSITION_POLYNOMIAL }, @@ -197,29 +220,6 @@ pub trait IsStarkVerifier< .bus_table_contribution() .map(BusPublicInputs::from_contribution); - // Reject either OOD block whose shape disagrees with the AIR before - // reading it, so a malicious prover cannot reshape them to dodge a check - // or desync the frame reconstruction below. - if !Self::ood_blocks_well_formed(air, proof) { - return false; - } - let step_size = air.step_size(); - let num_eval_points = air.context().transition_offsets.len() * step_size; - let next_row_cols = air.trace_ood_next_row_columns(); - let ood_current = proof.trace_ood_evaluations(); - let ood_next = proof.trace_ood_next_evaluations(); - - // Reconstruct the full current+next-row OOD grid (surviving values placed, - // pruned next-row entries zero -- those are never read by any constraint). - let ood_full = crate::ood::reconstruct_ood_full( - ood_current.row_major_data(), - ood_current.width(), - ood_next.row_major_data(), - num_eval_points, - step_size, - &next_row_cols, - ); - let boundary_constraints = air.boundary_constraints( public_inputs, &challenges.rap_challenges, @@ -318,7 +318,7 @@ pub trait IsStarkVerifier< // its transition-window columns; the zero-filled remainder is never read. // `into_frame` lives on the borrowed table view, so wrap the owned grid. let ood_frame = - StarkTableView::Owned(&ood_full).into_frame(num_main_trace_columns, step_size); + StarkTableView::Owned(ood_full).into_frame(num_main_trace_columns, step_size); let transition_evaluation_context = TransitionEvaluationContext::new_verifier( &ood_frame, &challenges.rap_challenges, @@ -389,33 +389,25 @@ pub trait IsStarkVerifier< proof: StarkProofView<'_, Field, FieldExtension, PI>, domain: &VerifierDomain, challenges: &Challenges, + // g·z pruning: the full OOD grid (reconstructed once by the caller and + // shared with `step_2`) plus the transition-window column indices, so the + // DEEP reconstruction can skip pruned next-row openings. + ood_full: &Table, + next_row_cols: &[usize], + step_size: usize, ) -> bool where FieldElement: AsBytes + Sync + Send, FieldElement: AsBytes + Sync + Send, { crate::profile_markers::step_marker::<{ crate::profile_markers::STEP_VERIFY_FRI }>(); - // g·z pruning: rebuild the full OOD grid from the two proof blocks so - // the DEEP reconstruction can skip pruned next-row openings. - let step_size = air.step_size(); - let num_eval_points = air.context().transition_offsets.len() * step_size; - let next_row_cols = air.trace_ood_next_row_columns(); - let ood_current = proof.trace_ood_evaluations(); - let ood_full = crate::ood::reconstruct_ood_full( - ood_current.row_major_data(), - ood_current.width(), - proof.trace_ood_next_evaluations().row_major_data(), - num_eval_points, - step_size, - &next_row_cols, - ); let (deep_poly_evaluations, deep_poly_evaluations_sym) = match Self::reconstruct_deep_composition_poly_evaluations_for_all_queries( challenges, domain, proof, - &ood_full, - &next_row_cols, + ood_full, + next_row_cols, step_size, ) { Some(pair) => pair, @@ -1368,6 +1360,7 @@ pub trait IsStarkVerifier< domain: &VerifierDomain, transcript: &mut impl IsStarkTranscript, rap_challenges: Vec>, + layout: &crate::ood::OodLayout, ) -> Challenges where FieldElement: AsBytes, @@ -1442,17 +1435,10 @@ pub trait IsStarkVerifier< // =================================== let num_terms_composition_poly = proof.composition_poly_parts_ood_evaluation().len(); - let num_eval_points = air.context().transition_offsets.len() * air.step_size(); - let next_row_cols = air.trace_ood_next_row_columns(); // Must match the prover's g·z pruning exactly (same AIR metadata): the // current-row block opens every column, the next-row block only the // transition-window columns. - let num_terms_trace = crate::ood::num_surviving_trace_openings( - air.context().trace_columns, - num_eval_points, - air.step_size(), - next_row_cols.len(), - ); + let num_terms_trace = layout.num_surviving(); let gamma = transcript.sample_field_element(); // <<<< Receive challenges: 𝛾, 𝛾' @@ -1464,13 +1450,7 @@ pub trait IsStarkVerifier< let trace_term_powers: Vec<_> = deep_composition_coefficients .drain(..num_terms_trace) .collect(); - let trace_term_coeffs = crate::ood::build_pruned_trace_term_coeffs( - &trace_term_powers, - air.context().trace_columns, - num_eval_points, - air.step_size(), - &next_row_cols, - ); + let trace_term_coeffs = layout.build_trace_term_coeffs(&trace_term_powers); // <<<< Receive challenges: 𝛾ⱼ, 𝛾ⱼ' let gammas = deep_composition_coefficients; @@ -1552,6 +1532,12 @@ pub trait IsStarkVerifier< return false; } + // The pruned-OOD layout, read from the AIR once and shared by the round-4 + // challenge replay, the block-shape guard, the single grid reconstruction, + // and both verify steps below — one reconstruction instead of the previous + // two, and no chance of the sites drifting apart. + let layout = Self::ood_layout(air); + #[cfg(feature = "instruments")] println!("- Started step 1: Recover challenges"); #[cfg(feature = "instruments")] @@ -1564,6 +1550,7 @@ pub trait IsStarkVerifier< &domain, transcript, rap_challenges, + &layout, ); // verify grinding @@ -1590,12 +1577,37 @@ pub trait IsStarkVerifier< #[cfg(feature = "instruments")] let timer2 = Instant::now(); + // Reject either OOD block whose shape disagrees with the AIR before + // reconstructing or using it, so a malicious prover cannot reshape them + // to dodge a check or desync the frame reconstruction. This guard used to + // run at the top of `step_2`; `step_3` silently relied on it. Now it runs + // once here, before both steps, and the full grid is reconstructed once + // and shared with them (one reconstruction instead of two). The Phase A + // loop in `multi_verify_views` runs the same guard even earlier, before + // Round 3 absorbs the next-row block. + if !Self::ood_blocks_well_formed(air, proof) { + #[cfg(not(feature = "test_fiat_shamir"))] + error!("Composition Polynomial verification failed"); + return false; + } + let ood_current = proof.trace_ood_evaluations(); + let ood_next = proof.trace_ood_next_evaluations(); + // Full current+next-row OOD grid (surviving values placed, pruned next-row + // entries zero — those are never read by any constraint). + let ood_full = layout.reconstruct_full( + ood_current.row_major_data(), + ood_current.width(), + ood_next.row_major_data(), + ); + if !Self::step_2_verify_claimed_composition_polynomial( air, proof, public_inputs, &domain, &challenges, + &ood_full, + layout.step_size(), ) { #[cfg(not(feature = "test_fiat_shamir"))] error!("Composition Polynomial verification failed"); @@ -1611,7 +1623,15 @@ pub trait IsStarkVerifier< #[cfg(feature = "instruments")] let timer3 = Instant::now(); - if !Self::step_3_verify_fri(air, proof, &domain, &challenges) { + if !Self::step_3_verify_fri( + air, + proof, + &domain, + &challenges, + &ood_full, + layout.next_row_cols(), + layout.step_size(), + ) { #[cfg(not(feature = "test_fiat_shamir"))] error!("FRI verification failed"); return false;