Skip to main content

p3_sumcheck/layout/prover/
mod.rs

1//! Provers for the stacked layout, specialised per residual-sumcheck binding mode.
2//!
3//! # Modules
4//!
5//! - Prefix prover: SIMD-packed first round.
6//! - Suffix prover: SVO-accumulator preprocessing.
7
8mod prefix;
9mod suffix;
10
11use alloc::vec::Vec;
12
13use p3_challenger::{CanObserve, FieldChallenger, GrindingChallenger};
14use p3_commit::Mmcs;
15use p3_dft::TwoAdicSubgroupDft;
16use p3_field::{ExtensionField, TwoAdicField};
17use p3_matrix::dense::DenseMatrix;
18use p3_multilinear_util::point::Point;
19pub use prefix::PrefixProver;
20pub use suffix::SuffixProver;
21
22use crate::SumcheckData;
23use crate::layout::{LayoutStrategy, Table, Witness};
24use crate::strategy::{SumcheckProver, VariableOrder};
25
26/// Stacked-sumcheck prover layout
27pub trait Layout<F: TwoAdicField, EF: ExtensionField<F>>: Sized {
28    /// Builds this layout from a committed witness.
29    fn from_witness(witness: Witness<F>) -> Self;
30
31    /// Builds a witness structure for this layout from source tables.
32    fn new_witness(tables: Vec<Table<F>>, folding: usize) -> Witness<F>;
33
34    /// Commits to the witness and returns the layout.
35    ///
36    /// # Arguments
37    ///
38    /// - `dft`                    — base-field DFT used to encode the codeword.
39    /// - `mmcs`                   — Merkle commitment scheme over the base field.
40    /// - `challenger`             — Fiat–Shamir transcript; absorbs the Merkle root.
41    /// - `witness`                — stacked committed polynomial plus its tables.
42    /// - `folding`                — folding factor consumed by the first WHIR round.
43    /// - `starting_log_inv_rate`  — initial log-inverse rate of the RS code.
44    fn commit<Dft, MT, Challenger>(
45        dft: &Dft,
46        mmcs: &MT,
47        challenger: &mut Challenger,
48        witness: Witness<F>,
49        folding: usize,
50        starting_log_inv_rate: usize,
51    ) -> (Self, MT::Commitment, MT::ProverData<DenseMatrix<F>>)
52    where
53        Dft: TwoAdicSubgroupDft<F>,
54        MT: Mmcs<F>,
55        Challenger: CanObserve<MT::Commitment>;
56
57    /// Returns the total number of concrete openings recorded so far.
58    fn num_claims(&self) -> usize;
59
60    /// Returns the verifier strategy required to replay this committed layout.
61    fn strategy() -> LayoutStrategy;
62
63    /// Returns the variable order.
64    fn variable_order() -> VariableOrder {
65        Self::strategy().variable_order
66    }
67
68    /// Returns the number of variables of first round
69    fn folding(&self) -> usize;
70
71    /// Returns the number of variables of the stacked polynomial.
72    fn num_variables(&self) -> usize;
73
74    /// Returns the number of variables of table `id`.
75    fn num_variables_table(&self, id: usize) -> usize;
76
77    /// Records opening claims for the selected columns of `table_idx`.
78    fn eval<Ch>(&mut self, table_idx: usize, polys: &[usize], challenger: &mut Ch) -> Vec<EF>
79    where
80        Ch: FieldChallenger<F> + GrindingChallenger<Witness = F>;
81
82    /// Samples a virtual evaluation on the full stacked polynomial.
83    fn add_virtual_eval<Ch>(&mut self, challenger: &mut Ch) -> EF
84    where
85        Ch: FieldChallenger<F> + GrindingChallenger<Witness = F>;
86
87    /// Processes initial rounds of sumcheck and returns the residual sumcheck prover.
88    ///
89    /// # Returns
90    ///
91    /// - Residual sumcheck prover over the unpacked product polynomial.
92    /// - Folding challenges sampled during preprocessing.
93    fn into_sumcheck<Ch>(
94        self,
95        sumcheck_data: &mut SumcheckData<F, EF>,
96        pow_bits: usize,
97        challenger: &mut Ch,
98    ) -> (SumcheckProver<F, EF>, Point<EF>)
99    where
100        Ch: FieldChallenger<F> + GrindingChallenger<Witness = F>;
101}
102
103#[cfg(test)]
104pub(super) mod test_utils {
105    //! Shared fixtures and end-to-end roundtrip runners used by both mode tests.
106    //!
107    //! - Fixed two-table witness reused by every scenario.
108    //! - Per-mode runners that drive prover and verifier side-by-side.
109    //! - Proptest strategy for random opening schedules.
110
111    use alloc::vec;
112    use alloc::vec::Vec;
113
114    use p3_challenger::FieldChallenger;
115    use p3_field::PrimeCharacteristicRing;
116    use p3_multilinear_util::point::Point;
117    use p3_multilinear_util::poly::Poly;
118    use proptest::prelude::*;
119    use rand::SeedableRng;
120    use rand::rngs::SmallRng;
121
122    use crate::SumcheckData;
123    use crate::layout::{Layout, LayoutStrategy, Table, TableShape, Verifier, Witness};
124    use crate::strategy::VariableOrder;
125    use crate::tests::*;
126
127    /// Preprocessing rounds each end-to-end test consumes on both sides.
128    pub(crate) const FOLDING: usize = 4;
129    /// Proof-of-work difficulty for the intermediate sumcheck phase.
130    pub(crate) const POW_BITS: usize = 10;
131    /// STIR-style constraint points sampled for the intermediate round.
132    pub(crate) const ROUND_EQ_POINTS: usize = 10;
133    /// STIR-style selector points sampled for the intermediate round.
134    pub(crate) const ROUND_SEL_POINTS: usize = 100;
135
136    /// Opening schedule with columns in ascending order inside each claim.
137    pub(crate) const ASCENDING_POLYS: &[(usize, &[usize])] =
138        &[(0, &[0, 1]), (0, &[0]), (1, &[0, 1]), (1, &[1])];
139
140    /// Opening schedule with non-ascending columns in the first claim.
141    ///
142    /// Targets the alpha / partial-eval misalignment that used to bite suffix mode.
143    pub(crate) const NON_ASCENDING_POLYS: &[(usize, &[usize])] =
144        &[(0, &[1, 0]), (0, &[0]), (1, &[0, 1]), (1, &[1])];
145
146    /// Builds the fixed two-table witness shared by every roundtrip test.
147    ///
148    /// # Layout
149    ///
150    /// - Table 0: arity 9, two columns.
151    /// - Table 1: arity 10, two columns.
152    pub(crate) fn build_tables() -> Vec<Table<F>> {
153        let mut rng = SmallRng::seed_from_u64(1);
154        // Table at index 1 in the insertion order: arity 10, two columns.
155        let a0 = Poly::<F>::rand(&mut rng, 10);
156        let a1 = Poly::<F>::rand(&mut rng, 10);
157        // Table at index 0 in the insertion order: arity 9, two columns.
158        let b0 = Poly::<F>::rand(&mut rng, 9);
159        let b1 = Poly::<F>::rand(&mut rng, 9);
160        vec![Table::new(vec![b0, b1]), Table::new(vec![a0, a1])]
161    }
162
163    /// Returns the per-table shape used by the verifier side.
164    pub(crate) fn table_shapes() -> Vec<TableShape> {
165        vec![TableShape::new(9, 2), TableShape::new(10, 2)]
166    }
167
168    /// Proptest strategy: random opening schedules over the fixed witness.
169    ///
170    /// # Shape
171    ///
172    /// - 1..=6 calls in total.
173    /// - Each call picks a random table index (0 or 1) and a non-empty
174    ///   subset of its 2 columns, possibly in any order.
175    pub(crate) fn arb_opening_schedule() -> impl Strategy<Value = Vec<(usize, Vec<usize>)>> {
176        // One call: (table_idx in {0, 1}, random non-empty permutation of [0, 1]).
177        let one_call =
178            (0usize..2, prop::collection::vec(0usize..2, 1..=2)).prop_map(|(table_idx, polys)| {
179                // Deduplicate while preserving first-seen order so the opening
180                // list stays valid (columns open at most once per claim).
181                let mut seen = [false; 2];
182                let dedup: Vec<usize> = polys
183                    .into_iter()
184                    .filter(|&p| {
185                        let first = !seen[p];
186                        seen[p] = true;
187                        first
188                    })
189                    .collect();
190                (table_idx, dedup)
191            });
192        prop::collection::vec(one_call, 1..=6)
193    }
194
195    /// Runs the verifier side and the final batched-sum check.
196    ///
197    /// Mode-agnostic: the binding direction is passed in as a runtime tag used
198    /// only to evaluate the batched-constraint polynomial. Every caller-provided
199    /// value comes straight from the prover side.
200    #[allow(clippy::too_many_arguments)]
201    pub(crate) fn verify_roundtrip(
202        strategy: LayoutStrategy,
203        shapes: &[TableShape],
204        stacked_num_variables: usize,
205        opening_claims: Vec<(usize, Vec<usize>, Vec<EF>)>,
206        virtual_eval: EF,
207        proof0: &SumcheckData<F, EF>,
208        proof1: &SumcheckData<F, EF>,
209        proof2: &SumcheckData<F, EF>,
210        intermediate_evals: &[EF],
211        expected_randomness: &Point<EF>,
212        final_folded_value: EF,
213    ) {
214        // Fresh challenger: verifier must stay in lockstep with the prover transcript.
215        let mut verifier_challenger = challenger();
216        let mut verifier: Verifier<F, EF> = Verifier::new(shapes, strategy);
217
218        // Re-sample the same opening points and record the claimed evaluations.
219        // `add_claim` samples the point + absorbs the evals internally, mirroring
220        // the prover's `eval`.
221        for (table_idx, polys, evals) in opening_claims {
222            verifier.add_claim(table_idx, &polys, &evals, &mut verifier_challenger);
223        }
224
225        // Re-sample the virtual claim too; mirrors the prover's `add_virtual_eval`.
226        verifier.add_virtual_eval(virtual_eval, &mut verifier_challenger);
227
228        // Sample the batching challenge, build the initial constraint, seed the running sum.
229        let alpha = verifier_challenger.sample_algebra_element();
230        let initial_constraint = verifier.constraint(alpha);
231        let mut sum = EF::ZERO;
232        initial_constraint.combine_evals(&mut sum);
233        assert_eq!(sum, verifier.sum(alpha));
234
235        // Drive the three-proof transcript verification.
236        let mut constraints = vec![initial_constraint];
237        let mut verifier_challenge = Point::new(vec![]);
238        verifier_challenge.extend(
239            &proof0
240                .verify_rounds(&mut verifier_challenger, &mut sum, 0)
241                .unwrap(),
242        );
243
244        let intermediate_constraint = read_constraint(
245            &mut verifier_challenger,
246            intermediate_evals,
247            stacked_num_variables - FOLDING,
248            ROUND_EQ_POINTS,
249            ROUND_SEL_POINTS,
250        );
251        intermediate_constraint.combine_evals(&mut sum);
252        constraints.push(intermediate_constraint);
253        verifier_challenge.extend(
254            &proof1
255                .verify_rounds(&mut verifier_challenger, &mut sum, POW_BITS)
256                .unwrap(),
257        );
258        verifier_challenge.extend(
259            &proof2
260                .verify_rounds(&mut verifier_challenger, &mut sum, 0)
261                .unwrap(),
262        );
263
264        // Final invariants:
265        //     - Prover and verifier agreed on the same randomness.
266        //     - Batched sum equals the final folded value times the batched weights.
267        assert_eq!(expected_randomness, &verifier_challenge);
268        let weights = strategy
269            .variable_order
270            .eval_constraints_poly(&constraints, &verifier_challenge);
271        assert_eq!(sum, final_folded_value * weights);
272    }
273
274    /// Drives the intermediate + final sumcheck phases on the residual prover.
275    ///
276    /// Returns the two extra transcript chunks, the intermediate evals, and
277    /// the final folded value. Extends `prover_randomness` with every
278    /// challenge sampled during these two phases.
279    pub(crate) fn drive_intermediate_and_final(
280        prover: &mut crate::strategy::SumcheckProver<F, EF>,
281        prover_challenger: &mut MyChallenger,
282        prover_randomness: &mut Point<EF>,
283        stacked_num_variables: usize,
284    ) -> (SumcheckData<F, EF>, SumcheckData<F, EF>, Vec<EF>, EF) {
285        // Intermediate phase: build a STIR-style constraint and absorb it.
286        let mut intermediate_evals: Vec<EF> = Vec::new();
287        let constraint = make_constraint_ext(
288            prover_challenger,
289            &mut intermediate_evals,
290            prover.num_variables(),
291            ROUND_EQ_POINTS,
292            ROUND_SEL_POINTS,
293            &prover.evals(),
294        );
295
296        let mut proof1 = SumcheckData::<F, EF>::default();
297        prover_randomness.extend(&prover.compute_sumcheck_polynomials(
298            &mut proof1,
299            prover_challenger,
300            FOLDING,
301            POW_BITS,
302            Some(constraint),
303        ));
304        let remaining_vars = stacked_num_variables - FOLDING - FOLDING;
305        assert_eq!(proof1.num_rounds(), FOLDING);
306        assert_eq!(prover.num_variables(), remaining_vars);
307
308        // Final phase: fold the remaining variables down to a constant.
309        let mut proof2 = SumcheckData::<F, EF>::default();
310        prover_randomness.extend(&prover.compute_sumcheck_polynomials(
311            &mut proof2,
312            prover_challenger,
313            remaining_vars,
314            0,
315            None,
316        ));
317        assert_eq!(proof2.num_rounds(), remaining_vars);
318        assert_eq!(prover.num_variables(), 0);
319
320        let final_folded_value = prover.evals().as_constant().unwrap();
321        (proof1, proof2, intermediate_evals, final_folded_value)
322    }
323
324    /// Replays an opening schedule against a prover, returning the
325    /// `(table_idx, polys, evals)` triples the verifier will reconstruct.
326    ///
327    /// The prover's `eval` internally samples the point and absorbs the
328    /// returned evaluations, keeping the challenger in lockstep with the
329    /// verifier's `add_claim` replay on the other side.
330    fn replay_schedule<F>(
331        calls: &[(usize, &[usize])],
332        mut step: F,
333    ) -> Vec<(usize, Vec<usize>, Vec<EF>)>
334    where
335        F: FnMut(usize, &[usize]) -> Vec<EF>,
336    {
337        calls
338            .iter()
339            .map(|&(table_idx, polys)| (table_idx, polys.to_vec(), step(table_idx, polys)))
340            .collect()
341    }
342
343    /// Runs the full mode-generic roundtrip with verifier metadata from the layout.
344    pub(crate) fn run_roundtrip_test<L>(
345        witness: Witness<F>,
346        shapes: &[TableShape],
347        calls: &[(usize, &[usize])],
348    ) where
349        L: Layout<F, EF>,
350    {
351        let mut prover_challenger = challenger();
352        let stacked_num_variables = witness.num_variables();
353        // Snapshot the stacked polynomial before the witness is consumed.
354        let stacked_poly = witness.poly().clone();
355
356        // Prover: build the selected layout, record openings, add a virtual claim.
357        let mut prover_state = L::from_witness(witness);
358        let strategy = L::strategy();
359        let order = strategy.variable_order;
360        let opening_claims = replay_schedule(calls, |t, polys| {
361            prover_state.eval(t, polys, &mut prover_challenger)
362        });
363        let virtual_eval = prover_state.add_virtual_eval(&mut prover_challenger);
364
365        // Preprocessing: consume FOLDING rounds, hand off the residual prover.
366        let mut proof0 = SumcheckData::<F, EF>::default();
367        let (mut prover, mut prover_randomness) =
368            prover_state.into_sumcheck(&mut proof0, 0, &mut prover_challenger);
369        assert_eq!(proof0.num_rounds(), FOLDING);
370        assert_eq!(prover.num_variables(), stacked_num_variables - FOLDING);
371
372        // Intermediate + final rounds (mode-agnostic once the residual prover exists).
373        let (proof1, proof2, intermediate_evals, final_folded_value) = drive_intermediate_and_final(
374            &mut prover,
375            &mut prover_challenger,
376            &mut prover_randomness,
377            stacked_num_variables,
378        );
379
380        let final_eval = match order {
381            VariableOrder::Prefix => stacked_poly.eval_base(&prover_randomness),
382            VariableOrder::Suffix => stacked_poly.eval_base(&prover_randomness.reversed()),
383        };
384        assert_eq!(final_eval, final_folded_value);
385
386        verify_roundtrip(
387            strategy,
388            shapes,
389            stacked_num_variables,
390            opening_claims,
391            virtual_eval,
392            &proof0,
393            &proof1,
394            &proof2,
395            &intermediate_evals,
396            &prover_randomness,
397            final_folded_value,
398        );
399    }
400
401    /// Minimum source-table arity used by the shape proptest.
402    ///
403    /// # Constraints
404    ///
405    /// - Must be at least the preprocessing depth (every table arity >= FOLDING).
406    /// - Must be at least `log2(packing_width)` so prefix mode accepts it.
407    ///   BabyBear packing widths on current targets peak at 16, giving `k_pack = 4`.
408    const SHAPE_MIN_ARITY: usize = 4;
409
410    /// Upper bound on per-table arity in the shape proptest.
411    const SHAPE_MAX_ARITY: usize = 8;
412
413    /// Upper bound on the number of columns per table.
414    const SHAPE_MAX_COLUMNS: usize = 3;
415
416    /// Upper bound on the number of tables per witness.
417    const SHAPE_MAX_TABLES: usize = 3;
418
419    /// Upper bound on the number of opening calls in the generated schedule.
420    const SHAPE_MAX_CALLS: usize = 5;
421
422    /// One `(arity, column_count)` pair per source table.
423    pub(crate) type WitnessShape = Vec<(usize, usize)>;
424
425    /// One `(table_index, column_subset)` pair per opening call.
426    pub(crate) type OpeningSchedule = Vec<(usize, Vec<usize>)>;
427
428    /// Builds a deterministic witness matching the given `(arity, column_count)` shape.
429    ///
430    /// # Arguments
431    ///
432    /// - `shape` — one `(arity, column_count)` pair per source table.
433    pub(crate) fn tables_from_shape(shape: &[(usize, usize)]) -> Vec<Table<F>> {
434        // Fixed seed: every proptest case gets reproducible polynomial evaluations.
435        let mut rng = SmallRng::seed_from_u64(42);
436
437        // One table per (arity, column_count) pair; each column is a random polynomial.
438        shape
439            .iter()
440            .map(|&(arity, num_cols)| {
441                let polys: Vec<Poly<F>> = (0..num_cols)
442                    .map(|_| Poly::<F>::rand(&mut rng, arity))
443                    .collect();
444                Table::new(polys)
445            })
446            .collect()
447    }
448
449    /// Mirrors a `(arity, column_count)` shape onto the verifier-side table shapes.
450    pub(crate) fn table_shapes_from(shape: &[(usize, usize)]) -> Vec<TableShape> {
451        shape
452            .iter()
453            .map(|&(arity, num_cols)| TableShape::new(arity, num_cols))
454            .collect()
455    }
456
457    /// Proptest strategy: random witness shape paired with a valid opening schedule.
458    ///
459    /// # Shape
460    ///
461    /// - 1..=`SHAPE_MAX_TABLES` source tables.
462    /// - Each table: arity in `[SHAPE_MIN_ARITY, SHAPE_MAX_ARITY]`, column count
463    ///   in `[1, SHAPE_MAX_COLUMNS]`.
464    ///
465    /// # Schedule
466    ///
467    /// - 1..=`SHAPE_MAX_CALLS` opening calls over the generated witness.
468    /// - Every call picks an existing table index and a non-empty, de-duplicated
469    ///   subset of that table's columns (columns may appear in any order).
470    pub(crate) fn arb_witness_and_schedule()
471    -> impl Strategy<Value = (WitnessShape, OpeningSchedule)> {
472        // Step 1: pick the witness shape.
473        //
474        // Stacked arity must accommodate two phases of FOLDING rounds plus the
475        // final fold-to-constant phase, so total size must exceed `2^(2 * FOLDING - 1)`.
476        let shape = prop::collection::vec(
477            (
478                SHAPE_MIN_ARITY..=SHAPE_MAX_ARITY,
479                1usize..=SHAPE_MAX_COLUMNS,
480            ),
481            1..=SHAPE_MAX_TABLES,
482        )
483        .prop_filter(
484            "stacked polynomial must support two phases of FOLDING rounds",
485            |shape| {
486                let total: usize = shape.iter().map(|&(a, c)| (1usize << a) * c).sum();
487                // log2_ceil(total) >= 2 * FOLDING.
488                total > (1usize << (2 * FOLDING - 1))
489            },
490        );
491
492        shape.prop_flat_map(|shape| {
493            // Step 2: given the shape, pick a schedule that respects it.
494            let num_tables = shape.len();
495            let shape_for_calls = shape.clone();
496
497            // One call: pick a table, then a non-empty unique column subset.
498            let one_call = (0..num_tables).prop_flat_map(move |t_idx| {
499                let num_cols = shape_for_calls[t_idx].1;
500                // Draw a random column sequence of length 1..=num_cols, then deduplicate
501                // while preserving first-seen order so the opening list stays valid.
502                prop::collection::vec(0..num_cols, 1..=num_cols).prop_map(move |cols| {
503                    let mut seen = vec![false; num_cols];
504                    let dedup: Vec<usize> = cols
505                        .into_iter()
506                        .filter(|&c| {
507                            let first = !seen[c];
508                            seen[c] = true;
509                            first
510                        })
511                        .collect();
512                    (t_idx, dedup)
513                })
514            });
515
516            // Step 3: bundle the schedule back with its originating shape.
517            prop::collection::vec(one_call, 1..=SHAPE_MAX_CALLS)
518                .prop_map(move |sched| (shape.clone(), sched))
519        })
520    }
521}
522
523#[cfg(test)]
524mod tests {
525
526    use alloc::vec::Vec;
527
528    use proptest::prelude::*;
529
530    use super::test_utils::{
531        ASCENDING_POLYS, NON_ASCENDING_POLYS, arb_opening_schedule, arb_witness_and_schedule,
532        table_shapes_from,
533    };
534    use super::{PrefixProver, SuffixProver};
535    use crate::layout::Layout;
536    use crate::layout::prover::test_utils::{
537        FOLDING, build_tables, run_roundtrip_test, table_shapes, tables_from_shape,
538    };
539    use crate::tests::*;
540
541    #[test]
542    fn num_claims_counts_every_recorded_opening() {
543        fn run_num_claims_test_with<L>(witness: crate::layout::Witness<F>)
544        where
545            L: Layout<F, EF>,
546        {
547            let mut prover = L::from_witness(witness);
548            assert_eq!(prover.num_claims(), 0);
549
550            let mut ch = challenger();
551            prover.eval(0, &[0, 1], &mut ch);
552            assert_eq!(prover.num_claims(), 2);
553
554            prover.eval(1, &[0], &mut ch);
555            assert_eq!(prover.num_claims(), 3);
556        }
557
558        run_num_claims_test_with::<SuffixProver<F, EF>>(SuffixProver::<F, EF>::new_witness(
559            build_tables(),
560            FOLDING,
561        ));
562        run_num_claims_test_with::<PrefixProver<F, EF>>(PrefixProver::<F, EF>::new_witness(
563            build_tables(),
564            FOLDING,
565        ));
566    }
567
568    #[test]
569    fn roundtrip_ascending_polys() {
570        run_roundtrip_test::<PrefixProver<F, EF>>(
571            PrefixProver::<F, EF>::new_witness(build_tables(), FOLDING),
572            &table_shapes(),
573            ASCENDING_POLYS,
574        );
575
576        run_roundtrip_test::<SuffixProver<F, EF>>(
577            SuffixProver::<F, EF>::new_witness(build_tables(), FOLDING),
578            &table_shapes(),
579            ASCENDING_POLYS,
580        );
581    }
582
583    #[test]
584    fn roundtrip_non_ascending_polys() {
585        run_roundtrip_test::<PrefixProver<F, EF>>(
586            PrefixProver::<F, EF>::new_witness(build_tables(), FOLDING),
587            &table_shapes(),
588            NON_ASCENDING_POLYS,
589        );
590
591        run_roundtrip_test::<SuffixProver<F, EF>>(
592            SuffixProver::<F, EF>::new_witness(build_tables(), FOLDING),
593            &table_shapes(),
594            NON_ASCENDING_POLYS,
595        );
596    }
597
598    fn run_shape_test<L>(shape: &[(usize, usize)], schedule: &[(usize, Vec<usize>)])
599    where
600        L: Layout<F, EF>,
601    {
602        let witness = L::new_witness(tables_from_shape(shape), FOLDING);
603        let shapes = table_shapes_from(shape);
604        let borrowed: Vec<(usize, &[usize])> = schedule
605            .iter()
606            .map(|(t, polys)| (*t, polys.as_slice()))
607            .collect();
608        run_roundtrip_test::<L>(witness, &shapes, &borrowed);
609    }
610
611    proptest! {
612        #![proptest_config(ProptestConfig { cases: 16, ..ProptestConfig::default() })]
613
614        // Invariant:
615        //     Every valid opening schedule over the fixed two-table witness
616        //     roundtrips through the protocol without prover/verifier divergence.
617        //
618        // Coverage: includes non-ascending column orders that previously exposed
619        // alpha / partial-eval alignment bugs.
620        #[test]
621        fn roundtrip_proptest(schedule in arb_opening_schedule()) {
622            let borrowed: Vec<(usize, &[usize])> = schedule
623                .iter()
624                .map(|(t, polys)| (*t, polys.as_slice()))
625                .collect();
626
627            run_roundtrip_test::<PrefixProver<F, EF>>(
628                PrefixProver::<F, EF>::new_witness(build_tables(), FOLDING),
629                &table_shapes(),
630                &borrowed,
631            );
632            run_roundtrip_test::<SuffixProver<F, EF>>(
633                SuffixProver::<F, EF>::new_witness(build_tables(), FOLDING),
634                &table_shapes(),
635                &borrowed,
636            );
637        }
638    }
639
640    proptest! {
641        #![proptest_config(ProptestConfig { cases: 8, ..ProptestConfig::default() })]
642
643        // Invariant:
644        //     Roundtrip agreement holds for valid generated witness shapes, not
645        //     only the fixed two-table fixture.
646        #[test]
647        fn roundtrip_shape_proptest((shape, schedule) in arb_witness_and_schedule()) {
648            run_shape_test::<PrefixProver<F, EF>>(&shape, &schedule);
649            run_shape_test::<SuffixProver<F, EF>>(&shape, &schedule);
650        }
651    }
652}