1use std::f32::consts::PI;
31use std::marker::PhantomData;
32
33use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
34use rand::Rng;
35use rand::RngExt;
36use rand_distr::{Distribution as RandDistDist, Normal};
37
38use rlevo_core::bounds::Bounds;
39use rlevo_core::config::{self, ConfigError, Validate};
40
41use super::len_matches_pop;
42use crate::ops::selection::argmax_host;
43use crate::rng::{SeedPurpose, seed_stream};
44use crate::strategy::{Strategy, StrategyMetrics};
45
46#[derive(Debug, Clone)]
48pub struct CuckooConfig {
49 pub pop_size: usize,
51 pub genome_dim: usize,
53 pub bounds: Bounds,
55 pub alpha: f32,
59 pub beta: f32,
61 pub p_a: f32,
63}
64
65impl CuckooConfig {
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 alpha: 0.05,
74 beta: 1.5,
75 p_a: 0.25,
76 }
77 }
78}
79
80impl Validate for CuckooConfig {
81 fn validate(&self) -> Result<(), ConfigError> {
82 const C: &str = "CuckooConfig";
83 config::at_least(C, "pop_size", self.pop_size, 1)?;
84 config::nonzero(C, "genome_dim", self.genome_dim)?;
85 config::positive(C, "alpha", f64::from(self.alpha))?;
86 config::positive(C, "beta", f64::from(self.beta))?;
88 config::ordered(C, "beta", f64::from(self.beta), 2.0)?;
89 config::in_range(C, "p_a", 0.0, 1.0, f64::from(self.p_a))?;
90 Ok(())
91 }
92}
93
94#[derive(Debug, Clone)]
96pub struct CuckooState<B: Backend> {
97 nests: Tensor<B, 2>,
99 fitness: Vec<f32>,
101 best_genome: Option<Tensor<B, 2>>,
103 best_fitness: f32,
105 generation: usize,
107}
108
109impl<B: Backend> CuckooState<B> {
110 pub fn try_new(
117 nests: Tensor<B, 2>,
118 fitness: Vec<f32>,
119 best_genome: Option<Tensor<B, 2>>,
120 best_fitness: f32,
121 generation: usize,
122 ) -> Result<Self, ConfigError> {
123 let pop = nests.dims()[0];
124 config::nonzero("CuckooState", "pop_size", pop)?;
125 len_matches_pop("CuckooState", "fitness", pop, fitness.len())?;
126 Ok(Self {
127 nests,
128 fitness,
129 best_genome,
130 best_fitness,
131 generation,
132 })
133 }
134
135 #[must_use]
137 pub fn nests(&self) -> &Tensor<B, 2> {
138 &self.nests
139 }
140
141 #[must_use]
143 pub fn fitness(&self) -> &[f32] {
144 &self.fitness
145 }
146
147 #[must_use]
149 pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
150 self.best_genome.as_ref()
151 }
152
153 #[must_use]
155 pub fn best_fitness(&self) -> f32 {
156 self.best_fitness
157 }
158
159 #[must_use]
161 pub fn generation(&self) -> usize {
162 self.generation
163 }
164}
165
166#[derive(Debug, Clone, Copy, Default)]
179pub struct CuckooSearch<B: Backend> {
180 _backend: PhantomData<fn() -> B>,
181}
182
183impl<B: Backend> CuckooSearch<B> {
184 #[must_use]
186 pub fn new() -> Self {
187 Self {
188 _backend: PhantomData,
189 }
190 }
191
192 fn mantegna_sigma_u(beta: f32) -> f32 {
194 let num = gamma(1.0 + beta) * ((PI * beta) / 2.0).sin();
196 let den = gamma(f32::midpoint(1.0, beta)) * beta * 2f32.powf((beta - 1.0) / 2.0);
197 (num / den).powf(1.0 / beta)
198 }
199}
200
201#[allow(clippy::many_single_char_names)]
208fn gamma(z: f32) -> f32 {
209 let g = 7.0_f32;
212 let p: [f32; 9] = [
213 0.999_999_999_999_809_93,
214 676.520_4,
215 -1_259.139_2,
216 771.323_4,
217 -176.615_04,
218 12.507_343,
219 -0.138_571_1,
220 9.984_369e-6,
221 1.505_632_7e-7,
222 ];
223 if z < 0.5 {
224 return PI / ((PI * z).sin() * gamma(1.0 - z));
225 }
226 let z = z - 1.0;
227 let mut x = p[0];
228 for (i, &coef) in p.iter().enumerate().skip(1) {
229 #[allow(clippy::cast_precision_loss)]
230 let i_f32 = i as f32;
231 x += coef / (z + i_f32);
232 }
233 let t = z + g + 0.5;
234 (2.0 * PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * x
235}
236
237fn levy_step(u: f32, w: f32, beta: f32) -> f32 {
250 let denom: f32 = w.abs().powf(1.0 / beta);
251 if denom.is_finite() && denom > 0.0 {
252 u / denom
253 } else {
254 0.0
255 }
256}
257
258impl<B: Backend> Strategy<B> for CuckooSearch<B>
259where
260 B::Device: Clone,
261{
262 type Params = CuckooConfig;
263 type State = CuckooState<B>;
264 type Genome = Tensor<B, 2>;
265
266 fn init(
277 &self,
278 params: &CuckooConfig,
279 rng: &mut dyn Rng,
280 device: &<B as burn::tensor::backend::BackendTypes>::Device,
281 ) -> CuckooState<B> {
282 debug_assert!(
283 params.validate().is_ok(),
284 "invalid CuckooConfig reached init: {params:?}"
285 );
286 let (lo, hi): (f32, f32) = params.bounds.into();
287 let pop = params.pop_size;
292 let genome_dim = params.genome_dim;
293 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
294 let mut nest_rows = Vec::with_capacity(pop * genome_dim);
295 for _ in 0..pop * genome_dim {
296 nest_rows.push(lo + (hi - lo) * stream.random::<f32>());
297 }
298 let nests =
299 Tensor::<B, 2>::from_data(TensorData::new(nest_rows, [pop, genome_dim]), device);
300 CuckooState {
301 nests,
302 fitness: Vec::new(),
303 best_genome: None,
304 best_fitness: f32::NEG_INFINITY,
305 generation: 0,
306 }
307 }
308
309 fn ask(
319 &self,
320 params: &CuckooConfig,
321 state: &CuckooState<B>,
322 rng: &mut dyn Rng,
323 device: &<B as burn::tensor::backend::BackendTypes>::Device,
324 ) -> (Tensor<B, 2>, CuckooState<B>) {
325 if state.fitness.is_empty() {
326 return (state.nests.clone(), state.clone());
327 }
328
329 let pop = params.pop_size;
330 let d = params.genome_dim;
331 let sigma_u = Self::mantegna_sigma_u(params.beta);
332
333 let mut stream = seed_stream(
334 rng.next_u64(),
335 state.generation as u64,
336 SeedPurpose::Mutation,
337 );
338 let normal_u = Normal::new(0.0_f32, sigma_u).expect("σ_u > 0");
339 let mut step = vec![0f32; pop * d];
340 for v in &mut step {
341 let u: f32 = normal_u.sample(&mut stream);
342 let w: f32 = crate::sampling::standard_normal(&mut stream);
343 *v = levy_step(u, w, params.beta);
346 }
347 let step_tensor = Tensor::<B, 2>::from_data(TensorData::new(step, [pop, d]), device);
348
349 let (lo, hi): (f32, f32) = params.bounds.into();
350 let new_nests = (state.nests.clone() + step_tensor.mul_scalar(params.alpha)).clamp(lo, hi);
351
352 let mut next = state.clone();
353 next.nests.clone_from(&new_nests);
354 (new_nests, next)
355 }
356
357 fn tell(
372 &self,
373 params: &CuckooConfig,
374 population: Tensor<B, 2>,
375 fitness: Tensor<B, 1>,
376 mut state: CuckooState<B>,
377 rng: &mut dyn Rng,
378 ) -> (CuckooState<B>, StrategyMetrics) {
379 let fitness_host = fitness
380 .into_data()
381 .into_vec::<f32>()
382 .expect("fitness tensor must be readable as f32");
383 let device = population.device();
384 let pop = params.pop_size;
385 let d = params.genome_dim;
386
387 if state.fitness.is_empty() {
388 state.fitness.clone_from(&fitness_host);
389 let best_idx = argmax_host(&fitness_host);
390 state.best_fitness = fitness_host[best_idx];
391 #[allow(clippy::cast_possible_wrap)]
392 let idx = Tensor::<B, 1, Int>::from_data(
393 TensorData::new(vec![best_idx as i64], [1]),
394 &device,
395 );
396 state.best_genome = Some(population.clone().select(0, idx));
397 state.nests = population;
398 state.generation += 1;
399 let m = StrategyMetrics::from_host_fitness(
400 state.generation,
401 &fitness_host,
402 state.best_fitness,
403 );
404 state.best_fitness = m.best_fitness_ever();
405 return (state, m);
406 }
407
408 #[allow(clippy::cast_possible_wrap)]
410 let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
411 let mut new_fitness = state.fitness.clone();
412 for i in 0..pop {
413 if fitness_host[i] >= state.fitness[i] {
414 #[allow(clippy::cast_possible_wrap)]
415 {
416 rs[i] = (pop + i) as i64;
417 }
418 new_fitness[i] = fitness_host[i];
419 }
420 }
421 let stacked = Tensor::cat(vec![state.nests.clone(), population.clone()], 0);
422 let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
423 state.nests = stacked.select(0, idx);
424 state.fitness = new_fitness;
425
426 #[allow(
430 clippy::cast_possible_truncation,
431 clippy::cast_sign_loss,
432 clippy::cast_precision_loss
433 )]
434 let n_abandon = (params.p_a * pop as f32) as usize;
435 if n_abandon > 0 {
436 let mut rank: Vec<usize> = (0..pop).collect();
437 let sane: Vec<f32> = state
440 .fitness
441 .iter()
442 .map(|&f| crate::fitness::sanitize_fitness(f))
443 .collect();
444 rank.sort_by(|&a, &b| sane[a].total_cmp(&sane[b]));
445 let worst: Vec<usize> = rank.into_iter().take(n_abandon).collect();
446 let (lo, hi): (f32, f32) = params.bounds.into();
447 let mut abandon_stream = seed_stream(
451 rng.next_u64(),
452 state.generation as u64,
453 SeedPurpose::Replacement,
454 );
455 let mut fresh_rows = Vec::with_capacity(n_abandon * d);
456 for _ in 0..n_abandon * d {
457 fresh_rows.push(lo + (hi - lo) * abandon_stream.random::<f32>());
458 }
459 let fresh =
460 Tensor::<B, 2>::from_data(TensorData::new(fresh_rows, [n_abandon, d]), &device);
461 #[allow(clippy::cast_possible_wrap)]
462 let mut rs2: Vec<i64> = (0..pop).map(|i| i as i64).collect();
463 for (k, &slot) in worst.iter().enumerate() {
464 #[allow(clippy::cast_possible_wrap)]
465 {
466 rs2[slot] = (pop + k) as i64;
467 }
468 state.fitness[slot] = f32::NEG_INFINITY;
469 }
470 let stacked2 = Tensor::cat(vec![state.nests.clone(), fresh], 0);
471 let idx2 = Tensor::<B, 1, Int>::from_data(TensorData::new(rs2, [pop]), &device);
472 state.nests = stacked2.select(0, idx2);
473 }
474
475 let best_idx = argmax_host(&state.fitness);
477 if state.fitness[best_idx].is_finite() && state.fitness[best_idx] > state.best_fitness {
478 state.best_fitness = state.fitness[best_idx];
479 #[allow(clippy::cast_possible_wrap)]
480 let idx = Tensor::<B, 1, Int>::from_data(
481 TensorData::new(vec![best_idx as i64], [1]),
482 &device,
483 );
484 state.best_genome = Some(state.nests.clone().select(0, idx));
485 }
486
487 state.generation += 1;
488 let m =
489 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
490 state.best_fitness = m.best_fitness_ever();
491 (state, m)
492 }
493
494 fn best(&self, state: &CuckooState<B>) -> Option<(Tensor<B, 2>, f32)> {
497 state
498 .best_genome
499 .as_ref()
500 .map(|g| (g.clone(), state.best_fitness))
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::fitness::FromFitnessEvaluable;
508 use crate::strategy::EvolutionaryHarness;
509 use burn::backend::Flex;
510 use rand::SeedableRng;
511 use rand::rngs::StdRng;
512 use rlevo_core::fitness::FitnessEvaluable;
513
514 type TestBackend = Flex;
515
516 #[test]
517 fn try_new_checks_fitness_length() {
518 let device = Default::default();
519 let nests = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
520 assert!(CuckooState::try_new(nests.clone(), vec![1.0; 3], None, 1.0, 1).is_ok());
521 assert!(CuckooState::try_new(nests.clone(), vec![], None, f32::MIN, 0).is_ok());
522 assert!(CuckooState::try_new(nests, vec![1.0; 2], None, 1.0, 1).is_err());
523 let empty = Tensor::<TestBackend, 2>::zeros([0, 2], &device);
524 assert!(CuckooState::try_new(empty, vec![], None, 1.0, 0).is_err());
525 }
526
527 #[test]
528 fn default_config_validates() {
529 assert!(CuckooConfig::default_for(25, 10).validate().is_ok());
530 }
531
532 #[test]
533 fn rejects_beta_at_upper_bound() {
534 let mut cfg = CuckooConfig::default_for(25, 10);
535 cfg.beta = 2.0;
536 assert_eq!(cfg.validate().unwrap_err().field, "beta");
537 }
538
539 struct Sphere;
540 struct SphereFit;
541 impl FitnessEvaluable for SphereFit {
542 type Individual = Vec<f64>;
543 type Landscape = Sphere;
544 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
545 x.iter().map(|v| v * v).sum()
546 }
547 }
548
549 #[test]
550 fn gamma_matches_known_values() {
551 approx::assert_relative_eq!(gamma(1.0), 1.0, epsilon = 1e-4);
553 approx::assert_relative_eq!(gamma(2.0), 1.0, epsilon = 1e-4);
554 approx::assert_relative_eq!(gamma(5.0), 24.0, epsilon = 1e-3);
555 approx::assert_relative_eq!(gamma(0.5), PI.sqrt(), epsilon = 1e-3);
556 }
557
558 #[test]
559 fn mantegna_sigma_u_is_finite() {
560 let s = CuckooSearch::<TestBackend>::mantegna_sigma_u(1.5);
561 assert!(s.is_finite() && s > 0.0);
562 }
563
564 #[test]
565 fn cuckoo_reduces_on_sphere_d10() {
566 let device = Default::default();
575 let strategy = CuckooSearch::<TestBackend>::new();
576 let mut params = CuckooConfig::default_for(30, 10);
577 params.alpha = 0.2;
578 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
579 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
580 strategy, params, fitness_fn, 19, device, 800,
581 )
582 .expect("valid params");
583 harness.reset();
584 while !harness.step(()).done {}
585 let best = harness.latest_metrics().unwrap().best_fitness_ever();
586 assert!(best < 20.0, "Cuckoo D10 best={best}");
587 }
588
589 #[test]
590 #[allow(clippy::float_cmp)] fn levy_step_folds_pathological_denominator_to_zero() {
592 let beta: f32 = 1.5;
602
603 let unguarded_nan: f32 = 0.0_f32 / 0.0_f32.abs().powf(1.0 / beta);
605 assert!(unguarded_nan.is_nan());
606 assert_eq!(levy_step(0.0, 0.0, beta), 0.0);
607
608 let unguarded_inf: f32 = 1.0_f32 / 0.0_f32.abs().powf(1.0 / beta);
610 assert!(!unguarded_inf.is_finite());
611 assert_eq!(levy_step(1.0, 0.0, beta), 0.0);
612
613 assert_eq!(levy_step(1.0, f32::NAN, beta), 0.0);
615
616 let expected: f32 = 0.5_f32 / 1.2_f32.abs().powf(1.0 / beta);
619 let got: f32 = levy_step(0.5, 1.2, beta);
620 assert!(got.is_finite());
621 approx::assert_relative_eq!(got, expected, epsilon = 1e-6);
622 assert_eq!(got, expected);
624 }
625
626 struct PartialNanFitness;
629 impl<B: Backend> crate::fitness::BatchFitnessFn<B, Tensor<B, 2>> for PartialNanFitness {
630 fn evaluate_batch(
631 &mut self,
632 population: &Tensor<B, 2>,
633 device: &<B as burn::tensor::backend::BackendTypes>::Device,
634 ) -> Tensor<B, 1> {
635 let n = population.dims()[0];
636 #[allow(clippy::cast_precision_loss)]
637 let mut vals: Vec<f32> = (0..n).map(|i| -(i as f32)).collect();
638 vals[0] = f32::NAN;
639 Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
640 }
641 fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
642 rlevo_core::objective::ObjectiveSense::Maximize
643 }
644 }
645
646 #[test]
651 fn rejects_invalid_beta_values() {
652 for bad in [0.0_f32, 3.0, f32::NAN] {
653 let mut cfg = CuckooConfig::default_for(25, 10);
654 cfg.beta = bad;
655 assert_eq!(
656 cfg.validate().unwrap_err().field,
657 "beta",
658 "β = {bad} should be rejected on the beta field"
659 );
660 }
661 }
662
663 #[test]
666 #[should_panic(expected = "invalid range")]
667 fn inverted_bounds_are_unrepresentable() {
668 let _ = CuckooConfig {
669 bounds: Bounds::new(5.0, -5.0),
670 ..CuckooConfig::default_for(25, 10)
671 };
672 }
673
674 #[test]
678 fn abandonment_marks_floor_pa_pop_nests() {
679 let device = Default::default();
680 let strategy = CuckooSearch::<TestBackend>::new();
681 let params = CuckooConfig::default_for(8, 2); let nests = Tensor::<TestBackend, 2>::zeros([8, 2], &device);
683 let state = CuckooState::try_new(
684 nests,
685 vec![8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0],
686 None,
687 f32::NEG_INFINITY,
688 1,
689 )
690 .expect("valid state");
691 let eggs = Tensor::<TestBackend, 2>::full([8, 2], 5.0, &device);
694 let fit =
695 Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![0.0_f32; 8], [8]), &device);
696 let mut rng = StdRng::seed_from_u64(4);
697 let (next, _m) = strategy.tell(¶ms, eggs, fit, state, &mut rng);
698 let f = next.fitness();
699 let abandoned = f
700 .iter()
701 .filter(|v| v.is_infinite() && v.is_sign_negative())
702 .count();
703 assert_eq!(abandoned, 2, "expected floor(0.25 * 8) = 2 abandoned nests");
704 assert!(f[6].is_infinite() && f[6].is_sign_negative());
706 assert!(f[7].is_infinite() && f[7].is_sign_negative());
707 }
708
709 #[test]
713 #[allow(clippy::float_cmp)] fn greedy_accept_keeps_nests_on_all_worse_eggs() {
715 let device = Default::default();
716 let strategy = CuckooSearch::<TestBackend>::new();
717 let mut params = CuckooConfig::default_for(4, 2);
718 params.p_a = 0.0; let nest_vals = vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
720 let nests = Tensor::<TestBackend, 2>::from_data(
721 TensorData::new(nest_vals.clone(), [4, 2]),
722 &device,
723 );
724 let state =
725 CuckooState::try_new(nests, vec![4.0, 3.0, 2.0, 1.0], None, f32::NEG_INFINITY, 1)
726 .expect("valid state");
727 let eggs = Tensor::<TestBackend, 2>::full([4, 2], 9.0, &device);
728 let fit =
729 Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![0.0_f32; 4], [4]), &device);
730 let mut rng = StdRng::seed_from_u64(5);
731 let (next, _m) = strategy.tell(¶ms, eggs, fit, state, &mut rng);
732 let after = next
733 .nests()
734 .clone()
735 .into_data()
736 .into_vec::<f32>()
737 .expect("nests readable as f32");
738 assert_eq!(after, nest_vals);
739 }
740
741 #[test]
745 fn best_so_far_is_monotone() {
746 let device = Default::default();
747 let strategy = CuckooSearch::<TestBackend>::new();
748 let params = CuckooConfig::default_for(20, 6);
749 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
750 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
751 strategy, params, fitness_fn, 11, device, 40,
752 )
753 .expect("valid params");
754 harness.reset();
755 let mut prev = f32::INFINITY;
756 loop {
757 let done = harness.step(()).done;
758 let cur = harness.latest_metrics().unwrap().best_fitness_ever();
759 assert!(
760 cur <= prev + 1e-6,
761 "best_fitness_ever worsened: {cur} > {prev}"
762 );
763 prev = cur;
764 if done {
765 break;
766 }
767 }
768 }
769
770 #[test]
773 fn nan_fitness_survives_harness() {
774 let device = Default::default();
775 let strategy = CuckooSearch::<TestBackend>::new();
776 let params = CuckooConfig::default_for(8, 3);
777 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
778 strategy,
779 params,
780 PartialNanFitness,
781 4,
782 device,
783 4,
784 )
785 .expect("valid params");
786 harness.reset();
787 while !harness.step(()).done {}
788 let m = harness.latest_metrics().unwrap();
789 assert!(
790 m.best_fitness_ever().is_finite(),
791 "best_fitness_ever not finite: {}",
792 m.best_fitness_ever()
793 );
794 assert!(m.broken_count() > 0, "expected a broken (NaN) member");
795 }
796}