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}