use std::fmt::Debug;
use std::marker::PhantomData;
use burn::tensor::{Tensor, backend::Backend};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use rlevo_core::config::{ConfigError, Validate};
use rlevo_core::evaluation::{BenchEnv, BenchError, BenchStep};
use rlevo_core::objective::ObjectiveSense;
use crate::fitness::BatchFitnessFn;
use crate::observer::{PopulationSnapshot, SharedPopulationObserver};
pub trait Strategy<B: Backend>: Send + Sync {
type Params: Clone + Debug + Send + Sync;
type State: Clone + Debug + Send;
type Genome: Clone + Send;
fn init(
&self,
params: &Self::Params,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Self::State;
fn ask(
&self,
params: &Self::Params,
state: &Self::State,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> (Self::Genome, Self::State);
fn tell(
&self,
params: &Self::Params,
population: Self::Genome,
fitness: Tensor<B, 1>,
state: Self::State,
rng: &mut dyn Rng,
) -> (Self::State, StrategyMetrics);
fn best(&self, state: &Self::State) -> Option<(Self::Genome, f32)>;
}
#[derive(Debug, Clone)]
pub struct StrategyMetrics {
generation: usize,
population_size: usize,
best_fitness: f32,
mean_fitness: f32,
worst_fitness: f32,
best_fitness_ever: f32,
broken_count: usize,
}
impl StrategyMetrics {
#[must_use]
pub fn from_host_fitness(generation: usize, fitnesses: &[f32], best_fitness_ever: f32) -> Self {
assert!(!fitnesses.is_empty(), "fitness slice must be non-empty");
let population_size = fitnesses.len();
let mut best = f32::NEG_INFINITY;
let mut worst = f32::INFINITY;
let mut finite_sum = 0.0_f32;
let mut finite_n = 0_usize;
let mut broken_count = 0_usize;
for &f in fitnesses {
let f = crate::fitness::sanitize_fitness(f);
if f > best {
best = f;
}
if f < worst {
worst = f;
}
if f.is_finite() {
finite_sum += f;
finite_n += 1;
} else {
broken_count += 1;
}
}
let mean = if finite_n > 0 {
#[allow(clippy::cast_precision_loss)]
let n = finite_n as f32;
finite_sum / n
} else {
f32::NEG_INFINITY
};
Self {
generation,
population_size,
best_fitness: best,
mean_fitness: mean,
worst_fitness: worst,
best_fitness_ever: best_fitness_ever.max(best),
broken_count,
}
}
#[must_use]
pub fn generation(&self) -> usize {
self.generation
}
#[must_use]
pub fn population_size(&self) -> usize {
self.population_size
}
#[must_use]
pub fn best_fitness(&self) -> f32 {
self.best_fitness
}
#[must_use]
pub fn mean_fitness(&self) -> f32 {
self.mean_fitness
}
#[must_use]
pub fn broken_count(&self) -> usize {
self.broken_count
}
#[must_use]
pub fn worst_fitness(&self) -> f32 {
self.worst_fitness
}
#[must_use]
pub fn best_fitness_ever(&self) -> f32 {
self.best_fitness_ever
}
}
fn build_population_snapshot(
generation: u32,
fitnesses: Vec<f32>,
sense: ObjectiveSense,
) -> Option<PopulationSnapshot> {
if fitnesses.is_empty() {
return None;
}
let best_index = fitnesses
.iter()
.enumerate()
.reduce(|best, cur| {
let better = match sense {
ObjectiveSense::Minimize => cur.1 < best.1,
ObjectiveSense::Maximize => cur.1 > best.1,
};
if better { cur } else { best }
})
.map_or(0, |(i, _)| u32::try_from(i).unwrap_or(0));
Some(PopulationSnapshot {
generation,
fitnesses,
diversity: None,
best_index,
best_genome_digest: None,
parents_of_best: Vec::new(),
})
}
pub struct EvolutionaryHarness<B, S, F>
where
B: Backend,
S: Strategy<B>,
F: BatchFitnessFn<B, S::Genome>,
{
strategy: S,
params: S::Params,
fitness_fn: F,
state: Option<S::State>,
rng: StdRng,
base_seed: u64,
device: B::Device,
generation: usize,
max_generations: usize,
latest_metrics: Option<StrategyMetrics>,
observer: Option<SharedPopulationObserver>,
_backend: PhantomData<B>,
}
impl<B, S, F> Debug for EvolutionaryHarness<B, S, F>
where
B: Backend,
S: Strategy<B>,
F: BatchFitnessFn<B, S::Genome>,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EvolutionaryHarness")
.field("base_seed", &self.base_seed)
.field("generation", &self.generation)
.field("max_generations", &self.max_generations)
.field("latest_metrics", &self.latest_metrics)
.finish_non_exhaustive()
}
}
impl<B, S, F> EvolutionaryHarness<B, S, F>
where
B: Backend,
S: Strategy<B>,
F: BatchFitnessFn<B, S::Genome>,
{
pub fn new(
strategy: S,
params: S::Params,
fitness_fn: F,
seed: u64,
device: B::Device,
max_generations: usize,
) -> Result<Self, ConfigError>
where
S::Params: Validate,
{
params.validate()?;
Ok(Self {
strategy,
params,
fitness_fn,
state: None,
rng: StdRng::seed_from_u64(seed),
base_seed: seed,
device,
generation: 0,
max_generations,
latest_metrics: None,
observer: None,
_backend: PhantomData,
})
}
#[must_use]
pub fn with_observer(mut self, observer: SharedPopulationObserver) -> Self {
self.observer = Some(observer);
self
}
#[must_use]
pub fn latest_metrics(&self) -> Option<&StrategyMetrics> {
self.latest_metrics.as_ref()
}
#[must_use]
pub fn generation(&self) -> usize {
self.generation
}
#[must_use]
pub fn state(&self) -> Option<&S::State> {
self.state.as_ref()
}
pub fn best(&self) -> Option<(S::Genome, f32)> {
let sense = self.fitness_fn.sense();
self.state
.as_ref()
.and_then(|s| self.strategy.best(s))
.map(|(genome, canonical)| (genome, sense.from_canonical(canonical)))
}
pub fn reset(&mut self) {
self.rng = StdRng::seed_from_u64(self.base_seed);
self.generation = 0;
self.latest_metrics = None;
self.state = Some(
self.strategy
.init(&self.params, &mut self.rng, &self.device),
);
}
pub fn step(&mut self, _action: ()) -> BenchStep<()> {
let state = self
.state
.take()
.expect("EvolutionaryHarness::reset must be called before step");
let (population, state) =
self.strategy
.ask(&self.params, &state, &mut self.rng, &self.device);
let sense = self.fitness_fn.sense();
let fitness_natural = self.fitness_fn.evaluate_batch(&population, &self.device);
let snapshot_fitness: Option<Vec<f32>> = self.observer.as_ref().map(|_| {
fitness_natural
.clone()
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32")
});
let fitness_canon = match sense {
ObjectiveSense::Maximize => fitness_natural,
ObjectiveSense::Minimize => fitness_natural.neg(),
};
let fitness_canon = crate::fitness::sanitize_fitness_tensor(fitness_canon);
let (new_state, metrics_canon) = self.strategy.tell(
&self.params,
population,
fitness_canon,
state,
&mut self.rng,
);
self.state = Some(new_state);
self.generation += 1;
let reward = f64::from(metrics_canon.best_fitness_ever);
let metrics = StrategyMetrics {
generation: metrics_canon.generation,
population_size: metrics_canon.population_size,
best_fitness: sense.from_canonical(metrics_canon.best_fitness),
mean_fitness: sense.from_canonical(metrics_canon.mean_fitness),
worst_fitness: sense.from_canonical(metrics_canon.worst_fitness),
best_fitness_ever: sense.from_canonical(metrics_canon.best_fitness_ever),
broken_count: metrics_canon.broken_count,
};
tracing::info!(
generation = metrics.generation,
population_size = metrics.population_size,
best_fitness = f64::from(metrics.best_fitness),
mean_fitness = f64::from(metrics.mean_fitness),
worst_fitness = f64::from(metrics.worst_fitness),
best_fitness_ever = f64::from(metrics.best_fitness_ever),
broken_count = metrics.broken_count,
"evolution generation",
);
if let (Some(observer), Some(fitnesses)) = (self.observer.as_ref(), snapshot_fitness) {
let generation = u32::try_from(metrics.generation).unwrap_or(u32::MAX);
match build_population_snapshot(generation, fitnesses, sense) {
Some(snapshot) => {
let dispatched = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
observer.lock().on_population(snapshot);
}));
if dispatched.is_err() {
tracing::warn!(
generation,
"population observer panicked; dropping snapshot and continuing",
);
}
}
None => {
tracing::warn!(
generation,
"empty population fitness vector; skipping observer snapshot \
(device→host transfer likely failed)",
);
}
}
}
self.latest_metrics = Some(metrics);
let done = self.generation >= self.max_generations;
BenchStep {
observation: (),
reward,
done,
}
}
}
impl<B, S, F> BenchEnv for EvolutionaryHarness<B, S, F>
where
B: Backend,
S: Strategy<B>,
F: BatchFitnessFn<B, S::Genome>,
{
type Observation = ();
type Action = ();
fn reset(&mut self) -> Result<Self::Observation, BenchError> {
EvolutionaryHarness::<B, S, F>::reset(self);
Ok(())
}
fn step(&mut self, action: Self::Action) -> Result<BenchStep<Self::Observation>, BenchError> {
Ok(EvolutionaryHarness::<B, S, F>::step(self, action))
}
}
#[cfg(test)]
mod tests {
use super::*;
use burn::backend::Flex;
use burn::tensor::TensorData;
type TestBackend = Flex;
#[derive(Debug, Clone, Copy)]
struct Constant;
#[derive(Debug, Clone)]
struct Params {
pop_size: usize,
dim: usize,
}
impl Validate for Params {
fn validate(&self) -> Result<(), ConfigError> {
rlevo_core::config::nonzero("Params", "pop_size", self.pop_size)?;
rlevo_core::config::nonzero("Params", "dim", self.dim)?;
Ok(())
}
}
#[derive(Debug, Clone)]
struct State {
generation: usize,
best: f32,
}
impl Strategy<TestBackend> for Constant {
type Params = Params;
type State = State;
type Genome = Tensor<TestBackend, 2>;
fn init(
&self,
params: &Params,
_: &mut dyn Rng,
device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
) -> State {
let _ = device;
let _ = params;
State {
generation: 0,
best: f32::NEG_INFINITY,
}
}
fn ask(
&self,
params: &Params,
state: &State,
_: &mut dyn Rng,
device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
) -> (Tensor<TestBackend, 2>, State) {
let data = TensorData::new(
vec![0.0f32; params.pop_size * params.dim],
[params.pop_size, params.dim],
);
let pop = Tensor::<TestBackend, 2>::from_data(data, device);
(pop, state.clone())
}
fn tell(
&self,
_: &Params,
_: Tensor<TestBackend, 2>,
fitness: Tensor<TestBackend, 1>,
mut state: State,
_: &mut dyn Rng,
) -> (State, StrategyMetrics) {
let values = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness host-read of a tensor this test just built");
state.generation += 1;
let metrics = StrategyMetrics::from_host_fitness(state.generation, &values, state.best);
state.best = metrics.best_fitness_ever();
(state, metrics)
}
fn best(&self, _state: &State) -> Option<(Tensor<TestBackend, 2>, f32)> {
None
}
}
struct FortyTwo;
impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for FortyTwo {
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];
let data = TensorData::new(vec![42.0f32; n], [n]);
Tensor::<B, 1>::from_data(data, device)
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Minimize
}
}
#[test]
#[allow(clippy::float_cmp)]
fn harness_runs_one_generation() {
let device = Default::default();
let strategy = Constant;
let params = Params {
pop_size: 4,
dim: 3,
};
let mut harness =
EvolutionaryHarness::<TestBackend, _, _>::new(strategy, params, FortyTwo, 1, device, 5)
.expect("valid params");
harness.reset();
let step = harness.step(());
assert_eq!(step.reward, -42.0);
assert!(!step.done);
assert_eq!(harness.generation(), 1);
let m = harness.latest_metrics().unwrap();
assert_eq!(m.generation, 1);
assert_eq!(m.population_size, 4);
approx::assert_relative_eq!(m.best_fitness, 42.0, epsilon = 1e-6);
}
#[test]
fn harness_reports_done_after_budget() {
let device = Default::default();
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
Constant,
Params {
pop_size: 2,
dim: 2,
},
FortyTwo,
1,
device,
2,
)
.expect("valid params");
harness.reset();
assert!(!harness.step(()).done);
assert!(harness.step(()).done);
}
#[test]
fn from_host_fitness_computes_stats() {
let m = StrategyMetrics::from_host_fitness(5, &[3.0, 1.0, 5.0, 2.0], 4.0);
assert_eq!(m.generation(), 5);
assert_eq!(m.population_size(), 4);
approx::assert_relative_eq!(m.best_fitness(), 5.0, epsilon = 1e-6);
approx::assert_relative_eq!(m.worst_fitness(), 1.0, epsilon = 1e-6);
approx::assert_relative_eq!(m.mean_fitness(), 2.75, epsilon = 1e-6);
approx::assert_relative_eq!(m.best_fitness_ever(), 5.0, epsilon = 1e-6);
}
#[test]
fn from_host_fitness_sanitizes_nan() {
let m = StrategyMetrics::from_host_fitness(0, &[1.0, f32::NAN, 3.0, 2.0], 0.0);
approx::assert_relative_eq!(m.best_fitness(), 3.0, epsilon = 1e-6);
assert!(m.worst_fitness().is_infinite() && m.worst_fitness().is_sign_negative());
approx::assert_relative_eq!(m.mean_fitness(), 2.0, epsilon = 1e-6);
assert_eq!(m.broken_count(), 1);
approx::assert_relative_eq!(m.best_fitness_ever(), 3.0, epsilon = 1e-6);
}
#[test]
fn from_host_fitness_pos_inf_ranks_top_but_mean_stays_finite() {
let m = StrategyMetrics::from_host_fitness(0, &[1.0, f32::INFINITY, 3.0], 0.0);
approx::assert_relative_eq!(m.best_fitness(), f32::MAX);
assert_eq!(m.broken_count(), 0);
assert!(m.mean_fitness().is_finite());
}
#[test]
fn from_host_fitness_all_broken_yields_neg_inf_mean() {
let m = StrategyMetrics::from_host_fitness(0, &[f32::NAN, f32::NAN], 0.0);
assert_eq!(m.broken_count(), 2);
assert!(m.mean_fitness().is_infinite() && m.mean_fitness().is_sign_negative());
}
struct NonFiniteFitness;
impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for NonFiniteFitness {
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;
if n > 1 {
vals[1] = f32::INFINITY;
}
Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Maximize
}
}
#[derive(Debug, Clone, Copy)]
struct TrustingStrategy;
#[derive(Debug, Clone)]
struct TrustingState {
generation: usize,
best: f32,
received: Vec<f32>,
}
impl Strategy<TestBackend> for TrustingStrategy {
type Params = Params;
type State = TrustingState;
type Genome = Tensor<TestBackend, 2>;
fn init(
&self,
_: &Params,
_: &mut dyn Rng,
_: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
) -> TrustingState {
TrustingState {
generation: 0,
best: f32::NEG_INFINITY,
received: Vec::new(),
}
}
fn ask(
&self,
params: &Params,
state: &TrustingState,
_: &mut dyn Rng,
device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
) -> (Tensor<TestBackend, 2>, TrustingState) {
let data = TensorData::new(
vec![0.0f32; params.pop_size * params.dim],
[params.pop_size, params.dim],
);
(
Tensor::<TestBackend, 2>::from_data(data, device),
state.clone(),
)
}
fn tell(
&self,
_: &Params,
_: Tensor<TestBackend, 2>,
fitness: Tensor<TestBackend, 1>,
mut state: TrustingState,
_: &mut dyn Rng,
) -> (TrustingState, StrategyMetrics) {
let values: Vec<f32> = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness host-read of a tensor this test just built");
state.received = values.clone();
state.generation += 1;
let metrics: StrategyMetrics =
StrategyMetrics::from_host_fitness(state.generation, &values, state.best);
state.best = metrics.best_fitness_ever();
(state, metrics)
}
fn best(&self, _: &TrustingState) -> Option<(Tensor<TestBackend, 2>, f32)> {
None
}
}
#[test]
fn harness_sanitizes_non_finite_fitness_before_tell() {
let device = Default::default();
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
TrustingStrategy,
Params {
pop_size: 4,
dim: 2,
},
NonFiniteFitness,
7,
device,
1,
)
.expect("valid params");
harness.reset();
harness.step(());
let received = &harness.state().expect("state after step").received;
assert_eq!(received.len(), 4);
assert!(
received.iter().all(|f| !f.is_nan()),
"harness must strip NaN before tell; got {received:?}"
);
assert!(
received
.iter()
.all(|f| !(f.is_infinite() && f.is_sign_positive())),
"harness must clamp +∞ before tell; got {received:?}"
);
assert!(
received[0].is_infinite() && received[0].is_sign_negative(),
"NaN row → −∞"
);
approx::assert_relative_eq!(received[1], f32::MAX);
let m = harness.latest_metrics().expect("metrics after step");
assert!(
m.best_fitness().is_finite(),
"best must be finite, got {}",
m.best_fitness()
);
assert_eq!(m.broken_count(), 1, "the NaN row is the one broken member");
assert!(
m.mean_fitness().is_finite(),
"mean over finite members stays finite"
);
}
#[test]
fn build_population_snapshot_empty_returns_none() {
assert!(build_population_snapshot(0, Vec::new(), ObjectiveSense::Minimize).is_none());
}
#[test]
fn build_population_snapshot_picks_best_for_sense() {
let min = build_population_snapshot(7, vec![0.3, 0.1, 0.9], ObjectiveSense::Minimize)
.expect("non-empty");
assert_eq!(min.best_index, 1);
assert_eq!(min.generation, 7);
let max = build_population_snapshot(7, vec![0.3, 0.1, 0.9], ObjectiveSense::Maximize)
.expect("non-empty");
assert_eq!(max.best_index, 2);
}
struct RankedFitness;
impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for RankedFitness {
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 values: Vec<f32> = (0..n).map(|i| 1.0 / (i as f32 + 1.0)).collect();
let data = TensorData::new(values, [n]);
Tensor::<B, 1>::from_data(data, device)
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Minimize
}
}
#[derive(Debug, Default)]
struct CountingObserver {
snapshots: Vec<PopulationSnapshot>,
}
impl crate::observer::PopulationObserver for CountingObserver {
fn on_population(&mut self, snapshot: PopulationSnapshot) {
self.snapshots.push(snapshot);
}
}
#[test]
fn harness_fires_observer_per_generation() {
use std::sync::Arc;
use parking_lot::Mutex;
let device = Default::default();
let observer = Arc::new(Mutex::new(CountingObserver::default()));
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
Constant,
Params {
pop_size: 5,
dim: 2,
},
RankedFitness,
1,
device,
3,
)
.expect("valid params")
.with_observer(observer.clone() as SharedPopulationObserver);
harness.reset();
for _ in 0..3 {
harness.step(());
}
let guard = observer.lock();
assert_eq!(guard.snapshots.len(), 3);
assert_eq!(guard.snapshots[0].fitnesses.len(), 5);
assert_eq!(guard.snapshots[0].best_index, 4);
assert_eq!(guard.snapshots[2].generation, 3);
assert!(guard.snapshots[0].diversity.is_none());
assert!(guard.snapshots[0].best_genome_digest.is_none());
assert!(guard.snapshots[0].parents_of_best.is_empty());
}
#[derive(Debug, Default)]
struct PanicObserver;
impl crate::observer::PopulationObserver for PanicObserver {
fn on_population(&mut self, _snapshot: PopulationSnapshot) {
panic!("observer intentionally panics");
}
}
#[test]
fn harness_survives_panicking_observer() {
use std::sync::Arc;
use parking_lot::Mutex;
let device = Default::default();
let observer = Arc::new(Mutex::new(PanicObserver));
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
Constant,
Params {
pop_size: 4,
dim: 2,
},
RankedFitness,
1,
device,
2,
)
.expect("valid params")
.with_observer(observer.clone() as SharedPopulationObserver);
harness.reset();
assert!(!harness.step(()).done);
assert!(harness.step(()).done);
assert_eq!(harness.generation(), 2);
}
#[test]
fn harness_without_observer_skips_host_transfer() {
let device = Default::default();
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
Constant,
Params {
pop_size: 3,
dim: 1,
},
RankedFitness,
1,
device,
1,
)
.expect("valid params");
harness.reset();
let step = harness.step(());
assert!(step.done);
}
}