rlevo_evolution/coevolution/hof.rs
1//! Hall-of-fame pathology mitigation for competitive co-evolution.
2//!
3//! Naive competitive co-evolution suffers from **cycling** (Ficici & Pollack
4//! 1998; Watson & Pollack 2001; Ficici 2004): a population evolves a best
5//! response to the opponent's current composition, the opponent shifts in
6//! turn, and neither makes lasting progress (the rock-paper-scissors trap).
7//! Rosin & Belew (1997) mitigate this with a
8//! *hall of fame* — an archive of past champions that the current population
9//! must also perform well against, anchoring the fitness landscape so it can
10//! no longer be chased in a circle.
11//!
12//! [`HallOfFame`] is the archive; [`HallOfFameFitness`] is the
13//! [`CoupledFitness`] wrapper that blends each individual's score against the
14//! current opponents with its score against the archived champions. Passing a
15//! `HallOfFameFitness` to a co-evolutionary algorithm enables the mitigation;
16//! passing the raw fitness disables it — no flag inside the algorithm.
17
18use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
19use parking_lot::Mutex;
20use rlevo_core::objective::ObjectiveSense;
21
22use super::fitness::CoupledFitness;
23use crate::fitness::sanitize_fitness;
24
25/// Per-population archive of past champions, capped at a fixed capacity.
26///
27/// Each [`update`](Self::update) appends the current generation's best
28/// individual (highest fitness, canonical maximise convention) to each
29/// population's archive. When an archive would exceed `capacity` the single
30/// worst-fitness (lowest) member is dropped, so the archive always retains the
31/// `capacity` best champions seen across the whole run.
32///
33/// The capacity is computed by [`capacity_for`](Self::capacity_for) as
34/// `max(10, pop_size / 5)` (Rosin & Belew sizing).
35///
36/// # Invariants
37///
38/// - All archives share a single `genome_dim`; v1 co-evolution is
39/// bi-population over equal-width genomes. Asymmetric genome widths are out
40/// of scope.
41/// - `archives()[p].dims()[0] <= capacity` after every `update`.
42#[derive(Debug, Clone)]
43pub struct HallOfFame<B: Backend> {
44 /// Top-k champions retained per population, each `(size_p, genome_dim)`.
45 archives: Vec<Tensor<B, 2>>,
46 /// Host-side fitness of each archived champion (as inserted), parallel to
47 /// `archives`. Used to prune the worst member on overflow.
48 archive_fitness: Vec<Vec<f32>>,
49 /// Maximum number of champions retained per population.
50 capacity: usize,
51}
52
53impl<B: Backend> HallOfFame<B> {
54 /// Build an empty hall of fame for `num_populations` populations.
55 ///
56 /// Each archive starts as a `(0, genome_dim)` tensor and grows by one row
57 /// per [`update`](Self::update) until `capacity` is reached.
58 #[must_use]
59 pub fn new(
60 num_populations: usize,
61 capacity: usize,
62 genome_dim: usize,
63 device: &<B as burn::tensor::backend::BackendTypes>::Device,
64 ) -> Self {
65 let archives = (0..num_populations)
66 .map(|_| Tensor::<B, 2>::empty([0, genome_dim], device))
67 .collect();
68 let archive_fitness = vec![Vec::new(); num_populations];
69 Self {
70 archives,
71 archive_fitness,
72 capacity,
73 }
74 }
75
76 /// Recommended capacity for a population of `pop_size`: `max(10, pop_size / 5)`.
77 #[must_use]
78 pub fn capacity_for(pop_size: usize) -> usize {
79 (pop_size / 5).max(10)
80 }
81
82 /// Insert the best individual of each population into its archive.
83 ///
84 /// For population `p`, the highest-fitness row of `populations[p]` is
85 /// appended to `archives[p]` (canonical maximise: higher is better). If that
86 /// pushes the archive past `capacity`, the single lowest-fitness (worst)
87 /// archived member is removed. Empty populations are skipped.
88 ///
89 /// This method is **sense-blind**: it argmaxes highest = best and evicts
90 /// lowest = worst, which is correct only in canonical (maximise) space. The
91 /// **caller is responsible for passing canonical fitness** — for a
92 /// `Minimize` objective the natural cost must be negated first, or the
93 /// highest-*cost* (worst) individual would be crowned champion.
94 ///
95 /// # Panics
96 ///
97 /// Panics if a population's fitness tensor cannot be read back to host as
98 /// `f32` (a device→host transfer failure). A legitimately empty population
99 /// is a valid non-error host-read and is skipped, not a panic.
100 pub fn update(&mut self, populations: &[Tensor<B, 2>], fitnesses: &[Tensor<B, 1>]) {
101 let n = self
102 .archives
103 .len()
104 .min(populations.len())
105 .min(fitnesses.len());
106 for p in 0..n {
107 let fit_host = fitnesses[p]
108 .clone()
109 .into_data()
110 .into_vec::<f32>()
111 .expect("fitness tensor must be readable as f32");
112 if fit_host.is_empty() {
113 continue;
114 }
115 // Sanitize NaN → −inf (worst) so a NaN-fitness member can never be
116 // crowned champion over a finite one; this also keeps `archive_fitness`
117 // NaN-free, which the eviction `min_by` below relies on.
118 let sane: Vec<f32> = fit_host.iter().map(|&f| sanitize_fitness(f)).collect();
119 // Argmax (best, highest fitness — canonical maximise) — ties
120 // resolve to the lowest index. Hand-rolled with a strict
121 // `total_cmp == Greater` so equal-fitness ties keep the earliest
122 // index (`Iterator::max_by` would instead keep the last).
123 let mut best_idx = 0_usize;
124 for i in 1..sane.len() {
125 if sane[i].total_cmp(&sane[best_idx]) == std::cmp::Ordering::Greater {
126 best_idx = i;
127 }
128 }
129 let best_f = sane[best_idx];
130 let device = populations[p].device();
131 // usize → i64 index tensor; population indices never approach i64::MAX.
132 #[allow(clippy::cast_possible_wrap)]
133 let idx = Tensor::<B, 1, Int>::from_data(
134 TensorData::new(vec![best_idx as i64], [1]),
135 &device,
136 );
137 let champion = populations[p].clone().select(0, idx);
138
139 self.archives[p] = if self.archives[p].dims()[0] == 0 {
140 champion
141 } else {
142 Tensor::cat(vec![self.archives[p].clone(), champion], 0)
143 };
144 self.archive_fitness[p].push(best_f);
145
146 if self.archive_fitness[p].len() > self.capacity {
147 // Worst = lowest fitness under the maximise convention.
148 // `archive_fitness` is sanitised at push (no NaN), so a plain
149 // `total_cmp` correctly evicts the worst here.
150 // Over capacity => non-empty, so `min_by` is always `Some`.
151 let Some(worst_idx) = self.archive_fitness[p]
152 .iter()
153 .enumerate()
154 .min_by(|(_, a), (_, b)| a.total_cmp(b))
155 .map(|(i, _)| i)
156 else {
157 continue;
158 };
159 let len = self.archive_fitness[p].len();
160 #[allow(clippy::cast_possible_wrap)]
161 let keep: Vec<i64> = (0..len)
162 .filter(|&i| i != worst_idx)
163 .map(|i| i as i64)
164 .collect();
165 let keep_len = keep.len();
166 let keep_idx =
167 Tensor::<B, 1, Int>::from_data(TensorData::new(keep, [keep_len]), &device);
168 self.archives[p] = self.archives[p].clone().select(0, keep_idx);
169 self.archive_fitness[p].remove(worst_idx);
170 }
171 }
172 }
173
174 /// Borrow the per-population champion archives.
175 #[must_use]
176 pub fn archives(&self) -> &[Tensor<B, 2>] {
177 &self.archives
178 }
179
180 /// The per-population capacity cap.
181 #[must_use]
182 pub fn capacity(&self) -> usize {
183 self.capacity
184 }
185}
186
187/// A [`CoupledFitness`] wrapper that anchors fitness against a hall of fame.
188///
189/// Wraps any concrete `F: CoupledFitness<B>` and, each evaluation, blends an
190/// individual's score against the *current* opponents with its score against
191/// the *archived* champions:
192///
193/// ```text
194/// fitness_blended = (1 - w) * fitness_current + w * fitness_hof
195/// ```
196///
197/// where `w` is [`hof_blend_weight`](Self::with_blend_weight) (default `0.3`).
198/// Setting `w = 0.0` disables the mitigation without removing the wrapper;
199/// the archive is still maintained so the wrapper can be re-enabled or its
200/// archive inspected. The archive is updated after each evaluation with the
201/// current generation's champions.
202///
203/// Because [`CoupledFitness::evaluate_coupled`] takes `&self` but the archive
204/// must mutate per generation, the [`HallOfFame`] is held behind a
205/// `parking_lot::Mutex` (the project-standard lock, ADR-0010). Each harness
206/// runs its own wrapper instance, so the lock is effectively uncontended.
207///
208/// # Invariants
209///
210/// - v1 is bi-population: `evaluate_coupled` debug-asserts
211/// `populations.len() == 2`.
212pub struct HallOfFameFitness<B: Backend, F: CoupledFitness<B>> {
213 inner: F,
214 hall: Mutex<HallOfFame<B>>,
215 hof_blend_weight: f32,
216}
217
218impl<B: Backend, F: CoupledFitness<B>> std::fmt::Debug for HallOfFameFitness<B, F> {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("HallOfFameFitness")
221 .field("hof_blend_weight", &self.hof_blend_weight)
222 .finish_non_exhaustive()
223 }
224}
225
226impl<B: Backend, F: CoupledFitness<B>> HallOfFameFitness<B, F> {
227 /// Default blend weight (`0.3`).
228 pub const DEFAULT_BLEND_WEIGHT: f32 = 0.3;
229
230 /// Wrap `inner` with a hall of fame sized `max(10, pop_size / 5)`.
231 ///
232 /// `num_populations` and `genome_dim` size the archives; the blend weight
233 /// starts at [`DEFAULT_BLEND_WEIGHT`](Self::DEFAULT_BLEND_WEIGHT). Use
234 /// [`with_blend_weight`](Self::with_blend_weight) to override it.
235 #[must_use]
236 pub fn new(
237 inner: F,
238 num_populations: usize,
239 pop_size: usize,
240 genome_dim: usize,
241 device: &<B as burn::tensor::backend::BackendTypes>::Device,
242 ) -> Self {
243 let capacity = HallOfFame::<B>::capacity_for(pop_size);
244 let hall = HallOfFame::new(num_populations, capacity, genome_dim, device);
245 Self {
246 inner,
247 hall: Mutex::new(hall),
248 hof_blend_weight: Self::DEFAULT_BLEND_WEIGHT,
249 }
250 }
251
252 /// Override the hall-of-fame blend weight (clamped to `[0.0, 1.0]`).
253 ///
254 /// `0.0` disables the mitigation (pure current-generation fitness); `1.0`
255 /// evaluates purely against the archive.
256 #[must_use]
257 pub fn with_blend_weight(mut self, weight: f32) -> Self {
258 self.hof_blend_weight = weight.clamp(0.0, 1.0);
259 self
260 }
261
262 /// The current blend weight.
263 #[must_use]
264 pub fn blend_weight(&self) -> f32 {
265 self.hof_blend_weight
266 }
267}
268
269/// `cur * (1 - w) + hof * w`, element-wise.
270fn blend<B: Backend>(cur: &Tensor<B, 1>, hof: &Tensor<B, 1>, w: f32) -> Tensor<B, 1> {
271 cur.clone()
272 .mul_scalar(1.0 - w)
273 .add(hof.clone().mul_scalar(w))
274}
275
276impl<B: Backend, F: CoupledFitness<B>> CoupledFitness<B> for HallOfFameFitness<B, F> {
277 /// Blend the inner fitness against the hall-of-fame archive, in NATURAL
278 /// space.
279 ///
280 /// The returned `blended` is left in the inner objective's **natural** sense
281 /// — the co-evolutionary algorithm canonicalises it, exactly as for the raw
282 /// inner fitness. This is correct because the blend is affine and
283 /// `to_canonical` is negation: `neg((1−w)·cur + w·res) == (1−w)·neg(cur) +
284 /// w·neg(res)`, so canonicalising the blend equals blending the
285 /// canonicalised terms. The internal archive champion-selection is a
286 /// *separate* concern and **is** canonicalised here (see `current_canon`),
287 /// because [`HallOfFame::update`] argmaxes highest = best in maximise space.
288 ///
289 /// This method is logically **serial per instance**: the archive snapshot
290 /// and the later `update` are two separate lock acquisitions, so a single
291 /// instance's generations must not be evaluated concurrently (each harness
292 /// owns its own wrapper instance, so this holds by construction).
293 fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 1>> {
294 debug_assert_eq!(populations.len(), 2, "v1 hall-of-fame is bi-population");
295 let sense = self.inner.sense();
296 let current = self.inner.evaluate_coupled(populations); // natural
297 let w = self.hof_blend_weight;
298
299 // Canonicalise the current-gen fitness for archive champion-selection
300 // and eviction (both run in maximise-native space in `HallOfFame::update`).
301 // Done UNCONDITIONALLY — even at w=0 the archive is still updated and its
302 // champion selection must be canonical.
303 let current_canon: Vec<Tensor<B, 1>> = current
304 .iter()
305 .map(|t| match sense {
306 ObjectiveSense::Maximize => t.clone(),
307 ObjectiveSense::Minimize => t.clone().neg(),
308 })
309 .collect();
310
311 // Snapshot the archives under the lock, then RELEASE it before the heavy
312 // inner `evaluate_coupled` calls. Burn tensors are Arc-backed, so these
313 // clones are cheap handle bumps.
314 let (archive_a, archive_b) = {
315 let hall = self.hall.lock();
316 (hall.archives()[0].clone(), hall.archives()[1].clone())
317 };
318
319 let blended = if w <= 0.0 {
320 current.clone()
321 } else {
322 // Population A scored against the archived B champions.
323 let blended_a = if archive_b.dims()[0] > 0 {
324 let res = self
325 .inner
326 .evaluate_coupled(&[populations[0].clone(), archive_b]);
327 blend(¤t[0], &res[0], w)
328 } else {
329 current[0].clone()
330 };
331 // Population B scored against the archived A champions (index 1).
332 let blended_b = if archive_a.dims()[0] > 0 {
333 let res = self
334 .inner
335 .evaluate_coupled(&[archive_a, populations[1].clone()]);
336 blend(¤t[1], &res[1], w)
337 } else {
338 current[1].clone()
339 };
340 vec![blended_a, blended_b]
341 };
342
343 // Re-acquire only for the cheap archive mutation. Champions are selected
344 // from the CANONICAL current-gen fitness (`HallOfFame::update` is
345 // sense-blind and argmaxes highest = best in maximise space).
346 self.hall.lock().update(populations, ¤t_canon);
347 blended
348 }
349
350 fn sense(&self) -> ObjectiveSense {
351 self.inner.sense()
352 }
353
354 fn archive_sizes(&self) -> Vec<usize> {
355 self.hall
356 .lock()
357 .archives()
358 .iter()
359 .map(|a| a.dims()[0])
360 .collect()
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use burn::backend::Flex;
368
369 type B = Flex;
370
371 fn pop(rows: &[f32], n: usize, d: usize) -> Tensor<B, 2> {
372 let device = Default::default();
373 Tensor::<B, 2>::from_data(TensorData::new(rows.to_vec(), [n, d]), &device)
374 }
375
376 fn fit(values: &[f32]) -> Tensor<B, 1> {
377 let device = Default::default();
378 Tensor::<B, 1>::from_data(TensorData::new(values.to_vec(), [values.len()]), &device)
379 }
380
381 #[test]
382 fn capacity_formula() {
383 assert_eq!(HallOfFame::<B>::capacity_for(10), 10);
384 assert_eq!(HallOfFame::<B>::capacity_for(50), 10);
385 assert_eq!(HallOfFame::<B>::capacity_for(100), 20);
386 assert_eq!(HallOfFame::<B>::capacity_for(0), 10);
387 }
388
389 #[test]
390 fn archive_grows_to_capacity_then_prunes_worst() {
391 let device = Default::default();
392 let mut hof = HallOfFame::<B>::new(2, 3, 1, &device);
393 // Each generation's champion (index 0, highest fitness) is 5,4,3,2,1;
394 // the index-1 value of −100 is always the worst, so it is never the
395 // champion under the maximise convention.
396 for g in 0..5_usize {
397 #[allow(clippy::cast_precision_loss)]
398 let p = pop(&[g as f32, g as f32 + 0.5], 2, 1);
399 #[allow(clippy::cast_precision_loss)]
400 let f = fit(&[(5 - g) as f32, -100.0]);
401 hof.update(&[p.clone(), p], &[f.clone(), f]);
402 assert!(
403 hof.archives()[0].dims()[0] <= 3,
404 "archive exceeded capacity at gen {g}"
405 );
406 }
407 // After 5 generations at capacity 3, the three best (highest) champions
408 // survive: 5, 4, 3.
409 assert_eq!(hof.archives()[0].dims()[0], 3);
410 let mut surviving = hof.archive_fitness[0].clone();
411 surviving.sort_by(f32::total_cmp);
412 assert_eq!(surviving, vec![3.0, 4.0, 5.0]);
413 }
414
415 /// Inner fitness = row sum; used to exercise the wrapper plumbing.
416 struct RowSum;
417 impl CoupledFitness<B> for RowSum {
418 fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 1>> {
419 populations
420 .iter()
421 .map(|p| p.clone().sum_dim(1).squeeze_dim::<1>(1))
422 .collect()
423 }
424 fn sense(&self) -> ObjectiveSense {
425 ObjectiveSense::Maximize
426 }
427 }
428
429 #[test]
430 fn wrapper_reports_archive_sizes_and_grows() {
431 let device = Default::default();
432 let wrapper = HallOfFameFitness::new(RowSum, 2, 50, 2, &device);
433 assert_eq!(wrapper.archive_sizes(), vec![0, 0]);
434 let a = pop(&[1.0, 1.0, 2.0, 2.0], 2, 2);
435 let b = pop(&[0.0, 0.0, 3.0, 3.0], 2, 2);
436 let out = wrapper.evaluate_coupled(&[a.clone(), b.clone()]);
437 assert_eq!(out.len(), 2);
438 assert_eq!(out[0].dims(), [2]);
439 // One champion archived per population after one evaluation.
440 assert_eq!(wrapper.archive_sizes(), vec![1, 1]);
441 }
442
443 /// Inner cost fitness: each individual's natural cost is its genome's first
444 /// column value (lower is better), declared [`ObjectiveSense::Minimize`].
445 struct MinCost;
446 impl CoupledFitness<B> for MinCost {
447 fn evaluate_coupled(&self, populations: &[Tensor<B, 2>]) -> Vec<Tensor<B, 1>> {
448 populations
449 .iter()
450 .map(|p| {
451 // Cost = first-column value of each row.
452 p.clone().narrow(1, 0, 1).squeeze_dim::<1>(1)
453 })
454 .collect()
455 }
456 fn sense(&self) -> ObjectiveSense {
457 ObjectiveSense::Minimize
458 }
459 }
460
461 /// Under [`ObjectiveSense::Minimize`], the archived champion must be the
462 /// LOWEST-cost (best) individual, not the highest — proving the
463 /// canonicalisation in `HallOfFameFitness::evaluate_coupled` reaches
464 /// `HallOfFame::update`'s champion selection. Row 1 (cost `1.0`) is the
465 /// unique minimum; the archive must hold its genome, not row 2 (cost `5.0`).
466 #[test]
467 fn minimize_archives_lowest_cost_champion() {
468 let device = Default::default();
469 let wrapper = HallOfFameFitness::new(MinCost, 2, 50, 1, &device);
470 // Rows: costs 3.0, 1.0, 5.0 -> min is row 1.
471 let a = pop(&[3.0, 1.0, 5.0], 3, 1);
472 let b = pop(&[3.0, 1.0, 5.0], 3, 1);
473 let _ = wrapper.evaluate_coupled(&[a, b]);
474
475 let champ = {
476 let hall = wrapper.hall.lock();
477 hall.archives()[0]
478 .clone()
479 .into_data()
480 .into_vec::<f32>()
481 .expect("archived champion host-read")
482 };
483 assert_eq!(
484 champ,
485 vec![1.0],
486 "Minimize champion must be the min-cost genome (1.0), not the max-cost one"
487 );
488 }
489
490 /// The highest-risk invariant of the ADR 0035 change: the `current_canon`
491 /// canonicalisation must sit OUTSIDE the `if w <= 0.0` branch of
492 /// `HallOfFameFitness::evaluate_coupled`. At blend weight `0` the blend is
493 /// skipped but the archive is STILL updated, so champion selection must
494 /// remain canonical — otherwise a `Minimize` objective would crown the
495 /// highest-cost (worst) individual. This pins that: if someone moves
496 /// `current_canon` inside the `w <= 0.0` branch, the archived champion flips
497 /// to the max-cost row and this test fails.
498 #[test]
499 fn minimize_archives_lowest_cost_champion_even_at_zero_blend() {
500 let device = Default::default();
501 let wrapper = HallOfFameFitness::new(MinCost, 2, 50, 1, &device).with_blend_weight(0.0);
502 // Rows: costs 3.0, 1.0, 5.0 -> min is row 1.
503 let a = pop(&[3.0, 1.0, 5.0], 3, 1);
504 let b = pop(&[3.0, 1.0, 5.0], 3, 1);
505 let _ = wrapper.evaluate_coupled(&[a, b]);
506
507 let champ = {
508 let hall = wrapper.hall.lock();
509 hall.archives()[0]
510 .clone()
511 .into_data()
512 .into_vec::<f32>()
513 .expect("archived champion host-read")
514 };
515 assert_eq!(
516 champ,
517 vec![1.0],
518 "at w=0 the Minimize champion must still be the min-cost genome (1.0), \
519 proving canonicalisation reaches champion selection with blending disabled"
520 );
521 }
522
523 #[test]
524 fn blend_zero_passes_through_current_fitness() {
525 let device = Default::default();
526 let wrapper = HallOfFameFitness::new(RowSum, 2, 50, 2, &device).with_blend_weight(0.0);
527 let a = pop(&[1.0, 1.0, 2.0, 2.0], 2, 2);
528 let b = pop(&[0.0, 0.0, 3.0, 3.0], 2, 2);
529 // First eval seeds the archive; second would blend if w > 0.
530 let _ = wrapper.evaluate_coupled(&[a.clone(), b.clone()]);
531 let out = wrapper.evaluate_coupled(&[a, b]);
532 let va = out[0]
533 .clone()
534 .into_data()
535 .into_vec::<f32>()
536 .expect("fitness host-read of a tensor this test just built");
537 // Pure row sums regardless of the (non-empty) archive.
538 assert_eq!(va, vec![2.0, 4.0]);
539 }
540}