Skip to main content

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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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}