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 [`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}