Skip to main content

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}