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}