Skip to main content

zinc_protocol/
fold.rs

1use crypto_primitives::{BaseFieldConfig, ProjectElementWithConfig, boolean::Boolean};
2use itertools::Itertools;
3use num_traits::Zero;
4use zinc_poly::{
5    mle::DenseMultilinearExtension,
6    univariate::{binary::BinaryPoly, binary_ref::BinaryRefPoly, binary_u64::BinaryU64Poly},
7};
8use zinc_utils::{add, mul};
9
10/// Fold a trace of one type into a trace of another (similar but smaller) type.
11///
12/// Note that folding will increase number of variables for MLEs by
13/// `ilog2(Self::FOLDING_FACTOR)`.
14pub trait FoldTrace<From, To> {
15    /// Folding factor, a positive power of 2.
16    const FOLDING_FACTOR: usize;
17
18    fn fold_trace_mle(mle: &DenseMultilinearExtension<From>) -> DenseMultilinearExtension<To>;
19
20    /// Verifier-side: compute one column's contribution to the folded PCS
21    /// eval-claim at the extended evaluation point
22    /// `r_0 || gamma_1 || ... || gamma_k`, where `k = log2(FOLDING_FACTOR)`.
23    ///
24    /// # Inputs:
25    /// - `bar_u_coeffs`: the column's unfolded lifted-eval coefficients in
26    ///   `F_q[X]`, of formal length `D`. May be shorter than `D` if trailing
27    ///   zero coefficients were trimmed.
28    /// - `alphas`: per-poly PCS alphas, of length `D / FOLDING_FACTOR`,
29    ///   matching the folded entry size.
30    /// - `folding_challenges`: `k` field-typed challenges sampled by the
31    ///   verifier, in sampling order (`gamma_1` first, `gamma_k` last).
32    ///
33    /// Returns the value `<alphas, bar_u_folded>` where `bar_u_folded` is the
34    /// column's lifted-eval polynomial after applying `k` chained 2x splits
35    /// and pinning the appended boolean variables to `(gamma_1, ..., gamma_k)`.
36    fn fold_eval_claim<C, A>(
37        bar_u_coeffs: &[C::Element],
38        alphas: &[A],
39        folding_challenges: &[C::Element],
40        field_cfg: &C,
41    ) -> C::Element
42    where
43        C: BaseFieldConfig + ProjectElementWithConfig<A>,
44    {
45        // MSB-first contiguous chained-2x-split chunking.
46        //
47        // `bar_u_coeffs` is partitioned into `FOLDING_FACTOR` contiguous chunks of
48        // length `D / FOLDING_FACTOR`.
49        //
50        // Their inner products with `alphas` form a `k`-variable multilinear polynomial
51        // which is then evaluated at `(gamma_1, ..., gamma_k)` with `gamma_1` paired
52        // with the high bit of the chunk index.
53        //
54        // This covers `NoopFoldTrace` (k = 0, degenerating to a single inner product)
55        // as well as chained 2x folds.
56
57        debug_assert_eq!(
58            1usize << folding_challenges.len(),
59            Self::FOLDING_FACTOR,
60            "fold_eval_claim: 1 << folding_challenges.len() must equal FOLDING_FACTOR",
61        );
62        debug_assert!(
63            bar_u_coeffs.len() <= mul!(alphas.len(), Self::FOLDING_FACTOR),
64            "fold_eval_claim: bar_u_coeffs.len() must not exceed alphas.len() * FOLDING_FACTOR",
65        );
66
67        let chunk_size = alphas.len();
68        let alphas = alphas.iter().map(|a| field_cfg.project(a)).collect_vec();
69
70        // Step 1: Per-chunk inner products
71        //   P_i = sum_{j < chunk_size} alphas[j] * bar_u_coeffs[i*chunk_size + j],
72        // Trimmed (missing-trailing-zero) coefficients are treated as zero.
73        let chunk_evals: Vec<C::Element> = (0..Self::FOLDING_FACTOR)
74            .map(|i| {
75                let start = mul!(i, chunk_size);
76                let mut acc = field_cfg.zero();
77                for (j, alpha) in alphas.iter().enumerate() {
78                    if let Some(coeff) = bar_u_coeffs.get(add!(start, j)) {
79                        field_cfg.add_assign(&mut acc, &field_cfg.mul(alpha, coeff));
80                    }
81                }
82                acc
83            })
84            .collect();
85
86        // Step 2: MLE-evaluate the per-chunk inner products at folding_challenges,
87        // MSB-first (gamma_1 = high bit, peeled first).
88        mle_eval_msb_first(field_cfg, chunk_evals, folding_challenges)
89    }
90}
91
92//
93// NOOP fold
94//
95
96pub struct NoopFoldTrace;
97
98impl<T: Clone> FoldTrace<T, T> for NoopFoldTrace {
99    const FOLDING_FACTOR: usize = 1;
100
101    fn fold_trace_mle(mle: &DenseMultilinearExtension<T>) -> DenseMultilinearExtension<T> {
102        mle.clone()
103    }
104}
105
106//
107// Binary folds
108//
109
110pub struct FoldBinaryTrace2x<const D: usize, const HALF_D: usize>;
111
112impl<const D: usize, const HALF_D: usize> FoldTrace<BinaryPoly<D>, BinaryPoly<HALF_D>>
113    for FoldBinaryTrace2x<D, HALF_D>
114{
115    const FOLDING_FACTOR: usize = 2;
116
117    fn fold_trace_mle(
118        mle: &DenseMultilinearExtension<BinaryPoly<D>>,
119    ) -> DenseMultilinearExtension<BinaryPoly<HALF_D>> {
120        split_binary_poly_mle(mle)
121    }
122}
123
124pub struct FoldBinaryTrace4x<const D: usize, const HALF_D: usize, const QUARTER_D: usize>;
125
126impl<const D: usize, const HALF_D: usize, const QUARTER_D: usize>
127    FoldTrace<BinaryPoly<D>, BinaryPoly<QUARTER_D>> for FoldBinaryTrace4x<D, HALF_D, QUARTER_D>
128{
129    const FOLDING_FACTOR: usize = 4;
130
131    fn fold_trace_mle(
132        mle: &DenseMultilinearExtension<BinaryPoly<D>>,
133    ) -> DenseMultilinearExtension<BinaryPoly<QUARTER_D>> {
134        let mle = split_binary_poly_mle::<D, HALF_D>(mle);
135        split_binary_poly_mle::<HALF_D, QUARTER_D>(&mle)
136    }
137}
138
139//
140// Helper functions
141//
142
143/// Split a column of `BinaryPoly<D>` entries into a concatenated column
144/// of `BinaryPoly<HALF_D>` entries.
145///
146/// Each entry `v[i]` with `D` binary coefficients is split into:
147/// - `u[i]` = low `HALF_D` coefficients (indices `0..HALF_D`)
148/// - `w[i]` = high `HALF_D` coefficients (indices `HALF_D..D`)
149///
150/// so that `v[i] = u[i] + X^HALF_D ยท w[i]`.
151///
152/// Returns a column of length `2n` where:
153/// - `v'[0..n]   = u[0..n]`  (low halves)
154/// - `v'[n..2n]  = w[0..n]`  (high halves)
155///
156/// The returned MLE has `num_vars + 1` variables, with the last variable
157/// selecting between the low half (0) and high half (1).
158///
159/// Panics at compile-time if `D != 2 * HALF_D`.
160fn split_binary_poly_mle<const D: usize, const HALF_D: usize>(
161    mle: &DenseMultilinearExtension<BinaryPoly<D>>,
162) -> DenseMultilinearExtension<BinaryPoly<HALF_D>> {
163    const {
164        assert!(D == 2 * HALF_D, "split_column: D must equal 2 * HALF_D");
165    }
166
167    #[cfg(not(feature = "simd"))]
168    let res = split_binary_poly_mle_ref(mle);
169
170    #[cfg(feature = "simd")]
171    let res = split_binary_poly_mle_u64(mle);
172
173    res
174}
175
176#[allow(dead_code)]
177fn split_binary_poly_mle_ref<const D: usize, const HALF_D: usize>(
178    mle: &DenseMultilinearExtension<BinaryRefPoly<D>>,
179) -> DenseMultilinearExtension<BinaryRefPoly<HALF_D>> {
180    let n = mle.evaluations.len();
181    let mut lo_evals = Vec::with_capacity(n);
182    let mut hi_evals = Vec::with_capacity(n);
183
184    for entry in &mle.evaluations {
185        let lo_arr: [Boolean; HALF_D] = std::array::from_fn(|i| entry[i]);
186        let hi_arr: [Boolean; HALF_D] = std::array::from_fn(|i| entry[add!(HALF_D, i)]);
187        lo_evals.push(BinaryRefPoly::<HALF_D>::new(lo_arr));
188        hi_evals.push(BinaryRefPoly::<HALF_D>::new(hi_arr));
189    }
190
191    // Concatenate: v' = u || w (low halves first, high halves second).
192    lo_evals.extend(hi_evals);
193
194    DenseMultilinearExtension::from_evaluations_vec(add!(mle.num_vars, 1), lo_evals, Zero::zero())
195}
196
197#[allow(dead_code)]
198fn split_binary_poly_mle_u64<const D: usize, const HALF_D: usize>(
199    mle: &DenseMultilinearExtension<BinaryU64Poly<D>>,
200) -> DenseMultilinearExtension<BinaryU64Poly<HALF_D>> {
201    let n = mle.evaluations.len();
202    let mut lo_evals: Vec<BinaryU64Poly<HALF_D>> = Vec::with_capacity(n);
203    let mut hi_evals: Vec<BinaryU64Poly<HALF_D>> = Vec::with_capacity(n);
204
205    for entry in &mle.evaluations {
206        let bits: u64 = *entry.inner();
207        // `From<u64>` masks off bits at positions `>= HALF_D` so each half
208        // upholds the `BinaryU64Poly<HALF_D>` invariant.
209        lo_evals.push(BinaryU64Poly::<HALF_D>::from(bits));
210        hi_evals.push(BinaryU64Poly::<HALF_D>::from(bits >> HALF_D));
211    }
212
213    // Concatenate: v' = u || w (low halves first, high halves second), matching
214    // the layout produced by `split_binary_poly_mle_ref`.
215    lo_evals.extend(hi_evals);
216
217    DenseMultilinearExtension::from_evaluations_vec(add!(mle.num_vars, 1), lo_evals, Zero::zero())
218}
219
220/// Multilinear evaluation of `values` (length `2^gammas.len()`, MSB-first
221/// indexed) at point `gammas`. Peels `gammas[0]` (the high bit, equivalently
222/// the first sampled challenge) at each recursive step, splitting `values`
223/// into a lower half (high bit = 0) and an upper half (high bit = 1).
224fn mle_eval_msb_first<C: BaseFieldConfig>(
225    cfg: &C,
226    values: Vec<C::Element>,
227    gammas: &[C::Element],
228) -> C::Element {
229    if gammas.is_empty() {
230        debug_assert_eq!(values.len(), 1);
231        return values.into_iter().next().expect("non-empty values");
232    }
233    debug_assert_eq!(values.len(), 1usize << gammas.len());
234
235    let half = values.len() >> 1;
236    let g = &gammas[0];
237    let one_minus_g = cfg.sub(&cfg.one(), g);
238
239    let mut next: Vec<C::Element> = Vec::with_capacity(half);
240    for i in 0..half {
241        let mut lo = cfg.mul(&one_minus_g, &values[i]);
242        cfg.add_assign(&mut lo, &cfg.mul(g, &values[add!(i, half)]));
243        next.push(lo);
244    }
245    mle_eval_msb_first(cfg, next, &gammas[1..])
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crypto_primitives::Wrapper;
252    use rand::prelude::*;
253    use zinc_transcript::traits::GenTranscribable;
254
255    /// Build two MLEs (`BinaryRefPoly<D>` and `BinaryU64Poly<D>`) carrying the
256    /// same coefficient pattern from a list of bit-packed `u64` entries.
257    fn build_matched_mles<const D: usize>(
258        bits_list: &[u64],
259    ) -> (
260        DenseMultilinearExtension<BinaryRefPoly<D>>,
261        DenseMultilinearExtension<BinaryU64Poly<D>>,
262    ) {
263        let n = bits_list.len();
264        assert!(n.is_power_of_two(), "n must be a power of two");
265        let num_vars = n.trailing_zeros() as usize;
266
267        let ref_entries: Vec<BinaryRefPoly<D>> = bits_list
268            .iter()
269            .map(|&bits| BinaryRefPoly::read_transcription_bytes_exact(&bits.to_le_bytes()))
270            .collect();
271        let u64_entries: Vec<BinaryU64Poly<D>> = bits_list
272            .iter()
273            .map(|&bits| BinaryU64Poly::from(bits))
274            .collect();
275
276        let ref_mle =
277            DenseMultilinearExtension::from_evaluations_vec(num_vars, ref_entries, Zero::zero());
278        let u64_mle =
279            DenseMultilinearExtension::from_evaluations_vec(num_vars, u64_entries, Zero::zero());
280
281        (ref_mle, u64_mle)
282    }
283
284    /// Run both splitters on matched inputs and assert that every output
285    /// coefficient agrees bit-for-bit.
286    fn assert_split_matches<const D: usize, const HALF_D: usize>(bits_list: Vec<u64>) {
287        let (ref_mle, u64_mle) = build_matched_mles::<D>(&bits_list);
288
289        let split_ref = split_binary_poly_mle_ref::<D, HALF_D>(&ref_mle);
290        let split_u64 = split_binary_poly_mle_u64::<D, HALF_D>(&u64_mle);
291
292        assert_eq!(split_ref.num_vars, split_u64.num_vars);
293        assert_eq!(split_ref.evaluations.len(), split_u64.evaluations.len());
294        for (idx, (r, u)) in split_ref
295            .evaluations
296            .iter()
297            .zip(split_u64.evaluations.iter())
298            .enumerate()
299        {
300            // Compare bits in two different ways - directly, as via iterator
301
302            for i in 0..HALF_D {
303                let r_bit = *r[i].inner();
304                let u_bit = ((*u.inner()) >> i) & 1 != 0;
305                assert_eq!(
306                    r_bit, u_bit,
307                    "mismatch at output entry {idx}, coefficient bit {i}",
308                );
309            }
310
311            for (i, pair) in r.iter().zip_longest(u.iter()).enumerate() {
312                match pair {
313                    itertools::EitherOrBoth::Both(r_bit, u_bit) => {
314                        assert_eq!(
315                            *r_bit.inner(),
316                            *u_bit,
317                            "mismatch at output entry {idx}, coefficient bit {i}",
318                        );
319                    }
320                    itertools::EitherOrBoth::Left(_) | itertools::EitherOrBoth::Right(_) => {
321                        panic!("mismatch in number of coefficients at output entry {idx}");
322                    }
323                }
324            }
325        }
326    }
327
328    #[test]
329    fn split_ref_and_u64_match_d4_exhaustive() {
330        // Single-entry input: enumerate all 16 bit patterns.
331        for bits in 0u64..16 {
332            assert_split_matches::<4, 2>(vec![bits]);
333        }
334
335        // 4-entry input: enumerate all 16^4 = 65536 patterns is too much; sample.
336        let mut rng = rand::rng();
337        for _ in 0..32 {
338            let bits_list: Vec<u64> = (0..4).map(|_| rng.random::<u64>() & 0xF).collect();
339            assert_split_matches::<4, 2>(bits_list);
340        }
341    }
342
343    #[test]
344    fn split_ref_and_u64_match_random() {
345        let mut rng = rand::rng();
346
347        for n_log in 0..=3 {
348            let n = 1usize << n_log;
349            let bits_list: Vec<u64> = (0..n).map(|_| rng.random::<u64>() & 0xF).collect();
350            assert_split_matches::<4, 2>(bits_list);
351        }
352
353        for n_log in 0..=4 {
354            let n = 1usize << n_log;
355            let bits_list: Vec<u64> = (0..n).map(|_| rng.random::<u64>() & 0xFF).collect();
356            assert_split_matches::<8, 4>(bits_list);
357        }
358
359        for n_log in 0..=5 {
360            let n = 1usize << n_log;
361            let bits_list: Vec<u64> = (0..n).map(|_| rng.random::<u64>() & 0xFFFF_FFFF).collect();
362            assert_split_matches::<32, 16>(bits_list);
363        }
364
365        for n_log in 0..=6 {
366            let n = 1usize << n_log;
367            let bits_list: Vec<u64> = (0..n).map(|_| rng.random::<u64>()).collect();
368            assert_split_matches::<64, 32>(bits_list);
369        }
370    }
371
372    #[test]
373    fn split_u64_pins_all_zero_entries() {
374        // Zero input should round-trip to all-zero output regardless of D.
375        let bits_list = vec![0u64; 8];
376        assert_split_matches::<8, 4>(bits_list.clone());
377        assert_split_matches::<32, 16>(bits_list.clone());
378        assert_split_matches::<64, 32>(bits_list);
379    }
380
381    #[test]
382    fn split_u64_handles_all_ones_d64() {
383        // All-ones (every bit set) is the high-edge case for D=64 since the
384        // mask `(1 << 64) - 1` is not directly representable. Expect lo = hi =
385        // 2^32 - 1 for D=64, HALF_D=32.
386        let bits_list = vec![u64::MAX; 4];
387        assert_split_matches::<64, 32>(bits_list);
388    }
389}