Skip to main content

fugue_evo/interactive/
selection_strategy.rs

1//! Active learning strategies for intelligent candidate selection
2//!
3//! This module provides strategies for selecting which candidates to present
4//! to users for evaluation. Instead of random or sequential selection,
5//! active learning strategies prioritize candidates that will provide the
6//! most useful information for ranking.
7//!
8//! # Available Strategies
9//!
10//! - **Sequential**: Default behavior - simple sequential/round-robin selection
11//! - **UncertaintySampling**: Prioritize candidates with highest uncertainty
12//! - **ExpectedInformationGain**: Select pairs that maximize information gain
13//! - **CoverageAware**: Balance coverage requirements with exploration
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use fugue_evo::interactive::selection_strategy::SelectionStrategy;
19//!
20//! // Use uncertainty sampling with coverage bonus
21//! let strategy = SelectionStrategy::UncertaintySampling {
22//!     uncertainty_weight: 1.0,
23//! };
24//!
25//! let selected = strategy.select_batch(&candidates, &aggregator, 4);
26//! ```
27
28use rand::prelude::*;
29use serde::{Deserialize, Serialize};
30
31use super::aggregation::FitnessAggregator;
32use super::evaluator::Candidate;
33use super::uncertainty::FitnessEstimate;
34use crate::genome::traits::EvolutionaryGenome;
35
36/// Active learning selection strategy
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub enum SelectionStrategy {
39    /// Sequential selection (current default behavior)
40    ///
41    /// Selects candidates in order of their index, cycling through
42    /// the population. Simple but not optimal for learning.
43    Sequential,
44
45    /// Uncertainty sampling
46    ///
47    /// Prioritizes candidates with the highest uncertainty (variance)
48    /// in their fitness estimates. This helps reduce overall uncertainty
49    /// in the ranking.
50    UncertaintySampling {
51        /// Weight for uncertainty vs coverage balance (default: 1.0)
52        /// Higher values prioritize uncertain candidates more strongly
53        uncertainty_weight: f64,
54    },
55
56    /// Expected information gain (for pairwise comparisons)
57    ///
58    /// Selects pairs of candidates where the comparison result is
59    /// most uncertain (probability close to 0.5). This maximizes
60    /// the expected reduction in entropy.
61    ExpectedInformationGain {
62        /// Temperature for softmax selection (default: 1.0)
63        /// Higher values make selection more random
64        temperature: f64,
65    },
66
67    /// Coverage-aware selection
68    ///
69    /// Ensures minimum coverage before exploring uncertain candidates.
70    /// Good for balancing exploration with ensuring all candidates
71    /// are evaluated at least some minimum number of times.
72    CoverageAware {
73        /// Minimum evaluations before considering a candidate "covered"
74        min_evaluations: usize,
75        /// Bonus weight for under-evaluated candidates
76        exploration_bonus: f64,
77    },
78}
79
80impl Default for SelectionStrategy {
81    fn default() -> Self {
82        Self::Sequential
83    }
84}
85
86impl SelectionStrategy {
87    /// Create uncertainty sampling strategy
88    pub fn uncertainty_sampling(uncertainty_weight: f64) -> Self {
89        Self::UncertaintySampling { uncertainty_weight }
90    }
91
92    /// Create expected information gain strategy
93    pub fn information_gain(temperature: f64) -> Self {
94        Self::ExpectedInformationGain { temperature }
95    }
96
97    /// Create coverage-aware strategy
98    pub fn coverage_aware(min_evaluations: usize, exploration_bonus: f64) -> Self {
99        Self::CoverageAware {
100            min_evaluations,
101            exploration_bonus,
102        }
103    }
104
105    /// Select a batch of candidates for evaluation
106    ///
107    /// # Arguments
108    ///
109    /// * `candidates` - All candidates in the population
110    /// * `aggregator` - Fitness aggregator with current estimates
111    /// * `batch_size` - Number of candidates to select
112    /// * `rng` - Random number generator
113    ///
114    /// # Returns
115    ///
116    /// Indices of selected candidates (into the candidates slice)
117    pub fn select_batch<G, R>(
118        &self,
119        candidates: &[Candidate<G>],
120        aggregator: &FitnessAggregator,
121        batch_size: usize,
122        rng: &mut R,
123    ) -> Vec<usize>
124    where
125        G: EvolutionaryGenome,
126        R: Rng,
127    {
128        if candidates.is_empty() || batch_size == 0 {
129            return vec![];
130        }
131
132        let batch_size = batch_size.min(candidates.len());
133
134        match self {
135            Self::Sequential => self.select_sequential(candidates, batch_size),
136            Self::UncertaintySampling { uncertainty_weight } => {
137                self.select_by_uncertainty(candidates, aggregator, batch_size, *uncertainty_weight)
138            }
139            Self::ExpectedInformationGain { temperature } => self.select_by_information_gain(
140                candidates,
141                aggregator,
142                batch_size,
143                *temperature,
144                rng,
145            ),
146            Self::CoverageAware {
147                min_evaluations,
148                exploration_bonus,
149            } => self.select_coverage_aware(
150                candidates,
151                aggregator,
152                batch_size,
153                *min_evaluations,
154                *exploration_bonus,
155            ),
156        }
157    }
158
159    /// Select a pair for pairwise comparison
160    ///
161    /// # Arguments
162    ///
163    /// * `candidates` - All candidates in the population
164    /// * `aggregator` - Fitness aggregator with current estimates
165    /// * `rng` - Random number generator
166    ///
167    /// # Returns
168    ///
169    /// Tuple of indices for the two candidates to compare
170    pub fn select_pair<G, R>(
171        &self,
172        candidates: &[Candidate<G>],
173        aggregator: &FitnessAggregator,
174        rng: &mut R,
175    ) -> Option<(usize, usize)>
176    where
177        G: EvolutionaryGenome,
178        R: Rng,
179    {
180        if candidates.len() < 2 {
181            return None;
182        }
183
184        match self {
185            Self::Sequential => {
186                // Simple sequential pairing
187                Some((0, 1))
188            }
189            Self::UncertaintySampling { .. } => {
190                // Select two most uncertain candidates
191                let scores = self.compute_uncertainty_scores(candidates, aggregator);
192                let mut indices: Vec<usize> = (0..candidates.len()).collect();
193                indices.sort_by(|&a, &b| {
194                    scores[b]
195                        .partial_cmp(&scores[a])
196                        .unwrap_or(std::cmp::Ordering::Equal)
197                });
198                Some((indices[0], indices[1]))
199            }
200            Self::ExpectedInformationGain { temperature } => {
201                self.select_pair_by_information_gain(candidates, aggregator, *temperature, rng)
202            }
203            Self::CoverageAware {
204                min_evaluations, ..
205            } => {
206                // Pair candidates with fewest evaluations
207                let mut indices: Vec<(usize, usize)> = candidates
208                    .iter()
209                    .enumerate()
210                    .map(|(i, c)| (i, c.evaluation_count))
211                    .collect();
212                indices.sort_by_key(|&(_, count)| count);
213
214                let a = indices[0].0;
215                let b = if indices.len() > 1 {
216                    // Find candidate with fewest evaluations that's also "close" in ranking
217                    let a_eval = candidates[a].evaluation_count;
218                    if a_eval < *min_evaluations {
219                        // First pass: just pick two under-evaluated
220                        indices[1].0
221                    } else {
222                        // Pick most informative pair among adequately covered
223                        self.find_informative_pair(candidates, aggregator, rng)
224                    }
225                } else {
226                    return None;
227                };
228                Some((a, b))
229            }
230        }
231    }
232
233    /// Sequential selection - first N unevaluated, then first N overall
234    fn select_sequential<G>(&self, candidates: &[Candidate<G>], batch_size: usize) -> Vec<usize>
235    where
236        G: EvolutionaryGenome,
237    {
238        // First, select unevaluated candidates
239        let mut selected: Vec<usize> = candidates
240            .iter()
241            .enumerate()
242            .filter(|(_, c)| c.evaluation_count == 0)
243            .take(batch_size)
244            .map(|(i, _)| i)
245            .collect();
246
247        // If need more, add from beginning
248        if selected.len() < batch_size {
249            for i in 0..candidates.len() {
250                if selected.len() >= batch_size {
251                    break;
252                }
253                if !selected.contains(&i) {
254                    selected.push(i);
255                }
256            }
257        }
258
259        selected
260    }
261
262    /// Compute uncertainty scores for all candidates
263    fn compute_uncertainty_scores<G>(
264        &self,
265        candidates: &[Candidate<G>],
266        aggregator: &FitnessAggregator,
267    ) -> Vec<f64>
268    where
269        G: EvolutionaryGenome,
270    {
271        candidates
272            .iter()
273            .map(|c| {
274                aggregator
275                    .get_fitness_estimate(&c.id)
276                    .map(|e| {
277                        if e.variance.is_infinite() {
278                            f64::MAX // Highest priority for unobserved
279                        } else {
280                            e.variance
281                        }
282                    })
283                    .unwrap_or(f64::MAX)
284            })
285            .collect()
286    }
287
288    /// Select by uncertainty (highest variance first)
289    fn select_by_uncertainty<G>(
290        &self,
291        candidates: &[Candidate<G>],
292        aggregator: &FitnessAggregator,
293        batch_size: usize,
294        uncertainty_weight: f64,
295    ) -> Vec<usize>
296    where
297        G: EvolutionaryGenome,
298    {
299        let mut scores: Vec<(usize, f64)> = candidates
300            .iter()
301            .enumerate()
302            .map(|(i, c)| {
303                let uncertainty = aggregator
304                    .get_fitness_estimate(&c.id)
305                    .map(|e| {
306                        if e.variance.is_infinite() {
307                            f64::MAX
308                        } else {
309                            e.variance
310                        }
311                    })
312                    .unwrap_or(f64::MAX);
313
314                // Bonus for fewer evaluations
315                let coverage_bonus = 1.0 / (c.evaluation_count as f64 + 1.0);
316
317                let score = uncertainty_weight * uncertainty + coverage_bonus;
318                (i, score)
319            })
320            .collect();
321
322        // Sort by score descending
323        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
324
325        scores
326            .into_iter()
327            .take(batch_size)
328            .map(|(i, _)| i)
329            .collect()
330    }
331
332    /// Select by expected information gain
333    fn select_by_information_gain<G, R>(
334        &self,
335        candidates: &[Candidate<G>],
336        aggregator: &FitnessAggregator,
337        batch_size: usize,
338        temperature: f64,
339        rng: &mut R,
340    ) -> Vec<usize>
341    where
342        G: EvolutionaryGenome,
343        R: Rng,
344    {
345        // For batch selection, use a simplified approach:
346        // Score candidates by how uncertain their ranking position is
347        let estimates: Vec<Option<FitnessEstimate>> = candidates
348            .iter()
349            .map(|c| aggregator.get_fitness_estimate(&c.id))
350            .collect();
351
352        // Score each candidate by entropy of pairwise comparisons with others
353        let mut scores: Vec<(usize, f64)> = candidates
354            .iter()
355            .enumerate()
356            .map(|(i, _)| {
357                let my_est = &estimates[i];
358                let score = estimates
359                    .iter()
360                    .enumerate()
361                    .filter(|(j, _)| *j != i)
362                    .map(|(_, other_est)| pairwise_entropy(my_est.as_ref(), other_est.as_ref()))
363                    .sum::<f64>();
364                (i, score)
365            })
366            .collect();
367
368        if temperature > 0.0 {
369            // Softmax sampling
370            let max_score = scores
371                .iter()
372                .map(|(_, s)| *s)
373                .fold(f64::NEG_INFINITY, f64::max);
374            let weights: Vec<f64> = scores
375                .iter()
376                .map(|(_, s)| ((s - max_score) / temperature).exp())
377                .collect();
378            let total: f64 = weights.iter().sum();
379
380            let mut selected = Vec::with_capacity(batch_size);
381            let mut remaining: Vec<(usize, f64)> = scores
382                .iter()
383                .zip(weights.iter())
384                .map(|((i, _), w)| (*i, *w / total))
385                .collect();
386
387            for _ in 0..batch_size {
388                if remaining.is_empty() {
389                    break;
390                }
391
392                let r: f64 = rng.gen();
393                let mut cumsum = 0.0;
394                let mut chosen_idx = 0;
395
396                for (idx, (_, w)) in remaining.iter().enumerate() {
397                    cumsum += w;
398                    if r < cumsum {
399                        chosen_idx = idx;
400                        break;
401                    }
402                }
403
404                let (i, _) = remaining.remove(chosen_idx);
405                selected.push(i);
406
407                // Renormalize remaining weights
408                let new_total: f64 = remaining.iter().map(|(_, w)| w).sum();
409                if new_total > 0.0 {
410                    for (_, w) in &mut remaining {
411                        *w /= new_total;
412                    }
413                }
414            }
415
416            selected
417        } else {
418            // Deterministic: take top scorers
419            scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
420            scores
421                .into_iter()
422                .take(batch_size)
423                .map(|(i, _)| i)
424                .collect()
425        }
426    }
427
428    /// Select pair by information gain (for pairwise comparison mode)
429    fn select_pair_by_information_gain<G, R>(
430        &self,
431        candidates: &[Candidate<G>],
432        aggregator: &FitnessAggregator,
433        temperature: f64,
434        rng: &mut R,
435    ) -> Option<(usize, usize)>
436    where
437        G: EvolutionaryGenome,
438        R: Rng,
439    {
440        let n = candidates.len();
441        if n < 2 {
442            return None;
443        }
444
445        let estimates: Vec<Option<FitnessEstimate>> = candidates
446            .iter()
447            .map(|c| aggregator.get_fitness_estimate(&c.id))
448            .collect();
449
450        // Compute information gain for each pair
451        let mut pair_scores: Vec<((usize, usize), f64)> = Vec::new();
452
453        for i in 0..n {
454            for j in (i + 1)..n {
455                let entropy = pairwise_entropy(estimates[i].as_ref(), estimates[j].as_ref());
456                pair_scores.push(((i, j), entropy));
457            }
458        }
459
460        if pair_scores.is_empty() {
461            return Some((0, 1));
462        }
463
464        if temperature > 0.0 {
465            // Softmax selection
466            let max_score = pair_scores
467                .iter()
468                .map(|(_, s)| *s)
469                .fold(f64::NEG_INFINITY, f64::max);
470            let weights: Vec<f64> = pair_scores
471                .iter()
472                .map(|(_, s)| ((s - max_score) / temperature).exp())
473                .collect();
474            let total: f64 = weights.iter().sum();
475
476            let r: f64 = rng.gen();
477            let mut cumsum = 0.0;
478
479            for ((pair, _), w) in pair_scores.iter().zip(weights.iter()) {
480                cumsum += w / total;
481                if r < cumsum {
482                    return Some(*pair);
483                }
484            }
485        }
486
487        // Return highest scoring pair
488        pair_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
489        Some(pair_scores[0].0)
490    }
491
492    /// Coverage-aware selection
493    fn select_coverage_aware<G>(
494        &self,
495        candidates: &[Candidate<G>],
496        aggregator: &FitnessAggregator,
497        batch_size: usize,
498        min_evaluations: usize,
499        exploration_bonus: f64,
500    ) -> Vec<usize>
501    where
502        G: EvolutionaryGenome,
503    {
504        let mut scores: Vec<(usize, f64)> = candidates
505            .iter()
506            .enumerate()
507            .map(|(i, c)| {
508                let score = if c.evaluation_count < min_evaluations {
509                    // Must evaluate - infinite priority
510                    f64::MAX
511                } else {
512                    // Base uncertainty
513                    let uncertainty = aggregator
514                        .get_fitness_estimate(&c.id)
515                        .map(|e| {
516                            if e.variance.is_infinite() {
517                                1e6
518                            } else {
519                                e.variance
520                            }
521                        })
522                        .unwrap_or(1e6);
523
524                    // Exploration bonus for fewer evaluations
525                    let bonus = exploration_bonus / (c.evaluation_count as f64 + 1.0);
526
527                    uncertainty + bonus
528                };
529                (i, score)
530            })
531            .collect();
532
533        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
534
535        scores
536            .into_iter()
537            .take(batch_size)
538            .map(|(i, _)| i)
539            .collect()
540    }
541
542    /// Find an informative pair among adequately covered candidates
543    fn find_informative_pair<G, R>(
544        &self,
545        candidates: &[Candidate<G>],
546        aggregator: &FitnessAggregator,
547        rng: &mut R,
548    ) -> usize
549    where
550        G: EvolutionaryGenome,
551        R: Rng,
552    {
553        // Find candidate whose ranking is most uncertain relative to others
554        let estimates: Vec<Option<FitnessEstimate>> = candidates
555            .iter()
556            .map(|c| aggregator.get_fitness_estimate(&c.id))
557            .collect();
558
559        let mut scores: Vec<(usize, f64)> = candidates
560            .iter()
561            .enumerate()
562            .map(|(i, _)| {
563                let score = estimates
564                    .iter()
565                    .enumerate()
566                    .filter(|(j, _)| *j != i)
567                    .map(|(_, other)| pairwise_entropy(estimates[i].as_ref(), other.as_ref()))
568                    .sum::<f64>();
569                (i, score)
570            })
571            .collect();
572
573        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
574
575        // Add some randomness to avoid always picking the same
576        let top_k = 3.min(scores.len());
577        let chosen = rng.gen_range(0..top_k);
578        scores[chosen].0
579    }
580}
581
582/// Compute entropy of pairwise comparison outcome
583///
584/// Entropy is maximized when P(A beats B) = 0.5 (most uncertain)
585fn pairwise_entropy(a: Option<&FitnessEstimate>, b: Option<&FitnessEstimate>) -> f64 {
586    match (a, b) {
587        (Some(est_a), Some(est_b)) => {
588            // Approximate P(A beats B) using normal approximation
589            let mean_diff = est_a.mean - est_b.mean;
590            let var_diff = est_a.variance + est_b.variance;
591
592            if var_diff.is_infinite() || var_diff <= 0.0 {
593                // Maximum entropy when we know nothing
594                return 1.0;
595            }
596
597            // P(A > B) ≈ Φ((μ_A - μ_B) / sqrt(σ²_A + σ²_B))
598            let z = mean_diff / var_diff.sqrt();
599            let p = normal_cdf(z);
600
601            // Binary entropy: -p*log(p) - (1-p)*log(1-p)
602            binary_entropy(p)
603        }
604        _ => 1.0, // Maximum entropy for unobserved
605    }
606}
607
608/// Binary entropy: H(p) = -p*log(p) - (1-p)*log(1-p)
609fn binary_entropy(p: f64) -> f64 {
610    let p = p.clamp(1e-10, 1.0 - 1e-10);
611    -(p * p.ln() + (1.0 - p) * (1.0 - p).ln())
612}
613
614/// Standard normal CDF approximation
615fn normal_cdf(x: f64) -> f64 {
616    // Abramowitz and Stegun approximation
617    let a1 = 0.254829592;
618    let a2 = -0.284496736;
619    let a3 = 1.421413741;
620    let a4 = -1.453152027;
621    let a5 = 1.061405429;
622    let p = 0.3275911;
623
624    let sign = if x < 0.0 { -1.0 } else { 1.0 };
625    let x = x.abs() / std::f64::consts::SQRT_2;
626
627    let t = 1.0 / (1.0 + p * x);
628    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
629
630    0.5 * (1.0 + sign * y)
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use crate::genome::real_vector::RealVector;
637    use crate::interactive::aggregation::{AggregationModel, FitnessAggregator};
638    use crate::interactive::evaluator::CandidateId;
639
640    fn make_candidates(n: usize) -> Vec<Candidate<RealVector>> {
641        (0..n)
642            .map(|i| {
643                let mut c = Candidate::new(CandidateId(i), RealVector::new(vec![i as f64]));
644                c.evaluation_count = 0;
645                c
646            })
647            .collect()
648    }
649
650    #[test]
651    fn test_sequential_selection() {
652        let candidates = make_candidates(10);
653        let aggregator = FitnessAggregator::new(AggregationModel::default());
654        let mut rng = rand::thread_rng();
655
656        let strategy = SelectionStrategy::Sequential;
657        let selected = strategy.select_batch(&candidates, &aggregator, 3, &mut rng);
658
659        assert_eq!(selected.len(), 3);
660        // Should select first 3 (all unevaluated)
661        assert!(selected.contains(&0));
662        assert!(selected.contains(&1));
663        assert!(selected.contains(&2));
664    }
665
666    #[test]
667    fn test_uncertainty_sampling() {
668        let mut candidates = make_candidates(5);
669        let mut aggregator = FitnessAggregator::new(AggregationModel::DirectRating {
670            default_rating: 5.0,
671        });
672        let mut rng = rand::thread_rng();
673
674        // Give candidate 0 multiple identical ratings (low variance)
675        aggregator.record_rating(CandidateId(0), 7.0);
676        aggregator.record_rating(CandidateId(0), 7.0);
677        aggregator.record_rating(CandidateId(0), 7.0);
678        candidates[0].evaluation_count = 3;
679
680        // Give candidate 1 multiple varied ratings (medium variance)
681        aggregator.record_rating(CandidateId(1), 4.0);
682        aggregator.record_rating(CandidateId(1), 8.0);
683        candidates[1].evaluation_count = 2;
684
685        // Candidates 2, 3, 4 are unevaluated (highest uncertainty)
686
687        let strategy = SelectionStrategy::UncertaintySampling {
688            uncertainty_weight: 1.0,
689        };
690        let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng);
691
692        // Should select high-uncertainty candidates, NOT the well-evaluated candidate 0
693        assert_eq!(selected.len(), 2);
694        for &idx in &selected {
695            assert!(
696                idx != 0,
697                "Should not select the well-evaluated candidate with low variance"
698            );
699        }
700    }
701
702    #[test]
703    fn test_coverage_aware() {
704        let mut candidates = make_candidates(5);
705        candidates[0].evaluation_count = 3;
706        candidates[1].evaluation_count = 2;
707        candidates[2].evaluation_count = 0; // Under min
708        candidates[3].evaluation_count = 0; // Under min
709        candidates[4].evaluation_count = 1;
710
711        let aggregator = FitnessAggregator::new(AggregationModel::default());
712        let mut rng = rand::thread_rng();
713
714        let strategy = SelectionStrategy::CoverageAware {
715            min_evaluations: 2,
716            exploration_bonus: 1.0,
717        };
718        let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng);
719
720        // Should prioritize candidates 2 and 3 (under min coverage)
721        assert!(selected.contains(&2) || selected.contains(&3));
722    }
723
724    #[test]
725    fn test_select_pair_sequential() {
726        let candidates = make_candidates(5);
727        let aggregator = FitnessAggregator::new(AggregationModel::default());
728        let mut rng = rand::thread_rng();
729
730        let strategy = SelectionStrategy::Sequential;
731        let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
732
733        assert!(pair.is_some());
734        let (a, b) = pair.unwrap();
735        assert_ne!(a, b);
736    }
737
738    #[test]
739    fn test_select_pair_info_gain() {
740        let candidates = make_candidates(5);
741        let aggregator = FitnessAggregator::new(AggregationModel::default());
742        let mut rng = rand::thread_rng();
743
744        let strategy = SelectionStrategy::ExpectedInformationGain { temperature: 1.0 };
745        let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
746
747        assert!(pair.is_some());
748        let (a, b) = pair.unwrap();
749        assert_ne!(a, b);
750    }
751
752    #[test]
753    fn test_binary_entropy() {
754        // Max entropy at p = 0.5
755        let max_entropy = binary_entropy(0.5);
756        assert!((max_entropy - std::f64::consts::LN_2).abs() < 1e-6);
757
758        // Zero entropy at p = 0 or 1
759        assert!(binary_entropy(0.001) < 0.1);
760        assert!(binary_entropy(0.999) < 0.1);
761    }
762
763    #[test]
764    fn test_normal_cdf() {
765        // CDF(0) = 0.5
766        assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6);
767
768        // CDF(-∞) → 0, CDF(+∞) → 1
769        assert!(normal_cdf(-10.0) < 0.001);
770        assert!(normal_cdf(10.0) > 0.999);
771
772        // Symmetry
773        assert!((normal_cdf(1.0) + normal_cdf(-1.0) - 1.0).abs() < 1e-6);
774    }
775
776    #[test]
777    fn test_empty_candidates() {
778        let candidates: Vec<Candidate<RealVector>> = vec![];
779        let aggregator = FitnessAggregator::new(AggregationModel::default());
780        let mut rng = rand::thread_rng();
781
782        let strategy = SelectionStrategy::default();
783        let selected = strategy.select_batch(&candidates, &aggregator, 5, &mut rng);
784        assert!(selected.is_empty());
785
786        let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
787        assert!(pair.is_none());
788    }
789
790    #[test]
791    fn test_single_candidate() {
792        let candidates = make_candidates(1);
793        let aggregator = FitnessAggregator::new(AggregationModel::default());
794        let mut rng = rand::thread_rng();
795
796        let strategy = SelectionStrategy::default();
797        let selected = strategy.select_batch(&candidates, &aggregator, 5, &mut rng);
798        assert_eq!(selected.len(), 1);
799
800        let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
801        assert!(pair.is_none()); // Can't make a pair from 1 candidate
802    }
803}