Skip to main content

fugue_evo/fitness/
traits.rs

1//! Fitness traits
2//!
3//! This module defines the fitness evaluation traits.
4
5use std::fmt::Debug;
6
7use serde::{de::DeserializeOwned, Serialize};
8
9use crate::genome::traits::EvolutionaryGenome;
10
11/// Trait bound for fitness values
12///
13/// Fitness values must be comparable and convertible to f64 for
14/// probabilistic selection operations. They must also be serializable
15/// for checkpointing.
16pub trait FitnessValue:
17    PartialOrd + Clone + Send + Sync + Debug + Serialize + DeserializeOwned + 'static
18{
19    /// Convert fitness to f64 for probabilistic operations
20    fn to_f64(&self) -> f64;
21
22    /// Check if this fitness is better than another
23    fn is_better_than(&self, other: &Self) -> bool;
24
25    /// Check if this fitness is worse than another
26    fn is_worse_than(&self, other: &Self) -> bool {
27        other.is_better_than(self)
28    }
29
30    /// Total ordering by quality, where [`Ordering::Greater`] means `self` is
31    /// the better individual.
32    ///
33    /// This delegates to [`is_better_than`](FitnessValue::is_better_than) for
34    /// the quality comparison (never to a `to_f64()`-derived scalar, which can
35    /// disagree with the true ordering — see the `ParetoFitness` case where
36    /// infinite crowding distances collapse every rank to `+inf`). Any value
37    /// whose `to_f64()` is `NaN` is ranked strictly worst, so the result is a
38    /// genuine total order usable with `max_by`/`min_by`/`sort_by` even if a
39    /// `NaN` fitness slips past [`Individual::set_fitness`]'s guard (defense in
40    /// depth).
41    ///
42    /// [`Ordering::Greater`]: std::cmp::Ordering::Greater
43    /// [`Individual::set_fitness`]: crate::population::individual::Individual::set_fitness
44    fn cmp_by_quality(&self, other: &Self) -> std::cmp::Ordering {
45        use std::cmp::Ordering;
46        let self_nan = self.to_f64().is_nan();
47        let other_nan = other.to_f64().is_nan();
48        match (self_nan, other_nan) {
49            (true, true) => Ordering::Equal,
50            // A NaN is strictly worse than any real fitness.
51            (true, false) => Ordering::Less,
52            (false, true) => Ordering::Greater,
53            (false, false) => {
54                if self.is_better_than(other) {
55                    Ordering::Greater
56                } else if other.is_better_than(self) {
57                    Ordering::Less
58                } else {
59                    Ordering::Equal
60                }
61            }
62        }
63    }
64}
65
66impl FitnessValue for f64 {
67    fn to_f64(&self) -> f64 {
68        *self
69    }
70
71    fn is_better_than(&self, other: &Self) -> bool {
72        self > other
73    }
74}
75
76impl FitnessValue for f32 {
77    fn to_f64(&self) -> f64 {
78        *self as f64
79    }
80
81    fn is_better_than(&self, other: &Self) -> bool {
82        self > other
83    }
84}
85
86impl FitnessValue for i64 {
87    fn to_f64(&self) -> f64 {
88        *self as f64
89    }
90
91    fn is_better_than(&self, other: &Self) -> bool {
92        self > other
93    }
94}
95
96impl FitnessValue for i32 {
97    fn to_f64(&self) -> f64 {
98        *self as f64
99    }
100
101    fn is_better_than(&self, other: &Self) -> bool {
102        self > other
103    }
104}
105
106impl FitnessValue for usize {
107    fn to_f64(&self) -> f64 {
108        *self as f64
109    }
110
111    fn is_better_than(&self, other: &Self) -> bool {
112        self > other
113    }
114}
115
116/// Multi-objective fitness value using Pareto ranking
117#[derive(Clone, Debug, PartialEq, Serialize, serde::Deserialize)]
118pub struct ParetoFitness {
119    /// Objective values (all to be maximized)
120    pub objectives: Vec<f64>,
121    /// Pareto rank (0 = non-dominated front)
122    pub rank: usize,
123    /// Crowding distance for diversity preservation
124    pub crowding_distance: f64,
125}
126
127impl ParetoFitness {
128    /// Create a new Pareto fitness with the given objectives
129    pub fn new(objectives: Vec<f64>) -> Self {
130        Self {
131            objectives,
132            rank: usize::MAX,
133            crowding_distance: 0.0,
134        }
135    }
136
137    /// Check if this solution dominates another
138    /// (all objectives >= and at least one >)
139    pub fn dominates(&self, other: &Self) -> bool {
140        let dominated = self
141            .objectives
142            .iter()
143            .zip(other.objectives.iter())
144            .all(|(a, b)| a >= b);
145        let strictly_better = self
146            .objectives
147            .iter()
148            .zip(other.objectives.iter())
149            .any(|(a, b)| a > b);
150        dominated && strictly_better
151    }
152
153    /// Number of objectives
154    pub fn num_objectives(&self) -> usize {
155        self.objectives.len()
156    }
157}
158
159impl PartialOrd for ParetoFitness {
160    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
161        // Compare by rank first, then by crowding distance
162        match self.rank.partial_cmp(&other.rank) {
163            Some(std::cmp::Ordering::Equal) => {
164                // Higher crowding distance is better (more diverse)
165                self.crowding_distance.partial_cmp(&other.crowding_distance)
166            }
167            ord => ord.map(|o| o.reverse()), // Reverse because lower rank is better
168        }
169    }
170}
171
172impl FitnessValue for ParetoFitness {
173    fn to_f64(&self) -> f64 {
174        // Aggregated scalar for probabilistic interpretation
175        // Lower rank is better, so negate it
176        -(self.rank as f64) + self.crowding_distance * 0.001
177    }
178
179    fn is_better_than(&self, other: &Self) -> bool {
180        self.rank < other.rank
181            || (self.rank == other.rank && self.crowding_distance > other.crowding_distance)
182    }
183}
184
185/// Fitness evaluation trait
186///
187/// Defines how to evaluate the fitness of a genome.
188///
189/// # `Send + Sync` and the `parallel` feature
190///
191/// With the `parallel` feature this trait has `Send + Sync` as supertraits
192/// (rayon evaluates a population from several threads); without it there is
193/// no such bound, so a fitness that holds a `!Send` value — a `js_sys::Function`
194/// in the crate's own WASM bindings, an `Rc` — is a valid implementor. This
195/// is a deliberate, **known non-additive feature** (EV-N5): a crate that
196/// compiles without `parallel` can implement `Fitness` for a `!Send` type
197/// and fail to build the moment any crate in its dependency graph enables
198/// `parallel`. Requiring the bound unconditionally was considered and
199/// rejected because it would force single-threaded WASM consumers into
200/// `unsafe impl Send` wrappers for their JavaScript callbacks. If your crate
201/// must build with and without `parallel`, make your fitness `Send + Sync`;
202/// the inference layer (`ppl`) requires that independently through
203/// `FactorFitness`, `GenomePrior` and `GenomeLikelihood`.
204#[cfg(feature = "parallel")]
205pub trait Fitness: Send + Sync {
206    /// The genome type being evaluated
207    type Genome: EvolutionaryGenome;
208
209    /// The fitness value type
210    type Value: FitnessValue;
211
212    /// Evaluate fitness (higher = better by convention)
213    fn evaluate(&self, genome: &Self::Genome) -> Self::Value;
214
215    /// Convert fitness to log-likelihood for probabilistic selection
216    ///
217    /// Uses Boltzmann distribution: P(x) ∝ exp(f(x) / T)
218    fn as_log_likelihood(&self, genome: &Self::Genome, temperature: f64) -> f64 {
219        let fitness = self.evaluate(genome).to_f64();
220        fitness / temperature
221    }
222
223    /// Optional: Provide gradient for gradient-assisted mutation
224    fn gradient(&self, _genome: &Self::Genome) -> Option<Vec<f64>> {
225        None
226    }
227}
228
229/// Fitness evaluation trait (non-parallel version)
230///
231/// Defines how to evaluate the fitness of a genome. Without the `parallel`
232/// feature there is no `Send + Sync` supertrait, so `!Send` fitnesses (a
233/// JavaScript callback in a WASM build) are valid implementors; see the
234/// `parallel` variant's documentation for why this split is deliberate and
235/// what it means for crates that build both ways.
236#[cfg(not(feature = "parallel"))]
237pub trait Fitness {
238    /// The genome type being evaluated
239    type Genome: EvolutionaryGenome;
240
241    /// The fitness value type
242    type Value: FitnessValue;
243
244    /// Evaluate fitness (higher = better by convention)
245    fn evaluate(&self, genome: &Self::Genome) -> Self::Value;
246
247    /// Convert fitness to log-likelihood for probabilistic selection
248    ///
249    /// Uses Boltzmann distribution: P(x) ∝ exp(f(x) / T)
250    fn as_log_likelihood(&self, genome: &Self::Genome, temperature: f64) -> f64 {
251        let fitness = self.evaluate(genome).to_f64();
252        fitness / temperature
253    }
254
255    /// Optional: Provide gradient for gradient-assisted mutation
256    fn gradient(&self, _genome: &Self::Genome) -> Option<Vec<f64>> {
257        None
258    }
259}
260
261/// A wrapper to negate a fitness function (for minimization problems)
262pub struct MinimizeFitness<F> {
263    inner: F,
264}
265
266impl<F> MinimizeFitness<F> {
267    /// Create a minimization wrapper around a fitness function
268    pub fn new(fitness: F) -> Self {
269        Self { inner: fitness }
270    }
271}
272
273impl<F: Fitness<Value = f64>> Fitness for MinimizeFitness<F> {
274    type Genome = F::Genome;
275    type Value = f64;
276
277    fn evaluate(&self, genome: &Self::Genome) -> f64 {
278        -self.inner.evaluate(genome)
279    }
280}
281
282/// A simple function wrapper for fitness evaluation
283pub struct FnFitness<G, F, V>
284where
285    F: Fn(&G) -> V,
286{
287    f: F,
288    _marker: std::marker::PhantomData<(G, V)>,
289}
290
291impl<G, F, V> FnFitness<G, F, V>
292where
293    F: Fn(&G) -> V,
294{
295    /// Create a new function-based fitness evaluator
296    pub fn new(f: F) -> Self {
297        Self {
298            f,
299            _marker: std::marker::PhantomData,
300        }
301    }
302}
303
304impl<G, F, V> Fitness for FnFitness<G, F, V>
305where
306    G: EvolutionaryGenome,
307    F: Fn(&G) -> V + Send + Sync,
308    V: FitnessValue,
309{
310    type Genome = G;
311    type Value = V;
312
313    fn evaluate(&self, genome: &Self::Genome) -> Self::Value {
314        (self.f)(genome)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::genome::real_vector::RealVector;
322    use crate::genome::traits::RealValuedGenome;
323
324    #[test]
325    fn test_f64_fitness_value() {
326        let a: f64 = 10.0;
327        let b: f64 = 5.0;
328
329        assert!(a.is_better_than(&b));
330        assert!(!b.is_better_than(&a));
331        assert!(b.is_worse_than(&a));
332        assert_eq!(a.to_f64(), 10.0);
333    }
334
335    #[test]
336    fn test_i32_fitness_value() {
337        let a: i32 = 10;
338        let b: i32 = 5;
339
340        assert!(a.is_better_than(&b));
341        assert!(!b.is_better_than(&a));
342        assert_eq!(a.to_f64(), 10.0);
343    }
344
345    #[test]
346    fn test_usize_fitness_value() {
347        let a: usize = 10;
348        let b: usize = 5;
349
350        assert!(a.is_better_than(&b));
351        assert!(!b.is_better_than(&a));
352        assert_eq!(a.to_f64(), 10.0);
353    }
354
355    #[test]
356    fn test_pareto_fitness_dominates() {
357        let a = ParetoFitness::new(vec![5.0, 5.0]);
358        let b = ParetoFitness::new(vec![3.0, 3.0]);
359        let c = ParetoFitness::new(vec![6.0, 3.0]); // Better in one, worse in other - not dominated by a
360
361        assert!(a.dominates(&b)); // a is better in all objectives
362        assert!(!b.dominates(&a)); // b is worse in all objectives
363        assert!(!a.dominates(&c)); // c is better in first objective, so not dominated
364        assert!(!c.dominates(&a)); // a is better in second objective, so c doesn't dominate a
365    }
366
367    #[test]
368    fn test_pareto_fitness_is_better_than() {
369        let mut a = ParetoFitness::new(vec![5.0, 5.0]);
370        a.rank = 0;
371        a.crowding_distance = 1.0;
372
373        let mut b = ParetoFitness::new(vec![3.0, 3.0]);
374        b.rank = 1;
375        b.crowding_distance = 2.0;
376
377        assert!(a.is_better_than(&b)); // Lower rank is better
378
379        let mut c = ParetoFitness::new(vec![4.0, 4.0]);
380        c.rank = 0;
381        c.crowding_distance = 0.5;
382
383        assert!(a.is_better_than(&c)); // Same rank, higher crowding distance
384    }
385
386    #[test]
387    fn test_fn_fitness() {
388        let fitness = FnFitness::new(|g: &RealVector| -> f64 {
389            -g.genes().iter().map(|x| x * x).sum::<f64>()
390        });
391
392        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
393        let value = fitness.evaluate(&genome);
394        assert_eq!(value, -14.0);
395    }
396
397    #[test]
398    fn test_minimize_fitness() {
399        let fitness = FnFitness::new(|g: &RealVector| -> f64 {
400            g.genes().iter().map(|x| x * x).sum::<f64>()
401        });
402        let minimize = MinimizeFitness::new(fitness);
403
404        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
405        let value = minimize.evaluate(&genome);
406        assert_eq!(value, -14.0);
407    }
408
409    #[test]
410    fn test_as_log_likelihood() {
411        let fitness = FnFitness::new(|g: &RealVector| -> f64 {
412            -g.genes().iter().map(|x| x * x).sum::<f64>()
413        });
414
415        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
416        let log_likelihood = fitness.as_log_likelihood(&genome, 1.0);
417        assert_eq!(log_likelihood, -14.0);
418
419        let log_likelihood_scaled = fitness.as_log_likelihood(&genome, 2.0);
420        assert_eq!(log_likelihood_scaled, -7.0);
421    }
422}