use std::fmt::Debug;
use std::marker::PhantomData;
use burn::tensor::{Tensor, backend::Backend};
use rand::Rng;
use rlevo_core::config::{ConfigError, Validate};
use rlevo_core::objective::ObjectiveSense;
use crate::fitness::sanitize_fitness_tensor;
use crate::strategy::Strategy;
use super::fitness::CoupledFitness;
use super::harness::CoEAMetrics;
use super::{CoEAState, CoEvolutionaryAlgorithm};
#[derive(Debug, Clone)]
pub struct CompetitiveCoEAParams<PA, PB> {
pub params_a: PA,
pub params_b: PB,
}
impl<PA, PB> Validate for CompetitiveCoEAParams<PA, PB> {
fn validate(&self) -> Result<(), ConfigError> {
Ok(())
}
}
pub struct CompetitiveCoEA<B, SA, SB, F>
where
B: Backend,
SA: Strategy<B, Genome = Tensor<B, 2>>,
SB: Strategy<B, Genome = Tensor<B, 2>>,
F: CoupledFitness<B>,
{
strategy_a: SA,
strategy_b: SB,
fitness: F,
_backend: PhantomData<fn() -> B>,
}
impl<B, SA, SB, F> Debug for CompetitiveCoEA<B, SA, SB, F>
where
B: Backend,
SA: Strategy<B, Genome = Tensor<B, 2>>,
SB: Strategy<B, Genome = Tensor<B, 2>>,
F: CoupledFitness<B>,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompetitiveCoEA").finish_non_exhaustive()
}
}
impl<B, SA, SB, F> CompetitiveCoEA<B, SA, SB, F>
where
B: Backend,
SA: Strategy<B, Genome = Tensor<B, 2>>,
SB: Strategy<B, Genome = Tensor<B, 2>>,
F: CoupledFitness<B>,
{
pub fn new(strategy_a: SA, strategy_b: SB, fitness: F) -> Self {
Self {
strategy_a,
strategy_b,
fitness,
_backend: PhantomData,
}
}
fn snapshot(&self, state: &CoEAState<SA::State, SB::State>) -> CoEAMetrics {
let sizes = self.fitness.archive_sizes();
let sense = self.fitness.sense();
let binding = state.best_a.min(state.best_b);
CoEAMetrics {
generation: state.generation,
best_fitness_a: sense.from_canonical(state.best_a),
best_fitness_b: sense.from_canonical(state.best_b),
mean_fitness_a: sense.from_canonical(state.mean_a),
mean_fitness_b: sense.from_canonical(state.mean_b),
binding_fitness: binding,
hof_size_a: sizes.first().copied().unwrap_or(0),
hof_size_b: sizes.get(1).copied().unwrap_or(0),
}
}
}
impl<B, SA, SB, F> CoEvolutionaryAlgorithm<B> for CompetitiveCoEA<B, SA, SB, F>
where
B: Backend,
SA: Strategy<B, Genome = Tensor<B, 2>>,
SB: Strategy<B, Genome = Tensor<B, 2>>,
F: CoupledFitness<B>,
{
type Params = CompetitiveCoEAParams<SA::Params, SB::Params>;
type State = CoEAState<SA::State, SB::State>;
fn init(
&self,
params: &Self::Params,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Self::State {
let state_a = self.strategy_a.init(¶ms.params_a, rng, device);
let state_b = self.strategy_b.init(¶ms.params_b, rng, device);
CoEAState::new(state_a, state_b)
}
fn step(
&self,
params: &Self::Params,
mut state: Self::State,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> (Self::State, CoEAMetrics) {
let (pop_a, asked_a) = self
.strategy_a
.ask(¶ms.params_a, &state.state_a, rng, device);
let (pop_b, asked_b) = self
.strategy_b
.ask(¶ms.params_b, &state.state_b, rng, device);
let sense = self.fitness.sense();
let fits = self
.fitness
.evaluate_coupled(&[pop_a.clone(), pop_b.clone()]);
debug_assert_eq!(fits.len(), 2, "competitive co-evolution is bi-population");
let canon = |t: Tensor<B, 1>| {
let c = match sense {
ObjectiveSense::Maximize => t,
ObjectiveSense::Minimize => t.neg(),
};
sanitize_fitness_tensor(c)
};
let fit_a = canon(fits[0].clone());
let fit_b = canon(fits[1].clone());
let (next_a, metrics_a) =
self.strategy_a
.tell(¶ms.params_a, pop_a, fit_a, asked_a, rng);
let (next_b, metrics_b) =
self.strategy_b
.tell(¶ms.params_b, pop_b, fit_b, asked_b, rng);
state.state_a = next_a;
state.state_b = next_b;
state.generation += 1;
state.best_a = metrics_a.best_fitness_ever();
state.best_b = metrics_b.best_fitness_ever();
state.mean_a = metrics_a.mean_fitness();
state.mean_b = metrics_b.mean_fitness();
let metrics = self.snapshot(&state);
(state, metrics)
}
fn metrics(&self, state: &Self::State) -> CoEAMetrics {
self.snapshot(state)
}
}
#[cfg(test)]
mod tests {
use super::*;
use burn::backend::Flex;
use burn::tensor::TensorData;
use rand::SeedableRng;
use rand::rngs::StdRng;
use rlevo_core::bounds::Bounds;
use rlevo_core::probability::Probability;
use rlevo_core::rate::NonNegativeRate;
use crate::algorithms::ga::{
GaConfig, GaCrossover, GaReplacement, GaSelection, GeneticAlgorithm,
};
type TB = Flex;
const POP: usize = 4;
const DIM: usize = 2;
fn ga_config() -> GaConfig {
GaConfig {
pop_size: POP,
genome_dim: DIM,
bounds: Bounds::new(0.0, 1.0),
mutation_sigma: NonNegativeRate::new(0.1),
selection: GaSelection::Tournament { size: 2 },
crossover: GaCrossover::Uniform {
p: Probability::new(0.5),
},
replacement: GaReplacement::Elitist { elitism_k: 1 },
}
}
struct PoisonRow0 {
poison: f32,
}
impl CoupledFitness<TB> for PoisonRow0 {
fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
populations
.iter()
.map(|p| {
let n = p.dims()[0];
let device = p.device();
#[allow(clippy::cast_precision_loss)]
let v: Vec<f32> = (0..n)
.map(|i| if i == 0 { self.poison } else { i as f32 })
.collect();
Tensor::<TB, 1>::from_data(TensorData::new(v, [n]), &device)
})
.collect()
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Maximize
}
}
struct AllNan;
impl CoupledFitness<TB> for AllNan {
fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
populations
.iter()
.map(|p| {
let n = p.dims()[0];
let device = p.device();
Tensor::<TB, 1>::from_data(TensorData::new(vec![f32::NAN; n], [n]), &device)
})
.collect()
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Maximize
}
}
fn run_one_step<F: CoupledFitness<TB>>(fitness: F) -> CoEAMetrics {
let device = Default::default();
let algo = CompetitiveCoEA::new(
GeneticAlgorithm::<TB>::new(),
GeneticAlgorithm::<TB>::new(),
fitness,
);
let params: CompetitiveCoEAParams<GaConfig, GaConfig> = CompetitiveCoEAParams {
params_a: ga_config(),
params_b: ga_config(),
};
let mut rng = StdRng::seed_from_u64(7);
let state = algo.init(¶ms, &mut rng, &device);
let (_next, metrics) = algo.step(¶ms, state, &mut rng, &device);
metrics
}
#[test]
fn nan_row_is_not_crowned_and_mean_stays_finite() {
let m = run_one_step(PoisonRow0 { poison: f32::NAN });
#[allow(clippy::cast_precision_loss)]
let expected_best = (POP - 1) as f32;
approx::assert_relative_eq!(m.best_fitness_a, expected_best, epsilon = 1e-6);
approx::assert_relative_eq!(m.best_fitness_b, expected_best, epsilon = 1e-6);
assert!(
m.mean_fitness_a.is_finite(),
"mean_fitness_a must stay finite when a NaN individual is present, got {}",
m.mean_fitness_a
);
assert!(
m.mean_fitness_b.is_finite(),
"mean_fitness_b must stay finite when a NaN individual is present, got {}",
m.mean_fitness_b
);
}
#[test]
fn pos_inf_fitness_is_clamped_finite_in_metrics() {
let m = run_one_step(PoisonRow0 {
poison: f32::INFINITY,
});
approx::assert_relative_eq!(m.best_fitness_a, f32::MAX);
assert!(
m.mean_fitness_a.is_finite(),
"a +∞ individual must not push the mean to +∞, got {}",
m.mean_fitness_a
);
}
#[test]
fn all_nan_population_yields_neg_inf_never_nan() {
let m = run_one_step(AllNan);
assert!(!m.best_fitness_a.is_nan(), "best must never be NaN");
assert!(!m.mean_fitness_a.is_nan(), "mean must never be NaN");
assert!(
m.best_fitness_a.is_infinite() && m.best_fitness_a.is_sign_negative(),
"all-broken population best is the −∞ sentinel, got {}",
m.best_fitness_a
);
}
struct NegCost;
impl CoupledFitness<TB> for NegCost {
fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
populations
.iter()
.map(|p| {
let n = p.dims()[0];
let device = p.device();
#[allow(clippy::cast_precision_loss)]
let v: Vec<f32> = (0..n).map(|i| i as f32).collect();
Tensor::<TB, 1>::from_data(TensorData::new(v, [n]), &device)
})
.collect()
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Minimize
}
}
#[test]
fn minimize_objective_is_maximized_and_reported_natural() {
let m = run_one_step(NegCost);
approx::assert_relative_eq!(m.best_fitness_a, 0.0, epsilon = 1e-6);
approx::assert_relative_eq!(m.best_fitness_b, 0.0, epsilon = 1e-6);
assert!(
m.binding_fitness.is_finite(),
"binding_fitness must be finite, got {}",
m.binding_fitness
);
approx::assert_relative_eq!(m.binding_fitness, 0.0, epsilon = 1e-6);
assert!(
m.mean_fitness_a.is_finite() && m.mean_fitness_a > 0.0,
"mean natural cost should be a finite positive, got {}",
m.mean_fitness_a
);
}
struct DistinctRamps;
impl CoupledFitness<TB> for DistinctRamps {
fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
let peaks = [10.0_f32, 20.0_f32];
populations
.iter()
.enumerate()
.map(|(k, p)| {
let n = p.dims()[0];
let device = p.device();
let peak = peaks[k];
#[allow(clippy::cast_precision_loss)]
let v: Vec<f32> = (0..n)
.map(|i| peak * (i as f32) / ((n - 1).max(1) as f32))
.collect();
Tensor::<TB, 1>::from_data(TensorData::new(v, [n]), &device)
})
.collect()
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Maximize
}
}
#[test]
fn step_increments_generation_and_splits_fitness_by_population() {
let device = Default::default();
let algo = CompetitiveCoEA::new(
GeneticAlgorithm::<TB>::new(),
GeneticAlgorithm::<TB>::new(),
DistinctRamps,
);
let params: CompetitiveCoEAParams<GaConfig, GaConfig> = CompetitiveCoEAParams {
params_a: ga_config(),
params_b: ga_config(),
};
let mut rng = StdRng::seed_from_u64(7);
let state = algo.init(¶ms, &mut rng, &device);
let (next, metrics) = algo.step(¶ms, state, &mut rng, &device);
assert_eq!(metrics.generation, 1, "one step must bump generation 0 → 1");
assert_eq!(
algo.metrics(&next).generation,
1,
"metrics() on the returned state must agree with the step snapshot"
);
approx::assert_relative_eq!(metrics.best_fitness_a, 10.0, epsilon = 1e-6);
approx::assert_relative_eq!(metrics.best_fitness_b, 20.0, epsilon = 1e-6);
}
#[test]
fn snapshot_hof_size_falls_back_to_zero_without_archive() {
let m = run_one_step(DistinctRamps);
assert_eq!(m.hof_size_a, 0, "no archive → hof_size_a falls back to 0");
assert_eq!(m.hof_size_b, 0, "no archive → hof_size_b falls back to 0");
}
#[test]
fn best_a_tracks_rolling_max_across_generations() {
let device = Default::default();
let algo = CompetitiveCoEA::new(
GeneticAlgorithm::<TB>::new(),
GeneticAlgorithm::<TB>::new(),
DistinctRamps,
);
let params: CompetitiveCoEAParams<GaConfig, GaConfig> = CompetitiveCoEAParams {
params_a: ga_config(),
params_b: ga_config(),
};
let mut rng = StdRng::seed_from_u64(7);
let state = algo.init(¶ms, &mut rng, &device);
let (state, m1) = algo.step(¶ms, state, &mut rng, &device);
let (_state, m2) = algo.step(¶ms, state, &mut rng, &device);
assert_eq!(m2.generation, 2, "two steps must reach generation 2");
assert!(
m2.best_fitness_a >= m1.best_fitness_a,
"best_fitness_a is a rolling max and must be non-decreasing: {} → {}",
m1.best_fitness_a,
m2.best_fitness_a
);
}
struct WrongLen;
impl CoupledFitness<TB> for WrongLen {
fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
let p = &populations[0];
let n = p.dims()[0];
let device = p.device();
vec![Tensor::<TB, 1>::from_data(
TensorData::new(vec![0.0_f32; n], [n]),
&device,
)]
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Maximize
}
}
#[test]
#[should_panic(expected = "competitive co-evolution is bi-population")]
fn step_panics_on_wrong_length_coupled_fitness() {
let _ = run_one_step(WrongLen);
}
}