Skip to main content

rlevo_evolution/algorithms/metaheuristic/
firefly.rs

1//! Firefly Algorithm.
2//!
3//! Each firefly `i` moves toward every **brighter** firefly `j`, with
4//! attractiveness decaying exponentially in the squared distance:
5//!
6//! - `β(r_ij) = β₀ · exp(−γ · r_ij²)` where `r_ij = ‖x_i − x_j‖`,
7//! - `Δx_i = Σ_{j : f(x_j) > f(x_i)} β(r_ij) · (x_j − x_i) + α · (U[−0.5, 0.5])`,
8//! - `x_i ← x_i + Δx_i`.
9//!
10//! The attraction sum is canonically `O(N²)`; a naïve tensor
11//! implementation materializes an `(N, N, D)` pairwise-difference
12//! tensor and therefore blows out memory at `N > 128`. This module
13//! enforces that hard cap when the `custom-kernels` feature is off. A
14//! future fused `CubeCL` kernel
15//! ([`super::kernels::pairwise_attract_cube`]) is designed to stream
16//! over the neighbour axis and keep memory at `O(ND)`, removing the
17//! cap; until that kernel lands, the pure-tensor path runs even when
18//! the feature is enabled.
19//!
20//! # References
21//!
22//! - Yang (2008), *Nature-Inspired Metaheuristic Algorithms*.
23
24use std::marker::PhantomData;
25
26use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
27use rand::Rng;
28use rand::RngExt;
29use rand::SeedableRng;
30
31use rlevo_core::bounds::Bounds;
32use rlevo_core::config::{self, ConfigError, Validate};
33
34use super::len_matches_pop;
35use crate::ops::selection::argmax_host;
36use crate::rng::{SeedPurpose, seed_stream};
37use crate::strategy::{Strategy, StrategyMetrics};
38
39/// Hard cap for the pure-tensor `O(N²D)` Firefly path. Exceeding this
40/// without the fused `CubeCL` kernel would allocate a cubic tensor on
41/// device; the kernel path removes the cap.
42pub const FIREFLY_PURE_TENSOR_CAP: usize = 128;
43
44/// Static configuration for [`FireflyAlgorithm`].
45#[derive(Debug, Clone)]
46pub struct FireflyConfig {
47    /// Number of fireflies.
48    pub pop_size: usize,
49    /// Genome dimensionality.
50    pub genome_dim: usize,
51    /// Search-space bounds.
52    pub bounds: Bounds,
53    /// Base attractiveness `β₀`. Canonical 1.0.
54    pub beta0: f32,
55    /// Light-absorption coefficient `γ`. Canonical 1.0; controls the
56    /// range over which fireflies can see each other.
57    pub gamma: f32,
58    /// Noise scale for the random walk term. Canonical 0.2.
59    pub alpha: f32,
60}
61
62impl FireflyConfig {
63    /// Default configuration. `γ` is scaled by the search-space extent
64    /// so the exponential decay lands in a useful regime — Yang's
65    /// canonical `γ = 1` assumes `[0, 1]` normalization; for the usual
66    /// `[−5.12, 5.12]` domain, `γ ≈ 1 / L²` keeps attractiveness
67    /// non-vanishing across pairs.
68    #[must_use]
69    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
70        let (lo, hi): (f32, f32) = (-5.12, 5.12);
71        let length: f32 = hi - lo;
72        // γ ≈ 1/L², Yang's canonical regime scaled to the domain extent.
73        let gamma: f32 = 1.0 / (length * length);
74        Self {
75            pop_size,
76            genome_dim,
77            bounds: Bounds::new(lo, hi),
78            beta0: 1.0,
79            gamma,
80            alpha: 0.2,
81        }
82    }
83}
84
85impl Validate for FireflyConfig {
86    fn validate(&self) -> Result<(), ConfigError> {
87        const C: &str = "FireflyConfig";
88        config::at_least(C, "pop_size", self.pop_size, 1)?;
89        // Without the fused kernel the pure-tensor path materialises an
90        // (N, N, D) tensor, so cap the swarm at FIREFLY_PURE_TENSOR_CAP.
91        #[cfg(not(feature = "custom-kernels"))]
92        if self.pop_size > FIREFLY_PURE_TENSOR_CAP {
93            return Err(ConfigError {
94                config: C,
95                field: "pop_size",
96                kind: rlevo_core::config::ConstraintKind::Custom(
97                    "pop_size exceeds the pure-tensor cap (128); enable `custom-kernels`",
98                ),
99            });
100        }
101        config::nonzero(C, "genome_dim", self.genome_dim)?;
102        config::in_range(C, "beta0", 0.0, f64::INFINITY, f64::from(self.beta0))?;
103        config::positive(C, "gamma", f64::from(self.gamma))?;
104        config::in_range(C, "alpha", 0.0, f64::INFINITY, f64::from(self.alpha))?;
105        Ok(())
106    }
107}
108
109/// Generation state for [`FireflyAlgorithm`].
110#[derive(Debug, Clone)]
111pub struct FireflyState<B: Backend> {
112    /// Current positions, shape `(pop_size, D)`.
113    positions: Tensor<B, 2>,
114    /// Host-side fitness cache.
115    fitness: Vec<f32>,
116    /// Best-so-far genome.
117    best_genome: Option<Tensor<B, 2>>,
118    /// Best-so-far fitness.
119    best_fitness: f32,
120    /// Generation counter.
121    generation: usize,
122}
123
124impl<B: Backend> FireflyState<B> {
125    /// Assembles a firefly state, checking the fitness cache matches `pop`.
126    ///
127    /// # Errors
128    ///
129    /// Returns a [`ConfigError`] if `positions` has zero rows or if `fitness`
130    /// is non-empty with a length other than `pop_size`.
131    pub fn try_new(
132        positions: Tensor<B, 2>,
133        fitness: Vec<f32>,
134        best_genome: Option<Tensor<B, 2>>,
135        best_fitness: f32,
136        generation: usize,
137    ) -> Result<Self, ConfigError> {
138        let pop = positions.dims()[0];
139        config::nonzero("FireflyState", "pop_size", pop)?;
140        len_matches_pop("FireflyState", "fitness", pop, fitness.len())?;
141        Ok(Self {
142            positions,
143            fitness,
144            best_genome,
145            best_fitness,
146            generation,
147        })
148    }
149
150    /// Current positions, shape `(pop_size, D)`.
151    #[must_use]
152    pub fn positions(&self) -> &Tensor<B, 2> {
153        &self.positions
154    }
155
156    /// Host-side fitness cache (empty at bootstrap, else `pop_size` long).
157    #[must_use]
158    pub fn fitness(&self) -> &[f32] {
159        &self.fitness
160    }
161
162    /// Best-so-far genome, or `None` before the first `tell`.
163    #[must_use]
164    pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
165        self.best_genome.as_ref()
166    }
167
168    /// Best-so-far (canonical, maximise) fitness.
169    #[must_use]
170    pub fn best_fitness(&self) -> f32 {
171        self.best_fitness
172    }
173
174    /// Generation counter.
175    #[must_use]
176    pub fn generation(&self) -> usize {
177        self.generation
178    }
179}
180
181/// Firefly Algorithm strategy.
182///
183/// When the `custom-kernels` feature is **off**, [`FireflyConfig`] enforces a
184/// `pop_size <= FIREFLY_PURE_TENSOR_CAP` (= 128) cap through
185/// [`Validate::validate`] at the harness chokepoint, since the pure-tensor path
186/// materializes an `(N, N, D)` pairwise tensor. With the feature on the same
187/// cap is surfaced via a `debug_assert!` in [`Strategy::init`], because the
188/// fused kernel [`super::kernels::pairwise_attract_cube`] is still designed-only
189/// and the strategy keeps using the pure-tensor path in the meantime.
190///
191/// # Example
192///
193/// ```no_run
194/// use burn::backend::Flex;
195/// use rlevo_evolution::algorithms::metaheuristic::firefly::{FireflyAlgorithm, FireflyConfig};
196///
197/// let strategy = FireflyAlgorithm::<Flex>::new();
198/// let params = FireflyConfig::default_for(32, 10);
199/// let _ = (strategy, params);
200/// ```
201#[derive(Debug, Clone, Copy, Default)]
202pub struct FireflyAlgorithm<B: Backend> {
203    _backend: PhantomData<fn() -> B>,
204}
205
206impl<B: Backend> FireflyAlgorithm<B> {
207    /// Builds a new (stateless) strategy object.
208    #[must_use]
209    pub fn new() -> Self {
210        Self {
211            _backend: PhantomData,
212        }
213    }
214
215    /// Pure-tensor `O(N²D)` attraction kernel — always available, even
216    /// without the `custom-kernels` feature. The fused `CubeCL` kernel
217    /// designed in [`super::kernels::pairwise_attract_cube`] slots in at
218    /// this call site once it lands.
219    fn pure_tensor_attract(
220        positions: &Tensor<B, 2>,
221        fitness: &[f32],
222        beta0: f32,
223        gamma: f32,
224        alpha: f32,
225        device: &<B as burn::tensor::backend::BackendTypes>::Device,
226        noise_seed: u64,
227    ) -> Tensor<B, 2> {
228        let pop = fitness.len();
229        let shape = positions.dims();
230        let d = shape[1];
231
232        // Pairwise squared distances via (x·x^T + ||x||² - 2x·x^T).
233        // Cheaper memory than the (N, N, D) difference tensor, but we
234        // still need the (N, N, D) tensor for the displacement `x_j -
235        // x_i`. Cap enforced at module level.
236        let xi = positions.clone().unsqueeze_dim::<3>(1); // (N, 1, D)
237        let xj = positions.clone().unsqueeze_dim::<3>(0); // (1, N, D)
238        let diff = xj.expand([pop, pop, d]) - xi.expand([pop, pop, d]); // (N, N, D)
239        let r2 = diff.clone().powi_scalar(2).sum_dim(2).squeeze_dim::<2>(2); // (N, N)
240        let beta = r2.mul_scalar(-gamma).exp().mul_scalar(beta0); // (N, N)
241
242        // Brightness mask: bright[i, j] = 1 iff fitness[j] > fitness[i].
243        let mut bright = vec![0i64; pop * pop];
244        for i in 0..pop {
245            for j in 0..pop {
246                if fitness[j] > fitness[i] {
247                    bright[i * pop + j] = 1;
248                }
249            }
250        }
251        let bright_mask =
252            Tensor::<B, 2, Int>::from_data(TensorData::new(bright, [pop, pop]), device)
253                .equal_elem(1);
254        // Zero-out non-bright pairs in β then multiply diff.
255        let zero = Tensor::<B, 2>::zeros([pop, pop], device);
256        let beta_m = beta.mask_where(bright_mask.bool_not(), zero);
257        let weight = beta_m.unsqueeze_dim::<3>(2).expand([pop, pop, d]); // (N, N, D)
258        let weighted = diff.mul(weight); // (N, N, D)
259        let attr_sum = weighted.sum_dim(1).squeeze_dim::<2>(1); // (N, D)
260
261        // Noise: α · (U[0,1] - 0.5). Host-sample from the supplied seed so
262        // the draw is reproducible across thread schedules rather than
263        // racing the process-wide Flex RNG.
264        let mut noise_rng = rand::rngs::StdRng::seed_from_u64(noise_seed);
265        let mut noise_rows = Vec::with_capacity(pop * d);
266        for _ in 0..pop * d {
267            noise_rows.push(noise_rng.random::<f32>() - 0.5);
268        }
269        let noise = Tensor::<B, 2>::from_data(TensorData::new(noise_rows, [pop, d]), device);
270        attr_sum + noise.mul_scalar(alpha)
271    }
272}
273
274impl<B: Backend> Strategy<B> for FireflyAlgorithm<B>
275where
276    B::Device: Clone,
277{
278    type Params = FireflyConfig;
279    type State = FireflyState<B>;
280    type Genome = Tensor<B, 2>;
281
282    /// Build the initial swarm by host-sampling `pop_size` positions
283    /// uniformly in `[bounds.lo, bounds.hi]`.
284    ///
285    /// Positions are drawn from a deterministic [`seed_stream`] so
286    /// initialisation is bit-stable regardless of core count or test
287    /// ordering; the process-wide Flex RNG is never touched.
288    ///
289    /// The `pop_size <= FIREFLY_PURE_TENSOR_CAP` cap (without `custom-kernels`)
290    /// is enforced by [`FireflyConfig`]'s [`Validate`] impl at the harness
291    /// chokepoint.
292    fn init(
293        &self,
294        params: &FireflyConfig,
295        rng: &mut dyn Rng,
296        device: &<B as burn::tensor::backend::BackendTypes>::Device,
297    ) -> FireflyState<B> {
298        debug_assert!(
299            params.validate().is_ok(),
300            "invalid FireflyConfig reached init: {params:?}"
301        );
302        // Even with the kernel feature active, the fused pairwise-attract
303        // kernel is currently a design placeholder and the pure-tensor
304        // path is still in use. A debug assert surfaces the limitation in
305        // tests without blocking downstream users who have wired in their
306        // own kernel.
307        #[cfg(feature = "custom-kernels")]
308        debug_assert!(
309            params.pop_size <= FIREFLY_PURE_TENSOR_CAP,
310            "Firefly pop_size > {FIREFLY_PURE_TENSOR_CAP} requires the fused pairwise-attract kernel; \
311             the placeholder kernel module still runs the pure-tensor path"
312        );
313        let (lo, hi): (f32, f32) = params.bounds.into();
314        // Host-sample the initial swarm from a deterministic `seed_stream`
315        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
316        // whose draws interleave with sibling tests under the parallel runner
317        // and are not reproducible across thread schedules.
318        let pop = params.pop_size;
319        let genome_dim = params.genome_dim;
320        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
321        let mut position_rows = Vec::with_capacity(pop * genome_dim);
322        for _ in 0..pop * genome_dim {
323            position_rows.push(lo + (hi - lo) * stream.random::<f32>());
324        }
325        let positions =
326            Tensor::<B, 2>::from_data(TensorData::new(position_rows, [pop, genome_dim]), device);
327        FireflyState {
328            positions,
329            fitness: Vec::new(),
330            best_genome: None,
331            best_fitness: f32::NEG_INFINITY,
332            generation: 0,
333        }
334    }
335
336    /// Propose the next swarm positions.
337    ///
338    /// On the first call (`state.fitness` is empty) returns the initial
339    /// positions unchanged so the caller can evaluate generation zero.
340    /// On subsequent calls, computes the pairwise attractiveness update
341    /// via `pure_tensor_attract` and clips positions to
342    /// `params.bounds`. The noise seed is derived from the host RNG
343    /// through [`seed_stream`], keeping draws reproducible.
344    fn ask(
345        &self,
346        params: &FireflyConfig,
347        state: &FireflyState<B>,
348        rng: &mut dyn Rng,
349        device: &<B as burn::tensor::backend::BackendTypes>::Device,
350    ) -> (Tensor<B, 2>, FireflyState<B>) {
351        if state.fitness.is_empty() {
352            return (state.positions.clone(), state.clone());
353        }
354
355        let seed = seed_stream(
356            rng.next_u64(),
357            state.generation as u64,
358            SeedPurpose::Mutation,
359        )
360        .next_u64();
361        let delta = Self::pure_tensor_attract(
362            &state.positions,
363            &state.fitness,
364            params.beta0,
365            params.gamma,
366            params.alpha,
367            device,
368            seed,
369        );
370        let (lo, hi): (f32, f32) = params.bounds.into();
371        let new_positions = (state.positions.clone() + delta).clamp(lo, hi);
372
373        let mut next = state.clone();
374        next.positions.clone_from(&new_positions);
375        (new_positions, next)
376    }
377
378    /// Ingest fitness values, update the swarm, and advance the generation counter.
379    ///
380    /// Pulls `fitness` to host, updates `state.positions` and
381    /// `state.fitness`, then refreshes the best-so-far genome if the
382    /// current generation contains a new maximum.  Returns the updated
383    /// state and a [`StrategyMetrics`] snapshot for the completed
384    /// generation.
385    fn tell(
386        &self,
387        _params: &FireflyConfig,
388        population: Tensor<B, 2>,
389        fitness: Tensor<B, 1>,
390        mut state: FireflyState<B>,
391        _rng: &mut dyn Rng,
392    ) -> (FireflyState<B>, StrategyMetrics) {
393        let fitness_host = fitness
394            .into_data()
395            .into_vec::<f32>()
396            .expect("fitness tensor must be readable as f32");
397        let device = population.device();
398        state.fitness.clone_from(&fitness_host);
399        state.positions.clone_from(&population);
400
401        let best_idx = argmax_host(&fitness_host);
402        if fitness_host[best_idx] > state.best_fitness {
403            state.best_fitness = fitness_host[best_idx];
404            #[allow(clippy::cast_possible_wrap)]
405            let idx = Tensor::<B, 1, Int>::from_data(
406                TensorData::new(vec![best_idx as i64], [1]),
407                &device,
408            );
409            state.best_genome = Some(population.select(0, idx));
410        }
411        state.generation += 1;
412        let m =
413            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
414        state.best_fitness = m.best_fitness_ever();
415        (state, m)
416    }
417
418    /// Returns the best-so-far `(genome, fitness)` pair, or `None` before
419    /// the first [`tell`](Strategy::tell) call.
420    fn best(&self, state: &FireflyState<B>) -> Option<(Tensor<B, 2>, f32)> {
421        state
422            .best_genome
423            .as_ref()
424            .map(|g| (g.clone(), state.best_fitness))
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::fitness::FromFitnessEvaluable;
432    use crate::strategy::EvolutionaryHarness;
433    use burn::backend::Flex;
434    use rand::rngs::StdRng;
435    use rlevo_core::fitness::FitnessEvaluable;
436
437    type TestBackend = Flex;
438
439    #[test]
440    fn try_new_checks_fitness_length() {
441        let device = Default::default();
442        let pos = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
443        assert!(FireflyState::try_new(pos.clone(), vec![1.0; 3], None, 1.0, 1).is_ok());
444        assert!(FireflyState::try_new(pos.clone(), vec![], None, f32::MIN, 0).is_ok());
445        assert!(FireflyState::try_new(pos, vec![1.0; 2], None, 1.0, 1).is_err());
446        let empty = Tensor::<TestBackend, 2>::zeros([0, 2], &device);
447        assert!(FireflyState::try_new(empty, vec![], None, 1.0, 0).is_err());
448    }
449
450    #[test]
451    fn default_config_validates() {
452        assert!(FireflyConfig::default_for(32, 10).validate().is_ok());
453    }
454
455    #[test]
456    fn default_gamma_matches_inverse_length_squared() {
457        let cfg = FireflyConfig::default_for(32, 10);
458        let (lo, hi): (f32, f32) = cfg.bounds.into();
459        let length: f32 = hi - lo;
460        let expected: f32 = 1.0 / (length * length);
461        approx::assert_relative_eq!(cfg.gamma, expected);
462    }
463
464    #[test]
465    fn rejects_zero_gamma() {
466        let mut cfg = FireflyConfig::default_for(32, 10);
467        cfg.gamma = 0.0;
468        assert_eq!(cfg.validate().unwrap_err().field, "gamma");
469    }
470
471    struct Sphere;
472    struct SphereFit;
473    impl FitnessEvaluable for SphereFit {
474        type Individual = Vec<f64>;
475        type Landscape = Sphere;
476        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
477            x.iter().map(|v| v * v).sum()
478        }
479    }
480
481    #[test]
482    fn firefly_converges_on_sphere_d10() {
483        // Firefly's attraction sum is O(N²D); we use 24 fireflies to
484        // keep the test fast while still exercising the pairwise
485        // kernel path.
486        let device = Default::default();
487        let strategy = FireflyAlgorithm::<TestBackend>::new();
488        let params = FireflyConfig::default_for(24, 10);
489        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
490        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
491            strategy, params, fitness_fn, 29, device, 500,
492        )
493        .expect("valid params");
494        harness.reset();
495        while !harness.step(()).done {}
496        let best = harness.latest_metrics().unwrap().best_fitness_ever();
497        assert!(best < 1.0, "Firefly D10 best={best}");
498    }
499
500    /// Fitness fn: row 0 → `NaN`, the rest finite. `Maximize` so natural ==
501    /// canonical, exercising the ADR-0034 harness sanitize with no `neg()`.
502    struct PartialNanFitness;
503    impl<B: Backend> crate::fitness::BatchFitnessFn<B, Tensor<B, 2>> for PartialNanFitness {
504        fn evaluate_batch(
505            &mut self,
506            population: &Tensor<B, 2>,
507            device: &<B as burn::tensor::backend::BackendTypes>::Device,
508        ) -> Tensor<B, 1> {
509            let n = population.dims()[0];
510            #[allow(clippy::cast_precision_loss)]
511            let mut vals: Vec<f32> = (0..n).map(|i| -(i as f32)).collect();
512            vals[0] = f32::NAN;
513            Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
514        }
515        fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
516            rlevo_core::objective::ObjectiveSense::Maximize
517        }
518    }
519
520    // Gap (a): the validator rejects a zero swarm and negative kernel scalars.
521    // `gamma` is `positive` (0 and negatives rejected — 0 is the existing case);
522    // `beta0` and `alpha` are `[0, ∞)` (0 allowed, negatives rejected).
523    #[test]
524    fn rejects_invalid_configs() {
525        let mut cfg = FireflyConfig::default_for(0, 10);
526        assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
527
528        cfg = FireflyConfig::default_for(32, 10);
529        cfg.gamma = -1.0;
530        assert_eq!(cfg.validate().unwrap_err().field, "gamma");
531
532        cfg = FireflyConfig::default_for(32, 10);
533        cfg.beta0 = -1.0;
534        assert_eq!(cfg.validate().unwrap_err().field, "beta0");
535
536        cfg = FireflyConfig::default_for(32, 10);
537        cfg.alpha = -1.0;
538        assert_eq!(cfg.validate().unwrap_err().field, "alpha");
539    }
540
541    // Gap (a) cont.: an inverted range is unrepresentable — `Bounds::new` panics
542    // before a `FireflyConfig` can carry `(5, −5)`.
543    #[test]
544    #[should_panic(expected = "invalid range")]
545    fn inverted_bounds_are_unrepresentable() {
546        let _ = FireflyConfig {
547            bounds: Bounds::new(5.0, -5.0),
548            ..FireflyConfig::default_for(32, 10)
549        };
550    }
551
552    // Gap (g): `pop_size > 128` on the pure-tensor path panics in `init` in a
553    // debug (test) build — either via the `Validate` `debug_assert!` (feature
554    // off) or the explicit cap `debug_assert!` (feature on). Either way the
555    // oversized swarm never materializes its cubic pairwise tensor.
556    #[test]
557    // The panic message differs by feature (`Validate` debug_assert vs. the cap
558    // debug_assert), so no single `expected` substring covers both builds.
559    #[allow(clippy::should_panic_without_expect)]
560    #[should_panic]
561    fn pop_size_over_cap_panics_in_init() {
562        let device = Default::default();
563        let strategy = FireflyAlgorithm::<TestBackend>::new();
564        let params = FireflyConfig::default_for(FIREFLY_PURE_TENSOR_CAP + 1, 4);
565        let mut rng = StdRng::seed_from_u64(0);
566        let _ = strategy.init(&params, &mut rng, &device);
567    }
568
569    // Gap (b): the pure-tensor attraction kernel. Firefly 0 is dimmer, so it is
570    // pulled toward the brighter firefly 1 (positive displacement); firefly 1 has
571    // no brighter neighbour, so its displacement is zero. With `gamma = 0` the
572    // attractiveness is exactly `beta0`, and `alpha = 0` removes noise, making
573    // the displacement exact. The output shape is `(pop, d)`.
574    #[test]
575    fn pure_tensor_attract_pulls_toward_brighter() {
576        let device = Default::default();
577        // 2-D positions (row-major): firefly 0 at (0, 0), firefly 1 at (1, 0).
578        let positions = Tensor::<TestBackend, 2>::from_data(
579            TensorData::new(vec![0.0_f32, 0.0, 1.0, 0.0], [2, 2]),
580            &device,
581        );
582        // firefly 1 is brighter (higher canonical fitness).
583        let fitness = [0.0_f32, 1.0];
584        let delta = FireflyAlgorithm::<TestBackend>::pure_tensor_attract(
585            &positions, &fitness, 1.0, // beta0
586            0.0, // gamma → attractiveness == beta0
587            0.0, // alpha → no noise
588            &device, 0,
589        );
590        assert_eq!(delta.dims(), [2, 2], "displacement is (pop, d)");
591        let d = delta
592            .into_data()
593            .into_vec::<f32>()
594            .expect("delta readable as f32");
595        // Firefly 0 moves +1·(x_1 − x_0) = (+1, 0) toward the brighter one.
596        approx::assert_relative_eq!(d[0], 1.0, epsilon = 1e-6);
597        approx::assert_relative_eq!(d[1], 0.0, epsilon = 1e-6);
598        // Brightest firefly has no brighter neighbour → no attraction.
599        approx::assert_relative_eq!(d[2], 0.0, epsilon = 1e-6);
600        approx::assert_relative_eq!(d[3], 0.0, epsilon = 1e-6);
601    }
602
603    // Gap (b') / issue #233: the `genome_dim = 1` (D == 1) pure-tensor path. The
604    // reductions `sum_dim(2).squeeze_dim::<2>(2)` and `sum_dim(1).squeeze_dim::<2>(1)`
605    // now strip only the reduced axis, so the trailing size-1 genome axis survives
606    // and the kernel no longer panics in burn's `Squeeze` rank-check. Firefly 0 is
607    // dimmer, so it is pulled toward the brighter firefly 1 along the single axis.
608    #[test]
609    fn pure_tensor_attract_d1_pulls_toward_brighter() {
610        let device = Default::default();
611        // 1-D positions: firefly 0 at [0.0], firefly 1 at [1.0].
612        let positions = Tensor::<TestBackend, 2>::from_data(
613            TensorData::new(vec![0.0_f32, 1.0], [2, 1]),
614            &device,
615        );
616        // firefly 1 is brighter (higher canonical fitness).
617        let fitness = [0.0_f32, 1.0];
618        let delta = FireflyAlgorithm::<TestBackend>::pure_tensor_attract(
619            &positions, &fitness, 1.0, // beta0
620            0.0, // gamma → attractiveness == beta0
621            0.0, // alpha → no noise
622            &device, 0,
623        );
624        assert_eq!(delta.dims(), [2, 1], "displacement is (pop, d)");
625        let d = delta
626            .into_data()
627            .into_vec::<f32>()
628            .expect("delta readable as f32");
629        // Firefly 0 moves +1·(x_1 − x_0) = +1 toward the brighter one.
630        approx::assert_relative_eq!(d[0], 1.0, epsilon = 1e-6);
631        // Brightest firefly has no brighter neighbour → no attraction.
632        approx::assert_relative_eq!(d[1], 0.0, epsilon = 1e-6);
633    }
634
635    // Gap (c): `argmax_host` edge cases. Empty slice panics; an all-`NaN` slice
636    // (nothing exceeds the `−∞` seed) falls back to index 0; a single element is
637    // trivially the max.
638    #[test]
639    #[should_panic(expected = "must be non-empty")]
640    fn argmax_host_empty_panics() {
641        let _ = argmax_host(&[]);
642    }
643
644    #[test]
645    fn argmax_host_all_nan_and_single() {
646        assert_eq!(argmax_host(&[f32::NAN, f32::NAN, f32::NAN]), 0);
647        assert_eq!(argmax_host(&[7.0]), 0);
648    }
649
650    // Gap (d): the bootstrap `ask` (empty `fitness`) returns positions verbatim.
651    #[test]
652    #[allow(clippy::float_cmp)] // byte-identical: `ask` clones `state.positions`
653    fn first_ask_returns_positions_unchanged() {
654        let device = Default::default();
655        let strategy = FireflyAlgorithm::<TestBackend>::new();
656        let params = FireflyConfig::default_for(8, 4);
657        let mut rng = StdRng::seed_from_u64(1);
658        let state = strategy.init(&params, &mut rng, &device);
659        let (genome, next) = strategy.ask(&params, &state, &mut rng, &device);
660        let before = state
661            .positions()
662            .clone()
663            .into_data()
664            .into_vec::<f32>()
665            .expect("positions readable as f32");
666        let after = genome
667            .into_data()
668            .into_vec::<f32>()
669            .expect("genome readable as f32");
670        assert_eq!(before, after);
671        assert!(next.fitness().is_empty());
672    }
673
674    // Gap (e): the best-so-far accessor is `None` until a `tell` records one.
675    #[test]
676    fn best_is_none_before_first_tell() {
677        let device = Default::default();
678        let strategy = FireflyAlgorithm::<TestBackend>::new();
679        let params = FireflyConfig::default_for(8, 4);
680        let mut rng = StdRng::seed_from_u64(2);
681        let state = strategy.init(&params, &mut rng, &device);
682        assert!(strategy.best(&state).is_none());
683    }
684
685    // Gap (f): every proposed position is clamped into `bounds` after `ask`,
686    // across 32 seeds.
687    #[test]
688    fn proposed_positions_within_bounds() {
689        let device = Default::default();
690        let strategy = FireflyAlgorithm::<TestBackend>::new();
691        let params = FireflyConfig::default_for(10, 4);
692        let (lo, hi): (f32, f32) = params.bounds.into();
693        // A steady-state swarm with a non-empty fitness cache so `ask` runs the
694        // attraction update rather than the bootstrap early return.
695        let mut rng = StdRng::seed_from_u64(0);
696        let base = strategy.init(&params, &mut rng, &device);
697        #[allow(clippy::cast_precision_loss)]
698        let fitness: Vec<f32> = (0..params.pop_size).map(|i| -(i as f32)).collect();
699        // Firefly's `ask` reads positions + fitness only, never `best_genome`.
700        let state = FireflyState::try_new(base.positions().clone(), fitness, None, 0.0, 1)
701            .expect("valid steady state");
702        for seed in 0..32 {
703            let mut rng = StdRng::seed_from_u64(seed);
704            let (pos, _next) = strategy.ask(&params, &state, &mut rng, &device);
705            let vals = pos
706                .into_data()
707                .into_vec::<f32>()
708                .expect("positions readable as f32");
709            for &v in &vals {
710                assert!(
711                    v >= lo && v <= hi,
712                    "position {v} out of bounds [{lo}, {hi}] (seed {seed})"
713                );
714            }
715        }
716    }
717
718    // Gap: a partly-`NaN` objective is neutralized by the harness sanitize
719    // chokepoint (ADR 0034).
720    #[test]
721    fn nan_fitness_survives_harness() {
722        let device = Default::default();
723        let strategy = FireflyAlgorithm::<TestBackend>::new();
724        let params = FireflyConfig::default_for(8, 3);
725        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
726            strategy,
727            params,
728            PartialNanFitness,
729            4,
730            device,
731            4,
732        )
733        .expect("valid params");
734        harness.reset();
735        while !harness.step(()).done {}
736        let m = harness.latest_metrics().unwrap();
737        assert!(
738            m.best_fitness_ever().is_finite(),
739            "best_fitness_ever not finite: {}",
740            m.best_fitness_ever()
741        );
742        assert!(m.broken_count() > 0, "expected a broken (NaN) member");
743    }
744
745    // Gap (b') cont. / issue #233: a full `genome_dim = 1` run drives the harness
746    // to completion without panicking and records a finite best.
747    #[test]
748    fn boundary_genome_dim_one_runs() {
749        let device = Default::default();
750        let strategy = FireflyAlgorithm::<TestBackend>::new();
751        let params = FireflyConfig::default_for(8, 1);
752        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
753        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
754            strategy, params, fitness_fn, 6, device, 6,
755        )
756        .expect("valid params");
757        harness.reset();
758        while !harness.step(()).done {}
759        assert!(
760            harness
761                .latest_metrics()
762                .unwrap()
763                .best_fitness_ever()
764                .is_finite(),
765            "non-finite best for genome_dim = 1"
766        );
767    }
768}