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}