Skip to main content

p3_security/whir/
hiding.rs

1//! Diagnostic ideal-IOP soundness accounting for the HVZK-WHIR base case.
2//!
3//! The componentwise heterogeneous extension of Construction 7.2 combines one
4//! randomized source code and a chronological family of randomized mask codes
5//! with one shared challenge. Its two RBR error coordinates have different
6//! composition rules:
7//!
8//! - component MCA errors add, as do the shared-challenge MCA and list terms;
9//! - candidate-list sizes multiply, so their base-two logarithms add;
10//! - query miss probabilities take a maximum, so their security bits take a
11//!   minimum;
12//! - the final query PoW is shared by the whole query round and, under the
13//!   Fiat--Shamir grinding model, is added once.
14//!
15//! This module deliberately reports those terms without changing parameter
16//! selection. It is not an end-to-end security claim: it excludes the earlier
17//! masked-sumcheck and code-switch reductions, Merkle hiding and binding,
18//! Fiat--Shamir compilation, random-oracle extraction, hash collisions, and
19//! simulation of the commitment view. In particular, a conditional or
20//! approximate number must not be presented as proven security.
21
22use alloc::vec::Vec;
23
24use p3_field::Field;
25use serde::Serialize;
26
27use crate::assumption::SecurityAssumption;
28
29/// How much theorem support a reported bound has.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[non_exhaustive]
32pub enum HidingBoundClassification {
33    /// The displayed component or ideal-IOP composition follows from a theorem.
34    ///
35    /// This label is local to the term and its documented branch premise. For
36    /// example, a query-miss term is proven when the oracle is at least the
37    /// reported radius from its code; that does not upgrade a conditional
38    /// close-word list or MCA branch in the Capacity regime. Likewise, a
39    /// proven aggregate covers only the ideal-IOP composition reported here,
40    /// not the reductions and compiler steps excluded by this module.
41    Proven,
42    /// The term depends on an additional coding or compiler assumption.
43    ///
44    /// This is used for the capacity-rate coding conjectures and for the
45    /// post-PoW query estimate under the Fiat--Shamir grinding model.
46    Conditional,
47    /// The term is a numerical approximation, not a conservative theorem bound.
48    ///
49    /// Retained for API compatibility and downstream constructions of
50    /// approximate bounds. This crate's reports do not currently produce it.
51    Approximation,
52    /// No accepted numerical bound is encoded for this geometry.
53    Unavailable,
54}
55
56/// A probability upper bound represented as `-log2(probability)`.
57///
58/// `bits` is `None` exactly when the term is unavailable. Available values
59/// are capped below at zero because a probability bound larger than one says
60/// nothing. `vacuous` records when that cap was needed; it is a certification
61/// failure, not evidence of an attack.
62#[derive(Debug, Clone, PartialEq, Serialize)]
63#[non_exhaustive]
64pub struct HidingErrorBound {
65    /// Security bits, or `None` if no numerical term is available.
66    pub bits: Option<f64>,
67    /// The theorem status of this term.
68    pub classification: HidingBoundClassification,
69    /// Whether the underlying probability upper bound is at least one.
70    pub vacuous: bool,
71}
72
73impl HidingErrorBound {
74    fn available(bits: f64, classification: HidingBoundClassification) -> Self {
75        let vacuous = bits <= 0.;
76        Self {
77            bits: Some(bits.max(0.)),
78            classification,
79            vacuous,
80        }
81    }
82
83    const fn unavailable() -> Self {
84        Self {
85            bits: None,
86            classification: HidingBoundClassification::Unavailable,
87            vacuous: false,
88        }
89    }
90
91    /// Returns bits only for a non-vacuous theorem-backed term in its
92    /// documented branch.
93    ///
94    /// This does not assert that sibling terms, a selected coding regime, or
95    /// the compiled PCS are proven end to end.
96    #[must_use]
97    pub const fn proven_bits(&self) -> Option<f64> {
98        match (self.classification, self.bits, self.vacuous) {
99            (HidingBoundClassification::Proven, bits, false) => bits,
100            _ => None,
101        }
102    }
103}
104
105/// A candidate-list size upper bound represented as `log2(list_size)`.
106#[derive(Debug, Clone, PartialEq, Serialize)]
107#[non_exhaustive]
108pub struct HidingListBound {
109    /// Base-two logarithm of the list-size bound.
110    pub log2_size: Option<f64>,
111    /// The theorem status of the list-size bound.
112    pub classification: HidingBoundClassification,
113}
114
115/// The role of one randomized code in Construction 7.2.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
117#[non_exhaustive]
118pub enum HidingCodeRole {
119    /// The folded terminal witness oracle.
120    Source,
121    /// A sumcheck-mask group, numbered in protocol order.
122    SumcheckMask {
123        /// Sumcheck batch index.
124        round: usize,
125    },
126    /// A code-switch-mask group.
127    CodeSwitchMask {
128        /// Code-switch round index.
129        round: usize,
130    },
131}
132
133/// Security-relevant geometry of one randomized code component.
134///
135/// Protocol crates assemble these values from their runtime configuration and
136/// pass them to [`hiding_base_case_security_report`]. Keeping this input free
137/// of protocol configuration types preserves the dependency direction from a
138/// protocol crate to `p3-security`.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct HidingCodeGeometry {
141    role: HidingCodeRole,
142    message_len: usize,
143    randomness_len: usize,
144    domain_size: usize,
145    width: usize,
146    requested_queries: usize,
147}
148
149impl HidingCodeGeometry {
150    /// Creates the security-relevant geometry for one randomized code.
151    ///
152    /// # Panics
153    ///
154    /// Panics unless `domain_size` is a power of two, the randomized
155    /// dimension fits in that domain, and `width` is nonzero and can be
156    /// doubled for the paired old/fresh interleaving.
157    #[must_use]
158    pub const fn new(
159        role: HidingCodeRole,
160        message_len: usize,
161        randomness_len: usize,
162        domain_size: usize,
163        width: usize,
164        requested_queries: usize,
165    ) -> Self {
166        assert!(
167            domain_size.is_power_of_two(),
168            "the codeword domain size must be a power of two"
169        );
170        assert!(
171            randomness_len <= domain_size && message_len <= domain_size - randomness_len,
172            "the randomized-code dimension must fit in the codeword domain"
173        );
174        assert!(width > 0, "the interleaving width must be nonzero");
175        assert!(
176            width <= usize::MAX / 2,
177            "the interleaving width must be safe to double"
178        );
179        Self {
180            role,
181            message_len,
182            randomness_len,
183            domain_size,
184            width,
185            requested_queries,
186        }
187    }
188}
189
190/// Geometry and ideal-IOP bounds for one randomized code component.
191#[derive(Debug, Clone, PartialEq, Serialize)]
192#[non_exhaustive]
193pub struct HidingCodeSecurityReport {
194    /// The component's protocol role.
195    pub role: HidingCodeRole,
196    /// Number of message coefficients in one lane.
197    pub message_len: usize,
198    /// Number of randomizing coefficients in one lane.
199    pub randomness_len: usize,
200    /// Actual randomized-code dimension, `message_len + randomness_len`.
201    pub randomized_dimension: usize,
202    /// Next-power-of-two envelope used for conservative RS bounds.
203    pub dyadic_dimension: usize,
204    /// Codeword length.
205    pub domain_size: usize,
206    /// Exact randomized-code rate, `(message_len + randomness_len) / domain_size`.
207    pub randomized_rate: f64,
208    /// Logarithm of the conservative dyadic-envelope inverse rate.
209    ///
210    /// This is unavailable when the dyadic envelope fills the domain: none
211    /// of the configured proximity regimes supplies a positive radius then.
212    pub analysis_log_inv_rate: Option<usize>,
213    /// Proximity radius selected at the dyadic-envelope rate.
214    pub analysis_radius: Option<f64>,
215    /// Number of lanes sharing one row-query schedule.
216    pub width: usize,
217    /// Number of row queries requested by the derived configuration.
218    pub requested_queries: usize,
219    /// Number of distinct rows the sampler actually opens.
220    ///
221    /// The sampler saturates at the codeword length.
222    pub effective_queries: usize,
223    /// Conservative query-miss error before the shared final PoW.
224    ///
225    /// At saturation this keeps the IID expression instead of reporting the
226    /// exact zero miss probability. The sampling bound is proven under the
227    /// premise that the pre-query component is at least `analysis_radius`
228    /// from its code. Under `CapacityBound`, the separate list/MCA terms
229    /// remain conditional; this field does not promote the full regime to
230    /// proven.
231    pub query_miss: HidingErrorBound,
232    /// MCA error for this component. Interleaved groups include the
233    /// conservative factor equal to `width`.
234    pub mca: HidingErrorBound,
235    /// Candidate-list bound for the old/fresh pair. Its interleaving is two
236    /// for the source and `2 * width` for a mask group.
237    pub paired_list: HidingListBound,
238}
239
240/// The shared query-round accounting.
241#[derive(Debug, Clone, PartialEq, Serialize)]
242#[non_exhaustive]
243pub struct HidingQueryRoundReport {
244    /// Source query-miss bits before PoW.
245    pub source_before_pow_bits: Option<f64>,
246    /// Mask query-miss bits in chronological group order, before PoW.
247    pub masks_before_pow_bits: Vec<Option<f64>>,
248    /// Minimum component bits, corresponding to the maximum miss probability.
249    pub weakest_before_pow_bits: Option<f64>,
250    /// Ideal-IOP query coordinate before any Fiat--Shamir grinding credit.
251    ///
252    /// This composes the proven far-word sampling branches. It does not
253    /// classify the corresponding close-word list/MCA branches.
254    pub before_pow: HidingErrorBound,
255    /// The one final grind shared by all source and mask queries.
256    pub shared_pow_bits: usize,
257    /// Source query estimate after applying the shared grind once.
258    pub source_with_pow: HidingErrorBound,
259    /// Weakest query estimate after applying the shared grind once.
260    ///
261    /// This is compiler-conditional when `shared_pow_bits > 0`.
262    pub with_pow: HidingErrorBound,
263}
264
265/// Shared-`gamma` combination-round accounting.
266#[derive(Debug, Clone, PartialEq, Serialize)]
267#[non_exhaustive]
268pub struct HidingGammaRoundReport {
269    /// Sum of the source and chronological mask-group MCA errors.
270    pub mca_sum: HidingErrorBound,
271    /// Logarithm of the product of all old/fresh candidate-list bounds.
272    pub product_list: HidingListBound,
273    /// Product-list size divided by the exact field order.
274    pub product_list_over_field: HidingErrorBound,
275    /// Sum of the MCA and product-list/field error terms.
276    pub combined: HidingErrorBound,
277}
278
279/// Non-enforcing report for the ideal interactive hiding base case.
280#[derive(Debug, Clone, PartialEq, Serialize)]
281#[non_exhaustive]
282pub struct HidingBaseCaseSecurityReport {
283    /// User-requested security level. The report does not assert it is met.
284    pub target_bits: usize,
285    /// Coding regime used for all component radii and bounds.
286    pub assumption: SecurityAssumption,
287    /// Numerical estimate of the exact `log2(EF::order())`.
288    ///
289    /// This retains the fractional part for diagnostics but is not used as a
290    /// theorem bound, because `libm::log2` does not provide directed rounding.
291    pub field_order_log2_estimate: f64,
292    /// Rigorous integer lower bound `floor(log2(EF::order()))`.
293    ///
294    /// All field-dependent probability bounds use this value.
295    pub field_order_log2_floor: usize,
296    /// Folded terminal source-code report.
297    pub source: HidingCodeSecurityReport,
298    /// Mask groups in chronological commitment order.
299    pub mask_groups: Vec<HidingCodeSecurityReport>,
300    /// Query-round report.
301    pub query_round: HidingQueryRoundReport,
302    /// Shared-challenge combination-round report.
303    pub gamma_round: HidingGammaRoundReport,
304    /// The weaker of the two Construction 7.2 RBR error coordinates.
305    ///
306    /// This is `min(gamma_round, query_round.before_pow)` in security-bit
307    /// notation, not `-log2` of a union of the coordinate errors. Its
308    /// classification conservatively combines both coordinates' provenance,
309    /// even when the less-proven coordinate is not numerically binding,
310    /// because certifying the reported RBR level requires both bounds. It
311    /// deliberately excludes `query_round.with_pow`, which assumes the
312    /// Fiat--Shamir grinding model, and does not compose with other WHIR or
313    /// compiler errors. A `Proven` classification here says only that both
314    /// coordinates and this ideal-IOP RBR composition are theorem-backed
315    /// within that scope.
316    pub round_by_round: HidingErrorBound,
317}
318
319/// Reports the heterogeneous HVZK-WHIR base case's ideal-IOP RBR terms.
320///
321/// The caller supplies protocol-derived source and mask geometry. The field
322/// order and every security term are derived here so all users share one
323/// implementation of the accounting. This is diagnostic only: it neither
324/// rejects a configuration nor certifies a compiled PCS or the preceding
325/// hiding reductions.
326#[must_use]
327pub fn hiding_base_case_security_report<EF: Field>(
328    target_bits: usize,
329    assumption: SecurityAssumption,
330    source: HidingCodeGeometry,
331    mask_groups: Vec<HidingCodeGeometry>,
332    shared_pow_bits: usize,
333) -> HidingBaseCaseSecurityReport {
334    build_hiding_base_case_security_report(
335        target_bits,
336        assumption,
337        log2_order_estimate::<EF>(),
338        EF::order().bits() as usize - 1,
339        source,
340        mask_groups,
341        shared_pow_bits,
342    )
343}
344
345#[allow(clippy::too_many_arguments)]
346fn build_hiding_base_case_security_report(
347    target_bits: usize,
348    assumption: SecurityAssumption,
349    field_order_log2_estimate: f64,
350    field_order_log2_floor: usize,
351    source: HidingCodeGeometry,
352    mask_groups: Vec<HidingCodeGeometry>,
353    shared_pow_bits: usize,
354) -> HidingBaseCaseSecurityReport {
355    let source = code_report(source, assumption, field_order_log2_floor);
356    let mask_groups: Vec<_> = mask_groups
357        .into_iter()
358        .map(|geometry| code_report(geometry, assumption, field_order_log2_floor))
359        .collect();
360
361    let source_before_pow_bits = source.query_miss.bits;
362    let masks_before_pow_bits: Vec<Option<f64>> = mask_groups
363        .iter()
364        .map(|group| group.query_miss.bits)
365        .collect();
366    let weakest_before_pow_bits = minimum_available(
367        core::iter::once(source_before_pow_bits).chain(masks_before_pow_bits.iter().copied()),
368    );
369    let before_pow = weakest_before_pow_bits.map_or_else(HidingErrorBound::unavailable, |bits| {
370        HidingErrorBound::available(bits, HidingBoundClassification::Proven)
371    });
372    let pow_classification = if shared_pow_bits == 0 {
373        HidingBoundClassification::Proven
374    } else {
375        HidingBoundClassification::Conditional
376    };
377    let source_with_pow = source_before_pow_bits
378        .map_or_else(HidingErrorBound::unavailable, |bits| {
379            HidingErrorBound::available(bits + shared_pow_bits as f64, pow_classification)
380        });
381    let with_pow = weakest_before_pow_bits.map_or_else(HidingErrorBound::unavailable, |bits| {
382        HidingErrorBound::available(bits + shared_pow_bits as f64, pow_classification)
383    });
384    let query_round = HidingQueryRoundReport {
385        source_before_pow_bits,
386        masks_before_pow_bits,
387        weakest_before_pow_bits,
388        before_pow,
389        shared_pow_bits,
390        source_with_pow,
391        with_pow,
392    };
393
394    let mca_bounds = core::iter::once(&source)
395        .chain(mask_groups.iter())
396        .map(|component| component.mca.bits);
397    let mca_classification = mca_classification(assumption);
398    let mca_sum = sum_available_probability_bits(mca_bounds)
399        .map_or_else(HidingErrorBound::unavailable, |bits| {
400            HidingErrorBound::available(bits, mca_classification)
401        });
402
403    let list_classification = list_classification(assumption);
404    let product_list_log2 = sum_available(
405        core::iter::once(&source)
406            .chain(mask_groups.iter())
407            .map(|component| component.paired_list.log2_size),
408    );
409    let product_list = HidingListBound {
410        log2_size: product_list_log2,
411        classification: if product_list_log2.is_some() {
412            list_classification
413        } else {
414            HidingBoundClassification::Unavailable
415        },
416    };
417    let product_list_over_field =
418        product_list_log2.map_or_else(HidingErrorBound::unavailable, |list_bits| {
419            HidingErrorBound::available(
420                field_order_log2_floor as f64 - list_bits,
421                list_classification,
422            )
423        });
424    let gamma_classification = combine_classifications(
425        mca_sum.classification,
426        product_list_over_field.classification,
427    );
428    let gamma_combined =
429        sum_available_probability_bits([mca_sum.bits, product_list_over_field.bits])
430            .map_or_else(HidingErrorBound::unavailable, |bits| {
431                HidingErrorBound::available(bits, gamma_classification)
432            });
433    let gamma_round = HidingGammaRoundReport {
434        mca_sum,
435        product_list,
436        product_list_over_field,
437        combined: gamma_combined,
438    };
439
440    let round_by_round_classification = combine_classifications(
441        gamma_round.combined.classification,
442        query_round.before_pow.classification,
443    );
444    let round_by_round =
445        minimum_available([gamma_round.combined.bits, query_round.before_pow.bits])
446            .map_or_else(HidingErrorBound::unavailable, |bits| {
447                HidingErrorBound::available(bits, round_by_round_classification)
448            });
449
450    HidingBaseCaseSecurityReport {
451        target_bits,
452        assumption,
453        field_order_log2_estimate,
454        field_order_log2_floor,
455        source,
456        mask_groups,
457        query_round,
458        gamma_round,
459        round_by_round,
460    }
461}
462
463fn code_report(
464    geometry: HidingCodeGeometry,
465    assumption: SecurityAssumption,
466    field_order_log2_floor: usize,
467) -> HidingCodeSecurityReport {
468    let HidingCodeGeometry {
469        role,
470        message_len,
471        randomness_len,
472        domain_size,
473        width,
474        requested_queries,
475    } = geometry;
476    let randomized_dimension = message_len + randomness_len;
477    let dyadic_dimension = randomized_dimension.next_power_of_two();
478    let log_degree = dyadic_dimension.ilog2() as usize;
479    let raw_log_inv_rate = domain_size.ilog2() as usize - log_degree;
480    let analysis_log_inv_rate = (raw_log_inv_rate > 0).then_some(raw_log_inv_rate);
481    let one_minus_delta =
482        analysis_log_inv_rate.map(|rate| libm::pow(2., assumption.log_1_delta(rate)));
483    let analysis_radius = one_minus_delta.map(|one_minus_delta| 1. - one_minus_delta);
484    let effective_queries = requested_queries.min(domain_size);
485
486    let (query_miss, mca, paired_list) = analysis_log_inv_rate.map_or_else(
487        || {
488            (
489                HidingErrorBound::unavailable(),
490                HidingErrorBound::unavailable(),
491                HidingListBound {
492                    log2_size: None,
493                    classification: HidingBoundClassification::Unavailable,
494                },
495            )
496        },
497        |log_inv_rate| {
498            let one_minus_delta = one_minus_delta.expect("a positive-rate envelope has a radius");
499            let query_bits = assumption.queries_error(log_inv_rate, effective_queries);
500            // Two functions give curve degree one: Johnson's Proven label here
501            // uses the literal line theorem, without the polynomial-curve extension.
502            // Its complete bound needs no legacy one-bit reporting reserve.
503            let mca_bits =
504                assumption.prox_gaps_error(log_degree, log_inv_rate, field_order_log2_floor, 2)
505                    - libm::log2(width as f64);
506            let paired_interleaving = 2 * width;
507            (
508                HidingErrorBound::available(query_bits, HidingBoundClassification::Proven),
509                HidingErrorBound::available(mca_bits, mca_classification(assumption)),
510                HidingListBound {
511                    log2_size: Some(interleaved_list_bits(
512                        assumption,
513                        log_degree,
514                        log_inv_rate,
515                        paired_interleaving,
516                        one_minus_delta,
517                    )),
518                    classification: list_classification(assumption),
519                },
520            )
521        },
522    );
523
524    HidingCodeSecurityReport {
525        role,
526        message_len,
527        randomness_len,
528        randomized_dimension,
529        dyadic_dimension,
530        domain_size,
531        randomized_rate: randomized_dimension as f64 / domain_size as f64,
532        analysis_log_inv_rate,
533        analysis_radius,
534        width,
535        requested_queries,
536        effective_queries,
537        query_miss,
538        mca,
539        paired_list,
540    }
541}
542
543fn interleaved_list_bits(
544    assumption: SecurityAssumption,
545    log_degree: usize,
546    log_inv_rate: usize,
547    interleaving: usize,
548    one_minus_delta: f64,
549) -> f64 {
550    let base = assumption.list_size_bits(log_degree, log_inv_rate);
551    let crude = interleaving as f64 * base;
552
553    // Lemma 3.13 of "Zero-Knowledge IOPPs for Constrained Interleaved
554    // Codes" (ePrint 2026/391), recalling [GGR11], gives the
555    // interleaving-stable alternative binomial(b + r, r) * L^r. Taking the
556    // better of this and L^K is conservative because both are upper bounds.
557    let rho = libm::pow(2., -(log_inv_rate as f64));
558    let distance = 1. - rho;
559    let delta = 1. - one_minus_delta;
560    let gap = one_minus_delta - rho;
561    let b = libm::ceil(delta / gap) as usize;
562    let r = libm::ceil(libm::log2(distance / gap)) as usize;
563    let stable = b
564        .checked_add(r)
565        .map_or(f64::INFINITY, |n| log2_binomial(n, r) + r as f64 * base);
566    crude.min(stable)
567}
568
569fn log2_binomial(n: usize, k: usize) -> f64 {
570    let k = k.min(n - k);
571    (1..=k)
572        .map(|i| libm::log2((n - k + i) as f64) - libm::log2(i as f64))
573        .sum()
574}
575
576const fn mca_classification(assumption: SecurityAssumption) -> HidingBoundClassification {
577    match assumption {
578        SecurityAssumption::UniqueDecoding => HidingBoundClassification::Proven,
579        SecurityAssumption::JohnsonBound => HidingBoundClassification::Proven,
580        SecurityAssumption::CapacityBound => HidingBoundClassification::Conditional,
581    }
582}
583
584const fn list_classification(assumption: SecurityAssumption) -> HidingBoundClassification {
585    match assumption {
586        SecurityAssumption::UniqueDecoding | SecurityAssumption::JohnsonBound => {
587            HidingBoundClassification::Proven
588        }
589        SecurityAssumption::CapacityBound => HidingBoundClassification::Conditional,
590    }
591}
592
593const fn combine_classifications(
594    left: HidingBoundClassification,
595    right: HidingBoundClassification,
596) -> HidingBoundClassification {
597    match (left, right) {
598        (HidingBoundClassification::Unavailable, _)
599        | (_, HidingBoundClassification::Unavailable) => HidingBoundClassification::Unavailable,
600        (HidingBoundClassification::Conditional, _)
601        | (_, HidingBoundClassification::Conditional) => HidingBoundClassification::Conditional,
602        (HidingBoundClassification::Approximation, _)
603        | (_, HidingBoundClassification::Approximation) => HidingBoundClassification::Approximation,
604        (HidingBoundClassification::Proven, HidingBoundClassification::Proven) => {
605            HidingBoundClassification::Proven
606        }
607    }
608}
609
610/// Returns `-log2(sum_i 2^-bits_i)` without underflowing large exponents.
611fn sum_probability_bits(bits: impl IntoIterator<Item = f64>) -> f64 {
612    let bits: Vec<f64> = bits.into_iter().collect();
613    let minimum = bits.iter().copied().fold(f64::INFINITY, f64::min);
614    if minimum == f64::INFINITY {
615        return minimum;
616    }
617    let scaled_sum: f64 = bits
618        .into_iter()
619        .map(|value| libm::pow(2., -(value - minimum)))
620        .sum();
621    minimum - libm::log2(scaled_sum)
622}
623
624fn sum_available_probability_bits(bits: impl IntoIterator<Item = Option<f64>>) -> Option<f64> {
625    let bits: Option<Vec<_>> = bits.into_iter().collect();
626    bits.map(sum_probability_bits)
627}
628
629fn sum_available(bits: impl IntoIterator<Item = Option<f64>>) -> Option<f64> {
630    bits.into_iter()
631        .try_fold(0., |sum, value| value.map(|value| sum + value))
632}
633
634fn minimum_available(bits: impl IntoIterator<Item = Option<f64>>) -> Option<f64> {
635    bits.into_iter().try_fold(f64::INFINITY, |minimum, value| {
636        value.map(|value| minimum.min(value))
637    })
638}
639
640/// A fractional `log2` estimate of the exact field cardinality.
641///
642/// The top 53 integer bits are retained before conversion. The report exposes
643/// this for diagnostics only; theorem bounds use the exact integer floor.
644fn log2_order_estimate<EF: Field>() -> f64 {
645    const RETAINED_BITS: u64 = 53;
646
647    let order = EF::order();
648    let order_bits = order.bits();
649    let shift = order_bits.saturating_sub(RETAINED_BITS) as usize;
650    let high = (&order >> shift)
651        .to_u64_digits()
652        .first()
653        .copied()
654        .expect("a field has nonzero order");
655    shift as f64 + libm::log2(high as f64)
656}
657
658#[cfg(test)]
659mod tests {
660    use alloc::vec;
661
662    use super::*;
663
664    const FIELD_LOG2_ESTIMATE: f64 = 128.75;
665    const FIELD_LOG2_FLOOR: usize = 128;
666
667    fn generic_report(
668        assumption: SecurityAssumption,
669        source: HidingCodeGeometry,
670        mask_groups: Vec<HidingCodeGeometry>,
671        shared_pow_bits: usize,
672    ) -> HidingBaseCaseSecurityReport {
673        build_hiding_base_case_security_report(
674            100,
675            assumption,
676            FIELD_LOG2_ESTIMATE,
677            FIELD_LOG2_FLOOR,
678            source,
679            mask_groups,
680            shared_pow_bits,
681        )
682    }
683
684    #[test]
685    fn heterogeneous_report_composes_query_and_gamma_terms() {
686        let source = HidingCodeGeometry::new(HidingCodeRole::Source, 2, 1, 16, 1, 4);
687        let mask =
688            HidingCodeGeometry::new(HidingCodeRole::SumcheckMask { round: 0 }, 1, 1, 16, 2, 3);
689        let report = generic_report(SecurityAssumption::UniqueDecoding, source, vec![mask], 5);
690
691        assert_eq!(report.field_order_log2_estimate, FIELD_LOG2_ESTIMATE);
692        assert_eq!(report.field_order_log2_floor, FIELD_LOG2_FLOOR);
693        assert_eq!(report.source.randomized_dimension, 3);
694        assert_eq!(report.source.dyadic_dimension, 4);
695        assert_eq!(report.source.analysis_log_inv_rate, Some(2));
696        assert_eq!(report.mask_groups[0].analysis_log_inv_rate, Some(3));
697        assert_eq!(
698            report.mask_groups[0].role,
699            HidingCodeRole::SumcheckMask { round: 0 }
700        );
701
702        let source_query = SecurityAssumption::UniqueDecoding.queries_error(2, 4);
703        let mask_query = SecurityAssumption::UniqueDecoding.queries_error(3, 3);
704        let weakest_query = source_query.min(mask_query);
705        assert_eq!(
706            report.query_round.source_before_pow_bits,
707            Some(source_query)
708        );
709        assert_eq!(
710            report.query_round.masks_before_pow_bits,
711            vec![Some(mask_query)]
712        );
713        assert_eq!(
714            report.query_round.weakest_before_pow_bits,
715            Some(weakest_query)
716        );
717        assert_eq!(report.query_round.before_pow.bits, Some(weakest_query));
718        assert_eq!(report.query_round.with_pow.bits, Some(weakest_query + 5.));
719        assert_eq!(
720            report.query_round.with_pow.classification,
721            HidingBoundClassification::Conditional
722        );
723
724        let source_mca = SecurityAssumption::UniqueDecoding.prox_gaps_error(2, 2, 128, 2);
725        let mask_mca = SecurityAssumption::UniqueDecoding.prox_gaps_error(1, 3, 128, 2) - 1.;
726        assert_eq!(
727            report.gamma_round.mca_sum.bits,
728            Some(sum_probability_bits([source_mca, mask_mca]))
729        );
730        assert_eq!(report.gamma_round.product_list.log2_size, Some(0.));
731        assert_eq!(
732            report.gamma_round.combined.classification,
733            HidingBoundClassification::Proven
734        );
735        assert_eq!(
736            report.round_by_round.classification,
737            HidingBoundClassification::Proven
738        );
739        assert_eq!(report.round_by_round.bits, Some(weakest_query));
740        assert_eq!(report.round_by_round.proven_bits(), Some(weakest_query));
741    }
742
743    #[test]
744    fn regime_classifications_propagate_without_overstatement() {
745        let source = HidingCodeGeometry::new(HidingCodeRole::Source, 2, 0, 16, 1, 1);
746
747        for (assumption, expected) in [
748            (
749                SecurityAssumption::UniqueDecoding,
750                HidingBoundClassification::Proven,
751            ),
752            (
753                SecurityAssumption::JohnsonBound,
754                HidingBoundClassification::Proven,
755            ),
756            (
757                SecurityAssumption::CapacityBound,
758                HidingBoundClassification::Conditional,
759            ),
760        ] {
761            let report = generic_report(assumption, source, vec![], 0);
762
763            assert_eq!(
764                report.query_round.before_pow.classification,
765                HidingBoundClassification::Proven
766            );
767            assert_eq!(report.gamma_round.combined.classification, expected);
768            assert_eq!(report.round_by_round.classification, expected);
769            assert_eq!(
770                report.round_by_round.proven_bits().is_some(),
771                expected == HidingBoundClassification::Proven
772            );
773        }
774    }
775
776    #[test]
777    fn aggregate_provenance_includes_a_nonbinding_coordinate() {
778        for classification in [
779            HidingBoundClassification::Conditional,
780            HidingBoundClassification::Approximation,
781        ] {
782            let gamma = HidingErrorBound::available(120., classification);
783            let query = HidingErrorBound::available(80., HidingBoundClassification::Proven);
784            let combined = combine_classifications(gamma.classification, query.classification);
785            let round_by_round =
786                HidingErrorBound::available(gamma.bits.unwrap().min(query.bits.unwrap()), combined);
787
788            assert_eq!(round_by_round.bits, Some(80.));
789            assert_eq!(round_by_round.classification, classification);
790            assert_eq!(round_by_round.proven_bits(), None);
791        }
792    }
793
794    #[test]
795    fn proven_bits_rejects_vacuous_proven_terms() {
796        let vacuous = HidingErrorBound::available(-1., HidingBoundClassification::Proven);
797        let positive = HidingErrorBound::available(1., HidingBoundClassification::Proven);
798
799        assert_eq!(vacuous.bits, Some(0.));
800        assert!(vacuous.vacuous);
801        assert_eq!(vacuous.proven_bits(), None);
802        assert_eq!(positive.proven_bits(), Some(1.));
803    }
804
805    #[test]
806    fn analysis_radius_comes_from_log_one_minus_delta() {
807        for log_inv_rate in 1..=16 {
808            for assumption in [
809                SecurityAssumption::UniqueDecoding,
810                SecurityAssumption::JohnsonBound,
811                SecurityAssumption::CapacityBound,
812            ] {
813                let report = generic_report(
814                    assumption,
815                    HidingCodeGeometry::new(HidingCodeRole::Source, 2, 0, 2 << log_inv_rate, 1, 1),
816                    vec![],
817                    0,
818                );
819                let radius = report.source.analysis_radius.unwrap();
820
821                assert!(
822                    (libm::log2(1. - radius) - assumption.log_1_delta(log_inv_rate)).abs() < 1e-10
823                );
824            }
825        }
826    }
827
828    #[test]
829    fn interleaved_list_uses_eprint_2026_391_lemma_3_13_bound() {
830        let assumption = SecurityAssumption::JohnsonBound;
831        let log_degree = 3;
832        let log_inv_rate = 2;
833        let interleaving = 4;
834        let one_minus_delta = libm::pow(2., assumption.log_1_delta(log_inv_rate));
835
836        let base = assumption.list_size_bits(log_degree, log_inv_rate);
837        let crude = interleaving as f64 * base;
838        let actual = interleaved_list_bits(
839            assumption,
840            log_degree,
841            log_inv_rate,
842            interleaving,
843            one_minus_delta,
844        );
845
846        assert!((actual - 13.228_818_690_495_881).abs() < 1e-12);
847        assert!((crude - 21.287_712_379_549_45).abs() < 1e-12);
848        assert!(actual < crude);
849    }
850
851    #[test]
852    fn saturated_queries_and_rate_one_envelopes_are_reported() {
853        let saturated = generic_report(
854            SecurityAssumption::UniqueDecoding,
855            HidingCodeGeometry::new(HidingCodeRole::Source, 2, 0, 4, 1, 10),
856            vec![],
857            0,
858        );
859        assert_eq!(saturated.source.requested_queries, 10);
860        assert_eq!(saturated.source.effective_queries, 4);
861        assert_eq!(
862            saturated.source.query_miss.bits,
863            Some(SecurityAssumption::UniqueDecoding.queries_error(1, 4))
864        );
865
866        let rate_one = generic_report(
867            SecurityAssumption::UniqueDecoding,
868            HidingCodeGeometry::new(HidingCodeRole::Source, 4, 4, 8, 1, 1),
869            vec![],
870            0,
871        );
872        assert_eq!(rate_one.source.randomized_rate, 1.);
873        assert_eq!(rate_one.source.analysis_log_inv_rate, None);
874        assert_eq!(rate_one.source.analysis_radius, None);
875        assert_eq!(rate_one.source.query_miss.bits, None);
876        assert_eq!(
877            rate_one.source.query_miss.classification,
878            HidingBoundClassification::Unavailable
879        );
880        assert_eq!(rate_one.source.mca.bits, None);
881        assert_eq!(rate_one.source.paired_list.log2_size, None);
882        assert_eq!(rate_one.round_by_round.bits, None);
883    }
884
885    #[test]
886    #[should_panic(expected = "the codeword domain size must be a power of two")]
887    fn geometry_rejects_non_dyadic_domains() {
888        let _ = HidingCodeGeometry::new(HidingCodeRole::Source, 1, 0, 3, 1, 1);
889    }
890
891    #[test]
892    #[should_panic(expected = "the randomized-code dimension must fit")]
893    fn geometry_rejects_dimensions_larger_than_the_domain() {
894        let _ = HidingCodeGeometry::new(HidingCodeRole::Source, 5, 4, 8, 1, 1);
895    }
896
897    #[test]
898    #[should_panic(expected = "the interleaving width must be nonzero")]
899    fn geometry_rejects_zero_width() {
900        let _ = HidingCodeGeometry::new(HidingCodeRole::Source, 1, 0, 8, 0, 1);
901    }
902
903    #[test]
904    fn probability_sum_is_stable_for_large_security_bits() {
905        let actual = sum_probability_bits([2_000., 2_001.]);
906        let expected = 2_000. - libm::log2(1.5);
907
908        assert!((actual - expected).abs() < 1e-12);
909    }
910}