From 5fd3c2906bb9624c8f8acdd5548c4287fb757bc8 Mon Sep 17 00:00:00 2001 From: amit0365 Date: Fri, 10 Apr 2026 22:19:07 +0530 Subject: [PATCH 1/6] Add LogUp module with Lookup Uairs --- piop/src/lookup/logup.rs | 488 +++++++++++++++++++++++++++++++++++++ piop/src/lookup/mod.rs | 3 +- piop/src/lookup/structs.rs | 76 +++++- piop/src/lookup/utils.rs | 485 ++++++++++++++++++++++++++++++++++++ test-uair/src/lib.rs | 312 +++++++++++++++++++++++- utils/src/parallel.rs | 27 ++ 6 files changed, 1385 insertions(+), 6 deletions(-) create mode 100644 piop/src/lookup/logup.rs create mode 100644 piop/src/lookup/utils.rs diff --git a/piop/src/lookup/logup.rs b/piop/src/lookup/logup.rs new file mode 100644 index 00000000..25082dd0 --- /dev/null +++ b/piop/src/lookup/logup.rs @@ -0,0 +1,488 @@ +//! LogUp sumcheck group construction and verifier finalization. +//! +//! Provides building blocks for the LogUp lookup argument within the +//! Zinc+ multi-degree sumcheck pipeline. The LogUp identity is: +//! +//! ```text +//! Σ_i 1/(β − w_i) = Σ_j m_j/(β − T_j) +//! ``` +//! +//! The prover commits multiplicities `m` and inverse witnesses +//! `u = 1/(β − w)` via Zip+ PCS. The table inverse `v = 1/(β − T)` is +//! NOT committed — the verifier computes it from the public table. +//! +//! ## Sumcheck groups +//! +//! Two groups are produced per witness column: +//! +//! - **Group 0** (zerocheck, degree 3): `(d·u − 1)·eq(r, y)` where `d = β − w`. +//! Enforces `u = 1/(β − w)` pointwise. +//! - **Group 1** (sumcheck, degree 2): `u(y) − m(y)·v(y)`. Enforces `Σ u = Σ +//! m·v` (the LogUp sum identity). this is sumcheck with claimed sum = 0. +//! +//! The verifier checks `claimed_sum == 0` for both groups, then +//! verifies the subclaim evaluations at the shared point `x*`. +//! +//! ## Committed model +//! +//! In the full pipeline (`protocol/src/prover.rs`), m and u are committed +//! via PCS and opened at `r_0` through the multipoint eval sumcheck. +//! The functions here are build sumcheck groups and check subclaim evaluations. + +use crypto_primitives::FromPrimitiveWithConfig; +use num_traits::Zero; +#[cfg(feature = "parallel")] +use rayon::prelude::*; +use std::marker::PhantomData; +use zinc_poly::{ + mle::DenseMultilinearExtension, + utils::{build_eq_x_r_inner, build_eq_x_r_vec}, +}; +use zinc_transcript::traits::{ConstTranscribable, Transcript}; +use zinc_uair::LookupTableType; +use zinc_utils::{cfg_iter, inner_transparent_field::InnerTransparentField}; + +use crate::{ + lookup::{ + LogupFinalizerInput, LogupProverAncillary, LookupGroup, utils::batch_inverse_shifted, + }, + sumcheck::multi_degree::MultiDegreeSumcheckGroup, +}; + +use super::{ + structs::{LogupVerifierPreSumcheckData, LookupAuxEvals, LookupError}, + utils::{generate_bitpoly_table, generate_word_table}, +}; + +/// LogUp sumcheck group builder and verifier finalizer. +pub struct LogupProtocol(PhantomData); + +impl LogupProtocol { + /// Build two LogUp sumcheck groups from pre-computed vectors. + /// + /// Returns `[group_0, group_1]` where: + /// - Group 0 (degree 3): `(d·u − 1)·eq(r, y)` — zerocheck for inverse + /// correctness. + /// - Group 1 (degree 2): `u − m·v` — sumcheck for the LogUp sum identity + /// with claimed_sum = 0. + /// + /// The caller is responsible for PCS commitment, transcript operations, + /// and squeezing β and r before calling this function. + #[allow(clippy::arithmetic_side_effects)] + pub fn build_sumcheck_groups( + witness: &[F], + table: &[F], + aux: &LogupProverAncillary<'_, F>, + beta: &F, + r: &[F], + field_cfg: &F::Config, + ) -> Result>, LookupError> + where + F::Inner: Zero + Default + Send + Sync, + F: 'static, + { + let zero = F::zero_with_cfg(field_cfg); + let one = F::one_with_cfg(field_cfg); + + let w_num_vars = zinc_utils::log2(witness.len().next_power_of_two()) as usize; + let t_num_vars = zinc_utils::log2(table.len().next_power_of_two()) as usize; + let num_vars = w_num_vars.max(t_num_vars); + + let eq_r = build_eq_x_r_inner(r, field_cfg)?; + + // ---- Build MLEs ---- + let inner_zero = zero.inner().clone(); + let mk_mle = |data: &[F]| -> DenseMultilinearExtension { + DenseMultilinearExtension::from_evaluations_vec( + num_vars, + cfg_iter!(data).map(|x| x.inner().clone()).collect(), + inner_zero.clone(), + ) + }; + + let beta_inner = beta.inner(); + + // d = (β − w): denominator for witness inverse check + let d_mle = DenseMultilinearExtension::from_evaluations_vec( + num_vars, + cfg_iter!(witness) + .map(|w_i| F::sub_inner(beta_inner, w_i.inner(), field_cfg)) + .collect(), + inner_zero.clone(), + ); + + let u_mle = mk_mle(aux.inverse_witness); + let v_mle = mk_mle(aux.inverse_table); + let m_mle = mk_mle(aux.multiplicities); + + // group0: (d·u − 1) · eq where d = (β − w) + let one0 = one.clone(); + let group_0 = MultiDegreeSumcheckGroup::new( + 3, + vec![eq_r.clone(), d_mle, u_mle.clone()], + Box::new(move |v: &[F]| (v[1].clone() * &v[2] - &one0) * &v[0]), + ); + + // group1: (u − m·v) + let group_1 = MultiDegreeSumcheckGroup::new( + 2, + vec![u_mle, m_mle.clone(), v_mle.clone()], + Box::new(move |v: &[F]| v[0].clone() - &(v[1].clone() * &v[2])), + ); + + Ok(vec![group_0, group_1]) + } + + /// Extract witness columns and the shared lookup table for a group. + /// + /// Returns `(witnesses, table)` where `witnesses[i]` corresponds to + /// `group_info.column_indices[i]`. The table is generated from + /// `group_info.table_type`. + pub fn extract_witnesses_and_table( + projected_trace_f: &[DenseMultilinearExtension], + group_info: &super::LookupGroup, + projecting_element_f: &F, + field_cfg: &F::Config, + ) -> (Vec>, Vec) { + use super::utils::{generate_bitpoly_table, generate_word_table}; + use zinc_uair::LookupTableType; + + let witnesses: Vec> = group_info + .column_indices + .iter() + .map(|&col_idx| { + projected_trace_f[col_idx] + .iter() + .map(|inner| F::new_unchecked_with_cfg(inner.clone(), field_cfg)) + .collect() + }) + .collect(); + + let table: Vec = match &group_info.table_type { + LookupTableType::BitPoly { width, .. } => { + generate_bitpoly_table(*width, projecting_element_f, field_cfg) + } + LookupTableType::Word { width, .. } => generate_word_table(*width, field_cfg), + }; + + (witnesses, table) + } + + /// Pre-sumcheck transcript sync for LogUp. + /// + /// Absorbs PCS commitment roots into the transcript + /// and squeezes the challenges β and r, mirroring the prover's + /// `prepare_lookup_groups`. Must run BEFORE the multi-degree sumcheck + /// verify to keep the transcript in sync. + pub fn build_verifier_pre_sumcheck( + transcript: &mut impl Transcript, + comm_m_root: &[u8], + comm_u_root: &[u8], + num_vars: usize, + field_cfg: &F::Config, + ) -> LogupVerifierPreSumcheckData + where + F::Inner: ConstTranscribable, + { + // Absorb multiplicity commitment root, squeeze β + transcript.absorb_slice(comm_m_root); + let beta: F = transcript.get_field_challenge(field_cfg); + + // Absorb inverse witness root, squeeze r + transcript.absorb_slice(comm_u_root); + let r: Vec = transcript.get_field_challenges(num_vars, field_cfg); + + LogupVerifierPreSumcheckData { r, beta } + } + + /// Post-sumcheck finalization for the LogUp verifier. + /// + /// Given the subclaim point `x*` and the two expected evaluations + /// from the multi-degree sumcheck, verifies both LogUp identities: + /// + /// - Group 0: `((β − w_eval)·u_eval − 1)·eq_val == expected[0]` + /// - Group 1: `u_eval − m_eval·v_eval == expected[1]` + /// + /// `v_eval` is computed internally from the public table + β (not + /// from the proof) — the table inverse is never committed. + #[allow(clippy::arithmetic_side_effects)] + pub fn finalize_verifier( + pre_sumcheck_data: &LogupVerifierPreSumcheckData, + input: LogupFinalizerInput<'_, F>, + group_info: &LookupGroup, + projecting_element_f: &F, + field_cfg: &F::Config, + ) -> Result<(), LookupError> + where + F::Inner: ConstTranscribable + Zero, + { + let one = F::one_with_cfg(field_cfg); + let LogupVerifierPreSumcheckData { r, beta } = pre_sumcheck_data; + let LogupFinalizerInput { + subclaim_point, + expected_evaluations, + w_eval, + aux_evals: LookupAuxEvals { u_eval, m_eval }, + } = input; + + // Evaluate eq(x*, r) + let eq_val = zinc_poly::utils::eq_eval(subclaim_point, r, one.clone())?; + + // Evaluate (β − T) at x*: the verifier regenerates the table + let table: Vec = match &group_info.table_type { + LookupTableType::BitPoly { width, .. } => { + generate_bitpoly_table(*width, projecting_element_f, field_cfg) + } + LookupTableType::Word { width, .. } => generate_word_table(*width, field_cfg), + }; + + let eq_at_point = build_eq_x_r_vec(subclaim_point, field_cfg)?; + let zero = F::zero_with_cfg(field_cfg); + + let v = batch_inverse_shifted(beta, &table); + let v_eval: F = v + .iter() + .zip(eq_at_point.iter()) + .fold(zero, |acc, (v, e)| acc + &(v.clone() * e)); + + // Group 0: inverse correctness — (d·u − 1)·eq where d = (β − w) + let computed_0 = ((beta.clone() - w_eval) * u_eval - &one) * &eq_val; + if computed_0 != expected_evaluations[0] { + return Err(LookupError::FinalEvaluationMismatch); + } + + // Group 1: (u − m·v) + let computed_1 = u_eval.clone() - &(m_eval.clone() * &v_eval); + if computed_1 != expected_evaluations[1] { + return Err(LookupError::FinalEvaluationMismatch); + } + + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] +mod tests { + use super::*; + use crate::sumcheck::multi_degree::MultiDegreeSumcheck; + use crypto_bigint::{U128, const_monty_params}; + use crypto_primitives::crypto_bigint_const_monty::ConstMontyField; + use num_traits::ConstZero; + use zinc_poly::mle::MultilinearExtensionWithConfig; + use zinc_transcript::Blake3Transcript; + + use super::super::{ + LogupFinalizerInput, LookupGroup, + utils::{batch_inverse_shifted, compute_multiplicities}, + }; + + const N: usize = 2; + const_monty_params!(TestParams, U128, "00000000b933426489189cb5b47d567f"); + type F = ConstMontyField; + + fn make_transcript() -> Blake3Transcript { + let mut t = Blake3Transcript::default(); + t.absorb_slice(b"logup-test"); + t + } + + /// Run the full LogUp pipeline on a single (witness, table) pair: + /// compute aux → build sumcheck groups → run multi-degree sumcheck → + /// finalize_verifier identity checks. + fn run_logup_roundtrip(witness: &[F], table: &[F]) { + let num_vars = zinc_utils::log2(witness.len().next_power_of_two()) as usize; + + let m = compute_multiplicities(witness, table, &()).expect("witness should be in table"); + let beta = F::from(17u32); + let u = batch_inverse_shifted(&beta, witness); + let v = batch_inverse_shifted(&beta, table); + + let r: Vec = (0..num_vars).map(|i| F::from((i + 3) as u32)).collect(); + + let aux = super::super::LogupProverAncillary { + multiplicities: &m, + inverse_witness: &u, + inverse_table: &v, + }; + let groups = LogupProtocol::build_sumcheck_groups(witness, table, &aux, &beta, &r, &()) + .expect("build_sumcheck_groups should succeed"); + + assert_eq!(groups.len(), 2); + + // Run the multi-degree sumcheck (prover side) + let mut prover_transcript = make_transcript(); + let (md_proof, _md_states) = MultiDegreeSumcheck::prove_as_subprotocol( + &mut prover_transcript, + groups, + num_vars, + &(), + ); + + // All the claimed sums should be zero (the identities sum to 0 + // over the boolean hypercube when witness exists in table). + for (g, cs) in md_proof.claimed_sums().iter().enumerate() { + assert_eq!(*cs, F::ZERO, "group {g} claimed sum should be zero"); + } + + // Verify the sumcheck + let mut verifier_transcript = make_transcript(); + let md_subclaims = MultiDegreeSumcheck::verify_as_subprotocol( + &mut verifier_transcript, + num_vars, + &md_proof, + &(), + ) + .expect("sumcheck verify should succeed"); + + // Compute w_eval, u_eval, m_eval at subclaim point x* + let x_star = md_subclaims.point(); + let inner_zero = F::ZERO.into_inner(); + let mk_mle = |data: &[F]| -> DenseMultilinearExtension<_> { + DenseMultilinearExtension::from_evaluations_vec( + num_vars, + data.iter().map(|f| f.into_inner()).collect(), + inner_zero, + ) + }; + + let w_eval: F = mk_mle(witness) + .evaluate_with_config(x_star, &()) + .expect("eval should succeed"); + let u_eval: F = mk_mle(&u) + .evaluate_with_config(x_star, &()) + .expect("eval should succeed"); + let m_eval: F = mk_mle(&m) + .evaluate_with_config(x_star, &()) + .expect("eval should succeed"); + + let table_width = zinc_utils::log2(table.len().next_power_of_two()) as usize; + let group_info = LookupGroup { + table_type: LookupTableType::Word { + width: table_width, + chunk_width: None, + }, + column_indices: vec![0], + }; + + let pre = LogupVerifierPreSumcheckData { r, beta }; + let aux_evals = LookupAuxEvals { u_eval, m_eval }; + let fin_input = LogupFinalizerInput { + subclaim_point: x_star, + expected_evaluations: md_subclaims.expected_evaluations(), + w_eval: &w_eval, + aux_evals: &aux_evals, + }; + + LogupProtocol::::finalize_verifier(&pre, fin_input, &group_info, &F::ZERO, &()) + .expect("finalize_verifier should succeed"); + } + + #[test] + fn logup_roundtrip_small() { + let table: Vec = (0..4u32).map(F::from).collect(); + let witness: Vec = vec![0u32, 1, 1, 3].into_iter().map(F::from).collect(); + run_logup_roundtrip(&witness, &table); + } + + #[test] + fn logup_roundtrip_all_same() { + let table: Vec = (0..4u32).map(F::from).collect(); + let witness: Vec = vec![2u32; 4].into_iter().map(F::from).collect(); + run_logup_roundtrip(&witness, &table); + } + + #[test] + fn logup_roundtrip_full_table() { + let table: Vec = (0..8u32).map(F::from).collect(); + let witness: Vec = (0..8u32).map(F::from).collect(); + run_logup_roundtrip(&witness, &table); + } + + #[test] + fn logup_reject_invalid_witness() { + let table: Vec = (0..4u32).map(F::from).collect(); + let witness: Vec = vec![0u32, 5].into_iter().map(F::from).collect(); + + let result = compute_multiplicities(&witness, &table, &()); + assert!( + result.is_none(), + "witness entry 5 not in table should return None" + ); + } + + #[test] + fn logup_wrong_aux_eval_rejected() { + let table: Vec = (0..4u32).map(F::from).collect(); + let witness: Vec = vec![0u32, 1, 1, 3].into_iter().map(F::from).collect(); + let num_vars = 2; + + let m = compute_multiplicities(&witness, &table, &()).unwrap(); + let beta = F::from(17u32); + let u = batch_inverse_shifted(&beta, &witness); + let v = batch_inverse_shifted(&beta, &table); + let r: Vec = (0..num_vars).map(|i| F::from((i + 3) as u32)).collect(); + + let aux = super::super::LogupProverAncillary { + multiplicities: &m, + inverse_witness: &u, + inverse_table: &v, + }; + let groups = + LogupProtocol::build_sumcheck_groups(&witness, &table, &aux, &beta, &r, &()).unwrap(); + + let mut prover_transcript = make_transcript(); + let (md_proof, _) = MultiDegreeSumcheck::prove_as_subprotocol( + &mut prover_transcript, + groups, + num_vars, + &(), + ); + + let mut verifier_transcript = make_transcript(); + let md_subclaims = MultiDegreeSumcheck::verify_as_subprotocol( + &mut verifier_transcript, + num_vars, + &md_proof, + &(), + ) + .unwrap(); + + let x_star = md_subclaims.point(); + let inner_zero = F::ZERO.into_inner(); + let mk_mle = |data: &[F]| -> DenseMultilinearExtension<_> { + DenseMultilinearExtension::from_evaluations_vec( + num_vars, + data.iter().map(|f| f.into_inner()).collect(), + inner_zero, + ) + }; + let w_eval: F = mk_mle(&witness).evaluate_with_config(x_star, &()).unwrap(); + let u_eval: F = mk_mle(&u).evaluate_with_config(x_star, &()).unwrap(); + let m_eval: F = mk_mle(&m).evaluate_with_config(x_star, &()).unwrap(); + + let group_info = LookupGroup { + table_type: LookupTableType::Word { + width: 2, + chunk_width: None, + }, + column_indices: vec![0], + }; + let pre = LogupVerifierPreSumcheckData { r, beta }; + + // Corrupt u_eval + let bad_aux = LookupAuxEvals { + u_eval: u_eval + F::from(1u32), + m_eval, + }; + let fin_input = LogupFinalizerInput { + subclaim_point: x_star, + expected_evaluations: md_subclaims.expected_evaluations(), + w_eval: &w_eval, + aux_evals: &bad_aux, + }; + let result = + LogupProtocol::::finalize_verifier(&pre, fin_input, &group_info, &F::ZERO, &()); + assert!(result.is_err(), "corrupted u_eval should be rejected"); + } +} diff --git a/piop/src/lookup/mod.rs b/piop/src/lookup/mod.rs index 54830ae6..b95bd5ca 100644 --- a/piop/src/lookup/mod.rs +++ b/piop/src/lookup/mod.rs @@ -1,4 +1,5 @@ //! Lookup argument for the Zinc+ PIOP. +pub mod logup; pub mod structs; - +pub mod utils; pub use structs::*; diff --git a/piop/src/lookup/structs.rs b/piop/src/lookup/structs.rs index 87cea7c5..16446b3b 100644 --- a/piop/src/lookup/structs.rs +++ b/piop/src/lookup/structs.rs @@ -1,12 +1,13 @@ //! Data structures for the lookup protocol. //! -//! Proof types, prover/verifier intermediates, instance types, and error +//! Proof types, prover/verifier intermediates and error //! definitions live here. Specification types (`LookupTableType`, //! `LookupColumnSpec`) live in `zinc-uair` and are re-exported from the //! parent module. use std::collections::BTreeMap; use thiserror::Error; +use zinc_poly::utils::ArithErrors; use zinc_uair::LookupTableType; // --------------------------------------------------------------------------- @@ -78,7 +79,7 @@ pub struct LookupGroupMeta { // Complete lookup proof // --------------------------------------------------------------------------- -/// Top-level proof: one [`BatchedDecompLogupProof`] per lookup group +/// Top-level proof: one [`LogupProof`] per lookup group /// (groups formed by batching columns with the same [`LookupTableType`]). /// Carries [`LookupGroupMeta`] per group so the verifier needs no /// external specs. @@ -90,6 +91,73 @@ pub struct BatchedLookupProof { pub group_meta: Vec, } +// --------------------------------------------------------------------------- +// Core LogUp types +// --------------------------------------------------------------------------- + +/// Verifier subclaim from the LogUp protocol. +#[derive(Clone, Debug)] +pub struct LogupVerifierSubClaim { + /// Evaluation point from the sumcheck subclaim. + pub evaluation_point: Vec, + /// Expected evaluation at the subclaim point. + pub expected_evaluation: F, +} + +/// Ancillary prover data for LogUp sumcheck group construction. +/// +/// Borrows the pre-computed auxiliary vectors needed by +/// [`LogupProtocol::build_sumcheck_groups`]. +#[derive(Clone, Debug)] +pub struct LogupProverAncillary<'a, F> { + /// Multiplicity vector. + pub multiplicities: &'a [F], + /// Inverse witness vector `u = 1/(β − w)`. + pub inverse_witness: &'a [F], + /// Inverse table vector `v = 1/(β − T)`. + pub inverse_table: &'a [F], +} + +/// Pre-sumcheck verifier data for the core LogUp protocol. +/// +/// Produced by `build_verifier_pre_sumcheck`, holds the transcript +/// challenges needed for `finalize_verifier`. +#[derive(Clone, Debug)] +pub struct LogupVerifierPreSumcheckData { + /// Random evaluation point `r` for `eq(y, r)`. + pub r: Vec, + /// The β challenge for LogUp. + pub beta: F, +} + +/// Scalar MLE evaluations of lookup auxiliary columns at the subclaim +/// point x*, obtained from PCS openings via MultipointEval. +/// +/// Used by `finalize_verifier` to check the LogUp identities +#[derive(Clone, Debug)] +pub struct LookupAuxEvals { + pub u_eval: F, + pub m_eval: F, +} + +/// Bundle of per-column evaluation data passed to +/// [`LogupProtocol::finalize_verifier`]. +/// +/// Groups the subclaim outputs and PCS-opened evaluations that vary +/// per lookup column. +#[derive(Clone, Debug)] +pub struct LogupFinalizerInput<'a, F> { + /// The multi-degree sumcheck subclaim point (x*). + pub subclaim_point: &'a [F], + /// Expected evaluations from the sumcheck subclaim for this + /// column's two groups. + pub expected_evaluations: &'a [F], + /// Evaluation of the witness column polynomial at x*. + pub w_eval: &'a F, + /// Auxiliary evaluations (u, m) at x*. + pub aux_evals: &'a LookupAuxEvals, +} + // --------------------------------------------------------------------------- // Grouping utility // --------------------------------------------------------------------------- @@ -129,7 +197,6 @@ pub fn group_lookup_specs(specs: &[zinc_uair::LookupColumnSpec]) -> Vec, usize>; + +/// Generate the projected `BitPoly(w)` table over F_q. +/// +/// For `BitPoly(w)`, the table has `2^w` entries. Each entry corresponds +/// to a binary polynomial `b_{w-1} X^{w-1} + … + b_0` evaluated at the +/// projecting element `a`: +/// +/// ```text +/// T[n] = Σ_{k=0}^{w-1} bit_k(n) · a^k +/// ``` +/// +/// Uses the recursive structure `T[n + 2^k] = T[n] + a^k` for +/// efficient computation. +#[allow(clippy::arithmetic_side_effects)] +pub fn generate_bitpoly_table( + width: usize, + projecting_element: &F, + field_cfg: &F::Config, +) -> Vec { + let size = 1usize << width; + let mut table = vec![F::zero_with_cfg(field_cfg); size]; + + // Precompute powers of `a`: a^0, a^1, ..., a^{w-1}. + let one = F::one_with_cfg(field_cfg); + let mut powers_of_a = Vec::with_capacity(width); + let mut current = one; + for _ in 0..width { + powers_of_a.push(current.clone()); + current *= projecting_element; + } + + // Build the table recursively: T[n + 2^k] = T[n] + a^k. + // Start with T[0] = 0. + // For each bit position k, the entries with bit k set are + // obtained by adding a^k to the entries without bit k set. + for (k, power) in powers_of_a.iter().enumerate() { + let step = 1usize << k; + for n in 0..step { + table[n + step] = table[n].clone() + power; + } + } + + table +} + +/// Generate the projected `Word(w)` table over F_q. +/// +/// For `Word(w)`, the table has `2^w` entries: +/// `{0, 1, …, 2^w − 1} mod q`. +#[allow(clippy::arithmetic_side_effects)] +pub fn generate_word_table( + width: usize, + field_cfg: &F::Config, +) -> Vec +where + F::Config: Sync, +{ + let size = 1usize << width; + cfg_into_iter!(0..size) + .map(|i| F::from_with_cfg(i as u64, field_cfg)) + .collect() +} + +/// Compute batch multiplicative inverses using a two-phase Montgomery trick. +/// +/// Given `values = [v_0, v_1, …, v_{n-1}]`, returns +/// `[v_0^{-1}, v_1^{-1}, …, v_{n-1}^{-1}]`. +/// +/// Performs exactly one field inversion and `O(n)` multiplications, +/// even when parallelised across multiple chunks. The three phases: +/// +/// 1. **Forward (parallel):** each chunk computes local prefix products. +/// 2. **Reduce (sequential, O(K) muls):** Montgomery trick on the K chunk +/// products → one inversion → per-chunk starting accumulators. +/// 3. **Backward (parallel):** each chunk sweeps to produce inverses. +/// +/// # Panics +/// +/// Panics if any element is zero (non-invertible). +#[allow(clippy::arithmetic_side_effects)] +pub fn batch_inverse(values: &[F]) -> Vec +where + F::Config: Sync, +{ + if values.is_empty() { + return Vec::new(); + } + + let n = values.len(); + let cfg = values[0].cfg(); + let one = F::one_with_cfg(cfg); + let chunk_size = cfg_chunk_size!(n, 4096); + + // Phase 1: per-chunk prefix products (parallel). + let prefixes: Vec> = cfg_chunks!(values, chunk_size) + .map(|chunk| { + let mut prefix = Vec::with_capacity(chunk.len()); + prefix.push(chunk[0].clone()); + for i in 1..chunk.len() { + let prev = prefix[i - 1].clone(); + prefix.push(prev * &chunk[i]); + } + prefix + }) + .collect(); + + // Phase 2: single global inversion over chunk products (sequential). + let chunk_inv_accs = reduce_chunk_products(&prefixes, &one); + + // Phase 3: per-chunk backward sweep (parallel). + let mut result = vec![F::zero_with_cfg(cfg); n]; + cfg_chunks_mut!(result, chunk_size) + .zip(cfg_chunks!(values, chunk_size)) + .enumerate() + .for_each(|(k, (res_chunk, val_chunk))| { + let cn = val_chunk.len(); + let prefix = &prefixes[k]; + let mut inv_acc = chunk_inv_accs[k].clone(); + for i in (1..cn).rev() { + res_chunk[i] = inv_acc.clone() * &prefix[i - 1]; + inv_acc *= &val_chunk[i]; + } + res_chunk[0] = inv_acc; + }); + + result +} + +/// Montgomery trick on K chunk products: one inversion, returns the +/// starting `inv_acc` for each chunk's backward sweep. +#[allow(clippy::arithmetic_side_effects)] +fn reduce_chunk_products(prefixes: &[Vec], one: &F) -> Vec { + let k = prefixes.len(); + let last = |i: usize| -> &F { + prefixes[i] + .last() + .expect("chunk prefix is non-empty by construction") + }; + + if k == 1 { + return vec![one.clone() / last(0)]; + } + + let mut global_prefix = Vec::with_capacity(k); + global_prefix.push(last(0).clone()); + for i in 1..k { + let prev = global_prefix[i - 1].clone(); + global_prefix.push(prev * last(i)); + } + + let mut inv_acc = one.clone() / &global_prefix[k - 1]; + let mut accs = vec![F::zero_with_cfg(one.cfg()); k]; + for i in (1..k).rev() { + accs[i] = inv_acc.clone() * &global_prefix[i - 1]; + inv_acc *= last(i); + } + accs[0] = inv_acc; + accs +} + +/// Build a reusable table index mapping each table entry's byte +/// representation to its position. +/// +/// Use with [`compute_multiplicities_with_index`] to avoid rebuilding +/// the hash map when computing multiplicities for multiple witnesses +/// against the same table. +pub fn build_table_index(table: &[F]) -> TableIndex { + let elem_size = std::mem::size_of::(); + table + .iter() + .enumerate() + .map(|(i, t)| { + let bytes = unsafe { + std::slice::from_raw_parts(t.inner() as *const F::Inner as *const u8, elem_size) + }; + (bytes.to_vec(), i) + }) + .collect() +} + +/// Compute multiplicity vector using a prebuilt [`TableIndex`]. +/// +/// Returns `m` such that `m[j]` is the number of occurrences of `T[j]` +/// in `witness`. +/// +/// # Errors +/// +/// Returns `None` if any witness entry is not found in the table. +#[allow(clippy::arithmetic_side_effects)] +pub fn compute_multiplicities_with_index( + witness: &[F], + table_index: &TableIndex, + table_len: usize, + field_cfg: &F::Config, +) -> Option> +where + F::Config: Sync, +{ + if witness.is_empty() { + return Some( + (0..table_len) + .map(|_| F::from_with_cfg(0u64, field_cfg)) + .collect(), + ); + } + + let elem_size = std::mem::size_of::(); + let chunk_size = cfg_chunk_size!(witness.len(), 4096); + + let local_counts: Vec> = cfg_chunks!(witness, chunk_size) + .map(|chunk| { + let mut counts = vec![0u64; table_len]; + for w in chunk { + let bytes = unsafe { + std::slice::from_raw_parts(w.inner() as *const F::Inner as *const u8, elem_size) + }; + match table_index.get(bytes) { + Some(&idx) => counts[idx] += 1, + None => return vec![], + } + } + counts + }) + .collect(); + + let mut counts = vec![0u64; table_len]; + for local in &local_counts { + if local.is_empty() { + return None; + } + for (i, &c) in local.iter().enumerate() { + counts[i] += c; + } + } + + Some( + counts + .into_iter() + .map(|c| F::from_with_cfg(c, field_cfg)) + .collect(), + ) +} + +/// Compute multiplicity vector: for each table entry, count how many +/// times it appears in the witness. +/// +/// Returns `m` such that `m[j]` is the number of occurrences of `T[j]` +/// in `witness`. +/// +/// # Errors +/// +/// Returns `None` if any witness entry is not found in the table. +#[allow(clippy::arithmetic_side_effects)] +pub fn compute_multiplicities( + witness: &[F], + table: &[F], + field_cfg: &F::Config, +) -> Option> +where + F::Config: Sync, +{ + let index = build_table_index(table); + compute_multiplicities_with_index(witness, &index, table.len(), field_cfg) +} + +/// Compute `[1/(β − v_0), 1/(β − v_1), …, 1/(β − v_{n-1})]` with +/// fused subtraction — avoids materializing a separate `β − v_i` vector. +/// +/// Uses the same two-phase Montgomery trick as [`batch_inverse`]: +/// exactly one field inversion, `O(n)` multiplications, parallel +/// forward/backward sweeps. +/// +/// # Panics +/// +/// Panics if any `β − v_i` is zero. +#[allow(clippy::arithmetic_side_effects)] +pub fn batch_inverse_shifted(beta: &F, values: &[F]) -> Vec +where + F::Config: Sync, +{ + if values.is_empty() { + return Vec::new(); + } + + let n = values.len(); + let cfg = beta.cfg(); + let one = F::one_with_cfg(cfg); + let chunk_size = cfg_chunk_size!(n, 4096); + + // Phase 1: per-chunk prefix products of (β − v_i) (parallel). + let prefixes: Vec> = cfg_chunks!(values, chunk_size) + .map(|chunk| { + let mut prefix = Vec::with_capacity(chunk.len()); + prefix.push(beta.clone() - &chunk[0]); + for i in 1..chunk.len() { + let diff = beta.clone() - &chunk[i]; + let prev = prefix[i - 1].clone(); + prefix.push(prev * &diff); + } + prefix + }) + .collect(); + + // Phase 2: single global inversion (sequential). + let chunk_inv_accs = reduce_chunk_products(&prefixes, &one); + + // Phase 3: per-chunk backward sweep (parallel). + let mut result = vec![F::zero_with_cfg(cfg); n]; + cfg_chunks_mut!(result, chunk_size) + .zip(cfg_chunks!(values, chunk_size)) + .enumerate() + .for_each(|(k, (res_chunk, val_chunk))| { + let cn = val_chunk.len(); + let prefix = &prefixes[k]; + let mut inv_acc = chunk_inv_accs[k].clone(); + for i in (1..cn).rev() { + res_chunk[i] = inv_acc.clone() * &prefix[i - 1]; + let diff = beta.clone() - &val_chunk[i]; + inv_acc *= &diff; + } + res_chunk[0] = inv_acc; + }); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crypto_bigint::{U128, const_monty_params}; + use crypto_primitives::crypto_bigint_const_monty::ConstMontyField; + + const N: usize = 2; + const_monty_params!(TestParams, U128, "00000000b933426489189cb5b47d567f"); + type F = ConstMontyField; + + #[test] + fn bitpoly_table_size_and_values() { + let a = F::from(3u32); + let table = generate_bitpoly_table(4, &a, &()); + + assert_eq!(table.len(), 16); // 2^4 + + // T[0] = 0 + assert_eq!(table[0], F::from(0u32)); + // T[1] = a^0 = 1 + assert_eq!(table[1], F::from(1u32)); + // T[2] = a^1 = 3 + assert_eq!(table[2], F::from(3u32)); + // T[3] = a^0 + a^1 = 1 + 3 = 4 + assert_eq!(table[3], F::from(4u32)); + // T[5] = a^0 + a^2 = 1 + 9 = 10 + assert_eq!(table[5], F::from(10u32)); + } + + #[test] + fn word_table_values() { + let table = generate_word_table::(4, &()); + + assert_eq!(table.len(), 16); + for i in 0..16u32 { + assert_eq!(table[i as usize], F::from(i)); + } + } + + #[test] + fn batch_inverse_correctness() { + let values: Vec = (1..=5u32).map(F::from).collect(); + let inverses = batch_inverse(&values); + + let one = F::from(1u32); + for (v, inv) in values.iter().zip(inverses.iter()) { + assert_eq!(v * inv, one); + } + } + + #[test] + fn multiplicity_computation() { + let table: Vec = (0..4u32).map(F::from).collect(); + let witness: Vec = vec![0u32, 1, 1, 3, 0].into_iter().map(F::from).collect(); + let m = compute_multiplicities(&witness, &table, &()).unwrap(); + assert_eq!(m[0], F::from(2u32)); + assert_eq!(m[1], F::from(2u32)); + assert_eq!(m[2], F::from(0u32)); + assert_eq!(m[3], F::from(1u32)); + } + + #[test] + fn multiplicity_rejects_invalid_witness() { + let table: Vec = (0..4u32).map(F::from).collect(); + let witness: Vec = vec![0u32, 5].into_iter().map(F::from).collect(); + assert!(compute_multiplicities(&witness, &table, &()).is_none()); + } + + #[test] + fn multiplicity_rejects_single_invalid_entry() { + let table: Vec = (0..4u32).map(F::from).collect(); + let witness: Vec = vec![0u32, 1, 2, 3, 99].into_iter().map(F::from).collect(); + assert!(compute_multiplicities(&witness, &table, &()).is_none()); + } + + #[test] + fn multiplicity_empty_witness() { + let table: Vec = (0..4u32).map(F::from).collect(); + let witness: Vec = vec![]; + let m = compute_multiplicities(&witness, &table, &()).unwrap(); + assert!(m.iter().all(|x| *x == F::from(0u32))); + } + + #[test] + fn multiplicity_all_same_entry() { + let table: Vec = (0..4u32).map(F::from).collect(); + let witness: Vec = vec![2u32; 8].into_iter().map(F::from).collect(); + let m = compute_multiplicities(&witness, &table, &()).unwrap(); + assert_eq!(m[0], F::from(0u32)); + assert_eq!(m[1], F::from(0u32)); + assert_eq!(m[2], F::from(8u32)); + assert_eq!(m[3], F::from(0u32)); + } + + #[test] + fn batch_inverse_shifted_correctness() { + let beta = F::from(100u32); + let values: Vec = (0..5u32).map(F::from).collect(); + let result = batch_inverse_shifted(&beta, &values); + + let one = F::from(1u32); + for (v, inv) in values.iter().zip(result.iter()) { + assert_eq!((beta - v) * inv, one); + } + } + + #[test] + fn batch_inverse_shifted_empty() { + let beta = F::from(100u32); + let result = batch_inverse_shifted(&beta, &[]); + assert!(result.is_empty()); + } + + #[test] + fn batch_inverse_empty() { + let result = batch_inverse::(&[]); + assert!(result.is_empty()); + } + + #[test] + fn batch_inverse_large() { + let values: Vec = (1..=8192u32).map(F::from).collect(); + let inverses = batch_inverse(&values); + let one = F::from(1u32); + for (v, inv) in values.iter().zip(inverses.iter()) { + assert_eq!(v * inv, one); + } + } + + #[test] + fn batch_inverse_shifted_large() { + let beta = F::from(100_000u32); + let values: Vec = (0..8192u32).map(F::from).collect(); + let result = batch_inverse_shifted(&beta, &values); + let one = F::from(1u32); + for (v, inv) in values.iter().zip(result.iter()) { + assert_eq!((beta - v) * inv, one); + } + } +} diff --git a/test-uair/src/lib.rs b/test-uair/src/lib.rs index dcdb0ca7..21f67945 100644 --- a/test-uair/src/lib.rs +++ b/test-uair/src/lib.rs @@ -19,8 +19,8 @@ use zinc_poly::{ }, }; use zinc_uair::{ - ConstraintBuilder, PublicColumnLayout, ShiftSpec, TotalColumnLayout, TraceRow, Uair, - UairSignature, UairTrace, + ConstraintBuilder, LookupColumnSpec, LookupTableType, PublicColumnLayout, ShiftSpec, + TotalColumnLayout, TraceRow, Uair, UairSignature, UairTrace, ideal::{ImpossibleIdeal, degree_one::DegreeOneIdeal}, }; use zinc_utils::from_ref::FromRef; @@ -589,6 +589,310 @@ where } } +/// Minimal UAIR with a lookup constraint: one int column looked up +/// against `Word(4)` (values in {0, ..., 15}). Two int columns total, +/// with a trivial constraint `a - b = 0` to keep the CPR non-degenerate. +pub struct SimpleLookupUair(PhantomData); + +impl Uair for SimpleLookupUair +where + R: ConstSemiring + From + 'static, +{ + type Ideal = ImpossibleIdeal; + type Scalar = DensePolynomial; + + fn signature() -> UairSignature { + let total = TotalColumnLayout::new(0, 0, 2); + UairSignature::new( + total, + PublicColumnLayout::default(), + vec![], + vec![LookupColumnSpec { + column_index: 0, + table_type: LookupTableType::Word { + width: 4, + chunk_width: None, + }, + }], + ) + } + + fn constrain_general( + b: &mut B, + up: TraceRow, + _down: TraceRow, + _from_ref: FromR, + _mbs: MulByScalar, + _ideal_from_ref: IFromR, + ) where + B: ConstraintBuilder, + IFromR: Fn(&Self::Ideal) -> B::Ideal, + { + b.assert_zero(up.int[0].clone() - &up.int[1]); + } +} + +impl GenerateRandomTrace<32> for SimpleLookupUair +where + R: ConstSemiring + From + 'static, +{ + type PolyCoeff = R; + type Int = R; + + fn generate_random_trace( + num_vars: usize, + rng: &mut Rng, + ) -> UairTrace<'static, R, R, 32> { + let n = 1 << num_vars; + let vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 16)).collect(); + let a: DenseMultilinearExtension = vals.clone().into_iter().collect(); + let b: DenseMultilinearExtension = vals.into_iter().collect(); + + UairTrace { + int: vec![a, b].into(), + ..Default::default() + } + } +} + +/// UAIR with two int columns both looked up against the same `Word(4)` table. +/// Tests L>1 (multi-column) lookup within a single group. +/// 3 int columns total: a, b looked up; c = a + b (trivial constraint). +pub struct MultiColLookupUair(PhantomData); + +impl Uair for MultiColLookupUair +where + R: ConstSemiring + From + 'static, +{ + type Ideal = ImpossibleIdeal; + type Scalar = DensePolynomial; + + fn signature() -> UairSignature { + let total = TotalColumnLayout::new(0, 0, 3); + UairSignature::new( + total, + PublicColumnLayout::default(), + vec![], + vec![ + LookupColumnSpec { + column_index: 0, + table_type: LookupTableType::Word { + width: 4, + chunk_width: None, + }, + }, + LookupColumnSpec { + column_index: 1, + table_type: LookupTableType::Word { + width: 4, + chunk_width: None, + }, + }, + ], + ) + } + + fn constrain_general( + b: &mut B, + up: TraceRow, + _down: TraceRow, + _from_ref: FromR, + _mbs: MulByScalar, + _ideal_from_ref: IFromR, + ) where + B: ConstraintBuilder, + IFromR: Fn(&Self::Ideal) -> B::Ideal, + { + b.assert_zero(up.int[0].clone() + &up.int[1] - &up.int[2]); + } +} + +impl GenerateRandomTrace<32> for MultiColLookupUair +where + R: ConstSemiring + From + 'static, +{ + type PolyCoeff = R; + type Int = R; + + fn generate_random_trace( + num_vars: usize, + rng: &mut Rng, + ) -> UairTrace<'static, R, R, 32> { + let n = 1 << num_vars; + let a_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 16)).collect(); + let b_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 16)).collect(); + let c_vals: Vec = a_vals + .iter() + .zip(b_vals.iter()) + .map(|(a, b)| a.clone() + b) + .collect(); + let a: DenseMultilinearExtension = a_vals.into_iter().collect(); + let b: DenseMultilinearExtension = b_vals.into_iter().collect(); + let c: DenseMultilinearExtension = c_vals.into_iter().collect(); + + UairTrace { + int: vec![a, b, c].into(), + ..Default::default() + } + } +} + +/// UAIR with two columns looking up different table types: +/// column 0 → Word(4), column 1 → Word(8). Tests multiple lookup groups. +/// 3 int columns: a in {0..15}, b in {0..255}, c = a + b. +pub struct MultiGroupLookupUair(PhantomData); + +impl Uair for MultiGroupLookupUair +where + R: ConstSemiring + From + 'static, +{ + type Ideal = ImpossibleIdeal; + type Scalar = DensePolynomial; + + fn signature() -> UairSignature { + let total = TotalColumnLayout::new(0, 0, 3); + UairSignature::new( + total, + PublicColumnLayout::default(), + vec![], + vec![ + LookupColumnSpec { + column_index: 0, + table_type: LookupTableType::Word { + width: 4, + chunk_width: None, + }, + }, + LookupColumnSpec { + column_index: 1, + table_type: LookupTableType::Word { + width: 8, + chunk_width: None, + }, + }, + ], + ) + } + + fn constrain_general( + b: &mut B, + up: TraceRow, + _down: TraceRow, + _from_ref: FromR, + _mbs: MulByScalar, + _ideal_from_ref: IFromR, + ) where + B: ConstraintBuilder, + IFromR: Fn(&Self::Ideal) -> B::Ideal, + { + b.assert_zero(up.int[0].clone() + &up.int[1] - &up.int[2]); + } +} + +impl GenerateRandomTrace<32> for MultiGroupLookupUair +where + R: ConstSemiring + From + 'static, +{ + type PolyCoeff = R; + type Int = R; + + fn generate_random_trace( + num_vars: usize, + rng: &mut Rng, + ) -> UairTrace<'static, R, R, 32> { + let n = 1 << num_vars; + let a_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 16)).collect(); + let b_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 256)).collect(); + let c_vals: Vec = a_vals + .iter() + .zip(b_vals.iter()) + .map(|(a, b)| a.clone() + b) + .collect(); + let a: DenseMultilinearExtension = a_vals.into_iter().collect(); + let b: DenseMultilinearExtension = b_vals.into_iter().collect(); + let c: DenseMultilinearExtension = c_vals.into_iter().collect(); + + UairTrace { + int: vec![a, b, c].into(), + ..Default::default() + } + } +} + +/// UAIR with a BitPoly lookup: one binary_poly column looked up against +/// `BitPoly(8)`. Tests the BitPoly table type. +/// 1 binary_poly column + 1 int column, constraint: binary_poly - int ∈ . +pub struct BitPolyLookupUair(PhantomData); + +impl Uair for BitPolyLookupUair +where + R: ConstSemiring + From + 'static, +{ + type Ideal = DegreeOneIdeal; + type Scalar = DensePolynomial; + + fn signature() -> UairSignature { + let total = TotalColumnLayout::new(1, 0, 1); + UairSignature::new( + total, + PublicColumnLayout::default(), + vec![], + vec![LookupColumnSpec { + column_index: 0, + table_type: LookupTableType::BitPoly { + width: 8, + chunk_width: None, + }, + }], + ) + } + + fn constrain_general( + b: &mut B, + up: TraceRow, + _down: TraceRow, + _from_ref: FromR, + _mbs: MulByScalar, + ideal_from_ref: IFromR, + ) where + B: ConstraintBuilder, + FromR: Fn(&Self::Scalar) -> B::Expr, + MulByScalar: Fn(&B::Expr, &Self::Scalar) -> Option, + IFromR: Fn(&Self::Ideal) -> B::Ideal, + { + b.assert_in_ideal( + up.binary_poly[0].clone() - &up.int[0], + &ideal_from_ref(&DegreeOneIdeal::new(R::from(2))), + ); + } +} + +impl GenerateRandomTrace<32> for BitPolyLookupUair +where + R: ConstSemiring + From + 'static, +{ + type PolyCoeff = R; + type Int = R; + + fn generate_random_trace( + num_vars: usize, + rng: &mut Rng, + ) -> UairTrace<'static, R, R, 32> { + let n = 1 << num_vars; + let values: Vec = (0..n).map(|_| rng.next_u32() % 256).collect(); + + let binary_poly_col: DenseMultilinearExtension> = + values.iter().map(|&v| BinaryPoly::from(v)).collect(); + let int_col: DenseMultilinearExtension = values.into_iter().map(R::from).collect(); + + UairTrace { + binary_poly: vec![binary_poly_col].into(), + int: vec![int_col].into(), + ..Default::default() + } + } +} + #[cfg(test)] mod tests { use crypto_primitives::crypto_bigint_int::Int; @@ -619,6 +923,10 @@ mod tests { assert_uair_shape::>(&[1]); assert_uair_shape::>(&[1; 17]); assert_uair_shape::>>(&[1, 1]); + assert_uair_shape::>(&[1]); + assert_uair_shape::>(&[1]); + assert_uair_shape::>(&[1]); + assert_uair_shape::>(&[1]); } #[test] diff --git a/utils/src/parallel.rs b/utils/src/parallel.rs index fbc871f6..14f49cdb 100644 --- a/utils/src/parallel.rs +++ b/utils/src/parallel.rs @@ -134,6 +134,33 @@ macro_rules! cfg_extend { }}; } +/// Compute chunk size for parallel/sequential dispatch. +/// +/// When the "parallel" feature is enabled and `$len >= $threshold`, +/// returns `$len / num_threads`. Otherwise returns `$len` (one chunk = +/// sequential). +/// +/// ```ignore +/// let chunk_size = cfg_chunk_size!(n, 4096); +/// cfg_chunks_mut!(result, chunk_size).zip(cfg_chunks!(values, chunk_size)).for_each(|..| ..); +/// ``` +#[macro_export] +macro_rules! cfg_chunk_size { + ($len:expr, $threshold:expr) => {{ + #[cfg(feature = "parallel")] + let _cs = if $len >= $threshold { + $len.div_ceil(rayon::current_num_threads()) + } else { + $len + }; + + #[cfg(not(feature = "parallel"))] + let _cs = $len; + + _cs + }}; +} + /// Conditionally fork expressions into parallel tasks using `rayon::join`. /// /// When the "parallel" feature is enabled, uses right-leaning nested From e6b6a2730cdd69d1a18e9bbd554d7c031498e2e6 Mon Sep 17 00:00:00 2001 From: amit0365 Date: Sat, 11 Apr 2026 16:17:24 +0530 Subject: [PATCH 2/6] Batch multiple lookup columns for sumcheck --- piop/src/lookup/logup.rs | 479 ++++++++++++++++++++++--------------- piop/src/lookup/structs.rs | 109 +-------- 2 files changed, 297 insertions(+), 291 deletions(-) diff --git a/piop/src/lookup/logup.rs b/piop/src/lookup/logup.rs index 25082dd0..45ea21d0 100644 --- a/piop/src/lookup/logup.rs +++ b/piop/src/lookup/logup.rs @@ -11,23 +11,24 @@ //! `u = 1/(β − w)` via Zip+ PCS. The table inverse `v = 1/(β − T)` is //! NOT committed — the verifier computes it from the public table. //! -//! ## Sumcheck groups +//! ## γ-Batched sumcheck groups //! -//! Two groups are produced per witness column: +//! For L witness columns sharing the same table, a random challenge γ +//! collapses 2·L individual groups into exactly **2 groups**: //! -//! - **Group 0** (zerocheck, degree 3): `(d·u − 1)·eq(r, y)` where `d = β − w`. -//! Enforces `u = 1/(β − w)` pointwise. -//! - **Group 1** (sumcheck, degree 2): `u(y) − m(y)·v(y)`. Enforces `Σ u = Σ -//! m·v` (the LogUp sum identity). this is sumcheck with claimed sum = 0. +//! - **Group 0** (zerocheck, degree 3): `(Σ_l γ^l · (d_l·u_l − 1)) · eq(r, y)` +//! where `d_l = β − w_l`. Enforces `u_l = 1/(β − w_l)` pointwise for all L +//! columns. +//! - **Group 1** (sumcheck, degree 2, claimed_sum = 0): `Σ_l γ^l · (u_l − +//! m_l·v)`. Enforces `Σ u_l = Σ m_l·v` for all L columns simultaneously. //! -//! The verifier checks `claimed_sum == 0` for both groups, then -//! verifies the subclaim evaluations at the shared point `x*`. +//! When L=1 this degenerates to the unbatched case (γ^0 = 1). //! //! ## Committed model //! //! In the full pipeline (`protocol/src/prover.rs`), m and u are committed //! via PCS and opened at `r_0` through the multipoint eval sumcheck. -//! The functions here are build sumcheck groups and check subclaim evaluations. +//! The functions here build sumcheck groups and check subclaim evaluations. use crypto_primitives::FromPrimitiveWithConfig; use num_traits::Zero; @@ -36,11 +37,11 @@ use rayon::prelude::*; use std::marker::PhantomData; use zinc_poly::{ mle::DenseMultilinearExtension, - utils::{build_eq_x_r_inner, build_eq_x_r_vec}, + utils::{build_eq_x_r_inner, build_eq_x_r_vec, eq_eval}, }; use zinc_transcript::traits::{ConstTranscribable, Transcript}; use zinc_uair::LookupTableType; -use zinc_utils::{cfg_iter, inner_transparent_field::InnerTransparentField}; +use zinc_utils::{cfg_iter, inner_transparent_field::InnerTransparentField, log2}; use crate::{ lookup::{ @@ -50,7 +51,7 @@ use crate::{ }; use super::{ - structs::{LogupVerifierPreSumcheckData, LookupAuxEvals, LookupError}, + structs::{LogupVerifierPreSumcheckData, LookupError}, utils::{generate_bitpoly_table, generate_word_table}, }; @@ -58,22 +59,25 @@ use super::{ pub struct LogupProtocol(PhantomData); impl LogupProtocol { - /// Build two LogUp sumcheck groups from pre-computed vectors. + /// Build two γ-batched LogUp sumcheck groups from L columns. /// - /// Returns `[group_0, group_1]` where: - /// - Group 0 (degree 3): `(d·u − 1)·eq(r, y)` — zerocheck for inverse - /// correctness. - /// - Group 1 (degree 2): `u − m·v` — sumcheck for the LogUp sum identity - /// with claimed_sum = 0. + /// Takes L witnesses and their auxiliary vectors. Returns exactly + /// 2 groups regardless of L: `[group_0, group_1]` /// + /// - Group 0 (degree 3, zerocheck): `(Σ_l γ^l · (d_l·u_l − 1)) · eq(r, y)` + /// - Group 1 (degree 2, sumcheck, claimed_sum = 0): `Σ_l γ^l · (u_l − + /// m_l·v)` + /// + /// When L=1, γ^0=1 so this degenerates to the unbatched case. /// The caller is responsible for PCS commitment, transcript operations, /// and squeezing β and r before calling this function. #[allow(clippy::arithmetic_side_effects)] pub fn build_sumcheck_groups( - witness: &[F], + witnesses: &[&[F]], table: &[F], - aux: &LogupProverAncillary<'_, F>, + auxs: &[LogupProverAncillary<'_, F>], beta: &F, + gamma: &F, r: &[F], field_cfg: &F::Config, ) -> Result>, LookupError> @@ -81,11 +85,22 @@ impl LogupProt F::Inner: Zero + Default + Send + Sync, F: 'static, { + let num_cols = witnesses.len(); + assert_eq!( + num_cols, + auxs.len(), + "witnesses and auxs must have same length" + ); + let zero = F::zero_with_cfg(field_cfg); let one = F::one_with_cfg(field_cfg); - let w_num_vars = zinc_utils::log2(witness.len().next_power_of_two()) as usize; - let t_num_vars = zinc_utils::log2(table.len().next_power_of_two()) as usize; + let w_num_vars = if num_cols > 0 { + log2(witnesses[0].len().next_power_of_two()) as usize + } else { + 0 + }; + let t_num_vars = log2(table.len().next_power_of_two()) as usize; let num_vars = w_num_vars.max(t_num_vars); let eq_r = build_eq_x_r_inner(r, field_cfg)?; @@ -102,32 +117,72 @@ impl LogupProt let beta_inner = beta.inner(); + let gamma_pows = zinc_utils::powers(gamma.clone(), one.clone(), num_cols); + + // Group 0 MLEs: [eq, d_0, u_0, d_1, u_1, ...] where // d = (β − w): denominator for witness inverse check - let d_mle = DenseMultilinearExtension::from_evaluations_vec( - num_vars, - cfg_iter!(witness) - .map(|w_i| F::sub_inner(beta_inner, w_i.inner(), field_cfg)) - .collect(), - inner_zero.clone(), - ); + let mut group0_mles = Vec::with_capacity(1 + 2 * num_cols); + group0_mles.push(eq_r); + for (witness, aux) in witnesses.iter().zip(auxs.iter()) { + let d_mle = DenseMultilinearExtension::from_evaluations_vec( + num_vars, + cfg_iter!(*witness) + .map(|w_i| F::sub_inner(beta_inner, w_i.inner(), field_cfg)) + .collect(), + inner_zero.clone(), + ); + group0_mles.push(d_mle); + group0_mles.push(mk_mle(aux.inverse_witness)); + } - let u_mle = mk_mle(aux.inverse_witness); - let v_mle = mk_mle(aux.inverse_table); - let m_mle = mk_mle(aux.multiplicities); + // Group 1 MLEs: [u_0, m_0, u_1, m_1, ..., v] + let mut group1_mles = Vec::with_capacity(2 * num_cols + 1); + for aux in auxs.iter() { + group1_mles.push(mk_mle(aux.inverse_witness)); + group1_mles.push(mk_mle(aux.multiplicities)); + } + group1_mles.push(mk_mle(auxs[0].inverse_table)); - // group0: (d·u − 1) · eq where d = (β − w) + // group0: (d_l·u_l − 1) · eq where d_l = (β − w_l) let one0 = one.clone(); + let zero0 = zero.clone(); + let gamma_powers_copy0 = gamma_pows.clone(); + let l0 = num_cols; let group_0 = MultiDegreeSumcheckGroup::new( 3, - vec![eq_r.clone(), d_mle, u_mle.clone()], - Box::new(move |v: &[F]| (v[1].clone() * &v[2] - &one0) * &v[0]), + group0_mles, + Box::new(move |v: &[F]| { + // v[0] = eq, v[1+2l] = d_l, v[2+2l] = u_l + let mut sum = zero0.clone(); + for l in 0..l0 { + let d = &v[1 + 2 * l]; + let u = &v[2 + 2 * l]; + let term = d.clone() * u - &one0; + sum += &(gamma_powers_copy0[l].clone() * &term); + } + sum * &v[0] + }), ); // group1: (u − m·v) + let zero1 = zero; + let gp1 = gamma_pows; + let l1 = num_cols; let group_1 = MultiDegreeSumcheckGroup::new( 2, - vec![u_mle, m_mle.clone(), v_mle.clone()], - Box::new(move |v: &[F]| v[0].clone() - &(v[1].clone() * &v[2])), + group1_mles, + Box::new(move |v: &[F]| { + // v[2l] = u_l, v[2l+1] = m_l, v[2*L] = v (shared) + let v_shared = &v[2 * l1]; + let mut sum = zero1.clone(); + for l in 0..l1 { + let u = &v[2 * l]; + let m = &v[2 * l + 1]; + let term = u.clone() - &(m.clone() * v_shared); + sum += &(gp1[l].clone() * &term); + } + sum + }), ); Ok(vec![group_0, group_1]) @@ -144,9 +199,6 @@ impl LogupProt projecting_element_f: &F, field_cfg: &F::Config, ) -> (Vec>, Vec) { - use super::utils::{generate_bitpoly_table, generate_word_table}; - use zinc_uair::LookupTableType; - let witnesses: Vec> = group_info .column_indices .iter() @@ -188,23 +240,24 @@ impl LogupProt transcript.absorb_slice(comm_m_root); let beta: F = transcript.get_field_challenge(field_cfg); - // Absorb inverse witness root, squeeze r + // Absorb inverse witness root, squeeze r then γ transcript.absorb_slice(comm_u_root); let r: Vec = transcript.get_field_challenges(num_vars, field_cfg); + let gamma: F = transcript.get_field_challenge(field_cfg); - LogupVerifierPreSumcheckData { r, beta } + LogupVerifierPreSumcheckData { r, beta, gamma } } - /// Post-sumcheck finalization for the LogUp verifier. + /// Post-sumcheck finalization for the γ-batched LogUp verifier. /// - /// Given the subclaim point `x*` and the two expected evaluations - /// from the multi-degree sumcheck, verifies both LogUp identities: + /// Given the subclaim point `x*` and two expected evaluations (one + /// per γ-batched group), checks: /// - /// - Group 0: `((β − w_eval)·u_eval − 1)·eq_val == expected[0]` - /// - Group 1: `u_eval − m_eval·v_eval == expected[1]` + /// - Group 0: `Σ_l γ^l · (d_l·u_l − 1) · eq_val == expected[0]` where d_l = + /// (β − w_l) + /// - Group 1: `Σ_l γ^l · (u_l − m_l·v_eval) == expected[1]` /// - /// `v_eval` is computed internally from the public table + β (not - /// from the proof) — the table inverse is never committed. + /// `v_eval` is computed once from the public table + β. #[allow(clippy::arithmetic_side_effects)] pub fn finalize_verifier( pre_sumcheck_data: &LogupVerifierPreSumcheckData, @@ -217,18 +270,20 @@ impl LogupProt F::Inner: ConstTranscribable + Zero, { let one = F::one_with_cfg(field_cfg); - let LogupVerifierPreSumcheckData { r, beta } = pre_sumcheck_data; let LogupFinalizerInput { subclaim_point, expected_evaluations, - w_eval, - aux_evals: LookupAuxEvals { u_eval, m_eval }, + w_evals, + aux_evals, } = input; + let LogupVerifierPreSumcheckData { r, beta, gamma } = pre_sumcheck_data; + let num_cols = w_evals.len(); + assert_eq!(num_cols, aux_evals.len()); // Evaluate eq(x*, r) - let eq_val = zinc_poly::utils::eq_eval(subclaim_point, r, one.clone())?; + let eq_val = eq_eval(subclaim_point, r, one.clone())?; - // Evaluate (β − T) at x*: the verifier regenerates the table + // Compute v_eval from public table let table: Vec = match &group_info.table_type { LookupTableType::BitPoly { width, .. } => { generate_bitpoly_table(*width, projecting_element_f, field_cfg) @@ -243,16 +298,31 @@ impl LogupProt let v_eval: F = v .iter() .zip(eq_at_point.iter()) - .fold(zero, |acc, (v, e)| acc + &(v.clone() * e)); + .fold(zero.clone(), |acc, (v, e)| acc + &(v.clone() * e)); + + let gamma_pows = zinc_utils::powers(gamma.clone(), one.clone(), num_cols); - // Group 0: inverse correctness — (d·u − 1)·eq where d = (β − w) - let computed_0 = ((beta.clone() - w_eval) * u_eval - &one) * &eq_val; + // Group 0: Σ_l γ^l · (d_l·u_l − 1) · eq_val, where d_l = (β − w_l) + let mut computed_0 = zero.clone(); + for l in 0..num_cols { + let w_l = &w_evals[l]; + let u_l = &aux_evals[l].u_eval; + let term = (beta.clone() - w_l) * u_l - &one; + computed_0 += &(gamma_pows[l].clone() * &term); + } + computed_0 *= &eq_val; if computed_0 != expected_evaluations[0] { return Err(LookupError::FinalEvaluationMismatch); } - // Group 1: (u − m·v) - let computed_1 = u_eval.clone() - &(m_eval.clone() * &v_eval); + // Group 1: Σ_l γ^l · (u_l − m_l · v_eval) + let mut computed_1 = zero; + for l in 0..num_cols { + let u_l = &aux_evals[l].u_eval; + let m_l = &aux_evals[l].m_eval; + let term = u_l.clone() - &(m_l.clone() * &v_eval); + computed_1 += &(gamma_pows[l].clone() * &term); + } if computed_1 != expected_evaluations[1] { return Err(LookupError::FinalEvaluationMismatch); } @@ -273,7 +343,7 @@ mod tests { use zinc_transcript::Blake3Transcript; use super::super::{ - LogupFinalizerInput, LookupGroup, + LogupFinalizerInput, LookupAuxEvals, LookupGroup, utils::{batch_inverse_shifted, compute_multiplicities}, }; @@ -287,56 +357,66 @@ mod tests { t } - /// Run the full LogUp pipeline on a single (witness, table) pair: - /// compute aux → build sumcheck groups → run multi-degree sumcheck → - /// finalize_verifier identity checks. - fn run_logup_roundtrip(witness: &[F], table: &[F]) { - let num_vars = zinc_utils::log2(witness.len().next_power_of_two()) as usize; + struct LogupTestHarness { + w_evals: Vec, + aux_evals: Vec>, + pre: LogupVerifierPreSumcheckData, + expected_evaluations: Vec, + x_star: Vec, + group_info: LookupGroup, + } - let m = compute_multiplicities(witness, table, &()).expect("witness should be in table"); + fn setup_logup_harness(witnesses: &[Vec], table: &[F]) -> LogupTestHarness { + let num_vars = zinc_utils::log2(witnesses[0].len().next_power_of_two()) as usize; + let num_cols = witnesses.len(); let beta = F::from(17u32); - let u = batch_inverse_shifted(&beta, witness); + let gamma = F::from(7u32); + + let mut m_vecs = Vec::with_capacity(num_cols); + let mut u_vecs = Vec::with_capacity(num_cols); let v = batch_inverse_shifted(&beta, table); - let r: Vec = (0..num_vars).map(|i| F::from((i + 3) as u32)).collect(); + for w in witnesses { + let m = compute_multiplicities(w, table, &()).expect("witness should be in table"); + let u = batch_inverse_shifted(&beta, w); + m_vecs.push(m); + u_vecs.push(u); + } - let aux = super::super::LogupProverAncillary { - multiplicities: &m, - inverse_witness: &u, - inverse_table: &v, - }; - let groups = LogupProtocol::build_sumcheck_groups(witness, table, &aux, &beta, &r, &()) - .expect("build_sumcheck_groups should succeed"); + let r: Vec = (0..num_vars).map(|i| F::from((i + 3) as u32)).collect(); - assert_eq!(groups.len(), 2); + let witness_refs: Vec<&[F]> = witnesses.iter().map(|w| w.as_slice()).collect(); + let auxs: Vec> = m_vecs + .iter() + .zip(u_vecs.iter()) + .map(|(m, u)| super::super::LogupProverAncillary { + multiplicities: m, + inverse_witness: u, + inverse_table: &v, + }) + .collect(); - // Run the multi-degree sumcheck (prover side) - let mut prover_transcript = make_transcript(); - let (md_proof, _md_states) = MultiDegreeSumcheck::prove_as_subprotocol( - &mut prover_transcript, - groups, - num_vars, + let groups = LogupProtocol::build_sumcheck_groups( + &witness_refs, + table, + &auxs, + &beta, + &gamma, + &r, &(), - ); + ) + .expect("build_sumcheck_groups should succeed"); - // All the claimed sums should be zero (the identities sum to 0 - // over the boolean hypercube when witness exists in table). - for (g, cs) in md_proof.claimed_sums().iter().enumerate() { - assert_eq!(*cs, F::ZERO, "group {g} claimed sum should be zero"); - } + assert_eq!(groups.len(), 2); - // Verify the sumcheck - let mut verifier_transcript = make_transcript(); - let md_subclaims = MultiDegreeSumcheck::verify_as_subprotocol( - &mut verifier_transcript, - num_vars, - &md_proof, - &(), - ) - .expect("sumcheck verify should succeed"); + let mut pt = make_transcript(); + let (md_proof, _) = + MultiDegreeSumcheck::prove_as_subprotocol(&mut pt, groups, num_vars, &()); + let mut vt = make_transcript(); + let md_sub = MultiDegreeSumcheck::verify_as_subprotocol(&mut vt, num_vars, &md_proof, &()) + .expect("sumcheck verify should succeed"); - // Compute w_eval, u_eval, m_eval at subclaim point x* - let x_star = md_subclaims.point(); + let x_star = md_sub.point().to_vec(); let inner_zero = F::ZERO.into_inner(); let mk_mle = |data: &[F]| -> DenseMultilinearExtension<_> { DenseMultilinearExtension::from_evaluations_vec( @@ -346,35 +426,61 @@ mod tests { ) }; - let w_eval: F = mk_mle(witness) - .evaluate_with_config(x_star, &()) - .expect("eval should succeed"); - let u_eval: F = mk_mle(&u) - .evaluate_with_config(x_star, &()) - .expect("eval should succeed"); - let m_eval: F = mk_mle(&m) - .evaluate_with_config(x_star, &()) - .expect("eval should succeed"); + let w_evals: Vec = witnesses + .iter() + .map(|w| mk_mle(w).evaluate_with_config(&x_star, &()).unwrap()) + .collect(); + let aux_evals: Vec> = m_vecs + .iter() + .zip(u_vecs.iter()) + .map(|(m, u)| LookupAuxEvals { + u_eval: mk_mle(u).evaluate_with_config(&x_star, &()).unwrap(), + m_eval: mk_mle(m).evaluate_with_config(&x_star, &()).unwrap(), + }) + .collect(); let table_width = zinc_utils::log2(table.len().next_power_of_two()) as usize; - let group_info = LookupGroup { - table_type: LookupTableType::Word { - width: table_width, - chunk_width: None, + LogupTestHarness { + w_evals, + aux_evals, + pre: LogupVerifierPreSumcheckData { r, beta, gamma }, + expected_evaluations: md_sub.expected_evaluations().to_vec(), + x_star, + group_info: LookupGroup { + table_type: LookupTableType::Word { + width: table_width, + chunk_width: None, + }, + column_indices: (0..num_cols).collect(), }, - column_indices: vec![0], - }; + } + } - let pre = LogupVerifierPreSumcheckData { r, beta }; - let aux_evals = LookupAuxEvals { u_eval, m_eval }; - let fin_input = LogupFinalizerInput { - subclaim_point: x_star, - expected_evaluations: md_subclaims.expected_evaluations(), - w_eval: &w_eval, - aux_evals: &aux_evals, - }; + impl LogupTestHarness { + fn finalize_with( + &self, + w_evals: &[F], + aux_evals: &[LookupAuxEvals], + ) -> Result<(), LookupError> { + let fin = LogupFinalizerInput { + subclaim_point: &self.x_star, + expected_evaluations: &self.expected_evaluations, + w_evals, + aux_evals, + }; + LogupProtocol::::finalize_verifier(&self.pre, fin, &self.group_info, &F::ZERO, &()) + } - LogupProtocol::::finalize_verifier(&pre, fin_input, &group_info, &F::ZERO, &()) + fn finalize_honest(&self) -> Result<(), LookupError> { + self.finalize_with(&self.w_evals, &self.aux_evals) + } + } + + /// Full γ-batched LogUp pipeline: `setup_logup_harness` then honest + /// finalize. + fn run_logup_roundtrip(witnesses: &[Vec], table: &[F]) { + setup_logup_harness(witnesses, table) + .finalize_honest() .expect("finalize_verifier should succeed"); } @@ -382,21 +488,30 @@ mod tests { fn logup_roundtrip_small() { let table: Vec = (0..4u32).map(F::from).collect(); let witness: Vec = vec![0u32, 1, 1, 3].into_iter().map(F::from).collect(); - run_logup_roundtrip(&witness, &table); + run_logup_roundtrip(&[witness], &table); } #[test] fn logup_roundtrip_all_same() { let table: Vec = (0..4u32).map(F::from).collect(); let witness: Vec = vec![2u32; 4].into_iter().map(F::from).collect(); - run_logup_roundtrip(&witness, &table); + run_logup_roundtrip(&[witness], &table); } #[test] fn logup_roundtrip_full_table() { let table: Vec = (0..8u32).map(F::from).collect(); let witness: Vec = (0..8u32).map(F::from).collect(); - run_logup_roundtrip(&witness, &table); + run_logup_roundtrip(&[witness], &table); + } + + #[test] + fn logup_roundtrip_multi_column() { + let table: Vec = (0..4u32).map(F::from).collect(); + let w0: Vec = vec![0u32, 1, 1, 3].into_iter().map(F::from).collect(); + let w1: Vec = vec![3u32, 3, 0, 2].into_iter().map(F::from).collect(); + let w2: Vec = vec![1u32, 2, 2, 0].into_iter().map(F::from).collect(); + run_logup_roundtrip(&[w0, w1, w2], &table); } #[test] @@ -411,78 +526,58 @@ mod tests { ); } - #[test] - fn logup_wrong_aux_eval_rejected() { + fn single_col_harness() -> LogupTestHarness { let table: Vec = (0..4u32).map(F::from).collect(); let witness: Vec = vec![0u32, 1, 1, 3].into_iter().map(F::from).collect(); - let num_vars = 2; + setup_logup_harness(&[witness], &table) + } - let m = compute_multiplicities(&witness, &table, &()).unwrap(); - let beta = F::from(17u32); - let u = batch_inverse_shifted(&beta, &witness); - let v = batch_inverse_shifted(&beta, &table); - let r: Vec = (0..num_vars).map(|i| F::from((i + 3) as u32)).collect(); + fn two_col_harness() -> LogupTestHarness { + let table: Vec = (0..4u32).map(F::from).collect(); + let w0: Vec = vec![0u32, 1, 1, 3].into_iter().map(F::from).collect(); + let w1: Vec = vec![3u32, 3, 0, 2].into_iter().map(F::from).collect(); + setup_logup_harness(&[w0, w1], &table) + } - let aux = super::super::LogupProverAncillary { - multiplicities: &m, - inverse_witness: &u, - inverse_table: &v, - }; - let groups = - LogupProtocol::build_sumcheck_groups(&witness, &table, &aux, &beta, &r, &()).unwrap(); - - let mut prover_transcript = make_transcript(); - let (md_proof, _) = MultiDegreeSumcheck::prove_as_subprotocol( - &mut prover_transcript, - groups, - num_vars, - &(), - ); + #[test] + fn logup_wrong_u_eval_rejected() { + let h = single_col_harness(); + assert!(h.finalize_honest().is_ok()); + let mut bad = h.aux_evals.clone(); + bad[0].u_eval += F::from(1u32); + assert!(h.finalize_with(&h.w_evals, &bad).is_err()); + } - let mut verifier_transcript = make_transcript(); - let md_subclaims = MultiDegreeSumcheck::verify_as_subprotocol( - &mut verifier_transcript, - num_vars, - &md_proof, - &(), - ) - .unwrap(); + #[test] + fn logup_wrong_m_eval_rejected() { + let h = single_col_harness(); + let mut bad = h.aux_evals.clone(); + bad[0].m_eval += F::from(1u32); + assert!(h.finalize_with(&h.w_evals, &bad).is_err()); + } - let x_star = md_subclaims.point(); - let inner_zero = F::ZERO.into_inner(); - let mk_mle = |data: &[F]| -> DenseMultilinearExtension<_> { - DenseMultilinearExtension::from_evaluations_vec( - num_vars, - data.iter().map(|f| f.into_inner()).collect(), - inner_zero, - ) - }; - let w_eval: F = mk_mle(&witness).evaluate_with_config(x_star, &()).unwrap(); - let u_eval: F = mk_mle(&u).evaluate_with_config(x_star, &()).unwrap(); - let m_eval: F = mk_mle(&m).evaluate_with_config(x_star, &()).unwrap(); - - let group_info = LookupGroup { - table_type: LookupTableType::Word { - width: 2, - chunk_width: None, - }, - column_indices: vec![0], - }; - let pre = LogupVerifierPreSumcheckData { r, beta }; + #[test] + fn logup_wrong_w_eval_rejected() { + let h = single_col_harness(); + let mut bad_w = h.w_evals.clone(); + bad_w[0] += F::from(1u32); + assert!(h.finalize_with(&bad_w, &h.aux_evals).is_err()); + } - // Corrupt u_eval - let bad_aux = LookupAuxEvals { - u_eval: u_eval + F::from(1u32), - m_eval, - }; - let fin_input = LogupFinalizerInput { - subclaim_point: x_star, - expected_evaluations: md_subclaims.expected_evaluations(), - w_eval: &w_eval, - aux_evals: &bad_aux, - }; - let result = - LogupProtocol::::finalize_verifier(&pre, fin_input, &group_info, &F::ZERO, &()); - assert!(result.is_err(), "corrupted u_eval should be rejected"); + #[test] + fn logup_wrong_gamma_rejected() { + let mut h = two_col_harness(); + assert!(h.finalize_honest().is_ok()); + h.pre.gamma = F::from(999u32); + assert!(h.finalize_honest().is_err()); + } + + #[test] + fn logup_multi_col_wrong_single_aux_rejected() { + let h = two_col_harness(); + assert!(h.finalize_honest().is_ok()); + let mut bad = h.aux_evals.clone(); + bad[1].m_eval += F::from(1u32); + assert!(h.finalize_with(&h.w_evals, &bad).is_err()); } } diff --git a/piop/src/lookup/structs.rs b/piop/src/lookup/structs.rs index 16446b3b..9a5193cb 100644 --- a/piop/src/lookup/structs.rs +++ b/piop/src/lookup/structs.rs @@ -10,100 +10,10 @@ use thiserror::Error; use zinc_poly::utils::ArithErrors; use zinc_uair::LookupTableType; -// --------------------------------------------------------------------------- -// Per-group proof (BatchedDecompLogup) -// --------------------------------------------------------------------------- - -/// Proof for one lookup group (columns sharing the same table type). -/// -/// Does **not** contain a sumcheck proof — the sumcheck is shared via -/// the protocol-level multi-degree sumcheck. This struct -/// carries only the auxiliary vectors the verifier needs to reconstruct -/// evaluations at the shared point. -/// -/// Chunk vectors are **not** included — the verifier reconstructs them -/// from the inverse witnesses: `c_k[j] = β − 1/u_k[j]`. Soundness -/// follows from the PCS commitment binding the parent column. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BatchedDecompLogupProof { - /// Per-witness aggregated multiplicity vectors: - /// `aggregated_multiplicities[l][j] = Σ_k m_k^(l)[j]`. - pub aggregated_multiplicities: Vec>, - /// Per-witness per-chunk inverse witness vectors: - /// `chunk_inverse_witnesses[l][k][i] = 1 / (β − chunk[l][k][i])`. - pub chunk_inverse_witnesses: Vec>>, - /// Shared inverse table vector: `inverse_table[j] = 1 / (β − T[j])`. - pub inverse_table: Vec, -} - -// --------------------------------------------------------------------------- -// Per-group metadata (carried in the proof for the verifier) -// --------------------------------------------------------------------------- - -/// Describes how a lookup witness column was derived from the trace. -/// -/// Carried in [`LookupGroupMeta`] so the verifier can reconstruct the -/// parent evaluation without re-receiving the lookup specs. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum LookupWitnessSource { - /// Standard column lookup: parent eval = `up_evals[column_index]`. - Column { - /// Original trace column index. - column_index: usize, - }, - /// Affine-combination lookup: parent eval = `Σ coeff·up_evals[col] + - /// offset`. Currently only needed for BitPoly - Affine { - /// `(column_index, coefficient)` pairs. - terms: Vec<(usize, i64)>, - /// Constant bit-polynomial offset encoded as a u32 bit pattern. - constant_offset_bits: u32, - }, -} - -/// Per-group metadata stored in the proof so the verifier can reconstruct -/// tables and column layout without being passed the original lookup specs. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct LookupGroupMeta { - /// Table type for this group (determines subtable generation). - pub table_type: LookupTableType, - /// Number of witness columns batched into this group (L). - pub num_columns: usize, - /// Number of rows in each witness vector (trace length). - pub witness_len: usize, - /// Per-witness source descriptors. - pub witness_sources: Vec, -} - -// --------------------------------------------------------------------------- -// Complete lookup proof -// --------------------------------------------------------------------------- - -/// Top-level proof: one [`LogupProof`] per lookup group -/// (groups formed by batching columns with the same [`LookupTableType`]). -/// Carries [`LookupGroupMeta`] per group so the verifier needs no -/// external specs. -#[derive(Clone, Default, Debug, PartialEq, Eq)] -pub struct BatchedLookupProof { - /// Per-group proofs, in group order. - pub group_proofs: Vec>, - /// Per-group metadata needed by the verifier. - pub group_meta: Vec, -} - // --------------------------------------------------------------------------- // Core LogUp types // --------------------------------------------------------------------------- -/// Verifier subclaim from the LogUp protocol. -#[derive(Clone, Debug)] -pub struct LogupVerifierSubClaim { - /// Evaluation point from the sumcheck subclaim. - pub evaluation_point: Vec, - /// Expected evaluation at the subclaim point. - pub expected_evaluation: F, -} - /// Ancillary prover data for LogUp sumcheck group construction. /// /// Borrows the pre-computed auxiliary vectors needed by @@ -128,6 +38,8 @@ pub struct LogupVerifierPreSumcheckData { pub r: Vec, /// The β challenge for LogUp. pub beta: F, + /// The γ challenge for batching multiple lookup columns. + pub gamma: F, } /// Scalar MLE evaluations of lookup auxiliary columns at the subclaim @@ -140,22 +52,21 @@ pub struct LookupAuxEvals { pub m_eval: F, } -/// Bundle of per-column evaluation data passed to +/// Bundle of per-group evaluation data passed to /// [`LogupProtocol::finalize_verifier`]. /// -/// Groups the subclaim outputs and PCS-opened evaluations that vary -/// per lookup column. +/// Carries L columns' evaluation data for the two γ-batched groups. #[derive(Clone, Debug)] pub struct LogupFinalizerInput<'a, F> { /// The multi-degree sumcheck subclaim point (x*). pub subclaim_point: &'a [F], - /// Expected evaluations from the sumcheck subclaim for this - /// column's two groups. + /// Expected evaluations from the sumcheck subclaim (len=2, one + /// per γ-batched group). pub expected_evaluations: &'a [F], - /// Evaluation of the witness column polynomial at x*. - pub w_eval: &'a F, - /// Auxiliary evaluations (u, m) at x*. - pub aux_evals: &'a LookupAuxEvals, + /// Evaluations of the L witness column polynomials at x*. + pub w_evals: &'a [F], + /// Per-column auxiliary evaluations (u, m) at x*. + pub aux_evals: &'a [LookupAuxEvals], } // --------------------------------------------------------------------------- From 17c05450d8a1810de62e9337be7a506a0c026ccd Mon Sep 17 00:00:00 2001 From: amit0365 Date: Sat, 11 Apr 2026 16:19:25 +0530 Subject: [PATCH 3/6] Fix clippy --- piop/src/lookup/structs.rs | 90 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/piop/src/lookup/structs.rs b/piop/src/lookup/structs.rs index 9a5193cb..19ba2109 100644 --- a/piop/src/lookup/structs.rs +++ b/piop/src/lookup/structs.rs @@ -10,10 +10,100 @@ use thiserror::Error; use zinc_poly::utils::ArithErrors; use zinc_uair::LookupTableType; +// --------------------------------------------------------------------------- +// Per-group proof (BatchedDecompLogup) +// --------------------------------------------------------------------------- + +/// Proof for one lookup group (columns sharing the same table type). +/// +/// Does **not** contain a sumcheck proof — the sumcheck is shared via +/// the protocol-level multi-degree sumcheck. This struct +/// carries only the auxiliary vectors the verifier needs to reconstruct +/// evaluations at the shared point. +/// +/// Chunk vectors are **not** included — the verifier reconstructs them +/// from the inverse witnesses: `c_k[j] = β − 1/u_k[j]`. Soundness +/// follows from the PCS commitment binding the parent column. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BatchedDecompLogupProof { + /// Per-witness aggregated multiplicity vectors: + /// `aggregated_multiplicities[l][j] = Σ_k m_k^(l)[j]`. + pub aggregated_multiplicities: Vec>, + /// Per-witness per-chunk inverse witness vectors: + /// `chunk_inverse_witnesses[l][k][i] = 1 / (β − chunk[l][k][i])`. + pub chunk_inverse_witnesses: Vec>>, + /// Shared inverse table vector: `inverse_table[j] = 1 / (β − T[j])`. + pub inverse_table: Vec, +} + +// --------------------------------------------------------------------------- +// Per-group metadata (carried in the proof for the verifier) +// --------------------------------------------------------------------------- + +/// Describes how a lookup witness column was derived from the trace. +/// +/// Carried in [`LookupGroupMeta`] so the verifier can reconstruct the +/// parent evaluation without re-receiving the lookup specs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LookupWitnessSource { + /// Standard column lookup: parent eval = `up_evals[column_index]`. + Column { + /// Original trace column index. + column_index: usize, + }, + /// Affine-combination lookup: parent eval = `Σ coeff·up_evals[col] + + /// offset`. Currently only needed for BitPoly + Affine { + /// `(column_index, coefficient)` pairs. + terms: Vec<(usize, i64)>, + /// Constant bit-polynomial offset encoded as a u32 bit pattern. + constant_offset_bits: u32, + }, +} + +/// Per-group metadata stored in the proof so the verifier can reconstruct +/// tables and column layout without being passed the original lookup specs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LookupGroupMeta { + /// Table type for this group (determines subtable generation). + pub table_type: LookupTableType, + /// Number of witness columns batched into this group (L). + pub num_columns: usize, + /// Number of rows in each witness vector (trace length). + pub witness_len: usize, + /// Per-witness source descriptors. + pub witness_sources: Vec, +} + +// --------------------------------------------------------------------------- +// Complete lookup proof +// --------------------------------------------------------------------------- + +/// Top-level proof: one [`BatchedDecompLogupProof`] per lookup group +/// (groups formed by batching columns with the same [`LookupTableType`]). +/// Carries [`LookupGroupMeta`] per group so the verifier needs no +/// external specs. +#[derive(Clone, Default, Debug, PartialEq, Eq)] +pub struct BatchedLookupProof { + /// Per-group proofs, in group order. + pub group_proofs: Vec>, + /// Per-group metadata needed by the verifier. + pub group_meta: Vec, +} + // --------------------------------------------------------------------------- // Core LogUp types // --------------------------------------------------------------------------- +/// Verifier subclaim from the LogUp protocol. +#[derive(Clone, Debug)] +pub struct LogupVerifierSubClaim { + /// Evaluation point from the sumcheck subclaim. + pub evaluation_point: Vec, + /// Expected evaluation at the subclaim point. + pub expected_evaluation: F, +} + /// Ancillary prover data for LogUp sumcheck group construction. /// /// Borrows the pre-computed auxiliary vectors needed by From cb590091ec39f4dacfe70f6b36ef0363c6060f2e Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 12 Apr 2026 14:31:52 +0530 Subject: [PATCH 4/6] Minor tweaks --- piop/src/lookup/logup.rs | 27 +++--- piop/src/lookup/utils.rs | 6 ++ test-uair/src/lib.rs | 190 +++++++++++++++++++-------------------- 3 files changed, 111 insertions(+), 112 deletions(-) diff --git a/piop/src/lookup/logup.rs b/piop/src/lookup/logup.rs index 45ea21d0..d871ec56 100644 --- a/piop/src/lookup/logup.rs +++ b/piop/src/lookup/logup.rs @@ -86,20 +86,21 @@ impl LogupProt F: 'static, { let num_cols = witnesses.len(); + assert!(num_cols > 0, "at least one witness column is required"); assert_eq!( num_cols, auxs.len(), "witnesses and auxs must have same length" ); + assert!( + witnesses.iter().all(|w| w.len() == witnesses[0].len()), + "all witness columns must have the same length" + ); let zero = F::zero_with_cfg(field_cfg); let one = F::one_with_cfg(field_cfg); - let w_num_vars = if num_cols > 0 { - log2(witnesses[0].len().next_power_of_two()) as usize - } else { - 0 - }; + let w_num_vars = log2(witnesses[0].len().next_power_of_two()) as usize; let t_num_vars = log2(table.len().next_power_of_two()) as usize; let num_vars = w_num_vars.max(t_num_vars); @@ -144,28 +145,28 @@ impl LogupProt group1_mles.push(mk_mle(auxs[0].inverse_table)); // group0: (d_l·u_l − 1) · eq where d_l = (β − w_l) - let one0 = one.clone(); - let zero0 = zero.clone(); - let gamma_powers_copy0 = gamma_pows.clone(); + let one_g0 = one.clone(); + let zero_g0 = zero.clone(); + let gamma_powers_g0 = gamma_pows.clone(); let l0 = num_cols; let group_0 = MultiDegreeSumcheckGroup::new( 3, group0_mles, Box::new(move |v: &[F]| { // v[0] = eq, v[1+2l] = d_l, v[2+2l] = u_l - let mut sum = zero0.clone(); + let mut sum = zero_g0.clone(); for l in 0..l0 { let d = &v[1 + 2 * l]; let u = &v[2 + 2 * l]; - let term = d.clone() * u - &one0; - sum += &(gamma_powers_copy0[l].clone() * &term); + let term = d.clone() * u - &one_g0; + sum += &(gamma_powers_g0[l].clone() * &term); } sum * &v[0] }), ); // group1: (u − m·v) - let zero1 = zero; + let zero_g1 = zero; let gp1 = gamma_pows; let l1 = num_cols; let group_1 = MultiDegreeSumcheckGroup::new( @@ -174,7 +175,7 @@ impl LogupProt Box::new(move |v: &[F]| { // v[2l] = u_l, v[2l+1] = m_l, v[2*L] = v (shared) let v_shared = &v[2 * l1]; - let mut sum = zero1.clone(); + let mut sum = zero_g1.clone(); for l in 0..l1 { let u = &v[2 * l]; let m = &v[2 * l + 1]; diff --git a/piop/src/lookup/utils.rs b/piop/src/lookup/utils.rs index f6911c51..188d8465 100644 --- a/piop/src/lookup/utils.rs +++ b/piop/src/lookup/utils.rs @@ -157,6 +157,7 @@ fn reduce_chunk_products(prefixes: &[Vec], one: &F) -> Vec }; if k == 1 { + // Panics if the sole chunk product is zero (i.e. a zero input element). return vec![one.clone() / last(0)]; } @@ -167,6 +168,9 @@ fn reduce_chunk_products(prefixes: &[Vec], one: &F) -> Vec global_prefix.push(prev * last(i)); } + // Single field inversion — panics here if any input to + // `batch_inverse` / `batch_inverse_shifted` was zero (or β − v_i = 0), + // since the global prefix product will be zero. let mut inv_acc = one.clone() / &global_prefix[k - 1]; let mut accs = vec![F::zero_with_cfg(one.cfg()); k]; for i in (1..k).rev() { @@ -235,6 +239,8 @@ where }; match table_index.get(bytes) { Some(&idx) => counts[idx] += 1, + // Empty vec: valid counts always have + // length table_len ≥ 1, so is_empty() detects this below. None => return vec![], } } diff --git a/test-uair/src/lib.rs b/test-uair/src/lib.rs index 21f67945..ebf9f9e4 100644 --- a/test-uair/src/lib.rs +++ b/test-uair/src/lib.rs @@ -632,29 +632,36 @@ where } } -impl GenerateRandomTrace<32> for SimpleLookupUair -where - R: ConstSemiring + From + 'static, -{ - type PolyCoeff = R; - type Int = R; - - fn generate_random_trace( - num_vars: usize, - rng: &mut Rng, - ) -> UairTrace<'static, R, R, 32> { - let n = 1 << num_vars; - let vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 16)).collect(); - let a: DenseMultilinearExtension = vals.clone().into_iter().collect(); - let b: DenseMultilinearExtension = vals.into_iter().collect(); - - UairTrace { - int: vec![a, b].into(), - ..Default::default() +/// Generate a `GenerateRandomTrace` impl for a UAIR with 2 int columns where +/// column 0 = column 1, values drawn uniformly from `0..modulus`. +macro_rules! impl_int_copy_trace { + ($name:ident, $modulus:expr) => { + impl GenerateRandomTrace<32> for $name + where + R: ConstSemiring + From + 'static, + { + type PolyCoeff = R; + type Int = R; + + fn generate_random_trace( + num_vars: usize, + rng: &mut Rng, + ) -> UairTrace<'static, R, R, 32> { + let n = 1 << num_vars; + let vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % $modulus)).collect(); + let a: DenseMultilinearExtension = vals.clone().into_iter().collect(); + let b: DenseMultilinearExtension = vals.into_iter().collect(); + UairTrace { + int: vec![a, b].into(), + ..Default::default() + } + } } - } + }; } +impl_int_copy_trace!(SimpleLookupUair, 16); + /// UAIR with two int columns both looked up against the same `Word(4)` table. /// Tests L>1 (multi-column) lookup within a single group. /// 3 int columns total: a, b looked up; c = a + b (trivial constraint). @@ -707,36 +714,43 @@ where } } -impl GenerateRandomTrace<32> for MultiColLookupUair -where - R: ConstSemiring + From + 'static, -{ - type PolyCoeff = R; - type Int = R; - - fn generate_random_trace( - num_vars: usize, - rng: &mut Rng, - ) -> UairTrace<'static, R, R, 32> { - let n = 1 << num_vars; - let a_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 16)).collect(); - let b_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 16)).collect(); - let c_vals: Vec = a_vals - .iter() - .zip(b_vals.iter()) - .map(|(a, b)| a.clone() + b) - .collect(); - let a: DenseMultilinearExtension = a_vals.into_iter().collect(); - let b: DenseMultilinearExtension = b_vals.into_iter().collect(); - let c: DenseMultilinearExtension = c_vals.into_iter().collect(); - - UairTrace { - int: vec![a, b, c].into(), - ..Default::default() +/// Generate a `GenerateRandomTrace` impl for a UAIR with 3 int columns where +/// `c = a + b`, with `a` in `0..range_a` and `b` in `0..range_b`. +macro_rules! impl_sum_pair_trace { + ($name:ident, $range_a:expr, $range_b:expr) => { + impl GenerateRandomTrace<32> for $name + where + R: ConstSemiring + From + 'static, + { + type PolyCoeff = R; + type Int = R; + + fn generate_random_trace( + num_vars: usize, + rng: &mut Rng, + ) -> UairTrace<'static, R, R, 32> { + let n = 1 << num_vars; + let a_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % $range_a)).collect(); + let b_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % $range_b)).collect(); + let c_vals: Vec = a_vals + .iter() + .zip(b_vals.iter()) + .map(|(a, b)| a.clone() + b) + .collect(); + let a: DenseMultilinearExtension = a_vals.into_iter().collect(); + let b: DenseMultilinearExtension = b_vals.into_iter().collect(); + let c: DenseMultilinearExtension = c_vals.into_iter().collect(); + UairTrace { + int: vec![a, b, c].into(), + ..Default::default() + } + } } - } + }; } +impl_sum_pair_trace!(MultiColLookupUair, 16, 16); + /// UAIR with two columns looking up different table types: /// column 0 → Word(4), column 1 → Word(8). Tests multiple lookup groups. /// 3 int columns: a in {0..15}, b in {0..255}, c = a + b. @@ -789,35 +803,7 @@ where } } -impl GenerateRandomTrace<32> for MultiGroupLookupUair -where - R: ConstSemiring + From + 'static, -{ - type PolyCoeff = R; - type Int = R; - - fn generate_random_trace( - num_vars: usize, - rng: &mut Rng, - ) -> UairTrace<'static, R, R, 32> { - let n = 1 << num_vars; - let a_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 16)).collect(); - let b_vals: Vec = (0..n).map(|_| R::from(rng.next_u32() % 256)).collect(); - let c_vals: Vec = a_vals - .iter() - .zip(b_vals.iter()) - .map(|(a, b)| a.clone() + b) - .collect(); - let a: DenseMultilinearExtension = a_vals.into_iter().collect(); - let b: DenseMultilinearExtension = b_vals.into_iter().collect(); - let c: DenseMultilinearExtension = c_vals.into_iter().collect(); - - UairTrace { - int: vec![a, b, c].into(), - ..Default::default() - } - } -} +impl_sum_pair_trace!(MultiGroupLookupUair, 16, 256); /// UAIR with a BitPoly lookup: one binary_poly column looked up against /// `BitPoly(8)`. Tests the BitPoly table type. @@ -867,32 +853,38 @@ where } } -impl GenerateRandomTrace<32> for BitPolyLookupUair -where - R: ConstSemiring + From + 'static, -{ - type PolyCoeff = R; - type Int = R; - - fn generate_random_trace( - num_vars: usize, - rng: &mut Rng, - ) -> UairTrace<'static, R, R, 32> { - let n = 1 << num_vars; - let values: Vec = (0..n).map(|_| rng.next_u32() % 256).collect(); - - let binary_poly_col: DenseMultilinearExtension> = - values.iter().map(|&v| BinaryPoly::from(v)).collect(); - let int_col: DenseMultilinearExtension = values.into_iter().map(R::from).collect(); - - UairTrace { - binary_poly: vec![binary_poly_col].into(), - int: vec![int_col].into(), - ..Default::default() +/// Generate a `GenerateRandomTrace` impl for a UAIR with 1 binary_poly column +/// and 1 int column, both carrying the same values in `0..modulus`. +macro_rules! impl_bitpoly_int_trace { + ($name:ident, $modulus:expr) => { + impl GenerateRandomTrace<32> for $name + where + R: ConstSemiring + From + 'static, + { + type PolyCoeff = R; + type Int = R; + + fn generate_random_trace( + num_vars: usize, + rng: &mut Rng, + ) -> UairTrace<'static, R, R, 32> { + let n = 1 << num_vars; + let values: Vec = (0..n).map(|_| rng.next_u32() % $modulus).collect(); + let bp: DenseMultilinearExtension> = + values.iter().map(|&v| BinaryPoly::from(v)).collect(); + let int: DenseMultilinearExtension = values.into_iter().map(R::from).collect(); + UairTrace { + binary_poly: vec![bp].into(), + int: vec![int].into(), + ..Default::default() + } + } } - } + }; } +impl_bitpoly_int_trace!(BitPolyLookupUair, 256); + #[cfg(test)] mod tests { use crypto_primitives::crypto_bigint_int::Int; From e103aa22954be38a10373635c05af5648dee1c42 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 12 Apr 2026 14:42:31 +0530 Subject: [PATCH 5/6] Remove `unsafe` impl --- piop/src/lookup/utils.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/piop/src/lookup/utils.rs b/piop/src/lookup/utils.rs index 188d8465..f53d0689 100644 --- a/piop/src/lookup/utils.rs +++ b/piop/src/lookup/utils.rs @@ -11,6 +11,7 @@ use std::collections::HashMap; use crypto_primitives::{FromPrimitiveWithConfig, PrimeField}; #[cfg(feature = "parallel")] use rayon::prelude::*; +use zinc_transcript::traits::{ConstTranscribable, GenTranscribable}; use zinc_utils::{cfg_chunk_size, cfg_chunks, cfg_chunks_mut, cfg_into_iter}; /// A precomputed table index mapping byte representations of table entries @@ -187,16 +188,17 @@ fn reduce_chunk_products(prefixes: &[Vec], one: &F) -> Vec /// Use with [`compute_multiplicities_with_index`] to avoid rebuilding /// the hash map when computing multiplicities for multiple witnesses /// against the same table. -pub fn build_table_index(table: &[F]) -> TableIndex { - let elem_size = std::mem::size_of::(); +pub fn build_table_index(table: &[F]) -> TableIndex +where + F::Inner: ConstTranscribable, +{ table .iter() .enumerate() .map(|(i, t)| { - let bytes = unsafe { - std::slice::from_raw_parts(t.inner() as *const F::Inner as *const u8, elem_size) - }; - (bytes.to_vec(), i) + let mut bytes = vec![0u8; F::Inner::NUM_BYTES]; + t.inner().write_transcription_bytes_exact(&mut bytes); + (bytes, i) }) .collect() } @@ -218,6 +220,7 @@ pub fn compute_multiplicities_with_index Option> where F::Config: Sync, + F::Inner: ConstTranscribable, { if witness.is_empty() { return Some( @@ -227,17 +230,15 @@ where ); } - let elem_size = std::mem::size_of::(); let chunk_size = cfg_chunk_size!(witness.len(), 4096); let local_counts: Vec> = cfg_chunks!(witness, chunk_size) .map(|chunk| { let mut counts = vec![0u64; table_len]; + let mut bytes = vec![0u8; F::Inner::NUM_BYTES]; for w in chunk { - let bytes = unsafe { - std::slice::from_raw_parts(w.inner() as *const F::Inner as *const u8, elem_size) - }; - match table_index.get(bytes) { + w.inner().write_transcription_bytes_exact(&mut bytes); + match table_index.get(&bytes) { Some(&idx) => counts[idx] += 1, // Empty vec: valid counts always have // length table_len ≥ 1, so is_empty() detects this below. @@ -283,6 +284,7 @@ pub fn compute_multiplicities Option> where F::Config: Sync, + F::Inner: ConstTranscribable, { let index = build_table_index(table); compute_multiplicities_with_index(witness, &index, table.len(), field_cfg) From 6e7bae41f2324fa291d0722ab8ee9f65bb1fc990 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 12 Apr 2026 16:25:56 +0530 Subject: [PATCH 6/6] Remove panics in verifier path --- piop/src/lookup/logup.rs | 15 ++++++++++++++- piop/src/lookup/structs.rs | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/piop/src/lookup/logup.rs b/piop/src/lookup/logup.rs index d871ec56..e8092330 100644 --- a/piop/src/lookup/logup.rs +++ b/piop/src/lookup/logup.rs @@ -279,7 +279,18 @@ impl LogupProt } = input; let LogupVerifierPreSumcheckData { r, beta, gamma } = pre_sumcheck_data; let num_cols = w_evals.len(); - assert_eq!(num_cols, aux_evals.len()); + if num_cols != aux_evals.len() { + return Err(LookupError::EvalLengthMismatch { + w: num_cols, + aux: aux_evals.len(), + }); + } + if expected_evaluations.len() < 2 { + return Err(LookupError::WrongEvaluationCount { + expected: 2, + got: expected_evaluations.len(), + }); + } // Evaluate eq(x*, r) let eq_val = eq_eval(subclaim_point, r, one.clone())?; @@ -295,6 +306,8 @@ impl LogupProt let eq_at_point = build_eq_x_r_vec(subclaim_point, field_cfg)?; let zero = F::zero_with_cfg(field_cfg); + // Panics if β equals a table entry. β is a Fiat-Shamir challenge, + // so collision probability is ~1/q (negligible for ≥128-bit fields). let v = batch_inverse_shifted(beta, &table); let v_eval: F = v .iter() diff --git a/piop/src/lookup/structs.rs b/piop/src/lookup/structs.rs index 19ba2109..29714ff3 100644 --- a/piop/src/lookup/structs.rs +++ b/piop/src/lookup/structs.rs @@ -218,6 +218,12 @@ pub enum LookupError { #[error("final evaluation check failed")] FinalEvaluationMismatch, + #[error("malformed proof: expected {expected} evaluations, got {got}")] + WrongEvaluationCount { expected: usize, got: usize }, + + #[error("malformed proof: w_evals length ({w}) != aux_evals length ({aux})")] + EvalLengthMismatch { w: usize, aux: usize }, + #[error("arithmetic error: {0}")] Arith(#[from] ArithErrors), }