use std::f32::consts::PI;
use std::marker::PhantomData;
use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
use rand::Rng;
use rand::RngExt;
use rand_distr::{Distribution as RandDistDist, Normal};
use rlevo_core::bounds::Bounds;
use rlevo_core::config::{self, ConfigError, Validate};
use super::len_matches_pop;
use crate::ops::selection::argmax_host;
use crate::rng::{SeedPurpose, seed_stream};
use crate::strategy::{Strategy, StrategyMetrics};
#[derive(Debug, Clone)]
pub struct CuckooConfig {
pub pop_size: usize,
pub genome_dim: usize,
pub bounds: Bounds,
pub alpha: f32,
pub beta: f32,
pub p_a: f32,
}
impl CuckooConfig {
#[must_use]
pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
Self {
pop_size,
genome_dim,
bounds: Bounds::new(-5.12, 5.12),
alpha: 0.05,
beta: 1.5,
p_a: 0.25,
}
}
}
impl Validate for CuckooConfig {
fn validate(&self) -> Result<(), ConfigError> {
const C: &str = "CuckooConfig";
config::at_least(C, "pop_size", self.pop_size, 1)?;
config::nonzero(C, "genome_dim", self.genome_dim)?;
config::positive(C, "alpha", f64::from(self.alpha))?;
config::positive(C, "beta", f64::from(self.beta))?;
config::ordered(C, "beta", f64::from(self.beta), 2.0)?;
config::in_range(C, "p_a", 0.0, 1.0, f64::from(self.p_a))?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CuckooState<B: Backend> {
nests: Tensor<B, 2>,
fitness: Vec<f32>,
best_genome: Option<Tensor<B, 2>>,
best_fitness: f32,
generation: usize,
}
impl<B: Backend> CuckooState<B> {
pub fn try_new(
nests: Tensor<B, 2>,
fitness: Vec<f32>,
best_genome: Option<Tensor<B, 2>>,
best_fitness: f32,
generation: usize,
) -> Result<Self, ConfigError> {
let pop = nests.dims()[0];
config::nonzero("CuckooState", "pop_size", pop)?;
len_matches_pop("CuckooState", "fitness", pop, fitness.len())?;
Ok(Self {
nests,
fitness,
best_genome,
best_fitness,
generation,
})
}
#[must_use]
pub fn nests(&self) -> &Tensor<B, 2> {
&self.nests
}
#[must_use]
pub fn fitness(&self) -> &[f32] {
&self.fitness
}
#[must_use]
pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
self.best_genome.as_ref()
}
#[must_use]
pub fn best_fitness(&self) -> f32 {
self.best_fitness
}
#[must_use]
pub fn generation(&self) -> usize {
self.generation
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CuckooSearch<B: Backend> {
_backend: PhantomData<fn() -> B>,
}
impl<B: Backend> CuckooSearch<B> {
#[must_use]
pub fn new() -> Self {
Self {
_backend: PhantomData,
}
}
fn mantegna_sigma_u(beta: f32) -> f32 {
let num = gamma(1.0 + beta) * ((PI * beta) / 2.0).sin();
let den = gamma(f32::midpoint(1.0, beta)) * beta * 2f32.powf((beta - 1.0) / 2.0);
(num / den).powf(1.0 / beta)
}
}
#[allow(clippy::many_single_char_names)]
fn gamma(z: f32) -> f32 {
let g = 7.0_f32;
let p: [f32; 9] = [
0.999_999_999_999_809_93,
676.520_4,
-1_259.139_2,
771.323_4,
-176.615_04,
12.507_343,
-0.138_571_1,
9.984_369e-6,
1.505_632_7e-7,
];
if z < 0.5 {
return PI / ((PI * z).sin() * gamma(1.0 - z));
}
let z = z - 1.0;
let mut x = p[0];
for (i, &coef) in p.iter().enumerate().skip(1) {
#[allow(clippy::cast_precision_loss)]
let i_f32 = i as f32;
x += coef / (z + i_f32);
}
let t = z + g + 0.5;
(2.0 * PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * x
}
fn levy_step(u: f32, w: f32, beta: f32) -> f32 {
let denom: f32 = w.abs().powf(1.0 / beta);
if denom.is_finite() && denom > 0.0 {
u / denom
} else {
0.0
}
}
impl<B: Backend> Strategy<B> for CuckooSearch<B>
where
B::Device: Clone,
{
type Params = CuckooConfig;
type State = CuckooState<B>;
type Genome = Tensor<B, 2>;
fn init(
&self,
params: &CuckooConfig,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> CuckooState<B> {
debug_assert!(
params.validate().is_ok(),
"invalid CuckooConfig reached init: {params:?}"
);
let (lo, hi): (f32, f32) = params.bounds.into();
let pop = params.pop_size;
let genome_dim = params.genome_dim;
let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
let mut nest_rows = Vec::with_capacity(pop * genome_dim);
for _ in 0..pop * genome_dim {
nest_rows.push(lo + (hi - lo) * stream.random::<f32>());
}
let nests =
Tensor::<B, 2>::from_data(TensorData::new(nest_rows, [pop, genome_dim]), device);
CuckooState {
nests,
fitness: Vec::new(),
best_genome: None,
best_fitness: f32::NEG_INFINITY,
generation: 0,
}
}
fn ask(
&self,
params: &CuckooConfig,
state: &CuckooState<B>,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> (Tensor<B, 2>, CuckooState<B>) {
if state.fitness.is_empty() {
return (state.nests.clone(), state.clone());
}
let pop = params.pop_size;
let d = params.genome_dim;
let sigma_u = Self::mantegna_sigma_u(params.beta);
let mut stream = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Mutation,
);
let normal_u = Normal::new(0.0_f32, sigma_u).expect("σ_u > 0");
let mut step = vec![0f32; pop * d];
for v in &mut step {
let u: f32 = normal_u.sample(&mut stream);
let w: f32 = crate::sampling::standard_normal(&mut stream);
*v = levy_step(u, w, params.beta);
}
let step_tensor = Tensor::<B, 2>::from_data(TensorData::new(step, [pop, d]), device);
let (lo, hi): (f32, f32) = params.bounds.into();
let new_nests = (state.nests.clone() + step_tensor.mul_scalar(params.alpha)).clamp(lo, hi);
let mut next = state.clone();
next.nests.clone_from(&new_nests);
(new_nests, next)
}
fn tell(
&self,
params: &CuckooConfig,
population: Tensor<B, 2>,
fitness: Tensor<B, 1>,
mut state: CuckooState<B>,
rng: &mut dyn Rng,
) -> (CuckooState<B>, StrategyMetrics) {
let fitness_host = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32");
let device = population.device();
let pop = params.pop_size;
let d = params.genome_dim;
if state.fitness.is_empty() {
state.fitness.clone_from(&fitness_host);
let best_idx = argmax_host(&fitness_host);
state.best_fitness = fitness_host[best_idx];
#[allow(clippy::cast_possible_wrap)]
let idx = Tensor::<B, 1, Int>::from_data(
TensorData::new(vec![best_idx as i64], [1]),
&device,
);
state.best_genome = Some(population.clone().select(0, idx));
state.nests = population;
state.generation += 1;
let m = StrategyMetrics::from_host_fitness(
state.generation,
&fitness_host,
state.best_fitness,
);
state.best_fitness = m.best_fitness_ever();
return (state, m);
}
#[allow(clippy::cast_possible_wrap)]
let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
let mut new_fitness = state.fitness.clone();
for i in 0..pop {
if fitness_host[i] >= state.fitness[i] {
#[allow(clippy::cast_possible_wrap)]
{
rs[i] = (pop + i) as i64;
}
new_fitness[i] = fitness_host[i];
}
}
let stacked = Tensor::cat(vec![state.nests.clone(), population.clone()], 0);
let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
state.nests = stacked.select(0, idx);
state.fitness = new_fitness;
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
let n_abandon = (params.p_a * pop as f32) as usize;
if n_abandon > 0 {
let mut rank: Vec<usize> = (0..pop).collect();
let sane: Vec<f32> = state
.fitness
.iter()
.map(|&f| crate::fitness::sanitize_fitness(f))
.collect();
rank.sort_by(|&a, &b| sane[a].total_cmp(&sane[b]));
let worst: Vec<usize> = rank.into_iter().take(n_abandon).collect();
let (lo, hi): (f32, f32) = params.bounds.into();
let mut abandon_stream = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Replacement,
);
let mut fresh_rows = Vec::with_capacity(n_abandon * d);
for _ in 0..n_abandon * d {
fresh_rows.push(lo + (hi - lo) * abandon_stream.random::<f32>());
}
let fresh =
Tensor::<B, 2>::from_data(TensorData::new(fresh_rows, [n_abandon, d]), &device);
#[allow(clippy::cast_possible_wrap)]
let mut rs2: Vec<i64> = (0..pop).map(|i| i as i64).collect();
for (k, &slot) in worst.iter().enumerate() {
#[allow(clippy::cast_possible_wrap)]
{
rs2[slot] = (pop + k) as i64;
}
state.fitness[slot] = f32::NEG_INFINITY;
}
let stacked2 = Tensor::cat(vec![state.nests.clone(), fresh], 0);
let idx2 = Tensor::<B, 1, Int>::from_data(TensorData::new(rs2, [pop]), &device);
state.nests = stacked2.select(0, idx2);
}
let best_idx = argmax_host(&state.fitness);
if state.fitness[best_idx].is_finite() && state.fitness[best_idx] > state.best_fitness {
state.best_fitness = state.fitness[best_idx];
#[allow(clippy::cast_possible_wrap)]
let idx = Tensor::<B, 1, Int>::from_data(
TensorData::new(vec![best_idx as i64], [1]),
&device,
);
state.best_genome = Some(state.nests.clone().select(0, idx));
}
state.generation += 1;
let m =
StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
state.best_fitness = m.best_fitness_ever();
(state, m)
}
fn best(&self, state: &CuckooState<B>) -> Option<(Tensor<B, 2>, f32)> {
state
.best_genome
.as_ref()
.map(|g| (g.clone(), state.best_fitness))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fitness::FromFitnessEvaluable;
use crate::strategy::EvolutionaryHarness;
use burn::backend::Flex;
use rand::SeedableRng;
use rand::rngs::StdRng;
use rlevo_core::fitness::FitnessEvaluable;
type TestBackend = Flex;
#[test]
fn try_new_checks_fitness_length() {
let device = Default::default();
let nests = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
assert!(CuckooState::try_new(nests.clone(), vec![1.0; 3], None, 1.0, 1).is_ok());
assert!(CuckooState::try_new(nests.clone(), vec![], None, f32::MIN, 0).is_ok());
assert!(CuckooState::try_new(nests, vec![1.0; 2], None, 1.0, 1).is_err());
let empty = Tensor::<TestBackend, 2>::zeros([0, 2], &device);
assert!(CuckooState::try_new(empty, vec![], None, 1.0, 0).is_err());
}
#[test]
fn default_config_validates() {
assert!(CuckooConfig::default_for(25, 10).validate().is_ok());
}
#[test]
fn rejects_beta_at_upper_bound() {
let mut cfg = CuckooConfig::default_for(25, 10);
cfg.beta = 2.0;
assert_eq!(cfg.validate().unwrap_err().field, "beta");
}
struct Sphere;
struct SphereFit;
impl FitnessEvaluable for SphereFit {
type Individual = Vec<f64>;
type Landscape = Sphere;
fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
x.iter().map(|v| v * v).sum()
}
}
#[test]
fn gamma_matches_known_values() {
approx::assert_relative_eq!(gamma(1.0), 1.0, epsilon = 1e-4);
approx::assert_relative_eq!(gamma(2.0), 1.0, epsilon = 1e-4);
approx::assert_relative_eq!(gamma(5.0), 24.0, epsilon = 1e-3);
approx::assert_relative_eq!(gamma(0.5), PI.sqrt(), epsilon = 1e-3);
}
#[test]
fn mantegna_sigma_u_is_finite() {
let s = CuckooSearch::<TestBackend>::mantegna_sigma_u(1.5);
assert!(s.is_finite() && s > 0.0);
}
#[test]
fn cuckoo_reduces_on_sphere_d10() {
let device = Default::default();
let strategy = CuckooSearch::<TestBackend>::new();
let mut params = CuckooConfig::default_for(30, 10);
params.alpha = 0.2;
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy, params, fitness_fn, 19, device, 800,
)
.expect("valid params");
harness.reset();
while !harness.step(()).done {}
let best = harness.latest_metrics().unwrap().best_fitness_ever();
assert!(best < 20.0, "Cuckoo D10 best={best}");
}
#[test]
#[allow(clippy::float_cmp)] fn levy_step_folds_pathological_denominator_to_zero() {
let beta: f32 = 1.5;
let unguarded_nan: f32 = 0.0_f32 / 0.0_f32.abs().powf(1.0 / beta);
assert!(unguarded_nan.is_nan());
assert_eq!(levy_step(0.0, 0.0, beta), 0.0);
let unguarded_inf: f32 = 1.0_f32 / 0.0_f32.abs().powf(1.0 / beta);
assert!(!unguarded_inf.is_finite());
assert_eq!(levy_step(1.0, 0.0, beta), 0.0);
assert_eq!(levy_step(1.0, f32::NAN, beta), 0.0);
let expected: f32 = 0.5_f32 / 1.2_f32.abs().powf(1.0 / beta);
let got: f32 = levy_step(0.5, 1.2, beta);
assert!(got.is_finite());
approx::assert_relative_eq!(got, expected, epsilon = 1e-6);
assert_eq!(got, expected);
}
struct PartialNanFitness;
impl<B: Backend> crate::fitness::BatchFitnessFn<B, Tensor<B, 2>> for PartialNanFitness {
fn evaluate_batch(
&mut self,
population: &Tensor<B, 2>,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 1> {
let n = population.dims()[0];
#[allow(clippy::cast_precision_loss)]
let mut vals: Vec<f32> = (0..n).map(|i| -(i as f32)).collect();
vals[0] = f32::NAN;
Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
}
fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
rlevo_core::objective::ObjectiveSense::Maximize
}
}
#[test]
fn rejects_invalid_beta_values() {
for bad in [0.0_f32, 3.0, f32::NAN] {
let mut cfg = CuckooConfig::default_for(25, 10);
cfg.beta = bad;
assert_eq!(
cfg.validate().unwrap_err().field,
"beta",
"β = {bad} should be rejected on the beta field"
);
}
}
#[test]
#[should_panic(expected = "invalid range")]
fn inverted_bounds_are_unrepresentable() {
let _ = CuckooConfig {
bounds: Bounds::new(5.0, -5.0),
..CuckooConfig::default_for(25, 10)
};
}
#[test]
fn abandonment_marks_floor_pa_pop_nests() {
let device = Default::default();
let strategy = CuckooSearch::<TestBackend>::new();
let params = CuckooConfig::default_for(8, 2); let nests = Tensor::<TestBackend, 2>::zeros([8, 2], &device);
let state = CuckooState::try_new(
nests,
vec![8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0],
None,
f32::NEG_INFINITY,
1,
)
.expect("valid state");
let eggs = Tensor::<TestBackend, 2>::full([8, 2], 5.0, &device);
let fit =
Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![0.0_f32; 8], [8]), &device);
let mut rng = StdRng::seed_from_u64(4);
let (next, _m) = strategy.tell(¶ms, eggs, fit, state, &mut rng);
let f = next.fitness();
let abandoned = f
.iter()
.filter(|v| v.is_infinite() && v.is_sign_negative())
.count();
assert_eq!(abandoned, 2, "expected floor(0.25 * 8) = 2 abandoned nests");
assert!(f[6].is_infinite() && f[6].is_sign_negative());
assert!(f[7].is_infinite() && f[7].is_sign_negative());
}
#[test]
#[allow(clippy::float_cmp)] fn greedy_accept_keeps_nests_on_all_worse_eggs() {
let device = Default::default();
let strategy = CuckooSearch::<TestBackend>::new();
let mut params = CuckooConfig::default_for(4, 2);
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];
let nests = Tensor::<TestBackend, 2>::from_data(
TensorData::new(nest_vals.clone(), [4, 2]),
&device,
);
let state =
CuckooState::try_new(nests, vec![4.0, 3.0, 2.0, 1.0], None, f32::NEG_INFINITY, 1)
.expect("valid state");
let eggs = Tensor::<TestBackend, 2>::full([4, 2], 9.0, &device);
let fit =
Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![0.0_f32; 4], [4]), &device);
let mut rng = StdRng::seed_from_u64(5);
let (next, _m) = strategy.tell(¶ms, eggs, fit, state, &mut rng);
let after = next
.nests()
.clone()
.into_data()
.into_vec::<f32>()
.expect("nests readable as f32");
assert_eq!(after, nest_vals);
}
#[test]
fn best_so_far_is_monotone() {
let device = Default::default();
let strategy = CuckooSearch::<TestBackend>::new();
let params = CuckooConfig::default_for(20, 6);
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy, params, fitness_fn, 11, device, 40,
)
.expect("valid params");
harness.reset();
let mut prev = f32::INFINITY;
loop {
let done = harness.step(()).done;
let cur = harness.latest_metrics().unwrap().best_fitness_ever();
assert!(
cur <= prev + 1e-6,
"best_fitness_ever worsened: {cur} > {prev}"
);
prev = cur;
if done {
break;
}
}
}
#[test]
fn nan_fitness_survives_harness() {
let device = Default::default();
let strategy = CuckooSearch::<TestBackend>::new();
let params = CuckooConfig::default_for(8, 3);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy,
params,
PartialNanFitness,
4,
device,
4,
)
.expect("valid params");
harness.reset();
while !harness.step(()).done {}
let m = harness.latest_metrics().unwrap();
assert!(
m.best_fitness_ever().is_finite(),
"best_fitness_ever not finite: {}",
m.best_fitness_ever()
);
assert!(m.broken_count() > 0, "expected a broken (NaN) member");
}
}