Skip to main content

zinc_piop/
ideal_check.rs

1//! Ideal-check subprotocol.
2mod batched_ideal_check;
3mod combined_poly_builder;
4mod structs;
5
6pub use batched_ideal_check::BatchedIdealCheckError;
7pub use structs::*;
8
9use crate::projections::{
10    ColumnMajorTrace, ProjectedScalars, RowMajorTrace, column_major_to_row_major,
11};
12use batched_ideal_check::*;
13use crypto_primitives::{BaseFieldConfig, ProjectPrimitiveIntegersWithConfig};
14use std::marker::PhantomData;
15use thiserror::Error;
16use zinc_poly::{
17    EvaluationError,
18    univariate::dynamic::{DynamicPolynomial, DynamicPolynomialConfig, HasDynamicPolynomialConfig},
19    utils::ArithErrors as PolyArithErrors,
20};
21use zinc_transcript::traits::{ConstTranscribable, Transcript};
22use zinc_uair::{
23    Uair,
24    degree_counter::count_constraint_degrees,
25    ideal::{Ideal, IdealCheck},
26    ideal_collector::{IdealOrZero, collect_ideals},
27};
28use zinc_utils::sub;
29
30/// Ideal-check subprotocol.
31///
32/// The evaluation point $r \in F^\mu$ at which the
33/// combined polynomial MLEs are pinned down is supplied **by the caller**
34/// rather than squeezed from the transcript inside this subprotocol:
35///
36/// The protocol layer samples a single shared integer vector
37/// $r \in [0, q^*)^\mu$ once and projects it into each family's field,
38/// so all $n + 1$ families re-use the same underlying integers (just typed in
39/// their respective fields).
40#[derive(Default, Clone, Copy)]
41pub struct IdealCheckProtocol<U: Uair>(PhantomData<U>);
42
43impl<U: Uair> IdealCheckProtocol<U> {
44    /// Prover using MLE-first evaluation (column-indexed trace).
45    ///
46    /// Routes each constraint through the most efficient evaluation path:
47    /// - Linear constraints with non-zero ideals are batched through
48    ///   [`evaluate_combined_polynomials`], which evaluates trace column MLEs
49    ///   at the challenge point and then applies the constraints to the
50    ///   evaluated values.
51    /// - Non-linear constraints with non-zero ideals fall back to the row-major
52    ///   [`evaluate_for_constraints`] path; the trace is transposed on demand
53    ///   via [`column_major_to_row_major`].
54    /// - Constraints with zero ideals are short-circuited to zero (their
55    ///   combined polynomial value is zero by construction for an honest
56    ///   prover).
57    ///
58    /// For $F_{q_i}[X]$ constraints, `trace_matrix` and `projected_scalars`
59    /// must already be projected mod $q_i$.
60    ///
61    /// # Parameters
62    /// - `transcript`: the Fiat-Shamir transcript.
63    /// - `trace_matrix`: input trace for the UAIR `U` projected to
64    ///   `DynamicPolynomialF<F>`, column-indexed: `trace_matrix[col][row]`.
65    /// - `projected_scalars`: UAIR scalars projected to
66    ///   `DynamicPolynomialF<F>`.
67    /// - `family_idx`: which constraint family to prove. `0` -> $Q[X]$; `i >=
68    ///   1` -> $F_{q_{i-1}}[X]$ (i.e. UAIR-level `prime_idx = i - 1`).
69    /// - `num_constraints`: number of constraints this UAIR encodes.
70    /// - `evaluation_point`: pre-sampled MLE evaluation point, shared for all
71    ///   families of constraints ($Q[X]$ and $F_q[X]$).
72    /// - `field_cfg`: random field configuration sampled on the previous steps
73    ///   of the overall protocol.
74    #[allow(clippy::type_complexity, clippy::arithmetic_side_effects)]
75    pub fn prove_mle_first<C, const DEGREE_PLUS_ONE: usize>(
76        transcript: &mut impl Transcript,
77        trace_matrix: &ColumnMajorTrace<C::Element>,
78        projected_scalars: &ProjectedScalars<U::Scalar, DynamicPolynomial<C::Element>>,
79        family_idx: usize,
80        num_constraints: usize,
81        evaluation_point: &[C::Element],
82        field_cfg: &C,
83    ) -> Result<Proof<C::Element>, IdealCheckError<C::Element>>
84    where
85        C: BaseFieldConfig + ProjectPrimitiveIntegersWithConfig,
86        C::Integer: ConstTranscribable,
87    {
88        // Classify constraints to drive dispatch below:
89        // * Linear non-zero-ideal goes through the column-major MLE-first path
90        // * Linear zero-ideal constraints need no tracking — their value is zero by
91        //   construction.
92        // * Non-linear non-zero-ideal goes through the row-major fallback
93        // * Non-linear zero-ideal entries are zeroed afterwards.
94        let ideal_collector = collect_ideals::<U>(num_constraints);
95        let degrees = count_constraint_degrees::<U>();
96
97        let mut has_linear_nonzero: bool = false;
98        let mut nonlinear_zero: Vec<usize> = Vec::new();
99        let mut nonlinear_nonzero: Vec<usize> = Vec::new();
100
101        macro_rules! categorize_ideals {
102            ($ideals:expr, $degrees:expr) => {
103                for (idx, ideal) in $ideals.iter().enumerate() {
104                    if $degrees[idx] <= 1 {
105                        has_linear_nonzero |= !ideal.is_zero_ideal();
106                    } else if ideal.is_zero_ideal() {
107                        nonlinear_zero.push(idx);
108                    } else {
109                        nonlinear_nonzero.push(idx);
110                    }
111                }
112            };
113        }
114
115        if family_idx == 0 {
116            categorize_ideals!(ideal_collector.ideals, degrees.q_degrees);
117        } else {
118            let prime_idx = family_idx - 1;
119            categorize_ideals!(
120                ideal_collector.fq_ideals[prime_idx],
121                degrees.fq_degrees[prime_idx]
122            );
123        }
124
125        // When any linear non-zero-ideal constraint exists, run
126        // `evaluate_combined_polynomials` once: all linear entries (zero or not) come
127        // out correct.
128        // Non-linear entries are garbage and get replaced below.
129        let mut combined_mle_values: Vec<DynamicPolynomial<C::Element>> = if has_linear_nonzero {
130            let mut res =
131                combined_poly_builder::evaluate_combined_polynomials::<_, U, DEGREE_PLUS_ONE>(
132                    trace_matrix,
133                    projected_scalars,
134                    family_idx,
135                    num_constraints,
136                    evaluation_point,
137                    field_cfg,
138                )?;
139
140            // Scrub garbage left by `evaluate_combined_polynomials` at non-linear
141            // zero-ideal indices.
142            for &i in &nonlinear_zero {
143                res[i] = DynamicPolynomial::ZERO;
144            }
145
146            res
147        } else {
148            vec![DynamicPolynomial::ZERO; num_constraints]
149        };
150
151        if !nonlinear_nonzero.is_empty() {
152            // `evaluate_for_constraints` works with row-major traces, so we have to
153            // transpose here.
154            // TODO(alex): Can/should we avoid the transposition?
155            let row_major = column_major_to_row_major(trace_matrix);
156            let values = combined_poly_builder::evaluate_for_constraints::<_, U, DEGREE_PLUS_ONE>(
157                &row_major,
158                projected_scalars,
159                family_idx,
160                num_constraints,
161                field_cfg,
162                &nonlinear_nonzero,
163                evaluation_point,
164            )?;
165            for (&i, v) in nonlinear_nonzero.iter().zip(values) {
166                combined_mle_values[i] = v;
167            }
168        }
169
170        let mut transcription_buf: Vec<u8> = vec![0; <C::Integer as ConstTranscribable>::NUM_BYTES];
171        combined_mle_values.iter().for_each(|cv| {
172            transcript.absorb_field_element_slice(field_cfg, &cv.coeffs, &mut transcription_buf);
173        });
174
175        Ok(Proof {
176            combined_mle_values,
177        })
178    }
179
180    /// Prover for any UAIR using combined polynomial construction.
181    ///
182    /// Uses row-indexed (transposed) trace for efficient row-by-row
183    /// combined polynomial construction.
184    ///
185    /// For $F_{q_i}[X]$ constraints, `trace_matrix` and `projected_scalars`
186    /// must already be projected mod $q_i$.
187    ///
188    /// # Parameters
189    /// - `transcript`: the Fiat-Shamir transcript.
190    /// - `trace_matrix`: input trace for the UAIR `U` projected to
191    ///   `DynamicPolynomialF<F>`, row-indexed: `trace_matrix[row][col]`.
192    /// - `projected_scalars`: UAIR scalars projected to
193    ///   `DynamicPolynomialF<F>`.
194    /// - `family_idx`: which constraint family to prove. `0` -> $Q[X]$; `i >=
195    ///   1` -> $F_{q_{i-1}}[X]$.
196    /// - `num_constraints`: number of constraints this UAIR encodes.
197    /// - `evaluation_point`: pre-sampled MLE evaluation point, shared for all
198    ///   families of constraints ($Q[X]$ and $F_q[X]$).
199    /// - `field_cfg`: random field configuration sampled on the previous steps
200    ///   of the overall protocol.
201    #[allow(clippy::type_complexity)]
202    pub fn prove_combined<C, const DEGREE_PLUS_ONE: usize>(
203        transcript: &mut impl Transcript,
204        trace_matrix: &RowMajorTrace<C::Element>,
205        projected_scalars: &ProjectedScalars<U::Scalar, DynamicPolynomial<C::Element>>,
206        family_idx: usize,
207        num_constraints: usize,
208        evaluation_point: &[C::Element],
209        field_cfg: &C,
210    ) -> Result<Proof<C::Element>, IdealCheckError<C::Element>>
211    where
212        C: BaseFieldConfig + ProjectPrimitiveIntegersWithConfig,
213        C::Integer: ConstTranscribable,
214    {
215        // Collect ideals to identify assert_zero constraints whose
216        // combined polynomial is zero by construction (for honest provers).
217        macro_rules! get_non_zero_indices {
218            ($ideals:expr) => {
219                $ideals
220                    .iter()
221                    .enumerate()
222                    .filter(|(_, i)| !i.is_zero_ideal())
223                    .map(|(idx, _)| idx)
224                    .collect::<Vec<usize>>()
225            };
226        }
227        let ideal_collector = collect_ideals::<U>(num_constraints);
228        let non_zero_indices: Vec<usize> = if family_idx == 0 {
229            get_non_zero_indices!(ideal_collector.ideals)
230        } else {
231            let prime_idx = sub!(family_idx, 1);
232            get_non_zero_indices!(ideal_collector.fq_ideals.get(prime_idx).unwrap_or(&vec![]))
233        };
234
235        let mut combined_mle_values = vec![DynamicPolynomial::ZERO; num_constraints];
236        if !non_zero_indices.is_empty() {
237            let computed = combined_poly_builder::evaluate_for_constraints::<_, U, DEGREE_PLUS_ONE>(
238                trace_matrix,
239                projected_scalars,
240                family_idx,
241                num_constraints,
242                field_cfg,
243                &non_zero_indices,
244                evaluation_point,
245            )?;
246
247            for (&ci, val) in non_zero_indices.iter().zip(computed) {
248                combined_mle_values[ci] = val;
249            }
250        };
251
252        let mut transcription_buf: Vec<u8> = vec![0; <C::Integer as ConstTranscribable>::NUM_BYTES];
253
254        combined_mle_values.iter().for_each(|v| {
255            transcript.absorb_field_element_slice(field_cfg, &v.coeffs, &mut transcription_buf);
256        });
257
258        Ok(Proof {
259            combined_mle_values,
260        })
261    }
262
263    /// The verifier part of the ideal-check subprotocol.
264    ///
265    /// Mirrors the prover: receives the prover's `combined_mle_values`,
266    /// absorbs them into the transcript, then checks each non-trivial
267    /// constraint's value against the corresponding lifted ideal.
268    ///
269    /// # Parameters
270    /// - `transcript`: the Fiat-Shamir transcript.
271    /// - `proof`: a purported proof produced by the prover.
272    /// - `family_idx`: which constraint family to verify. `0` -> $Q[X]$; `i >=
273    ///   1` -> $F_{q_{i-1}}[X]$.
274    /// - `num_constraints`: the number of constraints the UAIR `U` encodes.
275    /// - `evaluation_point`: pre-sampled MLE evaluation point matching the one
276    ///   used by the prover. The caller is responsible for ensuring transcript
277    ///   ordering matches the prover.
278    /// - `ideal_over_f_from_ref`: since the UAIR `U` is not aware of the field
279    ///   the ideal check is operating on it defines ideals over the ring
280    ///   `IcTypes::Witness`. `ideal_over_f_from_ref` allows to convert the
281    ///   ideals over `IcTypes::Witness` into ideals over the field
282    ///   `IcTypes::F`. Think of this as a projection for ideals.
283    /// - `field_cfg`: random field configuration sampled on the previous steps
284    ///   of the overall protocol.
285    #[allow(clippy::type_complexity, clippy::too_many_arguments)]
286    pub fn verify_as_subprotocol<'cfg, C, IdealOverF, IdealOverFFromRef, IdealOverFFromFqRef>(
287        transcript: &mut impl Transcript,
288        proof: Proof<C::Element>,
289        family_idx: usize,
290        num_constraints: usize,
291        evaluation_point: &[C::Element],
292        ideal_over_f_from_ref: IdealOverFFromRef,
293        ideal_over_f_from_fq_ref: IdealOverFFromFqRef,
294        field_cfg: &'cfg C,
295    ) -> Result<VerifierSubclaim<C::Element>, IdealCheckError<C::Element>>
296    where
297        C: BaseFieldConfig,
298        C::Integer: ConstTranscribable,
299        IdealOverF: Ideal + IdealCheck<DynamicPolynomialConfig<'cfg, C>>,
300        IdealOverFFromRef: Fn(&IdealOrZero<U::Ideal>) -> IdealOverF,
301        IdealOverFFromFqRef: Fn(&IdealOrZero<U::FqIdeal>) -> IdealOverF,
302    {
303        let mut transcription_buf: Vec<u8> = vec![0; <C::Integer as ConstTranscribable>::NUM_BYTES];
304
305        let combined_mle_values = proof.combined_mle_values;
306
307        for mle_value in &combined_mle_values {
308            transcript.absorb_field_element_slice(
309                field_cfg,
310                &mle_value.coeffs,
311                &mut transcription_buf,
312            );
313        }
314
315        let ideal_collector = collect_ideals::<U>(num_constraints);
316
317        macro_rules! collect_non_trivial {
318            ($ideals:expr, $ideal_from_ref:ident) => {
319                $ideals
320                    .iter()
321                    .zip(combined_mle_values.iter())
322                    .filter(|(ideal, _)| !ideal.is_zero_ideal())
323                    .map(|(ideal, value)| ($ideal_from_ref(ideal), value.clone()))
324                    .unzip()
325            };
326        }
327
328        // Only check non-trivial ideals. For assert_zero constraints
329        // the ideal is the zero ideal and the combined polynomial
330        // value is zero by construction; the sumcheck that follows
331        // verifies consistency of the claimed evaluations with the
332        // actual trace.
333        let (non_trivial_ideals, non_trivial_values): (Vec<_>, Vec<_>) = if family_idx == 0 {
334            collect_non_trivial!(ideal_collector.ideals, ideal_over_f_from_ref)
335        } else {
336            let prime_idx = sub!(family_idx, 1);
337            collect_non_trivial!(
338                ideal_collector.fq_ideals.get(prime_idx).unwrap_or(&vec![]),
339                ideal_over_f_from_fq_ref
340            )
341        };
342
343        batched_ideal_check(
344            &field_cfg.dyn_poly_cfg(),
345            &non_trivial_ideals,
346            &non_trivial_values,
347        )?;
348
349        Ok(VerifierSubclaim {
350            evaluation_point: evaluation_point.to_vec(),
351            values: combined_mle_values,
352        })
353    }
354}
355
356#[derive(Clone, Debug, Error)]
357pub enum IdealCheckError<F> {
358    #[error("ideal check prover failed to evaluate an mle: {0}")]
359    MleEvaluationError(#[from] EvaluationError),
360    #[error("mle evaluation ideal check failure: {0}")]
361    IdealCollectorError(#[from] BatchedIdealCheckError<DynamicPolynomial<F>>),
362    #[error("`eq` polynomial construction failure: {0}")]
363    EqPolyConstructionError(#[from] PolyArithErrors),
364}
365
366#[allow(clippy::arithmetic_side_effects, clippy::redundant_clone)]
367#[cfg(test)]
368mod tests {
369    use crate::test_utils::{
370        LIMBS, run_ideal_check_prover_combined, run_ideal_check_prover_linear, test_config,
371    };
372    use crypto_primitives::{
373        crypto_bigint_int::Int, crypto_bigint_monty::MontyField, crypto_bigint_uint::Uint,
374    };
375    use rand::rng;
376    use zinc_poly::univariate::{dense::DensePolynomial, dynamic::DynamicPolynomialConfig};
377    use zinc_test_uair::{
378        GenerateRandomTrace, TestUairBitOpsMixedSplice, TestUairNoMultiplication,
379        TestUairSimpleMultiplication,
380    };
381    use zinc_transcript::Blake3Transcript;
382    use zinc_uair::{
383        constraint_counter::count_constraints,
384        ideal::{DegreeOneIdeal, Ideal, IdealCheck},
385    };
386
387    use super::*;
388
389    // TODO(Ilia): These tests are absolute joke.
390    //             Once we have time we need to create a comprehensive test suite
391    //             akin to the one we have for the PCS or the sumcheck.
392
393    fn do_test<
394        U,
395        IdealOverF,
396        IdealOverFFromRef,
397        IdealOverFFromFqRef,
398        const DEGREE_PLUS_ONE: usize,
399    >(
400        num_vars: usize,
401        prime_idx: Option<usize>,
402        ideal_over_f_from_ref: IdealOverFFromRef,
403        ideal_over_f_from_fq_ref: IdealOverFFromFqRef,
404    ) where
405        U: Uair<Scalar = DensePolynomial<Int<5>, DEGREE_PLUS_ONE>>
406            + GenerateRandomTrace<DEGREE_PLUS_ONE, PolyCoeff = Int<5>, Int = Int<5>>,
407        IdealOverF: Ideal + for<'a> IdealCheck<DynamicPolynomialConfig<'a, MontyField<LIMBS>>>,
408        IdealOverFFromRef: Fn(&IdealOrZero<U::Ideal>) -> IdealOverF + Copy,
409        IdealOverFFromFqRef: Fn(&IdealOrZero<U::FqIdeal>) -> IdealOverF + Copy,
410    {
411        let mut rng = rng();
412        let field_cfg = test_config();
413
414        let num_constraints = count_constraints::<U>();
415        let num_constraints = prime_idx
416            .map(|i| num_constraints.for_prime(i))
417            .unwrap_or(num_constraints.q);
418        let family_idx = prime_idx.map_or(0, |i| i + 1);
419
420        // Combined approach
421        {
422            let transcript = Blake3Transcript::new();
423            let (proof, evaluation_point, ..) = run_ideal_check_prover_combined::<U, DEGREE_PLUS_ONE>(
424                num_vars,
425                &U::generate_random_trace(num_vars, &mut rng),
426                prime_idx,
427                &mut transcript.clone(),
428            );
429
430            // With the unified-challenge shape, the evaluation point is
431            // sampled by the caller; mirror the prover here.
432            let verifier_result = IdealCheckProtocol::<U>::verify_as_subprotocol(
433                &mut transcript.clone(),
434                proof,
435                family_idx,
436                num_constraints,
437                &evaluation_point,
438                ideal_over_f_from_ref,
439                ideal_over_f_from_fq_ref,
440                &field_cfg,
441            )
442            .expect("Verification failed");
443
444            assert_eq!(evaluation_point, verifier_result.evaluation_point);
445        }
446
447        // MLE-first
448        {
449            let transcript = Blake3Transcript::new();
450            let (proof, evaluation_point, ..) = run_ideal_check_prover_linear::<U, DEGREE_PLUS_ONE>(
451                num_vars,
452                &U::generate_random_trace(num_vars, &mut rng),
453                prime_idx,
454                &mut transcript.clone(),
455            );
456
457            let verifier_result = IdealCheckProtocol::<U>::verify_as_subprotocol(
458                &mut transcript.clone(),
459                proof,
460                family_idx,
461                num_constraints,
462                &evaluation_point,
463                ideal_over_f_from_ref,
464                ideal_over_f_from_fq_ref,
465                &field_cfg,
466            )
467            .expect("Verification failed");
468
469            assert_eq!(evaluation_point, verifier_result.evaluation_point);
470        }
471    }
472
473    #[test]
474    fn test_successful_verification() {
475        let field_cfg = test_config();
476
477        let num_vars = 2;
478
479        // Linear UAIR with non-zero ideals
480        do_test::<TestUairNoMultiplication<Int<5>, Uint<LIMBS>>, _, _, _, 32>(
481            num_vars,
482            None,
483            |ideal_over_ring| ideal_over_ring.map(|i| DegreeOneIdeal::project(&field_cfg, i)),
484            |_| unreachable!("F_q[X] should not be used for this UAIR"),
485        );
486
487        // Non-linear UAIR with all-zero ideals
488        do_test::<TestUairSimpleMultiplication<Int<5>, Uint<LIMBS>>, _, _, _, 32>(
489            num_vars,
490            None,
491            |_ideal_over_ring| IdealOrZero::<DegreeOneIdeal<_>>::zero(),
492            |_| unreachable!("F_q[X] should not be used for this UAIR"),
493        );
494
495        // Linear UAIR with bit-op virtuals and mixed down-row splicing.
496        do_test::<TestUairBitOpsMixedSplice<Int<5>, Uint<LIMBS>>, _, _, _, 32>(
497            num_vars,
498            None,
499            |ideal_over_ring| ideal_over_ring.map(|i| DegreeOneIdeal::project(&field_cfg, i)),
500            |_| unreachable!("F_q[X] should not be used for this UAIR"),
501        );
502    }
503}