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}