Skip to main content

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(&current[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(&current[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, &current_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}