Skip to main content

rlevo_evolution/
strategy.rs

1//! Central [`Strategy`] trait and the [`EvolutionaryHarness`] adapter.
2//!
3//! # The ask / tell contract
4//!
5//! A [`Strategy`] exposes three methods that together drive one
6//! generation:
7//!
8//! 1. [`init`](Strategy::init) — build the initial state (sampling the
9//!    population, initializing σ, generation counter, etc).
10//! 2. [`ask`](Strategy::ask) — propose the next population as a genome
11//!    container.
12//! 3. [`tell`](Strategy::tell) — consume that population together with
13//!    its fitness and produce the next state plus a metrics snapshot.
14//!
15//! All three methods take the RNG explicitly so the harness owns all
16//! stochasticity; strategies carry *no* internal PRNG state.
17//!
18//! # Fitness convention
19//!
20//! The fitness tensor passed to [`tell`](Strategy::tell) is the raw
21//! objective value. Strategies in this crate minimize it: the
22//! [`StrategyMetrics::best_fitness`] field is the smallest value observed
23//! so far, and the harness reports `reward = -best_fitness` so the
24//! benchmark harness's "higher = better" convention still holds.
25//!
26//! # The harness adapter
27//!
28//! [`EvolutionaryHarness`] glues a strategy to any
29//! [`BatchFitnessFn`] and implements
30//! [`BenchEnv`], so the benchmark
31//! evaluator drives it just like an RL environment.
32
33use std::fmt::Debug;
34use std::marker::PhantomData;
35
36use burn::tensor::{Tensor, backend::Backend};
37use rand::rngs::StdRng;
38use rand::{Rng, SeedableRng};
39
40use rlevo_core::evaluation::{BenchEnv, BenchError, BenchStep};
41
42use crate::fitness::BatchFitnessFn;
43use crate::observer::{PopulationSnapshot, SharedPopulationObserver};
44
45/// Central evolutionary-strategy abstraction.
46///
47/// The trait is intentionally pure — [`ask`](Self::ask) and
48/// [`tell`](Self::tell) return a new `State` rather than mutating
49/// through `&mut self`. That keeps strategies free of interior
50/// mutability (so many instances can run in parallel without locks) and
51/// makes [`Clone`]-based checkpointing straightforward.
52///
53/// # Example
54///
55/// The example below uses [`GeneticAlgorithm`] as a concrete strategy and
56/// drives one ask/tell cycle by hand. Concrete strategies expose their state
57/// fields directly; generic code over `S: Strategy<B>` must access state
58/// only through [`Strategy::best`] and the tuple returns of `ask`/`tell`.
59///
60/// ```no_run
61/// use burn::backend::Flex;
62/// use burn::tensor::TensorData;
63/// use rlevo_evolution::Strategy;
64/// use rlevo_evolution::algorithms::ga::{GaConfig, GeneticAlgorithm};
65/// use rand::{rngs::StdRng, SeedableRng};
66///
67/// let device = Default::default();
68/// let strategy = GeneticAlgorithm::<Flex>::new();
69/// let params = GaConfig::default_for(64, 10);
70/// let mut rng = StdRng::seed_from_u64(0);
71/// let state = strategy.init(&params, &mut rng, &device);
72/// // state.population is a GaState field; dims() is (pop_size, genome_dim).
73/// assert_eq!(state.population.dims(), [64, 10]);
74/// ```
75///
76/// [`GeneticAlgorithm`]: crate::algorithms::ga::GeneticAlgorithm
77///
78/// # Type Parameters
79///
80/// - `B`: Burn backend.
81///
82/// # Associated Types
83///
84/// - `Params`: Static configuration for a run (population size, σ, F,
85///   CR, …). Adaptive algorithms mutate their adaptive quantities inside
86///   `State`, not `Params`.
87/// - `State`: Generation-to-generation state (current population, σ,
88///   best-so-far, RNG-free sub-statistics). Must be clonable so the
89///   harness can snapshot before a risky step if needed.
90/// - `Genome`: Genome container produced by `ask` and consumed by
91///   `tell`. Typically a `Tensor<B, 2>` for real-valued strategies or a
92///   `Tensor<B, 2, Int>` for binary/integer kinds.
93pub trait Strategy<B: Backend>: Send + Sync {
94    /// Static parameters for a run.
95    type Params: Clone + Debug + Send + Sync;
96
97    /// Generation-to-generation state.
98    type State: Clone + Debug + Send;
99
100    /// Genome container produced by [`ask`](Self::ask).
101    type Genome: Clone + Send;
102
103    /// Build the initial state.
104    ///
105    /// Samples the initial population, primes adaptive quantities, and
106    /// sets the generation counter to zero.
107    fn init(&self, params: &Self::Params, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> Self::State;
108
109    /// Propose the next population.
110    ///
111    /// Takes the current `state` and returns the genome to evaluate
112    /// together with an updated state. The returned state typically
113    /// carries pre-computed bookkeeping (e.g. the parent indices a
114    /// tournament-based GA sampled) so [`tell`](Self::tell) can reuse
115    /// them without re-sampling.
116    fn ask(
117        &self,
118        params: &Self::Params,
119        state: &Self::State,
120        rng: &mut dyn Rng,
121        device: &<B as burn::tensor::backend::BackendTypes>::Device,
122    ) -> (Self::Genome, Self::State);
123
124    /// Consume fitness values and produce the next state.
125    ///
126    /// `fitness` has shape `(pop_size,)` on the same device as the
127    /// population. Strategies pull it to host only if they need to —
128    /// e.g. for tournament index lookups.
129    fn tell(
130        &self,
131        params: &Self::Params,
132        population: Self::Genome,
133        fitness: Tensor<B, 1>,
134        state: Self::State,
135        rng: &mut dyn Rng,
136    ) -> (Self::State, StrategyMetrics);
137
138    /// Best-so-far accessor.
139    ///
140    /// Returns `None` before the first [`tell`](Self::tell) call.
141    /// The tuple is `(genome, fitness)` where `fitness` is the raw
142    /// (minimization-convention) scalar — the smallest value seen across
143    /// all completed generations.
144    fn best(&self, state: &Self::State) -> Option<(Self::Genome, f32)>;
145}
146
147/// Per-generation summary reported by [`Strategy::tell`].
148///
149/// All statistics refer to the generation that just finished evaluating.
150/// Fitness values follow the minimization convention: lower is better.
151#[derive(Debug, Clone)]
152pub struct StrategyMetrics {
153    /// Zero-based generation index.
154    pub generation: usize,
155    /// Number of individuals evaluated in this generation.
156    pub population_size: usize,
157    /// Smallest fitness observed in this generation.
158    pub best_fitness: f32,
159    /// Mean fitness across this generation's population.
160    pub mean_fitness: f32,
161    /// Largest fitness observed in this generation.
162    pub worst_fitness: f32,
163    /// Best fitness seen across *all* generations to date.
164    pub best_fitness_ever: f32,
165}
166
167impl StrategyMetrics {
168    /// Computes population statistics from a host-side fitness slice.
169    ///
170    /// # Panics
171    ///
172    /// Panics if `fitnesses` is empty.
173    #[must_use]
174    pub fn from_host_fitness(generation: usize, fitnesses: &[f32], best_fitness_ever: f32) -> Self {
175        assert!(!fitnesses.is_empty(), "fitness slice must be non-empty");
176        let population_size = fitnesses.len();
177        let (mut best, mut worst, mut sum) = (f32::INFINITY, f32::NEG_INFINITY, 0.0_f32);
178        for &f in fitnesses {
179            if f < best {
180                best = f;
181            }
182            if f > worst {
183                worst = f;
184            }
185            sum += f;
186        }
187        #[allow(clippy::cast_precision_loss)]
188        let mean = sum / population_size as f32;
189        Self {
190            generation,
191            population_size,
192            best_fitness: best,
193            mean_fitness: mean,
194            worst_fitness: worst,
195            best_fitness_ever: best_fitness_ever.min(best),
196        }
197    }
198}
199
200/// Wraps a [`Strategy`] into a [`BenchEnv`] so the benchmark harness can
201/// drive it.
202///
203/// # Example
204///
205/// ```no_run
206/// use burn::backend::Flex;
207/// use rlevo_core::fitness::FitnessEvaluable;
208/// use rlevo_core::evaluation::BenchEnv;
209/// use rlevo_evolution::algorithms::ga::{GaConfig, GeneticAlgorithm};
210/// use rlevo_evolution::fitness::FromFitnessEvaluable;
211/// use rlevo_evolution::strategy::EvolutionaryHarness;
212///
213/// struct Sphere;
214/// struct SphereFit;
215/// impl FitnessEvaluable for SphereFit {
216///     type Individual = Vec<f64>;
217///     type Landscape = Sphere;
218///     fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
219///         x.iter().map(|v| v * v).sum()
220///     }
221/// }
222///
223/// let device = Default::default();
224/// let mut harness = EvolutionaryHarness::<Flex, _, _>::new(
225///     GeneticAlgorithm::<Flex>::new(),
226///     GaConfig::default_for(32, 5),
227///     FromFitnessEvaluable::new(SphereFit, Sphere),
228///     0, device, 100,
229/// );
230/// harness.reset();
231/// while !harness.step(()).done {}
232/// ```
233///
234/// Each [`step`](BenchEnv::step) runs one generation (ask → evaluate →
235/// tell). The reward returned to the harness is `-best_fitness_ever` so
236/// the harness's "higher = better" convention matches the strategy's
237/// minimization direction, and so the per-episode cumulative return
238/// (Σ step rewards) integrates the optimization trajectory —
239/// `return_value / num_steps` bounds the final `best_fitness_ever` from
240/// above. The harness only exposes episode-level returns to reporters,
241/// so the "best at end" signal would otherwise be lost.
242///
243/// # Determinism and parallel execution
244///
245/// Burn backends seed their tensor RNG through process-global state —
246/// the `flex` backend uses a `Mutex<Option<FlexRng>>`, the
247/// `wgpu` backend a per-device seeded stream. When multiple harness
248/// instances run in parallel threads (e.g.
249/// `Evaluator::run_suite` with the default rayon pool), their
250/// interleaved `B::seed(...) → Tensor::random(...)` call pairs race on
251/// that shared state and destroy bit-reproducibility across runs.
252///
253/// For deterministic reproduction, pass
254/// `EvaluatorConfig::num_threads = Some(1)` or run one harness per
255/// process. The `tests/determinism.rs` and `tests/rastrigin_run_suite.rs`
256/// integration tests both enforce serial execution for this reason.
257pub struct EvolutionaryHarness<B, S, F>
258where
259    B: Backend,
260    S: Strategy<B>,
261    F: BatchFitnessFn<B, S::Genome>,
262{
263    strategy: S,
264    params: S::Params,
265    fitness_fn: F,
266    state: Option<S::State>,
267    rng: StdRng,
268    base_seed: u64,
269    device: B::Device,
270    generation: usize,
271    max_generations: usize,
272    latest_metrics: Option<StrategyMetrics>,
273    observer: Option<SharedPopulationObserver>,
274    _backend: PhantomData<B>,
275}
276
277impl<B, S, F> Debug for EvolutionaryHarness<B, S, F>
278where
279    B: Backend,
280    S: Strategy<B>,
281    F: BatchFitnessFn<B, S::Genome>,
282{
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        f.debug_struct("EvolutionaryHarness")
285            .field("base_seed", &self.base_seed)
286            .field("generation", &self.generation)
287            .field("max_generations", &self.max_generations)
288            .field("latest_metrics", &self.latest_metrics)
289            .finish_non_exhaustive()
290    }
291}
292
293impl<B, S, F> EvolutionaryHarness<B, S, F>
294where
295    B: Backend,
296    S: Strategy<B>,
297    F: BatchFitnessFn<B, S::Genome>,
298{
299    /// Build a new harness from its parts.
300    ///
301    /// The harness is lazily initialized — the first [`reset`](BenchEnv::reset)
302    /// call materializes the initial state on the supplied device.
303    pub fn new(
304        strategy: S,
305        params: S::Params,
306        fitness_fn: F,
307        seed: u64,
308        device: B::Device,
309        max_generations: usize,
310    ) -> Self {
311        Self {
312            strategy,
313            params,
314            fitness_fn,
315            state: None,
316            rng: StdRng::seed_from_u64(seed),
317            base_seed: seed,
318            device,
319            generation: 0,
320            max_generations,
321            latest_metrics: None,
322            observer: None,
323            _backend: PhantomData,
324        }
325    }
326
327    /// Attach a per-generation [`PopulationObserver`].
328    ///
329    /// The observer is called once per [`step`](Self::step) call, after the
330    /// canonical `tracing::info!("evolution generation", …)` event. It
331    /// receives a [`PopulationSnapshot`]
332    /// carrying the full per-individual fitness vector for the completed
333    /// generation. The intended consumer is a benchmark-tier recording sink
334    /// that persists population-level data alongside the scalar metric stream.
335    ///
336    /// Attaching an observer adds one device→host transfer of the fitness
337    /// tensor per generation; runs without an observer pay nothing.
338    ///
339    /// [`PopulationObserver`]: crate::observer::PopulationObserver
340    #[must_use]
341    pub fn with_observer(mut self, observer: SharedPopulationObserver) -> Self {
342        self.observer = Some(observer);
343        self
344    }
345
346    /// Snapshot of the most recent generation's metrics, if any.
347    #[must_use]
348    pub fn latest_metrics(&self) -> Option<&StrategyMetrics> {
349        self.latest_metrics.as_ref()
350    }
351
352    /// Generation counter — number of completed `tell` calls.
353    #[must_use]
354    pub fn generation(&self) -> usize {
355        self.generation
356    }
357
358    /// Borrow the current strategy state if it exists.
359    #[must_use]
360    pub fn state(&self) -> Option<&S::State> {
361        self.state.as_ref()
362    }
363
364    /// Forward to [`Strategy::best`] when a state exists.
365    pub fn best(&self) -> Option<(S::Genome, f32)> {
366        self.state.as_ref().and_then(|s| self.strategy.best(s))
367    }
368
369    /// Reset to a fresh initial state.
370    ///
371    /// Inherent shape (infallible): `EvolutionaryHarness` cannot legitimately
372    /// fail to reset — it is a deterministic optimization driver. The
373    /// [`BenchEnv`] trait impl wraps this in `Ok(())` so the harness is
374    /// callable both directly (this method) and via the [`BenchEnv`] surface
375    /// when fed to `Evaluator::run_suite`.
376    pub fn reset(&mut self) {
377        self.rng = StdRng::seed_from_u64(self.base_seed);
378        self.generation = 0;
379        self.latest_metrics = None;
380        self.state = Some(
381            self.strategy
382                .init(&self.params, &mut self.rng, &self.device),
383        );
384    }
385
386    /// Run one ask → evaluate → tell generation.
387    ///
388    /// Inherent shape (infallible). The [`BenchEnv`] trait impl wraps this
389    /// in `Ok(...)`. See [`Self::reset`] for the rationale.
390    ///
391    /// # Panics
392    ///
393    /// Panics if [`reset`](Self::reset) has not been called first.
394    pub fn step(&mut self, _action: ()) -> BenchStep<()> {
395        let state = self
396            .state
397            .take()
398            .expect("EvolutionaryHarness::reset must be called before step");
399        let (population, state) =
400            self.strategy
401                .ask(&self.params, &state, &mut self.rng, &self.device);
402        let fitness = self.fitness_fn.evaluate_batch(&population, &self.device);
403        // Mirror the fitness tensor to host only if someone's actually
404        // listening — the device→host transfer is the expensive part.
405        let snapshot_fitness: Option<Vec<f32>> = self.observer.as_ref().map(|_| {
406            fitness
407                .clone()
408                .into_data()
409                .into_vec::<f32>()
410                .unwrap_or_default()
411        });
412        let (new_state, metrics) =
413            self.strategy
414                .tell(&self.params, population, fitness, state, &mut self.rng);
415        self.state = Some(new_state);
416        self.generation += 1;
417        // Emit `-best_fitness_ever` so the reward is monotone
418        // non-decreasing over a run and the cumulative return (Σ step
419        // reward) integrates the optimization trajectory under the
420        // best-so-far curve. The benchmark harness reads per-episode
421        // `return_value` not per-step rewards, so a pure "last best"
422        // signal would be lost.
423        let reward = -f64::from(metrics.best_fitness_ever);
424        // Structured per-generation event. Picked up by the
425        // canonical-metric registry in
426        // `rlevo-benchmarks::tui::log_layer::CANONICAL_METRICS` so the
427        // live TUI's fitness sparkline lights up without coupling this
428        // crate to the dashboard. Field names match the registry
429        // verbatim; renaming any of them requires a paired update on
430        // the benchmarks side.
431        tracing::info!(
432            generation = metrics.generation,
433            population_size = metrics.population_size,
434            best_fitness = f64::from(metrics.best_fitness),
435            mean_fitness = f64::from(metrics.mean_fitness),
436            worst_fitness = f64::from(metrics.worst_fitness),
437            best_fitness_ever = f64::from(metrics.best_fitness_ever),
438            "evolution generation",
439        );
440        if let (Some(observer), Some(fitnesses)) =
441            (self.observer.as_ref(), snapshot_fitness)
442        {
443            let best_index = fitnesses
444                .iter()
445                .enumerate()
446                .min_by(|(_, a), (_, b)| {
447                    a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
448                })
449                .map_or(0, |(i, _)| u32::try_from(i).unwrap_or(0));
450            let snapshot = PopulationSnapshot {
451                generation: u32::try_from(metrics.generation).unwrap_or(u32::MAX),
452                fitnesses,
453                diversity: None,
454                best_index,
455                best_genome_digest: None,
456                parents_of_best: Vec::new(),
457            };
458            observer.lock().on_population(snapshot);
459        }
460        self.latest_metrics = Some(metrics);
461        let done = self.generation >= self.max_generations;
462        BenchStep {
463            observation: (),
464            reward,
465            done,
466        }
467    }
468}
469
470impl<B, S, F> BenchEnv for EvolutionaryHarness<B, S, F>
471where
472    B: Backend,
473    S: Strategy<B>,
474    F: BatchFitnessFn<B, S::Genome>,
475{
476    type Observation = ();
477    type Action = ();
478
479    fn reset(&mut self) -> Result<Self::Observation, BenchError> {
480        EvolutionaryHarness::<B, S, F>::reset(self);
481        Ok(())
482    }
483
484    fn step(
485        &mut self,
486        action: Self::Action,
487    ) -> Result<BenchStep<Self::Observation>, BenchError> {
488        Ok(EvolutionaryHarness::<B, S, F>::step(self, action))
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use burn::backend::Flex;
496    use burn::tensor::TensorData;
497    type TestBackend = Flex;
498
499    /// Trivial strategy for unit-testing the harness plumbing: it
500    /// ignores `ask`/`tell` semantics and always reports the same best
501    /// fitness. Nothing here exercises real evolutionary dynamics.
502    #[derive(Debug, Clone, Copy)]
503    struct Constant;
504
505    #[derive(Debug, Clone)]
506    struct Params {
507        pop_size: usize,
508        dim: usize,
509    }
510
511    #[derive(Debug, Clone)]
512    struct State {
513        generation: usize,
514        best: f32,
515    }
516
517    impl Strategy<TestBackend> for Constant {
518        type Params = Params;
519        type State = State;
520        type Genome = Tensor<TestBackend, 2>;
521
522        fn init(
523            &self,
524            params: &Params,
525            _: &mut dyn Rng,
526            device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
527        ) -> State {
528            let _ = device;
529            let _ = params;
530            State {
531                generation: 0,
532                best: f32::INFINITY,
533            }
534        }
535
536        fn ask(
537            &self,
538            params: &Params,
539            state: &State,
540            _: &mut dyn Rng,
541            device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
542        ) -> (Tensor<TestBackend, 2>, State) {
543            let data = TensorData::new(
544                vec![0.0f32; params.pop_size * params.dim],
545                [params.pop_size, params.dim],
546            );
547            let pop = Tensor::<TestBackend, 2>::from_data(data, device);
548            (pop, state.clone())
549        }
550
551        fn tell(
552            &self,
553            _: &Params,
554            _: Tensor<TestBackend, 2>,
555            fitness: Tensor<TestBackend, 1>,
556            mut state: State,
557            _: &mut dyn Rng,
558        ) -> (State, StrategyMetrics) {
559            let values = fitness.into_data().into_vec::<f32>().unwrap();
560            state.generation += 1;
561            let metrics = StrategyMetrics::from_host_fitness(state.generation, &values, state.best);
562            state.best = metrics.best_fitness_ever;
563            (state, metrics)
564        }
565
566        fn best(&self, _state: &State) -> Option<(Tensor<TestBackend, 2>, f32)> {
567            None
568        }
569    }
570
571    /// Constant fitness = 42 regardless of input.
572    struct FortyTwo;
573    impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for FortyTwo {
574        fn evaluate_batch(
575            &mut self,
576            population: &Tensor<B, 2>,
577            device: &<B as burn::tensor::backend::BackendTypes>::Device,
578        ) -> Tensor<B, 1> {
579            let n = population.dims()[0];
580            let data = TensorData::new(vec![42.0f32; n], [n]);
581            Tensor::<B, 1>::from_data(data, device)
582        }
583    }
584
585    #[test]
586    #[allow(clippy::float_cmp)]
587    fn harness_runs_one_generation() {
588        let device = Default::default();
589        let strategy = Constant;
590        let params = Params {
591            pop_size: 4,
592            dim: 3,
593        };
594        let mut harness =
595            EvolutionaryHarness::<TestBackend, _, _>::new(strategy, params, FortyTwo, 1, device, 5);
596        harness.reset();
597        let step = harness.step(());
598        assert_eq!(step.reward, -42.0);
599        assert!(!step.done);
600        assert_eq!(harness.generation(), 1);
601        let m = harness.latest_metrics().unwrap();
602        assert_eq!(m.generation, 1);
603        assert_eq!(m.population_size, 4);
604        approx::assert_relative_eq!(m.best_fitness, 42.0, epsilon = 1e-6);
605    }
606
607    #[test]
608    fn harness_reports_done_after_budget() {
609        let device = Default::default();
610        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
611            Constant,
612            Params {
613                pop_size: 2,
614                dim: 2,
615            },
616            FortyTwo,
617            1,
618            device,
619            2,
620        );
621        harness.reset();
622        assert!(!harness.step(()).done);
623        assert!(harness.step(()).done);
624    }
625
626    #[test]
627    fn from_host_fitness_computes_stats() {
628        let m = StrategyMetrics::from_host_fitness(5, &[3.0, 1.0, 5.0, 2.0], 4.0);
629        assert_eq!(m.generation, 5);
630        assert_eq!(m.population_size, 4);
631        approx::assert_relative_eq!(m.best_fitness, 1.0, epsilon = 1e-6);
632        approx::assert_relative_eq!(m.worst_fitness, 5.0, epsilon = 1e-6);
633        approx::assert_relative_eq!(m.mean_fitness, 2.75, epsilon = 1e-6);
634        // best_fitness_ever = min(prior=4.0, current=1.0)
635        approx::assert_relative_eq!(m.best_fitness_ever, 1.0, epsilon = 1e-6);
636    }
637
638    /// Per-individual fitness = `1.0 / (i + 1)` so the best (smallest)
639    /// is always at index `pop_size - 1` — a deterministic shape the
640    /// observer test can pin against.
641    struct RankedFitness;
642    impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for RankedFitness {
643        fn evaluate_batch(
644            &mut self,
645            population: &Tensor<B, 2>,
646            device: &<B as burn::tensor::backend::BackendTypes>::Device,
647        ) -> Tensor<B, 1> {
648            let n = population.dims()[0];
649            #[allow(clippy::cast_precision_loss)]
650            let values: Vec<f32> = (0..n).map(|i| 1.0 / (i as f32 + 1.0)).collect();
651            let data = TensorData::new(values, [n]);
652            Tensor::<B, 1>::from_data(data, device)
653        }
654    }
655
656    #[derive(Debug, Default)]
657    struct CountingObserver {
658        snapshots: Vec<PopulationSnapshot>,
659    }
660
661    impl crate::observer::PopulationObserver for CountingObserver {
662        fn on_population(&mut self, snapshot: PopulationSnapshot) {
663            self.snapshots.push(snapshot);
664        }
665    }
666
667    #[test]
668    fn harness_fires_observer_per_generation() {
669        use std::sync::Arc;
670
671        use parking_lot::Mutex;
672        let device = Default::default();
673        let observer = Arc::new(Mutex::new(CountingObserver::default()));
674        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
675            Constant,
676            Params {
677                pop_size: 5,
678                dim: 2,
679            },
680            RankedFitness,
681            1,
682            device,
683            3,
684        )
685        .with_observer(observer.clone() as SharedPopulationObserver);
686        harness.reset();
687        for _ in 0..3 {
688            harness.step(());
689        }
690        let guard = observer.lock();
691        assert_eq!(guard.snapshots.len(), 3);
692        // pop_size = 5, ranked fitness = [1/1, 1/2, 1/3, 1/4, 1/5]; best
693        // (smallest) is the last element.
694        assert_eq!(guard.snapshots[0].fitnesses.len(), 5);
695        assert_eq!(guard.snapshots[0].best_index, 4);
696        assert_eq!(guard.snapshots[2].generation, 3);
697        // M8.1 leaves these fields empty / None — see observer.rs docs.
698        assert!(guard.snapshots[0].diversity.is_none());
699        assert!(guard.snapshots[0].best_genome_digest.is_none());
700        assert!(guard.snapshots[0].parents_of_best.is_empty());
701    }
702
703    #[test]
704    fn harness_without_observer_skips_host_transfer() {
705        // Smoke: no observer attached → step() still works, no panic,
706        // no transfer cost. Observability is verified above; here we
707        // just want the no-observer path to remain functional.
708        let device = Default::default();
709        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
710            Constant,
711            Params {
712                pop_size: 3,
713                dim: 1,
714            },
715            RankedFitness,
716            1,
717            device,
718            1,
719        );
720        harness.reset();
721        let step = harness.step(());
722        assert!(step.done);
723    }
724}