Skip to main content

zinc_protocol/
lib.rs

1//! Zinc+ PIOP for UCS - end-to-end protocol.
2//!
3//! Implements the Zinc+ compiler pipeline (cf. paper, Section "Zinc+
4//! Compiler"):
5//!
6//! ```text
7//! Z[X]  --\phi_q-->  F_q[X]  --MLE eval-->  F_q[X]  --\psi_a-->  F_q
8//!         Step 1               Step 2                  Step 3
9//! ```
10//!
11//! After the three compiler steps, the protocol continues with:
12//!
13//! - Combined CPR + Booleanity + Lookup multi-degree sumcheck (CPR group at
14//!   degree `max_deg+2`; optional degree-3 booleanity groups for committed
15//!   witness binary columns and affine virtual targets; one lookup group per
16//!   table type; shared eval point `r*`)
17//! - $\alpha'$ bridge: squeeze a fresh challenge $\alpha'$ after the booleanity
18//!   `bit_slice_evals` are absorbed, and append one extra $\alpha'$-projected
19//!   MLE + up-eval to the multipoint-eval inputs per witness binary-poly column
20//!   (see `BooleanityChecker`)
21//! - Affine virtual bridge: collapse each unshifted virtual at the same
22//!   `alpha'` and compare it directly with the affine combination of its
23//!   public/committed source projections at `r*`
24//! - Multi-point evaluation sumcheck (combines up/down evals at `r*` into a
25//!   single evaluation point `r_0`)
26//! - Lift-and-project (unprojected MLE evaluations at `r_0`)
27//! - Zip+ PCS open/verify at `r_0`
28
29pub mod fold;
30pub mod prover;
31pub mod shared_challenge;
32pub mod verifier;
33
34#[cfg(feature = "parallel")]
35use rayon::prelude::*;
36
37use crate::fold::FoldTrace;
38use crypto_primitives::{
39    BaseFieldConfig, ConstIntRing, ConstIntSemiring, ProjectElementWithConfig,
40    ProjectPrimitiveIntegersWithConfig, Semiring, SetElement, Wrapper,
41};
42use std::{
43    fmt::{Debug, Display},
44    iter,
45    marker::PhantomData,
46};
47use thiserror::Error;
48use zinc_piop::{
49    combined_poly_resolver::{CombinedPolyResolverError, Proof as CombinedPolyResolverProof},
50    ideal_check::{IdealCheckError, Proof as IdealCheckProof},
51    lookup::{
52        BatchedLookupProof, LookupError,
53        booleanity::{BooleanityError, BooleanityProof},
54    },
55    multipoint_eval::{MultipointEvalError, Proof as MultipointEvalProof},
56    projections::ProjectedTrace,
57    sumcheck::multi_degree::MultiDegreeSumcheckProof,
58};
59use zinc_poly::{
60    ConstCoeffBitWidth, EvaluationError as PolyEvaluationError,
61    mle::DenseMultilinearExtension,
62    univariate::{
63        binary::BinaryPoly,
64        dense::DensePolynomial,
65        dynamic::{DynamicPolyVec, DynamicPolynomial, HasDynamicPolynomialConfig},
66    },
67};
68use zinc_primality::PrimalityTest;
69use zinc_transcript::{
70    TranscriptError,
71    traits::{ConstTranscribable, GenTranscribable, Transcribable, Transcript},
72};
73use zinc_uair::{Uair, UairSignature};
74use zinc_utils::{cfg_extend, cfg_into_iter, cfg_iter, from_ref::FromRef, named::Named, powers};
75use zip_plus::{
76    ZipError,
77    code::LinearCode,
78    pcs::structs::{ZipPlusCommitment, ZipTypes},
79};
80
81//
82// Data structures
83//
84
85/// Full proof produced by the Zinc+ PIOP for UCS.
86///
87/// # Lifted-eval families
88///
89/// Witness lifted evals are sent **per family**: for each of the $n + 2$
90/// families (Q[X] / $q_0$, the declared $q_1, \dots, q_n$, and the
91/// PCS-only $q''$), the prover sends a vector of `DynamicPolynomial<F>`
92/// carrying the per-family coefficient lift of each witness column. The
93/// verifier reads each family's lifts under that family's field cfg, no
94/// per-coefficient `cfg.project` projection is needed.
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct Proof<F> {
97    /// Zip+ commitments to the witness columns.
98    pub commitments: (ZipPlusCommitment, ZipPlusCommitment, ZipPlusCommitment),
99    /// Serialized PCS proof data (Zip+ proving transcripts).
100    pub zip: Vec<u8>,
101    /// Randomized ideal check proof (Q[X] family).
102    pub ideal_check: IdealCheckProof<F>,
103    /// Combined polynomial resolver proof (up_evals + down_evals).
104    pub cpr_proof: CombinedPolyResolverProof<F>,
105    /// Multi-degree sumcheck proof (CPR group + lookup groups).
106    pub combined_sumcheck: MultiDegreeSumcheckProof<F>,
107    /// Multi-point evaluation sumcheck proof (combines up_evals and
108    /// down_evals at `r*` into a single evaluation point `r_0`).
109    pub multipoint_eval: MultipointEvalProof<F>,
110    /// Witness-only polynomial MLE evaluations at $r_0$, **per constraint
111    /// family**.
112    ///
113    /// Indexing follows the standard family convention used throughout
114    /// the protocol:
115    /// * `witness_lifted_evals[0]` — Q[X] family under $q_0$, $\bar
116    ///   u_j^{(0)}(X) = \sum_b \mathrm{eq}(b, r_0^{(0)}) \cdot u_j(b) \in
117    ///   F_{q_0}[X]$.
118    /// * `witness_lifted_evals[i]` for $i \in 1..=n$ — the $i$-th declared
119    ///   prime family from [`zinc_uair::UairSignature::primes`], lifted into
120    ///   $F_{q_i}[X]$ at $r_0$ projected mod $q_i$.
121    ///
122    /// Length is `n + 1` where `n = primes().len()`. Each inner Vec
123    /// orders columns as `[wit_bin..., wit_arb..., wit_int...]`.
124    ///
125    /// The verifier recomputes per-family public lifted-evals from public
126    /// data, interleaves them with these, evaluates at
127    /// `projecting_elements[family_idx]` for the per-family MP-eval
128    /// consistency check.
129    pub witness_lifted_evals: Vec<Vec<DynamicPolynomial<F>>>,
130    /// Lookup argument proof. `None` when the UAIR has no lookup specs.
131    pub lookup_proof: Option<BatchedLookupProof<F>>,
132    /// Binary-polynomial booleanity argument proof. `None` when the UAIR
133    /// has no witness binary-poly columns (the argument is omitted from
134    /// the multi-degree sumcheck in that case).
135    pub booleanity_proof: Option<BooleanityProof<F>>,
136    /// Affine-virtual booleanity argument proof. `None` when the UAIR has no
137    /// affine virtual specs.
138    pub affine_booleanity_proof: Option<BooleanityProof<F>>,
139    /// Per-prime $F_{q_i}[X]$ ideal-check proofs, one per declared
140    /// prime in [`zinc_uair::UairSignature::primes`], in the same order.
141    /// Empty for UAIRs with $Q[X]$-only constraints.
142    pub ideal_checks_fq: Vec<IdealCheckProof<F>>,
143    /// Per-prime CPR proofs, one per declared prime, produced by the
144    /// lockstep sumcheck in step 5. Empty for UAIRs with $Q[X]$ only
145    /// constraints.
146    pub cpr_proofs_fq: Vec<CombinedPolyResolverProof<F>>,
147    /// Per-prime multi-degree sumcheck proofs, one per declared prime,
148    /// produced by the lockstep sumcheck driver in step 5.
149    /// Empty for UAIRs with $Q[X]$ only constraints.
150    pub combined_sumchecks_fq: Vec<MultiDegreeSumcheckProof<F>>,
151    /// Per-prime multipoint-eval proofs, one per declared prime, produced
152    /// by the lockstep multipoint-eval in step 6.
153    /// Empty for UAIRs with $Q[X]$ only constraints.
154    pub multipoint_evals_fq: Vec<MultipointEvalProof<F>>,
155    /// Witness-only lifted MLE evaluations under the **PCS-only prime
156    /// $q''$**, sampled fresh at step 7 start. Length equals the number of
157    /// witness columns. The verifier uses these directly for the PCS
158    /// evaluation check at $r^\star = r_0 \bmod q''$ — no
159    /// per-coefficient $\phi_{q''}$ projection needed.
160    ///
161    /// Kept separate from `witness_lifted_evals` because $q''$ plays a
162    /// distinct role (PCS-only; no MP-eval / constraint check happens
163    /// under $q''$).
164    ///
165    /// If no $F_q[X]$ constraints are present, this will be `None` to indicate
166    /// $q'' := q_0$ and this is identical to `witness_lifted_evals`.
167    pub witness_lifted_evals_pp: Option<Vec<DynamicPolynomial<F>>>,
168}
169
170fn read_optional_booleanity_proof<F>(bytes: &[u8]) -> (Option<BooleanityProof<F>>, &[u8])
171where
172    F: ConstTranscribable,
173{
174    let (presence, bytes) = u32::read_transcription_bytes_subset(bytes);
175    if presence == 0 {
176        (None, bytes)
177    } else {
178        let (proof, bytes) = BooleanityProof::read_transcription_bytes_subset(bytes);
179        (Some(proof), bytes)
180    }
181}
182
183fn write_optional_booleanity_proof<'a, F>(
184    proof: &Option<BooleanityProof<F>>,
185    mut buf: &'a mut [u8],
186) -> &'a mut [u8]
187where
188    F: ConstTranscribable,
189{
190    buf = u32::from(proof.is_some()).write_transcription_bytes_subset(buf);
191    if let Some(proof) = proof {
192        buf = proof.write_transcription_bytes_subset(buf);
193    }
194    buf
195}
196
197#[allow(clippy::arithmetic_side_effects)]
198fn optional_booleanity_proof_num_bytes<F>(proof: &Option<BooleanityProof<F>>) -> usize
199where
200    F: ConstTranscribable,
201{
202    proof.as_ref().map_or(0, |proof| {
203        BooleanityProof::<F>::LENGTH_NUM_BYTES + proof.get_num_bytes()
204    })
205}
206
207impl<F> GenTranscribable for Proof<F>
208where
209    F: ConstTranscribable,
210{
211    fn read_transcription_bytes_exact(bytes: &[u8]) -> Self {
212        let (commit0, bytes) = ZipPlusCommitment::read_transcription_bytes_subset(bytes);
213        let (commit1, bytes) = ZipPlusCommitment::read_transcription_bytes_subset(bytes);
214        let (commit2, bytes) = ZipPlusCommitment::read_transcription_bytes_subset(bytes);
215
216        let (zip_len, bytes) = u32::read_transcription_bytes_subset(bytes);
217        let zip_len = usize::try_from(zip_len).expect("zip length must fit into usize");
218        let (zip_bytes, bytes) = bytes.split_at(zip_len);
219        let zip = zip_bytes.to_vec();
220
221        let (ideal_check, bytes) = IdealCheckProof::<F>::read_transcription_bytes_subset(bytes);
222        let (resolver, bytes) =
223            CombinedPolyResolverProof::<F>::read_transcription_bytes_subset(bytes);
224        let (combined_sumcheck, bytes) =
225            MultiDegreeSumcheckProof::<F>::read_transcription_bytes_subset(bytes);
226        let (multipoint_eval, bytes) =
227            MultipointEvalProof::<F>::read_transcription_bytes_subset(bytes);
228
229        // witness_lifted_evals: u32 count (= n + 1, one per constraint
230        // family) + length-prefixed DynamicPolyVec entries. Each entry
231        // carries its own field-cfg header.
232        let (n_wlf, mut bytes) = u32::read_transcription_bytes_subset(bytes);
233        let n_wlf = usize::try_from(n_wlf).expect("n_wlf must fit into usize");
234        let mut witness_lifted_evals: Vec<Vec<DynamicPolynomial<F>>> = Vec::with_capacity(n_wlf);
235        for _ in 0..n_wlf {
236            let (wv, rest) = DynamicPolyVec::<F>::read_transcription_bytes_subset(bytes);
237            witness_lifted_evals.push(wv.0);
238            bytes = rest;
239        }
240
241        let (booleanity_proof, bytes) = read_optional_booleanity_proof(bytes);
242        let (affine_booleanity_proof, bytes) = read_optional_booleanity_proof(bytes);
243
244        // ideal_checks_fq: u32 count, then that many length-prefixed
245        // IdealCheckProof entries (one per declared prime).
246        let (n_fq, mut bytes) = u32::read_transcription_bytes_subset(bytes);
247        let n_fq = usize::try_from(n_fq).expect("n_fq must fit into usize");
248        let mut ideal_checks_fq: Vec<IdealCheckProof<F>> = Vec::with_capacity(n_fq);
249        for _ in 0..n_fq {
250            let (ic, rest) = IdealCheckProof::<F>::read_transcription_bytes_subset(bytes);
251            ideal_checks_fq.push(ic);
252            bytes = rest;
253        }
254
255        // cpr_proofs_fq: u32 count + length-prefixed entries.
256        let (n_cpr_fq, mut bytes) = u32::read_transcription_bytes_subset(bytes);
257        let n_cpr_fq = usize::try_from(n_cpr_fq).expect("n_cpr_fq must fit into usize");
258        let mut cpr_proofs_fq: Vec<CombinedPolyResolverProof<F>> = Vec::with_capacity(n_cpr_fq);
259        for _ in 0..n_cpr_fq {
260            let (cpr, rest) =
261                CombinedPolyResolverProof::<F>::read_transcription_bytes_subset(bytes);
262            cpr_proofs_fq.push(cpr);
263            bytes = rest;
264        }
265
266        // combined_sumchecks_fq: u32 count + length-prefixed entries.
267        let (n_sum_fq, mut bytes) = u32::read_transcription_bytes_subset(bytes);
268        let n_sum_fq = usize::try_from(n_sum_fq).expect("n_sum_fq must fit into usize");
269        let mut combined_sumchecks_fq: Vec<MultiDegreeSumcheckProof<F>> =
270            Vec::with_capacity(n_sum_fq);
271        for _ in 0..n_sum_fq {
272            let (sumcheck, rest) =
273                MultiDegreeSumcheckProof::<F>::read_transcription_bytes_subset(bytes);
274            combined_sumchecks_fq.push(sumcheck);
275            bytes = rest;
276        }
277
278        // multipoint_evals_fq: u32 count + length-prefixed entries.
279        let (n_mp_fq, mut bytes) = u32::read_transcription_bytes_subset(bytes);
280        let n_mp_fq = usize::try_from(n_mp_fq).expect("n_mp_fq must fit into usize");
281        let mut multipoint_evals_fq: Vec<MultipointEvalProof<F>> = Vec::with_capacity(n_mp_fq);
282        for _ in 0..n_mp_fq {
283            let (mp, rest) = MultipointEvalProof::<F>::read_transcription_bytes_subset(bytes);
284            multipoint_evals_fq.push(mp);
285            bytes = rest;
286        }
287
288        // witness_lifted_evals_pp: u32 presence flag, then (optionally) single
289        // length-prefixed DynamicPolyVec (q'' family).
290        let (presence, bytes) = u32::read_transcription_bytes_subset(bytes);
291        let (witness_lifted_evals_pp, bytes) = if presence != 0 {
292            let (p, rest) = DynamicPolyVec::<F>::read_transcription_bytes_subset(bytes);
293            (Some(p.0), rest)
294        } else {
295            (None, bytes)
296        };
297
298        // TODO: deserialize lookup_proof once BatchedLookupProof gets
299        // Transcribable impls (lookup is not yet implemented).
300        assert!(bytes.is_empty(), "All bytes should be consumed");
301
302        Self {
303            commitments: (commit0, commit1, commit2),
304            zip,
305            ideal_check,
306            cpr_proof: resolver,
307            combined_sumcheck,
308            multipoint_eval,
309            witness_lifted_evals,
310            lookup_proof: None,
311            booleanity_proof,
312            affine_booleanity_proof,
313            ideal_checks_fq,
314            cpr_proofs_fq,
315            combined_sumchecks_fq,
316            multipoint_evals_fq,
317            witness_lifted_evals_pp,
318        }
319    }
320
321    fn write_transcription_bytes_exact(&self, mut buf: &mut [u8]) {
322        // 3 commitments (ConstTranscribable - no length prefix)
323        buf = self.commitments.0.write_transcription_bytes_subset(buf);
324        buf = self.commitments.1.write_transcription_bytes_subset(buf);
325        buf = self.commitments.2.write_transcription_bytes_subset(buf);
326
327        // zip: u32 length + raw bytes
328        let zip_len = u32::try_from(self.zip.len()).expect("zip length must fit into u32");
329        buf = zip_len.write_transcription_bytes_subset(buf);
330        buf[..self.zip.len()].copy_from_slice(&self.zip);
331        buf = &mut buf[self.zip.len()..];
332
333        // ideal_check: u32 length prefix + data
334        buf = self.ideal_check.write_transcription_bytes_subset(buf);
335
336        // resolver: u32 length prefix + data
337        buf = self.cpr_proof.write_transcription_bytes_subset(buf);
338
339        // combined_sumcheck: u32 length prefix + data
340        buf = self.combined_sumcheck.write_transcription_bytes_subset(buf);
341
342        // multipoint_eval: u32 length prefix + data
343        buf = self.multipoint_eval.write_transcription_bytes_subset(buf);
344
345        // witness_lifted_evals (per constraint family, n + 1 entries):
346        // u32 count + per-family DynamicPolyVec (each carries its own
347        // field-cfg header). Index 0 is Q[X] / q_0, indices 1..=n are
348        // declared primes.
349        let n_wlf = u32::try_from(self.witness_lifted_evals.len())
350            .expect("witness_lifted_evals length must fit into u32");
351        buf = n_wlf.write_transcription_bytes_subset(buf);
352        for wlf in &self.witness_lifted_evals {
353            buf = DynamicPolyVec::reinterpret(wlf).write_transcription_bytes_subset(buf);
354        }
355
356        buf = write_optional_booleanity_proof(&self.booleanity_proof, buf);
357        buf = write_optional_booleanity_proof(&self.affine_booleanity_proof, buf);
358
359        // ideal_checks_fq: u32 count + that many length-prefixed entries.
360        let n_fq = u32::try_from(self.ideal_checks_fq.len())
361            .expect("ideal_checks_fq length must fit into u32");
362        buf = n_fq.write_transcription_bytes_subset(buf);
363        for ic in &self.ideal_checks_fq {
364            buf = ic.write_transcription_bytes_subset(buf);
365        }
366
367        // cpr_proofs_fq: u32 count + length-prefixed entries.
368        let n_cpr_fq = u32::try_from(self.cpr_proofs_fq.len())
369            .expect("cpr_proofs_fq length must fit into u32");
370        buf = n_cpr_fq.write_transcription_bytes_subset(buf);
371        for cpr in &self.cpr_proofs_fq {
372            buf = cpr.write_transcription_bytes_subset(buf);
373        }
374
375        // combined_sumchecks_fq: u32 count + length-prefixed entries.
376        let n_sum_fq = u32::try_from(self.combined_sumchecks_fq.len())
377            .expect("combined_sumchecks_fq length must fit into u32");
378        buf = n_sum_fq.write_transcription_bytes_subset(buf);
379        for sumcheck in &self.combined_sumchecks_fq {
380            buf = sumcheck.write_transcription_bytes_subset(buf);
381        }
382
383        // multipoint_evals_fq: u32 count + length-prefixed entries.
384        let n_mp_fq = u32::try_from(self.multipoint_evals_fq.len())
385            .expect("multipoint_evals_fq length must fit into u32");
386        buf = n_mp_fq.write_transcription_bytes_subset(buf);
387        for mp in &self.multipoint_evals_fq {
388            buf = mp.write_transcription_bytes_subset(buf);
389        }
390
391        // witness_lifted_evals_pp: u32 presence flag, then (optionally) single
392        // length-prefixed DynamicPolyVec (q'' family).
393        let presence = u32::from(self.witness_lifted_evals_pp.is_some());
394        buf = presence.write_transcription_bytes_subset(buf);
395        if let Some(ref lifted_pp) = self.witness_lifted_evals_pp {
396            buf = DynamicPolyVec::reinterpret(lifted_pp).write_transcription_bytes_subset(buf);
397        }
398
399        // TODO: serialize lookup_proof once BatchedLookupProof gets
400        // Transcribable impls (lookup is not yet implemented).
401        let _ = buf;
402    }
403}
404
405impl<F> Transcribable for Proof<F>
406where
407    F: ConstTranscribable,
408{
409    #[allow(clippy::arithmetic_side_effects)]
410    fn get_num_bytes(&self) -> usize {
411        let booleanity_bytes = optional_booleanity_proof_num_bytes(&self.booleanity_proof);
412        let affine_booleanity_bytes =
413            optional_booleanity_proof_num_bytes(&self.affine_booleanity_proof);
414        let ideal_checks_fq_bytes: usize = self
415            .ideal_checks_fq
416            .iter()
417            .map(|ic| IdealCheckProof::<F>::LENGTH_NUM_BYTES + ic.get_num_bytes())
418            .sum();
419        let cpr_proofs_fq_bytes: usize = self
420            .cpr_proofs_fq
421            .iter()
422            .map(|cpr| CombinedPolyResolverProof::<F>::LENGTH_NUM_BYTES + cpr.get_num_bytes())
423            .sum();
424        let combined_sumchecks_fq_bytes: usize = self
425            .combined_sumchecks_fq
426            .iter()
427            .map(|sc| MultiDegreeSumcheckProof::<F>::LENGTH_NUM_BYTES + sc.get_num_bytes())
428            .sum();
429        let multipoint_evals_fq_bytes: usize = self
430            .multipoint_evals_fq
431            .iter()
432            .map(|mp| MultipointEvalProof::<F>::LENGTH_NUM_BYTES + mp.get_num_bytes())
433            .sum();
434        let witness_lifted_evals_bytes: usize = self
435            .witness_lifted_evals
436            .iter()
437            .map(|wlf| {
438                DynamicPolyVec::<F>::LENGTH_NUM_BYTES
439                    + DynamicPolyVec::reinterpret(wlf).get_num_bytes()
440            })
441            .sum();
442        let witness_lifted_evals_pp_bytes = match &self.witness_lifted_evals_pp {
443            Some(wpp) => {
444                DynamicPolyVec::<F>::LENGTH_NUM_BYTES
445                    + DynamicPolyVec::reinterpret(wpp).get_num_bytes()
446            }
447            None => 0,
448        };
449        3 * ZipPlusCommitment::NUM_BYTES
450            + u32::NUM_BYTES
451            + self.zip.len()
452            + IdealCheckProof::<F>::LENGTH_NUM_BYTES
453            + self.ideal_check.get_num_bytes()
454            + CombinedPolyResolverProof::<F>::LENGTH_NUM_BYTES
455            + self.cpr_proof.get_num_bytes()
456            + MultiDegreeSumcheckProof::<F>::LENGTH_NUM_BYTES
457            + self.combined_sumcheck.get_num_bytes()
458            + MultipointEvalProof::<F>::LENGTH_NUM_BYTES
459            + self.multipoint_eval.get_num_bytes()
460            // TODO: add lookup_proof size once BatchedLookupProof gets
461            // Transcribable impls (lookup is not yet implemented).
462            //
463            // witness_lifted_evals: count + sum of (length-prefix + body) per family
464            + u32::NUM_BYTES
465            + witness_lifted_evals_bytes
466            // committed-column booleanity presence flag + optional payload
467            + u32::NUM_BYTES
468            + booleanity_bytes
469            // affine-virtual booleanity presence flag + optional payload
470            + u32::NUM_BYTES
471            + affine_booleanity_bytes
472            // ideal_checks_fq: count + sum of (length-prefix + body) per entry
473            + u32::NUM_BYTES
474            + ideal_checks_fq_bytes
475            // cpr_proofs_fq: count + sum of (length-prefix + body) per entry
476            + u32::NUM_BYTES
477            + cpr_proofs_fq_bytes
478            // combined_sumchecks_fq: count + sum of (length-prefix + body) per entry
479            + u32::NUM_BYTES
480            + combined_sumchecks_fq_bytes
481            // multipoint_evals_fq: count + sum of (length-prefix + body) per entry
482            + u32::NUM_BYTES
483            + multipoint_evals_fq_bytes
484            // witness_lifted_evals_pp: single length-prefixed body
485            + u32::NUM_BYTES
486            + witness_lifted_evals_pp_bytes
487    }
488}
489
490/// Trait bundling the various type parameters for the public inputs (NYI),
491/// witness and Zinc+ PIOP.
492pub trait ZincTypes<const DEGREE_PLUS_ONE: usize, const FOLDED_DEG_PLUS_ONE: usize>:
493    Clone + Debug
494{
495    /// Main integer type for the protocol, used as a coefficient type for the
496    /// arbitrary polynomial trace columns and for the integer trace columns.
497    type Int: Semiring
498        + ConstTranscribable
499        + ConstCoeffBitWidth
500        + Named
501        + Default
502        + Clone
503        + Send
504        + Sync
505        + 'static;
506
507    /// Projecting element to project Zip+ evaluations and UAIR scalars to the
508    /// field.
509    type Chal: ConstIntRing + ConstTranscribable + Named;
510
511    /// Evaluation point type, used for all column types in Zip+ to evaluate
512    /// multilinear polynomials.
513    type Pt: ConstIntRing;
514
515    type CombR;
516
517    /// Randomly sampled field modulus type, used throughout the protocol for
518    /// finite field operations.
519    type Fmod: ConstIntSemiring
520        + ConstTranscribable
521        + FromRef<Self::Fmod>
522        + Display
523        + Named
524        + Send
525        + Sync;
526
527    /// Primality test for the field modulus.
528    type PrimeTest: PrimalityTest<Self::Fmod>;
529
530    /// Zip+ types for the binary polynomial trace columns.
531    type BinaryZt: ZipTypes<
532            Eval = BinaryPoly<FOLDED_DEG_PLUS_ONE>,
533            Chal = Self::Chal,
534            Pt = Self::Pt,
535            CombR = Self::CombR,
536            Fmod = Self::Fmod,
537            PrimeTest = Self::PrimeTest,
538        >;
539
540    /// Zip+ types for the arbitrary polynomial trace columns.
541    type ArbitraryZt: ZipTypes<
542            Eval = DensePolynomial<Self::Int, DEGREE_PLUS_ONE>,
543            Chal = Self::Chal,
544            Pt = Self::Pt,
545            CombR = Self::CombR,
546            Fmod = Self::Fmod,
547            PrimeTest = Self::PrimeTest,
548        >;
549
550    /// Zip+ types for the integer trace columns.
551    type IntZt: ZipTypes<
552            Eval = Self::Int,
553            Chal = Self::Chal,
554            Pt = Self::Pt,
555            CombR = Self::CombR,
556            Fmod = Self::Fmod,
557            PrimeTest = Self::PrimeTest,
558        >;
559
560    type BinaryFold: FoldTrace<BinaryPoly<DEGREE_PLUS_ONE>, BinaryPoly<FOLDED_DEG_PLUS_ONE>>;
561
562    /// Linear code used in Zip+ for the binary polynomial trace columns.
563    type BinaryLc: LinearCode<Self::BinaryZt>;
564
565    /// Linear code used in Zip+ for the arbitrary polynomial trace columns.
566    type ArbitraryLc: LinearCode<Self::ArbitraryZt>;
567
568    /// Linear code used in Zip+ for the integer trace columns.
569    type IntLc: LinearCode<Self::IntZt>;
570}
571
572/// Main struct for the Zinc+ PIOP. The protocol is implemented as associated
573/// functions on it.
574///
575/// (Note that type parameters are further constrained in the impl blocks for
576/// the prover and verifier)
577#[derive(Copy, Clone, Default, Debug)]
578pub struct ZincPlusPiop<Zt, U, C, const DEGREE_PLUS_ONE: usize, const FOLDED_DEGREE_PLUS_ONE: usize>(
579    PhantomData<(Zt, U, C)>,
580)
581where
582    Zt: ZincTypes<DEGREE_PLUS_ONE, FOLDED_DEGREE_PLUS_ONE>,
583    U: Uair,
584    C: BaseFieldConfig;
585
586/// Error type for error happening during the protocol execution (prover and
587/// verifier).
588#[derive(Debug, Error)]
589pub enum ProtocolError<F: SetElement> {
590    #[error("ideal check failed: {0}")]
591    IdealCheck(#[from] IdealCheckError<F>),
592    #[error("combined poly resolver failed: {0}")]
593    Resolver(#[from] CombinedPolyResolverError<F>),
594    #[error("scalar projection failed: {0}")]
595    ScalarProjection(PolyEvaluationError),
596    #[error("multi-point evaluation failed: {0}")]
597    MultipointEval(#[from] MultipointEvalError<F>),
598    #[error("lifted eval psi_a projection failed: {0}")]
599    LiftedEvalProjection(PolyEvaluationError),
600    #[error("lookup argument failed: {0}")]
601    Lookup(#[from] LookupError),
602    #[error("booleanity argument failed: {0}")]
603    Booleanity(#[from] BooleanityError<F>),
604    #[error("booleanity proof missing from proof object")]
605    BooleanityProofMissing,
606    #[error("affine virtual source projection failed: {0}")]
607    AffineVirtualSourceProjection(PolyEvaluationError),
608    #[error(
609        "affine virtual bridge mismatch at spec {spec_index}: got {got:?}, expected {expected:?}"
610    )]
611    AffineVirtualBridgeMismatch {
612        spec_index: usize,
613        got: F,
614        expected: F,
615    },
616    #[error("non-canonical proof element: lifted integer >= family modulus")]
617    NonCanonicalElement,
618    #[error("proof family count mismatch: got {got}, expected {expected}")]
619    FamilyCountMismatch { got: usize, expected: usize },
620    #[error("PCS error: {0}")]
621    Pcs(#[from] ZipError),
622    #[error("PCS verification failed at column {0}: {1}")]
623    PcsVerification(usize, ZipError),
624    /// Happens outside of Zip+, otherwise it would be wrapped in
625    /// [`ZipError::Transcript`]
626    #[error("transcript failure: {0}")]
627    Transcript(#[from] TranscriptError),
628    #[error("F_q[X] ideal check failed at prime_idx {prime_idx} (q = {q}): {source}")]
629    FqIdealCheck {
630        prime_idx: usize,
631        q: String,
632        source: IdealCheckError<F>,
633    },
634    #[error("q'' witness lifted-evals length mismatch: got {got}, expected {expected}")]
635    WitnessLiftedEvalsPpLengthMismatch { got: usize, expected: usize },
636    #[error(
637        "witness lifted-evals length mismatch at family {family_idx}: got {got}, expected {expected}"
638    )]
639    WitnessLiftedEvalsLengthMismatch {
640        family_idx: usize,
641        got: usize,
642        expected: usize,
643    },
644}
645
646//
647// Helper functions
648//
649
650/// Absorb public column entries into the Fiat-Shamir transcript.
651///
652/// Each entry is serialized via `ConstTranscribable::write_transcription_bytes`
653/// and absorbed. This must be called in the same order by both prover and
654/// verifier, after commitments and before the random prime draw.
655fn absorb_public_columns<T: ConstTranscribable>(
656    transcript: &mut impl Transcript,
657    cols: &[DenseMultilinearExtension<T>],
658) {
659    let mut buf = vec![0u8; T::NUM_BYTES];
660    for col in cols {
661        for entry in col.iter() {
662            entry.write_transcription_bytes_exact(&mut buf);
663            transcript.absorb_bytes(&buf);
664        }
665    }
666}
667
668/// Compute per-column lifted MLE evaluations at `point`.
669///
670/// For each column j, returns `\sum_b eq(b, point) * v_j(b)` as a polynomial
671/// in `F_q[X]` (coefficient-wise MLE evaluation). Dispatches on the trace
672/// layout internally.
673///
674/// Binary columns exploit the 0/1 structure for conditional additions only.
675/// The `eq(point, *)` table is built once and reused across all columns.
676#[allow(clippy::arithmetic_side_effects)]
677fn compute_lifted_evals<C: BaseFieldConfig, const D: usize>(
678    point: &[C::Element],
679    trace_bin_poly: &[DenseMultilinearExtension<BinaryPoly<D>>],
680    projected_trace: &ProjectedTrace<C::Element>,
681    field_cfg: &C,
682) -> Vec<DynamicPolynomial<C::Element>> {
683    let eq_table = zinc_poly::utils::build_eq_x_r_vec(field_cfg, point)
684        .expect("compute_lifted_evals: eq table build failed");
685
686    let n_bin = trace_bin_poly.len();
687    let zero = field_cfg.zero();
688    let poly_cfg = field_cfg.dyn_poly_cfg();
689
690    // Binary columns: exploit 0/1 structure for conditional additions.
691    let mut result: Vec<DynamicPolynomial<C::Element>> = cfg_iter!(trace_bin_poly)
692        .map(|col| {
693            let mut coeffs = vec![zero.clone(); D];
694            for (b, entry) in col.iter().enumerate() {
695                for (l, coeff) in entry.iter().enumerate() {
696                    if *coeff.inner() {
697                        field_cfg.add_assign(&mut coeffs[l], &eq_table[b]);
698                    }
699                }
700            }
701            poly_cfg.new_trimmed(coeffs)
702        })
703        .collect();
704
705    // Non-binary columns: coefficient-wise eq-weighted sum.
706    fn weighted_eq_sum<'a, C2: BaseFieldConfig>(
707        cfg: &C2,
708        col: impl Iterator<Item = &'a DynamicPolynomial<C2::Element>> + Clone,
709        eq_table: &[C2::Element],
710        zero: &C2::Element,
711    ) -> DynamicPolynomial<C2::Element>
712    where
713        C2::Element: 'a,
714    {
715        let num_coeffs = col.clone().map(|e| e.coeffs.len()).max().unwrap_or(0);
716        let mut coeffs = vec![zero.clone(); num_coeffs];
717        for (b, entry) in col.enumerate() {
718            for (l, coeff) in entry.coeffs.iter().enumerate() {
719                let term = cfg.mul(&eq_table[b], coeff);
720                cfg.add_assign(&mut coeffs[l], &term);
721            }
722        }
723        cfg.dyn_poly_cfg().new_trimmed(coeffs)
724    }
725
726    match projected_trace {
727        ProjectedTrace::RowMajor(t) => {
728            let num_cols = t.first().map(|r| r.len()).unwrap_or(0);
729            cfg_extend!(
730                result,
731                cfg_into_iter!(n_bin..num_cols).map(|col_idx| weighted_eq_sum(
732                    field_cfg,
733                    t.iter().map(|row| &row[col_idx]),
734                    &eq_table,
735                    &zero,
736                ))
737            );
738        }
739        ProjectedTrace::ColumnMajor(t) => {
740            cfg_extend!(
741                result,
742                cfg_iter!(t[n_bin..]).map(|col_mle| weighted_eq_sum(
743                    field_cfg,
744                    col_mle.iter(),
745                    &eq_table,
746                    &zero,
747                ))
748            );
749        }
750    }
751
752    result
753}
754
755/// Collapse column-major bit-slice evaluations at $\alpha'$:
756///
757/// $$
758///   c_j \;=\; \sum_{i=0}^{D-1} (\alpha')^{i} \cdot
759///     \text{bit\_slice\_evals}[j \cdot D + i].
760/// $$
761///
762/// One $c_j$ is produced per input column, in the same column-major order as
763/// `BooleanityProof::bit_slice_evals`. Committed witness results are appended
764/// to `MultipointEval`'s `up_evals`; affine virtual results are checked against
765/// the corresponding affine combination of source results.
766#[allow(clippy::arithmetic_side_effects)]
767fn collapse_bit_slice_evals<C: BaseFieldConfig, const D: usize>(
768    bit_slice_evals: &[C::Element],
769    num_cols: usize,
770    alpha_prime: &C::Element,
771    field_cfg: &C,
772) -> Vec<C::Element> {
773    debug_assert_eq!(bit_slice_evals.len(), num_cols * D);
774    let alpha_powers: Vec<C::Element> = powers(field_cfg, alpha_prime, D);
775    bit_slice_evals
776        .chunks_exact(D)
777        .map(|slice| {
778            slice
779                .iter()
780                .zip(&alpha_powers)
781                .fold(field_cfg.zero(), |mut acc, (b, alpha_pow)| {
782                    field_cfg.add_assign(&mut acc, &field_cfg.mul(b, alpha_pow));
783                    acc
784                })
785        })
786        .collect()
787}
788
789/// Reconstruct the expected alpha-prime projection of each unshifted affine
790/// virtual from the corresponding source-column projections.
791#[allow(clippy::arithmetic_side_effects)]
792fn expected_affine_virtual_bridge_evals<C, P, const D: usize>(
793    signature: &UairSignature<P>,
794    source_bridge_evals: &[C::Element],
795    alpha_prime: &C::Element,
796    field_cfg: &C,
797) -> Vec<C::Element>
798where
799    C: BaseFieldConfig + ProjectPrimitiveIntegersWithConfig,
800    P: Semiring,
801{
802    debug_assert_eq!(
803        source_bridge_evals.len(),
804        signature.total_cols().num_binary_poly_cols()
805    );
806
807    let ones_projection = powers(field_cfg, alpha_prime, D).into_iter().fold(
808        field_cfg.zero(),
809        |mut acc, alpha_power| {
810            field_cfg.add_assign(&mut acc, &alpha_power);
811            acc
812        },
813    );
814
815    signature
816        .affine_virtual_specs()
817        .iter()
818        .map(|spec| {
819            let mut expected = field_cfg.mul(
820                &field_cfg.project(&spec.ones_coefficient()),
821                &ones_projection,
822            );
823            for term in spec.terms() {
824                debug_assert_eq!(term.row_shift(), 0);
825                let term_eval = field_cfg.mul(
826                    &field_cfg.project(&term.coefficient()),
827                    &source_bridge_evals[term.source_col()],
828                );
829                field_cfg.add_assign(&mut expected, &term_eval);
830            }
831            expected
832        })
833        .collect()
834}
835
836/// Project a binary-polynomial column at a field element by evaluating each
837/// bit-packed cell $\sum_i \mathrm{bit}_i X^i$ at that element.
838#[allow(clippy::arithmetic_side_effects)]
839fn project_binary_col_at_field<C, const D: usize>(
840    col: &DenseMultilinearExtension<BinaryPoly<D>>,
841    alpha_powers: &[C::Element],
842    field_cfg: &C,
843) -> DenseMultilinearExtension<C::Element>
844where
845    C: BaseFieldConfig,
846{
847    debug_assert_eq!(alpha_powers.len(), D);
848    let zero = field_cfg.zero();
849
850    // Sequential row loop: per-row work is at most `D` conditional adds.
851    // Callers parallelize the outer loop over binary-polynomial columns.
852    let evaluations: Vec<C::Element> = col
853        .evaluations
854        .iter()
855        .map(|entry| {
856            let mut acc = zero.clone();
857            for (i, bit) in entry.iter().enumerate() {
858                if *bit.inner() {
859                    field_cfg.add_assign(&mut acc, &alpha_powers[i]);
860                }
861            }
862            acc
863        })
864        .collect();
865
866    DenseMultilinearExtension {
867        num_vars: col.num_vars,
868        evaluations,
869    }
870}
871
872/// Project a DensePolynomial scalar to DynamicPolynomial by projecting each
873/// coefficient via \phi_q.
874pub fn project_scalar_fn<R, C, const D: usize>(
875    scalar: &DensePolynomial<R, D>,
876    field_cfg: &C,
877) -> DynamicPolynomial<C::Element>
878where
879    C: BaseFieldConfig + ProjectElementWithConfig<R>,
880{
881    scalar
882        .iter()
883        .map(|coeff| field_cfg.project(coeff))
884        .collect()
885}
886
887/// Projects a canonical lifted integer from the wire into the field
888/// configured by `cfg`, rejecting non-canonical encodings: every field
889/// value has exactly one accepted wire representation (`0 <= int < q`).
890fn project_canonical<C, F>(cfg: &C, int: &C::Integer) -> Result<C::Element, ProtocolError<F>>
891where
892    C: BaseFieldConfig,
893    F: SetElement,
894{
895    if *int < cfg.modulus() {
896        Ok(cfg.project(int))
897    } else {
898        Err(ProtocolError::NonCanonicalElement)
899    }
900}
901
902/// Build the list of per-family field configs in family order:
903/// `prime_cfgs[0]` is the $Q[X]$ family's sampled prime $q_0$,
904/// `prime_cfgs[1..=n]` are the declared $q_1, ..., q_n$ in
905/// [`zinc_uair::UairSignature::primes`] order.
906///
907/// The family indexing convention follows the paper's
908/// `prot:zincplus-ucs-pior`: family 0 = $Q[X]$,
909/// families $i \ge 1$ = $F_{q_i}[X]$.
910///
911/// Primality is the UAIR author's responsibility (the UAIR is part of the
912/// pre-agreed relation index); no runtime check needed here.
913fn build_all_cfgs<C>(sig: &UairSignature<C::Integer>, qx_cfg: C) -> Vec<C>
914where
915    C: BaseFieldConfig,
916{
917    iter::once(qx_cfg)
918        .chain(
919            sig.primes()
920                .iter()
921                .map(|q| C::new(q).expect("declared prime is assumed prime")),
922        )
923        .collect()
924}
925
926//
927// Tests
928//
929
930#[cfg(test)]
931#[cfg(not(miri))] // long running
932#[allow(
933    clippy::arithmetic_side_effects,
934    clippy::result_large_err,
935    clippy::type_complexity,
936    clippy::cast_possible_truncation,
937    clippy::cast_precision_loss,
938    clippy::cast_sign_loss,
939    clippy::clone_on_copy,
940    clippy::redundant_clone
941)]
942mod tests {
943    use super::*;
944    use crate::fold::FoldBinaryTrace4x;
945    use crypto_primitives::{
946        FieldConfig, LiftElementWithConfig, RingConfig, SemiringConfig,
947        crypto_bigint_int::Int,
948        crypto_bigint_monty::{MontyField, MontyFieldElement},
949        crypto_bigint_uint::{U64, Uint},
950    };
951    use num_traits::{ConstOne, WrappingAdd, Zero};
952    use rand::rng;
953    use zinc_piop::{
954        combined_poly_resolver::CombinedPolyResolverError, multipoint_eval::MultipointEvalError,
955    };
956    use zinc_poly::univariate::{binary::BinaryPolyInnerProduct, dense::DensePolyInnerProduct};
957    use zinc_primality::MillerRabin;
958    use zinc_test_uair::{
959        BigLinearUair, BigLinearUairWithPublicInput, BinaryDecompositionUair, GenerateRandomTrace,
960        ShaProxy, TestUairAffineVirtualPublicOnly, TestUairAffineVirtualUnshifted,
961        TestUairBitOpsFqFamily, TestUairFqLargePrime, TestUairMixedShifts,
962        TestUairNoMultiplication, TestUairSimpleMultiplication,
963    };
964    use zinc_uair::{
965        constraint_counter::count_constraints, ideal::DegreeOneIdeal, ideal_collector::IdealOrZero,
966    };
967    use zinc_utils::{
968        CHECKED,
969        inner_product::{MBSInnerProduct, ScalarProduct},
970        projectable_to_field::ProjectableToField,
971    };
972    use zip_plus::{
973        code::{
974            iprs::{IprsCode, PnttConfigF65537},
975            raa::{RaaCode, RaaConfig},
976        },
977        pcs::structs::{ZipPlus, ZipPlusParams},
978        pcs_transcript::PcsProverTranscript,
979    };
980
981    const INT_LIMBS: usize = U64::LIMBS;
982    const FIELD_LIMBS: usize = U64::LIMBS * 3;
983
984    const D: usize = 32;
985    const HALF_D: usize = D / 2;
986    const QUARTER_D: usize = D / 4;
987
988    // Zip+ type parameters.
989
990    const K: usize = INT_LIMBS * 4;
991    const M: usize = INT_LIMBS * 8;
992
993    const REP_FACTOR: usize = 8;
994
995    type F = MontyField<FIELD_LIMBS>;
996    type E = MontyFieldElement<FIELD_LIMBS>;
997    type ZtFmod = Uint<FIELD_LIMBS>;
998
999    #[derive(Debug, Clone)]
1000    pub struct BinPolyZipTypes {}
1001    impl ZipTypes for BinPolyZipTypes {
1002        const NUM_COLUMN_OPENINGS: usize = 100;
1003        type Eval = BinaryPoly<QUARTER_D>;
1004        type Cw = DensePolynomial<i64, QUARTER_D>;
1005        type Fmod = ZtFmod;
1006        type PrimeTest = MillerRabin;
1007        type Chal = i128;
1008        type Pt = i128;
1009        type CombR = Int<M>;
1010        type Comb = DensePolynomial<Self::CombR, QUARTER_D>;
1011        type EvalDotChal = BinaryPolyInnerProduct<Self::Chal, QUARTER_D>;
1012        type CombDotChal = DensePolyInnerProduct<
1013            (),
1014            Self::CombR,
1015            Self::Chal,
1016            Self::CombR,
1017            MBSInnerProduct,
1018            QUARTER_D,
1019        >;
1020        type ArrCombRDotChal = MBSInnerProduct;
1021    }
1022
1023    #[derive(Debug, Clone)]
1024    pub struct ArbitraryPolyZipTypesIprs {}
1025    impl ZipTypes for ArbitraryPolyZipTypesIprs {
1026        const NUM_COLUMN_OPENINGS: usize = 100;
1027        type Eval = DensePolynomial<i64, D>;
1028        type Cw = DensePolynomial<i64, D>;
1029        type Fmod = ZtFmod;
1030        type PrimeTest = MillerRabin;
1031        type Chal = i128;
1032        type Pt = i128;
1033        type CombR = Int<M>;
1034        type Comb = DensePolynomial<Self::CombR, D>;
1035        type EvalDotChal =
1036            DensePolyInnerProduct<(), i64, Self::Chal, Self::CombR, MBSInnerProduct, D>;
1037        type CombDotChal =
1038            DensePolyInnerProduct<(), Self::CombR, Self::Chal, Self::CombR, MBSInnerProduct, D>;
1039        type ArrCombRDotChal = MBSInnerProduct;
1040    }
1041
1042    /// Arbitrary poly ZipTypes with wider codewords for RAA encoding.
1043    /// RAA accumulation grows the bit-width, so Cw needs more bits than Eval.
1044    #[derive(Debug, Clone)]
1045    pub struct ArbitraryPolyZipTypesRaa {}
1046    impl ZipTypes for ArbitraryPolyZipTypesRaa {
1047        const NUM_COLUMN_OPENINGS: usize = 100;
1048        type Eval = DensePolynomial<i64, D>;
1049        type Cw = DensePolynomial<Int<K>, D>;
1050        type Fmod = ZtFmod;
1051        type PrimeTest = MillerRabin;
1052        type Chal = i128;
1053        type Pt = i128;
1054        type CombR = Int<M>;
1055        type Comb = DensePolynomial<Self::CombR, D>;
1056        type EvalDotChal =
1057            DensePolyInnerProduct<(), i64, Self::Chal, Self::CombR, MBSInnerProduct, D>;
1058        type CombDotChal =
1059            DensePolyInnerProduct<(), Self::CombR, Self::Chal, Self::CombR, MBSInnerProduct, D>;
1060        type ArrCombRDotChal = MBSInnerProduct;
1061    }
1062
1063    type ZtInt = i64;
1064
1065    #[derive(Debug, Clone)]
1066    pub struct IntZipTypes {}
1067    impl ZipTypes for IntZipTypes {
1068        const NUM_COLUMN_OPENINGS: usize = 100;
1069        type Eval = ZtInt;
1070        type Cw = i128;
1071        type Fmod = ZtFmod;
1072        type PrimeTest = MillerRabin;
1073        type Chal = i128;
1074        type Pt = i128;
1075        type CombR = Int<M>;
1076        type Comb = Self::CombR;
1077        type EvalDotChal = ScalarProduct;
1078        type CombDotChal = ScalarProduct;
1079        type ArrCombRDotChal = MBSInnerProduct;
1080    }
1081
1082    #[derive(Clone, Debug)]
1083    struct TestZincTypesIprs;
1084
1085    impl ZincTypes<D, QUARTER_D> for TestZincTypesIprs {
1086        type Int = ZtInt;
1087        type Chal = i128;
1088        type Pt = i128;
1089        type CombR = Int<M>;
1090        type Fmod = ZtFmod;
1091        type PrimeTest = MillerRabin;
1092
1093        type BinaryZt = BinPolyZipTypes;
1094        type ArbitraryZt = ArbitraryPolyZipTypesIprs;
1095        type IntZt = IntZipTypes;
1096
1097        type BinaryFold = FoldBinaryTrace4x<D, HALF_D, QUARTER_D>;
1098
1099        type BinaryLc = IprsCode<Self::BinaryZt, PnttConfigF65537, REP_FACTOR, CHECKED>;
1100        type ArbitraryLc = IprsCode<Self::ArbitraryZt, PnttConfigF65537, REP_FACTOR, CHECKED>;
1101        type IntLc = IprsCode<Self::IntZt, PnttConfigF65537, REP_FACTOR, CHECKED>;
1102    }
1103
1104    #[derive(Copy, Clone)]
1105    struct TestRaaConfig;
1106    impl RaaConfig for TestRaaConfig {
1107        const PERMUTE_IN_PLACE: bool = false;
1108        const CHECK_FOR_OVERFLOWS: bool = true;
1109    }
1110
1111    #[derive(Clone, Debug)]
1112    struct TestZincTypesRaa;
1113
1114    impl ZincTypes<D, QUARTER_D> for TestZincTypesRaa {
1115        type Int = i64;
1116        type Chal = i128;
1117        type Pt = i128;
1118        type CombR = Int<M>;
1119        type Fmod = ZtFmod;
1120        type PrimeTest = MillerRabin;
1121
1122        type BinaryZt = BinPolyZipTypes;
1123        type ArbitraryZt = ArbitraryPolyZipTypesRaa;
1124        type IntZt = IntZipTypes;
1125
1126        type BinaryFold = FoldBinaryTrace4x<D, HALF_D, QUARTER_D>;
1127
1128        type BinaryLc = RaaCode<Self::BinaryZt, TestRaaConfig, REP_FACTOR>;
1129        type ArbitraryLc = RaaCode<Self::ArbitraryZt, TestRaaConfig, REP_FACTOR>;
1130        type IntLc = RaaCode<Self::IntZt, TestRaaConfig, REP_FACTOR>;
1131    }
1132
1133    /// Use row size equal to poly size, resulting in flat single-row matrices
1134    fn make_iprs<Zt: ZipTypes>(
1135        num_vars: usize,
1136    ) -> IprsCode<Zt, PnttConfigF65537, REP_FACTOR, CHECKED> {
1137        let poly_size = 1 << num_vars;
1138        IprsCode::new_with_optimal_depth(poly_size).unwrap()
1139    }
1140
1141    /// Set up Zip+ PCS parameters for a given number of MLE variables.
1142    fn setup_pp<Zt>(
1143        num_vars: usize,
1144        linear_codes: (Zt::BinaryLc, Zt::ArbitraryLc, Zt::IntLc),
1145    ) -> (
1146        ZipPlusParams<Zt::BinaryZt, Zt::BinaryLc>,
1147        ZipPlusParams<Zt::ArbitraryZt, Zt::ArbitraryLc>,
1148        ZipPlusParams<Zt::IntZt, Zt::IntLc>,
1149    )
1150    where
1151        Zt: ZincTypes<D, QUARTER_D>,
1152    {
1153        let folded_num_vars = num_vars + Zt::BinaryFold::FOLDING_FACTOR.ilog2() as usize;
1154
1155        let poly_size = 1 << num_vars;
1156        let folded_poly_size = 1 << folded_num_vars;
1157        (
1158            ZipPlus::<Zt::BinaryZt, Zt::BinaryLc>::setup(folded_poly_size, linear_codes.0),
1159            ZipPlus::<Zt::ArbitraryZt, Zt::ArbitraryLc>::setup(poly_size, linear_codes.1),
1160            ZipPlus::<Zt::IntZt, Zt::IntLc>::setup(poly_size, linear_codes.2),
1161        )
1162    }
1163
1164    macro_rules! default_project_ideal {
1165        () => {
1166            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i))
1167        };
1168    }
1169
1170    /// Older test UAIRs declare no primes, so the F_q[X] ideal projection
1171    /// is never invoked at runtime. UAIRs that exercise the F_q[X] family
1172    /// must pass a concrete projection closure.
1173    macro_rules! default_project_fq_ideal {
1174        () => {
1175            |_ideal, _cfg| -> IdealOrZero<DegreeOneIdeal<E>> {
1176                unreachable!("this UAIR has no F_q[X] constraints")
1177            }
1178        };
1179    }
1180
1181    fn do_test<Zt, U>(
1182        num_vars: usize,
1183        linear_codes: (Zt::BinaryLc, Zt::ArbitraryLc, Zt::IntLc),
1184        project_ideal: impl Fn(&IdealOrZero<U::Ideal>, &F) -> IdealOrZero<DegreeOneIdeal<E>> + Copy,
1185        project_fq_ideal: impl Fn(&IdealOrZero<U::FqIdeal>, &F) -> IdealOrZero<DegreeOneIdeal<E>> + Copy,
1186        tamper: impl Fn(&mut Proof<ZtFmod>),
1187        check_verification: impl Fn(Result<(), ProtocolError<E>>),
1188    ) where
1189        Zt: ZincTypes<D, QUARTER_D, Fmod = ZtFmod, Int = ZtInt, Chal = i128, CombR = Int<M>>,
1190        Zt::Int: ProjectableToField<F>,
1191        <Zt::ArbitraryZt as ZipTypes>::Eval: ProjectableToField<F>,
1192        U: Uair<Scalar = DensePolynomial<Zt::Int, D>, Prime = Zt::Fmod>
1193            + GenerateRandomTrace<D, PolyCoeff = Zt::Int, Int = Zt::Int>
1194            + 'static,
1195    {
1196        let mut rng = rng();
1197        let pp = setup_pp::<Zt>(num_vars, linear_codes);
1198
1199        let trace = U::generate_random_trace(num_vars, &mut rng);
1200
1201        let sig = U::signature();
1202        let public_trace = trace.public(&sig);
1203
1204        macro_rules! run_protocol {
1205            ($mle_first:ident) => {
1206                let mut proof = ZincPlusPiop::<Zt, U, F, D, QUARTER_D>::prove::<
1207                    { $mle_first },
1208                    CHECKED,
1209                >(&pp, &trace, num_vars, project_scalar_fn)
1210                .expect("Prover failed");
1211
1212                // Checking that the proof can be properly serialized and deserialized
1213                let mut transcript = PcsProverTranscript::new_from_commitments(std::iter::empty());
1214                transcript.write(&proof).expect("Failed to serialize proof");
1215                let mut transcript = transcript.into_verification_transcript();
1216                let proof_2 = transcript
1217                    .read()
1218                    .expect("Failed to deserialize proof after serialization");
1219                assert_eq!(proof, proof_2);
1220
1221                tamper(&mut proof);
1222
1223                let verification_result =
1224                    ZincPlusPiop::<Zt, U, F, D, QUARTER_D>::verify::<_, CHECKED>(
1225                        &pp,
1226                        proof,
1227                        &public_trace,
1228                        num_vars,
1229                        project_scalar_fn,
1230                        project_ideal,
1231                        project_fq_ideal,
1232                    );
1233                check_verification(verification_result);
1234            };
1235        }
1236
1237        run_protocol!(false);
1238
1239        run_protocol!(true);
1240    }
1241
1242    /// End-to-end test: [`TestUairNoMultiplication`].
1243    ///
1244    /// UAIR constraint: `a + b - c \in (X - 2)`
1245    /// (one constraint, no polynomial multiplication, ideal = `<X - 2>`).
1246    #[test]
1247    fn test_e2e_no_multiplication() {
1248        let num_vars = 8;
1249        do_test::<TestZincTypesIprs, TestUairNoMultiplication<ZtInt, ZtFmod>>(
1250            num_vars,
1251            (
1252                make_iprs(num_vars),
1253                make_iprs(num_vars),
1254                make_iprs(num_vars),
1255            ),
1256            default_project_ideal!(),
1257            default_project_fq_ideal!(),
1258            |_| {},
1259            |res| res.unwrap(),
1260        );
1261    }
1262
1263    /// End-to-end test: [`TestUairSimpleMultiplication`].
1264    ///
1265    /// UAIR constraints (3 total, no ideals):
1266    /// ```
1267    ///   up[0] * up[1] = down[0]
1268    ///   up[1] * up[2] = down[1]
1269    ///   up[0] * up[2] = down[2]
1270    /// ```
1271    ///
1272    /// Uses RAA code with small num_vars (2) because chained polynomial
1273    /// multiplication causes exponential growth in both degree and coefficient
1274    /// magnitude. With num_vars=2 (4 rows), max degree=6 and max coefficient
1275    /// ~= 127^8 ~= 2^56, which fits in i64.
1276    #[test]
1277    fn test_e2e_simple_multiplication() {
1278        let num_vars = 2;
1279        do_test::<TestZincTypesRaa, TestUairSimpleMultiplication<ZtInt, ZtFmod>>(
1280            num_vars,
1281            (
1282                RaaCode::new(num_vars),
1283                RaaCode::new(num_vars),
1284                RaaCode::new(num_vars),
1285            ),
1286            |_ideal, _field_cfg| IdealOrZero::<DegreeOneIdeal<E>>::zero(),
1287            default_project_fq_ideal!(),
1288            |_| {},
1289            |res| res.unwrap(),
1290        );
1291    }
1292
1293    /// End-to-end test: [`TestUairMixedShifts`].
1294    ///
1295    /// Uses mixed shift amounts (col a: shift 1, col b: shift 2).
1296    /// Constraints: `a[i+1] = a[i] + b[i], c[i] = b[i+2]`.
1297    #[test]
1298    fn test_e2e_mixed_shifts() {
1299        let num_vars = 8;
1300        do_test::<TestZincTypesIprs, TestUairMixedShifts<ZtInt, ZtFmod>>(
1301            num_vars,
1302            (
1303                make_iprs(num_vars),
1304                make_iprs(num_vars),
1305                make_iprs(num_vars),
1306            ),
1307            |_ideal, _field_cfg| IdealOrZero::<DegreeOneIdeal<E>>::zero(),
1308            default_project_fq_ideal!(),
1309            |_| {},
1310            |res| res.unwrap(),
1311        );
1312    }
1313
1314    /// End-to-end test: [`BinaryDecompositionUair`].
1315    ///
1316    /// Uses binary_poly (1 col) and int (1 col) trace types.
1317    /// UAIR constraint: `binary_poly[0] - int[0] \in <X - 2>`
1318    #[test]
1319    fn test_e2e_binary_decomposition() {
1320        let num_vars = 8;
1321        do_test::<TestZincTypesIprs, BinaryDecompositionUair<ZtInt, ZtFmod>>(
1322            num_vars,
1323            (
1324                make_iprs(num_vars),
1325                make_iprs(num_vars),
1326                make_iprs(num_vars),
1327            ),
1328            default_project_ideal!(),
1329            default_project_fq_ideal!(),
1330            |_| {},
1331            |res| res.unwrap(),
1332        );
1333    }
1334
1335    /// End-to-end test: [`TestUairFqLargePrime`] -- exercises the per-prime
1336    /// $F_{q_i}[X]$ ideal-check family with **two** large primes
1337    /// (`TEST_UAIR_FQ_LARGE_PRIME_0`, `TEST_UAIR_FQ_LARGE_PRIME_1`).
1338    ///
1339    /// UAIR has zero $Q[X]$ constraints and one $F_{q_i}[X]$
1340    /// constraint per prime, both of the form $\phi_{q_i}(a) \in (X - 0)$.
1341    #[test]
1342    fn test_e2e_fq_large_prime() {
1343        let num_vars = 8;
1344        do_test::<TestZincTypesIprs, TestUairFqLargePrime<ZtInt, ZtFmod>>(
1345            num_vars,
1346            (
1347                make_iprs(num_vars),
1348                make_iprs(num_vars),
1349                make_iprs(num_vars),
1350            ),
1351            // No Q[X] constraints -> Q[X] ideal projection is never invoked.
1352            |_ideal, _field_cfg| IdealOrZero::<DegreeOneIdeal<E>>::zero(),
1353            // F_q[X] ideal projection: `DegreeOneIdeal<R>` -> `DegreeOneIdeal<F>`
1354            // by lifting the generating root through the per-prime field cfg.
1355            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1356            |_| {},
1357            |res| res.unwrap(),
1358        );
1359    }
1360
1361    /// End-to-end test: [`TestUairBitOpsFqFamily`] -- exercises bit-op virtual
1362    /// columns through both the Q[X] and F_q[X] constraint families.
1363    #[test]
1364    fn test_e2e_bit_ops_with_fq_family() {
1365        let num_vars = 8;
1366        do_test::<TestZincTypesIprs, TestUairBitOpsFqFamily<ZtInt, ZtFmod>>(
1367            num_vars,
1368            (
1369                make_iprs(num_vars),
1370                make_iprs(num_vars),
1371                make_iprs(num_vars),
1372            ),
1373            default_project_ideal!(),
1374            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1375            |_| {},
1376            |res| res.unwrap(),
1377        );
1378    }
1379
1380    /// End-to-end test for the unshifted affine Ch-style booleanity bridge.
1381    #[test]
1382    fn test_e2e_affine_virtual_unshifted() {
1383        let num_vars = 8;
1384        do_test::<TestZincTypesIprs, TestUairAffineVirtualUnshifted<ZtInt, ZtFmod>>(
1385            num_vars,
1386            (
1387                make_iprs(num_vars),
1388                make_iprs(num_vars),
1389                make_iprs(num_vars),
1390            ),
1391            default_project_ideal!(),
1392            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1393            |proof| {
1394                assert_eq!(
1395                    proof
1396                        .booleanity_proof
1397                        .as_ref()
1398                        .expect("witness binary columns require a booleanity proof")
1399                        .bit_slice_evals
1400                        .len(),
1401                    3 * D,
1402                );
1403                assert_eq!(
1404                    proof
1405                        .affine_booleanity_proof
1406                        .as_ref()
1407                        .expect("affine virtuals require an affine booleanity proof")
1408                        .bit_slice_evals
1409                        .len(),
1410                    2 * D,
1411                );
1412            },
1413            |res| res.unwrap(),
1414        );
1415    }
1416
1417    /// Affine virtuals can be bound entirely to public source columns without
1418    /// adding witness PCS or multipoint-evaluation bridge columns.
1419    #[test]
1420    fn test_e2e_affine_virtual_public_only() {
1421        let num_vars = 8;
1422        do_test::<TestZincTypesIprs, TestUairAffineVirtualPublicOnly<ZtInt, ZtFmod>>(
1423            num_vars,
1424            (
1425                make_iprs(num_vars),
1426                make_iprs(num_vars),
1427                make_iprs(num_vars),
1428            ),
1429            default_project_ideal!(),
1430            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1431            |proof| {
1432                assert!(proof.booleanity_proof.is_none());
1433                assert_eq!(
1434                    proof
1435                        .affine_booleanity_proof
1436                        .as_ref()
1437                        .expect("affine virtuals require an affine booleanity proof")
1438                        .bit_slice_evals
1439                        .len(),
1440                    2 * D,
1441                );
1442            },
1443            |res| res.unwrap(),
1444        );
1445    }
1446
1447    /// A prover cannot omit affine bit-slice evaluations: their number is
1448    /// derived from the public UAIR signature.
1449    #[test]
1450    fn test_affine_virtual_truncated_bit_slice_evals() {
1451        let num_vars = 8;
1452        do_test::<TestZincTypesIprs, TestUairAffineVirtualUnshifted<ZtInt, ZtFmod>>(
1453            num_vars,
1454            (
1455                make_iprs(num_vars),
1456                make_iprs(num_vars),
1457                make_iprs(num_vars),
1458            ),
1459            default_project_ideal!(),
1460            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1461            |proof| {
1462                proof
1463                    .affine_booleanity_proof
1464                    .as_mut()
1465                    .expect("affine virtuals require an affine booleanity proof")
1466                    .bit_slice_evals
1467                    .pop();
1468            },
1469            |res| {
1470                assert!(matches!(
1471                    res.unwrap_err(),
1472                    ProtocolError::Booleanity(BooleanityError::WrongBitSliceEvalsNumber { .. })
1473                ));
1474            },
1475        );
1476    }
1477
1478    /// The involution `b -> 1-b` preserves `b(b-1)`, so this tamper passes
1479    /// affine Booleanity and can only be rejected by the source-binding bridge.
1480    #[test]
1481    fn test_affine_virtual_source_binding_rejects_booleanity_preserving_tamper() {
1482        type U = TestUairAffineVirtualUnshifted<ZtInt, ZtFmod>;
1483        type Piop = ZincPlusPiop<TestZincTypesIprs, U, F, D, QUARTER_D>;
1484        type Ideal = IdealOrZero<DegreeOneIdeal<E>>;
1485
1486        let num_vars = 8;
1487        let pp = setup_pp::<TestZincTypesIprs>(
1488            num_vars,
1489            (
1490                make_iprs(num_vars),
1491                make_iprs(num_vars),
1492                make_iprs(num_vars),
1493            ),
1494        );
1495        let trace = U::generate_random_trace(num_vars, &mut rng());
1496        let public_trace = trace.public(&U::signature());
1497        let mut proof =
1498            Piop::prove::<false, CHECKED>(&pp, &trace, num_vars, project_scalar_fn).expect("prove");
1499
1500        let cfg = Piop::step0_reconstruct_transcript::<Ideal>(
1501            &pp,
1502            proof.clone(),
1503            &public_trace,
1504            num_vars,
1505        )
1506        .and_then(|s| s.step1_prime_projection())
1507        .and_then(|s| {
1508            s.step2_ideal_check(default_project_ideal!(), |ideal, field_cfg| {
1509                ideal.map(|i| DegreeOneIdeal::project(field_cfg, i))
1510            })
1511        })
1512        .and_then(|s| s.step3_eval_projection(project_scalar_fn))
1513        .expect("steps 0..=3")
1514        .field_cfg()
1515        .clone();
1516
1517        let affine_evals = &mut proof
1518            .affine_booleanity_proof
1519            .as_mut()
1520            .expect("affine virtuals require an affine booleanity proof")
1521            .bit_slice_evals;
1522        let one = cfg.one();
1523        let tamper_index = affine_evals
1524            .iter()
1525            .position(|wire_eval| {
1526                let eval: E = cfg.project(wire_eval);
1527                eval != cfg.sub(&one, &eval)
1528            })
1529            .expect("at least one affine endpoint must differ from its complement");
1530        let eval: E = cfg.project(&affine_evals[tamper_index]);
1531        let tampered_eval = cfg.sub(&one, &eval);
1532        assert_eq!(
1533            cfg.mul(&eval, &cfg.sub(&eval, &one)),
1534            cfg.mul(&tampered_eval, &cfg.sub(&tampered_eval, &one)),
1535            "b -> 1-b must preserve the Booleanity residue",
1536        );
1537        affine_evals[tamper_index] = cfg.lift(&tampered_eval);
1538
1539        let err = Piop::verify::<Ideal, CHECKED>(
1540            &pp,
1541            proof,
1542            &public_trace,
1543            num_vars,
1544            project_scalar_fn,
1545            default_project_ideal!(),
1546            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1547        )
1548        .expect_err("the affine source-binding bridge must reject the tamper");
1549        assert!(matches!(
1550            err,
1551            ProtocolError::AffineVirtualBridgeMismatch { .. }
1552        ));
1553    }
1554
1555    /// End-to-end test: [`BigLinearUair`].
1556    ///
1557    /// Uses 16 binary_poly cols and 1 int col.
1558    /// UAIR constraints:
1559    /// ```
1560    ///   sum(up.binary_poly[0..16]) - up.int[0] \in <X - 1>
1561    ///   down.binary_poly[0] - up.int[0] \in <X - 2>
1562    ///   up.binary_poly[i] - down.binary_poly[i] = 0, for i=1..15
1563    /// ```
1564    #[test]
1565    fn test_e2e_big_linear() {
1566        let num_vars = 8;
1567        do_test::<TestZincTypesIprs, BigLinearUair<ZtInt, ZtFmod>>(
1568            num_vars,
1569            (
1570                make_iprs(num_vars),
1571                make_iprs(num_vars),
1572                make_iprs(num_vars),
1573            ),
1574            default_project_ideal!(),
1575            default_project_fq_ideal!(),
1576            |_| {},
1577            |res| res.unwrap(),
1578        );
1579    }
1580
1581    /// End-to-end test: [`BigLinearUairWithPublicInput`].
1582    ///
1583    /// Same as [`BigLinearUair`], but with the first few binary_poly columns as
1584    /// public inputs.
1585    #[test]
1586    fn test_e2e_big_linear_with_public_input() {
1587        let num_vars = 8;
1588        do_test::<TestZincTypesIprs, BigLinearUairWithPublicInput<ZtInt, ZtFmod>>(
1589            num_vars,
1590            (
1591                make_iprs(num_vars),
1592                make_iprs(num_vars),
1593                make_iprs(num_vars),
1594            ),
1595            default_project_ideal!(),
1596            default_project_fq_ideal!(),
1597            |_| {},
1598            |res| res.unwrap(),
1599        );
1600    }
1601
1602    /// End-to-end test: [`ShaProxy`].
1603    ///
1604    /// SHA-flavored benchmarking UAIR: 14 binary_poly cols, 4 int cols, with
1605    /// asymmetric shifts (`bp[0]` by 1, `bp[4]` by 4). UAIR constraints:
1606    /// ```
1607    ///   bp[0][t+1] - bp[1] - bp[2] - bp[3] - int[0] - int[1] - int[2] \in <X - 2>
1608    ///   bp[4][t+4] - bp[5] - bp[6] - bp[7] - int[1] - int[2] - int[3] \in <X - 2>
1609    ///   bp[8] - int[0] \in <X - 2>
1610    ///   bp[9] - int[1] \in <X - 2>
1611    ///   bp[10] - X * bp[11] \in <X - 1>
1612    ///   bp[12] - X * bp[13] \in <X - 1>
1613    /// ```
1614    #[test]
1615    fn test_e2e_sha_proxy() {
1616        let num_vars = 8;
1617        do_test::<TestZincTypesIprs, ShaProxy<ZtInt, ZtFmod>>(
1618            num_vars,
1619            (
1620                make_iprs(num_vars),
1621                make_iprs(num_vars),
1622                make_iprs(num_vars),
1623            ),
1624            default_project_ideal!(),
1625            default_project_fq_ideal!(),
1626            |_| {},
1627            |res| res.unwrap(),
1628        );
1629    }
1630
1631    //
1632    // Negative tests for BigLinearUairWithPublicInput: verify that proof
1633    // tampering is detected.
1634    //
1635
1636    #[test]
1637    fn test_big_linear_tamper_lifted_evals() {
1638        let num_vars = 8;
1639        do_test::<TestZincTypesIprs, BigLinearUairWithPublicInput<ZtInt, ZtFmod>>(
1640            num_vars,
1641            (
1642                make_iprs(num_vars),
1643                make_iprs(num_vars),
1644                make_iprs(num_vars),
1645            ),
1646            default_project_ideal!(),
1647            default_project_fq_ideal!(),
1648            |proof| proof.witness_lifted_evals[0].swap(0, 1),
1649            |res| {
1650                assert!(matches!(
1651                    res.unwrap_err(),
1652                    ProtocolError::MultipointEval(MultipointEvalError::ClaimMismatch { .. })
1653                ));
1654            },
1655        );
1656    }
1657
1658    /// Adversarial regression for the per-declared-prime family of the
1659    /// lifted-evals consistency check in step 6. [`TestUairFqLargePrime`]
1660    /// declares two primes, so `witness_lifted_evals` has shape
1661    /// `[Q, q_1, q_2]` (length 3).
1662    /// We perturb family `[1]` (declared prime $q_1$) and check that the
1663    /// per-prime [`MultipointEval::verify_subclaim`] call inside
1664    /// `step6_lifted_evals` rejects with `ClaimMismatch`.
1665    ///
1666    /// This complements [`test_big_linear_tamper_lifted_evals`] (which
1667    /// tampers the Q-family lift at `[0]`) by exercising the symmetric
1668    /// per-prime family — i.e. that the verifier independently binds each
1669    /// $\bar u_j^{(i)}$ to the $q_i$-projected trace at $r_0$, not just the
1670    /// Q-family.
1671    #[test]
1672    fn test_fq_large_prime_tamper_lifted_evals() {
1673        let num_vars = 8;
1674        do_test::<TestZincTypesIprs, TestUairFqLargePrime<ZtInt, ZtFmod>>(
1675            num_vars,
1676            (
1677                make_iprs(num_vars),
1678                make_iprs(num_vars),
1679                make_iprs(num_vars),
1680            ),
1681            // No Q[X] constraints (mirrors test_e2e_fq_large_prime).
1682            |_ideal, _field_cfg| IdealOrZero::<DegreeOneIdeal<E>>::zero(),
1683            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1684            |proof| {
1685                // Family 1 = declared prime q_1. The UAIR has a single
1686                // (arbitrary-poly) witness column, so the inner Vec has
1687                // length 1; tamper that one lifted polynomial by swapping
1688                // two of its coefficients.
1689                let lifted = &mut proof.witness_lifted_evals[1][0];
1690                assert!(
1691                    lifted.coeffs.len() >= 2,
1692                    "lifted polynomial should have at least 2 coefficients to swap"
1693                );
1694                lifted.coeffs.swap(0, 1);
1695            },
1696            |res| {
1697                assert!(matches!(
1698                    res.unwrap_err(),
1699                    ProtocolError::MultipointEval(MultipointEvalError::ClaimMismatch { .. })
1700                ));
1701            },
1702        );
1703    }
1704
1705    /// Regression for source-lift tampering on a bit-op UAIR in a
1706    /// declared-prime family.
1707    ///
1708    /// [`TestUairBitOpsFqFamily`] constrains `ShR(w, 3)` through both Q[X]
1709    /// and F_q[X]. Perturbing the F_q-family lifted eval of source column `w`
1710    /// changes the committed-source opening and the verifier-derived bit-op
1711    /// opening, so this is an end-to-end regression rather than an isolated
1712    /// bit-op binding test.
1713    #[test]
1714    fn test_bit_ops_fq_family_tamper_source_lifted_evals() {
1715        let num_vars = 8;
1716        do_test::<TestZincTypesIprs, TestUairBitOpsFqFamily<ZtInt, ZtFmod>>(
1717            num_vars,
1718            (
1719                make_iprs(num_vars),
1720                make_iprs(num_vars),
1721                make_iprs(num_vars),
1722            ),
1723            default_project_ideal!(),
1724            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1725            |proof| {
1726                // Family 1 = declared prime q_1. Source column 0 is `w`, the
1727                // source of the UAIR's single bit-op virtual `ShR(w, 3)`.
1728                // The family's declared prime is statically known.
1729                let sig = TestUairBitOpsFqFamily::<ZtInt, ZtFmod>::signature();
1730                let cfg = F::new(&sig.primes()[0]).expect("declared prime");
1731                let one = cfg.one();
1732                let lifted = &mut proof.witness_lifted_evals[1][0];
1733                if lifted.coeffs.is_empty() {
1734                    lifted.coeffs.push(cfg.lift(&one));
1735                } else {
1736                    let v = cfg.project(&lifted.coeffs[0]);
1737                    lifted.coeffs[0] = cfg.lift(&cfg.add(&v, &one));
1738                }
1739            },
1740            |res| {
1741                assert!(matches!(
1742                    res.unwrap_err(),
1743                    ProtocolError::MultipointEval(MultipointEvalError::ClaimMismatch { .. })
1744                ));
1745            },
1746        );
1747    }
1748
1749    /// Regression: a tampered F_q-family bit-op claim is rejected end-to-end.
1750    ///
1751    /// This perturbs the prover's claimed `ShR(w, 3)(r*)` value in the CPR
1752    /// proof. That value is shared by CPR and multipoint-eval; in practice,
1753    /// this mutation is caught by CPR's constraint reconstruction before the
1754    /// final multipoint-eval check.
1755    #[test]
1756    fn test_bit_ops_fq_family_tamper_bit_op_eval() {
1757        let num_vars = 8;
1758        do_test::<TestZincTypesIprs, TestUairBitOpsFqFamily<ZtInt, ZtFmod>>(
1759            num_vars,
1760            (
1761                make_iprs(num_vars),
1762                make_iprs(num_vars),
1763                make_iprs(num_vars),
1764            ),
1765            default_project_ideal!(),
1766            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1767            |proof| {
1768                // The family's declared prime is statically known.
1769                let sig = TestUairBitOpsFqFamily::<ZtInt, ZtFmod>::signature();
1770                let cfg = F::new(&sig.primes()[0]).expect("declared prime");
1771                let bit_op_eval = &mut proof.cpr_proofs_fq[0].bit_op_evals[0];
1772                let v = cfg.project(&*bit_op_eval);
1773                *bit_op_eval = cfg.lift(&cfg.add(&v, &cfg.one()));
1774            },
1775            |res| {
1776                assert!(
1777                    res.is_err(),
1778                    "tampered F_q-family bit-op evaluation must be rejected"
1779                );
1780            },
1781        );
1782    }
1783
1784    /// Regression: a too-short per-family inner lifted-evals vector must be
1785    /// rejected with `WitnessLiftedEvalsLengthMismatch`, not panic in the
1786    /// `assemble_all` slices of `step6_lifted_evals`.
1787    #[test]
1788    fn test_fq_large_prime_truncated_lifted_evals() {
1789        let num_vars = 8;
1790        do_test::<TestZincTypesIprs, TestUairFqLargePrime<ZtInt, ZtFmod>>(
1791            num_vars,
1792            (
1793                make_iprs(num_vars),
1794                make_iprs(num_vars),
1795                make_iprs(num_vars),
1796            ),
1797            |_ideal, _field_cfg| IdealOrZero::<DegreeOneIdeal<E>>::zero(),
1798            |ideal, field_cfg| ideal.map(|i| DegreeOneIdeal::project(field_cfg, i)),
1799            // Drop family 1's only witness column, making its inner vec shorter
1800            // than the witness-column count.
1801            |proof| proof.witness_lifted_evals[1].clear(),
1802            |res| {
1803                assert!(matches!(
1804                    res.unwrap_err(),
1805                    ProtocolError::WitnessLiftedEvalsLengthMismatch { family_idx: 1, .. }
1806                ));
1807            },
1808        );
1809    }
1810
1811    /// Adversarial regression for the q''-family lifted-evals length guard.
1812    /// The q'' vector (`witness_lifted_evals_pp`) is PCS-only:
1813    /// `step7_pcs_verify` consumes only the witness-column ranges — yet the
1814    /// whole vector is absorbed into the FS transcript first. Without the
1815    /// guard, a malicious prover could append arbitrary polynomials as free
1816    /// transcript entropy to grind the later PCS folding/alpha challenges
1817    /// without opening any extra column.
1818    #[test]
1819    fn test_tamper_witness_lifted_evals_pp_extra_tail() {
1820        let num_vars = 8;
1821        do_test::<TestZincTypesIprs, BigLinearUairWithPublicInput<ZtInt, ZtFmod>>(
1822            num_vars,
1823            (
1824                make_iprs(num_vars),
1825                make_iprs(num_vars),
1826                make_iprs(num_vars),
1827            ),
1828            default_project_ideal!(),
1829            default_project_fq_ideal!(),
1830            |proof| {
1831                // Append a surplus polynomial to the q'' lifted-evals vector,
1832                // inflating its length past the expected witness-column count.
1833                // This UAIR has no F_q[X] constraints, so q'' is aliased to
1834                // q_0 and the prover sends `None`;
1835                // The surplus entry — sourced from the Q-family lift — must still be rejected
1836                // by the length guard before it can be absorbed as free
1837                // transcript entropy.
1838                let extra = proof.witness_lifted_evals[0][0].clone();
1839                proof.witness_lifted_evals_pp = Some(vec![extra]);
1840            },
1841            |res| {
1842                assert!(matches!(
1843                    res.unwrap_err(),
1844                    ProtocolError::WitnessLiftedEvalsPpLengthMismatch { .. }
1845                ));
1846            },
1847        );
1848    }
1849
1850    #[test]
1851    fn test_big_linear_tamper_up_evals() {
1852        let num_vars = 8;
1853        do_test::<TestZincTypesIprs, BigLinearUairWithPublicInput<ZtInt, ZtFmod>>(
1854            num_vars,
1855            (
1856                make_iprs(num_vars),
1857                make_iprs(num_vars),
1858                make_iprs(num_vars),
1859            ),
1860            default_project_ideal!(),
1861            default_project_fq_ideal!(),
1862            |proof| proof.cpr_proof.up_evals.swap(0, 1),
1863            |res| {
1864                assert!(matches!(
1865                    res.unwrap_err(),
1866                    ProtocolError::Resolver(
1867                        CombinedPolyResolverError::ClaimValueDoesNotMatch { .. }
1868                    )
1869                ));
1870            },
1871        );
1872    }
1873
1874    #[test]
1875    fn test_big_linear_tamper_down_evals() {
1876        let num_vars = 8;
1877        do_test::<TestZincTypesIprs, BigLinearUairWithPublicInput<ZtInt, ZtFmod>>(
1878            num_vars,
1879            (
1880                make_iprs(num_vars),
1881                make_iprs(num_vars),
1882                make_iprs(num_vars),
1883            ),
1884            default_project_ideal!(),
1885            default_project_fq_ideal!(),
1886            |proof| proof.cpr_proof.down_evals.swap(0, 1),
1887            |res| {
1888                assert!(matches!(
1889                    res.unwrap_err(),
1890                    ProtocolError::Resolver(
1891                        CombinedPolyResolverError::ClaimValueDoesNotMatch { .. }
1892                    )
1893                ));
1894            },
1895        );
1896    }
1897
1898    // A wire integer >= the family modulus
1899    #[test]
1900    fn test_big_linear_tamper_non_canonical_wire_integer() {
1901        let num_vars = 8;
1902        do_test::<TestZincTypesIprs, BigLinearUairWithPublicInput<ZtInt, ZtFmod>>(
1903            num_vars,
1904            (
1905                make_iprs(num_vars),
1906                make_iprs(num_vars),
1907                make_iprs(num_vars),
1908            ),
1909            default_project_ideal!(),
1910            default_project_fq_ideal!(),
1911            |proof| proof.cpr_proof.up_evals[0] = ZtFmod::MAX,
1912            |res| {
1913                assert!(matches!(
1914                    res.unwrap_err(),
1915                    ProtocolError::NonCanonicalElement
1916                ));
1917            },
1918        );
1919    }
1920
1921    /// Bytes appended past the end of the proof stream are never read, and so
1922    /// are never absorbed into the transcript. They must be rejected rather
1923    /// than ignored: otherwise every verifying proof admits unboundedly many
1924    /// accepted variants, which breaks proof uniqueness wherever proofs are
1925    /// hashed, deduplicated, or compared for equality.
1926    #[test]
1927    fn test_big_linear_tamper_trailing_proof_bytes() {
1928        let num_vars = 8;
1929        do_test::<TestZincTypesIprs, BigLinearUairWithPublicInput<ZtInt, ZtFmod>>(
1930            num_vars,
1931            (
1932                make_iprs(num_vars),
1933                make_iprs(num_vars),
1934                make_iprs(num_vars),
1935            ),
1936            default_project_ideal!(),
1937            default_project_fq_ideal!(),
1938            |proof| proof.zip.push(0),
1939            |res| {
1940                assert!(res.is_err(), "trailing proof bytes were accepted");
1941                let err = res.unwrap_err();
1942                assert!(
1943                    matches!(err, ProtocolError::Transcript(_)),
1944                    "trailing proof bytes resulted in {err:?} rather than a transcript error"
1945                );
1946            },
1947        );
1948    }
1949
1950    // A *canonical* perturbation of the ideal-check opening values passes
1951    // the wire projection
1952    #[test]
1953    fn test_big_linear_tamper_ideal_check_values() {
1954        let num_vars = 8;
1955        do_test::<TestZincTypesIprs, BigLinearUairWithPublicInput<ZtInt, ZtFmod>>(
1956            num_vars,
1957            (
1958                make_iprs(num_vars),
1959                make_iprs(num_vars),
1960                make_iprs(num_vars),
1961            ),
1962            default_project_ideal!(),
1963            default_project_fq_ideal!(),
1964            |proof| {
1965                let coeffs = &mut proof.ideal_check.combined_mle_values[0].coeffs;
1966                if coeffs.is_empty() {
1967                    coeffs.push(ZtFmod::from(1u64));
1968                } else {
1969                    if coeffs[0].is_zero() {
1970                        coeffs[0] += ZtFmod::ONE;
1971                    } else {
1972                        coeffs[0] -= ZtFmod::ONE;
1973                    }
1974                }
1975            },
1976            |res| {
1977                assert!(matches!(res.unwrap_err(), ProtocolError::IdealCheck(..)));
1978            },
1979        );
1980    }
1981
1982    //
1983    // Booleanity-specific end-to-end tests. `BigLinearUair` has 16 binary-poly
1984    // witness columns, so the booleanity argument is exercised by all of the
1985    // protocol tests above. The tests here verify that tampering with the
1986    // booleanity proof and with witness bit values produces well-typed
1987    // verifier errors.
1988    //
1989
1990    /// Perturbing one entry of `booleanity_proof.bit_slice_evals` by a
1991    /// non-trivial additive constant breaks the recomputed booleanity
1992    /// residue at `r*`, which the verifier's `finalize_verifier` catches
1993    /// against the sumcheck's `expected_evaluation`.
1994    #[test]
1995    fn test_big_linear_tamper_booleanity_evals() {
1996        use zinc_piop::lookup::booleanity::BooleanityError;
1997        let num_vars = 8;
1998        do_test::<TestZincTypesIprs, BigLinearUair<ZtInt, ZtFmod>>(
1999            num_vars,
2000            (
2001                make_iprs(num_vars),
2002                make_iprs(num_vars),
2003                make_iprs(num_vars),
2004            ),
2005            default_project_ideal!(),
2006            default_project_fq_ideal!(),
2007            |proof| {
2008                let bp = proof
2009                    .booleanity_proof
2010                    .as_mut()
2011                    .expect("BigLinearUair has binary-poly witnesses");
2012                // Add a constant to the raw wire integer.
2013                let tampered = bp.bit_slice_evals[0].wrapping_add(&Uint::from(7_u64));
2014                bp.bit_slice_evals[0] = tampered
2015            },
2016            |res| {
2017                assert!(matches!(
2018                    res.unwrap_err(),
2019                    ProtocolError::Booleanity(BooleanityError::ClaimValueDoesNotMatch { .. })
2020                ));
2021            },
2022        );
2023    }
2024
2025    /// Removing entries from `booleanity_proof.bit_slice_evals` breaks the
2026    /// length invariant; `finalize_verifier` detects this via
2027    /// `WrongBitSliceEvalsNumber` before the residue check.
2028    #[test]
2029    fn test_big_linear_tamper_booleanity_evals_length() {
2030        use zinc_piop::lookup::booleanity::BooleanityError;
2031        let num_vars = 8;
2032        do_test::<TestZincTypesIprs, BigLinearUair<ZtInt, ZtFmod>>(
2033            num_vars,
2034            (
2035                make_iprs(num_vars),
2036                make_iprs(num_vars),
2037                make_iprs(num_vars),
2038            ),
2039            default_project_ideal!(),
2040            default_project_fq_ideal!(),
2041            |proof| {
2042                let bp = proof
2043                    .booleanity_proof
2044                    .as_mut()
2045                    .expect("BigLinearUair has binary-poly witnesses");
2046                bp.bit_slice_evals.pop();
2047            },
2048            |res| {
2049                assert!(matches!(
2050                    res.unwrap_err(),
2051                    ProtocolError::Booleanity(BooleanityError::WrongBitSliceEvalsNumber { .. })
2052                ));
2053            },
2054        );
2055    }
2056
2057    /// Removing `booleanity_proof` entirely when the UAIR has bin-poly
2058    /// witnesses produces `BooleanityProofMissing`.
2059    #[test]
2060    fn test_big_linear_drop_booleanity_proof() {
2061        let num_vars = 8;
2062        do_test::<TestZincTypesIprs, BigLinearUair<ZtInt, ZtFmod>>(
2063            num_vars,
2064            (
2065                make_iprs(num_vars),
2066                make_iprs(num_vars),
2067                make_iprs(num_vars),
2068            ),
2069            default_project_ideal!(),
2070            default_project_fq_ideal!(),
2071            |proof| {
2072                proof.booleanity_proof = None;
2073            },
2074            |res| {
2075                assert!(matches!(
2076                    res.unwrap_err(),
2077                    ProtocolError::BooleanityProofMissing
2078                ));
2079            },
2080        );
2081    }
2082
2083    /// Soundness regression: tamper $(\delta_0, \delta_1)$ on flat
2084    /// positions $(0, 1)$ of `bit_slice_evals` (witness column 0) that
2085    /// preserves both the booleanity residue at $r^\star$ and the OLD
2086    /// $\psi_a$ linear pin-down $\delta_0 + a \delta_1 = 0$ at the
2087    /// $\psi_a$ projecting element $a$ — caught by the $\alpha'$ bridge
2088    /// via the MP + PCS chain.
2089    ///
2090    /// Closed form ($\alpha$ = booleanity batching challenge):
2091    /// $$
2092    ///   \delta_0 = -\frac{(2 b_0 - 1) - (\alpha / a)(2 b_1 - 1)}
2093    ///                    {1 + \alpha / a^2}, \quad
2094    ///   \delta_1 = -\delta_0 / a.
2095    /// $$
2096    /// $a$ and $\alpha$ are recovered by replaying steps 0..=3 and
2097    /// driving the transcript through CPR + booleanity
2098    /// `prepare_verifier`.
2099    #[test]
2100    #[allow(clippy::arithmetic_side_effects)]
2101    fn test_big_linear_alpha_prime_bridge_catches_pin_down_preserving_tamper() {
2102        use zinc_piop::{
2103            combined_poly_resolver::CombinedPolyResolver, lookup::booleanity::BooleanityChecker,
2104        };
2105
2106        type Piop = ZincPlusPiop<TestZincTypesIprs, BigLinearUair<ZtInt, ZtFmod>, F, D, QUARTER_D>;
2107        type Ideal = IdealOrZero<DegreeOneIdeal<E>>;
2108
2109        let num_constraints = count_constraints::<BigLinearUair<ZtInt, ZtFmod>>();
2110
2111        let num_vars = 8;
2112        let iprs = (
2113            make_iprs(num_vars),
2114            make_iprs(num_vars),
2115            make_iprs(num_vars),
2116        );
2117        let pp = setup_pp::<TestZincTypesIprs>(num_vars, iprs);
2118        let trace = BigLinearUair::<ZtInt, ZtFmod>::generate_random_trace(num_vars, &mut rng());
2119        let public_trace = trace.public(&BigLinearUair::<ZtInt, ZtFmod>::signature());
2120        let mut proof =
2121            Piop::prove::<false, CHECKED>(&pp, &trace, num_vars, project_scalar_fn).expect("prove");
2122
2123        // Recover `a` and `\alpha` by replaying steps 0..=3 on a proof
2124        // clone, then advancing the transcript through CPR + booleanity
2125        // `prepare_verifier`.
2126        let (cfg, a, alpha) = {
2127            let mut v3 = Piop::step0_reconstruct_transcript::<Ideal>(
2128                &pp,
2129                proof.clone(),
2130                &public_trace,
2131                num_vars,
2132            )
2133            .and_then(|s| s.step1_prime_projection())
2134            .and_then(|s| {
2135                s.step2_ideal_check(default_project_ideal!(), default_project_fq_ideal!())
2136            })
2137            .and_then(|s| s.step3_eval_projection(project_scalar_fn))
2138            .expect("steps 0..=3");
2139
2140            let cfg = v3.field_cfg().clone();
2141            let a = v3.projecting_element_f().clone();
2142            let nv = v3.num_vars();
2143            let claimed_sums = v3.proof_combined_sumcheck().claimed_sums().to_vec();
2144            let proof_cpr = v3.proof_cpr().clone();
2145            let ic_subclaim = v3.ic_subclaim().clone();
2146
2147            let sig = v3.uair_signature().clone();
2148            let num_wit_bin =
2149                sig.total_cols().num_binary_poly_cols() - sig.public_cols().num_binary_poly_cols();
2150            let transcript = v3.fs_transcript_mut();
2151
2152            let folding_challenge: E = transcript.get_field_challenge(&cfg);
2153            CombinedPolyResolver::<F>::prepare_verifier::<BigLinearUair<ZtInt, ZtFmod>>(
2154                &proof_cpr,
2155                claimed_sums[0].clone(),
2156                &ic_subclaim,
2157                num_constraints.q,
2158                nv,
2159                &a,
2160                &folding_challenge,
2161                &cfg,
2162            )
2163            .expect("CPR prepare_verifier");
2164
2165            let bool_anc = BooleanityChecker::<F>::prepare_verifier(
2166                transcript,
2167                &claimed_sums[1],
2168                num_wit_bin,
2169                D,
2170                nv,
2171                &cfg,
2172            )
2173            .expect("booleanity prepare_verifier");
2174
2175            (cfg, a, bool_anc.alpha_powers[1].clone())
2176        };
2177
2178        // Build (\delta_0, \delta_1) from the closed form (see doc-comment).
2179        let one = cfg.one();
2180        let two = cfg.add(&one, &one);
2181
2182        let a_inv: E = cfg.inv(&a).expect("a != 0");
2183        let alpha_over_a: E = cfg.mul(&alpha, &a_inv);
2184        let alpha_over_a_sq: E = cfg.mul(&alpha_over_a, &a_inv);
2185
2186        let bp = proof
2187            .booleanity_proof
2188            .as_mut()
2189            .expect("BigLinearUair has binary-poly witnesses");
2190        let b0: E = cfg.project(&bp.bit_slice_evals[0]);
2191        let b1: E = cfg.project(&bp.bit_slice_evals[1]);
2192        let s0: E = cfg.sub(&cfg.mul(&two, &b0), &one); // 2 b_0 - 1
2193        let s1: E = cfg.sub(&cfg.mul(&two, &b1), &one); // 2 b_1 - 1
2194
2195        let denom_inv: E = cfg
2196            .inv(&cfg.add(&one, &alpha_over_a_sq))
2197            .expect("1 + α/a² != 0");
2198        let delta_0: E = cfg.neg(&cfg.mul(&cfg.sub(&s0, &cfg.mul(&alpha_over_a, &s1)), &denom_inv));
2199        let delta_1: E = cfg.neg(&cfg.mul(&a_inv, &delta_0));
2200
2201        // Sanity: tamper is non-trivial and preserves both OLD checks.
2202        assert!(!cfg.is_zero(&delta_0), "tamper must be non-zero");
2203        assert!(
2204            cfg.is_zero(&cfg.add(&delta_0, &cfg.mul(&a, &delta_1))),
2205            "must preserve OLD ψ_a linear pin-down"
2206        );
2207        let residue = cfg.add(
2208            &cfg.add(&cfg.mul(&delta_0, &s0), &cfg.mul(&delta_0, &delta_0)),
2209            &cfg.mul(
2210                &alpha,
2211                &cfg.add(&cfg.mul(&delta_1, &s1), &cfg.mul(&delta_1, &delta_1)),
2212            ),
2213        );
2214        assert!(cfg.is_zero(&residue), "must preserve booleanity residue");
2215
2216        bp.bit_slice_evals[0] = cfg.lift(&cfg.add(&b0, &delta_0));
2217        bp.bit_slice_evals[1] = cfg.lift(&cfg.add(&b1, &delta_1));
2218
2219        let err = Piop::verify::<_, CHECKED>(
2220            &pp,
2221            proof,
2222            &public_trace,
2223            num_vars,
2224            project_scalar_fn,
2225            default_project_ideal!(),
2226            default_project_fq_ideal!(),
2227        )
2228        .expect_err("verifier must reject alpha-prime-tampered proof");
2229        assert!(
2230            matches!(
2231                err,
2232                ProtocolError::MultipointEval(_) | ProtocolError::PcsVerification(..)
2233            ),
2234            "expected MultipointEval / PCS-chain error, got: {err:?}"
2235        );
2236    }
2237
2238    #[test]
2239    fn test_big_linear_tamper_ideal_check() {
2240        let num_vars = 8;
2241        do_test::<TestZincTypesIprs, BigLinearUairWithPublicInput<ZtInt, ZtFmod>>(
2242            num_vars,
2243            (
2244                make_iprs(num_vars),
2245                make_iprs(num_vars),
2246                make_iprs(num_vars),
2247            ),
2248            default_project_ideal!(),
2249            default_project_fq_ideal!(),
2250            |proof| proof.ideal_check.combined_mle_values.swap(0, 1),
2251            |res| {
2252                assert!(matches!(res.unwrap_err(), ProtocolError::IdealCheck(..)));
2253            },
2254        );
2255    }
2256}