Skip to main content

zinc_piop/
multipoint_eval.rs

1//! Multi-point evaluation subprotocol.
2//!
3//! Reduces MLE evaluation claims at a shared point r' - the "up" evaluations
4//! `v_j(r')`, the "down" (shifted) evaluations `v_j^{down}(r')`, and optional
5//! bit-op virtual evaluations - to a single set of standard MLE evaluation
6//! claims at a new random point `r_0` via one sumcheck.
7//!
8//! The trace column MLEs are precombined into a single MLE
9//! `precombined(b) = \sum_j \gamma_j * v_j(b)
10//!                 + \sum_l \gamma_l^bit * bit_op_l(b)`
11//! before entering the sumcheck, so the prover works with only 3 MLE groups
12//! (`eq`, `next`, `precombined`) regardless of the number of columns. The
13//! sumcheck proves:
14//! ```text
15//! \sum_b [eq(b, r') * (\sum_j \gamma_j * v_j(b)
16//!                      + \sum_l \gamma_l^bit * bit_op_l(b))
17//!         + \sum_k \alpha_k * next_{c_k}(r', b) * v_{src_k}(b)]
18//!   = \sum_j \gamma_j * up_eval_j
19//!     + \sum_l \gamma_l^bit * bit_op_eval_l
20//!     + \sum_k \alpha_k * down_eval_k
21//! ```
22//!
23//! where `\alpha_k` batch the per-shift evaluation kernels and `\gamma_j`
24//! batch across columns. After the sumcheck reduces to point `r_0`, the
25//! verifier calls [`MultipointEval::verify_subclaim`] with the committed-column
26//! `open_evals` and the verifier-derived `bit_op_open_evals` to check the
27//! final consistency equation. For bit-op virtuals, those open evaluations are
28//! derived from source lifted openings via Lemma 2.3 rather than trusted as
29//! independent witness openings.
30//!
31//! This corresponds to the T=2 case of Pi_{BMLE} in the paper. Following
32//! the paper, the prover sends only the polynomial-valued lifted evaluations
33//! (alpha'_j in F_q[X]); the scalar open_evals are derived by the verifier
34//! via \psi_a rather than being sent as a separate proof element.
35
36#[cfg(feature = "parallel")]
37use rayon::prelude::*;
38
39use crate::{
40    CombFn,
41    shift_predicate::eval_shift_predicate,
42    sumcheck::{
43        SumCheckError,
44        multi_degree::{
45            MultiDegreeSubClaims, MultiDegreeSumcheck, MultiDegreeSumcheckGroup,
46            MultiDegreeSumcheckProof,
47        },
48    },
49};
50use crypto_primitives::{
51    BaseFieldConfig, ProjectPrimitiveIntegersWithConfig, SemiringConfig, SetConfig,
52};
53use std::marker::PhantomData;
54use thiserror::Error;
55use zinc_poly::{
56    mle::DenseMultilinearExtension,
57    utils::{ArithErrors, build_eq_x_r, build_next_c_r_mle},
58};
59use zinc_transcript::{
60    delegate_transcribable,
61    traits::{ConstTranscribable, Transcript},
62};
63use zinc_uair::ShiftSpec;
64use zinc_utils::cfg_into_iter;
65
66//
67// Data structures
68//
69
70/// Proof for the multi-point evaluation protocol.
71///
72/// Wraps the inner [`MultiDegreeSumcheckProof`] driven by
73/// [`MultiDegreeSumcheck`]: each family contributes a single degree-2
74/// group, all families sharing one $r_0$ per round. The MLE evaluations
75/// at $r_0$ are provided externally via `lifted_evals` (in $F_q[X]$),
76/// from which the verifier derives the scalar `open_evals` via $\psi_a$.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct Proof<F> {
79    /// The inner multi-degree sumcheck proof. Single-family shape: one
80    /// degree-2 group.
81    pub sumcheck_proof: MultiDegreeSumcheckProof<F>,
82}
83
84impl<F> Proof<F> {
85    /// Maps every field element through `f`, preserving structure — used to
86    /// lift elements into wire integers and to project wire integers back
87    /// into elements at the (de)serialization boundary.
88    pub fn try_map<T, E>(&self, f: impl FnMut(&F) -> Result<T, E> + Copy) -> Result<Proof<T>, E> {
89        Ok(Proof {
90            sumcheck_proof: self.sumcheck_proof.try_map(f)?,
91        })
92    }
93}
94
95delegate_transcribable!(Proof<F> { sumcheck_proof: MultiDegreeSumcheckProof<F> }
96    where F: ConstTranscribable);
97
98/// Per-family inputs to the lockstep multi-point evaluation protocol,
99/// those that aren't shared between families.
100///
101/// All families share the same `shifts` (UAIR-static), the same column
102/// counts, and the same `num_vars`. They differ only in (a) the field
103/// config they operate over and (b) the per-family field-projected
104/// `trace_mles`, bit-op virtual MLEs, `eval_point`, `up_evals`, and
105/// `down_evals`.
106pub struct MultipointEvalFamilyInputs<'a, C: SetConfig> {
107    /// Field configuration for this family.
108    pub field_cfg: &'a C,
109    /// Trace MLEs for this family (projected into this family's field).
110    pub trace_mles: &'a [DenseMultilinearExtension<C::Element>],
111    /// Bit-op virtual MLEs for this family (projected into this family's
112    /// field).
113    pub bit_op_mles: &'a [DenseMultilinearExtension<C::Element>],
114    /// Evaluation point $r^\star$ for this family.
115    pub eval_point: &'a [C::Element],
116    /// `up_eval_j = v_j(r*)` for every column $j$, in this family's
117    /// field.
118    pub up_evals: &'a [C::Element],
119    /// `bit_op_eval_l = bit_op_l(r*)` for every bit-op virtual column
120    /// $l$, in this family's field.
121    pub bit_op_evals: &'a [C::Element],
122    /// `down_eval_k = v_{src_k}^{<<c_k}(r*)` for every shift $k$, in
123    /// this family's field.
124    pub down_evals: &'a [C::Element],
125}
126
127/// Prover state after the multi-point evaluation protocol for one constraint
128/// family.
129#[derive(Clone, Debug)]
130pub struct ProverState<F> {
131    /// The combined evaluation point `r_0` produced by the sumcheck
132    /// (lifted into this family's field — the underlying integer is shared
133    /// across all families).
134    pub eval_point: Vec<F>,
135}
136
137/// Verifier subclaim after the multi-point evaluation sumcheck for one
138/// constraint family.
139///
140/// Carries the shared evaluation point $r_0$ (lifted into this family's
141/// field), the expected MLE-combination evaluation handed back by the
142/// inner multi-degree sumcheck, plus the intermediate values needed to
143/// finalize the check via [`MultipointEval::verify_subclaim`] once the
144/// caller has assembled the `open_evals`.
145#[derive(Clone, Debug)]
146pub struct Subclaim<F> {
147    /// Shared sumcheck output point $r_0$ (in this family's field).
148    pub r0: Vec<F>,
149    /// Expected evaluation of the combined polynomial at $r_0$ handed back
150    /// by the inner multi-degree sumcheck — `verify_subclaim` checks this
151    /// against the batched `open_evals`.
152    pub expected_evaluation: F,
153    /// Column batching coefficients $\gamma_j$ sampled during the protocol
154    /// (lifted into this family's field).
155    pub gammas: Vec<F>,
156    /// Per-shift batching coefficients $\alpha_k$ sampled during the
157    /// protocol (lifted into this family's field).
158    pub alphas: Vec<F>,
159    /// Per-bit-op-virtual batching coefficients sampled during the
160    /// protocol (lifted into this family's field).
161    pub bit_op_gammas: Vec<F>,
162    /// `eq(r_0, r^\star)` — the equality selector at the sumcheck output
163    /// point.
164    pub eq_at_r0: F,
165    /// Per-shift selector values at $r_0$:
166    /// `shifts_at_r0[k] = next_{c_k}(r^\star, r_0)`.
167    pub shifts_at_r0: Vec<F>,
168}
169
170//
171// Protocol
172//
173
174pub struct MultipointEval<C>(PhantomData<C>);
175
176impl<C> MultipointEval<C>
177where
178    C: BaseFieldConfig + ProjectPrimitiveIntegersWithConfig + 'static,
179    C::Integer: ConstTranscribable,
180{
181    /// Multi-point evaluation protocol prover (lockstep over families).
182    ///
183    /// Drives one or more **families** of multi-point evaluation in lockstep,
184    /// each family operating over its own field config. All families share
185    /// the same UAIR-static [`ShiftSpec`]s, the same number of committed
186    /// columns, the same number of bit-op virtual columns, and the same
187    /// `num_vars`; they differ only in their per-family projected
188    /// MLEs/scalars.
189    ///
190    /// All protocol-level challenges $(\alpha_k, \gamma_j,
191    /// \gamma^\mathrm{bit}_l)$ are sampled once as integers in $[0, q^*)$
192    /// via `q_star_cfg` and lifted into each family's field via
193    /// `cfg.project`. The inner sumcheck is driven by
194    /// [`MultiDegreeSumcheck`] (one degree-2 group per family), so each
195    /// round's challenge is likewise shared across families and lifted
196    /// per-family.
197    ///
198    /// The single-family case (`families.len() == 1`) is the natural
199    /// degenerate form; pass `q_star_cfg = families[0].field_cfg`.
200    ///
201    /// Per family, proves
202    ///
203    /// $$\sum_b \Bigl[\, \mathrm{eq}(b, r^\star) (
204    /// \sum_j \gamma_j v_j(b) + \sum_l \gamma^\mathrm{bit}_l w_l(b)) +
205    /// \sum_k \alpha_k \cdot \mathrm{next}_{c_k}(r^\star, b) \cdot
206    /// v_{\mathrm{src}_k}(b) \Bigr] = \sum_j \gamma_j \cdot
207    /// \mathrm{up\\_eval}_j + \sum_l \gamma^\mathrm{bit}_l \cdot
208    /// \mathrm{bit\\_op\\_eval}_l + \sum_k \alpha_k \cdot
209    /// \mathrm{down\\_eval}_k.$$
210    ///
211    /// Returns one `(Proof, ProverState)` per family in family order. The
212    /// caller is responsible for computing and sending `lifted_evals` at
213    /// the shared $r_0$ for each family.
214    ///
215    /// # Panics
216    ///
217    /// * If `families` is empty.
218    /// * If families disagree on `num_vars` or column counts.
219    #[allow(
220        clippy::arithmetic_side_effects,
221        clippy::too_many_lines,
222        clippy::type_complexity
223    )]
224    pub fn prove_as_subprotocol(
225        transcript: &mut impl Transcript,
226        families: Vec<MultipointEvalFamilyInputs<'_, C>>,
227        shifts: &[ShiftSpec],
228        q_star_cfg: &C,
229    ) -> Result<Vec<(Proof<C::Element>, ProverState<C::Element>)>, MultipointEvalError<C::Element>>
230    {
231        assert!(!families.is_empty(), "need at least one family");
232
233        let num_families = families.len();
234        let num_cols = families[0].trace_mles.len();
235        let num_vars = families[0].eval_point.len();
236        let num_down_cols = shifts.len();
237        let num_bit_op_cols = families[0].bit_op_evals.len();
238
239        for b in &families {
240            assert_eq!(
241                b.trace_mles.len(),
242                num_cols,
243                "all families must have the same number of columns",
244            );
245            assert_eq!(
246                b.eval_point.len(),
247                num_vars,
248                "all families must have the same num_vars",
249            );
250            assert_eq!(
251                b.up_evals.len(),
252                num_cols,
253                "up_evals length must match trace_mles length",
254            );
255            assert_eq!(
256                b.down_evals.len(),
257                num_down_cols,
258                "down_evals length must match shifts length",
259            );
260            assert_eq!(
261                b.bit_op_mles.len(),
262                num_bit_op_cols,
263                "all families must have the same number of bit-op MLEs",
264            );
265            assert_eq!(
266                b.bit_op_evals.len(),
267                num_bit_op_cols,
268                "all families must have the same number of bit-op evals",
269            );
270        }
271
272        // Step 1: Sample shared batching coefficients $\alpha_k$ and
273        // $\gamma_j$, then bit-op $\gamma^\mathrm{bit}_l$, as integers in
274        // $[0, q^*)$, then lift into each family's field.
275        let sample_int = |transcript: &mut _| {
276            let chal: C::Element = Transcript::get_field_challenge(transcript, q_star_cfg);
277            q_star_cfg.lift(&chal)
278        };
279        let shared_alpha_ints: Vec<C::Integer> =
280            (0..num_down_cols).map(|_| sample_int(transcript)).collect();
281        let shared_gamma_ints: Vec<C::Integer> =
282            (0..num_cols).map(|_| sample_int(transcript)).collect();
283        let shared_bit_op_gamma_ints: Vec<C::Integer> = (0..num_bit_op_cols)
284            .map(|_| sample_int(transcript))
285            .collect();
286
287        let per_family_alphas: Vec<Vec<C::Element>> = families
288            .iter()
289            .map(|b| {
290                shared_alpha_ints
291                    .iter()
292                    .map(|v| b.field_cfg.project(v))
293                    .collect()
294            })
295            .collect();
296        let per_family_gammas: Vec<Vec<C::Element>> = families
297            .iter()
298            .map(|b| {
299                shared_gamma_ints
300                    .iter()
301                    .map(|v| b.field_cfg.project(v))
302                    .collect()
303            })
304            .collect();
305        let per_family_bit_op_gammas: Vec<Vec<C::Element>> = families
306            .iter()
307            .map(|b| {
308                shared_bit_op_gamma_ints
309                    .iter()
310                    .map(|v| b.field_cfg.project(v))
311                    .collect()
312            })
313            .collect();
314
315        // Step 2: Build per-family sumcheck groups.
316        //
317        // Each family contributes one degree-2 group with MLE layout
318        // `[eq_r, next_mles[..], precombined, down_cols[..]]` and
319        // comb_fn `eq * precombined + \sum_k \alpha_k * next_k * down_k`.
320        let mut family_groups: Vec<(Vec<MultiDegreeSumcheckGroup<C>>, &C)> =
321            Vec::with_capacity(num_families);
322
323        // Sanity-check claimed sums in debug builds.
324        let mut debug_expected_sums: Vec<C::Element> = Vec::with_capacity(num_families);
325
326        for (b_idx, family) in families.iter().enumerate() {
327            let cfg = family.field_cfg;
328            let alphas_b = per_family_alphas[b_idx].clone();
329            let gammas_b = &per_family_gammas[b_idx];
330            let bit_op_gammas_b = &per_family_bit_op_gammas[b_idx];
331            let zero = cfg.zero();
332
333            // Build the two selector MLEs:
334            //   eq_r(b)   = eq(b, r')
335            //   next_c_r_mle(b) = next_c_mle(r', b)
336            let eq_r = build_eq_x_r(cfg, family.eval_point)?;
337            let (next_mles, down_cols): (Vec<_>, Vec<_>) = shifts
338                .iter()
339                .map(|spec| {
340                    let next = build_next_c_r_mle(cfg, family.eval_point, spec.shift_amount())?;
341                    let col = family.trace_mles[spec.source_col()].clone();
342                    Ok((next, col))
343                })
344                .collect::<Result<Vec<_>, ArithErrors>>()?
345                .into_iter()
346                .unzip();
347
348            // Precombine committed columns and bit-op virtual columns.
349            let precombined = {
350                let evaluations: Vec<_> = cfg_into_iter!(0..1usize << num_vars)
351                    .map(|i| {
352                        let mut acc = zero.clone();
353                        for (j, gamma) in gammas_b.iter().enumerate() {
354                            let term = cfg.mul(&family.trace_mles[j].evaluations[i], gamma);
355                            cfg.add_assign(&mut acc, &term);
356                        }
357                        for (j, bit_op_gamma) in bit_op_gammas_b.iter().enumerate() {
358                            let term = cfg.mul(&family.bit_op_mles[j].evaluations[i], bit_op_gamma);
359                            cfg.add_assign(&mut acc, &term);
360                        }
361                        acc
362                    })
363                    .collect();
364                DenseMultilinearExtension::from_evaluations_vec(num_vars, evaluations, zero.clone())
365            };
366
367            // Pack MLEs: [eq_r, next_mles[..], precombined, down_cols[..]]
368            let mut mles = Vec::with_capacity(2 + 2 * num_down_cols);
369            mles.push(eq_r);
370            mles.extend(next_mles);
371            mles.push(precombined);
372            mles.extend(down_cols);
373
374            let comb_fn: CombFn<C::Element> = {
375                let alphas_b = alphas_b.clone();
376                let num_down_cols_local = num_down_cols;
377                let comb_cfg = cfg.clone();
378                Box::new(move |mle_values: &[C::Element]| {
379                    let eq_val = &mle_values[0];
380                    let precombined_val = &mle_values[num_down_cols_local + 1];
381                    alphas_b.iter().enumerate().fold(
382                        comb_cfg.mul(eq_val, precombined_val),
383                        |acc, (k, alpha)| {
384                            let next = &mle_values[1 + k];
385                            let down_col = &mle_values[num_down_cols_local + 2 + k];
386                            comb_cfg.add(&acc, &comb_cfg.mul(&comb_cfg.mul(alpha, next), down_col))
387                        },
388                    )
389                })
390            };
391
392            let group = MultiDegreeSumcheckGroup::new(2, mles, comb_fn);
393            family_groups.push((vec![group], cfg));
394
395            if cfg!(debug_assertions) {
396                debug_expected_sums.push(compute_expected_sum(
397                    cfg,
398                    family.up_evals,
399                    family.down_evals,
400                    family.bit_op_evals,
401                    gammas_b,
402                    &alphas_b,
403                    bit_op_gammas_b,
404                ));
405            }
406        }
407
408        // Step 3: Run the lockstep multi-degree sumcheck (degree 2, one
409        // group per family).
410        let sumcheck_outputs = MultiDegreeSumcheck::prove_as_subprotocol(
411            transcript,
412            family_groups,
413            num_vars,
414            q_star_cfg,
415        );
416
417        // Step 4: Repackage into per-family (Proof, ProverState).
418        let mut result = Vec::with_capacity(num_families);
419        for (b_idx, (proof, mut prover_states)) in sumcheck_outputs.into_iter().enumerate() {
420            debug_assert_eq!(
421                prover_states.len(),
422                1,
423                "each family contributed exactly one degree group",
424            );
425            debug_assert_eq!(
426                proof.claimed_sums()[0],
427                debug_expected_sums[b_idx],
428                "claimed sum mismatch on family {b_idx}",
429            );
430            let state = prover_states.pop().expect("single group per family");
431            result.push((
432                Proof {
433                    sumcheck_proof: proof,
434                },
435                ProverState {
436                    eval_point: state.randomness,
437                },
438            ));
439        }
440
441        Ok(result)
442    }
443
444    /// Multi-point evaluation protocol verifier (sumcheck phase, lockstep).
445    ///
446    /// Mirror of [`prove_as_subprotocol`]: drives one or more families in
447    /// lockstep, sharing batching coefficients and per-round challenges
448    /// in $[0, q^*)$ via `q_star_cfg`. Returns one [`Subclaim`] per family
449    /// carrying $r_0$, $\gamma_j$, $\alpha_k$, `eq_at_r0`, `shifts_at_r0`,
450    /// and the inner sumcheck's `expected_evaluation`. The caller
451    /// finalizes via [`verify_subclaim`](Self::verify_subclaim) once
452    /// `open_evals` are available.
453    ///
454    /// # Panics
455    ///
456    /// * If `families` is empty.
457    /// * If families disagree on `num_vars` or column counts.
458    #[allow(clippy::arithmetic_side_effects, clippy::too_many_arguments)]
459    pub fn verify_as_subprotocol(
460        transcript: &mut impl Transcript,
461        proofs: Vec<Proof<C::Element>>,
462        families: Vec<MultipointEvalFamilyInputs<'_, C>>,
463        shifts: &[ShiftSpec],
464        num_vars: usize,
465        q_star_cfg: &C,
466    ) -> Result<Vec<Subclaim<C::Element>>, MultipointEvalError<C::Element>> {
467        assert!(!families.is_empty(), "need at least one family");
468        assert_eq!(
469            proofs.len(),
470            families.len(),
471            "proofs and families must have the same length",
472        );
473
474        let num_cols = families[0].up_evals.len();
475        let num_down_cols = shifts.len();
476        let num_bit_op_cols = families[0].bit_op_evals.len();
477
478        // Sanity check
479        for b in &families {
480            assert_eq!(
481                b.up_evals.len(),
482                num_cols,
483                "all families must have the same number of up_evals",
484            );
485            assert_eq!(
486                b.down_evals.len(),
487                num_down_cols,
488                "down_evals length must match shifts length",
489            );
490            assert_eq!(
491                b.eval_point.len(),
492                num_vars,
493                "all families must have the same num_vars",
494            );
495            assert_eq!(
496                b.bit_op_evals.len(),
497                num_bit_op_cols,
498                "all families must have the same number of bit-op evals",
499            );
500        }
501
502        // Step 1: Sample shared $\alpha_k$, $\gamma_j$, and bit-op
503        // $\gamma^\mathrm{bit}_l$ in $[0, q^*)$ (must match prover
504        // transcript order: alphas, gammas, bit-op gammas).
505        let sample_int = |transcript: &mut _| {
506            let chal: C::Element = Transcript::get_field_challenge(transcript, q_star_cfg);
507            q_star_cfg.lift(&chal)
508        };
509        let shared_alpha_ints: Vec<C::Integer> =
510            (0..num_down_cols).map(|_| sample_int(transcript)).collect();
511        let shared_gamma_ints: Vec<C::Integer> =
512            (0..num_cols).map(|_| sample_int(transcript)).collect();
513        let shared_bit_op_gamma_ints: Vec<C::Integer> = (0..num_bit_op_cols)
514            .map(|_| sample_int(transcript))
515            .collect();
516
517        let per_family_alphas: Vec<Vec<C::Element>> = families
518            .iter()
519            .map(|b| {
520                shared_alpha_ints
521                    .iter()
522                    .map(|v| b.field_cfg.project(v))
523                    .collect()
524            })
525            .collect();
526        let per_family_gammas: Vec<Vec<C::Element>> = families
527            .iter()
528            .map(|b| {
529                shared_gamma_ints
530                    .iter()
531                    .map(|v| b.field_cfg.project(v))
532                    .collect()
533            })
534            .collect();
535        let per_family_bit_op_gammas: Vec<Vec<C::Element>> = families
536            .iter()
537            .map(|b| {
538                shared_bit_op_gamma_ints
539                    .iter()
540                    .map(|v| b.field_cfg.project(v))
541                    .collect()
542            })
543            .collect();
544
545        // Step 2: Per-family claimed-sum check (must equal the integer-
546        // shared expected sum derived from each family's up/down/bit-op evals).
547        for (b_idx, (proof, family)) in proofs.iter().zip(families.iter()).enumerate() {
548            let expected = compute_expected_sum(
549                family.field_cfg,
550                family.up_evals,
551                family.down_evals,
552                family.bit_op_evals,
553                &per_family_gammas[b_idx],
554                &per_family_alphas[b_idx],
555                &per_family_bit_op_gammas[b_idx],
556            );
557            let claimed = &proof.sumcheck_proof.claimed_sums()[0];
558            if claimed != &expected {
559                return Err(MultipointEvalError::WrongSumcheckSum {
560                    got: claimed.clone(),
561                    expected,
562                });
563            }
564        }
565
566        // Step 3: Run the lockstep multi-degree sumcheck verifier.
567        let proof_refs: Vec<(&MultiDegreeSumcheckProof<C::Element>, &C)> = proofs
568            .iter()
569            .zip(families.iter())
570            .map(|(p, b)| (&p.sumcheck_proof, b.field_cfg))
571            .collect();
572        let sub_claims: Vec<MultiDegreeSubClaims<C::Element>> =
573            MultiDegreeSumcheck::verify_as_subprotocol(
574                transcript,
575                num_vars,
576                &proof_refs,
577                q_star_cfg,
578            )?;
579
580        // Step 4: Per-family finalize: recompute selectors at $r_0$.
581        families
582            .iter()
583            .zip(sub_claims)
584            .enumerate()
585            .map(|(b_idx, (family, sub))| {
586                let cfg = family.field_cfg;
587                let r0: Vec<C::Element> = sub.point().to_vec();
588                let expected_evaluation = sub.expected_evaluations()[0].clone();
589
590                let eq_at_r0 = zinc_poly::utils::eq_eval(cfg, &r0, family.eval_point)?;
591                let shifts_at_r0: Vec<C::Element> = shifts
592                    .iter()
593                    .map(|spec| {
594                        eval_shift_predicate(cfg, family.eval_point, &r0, spec.shift_amount())
595                    })
596                    .collect();
597
598                Ok(Subclaim {
599                    r0,
600                    expected_evaluation,
601                    gammas: per_family_gammas[b_idx].clone(),
602                    alphas: per_family_alphas[b_idx].clone(),
603                    bit_op_gammas: per_family_bit_op_gammas[b_idx].clone(),
604                    eq_at_r0,
605                    shifts_at_r0,
606                })
607            })
608            .collect()
609    }
610
611    /// Finalize the multi-point evaluation check given `open_evals` for one
612    /// family.
613    ///
614    /// Verifies that
615    /// `eq_at_r0 * \sum_j(gamma_j * open_eval_j) + \sum_k(alpha_k *
616    /// shift_at_r0_k * open_eval[source_col_k])` equals the sumcheck's
617    /// expected evaluation. This is a pure arithmetic check with no
618    /// transcript interaction — call it once per family with that family's
619    /// `open_evals`.
620    #[allow(clippy::arithmetic_side_effects)]
621    pub fn verify_subclaim(
622        subclaim: &Subclaim<C::Element>,
623        open_evals: &[C::Element],
624        bit_op_open_evals: &[C::Element],
625        shifts: &[ShiftSpec],
626        field_cfg: &C,
627    ) -> Result<(), MultipointEvalError<C::Element>> {
628        let num_cols = subclaim.gammas.len();
629        let num_bit_op_cols = subclaim.bit_op_gammas.len();
630
631        if open_evals.len() != num_cols {
632            return Err(MultipointEvalError::WrongOpenEvalsNumber {
633                got: open_evals.len(),
634                expected: num_cols,
635            });
636        }
637
638        if bit_op_open_evals.len() != num_bit_op_cols {
639            return Err(MultipointEvalError::WrongBitOpOpenEvalsNumber {
640                got: bit_op_open_evals.len(),
641                expected: num_bit_op_cols,
642            });
643        }
644
645        let zero = field_cfg.zero();
646
647        let batched_up: C::Element = subclaim
648            .gammas
649            .iter()
650            .zip(open_evals.iter())
651            .fold(zero.clone(), |acc, (gamma, eval)| {
652                field_cfg.add(&acc, &field_cfg.mul(gamma, eval))
653            });
654        let batched_up = subclaim
655            .bit_op_gammas
656            .iter()
657            .zip(bit_op_open_evals.iter())
658            .fold(batched_up, |acc, (gamma, eval)| {
659                field_cfg.add(&acc, &field_cfg.mul(gamma, eval))
660            });
661
662        // open_evals[j] = trace_col_j(r_0) for all committed (up) columns.
663        // Shifted columns reuse the same opening: the shift is captured by
664        // the shift_at_r0 selector, so we index by source_col into open_evals.
665        let batched_down: C::Element = subclaim
666            .alphas
667            .iter()
668            .enumerate()
669            .zip(subclaim.shifts_at_r0.iter())
670            .fold(zero, |acc, ((k, alpha), shift_at_r0)| {
671                let src_col = shifts[k].source_col();
672                field_cfg.add(
673                    &acc,
674                    &field_cfg.mul(&field_cfg.mul(alpha, shift_at_r0), &open_evals[src_col]),
675                )
676            });
677
678        let expected_evaluation = field_cfg.add(
679            &field_cfg.mul(&subclaim.eq_at_r0, &batched_up),
680            &batched_down,
681        );
682
683        if expected_evaluation != subclaim.expected_evaluation {
684            return Err(MultipointEvalError::ClaimMismatch {
685                got: subclaim.expected_evaluation.clone(),
686                expected: expected_evaluation,
687            });
688        }
689
690        Ok(())
691    }
692}
693
694/// `expected_sum = \sum_j \gamma_j * up_eval_j
695///                + \sum_k \alpha_k * down_eval_k
696///                + \sum_l \gamma_l^bit * bit_op_eval_l`
697#[allow(clippy::too_many_arguments)]
698fn compute_expected_sum<C: SemiringConfig>(
699    cfg: &C,
700    up_evals: &[C::Element],
701    down_evals: &[C::Element],
702    bit_op_evals: &[C::Element],
703    gammas: &[C::Element],
704    alphas: &[C::Element],
705    bit_op_gammas: &[C::Element],
706) -> C::Element {
707    let up_sum = gammas
708        .iter()
709        .zip(up_evals.iter())
710        .fold(cfg.zero(), |acc, (gamma, up)| {
711            cfg.add(&acc, &cfg.mul(gamma, up))
712        });
713
714    let up_and_down = alphas
715        .iter()
716        .zip(down_evals.iter())
717        .fold(up_sum, |acc, (alpha, down)| {
718            cfg.add(&acc, &cfg.mul(alpha, down))
719        });
720
721    bit_op_gammas
722        .iter()
723        .zip(bit_op_evals.iter())
724        .fold(up_and_down, |acc, (gamma, eval)| {
725            cfg.add(&acc, &cfg.mul(gamma, eval))
726        })
727}
728
729//
730// Error type
731//
732
733#[derive(Debug, Error)]
734pub enum MultipointEvalError<F: std::fmt::Debug> {
735    #[error("wrong number of open evaluations: got {got}, expected {expected}")]
736    WrongOpenEvalsNumber { got: usize, expected: usize },
737    #[error("wrong number of bit-op open evaluations: got {got}, expected {expected}")]
738    WrongBitOpOpenEvalsNumber { got: usize, expected: usize },
739    #[error("wrong sumcheck claimed sum: got {got:?}, expected {expected:?}")]
740    WrongSumcheckSum { got: F, expected: F },
741    #[error("multi-point eval claim mismatch: got {got:?}, expected {expected:?}")]
742    ClaimMismatch { got: F, expected: F },
743    #[error("sumcheck error: {0}")]
744    SumcheckError(#[from] SumCheckError<F>),
745    #[error("arithmetic error: {0}")]
746    ArithError(#[from] ArithErrors),
747}
748
749#[cfg(test)]
750#[allow(
751    clippy::arithmetic_side_effects,
752    clippy::cast_possible_truncation,
753    clippy::cast_possible_wrap,
754    clippy::cast_sign_loss
755)]
756mod tests {
757    use super::*;
758    use crypto_bigint::{U128, const_monty_params};
759    use crypto_primitives::{FixedConfig, crypto_bigint_const_monty::ConstMontyField};
760    use num_traits::{ConstOne, ConstZero};
761    use zinc_poly::mle::DenseMultilinearExtension;
762    use zinc_transcript::Blake3Transcript;
763
764    const_monty_params!(Params, U128, "00000000b933426489189cb5b47d567f");
765    type F = ConstMontyField<Params, { U128::LIMBS }>;
766    type Cfg = FixedConfig<F>;
767
768    /// Data known to both prover and verifier from earlier protocol steps.
769    #[derive(Clone)]
770    struct SharedSubprotocolInput {
771        eval_point: Vec<F>,
772        up_evals: Vec<F>,
773        down_evals: Vec<F>,
774        shifts: Vec<ShiftSpec>,
775        num_vars: usize,
776    }
777
778    /// What the prover sends to the verifier.
779    #[derive(Clone)]
780    struct ProverMessage {
781        proof: Proof<F>,
782        open_evals: Vec<F>,
783    }
784
785    fn make_transcript() -> Blake3Transcript {
786        let mut t = Blake3Transcript::default();
787        t.absorb_bytes(b"Lorem ipsum");
788        t
789    }
790
791    fn build_trace(
792        num_vars: usize,
793        num_cols: usize,
794        shifts: &[ShiftSpec],
795    ) -> (Vec<DenseMultilinearExtension<F>>, SharedSubprotocolInput) {
796        let cfg = Cfg::default();
797        let n = 1usize << num_vars;
798
799        let trace_mles: Vec<DenseMultilinearExtension<_>> = (0..num_cols)
800            .map(|col| {
801                let evals: Vec<_> = (0..n).map(|i| F::from((col * n + i + 1) as u32)).collect();
802                DenseMultilinearExtension::from_evaluations_vec(num_vars, evals, F::ZERO)
803            })
804            .collect();
805
806        let eval_point: Vec<F> = (0..num_vars).map(|i| F::from((i + 7) as u32)).collect();
807
808        let up_evals: Vec<F> = trace_mles
809            .iter()
810            .map(|mle| mle.clone().evaluate(&cfg, &eval_point).unwrap())
811            .collect();
812
813        let down_evals: Vec<F> = shifts
814            .iter()
815            .map(|spec| {
816                let mle = &trace_mles[spec.source_col()];
817                let c = spec.shift_amount();
818                let mut shifted = mle.evaluations[c..].to_vec();
819                shifted.extend(vec![F::ZERO; c]);
820                let shifted_mle =
821                    DenseMultilinearExtension::from_evaluations_vec(num_vars, shifted, F::ZERO);
822                shifted_mle.evaluate(&cfg, &eval_point).unwrap()
823            })
824            .collect();
825
826        let public = SharedSubprotocolInput {
827            eval_point,
828            up_evals,
829            down_evals,
830            shifts: shifts.to_vec(),
831            num_vars,
832        };
833        (trace_mles, public)
834    }
835
836    /// Prover: has access to the trace, produces a proof and open_evals.
837    fn run_prover(
838        trace_mles: &[DenseMultilinearExtension<F>],
839        public: &SharedSubprotocolInput,
840    ) -> ProverMessage {
841        let cfg = Cfg::default();
842        let mut transcript = make_transcript();
843        let mut outputs = MultipointEval::<Cfg>::prove_as_subprotocol(
844            &mut transcript,
845            vec![MultipointEvalFamilyInputs {
846                field_cfg: &cfg,
847                trace_mles,
848                bit_op_mles: &[],
849                eval_point: &public.eval_point,
850                up_evals: &public.up_evals,
851                bit_op_evals: &[],
852                down_evals: &public.down_evals,
853            }],
854            &public.shifts,
855            &cfg,
856        )
857        .expect("prover should succeed");
858        assert_eq!(outputs.len(), 1, "single-family shape");
859        let (proof, prover_state) = outputs.pop().expect("single family");
860
861        let r_0 = &prover_state.eval_point;
862        let open_evals: Vec<F> = trace_mles
863            .iter()
864            .map(|mle| mle.clone().evaluate(&cfg, r_0).unwrap())
865            .collect();
866
867        ProverMessage { proof, open_evals }
868    }
869
870    /// Verifier: only receives the proof + open_evals + public data.
871    fn run_verifier(
872        public: &SharedSubprotocolInput,
873        msg: &ProverMessage,
874    ) -> Result<Subclaim<F>, MultipointEvalError<F>> {
875        let cfg = Cfg::default();
876        let mut subclaims = MultipointEval::<Cfg>::verify_as_subprotocol(
877            &mut make_transcript(),
878            vec![msg.proof.clone()],
879            vec![MultipointEvalFamilyInputs {
880                field_cfg: &cfg,
881                trace_mles: &[],
882                bit_op_mles: &[],
883                eval_point: &public.eval_point,
884                up_evals: &public.up_evals,
885                bit_op_evals: &[],
886                down_evals: &public.down_evals,
887            }],
888            &public.shifts,
889            public.num_vars,
890            &cfg,
891        )?;
892        assert_eq!(subclaims.len(), 1, "single-family shape");
893        let subclaim = subclaims.pop().expect("single family");
894
895        MultipointEval::<Cfg>::verify_subclaim(
896            &subclaim,
897            &msg.open_evals,
898            &[],
899            &public.shifts,
900            &cfg,
901        )?;
902
903        Ok(subclaim)
904    }
905
906    /// Convenience: build trace, prove, return (public, message).
907    fn honest_interaction(
908        num_vars: usize,
909        num_cols: usize,
910        shifts: &[ShiftSpec],
911    ) -> (SharedSubprotocolInput, ProverMessage) {
912        let (trace, public) = build_trace(num_vars, num_cols, shifts);
913        let msg = run_prover(&trace, &public);
914        (public, msg)
915    }
916
917    /// Helper: all-columns shift-by-1
918    fn all_shift_by_1(num_cols: usize) -> Vec<ShiftSpec> {
919        (0..num_cols).map(|i| ShiftSpec::new(i, 1)).collect()
920    }
921
922    // --- Happy-path ---
923
924    #[test]
925    fn honest_prove_verify_single_column() {
926        let shifts = all_shift_by_1(1);
927        let (public, msg) = honest_interaction(4, 1, &shifts);
928        run_verifier(&public, &msg).unwrap();
929    }
930
931    #[test]
932    fn honest_prove_verify_many_columns() {
933        let shifts = all_shift_by_1(10);
934        let (public, msg) = honest_interaction(3, 10, &shifts);
935        run_verifier(&public, &msg).unwrap();
936    }
937
938    #[test]
939    fn honest_prove_verify_no_shifts() {
940        let (public, msg) = honest_interaction(3, 3, &[]);
941        run_verifier(&public, &msg).unwrap();
942    }
943
944    #[test]
945    fn honest_prove_verify_mixed_shifts() {
946        let shifts = vec![ShiftSpec::new(0, 1), ShiftSpec::new(1, 3)];
947        let (public, msg) = honest_interaction(4, 3, &shifts);
948        run_verifier(&public, &msg).unwrap();
949    }
950
951    #[test]
952    fn honest_prove_verify_shift_by_3() {
953        let shifts = vec![
954            ShiftSpec::new(0, 3),
955            ShiftSpec::new(1, 3),
956            ShiftSpec::new(2, 3),
957        ];
958        let (public, msg) = honest_interaction(4, 3, &shifts);
959        run_verifier(&public, &msg).unwrap();
960    }
961
962    #[test]
963    fn honest_prove_verify_same_col_different_shifts() {
964        // Column 0 shifted by 2 and by 5
965        let shifts = vec![ShiftSpec::new(0, 2), ShiftSpec::new(0, 5)];
966        let (public, msg) = honest_interaction(4, 3, &shifts);
967        run_verifier(&public, &msg).unwrap();
968    }
969
970    #[test]
971    fn bit_op_virtual_opening_is_bound_in_subclaim() {
972        let cfg = Cfg::default();
973        let shifts = vec![ShiftSpec::new(0, 1)];
974        let (trace_mles, public) = build_trace(3, 2, &shifts);
975
976        let bit_op_mles = vec![DenseMultilinearExtension::from_evaluations_vec(
977            public.num_vars,
978            trace_mles[0]
979                .evaluations
980                .iter()
981                .map(|eval| *eval + F::from(11_u32))
982                .collect(),
983            F::ZERO,
984        )];
985        let bit_op_evals: Vec<F> = bit_op_mles
986            .iter()
987            .map(|mle| mle.clone().evaluate(&cfg, &public.eval_point).unwrap())
988            .collect();
989
990        let mut prover_transcript = make_transcript();
991        let mut prover_outputs = MultipointEval::<Cfg>::prove_as_subprotocol(
992            &mut prover_transcript,
993            vec![MultipointEvalFamilyInputs {
994                field_cfg: &cfg,
995                trace_mles: &trace_mles,
996                bit_op_mles: &bit_op_mles,
997                eval_point: &public.eval_point,
998                up_evals: &public.up_evals,
999                bit_op_evals: &bit_op_evals,
1000                down_evals: &public.down_evals,
1001            }],
1002            &public.shifts,
1003            &cfg,
1004        )
1005        .expect("prover should succeed");
1006        assert_eq!(prover_outputs.len(), 1, "single-family shape");
1007        let (proof, prover_state) = prover_outputs.pop().expect("single family");
1008
1009        let r_0 = &prover_state.eval_point;
1010        let open_evals: Vec<F> = trace_mles
1011            .iter()
1012            .map(|mle| mle.clone().evaluate(&cfg, r_0).unwrap())
1013            .collect();
1014        let bit_op_open_evals: Vec<F> = bit_op_mles
1015            .iter()
1016            .map(|mle| mle.clone().evaluate(&cfg, r_0).unwrap())
1017            .collect();
1018
1019        let mut verifier_transcript = make_transcript();
1020        let mut subclaims = MultipointEval::<Cfg>::verify_as_subprotocol(
1021            &mut verifier_transcript,
1022            vec![proof],
1023            vec![MultipointEvalFamilyInputs {
1024                field_cfg: &cfg,
1025                trace_mles: &[],
1026                bit_op_mles: &[],
1027                eval_point: &public.eval_point,
1028                up_evals: &public.up_evals,
1029                bit_op_evals: &bit_op_evals,
1030                down_evals: &public.down_evals,
1031            }],
1032            &public.shifts,
1033            public.num_vars,
1034            &cfg,
1035        )
1036        .expect("verifier should accept sumcheck");
1037        assert_eq!(subclaims.len(), 1, "single-family shape");
1038        let subclaim = subclaims.pop().expect("single family");
1039
1040        MultipointEval::<Cfg>::verify_subclaim(
1041            &subclaim,
1042            &open_evals,
1043            &bit_op_open_evals,
1044            &public.shifts,
1045            &cfg,
1046        )
1047        .expect("correct bit-op opening should satisfy subclaim");
1048
1049        let mut bad_bit_op_open_evals = bit_op_open_evals;
1050        bad_bit_op_open_evals[0] += F::ONE;
1051        let err = MultipointEval::<Cfg>::verify_subclaim(
1052            &subclaim,
1053            &open_evals,
1054            &bad_bit_op_open_evals,
1055            &public.shifts,
1056            &cfg,
1057        )
1058        .unwrap_err();
1059        assert!(
1060            matches!(err, MultipointEvalError::ClaimMismatch { .. }),
1061            "expected ClaimMismatch, got {err:?}",
1062        );
1063    }
1064
1065    // --- Failure: corrupted down_evals with mixed shifts ---
1066
1067    #[test]
1068    fn bad_down_eval_rejected_mixed_shifts() {
1069        let shifts = vec![ShiftSpec::new(0, 1), ShiftSpec::new(1, 3)];
1070        let (mut public, msg) = honest_interaction(4, 3, &shifts);
1071        public.down_evals[0] += F::ONE;
1072        let err = run_verifier(&public, &msg).unwrap_err();
1073        assert!(
1074            matches!(err, MultipointEvalError::WrongSumcheckSum { .. }),
1075            "expected WrongSumcheckSum, got {err:?}",
1076        );
1077    }
1078
1079    // --- Failure: wrong number of open_evals ---
1080
1081    #[test]
1082    fn wrong_open_evals_count() {
1083        let shifts = all_shift_by_1(3);
1084        let (public, msg) = honest_interaction(3, 3, &shifts);
1085
1086        let mut msg_short = msg.clone();
1087        msg_short.open_evals.pop();
1088
1089        let mut msg_long = msg;
1090        msg_long.open_evals.push(F::from(42_u32));
1091
1092        for bad_msg in [&msg_short, &msg_long] {
1093            let err = run_verifier(&public, bad_msg).unwrap_err();
1094            assert!(
1095                matches!(err, MultipointEvalError::WrongOpenEvalsNumber {
1096                    got,
1097                    expected: 3,
1098                } if got == bad_msg.open_evals.len()),
1099                "expected WrongOpenEvalsNumber, got {err:?}",
1100            );
1101        }
1102    }
1103
1104    // --- Failure: wrong claimed sum ---
1105
1106    #[test]
1107    fn wrong_claimed_sum_via_corrupted_up_evals() {
1108        let shifts = all_shift_by_1(3);
1109        let (mut public, msg) = honest_interaction(3, 3, &shifts);
1110        public.up_evals[0] += F::ONE;
1111        let err = run_verifier(&public, &msg).unwrap_err();
1112        assert!(
1113            matches!(err, MultipointEvalError::WrongSumcheckSum { .. }),
1114            "expected WrongSumcheckSum, got {err:?}",
1115        );
1116    }
1117
1118    #[test]
1119    fn wrong_claimed_sum_via_corrupted_down_evals() {
1120        let shifts = all_shift_by_1(3);
1121        let (mut public, msg) = honest_interaction(3, 3, &shifts);
1122        public.down_evals[1] += F::ONE;
1123        let err = run_verifier(&public, &msg).unwrap_err();
1124        assert!(
1125            matches!(err, MultipointEvalError::WrongSumcheckSum { .. }),
1126            "expected WrongSumcheckSum, got {err:?}",
1127        );
1128    }
1129
1130    // --- Failure: wrong open_evals values ---
1131
1132    #[test]
1133    fn wrong_open_eval_value() {
1134        let shifts = all_shift_by_1(3);
1135        let (public, mut msg) = honest_interaction(3, 3, &shifts);
1136        msg.open_evals[0] += F::ONE;
1137        let err = run_verifier(&public, &msg).unwrap_err();
1138        assert!(
1139            matches!(err, MultipointEvalError::ClaimMismatch { .. }),
1140            "expected ClaimMismatch, got {err:?}",
1141        );
1142    }
1143
1144    #[test]
1145    fn all_open_evals_zeroed() {
1146        let shifts = all_shift_by_1(3);
1147        let (public, mut msg) = honest_interaction(3, 3, &shifts);
1148        for e in &mut msg.open_evals {
1149            *e = F::ZERO;
1150        }
1151        let err = run_verifier(&public, &msg).unwrap_err();
1152        assert!(
1153            matches!(err, MultipointEvalError::ClaimMismatch { .. }),
1154            "expected ClaimMismatch, got {err:?}",
1155        );
1156    }
1157
1158    // --- Failure: mixed shifts ---
1159
1160    fn mixed_shifts() -> Vec<ShiftSpec> {
1161        vec![ShiftSpec::new(0, 1), ShiftSpec::new(1, 3)]
1162    }
1163
1164    #[test]
1165    fn mixed_shifts_corrupted_up_eval() {
1166        let (mut public, msg) = honest_interaction(4, 3, &mixed_shifts());
1167        public.up_evals[2] += F::ONE; // corrupt unshifted column
1168        let err = run_verifier(&public, &msg).unwrap_err();
1169        assert!(
1170            matches!(err, MultipointEvalError::WrongSumcheckSum { .. }),
1171            "expected WrongSumcheckSum, got {err:?}",
1172        );
1173    }
1174
1175    #[test]
1176    fn mixed_shifts_wrong_open_eval() {
1177        let (public, mut msg) = honest_interaction(4, 3, &mixed_shifts());
1178        msg.open_evals[1] += F::ONE; // corrupt a shifted column's opening
1179        let err = run_verifier(&public, &msg).unwrap_err();
1180        assert!(
1181            matches!(err, MultipointEvalError::ClaimMismatch { .. }),
1182            "expected ClaimMismatch, got {err:?}",
1183        );
1184    }
1185
1186    #[test]
1187    fn mixed_shifts_tampered_sumcheck() {
1188        let (public, mut msg) = honest_interaction(4, 3, &mixed_shifts());
1189        msg.proof.sumcheck_proof.group_messages_mut()[0][0]
1190            .0
1191            .tail_evaluations[0] += F::ONE;
1192        let err = run_verifier(&public, &msg).unwrap_err();
1193        assert!(
1194            matches!(
1195                err,
1196                MultipointEvalError::SumcheckError(_) | MultipointEvalError::ClaimMismatch { .. }
1197            ),
1198            "expected sumcheck or consistency error, got {err:?}",
1199        );
1200    }
1201
1202    // --- Failure: tampered sumcheck round messages ---
1203
1204    #[test]
1205    fn tampered_sumcheck_round_message() {
1206        let shifts = all_shift_by_1(3);
1207        let (public, mut msg) = honest_interaction(3, 3, &shifts);
1208        msg.proof.sumcheck_proof.group_messages_mut()[0][0]
1209            .0
1210            .tail_evaluations[0] += F::ONE;
1211        let err = run_verifier(&public, &msg).unwrap_err();
1212        assert!(
1213            matches!(
1214                err,
1215                MultipointEvalError::SumcheckError(_) | MultipointEvalError::ClaimMismatch { .. }
1216            ),
1217            "expected sumcheck or consistency error, got {err:?}",
1218        );
1219    }
1220}