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 (lower is
33/// better, per the project minimization convention).
34/// - `diversity` is currently `None` — the harness has no
35/// strategy-agnostic geometry over the population tensor. A future
36/// `Strategy::diversity` extension fills it in.
37/// - `best_genome_digest` and `parents_of_best` are emitted empty /
38/// `None` until per-strategy digest + parent-tracking lands (will
39/// feed a lineage DAG panel).
40#[derive(Debug, Clone, PartialEq)]
41pub struct PopulationSnapshot {
42 pub generation: u32,
43 pub fitnesses: Vec<f32>,
44 pub diversity: Option<f32>,
45 pub best_index: u32,
46 pub best_genome_digest: Option<[u8; 16]>,
47 pub parents_of_best: Vec<[u8; 16]>,
48}
49
50/// Callback the harness invokes once per generation, after
51/// [`Strategy::tell`](crate::Strategy::tell) has returned and the
52/// canonical `tracing::info!` aggregate event has been emitted.
53///
54/// `Send + 'static` so observers can sit behind
55/// [`Arc<Mutex<dyn PopulationObserver>>`](SharedPopulationObserver) and
56/// be shared across rayon worker threads — same shape as the
57/// `RecordSink` trait in `rlevo-benchmarks`.
58pub trait PopulationObserver: Send + 'static {
59 /// Receives one [`PopulationSnapshot`] per completed generation.
60 ///
61 /// Implementors should be cheap here: buffer the snapshot and do any
62 /// heavy I/O (serialization, channel sends) outside this call so the
63 /// harness loop is not blocked.
64 fn on_population(&mut self, snapshot: PopulationSnapshot);
65}
66
67/// Shared handle to a [`PopulationObserver`]. Backed by
68/// [`parking_lot::Mutex`] so the observer handle and the recording-tier
69/// sinks in `rlevo-benchmarks` share one lock type (ADR 0010); aliased so
70/// call sites do not have to spell out the `Arc<Mutex<…>>` shape every time.
71pub type SharedPopulationObserver = Arc<Mutex<dyn PopulationObserver>>;
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[derive(Debug, Default)]
78 struct CollectingObserver {
79 snapshots: Vec<PopulationSnapshot>,
80 }
81
82 impl PopulationObserver for CollectingObserver {
83 fn on_population(&mut self, snapshot: PopulationSnapshot) {
84 self.snapshots.push(snapshot);
85 }
86 }
87
88 #[test]
89 fn collecting_observer_records_each_call() {
90 let obs = Arc::new(Mutex::new(CollectingObserver::default()));
91 // Type-check that the concrete handle can be coerced to the
92 // dyn-shape the harness builder accepts.
93 let _shared: SharedPopulationObserver = obs.clone();
94 for g in 0..3 {
95 let snapshot = PopulationSnapshot {
96 generation: g,
97 fitnesses: vec![1.0, 2.0, 3.0],
98 diversity: None,
99 best_index: 0,
100 best_genome_digest: None,
101 parents_of_best: Vec::new(),
102 };
103 obs.lock().on_population(snapshot);
104 }
105 let guard = obs.lock();
106 assert_eq!(guard.snapshots.len(), 3);
107 assert_eq!(guard.snapshots[2].generation, 2);
108 }
109}