Skip to main content

stats_claw/
rng.rs

1//! Deterministic pseudo-random number generation and sampling primitives.
2//!
3//! This module provides the framework's reproducible PRNG (`SplitMix64`) and the
4//! low-level samplers that distributions build their `Sample` implementations
5//! on. Determinism is a hard requirement: identical seeds must produce
6//! identical streams so that golden-fixture equivalence tests are stable.
7
8use std::f64::consts::PI;
9
10/// Increment used to walk the `SplitMix64` state (the golden-ratio constant).
11const GOLDEN_GAMMA: u64 = 0x9E37_79B9_7F4A_7C15;
12/// First mixing multiplier from Steele, Lea & Flood (2014).
13const MIX_A: u64 = 0xBF58_476D_1CE4_E5B9;
14/// Second mixing multiplier from Steele, Lea & Flood (2014).
15const MIX_B: u64 = 0x94D0_49BB_1331_11EB;
16/// `2^53`, the number of representable mantissa steps for a uniform `f64`.
17const TWO_POW_53: f64 = 9_007_199_254_740_992.0;
18/// `2^32`, used to widen a `u64` to `f64` without an `as` cast.
19const TWO_POW_32: f64 = 4_294_967_296.0;
20
21/// Widens a `u64` to the nearest `f64` without an `as` cast.
22///
23/// Splits the value into 32-bit halves (each losslessly representable as `f64`)
24/// and recombines them, so the `style.rs` no-`as` guard is satisfied while large
25/// values still round exactly as a single cast would.
26///
27/// # Arguments
28///
29/// * `x` — the value to widen.
30///
31/// # Returns
32///
33/// `x` as an `f64` (exact for the 53-bit values this module feeds it).
34fn u64_to_f64(x: u64) -> f64 {
35    let hi = u32::try_from(x >> 32).unwrap_or(0);
36    let lo = u32::try_from(x & 0xFFFF_FFFF).unwrap_or(0);
37    f64::from(hi).mul_add(TWO_POW_32, f64::from(lo))
38}
39
40/// `SplitMix64` generator (Steele, Lea & Flood 2014).
41///
42/// A single-`u64`-state PRNG chosen for its tiny, portable, branch-free core: it
43/// produces a byte-identical stream for a given seed on every platform, which the
44/// equivalence harness relies on. That cross-platform guarantee covers
45/// [`next_u64`](Self::next_u64) and [`next_f64`](Self::next_f64), which are
46/// integer arithmetic plus one exact division by a power of two; it stops at
47/// [`standard_normal`](Self::standard_normal), whose Box–Muller transform routes
48/// through the platform math library — see that method's portability note. The
49/// generator also caches the second Box–Muller variate, so `standard_normal`
50/// costs one transcendental pair per two draws.
51#[derive(Debug, Clone)]
52pub struct SplitMix64 {
53    /// Current generator state, advanced by [`GOLDEN_GAMMA`] each draw.
54    state: u64,
55    /// The spare standard-normal variate from the previous Box–Muller draw.
56    cached_normal: Option<f64>,
57}
58
59impl SplitMix64 {
60    /// Creates a generator seeded with `seed`.
61    ///
62    /// # Arguments
63    ///
64    /// * `seed` — initial state; any `u64` is valid (including `0`).
65    #[must_use]
66    pub const fn new(seed: u64) -> Self {
67        Self {
68            state: seed,
69            cached_normal: None,
70        }
71    }
72
73    /// Draws the next 64-bit value and advances the state.
74    ///
75    /// # Returns
76    ///
77    /// A pseudo-random `u64` uniformly distributed over the full range.
78    pub const fn next_u64(&mut self) -> u64 {
79        self.state = self.state.wrapping_add(GOLDEN_GAMMA);
80        let mut z = self.state;
81        z = (z ^ (z >> 30)).wrapping_mul(MIX_A);
82        z = (z ^ (z >> 27)).wrapping_mul(MIX_B);
83        z ^ (z >> 31)
84    }
85
86    /// Draws a uniform value in `[0, 1)` with 53 bits of resolution.
87    ///
88    /// # Returns
89    ///
90    /// A pseudo-random `f64` in the half-open unit interval.
91    pub fn next_f64(&mut self) -> f64 {
92        u64_to_f64(self.next_u64() >> 11) / TWO_POW_53
93    }
94
95    /// Draws a standard-normal variate via Box–Muller, caching the spare.
96    ///
97    /// The transform produces two independent N(0,1) variates per call; the
98    /// second is cached and returned on the following call, halving the
99    /// transcendental cost over a long stream.
100    ///
101    /// # Portability
102    ///
103    /// The draw is deterministic: one seed gives one sequence, and the `u64`
104    /// stream underneath it is byte-identical on every target. The variate
105    /// itself is not. Box–Muller evaluates `ln`, `sin`, and `cos` through the
106    /// platform math library, which is accurate to well under an ulp but is not
107    /// correctly rounded, so different targets — and even different
108    /// optimisation levels on one target — can disagree in the last ulp or two.
109    /// Compare standard-normal draws, and any statistic accumulated from them,
110    /// with a tolerance; never bit-for-bit across builds or machines.
111    ///
112    /// # Returns
113    ///
114    /// A pseudo-random `f64` distributed as N(0, 1).
115    pub fn standard_normal(&mut self) -> f64 {
116        if let Some(z) = self.cached_normal.take() {
117            return z;
118        }
119        let u1 = self.next_f64().max(f64::MIN_POSITIVE);
120        let u2 = self.next_f64();
121        let r = (-2.0 * u1.ln()).sqrt();
122        let theta = 2.0 * PI * u2;
123        self.cached_normal = Some(r * theta.sin());
124        r * theta.cos()
125    }
126}
127
128/// Kani formal-verification harnesses for the PRNG core.
129///
130/// These prove properties over *all* inputs (symbolic `kani::any()` state), not
131/// the sampled inputs the `#[cfg(test)]` suite exercises. They are compiled only
132/// under `cargo kani` (behind `#[cfg(kani)]`) and are invisible to normal
133/// build/test/clippy. Run with e.g.
134/// `cargo kani --harness rng_next_f64_in_unit_interval -p stats-claw`.
135#[cfg(kani)]
136mod verification {
137    use super::{SplitMix64, TWO_POW_32, TWO_POW_53, u64_to_f64};
138
139    /// Proves [`u64_to_f64`] is a faithful widening for every 53-bit input: the
140    /// split-and-recombine reconstruction is finite, non-negative, and stays
141    /// strictly below `2^53` (the property [`SplitMix64::next_f64`] relies on to
142    /// keep its quotient in `[0, 1)`).
143    ///
144    /// Restricting to `x < 2^53` matches the module's only caller
145    /// (`next_u64() >> 11`) and keeps every intermediate exactly representable, so
146    /// the widening is provably lossless there.
147    #[kani::proof]
148    fn rng_u64_to_f64_faithful() {
149        let x: u64 = kani::any();
150        kani::assume(x < (1u64 << 53));
151        let y = u64_to_f64(x);
152        assert!(y.is_finite(), "u64_to_f64 produced non-finite output");
153        assert!(y >= 0.0, "u64_to_f64 produced a negative value");
154        assert!(y < TWO_POW_53, "u64_to_f64 escaped the 2^53 window");
155    }
156
157    /// Proves the 32-bit split constants are consistent: the widened halves of a
158    /// symbolic `u64` recombine to a finite, non-negative `f64` no greater than
159    /// `2^64` — i.e. the widening never overflows to `∞` over the full range.
160    #[kani::proof]
161    fn rng_u64_to_f64_no_overflow() {
162        let x: u64 = kani::any();
163        let y = u64_to_f64(x);
164        assert!(
165            y.is_finite(),
166            "full-range u64_to_f64 overflowed to non-finite"
167        );
168        assert!(y >= 0.0, "full-range u64_to_f64 produced a negative value");
169        // Both 32-bit halves are < 2^32, so the exact recombination is < 2^64. In
170        // `f64` the largest inputs (near `2^64 − 1`) round up to exactly `2^64`, so
171        // the tight, provable ceiling is `<= 2^64`, not `<`.
172        assert!(y <= TWO_POW_32 * TWO_POW_32, "u64_to_f64 exceeded 2^64");
173    }
174
175    /// Proves [`SplitMix64::next_u64`] is panic-/overflow-free for a fully symbolic
176    /// generator state. Every arithmetic step is `wrapping_*`, so this discharges
177    /// Kani's default overflow checks over the entire state space.
178    #[kani::proof]
179    fn rng_next_u64_no_panic() {
180        let state: u64 = kani::any();
181        let mut rng = SplitMix64::new(state);
182        let _ = rng.next_u64();
183    }
184
185    /// Proves [`SplitMix64::next_f64`] lands in the half-open unit interval
186    /// `[0.0, 1.0)` for *every* possible generator state — the uniform-sampler
187    /// contract the whole distributions layer builds on, verified symbolically
188    /// rather than over the 10 000 sampled draws the unit test checks.
189    #[kani::proof]
190    fn rng_next_f64_in_unit_interval() {
191        let state: u64 = kani::any();
192        let mut rng = SplitMix64::new(state);
193        let u = rng.next_f64();
194        assert!(u >= 0.0, "next_f64 produced a negative value: {u}");
195        assert!(u < 1.0, "next_f64 reached or exceeded 1.0: {u}");
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn same_seed_same_stream() {
205        let mut a = SplitMix64::new(42);
206        let mut b = SplitMix64::new(42);
207        for _ in 0..1000 {
208            assert_eq!(
209                a.next_u64(),
210                b.next_u64(),
211                "identical seeds must yield identical streams"
212            );
213        }
214    }
215
216    #[test]
217    fn uniform_in_unit_interval() {
218        let mut r = SplitMix64::new(7);
219        for _ in 0..10_000 {
220            let u = r.next_f64();
221            assert!((0.0..1.0).contains(&u), "next_f64 escaped [0, 1): {u}");
222        }
223    }
224
225    #[test]
226    fn standard_normal_is_deterministic_and_finite() {
227        let mut a = SplitMix64::new(99);
228        let mut b = SplitMix64::new(99);
229        for _ in 0..1000 {
230            let za = a.standard_normal();
231            assert!(za.is_finite(), "standard_normal produced non-finite {za}");
232            assert!(
233                (za - b.standard_normal()).abs() < 1e-15,
234                "standard_normal stream diverged for identical seeds"
235            );
236        }
237    }
238}