1use std::marker::PhantomData;
24
25use burn::tensor::{Tensor, TensorData, backend::Backend};
26use rand::Rng;
27use rand::RngExt;
28
29use rlevo_core::bounds::Bounds;
30use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
31
32use crate::ops::mutation::gaussian_mutation_per_row;
33use crate::ops::replacement::{mu_comma_lambda, mu_plus_lambda};
34use crate::ops::selection::argmax_host;
35use crate::rng::{SeedPurpose, seed_stream};
36use crate::strategy::{Strategy, StrategyMetrics};
37
38#[derive(Debug, Clone, Copy)]
40pub enum EsKind {
41 OnePlusOne,
43 OnePlusLambda { lambda: usize },
45 MuCommaLambda { mu: usize, lambda: usize },
47 MuPlusLambda { mu: usize, lambda: usize },
49}
50
51impl EsKind {
52 #[must_use]
54 pub fn population_size(&self) -> usize {
55 match self {
56 EsKind::OnePlusOne => 1,
57 EsKind::OnePlusLambda { lambda }
58 | EsKind::MuCommaLambda { lambda, .. }
59 | EsKind::MuPlusLambda { lambda, .. } => *lambda,
60 }
61 }
62}
63
64const DEFAULT_SIGMA_MIN: f32 = 1e-8;
67const DEFAULT_SIGMA_MAX: f32 = 1e6;
70
71#[derive(Debug, Clone)]
73pub struct EsConfig {
74 pub kind: EsKind,
76 pub genome_dim: usize,
78 pub bounds: Bounds,
80 pub initial_sigma: f32,
82 pub sigma_min: f32,
90 pub sigma_max: f32,
97 pub tau: f32,
100}
101
102impl EsConfig {
103 #[must_use]
109 pub fn default_for(kind: EsKind, genome_dim: usize) -> Self {
110 #[allow(clippy::cast_precision_loss)]
111 let d = genome_dim as f32;
112 let tau = 1.0 / (2.0 * d.sqrt()).sqrt();
113 Self {
114 kind,
115 genome_dim,
116 bounds: Bounds::new(-5.12, 5.12),
117 initial_sigma: 1.0,
118 sigma_min: DEFAULT_SIGMA_MIN,
119 sigma_max: DEFAULT_SIGMA_MAX,
120 tau,
121 }
122 }
123}
124
125impl Validate for EsConfig {
126 fn validate(&self) -> Result<(), ConfigError> {
127 const C: &str = "EsConfig";
128 config::nonzero(C, "genome_dim", self.genome_dim)?;
129 config::positive(C, "initial_sigma", f64::from(self.initial_sigma))?;
130 config::positive(C, "sigma_min", f64::from(self.sigma_min))?;
131 config::ordered(
132 C,
133 "sigma_max",
134 f64::from(self.sigma_min),
135 f64::from(self.sigma_max),
136 )?;
137 config::positive(C, "tau", f64::from(self.tau))?;
138 match self.kind {
139 EsKind::OnePlusOne => {}
140 EsKind::OnePlusLambda { lambda } => {
141 config::at_least(C, "lambda", lambda, 1)?;
142 }
143 EsKind::MuPlusLambda { mu, lambda } => {
144 config::at_least(C, "mu", mu, 1)?;
145 config::at_least(C, "lambda", lambda, 1)?;
146 }
147 EsKind::MuCommaLambda { mu, lambda } => {
148 config::at_least(C, "mu", mu, 1)?;
149 config::at_least(C, "lambda", lambda, 1)?;
150 if lambda < mu {
151 return Err(ConfigError {
152 config: C,
153 field: "lambda",
154 kind: ConstraintKind::Custom("(mu, lambda) requires lambda >= mu"),
155 });
156 }
157 }
158 }
159 Ok(())
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct EsState<B: Backend> {
166 parents: Tensor<B, 2>,
169 sigmas: Tensor<B, 1>,
176 parent_fitness: Vec<f32>,
178 best_genome: Option<Tensor<B, 2>>,
180 best_fitness: f32,
182 generation: usize,
184 successes_in_window: u32,
186 window_len: u32,
188}
189
190impl<B: Backend> EsState<B> {
191 #[allow(clippy::too_many_arguments)]
201 pub fn try_new(
202 parents: Tensor<B, 2>,
203 sigmas: Tensor<B, 1>,
204 parent_fitness: Vec<f32>,
205 best_genome: Option<Tensor<B, 2>>,
206 best_fitness: f32,
207 generation: usize,
208 successes_in_window: u32,
209 window_len: u32,
210 ) -> Result<Self, ConfigError> {
211 let mu = parents.dims()[0];
212 config::nonzero("EsState", "parents", mu)?;
213 if !parent_fitness.is_empty() && parent_fitness.len() != mu {
214 return Err(ConfigError {
215 config: "EsState",
216 field: "parent_fitness",
217 kind: ConstraintKind::Custom("length must equal the parent count μ"),
218 });
219 }
220 Ok(Self {
221 parents,
222 sigmas,
223 parent_fitness,
224 best_genome,
225 best_fitness,
226 generation,
227 successes_in_window,
228 window_len,
229 })
230 }
231
232 #[must_use]
234 pub fn parents(&self) -> &Tensor<B, 2> {
235 &self.parents
236 }
237
238 #[must_use]
241 pub fn sigmas(&self) -> &Tensor<B, 1> {
242 &self.sigmas
243 }
244
245 #[must_use]
247 pub fn parent_fitness(&self) -> &[f32] {
248 &self.parent_fitness
249 }
250
251 #[must_use]
253 pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
254 self.best_genome.as_ref()
255 }
256
257 #[must_use]
259 pub fn best_fitness(&self) -> f32 {
260 self.best_fitness
261 }
262
263 #[must_use]
265 pub fn generation(&self) -> usize {
266 self.generation
267 }
268
269 #[must_use]
271 pub fn successes_in_window(&self) -> u32 {
272 self.successes_in_window
273 }
274
275 #[must_use]
277 pub fn window_len(&self) -> u32 {
278 self.window_len
279 }
280}
281
282#[derive(Debug, Clone, Copy, Default)]
295pub struct EvolutionStrategy<B: Backend> {
296 _backend: PhantomData<fn() -> B>,
297}
298
299impl<B: Backend> EvolutionStrategy<B> {
300 #[must_use]
302 pub fn new() -> Self {
303 Self {
304 _backend: PhantomData,
305 }
306 }
307
308 fn mu(kind: EsKind) -> usize {
309 match kind {
310 EsKind::OnePlusOne | EsKind::OnePlusLambda { .. } => 1,
311 EsKind::MuCommaLambda { mu, .. } | EsKind::MuPlusLambda { mu, .. } => mu,
312 }
313 }
314
315 fn sample_initial_parents(
316 params: &EsConfig,
317 rng: &mut dyn Rng,
318 device: &<B as burn::tensor::backend::BackendTypes>::Device,
319 ) -> (Tensor<B, 2>, Tensor<B, 1>) {
320 let mu = Self::mu(params.kind);
321 let (lo, hi): (f32, f32) = params.bounds.into();
322 let genome_dim = params.genome_dim;
327 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
328 let mut parent_rows = Vec::with_capacity(mu * genome_dim);
329 for _ in 0..mu * genome_dim {
330 parent_rows.push(lo + (hi - lo) * stream.random::<f32>());
331 }
332 let parents =
333 Tensor::<B, 2>::from_data(TensorData::new(parent_rows, [mu, genome_dim]), device);
334 let sigmas = Tensor::<B, 1>::from_data(
335 TensorData::new(vec![params.initial_sigma; mu], [mu]),
336 device,
337 );
338 (parents, sigmas)
339 }
340}
341
342impl<B: Backend> Strategy<B> for EvolutionStrategy<B>
343where
344 B::Device: Clone,
345{
346 type Params = EsConfig;
347 type State = EsState<B>;
348 type Genome = Tensor<B, 2>;
349
350 fn init(
354 &self,
355 params: &EsConfig,
356 rng: &mut dyn Rng,
357 device: &<B as burn::tensor::backend::BackendTypes>::Device,
358 ) -> EsState<B> {
359 debug_assert!(
360 params.validate().is_ok(),
361 "invalid EsConfig reached init: {params:?}"
362 );
363 let (parents, sigmas) = Self::sample_initial_parents(params, rng, device);
364 EsState {
365 parents,
366 sigmas,
367 parent_fitness: Vec::new(),
368 best_genome: None,
369 best_fitness: f32::NEG_INFINITY,
370 generation: 0,
371 successes_in_window: 0,
372 window_len: 0,
373 }
374 }
375
376 fn ask(
387 &self,
388 params: &EsConfig,
389 state: &EsState<B>,
390 rng: &mut dyn Rng,
391 device: &<B as burn::tensor::backend::BackendTypes>::Device,
392 ) -> (Tensor<B, 2>, EsState<B>) {
393 if state.parent_fitness.is_empty() {
396 return (state.parents.clone(), state.clone());
397 }
398
399 let lambda = params.kind.population_size();
400 let mu = Self::mu(params.kind);
401
402 let mut mutation_rng = seed_stream(
403 rng.next_u64(),
404 state.generation as u64,
405 SeedPurpose::Mutation,
406 );
407 let mut sigma_rng =
408 seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
409
410 let mut parent_indices: Vec<i64> = Vec::with_capacity(lambda);
415 for _ in 0..lambda {
416 #[allow(clippy::cast_possible_wrap)]
417 parent_indices.push(sigma_rng.random_range(0..mu) as i64);
418 }
419 let idx_tensor = Tensor::<B, 1, burn::tensor::Int>::from_data(
420 TensorData::new(parent_indices.clone(), [lambda]),
421 device,
422 );
423 let duplicated_parents = state.parents.clone().select(0, idx_tensor.clone());
424 let duplicated_sigmas = state.sigmas.clone().select(0, idx_tensor);
425
426 let is_one_plus = matches!(
429 params.kind,
430 EsKind::OnePlusOne | EsKind::OnePlusLambda { .. }
431 );
432 let offspring_sigmas = if is_one_plus {
433 duplicated_sigmas
434 } else {
435 let mut noise_rows = Vec::with_capacity(lambda);
438 for _ in 0..lambda {
439 noise_rows.push(crate::sampling::standard_normal(&mut sigma_rng));
440 }
441 let noise = Tensor::<B, 1>::from_data(TensorData::new(noise_rows, [lambda]), device);
442 (duplicated_sigmas * noise.mul_scalar(params.tau).exp())
446 .clamp(params.sigma_min, params.sigma_max)
447 };
448
449 let mutated = gaussian_mutation_per_row(
452 duplicated_parents,
453 offspring_sigmas.clone(),
454 &mut mutation_rng,
455 device,
456 );
457
458 let (lo, hi): (f32, f32) = params.bounds.into();
460 let mutated = mutated.clamp(lo, hi);
461
462 let mut state = state.clone();
463 let combined_sigmas = Tensor::cat(vec![state.sigmas.clone(), offspring_sigmas], 0);
471 state.sigmas = combined_sigmas;
472 (mutated, state)
473 }
474
475 #[allow(clippy::too_many_lines)]
492 fn tell(
493 &self,
494 params: &EsConfig,
495 offspring: Tensor<B, 2>,
496 fitness: Tensor<B, 1>,
497 mut state: EsState<B>,
498 _rng: &mut dyn Rng,
499 ) -> (EsState<B>, StrategyMetrics) {
500 let fitness_host = fitness
501 .into_data()
502 .into_vec::<f32>()
503 .expect("fitness tensor must be readable as f32");
504
505 if state.parent_fitness.is_empty() {
508 state.parent_fitness.clone_from(&fitness_host);
509 state.generation += 1;
510 update_best(&mut state, &offspring, &fitness_host);
511 let m = StrategyMetrics::from_host_fitness(
512 state.generation,
513 &fitness_host,
514 state.best_fitness,
515 );
516 state.best_fitness = m.best_fitness_ever();
517 state.parents = offspring;
518 let mu = Self::mu(params.kind);
520 let device = state.parents.device();
521 state.sigmas = Tensor::<B, 1>::from_data(
522 TensorData::new(vec![params.initial_sigma; mu], [mu]),
523 &device,
524 );
525 return (state, m);
526 }
527
528 let device = offspring.device();
529 let mu = Self::mu(params.kind);
530 let lambda = params.kind.population_size();
533 #[allow(clippy::single_range_in_vec_init)]
534 let parent_sigmas = state.sigmas.clone().slice([0..mu]);
535 #[allow(clippy::single_range_in_vec_init)]
536 let offspring_sigmas = state.sigmas.clone().slice([mu..(mu + lambda)]);
537
538 match params.kind {
539 EsKind::OnePlusOne => {
540 let parent_fit = state.parent_fitness[0];
542 let offspring_fit = fitness_host[0];
543 let success = offspring_fit > parent_fit;
544 state.window_len += 1;
545 if success {
546 state.successes_in_window += 1;
547 state.parents.clone_from(&offspring);
548 state.parent_fitness = vec![offspring_fit];
549 }
550 #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
552 let window = 10_u32.saturating_mul(params.genome_dim as u32).max(1);
553 if state.window_len >= window {
554 #[allow(clippy::cast_precision_loss)]
555 let rate = state.successes_in_window as f32 / state.window_len as f32;
556 let current_sigma = state
557 .sigmas
558 .clone()
559 .into_data()
560 .into_vec::<f32>()
561 .expect("sigma tensor must be readable as f32")[0];
562 let new_sigma = if rate > 0.2 {
566 current_sigma * 1.22
567 } else if rate < 0.2 {
568 current_sigma / 1.22
569 } else {
570 current_sigma
571 }
572 .clamp(params.sigma_min, params.sigma_max);
573 state.sigmas =
574 Tensor::<B, 1>::from_data(TensorData::new(vec![new_sigma], [1]), &device);
575 state.successes_in_window = 0;
576 state.window_len = 0;
577 } else {
578 state.sigmas = parent_sigmas;
579 }
580 }
581 EsKind::OnePlusLambda { .. } => {
582 let best_off_idx = argmax_host(&fitness_host);
584 let best_off_fit = fitness_host[best_off_idx];
585 if best_off_fit > state.parent_fitness[0] {
586 #[allow(clippy::single_range_in_vec_init)]
587 let best_row = offspring.clone().slice([best_off_idx..best_off_idx + 1]);
588 state.parents = best_row;
589 state.parent_fitness = vec![best_off_fit];
590 }
591 state.sigmas = parent_sigmas;
592 }
593 EsKind::MuCommaLambda { mu, .. } => {
594 let (survivors, survivor_f) =
595 mu_comma_lambda::<B>(offspring.clone(), &fitness_host, mu, &device);
596 let survivor_idx =
598 crate::ops::selection::truncation_indices_host(&fitness_host, mu);
599 let survivor_sigmas = offspring_sigmas.select(
600 0,
601 Tensor::<B, 1, burn::tensor::Int>::from_data(
602 TensorData::new(survivor_idx, [mu]),
603 &device,
604 ),
605 );
606 state.parents = survivors;
607 state.parent_fitness = survivor_f;
608 state.sigmas = survivor_sigmas;
609 }
610 EsKind::MuPlusLambda { mu, .. } => {
611 let (survivors, survivor_f) = mu_plus_lambda::<B>(
612 state.parents.clone(),
613 &state.parent_fitness,
614 offspring.clone(),
615 &fitness_host,
616 mu,
617 &device,
618 );
619 let combined_f: Vec<f32> = state
621 .parent_fitness
622 .iter()
623 .chain(fitness_host.iter())
624 .copied()
625 .collect();
626 let survivor_idx = crate::ops::selection::truncation_indices_host(&combined_f, mu);
627 let combined_sigmas = Tensor::cat(vec![parent_sigmas, offspring_sigmas], 0);
628 let survivor_sigmas = combined_sigmas.select(
629 0,
630 Tensor::<B, 1, burn::tensor::Int>::from_data(
631 TensorData::new(survivor_idx, [mu]),
632 &device,
633 ),
634 );
635 state.parents = survivors;
636 state.parent_fitness = survivor_f;
637 state.sigmas = survivor_sigmas;
638 }
639 }
640
641 state.generation += 1;
642 update_best(&mut state, &offspring, &fitness_host);
643 let m =
644 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
645 state.best_fitness = m.best_fitness_ever();
646 (state, m)
647 }
648
649 fn best(&self, state: &EsState<B>) -> Option<(Tensor<B, 2>, f32)> {
652 state
653 .best_genome
654 .as_ref()
655 .map(|g| (g.clone(), state.best_fitness))
656 }
657}
658
659fn update_best<B: Backend>(state: &mut EsState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
660 if fitness.is_empty() {
661 return;
662 }
663 let best_idx = argmax_host(fitness);
664 let best_f = fitness[best_idx];
665 if best_f > state.best_fitness {
666 let device = pop.device();
667 #[allow(clippy::cast_possible_wrap)]
668 let idx = Tensor::<B, 1, burn::tensor::Int>::from_data(
669 TensorData::new(vec![best_idx as i64], [1]),
670 &device,
671 );
672 state.best_genome = Some(pop.clone().select(0, idx));
673 state.best_fitness = best_f;
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use crate::fitness::FromFitnessEvaluable;
681 use crate::strategy::EvolutionaryHarness;
682 use burn::backend::Flex;
683 use rlevo_core::fitness::FitnessEvaluable;
684 type TestBackend = Flex;
685
686 #[test]
687 fn try_new_checks_parent_fitness_length() {
688 let device = Default::default();
689 let parents = Tensor::<TestBackend, 2>::zeros([4, 2], &device);
690 let sigmas = Tensor::<TestBackend, 1>::ones([4], &device);
691 assert!(
693 EsState::try_new(
694 parents.clone(),
695 sigmas.clone(),
696 vec![],
697 None,
698 f32::MIN,
699 0,
700 0,
701 0
702 )
703 .is_ok()
704 );
705 assert!(
706 EsState::try_new(
707 parents.clone(),
708 sigmas.clone(),
709 vec![1.0; 4],
710 None,
711 1.0,
712 1,
713 0,
714 0,
715 )
716 .is_ok()
717 );
718 assert!(EsState::try_new(parents, sigmas, vec![1.0; 3], None, 1.0, 1, 0, 0).is_err());
720 }
721
722 #[test]
723 fn default_config_validates() {
724 let cfg = EsConfig::default_for(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10);
725 assert!(cfg.validate().is_ok());
726 }
727
728 #[test]
729 fn rejects_comma_lambda_below_mu() {
730 let cfg = EsConfig::default_for(EsKind::MuCommaLambda { mu: 10, lambda: 5 }, 10);
731 assert_eq!(cfg.validate().unwrap_err().field, "lambda");
732 }
733
734 #[test]
738 fn rejects_zero_genome_dim() {
739 let cfg = EsConfig::default_for(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 0);
740 assert!(
741 !cfg.tau.is_finite(),
742 "precondition: derived tau is non-finite for genome_dim == 0, got {}",
743 cfg.tau
744 );
745 assert_eq!(
746 cfg.validate().unwrap_err().field,
747 "genome_dim",
748 "genome_dim == 0 must be rejected before the non-finite tau can be used"
749 );
750 }
751
752 #[test]
755 fn rejects_inverted_sigma_window() {
756 let mut cfg = EsConfig::default_for(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10);
757 cfg.sigma_min = 10.0;
758 cfg.sigma_max = 1.0;
759 assert_eq!(
760 cfg.validate().unwrap_err().field,
761 "sigma_max",
762 "sigma_min >= sigma_max must be rejected"
763 );
764 }
765
766 #[test]
772 fn sigma_stays_within_bounds_across_updates() {
773 use rand::SeedableRng;
774 use rand::rngs::StdRng;
775
776 let device = Default::default();
777 let strategy = EvolutionStrategy::<TestBackend>::new();
778 let mut params = EsConfig::default_for(EsKind::MuPlusLambda { mu: 4, lambda: 12 }, 3);
779 params.tau = 5.0;
780 params.sigma_min = 1e-4;
781 params.sigma_max = 10.0;
782 assert!(params.validate().is_ok(), "test config must be valid");
783
784 let mut rng = StdRng::seed_from_u64(9);
785 let mut state = strategy.init(¶ms, &mut rng, &device);
786 for generation in 0..60 {
787 let (offspring, next) = strategy.ask(¶ms, &state, &mut rng, &device);
788 let sigmas: Vec<f32> = next
789 .sigmas()
790 .clone()
791 .into_data()
792 .into_vec::<f32>()
793 .expect("sigma host-read of a tensor this test just built");
794 for &s in &sigmas {
795 assert!(
796 s.is_finite() && s >= params.sigma_min && s <= params.sigma_max,
797 "σ left [{}, {}] at gen {generation}: {s}",
798 params.sigma_min,
799 params.sigma_max
800 );
801 }
802 let n = offspring.dims()[0];
803 let fitness = Tensor::<TestBackend, 1>::from_data(
804 TensorData::new(vec![1.0_f32; n], [n]),
805 &device,
806 );
807 let (advanced, _) = strategy.tell(¶ms, offspring, fitness, next, &mut rng);
808 state = advanced;
809 }
810 }
811
812 #[test]
818 fn selection_returns_in_range_indices() {
819 let fitness = [1.0_f32, f32::NAN, 5.0, 2.0, -3.0, f32::NAN];
820 let n = fitness.len();
821
822 let amax = argmax_host(&fitness);
823 assert!(amax < n, "argmax index {amax} out of range for len {n}");
824
825 for mu in 1..=4 {
826 let idx = crate::ops::selection::truncation_indices_host(&fitness, mu);
827 assert_eq!(idx.len(), mu, "truncation must return exactly mu indices");
828 for (a, &x) in idx.iter().enumerate() {
829 assert!(
830 usize::try_from(x).is_ok_and(|xi| xi < n),
831 "truncation index {x} out of range for len {n}"
832 );
833 for &y in &idx[a + 1..] {
834 assert_ne!(x, y, "truncation indices must be pairwise distinct");
835 }
836 }
837 }
838 }
839
840 fn neg_sphere(pop: &Tensor<TestBackend, 2>) -> Tensor<TestBackend, 1> {
843 let device = pop.device();
844 let [n, d] = pop.dims();
845 let rows: Vec<f32> = pop
846 .clone()
847 .into_data()
848 .into_vec::<f32>()
849 .expect("population host-read of a tensor this test just built");
850 #[allow(clippy::needless_range_loop)]
851 let fit: Vec<f32> = (0..n)
852 .map(|i| -(0..d).map(|j| rows[i * d + j].powi(2)).sum::<f32>())
853 .collect();
854 Tensor::<TestBackend, 1>::from_data(TensorData::new(fit, [n]), &device)
855 }
856
857 fn run_es_best_ever(kind: EsKind, dim: usize, gens: usize, seed: u64) -> Vec<f32> {
861 use rand::SeedableRng;
862 use rand::rngs::StdRng;
863
864 let device = Default::default();
865 let strategy = EvolutionStrategy::<TestBackend>::new();
866 let params = EsConfig::default_for(kind, dim);
867 let mut rng = StdRng::seed_from_u64(seed);
868 let mut state = strategy.init(¶ms, &mut rng, &device);
869 let mut traj = Vec::with_capacity(gens);
870 for _ in 0..gens {
871 let (offspring, next) = strategy.ask(¶ms, &state, &mut rng, &device);
872 let fitness = neg_sphere(&offspring);
873 let (advanced, m) = strategy.tell(¶ms, offspring, fitness, next, &mut rng);
874 traj.push(m.best_fitness_ever());
875 state = advanced;
876 }
877 traj
878 }
879
880 #[test]
887 fn best_fitness_ever_is_monotonic_on_maximize() {
888 for kind in [
889 EsKind::OnePlusOne,
890 EsKind::OnePlusLambda { lambda: 6 },
891 EsKind::MuPlusLambda { mu: 3, lambda: 8 },
892 EsKind::MuCommaLambda { mu: 3, lambda: 8 },
893 ] {
894 let traj = run_es_best_ever(kind, 3, 40, 17);
895 for w in traj.windows(2) {
896 assert!(
897 w[1] >= w[0],
898 "best_fitness_ever decreased for {kind:?}: {} -> {}",
899 w[0],
900 w[1]
901 );
902 }
903 assert!(
904 traj.last().copied().unwrap().is_finite(),
905 "rolling best must stay finite for {kind:?}"
906 );
907 }
908 }
909
910 struct Sphere;
911 struct SphereFit;
912 impl FitnessEvaluable for SphereFit {
913 type Individual = Vec<f64>;
914 type Landscape = Sphere;
915 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
916 x.iter().map(|v| v * v).sum()
917 }
918 }
919
920 fn run_es(kind: EsKind, dim: usize, generations: usize, seed: u64) -> f32 {
921 let device = Default::default();
922 let strategy = EvolutionStrategy::<TestBackend>::new();
923 let params = EsConfig::default_for(kind, dim);
924 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
925 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
926 strategy,
927 params,
928 fitness_fn,
929 seed,
930 device,
931 generations,
932 )
933 .expect("valid params");
934 harness.reset();
935 loop {
936 let step = harness.step(());
937 if step.done {
938 break;
939 }
940 }
941 harness.latest_metrics().unwrap().best_fitness_ever()
942 }
943
944 #[test]
945 fn one_plus_lambda_converges_on_sphere_d2() {
946 let best = run_es(EsKind::OnePlusLambda { lambda: 8 }, 2, 200, 7);
947 assert!(best < 1e-2, "OnePlusLambda best={best}");
948 }
949
950 #[test]
951 fn one_plus_one_converges_on_sphere_d2() {
952 let best = run_es(EsKind::OnePlusOne, 2, 500, 11);
953 assert!(best < 1e-2, "OnePlusOne best={best}");
954 }
955
956 #[test]
957 fn mu_plus_lambda_converges_on_sphere_d2() {
958 let best = run_es(EsKind::MuPlusLambda { mu: 3, lambda: 8 }, 2, 200, 7);
959 assert!(best < 1e-2, "MuPlusLambda best={best}");
960 }
961
962 #[test]
963 fn mu_comma_lambda_converges_on_sphere_d2() {
964 let best = run_es(EsKind::MuCommaLambda { mu: 3, lambda: 8 }, 2, 200, 7);
965 assert!(best < 1e-1, "MuCommaLambda best={best}");
966 }
967
968 #[test]
969 fn mu_plus_lambda_converges_on_sphere_d10() {
970 let best = run_es(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10, 1500, 42);
975 assert!(best < 1e-6, "MuPlusLambda D10 best={best}");
976 }
977}