Skip to main content

rlevo_evolution/
strategy.rs

1//! Central [`Strategy`] trait and the [`EvolutionaryHarness`] adapter.
2//!
3//! # The ask / tell contract
4//!
5//! A [`Strategy`] exposes three methods that together drive one
6//! generation:
7//!
8//! 1. [`init`](Strategy::init) — build the initial state (sampling the
9//!    population, initializing σ, generation counter, etc).
10//! 2. [`ask`](Strategy::ask) — propose the next population as a genome
11//!    container.
12//! 3. [`tell`](Strategy::tell) — consume that population together with
13//!    its fitness and produce the next state plus a metrics snapshot.
14//!
15//! All three methods take the RNG explicitly so the harness owns all
16//! stochasticity; strategies carry *no* internal PRNG state.
17//!
18//! # Fitness convention
19//!
20//! The fitness tensor passed to [`tell`](Strategy::tell) is the raw
21//! objective value. Strategies in this crate minimize it: the
22//! [`StrategyMetrics::best_fitness`] field is the smallest value observed
23//! so far, and the harness reports `reward = -best_fitness` so the
24//! benchmark harness's "higher = better" convention still holds.
25//!
26//! # The harness adapter
27//!
28//! [`EvolutionaryHarness`] glues a strategy to any
29//! [`BatchFitnessFn`] and implements
30//! [`BenchEnv`], so the benchmark
31//! evaluator drives it just like an RL environment.
32
33use std::fmt::Debug;
34use std::marker::PhantomData;
35
36use burn::tensor::{Tensor, backend::Backend};
37use rand::rngs::StdRng;
38use rand::{Rng, SeedableRng};
39
40use rlevo_core::evaluation::{BenchEnv, BenchError, BenchStep};
41
42use crate::fitness::BatchFitnessFn;
43
44/// Central evolutionary-strategy abstraction.
45///
46/// The trait is intentionally pure — [`ask`](Self::ask) and
47/// [`tell`](Self::tell) return a new `State` rather than mutating
48/// through `&mut self`. That keeps strategies free of interior
49/// mutability (so many instances can run in parallel without locks) and
50/// makes [`Clone`]-based checkpointing straightforward.
51///
52/// # Example
53///
54/// ```no_run
55/// use burn::backend::NdArray;
56/// use rlevo_evolution::algorithms::ga::{GaConfig, GeneticAlgorithm};
57/// use rlevo_evolution::Strategy;
58/// use rand::{rngs::StdRng, SeedableRng};
59///
60/// let device = Default::default();
61/// let strategy = GeneticAlgorithm::<NdArray>::new();
62/// let params = GaConfig::default_for(64, 10);
63/// let mut rng = StdRng::seed_from_u64(0);
64/// let state = strategy.init(&params, &mut rng, &device);
65/// assert_eq!(state.population.shape().dims, vec![64, 10]);
66/// ```
67///
68/// # Type Parameters
69///
70/// - `B`: Burn backend.
71///
72/// # Associated Types
73///
74/// - `Params`: Static configuration for a run (population size, σ, F,
75///   CR, …). Adaptive algorithms mutate their adaptive quantities inside
76///   `State`, not `Params`.
77/// - `State`: Generation-to-generation state (current population, σ,
78///   best-so-far, RNG-free sub-statistics). Must be clonable so the
79///   harness can snapshot before a risky step if needed.
80/// - `Genome`: Genome container produced by `ask` and consumed by
81///   `tell`. Typically a `Tensor<B, 2>` for real-valued strategies or a
82///   `Tensor<B, 2, Int>` for binary/integer kinds.
83pub trait Strategy<B: Backend>: Send + Sync {
84    /// Static parameters for a run.
85    type Params: Clone + Debug + Send + Sync;
86
87    /// Generation-to-generation state.
88    type State: Clone + Debug + Send;
89
90    /// Genome container produced by [`ask`](Self::ask).
91    type Genome: Clone + Send;
92
93    /// Build the initial state.
94    ///
95    /// Samples the initial population, primes adaptive quantities, and
96    /// sets the generation counter to zero.
97    fn init(&self, params: &Self::Params, rng: &mut dyn Rng, device: &B::Device) -> Self::State;
98
99    /// Propose the next population.
100    ///
101    /// Takes the current `state` and returns the genome to evaluate
102    /// together with an updated state. The returned state typically
103    /// carries pre-computed bookkeeping (e.g. the parent indices a
104    /// tournament-based GA sampled) so [`tell`](Self::tell) can reuse
105    /// them without re-sampling.
106    fn ask(
107        &self,
108        params: &Self::Params,
109        state: &Self::State,
110        rng: &mut dyn Rng,
111        device: &B::Device,
112    ) -> (Self::Genome, Self::State);
113
114    /// Consume fitness values and produce the next state.
115    ///
116    /// `fitness` has shape `(pop_size,)` on the same device as the
117    /// population. Strategies pull it to host only if they need to —
118    /// e.g. for tournament index lookups.
119    fn tell(
120        &self,
121        params: &Self::Params,
122        population: Self::Genome,
123        fitness: Tensor<B, 1>,
124        state: Self::State,
125        rng: &mut dyn Rng,
126    ) -> (Self::State, StrategyMetrics);
127
128    /// Best-so-far accessor.
129    ///
130    /// Returns `None` before the first [`tell`](Self::tell) call.
131    fn best(&self, state: &Self::State) -> Option<(Self::Genome, f32)>;
132}
133
134/// Per-generation summary reported by [`Strategy::tell`].
135///
136/// All statistics refer to the generation that just finished evaluating.
137/// Fitness values follow the minimization convention: lower is better.
138#[derive(Debug, Clone)]
139pub struct StrategyMetrics {
140    /// Zero-based generation index.
141    pub generation: usize,
142    /// Number of individuals evaluated in this generation.
143    pub population_size: usize,
144    /// Smallest fitness observed in this generation.
145    pub best_fitness: f32,
146    /// Mean fitness across this generation's population.
147    pub mean_fitness: f32,
148    /// Largest fitness observed in this generation.
149    pub worst_fitness: f32,
150    /// Best fitness seen across *all* generations to date.
151    pub best_fitness_ever: f32,
152}
153
154impl StrategyMetrics {
155    /// Computes population statistics from a host-side fitness slice.
156    ///
157    /// # Panics
158    ///
159    /// Panics if `fitnesses` is empty.
160    #[must_use]
161    pub fn from_host_fitness(generation: usize, fitnesses: &[f32], best_fitness_ever: f32) -> Self {
162        assert!(!fitnesses.is_empty(), "fitness slice must be non-empty");
163        let population_size = fitnesses.len();
164        let (mut best, mut worst, mut sum) = (f32::INFINITY, f32::NEG_INFINITY, 0.0_f32);
165        for &f in fitnesses {
166            if f < best {
167                best = f;
168            }
169            if f > worst {
170                worst = f;
171            }
172            sum += f;
173        }
174        #[allow(clippy::cast_precision_loss)]
175        let mean = sum / population_size as f32;
176        Self {
177            generation,
178            population_size,
179            best_fitness: best,
180            mean_fitness: mean,
181            worst_fitness: worst,
182            best_fitness_ever: best_fitness_ever.min(best),
183        }
184    }
185}
186
187/// Wraps a [`Strategy`] into a [`BenchEnv`] so the benchmark harness can
188/// drive it.
189///
190/// # Example
191///
192/// ```no_run
193/// use burn::backend::NdArray;
194/// use rlevo_core::fitness::FitnessEvaluable;
195/// use rlevo_core::evaluation::BenchEnv;
196/// use rlevo_evolution::algorithms::ga::{GaConfig, GeneticAlgorithm};
197/// use rlevo_evolution::fitness::FromFitnessEvaluable;
198/// use rlevo_evolution::strategy::EvolutionaryHarness;
199///
200/// struct Sphere;
201/// struct SphereFit;
202/// impl FitnessEvaluable for SphereFit {
203///     type Individual = Vec<f64>;
204///     type Landscape = Sphere;
205///     fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
206///         x.iter().map(|v| v * v).sum()
207///     }
208/// }
209///
210/// let device = Default::default();
211/// let mut harness = EvolutionaryHarness::<NdArray, _, _>::new(
212///     GeneticAlgorithm::<NdArray>::new(),
213///     GaConfig::default_for(32, 5),
214///     FromFitnessEvaluable::new(SphereFit, Sphere),
215///     0, device, 100,
216/// );
217/// harness.reset();
218/// while !harness.step(()).done {}
219/// ```
220///
221/// Each [`step`](BenchEnv::step) runs one generation (ask → evaluate →
222/// tell). The reward returned to the harness is `-best_fitness_ever` so
223/// the harness's "higher = better" convention matches the strategy's
224/// minimization direction, and so the per-episode cumulative return
225/// (Σ step rewards) integrates the optimization trajectory —
226/// `return_value / num_steps` bounds the final `best_fitness_ever` from
227/// above. The harness only exposes episode-level returns to reporters,
228/// so the "best at end" signal would otherwise be lost.
229///
230/// # Determinism and parallel execution
231///
232/// Burn backends seed their tensor RNG through process-global state —
233/// the `ndarray` backend uses a `Mutex<Option<NdArrayRng>>`, the
234/// `wgpu` backend a per-device seeded stream. When multiple harness
235/// instances run in parallel threads (e.g.
236/// `Evaluator::run_suite` with the default rayon pool), their
237/// interleaved `B::seed(...) → Tensor::random(...)` call pairs race on
238/// that shared state and destroy bit-reproducibility across runs.
239///
240/// For deterministic reproduction, pass
241/// `EvaluatorConfig::num_threads = Some(1)` or run one harness per
242/// process. The `tests/determinism.rs` and `tests/rastrigin_run_suite.rs`
243/// integration tests both enforce serial execution for this reason.
244pub struct EvolutionaryHarness<B, S, F>
245where
246    B: Backend,
247    S: Strategy<B>,
248    F: BatchFitnessFn<B, S::Genome>,
249{
250    strategy: S,
251    params: S::Params,
252    fitness_fn: F,
253    state: Option<S::State>,
254    rng: StdRng,
255    base_seed: u64,
256    device: B::Device,
257    generation: usize,
258    max_generations: usize,
259    latest_metrics: Option<StrategyMetrics>,
260    _backend: PhantomData<B>,
261}
262
263impl<B, S, F> Debug for EvolutionaryHarness<B, S, F>
264where
265    B: Backend,
266    S: Strategy<B>,
267    F: BatchFitnessFn<B, S::Genome>,
268{
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        f.debug_struct("EvolutionaryHarness")
271            .field("base_seed", &self.base_seed)
272            .field("generation", &self.generation)
273            .field("max_generations", &self.max_generations)
274            .field("latest_metrics", &self.latest_metrics)
275            .finish_non_exhaustive()
276    }
277}
278
279impl<B, S, F> EvolutionaryHarness<B, S, F>
280where
281    B: Backend,
282    S: Strategy<B>,
283    F: BatchFitnessFn<B, S::Genome>,
284{
285    /// Build a new harness from its parts.
286    ///
287    /// The harness is lazily initialized — the first [`reset`](BenchEnv::reset)
288    /// call materializes the initial state on the supplied device.
289    pub fn new(
290        strategy: S,
291        params: S::Params,
292        fitness_fn: F,
293        seed: u64,
294        device: B::Device,
295        max_generations: usize,
296    ) -> Self {
297        Self {
298            strategy,
299            params,
300            fitness_fn,
301            state: None,
302            rng: StdRng::seed_from_u64(seed),
303            base_seed: seed,
304            device,
305            generation: 0,
306            max_generations,
307            latest_metrics: None,
308            _backend: PhantomData,
309        }
310    }
311
312    /// Snapshot of the most recent generation's metrics, if any.
313    #[must_use]
314    pub fn latest_metrics(&self) -> Option<&StrategyMetrics> {
315        self.latest_metrics.as_ref()
316    }
317
318    /// Generation counter — number of completed `tell` calls.
319    #[must_use]
320    pub fn generation(&self) -> usize {
321        self.generation
322    }
323
324    /// Borrow the current strategy state if it exists.
325    #[must_use]
326    pub fn state(&self) -> Option<&S::State> {
327        self.state.as_ref()
328    }
329
330    /// Forward to [`Strategy::best`] when a state exists.
331    pub fn best(&self) -> Option<(S::Genome, f32)> {
332        self.state.as_ref().and_then(|s| self.strategy.best(s))
333    }
334
335    /// Reset to a fresh initial state.
336    ///
337    /// Inherent shape (infallible): `EvolutionaryHarness` cannot legitimately
338    /// fail to reset — it is a deterministic optimization driver. The
339    /// [`BenchEnv`] trait impl wraps this in `Ok(())` so the harness is
340    /// callable both directly (this method) and via the [`BenchEnv`] surface
341    /// when fed to `Evaluator::run_suite`.
342    pub fn reset(&mut self) {
343        self.rng = StdRng::seed_from_u64(self.base_seed);
344        self.generation = 0;
345        self.latest_metrics = None;
346        self.state = Some(
347            self.strategy
348                .init(&self.params, &mut self.rng, &self.device),
349        );
350    }
351
352    /// Run one ask → evaluate → tell generation.
353    ///
354    /// Inherent shape (infallible). The [`BenchEnv`] trait impl wraps this
355    /// in `Ok(...)`. See [`Self::reset`] for the rationale.
356    ///
357    /// # Panics
358    ///
359    /// Panics if [`reset`](Self::reset) has not been called first.
360    pub fn step(&mut self, _action: ()) -> BenchStep<()> {
361        let state = self
362            .state
363            .take()
364            .expect("EvolutionaryHarness::reset must be called before step");
365        let (population, state) =
366            self.strategy
367                .ask(&self.params, &state, &mut self.rng, &self.device);
368        let fitness = self.fitness_fn.evaluate_batch(&population, &self.device);
369        let (new_state, metrics) =
370            self.strategy
371                .tell(&self.params, population, fitness, state, &mut self.rng);
372        self.state = Some(new_state);
373        self.generation += 1;
374        // Emit `-best_fitness_ever` so the reward is monotone
375        // non-decreasing over a run and the cumulative return (Σ step
376        // reward) integrates the optimization trajectory under the
377        // best-so-far curve. The benchmark harness reads per-episode
378        // `return_value` not per-step rewards, so a pure "last best"
379        // signal would be lost.
380        let reward = -f64::from(metrics.best_fitness_ever);
381        self.latest_metrics = Some(metrics);
382        let done = self.generation >= self.max_generations;
383        BenchStep {
384            observation: (),
385            reward,
386            done,
387        }
388    }
389}
390
391impl<B, S, F> BenchEnv for EvolutionaryHarness<B, S, F>
392where
393    B: Backend,
394    S: Strategy<B>,
395    F: BatchFitnessFn<B, S::Genome>,
396{
397    type Observation = ();
398    type Action = ();
399
400    fn reset(&mut self) -> Result<Self::Observation, BenchError> {
401        EvolutionaryHarness::<B, S, F>::reset(self);
402        Ok(())
403    }
404
405    fn step(
406        &mut self,
407        action: Self::Action,
408    ) -> Result<BenchStep<Self::Observation>, BenchError> {
409        Ok(EvolutionaryHarness::<B, S, F>::step(self, action))
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use burn::backend::NdArray;
417    use burn::tensor::TensorData;
418    type TestBackend = NdArray;
419
420    /// Trivial strategy for unit-testing the harness plumbing: it
421    /// ignores `ask`/`tell` semantics and always reports the same best
422    /// fitness. Nothing here exercises real evolutionary dynamics.
423    #[derive(Debug, Clone, Copy)]
424    struct Constant;
425
426    #[derive(Debug, Clone)]
427    struct Params {
428        pop_size: usize,
429        dim: usize,
430    }
431
432    #[derive(Debug, Clone)]
433    struct State {
434        generation: usize,
435        best: f32,
436    }
437
438    impl Strategy<TestBackend> for Constant {
439        type Params = Params;
440        type State = State;
441        type Genome = Tensor<TestBackend, 2>;
442
443        fn init(
444            &self,
445            params: &Params,
446            _: &mut dyn Rng,
447            device: &<TestBackend as Backend>::Device,
448        ) -> State {
449            let _ = device;
450            let _ = params;
451            State {
452                generation: 0,
453                best: f32::INFINITY,
454            }
455        }
456
457        fn ask(
458            &self,
459            params: &Params,
460            state: &State,
461            _: &mut dyn Rng,
462            device: &<TestBackend as Backend>::Device,
463        ) -> (Tensor<TestBackend, 2>, State) {
464            let data = TensorData::new(
465                vec![0.0f32; params.pop_size * params.dim],
466                [params.pop_size, params.dim],
467            );
468            let pop = Tensor::<TestBackend, 2>::from_data(data, device);
469            (pop, state.clone())
470        }
471
472        fn tell(
473            &self,
474            _: &Params,
475            _: Tensor<TestBackend, 2>,
476            fitness: Tensor<TestBackend, 1>,
477            mut state: State,
478            _: &mut dyn Rng,
479        ) -> (State, StrategyMetrics) {
480            let values = fitness.into_data().into_vec::<f32>().unwrap();
481            state.generation += 1;
482            let metrics = StrategyMetrics::from_host_fitness(state.generation, &values, state.best);
483            state.best = metrics.best_fitness_ever;
484            (state, metrics)
485        }
486
487        fn best(&self, _state: &State) -> Option<(Tensor<TestBackend, 2>, f32)> {
488            None
489        }
490    }
491
492    /// Constant fitness = 42 regardless of input.
493    struct FortyTwo;
494    impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for FortyTwo {
495        fn evaluate_batch(
496            &mut self,
497            population: &Tensor<B, 2>,
498            device: &B::Device,
499        ) -> Tensor<B, 1> {
500            let n = population.shape().dims[0];
501            let data = TensorData::new(vec![42.0f32; n], [n]);
502            Tensor::<B, 1>::from_data(data, device)
503        }
504    }
505
506    #[test]
507    #[allow(clippy::float_cmp)]
508    fn harness_runs_one_generation() {
509        let device = Default::default();
510        let strategy = Constant;
511        let params = Params {
512            pop_size: 4,
513            dim: 3,
514        };
515        let mut harness =
516            EvolutionaryHarness::<TestBackend, _, _>::new(strategy, params, FortyTwo, 1, device, 5);
517        harness.reset();
518        let step = harness.step(());
519        assert_eq!(step.reward, -42.0);
520        assert!(!step.done);
521        assert_eq!(harness.generation(), 1);
522        let m = harness.latest_metrics().unwrap();
523        assert_eq!(m.generation, 1);
524        assert_eq!(m.population_size, 4);
525        approx::assert_relative_eq!(m.best_fitness, 42.0, epsilon = 1e-6);
526    }
527
528    #[test]
529    fn harness_reports_done_after_budget() {
530        let device = Default::default();
531        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
532            Constant,
533            Params {
534                pop_size: 2,
535                dim: 2,
536            },
537            FortyTwo,
538            1,
539            device,
540            2,
541        );
542        harness.reset();
543        assert!(!harness.step(()).done);
544        assert!(harness.step(()).done);
545    }
546
547    #[test]
548    fn from_host_fitness_computes_stats() {
549        let m = StrategyMetrics::from_host_fitness(5, &[3.0, 1.0, 5.0, 2.0], 4.0);
550        assert_eq!(m.generation, 5);
551        assert_eq!(m.population_size, 4);
552        approx::assert_relative_eq!(m.best_fitness, 1.0, epsilon = 1e-6);
553        approx::assert_relative_eq!(m.worst_fitness, 5.0, epsilon = 1e-6);
554        approx::assert_relative_eq!(m.mean_fitness, 2.75, epsilon = 1e-6);
555        // best_fitness_ever = min(prior=4.0, current=1.0)
556        approx::assert_relative_eq!(m.best_fitness_ever, 1.0, epsilon = 1e-6);
557    }
558}