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