Skip to main content

rlevo_evolution/algorithms/metaheuristic/
cuckoo.rs

1//! Cuckoo Search via Lévy flights.
2//!
3//! Each generation every nest proposes a new egg by taking a
4//! Lévy-stable step from its current position:
5//!
6//! - `u ∼ N(0, σ_u²)`, `v ∼ N(0, 1)`,
7//! - `step = u / |v|^(1/β)`,
8//! - `x'_i = x_i + α · step`,
9//!
10//! where `σ_u = (Γ(1+β)·sin(π·β/2) / (Γ((1+β)/2)·β·2^((β−1)/2)))^(1/β)`
11//! (Mantegna's algorithm, β ≈ 1.5).
12//!
13//! `tell` greedy-accepts each new egg against its own slot, then
14//! abandons the `p_a · N` worst nests and reinitializes them from the
15//! search bounds. Abandoned slots carry sentinel `+∞` fitness so the
16//! next generation's Lévy proposal always lands.
17//!
18//! # Numerical parity caveat
19//!
20//! The fractional power `|v|^(1/β)` is FMA-reorder-sensitive — wgpu
21//! reductions can drift ~`1e-3` relative from flex on the same seed.
22//! The backend-parity test relaxes tolerance for CS accordingly.
23//!
24//! # References
25//!
26//! - Yang & Deb (2009), *Cuckoo Search via Lévy Flights*.
27//! - Mantegna (1994), *Fast, accurate algorithm for numerical simulation
28//!   of Lévy stable stochastic processes*.
29
30use 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 crate::rng::{SeedPurpose, seed_stream};
39use crate::strategy::{Strategy, StrategyMetrics};
40
41/// Static configuration for [`CuckooSearch`].
42#[derive(Debug, Clone)]
43pub struct CuckooConfig {
44    /// Nest count.
45    pub pop_size: usize,
46    /// Genome dimensionality.
47    pub genome_dim: usize,
48    /// Search-space bounds.
49    pub bounds: (f32, f32),
50    /// Step size scale (`α` in the paper). Canonical `α = 0.01`
51    /// multiplied by the search-space width; strategy users should
52    /// tune relative to their domain.
53    pub alpha: f32,
54    /// Lévy index (`β`). Must be in `(0, 2)`; canonical 1.5.
55    pub beta: f32,
56    /// Nest abandonment probability (`p_a`). Canonical 0.25.
57    pub p_a: f32,
58}
59
60impl CuckooConfig {
61    /// Default configuration for a given population size and genome dimensionality.
62    #[must_use]
63    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
64        Self {
65            pop_size,
66            genome_dim,
67            bounds: (-5.12, 5.12),
68            alpha: 0.05,
69            beta: 1.5,
70            p_a: 0.25,
71        }
72    }
73}
74
75/// Generation state for [`CuckooSearch`].
76#[derive(Debug, Clone)]
77pub struct CuckooState<B: Backend> {
78    /// Current nests, shape `(pop_size, D)`.
79    pub nests: Tensor<B, 2>,
80    /// Host-side fitness cache; `+∞` for abandoned slots.
81    pub fitness: Vec<f32>,
82    /// Best-so-far genome.
83    pub best_genome: Option<Tensor<B, 2>>,
84    /// Best-so-far fitness.
85    pub best_fitness: f32,
86    /// Generation counter.
87    pub generation: usize,
88}
89
90/// Cuckoo Search strategy.
91///
92/// # Example
93///
94/// ```no_run
95/// use burn::backend::Flex;
96/// use rlevo_evolution::algorithms::metaheuristic::cuckoo::{CuckooConfig, CuckooSearch};
97///
98/// let strategy = CuckooSearch::<Flex>::new();
99/// let params = CuckooConfig::default_for(30, 10);
100/// let _ = (strategy, params);
101/// ```
102#[derive(Debug, Clone, Copy, Default)]
103pub struct CuckooSearch<B: Backend> {
104    _backend: PhantomData<fn() -> B>,
105}
106
107impl<B: Backend> CuckooSearch<B> {
108    /// Builds a new (stateless) strategy object.
109    #[must_use]
110    pub fn new() -> Self {
111        Self {
112            _backend: PhantomData,
113        }
114    }
115
116    /// Mantegna's `σ_u` for the `u ∼ N(0, σ_u²)` draw.
117    fn mantegna_sigma_u(beta: f32) -> f32 {
118        // Γ(1 + β) · sin(π·β/2)  /  ( Γ((1+β)/2) · β · 2^((β-1)/2) ) ) ^ (1/β)
119        let num = gamma(1.0 + beta) * ((PI * beta) / 2.0).sin();
120        let den = gamma(f32::midpoint(1.0, beta)) * beta * 2f32.powf((beta - 1.0) / 2.0);
121        (num / den).powf(1.0 / beta)
122    }
123}
124
125/// Lanczos approximation for `Γ(z)` on positive reals.
126///
127/// Used host-side by [`CuckooSearch::mantegna_sigma_u`] to evaluate the
128/// `σ_u` constant for Mantegna's Lévy-stable sampler. Accurate to `~1e-3`
129/// for `z ∈ [0.5, 5]`, which covers the valid range of the Lévy index
130/// `β ∈ (0, 2)`.
131#[allow(clippy::many_single_char_names)]
132fn gamma(z: f32) -> f32 {
133    // 5-term Lanczos coefficients (g = 7). Enough for `z ∈ [0.5, 5]`
134    // which covers the Lévy-flight parameter range.
135    let g = 7.0_f32;
136    let p: [f32; 9] = [
137        0.999_999_999_999_809_93,
138        676.520_4,
139        -1_259.139_2,
140        771.323_4,
141        -176.615_04,
142        12.507_343,
143        -0.138_571_1,
144        9.984_369e-6,
145        1.505_632_7e-7,
146    ];
147    if z < 0.5 {
148        return PI / ((PI * z).sin() * gamma(1.0 - z));
149    }
150    let z = z - 1.0;
151    let mut x = p[0];
152    for (i, &coef) in p.iter().enumerate().skip(1) {
153        #[allow(clippy::cast_precision_loss)]
154        let i_f32 = i as f32;
155        x += coef / (z + i_f32);
156    }
157    let t = z + g + 0.5;
158    (2.0 * PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * x
159}
160
161impl<B: Backend> Strategy<B> for CuckooSearch<B>
162where
163    B::Device: Clone,
164{
165    type Params = CuckooConfig;
166    type State = CuckooState<B>;
167    type Genome = Tensor<B, 2>;
168
169    /// Build the initial nest population by host-sampling `pop_size`
170    /// positions uniformly in `[bounds.lo, bounds.hi]`.
171    ///
172    /// The `fitness` field is left empty so the first [`ask`] → [`tell`]
173    /// pair bootstraps the fitness cache before any greedy acceptance or
174    /// abandonment logic runs.  Positions are drawn from a deterministic
175    /// [`seed_stream`]; the process-wide Flex RNG is never touched.
176    ///
177    /// [`ask`]: Strategy::ask
178    /// [`tell`]: Strategy::tell
179    fn init(&self, params: &CuckooConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> CuckooState<B> {
180        let (lo, hi) = params.bounds;
181        // Host-sample the initial nests from a deterministic `seed_stream`
182        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
183        // whose draws interleave with sibling tests under the parallel runner
184        // and are not reproducible across thread schedules.
185        let pop = params.pop_size;
186        let genome_dim = params.genome_dim;
187        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
188        let mut nest_rows = Vec::with_capacity(pop * genome_dim);
189        for _ in 0..pop * genome_dim {
190            nest_rows.push(lo + (hi - lo) * stream.random::<f32>());
191        }
192        let nests =
193            Tensor::<B, 2>::from_data(TensorData::new(nest_rows, [pop, genome_dim]), device);
194        CuckooState {
195            nests,
196            fitness: Vec::new(),
197            best_genome: None,
198            best_fitness: f32::INFINITY,
199            generation: 0,
200        }
201    }
202
203    /// Propose new egg positions via Mantegna's Lévy-stable step.
204    ///
205    /// On the first call (`state.fitness` is empty) returns the initial
206    /// nests unchanged so the caller can evaluate generation zero.
207    ///
208    /// On subsequent calls, samples `u ∼ N(0, σ_u²)` and `v ∼ N(0, 1)`
209    /// host-side from a deterministic [`seed_stream`], then forms
210    /// `step = u / |v|^(1/β)` and proposes
211    /// `x'_i = x_i + α · step`, clipped to `params.bounds`.
212    fn ask(
213        &self,
214        params: &CuckooConfig,
215        state: &CuckooState<B>,
216        rng: &mut dyn Rng,
217        device: &<B as burn::tensor::backend::BackendTypes>::Device,
218    ) -> (Tensor<B, 2>, CuckooState<B>) {
219        if state.fitness.is_empty() {
220            return (state.nests.clone(), state.clone());
221        }
222
223        let pop = params.pop_size;
224        let d = params.genome_dim;
225        let sigma_u = Self::mantegna_sigma_u(params.beta);
226
227        let mut stream = seed_stream(
228            rng.next_u64(),
229            state.generation as u64,
230            SeedPurpose::Mutation,
231        );
232        let normal_u = Normal::new(0.0_f32, sigma_u).expect("σ_u > 0");
233        let normal_v = Normal::new(0.0_f32, 1.0_f32).unwrap();
234        let mut step = vec![0f32; pop * d];
235        for v in &mut step {
236            let u: f32 = normal_u.sample(&mut stream);
237            let w: f32 = normal_v.sample(&mut stream);
238            *v = u / w.abs().powf(1.0 / params.beta);
239        }
240        let step_tensor = Tensor::<B, 2>::from_data(TensorData::new(step, [pop, d]), device);
241
242        let (lo, hi) = params.bounds;
243        let new_nests = (state.nests.clone() + step_tensor.mul_scalar(params.alpha)).clamp(lo, hi);
244
245        let mut next = state.clone();
246        next.nests.clone_from(&new_nests);
247        (new_nests, next)
248    }
249
250    /// Ingest egg fitness values, apply greedy per-slot acceptance, abandon
251    /// the worst nests, and advance the generation counter.
252    ///
253    /// On the first call (generation zero bootstrap) all eggs are
254    /// unconditionally accepted and nest abandonment is skipped.
255    ///
256    /// On subsequent calls:
257    ///
258    /// 1. **Greedy accept** — egg `i` replaces nest `i` iff
259    ///    `fitness[i] ≤ state.fitness[i]`.
260    /// 2. **Abandonment** — the `⌊p_a · pop_size⌋` worst nests are
261    ///    re-initialized from `bounds` via [`seed_stream`]; abandoned
262    ///    slots carry sentinel `+∞` fitness so the next generation's Lévy
263    ///    proposal always lands on them.
264    fn tell(
265        &self,
266        params: &CuckooConfig,
267        population: Tensor<B, 2>,
268        fitness: Tensor<B, 1>,
269        mut state: CuckooState<B>,
270        rng: &mut dyn Rng,
271    ) -> (CuckooState<B>, StrategyMetrics) {
272        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
273        let device = population.device();
274        let pop = params.pop_size;
275        let d = params.genome_dim;
276
277        if state.fitness.is_empty() {
278            state.fitness.clone_from(&fitness_host);
279            let best_idx = argmin(&fitness_host);
280            state.best_fitness = fitness_host[best_idx];
281            #[allow(clippy::cast_possible_wrap)]
282            let idx = Tensor::<B, 1, Int>::from_data(
283                TensorData::new(vec![best_idx as i64], [1]),
284                &device,
285            );
286            state.best_genome = Some(population.clone().select(0, idx));
287            state.nests = population;
288            state.generation += 1;
289            let m = StrategyMetrics::from_host_fitness(
290                state.generation,
291                &fitness_host,
292                state.best_fitness,
293            );
294            state.best_fitness = m.best_fitness_ever;
295            return (state, m);
296        }
297
298        // Greedy accept per slot.
299        #[allow(clippy::cast_possible_wrap)]
300        let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
301        let mut new_fitness = state.fitness.clone();
302        for i in 0..pop {
303            if fitness_host[i] <= state.fitness[i] {
304                #[allow(clippy::cast_possible_wrap)]
305                {
306                    rs[i] = (pop + i) as i64;
307                }
308                new_fitness[i] = fitness_host[i];
309            }
310        }
311        let stacked = Tensor::cat(vec![state.nests.clone(), population.clone()], 0);
312        let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
313        state.nests = stacked.select(0, idx);
314        state.fitness = new_fitness;
315
316        // Abandon worst `p_a · pop` nests — reinit with uniform sample;
317        // mark fitness +∞ so next ask's Lévy proposal always lands.
318        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
319        let n_abandon = (params.p_a * pop as f32) as usize;
320        if n_abandon > 0 {
321            let mut rank: Vec<usize> = (0..pop).collect();
322            rank.sort_by(|&a, &b| state.fitness[b].partial_cmp(&state.fitness[a]).unwrap());
323            let worst: Vec<usize> = rank.into_iter().take(n_abandon).collect();
324            let (lo, hi) = params.bounds;
325            // Host-sample abandoned-nest replacements from a deterministic
326            // `seed_stream` so the refill is reproducible across thread
327            // schedules rather than racing the global Flex RNG.
328            let mut abandon_stream =
329                seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Replacement);
330            let mut fresh_rows = Vec::with_capacity(n_abandon * d);
331            for _ in 0..n_abandon * d {
332                fresh_rows.push(lo + (hi - lo) * abandon_stream.random::<f32>());
333            }
334            let fresh =
335                Tensor::<B, 2>::from_data(TensorData::new(fresh_rows, [n_abandon, d]), &device);
336            #[allow(clippy::cast_possible_wrap)]
337            let mut rs2: Vec<i64> = (0..pop).map(|i| i as i64).collect();
338            for (k, &slot) in worst.iter().enumerate() {
339                #[allow(clippy::cast_possible_wrap)]
340                {
341                    rs2[slot] = (pop + k) as i64;
342                }
343                state.fitness[slot] = f32::INFINITY;
344            }
345            let stacked2 = Tensor::cat(vec![state.nests.clone(), fresh], 0);
346            let idx2 = Tensor::<B, 1, Int>::from_data(TensorData::new(rs2, [pop]), &device);
347            state.nests = stacked2.select(0, idx2);
348        }
349
350        // Best-so-far from finite-fitness slots.
351        let best_idx = argmin(&state.fitness);
352        if state.fitness[best_idx].is_finite() && state.fitness[best_idx] < state.best_fitness {
353            state.best_fitness = state.fitness[best_idx];
354            #[allow(clippy::cast_possible_wrap)]
355            let idx = Tensor::<B, 1, Int>::from_data(
356                TensorData::new(vec![best_idx as i64], [1]),
357                &device,
358            );
359            state.best_genome = Some(state.nests.clone().select(0, idx));
360        }
361
362        state.generation += 1;
363        let m =
364            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
365        state.best_fitness = m.best_fitness_ever;
366        (state, m)
367    }
368
369    /// Returns the best-so-far `(genome, fitness)` pair, or `None` before
370    /// the first [`tell`](Strategy::tell) call.
371    fn best(&self, state: &CuckooState<B>) -> Option<(Tensor<B, 2>, f32)> {
372        state
373            .best_genome
374            .as_ref()
375            .map(|g| (g.clone(), state.best_fitness))
376    }
377}
378
379fn argmin(xs: &[f32]) -> usize {
380    let mut best_idx = 0usize;
381    let mut best = f32::INFINITY;
382    for (i, &v) in xs.iter().enumerate() {
383        if v < best {
384            best = v;
385            best_idx = i;
386        }
387    }
388    best_idx
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::fitness::FromFitnessEvaluable;
395    use crate::strategy::EvolutionaryHarness;
396    use burn::backend::Flex;
397    use rlevo_core::fitness::FitnessEvaluable;
398
399    type TestBackend = Flex;
400
401    struct Sphere;
402    struct SphereFit;
403    impl FitnessEvaluable for SphereFit {
404        type Individual = Vec<f64>;
405        type Landscape = Sphere;
406        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
407            x.iter().map(|v| v * v).sum()
408        }
409    }
410
411    #[test]
412    fn gamma_matches_known_values() {
413        // Γ(1) = 1, Γ(2) = 1, Γ(5) = 24, Γ(0.5) = √π.
414        approx::assert_relative_eq!(gamma(1.0), 1.0, epsilon = 1e-4);
415        approx::assert_relative_eq!(gamma(2.0), 1.0, epsilon = 1e-4);
416        approx::assert_relative_eq!(gamma(5.0), 24.0, epsilon = 1e-3);
417        approx::assert_relative_eq!(gamma(0.5), PI.sqrt(), epsilon = 1e-3);
418    }
419
420    #[test]
421    fn mantegna_sigma_u_is_finite() {
422        let s = CuckooSearch::<TestBackend>::mantegna_sigma_u(1.5);
423        assert!(s.is_finite() && s > 0.0);
424    }
425
426    #[test]
427    fn cuckoo_reduces_on_sphere_d10() {
428        // Pure-Lévy CS has no gradient-biased update — it's a biased
429        // random walk with abandonment. The Lévy flights are the
430        // interesting part; otherwise CS is a thin wrapper around
431        // random walk + abandonment, so convergence to machine
432        // precision is not expected within reasonable budgets on
433        // Sphere-D10. Threshold 20.0 in 800 generations is still a ~4×
434        // reduction from the uniform-random baseline (≈ 87) — it
435        // verifies the Lévy machinery composes correctly.
436        let device = Default::default();
437        let strategy = CuckooSearch::<TestBackend>::new();
438        let mut params = CuckooConfig::default_for(30, 10);
439        params.alpha = 0.2;
440        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
441        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
442            strategy, params, fitness_fn, 19, device, 800,
443        );
444        harness.reset();
445        while !harness.step(()).done {}
446        let best = harness.latest_metrics().unwrap().best_fitness_ever;
447        assert!(best < 20.0, "Cuckoo D10 best={best}");
448    }
449}