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}