Skip to main content

zinc_piop/sumcheck/
multi_degree.rs

1//! Multi-degree sumcheck: runs multiple degree groups in lockstep with
2//! shared verifier randomness, producing a common evaluation point.
3//!
4//! # Protocol
5//!
6//! Given G degree groups each with (degree_g, mles_g, comb_fn_g):
7//!
8//! 1. Absorb metadata: num_vars, num_groups, per-group degrees
9//! 2. For each round `i = 1..num_vars`:
10//!    - Each group computes its round polynomial `P_g` (parallelizable)
11//!    - Absorb all round messages in deterministic order
12//!    - Sample ONE shared challenge `r_i`
13//!    - All groups fix variable `i` at `r_i`
14//! 3. Each group produces a subclaim at the shared point r = (r_1, ..., r_n)
15
16use crypto_primitives::{BaseFieldConfig, ProjectPrimitiveIntegersWithConfig, SetConfig};
17use itertools::Itertools;
18#[cfg(feature = "parallel")]
19use rayon::prelude::*;
20use std::marker::PhantomData;
21use zinc_poly::mle::DenseMultilinearExtension;
22use zinc_transcript::traits::{ConstTranscribable, GenTranscribable, Transcribable, Transcript};
23use zinc_utils::{add, cfg_into_iter, cfg_iter, cfg_iter_mut, mul};
24
25use crate::CombFn;
26
27use super::{
28    SumCheckError,
29    prover::{
30        NatEvaluatedPolyWithoutConstant, ProverMsg as SumcheckProverMsg,
31        ProverState as SumcheckProverState,
32    },
33    verifier::VerifierState,
34};
35
36// ---------------------------------------------------------------------------
37// Types
38// ---------------------------------------------------------------------------
39
40/// Output of a [`Round1FastPath::round_1_message`] call.
41/// Carries the asserted sum and the round-1 polynomial tail (evaluations at `1,
42/// 2, ..., degree`, omitting the constant term) that the framework wraps into a
43/// regular [`SumcheckProverMsg`] and absorbs into the transcript.
44pub struct Round1Output<F> {
45    pub asserted_sum: F,
46    pub tail_evaluations: Vec<F>,
47}
48
49/// Opt-in hook on a [`MultiDegreeSumcheckGroup`] that bypasses
50/// [`SumcheckProverState::prove_round`] in the first round and replaces
51/// the full-size MLE fold by a closed-form half-size construction. Groups
52/// that don't supply it are run as usual.
53///
54/// Implementors must produce a round-1 message bit-identical to what the
55/// standard prover would emit and post-fold MLEs bit-identical to what
56/// `fix_variables(cfg, &[r_1])` would produce on the standard
57/// path.
58pub trait Round1FastPath<C: SetConfig>: Send + Sync {
59    /// Closed-form computation of the round-1 polynomial tail plus the
60    /// asserted sum `p_1(0) + p_1(1)`.
61    fn round_1_message(&self, config: &C) -> Round1Output<C::Element>;
62
63    /// Closed-form fold of the group MLEs by the verifier's first
64    /// challenge `r_1`. Returns the half-size MLEs in the same order
65    /// `prepare_sumcheck_group` would have produced for the standard
66    /// path.
67    fn fold_with_challenge(
68        self: Box<Self>,
69        challenge: &C::Element,
70        config: &C,
71    ) -> Vec<DenseMultilinearExtension<C::Element>>;
72}
73
74/// A single degree group for the multi-degree sumcheck: (degree, mles,
75/// comb_fn).
76pub struct MultiDegreeSumcheckGroup<C: SetConfig> {
77    degree: usize,
78    poly: Vec<DenseMultilinearExtension<C::Element>>,
79    comb_fn: CombFn<C::Element>,
80    round_1_fast_path: Option<Box<dyn Round1FastPath<C>>>,
81}
82
83impl<C: SetConfig> MultiDegreeSumcheckGroup<C> {
84    pub fn new(
85        degree: usize,
86        poly: Vec<DenseMultilinearExtension<C::Element>>,
87        comb_fn: CombFn<C::Element>,
88    ) -> Self {
89        Self {
90            degree,
91            poly,
92            comb_fn,
93            round_1_fast_path: None,
94        }
95    }
96
97    pub fn new_with_fast_path(
98        degree: usize,
99        poly: Vec<DenseMultilinearExtension<C::Element>>,
100        comb_fn: CombFn<C::Element>,
101        round_1_fast: Box<dyn Round1FastPath<C>>,
102    ) -> Self {
103        Self {
104            degree,
105            poly,
106            comb_fn,
107            round_1_fast_path: Some(round_1_fast),
108        }
109    }
110}
111
112/// Proof for a multi-degree sumcheck.
113///
114/// `group_messages[g][round]` = prover message for group g in that round.
115/// All groups share verifier challenges, common evaluation point.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct MultiDegreeSumcheckProof<F> {
118    /// List of prover messages, one for each round per group.
119    group_messages: Vec<Vec<SumcheckProverMsg<F>>>,
120    // The claimed sum for the first round polynomial per group.
121    claimed_sums: Vec<F>,
122    // Max degrees per group.
123    degrees: Vec<usize>,
124}
125
126impl<F> MultiDegreeSumcheckProof<F> {
127    /// Maps every field element through `f`, preserving structure — used to
128    /// lift elements into wire integers and to project wire integers back
129    /// into elements at the (de)serialization boundary.
130    pub fn try_map<T, E>(
131        &self,
132        f: impl FnMut(&F) -> Result<T, E> + Copy,
133    ) -> Result<MultiDegreeSumcheckProof<T>, E> {
134        Ok(MultiDegreeSumcheckProof {
135            group_messages: self
136                .group_messages
137                .iter()
138                .map(|msgs| msgs.iter().map(|m| m.try_map(f)).try_collect())
139                .try_collect()?,
140            claimed_sums: self.claimed_sums.iter().map(f).try_collect()?,
141            degrees: self.degrees.clone(),
142        })
143    }
144
145    /// Needed by the verifier to check against expected
146    /// sums before running the sumcheck.
147    pub fn claimed_sums(&self) -> &[F] {
148        &self.claimed_sums
149    }
150
151    #[cfg(test)]
152    pub fn group_messages_mut(&mut self) -> &mut [Vec<SumcheckProverMsg<F>>] {
153        &mut self.group_messages
154    }
155}
156
157/// The proof is transcribed as raw field elements without field metadata:
158/// the field config is bound into the transcript separately, at the top
159/// level of the surrounding proof.
160impl<F: ConstTranscribable> GenTranscribable for MultiDegreeSumcheckProof<F> {
161    fn read_transcription_bytes_exact(bytes: &[u8]) -> Self {
162        let (num_groups, bytes) = u32::read_transcription_bytes_subset(bytes);
163        let num_groups = usize::try_from(num_groups).expect("group count must fit into usize");
164
165        let (num_vars, mut bytes) = u32::read_transcription_bytes_subset(bytes);
166        let num_vars = usize::try_from(num_vars).expect("num_vars must fit into usize");
167
168        let mut degrees = Vec::with_capacity(num_groups);
169        for _ in 0..num_groups {
170            let (deg, rest) = u32::read_transcription_bytes_subset(bytes);
171            degrees.push(usize::try_from(deg).expect("degree must fit into usize"));
172            bytes = rest;
173        }
174
175        let mut group_messages = Vec::with_capacity(num_groups);
176        for &deg in &degrees {
177            let msg_bytes = mul!(deg, F::NUM_BYTES);
178            let mut msgs = Vec::with_capacity(num_vars);
179            for _ in 0..num_vars {
180                let tail_evaluations: Vec<F> =
181                    Vec::read_transcription_bytes_exact(&bytes[..msg_bytes]);
182                msgs.push(SumcheckProverMsg(NatEvaluatedPolyWithoutConstant {
183                    tail_evaluations,
184                }));
185                bytes = &bytes[msg_bytes..];
186            }
187            group_messages.push(msgs);
188        }
189
190        let mut claimed_sums = Vec::with_capacity(num_groups);
191        for _ in 0..num_groups {
192            let cs = F::read_transcription_bytes_exact(&bytes[..F::NUM_BYTES]);
193            claimed_sums.push(cs);
194            bytes = &bytes[F::NUM_BYTES..];
195        }
196
197        Self {
198            group_messages,
199            claimed_sums,
200            degrees,
201        }
202    }
203
204    fn write_transcription_bytes_exact(&self, mut buf: &mut [u8]) {
205        let num_groups =
206            u32::try_from(self.group_messages.len()).expect("num groups must fit into u32");
207        num_groups.write_transcription_bytes_exact(&mut buf[..u32::NUM_BYTES]);
208        buf = &mut buf[u32::NUM_BYTES..];
209
210        // All groups share the same number of rounds (num_vars).
211        let num_vars =
212            u32::try_from(self.group_messages[0].len()).expect("num_vars must fit into u32");
213        num_vars.write_transcription_bytes_exact(&mut buf[..u32::NUM_BYTES]);
214        buf = &mut buf[u32::NUM_BYTES..];
215
216        for &deg in &self.degrees {
217            let deg = u32::try_from(deg).expect("degree must fit into u32");
218            deg.write_transcription_bytes_exact(&mut buf[..u32::NUM_BYTES]);
219            buf = &mut buf[u32::NUM_BYTES..];
220        }
221
222        for group in &self.group_messages {
223            for msg in group {
224                let evals = &msg.0.tail_evaluations;
225                let end = mul!(evals.len(), F::NUM_BYTES);
226                evals.write_transcription_bytes_exact(&mut buf[..end]);
227                buf = &mut buf[end..];
228            }
229        }
230
231        for cs in &self.claimed_sums {
232            cs.write_transcription_bytes_exact(&mut buf[..F::NUM_BYTES]);
233            buf = &mut buf[F::NUM_BYTES..];
234        }
235    }
236}
237
238impl<F: ConstTranscribable> Transcribable for MultiDegreeSumcheckProof<F> {
239    fn get_num_bytes(&self) -> usize {
240        let num_groups = self.group_messages.len();
241        let num_vars = self.group_messages[0].len();
242        // total_evals = Σ_g (degree_g × num_vars)
243        let total_evals: usize = self.degrees.iter().map(|&d| mul!(d, num_vars)).sum();
244
245        // [num_groups][num_vars][deg₀..degₙ][evals...][claimed_sums]
246        let header = add!(u32::NUM_BYTES, u32::NUM_BYTES);
247        let degrees = mul!(num_groups, u32::NUM_BYTES);
248        let eval_data = mul!(total_evals, F::NUM_BYTES);
249        let claimed = mul!(num_groups, F::NUM_BYTES);
250
251        add!(header, add!(degrees, add!(eval_data, claimed)))
252    }
253}
254
255/// Sub-claims: shared evaluation point + per-group expected evaluation.
256#[derive(Debug)]
257pub struct MultiDegreeSubClaims<F> {
258    point: Vec<F>,
259    expected_evaluations: Vec<F>,
260}
261
262impl<F> MultiDegreeSubClaims<F> {
263    pub fn point(&self) -> &[F] {
264        &self.point
265    }
266
267    pub fn expected_evaluations(&self) -> &[F] {
268        &self.expected_evaluations
269    }
270}
271
272// ---------------------------------------------------------------------------
273// MultiDegreeSumcheck
274// ---------------------------------------------------------------------------
275
276pub struct MultiDegreeSumcheck<C>(PhantomData<C>);
277
278impl<C> MultiDegreeSumcheck<C>
279where
280    C: BaseFieldConfig + ProjectPrimitiveIntegersWithConfig,
281    C::Integer: ConstTranscribable,
282{
283    /// Multi-degree sumcheck prover.
284    ///
285    /// Drives one or more **families** of multi-degree sumchecks in lockstep
286    /// (interleaved), sharing one per-round verifier challenge across **all
287    /// families and all groups within each family**. Each family carries
288    /// its own degree groups in its own field config (i.e. a different
289    /// prime); the shared challenge is sampled once per round as an integer
290    /// in $[0, q^*)$ via `q_star_cfg`, then lifted into each family's field
291    /// via `cfg.project` (a no-op type cast when $q^* \le q_i$).
292    ///
293    /// Proves, for every family $f$ and every group $g$ in that family:
294    ///
295    /// $$
296    /// \sum_{x \in \{0, 1\}^{\text{num\\_vars}}} G_{f, g}(x) =
297    /// \text{claimed\\_sum}_{f, g}
298    /// $$
299    ///
300    /// where $G_{f, g}(x) = \text{comb\\_fn}_{f, g}(\text{mles}_{f, g}(x))$.
301    ///
302    /// Designed to be used as a subprotocol within a larger system: takes
303    /// the FS transcript (`transcript`) as input and returns the **internal
304    /// ProverState** alongside the sumcheck proof for every family. Claimed
305    /// sums are derived by the prover during the first round.
306    ///
307    /// The single-family case (`families.len() == 1`) is the natural
308    /// degenerate form; pass `q_star_cfg = &families[0].1` and the lift is
309    /// the identity.
310    ///
311    /// # Arguments
312    ///
313    /// * `transcript`: Fiat-Shamir transcript.
314    /// * `families`: One entry per family: `(groups, F::Config)`. Each family
315    ///   contributes one or more degree groups that share that family's
316    ///   `F::Config`.
317    /// * `num_vars`: Number of variables (must be consistent across all groups
318    ///   in all families).
319    /// * `q_star_cfg`: Field configuration used for transcript metadata absorbs
320    ///   and per-round challenge squeezes. This is the smallest of the
321    ///   per-family moduli (so every family can losslessly cast the shared
322    ///   integer into its own field).
323    ///
324    /// # Returns
325    ///
326    /// One `(proof, prover_states)` tuple per family, in the order families
327    /// were provided.
328    ///
329    /// # Panics
330    ///
331    /// * If `num_vars == 0`.
332    /// * If `families` is empty or any family has no groups.
333    #[allow(clippy::type_complexity)]
334    pub fn prove_as_subprotocol(
335        transcript: &mut impl Transcript,
336        families: Vec<(Vec<MultiDegreeSumcheckGroup<C>>, &C)>,
337        num_vars: usize,
338        q_star_cfg: &C,
339    ) -> Vec<(
340        MultiDegreeSumcheckProof<C::Element>,
341        Vec<SumcheckProverState<C>>,
342    )> {
343        assert!(
344            num_vars > 0,
345            "Attempts to prove a constant: num_vars must be > 0"
346        );
347        assert!(!families.is_empty(), "need at least one family");
348        for (groups, _) in &families {
349            assert!(!groups.is_empty(), "every family needs at least one group");
350        }
351
352        let num_families = families.len();
353        let mut buf = vec![0; <C::Integer as ConstTranscribable>::NUM_BYTES];
354
355        // Metadata: absorb (num_vars, num_families) under `q_star_cfg` so the
356        // transcript layout is canonical across families.
357        let nvars_field = q_star_cfg.project(&(num_vars as u64));
358        let nfamilies_field = q_star_cfg.project(&(num_families as u64));
359        transcript.absorb_field_element(q_star_cfg, &nvars_field, &mut buf);
360        transcript.absorb_field_element(q_star_cfg, &nfamilies_field, &mut buf);
361
362        // Per-family state, one `Vec` per family.
363        let mut per_family_group_messages: Vec<Vec<Vec<SumcheckProverMsg<C::Element>>>> =
364            Vec::with_capacity(num_families);
365        let mut per_family_claimed_sums: Vec<Vec<C::Element>> = Vec::with_capacity(num_families);
366        let mut per_family_prover_states: Vec<Vec<SumcheckProverState<C>>> =
367            Vec::with_capacity(num_families);
368        let mut per_family_comb_fns: Vec<Vec<CombFn<C::Element>>> =
369            Vec::with_capacity(num_families);
370        let mut per_family_fast_paths: Vec<Vec<Option<Box<dyn Round1FastPath<C>>>>> =
371            Vec::with_capacity(num_families);
372        let mut per_family_cfg: Vec<&C> = Vec::with_capacity(num_families);
373
374        for (groups, cfg) in families {
375            let num_groups = groups.len();
376            let ngroups_field = q_star_cfg.project(&(num_groups as u64));
377            transcript.absorb_field_element(q_star_cfg, &ngroups_field, &mut buf);
378
379            let group_messages: Vec<Vec<SumcheckProverMsg<C::Element>>> = (0..num_groups)
380                .map(|_| Vec::with_capacity(num_vars))
381                .collect();
382            let mut prover_states: Vec<SumcheckProverState<C>> = Vec::with_capacity(num_groups);
383            let mut comb_fns: Vec<CombFn<C::Element>> = Vec::with_capacity(num_groups);
384            let mut fast_paths: Vec<Option<Box<dyn Round1FastPath<C>>>> =
385                Vec::with_capacity(num_groups);
386
387            for group in groups {
388                let degree_field = q_star_cfg.project(&(group.degree as u64));
389                transcript.absorb_field_element(q_star_cfg, &degree_field, &mut buf);
390
391                prover_states.push(SumcheckProverState::new(group.poly, num_vars, group.degree));
392                comb_fns.push(group.comb_fn);
393                fast_paths.push(group.round_1_fast_path);
394            }
395
396            per_family_group_messages.push(group_messages);
397            per_family_claimed_sums.push(Vec::with_capacity(num_groups));
398            per_family_prover_states.push(prover_states);
399            per_family_comb_fns.push(comb_fns);
400            per_family_fast_paths.push(fast_paths);
401            per_family_cfg.push(cfg);
402        }
403
404        // Per-family last challenge in each family's field. The underlying
405        // integer is shared across families; each family lifts it locally.
406        let mut per_family_verifier_msg: Vec<Option<C::Element>> = vec![None; num_families];
407
408        for round in 1..=num_vars {
409            // 1. Each family produces its round messages (per-family cfg).
410            for b in 0..num_families {
411                let cfg = per_family_cfg[b];
412                let verifier_msg = &per_family_verifier_msg[b];
413                let round_msgs: Vec<SumcheckProverMsg<C::Element>> =
414                    cfg_iter_mut!(per_family_prover_states[b])
415                        .zip(cfg_iter!(per_family_comb_fns[b]))
416                        .zip(cfg_iter!(per_family_fast_paths[b]))
417                        .map(|((state, comb_fn), fast_path)| {
418                            if round == 1
419                                && let Some(fast_path) = fast_path.as_ref()
420                            {
421                                // First round: per-group dispatch to fast path if available
422                                let out = fast_path.round_1_message(cfg);
423                                state.asserted_sum = Some(out.asserted_sum);
424                                state.round = 1;
425                                SumcheckProverMsg(NatEvaluatedPolyWithoutConstant::new(
426                                    out.tail_evaluations,
427                                ))
428                            } else {
429                                state.prove_round(verifier_msg, comb_fn, cfg)
430                            }
431                        })
432                        .collect();
433
434                for msg in &round_msgs {
435                    transcript.absorb_field_element_slice(cfg, &msg.0.tail_evaluations, &mut buf);
436                }
437
438                for (j, msg) in round_msgs.into_iter().enumerate() {
439                    per_family_group_messages[b][j].push(msg);
440                }
441            }
442
443            // 2. Sample one shared integer challenge in [0, q*) via q_star_cfg.
444            let shared_chal_q_star: C::Element = transcript.get_field_challenge(q_star_cfg);
445            transcript.absorb_field_element(q_star_cfg, &shared_chal_q_star, &mut buf);
446            let shared_chal_int = q_star_cfg.lift(&shared_chal_q_star);
447
448            // 3. Per family: lift the shared integer into its field and feed each group.
449            //    Install fast-path post-fold MLEs on round 1.
450            for b in 0..num_families {
451                let chal_b = per_family_cfg[b].project(&shared_chal_int);
452                if round == 1 {
453                    per_family_prover_states[b]
454                        .iter_mut()
455                        .zip(per_family_fast_paths[b].iter_mut())
456                        .for_each(|(state, fp_slot)| {
457                            if let Some(fp) = fp_slot.take() {
458                                state.mles = fp.fold_with_challenge(&chal_b, per_family_cfg[b]);
459                                state.skip_next_fold = true;
460                            }
461                        });
462                }
463                per_family_verifier_msg[b] = Some(chal_b);
464            }
465        }
466
467        // Finalize per family (mirrors the original single-family tail).
468        cfg_into_iter!(per_family_prover_states)
469            .zip(cfg_into_iter!(per_family_group_messages))
470            .zip(cfg_into_iter!(per_family_claimed_sums))
471            .zip(cfg_into_iter!(per_family_verifier_msg))
472            .map(
473                |(((mut prover_states, group_messages), mut claimed_sums), last_chal)| {
474                    prover_states.iter_mut().for_each(|state| {
475                        let sum = state
476                            .asserted_sum
477                            .clone()
478                            .expect("asserted sum should be recorded after the first prover round");
479                        claimed_sums.push(sum);
480
481                        if let Some(ref vmsg) = last_chal {
482                            state.randomness.push(vmsg.clone());
483                        }
484                    });
485
486                    let degrees = prover_states.iter().map(|s| s.max_degree).collect();
487                    let proof = MultiDegreeSumcheckProof {
488                        group_messages,
489                        claimed_sums,
490                        degrees,
491                    };
492                    (proof, prover_states)
493                },
494            )
495            .collect()
496    }
497
498    /// Multi-degree sumcheck verifier.
499    ///
500    /// Mirror of [`prove_as_subprotocol`]: drives one or more families of
501    /// multi-degree sumchecks in lockstep, sharing one per-round challenge
502    /// sampled in $[0, q^*)$ via `q_star_cfg` and lifted into each family's
503    /// field. Verifies, for every family $f$ and every group $g$:
504    ///
505    /// $$
506    /// \sum_{x \in \{0, 1\}^{\text{num\\_vars}}} G_{f, g}(x) =
507    /// \text{claimed\\_sum}_{f, g}
508    /// $$
509    ///
510    /// where $G_{f, g}(x) = \text{comb\\_fn}_{f, g}(\text{mles}_{f, g}(x))$.
511    ///
512    /// Returns one `MultiDegreeSubClaims<F>` per family: shared evaluation
513    /// point `r*` (in that family's field) and per-group expected
514    /// evaluations. The caller must verify each family's MLE combination at
515    /// its `r*` equals its expected evaluation.
516    ///
517    /// The single-family case (`proofs.len() == 1`) is the natural degenerate
518    /// form; pass `q_star_cfg = &proofs[0].1`.
519    ///
520    /// # Arguments
521    ///
522    /// * `transcript`: Fiat-Shamir transcript (must match prover state at the
523    ///   start of the sumcheck).
524    /// * `num_vars`: Number of variables (sumcheck rounds).
525    /// * `proofs`: One `(proof, F::Config)` per family.
526    /// * `q_star_cfg`: Field configuration used for transcript metadata absorbs
527    ///   and per-round challenge squeezes (mirror of the prover).
528    ///
529    /// # Panics
530    ///
531    /// * If `num_vars == 0`.
532    /// * If `proofs` is empty or any family's proof has no groups.
533    pub fn verify_as_subprotocol(
534        transcript: &mut impl Transcript,
535        num_vars: usize,
536        proofs: &[(&MultiDegreeSumcheckProof<C::Element>, &C)],
537        q_star_cfg: &C,
538    ) -> Result<Vec<MultiDegreeSubClaims<C::Element>>, SumCheckError<C::Element>> {
539        assert!(
540            num_vars > 0,
541            "Attempts to prove a constant: num_vars must be > 0"
542        );
543        assert!(!proofs.is_empty(), "need at least one family");
544
545        let num_families = proofs.len();
546        let mut buf = vec![0; <C::Integer as ConstTranscribable>::NUM_BYTES];
547
548        // Metadata: (num_vars, num_families) under q_star_cfg.
549        let nvars_field = q_star_cfg.project(&(num_vars as u64));
550        let nfamilies_field = q_star_cfg.project(&(num_families as u64));
551        transcript.absorb_field_element(q_star_cfg, &nvars_field, &mut buf);
552        transcript.absorb_field_element(q_star_cfg, &nfamilies_field, &mut buf);
553
554        let mut per_family_verifier_states: Vec<Vec<VerifierState<C>>> =
555            Vec::with_capacity(num_families);
556        for (proof, cfg) in proofs {
557            let num_groups = proof.degrees.len();
558            assert!(num_groups != 0, "every family needs at least one group");
559            let ngroups_field = q_star_cfg.project(&(num_groups as u64));
560            transcript.absorb_field_element(q_star_cfg, &ngroups_field, &mut buf);
561
562            let states: Vec<VerifierState<C>> = (0..num_groups)
563                .map(|j| {
564                    let degree = proof.degrees[j];
565                    let degree_field = q_star_cfg.project(&(degree as u64));
566                    transcript.absorb_field_element(q_star_cfg, &degree_field, &mut buf);
567                    VerifierState::new(num_vars, degree, *cfg)
568                })
569                .collect();
570
571            for msgs in &proof.group_messages {
572                if msgs.len() != num_vars {
573                    return Err(SumCheckError::InvalidProofLength {
574                        expected: num_vars,
575                        got: msgs.len(),
576                    });
577                }
578            }
579
580            assert_eq!(
581                states.len(),
582                proof.group_messages.len(),
583                "verifier states ({}) must match proof groups ({})",
584                states.len(),
585                proof.group_messages.len(),
586            );
587
588            per_family_verifier_states.push(states);
589        }
590
591        for i in 0..num_vars {
592            // Absorb all families' messages in family-major order to match
593            // the prover.
594            for (proof, cfg) in proofs {
595                proof.group_messages.iter().for_each(|msg| {
596                    transcript.absorb_field_element_slice(
597                        *cfg,
598                        &msg[i].0.tail_evaluations,
599                        &mut buf,
600                    )
601                });
602            }
603
604            // One shared integer challenge per round under q_star_cfg.
605            let shared_chal_q_star: C::Element = transcript.get_field_challenge(q_star_cfg);
606            transcript.absorb_field_element(q_star_cfg, &shared_chal_q_star, &mut buf);
607            let shared_chal_int = q_star_cfg.lift(&shared_chal_q_star);
608
609            for (b, (proof, cfg)) in proofs.iter().enumerate() {
610                let chal_b = cfg.project(&shared_chal_int);
611                per_family_verifier_states[b]
612                    .iter_mut()
613                    .zip(proof.group_messages.iter())
614                    .for_each(|(state, msg)| {
615                        state.verify_round_with_challenge(&msg[i], chal_b.clone())
616                    });
617            }
618        }
619
620        // TODO: parallelize when multiple lookup groups exist
621        let mut output = Vec::with_capacity(num_families);
622        for (b, (proof, _)) in proofs.iter().enumerate() {
623            let states = std::mem::take(&mut per_family_verifier_states[b]);
624            let mut shared_point: Option<Vec<C::Element>> = None;
625            let mut expected_evaluations = Vec::with_capacity(states.len());
626            for (j, state) in states.into_iter().enumerate() {
627                let subclaim = state.check_and_generate_subclaim(proof.claimed_sums[j].clone())?;
628                if let Some(ref p) = shared_point {
629                    debug_assert_eq!(p, &subclaim.point);
630                } else {
631                    shared_point = Some(subclaim.point);
632                }
633                expected_evaluations.push(subclaim.expected_evaluation);
634            }
635            output.push(MultiDegreeSubClaims {
636                point: shared_point.expect("at least one group"),
637                expected_evaluations,
638            });
639        }
640
641        Ok(output)
642    }
643}
644
645// ---------------------------------------------------------------------------
646// Tests
647// ---------------------------------------------------------------------------
648
649#[cfg(test)]
650#[allow(
651    clippy::arithmetic_side_effects,
652    clippy::cast_possible_truncation,
653    clippy::cast_possible_wrap,
654    clippy::cast_sign_loss,
655    clippy::redundant_clone
656)]
657mod tests {
658    use super::*;
659    use crypto_bigint::{U128, const_monty_params};
660    use crypto_primitives::{FixedConfig, crypto_bigint_const_monty::ConstMontyField};
661    use zinc_poly::utils::build_eq_x_r;
662    use zinc_transcript::Blake3Transcript;
663
664    const_monty_params!(TestParams, U128, "00000000b933426489189cb5b47d567f");
665    type F = ConstMontyField<TestParams, { U128::LIMBS }>;
666    type Cfg = FixedConfig<F>;
667
668    /// Two degree groups sharing the same evaluation point.
669    ///
670    /// - Group 0 (degree 2): `eq(y, r) · (a(y) + b(y))`
671    /// - Group 1 (degree 3): `eq(y, r) · a(y) · b(y)`
672    #[test]
673    fn multi_degree_two_groups() {
674        let num_vars = 3;
675        let cfg = &Cfg::default();
676
677        let a_vals: Vec<F> = (0_u32..8).map(|i| F::from(i + 1)).collect();
678        let b_vals: Vec<F> = (0_u32..8).map(|i| F::from(i + 10)).collect();
679
680        let a_mle =
681            DenseMultilinearExtension::from_evaluations_vec(num_vars, a_vals, F::from(0_u32));
682        let b_mle =
683            DenseMultilinearExtension::from_evaluations_vec(num_vars, b_vals, F::from(0_u32));
684
685        let r: Vec<F> = vec![F::from(5_u32), F::from(7_u32), F::from(11_u32)];
686        let eq_r = build_eq_x_r(cfg, &r).unwrap();
687
688        // Group 0 (degree 2): eq · (a + b)
689        let g0 = MultiDegreeSumcheckGroup::new(
690            2,
691            vec![eq_r.clone(), a_mle.clone(), b_mle.clone()],
692            Box::new(|v: &[F]| v[0] * (v[1] + v[2])),
693        );
694
695        // Group 1 (degree 3): eq · a · b
696        let g1 = MultiDegreeSumcheckGroup::new(
697            3,
698            vec![eq_r.clone(), a_mle.clone(), b_mle.clone()],
699            Box::new(|v: &[F]| v[0] * v[1] * v[2]),
700        );
701
702        // Prove (single-family shape: one family with `q_star_cfg = cfg`).
703        let mut pt = Blake3Transcript::new();
704        let mut outputs = MultiDegreeSumcheck::<Cfg>::prove_as_subprotocol(
705            &mut pt,
706            vec![(vec![g0, g1], cfg)],
707            num_vars,
708            cfg,
709        );
710        let (proof, _states) = outputs.pop().expect("single family");
711
712        // Verify
713        let mut vt = Blake3Transcript::new();
714        let mut subclaims_vec = MultiDegreeSumcheck::<Cfg>::verify_as_subprotocol(
715            &mut vt,
716            num_vars,
717            &[(&proof, cfg)],
718            cfg,
719        )
720        .expect("verification should succeed");
721        let subclaims = subclaims_vec.pop().expect("single family");
722
723        assert_eq!(subclaims.expected_evaluations.len(), 2);
724
725        // Check final evaluations manually
726        let point = &subclaims.point;
727        let eq_eval = zinc_poly::utils::eq_eval(cfg, point, &r).unwrap();
728        let a_eval = a_mle.evaluate(cfg, point).unwrap();
729        let b_eval = b_mle.evaluate(cfg, point).unwrap();
730
731        assert_eq!(
732            subclaims.expected_evaluations[0],
733            eq_eval * (a_eval + b_eval)
734        );
735        assert_eq!(subclaims.expected_evaluations[1], eq_eval * a_eval * b_eval);
736    }
737
738    /// Multi-degree sumcheck with a single group produces a valid subclaim.
739    #[test]
740    fn multi_degree_single_group() {
741        let num_vars = 2;
742        let cfg = &Cfg::default();
743
744        let vals: Vec<F> = (0_u32..4).map(|i| F::from(i + 1)).collect();
745        let mle = DenseMultilinearExtension::from_evaluations_vec(num_vars, vals, F::from(0_u32));
746
747        let r: Vec<F> = vec![F::from(3_u32), F::from(7_u32)];
748        let eq_r = build_eq_x_r(cfg, &r).unwrap();
749
750        let g = MultiDegreeSumcheckGroup::new(
751            2,
752            vec![eq_r.clone(), mle.clone()],
753            Box::new(|v: &[F]| v[0] * v[1]),
754        );
755
756        let mut pt = Blake3Transcript::new();
757        let mut outputs = MultiDegreeSumcheck::<Cfg>::prove_as_subprotocol(
758            &mut pt,
759            vec![(vec![g], cfg)],
760            num_vars,
761            cfg,
762        );
763        let (proof, _) = outputs.pop().expect("single family");
764
765        let mut vt = Blake3Transcript::new();
766        let mut subclaims_vec = MultiDegreeSumcheck::<Cfg>::verify_as_subprotocol(
767            &mut vt,
768            num_vars,
769            &[(&proof, cfg)],
770            cfg,
771        )
772        .expect("verification should succeed");
773        let subclaims = subclaims_vec.pop().expect("single family");
774
775        let point = &subclaims.point;
776        let eq_eval = zinc_poly::utils::eq_eval(cfg, point, &r).unwrap();
777        let a_eval = mle.clone().evaluate(cfg, point).unwrap();
778
779        assert_eq!(subclaims.expected_evaluations[0], eq_eval * a_eval);
780    }
781
782    /// Two families sharing the same `F::Config` (i.e. $q^* = q_i$ for every
783    /// family): every family must accept the shared per-round challenge in
784    /// its field, and the resulting subclaims must verify per family.
785    ///
786    /// Exercises the multi-family behavior of the unified API (one shared
787    /// integer challenge per round, lifted into each family).
788    #[test]
789    fn two_families_same_cfg() {
790        let num_vars = 3;
791        let cfg = &Cfg::default();
792        let q_star_cfg = cfg;
793
794        // Group A: degree-2 group on (a, b).
795        let a_vals: Vec<F> = (0_u32..8).map(|i| F::from(i + 1)).collect();
796        let b_vals: Vec<F> = (0_u32..8).map(|i| F::from(i + 10)).collect();
797        let a_mle =
798            DenseMultilinearExtension::from_evaluations_vec(num_vars, a_vals, F::from(0_u32));
799        let b_mle =
800            DenseMultilinearExtension::from_evaluations_vec(num_vars, b_vals, F::from(0_u32));
801        let r_a: Vec<F> = vec![F::from(5_u32), F::from(7_u32), F::from(11_u32)];
802        let eq_r_a = build_eq_x_r(cfg, &r_a).unwrap();
803        let group_a = MultiDegreeSumcheckGroup::new(
804            2,
805            vec![eq_r_a.clone(), a_mle.clone(), b_mle.clone()],
806            Box::new(|v: &[F]| v[0] * (v[1] + v[2])),
807        );
808
809        // Group B: degree-3 group on (a, b), different evaluation point.
810        let r_b: Vec<F> = vec![F::from(2_u32), F::from(3_u32), F::from(5_u32)];
811        let eq_r_b = build_eq_x_r(cfg, &r_b).unwrap();
812        let group_b = MultiDegreeSumcheckGroup::new(
813            3,
814            vec![eq_r_b.clone(), a_mle.clone(), b_mle.clone()],
815            Box::new(|v: &[F]| v[0] * v[1] * v[2]),
816        );
817
818        // Prover.
819        let mut pt = Blake3Transcript::new();
820        let mut outputs = MultiDegreeSumcheck::<Cfg>::prove_as_subprotocol(
821            &mut pt,
822            vec![(vec![group_a], cfg), (vec![group_b], cfg)],
823            num_vars,
824            q_star_cfg,
825        );
826        assert_eq!(outputs.len(), 2);
827        let (proof_b, _) = outputs.pop().unwrap();
828        let (proof_a, _) = outputs.pop().unwrap();
829
830        // Verifier.
831        let mut vt = Blake3Transcript::new();
832        let subclaims = MultiDegreeSumcheck::<Cfg>::verify_as_subprotocol(
833            &mut vt,
834            num_vars,
835            &[(&proof_a, cfg), (&proof_b, cfg)],
836            q_star_cfg,
837        )
838        .expect("verification should succeed");
839        assert_eq!(subclaims.len(), 2);
840
841        // Both families must land at the same shared point (same cfg -> same lift).
842        assert_eq!(subclaims[0].point(), subclaims[1].point());
843
844        // Per-family subclaim checks against the polynomial identity.
845        let point = subclaims[0].point();
846        let eq_a_eval = zinc_poly::utils::eq_eval(cfg, point, &r_a).unwrap();
847        let eq_b_eval = zinc_poly::utils::eq_eval(cfg, point, &r_b).unwrap();
848        let a_eval = a_mle.evaluate(cfg, point).unwrap();
849        let b_eval = b_mle.evaluate(cfg, point).unwrap();
850
851        assert_eq!(
852            subclaims[0].expected_evaluations()[0],
853            eq_a_eval * (a_eval + b_eval),
854        );
855        assert_eq!(
856            subclaims[1].expected_evaluations()[0],
857            eq_b_eval * a_eval * b_eval,
858        );
859    }
860}