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 partner among adequately covered,
223                        // excluding `a` so we can never return a self-pair (EV-68).
224                        self.find_informative_pair(candidates, aggregator, Some(a), rng)
225                    }
226                } else {
227                    return None;
228                };
229                Some((a, b))
230            }
231        }
232    }
233
234    /// Sequential selection - first N unevaluated, then first N overall
235    fn select_sequential<G>(&self, candidates: &[Candidate<G>], batch_size: usize) -> Vec<usize>
236    where
237        G: EvolutionaryGenome,
238    {
239        // First, select unevaluated candidates
240        let mut selected: Vec<usize> = candidates
241            .iter()
242            .enumerate()
243            .filter(|(_, c)| c.evaluation_count == 0)
244            .take(batch_size)
245            .map(|(i, _)| i)
246            .collect();
247
248        // If need more, add from beginning
249        if selected.len() < batch_size {
250            for i in 0..candidates.len() {
251                if selected.len() >= batch_size {
252                    break;
253                }
254                if !selected.contains(&i) {
255                    selected.push(i);
256                }
257            }
258        }
259
260        selected
261    }
262
263    /// Compute uncertainty scores for all candidates
264    fn compute_uncertainty_scores<G>(
265        &self,
266        candidates: &[Candidate<G>],
267        aggregator: &FitnessAggregator,
268    ) -> Vec<f64>
269    where
270        G: EvolutionaryGenome,
271    {
272        candidates
273            .iter()
274            .map(|c| {
275                aggregator
276                    .get_fitness_estimate(&c.id)
277                    .map(|e| {
278                        if e.variance.is_infinite() {
279                            f64::MAX // Highest priority for unobserved
280                        } else {
281                            e.variance
282                        }
283                    })
284                    .unwrap_or(f64::MAX)
285            })
286            .collect()
287    }
288
289    /// Select by uncertainty (highest variance first)
290    fn select_by_uncertainty<G>(
291        &self,
292        candidates: &[Candidate<G>],
293        aggregator: &FitnessAggregator,
294        batch_size: usize,
295        uncertainty_weight: f64,
296    ) -> Vec<usize>
297    where
298        G: EvolutionaryGenome,
299    {
300        // Normalize by the batch's mean variance so the coverage bonus is on a
301        // comparable, model-agnostic scale (EV-69).
302        let variances: Vec<f64> = candidates
303            .iter()
304            .map(|c| {
305                aggregator
306                    .get_fitness_estimate(&c.id)
307                    .map(|e| e.variance)
308                    .unwrap_or(f64::INFINITY)
309            })
310            .collect();
311        let var_scale = mean_variance_scale(&variances);
312
313        let mut scores: Vec<(usize, f64)> = candidates
314            .iter()
315            .enumerate()
316            .map(|(i, c)| {
317                let score = normalized_uncertainty_score(
318                    variances[i],
319                    c.evaluation_count,
320                    var_scale,
321                    uncertainty_weight,
322                    1.0,
323                );
324                (i, score)
325            })
326            .collect();
327
328        // Sort by score descending
329        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
330
331        scores
332            .into_iter()
333            .take(batch_size)
334            .map(|(i, _)| i)
335            .collect()
336    }
337
338    /// Select by expected information gain
339    fn select_by_information_gain<G, R>(
340        &self,
341        candidates: &[Candidate<G>],
342        aggregator: &FitnessAggregator,
343        batch_size: usize,
344        temperature: f64,
345        rng: &mut R,
346    ) -> Vec<usize>
347    where
348        G: EvolutionaryGenome,
349        R: Rng,
350    {
351        // For batch selection, use a simplified approach:
352        // Score candidates by how uncertain their ranking position is
353        let estimates: Vec<Option<FitnessEstimate>> = candidates
354            .iter()
355            .map(|c| aggregator.get_fitness_estimate(&c.id))
356            .collect();
357
358        // Score each candidate by entropy of pairwise comparisons with others
359        let mut scores: Vec<(usize, f64)> = candidates
360            .iter()
361            .enumerate()
362            .map(|(i, _)| {
363                let my_est = &estimates[i];
364                let score = estimates
365                    .iter()
366                    .enumerate()
367                    .filter(|(j, _)| *j != i)
368                    .map(|(_, other_est)| pairwise_entropy(my_est.as_ref(), other_est.as_ref()))
369                    .sum::<f64>();
370                (i, score)
371            })
372            .collect();
373
374        if temperature > 0.0 {
375            // Softmax sampling
376            let max_score = scores
377                .iter()
378                .map(|(_, s)| *s)
379                .fold(f64::NEG_INFINITY, f64::max);
380            let weights: Vec<f64> = scores
381                .iter()
382                .map(|(_, s)| ((s - max_score) / temperature).exp())
383                .collect();
384            let total: f64 = weights.iter().sum();
385
386            let mut selected = Vec::with_capacity(batch_size);
387            let mut remaining: Vec<(usize, f64)> = scores
388                .iter()
389                .zip(weights.iter())
390                .map(|((i, _), w)| (*i, *w / total))
391                .collect();
392
393            for _ in 0..batch_size {
394                if remaining.is_empty() {
395                    break;
396                }
397
398                let r: f64 = rng.gen();
399                let weights_now: Vec<f64> = remaining.iter().map(|(_, w)| *w).collect();
400                let chosen_idx = inverse_cdf_pick(&weights_now, r);
401
402                let (i, _) = remaining.remove(chosen_idx);
403                selected.push(i);
404
405                // Renormalize remaining weights
406                let new_total: f64 = remaining.iter().map(|(_, w)| w).sum();
407                if new_total > 0.0 {
408                    for (_, w) in &mut remaining {
409                        *w /= new_total;
410                    }
411                }
412            }
413
414            selected
415        } else {
416            // Deterministic: take top scorers
417            scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
418            scores
419                .into_iter()
420                .take(batch_size)
421                .map(|(i, _)| i)
422                .collect()
423        }
424    }
425
426    /// Select pair by information gain (for pairwise comparison mode)
427    fn select_pair_by_information_gain<G, R>(
428        &self,
429        candidates: &[Candidate<G>],
430        aggregator: &FitnessAggregator,
431        temperature: f64,
432        rng: &mut R,
433    ) -> Option<(usize, usize)>
434    where
435        G: EvolutionaryGenome,
436        R: Rng,
437    {
438        let n = candidates.len();
439        if n < 2 {
440            return None;
441        }
442
443        let estimates: Vec<Option<FitnessEstimate>> = candidates
444            .iter()
445            .map(|c| aggregator.get_fitness_estimate(&c.id))
446            .collect();
447
448        // Compute information gain for each pair
449        let mut pair_scores: Vec<((usize, usize), f64)> = Vec::new();
450
451        for i in 0..n {
452            for j in (i + 1)..n {
453                let entropy = pairwise_entropy(estimates[i].as_ref(), estimates[j].as_ref());
454                pair_scores.push(((i, j), entropy));
455            }
456        }
457
458        if pair_scores.is_empty() {
459            return Some((0, 1));
460        }
461
462        if temperature > 0.0 {
463            // Softmax selection
464            let max_score = pair_scores
465                .iter()
466                .map(|(_, s)| *s)
467                .fold(f64::NEG_INFINITY, f64::max);
468            let weights: Vec<f64> = pair_scores
469                .iter()
470                .map(|(_, s)| ((s - max_score) / temperature).exp())
471                .collect();
472            let total: f64 = weights.iter().sum();
473
474            // Same inverse-CDF sampler as the batch path, with the last-index
475            // residual fallback (EV-100) so the two paths behave consistently.
476            let normalized: Vec<f64> = weights.iter().map(|w| w / total).collect();
477            let r: f64 = rng.gen();
478            let idx = inverse_cdf_pick(&normalized, r);
479            return Some(pair_scores[idx].0);
480        }
481
482        // Deterministic (temperature <= 0): return highest scoring pair.
483        pair_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
484        Some(pair_scores[0].0)
485    }
486
487    /// Coverage-aware selection
488    fn select_coverage_aware<G>(
489        &self,
490        candidates: &[Candidate<G>],
491        aggregator: &FitnessAggregator,
492        batch_size: usize,
493        min_evaluations: usize,
494        exploration_bonus: f64,
495    ) -> Vec<usize>
496    where
497        G: EvolutionaryGenome,
498    {
499        // Normalize by the batch's mean variance so the exploration bonus is on a
500        // comparable, model-agnostic scale (EV-69).
501        let variances: Vec<f64> = candidates
502            .iter()
503            .map(|c| {
504                aggregator
505                    .get_fitness_estimate(&c.id)
506                    .map(|e| e.variance)
507                    .unwrap_or(f64::INFINITY)
508            })
509            .collect();
510        let var_scale = mean_variance_scale(&variances);
511
512        let mut scores: Vec<(usize, f64)> = candidates
513            .iter()
514            .enumerate()
515            .map(|(i, c)| {
516                let score = if c.evaluation_count < min_evaluations {
517                    // Must evaluate - infinite priority (ranks above any covered
518                    // candidate, whose normalized score is <= UNOBSERVED + bonus).
519                    f64::MAX
520                } else {
521                    normalized_uncertainty_score(
522                        variances[i],
523                        c.evaluation_count,
524                        var_scale,
525                        1.0,
526                        exploration_bonus,
527                    )
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 partner among adequately covered candidates.
543    ///
544    /// When `exclude` is `Some(a)`, index `a` is never returned, so callers that
545    /// have already committed to `a` cannot receive a self-pair `(a, a)` (EV-68).
546    fn find_informative_pair<G, R>(
547        &self,
548        candidates: &[Candidate<G>],
549        aggregator: &FitnessAggregator,
550        exclude: Option<usize>,
551        rng: &mut R,
552    ) -> usize
553    where
554        G: EvolutionaryGenome,
555        R: Rng,
556    {
557        // Find candidate whose ranking is most uncertain relative to others
558        let estimates: Vec<Option<FitnessEstimate>> = candidates
559            .iter()
560            .map(|c| aggregator.get_fitness_estimate(&c.id))
561            .collect();
562
563        let mut scores: Vec<(usize, f64)> = candidates
564            .iter()
565            .enumerate()
566            .filter(|(i, _)| Some(*i) != exclude)
567            .map(|(i, _)| {
568                let score = estimates
569                    .iter()
570                    .enumerate()
571                    .filter(|(j, _)| *j != i)
572                    .map(|(_, other)| pairwise_entropy(estimates[i].as_ref(), other.as_ref()))
573                    .sum::<f64>();
574                (i, score)
575            })
576            .collect();
577
578        // With `exclude` set and >= 2 candidates the caller guarantees at least
579        // one remaining index; fall back to the excluded index only if somehow
580        // nothing else exists (never on the reachable path).
581        if scores.is_empty() {
582            return exclude.unwrap_or(0);
583        }
584
585        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
586
587        // Add some randomness to avoid always picking the same
588        let top_k = 3.min(scores.len());
589        let chosen = rng.gen_range(0..top_k);
590        scores[chosen].0
591    }
592}
593
594/// Inverse-CDF sample over a (normalized) weight vector.
595///
596/// Returns the first index whose cumulative weight exceeds `r`. If floating-point
597/// round-off leaves the cumulative sum just below `r` so that no prefix qualifies,
598/// it falls back to the **last** index — the standard inverse-CDF residual choice
599/// (EV-100) — instead of the old behavior of defaulting to index 0, which biased
600/// selection toward the first remaining candidate.
601fn inverse_cdf_pick(weights: &[f64], r: f64) -> usize {
602    let mut cumsum = 0.0;
603    for (idx, w) in weights.iter().enumerate() {
604        cumsum += w;
605        if r < cumsum {
606            return idx;
607        }
608    }
609    weights.len().saturating_sub(1)
610}
611
612/// Score magnitude assigned to an unobserved (infinite-variance) candidate after
613/// normalization.
614///
615/// Large enough to dominate any normalized *finite* uncertainty (which is ~O(1)
616/// after dividing by the mean variance), so unobserved candidates keep top
617/// priority, but finite so the coverage bonus can still order equally-unobserved
618/// candidates and no `f64::MAX + bonus` overflow occurs.
619const UNOBSERVED_NORMALIZED_UNCERTAINTY: f64 = 1e6;
620
621/// Mean of the finite, positive variances in `variances` (EV-69).
622///
623/// Used to put raw model variances on a comparable, model-agnostic scale before
624/// an exploration/coverage bonus is added. Returns `1.0` when nothing finite is
625/// available to average, leaving the bonus as the sole differentiator.
626fn mean_variance_scale(variances: &[f64]) -> f64 {
627    let (sum, count) = variances
628        .iter()
629        .filter(|v| v.is_finite() && **v > 0.0)
630        .fold((0.0, 0usize), |(s, c), v| (s + v, c + 1));
631    if count == 0 {
632        1.0
633    } else {
634        sum / count as f64
635    }
636}
637
638/// Combine a candidate's raw variance and evaluation count into an acquisition
639/// score with a model-agnostic scale (EV-69).
640///
641/// The variance is divided by the batch's mean variance (`var_scale`) so the
642/// additive exploration/coverage bonus has a comparable influence regardless of
643/// the aggregation model's native variance magnitude (DirectRating ~O(1), Elo
644/// ~O(10²), ImplicitRanking ~O(10⁻²)). Previously the bonus was added to the raw
645/// variance and was therefore either inert or dominant depending on the model.
646fn normalized_uncertainty_score(
647    variance: f64,
648    eval_count: usize,
649    var_scale: f64,
650    uncertainty_weight: f64,
651    bonus_coeff: f64,
652) -> f64 {
653    let normalized = if variance.is_finite() {
654        variance / var_scale
655    } else {
656        UNOBSERVED_NORMALIZED_UNCERTAINTY
657    };
658    let bonus = bonus_coeff / (eval_count as f64 + 1.0);
659    uncertainty_weight * normalized + bonus
660}
661
662/// Maximum binary entropy, in **nats** (`ln 2`).
663///
664/// This is the sentinel returned for pairs whose comparison outcome is entirely
665/// unknown (an unobserved candidate / infinite variance). Keeping it equal to
666/// the true maximum of [`binary_entropy`] — rather than the mismatched `1.0`
667/// (which is 1 *bit*, not 1 nat) — means unobserved and observed pair scores are
668/// on the same scale (EV-99). Unobserved pairs still get priority through the
669/// coverage/exploration terms of the selection strategies, not through an
670/// inflated entropy.
671const MAX_BINARY_ENTROPY_NATS: f64 = std::f64::consts::LN_2;
672
673/// Compute the entropy (in nats) of a pairwise comparison outcome.
674///
675/// Entropy is maximized (`ln 2`) when `P(A beats B) = 0.5` (most uncertain) and
676/// approaches `0` as the outcome becomes determined.
677fn pairwise_entropy(a: Option<&FitnessEstimate>, b: Option<&FitnessEstimate>) -> f64 {
678    match (a, b) {
679        (Some(est_a), Some(est_b)) => {
680            let mean_diff = est_a.mean - est_b.mean;
681            let var_diff = est_a.variance + est_b.variance;
682
683            if var_diff.is_infinite() {
684                // At least one estimate is completely unobserved -> outcome
685                // genuinely unknown, so report the maximum entropy sentinel.
686                return MAX_BINARY_ENTROPY_NATS;
687            }
688
689            if var_diff <= 0.0 {
690                // Both candidates are perfectly measured (EV-70). The outcome is
691                // then fully DETERMINED by the means: entropy ~0 unless the means
692                // also tie (a genuine coin flip). Returning the max here — as the
693                // old code did — wrongly made the strategy spend comparisons on
694                // pairs it is already certain about.
695                return if mean_diff.abs() < f64::EPSILON {
696                    MAX_BINARY_ENTROPY_NATS
697                } else {
698                    0.0
699                };
700            }
701
702            // P(A > B) ≈ Φ((μ_A - μ_B) / sqrt(σ²_A + σ²_B))
703            let z = mean_diff / var_diff.sqrt();
704            let p = normal_cdf(z);
705
706            binary_entropy(p)
707        }
708        _ => MAX_BINARY_ENTROPY_NATS, // Unobserved pair
709    }
710}
711
712/// Binary entropy in **nats**: `H(p) = -p·ln(p) - (1-p)·ln(1-p)`.
713///
714/// Maximized at `p = 0.5` with value `ln 2 ≈ 0.6931` nats (not `1.0`, which
715/// would be 1 bit / log-base-2). See [`MAX_BINARY_ENTROPY_NATS`].
716fn binary_entropy(p: f64) -> f64 {
717    let p = p.clamp(1e-10, 1.0 - 1e-10);
718    -(p * p.ln() + (1.0 - p) * (1.0 - p).ln())
719}
720
721/// Standard normal CDF approximation
722fn normal_cdf(x: f64) -> f64 {
723    // Abramowitz and Stegun approximation
724    let a1 = 0.254829592;
725    let a2 = -0.284496736;
726    let a3 = 1.421413741;
727    let a4 = -1.453152027;
728    let a5 = 1.061405429;
729    let p = 0.3275911;
730
731    let sign = if x < 0.0 { -1.0 } else { 1.0 };
732    let x = x.abs() / std::f64::consts::SQRT_2;
733
734    let t = 1.0 / (1.0 + p * x);
735    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
736
737    0.5 * (1.0 + sign * y)
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use crate::genome::real_vector::RealVector;
744    use crate::interactive::aggregation::{AggregationModel, FitnessAggregator};
745    use crate::interactive::evaluator::CandidateId;
746
747    fn make_candidates(n: usize) -> Vec<Candidate<RealVector>> {
748        (0..n)
749            .map(|i| {
750                let mut c = Candidate::new(CandidateId(i), RealVector::new(vec![i as f64]));
751                c.evaluation_count = 0;
752                c
753            })
754            .collect()
755    }
756
757    #[test]
758    fn test_sequential_selection() {
759        let candidates = make_candidates(10);
760        let aggregator = FitnessAggregator::new(AggregationModel::default());
761        let mut rng = rand::thread_rng();
762
763        let strategy = SelectionStrategy::Sequential;
764        let selected = strategy.select_batch(&candidates, &aggregator, 3, &mut rng);
765
766        assert_eq!(selected.len(), 3);
767        // Should select first 3 (all unevaluated)
768        assert!(selected.contains(&0));
769        assert!(selected.contains(&1));
770        assert!(selected.contains(&2));
771    }
772
773    #[test]
774    fn test_uncertainty_sampling() {
775        let mut candidates = make_candidates(5);
776        let mut aggregator = FitnessAggregator::new(AggregationModel::DirectRating {
777            default_rating: 5.0,
778        });
779        let mut rng = rand::thread_rng();
780
781        // Give candidate 0 multiple identical ratings (low variance)
782        aggregator.record_rating(CandidateId(0), 7.0);
783        aggregator.record_rating(CandidateId(0), 7.0);
784        aggregator.record_rating(CandidateId(0), 7.0);
785        candidates[0].evaluation_count = 3;
786
787        // Give candidate 1 multiple varied ratings (medium variance)
788        aggregator.record_rating(CandidateId(1), 4.0);
789        aggregator.record_rating(CandidateId(1), 8.0);
790        candidates[1].evaluation_count = 2;
791
792        // Candidates 2, 3, 4 are unevaluated (highest uncertainty)
793
794        let strategy = SelectionStrategy::UncertaintySampling {
795            uncertainty_weight: 1.0,
796        };
797        let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng);
798
799        // Should select high-uncertainty candidates, NOT the well-evaluated candidate 0
800        assert_eq!(selected.len(), 2);
801        for &idx in &selected {
802            assert!(
803                idx != 0,
804                "Should not select the well-evaluated candidate with low variance"
805            );
806        }
807    }
808
809    #[test]
810    fn test_coverage_aware() {
811        let mut candidates = make_candidates(5);
812        candidates[0].evaluation_count = 3;
813        candidates[1].evaluation_count = 2;
814        candidates[2].evaluation_count = 0; // Under min
815        candidates[3].evaluation_count = 0; // Under min
816        candidates[4].evaluation_count = 1;
817
818        let aggregator = FitnessAggregator::new(AggregationModel::default());
819        let mut rng = rand::thread_rng();
820
821        let strategy = SelectionStrategy::CoverageAware {
822            min_evaluations: 2,
823            exploration_bonus: 1.0,
824        };
825        let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng);
826
827        // Should prioritize candidates 2 and 3 (under min coverage)
828        assert!(selected.contains(&2) || selected.contains(&3));
829    }
830
831    #[test]
832    fn test_select_pair_sequential() {
833        let candidates = make_candidates(5);
834        let aggregator = FitnessAggregator::new(AggregationModel::default());
835        let mut rng = rand::thread_rng();
836
837        let strategy = SelectionStrategy::Sequential;
838        let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
839
840        assert!(pair.is_some());
841        let (a, b) = pair.unwrap();
842        assert_ne!(a, b);
843    }
844
845    #[test]
846    fn test_select_pair_info_gain() {
847        let candidates = make_candidates(5);
848        let aggregator = FitnessAggregator::new(AggregationModel::default());
849        let mut rng = rand::thread_rng();
850
851        let strategy = SelectionStrategy::ExpectedInformationGain { temperature: 1.0 };
852        let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
853
854        assert!(pair.is_some());
855        let (a, b) = pair.unwrap();
856        assert_ne!(a, b);
857    }
858
859    #[test]
860    fn test_binary_entropy() {
861        // Max entropy at p = 0.5
862        let max_entropy = binary_entropy(0.5);
863        assert!((max_entropy - std::f64::consts::LN_2).abs() < 1e-6);
864
865        // Zero entropy at p = 0 or 1
866        assert!(binary_entropy(0.001) < 0.1);
867        assert!(binary_entropy(0.999) < 0.1);
868    }
869
870    #[test]
871    fn test_normal_cdf() {
872        // CDF(0) = 0.5
873        assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6);
874
875        // CDF(-∞) → 0, CDF(+∞) → 1
876        assert!(normal_cdf(-10.0) < 0.001);
877        assert!(normal_cdf(10.0) > 0.999);
878
879        // Symmetry
880        assert!((normal_cdf(1.0) + normal_cdf(-1.0) - 1.0).abs() < 1e-6);
881    }
882
883    #[test]
884    fn test_empty_candidates() {
885        let candidates: Vec<Candidate<RealVector>> = vec![];
886        let aggregator = FitnessAggregator::new(AggregationModel::default());
887        let mut rng = rand::thread_rng();
888
889        let strategy = SelectionStrategy::default();
890        let selected = strategy.select_batch(&candidates, &aggregator, 5, &mut rng);
891        assert!(selected.is_empty());
892
893        let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
894        assert!(pair.is_none());
895    }
896
897    #[test]
898    fn test_single_candidate() {
899        let candidates = make_candidates(1);
900        let aggregator = FitnessAggregator::new(AggregationModel::default());
901        let mut rng = rand::thread_rng();
902
903        let strategy = SelectionStrategy::default();
904        let selected = strategy.select_batch(&candidates, &aggregator, 5, &mut rng);
905        assert_eq!(selected.len(), 1);
906
907        let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
908        assert!(pair.is_none()); // Can't make a pair from 1 candidate
909    }
910
911    #[test]
912    fn test_inverse_cdf_pick_fallback_is_last() {
913        // regression: EV-100 — when the cumulative weight never reaches `r`
914        // (floating-point residual), the fallback must be the LAST index, not 0.
915        let weights = vec![0.3, 0.3, 0.3]; // sums to 0.9 < 1.0
916        assert_eq!(inverse_cdf_pick(&weights, 0.95), 2); // pre-fix returned 0
917                                                         // Normal inverse-CDF behavior still holds.
918        assert_eq!(inverse_cdf_pick(&weights, 0.1), 0);
919        assert_eq!(inverse_cdf_pick(&weights, 0.4), 1);
920        assert_eq!(inverse_cdf_pick(&weights, 0.7), 2);
921    }
922
923    #[test]
924    fn test_entropy_sentinel_is_nats() {
925        // regression: EV-99 — the unobserved/degenerate sentinel must be the max
926        // binary entropy in NATS (ln 2), matching binary_entropy's maximum, not 1.0.
927        assert!((MAX_BINARY_ENTROPY_NATS - std::f64::consts::LN_2).abs() < 1e-12);
928        let unobserved = pairwise_entropy(None, None);
929        assert!((unobserved - binary_entropy(0.5)).abs() < 1e-9);
930        assert!((unobserved - std::f64::consts::LN_2).abs() < 1e-9);
931        assert!(unobserved < 1.0); // ln 2 = 0.693..., not the old 1.0 (bit)
932    }
933
934    #[test]
935    fn test_pairwise_entropy_known_below_unknown() {
936        // regression: EV-70 — a pair of perfectly-known candidates with different
937        // means has a DETERMINED outcome (entropy ~0) and must score BELOW an
938        // unobserved pair (max entropy). The old code returned max entropy for the
939        // zero-variance case, inverting active-learning priority.
940        let known_a = FitnessEstimate::new(9.0, 0.0, 100);
941        let known_b = FitnessEstimate::new(1.0, 0.0, 100);
942        let known = pairwise_entropy(Some(&known_a), Some(&known_b));
943
944        let unknown_a = FitnessEstimate::uninformative(5.0); // infinite variance
945        let unknown_b = FitnessEstimate::uninformative(5.0);
946        let unknown = pairwise_entropy(Some(&unknown_a), Some(&unknown_b));
947
948        assert!(
949            known < unknown,
950            "known {known} should score below unknown {unknown}"
951        );
952        assert!(known < 1e-6, "determined outcome should be ~0, got {known}");
953        // Degenerate: zero variance AND equal means -> genuine coin flip -> max.
954        let tie_a = FitnessEstimate::new(5.0, 0.0, 100);
955        let tie_b = FitnessEstimate::new(5.0, 0.0, 100);
956        assert!(
957            (pairwise_entropy(Some(&tie_a), Some(&tie_b)) - std::f64::consts::LN_2).abs() < 1e-9
958        );
959    }
960
961    #[test]
962    fn test_coverage_aware_never_returns_self_pair() {
963        // regression: EV-68 — CoverageAware select_pair must never return (a, a),
964        // even when the least-evaluated candidate is also the most informative.
965        let mut candidates = make_candidates(2);
966        candidates[0].evaluation_count = 2;
967        candidates[1].evaluation_count = 2; // both >= min_evaluations
968        let aggregator = FitnessAggregator::new(AggregationModel::default());
969        let mut rng = rand::thread_rng();
970        let strategy = SelectionStrategy::CoverageAware {
971            min_evaluations: 1,
972            exploration_bonus: 1.0,
973        };
974        for _ in 0..200 {
975            let (a, b) = strategy
976                .select_pair(&candidates, &aggregator, &mut rng)
977                .unwrap();
978            assert_ne!(a, b, "select_pair returned a self-pair");
979        }
980    }
981
982    #[test]
983    fn test_normalized_uncertainty_scale_invariant() {
984        // regression: EV-69 — the exploration/coverage bonus must have a
985        // model-agnostic influence: scaling ALL variances by a constant must not
986        // change the ranking. Un-normalized scoring flips the ranking instead.
987        let counts = [1usize, 100usize];
988        let variances = [1.0, 1.05];
989        let var_scale = mean_variance_scale(&variances);
990        let s_a = normalized_uncertainty_score(variances[0], counts[0], var_scale, 1.0, 1.0);
991        let s_b = normalized_uncertainty_score(variances[1], counts[1], var_scale, 1.0, 1.0);
992
993        let variances_big: Vec<f64> = variances.iter().map(|v| v * 100.0).collect();
994        let scale_big = mean_variance_scale(&variances_big);
995        let s_a_big =
996            normalized_uncertainty_score(variances_big[0], counts[0], scale_big, 1.0, 1.0);
997        let s_b_big =
998            normalized_uncertainty_score(variances_big[1], counts[1], scale_big, 1.0, 1.0);
999
1000        // Ranking preserved across variance scales.
1001        assert_eq!(s_a > s_b, s_a_big > s_b_big);
1002        assert!(
1003            s_a > s_b,
1004            "the low-count candidate should win via the bonus"
1005        );
1006
1007        // Contrast: raw variance + fixed bonus flips the ranking under scaling.
1008        let raw_a = variances[0] + 1.0 / (counts[0] as f64 + 1.0);
1009        let raw_b = variances[1] + 1.0 / (counts[1] as f64 + 1.0);
1010        let raw_a_big = variances_big[0] + 1.0 / (counts[0] as f64 + 1.0);
1011        let raw_b_big = variances_big[1] + 1.0 / (counts[1] as f64 + 1.0);
1012        assert!(raw_a > raw_b); // A wins at small scale...
1013        assert!(raw_a_big < raw_b_big); // ...but B wins at large scale (the bug).
1014    }
1015}