1use std::marker::PhantomData;
29
30use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
31use rand::Rng;
32use rand::RngExt;
33
34use rlevo_core::bounds::Bounds;
35use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
36
37use super::len_matches_pop;
38use crate::ops::selection::argmax_host;
39use crate::rng::{SeedPurpose, seed_stream};
40use crate::strategy::{Strategy, StrategyMetrics};
41
42#[derive(Debug, Clone)]
44pub struct BatConfig {
45 pub pop_size: usize,
47 pub genome_dim: usize,
49 pub bounds: Bounds,
51 pub f_min: f32,
53 pub f_max: f32,
55 pub a0: f32,
57 pub r0: f32,
59 pub alpha: f32,
61 pub gamma: f32,
63}
64
65impl BatConfig {
66 #[must_use]
68 pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
69 Self {
70 pop_size,
71 genome_dim,
72 bounds: Bounds::new(-5.12, 5.12),
73 f_min: 0.0,
74 f_max: 2.0,
75 a0: 1.0,
76 r0: 0.5,
77 alpha: 0.9,
78 gamma: 0.9,
79 }
80 }
81}
82
83impl Validate for BatConfig {
84 fn validate(&self) -> Result<(), ConfigError> {
85 const C: &str = "BatConfig";
86 config::at_least(C, "pop_size", self.pop_size, 1)?;
87 config::nonzero(C, "genome_dim", self.genome_dim)?;
88 if self.f_min > self.f_max {
89 return Err(ConfigError {
90 config: C,
91 field: "f_min",
92 kind: ConstraintKind::Custom("f_min must not exceed f_max"),
93 });
94 }
95 config::in_range(C, "a0", 0.0, f64::INFINITY, f64::from(self.a0))?;
96 config::in_range(C, "r0", 0.0, 1.0, f64::from(self.r0))?;
97 config::positive(C, "alpha", f64::from(self.alpha))?;
99 config::in_range(C, "alpha", 0.0, 1.0, f64::from(self.alpha))?;
100 config::positive(C, "gamma", f64::from(self.gamma))?;
101 Ok(())
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct BatState<B: Backend> {
108 positions: Tensor<B, 2>,
110 velocities: Tensor<B, 2>,
112 loudness: Vec<f32>,
114 pulse_rate: Vec<f32>,
116 fitness: Vec<f32>,
118 best_genome: Option<Tensor<B, 2>>,
120 best_fitness: f32,
122 generation: usize,
124 pending_accept: Vec<bool>,
128}
129
130impl<B: Backend> BatState<B> {
131 #[allow(clippy::too_many_arguments)]
139 pub fn try_new(
140 positions: Tensor<B, 2>,
141 velocities: Tensor<B, 2>,
142 loudness: Vec<f32>,
143 pulse_rate: Vec<f32>,
144 fitness: Vec<f32>,
145 best_genome: Option<Tensor<B, 2>>,
146 best_fitness: f32,
147 generation: usize,
148 pending_accept: Vec<bool>,
149 ) -> Result<Self, ConfigError> {
150 let pop = positions.dims()[0];
151 config::nonzero("BatState", "pop_size", pop)?;
152 len_matches_pop("BatState", "loudness", pop, loudness.len())?;
153 len_matches_pop("BatState", "pulse_rate", pop, pulse_rate.len())?;
154 len_matches_pop("BatState", "fitness", pop, fitness.len())?;
155 len_matches_pop("BatState", "pending_accept", pop, pending_accept.len())?;
156 Ok(Self {
157 positions,
158 velocities,
159 loudness,
160 pulse_rate,
161 fitness,
162 best_genome,
163 best_fitness,
164 generation,
165 pending_accept,
166 })
167 }
168
169 #[must_use]
171 pub fn positions(&self) -> &Tensor<B, 2> {
172 &self.positions
173 }
174
175 #[must_use]
177 pub fn velocities(&self) -> &Tensor<B, 2> {
178 &self.velocities
179 }
180
181 #[must_use]
183 pub fn loudness(&self) -> &[f32] {
184 &self.loudness
185 }
186
187 #[must_use]
189 pub fn pulse_rate(&self) -> &[f32] {
190 &self.pulse_rate
191 }
192
193 #[must_use]
195 pub fn fitness(&self) -> &[f32] {
196 &self.fitness
197 }
198
199 #[must_use]
201 pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
202 self.best_genome.as_ref()
203 }
204
205 #[must_use]
207 pub fn best_fitness(&self) -> f32 {
208 self.best_fitness
209 }
210
211 #[must_use]
213 pub fn generation(&self) -> usize {
214 self.generation
215 }
216
217 #[must_use]
220 pub fn pending_accept(&self) -> &[bool] {
221 &self.pending_accept
222 }
223}
224
225#[derive(Debug, Clone, Copy, Default)]
238pub struct BatAlgorithm<B: Backend> {
239 _backend: PhantomData<fn() -> B>,
240}
241
242impl<B: Backend> BatAlgorithm<B> {
243 #[must_use]
245 pub fn new() -> Self {
246 Self {
247 _backend: PhantomData,
248 }
249 }
250}
251
252impl<B: Backend> Strategy<B> for BatAlgorithm<B>
253where
254 B::Device: Clone,
255{
256 type Params = BatConfig;
257 type State = BatState<B>;
258 type Genome = Tensor<B, 2>;
259
260 fn init(
270 &self,
271 params: &BatConfig,
272 rng: &mut dyn Rng,
273 device: &<B as burn::tensor::backend::BackendTypes>::Device,
274 ) -> BatState<B> {
275 debug_assert!(
276 params.validate().is_ok(),
277 "invalid BatConfig reached init: {params:?}"
278 );
279 let (lo, hi): (f32, f32) = params.bounds.into();
280 let pop = params.pop_size;
288 let genome_dim = params.genome_dim;
289 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
290 let mut position_rows = Vec::with_capacity(pop * genome_dim);
291 for _ in 0..pop * genome_dim {
292 position_rows.push(lo + (hi - lo) * stream.random::<f32>());
293 }
294 let positions =
295 Tensor::<B, 2>::from_data(TensorData::new(position_rows, [pop, genome_dim]), device);
296 let velocities = Tensor::<B, 2>::zeros([params.pop_size, params.genome_dim], device);
297 BatState {
298 positions,
299 velocities,
300 loudness: vec![params.a0; params.pop_size],
301 pulse_rate: vec![params.r0; params.pop_size],
302 fitness: Vec::new(),
303 best_genome: None,
304 best_fitness: f32::NEG_INFINITY,
305 generation: 0,
306 pending_accept: Vec::new(),
307 }
308 }
309
310 fn ask(
335 &self,
336 params: &BatConfig,
337 state: &BatState<B>,
338 rng: &mut dyn Rng,
339 device: &<B as burn::tensor::backend::BackendTypes>::Device,
340 ) -> (Tensor<B, 2>, BatState<B>) {
341 if state.fitness.is_empty() {
342 return (state.positions.clone(), state.clone());
345 }
346
347 let pop = params.pop_size;
348 let genome_dim = params.genome_dim;
349 let (lo, hi): (f32, f32) = params.bounds.into();
350
351 let mut stream = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
356
357 let mut betas = Vec::with_capacity(pop);
358 let mut use_local = Vec::with_capacity(pop);
359 let mut accept_draw = Vec::with_capacity(pop);
360 let mut epsilon_rows = Vec::with_capacity(pop * genome_dim);
361 for i in 0..pop {
362 betas.push(stream.random::<f32>());
363 use_local.push(stream.random::<f32>() > state.pulse_rate[i]);
364 accept_draw.push(stream.random::<f32>());
365 for _ in 0..genome_dim {
366 epsilon_rows.push(2.0 * stream.random::<f32>() - 1.0);
367 }
368 }
369
370 let mean_loudness: f32 = {
373 let s: f32 = state.loudness.iter().sum();
374 #[allow(clippy::cast_precision_loss)]
375 {
376 s / pop as f32
377 }
378 };
379
380 let best = state
381 .best_genome
382 .as_ref()
383 .expect("best populated after first tell")
384 .clone()
385 .expand([pop, genome_dim]);
386
387 let f_vec: Vec<f32> = betas
389 .iter()
390 .map(|b| params.f_min + (params.f_max - params.f_min) * b)
391 .collect();
392 let f_mat = Tensor::<B, 1>::from_data(TensorData::new(f_vec, [pop]), device)
393 .unsqueeze_dim::<2>(1)
394 .expand([pop, genome_dim]);
395
396 let span = (hi - lo).abs();
399 let new_velocities = (state.velocities.clone()
400 + (state.positions.clone() - best.clone()).mul(f_mat))
401 .clamp(-span, span);
402 let global_move = state.positions.clone() + new_velocities.clone();
403 let eps =
405 Tensor::<B, 2>::from_data(TensorData::new(epsilon_rows, [pop, genome_dim]), device);
406 let local_move = best + eps.mul_scalar(mean_loudness);
407
408 #[allow(clippy::cast_possible_wrap)]
409 let mask = Tensor::<B, 1, Int>::from_data(
410 TensorData::new(
411 use_local.iter().map(|&b| i64::from(b)).collect::<Vec<_>>(),
412 [pop],
413 ),
414 device,
415 )
416 .equal_elem(1)
417 .unsqueeze_dim::<2>(1)
418 .expand([pop, genome_dim]);
419 let candidates = global_move.mask_where(mask, local_move).clamp(lo, hi);
420
421 let mut next = state.clone();
423 next.velocities = new_velocities;
424 next.pending_accept = accept_draw
425 .iter()
426 .zip(state.loudness.iter())
427 .map(|(&draw, &a)| draw < a)
428 .collect();
429 (candidates, next)
430 }
431
432 fn tell(
445 &self,
446 params: &BatConfig,
447 candidates: Tensor<B, 2>,
448 fitness: Tensor<B, 1>,
449 mut state: BatState<B>,
450 _rng: &mut dyn Rng,
451 ) -> (BatState<B>, StrategyMetrics) {
452 let fitness_host = fitness
453 .into_data()
454 .into_vec::<f32>()
455 .expect("fitness tensor must be readable as f32");
456 let device = candidates.device();
457 let pop = params.pop_size;
458 let genome_dim = params.genome_dim;
459
460 if state.fitness.is_empty() {
461 state.fitness.clone_from(&fitness_host);
462 let best_idx = argmax_host(&fitness_host);
463 state.best_fitness = fitness_host[best_idx];
464 #[allow(clippy::cast_possible_wrap)]
465 let idx = Tensor::<B, 1, Int>::from_data(
466 TensorData::new(vec![best_idx as i64], [1]),
467 &device,
468 );
469 state.best_genome = Some(candidates.clone().select(0, idx));
470 state.positions = candidates;
471 state.generation += 1;
472 let m = StrategyMetrics::from_host_fitness(
473 state.generation,
474 &fitness_host,
475 state.best_fitness,
476 );
477 state.best_fitness = m.best_fitness_ever();
478 return (state, m);
479 }
480
481 #[allow(clippy::cast_possible_wrap)]
484 let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
485 let mut new_fitness = state.fitness.clone();
486 #[allow(clippy::cast_precision_loss)]
487 let t = state.generation as f32;
488 for i in 0..pop {
489 let accept_gate = state.pending_accept.get(i).copied().unwrap_or(false);
490 let improves = fitness_host[i] >= state.fitness[i];
491 if accept_gate && improves {
492 #[allow(clippy::cast_possible_wrap)]
493 {
494 rs[i] = (pop + i) as i64;
495 }
496 new_fitness[i] = fitness_host[i];
497 state.loudness[i] *= params.alpha;
498 state.pulse_rate[i] = params.r0 * (1.0 - (-params.gamma * t).exp());
499 }
500 }
501 let stacked = Tensor::cat(vec![state.positions.clone(), candidates], 0);
502 let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
503 state.positions = stacked.select(0, idx);
504 state.fitness = new_fitness;
505
506 let best_idx = argmax_host(&state.fitness);
508 if state.fitness[best_idx] > state.best_fitness {
509 state.best_fitness = state.fitness[best_idx];
510 #[allow(clippy::cast_possible_wrap)]
511 let idx = Tensor::<B, 1, Int>::from_data(
512 TensorData::new(vec![best_idx as i64], [1]),
513 &device,
514 );
515 state.best_genome = Some(state.positions.clone().select(0, idx));
516 }
517
518 state.generation += 1;
519 let m =
520 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
521 state.best_fitness = m.best_fitness_ever();
522 let _ = genome_dim;
523 (state, m)
524 }
525
526 fn best(&self, state: &BatState<B>) -> Option<(Tensor<B, 2>, f32)> {
529 state
530 .best_genome
531 .as_ref()
532 .map(|g| (g.clone(), state.best_fitness))
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::fitness::FromFitnessEvaluable;
540 use crate::strategy::EvolutionaryHarness;
541 use burn::backend::Flex;
542 use rand::SeedableRng;
543 use rand::rngs::StdRng;
544 use rlevo_core::fitness::FitnessEvaluable;
545
546 type TestBackend = Flex;
547
548 #[test]
549 fn try_new_checks_cache_lengths() {
550 let device = Default::default();
551 let pos = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
552 let vel = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
553 assert!(
554 BatState::try_new(
555 pos.clone(),
556 vel.clone(),
557 vec![1.0; 3],
558 vec![0.5; 3],
559 vec![1.0; 3],
560 None,
561 1.0,
562 1,
563 vec![false; 3],
564 )
565 .is_ok()
566 );
567 assert!(
569 BatState::try_new(
570 pos,
571 vel,
572 vec![1.0; 2],
573 vec![0.5; 3],
574 vec![1.0; 3],
575 None,
576 1.0,
577 1,
578 vec![false; 3],
579 )
580 .is_err()
581 );
582 }
583
584 #[test]
585 fn default_config_validates() {
586 assert!(BatConfig::default_for(30, 10).validate().is_ok());
587 }
588
589 #[test]
590 fn rejects_inverted_frequency_range() {
591 let mut cfg = BatConfig::default_for(30, 10);
592 cfg.f_min = 3.0;
593 cfg.f_max = 1.0;
594 assert_eq!(cfg.validate().unwrap_err().field, "f_min");
595 }
596
597 struct Sphere;
598 struct SphereFit;
599 impl FitnessEvaluable for SphereFit {
600 type Individual = Vec<f64>;
601 type Landscape = Sphere;
602 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
603 x.iter().map(|v| v * v).sum()
604 }
605 }
606
607 #[test]
608 fn bat_converges_on_sphere_d10() {
609 let device = Default::default();
616 let strategy = BatAlgorithm::<TestBackend>::new();
617 let params = BatConfig::default_for(40, 10);
618 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
619 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
620 strategy, params, fitness_fn, 23, device, 800,
621 )
622 .expect("valid params");
623 harness.reset();
624 while !harness.step(()).done {}
625 let best = harness.latest_metrics().unwrap().best_fitness_ever();
626 assert!(best < 0.1, "Bat D10 best={best}");
627 }
628
629 #[test]
630 fn velocities_stay_finite_and_bounded_under_pinning() {
631 let device = Default::default();
642 let strategy = BatAlgorithm::<TestBackend>::new();
643 let params = BatConfig::default_for(20, 4);
644 let (lo, hi): (f32, f32) = params.bounds.into();
645 let span = (hi - lo).abs();
646 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
647 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
648 strategy, params, fitness_fn, 7, device, 100,
649 )
650 .expect("valid params");
651 harness.reset();
652 while !harness.step(()).done {}
653 let velocities: Vec<f32> = harness
654 .state()
655 .expect("state populated after stepping")
656 .velocities()
657 .clone()
658 .into_data()
659 .into_vec::<f32>()
660 .expect("velocities readable as f32");
661 for v in velocities {
662 assert!(v.is_finite(), "velocity not finite: {v}");
663 assert!(
664 v.abs() <= span + 1e-3,
665 "velocity {v} exceeds search span {span}"
666 );
667 }
668 }
669
670 struct PartialNanFitness;
673 impl<B: Backend> crate::fitness::BatchFitnessFn<B, Tensor<B, 2>> for PartialNanFitness {
674 fn evaluate_batch(
675 &mut self,
676 population: &Tensor<B, 2>,
677 device: &<B as burn::tensor::backend::BackendTypes>::Device,
678 ) -> Tensor<B, 1> {
679 let n = population.dims()[0];
680 #[allow(clippy::cast_precision_loss)]
681 let mut vals: Vec<f32> = (0..n).map(|i| -(i as f32)).collect();
682 vals[0] = f32::NAN;
683 Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
684 }
685 fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
686 rlevo_core::objective::ObjectiveSense::Maximize
687 }
688 }
689
690 fn steady_state(
694 pop: usize,
695 d: usize,
696 device: burn::backend::flex::FlexDevice,
697 ) -> BatState<TestBackend> {
698 let positions = Tensor::<TestBackend, 2>::zeros([pop, d], &device);
699 let velocities = Tensor::<TestBackend, 2>::zeros([pop, d], &device);
700 let best = Tensor::<TestBackend, 2>::zeros([1, d], &device);
701 BatState::try_new(
702 positions,
703 velocities,
704 vec![1.0; pop],
705 vec![0.5; pop],
706 vec![0.0; pop],
707 Some(best),
708 0.0,
709 1,
710 vec![false; pop],
711 )
712 .expect("valid steady state")
713 }
714
715 #[test]
717 fn best_is_none_before_first_tell() {
718 let device = Default::default();
719 let strategy = BatAlgorithm::<TestBackend>::new();
720 let params = BatConfig::default_for(8, 4);
721 let mut rng = StdRng::seed_from_u64(1);
722 let state = strategy.init(¶ms, &mut rng, &device);
723 assert!(strategy.best(&state).is_none());
724 }
725
726 #[test]
729 fn degenerate_dims_run() {
730 for (pop, d) in [(1usize, 4usize), (6, 1)] {
731 let device = Default::default();
732 let strategy = BatAlgorithm::<TestBackend>::new();
733 let params = BatConfig::default_for(pop, d);
734 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
735 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
736 strategy, params, fitness_fn, 3, device, 8,
737 )
738 .expect("valid params");
739 harness.reset();
740 while !harness.step(()).done {}
741 assert!(
742 harness
743 .latest_metrics()
744 .unwrap()
745 .best_fitness_ever()
746 .is_finite(),
747 "non-finite best for (pop={pop}, d={d})"
748 );
749 }
750 }
751
752 #[test]
755 fn proposed_positions_within_bounds() {
756 let device = Default::default();
757 let strategy = BatAlgorithm::<TestBackend>::new();
758 let params = BatConfig::default_for(10, 4);
759 let (lo, hi): (f32, f32) = params.bounds.into();
760 let state = steady_state(10, 4, device);
761 for seed in 0..32 {
762 let mut rng = StdRng::seed_from_u64(seed);
763 let (cand, _next) = strategy.ask(¶ms, &state, &mut rng, &device);
764 let vals = cand
765 .into_data()
766 .into_vec::<f32>()
767 .expect("candidates readable as f32");
768 for &v in &vals {
769 assert!(
770 v >= lo && v <= hi,
771 "candidate {v} out of bounds [{lo}, {hi}] (seed {seed})"
772 );
773 }
774 }
775 }
776
777 #[test]
784 fn loudness_decay_pulse_growth_and_acceptance_gate() {
785 let device = Default::default();
786 let strategy = BatAlgorithm::<TestBackend>::new();
787 let params = BatConfig::default_for(2, 1); let generation: usize = 3;
789 let state = BatState::try_new(
790 Tensor::<TestBackend, 2>::zeros([2, 1], &device),
791 Tensor::<TestBackend, 2>::zeros([2, 1], &device),
792 vec![1.0, 1.0], vec![0.5, 0.5], vec![0.0, 0.0], Some(Tensor::<TestBackend, 2>::zeros([1, 1], &device)),
796 0.0,
797 generation,
798 vec![true, false], )
800 .expect("valid state");
801 let candidates = Tensor::<TestBackend, 2>::full([2, 1], 0.1, &device);
802 let fit =
804 Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![1.0_f32, 1.0], [2]), &device);
805 let mut rng = StdRng::seed_from_u64(0);
806 let (next, _m) = strategy.tell(¶ms, candidates, fit, state, &mut rng);
807
808 approx::assert_relative_eq!(next.loudness()[0], 0.9, epsilon = 1e-6);
810 approx::assert_relative_eq!(next.loudness()[1], 1.0, epsilon = 1e-6);
811
812 #[allow(clippy::cast_precision_loss)]
814 let expected_pulse = 0.5 * (1.0 - (-0.9_f32 * generation as f32).exp());
815 approx::assert_relative_eq!(next.pulse_rate()[0], expected_pulse, epsilon = 1e-6);
816 approx::assert_relative_eq!(next.pulse_rate()[1], 0.5, epsilon = 1e-6);
817
818 let pos = next
820 .positions()
821 .clone()
822 .into_data()
823 .into_vec::<f32>()
824 .expect("positions readable as f32");
825 approx::assert_relative_eq!(pos[0], 0.1, epsilon = 1e-6);
826 approx::assert_relative_eq!(pos[1], 0.0, epsilon = 1e-6);
827 }
828
829 #[test]
832 fn nan_fitness_survives_harness() {
833 let device = Default::default();
834 let strategy = BatAlgorithm::<TestBackend>::new();
835 let params = BatConfig::default_for(8, 3);
836 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
837 strategy,
838 params,
839 PartialNanFitness,
840 4,
841 device,
842 4,
843 )
844 .expect("valid params");
845 harness.reset();
846 while !harness.step(()).done {}
847 let m = harness.latest_metrics().unwrap();
848 assert!(
849 m.best_fitness_ever().is_finite(),
850 "best_fitness_ever not finite: {}",
851 m.best_fitness_ever()
852 );
853 assert!(m.broken_count() > 0, "expected a broken (NaN) member");
854 }
855}