use std::marker::PhantomData;
use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
use rand::Rng;
use rand::RngExt;
use rlevo_core::bounds::Bounds;
use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
use crate::ops::mutation::gaussian_mutation_per_row;
use crate::rng::{SeedPurpose, seed_stream};
use crate::strategy::{Strategy, StrategyMetrics};
const DEFAULT_SIGMA_MIN: f32 = 1e-8;
const DEFAULT_SIGMA_MAX: f32 = 1e6;
#[derive(Debug, Clone)]
pub struct EpConfig {
pub mu: usize,
pub genome_dim: usize,
pub bounds: Bounds,
pub initial_sigma: f32,
pub sigma_min: f32,
pub sigma_max: f32,
pub tau: f32,
pub tournament_q: usize,
}
impl EpConfig {
#[must_use]
pub fn default_for(mu: usize, genome_dim: usize) -> Self {
#[allow(clippy::cast_precision_loss)]
let d = genome_dim as f32;
let tau = 1.0 / (2.0 * d.sqrt()).sqrt();
Self {
mu,
genome_dim,
bounds: Bounds::new(-5.12, 5.12),
initial_sigma: 1.0,
sigma_min: DEFAULT_SIGMA_MIN,
sigma_max: DEFAULT_SIGMA_MAX,
tau,
tournament_q: 10,
}
}
}
impl Validate for EpConfig {
fn validate(&self) -> Result<(), ConfigError> {
const C: &str = "EpConfig";
config::at_least(C, "mu", self.mu, 1)?;
config::nonzero(C, "genome_dim", self.genome_dim)?;
config::positive(C, "initial_sigma", f64::from(self.initial_sigma))?;
config::positive(C, "sigma_min", f64::from(self.sigma_min))?;
config::ordered(
C,
"sigma_max",
f64::from(self.sigma_min),
f64::from(self.sigma_max),
)?;
config::positive(C, "tau", f64::from(self.tau))?;
config::at_least(C, "tournament_q", self.tournament_q, 1)?;
if self.tournament_q > 2 * self.mu {
return Err(ConfigError {
config: C,
field: "tournament_q",
kind: ConstraintKind::Custom("tournament_q must not exceed 2 * mu"),
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct EpState<B: Backend> {
pub parents: Tensor<B, 2>,
pub sigmas: Tensor<B, 1>,
pub parent_fitness: Vec<f32>,
pub best_genome: Option<Tensor<B, 2>>,
pub best_fitness: f32,
pub generation: usize,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct EvolutionaryProgramming<B: Backend> {
_backend: PhantomData<fn() -> B>,
}
impl<B: Backend> EvolutionaryProgramming<B> {
#[must_use]
pub fn new() -> Self {
Self {
_backend: PhantomData,
}
}
}
impl<B: Backend> Strategy<B> for EvolutionaryProgramming<B>
where
B::Device: Clone,
{
type Params = EpConfig;
type State = EpState<B>;
type Genome = Tensor<B, 2>;
fn init(
&self,
params: &EpConfig,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> EpState<B> {
debug_assert!(
params.validate().is_ok(),
"invalid EpConfig reached init: {params:?}"
);
let (lo, hi): (f32, f32) = params.bounds.into();
let mu = params.mu;
let genome_dim = params.genome_dim;
let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
let mut parent_rows = Vec::with_capacity(mu * genome_dim);
for _ in 0..mu * genome_dim {
parent_rows.push(lo + (hi - lo) * stream.random::<f32>());
}
let parents =
Tensor::<B, 2>::from_data(TensorData::new(parent_rows, [mu, genome_dim]), device);
let sigmas = Tensor::<B, 1>::from_data(
TensorData::new(vec![params.initial_sigma; params.mu], [params.mu]),
device,
);
EpState {
parents,
sigmas,
parent_fitness: Vec::new(),
best_genome: None,
best_fitness: f32::NEG_INFINITY,
generation: 0,
}
}
fn ask(
&self,
params: &EpConfig,
state: &EpState<B>,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> (Tensor<B, 2>, EpState<B>) {
if state.parent_fitness.is_empty() {
return (state.parents.clone(), state.clone());
}
let mu = params.mu;
let mut sigma_rng =
seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
let mut mutation_rng = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Mutation,
);
let mut noise_rows = Vec::with_capacity(mu);
for _ in 0..mu {
noise_rows.push(crate::sampling::standard_normal(&mut sigma_rng));
}
let noise = Tensor::<B, 1>::from_data(TensorData::new(noise_rows, [mu]), device);
let offspring_sigmas = (state.sigmas.clone() * noise.mul_scalar(params.tau).exp())
.clamp(params.sigma_min, params.sigma_max);
let offspring = gaussian_mutation_per_row(
state.parents.clone(),
offspring_sigmas.clone(),
&mut mutation_rng,
device,
);
let (lo, hi): (f32, f32) = params.bounds.into();
let offspring = offspring.clamp(lo, hi);
let mut state = state.clone();
state.sigmas = Tensor::cat(vec![state.sigmas.clone(), offspring_sigmas], 0);
(offspring, state)
}
fn tell(
&self,
params: &EpConfig,
offspring: Tensor<B, 2>,
fitness: Tensor<B, 1>,
mut state: EpState<B>,
rng: &mut dyn Rng,
) -> (EpState<B>, StrategyMetrics) {
let fitness_host = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32");
let device = offspring.device();
if state.parent_fitness.is_empty() {
state.parent_fitness.clone_from(&fitness_host);
state.generation += 1;
update_best(&mut state, &offspring, &fitness_host);
let m = StrategyMetrics::from_host_fitness(
state.generation,
&fitness_host,
state.best_fitness,
);
state.best_fitness = m.best_fitness_ever();
state.parents = offspring;
state.sigmas = Tensor::<B, 1>::from_data(
TensorData::new(vec![params.initial_sigma; params.mu], [params.mu]),
&device,
);
return (state, m);
}
let mu = params.mu;
let combined_pop = Tensor::cat(vec![state.parents.clone(), offspring.clone()], 0);
let combined_fit: Vec<f32> = state
.parent_fitness
.iter()
.chain(fitness_host.iter())
.copied()
.collect();
let combined_sigmas = state.sigmas.clone();
let mut selection_rng = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Selection,
);
let n = combined_fit.len();
let mut win_counts: Vec<u32> = vec![0; n];
for (i, &my_fit) in combined_fit.iter().enumerate() {
for _ in 0..params.tournament_q {
let opp = selection_rng.random_range(0..n);
if my_fit > combined_fit[opp] {
win_counts[i] += 1;
}
}
}
let mut indexed: Vec<usize> = (0..n).collect();
let sane: Vec<f32> = combined_fit
.iter()
.map(|&f| crate::fitness::sanitize_fitness(f))
.collect();
indexed.sort_by(|&a, &b| {
win_counts[b]
.cmp(&win_counts[a])
.then_with(|| sane[b].total_cmp(&sane[a]))
});
indexed.truncate(mu);
#[allow(clippy::cast_possible_wrap)]
let survivor_idx: Vec<i64> = indexed.iter().map(|&i| i as i64).collect();
let idx_tensor =
Tensor::<B, 1, Int>::from_data(TensorData::new(survivor_idx.clone(), [mu]), &device);
let next_parents = combined_pop.select(0, idx_tensor.clone());
let next_sigmas = combined_sigmas.select(0, idx_tensor);
let next_fitness: Vec<f32> = survivor_idx
.iter()
.map(|&i| {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
combined_fit[i as usize]
})
.collect();
state.parents = next_parents;
state.sigmas = next_sigmas;
state.parent_fitness = next_fitness;
state.generation += 1;
update_best(&mut state, &offspring, &fitness_host);
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: &EpState<B>) -> Option<(Tensor<B, 2>, f32)> {
state
.best_genome
.as_ref()
.map(|g| (g.clone(), state.best_fitness))
}
}
fn update_best<B: Backend>(state: &mut EpState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
if fitness.is_empty() {
return;
}
let mut best_idx = 0usize;
let mut best_f = fitness[0];
for (i, &f) in fitness.iter().enumerate().skip(1) {
if f > best_f {
best_f = f;
best_idx = i;
}
}
if best_f > state.best_fitness {
let device = pop.device();
#[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(pop.clone().select(0, idx));
state.best_fitness = best_f;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fitness::FromFitnessEvaluable;
use crate::strategy::EvolutionaryHarness;
use burn::backend::Flex;
use rlevo_core::fitness::FitnessEvaluable;
type TestBackend = Flex;
#[test]
fn default_config_validates() {
assert!(EpConfig::default_for(30, 10).validate().is_ok());
}
#[test]
fn rejects_tournament_q_above_two_mu() {
let mut cfg = EpConfig::default_for(5, 10);
cfg.tournament_q = 11;
assert_eq!(cfg.validate().unwrap_err().field, "tournament_q");
}
#[test]
fn rejects_zero_mu() {
let cfg = EpConfig::default_for(0, 10);
assert_eq!(cfg.validate().unwrap_err().field, "mu");
}
#[test]
fn mu_one_is_handled() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let device = Default::default();
let strategy = EvolutionaryProgramming::<TestBackend>::new();
let mut params = EpConfig::default_for(1, 3);
params.tournament_q = 2;
assert!(params.validate().is_ok(), "μ = 1 config must validate");
let mut rng = StdRng::seed_from_u64(3);
let mut state = strategy.init(¶ms, &mut rng, &device);
for _ in 0..10 {
let (offspring, next) = strategy.ask(¶ms, &state, &mut rng, &device);
let fitness = neg_sphere(&offspring);
let (advanced, _) = strategy.tell(¶ms, offspring, fitness, next, &mut rng);
state = advanced;
}
assert_eq!(
state.parents.dims()[0],
1,
"μ = 1 must keep a single parent"
);
}
fn neg_sphere(pop: &Tensor<TestBackend, 2>) -> Tensor<TestBackend, 1> {
let device = pop.device();
let [n, d] = pop.dims();
let rows: Vec<f32> = pop
.clone()
.into_data()
.into_vec::<f32>()
.expect("population host-read of a tensor this test just built");
#[allow(clippy::needless_range_loop)]
let fit: Vec<f32> = (0..n)
.map(|i| -(0..d).map(|j| rows[i * d + j].powi(2)).sum::<f32>())
.collect();
Tensor::<TestBackend, 1>::from_data(TensorData::new(fit, [n]), &device)
}
fn run_ep_trajectory(seed: u64, gens: usize) -> Vec<f32> {
use rand::SeedableRng;
use rand::rngs::StdRng;
let device = Default::default();
let strategy = EvolutionaryProgramming::<TestBackend>::new();
let params = EpConfig::default_for(8, 3);
let mut rng = StdRng::seed_from_u64(seed);
let mut state = strategy.init(¶ms, &mut rng, &device);
let mut traj = Vec::with_capacity(gens);
for _ in 0..gens {
let (offspring, next) = strategy.ask(¶ms, &state, &mut rng, &device);
let fitness = neg_sphere(&offspring);
let (advanced, m) = strategy.tell(¶ms, offspring, fitness, next, &mut rng);
traj.push(m.best_fitness_ever());
state = advanced;
}
traj
}
#[test]
fn same_seed_reproduces_trajectory() {
let a = run_ep_trajectory(2024, 30);
let b = run_ep_trajectory(2024, 30);
assert_eq!(a, b, "EP trajectory diverged under identical seed");
}
#[test]
fn best_genome_matches_best_fitness() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let device = Default::default();
let strategy = EvolutionaryProgramming::<TestBackend>::new();
let params = EpConfig::default_for(6, 3);
let mut rng = StdRng::seed_from_u64(5);
let state = strategy.init(¶ms, &mut rng, &device);
let (parents0, s) = strategy.ask(¶ms, &state, &mut rng, &device);
let [n, d] = parents0.dims();
#[allow(clippy::cast_precision_loss)]
let fit_vec: Vec<f32> = (0..n).map(|i| i as f32).collect();
let expected_idx = n - 1;
let expected_fit = fit_vec[expected_idx];
let parent_rows: Vec<f32> = parents0
.clone()
.into_data()
.into_vec::<f32>()
.expect("parent host-read of a tensor this test just built");
let expected_genome: Vec<f32> =
parent_rows[expected_idx * d..(expected_idx + 1) * d].to_vec();
let fitness = Tensor::<TestBackend, 1>::from_data(TensorData::new(fit_vec, [n]), &device);
let (s, _) = strategy.tell(¶ms, parents0, fitness, s, &mut rng);
let (genome, best_fit) = strategy.best(&s).expect("best after first tell");
approx::assert_relative_eq!(best_fit, expected_fit);
let got: Vec<f32> = genome
.into_data()
.into_vec::<f32>()
.expect("best-genome host-read of a tensor this test just built");
for (g, e) in got.iter().zip(expected_genome.iter()) {
approx::assert_relative_eq!(*g, *e);
}
}
struct NanSphere;
struct NanSphereFit;
impl FitnessEvaluable for NanSphereFit {
type Individual = Vec<f64>;
type Landscape = NanSphere;
fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
let s: f64 = x.iter().map(|v| v * v).sum();
if x[0] > 0.0 { f64::NAN } else { s }
}
}
#[test]
fn nan_fitness_never_becomes_best() {
let device = Default::default();
let params = EpConfig::default_for(20, 4);
let fitness_fn = FromFitnessEvaluable::new(NanSphereFit, NanSphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
EvolutionaryProgramming::<TestBackend>::new(),
params,
fitness_fn,
77,
device,
40,
)
.expect("valid params");
harness.reset();
loop {
if harness.step(()).done {
break;
}
}
let best = harness.latest_metrics().unwrap().best_fitness_ever();
assert!(
best.is_finite(),
"NaN fitness poisoned best_fitness_ever: {best}"
);
}
#[test]
fn rejects_zero_genome_dim() {
let cfg = EpConfig::default_for(5, 0);
assert!(
!cfg.tau.is_finite(),
"precondition: derived tau is non-finite for genome_dim == 0, got {}",
cfg.tau
);
assert_eq!(
cfg.validate().unwrap_err().field,
"genome_dim",
"genome_dim == 0 must be rejected before the non-finite tau can be used"
);
}
#[test]
fn rejects_inverted_sigma_window() {
let mut cfg = EpConfig::default_for(5, 10);
cfg.sigma_min = 10.0;
cfg.sigma_max = 1.0;
assert_eq!(
cfg.validate().unwrap_err().field,
"sigma_max",
"sigma_min >= sigma_max must be rejected"
);
}
#[test]
fn sigma_stays_within_bounds_across_updates() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let device = Default::default();
let strategy = EvolutionaryProgramming::<TestBackend>::new();
let mut params = EpConfig::default_for(6, 3);
params.tau = 5.0;
params.sigma_min = 1e-4;
params.sigma_max = 10.0;
assert!(params.validate().is_ok(), "test config must be valid");
let mut rng = StdRng::seed_from_u64(7);
let mut state = strategy.init(¶ms, &mut rng, &device);
for generation in 0..60 {
let (offspring, next) = strategy.ask(¶ms, &state, &mut rng, &device);
let sigmas: Vec<f32> = next
.sigmas
.clone()
.into_data()
.into_vec::<f32>()
.expect("sigma host-read of a tensor this test just built");
for &s in &sigmas {
assert!(
s.is_finite() && s >= params.sigma_min && s <= params.sigma_max,
"σ left [{}, {}] at gen {generation}: {s}",
params.sigma_min,
params.sigma_max
);
}
let n = offspring.dims()[0];
let fitness = Tensor::<TestBackend, 1>::from_data(
TensorData::new(vec![1.0_f32; n], [n]),
&device,
);
let (advanced, _) = strategy.tell(¶ms, offspring, fitness, next, &mut rng);
state = advanced;
}
}
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 ep_converges_on_sphere_d2() {
let device = Default::default();
let params = EpConfig::default_for(10, 2);
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
EvolutionaryProgramming::<TestBackend>::new(),
params,
fitness_fn,
3,
device,
300,
)
.expect("valid params");
harness.reset();
loop {
if harness.step(()).done {
break;
}
}
let best = harness.latest_metrics().unwrap().best_fitness_ever();
assert!(best < 1e-2, "EP Sphere-D2 best={best}");
}
#[test]
fn ep_converges_on_sphere_d10() {
let device = Default::default();
let params = EpConfig::default_for(20, 10);
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
EvolutionaryProgramming::<TestBackend>::new(),
params,
fitness_fn,
5,
device,
2000,
)
.expect("valid params");
harness.reset();
loop {
if harness.step(()).done {
break;
}
}
let best = harness.latest_metrics().unwrap().best_fitness_ever();
assert!(best < 1e-4, "EP Sphere-D10 best={best}");
}
}