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 crate::rng::{SeedPurpose, seed_stream};
32use crate::strategy::{Strategy, StrategyMetrics};
33
34/// Hard cap for the pure-tensor `O(N²D)` Firefly path. Exceeding this
35/// without the fused `CubeCL` kernel would allocate a cubic tensor on
36/// device; the kernel path removes the cap.
37pub const FIREFLY_PURE_TENSOR_CAP: usize = 128;
38
39/// Static configuration for [`FireflyAlgorithm`].
40#[derive(Debug, Clone)]
41pub struct FireflyConfig {
42    /// Number of fireflies.
43    pub pop_size: usize,
44    /// Genome dimensionality.
45    pub genome_dim: usize,
46    /// Search-space bounds.
47    pub bounds: (f32, f32),
48    /// Base attractiveness `β₀`. Canonical 1.0.
49    pub beta0: f32,
50    /// Light-absorption coefficient `γ`. Canonical 1.0; controls the
51    /// range over which fireflies can see each other.
52    pub gamma: f32,
53    /// Noise scale for the random walk term. Canonical 0.2.
54    pub alpha: f32,
55}
56
57impl FireflyConfig {
58    /// Default configuration. `γ` is scaled by the search-space extent
59    /// so the exponential decay lands in a useful regime — Yang's
60    /// canonical `γ = 1` assumes `[0, 1]` normalization; for the usual
61    /// `[−5.12, 5.12]` domain, `γ ≈ 1 / L²` keeps attractiveness
62    /// non-vanishing across pairs.
63    #[must_use]
64    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
65        Self {
66            pop_size,
67            genome_dim,
68            bounds: (-5.12, 5.12),
69            beta0: 1.0,
70            gamma: 0.01,
71            alpha: 0.2,
72        }
73    }
74}
75
76/// Generation state for [`FireflyAlgorithm`].
77#[derive(Debug, Clone)]
78pub struct FireflyState<B: Backend> {
79    /// Current positions, shape `(pop_size, D)`.
80    pub positions: Tensor<B, 2>,
81    /// Host-side fitness cache.
82    pub fitness: Vec<f32>,
83    /// Best-so-far genome.
84    pub best_genome: Option<Tensor<B, 2>>,
85    /// Best-so-far fitness.
86    pub best_fitness: f32,
87    /// Generation counter.
88    pub generation: usize,
89}
90
91/// Firefly Algorithm strategy.
92///
93/// # Panics
94///
95/// [`Strategy::init`] enforces a `pop_size <= FIREFLY_PURE_TENSOR_CAP`
96/// (= 128) cap when the `custom-kernels` feature is **off**, since the
97/// pure-tensor path materializes an `(N, N, D)` pairwise tensor.
98/// With the feature on the same cap is enforced via `debug_assert!`,
99/// because the fused kernel
100/// [`super::kernels::pairwise_attract_cube`] is still designed-only and
101/// the strategy keeps using the pure-tensor path in the meantime.
102///
103/// # Example
104///
105/// ```no_run
106/// use burn::backend::Flex;
107/// use rlevo_evolution::algorithms::metaheuristic::firefly::{FireflyAlgorithm, FireflyConfig};
108///
109/// let strategy = FireflyAlgorithm::<Flex>::new();
110/// let params = FireflyConfig::default_for(32, 10);
111/// let _ = (strategy, params);
112/// ```
113#[derive(Debug, Clone, Copy, Default)]
114pub struct FireflyAlgorithm<B: Backend> {
115    _backend: PhantomData<fn() -> B>,
116}
117
118impl<B: Backend> FireflyAlgorithm<B> {
119    /// Builds a new (stateless) strategy object.
120    #[must_use]
121    pub fn new() -> Self {
122        Self {
123            _backend: PhantomData,
124        }
125    }
126
127    /// Pure-tensor `O(N²D)` attraction kernel — always available, even
128    /// without the `custom-kernels` feature. The fused `CubeCL` kernel
129    /// designed in [`super::kernels::pairwise_attract_cube`] slots in at
130    /// this call site once it lands.
131    fn pure_tensor_attract(
132        positions: &Tensor<B, 2>,
133        fitness: &[f32],
134        beta0: f32,
135        gamma: f32,
136        alpha: f32,
137        device: &<B as burn::tensor::backend::BackendTypes>::Device,
138        noise_seed: u64,
139    ) -> Tensor<B, 2> {
140        let pop = fitness.len();
141        let shape = positions.dims();
142        let d = shape[1];
143
144        // Pairwise squared distances via (x·x^T + ||x||² - 2x·x^T).
145        // Cheaper memory than the (N, N, D) difference tensor, but we
146        // still need the (N, N, D) tensor for the displacement `x_j -
147        // x_i`. Cap enforced at module level.
148        let xi = positions.clone().unsqueeze_dim::<3>(1); // (N, 1, D)
149        let xj = positions.clone().unsqueeze_dim::<3>(0); // (1, N, D)
150        let diff = xj.expand([pop, pop, d]) - xi.expand([pop, pop, d]); // (N, N, D)
151        let r2 = diff.clone().powi_scalar(2).sum_dim(2).squeeze::<2>(); // (N, N)
152        let beta = r2.mul_scalar(-gamma).exp().mul_scalar(beta0); // (N, N)
153
154        // Brightness mask: bright[i, j] = 1 iff fitness[j] < fitness[i].
155        let mut bright = vec![0i64; pop * pop];
156        for i in 0..pop {
157            for j in 0..pop {
158                if fitness[j] < fitness[i] {
159                    bright[i * pop + j] = 1;
160                }
161            }
162        }
163        let bright_mask =
164            Tensor::<B, 2, Int>::from_data(TensorData::new(bright, [pop, pop]), device)
165                .equal_elem(1);
166        // Zero-out non-bright pairs in β then multiply diff.
167        let zero = Tensor::<B, 2>::zeros([pop, pop], device);
168        let beta_m = beta.mask_where(bright_mask.bool_not(), zero);
169        let weight = beta_m.unsqueeze_dim::<3>(2).expand([pop, pop, d]); // (N, N, D)
170        let weighted = diff.mul(weight); // (N, N, D)
171        let attr_sum = weighted.sum_dim(1).squeeze::<2>(); // (N, D)
172
173        // Noise: α · (U[0,1] - 0.5). Host-sample from the supplied seed so
174        // the draw is reproducible across thread schedules rather than
175        // racing the process-wide Flex RNG.
176        let mut noise_rng = rand::rngs::StdRng::seed_from_u64(noise_seed);
177        let mut noise_rows = Vec::with_capacity(pop * d);
178        for _ in 0..pop * d {
179            noise_rows.push(noise_rng.random::<f32>() - 0.5);
180        }
181        let noise = Tensor::<B, 2>::from_data(TensorData::new(noise_rows, [pop, d]), device);
182        attr_sum + noise.mul_scalar(alpha)
183    }
184}
185
186impl<B: Backend> Strategy<B> for FireflyAlgorithm<B>
187where
188    B::Device: Clone,
189{
190    type Params = FireflyConfig;
191    type State = FireflyState<B>;
192    type Genome = Tensor<B, 2>;
193
194    /// Build the initial swarm by host-sampling `pop_size` positions
195    /// uniformly in `[bounds.lo, bounds.hi]`.
196    ///
197    /// Positions are drawn from a deterministic [`seed_stream`] so
198    /// initialisation is bit-stable regardless of core count or test
199    /// ordering; the process-wide Flex RNG is never touched.
200    ///
201    /// # Panics
202    ///
203    /// Panics (in release builds without `custom-kernels`) if
204    /// `params.pop_size > FIREFLY_PURE_TENSOR_CAP`. See the struct-level
205    /// docs for the cap rationale.
206    fn init(
207        &self,
208        params: &FireflyConfig,
209        rng: &mut dyn Rng,
210        device: &<B as burn::tensor::backend::BackendTypes>::Device,
211    ) -> FireflyState<B> {
212        #[cfg(not(feature = "custom-kernels"))]
213        assert!(
214            params.pop_size <= FIREFLY_PURE_TENSOR_CAP,
215            "Firefly without `custom-kernels` feature caps pop_size at {FIREFLY_PURE_TENSOR_CAP} \
216             to keep the O(N²D) pairwise tensor bounded; enable `custom-kernels` for larger swarms",
217        );
218        // Even with the kernel feature active, the fused pairwise-attract
219        // kernel is currently a design placeholder and the pure-tensor
220        // path is still in use. A debug assert surfaces the limitation in
221        // tests without blocking downstream users who have wired in their
222        // own kernel.
223        #[cfg(feature = "custom-kernels")]
224        debug_assert!(
225            params.pop_size <= FIREFLY_PURE_TENSOR_CAP,
226            "Firefly pop_size > {FIREFLY_PURE_TENSOR_CAP} requires the fused pairwise-attract kernel; \
227             the placeholder kernel module still runs the pure-tensor path"
228        );
229        let (lo, hi) = params.bounds;
230        // Host-sample the initial swarm from a deterministic `seed_stream`
231        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
232        // whose draws interleave with sibling tests under the parallel runner
233        // and are not reproducible across thread schedules.
234        let pop = params.pop_size;
235        let genome_dim = params.genome_dim;
236        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
237        let mut position_rows = Vec::with_capacity(pop * genome_dim);
238        for _ in 0..pop * genome_dim {
239            position_rows.push(lo + (hi - lo) * stream.random::<f32>());
240        }
241        let positions =
242            Tensor::<B, 2>::from_data(TensorData::new(position_rows, [pop, genome_dim]), device);
243        FireflyState {
244            positions,
245            fitness: Vec::new(),
246            best_genome: None,
247            best_fitness: f32::INFINITY,
248            generation: 0,
249        }
250    }
251
252    /// Propose the next swarm positions.
253    ///
254    /// On the first call (`state.fitness` is empty) returns the initial
255    /// positions unchanged so the caller can evaluate generation zero.
256    /// On subsequent calls, computes the pairwise attractiveness update
257    /// via `pure_tensor_attract` and clips positions to
258    /// `params.bounds`. The noise seed is derived from the host RNG
259    /// through [`seed_stream`], keeping draws reproducible.
260    fn ask(
261        &self,
262        params: &FireflyConfig,
263        state: &FireflyState<B>,
264        rng: &mut dyn Rng,
265        device: &<B as burn::tensor::backend::BackendTypes>::Device,
266    ) -> (Tensor<B, 2>, FireflyState<B>) {
267        if state.fitness.is_empty() {
268            return (state.positions.clone(), state.clone());
269        }
270
271        let seed = seed_stream(
272            rng.next_u64(),
273            state.generation as u64,
274            SeedPurpose::Mutation,
275        )
276        .next_u64();
277        let delta = Self::pure_tensor_attract(
278            &state.positions,
279            &state.fitness,
280            params.beta0,
281            params.gamma,
282            params.alpha,
283            device,
284            seed,
285        );
286        let (lo, hi) = params.bounds;
287        let new_positions = (state.positions.clone() + delta).clamp(lo, hi);
288
289        let mut next = state.clone();
290        next.positions.clone_from(&new_positions);
291        (new_positions, next)
292    }
293
294    /// Ingest fitness values, update the swarm, and advance the generation counter.
295    ///
296    /// Pulls `fitness` to host, updates `state.positions` and
297    /// `state.fitness`, then refreshes the best-so-far genome if the
298    /// current generation contains a new minimum.  Returns the updated
299    /// state and a [`StrategyMetrics`] snapshot for the completed
300    /// generation.
301    fn tell(
302        &self,
303        _params: &FireflyConfig,
304        population: Tensor<B, 2>,
305        fitness: Tensor<B, 1>,
306        mut state: FireflyState<B>,
307        _rng: &mut dyn Rng,
308    ) -> (FireflyState<B>, StrategyMetrics) {
309        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
310        let device = population.device();
311        state.fitness.clone_from(&fitness_host);
312        state.positions.clone_from(&population);
313
314        let best_idx = argmin(&fitness_host);
315        if fitness_host[best_idx] < state.best_fitness {
316            state.best_fitness = fitness_host[best_idx];
317            #[allow(clippy::cast_possible_wrap)]
318            let idx = Tensor::<B, 1, Int>::from_data(
319                TensorData::new(vec![best_idx as i64], [1]),
320                &device,
321            );
322            state.best_genome = Some(population.select(0, idx));
323        }
324        state.generation += 1;
325        let m =
326            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
327        state.best_fitness = m.best_fitness_ever;
328        (state, m)
329    }
330
331    /// Returns the best-so-far `(genome, fitness)` pair, or `None` before
332    /// the first [`tell`](Strategy::tell) call.
333    fn best(&self, state: &FireflyState<B>) -> Option<(Tensor<B, 2>, f32)> {
334        state
335            .best_genome
336            .as_ref()
337            .map(|g| (g.clone(), state.best_fitness))
338    }
339}
340
341fn argmin(xs: &[f32]) -> usize {
342    let mut best_idx = 0usize;
343    let mut best = f32::INFINITY;
344    for (i, &v) in xs.iter().enumerate() {
345        if v < best {
346            best = v;
347            best_idx = i;
348        }
349    }
350    best_idx
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::fitness::FromFitnessEvaluable;
357    use crate::strategy::EvolutionaryHarness;
358    use burn::backend::Flex;
359    use rlevo_core::fitness::FitnessEvaluable;
360
361    type TestBackend = Flex;
362
363    struct Sphere;
364    struct SphereFit;
365    impl FitnessEvaluable for SphereFit {
366        type Individual = Vec<f64>;
367        type Landscape = Sphere;
368        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
369            x.iter().map(|v| v * v).sum()
370        }
371    }
372
373    #[test]
374    fn firefly_converges_on_sphere_d10() {
375        // Firefly's attraction sum is O(N²D); we use 24 fireflies to
376        // keep the test fast while still exercising the pairwise
377        // kernel path.
378        let device = Default::default();
379        let strategy = FireflyAlgorithm::<TestBackend>::new();
380        let params = FireflyConfig::default_for(24, 10);
381        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
382        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
383            strategy, params, fitness_fn, 29, device, 500,
384        );
385        harness.reset();
386        while !harness.step(()).done {}
387        let best = harness.latest_metrics().unwrap().best_fitness_ever;
388        assert!(best < 1.0, "Firefly D10 best={best}");
389    }
390}