Skip to main content

rlevo_evolution/
fitness.rs

1//! Fitness evaluation traits and adapters.
2//!
3//! Two traits model the two evaluation shapes strategies expect:
4//!
5//! - [`FitnessFn`] — evaluates a single member. Callers hand the fitness
6//!   function a host-side genome row (typically `Vec<f32>`) and receive a
7//!   scalar. Useful for simple benchmarks and for unit-testing operators.
8//! - [`BatchFitnessFn`] — evaluates an entire population in one call and
9//!   returns a device-resident `Tensor<B, 1>` of shape `(pop_size,)`. This
10//!   is the hot path — strategies call it once per generation.
11//!
12//! Two adapters bridge host-side scalar fitness code into [`BatchFitnessFn`]:
13//!
14//! - [`FromFitnessEvaluable`] — wraps
15//!   `rlevo-core::fitness::FitnessEvaluable<Individual = Vec<f64>, Landscape = L>`.
16//!   Use this when an evaluator and a landscape type are already defined
17//!   separately (e.g. `RastriginEvaluator` + `RastriginLandscape`).
18//! - [`FromLandscape`] — wraps `rlevo-core::fitness::Landscape` directly.
19//!   Use this when the landscape is self-evaluating (Sphere, Ackley, Rastrigin)
20//!   and a separate evaluator shim would add no value.
21//!
22//! Both adapters pull each population row to host as `f32`, widen to `f64`,
23//! evaluate on the CPU, and re-upload the results as a `Tensor<B, 1>`.
24//! Purpose-built batched-on-device landscapes should implement
25//! [`BatchFitnessFn`] directly to avoid that round-trip.
26
27use burn::tensor::{Tensor, TensorData, backend::Backend};
28
29use rlevo_core::fitness::{FitnessEvaluable, Landscape};
30use rlevo_core::objective::ObjectiveSense;
31
32/// Single-member fitness evaluation.
33///
34/// Implementors may hold mutable state (e.g. a counter for number of
35/// evaluations) and are therefore `&mut self`.
36pub trait FitnessFn<G>: Send {
37    /// Evaluates one genome and returns its scalar fitness.
38    fn evaluate_one(&mut self, member: &G) -> f32;
39}
40
41/// Batched fitness evaluation over a population genome container `G`.
42///
43/// The returned tensor has shape `(pop_size,)` on the supplied device.
44/// Implementors must preserve row order — `fitness[i]` refers to the
45/// individual at row `i` of `population`.
46pub trait BatchFitnessFn<B: Backend, G>: Send {
47    /// Evaluates every member of `population` and returns a fitness tensor in
48    /// the objective's **natural** value space (no hand-negation).
49    ///
50    /// The returned `Tensor<B, 1>` has shape `(pop_size,)` and is placed on
51    /// `device`. Row order is preserved: `fitness[i]` corresponds to the
52    /// individual at row `i` of `population`. Cost objectives return their
53    /// natural cost; the harness reconciles direction via [`sense`](Self::sense).
54    ///
55    /// The returned tensor **may contain `NaN` or `±∞`** — implementors are not
56    /// required to sanitize. The
57    /// [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness) canonicalizes
58    /// and then sanitizes (ADR 0034) before any [`Strategy::tell`](crate::strategy::Strategy::tell),
59    /// so a non-finite fitness cannot poison selection or best-so-far tracking on
60    /// harness-driven runs.
61    fn evaluate_batch(
62        &mut self,
63        population: &G,
64        device: &<B as burn::tensor::backend::BackendTypes>::Device,
65    ) -> Tensor<B, 1>;
66
67    /// The optimisation direction of this objective.
68    ///
69    /// This is the **single source of truth** the
70    /// [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness) reads to
71    /// reconcile a cost objective into the engine's canonical (maximise) space.
72    /// It is **required, with no default**, so a reward/accuracy objective
73    /// cannot silently inherit the wrong direction by omission — declare it
74    /// explicitly ([`ObjectiveSense::Maximize`] for a reward,
75    /// [`ObjectiveSense::Minimize`] for a cost). The bundled landscape adapters
76    /// ([`FromLandscape`], [`FromFitnessEvaluable`]) forward the landscape's
77    /// declared sense.
78    fn sense(&self) -> ObjectiveSense;
79}
80
81/// Adapter from `FitnessEvaluable` to [`BatchFitnessFn<B, Tensor<B, 2>>`].
82///
83/// Each row of the population is pulled to host, converted to `Vec<f64>`,
84/// and passed to the underlying evaluator with the configured landscape.
85/// Fitness is computed on the host and then re-uploaded as a single
86/// `Tensor<B, 1>`.
87///
88/// # Precision
89///
90/// Populations are read as `f32` and widened to `f64` for the evaluator
91/// call; the returned `f64` fitness is narrowed back to `f32` before it
92/// is uploaded as a `Tensor<B, 1>`. Fitness values that exceed `f32`
93/// range (or rely on sub-ulp precision) will lose information at the
94/// narrowing step. Purpose-built batched-on-device landscapes should
95/// implement [`BatchFitnessFn`] directly to avoid the round-trip.
96///
97/// # Type Parameters
98///
99/// - `FE`: Concrete [`FitnessEvaluable`] implementation.
100/// - `L`: Landscape type; must match `FE::Landscape`.
101///
102/// # Panics
103///
104/// `evaluate_batch` panics if the supplied population tensor is not rank
105/// 2, or if its data cannot be read as `f32` (e.g. an integer backend).
106#[derive(Debug)]
107pub struct FromFitnessEvaluable<FE, L> {
108    evaluator: FE,
109    landscape: L,
110    sense: ObjectiveSense,
111}
112
113impl<FE, L> FromFitnessEvaluable<FE, L> {
114    /// Builds the adapter from an evaluator and a landscape, defaulting the
115    /// objective sense to [`ObjectiveSense::Minimize`] (the cost convention a
116    /// [`FitnessEvaluable`] follows).
117    ///
118    /// Use [`with_sense`](Self::with_sense) to declare a maximisation objective
119    /// (reward, accuracy) explicitly.
120    pub fn new(evaluator: FE, landscape: L) -> Self {
121        Self::with_sense(evaluator, landscape, ObjectiveSense::Minimize)
122    }
123
124    /// Builds the adapter with an explicit [`ObjectiveSense`].
125    pub fn with_sense(evaluator: FE, landscape: L, sense: ObjectiveSense) -> Self {
126        Self {
127            evaluator,
128            landscape,
129            sense,
130        }
131    }
132
133    /// Returns a reference to the wrapped landscape.
134    pub fn landscape(&self) -> &L {
135        &self.landscape
136    }
137}
138
139impl<FE, L, B> BatchFitnessFn<B, Tensor<B, 2>> for FromFitnessEvaluable<FE, L>
140where
141    B: Backend,
142    FE: FitnessEvaluable<Individual = Vec<f64>, Landscape = L> + Send,
143    L: Send + Sync,
144{
145    fn evaluate_batch(
146        &mut self,
147        population: &Tensor<B, 2>,
148        device: &<B as burn::tensor::backend::BackendTypes>::Device,
149    ) -> Tensor<B, 1> {
150        let dims = population.dims();
151        assert_eq!(dims.len(), 2, "population tensor must be rank 2");
152        let pop_size = dims[0];
153        let genome_dim = dims[1];
154
155        let flat = population
156            .clone()
157            .into_data()
158            .into_vec::<f32>()
159            .expect("tensor data must be readable as f32");
160        debug_assert_eq!(flat.len(), pop_size * genome_dim);
161
162        let mut fitness = Vec::with_capacity(pop_size);
163        let mut individual = Vec::with_capacity(genome_dim);
164        for row in 0..pop_size {
165            individual.clear();
166            let start = row * genome_dim;
167            individual.extend(
168                flat[start..start + genome_dim]
169                    .iter()
170                    .map(|&v| f64::from(v)),
171            );
172            let f = self.evaluator.evaluate(&individual, &self.landscape);
173            #[allow(clippy::cast_possible_truncation)]
174            fitness.push(f as f32);
175        }
176
177        let data = TensorData::new(fitness, [pop_size]);
178        Tensor::<B, 1>::from_data(data, device)
179    }
180
181    fn sense(&self) -> ObjectiveSense {
182        self.sense
183    }
184}
185
186/// Adapter from [`Landscape`] to [`BatchFitnessFn<B, Tensor<B, 2>>`].
187///
188/// Use this when the landscape carries its own `evaluate(&[f64]) -> f64`
189/// (Sphere, Ackley, Rastrigin) so the example does not need a separate
190/// `FitnessEvaluable` shim. Each row is pulled to host as `f32`, widened
191/// to `f64`, evaluated, and re-uploaded as a `Tensor<B, 1>` — same
192/// precision caveats as [`FromFitnessEvaluable`] apply.
193///
194/// # Panics
195///
196/// `evaluate_batch` panics if the supplied population tensor is not rank
197/// 2, or if its data cannot be read as `f32` (e.g. an integer backend).
198#[derive(Debug)]
199pub struct FromLandscape<L> {
200    landscape: L,
201    sense: ObjectiveSense,
202}
203
204impl<L: Landscape> FromLandscape<L> {
205    /// Builds the adapter from a self-evaluating landscape, taking the
206    /// objective sense from the landscape's [`Landscape::sense`] (which
207    /// defaults to [`ObjectiveSense::Minimize`]).
208    pub fn new(landscape: L) -> Self {
209        let sense = landscape.sense();
210        Self { landscape, sense }
211    }
212
213    /// Builds the adapter with an explicit [`ObjectiveSense`], overriding the
214    /// landscape's declared sense. Examples and showcases spell out
215    /// [`ObjectiveSense::Minimize`] here so intent is visible at the call site.
216    pub fn with_sense(landscape: L, sense: ObjectiveSense) -> Self {
217        Self { landscape, sense }
218    }
219
220    /// Returns a reference to the wrapped landscape.
221    pub fn landscape(&self) -> &L {
222        &self.landscape
223    }
224}
225
226impl<L, B> BatchFitnessFn<B, Tensor<B, 2>> for FromLandscape<L>
227where
228    B: Backend,
229    L: Landscape,
230{
231    fn evaluate_batch(
232        &mut self,
233        population: &Tensor<B, 2>,
234        device: &<B as burn::tensor::backend::BackendTypes>::Device,
235    ) -> Tensor<B, 1> {
236        let dims = population.dims();
237        assert_eq!(dims.len(), 2, "population tensor must be rank 2");
238        let pop_size = dims[0];
239        let genome_dim = dims[1];
240
241        let flat = population
242            .clone()
243            .into_data()
244            .into_vec::<f32>()
245            .expect("tensor data must be readable as f32");
246        debug_assert_eq!(flat.len(), pop_size * genome_dim);
247
248        let mut fitness = Vec::with_capacity(pop_size);
249        let mut individual = Vec::with_capacity(genome_dim);
250        for row in 0..pop_size {
251            individual.clear();
252            let start = row * genome_dim;
253            individual.extend(
254                flat[start..start + genome_dim]
255                    .iter()
256                    .map(|&v| f64::from(v)),
257            );
258            let f = self.landscape.evaluate(&individual);
259            #[allow(clippy::cast_possible_truncation)]
260            fitness.push(f as f32);
261        }
262
263        let data = TensorData::new(fitness, [pop_size]);
264        Tensor::<B, 1>::from_data(data, device)
265    }
266
267    fn sense(&self) -> ObjectiveSense {
268        self.sense
269    }
270}
271
272/// Sanitizes one **canonical (maximise-space)** fitness value: `NaN →`
273/// [`f32::NEG_INFINITY`], `+∞ →` [`f32::MAX`], everything else (including `−∞`)
274/// passes through.
275///
276/// This is the crate-wide fitness-hygiene primitive and the single rule of the
277/// canonical convention (ADR 0023 / ADR 0034):
278///
279/// - `NaN → −∞`: `−∞` is the worst value under the maximise convention, so a
280///   sanitized `NaN` can never seed or displace a finite best-so-far. Rust's
281///   `f32::NAN` is a *positive* NaN, so `total_cmp` would otherwise rank it as
282///   the **maximum** (`rules.md` §3) — the exact inversion this prevents.
283/// - `+∞ → f32::MAX`: a genuinely optimal individual (a landscape hitting its
284///   optimum, an unbounded reward) still ranks top, but as a **finite** value —
285///   so it cannot blow a population `mean`/`variance`/reward to `+∞`.
286/// - `−∞` passes through: it is the worst-value sentinel *and* the
287///   uninitialized `best_fitness_ever` seed, and it must stay non-finite so the
288///   mean-over-finite statistic in
289///   [`StrategyMetrics::from_host_fitness`](crate::strategy::StrategyMetrics::from_host_fitness)
290///   can see and count it as a broken member.
291///
292/// Applied by [`BudgetedEval::eval`](crate::local_search::BudgetedEval) (every
293/// probe, including the seeding eval), the searchers'
294/// [`refine_with_known_fitness`](crate::local_search::LocalSearch::refine_with_known_fitness)
295/// overrides, the EDA `tell` chokepoint
296/// ([`crate::algorithms::eda::EdaStrategy`]), and every NaN-safe fitness sort
297/// across the crate (selection, replacement, the ES/NEAT/ACO rankers). For the
298/// finite benchmark landscapes the searchers ship against no branch is taken, so
299/// it costs only one `is_nan` / `is_infinite` check.
300pub(crate) fn sanitize_fitness(f: f32) -> f32 {
301    if f.is_nan() {
302        f32::NEG_INFINITY
303    } else if f.is_infinite() && f.is_sign_positive() {
304        // `f == f32::INFINITY` would trip the float-equality lint (rules §5/§8).
305        f32::MAX
306    } else {
307        f
308    }
309}
310
311/// Tensor-level [`sanitize_fitness`] for the driver chokepoints — a single
312/// device op over a `(pop_size,)` **canonical-space** fitness vector.
313///
314/// Applies the same rule (`NaN → −∞`, `+∞ → f32::MAX`, `−∞` pass-through) to a
315/// whole fitness tensor without a device→host→device round-trip, so the
316/// [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness) and the
317/// coevolution coupled-fitness path can sanitize on the hot path (ADR 0034).
318///
319/// Order matters: the `NaN → −∞` `mask_fill` runs first (so no `NaN` reaches the
320/// clamp, which would propagate it), then `clamp_max(f32::MAX)` caps `+∞` while
321/// leaving `−∞` and every finite value untouched. Mirrors the `is_nan` +
322/// `mask_fill` + clamp idiom already used by `EdaStrategy::tell`'s gene backstop.
323#[must_use]
324pub(crate) fn sanitize_fitness_tensor<B: Backend>(fitness: Tensor<B, 1>) -> Tensor<B, 1> {
325    let nan_mask = fitness.clone().is_nan();
326    fitness
327        .mask_fill(nan_mask, f32::NEG_INFINITY)
328        .clamp_max(f32::MAX)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use burn::backend::Flex;
335    type TestBackend = Flex;
336
337    #[derive(Debug, Clone, Copy)]
338    struct Sphere;
339
340    struct SphereFit;
341    impl FitnessEvaluable for SphereFit {
342        type Individual = Vec<f64>;
343        type Landscape = Sphere;
344        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
345            x.iter().map(|v| v * v).sum()
346        }
347    }
348
349    #[test]
350    fn from_fitness_evaluable_preserves_row_order() {
351        let device = Default::default();
352        let data = TensorData::new(
353            vec![1.0_f32, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0],
354            [3, 3],
355        );
356        let pop = Tensor::<TestBackend, 2>::from_data(data, &device);
357
358        let mut adapter = FromFitnessEvaluable::new(SphereFit, Sphere);
359        let fitness = adapter.evaluate_batch(&pop, &device);
360
361        let values = fitness
362            .into_data()
363            .into_vec::<f32>()
364            .expect("fitness host-read of a tensor this test just built");
365        assert_eq!(values.len(), 3);
366        approx::assert_relative_eq!(values[0], 1.0, epsilon = 1e-6);
367        approx::assert_relative_eq!(values[1], 4.0, epsilon = 1e-6);
368        approx::assert_relative_eq!(values[2], 9.0, epsilon = 1e-6);
369    }
370
371    #[test]
372    fn from_landscape_preserves_row_order() {
373        let device = Default::default();
374        let data = TensorData::new(
375            vec![1.0_f32, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0],
376            [3, 3],
377        );
378        let pop = Tensor::<TestBackend, 2>::from_data(data, &device);
379
380        let mut adapter = FromLandscape::new(SphereLandscape);
381        let fitness = adapter.evaluate_batch(&pop, &device);
382
383        let values = fitness
384            .into_data()
385            .into_vec::<f32>()
386            .expect("fitness host-read of a tensor this test just built");
387        assert_eq!(values.len(), 3);
388        approx::assert_relative_eq!(values[0], 1.0, epsilon = 1e-6);
389        approx::assert_relative_eq!(values[1], 4.0, epsilon = 1e-6);
390        approx::assert_relative_eq!(values[2], 9.0, epsilon = 1e-6);
391    }
392
393    struct SphereLandscape;
394    impl Landscape for SphereLandscape {
395        fn evaluate(&self, x: &[f64]) -> f64 {
396            x.iter().map(|v| v * v).sum()
397        }
398    }
399
400    /// The scalar hygiene rule (ADR 0034): `NaN → −∞`, `+∞ → f32::MAX`, `−∞` and
401    /// finite values pass through unchanged.
402    #[test]
403    fn sanitize_fitness_scalar_applies_canonical_rule() {
404        // `−∞` sentinel: assert via `is_infinite`/sign, not float `==` (rules §5/§8).
405        let nan_out: f32 = sanitize_fitness(f32::NAN);
406        assert!(
407            nan_out.is_infinite() && nan_out.is_sign_negative(),
408            "NaN → −∞"
409        );
410        approx::assert_relative_eq!(sanitize_fitness(f32::INFINITY), f32::MAX);
411        let neg_out: f32 = sanitize_fitness(f32::NEG_INFINITY);
412        assert!(
413            neg_out.is_infinite() && neg_out.is_sign_negative(),
414            "−∞ passes through"
415        );
416        approx::assert_relative_eq!(sanitize_fitness(2.5), 2.5, epsilon = 1e-6);
417        approx::assert_relative_eq!(sanitize_fitness(-7.0), -7.0, epsilon = 1e-6);
418    }
419
420    /// The tensor sibling applies the identical rule element-wise, and — crucially
421    /// — leaves `−∞` non-finite (it is not clamped to `−f32::MAX`) so downstream
422    /// mean-over-finite logic can still detect and count it.
423    #[test]
424    fn sanitize_fitness_tensor_matches_scalar_rule() {
425        let device = Default::default();
426        let data = TensorData::new(
427            vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 3.0_f32, -4.0],
428            [5],
429        );
430        let t = Tensor::<TestBackend, 1>::from_data(data, &device);
431        let out = sanitize_fitness_tensor(t)
432            .into_data()
433            .into_vec::<f32>()
434            .expect("fitness host-read of a tensor this test just built");
435
436        assert!(
437            out[0].is_infinite() && out[0].is_sign_negative(),
438            "NaN → −∞"
439        );
440        approx::assert_relative_eq!(out[1], f32::MAX); // +∞ → f32::MAX
441        assert!(
442            out[2].is_infinite() && out[2].is_sign_negative(),
443            "−∞ passes through, stays non-finite"
444        );
445        approx::assert_relative_eq!(out[3], 3.0, epsilon = 1e-6);
446        approx::assert_relative_eq!(out[4], -4.0, epsilon = 1e-6);
447    }
448
449    /// Regression test for the load-bearing `BatchFitnessFn` invariant
450    /// documented in the fitness chapter of the user-book: `evaluate_batch`
451    /// returns a `Tensor<B, 1>` of shape `(pop_size,)` with row order
452    /// preserved.
453    ///
454    /// The population is deliberately **non-square** (`pop_size != genome_dim`)
455    /// so a row/column transposition — reading the genome axis as the
456    /// population axis — cannot hide behind a square shape, and the output
457    /// length is asserted against `pop_size` (the rows), not `genome_dim`.
458    /// Row `i` is `[i + 1, 0]`, so Sphere fitness is `(i + 1)^2`: a permuted
459    /// mapping yields a different, detectable vector.
460    #[test]
461    fn from_fitness_evaluable_output_is_pop_size_shaped_and_row_aligned() {
462        let device = Default::default();
463        // 4 individuals, 2 genes each.
464        let data = TensorData::new(vec![1.0_f32, 0.0, 2.0, 0.0, 3.0, 0.0, 4.0, 0.0], [4, 2]);
465        let pop = Tensor::<TestBackend, 2>::from_data(data, &device);
466
467        let mut adapter = FromFitnessEvaluable::new(SphereFit, Sphere);
468        let fitness = adapter.evaluate_batch(&pop, &device);
469
470        // Shape `(pop_size,)`: rank 1, exactly `pop_size` (4) elements —
471        // not `genome_dim` (2).
472        assert_eq!(fitness.dims(), [4]);
473
474        let values = fitness
475            .into_data()
476            .into_vec::<f32>()
477            .expect("fitness host-read of a tensor this test just built");
478        for (i, &v) in values.iter().enumerate() {
479            #[allow(clippy::cast_precision_loss)]
480            let expected = ((i + 1) * (i + 1)) as f32;
481            approx::assert_relative_eq!(v, expected, epsilon = 1e-6);
482        }
483    }
484
485    /// Same invariant for [`FromLandscape`] — the two adapters carry
486    /// independent copies of the row-walking loop, so each pins the shape and
487    /// row alignment separately.
488    #[test]
489    fn from_landscape_output_is_pop_size_shaped_and_row_aligned() {
490        let device = Default::default();
491        // 4 individuals, 2 genes each — deliberately non-square.
492        let data = TensorData::new(vec![1.0_f32, 0.0, 2.0, 0.0, 3.0, 0.0, 4.0, 0.0], [4, 2]);
493        let pop = Tensor::<TestBackend, 2>::from_data(data, &device);
494
495        let mut adapter = FromLandscape::new(SphereLandscape);
496        let fitness = adapter.evaluate_batch(&pop, &device);
497
498        assert_eq!(fitness.dims(), [4]);
499
500        let values = fitness
501            .into_data()
502            .into_vec::<f32>()
503            .expect("fitness host-read of a tensor this test just built");
504        for (i, &v) in values.iter().enumerate() {
505            #[allow(clippy::cast_precision_loss)]
506            let expected = ((i + 1) * (i + 1)) as f32;
507            approx::assert_relative_eq!(v, expected, epsilon = 1e-6);
508        }
509    }
510}