Skip to main content

fx_durable_ga/models/
crossover.rs

1use crate::models::{Gene, Genotype};
2use rand::Rng;
3use serde::{Deserialize, Serialize};
4use tracing::instrument;
5
6/// Performs uniform crossover by selecting genes from each parent with the given probability.
7#[instrument(level = "debug", skip(rng, lhs, rhs), fields(genome_length = lhs.genome().len(), probability = probability))]
8fn crossover_uniform<R: Rng>(
9    rng: &mut R,
10    lhs: &Genotype,
11    rhs: &Genotype,
12    probability: f64,
13) -> Vec<Gene> {
14    let lhs_genome = lhs.genome();
15    let rhs_genome = rhs.genome();
16    lhs_genome
17        .iter()
18        .zip(rhs_genome.iter())
19        .map(|(&lhs, &rhs)| {
20            if rng.random_bool(probability) {
21                lhs
22            } else {
23                rhs
24            }
25        })
26        .collect()
27}
28
29/// Performs single-point crossover at the specified cut point.
30#[instrument(level = "debug", skip(lhs, rhs), fields(genome_length = lhs.genome().len(), cut_point = point))]
31fn crossover_single_point(lhs: &Genotype, rhs: &Genotype, point: usize) -> Vec<Gene> {
32    let lhs_genome = lhs.genome();
33    let rhs_genome = rhs.genome();
34    let mut genome = Vec::with_capacity(lhs_genome.len());
35
36    genome.extend_from_slice(&lhs_genome[..point]); // First part from lhs
37    genome.extend_from_slice(&rhs_genome[point..]); // Second part from rhs
38    genome
39}
40
41/// Crossover strategy for combining genetic material from two parents.
42///
43/// Crossover is a fundamental genetic algorithm operation that creates offspring by
44/// combining genetic material from two parent genotypes. This enum provides different
45/// strategies for how genes are selected and combined.
46///
47/// # Strategies
48///
49/// ## Uniform Crossover
50/// Each gene position is independently selected from either parent based on a probability.
51/// This provides fine-grained mixing of genetic material and is excellent for exploring
52/// the solution space when genes are largely independent.
53///
54/// **When to use:**
55/// - Problems where genes don't have strong positional dependencies
56/// - When you want maximum genetic diversity in offspring
57/// - For exploration-heavy phases of optimization
58///
59/// ## Single-Point Crossover
60/// Selects a random cut point and takes genes before the cut from one parent and
61/// genes after the cut from the other parent. This preserves larger genetic segments
62/// and is ideal when genes have positional relationships.
63///
64/// **When to use:**
65/// - Problems where adjacent genes work together (building blocks)
66/// - When you want to preserve genetic segments
67/// - For exploitation-focused optimization phases
68///
69/// # Examples
70///
71/// ```rust
72/// use fx_durable_ga::models::Crossover;
73///
74/// // Uniform crossover with 60% chance of selecting from first parent
75/// let uniform = Crossover::uniform(0.6)?;
76///
77/// // Single-point crossover for preserving gene segments
78/// let single_point = Crossover::single_point();
79/// # Ok::<(), Box<dyn std::error::Error>>(())
80/// ```
81///
82/// # Configuration Guidelines
83///
84/// **For exploration (early optimization):**
85/// - Use `Uniform` with probability around 0.4-0.6
86/// - Higher genetic diversity helps find promising regions
87///
88/// **For exploitation (later optimization):**
89/// - Use `SinglePoint` to preserve successful gene combinations
90/// - Or `Uniform` with probability closer to 0.5 for balanced mixing
91///
92/// **Problem-specific considerations:**
93/// - Permutation problems: Often benefit from specialized crossover (consider SinglePoint)
94/// - Continuous optimization: Uniform crossover typically works well
95/// - Combinatorial problems: Test both strategies to see which preserves building blocks better
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub enum Crossover {
98    /// Uniform crossover selects each gene from either parent with the given probability.
99    ///
100    /// The `probability` parameter controls the likelihood of selecting a gene from the
101    /// first parent at each position. Must be between 0.0 and 1.0 inclusive.
102    ///
103    /// - `probability = 0.0`: Always selects from second parent (creates clone)
104    /// - `probability = 0.5`: Equal chance from either parent (maximum mixing)
105    /// - `probability = 1.0`: Always selects from first parent (creates clone)
106    Uniform {
107        /// Probability of selecting each gene from the first parent (0.0 to 1.0)
108        probability: f64,
109    },
110    /// Single-point crossover cuts genomes at a random point and swaps the tails.
111    ///
112    /// Creates offspring by taking genes [0..cut_point) from the first parent and
113    /// genes [cut_point..length) from the second parent. The cut point is randomly
114    /// selected from positions 1 to genome_length-1 to ensure both parents contribute.
115    SinglePoint,
116}
117
118/// Error returned when attempting to create uniform crossover with invalid probability.
119///
120/// Uniform crossover requires a probability value between 0.0 and 1.0 inclusive.
121/// Values outside this range don't make sense mathematically and will result in
122/// this error being returned from [`Crossover::uniform`].
123///
124/// # Examples
125///
126/// ```rust
127/// use fx_durable_ga::models::Crossover;
128///
129/// // These will return ProbabilityOutOfRangeError
130/// assert!(Crossover::uniform(-0.1).is_err());
131/// assert!(Crossover::uniform(1.5).is_err());
132///
133/// // These are valid
134/// assert!(Crossover::uniform(0.0).is_ok());
135/// assert!(Crossover::uniform(0.5).is_ok());
136/// assert!(Crossover::uniform(1.0).is_ok());
137/// ```
138#[derive(Debug, thiserror::Error)]
139#[error("uniform crossover probability must be between 0.0 and 1.0, got {0}")]
140pub struct ProbabilityOutOfRangeError(f64);
141
142impl Crossover {
143    /// Creates a uniform crossover strategy with specified selection probability.
144    ///
145    /// The probability parameter determines how often genes are selected from the first parent
146    /// versus the second parent during crossover. Each gene position is independently decided.
147    ///
148    /// # Parameters
149    ///
150    /// * `probability` - Likelihood of selecting each gene from the first parent (must be 0.0-1.0)
151    ///   - `0.0` = Always select from second parent (effectively clones second parent)
152    ///   - `0.5` = Equal probability from both parents (maximum genetic mixing)
153    ///   - `1.0` = Always select from first parent (effectively clones first parent)
154    ///
155    /// # Returns
156    ///
157    /// * `Ok(Crossover)` - Valid uniform crossover strategy
158    /// * `Err(ProbabilityOutOfRangeError)` - When probability is outside [0.0, 1.0] range
159    ///
160    /// # Examples
161    ///
162    /// ```rust
163    /// use fx_durable_ga::models::Crossover;
164    ///
165    /// // Balanced mixing - equal chance from both parents
166    /// let balanced = Crossover::uniform(0.5)?;
167    ///
168    /// // Favor first parent - 70% chance from first parent
169    /// let favor_first = Crossover::uniform(0.7)?;
170    ///
171    /// // Exploration-focused - slight bias for diversity
172    /// let exploratory = Crossover::uniform(0.4)?;
173    ///
174    /// // This will fail with an error
175    /// assert!(Crossover::uniform(1.5).is_err());
176    /// # Ok::<(), Box<dyn std::error::Error>>(())
177    /// ```
178    ///
179    /// # Performance Considerations
180    ///
181    /// Uniform crossover generates one random number per gene, making it slightly more
182    /// computationally expensive than single-point crossover for large genomes. However,
183    /// it often produces better genetic diversity.
184    pub fn uniform(probability: f64) -> Result<Self, ProbabilityOutOfRangeError> {
185        if !(0.0..=1.0).contains(&probability) {
186            return Err(ProbabilityOutOfRangeError(probability));
187        }
188
189        Ok(Self::Uniform { probability })
190    }
191
192    /// Creates a single-point crossover strategy.
193    ///
194    /// Single-point crossover randomly selects a cut point in the genome and creates
195    /// offspring by taking genes before the cut point from the first parent and genes
196    /// after the cut point from the second parent.
197    ///
198    /// # Behavior
199    ///
200    /// - Cut point is randomly selected from positions 1 to (genome_length - 1)
201    /// - Both parents always contribute at least one gene
202    /// - Preserves contiguous genetic segments from each parent
203    /// - Only requires one random number per crossover operation
204    ///
205    /// # Examples
206    ///
207    /// ```rust
208    /// use fx_durable_ga::models::Crossover;
209    ///
210    /// // Create single-point crossover strategy
211    /// let crossover = Crossover::single_point();
212    ///
213    /// // Example with genomes [A,B,C,D,E] and [1,2,3,4,5]
214    /// // If cut point = 2, offspring would be [A,B,3,4,5]
215    /// // If cut point = 4, offspring would be [A,B,C,D,5]
216    /// ```
217    ///
218    /// # When to Use
219    ///
220    /// Single-point crossover works well when:
221    /// - Adjacent genes have functional relationships (building blocks)
222    /// - You want to preserve successful gene combinations
223    /// - Performance is critical (faster than uniform crossover)
224    /// - Problem structure suggests genes work in segments
225    ///
226    /// Consider uniform crossover instead when genes are largely independent.
227    pub fn single_point() -> Self {
228        Self::SinglePoint
229    }
230
231    /// Applies the crossover operation to two parent genotypes, producing a new genome.
232    #[instrument(level = "debug", skip(self, rng, lhs, rhs), fields(crossover_type = ?self, genome_length = lhs.genome().len()))]
233    pub(crate) fn apply<R: Rng>(&self, rng: &mut R, lhs: &Genotype, rhs: &Genotype) -> Vec<Gene> {
234        match self {
235            Self::Uniform { probability } => crossover_uniform(rng, lhs, rhs, *probability),
236            Self::SinglePoint => {
237                let point = rng.random_range(1..lhs.genome().len()); // Cut point
238                crossover_single_point(lhs, rhs, point)
239            }
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use rand::{SeedableRng, rngs::StdRng};
248    use uuid::Uuid;
249
250    fn create_test_genotype(genome: Vec<Gene>) -> Genotype {
251        Genotype::new("TestType", 123, genome, Uuid::now_v7(), 1)
252    }
253
254    #[test]
255    fn it_performs_uniform_crossover() {
256        let mut rng = StdRng::seed_from_u64(42);
257        let parent_a = create_test_genotype(vec![1, 2, 3, 4, 5]);
258        let parent_b = create_test_genotype(vec![6, 7, 8, 9, 10]);
259
260        let child_genome = crossover_uniform(&mut rng, &parent_a, &parent_b, 0.5);
261
262        // With seeded RNG, result should be deterministic
263        assert_eq!(child_genome.len(), 5);
264
265        // Each gene should be from either parent A or parent B
266        for (i, &gene) in child_genome.iter().enumerate() {
267            assert!(gene == parent_a.genome()[i] || gene == parent_b.genome()[i]);
268        }
269    }
270
271    #[test]
272    fn it_performs_single_point_crossover() {
273        let parent_a = create_test_genotype(vec![1, 2, 3, 4, 5]);
274        let parent_b = create_test_genotype(vec![6, 7, 8, 9, 10]);
275
276        let child_genome = crossover_single_point(&parent_a, &parent_b, 1);
277        assert_eq!(child_genome, vec![1, 7, 8, 9, 10]);
278
279        let child_genome = crossover_single_point(&parent_a, &parent_b, 2);
280        assert_eq!(child_genome, vec![1, 2, 8, 9, 10]);
281
282        let child_genome = crossover_single_point(&parent_a, &parent_b, 3);
283        assert_eq!(child_genome, vec![1, 2, 3, 9, 10]);
284
285        let child_genome = crossover_single_point(&parent_a, &parent_b, 4);
286        assert_eq!(child_genome, vec![1, 2, 3, 4, 10]);
287    }
288
289    #[test]
290    fn it_handles_single_point_crossover_via_enum() {
291        let mut rng = StdRng::seed_from_u64(42);
292        let parent_a = create_test_genotype(vec![1, 2, 3, 4, 5]);
293        let parent_b = create_test_genotype(vec![6, 7, 8, 9, 10]);
294
295        let crossover = Crossover::SinglePoint;
296        let child = crossover.apply(&mut rng, &parent_a, &parent_b);
297
298        // Verify basic properties
299        assert_eq!(child.len(), 5);
300
301        // Verify there's exactly one transition point
302        let mut transitions = 0;
303        let parent_a_genome = parent_a.genome();
304        let parent_b_genome = parent_b.genome();
305        for i in 1..child.len() {
306            let prev_from_a = child[i - 1] == parent_a_genome[i - 1];
307            let curr_from_a = child[i] == parent_a_genome[i];
308
309            if prev_from_a != curr_from_a {
310                transitions += 1;
311            }
312        }
313
314        // Single point crossover should have exactly 1 transition
315        assert_eq!(transitions, 1);
316
317        // All genes should come from one parent or the other
318        for (i, &gene) in child.iter().enumerate() {
319            assert!(gene == parent_a_genome[i] || gene == parent_b_genome[i]);
320        }
321    }
322
323    #[test]
324    fn it_handles_uniform_crossover_extreme_probabilities() {
325        let mut rng = StdRng::seed_from_u64(42);
326
327        let parent_a = create_test_genotype(vec![1, 2, 3]);
328        let parent_b = create_test_genotype(vec![4, 5, 6]);
329
330        // Probability 0.0 should always choose parent B
331        let crossover = Crossover::Uniform { probability: 0.0 };
332        let child_genome = crossover.apply(&mut rng, &parent_a, &parent_b);
333        assert_eq!(child_genome, parent_b.genome());
334
335        // Reset RNG
336        let mut rng = StdRng::seed_from_u64(42);
337
338        // Probability 1.0 should always choose parent A
339        let crossover = Crossover::Uniform { probability: 1.0 };
340        let child_genome = crossover.apply(&mut rng, &parent_a, &parent_b);
341        assert_eq!(child_genome, parent_a.genome());
342    }
343
344    #[test]
345    fn it_validates_uniform_crossover_probability() {
346        // Test invalid probabilities (line 45)
347        let result = Crossover::uniform(-0.1);
348        assert!(result.is_err());
349
350        let result = Crossover::uniform(1.5);
351        assert!(result.is_err());
352
353        // Test valid probabilities work
354        let result = Crossover::uniform(0.5);
355        assert!(result.is_ok());
356    }
357
358    #[test]
359    fn it_creates_single_point_crossover() {
360        // Test the constructor (lines 51-53)
361        let crossover = Crossover::single_point();
362
363        // Verify it actually works
364        let mut rng = StdRng::seed_from_u64(42);
365        let parent_a = create_test_genotype(vec![1, 2, 3]);
366        let parent_b = create_test_genotype(vec![4, 5, 6]);
367
368        let child = crossover.apply(&mut rng, &parent_a, &parent_b);
369        assert_eq!(child.len(), 3);
370    }
371}