Skip to content

Commit 4b99bbf

Browse files
committed
feat(halo2): session 85 — multi-column lookup argument support
Adds multi-column lookup-argument support to the Halo2 circuit evaluator. Real Halo2 lookups support arity > 1 — a single lookup constraint can pair `k` input columns against `k` table columns, all checked simultaneously. The verifier collapses the `k`-arity tuple into a single Fr via the θ challenge before applying the log-derivative identity: input_combined(ξ) = Σ_{i=0}^{k-1} θ^i · input_cols[i] table_combined(ξ) = Σ_{i=0}^{k-1} θ^i · table_cols[i] lookup(ξ) = m · (table_combined + θ^k)⁻¹ - (input_combined + θ^k)⁻¹ The outer additive blinder uses `θ^k` (one degree past the linear-combination weights) to keep input_combined / table_combined distinguishable from the blinder. For arity 1 this collapses to the basic `lookup_expr` form, pinned by `prop_multi_column_lookup_arity_1_matches_basic`. mosaic-halo2/src/circuit.rs additions: - `MultiColumnLookupEvals` — input_cols + table_cols (matched arity) + multiplicity m. Empty + arity-mismatched cases surface as `ProofLengthMismatch`. - `multi_column_lookup_expr` — the θ-combined log-derivative evaluator. Uses the shared `mosaic_zk_primitives::field::powers_of` (session 72) for θ-powers and `fr_inner_product` (session 77) for both input_combined / table_combined sums. Joins `compute_kzg_opening_lhs`, `verify_two_pair_pairing`, `msm_g1_fr` etc as production consumers of the shared primitives. 5 new tests (2 unit + 3 proptest, mosaic-halo2 lib total 75 → 80): - `multi_column_lookup_rejects_empty` — empty cols → error. - `multi_column_lookup_rejects_arity_mismatch` — input/table length mismatch → error. - `prop_multi_column_lookup_arity_1_matches_basic` — **backward-compatibility soundness pin**: arity-1 multi-column lookup is byte-identical to the basic `lookup_expr` over Fr. - `prop_multi_column_lookup_zero_on_matching_arity_2` — audit-grade soundness pin: when input_combined == table_combined (every column pair matches at ξ) and m = 1, the expression vanishes, contributing nothing to the vanishing identity. - `prop_multi_column_lookup_distinguishes_columns` — θ-combination is faithful: tampering any single column on the input side surfaces as a non-zero contribution (probability ≈ 1 - 1/r). Phase-3 audit caveat (preserved in rustdoc) This is the structural multi-column reduction. Real Halo2 also runs a sumcheck over the m polynomial (`Σ_X m(X) = 0`) to ensure the multiplicities are valid; that piece stays in `vanishing.rs::vanishing_identity_holds` as a scaffold caveat for session 4f's full lookup soundness pin. The basic `lookup_expr` and the existing `combined_expr` API are unchanged — backward-compatible. Future PRs can promote callers to multi-column when their circuit shape demands arity > 1.
1 parent b77a3f2 commit 4b99bbf

1 file changed

Lines changed: 212 additions & 0 deletions

File tree

crates/mosaic-halo2/src/circuit.rs

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,95 @@ pub fn lookup_expr(lookup: &LookupEvals, theta: &Fr) -> Result<Fr, OnChainError>
183183
Ok(lookup.m * table_inv - input_inv)
184184
}
185185

186+
// ---------------------------------------------------------------------
187+
// Multi-column lookup (session 85 — Phase-3 lookup arity expansion)
188+
// ---------------------------------------------------------------------
189+
190+
/// Multi-column lookup-argument evaluations at `ξ`.
191+
///
192+
/// Real Halo2 lookups support arity > 1: a single lookup constraint
193+
/// can pair `k` input columns against `k` table columns, all checked
194+
/// simultaneously. The verifier collapses the `k`-arity tuple into a
195+
/// single Fr via the θ challenge before applying the log-derivative
196+
/// identity:
197+
///
198+
/// ```text
199+
/// input_combined(ξ) = Σ_{i=0}^{k-1} θ^i · input_cols[i]
200+
/// table_combined(ξ) = Σ_{i=0}^{k-1} θ^i · table_cols[i]
201+
///
202+
/// lookup(ξ) = m · (table_combined + θ^k)⁻¹ - (input_combined + θ^k)⁻¹
203+
/// ```
204+
///
205+
/// Note: the outer additive blinder uses `θ^k` (one degree past the
206+
/// linear-combination weights) to keep input_combined / table_combined
207+
/// distinguishable from the blinder. For arity 1 this collapses to
208+
/// the basic `lookup_expr` form (`input + θ` blinder, single column),
209+
/// pinned by `multi_column_lookup_arity_1_matches_basic` below.
210+
///
211+
/// Phase-3 audit caveat
212+
///
213+
/// This is the structural multi-column reduction. Real Halo2 also
214+
/// runs a sumcheck over the m polynomial (`Σ_X m(X) = 0`) to ensure
215+
/// the multiplicities are valid; that piece stays in
216+
/// `vanishing.rs::vanishing_identity_holds` as a scaffold caveat for
217+
/// session 4f's full lookup soundness pin.
218+
#[derive(Clone, Debug, PartialEq, Eq)]
219+
pub struct MultiColumnLookupEvals {
220+
/// `k` input column evaluations at ξ. Empty input is rejected as
221+
/// `ProofLengthMismatch`.
222+
pub input_cols: alloc::vec::Vec<Fr>,
223+
/// `k` table column evaluations at ξ. Length must equal
224+
/// `input_cols.len()`.
225+
pub table_cols: alloc::vec::Vec<Fr>,
226+
/// Multiplicity polynomial evaluation `m(ξ)`.
227+
pub m: Fr,
228+
}
229+
230+
/// Evaluate the multi-column lookup argument at ξ via the θ-combined
231+
/// log-derivative form documented on [`MultiColumnLookupEvals`].
232+
///
233+
/// ## Errors
234+
///
235+
/// - [`OnChainError::ProofLengthMismatch`] if `input_cols.is_empty()`
236+
/// or `input_cols.len() != table_cols.len()`.
237+
/// - [`OnChainError::InternalInvariantViolation`] if either combined
238+
/// denominator (`table_combined + θ^k` or `input_combined + θ^k`)
239+
/// is zero. Probability ≈ 2/r for random θ — negligible in practice
240+
/// but explicit for consensus safety.
241+
pub fn multi_column_lookup_expr(
242+
lookup: &MultiColumnLookupEvals,
243+
theta: &Fr,
244+
) -> Result<Fr, OnChainError> {
245+
if lookup.input_cols.is_empty() || lookup.input_cols.len() != lookup.table_cols.len() {
246+
return Err(OnChainError::ProofLengthMismatch);
247+
}
248+
let k = lookup.input_cols.len();
249+
250+
// θ-powers `[1, θ, θ², …, θ^(k-1)]` for the linear combination.
251+
// The blinder uses `θ^k` — one degree past the combination — so
252+
// it can't coincide with any column weight.
253+
let theta_powers = mosaic_zk_primitives::field::powers_of(theta, k);
254+
255+
let input_combined =
256+
mosaic_zk_primitives::field::fr_inner_product(&theta_powers, &lookup.input_cols)?;
257+
let table_combined =
258+
mosaic_zk_primitives::field::fr_inner_product(&theta_powers, &lookup.table_cols)?;
259+
260+
let blinder = mosaic_zk_primitives::field::fr_pow_u64(theta, k as u64);
261+
262+
let table_plus_blinder = table_combined + blinder;
263+
let input_plus_blinder = input_combined + blinder;
264+
265+
let table_inv = table_plus_blinder
266+
.inverse()
267+
.ok_or(OnChainError::InternalInvariantViolation)?;
268+
let input_inv = input_plus_blinder
269+
.inverse()
270+
.ok_or(OnChainError::InternalInvariantViolation)?;
271+
272+
Ok(lookup.m * table_inv - input_inv)
273+
}
274+
186275
// ---------------------------------------------------------------------
187276
// Combined claim reduction
188277
// ---------------------------------------------------------------------
@@ -477,4 +566,127 @@ mod tests {
477566
.unwrap();
478567
assert_ne!(a, b, "combined expression should depend on y");
479568
}
569+
570+
// ───────────────────────────────────────────────────────────────────
571+
// Session 85 — multi_column_lookup_expr properties
572+
// ───────────────────────────────────────────────────────────────────
573+
574+
use proptest::prelude::*;
575+
576+
#[test]
577+
fn multi_column_lookup_rejects_empty() {
578+
let theta = Fr::from(7u64);
579+
let lookup = MultiColumnLookupEvals {
580+
input_cols: alloc::vec::Vec::new(),
581+
table_cols: alloc::vec::Vec::new(),
582+
m: Fr::from(1u64),
583+
};
584+
assert!(matches!(
585+
multi_column_lookup_expr(&lookup, &theta),
586+
Err(OnChainError::ProofLengthMismatch),
587+
));
588+
}
589+
590+
#[test]
591+
fn multi_column_lookup_rejects_arity_mismatch() {
592+
let theta = Fr::from(7u64);
593+
let lookup = MultiColumnLookupEvals {
594+
input_cols: alloc::vec![Fr::from(1u64), Fr::from(2u64)],
595+
table_cols: alloc::vec![Fr::from(3u64)],
596+
m: Fr::from(1u64),
597+
};
598+
assert!(matches!(
599+
multi_column_lookup_expr(&lookup, &theta),
600+
Err(OnChainError::ProofLengthMismatch),
601+
));
602+
}
603+
604+
proptest! {
605+
/// Arity-1 multi-column lookup matches the basic
606+
/// single-column `lookup_expr` form structurally:
607+
/// - input_combined = θ⁰ · input_cols[0] = input
608+
/// - table_combined = θ⁰ · table_cols[0] = table
609+
/// - blinder = θ¹ = θ
610+
/// so the inner identity reduces to
611+
/// `m · (table + θ)⁻¹ - (input + θ)⁻¹` — exactly the
612+
/// basic `lookup_expr` output.
613+
#[test]
614+
fn prop_multi_column_lookup_arity_1_matches_basic(
615+
input_seed in 1u64..=u64::MAX,
616+
table_seed in 1u64..=u64::MAX,
617+
m_seed in 1u64..=u64::MAX,
618+
theta_seed in 1u64..=u64::MAX,
619+
) {
620+
let input = Fr::from(input_seed);
621+
let table = Fr::from(table_seed);
622+
let m = Fr::from(m_seed);
623+
let theta = Fr::from(theta_seed);
624+
625+
let basic = lookup_expr(&LookupEvals { input, table, m }, &theta).unwrap();
626+
let multi = multi_column_lookup_expr(
627+
&MultiColumnLookupEvals {
628+
input_cols: alloc::vec![input],
629+
table_cols: alloc::vec![table],
630+
m,
631+
},
632+
&theta,
633+
)
634+
.unwrap();
635+
636+
prop_assert_eq!(basic, multi);
637+
}
638+
639+
/// When `input_combined == table_combined` (every column pair
640+
/// matches at ξ), the unique satisfying multiplicity is
641+
/// `m = 1`, and the expression vanishes:
642+
/// m · (T + θ^k)⁻¹ - (I + θ^k)⁻¹ = (T + θ^k)⁻¹ - (I + θ^k)⁻¹ = 0
643+
///
644+
/// This is the audit-grade soundness pin for the multi-column
645+
/// reduction: a satisfying assignment with unit multiplicity
646+
/// makes the lookup contribute nothing to the vanishing
647+
/// identity.
648+
#[test]
649+
fn prop_multi_column_lookup_zero_on_matching_arity_2(
650+
seed in 1u64..=u64::MAX,
651+
theta_seed in 2u64..=u64::MAX, // skip θ=0 to avoid blinder collisions
652+
) {
653+
let mut r = rng(seed);
654+
let col_a = Fr::rand(&mut r);
655+
let col_b = Fr::rand(&mut r);
656+
let theta = Fr::from(theta_seed);
657+
// Same column values on both sides ⇒ input_combined = table_combined.
658+
let lookup = MultiColumnLookupEvals {
659+
input_cols: alloc::vec![col_a, col_b],
660+
table_cols: alloc::vec![col_a, col_b],
661+
m: Fr::from(1u64),
662+
};
663+
let v = multi_column_lookup_expr(&lookup, &theta).unwrap();
664+
prop_assert_eq!(v, Fr::zero());
665+
}
666+
667+
/// θ-combination is faithful: changing any single column on
668+
/// the input side AND tweaking m so the basic identity holds
669+
/// for arity-1 should NOT make arity-2 vanish — the second
670+
/// column is still mismatched.
671+
#[test]
672+
fn prop_multi_column_lookup_distinguishes_columns(
673+
seed in 1u64..=u64::MAX,
674+
theta_seed in 2u64..=u64::MAX,
675+
) {
676+
let mut r = rng(seed);
677+
let col_a = Fr::rand(&mut r);
678+
let col_b = Fr::rand(&mut r);
679+
let mismatch = col_b + Fr::from(1u64);
680+
let theta = Fr::from(theta_seed);
681+
let lookup = MultiColumnLookupEvals {
682+
input_cols: alloc::vec![col_a, col_b],
683+
table_cols: alloc::vec![col_a, mismatch], // only 2nd column differs
684+
m: Fr::from(1u64),
685+
};
686+
let v = multi_column_lookup_expr(&lookup, &theta).unwrap();
687+
// With probability ≈ 1 - 1/r the mismatch surfaces as a
688+
// non-zero contribution.
689+
prop_assert_ne!(v, Fr::zero());
690+
}
691+
}
480692
}

0 commit comments

Comments
 (0)