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 /// The IS/RIS transposition rates default to Ferreira's (2001) canonical
56 /// `0.1`; the crossover rates (`0.3`/`0.3`) are rlevo's own choice, not
57 /// Ferreira's commonly cited `0.2`/`0.5`. Assign a validated
58 /// [`Probability`] to the public fields afterwards to override.
59 /// The point-mutation rate defaults to `2 / genome_len` (≈ two genes per
60 /// chromosome). Because the rates are [`Probability`], a `NaN`/`Inf`/
61 /// out-of-`[0, 1]` rate is unrepresentable — the silent operator
62 /// degeneracy of a bare `f32` rate cannot occur.
63 ///
64 /// # Errors
65 ///
66 /// Returns a [`ConfigError`] if `max_arity`, `head_len`, `n_vars`, or
67 /// `pop_size` is zero — each would make the genome layout or the tree
68 /// decode degenerate. `max_arity` is checked here (it is consumed to derive
69 /// `tail_len` rather than stored); the remaining field invariants are
70 /// checked via [`Validate`].
71 pub fn new(
72 head_len: usize,
73 max_arity: usize,
74 n_vars: usize,
75 pop_size: usize,
76 ) -> Result<Self, ConfigError> {
77 // `max_arity` is not a stored field (it is consumed below to derive
78 // `tail_len`), so it cannot be re-checked by `validate`; guard it here.
79 config::at_least("GepConfig", "max_arity", max_arity, 1)?;
80 // Guard `head_len` before deriving `mutation_rate`: `head_len == 0`
81 // yields `genome_len == 1` and `2 / genome_len == 2.0`, which would
82 // panic `Probability::new` below (an invalid config that `validate`
83 // should reject, not crash on). Re-checked in `validate` too.
84 config::at_least("GepConfig", "head_len", head_len, 1)?;
85
86 // Tail sized to the worst case: a head of all-max-arity functions
87 // demands exactly `head_len * (max_arity - 1) + 1` terminals, so this is
88 // the minimum tail that guarantees a repair-free decode.
89 let tail_len = head_len * (max_arity - 1) + 1;
90 let genome_len = head_len + tail_len;
91 // `genome_len >= 2` (head_len >= 1, tail_len >= 1), so `2 / genome_len`
92 // lies in `(0, 1]` — provably a valid `Probability`, hence `new`.
93 #[allow(clippy::cast_precision_loss)]
94 let mutation_rate = Probability::new(2.0 / genome_len as f32);
95
96 let config = Self {
97 head_len,
98 tail_len,
99 pop_size,
100 n_vars,
101 mutation_rate,
102 is_transpose_rate: Probability::new(0.1),
103 ris_transpose_rate: Probability::new(0.1),
104 crossover_1p_rate: Probability::new(0.3),
105 crossover_2p_rate: Probability::new(0.3),
106 };
107 config.validate()?;
108 Ok(config)
109 }
110
111 /// Total chromosome length (`head_len + tail_len`).
112 #[must_use]
113 pub fn genome_len(&self) -> usize {
114 self.head_len + self.tail_len
115 }
116}
117
118impl Validate for GepConfig {
119 fn validate(&self) -> Result<(), ConfigError> {
120 const C: &str = "GepConfig";
121 config::at_least(C, "head_len", self.head_len, 1)?;
122 config::at_least(C, "tail_len", self.tail_len, 1)?;
123 config::at_least(C, "n_vars", self.n_vars, 1)?;
124 config::at_least(C, "pop_size", self.pop_size, 1)?;
125 // The five operator rates are `Probability`: valid by construction, so
126 // no `in_range` checks here — see ADR 0031.
127 Ok(())
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn derives_tail_for_max_arity_two() {
137 // head 7, max_arity 2 -> tail = 7 * 1 + 1 = 8, genome = 15.
138 let cfg = GepConfig::new(7, 2, 1, 100).unwrap();
139 assert_eq!(cfg.tail_len, 8);
140 assert_eq!(cfg.genome_len(), 15);
141 }
142
143 #[test]
144 fn derives_tail_for_max_arity_three() {
145 // head 5, max_arity 3 -> tail = 5 * 2 + 1 = 11.
146 let cfg = GepConfig::new(5, 3, 2, 50).unwrap();
147 assert_eq!(cfg.tail_len, 11);
148 assert_eq!(cfg.genome_len(), 16);
149 }
150
151 #[test]
152 fn rejects_zero_head() {
153 let err = GepConfig::new(0, 2, 1, 10).unwrap_err();
154 assert_eq!(err.field, "head_len");
155 }
156
157 #[test]
158 fn rejects_zero_vars() {
159 let err = GepConfig::new(4, 2, 0, 10).unwrap_err();
160 assert_eq!(err.field, "n_vars");
161 }
162
163 #[test]
164 fn rejects_zero_arity() {
165 let err = GepConfig::new(4, 0, 1, 10).unwrap_err();
166 assert_eq!(err.field, "max_arity");
167 }
168
169 #[test]
170 fn accepts_valid_config() {
171 assert!(GepConfig::new(6, 2, 3, 64).unwrap().validate().is_ok());
172 }
173
174 #[test]
175 fn rejects_zero_pop() {
176 let err = GepConfig::new(4, 2, 1, 0).unwrap_err();
177 assert_eq!(err.field, "pop_size");
178 }
179
180 /// A unary function set (`max_arity == 1`) collapses the Ferreira tail to a
181 /// single locus, `t = h·(1−1)+1 = 1`, for every head size.
182 #[test]
183 fn unary_max_arity_derives_unit_tail() {
184 for head in [1usize, 2, 5, 7, 20] {
185 let cfg = GepConfig::new(head, 1, 1, 10).unwrap();
186 assert_eq!(cfg.tail_len, 1, "unary tail must be 1 for head {head}");
187 assert_eq!(cfg.genome_len(), head + 1);
188 // The derived layout still satisfies the config invariants.
189 assert!(cfg.validate().is_ok());
190 }
191 }
192
193 /// The operator rates are [`Probability`], so a `NaN`, negative, or `> 1`
194 /// rate is unrepresentable: it cannot be built to assign to a rate field,
195 /// while a valid rate assigns cleanly.
196 #[test]
197 fn rate_fields_reject_nan_negative_and_above_one() {
198 assert!(Probability::try_new(f32::NAN).is_err());
199 assert!(Probability::try_new(-0.1).is_err());
200 assert!(Probability::try_new(1.5).is_err());
201
202 let mut cfg: GepConfig = GepConfig::new(4, 2, 1, 10).unwrap();
203 cfg.mutation_rate = Probability::try_new(0.05).unwrap();
204 cfg.crossover_1p_rate = Probability::try_new(0.9).unwrap();
205 // `Probability`'s derived `PartialEq` compares the wrapped value without
206 // tripping `clippy::float_cmp`; both were assigned exact literals.
207 assert_eq!(cfg.mutation_rate, Probability::try_new(0.05).unwrap());
208 assert_eq!(cfg.crossover_1p_rate, Probability::try_new(0.9).unwrap());
209 }
210}