Skip to main content

rlevo_core/
objective.rs

1//! Objective direction: the [`ObjectiveSense`] primitive.
2//!
3//! `rlevo` spans three fields whose native optimisation conventions disagree —
4//! reinforcement learning **maximises** return, evolutionary computation
5//! **maximises** fitness, and gradient descent **minimises** loss. To keep the
6//! library coherent, the *internal* engine convention is **maximise (higher =
7//! better)**, and an objective declares its *natural* direction with an
8//! [`ObjectiveSense`].
9//!
10//! # Two value spaces
11//!
12//! The contract separates two spaces and confines the mapping between them to a
13//! single chokepoint (the evolutionary harness / fitness adapters):
14//!
15//! - **User space** — the sense the problem declares. A cost/loss/landscape is
16//!   `Minimize`; a reward/fitness/accuracy is `Maximize`.
17//! - **Canonical (engine) space** — always *maximise, higher = better*. Every
18//!   strategy, operator, shaping rule, and metric aggregation works purely here
19//!   and never sees an `ObjectiveSense`.
20//!
21//! [`to_canonical`](crate::objective::ObjectiveSense::to_canonical) maps user space → canonical
22//! space (negate iff `Minimize`); [`from_canonical`](crate::objective::ObjectiveSense::from_canonical)
23//! is its inverse, used to report results back in the user's sense (a `Minimize`
24//! landscape's `best_fitness` reads as its natural cost — Sphere → 0).
25//!
26//! The mapping is an **involution**: applying it twice is the identity, so the
27//! same negate-iff-`Minimize` operation serves both directions.
28//!
29//! # Multi-objective seam
30//!
31//! `ObjectiveSense` is the `K = 1` atom of a future per-objective sense vector.
32//! Multi-objective dominance canonicalises every objective to maximise space and
33//! then applies "≥ on all, > on at least one" with no per-objective branching —
34//! the same chokepoint philosophy scaled to a vector.
35
36use serde::{Deserialize, Serialize};
37
38/// The direction in which an objective is optimised.
39///
40/// This is the typed direction primitive that reconciles the library's
41/// maximise-native engine with cost objectives (the benchmark landscapes). See
42/// the [module documentation](crate::objective) for the two-space model.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
44pub enum ObjectiveSense {
45    /// Lower is better (cost, loss, error, distance-to-target).
46    Minimize,
47    /// Higher is better (reward, fitness, accuracy, score).
48    Maximize,
49}
50
51impl ObjectiveSense {
52    /// Maps a raw user-space objective value into the engine's maximise-native
53    /// canonical space.
54    ///
55    /// `Maximize` passes the value through unchanged; `Minimize` negates it so a
56    /// cost surface is optimised as `−cost` by the maximise-native engine.
57    /// Applied **once**, at the harness / fitness-adapter boundary.
58    ///
59    /// ```
60    /// use rlevo_core::objective::ObjectiveSense;
61    /// assert_eq!(ObjectiveSense::Maximize.to_canonical(3.0), 3.0);
62    /// assert_eq!(ObjectiveSense::Minimize.to_canonical(3.0), -3.0);
63    /// ```
64    #[must_use]
65    pub fn to_canonical(self, raw: f32) -> f32 {
66        match self {
67            ObjectiveSense::Maximize => raw,
68            ObjectiveSense::Minimize => -raw,
69        }
70    }
71
72    /// Inverse of [`to_canonical`](Self::to_canonical): maps an engine-space
73    /// (canonical, maximise) value back to the user's declared sense for
74    /// reporting (`best_fitness`, records, showcases).
75    ///
76    /// Because the mapping is an involution (negate iff `Minimize`), this is the
77    /// same operation as [`to_canonical`](Self::to_canonical).
78    ///
79    /// ```
80    /// use rlevo_core::objective::ObjectiveSense;
81    /// // A Minimize landscape optimised as -cost in canonical space reads back
82    /// // as its natural cost.
83    /// let canonical = ObjectiveSense::Minimize.to_canonical(2.5); // -2.5
84    /// assert_eq!(ObjectiveSense::Minimize.from_canonical(canonical), 2.5);
85    /// ```
86    #[must_use]
87    pub fn from_canonical(self, canonical: f32) -> f32 {
88        // Involution: negate iff Minimize.
89        self.to_canonical(canonical)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::ObjectiveSense;
96
97    #[test]
98    fn maximize_is_pass_through() {
99        assert_eq!(ObjectiveSense::Maximize.to_canonical(7.5), 7.5);
100        assert_eq!(ObjectiveSense::Maximize.from_canonical(7.5), 7.5);
101    }
102
103    #[test]
104    fn minimize_negates() {
105        assert_eq!(ObjectiveSense::Minimize.to_canonical(7.5), -7.5);
106        assert_eq!(ObjectiveSense::Minimize.from_canonical(-7.5), 7.5);
107    }
108
109    #[test]
110    fn round_trip_is_identity_for_both_senses() {
111        for sense in [ObjectiveSense::Minimize, ObjectiveSense::Maximize] {
112            for raw in [-3.0_f32, 0.0, 1.5, 42.0] {
113                let canonical = sense.to_canonical(raw);
114                approx::assert_relative_eq!(sense.from_canonical(canonical), raw, epsilon = 1e-9);
115            }
116        }
117    }
118}