Skip to main content

p3_sumcheck/
table.rs

1use alloc::vec::Vec;
2
3use p3_field::Field;
4use p3_matrix::Dimensions;
5use p3_multilinear_util::point::Point;
6use p3_util::log2_ceil_usize;
7use serde::{Deserialize, Serialize};
8
9/// Shape-only description of one verifier table.
10///
11/// # Contents
12///
13/// - Row count, always a power of two.
14/// - Column count, strictly positive.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct TableShape(Dimensions);
17
18impl TableShape {
19    /// Builds a table shape of `2^k` rows and `width` columns.
20    ///
21    /// # Panics
22    ///
23    /// - Column count must be at least one.
24    /// - Log row count must fit in the target's pointer width.
25    pub const fn new(num_variables: usize, width: usize) -> Self {
26        // Positive column count.
27        assert!(width > 0);
28        // Bound on the shift to rule out `1 << k` overflow on the current target.
29        assert!(num_variables < usize::BITS as usize);
30        // Expand the log row count to the concrete row count.
31        Self(Dimensions {
32            width,
33            height: 1 << num_variables,
34        })
35    }
36
37    /// Returns the number of variables per column.
38    pub const fn num_variables(&self) -> usize {
39        log2_ceil_usize(self.0.height)
40    }
41
42    /// Returns the number of columns.
43    pub const fn width(&self) -> usize {
44        self.0.width
45    }
46}
47
48/// Opening batch sampled at one transcript point for a table.
49///
50/// # Overview
51///
52/// - One batch records what is opened at a single sampled point.
53/// - It holds two column-index lists evaluated at that same point.
54/// - Transcript and evaluation order is always the first list, then the second.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct OpeningBatch<T> {
57    /// Columns evaluated directly at the sampled point.
58    current: Vec<T>,
59    /// Columns evaluated through the repeat-last successor view at the same point.
60    next: Vec<T>,
61}
62
63impl<T> OpeningBatch<T> {
64    /// Builds an opening batch from the direct list and the successor-view list.
65    ///
66    /// # Arguments
67    ///
68    /// - `current`: columns evaluated directly at the sampled point.
69    /// - `next`: columns evaluated through the repeat-last successor view.
70    ///
71    /// # Panics
72    ///
73    /// Panics if both lists are empty.
74    pub fn new(current: Vec<T>, next: Vec<T>) -> Self {
75        // Invariant: a batch must name at least one column on either side.
76        // An empty batch would consume a transcript point yet open nothing.
77        assert!(
78            !current.is_empty() || !next.is_empty(),
79            "opening batch must name at least one column"
80        );
81        Self { current, next }
82    }
83
84    /// Returns the columns evaluated directly at the sampled point.
85    pub fn current(&self) -> &[T] {
86        &self.current
87    }
88
89    /// Returns the columns evaluated through the repeat-last successor view.
90    pub fn next(&self) -> &[T] {
91        &self.next
92    }
93
94    /// Returns the total number of entries across both lists.
95    pub const fn len(&self) -> usize {
96        // Both sides share the same sampled point, so the batch size is their sum.
97        self.current.len() + self.next.len()
98    }
99
100    /// Returns true when neither list holds an entry.
101    pub const fn is_empty(&self) -> bool {
102        self.current.is_empty() && self.next.is_empty()
103    }
104
105    /// Returns true when both per-side list lengths match the other batch.
106    ///
107    /// # Arguments
108    ///
109    /// - `other`: the batch whose per-side lengths are compared against this one.
110    pub const fn has_same_shape<U>(&self, other: &OpeningBatch<U>) -> bool {
111        // Shape is the pair of side lengths; entry values are irrelevant here.
112        self.current.len() == other.current.len() && self.next.len() == other.next.len()
113    }
114}
115
116impl<T: Clone> OpeningBatch<T> {
117    /// Flattens both lists into one vector in transcript order.
118    ///
119    /// # Returns
120    ///
121    /// - The direct entries first.
122    /// - The successor-view entries second.
123    pub fn to_vec(&self) -> Vec<T> {
124        // Walk in transcript order (direct entries, then successor-view entries)
125        // and copy each entry into a flat vector.
126        self.current.iter().chain(&self.next).cloned().collect()
127    }
128}
129
130/// Column indices requested at one sampled point for a table.
131///
132/// # Overview
133///
134/// - One request names the columns opened at a single sampled point.
135/// - Each side may name direct columns, successor-view columns, or both.
136pub type OpeningRequest = OpeningBatch<usize>;
137
138/// Claimed column evaluations matching one request at a single sampled point.
139pub type OpeningEvals<EF> = OpeningBatch<EF>;
140
141/// Sequence of per-point opening requests for one table.
142///
143/// One entry per sampled point, in transcript order.
144pub type PointSchedule = Vec<OpeningRequest>;
145
146/// Description of a table used to build randomized stacked-sumcheck witnesses.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct TableSpec {
149    /// Logical table shape used to generate the witness.
150    ///
151    /// The committed shape may be padded later if the table has fewer variables
152    /// than the first folding round consumes.
153    shape: TableShape,
154    /// Opening schedule local to this table.
155    point_schedule: PointSchedule,
156}
157
158impl TableSpec {
159    /// Builds a table spec from a shape and point-local opening schedule.
160    ///
161    /// # Panics
162    ///
163    /// - Every scheduled polynomial index must be less than the table width.
164    /// - A column may not repeat within one side of a batch.
165    pub fn new(shape: TableShape, point_schedule: PointSchedule) -> Self {
166        // Invariant: every requested column must address a real column of the table.
167        // Both sides of each batch index into the same set of table columns,
168        // so direct and successor-view indices are validated against one width.
169        assert!(point_schedule.iter().all(|batch| {
170            batch
171                .current()
172                .iter()
173                .chain(batch.next())
174                .all(|&poly_idx| poly_idx < shape.width())
175        }));
176        // Invariant: a column appears at most once per side of a batch.
177        // Opening one column on both sides is intended.
178        // Repeating it within one side only burns a challenge power on a duplicate claim.
179        let side_has_no_repeat = |cols: &[usize]| {
180            cols.iter()
181                .enumerate()
182                .all(|(i, col)| !cols[..i].contains(col))
183        };
184        assert!(
185            point_schedule.iter().all(
186                |batch| side_has_no_repeat(batch.current()) && side_has_no_repeat(batch.next())
187            ),
188            "an opening batch must not repeat a column within its current or next side"
189        );
190        Self {
191            shape,
192            point_schedule,
193        }
194    }
195
196    /// Returns the logical table shape.
197    pub const fn shape(&self) -> &TableShape {
198        &self.shape
199    }
200
201    /// Returns the point-local opening schedule.
202    pub const fn point_schedule(&self) -> &PointSchedule {
203        &self.point_schedule
204    }
205
206    /// Pads this table shape to at least `min_num_variables`.
207    pub const fn pad_to_min_num_variables(&mut self, min_num_variables: usize) {
208        if self.shape.num_variables() < min_num_variables {
209            self.shape = TableShape::new(min_num_variables, self.shape.width());
210        }
211    }
212}
213
214/// Public protocol describing committed tables and their opening schedule.
215///
216/// This is the shape agreement between prover and verifier. The prover must
217/// commit to a witness whose table shapes match this protocol, and later opens
218/// exactly the point-local column batches listed here.
219#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct OpeningProtocol(Vec<TableSpec>);
221
222impl OpeningProtocol {
223    /// Builds an opening protocol from table specs.
224    pub const fn new(tables: Vec<TableSpec>) -> Self {
225        Self(tables)
226    }
227
228    /// Returns the table shapes in protocol order.
229    pub fn table_shapes(&self) -> Vec<TableShape> {
230        self.0.iter().map(|table| *table.shape()).collect()
231    }
232
233    /// Pads every table shape to at least `min_num_variables`.
234    ///
235    /// The column index lists in each schedule are unchanged.
236    /// Padding only appends zero rows to the committed table.
237    ///
238    /// The successor view is always taken over the padded space.
239    /// Padding therefore moves the repeated boundary row into the zero pad.
240    /// The last real row's successor then reads a pad row instead of repeating itself.
241    /// A successor opening after padding is a different polynomial identity than over the unpadded table.
242    /// Prover and verifier still agree, since both work over the padded space.
243    pub fn pad_to_min_num_variables(mut self, min_num_variables: usize) -> Self {
244        self.0
245            .iter_mut()
246            .for_each(|table| table.pad_to_min_num_variables(min_num_variables));
247        self
248    }
249
250    /// Returns the total number of point-local opening batches.
251    pub fn num_openings(&self) -> usize {
252        self.0
253            .iter()
254            .map(|table| table.point_schedule().len())
255            .sum()
256    }
257
258    /// Iterates over all opening batches in transcript order.
259    pub fn iter_openings(&self) -> impl Iterator<Item = (usize, &OpeningRequest)> {
260        // Walk tables in protocol order, then each table's points in schedule order.
261        // The flat stream pairs every request with the index of its owning table.
262        //
263        //     table 0: [req, req]   table 1: [req]
264        //          -> (0, req), (0, req), (1, req)
265        self.0.iter().enumerate().flat_map(|(table_idx, table)| {
266            table
267                .point_schedule()
268                .iter()
269                .map(move |batch| (table_idx, batch))
270        })
271    }
272
273    /// Entries across every batch, or `None` when the count overflows `usize`.
274    pub fn checked_num_claims(&self) -> Option<usize> {
275        self.iter_openings()
276            .try_fold(0usize, |count, (_, batch)| count.checked_add(batch.len()))
277    }
278
279    /// Cells across every table, `2^k` rows times width each, or `None` on overflow.
280    pub fn checked_num_cells(&self) -> Option<usize> {
281        self.0.iter().try_fold(0usize, |total, table| {
282            let shape = table.shape();
283            let rows = 1usize.checked_shl(shape.num_variables().try_into().ok()?)?;
284            total.checked_add(rows.checked_mul(shape.width())?)
285        })
286    }
287
288    /// One point per batch, each with its table's arity.
289    ///
290    /// The count is checked first, then batches in transcript order.
291    ///
292    /// # Errors
293    ///
294    /// The count mismatch, or else the first batch whose point has the wrong arity.
295    pub fn check_points<EF: Field>(
296        &self,
297        points: &[Point<EF>],
298    ) -> Result<(), OpeningPointMismatch> {
299        let expected = self.num_openings();
300        if points.len() != expected {
301            return Err(OpeningPointMismatch::Count {
302                expected,
303                actual: points.len(),
304            });
305        }
306        for ((table, _), point) in self.iter_openings().zip(points) {
307            let expected = self.0[table].shape().num_variables();
308            if point.num_variables() != expected {
309                return Err(OpeningPointMismatch::Arity {
310                    table,
311                    expected,
312                    actual: point.num_variables(),
313                });
314            }
315        }
316        Ok(())
317    }
318}
319
320/// Why a list of opening points does not fit an opening protocol.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
322pub enum OpeningPointMismatch {
323    /// One point per opening batch is required.
324    #[error("{actual} points against {expected} opening batches")]
325    Count {
326        /// Opening batches the protocol names.
327        expected: usize,
328        /// Points supplied.
329        actual: usize,
330    },
331    /// A point does not name the variables of the table its batch opens.
332    #[error("the point for table {table} names {actual} variables, expected {expected}")]
333    Arity {
334        /// Table the batch opens.
335        table: usize,
336        /// Variables of that table.
337        expected: usize,
338        /// Variables the point names.
339        actual: usize,
340    },
341}
342
343#[cfg(test)]
344mod tests {
345    use alloc::vec;
346
347    use p3_baby_bear::BabyBear;
348    use p3_field::PrimeCharacteristicRing;
349
350    use super::*;
351
352    // Single-table protocol: arity 3, two columns, two opening points.
353    //
354    //     point 0: direct columns {0, 1}
355    //     point 1: direct column  {0}
356    //
357    // Total opening count is 2; the two batches stream in transcript order
358    // against table index 0.
359    fn single_table_protocol() -> OpeningProtocol {
360        OpeningProtocol::new(vec![TableSpec::new(
361            TableShape::new(3, 2),
362            vec![
363                OpeningBatch::new(vec![0, 1], Vec::new()),
364                OpeningBatch::new(vec![0], Vec::new()),
365            ],
366        )])
367    }
368
369    // Two-table protocol with distinct shapes and schedules.
370    //
371    //     table 0: arity 3, two cols.   one point, direct {0, 1}
372    //     table 1: arity 4, three cols. point a direct {0, 2}; point b direct {1}
373    //
374    // Total opening count is 3; the stream is
375    // (0, direct [0, 1]), (1, direct [0, 2]), (1, direct [1]).
376    fn two_table_protocol() -> OpeningProtocol {
377        OpeningProtocol::new(vec![
378            TableSpec::new(
379                TableShape::new(3, 2),
380                vec![OpeningBatch::new(vec![0, 1], Vec::new())],
381            ),
382            TableSpec::new(
383                TableShape::new(4, 3),
384                vec![
385                    OpeningBatch::new(vec![0, 2], Vec::new()),
386                    OpeningBatch::new(vec![1], Vec::new()),
387                ],
388            ),
389        ])
390    }
391
392    #[test]
393    fn opening_protocol_table_shapes_returns_shapes_in_protocol_order() {
394        // Invariant:
395        //     table_shapes() returns one shape per table spec, in the order
396        //     they were handed to the constructor.
397        //
398        // Fixture state:
399        //     two-table protocol with shapes (3, 2) and (4, 3).
400        let protocol = two_table_protocol();
401
402        let shapes = protocol.table_shapes();
403
404        assert_eq!(shapes, vec![TableShape::new(3, 2), TableShape::new(4, 3)],);
405    }
406
407    #[test]
408    fn opening_protocol_num_openings_sums_per_table_schedules() {
409        // Invariant:
410        //     num_openings() equals the sum of point-schedule lengths across
411        //     every table.
412        //
413        // Fixture state:
414        //     table 0: 1 schedule entry.
415        //     table 1: 2 schedule entries.
416        //     Expected total: 3.
417        let protocol = two_table_protocol();
418        assert_eq!(protocol.num_openings(), 3);
419
420        // Edge case: empty protocol → no openings.
421        let empty = OpeningProtocol::new(vec![]);
422        assert_eq!(empty.num_openings(), 0);
423    }
424
425    #[test]
426    fn opening_protocol_iter_openings_yields_batches_in_transcript_order() {
427        // Invariant:
428        //     The opening stream walks tables in protocol order, then each
429        //     table's points in schedule order, pairing every batch with the
430        //     index of its owning table.
431        //
432        // Fixture state:
433        //     two-table protocol; every batch has direct columns and an empty
434        //     successor-view side.
435        //
436        // Each tuple is (table index, direct columns, successor-view columns).
437        let protocol = two_table_protocol();
438
439        let collected: Vec<(usize, Vec<usize>, Vec<usize>)> = protocol
440            .iter_openings()
441            .map(|(table_idx, batch)| (table_idx, batch.current().to_vec(), batch.next().to_vec()))
442            .collect();
443
444        assert_eq!(
445            collected,
446            vec![
447                (0, vec![0, 1], vec![]),
448                (1, vec![0, 2], vec![]),
449                (1, vec![1], vec![]),
450            ],
451        );
452    }
453
454    #[test]
455    fn opening_protocol_pad_to_min_num_variables_grows_small_tables() {
456        // Invariant:
457        //     pad_to_min_num_variables raises every table arity below the
458        //     given floor while leaving widths and the opening schedule
459        //     untouched.
460        //
461        // Fixture state:
462        //     table 0: arity 3, two cols.
463        //     table 1: arity 4, three cols.
464        //     Floor: 5 → both tables widen to arity 5.
465        let padded = two_table_protocol().pad_to_min_num_variables(5);
466
467        let shapes = padded.table_shapes();
468        assert_eq!(shapes[0], TableShape::new(5, 2));
469        assert_eq!(shapes[1], TableShape::new(5, 3));
470        // Schedules survive the pad untouched.
471        assert_eq!(padded.num_openings(), 3);
472    }
473
474    #[test]
475    fn opening_protocol_pad_to_min_num_variables_leaves_large_tables_alone() {
476        // Invariant:
477        //     pad_to_min_num_variables is a one-way clamp: tables already at
478        //     or above the floor are not modified.
479        //
480        // Fixture state:
481        //     single table of arity 3; floor 2 → no change.
482        let original = single_table_protocol();
483        let original_shapes = original.table_shapes();
484
485        let padded = original.pad_to_min_num_variables(2);
486
487        assert_eq!(padded.table_shapes(), original_shapes);
488    }
489
490    #[test]
491    fn table_spec_accessors_forward_constructor_arguments() {
492        // Invariant:
493        //     A spec stores the shape and the schedule verbatim; the
494        //     accessors return references to the stored data.
495        //
496        // Fixture state:
497        //     shape (3, 2), schedule [[0, 1], [0]].
498        let shape = TableShape::new(3, 2);
499        let schedule = vec![
500            OpeningBatch::new(vec![0, 1], Vec::new()),
501            OpeningBatch::new(vec![0], Vec::new()),
502        ];
503        let spec = TableSpec::new(shape, schedule.clone());
504
505        assert_eq!(*spec.shape(), shape);
506        assert_eq!(spec.point_schedule(), &schedule);
507    }
508
509    #[test]
510    fn table_spec_accepts_mixed_current_next_batches() {
511        // Invariant:
512        //     One sampled point may carry both direct openings and
513        //     successor-view openings.
514        //     The batch length is the sum of the two side lengths.
515        //
516        // Fixture state:
517        //     width 3, two points.
518        //
519        //     point 0: direct {0, 2}, successor-view {1}  -> len 3
520        //     point 1: direct {},     successor-view {2}  -> len 1
521        let spec = TableSpec::new(
522            TableShape::new(3, 3),
523            vec![
524                OpeningBatch::new(vec![0, 2], vec![1]),
525                OpeningBatch::new(Vec::new(), vec![2]),
526            ],
527        );
528
529        let batches = spec.point_schedule();
530        assert_eq!(batches.len(), 2);
531        assert_eq!(batches[0].current(), &[0, 2]);
532        assert_eq!(batches[0].next(), &[1]);
533        assert_eq!(batches[0].len(), 3);
534        assert_eq!(batches[1].current(), &[] as &[usize]);
535        assert_eq!(batches[1].next(), &[2]);
536        assert_eq!(batches[1].len(), 1);
537    }
538
539    #[test]
540    #[should_panic]
541    fn table_spec_new_panics_on_out_of_range_poly_idx() {
542        // Invariant:
543        //     A schedule that addresses a column past the table width is
544        //     rejected at construction time, not deferred to use.
545        //
546        // Fixture state:
547        //     width = 2, schedule references column 2 (only 0 and 1 are valid).
548        let _ = TableSpec::new(
549            TableShape::new(3, 2),
550            vec![OpeningBatch::new(vec![2], Vec::new())],
551        );
552    }
553
554    #[test]
555    #[should_panic]
556    fn table_spec_new_panics_on_out_of_range_next_poly_idx() {
557        // Invariant:
558        //     The successor-view side is validated against the table width too,
559        //     not just the direct side.
560        //
561        // Fixture state:
562        //     width = 2; successor-view names column 2 (only 0 and 1 are valid).
563        let _ = TableSpec::new(
564            TableShape::new(3, 2),
565            vec![OpeningBatch::new(vec![0], vec![2])],
566        );
567    }
568
569    #[test]
570    #[should_panic]
571    fn table_spec_new_panics_on_duplicate_column_in_one_side() {
572        // Invariant:
573        //     A column may not repeat within one side of a batch.
574        //
575        // Fixture state:
576        //     width = 2; the direct side names column 0 twice.
577        let _ = TableSpec::new(
578            TableShape::new(3, 2),
579            vec![OpeningBatch::new(vec![0, 0], Vec::new())],
580        );
581    }
582
583    #[test]
584    fn table_spec_new_accepts_same_column_across_both_sides() {
585        // Invariant:
586        //     Opening one column on both sides (direct and successor-view) is
587        //     intended and must be accepted; only within-side repeats are rejected.
588        let spec = TableSpec::new(
589            TableShape::new(3, 2),
590            vec![OpeningBatch::new(vec![0], vec![0])],
591        );
592        assert_eq!(spec.point_schedule().len(), 1);
593    }
594
595    #[test]
596    #[should_panic]
597    fn opening_batch_new_panics_on_empty_batch() {
598        // Invariant:
599        //     A batch with no columns on either side is rejected at construction.
600        //     Such a batch would consume a transcript point yet open nothing.
601        let _: OpeningBatch<usize> = OpeningBatch::new(vec![], vec![]);
602    }
603
604    #[test]
605    fn table_spec_pad_to_min_num_variables_grows_small_arity() {
606        // Invariant:
607        //     The in-place padder raises the table's arity but never lowers it,
608        //     and never touches the column count.
609        //
610        // Fixture state:
611        //     start arity 2, width 3; pad to floor 4 → arity becomes 4.
612        let mut spec = TableSpec::new(
613            TableShape::new(2, 3),
614            vec![OpeningBatch::new(vec![0], Vec::new())],
615        );
616
617        spec.pad_to_min_num_variables(4);
618
619        assert_eq!(spec.shape().num_variables(), 4);
620        assert_eq!(spec.shape().width(), 3);
621    }
622
623    #[test]
624    fn table_spec_pad_to_min_num_variables_leaves_larger_arity_alone() {
625        // Invariant:
626        //     A spec already wider than the floor is left unchanged.
627        //
628        // Fixture state:
629        //     start arity 5, width 1; pad to floor 3 → no change.
630        let mut spec = TableSpec::new(
631            TableShape::new(5, 1),
632            vec![OpeningBatch::new(vec![0], Vec::new())],
633        );
634
635        spec.pad_to_min_num_variables(3);
636
637        assert_eq!(spec.shape().num_variables(), 5);
638        assert_eq!(spec.shape().width(), 1);
639    }
640
641    // One zero point per requested arity, in the order given.
642    fn points(arities: &[usize]) -> Vec<Point<BabyBear>> {
643        arities
644            .iter()
645            .map(|&num_variables| Point::new(vec![BabyBear::ZERO; num_variables]))
646            .collect()
647    }
648
649    #[test]
650    fn opening_protocol_counts_claims_and_cells() {
651        // Invariant:
652        //     Claims are the batch entries summed over every batch.
653        //     Cells are rows times width summed over every table.
654        //
655        // Fixture state:
656        //     table 0: 2^3 rows, 2 cols. one batch of 2 entries.
657        //     table 1: 2^4 rows, 3 cols. batches of 2 and 1 entries.
658        //     Expected: claims 2 + 2 + 1 = 5, cells 8·2 + 16·3 = 64.
659        let protocol = two_table_protocol();
660        assert_eq!(protocol.checked_num_claims(), Some(5));
661        assert_eq!(protocol.checked_num_cells(), Some(64));
662
663        // Edge case: empty protocol → nothing to count.
664        let empty = OpeningProtocol::new(vec![]);
665        assert_eq!(empty.checked_num_claims(), Some(0));
666        assert_eq!(empty.checked_num_cells(), Some(0));
667    }
668
669    #[test]
670    fn checked_num_cells_reports_an_overflow() {
671        // Invariant:
672        //     A cell count past usize is reported, never wrapped.
673        //
674        // Fixture state:
675        //     one table of the largest representable arity and 3 columns.
676        //     Its row count fits in usize, its cell count does not.
677        let protocol = OpeningProtocol::new(vec![TableSpec::new(
678            TableShape::new(usize::BITS as usize - 1, 3),
679            vec![OpeningBatch::new(vec![0], Vec::new())],
680        )]);
681
682        assert_eq!(protocol.checked_num_cells(), None);
683    }
684
685    #[test]
686    fn check_points_reports_the_count_before_any_arity() {
687        // Invariant:
688        //     A wrong point count is reported even when every point also has the wrong arity.
689        //
690        // Fixture state:
691        //     two-table protocol, three batches.
692        let protocol = two_table_protocol();
693
694        assert_eq!(
695            protocol.check_points(&points(&[1, 1])),
696            Err(OpeningPointMismatch::Count {
697                expected: 3,
698                actual: 2,
699            }),
700        );
701        assert_eq!(
702            protocol.check_points(&points(&[1, 1, 1, 1])),
703            Err(OpeningPointMismatch::Count {
704                expected: 3,
705                actual: 4,
706            }),
707        );
708    }
709
710    #[test]
711    fn check_points_reports_the_first_batch_with_the_wrong_arity() {
712        // Invariant:
713        //     Batches are checked in transcript order and the first mismatch wins.
714        //
715        // Fixture state:
716        //     two-table protocol; batches open tables 0, 1, 1 (arities 3, 4, 4).
717        //     Points of arity 3, 3, 5: batches 2 and 3 are both wrong.
718        let protocol = two_table_protocol();
719
720        assert_eq!(
721            protocol.check_points(&points(&[3, 3, 5])),
722            Err(OpeningPointMismatch::Arity {
723                table: 1,
724                expected: 4,
725                actual: 3,
726            }),
727        );
728    }
729
730    #[test]
731    fn check_points_accepts_matching_points() {
732        // Invariant:
733        //     One point per batch, each of its table's arity, is accepted.
734        //
735        // Fixture state:
736        //     two-table protocol; points of arity 3, 4, 4.
737        let protocol = two_table_protocol();
738
739        assert_eq!(protocol.check_points(&points(&[3, 4, 4])), Ok(()));
740
741        // Edge case: empty protocol takes no points.
742        let empty = OpeningProtocol::new(vec![]);
743        assert_eq!(empty.check_points::<BabyBear>(&[]), Ok(()));
744    }
745}