binius_math/
univariate.rs

1// Copyright 2024-2025 Irreducible Inc.
2
3use binius_field::{BinaryField, Field, field::FieldOps};
4use itertools::izip;
5
6use super::{BinarySubspace, FieldBuffer};
7
8/// Evaluate a univariate polynomial specified by its monomial coefficients.
9///
10/// # Arguments
11/// * `coeffs` - Slice of coefficients ordered from low-degree terms to high-degree terms
12/// * `x` - Point at which to evaluate the polynomial
13pub fn evaluate_univariate<F: FieldOps>(coeffs: &[F], x: F) -> F {
14	let Some((highest_degree, rest)) = coeffs.split_last() else {
15		return F::zero();
16	};
17
18	// Evaluate using Horner's method
19	rest.iter()
20		.rev()
21		.fold(highest_degree.clone(), |acc, coeff| acc * &x + coeff)
22}
23
24/// Optimized Lagrange evaluation for power-of-2 domains in binary fields.
25///
26/// Computes the Lagrange polynomial evaluations L̃(z, i) for a power-of-2 domain at point `z`.
27/// Uses the provided binary subspace as the evaluation domain.
28///
29/// # Key Optimization
30/// For power-of-2 domains, all barycentric weights are identical due to the additive group
31/// structure. For each i ∈ {0, ..., 2^k - 1}, the set {i ⊕ j | j ≠ i} = {1, ..., 2^k - 1}.
32/// This allows us to:
33/// 1. Compute a single barycentric weight w = 1 / ∏_{j=1}^{n-1} j
34/// 2. Use prefix/suffix products to avoid redundant computation
35/// 3. Replace inversions with multiplications for better performance
36///
37/// # Complexity
38/// - Time: O(n) where n = subspace size, using 4n - 2 multiplications and 1 inversion
39/// - Space: O(n) for prefix/suffix arrays
40///
41/// # Parameters
42/// - `subspace`: The binary subspace defining the evaluation domain
43/// - `z`: The evaluation point
44///
45/// # Returns
46/// A vector of Lagrange polynomial evaluations, one for each domain element
47pub fn lagrange_evals<F: BinaryField>(subspace: &BinarySubspace<F>, z: F) -> FieldBuffer<F> {
48	let result = lagrange_evals_scalars(subspace, z);
49	FieldBuffer::new(subspace.dim(), result.into_boxed_slice())
50}
51
52/// Scalar variant of [`lagrange_evals`] that returns a `Vec<E>` instead of a `FieldBuffer`.
53///
54/// Computes Lagrange polynomial evaluations for a binary subspace domain, converting domain
55/// points from `F` to `E` and performing all arithmetic in `E`.
56///
57/// # Parameters
58/// - `subspace`: The binary subspace defining the evaluation domain (over `F`)
59/// - `z`: The evaluation point (in `E`)
60///
61/// # Returns
62/// A vector of Lagrange polynomial evaluations, one for each domain element
63pub fn lagrange_evals_scalars<F: BinaryField, E: FieldOps + From<F>>(
64	subspace: &BinarySubspace<F>,
65	z: E,
66) -> Vec<E> {
67	let domain: Vec<E> = subspace.iter().map(E::from).collect();
68	let n = domain.len();
69
70	// Compute single barycentric weight for the additive subgroup
71	let w = domain[1..]
72		.iter()
73		.fold(E::one(), |acc, d| acc * d)
74		.invert_or_zero();
75
76	// Compute prefix products: prefix[i] = ∏_{j=0}^{i-1} (z - domain[j])
77	let mut prefixes = vec![E::one(); n];
78	for i in 1..n {
79		prefixes[i] = prefixes[i - 1].clone() * (z.clone() - domain[i - 1].clone());
80	}
81
82	// Compute suffix products: suffix[i] = ∏_{j=i+1}^{n-1} (z - domain[j])
83	let mut suffixes = vec![E::one(); n];
84	for i in (0..n - 1).rev() {
85		suffixes[i] = suffixes[i + 1].clone() * (z.clone() - domain[i + 1].clone());
86	}
87
88	// Combine prefix, suffix, and weight: L_i(z) = prefix[i] * suffix[i] * w
89	izip!(prefixes, suffixes)
90		.map(|(p, s)| p * s * w.clone())
91		.collect()
92}
93
94/// Extrapolate a polynomial from its evaluations over a binary subspace to a point.
95///
96/// Given evaluations of a polynomial on all points of a binary subspace, computes the polynomial's
97/// value at an arbitrary point `z` using Lagrange interpolation. This is equivalent to computing
98/// the inner product of `values` with the Lagrange basis evaluations at `z`, but avoids
99/// materializing the full vector of Lagrange evaluations.
100///
101/// # Algorithm
102///
103/// Exploits the additive group structure of binary subspaces: all barycentric weights are
104/// identical, so a single weight `w = (∏_{j=1}^{n-1} domain[j])^{-1}` is computed once. The
105/// interpolated value is then `w * Σ_i values[i] * ∏_{j≠i} (z - domain[j])`, evaluated via a
106/// single linear pass using a prefix-product accumulator (same technique as
107/// [`EvaluationDomain::extrapolate`]).
108///
109/// # Complexity
110/// - Time: O(n) where n = subspace size
111/// - Space: O(1) beyond the input
112pub fn extrapolate_over_subspace<F: BinaryField, E: FieldOps + From<F>>(
113	subspace: &BinarySubspace<F>,
114	values: &[E],
115	z: E,
116) -> E {
117	let n = 1 << subspace.dim();
118	assert_eq!(values.len(), n);
119
120	// Compute single barycentric weight for the additive subgroup.
121	let w = subspace
122		.iter()
123		.skip(1)
124		.map(E::from)
125		.fold(E::one(), |acc, d| acc * d)
126		.invert_or_zero();
127
128	// Accumulate Σ_i values[i] * ∏_{j≠i} (z - domain[j]) using a prefix-product fold.
129	let (acc, _) = izip!(values, subspace.iter()).fold(
130		(E::zero(), E::one()),
131		|(acc, prod), (value, point)| {
132			let term = z.clone() - E::from(point);
133			let next_acc = acc * &term + prod.clone() * value;
134			(next_acc, prod * term)
135		},
136	);
137
138	acc * w
139}
140
141/// A domain that univariate polynomials may be evaluated on.
142///
143/// An evaluation domain of size d + 1 together with polynomial values on that domain uniquely
144/// defines a degree <= d polynomial.
145#[derive(Debug, Clone)]
146pub struct EvaluationDomain<F: Field> {
147	points: Vec<F>,
148	weights: Vec<F>,
149}
150
151impl<F: Field> EvaluationDomain<F> {
152	/// Create a new evaluation domain from a set of points.
153	///
154	/// # Arguments
155	/// * `points` - The points that define the domain
156	///
157	/// # Panics
158	/// * If any points are repeated (not distinct)
159	pub fn from_points(points: Vec<F>) -> Self {
160		let weights = compute_barycentric_weights(&points);
161		Self { points, weights }
162	}
163
164	pub fn size(&self) -> usize {
165		self.points.len()
166	}
167
168	pub fn points(&self) -> &[F] {
169		self.points.as_slice()
170	}
171
172	/// Compute a vector of Lagrange polynomial evaluations in $O(N)$ at a given point `x`.
173	///
174	/// For an evaluation domain consisting of points $x_i$ Lagrange polynomials $L_i(x)$
175	/// are defined by
176	///
177	/// $$L_i(x) = \prod_{j \neq i}\frac{x - \pi_j}{\pi_i - \pi_j}$$
178	pub fn lagrange_evals(&self, x: F) -> Vec<F> {
179		let n = self.size();
180
181		let mut result = vec![F::ONE; n];
182
183		// Multiply the product suffixes
184		for i in (1..n).rev() {
185			result[i - 1] = result[i] * (x - self.points[i]);
186		}
187
188		let mut prefix = F::ONE;
189
190		// Multiply the product prefixes and weights
191		for (result_i, &point, &weight) in izip!(&mut result, &self.points, &self.weights) {
192			*result_i *= prefix * weight;
193			prefix *= x - point;
194		}
195
196		result
197	}
198
199	/// Evaluate the unique interpolated polynomial at any point `x`.
200	///
201	/// Computational complexity is $O(n)$, for a domain of size $n$.
202	pub fn extrapolate(&self, values: &[F], x: F) -> F {
203		assert_eq!(values.len(), self.size()); // precondition
204
205		let (ret, _) = izip!(values, &self.points, &self.weights).fold(
206			(F::ZERO, F::ONE),
207			|(acc, prod), (&value, &point, &weight)| {
208				let term = x - point;
209				let next_acc = acc * term + prod * value * weight;
210				(next_acc, prod * term)
211			},
212		);
213
214		ret
215	}
216}
217
218/// Compute the Barycentric weights for a sequence of unique points.
219///
220/// The [Barycentric] weight $w_i$ for point $x_i$ is calculated as:
221/// $$w_i = \prod_{j \neq i} \frac{1}{x_i - x_j}$$
222///
223/// These weights are used in the Lagrange interpolation formula:
224/// $$L(x) = \sum_{i=0}^{n-1} f(x_i) \cdot \frac{w_i}{x - x_i} \cdot \prod_{j=0}^{n-1} (x - x_j)$$
225///
226/// # Preconditions
227/// * All points in the input slice must be distinct, otherwise this function panics.
228///
229/// [Barycentric]: <https://en.wikipedia.org/wiki/Lagrange_polynomial#Barycentric_form>
230fn compute_barycentric_weights<F: Field>(points: &[F]) -> Vec<F> {
231	let n = points.len();
232	(0..n)
233		.map(|i| {
234			// TODO: We could use batch inversion here, but it's not a bottleneck
235			let product = (0..n)
236				.filter(|&j| j != i)
237				.map(|j| points[i] - points[j])
238				.product::<F>();
239			product
240				.invert()
241				.expect("precondition: all points are distinct; invert only fails on 0")
242		})
243		.collect()
244}
245
246#[cfg(test)]
247mod tests {
248	use binius_field::{BinaryField128bGhash, Field, Random, util::powers};
249	use rand::prelude::*;
250
251	use super::*;
252	use crate::{
253		BinarySubspace,
254		inner_product::inner_product,
255		line::extrapolate_line_packed,
256		test_utils::{B128, random_scalars},
257	};
258
259	fn evaluate_univariate_with_powers<F: Field>(coeffs: &[F], x: F) -> F {
260		inner_product(coeffs.iter().copied(), powers(x).take(coeffs.len()))
261	}
262
263	type F = BinaryField128bGhash;
264
265	#[test]
266	fn test_evaluate_univariate_against_reference() {
267		let mut rng = StdRng::seed_from_u64(0);
268
269		for n_coeffs in [0, 1, 2, 5, 10] {
270			let coeffs = random_scalars(&mut rng, n_coeffs);
271			let x = F::random(&mut rng);
272			assert_eq!(
273				evaluate_univariate(&coeffs, x),
274				evaluate_univariate_with_powers(&coeffs, x)
275			);
276		}
277	}
278
279	#[test]
280	fn test_lagrange_evals() {
281		let mut rng = StdRng::seed_from_u64(0);
282
283		// Test mathematical properties across different domain sizes
284		for log_domain_size in [3, 4, 5, 6] {
285			// Create subspace for this test
286			let subspace = BinarySubspace::<F>::with_dim(log_domain_size);
287			let domain: Vec<F> = subspace.iter().collect();
288
289			// Test 1: Partition of Unity - Lagrange polynomials sum to 1
290			let eval_point = F::random(&mut rng);
291			let lagrange_coeffs = lagrange_evals(&subspace, eval_point);
292			let sum: F = lagrange_coeffs.as_ref().iter().copied().sum();
293			assert_eq!(
294				sum,
295				F::ONE,
296				"Partition of unity failed for domain size {}",
297				1 << log_domain_size
298			);
299
300			// Test 2: Interpolation Property - L_i(x_j) = δ_ij
301			for (j, &domain_point) in domain.iter().enumerate() {
302				let lagrange_at_domain = lagrange_evals(&subspace, domain_point);
303				for (i, &coeff) in lagrange_at_domain.as_ref().iter().enumerate() {
304					let expected = if i == j { F::ONE } else { F::ZERO };
305					assert_eq!(
306						coeff, expected,
307						"Interpolation property failed: L_{i}({j}) ≠ {expected}"
308					);
309				}
310			}
311		}
312
313		// Test 3: Polynomial Interpolation Accuracy
314		let log_domain_size = 6;
315		let subspace = BinarySubspace::<F>::with_dim(log_domain_size);
316		let domain: Vec<F> = subspace.iter().collect();
317		let coeffs = random_scalars(&mut rng, 10);
318
319		// Evaluate polynomial at domain points
320		let domain_evals: Vec<F> = domain
321			.iter()
322			.map(|&point| evaluate_univariate(&coeffs, point))
323			.collect();
324
325		// Test interpolation at random point
326		let test_point = F::random(&mut rng);
327		let lagrange_coeffs = lagrange_evals(&subspace, test_point);
328		let interpolated =
329			inner_product(domain_evals.iter().copied(), lagrange_coeffs.iter_scalars());
330		let direct = evaluate_univariate(&coeffs, test_point);
331
332		assert_eq!(interpolated, direct, "Polynomial interpolation accuracy failed");
333	}
334
335	#[test]
336	fn test_random_extrapolate() {
337		let mut rng = StdRng::seed_from_u64(0);
338		let degree = 6;
339
340		let domain = EvaluationDomain::from_points(random_scalars(&mut rng, degree + 1));
341
342		let coeffs = random_scalars(&mut rng, degree + 1);
343
344		let values = domain
345			.points()
346			.iter()
347			.map(|&x| evaluate_univariate(&coeffs, x))
348			.collect::<Vec<_>>();
349
350		let x = B128::random(&mut rng);
351		let expected_y = evaluate_univariate(&coeffs, x);
352		assert_eq!(domain.extrapolate(&values, x), expected_y);
353	}
354
355	#[test]
356	fn test_extrapolate_line() {
357		let mut rng = StdRng::seed_from_u64(0);
358		for _ in 0..10 {
359			let x0 = B128::random(&mut rng);
360			let x1 = B128::random(&mut rng);
361			// Use a smaller field element for z to test the subfield scalar multiplication
362			let z = B128::from(rng.next_u64() as u128);
363			assert_eq!(extrapolate_line_packed(x0, x1, z), x0 + (x1 - x0) * z);
364		}
365	}
366
367	#[test]
368	fn test_extrapolate_over_subspace_against_evaluate_univariate() {
369		let mut rng = StdRng::seed_from_u64(0);
370
371		for log_domain_size in 0..=6 {
372			let n = 1 << log_domain_size;
373			let subspace = BinarySubspace::<F>::with_dim(log_domain_size);
374
375			// Random polynomial of degree < n
376			let coeffs: Vec<F> = random_scalars(&mut rng, n);
377
378			// Evaluate at all domain points
379			let values: Vec<F> = subspace
380				.iter()
381				.map(|point| evaluate_univariate(&coeffs, point))
382				.collect();
383
384			// Extrapolate at a random point
385			let z = F::random(&mut rng);
386			let extrapolated = extrapolate_over_subspace(&subspace, &values, z);
387			let expected = evaluate_univariate(&coeffs, z);
388
389			assert_eq!(extrapolated, expected, "Mismatch for log_domain_size={log_domain_size}");
390		}
391	}
392
393	#[test]
394	fn test_extrapolate_over_subspace_against_lagrange_evals() {
395		let mut rng = StdRng::seed_from_u64(0);
396
397		for log_domain_size in 0..=6 {
398			let n = 1 << log_domain_size;
399			let subspace = BinarySubspace::<F>::with_dim(log_domain_size);
400
401			// Random values (not necessarily from a polynomial)
402			let values: Vec<F> = random_scalars(&mut rng, n);
403
404			let z = F::random(&mut rng);
405			let extrapolated = extrapolate_over_subspace(&subspace, &values, z);
406			let lagrange = lagrange_evals_scalars(&subspace, z);
407			let expected = inner_product(values.iter().copied(), lagrange);
408
409			assert_eq!(extrapolated, expected, "Mismatch for log_domain_size={log_domain_size}");
410		}
411	}
412
413	#[test]
414	fn test_evaluation_domain_lagrange_evals() {
415		let mut rng = StdRng::seed_from_u64(0);
416
417		// Create a small domain
418		let domain_points: Vec<B128> = (0..10).map(|_| B128::random(&mut rng)).collect();
419		let evaluation_domain = EvaluationDomain::from_points(domain_points.clone());
420
421		// Create random values for interpolation
422		let values: Vec<B128> = (0..10).map(|_| B128::random(&mut rng)).collect();
423
424		// Test point
425		let z = B128::random(&mut rng);
426
427		// Compute extrapolation
428		let extrapolated = evaluation_domain.extrapolate(values.as_slice(), z);
429
430		// Compute using Lagrange coefficients
431		let lagrange_coeffs = evaluation_domain.lagrange_evals(z);
432		let lagrange_eval = inner_product(lagrange_coeffs, values);
433
434		assert_eq!(lagrange_eval, extrapolated);
435	}
436}