Skip to main content

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}