Skip to main content

p3_security/
binary.rs

1//! Additive-domain binary PCS soundness over a binary tower field.
2//!
3//! Only unique decoding is supported.
4//! Write K for the field every challenge is drawn from.
5//!
6//! Each independent binary fold at word length N is charged twice over.
7//! It costs (N + 1)/|K| for proximity and 2/|K| for its quadratic sumcheck.
8//!
9//! Virtual folds between Merkle commitments are charged too.
10//! Full-coset authentication lifts agreement through each one.
11//!
12//! See Appendix A, Theorem 8 of <https://eprint.iacr.org/2024/1553>.
13//! Opening k evaluations with powers of one alpha adds (k - 1)/|K|, and all errors are summed.
14//!
15//! The sole grind precedes query sampling, after alpha and every fold challenge. It buys
16//! back only query error. An alpha grind would require a different transcript and model.
17//! Hash/transcript collision security is supplied separately by the caller.
18//!
19//! The committed columns may live in a narrower subfield than K.
20//! Choosing one moves no term here, because a challenge is always an element of K.
21
22use crate::{ErrorBits, SecurityAssumption, SecurityTerm};
23
24/// Label the additive-domain opening error is reported under.
25pub const BINARY_PCS_OPENING_LABEL: &str = "binary-pcs-opening";
26
27/// Widest challenge alphabet the binary tower offers.
28///
29/// A schedule may draw from any narrower tower level.
30/// This is the ceiling each one is validated against.
31pub const BINARY_PCS_FIELD_BITS: usize = 128;
32
33/// Validated mirror of the binary PCS schedule. Constructed by the protocol crate.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub struct BinaryPcsRegime {
36    challenge_field_bits: usize,
37    num_variables: usize,
38    log_inv_rate: usize,
39    log_folding_factor: usize,
40    num_queries: usize,
41    query_pow_bits: usize,
42}
43
44impl BinaryPcsRegime {
45    /// Reject empty, overflowing, rate-one, or query-free schedules.
46    ///
47    /// The challenge width must be a power-of-two tower level, one byte or wider.
48    /// Every algebraic error below is measured against it.
49    pub const fn new(
50        challenge_field_bits: usize,
51        num_variables: usize,
52        log_inv_rate: usize,
53        log_folding_factor: usize,
54        num_queries: usize,
55        query_pow_bits: usize,
56    ) -> Option<Self> {
57        let log_domain = match num_variables.checked_add(log_inv_rate) {
58            Some(value) => value,
59            None => return None,
60        };
61        if challenge_field_bits < 8
62            || challenge_field_bits > BINARY_PCS_FIELD_BITS
63            || !challenge_field_bits.is_power_of_two()
64            || num_variables == 0
65            || log_inv_rate == 0
66            || log_domain >= usize::BITS as usize
67            || log_folding_factor == 0
68            || log_folding_factor > num_variables
69            || num_queries == 0
70        {
71            return None;
72        }
73        Some(Self {
74            challenge_field_bits,
75            num_variables,
76            log_inv_rate,
77            log_folding_factor,
78            num_queries,
79            query_pow_bits,
80        })
81    }
82
83    /// Query count reserving half the requested error for queries. Full enumeration has
84    /// zero query error and releases that reserve in [`Self::max_opening_claims`].
85    pub fn queries_for_target(
86        security_level: usize,
87        log_inv_rate: usize,
88        query_pow_bits: usize,
89    ) -> usize {
90        SecurityAssumption::UniqueDecoding.queries(
91            security_level
92                .saturating_add(1)
93                .saturating_sub(query_pow_bits)
94                .max(1),
95            log_inv_rate,
96        )
97    }
98
99    /// Bit width of the field every challenge is drawn from.
100    pub const fn challenge_field_bits(&self) -> usize {
101        self.challenge_field_bits
102    }
103
104    /// The only credited grinding site: immediately before query sampling.
105    pub const fn query_pow_bits(&self) -> usize {
106        self.query_pow_bits
107    }
108
109    /// Whether every first-batch coset is authenticated, eliminating query error.
110    pub const fn queries_are_exhaustive(&self) -> bool {
111        self.num_queries
112            >= 1usize << (self.num_variables + self.log_inv_rate - self.log_folding_factor)
113    }
114
115    /// Sum of every fold and sumcheck error, as a numerator over the challenge field size.
116    pub const fn field_error_numerator(&self) -> u128 {
117        (2u128 << (self.num_variables + self.log_inv_rate)) - (2u128 << self.log_inv_rate)
118            + 3 * self.num_variables as u128
119    }
120
121    /// Whole-bit lower bound before adding opening claims or query error.
122    pub const fn field_security_bits(&self) -> usize {
123        let numerator = self.field_error_numerator();
124        self.challenge_field_bits
125            .saturating_sub((u128::BITS - (numerator - 1).leading_zeros()) as usize)
126    }
127
128    /// Field ceiling after reserving query error, before opening-claim batching.
129    pub const fn max_security_bits(&self) -> usize {
130        self.field_security_bits()
131            .saturating_sub(if self.queries_are_exhaustive() { 0 } else { 1 })
132    }
133
134    /// Query security before grinding; infinity when all cosets are checked.
135    pub fn query_security_bits(&self) -> f64 {
136        if self.queries_are_exhaustive() {
137            f64::INFINITY
138        } else {
139            SecurityAssumption::UniqueDecoding.queries_error(self.log_inv_rate, self.num_queries)
140        }
141    }
142
143    /// Conservative claim capacity for a positive total-error target.
144    ///
145    /// Returns zero if the base error already exceeds its budget, queries are too weak,
146    /// or the target is unsupported. In particular, subtraction underflow does not grant
147    /// a spurious one-claim allowance. Configurations must reject zero capacity themselves.
148    pub fn max_opening_claims(&self, security_level: usize) -> usize {
149        let reserve = usize::from(!self.queries_are_exhaustive());
150        if security_level == 0
151            || security_level > self.challenge_field_bits - reserve
152            || self.query_security_bits() + (self.query_pow_bits as f64)
153                < (security_level + reserve) as f64
154        {
155            return 0;
156        }
157        let budget = 1u128 << (self.challenge_field_bits - security_level - reserve);
158        let Some(remaining) = budget.checked_sub(self.field_error_numerator()) else {
159            return 0;
160        };
161        (remaining + 1).min(usize::MAX as u128) as usize
162    }
163
164    /// Union of the opening-claim, fold, sumcheck, and query errors.
165    ///
166    /// Under unique decoding the candidate polynomial is unique at commitment time.
167    /// This error therefore needs no candidate-list multiplier when composed with an AIR.
168    pub fn opening_error(&self, num_claims: usize) -> ErrorBits {
169        let numerator = self.field_error_numerator() + num_claims.saturating_sub(1) as u128;
170        let mut rounded = numerator as f64;
171        if (rounded as u128) < numerator {
172            rounded = rounded.next_up();
173        }
174        let field = ErrorBits::from_log2(self.challenge_field_bits as f64 - libm::log2(rounded));
175        let queries = ErrorBits::from_log2(self.query_security_bits() + self.query_pow_bits as f64);
176        ErrorBits::sum(&[field, queries])
177    }
178
179    /// The opening error, labelled for a report.
180    #[must_use]
181    pub fn opening_term(&self, num_claims: usize) -> SecurityTerm {
182        SecurityTerm::new(BINARY_PCS_OPENING_LABEL, self.opening_error(num_claims))
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn exact_claim_boundaries_and_exhaustive_queries() {
192        let tiny = BinaryPcsRegime::new(128, 1, 2, 1, 200, 0).unwrap();
193        assert_eq!(tiny.field_error_numerator(), 11);
194        assert_eq!(tiny.max_opening_claims(124), 6);
195        assert_eq!(tiny.opening_error(6).bits(), 124.0);
196        assert!(tiny.opening_error(7).bits() < 124.0);
197        let batched = BinaryPcsRegime::new(128, 4, 2, 2, 200, 0).unwrap();
198        assert_eq!(batched.field_error_numerator(), 132);
199        // All 16 cosets are checked: the unused query reserve admits 381 claims.
200        assert_eq!(batched.max_opening_claims(119), 381);
201        assert_eq!(batched.opening_error(381).bits(), 119.0);
202        assert!(batched.opening_error(382).bits() < 119.0);
203    }
204
205    #[test]
206    fn an_exhausted_or_invalid_budget_has_zero_capacity() {
207        let tiny = BinaryPcsRegime::new(128, 1, 2, 1, 200, 0).unwrap();
208        for target in [0, 125, 128, 129, usize::MAX] {
209            assert_eq!(tiny.max_opening_claims(target), 0);
210        }
211        let too_few_queries = BinaryPcsRegime::new(128, 10, 2, 1, 1, 0).unwrap();
212        assert_eq!(too_few_queries.max_opening_claims(100), 0);
213        assert!(BinaryPcsRegime::new(128, 0, 2, 1, 1, 0).is_none());
214        assert!(BinaryPcsRegime::new(128, 1, 0, 1, 1, 0).is_none());
215        assert!(BinaryPcsRegime::new(128, 1, 2, 2, 1, 0).is_none());
216        assert!(BinaryPcsRegime::new(128, 1, 2, 1, 0, 0).is_none());
217        assert!(BinaryPcsRegime::new(128, usize::MAX, 2, 1, 1, 0).is_none());
218    }
219
220    #[test]
221    fn a_narrower_challenge_field_loses_exactly_its_missing_bits() {
222        // Invariant: the numerator counts rounds, the width divides them.
223        //
224        //     - numerator   2^13 - 2^3 + 3*10 = 8214, so ceil(log2) = 14
225        //     - 128-bit     128 - 14 = 114 bits
226        //     -  64-bit     64 - 14 =  50 bits
227        //
228        // Nothing but the width differs, so the gap is the width gap.
229        let wide = BinaryPcsRegime::new(128, 10, 2, 1, 150, 0).unwrap();
230        let narrow = BinaryPcsRegime::new(64, 10, 2, 1, 150, 0).unwrap();
231
232        assert_eq!(wide.field_error_numerator(), 8214);
233        assert_eq!(narrow.field_error_numerator(), 8214);
234        assert_eq!(wide.field_security_bits(), 114);
235        assert_eq!(narrow.field_security_bits(), 50);
236
237        // A target the narrow field cannot reach buys no claims at all.
238        assert_eq!(narrow.max_opening_claims(100), 0);
239        assert!(wide.max_opening_claims(100) > 0);
240
241        // A sampled-query schedule's report is weaker but not by the full width.
242        //
243        // The query term is the same on both sides, and it is the binding one here.
244        assert!(narrow.opening_error(4).bits() < wide.opening_error(4).bits());
245
246        // Exhausting the cosets removes the query term, leaving the width gap in the clear.
247        let wide_all = BinaryPcsRegime::new(128, 4, 2, 2, 200, 0).unwrap();
248        let narrow_all = BinaryPcsRegime::new(64, 4, 2, 2, 200, 0).unwrap();
249        let gap = wide_all.opening_error(4).bits() - narrow_all.opening_error(4).bits();
250        assert!((gap - 64.0).abs() < 1e-9, "{gap}");
251
252        // A width off the tower, or wider than the tower's top, describes no regime.
253        assert!(BinaryPcsRegime::new(96, 10, 2, 1, 150, 0).is_none());
254        assert!(BinaryPcsRegime::new(256, 10, 2, 1, 150, 0).is_none());
255        assert!(BinaryPcsRegime::new(4, 10, 2, 1, 150, 0).is_none());
256    }
257
258    #[test]
259    fn query_grinding_cannot_subsidize_alpha_or_folds() {
260        let plain = BinaryPcsRegime::new(128, 10, 2, 1, 150, 0).unwrap();
261        let ground = BinaryPcsRegime::new(128, 10, 2, 1, 150, 16).unwrap();
262        assert_eq!(plain.field_error_numerator(), 8214);
263        assert_eq!(
264            plain.max_opening_claims(100),
265            ground.max_opening_claims(100)
266        );
267        assert_eq!(plain.max_opening_claims(100), 134_209_515);
268        assert!(plain.opening_error(2).bits() >= 100.0);
269        assert!(ground.opening_error(2).bits() > plain.opening_error(2).bits());
270    }
271
272    #[test]
273    fn both_folding_schedules_charge_every_round() {
274        let single = BinaryPcsRegime::new(128, 20, 2, 1, 150, 0).unwrap();
275        let batched = BinaryPcsRegime::new(128, 20, 2, 3, 150, 0).unwrap();
276        assert_eq!(single.field_error_numerator(), 8_388_660);
277        assert_eq!(
278            single.field_error_numerator(),
279            batched.field_error_numerator()
280        );
281        assert_eq!(single.max_security_bits(), 103);
282    }
283}