1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
45pub enum SelectionStrategy {
46 Sequential,
51
52 UncertaintySampling {
58 uncertainty_weight: f64,
61 },
62
63 ExpectedInformationGain {
69 temperature: f64,
72 },
73
74 CoverageAware {
80 min_evaluations: usize,
82 exploration_bonus: f64,
84 },
85}
86
87impl Default for SelectionStrategy {
88 fn default() -> Self {
89 Self::Sequential
90 }
91}
92
93impl SelectionStrategy {
94 pub fn uncertainty_sampling(uncertainty_weight: f64) -> Self {
96 Self::UncertaintySampling { uncertainty_weight }
97 }
98
99 pub fn information_gain(temperature: f64) -> Self {
101 Self::ExpectedInformationGain { temperature }
102 }
103
104 pub fn coverage_aware(min_evaluations: usize, exploration_bonus: f64) -> Self {
106 Self::CoverageAware {
107 min_evaluations,
108 exploration_bonus,
109 }
110 }
111
112 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 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 Some((0, 1))
195 }
196 Self::UncertaintySampling { .. } => {
197 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 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 let a_eval = candidates[a].evaluation_count;
225 if a_eval < *min_evaluations {
226 indices[1].0
228 } else {
229 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 fn select_sequential<G>(&self, candidates: &[Candidate<G>], batch_size: usize) -> Vec<usize>
243 where
244 G: EvolutionaryGenome,
245 {
246 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 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 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 } else {
288 e.variance
289 }
290 })
291 .unwrap_or(f64::MAX)
292 })
293 .collect()
294 }
295
296 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 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 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 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 let estimates: Vec<Option<FitnessEstimate>> = candidates
361 .iter()
362 .map(|c| aggregator.get_fitness_estimate(&c.id))
363 .collect();
364
365 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let top_k = 3.min(scores.len());
596 let chosen = rng.gen_range(0..top_k);
597 scores[chosen].0
598 }
599}
600
601fn 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
619const UNOBSERVED_NORMALIZED_UNCERTAINTY: f64 = 1e6;
627
628fn 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
645fn 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
669const MAX_BINARY_ENTROPY_NATS: f64 = std::f64::consts::LN_2;
679
680fn 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 return MAX_BINARY_ENTROPY_NATS;
694 }
695
696 if var_diff <= 0.0 {
697 return if mean_diff.abs() < f64::EPSILON {
703 MAX_BINARY_ENTROPY_NATS
704 } else {
705 0.0
706 };
707 }
708
709 let z = mean_diff / var_diff.sqrt();
711 let p = normal_cdf(z);
712
713 binary_entropy(p)
714 }
715 _ => MAX_BINARY_ENTROPY_NATS, }
717}
718
719fn 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
728fn normal_cdf(x: f64) -> f64 {
730 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 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 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 aggregator.record_rating(CandidateId(1), 4.0);
796 aggregator.record_rating(CandidateId(1), 8.0);
797 candidates[1].evaluation_count = 2;
798
799 let strategy = SelectionStrategy::UncertaintySampling {
802 uncertainty_weight: 1.0,
803 };
804 let selected = strategy.select_batch(&candidates, &aggregator, 2, &mut rng);
805
806 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; candidates[3].evaluation_count = 0; 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 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 let max_entropy = binary_entropy(0.5);
870 assert!((max_entropy - std::f64::consts::LN_2).abs() < 1e-6);
871
872 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 assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6);
881
882 assert!(normal_cdf(-10.0) < 0.001);
884 assert!(normal_cdf(10.0) > 0.999);
885
886 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()); }
917
918 #[test]
919 fn test_inverse_cdf_pick_fallback_is_last() {
920 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);
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 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); }
940
941 #[test]
942 fn test_pairwise_entropy_known_below_unknown() {
943 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); 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 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 let mut candidates = make_candidates(2);
973 candidates[0].evaluation_count = 2;
974 candidates[1].evaluation_count = 2; 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 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 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 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); assert!(raw_a_big < raw_b_big); }
1022}