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(®ime(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(®ime(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(®ime(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(®ime(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(®ime(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}