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
22//! use fugue_evo::interactive::aggregation::ComparisonRecord;
23//! use fugue_evo::interactive::bradley_terry::{BradleyTerryModel, BradleyTerryOptimizer};
24//! use fugue_evo::interactive::evaluator::CandidateId;
25//!
26//! let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
27//! let comparisons = vec![
28//!     ComparisonRecord { winner: CandidateId(0), loser: CandidateId(1), generation: 0 },
29//!     ComparisonRecord { winner: CandidateId(0), loser: CandidateId(2), generation: 0 },
30//! ];
31//!
32//! let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
33//! let result = model.fit(&comparisons, &candidate_ids);
34//!
35//! let estimate = result.get_estimate(CandidateId(0)).expect("candidate 0 was fitted");
36//! println!("Strength: {:.2} ± {:.2}", estimate.mean, estimate.std_error());
37//! ```
38
39use nalgebra::{DMatrix, DVector};
40use rand::prelude::*;
41use serde::{Deserialize, Serialize};
42use std::collections::HashMap;
43
44use super::aggregation::ComparisonRecord;
45use super::evaluator::CandidateId;
46use super::uncertainty::FitnessEstimate;
47
48/// Internal context for fitting operations
49///
50/// Groups common parameters for fit operations to reduce function argument count.
51struct FitContext<'a> {
52    comparisons: &'a [ComparisonRecord],
53    candidate_ids: &'a [CandidateId],
54    id_to_index: HashMap<CandidateId, usize>,
55    n: usize,
56}
57
58impl<'a> FitContext<'a> {
59    fn new(comparisons: &'a [ComparisonRecord], candidate_ids: &'a [CandidateId]) -> Self {
60        let id_to_index: HashMap<CandidateId, usize> = candidate_ids
61            .iter()
62            .enumerate()
63            .map(|(i, &id)| (id, i))
64            .collect();
65        let n = candidate_ids.len();
66        Self {
67            comparisons,
68            candidate_ids,
69            id_to_index,
70            n,
71        }
72    }
73}
74
75/// Bradley-Terry optimizer configuration
76#[derive(Clone, Debug, Serialize, Deserialize)]
77pub enum BradleyTerryOptimizer {
78    /// Newton-Raphson optimization with Fisher Information for uncertainty
79    ///
80    /// Faster convergence, provides analytical covariance matrix from
81    /// the inverse Fisher Information (negative Hessian).
82    NewtonRaphson {
83        /// Maximum iterations (default: 100)
84        max_iterations: usize,
85        /// Convergence tolerance for gradient norm (default: 1e-8)
86        tolerance: f64,
87        /// Gaussian prior precision on the log-strengths (L2 penalty
88        /// coefficient λ, default: 0.1).
89        ///
90        /// This is a genuine MAP prior: the penalized objective is
91        /// `LL(θ) − (λ/2)·‖θ‖²`, so the prior contributes `−λθ` to the
92        /// gradient and `−λ` to the Hessian diagonal. It shrinks the
93        /// log-strengths toward `0` (strength `1`), which keeps candidates
94        /// that win or lose *all* of their comparisons finite instead of
95        /// diverging to ±∞.
96        #[serde(alias = "regularization")]
97        prior_lambda: f64,
98    },
99
100    /// MM (Minorization-Maximization) algorithm with bootstrap for uncertainty
101    ///
102    /// Simpler, guaranteed monotonic likelihood increase, uses bootstrap
103    /// resampling to estimate variance.
104    MM {
105        /// Maximum iterations (default: 100)
106        max_iterations: usize,
107        /// Convergence tolerance for parameter change (default: 1e-8)
108        tolerance: f64,
109        /// Number of bootstrap samples for variance estimation (default: 100)
110        bootstrap_samples: usize,
111    },
112}
113
114impl Default for BradleyTerryOptimizer {
115    fn default() -> Self {
116        Self::NewtonRaphson {
117            max_iterations: 100,
118            tolerance: 1e-6, // Relaxed for better convergence on small datasets
119            prior_lambda: 0.1,
120        }
121    }
122}
123
124impl BradleyTerryOptimizer {
125    /// Create Newton-Raphson optimizer with custom parameters
126    ///
127    /// `prior_lambda` is the precision of the Gaussian prior on the
128    /// log-strengths (see [`BradleyTerryOptimizer::NewtonRaphson`]). A value of
129    /// `0.0` recovers the unregularized MLE (which can diverge for
130    /// all-win/all-loss candidates); `0.1` is a sensible default.
131    pub fn newton_raphson(max_iterations: usize, tolerance: f64, prior_lambda: f64) -> Self {
132        Self::NewtonRaphson {
133            max_iterations,
134            tolerance,
135            prior_lambda,
136        }
137    }
138
139    /// Create MM optimizer with custom parameters
140    pub fn mm(max_iterations: usize, tolerance: f64, bootstrap_samples: usize) -> Self {
141        Self::MM {
142            max_iterations,
143            tolerance,
144            bootstrap_samples,
145        }
146    }
147}
148
149/// Result of Bradley-Terry MLE optimization
150///
151/// # Scale convention
152///
153/// Both optimization paths (Newton-Raphson and MM) report the point estimate
154/// and its uncertainty on the **strength scale** `π = exp(θ)`:
155///
156/// - `strengths` holds `π_i` (strictly positive, mean-centered in log-space so
157///   `Σ log π_i = 0`).
158/// - `covariance` is `Cov(π)`, i.e. the covariance of the *strengths*, not of
159///   the log-strengths. Newton-Raphson obtains it by the delta method from the
160///   sum-to-zero-constrained Fisher information; MM obtains it by bootstrap.
161///   Because both are on the same (strength) scale, downstream consumers such
162///   as `CandidateStats::model_variance` and the active-learning acquisition
163///   can use them interchangeably.
164#[derive(Clone, Debug)]
165pub struct BradleyTerryResult {
166    /// Strength parameters `π_i = exp(θ_i)` (probability scale, log-strengths
167    /// sum to zero)
168    pub strengths: HashMap<CandidateId, f64>,
169    /// Covariance of the strengths `Cov(π)` (delta-method Fisher⁻¹ or bootstrap)
170    pub covariance: DMatrix<f64>,
171    /// Mapping from CandidateId to matrix index
172    pub id_to_index: HashMap<CandidateId, usize>,
173    /// Log-likelihood at solution
174    pub log_likelihood: f64,
175    /// Number of iterations to convergence
176    pub iterations: usize,
177    /// Did the algorithm converge?
178    pub converged: bool,
179    /// Final gradient norm (Newton-Raphson) or max parameter change (MM)
180    pub convergence_metric: f64,
181}
182
183impl BradleyTerryResult {
184    /// Get fitness estimate for a candidate with uncertainty
185    pub fn get_estimate(&self, id: CandidateId) -> Option<FitnessEstimate> {
186        let strength = *self.strengths.get(&id)?;
187        let idx = *self.id_to_index.get(&id)?;
188
189        // Variance is diagonal element of covariance matrix
190        let variance = if idx < self.covariance.nrows() {
191            self.covariance[(idx, idx)]
192        } else {
193            f64::INFINITY
194        };
195
196        // Count total comparisons involving this candidate
197        let observation_count = self.strengths.len(); // Approximate
198
199        Some(FitnessEstimate::new(strength, variance, observation_count))
200    }
201
202    /// Get all estimates as a map
203    pub fn all_estimates(&self) -> HashMap<CandidateId, FitnessEstimate> {
204        self.strengths
205            .keys()
206            .filter_map(|&id| self.get_estimate(id).map(|e| (id, e)))
207            .collect()
208    }
209
210    /// Predict probability that candidate a beats candidate b
211    pub fn predict_win_probability(&self, a: CandidateId, b: CandidateId) -> Option<f64> {
212        let pa = self.strengths.get(&a)?;
213        let pb = self.strengths.get(&b)?;
214        Some(pa / (pa + pb))
215    }
216}
217
218/// Bradley-Terry model for pairwise comparison data
219pub struct BradleyTerryModel {
220    optimizer: BradleyTerryOptimizer,
221}
222
223impl BradleyTerryModel {
224    /// Create a new Bradley-Terry model with specified optimizer
225    pub fn new(optimizer: BradleyTerryOptimizer) -> Self {
226        Self { optimizer }
227    }
228
229    /// Fit the model to comparison data
230    ///
231    /// # Arguments
232    ///
233    /// * `comparisons` - Historical pairwise comparison records
234    /// * `candidate_ids` - All candidate IDs to include (may include uncompared candidates)
235    ///
236    /// # Returns
237    ///
238    /// `BradleyTerryResult` with fitted strengths and uncertainty estimates
239    pub fn fit(
240        &self,
241        comparisons: &[ComparisonRecord],
242        candidate_ids: &[CandidateId],
243    ) -> BradleyTerryResult {
244        if candidate_ids.is_empty() || comparisons.is_empty() {
245            return self.empty_result(candidate_ids);
246        }
247
248        let ctx = FitContext::new(comparisons, candidate_ids);
249
250        match &self.optimizer {
251            BradleyTerryOptimizer::NewtonRaphson {
252                max_iterations,
253                tolerance,
254                prior_lambda,
255            } => self.fit_newton_raphson(&ctx, *max_iterations, *tolerance, *prior_lambda),
256            BradleyTerryOptimizer::MM {
257                max_iterations,
258                tolerance,
259                bootstrap_samples,
260            } => self.fit_mm(&ctx, *max_iterations, *tolerance, *bootstrap_samples),
261        }
262    }
263
264    /// Empty result for edge cases
265    fn empty_result(&self, candidate_ids: &[CandidateId]) -> BradleyTerryResult {
266        let n = candidate_ids.len();
267        let strengths: HashMap<CandidateId, f64> =
268            candidate_ids.iter().map(|&id| (id, 1.0)).collect();
269        let id_to_index: HashMap<CandidateId, usize> = candidate_ids
270            .iter()
271            .enumerate()
272            .map(|(i, &id)| (id, i))
273            .collect();
274
275        BradleyTerryResult {
276            strengths,
277            covariance: DMatrix::from_diagonal_element(n, n, f64::INFINITY),
278            id_to_index,
279            log_likelihood: 0.0,
280            iterations: 0,
281            converged: true,
282            convergence_metric: 0.0,
283        }
284    }
285
286    /// Newton-Raphson optimization
287    ///
288    /// Uses log-parameterization: θ_i = log(π_i), so the optimization is
289    /// unconstrained. A Gaussian prior on the log-strengths (precision
290    /// `prior_lambda`, EV-67) turns this into a MAP estimator: the penalized
291    /// objective is `LL(θ) − (λ/2)·‖θ‖²`, whose gradient carries `−λθ` and whose
292    /// Hessian diagonal carries `−λ`. The prior keeps all-win / all-loss
293    /// candidates finite and makes the (penalized) Hessian strictly negative
294    /// definite so the Newton solve never hits the singular all-ones direction.
295    fn fit_newton_raphson(
296        &self,
297        ctx: &FitContext,
298        max_iterations: usize,
299        tolerance: f64,
300        prior_lambda: f64,
301    ) -> BradleyTerryResult {
302        let n = ctx.n;
303        let comparisons = ctx.comparisons;
304        let candidate_ids = ctx.candidate_ids;
305        let id_to_index = &ctx.id_to_index;
306        // Initialize log-strengths to zero
307        let mut theta = DVector::zeros(n);
308
309        let mut converged = false;
310        let mut iterations = 0;
311        let mut gradient_norm = f64::INFINITY;
312
313        for iter in 0..max_iterations {
314            iterations = iter + 1;
315
316            // Compute gradient and Hessian of the log-likelihood.
317            let mut gradient = DVector::zeros(n);
318            let mut hessian = DMatrix::zeros(n, n);
319
320            for comp in comparisons {
321                let i = match id_to_index.get(&comp.winner) {
322                    Some(&idx) => idx,
323                    None => continue,
324                };
325                let j = match id_to_index.get(&comp.loser) {
326                    Some(&idx) => idx,
327                    None => continue,
328                };
329
330                // σ(θ_i - θ_j) = P(i beats j)
331                let diff = theta[i] - theta[j];
332                let p = sigmoid(diff);
333                let q = 1.0 - p; // P(j beats i)
334
335                // Gradient contributions
336                gradient[i] += q; // = 1 - p
337                gradient[j] -= q; // = -(1 - p) = p - 1
338
339                // Hessian contributions (second derivatives of log-likelihood)
340                let h = p * q;
341                hessian[(i, i)] -= h;
342                hessian[(j, j)] -= h;
343                hessian[(i, j)] += h;
344                hessian[(j, i)] += h;
345            }
346
347            // Add the Gaussian log-strength prior (EV-67): a genuine MAP penalty
348            // that contributes -λθ to the gradient AND -λ to the Hessian
349            // diagonal (consistent, unlike the previous Hessian-only ridge).
350            if prior_lambda > 0.0 {
351                for i in 0..n {
352                    gradient[i] -= prior_lambda * theta[i];
353                    hessian[(i, i)] -= prior_lambda;
354                }
355            }
356
357            // Check convergence on the penalized gradient.
358            gradient_norm = gradient.norm();
359            if gradient_norm < tolerance {
360                converged = true;
361                break;
362            }
363
364            // Newton ascent step: δ = (−H)^{-1} g. With the prior, −H = M + λI is
365            // positive definite, so the solve is well conditioned.
366            let neg_hessian = -&hessian;
367            let delta = match neg_hessian.clone().lu().solve(&gradient) {
368                Some(d) => d,
369                None => {
370                    // Extremely ill-conditioned graph: nudge the diagonal and retry.
371                    let mut reg_hessian = neg_hessian;
372                    let nudge = if prior_lambda > 0.0 {
373                        prior_lambda
374                    } else {
375                        1e-6
376                    };
377                    for i in 0..n {
378                        reg_hessian[(i, i)] += nudge;
379                    }
380                    match reg_hessian.lu().solve(&gradient) {
381                        Some(d) => d,
382                        None => break, // Give up
383                    }
384                }
385            };
386
387            // Backtracking line search enforcing the Armijo *sufficient-increase*
388            // condition (EV-65): accept only steps that raise the penalized
389            // log-likelihood by at least c·t·(gᵀδ).
390            let (new_theta, _backtracks) = self.backtracking_line_search(
391                &theta,
392                &delta,
393                &gradient,
394                comparisons,
395                id_to_index,
396                prior_lambda,
397            );
398            theta = new_theta;
399
400            // Normalize (subtract mean for identifiability); this stays inside the
401            // sum-to-zero subspace that the prior also prefers.
402            let mean_theta = theta.mean();
403            theta -= DVector::from_element(n, mean_theta);
404        }
405
406        // Convert to strength scale.
407        let strengths: HashMap<CandidateId, f64> = candidate_ids
408            .iter()
409            .enumerate()
410            .map(|(i, &id)| (id, theta[i].exp()))
411            .collect();
412
413        // Strength-scale covariance via the delta method from the constrained
414        // Fisher information (EV-25 / EV-66).
415        let covariance = self.strength_covariance(&theta, comparisons, id_to_index, n);
416
417        let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
418
419        BradleyTerryResult {
420            strengths,
421            covariance,
422            id_to_index: id_to_index.clone(),
423            log_likelihood,
424            iterations,
425            converged,
426            convergence_metric: gradient_norm,
427        }
428    }
429
430    /// Backtracking line search for the Newton *ascent* step.
431    ///
432    /// Returns the accepted parameter vector and the number of times the step
433    /// was halved. Accepts the first step `t ∈ {1, 1/2, 1/4, …}` satisfying the
434    /// Armijo sufficient-increase condition (EV-65)
435    ///
436    /// ```text
437    /// f(θ + t·δ) ≥ f(θ) + c·t·(∇f·δ)
438    /// ```
439    ///
440    /// where `f` is the penalized log-likelihood and `∇f·δ = gᵀ(−H)^{-1}g ≥ 0`
441    /// is a genuine ascent slope. If no step in the schedule qualifies (should
442    /// not happen for a proper ascent direction) the original `θ` is returned
443    /// unchanged so the outer loop can never *decrease* the objective.
444    fn backtracking_line_search(
445        &self,
446        theta: &DVector<f64>,
447        delta: &DVector<f64>,
448        gradient: &DVector<f64>,
449        comparisons: &[ComparisonRecord],
450        id_to_index: &HashMap<CandidateId, usize>,
451        prior_lambda: f64,
452    ) -> (DVector<f64>, usize) {
453        const C1: f64 = 1e-4;
454        const MAX_BACKTRACKS: usize = 30;
455
456        let dir_deriv = gradient.dot(delta);
457        let current = self.penalized_log_likelihood(theta, comparisons, id_to_index, prior_lambda);
458
459        let mut step_size = 1.0;
460        for backtracks in 0..MAX_BACKTRACKS {
461            let candidate = theta + step_size * delta;
462            let candidate_ll =
463                self.penalized_log_likelihood(&candidate, comparisons, id_to_index, prior_lambda);
464
465            if armijo_sufficient_increase(current, candidate_ll, step_size, dir_deriv, C1) {
466                return (candidate, backtracks);
467            }
468            step_size *= 0.5;
469        }
470
471        // No admissible step found: make no move rather than risk a decrease.
472        (theta.clone(), MAX_BACKTRACKS)
473    }
474
475    /// Penalized log-likelihood `LL(θ) − (λ/2)·‖θ‖²` (the MAP objective).
476    fn penalized_log_likelihood(
477        &self,
478        theta: &DVector<f64>,
479        comparisons: &[ComparisonRecord],
480        id_to_index: &HashMap<CandidateId, usize>,
481        prior_lambda: f64,
482    ) -> f64 {
483        self.log_likelihood(theta, comparisons, id_to_index) - 0.5 * prior_lambda * theta.dot(theta)
484    }
485
486    /// Strength-scale covariance from the sum-to-zero-constrained Fisher
487    /// information (EV-25 / EV-66).
488    ///
489    /// The BT log-likelihood in log-strengths `θ` is invariant to a global shift
490    /// `θ → θ + c·1`, so the Fisher information `M = −H_likelihood` is singular
491    /// with the all-ones vector in its null space. Ridge-inverting `(M + reg·I)`
492    /// (the previous approach) put a spurious `1/reg` variance along that null
493    /// direction, inflating every variance by ~`1/(n·reg)`. Instead we invert `M`
494    /// on the sum-to-zero subspace via the Moore-Penrose pseudo-inverse
495    /// (equivalently the reduced `(n−1)`-dimensional system), giving the
496    /// constrained covariance of `θ`. We then map to the strength scale
497    /// `π = exp(θ)` by the delta method, `Cov(π) = diag(π)·Cov(θ)·diag(π)`, so the
498    /// reported variance is on the same scale as the reported strengths (matching
499    /// the MM bootstrap covariance).
500    ///
501    /// The prior `λ` regularizes the *point estimate* only; the reported
502    /// covariance is the likelihood's constrained observed information, which is
503    /// what the numerical regression (`EV-25`) pins against the analytic value.
504    fn strength_covariance(
505        &self,
506        theta: &DVector<f64>,
507        comparisons: &[ComparisonRecord],
508        id_to_index: &HashMap<CandidateId, usize>,
509        n: usize,
510    ) -> DMatrix<f64> {
511        // Fisher information M = -H of the log-likelihood (no prior, no ridge).
512        let mut m = DMatrix::<f64>::zeros(n, n);
513        for comp in comparisons {
514            let i = match id_to_index.get(&comp.winner) {
515                Some(&idx) => idx,
516                None => continue,
517            };
518            let j = match id_to_index.get(&comp.loser) {
519                Some(&idx) => idx,
520                None => continue,
521            };
522
523            let p = sigmoid(theta[i] - theta[j]);
524            let h = p * (1.0 - p);
525
526            m[(i, i)] += h;
527            m[(j, j)] += h;
528            m[(i, j)] -= h;
529            m[(j, i)] -= h;
530        }
531
532        // Constrained (sum-to-zero) covariance of θ via the pseudo-inverse.
533        let cov_theta = match m.pseudo_inverse(1e-9) {
534            Ok(inv) => inv,
535            Err(_) => return DMatrix::from_diagonal_element(n, n, f64::INFINITY),
536        };
537
538        // Delta method to the strength scale: Cov(π) = diag(π)·Cov(θ)·diag(π).
539        let pi: Vec<f64> = (0..n).map(|i| theta[i].exp()).collect();
540        let mut cov = DMatrix::<f64>::zeros(n, n);
541        for i in 0..n {
542            for j in 0..n {
543                cov[(i, j)] = pi[i] * pi[j] * cov_theta[(i, j)];
544            }
545        }
546        cov
547    }
548
549    /// MM algorithm optimization
550    fn fit_mm(
551        &self,
552        ctx: &FitContext,
553        max_iterations: usize,
554        tolerance: f64,
555        bootstrap_samples: usize,
556    ) -> BradleyTerryResult {
557        let n = ctx.n;
558        let comparisons = ctx.comparisons;
559        let candidate_ids = ctx.candidate_ids;
560        let id_to_index = &ctx.id_to_index;
561
562        // Fit point estimates
563        let (pi, iterations, converged, max_change) =
564            self.mm_core(comparisons, id_to_index, n, max_iterations, tolerance);
565
566        // Bootstrap for variance estimation
567        let covariance =
568            self.bootstrap_covariance(ctx, max_iterations, tolerance, bootstrap_samples, &pi);
569
570        // Convert to HashMap
571        let strengths: HashMap<CandidateId, f64> = candidate_ids
572            .iter()
573            .enumerate()
574            .map(|(i, &id)| (id, pi[i]))
575            .collect();
576
577        // Compute log-likelihood
578        let theta: DVector<f64> = pi.iter().map(|&p| p.ln()).collect::<Vec<_>>().into();
579        let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
580
581        BradleyTerryResult {
582            strengths,
583            covariance,
584            id_to_index: id_to_index.clone(),
585            log_likelihood,
586            iterations,
587            converged,
588            convergence_metric: max_change,
589        }
590    }
591
592    /// Core MM iteration
593    fn mm_core(
594        &self,
595        comparisons: &[ComparisonRecord],
596        id_to_index: &HashMap<CandidateId, usize>,
597        n: usize,
598        max_iterations: usize,
599        tolerance: f64,
600    ) -> (Vec<f64>, usize, bool, f64) {
601        // Initialize strengths uniformly
602        let mut pi = vec![1.0; n];
603
604        // Count wins
605        let mut wins = vec![0usize; n];
606        for comp in comparisons {
607            if let Some(&idx) = id_to_index.get(&comp.winner) {
608                wins[idx] += 1;
609            }
610        }
611
612        let mut converged = false;
613        let mut iterations = 0;
614        let mut max_change = f64::INFINITY;
615
616        for iter in 0..max_iterations {
617            iterations = iter + 1;
618            let mut pi_new = vec![0.0; n];
619
620            for i in 0..n {
621                // Compute denominator: Σ_j n_ij / (π_i + π_j)
622                let mut denom = 0.0;
623                for comp in comparisons {
624                    let w_idx = id_to_index.get(&comp.winner).copied();
625                    let l_idx = id_to_index.get(&comp.loser).copied();
626
627                    match (w_idx, l_idx) {
628                        (Some(wi), Some(li)) if wi == i || li == i => {
629                            let other = if wi == i { li } else { wi };
630                            denom += 1.0 / (pi[i] + pi[other]);
631                        }
632                        _ => {}
633                    }
634                }
635
636                // Regularized MM update (EV-67). This is the closed-form MAP
637                // update under a Gamma(1+ε, ε) prior on π (mode at π = 1), which
638                // plays the same role for the multiplicative MM iteration that
639                // the Gaussian log-strength prior plays for Newton-Raphson: it
640                // shrinks toward the neutral strength π = 1 and keeps all-win
641                // (ε in the denominator) and all-loss (ε in the numerator)
642                // candidates finite, replacing the previous arbitrary 0.01 floor.
643                //
644                // A literal Gaussian-on-log-strength prior has no closed-form MM
645                // update; the Gamma pseudo-count is the mathematically standard
646                // regularizer for MM Bradley-Terry (Caron & Doucet, 2012).
647                let numerator = wins[i] as f64 + MM_PRIOR_PSEUDOCOUNT;
648                let denom = denom + MM_PRIOR_PSEUDOCOUNT;
649                pi_new[i] = if denom > 0.0 {
650                    numerator / denom
651                } else {
652                    pi[i]
653                };
654            }
655
656            // Normalize so strengths sum to n (arbitrary but stable)
657            let sum: f64 = pi_new.iter().sum();
658            if sum > 0.0 {
659                for p in &mut pi_new {
660                    *p *= n as f64 / sum;
661                }
662            }
663
664            // Check convergence
665            max_change = pi
666                .iter()
667                .zip(pi_new.iter())
668                .map(|(a, b)| (a - b).abs())
669                .fold(0.0, f64::max);
670
671            if max_change < tolerance {
672                converged = true;
673                pi = pi_new;
674                break;
675            }
676
677            pi = pi_new;
678        }
679
680        (pi, iterations, converged, max_change)
681    }
682
683    /// Bootstrap resampling for variance estimation
684    fn bootstrap_covariance(
685        &self,
686        ctx: &FitContext,
687        max_iterations: usize,
688        tolerance: f64,
689        bootstrap_samples: usize,
690        point_estimate: &[f64],
691    ) -> DMatrix<f64> {
692        let n = ctx.n;
693        let comparisons = ctx.comparisons;
694        let id_to_index = &ctx.id_to_index;
695
696        if bootstrap_samples == 0 || comparisons.is_empty() {
697            return DMatrix::from_diagonal_element(n, n, f64::INFINITY);
698        }
699
700        let mut rng = rand::thread_rng();
701        let mut bootstrap_estimates: Vec<Vec<f64>> = Vec::with_capacity(bootstrap_samples);
702
703        for _ in 0..bootstrap_samples {
704            // Resample comparisons with replacement
705            let resampled: Vec<ComparisonRecord> = (0..comparisons.len())
706                .map(|_| comparisons[rng.gen_range(0..comparisons.len())].clone())
707                .collect();
708
709            // Fit to resampled data
710            let (pi, _, _, _) = self.mm_core(&resampled, id_to_index, n, max_iterations, tolerance);
711            bootstrap_estimates.push(pi);
712        }
713
714        // Compute covariance matrix from bootstrap samples
715        let mut covariance = DMatrix::zeros(n, n);
716
717        for i in 0..n {
718            for j in 0..n {
719                let mean_i = point_estimate[i];
720                let mean_j = point_estimate[j];
721
722                let cov: f64 = bootstrap_estimates
723                    .iter()
724                    .map(|est| (est[i] - mean_i) * (est[j] - mean_j))
725                    .sum::<f64>()
726                    / (bootstrap_samples - 1).max(1) as f64;
727
728                covariance[(i, j)] = cov;
729            }
730        }
731
732        covariance
733    }
734
735    /// Compute log-likelihood
736    fn log_likelihood(
737        &self,
738        theta: &DVector<f64>,
739        comparisons: &[ComparisonRecord],
740        id_to_index: &HashMap<CandidateId, usize>,
741    ) -> f64 {
742        let mut ll = 0.0;
743
744        for comp in comparisons {
745            let i = match id_to_index.get(&comp.winner) {
746                Some(&idx) => idx,
747                None => continue,
748            };
749            let j = match id_to_index.get(&comp.loser) {
750                Some(&idx) => idx,
751                None => continue,
752            };
753
754            // log P(i beats j) = log(σ(θ_i - θ_j)) = θ_i - θ_j - log(1 + exp(θ_i - θ_j))
755            let diff = theta[i] - theta[j];
756            ll += log_sigmoid(diff);
757        }
758
759        ll
760    }
761}
762
763/// Pseudo-count (Gamma-style) prior strength for the MM path.
764///
765/// Mirrors the Newton-Raphson Gaussian log-strength prior: both shrink toward
766/// the neutral strength `π = 1` and keep all-win / all-loss candidates finite.
767const MM_PRIOR_PSEUDOCOUNT: f64 = 0.1;
768
769/// Armijo sufficient-*increase* test for maximizing `f` along an ascent
770/// direction `δ` (EV-65).
771///
772/// Accepts the step when `f(θ + t·δ) ≥ f(θ) + c·t·(∇f·δ)`. Because `∇f·δ ≥ 0`
773/// for an ascent direction, the acceptance threshold sits *above* the current
774/// value, so the guard genuinely enforces monotone progress. (The previous code
775/// *subtracted* the directional-derivative term, placing the threshold below the
776/// current value and thereby accepting small decreases.)
777fn armijo_sufficient_increase(
778    current: f64,
779    candidate: f64,
780    step: f64,
781    dir_deriv: f64,
782    c: f64,
783) -> bool {
784    candidate >= current + c * step * dir_deriv
785}
786
787/// Sigmoid function: σ(x) = 1 / (1 + exp(-x))
788fn sigmoid(x: f64) -> f64 {
789    if x >= 0.0 {
790        1.0 / (1.0 + (-x).exp())
791    } else {
792        let ex = x.exp();
793        ex / (1.0 + ex)
794    }
795}
796
797/// Log sigmoid: log(σ(x)) = -log(1 + exp(-x))
798fn log_sigmoid(x: f64) -> f64 {
799    if x >= 0.0 {
800        -(-x).exp().ln_1p()
801    } else {
802        x - x.exp().ln_1p()
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    fn make_comparisons(pairs: &[(usize, usize)]) -> Vec<ComparisonRecord> {
811        pairs
812            .iter()
813            .map(|&(w, l)| ComparisonRecord {
814                winner: CandidateId(w),
815                loser: CandidateId(l),
816                generation: 0,
817            })
818            .collect()
819    }
820
821    #[test]
822    fn test_newton_raphson_basic() {
823        // Simple case: A beats B twice, B beats C twice
824        let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
825        let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
826
827        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
828        let result = model.fit(&comparisons, &candidate_ids);
829
830        assert!(result.converged);
831
832        // A should be strongest, C weakest
833        let pa = result.strengths[&CandidateId(0)];
834        let pb = result.strengths[&CandidateId(1)];
835        let pc = result.strengths[&CandidateId(2)];
836
837        assert!(pa > pb);
838        assert!(pb > pc);
839    }
840
841    #[test]
842    fn test_mm_basic() {
843        let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
844        let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
845
846        let model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 50));
847        let result = model.fit(&comparisons, &candidate_ids);
848
849        assert!(result.converged);
850
851        let pa = result.strengths[&CandidateId(0)];
852        let pb = result.strengths[&CandidateId(1)];
853        let pc = result.strengths[&CandidateId(2)];
854
855        assert!(pa > pb);
856        assert!(pb > pc);
857    }
858
859    #[test]
860    fn test_newton_raphson_and_mm_agree() {
861        let comparisons = make_comparisons(&[
862            (0, 1),
863            (0, 2),
864            (1, 2),
865            (0, 1),
866            (1, 0),
867            (2, 1),
868            (0, 2),
869            (0, 2),
870        ]);
871        let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
872
873        let nr_model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
874        let mm_model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 0));
875
876        let nr_result = nr_model.fit(&comparisons, &candidate_ids);
877        let mm_result = mm_model.fit(&comparisons, &candidate_ids);
878
879        // Rankings should agree
880        let nr_ranking: Vec<_> = {
881            let mut r: Vec<_> = candidate_ids
882                .iter()
883                .map(|&id| (id, nr_result.strengths[&id]))
884                .collect();
885            r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
886            r.into_iter().map(|(id, _)| id).collect()
887        };
888
889        let mm_ranking: Vec<_> = {
890            let mut r: Vec<_> = candidate_ids
891                .iter()
892                .map(|&id| (id, mm_result.strengths[&id]))
893                .collect();
894            r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
895            r.into_iter().map(|(id, _)| id).collect()
896        };
897
898        assert_eq!(nr_ranking, mm_ranking);
899    }
900
901    #[test]
902    fn test_get_estimate() {
903        let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1)]);
904        let candidate_ids = vec![CandidateId(0), CandidateId(1)];
905
906        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
907        let result = model.fit(&comparisons, &candidate_ids);
908
909        let estimate = result.get_estimate(CandidateId(0)).unwrap();
910        assert!(estimate.variance < f64::INFINITY);
911        assert!(estimate.variance > 0.0);
912    }
913
914    #[test]
915    fn test_predict_win_probability() {
916        let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1)]);
917        let candidate_ids = vec![CandidateId(0), CandidateId(1)];
918
919        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
920        let result = model.fit(&comparisons, &candidate_ids);
921
922        let p = result
923            .predict_win_probability(CandidateId(0), CandidateId(1))
924            .unwrap();
925        assert!(p > 0.5); // A should be favored
926        assert!(p < 1.0);
927    }
928
929    #[test]
930    fn test_empty_comparisons() {
931        let comparisons: Vec<ComparisonRecord> = vec![];
932        let candidate_ids = vec![CandidateId(0), CandidateId(1)];
933
934        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
935        let result = model.fit(&comparisons, &candidate_ids);
936
937        // Should return uniform strengths with infinite variance
938        assert!(result.converged);
939        assert!(result.covariance[(0, 0)].is_infinite());
940    }
941
942    #[test]
943    fn test_sigmoid() {
944        assert!((sigmoid(0.0) - 0.5).abs() < 1e-9);
945        assert!(sigmoid(100.0) > 0.999);
946        assert!(sigmoid(-100.0) < 0.001);
947
948        // Symmetry: σ(-x) = 1 - σ(x)
949        for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
950            assert!((sigmoid(-x) - (1.0 - sigmoid(x))).abs() < 1e-9);
951        }
952    }
953
954    #[test]
955    fn test_log_sigmoid() {
956        // log(σ(x)) should be negative
957        for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
958            assert!(log_sigmoid(x) <= 0.0);
959            assert!((log_sigmoid(x).exp() - sigmoid(x)).abs() < 1e-9);
960        }
961    }
962
963    #[test]
964    fn test_covariance_positive_semidefinite() {
965        let comparisons = make_comparisons(&[(0, 1), (0, 2), (1, 2), (0, 1), (1, 2), (0, 2)]);
966        let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
967
968        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
969        let result = model.fit(&comparisons, &candidate_ids);
970
971        // Diagonal should be non-negative
972        for i in 0..3 {
973            assert!(result.covariance[(i, i)] >= 0.0);
974        }
975    }
976
977    #[test]
978    fn test_constrained_fisher_covariance_matches_analytic() {
979        // regression: EV-25 / EV-66 — the reported variance must equal the
980        // sum-to-zero-constrained Fisher inverse (delta-mapped to the strength
981        // scale), NOT a ridge-inflated `1/(n·reg)` value. Balanced 3-candidate
982        // round-robin (each unordered pair compared twice, one win each) => the
983        // MAP log-strengths are exactly 0, so π = 1 and the delta factor is 1.
984        let comparisons = make_comparisons(&[(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)]);
985        let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
986
987        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
988        let result = model.fit(&comparisons, &candidate_ids);
989
990        // Analytic constrained Fisher inverse: at θ=0 every h = 0.25, so
991        // M = 1.5·I − 0.5·J and M⁺ has diagonal 4/9.
992        let n = 3;
993        let mut m = DMatrix::<f64>::zeros(n, n);
994        for comp in &comparisons {
995            let i = comp.winner.0;
996            let j = comp.loser.0;
997            let h = 0.25;
998            m[(i, i)] += h;
999            m[(j, j)] += h;
1000            m[(i, j)] -= h;
1001            m[(j, i)] -= h;
1002        }
1003        let analytic = m.pseudo_inverse(1e-9).unwrap();
1004        for i in 0..n {
1005            assert!(
1006                (result.covariance[(i, i)] - analytic[(i, i)]).abs() < 1e-6,
1007                "diag {}: got {}, analytic {}",
1008                i,
1009                result.covariance[(i, i)],
1010                analytic[(i, i)]
1011            );
1012            assert!((result.covariance[(i, i)] - 4.0 / 9.0).abs() < 1e-6);
1013        }
1014        // The old ridge inversion produced ~1/(n·reg) ≈ 3.3e5. We must be O(1).
1015        assert!(result.covariance[(0, 0)] < 1.0);
1016    }
1017
1018    #[test]
1019    fn test_prior_keeps_all_win_all_loss_finite() {
1020        // regression: EV-67 — a candidate that wins (or loses) ALL comparisons
1021        // must stay finite thanks to the log-strength prior, not diverge.
1022        let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1), (0, 1)]);
1023        let ids = vec![CandidateId(0), CandidateId(1)];
1024
1025        // Newton-Raphson (Gaussian log-strength prior).
1026        let nr = BradleyTerryModel::new(BradleyTerryOptimizer::default());
1027        let r = nr.fit(&comparisons, &ids);
1028        let s0 = r.strengths[&CandidateId(0)];
1029        let s1 = r.strengths[&CandidateId(1)];
1030        assert!(s0.is_finite() && s1.is_finite());
1031        assert!(s0 > s1);
1032        assert!(s0 < 50.0, "NR strength diverged: {}", s0);
1033        assert!(s1 > 0.0, "NR loser strength collapsed: {}", s1);
1034
1035        // MM (Gamma pseudo-count prior).
1036        let mm = BradleyTerryModel::new(BradleyTerryOptimizer::mm(200, 1e-9, 0));
1037        let rm = mm.fit(&comparisons, &ids);
1038        let m0 = rm.strengths[&CandidateId(0)];
1039        let m1 = rm.strengths[&CandidateId(1)];
1040        assert!(m0.is_finite() && m0 < 50.0, "MM strength diverged: {}", m0);
1041        assert!(m0 > m1);
1042        assert!(m1 > 0.0);
1043    }
1044
1045    #[test]
1046    fn test_armijo_sign_rejects_small_decrease() {
1047        // regression: EV-65 — the sufficient-increase guard must REJECT a step
1048        // that decreases the objective. The pre-fix condition subtracted the
1049        // directional-derivative term and would have ACCEPTED this same step.
1050        let current = 10.0;
1051        let candidate = 9.99995; // a tiny decrease
1052        let step = 1.0;
1053        let dir_deriv = 1.0; // positive ascent slope
1054        let c = 1e-4;
1055
1056        // Correct threshold = 10 + 1e-4 = 10.0001, above the candidate -> reject.
1057        assert!(!armijo_sufficient_increase(
1058            current, candidate, step, dir_deriv, c
1059        ));
1060        // A sufficiently increasing step is accepted.
1061        assert!(armijo_sufficient_increase(
1062            current, 10.5, step, dir_deriv, c
1063        ));
1064        // The pre-fix (buggy) predicate used `current - c·t·(∇f·δ)` = 9.9999,
1065        // which the decreasing candidate exceeds -> it would have been accepted.
1066        let buggy_threshold = current - c * step * dir_deriv;
1067        assert!(candidate > buggy_threshold);
1068    }
1069
1070    #[test]
1071    fn test_backtracking_triggers_on_overshoot() {
1072        // regression: EV-65 — with a deliberately oversized ascent direction the
1073        // full step overshoots and lowers the penalized log-likelihood, so the
1074        // line search MUST backtrack and still finish no lower than it started.
1075        let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
1076        let ids = [CandidateId(0), CandidateId(1), CandidateId(2)];
1077        let id_to_index: HashMap<CandidateId, usize> =
1078            ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
1079        let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
1080        let lambda = 0.1;
1081
1082        let theta = DVector::from_element(3, 0.0);
1083        let mut gradient = DVector::zeros(3);
1084        let mut hessian = DMatrix::zeros(3, 3);
1085        for comp in &comparisons {
1086            let i = id_to_index[&comp.winner];
1087            let j = id_to_index[&comp.loser];
1088            let p = sigmoid(theta[i] - theta[j]);
1089            let q = 1.0 - p;
1090            let h = p * q;
1091            gradient[i] += q;
1092            gradient[j] -= q;
1093            hessian[(i, i)] -= h;
1094            hessian[(j, j)] -= h;
1095            hessian[(i, j)] += h;
1096            hessian[(j, i)] += h;
1097        }
1098        for i in 0..3 {
1099            gradient[i] -= lambda * theta[i];
1100            hessian[(i, i)] -= lambda;
1101        }
1102        let newton = (-&hessian).lu().solve(&gradient).unwrap();
1103        let big_delta = 50.0 * &newton; // gross overshoot
1104
1105        let before = model.penalized_log_likelihood(&theta, &comparisons, &id_to_index, lambda);
1106        let (new_theta, backtracks) = model.backtracking_line_search(
1107            &theta,
1108            &big_delta,
1109            &gradient,
1110            &comparisons,
1111            &id_to_index,
1112            lambda,
1113        );
1114        let after = model.penalized_log_likelihood(&new_theta, &comparisons, &id_to_index, lambda);
1115
1116        assert!(backtracks >= 1, "expected backtracking to trigger");
1117        assert!(
1118            after >= before,
1119            "line search must not decrease the objective"
1120        );
1121    }
1122}