Skip to main content

p3_security/whir/
mod.rs

1//! WHIR / STIR-style composition: error terms specific to RS-IOPs that
2//! combine sumcheck-based folding with random-linear-combination queries.
3//!
4//! The underlying proximity-gap and query primitives live in
5//! [`crate::assumption`]; this module orchestrates them into the
6//! protocol-level error budget pieces (OOD sampling, fold sumcheck,
7//! query-combination, folding PoW).
8
9pub mod hiding;
10
11pub use crate::assumption::SecurityAssumption;
12
13/// Label the composed opening error of this family is reported under.
14pub const WHIR_OPENING_LABEL: &str = "whir-opening";
15
16impl SecurityAssumption {
17    /// Initial claim-combination error in bits, before any folding or grinding.
18    ///
19    /// Combining `num_claims` constraints with powers `1, alpha, ..., alpha^(N-1)`
20    /// can hide a false claim at at most `N - 1` roots for each codeword in the
21    /// initial decoding list. The bound is `(N - 1) * L / |F|`. The count must
22    /// include both concrete openings and commitment-phase OOD claims.
23    /// Zero or one claim introduces no random-combination error.
24    #[must_use]
25    pub fn initial_claims_error(
26        &self,
27        field_size_bits: usize,
28        num_variables: usize,
29        log_inv_rate: usize,
30        num_claims: usize,
31    ) -> f64 {
32        if num_claims <= 1 {
33            return f64::INFINITY;
34        }
35        field_size_bits as f64
36            - libm::log2((num_claims - 1) as f64)
37            - self.list_size_bits(num_variables, log_inv_rate)
38    }
39
40    /// [`Self::ood_error`] at an explicit `log_eta`, rather than the regime's own default
41    /// safety margin ([`Self::log_eta`]).
42    ///
43    /// A caller deriving its own schedule of `eta` values across a protocol (STIR's per-round
44    /// bisection, for instance) needs the OOD error at each of those working points, not just
45    /// at the regime's fixed default.
46    #[must_use]
47    pub const fn ood_error_at_log_eta(
48        &self,
49        log_degree: usize,
50        log_inv_rate: usize,
51        field_size_bits: usize,
52        ood_samples: usize,
53        log_eta: f64,
54    ) -> f64 {
55        if matches!(self, Self::UniqueDecoding) {
56            return 0.;
57        }
58
59        let list_size_bits = self.list_size_bits_at_log_eta(log_degree, log_inv_rate, log_eta);
60
61        let error = 2. * list_size_bits + (log_degree * ood_samples) as f64;
62        (ood_samples * field_size_bits) as f64 + 1. - error
63    }
64
65    /// OOD sampling error (in bits). See Lemma 4.5 in STIR:
66    /// `error = L⁺² · (degree / |F|)^reps`.
67    /// The domain size is discounted as negligible relative to `|F|`.
68    #[must_use]
69    pub const fn ood_error(
70        &self,
71        log_degree: usize,
72        log_inv_rate: usize,
73        field_size_bits: usize,
74        ood_samples: usize,
75    ) -> f64 {
76        self.ood_error_at_log_eta(
77            log_degree,
78            log_inv_rate,
79            field_size_bits,
80            ood_samples,
81            self.log_eta_or_zero(log_inv_rate),
82        )
83    }
84
85    /// Smallest number of OOD samples (from the extension field) needed
86    /// for [`Self::ood_error`] to clear `security_level` bits.
87    ///
88    /// # Returns
89    ///
90    /// - `Some(0)` for unique decoding, which uses no OOD samples.
91    /// - `Some(n)` for the smallest count reaching the security level.
92    /// - `None` when no count in range suffices, i.e. the field is too small.
93    #[must_use]
94    pub fn determine_ood_samples(
95        &self,
96        security_level: usize,
97        log_degree: usize,
98        log_inv_rate: usize,
99        field_size_bits: usize,
100    ) -> Option<usize> {
101        if matches!(self, Self::UniqueDecoding) {
102            return Some(0);
103        }
104
105        // Each extra OOD sample adds roughly `field_size_bits - log_degree` bits.
106        // When the field is too small that term never reaches the target.
107        (1..64).find(|&ood_samples| {
108            self.ood_error(log_degree, log_inv_rate, field_size_bits, ood_samples)
109                >= security_level as f64
110        })
111    }
112
113    /// Sumcheck soundness term for the fold step (bits).
114    ///
115    /// During folding, the verifier samples a random challenge and
116    /// checks a degree-2 sumcheck identity. An adversary controlling a
117    /// list of L codewords can bias the check with probability at most
118    /// `L / |F|`:
119    ///
120    /// `bits = field_size_bits − (list_size_bits + 1)`
121    ///
122    /// The `+1` is the union bound over the list.
123    #[must_use]
124    pub const fn fold_sumcheck_error(
125        &self,
126        field_size_bits: usize,
127        num_variables: usize,
128        log_inv_rate: usize,
129    ) -> f64 {
130        self.fold_sumcheck_error_at_log_eta(
131            field_size_bits,
132            num_variables,
133            log_inv_rate,
134            self.log_eta_or_zero(log_inv_rate),
135        )
136    }
137
138    /// [`Self::fold_sumcheck_error`] at an explicit `log_eta`, rather than the regime's own
139    /// default safety margin ([`Self::log_eta`]).
140    ///
141    /// A caller deriving its own schedule of `eta` values across a protocol (STIR's per-round
142    /// bisection, for instance) needs the fold-sumcheck error at each of those working
143    /// points, not just at the regime's fixed default.
144    #[must_use]
145    pub const fn fold_sumcheck_error_at_log_eta(
146        &self,
147        field_size_bits: usize,
148        num_variables: usize,
149        log_inv_rate: usize,
150        log_eta: f64,
151    ) -> f64 {
152        // List size at the current proximity parameter and code rate.
153        let list_size = self.list_size_bits_at_log_eta(num_variables, log_inv_rate, log_eta);
154
155        // Security = field size minus the adversary's advantage from list decoding.
156        field_size_bits as f64 - (list_size + 1.)
157    }
158
159    /// Query-combination soundness (bits). After STIR queries and OOD
160    /// samples, the verifier takes a random linear combination of all
161    /// evaluations; an adversary must fool this combination for every
162    /// codeword in the list:
163    ///
164    /// `error = (ood_samples + num_queries) · L⁺ / |F|`,
165    /// `bits  = field_size_bits − (log₂(ood + queries) + list_size_bits + 1)`.
166    #[must_use]
167    pub fn queries_combination_error(
168        &self,
169        field_size_bits: usize,
170        num_variables: usize,
171        log_inv_rate: usize,
172        ood_samples: usize,
173        num_queries: usize,
174    ) -> f64 {
175        self.queries_combination_error_at_log_eta(
176            field_size_bits,
177            num_variables,
178            log_inv_rate,
179            ood_samples,
180            num_queries,
181            self.log_eta_or_zero(log_inv_rate),
182        )
183    }
184
185    /// [`Self::queries_combination_error`] at an explicit `log_eta`, rather than the regime's
186    /// own default safety margin ([`Self::log_eta`]).
187    ///
188    /// A caller deriving its own schedule of `eta` values across a protocol (STIR's per-round
189    /// bisection, for instance) needs the query-combination error at each of those working
190    /// points, not just at the regime's fixed default.
191    #[must_use]
192    pub fn queries_combination_error_at_log_eta(
193        &self,
194        field_size_bits: usize,
195        num_variables: usize,
196        log_inv_rate: usize,
197        ood_samples: usize,
198        num_queries: usize,
199        log_eta: f64,
200    ) -> f64 {
201        // List size at the current proximity parameter.
202        let list_size = self.list_size_bits_at_log_eta(num_variables, log_inv_rate, log_eta);
203
204        // Total number of evaluation points available for the combination.
205        let log_combination = libm::log2((ood_samples + num_queries) as f64);
206
207        // Security = field size minus the adversary's advantage.
208        field_size_bits as f64 - (log_combination + list_size + 1.)
209    }
210
211    /// PoW bits needed at the fold step to bridge the weaker of the
212    /// proximity-gap and fold-sumcheck bounds to `security_level`.
213    /// Returns 0 when the algebraic bounds already meet the target.
214    #[must_use]
215    pub fn folding_pow_bits(
216        &self,
217        security_level: usize,
218        field_size_bits: usize,
219        num_variables: usize,
220        log_inv_rate: usize,
221    ) -> f64 {
222        // Arity 2 because each fold combines exactly 2 evaluations.
223        let prox_gaps_error = self.prox_gaps_error(num_variables, log_inv_rate, field_size_bits, 2);
224        let sumcheck_error = self.fold_sumcheck_error(field_size_bits, num_variables, log_inv_rate);
225
226        // The fold step is only as strong as its weakest component.
227        let error = prox_gaps_error.min(sumcheck_error);
228
229        // PoW closes the residual gap; zero means no grinding needed.
230        0_f64.max(security_level as f64 - error)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn initial_claims_charge_the_polynomial_degree_and_decoding_list() {
240        let cases = [
241            (SecurityAssumption::UniqueDecoding, 118.),
242            (SecurityAssumption::JohnsonBound, 113.67807190511263),
243            (SecurityAssumption::CapacityBound, 101.67807190511263),
244        ];
245        for (assumption, expected) in cases {
246            // 1025 powers have degree 1024. At degree 2^10 and rate 1/2,
247            // L is respectively 1, 20, and 20 * 2^12.
248            assert!((assumption.initial_claims_error(128, 10, 1, 1025) - expected).abs() < 1e-10);
249            assert!(
250                (assumption.initial_claims_error(128, 10, 1, 2049) - (expected - 1.)).abs() < 1e-10
251            );
252            for count in [0, 1] {
253                assert_eq!(
254                    assumption.initial_claims_error(128, 10, 1, count),
255                    f64::INFINITY
256                );
257            }
258        }
259    }
260
261    /// Field size in bits used by the WHIR-style budget regression test.
262    /// 5 × ⌈log₂(p_KoalaBear)⌉ — degree-5 KoalaBear extension.
263    const KOALABEAR_QUINTIC_BITS: usize = 155;
264
265    /// Practical ceiling for Fiat-Shamir grinding. Above ~30 bits,
266    /// grinding becomes impractical for honest provers.
267    const MAX_POW_BITS: f64 = 30.0;
268
269    #[test]
270    fn test_folding_pow_bits() {
271        let field_size_bits = 64;
272        let soundness = SecurityAssumption::CapacityBound;
273
274        let pow_bits = soundness.folding_pow_bits(
275            100, // Security level
276            field_size_bits,
277            10, // Number of variables
278            5,  // Log inverse rate
279        );
280
281        // PoW bits should never be negative
282        assert!(pow_bits >= 0.);
283    }
284
285    #[test]
286    fn jb_prox_gap_covers_security_level_minus_pow_over_koalabear_quintic() {
287        // Prox-gap bound alone clears (security_level - MAX_POW_BITS),
288        // so a feasible PoW budget bridges the rest.
289        let jb = SecurityAssumption::JohnsonBound;
290        let security_level: f64 = 128.0;
291        let min_required_bits = security_level - MAX_POW_BITS;
292
293        for log_inv_rate in 1..=2 {
294            for log_degree in 10..=22 {
295                let prox_gap_bits =
296                    jb.prox_gaps_error(log_degree, log_inv_rate, KOALABEAR_QUINTIC_BITS, 2);
297
298                assert!(
299                    prox_gap_bits > min_required_bits,
300                    "prox-gap below {min_required_bits:.0} bits at \
301                     log_degree={log_degree}, log_inv_rate={log_inv_rate}: \
302                     got {prox_gap_bits:.2}"
303                );
304            }
305        }
306    }
307
308    #[test]
309    fn determine_ood_samples_reports_infeasibility() {
310        let jb = SecurityAssumption::JohnsonBound;
311
312        // A 10-bit field cannot reach 100-bit security at log-degree 20.
313        // Each OOD sample adds at most ~ (10 - 20) < 0 bits, so the loop never
314        // hits the target and the search yields nothing.
315        assert_eq!(jb.determine_ood_samples(100, 20, 2, 10), None);
316
317        // A large field reaches the target with a small sample count.
318        assert!(jb.determine_ood_samples(100, 20, 2, 128).is_some());
319
320        // Unique decoding never needs OOD samples.
321        assert_eq!(
322            SecurityAssumption::UniqueDecoding.determine_ood_samples(100, 20, 2, 10),
323            Some(0)
324        );
325    }
326
327    #[test]
328    fn jb_full_security_budget_reaches_128_bits() {
329        // Full WHIR soundness budget reaches `security_level` at a
330        // representative configuration.
331        //
332        // PoW-eligible components (>= security_level - MAX_POW_BITS):
333        //   prox-gap, sumcheck, query-linear-combination
334        // Self-sustaining components (>= security_level):
335        //   out-of-domain sample, FRI query phase
336        // Total grinding must not exceed MAX_POW_BITS.
337        let jb = SecurityAssumption::JohnsonBound;
338        let security_level: usize = 128;
339        let min_with_pow = security_level as f64 - MAX_POW_BITS;
340        let log_degree = 20;
341        let log_inv_rate = 2;
342
343        let num_queries = jb.queries(security_level, log_inv_rate);
344        let ood_samples = jb
345            .determine_ood_samples(
346                security_level,
347                log_degree,
348                log_inv_rate,
349                KOALABEAR_QUINTIC_BITS,
350            )
351            .expect("quintic field is large enough for these parameters");
352
353        let prox_gap = jb.prox_gaps_error(log_degree, log_inv_rate, KOALABEAR_QUINTIC_BITS, 2);
354        let sumcheck = jb.fold_sumcheck_error(KOALABEAR_QUINTIC_BITS, log_degree, log_inv_rate);
355        let ood = jb.ood_error(
356            log_degree,
357            log_inv_rate,
358            KOALABEAR_QUINTIC_BITS,
359            ood_samples,
360        );
361        let query = jb.queries_error(log_inv_rate, num_queries);
362        let combination = jb.queries_combination_error(
363            KOALABEAR_QUINTIC_BITS,
364            log_degree,
365            log_inv_rate,
366            ood_samples,
367            num_queries,
368        );
369
370        assert!(
371            prox_gap >= min_with_pow,
372            "prox-gap {prox_gap:.2} bits < {min_with_pow:.0}"
373        );
374        assert!(
375            sumcheck >= min_with_pow,
376            "sumcheck {sumcheck:.2} bits < {min_with_pow:.0}"
377        );
378        assert!(
379            combination >= min_with_pow,
380            "combination {combination:.2} bits < {min_with_pow:.0}"
381        );
382        assert!(
383            ood >= security_level as f64,
384            "OOD {ood:.2} bits < {security_level}"
385        );
386        assert!(
387            query >= security_level as f64,
388            "query {query:.2} bits < {security_level}"
389        );
390
391        let pow = jb.folding_pow_bits(
392            security_level,
393            KOALABEAR_QUINTIC_BITS,
394            log_degree,
395            log_inv_rate,
396        );
397        assert!(
398            pow <= MAX_POW_BITS,
399            "PoW grinding {pow:.2} bits > {MAX_POW_BITS:.0} cap"
400        );
401    }
402}