p3_security/multilinear.rs
1//! Algebraic error sources in the multilinear AIR reduction.
2//!
3//! These are union-bound contributions for independent uniform transcript challenges,
4//! before commitment openings. They inherit the caller's Fiat-Shamir assumptions;
5//! they do not certify a concrete hash or challenger. No grinding credit is taken.
6//!
7//! The sumcheck bound is the sum of its round degrees divided by the challenge
8//! space size (Thaler, *Proofs, Arguments, and Zero-Knowledge*, Chapter 4,
9//! <https://people.cs.georgetown.edu/jthaler/ProofsArgsAndZK.pdf>).
10//! The lookup fingerprint bound is the cleared-denominator argument described in
11//! [`crate::logup`]. Fractional GKR and coupling counts below follow the concrete
12//! multi-STARK transcript, rather than treating the lookup fingerprint as its
13//! entire reduction error.
14
15use alloc::vec::Vec;
16
17use libm::log2;
18
19use crate::{ErrorBits, SecurityTerm};
20
21/// Label for the bit-alphabet ring-switch term.
22pub const BIT_RING_SWITCH_LABEL: &str = "bit-ring-switch";
23
24/// Label for the independent column-point challenge in a batched opening.
25pub const COLUMN_BATCH_LABEL: &str = "column-batching";
26
27/// Error of batching `num_batches` column openings with `k` fresh coordinates each.
28///
29/// Every coordinate is sampled independently from the transcript after the claimed
30/// column values are bound. A nonzero discrepancy therefore survives with probability
31/// at most `num_batches * k / |F|`.
32#[must_use]
33pub fn column_batch_error(num_batches: usize, k: usize, field_bits: usize) -> ErrorBits {
34 if num_batches == 0 || k == 0 {
35 return ErrorBits::from_log2(f64::INFINITY);
36 }
37 // Keep the product in the logarithm's domain: usize multiplication can overflow even
38 // though the union-bound degree is representable as a floating-point number.
39 let log_challenges = log2(num_batches as f64) + log2(k as f64);
40 ErrorBits::from_log2(field_bits as f64 - log_challenges)
41}
42
43/// The labelled column-batching soundness term.
44#[must_use]
45pub fn column_batch_term(num_batches: usize, k: usize, field_bits: usize) -> SecurityTerm {
46 SecurityTerm::new(
47 COLUMN_BATCH_LABEL,
48 column_batch_error(num_batches, k, field_bits),
49 )
50}
51
52/// Error of reducing claims about a bit witness to claims about the elements packing it.
53///
54/// The single-tensor case of [`bit_ring_switch_tensors_error`], with `num_tensors = 1`.
55///
56/// ```text
57/// error = (absorbed_log + 2 * surviving) / |F|
58/// ```
59///
60/// # Arguments
61///
62/// - `num_reductions`: points reduced, one reduction each.
63/// - `absorbed_log`: log of the bits one packed element holds.
64/// - `surviving_variables`: variables the packed multilinear has.
65/// - `field_bits`: bit width of the field the challenges are drawn from.
66#[must_use]
67pub fn bit_ring_switch_error(
68 num_reductions: usize,
69 absorbed_log: usize,
70 surviving_variables: usize,
71 field_bits: usize,
72) -> ErrorBits {
73 bit_ring_switch_tensors_error(
74 num_reductions,
75 1,
76 absorbed_log,
77 surviving_variables,
78 field_bits,
79 )
80}
81
82/// The bit-alphabet ring-switch term, labelled for a report.
83#[must_use]
84pub fn bit_ring_switch_term(
85 num_reductions: usize,
86 absorbed_log: usize,
87 surviving_variables: usize,
88 field_bits: usize,
89) -> SecurityTerm {
90 bit_ring_switch_tensors_term(
91 num_reductions,
92 1,
93 absorbed_log,
94 surviving_variables,
95 field_bits,
96 )
97}
98
99/// Error of reducing claims about a bit witness to claims about the elements packing it,
100/// batching `num_tensors` claims at the same point under powers of a challenge alpha.
101///
102/// One element holds `2^absorbed_log` bits, so a point of `n` variables splits in two:
103///
104/// ```text
105/// absorbed_log coordinates inside one element
106/// n - absorbed_log coordinates addressing the elements
107/// ```
108///
109/// One reduction draws `absorbed_log` batching coordinates and, when `num_tensors > 1`, one
110/// challenge alpha.
111/// The batched row claim is multilinear in the coordinates and of degree `num_tensors - 1`
112/// in alpha. It then runs one degree-two sumcheck round per surviving variable.
113///
114/// ```text
115/// error = (absorbed_log + (num_tensors - 1) + 2 * surviving) / |F|
116/// ```
117///
118/// Reductions at different points share no challenge, so `k` of them union to `k` times that.
119/// Claims folded into one sumcheck pay this once, beside [`bit_ring_switch_claim_batching_error`].
120///
121/// The commitment the surviving claims are discharged against charges its own budget.
122///
123/// # Arguments
124///
125/// - `num_reductions`: points reduced, one reduction each.
126/// - `num_tensors`: tensors batched together at each point under powers of alpha.
127/// - `absorbed_log`: log of the bits one packed element holds.
128/// - `surviving_variables`: variables the packed multilinear has.
129/// - `field_bits`: bit width of the field the challenges are drawn from.
130#[must_use]
131pub fn bit_ring_switch_tensors_error(
132 num_reductions: usize,
133 num_tensors: usize,
134 absorbed_log: usize,
135 surviving_variables: usize,
136 field_bits: usize,
137) -> ErrorBits {
138 // No reduction runs, so no challenge separates anything.
139 if num_reductions == 0 {
140 return ErrorBits::from_log2(f64::INFINITY);
141 }
142 // Rounds per reduction: one batching draw per absorbed coordinate, degree
143 // num_tensors - 1 in the alpha challenge, two per sumcheck round.
144 let per_reduction = absorbed_log as f64
145 + num_tensors.saturating_sub(1) as f64
146 + 2.0 * surviving_variables as f64;
147 // A reduction over a point the packing absorbs whole, with a single tensor, runs no
148 // sumcheck round and no batching draw.
149 if per_reduction == 0.0 {
150 return ErrorBits::from_log2(f64::INFINITY);
151 }
152 ErrorBits::from_log2(field_bits as f64 - log2(num_reductions as f64 * per_reduction))
153}
154
155/// The bit-alphabet ring-switch term over batched tensors, labelled for a report.
156#[must_use]
157pub fn bit_ring_switch_tensors_term(
158 num_reductions: usize,
159 num_tensors: usize,
160 absorbed_log: usize,
161 surviving_variables: usize,
162 field_bits: usize,
163) -> SecurityTerm {
164 SecurityTerm::new(
165 BIT_RING_SWITCH_LABEL,
166 bit_ring_switch_tensors_error(
167 num_reductions,
168 num_tensors,
169 absorbed_log,
170 surviving_variables,
171 field_bits,
172 ),
173 )
174}
175
176/// Label for the challenge folding several bit ring-switch claims into one sumcheck.
177pub const BIT_RING_SWITCH_CLAIM_BATCHING_LABEL: &str = "bit-ring-switch-claim-batching";
178
179/// Error of folding `num_claims` bit ring-switch claims into one sumcheck under powers of lambda.
180///
181/// Every claim's elements are bound before lambda is drawn.
182/// A wrong element leaves a batched starting sum of degree `num_claims - 1` in lambda.
183///
184/// ```text
185/// error = (num_claims - 1) / |F|
186/// ```
187///
188/// The batch pays [`bit_ring_switch_tensors_error`] once, at `num_reductions = 1`, beside this.
189/// That replaces `num_claims` reductions, each with its own rounds.
190///
191/// # Arguments
192///
193/// - `num_claims`: claims folded together, one point each.
194/// - `field_bits`: bit width of the field lambda is drawn from.
195#[must_use]
196pub fn bit_ring_switch_claim_batching_error(num_claims: usize, field_bits: usize) -> ErrorBits {
197 // Zero or one claim draws no lambda.
198 if num_claims <= 1 {
199 return ErrorBits::from_log2(f64::INFINITY);
200 }
201 ErrorBits::from_log2(field_bits as f64 - log2((num_claims - 1) as f64))
202}
203
204/// The claim-batching term of a batch of bit ring-switch claims, labelled for a report.
205#[must_use]
206pub fn bit_ring_switch_claim_batching_term(num_claims: usize, field_bits: usize) -> SecurityTerm {
207 SecurityTerm::new(
208 BIT_RING_SWITCH_CLAIM_BATCHING_LABEL,
209 bit_ring_switch_claim_batching_error(num_claims, field_bits),
210 )
211}
212
213/// Verifier-derived shape of the AIR-to-opening reduction.
214#[derive(Clone, Copy, Debug)]
215pub struct MultilinearAirParams {
216 /// Number of AIRs batched with powers of beta.
217 pub num_instances: usize,
218 /// Largest number of constraints batched with powers of alpha in any AIR.
219 pub max_num_constraints: usize,
220 /// Number of variables in the common zerocheck cube, including lookup blocks.
221 pub num_variables: usize,
222 /// Largest constraint or lookup-expression degree, before the equality weight.
223 pub constraint_degree: usize,
224 /// Fractional lookup reduction, if the statement declares one.
225 pub lookup: Option<MultilinearLookupParams>,
226 /// Univariate-skip round, if the statement opens with one.
227 pub skip: Option<MultilinearSkipParams>,
228 /// Indexed-lookup reduction, if the statement declares one.
229 pub logup_star: Option<MultilinearLogupStarParams>,
230}
231
232/// Shape of a univariate-skip round opening the constraint sumcheck.
233///
234/// A skip round binds several variables with one challenge instead of one each.
235/// That moves error out of the per-round terms into one reconstruction term.
236///
237/// The two shapes are charged apart, rather than one bounding the other.
238#[derive(Clone, Copy, Debug)]
239pub struct MultilinearSkipParams {
240 /// Dimension of the subspace the round polynomial vanishes on.
241 ///
242 /// This is how many variables the round binds in one go.
243 pub log_size: usize,
244 /// Dimension of the subspace the round polynomial is transmitted on.
245 ///
246 /// The verifier admits any polynomial of degree below this size.
247 /// That is what the reconstruction term charges for.
248 ///
249 /// This must be the dimension the verifier actually interpolates on.
250 /// It is not a value re-derived from the constraint degree.
251 /// A domain wider than the verifier reads would be charged too little.
252 pub log_extended: usize,
253 /// Number of committed polynomials the opening reduction batches at once.
254 ///
255 /// One challenge separates them, and separating `n` costs `(n - 1)/q`.
256 pub num_polynomials: usize,
257}
258
259/// Exact tuple count and padded GKR shape derived from the lookup plan.
260#[derive(Clone, Copy, Debug)]
261pub struct MultilinearLookupParams {
262 /// Arity of the padded fraction tables, hence number of GKR layers.
263 pub num_variables: usize,
264 /// Sum of tuple counts times their respective trace heights, excluding padding.
265 pub num_fractions: usize,
266 /// Largest payload width; the next power is reserved for the bus identifier.
267 pub max_message_width: usize,
268}
269
270/// Largest subspace dimension a term may be computed from.
271///
272/// Two of the skip terms raise two to a dimension.
273/// At or above the word width that shift leaves range.
274///
275/// It panics in debug builds and drops the term in release.
276/// No real statement comes near it, so refusing the shape is the safe reading.
277const SHIFT_LIMIT: usize = 63;
278/// Shape of the indexed-lookup reduction, derived from its plan.
279///
280/// A reader names the entry it reads, and the values it pulled are claimed at one point.
281///
282/// The reduction turns those claims into claims about the table and the position column.
283#[derive(Clone, Copy, Debug)]
284pub struct MultilinearLogupStarParams {
285 /// Arity of the padded leaf table, hence the number of GKR layers.
286 pub num_variables: usize,
287 /// Leaves carrying a fraction: every reader row, and every entry of every table.
288 ///
289 /// Padding leaves carry a zero over a one, so they have no pole and are excluded.
290 pub num_leaves: usize,
291 /// Largest number of readers pulling from any one table.
292 pub max_readers_per_table: usize,
293 /// Variable count of the tallest reader, which the claim point spans.
294 pub max_reader_variables: usize,
295 /// Arity of the widest table, hence the product sumcheck's round count.
296 pub max_table_variables: usize,
297 /// Column claims the reduction closes on, counted over every table.
298 pub num_column_claims: usize,
299}
300
301/// Terms to union-compose with each prescribed-point PCS opening and a hash cap.
302///
303/// `field_bits` and `nonzero_field_bits` must be lower bounds on respectively
304/// `log2(q)` and `log2(q - 1)`. The latter accounts for the zerocheck's nonzero
305/// tau rejection sampling. A degree-zero term is omitted because that batching
306/// challenge cannot erase a nonzero claim. Invalid scalar shapes yield zero bits.
307pub fn reduction_terms(
308 air: &MultilinearAirParams,
309 field_bits: usize,
310 nonzero_field_bits: usize,
311) -> Vec<SecurityTerm> {
312 // Every scalar a term uses is checked before any term is formed.
313 // A shape this function cannot charge honestly yields zero bits.
314 // The alternative is a term that silently drops, rounds down, or overflows.
315 let skip_is_valid = air.skip.is_none_or(|skip| {
316 // The transmitted dimension has to exceed the skipped one.
317 // Otherwise the term is zero and the round is charged nothing.
318 skip.log_extended > skip.log_size
319 // Both dimensions reach a shift, kept in range by the widths below.
320 && skip.log_extended < SHIFT_LIMIT
321 // A round cannot bind more variables than the statement has.
322 && skip.log_size <= air.num_variables
323 // An empty batch has no claims to separate and no reduction to run.
324 && skip.num_polynomials > 0
325 });
326 if air.num_instances == 0
327 || air.num_variables == 0
328 || air.constraint_degree == 0
329 || !skip_is_valid
330 {
331 return alloc::vec![SecurityTerm::new(
332 "invalid-multilinear-shape",
333 ErrorBits::from_log2(0.0)
334 )];
335 }
336 let mut terms = Vec::new();
337 let mut add = |label, degree: f64, bits: usize| {
338 if degree > 0.0 {
339 terms.push(SecurityTerm::new(
340 label,
341 ErrorBits::from_log2((bits as f64 - log2(degree)).max(0.0)),
342 ));
343 }
344 };
345 add(
346 "constraint-batching",
347 air.max_num_constraints.saturating_sub(1) as f64,
348 field_bits,
349 );
350 add(
351 "air-batching",
352 air.num_instances.saturating_sub(1) as f64,
353 field_bits,
354 );
355 // A skip round takes its variables out of both the point and the rounds.
356 //
357 // plain: a point over m variables, then m rounds
358 // skip: a point over m - k, one skip round, then m - k rounds
359 //
360 // The skipped variables are never drawn into the equality point.
361 // The zerocheck term therefore shrinks with the round count.
362 let skipped = air.skip.map_or(0, |skip| skip.log_size);
363 let kept = air.num_variables.saturating_sub(skipped);
364
365 // A fixed nonzero constraint table has a multilinear extension. Its
366 // evaluation at tau vanishes with probability at most h/(q-1). The tail
367 // inherited from GKR is also rejection-sampled over the nonzero elements.
368 add("zerocheck", kept as f64, nonzero_field_bits);
369
370 if let Some(skip) = air.skip {
371 // The verifier reconstructs the round polynomial itself, admitting any
372 // below the extension size that vanishes on the subspace.
373 //
374 // admitted 2^(k + e) - 1 what the interpolation accepts
375 // honest d * (2^k - 1) what a correct prover sends
376 //
377 // A dishonest message is separated over whichever of the two is wider.
378 // A narrow transmitted domain does not make the round cheap.
379 // It makes the honest degree binding, and honest proofs stop fitting.
380 let admitted = ((1u64 << skip.log_extended) - 1) as f64;
381 let honest = air.constraint_degree as f64 * ((1u64 << skip.log_size) - 1) as f64;
382 add("skip-reconstruction", admitted.max(honest), field_bits);
383
384 // Collapsing the blend takes one degree-two round per skipped variable.
385 add("skip-opening", skipped as f64 * 2.0, field_bits);
386
387 // One challenge separates the polynomials the blend is taken over.
388 // Its powers keep their claims apart, so a wider batch costs more.
389 add(
390 "skip-opening-batching",
391 skip.num_polynomials.saturating_sub(1) as f64,
392 field_bits,
393 );
394 }
395
396 // The equality weight raises the native AIR degree by one in every round.
397 add(
398 "constraint-sumcheck",
399 kept as f64 * (air.constraint_degree as f64 + 1.0),
400 field_bits,
401 );
402 if let Some(lookup) = air.lookup {
403 add(
404 "logup-fingerprint",
405 lookup.num_fractions as f64 * (lookup.max_message_width as f64 + 2.0),
406 field_bits,
407 );
408 let layers = lookup.num_variables as f64;
409 // Each layer has one numerator/denominator batching draw and one
410 // branch draw. Layer i additionally has i cubic sumcheck rounds.
411 add(
412 "fractional-gkr",
413 2.0 * layers + 3.0 * layers * (layers - 1.0) / 2.0,
414 nonzero_field_bits,
415 );
416 add("lookup-opening-link", 1.0, field_bits); // theta
417 add("lookup-air-link", 1.0, field_bits); // eta
418 }
419 if let Some(logup_star) = air.logup_star {
420 // Readers of one table are combined under consecutive powers of one challenge.
421 //
422 // Per-reader errors reach the batched claim as a polynomial in that challenge.
423 //
424 // Its coefficients are the errors, so it vanishes only at one of its roots.
425 add(
426 "logup-star-reader-batching",
427 logup_star.max_readers_per_table.saturating_sub(1) as f64,
428 field_bits,
429 );
430 // The reduction rests on one rational identity per table:
431 //
432 // sum_i w_i / (c - iota(I_i)) - sum_v Y_v / (c - iota(v)) = 0
433 //
434 // It holds for every `c` exactly when `Y` is the pushforward of the positions.
435 //
436 // Clearing the denominators leaves a numerator of degree below the pole count.
437 //
438 // Every pole is a reader row or a table entry.
439 //
440 // One table's challenge varies while every other table's is held fixed.
441 //
442 // The others then move the identity by a constant rather than cancelling it.
443 //
444 // Clearing that constant against the denominator adds one degree.
445 //
446 // The event is bounded by the largest table's pole count.
447 //
448 // Summing over tables is what is charged.
449 //
450 // That is safe, and looser than the bound by up to the table count.
451 //
452 // Each challenge is rejection-sampled away from zero.
453 //
454 // The first entry embeds to zero, and would otherwise zero a denominator.
455 add(
456 "logup-star-entry-challenge",
457 logup_star.num_leaves as f64,
458 nonzero_field_bits,
459 );
460 // The reduction also inherits the point its claims are taken at.
461 //
462 // Turning `X(r) = <T, I_* eq_r>` into `X = T . I` is a Schwartz-Zippel event.
463 //
464 // So is the range check the argument gets for free.
465 //
466 // One wrong pulled value survives every check above where its weight vanishes.
467 //
468 // The weight is multilinear in the reader's own variables.
469 //
470 // Its degree is therefore their count.
471 add(
472 "logup-star-claim-point",
473 logup_star.max_reader_variables as f64,
474 field_bits,
475 );
476 // The same reduction the lookup argument uses, over this statement's leaf table.
477 let layers = logup_star.num_variables as f64;
478 add(
479 "logup-star-fractional-gkr",
480 2.0 * layers + 3.0 * layers * (layers - 1.0) / 2.0,
481 nonzero_field_bits,
482 );
483 // The product sumcheck's summand is a pushforward times a column.
484 //
485 // Both are multilinear, so every round polynomial is quadratic.
486 add(
487 "logup-star-product-sumcheck",
488 2.0 * logup_star.max_table_variables as f64,
489 field_bits,
490 );
491 // Every column claim in the reduction earns its own power of one challenge.
492 //
493 // Errors across tables and columns cancel only at a root of that polynomial.
494 add(
495 "logup-star-column-batching",
496 logup_star.num_column_claims.saturating_sub(1) as f64,
497 field_bits,
498 );
499 }
500 terms
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 /// One skip shape, with the three numbers every test below varies.
508 const fn skip(
509 log_size: usize,
510 log_extended: usize,
511 num_polynomials: usize,
512 ) -> MultilinearSkipParams {
513 MultilinearSkipParams {
514 log_size,
515 log_extended,
516 num_polynomials,
517 }
518 }
519
520 #[test]
521 fn reduction_charges_batching_zerocheck_and_every_sumcheck_round() {
522 let terms = reduction_terms(
523 &MultilinearAirParams {
524 num_instances: 2,
525 max_num_constraints: 3,
526 num_variables: 4,
527 constraint_degree: 3,
528 lookup: None,
529 skip: None,
530 logup_star: None,
531 },
532 100,
533 99,
534 );
535 // 2 alpha roots + 1 beta root + 4/(q-1) + 4*4 sumcheck roots.
536 // q >= 2^100 and q-1 >= 2^99, so the union bound is 27/2^100.
537 let bits = crate::ErrorBits::sum(
538 &terms
539 .iter()
540 .map(|term| term.bits)
541 .collect::<alloc::vec::Vec<_>>(),
542 )
543 .bits();
544 assert!((bits - (100.0 - libm::log2(27.0))).abs() < 1e-12);
545 }
546
547 /// The plain shape the skip variants are compared against.
548 fn plain(num_variables: usize) -> MultilinearAirParams {
549 MultilinearAirParams {
550 num_instances: 1,
551 max_num_constraints: 1,
552 num_variables,
553 constraint_degree: 2,
554 lookup: None,
555 logup_star: None,
556 skip: None,
557 }
558 }
559
560 /// The bits charged under one label.
561 fn charged(terms: &[SecurityTerm], label: &str) -> Option<f64> {
562 terms
563 .iter()
564 .find(|term| term.label == label)
565 .map(|term| term.bits.bits())
566 }
567
568 #[test]
569 fn a_statement_without_a_skip_is_charged_exactly_as_before() {
570 // Adding the skip shape must not move a statement that declares none.
571 // Fixture state: 10 variables, degree 2, no lookup.
572 //
573 // zerocheck 10
574 // constraint-sumcheck 10 * 3
575 // and no skip term at all
576 let terms = reduction_terms(&plain(10), 100, 99);
577
578 assert_eq!(charged(&terms, "zerocheck"), Some(99.0 - libm::log2(10.0)));
579 assert_eq!(
580 charged(&terms, "constraint-sumcheck"),
581 Some(100.0 - libm::log2(30.0))
582 );
583 assert_eq!(charged(&terms, "skip-reconstruction"), None);
584 assert_eq!(charged(&terms, "skip-opening"), None);
585 }
586
587 #[test]
588 fn a_skip_moves_error_out_of_the_rounds_and_into_the_reconstruction() {
589 // Fixture state: 10 variables, 6 skipped, transmitted on dimension 7.
590 //
591 // zerocheck 4 the point covers kept only
592 // constraint-sumcheck 4 * 3 four rounds, not ten
593 // skip-reconstruction 2^7 - 1 the degree admitted
594 // skip-opening 6 * 2 one degree-two round per skip
595 let mut params = plain(10);
596 params.skip = Some(skip(6, 7, 4));
597 let terms = reduction_terms(¶ms, 100, 99);
598
599 assert_eq!(charged(&terms, "zerocheck"), Some(99.0 - libm::log2(4.0)));
600 assert_eq!(
601 charged(&terms, "constraint-sumcheck"),
602 Some(100.0 - libm::log2(12.0))
603 );
604 assert_eq!(
605 charged(&terms, "skip-reconstruction"),
606 Some(100.0 - libm::log2(127.0))
607 );
608 assert_eq!(
609 charged(&terms, "skip-opening"),
610 Some(100.0 - libm::log2(12.0))
611 );
612 assert_eq!(
613 charged(&terms, "skip-opening-batching"),
614 Some(100.0 - libm::log2(3.0))
615 );
616 }
617
618 #[test]
619 fn the_honest_degree_binds_when_the_transmitted_domain_is_narrow() {
620 // Fixture state: degree 5, 6 skipped, one extra dimension sent.
621 //
622 // admitted 2^7 - 1 = 127
623 // honest 5 * (2^6 - 1) = 315
624 //
625 // The wider of the two is what a dishonest message is separated over.
626 let mut params = plain(10);
627 params.constraint_degree = 5;
628 params.skip = Some(skip(6, 7, 1));
629
630 assert_eq!(
631 charged(&reduction_terms(¶ms, 100, 99), "skip-reconstruction"),
632 Some(100.0 - libm::log2(315.0))
633 );
634 }
635
636 #[test]
637 fn the_admitted_degree_binds_when_the_transmitted_domain_is_wide() {
638 // Fixture state: degree 2, 6 skipped, two extra dimensions sent.
639 //
640 // admitted 2^8 - 1 = 255
641 // honest 2 * (2^6 - 1) = 126
642 //
643 // Here the interpolation is what admits more, which is the usual case.
644 let mut params = plain(10);
645 params.constraint_degree = 2;
646 params.skip = Some(skip(6, 8, 1));
647
648 assert_eq!(
649 charged(&reduction_terms(¶ms, 100, 99), "skip-reconstruction"),
650 Some(100.0 - libm::log2(255.0))
651 );
652 }
653
654 #[test]
655 fn a_single_polynomial_batch_costs_no_separating_draw() {
656 // One claim needs no challenge to keep it apart from anything.
657 let mut params = plain(10);
658 params.skip = Some(skip(6, 7, 1));
659
660 assert_eq!(
661 charged(&reduction_terms(¶ms, 100, 99), "skip-opening-batching"),
662 None
663 );
664 }
665
666 #[test]
667 fn a_skip_shape_no_term_can_charge_honestly_is_refused() {
668 // Each of these would otherwise be charged wrongly rather than refused:
669 //
670 // transmitted <= skipped the term is zero, so it vanishes
671 // transmitted at 63 the shift leaves range
672 // skipped > variables a round wider than the statement
673 // empty batch nothing to separate, nothing to run
674 for (log_size, log_extended, num_polynomials) in
675 [(6, 6, 1), (6, 5, 1), (6, 63, 1), (11, 12, 1), (6, 7, 0)]
676 {
677 let mut params = plain(10);
678 params.skip = Some(skip(log_size, log_extended, num_polynomials));
679 let terms = reduction_terms(¶ms, 100, 99);
680
681 assert_eq!(
682 terms.len(),
683 1,
684 "{log_size} {log_extended} {num_polynomials}"
685 );
686 assert_eq!(terms[0].label, "invalid-multilinear-shape");
687 assert_eq!(terms[0].bits.bits(), 0.0);
688 }
689 }
690
691 #[test]
692 fn a_skip_shape_every_term_can_charge_is_accepted() {
693 // The boundary just inside each refusal, so the guard is not too tight.
694 for (log_size, log_extended, num_polynomials) in [(6, 7, 1), (10, 11, 1), (6, 62, 1)] {
695 let mut params = plain(10);
696 params.skip = Some(skip(log_size, log_extended, num_polynomials));
697 let terms = reduction_terms(¶ms, 100, 99);
698
699 assert!(
700 terms.len() > 1,
701 "{log_size} {log_extended} {num_polynomials}"
702 );
703 }
704 }
705
706 #[test]
707 fn the_reconstruction_term_dominates_what_the_rounds_gave_up() {
708 // The skip is not a free saving in soundness.
709 // It trades many small round terms for one large reconstruction term.
710 // Nothing may read the skip as strictly cheaper, so this pins it.
711 let mut params = plain(10);
712 params.skip = Some(skip(6, 7, 1));
713
714 let plain_terms = reduction_terms(&plain(10), 100, 99);
715 let skip_terms = reduction_terms(¶ms, 100, 99);
716
717 let total = |terms: &[SecurityTerm]| {
718 ErrorBits::sum(
719 &terms
720 .iter()
721 .map(|term| term.bits)
722 .collect::<alloc::vec::Vec<_>>(),
723 )
724 .bits()
725 };
726
727 // The rounds the skip gave up: the plain terms, less the kept ones.
728 // Fewer bits is more error, so the comparison is on error either side.
729 let error = |bits: f64| libm::exp2(-bits);
730 let given_up = error(charged(&plain_terms, "zerocheck").unwrap())
731 + error(charged(&plain_terms, "constraint-sumcheck").unwrap())
732 - error(charged(&skip_terms, "zerocheck").unwrap())
733 - error(charged(&skip_terms, "constraint-sumcheck").unwrap());
734 let reconstruction = error(charged(&skip_terms, "skip-reconstruction").unwrap());
735
736 // The one term the skip adds outweighs every term it removed.
737 assert!(
738 reconstruction > given_up,
739 "reconstruction={reconstruction} given_up={given_up}"
740 );
741
742 // And so the composed budget is worse, not better.
743 assert!(
744 total(&skip_terms) < total(&plain_terms),
745 "the skip costs bits overall: skip={} plain={}",
746 total(&skip_terms),
747 total(&plain_terms)
748 );
749 }
750
751 #[test]
752 fn skipping_every_variable_leaves_no_rounds_to_charge() {
753 // The widest legal skip binds the whole cube, so residual terms go
754 // than going negative.
755 let mut params = plain(6);
756 params.skip = Some(skip(6, 7, 1));
757 let terms = reduction_terms(¶ms, 100, 99);
758
759 assert_eq!(charged(&terms, "zerocheck"), None);
760 assert_eq!(charged(&terms, "constraint-sumcheck"), None);
761 assert_eq!(
762 charged(&terms, "skip-reconstruction"),
763 Some(100.0 - libm::log2(127.0))
764 );
765 }
766
767 #[test]
768 fn lookup_charges_fingerprint_gkr_and_both_links() {
769 let params = MultilinearAirParams {
770 num_instances: 1,
771 max_num_constraints: 1,
772 num_variables: 5,
773 constraint_degree: 1,
774 lookup: Some(MultilinearLookupParams {
775 num_variables: 5,
776 num_fractions: 64,
777 max_message_width: 1,
778 }),
779 skip: None,
780 logup_star: None,
781 };
782 let terms = reduction_terms(¶ms, 100, 99);
783 let find = |label| {
784 terms
785 .iter()
786 .find(|term| term.label == label)
787 .unwrap()
788 .bits
789 .bits()
790 };
791 assert!((find("logup-fingerprint") - (100.0 - libm::log2(192.0))).abs() < 1e-12);
792 // Five lambda and branch draws, plus 0+1+2+3+4 cubic rounds.
793 assert!((find("fractional-gkr") - (99.0 - libm::log2(40.0))).abs() < 1e-12);
794 assert_eq!(find("lookup-opening-link"), 100.0);
795 assert_eq!(find("lookup-air-link"), 100.0);
796 }
797
798 /// A statement whose indexed lookups have a shape distinct in every dimension.
799 ///
800 /// leaf table 2^5 padded, 24 leaves carrying a fraction
801 /// readers at most 3 on one table
802 /// tables widest over 4 variables, 6 column claims in all
803 fn logup_star_params() -> MultilinearAirParams {
804 MultilinearAirParams {
805 num_instances: 1,
806 max_num_constraints: 1,
807 num_variables: 5,
808 constraint_degree: 1,
809 lookup: None,
810 skip: None,
811 logup_star: Some(MultilinearLogupStarParams {
812 num_variables: 5,
813 num_leaves: 24,
814 max_readers_per_table: 3,
815 max_reader_variables: 3,
816 max_table_variables: 4,
817 num_column_claims: 6,
818 }),
819 }
820 }
821
822 #[test]
823 fn logup_star_charges_every_challenge_it_draws() {
824 let terms = reduction_terms(&logup_star_params(), 100, 99);
825 let find = |label| {
826 terms
827 .iter()
828 .find(|term| term.label == label)
829 .unwrap()
830 .bits
831 .bits()
832 };
833
834 // Two roots separate three readers.
835 assert!((find("logup-star-reader-batching") - (100.0 - libm::log2(2.0))).abs() < 1e-12);
836
837 // One pole per leaf carrying a fraction, drawn away from zero.
838 assert!((find("logup-star-entry-challenge") - (99.0 - libm::log2(24.0))).abs() < 1e-12);
839
840 // Five layers: two draws each, plus 0+1+2+3+4 cubic rounds.
841 assert!((find("logup-star-fractional-gkr") - (99.0 - libm::log2(40.0))).abs() < 1e-12);
842
843 // Four rounds of a quadratic summand.
844 assert!((find("logup-star-product-sumcheck") - (100.0 - libm::log2(8.0))).abs() < 1e-12);
845
846 // Five roots separate six column claims.
847 assert!((find("logup-star-column-batching") - (100.0 - libm::log2(5.0))).abs() < 1e-12);
848
849 // The tallest reader's variables, over the field its claim point comes from.
850 assert!((find("logup-star-claim-point") - (100.0 - libm::log2(3.0))).abs() < 1e-12);
851 }
852
853 #[test]
854 fn a_batching_term_follows_the_count_it_separates() {
855 // Doubling what a challenge separates doubles the degree a prover needs a root of.
856 //
857 // The term therefore loses exactly one bit.
858 //
859 // 3 readers -> 2 roots -> 100 - log2(2)
860 // 5 readers -> 4 roots -> 100 - log2(4)
861 let mut wider = logup_star_params();
862 let mut plan = wider.logup_star.unwrap();
863 plan.max_readers_per_table = 5;
864 wider.logup_star = Some(plan);
865
866 let bits = |params: &MultilinearAirParams| {
867 reduction_terms(params, 100, 99)
868 .iter()
869 .find(|term| term.label == "logup-star-reader-batching")
870 .unwrap()
871 .bits
872 .bits()
873 };
874 assert!((bits(&logup_star_params()) - bits(&wider) - 1.0).abs() < 1e-12);
875 }
876
877 #[test]
878 fn one_reader_per_table_needs_no_batching_term() {
879 // A single reader is already its own claim.
880 //
881 // The challenge separates nothing, and cannot erase an error.
882 let mut alone = logup_star_params();
883 let mut plan = alone.logup_star.unwrap();
884 plan.max_readers_per_table = 1;
885 alone.logup_star = Some(plan);
886
887 let terms = reduction_terms(&alone, 100, 99);
888 assert!(
889 !terms
890 .iter()
891 .any(|term| term.label == "logup-star-reader-batching")
892 );
893 }
894
895 #[test]
896 fn a_statement_without_indexed_lookups_charges_nothing_new() {
897 // The reduction is opt-in.
898 //
899 // A batch declaring none must cost what it did before the reduction existed.
900 let terms = reduction_terms(
901 &MultilinearAirParams {
902 num_instances: 2,
903 max_num_constraints: 3,
904 num_variables: 4,
905 constraint_degree: 3,
906 lookup: None,
907 skip: None,
908 logup_star: None,
909 },
910 100,
911 99,
912 );
913 assert!(
914 !terms
915 .iter()
916 .any(|term| term.label.starts_with("logup-star-"))
917 );
918 }
919
920 #[test]
921 fn the_bit_ring_switch_charges_its_draws_and_its_rounds() {
922 // Invariant: one reduction costs (absorbed + 2 * surviving) / |F|.
923 //
924 // absorbed = 7, surviving = 4 -> 7 + 8 = 15 draws
925 // one reduction, 128-bit field -> 128 - log2(15) bits
926 let one = bit_ring_switch_error(1, 7, 4, 128).bits();
927 assert!((one - (128.0 - log2(15.0))).abs() < 1e-9, "{one}");
928
929 // Reductions at different points share no challenge, so k of them cost k times that.
930 //
931 // 4 reductions -> 60 draws -> exactly two bits below one reduction
932 let four = bit_ring_switch_error(4, 7, 4, 128).bits();
933 assert!((one - four - 2.0).abs() < 1e-9, "{one} {four}");
934
935 // A wider field buys bits one for one, since the width enters additively.
936 let gap =
937 bit_ring_switch_error(4, 7, 4, 128).bits() - bit_ring_switch_error(4, 7, 4, 64).bits();
938 assert!((gap - 64.0).abs() < 1e-9, "{gap}");
939
940 // Nothing to reduce and nothing to round over both leave no error to charge.
941 assert!(bit_ring_switch_error(0, 7, 4, 128).bits().is_infinite());
942 assert!(bit_ring_switch_error(3, 0, 0, 128).bits().is_infinite());
943
944 // The term carries the same number under its own label.
945 let term = bit_ring_switch_term(4, 7, 4, 128);
946 assert_eq!(term.label, BIT_RING_SWITCH_LABEL);
947 assert_eq!(term.bits.bits(), four);
948 }
949
950 #[test]
951 fn column_batching_charges_one_coordinate_per_batch() {
952 let one = column_batch_error(1, 3, 128).bits();
953 let four = column_batch_error(4, 3, 128).bits();
954 assert!((one - (128.0 - log2(3.0))).abs() < 1e-9, "{one}");
955 assert!((one - four - 2.0).abs() < 1e-9, "{one} {four}");
956 assert!(column_batch_error(0, 3, 128).bits().is_infinite());
957 assert!(column_batch_error(4, 0, 128).bits().is_infinite());
958
959 let term = column_batch_term(4, 3, 128);
960 assert_eq!(term.label, COLUMN_BATCH_LABEL);
961 assert_eq!(term.bits.bits(), four);
962 }
963
964 #[test]
965 fn column_batching_does_not_cap_an_overflowing_count() {
966 let batches = usize::MAX;
967 let coordinates = usize::MAX;
968 let expected = 128.0 - log2(batches as f64) - log2(coordinates as f64);
969 let actual = column_batch_error(batches, coordinates, 128).bits();
970 assert!((actual - expected).abs() < 1e-9, "{actual} vs {expected}");
971 }
972
973 #[test]
974 fn successor_tensors_charge_their_batching_degree() {
975 // Invariant: K tensors batched under powers of alpha add K - 1 to the per-reduction degree.
976 //
977 // K = 1 -> the plain reduction, bit for bit
978 // K = 3 -> (7 + 2 + 2 * 4) / 2^128 per reduction
979 assert_eq!(
980 bit_ring_switch_tensors_error(4, 1, 7, 4, 128).bits(),
981 bit_ring_switch_error(4, 7, 4, 128).bits()
982 );
983 let three = bit_ring_switch_tensors_error(1, 3, 7, 4, 128).bits();
984 assert!((three - (128.0 - libm::log2(17.0))).abs() < 1e-9);
985 let term = bit_ring_switch_tensors_term(1, 3, 7, 4, 128);
986 assert_eq!(term.label, BIT_RING_SWITCH_LABEL);
987 }
988
989 #[test]
990 fn claim_batching_charges_the_degree_of_lambda() {
991 // Invariant: k claims folded under powers of lambda cost (k - 1) / |F|.
992 //
993 // k = 0, 1 -> no lambda, nothing to charge
994 // k = 5 -> 4 / 2^128, two bits below one draw
995 assert!(
996 bit_ring_switch_claim_batching_error(0, 128)
997 .bits()
998 .is_infinite()
999 );
1000 assert!(
1001 bit_ring_switch_claim_batching_error(1, 128)
1002 .bits()
1003 .is_infinite()
1004 );
1005 assert_eq!(bit_ring_switch_claim_batching_error(2, 128).bits(), 128.0);
1006 let five = bit_ring_switch_claim_batching_error(5, 128).bits();
1007 assert!((five - 126.0).abs() < 1e-9, "{five}");
1008
1009 // One batched reduction plus lambda beats k reductions of their own.
1010 //
1011 // batched (7 + 4 + 2 * 4) / 2^128 = 19 draws
1012 // per claim 5 * (7 + 2 * 4) / 2^128 = 75 draws
1013 let batched = bit_ring_switch_error(1, 7, 4, 128).bits();
1014 let separate = bit_ring_switch_error(5, 7, 4, 128).bits();
1015 let union = -log2(libm::exp2(-batched) + libm::exp2(-five));
1016 assert!((union - (128.0 - log2(19.0))).abs() < 1e-9, "{union}");
1017 assert!(union > separate);
1018
1019 let term = bit_ring_switch_claim_batching_term(5, 128);
1020 assert_eq!(term.label, BIT_RING_SWITCH_CLAIM_BATCHING_LABEL);
1021 assert_eq!(term.bits.bits(), five);
1022 }
1023}