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