fx_durable_ga/models/selector.rs
1//! Parent selection strategies for genetic algorithms.
2//!
3//! This module provides configurable selection methods that determine how parent
4//! pairs are chosen from the candidate population for breeding operations. The
5//! selection strategy significantly affects the evolutionary dynamics and convergence
6//! behavior of the genetic algorithm.
7//!
8//! # Selection Methods
9//!
10//! ## Tournament Selection
11//!
12//! Tournament selection runs competitions between randomly chosen candidates,
13//! selecting the fittest from each group. This method provides:
14//!
15//! - **Consistent selection pressure** regardless of fitness distribution
16//! - **Tunable pressure** via tournament size parameter
17//! - **Robustness** to fitness scaling and outliers
18//! - **Computational efficiency** with O(k) comparisons per selection
19//!
20//! Tournament size guidelines:
21//! - **Size 2-3**: Balanced exploration and exploitation
22//! - **Size 4-5**: Moderate selection pressure for steady convergence
23//! - **Size 6+**: High pressure for rapid convergence (risk of premature convergence)
24//!
25//! ## Roulette Wheel Selection
26//!
27//! Roulette wheel selection chooses candidates with probability proportional
28//! to their fitness values. This method provides:
29//!
30//! - **Proportional selection** based on exact fitness ratios
31//! - **Predictable probabilities** directly reflecting fitness differences
32//! - **Smooth selection pressure** without discrete competition
33//!
34//! Best used when fitness values represent meaningful proportional differences
35//! and have reasonable dynamic range.
36//!
37//! # Configuration Examples
38//!
39//! ## Basic Usage
40//!
41//! ```rust
42//! use fx_durable_ga::models::Selector;
43//!
44//! // Tournament selection with moderate pressure
45//! let tournament = Selector::tournament(3, 100);
46//!
47//! // Roulette wheel selection
48//! let roulette = Selector::roulette(100);
49//! ```
50//!
51//! ## Problem-Specific Configurations
52//!
53//! ```rust
54//! use fx_durable_ga::models::Selector;
55//!
56//! // High exploration for complex, multimodal problems
57//! let exploratory = Selector::tournament(2, 200);
58//!
59//! // Balanced search for general optimization
60//! let balanced = Selector::tournament(4, 150);
61//!
62//! // Strong exploitation for fine-tuning near optima
63//! let exploitative = Selector::tournament(7, 80);
64//!
65//! // Proportional selection when fitness ratios are meaningful
66//! let proportional = Selector::roulette(120);
67//! ```
68//!
69//! # Selection Pressure Comparison
70//!
71//! | Method | Pressure | Best For | Considerations |
72//! |--------|----------|----------|-----------------|
73//! | Tournament (size 2-3) | Low-Moderate | Complex search spaces, early generations | High exploration, slower convergence |
74//! | Tournament (size 4-5) | Moderate | General optimization | Balanced exploration/exploitation |
75//! | Tournament (size 6+) | High | Fine-tuning, late generations | Fast convergence, premature convergence risk |
76//! | Roulette | Variable | Problems with meaningful fitness ratios | Requires non-negative fitness, sensitive to scaling |
77//!
78//! # Sample Size Considerations
79//!
80//! The `sample_size` parameter affects:
81//! - **Genetic diversity**: Larger samples better represent population fitness distribution
82//! - **Computational cost**: More candidates require more fitness evaluations
83//! - **Selection quality**: Better samples lead to more effective parent selection
84//!
85//! Recommended sample sizes:
86//! - **50-100**: Small populations or rapid prototyping
87//! - **100-200**: Standard optimization problems
88//! - **200+**: Complex problems requiring high genetic diversity
89
90use crate::models::{FitnessGoal, Genotype};
91use rand::seq::SliceRandom;
92use serde::{Deserialize, Serialize};
93use tracing::instrument;
94
95/// Performs a single roulette wheel spin to select a candidate index.
96fn spin_roulette(
97 candidates: &[(&Genotype, f64)],
98 total_fitness: f64,
99 offset: f64,
100 rng: &mut impl rand::Rng,
101) -> Result<usize, SelectionError> {
102 let spin = rng.random_range(0.0..total_fitness);
103 let mut cumulative = 0.0;
104
105 for (index, (_, fitness)) in candidates.iter().enumerate() {
106 cumulative += fitness + offset;
107 if cumulative >= spin {
108 return Ok(index);
109 }
110 }
111
112 // This should never happen with proper total_fitness calculation
113 Err(SelectionError::RouletteSelectionFailed)
114}
115
116#[cfg(test)]
117mod spin_roulette_tests {
118 use super::*;
119 use chrono::Utc;
120 use uuid::Uuid;
121
122 const TOLERANCE: f64 = 0.07;
123
124 fn create_test_genotype(id: &str) -> Genotype {
125 Genotype {
126 id: Uuid::parse_str(id).unwrap(),
127 generated_at: Utc::now(),
128 type_name: "test".to_string(),
129 type_hash: 123,
130 genome: vec![1, 2, 3],
131 genome_hash: Genotype::compute_genome_hash(&[1, 2, 3]),
132 request_id: Uuid::now_v7(),
133 generation_id: 1,
134 }
135 }
136
137 #[test]
138 fn it_spins_the_roulette() {
139 // Create candidates with fitness values 0.1, 0.3, 0.6
140 let genotypes = vec![
141 create_test_genotype("00000000-0000-0000-0000-000000000001"),
142 create_test_genotype("00000000-0000-0000-0000-000000000002"),
143 create_test_genotype("00000000-0000-0000-0000-000000000003"),
144 ];
145 let candidates: Vec<(&Genotype, f64)> = vec![
146 (&genotypes[0], 0.1),
147 (&genotypes[1], 0.3),
148 (&genotypes[2], 0.6),
149 ];
150 let total_fitness = 1.0;
151 let offset = 0.0;
152
153 // Spin 1000 times and count selections
154 let mut counts = [0; 3];
155 let mut rng = rand::rng();
156
157 for _ in 0..1000 {
158 let selected_idx = spin_roulette(&candidates, total_fitness, offset, &mut rng).unwrap();
159 counts[selected_idx] += 1;
160 }
161
162 // Check if proportions are within reasonable tolerance (±5%)
163 let proportion_0 = counts[0] as f64 / 1000.0;
164 let proportion_1 = counts[1] as f64 / 1000.0;
165 let proportion_2 = counts[2] as f64 / 1000.0;
166
167 // Expect 10% with tolerance
168 assert!((proportion_0 - 0.1).abs() < TOLERANCE);
169
170 // Expect 30% with tolerance
171 assert!((proportion_1 - 0.3).abs() < TOLERANCE);
172
173 // Expect 60% with tolerance
174 assert!((proportion_2 - 0.6).abs() < TOLERANCE);
175 }
176
177 #[test]
178 fn it_always_selects_single_candidate() {
179 let genotype = create_test_genotype("00000000-0000-0000-0000-000000000001");
180 let candidates = vec![(&genotype, 1.0)];
181 let mut rng = rand::rng();
182
183 for _ in 0..3 {
184 let selected_idx = spin_roulette(&candidates, 1.0, 0.0, &mut rng).unwrap();
185 assert_eq!(selected_idx, 0);
186 }
187 }
188
189 #[test]
190 fn it_distributes_equal_fitness_evenly() {
191 let genotype_1 = create_test_genotype("00000000-0000-0000-0000-000000000001");
192 let genotype_2 = create_test_genotype("00000000-0000-0000-0000-000000000002");
193 let candidates = vec![(&genotype_1, 1.0), (&genotype_2, 1.0)];
194 let mut counts = [0; 2];
195 let mut rng = rand::rng();
196
197 for _ in 0..1000 {
198 let idx = spin_roulette(&candidates, 2.0, 0.0, &mut rng).unwrap();
199 counts[idx] += 1;
200 }
201
202 // Should be roughly 50/50 (±5%)
203 let proportion_0 = counts[0] as f64 / 1000.0;
204 let proportion_1 = counts[1] as f64 / 1000.0;
205
206 // Expect 50% with tolerance
207 assert!((proportion_0 - 0.5).abs() < TOLERANCE);
208
209 // Expect 50% with tolerance
210 assert!((proportion_1 - 0.5).abs() < TOLERANCE);
211 }
212
213 #[test]
214 fn it_fails_when_total_fitness_is_incorrect() {
215 let genotype = create_test_genotype("00000000-0000-0000-0000-000000000001");
216 let candidates = vec![(&genotype, 0.0)];
217 let total_fitness = 1.0;
218 let offset = 0.0;
219 let mut rng = rand::rng();
220
221 let should_err = spin_roulette(&candidates, total_fitness, offset, &mut rng);
222 assert!(should_err.is_err(),);
223 }
224}
225
226/// Selects parent pairs using fitness-proportionate roulette wheel selection.
227#[instrument(level = "debug", skip(candidates_with_fitness, rng), fields(num_pairs = num_pairs, num_candidates = candidates_with_fitness.len()))]
228fn roulette_selection<'a>(
229 num_pairs: usize,
230 candidates_with_fitness: &'a [(Genotype, Option<f64>)],
231 goal: &FitnessGoal,
232 rng: &mut impl rand::Rng,
233) -> Result<Vec<(&'a Genotype, &'a Genotype)>, SelectionError> {
234 let mut parent_pairs = Vec::with_capacity(num_pairs);
235
236 // Filter out genotypes without fitness and calculate sum
237 let raw_fitness_pairs: Vec<(&Genotype, f64)> = candidates_with_fitness
238 .iter()
239 .filter_map(|(genotype, fitness_opt)| fitness_opt.map(|fitness| (genotype, fitness)))
240 .collect();
241
242 if raw_fitness_pairs.is_empty() {
243 return Err(SelectionError::NoValidParents);
244 }
245
246 // Transform fitness values for roulette wheel based on goal
247 let evaluated_candidates: Vec<(&Genotype, f64)> = match goal {
248 FitnessGoal::Maximize { .. } => {
249 // Find the minimum value
250 let min_fitness = raw_fitness_pairs
251 .iter()
252 .map(|(_, fitness)| *fitness)
253 .min_by(|a, b| a.partial_cmp(b).unwrap())
254 .unwrap();
255
256 // Figure out how much, if anything it needs to be shifted
257 let shift = if min_fitness < 0.0 { -min_fitness } else { 0.0 };
258
259 // Shift the values
260 raw_fitness_pairs
261 .iter()
262 .map(|(genotype, fitness)| (*genotype, fitness + shift))
263 .collect()
264 }
265 FitnessGoal::Minimize { .. } => {
266 // Find the maximum fitness
267 let max_fitness = raw_fitness_pairs
268 .iter()
269 .map(|(_, fitness)| *fitness)
270 .max_by(|a, b| a.partial_cmp(b).unwrap())
271 .unwrap();
272
273 // Invert: lower fitness = higher weight
274 raw_fitness_pairs
275 .iter()
276 .map(|(genotype, fitness)| (*genotype, max_fitness - fitness))
277 .collect()
278 }
279 };
280
281 // Calculate total fitness for roulette wheel
282 let total_fitness: f64 = evaluated_candidates.iter().map(|(_, weight)| *weight).sum();
283
284 if total_fitness <= 0.0 {
285 return Err(SelectionError::InvalidFitnessForRoulette);
286 }
287
288 // Select parent pairs
289 for _ in 0..num_pairs {
290 let parent1_idx = spin_roulette(&evaluated_candidates, total_fitness, 0.0, rng)?;
291 let parent2_idx = spin_roulette(&evaluated_candidates, total_fitness, 0.0, rng)?;
292 parent_pairs.push((
293 evaluated_candidates[parent1_idx].0,
294 evaluated_candidates[parent2_idx].0,
295 ));
296 }
297
298 Ok(parent_pairs)
299}
300
301/// Selects parent pairs using tournament selection with the given tournament size.
302#[instrument(level = "debug", skip(candidates_with_fitness, rng), fields(num_pairs = num_pairs, tournament_size = tournament_size, num_candidates = candidates_with_fitness.len()))]
303fn tournament_selection<'a>(
304 num_pairs: usize,
305 tournament_size: usize,
306 candidates_with_fitness: &'a [(Genotype, Option<f64>)],
307 goal: &FitnessGoal,
308 rng: &mut impl rand::Rng,
309) -> Result<Vec<(&'a Genotype, &'a Genotype)>, SelectionError> {
310 let mut parent_pairs = Vec::with_capacity(num_pairs);
311
312 // Filter out genotypes without fitness (borrowed)
313 let evaluated_candidates: Vec<(&Genotype, f64)> = candidates_with_fitness
314 .iter()
315 .filter_map(|(genotype, fitness_opt)| fitness_opt.map(|fitness| (genotype, fitness)))
316 .collect();
317
318 // Check if we have enough candidates
319 if evaluated_candidates.len() < tournament_size * 2 {
320 return Err(SelectionError::InvalidSampleSize {
321 min_required: tournament_size * 2,
322 provided: evaluated_candidates.len(),
323 });
324 }
325
326 for _ in 0..num_pairs {
327 // Create indices and shuffle them
328 let mut indices: Vec<usize> = (0..evaluated_candidates.len()).collect();
329 indices.shuffle(rng);
330
331 // Tournament for parent 1
332 let mut parent1_idx = indices[0];
333 for &idx in &indices[0..tournament_size] {
334 if goal.is_better(
335 evaluated_candidates[idx].1,
336 evaluated_candidates[parent1_idx].1,
337 ) {
338 parent1_idx = idx;
339 }
340 }
341
342 // Tournament for parent 2
343 let mut parent2_idx = indices[tournament_size];
344 for &idx in &indices[tournament_size..(tournament_size * 2)] {
345 if goal.is_better(
346 evaluated_candidates[idx].1,
347 evaluated_candidates[parent2_idx].1,
348 ) {
349 parent2_idx = idx;
350 }
351 }
352
353 parent_pairs.push((
354 evaluated_candidates[parent1_idx].0,
355 evaluated_candidates[parent2_idx].0,
356 ));
357 }
358
359 Ok(parent_pairs)
360}
361
362/// Configuration for parent selection in genetic algorithms.
363///
364/// The selector determines how parent pairs are chosen from the candidate population
365/// for breeding operations. Different selection methods bias toward higher-fitness
366/// candidates to varying degrees, affecting the evolutionary pressure.
367///
368/// # Selection Pressure
369///
370/// **Tournament Selection**: Moderate to high selection pressure. Larger tournament
371/// sizes increase pressure by making it more likely that high-fitness candidates win.
372///
373/// **Roulette Selection**: Proportional selection pressure. Candidates are chosen
374/// with probability proportional to their fitness values.
375///
376/// # Examples
377///
378/// ```rust
379/// use fx_durable_ga::models::Selector;
380///
381/// // Tournament selection with size 3 (moderate selection pressure)
382/// let tournament_selector = Selector::tournament(3, 100);
383///
384/// // Roulette wheel selection (fitness-proportionate)
385/// let roulette_selector = Selector::roulette(100);
386///
387/// // High selection pressure tournament
388/// let elite_selector = Selector::tournament(7, 50);
389/// ```
390#[derive(Debug, Deserialize, Serialize, Clone)]
391#[cfg_attr(test, derive(PartialEq))]
392pub struct Selector {
393 /// The selection algorithm to use for choosing parent pairs
394 pub method: SelectionMethod,
395 /// Number of candidates to sample from the population for selection
396 pub sample_size: usize,
397}
398
399/// Selection algorithms available for parent selection.
400///
401/// Each method has different characteristics and is suitable for different
402/// optimization scenarios.
403#[derive(Debug, Deserialize, Serialize, Clone)]
404#[cfg_attr(test, derive(PartialEq, Eq))]
405pub enum SelectionMethod {
406 /// Tournament selection runs competitions between randomly selected candidates.
407 ///
408 /// Winners are chosen based on fitness comparison. Larger tournament sizes
409 /// increase selection pressure, making it more likely that high-fitness
410 /// candidates are selected.
411 ///
412 /// **Best for**: Problems where you want consistent convergence with
413 /// adjustable selection pressure. Works well with any fitness distribution.
414 ///
415 /// **Tournament size guidelines**:
416 /// - Size 2-3: Low to moderate selection pressure, good exploration
417 /// - Size 4-5: Moderate selection pressure, balanced exploration/exploitation
418 /// - Size 6+: High selection pressure, strong exploitation
419 Tournament {
420 /// Number of candidates that compete in each tournament.
421 /// Must be at least 1. Requires at least `size * 2` evaluated candidates.
422 size: usize,
423 },
424
425 /// Roulette wheel selection chooses candidates with probability proportional to fitness.
426 ///
427 /// Also known as fitness-proportionate selection. Candidates with higher
428 /// fitness values have proportionally higher chances of being selected.
429 ///
430 /// **Best for**: Problems where fitness values are meaningful as proportions
431 /// and you want selection probability to directly reflect fitness differences.
432 ///
433 /// **Requirements**:
434 /// - All fitness values must be non-negative
435 /// - At least one candidate must have positive fitness
436 /// - Works best when fitness values have reasonable range (not too extreme)
437 Roulette,
438}
439
440/// Errors that can occur during parent selection.
441///
442/// These errors help diagnose configuration or data issues that prevent
443/// successful parent selection for breeding operations.
444#[derive(Debug, thiserror::Error)]
445#[cfg_attr(test, derive(PartialEq, Eq))]
446pub enum SelectionError {
447 /// No candidates with fitness values are available for selection.
448 ///
449 /// This occurs when all candidates in the population have `None` fitness values,
450 /// meaning no candidates have been evaluated yet.
451 ///
452 /// **Solution**: Ensure candidates are evaluated before attempting selection.
453 #[error("No valid parents available for selection")]
454 NoValidParents,
455
456 /// Invalid sample size for tournament selection configuration.
457 ///
458 /// Tournament selection requires `sample_size >= tournament_size * 2` because
459 /// it runs two separate tournaments (one for each parent in a breeding pair).
460 #[error(
461 "Sample size must be >= 2 * tournament_size. Min required: {min_required}, got {provided}"
462 )]
463 InvalidSampleSize {
464 min_required: usize,
465 provided: usize,
466 },
467
468 /// Roulette selection cannot proceed due to invalid fitness values.
469 ///
470 /// This error occurs when:
471 /// - Any candidate has negative fitness (< 0.0)
472 /// - All candidates have zero fitness (total fitness = 0.0)
473 ///
474 /// **Solutions**:
475 /// - Ensure all fitness values are non-negative
476 /// - Verify that at least some candidates have positive fitness
477 /// - Consider fitness normalization if needed
478 /// - Switch to tournament selection if fitness scaling is problematic
479 #[error("All candidates have zero or negative fitness for roulette selection")]
480 InvalidFitnessForRoulette,
481
482 /// Internal roulette wheel algorithm failure.
483 ///
484 /// This should not occur under normal conditions and indicates a bug
485 /// in the roulette selection implementation.
486 #[error("Internal error: roulette wheel failed to select candidate")]
487 RouletteSelectionFailed,
488}
489
490impl Selector {
491 /// Creates a tournament selector with the specified tournament size.
492 ///
493 /// Tournament selection randomly selects groups of candidates and chooses
494 /// the fittest from each group. This provides consistent selection pressure
495 /// that can be tuned via the tournament size.
496 ///
497 /// # Parameters
498 ///
499 /// * `tournament_size` - Number of candidates competing in each tournament.
500 /// Larger values increase selection pressure. Must be at least 1.
501 /// * `sample_size` - Number of candidates to sample from the population.
502 /// Should be large enough to provide genetic diversity.
503 ///
504 /// # Selection Pressure Guide
505 ///
506 /// * **Size 2**: Weak selection pressure, high exploration
507 /// * **Size 3-4**: Moderate selection pressure, balanced search
508 /// * **Size 5-7**: Strong selection pressure, focused exploitation
509 /// * **Size 8+**: Very strong selection pressure, may cause premature convergence
510 ///
511 /// # Examples
512 ///
513 /// ```rust
514 /// use fx_durable_ga::models::Selector;
515 ///
516 /// // Balanced exploration and exploitation
517 /// let balanced = Selector::tournament(3, 100);
518 ///
519 /// // High exploration for complex search spaces
520 /// let exploratory = Selector::tournament(2, 150);
521 ///
522 /// // Strong exploitation for fine-tuning
523 /// let exploitative = Selector::tournament(6, 80);
524 /// ```
525 ///
526 /// # Requirements
527 ///
528 /// The population must have at least `tournament_size * 2` evaluated candidates
529 /// to perform selection, as each parent pair requires two separate tournaments.
530 pub fn tournament(tournament_size: usize, sample_size: usize) -> Result<Self, SelectionError> {
531 let min_sample_size = tournament_size * 2;
532 if sample_size < min_sample_size {
533 return Err(SelectionError::InvalidSampleSize {
534 min_required: min_sample_size,
535 provided: sample_size,
536 });
537 }
538
539 Ok(Self {
540 method: SelectionMethod::Tournament {
541 size: tournament_size,
542 },
543 sample_size,
544 })
545 }
546
547 /// Creates a roulette wheel selector for fitness-proportionate selection.
548 ///
549 /// Roulette selection chooses candidates with probability directly proportional
550 /// to their fitness values. This means a candidate with fitness 6.0 is twice
551 /// as likely to be selected as one with fitness 3.0.
552 ///
553 /// # Parameters
554 ///
555 /// * `sample_size` - Number of candidates to sample from the population.
556 /// Larger samples provide better representation of the fitness distribution.
557 ///
558 /// # When to Use
559 ///
560 /// Roulette selection works best when:
561 /// * Fitness values represent meaningful proportional differences
562 /// * You want selection probability to directly reflect fitness ratios
563 /// * The fitness landscape has reasonable dynamic range (not too extreme)
564 /// * You need predictable selection behavior based on fitness distributions
565 ///
566 /// # Examples
567 ///
568 /// ```rust
569 /// use fx_durable_ga::models::Selector;
570 ///
571 /// // Standard roulette selection
572 /// let roulette = Selector::roulette(100);
573 ///
574 /// // Larger sample for better fitness representation
575 /// let large_sample = Selector::roulette(200);
576 /// ```
577 ///
578 /// # Fitness Requirements
579 ///
580 /// * All fitness values must be non-negative (≥ 0.0)
581 /// * At least one candidate must have positive fitness (> 0.0)
582 /// * Total fitness across all candidates must be positive
583 ///
584 /// # Performance Considerations
585 ///
586 /// Roulette selection may struggle with:
587 /// * Fitness values with extreme ranges (e.g., 0.001 vs 1000.0)
588 /// * Populations where most candidates have very similar fitness
589 /// * Scenarios requiring rapid convergence
590 pub fn roulette(sample_size: usize) -> Self {
591 Self {
592 method: SelectionMethod::Roulette,
593 sample_size,
594 }
595 }
596
597 /// Selects parent pairs from candidates using the configured selection method.
598 #[instrument(level = "debug", skip(self, candidates_with_fitness), fields(method = ?self.method, num_pairs = num_pairs, num_candidates = candidates_with_fitness.len()))]
599 pub(crate) fn select_parents<'a>(
600 &self,
601 num_pairs: usize,
602 candidates_with_fitness: &'a [(Genotype, Option<f64>)],
603 goal: &FitnessGoal,
604 ) -> Result<Vec<(&'a Genotype, &'a Genotype)>, SelectionError> {
605 let mut rng = rand::rng();
606 match self.method {
607 SelectionMethod::Tournament { size } => {
608 tournament_selection(num_pairs, size, candidates_with_fitness, goal, &mut rng)
609 }
610 SelectionMethod::Roulette => {
611 roulette_selection(num_pairs, candidates_with_fitness, goal, &mut rng)
612 }
613 }
614 }
615
616 /// Returns the configured sample size for this selector.
617 ///
618 /// The sample size determines how many candidates are drawn from the
619 /// population before performing selection. Larger sample sizes provide
620 /// better representation of the population's fitness distribution but
621 /// may increase computational cost.
622 ///
623 /// # Returns
624 ///
625 /// The sample size as a signed integer for compatibility with database
626 /// query operations.
627 ///
628 /// # Examples
629 ///
630 /// ```rust
631 /// use fx_durable_ga::models::Selector;
632 ///
633 /// let selector = Selector::tournament(3, 150).expect("sample_size is larger than 2 * tournament_size");
634 /// assert_eq!(selector.sample_size(), 150);
635 ///
636 /// let roulette = Selector::roulette(75);
637 /// assert_eq!(roulette.sample_size(), 75);
638 /// ```
639 pub fn sample_size(&self) -> i64 {
640 self.sample_size as i64
641 }
642}
643
644#[cfg(test)]
645mod selector_tests {
646 use super::*;
647
648 #[test]
649 fn test_tournament_constructor() {
650 let selector = Selector::tournament(3, 50).expect("is valid");
651
652 assert_eq!(selector.sample_size, 50);
653 assert_eq!(selector.method, SelectionMethod::Tournament { size: 3 });
654 }
655
656 #[test]
657 fn test_tournament_constructor_validation() {
658 let selector = Selector::tournament(3, 5);
659
660 assert!(selector.is_err());
661 assert!(matches!(
662 selector.unwrap_err(),
663 SelectionError::InvalidSampleSize {
664 min_required: 6,
665 provided: 5
666 }
667 ));
668 }
669
670 #[test]
671 fn test_roulette_constructor() {
672 let selector = Selector::roulette(25);
673
674 assert_eq!(selector.sample_size, 25);
675 assert_eq!(selector.method, SelectionMethod::Roulette);
676 }
677
678 #[test]
679 fn test_sample_size_method() {
680 let tournament_selector = Selector::tournament(2, 100).expect("is valid");
681 assert_eq!(tournament_selector.sample_size(), 100);
682
683 let roulette_selector = Selector::roulette(75);
684 assert_eq!(roulette_selector.sample_size(), 75);
685 }
686
687 #[test]
688 fn test_select_parents_tournament() {
689 use rand::SeedableRng;
690 use rand::rngs::StdRng;
691
692 // Test with a seed that ensures we hit the parent2_idx assignment
693 let mut rng = StdRng::seed_from_u64(1);
694
695 let candidates = vec![
696 (
697 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000001"),
698 Some(1.0), // lowest fitness
699 ),
700 (
701 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000002"),
702 Some(2.0),
703 ),
704 (
705 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000003"),
706 Some(3.0),
707 ),
708 (
709 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000004"),
710 Some(4.0),
711 ),
712 (
713 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000005"),
714 Some(5.0),
715 ),
716 (
717 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000006"),
718 Some(6.0), // highest fitness
719 ),
720 ];
721
722 let goal = &crate::models::FitnessGoal::maximize(0.9).unwrap();
723 let result = tournament_selection(2, 2, &candidates, goal, &mut rng);
724 assert!(result.is_ok());
725
726 let pairs = result.unwrap();
727 assert_eq!(pairs.len(), 2);
728
729 // Verify returned refs point into candidates
730 for (p1, p2) in &pairs {
731 let any_match = candidates.iter().any(|(g, _)| std::ptr::eq(*p1, g))
732 && candidates.iter().any(|(g, _)| std::ptr::eq(*p2, g));
733 assert!(any_match);
734 }
735 }
736
737 #[test]
738 fn test_select_parents_roulette() {
739 let selector = Selector::roulette(25);
740
741 let candidates = vec![
742 (
743 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000001"),
744 Some(1.0),
745 ),
746 (
747 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000002"),
748 Some(2.0),
749 ),
750 (
751 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000003"),
752 Some(3.0),
753 ),
754 ];
755
756 let goal = &crate::models::FitnessGoal::maximize(0.9).unwrap();
757 let result = selector.select_parents(2, &candidates, goal);
758 assert!(result.is_ok());
759
760 let pairs = result.unwrap();
761 assert_eq!(pairs.len(), 2);
762
763 for (p1, p2) in &pairs {
764 let any_match = candidates.iter().any(|(g, _)| std::ptr::eq(*p1, g))
765 && candidates.iter().any(|(g, _)| std::ptr::eq(*p2, g));
766 assert!(any_match);
767 }
768 }
769
770 #[test]
771 fn test_select_parents_tournament_insufficient_candidates() {
772 let selector = Selector::tournament(3, 50).expect("is valid");
773
774 let candidates = vec![(
775 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000001"),
776 Some(1.0),
777 )];
778
779 let goal = &crate::models::FitnessGoal::maximize(0.9).unwrap();
780 let result = selector.select_parents(1, &candidates, goal);
781 assert_eq!(
782 result,
783 Err(SelectionError::InvalidSampleSize {
784 min_required: 6,
785 provided: 1
786 })
787 );
788 }
789
790 #[test]
791 fn test_select_parents_roulette_no_valid_parents() {
792 let selector = Selector::roulette(25);
793
794 let candidates = vec![(
795 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000001"),
796 None,
797 )];
798
799 let goal = &crate::models::FitnessGoal::maximize(0.9).unwrap();
800 let result = selector.select_parents(1, &candidates, goal);
801 assert_eq!(result, Err(SelectionError::NoValidParents));
802 }
803
804 #[test]
805 fn test_roulette_handles_negative_fitness() {
806 let selector = Selector::roulette(25);
807 let candidates = vec![
808 (
809 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000001"),
810 Some(-1.0),
811 ),
812 (
813 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000002"),
814 Some(2.0),
815 ),
816 ];
817 let goal = &crate::models::FitnessGoal::maximize(0.9).unwrap();
818 let result = selector.select_parents(1, &candidates, goal);
819
820 // Should succeed now that we handle negative fitness
821 assert!(result.is_ok());
822 let pairs = result.unwrap();
823 assert_eq!(pairs.len(), 1);
824 }
825
826 #[test]
827 fn test_roulette_errors_on_total_fitness_zero() {
828 let selector = Selector::roulette(25);
829
830 let candidates = vec![(
831 super::test_utilities::create_test_genotype("00000000-0000-0000-0000-000000000001"),
832 Some(0.0),
833 )];
834
835 let goal = &crate::models::FitnessGoal::maximize(0.9).unwrap();
836 let result = selector.select_parents(1, &candidates, goal);
837 assert_eq!(result, Err(SelectionError::InvalidFitnessForRoulette));
838 }
839
840 #[test]
841 fn test_selector_clone_and_equality() {
842 let selector1 = Selector::tournament(3, 50).expect("is valid");
843 let selector2 = selector1.clone();
844
845 assert_eq!(selector1, selector2);
846
847 let selector3 = Selector::roulette(25);
848 assert_ne!(selector1, selector3);
849 }
850}
851
852#[cfg(test)]
853mod test_utilities {
854 use crate::models::Genotype;
855 use chrono::Utc;
856 use uuid::Uuid;
857
858 pub(super) fn create_test_genotype(id: &str) -> Genotype {
859 Genotype {
860 id: Uuid::parse_str(id).unwrap(),
861 generated_at: Utc::now(),
862 type_name: "test".to_string(),
863 type_hash: 123,
864 genome: vec![1, 2, 3],
865 genome_hash: Genotype::compute_genome_hash(&[1, 2, 3]),
866 request_id: Uuid::now_v7(),
867 generation_id: 1,
868 }
869 }
870}