Skip to main content

p3_security/
fixed.rs

1//! Deterministic integer analog of this crate's floating-point error-bit arithmetic.
2//!
3//! Every quantity elsewhere in this crate is `-log2(error)` as an `f64` via [`crate::error::ErrorBits`].
4//! A protocol that grades proof soundness *inside a recursive verifier* — a circuit or a VM with no
5//! floating-point unit — cannot reproduce that arithmetic bit-for-bit, and needs every rounding
6//! step biased toward understating security rather than overstating it. This module provides that:
7//! `-log2(error)` as a 16-fractional-bit fixed-point integer, computed with `const fn` integer
8//! operations only, rounding down on added logarithms and up on subtracted ones so the result is
9//! always a conservative lower bound on the true value.
10//!
11//! Not wired into the rest of the crate's `f64` composition machinery — a consumer needing
12//! bit-exact recursive verification uses this module's primitives directly rather than
13//! [`crate::error::ErrorBits`].
14
15/// Number of fractional bits in the fixed-point representation.
16pub const FRACTIONAL_BITS: u32 = 16;
17
18/// The value `1.0` in fixed point.
19pub const ONE: u64 = 1 << FRACTIONAL_BITS;
20
21/// `log2(e)` in fixed point, rounded up.
22///
23/// Appears in the random-words cutoff of [2025/2010](https://eprint.iacr.org/2025/2010) section
24/// 1.5, in [`bits_per_query`]'s `numerator`, whose `ceil_log2` is then subtracted from the
25/// uncorrected rate. Rounding this constant down would shrink that correction and overstate the
26/// reported per-query rate, contradicting this module's conservative-lower-bound invariant.
27pub const LOG2_E: u64 = 94_549;
28
29/// Converts a whole number of bits into fixed point.
30pub const fn from_bits(bits: u32) -> u64 {
31    (bits as u64) << FRACTIONAL_BITS
32}
33
34/// Truncates a fixed-point quantity to whole bits, rounding down.
35pub const fn to_bits(value: u64) -> u32 {
36    (value >> FRACTIONAL_BITS) as u32
37}
38
39/// Returns `log2(x)` in fixed point, rounded down.
40///
41/// # Panics
42/// Panics if `x` is zero, which has no logarithm.
43pub const fn floor_log2(x: u64) -> u64 {
44    assert!(x != 0, "log2 is undefined at zero");
45
46    let integer_part = 63 - x.leading_zeros() as u64;
47    let mut result = integer_part << FRACTIONAL_BITS;
48
49    // Normalize the mantissa to Q32, so it lies in `[1, 2)` and its square stays inside a `u128`.
50    let mut mantissa: u128 = if integer_part >= 32 {
51        (x as u128) >> (integer_part - 32)
52    } else {
53        (x as u128) << (32 - integer_part)
54    };
55
56    // Standard bit-by-bit binary logarithm: squaring the mantissa shifts the next fractional bit
57    // of the result into the integer position.
58    let mut bit = 0;
59    while bit < FRACTIONAL_BITS {
60        mantissa = (mantissa * mantissa) >> 32;
61        if mantissa >= 1 << 33 {
62            mantissa >>= 1;
63            result |= 1 << (FRACTIONAL_BITS - 1 - bit);
64        }
65        bit += 1;
66    }
67
68    result
69}
70
71/// Returns `log2(x)` in fixed point, rounded up.
72///
73/// Exact for powers of two; otherwise one unit in the last place above [`floor_log2`], which is at
74/// most `2^-16` of a bit coarser than the true value.
75///
76/// # Panics
77/// Panics if `x` is zero, which has no logarithm.
78pub const fn ceil_log2(x: u64) -> u64 {
79    if x.is_power_of_two() {
80        from_bits(x.trailing_zeros())
81    } else {
82        floor_log2(x) + 1
83    }
84}
85
86/// Returns the conjectured security contributed by a single FRI query, in fixed point.
87///
88/// This is the "random words" rate of [2025/2010](https://eprint.iacr.org/2025/2010) section 1.5:
89///
90/// ```text
91/// bits_per_query = -log2(ρ + η),   ρ = 2^-log_blowup,   η = log2(e/ρ) · ρ / log2(q)
92/// ```
93///
94/// Rewriting to keep every step in fixed point and avoid materializing `ρ`:
95///
96/// ```text
97/// -log2(ρ + η) = log_blowup - log2(1 + (log2(e) + log_blowup) / log2(q))
98/// ```
99///
100/// so the correction is a ratio of two fixed-point integers whose logarithm [`ceil_log2`] can take
101/// directly. The correction is rounded up and then subtracted, keeping the rate conservative.
102///
103/// `field_bits` is `log2(q)` for the field FRI operates over — the challenge field, in fixed point.
104/// Values outside `1..=from_bits(u32::MAX)` are unsupported and return zero. This bound keeps
105/// the numerator representable even at the largest `log_blowup`.
106///
107/// Returns zero when the correction would exceed `log_blowup`, i.e. when the rate is not positive
108/// and queries contribute nothing.
109pub const fn bits_per_query(log_blowup: u32, field_bits: u64) -> u64 {
110    if log_blowup == 0 || field_bits == 0 || field_bits > from_bits(u32::MAX) {
111        return 0;
112    }
113
114    let uncorrected = from_bits(log_blowup);
115    let numerator = field_bits + LOG2_E + uncorrected;
116    let correction = ceil_log2(numerator) - floor_log2(field_bits);
117
118    uncorrected.saturating_sub(correction)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::fri::{FriRegime, conjectured_error};
125    use crate::shape::InstanceShape;
126
127    #[test]
128    fn malformed_field_bits_have_no_query_security() {
129        for field_bits in [
130            0,
131            18_446_744_073_709_391_531,
132            u64::MAX,
133            from_bits(u32::MAX) + 1,
134        ] {
135            assert_eq!(bits_per_query(1, field_bits), 0);
136        }
137        assert!(bits_per_query(1, from_bits(u32::MAX)) > 0);
138        assert!(bits_per_query(u32::MAX, from_bits(u32::MAX)) > 0);
139    }
140
141    /// The true value of `log2` must never be understated by [`ceil_log2`] nor overstated by
142    /// [`floor_log2`], and both must track it to within one unit in the last place. These are the
143    /// directions a round budget built on this module relies on to stay conservative.
144    #[test]
145    fn log2_brackets_the_true_logarithm() {
146        let mut x = 1u64;
147        while x < u64::MAX / 3 {
148            for candidate in [x, x + 1, x * 3 / 2] {
149                let truth = (candidate as f64).log2() * ONE as f64;
150                let floor = floor_log2(candidate) as f64;
151                let ceil = ceil_log2(candidate) as f64;
152
153                assert!(
154                    floor <= truth + 1e-3,
155                    "floor_log2({candidate}) overstates log2"
156                );
157                assert!(
158                    truth - floor < 2.0,
159                    "floor_log2({candidate}) is more than 1 ulp low"
160                );
161                assert!(
162                    ceil + 1e-3 >= truth,
163                    "ceil_log2({candidate}) understates log2"
164                );
165                assert!(
166                    ceil - truth < 2.0,
167                    "ceil_log2({candidate}) is more than 1 ulp high"
168                );
169            }
170            x *= 2;
171        }
172    }
173
174    /// `LOG2_E` enters `bits_per_query`'s subtracted correction, so rounding it down would
175    /// shrink the correction and overstate the reported per-query rate.
176    #[test]
177    fn log2_e_rounds_up_not_down() {
178        let truth = core::f64::consts::LOG2_E * ONE as f64;
179        assert!(
180            LOG2_E as f64 >= truth,
181            "LOG2_E must round up, not down: {LOG2_E} < {truth}"
182        );
183    }
184
185    #[test]
186    fn log2_is_exact_on_powers_of_two() {
187        for exponent in 0..64u32 {
188            assert_eq!(floor_log2(1 << exponent), from_bits(exponent));
189            assert_eq!(ceil_log2(1 << exponent), from_bits(exponent));
190        }
191    }
192
193    /// The fixed-point per-query rate must never exceed the real-valued random-words formula, and
194    /// must track it within one ulp, across every blowup the PCS parameters admit.
195    #[test]
196    fn bits_per_query_never_overstates_the_random_words_rate() {
197        for log_blowup in 1..=8u32 {
198            for field_bits in [from_bits(64), from_bits(96), from_bits(128), from_bits(192)] {
199                let rho = 2f64.powi(-(log_blowup as i32));
200                let eta = (core::f64::consts::LOG2_E + f64::from(log_blowup)) * rho
201                    / (field_bits as f64 / ONE as f64);
202                let truth = -(rho + eta).log2() * ONE as f64;
203
204                let actual = bits_per_query(log_blowup, field_bits) as f64;
205                assert!(
206                    actual <= truth,
207                    "bits_per_query({log_blowup}, {field_bits}) overstates the rate"
208                );
209                assert!(
210                    truth - actual < 2.0,
211                    "bits_per_query({log_blowup}, {field_bits}) is more than 1 ulp low"
212                );
213            }
214        }
215    }
216
217    #[test]
218    fn bits_per_query_is_zero_without_a_blowup() {
219        assert_eq!(bits_per_query(0, from_bits(128)), 0);
220    }
221
222    /// [`bits_per_query`] and this crate's own [`crate::fri::conjectured_error`] compute the same
223    /// per-query rate through two independent code paths (fixed-point vs. `f64` + `libm`); at a
224    /// single query with no grinding they must agree to within the fixed-point representation's own
225    /// resolution.
226    #[test]
227    fn bits_per_query_matches_fri_conjectured_error_per_query_rate() {
228        for log_blowup in 1..=8usize {
229            let regime = FriRegime {
230                log_blowup,
231                num_queries: 1,
232                log_final_poly_len: 0,
233                max_log_arity: 0,
234                commit_pow_bits: 0,
235                query_pow_bits: 0,
236            };
237            let shape = InstanceShape {
238                log_trace_length: 20,
239                modulus_bits: 128,
240                collision_resistance: 128,
241                num_batched_functions: 1,
242            };
243
244            let f64_bits = conjectured_error(&regime, &shape).bits();
245            let fixed_bits = bits_per_query(log_blowup as u32, from_bits(128)) as f64 / ONE as f64;
246
247            assert!(
248                fixed_bits <= f64_bits + 1e-9,
249                "log_blowup={log_blowup}: fixed overstates f64"
250            );
251            assert!(
252                f64_bits - fixed_bits < 2.0 / ONE as f64 + 1e-9,
253                "log_blowup={log_blowup}: fixed is more than 1 ulp below f64"
254            );
255        }
256    }
257}