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}