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}