Skip to main content

fugue_evo/interactive/
aggregation.rs

1//! Fitness aggregation models for interactive evaluation
2//!
3//! This module provides various statistical models for converting user feedback
4//! (ratings, comparisons, selections) into fitness values suitable for evolution.
5//!
6//! # Available Models
7//!
8//! - **DirectRating**: Simple average of user ratings
9//! - **Elo**: Classic Elo rating system from pairwise comparisons
10//! - **BradleyTerry**: Maximum likelihood estimation for pairwise data
11//! - **ImplicitRanking**: Bonus/penalty system from batch selections
12//!
13//! # Uncertainty Quantification
14//!
15//! All models support uncertainty estimation via `get_fitness_estimate()`,
16//! which returns a `FitnessEstimate` with variance and confidence intervals.
17
18use 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/// Aggregation model for converting user feedback to fitness
26#[derive(Clone, Debug, Serialize, Deserialize)]
27pub enum AggregationModel {
28    /// Direct rating average
29    ///
30    /// Simply averages all ratings received for each candidate.
31    /// Uses default_rating for candidates with no ratings.
32    DirectRating {
33        /// Default rating for unevaluated candidates
34        default_rating: f64,
35    },
36
37    /// Elo rating system
38    ///
39    /// Classic chess-style rating from pairwise comparisons.
40    /// Good for transitive preference modeling.
41    Elo {
42        /// Initial rating for new candidates
43        initial_rating: f64,
44        /// K-factor controlling rating volatility
45        k_factor: f64,
46    },
47
48    /// Bradley-Terry model
49    ///
50    /// Maximum likelihood estimation for pairwise comparison data.
51    /// Provides more statistically principled estimates than Elo.
52    /// Now supports proper MLE with Newton-Raphson or MM algorithms.
53    BradleyTerry {
54        /// Initial strength parameter
55        initial_strength: f64,
56        /// Optimizer configuration (Newton-Raphson or MM)
57        #[serde(default)]
58        optimizer: BradleyTerryOptimizer,
59    },
60
61    /// Legacy Bradley-Terry model (for backward compatibility)
62    ///
63    /// Uses the simplified iterative MM approach from earlier versions.
64    #[serde(alias = "BradleyTerryLegacy")]
65    BradleyTerrySimple {
66        /// Initial strength parameter
67        initial_strength: f64,
68        /// Learning rate for iterative updates
69        learning_rate: f64,
70        /// Number of iterations
71        iterations: usize,
72    },
73
74    /// Implicit ranking from batch selections
75    ///
76    /// Assigns bonuses to selected candidates and penalties to
77    /// non-selected candidates in each batch.
78    ImplicitRanking {
79        /// Fitness bonus for being selected
80        selected_bonus: f64,
81        /// Fitness penalty for not being selected
82        not_selected_penalty: f64,
83        /// Base fitness for all candidates
84        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/// Statistics tracked for each candidate
97#[derive(Clone, Debug, Default, Serialize, Deserialize)]
98pub struct CandidateStats {
99    /// Sum of all ratings received
100    pub rating_sum: f64,
101    /// Sum of squared ratings (for variance calculation)
102    #[serde(default)]
103    pub rating_sum_squares: f64,
104    /// Count of ratings received
105    pub rating_count: usize,
106    /// Current model-based score (Elo, Bradley-Terry strength, etc.)
107    pub model_score: f64,
108    /// Variance of the model score (for uncertainty quantification)
109    #[serde(default = "default_variance")]
110    pub model_variance: f64,
111    /// Number of wins in pairwise comparisons
112    pub wins: usize,
113    /// Number of losses in pairwise comparisons
114    pub losses: usize,
115    /// Number of ties in pairwise comparisons
116    pub ties: usize,
117    /// Times selected in batch selection
118    pub times_selected: usize,
119    /// Times presented but not selected
120    pub times_passed: usize,
121}
122
123fn default_variance() -> f64 {
124    f64::INFINITY
125}
126
127impl CandidateStats {
128    /// Create new stats with the given initial model score
129    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    /// Get the average rating (or None if no ratings)
138    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    /// Get the sample variance of ratings
147    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        // Var = E[X²] - E[X]²
154        let var = (self.rating_sum_squares / n) - (mean * mean);
155        // Convert to sample variance (Bessel's correction)
156        Some(var * n / (n - 1.0))
157    }
158
159    /// Get the variance of the mean (standard error squared)
160    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    /// Get total number of comparisons
166    pub fn total_comparisons(&self) -> usize {
167        self.wins + self.losses + self.ties
168    }
169
170    /// Get win rate (0.0 to 1.0)
171    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    /// Get selection rate (0.0 to 1.0)
181    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/// Record of a pairwise comparison
192#[derive(Clone, Debug, Serialize, Deserialize)]
193pub struct ComparisonRecord {
194    /// Winner's ID
195    pub winner: CandidateId,
196    /// Loser's ID
197    pub loser: CandidateId,
198    /// Generation when comparison occurred
199    pub generation: usize,
200}
201
202/// Aggregates partial/incremental feedback into fitness estimates
203#[derive(Clone, Debug, Serialize, Deserialize)]
204pub struct FitnessAggregator {
205    /// The aggregation model to use
206    model: AggregationModel,
207    /// Per-candidate statistics
208    candidate_stats: HashMap<CandidateId, CandidateStats>,
209    /// History of pairwise comparisons (for Bradley-Terry updates)
210    comparisons: Vec<ComparisonRecord>,
211    /// Current generation
212    current_generation: usize,
213}
214
215impl FitnessAggregator {
216    /// Create a new aggregator with the given model
217    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    /// Get the aggregation model
227    pub fn model(&self) -> &AggregationModel {
228        &self.model
229    }
230
231    /// Set the current generation
232    pub fn set_generation(&mut self, generation: usize) {
233        self.current_generation = generation;
234    }
235
236    /// Ensure a candidate has stats initialized
237    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    /// Get stats for a candidate
256    pub fn get_stats(&self, id: &CandidateId) -> Option<&CandidateStats> {
257        self.candidate_stats.get(id)
258    }
259
260    /// Get current fitness estimate for a candidate (point estimate only)
261    ///
262    /// For uncertainty information, use `get_fitness_estimate()` instead.
263    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                // Score is base + cumulative bonuses/penalties
275                stats.model_score
276            }
277        })
278    }
279
280    /// Get fitness estimate with uncertainty quantification
281    ///
282    /// Returns a `FitnessEstimate` containing the point estimate, variance,
283    /// and confidence intervals.
284    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                // EV-98: an Elo rating is a constant-step stochastic-approximation
299                // (EWMA-style) estimate, NOT an average of `n` Bernoulli draws, so
300                // its uncertainty does not decay to 0 as 1/n. Linearizing the
301                // update near equilibrium gives a stationary random-walk variance
302                // floor of `k·s/2` on the RATING scale, where `s = 400/ln(10)` is
303                // the logistic rating scale. We report that floor plus a 1/n
304                // transient, both in rating² units (the same scale as the reported
305                // rating mean), so the variance shrinks with games toward a
306                // positive floor rather than spuriously toward 0.
307                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; // rating² floor
313                    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                // Use stored variance from MLE computation
319                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                    // Fallback: approximate variance
326                    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                // EV-98: `model_score = base + bonus·S − penalty·(n−S)
336                //                    = C + (bonus + penalty)·S`, a linear map of the
337                // selection count `S ~ Binomial(n, p)`. Propagate the count
338                // variance through that map so the reported variance is on the SAME
339                // (score) scale as the reported mean, instead of the raw [0,1]
340                // selection-proportion variance `p(1−p)/n` used previously:
341                //   Var(score) = (bonus + penalty)²·n·p·(1−p).
342                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    /// Get access to comparison records (for Bradley-Terry MLE)
356    pub fn comparisons(&self) -> &[ComparisonRecord] {
357        &self.comparisons
358    }
359
360    /// Record a rating for a candidate
361    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    /// Record a pairwise comparison result
371    pub fn record_comparison(&mut self, winner: CandidateId, loser: CandidateId) {
372        self.ensure_stats(winner);
373        self.ensure_stats(loser);
374
375        // Update stats
376        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        // Record comparison for history
384        self.comparisons.push(ComparisonRecord {
385            winner,
386            loser,
387            generation: self.current_generation,
388        });
389
390        // Update model scores
391        match &self.model {
392            AggregationModel::Elo { k_factor, .. } => {
393                self.update_elo(winner, loser, *k_factor);
394            }
395            AggregationModel::BradleyTerry { .. } => {
396                // Bradley-Terry updates are batched via recompute_all()
397            }
398            _ => {}
399        }
400    }
401
402    /// Record a tie in pairwise comparison
403    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        // For Elo, treat tie as half-win each
415        if let AggregationModel::Elo { k_factor, .. } = &self.model {
416            self.update_elo_draw(id_a, id_b, *k_factor);
417        }
418    }
419
420    /// Record batch selection results
421    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 other models, just track selection counts
452            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    /// Update Elo ratings after a comparison
468    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        // Expected scores
481        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        // Update ratings
485        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    /// Update Elo ratings after a draw
494    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        // Expected scores
507        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        // Update ratings (actual = 0.5 for draw)
511        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    /// Recompute all fitness estimates from comparison history
520    ///
521    /// This is useful for Bradley-Terry model which uses batch MLE,
522    /// or after loading a session from checkpoint.
523    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        // Return current fitness estimates
539        self.candidate_stats
540            .keys()
541            .filter_map(|id| self.get_fitness(id).map(|f| (*id, f)))
542            .collect()
543    }
544
545    /// Recompute Bradley-Terry using proper MLE (Newton-Raphson or MM)
546    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        // Update stats with MLE results
556        for (&id, &strength) in &result.strengths {
557            if let Some(stats) = self.candidate_stats.get_mut(&id) {
558                stats.model_score = strength;
559
560                // Update variance from covariance matrix
561                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    /// Recompute Bradley-Terry using simplified iterative MM (legacy)
571    fn recompute_bradley_terry_simple(
572        &mut self,
573        initial_strength: f64,
574        learning_rate: f64,
575        iterations: usize,
576    ) {
577        // Initialize strengths
578        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        // Iterative MM algorithm for Bradley-Terry
586        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                // Compute denominator: sum of 1/(p_i + p_j) over all comparisons
602                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                    // Smooth update with learning rate
624                    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)); // Avoid zero strength
630            }
631
632            // Apply new scores
633            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    /// Process an evaluation response and return updated fitness values
642    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                // We need both IDs to record a comparison
655                // For now, just return the winner's fitness
656                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                // Tie - nothing to update without both IDs
665                vec![]
666            }
667            EvaluationResponse::BatchSelected(selected) => {
668                // Update selection counts
669                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    /// Process a pairwise comparison with both candidate IDs
690    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                // Winner ID doesn't match either candidate
705            }
706            None => {
707                self.record_tie(id_a, id_b);
708            }
709        }
710
711        // EV-06: Bradley-Terry strengths are fit by a batch MLE, so a single
712        // comparison changes NO score until the model is re-fit. Do it here,
713        // immediately after recording and BEFORE any fitness read, so the live
714        // interactive loop actually exerts selection pressure from pairwise
715        // feedback (previously `recompute_all()` ran only in tests, leaving every
716        // candidate frozen at its initial strength).
717        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    /// Process batch selection with full context
731    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, &not_selected);
744
745        all_candidates
746            .iter()
747            .filter_map(|id| self.get_fitness(id).map(|f| (*id, f)))
748            .collect()
749    }
750
751    /// Get all candidate IDs with fitness estimates
752    pub fn all_candidates(&self) -> Vec<CandidateId> {
753        self.candidate_stats.keys().copied().collect()
754    }
755
756    /// Get the number of comparisons recorded
757    pub fn comparison_count(&self) -> usize {
758        self.comparisons.len()
759    }
760
761    /// Clear all recorded data
762    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        // Initially should return default
781        agg.ensure_stats(id);
782        assert_eq!(agg.get_fitness(&id), Some(5.0));
783
784        // After rating
785        agg.record_rating(id, 8.0);
786        assert_eq!(agg.get_fitness(&id), Some(8.0));
787
788        // After second rating, should average
789        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        // Initial ratings should be equal
807        assert_eq!(agg.get_fitness(&id_a), Some(1500.0));
808        assert_eq!(agg.get_fitness(&id_b), Some(1500.0));
809
810        // After A beats B
811        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        // Winner should gain rating
817        assert!(fitness_a > 1500.0);
818        // Loser should lose rating
819        assert!(fitness_b < 1500.0);
820        // Total rating should be conserved
821        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        // After tie between equal players, ratings should stay the same
838        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, &not_selected);
859
860        // Selected candidates should have bonus
861        assert_eq!(agg.get_fitness(&CandidateId(0)), Some(6.0));
862        assert_eq!(agg.get_fitness(&CandidateId(1)), Some(6.0));
863
864        // Not selected should have penalty
865        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        // A beats B multiple times, B beats C
878        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        // A should have highest strength
889        assert!(fitness[&CandidateId(0)] > fitness[&CandidateId(1)]);
890        // B should beat C
891        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        // A beats B multiple times, B beats C
904        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        // A should have highest strength
915        assert!(fitness[&CandidateId(0)] > fitness[&CandidateId(1)]);
916        // B should beat C
917        assert!(fitness[&CandidateId(1)] > fitness[&CandidateId(2)]);
918
919        // MLE should also provide variance estimates
920        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        // regression: EV-06 — recording a comparison via the LIVE entry point
928        // (`process_pairwise`) must re-fit the Bradley-Terry MLE so fitness
929        // reflects it. Pre-fix, `process_pairwise` left every candidate frozen at
930        // its initial strength (recompute_all ran only in tests), so pairwise
931        // feedback exerted zero selection pressure.
932        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        // A beats B beats C (plus A beats C), repeatedly, via the live path only.
942        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        // Strengths must be meaningfully separated, not all == initial_strength.
954        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        // regression: EV-98 — ImplicitRanking variance must be on the score scale
961        // (bonus+penalty)²·n·p·(1−p), not the [0,1] proportion variance p(1−p)/n.
962        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        // Presented 4 times: selected twice, passed twice -> p = 0.5, n = 4.
969        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; // = 9.0
977        assert!(
978            (est.variance - expected).abs() < 1e-9,
979            "got {}",
980            est.variance
981        );
982        // The old proportion formula would have given 0.5·0.5/4 = 0.0625.
983        assert!(est.variance > 1.0);
984    }
985
986    #[test]
987    fn test_elo_variance_has_positive_floor() {
988        // regression: EV-98 — Elo variance must approach a positive steady-state
989        // floor (k·s/2 on the rating scale), not decay toward 0 as 1/n_games.
990        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        // The old formula k²·0.25/n = 256/200 ≈ 1.28 would be far below the floor.
1009        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        // Initially should be uninformative
1022        let estimate = agg.get_fitness_estimate(&id).unwrap();
1023        assert_eq!(estimate.mean, 5.0);
1024        assert!(estimate.variance.is_infinite());
1025
1026        // After ratings, should have finite variance
1027        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        // Test rating tracking
1042        stats.rating_sum = 24.0;
1043        stats.rating_count = 3;
1044        assert_eq!(stats.average_rating(), Some(8.0));
1045
1046        // Test win rate
1047        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        // Test selection rate
1053        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        // Check selected got bonus
1099        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        // Check not selected got penalty
1107        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}