Skip to main content

binius_ip_prover/prodcheck/
one_pad_mle.rs

1// Copyright 2026 The Binius Developers
2
3//! MLE-check prover for one layer of a one-padded product check.
4//!
5//! One-padding lifts a product tree of depth $k$ to depth $n \ge k$ by filling the extra leaves
6//! with ones, which leaves the tree's product unchanged. Batching product checks of unequal depths
7//! pads each shallow tree up to the deepest one, so the batch's layer loop runs a single uniform
8//! schedule and the verifier never learns the individual depths. See the *Batched Product Checks of
9//! Unequal Depths* appendix of the Binius64 whitepaper for the protocol and the derivation behind
10//! the round polynomials below.
11//!
12//! The point of this module is that the prover never materializes a padded layer:
13//! [`OnePadMleCheckProver`] wraps the unpadded layer's own MLE-check and corrects its messages at a
14//! cost of $O(1)$ per round.
15
16use std::{iter, mem};
17
18use binius_compute::Allocator;
19use binius_field::{Field, PackedField};
20use binius_ip::sumcheck::RoundCoeffs;
21use binius_math::{FieldVec, multilinear::eq::eq_one_var};
22
23use crate::sumcheck::{bivariate_product_mle, common::MleCheckProver};
24
25/// The one-padding selector $\textsf{sel}(s, v) = 1 + (v - 1) s$.
26///
27/// It interpolates between the constant one at $s = 0$ and $v$ at $s = 1$, which is how a padded
28/// leaf position holds a one while a real one holds the witness value.
29fn select<F: Field>(s: F, v: F) -> F {
30	F::ONE + (v - F::ONE) * s
31}
32
33/// MLE-check prover for one layer of a product check over a one-padded witness.
34///
35/// The tree's product is a scalar, so the layer's claim point is node coordinates only, split into
36/// two segments with the padding ones lowest:
37///
38/// ```text
39///     [ padding (nu) | real (m) ]
40/// ```
41///
42/// The padded layer is the unpadded one wrapped in the one-padding
43/// $\textsf{sel}(\textsf{eq}(0^\nu, Z'), \cdot)$ over the padding variables. MLE-check binds
44/// variables from the highest index down, so the real rounds come first and the padding rounds
45/// last:
46///
47/// - **Real rounds.** Delegate to `inner`, the ordinary MLE-check over the unpadded layer, and
48///   correct each round polynomial by the affine map $R'(X) = 1 + q \cdot (R(X) - 1)$, where $q$ is
49///   the equality weight $\textsf{eq}(0^\nu, \rho_\text{pa})$ of the claim point's padding segment.
50///   Off the all-zeros padding slab both children are one, which is where the residual weight $1 -
51///   q$ comes from.
52/// - **Padding rounds.** No multilinear is touched. Every real variable is bound by now, so
53///   `inner`'s two child evaluations $g_0, g_1$ are scalars and the round polynomial is the closed
54///   form $R'(X) = (1 - e) + e \cdot \textsf{sel}(E(X), g_0) \cdot \textsf{sel}(E(X), g_1)$, where
55///   $e$ is the equality weight of the padding coordinates still unbound and $E(X)$ that of the
56///   ones already bound.
57///
58/// Finishing returns the *padded* layer's child evaluations $\textsf{sel}(e^*, g_b)$, which is
59/// what the batch's selector rounds consume.
60pub struct OnePadMleCheckProver<F: Field, Inner> {
61	/// The padded claim point `[padding | real]`, low variables first.
62	eval_point: Vec<F>,
63	/// Length of the point's padding segment.
64	pad_len: usize,
65	/// Number of folds performed so far.
66	round: usize,
67	/// Equality weights of the claim point's padding segment: entry `i` is
68	/// $\prod_{c < i} \textsf{eq}(0, \rho_{\text{pa}, c})$, so the last entry is $q$.
69	pad_eq_prefixes: Vec<F>,
70	phase: Phase<F, Inner>,
71}
72
73/// The segment of rounds the prover is in. See [`OnePadMleCheckProver`].
74enum Phase<F, Inner> {
75	/// Reducing the unpadded layer's real node variables.
76	Real(Inner),
77	/// Every real variable is bound, leaving a closed form in these scalars.
78	Padding {
79		/// The unpadded layer's two child evaluations.
80		children: [F; 2],
81		/// $\prod \textsf{eq}(0, r)$ over the padding challenges bound so far, which is the
82		/// constant factor of $E$.
83		bound_eq: F,
84	},
85}
86
87/// Creates the prover for one padded product-check layer.
88///
89/// # Arguments
90///
91/// * `layer` - The unpadded child layer, whose low and high halves on its highest variable are the
92///   two multilinears whose product this layer reduces.
93/// * `pad_len` - Length of `eval_point`'s padding segment. Zero leaves the inner reduction
94///   uncorrected.
95/// * `eval_point` - The padded layer's claim point, `[padding | real]`.
96/// * `claim` - The padded layer's claimed evaluation at `eval_point`.
97///
98/// # Preconditions
99///
100/// * `layer.log_len() >= 1`
101/// * `eval_point.len() == layer.log_len() - 1 + pad_len`
102///
103/// # Panics
104///
105/// Panics if the padding segment's equality weight $q$ is zero, which requires one of its
106/// coordinates — all verifier challenges — to equal one, and so happens with probability at most
107/// $\nu / |K|$.
108pub fn new<'alloc, A, F, P>(
109	alloc: &'alloc A,
110	layer: FieldVec<P, A>,
111	pad_len: usize,
112	eval_point: Vec<F>,
113	claim: F,
114) -> OnePadMleCheckProver<F, impl MleCheckProver<F> + 'alloc>
115where
116	A: Allocator,
117	F: Field,
118	P: PackedField<Scalar = F>,
119{
120	assert!(layer.log_len() >= 1); // precondition
121	let n_real_rounds = layer.log_len() - 1;
122	assert_eq!(eval_point.len(), pad_len + n_real_rounds); // precondition
123
124	// Prefix products over the padding segment, so both the round polynomials' `e` factors and the
125	// claim's `q` are lookups.
126	let pad_eq_prefixes = iter::once(F::ONE)
127		.chain(eval_point[..pad_len].iter().scan(F::ONE, |acc, &coord| {
128			*acc *= eq_one_var(F::ZERO, coord);
129			Some(*acc)
130		}))
131		.collect::<Vec<_>>();
132	let pad_eq = pad_eq_prefixes[pad_len];
133	assert!(pad_eq != F::ZERO, "a padding coordinate of the claim point equals one");
134
135	// The padded claim is `sel(q, z)` for the unpadded claim `z`, so the inner prover starts from
136	// the preimage.
137	let inner_claim = F::ONE + (claim - F::ONE) * pad_eq.invert_or_zero();
138	let inner = bivariate_product_mle::new_split_half(
139		alloc,
140		layer,
141		eval_point[pad_len..].to_vec(),
142		inner_claim,
143	);
144
145	let mut prover = OnePadMleCheckProver {
146		eval_point,
147		pad_len,
148		round: 0,
149		pad_eq_prefixes,
150		phase: Phase::Real(inner),
151	};
152	// A layer with no real variables starts in the padding phase.
153	prover.advance();
154	prover
155}
156
157impl<F: Field, Inner: MleCheckProver<F>> OnePadMleCheckProver<F, Inner> {
158	/// The number of rounds that reduce the unpadded layer's real variables.
159	const fn n_real_rounds(&self) -> usize {
160		self.eval_point.len() - self.pad_len
161	}
162
163	/// Finishes the inner prover once its last real variable is bound, fixing the child evaluations
164	/// the padding rounds close over.
165	fn advance(&mut self) {
166		if self.round != self.n_real_rounds() || !matches!(self.phase, Phase::Real(_)) {
167			return;
168		}
169		// The guard above pins the phase, so this placeholder is overwritten before it is read.
170		let placeholder = Phase::Padding {
171			children: [F::ONE; 2],
172			bound_eq: F::ONE,
173		};
174		let Phase::Real(inner) = mem::replace(&mut self.phase, placeholder) else {
175			unreachable!("the guard checked the phase");
176		};
177		self.phase = Phase::Padding {
178			children: inner
179				.finish()
180				.try_into()
181				.expect("the layer prover reduces two multilinears"),
182			bound_eq: F::ONE,
183		};
184	}
185}
186
187impl<F: Field, Inner: MleCheckProver<F>> MleCheckProver<F> for OnePadMleCheckProver<F, Inner> {
188	fn n_vars(&self) -> usize {
189		self.eval_point.len() - self.round
190	}
191
192	fn execute(&mut self) -> Vec<RoundCoeffs<F>> {
193		// Destructured so a padding round can read the prefix table while the phase is borrowed.
194		let Self {
195			eval_point,
196			pad_len,
197			round,
198			pad_eq_prefixes,
199			phase,
200		} = self;
201		let n_vars = eval_point.len() - *round;
202
203		let coeffs = match phase {
204			Phase::Real(inner) => {
205				let mut round_coeffs = inner.execute();
206				assert_eq!(round_coeffs.len(), 1, "the layer prover carries one claim");
207				let mut coeffs = round_coeffs.pop().expect("the vector holds one element");
208				// R'(X) = 1 + q * (R(X) - 1): on the all-zeros padding slab the sum is the unpadded
209				// one, weighted by q; off it both children are one and the weights sum to 1 - q.
210				let pad_eq = pad_eq_prefixes[*pad_len];
211				coeffs *= pad_eq;
212				coeffs.0[0] += F::ONE - pad_eq;
213				coeffs
214			}
215			Phase::Padding { children, bound_eq } => {
216				// The equality weight of the padding coordinates still unbound below this round's.
217				let unbound_eq = pad_eq_prefixes[n_vars - 1];
218				// E(X) = bound_eq * eq(0, X) in monomial coefficients, then each child pushed
219				// through the one-padding selector at E(X).
220				let big_e = [*bound_eq, -*bound_eq];
221				let [[a_0, a_1], [b_0, b_1]] =
222					children.map(|child| [select(big_e[0], child), (child - F::ONE) * big_e[1]]);
223				// R'(X) = (1 - e) + e * sel(E(X), g_0) * sel(E(X), g_1): off the all-zeros slab of
224				// those unbound coordinates both children are one.
225				RoundCoeffs(vec![
226					F::ONE - unbound_eq + unbound_eq * a_0 * b_0,
227					unbound_eq * (a_0 * b_1 + a_1 * b_0),
228					unbound_eq * a_1 * b_1,
229				])
230			}
231		};
232		vec![coeffs]
233	}
234
235	fn fold(&mut self, challenge: F) {
236		match &mut self.phase {
237			Phase::Real(inner) => inner.fold(challenge),
238			Phase::Padding { bound_eq, .. } => {
239				*bound_eq *= eq_one_var(F::ZERO, challenge);
240			}
241		}
242		self.round += 1;
243		self.advance();
244	}
245
246	fn finish(self) -> Vec<F> {
247		match self.phase {
248			Phase::Padding { children, bound_eq } => {
249				children.map(|child| select(bound_eq, child)).to_vec()
250			}
251			Phase::Real(_) => panic!("finish requires every variable to be bound"),
252		}
253	}
254
255	fn eval_point(&self) -> &[F] {
256		&self.eval_point[..self.n_vars()]
257	}
258}
259
260// The prover is checked against the padded layer it stands in for: the same reduction run by an
261// ordinary bivariate-product MLE-check over an explicitly materialized one-padded layer must
262// produce the same round polynomials and the same child evaluations.
263#[cfg(test)]
264mod tests {
265	use binius_compute::GlobalAllocator;
266	use binius_field::{Random, field::FieldOps};
267	use binius_math::{
268		FieldBuffer,
269		multilinear::evaluate::evaluate,
270		test_utils::{Packed128b, random_field_buffer, random_scalars},
271	};
272	use rand::prelude::*;
273
274	use super::*;
275
276	type P = Packed128b;
277	type F = <P as FieldOps>::Scalar;
278
279	/// Materializes `OnePad_{pad_len}` of a layer, whose variables are `[real | split]`.
280	///
281	/// The padding variables land below the real ones, matching the claim-point layout [`new`]
282	/// expects.
283	fn one_pad_layer(layer: &FieldBuffer<P>, pad_len: usize) -> FieldBuffer<P> {
284		let values = (0..1 << (layer.log_len() + pad_len))
285			.map(|index| {
286				let padding = index & ((1 << pad_len) - 1);
287				if padding == 0 {
288					layer.get(index >> pad_len)
289				} else {
290					F::ONE
291				}
292			})
293			.collect::<Vec<_>>();
294		FieldBuffer::from_values(&values)
295	}
296
297	/// The bivariate-product MLE-check claim on a buffer's two halves at `eval_point`.
298	fn split_half_claim(buffer: &FieldBuffer<P>, eval_point: &[F]) -> F {
299		let (low, high) = buffer.split_half();
300		let products = (0..low.len())
301			.map(|i| low.get(i) * high.get(i))
302			.collect::<Vec<_>>();
303		evaluate(&FieldBuffer::<P>::from_values(&products), eval_point)
304	}
305
306	/// Runs the padded prover and the reference prover over the materialized padded layer in
307	/// lockstep.
308	fn assert_matches_padded_reference(layer: FieldBuffer<P>, pad_len: usize) {
309		let mut rng = StdRng::seed_from_u64(0);
310		let alloc = GlobalAllocator;
311
312		let padded_layer = one_pad_layer(&layer, pad_len);
313		let n_vars = padded_layer.log_len() - 1;
314		let eval_point = random_scalars::<F>(&mut rng, n_vars);
315		let claim = split_half_claim(&padded_layer, &eval_point);
316
317		let mut reference =
318			bivariate_product_mle::new_split_half(&alloc, padded_layer, eval_point.clone(), claim);
319		let mut prover = new(&alloc, layer, pad_len, eval_point, claim);
320
321		for round in 0..n_vars {
322			assert_eq!(prover.n_vars(), n_vars - round);
323			assert_eq!(prover.eval_point(), reference.eval_point());
324			assert_eq!(prover.execute(), reference.execute(), "round {round}");
325
326			let challenge = F::random(&mut rng);
327			prover.fold(challenge);
328			reference.fold(challenge);
329		}
330
331		assert_eq!(prover.finish(), reference.finish());
332	}
333
334	#[test]
335	fn matches_padded_reference() {
336		let mut rng = StdRng::seed_from_u64(1);
337		for n_real_rounds in [0, 1, 3] {
338			for pad_len in [0, 1, 3] {
339				let layer = random_field_buffer::<P>(&mut rng, n_real_rounds + 1);
340				assert_matches_padded_reference(layer, pad_len);
341			}
342		}
343	}
344
345	// The layers a shallow tree spends while the batch is still above it are one-paddings of its
346	// product, whose high child is identically one. That degenerate shape is what
347	// `batch_prove_unequal_depths` feeds in for those layers.
348	#[test]
349	fn matches_padded_reference_with_constant_one_child() {
350		let mut rng = StdRng::seed_from_u64(2);
351		for pad_len in [1, 2, 4] {
352			let product = F::random(&mut rng);
353			let layer = FieldBuffer::<P>::from_values(&[product, F::ONE]);
354			assert_matches_padded_reference(layer, pad_len);
355		}
356	}
357}