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 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/// Static configuration for [`CuckooSearch`].
47#[derive(Debug, Clone)]
48pub struct CuckooConfig {
49    /// Nest count.
50    pub pop_size: usize,
51    /// Genome dimensionality.
52    pub genome_dim: usize,
53    /// Search-space bounds.
54    pub bounds: Bounds,
55    /// Step size scale (`α` in the paper). Canonical `α = 0.01`
56    /// multiplied by the search-space width; strategy users should
57    /// tune relative to their domain.
58    pub alpha: f32,
59    /// Lévy index (`β`). Must be in `(0, 2)`; canonical 1.5.
60    pub beta: f32,
61    /// Nest abandonment probability (`p_a`). Canonical 0.25.
62    pub p_a: f32,
63}
64
65impl CuckooConfig {
66    /// Default configuration for a given population size and genome dimensionality.
67    #[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        // β ∈ (0, 2), open on both ends.
87        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/// Generation state for [`CuckooSearch`].
95#[derive(Debug, Clone)]
96pub struct CuckooState<B: Backend> {
97    /// Current nests, shape `(pop_size, D)`.
98    nests: Tensor<B, 2>,
99    /// Host-side fitness cache; `+∞` for abandoned slots.
100    fitness: Vec<f32>,
101    /// Best-so-far genome.
102    best_genome: Option<Tensor<B, 2>>,
103    /// Best-so-far fitness.
104    best_fitness: f32,
105    /// Generation counter.
106    generation: usize,
107}
108
109impl<B: Backend> CuckooState<B> {
110    /// Assembles a nest state, checking the fitness cache matches `pop`.
111    ///
112    /// # Errors
113    ///
114    /// Returns a [`ConfigError`] if `nests` has zero rows or if `fitness` is
115    /// non-empty with a length other than `pop_size`.
116    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    /// Current nests, shape `(pop_size, D)`.
136    #[must_use]
137    pub fn nests(&self) -> &Tensor<B, 2> {
138        &self.nests
139    }
140
141    /// Host-side fitness cache (empty at bootstrap, else `pop_size` long).
142    #[must_use]
143    pub fn fitness(&self) -> &[f32] {
144        &self.fitness
145    }
146
147    /// Best-so-far genome, or `None` before the first `tell`.
148    #[must_use]
149    pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
150        self.best_genome.as_ref()
151    }
152
153    /// Best-so-far (canonical, maximise) fitness.
154    #[must_use]
155    pub fn best_fitness(&self) -> f32 {
156        self.best_fitness
157    }
158
159    /// Generation counter.
160    #[must_use]
161    pub fn generation(&self) -> usize {
162        self.generation
163    }
164}
165
166/// Cuckoo Search strategy.
167///
168/// # Example
169///
170/// ```no_run
171/// use burn::backend::Flex;
172/// use rlevo_evolution::algorithms::metaheuristic::cuckoo::{CuckooConfig, CuckooSearch};
173///
174/// let strategy = CuckooSearch::<Flex>::new();
175/// let params = CuckooConfig::default_for(30, 10);
176/// let _ = (strategy, params);
177/// ```
178#[derive(Debug, Clone, Copy, Default)]
179pub struct CuckooSearch<B: Backend> {
180    _backend: PhantomData<fn() -> B>,
181}
182
183impl<B: Backend> CuckooSearch<B> {
184    /// Builds a new (stateless) strategy object.
185    #[must_use]
186    pub fn new() -> Self {
187        Self {
188            _backend: PhantomData,
189        }
190    }
191
192    /// Mantegna's `σ_u` for the `u ∼ N(0, σ_u²)` draw.
193    fn mantegna_sigma_u(beta: f32) -> f32 {
194        // Γ(1 + β) · sin(π·β/2)  /  ( Γ((1+β)/2) · β · 2^((β-1)/2) ) ) ^ (1/β)
195        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/// Lanczos approximation for `Γ(z)` on positive reals.
202///
203/// Used host-side by [`CuckooSearch::mantegna_sigma_u`] to evaluate the
204/// `σ_u` constant for Mantegna's Lévy-stable sampler. Accurate to `~1e-3`
205/// for `z ∈ [0.5, 5]`, which covers the valid range of the Lévy index
206/// `β ∈ (0, 2)`.
207#[allow(clippy::many_single_char_names)]
208fn gamma(z: f32) -> f32 {
209    // 5-term Lanczos coefficients (g = 7). Enough for `z ∈ [0.5, 5]`
210    // which covers the Lévy-flight parameter range.
211    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
237/// One Mantegna Lévy step component `u / |w|^(1/β)`.
238///
239/// Guards the measure-zero pathological draw: a Normal draw `w == 0` (or
240/// any `w` whose `|w|^(1/β)` rounds to `0` or a non-finite value) makes the
241/// denominator degenerate. Un-guarded, `0/0` is `NaN` and `x/0` is `±inf` —
242/// both survive the downstream bounds clamp and would poison a nest slot
243/// forever. A non-finite or zero denominator folds the step to `0.0`
244/// (a no-op) so the next draw can move the nest.
245///
246/// This is the pure host-side core the `ask` Lévy loop is built on; keeping
247/// it out of the tensor pipeline makes the guard directly unit-testable with
248/// injected pathological `(u, w)` inputs.
249fn 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    /// Build the initial nest population by host-sampling `pop_size`
267    /// positions uniformly in `[bounds.lo, bounds.hi]`.
268    ///
269    /// The `fitness` field is left empty so the first [`ask`] → [`tell`]
270    /// pair bootstraps the fitness cache before any greedy acceptance or
271    /// abandonment logic runs.  Positions are drawn from a deterministic
272    /// [`seed_stream`]; the process-wide Flex RNG is never touched.
273    ///
274    /// [`ask`]: Strategy::ask
275    /// [`tell`]: Strategy::tell
276    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        // Host-sample the initial nests from a deterministic `seed_stream`
288        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
289        // whose draws interleave with sibling tests under the parallel runner
290        // and are not reproducible across thread schedules.
291        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    /// Propose new egg positions via Mantegna's Lévy-stable step.
310    ///
311    /// On the first call (`state.fitness` is empty) returns the initial
312    /// nests unchanged so the caller can evaluate generation zero.
313    ///
314    /// On subsequent calls, samples `u ∼ N(0, σ_u²)` and `v ∼ N(0, 1)`
315    /// host-side from a deterministic [`seed_stream`], then forms
316    /// `step = u / |v|^(1/β)` and proposes
317    /// `x'_i = x_i + α · step`, clipped to `params.bounds`.
318    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            // `levy_step` guards the degenerate `w == 0` denominator (±∞/NaN
344            // survive the bounds clamp and would poison the slot forever).
345            *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    /// Ingest egg fitness values, apply greedy per-slot acceptance, abandon
358    /// the worst nests, and advance the generation counter.
359    ///
360    /// On the first call (generation zero bootstrap) all eggs are
361    /// unconditionally accepted and nest abandonment is skipped.
362    ///
363    /// On subsequent calls:
364    ///
365    /// 1. **Greedy accept** — egg `i` replaces nest `i` iff
366    ///    `fitness[i] ≤ state.fitness[i]`.
367    /// 2. **Abandonment** — the `⌊p_a · pop_size⌋` worst nests are
368    ///    re-initialized from `bounds` via [`seed_stream`]; abandoned
369    ///    slots carry sentinel `+∞` fitness so the next generation's Lévy
370    ///    proposal always lands on them.
371    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        // Greedy accept per slot.
409        #[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        // Abandon worst `p_a · pop` nests — reinit with uniform sample;
427        // mark fitness −∞ (worst under maximise) so next ask's Lévy
428        // proposal always lands.
429        #[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            // Ascending: lowest fitness (worst under maximise) first. Sanitize
438            // NaN → −inf so a NaN-fitness nest is treated as worst (abandoned).
439            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            // Host-sample abandoned-nest replacements from a deterministic
448            // `seed_stream` so the refill is reproducible across thread
449            // schedules rather than racing the global Flex RNG.
450            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        // Best-so-far from finite-fitness slots.
476        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    /// Returns the best-so-far `(genome, fitness)` pair, or `None` before
495    /// the first [`tell`](Strategy::tell) call.
496    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        // Γ(1) = 1, Γ(2) = 1, Γ(5) = 24, Γ(0.5) = √π.
552        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        // Pure-Lévy CS has no gradient-biased update — it's a biased
567        // random walk with abandonment. The Lévy flights are the
568        // interesting part; otherwise CS is a thin wrapper around
569        // random walk + abandonment, so convergence to machine
570        // precision is not expected within reasonable budgets on
571        // Sphere-D10. Threshold 20.0 in 800 generations is still a ~4×
572        // reduction from the uniform-random baseline (≈ 87) — it
573        // verifies the Lévy machinery composes correctly.
574        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)] // exact by design: 0.0 fold + byte-identical pass-through
591    fn levy_step_folds_pathological_denominator_to_zero() {
592        // Deterministic reproducer for #156 (Cuckoo): the Lévy step
593        // component `u / |w|^(1/β)`. A zero Normal draw `w` makes the
594        // denominator zero; un-guarded, `0/0` is `NaN` and `x/0` is `±inf`.
595        // Both survive the bounds clamp and permanently poison a nest slot,
596        // so `levy_step` folds any non-finite/zero-denominator case to `0.0`.
597        //
598        // Each pathological assertion below FAILS against the pre-fix loop
599        // body (which computed `u / denom` unconditionally), shown by the
600        // `unguarded` reference expressions being non-finite.
601        let beta: f32 = 1.5;
602
603        // w == 0, u == 0 → un-guarded `0/0 = NaN`.
604        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        // w == 0, u != 0 → un-guarded `x/0 = ±inf`.
609        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        // A NaN Normal draw `w` makes the denominator non-finite → folded to 0.
614        assert_eq!(levy_step(1.0, f32::NAN, beta), 0.0);
615
616        // Normal case: finite and byte-identical to the un-guarded value
617        // (the guard is a pass-through whenever the denominator is sound).
618        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        // Byte-identical: same operations, no reorder.
623        assert_eq!(got, expected);
624    }
625
626    /// Fitness fn: row 0 → `NaN`, the rest finite. `Maximize` so natural ==
627    /// canonical, exercising the ADR-0034 harness sanitize with no `neg()`.
628    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    // Gap (a): the Lévy index β must lie in the open interval (0, 2). Rejection
647    // is broadened beyond the existing β = 2.0 case: β = 0.0 (fails `positive`),
648    // β = 3.0 (fails `ordered` against 2.0), and β = NaN (fails `positive`, since
649    // `NaN > 0` is false) all report the `beta` field.
650    #[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    // Gap (b): an inverted range is unrepresentable — `Bounds::new` panics before
664    // a `CuckooConfig` can carry `(5, −5)`, so the config can never hold it.
665    #[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    // Gap (c): abandonment marks exactly `⌊p_a · pop⌋` nests as abandoned
675    // (sentinel `−∞`). With `pop = 8`, `p_a = 0.25` ⇒ 2 nests; the two worst
676    // (lowest canonical fitness, indices 6 and 7) are the ones abandoned.
677    #[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); // p_a = 0.25 → 2 abandoned
682        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        // Every egg is worse than its slot → no greedy accept; only abandonment
692        // rewrites fitness.
693        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(&params, 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        // The two lowest-fitness slots (6, 7) are the abandoned ones.
705        assert!(f[6].is_infinite() && f[6].is_sign_negative());
706        assert!(f[7].is_infinite() && f[7].is_sign_negative());
707    }
708
709    // Gap (d): greedy acceptance is per-slot and strictly non-worsening. With
710    // `p_a = 0` (abandonment disabled), a generation of all-worse eggs must leave
711    // every nest byte-identical.
712    #[test]
713    #[allow(clippy::float_cmp)] // exact: rejected eggs leave nests untouched
714    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; // disable abandonment to isolate greedy accept
719        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(&params, 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    // Gap (e): best-so-far is monotone. Across a harness run on Sphere
742    // (Minimize), the reported `best_fitness_ever` (natural cost) never worsens
743    // generation to generation.
744    #[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    // Gap (f): a partly-`NaN` objective is neutralized by the harness sanitize
771    // chokepoint (ADR 0034).
772    #[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}