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}