Skip to main content

zinc_piop/lookup/
booleanity.rs

1//! Booleanity (binary-polynomial lookup) argument.
2//!
3//! Proves that every coefficient of every witness binary-polynomial column is
4//! a bit $\in \\{0,1\\}$. The committed-witness path is structured as a
5//! [`MultiDegreeSumcheckGroup`] of degree 3, batched alongside the existing CPR
6//! group with shared randomness, and emits bit-slice claims at the
7//! multi-degree sumcheck output point $r^\star$ for the protocol layer's
8//! $\alpha'$ bridge into multipoint-eval (see "Soundness bridge" below).
9//! Affine virtual booleanity targets use a generic degree-3 group without the
10//! `BinaryPoly` round-1 fast path.
11//!
12//! # Relation
13//!
14//! For each witness binary-poly column $u_j \in (F_q^{<D}[X])^n$ with
15//! $n = 2^\mu$, decompose row-wise as
16//!
17//! $$
18//! {u_j}[b](X) = \sum_{i=0}^{D-1} v_{j,i,b} * X^i
19//! $$
20//!
21//! The booleanity claim is:
22//!
23//! $$
24//!   \forall j, i, b:  v_{j,i,b} \in \\{0,1\\}
25//! $$
26//!
27//! Equivalently, the MLE statement
28//! $\widetilde{v_{j,i}}(b)*(\widetilde{v_{j,i}}(b)-1) = 0$ for all
29//! $b \in {0,1}^\mu$. The protocol reduces this to a single batched sumcheck
30//!
31//! $$
32//! \sum_{b in {0,1}^\mu} eq(r, b) *
33//!   \sum_{k=0}^{N*D-1} \alpha^k *
34//!     \widetilde{v_k}(b) * (\widetilde{v_k}(b) - 1)  =  0
35//! $$
36//!
37//! with a single batching challenge $\alpha$ over the flat
38//! $(j\text{-major}, i\text{-minor})$ index $k = j \cdot D + i$, and
39//! zerocheck point $r$. After the sumcheck reaches $r^\star$, the prover
40//! sends `bit_slice_evals` $= (\widetilde{v_{j,i}}(r^\star))$ to the
41//! verifier.
42//!
43//! # Soundness bridge to multipoint-eval ($\alpha'$)
44//!
45//! Booleanity does **not** itself close the bit-decomposition consistency
46//! at $r^\star$. The protocol-layer caller squeezes a fresh challenge
47//! $\alpha'$ from the transcript **after** `bit_slice_evals` are absorbed
48//! (the absorption is performed by [`BooleanityChecker::finalize_prover`] /
49//! `finalize_verifier`), then, for each witness binary-poly column $u_j$,
50//! *appends* one extra column to the multipoint-eval input list:
51//! - an extra MLE $\widetilde{\psi_{\alpha'}(u_j)}$ (the prover-side
52//!   $\alpha'$-projection of $u_j$), and
53//! - an extra up-eval scalar $c'_j \;:=\; \sum_{i=0}^{D-1}
54//!   b_{j,i}\,\alpha'^{\,i}$ (where $b_{j,i} = \widetilde{v_{j,i}}(r^\star)$
55//!   from `bit_slice_evals`).
56//!
57//! No `ShiftSpec` references the appended slot, so down-evals / shifts
58//! pass through unchanged: row-shifted projections of witness binary-poly
59//! columns inherit booleanity from the un-shifted, $\psi_a$-projected
60//! slot they already reference in the multipoint-eval sumcheck.
61//!
62//! Multipoint-eval / PCS is $\psi$-oblivious, so the appended slot
63//! combined with the lifted-evals step evaluating the corresponding
64//! $\bar u_j$ at $\alpha'$ enforces
65//! $\widetilde{\psi_{\alpha'}(u_j)}(r^\star) = c'_j$ via the PCS chain.
66//! By Schwartz–Zippel on the indeterminate $X$, if `bit_slice_evals` are
67//! not the true bit-decomposition then equality fails with probability
68//! $\le (D-1)/|F|$, so the verifier rejects.
69//!
70//! For an unshifted affine virtual, the protocol collapses its bit-slice
71//! claims at the same $\alpha'$ and compares the result directly with the
72//! declared affine combination of source-column collapses at $r^\star$.
73//! Public source collapses are recomputed from the public trace; witness source
74//! collapses are the committed-column values bound through the MP/PCS chain
75//! above. Shifted affine terms require a separate row-shift opening and are
76//! rejected when affine specs are attached to the UAIR signature until that
77//! binding is implemented.
78
79use crate::{
80    CombFn,
81    sumcheck::{
82        SumCheckError,
83        multi_degree::{MultiDegreeSumcheckGroup, Round1FastPath, Round1Output},
84        prover::ProverState as SumcheckProverState,
85    },
86};
87use crypto_primitives::{
88    BaseFieldConfig, ProjectPrimitiveIntegersWithConfig, SemiringConfig, SetConfig, Wrapper,
89};
90use itertools::Itertools;
91use std::{marker::PhantomData, slice};
92use thiserror::Error;
93use zinc_poly::{
94    EvaluationError,
95    mle::DenseMultilinearExtension,
96    univariate::binary::BinaryPoly,
97    utils::{ArithErrors, build_eq_x_r, eq_eval},
98};
99use zinc_transcript::{
100    delegate_transcribable,
101    traits::{ConstTranscribable, Transcript},
102};
103use zinc_uair::AffineVirtualSpec;
104use zinc_utils::{add, mul, powers};
105
106//
107// Structs
108//
109
110/// Booleanity sumcheck group constructor / verifier.
111pub struct BooleanityChecker<C: SemiringConfig>(PhantomData<C>);
112
113type BooleanityZerocheckSetup<F> = (Vec<F>, Vec<F>, CombFn<F>);
114
115/// Proof produced by the booleanity prover.
116///
117/// Carries `bit_slice_evals`: per-booleanity-target bit-slice MLE evaluations
118/// at the multi-degree sumcheck output point $r^\star$, flat in
119/// `(j-major, i-minor)` order. It is absorbed into the transcript by
120/// `finalize_prover` / `finalize_verifier`, after which the protocol layer
121/// squeezes the $\alpha'$ challenge used to bind the bit-decomposition into
122/// the multipoint-eval boundary.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct BooleanityProof<F> {
125    /// Flat list of `\widetilde{v_{j,i}}(r*)`, ordered `(j-major, i-minor)`.
126    /// Length is `num_cols * D` for the corresponding Booleanity group.
127    pub bit_slice_evals: Vec<F>,
128}
129
130impl<F> BooleanityProof<F> {
131    /// Maps every field element through `f`, preserving structure — used to
132    /// lift elements into wire integers and to project wire integers back
133    /// into elements at the (de)serialization boundary.
134    pub fn try_map<T, E>(
135        &self,
136        f: impl FnMut(&F) -> Result<T, E> + Copy,
137    ) -> Result<BooleanityProof<T>, E> {
138        Ok(BooleanityProof {
139            bit_slice_evals: self.bit_slice_evals.iter().map(f).try_collect()?,
140        })
141    }
142}
143
144delegate_transcribable!(BooleanityProof<F> { bit_slice_evals: Vec<F> }
145    where F: ConstTranscribable);
146
147/// Ancillary data produced by [`BooleanityChecker::prepare_sumcheck_group`]
148/// and consumed by [`BooleanityChecker::finalize_prover`].
149pub struct BoolProverAncillary {
150    /// Number of binary-poly booleanity target columns (`N`).
151    ///
152    /// This is the witness binary-poly count for the committed-column group
153    /// and the affine virtual count for an affine virtual group.
154    pub num_wit_bin_cols: usize,
155    /// Bit-width of each binary-poly coefficient cell (`D`).
156    pub bit_width: usize,
157    /// Number of variables of the trace MLEs.
158    pub num_vars: usize,
159}
160
161/// Ancillary data produced by [`BooleanityChecker::prepare_verifier`] and
162/// consumed by [`BooleanityChecker::finalize_verifier`].
163pub struct BoolVerifierAncillary<F> {
164    /// Powers of the single batching challenge over the flat
165    /// `(j-major, i-minor)` index: `[1, alpha, ..., alpha^{N*D - 1}]`.
166    pub alpha_powers: Vec<F>,
167    /// The zerocheck point `r` sampled before the multi-degree sumcheck.
168    pub zerocheck_point: Vec<F>,
169    /// Number of binary-poly booleanity target columns (`N`).
170    pub num_wit_bin_cols: usize,
171    /// Bit-width of each binary-poly coefficient cell (`D`).
172    pub bit_width: usize,
173    /// Number of variables (for sanity-checking the shared point length).
174    pub num_vars: usize,
175}
176
177/// Subclaim emitted by [`BooleanityChecker::finalize_verifier`].
178///
179/// Carries the (sumcheck-residue-validated) bit-slice evaluations at the
180/// shared multi-degree sumcheck point $r^\star$. The protocol-layer
181/// caller squeezes $\alpha'$ from the transcript (after this struct
182/// is produced) and uses it together with these values to *append* one
183/// extra multipoint-eval column (and up-eval $c'_j$) per witness
184/// binary-poly column — see the module-level docs.
185#[derive(Clone, Debug)]
186pub struct BoolVerifierSubclaim<F> {
187    /// Bit-slice MLE evaluations at `r*`, in `(j-major, i-minor)` order.
188    pub bit_slice_evals: Vec<F>,
189}
190
191//
192// Protocol
193//
194
195impl<C> BooleanityChecker<C>
196where
197    C: BaseFieldConfig + ProjectPrimitiveIntegersWithConfig + 'static,
198    C::Integer: ConstTranscribable,
199{
200    /// Build the booleanity sumcheck group, to be appended to the
201    /// multi-degree sumcheck.
202    ///
203    /// Installs a [`BooleanityRound1FastPath`] hook on the returned group:
204    /// the round-1 polynomial and the post-round-1 MLE fold are computed
205    /// in closed form directly from the `BinaryPoly`-typed trace columns
206    /// (no full-size `F`-valued bit-slice MLEs are ever materialized).
207    /// Verifier doesn't see a difference.
208    pub fn prepare_sumcheck_group<const D: usize>(
209        transcript: &mut impl Transcript,
210        trace_bin_poly: &[DenseMultilinearExtension<BinaryPoly<D>>],
211        num_vars: usize,
212        field_cfg: &C,
213    ) -> Result<(MultiDegreeSumcheckGroup<C>, BoolProverAncillary), BooleanityError<C::Element>>
214    {
215        let n = trace_bin_poly.len();
216        let (r, alpha_powers, comb_fn) =
217            booleanity_zerocheck_setup(transcript, n, D, num_vars, field_cfg);
218
219        // 4. Precompute `E_other(b')` = eq(b', r[1..]) for the fast path.
220        //    `build_eq_x_r` rejects empty inputs, so handle num_vars == 1 explicitly by
221        //    emitting the empty-product table `[1]`.
222        let one = field_cfg.one();
223        let eq_other_table: Vec<C::Element> = if num_vars <= 1 {
224            vec![one]
225        } else {
226            build_eq_x_r(field_cfg, &r[1..])?.evaluations
227        };
228
229        let r_first_coord = r
230            .into_iter()
231            .next()
232            .expect("num_vars >= 1 guarantees a first coordinate");
233
234        let fast_path = BooleanityRound1FastPath::<C, D> {
235            binary_cols: trace_bin_poly.to_vec(),
236            alpha_powers,
237            eq_other_table,
238            r_first_coord,
239            num_vars,
240        };
241
242        Ok((
243            MultiDegreeSumcheckGroup::new_with_fast_path(
244                3,
245                Vec::new(),
246                comb_fn,
247                Box::new(fast_path),
248            ),
249            BoolProverAncillary {
250                num_wit_bin_cols: n,
251                bit_width: D,
252                num_vars,
253            },
254        ))
255    }
256
257    /// Build a generic booleanity sumcheck group for affine virtual targets.
258    ///
259    /// Unlike [`Self::prepare_sumcheck_group`], this path does not install the
260    /// `BinaryPoly` round-1 fast path: affine residual cells may be non-binary
261    /// field values before the booleanity relation is enforced. The trace
262    /// slice must contain all binary-polynomial columns in public-then-witness
263    /// order so that affine source indices match the UAIR layout.
264    pub fn prepare_affine_virtual_sumcheck_group<const D: usize>(
265        transcript: &mut impl Transcript,
266        all_trace_bin_poly: &[DenseMultilinearExtension<BinaryPoly<D>>],
267        affine_virtual_specs: &[AffineVirtualSpec],
268        num_vars: usize,
269        field_cfg: &C,
270    ) -> Result<(MultiDegreeSumcheckGroup<C>, BoolProverAncillary), BooleanityError<C::Element>>
271    {
272        if affine_virtual_specs.is_empty() {
273            return Err(BooleanityError::NoAffineVirtualSpecs);
274        }
275        if all_trace_bin_poly.is_empty() {
276            return Err(BooleanityError::NoBinaryPolyColumns);
277        }
278
279        let bit_slice_mles = build_affine_virtual_bit_slice_mles::<C, D>(
280            all_trace_bin_poly,
281            affine_virtual_specs,
282            num_vars,
283            field_cfg,
284        );
285        let num_cols = affine_virtual_specs.len();
286        let active_len = num_cols.saturating_mul(D);
287        let (r, _, comb_fn) =
288            booleanity_zerocheck_setup(transcript, num_cols, D, num_vars, field_cfg);
289
290        debug_assert_eq!(bit_slice_mles.len(), active_len);
291
292        let mut mles = Vec::with_capacity(add!(1, bit_slice_mles.len()));
293        mles.push(build_eq_x_r(field_cfg, &r)?);
294        mles.extend(bit_slice_mles);
295
296        Ok((
297            MultiDegreeSumcheckGroup::new(3, mles, comb_fn),
298            BoolProverAncillary {
299                num_wit_bin_cols: num_cols,
300                bit_width: D,
301                num_vars,
302            },
303        ))
304    }
305
306    /// Finalize the booleanity proof after the multi-degree sumcheck
307    /// completes.
308    ///
309    /// Mirrors the structure of `CombinedPolyResolver::finalize_prover`:
310    /// evaluates each bit-slice MLE at the final sumcheck challenge,
311    /// emits the flat `bit_slice_evals` vector, and absorbs it into the
312    /// transcript.
313    pub fn finalize_prover(
314        transcript: &mut impl Transcript,
315        sumcheck_prover_state: SumcheckProverState<C>,
316        ancillary: BoolProverAncillary,
317        field_cfg: &C,
318    ) -> Result<BooleanityProof<C::Element>, BooleanityError<C::Element>> {
319        debug_assert!(
320            sumcheck_prover_state
321                .mles
322                .iter()
323                .all(|mle| mle.num_vars == 1),
324            "sumcheck should reduce MLEs to num_vars == 1"
325        );
326
327        let last_sumcheck_challenge = sumcheck_prover_state
328            .randomness
329            .last()
330            .expect("sumcheck cannot have had 0 rounds")
331            .clone();
332
333        let active_len = ancillary
334            .num_wit_bin_cols
335            .saturating_mul(ancillary.bit_width);
336
337        let mut mles = sumcheck_prover_state.mles;
338        debug_assert_eq!(mles.len(), add!(1, active_len));
339
340        // Skip MLE 0 = eq_r (verifier recomputes it).
341        let bit_slice_evals: Vec<C::Element> = mles
342            .drain(1..)
343            .map(|mle| mle.evaluate(field_cfg, slice::from_ref(&last_sumcheck_challenge)))
344            .collect::<Result<Vec<_>, _>>()?;
345
346        debug_assert_eq!(bit_slice_evals.len(), active_len);
347
348        let mut transcription_buf: Vec<u8> = vec![0; <C::Integer as ConstTranscribable>::NUM_BYTES];
349        transcript.absorb_field_element_slice(field_cfg, &bit_slice_evals, &mut transcription_buf);
350
351        Ok(BooleanityProof { bit_slice_evals })
352    }
353
354    /// Pre-sumcheck half of the booleanity verifier.
355    ///
356    /// Must run after the CPR `prepare_verifier` and before
357    /// `MultiDegreeSumcheck::verify_as_subprotocol` to maintain transcript
358    /// ordering.
359    pub fn prepare_verifier(
360        transcript: &mut impl Transcript,
361        claimed_sum: &C::Element,
362        num_wit_bin_cols: usize,
363        bit_width: usize,
364        num_vars: usize,
365        field_cfg: &C,
366    ) -> Result<BoolVerifierAncillary<C::Element>, BooleanityError<C::Element>> {
367        if num_wit_bin_cols == 0 {
368            return Err(BooleanityError::NoBinaryPolyColumns);
369        }
370        if !field_cfg.is_zero(claimed_sum) {
371            return Err(BooleanityError::NonZeroClaimedSum {
372                got: claimed_sum.clone(),
373            });
374        }
375
376        // Re-squeeze in the same order as the prover.
377        let zerocheck_point: Vec<C::Element> = transcript.get_field_challenges(num_vars, field_cfg);
378        let alpha: C::Element = transcript.get_field_challenge(field_cfg);
379        let alpha_powers: Vec<C::Element> = powers(
380            field_cfg,
381            &alpha,
382            num_wit_bin_cols.saturating_mul(bit_width),
383        );
384
385        Ok(BoolVerifierAncillary {
386            alpha_powers,
387            zerocheck_point,
388            num_wit_bin_cols,
389            bit_width,
390            num_vars,
391        })
392    }
393
394    /// Post-sumcheck half of the booleanity verifier.
395    ///
396    /// Validates the length of `bit_slice_evals`, recomputes the
397    /// booleanity residue at the shared sumcheck point $r^\star$, and
398    /// compares it against the sumcheck's `expected_eval`. On success,
399    /// absorbs `bit_slice_evals` into the transcript.
400    ///
401    /// The bit-decomposition consistency at $r^\star$ is **not** checked
402    /// here; the protocol layer squeezes $\alpha'$ and appends an extra
403    /// multipoint-eval column (and up-eval $c'_j$) per witness
404    /// binary-poly column derived from `bit_slice_evals` — see the
405    /// module-level docs.
406    pub fn finalize_verifier(
407        transcript: &mut impl Transcript,
408        proof: BooleanityProof<C::Element>,
409        shared_point: Vec<C::Element>,
410        expected_eval: &C::Element,
411        ancillary: BoolVerifierAncillary<C::Element>,
412        field_cfg: &C,
413    ) -> Result<BoolVerifierSubclaim<C::Element>, BooleanityError<C::Element>> {
414        let expected_len = ancillary
415            .num_wit_bin_cols
416            .saturating_mul(ancillary.bit_width);
417        if proof.bit_slice_evals.len() != expected_len {
418            return Err(BooleanityError::WrongBitSliceEvalsNumber {
419                expected: expected_len,
420                got: proof.bit_slice_evals.len(),
421            });
422        }
423
424        // eq(r*, r) — selector value at the shared sumcheck point.
425        let eq_r_val = eq_eval(field_cfg, &shared_point, &ancillary.zerocheck_point)?;
426
427        // Recompute the comb_fn body at r* using the received bit-slice evals.
428        let sum =
429            batched_booleanity_sum(field_cfg, &proof.bit_slice_evals, &ancillary.alpha_powers);
430        let expected_claim_value = field_cfg.mul(&sum, &eq_r_val);
431
432        if expected_claim_value != *expected_eval {
433            return Err(BooleanityError::ClaimValueDoesNotMatch {
434                expected: expected_claim_value,
435                got: expected_eval.clone(),
436            });
437        }
438
439        let mut transcription_buf: Vec<u8> = vec![0; <C::Integer as ConstTranscribable>::NUM_BYTES];
440        transcript.absorb_field_element_slice(
441            field_cfg,
442            &proof.bit_slice_evals,
443            &mut transcription_buf,
444        );
445
446        Ok(BoolVerifierSubclaim {
447            bit_slice_evals: proof.bit_slice_evals,
448        })
449    }
450}
451
452//
453// Round-1 fast path
454//
455
456/// Closed-form round-1 message and round-1 fold for the booleanity sumcheck
457/// group. Bit-identical to running [`SumcheckProverState::prove_round`].
458///
459/// Algebraic identities exploited:
460///
461/// 1. Bit-slice $v(v-1)$ collapse: for $v(X, b') = (1-X) \cdot A + X \cdot B$
462///    with $A, B \in \\{0,1\\}$, $$ v(X, b') (v(X, b') - 1) = (A \oplus B)
463///    \cdot X(X-1). $$ Only the bit `A XOR B` depends on data; the factor
464///    $X(X-1)$ is universal.
465/// 2. $eq_r$ factorization on the first variable: $$\widetilde{eq_r}(X, b') =
466///    e_0(X) \cdot E_{\text{other}}(b'),$$ with $e_0(X) = (1-X)(1-r_0) + X r_0$
467///    and $E_{\text{other}}(b') = \widetilde{eq_{r_{[1..]}}}(b')$.
468///
469/// The round-1 polynomial collapses to
470/// $$
471/// p_1(X) = e_0(X) \cdot X(X-1) \cdot T_1, \quad T_1 = \sum_{b'} S(b')
472///   E_{\text{other}}(b'), \quad S(b') = \sum_k \alpha^k (A_k(b') \oplus
473///   B_k(b')),
474/// $$
475/// The round-1 fold consumes the verifier challenge $r_1$ and writes each
476/// post-fold bit-slice entry via a 4-way table lookup keyed by $(A, B) \in
477/// \{0,1\}^2$.
478struct BooleanityRound1FastPath<C: SetConfig, const D: usize> {
479    /// Per-column binary trace (cloned to avoid lifetime issues).
480    binary_cols: Vec<DenseMultilinearExtension<BinaryPoly<D>>>,
481    /// Powers of the batching challenge over the flat `(j-major, i-minor)`
482    /// index: `[1, alpha, ..., alpha^{N*D - 1}]`.
483    alpha_powers: Vec<C::Element>,
484    /// Evaluations of $E_{\text{other}}(b') = \widetilde{eq_{r_{[1..]}}}(b')$
485    /// on `{0,1}^{num_vars - 1}`. When `num_vars == 1` this is the single
486    /// entry `[1]` (the empty product).
487    eq_other_table: Vec<C::Element>,
488    /// The first coordinate of the zerocheck point, $r_0$.
489    r_first_coord: C::Element,
490    /// Number of variables of the sumcheck.
491    num_vars: usize,
492}
493
494impl<C, const D: usize> Round1FastPath<C> for BooleanityRound1FastPath<C, D>
495where
496    C: SemiringConfig,
497{
498    fn round_1_message(&self, config: &C) -> Round1Output<C::Element> {
499        let zero = config.zero();
500        let one = config.one();
501        let half_n: usize = 1_usize << self.num_vars.saturating_sub(1);
502
503        // T_1 = sum_{b'} S(b') * E_other(b').
504        let mut t1 = zero.clone();
505        for b_prime in 0..half_n {
506            let mut s_b = zero.clone();
507            for (j, col) in self.binary_cols.iter().enumerate() {
508                let two_b = mul!(2, b_prime);
509                let row_a = &col.evaluations[two_b];
510                let row_b = &col.evaluations[add!(two_b, 1)];
511                for (i, (a_bit, b_bit)) in row_a.iter().zip(row_b.iter()).enumerate() {
512                    if a_bit != b_bit {
513                        let k = add!(i, mul!(j, D));
514                        config.add_assign(&mut s_b, &self.alpha_powers[k]);
515                    }
516                }
517            }
518            let term = config.mul(&s_b, &self.eq_other_table[b_prime]);
519            config.add_assign(&mut t1, &term);
520        }
521
522        // Closed-form tail evaluations (with X(X-1) = 0 at X=1):
523        //   p_1(1) = 0
524        //   p_1(2) = e_0(2) * 2*1*T_1 = 2 * (3*r_0 - 1) * T_1
525        //   p_1(3) = e_0(3) * 3*2*T_1 = 6 * (5*r_0 - 2) * T_1
526        let two = config.add(&one, &one);
527        let three = config.add(&two, &one);
528        let five = config.add(&three, &two);
529        let six = config.mul(&three, &two);
530
531        let three_r0_minus_one = config.sub(&config.mul(&three, &self.r_first_coord), &one);
532        let five_r0_minus_two = config.sub(&config.mul(&five, &self.r_first_coord), &two);
533
534        let p1_at_1 = zero.clone();
535        let p1_at_2 = config.mul(&config.mul(&two, &three_r0_minus_one), &t1);
536        let p1_at_3 = config.mul(&config.mul(&six, &five_r0_minus_two), &t1);
537
538        Round1Output {
539            // Booleanity is a zerocheck; the asserted sum p_1(0)+p_1(1) is 0.
540            asserted_sum: zero,
541            tail_evaluations: vec![p1_at_1, p1_at_2, p1_at_3],
542        }
543    }
544
545    fn fold_with_challenge(
546        self: Box<Self>,
547        r_1: &C::Element,
548        config: &C,
549    ) -> Vec<DenseMultilinearExtension<C::Element>> {
550        // Edge case: when the sumcheck has only one round, the standard prover never
551        // performs the folding.
552        //
553        // The post-round-1 state expected by `finalize_prover` is the un-folded
554        // 1-variable MLE bundle (it then evaluates each MLE at `r_1`). Return that
555        // directly without applying the fold.
556        if self.num_vars <= 1 {
557            let r = [self.r_first_coord.clone()];
558            let eq_r =
559                build_eq_x_r(config, &r).expect("num_vars == 1 implies a single-coordinate r");
560            let mut out: Vec<DenseMultilinearExtension<C::Element>> =
561                Vec::with_capacity(self.binary_cols.len().saturating_mul(D).saturating_add(1));
562            out.push(eq_r);
563            out.extend(build_witness_bit_slice_mles::<C, D>(
564                &self.binary_cols,
565                config,
566            ));
567            return out;
568        }
569
570        let n_cols = self.binary_cols.len();
571        let total_bit_slices = n_cols.saturating_mul(D);
572        let new_num_vars = self.num_vars.saturating_sub(1);
573        let half_n: usize = 1_usize << new_num_vars;
574
575        let one = config.one();
576
577        // 4-way table for bit-slice fold: index = (A as usize) << 1 | (B as usize).
578        //   (0,0) -> 0
579        //   (0,1) -> r_1
580        //   (1,0) -> 1 - r_1
581        //   (1,1) -> 1
582        let bit_fold_table: [C::Element; 4] = [
583            config.zero(),
584            r_1.clone(),
585            config.sub(&one, r_1),
586            one.clone(),
587        ];
588
589        // eq_r fold: e_0(r_1) * E_other(b').
590        // e_0(r_1) = (1 - r_1)(1 - r_0) + r_1 * r_0.
591        let one_minus_r0 = config.sub(&one, &self.r_first_coord);
592        let one_minus_r1 = config.sub(&one, r_1);
593        let e_0_r1: C::Element = config.add(
594            &config.mul(&one_minus_r1, &one_minus_r0),
595            &config.mul(r_1, &self.r_first_coord),
596        );
597
598        let folded_eq_r_evals = (0..half_n)
599            .map(|b_prime| config.mul(&e_0_r1, &self.eq_other_table[b_prime]))
600            .collect_vec();
601
602        // Length = N*D + 1
603        let mut out: Vec<DenseMultilinearExtension<C::Element>> =
604            Vec::with_capacity(total_bit_slices.saturating_add(1));
605        out.push(DenseMultilinearExtension {
606            num_vars: new_num_vars,
607            evaluations: folded_eq_r_evals,
608        });
609
610        // Bit-slice fold via 4-way table lookup; emit MLEs in
611        // (j-major, i-minor) order.
612        for col in &self.binary_cols {
613            let mut col_evals: Vec<Vec<C::Element>> =
614                (0..D).map(|_| Vec::with_capacity(half_n)).collect();
615            for b_prime in 0..half_n {
616                let two_b = mul!(2, b_prime);
617                let row_a = &col.evaluations[two_b];
618                let row_b = &col.evaluations[add!(two_b, 1)];
619                for (i, (a_bit, b_bit)) in row_a.iter().zip(row_b.iter()).enumerate() {
620                    let idx =
621                        (usize::from(a_bit.into_inner()) << 1) | usize::from(b_bit.into_inner());
622                    col_evals[i].push(bit_fold_table[idx].clone());
623                }
624            }
625            for evals in col_evals {
626                out.push(DenseMultilinearExtension {
627                    num_vars: new_num_vars,
628                    evaluations: evals,
629                });
630            }
631        }
632
633        out
634    }
635}
636
637/// Errors from the booleanity subprotocol.
638#[derive(Debug, Error)]
639pub enum BooleanityError<F: std::fmt::Debug> {
640    #[error("no binary polynomial columns provided to booleanity checker")]
641    NoBinaryPolyColumns,
642    #[error("no affine virtual specs provided to booleanity checker")]
643    NoAffineVirtualSpecs,
644    #[error("sumcheck error: {0}")]
645    SumcheckError(#[from] SumCheckError<F>),
646    #[error("expected booleanity claimed sum is non-zero: got {got:?}")]
647    NonZeroClaimedSum { got: F },
648    #[error("wrong number of bit-slice evaluations: expected {expected}, got {got}")]
649    WrongBitSliceEvalsNumber { expected: usize, got: usize },
650    #[error("booleanity claim value does not match: expected {expected:?}, got {got:?}")]
651    ClaimValueDoesNotMatch { expected: F, got: F },
652    #[error("error evaluating MLE: {0}")]
653    MleEvaluationError(#[from] EvaluationError),
654    #[error("arithmetic error: {0}")]
655    Arith(#[from] ArithErrors),
656}
657
658//
659// Helpers
660//
661
662/// Compute the booleanity residue
663///
664/// $$
665/// sum_{k=0}^{N*D - 1} \alpha^k * v_k * (v_k - 1)
666/// $$
667///
668/// over a flat $(j\text{-major}, i\text{-minor})$ slice of bit-slice
669/// values with $k = j \cdot D + i$. `alpha_powers.len() ==
670/// bit_slice_values.len() == N * D`.
671///
672/// This is the body of the booleanity sumcheck's combination function
673/// (without the leading `eq_r` factor).
674fn batched_booleanity_sum<C: SemiringConfig>(
675    cfg: &C,
676    bit_slice_values: &[C::Element],
677    alpha_powers: &[C::Element],
678) -> C::Element {
679    debug_assert_eq!(bit_slice_values.len(), alpha_powers.len());
680
681    let one = cfg.one();
682    let mut sum = cfg.zero();
683    for (v, alpha_k) in bit_slice_values.iter().zip(alpha_powers.iter()) {
684        let booleanity = cfg.mul(&cfg.sub(v, &one), v);
685        cfg.add_assign(&mut sum, &cfg.mul(&booleanity, alpha_k));
686    }
687    sum
688}
689
690/// Build per-bit-slice MLEs for a set of binary-poly columns.
691///
692/// Returns `N * D` MLEs in `(j-major, i-minor)` order:
693/// $[v_{0,0}, v_{0,1}, ..., v_{0,D-1}, v_{1,0}, ...]$. The j-th column,
694/// i-th bit MLE evaluates at hypercube point `b` to the i-th bit of the
695/// row entry `trace_bin_poly[j][b]`.
696///
697/// Used by the `num_vars == 1` fallback of
698/// [`BooleanityRound1FastPath::fold_with_challenge`] (where the standard
699/// prover never performs the round-1 fold, so the fast path must emit
700/// the un-folded MLE bundle directly) and by booleanity unit tests.
701fn build_witness_bit_slice_mles<C, const D: usize>(
702    trace_bin_poly: &[DenseMultilinearExtension<BinaryPoly<D>>],
703    field_cfg: &C,
704) -> Vec<DenseMultilinearExtension<C::Element>>
705where
706    C: SemiringConfig,
707{
708    let zero = field_cfg.zero();
709    let one = field_cfg.one();
710
711    let mut out = Vec::with_capacity(trace_bin_poly.len().saturating_mul(D));
712    for col in trace_bin_poly {
713        let num_vars = col.num_vars;
714        let n_rows = col.evaluations.len();
715        for i in 0..D {
716            let mut evals: Vec<C::Element> = Vec::with_capacity(n_rows);
717            for entry in col.iter() {
718                let bit = entry
719                    .iter()
720                    .nth(i)
721                    .expect("BinaryPoly<D> has D coefficients")
722                    .into_inner();
723                evals.push(if bit { one.clone() } else { zero.clone() });
724            }
725            out.push(DenseMultilinearExtension {
726                num_vars,
727                evaluations: evals,
728            });
729        }
730    }
731    out
732}
733
734fn booleanity_zerocheck_setup<C>(
735    transcript: &mut impl Transcript,
736    num_cols: usize,
737    bit_width: usize,
738    num_vars: usize,
739    field_cfg: &C,
740) -> BooleanityZerocheckSetup<C::Element>
741where
742    C: BaseFieldConfig + ProjectPrimitiveIntegersWithConfig + 'static,
743    C::Integer: ConstTranscribable,
744{
745    // Order of challenge squeezing must match between prover and verifier.
746
747    // 1. Zerocheck point r.
748    let r: Vec<C::Element> = transcript.get_field_challenges(num_vars, field_cfg);
749
750    // 2. Single batching challenge alpha over the flat (j-major, i-minor) index.
751    //    Powers vector has length N*D.
752    let alpha: C::Element = transcript.get_field_challenge(field_cfg);
753    let alpha_powers: Vec<C::Element> =
754        powers(field_cfg, &alpha, num_cols.saturating_mul(bit_width));
755
756    // 3. comb_fn (degree 3 in the variables):
757    //
758    //       eq_r(b) * sum_k alpha^k * v_k(b) * (v_k(b) - 1)
759    //
760    //    The committed-column group installs a fast path that emits the
761    //    round-1 message and folded MLEs directly, so this closure is not
762    //    invoked in its first round. Generic affine virtual groups use it in
763    //    every round.
764    let comb_fn: CombFn<C::Element> = {
765        let alpha_powers = alpha_powers.clone();
766        let field_cfg = field_cfg.clone();
767        Box::new(move |mle_values: &[C::Element]| {
768            let eq_r_val = &mle_values[0];
769            let sum = batched_booleanity_sum(&field_cfg, &mle_values[1..], &alpha_powers);
770            field_cfg.mul(&sum, eq_r_val)
771        })
772    };
773
774    (r, alpha_powers, comb_fn)
775}
776
777/// Build per-bit-slice MLEs for affine virtual booleanity targets.
778///
779/// Returns `N * D` MLEs in `(spec-major, i-minor)` order:
780/// `$[v_{0,0}, v_{0,1}, ..., v_{0,D-1}, v_{1,0}, ...]$`. The `j`-th
781/// spec, `i`-th bit MLE evaluates at row `b` to the `i`-th coefficient of
782///
783/// ```text
784/// ones_coefficient * 1_D + sum_t coefficient_t * source_t[b + row_shift_t]
785/// ```
786///
787/// with out-of-range shifted rows contributing zero, matching `ShiftSpec`.
788/// `all_trace_bin_poly` must contain every binary-polynomial trace column in
789/// the UAIR's flat total-column order: public columns first, then witness
790/// columns.
791pub fn build_affine_virtual_bit_slice_mles<C, const D: usize>(
792    all_trace_bin_poly: &[DenseMultilinearExtension<BinaryPoly<D>>],
793    affine_virtual_specs: &[AffineVirtualSpec],
794    num_vars: usize,
795    field_cfg: &C,
796) -> Vec<DenseMultilinearExtension<C::Element>>
797where
798    C: SemiringConfig + ProjectPrimitiveIntegersWithConfig,
799{
800    if affine_virtual_specs.is_empty() {
801        return Vec::new();
802    }
803
804    let n_rows = 1usize << num_vars;
805    for col in all_trace_bin_poly {
806        assert_eq!(
807            col.num_vars, num_vars,
808            "all affine virtual source columns must have the same num_vars",
809        );
810        assert_eq!(
811            col.evaluations.len(),
812            n_rows,
813            "source column length must match num_vars",
814        );
815    }
816
817    let mut out = Vec::with_capacity(affine_virtual_specs.len().saturating_mul(D));
818    for spec in affine_virtual_specs {
819        let ones = field_cfg.project(&spec.ones_coefficient());
820        let terms: Vec<_> = spec
821            .terms()
822            .iter()
823            .map(|term| {
824                assert!(
825                    term.source_col() < all_trace_bin_poly.len(),
826                    "AffineVirtualTerm source_col {} out of range (binary columns = {})",
827                    term.source_col(),
828                    all_trace_bin_poly.len(),
829                );
830                (
831                    term.source_col(),
832                    field_cfg.project(&term.coefficient()),
833                    term.row_shift(),
834                )
835            })
836            .collect();
837
838        for bit_idx in 0..D {
839            let mut evals: Vec<C::Element> = Vec::with_capacity(n_rows);
840            for row_idx in 0..n_rows {
841                let mut residual = ones.clone();
842                for (source_col, coeff, row_shift) in &terms {
843                    let shifted_row = add!(row_idx, *row_shift);
844                    if shifted_row >= n_rows {
845                        continue;
846                    }
847                    let bit = all_trace_bin_poly[*source_col].evaluations[shifted_row]
848                        .iter()
849                        .nth(bit_idx)
850                        .expect("BinaryPoly<D> has D coefficients")
851                        .into_inner();
852                    if bit {
853                        field_cfg.add_assign(&mut residual, coeff);
854                    }
855                }
856                evals.push(residual);
857            }
858            out.push(DenseMultilinearExtension {
859                num_vars,
860                evaluations: evals,
861            });
862        }
863    }
864
865    out
866}
867
868//
869// Tests
870//
871
872#[cfg(test)]
873#[allow(
874    clippy::arithmetic_side_effects,
875    clippy::cast_possible_truncation,
876    clippy::cast_precision_loss,
877    clippy::cast_sign_loss,
878    clippy::clone_on_copy
879)]
880mod tests {
881    use super::*;
882    use crate::sumcheck::multi_degree::MultiDegreeSumcheck;
883    use crypto_bigint::{U128, const_monty_params};
884    use crypto_primitives::{
885        FixedConfig, ProjectElementWithConfig, crypto_bigint_const_monty::ConstMontyField,
886    };
887    use num_traits::{One, Zero};
888    use zinc_poly::mle::MultilinearExtension;
889    use zinc_transcript::Blake3Transcript;
890    use zinc_uair::{AffineVirtualSpec, AffineVirtualTerm};
891    use zinc_utils::powers;
892
893    const_monty_params!(TestParams, U128, "00000000b933426489189cb5b47d567f");
894    type F = ConstMontyField<TestParams, { U128::LIMBS }>;
895    type FC = FixedConfig<F>;
896
897    const D: usize = 4;
898
899    /// Build a `BinaryPoly<D>` whose i-th coefficient is `bits[i]` (LSB-first).
900    fn binp_from_bits(bits: [bool; D]) -> BinaryPoly<D> {
901        let value: u64 = bits
902            .iter()
903            .enumerate()
904            .filter_map(|(i, &b)| b.then_some(1_u64 << i))
905            .fold(0_u64, |acc, mask| acc | mask);
906        BinaryPoly::from(value)
907    }
908
909    /// Build a single binary-poly trace column from a vec of row-bit patterns.
910    fn build_col(rows: Vec<[bool; D]>) -> DenseMultilinearExtension<BinaryPoly<D>> {
911        let num_vars = (rows.len() as f64).log2().round() as usize;
912        debug_assert_eq!(rows.len(), 1_usize << num_vars);
913        let zero = BinaryPoly::zero();
914        let mut evals: Vec<BinaryPoly<D>> = rows.into_iter().map(binp_from_bits).collect();
915        // Sanity: replace any padding (none expected here) with zero.
916        if evals.is_empty() {
917            evals.push(zero);
918        }
919        DenseMultilinearExtension {
920            num_vars,
921            evaluations: evals,
922        }
923    }
924
925    /// Helper: 4 rows of all-zero bits.
926    fn zero_col_4rows() -> DenseMultilinearExtension<BinaryPoly<D>> {
927        build_col(vec![[false; D]; 4])
928    }
929
930    fn make_transcript() -> Blake3Transcript {
931        let mut t = Blake3Transcript::default();
932        t.absorb_bytes(b"booleanity test");
933        t
934    }
935
936    /// Run prepare → MultiDegreeSumcheck::prove → finalize on the prover,
937    /// then prepare → verify → finalize on the verifier, asserting the
938    /// outcome via `expect_ok`.
939    fn run_roundtrip(
940        cols: &[DenseMultilinearExtension<BinaryPoly<D>>],
941        num_vars: usize,
942        tamper_proof: impl FnOnce(&mut BooleanityProof<F>),
943        expect_err_is: impl FnOnce(&BooleanityError<F>) -> bool,
944        expect_ok: bool,
945    ) {
946        let cfg = &FC::default();
947
948        // Prover side.
949        let mut pt = make_transcript();
950        let (group, anc) =
951            BooleanityChecker::<FC>::prepare_sumcheck_group::<D>(&mut pt, cols, num_vars, cfg)
952                .expect("prepare_sumcheck_group failed");
953        let mut sumcheck_outputs = MultiDegreeSumcheck::<FC>::prove_as_subprotocol(
954            &mut pt,
955            vec![(vec![group], cfg)],
956            num_vars,
957            cfg,
958        );
959        let (md_proof, states) = sumcheck_outputs.pop().expect("single family");
960        let state = states.into_iter().next().unwrap();
961        let mut proof = BooleanityChecker::<FC>::finalize_prover(&mut pt, state, anc, cfg)
962            .expect("finalize prover");
963        tamper_proof(&mut proof);
964
965        // Verifier side.
966        let mut vt = make_transcript();
967        let anc_v = BooleanityChecker::<FC>::prepare_verifier(
968            &mut vt,
969            &md_proof.claimed_sums()[0],
970            cols.len(),
971            D,
972            num_vars,
973            cfg,
974        )
975        .expect("prepare_verifier (claimed sum should be zero for honest input)");
976        let md_subclaims = MultiDegreeSumcheck::<FC>::verify_as_subprotocol(
977            &mut vt,
978            num_vars,
979            &[(&md_proof, cfg)],
980            cfg,
981        )
982        .expect("md verify")
983        .pop()
984        .expect("single family");
985
986        let res = BooleanityChecker::<FC>::finalize_verifier(
987            &mut vt,
988            proof,
989            md_subclaims.point().to_vec(),
990            &md_subclaims.expected_evaluations()[0],
991            anc_v,
992            cfg,
993        );
994
995        if expect_ok {
996            res.expect("finalize_verifier should succeed");
997        } else {
998            let err = res.expect_err("finalize_verifier should fail");
999            assert!(
1000                expect_err_is(&err),
1001                "unexpected booleanity error variant: {err:?}",
1002            );
1003        }
1004    }
1005
1006    #[test]
1007    fn happy_path_all_bits() {
1008        // Two columns, 4 rows each, all valid bit patterns.
1009        let c0 = build_col(vec![
1010            [true, false, true, false],
1011            [false, true, false, true],
1012            [true, true, false, false],
1013            [false, false, true, true],
1014        ]);
1015        let c1 = build_col(vec![
1016            [false, false, false, false],
1017            [true, true, true, true],
1018            [false, true, true, false],
1019            [true, false, false, true],
1020        ]);
1021        run_roundtrip(&[c0, c1], 2, |_| {}, |_| false, true);
1022    }
1023
1024    /// Exercises the fast-path edge case where `eq_other_table = [1]`
1025    /// (empty product) and `fold_with_challenge` produces 0-variable
1026    /// MLEs, after which the rounds-2..num_vars loop runs zero times.
1027    #[test]
1028    fn happy_path_num_vars_one() {
1029        // Two columns, two rows each. Mixed bit patterns so the bit-slice
1030        // MLEs are not all-zero (exercises every branch of the 4-way
1031        // bit-fold table in `fold_with_challenge`).
1032        let c0 = build_col(vec![[true, false, true, false], [false, true, true, true]]);
1033        let c1 = build_col(vec![[false, false, true, true], [true, true, false, false]]);
1034        run_roundtrip(&[c0, c1], 1, |_| {}, |_| false, true);
1035    }
1036
1037    #[test]
1038    fn empty_column_set_is_supported_by_helper() {
1039        // The helper itself returns an empty Vec for N = 0. We don't run a
1040        // sumcheck (MultiDegreeSumcheck requires at least one group) but we
1041        // do exercise the helper for the empty case.
1042        let cols: Vec<DenseMultilinearExtension<BinaryPoly<D>>> = vec![];
1043        let mles = build_witness_bit_slice_mles::<FC, D>(&cols, &FC::default());
1044        assert!(mles.is_empty());
1045    }
1046
1047    #[test]
1048    fn tampered_bit_slice_evals_rejected() {
1049        let c0 = zero_col_4rows();
1050        // Tamper one entry of bit_slice_evals to a non-{0,1} value. The
1051        // booleanity comb_fn is v*(v-1) which vanishes on {0,1}, so we must
1052        // pick a non-bit value to actually corrupt the recomputed residue.
1053        run_roundtrip(
1054            &[c0],
1055            2,
1056            |proof| {
1057                proof.bit_slice_evals[0] += F::from(7u32);
1058            },
1059            |err| matches!(err, BooleanityError::ClaimValueDoesNotMatch { .. }),
1060            false,
1061        );
1062    }
1063
1064    #[test]
1065    fn non_bit_witness_rejected() {
1066        // Build cols honestly, then directly tamper one bit-slice eval in
1067        // the proof to simulate a malicious prover whose actual witness
1068        // had a non-bit entry. Note: we cannot inject a non-bit entry
1069        // through `BinaryPoly<D>` (it stores `Boolean`), so this is the
1070        // verifier-visible failure mode of a non-bit witness:
1071        // `bit_slice_evals` whose recomputed booleanity residue is
1072        // non-zero at the sumcheck point.
1073        let c0 = build_col(vec![
1074            [true, true, false, false],
1075            [false, true, true, false],
1076            [false, false, false, true],
1077            [true, false, true, true],
1078        ]);
1079        run_roundtrip(
1080            &[c0],
1081            2,
1082            |proof| {
1083                // Two of v=0/1 nudges that produce a non-{0,1} residue.
1084                proof.bit_slice_evals[1] += F::from(3u32);
1085            },
1086            |err| matches!(err, BooleanityError::ClaimValueDoesNotMatch { .. }),
1087            false,
1088        );
1089    }
1090
1091    #[test]
1092    fn wrong_bit_slice_evals_length_rejected() {
1093        let c0 = zero_col_4rows();
1094        run_roundtrip(
1095            &[c0],
1096            2,
1097            |proof| {
1098                proof.bit_slice_evals.pop();
1099            },
1100            |err| matches!(err, BooleanityError::WrongBitSliceEvalsNumber { .. }),
1101            false,
1102        );
1103    }
1104
1105    #[test]
1106    fn affine_virtual_bit_slice_mles_materialize_residual_coefficients() {
1107        let cfg = &FC::default();
1108        let public = build_col(vec![[true; D], [true; D], [true; D], [true; D]]);
1109        let a = build_col(vec![
1110            [true, false, true, false],
1111            [false, true, false, true],
1112            [true, true, false, false],
1113            [false, false, true, true],
1114        ]);
1115        let b = build_col(vec![
1116            [false, true, true, false],
1117            [true, false, false, false],
1118            [false, true, true, true],
1119            [true, true, false, false],
1120        ]);
1121        let m = build_col(vec![
1122            [false, false, true, false],
1123            [false, false, false, false],
1124            [false, true, false, false],
1125            [false, false, false, false],
1126        ]);
1127        let specs = vec![AffineVirtualSpec::new(vec![
1128            AffineVirtualTerm::new(1, 1),
1129            AffineVirtualTerm::new(2, 1),
1130            AffineVirtualTerm::new(3, -2),
1131        ])];
1132
1133        // Index 0 is the public binary-polynomial prefix. Affine source
1134        // indices use the full public-then-witness trace layout.
1135        let mles = build_affine_virtual_bit_slice_mles::<FC, D>(&[public, a, b, m], &specs, 2, cfg);
1136
1137        assert_eq!(mles.len(), D);
1138        let expected_by_bit: [[i32; 4]; D] =
1139            [[1, 1, 1, 1], [1, 1, 0, 1], [0, 0, 1, 1], [0, 1, 1, 1]];
1140        for (bit_idx, expected_rows) in expected_by_bit.iter().enumerate() {
1141            let expected: Vec<_> = expected_rows
1142                .iter()
1143                .map(|value| cfg.project(value))
1144                .collect();
1145            assert_eq!(
1146                mles[bit_idx].evaluations, expected,
1147                "unexpected affine virtual residual at bit {bit_idx}"
1148            );
1149        }
1150    }
1151
1152    #[test]
1153    fn affine_virtual_bit_slice_mles_apply_forward_shift_and_ones_offset() {
1154        let cfg = &FC::default();
1155        let a = build_col(vec![
1156            [true, false, false, false],
1157            [false, true, false, false],
1158            [true, true, false, false],
1159            [false, false, false, false],
1160        ]);
1161        let specs = vec![AffineVirtualSpec::with_ones_coefficient(
1162            vec![AffineVirtualTerm::new_shifted(0, -1, 1)],
1163            1,
1164        )];
1165
1166        let mles = build_affine_virtual_bit_slice_mles::<FC, D>(&[a], &specs, 2, cfg);
1167
1168        assert_eq!(mles.len(), D);
1169        let expected_by_bit: [[i32; 4]; D] =
1170            [[1, 0, 1, 1], [0, 0, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]];
1171        for (bit_idx, expected_rows) in expected_by_bit.iter().enumerate() {
1172            let expected: Vec<_> = expected_rows
1173                .iter()
1174                .map(|value| cfg.project(value))
1175                .collect();
1176            assert_eq!(
1177                mles[bit_idx].evaluations, expected,
1178                "unexpected shifted affine virtual residual at bit {bit_idx}"
1179            );
1180        }
1181    }
1182
1183    fn run_affine_virtual_roundtrip(
1184        all_trace_bin_poly: &[DenseMultilinearExtension<BinaryPoly<D>>],
1185        specs: &[AffineVirtualSpec],
1186        num_vars: usize,
1187        tamper_proof: impl FnOnce(&mut BooleanityProof<F>),
1188    ) -> Result<(), BooleanityError<F>> {
1189        let cfg = &FC::default();
1190
1191        let mut pt = make_transcript();
1192        let (group, anc) = BooleanityChecker::<FC>::prepare_affine_virtual_sumcheck_group::<D>(
1193            &mut pt,
1194            all_trace_bin_poly,
1195            specs,
1196            num_vars,
1197            cfg,
1198        )
1199        .expect("prepare_affine_virtual_sumcheck_group failed");
1200        let mut sumcheck_outputs = MultiDegreeSumcheck::<FC>::prove_as_subprotocol(
1201            &mut pt,
1202            vec![(vec![group], cfg)],
1203            num_vars,
1204            cfg,
1205        );
1206        let (md_proof, states) = sumcheck_outputs.pop().expect("single family");
1207        let state = states.into_iter().next().unwrap();
1208        let mut proof = BooleanityChecker::<FC>::finalize_prover(&mut pt, state, anc, cfg)
1209            .expect("finalize prover");
1210        tamper_proof(&mut proof);
1211
1212        let mut vt = make_transcript();
1213        let verifier_anc = BooleanityChecker::<FC>::prepare_verifier(
1214            &mut vt,
1215            &md_proof.claimed_sums()[0],
1216            specs.len(),
1217            D,
1218            num_vars,
1219            cfg,
1220        )?;
1221        let md_subclaims = MultiDegreeSumcheck::<FC>::verify_as_subprotocol(
1222            &mut vt,
1223            num_vars,
1224            &[(&md_proof, cfg)],
1225            cfg,
1226        )
1227        .expect("md verify")
1228        .pop()
1229        .expect("single family");
1230
1231        BooleanityChecker::<FC>::finalize_verifier(
1232            &mut vt,
1233            proof,
1234            md_subclaims.point().to_vec(),
1235            &md_subclaims.expected_evaluations()[0],
1236            verifier_anc,
1237            cfg,
1238        )
1239        .map(|_| ())
1240    }
1241
1242    #[test]
1243    fn affine_virtual_group_accepts_boolean_residual_and_rejects_non_boolean_residual() {
1244        let public = build_col(vec![[true; D], [true; D], [true; D], [true; D]]);
1245        let a = build_col(vec![
1246            [true, false, true, false],
1247            [false, true, false, true],
1248            [true, true, false, false],
1249            [false, false, true, true],
1250        ]);
1251        let b = build_col(vec![
1252            [false, true, true, false],
1253            [true, false, false, false],
1254            [false, true, true, true],
1255            [true, true, false, false],
1256        ]);
1257        let m = build_col(vec![
1258            [false, false, true, false],
1259            [false, false, false, false],
1260            [false, true, false, false],
1261            [false, false, false, false],
1262        ]);
1263        let specs = vec![AffineVirtualSpec::new(vec![
1264            AffineVirtualTerm::new(1, 1),
1265            AffineVirtualTerm::new(2, 1),
1266            AffineVirtualTerm::new(3, -2),
1267        ])];
1268
1269        run_affine_virtual_roundtrip(
1270            &[public.clone(), a.clone(), b.clone(), m],
1271            &specs,
1272            2,
1273            |_| {},
1274        )
1275        .expect("boolean affine residual should verify");
1276
1277        let bad_m = build_col(vec![
1278            [true, false, true, false],
1279            [false, false, false, false],
1280            [false, true, false, false],
1281            [false, false, false, false],
1282        ]);
1283        let err = run_affine_virtual_roundtrip(&[public, a, b, bad_m], &specs, 2, |_| {})
1284            .expect_err("non-boolean affine residual should be rejected");
1285        assert!(matches!(err, BooleanityError::NonZeroClaimedSum { .. }));
1286    }
1287
1288    #[test]
1289    fn affine_virtual_group_rejects_tampered_bit_slice_eval() {
1290        let zero = zero_col_4rows();
1291        let specs = vec![AffineVirtualSpec::new(vec![AffineVirtualTerm::new(0, 1)])];
1292
1293        let err = run_affine_virtual_roundtrip(&[zero], &specs, 2, |proof| {
1294            proof.bit_slice_evals[0] += F::from(7u32);
1295        })
1296        .expect_err("tampered affine bit-slice evaluation should be rejected");
1297        assert!(matches!(
1298            err,
1299            BooleanityError::ClaimValueDoesNotMatch { .. }
1300        ));
1301    }
1302
1303    #[test]
1304    fn affine_virtual_prepare_rejects_missing_inputs_with_precise_errors() {
1305        let cfg = &FC::default();
1306        let mut transcript = make_transcript();
1307        let col = zero_col_4rows();
1308        let no_specs = BooleanityChecker::<FC>::prepare_affine_virtual_sumcheck_group::<D>(
1309            &mut transcript,
1310            &[col],
1311            &[],
1312            2,
1313            cfg,
1314        );
1315        assert!(matches!(
1316            no_specs,
1317            Err(BooleanityError::NoAffineVirtualSpecs)
1318        ));
1319
1320        let mut transcript = make_transcript();
1321        let specs = vec![AffineVirtualSpec::new(vec![AffineVirtualTerm::new(0, 1)])];
1322        let no_columns = BooleanityChecker::<FC>::prepare_affine_virtual_sumcheck_group::<D>(
1323            &mut transcript,
1324            &[],
1325            &specs,
1326            2,
1327            cfg,
1328        );
1329        assert!(matches!(
1330            no_columns,
1331            Err(BooleanityError::NoBinaryPolyColumns)
1332        ));
1333    }
1334
1335    //
1336    // Round-1 fast path cross-validation.
1337    //
1338    // These tests pin down the fast path's bit-identical equivalence with
1339    // the standard prover (`prove_round` + `fix_variables`) on
1340    // the same booleanity inputs. If either of these fails the fast path
1341    // has diverged from the standard path and the verifier will reject.
1342    //
1343
1344    /// Inputs shared between the two cross-validation tests.
1345    struct FastPathHarness {
1346        cols: Vec<DenseMultilinearExtension<BinaryPoly<D>>>,
1347        num_vars: usize,
1348        r: Vec<F>,
1349        alpha_powers: Vec<F>,
1350        zero: F,
1351        one: F,
1352    }
1353
1354    fn cross_validation_inputs() -> FastPathHarness {
1355        // Three columns, eight rows, mixed bit patterns. nvars = 3 exercises
1356        // both round-1 (closed form) and rounds 2..num_vars (standard path).
1357        let c0 = build_col(vec![
1358            [true, false, true, false],
1359            [false, true, false, true],
1360            [true, true, false, false],
1361            [false, false, true, true],
1362            [true, true, true, false],
1363            [false, true, true, false],
1364            [true, false, false, true],
1365            [false, false, false, false],
1366        ]);
1367        let c1 = build_col(vec![
1368            [false, false, false, false],
1369            [true, true, true, true],
1370            [false, true, true, false],
1371            [true, false, false, true],
1372            [false, false, true, false],
1373            [true, true, false, true],
1374            [false, true, false, true],
1375            [true, false, true, false],
1376        ]);
1377        let c2 = build_col(vec![
1378            [true; D],
1379            [false; D],
1380            [true, false, false, false],
1381            [false, false, false, true],
1382            [true, true, false, false],
1383            [false, false, true, true],
1384            [true, false, true, true],
1385            [false, true, false, false],
1386        ]);
1387        let cols = vec![c0, c1, c2];
1388
1389        let num_vars = 3usize;
1390        let one = F::one();
1391        let zero = F::zero();
1392
1393        // Arbitrary (but deterministic) zerocheck point and batching challenge.
1394        let r: Vec<F> = (0..num_vars)
1395            .map(|i| F::from((i as u32) * 13 + 41))
1396            .collect();
1397        let alpha = F::from(23u32);
1398        let alpha_powers = powers(&FC::default(), &alpha, cols.len() * D);
1399
1400        FastPathHarness {
1401            cols,
1402            num_vars,
1403            r,
1404            alpha_powers,
1405            zero,
1406            one,
1407        }
1408    }
1409
1410    fn standard_path_mles(
1411        cols: &[DenseMultilinearExtension<BinaryPoly<D>>],
1412        r: &[F],
1413        cfg: &FC,
1414    ) -> Vec<DenseMultilinearExtension<F>> {
1415        let mut mles = Vec::with_capacity(1_usize.saturating_add(cols.len().saturating_mul(D)));
1416        mles.push(build_eq_x_r(cfg, r).expect("eq_r build"));
1417        mles.extend(build_witness_bit_slice_mles::<FC, D>(cols, cfg));
1418        mles
1419    }
1420
1421    fn make_comb_fn(alpha_powers: Vec<F>) -> CombFn<F> {
1422        Box::new(move |mle_values: &[F]| {
1423            let cfg = FC::default();
1424            let eq_r_val = &mle_values[0];
1425            let sum = batched_booleanity_sum(&cfg, &mle_values[1..], &alpha_powers);
1426            sum * eq_r_val
1427        })
1428    }
1429
1430    fn make_fast_path(h: &FastPathHarness, cfg: &FC) -> BooleanityRound1FastPath<FC, D> {
1431        let eq_other_table: Vec<F> = if h.num_vars <= 1 {
1432            vec![h.one.clone()]
1433        } else {
1434            build_eq_x_r(cfg, &h.r[1..])
1435                .expect("eq_other build")
1436                .evaluations
1437        };
1438        BooleanityRound1FastPath::<FC, D> {
1439            binary_cols: h.cols.clone(),
1440            alpha_powers: h.alpha_powers.clone(),
1441            eq_other_table,
1442            r_first_coord: h.r[0].clone(),
1443            num_vars: h.num_vars,
1444        }
1445    }
1446
1447    /// Round-1 polynomial tail and asserted sum produced by the fast path
1448    /// match what `SumcheckProverState::prove_round` would emit on the same
1449    /// inputs.
1450    #[test]
1451    fn fast_path_round_1_matches_standard_prove_round() {
1452        let cfg = &FC::default();
1453        let h = cross_validation_inputs();
1454        let fast_path = make_fast_path(&h, cfg);
1455
1456        let fp_out = fast_path.round_1_message(cfg);
1457
1458        // Standard path: build full-size MLEs, then run a single prove_round
1459        // (with no verifier message — this is the first round).
1460        let mles = standard_path_mles(&h.cols, &h.r, cfg);
1461        let comb_fn = make_comb_fn(h.alpha_powers.clone());
1462        let mut state = SumcheckProverState::<FC>::new(mles, h.num_vars, 3);
1463        let std_msg = state.prove_round(&None, &comb_fn, cfg);
1464
1465        assert_eq!(
1466            fp_out.tail_evaluations, std_msg.0.tail_evaluations,
1467            "round-1 tail evaluations differ between fast and standard paths"
1468        );
1469        assert_eq!(
1470            Some(fp_out.asserted_sum.clone()),
1471            state.asserted_sum,
1472            "round-1 asserted sums differ between fast and standard paths"
1473        );
1474        assert_eq!(
1475            fp_out.asserted_sum, h.zero,
1476            "booleanity is a zerocheck — asserted sum must be 0"
1477        );
1478    }
1479
1480    /// Post-round-1 MLE values from `fold_with_challenge(&r_1)` are
1481    /// bit-identical to what `fix_variables(cfg, &[r_1])`
1482    /// would produce on the standard-path full-size MLEs.
1483    #[test]
1484    fn fast_path_fold_with_r1_matches_standard_fix_variables() {
1485        let cfg = &FC::default();
1486        let h = cross_validation_inputs();
1487        let r_1 = F::from(97u32);
1488
1489        // Fast path.
1490        let fast_path = Box::new(make_fast_path(&h, cfg));
1491        let fp_mles = fast_path.fold_with_challenge(&r_1, cfg);
1492
1493        // Standard path: build full-size MLEs, then fold the first variable.
1494        let mut std_mles = standard_path_mles(&h.cols, &h.r, cfg);
1495        for mle in &mut std_mles {
1496            mle.fix_variables(cfg, std::slice::from_ref(&r_1));
1497        }
1498
1499        assert_eq!(
1500            fp_mles.len(),
1501            std_mles.len(),
1502            "fast and standard paths must produce the same number of MLEs"
1503        );
1504        for (k, (fp_mle, std_mle)) in fp_mles.iter().zip(std_mles.iter()).enumerate() {
1505            assert_eq!(
1506                fp_mle.num_vars, std_mle.num_vars,
1507                "num_vars mismatch on folded MLE #{k}"
1508            );
1509            assert_eq!(
1510                fp_mle.evaluations, std_mle.evaluations,
1511                "evaluations mismatch on folded MLE #{k}"
1512            );
1513        }
1514    }
1515}