use rand::RngExt;
use rand::rngs::StdRng;
use super::topology::{InnovationId, TopologyGenome};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SpeciesId(u64);
impl SpeciesId {
#[must_use]
pub const fn new(raw: u64) -> Self {
Self(raw)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
#[must_use]
pub(crate) const fn succ(self) -> Self {
Self(self.0 + 1)
}
}
pub const STAGNATION_PROTECT_TOP_K: usize = 2;
#[derive(Clone, Debug)]
pub struct Species {
pub(crate) id: SpeciesId,
pub(crate) representative: TopologyGenome,
pub(crate) members: Vec<usize>,
pub(crate) best_fitness: f32,
pub(crate) last_improved_generation: u64,
pub(crate) adjusted_fitness_sum: f32,
}
impl Species {
#[must_use]
pub fn id(&self) -> SpeciesId {
self.id
}
}
#[allow(clippy::many_single_char_names)]
#[must_use]
pub fn compatibility_distance(
a: &TopologyGenome,
b: &TopologyGenome,
c1: f32,
c2: f32,
c3: f32,
) -> f32 {
let ca = &a.connections;
let cb = &b.connections;
if ca.is_empty() || cb.is_empty() {
let excess = ca.len().max(cb.len());
#[allow(clippy::cast_precision_loss)]
let n = if excess < 20 { 1.0 } else { excess as f32 };
#[allow(clippy::cast_precision_loss)]
return c1 * excess as f32 / n;
}
let max_a = ca.last().map_or(InnovationId::new(0), |c| c.innovation);
let max_b = cb.last().map_or(InnovationId::new(0), |c| c.innovation);
let (mut i, mut j) = (0usize, 0usize);
let (mut excess, mut disjoint, mut matching) = (0u32, 0u32, 0u32);
let mut weight_diff_sum = 0.0_f32;
while i < ca.len() && j < cb.len() {
match ca[i].innovation.cmp(&cb[j].innovation) {
std::cmp::Ordering::Equal => {
weight_diff_sum += (ca[i].weight - cb[j].weight).abs();
matching += 1;
i += 1;
j += 1;
}
std::cmp::Ordering::Less => {
if ca[i].innovation > max_b {
excess += 1;
} else {
disjoint += 1;
}
i += 1;
}
std::cmp::Ordering::Greater => {
if cb[j].innovation > max_a {
excess += 1;
} else {
disjoint += 1;
}
j += 1;
}
}
}
while i < ca.len() {
if ca[i].innovation > max_b {
excess += 1;
} else {
disjoint += 1;
}
i += 1;
}
while j < cb.len() {
if cb[j].innovation > max_a {
excess += 1;
} else {
disjoint += 1;
}
j += 1;
}
let n_genes = ca.len().max(cb.len());
#[allow(clippy::cast_precision_loss)]
{
let n = if n_genes < 20 { 1.0 } else { n_genes as f32 };
let w_bar = if matching > 0 {
weight_diff_sum / matching as f32
} else {
0.0
};
c1 * excess as f32 / n + c2 * disjoint as f32 / n + c3 * w_bar
}
}
#[allow(clippy::too_many_arguments)]
pub fn speciate(
population: &[TopologyGenome],
fitness: &[f32],
species: &mut Vec<Species>,
c1: f32,
c2: f32,
c3: f32,
compat_threshold: f32,
next_species_id: &mut SpeciesId,
generation: u64,
rng: &mut StdRng,
) {
assert_eq!(
population.len(),
fitness.len(),
"population and fitness must have equal length"
);
for s in species.iter_mut() {
s.members.clear();
s.adjusted_fitness_sum = 0.0;
}
for (i, genome) in population.iter().enumerate() {
let mut placed = false;
for s in species.iter_mut() {
if compatibility_distance(genome, &s.representative, c1, c2, c3) < compat_threshold {
s.members.push(i);
placed = true;
break;
}
}
if !placed {
let id = *next_species_id;
*next_species_id = id.succ();
species.push(Species {
id,
representative: genome.clone(),
members: vec![i],
best_fitness: f32::NEG_INFINITY,
last_improved_generation: generation,
adjusted_fitness_sum: 0.0,
});
}
}
species.retain(|s| !s.members.is_empty());
for s in species.iter_mut() {
let species_best = s
.members
.iter()
.map(|&i| crate::fitness::sanitize_fitness(fitness[i]))
.fold(f32::NEG_INFINITY, f32::max);
if species_best > s.best_fitness {
s.best_fitness = species_best;
s.last_improved_generation = generation;
}
let sum: f32 = s
.members
.iter()
.map(|&i| crate::fitness::sanitize_fitness(fitness[i]))
.sum();
#[allow(clippy::cast_precision_loss)]
let mean = sum / s.members.len() as f32;
s.adjusted_fitness_sum = mean;
}
for s in species.iter_mut() {
let pick = rng.random_range(0..s.members.len());
s.representative = population[s.members[pick]].clone();
}
}
pub fn remove_stagnant(species: &mut Vec<Species>, generation: u64, stagnation_limit: u64) {
if species.len() <= 1 {
return;
}
let mut order: Vec<usize> = (0..species.len()).collect();
let sane: Vec<f32> = species
.iter()
.map(|s| crate::fitness::sanitize_fitness(s.best_fitness))
.collect();
order.sort_by(|&a, &b| sane[b].total_cmp(&sane[a]));
let protected_count = STAGNATION_PROTECT_TOP_K.min(species.len());
let mut keep = vec![false; species.len()];
for &idx in order.iter().take(protected_count) {
keep[idx] = true;
}
for (idx, s) in species.iter().enumerate() {
if !keep[idx] {
let stagnant =
generation.saturating_sub(s.last_improved_generation) >= stagnation_limit;
keep[idx] = !stagnant;
}
}
if !keep.iter().any(|&k| k) {
keep[order[0]] = true;
}
let mut idx = 0usize;
species.retain(|_| {
let k = keep[idx];
idx += 1;
k
});
}
#[must_use]
pub fn allocate_offspring(species: &[Species], pop_size: usize) -> Vec<usize> {
let n = species.len();
if n == 0 {
return Vec::new();
}
let total: f32 = species
.iter()
.map(|s| s.adjusted_fitness_sum.max(0.0))
.sum();
if total <= 0.0 {
let base = pop_size / n;
let mut counts = vec![base; n];
let mut leftover = pop_size - base * n;
let mut k = 0usize;
while leftover > 0 {
counts[k % n] += 1;
k += 1;
leftover -= 1;
}
return counts;
}
let mut counts = vec![0usize; n];
let mut fracs: Vec<(usize, f32)> = Vec::with_capacity(n);
let mut assigned = 0usize;
for (i, s) in species.iter().enumerate() {
#[allow(clippy::cast_precision_loss)]
let share = pop_size as f32 * s.adjusted_fitness_sum.max(0.0) / total;
let base = share.floor();
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let base_usize = base as usize;
counts[i] = base_usize;
assigned += base_usize;
fracs.push((i, share - base));
}
fracs.sort_by(|a, b| {
b.1.total_cmp(&a.1).then_with(|| {
let (fa, fb) = (
crate::fitness::sanitize_fitness(species[a.0].best_fitness),
crate::fitness::sanitize_fitness(species[b.0].best_fitness),
);
fb.total_cmp(&fa)
})
});
match assigned.cmp(&pop_size) {
std::cmp::Ordering::Less => {
let mut leftover = pop_size - assigned;
let mut k = 0usize;
while leftover > 0 {
counts[fracs[k % n].0] += 1;
k += 1;
leftover -= 1;
}
}
std::cmp::Ordering::Greater => {
let mut excess = assigned - pop_size;
let mut k = 0usize;
while excess > 0 {
let idx = fracs[n - 1 - (k % n)].0;
if counts[idx] > 0 {
counts[idx] -= 1;
excess -= 1;
}
k += 1;
}
}
std::cmp::Ordering::Equal => {}
}
counts
}
#[cfg(test)]
mod tests {
use super::*;
use crate::neuroevolution::topology::{
ActivationFn, ConnectionGene, NodeGene, NodeId, NodeKind,
};
use rand::SeedableRng;
fn conn(innovation: u64, weight: f32) -> ConnectionGene {
ConnectionGene {
innovation: InnovationId::new(innovation),
source: NodeId::new(0),
target: NodeId::new(1),
weight,
enabled: true,
}
}
fn genome_with(conns: Vec<ConnectionGene>) -> TopologyGenome {
let nodes = vec![
NodeGene {
id: NodeId::new(0),
kind: NodeKind::Input,
activation: ActivationFn::Linear,
bias: 0.0,
},
NodeGene {
id: NodeId::new(1),
kind: NodeKind::Output,
activation: ActivationFn::Sigmoid,
bias: 0.0,
},
];
TopologyGenome::new(nodes, conns)
}
#[test]
fn test_compatibility_distance_identical_is_zero() {
let g = genome_with(vec![conn(0, 1.0), conn(1, -0.5)]);
approx::assert_relative_eq!(
compatibility_distance(&g, &g, 1.0, 1.0, 0.4),
0.0,
epsilon = 1e-6
);
}
#[test]
fn test_compatibility_distance_matching_weight_term() {
let a = genome_with(vec![conn(0, 0.0), conn(1, 0.0)]);
let b = genome_with(vec![conn(0, 1.0), conn(1, 2.0)]);
approx::assert_relative_eq!(
compatibility_distance(&a, &b, 1.0, 1.0, 1.0),
1.5,
epsilon = 1e-6
);
}
#[test]
fn test_compatibility_distance_disjoint_and_excess() {
let a = genome_with(vec![conn(0, 0.0), conn(1, 0.0), conn(2, 0.0)]);
let b = genome_with(vec![conn(0, 0.0), conn(3, 0.0)]);
approx::assert_relative_eq!(
compatibility_distance(&a, &b, 1.0, 1.0, 0.4),
3.0,
epsilon = 1e-6
);
}
fn species_with(id: u64, adjusted: f32, best: f32, last_improved: u64) -> Species {
Species {
id: SpeciesId::new(id),
representative: genome_with(vec![conn(0, 0.0)]),
members: vec![0],
best_fitness: best,
last_improved_generation: last_improved,
adjusted_fitness_sum: adjusted,
}
}
#[test]
fn test_compatibility_distance_empty_genome_branch() {
let full = genome_with((0..25).map(|k| conn(k, 0.0)).collect());
let empty = genome_with(vec![]);
approx::assert_relative_eq!(
compatibility_distance(&empty, &full, 1.0, 1.0, 0.4),
1.0,
epsilon = 1e-6
);
approx::assert_relative_eq!(
compatibility_distance(&full, &empty, 1.0, 1.0, 0.4),
1.0,
epsilon = 1e-6
);
approx::assert_relative_eq!(
compatibility_distance(&empty, &empty, 1.0, 1.0, 0.4),
0.0,
epsilon = 1e-6
);
}
#[test]
#[should_panic(expected = "population and fitness must have equal length")]
fn test_speciate_panics_on_length_mismatch() {
let mut rng = StdRng::seed_from_u64(0);
let population = vec![
genome_with(vec![conn(0, 0.0)]),
genome_with(vec![conn(0, 1.0)]),
];
let fitness = vec![1.0];
let mut species: Vec<Species> = Vec::new();
let mut next_id = SpeciesId::new(0);
speciate(
&population,
&fitness,
&mut species,
1.0,
1.0,
1.0,
1.0,
&mut next_id,
0,
&mut rng,
);
}
#[test]
fn test_allocate_offspring_single_species_gets_all() {
let species = vec![species_with(0, 3.0, 3.0, 0)];
let counts = allocate_offspring(&species, 32);
assert_eq!(counts, vec![32]);
}
#[test]
fn test_allocate_offspring_empty_species_returns_empty() {
let species: Vec<Species> = Vec::new();
let counts = allocate_offspring(&species, 32);
assert!(counts.is_empty(), "no species → no offspring counts");
}
#[test]
fn test_allocate_offspring_counts_len_equals_species_len() {
let species = vec![
species_with(0, 1.0, 1.0, 0),
species_with(1, 2.0, 1.0, 0),
species_with(2, 3.0, 1.0, 0),
];
let counts = allocate_offspring(&species, 20);
assert_eq!(
counts.len(),
species.len(),
"one count per species, positionally aligned"
);
}
#[test]
fn test_remove_stagnant_keeps_best_when_all_stagnant() {
let mut species = vec![
species_with(0, 1.0, 9.0, 0),
species_with(1, 1.0, 5.0, 0),
species_with(2, 1.0, 1.0, 0),
];
remove_stagnant(&mut species, 30, 15);
assert_eq!(
species.len(),
2,
"top-K protection keeps the population alive"
);
let ids: Vec<u64> = species.iter().map(|s| s.id().get()).collect();
assert!(ids.contains(&0), "highest-fitness species survives");
assert!(ids.contains(&1), "second-highest-fitness species survives");
assert!(
!ids.contains(&2),
"lowest-fitness stagnant species is removed"
);
assert!(!species.is_empty(), "removal never empties the population");
}
#[test]
fn test_remove_stagnant_single_species_is_noop() {
let mut species = vec![species_with(0, 1.0, 1.0, 0)];
remove_stagnant(&mut species, 1000, 15);
assert_eq!(species.len(), 1, "a single species is a no-op");
assert_eq!(species[0].id().get(), 0);
}
#[test]
fn test_allocate_offspring_sums_to_pop_size() {
let species = vec![
species_with(0, 1.0, 1.0, 0),
species_with(1, 2.0, 1.0, 0),
species_with(2, 3.0, 1.0, 0),
];
let counts = allocate_offspring(&species, 64);
assert_eq!(
counts.iter().sum::<usize>(),
64,
"apportionment must sum exactly"
);
assert!(counts[2] >= counts[1] && counts[1] >= counts[0]);
}
#[test]
fn test_allocate_offspring_zero_total_splits_evenly() {
let species = vec![
species_with(0, 0.0, 0.0, 0),
species_with(1, 0.0, 0.0, 0),
species_with(2, 0.0, 0.0, 0),
];
let counts = allocate_offspring(&species, 10);
assert_eq!(counts.iter().sum::<usize>(), 10);
assert_eq!(counts, vec![4, 3, 3]);
}
#[test]
fn test_remove_stagnant_protects_top_k() {
let mut species = vec![
species_with(0, 1.0, 9.0, 0),
species_with(1, 1.0, 5.0, 25),
species_with(2, 1.0, 1.0, 0),
];
remove_stagnant(&mut species, 30, 15);
let ids: Vec<u64> = species.iter().map(|s| s.id().get()).collect();
assert!(
ids.contains(&0),
"top-fitness stagnant species is protected"
);
assert!(ids.contains(&1), "recently-improved species survives");
assert!(!ids.contains(&2), "low-fitness stagnant species is removed");
}
#[test]
fn test_speciate_assigns_to_first_compatible_representative() {
let mut rng = StdRng::seed_from_u64(0);
let g0 = genome_with(vec![conn(0, 0.0)]);
let g1 = genome_with(vec![conn(0, 10.0)]);
let g0_clone = genome_with(vec![conn(0, 0.05)]);
let population = vec![g0, g1, g0_clone];
let fitness = vec![1.0, 1.0, 1.0];
let mut species: Vec<Species> = Vec::new();
let mut next_id = SpeciesId::new(0);
speciate(
&population,
&fitness,
&mut species,
1.0,
1.0,
1.0,
1.0,
&mut next_id,
0,
&mut rng,
);
assert_eq!(species.len(), 2, "distinct genome forms its own species");
let sizes: Vec<usize> = species.iter().map(|s| s.members.len()).collect();
assert!(
sizes.contains(&2) && sizes.contains(&1),
"g0 and its clone share a species"
);
}
#[test]
fn test_speciate_sanitizes_nan_and_inf_fitness() {
let mut rng = StdRng::seed_from_u64(0);
let population = vec![
genome_with(vec![conn(0, 0.0)]),
genome_with(vec![conn(0, 0.01)]),
genome_with(vec![conn(0, 0.02)]),
];
let fitness = vec![f32::NAN, f32::INFINITY, 2.0];
let mut species: Vec<Species> = Vec::new();
let mut next_id = SpeciesId::new(0);
speciate(
&population,
&fitness,
&mut species,
1.0,
1.0,
1.0,
1.0,
&mut next_id,
0,
&mut rng,
);
assert_eq!(species.len(), 1, "near-clones form a single species");
let s = &species[0];
assert!(
!s.best_fitness.is_nan(),
"a NaN member never becomes a species best"
);
approx::assert_relative_eq!(s.best_fitness, f32::MAX);
assert!(
!s.adjusted_fitness_sum.is_nan(),
"a NaN member never poisons adjusted_fitness_sum"
);
let counts = allocate_offspring(&species, 8);
assert_eq!(
counts.iter().sum::<usize>(),
8,
"offspring apportionment sums exactly, uncorrupted by a NaN member"
);
}
}