Skip to main content

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}