use burn::tensor::{Tensor, TensorData, backend::Backend};
use rand::{Rng, RngExt};
use rlevo_core::config::{self, ConfigError};
use crate::probability_model::ProbabilityModel;
#[derive(Debug, Clone)]
pub struct CompactGeneticParams {
pub genome_dim: usize,
pub virtual_pop_size: usize,
}
impl CompactGeneticParams {
#[must_use]
pub fn default_for(genome_dim: usize) -> Self {
Self {
genome_dim,
virtual_pop_size: 50,
}
}
}
#[derive(Debug, Clone)]
pub struct CompactGeneticState {
prob: Vec<f32>,
}
impl CompactGeneticState {
pub fn try_new(prob: Vec<f32>) -> Result<Self, ConfigError> {
config::nonzero("CompactGeneticState", "prob", prob.len())?;
for &p in &prob {
config::in_range("CompactGeneticState", "prob", 0.0, 1.0, f64::from(p))?;
}
Ok(Self { prob })
}
#[must_use]
pub fn prob(&self) -> &[f32] {
&self.prob
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CompactGenetic;
impl<B: Backend> ProbabilityModel<B> for CompactGenetic {
type Params = CompactGeneticParams;
type State = CompactGeneticState;
fn fit(
&self,
params: &Self::Params,
prev: Option<&Self::State>,
population: Tensor<B, 2>,
fitness: Tensor<B, 1>,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Self::State {
let _ = device;
let Some(prev) = prev else {
let _ = (population, fitness);
return CompactGeneticState {
prob: vec![0.5; params.genome_dim],
};
};
let [k, d] = population.dims();
let rows = population
.into_data()
.into_vec::<f32>()
.expect("population tensor must be readable as f32");
let fit_host = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32");
let mut winner_idx = 0_usize;
let mut loser_idx = 0_usize;
let mut best_f = f32::NEG_INFINITY;
let mut worst_f = f32::INFINITY;
for i in 0..k {
let f = crate::fitness::sanitize_fitness(
fit_host.get(i).copied().unwrap_or(f32::NEG_INFINITY),
);
if f.total_cmp(&best_f) == std::cmp::Ordering::Greater {
best_f = f;
winner_idx = i;
}
if f.total_cmp(&worst_f) == std::cmp::Ordering::Less {
worst_f = f;
loser_idx = i;
}
}
#[allow(clippy::cast_precision_loss)]
let step = 1.0 / params.virtual_pop_size as f32;
let mut prob = prev.prob.clone();
for j in 0..d {
let winner = rows[winner_idx * d + j];
let loser = rows[loser_idx * d + j];
if (winner - loser).abs() > 0.5 {
if winner > 0.5 {
prob[j] += step;
} else {
prob[j] -= step;
}
prob[j] = prob[j].clamp(0.0, 1.0);
}
}
CompactGeneticState { prob }
}
fn sample(
&self,
state: &Self::State,
n: usize,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 2> {
let d = state.prob.len();
let mut rows = Vec::with_capacity(n * d);
for _ in 0..n {
for &p in &state.prob {
let gene = if rng.random::<f32>() < p { 1.0 } else { 0.0 };
rows.push(gene);
}
}
Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
}
}
#[cfg(test)]
mod tests {
use super::*;
use burn::backend::Flex;
use rand::SeedableRng;
use rand::rngs::StdRng;
type TestBackend = Flex;
fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
let device = Default::default();
Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
}
fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
let device = Default::default();
let n = values.len();
Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
}
fn fit_prior(p: &CompactGeneticParams) -> CompactGeneticState {
let device = Default::default();
<CompactGenetic as ProbabilityModel<TestBackend>>::fit(
&CompactGenetic,
p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
)
}
#[test]
fn prior_is_half() {
let p = CompactGeneticParams::default_for(3);
assert_eq!(fit_prior(&p).prob, vec![0.5, 0.5, 0.5]);
}
#[test]
fn try_new_accepts_valid_and_rejects_out_of_range() {
let state = CompactGeneticState::try_new(vec![0.0, 0.5, 1.0]).unwrap();
assert_eq!(state.prob(), &[0.0, 0.5, 1.0]);
assert!(CompactGeneticState::try_new(vec![]).is_err());
assert!(CompactGeneticState::try_new(vec![0.5, 1.5]).is_err());
assert!(CompactGeneticState::try_new(vec![-0.1]).is_err());
assert!(CompactGeneticState::try_new(vec![f32::NAN]).is_err());
}
#[test]
fn nudge_is_exactly_one_over_vps() {
let device = Default::default();
let p = CompactGeneticParams {
genome_dim: 2,
virtual_pop_size: 10,
};
let prior = fit_prior(&p);
let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
&CompactGenetic,
&p,
Some(&prior),
pop(vec![1.0, 0.0, 0.0, 1.0], 2, 2),
fitness(vec![1.0, 0.0]),
&device,
);
approx::assert_relative_eq!(state.prob[0], 0.6, epsilon = 1e-6);
approx::assert_relative_eq!(state.prob[1], 0.4, epsilon = 1e-6);
}
#[test]
fn clamp_at_zero_and_one() {
let device = Default::default();
let p = CompactGeneticParams {
genome_dim: 2,
virtual_pop_size: 2,
};
let mut state = CompactGeneticState {
prob: vec![0.9, 0.1],
};
for _ in 0..3 {
state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
&CompactGenetic,
&p,
Some(&state),
pop(vec![1.0, 0.0, 0.0, 1.0], 2, 2),
fitness(vec![1.0, 0.0]),
&device,
);
}
approx::assert_relative_eq!(state.prob[0], 1.0, epsilon = 1e-6);
approx::assert_relative_eq!(state.prob[1], 0.0, epsilon = 1e-6);
}
#[test]
fn genes_where_winner_equals_loser_untouched() {
let device = Default::default();
let p = CompactGeneticParams {
genome_dim: 2,
virtual_pop_size: 10,
};
let prior = fit_prior(&p);
let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
&CompactGenetic,
&p,
Some(&prior),
pop(vec![1.0, 1.0, 1.0, 0.0], 2, 2),
fitness(vec![1.0, 0.0]),
&device,
);
approx::assert_relative_eq!(state.prob[0], 0.5, epsilon = 1e-6);
approx::assert_relative_eq!(state.prob[1], 0.6, epsilon = 1e-6);
}
#[test]
fn not_the_column_mean() {
let device = Default::default();
let p = CompactGeneticParams {
genome_dim: 1,
virtual_pop_size: 10,
};
let prior = fit_prior(&p);
let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
&CompactGenetic,
&p,
Some(&prior),
pop(vec![1.0, 0.0, 0.0], 3, 1),
fitness(vec![2.0, 1.0, 0.0]),
&device,
);
approx::assert_relative_eq!(state.prob[0], 0.6, epsilon = 1e-6);
assert!((state.prob[0] - 1.0 / 3.0).abs() > 0.2);
}
#[test]
fn samples_are_binary() {
let device = Default::default();
let state = CompactGeneticState {
prob: vec![0.2, 0.8],
};
let mut rng = StdRng::seed_from_u64(11);
let samples = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
&CompactGenetic,
&state,
300,
&mut rng,
&device,
);
for v in samples
.into_data()
.into_vec::<f32>()
.expect("samples host-read of a tensor this test just built")
{
#[allow(clippy::float_cmp)]
let is_binary = v == 0.0 || v == 1.0;
assert!(is_binary);
}
}
#[test]
fn nan_fitness_not_selected_as_winner() {
let device = Default::default();
let p = CompactGeneticParams::default_for(2);
let prior = fit_prior(&p);
let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
&CompactGenetic,
&p,
Some(&prior),
pop(vec![1.0, 1.0, 0.0, 0.0], 2, 2),
fitness(vec![f32::NAN, 5.0]),
&device,
);
for &pj in &state.prob {
assert!(
pj.is_finite() && (0.0..=1.0).contains(&pj),
"prob out of range: {pj}"
);
assert!(
pj < 0.5,
"winner should be the finite-fitness zero row, got {pj}"
);
}
}
#[test]
fn sample_respects_probabilities() {
let device = Default::default();
let prob: Vec<f32> = vec![0.1, 0.5, 0.9];
let d = prob.len();
let state = CompactGeneticState { prob: prob.clone() };
let mut rng = StdRng::seed_from_u64(42);
let n = 20_000_usize;
let samples = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
&CompactGenetic,
&state,
n,
&mut rng,
&device,
);
let data = samples
.into_data()
.into_vec::<f32>()
.expect("samples host-read of a tensor this test just built");
#[allow(clippy::cast_precision_loss)]
let nf = n as f32;
for j in 0..d {
let mut sum = 0.0_f32;
for i in 0..n {
sum += data[i * d + j];
}
let freq = sum / nf;
approx::assert_abs_diff_eq!(freq, prob[j], epsilon = 0.02);
}
}
#[test]
fn zero_population_with_prev_returns_prev_unchanged() {
let device = Default::default();
let p = CompactGeneticParams::default_for(3);
let prev = CompactGeneticState {
prob: vec![0.25, 0.5, 0.75],
};
let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
&CompactGenetic,
&p,
Some(&prev),
pop(vec![], 0, 0),
fitness(vec![]),
&device,
);
assert_eq!(
state.prob, prev.prob,
"zero population must leave probabilities unchanged"
);
}
#[test]
fn fit_uses_population_dims_not_params_genome_dim() {
let device = Default::default();
let p = CompactGeneticParams {
genome_dim: 9,
virtual_pop_size: 10,
};
let prev = CompactGeneticState {
prob: vec![0.5, 0.5],
};
let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
&CompactGenetic,
&p,
Some(&prev),
pop(vec![1.0, 0.0, 0.0, 1.0], 2, 2),
fitness(vec![1.0, 0.0]),
&device,
);
assert_eq!(
state.prob.len(),
2,
"output length follows the population/prev, not params.genome_dim"
);
}
#[test]
fn sample_is_deterministic_for_seed_and_state() {
let device = Default::default();
let state = CompactGeneticState {
prob: vec![0.3, 0.6, 0.9],
};
let mut rng_a = StdRng::seed_from_u64(77);
let mut rng_b = StdRng::seed_from_u64(77);
let a = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
&CompactGenetic,
&state,
256,
&mut rng_a,
&device,
);
let b = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
&CompactGenetic,
&state,
256,
&mut rng_b,
&device,
);
let data_a = a
.into_data()
.into_vec::<f32>()
.expect("samples host-read of a tensor this test just built");
let data_b = b
.into_data()
.into_vec::<f32>()
.expect("samples host-read of a tensor this test just built");
assert_eq!(
data_a, data_b,
"same seed + state must produce identical output"
);
}
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
#[test]
fn prob_stays_in_unit_interval(
genome_dim in 1usize..=16,
virtual_pop_size in 1usize..=200,
iters in 1u32..=50,
seed in any::<u64>(),
) {
let device = Default::default();
let params = CompactGeneticParams {
genome_dim,
virtual_pop_size,
};
let mut state = fit_prior(¶ms);
let mut rng = StdRng::seed_from_u64(seed);
for _ in 0..iters {
let k: usize = rng.random_range(2..=16);
let rows: Vec<f32> = (0..k * genome_dim)
.map(|_| if rng.random_bool(0.5) { 1.0 } else { 0.0 })
.collect();
let fit_values: Vec<f32> = (0..k).map(|_| rng.random::<f32>()).collect();
state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
&CompactGenetic,
¶ms,
Some(&state),
pop(rows, k, genome_dim),
fitness(fit_values),
&device,
);
prop_assert!(
state.prob().iter().all(|&p| (0.0..=1.0).contains(&p)),
"prob escaped [0, 1] after fit: {:?}",
state.prob()
);
}
}
}
}