Skip to main content

rlevo_evolution/
rng.rs

1//! Deterministic seed derivation for strategies.
2//!
3//! Callers derive sub-seeds by mixing a `base`, a `generation` index, and
4//! a [`SeedPurpose`] so parallel streams (selection, mutation, crossover)
5//! do not alias. The mixer is splitmix64, matching the algorithm used by
6//! [`rlevo_core::util::seed::SeedStream`] for trial-level seed fan-out.
7//!
8//! # Host-RNG convention
9//!
10//! All randomness in `rlevo-evolution` **must** go through [`seed_stream`].
11//! Do **not** use `B::seed(…) + Tensor::random(…)` for stochastic EA
12//! operators. Burn's backend-level RNG is a process-wide mutex; seeding it
13//! from inside an operator races with parallel test threads and with other
14//! concurrent strategy calls, making results non-reproducible and causing
15//! intermittent test failures. [`seed_stream`] returns a fully-isolated
16//! [`rand::rngs::StdRng`] whose state is private to the call site.
17
18use rand::rngs::StdRng;
19use rand::SeedableRng;
20
21/// Tag identifying which evolutionary operation a sub-stream is for.
22///
23/// Mixing the purpose into the seed means that, within a single
24/// generation, selection and mutation draw from non-overlapping PRNG
25/// streams — critical for reproducing trial-level determinism across
26/// refactors that reorder operator calls.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[repr(u8)]
29pub enum SeedPurpose {
30    /// Initial population sampling.
31    Init = 0,
32    /// Parent selection.
33    Selection = 1,
34    /// Recombination / crossover.
35    Crossover = 2,
36    /// Mutation.
37    Mutation = 3,
38    /// Replacement / survivor selection.
39    Replacement = 4,
40    /// Differential-evolution trial-vector construction.
41    Trial = 5,
42    /// Catch-all for strategy-specific stochastic steps.
43    Other = 6,
44}
45
46impl SeedPurpose {
47    const fn constant(self) -> u64 {
48        match self {
49            SeedPurpose::Init => 0xA5A5_A5A5_A5A5_A5A5,
50            SeedPurpose::Selection => 0x1234_5678_9ABC_DEF0,
51            SeedPurpose::Crossover => 0xDEAD_BEEF_CAFE_F00D,
52            SeedPurpose::Mutation => 0xFEED_FACE_0BAD_F00D,
53            SeedPurpose::Replacement => 0x0123_4567_89AB_CDEF,
54            SeedPurpose::Trial => 0xBAAD_F00D_DEAD_C0DE,
55            SeedPurpose::Other => 0x9E37_79B9_7F4A_7C15,
56        }
57    }
58}
59
60/// Derives a seeded PRNG from a base seed, generation counter, and purpose.
61///
62/// Each combination of `(base, generation, purpose)` produces an
63/// independent [`rand::rngs::StdRng`]; repeated calls with the same
64/// arguments return bit-identical sequences.
65///
66/// The mixer is two rounds of splitmix64 applied to
67/// `base + generation * φ64 + purpose_constant`, where φ64 is the
68/// 64-bit golden-ratio constant `0x9E3779B97F4A7C15`. This produces
69/// well-distributed seeds even for small or sequential inputs.
70///
71/// # Examples
72///
73/// ```
74/// use rand::Rng;
75/// use rlevo_evolution::rng::{seed_stream, SeedPurpose};
76///
77/// // Same arguments → identical stream.
78/// let mut a = seed_stream(42, 0, SeedPurpose::Mutation);
79/// let mut b = seed_stream(42, 0, SeedPurpose::Mutation);
80/// assert_eq!(a.next_u64(), b.next_u64());
81///
82/// // Different purposes → independent streams from the same base/generation.
83/// let first_mutation  = seed_stream(42, 0, SeedPurpose::Mutation).next_u64();
84/// let first_selection = seed_stream(42, 0, SeedPurpose::Selection).next_u64();
85/// assert_ne!(first_mutation, first_selection);
86/// ```
87#[must_use]
88pub fn seed_stream(base: u64, generation: u64, purpose: SeedPurpose) -> StdRng {
89    let mut x = base
90        .wrapping_add(generation.wrapping_mul(0x9E37_79B9_7F4A_7C15))
91        .wrapping_add(purpose.constant());
92    x = splitmix64(x);
93    x = splitmix64(x);
94    StdRng::seed_from_u64(x)
95}
96
97const fn splitmix64(mut x: u64) -> u64 {
98    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
99    x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
100    x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
101    x ^ (x >> 31)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use rand::{Rng, RngExt};
108
109    #[test]
110    fn seed_stream_is_deterministic() {
111        let mut a = seed_stream(42, 0, SeedPurpose::Init);
112        let mut b = seed_stream(42, 0, SeedPurpose::Init);
113        for _ in 0..8 {
114            assert_eq!(a.next_u64(), b.next_u64());
115        }
116    }
117
118    #[test]
119    fn different_purposes_produce_different_streams() {
120        let a = seed_stream(42, 0, SeedPurpose::Init).next_u64();
121        let b = seed_stream(42, 0, SeedPurpose::Selection).next_u64();
122        let c = seed_stream(42, 0, SeedPurpose::Mutation).next_u64();
123        assert_ne!(a, b);
124        assert_ne!(a, c);
125        assert_ne!(b, c);
126    }
127
128    #[test]
129    fn different_generations_produce_different_streams() {
130        let a = seed_stream(42, 0, SeedPurpose::Mutation).next_u64();
131        let b = seed_stream(42, 1, SeedPurpose::Mutation).next_u64();
132        assert_ne!(a, b);
133    }
134
135    #[test]
136    fn different_bases_produce_different_streams() {
137        let a = seed_stream(1, 0, SeedPurpose::Init).next_u64();
138        let b = seed_stream(2, 0, SeedPurpose::Init).next_u64();
139        assert_ne!(a, b);
140    }
141
142    #[test]
143    fn rng_generates_bounded_values() {
144        let mut rng = seed_stream(7, 0, SeedPurpose::Init);
145        let x: u32 = rng.random_range(0..100);
146        assert!(x < 100);
147    }
148}