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 [`rlevo_core::util::seed::splitmix64`], the
6//! single frozen mixer shared with [`SeedStream`]'s trial-level seed fan-out
7//! (ADR 0033). The two seed-derivation *schemes* remain independent: they
8//! share the mixer, not a derivation contract.
9//!
10//! [`SeedStream`]: rlevo_core::util::seed::SeedStream
11//!
12//! # Host-RNG convention
13//!
14//! All randomness in `rlevo-evolution` **must** go through [`seed_stream`].
15//! Do **not** use `B::seed(…) + Tensor::random(…)` for stochastic EA
16//! operators. Burn's backend-level RNG is a process-wide mutex; seeding it
17//! from inside an operator races with parallel test threads and with other
18//! concurrent strategy calls, making results non-reproducible and causing
19//! intermittent test failures. [`seed_stream`] returns a fully-isolated
20//! [`rand::rngs::StdRng`] whose state is private to the call site.
21
22use rand::SeedableRng;
23use rand::rngs::StdRng;
24use rlevo_core::util::seed::splitmix64;
25
26/// Tag identifying which evolutionary operation a sub-stream is for.
27///
28/// Mixing the purpose into the seed means that, within a single
29/// generation, selection and mutation draw from non-overlapping PRNG
30/// streams — critical for reproducing trial-level determinism across
31/// refactors that reorder operator calls.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33#[repr(u8)]
34pub enum SeedPurpose {
35 /// Initial population sampling.
36 Init = 0,
37 /// Parent selection.
38 Selection = 1,
39 /// Recombination / crossover.
40 Crossover = 2,
41 /// Mutation.
42 Mutation = 3,
43 /// Replacement / survivor selection.
44 Replacement = 4,
45 /// Differential-evolution trial-vector construction.
46 Trial = 5,
47 /// Catch-all for strategy-specific stochastic steps.
48 ///
49 /// # Isolation caveat
50 ///
51 /// Unlike the named purposes (each owning a distinct operator role),
52 /// `Other` is a **shared bucket** used by many unrelated strategies
53 /// (evolutionary programming, ES, NEAT, NAS, and the swarm family:
54 /// SALP/WOA/GWO/ABC/PSO/BAT). Two different strategies both passing
55 /// `Other` get the **same** domain constant, so their cross-strategy
56 /// isolation relies *entirely* on each call site passing a distinct
57 /// `base` (and/or `generation`) — typically a fresh `rng.next_u64()`.
58 /// If two `Other` call sites ever share the same `(base, generation)`,
59 /// their streams alias. Prefer a dedicated named variant for any operator
60 /// that needs guaranteed isolation within a fixed `(base, generation)`.
61 ///
62 /// Note: this variant's constant `0x9E37_79B9_7F4A_7C15` coincides with the
63 /// φ64 golden-ratio multiplier applied to `generation` in [`seed_stream`].
64 /// No concrete collision exists today (no purpose uses constant `0`, and
65 /// the generation term is multiplied), but it is a latent footgun — do not
66 /// assume the `Other` domain is independent of the generation axis.
67 Other = 6,
68 /// Local-search refinement (memetic algorithms).
69 ///
70 /// Used by [`crate::local_search`] searchers so a memetic wrapper's
71 /// per-individual refinement draws from a stream independent of the
72 /// inner strategy's selection/mutation/crossover/replacement streams.
73 LocalSearch = 7,
74 /// Model sampling in estimation-of-distribution (EDA) strategies.
75 ///
76 /// Used by [`crate::algorithms::eda`] so each generation draws its new
77 /// population from an independent per-generation stream, isolated from
78 /// every other operator purpose.
79 EdaSampling = 8,
80 /// Representative selection in cooperative co-evolution (CCGA).
81 ///
82 /// Used by [`crate::coevolution`] so the `Random` and `Archive`
83 /// representative-selection policies draw their opposing-population
84 /// representatives from a stream independent of either sub-strategy's
85 /// selection/mutation/crossover/replacement streams.
86 Representative = 9,
87 /// Transposition operators (gene expression programming).
88 ///
89 /// Used by [`crate::algorithms::gep`] so IS/RIS transposition draws from a
90 /// stream independent of the point-mutation ([`Mutation`](Self::Mutation))
91 /// and crossover ([`Crossover`](Self::Crossover)) streams within the same
92 /// generation.
93 Transposition = 10,
94 /// Multivariate-Gaussian sampling in covariance-matrix strategies.
95 ///
96 /// Used by [`crate::algorithms::cma_es`] and
97 /// [`crate::algorithms::cmsa_es`] so each generation draws its `N(m, σ²C)`
98 /// offspring (and, for CMSA-ES, the per-individual log-normal σ mutations)
99 /// from a stream independent of every other operator purpose.
100 CmaSampling = 11,
101}
102
103impl SeedPurpose {
104 const fn constant(self) -> u64 {
105 match self {
106 SeedPurpose::Init => 0xA5A5_A5A5_A5A5_A5A5,
107 SeedPurpose::Selection => 0x1234_5678_9ABC_DEF0,
108 SeedPurpose::Crossover => 0xDEAD_BEEF_CAFE_F00D,
109 SeedPurpose::Mutation => 0xFEED_FACE_0BAD_F00D,
110 SeedPurpose::Replacement => 0x0123_4567_89AB_CDEF,
111 SeedPurpose::Trial => 0xBAAD_F00D_DEAD_C0DE,
112 SeedPurpose::Other => 0x9E37_79B9_7F4A_7C15,
113 SeedPurpose::LocalSearch => 0xC0FF_EE15_600D_F00D,
114 SeedPurpose::EdaSampling => 0xEDA0_5EED_BEEF_CAFE,
115 SeedPurpose::Representative => 0xC0EA_5E1E_C7ED_0009,
116 SeedPurpose::Transposition => 0x7A05_9051_70F0_000A,
117 SeedPurpose::CmaSampling => 0xC3A0_5EED_1AC0_B11B,
118 }
119 }
120}
121
122/// Derives a seeded PRNG from a base seed, generation counter, and purpose.
123///
124/// Each combination of `(base, generation, purpose)` produces an
125/// independent [`rand::rngs::StdRng`]; repeated calls with the same
126/// arguments return bit-identical sequences.
127///
128/// The mixer is two rounds of splitmix64 applied to
129/// `base + generation * φ64 + purpose_constant`, where φ64 is the
130/// 64-bit golden-ratio constant `0x9E3779B97F4A7C15`. This produces
131/// well-distributed seeds even for small or sequential inputs.
132///
133/// # Examples
134///
135/// ```
136/// use rand::Rng;
137/// use rlevo_evolution::rng::{seed_stream, SeedPurpose};
138///
139/// // Same arguments → identical stream.
140/// let mut a = seed_stream(42, 0, SeedPurpose::Mutation);
141/// let mut b = seed_stream(42, 0, SeedPurpose::Mutation);
142/// assert_eq!(a.next_u64(), b.next_u64());
143///
144/// // Different purposes → independent streams from the same base/generation.
145/// let first_mutation = seed_stream(42, 0, SeedPurpose::Mutation).next_u64();
146/// let first_selection = seed_stream(42, 0, SeedPurpose::Selection).next_u64();
147/// assert_ne!(first_mutation, first_selection);
148/// ```
149#[must_use]
150pub fn seed_stream(base: u64, generation: u64, purpose: SeedPurpose) -> StdRng {
151 let mut x = base
152 .wrapping_add(generation.wrapping_mul(0x9E37_79B9_7F4A_7C15))
153 .wrapping_add(purpose.constant());
154 x = splitmix64(x);
155 x = splitmix64(x);
156 StdRng::seed_from_u64(x)
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use rand::{Rng, RngExt};
163
164 #[test]
165 fn seed_stream_is_deterministic() {
166 let mut a = seed_stream(42, 0, SeedPurpose::Init);
167 let mut b = seed_stream(42, 0, SeedPurpose::Init);
168 for _ in 0..8 {
169 assert_eq!(a.next_u64(), b.next_u64());
170 }
171 }
172
173 // All `SeedPurpose` variants, kept exhaustive by `_exhaustiveness_guard`.
174 const ALL_PURPOSES: [SeedPurpose; 12] = [
175 SeedPurpose::Init,
176 SeedPurpose::Selection,
177 SeedPurpose::Crossover,
178 SeedPurpose::Mutation,
179 SeedPurpose::Replacement,
180 SeedPurpose::Trial,
181 SeedPurpose::Other,
182 SeedPurpose::LocalSearch,
183 SeedPurpose::EdaSampling,
184 SeedPurpose::Representative,
185 SeedPurpose::Transposition,
186 SeedPurpose::CmaSampling,
187 ];
188
189 // Compile-time guard: adding a variant makes this match non-exhaustive,
190 // forcing `ALL_PURPOSES` (and its length) to be updated in lock-step.
191 #[allow(dead_code)]
192 fn _exhaustiveness_guard(p: SeedPurpose) {
193 match p {
194 SeedPurpose::Init
195 | SeedPurpose::Selection
196 | SeedPurpose::Crossover
197 | SeedPurpose::Mutation
198 | SeedPurpose::Replacement
199 | SeedPurpose::Trial
200 | SeedPurpose::Other
201 | SeedPurpose::LocalSearch
202 | SeedPurpose::EdaSampling
203 | SeedPurpose::Representative
204 | SeedPurpose::Transposition
205 | SeedPurpose::CmaSampling => {}
206 }
207 }
208
209 #[test]
210 fn all_purpose_domain_constants_are_pairwise_distinct() {
211 // Stronger than stream distinctness: catches the root cause (a
212 // duplicated or zero domain constant) directly, independent of the
213 // mixer.
214 for (i, &p) in ALL_PURPOSES.iter().enumerate() {
215 for &q in &ALL_PURPOSES[i + 1..] {
216 assert_ne!(
217 p.constant(),
218 q.constant(),
219 "domain constants collide for {p:?} and {q:?}"
220 );
221 }
222 }
223 }
224
225 #[test]
226 fn all_purposes_produce_distinct_first_draws() {
227 // Exhaustive all-pairs over every `SeedPurpose` variant at a fixed
228 // base/generation.
229 for (i, &p) in ALL_PURPOSES.iter().enumerate() {
230 let first_p = seed_stream(42, 0, p).next_u64();
231 for &q in &ALL_PURPOSES[i + 1..] {
232 let first_q = seed_stream(42, 0, q).next_u64();
233 assert_ne!(first_p, first_q, "first draws collide for {p:?} and {q:?}");
234 }
235 }
236 }
237
238 #[test]
239 fn different_generations_produce_different_streams() {
240 let a = seed_stream(42, 0, SeedPurpose::Mutation).next_u64();
241 let b = seed_stream(42, 1, SeedPurpose::Mutation).next_u64();
242 assert_ne!(a, b);
243 }
244
245 #[test]
246 fn different_bases_produce_different_streams() {
247 let a = seed_stream(1, 0, SeedPurpose::Init).next_u64();
248 let b = seed_stream(2, 0, SeedPurpose::Init).next_u64();
249 assert_ne!(a, b);
250 }
251
252 #[test]
253 fn rng_generates_bounded_values() {
254 let mut rng = seed_stream(7, 0, SeedPurpose::Init);
255 let x: u32 = rng.random_range(0..100);
256 assert!(x < 100);
257 }
258}