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(¶ms, &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}