1use 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
39pub const FIREFLY_PURE_TENSOR_CAP: usize = 128;
43
44#[derive(Debug, Clone)]
46pub struct FireflyConfig {
47 pub pop_size: usize,
49 pub genome_dim: usize,
51 pub bounds: Bounds,
53 pub beta0: f32,
55 pub gamma: f32,
58 pub alpha: f32,
60}
61
62impl FireflyConfig {
63 #[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 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 #[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#[derive(Debug, Clone)]
111pub struct FireflyState<B: Backend> {
112 positions: Tensor<B, 2>,
114 fitness: Vec<f32>,
116 best_genome: Option<Tensor<B, 2>>,
118 best_fitness: f32,
120 generation: usize,
122}
123
124impl<B: Backend> FireflyState<B> {
125 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 #[must_use]
152 pub fn positions(&self) -> &Tensor<B, 2> {
153 &self.positions
154 }
155
156 #[must_use]
158 pub fn fitness(&self) -> &[f32] {
159 &self.fitness
160 }
161
162 #[must_use]
164 pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
165 self.best_genome.as_ref()
166 }
167
168 #[must_use]
170 pub fn best_fitness(&self) -> f32 {
171 self.best_fitness
172 }
173
174 #[must_use]
176 pub fn generation(&self) -> usize {
177 self.generation
178 }
179}
180
181#[derive(Debug, Clone, Copy, Default)]
202pub struct FireflyAlgorithm<B: Backend> {
203 _backend: PhantomData<fn() -> B>,
204}
205
206impl<B: Backend> FireflyAlgorithm<B> {
207 #[must_use]
209 pub fn new() -> Self {
210 Self {
211 _backend: PhantomData,
212 }
213 }
214
215 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 let xi = positions.clone().unsqueeze_dim::<3>(1); let xj = positions.clone().unsqueeze_dim::<3>(0); let diff = xj.expand([pop, pop, d]) - xi.expand([pop, pop, d]); let r2 = diff.clone().powi_scalar(2).sum_dim(2).squeeze_dim::<2>(2); let beta = r2.mul_scalar(-gamma).exp().mul_scalar(beta0); 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 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]); let weighted = diff.mul(weight); let attr_sum = weighted.sum_dim(1).squeeze_dim::<2>(1); 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 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 #[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 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 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 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 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 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 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 #[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 #[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 #[test]
557 #[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(¶ms, &mut rng, &device);
567 }
568
569 #[test]
575 fn pure_tensor_attract_pulls_toward_brighter() {
576 let device = Default::default();
577 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 let fitness = [0.0_f32, 1.0];
584 let delta = FireflyAlgorithm::<TestBackend>::pure_tensor_attract(
585 &positions, &fitness, 1.0, 0.0, 0.0, &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 approx::assert_relative_eq!(d[0], 1.0, epsilon = 1e-6);
597 approx::assert_relative_eq!(d[1], 0.0, epsilon = 1e-6);
598 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 #[test]
609 fn pure_tensor_attract_d1_pulls_toward_brighter() {
610 let device = Default::default();
611 let positions = Tensor::<TestBackend, 2>::from_data(
613 TensorData::new(vec![0.0_f32, 1.0], [2, 1]),
614 &device,
615 );
616 let fitness = [0.0_f32, 1.0];
618 let delta = FireflyAlgorithm::<TestBackend>::pure_tensor_attract(
619 &positions, &fitness, 1.0, 0.0, 0.0, &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 approx::assert_relative_eq!(d[0], 1.0, epsilon = 1e-6);
631 approx::assert_relative_eq!(d[1], 0.0, epsilon = 1e-6);
633 }
634
635 #[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 #[test]
652 #[allow(clippy::float_cmp)] 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(¶ms, &mut rng, &device);
659 let (genome, next) = strategy.ask(¶ms, &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 #[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(¶ms, &mut rng, &device);
682 assert!(strategy.best(&state).is_none());
683 }
684
685 #[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 let mut rng = StdRng::seed_from_u64(0);
696 let base = strategy.init(¶ms, &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 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(¶ms, &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 #[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 #[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}