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