use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
use parking_lot::Mutex;
use rlevo_core::objective::ObjectiveSense;
use super::fitness::CoupledFitness;
use crate::fitness::sanitize_fitness;
#[derive(Debug, Clone)]
pub struct HallOfFame<B: Backend> {
archives: Vec<Tensor<B, 2>>,
archive_fitness: Vec<Vec<f32>>,
capacity: usize,
}
impl<B: Backend> HallOfFame<B> {
#[must_use]
pub fn new(
num_populations: usize,
capacity: usize,
genome_dim: usize,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Self {
let archives = (0..num_populations)
.map(|_| Tensor::<B, 2>::empty([0, genome_dim], device))
.collect();
let archive_fitness = vec![Vec::new(); num_populations];
Self {
archives,
archive_fitness,
capacity,
}
}
#[must_use]
pub fn capacity_for(pop_size: usize) -> usize {
(pop_size / 5).max(10)
}
pub fn update(&mut self, populations: &[Tensor<B, 2>], fitnesses: &[Tensor<B, 1>]) {
let n = self
.archives
.len()
.min(populations.len())
.min(fitnesses.len());
for p in 0..n {
let fit_host = fitnesses[p]
.clone()
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32");
if fit_host.is_empty() {
continue;
}
let sane: Vec<f32> = fit_host.iter().map(|&f| sanitize_fitness(f)).collect();
let mut best_idx = 0_usize;
for i in 1..sane.len() {
if sane[i].total_cmp(&sane[best_idx]) == std::cmp::Ordering::Greater {
best_idx = i;
}
}
let best_f = sane[best_idx];
let device = populations[p].device();
#[allow(clippy::cast_possible_wrap)]
let idx = Tensor::<B, 1, Int>::from_data(
TensorData::new(vec![best_idx as i64], [1]),
&device,
);
let champion = populations[p].clone().select(0, idx);
self.archives[p] = if self.archives[p].dims()[0] == 0 {
champion
} else {
Tensor::cat(vec![self.archives[p].clone(), champion], 0)
};
self.archive_fitness[p].push(best_f);
if self.archive_fitness[p].len() > self.capacity {
let Some(worst_idx) = self.archive_fitness[p]
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.total_cmp(b))
.map(|(i, _)| i)
else {
continue;
};
let len = self.archive_fitness[p].len();
#[allow(clippy::cast_possible_wrap)]
let keep: Vec<i64> = (0..len)
.filter(|&i| i != worst_idx)
.map(|i| i as i64)
.collect();
let keep_len = keep.len();
let keep_idx =
Tensor::<B, 1, Int>::from_data(TensorData::new(keep, [keep_len]), &device);
self.archives[p] = self.archives[p].clone().select(0, keep_idx);
self.archive_fitness[p].remove(worst_idx);
}
}
}
#[must_use]
pub fn archives(&self) -> &[Tensor<B, 2>] {
&self.archives
}
#[must_use]
pub fn capacity(&self) -> usize {
self.capacity
}
}
pub struct HallOfFameFitness<B: Backend, F: CoupledFitness<B>> {
inner: F,
hall: Mutex<HallOfFame<B>>,
hof_blend_weight: f32,
}
impl<B: Backend, F: CoupledFitness<B>> std::fmt::Debug for HallOfFameFitness<B, F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HallOfFameFitness")
.field("hof_blend_weight", &self.hof_blend_weight)
.finish_non_exhaustive()
}
}
impl<B: Backend, F: CoupledFitness<B>> HallOfFameFitness<B, F> {
pub const DEFAULT_BLEND_WEIGHT: f32 = 0.3;
#[must_use]
pub fn new(
inner: F,
num_populations: usize,
pop_size: usize,
genome_dim: usize,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Self {
let capacity = HallOfFame::<B>::capacity_for(pop_size);
let hall = HallOfFame::new(num_populations, capacity, genome_dim, device);
Self {
inner,
hall: Mutex::new(hall),
hof_blend_weight: Self::DEFAULT_BLEND_WEIGHT,
}
}
#[must_use]
pub fn with_blend_weight(mut self, weight: f32) -> Self {
self.hof_blend_weight = weight.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn blend_weight(&self) -> f32 {
self.hof_blend_weight
}
}
fn blend<B: Backend>(cur: &Tensor<B, 1>, hof: &Tensor<B, 1>, w: f32) -> Tensor<B, 1> {
cur.clone()
.mul_scalar(1.0 - w)
.add(hof.clone().mul_scalar(w))
}
impl<B: Backend, F: CoupledFitness<B>> CoupledFitness<B> for HallOfFameFitness<B, F> {
fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 1>> {
debug_assert_eq!(populations.len(), 2, "v1 hall-of-fame is bi-population");
let sense = self.inner.sense();
let current = self.inner.evaluate_coupled(populations); let w = self.hof_blend_weight;
let current_canon: Vec<Tensor<B, 1>> = current
.iter()
.map(|t| match sense {
ObjectiveSense::Maximize => t.clone(),
ObjectiveSense::Minimize => t.clone().neg(),
})
.collect();
let (archive_a, archive_b) = {
let hall = self.hall.lock();
(hall.archives()[0].clone(), hall.archives()[1].clone())
};
let blended = if w <= 0.0 {
current.clone()
} else {
let blended_a = if archive_b.dims()[0] > 0 {
let res = self
.inner
.evaluate_coupled(&[populations[0].clone(), archive_b]);
blend(¤t[0], &res[0], w)
} else {
current[0].clone()
};
let blended_b = if archive_a.dims()[0] > 0 {
let res = self
.inner
.evaluate_coupled(&[archive_a, populations[1].clone()]);
blend(¤t[1], &res[1], w)
} else {
current[1].clone()
};
vec![blended_a, blended_b]
};
self.hall.lock().update(populations, ¤t_canon);
blended
}
fn sense(&self) -> ObjectiveSense {
self.inner.sense()
}
fn archive_sizes(&self) -> Vec<usize> {
self.hall
.lock()
.archives()
.iter()
.map(|a| a.dims()[0])
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use burn::backend::Flex;
type B = Flex;
fn pop(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)
}
fn fit(values: &[f32]) -> Tensor<B, 1> {
let device = Default::default();
Tensor::<B, 1>::from_data(TensorData::new(values.to_vec(), [values.len()]), &device)
}
#[test]
fn capacity_formula() {
assert_eq!(HallOfFame::<B>::capacity_for(10), 10);
assert_eq!(HallOfFame::<B>::capacity_for(50), 10);
assert_eq!(HallOfFame::<B>::capacity_for(100), 20);
assert_eq!(HallOfFame::<B>::capacity_for(0), 10);
}
#[test]
fn archive_grows_to_capacity_then_prunes_worst() {
let device = Default::default();
let mut hof = HallOfFame::<B>::new(2, 3, 1, &device);
for g in 0..5_usize {
#[allow(clippy::cast_precision_loss)]
let p = pop(&[g as f32, g as f32 + 0.5], 2, 1);
#[allow(clippy::cast_precision_loss)]
let f = fit(&[(5 - g) as f32, -100.0]);
hof.update(&[p.clone(), p], &[f.clone(), f]);
assert!(
hof.archives()[0].dims()[0] <= 3,
"archive exceeded capacity at gen {g}"
);
}
assert_eq!(hof.archives()[0].dims()[0], 3);
let mut surviving = hof.archive_fitness[0].clone();
surviving.sort_by(f32::total_cmp);
assert_eq!(surviving, vec![3.0, 4.0, 5.0]);
}
struct RowSum;
impl CoupledFitness<B> for RowSum {
fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 1>> {
populations
.iter()
.map(|p| p.clone().sum_dim(1).squeeze_dim::<1>(1))
.collect()
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Maximize
}
}
#[test]
fn wrapper_reports_archive_sizes_and_grows() {
let device = Default::default();
let wrapper = HallOfFameFitness::new(RowSum, 2, 50, 2, &device);
assert_eq!(wrapper.archive_sizes(), vec![0, 0]);
let a = pop(&[1.0, 1.0, 2.0, 2.0], 2, 2);
let b = pop(&[0.0, 0.0, 3.0, 3.0], 2, 2);
let out = wrapper.evaluate_coupled(&[a.clone(), b.clone()]);
assert_eq!(out.len(), 2);
assert_eq!(out[0].dims(), [2]);
assert_eq!(wrapper.archive_sizes(), vec![1, 1]);
}
struct MinCost;
impl CoupledFitness<B> for MinCost {
fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 1>> {
populations
.iter()
.map(|p| {
p.clone().narrow(1, 0, 1).squeeze_dim::<1>(1)
})
.collect()
}
fn sense(&self) -> ObjectiveSense {
ObjectiveSense::Minimize
}
}
#[test]
fn minimize_archives_lowest_cost_champion() {
let device = Default::default();
let wrapper = HallOfFameFitness::new(MinCost, 2, 50, 1, &device);
let a = pop(&[3.0, 1.0, 5.0], 3, 1);
let b = pop(&[3.0, 1.0, 5.0], 3, 1);
let _ = wrapper.evaluate_coupled(&[a, b]);
let champ = {
let hall = wrapper.hall.lock();
hall.archives()[0]
.clone()
.into_data()
.into_vec::<f32>()
.expect("archived champion host-read")
};
assert_eq!(
champ,
vec![1.0],
"Minimize champion must be the min-cost genome (1.0), not the max-cost one"
);
}
#[test]
fn minimize_archives_lowest_cost_champion_even_at_zero_blend() {
let device = Default::default();
let wrapper = HallOfFameFitness::new(MinCost, 2, 50, 1, &device).with_blend_weight(0.0);
let a = pop(&[3.0, 1.0, 5.0], 3, 1);
let b = pop(&[3.0, 1.0, 5.0], 3, 1);
let _ = wrapper.evaluate_coupled(&[a, b]);
let champ = {
let hall = wrapper.hall.lock();
hall.archives()[0]
.clone()
.into_data()
.into_vec::<f32>()
.expect("archived champion host-read")
};
assert_eq!(
champ,
vec![1.0],
"at w=0 the Minimize champion must still be the min-cost genome (1.0), \
proving canonicalisation reaches champion selection with blending disabled"
);
}
#[test]
fn blend_zero_passes_through_current_fitness() {
let device = Default::default();
let wrapper = HallOfFameFitness::new(RowSum, 2, 50, 2, &device).with_blend_weight(0.0);
let a = pop(&[1.0, 1.0, 2.0, 2.0], 2, 2);
let b = pop(&[0.0, 0.0, 3.0, 3.0], 2, 2);
let _ = wrapper.evaluate_coupled(&[a.clone(), b.clone()]);
let out = wrapper.evaluate_coupled(&[a, b]);
let va = out[0]
.clone()
.into_data()
.into_vec::<f32>()
.expect("fitness host-read of a tensor this test just built");
assert_eq!(va, vec![2.0, 4.0]);
}
}