Skip to main content

rlevo_evolution/coevolution/
harness.rs

1//! Drive loop adapting a [`CoEvolutionaryAlgorithm`] to `BenchEnv`.
2//!
3//! [`CoEvolutionaryHarness`] is to [`CoEvolutionaryAlgorithm`] what
4//! [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness) is to
5//! [`Strategy`](crate::strategy::Strategy): it owns the joint state, the RNG,
6//! and the generation budget, and exposes the run to the `rlevo-benchmarks`
7//! evaluator through `rlevo-core::evaluation::BenchEnv` with no benchmark-side
8//! changes. One [`BenchEnv::step`] drives one simultaneous-update generation.
9
10use std::fmt::Debug;
11use std::marker::PhantomData;
12
13use burn::tensor::backend::Backend;
14use rand::SeedableRng;
15use rand::rngs::StdRng;
16
17use rlevo_core::config::{ConfigError, Validate};
18use rlevo_core::evaluation::{BenchEnv, BenchError, BenchStep};
19
20use super::CoEvolutionaryAlgorithm;
21
22/// Per-generation summary for a co-evolutionary run.
23///
24/// The [`CoEAMetrics`] analogue of
25/// [`StrategyMetrics`](crate::strategy::StrategyMetrics), but tracking both
26/// populations separately so a benchmark report can plot per-population
27/// dynamics. The four `best_fitness_*` / `mean_fitness_*` display fields are
28/// reported in the objective's **natural** declared sense (parity with
29/// single-population `StrategyMetrics`); the separate `binding_fitness` field
30/// carries the canonical (engine-space) harness reward (ADR 0023).
31#[derive(Debug, Clone)]
32pub struct CoEAMetrics {
33    /// Number of completed simultaneous-update generations.
34    pub generation: u64,
35    /// Best fitness population A has seen so far, in the objective's **natural**
36    /// declared sense (a `Minimize` cost reads as its natural cost).
37    pub best_fitness_a: f32,
38    /// Best fitness population B has seen so far, in the objective's **natural**
39    /// declared sense (a `Minimize` cost reads as its natural cost).
40    pub best_fitness_b: f32,
41    /// Mean fitness of population A this generation, in the objective's
42    /// **natural** declared sense.
43    pub mean_fitness_a: f32,
44    /// Mean fitness of population B this generation, in the objective's
45    /// **natural** declared sense.
46    pub mean_fitness_b: f32,
47    /// Canonical (engine-space, maximise) binding fitness `min(best_a, best_b)`
48    /// — the weaker population binds. Engine-space, NOT mapped to the
49    /// objective's natural sense; used as the harness reward. All other fitness
50    /// fields are in the objective's natural sense.
51    pub binding_fitness: f32,
52    /// Hall-of-fame archive size for population A (`0` if no archive).
53    pub hof_size_a: usize,
54    /// Hall-of-fame archive size for population B (`0` if no archive).
55    pub hof_size_b: usize,
56}
57
58/// Wraps a [`CoEvolutionaryAlgorithm`] into a `BenchEnv`.
59///
60/// Like [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness), the
61/// harness is lazily initialized: [`reset`](BenchEnv::reset) materializes the
62/// joint state on the configured device, and each
63/// [`step`](BenchEnv::step) runs one generation. The reward exposed to the
64/// benchmark harness is the **canonical** `binding_fitness = min(best_a, best_b)`
65/// (canonical maximise, no negation): the weaker population — the lower canonical
66/// fitness — is the binding constraint, and a higher binding value is better.
67/// The per-population `best_fitness_{a,b}` / `mean_fitness_{a,b}` in
68/// [`CoEAMetrics`] are reported in the objective's **natural** sense (ADR 0023);
69/// only `binding_fitness` stays canonical.
70///
71/// Per-generation metrics are emitted through `tracing` with structured
72/// per-population fields. (A dual-population [`PopulationObserver`] channel —
73/// the single-population
74/// [`PopulationSnapshot`](crate::observer::PopulationSnapshot) cannot carry
75/// both populations — is deferred to a follow-up.)
76///
77/// # Determinism
78///
79/// Determinism follows the same backend-RNG caveats documented on
80/// [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness): run one
81/// harness per process, or pin `EvaluatorConfig::num_threads = Some(1)`, for
82/// bit-reproducible runs.
83///
84/// [`PopulationObserver`]: crate::observer::PopulationObserver
85pub struct CoEvolutionaryHarness<B, C>
86where
87    B: Backend,
88    C: CoEvolutionaryAlgorithm<B>,
89{
90    algorithm: C,
91    params: C::Params,
92    state: Option<C::State>,
93    rng: StdRng,
94    base_seed: u64,
95    device: B::Device,
96    generation: usize,
97    max_generations: usize,
98    latest_metrics: Option<CoEAMetrics>,
99    _backend: PhantomData<B>,
100}
101
102impl<B, C> Debug for CoEvolutionaryHarness<B, C>
103where
104    B: Backend,
105    C: CoEvolutionaryAlgorithm<B>,
106{
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("CoEvolutionaryHarness")
109            .field("base_seed", &self.base_seed)
110            .field("generation", &self.generation)
111            .field("max_generations", &self.max_generations)
112            .field("latest_metrics", &self.latest_metrics)
113            .finish_non_exhaustive()
114    }
115}
116
117impl<B, C> CoEvolutionaryHarness<B, C>
118where
119    B: Backend,
120    C: CoEvolutionaryAlgorithm<B>,
121{
122    /// Build a new harness from an algorithm, its params, a seed, a device,
123    /// and a generation budget.
124    ///
125    /// The caller-supplied `params` are validated up front — this is the
126    /// co-evolutionary harness consumption chokepoint (ADR 0026).
127    ///
128    /// The harness is lazily initialized — the first [`reset`](Self::reset)
129    /// call materializes the joint state on `device`.
130    ///
131    /// # Errors
132    ///
133    /// Returns a [`ConfigError`] when `params` fails [`Validate::validate`],
134    /// naming the offending field and violated invariant.
135    pub fn new(
136        algorithm: C,
137        params: C::Params,
138        seed: u64,
139        device: B::Device,
140        max_generations: usize,
141    ) -> Result<Self, ConfigError>
142    where
143        C::Params: Validate,
144    {
145        params.validate()?;
146        Ok(Self {
147            algorithm,
148            params,
149            state: None,
150            rng: StdRng::seed_from_u64(seed),
151            base_seed: seed,
152            device,
153            generation: 0,
154            max_generations,
155            latest_metrics: None,
156            _backend: PhantomData,
157        })
158    }
159
160    /// The most recent generation's metrics, if any.
161    #[must_use]
162    pub fn latest_metrics(&self) -> Option<&CoEAMetrics> {
163        self.latest_metrics.as_ref()
164    }
165
166    /// Number of completed generations.
167    #[must_use]
168    pub fn generation(&self) -> usize {
169        self.generation
170    }
171
172    /// Reset to a fresh joint state, re-seeding the RNG.
173    ///
174    /// Infallible; the [`BenchEnv`] impl wraps this in `Ok(())`.
175    pub fn reset(&mut self) {
176        self.rng = StdRng::seed_from_u64(self.base_seed);
177        self.generation = 0;
178        self.latest_metrics = None;
179        self.state = Some(
180            self.algorithm
181                .init(&self.params, &mut self.rng, &self.device),
182        );
183    }
184
185    /// Run one simultaneous-update generation.
186    ///
187    /// Infallible; the [`BenchEnv`] impl wraps the result in `Ok(...)`.
188    ///
189    /// # Panics
190    ///
191    /// Panics if [`reset`](Self::reset) has not been called first.
192    pub fn step(&mut self, _action: ()) -> BenchStep<()> {
193        let state = self
194            .state
195            .take()
196            .expect("CoEvolutionaryHarness::reset must be called before step");
197        let (new_state, metrics) =
198            self.algorithm
199                .step(&self.params, state, &mut self.rng, &self.device);
200        self.state = Some(new_state);
201        self.generation += 1;
202
203        // The reward is the CANONICAL `binding_fitness` (`min(best_a, best_b)`
204        // in engine/maximise space): the weaker population (lower canonical
205        // fitness) is the binding constraint, and a higher binding value is
206        // better — no negation. It is read from the dedicated canonical field,
207        // NOT re-derived off `best_fitness_{a,b}`, which are now mapped to the
208        // objective's natural sense (ADR 0023) and would give the wrong `min`
209        // for a `Minimize` objective.
210        //
211        // Fitness hygiene (ADR 0034): `binding_fitness` is a `min` of the
212        // per-population canonical bests, each sourced from the `tell` metrics
213        // over fitness the coupled-fitness chokepoint canonicalised *and*
214        // sanitized (competitive/cooperative `step`), so it is finite-or-`−∞`,
215        // never `NaN`.
216        let reward = f64::from(metrics.binding_fitness);
217
218        tracing::info!(
219            generation = metrics.generation,
220            best_fitness_a = f64::from(metrics.best_fitness_a),
221            best_fitness_b = f64::from(metrics.best_fitness_b),
222            mean_fitness_a = f64::from(metrics.mean_fitness_a),
223            mean_fitness_b = f64::from(metrics.mean_fitness_b),
224            hof_size_a = metrics.hof_size_a,
225            hof_size_b = metrics.hof_size_b,
226            "coevolution generation",
227        );
228
229        self.latest_metrics = Some(metrics);
230        let done = self.generation >= self.max_generations;
231        BenchStep {
232            observation: (),
233            reward,
234            done,
235        }
236    }
237}
238
239impl<B, C> BenchEnv for CoEvolutionaryHarness<B, C>
240where
241    B: Backend,
242    C: CoEvolutionaryAlgorithm<B>,
243{
244    type Observation = ();
245    type Action = ();
246
247    fn reset(&mut self) -> Result<Self::Observation, BenchError> {
248        CoEvolutionaryHarness::<B, C>::reset(self);
249        Ok(())
250    }
251
252    fn step(&mut self, action: Self::Action) -> Result<BenchStep<Self::Observation>, BenchError> {
253        Ok(CoEvolutionaryHarness::<B, C>::step(self, action))
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use burn::backend::Flex;
261    use burn::tensor::{Tensor, TensorData};
262
263    use rlevo_core::bounds::Bounds;
264    use rlevo_core::objective::ObjectiveSense;
265    use rlevo_core::probability::Probability;
266    use rlevo_core::rate::NonNegativeRate;
267
268    use crate::algorithms::ga::{
269        GaConfig, GaCrossover, GaReplacement, GaSelection, GeneticAlgorithm,
270    };
271    use crate::coevolution::{CompetitiveCoEA, CompetitiveCoEAParams, CoupledFitness};
272
273    type TB = Flex;
274
275    const POP: usize = 4;
276    const DIM: usize = 2;
277
278    fn ga_config() -> GaConfig {
279        GaConfig {
280            pop_size: POP,
281            genome_dim: DIM,
282            bounds: Bounds::new(0.0, 1.0),
283            mutation_sigma: NonNegativeRate::new(0.1),
284            selection: GaSelection::Tournament { size: 2 },
285            crossover: GaCrossover::Uniform {
286                p: Probability::new(0.5),
287            },
288            replacement: GaReplacement::Elitist { elitism_k: 1 },
289        }
290    }
291
292    /// Row 0 is `NaN`, the rest a finite ramp — for both populations.
293    struct PoisonRow0Nan;
294
295    impl CoupledFitness<TB> for PoisonRow0Nan {
296        fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
297            populations
298                .iter()
299                .map(|p| {
300                    let n = p.dims()[0];
301                    let device = p.device();
302                    #[allow(clippy::cast_precision_loss)]
303                    let v: Vec<f32> = (0..n)
304                        .map(|i| if i == 0 { f32::NAN } else { i as f32 })
305                        .collect();
306                    Tensor::<TB, 1>::from_data(TensorData::new(v, [n]), &device)
307                })
308                .collect()
309        }
310        fn sense(&self) -> ObjectiveSense {
311            ObjectiveSense::Maximize
312        }
313    }
314
315    /// A `NaN` fitness from a [`CoupledFitness`] impl cannot make the harness
316    /// reward `NaN`: the coupled-fitness chokepoint sanitizes before `best_a`/
317    /// `best_b` are computed, so `min(best_a, best_b)` is finite-or-`−∞`.
318    /// Regression for issue #134 (harness §1.1) / ADR 0034.
319    #[test]
320    fn harness_reward_is_never_nan_with_nan_fitness() {
321        let device = Default::default();
322        let algo = CompetitiveCoEA::new(
323            GeneticAlgorithm::<TB>::new(),
324            GeneticAlgorithm::<TB>::new(),
325            PoisonRow0Nan,
326        );
327        let params: CompetitiveCoEAParams<GaConfig, GaConfig> = CompetitiveCoEAParams {
328            params_a: ga_config(),
329            params_b: ga_config(),
330        };
331        let mut harness =
332            CoEvolutionaryHarness::<TB, _>::new(algo, params, 7, device, 3).expect("valid params");
333        harness.reset();
334        let step = harness.step(());
335
336        assert!(!step.reward.is_nan(), "harness reward must never be NaN");
337        // The finite ramp maximum (POP - 1) binds both populations, so the
338        // reward is that finite value — the NaN row was sanitized, not crowned.
339        assert!(
340            step.reward.is_finite(),
341            "reward should be the finite binding value, got {}",
342            step.reward
343        );
344        #[allow(clippy::cast_precision_loss)]
345        let expected = f64::from((POP - 1) as f32);
346        approx::assert_relative_eq!(step.reward, expected, epsilon = 1e-6);
347    }
348
349    /// Row-wise cost `i + 1` declaring [`ObjectiveSense::Minimize`]: row 0 is
350    /// best (cost `1.0`), canonicalising to `−1.0` (the maximum).
351    struct RowCostMin;
352
353    impl CoupledFitness<TB> for RowCostMin {
354        fn evaluate_coupled(&self, populations: &[Tensor<TB, 2>]) -> Vec<Tensor<TB, 1>> {
355            populations
356                .iter()
357                .map(|p| {
358                    let n = p.dims()[0];
359                    let device = p.device();
360                    #[allow(clippy::cast_precision_loss)]
361                    let v: Vec<f32> = (0..n).map(|i| i as f32 + 1.0).collect();
362                    Tensor::<TB, 1>::from_data(TensorData::new(v, [n]), &device)
363                })
364                .collect()
365        }
366        fn sense(&self) -> ObjectiveSense {
367            ObjectiveSense::Minimize
368        }
369    }
370
371    /// For a `Minimize` objective the harness reward is the CANONICAL
372    /// `binding_fitness` (`min` of the canonical bests), not the natural cost.
373    /// Row 0's natural cost `1.0` canonicalises to `−1.0`, so the binding value
374    /// — and the reward — is `−1.0`, while the natural `best_fitness_a` reads
375    /// `1.0`.
376    #[test]
377    fn minimize_harness_reward_is_canonical_binding() {
378        let device = Default::default();
379        let algo = CompetitiveCoEA::new(
380            GeneticAlgorithm::<TB>::new(),
381            GeneticAlgorithm::<TB>::new(),
382            RowCostMin,
383        );
384        let params: CompetitiveCoEAParams<GaConfig, GaConfig> = CompetitiveCoEAParams {
385            params_a: ga_config(),
386            params_b: ga_config(),
387        };
388        let mut harness =
389            CoEvolutionaryHarness::<TB, _>::new(algo, params, 7, device, 3).expect("valid params");
390        harness.reset();
391        let step = harness.step(());
392
393        assert!(step.reward.is_finite(), "reward must be finite");
394        // Canonical binding = min(−1, −1) = −1.
395        approx::assert_relative_eq!(step.reward, -1.0, epsilon = 1e-6);
396        let m = harness.latest_metrics().expect("metrics after a step");
397        approx::assert_relative_eq!(m.binding_fitness, -1.0, epsilon = 1e-6);
398        // Natural best reads back as the low cost 1.0.
399        approx::assert_relative_eq!(m.best_fitness_a, 1.0, epsilon = 1e-6);
400    }
401}