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    /// 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}