Skip to main content

rlevo_evolution/coevolution/
competitive.rs

1//! Competitive co-evolution — predator vs. prey (Hillis 1990).
2//!
3//! Two populations are adversaries: each is scored by how well it performs
4//! against the other, driving an arms race. [`CompetitiveCoEA`] runs both
5//! under simultaneous updates — `ask` both, evaluate the pair with a single
6//! [`CoupledFitness`], `tell` both. The fitness function carries the
7//! adversarial relationship; wrapping it in
8//! [`HallOfFameFitness`](super::HallOfFameFitness) adds cycling mitigation
9//! without any change to this algorithm.
10
11use 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/// Static parameters for [`CompetitiveCoEA`]: one params bundle per inner
28/// strategy.
29#[derive(Debug, Clone)]
30pub struct CompetitiveCoEAParams<PA, PB> {
31    /// Params for population A's inner strategy.
32    pub params_a: PA,
33    /// Params for population B's inner strategy.
34    pub params_b: PB,
35}
36
37/// Validation delegates to nothing: [`CompetitiveCoEAParams`] carries only the
38/// two inner-strategy params, each already validated at that strategy's own
39/// harness chokepoint. No `PA: Validate` / `PB: Validate` bound is imposed, so
40/// the params stay usable with unit-typed inner params in tests.
41impl<PA, PB> Validate for CompetitiveCoEAParams<PA, PB> {
42    fn validate(&self) -> Result<(), ConfigError> {
43        Ok(())
44    }
45}
46
47/// Competitive co-evolutionary algorithm over two adversarial populations.
48///
49/// Generic over the backend `B`, the two inner strategies `SA`/`SB` (each
50/// producing `Tensor<B, 2>` genomes), and the [`CoupledFitness`] `F` that
51/// scores them against each other. Implements
52/// [`CoEvolutionaryAlgorithm`] so it can be driven by
53/// [`CoEvolutionaryHarness`](super::CoEvolutionaryHarness).
54pub 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    /// Build a competitive co-evolution from two inner strategies and a
87    /// coupled fitness.
88    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    /// Project the joint state into public [`CoEAMetrics`].
98    ///
99    /// The `best`/`mean` trackers on `state` are **canonical** (maximise-native):
100    /// `step` sources them from the per-population `tell` metrics computed over
101    /// the canonicalised, **sanitized** fitness (see the chokepoint in
102    /// [`step`](Self::step) and ADR 0023 / 0034). This projection maps the four
103    /// display fields back into the objective's **natural** sense via
104    /// [`from_canonical`](ObjectiveSense::from_canonical) (mirroring
105    /// single-population `StrategyMetrics`), while `binding_fitness` — the
106    /// harness reward — is kept in canonical space. No non-finite value can reach
107    /// here as a `NaN`: means are averaged over the finite members only
108    /// (`StrategyMetrics::from_host_fitness`), so a broken individual cannot blank
109    /// a mean.
110    fn snapshot(&self, state: &CoEAState<SA::State, SB::State>) -> CoEAMetrics {
111        let sizes = self.fitness.archive_sizes();
112        let sense = self.fitness.sense();
113        // `binding_fitness` is the harness reward and stays in CANONICAL
114        // (engine, maximise) space — the weaker population (lower canonical
115        // fitness) binds — so compute it *before* mapping the display fields
116        // back to the objective's natural sense.
117        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(&params.params_a, rng, device);
148        let state_b = self.strategy_b.init(&params.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        // Both populations propose simultaneously.
160        let (pop_a, asked_a) = self
161            .strategy_a
162            .ask(&params.params_a, &state.state_a, rng, device);
163        let (pop_b, asked_b) = self
164            .strategy_b
165            .ask(&params.params_b, &state.state_b, rng, device);
166
167        // `sense` is the single source of truth (ADR 0023), read off the
168        // coupled fitness so the algorithm and the objective can never disagree.
169        let sense = self.fitness.sense();
170        // Single coupled evaluation scores each against the other; `evaluate_coupled`
171        // returns NATURAL fitness.
172        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        // Canonicalise-then-sanitize chokepoint for the coupled-fitness path
178        // (ADR 0023 / 0034), mirroring `EvolutionaryHarness::step`. First map
179        // NATURAL → canonical (maximise-native: negate iff `Minimize`) so the
180        // maximise-native engine optimises `−cost`, THEN sanitize (`NaN → −∞`
181        // worst, `+∞ → f32::MAX`). The ordering is load-bearing: "NaN = worst"
182        // is only well-defined in maximise space, so sanitizing before `neg()`
183        // would flip a `NaN` cost to `+∞` = canonical *best* under `Minimize`.
184        // After this, the per-population `tell`, the `snapshot` best/mean written
185        // into `CoEAState`, and any `HallOfFameFitness` downstream all see
186        // canonical, finite-or-`−∞` fitness.
187        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        // Both populations consume their relative fitness.
198        let (next_a, metrics_a) =
199            self.strategy_a
200                .tell(&params.params_a, pop_a, fit_a, asked_a, rng);
201        let (next_b, metrics_b) =
202            self.strategy_b
203                .tell(&params.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    /// Coupled fitness that poisons row 0 of every population with a
258    /// non-finite value and fills the rest with a finite ramp `1, 2, …`.
259    /// `poison` selects `NaN` or `+∞`; either must be sanitized at the
260    /// chokepoint so the finite ramp maximum (`POP - 1`) is the champion.
261    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    /// A single all-`NaN` coupled fitness — the degenerate whole-population
286    /// break — must sanitize to `−∞`, never `NaN`.
287    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(&params, &mut rng, &device);
318        let (_next, metrics) = algo.step(&params, state, &mut rng, &device);
319        metrics
320    }
321
322    /// A `NaN` fitness from a [`CoupledFitness`] impl is sanitized to `−∞` at
323    /// the chokepoint, so it can neither become the champion nor blank a mean:
324    /// the finite ramp maximum (`POP - 1`) is `best`, and the mean is finite
325    /// (averaged over the finite members only). Regression for issue #134.
326    #[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    /// A `+∞` fitness is clamped to `f32::MAX` (still the top rank, but finite),
346    /// so it cannot blow the population mean up to `+∞`. Regression for the
347    /// ADR 0034 `+∞` rule on the coevolution path.
348    #[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    /// An all-`NaN` population sanitizes to `−∞` everywhere: `best`/`mean` are
362    /// the well-defined `−∞` sentinel (degenerate but flagged), never `NaN`.
363    #[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    /// A coupled objective declaring [`ObjectiveSense::Minimize`] whose natural
376    /// cost is `row_index` (so row 0 is the best at cost `0.0`). Both populations
377    /// share the cost. Regression proving the coupled canonicalisation chokepoint
378    /// (ADR 0023) actually MAXIMIZES a `Minimize` objective: the engine must
379    /// select the low-cost individual, `best_fitness_a` must read back as the
380    /// natural low cost, and `binding_fitness` must be the canonical (negated)
381    /// value.
382    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                    // Natural cost = row index: row 0 is best (cost 0.0).
392                    #[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        // The engine optimises `−cost`; the best natural cost is 0.0 (row 0).
407        // `best_fitness_a` is reported in natural sense, so it reads back as the
408        // low cost `0.0` — NOT the high-cost row.
409        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        // The canonical best is `from_canonical` of the natural best under
412        // Minimize, i.e. `−0.0 == 0.0`; binding = min of the two canonical bests.
413        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        // Mean is a natural cost > 0 (averaged over rows 0..POP), and finite.
420        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    /// Coupled fitness returning a DISTINCT ascending ramp per population: A's
428    /// ramp peaks at `10.0`, B's at `20.0`. Because the two returned vectors
429    /// differ, this pins that population A is scored by
430    /// `evaluate_coupled(...)[0]` and B by `[1]` (index 0 → A, index 1 → B).
431    /// Maximize, so natural == canonical and the reported best is the peak.
432    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    /// One `step` bumps `generation` from 0 to 1 (asserted via both the
458    /// returned [`CoEAMetrics`] and `metrics()` on the returned state), and the
459    /// bi-population fitness split routes each population to its own index:
460    /// A is scored by `evaluate_coupled(...)[0]` (peak `10.0`) and B by `[1]`
461    /// (peak `20.0`). Maximize sense, so natural == canonical.
462    #[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(&params, &mut rng, &device);
476        let (next, metrics) = algo.step(&params, 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        // A peaks at 10.0 (index 0), B at 20.0 (index 1): the split is correct.
485        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    /// A plain [`CoupledFitness`] with no hall-of-fame archive returns an empty
490    /// `archive_sizes()` (fewer than 2 entries), so `snapshot` falls back to
491    /// `hof_size_a == 0` and `hof_size_b == 0` via `.first()` / `.get(1)` with
492    /// `.unwrap_or(0)`.
493    #[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    /// `best_fitness_a` is a rolling max (`best_fitness_ever`), so threading the
501    /// returned state through a second `step` must leave it monotonically
502    /// non-decreasing while `generation` advances 0 → 1 → 2. Deterministic under
503    /// a fixed seed.
504    #[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(&params, &mut rng, &device);
518        let (state, m1) = algo.step(&params, state, &mut rng, &device);
519        let (_state, m2) = algo.step(&params, 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    /// Coupled fitness returning the WRONG number of vectors (one, not two),
531    /// violating the bi-population contract.
532    struct WrongLen;
533
534    impl CoupledFitness<TB> for WrongLen {
535        fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
536            // Return only ONE vector (for population A) — one short of the two
537            // the competitive algorithm requires.
538            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    // Pins the debug-only bi-population length contract: `step` carries a
552    // `debug_assert_eq!(fits.len(), 2, …)` which fires under `cargo test`
553    // (debug_assertions on). A wrong-length `evaluate_coupled` must trip it.
554    #[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}