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}