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}