1use std::fmt::Debug;
12use std::marker::PhantomData;
13
14use burn::tensor::{Tensor, backend::Backend};
15use rand::Rng;
16
17use rlevo_core::config::{ConfigError, Validate};
18use rlevo_core::objective::ObjectiveSense;
19
20use crate::fitness::sanitize_fitness_tensor;
21use crate::strategy::Strategy;
22
23use super::fitness::CoupledFitness;
24use super::harness::CoEAMetrics;
25use super::{CoEAState, CoEvolutionaryAlgorithm};
26
27#[derive(Debug, Clone)]
30pub struct CompetitiveCoEAParams<PA, PB> {
31 pub params_a: PA,
33 pub params_b: PB,
35}
36
37impl<PA, PB> Validate for CompetitiveCoEAParams<PA, PB> {
42 fn validate(&self) -> Result<(), ConfigError> {
43 Ok(())
44 }
45}
46
47pub struct CompetitiveCoEA<B, SA, SB, F>
55where
56 B: Backend,
57 SA: Strategy<B, Genome = Tensor<B, 2>>,
58 SB: Strategy<B, Genome = Tensor<B, 2>>,
59 F: CoupledFitness<B>,
60{
61 strategy_a: SA,
62 strategy_b: SB,
63 fitness: F,
64 _backend: PhantomData<fn() -> B>,
65}
66
67impl<B, SA, SB, F> Debug for CompetitiveCoEA<B, SA, SB, F>
68where
69 B: Backend,
70 SA: Strategy<B, Genome = Tensor<B, 2>>,
71 SB: Strategy<B, Genome = Tensor<B, 2>>,
72 F: CoupledFitness<B>,
73{
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.debug_struct("CompetitiveCoEA").finish_non_exhaustive()
76 }
77}
78
79impl<B, SA, SB, F> CompetitiveCoEA<B, SA, SB, F>
80where
81 B: Backend,
82 SA: Strategy<B, Genome = Tensor<B, 2>>,
83 SB: Strategy<B, Genome = Tensor<B, 2>>,
84 F: CoupledFitness<B>,
85{
86 pub fn new(strategy_a: SA, strategy_b: SB, fitness: F) -> Self {
89 Self {
90 strategy_a,
91 strategy_b,
92 fitness,
93 _backend: PhantomData,
94 }
95 }
96
97 fn snapshot(&self, state: &CoEAState<SA::State, SB::State>) -> CoEAMetrics {
111 let sizes = self.fitness.archive_sizes();
112 let sense = self.fitness.sense();
113 let binding = state.best_a.min(state.best_b);
118 CoEAMetrics {
119 generation: state.generation,
120 best_fitness_a: sense.from_canonical(state.best_a),
121 best_fitness_b: sense.from_canonical(state.best_b),
122 mean_fitness_a: sense.from_canonical(state.mean_a),
123 mean_fitness_b: sense.from_canonical(state.mean_b),
124 binding_fitness: binding,
125 hof_size_a: sizes.first().copied().unwrap_or(0),
126 hof_size_b: sizes.get(1).copied().unwrap_or(0),
127 }
128 }
129}
130
131impl<B, SA, SB, F> CoEvolutionaryAlgorithm<B> for CompetitiveCoEA<B, SA, SB, F>
132where
133 B: Backend,
134 SA: Strategy<B, Genome = Tensor<B, 2>>,
135 SB: Strategy<B, Genome = Tensor<B, 2>>,
136 F: CoupledFitness<B>,
137{
138 type Params = CompetitiveCoEAParams<SA::Params, SB::Params>;
139 type State = CoEAState<SA::State, SB::State>;
140
141 fn init(
142 &self,
143 params: &Self::Params,
144 rng: &mut dyn Rng,
145 device: &<B as burn::tensor::backend::BackendTypes>::Device,
146 ) -> Self::State {
147 let state_a = self.strategy_a.init(¶ms.params_a, rng, device);
148 let state_b = self.strategy_b.init(¶ms.params_b, rng, device);
149 CoEAState::new(state_a, state_b)
150 }
151
152 fn step(
153 &self,
154 params: &Self::Params,
155 mut state: Self::State,
156 rng: &mut dyn Rng,
157 device: &<B as burn::tensor::backend::BackendTypes>::Device,
158 ) -> (Self::State, CoEAMetrics) {
159 let (pop_a, asked_a) = self
161 .strategy_a
162 .ask(¶ms.params_a, &state.state_a, rng, device);
163 let (pop_b, asked_b) = self
164 .strategy_b
165 .ask(¶ms.params_b, &state.state_b, rng, device);
166
167 let sense = self.fitness.sense();
170 let fits = self
173 .fitness
174 .evaluate_coupled(&[pop_a.clone(), pop_b.clone()]);
175 debug_assert_eq!(fits.len(), 2, "competitive co-evolution is bi-population");
176
177 let canon = |t: Tensor<B, 1>| {
188 let c = match sense {
189 ObjectiveSense::Maximize => t,
190 ObjectiveSense::Minimize => t.neg(),
191 };
192 sanitize_fitness_tensor(c)
193 };
194 let fit_a = canon(fits[0].clone());
195 let fit_b = canon(fits[1].clone());
196
197 let (next_a, metrics_a) =
199 self.strategy_a
200 .tell(¶ms.params_a, pop_a, fit_a, asked_a, rng);
201 let (next_b, metrics_b) =
202 self.strategy_b
203 .tell(¶ms.params_b, pop_b, fit_b, asked_b, rng);
204
205 state.state_a = next_a;
206 state.state_b = next_b;
207 state.generation += 1;
208 state.best_a = metrics_a.best_fitness_ever();
209 state.best_b = metrics_b.best_fitness_ever();
210 state.mean_a = metrics_a.mean_fitness();
211 state.mean_b = metrics_b.mean_fitness();
212
213 let metrics = self.snapshot(&state);
214 (state, metrics)
215 }
216
217 fn metrics(&self, state: &Self::State) -> CoEAMetrics {
218 self.snapshot(state)
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use burn::backend::Flex;
226 use burn::tensor::TensorData;
227 use rand::SeedableRng;
228 use rand::rngs::StdRng;
229
230 use rlevo_core::bounds::Bounds;
231 use rlevo_core::probability::Probability;
232 use rlevo_core::rate::NonNegativeRate;
233
234 use crate::algorithms::ga::{
235 GaConfig, GaCrossover, GaReplacement, GaSelection, GeneticAlgorithm,
236 };
237
238 type TB = Flex;
239
240 const POP: usize = 4;
241 const DIM: usize = 2;
242
243 fn ga_config() -> GaConfig {
244 GaConfig {
245 pop_size: POP,
246 genome_dim: DIM,
247 bounds: Bounds::new(0.0, 1.0),
248 mutation_sigma: NonNegativeRate::new(0.1),
249 selection: GaSelection::Tournament { size: 2 },
250 crossover: GaCrossover::Uniform {
251 p: Probability::new(0.5),
252 },
253 replacement: GaReplacement::Elitist { elitism_k: 1 },
254 }
255 }
256
257 struct PoisonRow0 {
262 poison: f32,
263 }
264
265 impl CoupledFitness<TB> for PoisonRow0 {
266 fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
267 populations
268 .iter()
269 .map(|p| {
270 let n = p.dims()[0];
271 let device = p.device();
272 #[allow(clippy::cast_precision_loss)]
273 let v: Vec<f32> = (0..n)
274 .map(|i| if i == 0 { self.poison } else { i as f32 })
275 .collect();
276 Tensor::<TB, 1>::from_data(TensorData::new(v, [n]), &device)
277 })
278 .collect()
279 }
280 fn sense(&self) -> ObjectiveSense {
281 ObjectiveSense::Maximize
282 }
283 }
284
285 struct AllNan;
288
289 impl CoupledFitness<TB> for AllNan {
290 fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
291 populations
292 .iter()
293 .map(|p| {
294 let n = p.dims()[0];
295 let device = p.device();
296 Tensor::<TB, 1>::from_data(TensorData::new(vec![f32::NAN; n], [n]), &device)
297 })
298 .collect()
299 }
300 fn sense(&self) -> ObjectiveSense {
301 ObjectiveSense::Maximize
302 }
303 }
304
305 fn run_one_step<F: CoupledFitness<TB>>(fitness: F) -> CoEAMetrics {
306 let device = Default::default();
307 let algo = CompetitiveCoEA::new(
308 GeneticAlgorithm::<TB>::new(),
309 GeneticAlgorithm::<TB>::new(),
310 fitness,
311 );
312 let params: CompetitiveCoEAParams<GaConfig, GaConfig> = CompetitiveCoEAParams {
313 params_a: ga_config(),
314 params_b: ga_config(),
315 };
316 let mut rng = StdRng::seed_from_u64(7);
317 let state = algo.init(¶ms, &mut rng, &device);
318 let (_next, metrics) = algo.step(¶ms, state, &mut rng, &device);
319 metrics
320 }
321
322 #[test]
327 fn nan_row_is_not_crowned_and_mean_stays_finite() {
328 let m = run_one_step(PoisonRow0 { poison: f32::NAN });
329 #[allow(clippy::cast_precision_loss)]
330 let expected_best = (POP - 1) as f32;
331 approx::assert_relative_eq!(m.best_fitness_a, expected_best, epsilon = 1e-6);
332 approx::assert_relative_eq!(m.best_fitness_b, expected_best, epsilon = 1e-6);
333 assert!(
334 m.mean_fitness_a.is_finite(),
335 "mean_fitness_a must stay finite when a NaN individual is present, got {}",
336 m.mean_fitness_a
337 );
338 assert!(
339 m.mean_fitness_b.is_finite(),
340 "mean_fitness_b must stay finite when a NaN individual is present, got {}",
341 m.mean_fitness_b
342 );
343 }
344
345 #[test]
349 fn pos_inf_fitness_is_clamped_finite_in_metrics() {
350 let m = run_one_step(PoisonRow0 {
351 poison: f32::INFINITY,
352 });
353 approx::assert_relative_eq!(m.best_fitness_a, f32::MAX);
354 assert!(
355 m.mean_fitness_a.is_finite(),
356 "a +∞ individual must not push the mean to +∞, got {}",
357 m.mean_fitness_a
358 );
359 }
360
361 #[test]
364 fn all_nan_population_yields_neg_inf_never_nan() {
365 let m = run_one_step(AllNan);
366 assert!(!m.best_fitness_a.is_nan(), "best must never be NaN");
367 assert!(!m.mean_fitness_a.is_nan(), "mean must never be NaN");
368 assert!(
369 m.best_fitness_a.is_infinite() && m.best_fitness_a.is_sign_negative(),
370 "all-broken population best is the −∞ sentinel, got {}",
371 m.best_fitness_a
372 );
373 }
374
375 struct NegCost;
383
384 impl CoupledFitness<TB> for NegCost {
385 fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
386 populations
387 .iter()
388 .map(|p| {
389 let n = p.dims()[0];
390 let device = p.device();
391 #[allow(clippy::cast_precision_loss)]
393 let v: Vec<f32> = (0..n).map(|i| i as f32).collect();
394 Tensor::<TB, 1>::from_data(TensorData::new(v, [n]), &device)
395 })
396 .collect()
397 }
398 fn sense(&self) -> ObjectiveSense {
399 ObjectiveSense::Minimize
400 }
401 }
402
403 #[test]
404 fn minimize_objective_is_maximized_and_reported_natural() {
405 let m = run_one_step(NegCost);
406 approx::assert_relative_eq!(m.best_fitness_a, 0.0, epsilon = 1e-6);
410 approx::assert_relative_eq!(m.best_fitness_b, 0.0, epsilon = 1e-6);
411 assert!(
414 m.binding_fitness.is_finite(),
415 "binding_fitness must be finite, got {}",
416 m.binding_fitness
417 );
418 approx::assert_relative_eq!(m.binding_fitness, 0.0, epsilon = 1e-6);
419 assert!(
421 m.mean_fitness_a.is_finite() && m.mean_fitness_a > 0.0,
422 "mean natural cost should be a finite positive, got {}",
423 m.mean_fitness_a
424 );
425 }
426
427 struct DistinctRamps;
433
434 impl CoupledFitness<TB> for DistinctRamps {
435 fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
436 let peaks = [10.0_f32, 20.0_f32];
437 populations
438 .iter()
439 .enumerate()
440 .map(|(k, p)| {
441 let n = p.dims()[0];
442 let device = p.device();
443 let peak = peaks[k];
444 #[allow(clippy::cast_precision_loss)]
445 let v: Vec<f32> = (0..n)
446 .map(|i| peak * (i as f32) / ((n - 1).max(1) as f32))
447 .collect();
448 Tensor::<TB, 1>::from_data(TensorData::new(v, [n]), &device)
449 })
450 .collect()
451 }
452 fn sense(&self) -> ObjectiveSense {
453 ObjectiveSense::Maximize
454 }
455 }
456
457 #[test]
463 fn step_increments_generation_and_splits_fitness_by_population() {
464 let device = Default::default();
465 let algo = CompetitiveCoEA::new(
466 GeneticAlgorithm::<TB>::new(),
467 GeneticAlgorithm::<TB>::new(),
468 DistinctRamps,
469 );
470 let params: CompetitiveCoEAParams<GaConfig, GaConfig> = CompetitiveCoEAParams {
471 params_a: ga_config(),
472 params_b: ga_config(),
473 };
474 let mut rng = StdRng::seed_from_u64(7);
475 let state = algo.init(¶ms, &mut rng, &device);
476 let (next, metrics) = algo.step(¶ms, state, &mut rng, &device);
477
478 assert_eq!(metrics.generation, 1, "one step must bump generation 0 → 1");
479 assert_eq!(
480 algo.metrics(&next).generation,
481 1,
482 "metrics() on the returned state must agree with the step snapshot"
483 );
484 approx::assert_relative_eq!(metrics.best_fitness_a, 10.0, epsilon = 1e-6);
486 approx::assert_relative_eq!(metrics.best_fitness_b, 20.0, epsilon = 1e-6);
487 }
488
489 #[test]
494 fn snapshot_hof_size_falls_back_to_zero_without_archive() {
495 let m = run_one_step(DistinctRamps);
496 assert_eq!(m.hof_size_a, 0, "no archive → hof_size_a falls back to 0");
497 assert_eq!(m.hof_size_b, 0, "no archive → hof_size_b falls back to 0");
498 }
499
500 #[test]
505 fn best_a_tracks_rolling_max_across_generations() {
506 let device = Default::default();
507 let algo = CompetitiveCoEA::new(
508 GeneticAlgorithm::<TB>::new(),
509 GeneticAlgorithm::<TB>::new(),
510 DistinctRamps,
511 );
512 let params: CompetitiveCoEAParams<GaConfig, GaConfig> = CompetitiveCoEAParams {
513 params_a: ga_config(),
514 params_b: ga_config(),
515 };
516 let mut rng = StdRng::seed_from_u64(7);
517 let state = algo.init(¶ms, &mut rng, &device);
518 let (state, m1) = algo.step(¶ms, state, &mut rng, &device);
519 let (_state, m2) = algo.step(¶ms, state, &mut rng, &device);
520
521 assert_eq!(m2.generation, 2, "two steps must reach generation 2");
522 assert!(
523 m2.best_fitness_a >= m1.best_fitness_a,
524 "best_fitness_a is a rolling max and must be non-decreasing: {} → {}",
525 m1.best_fitness_a,
526 m2.best_fitness_a
527 );
528 }
529
530 struct WrongLen;
533
534 impl CoupledFitness<TB> for WrongLen {
535 fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
536 let p = &populations[0];
539 let n = p.dims()[0];
540 let device = p.device();
541 vec![Tensor::<TB, 1>::from_data(
542 TensorData::new(vec![0.0_f32; n], [n]),
543 &device,
544 )]
545 }
546 fn sense(&self) -> ObjectiveSense {
547 ObjectiveSense::Maximize
548 }
549 }
550
551 #[test]
555 #[should_panic(expected = "competitive co-evolution is bi-population")]
556 fn step_panics_on_wrong_length_coupled_fitness() {
557 let _ = run_one_step(WrongLen);
558 }
559}