Skip to main content

fugue_evo/interactive/
bradley_terry.rs

1//! Bradley-Terry model implementation with Maximum Likelihood Estimation
2//!
3//! This module provides proper MLE-based Bradley-Terry model fitting with two
4//! optimization algorithms:
5//!
6//! - **Newton-Raphson**: Fast convergence, provides Fisher Information for uncertainty
7//! - **MM (Minorization-Maximization)**: Simple, guaranteed convergence, uses bootstrap for uncertainty
8//!
9//! # Bradley-Terry Model
10//!
11//! The Bradley-Terry model estimates the probability that candidate i beats candidate j as:
12//!
13//! ```text
14//! P(i beats j) = π_i / (π_i + π_j)
15//! ```
16//!
17//! where π_i is the "strength" parameter for candidate i.
18//!
19//! # Example
20//!
21//! ```rust,ignore
22//! use fugue_evo::interactive::bradley_terry::{BradleyTerryModel, BradleyTerryOptimizer};
23//!
24//! let comparisons = vec![
25//!     ComparisonRecord { winner: CandidateId(0), loser: CandidateId(1), generation: 0 },
26//!     ComparisonRecord { winner: CandidateId(0), loser: CandidateId(2), generation: 0 },
27//! ];
28//!
29//! let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
30//! let result = model.fit(&comparisons, &candidate_ids);
31//!
32//! let estimate = result.get_estimate(CandidateId(0));
33//! println!("Strength: {:.2} ± {:.2}", estimate.mean, estimate.std_error());
34//! ```
35
36use nalgebra::{DMatrix, DVector};
37use rand::prelude::*;
38use serde::{Deserialize, Serialize};
39use std::collections::HashMap;
40
41use super::aggregation::ComparisonRecord;
42use super::evaluator::CandidateId;
43use super::uncertainty::FitnessEstimate;
44
45/// Internal context for fitting operations
46///
47/// Groups common parameters for fit operations to reduce function argument count.
48struct FitContext<'a> {
49    comparisons: &'a [ComparisonRecord],
50    candidate_ids: &'a [CandidateId],
51    id_to_index: HashMap<CandidateId, usize>,
52    n: usize,
53}
54
55impl<'a> FitContext<'a> {
56    fn new(comparisons: &'a [ComparisonRecord], candidate_ids: &'a [CandidateId]) -> Self {
57        let id_to_index: HashMap<CandidateId, usize> = candidate_ids
58            .iter()
59            .enumerate()
60            .map(|(i, &id)| (id, i))
61            .collect();
62        let n = candidate_ids.len();
63        Self {
64            comparisons,
65            candidate_ids,
66            id_to_index,
67            n,
68        }
69    }
70}
71
72/// Bradley-Terry optimizer configuration
73#[derive(Clone, Debug, Serialize, Deserialize)]
74pub enum BradleyTerryOptimizer {
75    /// Newton-Raphson optimization with Fisher Information for uncertainty
76    ///
77    /// Faster convergence, provides analytical covariance matrix from
78    /// the inverse Fisher Information (negative Hessian).
79    NewtonRaphson {
80        /// Maximum iterations (default: 100)
81        max_iterations: usize,
82        /// Convergence tolerance for gradient norm (default: 1e-8)
83        tolerance: f64,
84        /// L2 regularization for Hessian stability (default: 1e-6)
85        regularization: f64,
86    },
87
88    /// MM (Minorization-Maximization) algorithm with bootstrap for uncertainty
89    ///
90    /// Simpler, guaranteed monotonic likelihood increase, uses bootstrap
91    /// resampling to estimate variance.
92    MM {
93        /// Maximum iterations (default: 100)
94        max_iterations: usize,
95        /// Convergence tolerance for parameter change (default: 1e-8)
96        tolerance: f64,
97        /// Number of bootstrap samples for variance estimation (default: 100)
98        bootstrap_samples: usize,
99    },
100}
101
102impl Default for BradleyTerryOptimizer {
103    fn default() -> Self {
104        Self::NewtonRaphson {
105            max_iterations: 100,
106            tolerance: 1e-6, // Relaxed for better convergence on small datasets
107            regularization: 1e-6,
108        }
109    }
110}
111
112impl BradleyTerryOptimizer {
113    /// Create Newton-Raphson optimizer with custom parameters
114    pub fn newton_raphson(max_iterations: usize, tolerance: f64, regularization: f64) -> Self {
115        Self::NewtonRaphson {
116            max_iterations,
117            tolerance,
118            regularization,
119        }
120    }
121
122    /// Create MM optimizer with custom parameters
123    pub fn mm(max_iterations: usize, tolerance: f64, bootstrap_samples: usize) -> Self {
124        Self::MM {
125            max_iterations,
126            tolerance,
127            bootstrap_samples,
128        }
129    }
130}
131
132/// Result of Bradley-Terry MLE optimization
133#[derive(Clone, Debug)]
134pub struct BradleyTerryResult {
135    /// Strength parameters (probability scale, sum to n)
136    pub strengths: HashMap<CandidateId, f64>,
137    /// Covariance matrix (from Fisher^-1 or bootstrap)
138    pub covariance: DMatrix<f64>,
139    /// Mapping from CandidateId to matrix index
140    pub id_to_index: HashMap<CandidateId, usize>,
141    /// Log-likelihood at solution
142    pub log_likelihood: f64,
143    /// Number of iterations to convergence
144    pub iterations: usize,
145    /// Did the algorithm converge?
146    pub converged: bool,
147    /// Final gradient norm (Newton-Raphson) or max parameter change (MM)
148    pub convergence_metric: f64,
149}
150
151impl BradleyTerryResult {
152    /// Get fitness estimate for a candidate with uncertainty
153    pub fn get_estimate(&self, id: CandidateId) -> Option<FitnessEstimate> {
154        let strength = *self.strengths.get(&id)?;
155        let idx = *self.id_to_index.get(&id)?;
156
157        // Variance is diagonal element of covariance matrix
158        let variance = if idx < self.covariance.nrows() {
159            self.covariance[(idx, idx)]
160        } else {
161            f64::INFINITY
162        };
163
164        // Count total comparisons involving this candidate
165        let observation_count = self.strengths.len(); // Approximate
166
167        Some(FitnessEstimate::new(strength, variance, observation_count))
168    }
169
170    /// Get all estimates as a map
171    pub fn all_estimates(&self) -> HashMap<CandidateId, FitnessEstimate> {
172        self.strengths
173            .keys()
174            .filter_map(|&id| self.get_estimate(id).map(|e| (id, e)))
175            .collect()
176    }
177
178    /// Predict probability that candidate a beats candidate b
179    pub fn predict_win_probability(&self, a: CandidateId, b: CandidateId) -> Option<f64> {
180        let pa = self.strengths.get(&a)?;
181        let pb = self.strengths.get(&b)?;
182        Some(pa / (pa + pb))
183    }
184}
185
186/// Bradley-Terry model for pairwise comparison data
187pub struct BradleyTerryModel {
188    optimizer: BradleyTerryOptimizer,
189}
190
191impl BradleyTerryModel {
192    /// Create a new Bradley-Terry model with specified optimizer
193    pub fn new(optimizer: BradleyTerryOptimizer) -> Self {
194        Self { optimizer }
195    }
196
197    /// Fit the model to comparison data
198    ///
199    /// # Arguments
200    ///
201    /// * `comparisons` - Historical pairwise comparison records
202    /// * `candidate_ids` - All candidate IDs to include (may include uncompared candidates)
203    ///
204    /// # Returns
205    ///
206    /// `BradleyTerryResult` with fitted strengths and uncertainty estimates
207    pub fn fit(
208        &self,
209        comparisons: &[ComparisonRecord],
210        candidate_ids: &[CandidateId],
211    ) -> BradleyTerryResult {
212        if candidate_ids.is_empty() || comparisons.is_empty() {
213            return self.empty_result(candidate_ids);
214        }
215
216        let ctx = FitContext::new(comparisons, candidate_ids);
217
218        match &self.optimizer {
219            BradleyTerryOptimizer::NewtonRaphson {
220                max_iterations,
221                tolerance,
222                regularization,
223            } => self.fit_newton_raphson(&ctx, *max_iterations, *tolerance, *regularization),
224            BradleyTerryOptimizer::MM {
225                max_iterations,
226                tolerance,
227                bootstrap_samples,
228            } => self.fit_mm(&ctx, *max_iterations, *tolerance, *bootstrap_samples),
229        }
230    }
231
232    /// Empty result for edge cases
233    fn empty_result(&self, candidate_ids: &[CandidateId]) -> BradleyTerryResult {
234        let n = candidate_ids.len();
235        let strengths: HashMap<CandidateId, f64> =
236            candidate_ids.iter().map(|&id| (id, 1.0)).collect();
237        let id_to_index: HashMap<CandidateId, usize> = candidate_ids
238            .iter()
239            .enumerate()
240            .map(|(i, &id)| (id, i))
241            .collect();
242
243        BradleyTerryResult {
244            strengths,
245            covariance: DMatrix::from_diagonal_element(n, n, f64::INFINITY),
246            id_to_index,
247            log_likelihood: 0.0,
248            iterations: 0,
249            converged: true,
250            convergence_metric: 0.0,
251        }
252    }
253
254    /// Newton-Raphson optimization
255    ///
256    /// Uses log-parameterization: θ_i = log(π_i)
257    /// This makes the optimization unconstrained.
258    fn fit_newton_raphson(
259        &self,
260        ctx: &FitContext,
261        max_iterations: usize,
262        tolerance: f64,
263        regularization: f64,
264    ) -> BradleyTerryResult {
265        let n = ctx.n;
266        let comparisons = ctx.comparisons;
267        let candidate_ids = ctx.candidate_ids;
268        let id_to_index = &ctx.id_to_index;
269        // Initialize log-strengths to zero
270        let mut theta = DVector::zeros(n);
271
272        // Count wins for each candidate
273        let mut wins = vec![0usize; n];
274        for comp in comparisons {
275            if let Some(&idx) = id_to_index.get(&comp.winner) {
276                wins[idx] += 1;
277            }
278        }
279
280        let mut converged = false;
281        let mut iterations = 0;
282        let mut gradient_norm = f64::INFINITY;
283
284        for iter in 0..max_iterations {
285            iterations = iter + 1;
286
287            // Compute gradient and Hessian
288            let mut gradient = DVector::zeros(n);
289            let mut hessian = DMatrix::zeros(n, n);
290
291            // Gradient: g_i = wins_i - Σ_j n_ij * σ(θ_i - θ_j)
292            // Hessian: H_ii = -Σ_j n_ij * σ(θ_i - θ_j) * (1 - σ(θ_i - θ_j))
293            //          H_ij = n_ij * σ(θ_i - θ_j) * (1 - σ(θ_i - θ_j))
294
295            for comp in comparisons {
296                let i = match id_to_index.get(&comp.winner) {
297                    Some(&idx) => idx,
298                    None => continue,
299                };
300                let j = match id_to_index.get(&comp.loser) {
301                    Some(&idx) => idx,
302                    None => continue,
303                };
304
305                // σ(θ_i - θ_j) = P(i beats j)
306                let diff = theta[i] - theta[j];
307                let p = sigmoid(diff);
308                let q = 1.0 - p; // P(j beats i)
309
310                // Gradient contributions
311                gradient[i] += q; // = 1 - p
312                gradient[j] -= q; // = -(1 - p) = p - 1
313
314                // Hessian contributions (second derivatives of log-likelihood)
315                let h = p * q;
316                hessian[(i, i)] -= h;
317                hessian[(j, j)] -= h;
318                hessian[(i, j)] += h;
319                hessian[(j, i)] += h;
320            }
321
322            // Add regularization to diagonal
323            for i in 0..n {
324                hessian[(i, i)] -= regularization;
325            }
326
327            // Check convergence
328            gradient_norm = gradient.norm();
329            if gradient_norm < tolerance {
330                converged = true;
331                break;
332            }
333
334            // Newton step: δ = -H^{-1} * g
335            // Use LU decomposition for solving
336            let neg_hessian = -&hessian;
337            let delta = match neg_hessian.clone().lu().solve(&gradient) {
338                Some(d) => d,
339                None => {
340                    // Hessian is singular, add more regularization
341                    let mut reg_hessian = neg_hessian;
342                    for i in 0..n {
343                        reg_hessian[(i, i)] += regularization * 10.0;
344                    }
345                    match reg_hessian.lu().solve(&gradient) {
346                        Some(d) => d,
347                        None => break, // Give up
348                    }
349                }
350            };
351
352            // Update with line search backtracking for stability
353            let mut step_size = 1.0;
354            let current_ll = self.log_likelihood(&theta, comparisons, id_to_index);
355
356            for _ in 0..10 {
357                let new_theta = &theta + step_size * &delta;
358                let new_ll = self.log_likelihood(&new_theta, comparisons, id_to_index);
359
360                if new_ll > current_ll - 1e-4 * step_size * gradient.dot(&delta) {
361                    theta = new_theta;
362                    break;
363                }
364                step_size *= 0.5;
365            }
366
367            // Normalize (subtract mean for identifiability)
368            let mean_theta = theta.mean();
369            theta -= DVector::from_element(n, mean_theta);
370        }
371
372        // Convert to probability scale
373        let strengths: HashMap<CandidateId, f64> = candidate_ids
374            .iter()
375            .enumerate()
376            .map(|(i, &id)| (id, theta[i].exp()))
377            .collect();
378
379        // Covariance from inverse negative Hessian (Fisher Information)
380        let mut final_hessian = DMatrix::zeros(n, n);
381        for comp in comparisons {
382            let i = match id_to_index.get(&comp.winner) {
383                Some(&idx) => idx,
384                None => continue,
385            };
386            let j = match id_to_index.get(&comp.loser) {
387                Some(&idx) => idx,
388                None => continue,
389            };
390
391            let p = sigmoid(theta[i] - theta[j]);
392            let h = p * (1.0 - p);
393
394            final_hessian[(i, i)] -= h;
395            final_hessian[(j, j)] -= h;
396            final_hessian[(i, j)] += h;
397            final_hessian[(j, i)] += h;
398        }
399
400        // Add regularization
401        for i in 0..n {
402            final_hessian[(i, i)] -= regularization;
403        }
404
405        // Covariance = -H^{-1}
406        let covariance = match (-&final_hessian).clone().try_inverse() {
407            Some(inv) => inv,
408            None => DMatrix::from_diagonal_element(n, n, f64::INFINITY),
409        };
410
411        let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
412
413        BradleyTerryResult {
414            strengths,
415            covariance,
416            id_to_index: id_to_index.clone(),
417            log_likelihood,
418            iterations,
419            converged,
420            convergence_metric: gradient_norm,
421        }
422    }
423
424    /// MM algorithm optimization
425    fn fit_mm(
426        &self,
427        ctx: &FitContext,
428        max_iterations: usize,
429        tolerance: f64,
430        bootstrap_samples: usize,
431    ) -> BradleyTerryResult {
432        let n = ctx.n;
433        let comparisons = ctx.comparisons;
434        let candidate_ids = ctx.candidate_ids;
435        let id_to_index = &ctx.id_to_index;
436
437        // Fit point estimates
438        let (pi, iterations, converged, max_change) =
439            self.mm_core(comparisons, id_to_index, n, max_iterations, tolerance);
440
441        // Bootstrap for variance estimation
442        let covariance =
443            self.bootstrap_covariance(ctx, max_iterations, tolerance, bootstrap_samples, &pi);
444
445        // Convert to HashMap
446        let strengths: HashMap<CandidateId, f64> = candidate_ids
447            .iter()
448            .enumerate()
449            .map(|(i, &id)| (id, pi[i]))
450            .collect();
451
452        // Compute log-likelihood
453        let theta: DVector<f64> = pi.iter().map(|&p| p.ln()).collect::<Vec<_>>().into();
454        let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
455
456        BradleyTerryResult {
457            strengths,
458            covariance,
459            id_to_index: id_to_index.clone(),
460            log_likelihood,
461            iterations,
462            converged,
463            convergence_metric: max_change,
464        }
465    }
466
467    /// Core MM iteration
468    fn mm_core(
469        &self,
470        comparisons: &[ComparisonRecord],
471        id_to_index: &HashMap<CandidateId, usize>,
472        n: usize,
473        max_iterations: usize,
474        tolerance: f64,
475    ) -> (Vec<f64>, usize, bool, f64) {
476        // Initialize strengths uniformly
477        let mut pi = vec![1.0; n];
478
479        // Count wins
480        let mut wins = vec![0usize; n];
481        for comp in comparisons {
482            if let Some(&idx) = id_to_index.get(&comp.winner) {
483                wins[idx] += 1;
484            }
485        }
486
487        let mut converged = false;
488        let mut iterations = 0;
489        let mut max_change = f64::INFINITY;
490
491        for iter in 0..max_iterations {
492            iterations = iter + 1;
493            let mut pi_new = vec![0.0; n];
494
495            for i in 0..n {
496                if wins[i] == 0 {
497                    // Candidate with no wins - use small regularized value
498                    pi_new[i] = 0.01;
499                    continue;
500                }
501
502                // Compute denominator: Σ_j n_ij / (π_i + π_j)
503                let mut denom = 0.0;
504                for comp in comparisons {
505                    let w_idx = id_to_index.get(&comp.winner).copied();
506                    let l_idx = id_to_index.get(&comp.loser).copied();
507
508                    match (w_idx, l_idx) {
509                        (Some(wi), Some(li)) if wi == i || li == i => {
510                            let other = if wi == i { li } else { wi };
511                            denom += 1.0 / (pi[i] + pi[other]);
512                        }
513                        _ => {}
514                    }
515                }
516
517                if denom > 0.0 {
518                    pi_new[i] = wins[i] as f64 / denom;
519                } else {
520                    pi_new[i] = pi[i]; // No comparisons, keep current
521                }
522            }
523
524            // Normalize so strengths sum to n (arbitrary but stable)
525            let sum: f64 = pi_new.iter().sum();
526            if sum > 0.0 {
527                for p in &mut pi_new {
528                    *p *= n as f64 / sum;
529                }
530            }
531
532            // Check convergence
533            max_change = pi
534                .iter()
535                .zip(pi_new.iter())
536                .map(|(a, b)| (a - b).abs())
537                .fold(0.0, f64::max);
538
539            if max_change < tolerance {
540                converged = true;
541                pi = pi_new;
542                break;
543            }
544
545            pi = pi_new;
546        }
547
548        (pi, iterations, converged, max_change)
549    }
550
551    /// Bootstrap resampling for variance estimation
552    fn bootstrap_covariance(
553        &self,
554        ctx: &FitContext,
555        max_iterations: usize,
556        tolerance: f64,
557        bootstrap_samples: usize,
558        point_estimate: &[f64],
559    ) -> DMatrix<f64> {
560        let n = ctx.n;
561        let comparisons = ctx.comparisons;
562        let id_to_index = &ctx.id_to_index;
563
564        if bootstrap_samples == 0 || comparisons.is_empty() {
565            return DMatrix::from_diagonal_element(n, n, f64::INFINITY);
566        }
567
568        let mut rng = rand::thread_rng();
569        let mut bootstrap_estimates: Vec<Vec<f64>> = Vec::with_capacity(bootstrap_samples);
570
571        for _ in 0..bootstrap_samples {
572            // Resample comparisons with replacement
573            let resampled: Vec<ComparisonRecord> = (0..comparisons.len())
574                .map(|_| comparisons[rng.gen_range(0..comparisons.len())].clone())
575                .collect();
576
577            // Fit to resampled data
578            let (pi, _, _, _) = self.mm_core(&resampled, id_to_index, n, max_iterations, tolerance);
579            bootstrap_estimates.push(pi);
580        }
581
582        // Compute covariance matrix from bootstrap samples
583        let mut covariance = DMatrix::zeros(n, n);
584
585        for i in 0..n {
586            for j in 0..n {
587                let mean_i = point_estimate[i];
588                let mean_j = point_estimate[j];
589
590                let cov: f64 = bootstrap_estimates
591                    .iter()
592                    .map(|est| (est[i] - mean_i) * (est[j] - mean_j))
593                    .sum::<f64>()
594                    / (bootstrap_samples - 1).max(1) as f64;
595
596                covariance[(i, j)] = cov;
597            }
598        }
599
600        covariance
601    }
602
603    /// Compute log-likelihood
604    fn log_likelihood(
605        &self,
606        theta: &DVector<f64>,
607        comparisons: &[ComparisonRecord],
608        id_to_index: &HashMap<CandidateId, usize>,
609    ) -> f64 {
610        let mut ll = 0.0;
611
612        for comp in comparisons {
613            let i = match id_to_index.get(&comp.winner) {
614                Some(&idx) => idx,
615                None => continue,
616            };
617            let j = match id_to_index.get(&comp.loser) {
618                Some(&idx) => idx,
619                None => continue,
620            };
621
622            // log P(i beats j) = log(σ(θ_i - θ_j)) = θ_i - θ_j - log(1 + exp(θ_i - θ_j))
623            let diff = theta[i] - theta[j];
624            ll += log_sigmoid(diff);
625        }
626
627        ll
628    }
629}
630
631/// Sigmoid function: σ(x) = 1 / (1 + exp(-x))
632fn sigmoid(x: f64) -> f64 {
633    if x >= 0.0 {
634        1.0 / (1.0 + (-x).exp())
635    } else {
636        let ex = x.exp();
637        ex / (1.0 + ex)
638    }
639}
640
641/// Log sigmoid: log(σ(x)) = -log(1 + exp(-x))
642fn log_sigmoid(x: f64) -> f64 {
643    if x >= 0.0 {
644        -(-x).exp().ln_1p()
645    } else {
646        x - x.exp().ln_1p()
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn make_comparisons(pairs: &[(usize, usize)]) -> Vec<ComparisonRecord> {
655        pairs
656            .iter()
657            .map(|&(w, l)| ComparisonRecord {
658                winner: CandidateId(w),
659                loser: CandidateId(l),
660                generation: 0,
661            })
662            .collect()
663    }
664
665    #[test]
666    fn test_newton_raphson_basic() {
667        // Simple case: A beats B twice, B beats C twice
668        let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
669        let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
670
671        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
672        let result = model.fit(&comparisons, &candidate_ids);
673
674        assert!(result.converged);
675
676        // A should be strongest, C weakest
677        let pa = result.strengths[&CandidateId(0)];
678        let pb = result.strengths[&CandidateId(1)];
679        let pc = result.strengths[&CandidateId(2)];
680
681        assert!(pa > pb);
682        assert!(pb > pc);
683    }
684
685    #[test]
686    fn test_mm_basic() {
687        let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
688        let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
689
690        let model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 50));
691        let result = model.fit(&comparisons, &candidate_ids);
692
693        assert!(result.converged);
694
695        let pa = result.strengths[&CandidateId(0)];
696        let pb = result.strengths[&CandidateId(1)];
697        let pc = result.strengths[&CandidateId(2)];
698
699        assert!(pa > pb);
700        assert!(pb > pc);
701    }
702
703    #[test]
704    fn test_newton_raphson_and_mm_agree() {
705        let comparisons = make_comparisons(&[
706            (0, 1),
707            (0, 2),
708            (1, 2),
709            (0, 1),
710            (1, 0),
711            (2, 1),
712            (0, 2),
713            (0, 2),
714        ]);
715        let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
716
717        let nr_model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
718        let mm_model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 0));
719
720        let nr_result = nr_model.fit(&comparisons, &candidate_ids);
721        let mm_result = mm_model.fit(&comparisons, &candidate_ids);
722
723        // Rankings should agree
724        let nr_ranking: Vec<_> = {
725            let mut r: Vec<_> = candidate_ids
726                .iter()
727                .map(|&id| (id, nr_result.strengths[&id]))
728                .collect();
729            r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
730            r.into_iter().map(|(id, _)| id).collect()
731        };
732
733        let mm_ranking: Vec<_> = {
734            let mut r: Vec<_> = candidate_ids
735                .iter()
736                .map(|&id| (id, mm_result.strengths[&id]))
737                .collect();
738            r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
739            r.into_iter().map(|(id, _)| id).collect()
740        };
741
742        assert_eq!(nr_ranking, mm_ranking);
743    }
744
745    #[test]
746    fn test_get_estimate() {
747        let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1)]);
748        let candidate_ids = vec![CandidateId(0), CandidateId(1)];
749
750        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
751        let result = model.fit(&comparisons, &candidate_ids);
752
753        let estimate = result.get_estimate(CandidateId(0)).unwrap();
754        assert!(estimate.variance < f64::INFINITY);
755        assert!(estimate.variance > 0.0);
756    }
757
758    #[test]
759    fn test_predict_win_probability() {
760        let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1)]);
761        let candidate_ids = vec![CandidateId(0), CandidateId(1)];
762
763        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
764        let result = model.fit(&comparisons, &candidate_ids);
765
766        let p = result
767            .predict_win_probability(CandidateId(0), CandidateId(1))
768            .unwrap();
769        assert!(p > 0.5); // A should be favored
770        assert!(p < 1.0);
771    }
772
773    #[test]
774    fn test_empty_comparisons() {
775        let comparisons: Vec<ComparisonRecord> = vec![];
776        let candidate_ids = vec![CandidateId(0), CandidateId(1)];
777
778        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
779        let result = model.fit(&comparisons, &candidate_ids);
780
781        // Should return uniform strengths with infinite variance
782        assert!(result.converged);
783        assert!(result.covariance[(0, 0)].is_infinite());
784    }
785
786    #[test]
787    fn test_sigmoid() {
788        assert!((sigmoid(0.0) - 0.5).abs() < 1e-9);
789        assert!(sigmoid(100.0) > 0.999);
790        assert!(sigmoid(-100.0) < 0.001);
791
792        // Symmetry: σ(-x) = 1 - σ(x)
793        for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
794            assert!((sigmoid(-x) - (1.0 - sigmoid(x))).abs() < 1e-9);
795        }
796    }
797
798    #[test]
799    fn test_log_sigmoid() {
800        // log(σ(x)) should be negative
801        for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
802            assert!(log_sigmoid(x) <= 0.0);
803            assert!((log_sigmoid(x).exp() - sigmoid(x)).abs() < 1e-9);
804        }
805    }
806
807    #[test]
808    fn test_covariance_positive_semidefinite() {
809        let comparisons = make_comparisons(&[(0, 1), (0, 2), (1, 2), (0, 1), (1, 2), (0, 2)]);
810        let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
811
812        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
813        let result = model.fit(&comparisons, &candidate_ids);
814
815        // Diagonal should be non-negative
816        for i in 0..3 {
817            assert!(result.covariance[(i, i)] >= 0.0);
818        }
819    }
820}