use std::collections::HashSet;
use std::fmt::Debug;
use std::marker::PhantomData;
use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
use rand::rngs::StdRng;
use rand::{Rng, RngExt};
use rlevo_core::config::{ConfigError, ConstraintKind, Validate};
use rlevo_core::objective::ObjectiveSense;
use crate::fitness::sanitize_fitness_tensor;
use crate::rng::{SeedPurpose, seed_stream};
use crate::strategy::Strategy;
use super::fitness::CoupledFitness;
use super::harness::CoEAMetrics;
use super::{CoEAState, CoEvolutionaryAlgorithm};
#[derive(Clone, Copy, Debug, Default)]
pub enum RepresentativePolicy {
#[default]
Best,
Random,
Archive {
capacity: usize,
},
}
#[derive(Debug, Clone)]
pub struct CooperativeCoEAParams<PA, PB> {
pub params_a: PA,
pub params_b: PB,
pub dims_a: Vec<usize>,
pub total_dims: usize,
pub representative_policy: RepresentativePolicy,
pub evaluations_per_generation: usize,
}
impl<PA, PB> CooperativeCoEAParams<PA, PB> {
pub fn new(
params_a: PA,
params_b: PB,
dims_a: Vec<usize>,
total_dims: usize,
representative_policy: RepresentativePolicy,
evaluations_per_generation: usize,
) -> Result<Self, ConfigError> {
let params = Self {
params_a,
params_b,
dims_a,
total_dims,
representative_policy,
evaluations_per_generation,
};
params.validate()?;
Ok(params)
}
}
impl<PA, PB> Validate for CooperativeCoEAParams<PA, PB> {
fn validate(&self) -> Result<(), ConfigError> {
const C: &str = "CooperativeCoEAParams";
if self.total_dims == 0 {
return Err(ConfigError {
config: C,
field: "total_dims",
kind: ConstraintKind::Zero,
});
}
if self.dims_a.is_empty() {
return Err(ConfigError {
config: C,
field: "dims_a",
kind: ConstraintKind::Custom("dims_a must be non-empty"),
});
}
for &d in &self.dims_a {
if d >= self.total_dims {
return Err(ConfigError {
config: C,
field: "dims_a",
kind: ConstraintKind::Custom("dims_a index is out of range for total_dims"),
});
}
}
let unique: HashSet<usize> = self.dims_a.iter().copied().collect();
if unique.len() != self.dims_a.len() {
return Err(ConfigError {
config: C,
field: "dims_a",
kind: ConstraintKind::Custom("dims_a contains duplicate indices"),
});
}
if self.total_dims - self.dims_a.len() == 0 {
return Err(ConfigError {
config: C,
field: "dims_a",
kind: ConstraintKind::Custom(
"dims_a covers every dimension, leaving population B empty",
),
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CooperativeState<StA, StB, B: Backend> {
pub base: CoEAState<StA, StB>,
dims_b: Vec<usize>,
rep_archive_a: Option<Tensor<B, 2>>,
rep_archive_b: Option<Tensor<B, 2>>,
}
pub struct CooperativeCoEA<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 CooperativeCoEA<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("CooperativeCoEA").finish_non_exhaustive()
}
}
impl<B, SA, SB, F> CooperativeCoEA<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: &CooperativeState<SA::State, SB::State, B>) -> CoEAMetrics {
let sizes = self.fitness.archive_sizes();
let sense = self.fitness.sense();
let binding = state.base.best_a.min(state.base.best_b);
CoEAMetrics {
generation: state.base.generation,
best_fitness_a: sense.from_canonical(state.base.best_a),
best_fitness_b: sense.from_canonical(state.base.best_b),
mean_fitness_a: sense.from_canonical(state.base.mean_a),
mean_fitness_b: sense.from_canonical(state.base.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 CooperativeCoEA<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 = CooperativeCoEAParams<SA::Params, SB::Params>;
type State = CooperativeState<SA::State, SB::State, B>;
fn init(
&self,
params: &Self::Params,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Self::State {
debug_assert!(
params.validate().is_ok(),
"invalid CooperativeCoEAParams reached init: {:?}",
params.validate().err()
);
let state_a = self.strategy_a.init(¶ms.params_a, rng, device);
let state_b = self.strategy_b.init(¶ms.params_b, rng, device);
CooperativeState {
base: CoEAState::new(state_a, state_b),
dims_b: complement(¶ms.dims_a, params.total_dims),
rep_archive_a: None,
rep_archive_b: None,
}
}
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 dims_a = ¶ms.dims_a;
let dims_b = &state.dims_b;
let generation = state.base.generation;
let (pop_a, asked_a) =
self.strategy_a
.ask(¶ms.params_a, &state.base.state_a, rng, device);
let (pop_b, asked_b) =
self.strategy_b
.ask(¶ms.params_b, &state.base.state_b, rng, device);
let prev_best_a = self.strategy_a.best(&state.base.state_a).map(|(g, _)| g);
let prev_best_b = self.strategy_b.best(&state.base.state_b).map(|(g, _)| g);
let mut rep_rng = seed_stream(rng.next_u64(), generation, SeedPurpose::Representative);
let rep_a = select_representative(
&pop_a,
prev_best_a.as_ref(),
&mut state.rep_archive_a,
params.representative_policy,
&mut rep_rng,
generation,
device,
);
let rep_b = select_representative(
&pop_b,
prev_best_b.as_ref(),
&mut state.rep_archive_b,
params.representative_policy,
&mut rep_rng,
generation,
device,
);
let full_a = assemble(&pop_a, dims_a, &rep_b, dims_b, params.total_dims, device);
let full_b = assemble(&pop_b, dims_b, &rep_a, dims_a, params.total_dims, device);
let sense = self.fitness.sense();
let fits = self.fitness.evaluate_coupled(&[full_a, full_b]);
debug_assert_eq!(fits.len(), 2, "cooperative 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.base.state_a = next_a;
state.base.state_b = next_b;
state.base.generation += 1;
state.base.best_a = metrics_a.best_fitness_ever();
state.base.best_b = metrics_b.best_fitness_ever();
state.base.mean_a = metrics_a.mean_fitness();
state.base.mean_b = metrics_b.mean_fitness();
let metrics = self.snapshot(&state);
(state, metrics)
}
fn metrics(&self, state: &Self::State) -> CoEAMetrics {
self.snapshot(state)
}
}
fn complement(dims_a: &[usize], total_dims: usize) -> Vec<usize> {
let set: HashSet<usize> = dims_a.iter().copied().collect();
(0..total_dims).filter(|d| !set.contains(d)).collect()
}
fn row<B: Backend>(pop: &Tensor<B, 2>, idx: usize) -> Tensor<B, 2> {
let device = pop.device();
#[allow(clippy::cast_possible_wrap)]
let i = Tensor::<B, 1, Int>::from_data(TensorData::new(vec![idx as i64], [1]), &device);
pop.clone().select(0, i)
}
fn select_representative<B: Backend>(
pop: &Tensor<B, 2>,
prev_best: Option<&Tensor<B, 2>>,
archive: &mut Option<Tensor<B, 2>>,
policy: RepresentativePolicy,
rng: &mut StdRng,
generation: u64,
_device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 2> {
let n = pop.dims()[0];
match policy {
RepresentativePolicy::Best => match prev_best {
Some(best) => best.clone(),
None => row(pop, 0),
},
RepresentativePolicy::Random => {
let idx = rng.random_range(0..n.max(1));
row(pop, idx)
}
RepresentativePolicy::Archive { capacity } => {
if let Some(best) = prev_best {
let updated = match archive.take() {
None => best.clone(),
Some(existing) => {
let cat = Tensor::cat(vec![existing, best.clone()], 0);
let rows = cat.dims()[0];
if capacity > 0 && rows > capacity {
cat.narrow(0, rows - capacity, capacity)
} else {
cat
}
}
};
*archive = Some(updated);
}
match archive.as_ref() {
Some(a) if a.dims()[0] > 0 => {
let rows = a.dims()[0];
let pick = usize::try_from(generation % rows as u64).unwrap_or(0);
row(a, pick)
}
_ => row(pop, 0),
}
}
}
}
fn assemble<B: Backend>(
sub_pop: &Tensor<B, 2>,
my_dims: &[usize],
rep_other: &Tensor<B, 2>,
other_dims: &[usize],
total_dims: usize,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 2> {
let dims = sub_pop.dims();
let n = dims[0];
let sub_w = dims[1];
debug_assert_eq!(
sub_w,
my_dims.len(),
"sub-population width must match my_dims"
);
let sub_flat = sub_pop
.clone()
.into_data()
.into_vec::<f32>()
.expect("sub-population genome tensor must be readable as f32");
let rep_flat = rep_other
.clone()
.into_data()
.into_vec::<f32>()
.expect("representative genome tensor must be readable as f32");
debug_assert_eq!(
rep_flat.len(),
other_dims.len(),
"representative width must match other_dims"
);
let mut full = vec![0.0_f32; n * total_dims];
for i in 0..n {
let base = i * total_dims;
for (j, &d) in my_dims.iter().enumerate() {
full[base + d] = sub_flat[i * sub_w + j];
}
for (j, &d) in other_dims.iter().enumerate() {
full[base + d] = rep_flat[j];
}
}
Tensor::<B, 2>::from_data(TensorData::new(full, [n, total_dims]), device)
}
#[cfg(test)]
mod tests {
use super::*;
use burn::backend::Flex;
type B = Flex;
fn make(rows: &[f32], n: usize, d: usize) -> Tensor<B, 2> {
let device = Default::default();
Tensor::<B, 2>::from_data(TensorData::new(rows.to_vec(), [n, d]), &device)
}
#[test]
fn complement_is_ascending_set_difference() {
assert_eq!(complement(&[0, 2], 4), vec![1, 3]);
assert_eq!(complement(&[3, 1], 4), vec![0, 2]);
assert_eq!(complement(&[0, 1], 2), Vec::<usize>::new());
}
#[test]
fn assemble_scatters_into_global_positions() {
let device = Default::default();
let pop_a = make(&[10.0, 20.0, 11.0, 21.0], 2, 2); let rep_b = make(&[5.0, 7.0], 1, 2); let full = assemble(&pop_a, &[0, 2], &rep_b, &[1, 3], 4, &device);
let v = full
.into_data()
.into_vec::<f32>()
.expect("genome host-read of a tensor this test just built");
assert_eq!(&v[0..4], &[10.0, 5.0, 20.0, 7.0]);
assert_eq!(&v[4..8], &[11.0, 5.0, 21.0, 7.0]);
}
#[test]
fn representative_best_uses_prev_best_else_row_zero() {
let device = Default::default();
let pop = make(&[1.0, 2.0, 3.0, 4.0], 2, 2);
let mut rng = seed_stream(0, 0, SeedPurpose::Representative);
let mut archive = None;
let r0 = select_representative(
&pop,
None,
&mut archive,
RepresentativePolicy::Best,
&mut rng,
0,
&device,
);
assert_eq!(
r0.into_data()
.into_vec::<f32>()
.expect("genome host-read of a tensor this test just built"),
vec![1.0, 2.0]
);
let best = make(&[9.0, 9.0], 1, 2);
let r1 = select_representative(
&pop,
Some(&best),
&mut archive,
RepresentativePolicy::Best,
&mut rng,
1,
&device,
);
assert_eq!(
r1.into_data()
.into_vec::<f32>()
.expect("genome host-read of a tensor this test just built"),
vec![9.0, 9.0]
);
}
#[test]
fn archive_policy_bounds_archive_size() {
let device = Default::default();
let pop = make(&[0.0, 0.0], 1, 2);
let mut rng = seed_stream(0, 0, SeedPurpose::Representative);
let mut archive = None;
for g in 0..5_u64 {
#[allow(clippy::cast_precision_loss)]
let best = make(&[g as f32, g as f32], 1, 2);
let _ = select_representative(
&pop,
Some(&best),
&mut archive,
RepresentativePolicy::Archive { capacity: 2 },
&mut rng,
g,
&device,
);
if let Some(a) = archive.as_ref() {
assert!(a.dims()[0] <= 2, "archive exceeded capacity at gen {g}");
}
}
assert_eq!(archive.unwrap().dims()[0], 2);
}
#[test]
fn params_new_rejects_out_of_range_dim() {
let err =
CooperativeCoEAParams::new((), (), vec![0, 1, 4], 4, RepresentativePolicy::Best, 0)
.unwrap_err();
assert_eq!(err.field, "dims_a");
assert!(err.to_string().contains("out of range"));
}
#[test]
fn params_new_rejects_when_a_covers_everything() {
let err =
CooperativeCoEAParams::new((), (), vec![0, 1, 2, 3], 4, RepresentativePolicy::Best, 0)
.unwrap_err();
assert!(err.to_string().contains("leaving population B empty"));
}
#[test]
fn params_new_rejects_duplicate_dims() {
let err =
CooperativeCoEAParams::new((), (), vec![0, 0, 1], 4, RepresentativePolicy::Best, 0)
.unwrap_err();
assert!(err.to_string().contains("duplicate"));
}
#[test]
fn params_new_accepts_equal_split() {
let p = CooperativeCoEAParams::new((), (), vec![0, 1], 4, RepresentativePolicy::Best, 16)
.unwrap();
assert_eq!(complement(&p.dims_a, p.total_dims), vec![2, 3]);
}
use rand::SeedableRng;
use rlevo_core::bounds::Bounds;
use rlevo_core::probability::Probability;
use rlevo_core::rate::NonNegativeRate;
use crate::algorithms::ga::{
GaConfig, GaCrossover, GaReplacement, GaSelection, GeneticAlgorithm,
};
const COOP_POP: usize = 4;
fn ga_config_dim(dim: usize) -> GaConfig {
GaConfig {
pop_size: COOP_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 PoisonRow0Nan;
impl CoupledFitness<B> for PoisonRow0Nan {
fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 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 { f32::NAN } else { i as f32 })
.collect();
Tensor::<B, 1>::from_data(TensorData::new(v, [n]), &device)
})
.collect()
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Maximize
}
}
#[test]
fn cooperative_nan_is_sanitized_in_metrics() {
let device = Default::default();
let algo = CooperativeCoEA::new(
GeneticAlgorithm::<B>::new(),
GeneticAlgorithm::<B>::new(),
PoisonRow0Nan,
);
let params = CooperativeCoEAParams::new(
ga_config_dim(1),
ga_config_dim(1),
vec![0],
2,
RepresentativePolicy::Best,
0,
)
.unwrap();
let mut rng = StdRng::seed_from_u64(7);
let state = algo.init(¶ms, &mut rng, &device);
let (_next, m) = algo.step(¶ms, state, &mut rng, &device);
#[allow(clippy::cast_precision_loss)]
let expected_best = (COOP_POP - 1) as f32;
approx::assert_relative_eq!(m.best_fitness_a, expected_best, epsilon = 1e-6);
assert!(
m.mean_fitness_a.is_finite(),
"cooperative mean must stay finite when a NaN individual is present, got {}",
m.mean_fitness_a
);
assert!(
!m.best_fitness_b.is_nan(),
"best_fitness_b must never be NaN"
);
assert!(
!m.mean_fitness_b.is_nan(),
"mean_fitness_b must never be NaN"
);
}
struct RowCost;
impl CoupledFitness<B> for RowCost {
fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 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::<B, 1>::from_data(TensorData::new(v, [n]), &device)
})
.collect()
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Minimize
}
}
#[test]
fn cooperative_minimize_is_maximized_and_reported_natural() {
let device = Default::default();
let algo = CooperativeCoEA::new(
GeneticAlgorithm::<B>::new(),
GeneticAlgorithm::<B>::new(),
RowCost,
);
let params = CooperativeCoEAParams::new(
ga_config_dim(1),
ga_config_dim(1),
vec![0],
2,
RepresentativePolicy::Best,
0,
)
.unwrap();
let mut rng = StdRng::seed_from_u64(7);
let state = algo.init(¶ms, &mut rng, &device);
let (_next, m) = algo.step(¶ms, state, &mut rng, &device);
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.mean_fitness_a.is_finite() && m.mean_fitness_a > 0.0,
"mean natural cost should be finite positive, got {}",
m.mean_fitness_a
);
assert!(
m.binding_fitness.is_finite(),
"binding_fitness must be finite, got {}",
m.binding_fitness
);
}
}