Skip to main content

rlevo_evolution/algorithms/
de.rs

1//! Differential Evolution.
2//!
3//! Classical DE over `Tensor<B, 2>` populations with all common
4//! mutation/crossover variants enumerated in [`DeVariant`].
5//!
6//! # Variants
7//!
8//! | Variant | Mutation formula |
9//! |---|---|
10//! | [`DeVariant::Rand1Bin`], [`DeVariant::Rand1Exp`] | `v = x_{r1} + F · (x_{r2} − x_{r3})` |
11//! | [`DeVariant::Best1Bin`] | `v = x_{best} + F · (x_{r2} − x_{r3})` |
12//! | [`DeVariant::CurrentToBest1Bin`] | `v = x_i + F · (x_{best} − x_i) + F · (x_{r1} − x_{r2})` |
13//! | [`DeVariant::Rand2Bin`] | `v = x_{r1} + F · (x_{r2} − x_{r3}) + F · (x_{r4} − x_{r5})` |
14//!
15//! The suffix `Bin`/`Exp` selects between binomial and exponential
16//! crossover. All index draws reject repeated and self-referential
17//! indices.
18//!
19//! # Hot path
20//!
21//! A fused `CubeCL` kernel for trial-vector construction is tracked as
22//! follow-up work (see [`crate::ops::kernels`]). Until then this module
23//! uses host-sampled indices and composes the update from primitive
24//! tensor ops.
25//!
26//! # Reference
27//!
28//! - Storn & Price (1997), *Differential Evolution — A Simple and
29//!   Efficient Heuristic for Global Optimization over Continuous
30//!   Spaces*.
31
32use std::marker::PhantomData;
33
34use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
35use rand::{Rng, RngExt};
36
37use rlevo_core::bounds::Bounds;
38use rlevo_core::config::{self, ConfigError, Validate};
39
40use crate::ops::selection::argmax_host;
41use crate::rng::{SeedPurpose, seed_stream};
42use crate::strategy::{Strategy, StrategyMetrics};
43
44/// Mutation + crossover variant for differential evolution.
45///
46/// # Convergence caveats
47///
48/// Not every variant converges to machine precision on every landscape
49/// within the same budget. On unimodal landscapes like Sphere,
50/// [`Best1Bin`](DeVariant::Best1Bin) and
51/// [`CurrentToBest1Bin`](DeVariant::CurrentToBest1Bin) tend to
52/// **converge prematurely**: the population collapses around the
53/// current best before the differential search has fully explored, and
54/// the per-generation variance `F · (x_{r2} − x_{r3})` shrinks to zero.
55/// Classical DE literature documents this as the core trade-off of
56/// best-biased variants. The crate's integration tests therefore only
57/// require strong *reduction* from the random baseline for those
58/// variants, not optimality — see
59/// `algorithms::de::tests::all_variants_converge_on_sphere_d10` for the
60/// per-variant tolerance choice.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum DeVariant {
63    /// `x_{r1} + F · (x_{r2} − x_{r3})`, binomial crossover. Balanced
64    /// exploration / exploitation; reaches machine precision on Sphere
65    /// within a few hundred generations.
66    Rand1Bin,
67    /// `x_{best} + F · (x_{r2} − x_{r3})`, binomial crossover.
68    ///
69    /// Strong exploitation — the mutation base is always the current
70    /// best, so the population concentrates quickly. Prone to
71    /// **premature convergence** on landscapes where the current best
72    /// is far from the global optimum; on Sphere-D10 with 500 gens this
73    /// variant stalls around `best_fitness ≈ 1` while `Rand1Bin` reaches
74    /// `< 1e-20`.
75    Best1Bin,
76    /// `x_i + F · (x_{best} − x_i) + F · (x_{r1} − x_{r2})`, binomial.
77    ///
78    /// Hybrid of the current individual and the best-so-far. Still
79    /// **prone to premature convergence** because the
80    /// `F · (x_{best} − x_i)` term dominates once the population is
81    /// near the best. Useful on multimodal landscapes where pure-best
82    /// variants get stuck in local basins, less useful on Sphere.
83    CurrentToBest1Bin,
84    /// `x_{r1} + F · (x_{r2} − x_{r3}) + F · (x_{r4} − x_{r5})`,
85    /// binomial. Higher variance than `Rand1Bin` thanks to two
86    /// difference vectors; converges on Sphere but more slowly.
87    Rand2Bin,
88    /// `x_{r1} + F · (x_{r2} − x_{r3})`, exponential crossover.
89    /// Identical mutation to `Rand1Bin`, different crossover mask shape.
90    /// Performance comparable to `Rand1Bin` in practice.
91    Rand1Exp,
92}
93
94impl DeVariant {
95    /// Number of distinct random indices the variant needs (in
96    /// addition to the current individual `i`).
97    const fn random_indices(self) -> usize {
98        match self {
99            DeVariant::Rand1Bin | DeVariant::Rand1Exp => 3,
100            DeVariant::Best1Bin | DeVariant::CurrentToBest1Bin => 2,
101            DeVariant::Rand2Bin => 5,
102        }
103    }
104
105    /// Whether this variant uses exponential crossover.
106    const fn is_exponential(self) -> bool {
107        matches!(self, DeVariant::Rand1Exp)
108    }
109}
110
111/// Static configuration for a [`DifferentialEvolution`] run.
112#[derive(Debug, Clone)]
113pub struct DeConfig {
114    /// Population size (≥ 5 for `Rand2Bin`, ≥ 4 otherwise).
115    pub pop_size: usize,
116    /// Genome dimensionality.
117    pub genome_dim: usize,
118    /// Search-space bounds (initialization and clamping).
119    pub bounds: Bounds,
120    /// Differential weight (F). Typical range [0.4, 0.9].
121    pub f: f32,
122    /// Crossover probability (CR). Typical range [0.1, 0.9].
123    pub cr: f32,
124    /// Variant.
125    pub variant: DeVariant,
126}
127
128impl DeConfig {
129    /// Default configuration (`Rand1Bin`, F = 0.5, CR = 0.9) for a given
130    /// dimensionality.
131    #[must_use]
132    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
133        Self {
134            pop_size,
135            genome_dim,
136            bounds: Bounds::new(-5.12, 5.12),
137            f: 0.5,
138            cr: 0.9,
139            variant: DeVariant::Rand1Bin,
140        }
141    }
142}
143
144impl Validate for DeConfig {
145    fn validate(&self) -> Result<(), ConfigError> {
146        const C: &str = "DeConfig";
147        let min_pop = if self.variant == DeVariant::Rand2Bin {
148            5
149        } else {
150            4
151        };
152        config::at_least(C, "pop_size", self.pop_size, min_pop)?;
153        config::nonzero(C, "genome_dim", self.genome_dim)?;
154        config::in_range(C, "f", 0.0, 2.0, f64::from(self.f))?;
155        config::in_range(C, "cr", 0.0, 1.0, f64::from(self.cr))?;
156        Ok(())
157    }
158}
159
160/// Generation state for [`DifferentialEvolution`].
161///
162/// The two-phase ask/tell handshake uses `fitness.is_empty()` as a
163/// sentinel: on the very first [`Strategy::ask`] call the initial
164/// population is returned unchanged; on the very first
165/// [`Strategy::tell`] call `fitness` is populated and
166/// `best_genome`/`best_fitness` are initialized. Subsequent
167/// ask/tell cycles produce and evaluate trial vectors.
168#[derive(Debug, Clone)]
169pub struct DeState<B: Backend> {
170    /// Current population, shape `(pop_size, D)`.
171    pub population: Tensor<B, 2>,
172    /// Host-side fitness cache for the current population.
173    ///
174    /// Empty before the first [`Strategy::tell`] call; length `pop_size`
175    /// thereafter. The `is_empty()` check is the sentinel that
176    /// distinguishes the initial evaluation phase from subsequent
177    /// trial-vector generations.
178    pub fitness: Vec<f32>,
179    /// Index of the current best individual within `population`.
180    pub best_index: usize,
181    /// Best-so-far genome, shape `(1, D)`.
182    ///
183    /// `None` before the first [`Strategy::tell`] call.
184    pub best_genome: Option<Tensor<B, 2>>,
185    /// Best-so-far fitness across all completed generations.
186    ///
187    /// `f32::NEG_INFINITY` before the first [`Strategy::tell`] call (the
188    /// worst value under the maximise convention).
189    pub best_fitness: f32,
190    /// Number of completed `tell` calls (zero-based generation index + 1).
191    pub generation: usize,
192}
193
194/// Classical DE/rand/1/bin (and friends).
195///
196/// # Example
197///
198/// ```no_run
199/// use burn::backend::Flex;
200/// use rlevo_evolution::algorithms::de::{DeConfig, DeVariant, DifferentialEvolution};
201///
202/// let strategy = DifferentialEvolution::<Flex>::new();
203/// let mut params = DeConfig::default_for(30, 10);
204/// params.variant = DeVariant::Rand1Bin;
205/// let _ = (strategy, params);
206/// ```
207#[derive(Debug, Clone, Copy, Default)]
208pub struct DifferentialEvolution<B: Backend> {
209    _backend: PhantomData<fn() -> B>,
210}
211
212impl<B: Backend> DifferentialEvolution<B> {
213    /// Builds a new (stateless) strategy object.
214    #[must_use]
215    pub fn new() -> Self {
216        Self {
217            _backend: PhantomData,
218        }
219    }
220
221    fn sample_initial_population(
222        params: &DeConfig,
223        rng: &mut dyn Rng,
224        device: &<B as burn::tensor::backend::BackendTypes>::Device,
225    ) -> Tensor<B, 2> {
226        let (lo, hi): (f32, f32) = params.bounds.into();
227        // Host-sample the initial population from a deterministic
228        // `seed_stream` rather than the process-wide Flex RNG (`B::seed` +
229        // `Tensor::random`), whose draws interleave with sibling tests under
230        // the parallel runner and are not reproducible across schedules.
231        let pop = params.pop_size;
232        let genome_dim = params.genome_dim;
233        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
234        let mut rows = Vec::with_capacity(pop * genome_dim);
235        for _ in 0..pop * genome_dim {
236            rows.push(lo + (hi - lo) * stream.random::<f32>());
237        }
238        Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
239    }
240
241    /// Samples `k` indices from `0..pop_size`, all distinct and all
242    /// different from `self_idx`.
243    ///
244    /// # Panics
245    ///
246    /// Panics if `pop_size <= k`, since the rejection loop cannot make
247    /// progress without enough candidates outside `self_idx`.
248    fn sample_distinct_excluding(
249        self_idx: usize,
250        pop_size: usize,
251        k: usize,
252        rng: &mut dyn Rng,
253    ) -> Vec<usize> {
254        assert!(
255            pop_size > k,
256            "DE: pop_size must exceed the number of distinct indices required"
257        );
258        let mut chosen = Vec::with_capacity(k);
259        while chosen.len() < k {
260            let candidate = rng.random_range(0..pop_size);
261            if candidate != self_idx && !chosen.contains(&candidate) {
262                chosen.push(candidate);
263            }
264        }
265        chosen
266    }
267}
268
269impl<B: Backend> Strategy<B> for DifferentialEvolution<B>
270where
271    B::Device: Clone,
272{
273    type Params = DeConfig;
274    type State = DeState<B>;
275    type Genome = Tensor<B, 2>;
276
277    /// Samples the initial population uniformly within `params.bounds`
278    /// and returns a [`DeState`] with an empty fitness cache, signalling
279    /// that the first ask/tell cycle should evaluate the initial
280    /// population rather than generate trial vectors.
281    ///
282    /// Initial sampling goes through [`seed_stream`] rather than
283    /// `B::seed + Tensor::random` to keep results reproducible across
284    /// parallel test threads.
285    fn init(
286        &self,
287        params: &DeConfig,
288        rng: &mut dyn Rng,
289        device: &<B as burn::tensor::backend::BackendTypes>::Device,
290    ) -> DeState<B> {
291        debug_assert!(
292            params.validate().is_ok(),
293            "invalid DeConfig reached init: {params:?}"
294        );
295        let population = Self::sample_initial_population(params, rng, device);
296        DeState {
297            population,
298            fitness: Vec::new(),
299            best_index: 0,
300            best_genome: None,
301            best_fitness: f32::NEG_INFINITY,
302            generation: 0,
303        }
304    }
305
306    /// Proposes the next population of candidate solutions.
307    ///
308    /// **First call (fitness cache empty):** returns the initial
309    /// population from [`DeState::population`] unchanged so the caller
310    /// can evaluate it before any mutation/crossover step.
311    ///
312    /// **Subsequent calls:** for each individual `i` in `0..pop_size`:
313    ///
314    /// 1. Sample the required number of distinct random indices
315    ///    (excluding `i`) via [`seed_stream`] with [`SeedPurpose::Trial`].
316    /// 2. Compute the mutant vector `v_i` according to
317    ///    [`DeConfig::variant`].
318    /// 3. Apply binomial or exponential crossover (also seeded through
319    ///    [`seed_stream`] with [`SeedPurpose::Crossover`]) to blend `v_i`
320    ///    with the current individual, ensuring at least one gene comes
321    ///    from `v_i` (`j_rand` guarantee).
322    /// 4. Clamp the trial genome to `params.bounds`.
323    ///
324    /// The returned state is a clone of the input state; no fitness
325    /// update occurs here — that happens in [`Strategy::tell`].
326    #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
327    fn ask(
328        &self,
329        params: &DeConfig,
330        state: &DeState<B>,
331        rng: &mut dyn Rng,
332        device: &<B as burn::tensor::backend::BackendTypes>::Device,
333    ) -> (Tensor<B, 2>, DeState<B>) {
334        // First call: evaluate the initial population.
335        if state.fitness.is_empty() {
336            return (state.population.clone(), state.clone());
337        }
338
339        let DeConfig {
340            pop_size,
341            genome_dim,
342            f,
343            cr,
344            variant,
345            ..
346        } = *params;
347
348        let mut trial_rng =
349            seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Trial);
350
351        // ------------------------------------------------------------------
352        // 1. Build the mutant vector v_i for every i, host-side gathers.
353        //    We assemble three index tensors (a, b, c [and d, e for rand2])
354        //    and do the arithmetic on-device in one sweep.
355        // ------------------------------------------------------------------
356        let k = variant.random_indices();
357        let mut rand_indices: Vec<Vec<usize>> =
358            (0..k).map(|_| Vec::with_capacity(pop_size)).collect();
359        for i in 0..pop_size {
360            let chosen = Self::sample_distinct_excluding(i, pop_size, k, &mut trial_rng);
361            for (j, idx) in chosen.into_iter().enumerate() {
362                rand_indices[j].push(idx);
363            }
364        }
365
366        let gather = |idxs: &[usize]| -> Tensor<B, 2> {
367            #[allow(clippy::cast_possible_wrap)]
368            let v: Vec<i64> = idxs.iter().map(|&i| i as i64).collect();
369            let t = Tensor::<B, 1, Int>::from_data(TensorData::new(v, [pop_size]), device);
370            state.population.clone().select(0, t)
371        };
372
373        let v = match variant {
374            DeVariant::Rand1Bin | DeVariant::Rand1Exp => {
375                let a = gather(&rand_indices[0]);
376                let b = gather(&rand_indices[1]);
377                let c = gather(&rand_indices[2]);
378                a + (b - c).mul_scalar(f)
379            }
380            DeVariant::Best1Bin => {
381                #[allow(clippy::single_range_in_vec_init)]
382                let best = state
383                    .population
384                    .clone()
385                    .slice([state.best_index..state.best_index + 1])
386                    .expand([pop_size, genome_dim]);
387                let b = gather(&rand_indices[0]);
388                let c = gather(&rand_indices[1]);
389                best + (b - c).mul_scalar(f)
390            }
391            DeVariant::CurrentToBest1Bin => {
392                #[allow(clippy::single_range_in_vec_init)]
393                let best = state
394                    .population
395                    .clone()
396                    .slice([state.best_index..state.best_index + 1])
397                    .expand([pop_size, genome_dim]);
398                let current = state.population.clone();
399                let a = gather(&rand_indices[0]);
400                let b = gather(&rand_indices[1]);
401                current.clone() + (best - current).mul_scalar(f) + (a - b).mul_scalar(f)
402            }
403            DeVariant::Rand2Bin => {
404                let a = gather(&rand_indices[0]);
405                let b = gather(&rand_indices[1]);
406                let c = gather(&rand_indices[2]);
407                let d = gather(&rand_indices[3]);
408                let e = gather(&rand_indices[4]);
409                a + (b - c).mul_scalar(f) + (d - e).mul_scalar(f)
410            }
411        };
412
413        // ------------------------------------------------------------------
414        // 2. Crossover: binomial or exponential. Always preserve at
415        //    least one mutant gene per row (j_rand).
416        // ------------------------------------------------------------------
417        let mut cross_rng = seed_stream(
418            rng.next_u64(),
419            state.generation as u64,
420            SeedPurpose::Crossover,
421        );
422        let mut cross_mask = vec![false; pop_size * genome_dim];
423        if variant.is_exponential() {
424            for row in 0..pop_size {
425                let start = cross_rng.random_range(0..genome_dim);
426                let mut len = 1;
427                while len < genome_dim && cross_rng.random::<f32>() < cr {
428                    len += 1;
429                }
430                for k in 0..len {
431                    let j = (start + k) % genome_dim;
432                    cross_mask[row * genome_dim + j] = true;
433                }
434            }
435        } else {
436            for row in 0..pop_size {
437                let j_rand = cross_rng.random_range(0..genome_dim);
438                for j in 0..genome_dim {
439                    if j == j_rand || cross_rng.random::<f32>() < cr {
440                        cross_mask[row * genome_dim + j] = true;
441                    }
442                }
443            }
444        }
445        #[allow(clippy::cast_possible_wrap)]
446        let mask_int: Vec<i64> = cross_mask.iter().map(|&b| i64::from(b)).collect();
447        let mask_tensor = Tensor::<B, 2, Int>::from_data(
448            TensorData::new(mask_int, [pop_size, genome_dim]),
449            device,
450        );
451        let mask_bool = mask_tensor.equal_elem(1);
452
453        // Where cross_mask == 1, take from v; otherwise from state.population.
454        let trial = state.population.clone().mask_where(mask_bool, v);
455        let (lo, hi): (f32, f32) = params.bounds.into();
456        let trial = trial.clamp(lo, hi);
457
458        (trial, state.clone())
459    }
460
461    /// Consumes the evaluated trial population and advances the state.
462    ///
463    /// **First call (fitness cache empty):** stores the initial
464    /// population's fitness, initializes `best_genome`/`best_fitness`,
465    /// and increments the generation counter. No replacement occurs
466    /// because there are no previous individuals to compare against.
467    ///
468    /// **Subsequent calls:** applies greedy per-slot replacement — each
469    /// trial individual replaces its corresponding current individual if
470    /// and only if `trial_fitness[i] >= state.fitness[i]`. The best-ever
471    /// genome and fitness are updated if the new generation improves on
472    /// `state.best_fitness`.
473    ///
474    /// Returns the updated [`DeState`] and a [`StrategyMetrics`] snapshot
475    /// covering the current generation's fitness distribution.
476    fn tell(
477        &self,
478        _params: &DeConfig,
479        trial: Tensor<B, 2>,
480        fitness: Tensor<B, 1>,
481        mut state: DeState<B>,
482        _rng: &mut dyn Rng,
483    ) -> (DeState<B>, StrategyMetrics) {
484        let fitness_host = fitness
485            .into_data()
486            .into_vec::<f32>()
487            .expect("fitness tensor must be readable as f32");
488
489        // First `tell`: stash fitness for the initial population.
490        if state.fitness.is_empty() {
491            state.fitness.clone_from(&fitness_host);
492            state.best_index = argmax_host(&fitness_host);
493            state.generation += 1;
494            update_best(&mut state, &trial, &fitness_host);
495            let m = StrategyMetrics::from_host_fitness(
496                state.generation,
497                &fitness_host,
498                state.best_fitness,
499            );
500            state.best_fitness = m.best_fitness_ever();
501            state.population = trial;
502            return (state, m);
503        }
504
505        // Greedy per-slot replacement: trial replaces current iff
506        // trial is at least as good (canonical: fitness no lower).
507        let device = trial.device();
508        let pop_size = state.fitness.len();
509        let mut replace_mask = vec![0i64; pop_size];
510        let mut new_fit = state.fitness.clone();
511        for i in 0..pop_size {
512            if fitness_host[i] >= state.fitness[i] {
513                replace_mask[i] = 1;
514                new_fit[i] = fitness_host[i];
515            }
516        }
517
518        let mask_int =
519            Tensor::<B, 1, Int>::from_data(TensorData::new(replace_mask, [pop_size]), &device);
520        let mask_bool_row = mask_int.equal_elem(1);
521        let genome_dim = state.population.dims()[1];
522        let mask_bool = mask_bool_row
523            .unsqueeze_dim::<2>(1)
524            .expand([pop_size, genome_dim]);
525        let next_pop = state
526            .population
527            .clone()
528            .mask_where(mask_bool, trial.clone());
529
530        state.population = next_pop;
531        state.fitness.clone_from(&new_fit);
532        state.best_index = argmax_host(&new_fit);
533        state.generation += 1;
534        update_best(&mut state, &trial, &fitness_host);
535        let m = StrategyMetrics::from_host_fitness(state.generation, &new_fit, state.best_fitness);
536        state.best_fitness = m.best_fitness_ever();
537        (state, m)
538    }
539
540    /// Returns the best-so-far genome and its canonical (maximise) fitness.
541    ///
542    /// Returns `None` before the first [`Strategy::tell`] call, when
543    /// `DeState::best_genome` is still `None`.
544    fn best(&self, state: &DeState<B>) -> Option<(Tensor<B, 2>, f32)> {
545        state
546            .best_genome
547            .as_ref()
548            .map(|g| (g.clone(), state.best_fitness))
549    }
550}
551
552fn update_best<B: Backend>(state: &mut DeState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
553    if fitness.is_empty() {
554        return;
555    }
556    let best_idx = argmax_host(fitness);
557    let best_f = fitness[best_idx];
558    if best_f > state.best_fitness {
559        let device = pop.device();
560        #[allow(clippy::cast_possible_wrap)]
561        let idx =
562            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
563        state.best_genome = Some(pop.clone().select(0, idx));
564        state.best_fitness = best_f;
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::fitness::FromFitnessEvaluable;
572    use crate::strategy::EvolutionaryHarness;
573    use burn::backend::Flex;
574    use rlevo_core::fitness::FitnessEvaluable;
575    type TestBackend = Flex;
576
577    #[test]
578    fn default_config_validates() {
579        assert!(DeConfig::default_for(30, 10).validate().is_ok());
580    }
581
582    #[test]
583    fn rejects_pop_size_below_min() {
584        let mut cfg = DeConfig::default_for(3, 10);
585        cfg.pop_size = 3;
586        assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
587    }
588
589    /// [`DifferentialEvolution::sample_distinct_excluding`] must return
590    /// exactly `k` indices that are pairwise distinct, all in `0..pop_size`,
591    /// and all different from `self_idx`. Swept over every valid
592    /// `(pop_size, k, self_idx)` triple for a handful of draws each so the
593    /// rejection loop is exercised broadly (`de` §7, operator property).
594    #[test]
595    fn sample_distinct_excluding_yields_valid_indices() {
596        use rand::SeedableRng;
597        use rand::rngs::StdRng;
598
599        let mut rng = StdRng::seed_from_u64(20_240_607);
600        for pop_size in [4usize, 5, 8, 20] {
601            // `k` never exceeds the largest per-variant index count (5, Rand2Bin)
602            // and must stay strictly below `pop_size`.
603            for k in 1..pop_size.min(6) {
604                for self_idx in 0..pop_size {
605                    for _ in 0..25 {
606                        let chosen: Vec<usize> =
607                            DifferentialEvolution::<TestBackend>::sample_distinct_excluding(
608                                self_idx, pop_size, k, &mut rng,
609                            );
610                        assert_eq!(chosen.len(), k, "must return exactly k indices");
611                        for (a, &x) in chosen.iter().enumerate() {
612                            assert!(x < pop_size, "index {x} out of range for pop {pop_size}");
613                            assert_ne!(x, self_idx, "index must differ from self_idx");
614                            for &y in &chosen[a + 1..] {
615                                assert_ne!(x, y, "indices must be pairwise distinct");
616                            }
617                        }
618                    }
619                }
620            }
621        }
622    }
623
624    /// The rejection loop cannot make progress when `pop_size <= k` (there are
625    /// not enough candidates outside `self_idx`); the documented `assert`
626    /// guards that with a panic rather than spinning forever (`de` §7).
627    #[test]
628    #[should_panic(expected = "pop_size must exceed")]
629    fn sample_distinct_excluding_panics_when_pop_too_small() {
630        use rand::SeedableRng;
631        use rand::rngs::StdRng;
632
633        let mut rng = StdRng::seed_from_u64(1);
634        // k == pop_size: impossible to draw k distinct indices excluding self.
635        let _ = DifferentialEvolution::<TestBackend>::sample_distinct_excluding(0, 3, 3, &mut rng);
636    }
637
638    /// Every gene of a generated trial vector stays inside `params.bounds`
639    /// after the mutation + crossover + clamp pipeline (`de` §7, bounds
640    /// handling). Drives the strategy one full ask/tell/ask cycle so the
641    /// second `ask` returns genuine trial vectors rather than the initial
642    /// population.
643    #[test]
644    fn trial_genes_stay_within_bounds() {
645        use rand::SeedableRng;
646        use rand::rngs::StdRng;
647
648        let device = Default::default();
649        let strategy = DifferentialEvolution::<TestBackend>::new();
650        let mut params = DeConfig::default_for(12, 4);
651        params.variant = DeVariant::Rand1Bin;
652        let (lo, hi): (f32, f32) = params.bounds.into();
653
654        let mut rng = StdRng::seed_from_u64(4242);
655        let state = strategy.init(&params, &mut rng, &device);
656        // First ask returns the initial population unchanged; a tell populates
657        // the fitness cache so the next ask produces trial vectors.
658        let (pop0, s) = strategy.ask(&params, &state, &mut rng, &device);
659        let n = pop0.dims()[0];
660        let fitness =
661            Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![0.0_f32; n], [n]), &device);
662        let (s, _) = strategy.tell(&params, pop0, fitness, s, &mut rng);
663        let (trial, _) = strategy.ask(&params, &s, &mut rng, &device);
664        let genes: Vec<f32> = trial
665            .into_data()
666            .into_vec::<f32>()
667            .expect("trial host-read of a tensor this test just built");
668        for g in genes {
669            assert!(
670                g.is_finite() && g >= lo && g <= hi,
671                "trial gene {g} left [{lo}, {hi}]"
672            );
673        }
674    }
675
676    /// Sphere landscape that returns `NaN` for half the domain. Exercises the
677    /// fitness-hygiene chokepoint (ADR 0034): a `NaN` fitness must never become
678    /// the reported best nor poison a population slot ("zombie slot").
679    struct NanSphere;
680    struct NanSphereFit;
681    impl FitnessEvaluable for NanSphereFit {
682        type Individual = Vec<f64>;
683        type Landscape = NanSphere;
684        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
685            let s: f64 = x.iter().map(|v| v * v).sum();
686            if x[0] > 0.0 { f64::NAN } else { s }
687        }
688    }
689
690    /// A fitness function that yields `NaN` for many genomes must not crash the
691    /// run and must never report a `NaN` (or otherwise non-finite) best. The
692    /// harness sanitizes `NaN → −∞` at the driver chokepoint, so the poisoned
693    /// slots can never out-rank a finite individual or block replacement
694    /// (`de` §7, NaN regression).
695    #[test]
696    fn nan_fitness_never_becomes_best() {
697        let device = Default::default();
698        let params = DeConfig::default_for(30, 4);
699        let fitness_fn = FromFitnessEvaluable::new(NanSphereFit, NanSphere);
700        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
701            DifferentialEvolution::<TestBackend>::new(),
702            params,
703            fitness_fn,
704            99,
705            device,
706            40,
707        )
708        .expect("valid params");
709        harness.reset();
710        loop {
711            if harness.step(()).done {
712                break;
713            }
714        }
715        let best = harness.latest_metrics().unwrap().best_fitness_ever();
716        assert!(
717            best.is_finite(),
718            "NaN fitness poisoned best_fitness_ever: {best}"
719        );
720    }
721
722    struct Sphere;
723    struct SphereFit;
724    impl FitnessEvaluable for SphereFit {
725        type Individual = Vec<f64>;
726        type Landscape = Sphere;
727        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
728            x.iter().map(|v| v * v).sum()
729        }
730    }
731
732    fn run_de(variant: DeVariant, dim: usize, gens: usize) -> f32 {
733        let device = Default::default();
734        let mut params = DeConfig::default_for(30, dim);
735        params.variant = variant;
736        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
737        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
738            DifferentialEvolution::<TestBackend>::new(),
739            params,
740            fitness_fn,
741            11,
742            device,
743            gens,
744        )
745        .expect("valid params");
746        harness.reset();
747        loop {
748            if harness.step(()).done {
749                break;
750            }
751        }
752        harness.latest_metrics().unwrap().best_fitness_ever()
753    }
754
755    /// All five DE variants converge on Sphere-D10 within budget.
756    ///
757    /// The Burn Flex backend seeds its RNG through a process-wide
758    /// mutex, so separate `#[test]` functions that call `Tensor::random`
759    /// race on seeding and produce non-deterministic trajectories. This
760    /// single test runs the variants sequentially inside one function
761    /// so their seed state is not contended.
762    ///
763    /// Per-variant tolerance reflects classical characterizations:
764    /// `rand1`/`rand2` converge to optimum, `best1` / current-to-best
765    /// suffer from premature convergence on unimodal landscapes.
766    #[test]
767    fn all_variants_converge_on_sphere_d10() {
768        let rand1bin = run_de(DeVariant::Rand1Bin, 10, 500);
769        assert!(rand1bin < 1e-6, "DE/rand/1/bin best={rand1bin}");
770
771        let rand2bin = run_de(DeVariant::Rand2Bin, 10, 800);
772        assert!(rand2bin < 1e-6, "DE/rand/2/bin best={rand2bin}");
773
774        let rand1exp = run_de(DeVariant::Rand1Exp, 10, 500);
775        assert!(rand1exp < 1e-6, "DE/rand/1/exp best={rand1exp}");
776
777        let best1bin = run_de(DeVariant::Best1Bin, 10, 500);
778        assert!(best1bin < 1.0, "DE/best/1/bin best={best1bin}");
779
780        let c2b = run_de(DeVariant::CurrentToBest1Bin, 10, 500);
781        assert!(c2b < 2.0, "DE/current-to-best/1/bin best={c2b}");
782    }
783}