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