Skip to main content

p3_commit/
periodic.rs

1//! Periodic column evaluation support.
2//!
3//! Periodic columns are columns whose values repeat with a period that divides the trace length.
4//! This module provides the `PeriodicEvaluator` trait for evaluating periodic polynomials
5//! in a domain-agnostic way (supporting both two-adic and circle STARKs).
6//!
7//! ## Power-of-Two Requirement
8//!
9//! **All period lengths must be powers of two.** This is because:
10//! - The trace domain is a multiplicative/additive group of order `n` (a power of 2)
11//! - The periodic subdomain must be a subgroup of order `p`
12//! - For `p` to divide `n` as group orders, `p` must also be a power of 2
13//!
14//! ## Mathematical Background
15//!
16//! A periodic column with period `p` and trace length `n` repeats every `p` rows:
17//! `col[i] = col[i + p]` for all `i`.
18//!
19//! **The problem**: We have a polynomial `P` of degree `n-1` over the trace domain `H`,
20//! but it only takes `p` distinct values. Can we work with a smaller polynomial instead?
21//!
22//! **Key observation**: We want `P(ω^i) = P(ω^{i+p})` for all `i`. So we need a map
23//! `π: H → ?` that identifies points `p` apart: `π(ω^i) = π(ω^{i+p})`, i.e., `π` must
24//! be constant on cosets of the subgroup `⟨ω^p⟩` of order `n/p`.
25//!
26//! **Finding π**: For cyclic groups, raising to the power `k` gives a homomorphism with
27//! kernel of size `k`. Since we need `ker(π) = ⟨ω^p⟩` of order `n/p`, we set `π(x) = x^(n/p)`.
28//! Indeed, `π(ω^{i+p}) = ω^{(i+p)·n/p} = ω^{i·n/p} · ω^n = π(ω^i)` since `ω^n = 1`.
29//!
30//! **Where π lands**: The image of `π` is `H_p = {1, ω^(n/p), ω^(2n/p), ...}`, a subgroup
31//! of order `p`. Now we can factor `P = Q ∘ π` where `Q: H_p → F` is a degree `p-1`
32//! polynomial interpolating the `p` periodic values.
33//!
34//! **Group-theoretic view**: `π: H → H_p` is a surjective homomorphism with kernel of
35//! order `n/p`. By the first isomorphism theorem, `H/ker(π) ≅ H_p`. The periodic column
36//! is constant on cosets of `ker(π)`, so it factors through `π`.
37//!
38//! **For Circle STARKs**: The same idea applies with `π(P) = (n/p)·P` (repeated doubling)
39//! instead of exponentiation.
40//!
41//! **Evaluating at an out-of-domain point `ζ`**:
42//! 1. Compute `π(ζ)` to get a point in `H_p`
43//! 2. Evaluate `Q(π(ζ))` using Lagrange interpolation over `H_p`
44//!
45//! ## Memory-Efficient Storage
46//!
47//! Instead of materializing the full LDE-sized table (which would be wasteful for small periods),
48//! we store only `max_period × blowup` rows in a [`PeriodicLdeTable`]. All periodic columns are
49//! padded to the maximum period, creating a rectangular matrix that can be efficiently accessed
50//! with modular indexing in the constraint evaluation hot loop.
51
52use alloc::vec::Vec;
53
54use p3_field::{ExtensionField, Field};
55use p3_matrix::dense::RowMajorMatrix;
56use thiserror::Error;
57
58use crate::PolynomialSpace;
59
60/// Why a declared periodic column cannot be laid over a trace of a given height.
61#[derive(Clone, Debug, PartialEq, Eq, Error)]
62#[non_exhaustive]
63pub enum PeriodicColumnShapeError {
64    /// A length with no subgroup of that order to interpolate over.
65    #[error("periodic column {index} has length {length}, which is not a power of two")]
66    LengthNotPowerOfTwo {
67        /// Position of the offending column in the declared order.
68        index: usize,
69        /// How many values the column lists.
70        length: usize,
71    },
72    /// A length that cannot tile the rows it has to cover.
73    #[error(
74        "periodic column {index} has length {length}, which does not divide the trace height {height}"
75    )]
76    LengthNotDividingHeight {
77        /// Position of the offending column in the declared order.
78        index: usize,
79        /// How many values the column lists.
80        length: usize,
81        /// How many rows the column has to cover.
82        height: usize,
83    },
84}
85
86/// Periodic columns screened against the rows they have to cover.
87///
88/// A column of length `p` holds the evaluations of one polynomial over a subgroup of order `p`.
89///
90/// - Such a subgroup exists only when `p` is a power of two.
91/// - It tiles the rows only when `p` divides the height.
92///
93/// ```text
94///     height 8,  length 2   [0,1][0,1][0,1][0,1]       tiles
95///     height 8,  length 16  [0,1,...,7|8,...,15]       truncated
96///     height 12, length 8   [0,...,7][0,1,2,3|4,...]   partial repeat
97/// ```
98///
99/// Row lookups wrap with `row mod p`, so an ill-shaped column still yields a value on every row.
100/// Reading rows alone never reveals the mistake.
101///
102/// Evaluation is where it breaks.
103/// Every path divides the height by the length and takes a base-two logarithm of the quotient.
104/// Neither step means anything for a shape the rule rejects.
105///
106/// Holding this view is the evidence that the rule was applied.
107#[derive(Debug)]
108pub struct PeriodicColumns<'a, F> {
109    /// One period of values per declared column, in declaration order.
110    columns: &'a [Vec<F>],
111    /// Rows the columns were screened against.
112    height: usize,
113}
114
115// A shared slice and a row count are cheap to copy whatever the cell type is.
116// Deriving would tie that to the cell type for no reason.
117impl<F> Clone for PeriodicColumns<'_, F> {
118    fn clone(&self) -> Self {
119        *self
120    }
121}
122
123impl<F> Copy for PeriodicColumns<'_, F> {}
124
125impl<'a, F> PeriodicColumns<'a, F> {
126    /// Screen the declared columns against the rows they have to cover.
127    ///
128    /// # Errors
129    ///
130    /// - A length that is not a power of two, zero included.
131    /// - A length that does not divide the height.
132    pub fn new(columns: &'a [Vec<F>], height: usize) -> Result<Self, PeriodicColumnShapeError> {
133        for (index, column) in columns.iter().enumerate() {
134            // The length is how many values the column lists before repeating.
135            let length = column.len();
136
137            // Powers of two are the orders for which a two-adic subgroup exists.
138            // Zero fails here, ahead of the row lookup that would divide by it.
139            if !length.is_power_of_two() {
140                return Err(PeriodicColumnShapeError::LengthNotPowerOfTwo { index, length });
141            }
142
143            // Divisibility lands every repetition on a whole copy of that subgroup.
144            if !height.is_multiple_of(length) {
145                return Err(PeriodicColumnShapeError::LengthNotDividingHeight {
146                    index,
147                    length,
148                    height,
149                });
150            }
151        }
152
153        Ok(Self { columns, height })
154    }
155
156    /// The screened columns, in declaration order.
157    pub const fn as_slice(&self) -> &'a [Vec<F>] {
158        self.columns
159    }
160
161    /// Rows the columns were screened against.
162    pub const fn height(&self) -> usize {
163        self.height
164    }
165
166    /// How many columns are declared.
167    pub const fn len(&self) -> usize {
168        self.columns.len()
169    }
170
171    /// True when the declaration is empty.
172    pub const fn is_empty(&self) -> bool {
173        self.columns.is_empty()
174    }
175
176    /// Longest declared period, absent when the declaration is empty.
177    ///
178    /// Every period divides the height, so the longest one divides it too.
179    /// Padding every column up to it yields one rectangular table over a single subgroup.
180    pub fn max_period(&self) -> Option<usize> {
181        self.columns.iter().map(Vec::len).max()
182    }
183}
184
185/// Compact storage for periodic column values on the LDE domain.
186///
187/// Instead of materializing the full LDE-sized table, stores only `extended_height` rows
188/// (where `extended_height = max_period × blowup`) and uses modular indexing to access values.
189///
190/// All periodic columns are padded to the maximum period before extrapolation, creating a
191/// rectangular matrix for cache-friendly row-wise access.
192///
193/// # Invariants
194///
195/// - All periods must be powers of 2 (see module-level documentation)
196/// - Height is always `max_period × blowup` (both powers of 2, so height is power of 2)
197#[derive(Clone, Debug)]
198pub struct PeriodicLdeTable<F> {
199    /// Values in row-major form: height = extended_height, width = num_columns.
200    /// Empty if there are no periodic columns.
201    values: RowMajorMatrix<F>,
202    /// Cached `values.values.len() / values.width` (`0` if `values.width == 0`).
203    /// Guaranteed to be a power of two, so `get` can index with `& (height - 1)`
204    /// instead of `%`.
205    height: usize,
206}
207
208impl<F: Clone + Send + Sync> PeriodicLdeTable<F> {
209    /// Create a new periodic LDE table from extrapolated values.
210    ///
211    /// The matrix should have height = `max_period × blowup` and width = `num_periodic_columns`.
212    pub const fn new(values: RowMajorMatrix<F>) -> Self {
213        let height = match values.values.len().checked_div(values.width) {
214            Some(h) => h,
215            None => 0,
216        };
217        debug_assert!(
218            height == 0 || height.is_power_of_two(),
219            "PeriodicLdeTable height must be a power of two for bitmask indexing"
220        );
221        Self { values, height }
222    }
223
224    /// Create an empty table (for AIRs without periodic columns).
225    pub fn empty() -> Self {
226        Self {
227            values: RowMajorMatrix::new(Vec::new(), 0),
228            height: 0,
229        }
230    }
231
232    /// Returns true if there are no periodic columns.
233    pub const fn is_empty(&self) -> bool {
234        self.values.values.is_empty()
235    }
236
237    /// Number of periodic columns.
238    pub const fn width(&self) -> usize {
239        self.values.width
240    }
241
242    /// Height of the compact table (max_period × blowup).
243    pub const fn height(&self) -> usize {
244        self.height
245    }
246
247    /// Number of distinct packed row groups when the LDE domain is read in groups of
248    /// `pack_width` consecutive indices, group `g` starting at `g * pack_width`.
249    ///
250    /// [`get`](Self::get) reduces indices modulo `height`, and the group starts
251    /// `g * pack_width mod height` repeat with period `height / gcd(height, pack_width)`.
252    /// Group `g` therefore reads the same values as group `g % packed_group_period(pack_width)`.
253    ///
254    /// `pack_width` need not be a power of two or divide `height`. Returns `0` for an
255    /// empty table.
256    pub const fn packed_group_period(&self, pack_width: usize) -> usize {
257        debug_assert!(pack_width > 0, "pack_width must be nonzero");
258        // `height` is a power of two, so `gcd(height, pack_width)` is the largest power
259        // of two dividing `pack_width`, capped at `height`.
260        let log_gcd = if self.height.trailing_zeros() < pack_width.trailing_zeros() {
261            self.height.trailing_zeros()
262        } else {
263            pack_width.trailing_zeros()
264        };
265        self.height >> log_gcd
266    }
267
268    /// Get a specific periodic column value for a given LDE index.
269    #[inline]
270    pub fn get(&self, lde_idx: usize, col_idx: usize) -> &F {
271        let height = self.height;
272        debug_assert!(height > 0, "cannot index into empty periodic table");
273        let row_idx = lde_idx & (height - 1);
274        &self.values.values[row_idx * self.values.width + col_idx]
275    }
276}
277
278/// Evaluates periodic polynomials for a given domain system.
279///
280/// Periodic columns are defined by their values over one period. This trait
281/// handles interpolation and evaluation, abstracting over the domain-specific
282/// math (two-adic multiplicative groups vs circle groups).
283///
284/// # Power-of-Two Requirement
285///
286/// **All period lengths must be powers of two.** This ensures the periodic subdomain
287/// is a valid subgroup of the trace domain. See module-level documentation for details.
288///
289/// # Type Parameters
290/// - `F`: The base field type
291/// - `D`: The polynomial space / domain type
292pub trait PeriodicEvaluator<F: Field, D: PolynomialSpace<Val = F>> {
293    /// Evaluate all periodic columns on the LDE domain, returning a compact table.
294    ///
295    /// This is used by the prover to compute periodic column values on the
296    /// low-degree extension domain for constraint evaluation.
297    ///
298    /// The returned table stores only `max_period × blowup` rows. All columns are
299    /// padded to the maximum period before extrapolation, creating a rectangular
300    /// matrix for efficient row-wise access with modular indexing.
301    ///
302    /// # Arguments
303    /// * `periodic_table` - Slice of periodic columns, each containing one period of values.
304    ///   The length of each inner `Vec` is the period of that column (must be a power of 2).
305    /// * `trace_domain` - The original trace domain
306    /// * `lde_domain` - The low-degree extension domain
307    ///
308    /// # Returns
309    /// A [`PeriodicLdeTable`] with height = `max_period × blowup` and width = number of columns.
310    fn eval_on_lde(
311        periodic_table: &[Vec<F>],
312        trace_domain: &D,
313        lde_domain: &D,
314    ) -> PeriodicLdeTable<F>;
315
316    /// Evaluate all periodic columns at a single point (for verification).
317    ///
318    /// This is used by the verifier to compute periodic column values at
319    /// query points during constraint verification.
320    ///
321    /// # Arguments
322    /// * `periodic_table` - Slice of periodic columns. Each column's length (period)
323    ///   must be a power of 2.
324    /// * `trace_domain` - The original trace domain
325    /// * `point` - The query point (in extension field)
326    ///
327    /// # Returns
328    /// `Vec<EF>` containing the evaluation of each periodic column at `point`
329    fn eval_at_point<EF: ExtensionField<F>>(
330        periodic_table: &[Vec<F>],
331        trace_domain: &D,
332        point: EF,
333    ) -> Vec<EF>;
334}
335
336/// Unit type implements `PeriodicEvaluator` as a no-op.
337///
338/// This is used internally by `prove` and `verify` for AIRs without periodic columns.
339/// Panics if any periodic columns are present.
340impl<F: Field, D: PolynomialSpace<Val = F>> PeriodicEvaluator<F, D> for () {
341    fn eval_on_lde(
342        periodic_table: &[Vec<F>],
343        _trace_domain: &D,
344        _lde_domain: &D,
345    ) -> PeriodicLdeTable<F> {
346        assert!(
347            periodic_table.is_empty(),
348            "AIR has periodic columns but no PeriodicEvaluator was specified. \
349             Use prove_with_periodic or verify_with_periodic with TwoAdicPeriodicEvaluator \
350             or CirclePeriodicEvaluator."
351        );
352        PeriodicLdeTable::empty()
353    }
354
355    fn eval_at_point<EF: ExtensionField<F>>(
356        periodic_table: &[Vec<F>],
357        _trace_domain: &D,
358        _point: EF,
359    ) -> Vec<EF> {
360        assert!(
361            periodic_table.is_empty(),
362            "AIR has periodic columns but no PeriodicEvaluator was specified. \
363             Use prove_with_periodic or verify_with_periodic with TwoAdicPeriodicEvaluator \
364             or CirclePeriodicEvaluator."
365        );
366        Vec::new()
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use alloc::vec;
373
374    use super::*;
375
376    // An AIR without periodic columns imposes nothing on the height.
377    // Heights that no column could ever divide still pass.
378    #[test]
379    fn no_columns_accepts_any_height() {
380        for height in [0, 1, 3, 7, 12] {
381            let screened = PeriodicColumns::<u8>::new(&[], height).unwrap();
382
383            assert!(screened.is_empty());
384            assert_eq!(screened.len(), 0);
385            assert_eq!(screened.height(), height);
386            assert_eq!(screened.max_period(), None);
387        }
388    }
389
390    // Fixture state: 8 rows, every power-of-two length up to the height.
391    //
392    //     length 1 -> 8 repeats, length 2 -> 4, length 4 -> 2, length 8 -> 1
393    #[test]
394    fn every_power_of_two_divisor_of_the_height_is_accepted() {
395        for length in [1, 2, 4, 8] {
396            let columns = vec![vec![0u8; length]];
397            let screened = PeriodicColumns::new(&columns, 8).unwrap();
398
399            assert_eq!(screened.max_period(), Some(length));
400            assert_eq!(screened.as_slice(), columns.as_slice());
401        }
402    }
403
404    // Padding to the longest period is what makes the columns one rectangular table.
405    //
406    //     lengths [2, 8, 4]  ->  longest 8
407    #[test]
408    fn the_longest_period_is_reported() {
409        let columns = vec![vec![0u8; 2], vec![0u8; 8], vec![0u8; 4]];
410        let screened = PeriodicColumns::new(&columns, 8).unwrap();
411
412        assert_eq!(screened.len(), 3);
413        assert_eq!(screened.max_period(), Some(8));
414    }
415
416    // Three values cannot be the evaluations of a polynomial over a two-adic subgroup.
417    // The report names the column so an AIR with many of them stays diagnosable.
418    #[test]
419    fn non_power_of_two_length_is_rejected() {
420        let columns = vec![vec![0u8; 3]];
421
422        assert_eq!(
423            PeriodicColumns::new(&columns, 8).unwrap_err(),
424            PeriodicColumnShapeError::LengthNotPowerOfTwo {
425                index: 0,
426                length: 3
427            }
428        );
429    }
430
431    // An empty column would make the row lookup divide by zero.
432    // Zero is not a power of two, so it is caught by the same arm.
433    #[test]
434    fn empty_column_is_rejected() {
435        let columns: Vec<Vec<u8>> = vec![vec![]];
436
437        assert_eq!(
438            PeriodicColumns::new(&columns, 8).unwrap_err(),
439            PeriodicColumnShapeError::LengthNotPowerOfTwo {
440                index: 0,
441                length: 0
442            }
443        );
444    }
445
446    // Mutation: 8 values over 12 rows.
447    //
448    //     [0,...,7][0,1,2,3|4,...]  <- the second repeat is cut in half
449    //
450    // Eight fits inside twelve, so a bound that only compares sizes would accept this.
451    // Divisibility is the relation that matters, and it fails.
452    #[test]
453    fn length_that_fits_but_does_not_divide_is_rejected() {
454        let columns = vec![vec![0u8; 8]];
455
456        assert_eq!(
457            PeriodicColumns::new(&columns, 12).unwrap_err(),
458            PeriodicColumnShapeError::LengthNotDividingHeight {
459                index: 0,
460                length: 8,
461                height: 12
462            }
463        );
464    }
465
466    // Columns are screened in declaration order, so the first bad one is the one reported.
467    //
468    //     column 0: length 4  ok
469    //     column 1: length 6  not a power of two  <- reported
470    //     column 2: length 5  never reached
471    #[test]
472    fn the_first_offending_column_is_the_one_reported() {
473        let columns = vec![vec![0u8; 4], vec![0u8; 6], vec![0u8; 5]];
474
475        assert_eq!(
476            PeriodicColumns::new(&columns, 8).unwrap_err(),
477            PeriodicColumnShapeError::LengthNotPowerOfTwo {
478                index: 1,
479                length: 6
480            }
481        );
482    }
483
484    // Both in-repo trace domains have a power-of-two size.
485    // For p = 2^a and n = 2^b, p divides n iff a <= b iff p <= n.
486    // So on such a height, "fits inside" and "divides" are the same predicate.
487    #[test]
488    fn on_a_power_of_two_height_fitting_and_dividing_agree() {
489        for log_height in 0..16 {
490            let height = 1usize << log_height;
491            for log_length in 0..16 {
492                let length = 1usize << log_length;
493                let columns = vec![vec![0u8; length]];
494
495                let fits = length <= height;
496                let divides = PeriodicColumns::new(&columns, height).is_ok();
497
498                assert_eq!(fits, divides, "height {height}, length {length}");
499            }
500        }
501    }
502
503    #[test]
504    fn packed_group_period_matches_modular_indexing() {
505        // (height, pack_width, expected period): widths that are not powers of two, or
506        // that do not divide the height, visit every residue class before repeating.
507        let cases = [
508            (8, 3, 8),
509            (8, 6, 4),
510            (8, 1, 8),
511            (8, 4, 2),
512            (8, 8, 1),
513            (4, 8, 1),
514            (1, 3, 1),
515        ];
516        for (height, pack_width, expected) in cases {
517            let values: Vec<u32> = (0..height).map(|i| i as u32).collect();
518            let table = PeriodicLdeTable::new(RowMajorMatrix::new(values, 1));
519            let period = table.packed_group_period(pack_width);
520            assert_eq!(period, expected, "height {height}, pack_width {pack_width}");
521
522            for group in 0..4 * height {
523                let cached = group % period;
524                for offset in 0..pack_width {
525                    assert_eq!(
526                        table.get(group * pack_width + offset, 0),
527                        table.get(cached * pack_width + offset, 0),
528                        "height {height}, pack_width {pack_width}, group {group}, offset {offset}"
529                    );
530                }
531            }
532        }
533
534        assert_eq!(PeriodicLdeTable::<u32>::empty().packed_group_period(3), 0);
535    }
536
537    #[cfg(debug_assertions)]
538    #[test]
539    #[should_panic(expected = "PeriodicLdeTable height must be a power of two")]
540    fn new_panics_on_non_power_of_two_height() {
541        use alloc::vec;
542
543        use p3_baby_bear::BabyBear;
544        use p3_field::PrimeCharacteristicRing;
545
546        use super::*;
547
548        type F = BabyBear;
549
550        let (a, b, c) = (F::ONE, F::TWO, F::from_u8(3));
551        let _ = PeriodicLdeTable::new(RowMajorMatrix::new(vec![a, b, c], 1));
552    }
553}