Skip to main content

p3_binary_pcs/
prover.rs

1//! Prover side: commit the stacked polynomial as one codeword column, then fold that codeword
2//! in lockstep with the residual sumcheck.
3//!
4//! The layout commits at no preprocessing depth, so its commit phase produces one column.
5//! That column is the whole committed polynomial, Reed-Solomon encoded.
6//!
7//! No preprocessing round is consumed either.
8//! Every committed variable is therefore a folding round.
9//!
10//! Each round's challenge is used twice.
11//! It binds one multilinear variable through the layout's evaluation-basis suffix binding.
12//!
13//! It also folds the codeword, a Reed-Solomon codeword fold in the same basis.
14//! The two stay in correspondence throughout, which the tests below drive and check.
15//!
16//! # Two fields, one schedule
17//!
18//! ```text
19//!     base word       the committed alphabet, committed through the base scheme
20//!     folded words    the challenge field, committed through the folded scheme
21//! ```
22//!
23//! The first fold of the first batch is the only one that crosses between them.
24
25use alloc::vec::Vec;
26
27use p3_challenger::fs::TranscriptField;
28use p3_challenger::{CanObserve, CanSampleUniformBits, FieldChallenger, GrindingChallenger};
29use p3_commit::{Encoder, Mmcs};
30use p3_field::{ExtensionField, Field};
31use p3_matrix::dense::{DenseMatrix, RowMajorMatrix};
32use p3_multilinear_util::point::Point;
33use p3_sumcheck::SumcheckData;
34use p3_sumcheck::layout::{Layout, SuffixResidualProver, Table, Witness};
35
36use crate::PcsLayout;
37use crate::fold::{ChallengeField, FoldAlphabet, fold_codeword_batch};
38use crate::params::BinaryPcsConfig;
39use crate::proof::RoundProof;
40use crate::transcript::BinaryPcsProverTranscript;
41use crate::verifier::flat_coset_indices;
42
43/// Preprocessing depth the commit phase lays out inside a committed row.
44///
45/// Zero, so the codeword is a single column and every variable is folded rather than bound
46/// across a row. Binding a prefix inside the row is the deferred head collapse, which needs
47/// its own eq-weighted column combination before it composes with the folds below.
48const FOLDING: usize = 0;
49
50/// Fewest rounds a batch folds for its codeword to be encoded from the bound message rather than
51/// folded from the previous codeword.
52///
53/// Below it, folding the previous codeword is the cheaper of the two at every codeword length.
54const MIN_ENCODED_ARITY: usize = 4;
55
56/// Data produced by committing the base codeword: the layout used to build the residual
57/// sumcheck, and the base commitment's Merkle prover data.
58///
59/// Retains the source tables for AIR evaluation between commitment and opening.
60/// Cloning reuses the encoding and Merkle tree for another opening of the commitment.
61#[derive(Clone)]
62pub struct BinaryPcsProverData<F: Field, EF: ExtensionField<F>, MT: Mmcs<F>> {
63    /// The layout that ran the commit phase, carried forward to build the residual sumcheck
64    /// and, later, to evaluate opening claims against the committed polynomial.
65    pub(crate) layout: PcsLayout<F, EF>,
66    /// The base codeword's Merkle prover data, needed to open base-round queries once the
67    /// query phase samples its indices.
68    pub(crate) merkle_data: MT::ProverData<DenseMatrix<F>>,
69}
70
71impl<F, EF, MT> BinaryPcsProverData<F, EF, MT>
72where
73    F: TranscriptField,
74    EF: ExtensionField<F>,
75    MT: Mmcs<F>,
76{
77    /// Returns source table `id` retained by the committed layout.
78    pub fn table(&self, id: usize) -> &Table<F> {
79        self.layout.table(id)
80    }
81}
82
83/// One folding round's prover-side output.
84pub(crate) struct RoundCommitment<EF: Field, MX: Mmcs<EF>> {
85    /// Merkle root of this round's folded codeword.
86    pub commitment: MX::Commitment,
87    /// Merkle prover data for this round's folded codeword, needed to open its queries.
88    pub merkle_data: MX::ProverData<DenseMatrix<EF>>,
89}
90
91/// Commits `witness`'s stacked polynomial and returns the base commitment alongside the data
92/// needed to run the residual sumcheck and later open the base codeword's queries.
93///
94/// Nothing is bound here.
95///
96/// The root is returned instead, and the caller binds it.
97///
98/// `witness` must have the configured number of variables, at zero preprocessing depth.
99#[tracing::instrument(name = "binary pcs commit", skip_all)]
100pub(crate) fn commit<F, EF, E, MT>(
101    config: &BinaryPcsConfig,
102    encoder: &E,
103    mmcs: &MT,
104    witness: Witness<F>,
105) -> (MT::Commitment, BinaryPcsProverData<F, EF, MT>)
106where
107    F: TranscriptField,
108    EF: ExtensionField<F>,
109    E: Encoder<F>,
110    MT: Mmcs<F>,
111{
112    assert_eq!(
113        witness.num_variables(),
114        config.num_variables(),
115        "witness arity must match the config it is committed against"
116    );
117
118    let (layout, commitment, merkle_data) =
119        PcsLayout::<F, EF>::commit(encoder, mmcs, witness, FOLDING, config.log_inv_rate());
120
121    (
122        commitment,
123        BinaryPcsProverData {
124            layout,
125            merkle_data,
126        },
127    )
128}
129
130/// Runs each residual sumcheck round before consuming its challenge in a codeword fold.
131/// Consecutive folds are fused into configured batches, with one commitment per batch except
132/// the last, whose entire codeword travels in the clear.
133///
134/// The single grinding budget is spent once before the query phase, not here.
135/// Every round below therefore runs at zero difficulty.
136///
137/// # Returns
138///
139/// - The base commitment's Merkle prover data, so base-round queries can still open.
140/// - The sumcheck transcript.
141/// - One round commitment per fold batch except the last.
142/// - The folding randomness, in round order.
143/// - The final folded codeword.
144///
145/// `BIND_EACH_ROUND` picks when each round's challenge is applied to the sumcheck tables:
146///
147/// ```text
148///     false: left outstanding, so the next round's measuring pass absorbs it
149///            one pass per round
150///     true : applied on the spot, so the next round measures in a pass of its own
151///            two passes per round
152/// ```
153///
154/// The two produce the same round polynomials, so one can be pinned against the other.
155///
156/// The choice is a const parameter, so a build that never asks for the two-pass route
157/// never compiles one.
158///
159/// This is the only seam between the two routes.
160#[allow(clippy::type_complexity)]
161#[tracing::instrument(name = "binary pcs fold rounds", skip_all)]
162pub(crate) fn fold_rounds_with<const BIND_EACH_ROUND: bool, F, EF, MT, MX, Ch>(
163    prover_data: BinaryPcsProverData<F, EF, MT>,
164    config: &BinaryPcsConfig,
165    mmcs: &MT,
166    round_mmcs: &MX,
167    transcript: &mut BinaryPcsProverTranscript<'_, F, EF, Ch>,
168) -> (
169    MT::ProverData<DenseMatrix<F>>,
170    SumcheckData<F, EF>,
171    Vec<RoundCommitment<EF, MX>>,
172    Point<EF>,
173    Vec<EF>,
174)
175where
176    F: TranscriptField + FoldAlphabet<EF>,
177    EF: ChallengeField<F> + ExtensionField<F> + FoldAlphabet<EF>,
178    MT: Mmcs<F>,
179    MX: Mmcs<EF>,
180    Ch: FieldChallenger<F>
181        + GrindingChallenger<Witness = F>
182        + CanSampleUniformBits<F>
183        + CanObserve<MX::Commitment>,
184{
185    let BinaryPcsProverData {
186        layout,
187        merkle_data,
188    } = prover_data;
189
190    let mut sumcheck_data = SumcheckData::default();
191
192    // Each challenge width uses its carryless-multiply representation for round arithmetic.
193    // The transcript carries tower elements in both cases.
194    let (mut sumcheck, mut randomness) = transcript.fold_batch(|challenger| {
195        layout.into_sumcheck_in::<<EF as ChallengeField<F>>::SumcheckRepr, _>(
196            &mut sumcheck_data,
197            0,
198            challenger,
199        )
200    });
201    assert_eq!(
202        randomness.num_variables(),
203        0,
204        "the commit phase runs at zero preprocessing depth, so the sumcheck consumes no head rounds"
205    );
206
207    assert_eq!(
208        mmcs.get_matrices(&merkle_data)[0].width,
209        1,
210        "zero preprocessing depth commits a width-1 codeword"
211    );
212
213    // Sumcheck messages/challenges remain sequential. Only batch boundaries materialize
214    // codewords and observe roots; the final batch is returned in the clear.
215    let num_batches = config.num_fold_batches();
216    let mut rounds: Vec<RoundCommitment<EF, MX>> = Vec::with_capacity(num_batches - 1);
217    let mut final_codeword = Vec::new();
218    for (batch, (start, arity)) in config.fold_batches().enumerate() {
219        let _batch_span = tracing::info_span!("fold batch", batch, start, arity).entered();
220        let mut challenges = Vec::with_capacity(arity);
221        for round in start..start + arity {
222            let challenge = tracing::debug_span!("sumcheck round", round).in_scope(|| {
223                // A sumcheck round seeds a sub-transcript of its own.
224                transcript.fold_batch(|challenger| {
225                    sumcheck.compute_sumcheck_polynomials(&mut sumcheck_data, challenger, 1, 0)
226                })
227            });
228            challenges.push(challenge.as_slice()[0]);
229            randomness.extend(&challenge);
230
231            // The reference route applies this round's binding now.
232            //
233            // The fused route instead lets the next round's measuring pass absorb it.
234            if BIND_EACH_ROUND {
235                sumcheck.settle();
236            }
237        }
238
239        // A batch's folded codeword is also the encoding of the message its rounds bound.
240        //
241        //     fold     one pass per round over the previous codeword, halving it each time,
242        //              so its cost is set by the codeword it reads, whatever the arity
243        //     encode   one transform of the folded codeword, a product per symbol per bit of
244        //              its length, so its cost halves with every round the batch folds
245        //
246        // Encoding is therefore the cheaper one only for a batch of enough rounds, and it needs
247        // the sumcheck to hold the bound message at the batch's end.
248        //
249        // Otherwise fold out of the previous batch's Merkle leaves, never out of a copy of them.
250        // The commitment scheme already owns every codeword it committed.
251        //
252        // The first batch reads the committed alphabet, every later one the challenge field.
253        // The two arms therefore differ in the type of the codeword they load.
254        let folded = tracing::info_span!("fold codeword").in_scope(|| {
255            let encoded = (arity >= MIN_ENCODED_ARITY)
256                .then(|| encode_bound_message(&mut sumcheck, config.log_inv_rate()))
257                .flatten();
258            encoded.unwrap_or_else(|| {
259                if batch == 0 {
260                    fold_codeword_batch(&mmcs.get_matrices(&merkle_data)[0].values, &challenges)
261                } else {
262                    let source = &rounds[batch - 1].merkle_data;
263                    fold_codeword_batch(&round_mmcs.get_matrices(source)[0].values, &challenges)
264                }
265            })
266        });
267        if batch + 1 < num_batches {
268            let (commitment, round_data) = tracing::info_span!("commit folded codeword")
269                .in_scope(|| round_mmcs.commit_matrix(RowMajorMatrix::new(folded, 1)));
270            transcript.oracle_commitment(commitment.clone());
271            rounds.push(RoundCommitment {
272                commitment,
273                merkle_data: round_data,
274            });
275        } else {
276            final_codeword = folded;
277        }
278    }
279
280    // A challenge the sumcheck still holds after the last round is never applied.
281    //
282    // The last batch's codeword, not the sumcheck tables, carries the folded message forward.
283    // The returned tuple holds no sumcheck state, so nothing downstream can read the tables.
284    // A final binding pass would only produce a table nobody looks at.
285    //
286    // A last codeword encoded from the bound message has applied it already, and holds none.
287    //
288    // A debug build applies a held one anyway, purely to check the claim against the pair it
289    // binds. That is the last held binding's only validation, in any profile.
290    #[cfg(debug_assertions)]
291    sumcheck.settle();
292
293    (
294        merkle_data,
295        sumcheck_data,
296        rounds,
297        randomness,
298        final_codeword,
299    )
300}
301
302/// Encodes the message the rounds played so far bound, when the sumcheck holds it.
303///
304/// Folding a codeword binds its message's lowest variable, so this is the codeword that folding
305/// the base codeword by every challenge played so far produces, symbol for symbol.
306///
307/// `None` when the sumcheck does not hold that message.
308fn encode_bound_message<F, EF>(
309    sumcheck: &mut SuffixResidualProver<F, EF, <EF as ChallengeField<F>>::SumcheckRepr>,
310    log_inv_rate: usize,
311) -> Option<Vec<EF>>
312where
313    F: Field,
314    EF: ChallengeField<F> + ExtensionField<F>,
315{
316    let column = sumcheck.bound_column()?;
317
318    // The tail past the message stays zero, which is the padding the encoding reads it as.
319    let mut message = EF::zero_vec(column.len() << log_inv_rate);
320    EF::from_sumcheck_repr(column, &mut message[..column.len()]);
321    let codeword = tracing::info_span!("encode bound message").in_scope(|| {
322        EF::Encoder::default().encode_batch_padded(RowMajorMatrix::new(message, 1), log_inv_rate)
323    });
324    Some(codeword.values)
325}
326
327/// The query phase's prover-side output.
328/// Every opening the verifier's path check needs, plus the grinding witness.
329pub(crate) struct QueryProofs<F: Field, EF: Field, MT: Mmcs<F>, MX: Mmcs<EF>> {
330    /// Every symbol of each queried base coset, one width-1 row per symbol.
331    /// Cosets follow sampled query order; symbols inside a coset are ascending.
332    pub base_opened_values: Vec<Vec<F>>,
333    /// Multiproof for the base commitment's queried rows.
334    pub base_multi_proof: MT::MultiProof,
335    /// One entry per intermediate fold batch, excluding the final batch.
336    pub rounds: Vec<RoundProof<EF, MX>>,
337    /// Witness for the single grind before the query phase.
338    pub pow_witness: F,
339}
340
341/// Runs the query phase: binds the full final codeword, grinds the single proof-of-work
342/// witness, samples query indices from the base codeword's domain, then opens the base
343/// commitment and every intermediate fold-batch commitment at all coset indices each query needs.
344///
345/// `rounds` is every round commitment the fold phase produced.
346/// That is one per fold batch except the last, whose codeword is never committed.
347///
348/// The last batch travels in the clear as the proof's final codeword.
349/// A Merkle path for it would only repeat what the verifier can already read.
350#[tracing::instrument(name = "binary pcs open queries", skip_all)]
351pub(crate) fn open_queries<F, EF, MT, MX, Ch>(
352    config: &BinaryPcsConfig,
353    mmcs: &MT,
354    round_mmcs: &MX,
355    transcript: &mut BinaryPcsProverTranscript<'_, F, EF, Ch>,
356    base_merkle_data: &MT::ProverData<DenseMatrix<F>>,
357    rounds: &[RoundCommitment<EF, MX>],
358    final_codeword: &[EF],
359) -> QueryProofs<F, EF, MT, MX>
360where
361    F: TranscriptField,
362    EF: ExtensionField<F>,
363    MT: Mmcs<F>,
364    MX: Mmcs<EF>,
365    Ch: FieldChallenger<F> + GrindingChallenger<Witness = F> + CanSampleUniformBits<F>,
366{
367    assert_eq!(
368        rounds.len(),
369        config.num_fold_batches() - 1,
370        "rounds is the caller's own fold-phase output, never proof-supplied data"
371    );
372
373    // The last word is sent in the clear, not committed by a Merkle root. Bind every
374    // symbol before grinding or sampling so it cannot be chosen after seeing queries.
375    transcript.final_codeword(final_codeword);
376    let pow_witness = transcript.query_pow();
377
378    // The draw indexes pairs, so each position is lifted back to a coset start.
379    let shift = config.log_folding_factor() - 1;
380    let indices: Vec<usize> = transcript
381        .query_pairs()
382        .into_iter()
383        .map(|pair| pair << shift)
384        .collect();
385    let base_indices = flat_coset_indices(&indices, 0, config.log_folding_factor());
386    let (base_values, base_multi_proof) = mmcs.open_multi_batch(&base_indices, base_merkle_data);
387    let base_opened_values = single_matrix_rows(base_values);
388
389    let opened_rounds = rounds
390        .iter()
391        .zip(config.fold_batches().skip(1))
392        .map(|(round, (start, arity))| {
393            let round_indices = flat_coset_indices(&indices, start, arity);
394            let (values, multi_proof) =
395                round_mmcs.open_multi_batch(&round_indices, &round.merkle_data);
396            RoundProof {
397                commitment: round.commitment.clone(),
398                opened_values: single_matrix_rows(values),
399                multi_proof,
400            }
401        })
402        .collect();
403
404    QueryProofs {
405        base_opened_values,
406        base_multi_proof,
407        rounds: opened_rounds,
408        pow_witness,
409    }
410}
411
412/// Strips the always-one-matrix middle index from a batched-opening result.
413/// The count is asserted rather than assumed.
414fn single_matrix_rows<T>(values: Vec<Vec<Vec<T>>>) -> Vec<Vec<T>> {
415    values
416        .into_iter()
417        .map(|mut per_matrix| {
418            assert_eq!(per_matrix.len(), 1, "each round commits exactly one matrix");
419            per_matrix.swap_remove(0)
420        })
421        .collect()
422}
423
424#[cfg(test)]
425mod tests {
426    use alloc::vec::Vec;
427    use alloc::{format, vec};
428
429    use p3_binary_dft::{AdditiveRsEncoder, EncodableLevel, NaiveAdditiveNtt};
430    use p3_binary_field::{BinaryField8, BinaryField64, BinaryField128};
431    use p3_challenger::fs::TranscriptField;
432    use p3_challenger::{CanObserve, CanSampleUniformBits, FieldChallenger, GrindingChallenger};
433    use p3_commit::Mmcs;
434    use p3_field::ExtensionField;
435    use p3_matrix::dense::RowMajorMatrix;
436    use p3_multilinear_util::point::Point;
437    use p3_multilinear_util::poly::Poly;
438    use p3_sumcheck::layout::{Layout, PrefixProver, SuffixProver, Table};
439    use p3_sumcheck::{OpeningBatch, SumcheckData};
440    use rand::SeedableRng;
441    use rand::distr::{Distribution, StandardUniform};
442    use rand::rngs::SmallRng;
443
444    use super::{MIN_ENCODED_ARITY, commit, fold_rounds_with};
445    use crate::fold::{ChallengeField, FoldAlphabet, fold_codeword, fold_codeword_batch};
446    use crate::params::{BinaryPcsConfig, BinaryPcsParams};
447    use crate::test_util::{
448        LevelChallenger, LevelMmcs, challenger, level_challenger, level_mmcs, mmcs,
449        narrow_challenger, narrow_mmcs,
450    };
451    use crate::transcript::{BinaryPcsProverTranscript, BinaryPcsShape};
452
453    type F = BinaryField128;
454
455    const NUM_VARIABLES: usize = 8;
456    const LOG_INV_RATE: usize = 2;
457
458    /// After every round, the codeword and the sumcheck describe the same polynomial: the
459    /// final codeword's symbols all equal the constant the sumcheck folds to.
460    #[test]
461    fn the_codeword_and_the_sumcheck_stay_in_lockstep() {
462        let mut rng = SmallRng::seed_from_u64(21);
463        let table = Table::rand(&mut rng, 1, NUM_VARIABLES);
464        let witness = SuffixProver::<F, F>::new_witness(vec![table], 0);
465
466        let mut ch = challenger();
467        let (mut layout, _root, data) = SuffixProver::<F, F>::commit(
468            &AdditiveRsEncoder::<F, NaiveAdditiveNtt<F>>::default(),
469            &mmcs(),
470            witness,
471            0,
472            LOG_INV_RATE,
473        );
474        let m = mmcs().get_matrices(&data)[0].clone();
475        assert_eq!(m.width, 1);
476        let mut codeword = m.values;
477
478        let _virtual_eval = layout.add_virtual_eval(&mut ch);
479        let mut sc = SumcheckData::<F, F>::default();
480        let (mut prover, head) = layout.into_sumcheck(&mut sc, 0, &mut ch);
481        assert_eq!(head.num_variables(), 0);
482
483        for _ in 0..prover.num_variables() {
484            let beta = prover.compute_sumcheck_polynomials(&mut sc, &mut ch, 1, 0, None);
485            codeword = fold_codeword(&codeword, beta.as_slice()[0]);
486        }
487
488        let final_value = prover.evals().as_constant().expect("fully folded");
489        assert_eq!(codeword.len(), 1 << LOG_INV_RATE);
490        assert!(codeword.iter().all(|&v| v == final_value));
491    }
492
493    /// The lockstep property is specific to suffix binding's evaluation-basis order.
494    /// Driving the identical procedure through prefix binding breaks it.
495    ///
496    /// The final codeword's symbols no longer all equal the constant the sumcheck reaches.
497    ///
498    /// This is why the layout is a fixed type rather than a caller-supplied parameter.
499    /// The two orders are not interchangeable.
500    ///
501    /// The difference is a property of the fold's pair-merging arithmetic, not a tuning knob.
502    #[test]
503    fn prefix_layout_does_not_stay_in_lockstep() {
504        let mut rng = SmallRng::seed_from_u64(21);
505        let table = Table::rand(&mut rng, 1, NUM_VARIABLES);
506        let witness = PrefixProver::<F, F>::new_witness(vec![table], 0);
507
508        let mut ch = challenger();
509        let (mut layout, _root, data) = PrefixProver::<F, F>::commit(
510            &AdditiveRsEncoder::<F, NaiveAdditiveNtt<F>>::default(),
511            &mmcs(),
512            witness,
513            0,
514            LOG_INV_RATE,
515        );
516        let m = mmcs().get_matrices(&data)[0].clone();
517        assert_eq!(m.width, 1);
518        let mut codeword = m.values;
519
520        let _virtual_eval = layout.add_virtual_eval(&mut ch);
521        let mut sc = SumcheckData::<F, F>::default();
522        let (mut prover, head) = layout.into_sumcheck(&mut sc, 0, &mut ch);
523        assert_eq!(head.num_variables(), 0);
524
525        for _ in 0..prover.num_variables() {
526            let beta = prover.compute_sumcheck_polynomials(&mut sc, &mut ch, 1, 0, None);
527            codeword = fold_codeword(&codeword, beta.as_slice()[0]);
528        }
529
530        let final_value = prover.evals().as_constant().expect("fully folded");
531        assert!(!codeword.iter().all(|&v| v == final_value));
532    }
533
534    /// Drives the crate's own commit and fold phases end to end, against an independent oracle.
535    ///
536    /// The oracle folds the original message variable by variable.
537    /// It consumes the randomness the fold phase returns, in the order returned.
538    ///
539    /// The constant it reaches must be the value every final codeword symbol carries.
540    #[test]
541    fn commit_and_fold_rounds_match_an_independent_message_fold() {
542        let mut rng = SmallRng::seed_from_u64(0xC0FFEE);
543        let poly = Poly::<F>::rand(&mut rng, NUM_VARIABLES);
544        let mut message = poly.clone();
545        let table = Table::new(RowMajorMatrix::new(poly.into_evals(), 1 << NUM_VARIABLES));
546        let witness = SuffixProver::<F, F>::new_witness(vec![table], 0);
547
548        let params = BinaryPcsParams {
549            log_inv_rate: LOG_INV_RATE,
550            pow_bits: 4,
551            security_level: 40,
552        };
553        let config = BinaryPcsConfig::try_new::<F, F>(NUM_VARIABLES, params).unwrap();
554        let encoder = AdditiveRsEncoder::<F, NaiveAdditiveNtt<F>>::default();
555        let mmcs_instance = mmcs();
556
557        let mut ch = challenger();
558        let (_commitment, prover_data) =
559            commit::<F, F, _, _>(&config, &encoder, &mmcs_instance, witness);
560        let mut transcript = BinaryPcsProverTranscript::new(&mut ch, BinaryPcsShape::new(&config));
561        let (_merkle_data, _sumcheck_data, rounds, randomness, final_codeword) =
562            fold_rounds_with::<false, F, F, _, _, _>(
563                prover_data,
564                &config,
565                &mmcs_instance,
566                &mmcs_instance,
567                &mut transcript,
568            );
569        // Only the fold phase runs here, so the driver is released rather than closed.
570        transcript.abort();
571
572        assert_eq!(rounds.len(), config.num_fold_rounds() - 1);
573        assert_eq!(randomness.num_variables(), NUM_VARIABLES);
574        assert_eq!(final_codeword.len(), 1 << LOG_INV_RATE);
575
576        for &beta in randomness.as_slice() {
577            message.fix_suffix_var_mut(beta);
578        }
579        let expected = message.as_constant().expect("fully folded");
580        assert!(final_codeword.iter().all(|&v| v == expected));
581    }
582
583    /// The same independent-oracle check, with the columns committed over a narrower alphabet.
584    ///
585    /// The base codeword holds 64-bit symbols and every folded codeword 128-bit ones.
586    /// The first fold of the schedule is the one that crosses between them.
587    #[test]
588    fn a_narrow_alphabet_folds_to_the_same_constant_as_its_own_message() {
589        let mut rng = SmallRng::seed_from_u64(0xC0FFEE);
590        let table = Table::rand(&mut rng, 1, NUM_VARIABLES);
591
592        // The message as a polynomial over the challenge field, cell for cell.
593        let embedded = Poly::new(
594            table
595                .poly(0)
596                .as_slice()
597                .iter()
598                .map(|&value| F::from(value))
599                .collect::<alloc::vec::Vec<_>>(),
600        );
601        let witness = SuffixProver::<BinaryField64, F>::new_witness(vec![table], 0);
602
603        let params = BinaryPcsParams {
604            log_inv_rate: LOG_INV_RATE,
605            pow_bits: 4,
606            security_level: 40,
607        };
608        let config = BinaryPcsConfig::try_new::<BinaryField64, F>(NUM_VARIABLES, params).unwrap();
609        let encoder = <BinaryField64 as EncodableLevel>::Encoder::default();
610
611        // The sponge speaks the committed alphabet, so a narrow run needs its own challenger.
612        let mut ch = narrow_challenger();
613        let (_commitment, prover_data) =
614            commit::<BinaryField64, F, _, _>(&config, &encoder, &narrow_mmcs(), witness);
615        let mut transcript = BinaryPcsProverTranscript::new(&mut ch, BinaryPcsShape::new(&config));
616        let (_merkle_data, _sumcheck_data, _rounds, randomness, final_codeword) =
617            fold_rounds_with::<false, BinaryField64, F, _, _, _>(
618                prover_data,
619                &config,
620                &narrow_mmcs(),
621                &mmcs(),
622                &mut transcript,
623            );
624        transcript.abort();
625
626        // Binding the embedded message in the same order must reach the same constant.
627        let mut message = embedded;
628        for &beta in randomness.as_slice() {
629            message.fix_suffix_var_mut(beta);
630        }
631        let expected = message.as_constant().expect("fully folded");
632        assert_eq!(final_codeword.len(), 1 << LOG_INV_RATE);
633        assert!(final_codeword.iter().all(|&v| v == expected));
634    }
635
636    /// Invariant: the fused route and the reference route agree on every fold round.
637    ///
638    ///     round messages       round commitments      folding randomness
639    ///     final codeword       transcript state left behind
640    ///
641    /// The fold rounds are the only place the two routes differ.
642    ///
643    /// Everything they produce is a deterministic function of the transcript.
644    ///
645    /// So the comparison is reproducible under threaded execution.
646    ///
647    /// The query phase's grinding search is not, which is why it stays out of scope here.
648    #[test]
649    fn fold_rounds_agree_with_binding_each_round() {
650        // Invariant: how many passes compute a round polynomial never changes its value.
651        //
652        //     fused    : round r measures and applies round r-1's binding in one pass
653        //     reference: round r measures, then a second pass applies round r's binding
654        //
655        // Fixture shapes: driven twice from identically seeded challengers.
656        //
657        //     8  variables: every fused pass takes the serial branch
658        //     15 variables: the early rounds take the threaded branch, where a fused pass
659        //                   writing its own input in place would race another task's reads
660        //
661        // Both folding factors, because they exercise different held-challenge paths:
662        //
663        //     1:  every round is its own batch    ->  every held challenge crosses a batch
664        //     3:  three rounds share a batch      ->  one also crosses a round boundary
665        //
666        // That inner boundary is where the reference route settles its binding.
667        for (num_variables, log_folding_factor) in [(8usize, 1usize), (8, 3), (15, 1), (15, 3)] {
668            let params = BinaryPcsParams {
669                log_inv_rate: LOG_INV_RATE,
670                pow_bits: 4,
671                security_level: 40,
672            };
673            let config = BinaryPcsConfig::try_new_with_folding::<F, F>(
674                num_variables,
675                params,
676                log_folding_factor,
677            )
678            .unwrap();
679
680            // The shipped encoder, not the naive one.
681            //
682            // The larger arity is out of reach of a quadratic transform in a debug build.
683            let encoder = AdditiveRsEncoder::<F>::default();
684            let mmcs_instance = mmcs();
685
686            let mut rng = SmallRng::seed_from_u64(0xF0FA + num_variables as u64);
687            let table = Table::rand(&mut rng, 1, num_variables);
688            let shape = format!("{num_variables} variables, arity {log_folding_factor}");
689
690            // Fused route.
691            let mut got_ch = challenger();
692            let (_commitment, got_data) = commit::<F, F, _, _>(
693                &config,
694                &encoder,
695                &mmcs_instance,
696                SuffixProver::<F, F>::new_witness(vec![table.clone()], 0),
697            );
698            let mut got_t =
699                BinaryPcsProverTranscript::new(&mut got_ch, BinaryPcsShape::new(&config));
700            let (_, got_sumcheck, got_rounds, got_randomness, got_final) =
701                fold_rounds_with::<false, F, F, _, _, _>(
702                    got_data,
703                    &config,
704                    &mmcs_instance,
705                    &mmcs_instance,
706                    &mut got_t,
707                );
708            // Only the fold phase runs here, so the driver is released rather than closed.
709            got_t.abort();
710            drop(got_t);
711
712            // Reference route, from an identically seeded challenger.
713            let mut want_ch = challenger();
714            let (_commitment, want_data) = commit::<F, F, _, _>(
715                &config,
716                &encoder,
717                &mmcs_instance,
718                SuffixProver::<F, F>::new_witness(vec![table], 0),
719            );
720            let mut want_t =
721                BinaryPcsProverTranscript::new(&mut want_ch, BinaryPcsShape::new(&config));
722            let (_, want_sumcheck, want_rounds, want_randomness, want_final) =
723                fold_rounds_with::<true, F, F, _, _, _>(
724                    want_data,
725                    &config,
726                    &mmcs_instance,
727                    &mmcs_instance,
728                    &mut want_t,
729                );
730            want_t.abort();
731            drop(want_t);
732
733            // Round by round first, so a discrepancy is localised to the round that drifted.
734            assert_eq!(
735                got_sumcheck.num_rounds(),
736                want_sumcheck.num_rounds(),
737                "{shape}: round count"
738            );
739            for (round, (got_msg, want_msg)) in got_sumcheck
740                .polynomial_evaluations()
741                .iter()
742                .zip(want_sumcheck.polynomial_evaluations())
743                .enumerate()
744            {
745                assert_eq!(got_msg, want_msg, "{shape}: round {round} message");
746            }
747
748            // The challenges follow the messages, and the codeword folds follow the challenges.
749            assert_eq!(
750                got_randomness.as_slice(),
751                want_randomness.as_slice(),
752                "{shape}: folding randomness"
753            );
754            assert_eq!(got_final, want_final, "{shape}: final codeword");
755            for (round, (got_round, want_round)) in got_rounds.iter().zip(&want_rounds).enumerate()
756            {
757                assert_eq!(
758                    got_round.commitment, want_round.commitment,
759                    "{shape}: round {round} commitment"
760                );
761            }
762
763            // The transcripts must also be left in the same state.
764            //
765            // Equal outputs do not show that on their own.
766            //
767            // Two challengers that diverged could still have produced the same outputs.
768            assert_eq!(
769                got_ch.sample_algebra_element::<F>(),
770                want_ch.sample_algebra_element::<F>(),
771                "{shape}: transcript state"
772            );
773        }
774    }
775
776    /// Runs the fold phase over one column under one direct claim, and compares every codeword it
777    /// produces with folding the base codeword by the batch challenges it returns.
778    ///
779    /// The claim shape takes the banked residual route, which holds the bound message at its
780    /// stage boundaries. Whichever of encoding and folding the phase picks per batch, every
781    /// committed codeword and the final one must be the fold.
782    fn check_fold_phase_codewords<const BIND_EACH_ROUND: bool, A, EF>(
783        num_variables: usize,
784        log_folding_factor: usize,
785        log_inv_rate: usize,
786        seed: u64,
787    ) where
788        A: EncodableLevel + TranscriptField + FoldAlphabet<EF>,
789        EF: ChallengeField<A> + ExtensionField<A> + FoldAlphabet<EF>,
790        LevelMmcs<A>: Mmcs<A>,
791        LevelMmcs<EF>: Mmcs<EF>,
792        LevelChallenger<A>: FieldChallenger<A>
793            + GrindingChallenger<Witness = A>
794            + CanSampleUniformBits<A>
795            + CanObserve<<LevelMmcs<EF> as Mmcs<EF>>::Commitment>
796            + Clone,
797        StandardUniform: Distribution<A> + Distribution<EF>,
798    {
799        let params = BinaryPcsParams {
800            log_inv_rate,
801            pow_bits: 0,
802            security_level: 40,
803        };
804        let config = BinaryPcsConfig::try_new_with_folding::<A, EF>(
805            num_variables,
806            params,
807            log_folding_factor,
808        )
809        .unwrap();
810        let (base_mmcs, round_mmcs) = (level_mmcs::<A>(), level_mmcs::<EF>());
811        let shape = format!(
812            "bits {}/{}, {num_variables} variables, arity {log_folding_factor}, rate {log_inv_rate}",
813            A::bits(),
814            EF::bits()
815        );
816
817        let mut rng = SmallRng::seed_from_u64(seed);
818        let table = Table::<A>::rand(&mut rng, 1, num_variables);
819        let (_commitment, mut prover_data) = commit::<A, EF, _, _>(
820            &config,
821            &A::Encoder::default(),
822            &base_mmcs,
823            SuffixProver::<A, EF>::new_witness(vec![table], 0),
824        );
825        let point = Point::<EF>::rand(&mut rng, num_variables);
826        prover_data
827            .layout
828            .record_opening(0, &OpeningBatch::new(vec![0], Vec::new()), &point);
829
830        // The route hands out a bound column at some round, which only the banked route does.
831        // A batch folding enough rounds to be encoded ends on a round that holds one, so its
832        // codeword below comes from encoding rather than from folding.
833        let mut probe_ch = level_challenger::<A>();
834        let mut probe_sc = SumcheckData::default();
835        let (mut probe, _) = prover_data
836            .layout
837            .clone()
838            .into_sumcheck_in::<EF::SumcheckRepr, _>(&mut probe_sc, 0, &mut probe_ch);
839        let mut holds_bound_column = false;
840        for (batch, (_, arity)) in config.fold_batches().enumerate() {
841            for _ in 0..arity {
842                let _ = probe.compute_sumcheck_polynomials(&mut probe_sc, &mut probe_ch, 1, 0);
843                holds_bound_column |= probe.bound_column().is_some();
844            }
845            if arity >= MIN_ENCODED_ARITY {
846                assert!(
847                    probe.bound_column().is_some(),
848                    "{shape}: batch {batch} encodes its codeword"
849                );
850            }
851        }
852        assert!(holds_bound_column, "{shape}: banked route");
853
854        let mut ch = level_challenger::<A>();
855        let mut transcript = BinaryPcsProverTranscript::new(&mut ch, BinaryPcsShape::new(&config));
856        let (merkle_data, _sumcheck_data, rounds, randomness, final_codeword) =
857            fold_rounds_with::<BIND_EACH_ROUND, A, EF, _, _, _>(
858                prover_data,
859                &config,
860                &base_mmcs,
861                &round_mmcs,
862                &mut transcript,
863            );
864        transcript.abort();
865
866        // Fold batch by batch from the base codeword, the reference every route must match.
867        let base = base_mmcs.get_matrices(&merkle_data)[0].values.clone();
868        let mut expected: Vec<EF> = Vec::new();
869        for (batch, (start, arity)) in config.fold_batches().enumerate() {
870            let challenges = &randomness.as_slice()[start..start + arity];
871            expected = if batch == 0 {
872                fold_codeword_batch(&base, challenges)
873            } else {
874                fold_codeword_batch(&expected, challenges)
875            };
876            let got = rounds.get(batch).map_or_else(
877                || final_codeword.clone(),
878                |round| {
879                    round_mmcs.get_matrices(&round.merkle_data)[0]
880                        .values
881                        .clone()
882                },
883            );
884            assert_eq!(got, expected, "{shape}: batch {batch}");
885        }
886    }
887
888    /// Invariant: whichever of encoding the bound message and folding the previous codeword a
889    /// batch takes, its codeword is the fold of the base codeword by every challenge so far.
890    ///
891    /// The prover commits each batch's codeword from whichever of the two it picks, and the
892    /// verifier folds queried cosets of the committed codewords, so the two must never differ.
893    ///
894    /// Fixture state: every folding factor from one round per batch to four, both fold routes,
895    /// and every challenge field over a committed alphabet as wide as itself and a narrow one.
896    ///
897    ///     8-bit alphabet        a 2^8 base codeword, the whole domain it spans
898    ///     wider alphabets       several stages, ending on a short one, at two rates
899    #[test]
900    fn every_fold_phase_codeword_is_the_fold_of_the_base_codeword() {
901        for (seed, log_folding_factor) in (0..).zip(1..=4) {
902            for (num_variables, log_inv_rate) in [(13, 1), (13, 2)] {
903                check_fold_phase_codewords::<false, F, F>(
904                    num_variables,
905                    log_folding_factor,
906                    log_inv_rate,
907                    seed,
908                );
909                check_fold_phase_codewords::<false, BinaryField64, F>(
910                    num_variables,
911                    log_folding_factor,
912                    log_inv_rate,
913                    seed,
914                );
915                check_fold_phase_codewords::<false, BinaryField64, BinaryField64>(
916                    num_variables,
917                    log_folding_factor,
918                    log_inv_rate,
919                    seed,
920                );
921            }
922            check_fold_phase_codewords::<false, BinaryField8, BinaryField64>(
923                6,
924                log_folding_factor,
925                2,
926                seed,
927            );
928            check_fold_phase_codewords::<false, BinaryField8, F>(6, log_folding_factor, 2, seed);
929            check_fold_phase_codewords::<true, F, F>(13, log_folding_factor, 1, seed);
930            check_fold_phase_codewords::<true, BinaryField64, BinaryField64>(
931                13,
932                log_folding_factor,
933                1,
934                seed,
935            );
936        }
937        for (seed, num_variables) in [(4, 12), (5, 16)] {
938            check_fold_phase_codewords::<false, F, F>(num_variables, 4, 1, seed);
939        }
940        check_fold_phase_codewords::<false, F, F>(16, 8, 1, 6);
941    }
942
943    /// Committing rejects an arity mismatch in every build profile, not only a debug one.
944    ///
945    /// Falling through would surface later, far from the precondition that failed.
946    /// It would read as a final-codeword length mismatch, or a panic inside the fold.
947    #[test]
948    #[should_panic(expected = "witness arity must match the config it is committed against")]
949    fn commit_rejects_a_witness_arity_mismatch() {
950        let params = BinaryPcsParams {
951            log_inv_rate: LOG_INV_RATE,
952            pow_bits: 4,
953            security_level: 40,
954        };
955        let config = BinaryPcsConfig::try_new::<F, F>(NUM_VARIABLES, params).unwrap();
956        let encoder = AdditiveRsEncoder::<F, NaiveAdditiveNtt<F>>::default();
957        let mmcs_instance = mmcs();
958        let mut rng = SmallRng::seed_from_u64(0);
959        let table = Table::rand(&mut rng, 1, NUM_VARIABLES - 1);
960        let witness = SuffixProver::<F, F>::new_witness(vec![table], 0);
961
962        let _ = commit::<F, F, _, _>(&config, &encoder, &mmcs_instance, witness);
963    }
964}