Skip to main content

simu/
rng.rs

1// SPDX-FileCopyrightText: Copyright (c) Siemens 2026 contributed by Christoph Kuhmuench christoph.kuhmuench@gmail.com
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Pluggable randomness sources and a portable, deterministic feed.
6//!
7//! By default a [`SimEnv`](crate::SimEnv) draws from `rand`'s `StdRng`. For
8//! cross-language reproducibility (e.g. the SimPy comparison harness) the env
9//! can instead be driven from any [`RandomSource`] via
10//! [`SimEnv::with_source`](crate::SimEnv::with_source).
11//!
12//! This module ships [`SplitMix64`], a tiny portable PRNG whose output stream
13//! is defined purely by integer arithmetic, so it can be re-implemented
14//! byte-for-byte in another language (see `compare/models/_feed.py`). Combined
15//! with the closed-form transforms in [`sample`], two engines seeded with the
16//! same value draw the *same* numbers and turn them into the *same* samples.
17//!
18//! The two layers compose: every [`sample`] function takes any `RngCore`, so it
19//! works over `StdRng` or a [`SplitMix64`] feed alike.
20
21use rand::rngs::StdRng;
22use rand::{RngCore, SeedableRng};
23
24/// A pluggable source of randomness for a [`SimEnv`](crate::SimEnv).
25///
26/// The `RngCore` supertrait means `env.rng().sample(dist)` keeps working for
27/// every source. Implementations that are seed-based override [`reseed`] so
28/// that [`SimEnv::set_seed`](crate::SimEnv::set_seed) can restart their stream;
29/// sources that are not seed-based (e.g. a recorded-data feed) may keep the
30/// default, which panics — consistent with the crate's "programming error ⇒
31/// panic" policy.
32///
33/// [`reseed`]: RandomSource::reseed
34pub trait RandomSource: RngCore {
35    /// Re-seed the source deterministically, restarting its stream.
36    ///
37    /// The default implementation panics. Override it for seed-based sources.
38    fn reseed(&mut self, seed: u64) {
39        let _ = seed;
40        panic!("this random source does not support reseeding");
41    }
42}
43
44impl RandomSource for StdRng {
45    fn reseed(&mut self, seed: u64) {
46        *self = StdRng::seed_from_u64(seed);
47    }
48}
49
50/// A portable, deterministic PRNG (SplitMix64).
51///
52/// SplitMix64 is a well-known generator whose output is defined entirely by
53/// wrapping `u64` arithmetic with fixed constants, so it can be re-implemented
54/// identically in any language. `simu` uses it as the shared "external feed"
55/// that lets a Rust run and a Python run draw the same number stream from the
56/// same seed.
57///
58/// It is fast and has good statistical quality for simulation, but it is *not*
59/// cryptographically secure — do not use it where unpredictability matters.
60#[derive(Debug, Clone)]
61pub struct SplitMix64 {
62    state: u64,
63}
64
65impl SplitMix64 {
66    /// Create a feed seeded with `seed`.
67    #[must_use]
68    pub fn new(seed: u64) -> Self {
69        SplitMix64 { state: seed }
70    }
71
72    /// Reset the feed to `seed`, restarting the stream from the beginning.
73    pub fn set_seed(&mut self, seed: u64) {
74        self.state = seed;
75    }
76
77    /// Advance the state and return the next 64-bit output.
78    #[inline]
79    fn next(&mut self) -> u64 {
80        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
81        let mut z = self.state;
82        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
83        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
84        z ^ (z >> 31)
85    }
86}
87
88impl RngCore for SplitMix64 {
89    fn next_u64(&mut self) -> u64 {
90        self.next()
91    }
92
93    fn next_u32(&mut self) -> u32 {
94        // Take the high 32 bits — mirrored exactly on the Python side.
95        (self.next() >> 32) as u32
96    }
97
98    fn fill_bytes(&mut self, dest: &mut [u8]) {
99        let mut chunks = dest.chunks_exact_mut(8);
100        for chunk in &mut chunks {
101            chunk.copy_from_slice(&self.next().to_le_bytes());
102        }
103        let rem = chunks.into_remainder();
104        if !rem.is_empty() {
105            let bytes = self.next().to_le_bytes();
106            rem.copy_from_slice(&bytes[..rem.len()]);
107        }
108    }
109}
110
111impl RandomSource for SplitMix64 {
112    fn reseed(&mut self, seed: u64) {
113        self.state = seed;
114    }
115}
116
117/// Closed-form sampling transforms shared with the Python comparison harness.
118///
119/// Each function consumes raw output from any `RngCore` and applies a transform
120/// that is mirrored *exactly* in `compare/models/_feed.py`. Driving both engines
121/// from a [`SplitMix64`] feed plus these transforms makes their per-draw samples
122/// agree to floating-point tolerance.
123///
124/// ```
125/// use simu::rng::{sample, SplitMix64};
126/// let mut feed = SplitMix64::new(42);
127/// let u = sample::uniform01(&mut feed); // in [0, 1)
128/// assert!((0.0..1.0).contains(&u));
129/// ```
130pub mod sample {
131    use rand::RngCore;
132
133    /// 2^-53, used to map a 53-bit integer into `[0, 1)`.
134    const TWO_POW_NEG_53: f64 = 1.0 / 9_007_199_254_740_992.0;
135
136    /// Draw a uniform `f64` in `[0, 1)` using the top 53 bits of a `u64`.
137    ///
138    /// Matches NumPy's `random_double` construction so the value is identical
139    /// to the Python feed.
140    pub fn uniform01<R: RngCore + ?Sized>(rng: &mut R) -> f64 {
141        ((rng.next_u64() >> 11) as f64) * TWO_POW_NEG_53
142    }
143
144    /// Draw an exponential variate with the given `mean` via inverse-CDF.
145    ///
146    /// `-mean * ln(1 - u)`, computed with `ln_1p(-u)` for accuracy.
147    pub fn exponential<R: RngCore + ?Sized>(rng: &mut R, mean: f64) -> f64 {
148        let u = uniform01(rng);
149        -mean * (-u).ln_1p()
150    }
151
152    /// Draw a Bernoulli trial that is `true` with probability `p`.
153    pub fn bernoulli<R: RngCore + ?Sized>(rng: &mut R, p: f64) -> bool {
154        uniform01(rng) < p
155    }
156
157    /// Draw a normal variate via Box–Muller, consuming exactly two uniforms.
158    ///
159    /// Only the cosine arm is used (no caching of the sine arm) so the draw
160    /// count per call is fixed and matches the Python feed.
161    pub fn normal<R: RngCore + ?Sized>(rng: &mut R, mean: f64, std: f64) -> f64 {
162        let u1 = uniform01(rng);
163        let u2 = uniform01(rng);
164        let r = (-2.0 * (-u1).ln_1p()).sqrt();
165        let z = r * (std::f64::consts::TAU * u2).cos();
166        mean + std * z
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    // Shared cross-language known-answer table. The same literals are asserted
175    // in `compare/models/test_feed.py`; if either implementation drifts, one of
176    // the two test suites fails. Values are the canonical SplitMix64 outputs.
177    const KAT_SEED0: [u64; 6] = [
178        0xE220_A839_7B1D_CDAF,
179        0x6E78_9E6A_A1B9_65F4,
180        0x06C4_5D18_8009_454F,
181        0xF88B_B8A8_724C_81EC,
182        0x1B39_896A_51A8_749B,
183        0x53CB_9F0C_747E_A2EA,
184    ];
185    const KAT_SEED42: [u64; 6] = [
186        0xBDD7_3226_2FEB_6E95,
187        0x28EF_E333_B266_F103,
188        0x4752_6757_130F_9F52,
189        0x581C_E1FF_0E4A_E394,
190        0x09BC_585A_2448_23F2,
191        0xDE44_31FA_3C80_DB06,
192    ];
193
194    #[test]
195    fn splitmix64_known_answer_vectors() {
196        for (seed, expected) in [(0u64, KAT_SEED0), (42u64, KAT_SEED42)] {
197            let mut rng = SplitMix64::new(seed);
198            for &want in &expected {
199                assert_eq!(rng.next_u64(), want, "seed {seed}");
200            }
201        }
202    }
203
204    #[test]
205    fn uniform01_and_exponential_known_answer() {
206        let mut rng = SplitMix64::new(0);
207        let u = sample::uniform01(&mut rng);
208        assert_eq!(u, 0.8833108082136426);
209
210        let mut rng = SplitMix64::new(0);
211        let e = sample::exponential(&mut rng, 1.0);
212        assert_eq!(e, 2.148241359348383);
213    }
214
215    #[test]
216    fn uniform01_in_unit_interval() {
217        let mut rng = SplitMix64::new(7);
218        for _ in 0..10_000 {
219            let u = sample::uniform01(&mut rng);
220            assert!((0.0..1.0).contains(&u));
221        }
222    }
223
224    #[test]
225    fn next_u32_is_high_bits_of_next_u64() {
226        let expected = (KAT_SEED0[0] >> 32) as u32;
227        let mut rng = SplitMix64::new(0);
228        assert_eq!(rng.next_u32(), expected);
229    }
230
231    #[test]
232    fn same_seed_same_stream() {
233        let mut a = SplitMix64::new(123);
234        let mut b = SplitMix64::new(123);
235        for _ in 0..1000 {
236            assert_eq!(a.next_u64(), b.next_u64());
237        }
238    }
239
240    #[test]
241    fn reseed_restarts_stream() {
242        let mut rng = SplitMix64::new(0);
243        let first = rng.next_u64();
244        let _ = rng.next_u64();
245        rng.reseed(0);
246        assert_eq!(rng.next_u64(), first);
247        rng.set_seed(0);
248        assert_eq!(rng.next_u64(), first);
249    }
250
251    #[test]
252    fn exponential_mean_is_sane() {
253        let mut rng = SplitMix64::new(99);
254        let n = 200_000;
255        let sum: f64 = (0..n).map(|_| sample::exponential(&mut rng, 5.0)).sum();
256        let mean = sum / f64::from(n);
257        assert!((mean - 5.0).abs() < 0.1, "mean was {mean}");
258    }
259
260    #[test]
261    fn normal_consumes_two_draws_and_is_centered() {
262        // Two normals consume four uniforms; verify the draw count is fixed.
263        let mut a = SplitMix64::new(5);
264        let _ = sample::normal(&mut a, 0.0, 1.0);
265        let after_one = a.clone().next_u64();
266
267        let mut b = SplitMix64::new(5);
268        sample::uniform01(&mut b);
269        sample::uniform01(&mut b);
270        assert_eq!(after_one, b.next_u64());
271
272        let mut rng = SplitMix64::new(1);
273        let n = 200_000;
274        let sum: f64 = (0..n).map(|_| sample::normal(&mut rng, 10.0, 2.0)).sum();
275        let mean = sum / f64::from(n);
276        assert!((mean - 10.0).abs() < 0.05, "mean was {mean}");
277    }
278
279    #[test]
280    fn stdrng_reseed_is_deterministic() {
281        let mut a = StdRng::seed_from_u64(0);
282        a.reseed(77);
283        let mut b = StdRng::seed_from_u64(77);
284        assert_eq!(a.next_u64(), b.next_u64());
285    }
286}