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 engine is **maximise-native**: the fitness tensor passed to
21//! [`tell`](Strategy::tell) is a **canonical** value where *higher is
22//! better*, and strategies maximise it directly. The
23//! [`StrategyMetrics::best_fitness`] field is the largest value observed in
24//! a generation; [`StrategyMetrics::best_fitness_ever`] is a rolling
25//! maximum. Strategies are **sense-unaware** — they never see an
26//! [`ObjectiveSense`]. Cost
27//! objectives (e.g. the benchmark landscapes) are negated into canonical
28//! space at exactly one chokepoint, [`EvolutionaryHarness`], which also
29//! maps metrics back to the objective's declared sense for reporting.
30//!
31//! # The harness adapter
32//!
33//! [`EvolutionaryHarness`] glues a strategy to any
34//! [`BatchFitnessFn`] and implements
35//! [`BenchEnv`], so the benchmark
36//! evaluator drives it just like an RL environment.
37
38use std::fmt::Debug;
39use std::marker::PhantomData;
40
41use burn::tensor::{Tensor, backend::Backend};
42use rand::rngs::StdRng;
43use rand::{Rng, SeedableRng};
44
45use rlevo_core::config::{ConfigError, Validate};
46use rlevo_core::evaluation::{BenchEnv, BenchError, BenchStep};
47use rlevo_core::objective::ObjectiveSense;
48
49use crate::fitness::BatchFitnessFn;
50use crate::observer::{PopulationSnapshot, SharedPopulationObserver};
51
52/// Central evolutionary-strategy abstraction.
53///
54/// The trait is intentionally pure — [`ask`](Self::ask) and
55/// [`tell`](Self::tell) return a new `State` rather than mutating
56/// through `&mut self`. That keeps strategies free of interior
57/// mutability (so many instances can run in parallel without locks) and
58/// makes [`Clone`]-based checkpointing straightforward.
59///
60/// # Example
61///
62/// The example below uses [`GeneticAlgorithm`] as a concrete strategy and
63/// drives one ask/tell cycle by hand. Concrete strategies expose their state
64/// fields directly; generic code over `S: Strategy<B>` must access state
65/// only through [`Strategy::best`] and the tuple returns of `ask`/`tell`.
66///
67/// ```no_run
68/// use burn::backend::Flex;
69/// use burn::tensor::TensorData;
70/// use rlevo_evolution::Strategy;
71/// use rlevo_evolution::algorithms::ga::{GaConfig, GeneticAlgorithm};
72/// use rand::{rngs::StdRng, SeedableRng};
73///
74/// let device = Default::default();
75/// let strategy = GeneticAlgorithm::<Flex>::new();
76/// let params = GaConfig::default_for(64, 10);
77/// let mut rng = StdRng::seed_from_u64(0);
78/// let state = strategy.init(¶ms, &mut rng, &device);
79/// // state.population is a GaState field; dims() is (pop_size, genome_dim).
80/// assert_eq!(state.population.dims(), [64, 10]);
81/// ```
82///
83/// [`GeneticAlgorithm`]: crate::algorithms::ga::GeneticAlgorithm
84///
85/// # Type Parameters
86///
87/// - `B`: Burn backend.
88///
89/// # Associated Types
90///
91/// - `Params`: Static configuration for a run (population size, σ, F,
92/// CR, …). Adaptive algorithms mutate their adaptive quantities inside
93/// `State`, not `Params`.
94/// - `State`: Generation-to-generation state (current population, σ,
95/// best-so-far, RNG-free sub-statistics). Must be clonable so the
96/// harness can snapshot before a risky step if needed.
97/// - `Genome`: Genome container produced by `ask` and consumed by
98/// `tell`. Typically a `Tensor<B, 2>` for real-valued strategies or a
99/// `Tensor<B, 2, Int>` for binary/integer kinds.
100pub trait Strategy<B: Backend>: Send + Sync {
101 /// Static parameters for a run.
102 type Params: Clone + Debug + Send + Sync;
103
104 /// Generation-to-generation state.
105 type State: Clone + Debug + Send;
106
107 /// Genome container produced by [`ask`](Self::ask).
108 type Genome: Clone + Send;
109
110 /// Build the initial state.
111 ///
112 /// Samples the initial population, primes adaptive quantities, and
113 /// sets the generation counter to zero.
114 fn init(
115 &self,
116 params: &Self::Params,
117 rng: &mut dyn Rng,
118 device: &<B as burn::tensor::backend::BackendTypes>::Device,
119 ) -> Self::State;
120
121 /// Propose the next population.
122 ///
123 /// Takes the current `state` and returns the genome to evaluate
124 /// together with an updated state. The returned state typically
125 /// carries pre-computed bookkeeping (e.g. the parent indices a
126 /// tournament-based GA sampled) so [`tell`](Self::tell) can reuse
127 /// them without re-sampling.
128 fn ask(
129 &self,
130 params: &Self::Params,
131 state: &Self::State,
132 rng: &mut dyn Rng,
133 device: &<B as burn::tensor::backend::BackendTypes>::Device,
134 ) -> (Self::Genome, Self::State);
135
136 /// Consume fitness values and produce the next state.
137 ///
138 /// `fitness` has shape `(pop_size,)` on the same device as the
139 /// population. Strategies pull it to host only if they need to —
140 /// e.g. for tournament index lookups.
141 ///
142 /// # Invariants
143 ///
144 /// When driven by [`EvolutionaryHarness`], the `fitness` tensor is
145 /// **canonical (maximise) and sanitized** (ADR 0034): every element is finite
146 /// or `f32::NEG_INFINITY` — no `NaN`, no `+∞`. A `tell` impl may therefore
147 /// build leaders / personal-best / global-best directly from it without a
148 /// finite check. Callers that invoke `tell` **directly, bypassing the
149 /// harness**, do *not* get this guarantee and must apply
150 /// `sanitize_fitness` at every
151 /// ordering/aggregation site (`rules.md` §3).
152 fn tell(
153 &self,
154 params: &Self::Params,
155 population: Self::Genome,
156 fitness: Tensor<B, 1>,
157 state: Self::State,
158 rng: &mut dyn Rng,
159 ) -> (Self::State, StrategyMetrics);
160
161 /// Best-so-far accessor.
162 ///
163 /// Returns `None` before the first [`tell`](Self::tell) call.
164 /// The tuple is `(genome, fitness)` where `fitness` is the **canonical**
165 /// (maximise-convention) scalar — the largest value seen across all
166 /// completed generations. The harness maps it back to the objective's
167 /// declared sense before surfacing it to callers.
168 fn best(&self, state: &Self::State) -> Option<(Self::Genome, f32)>;
169}
170
171/// Per-generation summary reported by [`Strategy::tell`].
172///
173/// All statistics refer to the generation that just finished evaluating.
174/// These values are in **canonical (maximise) space**: *higher is better*.
175/// Strategies are sense-unaware, so the metrics they emit are always
176/// canonical. [`EvolutionaryHarness::latest_metrics`] maps them back to the
177/// objective's declared sense before surfacing them to callers, so a
178/// `Minimize` landscape reads as its natural cost (Sphere → 0).
179///
180/// When printed in a benchmark showcase (e.g. `ackley_showcase`), the
181/// two most informative fields are:
182///
183/// - **`best_fitness_ever`** — the best (canonical: largest) fitness seen
184/// across *all* generations so far. This is a rolling maximum that tells
185/// you how close the best individual ever found came to the optimum.
186/// - **`mean_fitness`** — the arithmetic mean of the current generation's
187/// per-individual fitness vector. This tells you the average quality of
188/// the population in that generation.
189///
190/// A large gap between `best_fitness_ever` and `mean_fitness` in the final
191/// generation usually indicates premature convergence: a few elite
192/// individuals found a good basin while the rest of the population is still
193/// scattered. A small gap suggests the whole population has settled near the
194/// same optimum.
195#[derive(Debug, Clone)]
196pub struct StrategyMetrics {
197 /// Zero-based generation index.
198 generation: usize,
199 /// Number of individuals evaluated in this generation.
200 population_size: usize,
201 /// Best (canonical: largest) fitness observed in this generation.
202 best_fitness: f32,
203 /// Mean fitness across this generation's population.
204 ///
205 /// This is the arithmetic mean of the per-individual fitness vector
206 /// for the generation that just finished. In a showcase table printed
207 /// after a run, this value reflects the *final* generation's average
208 /// quality. See the struct-level docs for how to interpret the gap
209 /// between this field and [`Self::best_fitness_ever`].
210 mean_fitness: f32,
211 /// Worst (canonical: smallest) fitness observed in this generation.
212 worst_fitness: f32,
213 /// Best fitness seen across *all* generations to date.
214 ///
215 /// This is a rolling maximum (`previous_best.max(current_generation_best)`)
216 /// in canonical space. When mapped back to the objective's sense and
217 /// printed in a benchmark showcase, it represents the best solution
218 /// quality found during the entire run. For landscapes whose global
219 /// optimum is known (e.g. Ackley → 0), the harness-reported value tells
220 /// you how close the algorithm got to the theoretical optimum.
221 best_fitness_ever: f32,
222 /// Number of individuals whose sanitized fitness was non-finite (`−∞`) in
223 /// this generation — i.e. members that evaluated to `NaN` (or a genuine
224 /// worst-sentinel `−∞`) and were therefore **excluded from
225 /// [`mean_fitness`](Self::mean_fitness)** (ADR 0034). Zero on a healthy run;
226 /// a non-zero value flags a population carrying broken individuals without
227 /// letting them blank the mean to `−∞`.
228 broken_count: usize,
229}
230
231impl StrategyMetrics {
232 /// Computes population statistics from a host-side fitness slice.
233 ///
234 /// Each value is passed through the crate's fitness-hygiene primitive
235 /// `sanitize_fitness` before folding, so
236 /// `NaN → −∞` and `+∞ → f32::MAX` (the maximise convention, ADR 0023/0034)
237 /// *consistently* across every statistic — `best`/`worst` can no longer
238 /// silently drop a `NaN` (comparisons against `NaN` are false) while the sum
239 /// propagates it.
240 ///
241 /// `mean_fitness` is computed **over the finite members only**: a sanitized
242 /// `−∞` member (a `NaN` evaluation, or a genuine worst-sentinel) is excluded
243 /// from the average and counted in [`broken_count`](Self::broken_count)
244 /// instead (ADR 0034). This keeps a single broken individual from blanking
245 /// the whole mean to `−∞` while still surfacing that the population is
246 /// unhealthy. `+∞ → f32::MAX` members are finite and *are* included, so an
247 /// optimal individual cannot blow the mean to `+∞`. If *every* member is
248 /// broken, `mean_fitness = −∞` (degenerate but well-defined).
249 ///
250 /// # Panics
251 ///
252 /// Panics if `fitnesses` is empty. Callers hold a non-empty population by
253 /// construction — `pop_size` is validated non-zero at the harness
254 /// constructor (ADR 0026).
255 #[must_use]
256 pub fn from_host_fitness(generation: usize, fitnesses: &[f32], best_fitness_ever: f32) -> Self {
257 assert!(!fitnesses.is_empty(), "fitness slice must be non-empty");
258 let population_size = fitnesses.len();
259 // Canonical (maximise) space: best is the largest value, worst the
260 // smallest, best-ever a rolling maximum. Each value is sanitized up front
261 // so all statistics agree on the crate-wide convention. The mean is taken
262 // over finite members only; non-finite (`−∞`) members are counted as
263 // broken rather than dragging the mean to `−∞`.
264 let mut best = f32::NEG_INFINITY;
265 let mut worst = f32::INFINITY;
266 let mut finite_sum = 0.0_f32;
267 let mut finite_n = 0_usize;
268 let mut broken_count = 0_usize;
269 for &f in fitnesses {
270 let f = crate::fitness::sanitize_fitness(f);
271 if f > best {
272 best = f;
273 }
274 if f < worst {
275 worst = f;
276 }
277 if f.is_finite() {
278 finite_sum += f;
279 finite_n += 1;
280 } else {
281 broken_count += 1;
282 }
283 }
284 let mean = if finite_n > 0 {
285 #[allow(clippy::cast_precision_loss)]
286 let n = finite_n as f32;
287 finite_sum / n
288 } else {
289 // Every member is broken: no finite value to average.
290 f32::NEG_INFINITY
291 };
292 Self {
293 generation,
294 population_size,
295 best_fitness: best,
296 mean_fitness: mean,
297 worst_fitness: worst,
298 best_fitness_ever: best_fitness_ever.max(best),
299 broken_count,
300 }
301 }
302
303 /// Zero-based generation index.
304 #[must_use]
305 pub fn generation(&self) -> usize {
306 self.generation
307 }
308
309 /// Number of individuals evaluated in this generation.
310 #[must_use]
311 pub fn population_size(&self) -> usize {
312 self.population_size
313 }
314
315 /// Best (canonical: largest) fitness observed in this generation.
316 #[must_use]
317 pub fn best_fitness(&self) -> f32 {
318 self.best_fitness
319 }
320
321 /// Mean fitness across this generation's population.
322 ///
323 /// Averaged over the **finite** members only; broken (`−∞`) members are
324 /// excluded and reported by [`broken_count`](Self::broken_count) (ADR 0034).
325 #[must_use]
326 pub fn mean_fitness(&self) -> f32 {
327 self.mean_fitness
328 }
329
330 /// Number of non-finite (broken) individuals excluded from
331 /// [`mean_fitness`](Self::mean_fitness) this generation (ADR 0034).
332 ///
333 /// Zero on a healthy run; non-zero flags a population carrying `NaN`/`−∞`
334 /// members.
335 #[must_use]
336 pub fn broken_count(&self) -> usize {
337 self.broken_count
338 }
339
340 /// Worst (canonical: smallest) fitness observed in this generation.
341 #[must_use]
342 pub fn worst_fitness(&self) -> f32 {
343 self.worst_fitness
344 }
345
346 /// Best (canonical: largest) fitness seen across *all* generations to date.
347 #[must_use]
348 pub fn best_fitness_ever(&self) -> f32 {
349 self.best_fitness_ever
350 }
351}
352
353/// Builds a per-generation [`PopulationSnapshot`] from a host-side fitness
354/// vector, or `None` when the vector is empty.
355///
356/// `fitnesses` is in **natural (user-sense)** space: the best individual is the
357/// smallest value for a [`Minimize`](ObjectiveSense::Minimize) objective and the
358/// largest for [`Maximize`](ObjectiveSense::Maximize). Returning `None` on an
359/// empty vector guards against emitting an out-of-range `best_index` (the fold
360/// would otherwise default to `0`, indexing into a zero-length slice).
361fn build_population_snapshot(
362 generation: u32,
363 fitnesses: Vec<f32>,
364 sense: ObjectiveSense,
365) -> Option<PopulationSnapshot> {
366 if fitnesses.is_empty() {
367 return None;
368 }
369 let best_index = fitnesses
370 .iter()
371 .enumerate()
372 .reduce(|best, cur| {
373 let better = match sense {
374 ObjectiveSense::Minimize => cur.1 < best.1,
375 ObjectiveSense::Maximize => cur.1 > best.1,
376 };
377 if better { cur } else { best }
378 })
379 .map_or(0, |(i, _)| u32::try_from(i).unwrap_or(0));
380 Some(PopulationSnapshot {
381 generation,
382 fitnesses,
383 diversity: None,
384 best_index,
385 best_genome_digest: None,
386 parents_of_best: Vec::new(),
387 })
388}
389
390/// Wraps a [`Strategy`] into a [`BenchEnv`] so the benchmark harness can
391/// drive it.
392///
393/// # Example
394///
395/// ```no_run
396/// use burn::backend::Flex;
397/// use rlevo_core::fitness::FitnessEvaluable;
398/// use rlevo_core::evaluation::BenchEnv;
399/// use rlevo_evolution::algorithms::ga::{GaConfig, GeneticAlgorithm};
400/// use rlevo_evolution::fitness::FromFitnessEvaluable;
401/// use rlevo_evolution::strategy::EvolutionaryHarness;
402///
403/// struct Sphere;
404/// struct SphereFit;
405/// impl FitnessEvaluable for SphereFit {
406/// type Individual = Vec<f64>;
407/// type Landscape = Sphere;
408/// fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
409/// x.iter().map(|v| v * v).sum()
410/// }
411/// }
412///
413/// let device = Default::default();
414/// let mut harness = EvolutionaryHarness::<Flex, _, _>::new(
415/// GeneticAlgorithm::<Flex>::new(),
416/// GaConfig::default_for(32, 5),
417/// FromFitnessEvaluable::new(SphereFit, Sphere),
418/// 0, device, 100,
419/// ).expect("valid params");
420/// harness.reset();
421/// while !harness.step(()).done {}
422/// ```
423///
424/// Each [`step`](BenchEnv::step) runs one generation (ask → evaluate →
425/// tell). The harness is the sole canonicaliser: it reads the fitness fn's
426/// [`ObjectiveSense`], negates a
427/// `Minimize` objective into the engine's maximise space before `tell`, and
428/// maps the metrics back to the declared sense for reporting. The reward
429/// returned is the **canonical** `best_fitness_ever` directly (already
430/// higher-is-better — no negation), so the per-episode cumulative return
431/// (Σ step rewards) integrates the optimization trajectory. The harness only
432/// exposes episode-level returns to reporters, so the "best at end" signal
433/// would otherwise be lost.
434///
435/// # Determinism and parallel execution
436///
437/// Burn backends seed their tensor RNG through process-global state —
438/// the `flex` backend uses a `Mutex<Option<FlexRng>>`, the
439/// `wgpu` backend a per-device seeded stream. When multiple harness
440/// instances run in parallel threads (e.g.
441/// `Evaluator::run_suite` with the default rayon pool), their
442/// interleaved `B::seed(...) → Tensor::random(...)` call pairs race on
443/// that shared state and destroy bit-reproducibility across runs.
444///
445/// For deterministic reproduction, pass
446/// `EvaluatorConfig::num_threads = Some(1)` or run one harness per
447/// process. The `tests/determinism.rs` and `tests/rastrigin_run_suite.rs`
448/// integration tests both enforce serial execution for this reason.
449pub struct EvolutionaryHarness<B, S, F>
450where
451 B: Backend,
452 S: Strategy<B>,
453 F: BatchFitnessFn<B, S::Genome>,
454{
455 strategy: S,
456 params: S::Params,
457 fitness_fn: F,
458 state: Option<S::State>,
459 rng: StdRng,
460 base_seed: u64,
461 device: B::Device,
462 generation: usize,
463 max_generations: usize,
464 latest_metrics: Option<StrategyMetrics>,
465 observer: Option<SharedPopulationObserver>,
466 _backend: PhantomData<B>,
467}
468
469impl<B, S, F> Debug for EvolutionaryHarness<B, S, F>
470where
471 B: Backend,
472 S: Strategy<B>,
473 F: BatchFitnessFn<B, S::Genome>,
474{
475 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476 f.debug_struct("EvolutionaryHarness")
477 .field("base_seed", &self.base_seed)
478 .field("generation", &self.generation)
479 .field("max_generations", &self.max_generations)
480 .field("latest_metrics", &self.latest_metrics)
481 .finish_non_exhaustive()
482 }
483}
484
485impl<B, S, F> EvolutionaryHarness<B, S, F>
486where
487 B: Backend,
488 S: Strategy<B>,
489 F: BatchFitnessFn<B, S::Genome>,
490{
491 /// Build a new harness from its parts.
492 ///
493 /// The caller-supplied `params` are validated up front — this is the
494 /// harness consumption chokepoint (ADR 0026), so an invalid configuration
495 /// is rejected here rather than surfacing as a panic deep inside a
496 /// strategy's tensor code.
497 ///
498 /// The harness is lazily initialized — the first [`reset`](BenchEnv::reset)
499 /// call materializes the initial state on the supplied device.
500 ///
501 /// # Errors
502 ///
503 /// Returns a [`ConfigError`] when `params` fails [`Validate::validate`],
504 /// naming the offending field and violated invariant.
505 pub fn new(
506 strategy: S,
507 params: S::Params,
508 fitness_fn: F,
509 seed: u64,
510 device: B::Device,
511 max_generations: usize,
512 ) -> Result<Self, ConfigError>
513 where
514 S::Params: Validate,
515 {
516 params.validate()?;
517 Ok(Self {
518 strategy,
519 params,
520 fitness_fn,
521 state: None,
522 rng: StdRng::seed_from_u64(seed),
523 base_seed: seed,
524 device,
525 generation: 0,
526 max_generations,
527 latest_metrics: None,
528 observer: None,
529 _backend: PhantomData,
530 })
531 }
532
533 /// Attach a per-generation [`PopulationObserver`].
534 ///
535 /// The observer is called once per [`step`](Self::step) call, after the
536 /// canonical `tracing::info!("evolution generation", …)` event. It
537 /// receives a [`PopulationSnapshot`]
538 /// carrying the full per-individual fitness vector for the completed
539 /// generation. The intended consumer is a benchmark-tier recording sink
540 /// that persists population-level data alongside the scalar metric stream.
541 ///
542 /// Attaching an observer adds one device→host transfer of the fitness
543 /// tensor per generation; runs without an observer pay nothing.
544 ///
545 /// [`PopulationObserver`]: crate::observer::PopulationObserver
546 #[must_use]
547 pub fn with_observer(mut self, observer: SharedPopulationObserver) -> Self {
548 self.observer = Some(observer);
549 self
550 }
551
552 /// Snapshot of the most recent generation's metrics, if any.
553 #[must_use]
554 pub fn latest_metrics(&self) -> Option<&StrategyMetrics> {
555 self.latest_metrics.as_ref()
556 }
557
558 /// Generation counter — number of completed `tell` calls.
559 #[must_use]
560 pub fn generation(&self) -> usize {
561 self.generation
562 }
563
564 /// Borrow the current strategy state if it exists.
565 #[must_use]
566 pub fn state(&self) -> Option<&S::State> {
567 self.state.as_ref()
568 }
569
570 /// Forward to [`Strategy::best`] when a state exists.
571 ///
572 /// The strategy tracks the best genome in **canonical (maximise)** space;
573 /// the returned fitness is mapped back to the objective's declared sense so
574 /// a `Minimize` landscape reads as its natural cost.
575 pub fn best(&self) -> Option<(S::Genome, f32)> {
576 let sense = self.fitness_fn.sense();
577 self.state
578 .as_ref()
579 .and_then(|s| self.strategy.best(s))
580 .map(|(genome, canonical)| (genome, sense.from_canonical(canonical)))
581 }
582
583 /// Reset to a fresh initial state.
584 ///
585 /// Inherent shape (infallible): `EvolutionaryHarness` cannot legitimately
586 /// fail to reset — it is a deterministic optimization driver. The
587 /// [`BenchEnv`] trait impl wraps this in `Ok(())` so the harness is
588 /// callable both directly (this method) and via the [`BenchEnv`] surface
589 /// when fed to `Evaluator::run_suite`.
590 pub fn reset(&mut self) {
591 self.rng = StdRng::seed_from_u64(self.base_seed);
592 self.generation = 0;
593 self.latest_metrics = None;
594 self.state = Some(
595 self.strategy
596 .init(&self.params, &mut self.rng, &self.device),
597 );
598 }
599
600 /// Run one ask → evaluate → tell generation.
601 ///
602 /// Inherent shape (infallible). The [`BenchEnv`] trait impl wraps this
603 /// in `Ok(...)`. See [`Self::reset`] for the rationale.
604 ///
605 /// # Panics
606 ///
607 /// Panics if [`reset`](Self::reset) has not been called first. Also panics
608 /// if an observer is attached and the natural-fitness tensor cannot be read
609 /// back to host as `f32` (a device→host transfer failure).
610 pub fn step(&mut self, _action: ()) -> BenchStep<()> {
611 let state = self
612 .state
613 .take()
614 .expect("EvolutionaryHarness::reset must be called before step");
615 let (population, state) =
616 self.strategy
617 .ask(&self.params, &state, &mut self.rng, &self.device);
618 // The fitness function reports NATURAL values; the harness is the sole
619 // canonicaliser. `sense` is the single source of truth (read off the
620 // fitness fn, so the ctor and the adapter can never disagree).
621 let sense = self.fitness_fn.sense();
622 let fitness_natural = self.fitness_fn.evaluate_batch(&population, &self.device);
623 // Mirror the NATURAL fitness tensor to host only if someone's actually
624 // listening — the device→host transfer is the expensive part. The
625 // observer records natural (user-sense) per-individual fitness.
626 let snapshot_fitness: Option<Vec<f32>> = self.observer.as_ref().map(|_| {
627 fitness_natural
628 .clone()
629 .into_data()
630 .into_vec::<f32>()
631 .expect("fitness tensor must be readable as f32")
632 });
633 // Canonicalise into the engine's maximise-native space: a `Minimize`
634 // objective is negated (one device op), a `Maximize` one passes through.
635 let fitness_canon = match sense {
636 ObjectiveSense::Maximize => fitness_natural,
637 ObjectiveSense::Minimize => fitness_natural.neg(),
638 };
639 // Fitness-hygiene chokepoint (ADR 0034). Sanitize in CANONICAL (maximise)
640 // space — `NaN → −∞` (worst), `+∞ → f32::MAX` — so no `Strategy::tell`
641 // impl can be poisoned by a non-finite fitness and every downstream best/
642 // leader/metric is finite-or-`−∞`. This runs *after* the `sense` negation
643 // on purpose: "NaN = worst" is defined in maximise space, so sanitizing
644 // the natural tensor before `neg()` would flip a `NaN` cost to `+∞`
645 // (canonical *best*) under `Minimize`.
646 let fitness_canon = crate::fitness::sanitize_fitness_tensor(fitness_canon);
647 let (new_state, metrics_canon) = self.strategy.tell(
648 &self.params,
649 population,
650 fitness_canon,
651 state,
652 &mut self.rng,
653 );
654 self.state = Some(new_state);
655 self.generation += 1;
656 // The reward is the canonical `best_fitness_ever` directly — canonical
657 // space is already higher-is-better, so the old `-best_fitness_ever`
658 // negation is gone. It stays monotone non-decreasing over a run, so the
659 // cumulative return (Σ step reward) integrates the optimization
660 // trajectory under the best-so-far curve. The benchmark harness reads
661 // per-episode `return_value`, not per-step rewards, so a pure "last
662 // best" signal would be lost.
663 let reward = f64::from(metrics_canon.best_fitness_ever);
664 // Map the canonical metrics back into the objective's declared sense so
665 // every surfaced value (tracing, `latest_metrics`, records) reads in
666 // user space — a `Minimize` landscape's `best_fitness` is its natural
667 // cost (Sphere → 0).
668 let metrics = StrategyMetrics {
669 generation: metrics_canon.generation,
670 population_size: metrics_canon.population_size,
671 best_fitness: sense.from_canonical(metrics_canon.best_fitness),
672 mean_fitness: sense.from_canonical(metrics_canon.mean_fitness),
673 worst_fitness: sense.from_canonical(metrics_canon.worst_fitness),
674 best_fitness_ever: sense.from_canonical(metrics_canon.best_fitness_ever),
675 // A count, not a value — sense-invariant, carried through verbatim.
676 broken_count: metrics_canon.broken_count,
677 };
678 // Structured per-generation event. Picked up by the
679 // canonical-metric registry in
680 // `rlevo-benchmarks::tui::log_layer::CANONICAL_METRICS` so the
681 // live TUI's fitness sparkline lights up without coupling this
682 // crate to the dashboard. Field names match the registry
683 // verbatim; renaming any of them requires a paired update on
684 // the benchmarks side.
685 tracing::info!(
686 generation = metrics.generation,
687 population_size = metrics.population_size,
688 best_fitness = f64::from(metrics.best_fitness),
689 mean_fitness = f64::from(metrics.mean_fitness),
690 worst_fitness = f64::from(metrics.worst_fitness),
691 best_fitness_ever = f64::from(metrics.best_fitness_ever),
692 broken_count = metrics.broken_count,
693 "evolution generation",
694 );
695 if let (Some(observer), Some(fitnesses)) = (self.observer.as_ref(), snapshot_fitness) {
696 let generation = u32::try_from(metrics.generation).unwrap_or(u32::MAX);
697 match build_population_snapshot(generation, fitnesses, sense) {
698 Some(snapshot) => {
699 // Isolate the observer: a panicking third-party sink drops
700 // this snapshot but must not abort an otherwise-healthy
701 // optimization run. `SharedPopulationObserver` is backed by a
702 // `parking_lot::Mutex` (no poisoning), so the guard drops
703 // during unwind and the next generation re-locks cleanly.
704 let dispatched = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
705 observer.lock().on_population(snapshot);
706 }));
707 if dispatched.is_err() {
708 tracing::warn!(
709 generation,
710 "population observer panicked; dropping snapshot and continuing",
711 );
712 }
713 }
714 None => {
715 // An empty fitness vector means the device→host transfer at
716 // `snapshot_fitness` yielded nothing (a masked conversion
717 // failure). Surface it rather than emitting an out-of-range
718 // `best_index` into a zero-length vector.
719 tracing::warn!(
720 generation,
721 "empty population fitness vector; skipping observer snapshot \
722 (device→host transfer likely failed)",
723 );
724 }
725 }
726 }
727 self.latest_metrics = Some(metrics);
728 let done = self.generation >= self.max_generations;
729 BenchStep {
730 observation: (),
731 reward,
732 done,
733 }
734 }
735}
736
737impl<B, S, F> BenchEnv for EvolutionaryHarness<B, S, F>
738where
739 B: Backend,
740 S: Strategy<B>,
741 F: BatchFitnessFn<B, S::Genome>,
742{
743 type Observation = ();
744 type Action = ();
745
746 fn reset(&mut self) -> Result<Self::Observation, BenchError> {
747 EvolutionaryHarness::<B, S, F>::reset(self);
748 Ok(())
749 }
750
751 fn step(&mut self, action: Self::Action) -> Result<BenchStep<Self::Observation>, BenchError> {
752 Ok(EvolutionaryHarness::<B, S, F>::step(self, action))
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use burn::backend::Flex;
760 use burn::tensor::TensorData;
761 type TestBackend = Flex;
762
763 /// Trivial strategy for unit-testing the harness plumbing: it
764 /// ignores `ask`/`tell` semantics and always reports the same best
765 /// fitness. Nothing here exercises real evolutionary dynamics.
766 #[derive(Debug, Clone, Copy)]
767 struct Constant;
768
769 #[derive(Debug, Clone)]
770 struct Params {
771 pop_size: usize,
772 dim: usize,
773 }
774
775 impl Validate for Params {
776 fn validate(&self) -> Result<(), ConfigError> {
777 rlevo_core::config::nonzero("Params", "pop_size", self.pop_size)?;
778 rlevo_core::config::nonzero("Params", "dim", self.dim)?;
779 Ok(())
780 }
781 }
782
783 #[derive(Debug, Clone)]
784 struct State {
785 generation: usize,
786 best: f32,
787 }
788
789 impl Strategy<TestBackend> for Constant {
790 type Params = Params;
791 type State = State;
792 type Genome = Tensor<TestBackend, 2>;
793
794 fn init(
795 &self,
796 params: &Params,
797 _: &mut dyn Rng,
798 device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
799 ) -> State {
800 let _ = device;
801 let _ = params;
802 State {
803 generation: 0,
804 best: f32::NEG_INFINITY,
805 }
806 }
807
808 fn ask(
809 &self,
810 params: &Params,
811 state: &State,
812 _: &mut dyn Rng,
813 device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
814 ) -> (Tensor<TestBackend, 2>, State) {
815 let data = TensorData::new(
816 vec![0.0f32; params.pop_size * params.dim],
817 [params.pop_size, params.dim],
818 );
819 let pop = Tensor::<TestBackend, 2>::from_data(data, device);
820 (pop, state.clone())
821 }
822
823 fn tell(
824 &self,
825 _: &Params,
826 _: Tensor<TestBackend, 2>,
827 fitness: Tensor<TestBackend, 1>,
828 mut state: State,
829 _: &mut dyn Rng,
830 ) -> (State, StrategyMetrics) {
831 let values = fitness
832 .into_data()
833 .into_vec::<f32>()
834 .expect("fitness host-read of a tensor this test just built");
835 state.generation += 1;
836 let metrics = StrategyMetrics::from_host_fitness(state.generation, &values, state.best);
837 state.best = metrics.best_fitness_ever();
838 (state, metrics)
839 }
840
841 fn best(&self, _state: &State) -> Option<(Tensor<TestBackend, 2>, f32)> {
842 None
843 }
844 }
845
846 /// Constant fitness = 42 regardless of input.
847 struct FortyTwo;
848 impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for FortyTwo {
849 fn evaluate_batch(
850 &mut self,
851 population: &Tensor<B, 2>,
852 device: &<B as burn::tensor::backend::BackendTypes>::Device,
853 ) -> Tensor<B, 1> {
854 let n = population.dims()[0];
855 let data = TensorData::new(vec![42.0f32; n], [n]);
856 Tensor::<B, 1>::from_data(data, device)
857 }
858
859 fn sense(&self) -> ObjectiveSense {
860 // Treated as a cost so the harness reports natural 42 and reward
861 // stays the canonical −42 the existing assertions expect.
862 ObjectiveSense::Minimize
863 }
864 }
865
866 #[test]
867 #[allow(clippy::float_cmp)]
868 fn harness_runs_one_generation() {
869 let device = Default::default();
870 let strategy = Constant;
871 let params = Params {
872 pop_size: 4,
873 dim: 3,
874 };
875 let mut harness =
876 EvolutionaryHarness::<TestBackend, _, _>::new(strategy, params, FortyTwo, 1, device, 5)
877 .expect("valid params");
878 harness.reset();
879 let step = harness.step(());
880 assert_eq!(step.reward, -42.0);
881 assert!(!step.done);
882 assert_eq!(harness.generation(), 1);
883 let m = harness.latest_metrics().unwrap();
884 assert_eq!(m.generation, 1);
885 assert_eq!(m.population_size, 4);
886 approx::assert_relative_eq!(m.best_fitness, 42.0, epsilon = 1e-6);
887 }
888
889 #[test]
890 fn harness_reports_done_after_budget() {
891 let device = Default::default();
892 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
893 Constant,
894 Params {
895 pop_size: 2,
896 dim: 2,
897 },
898 FortyTwo,
899 1,
900 device,
901 2,
902 )
903 .expect("valid params");
904 harness.reset();
905 assert!(!harness.step(()).done);
906 assert!(harness.step(()).done);
907 }
908
909 #[test]
910 fn from_host_fitness_computes_stats() {
911 let m = StrategyMetrics::from_host_fitness(5, &[3.0, 1.0, 5.0, 2.0], 4.0);
912 // Read through the public accessors (fields are private).
913 assert_eq!(m.generation(), 5);
914 assert_eq!(m.population_size(), 4);
915 // Canonical maximise: best is the largest, worst the smallest.
916 approx::assert_relative_eq!(m.best_fitness(), 5.0, epsilon = 1e-6);
917 approx::assert_relative_eq!(m.worst_fitness(), 1.0, epsilon = 1e-6);
918 approx::assert_relative_eq!(m.mean_fitness(), 2.75, epsilon = 1e-6);
919 // best_fitness_ever = max(prior=4.0, current=5.0)
920 approx::assert_relative_eq!(m.best_fitness_ever(), 5.0, epsilon = 1e-6);
921 }
922
923 #[test]
924 fn from_host_fitness_sanitizes_nan() {
925 // A NaN is sanitized to −∞ (worst under maximise): it never becomes best,
926 // and it drags `worst` to −∞. Under ADR 0034 it is *excluded* from the
927 // mean (counted as broken) rather than blanking the mean to −∞: the mean
928 // is over the finite members {1, 3, 2} = 2.0, with broken_count == 1.
929 let m = StrategyMetrics::from_host_fitness(0, &[1.0, f32::NAN, 3.0, 2.0], 0.0);
930 approx::assert_relative_eq!(m.best_fitness(), 3.0, epsilon = 1e-6);
931 assert!(m.worst_fitness().is_infinite() && m.worst_fitness().is_sign_negative());
932 approx::assert_relative_eq!(m.mean_fitness(), 2.0, epsilon = 1e-6);
933 assert_eq!(m.broken_count(), 1);
934 approx::assert_relative_eq!(m.best_fitness_ever(), 3.0, epsilon = 1e-6);
935 }
936
937 #[test]
938 fn from_host_fitness_pos_inf_ranks_top_but_mean_stays_finite() {
939 // +∞ → f32::MAX (ADR 0034): it stays best/finite and is *included* in the
940 // mean (no −∞/broken), so an optimal individual cannot blow the mean up.
941 let m = StrategyMetrics::from_host_fitness(0, &[1.0, f32::INFINITY, 3.0], 0.0);
942 approx::assert_relative_eq!(m.best_fitness(), f32::MAX);
943 assert_eq!(m.broken_count(), 0);
944 assert!(m.mean_fitness().is_finite());
945 }
946
947 #[test]
948 fn from_host_fitness_all_broken_yields_neg_inf_mean() {
949 // Degenerate but well-defined: every member broken → mean = −∞.
950 let m = StrategyMetrics::from_host_fitness(0, &[f32::NAN, f32::NAN], 0.0);
951 assert_eq!(m.broken_count(), 2);
952 assert!(m.mean_fitness().is_infinite() && m.mean_fitness().is_sign_negative());
953 }
954
955 /// A misbehaving objective: row 0 → `NaN`, row 1 → `+∞`, the rest finite.
956 /// `Maximize` so natural == canonical (no `neg()` obscuring the sanitize).
957 struct NonFiniteFitness;
958 impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for NonFiniteFitness {
959 fn evaluate_batch(
960 &mut self,
961 population: &Tensor<B, 2>,
962 device: &<B as burn::tensor::backend::BackendTypes>::Device,
963 ) -> Tensor<B, 1> {
964 let n = population.dims()[0];
965 #[allow(clippy::cast_precision_loss)] // tiny test population indices
966 let mut vals: Vec<f32> = (0..n).map(|i| i as f32).collect();
967 vals[0] = f32::NAN;
968 if n > 1 {
969 vals[1] = f32::INFINITY;
970 }
971 Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
972 }
973 fn sense(&self) -> ObjectiveSense {
974 ObjectiveSense::Maximize
975 }
976 }
977
978 /// A strategy that **trusts the harness guarantee** (ADR 0034): its `tell`
979 /// stores the fitness tensor it receives verbatim, *without* re-sanitizing.
980 /// This is the whole point of the chokepoint — a `tell` impl should be able
981 /// to build best/leaders directly from `fitness`. `received` lets the test
982 /// assert what actually crossed the seam.
983 #[derive(Debug, Clone, Copy)]
984 struct TrustingStrategy;
985
986 #[derive(Debug, Clone)]
987 struct TrustingState {
988 generation: usize,
989 best: f32,
990 received: Vec<f32>,
991 }
992
993 impl Strategy<TestBackend> for TrustingStrategy {
994 type Params = Params;
995 type State = TrustingState;
996 type Genome = Tensor<TestBackend, 2>;
997
998 fn init(
999 &self,
1000 _: &Params,
1001 _: &mut dyn Rng,
1002 _: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
1003 ) -> TrustingState {
1004 TrustingState {
1005 generation: 0,
1006 best: f32::NEG_INFINITY,
1007 received: Vec::new(),
1008 }
1009 }
1010
1011 fn ask(
1012 &self,
1013 params: &Params,
1014 state: &TrustingState,
1015 _: &mut dyn Rng,
1016 device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
1017 ) -> (Tensor<TestBackend, 2>, TrustingState) {
1018 let data = TensorData::new(
1019 vec![0.0f32; params.pop_size * params.dim],
1020 [params.pop_size, params.dim],
1021 );
1022 (
1023 Tensor::<TestBackend, 2>::from_data(data, device),
1024 state.clone(),
1025 )
1026 }
1027
1028 fn tell(
1029 &self,
1030 _: &Params,
1031 _: Tensor<TestBackend, 2>,
1032 fitness: Tensor<TestBackend, 1>,
1033 mut state: TrustingState,
1034 _: &mut dyn Rng,
1035 ) -> (TrustingState, StrategyMetrics) {
1036 // Deliberately NOT sanitized here — the harness must have done it.
1037 let values: Vec<f32> = fitness
1038 .into_data()
1039 .into_vec::<f32>()
1040 .expect("fitness host-read of a tensor this test just built");
1041 state.received = values.clone();
1042 state.generation += 1;
1043 let metrics: StrategyMetrics =
1044 StrategyMetrics::from_host_fitness(state.generation, &values, state.best);
1045 state.best = metrics.best_fitness_ever();
1046 (state, metrics)
1047 }
1048
1049 fn best(&self, _: &TrustingState) -> Option<(Tensor<TestBackend, 2>, f32)> {
1050 None
1051 }
1052 }
1053
1054 /// End-to-end proof that the `EvolutionaryHarness::step` chokepoint (ADR
1055 /// 0034) sanitizes a non-finite fitness **before** it reaches a real
1056 /// `Strategy::tell`. This is the widest-blast-radius line in the design
1057 /// (it covers every `Strategy` impl), so it gets a direct test rather than
1058 /// relying on `StrategyMetrics::from_host_fitness`'s own sanitize: the
1059 /// `TrustingStrategy` captures the raw tensor it was handed, so deleting or
1060 /// mis-ordering the harness sanitize (relative to `neg()`) fails here.
1061 #[test]
1062 fn harness_sanitizes_non_finite_fitness_before_tell() {
1063 let device = Default::default();
1064 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
1065 TrustingStrategy,
1066 Params {
1067 pop_size: 4,
1068 dim: 2,
1069 },
1070 NonFiniteFitness,
1071 7,
1072 device,
1073 1,
1074 )
1075 .expect("valid params");
1076 harness.reset();
1077 harness.step(());
1078
1079 let received = &harness.state().expect("state after step").received;
1080 assert_eq!(received.len(), 4);
1081 // The chokepoint guarantee: what `tell` saw is finite-or-`−∞`.
1082 assert!(
1083 received.iter().all(|f| !f.is_nan()),
1084 "harness must strip NaN before tell; got {received:?}"
1085 );
1086 assert!(
1087 received
1088 .iter()
1089 .all(|f| !(f.is_infinite() && f.is_sign_positive())),
1090 "harness must clamp +∞ before tell; got {received:?}"
1091 );
1092 // Row 0 was NaN → −∞ (worst); row 1 was +∞ → f32::MAX (finite best).
1093 assert!(
1094 received[0].is_infinite() && received[0].is_sign_negative(),
1095 "NaN row → −∞"
1096 );
1097 approx::assert_relative_eq!(received[1], f32::MAX);
1098
1099 // Metrics stay honest: best is finite (the f32::MAX row), one broken member.
1100 let m = harness.latest_metrics().expect("metrics after step");
1101 assert!(
1102 m.best_fitness().is_finite(),
1103 "best must be finite, got {}",
1104 m.best_fitness()
1105 );
1106 assert_eq!(m.broken_count(), 1, "the NaN row is the one broken member");
1107 assert!(
1108 m.mean_fitness().is_finite(),
1109 "mean over finite members stays finite"
1110 );
1111 }
1112
1113 #[test]
1114 fn build_population_snapshot_empty_returns_none() {
1115 assert!(build_population_snapshot(0, Vec::new(), ObjectiveSense::Minimize).is_none());
1116 }
1117
1118 #[test]
1119 fn build_population_snapshot_picks_best_for_sense() {
1120 // Values: [0.3, 0.1, 0.9]. Minimize → best is the smallest (index 1);
1121 // Maximize → best is the largest (index 2).
1122 let min = build_population_snapshot(7, vec![0.3, 0.1, 0.9], ObjectiveSense::Minimize)
1123 .expect("non-empty");
1124 assert_eq!(min.best_index, 1);
1125 assert_eq!(min.generation, 7);
1126 let max = build_population_snapshot(7, vec![0.3, 0.1, 0.9], ObjectiveSense::Maximize)
1127 .expect("non-empty");
1128 assert_eq!(max.best_index, 2);
1129 }
1130
1131 /// Per-individual fitness = `1.0 / (i + 1)` so the best (smallest)
1132 /// is always at index `pop_size - 1` — a deterministic shape the
1133 /// observer test can pin against.
1134 struct RankedFitness;
1135 impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for RankedFitness {
1136 fn evaluate_batch(
1137 &mut self,
1138 population: &Tensor<B, 2>,
1139 device: &<B as burn::tensor::backend::BackendTypes>::Device,
1140 ) -> Tensor<B, 1> {
1141 let n = population.dims()[0];
1142 #[allow(clippy::cast_precision_loss)]
1143 let values: Vec<f32> = (0..n).map(|i| 1.0 / (i as f32 + 1.0)).collect();
1144 let data = TensorData::new(values, [n]);
1145 Tensor::<B, 1>::from_data(data, device)
1146 }
1147
1148 fn sense(&self) -> ObjectiveSense {
1149 // Cost: the best (smallest) is the last index, which the observer
1150 // test pins via the sense-aware `best_index`.
1151 ObjectiveSense::Minimize
1152 }
1153 }
1154
1155 #[derive(Debug, Default)]
1156 struct CountingObserver {
1157 snapshots: Vec<PopulationSnapshot>,
1158 }
1159
1160 impl crate::observer::PopulationObserver for CountingObserver {
1161 fn on_population(&mut self, snapshot: PopulationSnapshot) {
1162 self.snapshots.push(snapshot);
1163 }
1164 }
1165
1166 #[test]
1167 fn harness_fires_observer_per_generation() {
1168 use std::sync::Arc;
1169
1170 use parking_lot::Mutex;
1171 let device = Default::default();
1172 let observer = Arc::new(Mutex::new(CountingObserver::default()));
1173 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
1174 Constant,
1175 Params {
1176 pop_size: 5,
1177 dim: 2,
1178 },
1179 RankedFitness,
1180 1,
1181 device,
1182 3,
1183 )
1184 .expect("valid params")
1185 .with_observer(observer.clone() as SharedPopulationObserver);
1186 harness.reset();
1187 for _ in 0..3 {
1188 harness.step(());
1189 }
1190 let guard = observer.lock();
1191 assert_eq!(guard.snapshots.len(), 3);
1192 // pop_size = 5, ranked fitness = [1/1, 1/2, 1/3, 1/4, 1/5]; best
1193 // (smallest) is the last element.
1194 assert_eq!(guard.snapshots[0].fitnesses.len(), 5);
1195 assert_eq!(guard.snapshots[0].best_index, 4);
1196 assert_eq!(guard.snapshots[2].generation, 3);
1197 // M8.1 leaves these fields empty / None — see observer.rs docs.
1198 assert!(guard.snapshots[0].diversity.is_none());
1199 assert!(guard.snapshots[0].best_genome_digest.is_none());
1200 assert!(guard.snapshots[0].parents_of_best.is_empty());
1201 }
1202
1203 /// Observer whose callback always panics — used to prove the harness
1204 /// isolates a misbehaving sink instead of aborting the run.
1205 #[derive(Debug, Default)]
1206 struct PanicObserver;
1207
1208 impl crate::observer::PopulationObserver for PanicObserver {
1209 fn on_population(&mut self, _snapshot: PopulationSnapshot) {
1210 panic!("observer intentionally panics");
1211 }
1212 }
1213
1214 #[test]
1215 fn harness_survives_panicking_observer() {
1216 use std::sync::Arc;
1217
1218 use parking_lot::Mutex;
1219 let device = Default::default();
1220 let observer = Arc::new(Mutex::new(PanicObserver));
1221 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
1222 Constant,
1223 Params {
1224 pop_size: 4,
1225 dim: 2,
1226 },
1227 RankedFitness,
1228 1,
1229 device,
1230 2,
1231 )
1232 .expect("valid params")
1233 .with_observer(observer.clone() as SharedPopulationObserver);
1234 harness.reset();
1235 // Each step's observer dispatch panics; the harness must swallow it and
1236 // keep advancing generations to completion.
1237 assert!(!harness.step(()).done);
1238 assert!(harness.step(()).done);
1239 assert_eq!(harness.generation(), 2);
1240 }
1241
1242 #[test]
1243 fn harness_without_observer_skips_host_transfer() {
1244 // Smoke: no observer attached → step() still works, no panic,
1245 // no transfer cost. Observability is verified above; here we
1246 // just want the no-observer path to remain functional.
1247 let device = Default::default();
1248 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
1249 Constant,
1250 Params {
1251 pop_size: 3,
1252 dim: 1,
1253 },
1254 RankedFitness,
1255 1,
1256 device,
1257 1,
1258 )
1259 .expect("valid params");
1260 harness.reset();
1261 let step = harness.step(());
1262 assert!(step.done);
1263 }
1264}