Skip to main content

katgpt_types/
rng.rs

1//! XorShift64 PRNG.
2
3// ---------------------------------------------------------------------------
4// RNG
5// ---------------------------------------------------------------------------
6
7/// XorShift64 PRNG — deterministic per seed.
8pub struct Rng {
9    state: u64,
10}
11
12impl Rng {
13    /// Construct from a 64-bit seed.
14    ///
15    /// Applies one round of SplitMix64 mixing to decorrelate low-entropy seeds
16    /// (Issue 296). Without this, small seeds like `0`, `1`, `2`, `42` keep the
17    /// XorShift64 state small enough that the first `next()` has zero upper 24
18    /// bits, so `uniform()` returns exactly `0.0` — which sits on the left
19    /// boundary of any inverse-CDF sampler and deterministically selects the
20    /// first token with nonzero mass. SplitMix64 is the standard remedy for
21    /// XorShift "small seed" weakness: it diffuses any input (including 0)
22    /// into a well-distributed 64-bit state in O(1).
23    ///
24    /// Cost: ~3 mul/shift per `Rng::new`. Negligible — `new()` is called once
25    /// per inference/training session, not in hot loops.
26    pub fn new(seed: u64) -> Self {
27        // SplitMix64 finalizer (Steele/Lea/Leiserson — see Java SplittableRandom).
28        let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
29        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
30        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
31        let state = z ^ (z >> 31);
32        // XorShift64 with state == 0 is an absorbing state (stuck at 0 forever).
33        // SplitMix64 never outputs 0 for any u64 input in practice, but guard
34        // defensively: the cost is one branch on construction, not on hot paths.
35        Self {
36            state: if state == 0 { 1 } else { state },
37        }
38    }
39
40    #[allow(clippy::should_implement_trait)]
41    #[inline(always)]
42    pub fn next(&mut self) -> u64 {
43        self.state ^= self.state << 13;
44        self.state ^= self.state >> 7;
45        self.state ^= self.state << 17;
46        self.state
47    }
48
49    /// Uniform [0, 1) — 24 bits of entropy (full f32 mantissa precision).
50    ///
51    /// Note: returns a value in the half-open interval [0, 1). The exact bound
52    /// `0.0` is reachable in principle (24 zero bits) but, after the SplitMix64
53    /// seed mixing in `Rng::new`, is no longer the deterministic first draw for
54    /// small seeds. Samplers that require a strictly positive variate should
55    /// still redraw on `r == 0.0` as a defensive belt — see Issue 296.
56    #[inline(always)]
57    pub fn uniform(&mut self) -> f32 {
58        // Bit-manipulation trick: take upper 24 bits, OR into the 23-bit mantissa
59        // position with exponent set to 0x3f80 (1.0), then subtract 1.0.
60        // This gives exactly 24 bits of uniform random bits mapped to [0, 1).
61        let bits = ((self.next() >> 40) as u32 & 0x007f_ffff) | 0x3f80_0000;
62        f32::from_bits(bits) - 1.0
63    }
64
65    /// Standard normal via Box-Muller transform.
66    #[inline(always)]
67    pub fn normal(&mut self) -> f32 {
68        let u1 = self.uniform().max(1e-10);
69        let u2 = self.uniform();
70        (-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos()
71    }
72}
73
74#[cfg(test)]
75mod tests_rng {
76    use super::*;
77
78    /// Issue 296 regression: `Rng::new(s).uniform()` must not be exactly `0.0`
79    /// on the first draw for any small seed. Before the SplitMix64 fix in
80    /// `Rng::new`, seeds `{0,1,2,3,42}` all produced `uniform() == 0.0` first.
81    #[test]
82    fn first_uniform_is_nonzero_for_small_seeds() {
83        for &seed in &[0u64, 1, 2, 3, 4, 5, 42, 100, 1337, 0xFFFF_FFFF] {
84            let first = Rng::new(seed).uniform();
85            assert_ne!(
86                first, 0.0,
87                "seed {seed}: first uniform() must not be 0.0 (Issue 296)"
88            );
89            assert!(
90                first > 0.0 && first < 1.0,
91                "seed {seed}: uniform() out of range: {first}"
92            );
93        }
94    }
95
96    /// `Rng::new(0)` must still produce a usable (non-zero, evolving) state —
97    /// XorShift64 with state 0 is stuck at 0 forever.
98    #[test]
99    fn new_zero_seed_is_not_stuck() {
100        let mut rng = Rng::new(0);
101        let a = rng.next();
102        let b = rng.next();
103        assert_ne!(a, 0, "Rng::new(0).next() must not be 0");
104        assert_ne!(a, b, "Rng::new(0) must evolve");
105    }
106
107    /// Same seed → same sequence (determinism preserved after SplitMix64).
108    #[test]
109    fn same_seed_same_sequence() {
110        let mut a = Rng::new(42);
111        let mut b = Rng::new(42);
112        for _ in 0..16 {
113            assert_eq!(a.uniform(), b.uniform());
114        }
115    }
116
117    /// Different small seeds → different first draws (the original defect:
118    /// seeds 1 and 2 produced identical sampler output because both yielded 0.0).
119    #[test]
120    fn different_small_seeds_diverge_immediately() {
121        let r1 = Rng::new(1).uniform();
122        let r2 = Rng::new(2).uniform();
123        let r3 = Rng::new(3).uniform();
124        assert_ne!(r1, r2, "seeds 1 and 2 must differ on first draw");
125        assert_ne!(r1, r3, "seeds 1 and 3 must differ on first draw");
126        assert_ne!(r2, r3, "seeds 2 and 3 must differ on first draw");
127    }
128
129    /// Lightweight χ² goodness-of-fit on the first 65_536 uniforms: bin into
130    /// 256 equal-width buckets and check the statistic is within reason.
131    /// This is not a full DIEHARD suite, but catches catastrophic bias.
132    #[test]
133    fn uniform_is_well_distributed() {
134        const BUCKETS: usize = 256;
135        const N: usize = 65_536;
136        let mut rng = Rng::new(42);
137        let mut counts = [0u32; BUCKETS];
138        for _ in 0..N {
139            let u = rng.uniform();
140            let idx = ((u * BUCKETS as f32) as usize).min(BUCKETS - 1);
141            counts[idx] += 1;
142        }
143        let expected = N as f64 / BUCKETS as f64;
144        let chi_sq: f64 = counts
145            .iter()
146            .map(|&c| {
147                let d = c as f64 - expected;
148                d * d
149            })
150            .sum::<f64>()
151            / expected;
152        // 255 degrees of freedom: 99% critical value ≈ 330.09; 1% ≈ 188.33.
153        // Use a generous upper bound of 400 to avoid flakes from V8/CI variance.
154        assert!(
155            chi_sq < 400.0,
156            "χ²={chi_sq:.1} exceeds 400 (255 DoF, 99.99%ile ≈ 336) — distribution is biased"
157        );
158    }
159}