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 argument is structured as a single
5//! [`MultiDegreeSumcheckGroup`] of degree 3, batched alongside the existing
6//! CPR group with shared randomness, and discharges its bit-slice claims for
7//! free against the existing `lifted_evals` payload.
8//!
9//! # Relation
10//!
11//! For each witness binary-poly column $u_j \in (F_q^{<D}[X])^n$ with
12//! $n = 2^\mu$, decompose row-wise as
13//!
14//! $$
15//! {u_j}[b](X) = \sum_{i=0}^{D-1} v_{j,i,b} * X^i
16//! $$
17//!
18//! The booleanity claim is:
19//!
20//! $$
21//!   \forall j, i, b:  v_{j,i,b} \in \\{0,1\\}
22//! $$
23//!
24//! Equivalently, the MLE statement
25//! $\widetilde{v_{j,i}}(b)*(\widetilde{v_{j,i}}(b)-1) = 0$ for all
26//! $b \in {0,1}^\mu$. The protocol reduces this to a single batched sumcheck
27//!
28//! $$
29//! \sum_{b in {0,1}^\mu} eq(r, b) *
30//!   \sum_{j=0}^{N-1} \sum_{i=0}^{D-1} \delta^j * \gamma^i *
31//!     \widetilde{v_{j,i}}(b) * (\widetilde{v_{j,i}}(b) - 1)  =  0
32//! $$
33//!
34//! with batching challenges $\gamma$ (over the `D` bit-slices) and $\delta$
35//! (over the `N` witness binary-poly columns), and zerocheck point $r$.
36//! After the sumcheck reaches $r*$, the prover sends `bit_slice_evals` =
37//! $(\widetilde{v_{j,i}}(r*))$ to the verifier; these are then folded by
38//! `MultipointEval` into the standard evaluation point $r_0$ together with
39//! the CPR up/down evals. At $r_0$ the bit-slice MLE evaluations are exactly
40//! the coefficients of the polynomial-valued `lifted_evals` (by the
41//! MLE-commutes-with-coefficient-extraction identity), so the verifier
42//! discharges them for free.
43
44use crate::{
45    CombFn,
46    sumcheck::{
47        SumCheckError, multi_degree::MultiDegreeSumcheckGroup,
48        prover::ProverState as SumcheckProverState,
49    },
50};
51use crypto_primitives::PrimeField;
52use num_traits::Zero;
53use std::{marker::PhantomData, slice};
54use thiserror::Error;
55use zinc_poly::{
56    EvaluationError,
57    mle::{DenseMultilinearExtension, MultilinearExtensionWithConfig},
58    univariate::binary::BinaryPoly,
59    utils::{ArithErrors, build_eq_x_r_inner, eq_eval},
60};
61use zinc_transcript::{
62    delegate_transcribable,
63    traits::{ConstTranscribable, Transcript},
64};
65use zinc_utils::{add, inner_transparent_field::InnerTransparentField, mul, powers};
66
67//
68// Structs
69//
70
71/// Booleanity sumcheck group constructor / verifier.
72pub struct BooleanityChecker<F: InnerTransparentField>(PhantomData<F>);
73
74/// Proof produced by the booleanity prover. Carries only the flat list of
75/// bit-slice MLE evaluations at the multi-degree sumcheck output point
76/// $r*$. The remaining open-eval consistency check at $r_0$ is discharged
77/// by the protocol-layer caller against `lifted_evals.coeffs`.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct BooleanityProof<F: PrimeField> {
80    /// Flat list of `\widetilde{v_{j,i}}(r*)`, ordered `(j-major, i-minor)`.
81    /// Length = `num_wit_bin_cols * D`.
82    pub bit_slice_evals: Vec<F>,
83}
84
85delegate_transcribable!(BooleanityProof<F> { bit_slice_evals: Vec<F> }
86    where F: PrimeField, F::Inner: ConstTranscribable, F::Modulus: ConstTranscribable);
87
88/// Ancillary data produced by [`BooleanityChecker::prepare_sumcheck_group`]
89/// and consumed by [`BooleanityChecker::finalize_prover`].
90pub struct BoolProverAncillary {
91    /// Number of witness binary-poly columns (`N`).
92    pub num_wit_bin_cols: usize,
93    /// Bit-width of each binary-poly coefficient cell (`D`).
94    pub bit_width: usize,
95    /// Number of variables of the trace MLEs.
96    pub num_vars: usize,
97}
98
99/// Ancillary data produced by [`BooleanityChecker::prepare_verifier`] and
100/// consumed by [`BooleanityChecker::finalize_verifier`].
101pub struct BoolVerifierAncillary<F: PrimeField> {
102    /// Powers of the bit-slice batching challenge:
103    /// `[1, gamma, ..., gamma^{D-1}]`.
104    pub gamma_powers: Vec<F>,
105    /// Powers of the column batching challenge:
106    /// `[1, delta, ..., delta^{N-1}]`.
107    pub delta_powers: Vec<F>,
108    /// The zerocheck point `r` sampled before the multi-degree sumcheck.
109    pub zerocheck_point: Vec<F>,
110    /// Number of witness binary-poly columns (`N`).
111    pub num_wit_bin_cols: usize,
112    /// Bit-width of each binary-poly coefficient cell (`D`).
113    pub bit_width: usize,
114    /// Number of variables (for sanity-checking the shared point length).
115    pub num_vars: usize,
116}
117
118/// Subclaim emitted by [`BooleanityChecker::finalize_verifier`].
119///
120/// Carries the (now-validated against the sumcheck residue)
121/// `bit_slice_evals` at the shared multi-degree sumcheck point `r*`. The
122/// protocol-layer caller threads these as additional `up_evals` into
123/// [`crate::multipoint_eval::MultipointEval`], whose final consistency
124/// check at `r_0` is discharged against the coefficients of the existing
125/// `lifted_evals` polynomials.
126#[derive(Clone, Debug)]
127pub struct BoolVerifierSubclaim<F: PrimeField> {
128    /// Bit-slice MLE evaluations at `r*`, in `(j-major, i-minor)` order.
129    pub bit_slice_evals: Vec<F>,
130    /// Shared evaluation point `r*` from the multi-degree sumcheck.
131    pub evaluation_point: Vec<F>,
132}
133
134//
135// Protocol
136//
137
138impl<F> BooleanityChecker<F>
139where
140    F: InnerTransparentField + Send + Sync + 'static,
141    F::Inner: ConstTranscribable + Clone + Send + Sync + Zero + Default,
142    F::Modulus: ConstTranscribable,
143{
144    /// Build the booleanity sumcheck group, to be appended to the
145    /// multi-degree sumcheck.
146    pub fn prepare_sumcheck_group<const D: usize>(
147        transcript: &mut impl Transcript,
148        trace_bin_poly: &[DenseMultilinearExtension<BinaryPoly<D>>],
149        num_vars: usize,
150        field_cfg: &F::Config,
151    ) -> Result<(MultiDegreeSumcheckGroup<F>, BoolProverAncillary), BooleanityError<F>> {
152        let n = trace_bin_poly.len();
153        let one = F::one_with_cfg(field_cfg);
154        let zero = F::zero_with_cfg(field_cfg);
155
156        // Order of challenge squeezing must match between prover and verifier.
157
158        // 1. Zerocheck point r.
159        let r: Vec<F> = transcript.get_field_challenges(num_vars, field_cfg);
160
161        // 2. Slice batching challenge gamma and its powers.
162        let gamma: F = transcript.get_field_challenge(field_cfg);
163        let gamma_powers: Vec<F> = powers(gamma, one.clone(), D);
164
165        // 3. Column batching challenge delta and its powers.
166        let delta: F = transcript.get_field_challenge(field_cfg);
167        let delta_powers: Vec<F> = powers(delta, one.clone(), n);
168
169        // 4. Build eq(r, *) MLE.
170        let eq_r = build_eq_x_r_inner(&r, field_cfg)?;
171
172        // 5. Build N*D bit-slice MLEs in canonical (j-major, i-minor) order.
173        let mut mles = Vec::with_capacity(add!(n.saturating_mul(D), 1));
174        mles.push(eq_r);
175        mles.extend(build_witness_bit_slice_mles::<F, D>(
176            trace_bin_poly,
177            field_cfg,
178        ));
179
180        // 6. Build comb_fn (degree 3 in the variables):
181        //   eq_r(b) * sum_{j,i} delta^j * gamma^i * v_{j,i}(b) * (v_{j,i}(b) - 1)
182        let comb_fn: CombFn<F> = Box::new(move |mle_values: &[F]| {
183            let eq_r_val = &mle_values[0];
184            let sum =
185                batched_booleanity_sum(&mle_values[1..], &delta_powers, &gamma_powers, &zero, &one);
186            sum * eq_r_val
187        });
188
189        Ok((
190            MultiDegreeSumcheckGroup::new(3, mles, comb_fn),
191            BoolProverAncillary {
192                num_wit_bin_cols: n,
193                bit_width: D,
194                num_vars,
195            },
196        ))
197    }
198
199    /// Finalize the booleanity proof after the multi-degree sumcheck
200    /// completes.
201    ///
202    /// Mirrors the structure of `CombinedPolyResolver::finalize_prover`:
203    /// evaluates each bit-slice MLE at the final sumcheck challenge,
204    /// emits the flat `bit_slice_evals` vector, and absorbs it into the
205    /// transcript.
206    pub fn finalize_prover(
207        transcript: &mut impl Transcript,
208        sumcheck_prover_state: SumcheckProverState<F>,
209        ancillary: BoolProverAncillary,
210        field_cfg: &F::Config,
211    ) -> Result<BooleanityProof<F>, BooleanityError<F>> {
212        debug_assert!(
213            sumcheck_prover_state
214                .mles
215                .iter()
216                .all(|mle| mle.num_vars == 1),
217            "sumcheck should reduce MLEs to num_vars == 1"
218        );
219
220        let last_sumcheck_challenge = sumcheck_prover_state
221            .randomness
222            .last()
223            .expect("sumcheck cannot have had 0 rounds")
224            .clone();
225
226        let expected_len = ancillary
227            .num_wit_bin_cols
228            .saturating_mul(ancillary.bit_width);
229
230        let mut mles = sumcheck_prover_state.mles;
231        // Skip MLE 0 = eq_r (verifier recomputes it).
232        let bit_slice_evals: Vec<F> = mles
233            .drain(1..)
234            .map(|mle| {
235                mle.evaluate_with_config(slice::from_ref(&last_sumcheck_challenge), field_cfg)
236            })
237            .collect::<Result<Vec<_>, _>>()?;
238
239        debug_assert_eq!(bit_slice_evals.len(), expected_len);
240
241        let mut transcription_buf: Vec<u8> = vec![0; F::Inner::NUM_BYTES];
242        transcript.absorb_random_field_slice(&bit_slice_evals, &mut transcription_buf);
243
244        Ok(BooleanityProof { bit_slice_evals })
245    }
246
247    /// Pre-sumcheck half of the booleanity verifier.
248    ///
249    /// Must run after the CPR `prepare_verifier` and before
250    /// `MultiDegreeSumcheck::verify_as_subprotocol` to maintain transcript
251    /// ordering.
252    pub fn prepare_verifier(
253        transcript: &mut impl Transcript,
254        claimed_sum: &F,
255        num_wit_bin_cols: usize,
256        bit_width: usize,
257        num_vars: usize,
258        field_cfg: &F::Config,
259    ) -> Result<BoolVerifierAncillary<F>, BooleanityError<F>> {
260        if num_wit_bin_cols == 0 {
261            return Err(BooleanityError::NoBinaryPolyColumns);
262        }
263        if !F::is_zero(claimed_sum) {
264            return Err(BooleanityError::NonZeroClaimedSum {
265                got: claimed_sum.clone(),
266            });
267        }
268
269        let one = F::one_with_cfg(field_cfg);
270
271        // Re-squeeze in the same order as the prover.
272        let zerocheck_point: Vec<F> = transcript.get_field_challenges(num_vars, field_cfg);
273        let gamma: F = transcript.get_field_challenge(field_cfg);
274        let gamma_powers: Vec<F> = powers(gamma, one.clone(), bit_width);
275        let delta: F = transcript.get_field_challenge(field_cfg);
276        let delta_powers: Vec<F> = powers(delta, one, num_wit_bin_cols);
277
278        Ok(BoolVerifierAncillary {
279            gamma_powers,
280            delta_powers,
281            zerocheck_point,
282            num_wit_bin_cols,
283            bit_width,
284            num_vars,
285        })
286    }
287
288    /// Post-sumcheck half of the booleanity verifier.
289    ///
290    /// Validates the length of `bit_slice_evals`, recomputes the expected
291    /// combination-function evaluation at the shared sumcheck point `r*`
292    /// using the received `bit_slice_evals`, and compares it against the
293    /// sumcheck's `expected_evaluation`. On success, absorbs
294    /// `bit_slice_evals` into the transcript (mirroring the prover's
295    /// final absorption in `finalize_prover`).
296    pub fn finalize_verifier(
297        transcript: &mut impl Transcript,
298        proof: BooleanityProof<F>,
299        shared_point: Vec<F>,
300        expected_evaluation: &F,
301        ancillary: BoolVerifierAncillary<F>,
302        field_cfg: &F::Config,
303    ) -> Result<BoolVerifierSubclaim<F>, BooleanityError<F>> {
304        let expected_len = ancillary
305            .num_wit_bin_cols
306            .saturating_mul(ancillary.bit_width);
307        if proof.bit_slice_evals.len() != expected_len {
308            return Err(BooleanityError::WrongBitSliceEvalsNumber {
309                expected: expected_len,
310                got: proof.bit_slice_evals.len(),
311            });
312        }
313
314        let one = F::one_with_cfg(field_cfg);
315        let zero = F::zero_with_cfg(field_cfg);
316
317        // eq(r*, r) — selector value at the shared sumcheck point.
318        let eq_r_val = eq_eval(&shared_point, &ancillary.zerocheck_point, one.clone())?;
319
320        // Recompute the comb_fn body at r* using the received bit-slice evals.
321        let sum = batched_booleanity_sum(
322            &proof.bit_slice_evals,
323            &ancillary.delta_powers,
324            &ancillary.gamma_powers,
325            &zero,
326            &one,
327        );
328        let expected_claim_value = sum * eq_r_val;
329
330        if expected_claim_value != *expected_evaluation {
331            return Err(BooleanityError::ClaimValueDoesNotMatch {
332                expected: expected_claim_value,
333                got: expected_evaluation.clone(),
334            });
335        }
336
337        let mut transcription_buf: Vec<u8> = vec![0; F::Inner::NUM_BYTES];
338        transcript.absorb_random_field_slice(&proof.bit_slice_evals, &mut transcription_buf);
339
340        Ok(BoolVerifierSubclaim {
341            bit_slice_evals: proof.bit_slice_evals,
342            evaluation_point: shared_point,
343        })
344    }
345}
346
347/// Errors from the booleanity subprotocol.
348#[derive(Debug, Error)]
349pub enum BooleanityError<F: PrimeField> {
350    #[error("no binary polynomial columns provided to booleanity checker")]
351    NoBinaryPolyColumns,
352    #[error("sumcheck error: {0}")]
353    SumcheckError(#[from] SumCheckError<F>),
354    #[error("expected booleanity claimed sum is non-zero: got {got}")]
355    NonZeroClaimedSum { got: F },
356    #[error("wrong number of bit-slice evaluations: expected {expected}, got {got}")]
357    WrongBitSliceEvalsNumber { expected: usize, got: usize },
358    #[error("booleanity claim value does not match: expected {expected}, got {got}")]
359    ClaimValueDoesNotMatch { expected: F, got: F },
360    #[error("error evaluating MLE: {0}")]
361    MleEvaluationError(#[from] EvaluationError),
362    #[error("arithmetic error: {0}")]
363    Arith(#[from] ArithErrors),
364}
365
366//
367// Helpers
368//
369
370/// Compute the booleanity residue
371///
372/// $$
373/// sum_{j=0}^{N-1} sum_{i=0}^{D-1}
374///   \delta^j * \gamma^i * v_{j,i} * (v_{j,i} - 1)
375/// $$
376///
377/// over a flat `(j-major, i-minor)` slice of bit-slice values
378/// (`N = delta_powers.len()`, `D = gamma_powers.len()`,
379/// `bit_slice_values.len() == N * D`).
380///
381/// This is the body of the booleanity sumcheck's combination function
382/// (without the leading `eq_r` factor).
383fn batched_booleanity_sum<F: PrimeField>(
384    bit_slice_values: &[F],
385    delta_powers: &[F],
386    gamma_powers: &[F],
387    zero: &F,
388    one: &F,
389) -> F {
390    let n = delta_powers.len();
391    let d = gamma_powers.len();
392    debug_assert_eq!(bit_slice_values.len(), mul!(n, d));
393
394    let mut sum = zero.clone();
395    for (j, delta_j) in delta_powers.iter().enumerate().take(n) {
396        let jd = mul!(j, d);
397        for i in 0..d {
398            let v = &bit_slice_values[add!(i, jd)];
399            let gamma_i = &gamma_powers[i];
400            let booleanity = (v.clone() - one) * v;
401            sum += booleanity * delta_j * gamma_i;
402        }
403    }
404    sum
405}
406
407/// Build per-bit-slice MLEs for a set of binary-poly columns.
408///
409/// Returns `N * D` MLEs in `(j-major, i-minor)` order:
410/// $[v_{0,0}, v_{0,1}, ..., v_{0,D-1}, v_{1,0}, ...]$. The j-th column,
411/// i-th bit MLE evaluates at hypercube point `b` to the i-th bit of the
412/// row entry `trace_bin_poly[j][b]`.
413///
414/// This helper is the single source of truth for MLE ordering. Both the
415/// booleanity sumcheck group and the `MultipointEval` extension call it
416/// to guarantee identical ordering between prover and verifier.
417pub fn build_witness_bit_slice_mles<F, const D: usize>(
418    trace_bin_poly: &[DenseMultilinearExtension<BinaryPoly<D>>],
419    field_cfg: &F::Config,
420) -> Vec<DenseMultilinearExtension<F::Inner>>
421where
422    F: PrimeField,
423    F::Inner: Clone + Send + Sync,
424{
425    let zero_inner = F::zero_with_cfg(field_cfg).into_inner();
426    let one_inner = F::one_with_cfg(field_cfg).into_inner();
427
428    let mut out = Vec::with_capacity(trace_bin_poly.len().saturating_mul(D));
429    for col in trace_bin_poly {
430        let num_vars = col.num_vars;
431        let n_rows = col.evaluations.len();
432        for i in 0..D {
433            let mut evals: Vec<F::Inner> = Vec::with_capacity(n_rows);
434            for entry in col.iter() {
435                let bit = entry
436                    .iter()
437                    .nth(i)
438                    .expect("BinaryPoly<D> has D coefficients")
439                    .into_inner();
440                evals.push(if bit {
441                    one_inner.clone()
442                } else {
443                    zero_inner.clone()
444                });
445            }
446            out.push(DenseMultilinearExtension {
447                num_vars,
448                evaluations: evals,
449            });
450        }
451    }
452    out
453}
454
455//
456// Tests
457//
458
459#[cfg(test)]
460#[allow(
461    clippy::cast_possible_truncation,
462    clippy::cast_precision_loss,
463    clippy::cast_sign_loss
464)]
465mod tests {
466    use super::*;
467    use crate::sumcheck::multi_degree::MultiDegreeSumcheck;
468    use crypto_bigint::{U128, const_monty_params};
469    use crypto_primitives::crypto_bigint_const_monty::ConstMontyField;
470    use zinc_transcript::Blake3Transcript;
471
472    const_monty_params!(TestParams, U128, "00000000b933426489189cb5b47d567f");
473    type F = ConstMontyField<TestParams, { U128::LIMBS }>;
474
475    const D: usize = 4;
476
477    /// Build a `BinaryPoly<D>` whose i-th coefficient is `bits[i]` (LSB-first).
478    fn binp_from_bits(bits: [bool; D]) -> BinaryPoly<D> {
479        let value: u64 = bits
480            .iter()
481            .enumerate()
482            .filter_map(|(i, &b)| b.then_some(1_u64 << i))
483            .fold(0_u64, |acc, mask| acc | mask);
484        BinaryPoly::from(value)
485    }
486
487    /// Build a single binary-poly trace column from a vec of row-bit patterns.
488    fn build_col(rows: Vec<[bool; D]>) -> DenseMultilinearExtension<BinaryPoly<D>> {
489        let num_vars = (rows.len() as f64).log2().round() as usize;
490        debug_assert_eq!(rows.len(), 1usize << num_vars);
491        let zero = BinaryPoly::zero();
492        let mut evals: Vec<BinaryPoly<D>> = rows.into_iter().map(binp_from_bits).collect();
493        // Sanity: replace any padding (none expected here) with zero.
494        if evals.is_empty() {
495            evals.push(zero);
496        }
497        DenseMultilinearExtension {
498            num_vars,
499            evaluations: evals,
500        }
501    }
502
503    /// Helper: 4 rows of all-zero bits.
504    fn zero_col_4rows() -> DenseMultilinearExtension<BinaryPoly<D>> {
505        build_col(vec![[false; D]; 4])
506    }
507
508    fn make_transcript() -> Blake3Transcript {
509        let mut t = Blake3Transcript::default();
510        t.absorb_slice(b"booleanity test");
511        t
512    }
513
514    /// Run prepare → MultiDegreeSumcheck::prove → finalize on the prover,
515    /// then prepare → verify → finalize on the verifier, asserting the
516    /// outcome via `expect_ok`.
517    fn run_roundtrip(
518        cols: &[DenseMultilinearExtension<BinaryPoly<D>>],
519        num_vars: usize,
520        tamper_proof: impl FnOnce(&mut BooleanityProof<F>),
521        expect_err_is: impl FnOnce(&BooleanityError<F>) -> bool,
522        expect_ok: bool,
523    ) {
524        let cfg = &();
525
526        // Prover side.
527        let mut pt = make_transcript();
528        let (group, anc) =
529            BooleanityChecker::<F>::prepare_sumcheck_group::<D>(&mut pt, cols, num_vars, cfg)
530                .expect("prepare_sumcheck_group failed");
531        let (md_proof, states) =
532            MultiDegreeSumcheck::<F>::prove_as_subprotocol(&mut pt, vec![group], num_vars, cfg);
533        let state = states.into_iter().next().unwrap();
534        let mut proof = BooleanityChecker::<F>::finalize_prover(&mut pt, state, anc, cfg)
535            .expect("finalize prover");
536        tamper_proof(&mut proof);
537
538        // Verifier side.
539        let mut vt = make_transcript();
540        let anc_v = BooleanityChecker::<F>::prepare_verifier(
541            &mut vt,
542            &md_proof.claimed_sums()[0],
543            cols.len(),
544            D,
545            num_vars,
546            cfg,
547        )
548        .expect("prepare_verifier (claimed sum should be zero for honest input)");
549        let md_subclaims =
550            MultiDegreeSumcheck::<F>::verify_as_subprotocol(&mut vt, num_vars, &md_proof, cfg)
551                .expect("md verify");
552
553        let res = BooleanityChecker::<F>::finalize_verifier(
554            &mut vt,
555            proof,
556            md_subclaims.point().to_vec(),
557            &md_subclaims.expected_evaluations()[0],
558            anc_v,
559            cfg,
560        );
561
562        if expect_ok {
563            res.expect("finalize_verifier should succeed");
564        } else {
565            let err = res.expect_err("finalize_verifier should fail");
566            assert!(
567                expect_err_is(&err),
568                "unexpected booleanity error variant: {err:?}",
569            );
570        }
571    }
572
573    #[test]
574    fn happy_path_all_bits() {
575        // Two columns, 4 rows each, all valid bit patterns.
576        let c0 = build_col(vec![
577            [true, false, true, false],
578            [false, true, false, true],
579            [true, true, false, false],
580            [false, false, true, true],
581        ]);
582        let c1 = build_col(vec![
583            [false, false, false, false],
584            [true, true, true, true],
585            [false, true, true, false],
586            [true, false, false, true],
587        ]);
588        run_roundtrip(&[c0, c1], 2, |_| {}, |_| false, true);
589    }
590
591    #[test]
592    fn empty_column_set_is_supported_by_helper() {
593        // The helper itself returns an empty Vec for N = 0. We don't run a
594        // sumcheck (MultiDegreeSumcheck requires at least one group) but we
595        // do exercise the helper for the empty case.
596        let cols: Vec<DenseMultilinearExtension<BinaryPoly<D>>> = vec![];
597        let mles = build_witness_bit_slice_mles::<F, D>(&cols, &());
598        assert!(mles.is_empty());
599    }
600
601    #[test]
602    fn tampered_bit_slice_evals_rejected() {
603        let c0 = zero_col_4rows();
604        // Tamper one entry of bit_slice_evals to a non-{0,1} value. The
605        // booleanity comb_fn is v*(v-1) which vanishes on {0,1}, so we must
606        // pick a non-bit value to actually corrupt the recomputed residue.
607        run_roundtrip(
608            &[c0],
609            2,
610            |proof| {
611                proof.bit_slice_evals[0] += F::from(7u32);
612            },
613            |err| matches!(err, BooleanityError::ClaimValueDoesNotMatch { .. }),
614            false,
615        );
616    }
617
618    #[test]
619    fn non_bit_witness_rejected() {
620        // Build cols honestly, then directly tamper one bit-slice eval in
621        // the proof to simulate a malicious prover whose actual witness
622        // had a non-bit entry. Note: we cannot inject a non-bit entry
623        // through `BinaryPoly<D>` (it stores `Boolean`), so this is the
624        // verifier-visible failure mode of a non-bit witness:
625        // `bit_slice_evals` whose recomputed booleanity residue is
626        // non-zero at the sumcheck point.
627        let c0 = build_col(vec![
628            [true, true, false, false],
629            [false, true, true, false],
630            [false, false, false, true],
631            [true, false, true, true],
632        ]);
633        run_roundtrip(
634            &[c0],
635            2,
636            |proof| {
637                // Two of v=0/1 nudges that produce a non-{0,1} residue.
638                proof.bit_slice_evals[1] += F::from(3u32);
639            },
640            |err| matches!(err, BooleanityError::ClaimValueDoesNotMatch { .. }),
641            false,
642        );
643    }
644
645    #[test]
646    fn wrong_bit_slice_evals_length_rejected() {
647        let c0 = zero_col_4rows();
648        run_roundtrip(
649            &[c0],
650            2,
651            |proof| {
652                proof.bit_slice_evals.pop();
653            },
654            |err| matches!(err, BooleanityError::WrongBitSliceEvalsNumber { .. }),
655            false,
656        );
657    }
658}