Skip to main content

binius_math/multilinear/
eq_prefix.rs

1// Copyright 2026 The Binius Developers
2
3//! The prefix equality indicator over the Boolean hypercube.
4//!
5//! For `m <= 2^n`, `eq_{<m}(X, Y)` is the `2n`-variate multilinear that agrees with
6//! `eq(X, y)` at the hypercube vertices `y < m` and vanishes at the vertices `y >= m`, reading
7//! a vertex as the integer whose bit `k` is coordinate `k`. Its partial evaluation
8//! `eq_{<m}(r, ·)` is the length-`m` prefix of the eq tensor of `r`, zero-extended.
9//!
10//! The evaluators run the recursion from the lowest coordinate up, with `m = Σ m_k 2^k`:
11//!
12//! ```text
13//! P_0 = 0,  E_0 = 1
14//! P_{k+1} = (1 - X_k)(1 - Y_k) · (m_k ? E_k : P_k) + X_k Y_k · (m_k ? P_k : 0)
15//! E_{k+1} = eq(X_k, Y_k) · E_k
16//! ```
17//!
18//! Factoring each term as a lerp at `X_k` leaves only two products with `Y_k` per step,
19//! `E_k Y_k` and `P_k Y_k`, which matters in the tensor algebra, where those are the costly
20//! horizontal scalings:
21//!
22//! ```text
23//! P_{k+1} = m_k ? lerp(X_k, E_k - E_k Y_k, P_k Y_k) : lerp(X_k, P_k - P_k Y_k, 0)
24//! E_{k+1} = lerp(X_k, E_k - E_k Y_k, E_k Y_k)
25//! ```
26//!
27//! where `lerp(t, a, b) = a + t (b - a)`, computed by [`extrapolate_line`] with arguments
28//! `(a, b, t)`.
29//!
30//! `P_k` is the indicator of the low `k` coordinates against `m mod 2^k`, so the result is
31//! `P_n`, except that `m = 2^n` selects `E_n = eq(X, Y)`.
32
33use std::iter;
34
35use binius_compute::{Allocator, CollectIntoAllocVec};
36use binius_field::{ExtensionField, Field, PackedField, field::FieldOps};
37use binius_utils::rayon::{iter::repeat_n, prelude::*, task_size::min_len_for_bytes};
38
39use super::eq::{eq_ind_partial_eval_in, eq_ind_partial_eval_scalars};
40use crate::{FieldBuffer, FieldVec, line::extrapolate_line, tensor_algebra::TensorAlgebra};
41
42/// Evaluates the prefix equality indicator `eq_{<m}(x, y)` in `O(n)` field operations.
43///
44/// ## Preconditions
45///
46/// * `x` and `y` must have the same length `n`
47/// * `m` must be at most `2^n`
48pub fn eq_ind_prefix_eval<F: FieldOps>(m: usize, x: &[F], y: &[F]) -> F {
49	let n = check_prefix_args(m, x.len(), y.len());
50
51	let mut prefix = F::zero();
52	let mut eq = F::one();
53	for (k, (x_k, y_k)) in iter::zip(x, y).enumerate() {
54		let eq_hi = eq.clone() * y_k;
55		let eq_lo = eq - &eq_hi;
56		let prefix_hi = prefix.clone() * y_k;
57		prefix = if (m >> k) & 1 == 1 {
58			extrapolate_line(eq_lo.clone(), prefix_hi, x_k.clone())
59		} else {
60			extrapolate_line(prefix - prefix_hi, F::zero(), x_k.clone())
61		};
62		eq = extrapolate_line(eq_lo, eq_hi, x_k.clone());
63	}
64	if m >> n == 1 { eq } else { prefix }
65}
66
67/// Evaluates `eq_{<m}(φ0(x), φ1(y))` in the tensor algebra.
68///
69/// `x` is embedded through the vertical subring and `y` through the horizontal subring. Each step
70/// of the recursion costs two horizontal and two vertical scalings.
71///
72/// ## Preconditions
73///
74/// * `x` and `y` must have the same length `n`
75/// * `m` must be at most `2^n`
76pub fn eq_ind_prefix_eval_tensor_algebra<F, FE>(
77	m: usize,
78	x: &[FE],
79	y: &[FE],
80) -> TensorAlgebra<F, FE>
81where
82	F: Field,
83	FE: FieldOps<Scalar: ExtensionField<F>>,
84{
85	let n = check_prefix_args(m, x.len(), y.len());
86
87	// The lerp parameter is vertical, so it costs a vertical scaling only.
88	let lerp =
89		|t: &FE, a: TensorAlgebra<F, FE>, b: TensorAlgebra<F, FE>| (b - &a).scale_vertical(t) + &a;
90
91	let mut prefix = TensorAlgebra::default();
92	let mut eq = TensorAlgebra::one();
93	for (k, (x_k, y_k)) in iter::zip(x, y).enumerate() {
94		let eq_hi = eq.clone().scale_horizontal(y_k);
95		let eq_lo = eq - &eq_hi;
96		let prefix_hi = prefix.clone().scale_horizontal(y_k);
97		prefix = if (m >> k) & 1 == 1 {
98			lerp(x_k, eq_lo.clone(), prefix_hi)
99		} else {
100			lerp(x_k, prefix - &prefix_hi, TensorAlgebra::default())
101		};
102		eq = lerp(x_k, eq_lo, eq_hi);
103	}
104	if m >> n == 1 { eq } else { prefix }
105}
106
107/// Computes the partial evaluation `eq_{<m}(r, ·)` into a buffer drawn from an allocator: the eq
108/// tensor of `r` with every entry at index `>= m` zeroed.
109///
110/// ## Preconditions
111///
112/// * `m` must be at most `2^n`, where `n` is the length of `point`
113pub fn eq_ind_prefix_partial_eval_in<A: Allocator, P: PackedField>(
114	alloc: &A,
115	m: usize,
116	point: &[P::Scalar],
117) -> FieldVec<P, A> {
118	let n = point.len();
119	assert!(n < usize::BITS as usize && m <= 1 << n, "precondition: m must be at most 2^n");
120
121	// Cut the point as the eq expansion does, so that
122	//
123	//     index = high * 2^low_len + low
124	//     coeff = high_expansion[high] * low_expansion[low]
125	//
126	// Only the words holding scalars below m are multiplied out, so the cost is about m
127	// multiplications rather than 2^n.
128	let low_len = (n / 2).max(P::LOG_WIDTH).min(n);
129	let (low_coords, high_coords) = point.split_at(low_len);
130	let low = eq_ind_partial_eval_in::<A, P>(alloc, low_coords);
131	let low_words = low.as_ref();
132	let high = eq_ind_partial_eval_scalars(high_coords);
133
134	// Each word is written once, straight into the allocator's buffer, with no zero-fill pass.
135	let log_block_words = low_len.saturating_sub(P::LOG_WIDTH);
136	let block_mask = (1 << log_block_words) - 1;
137	let kept_words = m.div_ceil(P::WIDTH);
138	let total_words = 1 << n.saturating_sub(P::LOG_WIDTH);
139	let words = (0..kept_words)
140		.into_par_iter()
141		// A task walks consecutive words, so it broadcasts each block's coefficient once.
142		.map_init(
143			|| (usize::MAX, P::zero()),
144			|(block, coeff), word| {
145				if word >> log_block_words != *block {
146					*block = word >> log_block_words;
147					*coeff = P::broadcast(high[*block]);
148				}
149				low_words[word & block_mask] * *coeff
150			},
151		)
152		.chain(repeat_n(P::zero(), total_words - kept_words))
153		.with_min_len(min_len_for_bytes::<P>())
154		.collect_into_alloc_vec(alloc);
155	let mut values = FieldBuffer::new(n, words);
156
157	// The last word multiplied out can run past m.
158	for i in m..m.next_multiple_of(P::WIDTH).min(values.len()) {
159		values.set(i, P::Scalar::ZERO);
160	}
161	values
162}
163
164fn check_prefix_args(m: usize, x_len: usize, y_len: usize) -> usize {
165	assert_eq!(x_len, y_len, "precondition: x and y must be the same length");
166	assert!(x_len < usize::BITS as usize && m <= 1 << x_len, "precondition: m must be at most 2^n");
167	x_len
168}
169
170#[cfg(test)]
171mod tests {
172	use binius_compute::GlobalAllocator;
173	use binius_field::{BinaryField1b as B1, Field};
174	use rand::prelude::*;
175
176	use super::*;
177	use crate::{
178		inner_product::inner_product_buffers,
179		multilinear::eq::eq_ind,
180		test_utils::{
181			B128, Packed128b, index_to_hypercube_point, random_field_buffer, random_scalars,
182		},
183	};
184
185	type P = Packed128b;
186	type F = B128;
187
188	#[test]
189	fn scalar_eval_matches_naive_sum() {
190		let mut rng = StdRng::seed_from_u64(0);
191		for n in 0..=4 {
192			let x = random_scalars::<F>(&mut rng, n);
193			let y = random_scalars::<F>(&mut rng, n);
194			for m in 0..=1 << n {
195				let expected = (0..m)
196					.map(|v| {
197						let vertex = index_to_hypercube_point::<F>(n, v);
198						eq_ind(&x, &vertex) * eq_ind(&vertex, &y)
199					})
200					.sum::<F>();
201				assert_eq!(eq_ind_prefix_eval(m, &x, &y), expected, "n={n}, m={m}");
202			}
203		}
204	}
205
206	#[test]
207	fn scalar_eval_endpoints() {
208		let mut rng = StdRng::seed_from_u64(0);
209		let n = 5;
210		let x = random_scalars::<F>(&mut rng, n);
211		let y = random_scalars::<F>(&mut rng, n);
212		assert_eq!(eq_ind_prefix_eval(1 << n, &x, &y), eq_ind(&x, &y));
213		assert_eq!(eq_ind_prefix_eval(0, &x, &y), F::ZERO);
214	}
215
216	#[test]
217	fn tensor_algebra_eval_matches_naive_sum() {
218		let mut rng = StdRng::seed_from_u64(0);
219		for n in 0..=3 {
220			let x = random_scalars::<F>(&mut rng, n);
221			let y = random_scalars::<F>(&mut rng, n);
222			for m in 0..=1 << n {
223				let expected = (0..m)
224					.map(|v| {
225						let vertex = index_to_hypercube_point::<F>(n, v);
226						TensorAlgebra::<B1, F>::tensor(eq_ind(&x, &vertex), eq_ind(&vertex, &y))
227					})
228					.fold(TensorAlgebra::default(), |sum, term| sum + &term);
229				assert_eq!(eq_ind_prefix_eval_tensor_algebra(m, &x, &y), expected, "n={n}, m={m}");
230			}
231		}
232	}
233
234	#[test]
235	fn tensor_algebra_eval_with_subfield_horizontal_matches_scalar() {
236		// A horizontal operand in the subfield embeds identically through both subrings, so the
237		// result is the scalar indicator in the vertical subring.
238		let mut rng = StdRng::seed_from_u64(0);
239		let n = 4;
240		let x = random_scalars::<F>(&mut rng, n);
241		for y_index in 0..1 << n {
242			let y = index_to_hypercube_point::<F>(n, y_index);
243			for m in 0..=1 << n {
244				let elem = eq_ind_prefix_eval_tensor_algebra::<B1, F>(m, &x, &y);
245				assert_eq!(elem.try_extract_vertical(), Some(eq_ind_prefix_eval(m, &x, &y)));
246			}
247		}
248	}
249
250	#[test]
251	fn partial_eval_inner_product_matches_scalar_eval() {
252		let mut rng = StdRng::seed_from_u64(0);
253		for n in 0..=7 {
254			let r = random_scalars::<F>(&mut rng, n);
255			let table = random_field_buffer::<P>(&mut rng, n);
256			for m in 0..=1 << n {
257				let tensor = eq_ind_prefix_partial_eval_in::<_, P>(&GlobalAllocator, m, &r);
258				// Σ_y eq_{<m}(r, y) t(y), with the scalar indicator at each vertex y.
259				let expected = (0..1 << n)
260					.map(|v| {
261						let vertex = index_to_hypercube_point::<F>(n, v);
262						eq_ind_prefix_eval(m, &r, &vertex) * table.get(v)
263					})
264					.sum::<F>();
265				assert_eq!(inner_product_buffers(&tensor, &table), expected, "n={n}, m={m}");
266			}
267		}
268	}
269}