Skip to main content

p3_binary_pcs/
pcs.rs

1//! The multilinear commitment scheme this crate exposes.
2//! It ties the stacked-sumcheck layout machinery to the commit, fold and query pipeline.
3//!
4//! An opening protocol's claims fold into the residual sumcheck.
5//! The layout already folds any other consumer's claims the same way.
6//!
7//! Each claim contributes one alpha-batched equality weight.
8//! The sumcheck then reduces the whole batch to a single scalar as it folds.
9//!
10//! What is specific to this crate is what that scalar is checked against.
11//!
12//! It is the alpha-batched weight polynomial at the fold-derived point.
13//! That value is multiplied by the uniform value the final codeword carries.
14//!
15//! The query paths tie every sampled position to that same codeword.
16//! So together the two checks close the proximity and the evaluation claim in one proof.
17//!
18//! # The two fields
19//!
20//! ```text
21//!     committed alphabet   the columns and the base codeword
22//!     challenge field      every challenge, every folded codeword, every claimed value
23//! ```
24//!
25//! A narrower alphabet shrinks the largest object in the proof without moving a challenge.
26
27use alloc::vec;
28use alloc::vec::Vec;
29use core::marker::PhantomData;
30
31use p3_binary_dft::{AdditiveNtt, AdditiveRsEncoder, EncodableLevel};
32use p3_binary_field::TowerLevel;
33use p3_challenger::fs::TranscriptField;
34use p3_challenger::{CanObserve, CanSampleUniformBits, FieldChallenger, GrindingChallenger};
35use p3_commit::{Encoder, Mmcs, MultilinearPcs};
36use p3_field::ExtensionField;
37use p3_multilinear_util::point::Point;
38use p3_multilinear_util::poly::Poly;
39use p3_sumcheck::layout::{Layout, Verifier, Witness, observe_commitment};
40use p3_sumcheck::strategy::Basis;
41use p3_sumcheck::{
42    OpeningBatch, OpeningEvals, OpeningProtocol, PrescribedOpeningSecurity, PrescribedPointPcs,
43    SumcheckData, SumcheckError,
44};
45use p3_util::log2_ceil_usize;
46
47use crate::PcsLayout;
48use crate::error::BinaryPcsError;
49use crate::fold::{ChallengeField, FoldAlphabet};
50use crate::params::{BinaryPcsConfig, BinaryPcsConfigError};
51use crate::proof::BinaryPcsProof;
52use crate::prover::{BinaryPcsProverData, commit, fold_rounds_with, open_queries};
53use crate::transcript::{BinaryPcsProverTranscript, BinaryPcsShape, BinaryPcsVerifierTranscript};
54use crate::verifier::{
55    check_canonical_pow_witness, check_round_and_final_lengths, verify_query_paths,
56};
57
58/// Why an opening could not be produced or accepted, for one base commitment scheme.
59type Failure<F, MT> = BinaryPcsError<F, <MT as Mmcs<F>>::Error>;
60
61/// The mismatch between one opening request and the evaluations offered against it.
62///
63/// Both sides carry their own length, because a request and a list of evaluations that agree
64/// on the total can still split it differently.
65fn batch_size_mismatch<F, MmcsError, T, U>(
66    table_idx: usize,
67    batch: &OpeningBatch<T>,
68    evals: &OpeningBatch<U>,
69) -> BinaryPcsError<F, MmcsError> {
70    BinaryPcsError::OpeningBatchSizeMismatch {
71        table_idx,
72        expected_current: batch.current().len(),
73        expected_next: batch.next().len(),
74        actual_current: evals.current().len(),
75        actual_next: evals.next().len(),
76    }
77}
78
79/// An opening proof, or the reason there is none.
80type Opening<F, EF, MT, MX> = Result<BinaryPcsProof<F, EF, MT, MX>, Failure<F, MT>>;
81
82/// A multilinear polynomial commitment scheme over a binary tower field: an additive-domain
83/// Reed-Solomon codeword folded in lockstep with a residual sumcheck.
84///
85/// The stacked-layout binding mode is fixed rather than chosen.
86/// The codeword fold merges adjacent pairs, which only suffix-order binding matches.
87///
88/// The base codeword and the folded codewords live over different fields.
89/// Each therefore has its own commitment scheme.
90///
91/// The two schemes must report the same failure type.
92/// One commitment family instantiated at two levels does.
93pub struct BinaryPcs<F: EncodableLevel, EF, MT, MX, E = <F as EncodableLevel>::Encoder> {
94    config: BinaryPcsConfig,
95    mmcs: MT,
96    round_mmcs: MX,
97    encoder: E,
98    _fields: PhantomData<(F, EF)>,
99}
100
101impl<F: EncodableLevel, EF: TowerLevel, MT, MX> BinaryPcs<F, EF, MT, MX> {
102    /// Builds a PCS instance from a derived configuration and its two commitment schemes.
103    ///
104    /// The schedule carries the two widths it was derived for, and both are checked here.
105    ///
106    /// Every cap the derivation applied belongs to one of those two levels:
107    ///
108    /// ```text
109    ///     committed alphabet   the additive domain the codeword lives in, and the grind
110    ///     challenge field      the width every algebraic error is charged against
111    /// ```
112    ///
113    /// A schedule derived elsewhere would otherwise report a bound it cannot deliver.
114    ///
115    /// # Arguments
116    ///
117    /// - `config`: the validated fold and query schedule both sides read.
118    /// - `mmcs`: commits the base codeword over the committed alphabet.
119    /// - `round_mmcs`: commits every folded codeword over the challenge field.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error unless the schedule was derived for these two levels.
124    pub fn new(
125        config: BinaryPcsConfig,
126        mmcs: MT,
127        round_mmcs: MX,
128    ) -> Result<Self, BinaryPcsConfigError> {
129        config.check_alphabets::<F, EF>()?;
130        Ok(Self {
131            config,
132            mmcs,
133            round_mmcs,
134            encoder: F::Encoder::default(),
135            _fields: PhantomData,
136        })
137    }
138
139    /// Variables of the committed stacked polynomial.
140    ///
141    /// The same number the commitment trait reports, reachable without naming a challenger.
142    #[must_use]
143    pub const fn num_variables(&self) -> usize {
144        self.config.num_variables()
145    }
146}
147
148impl<F, EF, MT, MX, Ntt> BinaryPcs<F, EF, MT, MX, AdditiveRsEncoder<F, Ntt>>
149where
150    F: EncodableLevel,
151    EF: TowerLevel,
152    Ntt: AdditiveNtt<F> + Sync,
153{
154    /// Builds an instance around an explicitly selected additive transform.
155    ///
156    /// The transform encodes the base codeword only. A folded codeword the opening encodes from
157    /// its bound message goes through the challenge field's own encoder instead. Every correct
158    /// transform produces the same codeword, so the choice moves no committed symbol.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error unless the schedule was derived for these two tower levels.
163    pub fn with_ntt(
164        config: BinaryPcsConfig,
165        mmcs: MT,
166        round_mmcs: MX,
167        ntt: Ntt,
168    ) -> Result<Self, BinaryPcsConfigError> {
169        config.check_alphabets::<F, EF>()?;
170        Ok(Self {
171            config,
172            mmcs,
173            round_mmcs,
174            encoder: AdditiveRsEncoder::new(ntt),
175            _fields: PhantomData,
176        })
177    }
178}
179
180impl<F, EF, MT, MX, E> BinaryPcs<F, EF, MT, MX, E>
181where
182    F: EncodableLevel + TranscriptField + FoldAlphabet<EF>,
183    EF: ChallengeField<F> + ExtensionField<F> + TowerLevel + FoldAlphabet<EF>,
184    MT: Mmcs<F>,
185    MX: Mmcs<EF, Error = MT::Error>,
186    E: Encoder<F> + Sync,
187{
188    /// Check table dimensions and scalar claim capacity before committing or opening.
189    ///
190    /// Security rejection is a behavior change.
191    /// A protocol an older release accepted can exceed the configured target.
192    ///
193    /// Use this preflight, or a fallible opening entry point, to validate one directly.
194    /// The commitment traits propagate the same typed errors.
195    ///
196    /// # Minimum non-degenerate protocol
197    ///
198    /// A protocol of empty batches passes every check here.
199    ///
200    /// A table with no opening still contributes its rows, and no claim is under any budget.
201    ///
202    /// Such a run reaches the terminal relation with both sides zero:
203    ///
204    /// ```text
205    ///     0  ==  0 * final_value      holds whatever the final value is
206    /// ```
207    ///
208    /// That is harmless rather than unsound.
209    ///
210    /// A run that claims nothing proves nothing, and asserts nothing either.
211    ///
212    /// Every column it might have opened stays as unconstrained as it was.
213    ///
214    /// The shape is also load-bearing in the security tests.
215    ///
216    /// Only it reaches query sampling with a tampered final codeword.
217    ///
218    /// That isolates the codeword's binding from the check that otherwise rejects first.
219    ///
220    /// A caller wanting a floor of one claim can read the opening count and impose it.
221    pub fn validate_opening_protocol(
222        &self,
223        protocol: &OpeningProtocol,
224    ) -> Result<(), BinaryPcsError<F, MT::Error>> {
225        let total = protocol.checked_num_cells();
226        if !matches!(total, Some(total) if total > 0 && log2_ceil_usize(total) == self.config.num_variables())
227        {
228            return Err(BinaryPcsError::InvalidOpeningProtocol);
229        }
230        let actual = protocol
231            .checked_num_claims()
232            .ok_or(BinaryPcsError::InvalidOpeningProtocol)?;
233
234        let max = self.config.max_opening_claims();
235        if actual > max {
236            return Err(BinaryPcsError::OpeningClaimCountExceedsSecurityBudget {
237                actual,
238                max,
239                security_level: self.config.security_level(),
240            });
241        }
242        Ok(())
243    }
244
245    /// Produce a sampled-point opening, returning an error for an invalid or over-budget
246    /// protocol before touching the challenger. Prover data must match the committed tables.
247    #[tracing::instrument(name = "binary pcs open", skip_all)]
248    pub fn try_open<Challenger>(
249        &self,
250        mut prover_data: BinaryPcsProverData<F, EF, MT>,
251        protocol: &OpeningProtocol,
252        challenger: &mut Challenger,
253    ) -> Opening<F, EF, MT, MX>
254    where
255        Challenger: FieldChallenger<F>
256            + GrindingChallenger<Witness = F>
257            + CanSampleUniformBits<F>
258            + CanObserve<MT::Commitment>
259            + CanObserve<MX::Commitment>,
260    {
261        self.validate_opening_protocol(protocol)?;
262        Self::validate_preprocessing_depth(&prover_data)?;
263        let evals = protocol
264            .iter_openings()
265            .map(|(table_idx, batch)| prover_data.layout.eval(table_idx, batch, challenger))
266            .collect();
267        Ok(self.finish_open(prover_data, evals, challenger))
268    }
269
270    /// Produce a prescribed-point opening with recoverable protocol and budget errors.
271    ///
272    /// Points must already be transcript-bound, as the prescribed-point contract requires.
273    /// Rejection leaves the challenger unchanged.
274    ///
275    /// Prover data must match the committed tables.
276    #[tracing::instrument(name = "binary pcs open", skip_all)]
277    pub fn try_open_at<Challenger>(
278        &self,
279        mut prover_data: BinaryPcsProverData<F, EF, MT>,
280        protocol: &OpeningProtocol,
281        points: &[Point<EF>],
282        challenger: &mut Challenger,
283    ) -> Opening<F, EF, MT, MX>
284    where
285        Challenger: FieldChallenger<F>
286            + GrindingChallenger<Witness = F>
287            + CanSampleUniformBits<F>
288            + CanObserve<MT::Commitment>
289            + CanObserve<MX::Commitment>,
290    {
291        self.validate_opening_protocol(protocol)?;
292        Self::validate_preprocessing_depth(&prover_data)?;
293        Self::validate_points(protocol, points)?;
294        let evals = protocol
295            .iter_openings()
296            .zip(points)
297            .map(|((table_idx, batch), point)| {
298                prover_data
299                    .layout
300                    .eval_at(table_idx, batch, point, challenger)
301            })
302            .collect();
303        Ok(self.finish_open(prover_data, evals, challenger))
304    }
305
306    /// Produce a prescribed-point opening from evaluations the caller already holds.
307    ///
308    /// As [`Self::try_open_at`], except that each batch's claimed values are supplied
309    /// instead of read off the committed columns. A protocol whose own reduction already
310    /// produced the evaluation at every prescribed point passes it here, rather than paying
311    /// a pass over the columns to find a value it has.
312    ///
313    /// Points must already be transcript-bound, as the prescribed-point contract requires.
314    /// Rejection leaves the challenger unchanged.
315    ///
316    /// Prover data must match the committed tables.
317    ///
318    /// # Soundness
319    ///
320    /// A supplied evaluation is bound exactly as a computed one is, and a verifier recomputes
321    /// its own. A wrong one therefore yields a proof that does not verify, never one that does.
322    #[tracing::instrument(name = "binary pcs open", skip_all)]
323    pub fn try_open_at_known<Challenger>(
324        &self,
325        mut prover_data: BinaryPcsProverData<F, EF, MT>,
326        protocol: &OpeningProtocol,
327        points: &[Point<EF>],
328        evals: &[OpeningEvals<EF>],
329        challenger: &mut Challenger,
330    ) -> Opening<F, EF, MT, MX>
331    where
332        Challenger: FieldChallenger<F>
333            + GrindingChallenger<Witness = F>
334            + CanSampleUniformBits<F>
335            + CanObserve<MT::Commitment>
336            + CanObserve<MX::Commitment>,
337    {
338        self.validate_opening_protocol(protocol)?;
339        Self::validate_preprocessing_depth(&prover_data)?;
340        Self::validate_points(protocol, points)?;
341        Self::validate_evals(protocol, evals)?;
342        for (((table_idx, batch), point), batch_evals) in
343            protocol.iter_openings().zip(points).zip(evals)
344        {
345            prover_data
346                .layout
347                .eval_at_known(table_idx, batch, point, batch_evals, challenger);
348        }
349        Ok(self.finish_open(prover_data, evals.to_vec(), challenger))
350    }
351
352    /// Check that the committed layout runs no round the opening pipeline cannot supply.
353    ///
354    /// A preprocessing round reads a per-round residual of the column, which neither the
355    /// width-1 committed codeword nor a supplied evaluation carries. The pipeline asserts
356    /// that depth once it is under way, so the entry points refuse it before then.
357    fn validate_preprocessing_depth(
358        prover_data: &BinaryPcsProverData<F, EF, MT>,
359    ) -> Result<(), BinaryPcsError<F, MT::Error>> {
360        let folding = prover_data.layout.folding();
361        if folding != 0 {
362            return Err(BinaryPcsError::OpeningPreprocessingDepth { folding });
363        }
364        Ok(())
365    }
366
367    fn validate_points(
368        protocol: &OpeningProtocol,
369        points: &[Point<EF>],
370    ) -> Result<(), BinaryPcsError<F, MT::Error>> {
371        protocol
372            .check_points(points)
373            .map_err(|_| BinaryPcsError::OpeningPointShapeMismatch)
374    }
375
376    /// Check that supplied evaluations cover every batch the protocol names, column for column.
377    fn validate_evals(
378        protocol: &OpeningProtocol,
379        evals: &[OpeningEvals<EF>],
380    ) -> Result<(), BinaryPcsError<F, MT::Error>> {
381        if protocol.num_openings() != evals.len() {
382            return Err(BinaryPcsError::OpeningEvalCountMismatch {
383                expected: protocol.num_openings(),
384                actual: evals.len(),
385            });
386        }
387        for ((table_idx, batch), batch_evals) in protocol.iter_openings().zip(evals) {
388            if !batch.has_same_shape(batch_evals) {
389                return Err(batch_size_mismatch(table_idx, batch, batch_evals));
390            }
391        }
392        Ok(())
393    }
394
395    /// Runs the fold-and-query pipeline both opening modes share.
396    /// Every claim the protocol names is already recorded against the layout by then.
397    fn finish_open<Challenger>(
398        &self,
399        prover_data: BinaryPcsProverData<F, EF, MT>,
400        evals: Vec<OpeningEvals<EF>>,
401        challenger: &mut Challenger,
402    ) -> BinaryPcsProof<F, EF, MT, MX>
403    where
404        Challenger: FieldChallenger<F>
405            + GrindingChallenger<Witness = F>
406            + CanSampleUniformBits<F>
407            + CanObserve<MT::Commitment>
408            + CanObserve<MX::Commitment>,
409    {
410        self.finish_open_with::<false, Challenger>(prover_data, evals, challenger)
411    }
412
413    /// The body of the opening pipeline, with the fold route selected by a const parameter.
414    ///
415    /// `BIND_EACH_ROUND` is the fold phase's own parameter, carried one level up:
416    ///
417    /// ```text
418    ///     false: the shipped route, each held challenge absorbed by the next round's pass
419    ///     true : the reference route, each round's binding applied on its own pass
420    /// ```
421    ///
422    /// Both routes send the same transcript, so a test can pin one against the other
423    /// without reproducing anything the shipped path does after the fold rounds.
424    fn finish_open_with<const BIND_EACH_ROUND: bool, Challenger>(
425        &self,
426        prover_data: BinaryPcsProverData<F, EF, MT>,
427        evals: Vec<OpeningEvals<EF>>,
428        challenger: &mut Challenger,
429    ) -> BinaryPcsProof<F, EF, MT, MX>
430    where
431        Challenger: FieldChallenger<F>
432            + GrindingChallenger<Witness = F>
433            + CanSampleUniformBits<F>
434            + CanObserve<MT::Commitment>
435            + CanObserve<MX::Commitment>,
436    {
437        // One driver spans the fold batches and the query phase.
438        //
439        // The description is therefore walked exactly once.
440        let shape = BinaryPcsShape::new(&self.config);
441        let mut transcript = BinaryPcsProverTranscript::new(challenger, shape);
442
443        let (base_merkle_data, sumcheck_data, rounds, _randomness, final_codeword) =
444            fold_rounds_with::<BIND_EACH_ROUND, F, EF, MT, MX, _>(
445                prover_data,
446                &self.config,
447                &self.mmcs,
448                &self.round_mmcs,
449                &mut transcript,
450            );
451        let query_proofs = open_queries(
452            &self.config,
453            &self.mmcs,
454            &self.round_mmcs,
455            &mut transcript,
456            &base_merkle_data,
457            &rounds,
458            &final_codeword,
459        );
460
461        // Require that every described step was played.
462        transcript.finish();
463
464        BinaryPcsProof {
465            sumcheck: sumcheck_data,
466            rounds: query_proofs.rounds,
467            base_opened_values: query_proofs.base_opened_values,
468            base_multi_proof: query_proofs.base_multi_proof,
469            final_codeword: Poly::new(final_codeword),
470            pow_witness: query_proofs.pow_witness,
471            evals,
472        }
473    }
474
475    /// Replays an opening proof's transcript against `protocol`'s claims and returns the
476    /// claimed evaluations once the proof checks out.
477    ///
478    /// `points` selects prescribed-point mode.
479    ///
480    /// ```text
481    ///     supplied  ->  each claim is recorded at its own point
482    ///     absent    ->  each point is sampled from the transcript
483    /// ```
484    ///
485    /// Either way the choice mirrors the one the prover made.
486    ///
487    /// The proof-shape checks below all run before this function performs any transcript
488    /// operation of its own:
489    ///
490    /// - the opening-batch count
491    /// - both round-count checks
492    /// - the final codeword's length
493    /// - the presence of sumcheck grinding witnesses
494    /// - the canonical zero-difficulty grinding witness
495    ///
496    /// A malformed proof is rejected there, rather than indexed out of bounds or used to
497    /// desync the replay.
498    ///
499    /// The challenger is not untouched by then.
500    /// The sampled-point entry point observes the commitment before calling here.
501    ///
502    /// The prescribed-point one requires the caller to have done the same.
503    ///
504    /// The per-claim batch-size check sits outside that group.
505    /// It runs once per claim, inside the claim-recording loop below.
506    ///
507    /// It is ordered against its own claim only, never against the transcript as a whole.
508    ///
509    /// The sumcheck replay is interleaved with each intermediate round's commitment.
510    /// One round-verification call runs per fold round.
511    ///
512    /// A single call covering every round would read all the polynomial messages first.
513    /// No round commitment would be observed until after them, desyncing the two sides.
514    ///
515    /// The claim closes on one equality.
516    ///
517    /// ```text
518    ///     weights(fold point) * final value  ==  running sumcheck claim
519    /// ```
520    ///
521    /// The weight polynomial is the alpha-batched one the recorded claims define.
522    /// The fold point is what the fold challenges name, and the final value is uniform.
523    ///
524    /// The query paths then tie every sampled query's fold chain to that same codeword.
525    #[tracing::instrument(name = "binary pcs verify", skip_all)]
526    fn verify_opening<'p, Challenger>(
527        &self,
528        commitment: &MT::Commitment,
529        proof: &'p BinaryPcsProof<F, EF, MT, MX>,
530        protocol: &OpeningProtocol,
531        points: Option<&[Point<EF>]>,
532        challenger: &mut Challenger,
533    ) -> Result<&'p [OpeningEvals<EF>], BinaryPcsError<F, MT::Error>>
534    where
535        Challenger: FieldChallenger<F>
536            + GrindingChallenger<Witness = F>
537            + CanSampleUniformBits<F>
538            + CanObserve<MT::Commitment>
539            + CanObserve<MX::Commitment>,
540    {
541        self.validate_opening_protocol(protocol)?;
542        if protocol.num_openings() != proof.evals.len() {
543            return Err(BinaryPcsError::OpeningBatchCountMismatch {
544                expected: protocol.num_openings(),
545                actual: proof.evals.len(),
546            });
547        }
548
549        let num_fold_rounds = self.config.num_fold_rounds();
550        if proof.sumcheck.num_rounds() != num_fold_rounds {
551            return Err(SumcheckError::RoundCountMismatch {
552                expected: num_fold_rounds,
553                actual: proof.sumcheck.num_rounds(),
554            }
555            .into());
556        }
557
558        // Every fold round below replays with a freshly built, always-empty witness vector.
559        // Nothing ever reads the vector the proof carries.
560        //
561        // A non-empty one is therefore unchecked, mutable data riding along with the proof.
562        if !proof.sumcheck.pow_witnesses.is_empty() {
563            return Err(BinaryPcsError::NonEmptyPowWitnesses {
564                actual: proof.sumcheck.pow_witnesses.len(),
565            });
566        }
567
568        check_round_and_final_lengths(&self.config, proof)?;
569
570        // At a zero grinding budget the witness never reaches the sponge, so its value is
571        // pinned here rather than by the grind.
572        check_canonical_pow_witness(&self.config, proof)?;
573
574        // From here on the transcript is touched: every remaining check runs against the
575        // replayed randomness, not the proof's raw bytes.
576        let mut layout_verifier =
577            Verifier::<F, EF>::new(&protocol.table_shapes(), PcsLayout::<F, EF>::strategy());
578
579        for (i, (table_idx, batch)) in protocol.iter_openings().enumerate() {
580            let evals = &proof.evals[i];
581            if !batch.has_same_shape(evals) {
582                return Err(batch_size_mismatch(table_idx, batch, evals));
583            }
584            match points {
585                Some(points) => {
586                    layout_verifier
587                        .add_claim_at(table_idx, batch, &points[i], evals, challenger)?;
588                }
589                None => {
590                    layout_verifier.add_claim(table_idx, batch, evals, challenger)?;
591                }
592            }
593        }
594
595        // The layout draws this batching challenge unconditionally.
596        //
597        // It is drawn even when no claim was recorded at all.
598        //
599        // Both sides draw it through the layout.
600        //
601        // The recorded claim counts therefore reach the sponge first.
602        // One driver spans the fold batches and the query phase.
603        //
604        // The prover seeds at the same point, just before the batching challenge.
605        let shape = BinaryPcsShape::new(&self.config);
606        let mut transcript = BinaryPcsVerifierTranscript::new(challenger, shape);
607
608        let alpha = transcript.fold_batch(|ch| layout_verifier.batching_challenge(ch));
609        let constraint = layout_verifier.constraint(alpha);
610        let mut claimed_sum = EF::ZERO;
611        constraint.combine_evals(&mut claimed_sum);
612
613        // Replay each polynomial before its own challenge, observing roots only at the
614        // batch boundaries chosen by the verifier's configuration.
615        let mut betas = Vec::with_capacity(num_fold_rounds);
616        for (batch, (start, arity)) in self.config.fold_batches().enumerate() {
617            for r in start..start + arity {
618                let round_data = SumcheckData {
619                    polynomial_evaluations: vec![proof.sumcheck.polynomial_evaluations()[r]],
620                    pow_witnesses: Vec::new(),
621                };
622                // A rejection leaves the driver mid-description, so release it first.
623                let round_point = match transcript.fold_batch(|ch| {
624                    round_data.verify_rounds(ch, &mut claimed_sum, 1, 0, Basis::Evaluation)
625                }) {
626                    Ok(point) => point,
627                    Err(error) => {
628                        transcript.abort();
629                        return Err(error.into());
630                    }
631                };
632                betas.push(round_point.as_slice()[0]);
633            }
634            if batch + 1 < self.config.num_fold_batches() {
635                transcript.oracle_commitment(proof.rounds[batch].commitment.clone());
636            }
637        }
638
639        // The codeword fold runs in the layout's own variable order.
640        // Suffix binding folds the last variable first, so the challenges end up in round order.
641        //
642        // The committed polynomial's variable-order point is that sequence reversed.
643        // Reconstructing it is what the constraint evaluation does internally.
644        let fold_point = Point::new(betas);
645        let evaluation_of_weights = PcsLayout::<F, EF>::strategy()
646            .variable_order
647            .eval_constraints_poly(core::slice::from_ref(&constraint), &fold_point);
648        let final_value = proof.final_codeword.as_slice()[0];
649        let final_codeword_is_uniform = proof
650            .final_codeword
651            .as_slice()
652            .iter()
653            .all(|&v| v == final_value);
654        if !final_codeword_is_uniform || claimed_sum != evaluation_of_weights * final_value {
655            transcript.abort();
656            return Err(BinaryPcsError::FinalCheck);
657        }
658
659        match verify_query_paths(
660            &self.config,
661            &self.mmcs,
662            &self.round_mmcs,
663            commitment,
664            fold_point.as_slice(),
665            proof,
666            &mut transcript,
667        ) {
668            Ok(()) => transcript.finish(),
669            Err(error) => {
670                transcript.abort();
671                return Err(error);
672            }
673        }
674
675        Ok(&proof.evals)
676    }
677}
678
679impl<F, EF, MT, MX, E, Challenger> MultilinearPcs<EF, Challenger> for BinaryPcs<F, EF, MT, MX, E>
680where
681    F: EncodableLevel + TranscriptField + FoldAlphabet<EF>,
682    EF: ChallengeField<F> + ExtensionField<F> + TowerLevel + FoldAlphabet<EF>,
683    MT: Mmcs<F>,
684    MX: Mmcs<EF, Error = MT::Error>,
685    E: Encoder<F> + Sync,
686    Challenger: FieldChallenger<F>
687        + GrindingChallenger<Witness = F>
688        + CanSampleUniformBits<F>
689        + CanObserve<MT::Commitment>
690        + CanObserve<MX::Commitment>,
691{
692    type Val = F;
693    type Commitment = MT::Commitment;
694    type ProverData = BinaryPcsProverData<F, EF, MT>;
695    type Proof = BinaryPcsProof<F, EF, MT, MX>;
696    type Error = BinaryPcsError<F, MT::Error>;
697    type ProverError = BinaryPcsError<F, MT::Error>;
698    type Witness = Witness<F>;
699    type OpeningProtocol = OpeningProtocol;
700
701    fn num_vars(&self) -> usize {
702        self.config.num_variables()
703    }
704
705    fn commit(
706        &self,
707        witness: Self::Witness,
708        challenger: &mut Challenger,
709    ) -> Result<(Self::Commitment, Self::ProverData), Self::ProverError> {
710        let (commitment, prover_data) =
711            commit::<F, EF, _, _>(&self.config, &self.encoder, &self.mmcs, witness);
712
713        // The verifier reaches the same call, so neither side can bind differently.
714        self.observe_commitment(&commitment, challenger);
715
716        Ok((commitment, prover_data))
717    }
718
719    fn observe_commitment(&self, commitment: &Self::Commitment, challenger: &mut Challenger) {
720        observe_commitment::<F, _, _>(challenger, commitment.clone());
721    }
722
723    /// Rejects an over-budget protocol before touching the challenger.
724    fn open(
725        &self,
726        prover_data: Self::ProverData,
727        protocol: Self::OpeningProtocol,
728        challenger: &mut Challenger,
729    ) -> Result<Self::Proof, Self::ProverError> {
730        self.try_open(prover_data, &protocol, challenger)
731    }
732
733    fn verify(
734        &self,
735        commitment: &Self::Commitment,
736        proof: &Self::Proof,
737        challenger: &mut Challenger,
738        protocol: Self::OpeningProtocol,
739    ) -> Result<(), Self::Error> {
740        // The prover binds the root while committing, so the verifier binds it here.
741        self.observe_commitment(commitment, challenger);
742        self.verify_opening(commitment, proof, &protocol, None, challenger)
743            .map(|_| ())
744    }
745}
746
747impl<F, EF, MT, MX, E, Challenger> PrescribedPointPcs<EF, Challenger>
748    for BinaryPcs<F, EF, MT, MX, E>
749where
750    F: EncodableLevel + TranscriptField + FoldAlphabet<EF>,
751    EF: ChallengeField<F> + ExtensionField<F> + TowerLevel + FoldAlphabet<EF>,
752    MT: Mmcs<F>,
753    MX: Mmcs<EF, Error = MT::Error>,
754    E: Encoder<F> + Sync,
755    Challenger: FieldChallenger<F>
756        + GrindingChallenger<Witness = F>
757        + CanSampleUniformBits<F>
758        + CanObserve<MT::Commitment>
759        + CanObserve<MX::Commitment>,
760{
761    fn prescribed_security(&self, protocol: &OpeningProtocol) -> Option<PrescribedOpeningSecurity> {
762        self.validate_opening_protocol(protocol).ok()?;
763        Some(PrescribedOpeningSecurity {
764            terms: vec![
765                self.config
766                    .security_regime()
767                    .opening_term(protocol.checked_num_claims()?),
768            ],
769            log2_max_candidates: 0.0,
770        })
771    }
772
773    /// Rejects invalid or over-budget protocols before touching the challenger.
774    /// Points must already be transcript-bound.
775    fn open_at(
776        &self,
777        prover_data: Self::ProverData,
778        protocol: &OpeningProtocol,
779        points: &[Point<EF>],
780        challenger: &mut Challenger,
781    ) -> Result<Self::Proof, Self::ProverError> {
782        self.try_open_at(prover_data, protocol, points, challenger)
783    }
784
785    /// Verifies an opening proof against `points` instead of sampling each opening point from
786    /// the transcript.
787    ///
788    /// This trait gives no Fiat-Shamir guarantee on its own.
789    /// The caller must bind `points` to the shared transcript first, as the prover did.
790    ///
791    /// This method does not absorb `commitment` either.
792    /// The caller absorbs it once, before drawing any challenge of its own.
793    ///
794    /// That absorption is what binding `points` to the transcript rests on.
795    fn verify_at(
796        &self,
797        commitment: &Self::Commitment,
798        proof: &Self::Proof,
799        protocol: &OpeningProtocol,
800        points: &[Point<EF>],
801        challenger: &mut Challenger,
802    ) -> Result<Vec<OpeningEvals<EF>>, Self::Error> {
803        Self::validate_points(protocol, points)?;
804        self.verify_opening(commitment, proof, protocol, Some(points), challenger)
805            .map(<[_]>::to_vec)
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use alloc::vec::Vec;
812    use alloc::{format, vec};
813
814    use p3_binary_dft::EncodableLevel;
815    use p3_binary_field::{BinaryField8, BinaryField16, BinaryField64, BinaryField128};
816    use p3_challenger::fs::TranscriptField;
817    use p3_challenger::{CanObserve, CanSampleUniformBits, FieldChallenger, GrindingChallenger};
818    use p3_commit::{Mmcs, MultilinearPcs};
819    use p3_field::{ExtensionField, PrimeCharacteristicRing};
820    use p3_multilinear_util::point::Point;
821    use p3_sumcheck::layout::{Layout, SuffixProver, Table, Witness};
822    use p3_sumcheck::{OpeningBatch, OpeningProtocol, PrescribedPointPcs, TableShape, TableSpec};
823    use rand::SeedableRng;
824    use rand::distr::{Distribution, StandardUniform};
825    use rand::rngs::SmallRng;
826
827    use super::BinaryPcs;
828    use crate::error::BinaryPcsError;
829    use crate::fold::{ChallengeField, FoldAlphabet};
830    use crate::params::{BinaryPcsConfig, BinaryPcsConfigError, BinaryPcsParams};
831    use crate::proof::BinaryPcsProof;
832    use crate::prover::BinaryPcsProverData;
833    use crate::test_util::{
834        LevelChallenger, LevelMmcs, MyChallenger, MyMmcs, challenger, level_challenger, level_mmcs,
835        mmcs, run_lifecycle,
836    };
837
838    type F = BinaryField128;
839
840    const NUM_VARIABLES: usize = 8;
841
842    #[test]
843    fn constructor_rejects_schedules_derived_for_other_tower_levels() {
844        // Invariant: the schedule's security bounds belong to both derivation fields.
845        //
846        // A wider alphabet can admit a larger domain and a wider grinding witness.
847        // A wider challenge field can also report smaller algebraic errors.
848        // Reusing either bound at a narrower level would overstate security.
849        let high_security = BinaryPcsParams {
850            log_inv_rate: 2,
851            pow_bits: 0,
852            security_level: 100,
853        };
854
855        // Fixture state: derive at (128, 128), then request (64, 64).
856        // The alphabet mismatch is checked first and returned through the constructor.
857        let wide =
858            BinaryPcsConfig::try_new::<BinaryField128, BinaryField128>(20, high_security).unwrap();
859        let committed = BinaryPcs::<BinaryField64, BinaryField64, (), ()>::new(wide, (), ()).err();
860        assert_eq!(
861            committed,
862            Some(BinaryPcsConfigError::CommittedFieldMismatch {
863                derived: 128,
864                actual: 64,
865            })
866        );
867
868        // Fixture state: derive at (16, 128), then narrow only the committed alphabet.
869        let mixed = BinaryPcsConfig::try_new::<BinaryField16, BinaryField128>(
870            8,
871            BinaryPcsParams {
872                log_inv_rate: 2,
873                pow_bits: 0,
874                security_level: 40,
875            },
876        )
877        .unwrap();
878        let committed = BinaryPcs::<BinaryField8, BinaryField128, (), ()>::new(mixed, (), ()).err();
879        assert_eq!(
880            committed,
881            Some(BinaryPcsConfigError::CommittedFieldMismatch {
882                derived: 16,
883                actual: 8,
884            })
885        );
886
887        // Fixture state: keep the 16-bit alphabet and narrow only the challenge field.
888        let challenge = BinaryPcs::<BinaryField16, BinaryField64, (), ()>::new(mixed, (), ()).err();
889        assert_eq!(
890            challenge,
891            Some(BinaryPcsConfigError::ChallengeFieldMismatch {
892                derived: 128,
893                actual: 64,
894            })
895        );
896    }
897
898    /// Commit one column, open it directly at a transcript-sampled point, and verify, over one
899    /// committed alphabet and one challenge field.
900    ///
901    /// That claim shape is the one the banked residual route plays, so this reaches the
902    /// challenge field's own sumcheck representation and its encoder end to end.
903    ///
904    /// A tampered base symbol must then break the fold chain.
905    fn a_single_column_round_trip<A, C>(
906        num_variables: usize,
907        log_folding_factor: usize,
908        log_inv_rate: usize,
909        seed: u64,
910    ) where
911        A: EncodableLevel + TranscriptField + FoldAlphabet<C> + PrimeCharacteristicRing,
912        C: ChallengeField<A> + ExtensionField<A> + FoldAlphabet<C>,
913        StandardUniform: Distribution<A>,
914        LevelMmcs<A>: Mmcs<A>,
915        LevelMmcs<C>: Mmcs<C, Error = <LevelMmcs<A> as Mmcs<A>>::Error>,
916        LevelChallenger<A>: FieldChallenger<A>
917            + GrindingChallenger<Witness = A>
918            + CanSampleUniformBits<A>
919            + CanObserve<<LevelMmcs<A> as Mmcs<A>>::Commitment>
920            + CanObserve<<LevelMmcs<C> as Mmcs<C>>::Commitment>,
921    {
922        let shape = format!(
923            "bits {}/{}, arity {log_folding_factor}",
924            A::bits(),
925            C::bits()
926        );
927        let mut rng = SmallRng::seed_from_u64(seed);
928        let table = Table::<A>::rand(&mut rng, 1, num_variables);
929        let protocol = OpeningProtocol::new(vec![TableSpec::new(
930            TableShape::new(num_variables, 1),
931            vec![OpeningBatch::new(vec![0], Vec::new())],
932        )]);
933
934        let params = BinaryPcsParams {
935            log_inv_rate,
936            pow_bits: 0,
937            security_level: 40,
938        };
939        let config = BinaryPcsConfig::try_new_with_folding::<A, C>(
940            num_variables,
941            params,
942            log_folding_factor,
943        )
944        .unwrap();
945        let pcs: BinaryPcs<A, C, LevelMmcs<A>, LevelMmcs<C>> =
946            BinaryPcs::new(config, level_mmcs(), level_mmcs()).unwrap();
947
948        let mut prover_ch = level_challenger::<A>();
949        let witness = SuffixProver::<A, C>::new_witness(vec![table], 0);
950        let (root, data) = pcs.commit(witness, &mut prover_ch).unwrap();
951        let proof = pcs.open(data, protocol.clone(), &mut prover_ch).unwrap();
952
953        pcs.verify(
954            &root,
955            &proof,
956            &mut level_challenger::<A>(),
957            protocol.clone(),
958        )
959        .unwrap_or_else(|error| panic!("{shape}: {error:?}"));
960
961        let mut tampered = proof;
962        tampered.base_opened_values[0][0] += A::ONE;
963        assert!(
964            pcs.verify(&root, &tampered, &mut level_challenger::<A>(), protocol)
965                .is_err(),
966            "{shape}: a tampered base symbol was accepted"
967        );
968    }
969
970    #[test]
971    fn a_single_column_round_trips_over_every_challenge_field() {
972        // Fixture state: every folding factor from one round per batch to four.
973        //
974        //     8-bit alphabet    arity 6 at rate 2, a 2^8 base codeword, the whole 8-bit domain
975        //     wider alphabets   arity 10, several stages of the residual route
976        for log_folding_factor in 1..=4 {
977            a_single_column_round_trip::<BinaryField8, BinaryField64>(6, log_folding_factor, 2, 1);
978            a_single_column_round_trip::<BinaryField8, F>(6, log_folding_factor, 2, 2);
979            a_single_column_round_trip::<BinaryField64, BinaryField64>(
980                10,
981                log_folding_factor,
982                1,
983                3,
984            );
985            a_single_column_round_trip::<BinaryField64, F>(10, log_folding_factor, 1, 4);
986            a_single_column_round_trip::<F, F>(10, log_folding_factor, 1, 5);
987        }
988    }
989
990    /// Commit, open at a transcript-sampled point, verify. The prover and verifier run on
991    /// independent challengers seeded identically, which is what makes a transcript desync
992    /// show up as a failure rather than pass by sharing state.
993    #[test]
994    fn commit_open_verify_round_trips() {
995        let (pcs, commitment, proof, protocol) = run_lifecycle(NUM_VARIABLES);
996
997        let mut verifier_challenger = challenger();
998        pcs.verify(&commitment, &proof, &mut verifier_challenger, protocol)
999            .unwrap();
1000    }
1001
1002    /// The workspace wire format is postcard, which is not self-describing; a proof type that
1003    /// only round-trips through a self-describing format is not actually shippable. Decoding
1004    /// also exercises the `Poly` deserialize path `FinalCodewordLengthMismatch` exists to guard.
1005    #[test]
1006    fn a_proof_round_trips_through_postcard() {
1007        let (pcs, commitment, proof, protocol) = run_lifecycle(NUM_VARIABLES);
1008
1009        let bytes = postcard::to_allocvec(&proof).unwrap();
1010        let decoded: BinaryPcsProof<F, F, MyMmcs, MyMmcs> = postcard::from_bytes(&bytes).unwrap();
1011        assert_eq!(decoded.rounds.len(), proof.rounds.len());
1012
1013        let mut verifier_challenger = challenger();
1014        pcs.verify(&commitment, &decoded, &mut verifier_challenger, protocol)
1015            .unwrap();
1016    }
1017
1018    /// Opens through the reference fold route, and through the shipped path everywhere else.
1019    ///
1020    /// The claims are recorded exactly as [`BinaryPcs::try_open`] records them, then
1021    /// [`BinaryPcs::finish_open_with`] runs the rest with `BIND_EACH_ROUND = true`.
1022    ///
1023    /// Nothing the shipped path does after the fold rounds is reproduced here.
1024    ///
1025    /// So the two proofs can only differ if a round polynomial did.
1026    fn open_binding_each_round(
1027        pcs: &BinaryPcs<F, F, MyMmcs, MyMmcs>,
1028        mut prover_data: BinaryPcsProverData<F, F, MyMmcs>,
1029        protocol: &OpeningProtocol,
1030        challenger: &mut MyChallenger,
1031    ) -> BinaryPcsProof<F, F, MyMmcs, MyMmcs> {
1032        let evals = protocol
1033            .iter_openings()
1034            .map(|(table_idx, batch)| prover_data.layout.eval(table_idx, batch, challenger))
1035            .collect();
1036
1037        pcs.finish_open_with::<true, MyChallenger>(prover_data, evals, challenger)
1038    }
1039
1040    #[test]
1041    fn fusing_the_binding_into_the_measuring_pass_leaves_the_proof_byte_identical() {
1042        // Invariant: how many passes compute a round polynomial never changes its value.
1043        //
1044        //     shipped  : round r measures and applies round r-1's binding in one pass
1045        //     reference: round r measures, then a second pass applies round r's binding
1046        //
1047        // Both routes must send the same transcript, so the proof bytes must match.
1048        //
1049        // Fixture state: one random single-column table.
1050        //
1051        //     opened at : a transcript-sampled point
1052        //     driven    : twice, from identically seeded challengers
1053        //
1054        // The grinding budget is zero, which is what makes the whole proof reproducible.
1055        //
1056        // A non-zero budget searches its witness across threads.
1057        //
1058        // It keeps whichever witness a thread finds first.
1059        //
1060        // The witness, and every transcript draw after it, then varies run to run.
1061        //
1062        // Both folding factors, because a held challenge takes a different path in each:
1063        //
1064        //     1: every round is its own fold batch, so it only ever crosses a batch
1065        //     3: three rounds share a batch, so it also crosses a round boundary inside one
1066        let num_variables = NUM_VARIABLES;
1067        let params = BinaryPcsParams {
1068            log_inv_rate: 2,
1069            pow_bits: 0,
1070            security_level: 40,
1071        };
1072
1073        for log_folding_factor in [1usize, 3] {
1074            let mut rng = SmallRng::seed_from_u64(0x50FA);
1075            let table = Table::rand(&mut rng, 1, num_variables);
1076            let shape = format!("arity {log_folding_factor}");
1077
1078            let protocol = OpeningProtocol::new(vec![TableSpec::new(
1079                TableShape::new(num_variables, 1),
1080                vec![OpeningBatch::new(vec![0], Vec::new())],
1081            )]);
1082
1083            let config = BinaryPcsConfig::try_new_with_folding::<F, F>(
1084                num_variables,
1085                params,
1086                log_folding_factor,
1087            )
1088            .unwrap();
1089            let pcs = BinaryPcs::new(config, mmcs(), mmcs()).unwrap();
1090
1091            // Shipped route.
1092            let mut got_challenger = challenger();
1093            let (got_commitment, got_data) = pcs
1094                .commit(
1095                    SuffixProver::<F, F>::new_witness(vec![table.clone()], 0),
1096                    &mut got_challenger,
1097                )
1098                .unwrap();
1099            let got = pcs
1100                .open(got_data, protocol.clone(), &mut got_challenger)
1101                .unwrap();
1102
1103            // Reference route, from an identically seeded challenger.
1104            let mut want_challenger = challenger();
1105            let (want_commitment, want_data) = pcs
1106                .commit(
1107                    SuffixProver::<F, F>::new_witness(vec![table], 0),
1108                    &mut want_challenger,
1109                )
1110                .unwrap();
1111            let want = open_binding_each_round(&pcs, want_data, &protocol, &mut want_challenger);
1112
1113            // Round by round first, so a discrepancy is localised to the round that drifted.
1114            assert_eq!(
1115                got.sumcheck.num_rounds(),
1116                want.sumcheck.num_rounds(),
1117                "{shape}: round counts"
1118            );
1119            for (round, (got_msg, want_msg)) in got
1120                .sumcheck
1121                .polynomial_evaluations()
1122                .iter()
1123                .zip(want.sumcheck.polynomial_evaluations())
1124                .enumerate()
1125            {
1126                assert_eq!(got_msg, want_msg, "{shape}: round {round} message");
1127            }
1128
1129            // Then the whole proof, on the wire.
1130            let got_bytes = postcard::to_allocvec(&got).unwrap();
1131            let want_bytes = postcard::to_allocvec(&want).unwrap();
1132            assert_eq!(got_bytes, want_bytes, "{shape}: proof bytes");
1133
1134            // The transcripts must also be left in the same state.
1135            //
1136            // Equal proof bytes do not show that on their own.
1137            //
1138            // Two challengers that diverged could still have produced the same bytes.
1139            assert_eq!(
1140                got_challenger.sample_algebra_element::<F>(),
1141                want_challenger.sample_algebra_element::<F>(),
1142                "{shape}: transcript state after opening"
1143            );
1144
1145            // And the proof both routes produced verifies.
1146            assert_eq!(got_commitment, want_commitment, "{shape}");
1147            pcs.verify(&got_commitment, &got, &mut challenger(), protocol)
1148                .unwrap();
1149        }
1150    }
1151
1152    /// Zero grinding, so every draw after the first is reproducible run to run.
1153    const fn reproducible_params() -> BinaryPcsParams {
1154        BinaryPcsParams {
1155            log_inv_rate: 2,
1156            pow_bits: 0,
1157            security_level: 40,
1158        }
1159    }
1160
1161    /// One random single-column table, rebuilt from the same seed on every call.
1162    fn one_column_witness() -> Witness<F> {
1163        let mut rng = SmallRng::seed_from_u64(0x0EAF);
1164        SuffixProver::<F, F>::new_witness(vec![Table::rand(&mut rng, 1, NUM_VARIABLES)], 0)
1165    }
1166
1167    /// The single-claim protocol [`one_column_witness`] is opened under.
1168    fn one_column_protocol() -> OpeningProtocol {
1169        OpeningProtocol::new(vec![TableSpec::new(
1170            TableShape::new(NUM_VARIABLES, 1),
1171            vec![OpeningBatch::new(vec![0], Vec::new())],
1172        )])
1173    }
1174
1175    #[test]
1176    fn supplying_the_evaluation_leaves_the_proof_byte_identical() {
1177        // Invariant: where a claimed value comes from never changes what is proved.
1178        //
1179        //     computed: the commitment evaluates the opened column at the point
1180        //     supplied: the caller hands in the same value, and no column is read
1181        //
1182        // Both routes record one claim and bind one value, so the proof bytes and the
1183        // transcript state must match.
1184        //
1185        // Fixture state: one random single-column table.
1186        //
1187        //     opened at: a point each route derives from its own identically seeded sponge
1188        //     ground   : nothing, so the whole proof is reproducible
1189        let protocol = one_column_protocol();
1190        let config =
1191            BinaryPcsConfig::try_new::<F, F>(NUM_VARIABLES, reproducible_params()).unwrap();
1192        let pcs = BinaryPcs::new(config, mmcs(), mmcs()).unwrap();
1193
1194        // Computed route.
1195        let mut computed_challenger = challenger();
1196        let (commitment, computed_data) = pcs
1197            .commit(one_column_witness(), &mut computed_challenger)
1198            .unwrap();
1199        let sample: F = computed_challenger.sample_algebra_element();
1200        let point = Point::expand_from_univariate(sample, NUM_VARIABLES);
1201        let computed = pcs
1202            .try_open_at(
1203                computed_data,
1204                &protocol,
1205                core::slice::from_ref(&point),
1206                &mut computed_challenger,
1207            )
1208            .unwrap();
1209
1210        // Supplied route, from an identically seeded challenger, handed what the first found.
1211        let mut supplied_challenger = challenger();
1212        let (same_commitment, supplied_data) = pcs
1213            .commit(one_column_witness(), &mut supplied_challenger)
1214            .unwrap();
1215        let sample: F = supplied_challenger.sample_algebra_element();
1216        let supplied_point = Point::expand_from_univariate(sample, NUM_VARIABLES);
1217        assert_eq!(same_commitment, commitment, "same witness, same commitment");
1218        assert_eq!(supplied_point, point, "same sponge, same point");
1219
1220        let supplied = pcs
1221            .try_open_at_known(
1222                supplied_data,
1223                &protocol,
1224                core::slice::from_ref(&supplied_point),
1225                &computed.evals,
1226                &mut supplied_challenger,
1227            )
1228            .unwrap();
1229
1230        assert_eq!(
1231            postcard::to_allocvec(&supplied).unwrap(),
1232            postcard::to_allocvec(&computed).unwrap(),
1233            "proof bytes"
1234        );
1235
1236        // Equal proof bytes do not show the two sponges agree, so check that separately.
1237        assert_eq!(
1238            supplied_challenger.sample_algebra_element::<F>(),
1239            computed_challenger.sample_algebra_element::<F>(),
1240            "transcript state after opening"
1241        );
1242
1243        // And the proof the supplied route produced verifies against an independent sponge.
1244        let mut verifier_challenger = challenger();
1245        pcs.observe_commitment(&commitment, &mut verifier_challenger);
1246        let verifier_sample: F = verifier_challenger.sample_algebra_element();
1247        let verifier_point = Point::expand_from_univariate(verifier_sample, NUM_VARIABLES);
1248        pcs.verify_at(
1249            &commitment,
1250            &supplied,
1251            &protocol,
1252            core::slice::from_ref(&verifier_point),
1253            &mut verifier_challenger,
1254        )
1255        .unwrap();
1256    }
1257
1258    #[test]
1259    fn a_known_opening_whose_evaluations_miss_the_protocol_is_refused() {
1260        // Every refusal is structural, so none may move the sponge.
1261        //
1262        //     none      : no evaluation for the one batch the protocol names
1263        //     two values: one batch, but two values where it opens one column
1264        //     wrong side: one batch and one value, but against the successor view
1265        let protocol = one_column_protocol();
1266        let config =
1267            BinaryPcsConfig::try_new::<F, F>(NUM_VARIABLES, reproducible_params()).unwrap();
1268        let pcs = BinaryPcs::new(config, mmcs(), mmcs()).unwrap();
1269
1270        let mut prover_challenger = challenger();
1271        let (_, data) = pcs
1272            .commit(one_column_witness(), &mut prover_challenger)
1273            .unwrap();
1274        let sample: F = prover_challenger.sample_algebra_element();
1275        let point = Point::expand_from_univariate(sample, NUM_VARIABLES);
1276
1277        let mut snapshot = prover_challenger.clone();
1278        let missing = pcs
1279            .try_open_at_known(
1280                data.clone(),
1281                &protocol,
1282                core::slice::from_ref(&point),
1283                &[],
1284                &mut prover_challenger,
1285            )
1286            .err()
1287            .unwrap();
1288        assert!(
1289            matches!(
1290                missing,
1291                BinaryPcsError::OpeningEvalCountMismatch {
1292                    expected: 1,
1293                    actual: 0,
1294                }
1295            ),
1296            "{missing:?}"
1297        );
1298
1299        let over = pcs
1300            .try_open_at_known(
1301                data.clone(),
1302                &protocol,
1303                core::slice::from_ref(&point),
1304                &[OpeningBatch::new(vec![sample, sample], Vec::new())],
1305                &mut prover_challenger,
1306            )
1307            .err()
1308            .unwrap();
1309        assert!(
1310            matches!(
1311                over,
1312                BinaryPcsError::OpeningBatchSizeMismatch {
1313                    table_idx: 0,
1314                    expected_current: 1,
1315                    expected_next: 0,
1316                    actual_current: 2,
1317                    actual_next: 0,
1318                }
1319            ),
1320            "{over:?}"
1321        );
1322
1323        let sided = pcs
1324            .try_open_at_known(
1325                data,
1326                &protocol,
1327                core::slice::from_ref(&point),
1328                &[OpeningBatch::new(Vec::new(), vec![sample])],
1329                &mut prover_challenger,
1330            )
1331            .err()
1332            .unwrap();
1333        assert!(
1334            matches!(
1335                sided,
1336                BinaryPcsError::OpeningBatchSizeMismatch {
1337                    table_idx: 0,
1338                    expected_current: 1,
1339                    expected_next: 0,
1340                    actual_current: 0,
1341                    actual_next: 1,
1342                }
1343            ),
1344            "{sided:?}"
1345        );
1346
1347        assert_eq!(
1348            prover_challenger.sample_algebra_element::<F>(),
1349            snapshot.sample_algebra_element::<F>(),
1350            "a refused opening leaves the transcript alone"
1351        );
1352    }
1353
1354    #[test]
1355    fn an_opening_of_a_layout_that_runs_preprocessing_rounds_is_refused() {
1356        // The commit phase lays out one committed column, so no round has the per-round
1357        // residual a preprocessing round reads, and the fold pipeline asserts that depth
1358        // once it is already under way. Every entry point must refuse it before then.
1359        //
1360        // Fixture state: one random single-column table, committed at depth one.
1361        let protocol = one_column_protocol();
1362        let config =
1363            BinaryPcsConfig::try_new::<F, F>(NUM_VARIABLES, reproducible_params()).unwrap();
1364        let pcs = BinaryPcs::new(config, mmcs(), mmcs()).unwrap();
1365
1366        let mut rng = SmallRng::seed_from_u64(0x0EAF);
1367        let witness =
1368            SuffixProver::<F, F>::new_witness(vec![Table::rand(&mut rng, 1, NUM_VARIABLES)], 1);
1369        let mut prover_challenger = challenger();
1370        let (_, data) = pcs.commit(witness, &mut prover_challenger).unwrap();
1371        assert_eq!(data.layout.folding(), 1, "the fixture must carry the depth");
1372
1373        let sample: F = prover_challenger.sample_algebra_element();
1374        let point = Point::expand_from_univariate(sample, NUM_VARIABLES);
1375        let evals = [OpeningBatch::new(vec![sample], Vec::new())];
1376
1377        let mut snapshot = prover_challenger.clone();
1378        let refusals = [
1379            pcs.try_open(data.clone(), &protocol, &mut prover_challenger),
1380            pcs.try_open_at(
1381                data.clone(),
1382                &protocol,
1383                core::slice::from_ref(&point),
1384                &mut prover_challenger,
1385            ),
1386            pcs.try_open_at_known(
1387                data,
1388                &protocol,
1389                core::slice::from_ref(&point),
1390                &evals,
1391                &mut prover_challenger,
1392            ),
1393        ];
1394        for refusal in refusals {
1395            let refusal = refusal.err().unwrap();
1396            assert!(
1397                matches!(
1398                    refusal,
1399                    BinaryPcsError::OpeningPreprocessingDepth { folding: 1 }
1400                ),
1401                "{refusal:?}"
1402            );
1403        }
1404
1405        assert_eq!(
1406            prover_challenger.sample_algebra_element::<F>(),
1407            snapshot.sample_algebra_element::<F>(),
1408            "a refused opening leaves the transcript alone"
1409        );
1410    }
1411
1412    const fn params() -> BinaryPcsParams {
1413        BinaryPcsParams {
1414            log_inv_rate: 2,
1415            pow_bits: 4,
1416            security_level: 40,
1417        }
1418    }
1419
1420    /// Commits a random single-column table and opens it with [`PrescribedPointPcs::open_at`]
1421    /// at a point derived from the prover's own transcript, after the commitment `commit` has
1422    /// already absorbed — the same "sampled after the commitment" convention [`Layout::eval`]
1423    /// uses internally for the transcript-sampled path, just performed by the caller instead.
1424    ///
1425    /// Returns the point alongside everything a caller needs to replay `verify_at`, so a test
1426    /// can either re-derive a matching point on its own challenger, or reuse this exact one.
1427    #[allow(clippy::type_complexity)]
1428    fn open_at_fixture(
1429        seed: u64,
1430        log_folding_factor: usize,
1431    ) -> (
1432        BinaryPcs<F, F, MyMmcs, MyMmcs>,
1433        <MyMmcs as Mmcs<F>>::Commitment,
1434        BinaryPcsProof<F, F, MyMmcs, MyMmcs>,
1435        OpeningProtocol,
1436        Point<F>,
1437    ) {
1438        let mut rng = SmallRng::seed_from_u64(seed);
1439        let table = Table::rand(&mut rng, 1, NUM_VARIABLES);
1440        let witness = SuffixProver::<F, F>::new_witness(vec![table], 0);
1441
1442        let protocol = OpeningProtocol::new(vec![TableSpec::new(
1443            TableShape::new(NUM_VARIABLES, 1),
1444            vec![OpeningBatch::new(vec![0], Vec::new())],
1445        )]);
1446
1447        let config = BinaryPcsConfig::try_new::<F, F>(NUM_VARIABLES, params())
1448            .unwrap()
1449            .try_with_folding(log_folding_factor)
1450            .unwrap();
1451        let pcs = BinaryPcs::new(config, mmcs(), mmcs()).unwrap();
1452
1453        let mut prover_challenger = challenger();
1454        let (commitment, prover_data) = pcs.commit(witness, &mut prover_challenger).unwrap();
1455        let sample: F = prover_challenger.sample_algebra_element();
1456        let point = Point::expand_from_univariate(sample, NUM_VARIABLES);
1457        let proof = pcs
1458            .open_at(
1459                prover_data,
1460                &protocol,
1461                core::slice::from_ref(&point),
1462                &mut prover_challenger,
1463            )
1464            .unwrap();
1465
1466        (pcs, commitment, proof, protocol, point)
1467    }
1468
1469    /// Commit, open at a point the prover derives from its own transcript, verify against a
1470    /// point the verifier derives, independently, from its own — never a clone taken after
1471    /// proving. Both challengers are seeded identically and have, at the point each samples,
1472    /// observed exactly the commitment and nothing else, so the two derivations agree; that
1473    /// agreement is `PrescribedPointPcs`'s whole Fiat-Shamir contract, exercised here rather
1474    /// than assumed.
1475    #[test]
1476    fn verify_at_round_trips_with_transcript_derived_points() {
1477        let (pcs, commitment, proof, protocol, point) = open_at_fixture(0xFEED, 1);
1478
1479        // `verify_at` does not absorb the commitment; the caller does, exactly once, before
1480        // deriving the point it then hands to `verify_at`.
1481        let mut verifier_challenger = challenger();
1482        pcs.observe_commitment(&commitment, &mut verifier_challenger);
1483        let sample: F = verifier_challenger.sample_algebra_element();
1484        let verifier_point = Point::expand_from_univariate(sample, NUM_VARIABLES);
1485        assert_eq!(
1486            verifier_point, point,
1487            "both sides derive the same point from identically-seeded transcripts"
1488        );
1489
1490        pcs.verify_at(
1491            &commitment,
1492            &proof,
1493            &protocol,
1494            core::slice::from_ref(&verifier_point),
1495            &mut verifier_challenger,
1496        )
1497        .unwrap();
1498    }
1499
1500    #[test]
1501    fn batched_verify_at_round_trips_with_transcript_derived_points() {
1502        let (pcs, commitment, proof, protocol, point) = open_at_fixture(0xFEED, 3);
1503        let mut verifier_challenger = challenger();
1504        pcs.observe_commitment(&commitment, &mut verifier_challenger);
1505        let sample: F = verifier_challenger.sample_algebra_element();
1506        let verifier_point = Point::expand_from_univariate(sample, NUM_VARIABLES);
1507        assert_eq!(verifier_point, point);
1508        pcs.verify_at(
1509            &commitment,
1510            &proof,
1511            &protocol,
1512            core::slice::from_ref(&verifier_point),
1513            &mut verifier_challenger,
1514        )
1515        .unwrap();
1516    }
1517
1518    /// A verifier that skips absorbing the commitment before calling `verify_at` — the one
1519    /// responsibility `verify_at` leaves to its caller — must reject the proof, even though the
1520    /// point it supplies is the genuine one the proof was opened at. This is what would catch a
1521    /// future edit that "fixes" `verify_at` to absorb the commitment internally for symmetry
1522    /// with `verify`: such a fix could not repair a point already computed from an unabsorbed
1523    /// transcript, but it would make this exact scenario (genuine point, skipped absorption)
1524    /// verify anyway, since the point supplied here needs no repair — only the challenger does.
1525    #[test]
1526    fn verify_at_rejects_a_proof_when_the_caller_skips_absorbing_the_commitment() {
1527        let (pcs, commitment, proof, protocol, point) = open_at_fixture(0xFEED, 1);
1528
1529        let mut verifier_challenger = challenger();
1530        let err = pcs
1531            .verify_at(
1532                &commitment,
1533                &proof,
1534                &protocol,
1535                core::slice::from_ref(&point),
1536                &mut verifier_challenger,
1537            )
1538            .unwrap_err();
1539        assert!(
1540            matches!(err, BinaryPcsError::FinalCheck),
1541            "expected FinalCheck, got {err:?}"
1542        );
1543    }
1544
1545    /// `run_lifecycle`'s single-table, single-column, `next = []` fixture stacks trivially:
1546    /// the committed polynomial's arity already equals the one table's own arity, so no
1547    /// selector bits are spent lifting a local claim into the stacked space, and the
1548    /// `Statements::Next` arm of `Verifier::constraint` never runs. A two-table layout forces
1549    /// real selector lifting, and a `next` opening exercises the repeat-last successor view.
1550    ///
1551    /// Table A costs one slot per column at its own arity — a multi-column table does not
1552    /// share a hypercube across its columns — so its two columns at arity 2 cost `2 * 2^2 = 8`
1553    /// cells; table B costs `1 * 2^3 = 8` more. The stacked arity is `log2_ceil` of that total,
1554    /// `4`, not a sum of the two tables' own arities.
1555    #[test]
1556    fn commit_open_verify_round_trips_with_a_stacked_multi_table_layout() {
1557        let table_a_arity = 2;
1558        let table_b_arity = 3;
1559        let stacked_arity = 4;
1560
1561        let mut rng = SmallRng::seed_from_u64(0x57AC);
1562        let table_a = Table::rand(&mut rng, 2, table_a_arity);
1563        let table_b = Table::rand(&mut rng, 1, table_b_arity);
1564        let witness = SuffixProver::<F, F>::new_witness(vec![table_a, table_b], 0);
1565
1566        let protocol = OpeningProtocol::new(vec![
1567            TableSpec::new(
1568                TableShape::new(table_a_arity, 2),
1569                vec![OpeningBatch::new(vec![0, 1], Vec::new())],
1570            ),
1571            TableSpec::new(
1572                TableShape::new(table_b_arity, 1),
1573                // Column 0 opened both directly and through the repeat-last successor view,
1574                // at the same sampled point, which is what puts a `Statements::Next` entry
1575                // into the constraint the final check evaluates.
1576                vec![OpeningBatch::new(vec![0], vec![0])],
1577            ),
1578        ]);
1579
1580        let config = BinaryPcsConfig::try_new::<F, F>(stacked_arity, params()).unwrap();
1581        let pcs: BinaryPcs<F, F, MyMmcs, MyMmcs> = BinaryPcs::new(config, mmcs(), mmcs()).unwrap();
1582
1583        let mut prover_challenger = challenger();
1584        let (commitment, prover_data) = pcs.commit(witness, &mut prover_challenger).unwrap();
1585        let proof = pcs
1586            .open(prover_data, protocol.clone(), &mut prover_challenger)
1587            .unwrap();
1588
1589        let mut verifier_challenger = challenger();
1590        pcs.verify(&commitment, &proof, &mut verifier_challenger, protocol)
1591            .unwrap();
1592    }
1593}