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}