1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
38pub enum SelectionStrategy {
39 Sequential,
44
45 UncertaintySampling {
51 uncertainty_weight: f64,
54 },
55
56 ExpectedInformationGain {
62 temperature: f64,
65 },
66
67 CoverageAware {
73 min_evaluations: usize,
75 exploration_bonus: f64,
77 },
78}
79
80impl Default for SelectionStrategy {
81 fn default() -> Self {
82 Self::Sequential
83 }
84}
85
86impl SelectionStrategy {
87 pub fn uncertainty_sampling(uncertainty_weight: f64) -> Self {
89 Self::UncertaintySampling { uncertainty_weight }
90 }
91
92 pub fn information_gain(temperature: f64) -> Self {
94 Self::ExpectedInformationGain { temperature }
95 }
96
97 pub fn coverage_aware(min_evaluations: usize, exploration_bonus: f64) -> Self {
99 Self::CoverageAware {
100 min_evaluations,
101 exploration_bonus,
102 }
103 }
104
105 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 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 Some((0, 1))
188 }
189 Self::UncertaintySampling { .. } => {
190 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 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 let a_eval = candidates[a].evaluation_count;
218 if a_eval < *min_evaluations {
219 indices[1].0
221 } else {
222 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 fn select_sequential<G>(&self, candidates: &[Candidate<G>], batch_size: usize) -> Vec<usize>
236 where
237 G: EvolutionaryGenome,
238 {
239 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 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 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 } else {
281 e.variance
282 }
283 })
284 .unwrap_or(f64::MAX)
285 })
286 .collect()
287 }
288
289 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 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 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 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 let estimates: Vec<Option<FitnessEstimate>> = candidates
354 .iter()
355 .map(|c| aggregator.get_fitness_estimate(&c.id))
356 .collect();
357
358 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let top_k = 3.min(scores.len());
589 let chosen = rng.gen_range(0..top_k);
590 scores[chosen].0
591 }
592}
593
594fn 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
612const UNOBSERVED_NORMALIZED_UNCERTAINTY: f64 = 1e6;
620
621fn 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
638fn 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
662const MAX_BINARY_ENTROPY_NATS: f64 = std::f64::consts::LN_2;
672
673fn 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 return MAX_BINARY_ENTROPY_NATS;
687 }
688
689 if var_diff <= 0.0 {
690 return if mean_diff.abs() < f64::EPSILON {
696 MAX_BINARY_ENTROPY_NATS
697 } else {
698 0.0
699 };
700 }
701
702 let z = mean_diff / var_diff.sqrt();
704 let p = normal_cdf(z);
705
706 binary_entropy(p)
707 }
708 _ => MAX_BINARY_ENTROPY_NATS, }
710}
711
712fn 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
721fn normal_cdf(x: f64) -> f64 {
723 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 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 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 aggregator.record_rating(CandidateId(1), 4.0);
789 aggregator.record_rating(CandidateId(1), 8.0);
790 candidates[1].evaluation_count = 2;
791
792 let strategy = SelectionStrategy::UncertaintySampling {
795 uncertainty_weight: 1.0,
796 };
797 let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng);
798
799 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; candidates[3].evaluation_count = 0; 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 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 let max_entropy = binary_entropy(0.5);
863 assert!((max_entropy - std::f64::consts::LN_2).abs() < 1e-6);
864
865 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 assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6);
874
875 assert!(normal_cdf(-10.0) < 0.001);
877 assert!(normal_cdf(10.0) > 0.999);
878
879 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()); }
910
911 #[test]
912 fn test_inverse_cdf_pick_fallback_is_last() {
913 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);
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 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); }
933
934 #[test]
935 fn test_pairwise_entropy_known_below_unknown() {
936 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); 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 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 let mut candidates = make_candidates(2);
966 candidates[0].evaluation_count = 2;
967 candidates[1].evaluation_count = 2; 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 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 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 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); assert!(raw_a_big < raw_b_big); }
1015}