Skip to main content

p3_circle/
periodic.rs

1//! Periodic column support for Circle STARKs.
2//!
3//! This module provides `CirclePeriodicEvaluator` for evaluating periodic columns
4//! in Circle STARK proofs. The implementation supports:
5//!
6//! - `eval_on_lde`: Evaluates periodic columns on the LDE domain using CFFT extrapolation.
7//!   All columns are padded to the maximum period, creating a rectangular matrix that
8//!   stores only `max_period × blowup` rows with modular indexing for O(1) lookup.
9//!
10//! - `eval_at_point`: Evaluates periodic columns at arbitrary points using polynomial
11//!   evaluation with repeated doubling projection. This is used by the verifier.
12//!
13//! ## Memory Efficiency
14//!
15//! Instead of materializing the full LDE-sized table, we store only `max_period × blowup`
16//! rows. For a trace of size 2^20 with period-4 columns and blowup 4, this means storing
17//! 16 rows instead of 4M rows per column.
18//!
19//! ## Complexity
20//!
21//! - `eval_on_lde`: O(max_period × blowup × log(max_period × blowup)) for CFFT extrapolation,
22//!   then O(1) per LDE point lookup using modular indexing.
23//!
24//! - `eval_at_point`: O(period) per column using polynomial evaluation.
25//!
26//! Note: The current `eval_at_point` implementation is not optimized for multiple columns
27//! with the same period. The interpolation setup could be shared across columns with the
28//! same period.
29
30use alloc::vec::Vec;
31
32use p3_commit::{PeriodicEvaluator, PeriodicLdeTable, PolynomialSpace};
33use p3_field::ExtensionField;
34use p3_field::extension::ComplexExtendable;
35use p3_matrix::Matrix;
36use p3_matrix::dense::RowMajorMatrix;
37use p3_util::log2_strict_usize;
38
39use crate::CircleEvaluations;
40use crate::domain::CircleDomain;
41
42/// Build the compact periodic LDE table using the circle evaluator.
43///
44/// This is a type-level helper so callers can use concrete `F` without
45/// the compiler struggling to unify `F` with `PolynomialSpace::Val`.
46pub fn build_periodic_lde_table_circle<F: ComplexExtendable>(
47    periodic_table: &[Vec<F>],
48    trace_domain: &CircleDomain<F>,
49    lde_domain: &CircleDomain<F>,
50) -> p3_commit::PeriodicLdeTable<F> {
51    CirclePeriodicEvaluator::eval_on_lde(periodic_table, trace_domain, lde_domain)
52}
53
54/// Evaluates periodic polynomials for Circle STARKs.
55///
56/// For a periodic column with period `p` and trace length `n`, the periodic values
57/// are interpolated on a Circle domain of size `p`. To evaluate at any point:
58/// 1. Interpolate the periodic values on a small Circle domain of size `p`
59/// 2. Project the query point to the periodic subdomain via repeated doubling
60/// 3. Evaluate the polynomial at the projected point
61#[derive(Clone, Copy, Debug, Default)]
62pub struct CirclePeriodicEvaluator;
63
64impl<F: ComplexExtendable> PeriodicEvaluator<F, CircleDomain<F>> for CirclePeriodicEvaluator {
65    fn eval_on_lde(
66        periodic_table: &[Vec<F>],
67        trace_domain: &CircleDomain<F>,
68        lde_domain: &CircleDomain<F>,
69    ) -> PeriodicLdeTable<F> {
70        if periodic_table.is_empty() {
71            return PeriodicLdeTable::empty();
72        }
73
74        let trace_len = trace_domain.size();
75        let log_blowup = lde_domain
76            .log_n
77            .checked_sub(trace_domain.log_n)
78            .expect("LDE domain log_n must be >= trace domain log_n");
79        let blowup = 1usize
80            .checked_shl(log_blowup as u32)
81            .expect("blowup overflow when computing 1 << log_blowup");
82
83        for col in periodic_table {
84            let period = col.len();
85            assert!(
86                period > 0 && period.is_power_of_two(),
87                "periodic column length must be a non-zero power of 2, got {period}",
88            );
89            assert!(
90                trace_len.is_multiple_of(period),
91                "trace domain size ({trace_len}) must be divisible by periodic column length ({period})",
92            );
93        }
94        let max_period = periodic_table.iter().map(|c| c.len()).max().unwrap();
95
96        let log_max_period = log2_strict_usize(max_period);
97        let log_repetitions = log2_strict_usize(trace_len / max_period);
98        let extended_height = max_period
99            .checked_mul(blowup)
100            .expect("extended height overflow when computing max_period * blowup");
101        let num_cols = periodic_table.len();
102        let row_major_capacity = extended_height
103            .checked_mul(num_cols)
104            .expect("row-major periodic table capacity overflow");
105
106        // Compute the shift for the periodic subdomain at max_period.
107        // This aligns the periodic domain with the LDE domain so modular indexing works.
108        let extended_shift = lde_domain.shift.repeated_double(log_repetitions);
109        let extended_log_n = log_max_period
110            .checked_add(log_blowup)
111            .expect("extended periodic domain log size overflow");
112        let extended_periodic_domain = CircleDomain::new(extended_log_n, extended_shift);
113
114        // Process each column: pad to max_period, then extrapolate
115        // Build the result in column-major order first, then transpose to row-major
116        let mut columns: Vec<Vec<F>> = Vec::with_capacity(num_cols);
117
118        for col in periodic_table {
119            let period = col.len();
120
121            // Pad column to max_period by repeating values
122            let padded: Vec<F> = if period == max_period {
123                col.clone()
124            } else {
125                (0..max_period).map(|i| col[i % period]).collect()
126            };
127
128            // Interpolate on the max_period domain
129            let periodic_domain = CircleDomain::standard(log_max_period);
130            let evals = CircleEvaluations::from_natural_order(
131                periodic_domain,
132                RowMajorMatrix::new_col(padded),
133            );
134
135            // Extrapolate to extended_height using CFFT
136            let extended_evals = evals.extrapolate(extended_periodic_domain);
137            let extended_values = extended_evals.to_natural_order().to_row_major_matrix();
138            columns.push(extended_values.values);
139        }
140
141        // Convert from column-major to row-major storage
142        let mut row_major_values = Vec::with_capacity(row_major_capacity);
143        for row_idx in 0..extended_height {
144            for col in &columns {
145                row_major_values.push(col[row_idx]);
146            }
147        }
148
149        PeriodicLdeTable::new(RowMajorMatrix::new(row_major_values, num_cols))
150    }
151
152    fn eval_at_point<EF: ExtensionField<F>>(
153        periodic_table: &[Vec<F>],
154        trace_domain: &CircleDomain<F>,
155        point: EF,
156    ) -> Vec<EF> {
157        let trace_len = trace_domain.size();
158        periodic_table
159            .iter()
160            .map(|col| {
161                let period = col.len();
162                assert!(
163                    period > 0 && period.is_power_of_two(),
164                    "periodic column length must be a non-zero power of 2, got {period}",
165                );
166                assert!(
167                    trace_len.is_multiple_of(period),
168                    "trace domain size ({trace_len}) must be divisible by periodic column length ({period})",
169                );
170                PolynomialSpace::evaluate_periodic_column_at(trace_domain, col, point)
171            })
172            .collect()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use alloc::vec;
179
180    use hashbrown::HashMap;
181    use p3_field::PrimeCharacteristicRing;
182    use p3_field::extension::BinomialExtensionField;
183    use p3_mersenne_31::Mersenne31;
184    use rand::rngs::SmallRng;
185    use rand::{RngExt, SeedableRng};
186
187    use super::*;
188    use crate::point::Point;
189
190    type F = Mersenne31;
191    type EF = BinomialExtensionField<F, 3>;
192
193    #[test]
194    fn test_periodic_eval_consistency_random_points() {
195        // Test that eval_on_lde and eval_at_point define the same polynomial
196        // by checking consistency at random out-of-domain points
197        let log_n = 4;
198        let log_blowup = 1;
199        let trace_domain = CircleDomain::<F>::standard(log_n);
200        let lde_domain = CircleDomain::<F>::standard(log_n + log_blowup);
201        let lde_len = lde_domain.size();
202
203        // Periodic column: [10, 20, 30, 40]
204        let periodic_col = vec![
205            F::from_u32(10),
206            F::from_u32(20),
207            F::from_u32(30),
208            F::from_u32(40),
209        ];
210        let periodic_table = vec![periodic_col];
211
212        // Evaluate on LDE domain
213        let lde_table =
214            CirclePeriodicEvaluator::eval_on_lde(&periodic_table, &trace_domain, &lde_domain);
215
216        assert_eq!(lde_table.width(), 1);
217        // Compact table has height = period * blowup = 4 * 2 = 8
218        assert_eq!(lde_table.height(), 8);
219
220        // Expand compact table to full LDE for interpolation test
221        let full_lde: Vec<F> = (0..lde_len).map(|i| *lde_table.get(i, 0)).collect();
222
223        // Interpolate the LDE result to get a polynomial we can evaluate anywhere
224        let lde_evals =
225            CircleEvaluations::from_natural_order(lde_domain, RowMajorMatrix::new_col(full_lde));
226
227        // Test at random out-of-domain points
228        let mut rng = SmallRng::seed_from_u64(42);
229        for _ in 0..10 {
230            let random_point: EF = rng.random();
231
232            // Evaluate the LDE polynomial at the random point
233            let lde_at_point =
234                lde_evals.evaluate_at_point(Point::from_projective_line(random_point))[0];
235
236            // Evaluate using eval_at_point directly
237            let eval_at_point_result = CirclePeriodicEvaluator::eval_at_point(
238                &periodic_table,
239                &trace_domain,
240                random_point,
241            );
242
243            assert_eq!(
244                lde_at_point, eval_at_point_result[0],
245                "Mismatch at random point: LDE interpolation={:?}, eval_at_point={:?}",
246                lde_at_point, eval_at_point_result[0]
247            );
248        }
249    }
250
251    #[test]
252    fn test_periodic_eval_at_trace_domain_points() {
253        // Test that evaluating the periodic polynomial at trace domain points
254        // gives the expected periodic pattern
255        let log_n = 4; // 16 rows
256        let trace_domain = CircleDomain::<F>::standard(log_n);
257        let trace_len = trace_domain.size();
258        let period = 4;
259
260        // Periodic column: [1, 2, 3, 4]
261        let periodic_col = vec![
262            F::from_u32(1),
263            F::from_u32(2),
264            F::from_u32(3),
265            F::from_u32(4),
266        ];
267        let periodic_table = vec![periodic_col];
268
269        // Evaluate on trace domain (same as LDE with blowup=1)
270        let lde_table =
271            CirclePeriodicEvaluator::eval_on_lde(&periodic_table, &trace_domain, &trace_domain);
272
273        assert_eq!(lde_table.width(), 1);
274        // Compact table has height = period * blowup = 4 * 1 = 4
275        assert_eq!(lde_table.height(), 4);
276
277        // Expand compact table to full trace
278        let full_trace: Vec<F> = (0..trace_len).map(|i| *lde_table.get(i, 0)).collect();
279
280        // The values should follow a periodic pattern with period 4
281        // But the exact mapping depends on Circle domain structure.
282        // Verify that we get exactly 4 distinct values, each appearing 4 times.
283        let mut value_counts = HashMap::new();
284        for &val in &full_trace {
285            *value_counts.entry(val).or_insert(0) += 1;
286        }
287        assert_eq!(
288            value_counts.len(),
289            period,
290            "Expected {} distinct values, got {}",
291            period,
292            value_counts.len()
293        );
294        for (val, count) in &value_counts {
295            assert_eq!(
296                *count, 4,
297                "Value {:?} appears {} times, expected 4",
298                val, count
299            );
300        }
301    }
302
303    #[test]
304    fn test_cfft_extrapolation_matches_naive() {
305        // Verify that the CFFT-based eval_on_lde matches point-by-point evaluation
306        // using the naive repeated_double approach.
307        for (log_n, log_blowup, log_period) in [(4, 1, 2), (5, 2, 2), (6, 1, 3), (8, 2, 4)] {
308            let trace_domain = CircleDomain::<F>::standard(log_n);
309            let lde_domain = CircleDomain::<F>::standard(log_n + log_blowup);
310            let lde_len = lde_domain.size();
311            let period = 1 << log_period;
312            let log_repetitions = log_n - log_period;
313
314            // Create a periodic column with distinct values
315            let periodic_col: Vec<F> = (0..period).map(|i| F::from_u32(i as u32 + 1)).collect();
316            let periodic_table = vec![periodic_col.clone()];
317
318            // Evaluate using the optimized CFFT-based method
319            let cfft_table =
320                CirclePeriodicEvaluator::eval_on_lde(&periodic_table, &trace_domain, &lde_domain);
321
322            // Expand compact table to full LDE
323            let cfft_result: Vec<F> = (0..lde_len).map(|i| *cfft_table.get(i, 0)).collect();
324
325            // Evaluate using the naive point-by-point method
326            let periodic_domain = CircleDomain::standard(log_period);
327            let evals = CircleEvaluations::from_natural_order(
328                periodic_domain,
329                RowMajorMatrix::new_col(periodic_col.clone()),
330            );
331
332            let naive_result: Vec<F> = (0..lde_len)
333                .map(|lde_idx| {
334                    let lde_point = lde_domain.nth_point(lde_idx);
335                    let periodic_point = lde_point.repeated_double(log_repetitions);
336                    evals.evaluate_at_point(periodic_point)[0]
337                })
338                .collect();
339
340            assert_eq!(
341                cfft_result, naive_result,
342                "CFFT-based and naive methods disagree for log_n={}, log_blowup={}, log_period={}",
343                log_n, log_blowup, log_period
344            );
345        }
346    }
347}