Skip to main content

rlevo_evolution/
observer.rs

1//! Per-generation observer surface for [`EvolutionaryHarness`].
2//!
3//! The harness emits a scalar tracing event per generation
4//! (`best_fitness`, `mean_fitness`, …) which feeds the on-disk metric
5//! stream. Population-level reporting — the full fitness vector, the
6//! best-individual digest, parent lineage — does not fit through
7//! `tracing::info!` cleanly: events are string-keyed and scalar-valued.
8//! [`PopulationObserver`] is the structured callback the EA-population
9//! recorder (`rlevo_benchmarks::record::PopulationReporter`) attaches
10//! to to capture that shape.
11//!
12//! The trait is intentionally narrow — the snapshot fields mirror the
13//! report-tier `PopulationSample` schema 1:1 minus the `inner_rl_returns`
14//! field, which is the hybrid driver's responsibility.
15//!
16//! [`EvolutionaryHarness`]: crate::strategy::EvolutionaryHarness
17
18use std::sync::Arc;
19
20use parking_lot::Mutex;
21
22/// Per-generation population snapshot delivered to a
23/// [`PopulationObserver`].
24///
25/// Fields mirror the on-disk `PopulationSample` schema; the harness
26/// emits one of these after every successful
27/// [`Strategy::tell`](crate::Strategy::tell) call when an observer is
28/// attached.
29///
30/// **Field semantics**:
31///
32/// - `fitnesses` is the full per-individual fitness vector in **natural
33///   (user-sense)** space — the values are recorded before the harness
34///   canonicalises, so they read in the objective's declared
35///   [`ObjectiveSense`](rlevo_core::objective::ObjectiveSense) (a `Minimize`
36///   landscape's costs, a `Maximize` objective's rewards). `best_index` points
37///   at the best individual in that sense.
38/// - `diversity` is currently `None` — the harness has no
39///   strategy-agnostic geometry over the population tensor. A future
40///   `Strategy::diversity` extension fills it in.
41/// - `best_genome_digest` and `parents_of_best` are emitted empty /
42///   `None` until per-strategy digest + parent-tracking lands (will
43///   feed a lineage DAG panel).
44#[derive(Debug, Clone, PartialEq)]
45pub struct PopulationSnapshot {
46    pub generation: u32,
47    pub fitnesses: Vec<f32>,
48    pub diversity: Option<f32>,
49    pub best_index: u32,
50    pub best_genome_digest: Option<[u8; 16]>,
51    pub parents_of_best: Vec<[u8; 16]>,
52}
53
54/// Callback the harness invokes once per generation, after
55/// [`Strategy::tell`](crate::Strategy::tell) has returned and the
56/// canonical `tracing::info!` aggregate event has been emitted.
57///
58/// `Send + 'static` so observers can sit behind
59/// [`Arc<Mutex<dyn PopulationObserver>>`](SharedPopulationObserver) and
60/// be shared across rayon worker threads — same shape as the
61/// `RecordSink` trait in `rlevo-benchmarks`.
62pub trait PopulationObserver: Send + 'static {
63    /// Receives one [`PopulationSnapshot`] per completed generation.
64    ///
65    /// Implementors should be cheap here: buffer the snapshot and do any
66    /// heavy I/O (serialization, channel sends) outside this call so the
67    /// harness loop is not blocked.
68    fn on_population(&mut self, snapshot: PopulationSnapshot);
69}
70
71/// Shared handle to a [`PopulationObserver`]. Backed by
72/// [`parking_lot::Mutex`] so the observer handle and the recording-tier
73/// sinks in `rlevo-benchmarks` share one lock type (ADR 0010); aliased so
74/// call sites do not have to spell out the `Arc<Mutex<…>>` shape every time.
75pub type SharedPopulationObserver = Arc<Mutex<dyn PopulationObserver>>;
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[derive(Debug, Default)]
82    struct CollectingObserver {
83        snapshots: Vec<PopulationSnapshot>,
84    }
85
86    impl PopulationObserver for CollectingObserver {
87        fn on_population(&mut self, snapshot: PopulationSnapshot) {
88            self.snapshots.push(snapshot);
89        }
90    }
91
92    #[test]
93    fn collecting_observer_records_each_call() {
94        let obs = Arc::new(Mutex::new(CollectingObserver::default()));
95        // Type-check that the concrete handle can be coerced to the
96        // dyn-shape the harness builder accepts.
97        let _shared: SharedPopulationObserver = obs.clone();
98        for g in 0..3 {
99            let snapshot = PopulationSnapshot {
100                generation: g,
101                fitnesses: vec![1.0, 2.0, 3.0],
102                diversity: None,
103                best_index: 0,
104                best_genome_digest: None,
105                parents_of_best: Vec::new(),
106            };
107            obs.lock().on_population(snapshot);
108        }
109        let guard = obs.lock();
110        assert_eq!(guard.snapshots.len(), 3);
111        assert_eq!(guard.snapshots[2].generation, 2);
112    }
113}