1use std::f32::consts::PI;
31use std::marker::PhantomData;
32
33use burn::tensor::{Distribution, Int, Tensor, TensorData, backend::Backend};
34use rand::Rng;
35use rand_distr::{Distribution as RandDistDist, Normal};
36
37use crate::rng::{SeedPurpose, seed_stream};
38use crate::strategy::{Strategy, StrategyMetrics};
39
40#[derive(Debug, Clone)]
42pub struct CuckooConfig {
43 pub pop_size: usize,
45 pub genome_dim: usize,
47 pub bounds: (f32, f32),
49 pub alpha: f32,
53 pub beta: f32,
55 pub p_a: f32,
57}
58
59impl CuckooConfig {
60 #[must_use]
62 pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
63 Self {
64 pop_size,
65 genome_dim,
66 bounds: (-5.12, 5.12),
67 alpha: 0.05,
68 beta: 1.5,
69 p_a: 0.25,
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct CuckooState<B: Backend> {
77 pub nests: Tensor<B, 2>,
79 pub fitness: Vec<f32>,
81 pub best_genome: Option<Tensor<B, 2>>,
83 pub best_fitness: f32,
85 pub generation: usize,
87}
88
89#[derive(Debug, Clone, Copy, Default)]
102pub struct CuckooSearch<B: Backend> {
103 _backend: PhantomData<fn() -> B>,
104}
105
106impl<B: Backend> CuckooSearch<B> {
107 #[must_use]
109 pub fn new() -> Self {
110 Self {
111 _backend: PhantomData,
112 }
113 }
114
115 fn mantegna_sigma_u(beta: f32) -> f32 {
117 let num = gamma(1.0 + beta) * ((PI * beta) / 2.0).sin();
119 let den = gamma(f32::midpoint(1.0, beta)) * beta * 2f32.powf((beta - 1.0) / 2.0);
120 (num / den).powf(1.0 / beta)
121 }
122}
123
124#[allow(clippy::many_single_char_names)]
127fn gamma(z: f32) -> f32 {
128 let g = 7.0_f32;
131 let p: [f32; 9] = [
132 0.999_999_999_999_809_93,
133 676.520_4,
134 -1_259.139_2,
135 771.323_4,
136 -176.615_04,
137 12.507_343,
138 -0.138_571_1,
139 9.984_369e-6,
140 1.505_632_7e-7,
141 ];
142 if z < 0.5 {
143 return PI / ((PI * z).sin() * gamma(1.0 - z));
144 }
145 let z = z - 1.0;
146 let mut x = p[0];
147 for (i, &coef) in p.iter().enumerate().skip(1) {
148 #[allow(clippy::cast_precision_loss)]
149 let i_f32 = i as f32;
150 x += coef / (z + i_f32);
151 }
152 let t = z + g + 0.5;
153 (2.0 * PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * x
154}
155
156impl<B: Backend> Strategy<B> for CuckooSearch<B>
157where
158 B::Device: Clone,
159{
160 type Params = CuckooConfig;
161 type State = CuckooState<B>;
162 type Genome = Tensor<B, 2>;
163
164 fn init(&self, params: &CuckooConfig, rng: &mut dyn Rng, device: &B::Device) -> CuckooState<B> {
165 let (lo, hi) = params.bounds;
166 B::seed(device, rng.next_u64());
167 let nests = Tensor::<B, 2>::random(
168 [params.pop_size, params.genome_dim],
169 Distribution::Uniform(f64::from(lo), f64::from(hi)),
170 device,
171 );
172 CuckooState {
173 nests,
174 fitness: Vec::new(),
175 best_genome: None,
176 best_fitness: f32::INFINITY,
177 generation: 0,
178 }
179 }
180
181 fn ask(
182 &self,
183 params: &CuckooConfig,
184 state: &CuckooState<B>,
185 rng: &mut dyn Rng,
186 device: &B::Device,
187 ) -> (Tensor<B, 2>, CuckooState<B>) {
188 if state.fitness.is_empty() {
189 return (state.nests.clone(), state.clone());
190 }
191
192 let pop = params.pop_size;
193 let d = params.genome_dim;
194 let sigma_u = Self::mantegna_sigma_u(params.beta);
195
196 let mut stream = seed_stream(
197 rng.next_u64(),
198 state.generation as u64,
199 SeedPurpose::Mutation,
200 );
201 let normal_u = Normal::new(0.0_f32, sigma_u).expect("σ_u > 0");
202 let normal_v = Normal::new(0.0_f32, 1.0_f32).unwrap();
203 let mut step = vec![0f32; pop * d];
204 for v in &mut step {
205 let u: f32 = normal_u.sample(&mut stream);
206 let w: f32 = normal_v.sample(&mut stream);
207 *v = u / w.abs().powf(1.0 / params.beta);
208 }
209 let step_tensor = Tensor::<B, 2>::from_data(TensorData::new(step, [pop, d]), device);
210
211 let (lo, hi) = params.bounds;
212 let new_nests = (state.nests.clone() + step_tensor.mul_scalar(params.alpha)).clamp(lo, hi);
213
214 let mut next = state.clone();
215 next.nests.clone_from(&new_nests);
216 (new_nests, next)
217 }
218
219 fn tell(
220 &self,
221 params: &CuckooConfig,
222 population: Tensor<B, 2>,
223 fitness: Tensor<B, 1>,
224 mut state: CuckooState<B>,
225 rng: &mut dyn Rng,
226 ) -> (CuckooState<B>, StrategyMetrics) {
227 let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
228 let device = population.device();
229 let pop = params.pop_size;
230 let d = params.genome_dim;
231
232 if state.fitness.is_empty() {
233 state.fitness.clone_from(&fitness_host);
234 let best_idx = argmin(&fitness_host);
235 state.best_fitness = fitness_host[best_idx];
236 #[allow(clippy::cast_possible_wrap)]
237 let idx = Tensor::<B, 1, Int>::from_data(
238 TensorData::new(vec![best_idx as i64], [1]),
239 &device,
240 );
241 state.best_genome = Some(population.clone().select(0, idx));
242 state.nests = population;
243 state.generation += 1;
244 let m = StrategyMetrics::from_host_fitness(
245 state.generation,
246 &fitness_host,
247 state.best_fitness,
248 );
249 state.best_fitness = m.best_fitness_ever;
250 return (state, m);
251 }
252
253 #[allow(clippy::cast_possible_wrap)]
255 let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
256 let mut new_fitness = state.fitness.clone();
257 for i in 0..pop {
258 if fitness_host[i] <= state.fitness[i] {
259 #[allow(clippy::cast_possible_wrap)]
260 {
261 rs[i] = (pop + i) as i64;
262 }
263 new_fitness[i] = fitness_host[i];
264 }
265 }
266 let stacked = Tensor::cat(vec![state.nests.clone(), population.clone()], 0);
267 let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
268 state.nests = stacked.select(0, idx);
269 state.fitness = new_fitness;
270
271 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
274 let n_abandon = (params.p_a * pop as f32) as usize;
275 if n_abandon > 0 {
276 let mut rank: Vec<usize> = (0..pop).collect();
277 rank.sort_by(|&a, &b| state.fitness[b].partial_cmp(&state.fitness[a]).unwrap());
278 let worst: Vec<usize> = rank.into_iter().take(n_abandon).collect();
279 let (lo, hi) = params.bounds;
280 B::seed(&device, rng.next_u64());
281 let fresh = Tensor::<B, 2>::random(
282 [n_abandon, d],
283 Distribution::Uniform(f64::from(lo), f64::from(hi)),
284 &device,
285 );
286 #[allow(clippy::cast_possible_wrap)]
287 let mut rs2: Vec<i64> = (0..pop).map(|i| i as i64).collect();
288 for (k, &slot) in worst.iter().enumerate() {
289 #[allow(clippy::cast_possible_wrap)]
290 {
291 rs2[slot] = (pop + k) as i64;
292 }
293 state.fitness[slot] = f32::INFINITY;
294 }
295 let stacked2 = Tensor::cat(vec![state.nests.clone(), fresh], 0);
296 let idx2 = Tensor::<B, 1, Int>::from_data(TensorData::new(rs2, [pop]), &device);
297 state.nests = stacked2.select(0, idx2);
298 }
299
300 let best_idx = argmin(&state.fitness);
302 if state.fitness[best_idx].is_finite() && state.fitness[best_idx] < state.best_fitness {
303 state.best_fitness = state.fitness[best_idx];
304 #[allow(clippy::cast_possible_wrap)]
305 let idx = Tensor::<B, 1, Int>::from_data(
306 TensorData::new(vec![best_idx as i64], [1]),
307 &device,
308 );
309 state.best_genome = Some(state.nests.clone().select(0, idx));
310 }
311
312 state.generation += 1;
313 let m =
314 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
315 state.best_fitness = m.best_fitness_ever;
316 (state, m)
317 }
318
319 fn best(&self, state: &CuckooState<B>) -> Option<(Tensor<B, 2>, f32)> {
320 state
321 .best_genome
322 .as_ref()
323 .map(|g| (g.clone(), state.best_fitness))
324 }
325}
326
327fn argmin(xs: &[f32]) -> usize {
328 let mut best_idx = 0usize;
329 let mut best = f32::INFINITY;
330 for (i, &v) in xs.iter().enumerate() {
331 if v < best {
332 best = v;
333 best_idx = i;
334 }
335 }
336 best_idx
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::fitness::FromFitnessEvaluable;
343 use crate::strategy::EvolutionaryHarness;
344 use burn::backend::NdArray;
345 use rlevo_core::fitness::FitnessEvaluable;
346
347 type TestBackend = NdArray;
348
349 struct Sphere;
350 struct SphereFit;
351 impl FitnessEvaluable for SphereFit {
352 type Individual = Vec<f64>;
353 type Landscape = Sphere;
354 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
355 x.iter().map(|v| v * v).sum()
356 }
357 }
358
359 #[test]
360 fn gamma_matches_known_values() {
361 approx::assert_relative_eq!(gamma(1.0), 1.0, epsilon = 1e-4);
363 approx::assert_relative_eq!(gamma(2.0), 1.0, epsilon = 1e-4);
364 approx::assert_relative_eq!(gamma(5.0), 24.0, epsilon = 1e-3);
365 approx::assert_relative_eq!(gamma(0.5), PI.sqrt(), epsilon = 1e-3);
366 }
367
368 #[test]
369 fn mantegna_sigma_u_is_finite() {
370 let s = CuckooSearch::<TestBackend>::mantegna_sigma_u(1.5);
371 assert!(s.is_finite() && s > 0.0);
372 }
373
374 #[test]
375 fn cuckoo_reduces_on_sphere_d10() {
376 let device = Default::default();
385 let strategy = CuckooSearch::<TestBackend>::new();
386 let mut params = CuckooConfig::default_for(30, 10);
387 params.alpha = 0.2;
388 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
389 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
390 strategy, params, fitness_fn, 19, device, 800,
391 );
392 harness.reset();
393 while !harness.step(()).done {}
394 let best = harness.latest_metrics().unwrap().best_fitness_ever;
395 assert!(best < 20.0, "Cuckoo D10 best={best}");
396 }
397}