binius_ip/logup_star/verify.rs
1// Copyright 2026 The Binius Developers
2
3//! The top-level logUp* verification routine.
4
5use std::{iter, slice};
6
7use binius_field::{BinaryField1b, ExtensionField, Field, field::FieldOps, util::powers};
8use binius_math::{
9 multilinear::{
10 eq::{eq_ind, eq_ind_zero},
11 evaluate::evaluate_inplace_scalars,
12 },
13 univariate::evaluate_univariate,
14};
15use itertools::izip;
16
17use super::{
18 error::{Error, VerificationError},
19 output::{LogupOutput, LogupTableOutput, LogupTransparentOutput, LogupTransparentTableOutput},
20 pushforward::{Pushforward, TableClaim, denominator_eval, verify_pushforward},
21};
22use crate::{
23 channel::IPVerifierChannel,
24 fracaddcheck::{self, FracAddEvalClaim},
25};
26
27/// One looker's claim on its looked-up vector: `(I^* T)(eval_point) = eval_claim`.
28#[derive(Debug, Clone)]
29pub struct LookerClaim<'a, Elem> {
30 /// The `n`-coordinate evaluation point of this looker's claim.
31 pub eval_point: &'a [Elem],
32 /// The claimed evaluation of this looker's looked-up vector at the point.
33 pub eval_claim: Elem,
34}
35
36/// One table together with the lookers that read it.
37#[derive(Debug, Clone)]
38pub struct TableLookup<'a, Elem> {
39 /// The number of variables `m` of this table's multilinear (`2^m` entries).
40 pub n_vars: usize,
41 /// The claims of the lookers that read this table. Their evaluation points may differ in
42 /// length, both from each other and from the table.
43 pub lookers: Vec<LookerClaim<'a, Elem>>,
44}
45
46/// Verify a logUp* indexed-lookup reduction over one or more tables, each with its own lookers.
47///
48/// Reduces the claims `(I^* T)(r) = e` to the claims in [`LogupOutput`]. Each table batches its own
49/// lookers by a random linear combination: its challenge `gamma` scales its looker `i`'s numerator
50/// by `gamma^i`. A table's pushforward `Y` is the gamma-weighted sum of its lookers' pushforwards,
51/// and its product check binds `<T, Y>` to the gamma-combination of its lookers' claims. Tables
52/// share nothing but the batching machinery.
53///
54/// Every fractional-addition circuit — one per looker over `n` variables, plus one per table over
55/// `m` — is an instance of **one** GKR of `k + max(max n, max m)` layers, where
56/// `k = ceil(log2(#lookers + #tables))`. Its top `k` layers add the per-instance root fractions
57/// together and its lower layers run the instances in a batch, with every shallower instance padded
58/// by zero fractions — that leaves a fractional sum unchanged and costs `O(1)` per round, so the
59/// layer count depends only on the deepest instance.
60///
61/// No column need agree with any other on a length: an instance over `d` variables is simply padded
62/// by `max(max n, max m) - d`. A looker's reduced index claim lands on the **last `n`** coordinates
63/// of [`LogupOutput::index_eval_point`].
64///
65/// Every table's fraction enters that sum negated, so the circuit's root is
66/// `sum_lookers num/den - sum_tables num/den`, whose numerator vanishes exactly when the lookup
67/// identities hold. The verifier therefore reads only the root *denominator* and supplies the zero
68/// numerator itself: the identities are enforced by the shape of the claim rather than by a
69/// separate check.
70///
71/// # Why one logUp challenge per table
72///
73/// Each table is randomized by its own `c`, so table `t`'s contribution to the root fraction,
74///
75/// ```text
76/// f_t(c_t) = sum_i gamma_t^i sum_x eq_{r_i}(x)/(c_t - I_i(x)) - sum_v Y_t(v)/(c_t - v)
77/// ```
78///
79/// is a rational function of `c_t` alone, vanishing as `c_t` grows. A sum of such functions in
80/// disjoint variables is identically zero only when every term is, so the one root check certifies
81/// every table separately. Under a single shared challenge that argument fails: two tables could
82/// miscount the same position in opposite directions and cancel inside the root numerator.
83///
84/// The logUp challenges are sampled against the committed `I`, `T`, and pushforwards `Y`.
85/// So the caller must absorb those commitments into the transcript before calling this routine, and
86/// must have sampled each table's `gamma` before its pushforward commitment.
87///
88/// # Arguments
89///
90/// * `tables` - One [`TableLookup`] per table, each carrying its batching challenge, its variable
91/// count, and its lookers' claims.
92/// * `channel` - The verifier channel for receiving prover messages and sampling challenges.
93///
94/// # Transcript layout
95///
96/// The prover messages are consumed in this exact order:
97///
98/// ```text
99/// 1. sample c (one logUp challenge per table)
100/// 2. recv den_root (the root denominator; its numerator is 0)
101/// 3. combined GKR, k + max(max n, max m) layers (see fracaddcheck::verify)
102/// 4. recv per-looker index evaluations, then per-table Y (the non-transparent leaf halves)
103/// 5. pushforward reduction:
104/// a. sample batch_coeff
105/// b. max m rounds of degree-2 sumcheck
106/// c. recv per-table [Y, T] (evaluations at the challenge point)
107/// ```
108///
109/// The per-looker index evaluations arrive table by table, in the order the tables are given, and
110/// within a table in its own looker order.
111///
112/// The sumchecks are assumed to bind variables from the highest index to the lowest.
113/// This matches the convention of the fractional-addition GKR layers.
114///
115/// # Preconditions
116///
117/// - `tables` is non-empty, every table has at least one variable so its GKR has a variable to
118/// split on, and every table has at least one looker.
119///
120/// # Returns
121///
122/// The reduced [`LogupOutput`] claims on the tables, pushforwards, and index multilinears.
123///
124/// # Errors
125///
126/// Returns an error when the proof is malformed or any verification identity fails:
127///
128/// - a GKR layer's reduction is inconsistent, which is where a violated lookup identity surfaces,
129/// - the transparent leaf numerators do not interpolate to the batch's leaf numerator,
130/// - the index and table denominators do not interpolate to the batch's leaf denominator,
131/// - the pushforward reduction is inconsistent.
132pub fn verify_reduction<'a, F, C>(
133 gamma: &C::Elem,
134 tables: impl IntoIterator<Item = TableLookup<'a, C::Elem>>,
135 channel: &mut C,
136) -> Result<LogupOutput<C::Elem>, Error>
137where
138 F: Field + ExtensionField<BinaryField1b>,
139 C: IPVerifierChannel<F>,
140 C::Elem: From<F> + 'a,
141{
142 let LogupTransparentOutput {
143 index_eval_point,
144 tables,
145 } = verify_reduction_transparent::<F, C>(gamma, tables, channel)?;
146
147 // Reduce every table's leaf claim on Y_t and its product claim <T_t, Y_t> = e_t to one shared
148 // evaluation point.
149 let table_claims = tables.iter().map(|table| TableClaim {
150 eval_claim: table.product_claim.clone(),
151 pushforward_eval_claim: table.pushforward_eval_claim.clone(),
152 pushforward_eval_point: &table.pushforward_eval_point,
153 });
154 let Pushforward {
155 table_eval_point,
156 table_eval_claims,
157 pushforward_eval_claims,
158 } = verify_pushforward::<F, C>(table_claims, channel)?;
159
160 Ok(LogupOutput {
161 table_eval_point,
162 index_eval_point,
163 tables: izip!(table_eval_claims, pushforward_eval_claims, tables)
164 .map(|(eval_claim, pushforward_claim, table)| LogupTableOutput {
165 eval_claim,
166 pushforward_claim,
167 index_eval_claims: table.index_eval_claims,
168 })
169 .collect(),
170 })
171}
172
173/// Verify a logUp* reduction over transparent tables, leaving the table side open.
174///
175/// The same reduction as [`verify_reduction`], stopped one step short of the pushforward sumcheck.
176/// Each table ends with two claims on its pushforward and none on itself:
177///
178/// ```text
179/// <Y_t, eq_{z_t}> = Y_t(z_t) the fractional-addition leaf claim
180/// <Y_t, T_t> = e_t the product claim
181/// ```
182///
183/// Both are linear relations on the one multilinear `Y_t`.
184/// A caller holding `Y_t` as a committed oracle opens the two together against that commitment.
185/// Skipping the sumcheck drops `max m` rounds of round polynomials and two evaluations per table.
186/// It asks the verifier to evaluate `T_t` itself, so a committed table must use
187/// [`verify_reduction`].
188///
189/// # Arguments
190///
191/// * `gamma` - The looker batching challenge, as in [`verify_reduction`].
192/// * `tables` - One [`TableLookup`] per table.
193/// * `channel` - The verifier channel.
194///
195/// # Transcript layout
196///
197/// Steps 1 to 4 of [`verify_reduction`]'s layout, and nothing after them.
198///
199/// # Soundness
200///
201/// The returned product claims are **unchecked**: this routine never reads a table.
202/// A lookup claim is proved only once its caller opens `<Y_t, T_t> = e_t`.
203/// Everything else — the logUp identity that pins `Y_t` to the pushforward — is checked here.
204///
205/// # Preconditions
206///
207/// The preconditions of [`verify_reduction`].
208///
209/// # Errors
210///
211/// The errors of [`verify_reduction`], less the pushforward reduction, which does not run here.
212pub fn verify_reduction_transparent<'a, F, C>(
213 gamma: &C::Elem,
214 tables: impl IntoIterator<Item = TableLookup<'a, C::Elem>>,
215 channel: &mut C,
216) -> Result<LogupTransparentOutput<C::Elem>, Error>
217where
218 F: Field + ExtensionField<BinaryField1b>,
219 C: IPVerifierChannel<F>,
220 C::Elem: From<F> + 'a,
221{
222 let tables = tables.into_iter().collect::<Vec<_>>();
223 assert!(!tables.is_empty(), "at least one table is required");
224 // Each table-side GKR circuit needs at least one variable to split on.
225 assert!(
226 tables.iter().all(|table| table.n_vars > 0),
227 "every table must have at least one variable"
228 );
229 assert!(
230 tables.iter().all(|table| !table.lookers.is_empty()),
231 "every table must have at least one looker"
232 );
233
234 let n_tables = tables.len();
235 let n_lookers = tables
236 .iter()
237 .map(|table| table.lookers.len())
238 .sum::<usize>();
239 // No column need agree with any other on a length; the batch pads each up to the deepest
240 // instance.
241 let max_n = tables
242 .iter()
243 .flat_map(|table| &table.lookers)
244 .map(|looker| looker.eval_point.len())
245 .max()
246 .expect("every table has at least one looker");
247 let max_m = tables
248 .iter()
249 .map(|table| table.n_vars)
250 .max()
251 .expect("tables is non-empty");
252
253 // Within a table, looker `i` is weighted by gamma^i. The same series serves every table: the
254 // combination only has to bind the lookers inside one table, because the per-table denominator
255 // challenges already separate the tables from each other. So only as many powers are needed as
256 // the largest table has lookers.
257 let max_table_lookers = tables
258 .iter()
259 .map(|table| table.lookers.len())
260 .max()
261 .expect("tables is non-empty");
262 let looker_powers = powers(gamma.clone())
263 .take(max_table_lookers)
264 .collect::<Vec<_>>();
265
266 // Sample one logUp challenge per table. Distinct challenges are what make the single root check
267 // certify every table separately: a table's contribution to the root fraction is a rational
268 // function of its own c alone, so a sum of them vanishes only when each does. Under one shared
269 // challenge two tables' errors could cancel inside the root numerator.
270 let cs = channel.sample_many(n_tables);
271
272 // Read the root denominator. The root numerator is not on the transcript: the whole circuit
273 // sums the looker fractions against the negated table fractions, so its value is zero exactly
274 // when every lookup identity holds, and the verifier supplies that zero itself.
275 let root_den: C::Elem = channel
276 .recv_one()
277 .map_err(|_| VerificationError::TranscriptIsEmpty)?;
278
279 // One GKR over the whole thing, from that single root fraction down to the leaves: k layers
280 // interpolating the per-instance roots, then max(max_n, max_m) more over the instances. Looker
281 // j's tree has depth n_j and table t's tree depth m_t, so every shallower instance is padded by
282 // zero fractions — the layer count reveals only the deepest one.
283 let n_instances = n_lookers + n_tables;
284 let k = n_instances.next_power_of_two().ilog2() as usize;
285 let n_layers = k + max_n.max(max_m);
286 let FracAddEvalClaim {
287 num_eval: leaf_num,
288 den_eval: leaf_den,
289 point: leaf_point,
290 } = fracaddcheck::verify::<F, C>(
291 n_layers,
292 FracAddEvalClaim {
293 num_eval: C::Elem::zero(),
294 den_eval: root_den,
295 point: Vec::new(),
296 },
297 channel,
298 )?;
299
300 // The leaf point splits into the selector coordinates and the shared node point.
301 let (selector_coords, node_point) = leaf_point.split_at(k);
302
303 // Read the claims the verifier cannot derive: the per-looker index evaluations and the
304 // per-table pushforward evaluations.
305 let index_evals: Vec<C::Elem> = channel
306 .recv_many(n_lookers)
307 .map_err(|_| VerificationError::TranscriptIsEmpty)?;
308 let pushforward_evals: Vec<C::Elem> = channel
309 .recv_many(n_tables)
310 .map_err(|_| VerificationError::TranscriptIsEmpty)?;
311
312 // Rebuild each circuit's padded leaf fraction and check they interpolate to the batch's leaf.
313 //
314 // The node point spans max(max_n, max_m) coordinates, so an instance over `d` variables is
315 // padded by `max(max_n, max_m) - d` and its own content is the last `d` coordinates. Padding
316 // scales a numerator by the padding coordinates' equality weight q and sends a denominator
317 // through sel(q, .), so both halves follow from the claims above.
318 let n_node_vars = node_point.len();
319
320 // Every instance's padding weight is a prefix of the node point, so accumulate the prefixes
321 // once: `pad_eqs[p] = eq(0^p; node_point[..p])`. With lookers of differing lengths there is one
322 // weight per distinct depth, and this indexes them all in a single pass.
323 let pad_eqs = iter::once(C::Elem::one())
324 .chain(node_point.iter().scan(C::Elem::one(), |acc, coord| {
325 *acc = acc.clone() * eq_ind_zero(slice::from_ref(coord));
326 Some(acc.clone())
327 }))
328 .collect::<Vec<_>>();
329
330 // Each table's own content is the last m_t coordinates of the node point.
331 let table_points = tables
332 .iter()
333 .map(|table| &node_point[n_node_vars - table.n_vars..])
334 .collect::<Vec<_>>();
335
336 // Looker numerators are transparent: its table's gamma^i scales the equality indicator at r_i.
337 // The denominators are that table's c minus the index evaluation just read. The evaluations
338 // arrive table by table, so they are split back into per-table groups here.
339 let mut index_eval_claims = Vec::with_capacity(n_tables);
340 let mut remaining_index_evals = index_evals.as_slice();
341 let (mut leaf_nums, mut leaf_dens): (Vec<_>, Vec<_>) = izip!(&tables, &cs)
342 .flat_map(|(table, c)| {
343 let (table_evals, rest) = remaining_index_evals.split_at(table.lookers.len());
344 remaining_index_evals = rest;
345 index_eval_claims.push(table_evals.to_vec());
346 izip!(&table.lookers, &looker_powers, table_evals).map(|(looker, power, index_eval)| {
347 let pad = n_node_vars - looker.eval_point.len();
348 let content = &node_point[pad..];
349 let num = power.clone() * eq_ind(looker.eval_point, content);
350 let den = c.clone() - index_eval.clone();
351 fracaddcheck::pad_leaf_fraction((num, den), pad_eqs[pad].clone())
352 })
353 })
354 .unzip();
355
356 // A table's numerator is its Y_t, just read. Its denominator is the transparent J - c_t, the
357 // logUp denominator negated — every table's fraction enters the sum that way, which is what
358 // makes the root numerator vanish.
359 for ((c, point), pushforward_eval) in
360 iter::zip(iter::zip(&cs, &table_points), &pushforward_evals)
361 {
362 let pad = n_node_vars - point.len();
363 let (num, den) = fracaddcheck::pad_leaf_fraction(
364 (pushforward_eval.clone(), denominator_eval::<F, C::Elem>(c, point)),
365 pad_eqs[pad].clone(),
366 );
367 leaf_nums.push(num);
368 leaf_dens.push(den);
369 }
370
371 leaf_nums.resize(1 << k, C::Elem::zero());
372 leaf_dens.resize(1 << k, C::Elem::one());
373 channel
374 .assert_zero(leaf_num - evaluate_inplace_scalars(leaf_nums, selector_coords))
375 .map_err(|_| VerificationError::IncorrectXEvaluation)?;
376 channel
377 .assert_zero(leaf_den - evaluate_inplace_scalars(leaf_dens, selector_coords))
378 .map_err(|_| VerificationError::IncorrectIndexEvaluation)?;
379
380 // Every table's two claims on its pushforward: the leaf claim just read at the leaf point, and
381 // the product claim binding <T_t, Y_t> to the gamma-combination of the claims of the lookers
382 // that read it.
383 let tables = izip!(&tables, pushforward_evals, &table_points, index_eval_claims)
384 .map(|(table, pushforward_eval_claim, &point, index_eval_claims)| {
385 // Its lookers are weighted gamma^0, gamma^1, ..., so the combination is the
386 // univariate evaluation of their claims at gamma.
387 let claims = table
388 .lookers
389 .iter()
390 .map(|looker| looker.eval_claim.clone())
391 .collect::<Vec<_>>();
392 LogupTransparentTableOutput {
393 pushforward_eval_point: point.to_vec(),
394 pushforward_eval_claim,
395 product_claim: evaluate_univariate(&claims, gamma),
396 index_eval_claims,
397 }
398 })
399 .collect();
400
401 Ok(LogupTransparentOutput {
402 // Spans the deepest looker, not the whole node point: when a table is deeper than every
403 // looker its extra coordinates belong to the tables alone. A looker reads the last n.
404 index_eval_point: node_point[n_node_vars - max_n..].to_vec(),
405 tables,
406 })
407}
408
409#[cfg(test)]
410mod tests {
411 use binius_field::{Field, arch::OptimalB128 as B128};
412 use binius_transcript::{ProverTranscript, fiat_shamir::HasherChallenger};
413
414 use super::*;
415
416 type StdChallenger = HasherChallenger<sha2::Sha256>;
417
418 #[test]
419 #[should_panic(expected = "every table must have at least one variable")]
420 fn test_empty_table_panics() {
421 // A zero-variable table has no variable for the GKR circuit to split on.
422 let transcript = ProverTranscript::new(StdChallenger::default());
423 let mut verifier = transcript.into_verifier();
424
425 // The precondition assertion fires before any transcript interaction.
426 let _ = verify_reduction::<B128, _>(
427 &B128::ZERO,
428 [TableLookup {
429 n_vars: 0,
430 lookers: vec![LookerClaim {
431 eval_point: &[],
432 eval_claim: B128::ZERO,
433 }],
434 }],
435 &mut verifier,
436 );
437 }
438
439 #[test]
440 #[should_panic(expected = "every table must have at least one looker")]
441 fn test_table_without_lookers_panics() {
442 // A table nothing reads has no claim to prove, so it does not belong in the batch.
443 let transcript = ProverTranscript::new(StdChallenger::default());
444 let mut verifier = transcript.into_verifier();
445
446 let _ = verify_reduction::<B128, _>(
447 &B128::ZERO,
448 [TableLookup {
449 n_vars: 3,
450 lookers: Vec::new(),
451 }],
452 &mut verifier,
453 );
454 }
455}