1use std::f32::consts::PI;
33use 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
47const MAX_SPIRAL_EXP: f32 = 80.0; fn spiral_factor(l: f32, b: f32) -> f32 {
64 (b * l).clamp(-MAX_SPIRAL_EXP, MAX_SPIRAL_EXP).exp() * (2.0 * PI * l).cos()
65}
66
67#[derive(Debug, Clone)]
69pub struct WoaConfig {
70 pub pop_size: usize,
72 pub genome_dim: usize,
74 pub bounds: Bounds,
76 pub max_generations: usize,
78 pub b: f32,
80}
81
82impl WoaConfig {
83 #[must_use]
85 pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
86 Self {
87 pop_size,
88 genome_dim,
89 bounds: Bounds::new(-5.12, 5.12),
90 max_generations: 500,
91 b: 1.0,
92 }
93 }
94}
95
96impl Validate for WoaConfig {
97 fn validate(&self) -> Result<(), ConfigError> {
98 const C: &str = "WoaConfig";
99 config::at_least(C, "pop_size", self.pop_size, 1)?;
100 config::nonzero(C, "genome_dim", self.genome_dim)?;
101 config::at_least(C, "max_generations", self.max_generations, 1)?;
102 config::positive(C, "b", f64::from(self.b))?;
103 Ok(())
104 }
105}
106
107#[derive(Debug, Clone)]
109pub struct WoaState<B: Backend> {
110 positions: Tensor<B, 2>,
112 fitness: Vec<f32>,
114 best_genome: Option<Tensor<B, 2>>,
116 best_fitness: f32,
118 generation: usize,
120}
121
122impl<B: Backend> WoaState<B> {
123 pub fn try_new(
130 positions: Tensor<B, 2>,
131 fitness: Vec<f32>,
132 best_genome: Option<Tensor<B, 2>>,
133 best_fitness: f32,
134 generation: usize,
135 ) -> Result<Self, ConfigError> {
136 let pop = positions.dims()[0];
137 config::nonzero("WoaState", "pop_size", pop)?;
138 len_matches_pop("WoaState", "fitness", pop, fitness.len())?;
139 Ok(Self {
140 positions,
141 fitness,
142 best_genome,
143 best_fitness,
144 generation,
145 })
146 }
147
148 #[must_use]
150 pub fn positions(&self) -> &Tensor<B, 2> {
151 &self.positions
152 }
153
154 #[must_use]
156 pub fn fitness(&self) -> &[f32] {
157 &self.fitness
158 }
159
160 #[must_use]
162 pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
163 self.best_genome.as_ref()
164 }
165
166 #[must_use]
168 pub fn best_fitness(&self) -> f32 {
169 self.best_fitness
170 }
171
172 #[must_use]
174 pub fn generation(&self) -> usize {
175 self.generation
176 }
177}
178
179#[derive(Debug, Clone, Copy, Default)]
202pub struct WhaleOptimization<B: Backend> {
203 _backend: PhantomData<fn() -> B>,
204}
205
206impl<B: Backend> WhaleOptimization<B> {
207 #[must_use]
209 pub fn new() -> Self {
210 Self {
211 _backend: PhantomData,
212 }
213 }
214}
215
216impl<B: Backend> Strategy<B> for WhaleOptimization<B>
217where
218 B::Device: Clone,
219{
220 type Params = WoaConfig;
221 type State = WoaState<B>;
222 type Genome = Tensor<B, 2>;
223
224 fn init(
228 &self,
229 params: &WoaConfig,
230 rng: &mut dyn Rng,
231 device: &<B as burn::tensor::backend::BackendTypes>::Device,
232 ) -> WoaState<B> {
233 debug_assert!(
234 params.validate().is_ok(),
235 "invalid WoaConfig reached init: {params:?}"
236 );
237 let (lo, hi): (f32, f32) = params.bounds.into();
238 let pop = params.pop_size;
243 let genome_dim = params.genome_dim;
244 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
245 let mut position_rows = Vec::with_capacity(pop * genome_dim);
246 for _ in 0..pop * genome_dim {
247 position_rows.push(lo + (hi - lo) * stream.random::<f32>());
248 }
249 let positions =
250 Tensor::<B, 2>::from_data(TensorData::new(position_rows, [pop, genome_dim]), device);
251 WoaState {
252 positions,
253 fitness: Vec::new(),
254 best_genome: None,
255 best_fitness: f32::NEG_INFINITY,
256 generation: 0,
257 }
258 }
259
260 #[allow(clippy::many_single_char_names)]
274 fn ask(
275 &self,
276 params: &WoaConfig,
277 state: &WoaState<B>,
278 rng: &mut dyn Rng,
279 device: &<B as burn::tensor::backend::BackendTypes>::Device,
280 ) -> (Tensor<B, 2>, WoaState<B>) {
281 if state.fitness.is_empty() {
283 return (state.positions.clone(), state.clone());
284 }
285
286 let pop_size = params.pop_size;
287 let genome_dim = params.genome_dim;
288
289 #[allow(clippy::cast_precision_loss)]
291 let t = state.generation as f32;
292 #[allow(clippy::cast_precision_loss)]
293 let max_t = params.max_generations.max(1) as f32;
294 let a = 2.0 * (1.0 - (t / max_t).min(1.0));
295
296 let mut stream = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
299 let mut rand_idx: Vec<i64> = Vec::with_capacity(pop_size);
300 let mut a_scalar: Vec<f32> = Vec::with_capacity(pop_size);
301 let mut c_scalar: Vec<f32> = Vec::with_capacity(pop_size);
302 let mut p_scalar: Vec<f32> = Vec::with_capacity(pop_size);
303 let mut l_scalar: Vec<f32> = Vec::with_capacity(pop_size);
304 let mut abs_a_lt_one: Vec<i64> = Vec::with_capacity(pop_size);
305 let mut p_lt_half: Vec<i64> = Vec::with_capacity(pop_size);
306 for i in 0..pop_size {
307 let r_a: f32 = stream.random::<f32>();
308 let r_c: f32 = stream.random::<f32>();
309 let p: f32 = stream.random::<f32>();
310 let l: f32 = 2.0 * stream.random::<f32>() - 1.0;
311 let a_val = 2.0 * a * r_a - a;
312 let c_val = 2.0 * r_c;
313 a_scalar.push(a_val);
314 c_scalar.push(c_val);
315 p_scalar.push(p);
316 l_scalar.push(l);
317 abs_a_lt_one.push(i64::from(a_val.abs() < 1.0));
318 p_lt_half.push(i64::from(p < 0.5));
319 let mut r = stream.random_range(0..pop_size);
321 if r == i {
322 r = (r + 1) % pop_size;
323 }
324 #[allow(clippy::cast_possible_wrap)]
325 rand_idx.push(r as i64);
326 }
327
328 let a_row = Tensor::<B, 1>::from_data(TensorData::new(a_scalar, [pop_size]), device)
329 .unsqueeze_dim::<2>(1)
330 .expand([pop_size, genome_dim]);
331 let c_row = Tensor::<B, 1>::from_data(TensorData::new(c_scalar, [pop_size]), device)
332 .unsqueeze_dim::<2>(1)
333 .expand([pop_size, genome_dim]);
334 let rand_idx_t =
335 Tensor::<B, 1, Int>::from_data(TensorData::new(rand_idx, [pop_size]), device);
336 let x_rand = state.positions.clone().select(0, rand_idx_t);
337
338 let x_best = state
339 .best_genome
340 .as_ref()
341 .expect("best_genome populated after the first tell")
342 .clone()
343 .expand([pop_size, genome_dim]);
344
345 let enc_best = x_best.clone()
347 - a_row
348 .clone()
349 .mul((c_row.clone().mul(x_best.clone()) - state.positions.clone()).abs());
350 let enc_rand =
352 x_rand.clone() - a_row.mul((c_row.mul(x_rand) - state.positions.clone()).abs());
353 let dist = (x_best.clone() - state.positions.clone()).abs();
358 let factor_host: Vec<f32> = l_scalar
359 .iter()
360 .map(|&l| spiral_factor(l, params.b))
361 .collect();
362 let factor = Tensor::<B, 1>::from_data(TensorData::new(factor_host, [pop_size]), device);
363 let factor_mat = factor.unsqueeze_dim::<2>(1).expand([pop_size, genome_dim]);
364 let spiral = dist.mul(factor_mat) + x_best;
365
366 let m_abs_a_lt_one =
368 Tensor::<B, 1, Int>::from_data(TensorData::new(abs_a_lt_one, [pop_size]), device)
369 .equal_elem(1)
370 .unsqueeze_dim::<2>(1)
371 .expand([pop_size, genome_dim]);
372 let m_p_lt_half =
373 Tensor::<B, 1, Int>::from_data(TensorData::new(p_lt_half, [pop_size]), device)
374 .equal_elem(1)
375 .unsqueeze_dim::<2>(1)
376 .expand([pop_size, genome_dim]);
377
378 let encircle = enc_rand.mask_where(m_abs_a_lt_one, enc_best);
379 let new_positions = spiral.mask_where(m_p_lt_half, encircle);
380
381 let (lo, hi): (f32, f32) = params.bounds.into();
382 let new_positions = new_positions.clamp(lo, hi);
383
384 let mut next = state.clone();
385 next.positions.clone_from(&new_positions);
386 (new_positions, next)
387 }
388
389 fn tell(
395 &self,
396 _params: &WoaConfig,
397 population: Tensor<B, 2>,
398 fitness: Tensor<B, 1>,
399 mut state: WoaState<B>,
400 _rng: &mut dyn Rng,
401 ) -> (WoaState<B>, StrategyMetrics) {
402 let fitness_host = fitness
403 .into_data()
404 .into_vec::<f32>()
405 .expect("fitness tensor must be readable as f32");
406 state.fitness.clone_from(&fitness_host);
407 state.positions.clone_from(&population);
408 let best_idx = argmax_host(&fitness_host);
409 if fitness_host[best_idx] > state.best_fitness {
410 state.best_fitness = fitness_host[best_idx];
411 let device = population.device();
412 #[allow(clippy::cast_possible_wrap)]
413 let idx = Tensor::<B, 1, Int>::from_data(
414 TensorData::new(vec![best_idx as i64], [1]),
415 &device,
416 );
417 state.best_genome = Some(population.select(0, idx));
418 }
419 state.generation += 1;
420 let m =
421 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
422 state.best_fitness = m.best_fitness_ever();
423 (state, m)
424 }
425
426 fn best(&self, state: &WoaState<B>) -> Option<(Tensor<B, 2>, f32)> {
429 state
430 .best_genome
431 .as_ref()
432 .map(|g| (g.clone(), state.best_fitness))
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use crate::fitness::FromFitnessEvaluable;
440 use crate::strategy::EvolutionaryHarness;
441 use burn::backend::Flex;
442 use rand::SeedableRng;
443 use rand::rngs::StdRng;
444 use rlevo_core::fitness::FitnessEvaluable;
445
446 type TestBackend = Flex;
447
448 #[allow(clippy::trivially_copy_pass_by_ref)] fn finite_fitness(
452 n: usize,
453 device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
454 ) -> Tensor<TestBackend, 1> {
455 #[allow(clippy::cast_precision_loss)]
456 let vals: Vec<f32> = (0..n).map(|i| -(i as f32) - 1.0).collect();
457 Tensor::<TestBackend, 1>::from_data(TensorData::new(vals, [n]), device)
458 }
459
460 #[test]
461 fn try_new_checks_fitness_length() {
462 let device = Default::default();
463 let pos = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
464 assert!(WoaState::try_new(pos.clone(), vec![1.0; 3], None, 1.0, 1).is_ok());
465 assert!(WoaState::try_new(pos.clone(), vec![], None, f32::MIN, 0).is_ok());
466 assert!(WoaState::try_new(pos, vec![1.0; 2], None, 1.0, 1).is_err());
467 let empty = Tensor::<TestBackend, 2>::zeros([0, 2], &device);
468 assert!(WoaState::try_new(empty, vec![], None, 1.0, 0).is_err());
469 }
470
471 #[test]
472 fn default_config_validates() {
473 assert!(WoaConfig::default_for(30, 10).validate().is_ok());
474 }
475
476 #[test]
477 fn rejects_nonpositive_b() {
478 let mut cfg = WoaConfig::default_for(30, 10);
479 cfg.b = 0.0;
480 assert_eq!(cfg.validate().unwrap_err().field, "b");
481 }
482
483 struct Sphere;
484 struct SphereFit;
485 impl FitnessEvaluable for SphereFit {
486 type Individual = Vec<f64>;
487 type Landscape = Sphere;
488 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
489 x.iter().map(|v| v * v).sum()
490 }
491 }
492
493 #[test]
494 fn woa_converges_on_sphere_d10() {
495 let device = Default::default();
500 let strategy = WhaleOptimization::<TestBackend>::new();
501 let params = WoaConfig::default_for(32, 10);
502 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
503 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
504 strategy, params, fitness_fn, 5, device, 600,
505 )
506 .expect("valid params");
507 harness.reset();
508 while !harness.step(()).done {}
509 let best = harness.latest_metrics().unwrap().best_fitness_ever();
510 assert!(best < 1e-4, "WOA D10 best={best}");
511 }
512
513 #[test]
514 fn spiral_factor_stays_finite_under_overflow() {
515 for &(l, b) in &[
528 (0.75_f32, 200.0_f32),
529 (0.5, 200.0),
530 (0.9, 200.0),
531 (0.75, 500.0),
532 ] {
533 let unguarded: f32 = (b * l).exp() * (2.0 * PI * l).cos();
535 assert!(
536 !unguarded.is_finite(),
537 "test setup: expected overflow for l={l}, b={b}, got {unguarded}"
538 );
539 let guarded: f32 = spiral_factor(l, b);
540 assert!(
541 guarded.is_finite(),
542 "spiral_factor non-finite for l={l}, b={b}: {guarded}"
543 );
544 }
545 }
546
547 #[test]
548 fn spiral_factor_matches_unguarded_when_in_range() {
549 for &(l, b) in &[(0.3_f32, 1.0_f32), (-0.7, 2.0), (0.25, 10.0)] {
552 let expected: f32 = (b * l).exp() * (2.0 * PI * l).cos();
553 let got: f32 = spiral_factor(l, b);
554 approx::assert_relative_eq!(got, expected, epsilon = 1e-6);
555 }
556 }
557
558 #[test]
559 fn best_is_none_until_first_tell() {
560 let device = Default::default();
561 let strategy = WhaleOptimization::<TestBackend>::new();
562 let params = WoaConfig::default_for(4, 3);
563 let mut rng = StdRng::seed_from_u64(0);
564 let state = strategy.init(¶ms, &mut rng, &device);
565 assert!(strategy.best(&state).is_none());
566 let (pop, state) = strategy.ask(¶ms, &state, &mut rng, &device);
567 let fitness = finite_fitness(4, &device);
568 let (state, _m) = strategy.tell(¶ms, pop, fitness, state, &mut rng);
569 assert!(strategy.best(&state).is_some());
570 }
571
572 #[test]
573 fn ask_keeps_positions_in_bounds() {
574 let device = Default::default();
577 let strategy = WhaleOptimization::<TestBackend>::new();
578 let params = WoaConfig::default_for(6, 4);
579 let (lo, hi): (f32, f32) = params.bounds.into();
580 for seed in 0..32 {
581 let mut rng = StdRng::seed_from_u64(seed);
582 let state = strategy.init(¶ms, &mut rng, &device);
583 let (pop1, state) = strategy.ask(¶ms, &state, &mut rng, &device);
584 let fitness = finite_fitness(6, &device);
585 let (state, _m) = strategy.tell(¶ms, pop1, fitness, state, &mut rng);
586 let (pop2, _state) = strategy.ask(¶ms, &state, &mut rng, &device);
587 let values = pop2.into_data().into_vec::<f32>().unwrap();
588 for v in values {
589 assert!(
590 v >= lo - 1e-4 && v <= hi + 1e-4,
591 "seed {seed}: position {v} out of bounds [{lo}, {hi}]"
592 );
593 }
594 }
595 }
596
597 #[test]
598 fn pop_size_two_runs() {
599 let device = Default::default();
602 let strategy = WhaleOptimization::<TestBackend>::new();
603 let params = WoaConfig::default_for(2, 3);
604 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
605 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
606 strategy, params, fitness_fn, 0, device, 5,
607 )
608 .expect("valid params");
609 harness.reset();
610 while !harness.step(()).done {}
611 assert!(
612 harness
613 .latest_metrics()
614 .unwrap()
615 .best_fitness_ever()
616 .is_finite()
617 );
618 }
619}