Skip to main content

fugue_evo/genome/
dynamic_ops.rs

1//! Variation operators for [`DynamicRealVector`].
2//!
3//! [`DynamicRealVector`] is a variable-length real-valued genome, but the
4//! fixed-length real operators in `crate::operators` are written concretely for
5//! [`RealVector`](crate::genome::real_vector::RealVector) and therefore cannot
6//! vary a genome's length. This module provides the missing length-aware
7//! operators so a population of `DynamicRealVector` genomes can actually be
8//! evolved:
9//!
10//! - [`cut_and_splice`]: a crossover that recombines two variable-length parents
11//!   and yields two children whose lengths may differ from either parent.
12//! - [`DynamicGaussianMutation`]: a mutation that combines per-gene Gaussian
13//!   perturbation with length-changing insert/delete.
14//!
15//! These live under `crate::genome` (not `crate::operators`) so they stay
16//! next to the genome type they are specialized for, and they operate directly
17//! on [`DynamicRealVector`] rather than through the operator traits.
18
19use rand::Rng;
20use rand_distr::{Distribution, Normal};
21
22use crate::error::GenomeError;
23use crate::genome::bounds::MultiBounds;
24use crate::genome::dynamic_real_vector::DynamicRealVector;
25use crate::genome::traits::{EvolutionaryGenome, RealValuedGenome};
26
27/// Clamp a gene vector's length into `[min_length, max_length]`.
28///
29/// Overlong vectors are truncated; overshort vectors are padded by repeating
30/// their last gene (or `0.0` when empty), so padding values stay within the
31/// value range of the existing genes.
32fn repair_length(mut genes: Vec<f64>, min_length: usize, max_length: usize) -> Vec<f64> {
33    if genes.len() > max_length {
34        genes.truncate(max_length);
35    }
36    while genes.len() < min_length {
37        let fill = genes.last().copied().unwrap_or(0.0);
38        genes.push(fill);
39    }
40    genes
41}
42
43/// Cut-and-splice crossover for variable-length real vectors.
44///
45/// Each parent is cut at an independent, uniformly chosen point; the head of one
46/// parent is spliced with the tail of the other (and vice versa), producing two
47/// children whose lengths may differ from both parents. Children are then
48/// repaired into the shared length window `[min_length, max_length]`.
49///
50/// The children's length constraints are the intersection of the parents'
51/// constraints (`max(min_length)` .. `min(max_length)`); if that window is empty
52/// this returns [`GenomeError::InvalidStructure`].
53pub fn cut_and_splice<R: Rng>(
54    parent1: &DynamicRealVector,
55    parent2: &DynamicRealVector,
56    rng: &mut R,
57) -> Result<(DynamicRealVector, DynamicRealVector), GenomeError> {
58    let min_length = parent1.min_length().max(parent2.min_length());
59    let max_length = parent1.max_length().min(parent2.max_length());
60    if min_length > max_length {
61        return Err(GenomeError::InvalidStructure(format!(
62            "cut_and_splice: parents have incompatible length constraints \
63             (combined min_length {min_length} > max_length {max_length})"
64        )));
65    }
66
67    let g1 = parent1.genes();
68    let g2 = parent2.genes();
69    // Cut points range over `0..=len` so a head or tail may be empty.
70    let cut1 = rng.gen_range(0..=g1.len());
71    let cut2 = rng.gen_range(0..=g2.len());
72
73    let mut child1: Vec<f64> = g1[..cut1].to_vec();
74    child1.extend_from_slice(&g2[cut2..]);
75    let mut child2: Vec<f64> = g2[..cut2].to_vec();
76    child2.extend_from_slice(&g1[cut1..]);
77
78    let child1 = repair_length(child1, min_length, max_length);
79    let child2 = repair_length(child2, min_length, max_length);
80
81    Ok((
82        DynamicRealVector::new(child1, min_length, max_length)?,
83        DynamicRealVector::new(child2, min_length, max_length)?,
84    ))
85}
86
87/// Length-aware mutation for [`DynamicRealVector`].
88///
89/// Combines two independent effects, applied in this order:
90/// 1. **Gaussian perturbation** — each gene is, with probability
91///    `gene_mutation_prob`, perturbed by a sample from `N(0, sigma^2)` and then
92///    clamped back into the supplied bounds.
93/// 2. **Length change** — with probability `grow_prob` a new gene (sampled
94///    within bounds) is inserted at a random position, and with probability
95///    `shrink_prob` a random gene is removed. Both changes respect the genome's
96///    own `[min_length, max_length]` window via
97///    [`can_grow`](DynamicRealVector::can_grow) /
98///    [`can_shrink`](DynamicRealVector::can_shrink), so the length always stays
99///    in range.
100#[derive(Clone, Copy, Debug)]
101pub struct DynamicGaussianMutation {
102    /// Standard deviation of the per-gene Gaussian perturbation.
103    pub sigma: f64,
104    /// Per-gene probability of applying the Gaussian perturbation.
105    pub gene_mutation_prob: f64,
106    /// Probability of inserting a new gene (subject to `can_grow`).
107    pub grow_prob: f64,
108    /// Probability of removing a gene (subject to `can_shrink`).
109    pub shrink_prob: f64,
110}
111
112impl DynamicGaussianMutation {
113    /// Create a new mutation operator.
114    ///
115    /// # Panics
116    /// Panics if any probability is outside `[0, 1]` or if `sigma` is negative.
117    pub fn new(sigma: f64, gene_mutation_prob: f64, grow_prob: f64, shrink_prob: f64) -> Self {
118        assert!(sigma >= 0.0, "sigma must be non-negative");
119        for (name, p) in [
120            ("gene_mutation_prob", gene_mutation_prob),
121            ("grow_prob", grow_prob),
122            ("shrink_prob", shrink_prob),
123        ] {
124            assert!(
125                (0.0..=1.0).contains(&p),
126                "{name} must be in [0, 1], got {p}"
127            );
128        }
129        Self {
130            sigma,
131            gene_mutation_prob,
132            grow_prob,
133            shrink_prob,
134        }
135    }
136
137    /// Apply the mutation in place.
138    pub fn mutate<R: Rng>(
139        &self,
140        genome: &mut DynamicRealVector,
141        bounds: &MultiBounds,
142        rng: &mut R,
143    ) {
144        // 1. Per-gene Gaussian perturbation.
145        if self.sigma > 0.0 && self.gene_mutation_prob > 0.0 {
146            let normal = Normal::new(0.0, self.sigma).expect("sigma > 0");
147            for gene in genome.genes_mut().iter_mut() {
148                if rng.gen::<f64>() < self.gene_mutation_prob {
149                    *gene += normal.sample(rng);
150                }
151            }
152            genome.apply_bounds(bounds);
153        }
154
155        // 2a. Length-increasing insert.
156        if self.grow_prob > 0.0 && rng.gen::<f64>() < self.grow_prob && genome.can_grow() {
157            let len = genome.dimension();
158            let index = rng.gen_range(0..=len);
159            let value = match bounds.get(index).or_else(|| bounds.get(0)) {
160                Some(b) => rng.gen_range(b.min..=b.max),
161                None => 0.0,
162            };
163            // `can_grow` guarantees room; ignore the (impossible) error path.
164            let _ = genome.insert(index, value);
165        }
166
167        // 2b. Length-decreasing delete.
168        if self.shrink_prob > 0.0 && rng.gen::<f64>() < self.shrink_prob && genome.can_shrink() {
169            let len = genome.dimension();
170            if len > 0 {
171                let index = rng.gen_range(0..len);
172                let _ = genome.remove(index);
173            }
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use proptest::prelude::*;
182
183    proptest! {
184        #[test]
185        fn cut_and_splice_lengths_and_values_within_range(
186            g1 in prop::collection::vec(-5.0..5.0f64, 1..12),
187            g2 in prop::collection::vec(-5.0..5.0f64, 1..12),
188        ) {
189            // Property (EV-57): children lengths stay within [min, max] and
190            // values stay within bounds (they come from the parents, which are
191            // within [-5, 5], or from in-range padding).
192            let min_length = 1;
193            let max_length = 16;
194            let p1 = DynamicRealVector::new(g1, min_length, max_length).unwrap();
195            let p2 = DynamicRealVector::new(g2, min_length, max_length).unwrap();
196            let mut rng = rand::thread_rng();
197
198            let (c1, c2) = cut_and_splice(&p1, &p2, &mut rng).unwrap();
199
200            prop_assert!(c1.dimension() >= min_length && c1.dimension() <= max_length);
201            prop_assert!(c2.dimension() >= min_length && c2.dimension() <= max_length);
202            for &v in c1.genes() {
203                prop_assert!((-5.0..=5.0).contains(&v));
204            }
205            for &v in c2.genes() {
206                prop_assert!((-5.0..=5.0).contains(&v));
207            }
208        }
209
210        #[test]
211        fn mutation_preserves_length_window_and_bounds(
212            genes in prop::collection::vec(-4.0..4.0f64, 2..10),
213        ) {
214            // Property (EV-57): after any number of mutations the length stays in
215            // [min, max] and all values stay within the configured bounds.
216            let min_length = 1;
217            let max_length = 12;
218            let mut genome = DynamicRealVector::new(genes, min_length, max_length).unwrap();
219            let bounds = MultiBounds::symmetric(5.0, max_length);
220            let op = DynamicGaussianMutation::new(0.75, 0.5, 0.5, 0.5);
221            let mut rng = rand::thread_rng();
222
223            for _ in 0..64 {
224                op.mutate(&mut genome, &bounds, &mut rng);
225                prop_assert!(
226                    genome.dimension() >= min_length && genome.dimension() <= max_length
227                );
228                for &v in genome.genes() {
229                    prop_assert!((-5.0..=5.0).contains(&v));
230                }
231            }
232        }
233    }
234
235    #[test]
236    fn cut_and_splice_incompatible_constraints_errors() {
237        // Combined window is max(2,1)=2 .. min(4,1)=1, which is empty.
238        let p1 = DynamicRealVector::new(vec![1.0, 2.0, 3.0], 2, 4).unwrap();
239        let p2 = DynamicRealVector::new(vec![9.0], 1, 1).unwrap();
240        let mut rng = rand::thread_rng();
241        assert!(cut_and_splice(&p1, &p2, &mut rng).is_err());
242    }
243
244    #[test]
245    fn cut_and_splice_is_usable_for_evolution() {
246        // Smoke test: two parents recombine into two valid children.
247        let p1 = DynamicRealVector::new(vec![1.0, 2.0, 3.0, 4.0], 1, 8).unwrap();
248        let p2 = DynamicRealVector::new(vec![-1.0, -2.0], 1, 8).unwrap();
249        let mut rng = rand::thread_rng();
250        let (c1, c2) = cut_and_splice(&p1, &p2, &mut rng).unwrap();
251        assert!(c1.dimension() >= 1 && c1.dimension() <= 8);
252        assert!(c2.dimension() >= 1 && c2.dimension() <= 8);
253    }
254
255    #[test]
256    fn mutation_can_grow_and_shrink() {
257        // With grow-only settings the genome should be able to reach max length;
258        // with shrink-only settings it should reach min length.
259        let bounds = MultiBounds::symmetric(5.0, 8);
260        let mut rng = rand::thread_rng();
261
262        let grow = DynamicGaussianMutation::new(0.1, 0.0, 1.0, 0.0);
263        let mut g = DynamicRealVector::new(vec![0.0, 0.0], 2, 8).unwrap();
264        for _ in 0..50 {
265            grow.mutate(&mut g, &bounds, &mut rng);
266        }
267        assert_eq!(g.dimension(), 8);
268
269        let shrink = DynamicGaussianMutation::new(0.1, 0.0, 0.0, 1.0);
270        let mut s = DynamicRealVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 2, 8).unwrap();
271        for _ in 0..50 {
272            shrink.mutate(&mut s, &bounds, &mut rng);
273        }
274        assert_eq!(s.dimension(), 2);
275    }
276}