Skip to main content

binius_ip_prover/sumcheck/
sparse_dense_product.rs

1// Copyright 2026 The Binius Developers
2
3//! Sumcheck prover for the product of a sparse and a dense multilinear.
4
5use binius_compute::BufferData;
6use binius_field::{Field, PackedField};
7use binius_ip::sumcheck::RoundCoeffs;
8use binius_utils::rayon::{
9	prelude::*,
10	task_size::{IndexedParallelIteratorExt, WorkPerItem},
11};
12
13use super::{
14	common::SumcheckProver, factored_multilinear::FactoredMultilinear, round_evals::RoundEvals,
15	round_state::RoundState,
16};
17
18/// One entry of a sparse multilinear: a hypercube index and the value carried there.
19///
20/// The multilinear is the sum of its entries, so several entries may share an index.
21pub type SparseEntry<F> = (usize, F);
22
23/// Proves the hypercube sum of the product of a sparse and a dense multilinear.
24///
25/// The sparse multilinear $A$ is given as a list of (index, value) entries, defined as
26///
27/// $$
28/// A(v) = \sum_{(i, c) \in \text{entries}, i = v} c
29/// $$
30///
31/// so entries at a repeated index add up and need not be deduplicated. The dense multilinear $B$
32/// is a full buffer over the same $n$ variables. The prover argues the claim
33///
34/// $$
35/// s = \sum_{v \in B_n} A(v) B(v)
36/// $$
37///
38/// which is the plain, non-eq-weighted sumcheck of a degree-2 composition. Its rounds cost one
39/// pass over the entry list plus two dense lookups per entry, so the work per round is set by the
40/// number of entries rather than by the size of the hypercube.
41///
42/// Variables bind from the highest index down, as in the dense provers of this module. Round $j$
43/// therefore splits the index space at `half`, the bit the round binds: an entry below it lies in
44/// the half where the bound variable is 0, one at or above it in the half where it is 1.
45///
46/// ## Round polynomial
47///
48/// With $A_0, A_1$ the two halves of $A$ on the bound variable (likewise $B$), the round
49/// polynomial is sampled at 1 and at infinity, and its value at 0 recovered from the round claim:
50///
51/// $$
52/// R(1) = \sum_v A_1(v) B_1(v) \qquad R(\infty) = \sum_v (A_0 + A_1)(v) (B_0 + B_1)(v)
53/// $$
54///
55/// Both are linear in $A$, so each entry contributes to them on its own: pairing an entry with
56/// the one facing it across the split is never needed. An entry $(i, c)$ with $v = i \bmod
57/// \text{half}$ adds $c B(i)$ to $R(1)$ when it lies in the upper half, and $c (B_0 + B_1)(v)$ to
58/// $R(\infty)$ wherever it lies.
59///
60/// ## Folding
61///
62/// Folding is linear in $A$ for the same reason: an entry keeps its identity across the fold,
63/// scaled by the challenge weight of the half it sits in and moved down into the lower half.
64/// Entries thus stay a flat list of the same length for the whole protocol, and collapse to the
65/// evaluation of $A$ at the challenge point only in [`SumcheckProver::finish`], which emits the
66/// sparse multilinear's evaluation before the dense one's.
67pub struct SparseDenseProductSumcheckProver<P: PackedField, Data: BufferData<P> = Vec<P>> {
68	/// The sparse multilinear, folded in place.
69	///
70	/// Indices are always below `1 << self.n_vars()`.
71	sparse: Vec<SparseEntry<P::Scalar>>,
72	/// The dense multilinear, folded in place. Its length tracks the free variables.
73	///
74	/// Held as a product of factors rather than one table.
75	/// So a weight whose table is too large to materialize can drive this prover too.
76	///
77	/// A weight that really is one table is one factor, so nothing is given up by taking this.
78	dense: FactoredMultilinear<P, Data>,
79	/// This round's sum claim, or the round polynomial awaiting the challenge that reduces it.
80	state: RoundState<RoundCoeffs<P::Scalar>, P::Scalar>,
81}
82
83impl<P: PackedField, Data: BufferData<P>> SparseDenseProductSumcheckProver<P, Data> {
84	/// Creates a prover for the claim that the sparse-dense product sums to `sum`.
85	///
86	/// # Arguments
87	///
88	/// * `sparse` - the entries of the sparse multilinear, in any order, indices repeatable
89	/// * `dense` - the dense multilinear, whose length fixes the number of variables
90	/// * `sum` - the claimed sum of the product over the hypercube
91	///
92	/// # Panics
93	///
94	/// Panics if any entry index is out of range for `dense`.
95	pub fn new(
96		sparse: Vec<SparseEntry<P::Scalar>>,
97		dense: FactoredMultilinear<P, Data>,
98		sum: P::Scalar,
99	) -> Self {
100		assert!(
101			sparse.iter().all(|&(index, _)| index < 1 << dense.n_vars()),
102			"precondition: every sparse index must be within the dense multilinear"
103		);
104
105		Self {
106			sparse,
107			dense,
108			state: RoundState::Claim(sum),
109		}
110	}
111
112	/// The index-space bit this round binds, separating the two halves of the hypercube.
113	///
114	/// # Panics
115	///
116	/// Panics if no variables are left to bind.
117	fn half(&self) -> usize {
118		let n_vars = self.dense.n_vars();
119		assert!(n_vars > 0, "no variables remain to bind");
120		1 << (n_vars - 1)
121	}
122}
123
124impl<F: Field, P: PackedField<Scalar = F>, Data: BufferData<P> + Sync> SumcheckProver<F>
125	for SparseDenseProductSumcheckProver<P, Data>
126{
127	fn n_vars(&self) -> usize {
128		self.dense.n_vars()
129	}
130
131	fn execute(&mut self) -> Vec<RoundCoeffs<F>> {
132		let claim = *self.state.claim();
133		let half = self.half();
134
135		// Each entry contributes on its own, by linearity of both sampled evaluations in the
136		// sparse multilinear. `dense.get(index)` is the entry's own half, `index ^ half` faces it
137		// across the split.
138		let (y_1, y_inf) = self
139			.sparse
140			.par_iter()
141			.with_min_task(WorkPerItem::FieldMuls)
142			.map(|&(index, value)| {
143				let own = self.dense.get(index);
144				let facing = self.dense.get(index ^ half);
145
146				// The infinity evaluation reads B(0) + B(1), which is the same sum from either
147				// half, so the entry's own half only decides the evaluation at 1.
148				let y_1 = if index & half == 0 {
149					F::ZERO
150				} else {
151					value * own
152				};
153				(y_1, value * (own + facing))
154			})
155			.reduce(
156				|| (F::ZERO, F::ZERO),
157				|(lhs_1, lhs_inf), (rhs_1, rhs_inf)| (lhs_1 + rhs_1, lhs_inf + rhs_inf),
158			);
159
160		let coeffs = RoundEvals([y_1, y_inf]).interpolate(claim);
161		self.state = RoundState::Coeffs(coeffs.clone());
162		vec![coeffs]
163	}
164
165	fn fold(&mut self, challenge: F) {
166		let claim = self.state.coeffs().evaluate(&challenge);
167		let half = self.half();
168
169		// Scale each entry by the challenge weight of the half it sits in, then move it down into
170		// the folded index space.
171		let lower_weight = F::ONE - challenge;
172		self.sparse
173			.par_iter_mut()
174			.with_min_task(WorkPerItem::FieldMuls)
175			.for_each(|(index, value)| {
176				if *index & half == 0 {
177					*value *= lower_weight;
178				} else {
179					*value *= challenge;
180					*index ^= half;
181				}
182			});
183
184		self.dense.fold_highest_var(challenge);
185		self.state = RoundState::Claim(claim);
186	}
187
188	fn finish(self) -> Vec<F> {
189		assert_eq!(self.n_vars(), 0, "finish called before the last fold");
190
191		// Every entry has folded down onto the single remaining index, so the sparse evaluation is
192		// what is left of their sum.
193		let sparse_eval = self.sparse.iter().map(|&(_, value)| value).sum();
194		vec![sparse_eval, self.dense.get(0)]
195	}
196}
197
198/// Proves the hypercube sums of one sparse multilinear against several dense ones.
199///
200/// The sparse multilinear is shared, so it is stored once and folded once.
201/// That holds however many dense columns ride along.
202///
203/// One prover per column would instead hold a copy of the entry list each.
204/// Every copy would then fold each round.
205/// Avoiding that is the whole reason this exists.
206///
207/// One round polynomial comes out per column.
208/// The batching driver combines a prover's polynomials exactly as it would separate provers'.
209///
210/// The claim each column carries is its own.
211/// So the columns are independent sums that happen to share a factor.
212///
213/// Everything else follows the single-column prover in this module.
214/// A test pins the two together at one column.
215pub struct SparseMultiDenseProductSumcheckProver<P: PackedField> {
216	/// The shared sparse multilinear, folded in place, once per round.
217	///
218	/// Indices are always below `1 << self.n_vars()`.
219	sparse: Vec<SparseEntry<P::Scalar>>,
220
221	/// The dense multilinears, folded in place.
222	/// Their lengths track the free variables.
223	dense: Vec<FactoredMultilinear<P>>,
224
225	/// Each column's sum claim, or its round polynomial awaiting the challenge that reduces it.
226	state: Vec<RoundState<RoundCoeffs<P::Scalar>, P::Scalar>>,
227}
228
229impl<P: PackedField> SparseMultiDenseProductSumcheckProver<P> {
230	/// Creates a prover for the claim that each sparse-dense product sums to its stated value.
231	///
232	/// # Arguments
233	///
234	/// * `sparse` - entries of the shared sparse multilinear, in any order, indices repeatable.
235	/// * `dense` - one dense multilinear per claim, all over the same variables.
236	/// * `sums` - the claimed sum of each product over the hypercube.
237	///
238	/// # Panics
239	///
240	/// Panics if the columns disagree on their variable count.
241	///
242	/// Panics if there is not one sum per column, or if any entry index is out of range.
243	pub fn new(
244		sparse: Vec<SparseEntry<P::Scalar>>,
245		dense: Vec<FactoredMultilinear<P>>,
246		sums: &[P::Scalar],
247	) -> Self {
248		let n_vars = dense
249			.first()
250			.expect("precondition: at least one dense column")
251			.n_vars();
252		assert!(
253			dense.iter().all(|column| column.n_vars() == n_vars),
254			"precondition: every dense column must span the same variables"
255		);
256		assert_eq!(dense.len(), sums.len(), "precondition: one sum per dense column");
257		assert!(
258			sparse.iter().all(|&(index, _)| index < 1 << n_vars),
259			"precondition: every sparse index must be within the dense multilinears"
260		);
261
262		Self {
263			sparse,
264			dense,
265			state: sums.iter().map(|&sum| RoundState::Claim(sum)).collect(),
266		}
267	}
268
269	/// The index-space bit this round binds, separating the two halves of the hypercube.
270	///
271	/// # Panics
272	///
273	/// Panics if no variables are left to bind.
274	fn half(&self) -> usize {
275		let n_vars = self.n_vars();
276		assert!(n_vars > 0, "no variables remain to bind");
277		1 << (n_vars - 1)
278	}
279}
280
281impl<F: Field, P: PackedField<Scalar = F>> SumcheckProver<F>
282	for SparseMultiDenseProductSumcheckProver<P>
283{
284	fn n_vars(&self) -> usize {
285		self.dense.first().map_or(0, FactoredMultilinear::n_vars)
286	}
287
288	fn execute(&mut self) -> Vec<RoundCoeffs<F>> {
289		let half = self.half();
290		let sparse = &self.sparse;
291
292		// One round polynomial per column, each read against the shared entry list.
293		//
294		// The entries are walked once per column, which is inherent.
295		// A column's polynomial is its own.
296		//
297		// What is not repeated is the folding, or the storage.
298		let coeffs = self
299			.dense
300			.iter()
301			.zip(&self.state)
302			.map(|(dense, state)| {
303				let (y_1, y_inf) = sparse
304					.par_iter()
305					.with_min_task(WorkPerItem::FieldMuls)
306					.map(|&(index, value)| {
307						let own = dense.get(index);
308						let facing = dense.get(index ^ half);
309
310						// The infinity evaluation reads B(0) + B(1).
311						// That is the same sum from either half.
312						//
313						// So the entry's own half only decides the evaluation at 1.
314						let y_1 = if index & half == 0 {
315							F::ZERO
316						} else {
317							value * own
318						};
319						(y_1, value * (own + facing))
320					})
321					.reduce(
322						|| (F::ZERO, F::ZERO),
323						|(lhs_1, lhs_inf), (rhs_1, rhs_inf)| (lhs_1 + rhs_1, lhs_inf + rhs_inf),
324					);
325				RoundEvals([y_1, y_inf]).interpolate(*state.claim())
326			})
327			.collect::<Vec<_>>();
328
329		self.state = coeffs.iter().cloned().map(RoundState::Coeffs).collect();
330		coeffs
331	}
332
333	fn fold(&mut self, challenge: F) {
334		let half = self.half();
335
336		// Every column reduces its own claim on the shared challenge.
337		self.state = self
338			.state
339			.iter()
340			.map(|state| RoundState::Claim(state.coeffs().evaluate(&challenge)))
341			.collect();
342
343		// The shared entry list folds once, which is the whole point of holding it here.
344		let lower_weight = F::ONE - challenge;
345		self.sparse
346			.par_iter_mut()
347			.with_min_task(WorkPerItem::FieldMuls)
348			.for_each(|(index, value)| {
349				if *index & half == 0 {
350					*value *= lower_weight;
351				} else {
352					*value *= challenge;
353					*index ^= half;
354				}
355			});
356
357		for dense in &mut self.dense {
358			dense.fold_highest_var(challenge);
359		}
360	}
361
362	fn finish(self) -> Vec<F> {
363		assert_eq!(self.n_vars(), 0, "finish called before the last fold");
364
365		// Every entry has folded onto the single remaining index.
366		// So the sparse evaluation is what is left of their sum.
367		//
368		// It leads, then one evaluation per dense column.
369		let sparse_eval = self.sparse.iter().map(|&(_, value)| value).sum();
370		let mut evals = vec![sparse_eval];
371		evals.extend(self.dense.iter().map(|dense| dense.get(0)));
372		evals
373	}
374}
375
376#[cfg(test)]
377mod tests {
378	use binius_compute::GlobalAllocator;
379	use binius_field::{
380		Random,
381		arch::{OptimalB128, OptimalPackedB128},
382	};
383	use binius_ip::sumcheck::verify;
384	use binius_math::{
385		FieldBuffer, multilinear::evaluate::evaluate, test_utils::random_field_buffer,
386	};
387	use binius_transcript::{ProverTranscript, fiat_shamir::HasherChallenger};
388	use proptest::prelude::*;
389	use rand::{SeedableRng, prelude::*};
390
391	use super::*;
392	use crate::sumcheck::{
393		batch::batch_prove, factored_multilinear::FactoredMultilinear, prove::prove_single,
394	};
395
396	type F = OptimalB128;
397	type P = OptimalPackedB128;
398	type StdChallenger = HasherChallenger<sha2::Sha256>;
399
400	/// Materializes a sparse multilinear as a dense buffer, adding up repeated indices.
401	fn densify(sparse: &[SparseEntry<F>], n_vars: usize) -> FieldBuffer<P> {
402		let mut buffer = FieldBuffer::<P>::zeros(n_vars);
403		for &(index, value) in sparse {
404			buffer.set(index, buffer.get(index) + value);
405		}
406		buffer
407	}
408
409	/// Proves the sparse-dense product sum, verifies it, and checks the reduced claim against the
410	/// two multilinears evaluated at the challenge point.
411	fn prove_verify(sparse: Vec<SparseEntry<F>>, dense: &FieldBuffer<P>) {
412		let n_vars = dense.log_len();
413		let sparse_dense = densify(&sparse, n_vars);
414		let sum = sparse
415			.iter()
416			.map(|&(index, value)| value * dense.get(index))
417			.sum::<F>();
418
419		// One factor over every variable is exactly the table it holds.
420		let weight = FactoredMultilinear::new([dense.clone()]);
421		let prover = SparseDenseProductSumcheckProver::new(sparse, weight, sum);
422
423		let mut prover_transcript = ProverTranscript::new(StdChallenger::default());
424		let output = prove_single(prover, &mut prover_transcript);
425		prover_transcript
426			.message()
427			.write_slice(&output.multilinear_evals);
428
429		let mut verifier_transcript = prover_transcript.into_verifier();
430		let sumcheck_output = verify(n_vars, 2, sum, &mut verifier_transcript).unwrap();
431		let multilinear_evals = verifier_transcript.message().read_vec::<F>(2).unwrap();
432
433		assert_eq!(
434			multilinear_evals[0] * multilinear_evals[1],
435			sumcheck_output.eval,
436			"product of the multilinear evaluations should equal the reduced evaluation"
437		);
438
439		// The prover binds variables high-to-low; `evaluate` expects them low-to-high.
440		let mut eval_point = sumcheck_output.challenges.clone();
441		eval_point.reverse();
442		assert_eq!(evaluate(&sparse_dense, &eval_point), multilinear_evals[0]);
443		assert_eq!(evaluate(dense, &eval_point), multilinear_evals[1]);
444		assert_eq!(output.challenges, sumcheck_output.challenges);
445	}
446
447	#[test]
448	fn a_factored_weight_proves_the_same_sum_as_the_table_it_stands_for() {
449		// Invariant: the dense side's storage is invisible to the protocol.
450		//
451		// A weight held as factors and the table it stands for are the same multilinear.
452		// So the same claim must prove, verify, and reduce to the same evaluations.
453		//
454		// This is the whole point of the generalization.
455		// A weight too large to materialize can then drive this prover.
456		//
457		// Fixture state: three factors over 2, 1 and 2 variables, so five in total.
458		//
459		//     index bits:  [ f0 : 2 | f1 : 1 | f2 : 2 ]
460		//                    low                  high
461		let mut rng = StdRng::seed_from_u64(7);
462
463		let factors = [2usize, 1, 2]
464			.iter()
465			.map(|&log_len| random_field_buffer::<P>(&mut rng, log_len))
466			.collect::<Vec<_>>();
467		let n_vars = 5;
468
469		// The same weight, twice: once as factors, once as the table they multiply out to.
470		let factored = FactoredMultilinear::new(factors);
471		let table = FieldBuffer::<P>::from_values(
472			&(0..1usize << n_vars)
473				.map(|index| factored.get(index))
474				.collect::<Vec<_>>(),
475		);
476
477		let sparse = random_sparse(&mut rng, n_vars, 12);
478		let sum = sparse
479			.iter()
480			.map(|&(index, value)| value * table.get(index))
481			.sum::<F>();
482		// A vacuous sum would let a broken weight pass unnoticed.
483		assert_ne!(sum, F::ZERO);
484
485		// Prove the same claim twice, once over each storage form.
486		let transcripts = [
487			{
488				let prover = SparseDenseProductSumcheckProver::new(sparse.clone(), factored, sum);
489				let mut transcript = ProverTranscript::new(StdChallenger::default());
490				let output = prove_single(prover, &mut transcript);
491				(output.multilinear_evals, transcript.finalize())
492			},
493			{
494				let prover = SparseDenseProductSumcheckProver::new(
495					sparse.clone(),
496					FactoredMultilinear::new([table.clone()]),
497					sum,
498				);
499				let mut transcript = ProverTranscript::new(StdChallenger::default());
500				let output = prove_single(prover, &mut transcript);
501				(output.multilinear_evals, transcript.finalize())
502			},
503		];
504
505		// Identical transcripts mean identical round polynomials, every round.
506		assert_eq!(transcripts[0].1, transcripts[1].1, "the two forms must prove the same rounds");
507		assert_eq!(transcripts[0].0, transcripts[1].0, "and reduce to the same evaluations");
508
509		// And the shared proof verifies, so neither form is consistently wrong.
510		prove_verify(sparse, &table);
511	}
512
513	#[test]
514	fn one_column_matches_the_single_column_prover() {
515		// Invariant: the multi-column prover is the single-column one, generalized.
516		//
517		// Two provers computing the same round polynomials is a thing to pin, not assume.
518		// The round math is written twice, so it can drift once.
519		//
520		// At one column they must agree.
521		//
522		// Both run through the batching driver, which is what makes the comparison fair.
523		//
524		// That driver samples a batching coefficient before the rounds.
525		// The single-claim driver does not.
526		// The challenger would otherwise diverge from round two onward.
527		//
528		// Comparing transcripts compares every round polynomial, not just the final result.
529		let mut rng = StdRng::seed_from_u64(17);
530
531		let n_vars = 5;
532		let weight = FactoredMultilinear::new([random_field_buffer::<P>(&mut rng, n_vars)]);
533		let sparse = random_sparse(&mut rng, n_vars, 14);
534		let sum = sparse
535			.iter()
536			.map(|&(index, _)| index)
537			.zip(sparse.iter().map(|&(_, value)| value))
538			.map(|(index, value)| value * weight.get(index))
539			.sum::<F>();
540		assert_ne!(sum, F::ZERO, "a vacuous claim would prove nothing");
541
542		let single = {
543			let prover = SparseDenseProductSumcheckProver::new(sparse.clone(), weight.clone(), sum);
544			let mut transcript = ProverTranscript::new(StdChallenger::default());
545			let output = batch_prove(vec![prover], &mut transcript);
546			(output.multilinear_evals[0].clone(), transcript.finalize())
547		};
548		let multi = {
549			let prover = SparseMultiDenseProductSumcheckProver::new(
550				sparse,
551				vec![weight],
552				std::slice::from_ref(&sum),
553			);
554			let mut transcript = ProverTranscript::new(StdChallenger::default());
555			let output = batch_prove(vec![prover], &mut transcript);
556			(output.multilinear_evals[0].clone(), transcript.finalize())
557		};
558
559		assert_eq!(single.1, multi.1, "the two provers must prove the same rounds");
560		assert_eq!(single.0, multi.0, "and reduce to the same evaluations");
561	}
562
563	#[test]
564	fn the_sparse_column_is_stored_once_however_many_dense_columns_ride_it() {
565		// Invariant: the shared entry list is shared, not copied per column.
566		//
567		// This is the reason the multi-column prover exists.
568		//
569		// A prover per claim would hold one entry list each, and fold every one each round.
570		// Both memory and folding work would then scale with the claim count.
571		//
572		//     three columns  ->  one entry list, folded once per round
573		//
574		// Checked by the sums each column reduces to.
575		// They must be the three distinct claims.
576		//
577		// That is only possible if one shared list was read against three different weights.
578		let mut rng = StdRng::seed_from_u64(19);
579
580		let n_vars = 4;
581		let sparse = random_sparse(&mut rng, n_vars, 10);
582		let weights = (0..3)
583			.map(|_| FactoredMultilinear::new([random_field_buffer::<P>(&mut rng, n_vars)]))
584			.collect::<Vec<_>>();
585		let sums = weights
586			.iter()
587			.map(|weight| {
588				sparse
589					.iter()
590					.map(|&(index, value)| value * weight.get(index))
591					.sum::<F>()
592			})
593			.collect::<Vec<_>>();
594
595		let prover = SparseMultiDenseProductSumcheckProver::new(sparse, weights, &sums);
596		let mut transcript = ProverTranscript::new(StdChallenger::default());
597		let output = batch_prove(vec![prover], &mut transcript);
598
599		// One evaluation for the shared sparse column, then one per dense column.
600		let evals = &output.multilinear_evals[0];
601		assert_eq!(evals.len(), 4);
602
603		// Every column's product reduces against the same sparse evaluation.
604		// That is what makes the sharing observable from outside.
605		let sparse_eval = evals[0];
606		assert_ne!(sparse_eval, F::ZERO);
607		assert!(evals[1..].iter().all(|&dense| dense != sparse_eval));
608	}
609
610	#[test]
611	fn an_arena_backed_weight_proves_the_same_claim_as_a_heap_backed_one() {
612		// Invariant: where the weight's words live never reaches the protocol.
613		//
614		// A prover working out of an arena builds its weights there.
615		// So it must be able to hand them over as they are.
616		//
617		// Copying onto the heap first is the cost the arena exists to avoid.
618		// That cost grows with the weight.
619		//
620		// Proving the same claim over both storages, then comparing transcripts byte for byte.
621		// That asserts identical round polynomials in every round, not a matching final value.
622		let mut rng = StdRng::seed_from_u64(29);
623		let alloc = GlobalAllocator;
624
625		let n_vars = 5;
626		let scalars = (0..1usize << n_vars)
627			.map(|_| F::random(&mut rng))
628			.collect::<Vec<_>>();
629		let sparse = random_sparse(&mut rng, n_vars, 13);
630
631		let heap = FactoredMultilinear::<P>::new([FieldBuffer::<P>::from_values(&scalars)]);
632		let sum = sparse
633			.iter()
634			.map(|&(index, value)| value * heap.get(index))
635			.sum::<F>();
636		// A vacuous claim would prove nothing about either storage.
637		assert_ne!(sum, F::ZERO);
638
639		let prove_over = |weight| {
640			let prover = SparseDenseProductSumcheckProver::new(sparse.clone(), weight, sum);
641			let mut transcript = ProverTranscript::new(StdChallenger::default());
642			let output = prove_single(prover, &mut transcript);
643			(output.multilinear_evals, transcript.finalize())
644		};
645
646		let from_heap = prove_over(heap);
647		let from_arena = {
648			let weight =
649				FactoredMultilinear::new([FieldBuffer::<P>::from_values_in(&alloc, &scalars)]);
650			let prover = SparseDenseProductSumcheckProver::new(sparse, weight, sum);
651			let mut transcript = ProverTranscript::new(StdChallenger::default());
652			let output = prove_single(prover, &mut transcript);
653			(output.multilinear_evals, transcript.finalize())
654		};
655
656		assert_eq!(from_heap.1, from_arena.1, "both storages must prove the same rounds");
657		assert_eq!(from_heap.0, from_arena.0, "and reduce to the same evaluations");
658	}
659
660	/// Draws `n_entries` entries at uniformly random indices, so repeats arise on their own.
661	fn random_sparse(
662		mut rng: impl rand::Rng,
663		n_vars: usize,
664		n_entries: usize,
665	) -> Vec<SparseEntry<F>> {
666		(0..n_entries)
667			.map(|_| (rng.random_range(0..1 << n_vars), F::random(&mut rng)))
668			.collect()
669	}
670
671	proptest! {
672		#![proptest_config(ProptestConfig::with_cases(32))]
673
674		// Invariant: the sumcheck verifier accepts every round, and the evaluation claims the
675		// prover reduces to are those of the two multilinears at the challenge point.
676		//
677		// Indices are drawn uniformly over a hypercube that may be narrower than one packed
678		// element, so repeated indices, unused vertices, entry lists longer than the hypercube and
679		// dead packed lanes all arise on their own.
680		#[test]
681		fn prove_verify_matches_the_materialized_multilinears(
682			n_vars in 1usize..=8,
683			n_entries in 0usize..=100,
684			seed in any::<u64>(),
685		) {
686			let mut rng = StdRng::seed_from_u64(seed);
687
688			let sparse = random_sparse(&mut rng, n_vars, n_entries);
689			let dense = random_field_buffer::<P>(&mut rng, n_vars);
690			prove_verify(sparse, &dense);
691		}
692	}
693
694	// Entries are not deduplicated, so a multilinear whose every entry shares one index has to
695	// prove out the same as its materialized form. Uniform indices rarely pile up like this.
696	#[test]
697	fn test_sparse_dense_product_sumcheck_repeated_indices() {
698		let n_vars = 6;
699		let mut rng = StdRng::seed_from_u64(0);
700
701		let sparse = (0..10).map(|_| (23, F::random(&mut rng))).collect();
702		let dense = random_field_buffer::<P>(&mut rng, n_vars);
703		prove_verify(sparse, &dense);
704	}
705}