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(¶ms, &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(¶ms, &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(¶ms, pop0, fitness, s, &mut rng);
663 let (trial, _) = strategy.ask(¶ms, &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}