Skip to main content

p3_sumcheck/
transcript.rs

1//! Fiat-Shamir transcript of the quadratic sumcheck.
2//!
3//! # Overview
4//!
5//! This module is the single statement of what a batch of quadratic sumcheck rounds absorbs.
6//! Both sides consume it through the same three calls.
7//!
8//! The description itself is a value, built from the numbers that shape a batch.
9//!
10//! # Shape
11//!
12//! ```text
13//!     round 0:  polynomial     2 extension elements
14//!               grinding       present only when the difficulty is positive
15//!               challenge      1 extension element
16//!     round 1:  ...
17//! ```
18//!
19//! A quadratic round polynomial has three values and two of them cross the wire.
20//! The third is what the round identity of the basis reconstructs.
21//!
22//! # Sub-protocol
23//!
24//! A sumcheck is almost always a phase of something larger.
25//!
26//! It therefore seeds a transcript of its own from a borrowed sponge, and hands the sponge back.
27//! The surrounding protocol keeps its own description, and brackets this run inside it.
28//!
29//! That is what lets the two descriptions be written independently and still compose.
30//!
31//! # Empty runs
32//!
33//! A batch of zero rounds absorbs nothing and draws nothing.
34//!
35//! Such a batch is therefore not a transcript at all.
36//! No seed is folded in, and no step is played.
37//!
38//! A caller whose configuration reduces to zero rounds needs no special case of its own.
39//!
40//! # What the shape binds
41//!
42//! A fingerprint of the description enters the sponge before any step runs.
43//!
44//! The round count and the grinding difficulty both change the description.
45//! The fingerprint therefore covers them.
46//!
47//! The basis does not change it.
48//! It renames the two values a round sends without moving a single step.
49//! It is bound as an instance label instead.
50//!
51//! No shipped prover or verifier drives the projective reading yet.
52//! The label makes the two seeds differ before one exists.
53
54use alloc::vec::Vec;
55use core::marker::PhantomData;
56
57use p3_challenger::fs::{
58    DomainSeparator, FieldToFieldCodec, FieldUnit, Hierarchy, Interaction, InteractionPattern,
59    Kind, Length, ProverState, TranscriptField, VerifierState,
60};
61use p3_challenger::{CanObserve, CanSample, GrindingChallenger};
62use p3_field::ExtensionField;
63
64use crate::error::SumcheckError;
65use crate::strategy::Basis;
66
67/// Version byte bound into the transcript seed.
68///
69/// Bumping it separates two revisions of this protocol.
70/// It does so even when their step sequences agree.
71const VERSION: u8 = 1;
72
73/// Protocol name bound into the transcript seed.
74pub(crate) const NAME: &[u8] = b"p3-sumcheck-quadratic";
75
76/// Step label of the two round-polynomial values a round sends.
77const ROUND_POLY: &str = "round_poly";
78
79/// Step label of a per-round grinding step.
80const ROUND_POW: &str = "round_pow";
81
82/// Step label of a per-round challenge.
83const ROUND_CHALLENGE: &str = "round_challenge";
84
85/// Values of the round polynomial that cross the wire.
86const VALUES_PER_ROUND: usize = 2;
87
88/// Sponge alphabet of a challenger that speaks the base field natively.
89type Alphabet<F> = FieldUnit<F>;
90
91/// Numbers that fix the transcript of one batch of quadratic sumcheck rounds.
92///
93/// Both sides build this from their own configuration, never from a proof.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub struct SumcheckShape {
96    /// Number of variables the batch binds, and so the number of rounds.
97    pub num_rounds: usize,
98    /// Grinding difficulty guarding each round's challenge, or zero to omit grinding.
99    pub pow_bits: usize,
100    /// Basis the two transmitted values are read in.
101    pub basis: Basis,
102}
103
104impl SumcheckShape {
105    /// Collect the numbers that fix one batch of rounds.
106    ///
107    /// # Arguments
108    ///
109    /// - `num_rounds`: number of variables the batch binds.
110    /// - `pow_bits`: grinding difficulty per round, or zero to omit grinding.
111    /// - `basis`: how the two transmitted values are read.
112    #[must_use]
113    pub const fn new(num_rounds: usize, pow_bits: usize, basis: Basis) -> Self {
114        Self {
115            num_rounds,
116            pow_bits,
117            basis,
118        }
119    }
120
121    /// Describe the transcript this shape fixes.
122    ///
123    /// # Panics
124    ///
125    /// Never in practice.
126    /// A flat sequence of leaf steps always passes structural validation.
127    #[must_use]
128    pub fn pattern<F, EF>(&self) -> InteractionPattern
129    where
130        F: TranscriptField,
131        EF: ExtensionField<F>,
132    {
133        // Both readings send two values per round, so both describe the same steps.
134        //
135        //     evaluation basis:  [h(0), h(inf)]
136        //     projective basis:  [s(1), s(inf)]
137        //
138        // Matching every variant forces a new reading to be given a width here.
139        //
140        // That is the whole of what the match buys:
141        //
142        //     another width         -> this line must change, so the description changes
143        //     another step sequence -> a copied arm compiles and shares this description
144        //
145        // Readings that agree on width and sequence are separated only by the instance byte.
146        let values_per_round = match self.basis {
147            Basis::Evaluation => VALUES_PER_ROUND,
148            Basis::Projective => VALUES_PER_ROUND,
149        };
150
151        // Up to three steps per round.
152        let mut steps = Vec::with_capacity(3 * self.num_rounds);
153
154        for _ in 0..self.num_rounds {
155            // The round polynomial is one step carrying both transmitted values.
156            steps.push(Interaction::algebra::<F, EF>(
157                Hierarchy::Atomic,
158                Kind::Message,
159                ROUND_POLY,
160                Length::Fixed(values_per_round),
161            ));
162
163            // Grinding sits between the polynomial and the challenge it protects.
164            //
165            // The difficulty travels inside the step.
166            // A verifier expecting a cheaper grind therefore fails the shape check.
167            if self.pow_bits > 0 {
168                steps.push(Interaction::algebra::<F, F>(
169                    Hierarchy::Atomic,
170                    Kind::Pow,
171                    ROUND_POW,
172                    Length::Fixed(self.pow_bits),
173                ));
174            }
175
176            // The challenge binds the variable this round reduces away.
177            steps.push(Interaction::algebra::<F, EF>(
178                Hierarchy::Atomic,
179                Kind::Challenge,
180                ROUND_CHALLENGE,
181                Length::Scalar,
182            ));
183        }
184
185        InteractionPattern::new(steps).expect("a flat sequence of leaf steps is always well formed")
186    }
187
188    /// Bind the protocol identity, this shape, and the basis.
189    ///
190    /// The round count and the difficulty both move the step sequence.
191    /// The fingerprint therefore carries them.
192    ///
193    /// The basis does not move it.
194    ///
195    /// ```text
196    ///     evaluation basis:  the pair is  [h(0), h(inf)]
197    ///     projective basis:  the pair is  [s(1), s(inf)]
198    /// ```
199    ///
200    /// Both are two extension elements followed by one challenge.
201    /// Only the instance label separates two runs that disagree on which reading is meant.
202    ///
203    /// Every shipped driver picks the evaluation reading, so no live pair can disagree today.
204    /// The label is what keeps the two apart once a projective driver exists.
205    #[must_use]
206    pub fn domain_separator<F, EF>(&self) -> DomainSeparator<Alphabet<F>>
207    where
208        F: TranscriptField,
209        EF: ExtensionField<F>,
210    {
211        let mut separator = DomainSeparator::new(VERSION, NAME, self.pattern::<F, EF>());
212
213        separator.instance(&[match self.basis {
214            Basis::Evaluation => 0,
215            Basis::Projective => 1,
216        }]);
217
218        separator
219    }
220}
221
222/// Prover-side transcript of one batch of quadratic sumcheck rounds.
223///
224/// # Overview
225///
226/// Holds the only definition of what a prover writes per round.
227///
228/// Every prover of these rounds drives them through this type.
229/// No two prover loops can then drift from each other, or from the verifier.
230///
231/// # Borrowing
232///
233/// The challenger is borrowed, not consumed.
234///
235/// A sumcheck runs inside a larger protocol.
236/// That protocol's own transcript continues where this one stops.
237pub struct ProverTranscript<'a, C, F: TranscriptField, EF> {
238    /// Driver walking the description and holding the borrowed sponge.
239    ///
240    /// Absent when the batch has no rounds, because then there is nothing to describe.
241    state: Option<ProverState<&'a mut C, Alphabet<F>>>,
242    /// The numbers this batch was described with.
243    shape: SumcheckShape,
244    /// Marker for the extension field the rounds carry.
245    _ef: PhantomData<EF>,
246}
247
248impl<'a, C, F, EF> ProverTranscript<'a, C, F, EF>
249where
250    F: TranscriptField,
251    EF: ExtensionField<F>,
252    C: CanObserve<F> + CanSample<F> + GrindingChallenger<Witness = F>,
253{
254    /// Seed the transcript from the shape.
255    ///
256    /// A batch of zero rounds leaves the sponge exactly as it was found.
257    ///
258    /// # Arguments
259    ///
260    /// - `challenger`: sponge of the surrounding protocol, borrowed for the batch.
261    /// - `shape`: the numbers that fix this batch's transcript.
262    pub fn new(challenger: &'a mut C, shape: SumcheckShape) -> Self {
263        let state = (shape.num_rounds > 0).then(|| {
264            // Seeding folds the shape fingerprint into the sponge before any step.
265            let separator = shape.domain_separator::<F, EF>();
266            ProverState::new(challenger, &separator)
267        });
268
269        Self {
270            state,
271            shape,
272            _ef: PhantomData,
273        }
274    }
275
276    /// Play one round: bind the two transmitted values, grind, and draw the challenge.
277    ///
278    /// # Returns
279    ///
280    /// - The challenge that binds this round's variable.
281    /// - The grinding witness, when the difficulty is positive.
282    ///
283    /// The caller stores both in its own proof.
284    ///
285    /// # Panics
286    ///
287    /// When the batch was described with no rounds at all.
288    pub fn round(&mut self, c_a: EF, c_inf: EF) -> (EF, Option<F>) {
289        let state = self
290            .state
291            .as_mut()
292            .expect("a batch described with no rounds has no round to play");
293
294        // Bind both values before the challenge that will be evaluated against them.
295        state.observe_extensions::<F, EF, FieldToFieldCodec<F>>(ROUND_POLY, &[c_a, c_inf]);
296
297        // Grinding raises the cost of searching for a favourable challenge.
298        let witness =
299            (self.shape.pow_bits > 0).then(|| state.observe_pow(ROUND_POW, self.shape.pow_bits));
300
301        // Draw the challenge for the caller to fold with.
302        let challenge = state
303            .challenge_extension::<F, EF, FieldToFieldCodec<F>>(ROUND_CHALLENGE)
304            .into_inner();
305
306        (challenge, witness)
307    }
308
309    /// Close the transcript once every described step has been played.
310    ///
311    /// # Panics
312    ///
313    /// When fewer rounds were played than the batch was described with.
314    pub fn finish(self) {
315        if let Some(state) = self.state {
316            // Nothing was written to the driver's own buffer.
317            // Closing is therefore purely the check that the description was consumed.
318            assert!(
319                state.finalize().is_empty(),
320                "the quadratic sumcheck carries every value in its own proof",
321            );
322        }
323    }
324}
325
326/// Verifier-side transcript of one batch of quadratic sumcheck rounds.
327///
328/// Mirrors the prover side call for call, over the same description.
329///
330/// The values come from the proof rather than from a wire.
331/// The caller must therefore have counted them against the described shape first.
332pub struct VerifierTranscript<'a, C, F: TranscriptField, EF> {
333    /// Driver walking the description and holding the borrowed sponge.
334    ///
335    /// The proof carries every value, so the driver reads an empty wire.
336    ///
337    /// Absent when the batch has no rounds, because then there is nothing to describe.
338    state: Option<VerifierState<'static, &'a mut C, Alphabet<F>>>,
339    /// The numbers this batch was described with.
340    shape: SumcheckShape,
341    /// Index of the next round to play, used to place a round failure.
342    round: usize,
343    /// Marker for the extension field the rounds carry.
344    _ef: PhantomData<EF>,
345}
346
347impl<'a, C, F, EF> VerifierTranscript<'a, C, F, EF>
348where
349    F: TranscriptField,
350    EF: ExtensionField<F>,
351    C: CanObserve<F> + CanSample<F> + GrindingChallenger<Witness = F>,
352{
353    /// Seed the transcript from the shape.
354    ///
355    /// The argument matches the prover's, so both sides seed identically.
356    pub fn new(challenger: &'a mut C, shape: SumcheckShape) -> Self {
357        let state = (shape.num_rounds > 0).then(|| {
358            // Seeding folds the shape fingerprint into the sponge before any step.
359            let separator = shape.domain_separator::<F, EF>();
360            VerifierState::new(challenger, &separator, &[])
361        });
362
363        Self {
364            state,
365            shape,
366            round: 0,
367            _ef: PhantomData,
368        }
369    }
370
371    /// Replay one round: bind both values, re-check the grind, draw the challenge.
372    ///
373    /// Every value comes from the proof, so every disagreement is a rejection.
374    ///
375    /// # Errors
376    ///
377    /// - Grinding is enabled and the round carries no witness.
378    /// - The witness misses the required difficulty.
379    ///
380    /// # Panics
381    ///
382    /// When the batch was described with no rounds at all.
383    pub fn round(&mut self, c_a: EF, c_inf: EF, witness: Option<F>) -> Result<EF, SumcheckError> {
384        let round = self.round;
385        self.round += 1;
386
387        let state = self
388            .state
389            .as_mut()
390            .expect("a batch described with no rounds has no round to play");
391
392        // Bind both values before the challenge that will be evaluated against them.
393        //
394        // A round always sends exactly two, so this step cannot disagree on its width.
395        state
396            .observe_extensions::<F, EF, FieldToFieldCodec<F>>(ROUND_POLY, &[c_a, c_inf])
397            .expect("a round always carries exactly the two values the step describes");
398
399        // Re-run the prover's grinding step on the witness it committed to.
400        if self.shape.pow_bits > 0 {
401            // With no witness the described step cannot be played at all.
402            //
403            // Releasing the completeness check keeps this rejection the only failure.
404            let Some(witness) = witness else {
405                state.abort();
406                return Err(SumcheckError::MissingPowWitness {
407                    round,
408                    difficulty: self.shape.pow_bits,
409                });
410            };
411            // A failing check releases the completeness check on its own way out.
412            state
413                .observe_pow(ROUND_POW, self.shape.pow_bits, witness)
414                .map_err(|_| SumcheckError::InvalidPowWitness {
415                    round,
416                    difficulty: self.shape.pow_bits,
417                })?;
418        }
419
420        // Draw the same challenge the prover saw.
421        Ok(state
422            .challenge_extension::<F, EF, FieldToFieldCodec<F>>(ROUND_CHALLENGE)
423            .into_inner())
424    }
425
426    /// Close the transcript once every described step has been played.
427    ///
428    /// # Panics
429    ///
430    /// When fewer rounds were played than the batch was described with.
431    pub fn finish(self) {
432        if let Some(state) = self.state {
433            // The proof carries every value, so no unread wire bytes can remain.
434            state
435                .finalize()
436                .expect("the quadratic sumcheck reads an empty wire");
437        }
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use alloc::vec::Vec;
444
445    use p3_baby_bear::{BabyBear, Poseidon2BabyBear};
446    use p3_challenger::testing::pow_difficulties;
447    use p3_challenger::{CanSample, DuplexChallenger};
448    use p3_field::PrimeCharacteristicRing;
449    use p3_field::extension::BinomialExtensionField;
450    use rand::SeedableRng;
451    use rand::rngs::SmallRng;
452
453    use super::*;
454
455    type F = BabyBear;
456    type EF = BinomialExtensionField<F, 4>;
457    type Perm = Poseidon2BabyBear<16>;
458    type Ch = DuplexChallenger<F, Perm, 16, 8>;
459
460    fn fresh_challenger() -> Ch {
461        // Fixed seed so two runs differ only where the transcript makes them differ.
462        let mut rng = SmallRng::seed_from_u64(0xDEADBEEF);
463        Ch::new(Perm::new_from_rng_128(&mut rng))
464    }
465
466    /// The first challenge a shape's seed produces.
467    fn first_challenge(shape: SumcheckShape) -> F {
468        let mut challenger = fresh_challenger();
469        shape.domain_separator::<F, EF>().seed(&mut challenger);
470        challenger.sample()
471    }
472
473    #[test]
474    fn every_number_that_shapes_a_batch_reaches_the_seed() {
475        // Fixture state: 4 rounds, no grinding, values read in the evaluation basis.
476        let base = SumcheckShape::new(4, 0, Basis::Evaluation);
477
478        // One more round appends two more steps.
479        assert_ne!(
480            first_challenge(base),
481            first_challenge(SumcheckShape::new(5, 0, Basis::Evaluation))
482        );
483
484        // Enabling grinding inserts a step per round.
485        assert_ne!(
486            first_challenge(base),
487            first_challenge(SumcheckShape::new(4, 8, Basis::Evaluation))
488        );
489
490        // Two positive difficulties differ only inside the grinding steps.
491        assert_ne!(
492            first_challenge(SumcheckShape::new(4, 8, Basis::Evaluation)),
493            first_challenge(SumcheckShape::new(4, 9, Basis::Evaluation))
494        );
495
496        // The basis leaves the step sequence identical, so only the label separates the two.
497        assert_ne!(
498            first_challenge(base),
499            first_challenge(SumcheckShape::new(4, 0, Basis::Projective))
500        );
501    }
502
503    #[test]
504    fn a_batch_of_no_rounds_leaves_the_sponge_untouched() {
505        // Invariant: a batch with nothing to absorb is not a transcript.
506        //
507        // A caller whose configuration reduces to zero rounds runs no steps and seeds nothing.
508        // Its counterpart on the other side, which may not call in at all, therefore stays in step.
509        let mut untouched = fresh_challenger();
510        let expected: F = untouched.sample();
511
512        let mut challenger = fresh_challenger();
513        let transcript = ProverTranscript::<Ch, F, EF>::new(
514            &mut challenger,
515            SumcheckShape::new(0, 0, Basis::Evaluation),
516        );
517        transcript.finish();
518
519        assert_eq!(CanSample::<F>::sample(&mut challenger), expected);
520    }
521
522    #[test]
523    fn a_round_missing_its_grinding_witness_is_rejected() {
524        // Described batch: 2 rounds guarded by 4 bits of grinding each.
525        //
526        // A described grinding step cannot be replayed with no witness to feed it.
527        //
528        // This is the path a downstream crate reaches with a proof whose witness list is short.
529        // A panic here would compound with the driver's own drop check and abort the process.
530        let mut challenger = fresh_challenger();
531        let mut transcript = VerifierTranscript::<Ch, F, EF>::new(
532            &mut challenger,
533            SumcheckShape::new(2, 4, Basis::Evaluation),
534        );
535
536        let err = transcript
537            .round(EF::ONE, EF::ONE, None)
538            .expect_err("a described grinding step with no witness must error");
539
540        assert_eq!(
541            err,
542            SumcheckError::MissingPowWitness {
543                round: 0,
544                difficulty: 4
545            }
546        );
547    }
548
549    #[test]
550    fn a_grinding_witness_below_the_difficulty_is_rejected() {
551        // Described batch: 1 round guarded by 12 bits of grinding.
552        //
553        // Mutation: hand the round a witness that was never ground at all.
554        let mut challenger = fresh_challenger();
555        let mut transcript = VerifierTranscript::<Ch, F, EF>::new(
556            &mut challenger,
557            SumcheckShape::new(1, 12, Basis::Evaluation),
558        );
559
560        let err = transcript
561            .round(EF::ONE, EF::ONE, Some(F::ZERO))
562            .expect_err("a witness below the required difficulty must error");
563
564        assert_eq!(
565            err,
566            SumcheckError::InvalidPowWitness {
567                round: 0,
568                difficulty: 12
569            }
570        );
571    }
572
573    /// The challenges a verifier redraws from one recorded pair of values, per round.
574    fn replay(rounds: &[[EF; 2]]) -> Vec<EF> {
575        let mut challenger = fresh_challenger();
576        let shape = SumcheckShape::new(rounds.len(), 0, Basis::Evaluation);
577        let mut transcript = VerifierTranscript::<Ch, F, EF>::new(&mut challenger, shape);
578        let replayed = rounds
579            .iter()
580            .map(|&[c_a, c_inf]| transcript.round(c_a, c_inf, None).unwrap())
581            .collect();
582        transcript.finish();
583        replayed
584    }
585
586    #[test]
587    fn a_perturbed_finite_point_value_moves_every_later_challenge() {
588        // Invariant: the pair is bound before the challenge that is evaluated against it.
589        //
590        // Without that, a prover could pick the round polynomial to suit the challenge.
591        //
592        // Fixture state: two rounds, no grinding.
593        //
594        // Mutation: change the first round's finite-point value.
595        //
596        //     honest:   [[1, 2], [2, 1]]
597        //     tampered: [[3, 2], [2, 1]]
598        let honest = [[EF::ONE, EF::TWO], [EF::TWO, EF::ONE]];
599        let mut tampered = honest;
600        tampered[0][0] = EF::from_u8(3);
601
602        assert_ne!(replay(&honest), replay(&tampered));
603    }
604
605    #[test]
606    fn a_perturbed_leading_coefficient_moves_every_later_challenge() {
607        // Same invariant for the other half of the pair.
608        //
609        // Fixture state: two rounds, no grinding.
610        //
611        // Mutation: change the first round's leading coefficient.
612        //
613        //     honest:   [[1, 2], [2, 1]]
614        //     tampered: [[1, 3], [2, 1]]
615        let honest = [[EF::ONE, EF::TWO], [EF::TWO, EF::ONE]];
616        let mut tampered = honest;
617        tampered[0][1] = EF::from_u8(3);
618
619        assert_ne!(replay(&honest), replay(&tampered));
620    }
621
622    #[test]
623    fn a_recorded_grinding_witness_replays_to_the_same_challenge() {
624        // Completeness of the guarded path: the witness is absorbed, not merely checked.
625        //
626        // The verifier redraws the challenge only after feeding back the witness the prover found.
627        //
628        // Fixture state: one round guarded by 4 bits.
629        let shape = SumcheckShape::new(1, 4, Basis::Evaluation);
630
631        let mut challenger = fresh_challenger();
632        let mut prover = ProverTranscript::<Ch, F, EF>::new(&mut challenger, shape);
633        let (challenge, witness) = prover.round(EF::ONE, EF::TWO);
634        prover.finish();
635        let witness = witness.expect("a positive difficulty always yields a witness");
636
637        // Replaying the recorded witness reaches the recorded challenge.
638        let mut challenger = fresh_challenger();
639        let mut verifier = VerifierTranscript::<Ch, F, EF>::new(&mut challenger, shape);
640        let replayed = verifier.round(EF::ONE, EF::TWO, Some(witness)).unwrap();
641        verifier.finish();
642
643        assert_eq!(challenge, replayed);
644    }
645
646    #[test]
647    fn both_sides_of_a_round_draw_the_same_challenge() {
648        // Completeness: the two drivers walk one description and land on one challenge.
649        let shape = SumcheckShape::new(2, 0, Basis::Evaluation);
650
651        let mut prover_challenger = fresh_challenger();
652        let mut prover = ProverTranscript::<Ch, F, EF>::new(&mut prover_challenger, shape);
653        let (first, _) = prover.round(EF::ONE, EF::TWO);
654        let (second, _) = prover.round(EF::TWO, EF::ONE);
655        prover.finish();
656
657        let mut verifier_challenger = fresh_challenger();
658        let mut verifier = VerifierTranscript::<Ch, F, EF>::new(&mut verifier_challenger, shape);
659        let replayed_first = verifier.round(EF::ONE, EF::TWO, None).unwrap();
660        let replayed_second = verifier.round(EF::TWO, EF::ONE, None).unwrap();
661        verifier.finish();
662
663        assert_eq!(first, replayed_first);
664        assert_eq!(second, replayed_second);
665
666        // The sponge is handed back in one state, so the surrounding protocol stays in step.
667        assert_eq!(
668            CanSample::<F>::sample(&mut prover_challenger),
669            CanSample::<F>::sample(&mut verifier_challenger)
670        );
671    }
672
673    #[test]
674    fn the_described_grinding_matches_the_configured_difficulty() {
675        // Invariant: a grinding difficulty lives in two places.
676        //
677        //     transcript  ->  the bits the pattern describes
678        //     model       ->  the bits this protocol's own report credits
679        //
680        // Both read the same configuration field, so they must agree round for round.
681        //
682        // This protocol prices its own grinding, so the shared budget never compares it.
683        //
684        // Fixture state: four rounds, ground and unground.
685        for bits in [0, 4] {
686            let shape = SumcheckShape::new(4, bits, Basis::Evaluation);
687            let described = pow_difficulties(&shape.pattern::<F, EF>());
688
689            // A zero difficulty describes no step.
690            //
691            // The ground case is therefore the only one that records anything.
692            let expected = if bits == 0 { 0 } else { shape.num_rounds };
693            assert_eq!(described.len(), expected);
694
695            assert!(
696                described
697                    .iter()
698                    .all(|&(label, got)| label == ROUND_POW && got == bits)
699            );
700        }
701    }
702}