1use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21use super::bradley_terry::{BradleyTerryModel, BradleyTerryOptimizer};
22use super::evaluator::{CandidateId, EvaluationResponse};
23use super::uncertainty::FitnessEstimate;
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
27pub enum AggregationModel {
28 DirectRating {
33 default_rating: f64,
35 },
36
37 Elo {
42 initial_rating: f64,
44 k_factor: f64,
46 },
47
48 BradleyTerry {
54 initial_strength: f64,
56 #[serde(default)]
58 optimizer: BradleyTerryOptimizer,
59 },
60
61 #[serde(alias = "BradleyTerryLegacy")]
65 BradleyTerrySimple {
66 initial_strength: f64,
68 learning_rate: f64,
70 iterations: usize,
72 },
73
74 ImplicitRanking {
79 selected_bonus: f64,
81 not_selected_penalty: f64,
83 base_fitness: f64,
85 },
86}
87
88impl Default for AggregationModel {
89 fn default() -> Self {
90 Self::DirectRating {
91 default_rating: 5.0,
92 }
93 }
94}
95
96#[derive(Clone, Debug, Default, Serialize, Deserialize)]
98pub struct CandidateStats {
99 pub rating_sum: f64,
101 #[serde(default)]
103 pub rating_sum_squares: f64,
104 pub rating_count: usize,
106 pub model_score: f64,
108 #[serde(default = "default_variance")]
110 pub model_variance: f64,
111 pub wins: usize,
113 pub losses: usize,
115 pub ties: usize,
117 pub times_selected: usize,
119 pub times_passed: usize,
121}
122
123fn default_variance() -> f64 {
124 f64::INFINITY
125}
126
127impl CandidateStats {
128 pub fn new(initial_score: f64) -> Self {
130 Self {
131 model_score: initial_score,
132 model_variance: f64::INFINITY,
133 ..Default::default()
134 }
135 }
136
137 pub fn average_rating(&self) -> Option<f64> {
139 if self.rating_count > 0 {
140 Some(self.rating_sum / self.rating_count as f64)
141 } else {
142 None
143 }
144 }
145
146 pub fn rating_variance(&self) -> Option<f64> {
148 if self.rating_count < 2 {
149 return None;
150 }
151 let n = self.rating_count as f64;
152 let mean = self.rating_sum / n;
153 let var = (self.rating_sum_squares / n) - (mean * mean);
155 Some(var * n / (n - 1.0))
157 }
158
159 pub fn rating_variance_of_mean(&self) -> Option<f64> {
161 self.rating_variance()
162 .map(|var| var / self.rating_count as f64)
163 }
164
165 pub fn total_comparisons(&self) -> usize {
167 self.wins + self.losses + self.ties
168 }
169
170 pub fn win_rate(&self) -> Option<f64> {
172 let total = self.total_comparisons();
173 if total > 0 {
174 Some(self.wins as f64 / total as f64)
175 } else {
176 None
177 }
178 }
179
180 pub fn selection_rate(&self) -> Option<f64> {
182 let total = self.times_selected + self.times_passed;
183 if total > 0 {
184 Some(self.times_selected as f64 / total as f64)
185 } else {
186 None
187 }
188 }
189}
190
191#[derive(Clone, Debug, Serialize, Deserialize)]
193pub struct ComparisonRecord {
194 pub winner: CandidateId,
196 pub loser: CandidateId,
198 pub generation: usize,
200}
201
202#[derive(Clone, Debug, Serialize, Deserialize)]
204pub struct FitnessAggregator {
205 model: AggregationModel,
207 candidate_stats: HashMap<CandidateId, CandidateStats>,
209 comparisons: Vec<ComparisonRecord>,
211 current_generation: usize,
213}
214
215impl FitnessAggregator {
216 pub fn new(model: AggregationModel) -> Self {
218 Self {
219 model,
220 candidate_stats: HashMap::new(),
221 comparisons: Vec::new(),
222 current_generation: 0,
223 }
224 }
225
226 pub fn model(&self) -> &AggregationModel {
228 &self.model
229 }
230
231 pub fn set_generation(&mut self, generation: usize) {
233 self.current_generation = generation;
234 }
235
236 fn ensure_stats(&mut self, id: CandidateId) {
238 if !self.candidate_stats.contains_key(&id) {
239 let initial_score = match &self.model {
240 AggregationModel::DirectRating { default_rating } => *default_rating,
241 AggregationModel::Elo { initial_rating, .. } => *initial_rating,
242 AggregationModel::BradleyTerry {
243 initial_strength, ..
244 } => *initial_strength,
245 AggregationModel::BradleyTerrySimple {
246 initial_strength, ..
247 } => *initial_strength,
248 AggregationModel::ImplicitRanking { base_fitness, .. } => *base_fitness,
249 };
250 self.candidate_stats
251 .insert(id, CandidateStats::new(initial_score));
252 }
253 }
254
255 pub fn get_stats(&self, id: &CandidateId) -> Option<&CandidateStats> {
257 self.candidate_stats.get(id)
258 }
259
260 pub fn get_fitness(&self, id: &CandidateId) -> Option<f64> {
264 let stats = self.candidate_stats.get(id)?;
265
266 Some(match &self.model {
267 AggregationModel::DirectRating { default_rating } => {
268 stats.average_rating().unwrap_or(*default_rating)
269 }
270 AggregationModel::Elo { .. } => stats.model_score,
271 AggregationModel::BradleyTerry { .. } => stats.model_score,
272 AggregationModel::BradleyTerrySimple { .. } => stats.model_score,
273 AggregationModel::ImplicitRanking { .. } => {
274 stats.model_score
276 }
277 })
278 }
279
280 pub fn get_fitness_estimate(&self, id: &CandidateId) -> Option<FitnessEstimate> {
285 let stats = self.candidate_stats.get(id)?;
286
287 Some(match &self.model {
288 AggregationModel::DirectRating { default_rating } => {
289 if stats.rating_count == 0 {
290 FitnessEstimate::uninformative(*default_rating)
291 } else {
292 let mean = stats.rating_sum / stats.rating_count as f64;
293 let variance = stats.rating_variance_of_mean().unwrap_or(f64::INFINITY);
294 FitnessEstimate::new(mean, variance, stats.rating_count)
295 }
296 }
297 AggregationModel::Elo { k_factor, .. } => {
298 let n_games = stats.total_comparisons();
308 let variance = if n_games == 0 {
309 f64::INFINITY
310 } else {
311 let s = 400.0 / std::f64::consts::LN_10;
312 let steady_state = k_factor * s / 2.0; steady_state + k_factor * k_factor / (4.0 * n_games as f64)
314 };
315 FitnessEstimate::new(stats.model_score, variance, n_games)
316 }
317 AggregationModel::BradleyTerry { .. } | AggregationModel::BradleyTerrySimple { .. } => {
318 let n_comparisons = stats.total_comparisons();
320 let variance = if stats.model_variance.is_finite() {
321 stats.model_variance
322 } else if n_comparisons == 0 {
323 f64::INFINITY
324 } else {
325 1.0 / n_comparisons as f64
327 };
328 FitnessEstimate::new(stats.model_score, variance, n_comparisons)
329 }
330 AggregationModel::ImplicitRanking {
331 selected_bonus,
332 not_selected_penalty,
333 ..
334 } => {
335 let n = stats.times_selected + stats.times_passed;
343 if n == 0 {
344 FitnessEstimate::uninformative(stats.model_score)
345 } else {
346 let p = stats.times_selected as f64 / n as f64;
347 let slope = selected_bonus + not_selected_penalty;
348 let variance = slope * slope * n as f64 * p * (1.0 - p);
349 FitnessEstimate::new(stats.model_score, variance, n)
350 }
351 }
352 })
353 }
354
355 pub fn comparisons(&self) -> &[ComparisonRecord] {
357 &self.comparisons
358 }
359
360 pub fn record_rating(&mut self, id: CandidateId, rating: f64) {
362 self.ensure_stats(id);
363 if let Some(stats) = self.candidate_stats.get_mut(&id) {
364 stats.rating_sum += rating;
365 stats.rating_sum_squares += rating * rating;
366 stats.rating_count += 1;
367 }
368 }
369
370 pub fn record_comparison(&mut self, winner: CandidateId, loser: CandidateId) {
372 self.ensure_stats(winner);
373 self.ensure_stats(loser);
374
375 if let Some(winner_stats) = self.candidate_stats.get_mut(&winner) {
377 winner_stats.wins += 1;
378 }
379 if let Some(loser_stats) = self.candidate_stats.get_mut(&loser) {
380 loser_stats.losses += 1;
381 }
382
383 self.comparisons.push(ComparisonRecord {
385 winner,
386 loser,
387 generation: self.current_generation,
388 });
389
390 match &self.model {
392 AggregationModel::Elo { k_factor, .. } => {
393 self.update_elo(winner, loser, *k_factor);
394 }
395 AggregationModel::BradleyTerry { .. } => {
396 }
398 _ => {}
399 }
400 }
401
402 pub fn record_tie(&mut self, id_a: CandidateId, id_b: CandidateId) {
404 self.ensure_stats(id_a);
405 self.ensure_stats(id_b);
406
407 if let Some(stats) = self.candidate_stats.get_mut(&id_a) {
408 stats.ties += 1;
409 }
410 if let Some(stats) = self.candidate_stats.get_mut(&id_b) {
411 stats.ties += 1;
412 }
413
414 if let AggregationModel::Elo { k_factor, .. } = &self.model {
416 self.update_elo_draw(id_a, id_b, *k_factor);
417 }
418 }
419
420 pub fn record_batch_selection(
422 &mut self,
423 selected: &[CandidateId],
424 not_selected: &[CandidateId],
425 ) {
426 if let AggregationModel::ImplicitRanking {
427 selected_bonus,
428 not_selected_penalty,
429 ..
430 } = &self.model
431 {
432 let bonus = *selected_bonus;
433 let penalty = *not_selected_penalty;
434
435 for &id in selected {
436 self.ensure_stats(id);
437 if let Some(stats) = self.candidate_stats.get_mut(&id) {
438 stats.times_selected += 1;
439 stats.model_score += bonus;
440 }
441 }
442
443 for &id in not_selected {
444 self.ensure_stats(id);
445 if let Some(stats) = self.candidate_stats.get_mut(&id) {
446 stats.times_passed += 1;
447 stats.model_score -= penalty;
448 }
449 }
450 } else {
451 for &id in selected {
453 self.ensure_stats(id);
454 if let Some(stats) = self.candidate_stats.get_mut(&id) {
455 stats.times_selected += 1;
456 }
457 }
458 for &id in not_selected {
459 self.ensure_stats(id);
460 if let Some(stats) = self.candidate_stats.get_mut(&id) {
461 stats.times_passed += 1;
462 }
463 }
464 }
465 }
466
467 fn update_elo(&mut self, winner: CandidateId, loser: CandidateId, k: f64) {
469 let winner_rating = self
470 .candidate_stats
471 .get(&winner)
472 .map(|s| s.model_score)
473 .unwrap_or(1500.0);
474 let loser_rating = self
475 .candidate_stats
476 .get(&loser)
477 .map(|s| s.model_score)
478 .unwrap_or(1500.0);
479
480 let exp_winner = 1.0 / (1.0 + 10.0_f64.powf((loser_rating - winner_rating) / 400.0));
482 let exp_loser = 1.0 - exp_winner;
483
484 if let Some(stats) = self.candidate_stats.get_mut(&winner) {
486 stats.model_score += k * (1.0 - exp_winner);
487 }
488 if let Some(stats) = self.candidate_stats.get_mut(&loser) {
489 stats.model_score += k * (0.0 - exp_loser);
490 }
491 }
492
493 fn update_elo_draw(&mut self, id_a: CandidateId, id_b: CandidateId, k: f64) {
495 let rating_a = self
496 .candidate_stats
497 .get(&id_a)
498 .map(|s| s.model_score)
499 .unwrap_or(1500.0);
500 let rating_b = self
501 .candidate_stats
502 .get(&id_b)
503 .map(|s| s.model_score)
504 .unwrap_or(1500.0);
505
506 let exp_a = 1.0 / (1.0 + 10.0_f64.powf((rating_b - rating_a) / 400.0));
508 let exp_b = 1.0 - exp_a;
509
510 if let Some(stats) = self.candidate_stats.get_mut(&id_a) {
512 stats.model_score += k * (0.5 - exp_a);
513 }
514 if let Some(stats) = self.candidate_stats.get_mut(&id_b) {
515 stats.model_score += k * (0.5 - exp_b);
516 }
517 }
518
519 pub fn recompute_all(&mut self) -> HashMap<CandidateId, f64> {
524 match &self.model {
525 AggregationModel::BradleyTerry { optimizer, .. } => {
526 self.recompute_bradley_terry_mle(optimizer.clone());
527 }
528 AggregationModel::BradleyTerrySimple {
529 initial_strength,
530 learning_rate,
531 iterations,
532 } => {
533 self.recompute_bradley_terry_simple(*initial_strength, *learning_rate, *iterations);
534 }
535 _ => {}
536 }
537
538 self.candidate_stats
540 .keys()
541 .filter_map(|id| self.get_fitness(id).map(|f| (*id, f)))
542 .collect()
543 }
544
545 fn recompute_bradley_terry_mle(&mut self, optimizer: BradleyTerryOptimizer) {
547 let ids: Vec<CandidateId> = self.candidate_stats.keys().copied().collect();
548 if ids.is_empty() || self.comparisons.is_empty() {
549 return;
550 }
551
552 let model = BradleyTerryModel::new(optimizer);
553 let result = model.fit(&self.comparisons, &ids);
554
555 for (&id, &strength) in &result.strengths {
557 if let Some(stats) = self.candidate_stats.get_mut(&id) {
558 stats.model_score = strength;
559
560 if let Some(&idx) = result.id_to_index.get(&id) {
562 if idx < result.covariance.nrows() {
563 stats.model_variance = result.covariance[(idx, idx)];
564 }
565 }
566 }
567 }
568 }
569
570 fn recompute_bradley_terry_simple(
572 &mut self,
573 initial_strength: f64,
574 learning_rate: f64,
575 iterations: usize,
576 ) {
577 let ids: Vec<CandidateId> = self.candidate_stats.keys().copied().collect();
579 for &id in &ids {
580 if let Some(stats) = self.candidate_stats.get_mut(&id) {
581 stats.model_score = initial_strength;
582 }
583 }
584
585 for _ in 0..iterations {
587 let mut new_scores: HashMap<CandidateId, f64> = HashMap::new();
588
589 for &id in &ids {
590 let stats = match self.candidate_stats.get(&id) {
591 Some(s) => s,
592 None => continue,
593 };
594
595 let wins = stats.wins as f64;
596 if wins == 0.0 {
597 new_scores.insert(id, stats.model_score);
598 continue;
599 }
600
601 let mut denom = 0.0;
603 for comparison in &self.comparisons {
604 if comparison.winner == id {
605 let other_score = self
606 .candidate_stats
607 .get(&comparison.loser)
608 .map(|s| s.model_score)
609 .unwrap_or(initial_strength);
610 denom += 1.0 / (stats.model_score + other_score);
611 } else if comparison.loser == id {
612 let other_score = self
613 .candidate_stats
614 .get(&comparison.winner)
615 .map(|s| s.model_score)
616 .unwrap_or(initial_strength);
617 denom += 1.0 / (stats.model_score + other_score);
618 }
619 }
620
621 let new_score = if denom > 0.0 {
622 let raw = wins / denom;
623 stats.model_score + learning_rate * (raw - stats.model_score)
625 } else {
626 stats.model_score
627 };
628
629 new_scores.insert(id, new_score.max(0.001)); }
631
632 for (id, score) in new_scores {
634 if let Some(stats) = self.candidate_stats.get_mut(&id) {
635 stats.model_score = score;
636 }
637 }
638 }
639 }
640
641 pub fn process_response(&mut self, response: &EvaluationResponse) -> Vec<(CandidateId, f64)> {
643 match response {
644 EvaluationResponse::Ratings(ratings) => {
645 for (id, rating) in ratings {
646 self.record_rating(*id, *rating);
647 }
648 ratings
649 .iter()
650 .filter_map(|(id, _)| self.get_fitness(id).map(|f| (*id, f)))
651 .collect()
652 }
653 EvaluationResponse::PairwiseWinner(Some(winner)) => {
654 self.ensure_stats(*winner);
657 if let Some(f) = self.get_fitness(winner) {
658 vec![(*winner, f)]
659 } else {
660 vec![]
661 }
662 }
663 EvaluationResponse::PairwiseWinner(None) => {
664 vec![]
666 }
667 EvaluationResponse::BatchSelected(selected) => {
668 for id in selected {
670 self.ensure_stats(*id);
671 if let Some(stats) = self.candidate_stats.get_mut(id) {
672 stats.times_selected += 1;
673 if let AggregationModel::ImplicitRanking { selected_bonus, .. } =
674 &self.model
675 {
676 stats.model_score += *selected_bonus;
677 }
678 }
679 }
680 selected
681 .iter()
682 .filter_map(|id| self.get_fitness(id).map(|f| (*id, f)))
683 .collect()
684 }
685 EvaluationResponse::Skip => vec![],
686 }
687 }
688
689 pub fn process_pairwise(
691 &mut self,
692 id_a: CandidateId,
693 id_b: CandidateId,
694 winner: Option<CandidateId>,
695 ) -> Vec<(CandidateId, f64)> {
696 match winner {
697 Some(w) if w == id_a => {
698 self.record_comparison(id_a, id_b);
699 }
700 Some(w) if w == id_b => {
701 self.record_comparison(id_b, id_a);
702 }
703 Some(_) => {
704 }
706 None => {
707 self.record_tie(id_a, id_b);
708 }
709 }
710
711 if matches!(
718 self.model,
719 AggregationModel::BradleyTerry { .. } | AggregationModel::BradleyTerrySimple { .. }
720 ) {
721 self.recompute_all();
722 }
723
724 vec![id_a, id_b]
725 .into_iter()
726 .filter_map(|id| self.get_fitness(&id).map(|f| (id, f)))
727 .collect()
728 }
729
730 pub fn process_batch_selection(
732 &mut self,
733 all_candidates: &[CandidateId],
734 selected: &[CandidateId],
735 ) -> Vec<(CandidateId, f64)> {
736 let selected_set: std::collections::HashSet<_> = selected.iter().copied().collect();
737 let not_selected: Vec<_> = all_candidates
738 .iter()
739 .copied()
740 .filter(|id| !selected_set.contains(id))
741 .collect();
742
743 self.record_batch_selection(selected, ¬_selected);
744
745 all_candidates
746 .iter()
747 .filter_map(|id| self.get_fitness(id).map(|f| (*id, f)))
748 .collect()
749 }
750
751 pub fn all_candidates(&self) -> Vec<CandidateId> {
753 self.candidate_stats.keys().copied().collect()
754 }
755
756 pub fn comparison_count(&self) -> usize {
758 self.comparisons.len()
759 }
760
761 pub fn clear(&mut self) {
763 self.candidate_stats.clear();
764 self.comparisons.clear();
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 #[test]
773 fn test_direct_rating_aggregation() {
774 let mut agg = FitnessAggregator::new(AggregationModel::DirectRating {
775 default_rating: 5.0,
776 });
777
778 let id = CandidateId(0);
779
780 agg.ensure_stats(id);
782 assert_eq!(agg.get_fitness(&id), Some(5.0));
783
784 agg.record_rating(id, 8.0);
786 assert_eq!(agg.get_fitness(&id), Some(8.0));
787
788 agg.record_rating(id, 6.0);
790 assert_eq!(agg.get_fitness(&id), Some(7.0));
791 }
792
793 #[test]
794 fn test_elo_rating() {
795 let mut agg = FitnessAggregator::new(AggregationModel::Elo {
796 initial_rating: 1500.0,
797 k_factor: 32.0,
798 });
799
800 let id_a = CandidateId(0);
801 let id_b = CandidateId(1);
802
803 agg.ensure_stats(id_a);
804 agg.ensure_stats(id_b);
805
806 assert_eq!(agg.get_fitness(&id_a), Some(1500.0));
808 assert_eq!(agg.get_fitness(&id_b), Some(1500.0));
809
810 agg.record_comparison(id_a, id_b);
812
813 let fitness_a = agg.get_fitness(&id_a).unwrap();
814 let fitness_b = agg.get_fitness(&id_b).unwrap();
815
816 assert!(fitness_a > 1500.0);
818 assert!(fitness_b < 1500.0);
820 assert!((fitness_a + fitness_b - 3000.0).abs() < 0.01);
822 }
823
824 #[test]
825 fn test_elo_draw() {
826 let mut agg = FitnessAggregator::new(AggregationModel::Elo {
827 initial_rating: 1500.0,
828 k_factor: 32.0,
829 });
830
831 let id_a = CandidateId(0);
832 let id_b = CandidateId(1);
833
834 agg.ensure_stats(id_a);
835 agg.ensure_stats(id_b);
836
837 agg.record_tie(id_a, id_b);
839
840 let fitness_a = agg.get_fitness(&id_a).unwrap();
841 let fitness_b = agg.get_fitness(&id_b).unwrap();
842
843 assert!((fitness_a - 1500.0).abs() < 0.01);
844 assert!((fitness_b - 1500.0).abs() < 0.01);
845 }
846
847 #[test]
848 fn test_implicit_ranking() {
849 let mut agg = FitnessAggregator::new(AggregationModel::ImplicitRanking {
850 selected_bonus: 1.0,
851 not_selected_penalty: 0.5,
852 base_fitness: 5.0,
853 });
854
855 let selected = vec![CandidateId(0), CandidateId(1)];
856 let not_selected = vec![CandidateId(2), CandidateId(3)];
857
858 agg.record_batch_selection(&selected, ¬_selected);
859
860 assert_eq!(agg.get_fitness(&CandidateId(0)), Some(6.0));
862 assert_eq!(agg.get_fitness(&CandidateId(1)), Some(6.0));
863
864 assert_eq!(agg.get_fitness(&CandidateId(2)), Some(4.5));
866 assert_eq!(agg.get_fitness(&CandidateId(3)), Some(4.5));
867 }
868
869 #[test]
870 fn test_bradley_terry_simple_recompute() {
871 let mut agg = FitnessAggregator::new(AggregationModel::BradleyTerrySimple {
872 initial_strength: 1.0,
873 learning_rate: 0.5,
874 iterations: 10,
875 });
876
877 agg.ensure_stats(CandidateId(0));
879 agg.ensure_stats(CandidateId(1));
880 agg.ensure_stats(CandidateId(2));
881
882 agg.record_comparison(CandidateId(0), CandidateId(1));
883 agg.record_comparison(CandidateId(0), CandidateId(1));
884 agg.record_comparison(CandidateId(1), CandidateId(2));
885
886 let fitness = agg.recompute_all();
887
888 assert!(fitness[&CandidateId(0)] > fitness[&CandidateId(1)]);
890 assert!(fitness[&CandidateId(1)] > fitness[&CandidateId(2)]);
892 }
893
894 #[test]
895 fn test_bradley_terry_mle_recompute() {
896 use crate::interactive::bradley_terry::BradleyTerryOptimizer;
897
898 let mut agg = FitnessAggregator::new(AggregationModel::BradleyTerry {
899 initial_strength: 1.0,
900 optimizer: BradleyTerryOptimizer::default(),
901 });
902
903 agg.ensure_stats(CandidateId(0));
905 agg.ensure_stats(CandidateId(1));
906 agg.ensure_stats(CandidateId(2));
907
908 agg.record_comparison(CandidateId(0), CandidateId(1));
909 agg.record_comparison(CandidateId(0), CandidateId(1));
910 agg.record_comparison(CandidateId(1), CandidateId(2));
911
912 let fitness = agg.recompute_all();
913
914 assert!(fitness[&CandidateId(0)] > fitness[&CandidateId(1)]);
916 assert!(fitness[&CandidateId(1)] > fitness[&CandidateId(2)]);
918
919 let estimate_a = agg.get_fitness_estimate(&CandidateId(0)).unwrap();
921 assert!(estimate_a.variance.is_finite());
922 assert!(estimate_a.observation_count > 0);
923 }
924
925 #[test]
926 fn test_bradley_terry_process_pairwise_updates_fitness() {
927 use crate::interactive::bradley_terry::BradleyTerryOptimizer;
933 let mut agg = FitnessAggregator::new(AggregationModel::BradleyTerry {
934 initial_strength: 1.0,
935 optimizer: BradleyTerryOptimizer::default(),
936 });
937 let a = CandidateId(0);
938 let b = CandidateId(1);
939 let c = CandidateId(2);
940
941 for _ in 0..8 {
943 agg.process_pairwise(a, b, Some(a));
944 agg.process_pairwise(b, c, Some(b));
945 agg.process_pairwise(a, c, Some(a));
946 }
947
948 let fa = agg.get_fitness(&a).unwrap();
949 let fb = agg.get_fitness(&b).unwrap();
950 let fc = agg.get_fitness(&c).unwrap();
951 assert!(fa > fb, "A ({fa}) should outrank B ({fb})");
952 assert!(fb > fc, "B ({fb}) should outrank C ({fc})");
953 assert!((fa - fb).abs() > 1e-3);
955 assert!((fb - fc).abs() > 1e-3);
956 }
957
958 #[test]
959 fn test_implicit_ranking_variance_on_score_scale() {
960 let mut agg = FitnessAggregator::new(AggregationModel::ImplicitRanking {
963 selected_bonus: 2.0,
964 not_selected_penalty: 1.0,
965 base_fitness: 5.0,
966 });
967 let id = CandidateId(0);
968 agg.record_batch_selection(&[id], &[]);
970 agg.record_batch_selection(&[id], &[]);
971 agg.record_batch_selection(&[], &[id]);
972 agg.record_batch_selection(&[], &[id]);
973
974 let est = agg.get_fitness_estimate(&id).unwrap();
975 let slope = 2.0 + 1.0;
976 let expected = slope * slope * 4.0 * 0.5 * 0.5; assert!(
978 (est.variance - expected).abs() < 1e-9,
979 "got {}",
980 est.variance
981 );
982 assert!(est.variance > 1.0);
984 }
985
986 #[test]
987 fn test_elo_variance_has_positive_floor() {
988 let mut agg = FitnessAggregator::new(AggregationModel::Elo {
991 initial_rating: 1500.0,
992 k_factor: 32.0,
993 });
994 let a = CandidateId(0);
995 let b = CandidateId(1);
996 for _ in 0..200 {
997 agg.record_comparison(a, b);
998 }
999 let est = agg.get_fitness_estimate(&a).unwrap();
1000 let s = 400.0 / std::f64::consts::LN_10;
1001 let floor = 32.0 * s / 2.0;
1002 assert!(
1003 est.variance >= floor,
1004 "variance {} below floor {}",
1005 est.variance,
1006 floor
1007 );
1008 assert!(est.variance > 100.0);
1010 }
1011
1012 #[test]
1013 fn test_fitness_estimate_direct_rating() {
1014 let mut agg = FitnessAggregator::new(AggregationModel::DirectRating {
1015 default_rating: 5.0,
1016 });
1017
1018 let id = CandidateId(0);
1019 agg.ensure_stats(id);
1020
1021 let estimate = agg.get_fitness_estimate(&id).unwrap();
1023 assert_eq!(estimate.mean, 5.0);
1024 assert!(estimate.variance.is_infinite());
1025
1026 agg.record_rating(id, 8.0);
1028 agg.record_rating(id, 6.0);
1029 agg.record_rating(id, 7.0);
1030
1031 let estimate = agg.get_fitness_estimate(&id).unwrap();
1032 assert_eq!(estimate.mean, 7.0);
1033 assert!(estimate.variance.is_finite());
1034 assert_eq!(estimate.observation_count, 3);
1035 }
1036
1037 #[test]
1038 fn test_candidate_stats() {
1039 let mut stats = CandidateStats::new(1500.0);
1040
1041 stats.rating_sum = 24.0;
1043 stats.rating_count = 3;
1044 assert_eq!(stats.average_rating(), Some(8.0));
1045
1046 stats.wins = 3;
1048 stats.losses = 1;
1049 assert_eq!(stats.total_comparisons(), 4);
1050 assert_eq!(stats.win_rate(), Some(0.75));
1051
1052 stats.times_selected = 2;
1054 stats.times_passed = 3;
1055 assert_eq!(stats.selection_rate(), Some(0.4));
1056 }
1057
1058 #[test]
1059 fn test_process_response_ratings() {
1060 let mut agg = FitnessAggregator::new(AggregationModel::DirectRating {
1061 default_rating: 5.0,
1062 });
1063
1064 let response =
1065 EvaluationResponse::ratings(vec![(CandidateId(0), 8.0), (CandidateId(1), 6.0)]);
1066
1067 let updated = agg.process_response(&response);
1068
1069 assert_eq!(updated.len(), 2);
1070 assert!(updated
1071 .iter()
1072 .any(|(id, f)| *id == CandidateId(0) && *f == 8.0));
1073 assert!(updated
1074 .iter()
1075 .any(|(id, f)| *id == CandidateId(1) && *f == 6.0));
1076 }
1077
1078 #[test]
1079 fn test_process_batch_selection() {
1080 let mut agg = FitnessAggregator::new(AggregationModel::ImplicitRanking {
1081 selected_bonus: 1.0,
1082 not_selected_penalty: 0.5,
1083 base_fitness: 5.0,
1084 });
1085
1086 let all = vec![
1087 CandidateId(0),
1088 CandidateId(1),
1089 CandidateId(2),
1090 CandidateId(3),
1091 ];
1092 let selected = vec![CandidateId(0), CandidateId(2)];
1093
1094 let updated = agg.process_batch_selection(&all, &selected);
1095
1096 assert_eq!(updated.len(), 4);
1097
1098 let fitness_0 = updated
1100 .iter()
1101 .find(|(id, _)| *id == CandidateId(0))
1102 .unwrap()
1103 .1;
1104 assert_eq!(fitness_0, 6.0);
1105
1106 let fitness_1 = updated
1108 .iter()
1109 .find(|(id, _)| *id == CandidateId(1))
1110 .unwrap()
1111 .1;
1112 assert_eq!(fitness_1, 4.5);
1113 }
1114}