Skip to main content

rlevo_evolution/
function_set.rs

1//! Shared opcode contract for tree- and graph-structured genetic programming.
2//!
3//! Both Cartesian Genetic Programming ([`crate::algorithms::gp_cgp`]) and
4//! Gene Expression Programming ([`crate::algorithms::gep`]) evaluate programs
5//! built from the same primitive set: a small table of arithmetic and
6//! transcendental functions plus a constant. [`FunctionSet`] is the minimal
7//! contract those families share — it describes the **function opcodes only**.
8//!
9//! # What is *not* here
10//!
11//! Terminals (variables drawn from an input row, problem constants) are
12//! deliberately absent from this trait. CGP wires its inputs through the graph
13//! connection genes, and GEP resolves them in its tree evaluator
14//! ([`crate::algorithms::gep::ExpressionTree::eval`]); neither needs the
15//! function set to carry terminal state. Keeping terminals out of
16//! [`FunctionSet`] means the CGP retrofit adds no dead methods — the GEP-only
17//! terminal layer lives in [`crate::algorithms::gep::Alphabet`].
18//!
19//! # Symbol id-space
20//!
21//! A [`Symbol`] is an `i32` opcode id. Function ids occupy `0..num_functions()`.
22//! GEP extends this with variable and constant ids above the function range
23//! (see [`Alphabet`](crate::algorithms::gep::Alphabet)); CGP uses only the
24//! function ids. `i32` is mandatory because populations are stored as Burn
25//! integer tensors (`Tensor<B, 2, Int>`), whose element type is `i32`.
26
27use std::fmt::{self, Debug};
28
29/// A program symbol: an `i32` opcode id.
30///
31/// Within a [`FunctionSet`], ids `0..num_functions()` name function opcodes.
32/// GEP alphabets assign higher ids to variable and constant terminals; see
33/// [`Alphabet`](crate::algorithms::gep::Alphabet). The newtype keeps symbol
34/// ids from being confused with the many other `i32` indices in the genome
35/// (node indices, input indices, gene positions).
36///
37/// The inner id is private so a `Symbol` cannot be silently confused with a
38/// bare `i32`. Read it with [`value`](Symbol::value) (or `i32::from(symbol)`);
39/// construct one from a raw genome id with the crate-internal `from_raw`. Ids
40/// are not range-checked at construction — out-of-range ids classify as inert
41/// (arity `0`, `apply` → `0.0`), matching the evaluator's tolerance for
42/// mutated-but-unrepaired genotypes.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub struct Symbol(i32);
45
46impl Symbol {
47    /// The raw opcode id.
48    ///
49    /// Exposed for tensor serialization and table indexing; it is not a
50    /// constructor, so it cannot be used to fabricate an unchecked symbol from
51    /// outside the crate.
52    #[must_use]
53    #[inline]
54    pub const fn value(self) -> i32 {
55        self.0
56    }
57
58    /// Constructs a symbol from a raw `i32` id without range-checking it.
59    ///
60    /// For the GP evaluators that read ids straight from genome tensors, where
61    /// the id may legitimately fall outside the function range (an unrepaired
62    /// mutation). Out-of-range ids are inert at evaluation, so no validation is
63    /// needed here.
64    #[must_use]
65    #[inline]
66    pub(crate) const fn from_raw(id: i32) -> Self {
67        Self(id)
68    }
69}
70
71impl From<Symbol> for i32 {
72    #[inline]
73    fn from(symbol: Symbol) -> Self {
74        symbol.0
75    }
76}
77
78impl fmt::Display for Symbol {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        write!(f, "sym{}", self.0)
81    }
82}
83
84/// Collapses a non-finite phenotype value to the inert `0.0`.
85///
86/// This is the **node-level** numerical-stability guard: intermediate node
87/// values in the CGP ([`evaluate_cgp_with`](crate::algorithms::gp_cgp)) and GEP
88/// ([`ExpressionTree::eval`](crate::algorithms::gep::ExpressionTree::eval))
89/// evaluators are fed back into downstream node arithmetic, so a non-finite
90/// intermediate (overflow `±inf`, `0/0` `NaN`) must be neutralized *in place*
91/// with a finite value rather than allowed to poison the rest of the tree.
92///
93/// This is **not** the fitness `NaN → −inf` convention from `rules.md §3`: that
94/// rule applies to the final aggregated fitness and is handled separately by
95/// [`sanitize_fitness`](crate::fitness::sanitize_fitness). `0.0` is correct
96/// here precisely because the value is an operand, not a fitness score.
97#[must_use]
98#[inline]
99pub(crate) fn finite_or_zero(v: f32) -> f32 {
100    if v.is_finite() { v } else { 0.0 }
101}
102
103/// The function-opcode contract shared by CGP and GEP.
104///
105/// Implementors describe a fixed table of functions. Each function has an
106/// arity (number of arguments) and an [`apply`](FunctionSet::apply)
107/// implementation that combines already-evaluated arguments into a result.
108///
109/// The trait is intentionally tiny: it says nothing about terminals, genome
110/// layout, or evaluation order. Callers thread a concrete `&F` (never
111/// `&dyn FunctionSet`) through their evaluation hot loops so the opcode
112/// [`apply`](FunctionSet::apply) inlines.
113pub trait FunctionSet: Send + Sync + Debug {
114    /// Number of function opcodes. Their ids are `0..num_functions()`.
115    fn num_functions(&self) -> usize;
116
117    /// Arity (argument count) of a function opcode.
118    ///
119    /// Only meaningful for function symbols (`symbol.0` in
120    /// `0..num_functions()`). Out-of-range ids return `0` so callers that
121    /// evaluate mutated-but-unrepaired genotypes do not panic.
122    fn arity(&self, symbol: Symbol) -> usize;
123
124    /// Largest arity among all functions.
125    ///
126    /// Drives the GEP tail-length constraint
127    /// (`tail_len >= head_len * (max_arity - 1) + 1`); see
128    /// [`GepConfig::new`](crate::algorithms::gep::GepConfig::new).
129    fn max_arity(&self) -> usize;
130
131    /// Applies a function opcode to its already-evaluated arguments.
132    ///
133    /// Callers pass `args.len() == self.arity(symbol)`. For zero-arity
134    /// functions (constants such as `1.0`), `args` is empty. Variable and
135    /// constant *terminals* are never passed here — they are resolved by the
136    /// caller's evaluator before `apply` is reached.
137    ///
138    /// A shorter-than-arity slice does **not** panic: missing arguments read as
139    /// `0.0`. This keeps a malformed or unrepaired genotype (runtime data) from
140    /// aborting a training run, per `rules.md §4`.
141    ///
142    /// # Numerical
143    ///
144    /// `apply` performs raw IEEE-754 `f32` arithmetic and **may return a
145    /// non-finite value** — overflow yields `±inf`, `0.0 / 0.0` yields `NaN`.
146    /// It does **not** sanitize its result. Finiteness is the caller's
147    /// responsibility at two distinct layers: intermediate node values are
148    /// collapsed to `0.0` for phenotype-evaluation stability (see the
149    /// `finite_or_zero` helper), and the final fitness is mapped to `−inf` by
150    /// the crate's `sanitize_fitness` per `rules.md §3`.
151    fn apply(&self, symbol: Symbol, args: &[f32]) -> f32;
152}
153
154/// The canonical v1 arithmetic function set shared by CGP and GEP.
155///
156/// Eight opcodes, matching the historical inline CGP table exactly:
157///
158/// | id | op | arity | formula |
159/// |----|----|-------|---------|
160/// | 0 | add | 2 | `a + b` |
161/// | 1 | sub | 2 | `a - b` |
162/// | 2 | mul | 2 | `a * b` |
163/// | 3 | `protected_div` | 2 | `a / b`, or `a` if `|b| < 1e-6` |
164/// | 4 | sin | 1 | `sin(a)` |
165/// | 5 | cos | 1 | `cos(a)` |
166/// | 6 | tanh | 1 | `tanh(a)` |
167/// | 7 | const | 0 | `1.0` |
168///
169/// The single zero-arity opcode (`const 1.0`) is the **last** function id by
170/// construction. GEP relies on this ordering so that all terminal-valued
171/// symbols (zero-arity functions, then variables, then constants) form one
172/// contiguous id range usable as a tail column mask — see
173/// [`Alphabet::terminal_range`](crate::algorithms::gep::Alphabet::terminal_range).
174#[derive(Clone, Copy, Debug, Default)]
175pub struct ArithmeticFunctionSet;
176
177impl ArithmeticFunctionSet {
178    /// Arity of each opcode, indexed by id.
179    ///
180    /// Mirrors [`crate::algorithms::gp_cgp::FUNCTION_ARITIES`]; kept as an
181    /// associated constant so [`FunctionSet::arity`] is a simple table lookup.
182    pub const ARITIES: [usize; 8] = [2, 2, 2, 2, 1, 1, 1, 0];
183}
184
185impl FunctionSet for ArithmeticFunctionSet {
186    fn num_functions(&self) -> usize {
187        Self::ARITIES.len()
188    }
189
190    fn arity(&self, symbol: Symbol) -> usize {
191        usize::try_from(symbol.value())
192            .ok()
193            .and_then(|i| Self::ARITIES.get(i).copied())
194            .unwrap_or(0)
195    }
196
197    fn max_arity(&self) -> usize {
198        2
199    }
200
201    fn apply(&self, symbol: Symbol, args: &[f32]) -> f32 {
202        // Arms 0..=3 use two args, 4..=6 use one, 7 uses none. Missing
203        // arguments (a shorter-than-arity slice) read as 0.0 rather than
204        // panicking. Unknown ids collapse to 0.0, matching the historical
205        // `_ => 0.0` arm of the inline CGP match.
206        let a = args.first().copied().unwrap_or(0.0);
207        let b = args.get(1).copied().unwrap_or(0.0);
208        match symbol.value() {
209            0 => a + b,
210            1 => a - b,
211            2 => a * b,
212            3 => {
213                if b.abs() < 1e-6 {
214                    a
215                } else {
216                    a / b
217                }
218            }
219            4 => a.sin(),
220            5 => a.cos(),
221            6 => a.tanh(),
222            7 => 1.0,
223            _ => 0.0,
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    /// `apply` reproduces the historical inline CGP opcode match across all
233    /// eight opcodes for a representative `(a, b)` pair.
234    #[test]
235    fn arithmetic_apply_matches_inline_cgp_match() {
236        let fs = ArithmeticFunctionSet;
237        let (a, b) = (0.7_f32, 0.25_f32);
238        let inline = |func: usize, a: f32, b: f32| -> f32 {
239            match func {
240                0 => a + b,
241                1 => a - b,
242                2 => a * b,
243                3 => {
244                    if b.abs() < 1e-6 {
245                        a
246                    } else {
247                        a / b
248                    }
249                }
250                4 => a.sin(),
251                5 => a.cos(),
252                6 => a.tanh(),
253                7 => 1.0,
254                _ => 0.0,
255            }
256        };
257        for func in 0..8 {
258            // `func` is in `0..8`, so neither truncation nor wrap can occur.
259            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
260            let sym = Symbol::from_raw(func as i32);
261            let arity = fs.arity(sym);
262            let arg_buf = [a, b];
263            let got = fs.apply(sym, &arg_buf[..arity]);
264            let want = inline(func, a, b);
265            approx::assert_relative_eq!(got, want, epsilon = 1e-7);
266        }
267    }
268
269    /// A shorter-than-arity slice must not panic; missing arguments read as
270    /// `0.0` (`rules.md §4`: never panic on runtime data).
271    #[test]
272    fn apply_handles_short_args_without_panic() {
273        let fs = ArithmeticFunctionSet;
274        // arity(add) == 2, but the slice is empty: 0.0 + 0.0 == 0.0.
275        approx::assert_relative_eq!(fs.apply(Symbol::from_raw(0), &[]), 0.0, epsilon = 1e-7);
276        // arity(sub) == 2 with a single arg: 5.0 - 0.0 == 5.0.
277        approx::assert_relative_eq!(fs.apply(Symbol::from_raw(1), &[5.0]), 5.0, epsilon = 1e-7);
278    }
279
280    /// `apply` does not sanitize: a `NaN` argument propagates through raw
281    /// arithmetic (finiteness is the caller's responsibility, see
282    /// [`finite_or_zero`]).
283    #[test]
284    fn apply_propagates_non_finite_arguments() {
285        let fs = ArithmeticFunctionSet;
286        assert!(fs.apply(Symbol::from_raw(0), &[f32::NAN, 1.0]).is_nan());
287        assert!(
288            fs.apply(Symbol::from_raw(2), &[f32::INFINITY, 2.0])
289                .is_infinite()
290        );
291    }
292
293    /// Protected division returns the numerator when the denominator is near
294    /// zero (no `inf`).
295    #[test]
296    fn protected_div_guards_small_denominator() {
297        let fs = ArithmeticFunctionSet;
298        let got = fs.apply(Symbol::from_raw(3), &[3.0, 1e-9]);
299        approx::assert_relative_eq!(got, 3.0, epsilon = 1e-7);
300    }
301
302    /// Out-of-range opcodes report arity 0 and evaluate to 0.0 rather than
303    /// panicking, matching the inline match's `_ => 0.0`.
304    #[test]
305    fn out_of_range_opcode_is_inert() {
306        let fs = ArithmeticFunctionSet;
307        assert_eq!(fs.arity(Symbol::from_raw(99)), 0);
308        assert_eq!(fs.arity(Symbol::from_raw(-1)), 0);
309        approx::assert_relative_eq!(fs.apply(Symbol::from_raw(99), &[]), 0.0, epsilon = 1e-7);
310    }
311
312    #[test]
313    fn arities_and_counts() {
314        let fs = ArithmeticFunctionSet;
315        assert_eq!(fs.num_functions(), 8);
316        assert_eq!(fs.max_arity(), 2);
317        assert_eq!(fs.arity(Symbol::from_raw(0)), 2);
318        assert_eq!(fs.arity(Symbol::from_raw(6)), 1);
319        assert_eq!(fs.arity(Symbol::from_raw(7)), 0);
320    }
321}