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