1use std::marker::PhantomData;
34
35use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
36use rand::Rng;
37use rand::RngExt;
38
39use rlevo_core::bounds::Bounds;
40use rlevo_core::config::{self, ConfigError, Validate};
41
42use super::len_matches_pop;
43use crate::ops::selection::argmax_host;
44use crate::rng::{SeedPurpose, seed_stream};
45use crate::strategy::{Strategy, StrategyMetrics};
46
47#[derive(Debug, Clone)]
49pub struct GwoConfig {
50 pub pop_size: usize,
52 pub genome_dim: usize,
54 pub bounds: Bounds,
56 pub max_generations: usize,
61}
62
63impl GwoConfig {
64 #[must_use]
66 pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
67 Self {
68 pop_size,
69 genome_dim,
70 bounds: Bounds::new(-5.12, 5.12),
71 max_generations: 500,
72 }
73 }
74}
75
76impl Validate for GwoConfig {
77 fn validate(&self) -> Result<(), ConfigError> {
78 const C: &str = "GwoConfig";
79 config::at_least(C, "pop_size", self.pop_size, 3)?;
80 config::nonzero(C, "genome_dim", self.genome_dim)?;
81 config::at_least(C, "max_generations", self.max_generations, 1)?;
82 Ok(())
83 }
84}
85
86#[derive(Debug, Clone)]
88pub struct GwoState<B: Backend> {
89 pack: Tensor<B, 2>,
91 fitness: Vec<f32>,
93 best_genome: Option<Tensor<B, 2>>,
95 best_fitness: f32,
97 generation: usize,
99}
100
101impl<B: Backend> GwoState<B> {
102 pub fn try_new(
109 pack: Tensor<B, 2>,
110 fitness: Vec<f32>,
111 best_genome: Option<Tensor<B, 2>>,
112 best_fitness: f32,
113 generation: usize,
114 ) -> Result<Self, ConfigError> {
115 let pop = pack.dims()[0];
116 config::nonzero("GwoState", "pop_size", pop)?;
117 len_matches_pop("GwoState", "fitness", pop, fitness.len())?;
118 Ok(Self {
119 pack,
120 fitness,
121 best_genome,
122 best_fitness,
123 generation,
124 })
125 }
126
127 #[must_use]
129 pub fn pack(&self) -> &Tensor<B, 2> {
130 &self.pack
131 }
132
133 #[must_use]
135 pub fn fitness(&self) -> &[f32] {
136 &self.fitness
137 }
138
139 #[must_use]
141 pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
142 self.best_genome.as_ref()
143 }
144
145 #[must_use]
147 pub fn best_fitness(&self) -> f32 {
148 self.best_fitness
149 }
150
151 #[must_use]
153 pub fn generation(&self) -> usize {
154 self.generation
155 }
156}
157
158#[derive(Debug, Clone, Copy, Default)]
176pub struct GreyWolfOptimizer<B: Backend> {
177 _backend: PhantomData<fn() -> B>,
178}
179
180impl<B: Backend> GreyWolfOptimizer<B> {
181 #[must_use]
183 pub fn new() -> Self {
184 Self {
185 _backend: PhantomData,
186 }
187 }
188
189 fn sample_initial(
190 params: &GwoConfig,
191 rng: &mut dyn Rng,
192 device: &<B as burn::tensor::backend::BackendTypes>::Device,
193 ) -> Tensor<B, 2> {
194 let (lo, hi): (f32, f32) = params.bounds.into();
195 let pop = params.pop_size;
200 let genome_dim = params.genome_dim;
201 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
202 let mut rows = Vec::with_capacity(pop * genome_dim);
203 for _ in 0..pop * genome_dim {
204 rows.push(lo + (hi - lo) * stream.random::<f32>());
205 }
206 Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
207 }
208}
209
210impl<B: Backend> Strategy<B> for GreyWolfOptimizer<B>
211where
212 B::Device: Clone,
213{
214 type Params = GwoConfig;
215 type State = GwoState<B>;
216 type Genome = Tensor<B, 2>;
217
218 fn init(
224 &self,
225 params: &GwoConfig,
226 rng: &mut dyn Rng,
227 device: &<B as burn::tensor::backend::BackendTypes>::Device,
228 ) -> GwoState<B> {
229 debug_assert!(
230 params.validate().is_ok(),
231 "invalid GwoConfig reached init: {params:?}"
232 );
233 let pack = Self::sample_initial(params, rng, device);
234 GwoState {
235 pack,
236 fitness: Vec::new(),
237 best_genome: None,
238 best_fitness: f32::NEG_INFINITY,
239 generation: 0,
240 }
241 }
242
243 fn ask(
251 &self,
252 params: &GwoConfig,
253 state: &GwoState<B>,
254 rng: &mut dyn Rng,
255 device: &<B as burn::tensor::backend::BackendTypes>::Device,
256 ) -> (Tensor<B, 2>, GwoState<B>) {
257 if state.fitness.is_empty() {
259 return (state.pack.clone(), state.clone());
260 }
261
262 let pop_size = params.pop_size;
263 let genome_dim = params.genome_dim;
264
265 let top3 = argtop3_max(&state.fitness);
269
270 #[allow(clippy::cast_possible_wrap)]
271 let idx = Tensor::<B, 1, Int>::from_data(
272 TensorData::new(vec![top3[0] as i64, top3[1] as i64, top3[2] as i64], [3]),
273 device,
274 );
275 let leaders = state.pack.clone().select(0, idx); #[allow(clippy::cast_precision_loss)]
279 let t = state.generation as f32;
280 #[allow(clippy::cast_precision_loss)]
281 let max_t = params.max_generations.max(1) as f32;
282 let a = 2.0 * (1.0 - (t / max_t).min(1.0));
283
284 let mut update = Tensor::<B, 2>::zeros([pop_size, genome_dim], device);
285 #[allow(clippy::cast_sign_loss)]
289 for k in 0..3 {
290 let gen_k = state.generation as u64 * 3 + k as u64;
291 let r1 = {
292 let mut s = seed_stream(rng.next_u64(), gen_k, SeedPurpose::Other);
293 let mut rows = Vec::with_capacity(pop_size * genome_dim);
294 for _ in 0..pop_size * genome_dim {
295 rows.push(s.random::<f32>());
296 }
297 Tensor::<B, 2>::from_data(TensorData::new(rows, [pop_size, genome_dim]), device)
298 };
299 let r2 = {
300 let mut s = seed_stream(rng.next_u64(), gen_k, SeedPurpose::Mutation);
301 let mut rows = Vec::with_capacity(pop_size * genome_dim);
302 for _ in 0..pop_size * genome_dim {
303 rows.push(s.random::<f32>());
304 }
305 Tensor::<B, 2>::from_data(TensorData::new(rows, [pop_size, genome_dim]), device)
306 };
307 let a_mat = r1.mul_scalar(2.0 * a).sub_scalar(a);
308 let c_mat = r2.mul_scalar(2.0);
309
310 #[allow(clippy::single_range_in_vec_init)]
311 let leader_row = leaders.clone().slice([k..k + 1]);
312 let leader_exp = leader_row.expand([pop_size, genome_dim]);
313 let d_k = (c_mat.mul(leader_exp.clone()) - state.pack.clone()).abs();
314 let x_k_prime = leader_exp - a_mat.mul(d_k);
315 update = update + x_k_prime;
316 }
317 let new_pack = update.div_scalar(3.0);
318 let (lo, hi): (f32, f32) = params.bounds.into();
319 let new_pack = new_pack.clamp(lo, hi);
320
321 let mut next = state.clone();
322 next.pack.clone_from(&new_pack);
323 (new_pack, next)
324 }
325
326 fn tell(
332 &self,
333 _params: &GwoConfig,
334 population: Tensor<B, 2>,
335 fitness: Tensor<B, 1>,
336 mut state: GwoState<B>,
337 _rng: &mut dyn Rng,
338 ) -> (GwoState<B>, StrategyMetrics) {
339 let fitness_host = fitness
340 .into_data()
341 .into_vec::<f32>()
342 .expect("fitness tensor must be readable as f32");
343 state.fitness.clone_from(&fitness_host);
344 state.pack.clone_from(&population);
345 let best_idx = argmax_host(&fitness_host);
346 if fitness_host[best_idx] > state.best_fitness {
347 state.best_fitness = fitness_host[best_idx];
348 let device = population.device();
349 #[allow(clippy::cast_possible_wrap)]
350 let idx = Tensor::<B, 1, Int>::from_data(
351 TensorData::new(vec![best_idx as i64], [1]),
352 &device,
353 );
354 state.best_genome = Some(population.select(0, idx));
355 }
356 state.generation += 1;
357 let m =
358 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
359 state.best_fitness = m.best_fitness_ever();
360 (state, m)
361 }
362
363 fn best(&self, state: &GwoState<B>) -> Option<(Tensor<B, 2>, f32)> {
366 state
367 .best_genome
368 .as_ref()
369 .map(|g| (g.clone(), state.best_fitness))
370 }
371}
372
373fn argtop3_max(xs: &[f32]) -> [usize; 3] {
387 assert!(xs.len() >= 3, "argtop3_max requires at least 3 elements");
388 let sane = |i: usize| crate::fitness::sanitize_fitness(xs[i]);
389 let mut idx = [0usize, 1, 2];
390 let mut vals = [sane(0), sane(1), sane(2)];
391 if vals[0] < vals[1] {
393 vals.swap(0, 1);
394 idx.swap(0, 1);
395 }
396 if vals[1] < vals[2] {
397 vals.swap(1, 2);
398 idx.swap(1, 2);
399 }
400 if vals[0] < vals[1] {
401 vals.swap(0, 1);
402 idx.swap(0, 1);
403 }
404 for i in 3..xs.len() {
405 let v = sane(i);
406 if v > vals[2] {
407 vals[2] = v;
408 idx[2] = i;
409 if vals[1] < vals[2] {
410 vals.swap(1, 2);
411 idx.swap(1, 2);
412 }
413 if vals[0] < vals[1] {
414 vals.swap(0, 1);
415 idx.swap(0, 1);
416 }
417 }
418 }
419 idx
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crate::fitness::{BatchFitnessFn, FromFitnessEvaluable};
426 use crate::strategy::EvolutionaryHarness;
427 use burn::backend::Flex;
428 use rand::SeedableRng;
429 use rand::rngs::StdRng;
430 use rlevo_core::fitness::FitnessEvaluable;
431 use rlevo_core::objective::ObjectiveSense;
432
433 type TestBackend = Flex;
434
435 struct NanFitness;
438 impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for NanFitness {
439 fn evaluate_batch(
440 &mut self,
441 population: &Tensor<B, 2>,
442 device: &<B as burn::tensor::backend::BackendTypes>::Device,
443 ) -> Tensor<B, 1> {
444 let n = population.dims()[0];
445 #[allow(clippy::cast_precision_loss)]
446 let mut vals: Vec<f32> = (0..n).map(|i| i as f32).collect();
447 vals[0] = f32::NAN;
448 Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
449 }
450 fn sense(&self) -> ObjectiveSense {
451 ObjectiveSense::Maximize
452 }
453 }
454
455 #[test]
456 fn try_new_checks_fitness_length() {
457 let device = Default::default();
458 let pack = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
459 assert!(GwoState::try_new(pack.clone(), vec![1.0; 3], None, 1.0, 1).is_ok());
460 assert!(GwoState::try_new(pack.clone(), vec![], None, f32::MIN, 0).is_ok());
461 assert!(GwoState::try_new(pack, vec![1.0; 2], None, 1.0, 1).is_err());
462 let empty = Tensor::<TestBackend, 2>::zeros([0, 2], &device);
463 assert!(GwoState::try_new(empty, vec![], None, 1.0, 0).is_err());
464 }
465
466 #[test]
467 fn default_config_validates() {
468 assert!(GwoConfig::default_for(30, 10).validate().is_ok());
469 }
470
471 #[test]
472 fn rejects_pop_size_below_three() {
473 let mut cfg = GwoConfig::default_for(30, 10);
474 cfg.pop_size = 2;
475 assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
476 }
477
478 struct Sphere;
479 struct SphereFit;
480 impl FitnessEvaluable for SphereFit {
481 type Individual = Vec<f64>;
482 type Landscape = Sphere;
483 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
484 x.iter().map(|v| v * v).sum()
485 }
486 }
487
488 #[test]
489 fn argtop3_max_finds_three_largest() {
490 let xs = [5.0, 2.0, 8.0, 1.0, 3.0, 9.0, 0.5];
491 let top = argtop3_max(&xs);
492 assert_eq!(top, [5, 2, 0]);
494 }
495
496 #[test]
501 fn argtop3_max_nan_never_becomes_a_leader() {
502 let xs = [5.0_f32, 2.0, f32::NAN, 8.0, 3.0, 9.0];
504 let top = argtop3_max(&xs);
505 assert!(
508 !top.contains(&2),
509 "NaN-fitness row must not be selected as an α/β/δ leader, got {top:?}"
510 );
511 assert_eq!(
512 top,
513 [5, 3, 0],
514 "leaders must be the three strictly-largest finite rows"
515 );
516 }
517
518 #[test]
521 fn argtop3_max_alpha_is_the_strict_maximum() {
522 let xs = [1.0_f32, 7.0, 3.0, 42.0, 2.0, 5.0];
523 let top = argtop3_max(&xs);
524 assert_eq!(
525 top[0], 3,
526 "the strictly-highest-fitness row (index 3) must be the α leader, got {top:?}"
527 );
528 }
529
530 #[test]
531 fn gwo_converges_on_sphere_d10() {
532 let device = Default::default();
538 let strategy = GreyWolfOptimizer::<TestBackend>::new();
539 let params = GwoConfig::default_for(32, 10);
540 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
541 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
542 strategy, params, fitness_fn, 11, device, 600,
543 )
544 .expect("valid params");
545 harness.reset();
546 while !harness.step(()).done {}
547 let best = harness.latest_metrics().unwrap().best_fitness_ever();
548 assert!(best < 1e-3, "GWO D10 best={best}");
549 }
550
551 #[test]
552 fn argtop3_max_all_equal_returns_stable_prefix() {
553 let xs = [5.0_f32, 5.0, 5.0, 5.0];
556 assert_eq!(argtop3_max(&xs), [0, 1, 2]);
557 }
558
559 #[test]
560 fn argtop3_max_handles_duplicate_maxima() {
561 let xs = [3.0_f32, 9.0, 9.0, 1.0, 9.0];
564 let top = argtop3_max(&xs);
565 assert!(
566 top[0] != top[1] && top[1] != top[2] && top[0] != top[2],
567 "leaders must be distinct rows, got {top:?}"
568 );
569 for &i in &top {
570 approx::assert_relative_eq!(xs[i], 9.0);
571 }
572 }
573
574 #[test]
575 fn minimal_pack_of_three_runs() {
576 let device = Default::default();
578 let strategy = GreyWolfOptimizer::<TestBackend>::new();
579 let params = GwoConfig::default_for(3, 3);
580 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
581 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
582 strategy, params, fitness_fn, 0, device, 5,
583 )
584 .expect("valid params");
585 harness.reset();
586 while !harness.step(()).done {}
587 assert!(
588 harness
589 .latest_metrics()
590 .unwrap()
591 .best_fitness_ever()
592 .is_finite()
593 );
594 }
595
596 #[test]
597 fn rejects_max_generations_zero() {
598 let mut cfg = GwoConfig::default_for(3, 3);
601 cfg.max_generations = 0;
602 assert_eq!(cfg.validate().unwrap_err().field, "max_generations");
603 }
604
605 #[test]
606 fn ask_survives_zero_max_generations_via_guard() {
607 let device = Default::default();
613 let strategy = GreyWolfOptimizer::<TestBackend>::new();
614 let mut cfg = GwoConfig::default_for(3, 3);
615 cfg.max_generations = 0;
616 let (lo, hi): (f32, f32) = cfg.bounds.into();
617 let pack = Tensor::<TestBackend, 2>::zeros([3, 3], &device);
618 let best = pack.clone().slice([0..1, 0..3]);
619 let state =
620 GwoState::try_new(pack, vec![1.0, 2.0, 3.0], Some(best), 3.0, 0).expect("valid state");
621 let mut rng = StdRng::seed_from_u64(0);
622 let (new_pack, _next) = strategy.ask(&cfg, &state, &mut rng, &device);
623 let values = new_pack.into_data().into_vec::<f32>().unwrap();
624 for v in values {
625 assert!(v.is_finite(), "guard failed: non-finite {v}");
626 assert!(v >= lo - 1e-4 && v <= hi + 1e-4, "out of bounds: {v}");
627 }
628 }
629
630 #[test]
631 fn inverted_bounds_are_unrepresentable() {
632 assert!(Bounds::try_new(5.12, -5.12).is_err());
635 assert!(Bounds::try_new(3.0, 3.0).is_ok());
636 }
637
638 #[test]
639 fn first_ask_returns_initial_pack_unchanged() {
640 let device = Default::default();
641 let strategy = GreyWolfOptimizer::<TestBackend>::new();
642 let params = GwoConfig::default_for(4, 3);
643 let mut rng = StdRng::seed_from_u64(2);
644 let state = strategy.init(¶ms, &mut rng, &device);
645 let expected = state.pack().clone().into_data().into_vec::<f32>().unwrap();
646 let (pack, _state) = strategy.ask(¶ms, &state, &mut rng, &device);
647 let got = pack.into_data().into_vec::<f32>().unwrap();
648 assert_eq!(expected, got);
649 }
650
651 #[test]
652 fn nan_fitness_through_harness_stays_finite() {
653 let device = Default::default();
654 let strategy = GreyWolfOptimizer::<TestBackend>::new();
655 let params = GwoConfig::default_for(3, 3);
656 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
657 strategy, params, NanFitness, 1, device, 3,
658 )
659 .expect("valid params");
660 harness.reset();
661 while !harness.step(()).done {}
662 let m = harness.latest_metrics().unwrap();
663 assert!(
664 m.best_fitness_ever().is_finite(),
665 "best={}",
666 m.best_fitness_ever()
667 );
668 assert!(m.broken_count() >= 1, "the NaN row must be counted broken");
669 }
670}