Skip to main content

p3_security/
report.rs

1//! Labeled soundness breakdown produced by the composite orchestration.
2//!
3//! [`SecurityReport`] is the audit-facing output of [`crate::stark::proven_security_report`].
4//!
5//! Every soundness contribution arrives as a named [`SecurityTerm`], one set per proximity regime.
6//!
7//! The binding term stays inspectable instead of collapsing into a single number.
8
9use alloc::vec::Vec;
10use core::cmp::Ordering;
11
12use serde::Serialize;
13
14use crate::error::ErrorBits;
15
16/// Label for the AIR-composition (ALI) term.
17pub const ALI_LABEL: &str = "air-composition";
18/// Label for the DEEP-ALI out-of-domain term.
19pub const DEEP_LABEL: &str = "deep-ali";
20/// Label for the low-degree test, reported as one already-composed bound.
21///
22/// # Asymmetry with the conjectured path
23///
24/// The proven path reports one label here.
25///
26/// The conjectured path reports [`LDT_QUERY_LABEL`] and [`LDT_COMMIT_LABEL`] separately.
27///
28/// A consumer diffing the two sees different label sets for the same protocol phases.
29///
30/// That is deliberate.
31///
32/// The proven error is already a minimum by the time the composite sees it.
33///
34/// The regime search, [`crate::fri::best_ldr_m`], picks the proximity parameter that maximises the weaker phase.
35///
36/// Splitting the label would force that search to carry both phases through.
37///
38/// It would then be optimising something else.
39///
40/// The conjectured path runs no such search, so nothing forces its phases together.
41pub const LDT_LABEL: &str = "low-degree-test";
42/// Label for the low-degree test's query phase, when the phases are reported apart.
43///
44/// See [`crate::ldt::LowDegreeTest::conjectured_terms`].
45pub const LDT_QUERY_LABEL: &str = "ldt-query-phase";
46/// Label for the low-degree test's commit phase, when the phases are reported apart.
47pub const LDT_COMMIT_LABEL: &str = "ldt-commit-phase";
48/// Label for the batched-openings random-linear-combination term.
49pub const BATCH_LABEL: &str = "batch-combination";
50/// Label for the commitment-collision cap term.
51pub const COLLISION_LABEL: &str = "commitment-collision";
52
53/// A single named soundness contribution, in `−log2(error)` bits.
54///
55/// The label names the error source, and the crate charging it picks the name.
56///
57/// A protocol composing two instances of one scheme sees that label twice.
58///
59/// Naming the instance is what the component is for.
60#[derive(Copy, Clone, Debug, PartialEq, Serialize)]
61pub struct SecurityTerm {
62    /// Error source this term charges.
63    pub label: &'static str,
64    /// Which of the composing protocol's parts charged it, when there is a choice.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub component: Option<&'static str>,
67    /// The bound itself, in `−log2(error)` bits.
68    pub bits: ErrorBits,
69}
70
71impl SecurityTerm {
72    /// A term charged by whatever crate owns the error source.
73    ///
74    /// The composing protocol is the only side that can name a component, so this leaves none.
75    pub const fn new(label: &'static str, bits: ErrorBits) -> Self {
76        Self {
77            label,
78            component: None,
79            bits,
80        }
81    }
82
83    /// The same term, attributed to one part of the composing protocol.
84    #[must_use]
85    pub const fn in_component(mut self, component: &'static str) -> Self {
86        self.component = Some(component);
87        self
88    }
89
90    /// The same term, charged over every candidate a commitment still leaves open.
91    ///
92    /// A draw made before one candidate is named gives a prover one try per candidate.
93    ///
94    /// Subtracting the log of the set size is the union bound over those tries.
95    ///
96    /// This is the workspace's only implementation of that charge.
97    ///
98    /// The set is passed by value and comes back unspent, so the layer above charges it too.
99    #[must_use]
100    pub fn over_candidates(self, candidates: CandidateSet) -> Self {
101        // An error above one is no bound at all, so the charge stops at zero bits.
102        //
103        // Zero bits reports "no bound" rather than hiding a shortfall under the floor.
104        //
105        // A union holding a zero-bit term composes to at most zero bits.
106        //
107        // Every caller grading a report against a positive target then fails closed.
108        Self {
109            bits: ErrorBits::from_log2((self.bits.bits() - candidates.log2_size()).max(0.0)),
110            ..self
111        }
112    }
113}
114
115/// How many polynomials a commitment still leaves open.
116///
117/// The rule: a layer charges its own draws over the whole set, then passes the set on.
118///
119/// A charge never shrinks the set, which the commitment fixed before anything above drew.
120///
121/// Unique decoding leaves one candidate, which costs a later draw nothing.
122///
123/// A list-decoding argument leaves a list, and each earlier draw gets one try per member.
124///
125/// # What this type does and does not catch
126///
127/// It catches a count that is not a set size.
128///
129/// That is the one arithmetic error here that overstates a level.
130///
131/// A negative count would add bits to the term it is charged over.
132///
133/// Being copied rather than spent is what forwarding looks like in the signature.
134///
135/// It does not catch a draw nobody charged.
136///
137/// Nor a scheme reporting an open list as unique.
138///
139/// Both of those overstate a level, and no type can see either one.
140///
141/// A component supplying no evidence leaves the report with no number to give.
142///
143/// Past that, a missing term is caught by the tests that name each one.
144///
145/// An understated list is a claim about the proximity argument, checked where it lives.
146///
147/// # Example
148///
149/// Two stacked layers, over a commitment leaving sixteen candidates open.
150///
151/// ```
152/// use p3_security::{CandidateSet, ErrorBits, SecurityTerm};
153///
154/// let candidates = CandidateSet::from_log2(4.0).unwrap();
155///
156/// // The inner layer charges its own reduction and forwards the set untouched.
157/// let inner = SecurityTerm::new("inner", ErrorBits::from_log2(100.0)).over_candidates(candidates);
158/// assert_eq!(inner.bits.bits(), 96.0);
159///
160/// // The outer layer charges its own draw over the same set, not over what is left of it.
161/// let outer = SecurityTerm::new("outer", ErrorBits::from_log2(100.0)).over_candidates(candidates);
162/// assert_eq!(outer.bits.bits(), 96.0);
163/// ```
164#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Serialize)]
165pub struct CandidateSet {
166    /// Base-two logarithm of the set size.
167    ///
168    /// Finite and non-negative by construction.
169    log2_size: f64,
170}
171
172impl CandidateSet {
173    /// The commitment names one polynomial, so a later draw pays nothing.
174    pub const UNIQUE: Self = Self { log2_size: 0.0 };
175
176    /// A set of that many candidates, as a base-two logarithm.
177    ///
178    /// # Returns
179    ///
180    /// Nothing when the argument is not a set size.
181    ///
182    /// A set has at least one member and finitely many.
183    ///
184    /// So a negative, infinite, or undefined argument names no set at all.
185    #[must_use]
186    pub fn from_log2(log2_size: f64) -> Option<Self> {
187        (log2_size.is_finite() && log2_size >= 0.0).then_some(Self { log2_size })
188    }
189
190    /// Base-two logarithm of how many candidates are left open.
191    #[must_use]
192    pub const fn log2_size(self) -> f64 {
193        self.log2_size
194    }
195
196    /// Whether the commitment already names one polynomial.
197    #[must_use]
198    pub fn is_unique(self) -> bool {
199        self.log2_size == 0.0
200    }
201
202    /// The set a draw faces when two independent commitments are both still open.
203    ///
204    /// A prover chooses one member of each, so the two choices multiply.
205    #[must_use]
206    pub fn product(self, other: Self) -> Self {
207        Self {
208            log2_size: self.log2_size + other.log2_size,
209        }
210    }
211}
212
213#[cfg(test)]
214mod candidate_tests {
215    use super::*;
216
217    /// Sixteen candidates, the running example of the layering rule.
218    fn sixteen() -> CandidateSet {
219        CandidateSet::from_log2(4.0).expect("sixteen is a set size")
220    }
221
222    #[test]
223    fn a_draw_before_a_candidate_is_named_pays_for_every_one_left_open() {
224        // Sixteen candidates cost a draw four bits, and the label is carried through.
225        let charged =
226            SecurityTerm::new("r", ErrorBits::from_log2(100.0)).over_candidates(sixteen());
227        assert_eq!(charged.bits.bits(), 96.0);
228        assert_eq!(charged.label, "r");
229
230        // A draw weaker than the candidate count is worth nothing, rather than negative.
231        let drowned =
232            SecurityTerm::new("weak", ErrorBits::from_log2(3.0)).over_candidates(sixteen());
233        assert_eq!(drowned.bits.bits(), 0.0);
234    }
235
236    #[test]
237    fn one_candidate_leaves_a_draw_at_its_own_strength() {
238        // No choice is no advantage, so nothing is subtracted.
239        let term = SecurityTerm::new("r", ErrorBits::from_log2(100.0));
240        assert_eq!(
241            term.over_candidates(CandidateSet::UNIQUE).bits.bits(),
242            100.0
243        );
244
245        // Unique decoding is exactly the set a commitment that names one polynomial leaves.
246        assert!(CandidateSet::UNIQUE.is_unique());
247        assert_eq!(CandidateSet::from_log2(0.0), Some(CandidateSet::UNIQUE));
248    }
249
250    #[test]
251    fn the_floor_reports_no_bound_rather_than_hiding_a_negative_margin() {
252        // A draw three bits short of the candidate count has no bound left at all.
253        //
254        // ```text
255        //     2^-3 error, 16 tries  ->  the prover expects to succeed
256        // ```
257        //
258        // Zero bits says exactly that, and a union holding one composes to zero bits.
259        //
260        // So nothing downstream can read the shortfall as a passing margin.
261        let drowned =
262            SecurityTerm::new("weak", ErrorBits::from_log2(1.0)).over_candidates(sixteen());
263        assert_eq!(drowned.bits.bits(), 0.0);
264
265        let composed = ErrorBits::sum(&[drowned.bits, ErrorBits::from_log2(128.0)]);
266        assert!(composed.bits() <= 0.0);
267    }
268
269    #[test]
270    fn a_count_that_names_no_set_is_refused_before_it_can_be_charged() {
271        // A negative count would add bits, which is the direction that overstates.
272        //
273        // An infinite or undefined one prices nothing, and neither names a set.
274        for count in [-1.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
275            assert_eq!(CandidateSet::from_log2(count), None);
276        }
277    }
278
279    #[test]
280    fn a_layer_charges_its_own_draw_and_forwards_the_set_untouched() {
281        // Fixture state: one commitment leaving sixteen candidates, two layers above it.
282        //
283        // ```text
284        //     commitment        its own error, already final
285        //     inner reduction   drawn before a candidate is named  ->  pays 4 bits
286        //     outer reduction   also drawn before one is named     ->  pays 4 bits
287        // ```
288        //
289        // The outer layer charges the set the commitment fixed, not a smaller one.
290        //
291        // The inner union bound took nothing away from the prover.
292        let candidates = sixteen();
293
294        let commitment = SecurityTerm::new("commitment", ErrorBits::from_log2(90.0));
295        let inner =
296            SecurityTerm::new("inner", ErrorBits::from_log2(100.0)).over_candidates(candidates);
297        let outer =
298            SecurityTerm::new("outer", ErrorBits::from_log2(100.0)).over_candidates(candidates);
299
300        assert_eq!(inner.bits.bits(), 96.0);
301        assert_eq!(outer.bits.bits(), 96.0);
302
303        // The commitment's own term is not a draw made before it, so it pays nothing.
304        assert_eq!(commitment.bits.bits(), 90.0);
305
306        // Charging the inner draw again would take the log of the same list off twice.
307        //
308        // That reports 92 bits for a draw worth 96, which understates the level.
309        //
310        // It is the harmless direction, so nothing in the type stands in its way.
311        //
312        // Each layer holding its own draws apart, and paying once, is what avoids it.
313        assert_eq!(inner.over_candidates(candidates).bits.bits(), 92.0);
314    }
315
316    #[test]
317    fn two_open_commitments_multiply_the_tries_a_single_draw_gets() {
318        // A joint choice of one member from each list is a choice from the product.
319        let main = CandidateSet::from_log2(4.0).unwrap();
320        let preprocessed = CandidateSet::from_log2(3.0).unwrap();
321        assert_eq!(main.product(preprocessed).log2_size(), 7.0);
322
323        // Charging over the product is charging once, for both, as one union bound.
324        let charged = SecurityTerm::new("r", ErrorBits::from_log2(100.0))
325            .over_candidates(main.product(preprocessed));
326        assert_eq!(charged.bits.bits(), 93.0);
327
328        // Unique decoding on one side leaves the other side's set as it was.
329        assert_eq!(main.product(CandidateSet::UNIQUE), main);
330    }
331}
332
333/// The proximity regime a [`RegimeReport`] was evaluated in.
334#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
335#[non_exhaustive]
336pub enum Regime {
337    /// Unique-decoding regime (list size 1).
338    UniqueDecoding,
339    /// List-decoding regime at proximity parameter `m`.
340    ListDecoding { m: usize },
341    /// Conjectured random-words regime, at list size one.
342    ///
343    /// Correlated agreement is assumed up to list-decoding capacity.
344    ///
345    /// See [`crate::proximity::list_size_conjectured`].
346    Conjectured,
347    /// Legacy conjectured regime, on the pre-random-words ethSTARK query bound.
348    ///
349    /// For FRI this omits the folding round.
350    ///
351    /// See [`crate::fri::legacy_conjectured_error`] and [`crate::stark::legacy_security_report`].
352    Legacy,
353}
354
355/// Full soundness breakdown within a single proximity regime.
356///
357/// The terms hold every contribution, plus the commitment-collision cap.
358///
359/// The attained security is the minimum over all of them.
360///
361/// A collision, or any single binding error, forges the proof.
362///
363/// It is also the top-level output of [`crate::stark::conjectured_security_report`].
364///
365/// The same holds for [`crate::stark::legacy_security_report`].
366///
367/// Each has one regime, so neither needs a [`SecurityReport`] envelope to maximize over.
368#[derive(Clone, Debug, Serialize)]
369pub struct RegimeReport {
370    pub regime: Regime,
371    terms: Vec<SecurityTerm>,
372}
373
374impl RegimeReport {
375    /// Builds a report from its labeled terms.
376    ///
377    /// The terms must be non-empty.
378    ///
379    /// Every regime carries at least [`ALI_LABEL`], [`DEEP_LABEL`], a low-degree-test term and [`COLLISION_LABEL`].
380    pub(crate) fn new(regime: Regime, terms: Vec<SecurityTerm>) -> Self {
381        debug_assert!(
382            !terms.is_empty(),
383            "a regime report must carry at least one term"
384        );
385        Self { regime, terms }
386    }
387
388    /// Every soundness contribution in this regime, cap included.
389    pub fn terms(&self) -> &[SecurityTerm] {
390        &self.terms
391    }
392
393    /// The binding term, which is the one with the fewest bits.
394    ///
395    /// A regime always carries at least four terms, so there is always one.
396    pub fn binding(&self) -> SecurityTerm {
397        self.terms
398            .iter()
399            .copied()
400            .min_by(|a, b| {
401                a.bits
402                    .bits()
403                    .partial_cmp(&b.bits.bits())
404                    .unwrap_or(Ordering::Equal)
405            })
406            .expect("a regime report always carries the ALI/DEEP/LDT/collision terms")
407    }
408
409    /// Attained security in this regime, in bits.
410    pub fn security_bits(&self) -> f64 {
411        self.binding().bits.bits()
412    }
413}
414
415/// Proven-soundness report across both proximity regimes.
416///
417/// Each regime is an independent lower bound on round-by-round soundness.
418///
419/// So the attained security is the larger of the two.
420#[derive(Clone, Debug, Serialize)]
421pub struct SecurityReport {
422    pub udr: RegimeReport,
423    /// Absent when no valid list-decoding regime exists for the instance.
424    pub ldr: Option<RegimeReport>,
425}
426
427impl SecurityReport {
428    /// Attained proven security in bits: the better of the two regimes.
429    pub fn security_bits(&self) -> f64 {
430        let ldr = self.ldr.as_ref().map_or(0.0, RegimeReport::security_bits);
431        self.udr.security_bits().max(ldr)
432    }
433
434    /// The winning regime and its binding term.
435    pub fn binding(&self) -> (Regime, SecurityTerm) {
436        match &self.ldr {
437            Some(ldr) if ldr.security_bits() > self.udr.security_bits() => {
438                (ldr.regime, ldr.binding())
439            }
440            _ => (self.udr.regime, self.udr.binding()),
441        }
442    }
443}