use rand::prelude::*;
use serde::{Deserialize, Serialize};
use super::aggregation::FitnessAggregator;
use super::evaluator::Candidate;
use super::uncertainty::FitnessEstimate;
use crate::genome::traits::EvolutionaryGenome;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum SelectionStrategy {
Sequential,
UncertaintySampling {
uncertainty_weight: f64,
},
ExpectedInformationGain {
temperature: f64,
},
CoverageAware {
min_evaluations: usize,
exploration_bonus: f64,
},
}
impl Default for SelectionStrategy {
fn default() -> Self {
Self::Sequential
}
}
impl SelectionStrategy {
pub fn uncertainty_sampling(uncertainty_weight: f64) -> Self {
Self::UncertaintySampling { uncertainty_weight }
}
pub fn information_gain(temperature: f64) -> Self {
Self::ExpectedInformationGain { temperature }
}
pub fn coverage_aware(min_evaluations: usize, exploration_bonus: f64) -> Self {
Self::CoverageAware {
min_evaluations,
exploration_bonus,
}
}
pub fn select_batch<G, R>(
&self,
candidates: &[Candidate<G>],
aggregator: &FitnessAggregator,
batch_size: usize,
rng: &mut R,
) -> Vec<usize>
where
G: EvolutionaryGenome,
R: Rng,
{
if candidates.is_empty() || batch_size == 0 {
return vec![];
}
let batch_size = batch_size.min(candidates.len());
match self {
Self::Sequential => self.select_sequential(candidates, batch_size),
Self::UncertaintySampling { uncertainty_weight } => {
self.select_by_uncertainty(candidates, aggregator, batch_size, *uncertainty_weight)
}
Self::ExpectedInformationGain { temperature } => self.select_by_information_gain(
candidates,
aggregator,
batch_size,
*temperature,
rng,
),
Self::CoverageAware {
min_evaluations,
exploration_bonus,
} => self.select_coverage_aware(
candidates,
aggregator,
batch_size,
*min_evaluations,
*exploration_bonus,
),
}
}
pub fn select_pair<G, R>(
&self,
candidates: &[Candidate<G>],
aggregator: &FitnessAggregator,
rng: &mut R,
) -> Option<(usize, usize)>
where
G: EvolutionaryGenome,
R: Rng,
{
if candidates.len() < 2 {
return None;
}
match self {
Self::Sequential => {
Some((0, 1))
}
Self::UncertaintySampling { .. } => {
let scores = self.compute_uncertainty_scores(candidates, aggregator);
let mut indices: Vec<usize> = (0..candidates.len()).collect();
indices.sort_by(|&a, &b| {
scores[b]
.partial_cmp(&scores[a])
.unwrap_or(std::cmp::Ordering::Equal)
});
Some((indices[0], indices[1]))
}
Self::ExpectedInformationGain { temperature } => {
self.select_pair_by_information_gain(candidates, aggregator, *temperature, rng)
}
Self::CoverageAware {
min_evaluations, ..
} => {
let mut indices: Vec<(usize, usize)> = candidates
.iter()
.enumerate()
.map(|(i, c)| (i, c.evaluation_count))
.collect();
indices.sort_by_key(|&(_, count)| count);
let a = indices[0].0;
let b = if indices.len() > 1 {
let a_eval = candidates[a].evaluation_count;
if a_eval < *min_evaluations {
indices[1].0
} else {
self.find_informative_pair(candidates, aggregator, Some(a), rng)
}
} else {
return None;
};
Some((a, b))
}
}
}
fn select_sequential<G>(&self, candidates: &[Candidate<G>], batch_size: usize) -> Vec<usize>
where
G: EvolutionaryGenome,
{
let mut selected: Vec<usize> = candidates
.iter()
.enumerate()
.filter(|(_, c)| c.evaluation_count == 0)
.take(batch_size)
.map(|(i, _)| i)
.collect();
if selected.len() < batch_size {
for i in 0..candidates.len() {
if selected.len() >= batch_size {
break;
}
if !selected.contains(&i) {
selected.push(i);
}
}
}
selected
}
fn compute_uncertainty_scores<G>(
&self,
candidates: &[Candidate<G>],
aggregator: &FitnessAggregator,
) -> Vec<f64>
where
G: EvolutionaryGenome,
{
candidates
.iter()
.map(|c| {
aggregator
.get_fitness_estimate(&c.id)
.map(|e| {
if e.variance.is_infinite() {
f64::MAX } else {
e.variance
}
})
.unwrap_or(f64::MAX)
})
.collect()
}
fn select_by_uncertainty<G>(
&self,
candidates: &[Candidate<G>],
aggregator: &FitnessAggregator,
batch_size: usize,
uncertainty_weight: f64,
) -> Vec<usize>
where
G: EvolutionaryGenome,
{
let variances: Vec<f64> = candidates
.iter()
.map(|c| {
aggregator
.get_fitness_estimate(&c.id)
.map(|e| e.variance)
.unwrap_or(f64::INFINITY)
})
.collect();
let var_scale = mean_variance_scale(&variances);
let mut scores: Vec<(usize, f64)> = candidates
.iter()
.enumerate()
.map(|(i, c)| {
let score = normalized_uncertainty_score(
variances[i],
c.evaluation_count,
var_scale,
uncertainty_weight,
1.0,
);
(i, score)
})
.collect();
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scores
.into_iter()
.take(batch_size)
.map(|(i, _)| i)
.collect()
}
fn select_by_information_gain<G, R>(
&self,
candidates: &[Candidate<G>],
aggregator: &FitnessAggregator,
batch_size: usize,
temperature: f64,
rng: &mut R,
) -> Vec<usize>
where
G: EvolutionaryGenome,
R: Rng,
{
let estimates: Vec<Option<FitnessEstimate>> = candidates
.iter()
.map(|c| aggregator.get_fitness_estimate(&c.id))
.collect();
let mut scores: Vec<(usize, f64)> = candidates
.iter()
.enumerate()
.map(|(i, _)| {
let my_est = &estimates[i];
let score = estimates
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, other_est)| pairwise_entropy(my_est.as_ref(), other_est.as_ref()))
.sum::<f64>();
(i, score)
})
.collect();
if temperature > 0.0 {
let max_score = scores
.iter()
.map(|(_, s)| *s)
.fold(f64::NEG_INFINITY, f64::max);
let weights: Vec<f64> = scores
.iter()
.map(|(_, s)| ((s - max_score) / temperature).exp())
.collect();
let total: f64 = weights.iter().sum();
let mut selected = Vec::with_capacity(batch_size);
let mut remaining: Vec<(usize, f64)> = scores
.iter()
.zip(weights.iter())
.map(|((i, _), w)| (*i, *w / total))
.collect();
for _ in 0..batch_size {
if remaining.is_empty() {
break;
}
let r: f64 = rng.gen();
let weights_now: Vec<f64> = remaining.iter().map(|(_, w)| *w).collect();
let chosen_idx = inverse_cdf_pick(&weights_now, r);
let (i, _) = remaining.remove(chosen_idx);
selected.push(i);
let new_total: f64 = remaining.iter().map(|(_, w)| w).sum();
if new_total > 0.0 {
for (_, w) in &mut remaining {
*w /= new_total;
}
}
}
selected
} else {
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scores
.into_iter()
.take(batch_size)
.map(|(i, _)| i)
.collect()
}
}
fn select_pair_by_information_gain<G, R>(
&self,
candidates: &[Candidate<G>],
aggregator: &FitnessAggregator,
temperature: f64,
rng: &mut R,
) -> Option<(usize, usize)>
where
G: EvolutionaryGenome,
R: Rng,
{
let n = candidates.len();
if n < 2 {
return None;
}
let estimates: Vec<Option<FitnessEstimate>> = candidates
.iter()
.map(|c| aggregator.get_fitness_estimate(&c.id))
.collect();
let mut pair_scores: Vec<((usize, usize), f64)> = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
let entropy = pairwise_entropy(estimates[i].as_ref(), estimates[j].as_ref());
pair_scores.push(((i, j), entropy));
}
}
if pair_scores.is_empty() {
return Some((0, 1));
}
if temperature > 0.0 {
let max_score = pair_scores
.iter()
.map(|(_, s)| *s)
.fold(f64::NEG_INFINITY, f64::max);
let weights: Vec<f64> = pair_scores
.iter()
.map(|(_, s)| ((s - max_score) / temperature).exp())
.collect();
let total: f64 = weights.iter().sum();
let normalized: Vec<f64> = weights.iter().map(|w| w / total).collect();
let r: f64 = rng.gen();
let idx = inverse_cdf_pick(&normalized, r);
return Some(pair_scores[idx].0);
}
pair_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Some(pair_scores[0].0)
}
fn select_coverage_aware<G>(
&self,
candidates: &[Candidate<G>],
aggregator: &FitnessAggregator,
batch_size: usize,
min_evaluations: usize,
exploration_bonus: f64,
) -> Vec<usize>
where
G: EvolutionaryGenome,
{
let variances: Vec<f64> = candidates
.iter()
.map(|c| {
aggregator
.get_fitness_estimate(&c.id)
.map(|e| e.variance)
.unwrap_or(f64::INFINITY)
})
.collect();
let var_scale = mean_variance_scale(&variances);
let mut scores: Vec<(usize, f64)> = candidates
.iter()
.enumerate()
.map(|(i, c)| {
let score = if c.evaluation_count < min_evaluations {
f64::MAX
} else {
normalized_uncertainty_score(
variances[i],
c.evaluation_count,
var_scale,
1.0,
exploration_bonus,
)
};
(i, score)
})
.collect();
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scores
.into_iter()
.take(batch_size)
.map(|(i, _)| i)
.collect()
}
fn find_informative_pair<G, R>(
&self,
candidates: &[Candidate<G>],
aggregator: &FitnessAggregator,
exclude: Option<usize>,
rng: &mut R,
) -> usize
where
G: EvolutionaryGenome,
R: Rng,
{
let estimates: Vec<Option<FitnessEstimate>> = candidates
.iter()
.map(|c| aggregator.get_fitness_estimate(&c.id))
.collect();
let mut scores: Vec<(usize, f64)> = candidates
.iter()
.enumerate()
.filter(|(i, _)| Some(*i) != exclude)
.map(|(i, _)| {
let score = estimates
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, other)| pairwise_entropy(estimates[i].as_ref(), other.as_ref()))
.sum::<f64>();
(i, score)
})
.collect();
if scores.is_empty() {
return exclude.unwrap_or(0);
}
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let top_k = 3.min(scores.len());
let chosen = rng.gen_range(0..top_k);
scores[chosen].0
}
}
fn inverse_cdf_pick(weights: &[f64], r: f64) -> usize {
let mut cumsum = 0.0;
for (idx, w) in weights.iter().enumerate() {
cumsum += w;
if r < cumsum {
return idx;
}
}
weights.len().saturating_sub(1)
}
const UNOBSERVED_NORMALIZED_UNCERTAINTY: f64 = 1e6;
fn mean_variance_scale(variances: &[f64]) -> f64 {
let (sum, count) = variances
.iter()
.filter(|v| v.is_finite() && **v > 0.0)
.fold((0.0, 0usize), |(s, c), v| (s + v, c + 1));
if count == 0 {
1.0
} else {
sum / count as f64
}
}
fn normalized_uncertainty_score(
variance: f64,
eval_count: usize,
var_scale: f64,
uncertainty_weight: f64,
bonus_coeff: f64,
) -> f64 {
let normalized = if variance.is_finite() {
variance / var_scale
} else {
UNOBSERVED_NORMALIZED_UNCERTAINTY
};
let bonus = bonus_coeff / (eval_count as f64 + 1.0);
uncertainty_weight * normalized + bonus
}
const MAX_BINARY_ENTROPY_NATS: f64 = std::f64::consts::LN_2;
fn pairwise_entropy(a: Option<&FitnessEstimate>, b: Option<&FitnessEstimate>) -> f64 {
match (a, b) {
(Some(est_a), Some(est_b)) => {
let mean_diff = est_a.mean - est_b.mean;
let var_diff = est_a.variance + est_b.variance;
if var_diff.is_infinite() {
return MAX_BINARY_ENTROPY_NATS;
}
if var_diff <= 0.0 {
return if mean_diff.abs() < f64::EPSILON {
MAX_BINARY_ENTROPY_NATS
} else {
0.0
};
}
let z = mean_diff / var_diff.sqrt();
let p = normal_cdf(z);
binary_entropy(p)
}
_ => MAX_BINARY_ENTROPY_NATS, }
}
fn binary_entropy(p: f64) -> f64 {
let p = p.clamp(1e-10, 1.0 - 1e-10);
-(p * p.ln() + (1.0 - p) * (1.0 - p).ln())
}
fn normal_cdf(x: f64) -> f64 {
let a1 = 0.254829592;
let a2 = -0.284496736;
let a3 = 1.421413741;
let a4 = -1.453152027;
let a5 = 1.061405429;
let p = 0.3275911;
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs() / std::f64::consts::SQRT_2;
let t = 1.0 / (1.0 + p * x);
let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
0.5 * (1.0 + sign * y)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::genome::real_vector::RealVector;
use crate::interactive::aggregation::{AggregationModel, FitnessAggregator};
use crate::interactive::evaluator::CandidateId;
fn make_candidates(n: usize) -> Vec<Candidate<RealVector>> {
(0..n)
.map(|i| {
let mut c = Candidate::new(CandidateId(i), RealVector::new(vec![i as f64]));
c.evaluation_count = 0;
c
})
.collect()
}
#[test]
fn test_sequential_selection() {
let candidates = make_candidates(10);
let aggregator = FitnessAggregator::new(AggregationModel::default());
let mut rng = rand::thread_rng();
let strategy = SelectionStrategy::Sequential;
let selected = strategy.select_batch(&candidates, &aggregator, 3, &mut rng);
assert_eq!(selected.len(), 3);
assert!(selected.contains(&0));
assert!(selected.contains(&1));
assert!(selected.contains(&2));
}
#[test]
fn test_uncertainty_sampling() {
let mut candidates = make_candidates(5);
let mut aggregator = FitnessAggregator::new(AggregationModel::DirectRating {
default_rating: 5.0,
});
let mut rng = rand::thread_rng();
aggregator.record_rating(CandidateId(0), 7.0);
aggregator.record_rating(CandidateId(0), 7.0);
aggregator.record_rating(CandidateId(0), 7.0);
candidates[0].evaluation_count = 3;
aggregator.record_rating(CandidateId(1), 4.0);
aggregator.record_rating(CandidateId(1), 8.0);
candidates[1].evaluation_count = 2;
let strategy = SelectionStrategy::UncertaintySampling {
uncertainty_weight: 1.0,
};
let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng);
assert_eq!(selected.len(), 2);
for &idx in &selected {
assert!(
idx != 0,
"Should not select the well-evaluated candidate with low variance"
);
}
}
#[test]
fn test_coverage_aware() {
let mut candidates = make_candidates(5);
candidates[0].evaluation_count = 3;
candidates[1].evaluation_count = 2;
candidates[2].evaluation_count = 0; candidates[3].evaluation_count = 0; candidates[4].evaluation_count = 1;
let aggregator = FitnessAggregator::new(AggregationModel::default());
let mut rng = rand::thread_rng();
let strategy = SelectionStrategy::CoverageAware {
min_evaluations: 2,
exploration_bonus: 1.0,
};
let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng);
assert!(selected.contains(&2) || selected.contains(&3));
}
#[test]
fn test_select_pair_sequential() {
let candidates = make_candidates(5);
let aggregator = FitnessAggregator::new(AggregationModel::default());
let mut rng = rand::thread_rng();
let strategy = SelectionStrategy::Sequential;
let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
assert!(pair.is_some());
let (a, b) = pair.unwrap();
assert_ne!(a, b);
}
#[test]
fn test_select_pair_info_gain() {
let candidates = make_candidates(5);
let aggregator = FitnessAggregator::new(AggregationModel::default());
let mut rng = rand::thread_rng();
let strategy = SelectionStrategy::ExpectedInformationGain { temperature: 1.0 };
let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
assert!(pair.is_some());
let (a, b) = pair.unwrap();
assert_ne!(a, b);
}
#[test]
fn test_binary_entropy() {
let max_entropy = binary_entropy(0.5);
assert!((max_entropy - std::f64::consts::LN_2).abs() < 1e-6);
assert!(binary_entropy(0.001) < 0.1);
assert!(binary_entropy(0.999) < 0.1);
}
#[test]
fn test_normal_cdf() {
assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6);
assert!(normal_cdf(-10.0) < 0.001);
assert!(normal_cdf(10.0) > 0.999);
assert!((normal_cdf(1.0) + normal_cdf(-1.0) - 1.0).abs() < 1e-6);
}
#[test]
fn test_empty_candidates() {
let candidates: Vec<Candidate<RealVector>> = vec![];
let aggregator = FitnessAggregator::new(AggregationModel::default());
let mut rng = rand::thread_rng();
let strategy = SelectionStrategy::default();
let selected = strategy.select_batch(&candidates, &aggregator, 5, &mut rng);
assert!(selected.is_empty());
let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
assert!(pair.is_none());
}
#[test]
fn test_single_candidate() {
let candidates = make_candidates(1);
let aggregator = FitnessAggregator::new(AggregationModel::default());
let mut rng = rand::thread_rng();
let strategy = SelectionStrategy::default();
let selected = strategy.select_batch(&candidates, &aggregator, 5, &mut rng);
assert_eq!(selected.len(), 1);
let pair = strategy.select_pair(&candidates, &aggregator, &mut rng);
assert!(pair.is_none()); }
#[test]
fn test_inverse_cdf_pick_fallback_is_last() {
let weights = vec![0.3, 0.3, 0.3]; assert_eq!(inverse_cdf_pick(&weights, 0.95), 2); assert_eq!(inverse_cdf_pick(&weights, 0.1), 0);
assert_eq!(inverse_cdf_pick(&weights, 0.4), 1);
assert_eq!(inverse_cdf_pick(&weights, 0.7), 2);
}
#[test]
fn test_entropy_sentinel_is_nats() {
assert!((MAX_BINARY_ENTROPY_NATS - std::f64::consts::LN_2).abs() < 1e-12);
let unobserved = pairwise_entropy(None, None);
assert!((unobserved - binary_entropy(0.5)).abs() < 1e-9);
assert!((unobserved - std::f64::consts::LN_2).abs() < 1e-9);
assert!(unobserved < 1.0); }
#[test]
fn test_pairwise_entropy_known_below_unknown() {
let known_a = FitnessEstimate::new(9.0, 0.0, 100);
let known_b = FitnessEstimate::new(1.0, 0.0, 100);
let known = pairwise_entropy(Some(&known_a), Some(&known_b));
let unknown_a = FitnessEstimate::uninformative(5.0); let unknown_b = FitnessEstimate::uninformative(5.0);
let unknown = pairwise_entropy(Some(&unknown_a), Some(&unknown_b));
assert!(
known < unknown,
"known {known} should score below unknown {unknown}"
);
assert!(known < 1e-6, "determined outcome should be ~0, got {known}");
let tie_a = FitnessEstimate::new(5.0, 0.0, 100);
let tie_b = FitnessEstimate::new(5.0, 0.0, 100);
assert!(
(pairwise_entropy(Some(&tie_a), Some(&tie_b)) - std::f64::consts::LN_2).abs() < 1e-9
);
}
#[test]
fn test_coverage_aware_never_returns_self_pair() {
let mut candidates = make_candidates(2);
candidates[0].evaluation_count = 2;
candidates[1].evaluation_count = 2; let aggregator = FitnessAggregator::new(AggregationModel::default());
let mut rng = rand::thread_rng();
let strategy = SelectionStrategy::CoverageAware {
min_evaluations: 1,
exploration_bonus: 1.0,
};
for _ in 0..200 {
let (a, b) = strategy
.select_pair(&candidates, &aggregator, &mut rng)
.unwrap();
assert_ne!(a, b, "select_pair returned a self-pair");
}
}
#[test]
fn test_normalized_uncertainty_scale_invariant() {
let counts = [1usize, 100usize];
let variances = [1.0, 1.05];
let var_scale = mean_variance_scale(&variances);
let s_a = normalized_uncertainty_score(variances[0], counts[0], var_scale, 1.0, 1.0);
let s_b = normalized_uncertainty_score(variances[1], counts[1], var_scale, 1.0, 1.0);
let variances_big: Vec<f64> = variances.iter().map(|v| v * 100.0).collect();
let scale_big = mean_variance_scale(&variances_big);
let s_a_big =
normalized_uncertainty_score(variances_big[0], counts[0], scale_big, 1.0, 1.0);
let s_b_big =
normalized_uncertainty_score(variances_big[1], counts[1], scale_big, 1.0, 1.0);
assert_eq!(s_a > s_b, s_a_big > s_b_big);
assert!(
s_a > s_b,
"the low-count candidate should win via the bonus"
);
let raw_a = variances[0] + 1.0 / (counts[0] as f64 + 1.0);
let raw_b = variances[1] + 1.0 / (counts[1] as f64 + 1.0);
let raw_a_big = variances_big[0] + 1.0 / (counts[0] as f64 + 1.0);
let raw_b_big = variances_big[1] + 1.0 / (counts[1] as f64 + 1.0);
assert!(raw_a > raw_b); assert!(raw_a_big < raw_b_big); }
}