Skip to main content

openvm_stark_backend/soundness/
calculator.rs

1//! Soundness calculator for the SWIRL proof system.
2//!
3//! The SWIRL proof system consists of the following components:
4//! 1. LogUp GKR - Fractional sumcheck for interaction constraints
5//! 2. ZeroCheck - Batched constraint verification across AIRs
6//! 3. Stacked Reduction - Reduces trace evaluations to stacked polynomial evaluations
7//! 4. WHIR - Polynomial commitment opening via FRI-like folding
8//!
9//! Each component contributes to the overall soundness error, and the total security
10//! is the minimum across all components.
11//!
12//! Components add proof-of-work grinding (`*_pow_bits`) on top of their statistical soundness.
13//! Grinding bits count hash evaluations, so the attacker's cost for `g` grinding bits is
14//! `≈ 2^g · cost(H)` for the grinding hash `H` (the transcript hash). Bit totals are thus
15//! comparable only across configs that share a grinding hash.
16
17use std::f64;
18
19use crate::{
20    config::{ProximityRegime, SystemParams, WhirConfig},
21    WhirProximityStrategy,
22};
23
24#[derive(Clone, Debug)]
25pub struct SoundnessCalculator {
26    /// Security bits from LogUp α/β sampling and PoW.
27    pub logup_bits: f64,
28    /// Security bits from ordinary GKR sumcheck rounds.
29    pub gkr_sumcheck_bits: f64,
30    /// Security bits from GKR μ/λ batching per layer.
31    pub gkr_batching_bits: f64,
32    /// Security bits from ZeroCheck sumcheck after the fused input-layer boundary.
33    pub zerocheck_sumcheck_bits: f64,
34    /// Security bits from the fused GKR/ZeroCheck input boundary and constraint batching.
35    pub constraint_batching_bits: f64,
36    /// Security bits from stacked reduction sumcheck.
37    pub stacked_reduction_bits: f64,
38    /// Security bits from WHIR (minimum across all rounds and error sources).
39    pub whir_bits: f64,
40    /// Detailed WHIR soundness breakdown.
41    pub whir_details: WhirSoundnessCalculator,
42    /// Total security bits (minimum of all components).
43    pub total_bits: f64,
44}
45
46/// WHIR soundness breakdown by error source.
47#[derive(Clone, Debug)]
48pub struct WhirSoundnessCalculator {
49    /// Security bits from μ batching (initial polynomial batching).
50    pub mu_batching_bits: f64,
51    /// Minimum round-by-round security bits across folding rounds, i.e. `ε_fold`.
52    pub fold_rbr_bits: f64,
53    /// Security bits from proximity gaps (folding soundness).
54    pub proximity_gaps_bits: f64,
55    /// Security bits from sumcheck within WHIR rounds.
56    pub sumcheck_bits: f64,
57    /// Security bits from out-of-domain sampling.
58    pub ood_rbr_bits: f64,
59    /// Minimum round-by-round security bits across shift/final rounds, i.e. `ε_shift` / `ε_fin`.
60    pub shift_rbr_bits: f64,
61    /// Security bits from query sampling.
62    pub query_bits: f64,
63    /// Security bits from γ batching (combining query and OOD claims).
64    pub gamma_batching_bits: f64,
65}
66
67#[derive(Clone, Debug)]
68pub struct ProximityGapSecurity {
69    pub log2_err: f64,
70    pub log2_list_size: f64,
71}
72
73impl SoundnessCalculator {
74    /// Calculates soundness for the given system parameters.
75    ///
76    /// # Arguments
77    /// * `params` - System parameters including WHIR config and LogUp parameters.
78    /// * `base_field_order` - Order `p` of the base field that `sample_bits` reduces. For BabyBear:
79    ///   `2^31 - 2^27 + 1`. Used to charge the `sample_bits` sampling bias (query bad-set argument
80    ///   and proof-of-work).
81    /// * `challenge_field_bits` - Bits in the challenge field. For BabyBear4: ~124 bits.
82    /// * `max_num_constraints_per_air` - Maximum constraints in any single AIR.
83    /// * `num_airs` - Number of AIRs being batched.
84    /// * `max_constraint_degree` - Maximum degree of any constraint polynomial.
85    /// * `max_log_trace_height` - Maximum log₂(trace height) across all AIRs.
86    /// * `num_trace_columns` - Total columns batched in stacked reduction.
87    /// * `num_stacked_columns` - Total columns across all commitments (for WHIR μ batching).
88    /// * `n_logup` - GKR depth (log₂ of total interactions), or 0 if no interactions.
89    /// * `proximity_regime` - Unique decoding or other regimes (for WHIR-related calculations).
90    #[allow(clippy::too_many_arguments)]
91    pub fn calculate(
92        params: &SystemParams,
93        base_field_order: f64,
94        challenge_field_bits: f64,
95        max_num_constraints_per_air: usize,
96        num_airs: usize,
97        max_constraint_degree: usize,
98        max_log_trace_height: usize,
99        num_trace_columns: usize,
100        num_stacked_columns: usize,
101        n_logup: usize,
102    ) -> Self {
103        let init_prox_gap = Self::whir_proximity_gap_security(
104            params.whir.proximity.initial_round(),
105            challenge_field_bits,
106            params.log_stacked_height(),
107            params.log_blowup,
108            num_stacked_columns,
109        );
110        // log2_list_size is log2(L_{PCS}) where L_{PCS} is the list size with respect to the
111        // proximity radius of the _initial_ WHIR round.
112        let log2_list_size = init_prox_gap.log2_list_size;
113        let logup_bits = Self::logup_soundness(
114            params.logup.max_interaction_count,
115            params.logup.log_max_message_length,
116            challenge_field_bits,
117            log2_list_size,
118        ) + Self::effective_pow_bits(params.logup.pow_bits, base_field_order);
119
120        let gkr_sumcheck_bits =
121            Self::calculate_gkr_sumcheck_soundness(challenge_field_bits, params.l_skip, n_logup);
122
123        let gkr_batching_bits =
124            Self::calculate_gkr_batching_soundness(challenge_field_bits, params.l_skip, n_logup);
125
126        let zerocheck_sumcheck_bits = Self::calculate_zerocheck_sumcheck_soundness(
127            challenge_field_bits,
128            max_constraint_degree,
129            params.l_skip,
130            log2_list_size,
131        );
132
133        let constraint_batching_bits = Self::calculate_constraint_batching_soundness(
134            challenge_field_bits,
135            max_num_constraints_per_air,
136            num_airs,
137            params.l_skip,
138            max_log_trace_height,
139            n_logup,
140            log2_list_size,
141        );
142
143        let stacked_reduction_bits = Self::calculate_stacked_reduction_soundness(
144            challenge_field_bits,
145            num_trace_columns,
146            params.l_skip,
147            params.n_stack,
148            log2_list_size,
149        );
150
151        let (whir_bits, whir_details) = Self::calculate_whir_soundness(
152            params,
153            base_field_order,
154            challenge_field_bits,
155            num_stacked_columns,
156            params.whir.proximity,
157        );
158
159        let total_bits = logup_bits
160            .min(gkr_sumcheck_bits)
161            .min(gkr_batching_bits)
162            .min(zerocheck_sumcheck_bits)
163            .min(constraint_batching_bits)
164            .min(stacked_reduction_bits)
165            .min(whir_bits);
166
167        Self {
168            logup_bits,
169            gkr_sumcheck_bits,
170            gkr_batching_bits,
171            zerocheck_sumcheck_bits,
172            constraint_batching_bits,
173            stacked_reduction_bits,
174            whir_bits,
175            whir_details,
176            total_bits,
177        }
178    }
179
180    /// LogUp soundness from α/β sampling (before proof-of-work grinding).
181    ///
182    /// - α: Random evaluation point to test whether Σ p(y)/q(y) = 0. If interactions are
183    ///   unbalanced, the sum is a non-zero rational function with a bounded number of roots. By
184    ///   Schwartz-Zippel, a random α detects this with high probability.
185    /// - β: Random challenge for compressing interaction messages into field elements. Degeneracy
186    ///   would allow distinct message tuples to collide.
187    ///
188    /// Security = |F_ext| - log₂(2 × max_interaction_count) - log_max_message_length - log₂(L_PCS)
189    ///
190    /// Proof-of-work grinding is an orthogonal amplification that callers add on top via
191    /// [`Self::effective_pow_bits`]. This is the single source of truth for the formula; SDK
192    /// parameter selection calibrates against it.
193    ///
194    /// Reference: Section 4 of docs/Soundness_of_Interactions_via_LogUp.pdf
195    pub fn logup_soundness(
196        max_interaction_count: u32,
197        log_max_message_length: u32,
198        challenge_field_bits: f64,
199        log2_list_size: f64,
200    ) -> f64 {
201        challenge_field_bits
202            - (2.0 * max_interaction_count as f64).log2()
203            - log_max_message_length as f64
204            - log2_list_size
205    }
206
207    /// Ordinary GKR sumcheck soundness (per-round).
208    ///
209    /// The GKR protocol has a triangular sumcheck structure where round j has j sub-rounds.
210    /// Each sub-round uses degree-3 interpolation, giving per-round error = 3 / |F_ext|.
211    /// The final input-layer boundary shared with ZeroCheck is accounted for in
212    /// [`Self::calculate_constraint_batching_soundness`].
213    ///
214    /// Security is determined by the worst round: |F_ext| - log₂(3)
215    fn calculate_gkr_sumcheck_soundness(
216        challenge_field_bits: f64,
217        l_skip: usize,
218        n_logup: usize,
219    ) -> f64 {
220        let total_rounds = l_skip + n_logup;
221        assert!(total_rounds >= 1, "GKR requires at least 1 round");
222
223        // Each sub-round has degree 3; security = field_bits - log2(degree)
224        let degree_per_subround = 3;
225        challenge_field_bits - (degree_per_subround as f64).log2()
226    }
227
228    /// GKR batching soundness from μ and λ challenges per layer.
229    ///
230    /// Each layer samples:
231    /// - μ: Reduces four evaluation claims to two via linear interpolation (degree 1)
232    /// - λ: Batches numerator and denominator claims (degree 1)
233    ///
234    /// Per-round security = |F_ext| - log₂(degree) = |F_ext| - log₂(1) = |F_ext|
235    fn calculate_gkr_batching_soundness(
236        challenge_field_bits: f64,
237        _l_skip: usize,
238        _n_logup: usize,
239    ) -> f64 {
240        // Each μ/λ challenge is a degree-1 polynomial test (linear interpolation)
241        let degree = 1;
242        challenge_field_bits - (degree as f64).log2()
243    }
244
245    /// ZeroCheck sumcheck soundness after the fused input-layer boundary.
246    ///
247    /// The input-point identity test is fused with the last LogUp-GKR input-layer challenge and
248    /// the first ZeroCheck batching challenges in
249    /// [`Self::calculate_constraint_batching_soundness`]. After that boundary, the remaining
250    /// batch sumcheck has two per-round degree sources:
251    ///
252    /// 1. **Univariate round** over coset domain (size 2^l_skip):
253    ///    - Degree: (max_constraint_degree + 1) × (2^l_skip - 1)
254    ///
255    /// 2. **Multilinear rounds** (n_max = max_log_trace_height - l_skip):
256    ///    - Per-round degree: max_constraint_degree + 1
257    ///
258    /// The full-protocol bound applies the list-size union bound over `L_PCS` candidate traces.
259    fn calculate_zerocheck_sumcheck_soundness(
260        challenge_field_bits: f64,
261        max_constraint_degree: usize,
262        l_skip: usize,
263        log2_list_size: f64,
264    ) -> f64 {
265        let univariate_degree = (max_constraint_degree + 1) * ((1 << l_skip) - 1);
266        let multilinear_degree = max_constraint_degree + 1;
267
268        let worst_degree = univariate_degree.max(multilinear_degree);
269        challenge_field_bits - (worst_degree as f64).log2() - log2_list_size
270    }
271
272    /// Fused batch-constraint boundary soundness via Schwartz-Zippel.
273    ///
274    /// This is the first term of the batch-constraint theorem after fusing the last LogUp-GKR
275    /// input-layer challenge with ZeroCheck's input-point and batching challenges:
276    ///
277    /// `max(3, n_extra) + (2^l_skip - 1) + (N_C - 1)`.
278    ///
279    /// The remaining batch-sumcheck batching round contributes a separate `3|T| - 1` term.
280    ///
281    /// The full-protocol bound applies the list-size union bound over `L_PCS` candidate traces.
282    fn calculate_constraint_batching_soundness(
283        challenge_field_bits: f64,
284        max_num_constraints_per_air: usize,
285        num_airs: usize,
286        l_skip: usize,
287        max_log_trace_height: usize,
288        n_logup: usize,
289        log2_list_size: f64,
290    ) -> f64 {
291        assert!(
292            max_num_constraints_per_air > 0,
293            "batch-constraint soundness requires at least one constraint"
294        );
295        assert!(
296            num_airs > 0,
297            "batch-constraint soundness requires at least one nonempty AIR"
298        );
299
300        let n_trace = max_log_trace_height.saturating_sub(l_skip);
301        let n_extra = n_trace.saturating_sub(n_logup);
302        let skip_degree = (1 << l_skip) - 1;
303
304        // Each AIR contributes 3 sum claims to the batch sumcheck:
305        // 1. ZeroCheck (constraint satisfaction)
306        // 2. LogUp numerator (p̂(ξ) input layer)
307        // 3. LogUp denominator (q̂(ξ) input layer)
308        let fused_boundary_degree =
309            n_extra.max(3) + skip_degree + (max_num_constraints_per_air - 1);
310        let batch_sumcheck_batching_degree = 3 * num_airs - 1;
311
312        let fused_boundary_bits = challenge_field_bits - (fused_boundary_degree as f64).log2();
313        let batch_sumcheck_batching_bits =
314            challenge_field_bits - (batch_sumcheck_batching_degree as f64).log2();
315
316        fused_boundary_bits.min(batch_sumcheck_batching_bits) - log2_list_size
317    }
318
319    /// Stacked reduction soundness.
320    ///
321    /// Reduces trace evaluations at point r to stacked polynomial evaluations at point u.
322    ///
323    /// Note: Trace heights do not appear directly; polynomial degrees are determined by the
324    /// stacking structure (l_skip, n_stack), not individual trace heights.
325    ///
326    /// Error sources:
327    /// 1. **λ batching**: 2 claims per column (T(r) and T_rot(r)). Error = 2n / |F_ext|
328    /// 2. **Univariate round**: Degree 2×(2^l_skip - 1). Per-round error = degree / |F_ext|
329    /// 3. **Multilinear rounds**: n_stack rounds, each with degree 2. Per-round error = 2 / |F_ext|
330    fn calculate_stacked_reduction_soundness(
331        challenge_field_bits: f64,
332        num_trace_columns: usize,
333        l_skip: usize,
334        _n_stack: usize,
335        log2_list_size: f64,
336    ) -> f64 {
337        let batching_bits = challenge_field_bits - (2.0 * num_trace_columns as f64).log2();
338
339        let univariate_degree = 2 * ((1 << l_skip) - 1);
340        let univariate_bits = challenge_field_bits - (univariate_degree as f64).log2();
341
342        // Degree 2 per round => log2(2) = 1.
343        let multilinear_bits = challenge_field_bits - 1.0;
344
345        batching_bits.min(univariate_bits).min(multilinear_bits) - log2_list_size
346    }
347
348    /// WHIR soundness analysis based on the WHIR paper (ePrint 2024/1586).
349    ///
350    /// Error sources (formulas depend on the proximity regime):
351    /// 1. **Fold error** (sumcheck + proximity gap per sub-round)
352    /// 2. **OOD error** (non-final rounds)
353    /// 3. **Shift/final error** (query sampling + γ batching)
354    /// 5. **Initial μ batching**
355    fn calculate_whir_soundness(
356        params: &SystemParams,
357        base_field_order: f64,
358        challenge_field_bits: f64,
359        num_stacked_columns: usize,
360        proximity: WhirProximityStrategy,
361    ) -> (f64, WhirSoundnessCalculator) {
362        let whir = &params.whir;
363        let k_whir = whir.k;
364        let log_stacked_height = params.log_stacked_height();
365        let num_whir_rounds = whir.rounds.len();
366
367        let mut min_query_bits = f64::INFINITY;
368        let mut min_prox_gaps_bits = f64::INFINITY;
369        let mut min_sumcheck_bits = f64::INFINITY;
370        let mut min_ood_bits = f64::INFINITY;
371        let mut min_gamma_batching_bits = f64::INFINITY;
372        let mut min_fold_rbr_bits = f64::INFINITY;
373        let mut min_shift_rbr_bits = f64::INFINITY;
374
375        assert!(
376            num_stacked_columns >= 2,
377            "WHIR requires at least 2 stacked columns for μ batching"
378        );
379        let mu_security = Self::whir_proximity_gap_security(
380            proximity.initial_round(),
381            challenge_field_bits,
382            log_stacked_height,
383            params.log_blowup,
384            num_stacked_columns,
385        );
386        let mu_batching_bits =
387            mu_security.log2_err + Self::effective_pow_bits(whir.mu_pow_bits, base_field_order);
388        let mut min_rbr_bits = mu_batching_bits;
389
390        let mut log_inv_rate = params.log_blowup;
391        let mut current_log_degree = log_stacked_height;
392
393        for (round, round_config) in whir.rounds.iter().enumerate() {
394            let proximity_regime = proximity.in_round(round);
395            let is_final_round = round == num_whir_rounds - 1;
396            let next_rate = log_inv_rate + (k_whir - 1);
397            // log2(list size) only depends on proximity regime, will not change depending on the
398            // sub-round
399            let mut log2_list_size: Option<f64> = None;
400
401            for _ in 0..k_whir {
402                current_log_degree -= 1;
403
404                let prox_gaps = Self::whir_proximity_gap_security(
405                    proximity_regime,
406                    challenge_field_bits,
407                    current_log_degree,
408                    log_inv_rate,
409                    2,
410                );
411                if let Some(l2) = log2_list_size.as_ref() {
412                    debug_assert!((*l2 - prox_gaps.log2_list_size).abs() < 1e-6);
413                } else {
414                    log2_list_size = Some(prox_gaps.log2_list_size);
415                }
416                let prox_gaps_bits = prox_gaps.log2_err
417                    + Self::effective_pow_bits(whir.folding_pow_bits, base_field_order);
418                min_prox_gaps_bits = min_prox_gaps_bits.min(prox_gaps_bits);
419
420                let sumcheck_bits = Self::whir_sumcheck_security(
421                    base_field_order,
422                    challenge_field_bits,
423                    whir.folding_pow_bits,
424                    log2_list_size.unwrap(),
425                );
426                min_sumcheck_bits = min_sumcheck_bits.min(sumcheck_bits);
427
428                // Theorem 5.2: ε_fold = d * ℓ_{i,s-1} / |F| + err*.
429                let fold_rbr_bits = Self::combine_security_bits(sumcheck_bits, prox_gaps_bits);
430                min_fold_rbr_bits = min_fold_rbr_bits.min(fold_rbr_bits);
431                min_rbr_bits = min_rbr_bits.min(fold_rbr_bits);
432            }
433
434            // Query error (all rounds), protected by query_phase_pow_bits.
435            //
436            // Query indices are drawn with `sample_bits(log_query_domain)` over the folded RS
437            // domain, whose log-size matches `log_rs_domain_size - k_whir` in the prover, i.e.
438            // `current_log_degree + log_inv_rate` here. `whir_query_security_biased` folds the
439            // `sample_bits` bias into the agreement-set bound.
440            let log_query_domain = current_log_degree + log_inv_rate;
441            let query_bits =
442                Self::whir_query_security_biased(
443                    proximity_regime,
444                    round_config.num_queries,
445                    log_inv_rate,
446                    log_query_domain,
447                    base_field_order,
448                ) + Self::effective_pow_bits(whir.query_phase_pow_bits, base_field_order);
449            min_query_bits = min_query_bits.min(query_bits);
450
451            // ε_shift and ε_out reference m_i, ℓ_{i,0} where `i = round + 1` refers to the *next*
452            // round.
453            let next_log2_list_size = Self::whir_proximity_gap_security(
454                proximity.in_round(round + 1),
455                challenge_field_bits,
456                current_log_degree,
457                next_rate,
458                2,
459            )
460            .log2_list_size;
461            // In-domain γ batching (not protected by PoW; Merkle proofs are observed before γ).
462            // NOTE[jpw] For now we use the original paper where this is fixed to 1. <https://github.com/WizardOfMenlo/whir/blob/cf1599b56ff50e09142ebe6d2e2fbd86875c9986/src/whir/parameters.rs#L373> now varies this to increase security in LDR.
463            const NUM_OOD_SAMPLES: usize = 1;
464            let batch_size = round_config.num_queries + NUM_OOD_SAMPLES;
465            debug_assert!(batch_size > 0);
466            let gamma_batching_bits = Self::whir_gamma_batching_security(
467                challenge_field_bits,
468                batch_size,
469                next_log2_list_size,
470            );
471            min_gamma_batching_bits = min_gamma_batching_bits.min(gamma_batching_bits);
472
473            // Theorem 5.2 / Claim 5.4: ε_shift = (1 - δ)^t + ℓ_{i,0} * (t + 1) / |F|. The
474            // implementation keeps the same additive structure, with the final round
475            // batching only the query claims.
476            let shift_rbr_bits = Self::combine_security_bits(query_bits, gamma_batching_bits);
477            min_shift_rbr_bits = min_shift_rbr_bits.min(shift_rbr_bits);
478            min_rbr_bits = min_rbr_bits.min(shift_rbr_bits);
479
480            if !is_final_round {
481                // OOD error (not protected by PoW; sampled after commitment observed).
482                // This is OOD sample on f_i for the *next* round `i = round + 1` after folding. So
483                // `m_i = current_log_degree` (with the present round `round`'s foldings)
484                let ood_bits = Self::whir_ood_security(
485                    next_log2_list_size,
486                    challenge_field_bits,
487                    current_log_degree,
488                );
489                min_ood_bits = min_ood_bits.min(ood_bits);
490                min_rbr_bits = min_rbr_bits.min(ood_bits);
491
492                tracing::debug!(
493                    "WHIR round {} | rate=2^-{} | queries={} | query={:.1} | prox_gaps={:.1} | sumcheck={:.1} | shift={:.1} | ood={:.1} | gamma={:.1}",
494                    round, log_inv_rate, round_config.num_queries, query_bits,
495                    min_prox_gaps_bits, min_sumcheck_bits, shift_rbr_bits, ood_bits,
496                    min_gamma_batching_bits,
497                );
498            } else {
499                tracing::debug!(
500                    "WHIR round {} (final) | rate=2^-{} | queries={} | query={:.1} | prox_gaps={:.1} | sumcheck={:.1} | final={:.1} | gamma={:.1}",
501                    round, log_inv_rate, round_config.num_queries, query_bits,
502                    min_prox_gaps_bits, min_sumcheck_bits, shift_rbr_bits,
503                    min_gamma_batching_bits,
504                );
505            }
506
507            log_inv_rate = next_rate;
508        }
509
510        let details = WhirSoundnessCalculator {
511            mu_batching_bits,
512            fold_rbr_bits: min_fold_rbr_bits,
513            ood_rbr_bits: min_ood_bits,
514            shift_rbr_bits: min_shift_rbr_bits,
515            // The following are part of above rbr error, but kept for detailed analysis
516            query_bits: min_query_bits,
517            proximity_gaps_bits: min_prox_gaps_bits,
518            sumcheck_bits: min_sumcheck_bits,
519            gamma_batching_bits: min_gamma_batching_bits,
520        };
521
522        let min_security = min_rbr_bits;
523
524        (min_security, details)
525    }
526
527    /// Computes WHIR proximity gap security bits.
528    ///
529    /// Per WHIR paper: err*(C, ℓ, δ) = (ℓ - 1) · 2^m / (ρ · |F|)
530    ///
531    /// - `UniqueDecoding`: Security bits = |F_ext| - log₂(ℓ - 1) - log₂(degree) - log₂(1/rate)
532    /// - `ListDecoding { m }`: Uses BCHKS25/TR25-169 Theorem 1.5 (contrapositive) to bound the "bad
533    ///   z set" size by `a`, with Haböck's global extension introducing a linear factor `(ℓ - 1)`.
534    pub fn whir_proximity_gap_security(
535        proximity_regime: ProximityRegime,
536        challenge_field_bits: f64,
537        log_degree: usize,
538        log_inv_rate: usize,
539        batch_size: usize,
540    ) -> ProximityGapSecurity {
541        debug_assert!(batch_size > 1, "batch_size must be > 1 for err*");
542        match proximity_regime {
543            ProximityRegime::UniqueDecoding => {
544                let log2_err = challenge_field_bits
545                    - ((batch_size - 1) as f64).log2()
546                    - log_degree as f64
547                    - log_inv_rate as f64;
548                ProximityGapSecurity {
549                    log2_err,
550                    log2_list_size: 0.0,
551                }
552            }
553            ProximityRegime::ListDecoding { m } => {
554                let (log2_a, log2_list_size) =
555                    Self::log2_a_bound_bchks25(log_degree, log_inv_rate, m);
556                // Haböck global mutual correlated agreement: error ∝ (ℓ - 1) * a / |F|.
557                let log2_err = challenge_field_bits - ((batch_size - 1) as f64).log2() - log2_a;
558                ProximityGapSecurity {
559                    log2_err,
560                    log2_list_size,
561                }
562            }
563        }
564    }
565
566    /// Numerically stable `log2(2^x + 2^y)`.
567    #[inline]
568    fn log2_add(log2_x: f64, log2_y: f64) -> f64 {
569        if log2_x.is_infinite() && log2_x.is_sign_positive() {
570            return log2_x;
571        }
572        if log2_y.is_infinite() && log2_y.is_sign_positive() {
573            return log2_y;
574        }
575        if log2_x.is_nan() || log2_y.is_nan() {
576            return f64::NAN;
577        }
578
579        let (hi, lo) = if log2_x >= log2_y {
580            (log2_x, log2_y)
581        } else {
582            (log2_y, log2_x)
583        };
584        let ratio = (lo - hi).exp2();
585        hi + (1.0 + ratio).log2()
586    }
587
588    /// Combines two additive error terms `2^-a + 2^-b` into security bits `-log2(error)`.
589    #[inline]
590    fn combine_security_bits(bits_a: f64, bits_b: f64) -> f64 {
591        if bits_a.is_infinite() && bits_a.is_sign_positive() {
592            return bits_b;
593        }
594        if bits_b.is_infinite() && bits_b.is_sign_positive() {
595            return bits_a;
596        }
597        if bits_a.is_nan() || bits_b.is_nan() {
598            return f64::NAN;
599        }
600
601        -Self::log2_add(-bits_a, -bits_b)
602    }
603
604    /// The bias of `sample_bits(n)`, the reduction `x mod 2^n` of a uniform `x ∈ {0, ..., p - 1}`.
605    ///
606    /// Writing `p = c·2^n + r` with `0 ≤ r < 2^n`, the `2^n` residues are not equiprobable: the
607    /// `r` "heavy" residues `0..r` occur with probability `(c+1)/p`, the `2^n - r` "light" ones
608    /// with `c/p`. Returns `(c+1)/p` (heavy), `c/p` (light), and `r` (the number of heavy
609    /// residues). `p` is the order of the field `sample_bits` reduces (the base field, BabyBear).
610    ///
611    /// Note for BabyBear `p - 1 = 2^27 · 15`, so `p ≡ 1 (mod 2^n)` and thus `r = 1` for every
612    /// `n ≤ 27` — there is a single heavy residue (the all-zero one).
613    #[inline]
614    fn sample_bits_residue_probs(num_sampled_bits: f64, base_field_order: f64) -> (f64, f64, f64) {
615        let two_pow_n = num_sampled_bits.exp2();
616        let c = (base_field_order / two_pow_n).floor();
617        let r = base_field_order - c * two_pow_n; // p mod 2^n, exact for integers < 2^53.
618        ((c + 1.0) / base_field_order, c / base_field_order, r)
619    }
620
621    /// Security bits from `num_queries` WHIR query draws of `sample_bits(log_query_domain)`,
622    /// charging the sampling bias via the agreement-set argument.
623    ///
624    /// Under ideal uniform sampling a far word evades one query with prob `≤ α` (the max
625    /// agreement). The adversary aligns its size-`g = α·N` agreement set (`N = 2^log_query_domain`)
626    /// with the heavy residues, but only `r` of those exist, so the worst-case single-query hit is
627    ///   `(g·c + min(g, r))/p = α + min(α N, r)·(1 - α)/p`   (using `Nc = p - r`).
628    /// For BabyBear `r = 1` on every query domain, so the penalty is ~`2^-31` per query
629    /// (negligible).
630    fn whir_query_security_biased(
631        proximity_regime: ProximityRegime,
632        num_queries: usize,
633        log_inv_rate: usize,
634        log_query_domain: usize,
635        base_field_order: f64,
636    ) -> f64 {
637        let alpha = proximity_regime.max_agreement(log_inv_rate);
638        let (_p_hi, _p_lo, r) =
639            Self::sample_bits_residue_probs(log_query_domain as f64, base_field_order);
640        let big_n = (log_query_domain as f64).exp2();
641        let heavy_used = (alpha * big_n).min(r);
642        // mass = α·(Nc/p) + heavy_used/p = α·(1 - r/p) + heavy_used/p.
643        let mass = (alpha * (1.0 - r / base_field_order) + heavy_used / base_field_order)
644            .clamp(f64::MIN_POSITIVE, 1.0);
645        -(num_queries as f64) * mass.log2()
646    }
647
648    /// Effective security bits of a `pow_bits` proof-of-work grind, accounting for the bias of
649    /// `sample_bits` towards its all-zero target. A random witness passes `sample_bits(pow_bits)
650    /// == 0` with probability `(c+1)/p` (residue 0 is always heavy), not `2^{-pow_bits}`, so the
651    /// grind is worth `-log2((c+1)/p)` bits — slightly under its nominal `pow_bits`. These count
652    /// hash evaluations; see the module-level note on the grinding hash.
653    #[inline]
654    fn effective_pow_bits(pow_bits: usize, base_field_order: f64) -> f64 {
655        if pow_bits == 0 {
656            // `check_witness` short-circuits to `true` without sampling, so there is no bias.
657            return 0.0;
658        }
659        let (p_hi, _p_lo, _r) = Self::sample_bits_residue_probs(pow_bits as f64, base_field_order);
660        -p_hi.log2()
661    }
662
663    #[inline]
664    fn bchks25_log2_a_from_log2_degrees(
665        log2_d_x: f64,
666        log2_d_y: f64,
667        log2_d_z: f64,
668        log2_agreement_term: f64,
669    ) -> f64 {
670        // Equation (13): a > 2*D_X*D_Y^2*D_Z + agreement_term*D_Y
671        let log2_term_poly = 1.0 + log2_d_x + 2.0 * log2_d_y + log2_d_z;
672        let log2_term_gamma = log2_d_y + log2_agreement_term;
673        Self::log2_add(log2_term_poly, log2_term_gamma)
674    }
675
676    /// Reference closed-form degrees from BCHKS25 Lemma 3.1, Equations (7), (8), (9).
677    /// For `m < 3`, use `D_Z = max(D_Y, Equation (9) value)` as noted below Lemma 3.1.
678    ///
679    /// Returns (log2(D_X), log2(D_Y), log2(D_Z))
680    fn bchks25_log2_degrees(
681        log_degree: usize,
682        log_inv_rate: usize,
683        m: usize,
684        _gamma: f64,
685    ) -> (f64, f64, f64) {
686        #[cfg(feature = "soundness-bchks25-optimized")]
687        if let Some((degrees, _)) = bchks25_brute_force_params::bchks25_optimal_degrees_bruteforce(
688            log_degree,
689            log_inv_rate,
690            m,
691            _gamma,
692        ) {
693            debug_assert!(degrees.d_x > 0 && degrees.d_y > 0 && degrees.d_z > 0);
694            return (
695                (degrees.d_x as f64).log2(),
696                (degrees.d_y as f64).log2(),
697                (degrees.d_z as f64).log2(),
698            );
699        }
700
701        Self::bchks25_reference_log2_degrees(log_degree, log_inv_rate, m)
702    }
703
704    fn bchks25_reference_log2_degrees(
705        log_degree: usize,
706        log_inv_rate: usize,
707        m: usize,
708    ) -> (f64, f64, f64) {
709        let m_bar = m.max(1) as f64 + 0.5;
710        let log2_m_bar = m_bar.log2();
711        let log2_n = (log_degree + log_inv_rate) as f64;
712        let log2_3 = 3.0_f64.log2();
713        let log2_rho = -(log_inv_rate as f64);
714
715        // D_X = (m + 1/2) * sqrt(k * n)
716        // D_Y = (m + 1/2) * sqrt(n / k)
717        // D_Z (Equation 9) = ((m + 1/2)^2 * n) / (3 * k)
718        let log2_d_x = log2_m_bar + log2_n + 0.5 * log2_rho;
719        let log2_d_y = log2_m_bar - 0.5 * log2_rho;
720        let log2_d_z = 2.0 * log2_m_bar - log2_3 - log2_rho;
721        let log2_d_z = log2_d_y.max(log2_d_z);
722
723        (log2_d_x, log2_d_y, log2_d_z)
724    }
725
726    /// Computes `log2(a_bound)` from BCHKS25/TR25-169 Theorem 1.5 (contrapositive), where
727    /// `a_bound = ceil(a).max(1)`.
728    ///
729    /// We use Lemma 3.1 and the bounds on `a` in terms of `D_X, D_Y, D_Z` from Section 3.2 and
730    /// Equation (13). As noted in the paragraph after Lemma 3.1, the parameters chosen in the
731    /// Lemma 3.1 statement are not optimal and chosen to provide cleaner expressions.
732    /// When feature `soundness-bchks25-optimized` is enabled,
733    /// we do a brute-force search in `bchks25_optimal_degrees_bruteforce` to find parameters that
734    /// meet the conditions for the proof of Lemma 3.1 to be applied to find the polynomial `Q`.
735    /// When the feature is not enabled, a closed-form Lemma 3.1 degree computation is used.
736    ///
737    /// Parameters are mapped as:
738    /// - `num_variables = log_degree`
739    /// - `rho = 2^{-log_inv_rate}`
740    /// - `n = 2^{log_degree + log_inv_rate}`
741    ///
742    /// We set the theorem slack `η` from the provided multiplicity `m` as
743    /// `η = sqrt(rho) / (2m)`, so that `m = ceil(sqrt(rho)/(2η))`.
744    ///
745    /// Returns `log2(a_bound), log2(list_size)`.
746    fn log2_a_bound_bchks25(log_degree: usize, log_inv_rate: usize, m: usize) -> (f64, f64) {
747        const INVALID: (f64, f64) = (f64::INFINITY, f64::INFINITY);
748        let m_eff = m.max(1);
749        let log2_rho = -(log_inv_rate as f64);
750        let rho = log2_rho.exp2();
751        if rho <= 0.0 || !rho.is_finite() {
752            return INVALID;
753        }
754        if m_eff == 1 && rho >= (4.0 / 9.0) {
755            // For m=1: gamma = 1 - sqrt(rho) - sqrt(rho)/(2m) = 1 - 3*sqrt(rho)/2.
756            // Gamma must be positive to apply the Section 3.2 argument.
757            return INVALID;
758        }
759
760        let sqrt_rho = rho.sqrt();
761        let eta = sqrt_rho / (2.0 * m_eff as f64);
762        let gamma = 1.0 - sqrt_rho - eta;
763        if eta <= 0.0 || gamma <= 0.0 || gamma >= 1.0 - sqrt_rho {
764            // Invalid theorem regime => no security from this term.
765            return INVALID;
766        }
767
768        let log2_n = (log_degree + log_inv_rate) as f64;
769        let (log2_a_real, log2_list_size) = {
770            // Fallback for extreme parameter regimes where exact integer search is not
771            // representable.
772            let (log2_d_x, log2_d_y, log2_d_z) =
773                Self::bchks25_log2_degrees(log_degree, log_inv_rate, m_eff, gamma);
774            let log2_gamma_n_plus_1 = Self::log2_add(gamma.log2() + log2_n, 0.0);
775            let log2_a = Self::bchks25_log2_a_from_log2_degrees(
776                log2_d_x,
777                log2_d_y,
778                log2_d_z,
779                log2_gamma_n_plus_1,
780            );
781            // Note: we could take log2(floor(2^log2_d_y)) for a tighter list size bound
782            (log2_a, log2_d_y)
783        };
784        if !log2_a_real.is_finite() {
785            return INVALID;
786        }
787
788        // Clamp `a_bound >= 1` => `log2(a_bound) >= 0`.
789        let log2_a_real = log2_a_real.max(0.0);
790
791        // If `a` is small enough, apply `ceil` in normal space for exactness.
792        let a = log2_a_real.exp2();
793        let a_bound = a.ceil().max(1.0);
794        (a_bound.log2(), log2_list_size)
795    }
796
797    /// Computes WHIR sumcheck security bits for a sub-round.
798    ///
799    /// Sumcheck error is d * ℓ_{i,s-1} / |F|, d^*:= 1 + deg_Z(w0) + max_i deg_{X_i}(w0) and d :=
800    /// max{d^*,3}.
801    ///
802    /// Security bits = |F_ext| - log₂(d) - log2(ℓ_{i,s-1}) + folding_pow_bits
803    fn whir_sumcheck_security(
804        base_field_order: f64,
805        challenge_field_bits: f64,
806        folding_pow_bits: usize,
807        log2_list_size: f64,
808    ) -> f64 {
809        // For our use case, w0 has degree 1 in each variable, so d = 3.
810        let sumcheck_degree: f64 = 3.0;
811        challenge_field_bits - sumcheck_degree.log2() - log2_list_size
812            + Self::effective_pow_bits(folding_pow_bits, base_field_order)
813    }
814
815    /// Computes WHIR out-of-domain (OOD) security bits.
816    ///
817    /// OOD error is 2^{m_i - 1} ℓ_{i,0}^2 / |F| where m_i is the log_degree at the start of WHIR
818    /// round `i`.
819    ///
820    /// Security bits = |F_ext| - m_i + 1 - 2 * log2(ℓ_{i,0})
821    fn whir_ood_security(
822        log2_list_size: f64,
823        challenge_field_bits: f64,
824        log_degree_at_round_start: usize,
825    ) -> f64 {
826        let base_bits = challenge_field_bits - log_degree_at_round_start as f64 + 1.0;
827        base_bits - 2.0 * log2_list_size
828    }
829
830    /// Computes WHIR in-domain γ batching security bits.
831    ///
832    /// Theorem 5.6 / Claim 5.4: batching `t` claims against a list of size `ℓ` gives
833    /// error `ℓ * t / |F|`.
834    fn whir_gamma_batching_security(
835        challenge_field_bits: f64,
836        batch_size: usize,
837        log2_list_size: f64,
838    ) -> f64 {
839        debug_assert!(batch_size > 0, "batch_size must be > 0 for gamma batching");
840        challenge_field_bits - (batch_size as f64).log2() - log2_list_size
841    }
842}
843
844/// Prints a detailed soundness report to stdout.
845#[allow(clippy::too_many_arguments)]
846pub fn print_soundness_report(
847    params: &SystemParams,
848    base_field_order: f64,
849    challenge_field_bits: f64,
850    max_num_constraints_per_air: usize,
851    num_airs: usize,
852    max_constraint_degree: usize,
853    max_log_trace_height: usize,
854    num_trace_columns: usize,
855    num_stacked_columns: usize,
856    n_logup: usize,
857    proximity_regime: ProximityRegime,
858) {
859    let soundness = SoundnessCalculator::calculate(
860        params,
861        base_field_order,
862        challenge_field_bits,
863        max_num_constraints_per_air,
864        num_airs,
865        max_constraint_degree,
866        max_log_trace_height,
867        num_trace_columns,
868        num_stacked_columns,
869        n_logup,
870    );
871
872    println!("=== V2 Proof System Soundness Report ===");
873    println!();
874    println!("System Parameters:");
875    println!("  l_skip: {}", params.l_skip);
876    println!("  n_stack: {}", params.n_stack);
877    println!("  log_blowup: {}", params.log_blowup);
878    println!("  WHIR k: {}", params.whir.k);
879    println!("  WHIR rounds: {}", params.whir.rounds.len());
880    println!("  WHIR mu_pow_bits: {}", params.whir.mu_pow_bits);
881    println!(
882        "  WHIR query_phase_pow_bits: {}",
883        params.whir.query_phase_pow_bits
884    );
885    println!("  WHIR folding_pow_bits: {}", params.whir.folding_pow_bits);
886    println!("  LogUp pow_bits: {}", params.logup.pow_bits);
887    println!(
888        "  LogUp max_interaction_count: {}",
889        params.logup.max_interaction_count
890    );
891    println!(
892        "  LogUp log_max_message_length: {}",
893        params.logup.log_max_message_length
894    );
895    println!("  max_constraint_degree: {}", params.max_constraint_degree);
896    println!();
897    println!("Proving Context:");
898    println!("  challenge_field_bits: {:.0}", challenge_field_bits);
899    println!("  base_field_order: {:.0}", base_field_order);
900    println!(
901        "  max_num_constraints_per_air: {}",
902        max_num_constraints_per_air
903    );
904    println!("  num_airs: {}", num_airs);
905    println!("  max_constraint_degree: {}", max_constraint_degree);
906    println!("  max_log_trace_height: {}", max_log_trace_height);
907    println!("  num_trace_columns: {}", num_trace_columns);
908    println!("  num_stacked_columns: {}", num_stacked_columns);
909    println!("  n_logup (GKR depth): {}", n_logup);
910    println!();
911    println!("Security Analysis (bits):");
912    println!("  LogUp (α/β + PoW):           {:.1}", soundness.logup_bits);
913    println!(
914        "  GKR sumcheck:                {:.1}",
915        soundness.gkr_sumcheck_bits
916    );
917    println!(
918        "  GKR batching (μ/λ):          {:.1}",
919        soundness.gkr_batching_bits
920    );
921    println!(
922        "  ZeroCheck sumcheck:          {:.1}",
923        soundness.zerocheck_sumcheck_bits
924    );
925    println!(
926        "  Fused boundary/batching:     {:.1}",
927        soundness.constraint_batching_bits
928    );
929    println!(
930        "  Stacked reduction:           {:.1}",
931        soundness.stacked_reduction_bits
932    );
933    println!("  WHIR (round-by-round min):   {:.1}", soundness.whir_bits);
934    println!();
935    println!(
936        "  TOTAL SECURITY:              {:.1} bits",
937        soundness.total_bits
938    );
939    println!();
940
941    println!("WHIR Error Source Breakdown:");
942    let whir = &soundness.whir_details;
943    println!("  Query error:          {:.1} bits", whir.query_bits);
944    println!(
945        "  Proximity gaps:       {:.1} bits",
946        whir.proximity_gaps_bits
947    );
948    println!("  Sumcheck error:       {:.1} bits", whir.sumcheck_bits);
949    println!("  Min ε_fold:           {:.1} bits", whir.fold_rbr_bits);
950    println!("  OOD error:            {:.1} bits", whir.ood_rbr_bits);
951    println!(
952        "  γ batching error:     {:.1} bits",
953        whir.gamma_batching_bits
954    );
955    println!("  Min ε_shift/ε_fin:    {:.1} bits", whir.shift_rbr_bits);
956    println!("  μ batching error:     {:.1} bits", whir.mu_batching_bits);
957    println!();
958
959    println!("WHIR Round Breakdown:");
960    let k_whir = params.whir.k;
961    let mut log_inv_rate = params.log_blowup;
962    for (round, round_config) in params.whir.rounds.iter().enumerate() {
963        let query_sec =
964            proximity_regime.whir_query_security_bits(round_config.num_queries, log_inv_rate);
965        println!(
966            "  Round {} | rate=2^-{:<2} | queries={:<3} | query_sec={:5.1} | pow={} | fold_pow={}",
967            round,
968            log_inv_rate,
969            round_config.num_queries,
970            query_sec,
971            params.whir.query_phase_pow_bits,
972            params.whir.folding_pow_bits
973        );
974        log_inv_rate += k_whir - 1;
975    }
976}
977
978/// Calculates the minimum WHIR queries needed for a target security level.
979pub fn min_whir_queries(
980    proximity_regime: ProximityRegime,
981    target_security_bits: usize,
982    log_inv_rate: usize,
983) -> usize {
984    WhirConfig::queries(proximity_regime, target_security_bits, log_inv_rate)
985}
986
987#[cfg(test)]
988mod tests {
989    use openvm_stark_sdk::config::{base_field_order, challenge_field_bits};
990
991    use super::*;
992    use crate::{config::WhirRoundConfig, interaction::LogUpSecurityParameters};
993
994    // ==========================================================================
995    // Test fixtures
996    // ==========================================================================
997
998    fn test_params() -> SystemParams {
999        SystemParams {
1000            l_skip: 3,
1001            n_stack: 8,
1002            w_stack: 64,
1003            log_blowup: 1,
1004            whir: WhirConfig {
1005                k: 4,
1006                rounds: vec![
1007                    WhirRoundConfig { num_queries: 36 },
1008                    WhirRoundConfig { num_queries: 18 },
1009                ],
1010                mu_pow_bits: 16,
1011                query_phase_pow_bits: 16,
1012                folding_pow_bits: 10,
1013                proximity: WhirProximityStrategy::UniqueDecoding,
1014            },
1015            logup: LogUpSecurityParameters {
1016                max_interaction_count: 1 << 20,
1017                log_max_message_length: 4,
1018                pow_bits: 16,
1019            },
1020            max_constraint_degree: 5,
1021        }
1022    }
1023
1024    // ==========================================================================
1025    // Unit tests
1026    // ==========================================================================
1027    const TARGET_SECURITY_BITS: usize = 100;
1028
1029    #[test]
1030    fn test_soundness_calculation() {
1031        let params = test_params();
1032        let soundness = SoundnessCalculator::calculate(
1033            &params,
1034            base_field_order(),
1035            challenge_field_bits(),
1036            1000,
1037            50,
1038            4,
1039            24,
1040            200,
1041            10,
1042            15,
1043        );
1044
1045        assert!(soundness.logup_bits > 0.0);
1046        assert!(soundness.gkr_sumcheck_bits > 0.0);
1047        assert!(soundness.gkr_batching_bits > 0.0);
1048        assert!(soundness.zerocheck_sumcheck_bits > 0.0);
1049        assert!(soundness.constraint_batching_bits > 0.0);
1050        assert!(soundness.stacked_reduction_bits > 0.0);
1051        assert!(soundness.whir_bits > 0.0);
1052        assert!(soundness.total_bits > 0.0);
1053
1054        let expected_total = soundness
1055            .logup_bits
1056            .min(soundness.gkr_sumcheck_bits)
1057            .min(soundness.gkr_batching_bits)
1058            .min(soundness.zerocheck_sumcheck_bits)
1059            .min(soundness.constraint_batching_bits)
1060            .min(soundness.stacked_reduction_bits)
1061            .min(soundness.whir_bits);
1062        assert!((soundness.total_bits - expected_total).abs() < 0.001);
1063    }
1064
1065    #[test]
1066    fn test_whir_query_calculation() {
1067        let queries = min_whir_queries(ProximityRegime::UniqueDecoding, TARGET_SECURITY_BITS, 1);
1068        assert!(queries > 0);
1069    }
1070
1071    #[test]
1072    fn test_logup_soundness() {
1073        let logup = test_params().logup;
1074        let security =
1075            SoundnessCalculator::logup_soundness(
1076                logup.max_interaction_count,
1077                logup.log_max_message_length,
1078                challenge_field_bits(),
1079                0.0,
1080            ) + SoundnessCalculator::effective_pow_bits(logup.pow_bits, base_field_order());
1081        assert!(security > TARGET_SECURITY_BITS as f64);
1082    }
1083
1084    #[test]
1085    fn test_logup_list_size_is_security_penalty() {
1086        let logup = test_params().logup;
1087        let no_list = SoundnessCalculator::logup_soundness(
1088            logup.max_interaction_count,
1089            logup.log_max_message_length,
1090            challenge_field_bits(),
1091            0.0,
1092        );
1093        let list_size_bits = 5.0;
1094        let with_list = SoundnessCalculator::logup_soundness(
1095            logup.max_interaction_count,
1096            logup.log_max_message_length,
1097            challenge_field_bits(),
1098            list_size_bits,
1099        );
1100        assert!((no_list - with_list - list_size_bits).abs() < 1e-9);
1101    }
1102
1103    #[test]
1104    fn test_fused_batch_constraint_boundary_soundness() {
1105        let security = SoundnessCalculator::calculate_constraint_batching_soundness(
1106            100.0, // challenge field bits
1107            11,    // N_C
1108            7,     // |T|
1109            3,     // l_skip, so 2^l_skip - 1 = 7
1110            10,    // max_log_trace_height, so n_T = 7
1111            4,     // n_logup, so n_extra = 3
1112            2.0,   // log2(L_PCS)
1113        );
1114        let expected_degree: f64 = (3.0_f64 + 7.0 + 10.0).max(20.0);
1115        let expected = 100.0 - expected_degree.log2() - 2.0;
1116        assert!((security - expected).abs() < 1e-9);
1117    }
1118
1119    #[test]
1120    fn test_whir_unique_decoding_security() {
1121        // rate = 0.5: error = 0.75, security per query ≈ 0.415 bits
1122        let security = ProximityRegime::UniqueDecoding.whir_query_security_bits(100, 1);
1123        assert!(
1124            (security - 41.5).abs() < 1.0,
1125            "Expected ~41.5, got {}",
1126            security
1127        );
1128
1129        // rate = 0.25: error = 0.625, security per query ≈ 0.678 bits
1130        let security_blowup2 = ProximityRegime::UniqueDecoding.whir_query_security_bits(100, 2);
1131        assert!(
1132            (security_blowup2 - 67.8).abs() < 1.0,
1133            "Expected ~67.8, got {}",
1134            security_blowup2
1135        );
1136    }
1137
1138    #[test]
1139    fn test_whir_gamma_batching_uses_list_size_and_full_batch_size() {
1140        let security = SoundnessCalculator::whir_gamma_batching_security(100.0, 5, 3.0);
1141        let expected = 100.0 - 5.0_f64.log2() - 3.0;
1142        assert!((security - expected).abs() < 1e-9);
1143    }
1144
1145    #[test]
1146    fn test_combine_security_bits_sums_errors_before_taking_log() {
1147        let combined = SoundnessCalculator::combine_security_bits(100.0, 100.0);
1148        let expected = 99.0;
1149        assert!((combined - expected).abs() < 1e-9);
1150    }
1151
1152    #[test]
1153    fn test_bchks25_reference_m2_enforces_dz_ge_dy() {
1154        let (_log2_d_x, log2_d_y, log2_d_z) =
1155            SoundnessCalculator::bchks25_reference_log2_degrees(24, 2, 2);
1156        assert!(log2_d_z >= log2_d_y);
1157    }
1158
1159    #[test]
1160    fn test_bchks25_m1_requires_rho_below_four_ninths() {
1161        // log_inv_rate=1 => rho=1/2 > 4/9, so m=1 regime is invalid.
1162        let invalid = SoundnessCalculator::log2_a_bound_bchks25(12, 1, 1);
1163        assert!(invalid.0.is_infinite() && invalid.1.is_infinite());
1164
1165        // log_inv_rate=2 => rho=1/4 < 4/9, so m=1 regime is admissible.
1166        let valid = SoundnessCalculator::log2_a_bound_bchks25(12, 2, 1);
1167        assert!(valid.0.is_finite() && valid.1.is_finite());
1168    }
1169}
1170
1171/// The D_X, D_Y, D_Z given in [BCHKS25] Lemma 3.1 are not optimal (as noted by the authors) and `m
1172/// < 3` assumption is not needed. We can perform a brute force search over possible values of D_X,
1173/// D_Y, D_Z that satisfy properties to allow the proof of Lemma 3.1 to go through.
1174///
1175/// Everything in this module is still backed by proven results.
1176#[allow(dead_code)]
1177#[cfg(feature = "soundness-bchks25-optimized")]
1178mod bchks25_brute_force_params {
1179    use crate::soundness::SoundnessCalculator;
1180
1181    const BCHKS25_DY_SEARCH_MIN_MAX: u128 = 9;
1182    const BCHKS25_DY_SEARCH_REF_MULTIPLIER: u128 = 4;
1183    const BCHKS25_DY_SEARCH_HARD_MAX: u128 = 4096;
1184    const BCHKS25_DZ_SEARCH_MAX: u128 = 500_000;
1185
1186    #[derive(Clone, Copy, Debug)]
1187    pub struct Bchks25Degrees {
1188        pub d_x: u128,
1189        pub d_y: u128,
1190        // Integer index representation for Z-degree support:
1191        // `j + h < D_Z` is represented as `0 <= h <= d_z - j`, so `d_z = ceil(D_Z) - 1`.
1192        pub d_z: u128,
1193    }
1194
1195    /// We find optimal parameters `D_X, D_Y, D_Z` that minimize Equation (13) in BCHKS25 **and**
1196    /// satisfy the conditions necessary for the proof of Lemma 3.1 to carry through:
1197    /// - `D_X >= k * D_Y`
1198    /// - `D_Z >= D_Y`
1199    /// - for `m < 3`, additionally `D_Z >=` Equation (9), i.e. `D_Z = max(D_Y, Equation (9) value)`
1200    /// - `D_Y >= m - 1`
1201    /// - `D_X <= (1 - gamma) * m * n` (Section 3.2 precondition before applying Equation (13))
1202    /// - `n_vars > n_eqs` where `n_eqs` is given by Equation (11) and `n_vars = \sum_0^{ceil(D_Y) -
1203    ///   1} (ceil(D_X) - kj)(ceil(D_Z) - j)`
1204    /// ```text
1205    /// n_{\mathrm{vars}}=\sum_{j=0}^{\lceil D_Y\rceil-1}(\lceil D_X\rceil-kj)(\lceil D_Z\rceil-j)
1206    /// n_{\mathrm{eqs}}=n\sum_{s=0}^{m-1}(\lceil D_Z\rceil-s)(m-s)
1207    /// ```
1208    ///
1209    /// Brute-force search for degrees minimizing Equation (13):
1210    /// - scan `D_Y in [max(1, m - 1), D_Y_max]`
1211    /// - `D_Y_max` is chosen from a scaled Lemma 3.1 reference degree (with a hard cap to keep
1212    ///   computation bounded)
1213    /// - scan candidate `D_X >= k * D_Y` up to the Section 3.2 limit
1214    /// - solve directly for the smallest valid `D_Z` for each `(D_X, D_Y)`
1215    pub fn bchks25_optimal_degrees_bruteforce(
1216        log_degree: usize,
1217        log_inv_rate: usize,
1218        m: usize,
1219        gamma: f64,
1220    ) -> Option<(Bchks25Degrees, f64)> {
1221        if !gamma.is_finite() || gamma <= 0.0 {
1222            return None;
1223        }
1224
1225        let log_n = log_degree.checked_add(log_inv_rate)?;
1226        if log_n >= u128::BITS as usize || log_degree >= u128::BITS as usize {
1227            return None;
1228        }
1229
1230        let n = 1_u128.checked_shl(log_n as u32)?;
1231        let k = 1_u128.checked_shl(log_degree as u32)?;
1232        let m_u = m as u128;
1233
1234        let agreements_plus_one = (gamma * n as f64).ceil() + 1.0;
1235        if !agreements_plus_one.is_finite() || agreements_plus_one <= 0.0 {
1236            return None;
1237        }
1238        let log2_agreements_plus_one = agreements_plus_one.log2();
1239        let max_d_x_for_gamma = (1.0 - gamma) * (m_u as f64) * (n as f64);
1240        if !max_d_x_for_gamma.is_finite() || max_d_x_for_gamma <= 0.0 {
1241            return None;
1242        }
1243
1244        let mut best: Option<(Bchks25Degrees, f64)> = None;
1245        let d_y_start = 1_u128.max(m_u.saturating_sub(1));
1246        let d_y_end = bchks25_dy_search_upper_bound(log_degree, log_inv_rate, m);
1247        let d_z_floor = if m < 3 {
1248            bchks25_dz_eq9_index_lower_bound(log_inv_rate, m)?
1249        } else {
1250            0
1251        };
1252        if d_y_start > d_y_end {
1253            return None;
1254        }
1255
1256        for d_y in d_y_start..=d_y_end {
1257            let Some(d_x_base) = k.checked_mul(d_y) else {
1258                continue;
1259            };
1260            if (d_x_base as f64) >= max_d_x_for_gamma {
1261                continue;
1262            }
1263
1264            let d_x_upper = max_d_x_for_gamma.ceil() as u128;
1265            let mut d_x_candidates = Vec::with_capacity(24);
1266            d_x_candidates.push(d_x_base);
1267            if let Some(v) = d_x_base.checked_add(1) {
1268                d_x_candidates.push(v);
1269            }
1270
1271            let d_y_plus_1 = d_y.checked_add(1)?;
1272            let k_term = k
1273                .checked_mul(d_y)?
1274                .checked_mul(d_y_plus_1)?
1275                .checked_div(2)?;
1276            let a_e = n.checked_mul(m_u.checked_mul(m_u.checked_add(1)?)?.checked_div(2)?)?;
1277            let d_x_slope_cross = a_e.checked_add(k_term)?.checked_div(d_y_plus_1)?;
1278            for off in [0_u128, 1, 2] {
1279                if d_x_slope_cross >= off {
1280                    d_x_candidates.push(d_x_slope_cross - off);
1281                }
1282                if let Some(v) = d_x_slope_cross.checked_add(off) {
1283                    d_x_candidates.push(v);
1284                }
1285            }
1286
1287            if d_x_upper > d_x_base {
1288                let step = ((d_x_upper - d_x_base) / 16).max(1);
1289                let mut cur = d_x_base;
1290                while cur <= d_x_upper {
1291                    d_x_candidates.push(cur);
1292                    let Some(next) = cur.checked_add(step) else {
1293                        break;
1294                    };
1295                    if next <= cur {
1296                        break;
1297                    }
1298                    cur = next;
1299                }
1300                d_x_candidates.push(d_x_upper);
1301                d_x_candidates.push(d_x_upper.saturating_sub(1));
1302            }
1303
1304            d_x_candidates.sort_unstable();
1305            d_x_candidates.dedup();
1306
1307            for d_x in d_x_candidates {
1308                if d_x < d_x_base || (d_x as f64) >= max_d_x_for_gamma {
1309                    continue;
1310                }
1311
1312                let Some(d_z) = bchks25_min_dz_for_dx_dy(k, n, m_u, d_x, d_y, d_z_floor) else {
1313                    continue;
1314                };
1315
1316                let log2_d_x = (d_x as f64).log2();
1317                let log2_d_y = (d_y as f64).log2();
1318                let log2_d_z = (d_z as f64).log2();
1319                let log2_a = SoundnessCalculator::bchks25_log2_a_from_log2_degrees(
1320                    log2_d_x,
1321                    log2_d_y,
1322                    log2_d_z,
1323                    log2_agreements_plus_one,
1324                );
1325                if !log2_a.is_finite() {
1326                    continue;
1327                }
1328
1329                let candidate = (Bchks25Degrees { d_x, d_y, d_z }, log2_a);
1330                match best {
1331                    None => best = Some(candidate),
1332                    Some((best_deg, best_log2_a)) => {
1333                        // Stable tie-breaking keeps smaller degrees if scores are effectively
1334                        // equal.
1335                        let better = log2_a + 1e-12 < best_log2_a
1336                            || ((log2_a - best_log2_a).abs() <= 1e-12
1337                                && (d_y < best_deg.d_y
1338                                    || (d_y == best_deg.d_y
1339                                        && (d_z < best_deg.d_z
1340                                            || (d_z == best_deg.d_z && d_x < best_deg.d_x)))));
1341                        if better {
1342                            best = Some(candidate);
1343                        }
1344                    }
1345                }
1346            }
1347        }
1348        best
1349    }
1350
1351    fn bchks25_dy_search_upper_bound(log_degree: usize, log_inv_rate: usize, m: usize) -> u128 {
1352        let (_log2_d_x, log2_d_y, _log2_d_z) =
1353            SoundnessCalculator::bchks25_reference_log2_degrees(log_degree, log_inv_rate, m);
1354        if !log2_d_y.is_finite() {
1355            return BCHKS25_DY_SEARCH_HARD_MAX;
1356        }
1357        let ref_d_y = log2_d_y.exp2().ceil();
1358        if !ref_d_y.is_finite() || ref_d_y <= 0.0 {
1359            return BCHKS25_DY_SEARCH_HARD_MAX;
1360        }
1361        let ref_scaled = (ref_d_y as u128).saturating_mul(BCHKS25_DY_SEARCH_REF_MULTIPLIER);
1362        ref_scaled.clamp(BCHKS25_DY_SEARCH_MIN_MAX, BCHKS25_DY_SEARCH_HARD_MAX)
1363    }
1364
1365    /// Index-space lower bound from Equation (9), where `d_z = ceil(D_Z)-1`.
1366    fn bchks25_dz_eq9_index_lower_bound(log_inv_rate: usize, m: usize) -> Option<u128> {
1367        let m_u = m.max(1) as u128;
1368        if log_inv_rate >= u128::BITS as usize {
1369            return None;
1370        }
1371
1372        // Equation (9): D_Z = ((m + 1/2)^2 * (n/k)) / 3
1373        // with n/k = 2^{log_inv_rate}. So:
1374        // D_Z = ((2m+1)^2 * 2^{log_inv_rate}) / 12.
1375        let n_over_k = 1_u128.checked_shl(log_inv_rate as u32)?;
1376        let two_m_plus_1 = m_u.checked_mul(2)?.checked_add(1)?;
1377        let numerator = two_m_plus_1
1378            .checked_mul(two_m_plus_1)?
1379            .checked_mul(n_over_k)?;
1380        let ceil_d_z = numerator.checked_add(11)?.checked_div(12)?;
1381        Some(ceil_d_z.saturating_sub(1))
1382    }
1383
1384    /// Counts interpolation variables for fixed `D_X, D_Y, D_Z`.
1385    ///
1386    /// Here `d_x`, `d_y` and `d_z` are index-space bounds
1387    /// (`ceil(D_X)-1`, `ceil(D_Y)-1`, `ceil(D_Z)-1`), not real degrees.
1388    /// This matches the lattice count used in Lemma 3.1:
1389    /// `j + h < D_Z` contributes `(d_z - j + 1)` monomials in `Z`.
1390    #[cfg(test)]
1391    fn bchks25_num_vars(k: u128, d_x: u128, d_y: u128, d_z: u128) -> Option<u128> {
1392        if d_z < d_y {
1393            return Some(0);
1394        }
1395
1396        let mut total = 0_u128;
1397        for j in 0..=d_y {
1398            let x_terms = d_x.checked_sub(k.checked_mul(j)?)?.checked_add(1)?;
1399            let z_terms = (d_z - j).checked_add(1)?;
1400            let add = x_terms.checked_mul(z_terms)?;
1401            total = total.checked_add(add)?;
1402        }
1403        Some(total)
1404    }
1405
1406    /// Equation (11) RHS (closed form):
1407    /// `n_eqs = n * ( m(m+1)/2 * ceil(D_Z) - (m^3-m)/6 )`.
1408    ///
1409    /// We store `d_z = ceil(D_Z)-1` (index-space convention), therefore `ceil(D_Z)=d_z+1`.
1410    #[cfg(test)]
1411    fn bchks25_num_eqs_eq11(n: u128, m: u128, d_z: u128) -> Option<u128> {
1412        let ceil_d_z = d_z.checked_add(1)?;
1413        let m_plus_1 = m.checked_add(1)?;
1414        let m_choose_2_scaled = m.checked_mul(m_plus_1)?.checked_div(2)?;
1415        let m_cubic_minus_m_over_6 = m
1416            .checked_mul(m)?
1417            .checked_mul(m)?
1418            .checked_sub(m)?
1419            .checked_div(6)?;
1420        let inner = m_choose_2_scaled
1421            .checked_mul(ceil_d_z)?
1422            .checked_sub(m_cubic_minus_m_over_6)?;
1423        n.checked_mul(inner)
1424    }
1425
1426    /// Solves for the minimal index-space `d_z` satisfying
1427    /// `n_vars(d_z) > n_eqs(d_z)` (Equation (11)).
1428    ///
1429    /// Both sides are affine in `d_z`, so this is a 1D linear inequality with bounds
1430    /// `d_z in [max(d_y, m-1), BCHKS25_DZ_SEARCH_MAX]` and does not require binary search.
1431    ///
1432    /// The returned value corresponds to real-degree parameter `D_Z` via `d_z = ceil(D_Z)-1`.
1433    fn bchks25_min_dz_for_dx_dy(
1434        k: u128,
1435        n: u128,
1436        m: u128,
1437        d_x: u128,
1438        d_y: u128,
1439        d_z_floor: u128,
1440    ) -> Option<u128> {
1441        let low = d_y.max(m.saturating_sub(1)).max(d_z_floor);
1442        let high = BCHKS25_DZ_SEARCH_MAX;
1443        if low > high {
1444            return None;
1445        }
1446
1447        // n_vars(d_z) for fixed (d_x, d_y):
1448        // n_vars = sum_{j=0}^{d_y} (d_x - k*j + 1) * (d_z - j + 1)
1449        //        = A_v * (d_z + 1) - B_v
1450        //
1451        let d_y_plus_1 = d_y.checked_add(1)?;
1452        let d_y_d_y_plus_1_over_2 = d_y.checked_mul(d_y_plus_1)?.checked_div(2)?;
1453        let d_y_d_y_plus_1_two_d_y_plus_1_over_6 = d_y
1454            .checked_mul(d_y)?
1455            .checked_add(d_y)?
1456            .checked_mul(d_y.checked_mul(2)?.checked_add(1)?)?
1457            .checked_div(6)?;
1458        let a_v = d_y_plus_1
1459            .checked_mul(d_x.checked_add(1)?)?
1460            .checked_sub(k.checked_mul(d_y_d_y_plus_1_over_2)?)?;
1461        let b_v = d_x
1462            .checked_add(1)?
1463            .checked_mul(d_y_d_y_plus_1_over_2)?
1464            .checked_sub(k.checked_mul(d_y_d_y_plus_1_two_d_y_plus_1_over_6)?)?;
1465        if a_v == 0 {
1466            return None;
1467        }
1468
1469        // n_eqs(d_z) = A_e * (d_z + 1) - B_e from Equation (11) closed form.
1470        let m_plus_1 = m.checked_add(1)?;
1471        let a_e = n.checked_mul(m.checked_mul(m_plus_1)?.checked_div(2)?)?;
1472        let b_e = n.checked_mul(
1473            m.checked_mul(m)?
1474                .checked_mul(m)?
1475                .checked_sub(m)?
1476                .checked_div(6)?,
1477        )?;
1478
1479        let is_valid = |d_z: u128| -> Option<bool> {
1480            let x = d_z.checked_add(1)?;
1481            let n_vars = a_v.checked_mul(x)?.checked_sub(b_v)?;
1482            let n_eqs = a_e.checked_mul(x)?.checked_sub(b_e)?;
1483            Some(n_vars > n_eqs)
1484        };
1485
1486        match a_v.cmp(&a_e) {
1487            core::cmp::Ordering::Greater => {
1488                // Increasing predicate: solve exactly, then clamp to bounds.
1489                let slope = a_v.checked_sub(a_e)?;
1490                let candidate = if b_v < b_e {
1491                    low
1492                } else {
1493                    let rhs = b_v.checked_sub(b_e)?;
1494                    let x_min = rhs.checked_div(slope)?.checked_add(1)?;
1495                    let d_min = x_min.checked_sub(1)?;
1496                    low.max(d_min)
1497                };
1498                if candidate > high {
1499                    return None;
1500                }
1501                if is_valid(candidate)? {
1502                    Some(candidate)
1503                } else {
1504                    None
1505                }
1506            }
1507            core::cmp::Ordering::Equal => {
1508                if b_v < b_e {
1509                    Some(low)
1510                } else {
1511                    None
1512                }
1513            }
1514            core::cmp::Ordering::Less => {
1515                // Decreasing predicate: if low is not valid, no later value can be valid.
1516                if is_valid(low)? {
1517                    Some(low)
1518                } else {
1519                    None
1520                }
1521            }
1522        }
1523    }
1524
1525    #[test]
1526    fn test_bchks25_optimizer_finds_minimal_valid_dz() {
1527        let log_degree = 14;
1528        let log_inv_rate = 5;
1529        let m = 1;
1530
1531        let rho = (-(log_inv_rate as f64)).exp2();
1532        let sqrt_rho = rho.sqrt();
1533        let eta = sqrt_rho / (2.0 * m as f64);
1534        let gamma = 1.0 - sqrt_rho - eta;
1535
1536        let (degrees, _log2_a) =
1537            bchks25_optimal_degrees_bruteforce(log_degree, log_inv_rate, m, gamma)
1538                .expect("optimizer should find valid degrees");
1539        let n = 1_u128 << (log_degree + log_inv_rate);
1540        let k = 1_u128 << log_degree;
1541        let m_u = m as u128;
1542
1543        assert!(degrees.d_y >= m_u.saturating_sub(1));
1544        let max_d_x_for_gamma = (1.0 - gamma) * (m as f64) * (n as f64);
1545        assert!(degrees.d_x >= k * degrees.d_y);
1546        assert!((degrees.d_x as f64) < max_d_x_for_gamma);
1547        let d_z_floor = bchks25_dz_eq9_index_lower_bound(log_inv_rate, m)
1548            .expect("d_z floor should be representable");
1549        assert!(degrees.d_z >= degrees.d_y.max(d_z_floor));
1550        let vars = bchks25_num_vars(k, degrees.d_x, degrees.d_y, degrees.d_z)
1551            .expect("vars should fit in u128");
1552        let eqs = bchks25_num_eqs_eq11(n, m_u, degrees.d_z).expect("eqs should fit in u128");
1553        assert!(vars > eqs);
1554        let low = degrees.d_y.max(m_u.saturating_sub(1)).max(d_z_floor);
1555        if degrees.d_z > low {
1556            let prev_vars = bchks25_num_vars(k, degrees.d_x, degrees.d_y, degrees.d_z - 1)
1557                .expect("vars should fit in u128");
1558            let prev_eqs =
1559                bchks25_num_eqs_eq11(n, m_u, degrees.d_z - 1).expect("eqs should fit in u128");
1560            assert!(prev_vars <= prev_eqs);
1561        }
1562    }
1563
1564    #[test]
1565    fn test_bchks25_eq11_closed_form_matches_expanded_sum() {
1566        let n = 1_u128 << 12;
1567        for m in 2_u128..=8 {
1568            for d_z in (m - 1)..=(m + 12) {
1569                let closed_form =
1570                    bchks25_num_eqs_eq11(n, m, d_z).expect("closed-form n_eqs should fit in u128");
1571                let ceil_d_z = d_z + 1;
1572                let mut expanded_sum = 0_u128;
1573                for s in 0..m {
1574                    expanded_sum += (ceil_d_z - s) * (m - s);
1575                }
1576                let expanded = n * expanded_sum;
1577                assert_eq!(closed_form, expanded);
1578            }
1579        }
1580    }
1581
1582    #[test]
1583    fn test_bchks25_min_dz_direct_solve_matches_linear_scan() {
1584        let cases = [
1585            // Increasing predicate case.
1586            (
1587                1_u128 << 20,
1588                1_u128 << 22,
1589                3_u128,
1590                2_u128,
1591                (1_u128 << 20) * 2,
1592            ),
1593            // Decreasing predicate case.
1594            (
1595                1_u128 << 12,
1596                1_u128 << 24,
1597                6_u128,
1598                5_u128,
1599                (1_u128 << 12) * 5,
1600            ),
1601            // Near-flat-ish case.
1602            (
1603                1_u128 << 16,
1604                1_u128 << 20,
1605                4_u128,
1606                3_u128,
1607                (1_u128 << 16) * 3 + 1,
1608            ),
1609            // m < 3 with Equation (9) lower bound active.
1610            (
1611                1_u128 << 12,
1612                1_u128 << 17,
1613                1_u128,
1614                2_u128,
1615                (1_u128 << 12) * 2 + 17,
1616            ),
1617        ];
1618
1619        for (k, n, m, d_y, d_x) in cases {
1620            assert!(d_x >= k * d_y);
1621            let log_inv_rate = (n / k).ilog2() as usize;
1622            let d_z_floor = if m < 3 {
1623                bchks25_dz_eq9_index_lower_bound(log_inv_rate, m as usize)
1624                    .expect("d_z floor should be representable")
1625            } else {
1626                0
1627            };
1628            let got = bchks25_min_dz_for_dx_dy(k, n, m, d_x, d_y, d_z_floor);
1629            let low = d_y.max(m.saturating_sub(1)).max(d_z_floor);
1630            let expected = (low..=BCHKS25_DZ_SEARCH_MAX).find(|&d_z| {
1631                let vars = bchks25_num_vars(k, d_x, d_y, d_z).expect("vars should fit");
1632                let eqs = bchks25_num_eqs_eq11(n, m, d_z).expect("eqs should fit");
1633                vars > eqs
1634            });
1635            assert_eq!(got, expected);
1636        }
1637    }
1638}