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