Skip to main content

binius_ip_prover/logup_star/
witness.rs

1// Copyright 2026 The Binius Developers
2
3//! Witness construction for the logUp* prover.
4//!
5//! These helpers build the multilinears that the two fractional-addition circuits run over:
6//!
7//! - the looker numerator `eq_r`, the equality indicator at the evaluation point,
8//! - the looker denominator `c - I`, with `I` the embedded index column,
9//! - the negated table denominator `J - c`, with `J` the embedded table positions,
10//! - the pushforward `Y = I_* eq_r`, the looker numerator scattered onto table positions.
11
12use std::iter;
13
14use binius_compute::{Allocator, CollectIntoAllocVec, VecLike};
15use binius_field::{BinaryField, Divisible, Field, PackedField, util::powers};
16use binius_math::{
17	FieldBuffer, FieldSlice, FieldVec, multilinear::eq::scaled_eq_ind_partial_eval_into,
18};
19use binius_utils::rayon::{current_num_threads, prelude::*};
20
21use super::prove::TableLookup;
22
23/// The witnesses [`combined_lookers`] builds: the numerators grouped per table, and one
24/// pushforward per table.
25type LogupWitnesses<P, A> = (Vec<Vec<FieldVec<P, A>>>, Vec<FieldVec<P, A>>);
26
27/// Build each table's gamma-scaled looker numerators and its combined pushforward `Y`.
28///
29/// Within a table, looker `i`'s numerator is `gamma^i * eq_{r_i}`, the scaled equality indicator
30/// its fractional-addition circuit runs over, so the fractional sum of that table's looker circuits
31/// is the gamma-combination of their sums. The table's pushforward is the scatter of those same
32/// numerators:
33///
34/// ```text
35///     Y = sum_i gamma^i * (I_i)_* eq_{r_i}
36/// ```
37///
38/// Each table uses its own `gamma`, so the tables share nothing here.
39///
40/// Both the numerators and the pushforwards are drawn from `alloc`: the numerators become the leaf
41/// layers of the per-looker fractional-addition circuits, and a committing caller hands the
42/// pushforwards to the channel, which owns them until the openings run.
43///
44/// # Preconditions
45///
46/// * `tables` is non-empty, every table has at least one looker, every looker's index column has
47///   `2^n` entries for its own evaluation point length `n`, and every index entry is less than its
48///   table's size.
49///
50/// # Panics
51///
52/// Panics if any precondition is violated.
53#[tracing::instrument(
54	skip_all,
55	level = "debug",
56	name = "Build logup* witnesses",
57	fields(n_tables = tables.len())
58)]
59pub fn combined_lookers<A, F, P>(
60	alloc: &A,
61	gamma: F,
62	tables: &[TableLookup<'_, P>],
63) -> LogupWitnesses<P, A>
64where
65	A: Allocator,
66	F: Field,
67	P: PackedField<Scalar = F>,
68{
69	assert!(!tables.is_empty(), "at least one table is required");
70	assert!(
71		tables.iter().all(|table| !table.lookers.is_empty()),
72		"every table must have at least one looker"
73	);
74
75	// Build one numerator per looker, fanned out across all of them at once.
76	// Why fan out: the per-looker expansion is itself parallel.
77	//   But it under-saturates the machine at moderate n.
78	//   Spreading the lookers over the cores fills them.
79	// The 2^n backing buffers are drawn from `alloc` up front on this thread, so the parallel
80	// region only fills them — no allocator traffic inside the rayon closures. Lookers may differ
81	// in length, so each buffer is sized to its own looker.
82	//
83	// Within a table, looker `i` is scaled by gamma^i; the same series serves every table, since
84	// the per-table denominator challenges separate them. The powers chain is sequential, so the
85	// scales are materialized once here, ahead of the parallel region.
86	// Invariant: the fill writes results back in the flattened order (the zip is index-aligned).
87	let max_table_lookers = tables
88		.iter()
89		.map(|table| table.lookers.len())
90		.max()
91		.expect("tables is non-empty");
92	let scales = powers(gamma).take(max_table_lookers).collect::<Vec<_>>();
93	let flat = tables
94		.iter()
95		.flat_map(|table| iter::zip(&table.lookers, &scales))
96		.collect::<Vec<_>>();
97	let buffers = flat
98		.iter()
99		.map(|(looker, _)| {
100			let packed_len = 1 << looker.eval_point.len().saturating_sub(P::LOG_WIDTH);
101			alloc.alloc::<P>(packed_len)
102		})
103		.collect::<Vec<_>>();
104	let flat_numerators = (buffers, flat.as_slice())
105		.into_par_iter()
106		.map(|(buffer, &(looker, &scale))| {
107			let n = looker.eval_point.len();
108			assert_eq!(
109				looker.index.len(),
110				1 << n,
111				"index column has {} entries but {} were expected for {n} variables",
112				looker.index.len(),
113				1usize << n,
114			);
115			// Seeding the expansion with the scale folds it into the tensor product.
116			// That keeps it to one pass over one 2^n buffer.
117			scaled_eq_ind_partial_eval_into(looker.eval_point, scale, buffer)
118		})
119		.collect::<Vec<_>>();
120
121	// Scatter each table's numerators onto its own cube, summed into one buffer. The scatter reads
122	// the numerators from rayon tasks, so it borrows them as slices: `Allocator::Vec` is declared
123	// only `Send`, so a numerator cannot be shared across tasks by reference.
124	//
125	// The tables are walked one at a time rather than in parallel: each scatter is already parallel
126	// over the looker rows that dominate its cost, and drawing a buffer from `alloc` inside a rayon
127	// task is not available here.
128	//
129	// The scatter reads every index entry into a cube that spans exactly the table.
130	// So it is also where each index is checked to address a real table position.
131	let mut remaining = flat_numerators.as_slice();
132	let mut grouped_slices = Vec::with_capacity(tables.len());
133	for table in tables {
134		let (mine, rest) = remaining.split_at(table.lookers.len());
135		remaining = rest;
136		grouped_slices.push(mine.iter().map(FieldBuffer::as_view).collect::<Vec<_>>());
137	}
138	let pushforwards = iter::zip(tables, &grouped_slices)
139		.map(|(table, numerators)| {
140			let indexes = table
141				.lookers
142				.iter()
143				.map(|looker| looker.index)
144				.collect::<Vec<_>>();
145			combined_pushforward::<A, F, P>(alloc, numerators, &indexes, table.table.log_len())
146		})
147		.collect::<Vec<_>>();
148
149	// Regroup the numerators themselves to match, now that the borrows above are done with.
150	let mut flat_iter = flat_numerators.into_iter();
151	let numerators = tables
152		.iter()
153		.map(|table| {
154			flat_iter
155				.by_ref()
156				.take(table.lookers.len())
157				.collect::<Vec<_>>()
158		})
159		.collect::<Vec<_>>();
160
161	(numerators, pushforwards)
162}
163
164/// Scatter one table's lookers' numerators onto its `m`-variable cube and sum.
165///
166/// ```text
167///     Y[v] = sum_j sum_{i : index_j[i] = v} numerator_j[i]
168/// ```
169///
170/// The per-looker `gamma^j` scale already lives in each numerator.
171/// So the plain sum of the scatters is the gamma-combined pushforward.
172/// The sum is over a field, so the accumulation order does not matter.
173///
174/// # Performance
175///
176/// The scatter over every looker row is the dominant `n`-axis cost.
177/// Two choices keep it lean:
178///
179/// - Each looker is read sequentially in row order, so no row pays an indexed lane-extract.
180/// - The work parallelizes across lookers, not rows.
181/// - So each task fills a single `2^m` accumulator for its run of lookers.
182/// - The per-task accumulators merge in a single pass.
183///
184/// With few lookers this leaves cores idle on the `n`-axis.
185/// The target regime has one looker per column, so the tasks stay busy.
186///
187/// # Preconditions
188///
189/// * `numerators` and `indexes` have equal length.
190/// * Each numerator has one entry per row of its looker's index column.
191/// * Every index entry is less than `2^table_n_vars`.
192fn combined_pushforward<A, F, P>(
193	alloc: &A,
194	numerators: &[FieldSlice<'_, P>],
195	indexes: &[&[usize]],
196	table_n_vars: usize,
197) -> FieldVec<P, A>
198where
199	A: Allocator,
200	F: Field,
201	P: PackedField<Scalar = F>,
202{
203	// One accumulator slot per table position.
204	let table_size = 1usize << table_n_vars;
205	let n_lookers = numerators.len();
206
207	// One accumulator per worker, allocated up front on this thread rather than inside the
208	// parallel region. Each worker scatters a contiguous chunk of lookers into its own
209	// accumulator; the chunk count is capped at the number of workers (and at the looker count),
210	// so at most one accumulator is allocated per busy core.
211	// A table no looker reads takes no chunk at all; it still needs the one zero accumulator, which
212	// is its honest all-zero pushforward.
213	let n_workers = current_num_threads().clamp(1, n_lookers.max(1));
214	let chunk_size = n_lookers.div_ceil(n_workers).max(1);
215	let mut accumulators = iter::repeat_with(|| vec![F::ZERO; table_size])
216		.take(n_lookers.div_ceil(chunk_size).max(1))
217		.collect::<Vec<_>>();
218
219	(
220		accumulators.par_iter_mut(),
221		numerators.par_chunks(chunk_size),
222		indexes.par_chunks(chunk_size),
223	)
224		.into_par_iter()
225		.for_each(|(acc, numerator_chunk, index_chunk)| {
226			for (numerator, index) in iter::zip(numerator_chunk, index_chunk) {
227				scatter_add(acc, numerator, index);
228			}
229		});
230
231	// Merge the per-worker accumulators position by position into the first. The merge is a single
232	// pass of `n_workers` sums per slot, negligible against the scatter over every looker row.
233	let mut buckets = accumulators.pop().expect("at least one accumulator");
234	for partial in &accumulators {
235		for (slot, add) in iter::zip(buckets.iter_mut(), partial) {
236			*slot += *add;
237		}
238	}
239
240	// Repack the merged scalar accumulator into the packed table buffer.
241	FieldBuffer::from_values_in(alloc, &buckets)
242}
243
244/// Scatter-add one looker's numerator onto the table accumulator in row order.
245///
246/// ```text
247///     acc[index[i]] += numerator[i]
248/// ```
249///
250/// The numerator is read sequentially, so each row is a lane read, not an indexed lookup.
251///
252/// # Panics
253///
254/// Panics if a row indexes past the last table position.
255#[inline]
256fn scatter_add<F, P>(acc: &mut [F], numerator: &FieldSlice<'_, P>, index: &[usize])
257where
258	F: Field,
259	P: PackedField<Scalar = F>,
260{
261	// Row i's numerator value lands in the table position that row indexes into.
262	//
263	// The accumulator spans exactly the table.
264	// So resolving the slot is the range check on the index column.
265	// That check is the one the write already pays for.
266	for (value, &target) in numerator.iter_scalars().zip(index) {
267		let slot = acc
268			.get_mut(target)
269			.expect("every index entry must be less than the size of the table its looker reads");
270		*slot += value;
271	}
272}
273
274/// Embed a table position `j` into the field through the `GF(2)`-linear basis.
275///
276/// ```text
277///     iota(j) = sum_{t : bit t of j is set} basis(t)
278/// ```
279///
280/// This is the same embedding the verifier uses for the table-side denominator `J`.
281/// It makes a position and an index value that point to it embed to the same field element.
282///
283/// The `GF(2)`-linear basis of a binary tower field is its underlier's bit basis: basis element
284/// `t` is the field element whose underlier has only bit `t` set. So `iota(j)` is just the field
285/// element whose underlier is `j`, which we build directly instead of summing basis elements.
286#[inline]
287pub fn embed_position<F>(j: usize) -> F
288where
289	F: BinaryField<Underlier: Divisible<u64>>,
290{
291	F::from_underlier(F::Underlier::from_iter(iter::once(j as u64)))
292}
293
294/// Build the looker denominator `c - I` over the `n`-variable looker cube.
295///
296/// Entry `i` is `c - iota(index[i])`, the logUp denominator for looker row `i`.
297///
298/// # Preconditions
299///
300/// * `index.len()` is a power of two.
301pub fn looker_denominator<A, F, P>(alloc: &A, c: F, index: &[usize]) -> FieldVec<P, A>
302where
303	A: Allocator,
304	F: BinaryField<Underlier: Divisible<u64>>,
305	P: PackedField<Scalar = F>,
306{
307	// n, the number of looker variables, from the 2^n rows.
308	let log_len = index.len().ilog2() as usize;
309
310	// One denominator per row: c minus the row's embedded index value.
311	// Subtract a full word at a time: one packed subtraction per word, built in parallel straight
312	// into the allocator's buffer.
313	let c_packed = P::broadcast(c);
314	let packed = index
315		.par_chunks(P::WIDTH)
316		.map(|chunk| c_packed - P::from_scalars(chunk.iter().copied().map(embed_position::<F>)))
317		.collect_into_alloc_vec(alloc);
318
319	FieldBuffer::new(log_len, packed)
320}
321
322/// Build the negated table denominator `J - c` over the `m`-variable table cube.
323///
324/// Entry `j` is `iota(j) - c`. The logUp denominator for table position `j` is `c - iota(j)`; the
325/// table's fraction enters the sum of every instance negated, and carrying that negation on the
326/// denominator rather than the numerator costs nothing here, where the entries are built anyway.
327pub fn table_denominator<A, F, P>(alloc: &A, c: F, table_n_vars: usize) -> FieldVec<P, A>
328where
329	A: Allocator,
330	F: BinaryField<Underlier: Divisible<u64>>,
331	P: PackedField<Scalar = F>,
332{
333	let packed_len = 1 << table_n_vars.saturating_sub(P::LOG_WIDTH);
334	// A table shorter than one packed word occupies only the low lanes of its single word.
335	let live_lanes = P::WIDTH.min(1usize << table_n_vars);
336
337	// Positions sharing a word differ only in the low bits the word index leaves clear.
338	// The embedding is `GF(2)`-linear, so a word is one lane pattern shifted by a single scalar.
339	//
340	//     word w, lane l  ->  iota(w * WIDTH) + (iota(l) - c)
341	//
342	// Invariant: the challenge rides in the lane pattern, not in the shift.
343	// So a short table's dead lanes stay zero: only the first word has them, and its shift is zero.
344	let lanes = P::from_scalars((0..live_lanes).map(|l| embed_position::<F>(l) - c));
345
346	let mut packed = alloc.alloc::<P>(packed_len);
347	// The allocator rounds its blocks up, so the fill is bounded to the words that are entries.
348	let words = &mut packed.spare_capacity_mut()[..packed_len];
349	for (word, slot) in words.iter_mut().enumerate() {
350		slot.write(P::broadcast(embed_position::<F>(word << P::LOG_WIDTH)) + lanes);
351	}
352	// Safety: the loop writes each of the first `packed_len` slots exactly once.
353	unsafe { packed.set_len(packed_len) };
354
355	FieldBuffer::new(table_n_vars, packed)
356}
357
358/// Build the pushforward `Y = I_* eq_r` over the `m`-variable table cube.
359///
360/// ```text
361///     Y[j] = sum_{i : index[i] = j} eq_r[i]
362/// ```
363///
364/// `Y` is the dual of the pullback under the inner product, so `<T, Y> = (I^* T)(eval_point)`.
365/// It has only `2^m` entries, which is the cost saving over committing the `2^n`-entry pullback.
366///
367/// This is the single-looker scatter.
368/// The prover combines many lookers by summing their scatters onto the same cube.
369///
370/// # Preconditions
371///
372/// * every `index[i]` is less than `2^table_n_vars`.
373pub fn pushforward<F, P>(
374	eq_r: &FieldBuffer<P>,
375	index: &[usize],
376	table_n_vars: usize,
377) -> FieldBuffer<P>
378where
379	F: Field,
380	P: PackedField<Scalar = F>,
381{
382	// One accumulator slot per table position, all starting empty.
383	let mut buckets = vec![F::ZERO; 1usize << table_n_vars];
384	// Add each row's numerator value into the position it indexes into.
385	scatter_add(&mut buckets, &eq_r.as_view(), index);
386	// Repack the scalar accumulator into the packed table buffer.
387	FieldBuffer::from_values(&buckets)
388}
389
390#[cfg(test)]
391mod tests {
392	use binius_compute::GlobalAllocator;
393	use binius_field::{
394		Field, PackedField, PackedGhash4x128b,
395		arch::{OptimalB128, OptimalPackedB128},
396	};
397	use binius_math::{
398		FieldBuffer,
399		test_utils::{random_field_buffer, random_scalars},
400	};
401	use proptest::prelude::*;
402	use rand::prelude::*;
403
404	use super::{
405		combined_pushforward, embed_position, looker_denominator, pushforward, table_denominator,
406	};
407
408	type F = OptimalB128;
409	type P = OptimalPackedB128;
410	// The optimal packing is one scalar per word at baseline x86-64, where the tests build.
411	// Four scalars to a word is what makes a table shorter than a word possible at all.
412	type Wide = PackedGhash4x128b;
413
414	// An independent single-threaded scatter, the reference the dispatched result must match.
415	fn reference(eq_r: &FieldBuffer<P>, index: &[usize], m: usize) -> Vec<F> {
416		let mut values = vec![F::ZERO; 1usize << m];
417		for (i, &j) in index.iter().enumerate() {
418			values[j] += eq_r.get(i);
419		}
420		values
421	}
422
423	// Assert pushforward equals the reference on a random instance of shape (n, m).
424	fn check(n: usize, m: usize, seed: u64) {
425		let mut rng = StdRng::seed_from_u64(seed);
426		let eq_r = random_field_buffer::<P>(&mut rng, n);
427		let index = (0..(1usize << n))
428			.map(|_| rng.random_range(0..(1usize << m)))
429			.collect::<Vec<_>>();
430
431		let got = pushforward::<F, P>(&eq_r, &index, m)
432			.iter_scalars()
433			.collect::<Vec<_>>();
434		assert_eq!(got, reference(&eq_r, &index, m));
435	}
436
437	#[test]
438	fn pushforward_matches_reference() {
439		// n = 0: the single-row edge.
440		check(0, 3, 7);
441		// 2^10 rows collapsed into 2 buckets: every position takes heavy collisions.
442		check(10, 1, 42);
443		// A wider 16-bucket cube with sparser collisions.
444		check(12, 4, 1);
445	}
446
447	// Reference scatter: the gamma-combined pushforward, single-threaded, one pass per looker.
448	// The fused parallel build must reproduce this exactly.
449	fn combined_reference(
450		numerators: &[FieldBuffer<P>],
451		indices: &[Vec<usize>],
452		m: usize,
453	) -> Vec<F> {
454		let mut acc = vec![F::ZERO; 1usize << m];
455		for (numerator, index) in numerators.iter().zip(indices) {
456			for (value, &target) in numerator.iter_scalars().zip(index) {
457				acc[target] += value;
458			}
459		}
460		acc
461	}
462
463	// Assert the fused scatter equals the reference on a random multi-looker instance.
464	fn check_combined(n: usize, m: usize, n_lookers: usize, seed: u64) {
465		let mut rng = StdRng::seed_from_u64(seed);
466
467		// Each looker gets its own numerator buffer and its own index column.
468		let numerators = (0..n_lookers)
469			.map(|_| random_field_buffer::<P>(&mut rng, n))
470			.collect::<Vec<_>>();
471		let indices = (0..n_lookers)
472			.map(|_| {
473				(0..(1usize << n))
474					.map(|_| rng.random_range(0..(1usize << m)))
475					.collect::<Vec<_>>()
476			})
477			.collect::<Vec<_>>();
478
479		// The scatter reads only the index columns.
480		let index_slices = indices.iter().map(Vec::as_slice).collect::<Vec<_>>();
481		let numerator_slices = numerators
482			.iter()
483			.map(FieldBuffer::as_view)
484			.collect::<Vec<_>>();
485		let got =
486			combined_pushforward::<_, F, P>(&GlobalAllocator, &numerator_slices, &index_slices, m)
487				.iter_scalars()
488				.collect::<Vec<_>>();
489		assert_eq!(got, combined_reference(&numerators, &indices, m));
490	}
491
492	#[test]
493	fn combined_pushforward_of_no_lookers_is_zero() {
494		// A table no looker reads still needs a pushforward buffer; its honest value is all zeros.
495		let got = combined_pushforward::<_, F, P>(&GlobalAllocator, &[], &[], 3)
496			.iter_scalars()
497			.collect::<Vec<_>>();
498		assert_eq!(got, vec![F::ZERO; 8]);
499	}
500
501	#[test]
502	fn combined_pushforward_small_cases() {
503		// One looker: the combined scatter degenerates to a single pushforward.
504		check_combined(4, 3, 1, 5);
505		// n = 0: each looker contributes a single row.
506		check_combined(0, 3, 3, 6);
507	}
508
509	proptest! {
510		#![proptest_config(ProptestConfig::with_cases(8))]
511
512		// Fuzz the fused scatter across shapes.
513		// Small m forces heavy collisions into few buckets.
514		// Several lookers exercise the parallel fold and the merging reduce.
515		#[test]
516		fn combined_pushforward_matches_reference(
517			seed in any::<u64>(),
518			n in 0usize..=10,
519			m in 1usize..=6,
520			n_lookers in 1usize..=5,
521		) {
522			check_combined(n, m, n_lookers, seed);
523		}
524	}
525
526	// The scalar reference for the looker denominator: c - iota(index[i]) per row.
527	fn denominator_reference(c: F, index: &[usize]) -> Vec<F> {
528		index.iter().map(|&i| c - embed_position::<F>(i)).collect()
529	}
530
531	#[test]
532	fn looker_denominator_small_cases() {
533		let c = F::new(7);
534
535		// n = 0: a single row, so the packed word carries one meaningful lane.
536		let one_row = looker_denominator::<_, F, P>(&GlobalAllocator, c, &[3])
537			.iter_scalars()
538			.collect::<Vec<_>>();
539		assert_eq!(one_row, denominator_reference(c, &[3]));
540
541		// n = 2: four rows with distinct embedded positions.
542		let index = [0usize, 1, 2, 5];
543		let four_rows = looker_denominator::<_, F, P>(&GlobalAllocator, c, &index)
544			.iter_scalars()
545			.collect::<Vec<_>>();
546		assert_eq!(four_rows, denominator_reference(c, &index));
547	}
548
549	proptest! {
550		#![proptest_config(ProptestConfig::with_cases(16))]
551
552		// The direct packed build must equal the scalar reference, value by value.
553		// n spans below, at, and above the packing width; index values exercise multi-bit embeddings.
554		#[test]
555		fn looker_denominator_matches_reference(seed in any::<u64>(), n in 0usize..=8) {
556			let mut rng = StdRng::seed_from_u64(seed);
557			let c = random_scalars::<F>(&mut rng, 1)[0];
558			let index = (0..(1usize << n))
559				.map(|_| rng.random_range(0..(1usize << 12)))
560				.collect::<Vec<_>>();
561
562			let got = looker_denominator::<_, F, P>(&GlobalAllocator, c, &index)
563				.iter_scalars()
564				.collect::<Vec<_>>();
565			prop_assert_eq!(got, denominator_reference(c, &index));
566		}
567	}
568
569	// The scalar reference for the table denominator: iota(j) - c per table position.
570	fn table_reference(c: F, table_n_vars: usize) -> Vec<F> {
571		(0..1usize << table_n_vars)
572			.map(|j| embed_position::<F>(j) - c)
573			.collect()
574	}
575
576	// Pins one table shape against the scalar reference, entry by entry and then word by word.
577	fn check_table_denominator<Q>(c: F, table_n_vars: usize)
578	where
579		Q: PackedField<Scalar = F>,
580	{
581		let got = table_denominator::<_, F, Q>(&GlobalAllocator, c, table_n_vars);
582		let want = table_reference(c, table_n_vars);
583
584		assert_eq!(got.iter_scalars().collect::<Vec<_>>(), want);
585
586		// Packing the scalar reference zero-fills a final word the table does not fill.
587		// So the words must agree bit for bit, not just entry by entry.
588		let packed = FieldBuffer::<Q, _>::from_values_in(&GlobalAllocator, &want);
589		assert_eq!(got.iter_packed().collect::<Vec<_>>(), packed.iter_packed().collect::<Vec<_>>());
590
591		// Only the first word can hold lanes past the last position, and they are not entries.
592		let first = *got.iter_packed().next().expect("a buffer holds one word");
593		for lane in first.iter().skip(want.len()) {
594			assert_eq!(lane, F::ZERO);
595		}
596	}
597
598	#[test]
599	fn table_denominator_small_cases() {
600		let c = F::new(7);
601
602		// One entry in a four-lane word, so three lanes are not entries.
603		check_table_denominator::<Wide>(c, 0);
604		// Two entries, so the word is still short.
605		check_table_denominator::<Wide>(c, 1);
606		// Exactly one full word.
607		check_table_denominator::<Wide>(c, 2);
608		// Eight words, so the lane pattern repeats.
609		check_table_denominator::<Wide>(c, 5);
610	}
611
612	proptest! {
613		#![proptest_config(ProptestConfig::with_cases(16))]
614
615		// The range spans below, at, and above the four-lane packing width.
616		#[test]
617		fn table_denominator_matches_reference(seed in any::<u64>(), m in 0usize..=8) {
618			let mut rng = StdRng::seed_from_u64(seed);
619			let c = random_scalars::<F>(&mut rng, 1)[0];
620
621			check_table_denominator::<Wide>(c, m);
622			check_table_denominator::<P>(c, m);
623		}
624	}
625}