Skip to main content

p3_binary_pcs/whir/
profile.rs

1//! Supported parameter profiles, one per proximity regime, derived from the soundness analysis.
2
3use alloc::vec::Vec;
4
5use p3_challenger::{FieldChallenger, GrindingChallenger};
6use p3_field::{ExtensionField, Field};
7use p3_whir::{
8    FoldingFactor, ProtocolParameters, SecurityAssumption, WhirConfig, WhirConfigError, WhirDomain,
9};
10
11use crate::whir::error::ProfileError;
12
13/// Widest candidate space a grinding search enumerates, in bits.
14///
15/// The search counts with a sixty-four bit index, so no alphabet offers more than that.
16const GRIND_SEARCH_BITS: usize = 64;
17
18/// Headroom a grinding search keeps below the candidate space, so an exhaustive one rarely fails.
19const GRIND_MARGIN_BITS: usize = 8;
20
21/// Grinding one witness of the alphabet can carry.
22fn grinding_ceiling<F: Field>() -> usize {
23    F::bits()
24        .min(GRIND_SEARCH_BITS)
25        .saturating_sub(GRIND_MARGIN_BITS)
26}
27
28/// Raise the grinding allowance to a figure the analysis asks for.
29///
30/// # Errors
31///
32/// Returns an error when one witness of the alphabet cannot carry the figure.
33fn raise_allowance<F: Field>(required: usize) -> Result<usize, ProfileError> {
34    let ceiling = grinding_ceiling::<F>();
35    if required > ceiling {
36        return Err(ProfileError::Grinding { required, ceiling });
37    }
38    Ok(required)
39}
40
41/// A named parameter profile for one proximity regime.
42///
43/// The regime, the code rate and the folding width are chosen by the caller.
44///
45/// Query counts, samples and grinding are all derived from the soundness analysis.
46///
47/// Nothing here defaults to a regime: each one is a constructor of its own.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct BinaryWhirProfile {
50    /// Proximity regime the derivation is graded under.
51    assumption: SecurityAssumption,
52    /// Target in bits for every error term the schedule charges.
53    security_level: usize,
54    /// Base-two logarithm of the inverse rate of the first codeword.
55    starting_log_inv_rate: usize,
56    /// Variables eliminated by each round.
57    folding_factor: usize,
58}
59
60impl BinaryWhirProfile {
61    /// The regime that assumes only unique decoding, and conjectures nothing.
62    #[must_use]
63    pub const fn unique_decoding(
64        security_level: usize,
65        starting_log_inv_rate: usize,
66        folding_factor: usize,
67    ) -> Self {
68        Self {
69            assumption: SecurityAssumption::UniqueDecoding,
70            security_level,
71            starting_log_inv_rate,
72            folding_factor,
73        }
74    }
75
76    /// The regime that decodes to the Johnson radius, where list decoding is proven.
77    ///
78    /// Mutual correlated agreement at this radius is a theorem, resting on no conjecture.
79    #[must_use]
80    pub const fn proven_list_decoding(
81        security_level: usize,
82        starting_log_inv_rate: usize,
83        folding_factor: usize,
84    ) -> Self {
85        Self {
86            assumption: SecurityAssumption::JohnsonBound,
87            security_level,
88            starting_log_inv_rate,
89            folding_factor,
90        }
91    }
92
93    /// The regime that decodes to capacity, which no theorem supports.
94    ///
95    /// Every additive binary domain refuses it, because the assumption is refuted there.
96    #[must_use]
97    pub const fn conjectural(
98        security_level: usize,
99        starting_log_inv_rate: usize,
100        folding_factor: usize,
101    ) -> Self {
102        Self {
103            assumption: SecurityAssumption::CapacityBound,
104            security_level,
105            starting_log_inv_rate,
106            folding_factor,
107        }
108    }
109
110    /// The proximity regime this profile is graded under.
111    #[must_use]
112    pub const fn assumption(&self) -> SecurityAssumption {
113        self.assumption
114    }
115
116    /// The target in bits for every error term.
117    #[must_use]
118    pub const fn security_level(&self) -> usize {
119        self.security_level
120    }
121
122    /// Derive the schedule this profile stands for over a concrete evaluation domain.
123    ///
124    /// Grinding is raised to exactly what the analysis demands, rather than guessed at.
125    ///
126    /// The domain vetoes a regime whose distance assumption fails for its code.
127    ///
128    /// # Errors
129    ///
130    /// Returns an error when the derived schedule opens no position.
131    ///
132    /// Returns an error when the domain refuses the regime.
133    ///
134    /// Returns an error when the analysis demands more grinding than one witness can carry.
135    ///
136    /// Returns an error when the schedule itself is infeasible.
137    pub fn config<EF, F, Challenger, Domain>(
138        &self,
139        num_variables: usize,
140        domain: &Domain,
141    ) -> Result<WhirConfig<EF, F, Challenger>, ProfileError>
142    where
143        F: Field,
144        EF: ExtensionField<F>,
145        Challenger: FieldChallenger<F> + GrindingChallenger<Witness = F>,
146        Domain: WhirDomain<F, EF>,
147    {
148        let mut pow_bits = 0;
149        loop {
150            let parameters = ProtocolParameters {
151                starting_log_inv_rate: self.starting_log_inv_rate,
152                round_log_inv_rates: Vec::new(),
153                folding_factor: FoldingFactor::Constant(self.folding_factor),
154                soundness_type: self.assumption,
155                security_level: self.security_level,
156                pow_bits,
157            };
158            match WhirConfig::new_with_domain(num_variables, parameters, domain) {
159                Ok(config) => return Ok(config),
160                // The analysis reports exactly how much grinding the gap needs.
161                // Raising the allowance to that figure is the derivation, not a search.
162                Err(WhirConfigError::PowBitsExceedBudget { required, .. })
163                    if required > pow_bits =>
164                {
165                    pow_bits = raise_allowance::<F>(required)?;
166                }
167                // The schedule refuses a run that opens nothing, whichever route built it.
168                //
169                // Naming it here keeps callers from reading a bare scheduling failure.
170                Err(WhirConfigError::ZeroQueries) => return Err(ProfileError::ZeroQueries),
171                Err(error) => return Err(ProfileError::Schedule(error)),
172            }
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use p3_binary_field::{BinaryChallenger, BinaryField32, BinaryField64, BinaryField128};
180    use p3_challenger::{GrindingChallenger, HashChallenger};
181    use p3_keccak::Keccak256Hash;
182    use p3_whir::{FoldingFactorError, SecurityAssumption, WhirConfigError};
183
184    use super::{BinaryWhirProfile, grinding_ceiling, raise_allowance};
185    use crate::test_util::MyChallenger;
186    use crate::whir::error::ProfileError;
187    use crate::whir::{BinaryWhirDomain, BooleanWhirDomain, ProofShape};
188
189    type EF = BinaryField128;
190    type NarrowChallenger = BinaryChallenger<BinaryField32, HashChallenger<u8, Keccak256Hash, 32>>;
191
192    const NUM_VARIABLES: usize = 9;
193    const SECURITY_LEVEL: usize = 100;
194    const LOG_INV_RATE: usize = 2;
195    const FOLDING: usize = 3;
196
197    #[test]
198    fn a_schedule_that_opens_no_position_is_refused() {
199        // Invariant: a schedule must open at least one position.
200        //
201        // Queries alone test proximity, so a schedule opening none accepts any codeword.
202        //
203        // Crediting grinding with the whole target derives exactly that.
204        //
205        // This profile reaches it with a zero target, which credits the whole of nothing.
206        let domain = BooleanWhirDomain::default();
207        let profile = BinaryWhirProfile::proven_list_decoding(0, LOG_INV_RATE, FOLDING);
208        assert!(matches!(
209            profile
210                .config::<EF, EF, MyChallenger, _>(NUM_VARIABLES, &domain)
211                .err(),
212            Some(ProfileError::ZeroQueries)
213        ));
214
215        // The floor is on the derived count, so a target that does buy queries is untouched.
216        let real = BinaryWhirProfile::proven_list_decoding(SECURITY_LEVEL, LOG_INV_RATE, FOLDING)
217            .config::<EF, EF, MyChallenger, _>(NUM_VARIABLES, &domain)
218            .unwrap();
219        assert!(real.terminal().num_queries > 0);
220    }
221
222    #[test]
223    fn a_profile_that_folds_nothing_is_refused_by_the_schedule() {
224        // Invariant: a round must eliminate at least one variable.
225        //
226        // This one needs no check of our own, since the schedule already refuses it.
227        //
228        // The test pins that refusal, so nobody adds a redundant guard here later.
229        let domain = BooleanWhirDomain::default();
230        let profile = BinaryWhirProfile::proven_list_decoding(SECURITY_LEVEL, LOG_INV_RATE, 0);
231        assert!(matches!(
232            profile
233                .config::<EF, EF, MyChallenger, _>(NUM_VARIABLES, &domain)
234                .err(),
235            Some(ProfileError::Schedule(WhirConfigError::FoldingFactor(
236                FoldingFactorError::ZeroFactor
237            )))
238        ));
239    }
240
241    #[test]
242    fn each_regime_is_reached_by_its_own_constructor() {
243        assert_eq!(
244            BinaryWhirProfile::unique_decoding(SECURITY_LEVEL, LOG_INV_RATE, FOLDING).assumption(),
245            SecurityAssumption::UniqueDecoding
246        );
247        assert_eq!(
248            BinaryWhirProfile::proven_list_decoding(SECURITY_LEVEL, LOG_INV_RATE, FOLDING)
249                .assumption(),
250            SecurityAssumption::JohnsonBound
251        );
252        assert_eq!(
253            BinaryWhirProfile::conjectural(SECURITY_LEVEL, LOG_INV_RATE, FOLDING).assumption(),
254            SecurityAssumption::CapacityBound
255        );
256    }
257
258    #[test]
259    fn decoding_further_buys_fewer_opened_positions() {
260        let domain = BooleanWhirDomain::default();
261        let shape = |profile: BinaryWhirProfile| {
262            ProofShape::of(
263                &profile
264                    .config::<EF, EF, MyChallenger, _>(NUM_VARIABLES, &domain)
265                    .unwrap(),
266                1,
267            )
268        };
269        let unique = shape(BinaryWhirProfile::unique_decoding(
270            SECURITY_LEVEL,
271            LOG_INV_RATE,
272            FOLDING,
273        ));
274        let proven = shape(BinaryWhirProfile::proven_list_decoding(
275            SECURITY_LEVEL,
276            LOG_INV_RATE,
277            FOLDING,
278        ));
279        assert!(proven.stir_queries < unique.stir_queries);
280    }
281
282    #[test]
283    fn the_conjectural_regime_is_refused_by_the_additive_domain() {
284        let refused = BinaryWhirProfile::conjectural(SECURITY_LEVEL, LOG_INV_RATE, FOLDING)
285            .config::<EF, EF, MyChallenger, _>(NUM_VARIABLES, &BooleanWhirDomain::default())
286            .unwrap_err();
287        // The schedule error carries no equality.
288        //
289        // So the variant is pinned whole, and the rendered message is checked as well.
290        assert!(matches!(
291            refused,
292            ProfileError::Schedule(WhirConfigError::UnsupportedSecurityAssumption {
293                assumption: SecurityAssumption::CapacityBound
294            })
295        ));
296        assert_eq!(
297            alloc::format!("{refused}"),
298            "the evaluation domain does not support the CapacityBound soundness regime"
299        );
300    }
301
302    // A schedule the challenger can grind, or a refusal naming a figure above what it can.
303    fn inside_the_ceiling(derived: &Result<usize, ProfileError>, ceiling: usize) {
304        match derived {
305            Ok(pow_bits) => assert!(*pow_bits <= ceiling, "{pow_bits} bits past {ceiling}"),
306            Err(ProfileError::Grinding {
307                required,
308                ceiling: named,
309            }) => {
310                assert_eq!(*named, ceiling);
311                assert!(*required > ceiling, "{required} bits is not past {ceiling}");
312            }
313            // A derivation refused for another reason is not this test's business.
314            Err(ProfileError::Schedule(_) | ProfileError::ZeroQueries) => {}
315        }
316    }
317
318    #[test]
319    fn the_ceiling_is_the_headroom_the_search_keeps_below_one_witness() {
320        // Integer arithmetic over the alphabet's width, so every host reads the same figure.
321        assert_eq!(grinding_ceiling::<BinaryField32>(), 24);
322        assert_eq!(grinding_ceiling::<BinaryField64>(), 56);
323        assert_eq!(grinding_ceiling::<EF>(), 56);
324    }
325
326    #[test]
327    fn a_request_one_bit_past_the_ceiling_is_refused() {
328        // The request is constructed rather than derived, so no analysis can move the premise.
329        let refused = raise_allowance::<BinaryField32>(25).unwrap_err();
330        assert!(
331            matches!(
332                refused,
333                ProfileError::Grinding {
334                    required: 25,
335                    ceiling: 24
336                }
337            ),
338            "{refused:?}"
339        );
340        assert_eq!(
341            alloc::format!("{refused}"),
342            "the schedule needs 25 grinding bits, one witness allows 24"
343        );
344        assert!(matches!(
345            raise_allowance::<EF>(57).unwrap_err(),
346            ProfileError::Grinding {
347                required: 57,
348                ceiling: 56
349            }
350        ));
351
352        // The ceiling itself is allowed, so the refusal starts exactly one bit above it.
353        assert_eq!(raise_allowance::<BinaryField32>(24).unwrap(), 24);
354        assert_eq!(raise_allowance::<EF>(56).unwrap(), 56);
355    }
356
357    #[test]
358    fn no_schedule_the_profile_returns_can_abort_the_prover() {
359        // What must never come back is a schedule the challenger would refuse to grind.
360        let narrow = BinaryWhirDomain::<BinaryField32>::default();
361        let wide = BooleanWhirDomain::default();
362        for security_level in [80, 100, 128, 160, 200, 256] {
363            for log_inv_rate in [1, 2, 3, 4] {
364                for num_variables in [8, 12, 16, 20] {
365                    for folding in [2, 3, 4] {
366                        let profile = BinaryWhirProfile::proven_list_decoding(
367                            security_level,
368                            log_inv_rate,
369                            folding,
370                        );
371                        inside_the_ceiling(
372                            &profile
373                                .config::<EF, BinaryField32, NarrowChallenger, _>(
374                                    num_variables,
375                                    &narrow,
376                                )
377                                .map(|config| config.max_pow_bits()),
378                            grinding_ceiling::<BinaryField32>(),
379                        );
380                        inside_the_ceiling(
381                            &profile
382                                .config::<EF, EF, MyChallenger, _>(num_variables, &wide)
383                                .map(|config| config.max_pow_bits()),
384                            grinding_ceiling::<EF>(),
385                        );
386                    }
387                }
388            }
389        }
390    }
391
392    #[test]
393    #[should_panic = "too small a margin"]
394    fn the_narrow_ceiling_is_the_one_the_challenger_enforces() {
395        // The refusal above is worth nothing unless one more bit really does abort a prover.
396        let mut challenger = NarrowChallenger::from_hasher(alloc::vec::Vec::new(), Keccak256Hash);
397        let _ = challenger.grind(grinding_ceiling::<BinaryField32>() + 1);
398    }
399
400    #[test]
401    #[should_panic = "too small a margin"]
402    fn the_wide_ceiling_is_the_one_the_challenger_enforces() {
403        let mut challenger = MyChallenger::from_hasher(alloc::vec::Vec::new(), Keccak256Hash);
404        let _ = challenger.grind(grinding_ceiling::<EF>() + 1);
405    }
406}