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