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}