Skip to main content

p3_security/
grinding.rs

1//! Grinding (proof-of-work) bits — additive contribution to security.
2//!
3//! A grind sited immediately before a Fiat–Shamir challenge forces a
4//! malicious prover to redo `2^pow_bits` work per resampling attempt, so it
5//! adds `pow_bits` to the round-by-round error of the round that challenge
6//! opens (ethSTARK [2021/582](https://eprint.iacr.org/2021/582) §5, and
7//! [2024/1553](https://eprint.iacr.org/2024/1553) §2 for the round-by-round
8//! accounting the composite uses).
9//!
10//! Which round a grind boosts therefore depends on **where** the protocol
11//! grinds. [`GrindingSites`] enumerates those sites so a protocol states them
12//! as data instead of the crate hardcoding one placement per protocol.
13//!
14//! # Vocabulary
15//!
16//! The same proof-of-work phase carries three names in three places.
17//!
18//! | transcript step | this crate's field    | report term         |
19//! | --------------- | --------------------- | ------------------- |
20//! | `ood_pow`       | `out_of_domain`       | `deep-ali`          |
21//! | `batch_pow`     | `batch_combination`   | `batch-combination` |
22//! | `lookup_pow`    | `lookup_challenge`    | `logup-fingerprint` |
23//! | `commit_pow`    | FRI `commit_pow_bits` | `ldt-commit-phase`  |
24//! | `query_pow`     | FRI `query_pow_bits`  | `ldt-query-phase`   |
25//!
26//! A site is the one identity behind all three names.
27//!
28//! The same table is carried as data, keyed on the protocol name and the step label.
29//!
30//! # Two places, one number
31//!
32//! Grinding bits are read twice for two different purposes.
33//!
34//! ```text
35//!     transcript  ->  how much work the prover actually pays
36//!     this crate  ->  how many bits the parameter set is reported to buy
37//! ```
38//!
39//! A parameter set where the two disagree is a misreported security level.
40//!
41//! The check in this module compares them.
42
43use core::fmt::{Display, Formatter, Result as FmtResult};
44
45use serde::Serialize;
46use thiserror::Error;
47
48use crate::error::ErrorBits;
49use crate::fri::FriRegime;
50use crate::logup::LOGUP_LABEL;
51use crate::report::{BATCH_LABEL, DEEP_LABEL, LDT_COMMIT_LABEL, LDT_QUERY_LABEL};
52
53/// Bits added to the soundness budget by a `pow_bits`-bit grinding round.
54/// Equal to `pow_bits` when grinding is honest; provided as a function
55/// so future tweaks (multi-round PoW, variable difficulty) stay local.
56pub const fn grinding_bits(pow_bits: usize) -> f64 {
57    pow_bits as f64
58}
59
60/// `error` boosted by a `pow_bits`-bit grind placed before the challenge that
61/// round samples. A zero-bit grind is the identity.
62pub const fn boost(error: ErrorBits, pow_bits: usize) -> ErrorBits {
63    ErrorBits::from_log2(error.bits() + grinding_bits(pow_bits))
64}
65
66/// Where a STARK grinds, in bits per site.
67///
68/// Every field defaults to `0` — a protocol declares only the sites it
69/// actually uses, and a default-constructed value is neutral.
70///
71/// Each site is consumed by the term whose round it opens:
72///
73/// - [`Self::out_of_domain`] is applied to the DEEP-ALI term by
74///   [`crate::stark::proven_security_report`],
75///   [`crate::stark::conjectured_security_report`], and
76///   [`crate::stark::legacy_security_report`].
77/// - [`Self::batch_combination`] is applied to the batched-openings term by
78///   [`crate::stark::proven_security_report`],
79///   [`crate::stark::conjectured_security_report`], and
80///   [`crate::stark::legacy_security_report`].
81/// - [`Self::lookup_challenge`] is applied to the LogUp fingerprint term by
82///   [`crate::logup::security_term`].
83///
84/// The low-degree test's own grinding sites (e.g. FRI's query- and
85/// commit-phase proof-of-work) are **not** modeled here: a
86/// [`crate::ldt::LowDegreeTest`] implementation carries those itself
87/// (`FriRegime::query_pow_bits` / `FriRegime::commit_pow_bits`) and folds
88/// them into the terms it returns, so the composite never re-applies them —
89/// a site in this struct for them would be read back out unchanged, never
90/// consulted.
91///
92/// A protocol grinding at a site this crate does not model builds its own
93/// [`crate::report::SecurityTerm`], applies [`boost`] to it, and passes it
94/// through `extras` — no change to this struct is needed.
95#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)]
96pub struct GrindingSites {
97    /// Bits ground before the DEEP out-of-domain point is sampled.
98    pub out_of_domain: usize,
99    /// Bits ground before the challenge that random-linear-combines the
100    /// committed codewords into the single low-degree-test instance.
101    ///
102    /// This is the opening-batching challenge of the polynomial commitment
103    /// scheme (`alpha` in `p3_fri::TwoAdicFriPcs::open`), **not** a FRI
104    /// folding challenge — those are `FriRegime::commit_pow_bits`, carried by
105    /// the low-degree test itself. It is credited only to the term
106    /// [`crate::report::BATCH_LABEL`] names, which exists only when more than
107    /// one codeword is batched (`InstanceShape::num_batched_functions >= 2`);
108    /// with nothing to batch there is no such round and these bits buy
109    /// nothing.
110    ///
111    /// The proven, conjectured, and legacy composites all model this round.
112    /// They grade it in different proximity regimes, but the prover pays the
113    /// same work regardless.
114    pub batch_combination: usize,
115    /// Bits ground before the lookup / permutation argument's challenges are
116    /// sampled.
117    pub lookup_challenge: usize,
118}
119
120impl GrindingSites {
121    /// No grinding at any site — the neutral element, usable in `const`
122    /// contexts where [`Default::default`] is not available.
123    pub const NONE: Self = Self {
124        out_of_domain: 0,
125        batch_combination: 0,
126        lookup_challenge: 0,
127    };
128}
129
130/// One proof-of-work phase of a FRI-backed STARK, named once and for all.
131///
132/// The transcript, this crate, and the security report each have their own word for one phase.
133///
134/// A site is the identity the phase keeps across all three.
135///
136/// That is what lets the mapping between the vocabularies be written down rather than remembered.
137#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
138pub enum GrindingSite {
139    /// Grinding before the DEEP out-of-domain point is sampled.
140    OutOfDomain,
141    /// Grinding before the polynomial commitment scheme's opening-batching challenge.
142    BatchCombination,
143    /// Grinding before the LogUp argument's `(alpha, beta)` pair is sampled.
144    LookupChallenge,
145    /// Grinding before each of the low-degree test's folding challenges.
146    LdtCommitPhase,
147    /// Grinding before the low-degree test's query indices are sampled.
148    LdtQueryPhase,
149}
150
151impl GrindingSite {
152    /// Every site, in the order a grinding budget indexes them.
153    pub const ALL: [Self; 5] = [
154        Self::OutOfDomain,
155        Self::BatchCombination,
156        Self::LookupChallenge,
157        Self::LdtCommitPhase,
158        Self::LdtQueryPhase,
159    ];
160
161    /// Label of the security-report term this site's bits are added to.
162    ///
163    /// A site boosts exactly one term.
164    ///
165    /// That is what makes the boost an addition rather than a re-derivation.
166    #[must_use]
167    pub const fn report_label(self) -> &'static str {
168        match self {
169            Self::OutOfDomain => DEEP_LABEL,
170            Self::BatchCombination => BATCH_LABEL,
171            Self::LookupChallenge => LOGUP_LABEL,
172            Self::LdtCommitPhase => LDT_COMMIT_LABEL,
173            Self::LdtQueryPhase => LDT_QUERY_LABEL,
174        }
175    }
176
177    /// Position of this site in the canonical site order.
178    const fn index(self) -> usize {
179        match self {
180            Self::OutOfDomain => 0,
181            Self::BatchCombination => 1,
182            Self::LookupChallenge => 2,
183            Self::LdtCommitPhase => 3,
184            Self::LdtQueryPhase => 4,
185        }
186    }
187}
188
189impl Display for GrindingSite {
190    /// Render the site by its own name, not by any of its three aliases.
191    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
192        match self {
193            Self::OutOfDomain => write!(f, "out-of-domain"),
194            Self::BatchCombination => write!(f, "batch-combination"),
195            Self::LookupChallenge => write!(f, "lookup-challenge"),
196            Self::LdtCommitPhase => write!(f, "ldt-commit-phase"),
197            Self::LdtQueryPhase => write!(f, "ldt-query-phase"),
198        }
199    }
200}
201
202/// Whether a protocol describes a grinding step whose difficulty is zero.
203///
204/// A zero-bit grind is free to produce and vacuous to check.
205///
206/// Whether it is described at all is therefore a per-protocol choice.
207///
208/// The choice is not uniform across this workspace, so an absent step cannot be read as zero bits everywhere.
209#[derive(Copy, Clone, Debug, PartialEq, Eq)]
210pub enum ZeroBitConvention {
211    /// The step is described only when the difficulty is positive.
212    ///
213    /// ```text
214    ///     bits == 0  ->  no step at all
215    ///     bits >  0  ->  one step carrying `bits`
216    /// ```
217    Elided,
218    /// The step is described whatever the difficulty, zero included.
219    ///
220    /// Used where the witness travels in the proof unconditionally.
221    ///
222    /// The proof layout then does not depend on the difficulty.
223    Always,
224    /// The step is described, carrying its exact difficulty, only while its challenge's phase runs at all.
225    ///
226    /// An absent step then means the phase did not run, so nothing was ground.
227    ///
228    /// Nothing is credited either: the term the site would boost is itself absent from the report.
229    ///
230    /// The lookup argument is the case in point: with no interactions it contributes no term.
231    WhenPhaseRuns,
232}
233
234/// One row of the grinding vocabulary.
235///
236/// A row is a protocol's step label, the site that label names, and how the protocol records it at zero bits.
237///
238/// Rows are keyed on `(protocol, label)` rather than on the label alone.
239///
240/// ```text
241///     "query_pow"  ->  p3-fri, p3-circle, p3-whir, p3-stir all use it
242///     "ood_pow"    ->  p3-uni-stark and p3-batch-stark, under two conventions
243/// ```
244///
245/// Labels are namespaced by the protocol name in the transcript seed, so this is not a soundness problem.
246#[derive(Copy, Clone, Debug, PartialEq, Eq)]
247pub struct GrindingStep {
248    /// Protocol name bound into the transcript seed, such as `"p3-fri"`.
249    pub protocol: &'static str,
250    /// Label the protocol's proof-of-work step carries.
251    pub label: &'static str,
252    /// Phase this step grinds for.
253    pub site: GrindingSite,
254    /// How this protocol records the step at zero difficulty.
255    pub zero_bits: ZeroBitConvention,
256}
257
258/// Every proof-of-work step of every protocol whose security this crate models.
259///
260/// This is the mapping table between the three vocabularies, as data.
261///
262/// | protocol         | label        | site              | zero bits           |
263/// | ---------------- | ------------ | ----------------- | ------------------- |
264/// | `p3-uni-stark`   | `ood_pow`    | out-of-domain     | elided              |
265/// | `p3-batch-stark` | `ood_pow`    | out-of-domain     | always              |
266/// | `p3-batch-stark` | `lookup_pow` | lookup-challenge  | when the phase runs |
267/// | `p3-fri-pcs`     | `batch_pow`  | batch-combination | elided              |
268/// | `p3-fri`         | `commit_pow` | ldt-commit-phase  | elided              |
269/// | `p3-fri`         | `query_pow`  | ldt-query-phase   | elided              |
270/// | `p3-circle-pcs`  | `batch_pow`  | batch-combination | elided              |
271/// | `p3-circle-pcs`  | `commit_pow` | ldt-commit-phase  | elided              |
272/// | `p3-circle-pcs`  | `query_pow`  | ldt-query-phase   | elided              |
273/// | `p3-stir-pcs-batch` | `batch_pow` | batch-combination | elided           |
274///
275/// Sites outside this vocabulary are named by the unpriced table below.
276pub const GRINDING_VOCABULARY: [GrindingStep; 10] = [
277    GrindingStep {
278        protocol: "p3-circle-pcs",
279        label: "batch_pow",
280        site: GrindingSite::BatchCombination,
281        zero_bits: ZeroBitConvention::Elided,
282    },
283    GrindingStep {
284        protocol: "p3-circle-pcs",
285        label: "commit_pow",
286        site: GrindingSite::LdtCommitPhase,
287        zero_bits: ZeroBitConvention::Elided,
288    },
289    GrindingStep {
290        protocol: "p3-circle-pcs",
291        label: "query_pow",
292        site: GrindingSite::LdtQueryPhase,
293        zero_bits: ZeroBitConvention::Elided,
294    },
295    GrindingStep {
296        protocol: "p3-stir-pcs-batch",
297        label: "batch_pow",
298        site: GrindingSite::BatchCombination,
299        zero_bits: ZeroBitConvention::Elided,
300    },
301    GrindingStep {
302        protocol: "p3-uni-stark",
303        label: "ood_pow",
304        site: GrindingSite::OutOfDomain,
305        zero_bits: ZeroBitConvention::Elided,
306    },
307    GrindingStep {
308        protocol: "p3-batch-stark",
309        label: "ood_pow",
310        site: GrindingSite::OutOfDomain,
311        zero_bits: ZeroBitConvention::Always,
312    },
313    GrindingStep {
314        protocol: "p3-batch-stark",
315        label: "lookup_pow",
316        site: GrindingSite::LookupChallenge,
317        zero_bits: ZeroBitConvention::WhenPhaseRuns,
318    },
319    GrindingStep {
320        protocol: "p3-fri-pcs",
321        label: "batch_pow",
322        site: GrindingSite::BatchCombination,
323        zero_bits: ZeroBitConvention::Elided,
324    },
325    GrindingStep {
326        protocol: "p3-fri",
327        label: "commit_pow",
328        site: GrindingSite::LdtCommitPhase,
329        zero_bits: ZeroBitConvention::Elided,
330    },
331    GrindingStep {
332        protocol: "p3-fri",
333        label: "query_pow",
334        site: GrindingSite::LdtQueryPhase,
335        zero_bits: ZeroBitConvention::Elided,
336    },
337];
338
339/// Every grinding step this crate does not price, named once.
340///
341/// # Overview
342///
343/// A protocol here credits the step inside its own security report.
344///
345/// The shared budget skips it, so the comparison never sees it.
346///
347/// # Soundness
348///
349/// A step in neither table is a difficulty nobody compares.
350///
351/// That is how a budget and a transcript drift apart unseen.
352///
353/// The transcript suites walk every described step and assert one table names it.
354///
355/// A step listed here is therefore a decision, not an omission.
356pub const UNPRICED_GRINDING_SITES: [(&str, &str); 12] = [
357    ("p3-binary-pcs", "query_pow"),
358    ("p3-whir", "query_pow"),
359    ("p3-whir", "final_query_pow"),
360    ("p3-whir-hvzk", "query_pow"),
361    ("p3-whir-hvzk-base", "base_pow"),
362    ("p3-stir", "folding_pow"),
363    ("p3-stir", "query_pow"),
364    ("p3-stir", "final_folding_pow"),
365    ("p3-stir", "final_pow"),
366    ("p3-sumcheck-quadratic", "round_pow"),
367    ("p3-sumcheck-hvzk", "round_pow"),
368    ("p3-sumcheck-generic-degree", "round_pow"),
369];
370
371/// Whether a protocol's step label is priced by its own report rather than the budget.
372#[must_use]
373pub fn is_unpriced_grinding_site(protocol: &str, label: &str) -> bool {
374    UNPRICED_GRINDING_SITES
375        .iter()
376        .any(|&(p, l)| p == protocol && l == label)
377}
378
379/// The vocabulary row for one protocol's step label, or `None` when the pair
380/// names no site this crate models.
381#[must_use]
382pub fn grinding_step(protocol: &str, label: &str) -> Option<&'static GrindingStep> {
383    GRINDING_VOCABULARY
384        .iter()
385        .find(|step| step.protocol == protocol && step.label == label)
386}
387
388/// One proof-of-work step read back out of an interaction pattern.
389///
390/// `protocol` is the name that protocol binds into its transcript seed.
391///
392/// That is what makes `label` unambiguous, since four crates label a step `"query_pow"`.
393#[derive(Copy, Clone, Debug, PartialEq, Eq)]
394pub struct RecordedGrind {
395    /// Protocol name bound into the transcript seed the step was read from.
396    pub protocol: &'static str,
397    /// Label the step carries.
398    pub label: &'static str,
399    /// Difficulty in bits the step demands.
400    pub bits: usize,
401}
402
403impl RecordedGrind {
404    /// One recorded step.
405    #[must_use]
406    pub const fn new(protocol: &'static str, label: &'static str, bits: usize) -> Self {
407        Self {
408            protocol,
409            label,
410            bits,
411        }
412    }
413}
414
415/// The difficulty the security model credits at every site of one parameter set.
416///
417/// A FRI-backed protocol splits its grinding across two carriers, and this joins them.
418///
419/// ```text
420///     the STARK's own sites  ->  out-of-domain, batch-combination, lookup-challenge
421///     the FRI regime         ->  ldt-commit-phase, ldt-query-phase
422/// ```
423///
424/// The composite boosts the first three.
425///
426/// The low-degree test folds the last two into its own terms.
427#[derive(Copy, Clone, Debug, PartialEq, Eq)]
428pub struct GrindingBudget {
429    /// Bits per site, in the canonical site order.
430    bits: [usize; GrindingSite::ALL.len()],
431}
432
433impl GrindingBudget {
434    /// No grinding credited at any site.
435    pub const NONE: Self = Self {
436        bits: [0; GrindingSite::ALL.len()],
437    };
438
439    /// The three sites a STARK declares, with the low-degree test's own two at zero.
440    ///
441    /// Fill those two in afterwards from the low-degree test's regime.
442    #[must_use]
443    pub const fn from_sites(sites: &GrindingSites) -> Self {
444        let mut bits = [0; GrindingSite::ALL.len()];
445        bits[GrindingSite::OutOfDomain.index()] = sites.out_of_domain;
446        bits[GrindingSite::BatchCombination.index()] = sites.batch_combination;
447        bits[GrindingSite::LookupChallenge.index()] = sites.lookup_challenge;
448        Self { bits }
449    }
450
451    /// Fill in the two sites a FRI low-degree test owns.
452    ///
453    /// The commit-phase grind repeats once per folding round, at one difficulty.
454    ///
455    /// The commit-phase error term credits that difficulty once, which is the conservative reading.
456    #[must_use]
457    pub const fn with_fri(mut self, ldt: &FriRegime) -> Self {
458        self.bits[GrindingSite::LdtCommitPhase.index()] = ldt.commit_pow_bits;
459        self.bits[GrindingSite::LdtQueryPhase.index()] = ldt.query_pow_bits;
460        self
461    }
462
463    /// Bits credited at one site.
464    #[must_use]
465    pub const fn bits(&self, site: GrindingSite) -> usize {
466        self.bits[site.index()]
467    }
468
469    /// Check the difficulties a transcript records against the ones this budget credits.
470    ///
471    /// `protocols` names the transcripts `recorded` was read from.
472    ///
473    /// Without it, a step the model credits and no transcript describes would be
474    /// indistinguishable from a protocol that is simply not in the stack.
475    ///
476    /// A grind repeated once per round appears once per round in `recorded`.
477    ///
478    /// Every occurrence has to carry the credited difficulty.
479    ///
480    /// # Errors
481    ///
482    /// Returns the first disagreement found, naming the site and both numbers.
483    pub fn check(
484        &self,
485        protocols: &[&str],
486        recorded: &[RecordedGrind],
487    ) -> Result<(), GrindingMismatch> {
488        // Every recorded step must belong to a listed protocol and name a known site.
489        //
490        // Either failure means the caller and this table disagree about what
491        // the stack is, which would silently exempt a site from the comparison.
492        for grind in recorded {
493            if !protocols.contains(&grind.protocol) {
494                return Err(GrindingMismatch::UnlistedProtocol {
495                    protocol: grind.protocol,
496                    label: grind.label,
497                    recorded: grind.bits,
498                });
499            }
500            if grinding_step(grind.protocol, grind.label).is_none() {
501                return Err(GrindingMismatch::UnknownStep {
502                    protocol: grind.protocol,
503                    label: grind.label,
504                    recorded: grind.bits,
505                });
506            }
507        }
508
509        // Every step a listed protocol can describe must agree with the model.
510        for step in GRINDING_VOCABULARY
511            .iter()
512            .filter(|step| protocols.contains(&step.protocol))
513        {
514            let credited = self.bits(step.site);
515
516            // Presence is per site.
517            // The difficulty is checked on each occurrence.
518            let mut described = false;
519            for grind in recorded
520                .iter()
521                .filter(|grind| grind.protocol == step.protocol && grind.label == step.label)
522            {
523                described = true;
524                if grind.bits != credited {
525                    return Err(GrindingMismatch::Difficulty {
526                        protocol: step.protocol,
527                        label: step.label,
528                        site: step.site,
529                        credited,
530                        recorded: grind.bits,
531                    });
532                }
533            }
534
535            // What presence has to look like is the protocol's zero-bit convention.
536            match (step.zero_bits, described) {
537                // Conventions that describe the step whatever the difficulty.
538                (ZeroBitConvention::Always | ZeroBitConvention::WhenPhaseRuns, true) => {}
539                // An elided convention describes the step exactly when bits are credited.
540                (ZeroBitConvention::Elided, true) if credited > 0 => {}
541                (ZeroBitConvention::Elided, true) => {
542                    return Err(GrindingMismatch::Unexpected {
543                        protocol: step.protocol,
544                        label: step.label,
545                        site: step.site,
546                    });
547                }
548                // A phase that did not run pays nothing and is credited nothing.
549                (ZeroBitConvention::WhenPhaseRuns, false) => {}
550                // Nothing credited, nothing described.
551                (ZeroBitConvention::Elided, false) if credited == 0 => {}
552                // Bits credited that the transcript never demands.
553                (ZeroBitConvention::Elided | ZeroBitConvention::Always, false) => {
554                    return Err(GrindingMismatch::Missing {
555                        protocol: step.protocol,
556                        label: step.label,
557                        site: step.site,
558                        credited,
559                    });
560                }
561            }
562        }
563
564        Ok(())
565    }
566}
567
568/// A way the recorded difficulties and the credited ones can disagree.
569///
570/// Every variant names the site, and both numbers wherever there are two.
571#[derive(Copy, Clone, Debug, Error, PartialEq, Eq)]
572pub enum GrindingMismatch {
573    /// A step is described at a difficulty the model does not credit.
574    #[error(
575        "{site} grinding disagrees: the security model credits {credited} bits, `{protocol}`'s `{label}` step demands {recorded}"
576    )]
577    Difficulty {
578        /// Protocol whose transcript describes the step.
579        protocol: &'static str,
580        /// Label the step carries.
581        label: &'static str,
582        /// Site both halves are talking about.
583        site: GrindingSite,
584        /// Bits the security model credits.
585        credited: usize,
586        /// Bits the transcript demands.
587        recorded: usize,
588    },
589    /// The model credits bits at a site the transcript never grinds for.
590    ///
591    /// This is the overstating direction.
592    ///
593    /// The reported level then includes work no prover pays and no verifier checks.
594    #[error(
595        "{site} grinding disagrees: the security model credits {credited} bits, `{protocol}` describes no `{label}` step, so 0 are demanded"
596    )]
597    Missing {
598        /// Protocol whose transcript omits the step.
599        protocol: &'static str,
600        /// Label the omitted step would carry.
601        label: &'static str,
602        /// Site the model credits.
603        site: GrindingSite,
604        /// Bits the security model credits.
605        credited: usize,
606    },
607    /// A zero-bit step is described where the protocol's convention elides it.
608    #[error(
609        "{site} grinding disagrees: the security model credits 0 bits, and `{protocol}` describes a zero-bit `{label}` step it should elide"
610    )]
611    Unexpected {
612        /// Protocol whose transcript describes the step.
613        protocol: &'static str,
614        /// Label the step carries.
615        label: &'static str,
616        /// Site the step names.
617        site: GrindingSite,
618    },
619    /// A described step whose `(protocol, label)` pair names no modeled site.
620    #[error(
621        "`{protocol}`'s `{label}` step demands {recorded} bits at a site `GRINDING_VOCABULARY` does not map"
622    )]
623    UnknownStep {
624        /// Protocol whose transcript describes the step.
625        protocol: &'static str,
626        /// Label the step carries.
627        label: &'static str,
628        /// Bits the transcript demands.
629        recorded: usize,
630    },
631    /// A described step from a protocol the caller did not list.
632    #[error(
633        "`{protocol}`'s `{label}` step demands {recorded} bits, and `{protocol}` is not among the protocols checked"
634    )]
635    UnlistedProtocol {
636        /// Protocol whose transcript describes the step.
637        protocol: &'static str,
638        /// Label the step carries.
639        label: &'static str,
640        /// Bits the transcript demands.
641        recorded: usize,
642    },
643}
644
645#[cfg(test)]
646mod tests {
647    use alloc::string::ToString;
648
649    use super::*;
650
651    #[test]
652    fn grinding_mismatch_is_an_error_naming_site_and_counts() {
653        fn assert_error<E: core::error::Error>() {}
654        assert_error::<GrindingMismatch>();
655
656        let mismatch = GrindingMismatch::Difficulty {
657            protocol: "p3-fri",
658            label: "query_pow",
659            site: GrindingSite::LdtQueryPhase,
660            credited: 8,
661            recorded: 12,
662        };
663        assert_eq!(
664            mismatch.to_string(),
665            "ldt-query-phase grinding disagrees: the security model credits 8 bits, `p3-fri`'s `query_pow` step demands 12"
666        );
667    }
668
669    #[test]
670    fn boost_adds_pow_bits_and_zero_is_neutral() {
671        let error = ErrorBits::from_log2(80.0);
672        assert!((boost(error, 0).bits() - 80.0).abs() < 1e-12);
673        assert!((boost(error, 16).bits() - 96.0).abs() < 1e-12);
674    }
675
676    #[test]
677    fn default_sites_are_neutral() {
678        assert_eq!(GrindingSites::default(), GrindingSites::NONE);
679    }
680
681    /// The FRI regime this crate's own tests grind against.
682    const fn regime(commit_pow_bits: usize, query_pow_bits: usize) -> FriRegime {
683        FriRegime {
684            log_blowup: 1,
685            num_queries: 64,
686            log_final_poly_len: 0,
687            max_log_arity: 3,
688            commit_pow_bits,
689            query_pow_bits,
690        }
691    }
692
693    /// The full FRI-backed uni-STARK stack, in transcript nesting order.
694    const UNI_STARK_STACK: [&str; 3] = ["p3-uni-stark", "p3-fri-pcs", "p3-fri"];
695
696    #[test]
697    fn circle_and_stir_batch_grinding_matches_credited_sites() {
698        let sites = GrindingSites {
699            batch_combination: 5,
700            ..GrindingSites::NONE
701        };
702        let circle = GrindingBudget::from_sites(&sites).with_fri(&regime(3, 7));
703        let recorded = [
704            RecordedGrind::new("p3-circle-pcs", "batch_pow", 5),
705            RecordedGrind::new("p3-circle-pcs", "commit_pow", 3),
706            RecordedGrind::new("p3-circle-pcs", "query_pow", 7),
707        ];
708        circle.check(&["p3-circle-pcs"], &recorded).unwrap();
709        for index in 0..recorded.len() {
710            let mut mismatched = recorded;
711            mismatched[index].bits += 1;
712            assert!(circle.check(&["p3-circle-pcs"], &mismatched).is_err());
713        }
714        let stir = GrindingBudget::from_sites(&sites);
715        stir.check(
716            &["p3-stir-pcs-batch"],
717            &[RecordedGrind::new("p3-stir-pcs-batch", "batch_pow", 5)],
718        )
719        .unwrap();
720        assert!(stir.check(&["p3-stir-pcs-batch"], &[]).is_err());
721        for protocol in ["p3-circle-pcs", "p3-stir-pcs-batch"] {
722            GrindingBudget::NONE.check(&[protocol], &[]).unwrap();
723        }
724    }
725
726    #[test]
727    fn every_site_appears_in_the_vocabulary_exactly_as_often_as_a_protocol_names_it() {
728        // Invariant: the table maps every site, so no site escapes the comparison.
729        for site in GrindingSite::ALL {
730            assert!(
731                GRINDING_VOCABULARY.iter().any(|step| step.site == site),
732                "{site} is modeled but no protocol's step maps to it"
733            );
734        }
735
736        // No two rows share a key, or the lookup would silently pick one.
737        for (index, left) in GRINDING_VOCABULARY.iter().enumerate() {
738            for right in &GRINDING_VOCABULARY[index + 1..] {
739                assert!(
740                    (left.protocol, left.label) != (right.protocol, right.label),
741                    "`{}`'s `{}` has two vocabulary rows",
742                    left.protocol,
743                    left.label
744                );
745            }
746        }
747    }
748
749    #[test]
750    fn the_same_label_in_two_protocols_names_one_site_under_two_conventions() {
751        // Fixture state: `ood_pow` is the label both STARK front-ends use.
752        let uni = grinding_step("p3-uni-stark", "ood_pow").expect("uni-STARK grinds before zeta");
753        let batch =
754            grinding_step("p3-batch-stark", "ood_pow").expect("batch-STARK grinds before zeta");
755
756        // The site is a property of the phase, so the two agree on it.
757        assert_eq!(uni.site, batch.site);
758        assert_eq!(uni.site, GrindingSite::OutOfDomain);
759
760        // The convention is a property of the proof layout, so they differ.
761        assert_eq!(uni.zero_bits, ZeroBitConvention::Elided);
762        assert_eq!(batch.zero_bits, ZeroBitConvention::Always);
763    }
764
765    #[test]
766    fn each_site_boosts_the_report_term_its_own_module_labels() {
767        // The report label is the third name of the same phase.
768        assert_eq!(GrindingSite::OutOfDomain.report_label(), DEEP_LABEL);
769        assert_eq!(GrindingSite::BatchCombination.report_label(), BATCH_LABEL);
770        assert_eq!(GrindingSite::LookupChallenge.report_label(), LOGUP_LABEL);
771        assert_eq!(
772            GrindingSite::LdtCommitPhase.report_label(),
773            LDT_COMMIT_LABEL
774        );
775        assert_eq!(GrindingSite::LdtQueryPhase.report_label(), LDT_QUERY_LABEL);
776    }
777
778    #[test]
779    fn the_budget_reads_back_both_halves_of_the_model() {
780        // Fixture state: a distinct value per site, so a swap is visible.
781        let sites = GrindingSites {
782            out_of_domain: 3,
783            batch_combination: 5,
784            lookup_challenge: 7,
785        };
786        let budget = GrindingBudget::from_sites(&sites).with_fri(&regime(11, 13));
787
788        assert_eq!(budget.bits(GrindingSite::OutOfDomain), 3);
789        assert_eq!(budget.bits(GrindingSite::BatchCombination), 5);
790        assert_eq!(budget.bits(GrindingSite::LookupChallenge), 7);
791        assert_eq!(budget.bits(GrindingSite::LdtCommitPhase), 11);
792        assert_eq!(budget.bits(GrindingSite::LdtQueryPhase), 13);
793
794        // `from_sites` alone leaves the low-degree test's own sites at zero.
795        let without_ldt = GrindingBudget::from_sites(&sites);
796        assert_eq!(without_ldt.bits(GrindingSite::LdtCommitPhase), 0);
797        assert_eq!(without_ldt.bits(GrindingSite::LdtQueryPhase), 0);
798    }
799
800    #[test]
801    fn a_stack_that_grinds_exactly_what_the_model_credits_agrees() {
802        let sites = GrindingSites {
803            out_of_domain: 8,
804            batch_combination: 10,
805            ..GrindingSites::NONE
806        };
807        let budget = GrindingBudget::from_sites(&sites).with_fri(&regime(4, 16));
808
809        // The commit-phase grind repeats once per folding round at one difficulty.
810        let recorded = [
811            RecordedGrind::new("p3-uni-stark", "ood_pow", 8),
812            RecordedGrind::new("p3-fri-pcs", "batch_pow", 10),
813            RecordedGrind::new("p3-fri", "commit_pow", 4),
814            RecordedGrind::new("p3-fri", "commit_pow", 4),
815            RecordedGrind::new("p3-fri", "query_pow", 16),
816        ];
817
818        budget
819            .check(&UNI_STARK_STACK, &recorded)
820            .expect("the two halves agree");
821    }
822
823    #[test]
824    fn a_credited_site_the_transcript_never_grinds_for_is_the_overstating_direction() {
825        // Fixture state: the model credits ten batch bits, the transcript demands none.
826        let budget = GrindingBudget::from_sites(&GrindingSites {
827            batch_combination: 10,
828            ..GrindingSites::NONE
829        })
830        .with_fri(&regime(0, 0));
831
832        let mismatch = budget
833            .check(&UNI_STARK_STACK, &[])
834            .expect_err("credited bits nobody pays must be reported");
835
836        assert_eq!(
837            mismatch,
838            GrindingMismatch::Missing {
839                protocol: "p3-fri-pcs",
840                label: "batch_pow",
841                site: GrindingSite::BatchCombination,
842                credited: 10,
843            }
844        );
845    }
846
847    #[test]
848    fn a_difficulty_disagreement_names_the_site_and_both_numbers() {
849        let budget = GrindingBudget::from_sites(&GrindingSites::NONE).with_fri(&regime(0, 16));
850        let recorded = [RecordedGrind::new("p3-fri", "query_pow", 20)];
851
852        let mismatch = budget
853            .check(&["p3-fri"], &recorded)
854            .expect_err("20 recorded against 16 credited must be reported");
855
856        assert_eq!(
857            mismatch,
858            GrindingMismatch::Difficulty {
859                protocol: "p3-fri",
860                label: "query_pow",
861                site: GrindingSite::LdtQueryPhase,
862                credited: 16,
863                recorded: 20,
864            }
865        );
866        // The message is the deliverable, so it carries the site and both numbers.
867        let rendered = alloc::format!("{mismatch}");
868        assert!(rendered.contains("ldt-query-phase"), "{rendered}");
869        assert!(rendered.contains("16"), "{rendered}");
870        assert!(rendered.contains("20"), "{rendered}");
871    }
872
873    #[test]
874    fn the_always_convention_requires_a_zero_bit_step_the_elided_one_forbids() {
875        let budget = GrindingBudget::from_sites(&GrindingSites::NONE);
876
877        // batch-STARK always describes the step, so omitting it is a disagreement.
878        assert_eq!(
879            budget
880                .check(&["p3-batch-stark"], &[])
881                .expect_err("an always-described step cannot be absent"),
882            GrindingMismatch::Missing {
883                protocol: "p3-batch-stark",
884                label: "ood_pow",
885                site: GrindingSite::OutOfDomain,
886                credited: 0,
887            }
888        );
889
890        // Described at zero bits, it agrees.
891        budget
892            .check(
893                &["p3-batch-stark"],
894                &[RecordedGrind::new("p3-batch-stark", "ood_pow", 0)],
895            )
896            .expect("a zero-bit step is what the convention calls for");
897
898        // uni-STARK elides at zero, so describing the step is a disagreement.
899        assert_eq!(
900            budget
901                .check(
902                    &["p3-uni-stark"],
903                    &[RecordedGrind::new("p3-uni-stark", "ood_pow", 0)],
904                )
905                .expect_err("an elided step cannot be described at zero bits"),
906            GrindingMismatch::Unexpected {
907                protocol: "p3-uni-stark",
908                label: "ood_pow",
909                site: GrindingSite::OutOfDomain,
910            }
911        );
912    }
913
914    #[test]
915    fn a_lookup_phase_that_does_not_run_is_neither_paid_for_nor_credited() {
916        // Fixture state: a batch with no lookups describes no `lookup_pow` step.
917        //
918        //     no lookups  ->  no `lookup_pow` step
919        //                 ->  `logup::security_term` returns `None`
920        //                 ->  no term for `lookup_challenge` to boost
921        let budget = GrindingBudget::from_sites(&GrindingSites {
922            lookup_challenge: 20,
923            ..GrindingSites::NONE
924        });
925
926        budget
927            .check(
928                &["p3-batch-stark"],
929                &[RecordedGrind::new("p3-batch-stark", "ood_pow", 0)],
930            )
931            .expect("a phase that does not run credits nothing");
932
933        // Once the phase runs, the difficulty has to be the credited one.
934        assert_eq!(
935            budget
936                .check(
937                    &["p3-batch-stark"],
938                    &[
939                        RecordedGrind::new("p3-batch-stark", "ood_pow", 0),
940                        RecordedGrind::new("p3-batch-stark", "lookup_pow", 8),
941                    ],
942                )
943                .expect_err("8 recorded against 20 credited must be reported"),
944            GrindingMismatch::Difficulty {
945                protocol: "p3-batch-stark",
946                label: "lookup_pow",
947                site: GrindingSite::LookupChallenge,
948                credited: 20,
949                recorded: 8,
950            }
951        );
952    }
953
954    #[test]
955    fn a_step_outside_the_checked_stack_is_reported_rather_than_ignored() {
956        let budget = GrindingBudget::NONE;
957
958        // WHIR's own query grind names a site this table does not map.
959        assert_eq!(
960            budget
961                .check(
962                    &["p3-whir"],
963                    &[RecordedGrind::new("p3-whir", "query_pow", 16)]
964                )
965                .expect_err("an unmapped site must not pass silently"),
966            GrindingMismatch::UnknownStep {
967                protocol: "p3-whir",
968                label: "query_pow",
969                recorded: 16,
970            }
971        );
972
973        // A mapped step from a protocol the caller forgot to list is reported too.
974        assert_eq!(
975            budget
976                .check(
977                    &["p3-fri"],
978                    &[RecordedGrind::new("p3-fri-pcs", "batch_pow", 4)]
979                )
980                .expect_err("an unlisted protocol must not pass silently"),
981            GrindingMismatch::UnlistedProtocol {
982                protocol: "p3-fri-pcs",
983                label: "batch_pow",
984                recorded: 4,
985            }
986        );
987    }
988}