Skip to main content

binius_math/
univariate.rs

1// Copyright 2024-2025 Irreducible Inc.
2// Copyright 2026 The Binius Developers
3
4//! Univariate polynomials, in the two forms a protocol carries them in.
5//!
6//! ```text
7//!     coefficients   ->   evaluate_univariate      Horner over the monomial basis
8//!     evaluations    ->   BinarySubspace methods   Lagrange interpolation over a domain
9//! ```
10//!
11//! The evaluation domain is always a [`BinarySubspace`], and that is what makes the second form
12//! cheap.
13//!
14//! A subspace is an additive group, so every one of its points shares a single barycentric weight.
15//! The usual Lagrange formula needs one weight per point, and one inversion to build each.
16//! Here there is one weight and one inversion, whatever the domain size.
17
18use std::{iter, mem, ops::Deref};
19
20use binius_field::{BinaryField, field::FieldOps};
21use itertools::izip;
22
23use super::{BinarySubspace, FieldBuffer};
24
25/// Evaluates a univariate polynomial given by its monomial coefficients.
26///
27/// Most callers reach for this to batch several claims under one challenge.
28/// Reading `coeffs` as the claims and `x` as the challenge, the result is `sum_i claim_i * x^i`.
29///
30/// # Arguments
31///
32/// * `coeffs` - coefficients ordered from the low-degree term to the high-degree term
33/// * `x` - the point to evaluate at
34pub fn evaluate_univariate<F: FieldOps>(coeffs: &[F], x: &F) -> F {
35	let Some((highest_degree, rest)) = coeffs.split_last() else {
36		return F::zero();
37	};
38
39	// Horner's method, from the highest-degree coefficient down.
40	rest.iter()
41		.rev()
42		.fold(highest_degree.clone(), |acc, coeff| acc * x + coeff)
43}
44
45/// Lagrange interpolation over a domain of `2^dim` points.
46///
47/// Bring this into scope to read a [`BinarySubspace`] as the domain a polynomial is given on.
48///
49/// Every method carries two field parameters:
50///
51/// ```text
52///     F   the domain's own field, where the points live
53///     E   the field the arithmetic runs in, which F embeds into
54/// ```
55///
56/// A native verifier takes `E = F`.
57/// A recursion verifier takes `E` to be its channel's element type, so the same code builds a
58/// circuit.
59pub trait EvaluationDomain<F: BinaryField> {
60	/// The Lagrange basis evaluated at `z`, one value per domain point.
61	///
62	/// Entry `i` is `L_i(z) = w * prod_{j != i} (z - d_j)`, for the shared weight `w`.
63	fn lagrange_evals<E: FieldOps + From<F>>(&self, z: &E) -> Vec<E>;
64
65	/// The Lagrange basis at `z`, packed into a buffer instead of a vector.
66	///
67	/// Same values as [`Self::lagrange_evals`], for callers that feed a buffer-shaped consumer.
68	fn lagrange_evals_buffer(&self, z: F) -> FieldBuffer<F>;
69
70	/// Evaluates at `z` the polynomial that takes `values` on this domain.
71	///
72	/// This is the inner product of `values` with [`Self::lagrange_evals`], without building that
73	/// vector:
74	///
75	/// ```text
76	///     f(z) = w * sum_i values_i * prod_{j != i} (z - d_j)
77	/// ```
78	///
79	/// # Panics
80	///
81	/// Panics unless `values` holds one entry per domain point.
82	fn extrapolate<E: FieldOps + From<F>>(&self, values: &[E], z: &E) -> E;
83}
84
85impl<F: BinaryField, Data: Deref<Target = [F]>> EvaluationDomain<F> for BinarySubspace<F, Data> {
86	/// Two sweeps build every entry without ever dividing:
87	///
88	/// ```text
89	///     backward:   r_i <- w * prod_{j > i} (z - d_j)
90	///     forward:    r_i <- r_i * prod_{j < i} (z - d_j)
91	/// ```
92	///
93	/// That is about `4n` multiplications and the single inversion the weight costs.
94	fn lagrange_evals<E: FieldOps + From<F>>(&self, z: &E) -> Vec<E> {
95		// Seed the output with the linear terms t_i = z - d_i.
96		let mut result: Vec<E> = self.iter().map(|d| z.clone() - E::from(d)).collect();
97
98		// Backward sweep: replace t_i with w * prod_{j > i} t_j.
99		// Seeding the accumulator with the weight absorbs the multiply-by-w pass.
100		let mut suffix = barycentric_weight::<F, E, Data>(self);
101		for r_i in result.iter_mut().rev() {
102			let t_i = mem::replace(r_i, suffix.clone());
103			suffix *= t_i;
104		}
105
106		// Forward sweep: multiply in prefix_i = prod_{j < i} t_j, completing
107		//
108		//     L_i(z) = w * prod_{j > i} t_j * prod_{j < i} t_j = w * prod_{j != i} (z - d_j).
109		//
110		// The terms are recomputed on the fly; iterating the subspace is a cheap XOR walk.
111		let mut prefix = E::one();
112		for (r_i, d) in iter::zip(&mut result, self.iter()) {
113			*r_i *= prefix.clone();
114			prefix *= z.clone() - E::from(d);
115		}
116
117		result
118	}
119
120	fn lagrange_evals_buffer(&self, z: F) -> FieldBuffer<F> {
121		FieldBuffer::new(self.dim(), self.lagrange_evals(&z))
122	}
123
124	/// One prefix-product accumulator carries the whole sum in a single pass, so the extra space
125	/// is constant rather than `O(n)`.
126	fn extrapolate<E: FieldOps + From<F>>(&self, values: &[E], z: &E) -> E {
127		assert_eq!(
128			values.len(),
129			1 << self.dim(),
130			"precondition: values must hold one entry per domain point"
131		);
132
133		// Fold sum_i values_i * prod_{j != i} (z - d_j), carrying the running prefix product.
134		let (acc, _) = izip!(values, self.iter()).fold(
135			(E::zero(), E::one()),
136			|(acc, prod), (value, point)| {
137				let term = z.clone() - E::from(point);
138				let next_acc = acc * &term + prod.clone() * value;
139				(next_acc, prod * term)
140			},
141		);
142
143		acc * barycentric_weight::<F, E, Data>(self)
144	}
145}
146
147/// The barycentric weight shared by every point of a binary subspace.
148///
149/// The usual weight at point `d_i` is `prod_{j != i} (d_i - d_j)^{-1}`.
150///
151/// Subtracting `d_i` permutes the subspace, so that product runs over the non-zero elements
152/// whichever `i` it started from.
153///
154/// One weight therefore serves every point:
155///
156/// ```text
157///     w = (prod_{d != 0} d)^{-1}
158/// ```
159///
160/// # Algorithm
161///
162/// That product is the linear coefficient of the subspace polynomial, and the subspace polynomial
163/// has a recurrence:
164///
165/// ```text
166///     W_0(X)     = X
167///     W_{i+1}(X) = W_i(X) * (W_i(X) + W_i(b_i))
168/// ```
169///
170/// Squaring a linearized polynomial doubles every exponent, so it contributes no linear term.
171/// Each step therefore multiplies the linear coefficient by one number, leaving
172///
173/// ```text
174///     prod_{d != 0} d = prod_i W_i(b_i)
175/// ```
176///
177/// which costs a square of the dimension rather than one multiplication per point of the domain.
178///
179/// The weight depends on the subspace alone, so all of it runs in the domain's own field and
180/// crosses into the arithmetic field once.
181/// That is what allows the checked inversion below: one wrapper channel's element type offers the
182/// unchecked inverse alone.
183///
184/// # Panics
185///
186/// Panics if the basis is linearly dependent, which makes the product vanish.
187fn barycentric_weight<F, E, Data>(subspace: &BinarySubspace<F, Data>) -> E
188where
189	F: BinaryField,
190	E: FieldOps + From<F>,
191	Data: Deref<Target = [F]>,
192{
193	// Seed the recurrence at the polynomial `X`, whose values on the basis are the basis itself.
194	let mut evals = subspace.basis().to_vec();
195
196	let mut product = F::ONE;
197	for i in 0..evals.len() {
198		// Entry `i` has reached the polynomial vanishing on everything below it, so it is the
199		// factor this step contributes.
200		let normalizer = evals[i];
201		product *= normalizer;
202
203		// Advance the entries still to come one polynomial along.
204		for eval in &mut evals[i + 1..] {
205			*eval *= *eval + normalizer;
206		}
207	}
208
209	// Invariant: each factor is a subspace polynomial evaluated off the subspace it vanishes on,
210	// which is nonzero exactly when the basis is independent. The subspace type leaves that to its
211	// caller, so it is checked here rather than assumed.
212	assert_ne!(product, F::ZERO, "precondition: the subspace basis must be independent");
213
214	E::from(product.invert_or_zero())
215}
216
217#[cfg(test)]
218mod tests {
219	use binius_field::{
220		Field, Ghash128b, Random, Rijndael8b, arithmetic_traits::InvertOrZero, util::powers,
221	};
222	use proptest::prelude::*;
223	use rand::prelude::*;
224
225	use super::*;
226	use crate::{
227		BinarySubspace,
228		inner_product::inner_product,
229		test_utils::{B128, random_scalars},
230	};
231
232	type F = Ghash128b;
233
234	/// The definition of [`evaluate_univariate`], written out as a sum over powers.
235	fn evaluate_univariate_with_powers<F: Field>(coeffs: &[F], x: F) -> F {
236		inner_product(coeffs.iter().copied(), powers(x).take(coeffs.len()))
237	}
238
239	/// The textbook Lagrange basis: one weight per point, each built with its own inversion.
240	///
241	/// This is what the subspace methods collapse to a single shared weight, so it is the
242	/// reference the collapse is pinned against.
243	fn lagrange_evals_reference<F: Field>(domain: &[F], z: F) -> Vec<F> {
244		domain
245			.iter()
246			.map(|&d_i| {
247				let denominator: F = domain
248					.iter()
249					.filter(|&&d_j| d_j != d_i)
250					.map(|&d_j| d_i - d_j)
251					.product();
252				let numerator: F = domain
253					.iter()
254					.filter(|&&d_j| d_j != d_i)
255					.map(|&d_j| z - d_j)
256					.product();
257				numerator * denominator.invert_or_zero()
258			})
259			.collect()
260	}
261
262	#[test]
263	fn the_weight_recurrence_matches_the_product_over_the_subspace() {
264		// Invariant: the weight is the inverse of the product of every nonzero point of the
265		// domain. That product is what the weight is defined as, so it is the reference here.
266		//
267		// Fixture state: dim 0 is the one-point domain, whose empty product is one.
268		// Dim 8 is 255 factors, enough that a wrong recurrence cannot coincide.
269		for dim in 0..=8 {
270			let subspace = BinarySubspace::<F>::with_dim(dim);
271
272			let product: F = subspace.iter().skip(1).product();
273			let expected = product.invert_or_zero();
274
275			assert_eq!(barycentric_weight::<F, F, _>(&subspace), expected, "dim={dim}");
276		}
277	}
278
279	#[test]
280	fn the_weight_crosses_fields_once_and_lands_on_the_same_value() {
281		// Invariant: which field the arithmetic runs in cannot change the weight.
282		//
283		// The recurrence runs entirely in the domain's own field and embeds its result, so this
284		// pins that the embedding lands on the finished weight rather than partway through.
285		for dim in 0..=6 {
286			let subspace = BinarySubspace::<Rijndael8b>::with_dim(dim);
287
288			let native = barycentric_weight::<Rijndael8b, Rijndael8b, _>(&subspace);
289			let embedded = barycentric_weight::<Rijndael8b, B128, _>(&subspace);
290
291			assert_eq!(embedded, B128::from(native), "dim={dim}");
292		}
293	}
294
295	#[test]
296	#[should_panic(expected = "precondition")]
297	fn a_dependent_basis_is_rejected() {
298		// A repeated basis element spans fewer dimensions than the basis claims, so some index
299		// past zero also maps to the zero point and the product vanishes.
300		let subspace = BinarySubspace::<F>::new_unchecked(vec![F::ONE, F::ONE]);
301		let _ = barycentric_weight::<F, F, _>(&subspace);
302	}
303
304	#[test]
305	fn evaluate_univariate_matches_the_sum_over_powers() {
306		let mut rng = StdRng::seed_from_u64(0);
307
308		// An empty coefficient slice is the zero polynomial, which the fold has to special-case.
309		for n_coeffs in [0, 1, 2, 5, 10] {
310			let coeffs = random_scalars(&mut rng, n_coeffs);
311			let x = F::random(&mut rng);
312			assert_eq!(
313				evaluate_univariate(&coeffs, &x),
314				evaluate_univariate_with_powers(&coeffs, x)
315			);
316		}
317	}
318
319	#[test]
320	fn lagrange_evals_is_the_dual_basis_of_the_domain() {
321		let mut rng = StdRng::seed_from_u64(0);
322
323		// A one-point domain is the boundary: the basis is the constant 1, with no other point to
324		// divide against.
325		for dim in 0..=4 {
326			let subspace = BinarySubspace::<F>::with_dim(dim);
327			let domain: Vec<F> = subspace.iter().collect();
328
329			// The basis sums to one everywhere, since it interpolates the constant polynomial 1.
330			let evals = subspace.lagrange_evals(&F::random(&mut rng));
331			assert_eq!(evals.iter().copied().sum::<F>(), F::ONE, "partition of unity at dim={dim}");
332
333			// L_i(d_j) is one when i == j and zero otherwise.
334			for (j, &d_j) in domain.iter().enumerate() {
335				let at_domain = subspace.lagrange_evals(&d_j);
336				for (i, &value) in at_domain.iter().enumerate() {
337					let expected = if i == j { F::ONE } else { F::ZERO };
338					assert_eq!(value, expected, "L_{i}({j}) at dim={dim}");
339				}
340			}
341		}
342	}
343
344	#[test]
345	fn lagrange_evals_buffer_holds_what_lagrange_evals_returns() {
346		let mut rng = StdRng::seed_from_u64(0);
347
348		// The buffer variant is a repack, so it must agree entry for entry and carry the dimension.
349		for dim in 0..=4 {
350			let subspace = BinarySubspace::<F>::with_dim(dim);
351			let z = F::random(&mut rng);
352
353			let buffer = subspace.lagrange_evals_buffer(z);
354			assert_eq!(buffer.log_len(), dim);
355			assert_eq!(buffer.iter_scalars().collect::<Vec<_>>(), subspace.lagrange_evals(&z));
356		}
357	}
358
359	#[test]
360	#[should_panic(expected = "precondition: values must hold one entry per domain point")]
361	fn extrapolate_rejects_a_mismatched_value_count() {
362		let subspace = BinarySubspace::<F>::with_dim(3);
363		subspace.extrapolate(&[F::ONE; 4], &F::ONE);
364	}
365
366	proptest! {
367		/// The shared-weight collapse must agree with one weight per point, built the long way.
368		#[test]
369		fn lagrange_evals_matches_the_per_point_weights(dim in 0usize..=5, seed: u64) {
370			let mut rng = StdRng::seed_from_u64(seed);
371			let subspace = BinarySubspace::<F>::with_dim(dim);
372			let domain: Vec<F> = subspace.iter().collect();
373			let z = F::random(&mut rng);
374
375			prop_assert_eq!(subspace.lagrange_evals(&z), lagrange_evals_reference(&domain, z));
376		}
377
378		/// Interpolating a polynomial's own evaluations must reproduce the polynomial.
379		#[test]
380		fn extrapolate_matches_direct_evaluation(dim in 0usize..=5, seed: u64) {
381			let mut rng = StdRng::seed_from_u64(seed);
382			let subspace = BinarySubspace::<F>::with_dim(dim);
383
384			// Degree below the domain size, so the interpolant is the polynomial itself.
385			let coeffs: Vec<F> = random_scalars(&mut rng, 1 << dim);
386			let values: Vec<F> = subspace
387				.iter()
388				.map(|point| evaluate_univariate(&coeffs, &point))
389				.collect();
390
391			let z = F::random(&mut rng);
392			prop_assert_eq!(
393				subspace.extrapolate(&values, &z),
394				evaluate_univariate(&coeffs, &z)
395			);
396		}
397
398		/// On arbitrary values, extrapolation must equal the inner product with the basis.
399		#[test]
400		fn extrapolate_matches_the_inner_product_with_the_basis(dim in 0usize..=5, seed: u64) {
401			let mut rng = StdRng::seed_from_u64(seed);
402			let subspace = BinarySubspace::<B128>::with_dim(dim);
403
404			// Values off any low-degree polynomial, so only the basis identity can hold.
405			let values: Vec<B128> = random_scalars(&mut rng, 1 << dim);
406			let z = B128::random(&mut rng);
407
408			let expected = inner_product(values.iter().copied(), subspace.lagrange_evals(&z));
409			prop_assert_eq!(subspace.extrapolate(&values, &z), expected);
410		}
411	}
412}