rlevo_evolution/algorithms/gep/config.rs
1//! Runtime configuration for a Gene Expression Programming run.
2
3use rlevo_core::config::{self, ConfigError, Validate};
4use rlevo_core::probability::Probability;
5
6/// Static parameters for a [`GepStrategy`](super::GepStrategy) run.
7///
8/// GEP chromosomes are fixed-length: a `head` of `head_len` loci (which may
9/// hold any symbol) followed by a `tail` of `tail_len` loci (terminals only).
10/// The tail length is **not** a free parameter — it is derived from the head
11/// length and the function set's maximum arity so that every chromosome
12/// decodes to a complete expression tree without repair (see
13/// [`GepConfig::new`]).
14///
15/// Unlike the const-generic experiments considered during design, the genome
16/// dimensions are ordinary runtime fields (matching the shipped
17/// [`CgpConfig`](crate::algorithms::gp_cgp::CgpConfig) precedent); no
18/// `generic_const_exprs` is required.
19#[derive(Debug, Clone)]
20pub struct GepConfig {
21 /// Head length: number of leading loci that may hold any symbol.
22 pub head_len: usize,
23 /// Tail length: number of trailing loci that may hold terminals only.
24 ///
25 /// Derived in [`GepConfig::new`] as `head_len * (max_arity - 1) + 1`.
26 pub tail_len: usize,
27 /// Number of individuals in the population.
28 pub pop_size: usize,
29 /// Number of input variables the program sees.
30 pub n_vars: usize,
31 /// Per-gene point-mutation probability. Valid by construction (`[0, 1]`).
32 pub mutation_rate: Probability,
33 /// Per-individual probability of applying one IS transposition. Valid by
34 /// construction (`[0, 1]`).
35 pub is_transpose_rate: Probability,
36 /// Per-individual probability of applying one RIS transposition. Valid by
37 /// construction (`[0, 1]`).
38 pub ris_transpose_rate: Probability,
39 /// Per-pair probability of one-point crossover. Valid by construction
40 /// (`[0, 1]`).
41 pub crossover_1p_rate: Probability,
42 /// Per-pair probability of two-point crossover. Valid by construction
43 /// (`[0, 1]`).
44 pub crossover_2p_rate: Probability,
45}
46
47impl GepConfig {
48 /// Builds a config, deriving and validating the tail length.
49 ///
50 /// The tail length is set to `head_len * (max_arity - 1) + 1`, the minimum
51 /// that guarantees any head/tail-respecting chromosome decodes to a
52 /// complete tree. `max_arity` is the function set's largest
53 /// arity ([`FunctionSet::max_arity`](crate::function_set::FunctionSet::max_arity)).
54 ///
55 /// Operator rates default to canonical Ferreira (2001) values; assign a
56 /// validated [`Probability`] to the public fields afterwards to override.
57 /// The point-mutation rate defaults to `2 / genome_len` (≈ two genes per
58 /// chromosome). Because the rates are [`Probability`], a `NaN`/`Inf`/
59 /// out-of-`[0, 1]` rate is unrepresentable — the silent operator
60 /// degeneracy of a bare `f32` rate cannot occur.
61 ///
62 /// # Errors
63 ///
64 /// Returns a [`ConfigError`] if `max_arity`, `head_len`, `n_vars`, or
65 /// `pop_size` is zero — each would make the genome layout or the tree
66 /// decode degenerate. `max_arity` is checked here (it is consumed to derive
67 /// `tail_len` rather than stored); the remaining field invariants are
68 /// checked via [`Validate`].
69 pub fn new(
70 head_len: usize,
71 max_arity: usize,
72 n_vars: usize,
73 pop_size: usize,
74 ) -> Result<Self, ConfigError> {
75 // `max_arity` is not a stored field (it is consumed below to derive
76 // `tail_len`), so it cannot be re-checked by `validate`; guard it here.
77 config::at_least("GepConfig", "max_arity", max_arity, 1)?;
78 // Guard `head_len` before deriving `mutation_rate`: `head_len == 0`
79 // yields `genome_len == 1` and `2 / genome_len == 2.0`, which would
80 // panic `Probability::new` below (an invalid config that `validate`
81 // should reject, not crash on). Re-checked in `validate` too.
82 config::at_least("GepConfig", "head_len", head_len, 1)?;
83
84 // Tail sized to the worst case: a head of all-max-arity functions
85 // demands exactly `head_len * (max_arity - 1) + 1` terminals, so this is
86 // the minimum tail that guarantees a repair-free decode.
87 let tail_len = head_len * (max_arity - 1) + 1;
88 let genome_len = head_len + tail_len;
89 // `genome_len >= 2` (head_len >= 1, tail_len >= 1), so `2 / genome_len`
90 // lies in `(0, 1]` — provably a valid `Probability`, hence `new`.
91 #[allow(clippy::cast_precision_loss)]
92 let mutation_rate = Probability::new(2.0 / genome_len as f32);
93
94 let config = Self {
95 head_len,
96 tail_len,
97 pop_size,
98 n_vars,
99 mutation_rate,
100 is_transpose_rate: Probability::new(0.1),
101 ris_transpose_rate: Probability::new(0.1),
102 crossover_1p_rate: Probability::new(0.3),
103 crossover_2p_rate: Probability::new(0.3),
104 };
105 config.validate()?;
106 Ok(config)
107 }
108
109 /// Total chromosome length (`head_len + tail_len`).
110 #[must_use]
111 pub fn genome_len(&self) -> usize {
112 self.head_len + self.tail_len
113 }
114}
115
116impl Validate for GepConfig {
117 fn validate(&self) -> Result<(), ConfigError> {
118 const C: &str = "GepConfig";
119 config::at_least(C, "head_len", self.head_len, 1)?;
120 config::at_least(C, "tail_len", self.tail_len, 1)?;
121 config::at_least(C, "n_vars", self.n_vars, 1)?;
122 config::at_least(C, "pop_size", self.pop_size, 1)?;
123 // The five operator rates are `Probability`: valid by construction, so
124 // no `in_range` checks here — see ADR 0031.
125 Ok(())
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn derives_tail_for_max_arity_two() {
135 // head 7, max_arity 2 -> tail = 7 * 1 + 1 = 8, genome = 15.
136 let cfg = GepConfig::new(7, 2, 1, 100).unwrap();
137 assert_eq!(cfg.tail_len, 8);
138 assert_eq!(cfg.genome_len(), 15);
139 }
140
141 #[test]
142 fn derives_tail_for_max_arity_three() {
143 // head 5, max_arity 3 -> tail = 5 * 2 + 1 = 11.
144 let cfg = GepConfig::new(5, 3, 2, 50).unwrap();
145 assert_eq!(cfg.tail_len, 11);
146 assert_eq!(cfg.genome_len(), 16);
147 }
148
149 #[test]
150 fn rejects_zero_head() {
151 let err = GepConfig::new(0, 2, 1, 10).unwrap_err();
152 assert_eq!(err.field, "head_len");
153 }
154
155 #[test]
156 fn rejects_zero_vars() {
157 let err = GepConfig::new(4, 2, 0, 10).unwrap_err();
158 assert_eq!(err.field, "n_vars");
159 }
160
161 #[test]
162 fn rejects_zero_arity() {
163 let err = GepConfig::new(4, 0, 1, 10).unwrap_err();
164 assert_eq!(err.field, "max_arity");
165 }
166
167 #[test]
168 fn accepts_valid_config() {
169 assert!(GepConfig::new(6, 2, 3, 64).unwrap().validate().is_ok());
170 }
171
172 #[test]
173 fn rejects_zero_pop() {
174 let err = GepConfig::new(4, 2, 1, 0).unwrap_err();
175 assert_eq!(err.field, "pop_size");
176 }
177
178 /// A unary function set (`max_arity == 1`) collapses the Ferreira tail to a
179 /// single locus, `t = h·(1−1)+1 = 1`, for every head size.
180 #[test]
181 fn unary_max_arity_derives_unit_tail() {
182 for head in [1usize, 2, 5, 7, 20] {
183 let cfg = GepConfig::new(head, 1, 1, 10).unwrap();
184 assert_eq!(cfg.tail_len, 1, "unary tail must be 1 for head {head}");
185 assert_eq!(cfg.genome_len(), head + 1);
186 // The derived layout still satisfies the config invariants.
187 assert!(cfg.validate().is_ok());
188 }
189 }
190
191 /// The operator rates are [`Probability`], so a `NaN`, negative, or `> 1`
192 /// rate is unrepresentable: it cannot be built to assign to a rate field,
193 /// while a valid rate assigns cleanly.
194 #[test]
195 fn rate_fields_reject_nan_negative_and_above_one() {
196 assert!(Probability::try_new(f32::NAN).is_err());
197 assert!(Probability::try_new(-0.1).is_err());
198 assert!(Probability::try_new(1.5).is_err());
199
200 let mut cfg: GepConfig = GepConfig::new(4, 2, 1, 10).unwrap();
201 cfg.mutation_rate = Probability::try_new(0.05).unwrap();
202 cfg.crossover_1p_rate = Probability::try_new(0.9).unwrap();
203 // `Probability`'s derived `PartialEq` compares the wrapped value without
204 // tripping `clippy::float_cmp`; both were assigned exact literals.
205 assert_eq!(cfg.mutation_rate, Probability::try_new(0.05).unwrap());
206 assert_eq!(cfg.crossover_1p_rate, Probability::try_new(0.9).unwrap());
207 }
208}