1use burn::tensor::{Tensor, TensorData, backend::Backend};
28use rand::{Rng, RngExt};
29use rlevo_core::config::{self, ConfigError};
30
31use crate::probability_model::ProbabilityModel;
32
33#[derive(Debug, Clone)]
39pub struct CompactGeneticParams {
40 pub genome_dim: usize,
43 pub virtual_pop_size: usize,
48}
49
50impl CompactGeneticParams {
51 #[must_use]
53 pub fn default_for(genome_dim: usize) -> Self {
54 Self {
55 genome_dim,
56 virtual_pop_size: 50,
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
73pub struct CompactGeneticState {
74 prob: Vec<f32>,
76}
77
78impl CompactGeneticState {
79 pub fn try_new(prob: Vec<f32>) -> Result<Self, ConfigError> {
86 config::nonzero("CompactGeneticState", "prob", prob.len())?;
87 for &p in &prob {
88 config::in_range("CompactGeneticState", "prob", 0.0, 1.0, f64::from(p))?;
89 }
90 Ok(Self { prob })
91 }
92
93 #[must_use]
95 pub fn prob(&self) -> &[f32] {
96 &self.prob
97 }
98}
99
100#[derive(Debug, Clone, Copy, Default)]
110pub struct CompactGenetic;
111
112impl<B: Backend> ProbabilityModel<B> for CompactGenetic {
113 type Params = CompactGeneticParams;
114 type State = CompactGeneticState;
115
116 fn fit(
132 &self,
133 params: &Self::Params,
134 prev: Option<&Self::State>,
135 population: Tensor<B, 2>,
136 fitness: Tensor<B, 1>,
137 device: &<B as burn::tensor::backend::BackendTypes>::Device,
138 ) -> Self::State {
139 let _ = device;
140 let Some(prev) = prev else {
141 let _ = (population, fitness);
143 return CompactGeneticState {
144 prob: vec![0.5; params.genome_dim],
145 };
146 };
147
148 let [k, d] = population.dims();
149 let rows = population
150 .into_data()
151 .into_vec::<f32>()
152 .expect("population tensor must be readable as f32");
153 let fit_host = fitness
154 .into_data()
155 .into_vec::<f32>()
156 .expect("fitness tensor must be readable as f32");
157
158 let mut winner_idx = 0_usize;
161 let mut loser_idx = 0_usize;
162 let mut best_f = f32::NEG_INFINITY;
163 let mut worst_f = f32::INFINITY;
164 for i in 0..k {
165 let f = crate::fitness::sanitize_fitness(
171 fit_host.get(i).copied().unwrap_or(f32::NEG_INFINITY),
172 );
173 if f.total_cmp(&best_f) == std::cmp::Ordering::Greater {
174 best_f = f;
175 winner_idx = i;
176 }
177 if f.total_cmp(&worst_f) == std::cmp::Ordering::Less {
178 worst_f = f;
179 loser_idx = i;
180 }
181 }
182
183 #[allow(clippy::cast_precision_loss)]
186 let step = 1.0 / params.virtual_pop_size as f32;
187 let mut prob = prev.prob.clone();
188 for j in 0..d {
189 let winner = rows[winner_idx * d + j];
190 let loser = rows[loser_idx * d + j];
191 if (winner - loser).abs() > 0.5 {
193 if winner > 0.5 {
194 prob[j] += step;
195 } else {
196 prob[j] -= step;
197 }
198 prob[j] = prob[j].clamp(0.0, 1.0);
199 }
200 }
201
202 CompactGeneticState { prob }
203 }
204
205 fn sample(
217 &self,
218 state: &Self::State,
219 n: usize,
220 rng: &mut dyn Rng,
221 device: &<B as burn::tensor::backend::BackendTypes>::Device,
222 ) -> Tensor<B, 2> {
223 let d = state.prob.len();
224 let mut rows = Vec::with_capacity(n * d);
225 for _ in 0..n {
227 for &p in &state.prob {
228 let gene = if rng.random::<f32>() < p { 1.0 } else { 0.0 };
229 rows.push(gene);
230 }
231 }
232 Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use burn::backend::Flex;
240 use rand::SeedableRng;
241 use rand::rngs::StdRng;
242
243 type TestBackend = Flex;
244
245 fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
246 let device = Default::default();
247 Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
248 }
249
250 fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
251 let device = Default::default();
252 let n = values.len();
253 Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
254 }
255
256 fn fit_prior(p: &CompactGeneticParams) -> CompactGeneticState {
257 let device = Default::default();
258 <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
259 &CompactGenetic,
260 p,
261 None,
262 pop(vec![], 0, 0),
263 fitness(vec![]),
264 &device,
265 )
266 }
267
268 #[test]
269 fn prior_is_half() {
270 let p = CompactGeneticParams::default_for(3);
271 assert_eq!(fit_prior(&p).prob, vec![0.5, 0.5, 0.5]);
272 }
273
274 #[test]
275 fn try_new_accepts_valid_and_rejects_out_of_range() {
276 let state = CompactGeneticState::try_new(vec![0.0, 0.5, 1.0]).unwrap();
277 assert_eq!(state.prob(), &[0.0, 0.5, 1.0]);
278 assert!(CompactGeneticState::try_new(vec![]).is_err());
279 assert!(CompactGeneticState::try_new(vec![0.5, 1.5]).is_err());
280 assert!(CompactGeneticState::try_new(vec![-0.1]).is_err());
281 assert!(CompactGeneticState::try_new(vec![f32::NAN]).is_err());
282 }
283
284 #[test]
285 fn nudge_is_exactly_one_over_vps() {
286 let device = Default::default();
287 let p = CompactGeneticParams {
288 genome_dim: 2,
289 virtual_pop_size: 10,
290 };
291 let prior = fit_prior(&p);
292 let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
295 &CompactGenetic,
296 &p,
297 Some(&prior),
298 pop(vec![1.0, 0.0, 0.0, 1.0], 2, 2),
299 fitness(vec![1.0, 0.0]),
300 &device,
301 );
302 approx::assert_relative_eq!(state.prob[0], 0.6, epsilon = 1e-6);
303 approx::assert_relative_eq!(state.prob[1], 0.4, epsilon = 1e-6);
304 }
305
306 #[test]
307 fn clamp_at_zero_and_one() {
308 let device = Default::default();
309 let p = CompactGeneticParams {
310 genome_dim: 2,
311 virtual_pop_size: 2,
312 };
313 let mut state = CompactGeneticState {
314 prob: vec![0.9, 0.1],
315 };
316 for _ in 0..3 {
319 state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
320 &CompactGenetic,
321 &p,
322 Some(&state),
323 pop(vec![1.0, 0.0, 0.0, 1.0], 2, 2),
324 fitness(vec![1.0, 0.0]),
325 &device,
326 );
327 }
328 approx::assert_relative_eq!(state.prob[0], 1.0, epsilon = 1e-6);
329 approx::assert_relative_eq!(state.prob[1], 0.0, epsilon = 1e-6);
330 }
331
332 #[test]
333 fn genes_where_winner_equals_loser_untouched() {
334 let device = Default::default();
335 let p = CompactGeneticParams {
336 genome_dim: 2,
337 virtual_pop_size: 10,
338 };
339 let prior = fit_prior(&p);
340 let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
343 &CompactGenetic,
344 &p,
345 Some(&prior),
346 pop(vec![1.0, 1.0, 1.0, 0.0], 2, 2),
347 fitness(vec![1.0, 0.0]),
348 &device,
349 );
350 approx::assert_relative_eq!(state.prob[0], 0.5, epsilon = 1e-6);
351 approx::assert_relative_eq!(state.prob[1], 0.6, epsilon = 1e-6);
352 }
353
354 #[test]
355 fn not_the_column_mean() {
356 let device = Default::default();
357 let p = CompactGeneticParams {
358 genome_dim: 1,
359 virtual_pop_size: 10,
360 };
361 let prior = fit_prior(&p);
362 let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
367 &CompactGenetic,
368 &p,
369 Some(&prior),
370 pop(vec![1.0, 0.0, 0.0], 3, 1),
371 fitness(vec![2.0, 1.0, 0.0]),
372 &device,
373 );
374 approx::assert_relative_eq!(state.prob[0], 0.6, epsilon = 1e-6);
375 assert!((state.prob[0] - 1.0 / 3.0).abs() > 0.2);
376 }
377
378 #[test]
379 fn samples_are_binary() {
380 let device = Default::default();
381 let state = CompactGeneticState {
382 prob: vec![0.2, 0.8],
383 };
384 let mut rng = StdRng::seed_from_u64(11);
385 let samples = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
386 &CompactGenetic,
387 &state,
388 300,
389 &mut rng,
390 &device,
391 );
392 for v in samples
393 .into_data()
394 .into_vec::<f32>()
395 .expect("samples host-read of a tensor this test just built")
396 {
397 #[allow(clippy::float_cmp)]
399 let is_binary = v == 0.0 || v == 1.0;
400 assert!(is_binary);
401 }
402 }
403
404 #[test]
405 fn nan_fitness_not_selected_as_winner() {
406 let device = Default::default();
411 let p = CompactGeneticParams::default_for(2);
412 let prior = fit_prior(&p);
413 let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
414 &CompactGenetic,
415 &p,
416 Some(&prior),
417 pop(vec![1.0, 1.0, 0.0, 0.0], 2, 2),
418 fitness(vec![f32::NAN, 5.0]),
419 &device,
420 );
421 for &pj in &state.prob {
422 assert!(
423 pj.is_finite() && (0.0..=1.0).contains(&pj),
424 "prob out of range: {pj}"
425 );
426 assert!(
427 pj < 0.5,
428 "winner should be the finite-fitness zero row, got {pj}"
429 );
430 }
431 }
432
433 #[test]
434 fn sample_respects_probabilities() {
435 let device = Default::default();
438 let prob: Vec<f32> = vec![0.1, 0.5, 0.9];
439 let d = prob.len();
440 let state = CompactGeneticState { prob: prob.clone() };
441 let mut rng = StdRng::seed_from_u64(42);
442 let n = 20_000_usize;
443 let samples = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
444 &CompactGenetic,
445 &state,
446 n,
447 &mut rng,
448 &device,
449 );
450 let data = samples
451 .into_data()
452 .into_vec::<f32>()
453 .expect("samples host-read of a tensor this test just built");
454 #[allow(clippy::cast_precision_loss)]
457 let nf = n as f32;
458 for j in 0..d {
459 let mut sum = 0.0_f32;
460 for i in 0..n {
461 sum += data[i * d + j];
462 }
463 let freq = sum / nf;
464 approx::assert_abs_diff_eq!(freq, prob[j], epsilon = 0.02);
465 }
466 }
467
468 #[test]
469 fn zero_population_with_prev_returns_prev_unchanged() {
470 let device = Default::default();
473 let p = CompactGeneticParams::default_for(3);
474 let prev = CompactGeneticState {
475 prob: vec![0.25, 0.5, 0.75],
476 };
477 let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
478 &CompactGenetic,
479 &p,
480 Some(&prev),
481 pop(vec![], 0, 0),
482 fitness(vec![]),
483 &device,
484 );
485 assert_eq!(
486 state.prob, prev.prob,
487 "zero population must leave probabilities unchanged"
488 );
489 }
490
491 #[test]
492 fn fit_uses_population_dims_not_params_genome_dim() {
493 let device = Default::default();
498 let p = CompactGeneticParams {
499 genome_dim: 9,
500 virtual_pop_size: 10,
501 };
502 let prev = CompactGeneticState {
503 prob: vec![0.5, 0.5],
504 };
505 let state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
506 &CompactGenetic,
507 &p,
508 Some(&prev),
509 pop(vec![1.0, 0.0, 0.0, 1.0], 2, 2),
510 fitness(vec![1.0, 0.0]),
511 &device,
512 );
513 assert_eq!(
514 state.prob.len(),
515 2,
516 "output length follows the population/prev, not params.genome_dim"
517 );
518 }
519
520 #[test]
521 fn sample_is_deterministic_for_seed_and_state() {
522 let device = Default::default();
524 let state = CompactGeneticState {
525 prob: vec![0.3, 0.6, 0.9],
526 };
527 let mut rng_a = StdRng::seed_from_u64(77);
528 let mut rng_b = StdRng::seed_from_u64(77);
529 let a = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
530 &CompactGenetic,
531 &state,
532 256,
533 &mut rng_a,
534 &device,
535 );
536 let b = <CompactGenetic as ProbabilityModel<TestBackend>>::sample(
537 &CompactGenetic,
538 &state,
539 256,
540 &mut rng_b,
541 &device,
542 );
543 let data_a = a
544 .into_data()
545 .into_vec::<f32>()
546 .expect("samples host-read of a tensor this test just built");
547 let data_b = b
548 .into_data()
549 .into_vec::<f32>()
550 .expect("samples host-read of a tensor this test just built");
551 assert_eq!(
552 data_a, data_b,
553 "same seed + state must produce identical output"
554 );
555 }
556
557 use proptest::prelude::*;
558
559 proptest! {
560 #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
563
564 #[test]
572 fn prob_stays_in_unit_interval(
573 genome_dim in 1usize..=16,
574 virtual_pop_size in 1usize..=200,
575 iters in 1u32..=50,
576 seed in any::<u64>(),
577 ) {
578 let device = Default::default();
579 let params = CompactGeneticParams {
580 genome_dim,
581 virtual_pop_size,
582 };
583 let mut state = fit_prior(¶ms);
584 let mut rng = StdRng::seed_from_u64(seed);
585
586 for _ in 0..iters {
587 let k: usize = rng.random_range(2..=16);
589 let rows: Vec<f32> = (0..k * genome_dim)
590 .map(|_| if rng.random_bool(0.5) { 1.0 } else { 0.0 })
591 .collect();
592 let fit_values: Vec<f32> = (0..k).map(|_| rng.random::<f32>()).collect();
593
594 state = <CompactGenetic as ProbabilityModel<TestBackend>>::fit(
595 &CompactGenetic,
596 ¶ms,
597 Some(&state),
598 pop(rows, k, genome_dim),
599 fitness(fit_values),
600 &device,
601 );
602
603 prop_assert!(
604 state.prob().iter().all(|&p| (0.0..=1.0).contains(&p)),
605 "prob escaped [0, 1] after fit: {:?}",
606 state.prob()
607 );
608 }
609 }
610 }
611}