use std::marker::PhantomData;
use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
use rand::Rng;
use rand::RngExt;
use rand::SeedableRng;
use rlevo_core::bounds::Bounds;
use rlevo_core::config::{self, ConfigError, Validate};
use super::len_matches_pop;
use crate::ops::selection::argmax_host;
use crate::rng::{SeedPurpose, seed_stream};
use crate::strategy::{Strategy, StrategyMetrics};
pub const FIREFLY_PURE_TENSOR_CAP: usize = 128;
#[derive(Debug, Clone)]
pub struct FireflyConfig {
pub pop_size: usize,
pub genome_dim: usize,
pub bounds: Bounds,
pub beta0: f32,
pub gamma: f32,
pub alpha: f32,
}
impl FireflyConfig {
#[must_use]
pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
let (lo, hi): (f32, f32) = (-5.12, 5.12);
let length: f32 = hi - lo;
let gamma: f32 = 1.0 / (length * length);
Self {
pop_size,
genome_dim,
bounds: Bounds::new(lo, hi),
beta0: 1.0,
gamma,
alpha: 0.2,
}
}
}
impl Validate for FireflyConfig {
fn validate(&self) -> Result<(), ConfigError> {
const C: &str = "FireflyConfig";
config::at_least(C, "pop_size", self.pop_size, 1)?;
#[cfg(not(feature = "custom-kernels"))]
if self.pop_size > FIREFLY_PURE_TENSOR_CAP {
return Err(ConfigError {
config: C,
field: "pop_size",
kind: rlevo_core::config::ConstraintKind::Custom(
"pop_size exceeds the pure-tensor cap (128); enable `custom-kernels`",
),
});
}
config::nonzero(C, "genome_dim", self.genome_dim)?;
config::in_range(C, "beta0", 0.0, f64::INFINITY, f64::from(self.beta0))?;
config::positive(C, "gamma", f64::from(self.gamma))?;
config::in_range(C, "alpha", 0.0, f64::INFINITY, f64::from(self.alpha))?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct FireflyState<B: Backend> {
positions: Tensor<B, 2>,
fitness: Vec<f32>,
best_genome: Option<Tensor<B, 2>>,
best_fitness: f32,
generation: usize,
}
impl<B: Backend> FireflyState<B> {
pub fn try_new(
positions: Tensor<B, 2>,
fitness: Vec<f32>,
best_genome: Option<Tensor<B, 2>>,
best_fitness: f32,
generation: usize,
) -> Result<Self, ConfigError> {
let pop = positions.dims()[0];
config::nonzero("FireflyState", "pop_size", pop)?;
len_matches_pop("FireflyState", "fitness", pop, fitness.len())?;
Ok(Self {
positions,
fitness,
best_genome,
best_fitness,
generation,
})
}
#[must_use]
pub fn positions(&self) -> &Tensor<B, 2> {
&self.positions
}
#[must_use]
pub fn fitness(&self) -> &[f32] {
&self.fitness
}
#[must_use]
pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
self.best_genome.as_ref()
}
#[must_use]
pub fn best_fitness(&self) -> f32 {
self.best_fitness
}
#[must_use]
pub fn generation(&self) -> usize {
self.generation
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct FireflyAlgorithm<B: Backend> {
_backend: PhantomData<fn() -> B>,
}
impl<B: Backend> FireflyAlgorithm<B> {
#[must_use]
pub fn new() -> Self {
Self {
_backend: PhantomData,
}
}
fn pure_tensor_attract(
positions: &Tensor<B, 2>,
fitness: &[f32],
beta0: f32,
gamma: f32,
alpha: f32,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
noise_seed: u64,
) -> Tensor<B, 2> {
let pop = fitness.len();
let shape = positions.dims();
let d = shape[1];
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];
for i in 0..pop {
for j in 0..pop {
if fitness[j] > fitness[i] {
bright[i * pop + j] = 1;
}
}
}
let bright_mask =
Tensor::<B, 2, Int>::from_data(TensorData::new(bright, [pop, pop]), device)
.equal_elem(1);
let zero = Tensor::<B, 2>::zeros([pop, pop], device);
let beta_m = beta.mask_where(bright_mask.bool_not(), zero);
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);
let mut noise_rows = Vec::with_capacity(pop * d);
for _ in 0..pop * d {
noise_rows.push(noise_rng.random::<f32>() - 0.5);
}
let noise = Tensor::<B, 2>::from_data(TensorData::new(noise_rows, [pop, d]), device);
attr_sum + noise.mul_scalar(alpha)
}
}
impl<B: Backend> Strategy<B> for FireflyAlgorithm<B>
where
B::Device: Clone,
{
type Params = FireflyConfig;
type State = FireflyState<B>;
type Genome = Tensor<B, 2>;
fn init(
&self,
params: &FireflyConfig,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> FireflyState<B> {
debug_assert!(
params.validate().is_ok(),
"invalid FireflyConfig reached init: {params:?}"
);
#[cfg(feature = "custom-kernels")]
debug_assert!(
params.pop_size <= FIREFLY_PURE_TENSOR_CAP,
"Firefly pop_size > {FIREFLY_PURE_TENSOR_CAP} requires the fused pairwise-attract kernel; \
the placeholder kernel module still runs the pure-tensor path"
);
let (lo, hi): (f32, f32) = params.bounds.into();
let pop = params.pop_size;
let genome_dim = params.genome_dim;
let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
let mut position_rows = Vec::with_capacity(pop * genome_dim);
for _ in 0..pop * genome_dim {
position_rows.push(lo + (hi - lo) * stream.random::<f32>());
}
let positions =
Tensor::<B, 2>::from_data(TensorData::new(position_rows, [pop, genome_dim]), device);
FireflyState {
positions,
fitness: Vec::new(),
best_genome: None,
best_fitness: f32::NEG_INFINITY,
generation: 0,
}
}
fn ask(
&self,
params: &FireflyConfig,
state: &FireflyState<B>,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> (Tensor<B, 2>, FireflyState<B>) {
if state.fitness.is_empty() {
return (state.positions.clone(), state.clone());
}
let seed = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Mutation,
)
.next_u64();
let delta = Self::pure_tensor_attract(
&state.positions,
&state.fitness,
params.beta0,
params.gamma,
params.alpha,
device,
seed,
);
let (lo, hi): (f32, f32) = params.bounds.into();
let new_positions = (state.positions.clone() + delta).clamp(lo, hi);
let mut next = state.clone();
next.positions.clone_from(&new_positions);
(new_positions, next)
}
fn tell(
&self,
_params: &FireflyConfig,
population: Tensor<B, 2>,
fitness: Tensor<B, 1>,
mut state: FireflyState<B>,
_rng: &mut dyn Rng,
) -> (FireflyState<B>, StrategyMetrics) {
let fitness_host = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32");
let device = population.device();
state.fitness.clone_from(&fitness_host);
state.positions.clone_from(&population);
let best_idx = argmax_host(&fitness_host);
if fitness_host[best_idx] > state.best_fitness {
state.best_fitness = fitness_host[best_idx];
#[allow(clippy::cast_possible_wrap)]
let idx = Tensor::<B, 1, Int>::from_data(
TensorData::new(vec![best_idx as i64], [1]),
&device,
);
state.best_genome = Some(population.select(0, idx));
}
state.generation += 1;
let m =
StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
state.best_fitness = m.best_fitness_ever();
(state, m)
}
fn best(&self, state: &FireflyState<B>) -> Option<(Tensor<B, 2>, f32)> {
state
.best_genome
.as_ref()
.map(|g| (g.clone(), state.best_fitness))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fitness::FromFitnessEvaluable;
use crate::strategy::EvolutionaryHarness;
use burn::backend::Flex;
use rand::rngs::StdRng;
use rlevo_core::fitness::FitnessEvaluable;
type TestBackend = Flex;
#[test]
fn try_new_checks_fitness_length() {
let device = Default::default();
let pos = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
assert!(FireflyState::try_new(pos.clone(), vec![1.0; 3], None, 1.0, 1).is_ok());
assert!(FireflyState::try_new(pos.clone(), vec![], None, f32::MIN, 0).is_ok());
assert!(FireflyState::try_new(pos, vec![1.0; 2], None, 1.0, 1).is_err());
let empty = Tensor::<TestBackend, 2>::zeros([0, 2], &device);
assert!(FireflyState::try_new(empty, vec![], None, 1.0, 0).is_err());
}
#[test]
fn default_config_validates() {
assert!(FireflyConfig::default_for(32, 10).validate().is_ok());
}
#[test]
fn default_gamma_matches_inverse_length_squared() {
let cfg = FireflyConfig::default_for(32, 10);
let (lo, hi): (f32, f32) = cfg.bounds.into();
let length: f32 = hi - lo;
let expected: f32 = 1.0 / (length * length);
approx::assert_relative_eq!(cfg.gamma, expected);
}
#[test]
fn rejects_zero_gamma() {
let mut cfg = FireflyConfig::default_for(32, 10);
cfg.gamma = 0.0;
assert_eq!(cfg.validate().unwrap_err().field, "gamma");
}
struct Sphere;
struct SphereFit;
impl FitnessEvaluable for SphereFit {
type Individual = Vec<f64>;
type Landscape = Sphere;
fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
x.iter().map(|v| v * v).sum()
}
}
#[test]
fn firefly_converges_on_sphere_d10() {
let device = Default::default();
let strategy = FireflyAlgorithm::<TestBackend>::new();
let params = FireflyConfig::default_for(24, 10);
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy, params, fitness_fn, 29, device, 500,
)
.expect("valid params");
harness.reset();
while !harness.step(()).done {}
let best = harness.latest_metrics().unwrap().best_fitness_ever();
assert!(best < 1.0, "Firefly D10 best={best}");
}
struct PartialNanFitness;
impl<B: Backend> crate::fitness::BatchFitnessFn<B, Tensor<B, 2>> for PartialNanFitness {
fn evaluate_batch(
&mut self,
population: &Tensor<B, 2>,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 1> {
let n = population.dims()[0];
#[allow(clippy::cast_precision_loss)]
let mut vals: Vec<f32> = (0..n).map(|i| -(i as f32)).collect();
vals[0] = f32::NAN;
Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
}
fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
rlevo_core::objective::ObjectiveSense::Maximize
}
}
#[test]
fn rejects_invalid_configs() {
let mut cfg = FireflyConfig::default_for(0, 10);
assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
cfg = FireflyConfig::default_for(32, 10);
cfg.gamma = -1.0;
assert_eq!(cfg.validate().unwrap_err().field, "gamma");
cfg = FireflyConfig::default_for(32, 10);
cfg.beta0 = -1.0;
assert_eq!(cfg.validate().unwrap_err().field, "beta0");
cfg = FireflyConfig::default_for(32, 10);
cfg.alpha = -1.0;
assert_eq!(cfg.validate().unwrap_err().field, "alpha");
}
#[test]
#[should_panic(expected = "invalid range")]
fn inverted_bounds_are_unrepresentable() {
let _ = FireflyConfig {
bounds: Bounds::new(5.0, -5.0),
..FireflyConfig::default_for(32, 10)
};
}
#[test]
#[allow(clippy::should_panic_without_expect)]
#[should_panic]
fn pop_size_over_cap_panics_in_init() {
let device = Default::default();
let strategy = FireflyAlgorithm::<TestBackend>::new();
let params = FireflyConfig::default_for(FIREFLY_PURE_TENSOR_CAP + 1, 4);
let mut rng = StdRng::seed_from_u64(0);
let _ = strategy.init(¶ms, &mut rng, &device);
}
#[test]
fn pure_tensor_attract_pulls_toward_brighter() {
let device = Default::default();
let positions = Tensor::<TestBackend, 2>::from_data(
TensorData::new(vec![0.0_f32, 0.0, 1.0, 0.0], [2, 2]),
&device,
);
let fitness = [0.0_f32, 1.0];
let delta = FireflyAlgorithm::<TestBackend>::pure_tensor_attract(
&positions, &fitness, 1.0, 0.0, 0.0, &device, 0,
);
assert_eq!(delta.dims(), [2, 2], "displacement is (pop, d)");
let d = delta
.into_data()
.into_vec::<f32>()
.expect("delta readable as f32");
approx::assert_relative_eq!(d[0], 1.0, epsilon = 1e-6);
approx::assert_relative_eq!(d[1], 0.0, epsilon = 1e-6);
approx::assert_relative_eq!(d[2], 0.0, epsilon = 1e-6);
approx::assert_relative_eq!(d[3], 0.0, epsilon = 1e-6);
}
#[test]
fn pure_tensor_attract_d1_pulls_toward_brighter() {
let device = Default::default();
let positions = Tensor::<TestBackend, 2>::from_data(
TensorData::new(vec![0.0_f32, 1.0], [2, 1]),
&device,
);
let fitness = [0.0_f32, 1.0];
let delta = FireflyAlgorithm::<TestBackend>::pure_tensor_attract(
&positions, &fitness, 1.0, 0.0, 0.0, &device, 0,
);
assert_eq!(delta.dims(), [2, 1], "displacement is (pop, d)");
let d = delta
.into_data()
.into_vec::<f32>()
.expect("delta readable as f32");
approx::assert_relative_eq!(d[0], 1.0, epsilon = 1e-6);
approx::assert_relative_eq!(d[1], 0.0, epsilon = 1e-6);
}
#[test]
#[should_panic(expected = "must be non-empty")]
fn argmax_host_empty_panics() {
let _ = argmax_host(&[]);
}
#[test]
fn argmax_host_all_nan_and_single() {
assert_eq!(argmax_host(&[f32::NAN, f32::NAN, f32::NAN]), 0);
assert_eq!(argmax_host(&[7.0]), 0);
}
#[test]
#[allow(clippy::float_cmp)] fn first_ask_returns_positions_unchanged() {
let device = Default::default();
let strategy = FireflyAlgorithm::<TestBackend>::new();
let params = FireflyConfig::default_for(8, 4);
let mut rng = StdRng::seed_from_u64(1);
let state = strategy.init(¶ms, &mut rng, &device);
let (genome, next) = strategy.ask(¶ms, &state, &mut rng, &device);
let before = state
.positions()
.clone()
.into_data()
.into_vec::<f32>()
.expect("positions readable as f32");
let after = genome
.into_data()
.into_vec::<f32>()
.expect("genome readable as f32");
assert_eq!(before, after);
assert!(next.fitness().is_empty());
}
#[test]
fn best_is_none_before_first_tell() {
let device = Default::default();
let strategy = FireflyAlgorithm::<TestBackend>::new();
let params = FireflyConfig::default_for(8, 4);
let mut rng = StdRng::seed_from_u64(2);
let state = strategy.init(¶ms, &mut rng, &device);
assert!(strategy.best(&state).is_none());
}
#[test]
fn proposed_positions_within_bounds() {
let device = Default::default();
let strategy = FireflyAlgorithm::<TestBackend>::new();
let params = FireflyConfig::default_for(10, 4);
let (lo, hi): (f32, f32) = params.bounds.into();
let mut rng = StdRng::seed_from_u64(0);
let base = strategy.init(¶ms, &mut rng, &device);
#[allow(clippy::cast_precision_loss)]
let fitness: Vec<f32> = (0..params.pop_size).map(|i| -(i as f32)).collect();
let state = FireflyState::try_new(base.positions().clone(), fitness, None, 0.0, 1)
.expect("valid steady state");
for seed in 0..32 {
let mut rng = StdRng::seed_from_u64(seed);
let (pos, _next) = strategy.ask(¶ms, &state, &mut rng, &device);
let vals = pos
.into_data()
.into_vec::<f32>()
.expect("positions readable as f32");
for &v in &vals {
assert!(
v >= lo && v <= hi,
"position {v} out of bounds [{lo}, {hi}] (seed {seed})"
);
}
}
}
#[test]
fn nan_fitness_survives_harness() {
let device = Default::default();
let strategy = FireflyAlgorithm::<TestBackend>::new();
let params = FireflyConfig::default_for(8, 3);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy,
params,
PartialNanFitness,
4,
device,
4,
)
.expect("valid params");
harness.reset();
while !harness.step(()).done {}
let m = harness.latest_metrics().unwrap();
assert!(
m.best_fitness_ever().is_finite(),
"best_fitness_ever not finite: {}",
m.best_fitness_ever()
);
assert!(m.broken_count() > 0, "expected a broken (NaN) member");
}
#[test]
fn boundary_genome_dim_one_runs() {
let device = Default::default();
let strategy = FireflyAlgorithm::<TestBackend>::new();
let params = FireflyConfig::default_for(8, 1);
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy, params, fitness_fn, 6, device, 6,
)
.expect("valid params");
harness.reset();
while !harness.step(()).done {}
assert!(
harness
.latest_metrics()
.unwrap()
.best_fitness_ever()
.is_finite(),
"non-finite best for genome_dim = 1"
);
}
}