Files
trueskill-tt/src/game.rs
T
logaritmiskandClaude Opus 5 076a7ded8c feat!: Gaussian's EP operations stop wearing arithmetic's clothes
`Gaussian` publicly implemented `Mul`, `Div`, `Add` and `Sub`. They were
the EP product, cavity and variance-space convolutions, and every one of
them lies to a reader who takes the operator at face value:

    a = N(10, 2)   b = N(4, 3)   c = N(1, 1)

    a * b        N(8.15, 1.66)   not 40
    a - b        sigma GREW, 2 -> sqrt(4 + 9)
    a * N(1, 0)  mu = NaN        "multiply by one"
    a / c        pi = -0.75      mu() prints a confident 0

The last is this crate's signature defect on a public operator. `Div` is
the cavity and can legitimately leave a negative precision, which is not
a distribution — and `mu()`/`sigma()` guard `pi <= 0` and report `0.0`
and `inf`, so it comes back as a plausible number with no panic, no
`Debug` marker and nothing to test against.

The four impls are now `pub(crate)` inherent methods that say what they
do: `ep_product`, `cavity`, `convolve`, `convolve_diff`, plus `scale`
for the one operation that genuinely is arithmetic. Nothing in a user's
workflow needed operator syntax; inference did, and it still has it.

`pi()` and `tau()` follow. Storing natural parameters is a performance
decision — it makes message passing two adds — not a contract. The
public surface is now exactly: `from_ms`, `from_mv`, `mu`, `sigma`,
`variance`, `probability_below`, `probability_above`. `from_mv` and
`variance` are promoted from `pub(crate)`; they are the honest pair for
callers who already hold a variance and should not pay a round trip
through the square root.

Four integration tests asserted bit-identity on `(pi, tau)`. They assert
it on `(mu, variance)` instead — still `assert_eq!`, still exact, and
`1/pi` and `tau/pi` are deterministic, so bit-equal natural parameters
give bit-equal moments. `a_nan_sigma_passes_through_from_ms` drops its
`|| g.pi().is_nan()` half: `sigma()` substitutes for `pi <= 0` and
`pi == inf`, so NaN survives to it only from a NaN precision.

`benches/gaussian.rs` is deleted. It timed two f64 additions through the
public operators, and keeping those public solely to feed it is the same
thing #73 objected to when a benchmark was dictating five public types.
The paths it covered are exercised by `batch` and `history_converge`
through the real call chain.

Closes #71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 23:13:10 +02:00

1642 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::cmp::Ordering;
use crate::{
N_INF, N00,
arena::ScratchArena,
compute_margin,
drift::Drift,
factor::{VarId, margin::MarginFactor, trunc::TruncFactor},
gaussian::Gaussian,
rating::Rating,
time::Time,
tuple_gt, tuple_max,
};
/// Per-adjacent-pair link factor in the game's diff chain.
///
/// `Trunc` is used for `Outcome::Ranked` (rank-based truncation).
/// `Margin` is used for `Outcome::Scored` (Gaussian observation on the diff).
#[derive(Debug)]
pub(crate) enum DiffFactor {
Trunc(TruncFactor),
Margin(MarginFactor),
}
impl DiffFactor {
pub(crate) fn diff(&self) -> VarId {
match self {
Self::Trunc(f) => f.diff,
Self::Margin(f) => f.diff,
}
}
pub(crate) fn msg(&self) -> Gaussian {
match self {
Self::Trunc(f) => f.msg,
Self::Margin(f) => f.msg,
}
}
/// Log of this link's cached evidence.
///
/// Accumulating in log space keeps a long diff chain from underflowing:
/// each link contributes a probability in `(0, 1]`, so the linear product
/// over an n-team game decays geometrically and flushes to zero — and
/// `ln(0.0)` is `-inf` — well within the team counts a large free-for-all
/// reaches.
pub(crate) fn log_evidence(&self) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence_cached.unwrap_or(0.0),
Self::Margin(f) => f.log_evidence_cached.unwrap_or(0.0),
}
}
pub(crate) fn propagate(
&mut self,
vars: &mut crate::factor::VarStore,
alpha: f64,
) -> (f64, f64) {
match self {
Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
}
}
}
/// Per-game inference options.
///
/// `p_draw` and `convergence` apply to ranked outcomes (`Game::ranked`).
/// `score_sigma` applies only to scored outcomes (`Game::scored`); it controls
/// how much the engine trusts the observed score margin (smaller σ = more trust).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GameOptions {
/// Probability the model assigns to two teams drawing, which sets the width
/// of the truncation band around a tie. Must be in `[0.0, 1.0)`; defaults
/// to [`P_DRAW`](crate::P_DRAW).
///
/// At `0.0` the band has zero width, so a ranked outcome that ties two
/// teams has no representable likelihood and [`Game::ranked`] rejects it
/// with `TieWithoutDrawProbability`.
pub p_draw: f64,
/// Standard deviation of the observation noise on an observed score margin,
/// used only by [`Game::scored`], which rejects a non-positive or NaN value
/// with `InvalidParameter`. Defaults to `1.0`.
///
/// It is in the units of the scores themselves, and says how much of a
/// margin the model reads as skill rather than noise: a small sigma takes
/// the margin near-literally, a large one barely moves the ratings.
pub score_sigma: f64,
/// Stopping rule and damping for the within-game message-passing loop:
/// iterate until the largest message change falls below `epsilon`, or
/// `max_iter` passes, with each update damped by `alpha`.
pub convergence: crate::ConvergenceOptions,
}
impl Default for GameOptions {
fn default() -> Self {
Self {
p_draw: crate::P_DRAW,
score_sigma: 1.0,
convergence: crate::ConvergenceOptions::default(),
}
}
}
/// One match, fitted on its own.
///
/// Rate a single match against ratings you already hold and read the updated
/// beliefs straight back. There is no history behind it: nothing is stored,
/// nothing propagates backward, and the priors you hand in are the only
/// evidence used. That makes it the wrong tool for the thing this crate exists
/// for — [`History`](crate::History) is what infers skill *through time*,
/// revising past estimates as later matches arrive, and a sequence of `Game`s
/// chained by hand is a forward-only filter, not the same answer.
///
/// Reach for it when a history would be overkill or unavailable: a one-off
/// matchup, replaying a rating step from stored numbers, checking the engine
/// against a reference, or a caller that keeps its own persistence and only
/// wants the update rule.
///
/// ```
/// use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
///
/// let strong: Rating = Rating::new(Gaussian::from_ms(30.0, 3.0), 1.0, ConstantDrift::new(0.0));
/// let weak: Rating = Rating::new(Gaussian::from_ms(20.0, 3.0), 1.0, ConstantDrift::new(0.0));
///
/// // The underdog wins.
/// let game = Game::ranked(
/// &[&[weak], &[strong]],
/// Outcome::winner(0, 2),
/// &GameOptions::default(),
/// )?;
///
/// let posteriors = game.posteriors();
/// assert!(posteriors[0][0].mu() > weak.prior().mu(), "the winner gained");
/// assert!(posteriors[1][0].mu() < strong.prior().mu(), "the loser lost");
///
/// // An upset is improbable, and `log_evidence` says so.
/// assert!(game.log_evidence() < 0.5_f64.ln());
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
#[derive(Debug)]
#[must_use]
pub struct Game<T: Time, D: Drift<T>> {
teams: Vec<Vec<Rating<T, D>>>,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) log_evidence: f64,
}
impl<T: Time, D: Drift<T>> Game<T, D> {
pub(crate) fn new(
teams: Vec<Vec<Rating<T, D>>>,
result: Vec<f64>,
weights: Vec<Vec<f64>>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
) -> Self {
let mut arena = ScratchArena::new();
// `Game` takes the teams by value and is dropped here, so take the vec
// back out of it rather than handing it a clone.
let g =
GameRef::ranked_with_arena(teams, &result, &weights, p_draw, convergence, &mut arena);
Self {
teams: g.teams,
likelihoods: g.likelihoods,
log_evidence: g.log_evidence,
}
}
pub(crate) fn new_scored(
teams: Vec<Vec<Rating<T, D>>>,
scores: Vec<f64>,
weights: Vec<Vec<f64>>,
score_sigma: f64,
convergence: crate::ConvergenceOptions,
) -> Self {
let mut arena = ScratchArena::new();
let g = GameRef::scored_with_arena(
teams,
&scores,
&weights,
score_sigma,
convergence,
&mut arena,
);
Self {
teams: g.teams,
likelihoods: g.likelihoods,
log_evidence: g.log_evidence,
}
}
/// Updated skill belief for every competitor, as `[team][member]` in the
/// order the teams and members were passed in.
///
/// Each is the competitor's own prior multiplied by the likelihood this one
/// match produced for it — so it reflects this match and the rating handed
/// in, and nothing else. Feeding it back as the next match's prior is the
/// caller's job; that is what a [`History`](crate::History) automates.
#[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
.iter()
.zip(self.teams.iter())
.map(|(l, t)| {
l.iter()
.zip(t.iter())
.map(|(&l, r)| l.ep_product(r.prior))
.collect()
})
.collect()
}
/// Natural log of how probable this outcome was under the priors, summed
/// over the diff chain's links.
///
/// Higher means the result was less surprising, so it doubles as a
/// closeness measure — two identically-rated competitors give exactly
/// `ln(0.5)`, either of them being equally likely to win:
///
/// ```
/// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
/// let r = Rating::new(Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift::new(0.0));
/// let g = Game::<i64, _>::ranked(&[&[r], &[r]], Outcome::winner(0, 2), &GameOptions::default())?;
/// assert!((g.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// Accumulated in log space because the linear product over a long chain
/// underflows to zero, and `ln(0.0)` is `-inf`.
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
}
}
/// The borrowing form of [`Game`], used only inside the crate.
///
/// `History` keeps each event's result and weight slices in its own storage
/// and sweeps them thousands of times, so the inference core borrows them
/// rather than copying. That borrow is the whole difference between this and
/// [`Game`]; it is why this type cannot be handed to a caller, and why it is
/// not part of the public API.
#[derive(Debug)]
pub(crate) struct GameRef<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64],
weights: &'a [Vec<f64>],
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) log_evidence: f64,
}
impl<'a, T: Time, D: Drift<T>> GameRef<'a, T, D> {
pub(crate) fn ranked_with_arena(
teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64],
weights: &'a [Vec<f64>],
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) -> Self {
debug_assert!(
result.len() == teams.len(),
"result must have the same length as teams"
);
debug_assert!(
weights
.iter()
.zip(teams.iter())
.all(|(w, t)| w.len() == t.len()),
"weights must have the same dimensions as teams"
);
debug_assert!(
(0.0..1.0).contains(&p_draw),
"draw probability must be >= 0.0 and < 1.0"
);
debug_assert!(
p_draw > 0.0 || {
let mut r = result.to_vec();
r.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
r.windows(2).all(|w| w[0] != w[1])
},
"draw must be > 0.0 if there are teams with draw"
);
debug_assert!(
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0]"
);
let mut this = Self {
teams,
result,
weights,
p_draw,
convergence,
likelihoods: Vec::new(),
log_evidence: 0.0,
};
this.likelihoods(arena);
this
}
pub(crate) fn scored_with_arena(
teams: Vec<Vec<Rating<T, D>>>,
scores: &'a [f64],
weights: &'a [Vec<f64>],
score_sigma: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) -> Self {
debug_assert!(
scores.len() == teams.len(),
"scores must have the same length as teams"
);
debug_assert!(
weights
.iter()
.zip(teams.iter())
.all(|(w, t)| w.len() == t.len()),
"weights must have the same dimensions as teams"
);
debug_assert!(score_sigma > 0.0, "score_sigma must be positive");
debug_assert!(
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0]"
);
let mut this = Self {
teams,
result: scores,
weights,
p_draw: 0.0,
convergence,
likelihoods: Vec::new(),
log_evidence: 0.0,
};
this.likelihoods_scored(arena, score_sigma);
this
}
fn run_chain<F>(&self, arena: &mut ScratchArena, mut make_link: F) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
{
arena.reset();
let alpha = self.convergence.alpha;
let epsilon = self.convergence.epsilon;
let max_iter = self.convergence.max_iter;
let n_teams = self.teams.len();
arena.sort_buf.extend(0..n_teams);
arena.sort_buf.sort_by(|&i, &j| {
self.result[j]
.partial_cmp(&self.result[i])
.unwrap_or(Ordering::Equal)
});
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
self.teams[t]
.iter()
.zip(self.weights[t].iter())
.fold(N00, |p, (competitor, &w)| {
p.convolve(competitor.performance().scale(w))
})
}));
let n_diffs = n_teams.saturating_sub(1);
let mut links: Vec<DiffFactor> = (0..n_diffs)
.map(|i| make_link(i, &arena.sort_buf, &mut arena.vars))
.collect();
arena.lhood_lose.resize(n_teams, N_INF);
arena.lhood_win.resize(n_teams, N_INF);
let mut step = (f64::INFINITY, f64::INFINITY);
let mut iter = 0;
while tuple_gt(step, epsilon) && iter < max_iter {
step = (0.0_f64, 0.0_f64);
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
let raw = pw.convolve_diff(pl);
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d);
let new_ll = pw.convolve_diff(lf.msg());
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
arena.lhood_lose[e + 1] = new_ll;
}
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e].ep_product(arena.lhood_lose[e]);
let pl = arena.team_prior[e + 1].ep_product(arena.lhood_win[e + 1]);
let raw = pw.convolve_diff(pl);
arena.vars.set(lf.diff(), raw.ep_product(lf.msg()));
let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d);
let new_lw = pl.convolve(lf.msg());
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
arena.lhood_win[e] = new_lw;
}
iter += 1;
}
// Special case: exactly 1 diff (2-team game); loop body was empty.
if n_diffs == 1 {
let raw = arena.team_prior[0]
.ep_product(arena.lhood_lose[0])
.convolve_diff(arena.team_prior[1].ep_product(arena.lhood_win[1]));
arena
.vars
.set(links[0].diff(), raw.ep_product(links[0].msg()));
links[0].propagate(&mut arena.vars, alpha);
}
// Boundary updates: close the chain at both ends.
if n_diffs > 0 {
let pl1 = arena.team_prior[1].ep_product(arena.lhood_win[1]);
arena.lhood_win[0] = pl1.convolve(links[0].msg());
let pw_last = arena.team_prior[n_teams - 2].ep_product(arena.lhood_lose[n_teams - 2]);
arena.lhood_lose[n_teams - 1] = pw_last.convolve_diff(links[n_diffs - 1].msg());
}
let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
// Inverse permutation: inv_buf[orig_i] = sorted_i.
arena.inv_buf.resize(n_teams, 0);
for (si, &orig_i) in arena.sort_buf.iter().enumerate() {
arena.inv_buf[orig_i] = si;
}
let likelihoods = self
.teams
.iter()
.zip(self.weights.iter())
.enumerate()
.map(|(orig_i, (competitors, weights))| {
let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si].ep_product(arena.lhood_lose[si]);
// Already folded into `team_prior` at the top of the chain,
// indexed by sorted position.
let performance = arena.team_prior[si];
competitors
.iter()
.zip(weights.iter())
.map(|(competitor, &w)| {
m.convolve_diff(performance.exclude(competitor.performance().scale(w)))
.scale(1.0 / w)
.forget(competitor.beta.powi(2))
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
(log_evidence, likelihoods)
}
fn likelihoods(&mut self, arena: &mut ScratchArena) {
let (log_evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
let margin = if self.p_draw == 0.0 {
0.0
} else {
let a: f64 = self.teams[sort_buf[i]].iter().map(|p| p.beta.powi(2)).sum();
let b: f64 = self.teams[sort_buf[i + 1]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
compute_margin(self.p_draw, (a + b).sqrt())
};
let vid = vars.alloc(N_INF);
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
});
self.log_evidence = log_evidence;
self.likelihoods = likelihoods;
}
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
let (log_evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
let vid = vars.alloc(N_INF);
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
});
self.log_evidence = log_evidence;
self.likelihoods = likelihoods;
}
/// As [`Game::posteriors`].
///
/// Test-only: inference reads `likelihoods` directly, and `GameRef` is not
/// public, so the only callers are this module's own goldens.
#[cfg(test)]
pub(crate) fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
.iter()
.zip(self.teams.iter())
.map(|(l, t)| {
l.iter()
.zip(t.iter())
.map(|(&l, p)| l.ep_product(p.prior))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
}
impl<T: Time, D: Drift<T>> Game<T, D> {
/// Reject the team shapes inference cannot represent.
///
/// `run_chain` builds one diff link per adjacent pair of teams, so fewer
/// than two teams leaves it indexing `links[1..]` on an empty vector — a
/// panic, in release, from safe API. An empty team is the quiet half: it
/// contributes no performance, so a malformed game returns a finite,
/// plausible-looking posterior for whoever it was matched against.
///
/// `History` validates the same two things at its own ingestion
/// chokepoint. `Game` is a separate public entry point that does not pass
/// through it, so it needs its own check rather than inheriting one.
fn validate_teams(teams: &[&[Rating<T, D>]]) -> Result<(), crate::InferenceError> {
if teams.len() < 2 {
return Err(crate::InferenceError::NotEnoughTeams { got: teams.len() });
}
for (team, members) in teams.iter().enumerate() {
if members.is_empty() {
return Err(crate::InferenceError::EmptyTeam { team });
}
}
Ok(())
}
/// Fit one match from an ordinal result.
///
/// `teams` is `[team][member]`, and `outcome` ranks those teams in the
/// same order. Read the result with [`posteriors`](Game::posteriors) and
/// [`log_evidence`](Game::log_evidence).
///
/// # Errors
///
/// - `InvalidParameter` if `options.convergence` is out of range — an
/// `alpha` of zero would leave every EP update unapplied and silently
/// return the priors.
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
/// `p_draw` is zero: the truncation margin is then zero and the two-sided
/// tie update evaluates `0/0`.
/// - `NotEnoughTeams` for fewer than two teams, and `EmptyTeam` for a team
/// with no members.
pub fn ranked(
teams: &[&[Rating<T, D>]],
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<Self, crate::InferenceError> {
options.convergence.validate()?;
Self::validate_teams(teams)?;
if !(0.0..1.0).contains(&options.p_draw) {
return Err(crate::InferenceError::InvalidProbability {
value: options.p_draw,
});
}
if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape {
kind: "outcome ranks vs teams",
expected: teams.len(),
got: outcome.team_count(),
});
}
let ranks = outcome
.as_ranks()
.ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::ranked",
expected: "Outcome::Ranked",
got: "Outcome::Scored",
})?;
let tied = if options.p_draw == 0.0 {
crate::first_tied_pair(ranks)
} else {
None
};
if let Some(teams) = tied {
return Err(crate::InferenceError::TieWithoutDrawProbability { teams });
}
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
let result: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect();
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
Ok(Self::new(
teams_owned,
result,
weights,
options.p_draw,
options.convergence,
))
}
/// Fit one match from continuous scores.
///
/// Unlike [`ranked`](Game::ranked), the *size* of each adjacent gap is
/// evidence: beating a team by ten says more than beating them by one.
/// How much more is set by `options.score_sigma`.
///
/// # Errors
///
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive
/// or is NaN, or if `options.convergence` is out of range.
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
/// - `NotEnoughTeams` for fewer than two teams, `EmptyTeam` for a team with
/// no members, and `InvalidParameter` for a non-finite score.
pub fn scored(
teams: &[&[Rating<T, D>]],
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<Self, crate::InferenceError> {
options.convergence.validate()?;
Self::validate_teams(teams)?;
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
return Err(crate::InferenceError::InvalidParameter {
name: "score_sigma",
value: options.score_sigma,
});
}
if outcome.team_count() != teams.len() {
return Err(crate::InferenceError::MismatchedShape {
kind: "outcome scores vs teams",
expected: teams.len(),
got: outcome.team_count(),
});
}
let scores = outcome
.as_scores()
.ok_or(crate::InferenceError::WrongOutcomeKind {
context: "Game::scored",
expected: "Outcome::Scored",
got: "Outcome::Ranked",
})?
.to_vec();
// A non-finite score poisons the chain rather than failing it. Ranks
// need no equivalent: they are `u32`.
for value in &scores {
if !value.is_finite() {
return Err(crate::InferenceError::InvalidParameter {
name: "score",
value: *value,
});
}
}
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
Ok(Self::new_scored(
teams_owned,
scores,
weights,
options.score_sigma,
options.convergence,
))
}
/// Two single-competitor teams: the common case, without the nesting.
///
/// Returns a `Game` like every other constructor. It used to return
/// `(Gaussian, Gaussian)` — the posteriors alone — which made it the one
/// member of the family you could not ask for
/// [`log_evidence`](Game::log_evidence). Call `.posteriors()` for the old
/// shape:
///
/// ```
/// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
/// # let a: Rating = Rating::new(Gaussian::from_ms(25.0, 8.0), 4.0, ConstantDrift::new(0.0));
/// # let b = a;
/// let game = Game::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default())?;
/// let post = game.posteriors();
/// let (a_post, b_post) = (post[0][0], post[1][0]);
/// # let _ = (a_post, b_post);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// # Errors
///
/// Delegates to [`Game::ranked`], so it returns the same errors — in
/// practice `WrongOutcomeKind` for a non-ranked outcome, or
/// `TieWithoutDrawProbability` for a draw when `options.p_draw` is zero.
pub fn one_v_one(
a: &Rating<T, D>,
b: &Rating<T, D>,
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<Self, crate::InferenceError> {
Self::ranked(&[&[*a], &[*b]], outcome, options)
}
/// A free-for-all: every competitor is their own one-member team.
///
/// # Errors
///
/// Wraps each competitor in a one-member team and delegates to
/// [`Game::ranked`], so it returns the same errors.
pub fn free_for_all(
competitors: &[&Rating<T, D>],
outcome: crate::Outcome,
options: &GameOptions,
) -> Result<Self, crate::InferenceError> {
let teams: Vec<Vec<Rating<T, D>>> = competitors.iter().map(|p| vec![**p]).collect();
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
Self::ranked(&team_refs, outcome, options)
}
}
#[cfg(test)]
mod tests {
use ::approx::assert_ulps_eq;
use super::*;
use crate::{ConstantDrift, GAMMA, Gaussian, N_INF, Rating, arena::ScratchArena};
type R = Rating<i64, ConstantDrift>;
#[test]
fn test_1vs1() {
let t_a = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 1.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
assert_ulps_eq!(a, Gaussian::from_ms(20.794779, 7.194481), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6);
let t_a = R::new(
Gaussian::from_ms(29.0, 1.0),
25.0 / 6.0,
ConstantDrift::new(GAMMA),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(GAMMA),
);
let w = [vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 1.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
assert_ulps_eq!(a, Gaussian::from_ms(28.896475, 0.996604), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(32.189211, 6.062063), epsilon = 1e-6);
let t_a = R::new(
Gaussian::from_ms(1.139, 0.531),
1.0,
ConstantDrift::new(0.2125),
);
let t_b = R::new(
Gaussian::from_ms(15.568, 0.51),
1.0,
ConstantDrift::new(0.2125),
);
let w = [vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 1.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
assert_eq!(g.likelihoods[0][0], N_INF);
assert_eq!(g.likelihoods[1][0], N_INF);
}
#[test]
fn test_1vs1vs1() {
let teams = vec![
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
)],
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
)],
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
)],
];
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
teams.clone(),
&[1.0, 2.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
assert_ulps_eq!(a, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6);
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
teams.clone(),
&[2.0, 1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
assert_ulps_eq!(a, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
teams,
&[1.0, 2.0, 0.0],
&w,
0.5,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
let c = p[2][0];
// T1 ULP shift: mu rounds to 25.0 (was 24.999999) under natural-parameter storage.
//
// The 1e-6-place values moved when `erfc_inv`'s sign error was fixed:
// this case runs at `p_draw = 0.5`, so it goes through `compute_margin`,
// and the margin is now 8.4e-8 from the exact quantile where it was
// 1.46e-7. Verified as movement *toward* analytic truth, not a
// regression — see `erfc_inv_matches_known_quantiles`.
assert_ulps_eq!(a, Gaussian::from_ms(25.0, 6.092561), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(33.379315, 6.483576), epsilon = 1e-6);
assert_ulps_eq!(c, Gaussian::from_ms(16.620685, 6.483576), epsilon = 1e-6);
}
#[test]
fn test_1vs1_draw() {
let t_a = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
// Two identical competitors drawing must land on their shared prior
// mean exactly, by symmetry. The reference transcription of 24.999999
// is that value rounded to six decimals; asserting it at epsilon 1e-6
// left no headroom. The root-free variance path now hits 25.0 exactly.
assert_ulps_eq!(a, Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
let t_a = R::new(
Gaussian::from_ms(25.0, 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(29.0, 2.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b]],
&[0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
assert_ulps_eq!(a, Gaussian::from_ms(25.736001, 2.709956), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(28.672888, 1.916471), epsilon = 1e-6);
}
#[test]
fn test_1vs1vs1_draw() {
let t_a = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let t_c = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b], vec![t_c]],
&[0.0, 0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
let c = p[2][0];
// Goldens updated for natural-parameter storage: mu rounds to 25.0 (was 24.999999),
// sigma shifts by ~3e-7 ULPs (within 1e-6 of original). Both bounded differences.
assert_ulps_eq!(a, Gaussian::from_ms(25.0, 5.729069), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(25.0, 5.707424), epsilon = 1e-6);
assert_ulps_eq!(c, Gaussian::from_ms(25.0, 5.729069), epsilon = 1e-6);
let t_a = R::new(
Gaussian::from_ms(25.0, 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let t_b = R::new(
Gaussian::from_ms(25.0, 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let t_c = R::new(
Gaussian::from_ms(29.0, 2.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
vec![vec![t_a], vec![t_b], vec![t_c]],
&[0.0, 0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
let c = p[2][0];
assert_ulps_eq!(a, Gaussian::from_ms(25.488507, 2.638208), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(25.510671, 2.628751), epsilon = 1e-6);
assert_ulps_eq!(c, Gaussian::from_ms(28.555920, 1.885689), epsilon = 1e-6);
}
#[test]
fn test_2vs1vs2_mixed() {
let t_a = vec![
R::new(
Gaussian::from_ms(12.0, 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
),
R::new(
Gaussian::from_ms(18.0, 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
),
];
let t_b = vec![R::new(
Gaussian::from_ms(30.0, 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
)];
let t_c = vec![
R::new(
Gaussian::from_ms(14.0, 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
),
R::new(
Gaussian::from_ms(16., 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
),
];
let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]];
let g = GameRef::ranked_with_arena(
vec![t_a, t_b, t_c],
&[1.0, 0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(p[0][0], Gaussian::from_ms(13.051, 2.864), epsilon = 1e-3);
assert_ulps_eq!(p[0][1], Gaussian::from_ms(19.051, 2.864), epsilon = 1e-3);
assert_ulps_eq!(p[1][0], Gaussian::from_ms(29.292, 2.764), epsilon = 1e-3);
assert_ulps_eq!(p[2][0], Gaussian::from_ms(13.658, 2.813), epsilon = 1e-3);
assert_ulps_eq!(p[2][1], Gaussian::from_ms(15.658, 2.813), epsilon = 1e-3);
}
#[test]
fn test_1vs1_weighted() {
let w_a = vec![1.0];
let w_b = vec![2.0];
let t_a = vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(0.0),
)];
let t_b = vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(0.0),
)];
let w = [w_a, w_b];
let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
p[0][0],
Gaussian::from_ms(30.625173, 7.765472),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][0],
Gaussian::from_ms(13.749653, 5.733840),
epsilon = 1e-6
);
let w_a = vec![1.0];
let w_b = vec![0.7];
let w = [w_a, w_b];
let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
p[0][0],
Gaussian::from_ms(27.630080, 7.206676),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][0],
Gaussian::from_ms(23.158943, 7.801628),
epsilon = 1e-6
);
let w_a = vec![1.6];
let w_b = vec![0.7];
let w = [w_a, w_b];
let g = GameRef::ranked_with_arena(
vec![t_a, t_b],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
p[0][0],
Gaussian::from_ms(26.142438, 7.573088),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][0],
Gaussian::from_ms(24.500183, 8.193278),
epsilon = 1e-6
);
let w_a = vec![1.0];
let w_b = vec![0.0];
let t_a = vec![R::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift::new(0.0),
)];
let t_b = vec![R::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift::new(0.0),
)];
let w = [w_a, w_b];
let g = GameRef::ranked_with_arena(
vec![t_a, t_b],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
p[0][0],
Gaussian::from_ms(5.557067, 4.052826),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][0],
Gaussian::from_ms(2.000000, 6.000000),
epsilon = 1e-6
);
let w_a = vec![1.0];
let w_b = vec![-1.0];
let t_a = vec![R::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift::new(0.0),
)];
let t_b = vec![R::new(
Gaussian::from_ms(2.0, 6.0),
1.0,
ConstantDrift::new(0.0),
)];
let w = [w_a, w_b];
let g = GameRef::ranked_with_arena(
vec![t_a, t_b],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(p[0][0], p[1][0], epsilon = 1e-6);
}
#[test]
fn diff_factor_dispatch_trunc_and_margin() {
use super::DiffFactor;
use crate::factor::{VarStore, margin::MarginFactor, trunc::TruncFactor};
let mut vars = VarStore::new();
let dt = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let dm = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut t = DiffFactor::Trunc(TruncFactor::new(dt, 0.0, false));
let mut m = DiffFactor::Margin(MarginFactor::new(dm, 5.0, 1.0));
let _ = t.propagate(&mut vars, 1.0);
let _ = m.propagate(&mut vars, 1.0);
// Smoke: both diffs got written; their msgs are non-N_INF.
assert!(t.msg().pi() > 0.0);
assert!(m.msg().pi() > 0.0);
assert_eq!(t.diff(), dt);
assert_eq!(m.diff(), dm);
}
#[test]
fn scored_path_sharper_when_margin_is_large() {
let prior = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let teams = vec![vec![prior], vec![prior]];
let result = vec![10.0, 0.0]; // a beat b by 10
let weights = [vec![1.0], vec![1.0]];
let mut arena = ScratchArena::new();
let g = GameRef::scored_with_arena(
teams,
&result,
&weights,
1.0,
crate::ConvergenceOptions::default(),
&mut arena,
);
let p = g.posteriors();
let a = p[0][0];
let b = p[1][0];
assert!(
a.mu() > b.mu(),
"expected team a posterior mu > team b; got {} vs {}",
a.mu(),
b.mu()
);
// Tighter score_sigma should produce a stronger update.
let mut arena2 = ScratchArena::new();
let g_tight = GameRef::scored_with_arena(
vec![vec![prior], vec![prior]],
&result,
&weights,
0.1,
crate::ConvergenceOptions::default(),
&mut arena2,
);
let p_tight = g_tight.posteriors();
let a_tight = p_tight[0][0];
assert!(
a_tight.mu() > a.mu(),
"expected tighter sigma to push posterior further; {} vs {}",
a_tight.mu(),
a.mu()
);
}
#[test]
fn game_scored_public_ctor() {
use crate::Outcome;
let prior = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let opts = GameOptions {
score_sigma: 1.0,
..GameOptions::default()
};
let g = Game::scored(&[&[prior], &[prior]], Outcome::scores([8.0, 2.0]), &opts).unwrap();
let p = g.posteriors();
assert!(p[0][0].mu() > p[1][0].mu());
}
#[test]
fn game_scored_rejects_ranked_outcome() {
let prior = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let err = Game::scored(
&[&[prior], &[prior]],
crate::Outcome::winner(0, 2),
&GameOptions::default(),
)
.unwrap_err();
assert!(matches!(
err,
crate::InferenceError::WrongOutcomeKind { .. }
));
}
#[test]
fn game_scored_rejects_zero_score_sigma() {
let prior = R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
);
let opts = GameOptions {
score_sigma: 0.0,
..GameOptions::default()
};
let err = Game::scored(
&[&[prior], &[prior]],
crate::Outcome::scores([1.0, 0.0]),
&opts,
)
.unwrap_err();
assert!(matches!(
err,
crate::InferenceError::InvalidParameter {
name: "score_sigma",
..
}
));
}
#[test]
fn test_2vs2_weighted() {
let t_a = vec![
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(0.0),
),
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(0.0),
),
];
let w_a = vec![0.4, 0.8];
let t_b = vec![
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(0.0),
),
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(0.0),
),
];
let w_b = vec![0.9, 0.6];
let w = [w_a, w_b];
let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
p[0][0],
Gaussian::from_ms(27.539023, 8.129639),
epsilon = 1e-6
);
assert_ulps_eq!(
p[0][1],
Gaussian::from_ms(30.078046, 7.485372),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][0],
Gaussian::from_ms(19.287198285, 7.243465848),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][1],
Gaussian::from_ms(21.191465, 7.867608),
epsilon = 1e-6
);
let w_a = vec![1.3, 1.5];
let w_b = vec![0.7, 0.4];
let w = [w_a, w_b];
let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
p[0][0],
Gaussian::from_ms(25.190190, 8.220511),
epsilon = 1e-6
);
assert_ulps_eq!(
p[0][1],
Gaussian::from_ms(25.219450, 8.182783),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][0],
Gaussian::from_ms(24.897589, 8.300779),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][1],
Gaussian::from_ms(24.941479, 8.322717),
epsilon = 1e-6
);
let w_a = vec![1.6, 0.2];
let w_b = vec![0.7, 2.4];
let w = [w_a, w_b];
let g = GameRef::ranked_with_arena(
vec![t_a.clone(), t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(
p[0][0],
Gaussian::from_ms(31.674698083, 7.501180037),
epsilon = 1e-6
);
assert_ulps_eq!(
p[0][1],
Gaussian::from_ms(25.834337, 8.320970),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][0],
Gaussian::from_ms(22.079819, 8.180607),
epsilon = 1e-6
);
assert_ulps_eq!(
p[1][1],
Gaussian::from_ms(14.987953, 6.308469),
epsilon = 1e-6
);
let w = [vec![1.0, 1.0], vec![1.0]];
let g = GameRef::ranked_with_arena(
vec![
t_a.clone(),
vec![R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(0.0),
)],
],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let post_2vs1 = g.posteriors();
let w_a = vec![1.0, 1.0];
let w_b = vec![1.0, 0.0];
let w = [w_a, w_b];
let g = GameRef::ranked_with_arena(
vec![t_a, t_b.clone()],
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
assert_ulps_eq!(p[0][0], post_2vs1[0][0], epsilon = 1e-6);
assert_ulps_eq!(p[0][1], post_2vs1[0][1], epsilon = 1e-6);
assert_ulps_eq!(p[1][0], post_2vs1[1][0], epsilon = 1e-6);
assert_ulps_eq!(p[1][1], t_b[1].prior, epsilon = 1e-6);
}
#[test]
fn run_chain_honours_max_iter_in_convergence_options() {
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
let result = vec![3.0, 2.0, 1.0, 0.0];
let weights = vec![vec![1.0]; 4];
// Capped at 1 iteration: cannot fully propagate down a 4-team chain.
let mut arena = ScratchArena::new();
let g_capped = GameRef::ranked_with_arena(
teams.clone(),
&result,
&weights,
0.0,
crate::ConvergenceOptions {
max_iter: 1,
..crate::ConvergenceOptions::default()
},
&mut arena,
);
let posteriors_capped = g_capped.posteriors();
// Same inputs, plenty of iterations: fully converged.
let mut arena = ScratchArena::new();
let g_full = GameRef::ranked_with_arena(
teams,
&result,
&weights,
0.0,
crate::ConvergenceOptions::default(),
&mut arena,
);
let posteriors_full = g_full.posteriors();
// The two posteriors should differ — capped did not converge.
let mut max_diff: f64 = 0.0;
for (team_capped, team_full) in posteriors_capped.iter().zip(posteriors_full.iter()) {
for (g_capped, g_full) in team_capped.iter().zip(team_full.iter()) {
max_diff = max_diff.max((g_capped.mu() - g_full.mu()).abs());
max_diff = max_diff.max((g_capped.sigma() - g_full.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"max_iter=1 should differ from full convergence; max_diff={max_diff}"
);
}
#[test]
fn run_chain_with_damping_converges_to_same_posterior() {
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
let result = vec![3.0, 2.0, 1.0, 0.0];
let weights = vec![vec![1.0]; 4];
let mut arena = ScratchArena::new();
let g_undamped = GameRef::ranked_with_arena(
teams.clone(),
&result,
&weights,
0.0,
crate::ConvergenceOptions::default(),
&mut arena,
);
let posteriors_undamped = g_undamped.posteriors();
// alpha=0.5 with extra iterations: should reach the same fixed point.
let mut arena = ScratchArena::new();
let g_damped = GameRef::ranked_with_arena(
teams,
&result,
&weights,
0.0,
crate::ConvergenceOptions {
alpha: 0.5,
max_iter: 100,
..crate::ConvergenceOptions::default()
},
&mut arena,
);
let posteriors_damped = g_damped.posteriors();
let mut max_diff: f64 = 0.0;
for (team_u, team_d) in posteriors_undamped.iter().zip(posteriors_damped.iter()) {
for (g_u, g_d) in team_u.iter().zip(team_d.iter()) {
max_diff = max_diff.max((g_u.mu() - g_d.mu()).abs());
max_diff = max_diff.max((g_u.sigma() - g_d.sigma()).abs());
}
}
assert!(
max_diff < 1e-4,
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
);
}
}