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}