Skip to main content

binius_math/multilinear/
hypercube.rs

1// Copyright 2025 Irreducible Inc.
2// Copyright 2026 The Binius Developers
3
4//! Multilinear tensor expansions, generic over the hypercube the coefficients are indexed by.
5//!
6//! An `n`-variate multilinear is stored as `2^n` coefficients.
7//! The basis those coefficients are taken against factors as a tensor product over the variables.
8//! Every variable contributes the same two-element basis `(b_0, b_1)` of linear polynomials.
9//! That single choice fixes the cube, and with it what each coefficient means:
10//!
11//! ```text
12//! basis (1 - X, X)    vertices {0, 1}      coefficients are evaluations
13//! basis (1, X)        vertices {0, inf}    coefficients are monomial coefficients
14//! ```
15//!
16//! The object built over a cube again and again is the equality indicator.
17//! Written `eq(X, Y)`, it extends the predicate `X == Y` multilinearly over the cube.
18//! Fixing one operand to a point leaves `2^n` coefficients, called the expansion of that point.
19//!
20//! Fixing a variable of a multilinear to a value likewise depends on the cube.
21//! The two halves the variable splits the coefficients into are weighted by its basis there.
22//!
23//! Every routine here is generic over the cube.
24//! The Boolean-cube specializations live beside them, in the sibling equality indicator and fold
25//! modules.
26
27use std::{iter, ops::Deref, slice};
28
29use binius_compute::{Allocator, BufferData, CollectIntoAllocVec, VecLike};
30use binius_field::{Field, PackedField, field::FieldOps};
31use binius_utils::rayon::{
32	prelude::*,
33	task_size::{IndexedParallelIteratorExt, WorkPerItem, min_len_for_bytes},
34};
35
36use crate::{FieldBuffer, FieldVec};
37
38/// A hypercube of coefficients for multilinear polynomials.
39///
40/// A cube is fixed by the two-element basis `(b_0, b_1)` that each of its variables contributes.
41/// That basis is a pair of linear polynomials, so a cube is a choice between two of them.
42/// Everything else is derived from that choice, and shared by every implementor.
43pub trait Hypercube {
44	/// Evaluates the basis of one variable at a coordinate.
45	///
46	/// Returns `(b_0(r), b_1(r))` for the coordinate `r`.
47	fn basis<F: FieldOps>(coord: &F) -> [F; 2];
48
49	/// Scales the basis of one variable by a value.
50	///
51	/// Returns `(v * b_0(r), v * b_1(r))` for the value `v` and the coordinate `r`.
52	/// This is the inner loop of every expansion.
53	/// So an implementor beats the two multiplications that scaling the basis directly costs.
54	fn expand_var<F: FieldOps>(value: &F, coord: &F) -> [F; 2];
55
56	/// Strips one variable's basis factor from the two halves of an expansion.
57	///
58	/// The halves hold `v * b_0(r)` and `v * b_1(r)` for the stripped variable's coordinate `r`.
59	/// The low half is overwritten with `v`.
60	///
61	/// Recovering `v` is one fixed linear combination of the two halves:
62	///
63	/// ```text
64	/// sum_i w_i * v * b_i(r) = v    where    sum_i w_i * b_i(X) = 1
65	/// ```
66	///
67	/// Those weights are unique and free of `r`, so the same combination works at any coordinate.
68	fn contract_var<F: FieldOps>(lo: &mut F, hi: &F);
69
70	/// Fixes one variable to a coordinate, given the two halves the variable splits a multilinear
71	/// into.
72	///
73	/// ```text
74	/// lo * b_0(r) + hi * b_1(r)
75	/// ```
76	///
77	/// This is the inner loop of every fold.
78	/// So an implementor beats the two multiplications that pairing with the basis directly costs.
79	fn extrapolate_line<F: FieldOps>(lo: F, hi: F, coord: F) -> F;
80
81	/// Evaluates the equality indicator of one variable.
82	///
83	/// ```text
84	/// eq(X, Y) = sum_i b_i(X) * b_i(Y)
85	/// ```
86	///
87	/// An implementor overrides this with a cheaper closed form.
88	fn eq_one_var<F: FieldOps>(x: F, y: F) -> F {
89		// The generic definition: pair the two bases at the two coordinates and sum.
90		let [x_0, x_1] = Self::basis(&x);
91		let [y_0, y_1] = Self::basis(&y);
92		x_0 * y_0 + x_1 * y_1
93	}
94}
95
96/// The Boolean cube `{0, 1}^n`, whose per-variable basis is `(1 - X, X)`.
97///
98/// That basis is the pair of Lagrange polynomials on the two vertices `0` and `1`.
99/// So the coefficient indexed by a vertex is the multilinear's evaluation at that vertex.
100#[derive(Debug)]
101pub struct OneCube;
102
103impl Hypercube for OneCube {
104	#[inline(always)]
105	fn basis<F: FieldOps>(coord: &F) -> [F; 2] {
106		[F::one() - coord, coord.clone()]
107	}
108
109	#[inline(always)]
110	fn expand_var<F: FieldOps>(value: &F, coord: &F) -> [F; 2] {
111		// Both halves share the product `value * coord`, so one multiplication covers both.
112		let prod = value.clone() * coord;
113		[value.clone() - &prod, prod]
114	}
115
116	#[inline(always)]
117	fn contract_var<F: FieldOps>(lo: &mut F, hi: &F) {
118		// The two basis polynomials sum to one, so both recovery weights are one.
119		*lo += hi;
120	}
121
122	#[inline(always)]
123	fn extrapolate_line<F: FieldOps>(lo: F, hi: F, coord: F) -> F {
124		// The basis is `(1 - r, r)`, so the pairing is the line through the halves at `r`.
125		// It is affine in `r`, so one multiplication and two additions suffice.
126		lo.clone() + (hi - lo) * coord
127	}
128
129	#[inline(always)]
130	fn eq_one_var<F: FieldOps>(x: F, y: F) -> F {
131		// Over characteristic two the `2 * X * Y` term vanishes, so
132		//
133		//     X * Y + (1 - X) * (1 - Y)  =  X + Y + 1
134		//
135		// The condition is a compile-time constant, so only one arm is ever generated.
136		if F::Scalar::CHARACTERISTIC == 2 {
137			x + y + F::one()
138		} else {
139			let one = F::one();
140			x.clone() * y.clone() + (one.clone() - x) * (one - y)
141		}
142	}
143}
144
145/// The infinity cube `{0, inf}^n`, whose per-variable basis is `(1, X)`.
146///
147/// The vertex `inf` selects a multilinear's leading coefficient in that variable.
148/// So the coefficient indexed by a vertex `v` belongs to the monomial
149///
150/// ```text
151/// prod_{i : v_i = inf} X_i
152/// ```
153#[derive(Debug)]
154pub struct InfCube;
155
156impl Hypercube for InfCube {
157	#[inline(always)]
158	fn basis<F: FieldOps>(coord: &F) -> [F; 2] {
159		[F::one(), coord.clone()]
160	}
161
162	#[inline(always)]
163	fn expand_var<F: FieldOps>(value: &F, coord: &F) -> [F; 2] {
164		// The constant basis polynomial is one, so the low half is the value untouched.
165		[value.clone(), value.clone() * coord]
166	}
167
168	#[inline(always)]
169	fn contract_var<F: FieldOps>(_lo: &mut F, _hi: &F) {
170		// The low half already holds the value, so the weights are one and zero.
171		// Contracting a variable is therefore free for this cube.
172	}
173
174	#[inline(always)]
175	fn extrapolate_line<F: FieldOps>(lo: F, hi: F, coord: F) -> F {
176		// The constant basis polynomial is one, so the low half passes through unscaled.
177		lo + hi * coord
178	}
179
180	#[inline(always)]
181	fn eq_one_var<F: FieldOps>(x: F, y: F) -> F {
182		// The constant basis polynomial contributes one, and the linear one the product.
183		F::one() + x * y
184	}
185}
186
187/// Tensor of values with the equality indicator evaluated at extra coordinates.
188///
189/// Take `n` values and the `k` coordinates `r = (r_0, ..., r_{k-1})`.
190/// The result is the tensor product of those values with the basis at every coordinate:
191///
192/// ```text
193/// v (x) b(r_0) (x) ... (x) b(r_{k-1})
194/// ```
195///
196/// It holds `2^(n + k)` coefficients, one variable added per coordinate.
197///
198/// Read as polynomials, the input holds an `n`-variate multilinear `f`.
199/// The output then holds the `(n + k)`-variate multilinear
200///
201/// ```text
202/// g(X_0, ..., X_{n+k-1}) = f(X_0, ..., X_{n-1}) * eq(X_n, ..., X_{n+k-1}, r)
203/// ```
204///
205/// Appending the coordinate `r` doubles the length, turning every value `v` into a pair:
206///
207/// ```text
208/// before   [ v_0            v_1            ]
209/// after    [ v_0 * b_0(r)   v_1 * b_0(r)   |   v_0 * b_1(r)   v_1 * b_1(r) ]
210/// ```
211///
212/// The two halves sit one after the other, so the appended variable is the highest indexed one.
213pub fn tensor_prod_eq_ind<Cube: Hypercube, P: PackedField>(
214	values: FieldBuffer<P, Vec<P>>,
215	extra_query_coordinates: &[P::Scalar],
216) -> FieldBuffer<P, Vec<P>> {
217	let start_log_len = values.log_len();
218	let final_log_len = start_log_len + extra_query_coordinates.len();
219	let mut data = values.into_inner();
220
221	// Reserve the whole final capacity once, so no round reallocates.
222	// Each round then writes its new coefficients straight into the reserved spare capacity,
223	// instead of zero-initializing a region the expansion immediately overwrites.
224	let final_packed_len = packed_words::<P>(final_log_len);
225	data.reserve_exact(final_packed_len.saturating_sub(data.len()));
226
227	tensor_prod_eq_ind_reserved::<Cube, P, _>(
228		FieldBuffer::new(start_log_len, data),
229		extra_query_coordinates,
230	)
231}
232
233/// The number of packed words an expansion of that many variables occupies.
234///
235/// Below one packed word the count is one, since a single word backs any shorter length.
236const fn packed_words<P: PackedField>(log_len: usize) -> usize {
237	1usize << log_len.saturating_sub(P::LOG_WIDTH)
238}
239
240/// Appends one variable per coordinate to a store that already has room for the result.
241///
242/// The public entry point reserves the final capacity before calling here.
243/// The allocator-backed callers pass a buffer drawn at the final size, which cannot grow.
244/// So for them the reservation is a precondition rather than a step of the expansion.
245///
246/// # Preconditions
247///
248/// * The store's capacity must cover the packed length of the final expansion.
249fn tensor_prod_eq_ind_reserved<Cube: Hypercube, P: PackedField, Data: VecLike<P>>(
250	values: FieldBuffer<P, Data>,
251	extra_query_coordinates: &[P::Scalar],
252) -> FieldBuffer<P, Data> {
253	let start_log_len = values.log_len();
254	let final_log_len = start_log_len + extra_query_coordinates.len();
255	let mut data = values.into_inner();
256
257	// precondition
258	debug_assert!(data.capacity() >= packed_words::<P>(final_log_len));
259
260	// The coordinates split cleanly in two at the packing width:
261	//
262	//     narrower than one word   the whole expansion lives in data[0]
263	//     one word or wider        every round doubles the packed length
264	let sub_width_count = extra_query_coordinates
265		.len()
266		.min(P::LOG_WIDTH.saturating_sub(start_log_len));
267	let (sub_width_coords, packed_coords) = extra_query_coordinates.split_at(sub_width_count);
268
269	// Sub-packing-width rounds: both halves of the result share the single word data[0].
270	// Split that word into its two halves, expand them, and interleave them back together.
271	// The backing store stays one element long throughout.
272	for (i, &r_i) in sub_width_coords.iter().enumerate() {
273		let log_len = start_log_len + i;
274		let packed_r_i = P::broadcast(r_i);
275		let (lo, _) = data[0].interleave(P::zero(), log_len);
276		let [lo, hi] = Cube::expand_var(&lo, &packed_r_i);
277		data[0] = lo.interleave(hi, log_len).0;
278	}
279
280	// Packed rounds: the initialized words are exactly the low half of the result.
281	//
282	//     low half    the initialized prefix, expanded in place
283	//     high half   reserved spare capacity, written once
284	for &r_i in packed_coords {
285		let packed_r_i = P::broadcast(r_i);
286		let old_packed = data.len();
287
288		// The safe two-slice split of a Vec into its initialized prefix and its spare capacity
289		// is still unstable (rust-lang/rust#81944).
290		// So the spare half comes from the safe accessor and the initialized half from a raw part.
291		let low_ptr = data.as_mut_ptr();
292		let high = &mut data.spare_capacity_mut()[..old_packed];
293		// SAFETY: `[0, old_packed)` is the initialized low half, disjoint from the spare `high`
294		// half `[old_packed, 2 * old_packed)`; the two slices never overlap.
295		let low = unsafe { slice::from_raw_parts_mut(low_ptr, old_packed) };
296		// Each round doubles the expansion, starting from a single word.
297		// So the first rounds are far too small to be worth splitting across threads.
298		(low, high)
299			.into_par_iter()
300			.with_min_task(WorkPerItem::FieldMuls)
301			.for_each(|(low_i, high_i)| {
302				let [new_low, new_high] = Cube::expand_var(low_i, &packed_r_i);
303				*low_i = new_low;
304				high_i.write(new_high);
305			});
306		// SAFETY: the loop above initialized every one of the `old_packed` spare words.
307		unsafe { data.set_len(2 * old_packed) };
308	}
309
310	FieldBuffer::new(final_log_len, data)
311}
312
313/// Computes the partial evaluation of the equality indicator polynomial.
314///
315/// For the point `r = (r_0, ..., r_{n-1})` the result holds the `2^n` coefficients
316///
317/// ```text
318/// b(r_0) (x) ... (x) b(r_{n-1})
319/// ```
320///
321/// which are the coefficients of the equality indicator `eq(X_0, ..., X_{n-1}, r)` over the cube.
322pub fn eq_ind_partial_eval<Cube: Hypercube, P: PackedField>(point: &[P::Scalar]) -> FieldBuffer<P> {
323	// The unscaled indicator is the scaled indicator with a scale of one.
324	scaled_eq_ind_partial_eval::<Cube, P>(point, P::Scalar::ONE)
325}
326
327/// Builds the equality indicator expansion of a point into a buffer drawn from an allocator.
328///
329/// Backed by a pool, the result is a recyclable buffer rather than a fresh allocation.
330pub fn eq_ind_partial_eval_in<Cube: Hypercube, A: Allocator, P: PackedField>(
331	alloc: &A,
332	point: &[P::Scalar],
333) -> FieldVec<P, A> {
334	// The allocator hands out the final packed length, which the expansion never outgrows.
335	let packed_len = packed_words::<P>(point.len());
336	scaled_eq_ind_partial_eval_into::<Cube, P, _>(
337		point,
338		P::Scalar::ONE,
339		alloc.alloc::<P>(packed_len),
340	)
341}
342
343/// Computes the partial evaluation of the equality indicator polynomial, scaled by a constant.
344///
345/// Every coefficient of the equality indicator is multiplied by the scale.
346/// A scale of one is the identity, since the expansion is linear in it.
347///
348/// # Arguments
349///
350/// * `point` - The evaluation point whose length is the number of variables.
351/// * `scale` - The constant every returned value is multiplied by.
352pub fn scaled_eq_ind_partial_eval<Cube: Hypercube, P: PackedField>(
353	point: &[P::Scalar],
354	scale: P::Scalar,
355) -> FieldBuffer<P> {
356	// Reserving the final packed length keeps the per-variable growth reallocation free.
357	let packed_len = packed_words::<P>(point.len());
358	scaled_eq_ind_partial_eval_into::<Cube, P, _>(point, scale, Vec::with_capacity(packed_len))
359}
360
361/// Builds the scaled equality indicator expansion of a point in a caller-supplied store.
362///
363/// This is the allocation-hoisting form.
364/// The caller owns the store, so it can be drawn from a pool.
365/// It can equally be reserved on a different thread than the one that fills it.
366///
367/// Each of the `2^n` coefficients is written once, so the expansion costs one multiplication per
368/// coefficient.
369///
370/// # Preconditions
371///
372/// * The store's capacity must cover the packed length of the expansion.
373pub fn scaled_eq_ind_partial_eval_into<Cube: Hypercube, P: PackedField, Data: VecLike<P>>(
374	point: &[P::Scalar],
375	scale: P::Scalar,
376	mut buffer: Data,
377) -> FieldBuffer<P, Data> {
378	assert!(
379		buffer.capacity() >= packed_words::<P>(point.len()),
380		"precondition: buffer capacity must cover the packed expansion length"
381	);
382
383	// Seed a one-coefficient expansion with the scale.
384	// Appending the coordinates multiplies it through, so every coefficient ends up scaled.
385	buffer.clear();
386	buffer.push(P::from_scalars(iter::once(scale)));
387	let seed = FieldBuffer::new(0, buffer);
388
389	// A coefficient is a product over the coordinates, so cutting the point cuts the product:
390	//
391	//     index = high * 2^low_len + low
392	//     coeff = high_expansion[high] * low_expansion[low]
393	//
394	// The cut sits at the midpoint, so neither expansion is much larger than the square root of the
395	// result and the two cost the same to build.
396	// A block spans whole packed words, so the cut never falls below the packing width, and it
397	// never passes the end of the point, which leaves the low expansion holding the whole result.
398	let low_len = (point.len() / 2).max(P::LOG_WIDTH).min(point.len());
399	let (low_coords, high_coords) = point.split_at(low_len);
400
401	// The low expansion is built straight into the store's first block, which is where the result
402	// wants it anyway. Nothing else is expanded over the result's own length.
403	let low = tensor_prod_eq_ind_reserved::<Cube, P, Data>(seed, low_coords);
404
405	// A cut that takes the whole point leaves one block, which is the result already.
406	if high_coords.is_empty() {
407		return low;
408	}
409
410	// A block is `2^low_len` scalars, a whole number of packed words exactly because a cut that
411	// leaves a tail sits at or above the packing width.
412	// That is what makes a block's position in the store equal its index into the high expansion.
413	let block = low.as_ref().len();
414	let mut data = low.into_inner();
415
416	// One scalar per block of the result, so the high expansion stays far smaller than it.
417	let high = eq_ind_partial_eval_scalars::<Cube, P::Scalar>(high_coords);
418	let total = block * high.len();
419	debug_assert_eq!(total, packed_words::<P>(point.len()));
420
421	// The safe two-slice split of a Vec into its initialized prefix and its spare capacity is
422	// still unstable (rust-lang/rust#81944).
423	// So the spare blocks come from the safe accessor and the first block from a raw part.
424	let first_ptr = data.as_mut_ptr();
425	let spare = &mut data.spare_capacity_mut()[..total - block];
426	// SAFETY: `[0, block)` is the initialized first block, disjoint from the spare blocks past
427	// it; the two slices never overlap.
428	let first = unsafe { slice::from_raw_parts(first_ptr, block) };
429
430	// Every block past the first is the first scaled by one coefficient of the high expansion.
431	// One item here is a whole block, so the byte floor is divided down by what a block holds.
432	let min_len = (min_len_for_bytes::<P>() / block).max(1);
433	spare
434		.par_chunks_mut(block)
435		.zip(high[1..].par_iter())
436		.with_min_len(min_len)
437		.for_each(|(dst, &coeff)| {
438			let coeff = P::broadcast(coeff);
439			for (dst_i, &src_i) in iter::zip(dst, first) {
440				dst_i.write(src_i * coeff);
441			}
442		});
443	// SAFETY: the loop above initialized every spare word up to the total.
444	unsafe { data.set_len(total) };
445
446	// The first block still holds the low expansion unscaled, since every other block read it.
447	// Its own coefficient therefore lands last.
448	let coeff = P::broadcast(high[0]);
449	for word in &mut data[..block] {
450		*word *= coeff;
451	}
452
453	FieldBuffer::new(point.len(), data)
454}
455
456/// Truncates a built equality indicator expansion to its low indexed variables.
457///
458/// Each step contracts the two halves of the buffer, stripping the highest variable.
459/// That removes the highest variable's basis factor, whatever its coordinate was.
460/// Truncating to `n'` variables therefore leaves the indicator over `r_0, ..., r_{n'-1}`.
461///
462/// The expansion occupies a prefix of the buffer.
463/// Scalars after the truncated length are dropped.
464///
465/// # Preconditions
466///
467/// * the truncated length must be at most the buffer's current length
468pub fn eq_ind_truncate_low_inplace<Cube: Hypercube, P: PackedField, Data: BufferData<P>>(
469	values: &mut FieldBuffer<P, Data>,
470	truncated_log_len: usize,
471) {
472	assert!(
473		truncated_log_len <= values.log_len(),
474		"precondition: truncated_log_len must be at most values.log_len()"
475	);
476
477	// One round per variable stripped, highest first, so the survivors stay in a prefix.
478	for log_len in (truncated_log_len..values.log_len()).rev() {
479		{
480			let mut split = values.split_half_mut();
481			let (mut lo, hi) = split.halves();
482			// Contracting a variable costs additions only.
483			// So the cost of one step is the two words it reads, not its arithmetic.
484			(lo.as_mut(), hi.as_ref())
485				.into_par_iter()
486				.with_min_task_bytes::<[P; 2]>()
487				.for_each(|(zero, one)| {
488					Cube::contract_var(zero, one);
489				});
490		}
491
492		values.truncate(log_len);
493	}
494}
495
496/// Evaluates the equality indicator multilinear at a pair of points.
497///
498/// This is the `2n`-variate multilinear
499///
500/// ```text
501/// eq(X_0, ..., X_{n-1}, Y_0, ..., Y_{n-1}) = prod_i sum_j b_j(X_i) * b_j(Y_i)
502/// ```
503pub fn eq_ind<Cube: Hypercube, F: FieldOps>(x: &[F], y: &[F]) -> F {
504	assert_eq!(x.len(), y.len(), "pre-condition: x and y must be the same length");
505	// The indicator factors over the variables, so one per-variable product suffices.
506	iter::zip(x, y)
507		.map(|(x, y)| Cube::eq_one_var(x.clone(), y.clone()))
508		.product()
509}
510
511/// Evaluates the equality indicator multilinear with one operand fixed to all zeros.
512///
513/// Only the constant basis polynomial survives at a zero coordinate:
514///
515/// ```text
516/// eq(0^n, Y_0, ..., Y_{n-1}) = prod_i b_0(Y_i)
517/// ```
518pub fn eq_ind_zero<Cube: Hypercube, F: FieldOps>(point: &[F]) -> F {
519	// The linear basis polynomial is multiplied by a zero coordinate, so it drops out.
520	point
521		.iter()
522		.map(|y| {
523			let [y_0, _] = Cube::basis(y);
524			y_0
525		})
526		.product()
527}
528
529/// Computes the partial evaluation of the equality indicator polynomial, returning scalars.
530///
531/// This is the scalar-only engine, which never touches a packed store.
532pub fn eq_ind_partial_eval_scalars<Cube: Hypercube, F: FieldOps>(point: &[F]) -> Vec<F> {
533	// The unscaled indicator is the scaled indicator with a scale of one.
534	scaled_eq_ind_partial_eval_scalars::<Cube, F>(point, F::one())
535}
536
537/// Computes the scaled partial evaluation of the equality indicator, returning scalars.
538///
539/// This is the scalar-only engine, which never touches a packed store.
540/// A scale of one is the identity, since the expansion is linear in it.
541pub fn scaled_eq_ind_partial_eval_scalars<Cube: Hypercube, F: FieldOps>(
542	point: &[F],
543	scale: F,
544) -> Vec<F> {
545	// One coefficient per cube vertex, allocated once.
546	let mut result = Vec::with_capacity(1 << point.len());
547	// Seed with the scale, which every later multiplication carries through.
548	result.push(scale);
549
550	for r_i in point {
551		// Each coordinate doubles the length.
552		// The low half takes the constant basis factor, the appended high half the linear one.
553		//
554		//     read index j  ->  overwrite result[j], push its partner past the end
555		//
556		// Walking the low half front to back is safe, since pushing only appends past it.
557		let len = result.len();
558		for j in 0..len {
559			let [lo, hi] = Cube::expand_var(&result[j], r_i);
560			result[j] = lo;
561			result.push(hi);
562		}
563	}
564	result
565}
566
567/// Fixes the highest variable of a multilinear to a value, in place.
568///
569/// ```text
570/// g(X_0, ..., X_{n-2}) = f(X_0, ..., X_{n-2}, r)
571/// ```
572///
573/// The result occupies the first half of the buffer.
574/// The buffer then reports one variable fewer.
575///
576/// ## Preconditions
577///
578/// * the buffer must have at least one variable
579pub fn fold_highest_var_inplace<Cube: Hypercube, P: PackedField, Data: BufferData<P>>(
580	values: &mut FieldBuffer<P, Data>,
581	scalar: P::Scalar,
582) {
583	// Each scalar of the result costs one multiplication.
584	// Broadcasting the challenge once lets every packed word reuse the same multiplier.
585	let broadcast_scalar = P::broadcast(scalar);
586	{
587		// The two halves are the multilinear's coefficients on the highest variable's two vertices.
588		let mut split = values.split_half_mut();
589		let (mut lo, mut hi) = split.halves();
590		// Fix the variable in each pair at the challenge, overwriting the low half.
591		(lo.as_mut(), hi.as_mut())
592			.into_par_iter()
593			.with_min_task(WorkPerItem::FieldMuls)
594			.for_each(|(lo_i, hi_i)| {
595				*lo_i = Cube::extrapolate_line(*lo_i, *hi_i, broadcast_scalar);
596			});
597	}
598
599	// The result occupies a prefix, so the truncation drops the scalars past it.
600	values.truncate(values.log_len() - 1);
601}
602
603/// Fixes the highest variable of a multilinear to a value, writing into memory from an allocator.
604///
605/// ```text
606/// g(X_0, ..., X_{n-2}) = f(X_0, ..., X_{n-2}, r)
607/// ```
608///
609/// Each output coefficient fixes the variable in one pair of input coefficients.
610/// The input is left untouched.
611///
612/// Use this when the input is borrowed or must be preserved.
613/// Otherwise prefer the form that overwrites the input.
614///
615/// ## Preconditions
616///
617/// * the buffer must have at least one variable
618pub fn fold_highest_var<
619	Cube: Hypercube,
620	A: Allocator,
621	P: PackedField,
622	Data: Deref<Target = [P]>,
623>(
624	alloc: &A,
625	values: &FieldBuffer<P, Data>,
626	scalar: P::Scalar,
627) -> FieldVec<P, A> {
628	assert!(values.log_len() > 0, "precondition: buffer must have at least one variable");
629
630	// The two halves are the multilinear's coefficients on the highest variable's two vertices.
631	let broadcast_scalar = P::broadcast(scalar);
632	let (lo, hi) = values.split_half();
633
634	// Fix the variable in each pair at the challenge directly into a fresh buffer drawn from the
635	// allocator.
636	let data = (lo.as_ref(), hi.as_ref())
637		.into_par_iter()
638		.with_min_task(WorkPerItem::FieldMuls)
639		.map(|(&lo_i, &hi_i)| Cube::extrapolate_line(lo_i, hi_i, broadcast_scalar))
640		.collect_into_alloc_vec(alloc);
641	FieldBuffer::new(values.log_len() - 1, data)
642}
643
644#[cfg(test)]
645mod tests {
646	use binius_compute::GlobalAllocator;
647	use binius_utils::rayon::task_size::{min_len_for_bytes, min_len_for_work};
648	use proptest::prelude::*;
649	use rand::prelude::*;
650
651	use super::*;
652	use crate::test_utils::{B128, Packed128b, random_field_buffer, random_scalars};
653
654	type P = Packed128b;
655	type F = B128;
656
657	#[test]
658	fn expand_var_matches_scaled_basis() {
659		let mut rng = StdRng::seed_from_u64(0);
660
661		// Each implementor saves a multiplication over scaling the basis the plain way.
662		// So both must land on the same pair.
663		let [value, coord] = [(); 2].map(|_| random_scalars::<F>(&mut rng, 1)[0]);
664		assert_eq!(
665			OneCube::expand_var(&value, &coord),
666			OneCube::basis(&coord).map(|b_i| b_i * value)
667		);
668		assert_eq!(
669			InfCube::expand_var(&value, &coord),
670			InfCube::basis(&coord).map(|b_i| b_i * value)
671		);
672	}
673
674	#[test]
675	fn contract_var_inverts_expand_var() {
676		let mut rng = StdRng::seed_from_u64(0);
677
678		// Expanding a value by a coordinate and contracting it back must be the identity.
679		let [value, coord] = [(); 2].map(|_| random_scalars::<F>(&mut rng, 1)[0]);
680
681		let [mut lo, hi] = OneCube::expand_var(&value, &coord);
682		OneCube::contract_var(&mut lo, &hi);
683		assert_eq!(lo, value);
684
685		let [mut lo, hi] = InfCube::expand_var(&value, &coord);
686		InfCube::contract_var(&mut lo, &hi);
687		assert_eq!(lo, value);
688	}
689
690	#[test]
691	fn eq_one_var_matches_basis_definition() {
692		let mut rng = StdRng::seed_from_u64(0);
693
694		// Each implementor overrides this with a closed form that skips the basis evaluations.
695		// So pin both against the generic pairing of the bases.
696		let [x, y] = [(); 2].map(|_| random_scalars::<F>(&mut rng, 1)[0]);
697		let eq_from_basis = |[x_0, x_1]: [F; 2], [y_0, y_1]: [F; 2]| x_0 * y_0 + x_1 * y_1;
698		assert_eq!(
699			OneCube::eq_one_var(x, y),
700			eq_from_basis(OneCube::basis(&x), OneCube::basis(&y))
701		);
702		assert_eq!(
703			InfCube::eq_one_var(x, y),
704			eq_from_basis(InfCube::basis(&x), InfCube::basis(&y))
705		);
706	}
707
708	#[test]
709	fn inf_cube_eq_ind_zero_is_one() {
710		let mut rng = StdRng::seed_from_u64(0);
711
712		// Every monomial of positive degree vanishes at zero, leaving the constant one.
713		for n_vars in [0, 1, 5] {
714			let point = random_scalars::<F>(&mut rng, n_vars);
715			assert_eq!(eq_ind_zero::<InfCube, F>(&point), F::ONE);
716
717			// The same value as evaluating the full indicator against an all-zero operand.
718			assert_eq!(
719				eq_ind_zero::<InfCube, F>(&point),
720				eq_ind::<InfCube, F>(&vec![F::ZERO; n_vars], &point)
721			);
722		}
723	}
724
725	/// The expansion of a point, straight from the definition of the tensor of bases `(1, r_i)`.
726	fn inf_cube_reference(point: &[F]) -> Vec<F> {
727		// The coefficient at an index is the product of the coordinates its set bits select.
728		(0..1 << point.len())
729			.map(|index| {
730				point
731					.iter()
732					.enumerate()
733					.filter(|(i, _)| index >> i & 1 == 1)
734					.map(|(_, r_i)| *r_i)
735					.product()
736			})
737			.collect()
738	}
739
740	/// Evaluates the multilinear whose monomial coefficients are given, at a point.
741	fn eval_monomial_basis(coeffs: &[F], point: &[F]) -> F {
742		// The coefficient at an index belongs to the monomial its set bits select.
743		coeffs
744			.iter()
745			.enumerate()
746			.map(|(index, coeff)| {
747				*coeff
748					* point
749						.iter()
750						.enumerate()
751						.filter(|(i, _)| index >> i & 1 == 1)
752						.map(|(_, x_i)| *x_i)
753						.product::<F>()
754			})
755			.sum()
756	}
757
758	#[test]
759	fn inf_cube_expansion_matches_the_tensor_of_bases() {
760		let mut rng = StdRng::seed_from_u64(0);
761
762		// Sizes span the empty point up to a 256-coefficient cube.
763		for n_vars in [0, 1, 2, 5, 8] {
764			let point = random_scalars::<F>(&mut rng, n_vars);
765			let expansion = eq_ind_partial_eval::<InfCube, P>(&point);
766			let expansion_scalars = expansion.iter_scalars().collect::<Vec<_>>();
767			assert_eq!(expansion_scalars, inf_cube_reference(&point), "mismatch at {n_vars} vars");
768		}
769	}
770
771	#[test]
772	fn inf_cube_expansion_holds_the_monomial_coefficients_of_the_indicator() {
773		let mut rng = StdRng::seed_from_u64(0);
774
775		// The defining property of this cube: coefficients are monomial coefficients.
776		//
777		//     expansion of r  ->  the monomial coefficients of eq(X, r)
778		//
779		// So reading the expansion in the monomial basis at any x must give the indicator there.
780		for n_vars in [0, 1, 2, 5] {
781			let point = random_scalars::<F>(&mut rng, n_vars);
782			let coeffs = eq_ind_partial_eval_scalars::<InfCube, F>(&point);
783
784			let x = random_scalars::<F>(&mut rng, n_vars);
785			assert_eq!(eval_monomial_basis(&coeffs, &x), eq_ind::<InfCube, F>(&x, &point));
786		}
787	}
788
789	#[test]
790	fn inf_cube_expansion_is_the_evaluation_functional() {
791		let mut rng = StdRng::seed_from_u64(0);
792
793		// Read the other way round, the expansion of a point is the functional that evaluates
794		// any multilinear at that point, given the multilinear's monomial coefficients.
795		for n_vars in [0, 1, 2, 5] {
796			let point = random_scalars::<F>(&mut rng, n_vars);
797			let coeffs = random_scalars::<F>(&mut rng, 1 << n_vars);
798
799			let expansion = eq_ind_partial_eval_scalars::<InfCube, F>(&point);
800			let inner_product = iter::zip(&coeffs, &expansion)
801				.map(|(c, e)| *c * e)
802				.sum::<F>();
803			assert_eq!(inner_product, eval_monomial_basis(&coeffs, &point));
804		}
805	}
806
807	#[test]
808	fn extrapolate_line_matches_basis_definition() {
809		let mut rng = StdRng::seed_from_u64(0);
810
811		// Each implementor saves a multiplication over pairing the halves with the basis.
812		// So pin both against the generic pairing.
813		let [lo, hi, coord] = [(); 3].map(|_| random_scalars::<F>(&mut rng, 1)[0]);
814		let from_basis = |[b_0, b_1]: [F; 2]| lo * b_0 + hi * b_1;
815		assert_eq!(OneCube::extrapolate_line(lo, hi, coord), from_basis(OneCube::basis(&coord)));
816		assert_eq!(InfCube::extrapolate_line(lo, hi, coord), from_basis(InfCube::basis(&coord)));
817	}
818
819	#[test]
820	fn inf_cube_fold_fixes_the_highest_variable() {
821		let mut rng = StdRng::seed_from_u64(0);
822
823		// Folding monomial coefficients at r leaves the monomial coefficients of f(.., r).
824		// So the folded table read at x must equal the original read at (x, r).
825		for n_vars in [1, 2, 5] {
826			let original = random_field_buffer::<P>(&mut rng, n_vars);
827			let challenge = random_scalars::<F>(&mut rng, 1)[0];
828			let x = random_scalars::<F>(&mut rng, n_vars - 1);
829
830			let out_of_place =
831				fold_highest_var::<InfCube, _, _, _>(&GlobalAllocator, &original, challenge);
832			let mut in_place = original.clone();
833			fold_highest_var_inplace::<InfCube, _, _>(&mut in_place, challenge);
834			assert_eq!(out_of_place, in_place);
835
836			let original = original.iter_scalars().collect::<Vec<_>>();
837			let folded = in_place.iter_scalars().collect::<Vec<_>>();
838			let full_point = [x.as_slice(), &[challenge]].concat();
839			assert_eq!(
840				eval_monomial_basis(&folded, &x),
841				eval_monomial_basis(&original, &full_point)
842			);
843		}
844	}
845
846	#[test]
847	fn growth_above_the_split_threshold_matches_the_inline_path() {
848		let mut rng = StdRng::seed_from_u64(9);
849
850		// Invariant: a round splits across threads only once it exceeds the minimum task size.
851		// Below that it runs inline, leaving the parallel path unexercised.
852		//
853		//     words in the widest round = 2^(n_vars - 1) / scalars per word
854		//     pick the smallest n_vars whose widest round reaches the minimum
855		//
856		// Every other test here is smaller, so this one covers the split.
857		let min_len = min_len_for_work(WorkPerItem::FieldMuls);
858		let n_vars = (2 * min_len * P::WIDTH).next_power_of_two().ilog2() as usize;
859		let point = random_scalars::<F>(&mut rng, n_vars);
860
861		let packed = eq_ind_partial_eval::<OneCube, P>(&point);
862		let reference = eq_ind_partial_eval_scalars::<OneCube, F>(&point);
863		assert!(packed.iter_scalars().eq(reference.iter().copied()));
864	}
865
866	#[test]
867	fn truncation_above_the_split_threshold_matches_the_inline_path() {
868		let mut rng = StdRng::seed_from_u64(0);
869
870		// Invariant: a round splits across threads only once it exceeds the minimum task size.
871		// Below that it runs inline, leaving the parallel path unexercised.
872		//
873		//     words read in the first round = 2^(n_vars - 1) / scalars per word
874		//     pick the smallest n_vars whose first round reaches the minimum
875		//
876		// Every other truncation test here is smaller, so this one covers the split.
877		let min_len = min_len_for_bytes::<[P; 2]>();
878		let n_vars = (2 * min_len * P::WIDTH).next_power_of_two().ilog2() as usize;
879		let point = random_scalars::<F>(&mut rng, n_vars);
880
881		// Strip the top variable and compare against a direct expansion of the prefix.
882		let mut truncated = eq_ind_partial_eval::<OneCube, P>(&point);
883		eq_ind_truncate_low_inplace::<OneCube, _, _>(&mut truncated, n_vars - 1);
884		assert_eq!(truncated, eq_ind_partial_eval::<OneCube, P>(&point[..n_vars - 1]));
885	}
886
887	proptest! {
888		#![proptest_config(ProptestConfig::with_cases(16))]
889
890		#[test]
891		fn the_two_engines_agree(
892			seed in any::<u64>(),
893			log_n in 0usize..=8,
894		) {
895			let mut rng = StdRng::seed_from_u64(seed);
896			let point = random_scalars::<F>(&mut rng, log_n);
897			let scale = random_scalars::<F>(&mut rng, 1)[0];
898
899			// Both bases, both the plain and the scaled form: the packed engine and the scalar
900			// engine must land on the same coefficients.
901			prop_assert_eq!(
902				eq_ind_partial_eval::<OneCube, P>(&point).iter_scalars().collect::<Vec<_>>(),
903				eq_ind_partial_eval_scalars::<OneCube, F>(&point)
904			);
905			prop_assert_eq!(
906				eq_ind_partial_eval::<InfCube, P>(&point).iter_scalars().collect::<Vec<_>>(),
907				eq_ind_partial_eval_scalars::<InfCube, F>(&point)
908			);
909			prop_assert_eq!(
910				scaled_eq_ind_partial_eval::<OneCube, P>(&point, scale)
911					.iter_scalars()
912					.collect::<Vec<_>>(),
913				scaled_eq_ind_partial_eval_scalars::<OneCube, F>(&point, scale)
914			);
915			prop_assert_eq!(
916				scaled_eq_ind_partial_eval::<InfCube, P>(&point, scale)
917					.iter_scalars()
918					.collect::<Vec<_>>(),
919				scaled_eq_ind_partial_eval_scalars::<InfCube, F>(&point, scale)
920			);
921
922			// A scalar field is a packed field of one lane, so the packed engine also runs at
923			// a packing width of one. That width is its own path through the growth loop:
924			//
925			//     4 lanes per word    two rounds live inside one word, then rounds double
926			//     1 lane per word     no round fits inside a word, so every round doubles
927			//
928			// The one-lane store is exactly the scalars, so the two must agree there too.
929			prop_assert_eq!(
930				eq_ind_partial_eval::<OneCube, F>(&point).into_inner(),
931				eq_ind_partial_eval_scalars::<OneCube, F>(&point)
932			);
933			prop_assert_eq!(
934				eq_ind_partial_eval::<InfCube, F>(&point).into_inner(),
935				eq_ind_partial_eval_scalars::<InfCube, F>(&point)
936			);
937		}
938
939		#[test]
940		fn scaling_commutes_with_the_expansion(
941			seed in any::<u64>(),
942			log_n in 0usize..=8,
943		) {
944			let mut rng = StdRng::seed_from_u64(seed);
945			let point = random_scalars::<F>(&mut rng, log_n);
946			let scale = random_scalars::<F>(&mut rng, 1)[0];
947
948			// Scaling the seed scales every coefficient, for either basis.
949			let one_scaled = scaled_eq_ind_partial_eval::<OneCube, P>(&point, scale);
950			let one_plain = eq_ind_partial_eval::<OneCube, P>(&point);
951			for (got, base) in one_scaled.iter_scalars().zip(one_plain.iter_scalars()) {
952				prop_assert_eq!(got, scale * base);
953			}
954
955			let inf_scaled = scaled_eq_ind_partial_eval::<InfCube, P>(&point, scale);
956			let inf_plain = eq_ind_partial_eval::<InfCube, P>(&point);
957			for (got, base) in inf_scaled.iter_scalars().zip(inf_plain.iter_scalars()) {
958				prop_assert_eq!(got, scale * base);
959			}
960		}
961
962		#[test]
963		fn truncation_strips_trailing_variables(
964			seed in any::<u64>(),
965			log_n in 0usize..=8,
966		) {
967			let mut rng = StdRng::seed_from_u64(seed);
968			let point = random_scalars::<F>(&mut rng, log_n);
969
970			// Truncating to any length must equal expanding that prefix of the point directly.
971			for truncated_log_len in 0..=log_n {
972				let mut one_cube = eq_ind_partial_eval::<OneCube, P>(&point);
973				eq_ind_truncate_low_inplace::<OneCube, _, _>(&mut one_cube, truncated_log_len);
974				prop_assert_eq!(
975					one_cube,
976					eq_ind_partial_eval::<OneCube, P>(&point[..truncated_log_len])
977				);
978
979				let mut inf_cube = eq_ind_partial_eval::<InfCube, P>(&point);
980				eq_ind_truncate_low_inplace::<InfCube, _, _>(&mut inf_cube, truncated_log_len);
981				prop_assert_eq!(
982					inf_cube,
983					eq_ind_partial_eval::<InfCube, P>(&point[..truncated_log_len])
984				);
985			}
986		}
987
988		#[test]
989		fn the_split_agrees_with_the_doubling_rounds(
990			seed in any::<u64>(),
991			n_vars in 0usize..=10,
992		) {
993			// Property: cutting the point and multiplying the two expansions together is the same
994			// map as appending its coordinates one at a time.
995			//
996			// The range covers a cut that takes the whole point, one held at the packing width,
997			// and one at the midpoint.
998			let mut rng = StdRng::seed_from_u64(seed);
999			let point = random_scalars::<F>(&mut rng, n_vars);
1000			let scale = random_scalars::<F>(&mut rng, 1)[0];
1001
1002			// A seed is one coefficient carrying the scale, over a store sized for the result.
1003			let seeded = || {
1004				let mut buffer = Vec::with_capacity(1 << n_vars.saturating_sub(P::LOG_WIDTH));
1005				buffer.push(P::from_scalars(iter::once(scale)));
1006				FieldBuffer::new(0, buffer)
1007			};
1008
1009			prop_assert_eq!(
1010				scaled_eq_ind_partial_eval::<OneCube, P>(&point, scale),
1011				tensor_prod_eq_ind_reserved::<OneCube, P, _>(seeded(), &point)
1012			);
1013			prop_assert_eq!(
1014				scaled_eq_ind_partial_eval::<InfCube, P>(&point, scale),
1015				tensor_prod_eq_ind_reserved::<InfCube, P, _>(seeded(), &point)
1016			);
1017		}
1018	}
1019
1020	#[test]
1021	fn the_split_path_agrees_with_the_scalar_engine() {
1022		let mut rng = StdRng::seed_from_u64(0);
1023
1024		// The public entry point picks the cut itself, so this runs against the scalar engine,
1025		// which shares no code with either packed path.
1026		//
1027		// 0 up to the packing width is where the cut takes the whole point and leaves one block.
1028		// Past it the packing-width floor holds the cut until the midpoint overtakes it, and
1029		// 13, 14 and 19 split many ways, odd and even.
1030		for n_vars in (0..=8).chain([13, 14, 19]) {
1031			let point = random_scalars::<F>(&mut rng, n_vars);
1032			let scale = random_scalars::<F>(&mut rng, 1)[0];
1033
1034			let packed = scaled_eq_ind_partial_eval::<OneCube, P>(&point, scale);
1035			let scalars = scaled_eq_ind_partial_eval_scalars::<OneCube, F>(&point, scale);
1036			assert!(packed.iter_scalars().eq(scalars), "one cube at {n_vars} vars");
1037
1038			let packed = scaled_eq_ind_partial_eval::<InfCube, P>(&point, scale);
1039			let scalars = scaled_eq_ind_partial_eval_scalars::<InfCube, F>(&point, scale);
1040			assert!(packed.iter_scalars().eq(scalars), "inf cube at {n_vars} vars");
1041		}
1042	}
1043}