Skip to main content

binius_ip_prover/logup_star/
prove.rs

1// Copyright 2026 The Binius Developers
2
3//! The top-level logUp* proving routine.
4
5use std::iter;
6
7use binius_compute::Allocator;
8use binius_field::{BinaryField, Divisible, PackedField};
9use binius_ip::{
10	fracaddcheck::FracAddEvalClaim,
11	logup_star::{
12		LogupOutput, LogupTableOutput, LogupTransparentOutput, LogupTransparentTableOutput,
13	},
14};
15use binius_math::{FieldBuffer, FieldSlice, FieldVec, univariate::evaluate_univariate};
16use binius_utils::{checked_arithmetics::log2_ceil_usize, rayon::prelude::*};
17use itertools::izip;
18
19use super::{
20	pushforward::{PushforwardOutput, TableWitness, prove_pushforward},
21	witness,
22};
23use crate::{
24	channel::IPProverChannel,
25	fracaddcheck::{self, FracAddCircuit, fraction::Fraction, unpad_leaf_claim},
26};
27
28/// One looker's column and claim: `(I^* T)(eval_point) = eval_claim` against the table it reads.
29#[derive(Debug, Clone, Copy)]
30pub struct Looker<'a, F> {
31	/// The index column, one table position per looker row (`2^n` entries).
32	pub index: &'a [usize],
33	/// The `n`-coordinate evaluation point of this looker's claim.
34	pub eval_point: &'a [F],
35	/// The claimed evaluation of this looker's looked-up vector at the point.
36	pub eval_claim: F,
37}
38
39/// One table together with the lookers that read it.
40pub struct TableLookup<'a, P: PackedField> {
41	/// The table multilinear `T` over its `m` variables (`2^m` entries).
42	pub table: FieldSlice<'a, P>,
43	/// The lookers that read this table. Their columns may differ in length, both from each other
44	/// and from the table.
45	pub lookers: Vec<Looker<'a, P::Scalar>>,
46}
47
48/// Prove a logUp* indexed-lookup reduction.
49///
50/// This is the prover for [`binius_ip::logup_star::verify_reduction`].
51/// It produces the transcript the verifier consumes and returns the same reduced claims.
52///
53/// The reduction proves the indexed lookups `(I_j^* T_{t(j)})(r_j) = e_j` for one or more lookers
54/// reading one or more tables. The lookers batch by a random linear combination: a challenge
55/// `gamma` scales looker `j`'s equality-indicator numerator by `gamma^j` across the whole batch,
56/// and table `t`'s pushforward is the gamma-weighted sum of the pushforwards of the lookers that
57/// read it, still with only `2^m_t` entries. The looked-up vectors are never committed. Every
58/// fractional-addition circuit — looker `j`'s over `n_j` variables and table `t`'s over `m_t` — is
59/// an instance of one GKR of `ceil(log2(#lookers + #tables)) + max(max_j n_j, max_t m_t)` layers,
60/// with every shallower instance padded by zero fractions. Neither the lookers nor the tables need
61/// agree on a length.
62///
63/// Each table is randomized by its own logUp challenge `c_t`, which is what makes the single root
64/// check certify every table separately; see [`binius_ip::logup_star::verify_reduction`].
65/// See [Soukhanov25] for the construction.
66///
67/// [Soukhanov25]: <https://eprint.iacr.org/2025/946>
68///
69/// # Arguments
70///
71/// * `tables` - The table multilinears `T_t`, each over its own `m_t` variables.
72/// * `lookers` - The looker columns and claims; each names the table it reads, evaluation points
73///   may differ in length, looker `j`'s index column must have `2^n_j` entries, and every index
74///   entry must be less than the size of the table it reads.
75/// * `channel` - The prover channel for sending messages and sampling challenges.
76///
77/// The logUp challenges are sampled against the committed `I_j`, `T_t`, and pushforwards `Y_t`.
78/// So the caller must absorb those commitments into the transcript before calling this routine.
79///
80/// # Preconditions
81///
82/// - `tables` is non-empty and each has at least one variable, so every table-side GKR has a
83///   variable to split on.
84/// - Every `eval_claim` must equal `(I_j^* T_{t(j)})(r_j)`, or the proof will not verify.
85///
86/// # Returns
87///
88/// The reduced claims on the tables, the pushforwards, and the per-looker index multilinears. The
89/// index claims are drawn from one point spanning the deepest looker; looker `j` is claimed at its
90/// last `n_j` coordinates. The table claims are drawn from one point spanning the widest table;
91/// table `t` is claimed at its first `m_t` coordinates.
92/// The caller verifies those claims, which is out of scope here.
93pub fn prove<'a, A, F, P>(
94	alloc: &A,
95	gamma: F,
96	tables: impl IntoIterator<Item = TableLookup<'a, P>>,
97	channel: &mut impl IPProverChannel<F>,
98) -> LogupOutput<F>
99where
100	A: Allocator,
101	F: BinaryField<Underlier: Divisible<u64>>,
102	P: PackedField<Scalar = F> + 'a,
103{
104	let tables = tables.into_iter().collect::<Vec<_>>();
105
106	// Build the witnesses that do not depend on the logUp challenges. Each table's own gamma scales
107	// its lookers:
108	//
109	//     gamma^i * eq_{r_i} = that table's scaled numerators
110	//     Y = sum_i gamma^i * (I_i)_* eq_{r_i}     that table's pushforward
111	let (numerators, pushforwards) = witness::combined_lookers::<A, F, P>(alloc, gamma, &tables);
112
113	// The self-contained prover commits nothing.
114	// It runs the reduction over the witnesses directly.
115	let pushforward_slices = pushforwards
116		.iter()
117		.map(FieldBuffer::as_view)
118		.collect::<Vec<_>>();
119	prove_reduction(alloc, gamma, &tables, numerators, &pushforward_slices, channel)
120}
121
122/// Prove a logUp* reduction over transparent tables, leaving the table side open.
123///
124/// This is the prover for [`binius_ip::logup_star::verify_reduction_transparent`], the counterpart
125/// of [`prove`] for a caller that evaluates its tables itself. It runs the same reduction and stops
126/// before the pushforward sumcheck, returning each table's two open claims on its pushforward.
127///
128/// # Arguments
129///
130/// The arguments of [`prove`]. The table multilinears are still needed: the reduction reads each
131/// one to build its table-side fractional-addition circuit.
132///
133/// # Preconditions
134///
135/// The preconditions of [`prove`].
136pub fn prove_transparent<'a, A, F, P>(
137	alloc: &A,
138	gamma: F,
139	tables: impl IntoIterator<Item = TableLookup<'a, P>>,
140	channel: &mut impl IPProverChannel<F>,
141) -> LogupTransparentOutput<F>
142where
143	A: Allocator,
144	F: BinaryField<Underlier: Divisible<u64>>,
145	P: PackedField<Scalar = F> + 'a,
146{
147	let tables = tables.into_iter().collect::<Vec<_>>();
148	let (numerators, pushforwards) = witness::combined_lookers::<A, F, P>(alloc, gamma, &tables);
149	let pushforward_slices = pushforwards
150		.iter()
151		.map(FieldBuffer::as_view)
152		.collect::<Vec<_>>();
153	prove_reduction_transparent(alloc, gamma, &tables, numerators, &pushforward_slices, channel)
154}
155
156/// Run the logUp* reduction over the pre-built witnesses `numerators` and pushforwards `Y_t`.
157///
158/// This is the reduction core of [`prove`], split out so a caller can build the `Y_t` once and
159/// commit them. The committing prover builds the numerators and the pushforwards, commits them,
160/// then hands both here. That way each scatter-add runs only once.
161///
162/// # Arguments
163///
164/// * `tables` - One [`TableLookup`] per table, each carrying its batching challenge, its
165///   multilinear, and its lookers.
166/// * `numerators` - The gamma-scaled numerators `gamma^i * eq_{r_i}`, grouped per table in the same
167///   order (see [`witness::combined_lookers`]).
168/// * `pushforwards` - The per-table pushforwards `Y`, the scatter of that table's numerators.
169/// * `channel` - The prover channel.
170///
171/// # Preconditions
172///
173/// - `tables` is non-empty, each has at least one variable, and each has at least one looker.
174/// - `numerators` and `pushforwards` are grouped/ordered to match `tables`, and `Y` has the same
175///   variable count as its table.
176/// - A looker's index column has `2^n` entries for its own point length `n`, with every entry less
177///   than the size of its table.
178/// - Each `Y` equals the scatter of its table's numerators.
179#[tracing::instrument(
180	skip_all,
181	level = "debug",
182	name = "logup* reduction",
183	fields(n_tables = tables.len())
184)]
185pub fn prove_reduction<A, F, P>(
186	alloc: &A,
187	gamma: F,
188	tables: &[TableLookup<'_, P>],
189	numerators: Vec<Vec<FieldVec<P, A>>>,
190	pushforwards: &[FieldSlice<'_, P>],
191	channel: &mut impl IPProverChannel<F>,
192) -> LogupOutput<F>
193where
194	A: Allocator,
195	F: BinaryField<Underlier: Divisible<u64>>,
196	P: PackedField<Scalar = F>,
197{
198	let LogupTransparentOutput {
199		index_eval_point,
200		tables: open_tables,
201	} = prove_reduction_transparent(alloc, gamma, tables, numerators, pushforwards, channel);
202
203	// Reduce every table's leaf claim on Y_t and its product claim <T_t, Y_t> = e_t to one shared
204	// evaluation point.
205	let table_witnesses =
206		izip!(tables, pushforwards, &open_tables).map(|(table, &pushforward, open)| TableWitness {
207			table: table.table,
208			pushforward,
209			eval_claim: open.product_claim,
210			pushforward_eval_claim: open.pushforward_eval_claim,
211			pushforward_eval_point: &open.pushforward_eval_point,
212		});
213	let PushforwardOutput {
214		table_eval_point,
215		table_eval_claims,
216		pushforward_eval_claims,
217	} = prove_pushforward(alloc, table_witnesses, channel);
218
219	LogupOutput {
220		table_eval_point,
221		index_eval_point,
222		tables: izip!(table_eval_claims, pushforward_eval_claims, open_tables)
223			.map(|(eval_claim, pushforward_claim, open)| LogupTableOutput {
224				eval_claim,
225				pushforward_claim,
226				index_eval_claims: open.index_eval_claims,
227			})
228			.collect(),
229	}
230}
231
232/// Run the transparent-table logUp* reduction over the pre-built witnesses.
233///
234/// This is the prover for [`binius_ip::logup_star::verify_reduction_transparent`], and the
235/// reduction core of [`prove_transparent`] the way [`prove_reduction`] is the core of [`prove`].
236/// It stops before the pushforward sumcheck, returning each table's two open claims on its `Y_t`.
237///
238/// # Arguments
239///
240/// The arguments of [`prove_reduction`].
241///
242/// # Preconditions
243///
244/// The preconditions of [`prove_reduction`].
245#[tracing::instrument(
246	skip_all,
247	level = "debug",
248	name = "logup* fracadd reduction",
249	fields(n_tables = tables.len())
250)]
251pub fn prove_reduction_transparent<A, F, P>(
252	alloc: &A,
253	gamma: F,
254	tables: &[TableLookup<'_, P>],
255	numerators: Vec<Vec<FieldVec<P, A>>>,
256	pushforwards: &[FieldSlice<'_, P>],
257	channel: &mut impl IPProverChannel<F>,
258) -> LogupTransparentOutput<F>
259where
260	A: Allocator,
261	F: BinaryField<Underlier: Divisible<u64>>,
262	P: PackedField<Scalar = F>,
263{
264	assert!(!tables.is_empty(), "at least one table is required");
265	// Each table-side GKR circuit needs at least one variable to split on.
266	assert!(
267		tables.iter().all(|table| table.table.log_len() > 0),
268		"every table must have at least one variable"
269	);
270	assert!(
271		tables.iter().all(|table| !table.lookers.is_empty()),
272		"every table must have at least one looker"
273	);
274
275	let n_lookers = tables
276		.iter()
277		.map(|table| table.lookers.len())
278		.sum::<usize>();
279	// One batch instance per looker, plus one per table.
280	let k = log2_ceil_usize(n_lookers + tables.len());
281
282	// Sample one logUp challenge per table, randomizing that table's logarithmic-derivative
283	// denominators. This is the prover's first transcript action, mirroring the verifier.
284	// A committing caller must absorb the I, T, and Y commitments into the transcript before this.
285	let cs = channel.sample_many(tables.len());
286
287	// Build the fractional-addition circuits, one per looker plus one per table. Constructing a
288	// circuit computes every layer and returns its single root fraction.
289	//
290	//     looker j: gamma^j * eq_{r_j}(i) / (c_t - I_j(i))   over n_j variables
291	//     table t:  Y_t(v)                / (c_t - v)        over m_t variables
292	let circuits_guard = tracing::debug_span!("Build fracadd circuits").entered();
293	// The circuits are independent, so they build in parallel across lookers. The looker instances
294	// are laid out table by table, in the order the tables are given, matching the verifier.
295	let flat_lookers = tables
296		.iter()
297		.zip(&cs)
298		.flat_map(|(table, &c)| table.lookers.iter().map(move |looker| (looker, c)))
299		.collect::<Vec<_>>();
300	let flat_numerators = numerators.into_iter().flatten().collect::<Vec<_>>();
301	let (mut provers, mut roots): (Vec<_>, Vec<_>) = (flat_lookers.as_slice(), flat_numerators)
302		.into_par_iter()
303		.map(|(&(looker, c), numerator)| {
304			let den = witness::looker_denominator::<A, F, P>(alloc, c, looker.index);
305			// Lookers may differ in length, so each circuit is built at its own depth.
306			let (prover, root) = FracAddCircuit::build(
307				looker.eval_point.len(),
308				alloc,
309				Fraction::new(numerator, den),
310			);
311			(prover, root.as_ref().map(|buffer| buffer.get(0)))
312		})
313		.unzip();
314	// A table's fraction enters the sum of every instance negated, which is what makes that sum's
315	// numerator vanish. The negation rides on the denominator, which is built here anyway.
316	for ((&c, table), pushforward) in iter::zip(iter::zip(&cs, tables), pushforwards) {
317		let table = table.table;
318		let table_den = witness::table_denominator::<A, F, P>(alloc, c, table.log_len());
319		// The pushforward is borrowed — a committing caller keeps it for the oracle opening — so
320		// the table circuit's leaf layer, which it folds in place, is a clone drawn from `alloc`.
321		let (table_prover, table_root) = FracAddCircuit::build(
322			table.log_len(),
323			alloc,
324			Fraction::new(FieldBuffer::from_view_in(alloc, pushforward.as_view()), table_den),
325		);
326		provers.push(table_prover);
327		roots.push(table_root.as_ref().map(|buffer| buffer.get(0)));
328	}
329
330	// Top circuit: interpolate every instance's root fraction into a multilinear pair over the k
331	// selector variables, padded with the zero fraction. Its own root is the fractional sum of all
332	// of them, and the table denominators are already negated, so that sum is
333	//
334	//     sum_j num_j / den_j  -  sum_t num_t / den_t
335	//
336	// which is zero exactly when the logUp identities hold.
337	let (top_prover, top_root) = FracAddCircuit::build(k, alloc, {
338		let (mut root_nums, mut root_dens): (Vec<_>, Vec<_>) =
339			roots.iter().map(|&root| (root.num, root.den)).unzip();
340		// The slots past the last instance hold the zero fraction, which the sum ignores.
341		root_nums.resize(1 << k, Fraction::ZERO.num);
342		root_dens.resize(1 << k, Fraction::ZERO.den);
343		Fraction::new(
344			FieldBuffer::<P, _>::from_values_in(alloc, &root_nums),
345			FieldBuffer::<P, _>::from_values_in(alloc, &root_dens),
346		)
347	});
348	let root_den = top_root.den.get(0);
349	drop(circuits_guard);
350
351	// A witness satisfying the lookup identity zeroes the root numerator, so it is not sent: the
352	// verifier supplies the zero itself, and a prover whose lookups do not match cannot make the
353	// rest of the GKR agree with it.
354	//
355	// One field comparison against a 2^n circuit, so it runs in every build.
356	// A mismatched witness then fails here instead of as an opaque verification failure later.
357	assert_eq!(
358		top_root.num.get(0),
359		F::ZERO,
360		"the lookup identities must hold: each table's looker fractions must sum to its own"
361	);
362	channel.send_one(root_den);
363
364	// One GKR over the whole thing: k layers of top circuit down to the per-instance roots, then
365	// max(max_n, max_m) more over the instances themselves. Looker j's tree has depth n_j and table
366	// t's tree depth m_t, so the batch pads every shallower instance up — the padding costs O(1)
367	// per round and the layer count depends only on that maximum.
368	let gkr_guard = tracing::debug_span!("Combined GKR").entered();
369	let top_claim = top_prover.prove(
370		FracAddEvalClaim {
371			num_eval: F::ZERO,
372			den_eval: root_den,
373			point: Vec::new(),
374		},
375		channel,
376	);
377	let selector_point = top_claim.point;
378	let fracaddcheck::BatchProveOutput {
379		eval_point,
380		fractions,
381	} = fracaddcheck::batch_prove_unequal_depths(provers, roots, selector_point, channel);
382	drop(gkr_guard);
383
384	// The leaf claims are on the padded witnesses; divide the padding back out to land on each
385	// circuit's own leaves. The node coordinates are the point past the selector ones.
386	let node_point = &eval_point[k..];
387	let n_layers = node_point.len();
388	let (looker_fractions, table_fractions) = fractions.split_at(n_lookers);
389
390	// The per-looker leaf denominators are its table's c minus I(content), so the index claims are
391	// their c-complements. The numerators are the transparent scaled equality indicators, which the
392	// verifier evaluates itself. The claims stay grouped by table, as the output reports them.
393	let mut remaining_fractions = looker_fractions;
394	let index_eval_claims = iter::zip(tables, &cs)
395		.map(|(table, &c)| {
396			let (table_fractions, rest) = remaining_fractions.split_at(table.lookers.len());
397			remaining_fractions = rest;
398			iter::zip(table_fractions, &table.lookers)
399				.map(|(&fraction, looker)| {
400					let leaf =
401						unpad_leaf_claim(fraction, node_point, n_layers - looker.eval_point.len());
402					c - leaf.den_eval
403				})
404				.collect::<Vec<_>>()
405		})
406		.collect::<Vec<_>>();
407	// Every looker's claim is a suffix of the deepest looker's point, so that one carries them all:
408	// a looker's is its last n coordinates. The node point can run deeper still when a table is
409	// the deepest instance, and those extra coordinates belong to the tables alone.
410	let max_n = tables
411		.iter()
412		.flat_map(|table| &table.lookers)
413		.map(|looker| looker.eval_point.len())
414		.max()
415		.expect("every table has at least one looker");
416	let index_eval_point = node_point[n_layers - max_n..].to_vec();
417
418	// A table's leaf claims Y at the point past its own padding; its denominator is the public
419	// J - c, which the verifier checks itself.
420	let table_leaves = iter::zip(table_fractions, tables)
421		.map(|(&fraction, table)| {
422			unpad_leaf_claim(fraction, node_point, n_layers - table.table.log_len())
423		})
424		.collect::<Vec<_>>();
425
426	// Send the claims the verifier cannot derive: the index evaluations and the Y. Together with
427	// the transparent halves they rebuild the batch's leaf claim. The index evaluations go out
428	// table by table, flattened, which is the order the verifier reads them.
429	let pushforward_evals = table_leaves
430		.iter()
431		.map(|leaf| leaf.num_eval)
432		.collect::<Vec<_>>();
433	for claims in &index_eval_claims {
434		channel.send_many(claims);
435	}
436	channel.send_many(&pushforward_evals);
437
438	// Every table's two claims on its pushforward: the leaf claim at the leaf point, and the
439	// product claim binding <T_t, Y_t> to the gamma-combination of its lookers' claims.
440	LogupTransparentOutput {
441		index_eval_point,
442		tables: izip!(tables, table_leaves, index_eval_claims)
443			.map(|(table, leaf, index_eval_claims)| {
444				// Its lookers are weighted gamma^0, gamma^1, ..., so the combination is the
445				// univariate evaluation of their claims at gamma.
446				let claims = table
447					.lookers
448					.iter()
449					.map(|looker| looker.eval_claim)
450					.collect::<Vec<_>>();
451				LogupTransparentTableOutput {
452					pushforward_eval_point: leaf.point,
453					pushforward_eval_claim: leaf.num_eval,
454					product_claim: evaluate_univariate(&claims, &gamma),
455					index_eval_claims,
456				}
457			})
458			.collect(),
459	}
460}
461
462#[cfg(test)]
463mod tests {
464	use binius_compute::GlobalAllocator;
465	use binius_field::{
466		BinaryField1b, ExtensionField, Field,
467		arch::{OptimalB128, OptimalPackedB128},
468		util::powers,
469	};
470	use binius_ip::{channel::IPVerifierChannel, logup_star};
471	use binius_math::{
472		FieldBuffer,
473		inner_product::inner_product_buffers,
474		multilinear::{eq::eq_ind_partial_eval_scalars, evaluate::evaluate},
475		test_utils::{random_field_buffer, random_scalars},
476	};
477	use binius_transcript::{ProverTranscript, fiat_shamir::HasherChallenger};
478	use rand::prelude::*;
479
480	use super::*;
481
482	type F = OptimalB128;
483	type P = OptimalPackedB128;
484	type StdChallenger = HasherChallenger<sha2::Sha256>;
485
486	// Embed a table position j into the field through the GF(2)-linear basis, as the protocol does.
487	//
488	//     iota(j) = sum_{t : bit t of j is set} basis(t)
489	fn iota(j: usize, m: usize) -> F {
490		(0..m)
491			.filter(|t| (j >> t) & 1 == 1)
492			.map(<F as ExtensionField<BinaryField1b>>::basis)
493			.fold(F::ZERO, |acc, b| acc + b)
494	}
495
496	/// One looker of a test instance: its column and its honest claim against its table.
497	struct TestLooker {
498		index: Vec<usize>,
499		eval_point: Vec<F>,
500		eq_r: Vec<F>,
501		eval_claim: F,
502	}
503
504	/// One table of a test instance: its values and the lookers that read it.
505	struct TestTable {
506		values: FieldBuffer<P>,
507		lookers: Vec<TestLooker>,
508	}
509
510	// Draw a looker over `n` variables reading `table_values`, with its honest claim.
511	fn random_looker(rng: &mut StdRng, n: usize, table_values: &FieldBuffer<P>) -> TestLooker {
512		let m = table_values.log_len();
513		let index = (0..(1usize << n))
514			.map(|_| rng.random_range(0..(1usize << m)))
515			.collect::<Vec<_>>();
516		let eval_point = random_scalars::<F>(&mut *rng, n);
517
518		// The looked-up evaluation: e = (I^* T)(r) = sum_i eq_r(i) * T[index[i]].
519		let eq_r = eq_ind_partial_eval_scalars(&eval_point);
520		let eval_claim = index
521			.iter()
522			.zip(&eq_r)
523			.map(|(&j, &eq)| eq * table_values.get(j))
524			.fold(F::ZERO, |acc, t| acc + t);
525
526		TestLooker {
527			index,
528			eval_point,
529			eq_r,
530			eval_claim,
531		}
532	}
533
534	// Build the instance named by `spec`: one entry per table, giving its variable count and the
535	// variable counts of the lookers that read it.
536	fn random_instance(spec: &[(usize, Vec<usize>)], seed: u64) -> Vec<TestTable> {
537		let mut rng = StdRng::seed_from_u64(seed);
538		spec.iter()
539			.map(|(m, looker_n_vars)| {
540				let values = random_field_buffer::<P>(&mut rng, *m);
541				let lookers = looker_n_vars
542					.iter()
543					.map(|&n| random_looker(&mut rng, n, &values))
544					.collect::<Vec<_>>();
545				TestTable { values, lookers }
546			})
547			.collect()
548	}
549
550	// The prover-side witnesses of an instance.
551	fn prover_tables(tables: &[TestTable]) -> Vec<TableLookup<'_, P>> {
552		tables
553			.iter()
554			.map(|table| TableLookup {
555				table: table.values.as_view(),
556				lookers: table
557					.lookers
558					.iter()
559					.map(|looker| Looker {
560						index: &looker.index,
561						eval_point: &looker.eval_point,
562						eval_claim: looker.eval_claim,
563					})
564					.collect(),
565			})
566			.collect()
567	}
568
569	// The verifier-side claims of an instance.
570	fn verifier_tables(tables: &[TestTable]) -> Vec<logup_star::TableLookup<'_, F>> {
571		tables
572			.iter()
573			.map(|table| logup_star::TableLookup {
574				n_vars: table.values.log_len(),
575				lookers: table
576					.lookers
577					.iter()
578					.map(|looker| logup_star::LookerClaim {
579						eval_point: &looker.eval_point,
580						eval_claim: looker.eval_claim,
581					})
582					.collect(),
583			})
584			.collect()
585	}
586
587	// The honest pushforward of one table: its own lookers' numerators, weighted by gamma^i within
588	// the table — the same power series serves every table — scattered onto its cube.
589	fn honest_pushforward(table: &TestTable, gamma: F) -> FieldBuffer<P> {
590		let mut pushforward = vec![F::ZERO; table.values.len()];
591		for (looker, power) in iter::zip(&table.lookers, powers(gamma)) {
592			for (&j, &eq) in iter::zip(&looker.index, &looker.eq_r) {
593				pushforward[j] += power * eq;
594			}
595		}
596		FieldBuffer::from_values(&pushforward)
597	}
598
599	// Check every looker's index claim, given the claims grouped by table.
600	//
601	// The index point spans the deepest looker; a looker's claim is at its last n coordinates, so a
602	// shorter looker reads a suffix of it.
603	fn check_index_claims(
604		index_point: &[F],
605		claims_by_table: &[Vec<F>],
606		tables: &[TestTable],
607		shape: &str,
608	) {
609		for (table_index, (table, claims)) in iter::zip(tables, claims_by_table).enumerate() {
610			let m = table.values.log_len();
611			assert_eq!(claims.len(), table.lookers.len(), "claim count ({shape})");
612			for (looker, claim) in iter::zip(&table.lookers, claims) {
613				let embedded = looker.index.iter().map(|&j| iota(j, m)).collect::<Vec<_>>();
614				let embedded = FieldBuffer::<P>::from_values(&embedded);
615				let own_point = &index_point[index_point.len() - looker.eval_point.len()..];
616				assert_eq!(
617					*claim,
618					evaluate(&embedded, own_point),
619					"index claim wrong for table {table_index}, n={} ({shape})",
620					looker.eval_point.len()
621				);
622			}
623		}
624	}
625
626	/// Round-trip a whole instance and check every reduced claim against the honest witness.
627	///
628	/// `spec` gives one `(table_n_vars, [looker_n_vars])` per table, so one helper covers the
629	/// single-table, multi-looker, unequal-length and multi-table shapes alike.
630	fn check_round_trip(spec: &[(usize, Vec<usize>)], seed: u64) {
631		let alloc = GlobalAllocator;
632		let tables = random_instance(spec, seed);
633		let shape = format!("{spec:?}");
634
635		// Prove, then replay the transcript through the verifier. The batching challenge is the
636		// caller's to sample, before the reduction runs.
637		let mut prover_transcript = ProverTranscript::new(StdChallenger::default());
638		let gamma = IPProverChannel::<F>::sample(&mut prover_transcript);
639		let prover_out = prove::<GlobalAllocator, F, P>(
640			&alloc,
641			gamma,
642			prover_tables(&tables),
643			&mut prover_transcript,
644		);
645
646		let mut verifier_transcript = prover_transcript.into_verifier();
647		let verifier_gamma = IPVerifierChannel::<F>::sample(&mut verifier_transcript);
648		assert_eq!(verifier_gamma, gamma, "both sides must draw the same challenge ({shape})");
649		let verifier_out = logup_star::verify_reduction::<F, _>(
650			&verifier_gamma,
651			verifier_tables(&tables),
652			&mut verifier_transcript,
653		)
654		.expect("verification succeeds");
655
656		// The prover and verifier must derive identical reduced claims from the same transcript.
657		assert_eq!(prover_out, verifier_out, "outputs disagree ({shape})");
658
659		// The table point spans the widest table; table t's claims are at its first m_t
660		// coordinates.
661		let table_point = &prover_out.table_eval_point;
662		for (table_index, table) in tables.iter().enumerate() {
663			let own_point = &table_point[..table.values.log_len()];
664
665			assert_eq!(
666				prover_out.tables[table_index].eval_claim,
667				evaluate(&table.values, own_point),
668				"table claim wrong for table {table_index} ({shape})"
669			);
670			assert_eq!(
671				prover_out.tables[table_index].pushforward_claim,
672				evaluate(&honest_pushforward(table, gamma), own_point),
673				"pushforward claim wrong for table {table_index} ({shape})"
674			);
675		}
676
677		let claims_by_table = prover_out
678			.tables
679			.iter()
680			.map(|table| table.index_eval_claims.clone())
681			.collect::<Vec<_>>();
682		check_index_claims(&prover_out.index_eval_point, &claims_by_table, &tables, &shape);
683	}
684
685	/// Round-trip the transparent-table variant and check both open claims on every pushforward.
686	fn check_transparent_round_trip(spec: &[(usize, Vec<usize>)], seed: u64) {
687		let alloc = GlobalAllocator;
688		let tables = random_instance(spec, seed);
689		let shape = format!("{spec:?}");
690
691		let mut prover_transcript = ProverTranscript::new(StdChallenger::default());
692		let gamma = IPProverChannel::<F>::sample(&mut prover_transcript);
693		let prover_out = prove_transparent::<GlobalAllocator, F, P>(
694			&alloc,
695			gamma,
696			prover_tables(&tables),
697			&mut prover_transcript,
698		);
699
700		let mut verifier_transcript = prover_transcript.into_verifier();
701		let verifier_gamma = IPVerifierChannel::<F>::sample(&mut verifier_transcript);
702		let verifier_out = logup_star::verify_reduction_transparent::<F, _>(
703			&verifier_gamma,
704			verifier_tables(&tables),
705			&mut verifier_transcript,
706		)
707		.expect("verification succeeds");
708		assert_eq!(prover_out, verifier_out, "outputs disagree ({shape})");
709
710		// Both open claims must be true statements about the honest witness, since the caller opens
711		// them against its Y commitment rather than having them checked here.
712		for (table_index, (table, out)) in iter::zip(&tables, &prover_out.tables).enumerate() {
713			let pushforward = honest_pushforward(table, gamma);
714			assert_eq!(
715				out.pushforward_eval_claim,
716				evaluate(&pushforward, &out.pushforward_eval_point),
717				"leaf claim wrong for table {table_index} ({shape})"
718			);
719			assert_eq!(
720				out.product_claim,
721				inner_product_buffers(&table.values, &pushforward),
722				"product claim wrong for table {table_index} ({shape})"
723			);
724		}
725
726		let claims_by_table = prover_out
727			.tables
728			.iter()
729			.map(|table| table.index_eval_claims.clone())
730			.collect::<Vec<_>>();
731		check_index_claims(&prover_out.index_eval_point, &claims_by_table, &tables, &shape);
732	}
733
734	#[test]
735	fn test_prove_verify_round_trip() {
736		// A spread of shapes: m << n (the target regime), m == n, and a wide table.
737		for (n, m) in [(6, 2), (5, 3), (4, 4), (3, 5), (7, 1)] {
738			check_round_trip(&[(m, vec![n])], 0);
739		}
740	}
741
742	#[test]
743	fn test_prove_verify_single_table_variable() {
744		// m = 1 exercises the table side with a single GKR layer and a one-variable reduction.
745		check_round_trip(&[(1, vec![4])], 1);
746	}
747
748	#[test]
749	fn test_prove_verify_single_looker_row() {
750		// n = 0 exercises the looker side with no GKR layers: the root is already the leaf claim.
751		check_round_trip(&[(3, vec![0])], 2);
752	}
753
754	#[test]
755	fn test_multi_looker_round_trip() {
756		// Several lookers sharing one table, the shape the intmul limb columns use.
757		check_round_trip(&[(3, vec![5, 5, 5])], 11);
758	}
759
760	#[test]
761	fn test_lookers_of_unequal_length() {
762		// A table's lookers need not agree on a column length: each is its own instance in the
763		// batch, padded up to the deepest one. The spread puts the table both above and below the
764		// deepest looker, and repeats a length so the shared-depth path is exercised too.
765		for (looker_n_vars, m) in [
766			(vec![5usize, 2, 4], 3usize),
767			(vec![2, 6], 2),
768			(vec![1, 1, 5], 4),
769			(vec![3, 3], 5),
770			(vec![0, 4], 3),
771		] {
772			check_round_trip(&[(m, looker_n_vars)], 17);
773		}
774	}
775
776	#[test]
777	fn test_multi_table_round_trip() {
778		// Several tables of differing sizes, each with its own gamma and its own lookers. The
779		// shapes vary how many lookers a table has, their lengths, and whether the deepest instance
780		// is a table or a looker.
781		for spec in [
782			// Two tables, two lookers each, every column a different length.
783			vec![(3usize, vec![5usize, 3usize]), (2, vec![2, 6])],
784			// Three tables, one looker each, the deepest instance being a table.
785			vec![(4, vec![1]), (2, vec![3]), (5, vec![2])],
786			// A table read by several lookers beside one read by a single looker.
787			vec![(2, vec![4, 4, 2]), (3, vec![5])],
788			// Equal-size tables, the path where no table is padded at all.
789			vec![(3, vec![4]), (3, vec![4]), (3, vec![4])],
790			// A single-row looker against a one-variable table, both extremes at once.
791			vec![(1, vec![0]), (4, vec![3])],
792		] {
793			check_round_trip(&spec, 23);
794		}
795	}
796
797	#[test]
798	fn test_tables_are_independent() {
799		// Each table carries its own gamma and its own logUp challenge, so two tables holding the
800		// same values but different lookers must still both verify.
801		check_round_trip(&[(3, vec![4, 2]), (3, vec![5])], 29);
802	}
803
804	#[test]
805	fn test_prove_verify_transparent_round_trip() {
806		// The transparent variant shares every step but the last, so one spread covers it: m << n
807		// (the target regime), m == n, a wide table, and a one-variable table.
808		for (n, m) in [(6, 2), (4, 4), (3, 5), (7, 1)] {
809			check_transparent_round_trip(&[(m, vec![n])], 0);
810		}
811	}
812
813	#[test]
814	fn test_transparent_multi_table_round_trip() {
815		// Several tables of differing sizes, mixing looker counts and lengths, with the deepest
816		// instance on either side.
817		for spec in [
818			vec![(3usize, vec![5usize, 3usize]), (2, vec![2, 6])],
819			vec![(4, vec![1]), (2, vec![3]), (5, vec![2])],
820			vec![(1, vec![0]), (4, vec![3])],
821		] {
822			check_transparent_round_trip(&spec, 23);
823		}
824	}
825
826	#[test]
827	fn test_transparent_reduction_leaves_the_product_claim_unchecked() {
828		// The transparent reduction never reads a table, so it cannot catch a wrong looked-up
829		// evaluation: only the caller's opening of <T, Y> = e binds it. This pins that contract.
830		let alloc = GlobalAllocator;
831		let tables = random_instance(&[(3, vec![5])], 3);
832		let table = &tables[0];
833		let looker = &table.lookers[0];
834		let wrong_claim = looker.eval_claim + F::ONE;
835
836		let mut prover_transcript = ProverTranscript::new(StdChallenger::default());
837		let gamma = IPProverChannel::<F>::sample(&mut prover_transcript);
838		prove_transparent::<GlobalAllocator, F, P>(
839			&alloc,
840			gamma,
841			[TableLookup {
842				table: table.values.as_view(),
843				lookers: vec![Looker {
844					index: &looker.index,
845					eval_point: &looker.eval_point,
846					eval_claim: wrong_claim,
847				}],
848			}],
849			&mut prover_transcript,
850		);
851
852		let mut verifier_transcript = prover_transcript.into_verifier();
853		let gamma = IPVerifierChannel::<F>::sample(&mut verifier_transcript);
854		let out = logup_star::verify_reduction_transparent::<F, _>(
855			&gamma,
856			[logup_star::TableLookup {
857				n_vars: 3,
858				lookers: vec![logup_star::LookerClaim {
859					eval_point: &looker.eval_point,
860					eval_claim: wrong_claim,
861				}],
862			}],
863			&mut verifier_transcript,
864		)
865		.expect("the reduction itself still verifies");
866
867		// The wrong claim comes straight back out, and it is not the honest inner product — which
868		// is exactly what the caller's opening would reject.
869		assert_eq!(out.tables[0].product_claim, wrong_claim);
870		assert_ne!(
871			out.tables[0].product_claim,
872			inner_product_buffers(&table.values, &honest_pushforward(table, gamma))
873		);
874	}
875
876	#[test]
877	fn test_verifier_rejects_wrong_eval_claim() {
878		let alloc = GlobalAllocator;
879		let tables = random_instance(&[(3, vec![5])], 3);
880		let table = &tables[0];
881		let looker = &table.lookers[0];
882
883		// Prove a false statement by perturbing the looked-up evaluation.
884		let wrong_claim = looker.eval_claim + F::ONE;
885		let mut prover_transcript = ProverTranscript::new(StdChallenger::default());
886		let gamma = IPProverChannel::<F>::sample(&mut prover_transcript);
887		prove::<GlobalAllocator, F, P>(
888			&alloc,
889			gamma,
890			[TableLookup {
891				table: table.values.as_view(),
892				lookers: vec![Looker {
893					index: &looker.index,
894					eval_point: &looker.eval_point,
895					eval_claim: wrong_claim,
896				}],
897			}],
898			&mut prover_transcript,
899		);
900
901		// The product-check inconsistency must surface as a verification failure.
902		let mut verifier_transcript = prover_transcript.into_verifier();
903		let gamma = IPVerifierChannel::<F>::sample(&mut verifier_transcript);
904		let result = logup_star::verify_reduction::<F, _>(
905			&gamma,
906			[logup_star::TableLookup {
907				n_vars: 3,
908				lookers: vec![logup_star::LookerClaim {
909					eval_point: &looker.eval_point,
910					eval_claim: wrong_claim,
911				}],
912			}],
913			&mut verifier_transcript,
914		);
915		assert!(result.is_err(), "verifier must reject a wrong eval claim");
916	}
917
918	#[test]
919	fn test_verifier_rejects_lookup_against_the_wrong_table() {
920		// The prover reads table 0's values but files the looker under table 1. Each table has its
921		// own logUp challenge, so the two fractions cannot cancel in the root sum.
922		let alloc = GlobalAllocator;
923		let tables = random_instance(&[(3, vec![4]), (3, vec![4])], 31);
924		let looker = &tables[0].lookers[0];
925
926		let mut prover_transcript = ProverTranscript::new(StdChallenger::default());
927		let gamma = IPProverChannel::<F>::sample(&mut prover_transcript);
928		// Table 1 gets table 0's honest looker; its own values differ, so the claim is false there.
929		prove::<GlobalAllocator, F, P>(
930			&alloc,
931			gamma,
932			[
933				TableLookup {
934					table: tables[0].values.as_view(),
935					lookers: vec![Looker {
936						index: &tables[0].lookers[0].index,
937						eval_point: &tables[0].lookers[0].eval_point,
938						eval_claim: tables[0].lookers[0].eval_claim,
939					}],
940				},
941				TableLookup {
942					table: tables[1].values.as_view(),
943					lookers: vec![Looker {
944						index: &looker.index,
945						eval_point: &looker.eval_point,
946						eval_claim: looker.eval_claim,
947					}],
948				},
949			],
950			&mut prover_transcript,
951		);
952
953		let mut verifier_transcript = prover_transcript.into_verifier();
954		let gamma = IPVerifierChannel::<F>::sample(&mut verifier_transcript);
955		let result = logup_star::verify_reduction::<F, _>(
956			&gamma,
957			[
958				logup_star::TableLookup {
959					n_vars: 3,
960					lookers: vec![logup_star::LookerClaim {
961						eval_point: &tables[0].lookers[0].eval_point,
962						eval_claim: tables[0].lookers[0].eval_claim,
963					}],
964				},
965				logup_star::TableLookup {
966					n_vars: 3,
967					lookers: vec![logup_star::LookerClaim {
968						eval_point: &looker.eval_point,
969						eval_claim: looker.eval_claim,
970					}],
971				},
972			],
973			&mut verifier_transcript,
974		);
975		assert!(result.is_err(), "verifier must reject a lookup against the wrong table");
976	}
977
978	#[test]
979	#[should_panic(expected = "every table must have at least one variable")]
980	fn test_zero_variable_table_panics() {
981		let mut rng = StdRng::seed_from_u64(0);
982		let alloc = GlobalAllocator;
983
984		// A zero-variable table has a single entry and no variable for the GKR to split on.
985		let table = random_field_buffer::<P>(&mut rng, 0);
986		let mut transcript = ProverTranscript::new(StdChallenger::default());
987		let gamma = IPProverChannel::<F>::sample(&mut transcript);
988		let _ = prove::<GlobalAllocator, F, P>(
989			&alloc,
990			gamma,
991			[TableLookup {
992				table: table.as_view(),
993				lookers: vec![Looker {
994					index: &[0],
995					eval_point: &[],
996					eval_claim: F::ZERO,
997				}],
998			}],
999			&mut transcript,
1000		);
1001	}
1002
1003	#[test]
1004	#[should_panic(expected = "every table must have at least one looker")]
1005	fn test_table_without_lookers_panics() {
1006		let mut rng = StdRng::seed_from_u64(0);
1007		let alloc = GlobalAllocator;
1008
1009		// A table nothing reads has no claim to prove, so it does not belong in the batch.
1010		let table = random_field_buffer::<P>(&mut rng, 3);
1011		let mut transcript = ProverTranscript::new(StdChallenger::default());
1012		let gamma = IPProverChannel::<F>::sample(&mut transcript);
1013		let _ = prove::<GlobalAllocator, F, P>(
1014			&alloc,
1015			gamma,
1016			[TableLookup {
1017				table: table.as_view(),
1018				lookers: Vec::new(),
1019			}],
1020			&mut transcript,
1021		);
1022	}
1023
1024	#[test]
1025	#[should_panic(expected = "index column has 3 entries but 16 were expected for 4 variables")]
1026	fn test_rejects_index_length_mismatch() {
1027		let mut rng = StdRng::seed_from_u64(0);
1028		let alloc = GlobalAllocator;
1029		let table = random_field_buffer::<P>(&mut rng, 3);
1030		let eval_point = random_scalars::<F>(&mut rng, 4);
1031		let mut transcript = ProverTranscript::new(StdChallenger::default());
1032		let gamma = IPProverChannel::<F>::sample(&mut transcript);
1033
1034		// eval_point has 4 coordinates, so the index column must have 2^4 = 16 entries, not 3.
1035		let _ = prove::<GlobalAllocator, F, P>(
1036			&alloc,
1037			gamma,
1038			[TableLookup {
1039				table: table.as_view(),
1040				lookers: vec![Looker {
1041					index: &[0, 1, 2],
1042					eval_point: &eval_point,
1043					eval_claim: F::ZERO,
1044				}],
1045			}],
1046			&mut transcript,
1047		);
1048	}
1049
1050	#[test]
1051	#[should_panic(expected = "every index entry must be less than the size of the table")]
1052	fn test_out_of_range_index_panics() {
1053		let mut rng = StdRng::seed_from_u64(0);
1054		let alloc = GlobalAllocator;
1055		let table = random_field_buffer::<P>(&mut rng, 2);
1056		let eval_point = random_scalars::<F>(&mut rng, 1);
1057		let mut transcript = ProverTranscript::new(StdChallenger::default());
1058		let gamma = IPProverChannel::<F>::sample(&mut transcript);
1059
1060		// Fixture state: the table has 2^2 = 4 positions, addressed 0..=3.
1061		//
1062		// Mutation: row 1 of the looker asks for position 4.
1063		//
1064		//     table positions:  [0, 1, 2, 3]
1065		//     index column:     [0, 4]
1066		//                           ^ one past the end
1067		//
1068		// The scatter that builds the pushforward writes into one slot per table position.
1069		// So the row is rejected whether or not assertions are compiled in.
1070		let _ = prove::<GlobalAllocator, F, P>(
1071			&alloc,
1072			gamma,
1073			[TableLookup {
1074				table: table.as_view(),
1075				lookers: vec![Looker {
1076					index: &[0, 4],
1077					eval_point: &eval_point,
1078					eval_claim: F::ZERO,
1079				}],
1080			}],
1081			&mut transcript,
1082		);
1083	}
1084}