Skip to main content

fugue_evo/operators/
selection.rs

1//! Selection operators
2//!
3//! This module provides various selection operators for genetic algorithms.
4
5use rand::seq::SliceRandom;
6use rand::Rng;
7use rand_distr::{Distribution, WeightedIndex};
8
9use crate::genome::traits::EvolutionaryGenome;
10use crate::operators::traits::SelectionOperator;
11
12/// Tournament selection operator
13///
14/// Selects the best individual from a randomly sampled subset (the
15/// "tournament") of the population.
16///
17/// # Sampling with vs. without replacement
18///
19/// By default competitors are sampled **with replacement** (each of the `k`
20/// competitors is drawn independently and uniformly from the whole
21/// population). This is the textbook model, for which the probability that the
22/// individual of rank `i` wins has the clean closed form and selection pressure
23/// grows smoothly with `k`; the same individual may appear more than once in a
24/// tournament. Crucially, `tournament_size >= population size` does **not**
25/// make selection deterministic under this model — a draw of `k = n`
26/// competitors with replacement includes the global best only with probability
27/// `1 - ((n-1)/n)^k` (≈ 0.63 at `k = n`).
28///
29/// The without-replacement variant (constructed via
30/// [`without_replacement`](Self::without_replacement)) instead draws `k`
31/// *distinct* individuals; there the tournament size is capped at the
32/// population size and `k >= n` degenerates to fully elitist selection (the
33/// global best is chosen every call).
34#[derive(Clone, Debug)]
35pub struct TournamentSelection {
36    /// Tournament size (number of individuals competing)
37    pub tournament_size: usize,
38    /// Whether competitors are sampled with replacement (canonical default) or
39    /// as distinct individuals.
40    pub with_replacement: bool,
41}
42
43impl TournamentSelection {
44    /// Create a new tournament selection with the given size (sampling with
45    /// replacement, the canonical model).
46    pub fn new(tournament_size: usize) -> Self {
47        assert!(tournament_size >= 1, "Tournament size must be at least 1");
48        Self {
49            tournament_size,
50            with_replacement: true,
51        }
52    }
53
54    /// Create binary tournament selection (size = 2, with replacement)
55    pub fn binary() -> Self {
56        Self::new(2)
57    }
58
59    /// Create a tournament selection that samples `tournament_size` **distinct**
60    /// competitors (without replacement).
61    ///
62    /// Note that with this variant `tournament_size >= population size` selects
63    /// the global best deterministically every call.
64    pub fn without_replacement(tournament_size: usize) -> Self {
65        assert!(tournament_size >= 1, "Tournament size must be at least 1");
66        Self {
67            tournament_size,
68            with_replacement: false,
69        }
70    }
71}
72
73impl<G: EvolutionaryGenome> SelectionOperator<G> for TournamentSelection {
74    fn select<R: Rng>(&self, population: &[(G, f64)], rng: &mut R) -> usize {
75        assert!(!population.is_empty(), "Population cannot be empty");
76
77        let n = population.len();
78
79        let tournament: Vec<usize> = if self.with_replacement {
80            // Canonical: k competitors drawn i.i.d. with replacement.
81            (0..self.tournament_size)
82                .map(|_| rng.gen_range(0..n))
83                .collect()
84        } else {
85            // Distinct competitors; cannot draw more than the population size.
86            let k = self.tournament_size.min(n);
87            (0..n)
88                .collect::<Vec<usize>>()
89                .choose_multiple(rng, k)
90                .copied()
91                .collect()
92        };
93
94        // Find the best in the tournament
95        tournament
96            .into_iter()
97            .max_by(|&a, &b| {
98                population[a]
99                    .1
100                    .partial_cmp(&population[b].1)
101                    .unwrap_or(std::cmp::Ordering::Equal)
102            })
103            .unwrap()
104    }
105}
106
107/// Roulette wheel selection (fitness proportionate)
108///
109/// Selection probability is proportional to fitness.
110#[derive(Clone, Debug)]
111pub struct RouletteSelection {
112    /// Offset to ensure all fitnesses are positive
113    offset: f64,
114}
115
116impl RouletteSelection {
117    /// Create a new roulette selection
118    pub fn new() -> Self {
119        Self { offset: 0.0 }
120    }
121
122    /// Create with a fitness offset (to handle negative fitness)
123    pub fn with_offset(offset: f64) -> Self {
124        Self { offset }
125    }
126}
127
128impl Default for RouletteSelection {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl<G: EvolutionaryGenome> SelectionOperator<G> for RouletteSelection {
135    fn select<R: Rng>(&self, population: &[(G, f64)], rng: &mut R) -> usize {
136        assert!(!population.is_empty(), "Population cannot be empty");
137
138        // Find minimum fitness and compute offset
139        let min_fitness = population
140            .iter()
141            .map(|(_, f)| *f)
142            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
143            .unwrap();
144
145        let offset = if min_fitness < 0.0 {
146            -min_fitness + self.offset + 1.0
147        } else {
148            self.offset
149        };
150
151        // Compute weights
152        let weights: Vec<f64> = population.iter().map(|(_, f)| f + offset).collect();
153
154        // Handle case where all weights are zero
155        let total: f64 = weights.iter().sum();
156        if total <= 0.0 {
157            return rng.gen_range(0..population.len());
158        }
159
160        // Create weighted distribution
161        match WeightedIndex::new(&weights) {
162            Ok(dist) => dist.sample(rng),
163            Err(_) => rng.gen_range(0..population.len()),
164        }
165    }
166}
167
168/// Truncation selection
169///
170/// Selects only from the top percentage of the population.
171#[derive(Clone, Debug)]
172pub struct TruncationSelection {
173    /// Fraction of population to select from (0.0 to 1.0)
174    pub truncation_ratio: f64,
175}
176
177impl TruncationSelection {
178    /// Create a new truncation selection
179    pub fn new(truncation_ratio: f64) -> Self {
180        assert!(
181            truncation_ratio > 0.0 && truncation_ratio <= 1.0,
182            "Truncation ratio must be in (0, 1]"
183        );
184        Self { truncation_ratio }
185    }
186}
187
188impl<G: EvolutionaryGenome> SelectionOperator<G> for TruncationSelection {
189    fn select<R: Rng>(&self, population: &[(G, f64)], rng: &mut R) -> usize {
190        assert!(!population.is_empty(), "Population cannot be empty");
191
192        // Sort indices by fitness (descending)
193        let mut indices: Vec<usize> = (0..population.len()).collect();
194        indices.sort_by(|&a, &b| {
195            population[b]
196                .1
197                .partial_cmp(&population[a].1)
198                .unwrap_or(std::cmp::Ordering::Equal)
199        });
200
201        // Select from top portion
202        let cutoff = ((population.len() as f64) * self.truncation_ratio).ceil() as usize;
203        let cutoff = cutoff.max(1);
204
205        indices[rng.gen_range(0..cutoff)]
206    }
207}
208
209/// Rank-based selection
210///
211/// Selection probability is based on rank rather than raw fitness.
212#[derive(Clone, Debug)]
213pub struct RankSelection {
214    /// Selection pressure (1.0 = uniform, 2.0 = strong pressure)
215    pub selection_pressure: f64,
216}
217
218impl RankSelection {
219    /// Create a new rank selection
220    pub fn new(selection_pressure: f64) -> Self {
221        assert!(
222            (1.0..=2.0).contains(&selection_pressure),
223            "Selection pressure must be in [1.0, 2.0]"
224        );
225        Self { selection_pressure }
226    }
227}
228
229impl Default for RankSelection {
230    fn default() -> Self {
231        Self::new(1.5)
232    }
233}
234
235impl<G: EvolutionaryGenome> SelectionOperator<G> for RankSelection {
236    fn select<R: Rng>(&self, population: &[(G, f64)], rng: &mut R) -> usize {
237        assert!(!population.is_empty(), "Population cannot be empty");
238
239        let n = population.len();
240        let sp = self.selection_pressure;
241
242        // Sort indices by fitness (ascending - worst first)
243        let mut indices: Vec<usize> = (0..n).collect();
244        indices.sort_by(|&a, &b| {
245            population[a]
246                .1
247                .partial_cmp(&population[b].1)
248                .unwrap_or(std::cmp::Ordering::Equal)
249        });
250
251        // Compute rank-based weights using Baker's linear ranking
252        // weight(i) = 2 - sp + 2(sp - 1)(rank - 1)/(n - 1)
253        let weights: Vec<f64> = (0..n)
254            .map(|rank| {
255                if n == 1 {
256                    1.0
257                } else {
258                    2.0 - sp + 2.0 * (sp - 1.0) * (rank as f64) / ((n - 1) as f64)
259                }
260            })
261            .collect();
262
263        match WeightedIndex::new(&weights) {
264            Ok(dist) => indices[dist.sample(rng)],
265            Err(_) => indices[rng.gen_range(0..n)],
266        }
267    }
268}
269
270/// Boltzmann selection (temperature-based)
271///
272/// Uses softmax of fitness values scaled by temperature.
273#[derive(Clone, Debug)]
274pub struct BoltzmannSelection {
275    /// Temperature parameter (higher = more uniform, lower = more greedy)
276    pub temperature: f64,
277}
278
279impl BoltzmannSelection {
280    /// Create a new Boltzmann selection
281    pub fn new(temperature: f64) -> Self {
282        assert!(temperature > 0.0, "Temperature must be positive");
283        Self { temperature }
284    }
285}
286
287impl<G: EvolutionaryGenome> SelectionOperator<G> for BoltzmannSelection {
288    fn select<R: Rng>(&self, population: &[(G, f64)], rng: &mut R) -> usize {
289        assert!(!population.is_empty(), "Population cannot be empty");
290
291        // Use log-sum-exp trick for numerical stability
292        let scaled: Vec<f64> = population
293            .iter()
294            .map(|(_, f)| f / self.temperature)
295            .collect();
296        let max_scaled = scaled.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
297
298        let weights: Vec<f64> = scaled.iter().map(|s| (s - max_scaled).exp()).collect();
299
300        match WeightedIndex::new(&weights) {
301            Ok(dist) => dist.sample(rng),
302            Err(_) => rng.gen_range(0..population.len()),
303        }
304    }
305}
306
307/// Random selection (uniform)
308#[derive(Clone, Debug, Default)]
309pub struct RandomSelection;
310
311impl RandomSelection {
312    /// Create a new random selection
313    pub fn new() -> Self {
314        Self
315    }
316}
317
318impl<G: EvolutionaryGenome> SelectionOperator<G> for RandomSelection {
319    fn select<R: Rng>(&self, population: &[(G, f64)], rng: &mut R) -> usize {
320        assert!(!population.is_empty(), "Population cannot be empty");
321        rng.gen_range(0..population.len())
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::genome::real_vector::RealVector;
329
330    fn create_population(size: usize) -> Vec<(RealVector, f64)> {
331        (0..size)
332            .map(|i| (RealVector::new(vec![i as f64]), i as f64))
333            .collect()
334    }
335
336    #[test]
337    fn test_tournament_selection_selects_valid_index() {
338        let mut rng = rand::thread_rng();
339        let population = create_population(10);
340        let selection = TournamentSelection::new(3);
341
342        for _ in 0..100 {
343            let idx = selection.select(&population, &mut rng);
344            assert!(idx < population.len());
345        }
346    }
347
348    #[test]
349    fn test_tournament_selection_binary() {
350        let selection = TournamentSelection::binary();
351        assert_eq!(selection.tournament_size, 2);
352    }
353
354    #[test]
355    fn test_tournament_selection_prefers_fitter() {
356        let mut rng = rand::thread_rng();
357        // Population with clear fitness difference
358        let population: Vec<(RealVector, f64)> = vec![
359            (RealVector::new(vec![0.0]), 0.0),
360            (RealVector::new(vec![1.0]), 100.0), // Much fitter
361            (RealVector::new(vec![2.0]), 0.0),
362        ];
363
364        // Without-replacement full tournament draws all distinct individuals,
365        // so it selects the best deterministically.
366        let selection = TournamentSelection::without_replacement(3);
367
368        let mut best_count = 0;
369        let trials = 100;
370        for _ in 0..trials {
371            let idx = selection.select(&population, &mut rng);
372            if idx == 1 {
373                best_count += 1;
374            }
375        }
376
377        // With a full without-replacement tournament, should always select the best
378        assert_eq!(best_count, trials);
379    }
380
381    #[test]
382    fn test_tournament_with_replacement_is_not_deterministic_at_full_size() {
383        // regression: EV-104 — the default (with-replacement) tournament must
384        // NOT collapse to fully elitist selection when tournament_size ==
385        // population size. Pre-fix, sampling was without replacement and
386        // clamped to the population, so k >= n selected the global best on
387        // every call (best_count would equal trials). With canonical
388        // with-replacement sampling the global best is included only with
389        // probability 1 - ((n-1)/n)^k, so it is selected only part of the time.
390        use rand::SeedableRng;
391        let mut rng = rand::rngs::StdRng::seed_from_u64(7);
392
393        // Unique global maximum at index 4.
394        let population: Vec<(RealVector, f64)> = (0..5)
395            .map(|i| (RealVector::new(vec![i as f64]), i as f64))
396            .collect();
397
398        let selection = TournamentSelection::new(5); // k == n, with replacement
399        assert!(selection.with_replacement);
400
401        let trials = 300;
402        let mut best_count = 0;
403        for _ in 0..trials {
404            if selection.select(&population, &mut rng) == 4 {
405                best_count += 1;
406            }
407        }
408
409        // Expected P(best selected) = 1 - (4/5)^5 ≈ 0.672, so the count must be
410        // strictly below `trials` (the pre-fix without-replacement code would
411        // give exactly `trials`).
412        assert!(
413            best_count < trials,
414            "with-replacement tournament should not be deterministic at k == n (got {best_count}/{trials})"
415        );
416        // ...but the best should still win the clear majority of the time.
417        assert!(
418            best_count > trials / 2,
419            "expected the fittest to win most tournaments (got {best_count}/{trials})"
420        );
421    }
422
423    #[test]
424    fn test_tournament_without_replacement_full_size_is_elitist() {
425        // The retained without-replacement variant selects the global best
426        // deterministically when tournament_size >= population size.
427        let mut rng = rand::thread_rng();
428        let population: Vec<(RealVector, f64)> = (0..5)
429            .map(|i| (RealVector::new(vec![i as f64]), i as f64))
430            .collect();
431
432        let selection = TournamentSelection::without_replacement(5);
433        for _ in 0..50 {
434            assert_eq!(selection.select(&population, &mut rng), 4);
435        }
436    }
437
438    #[test]
439    fn test_roulette_selection_selects_valid_index() {
440        let mut rng = rand::thread_rng();
441        let population = create_population(10);
442        let selection = RouletteSelection::new();
443
444        for _ in 0..100 {
445            let idx = selection.select(&population, &mut rng);
446            assert!(idx < population.len());
447        }
448    }
449
450    #[test]
451    fn test_roulette_selection_handles_negative_fitness() {
452        let mut rng = rand::thread_rng();
453        let population: Vec<(RealVector, f64)> = vec![
454            (RealVector::new(vec![0.0]), -10.0),
455            (RealVector::new(vec![1.0]), -5.0),
456            (RealVector::new(vec![2.0]), -1.0),
457        ];
458
459        let selection = RouletteSelection::new();
460
461        for _ in 0..100 {
462            let idx = selection.select(&population, &mut rng);
463            assert!(idx < population.len());
464        }
465    }
466
467    #[test]
468    fn test_truncation_selection_selects_from_top() {
469        let mut rng = rand::thread_rng();
470        let population = create_population(10);
471        let selection = TruncationSelection::new(0.2); // Top 20%
472
473        for _ in 0..100 {
474            let idx = selection.select(&population, &mut rng);
475            // Top 20% of [0..9] should be indices 8 or 9
476            assert!(idx >= 8);
477        }
478    }
479
480    #[test]
481    fn test_rank_selection_selects_valid_index() {
482        let mut rng = rand::thread_rng();
483        let population = create_population(10);
484        let selection = RankSelection::new(1.5);
485
486        for _ in 0..100 {
487            let idx = selection.select(&population, &mut rng);
488            assert!(idx < population.len());
489        }
490    }
491
492    #[test]
493    fn test_boltzmann_selection_selects_valid_index() {
494        let mut rng = rand::thread_rng();
495        let population = create_population(10);
496        let selection = BoltzmannSelection::new(1.0);
497
498        for _ in 0..100 {
499            let idx = selection.select(&population, &mut rng);
500            assert!(idx < population.len());
501        }
502    }
503
504    #[test]
505    fn test_boltzmann_selection_temperature_effect() {
506        let mut rng = rand::thread_rng();
507        // Population with clear fitness difference
508        let population: Vec<(RealVector, f64)> = vec![
509            (RealVector::new(vec![0.0]), 0.0),
510            (RealVector::new(vec![1.0]), 10.0),
511        ];
512
513        // Low temperature = more greedy
514        let low_temp = BoltzmannSelection::new(0.1);
515        // High temperature = more uniform
516        let high_temp = BoltzmannSelection::new(100.0);
517
518        let mut low_best_count = 0;
519        let mut high_best_count = 0;
520        let trials = 1000;
521
522        for _ in 0..trials {
523            if low_temp.select(&population, &mut rng) == 1 {
524                low_best_count += 1;
525            }
526            if high_temp.select(&population, &mut rng) == 1 {
527                high_best_count += 1;
528            }
529        }
530
531        // Low temperature should select best more often
532        assert!(low_best_count > high_best_count);
533    }
534
535    #[test]
536    fn test_random_selection_uniform() {
537        let mut rng = rand::thread_rng();
538        let population = create_population(2);
539        let selection = RandomSelection::new();
540
541        let mut counts = [0, 0];
542        let trials = 1000;
543
544        for _ in 0..trials {
545            counts[selection.select(&population, &mut rng)] += 1;
546        }
547
548        // Should be roughly 50-50 (with some variance)
549        let ratio = counts[0] as f64 / counts[1] as f64;
550        assert!(ratio > 0.8 && ratio < 1.2);
551    }
552
553    #[test]
554    fn test_select_many() {
555        let mut rng = rand::thread_rng();
556        let population = create_population(10);
557        let selection = TournamentSelection::new(3);
558
559        let indices = selection.select_many(&population, 5, &mut rng);
560        assert_eq!(indices.len(), 5);
561        for idx in indices {
562            assert!(idx < population.len());
563        }
564    }
565
566    #[test]
567    #[should_panic(expected = "Tournament size must be at least 1")]
568    fn test_tournament_size_zero() {
569        TournamentSelection::new(0);
570    }
571
572    #[test]
573    #[should_panic(expected = "Truncation ratio must be in (0, 1]")]
574    fn test_truncation_ratio_zero() {
575        TruncationSelection::new(0.0);
576    }
577
578    #[test]
579    #[should_panic(expected = "Temperature must be positive")]
580    fn test_boltzmann_temperature_zero() {
581        BoltzmannSelection::new(0.0);
582    }
583}