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