Skip to main content

rlevo_evolution/algorithms/metaheuristic/
bat.rs

1//! Bat Algorithm.
2//!
3//! Each bat carries a position, a velocity, a frequency `f`, a loudness
4//! `A`, and a pulse rate `r`. Per generation:
5//!
6//! 1. Sample `f_i = f_min + (f_max − f_min)·β`, `β ∈ U[0, 1]`.
7//! 2. Update velocity: `v_i ← v_i + (x_i − x_best)·f_i`.
8//! 3. Propose candidate: `x'_i = x_i + v_i`. If `rand > r_i`, override
9//!    with a local walk `x'_i = x_best + ε · mean(A)`, `ε ∈ U[−1, 1]`.
10//! 4. `tell` accepts the candidate iff
11//!    `rand < A_i` **and** `f(x'_i) ≥ f(x_i)`. On acceptance:
12//!    `A_i *= α` (decay loudness), `r_i = r_{i,0}·(1 − exp(−γ·t))`
13//!    (grow pulse rate).
14//!
15//! # Candor
16//!
17//! Legacy comparator. The velocity/position update is structurally a
18//! PSO variant toward the global best; the probabilistic acceptance
19//! adds simulated-annealing-style noise. Camacho Villalón et al. (2020)
20//! discuss the lack of search mechanisms not already present in
21//! simpler algorithms. Ship it for API coverage; prefer CMA-ES or
22//! LSHADE when available.
23//!
24//! # References
25//!
26//! - Yang (2010), *A New Metaheuristic Bat-Inspired Algorithm*.
27
28use std::marker::PhantomData;
29
30use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
31use rand::Rng;
32use rand::RngExt;
33
34use rlevo_core::bounds::Bounds;
35use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
36
37use super::len_matches_pop;
38use crate::ops::selection::argmax_host;
39use crate::rng::{SeedPurpose, seed_stream};
40use crate::strategy::{Strategy, StrategyMetrics};
41
42/// Static configuration for [`BatAlgorithm`].
43#[derive(Debug, Clone)]
44pub struct BatConfig {
45    /// Number of bats.
46    pub pop_size: usize,
47    /// Genome dimensionality.
48    pub genome_dim: usize,
49    /// Search-space bounds.
50    pub bounds: Bounds,
51    /// Minimum frequency.
52    pub f_min: f32,
53    /// Maximum frequency.
54    pub f_max: f32,
55    /// Initial loudness.
56    pub a0: f32,
57    /// Initial pulse rate.
58    pub r0: f32,
59    /// Loudness decay factor (0 < α ≤ 1). Canonical `α = 0.9`.
60    pub alpha: f32,
61    /// Pulse-rate growth factor (γ > 0). Canonical `γ = 0.9`.
62    pub gamma: f32,
63}
64
65impl BatConfig {
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            f_min: 0.0,
74            f_max: 2.0,
75            a0: 1.0,
76            r0: 0.5,
77            alpha: 0.9,
78            gamma: 0.9,
79        }
80    }
81}
82
83impl Validate for BatConfig {
84    fn validate(&self) -> Result<(), ConfigError> {
85        const C: &str = "BatConfig";
86        config::at_least(C, "pop_size", self.pop_size, 1)?;
87        config::nonzero(C, "genome_dim", self.genome_dim)?;
88        if self.f_min > self.f_max {
89            return Err(ConfigError {
90                config: C,
91                field: "f_min",
92                kind: ConstraintKind::Custom("f_min must not exceed f_max"),
93            });
94        }
95        config::in_range(C, "a0", 0.0, f64::INFINITY, f64::from(self.a0))?;
96        config::in_range(C, "r0", 0.0, 1.0, f64::from(self.r0))?;
97        // α ∈ (0, 1]: strictly positive and at most one.
98        config::positive(C, "alpha", f64::from(self.alpha))?;
99        config::in_range(C, "alpha", 0.0, 1.0, f64::from(self.alpha))?;
100        config::positive(C, "gamma", f64::from(self.gamma))?;
101        Ok(())
102    }
103}
104
105/// Generation state for [`BatAlgorithm`].
106#[derive(Debug, Clone)]
107pub struct BatState<B: Backend> {
108    /// Current positions, shape `(pop_size, D)`.
109    positions: Tensor<B, 2>,
110    /// Current velocities, shape `(pop_size, D)`.
111    velocities: Tensor<B, 2>,
112    /// Per-bat loudness.
113    loudness: Vec<f32>,
114    /// Per-bat pulse rate.
115    pulse_rate: Vec<f32>,
116    /// Host-side fitness cache for the current positions.
117    fitness: Vec<f32>,
118    /// Best-so-far genome.
119    best_genome: Option<Tensor<B, 2>>,
120    /// Best-so-far fitness.
121    best_fitness: f32,
122    /// Generation counter.
123    generation: usize,
124    /// Per-generation "accept this candidate?" decisions recorded in
125    /// `ask` so `tell` can gate the loudness/pulse updates consistently
126    /// with the RNG draws.
127    pending_accept: Vec<bool>,
128}
129
130impl<B: Backend> BatState<B> {
131    /// Assembles a bat-swarm state, checking the per-bat caches match `pop`.
132    ///
133    /// # Errors
134    ///
135    /// Returns a [`ConfigError`] if `positions` has zero rows or if any of
136    /// `loudness` / `pulse_rate` / `fitness` / `pending_accept` is non-empty
137    /// with a length other than `pop_size`.
138    #[allow(clippy::too_many_arguments)]
139    pub fn try_new(
140        positions: Tensor<B, 2>,
141        velocities: Tensor<B, 2>,
142        loudness: Vec<f32>,
143        pulse_rate: Vec<f32>,
144        fitness: Vec<f32>,
145        best_genome: Option<Tensor<B, 2>>,
146        best_fitness: f32,
147        generation: usize,
148        pending_accept: Vec<bool>,
149    ) -> Result<Self, ConfigError> {
150        let pop = positions.dims()[0];
151        config::nonzero("BatState", "pop_size", pop)?;
152        len_matches_pop("BatState", "loudness", pop, loudness.len())?;
153        len_matches_pop("BatState", "pulse_rate", pop, pulse_rate.len())?;
154        len_matches_pop("BatState", "fitness", pop, fitness.len())?;
155        len_matches_pop("BatState", "pending_accept", pop, pending_accept.len())?;
156        Ok(Self {
157            positions,
158            velocities,
159            loudness,
160            pulse_rate,
161            fitness,
162            best_genome,
163            best_fitness,
164            generation,
165            pending_accept,
166        })
167    }
168
169    /// Current positions, shape `(pop_size, D)`.
170    #[must_use]
171    pub fn positions(&self) -> &Tensor<B, 2> {
172        &self.positions
173    }
174
175    /// Current velocities, shape `(pop_size, D)`.
176    #[must_use]
177    pub fn velocities(&self) -> &Tensor<B, 2> {
178        &self.velocities
179    }
180
181    /// Per-bat loudness, `pop_size` long.
182    #[must_use]
183    pub fn loudness(&self) -> &[f32] {
184        &self.loudness
185    }
186
187    /// Per-bat pulse rate, `pop_size` long.
188    #[must_use]
189    pub fn pulse_rate(&self) -> &[f32] {
190        &self.pulse_rate
191    }
192
193    /// Host-side fitness cache (empty at bootstrap, else `pop_size` long).
194    #[must_use]
195    pub fn fitness(&self) -> &[f32] {
196        &self.fitness
197    }
198
199    /// Best-so-far genome, or `None` before the first `tell`.
200    #[must_use]
201    pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
202        self.best_genome.as_ref()
203    }
204
205    /// Best-so-far (canonical, maximise) fitness.
206    #[must_use]
207    pub fn best_fitness(&self) -> f32 {
208        self.best_fitness
209    }
210
211    /// Generation counter.
212    #[must_use]
213    pub fn generation(&self) -> usize {
214        self.generation
215    }
216
217    /// Per-candidate accept decisions recorded by `ask` (empty at bootstrap,
218    /// else `pop_size` long).
219    #[must_use]
220    pub fn pending_accept(&self) -> &[bool] {
221        &self.pending_accept
222    }
223}
224
225/// Bat Algorithm strategy.
226///
227/// # Example
228///
229/// ```no_run
230/// use burn::backend::Flex;
231/// use rlevo_evolution::algorithms::metaheuristic::bat::{BatAlgorithm, BatConfig};
232///
233/// let strategy = BatAlgorithm::<Flex>::new();
234/// let params = BatConfig::default_for(32, 10);
235/// let _ = (strategy, params);
236/// ```
237#[derive(Debug, Clone, Copy, Default)]
238pub struct BatAlgorithm<B: Backend> {
239    _backend: PhantomData<fn() -> B>,
240}
241
242impl<B: Backend> BatAlgorithm<B> {
243    /// Builds a new (stateless) strategy object.
244    #[must_use]
245    pub fn new() -> Self {
246        Self {
247            _backend: PhantomData,
248        }
249    }
250}
251
252impl<B: Backend> Strategy<B> for BatAlgorithm<B>
253where
254    B::Device: Clone,
255{
256    type Params = BatConfig;
257    type State = BatState<B>;
258    type Genome = Tensor<B, 2>;
259
260    /// Build the initial colony by host-sampling `pop_size` positions
261    /// uniformly in `[bounds.lo, bounds.hi]`.
262    ///
263    /// Velocities are zeroed, loudness is set to `params.a0`, pulse rate
264    /// to `params.r0`, and `fitness` left empty so that the first
265    /// [`ask`](Strategy::ask) → [`tell`](Strategy::tell) pair initialises
266    /// those fields before any acceptance logic runs.  Positions are drawn
267    /// from a deterministic [`seed_stream`]; the process-wide Flex RNG is
268    /// never touched.
269    fn init(
270        &self,
271        params: &BatConfig,
272        rng: &mut dyn Rng,
273        device: &<B as burn::tensor::backend::BackendTypes>::Device,
274    ) -> BatState<B> {
275        debug_assert!(
276            params.validate().is_ok(),
277            "invalid BatConfig reached init: {params:?}"
278        );
279        let (lo, hi): (f32, f32) = params.bounds.into();
280        // Sample initial positions on the host from a deterministic
281        // `seed_stream`, mirroring `ask`/`tell`. The Flex backend's
282        // `Tensor::random` draws from a process-wide RNG mutex; under the
283        // parallel test runner those draws interleave with sibling tests,
284        // so `B::seed` + `Tensor::random` is NOT reproducible across
285        // thread schedules. Host sampling keeps initialisation bit-stable
286        // regardless of core count or test ordering.
287        let pop = params.pop_size;
288        let genome_dim = params.genome_dim;
289        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
290        let mut position_rows = Vec::with_capacity(pop * genome_dim);
291        for _ in 0..pop * genome_dim {
292            position_rows.push(lo + (hi - lo) * stream.random::<f32>());
293        }
294        let positions =
295            Tensor::<B, 2>::from_data(TensorData::new(position_rows, [pop, genome_dim]), device);
296        let velocities = Tensor::<B, 2>::zeros([params.pop_size, params.genome_dim], device);
297        BatState {
298            positions,
299            velocities,
300            loudness: vec![params.a0; params.pop_size],
301            pulse_rate: vec![params.r0; params.pop_size],
302            fitness: Vec::new(),
303            best_genome: None,
304            best_fitness: f32::NEG_INFINITY,
305            generation: 0,
306            pending_accept: Vec::new(),
307        }
308    }
309
310    /// Propose candidate positions for the current generation.
311    ///
312    /// On the first call (`state.fitness` is empty) returns the initial
313    /// positions unchanged so the caller can evaluate generation zero.
314    ///
315    /// On subsequent calls the update proceeds in three host/device steps:
316    ///
317    /// 1. **Frequency** — sample `f_i = f_min + (f_max − f_min)·β_i`,
318    ///    `β_i ∈ U[0,1]`.
319    /// 2. **Global move** — `v_i ← v_i + (x_i − x_best)·f_i`,
320    ///    `x'_i = x_i + v_i`.
321    /// 3. **Local walk** (when `rand > r_i`) — override with
322    ///    `x'_i = x_best + ε·mean(A)`, `ε ∈ U[−1,1]`.
323    ///
324    /// All random draws are host-sampled through [`seed_stream`] for
325    /// bit-stable reproduction across thread schedules.  The
326    /// per-bat acceptance decisions (`pending_accept`) are recorded in the
327    /// returned state and consumed by [`tell`](Strategy::tell).
328    ///
329    /// # Panics
330    ///
331    /// Panics if called when `state.best_genome` is `None` after the first
332    /// generation has been evaluated (i.e. if `state.fitness` is non-empty
333    /// but `state.best_genome` was not set by a preceding `tell` call).
334    fn ask(
335        &self,
336        params: &BatConfig,
337        state: &BatState<B>,
338        rng: &mut dyn Rng,
339        device: &<B as burn::tensor::backend::BackendTypes>::Device,
340    ) -> (Tensor<B, 2>, BatState<B>) {
341        if state.fitness.is_empty() {
342            // Evaluate the initial colony first; the velocity update is
343            // only defined once a best exists.
344            return (state.positions.clone(), state.clone());
345        }
346
347        let pop = params.pop_size;
348        let genome_dim = params.genome_dim;
349        let (lo, hi): (f32, f32) = params.bounds.into();
350
351        // Host-side sampling for β, pulse check, acceptance draw, and
352        // local-walk ε. Keeping these on host preserves bit-parity
353        // across backends (Mantegna / wgpu normal RNG has documented
354        // fp drift under FMA reordering; BA draws are mostly uniform).
355        let mut stream = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
356
357        let mut betas = Vec::with_capacity(pop);
358        let mut use_local = Vec::with_capacity(pop);
359        let mut accept_draw = Vec::with_capacity(pop);
360        let mut epsilon_rows = Vec::with_capacity(pop * genome_dim);
361        for i in 0..pop {
362            betas.push(stream.random::<f32>());
363            use_local.push(stream.random::<f32>() > state.pulse_rate[i]);
364            accept_draw.push(stream.random::<f32>());
365            for _ in 0..genome_dim {
366                epsilon_rows.push(2.0 * stream.random::<f32>() - 1.0);
367            }
368        }
369
370        // Mean loudness across the colony — used by the local-walk
371        // step to scale its ε perturbation.
372        let mean_loudness: f32 = {
373            let s: f32 = state.loudness.iter().sum();
374            #[allow(clippy::cast_precision_loss)]
375            {
376                s / pop as f32
377            }
378        };
379
380        let best = state
381            .best_genome
382            .as_ref()
383            .expect("best populated after first tell")
384            .clone()
385            .expand([pop, genome_dim]);
386
387        // Frequency: f_i = f_min + (f_max - f_min) · β_i  → shape (pop, 1) → (pop, D).
388        let f_vec: Vec<f32> = betas
389            .iter()
390            .map(|b| params.f_min + (params.f_max - params.f_min) * b)
391            .collect();
392        let f_mat = Tensor::<B, 1>::from_data(TensorData::new(f_vec, [pop]), device)
393            .unsqueeze_dim::<2>(1)
394            .expand([pop, genome_dim]);
395
396        // Clamp velocity to the search extent to prevent unbounded ±∞/NaN
397        // drift when a bat is pinned against a bound (parity with PSO's v_max).
398        let span = (hi - lo).abs();
399        let new_velocities = (state.velocities.clone()
400            + (state.positions.clone() - best.clone()).mul(f_mat))
401        .clamp(-span, span);
402        let global_move = state.positions.clone() + new_velocities.clone();
403        // Local walk: x_best + ε · mean(A).
404        let eps =
405            Tensor::<B, 2>::from_data(TensorData::new(epsilon_rows, [pop, genome_dim]), device);
406        let local_move = best + eps.mul_scalar(mean_loudness);
407
408        #[allow(clippy::cast_possible_wrap)]
409        let mask = Tensor::<B, 1, Int>::from_data(
410            TensorData::new(
411                use_local.iter().map(|&b| i64::from(b)).collect::<Vec<_>>(),
412                [pop],
413            ),
414            device,
415        )
416        .equal_elem(1)
417        .unsqueeze_dim::<2>(1)
418        .expand([pop, genome_dim]);
419        let candidates = global_move.mask_where(mask, local_move).clamp(lo, hi);
420
421        // Defer acceptance decisions to tell; record the random draws.
422        let mut next = state.clone();
423        next.velocities = new_velocities;
424        next.pending_accept = accept_draw
425            .iter()
426            .zip(state.loudness.iter())
427            .map(|(&draw, &a)| draw < a)
428            .collect();
429        (candidates, next)
430    }
431
432    /// Ingest candidate fitness values, apply the acceptance gate, and
433    /// advance the generation counter.
434    ///
435    /// On the first call (generation zero bootstrap) all candidates are
436    /// unconditionally accepted and loudness/pulse-rate updates are
437    /// skipped.
438    ///
439    /// On subsequent calls candidate `i` replaces position `i` iff
440    /// `pending_accept[i]` (drawn in [`ask`](Strategy::ask)) **and**
441    /// `fitness[i] ≥ state.fitness[i]`.  On acceptance, loudness decays
442    /// (`A_i *= α`) and pulse rate grows
443    /// (`r_i = r₀·(1 − exp(−γ·t))`).
444    fn tell(
445        &self,
446        params: &BatConfig,
447        candidates: Tensor<B, 2>,
448        fitness: Tensor<B, 1>,
449        mut state: BatState<B>,
450        _rng: &mut dyn Rng,
451    ) -> (BatState<B>, StrategyMetrics) {
452        let fitness_host = fitness
453            .into_data()
454            .into_vec::<f32>()
455            .expect("fitness tensor must be readable as f32");
456        let device = candidates.device();
457        let pop = params.pop_size;
458        let genome_dim = params.genome_dim;
459
460        if state.fitness.is_empty() {
461            state.fitness.clone_from(&fitness_host);
462            let best_idx = argmax_host(&fitness_host);
463            state.best_fitness = fitness_host[best_idx];
464            #[allow(clippy::cast_possible_wrap)]
465            let idx = Tensor::<B, 1, Int>::from_data(
466                TensorData::new(vec![best_idx as i64], [1]),
467                &device,
468            );
469            state.best_genome = Some(candidates.clone().select(0, idx));
470            state.positions = candidates;
471            state.generation += 1;
472            let m = StrategyMetrics::from_host_fitness(
473                state.generation,
474                &fitness_host,
475                state.best_fitness,
476            );
477            state.best_fitness = m.best_fitness_ever();
478            return (state, m);
479        }
480
481        // Acceptance: accept candidate `i` iff `pending_accept[i]` AND
482        // candidate's fitness is no worse than current (higher is better).
483        #[allow(clippy::cast_possible_wrap)]
484        let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
485        let mut new_fitness = state.fitness.clone();
486        #[allow(clippy::cast_precision_loss)]
487        let t = state.generation as f32;
488        for i in 0..pop {
489            let accept_gate = state.pending_accept.get(i).copied().unwrap_or(false);
490            let improves = fitness_host[i] >= state.fitness[i];
491            if accept_gate && improves {
492                #[allow(clippy::cast_possible_wrap)]
493                {
494                    rs[i] = (pop + i) as i64;
495                }
496                new_fitness[i] = fitness_host[i];
497                state.loudness[i] *= params.alpha;
498                state.pulse_rate[i] = params.r0 * (1.0 - (-params.gamma * t).exp());
499            }
500        }
501        let stacked = Tensor::cat(vec![state.positions.clone(), candidates], 0);
502        let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
503        state.positions = stacked.select(0, idx);
504        state.fitness = new_fitness;
505
506        // Refresh global best.
507        let best_idx = argmax_host(&state.fitness);
508        if state.fitness[best_idx] > state.best_fitness {
509            state.best_fitness = state.fitness[best_idx];
510            #[allow(clippy::cast_possible_wrap)]
511            let idx = Tensor::<B, 1, Int>::from_data(
512                TensorData::new(vec![best_idx as i64], [1]),
513                &device,
514            );
515            state.best_genome = Some(state.positions.clone().select(0, idx));
516        }
517
518        state.generation += 1;
519        let m =
520            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
521        state.best_fitness = m.best_fitness_ever();
522        let _ = genome_dim;
523        (state, m)
524    }
525
526    /// Returns the best-so-far `(genome, fitness)` pair, or `None` before
527    /// the first [`tell`](Strategy::tell) call.
528    fn best(&self, state: &BatState<B>) -> Option<(Tensor<B, 2>, f32)> {
529        state
530            .best_genome
531            .as_ref()
532            .map(|g| (g.clone(), state.best_fitness))
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::fitness::FromFitnessEvaluable;
540    use crate::strategy::EvolutionaryHarness;
541    use burn::backend::Flex;
542    use rand::SeedableRng;
543    use rand::rngs::StdRng;
544    use rlevo_core::fitness::FitnessEvaluable;
545
546    type TestBackend = Flex;
547
548    #[test]
549    fn try_new_checks_cache_lengths() {
550        let device = Default::default();
551        let pos = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
552        let vel = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
553        assert!(
554            BatState::try_new(
555                pos.clone(),
556                vel.clone(),
557                vec![1.0; 3],
558                vec![0.5; 3],
559                vec![1.0; 3],
560                None,
561                1.0,
562                1,
563                vec![false; 3],
564            )
565            .is_ok()
566        );
567        // loudness length 2 ≠ pop 3.
568        assert!(
569            BatState::try_new(
570                pos,
571                vel,
572                vec![1.0; 2],
573                vec![0.5; 3],
574                vec![1.0; 3],
575                None,
576                1.0,
577                1,
578                vec![false; 3],
579            )
580            .is_err()
581        );
582    }
583
584    #[test]
585    fn default_config_validates() {
586        assert!(BatConfig::default_for(30, 10).validate().is_ok());
587    }
588
589    #[test]
590    fn rejects_inverted_frequency_range() {
591        let mut cfg = BatConfig::default_for(30, 10);
592        cfg.f_min = 3.0;
593        cfg.f_max = 1.0;
594        assert_eq!(cfg.validate().unwrap_err().field, "f_min");
595    }
596
597    struct Sphere;
598    struct SphereFit;
599    impl FitnessEvaluable for SphereFit {
600        type Individual = Vec<f64>;
601        type Landscape = Sphere;
602        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
603            x.iter().map(|v| v * v).sum()
604        }
605    }
606
607    #[test]
608    fn bat_converges_on_sphere_d10() {
609        // Bat is a "legacy comparator" per the module-level candor
610        // note. We require strong reduction from the random baseline,
611        // not machine precision — the probabilistic acceptance gate
612        // (A_i decay) throttles late-stage progress. Threshold 0.1 on
613        // Sphere-D10 in 800 generations is still orders of magnitude
614        // below the uniform-random baseline (≈ 87).
615        let device = Default::default();
616        let strategy = BatAlgorithm::<TestBackend>::new();
617        let params = BatConfig::default_for(40, 10);
618        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
619        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
620            strategy, params, fitness_fn, 23, device, 800,
621        )
622        .expect("valid params");
623        harness.reset();
624        while !harness.step(()).done {}
625        let best = harness.latest_metrics().unwrap().best_fitness_ever();
626        assert!(best < 0.1, "Bat D10 best={best}");
627    }
628
629    #[test]
630    fn velocities_stay_finite_and_bounded_under_pinning() {
631        // Regression for #156 (Bat §1.1). The velocity update
632        // `v ← v + (x − x_best)·f` is repulsive for a bat sitting away
633        // from `x_best`, and `ask` rewrites `state.velocities` every
634        // generation regardless of `tell`'s acceptance gate. A bat whose
635        // position stays fixed while `x_best` sits elsewhere therefore
636        // accrues a near-constant increment each generation, so unclamped
637        // velocities drift linearly to ±∞ and then to NaN (via `inf − inf`).
638        // The ±span clamp (parity with PSO's `v_max`) must keep every
639        // velocity finite and within the search extent no matter how long
640        // the swarm runs.
641        let device = Default::default();
642        let strategy = BatAlgorithm::<TestBackend>::new();
643        let params = BatConfig::default_for(20, 4);
644        let (lo, hi): (f32, f32) = params.bounds.into();
645        let span = (hi - lo).abs();
646        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
647        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
648            strategy, params, fitness_fn, 7, device, 100,
649        )
650        .expect("valid params");
651        harness.reset();
652        while !harness.step(()).done {}
653        let velocities: Vec<f32> = harness
654            .state()
655            .expect("state populated after stepping")
656            .velocities()
657            .clone()
658            .into_data()
659            .into_vec::<f32>()
660            .expect("velocities readable as f32");
661        for v in velocities {
662            assert!(v.is_finite(), "velocity not finite: {v}");
663            assert!(
664                v.abs() <= span + 1e-3,
665                "velocity {v} exceeds search span {span}"
666            );
667        }
668    }
669
670    /// Fitness fn: row 0 → `NaN`, the rest finite. `Maximize` so natural ==
671    /// canonical, exercising the ADR-0034 harness sanitize with no `neg()`.
672    struct PartialNanFitness;
673    impl<B: Backend> crate::fitness::BatchFitnessFn<B, Tensor<B, 2>> for PartialNanFitness {
674        fn evaluate_batch(
675            &mut self,
676            population: &Tensor<B, 2>,
677            device: &<B as burn::tensor::backend::BackendTypes>::Device,
678        ) -> Tensor<B, 1> {
679            let n = population.dims()[0];
680            #[allow(clippy::cast_precision_loss)]
681            let mut vals: Vec<f32> = (0..n).map(|i| -(i as f32)).collect();
682            vals[0] = f32::NAN;
683            Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
684        }
685        fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
686            rlevo_core::objective::ObjectiveSense::Maximize
687        }
688    }
689
690    /// Builds a steady-state (generation ≥ 1) bat swarm at the origin with a
691    /// non-empty fitness cache and a populated `best_genome`, so `ask` takes the
692    /// velocity-update path rather than the bootstrap early return.
693    fn steady_state(
694        pop: usize,
695        d: usize,
696        device: burn::backend::flex::FlexDevice,
697    ) -> BatState<TestBackend> {
698        let positions = Tensor::<TestBackend, 2>::zeros([pop, d], &device);
699        let velocities = Tensor::<TestBackend, 2>::zeros([pop, d], &device);
700        let best = Tensor::<TestBackend, 2>::zeros([1, d], &device);
701        BatState::try_new(
702            positions,
703            velocities,
704            vec![1.0; pop],
705            vec![0.5; pop],
706            vec![0.0; pop],
707            Some(best),
708            0.0,
709            1,
710            vec![false; pop],
711        )
712        .expect("valid steady state")
713    }
714
715    // Gap (e): the best-so-far accessor is `None` until a `tell` records one.
716    #[test]
717    fn best_is_none_before_first_tell() {
718        let device = Default::default();
719        let strategy = BatAlgorithm::<TestBackend>::new();
720        let params = BatConfig::default_for(8, 4);
721        let mut rng = StdRng::seed_from_u64(1);
722        let state = strategy.init(&params, &mut rng, &device);
723        assert!(strategy.best(&state).is_none());
724    }
725
726    // Gap (a): a lone bat (`pop_size = 1`) and a single-dimension genome are the
727    // degenerate extremes. Both must run a full harness loop without panicking.
728    #[test]
729    fn degenerate_dims_run() {
730        for (pop, d) in [(1usize, 4usize), (6, 1)] {
731            let device = Default::default();
732            let strategy = BatAlgorithm::<TestBackend>::new();
733            let params = BatConfig::default_for(pop, d);
734            let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
735            let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
736                strategy, params, fitness_fn, 3, device, 8,
737            )
738            .expect("valid params");
739            harness.reset();
740            while !harness.step(()).done {}
741            assert!(
742                harness
743                    .latest_metrics()
744                    .unwrap()
745                    .best_fitness_ever()
746                    .is_finite(),
747                "non-finite best for (pop={pop}, d={d})"
748            );
749        }
750    }
751
752    // Gap (b): every proposed candidate is clamped into `bounds` after `ask`,
753    // across 32 seeds.
754    #[test]
755    fn proposed_positions_within_bounds() {
756        let device = Default::default();
757        let strategy = BatAlgorithm::<TestBackend>::new();
758        let params = BatConfig::default_for(10, 4);
759        let (lo, hi): (f32, f32) = params.bounds.into();
760        let state = steady_state(10, 4, device);
761        for seed in 0..32 {
762            let mut rng = StdRng::seed_from_u64(seed);
763            let (cand, _next) = strategy.ask(&params, &state, &mut rng, &device);
764            let vals = cand
765                .into_data()
766                .into_vec::<f32>()
767                .expect("candidates readable as f32");
768            for &v in &vals {
769                assert!(
770                    v >= lo && v <= hi,
771                    "candidate {v} out of bounds [{lo}, {hi}] (seed {seed})"
772                );
773            }
774        }
775    }
776
777    // Gap (c): the BA-specific loudness/pulse update math and the acceptance
778    // gate. Bee 0 has `pending_accept = true` and an improving candidate, so it
779    // accepts: loudness decays by α and pulse rate jumps to
780    // `r₀·(1 − exp(−γ·t))`, and its position becomes the candidate. Bee 1 has
781    // `pending_accept = false`, so despite an improving candidate it is
782    // rejected: loudness, pulse rate, and position are all untouched.
783    #[test]
784    fn loudness_decay_pulse_growth_and_acceptance_gate() {
785        let device = Default::default();
786        let strategy = BatAlgorithm::<TestBackend>::new();
787        let params = BatConfig::default_for(2, 1); // α = 0.9, γ = 0.9, r₀ = 0.5
788        let generation: usize = 3;
789        let state = BatState::try_new(
790            Tensor::<TestBackend, 2>::zeros([2, 1], &device),
791            Tensor::<TestBackend, 2>::zeros([2, 1], &device),
792            vec![1.0, 1.0], // loudness = a0
793            vec![0.5, 0.5], // pulse = r0
794            vec![0.0, 0.0], // current fitness
795            Some(Tensor::<TestBackend, 2>::zeros([1, 1], &device)),
796            0.0,
797            generation,
798            vec![true, false], // bee 0 accepts, bee 1 rejects
799        )
800        .expect("valid state");
801        let candidates = Tensor::<TestBackend, 2>::full([2, 1], 0.1, &device);
802        // Both candidates improve (1.0 ≥ 0.0), isolating the acceptance gate.
803        let fit =
804            Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![1.0_f32, 1.0], [2]), &device);
805        let mut rng = StdRng::seed_from_u64(0);
806        let (next, _m) = strategy.tell(&params, candidates, fit, state, &mut rng);
807
808        // Bee 0 accepted → loudness *= α; bee 1 rejected → unchanged.
809        approx::assert_relative_eq!(next.loudness()[0], 0.9, epsilon = 1e-6);
810        approx::assert_relative_eq!(next.loudness()[1], 1.0, epsilon = 1e-6);
811
812        // Bee 0 accepted → pulse = r0·(1 − exp(−γ·t)); bee 1 rejected → stays r0.
813        #[allow(clippy::cast_precision_loss)]
814        let expected_pulse = 0.5 * (1.0 - (-0.9_f32 * generation as f32).exp());
815        approx::assert_relative_eq!(next.pulse_rate()[0], expected_pulse, epsilon = 1e-6);
816        approx::assert_relative_eq!(next.pulse_rate()[1], 0.5, epsilon = 1e-6);
817
818        // Position: bee 0 takes the candidate (0.1), bee 1 keeps the origin.
819        let pos = next
820            .positions()
821            .clone()
822            .into_data()
823            .into_vec::<f32>()
824            .expect("positions readable as f32");
825        approx::assert_relative_eq!(pos[0], 0.1, epsilon = 1e-6);
826        approx::assert_relative_eq!(pos[1], 0.0, epsilon = 1e-6);
827    }
828
829    // Gap (d): a partly-`NaN` objective is neutralized by the harness sanitize
830    // chokepoint (ADR 0034).
831    #[test]
832    fn nan_fitness_survives_harness() {
833        let device = Default::default();
834        let strategy = BatAlgorithm::<TestBackend>::new();
835        let params = BatConfig::default_for(8, 3);
836        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
837            strategy,
838            params,
839            PartialNanFitness,
840            4,
841            device,
842            4,
843        )
844        .expect("valid params");
845        harness.reset();
846        while !harness.step(()).done {}
847        let m = harness.latest_metrics().unwrap();
848        assert!(
849            m.best_fitness_ever().is_finite(),
850            "best_fitness_ever not finite: {}",
851            m.best_fitness_ever()
852        );
853        assert!(m.broken_count() > 0, "expected a broken (NaN) member");
854    }
855}