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. It also caches the second Box–Muller variate so
45/// `standard_normal` costs one transcendental pair per two draws.
46#[derive(Debug, Clone)]
47pub struct SplitMix64 {
48    /// Current generator state, advanced by [`GOLDEN_GAMMA`] each draw.
49    state: u64,
50    /// The spare standard-normal variate from the previous Box–Muller draw.
51    cached_normal: Option<f64>,
52}
53
54impl SplitMix64 {
55    /// Creates a generator seeded with `seed`.
56    ///
57    /// # Arguments
58    ///
59    /// * `seed` — initial state; any `u64` is valid (including `0`).
60    #[must_use]
61    pub const fn new(seed: u64) -> Self {
62        Self {
63            state: seed,
64            cached_normal: None,
65        }
66    }
67
68    /// Draws the next 64-bit value and advances the state.
69    ///
70    /// # Returns
71    ///
72    /// A pseudo-random `u64` uniformly distributed over the full range.
73    pub const fn next_u64(&mut self) -> u64 {
74        self.state = self.state.wrapping_add(GOLDEN_GAMMA);
75        let mut z = self.state;
76        z = (z ^ (z >> 30)).wrapping_mul(MIX_A);
77        z = (z ^ (z >> 27)).wrapping_mul(MIX_B);
78        z ^ (z >> 31)
79    }
80
81    /// Draws a uniform value in `[0, 1)` with 53 bits of resolution.
82    ///
83    /// # Returns
84    ///
85    /// A pseudo-random `f64` in the half-open unit interval.
86    pub fn next_f64(&mut self) -> f64 {
87        u64_to_f64(self.next_u64() >> 11) / TWO_POW_53
88    }
89
90    /// Draws a standard-normal variate via Box–Muller, caching the spare.
91    ///
92    /// The transform produces two independent N(0,1) variates per call; the
93    /// second is cached and returned on the following call, halving the
94    /// transcendental cost over a long stream.
95    ///
96    /// # Returns
97    ///
98    /// A pseudo-random `f64` distributed as N(0, 1).
99    pub fn standard_normal(&mut self) -> f64 {
100        if let Some(z) = self.cached_normal.take() {
101            return z;
102        }
103        let u1 = self.next_f64().max(f64::MIN_POSITIVE);
104        let u2 = self.next_f64();
105        let r = (-2.0 * u1.ln()).sqrt();
106        let theta = 2.0 * PI * u2;
107        self.cached_normal = Some(r * theta.sin());
108        r * theta.cos()
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn same_seed_same_stream() {
118        let mut a = SplitMix64::new(42);
119        let mut b = SplitMix64::new(42);
120        for _ in 0..1000 {
121            assert_eq!(
122                a.next_u64(),
123                b.next_u64(),
124                "identical seeds must yield identical streams"
125            );
126        }
127    }
128
129    #[test]
130    fn uniform_in_unit_interval() {
131        let mut r = SplitMix64::new(7);
132        for _ in 0..10_000 {
133            let u = r.next_f64();
134            assert!((0.0..1.0).contains(&u), "next_f64 escaped [0, 1): {u}");
135        }
136    }
137
138    #[test]
139    fn standard_normal_is_deterministic_and_finite() {
140        let mut a = SplitMix64::new(99);
141        let mut b = SplitMix64::new(99);
142        for _ in 0..1000 {
143            let za = a.standard_normal();
144            assert!(za.is_finite(), "standard_normal produced non-finite {za}");
145            assert!(
146                (za - b.standard_normal()).abs() < 1e-15,
147                "standard_normal stream diverged for identical seeds"
148            );
149        }
150    }
151}