Skip to main content

p3_circle/
pcs.rs

1use alloc::collections::BTreeMap;
2use alloc::vec;
3use alloc::vec::Vec;
4use core::marker::PhantomData;
5
6use itertools::{Itertools, izip};
7use p3_challenger::{CanObserve, FieldChallenger, GrindingChallenger};
8use p3_commit::{Mmcs, OpenedValues, Pcs, PeriodicLdeTable, PolynomialSpace};
9use p3_field::extension::ComplexExtendable;
10use p3_field::{ExtensionField, Field, batch_multiplicative_inverse, dot_product};
11use p3_fri::verifier::FriError;
12use p3_fri::{BatchMultiOpening, FriParameters};
13use p3_matrix::dense::{RowMajorMatrix, RowMajorMatrixCow};
14use p3_matrix::row_index_mapped::RowIndexMappedView;
15use p3_matrix::{Dimensions, Matrix};
16use p3_util::log2_strict_usize;
17use p3_util::zip_eq::zip_eq;
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20use tracing::{debug_span, info_span};
21
22use crate::deep_quotient::{
23    VanishingParts, accumulate_deep_quotient, compute_vanishing_parts, deep_quotient_reduce_row,
24    extract_lambda,
25};
26use crate::domain::CircleDomain;
27use crate::folding::{
28    CircleFriFolding, CircleFriFoldingForMmcs, fold_row_with_inv_twiddle, fold_y,
29};
30use crate::point::{Point, compute_lagrange_den_batched};
31use crate::prover::prove;
32use crate::verifier::verify;
33use crate::{
34    CfftPerm, CfftPermutable, CircleEvaluations, CircleFriProof, build_periodic_lde_table_circle,
35    cfft_permute_index, cfft_permute_slice,
36};
37
38#[derive(Clone, Debug)]
39pub struct CirclePcs<Val: Field, InputMmcs, FriMmcs> {
40    pub mmcs: InputMmcs,
41    pub fri_params: FriParameters<FriMmcs>,
42    pub _phantom: PhantomData<Val>,
43}
44
45impl<Val: Field, InputMmcs, FriMmcs> CirclePcs<Val, InputMmcs, FriMmcs> {
46    pub const fn new(mmcs: InputMmcs, fri_params: FriParameters<FriMmcs>) -> Self {
47        Self {
48            mmcs,
49            fri_params,
50            _phantom: PhantomData,
51        }
52    }
53}
54
55#[derive(Serialize, Deserialize, Clone)]
56#[serde(bound = "")]
57pub struct CircleInputProof<
58    Val: Field,
59    Challenge: Field,
60    InputMmcs: Mmcs<Val>,
61    FriMmcs: Mmcs<Challenge>,
62> {
63    /// One multi-opening per input commitment, each covering every query with a
64    /// single shared proof.
65    input_openings: Vec<BatchMultiOpening<Val, InputMmcs>>,
66    /// `first_layer_siblings[query]` holds one sibling per committed height.
67    first_layer_siblings: Vec<Vec<Challenge>>,
68    /// One shared proof authenticating every query's first-layer row.
69    first_layer_proof: FriMmcs::MultiProof,
70}
71
72#[derive(Debug, Error)]
73pub enum InputError<InputMmcsError, FriMmcsError>
74where
75    InputMmcsError: core::fmt::Debug,
76    FriMmcsError: core::fmt::Debug,
77{
78    #[error("input MMCS error: {0:?}")]
79    InputMmcsError(InputMmcsError),
80    #[error("first layer MMCS error: {0:?}")]
81    FirstLayerMmcsError(FriMmcsError),
82    #[error("input shape error: mismatched dimensions")]
83    InputShapeError,
84    /// The opening point coincides with a queried domain point.
85    ///
86    /// The DEEP-quotient denominator vanishes there, so the row cannot be reduced.
87    #[error("opening point coincides with a query point")]
88    OpeningPointMatchesQueryPoint,
89    #[error(
90        "batch {batch}, matrix {matrix}: opened at no points; its width cannot be authenticated"
91    )]
92    MatrixWithoutOpeningPoints { batch: usize, matrix: usize },
93}
94
95#[derive(Serialize, Deserialize, Clone)]
96#[serde(bound(
97    serialize = "Witness: Serialize",
98    deserialize = "Witness: Deserialize<'de>"
99))]
100pub struct CirclePcsProof<
101    Val: Field,
102    Challenge: Field,
103    InputMmcs: Mmcs<Val>,
104    FriMmcs: Mmcs<Challenge>,
105    Witness,
106> {
107    first_layer_commitment: FriMmcs::Commitment,
108    lambdas: Vec<Challenge>,
109    fri_proof: CircleFriProof<
110        Challenge,
111        FriMmcs,
112        Witness,
113        CircleInputProof<Val, Challenge, InputMmcs, FriMmcs>,
114    >,
115}
116
117impl<Val, InputMmcs, FriMmcs, Challenge, Challenger> Pcs<Challenge, Challenger>
118    for CirclePcs<Val, InputMmcs, FriMmcs>
119where
120    Val: ComplexExtendable,
121    Challenge: ExtensionField<Val>,
122    InputMmcs: Mmcs<Val>,
123    FriMmcs: Mmcs<Challenge>,
124    Challenger: FieldChallenger<Val> + GrindingChallenger + CanObserve<FriMmcs::Commitment>,
125{
126    type Domain = CircleDomain<Val>;
127    type Commitment = InputMmcs::Commitment;
128    type ProverData = InputMmcs::ProverData<RowMajorMatrix<Val>>;
129    type EvaluationsOnDomain<'a> = RowIndexMappedView<CfftPerm, RowMajorMatrixCow<'a, Val>>;
130    type Proof = CirclePcsProof<Val, Challenge, InputMmcs, FriMmcs, Challenger::Witness>;
131    type Error = FriError<FriMmcs::Error, InputError<InputMmcs::Error, FriMmcs::Error>>;
132    const ZK: bool = false;
133
134    fn natural_domain_for_degree(&self, degree: usize) -> Self::Domain {
135        CircleDomain::standard(log2_strict_usize(degree))
136    }
137
138    fn log_max_lde_height(&self) -> usize {
139        Val::CIRCLE_TWO_ADICITY - 1
140    }
141
142    fn commit(
143        &self,
144        evaluations: impl IntoIterator<Item = (Self::Domain, RowMajorMatrix<Val>)>,
145    ) -> (Self::Commitment, Self::ProverData) {
146        let ldes = evaluations
147            .into_iter()
148            .map(|(domain, evals)| {
149                assert!(
150                    domain.log_n >= 2,
151                    "CirclePcs cannot commit to a matrix with fewer than 4 rows.",
152                    // (because we bivariate fold one bit, and fri needs one more bit)
153                );
154                CircleEvaluations::from_natural_order(domain, evals)
155                    .extrapolate(CircleDomain::standard(
156                        domain.log_n + self.fri_params.log_blowup,
157                    ))
158                    .to_cfft_order()
159            })
160            .collect_vec();
161        let (comm, mmcs_data) = self.mmcs.commit(ldes);
162        (comm, mmcs_data)
163    }
164
165    fn get_quotient_ldes(
166        &self,
167        evaluations: impl IntoIterator<Item = (Self::Domain, RowMajorMatrix<Val>)>,
168        _num_chunks: usize,
169    ) -> Vec<RowMajorMatrix<Val>> {
170        evaluations
171            .into_iter()
172            .map(|(domain, evals)| {
173                assert!(
174                    domain.log_n >= 2,
175                    "CirclePcs cannot commit to a matrix with fewer than 4 rows.",
176                    // (because we bivariate fold one bit, and fri needs one more bit)
177                );
178                CircleEvaluations::from_natural_order(domain, evals)
179                    .extrapolate(CircleDomain::standard(
180                        domain.log_n + self.fri_params.log_blowup,
181                    ))
182                    .to_cfft_order()
183            })
184            .collect_vec()
185    }
186
187    fn commit_ldes(&self, ldes: Vec<RowMajorMatrix<Val>>) -> (Self::Commitment, Self::ProverData) {
188        self.mmcs.commit(ldes)
189    }
190
191    fn get_evaluations_on_domain<'a>(
192        &self,
193        data: &'a Self::ProverData,
194        idx: usize,
195        domain: Self::Domain,
196    ) -> Self::EvaluationsOnDomain<'a> {
197        let mat = self.mmcs.get_matrices(data)[idx].as_view();
198        let committed_domain = CircleDomain::standard(log2_strict_usize(mat.height()));
199        if domain == committed_domain {
200            mat.as_cow().cfft_perm_rows()
201        } else {
202            // The committed matrix is the LDE of a polynomial of `committed_domain.log_n -
203            // log_blowup` coefficients. The first `2^log_sub` CFFT-ordered rows of the LDE
204            // are exactly the CFFT-ordered evaluations over the smaller `sub_domain` of that
205            // size (see `eval_at_point_on_subdomain_prefix_matches_full`), so interpolating
206            // that prefix instead of the full committed matrix recovers the same coefficients
207            // at `1 / blowup` of the CFFT work. This also lets `domain` be smaller than the
208            // committed LDE (e.g. a quotient domain when `log_blowup` exceeds the quotient
209            // degree), which `extrapolate` would reject.
210            let log_sub = committed_domain.log_n - self.fri_params.log_blowup;
211            let sub_domain = CircleDomain::new(log_sub, committed_domain.shift);
212            let coeffs =
213                CircleEvaluations::from_cfft_order(sub_domain, mat.split_rows(1 << log_sub).0)
214                    .interpolate();
215            CircleEvaluations::evaluate(domain, coeffs)
216                .to_cfft_order()
217                .as_cow()
218                .cfft_perm_rows()
219        }
220    }
221
222    fn open(
223        &self,
224        // For each round,
225        rounds: Vec<(
226            &Self::ProverData,
227            // for each matrix,
228            Vec<
229                // points to open
230                Vec<Challenge>,
231            >,
232        )>,
233        challenger: &mut Challenger,
234    ) -> (OpenedValues<Challenge>, Self::Proof) {
235        // Materialize the CFFT-ordered domain points once per committed height. They are shared
236        // by the Lagrange denominators and the DEEP-quotient vanishing parts below, which are in
237        // turn shared by every matrix opened at the same point on the same domain.
238        let mut permuted_points: BTreeMap<usize, Vec<Point<Val>>> = BTreeMap::new();
239        debug_span!("materialize domain points").in_scope(|| {
240            for (data, _) in &rounds {
241                for mat in self.mmcs.get_matrices(data) {
242                    let log_height = log2_strict_usize(mat.height());
243                    permuted_points.entry(log_height).or_insert_with(|| {
244                        cfft_permute_slice(&CircleDomain::standard(log_height).points_vec())
245                    });
246                }
247            }
248        });
249
250        // (log_height, point) -> Lagrange denominators.
251        let mut lagrange_dens: Vec<((usize, Challenge), Vec<Challenge>)> = vec![];
252
253        // Open matrices at points
254        let values: OpenedValues<Challenge> = rounds
255            .iter()
256            .map(|(data, points_for_mats)| {
257                let mats = self.mmcs.get_matrices(data);
258                debug_assert_eq!(
259                    mats.len(),
260                    points_for_mats.len(),
261                    "Mismatched number of matrices and points"
262                );
263                izip!(mats, points_for_mats)
264                    .map(|(mat, points_for_mat)| {
265                        let log_height = log2_strict_usize(mat.height());
266                        // The committed polynomial has degree below the pre-blow-up domain
267                        // size, so its values on a sub-twin-coset of that size determine it.
268                        // The first `2^log_sub` rows of the CFFT-ordered LDE are exactly the
269                        // CFFT-ordered evaluations over `CircleDomain::new(log_sub, shift)`
270                        // (see `eval_at_point_on_subdomain_prefix_matches_full`), so the
271                        // out-of-domain evaluation only traverses `1 / blowup` of the matrix.
272                        let log_sub = log_height - self.fri_params.log_blowup;
273                        let sub_height = 1 << log_sub;
274                        let sub_domain = CircleDomain::new(
275                            log_sub,
276                            CircleDomain::<Val>::standard(log_height).shift,
277                        );
278                        // It was committed in cfft order.
279                        let evals = CircleEvaluations::from_cfft_order(
280                            sub_domain,
281                            mat.split_rows(sub_height).0,
282                        );
283
284                        // Resolve the Lagrange denominators for every point up front.
285                        let den_idxs = points_for_mat
286                            .iter()
287                            .map(|&zeta_uni| {
288                                let key = (log_height, zeta_uni);
289                                lagrange_dens
290                                    .iter()
291                                    .position(|(k, _)| *k == key)
292                                    .unwrap_or_else(|| {
293                                        let den = info_span!("compute Lagrange denominators")
294                                            .in_scope(|| {
295                                                compute_lagrange_den_batched(
296                                                    &permuted_points[&log_height][..sub_height],
297                                                    Point::from_projective_line(zeta_uni),
298                                                    log_sub,
299                                                )
300                                            });
301                                        lagrange_dens.push((key, den));
302                                        lagrange_dens.len() - 1
303                                    })
304                            })
305                            .collect_vec();
306
307                        let ps_for_points: Vec<Vec<Challenge>> =
308                            debug_span!("compute opened values with Lagrange interpolation")
309                                .in_scope(|| match (&points_for_mat[..], &den_idxs[..]) {
310                                    // A matrix opened at two points (e.g. zeta and zeta_next)
311                                    // is traversed once for both.
312                                    (&[zeta_0, zeta_1], &[idx_0, idx_1]) => evals
313                                        .evaluate_at_two_points_with_dens(
314                                            [
315                                                Point::from_projective_line(zeta_0),
316                                                Point::from_projective_line(zeta_1),
317                                            ],
318                                            [&lagrange_dens[idx_0].1, &lagrange_dens[idx_1].1],
319                                        )
320                                        .into(),
321                                    _ => izip!(points_for_mat, &den_idxs)
322                                        .map(|(&zeta_uni, &den_idx)| {
323                                            evals.evaluate_at_point_with_den(
324                                                Point::from_projective_line(zeta_uni),
325                                                &lagrange_dens[den_idx].1,
326                                            )
327                                        })
328                                        .collect(),
329                                });
330
331                        for ps_at_zeta in &ps_for_points {
332                            challenger.observe_algebra_slice(ps_at_zeta);
333                        }
334                        ps_for_points
335                    })
336                    .collect()
337            })
338            .collect();
339        drop(lagrange_dens);
340
341        // Batch combination challenge
342        let alpha: Challenge = challenger.sample_algebra_element();
343
344        /*
345        We are reducing columns ("ro" = reduced opening) with powers of alpha:
346          ro = .. + α^n c_n + α^(n+1) c_(n+1) + ..
347        But we want to precompute small powers of alpha, and batch the columns. So we can do:
348          ro = .. + α^n (α^0 c_n + α^1 c_(n+1) + ..) + ..
349        reusing the α^0, α^1, etc., then at the end of each column batch we multiply by the α^n.
350        (Due to circle stark specifics, we need 2 powers of α for each column, so actually α^(2n)).
351        We store this α^(2n), the running reducing factor per log_height, and call it the "alpha offset".
352        */
353
354        // log_height -> (alpha offset, reduced openings column)
355        let mut reduced_openings: BTreeMap<usize, (Challenge, Vec<Challenge>)> = BTreeMap::new();
356
357        // (log_height, point) -> DEEP-quotient vanishing parts.
358        let mut vanishing_parts: Vec<((usize, Challenge), VanishingParts<Challenge>)> = vec![];
359
360        rounds
361            .iter()
362            .zip(values.iter())
363            .for_each(|((data, points_for_mats), values)| {
364                let mats = self.mmcs.get_matrices(data);
365                izip!(mats, points_for_mats, values).for_each(|(mat, points_for_mat, values)| {
366                    let log_height = log2_strict_usize(mat.height());
367                    let log_sub = log_height - self.fri_params.log_blowup;
368
369                    let (alpha_offset, reduced_opening_for_log_height) = reduced_openings
370                        .entry(log_height)
371                        .or_insert_with(|| (Challenge::ONE, Challenge::zero_vec(1 << log_height)));
372
373                    // The lift below costs a single-column CFFT extrapolation, which is
374                    // latency-bound rather than bandwidth-bound: it costs about as much as
375                    // the half-traversal of a ~1000-column matrix it saves, so it only pays
376                    // off for matrices substantially wider than that.
377                    const LIFT_MIN_WIDTH: usize = 1024;
378
379                    // The only pass over the matrix; it does not depend on the opening points.
380                    // The reduced column lies in the pre-blow-up polynomial space, so it is
381                    // determined by the trace-size subdomain prefix (committed in cfft order):
382                    // reduce the prefix and lift it back with a narrow CFFT instead of
383                    // traversing the full LDE.
384                    let reduced_rows = if log_sub > 0 && mat.width() >= LIFT_MIN_WIDTH {
385                        let sub_domain = CircleDomain::new(
386                            log_sub,
387                            CircleDomain::<Val>::standard(log_height).shift,
388                        );
389                        CircleEvaluations::from_cfft_order(
390                            sub_domain,
391                            mat.split_rows(1 << log_sub).0,
392                        )
393                        .rowwise_alpha_reduce_lifted(alpha, CircleDomain::standard(log_height))
394                    } else {
395                        CircleEvaluations::from_cfft_order(
396                            CircleDomain::standard(log_height),
397                            mat.as_view(),
398                        )
399                        .rowwise_alpha_reduce(alpha)
400                    };
401                    let alpha_pow_width = alpha.exp_u64(mat.width() as u64);
402
403                    points_for_mat
404                        .iter()
405                        .zip(values.iter())
406                        .for_each(|(&zeta_uni, ps_at_zeta)| {
407                            let zeta = Point::from_projective_line(zeta_uni);
408                            let key = (log_height, zeta_uni);
409                            let vp_idx = vanishing_parts
410                                .iter()
411                                .position(|(k, _)| *k == key)
412                                .unwrap_or_else(|| {
413                                    let vp = compute_vanishing_parts(
414                                        &permuted_points[&log_height],
415                                        zeta,
416                                    );
417                                    vanishing_parts.push((key, vp));
418                                    vanishing_parts.len() - 1
419                                });
420
421                            // sum_j(alpha^j * p_j[zeta]), the same for all rows.
422                            let reduced_ps_at_zeta: Challenge =
423                                dot_product(alpha.powers(), ps_at_zeta.iter().copied());
424
425                            // Reduce this matrix, as a deep quotient, into the running
426                            // reduction, offset by alpha_offset.
427                            accumulate_deep_quotient(
428                                reduced_opening_for_log_height,
429                                *alpha_offset,
430                                alpha_pow_width,
431                                &reduced_rows,
432                                &vanishing_parts[vp_idx].1,
433                                reduced_ps_at_zeta,
434                            );
435
436                            // Update alpha_offset from α^i -> α^(i + 2 * width)
437                            *alpha_offset *= alpha_pow_width.square();
438                        });
439                });
440            });
441        drop(vanishing_parts);
442
443        // Iterate over our reduced columns and extract lambda - the multiple of the vanishing polynomial
444        // which may appear in the reduced quotient due to CFFT dimension gap.
445
446        let mut lambdas = vec![];
447        let mut log_heights = vec![];
448        let first_layer_mats: Vec<RowMajorMatrix<Challenge>> = reduced_openings
449            .into_iter()
450            .map(|(log_height, (_, mut ro))| {
451                assert!(log_height > 0);
452                log_heights.push(log_height);
453                let lambda = extract_lambda(&mut ro, self.fri_params.log_blowup);
454                lambdas.push(lambda);
455                // Prepare for first layer fold with 2 siblings per leaf.
456                RowMajorMatrix::new(ro, 2)
457            })
458            .collect();
459        let log_max_height = log_heights.iter().max().copied().unwrap();
460
461        // Commit to reduced openings at each log_height, so we can challenge a global
462        // folding factor for all first layers, which we use for a "manual" (not part of p3-fri) fold.
463        // This is necessary because the first layer of folding uses different twiddles, so it's easiest
464        // to do it here, before p3-fri.
465
466        let (first_layer_commitment, first_layer_data) =
467            self.fri_params.mmcs.commit(first_layer_mats);
468        challenger.observe(first_layer_commitment.clone());
469        let bivariate_beta: Challenge = challenger.sample_algebra_element();
470
471        // Fold all first layers at bivariate_beta.
472
473        let fri_input: Vec<Vec<Challenge>> = self
474            .fri_params
475            .mmcs
476            .get_matrices(&first_layer_data)
477            .into_iter()
478            .map(|m| fold_y(bivariate_beta, m))
479            // Reverse, because FRI expects descending by height
480            .rev()
481            .collect();
482
483        let folding: CircleFriFoldingForMmcs<Val, Challenge, InputMmcs, FriMmcs> =
484            CircleFriFolding(PhantomData);
485
486        let fri_proof = prove(
487            &folding,
488            &self.fri_params,
489            fri_input,
490            challenger,
491            |indices| {
492                // CircleFriFolder asks for an extra query index bit, so we use that here to index
493                // the first layer fold.
494
495                // Open the input (big opening, lots of columns) at every full index. Queries into
496                // one committed tree share a single proof, so overlapping paths ship once.
497                let input_openings = rounds
498                    .iter()
499                    .map(|(data, _)| {
500                        let log_max_batch_height =
501                            log2_strict_usize(self.mmcs.get_max_height(data));
502                        let bits_reduced = log_max_height - log_max_batch_height;
503                        let reduced_indices: Vec<usize> =
504                            indices.iter().map(|&index| index >> bits_reduced).collect();
505                        let (opened_values, opening_proof) =
506                            self.mmcs.open_multi_batch(&reduced_indices, data);
507                        BatchMultiOpening {
508                            opened_values,
509                            opening_proof,
510                        }
511                    })
512                    .collect();
513
514                // We committed to first_layer in pairs, so open the reduced index and include the sibling
515                // as part of the input proof.
516                let paired_indices: Vec<usize> = indices.iter().map(|&index| index >> 1).collect();
517                let (first_layer_values, first_layer_proof) = self
518                    .fri_params
519                    .mmcs
520                    .open_multi_batch(&paired_indices, &first_layer_data);
521                let first_layer_siblings = izip!(indices, first_layer_values)
522                    .map(|(&index, values)| {
523                        izip!(&values, &log_heights)
524                            .map(|(v, log_height)| {
525                                let reduced_index = index >> (log_max_height - log_height);
526                                let sibling_index = (reduced_index & 1) ^ 1;
527                                v[sibling_index]
528                            })
529                            .collect()
530                    })
531                    .collect();
532                CircleInputProof {
533                    input_openings,
534                    first_layer_siblings,
535                    first_layer_proof,
536                }
537            },
538        );
539
540        (
541            values,
542            CirclePcsProof {
543                first_layer_commitment,
544                lambdas,
545                fri_proof,
546            },
547        )
548    }
549
550    fn verify(
551        &self,
552        // For each round:
553        rounds: Vec<(
554            Self::Commitment,
555            // for each matrix:
556            Vec<(
557                // its domain,
558                Self::Domain,
559                // for each point:
560                Vec<(
561                    // the point,
562                    Challenge,
563                    // values at the point
564                    Vec<Challenge>,
565                )>,
566            )>,
567        )>,
568        proof: &Self::Proof,
569        challenger: &mut Challenger,
570    ) -> Result<(), Self::Error> {
571        // Write evaluations to challenger
572        for (_, round) in &rounds {
573            for (_, mat) in round {
574                for (_, point) in mat {
575                    challenger.observe_algebra_slice(point);
576                }
577            }
578        }
579
580        // Batch combination challenge
581        let alpha: Challenge = challenger.sample_algebra_element();
582
583        // Per (batch, matrix) `alpha^width` and `alpha^(2*width)`, plus a shared table of
584        // `alpha`'s powers up to the widest matrix. A matrix's width is fixed by the
585        // verifier's own claimed evaluations (`rounds`), independent of the query, so
586        // computing these once here replaces recomputing them on every (query, matrix)
587        // or (query, matrix, point) inside the per-query closure below.
588        let matrix_alpha_pows: Vec<Vec<(Challenge, Challenge)>> = rounds
589            .iter()
590            .map(|(_, mats)| {
591                mats.iter()
592                    .map(|(_, points_and_values)| {
593                        let width = points_and_values.first().map_or(0, |(_, v)| v.len());
594                        let alpha_pow_width = alpha.exp_u64(width as u64);
595                        (alpha_pow_width, alpha_pow_width.square())
596                    })
597                    .collect()
598            })
599            .collect();
600        let max_width = rounds
601            .iter()
602            .flat_map(|(_, mats)| mats.iter())
603            .flat_map(|(_, points_and_values)| points_and_values.iter().map(|(_, v)| v.len()))
604            .max()
605            .unwrap_or(0);
606        let alpha_powers: Vec<Challenge> = alpha.powers().collect_n(max_width);
607
608        challenger.observe(proof.first_layer_commitment.clone());
609        let bivariate_beta: Challenge = challenger.sample_algebra_element();
610
611        // +1 to account for first layer
612        let log_global_max_height =
613            proof.fri_proof.commit_phase_commits.len() + self.fri_params.log_blowup + 1;
614
615        // Guard the query-phase height subtraction against an under-reported round count.
616        //
617        // Invariant: the proof's global height covers every claimed matrix.
618        //
619        //     H_proof = commit-phase round count + log_blowup + 1   (first-layer fold)
620        //     H_claim = max committed log_n + log_blowup
621        //
622        // The query phase computes `index >> (log_global_max_height - log_height)`.
623        //   - `log_height <= H_claim` holds for every matrix
624        //   - `H_proof < H_claim` makes that usize subtraction underflow and the shift wrap
625        // Over-reporting is caught downstream (two-adicity bound, Merkle openings), so the
626        // under-report is the only case to reject here.
627        let expected_log_global_max_height = rounds
628            .iter()
629            .flat_map(|(_, mats)| {
630                mats.iter()
631                    .map(|(domain, _)| domain.log_n + self.fri_params.log_blowup)
632            })
633            .max();
634        if let Some(expected) = expected_log_global_max_height
635            && log_global_max_height < expected
636        {
637            return Err(FriError::GlobalMaxHeightMismatch {
638                expected,
639                got: log_global_max_height,
640            });
641        }
642
643        let folding: CircleFriFoldingForMmcs<Val, Challenge, InputMmcs, FriMmcs> =
644            CircleFriFolding(PhantomData);
645
646        verify(
647            &folding,
648            &self.fri_params,
649            &proof.fri_proof,
650            challenger,
651            |indices, input_proof| {
652                let CircleInputProof {
653                    input_openings,
654                    first_layer_siblings,
655                    first_layer_proof,
656                } = input_proof;
657
658                // One sibling set per query, one opened-row set per query per batch.
659                if first_layer_siblings.len() != indices.len() {
660                    return Err(InputError::InputShapeError);
661                }
662                for batch_opening in input_openings {
663                    if batch_opening.opened_values.len() != indices.len() {
664                        return Err(InputError::InputShapeError);
665                    }
666                }
667
668                // Check every input commitment's shared multi-opening once, before the
669                // per-query arithmetic reads any opened value.
670                for (batch, (batch_opening, (batch_commit, mats))) in
671                    zip_eq(input_openings, &rounds, InputError::InputShapeError)?.enumerate()
672                {
673                    let batch_heights: Vec<usize> = mats
674                        .iter()
675                        .map(|(domain, _)| domain.size() << self.fri_params.log_blowup)
676                        .collect_vec();
677                    // The opened rows must pair one-to-one with the committed matrices.
678                    for opened_values in &batch_opening.opened_values {
679                        if opened_values.len() != mats.len() {
680                            return Err(InputError::InputShapeError);
681                        }
682                    }
683                    let batch_dims: Vec<Dimensions> = batch_heights
684                        .iter()
685                        .zip(mats)
686                        .enumerate()
687                        .map(|(matrix, (&height, (_, points_and_values)))| {
688                            // Invariant: a matrix's width is fixed by its first opening point.
689                            //
690                            //     >= 1 point  ->  width = number of claimed evaluations
691                            //     no points   ->  reject
692                            //
693                            // Why reject the no-points case:
694                            //   - row boundaries in the flattened leaf hash are authenticated only from claimed widths
695                            //   - a matrix opened at no points claims no width
696                            //   - its width could then come only from the unverified proof
697                            let (_, values) = points_and_values
698                                .first()
699                                .ok_or(InputError::MatrixWithoutOpeningPoints { batch, matrix })?;
700                            Ok(Dimensions {
701                                width: values.len(),
702                                height,
703                            })
704                        })
705                        .collect::<Result<Vec<_>, _>>()?;
706
707                    let (dims, reduced_indices) = batch_heights
708                        .iter()
709                        .max()
710                        .map(|x| log2_strict_usize(*x))
711                        .map_or_else(
712                            ||
713                            // Empty batch?
714                            (&[][..], vec![0; indices.len()]),
715                            |log_batch_max_height| {
716                                let bits_reduced = log_global_max_height - log_batch_max_height;
717                                (
718                                    &batch_dims[..],
719                                    indices.iter().map(|&i| i >> bits_reduced).collect_vec(),
720                                )
721                            },
722                        );
723
724                    self.mmcs
725                        .verify_multi_batch(
726                            batch_commit,
727                            dims,
728                            &reduced_indices,
729                            &batch_opening.opened_values,
730                            &batch_opening.opening_proof,
731                        )
732                        .map_err(InputError::InputMmcsError)?;
733                }
734
735                // Per query, reduce the (now authenticated) opened rows into the FRI inputs
736                // and rebuild the first-layer leaves that the shared proof will authenticate.
737                let mut all_fri_inputs = Vec::with_capacity(indices.len());
738                let mut fl_leaves_by_query = Vec::with_capacity(indices.len());
739                let mut fl_dims: Vec<Dimensions> = Vec::new();
740
741                for (query, &index) in indices.iter().enumerate() {
742                    // log_height -> (alpha_offset, ro)
743                    let mut reduced_openings = BTreeMap::new();
744
745                    for (batch, (batch_opening, (_, mats))) in
746                        zip_eq(input_openings, &rounds, InputError::InputShapeError)?.enumerate()
747                    {
748                        for (matrix, (ps_at_x, (mat_domain, mat_points_and_values))) in zip_eq(
749                            &batch_opening.opened_values[query],
750                            mats,
751                            InputError::InputShapeError,
752                        )?
753                        .enumerate()
754                        {
755                            let log_height = mat_domain.log_n + self.fri_params.log_blowup;
756                            let bits_reduced = log_global_max_height - log_height;
757                            let orig_idx = cfft_permute_index(index >> bits_reduced, log_height);
758
759                            let committed_domain = CircleDomain::standard(log_height);
760                            let x = committed_domain.nth_point(orig_idx);
761
762                            let (alpha_offset, ro) = reduced_openings
763                                .entry(log_height)
764                                .or_insert((Challenge::ONE, Challenge::ZERO));
765                            let (alpha_pow_width, alpha_pow_width_2) =
766                                matrix_alpha_pows[batch][matrix];
767
768                            for (zeta_uni, ps_at_zeta) in mat_points_and_values {
769                                // The claimed opening must have exactly as many
770                                // values as the committed row has columns.
771                                if ps_at_zeta.len() != ps_at_x.len() {
772                                    return Err(InputError::InputShapeError);
773                                }
774                                let zeta = Point::from_projective_line(*zeta_uni);
775
776                                // A vanishing denominator means this opening point lands on the
777                                // query point; reject the proof rather than dividing by zero.
778                                *ro += *alpha_offset
779                                    * deep_quotient_reduce_row(
780                                        alpha_pow_width,
781                                        &alpha_powers,
782                                        x,
783                                        zeta,
784                                        ps_at_x,
785                                        ps_at_zeta,
786                                    )
787                                    .ok_or(InputError::OpeningPointMatchesQueryPoint)?;
788
789                                *alpha_offset *= alpha_pow_width_2;
790                            }
791                        }
792                    }
793
794                    // Verify bivariate fold and lambda correction
795
796                    // First pass: derive the lambda-corrected leaf values and each height's
797                    // first-layer (y) twiddle, without folding yet. The fold pairs a point with
798                    // its negation, so the canonical (b=0) twiddle is `p.y` (sign-flipped when
799                    // this query landed on the b=1 member) - the same point `p` already computed
800                    // for the lambda correction, with no separate `nth_y_twiddle` scalar
801                    // multiplication. All these per-height twiddles are then inverted in a single
802                    // batch instead of one inversion per height.
803                    let per_height: Vec<_> = zip_eq(
804                        zip_eq(
805                            reduced_openings,
806                            &first_layer_siblings[query],
807                            InputError::InputShapeError,
808                        )?,
809                        &proof.lambdas,
810                        InputError::InputShapeError,
811                    )?
812                    .map(|(((log_height, (_, ro)), &fl_sib), &lambda)| {
813                        assert!(log_height > 0);
814
815                        let orig_size = log_height - self.fri_params.log_blowup;
816                        let bits_reduced = log_global_max_height - log_height;
817                        let b = (index >> bits_reduced) & 1;
818                        let orig_idx = cfft_permute_index(index >> bits_reduced, log_height);
819
820                        let lde_domain = CircleDomain::standard(log_height);
821                        let p: Point<Val> = lde_domain.nth_point(orig_idx);
822
823                        let lambda_corrected = ro - lambda * p.v_n(orig_size);
824
825                        let mut fl_values = vec![lambda_corrected; 2];
826                        fl_values[b ^ 1] = fl_sib;
827
828                        let y_twiddle = if b == 0 { p.y } else { -p.y };
829
830                        let dims = Dimensions {
831                            // First-layer leaves hold the queried value and its sibling.
832                            width: 2,
833                            height: 1 << (log_height - 1),
834                        };
835
836                        (log_height, y_twiddle, fl_values, dims)
837                    })
838                    .collect();
839
840                    let y_twiddles_inv = batch_multiplicative_inverse(
841                        &per_height.iter().map(|&(_, t, _, _)| t).collect_vec(),
842                    );
843
844                    let (mut fri_input, query_fl_dims, fl_leaves): (Vec<_>, Vec<_>, Vec<_>) =
845                        per_height
846                            .into_iter()
847                            .zip(y_twiddles_inv)
848                            .map(|((log_height, _, fl_values, dims), y_twiddle_inv)| {
849                                let fri_input = (
850                                    // - 1 here is because we have already folded a layer.
851                                    log_height - 1,
852                                    fold_row_with_inv_twiddle(
853                                        y_twiddle_inv,
854                                        bivariate_beta,
855                                        fl_values.iter().copied(),
856                                    ),
857                                );
858                                (fri_input, dims, fl_values)
859                            })
860                            .multiunzip();
861
862                    // sort descending
863                    fri_input.reverse();
864
865                    // The committed first-layer shape is the same for every query.
866                    if query == 0 {
867                        fl_dims = query_fl_dims;
868                    }
869
870                    all_fri_inputs.push(fri_input);
871                    fl_leaves_by_query.push(fl_leaves);
872                }
873
874                // One shared check for every query's first-layer row.
875                let paired_indices = indices.iter().map(|&i| i >> 1).collect_vec();
876                self.fri_params
877                    .mmcs
878                    .verify_multi_batch(
879                        &proof.first_layer_commitment,
880                        &fl_dims,
881                        &paired_indices,
882                        &fl_leaves_by_query,
883                        first_layer_proof,
884                    )
885                    .map_err(InputError::FirstLayerMmcsError)?;
886
887                Ok(all_fri_inputs)
888            },
889        )
890    }
891
892    fn build_periodic_lde_table(
893        &self,
894        periodic_cols: &[Vec<Val>],
895        trace_domain: Self::Domain,
896        quotient_domain: Self::Domain,
897    ) -> PeriodicLdeTable<Val> {
898        build_periodic_lde_table_circle(periodic_cols, &trace_domain, &quotient_domain)
899    }
900}
901
902#[cfg(test)]
903mod tests {
904    use p3_challenger::{HashChallenger, SerializingChallenger32};
905    use p3_commit::ExtensionMmcs;
906    use p3_field::PrimeCharacteristicRing;
907    use p3_field::extension::BinomialExtensionField;
908    use p3_fri::FriParameters;
909    use p3_fri::verifier::FriError;
910    use p3_keccak::Keccak256Hash;
911    use p3_merkle_tree::MerkleTreeMmcs;
912    use p3_mersenne_31::Mersenne31;
913    use p3_symmetric::{CompressionFunctionFromHasher, SerializingHasher};
914    use rand::rngs::SmallRng;
915    use rand::{RngExt, SeedableRng};
916
917    use super::*;
918
919    type Val = Mersenne31;
920    type Challenge = BinomialExtensionField<Mersenne31, 3>;
921    type ByteHash = Keccak256Hash;
922    type FieldHash = SerializingHasher<ByteHash>;
923    type MyCompress = CompressionFunctionFromHasher<ByteHash, 2, 32>;
924    type ValMmcs = MerkleTreeMmcs<Val, u8, FieldHash, MyCompress, 2, 32>;
925    type ChallengeMmcs = ExtensionMmcs<Val, Challenge, ValMmcs>;
926    type Challenger = SerializingChallenger32<Val, HashChallenger<u8, ByteHash, 32>>;
927    type TestPcs = CirclePcs<Val, ValMmcs, ChallengeMmcs>;
928    type TestError = FriError<
929        <ChallengeMmcs as Mmcs<Challenge>>::Error,
930        InputError<<ValMmcs as Mmcs<Val>>::Error, <ChallengeMmcs as Mmcs<Challenge>>::Error>,
931    >;
932
933    /// Build a valid Circle PCS proof for a random single-column trace.
934    ///
935    /// Returns all the pieces needed to verify (or re-verify after mutation):
936    /// the PCS instance, hasher seed, commitment, domain, evaluation point,
937    /// opened values, and the proof itself.
938    ///
939    /// # Fixture parameters
940    ///
941    /// - Trace: 2^{10} = 1024 rows, 1 column of random field elements.
942    /// - FRI: testing parameters with log_blowup = 2, log_final_poly_len = 0.
943    /// - Hash: Keccak-256 with a binary Merkle tree.
944    #[allow(clippy::type_complexity)]
945    fn setup_valid_proof() -> (
946        TestPcs,
947        ByteHash,
948        <ValMmcs as Mmcs<Val>>::Commitment,
949        CircleDomain<Val>,
950        Challenge,
951        Vec<Vec<Vec<Vec<Challenge>>>>,
952        CirclePcsProof<Val, Challenge, ValMmcs, ChallengeMmcs, Val>,
953    ) {
954        let mut rng = SmallRng::seed_from_u64(0);
955
956        // Build the hash stack: field hasher → compression → Merkle tree.
957        let byte_hash = ByteHash {};
958        let field_hash = FieldHash::new(byte_hash);
959        let compress = MyCompress::new(byte_hash);
960        let val_mmcs = ValMmcs::new(field_hash, compress, 0);
961
962        // Wrap the value-domain Merkle tree for extension-field leaves.
963        let challenge_mmcs = ChallengeMmcs::new(val_mmcs.clone());
964
965        // Minimal FRI parameters for fast test execution.
966        let fri_params = FriParameters::new_testing(challenge_mmcs, 0);
967
968        let pcs = TestPcs {
969            mmcs: val_mmcs,
970            fri_params,
971            _phantom: PhantomData,
972        };
973
974        // Generate a random trace on a circle domain of size 2^{10}.
975        let log_n = 10;
976        let d =
977            <TestPcs as Pcs<Challenge, Challenger>>::natural_domain_for_degree(&pcs, 1 << log_n);
978
979        let evals = RowMajorMatrix::rand(&mut rng, 1 << log_n, 1);
980
981        // Commit to the trace and produce the Merkle root.
982        let (comm, data) = <TestPcs as Pcs<Challenge, Challenger>>::commit(&pcs, [(d, evals)]);
983
984        // Random evaluation point in the extension field.
985        let zeta: Challenge = rng.random();
986
987        // Generate the opening proof at the chosen evaluation point.
988        let mut chal = Challenger::from_hasher(vec![], byte_hash);
989        let (values, proof) = pcs.open(vec![(&data, vec![vec![zeta]])], &mut chal);
990
991        (pcs, byte_hash, comm, d, zeta, values, proof)
992    }
993
994    /// Run the PCS verifier with the given proof and return the result.
995    ///
996    /// This is a thin wrapper that reconstructs a fresh challenger and
997    /// calls the verification routine. Tests use it to verify both valid
998    /// proofs and intentionally malformed ones.
999    fn try_verify(
1000        pcs: &TestPcs,
1001        byte_hash: ByteHash,
1002        comm: &<ValMmcs as Mmcs<Val>>::Commitment,
1003        d: CircleDomain<Val>,
1004        zeta: Challenge,
1005        values: &[Vec<Vec<Vec<Challenge>>>],
1006        proof: &CirclePcsProof<Val, Challenge, ValMmcs, ChallengeMmcs, Val>,
1007    ) -> Result<(), TestError> {
1008        // Build a fresh challenger from the same seed so the transcript
1009        // replays identically to what the prover produced.
1010        let mut chal = Challenger::from_hasher(vec![], byte_hash);
1011        pcs.verify(
1012            vec![(
1013                comm.clone(),
1014                vec![(d, vec![(zeta, values[0][0][0].clone())])],
1015            )],
1016            proof,
1017            &mut chal,
1018        )
1019    }
1020
1021    #[test]
1022    fn circle_pcs() {
1023        // Smoke test: an honestly generated proof must verify successfully.
1024        let (pcs, byte_hash, comm, d, zeta, values, proof) = setup_valid_proof();
1025        try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof).expect("verify err");
1026    }
1027
1028    #[test]
1029    fn reject_matrix_without_opening_points() {
1030        // Invariant: every input matrix must be opened at >= 1 point.
1031        //
1032        //     no points  ->  no claimed width  ->  width would come from the proof  ->  reject
1033        //
1034        // A matrix opened at no points observes nothing into the challenger.
1035        // This holds identically on the proving side and the verifying side.
1036        //
1037        // Fixture state: one batch of two matrices sharing a domain.
1038        //   - matrix 0 is opened at one point, keeping the reduced openings non-empty
1039        //   - matrix 1 is opened at no points
1040        //
1041        // Flow:
1042        //   - both sides observe only matrix 0  ->  proof-of-work challenge matches
1043        //   - the query phase verifies the input opening  ->  matrix 1 rejected
1044        let mut rng = SmallRng::seed_from_u64(0);
1045
1046        let byte_hash = ByteHash {};
1047        let field_hash = FieldHash::new(byte_hash);
1048        let compress = MyCompress::new(byte_hash);
1049        let val_mmcs = ValMmcs::new(field_hash, compress, 0);
1050        let challenge_mmcs = ChallengeMmcs::new(val_mmcs.clone());
1051        let fri_params = FriParameters::new_testing(challenge_mmcs, 0);
1052        let pcs = TestPcs {
1053            mmcs: val_mmcs,
1054            fri_params,
1055            _phantom: PhantomData,
1056        };
1057
1058        // One batch, two single-column matrices sharing a domain of 2^{10} rows.
1059        let log_n = 10;
1060        let d =
1061            <TestPcs as Pcs<Challenge, Challenger>>::natural_domain_for_degree(&pcs, 1 << log_n);
1062        let evals_0 = RowMajorMatrix::rand(&mut rng, 1 << log_n, 1);
1063        let evals_1 = RowMajorMatrix::rand(&mut rng, 1 << log_n, 1);
1064        let (comm, data) =
1065            <TestPcs as Pcs<Challenge, Challenger>>::commit(&pcs, [(d, evals_0), (d, evals_1)]);
1066
1067        // Prove: open matrix 0 at one point, matrix 1 at no points.
1068        let zeta: Challenge = rng.random();
1069        let mut chal = Challenger::from_hasher(vec![], byte_hash);
1070        let (values, proof) = pcs.open(vec![(&data, vec![vec![zeta], vec![]])], &mut chal);
1071
1072        // Verify with the same shape: matrix 1 carries no opening points.
1073        let mut chal = Challenger::from_hasher(vec![], byte_hash);
1074        let err = pcs
1075            .verify(
1076                vec![(
1077                    comm,
1078                    vec![(d, vec![(zeta, values[0][0][0].clone())]), (d, vec![])],
1079                )],
1080                &proof,
1081                &mut chal,
1082            )
1083            .expect_err("matrix without opening points must be rejected");
1084
1085        // The offending matrix is identified by its batch and matrix index.
1086        let FriError::InputError(InputError::MatrixWithoutOpeningPoints { batch, matrix }) = err
1087        else {
1088            panic!("expected MatrixWithoutOpeningPoints, got {err:?}");
1089        };
1090        assert_eq!(batch, 0);
1091        assert_eq!(matrix, 1);
1092    }
1093
1094    #[test]
1095    fn get_evaluations_on_domain_matches_direct_lde() {
1096        // `get_evaluations_on_domain` must return the committed trace on the requested
1097        // domain whether that domain is smaller than, equal to, or larger than the
1098        // committed LDE. The smaller-than case is exercised whenever `log_blowup`
1099        // exceeds the quotient degree (e.g. the quotient domain in uni-stark).
1100        let mut rng = SmallRng::seed_from_u64(1);
1101
1102        let byte_hash = ByteHash {};
1103        let field_hash = FieldHash::new(byte_hash);
1104        let compress = MyCompress::new(byte_hash);
1105        let val_mmcs = ValMmcs::new(field_hash, compress, 0);
1106        let challenge_mmcs = ChallengeMmcs::new(val_mmcs.clone());
1107
1108        // log_blowup = 2 makes the committed LDE larger than a quotient-sized domain.
1109        let mut fri_params = FriParameters::new_testing(challenge_mmcs, 0);
1110        fri_params.log_blowup = 2;
1111
1112        let pcs = TestPcs {
1113            mmcs: val_mmcs,
1114            fri_params,
1115            _phantom: PhantomData,
1116        };
1117
1118        let log_n = 8;
1119        let width = 3;
1120        let d =
1121            <TestPcs as Pcs<Challenge, Challenger>>::natural_domain_for_degree(&pcs, 1 << log_n);
1122        let evals = RowMajorMatrix::<Val>::rand(&mut rng, 1 << log_n, width);
1123
1124        let (_comm, data) =
1125            <TestPcs as Pcs<Challenge, Challenger>>::commit(&pcs, [(d, evals.clone())]);
1126
1127        // The committed LDE lives on `standard(log_n + 2)`. Walk a target domain from the
1128        // original degree up past the committed LDE: `log_n + 1` is the smaller-than case,
1129        // `log_n + 2` hits the equal fast path, and `log_n + 3` is the larger-than case.
1130        for target_log_n in [log_n, log_n + 1, log_n + 2, log_n + 3] {
1131            let target = CircleDomain::standard(target_log_n);
1132            let got = <TestPcs as Pcs<Challenge, Challenger>>::get_evaluations_on_domain(
1133                &pcs, &data, 0, target,
1134            )
1135            .to_row_major_matrix();
1136
1137            // Ground truth: extrapolate the original trace straight onto `target`.
1138            let expected = CircleEvaluations::from_natural_order(d, evals.clone())
1139                .extrapolate(target)
1140                .to_natural_order()
1141                .to_row_major_matrix();
1142
1143            assert_eq!(got, expected, "mismatch for target_log_n = {target_log_n}");
1144        }
1145    }
1146
1147    #[test]
1148    fn reject_commit_phase_query_count_mismatch() {
1149        // Invariant: every commit-phase round must open every query. The round's
1150        // shared proof carries one sibling set per query.
1151        let (pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1152
1153        // Mutation: drop one query's siblings from round 0.
1154        //
1155        //     before: sibling_values = [s_0, s_1, ..., s_{n-1}]   (n = num_queries)
1156        //     after:  sibling_values = [s_0, s_1, ..., s_{n-2}]   (n - 1)
1157        //     → expected n, got n - 1 → error on round 0
1158        proof.fri_proof.commit_phase_openings[0]
1159            .sibling_values
1160            .pop();
1161
1162        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1163            .expect_err("expected CommitPhaseQueryCountMismatch");
1164
1165        // Destructure for precise field assertions (better diagnostics than matches!).
1166        let FriError::CommitPhaseQueryCountMismatch {
1167            round,
1168            expected,
1169            got,
1170        } = err
1171        else {
1172            panic!("expected CommitPhaseQueryCountMismatch, got {err:?}");
1173        };
1174        assert_eq!(round, 0);
1175        assert_eq!(expected, pcs.fri_params.num_queries);
1176        assert_eq!(got, pcs.fri_params.num_queries - 1);
1177    }
1178
1179    #[test]
1180    fn reject_zero_queries() {
1181        // Invariant: a zero-query instance performs no low-degree spot checks.
1182        // The per-query loop never runs.
1183        // Without the guard any final polynomial would verify.
1184        //
1185        // Fixture state: an honest proof built with the testing query count.
1186        //
1187        // Mutation: verify it under params with num_queries = 0.
1188        let (mut pcs, byte_hash, comm, d, zeta, values, proof) = setup_valid_proof();
1189        pcs.fri_params.num_queries = 0;
1190
1191        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1192            .expect_err("zero-query instance must be rejected");
1193
1194        assert!(
1195            matches!(err, FriError::ZeroQueries),
1196            "expected ZeroQueries, got {err:?}"
1197        );
1198    }
1199
1200    #[test]
1201    #[should_panic(expected = "num_queries must be at least 1")]
1202    fn prover_rejects_zero_queries() {
1203        // The prover must refuse to build a vacuous proof.
1204        // The verifier guards the same config, so the failure is symmetric.
1205        let mut rng = SmallRng::seed_from_u64(0);
1206
1207        // Build the hash stack: field hasher → compression → Merkle tree.
1208        let byte_hash = ByteHash {};
1209        let field_hash = FieldHash::new(byte_hash);
1210        let compress = MyCompress::new(byte_hash);
1211        let val_mmcs = ValMmcs::new(field_hash, compress, 0);
1212        let challenge_mmcs = ChallengeMmcs::new(val_mmcs.clone());
1213
1214        // Zero queries; every other parameter is otherwise valid.
1215        let mut fri_params = FriParameters::new_testing(challenge_mmcs, 0);
1216        fri_params.num_queries = 0;
1217
1218        let pcs = TestPcs {
1219            mmcs: val_mmcs,
1220            fri_params,
1221            _phantom: PhantomData,
1222        };
1223
1224        // Commit to a random single-column trace of 2^{10} rows.
1225        let log_n = 10;
1226        let d =
1227            <TestPcs as Pcs<Challenge, Challenger>>::natural_domain_for_degree(&pcs, 1 << log_n);
1228        let evals = RowMajorMatrix::rand(&mut rng, 1 << log_n, 1);
1229        let (_comm, data) = <TestPcs as Pcs<Challenge, Challenger>>::commit(&pcs, [(d, evals)]);
1230
1231        // Commit succeeds; the assert fires inside the opening (FRI prover).
1232        let zeta: Challenge = rng.random();
1233        let mut chal = Challenger::from_hasher(vec![], byte_hash);
1234        let _ = pcs.open(vec![(&data, vec![vec![zeta]])], &mut chal);
1235    }
1236
1237    #[test]
1238    fn reject_commit_pow_witness_count_mismatch() {
1239        let (pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1240        let num_rounds = proof.fri_proof.commit_phase_commits.len();
1241
1242        // Drop one witness so the per-round count falls short.
1243        proof.fri_proof.commit_pow_witnesses.pop();
1244
1245        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1246            .expect_err("expected CommitPowWitnessCountMismatch");
1247
1248        let FriError::CommitPowWitnessCountMismatch { expected, got } = err else {
1249            panic!("expected CommitPowWitnessCountMismatch, got {err:?}");
1250        };
1251        assert_eq!(expected, num_rounds);
1252        assert_eq!(got, num_rounds - 1);
1253    }
1254
1255    #[test]
1256    fn reject_under_reported_commit_rounds() {
1257        // Invariant: the reported commit-round count must cover the claimed matrix height.
1258        //   - log_global_max_height is derived from the proof's round count
1259        //   - under-reporting drives it below a matrix's log_height
1260        //   - then `index >> (log_global_max_height - log_height)` would underflow
1261        // The verifier must reject before that subtraction runs.
1262        let (pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1263
1264        // On an honest proof the two height derivations coincide:
1265        //
1266        //     H_claim = log_n + log_blowup                            (claimed matrix)
1267        //     H_proof = commit_phase_commits.len() + log_blowup + 1   (first-layer fold)
1268        let log_blowup = pcs.fri_params.log_blowup;
1269        let expected = d.log_n + log_blowup;
1270        let original = proof.fri_proof.commit_phase_commits.len() + log_blowup + 1;
1271        assert_eq!(original, expected, "fixture must start height-consistent");
1272
1273        // Mutation: drop one commit-phase commitment so the round count falls short.
1274        //
1275        //     before: commit_phase_commits = [c_0, ..., c_{n-1}]   → H_proof = expected
1276        //     after:  commit_phase_commits = [c_0, ..., c_{n-2}]   → H_proof = expected - 1
1277        //     → H_proof < H_claim → GlobalMaxHeightMismatch (no underflow)
1278        proof.fri_proof.commit_phase_commits.pop();
1279
1280        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1281            .expect_err("expected GlobalMaxHeightMismatch");
1282
1283        let FriError::GlobalMaxHeightMismatch { expected: exp, got } = err else {
1284            panic!("expected GlobalMaxHeightMismatch, got {err:?}");
1285        };
1286        // The verifier wants the height the claimed matrix demands.
1287        assert_eq!(exp, expected);
1288        // The proof under-reports by exactly the one round we removed.
1289        assert_eq!(got, expected - 1);
1290    }
1291
1292    #[test]
1293    fn reject_commit_phase_openings_count_mismatch() {
1294        // Invariant: the proof must carry exactly one opening set per
1295        // commit-phase round. Fewer (or more) than there are commitments
1296        // makes the proof shape invalid.
1297        let (pcs, byte_hash, comm, d, zeta, values, proof) = setup_valid_proof();
1298
1299        // We need the original proof to assert against its commitment count,
1300        // so clone before mutating.
1301        let mut bad = proof.clone();
1302
1303        // Mutation: remove the last round's openings.
1304        //
1305        //     commit_phase_commits:   [c_0, ..., c_{n-1}]   (n rounds)
1306        //     commit_phase_openings:  [o_0, ..., o_{n-2}]   (n - 1 after pop)
1307        //     → n != n - 1 → error
1308        bad.fri_proof.commit_phase_openings.pop();
1309
1310        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &bad)
1311            .expect_err("expected CommitPhaseOpeningsCountMismatch");
1312
1313        let FriError::CommitPhaseOpeningsCountMismatch { expected, got } = err else {
1314            panic!("expected CommitPhaseOpeningsCountMismatch, got {err:?}");
1315        };
1316        assert_eq!(expected, proof.fri_proof.commit_phase_commits.len());
1317        assert_eq!(got, expected - 1);
1318    }
1319
1320    #[test]
1321    fn reject_sibling_values_length_mismatch() {
1322        // Invariant: in each folding round with arity k, the prover must
1323        // supply exactly k - 1 sibling values (the queried evaluation is
1324        // the remaining one).
1325        let (pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1326
1327        // Capture the original sibling count and arity before mutating.
1328        let log_arity = proof.fri_proof.commit_phase_openings[0].log_arity as usize;
1329        let arity = 1usize << log_arity;
1330        let original_sibling_count =
1331            proof.fri_proof.commit_phase_openings[0].sibling_values[0].len();
1332
1333        // Mutation: remove one sibling value from query 0, round 0.
1334        //
1335        //     arity = 2^{log_arity}, expected siblings = arity - 1
1336        //     before: sibling_values = [s_0, ..., s_{arity-2}]   (arity - 1 elements)
1337        //     after:  sibling_values = [s_0, ..., s_{arity-3}]   (arity - 2 elements)
1338        //     → expected arity - 1, got arity - 2 → error at round 0
1339        proof.fri_proof.commit_phase_openings[0].sibling_values[0].pop();
1340
1341        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1342            .expect_err("expected SiblingValuesLengthMismatch");
1343
1344        let FriError::SiblingValuesLengthMismatch {
1345            round,
1346            expected,
1347            got,
1348        } = err
1349        else {
1350            panic!("expected SiblingValuesLengthMismatch, got {err:?}");
1351        };
1352        // Error must identify round 0 as the offender.
1353        assert_eq!(round, 0);
1354        // The verifier expects (arity - 1) siblings per folding group.
1355        assert_eq!(expected, arity - 1);
1356        // We popped one, so one fewer than the original count.
1357        assert_eq!(got, original_sibling_count - 1);
1358    }
1359
1360    // Two error variants cannot be triggered through the PCS verification
1361    // layer because Merkle commitment checks or input-proof validation
1362    // fail first for any proof mutation that would reach those code paths:
1363    //
1364    // - Final fold height mismatch: requires the total folding to stop at
1365    //   the wrong domain size, but altering round counts also invalidates
1366    //   Merkle proofs.
1367    // - Unconsumed reduced openings: requires leftover polynomial data
1368    //   after folding completes, but input-proof checks reject the shape
1369    //   before the folding loop runs.
1370    //
1371    // Both are reachable by a malicious prover who crafts openings that
1372    // pass Merkle checks but have wrong structure — they serve as defense
1373    // in depth in the low-level verifier.
1374
1375    #[test]
1376    fn reject_input_openings_query_count_mismatch() {
1377        // Invariant: the shared input openings must cover every query. The
1378        // first-layer siblings carry one entry per query, so dropping one
1379        // leaves a query without its opened row.
1380        //
1381        // The cross-query arity-schedule check this test used to perform is
1382        // now unrepresentable: `log_arity` lives once per round, not once per
1383        // query, so no two queries can disagree.
1384        let (pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1385
1386        // Mutation: drop the last query's first-layer siblings.
1387        //
1388        //     before: first_layer_siblings = [f_0, ..., f_{n-1}]   (n = num_queries)
1389        //     after:  first_layer_siblings = [f_0, ..., f_{n-2}]   (n - 1)
1390        //     → the shape gate rejects before any Merkle work
1391        proof.fri_proof.input_openings.first_layer_siblings.pop();
1392
1393        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1394            .expect_err("expected InputShapeError");
1395
1396        assert!(
1397            matches!(err, FriError::InputError(InputError::InputShapeError)),
1398            "expected InputShapeError, got {err:?}"
1399        );
1400    }
1401
1402    #[test]
1403    fn reject_tampered_commit_phase_sibling_value() {
1404        // Invariant: a tampered sibling cannot survive, whichever check reaches
1405        // it first. A sibling feeds the fold, so flipping one diverges the
1406        // folded constant and the final-polynomial check fires. Tampering that
1407        // preserves the fold is caught instead by the shared per-round Merkle
1408        // check, because the reconstructed row stops matching the committed
1409        // leaf (see `reject_tampered_commit_phase_opening_proof`). Together the
1410        // two paths cover both shapes of attack; the accepted set is their
1411        // conjunction, so the order in which they run does not widen it.
1412        let (pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1413
1414        proof.fri_proof.commit_phase_openings[0].sibling_values[0][0] += Challenge::ONE;
1415
1416        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1417            .expect_err("a tampered sibling value must be rejected");
1418
1419        assert!(
1420            matches!(err, FriError::FinalPolyMismatch),
1421            "expected FinalPolyMismatch, got {err:?}"
1422        );
1423    }
1424
1425    #[test]
1426    fn reject_tampered_commit_phase_opening_proof() {
1427        // The round's shared multiproof carries every deduplicated sibling
1428        // digest. Corrupting one makes the recomputed root diverge from the
1429        // round commitment.
1430        let (pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1431
1432        proof.fri_proof.commit_phase_openings[0]
1433            .opening_proof
1434            .sibling_hashes[0] = Default::default();
1435
1436        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1437            .expect_err("a tampered commit-phase digest must be rejected");
1438
1439        assert!(
1440            matches!(err, FriError::CommitPhaseMmcsError(_)),
1441            "expected CommitPhaseMmcsError, got {err:?}"
1442        );
1443    }
1444
1445    #[test]
1446    fn reject_tampered_first_layer_proof() {
1447        // The first-layer tree is opened once for every query through a single
1448        // shared multiproof; a corrupted digest there must fail before any
1449        // reduced opening is trusted.
1450        let (pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1451
1452        proof
1453            .fri_proof
1454            .input_openings
1455            .first_layer_proof
1456            .sibling_hashes[0] = Default::default();
1457
1458        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1459            .expect_err("a tampered first-layer digest must be rejected");
1460
1461        assert!(
1462            matches!(
1463                err,
1464                FriError::InputError(InputError::FirstLayerMmcsError(_))
1465            ),
1466            "expected FirstLayerMmcsError, got {err:?}"
1467        );
1468    }
1469
1470    #[test]
1471    fn reject_invalid_log_arity() {
1472        // Invariant: each log_arity must be in 1..=max_log_arity.
1473        let (pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1474
1475        // Mutation: force an invalid zero arity in query 0, round 0.
1476        proof.fri_proof.commit_phase_openings[0].log_arity = 0;
1477
1478        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1479            .expect_err("expected InvalidLogArity");
1480
1481        let FriError::InvalidLogArity {
1482            round,
1483            log_arity,
1484            max,
1485        } = err
1486        else {
1487            panic!("expected InvalidLogArity, got {err:?}");
1488        };
1489        assert_eq!(round, 0);
1490        assert_eq!(log_arity, 0);
1491        assert_eq!(max, pcs.fri_params.max_log_arity);
1492    }
1493
1494    #[test]
1495    fn reject_global_max_height_too_large() {
1496        // Invariant: the query-index width fits the circle group of order 2^CIRCLE_TWO_ADICITY.
1497        //
1498        //     field order = 2^CIRCLE_TWO_ADICITY - 1   (one short of the group order)
1499        //     => width of CIRCLE_TWO_ADICITY bits is unsampleable => verifier must reject
1500        let (mut pcs, byte_hash, comm, d, zeta, values, mut proof) = setup_valid_proof();
1501
1502        // Zero both proof-of-work targets.
1503        // Otherwise grinding rejects the cloned witnesses before the width check runs.
1504        pcs.fri_params.commit_proof_of_work_bits = 0;
1505        pcs.fri_params.query_proof_of_work_bits = 0;
1506
1507        // Mutation: clone commit-phase rounds until the width reaches the bound.
1508        //
1509        //     num_index_bits = rounds + log_blowup + extra_query_index_bits (= 1 for circle)
1510        //     stop once num_index_bits >= CIRCLE_TWO_ADICITY
1511        let extra_query_index_bits = 1;
1512        let commit = proof.fri_proof.commit_phase_commits[0].clone();
1513        let witness = proof.fri_proof.commit_pow_witnesses[0];
1514        while proof.fri_proof.commit_phase_commits.len()
1515            + pcs.fri_params.log_blowup
1516            + extra_query_index_bits
1517            < Val::CIRCLE_TWO_ADICITY
1518        {
1519            proof.fri_proof.commit_phase_commits.push(commit.clone());
1520            proof.fri_proof.commit_pow_witnesses.push(witness);
1521            // Each round needs its own opening set.
1522            let opening = proof.fri_proof.commit_phase_openings[0].clone();
1523            proof.fri_proof.commit_phase_openings.push(opening);
1524        }
1525
1526        let err = try_verify(&pcs, byte_hash, &comm, d, zeta, &values, &proof)
1527            .expect_err("expected GlobalMaxHeightTooLarge");
1528
1529        let FriError::GlobalMaxHeightTooLarge {
1530            log_global_max_height,
1531            two_adicity,
1532        } = err
1533        else {
1534            panic!("expected GlobalMaxHeightTooLarge, got {err:?}");
1535        };
1536        // The reported bound is the circle group two-adicity.
1537        assert_eq!(two_adicity, Val::CIRCLE_TWO_ADICITY);
1538        // The rejecting width is at least that bound.
1539        assert!(log_global_max_height >= two_adicity);
1540    }
1541}