Files
trueskill-tt/src/history.rs
T
logaritmiskandClaude Opus 5 b553c630f5 refactor!: K comes first in History, HistoryBuilder and Joint
`K` is the one type parameter people change, and it was last. Naming a
history in a struct field meant writing all four to say one thing:

    struct Ladder { history: History<i64, ConstantDrift, NullObserver, String> }
    struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> }

Now:

    struct Ladder { history: History<String> }
    struct Analysis<'h> { joint: Joint<'h> }

`History<K, T, D, O>`, all four defaulted. Bounds may reference later
parameters, so `D: Drift<T> = ConstantDrift` is legal in third position.
`Joint` gains the same defaults, so `Joint<'h, String>` spells it.

72 call sites swapped, and the reorder makes most of them shorter: 18
now read `History<String>` and the `&'static str` ones read `History`.
The two turbofished builders shrink from
`HistoryBuilder::<Untimed, _, _, String>::new()` to
`HistoryBuilder::<String, Untimed>::new()`.

`Joint` keeps `O` structurally, defaulted rather than removed. #72 notes
it never touches the observer, which is true — but it borrows the whole
`&'h History<K, T, D, O>` and calls `History::resolve_terms`, so dropping
the parameter means either a view type or moving that method off
`History`. The default already buys the entire user-visible benefit,
which was the spelling.

Refs #72.

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

4193 lines
157 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::{
borrow::Borrow,
collections::{BTreeMap, HashMap, HashSet},
hash::Hash,
marker::PhantomData,
};
use crate::{
BETA, GAMMA, Index, MU, P_DRAW, SIGMA,
competitor::{self, Competitor},
convergence::{ConvergenceOptions, ConvergenceReport},
drift::{ConstantDrift, Drift},
error::InferenceError,
event::Member,
gaussian::Gaussian,
key_table::KeyTable,
observer::{NullObserver, Observer},
predict::Prediction,
rating::Rating,
sort_time,
storage::CompetitorStore,
time::Time,
time_slice::{self, EventKind, FilteredStep, TimeSlice},
tuple_gt, tuple_max,
};
/// Configures a [`History`] before any events are added.
///
/// Everything a history needs that is not an event lives here: the prior
/// (`mu`, `sigma`), the performance noise `beta`, the draw probability, the
/// drift model, the convergence settings, the observer, and what to do about
/// an unknown key. None of them can be changed after `build`, because they
/// define the model the fit is of.
///
/// Parameterised as [`History`] is, `HistoryBuilder<K, T, D, O>`, with the
/// same defaults.
///
/// Two of the setters change the builder's *type* rather than a field —
/// [`HistoryBuilder::drift`] and [`HistoryBuilder::observer`] — so bind the
/// result rather than calling them on a `&mut`. [`HistoryBuilder::time_type`]
/// and [`HistoryBuilder::key_type`] exist for the same reason: to name a type
/// parameter that nothing in the call chain would otherwise infer.
#[derive(Clone, Debug)]
#[must_use = "a builder does nothing until `.build()`"]
pub struct HistoryBuilder<
K: Eq + Hash + Clone = &'static str,
T: Time = i64,
D: Drift<T> = ConstantDrift,
O: Observer<T> = NullObserver,
> {
mu: f64,
sigma: f64,
beta: f64,
drift: D,
p_draw: f64,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
unknown_keys: crate::UnknownKeys,
_time: PhantomData<T>,
_key: PhantomData<K>,
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<K, T, D, O> {
/// Prior mean skill.
///
/// # Panics
///
/// Panics if `mu` is not finite. A non-finite prior mean poisons every
/// posterior derived from it: `converge` reports `NonFiniteResult`, but a
/// caller who reads `current_skill` first is handed `tau: NaN`.
pub fn mu(mut self, mu: f64) -> Self {
assert!(mu.is_finite(), "mu must be finite (got {mu})");
self.mu = mu;
self
}
/// Prior standard deviation: how unsure the model is about a competitor's
/// **skill** before it has seen them play.
///
/// The first of the two noise knobs, and the one people reach for by
/// mistake. `sigma` is *epistemic* — it is what the model does not yet
/// know, and evidence shrinks it. [`HistoryBuilder::beta`] is *aleatoric*
/// — how much a single showing scatters around the skill, which no amount
/// of evidence removes.
///
/// So: results move ratings too slowly for your taste → raise `sigma` (or
/// `gamma`, if the problem is that skill genuinely moves). A single upset
/// swings ratings too far → raise `beta`, because you are telling the model
/// that one result is weaker evidence than it assumed.
///
/// The default is six betas, deliberately wide: a new competitor's first
/// result should move them a long way.
///
/// # Panics
///
/// Panics unless `sigma` is finite and strictly positive.
///
/// Zero and infinity both give a prior precision that is not a number, and
/// the whole fit comes back NaN. A *negative* sigma is the quieter half:
/// it is only ever squared, so `-8.33` produces bit-identical results to
/// `8.33` — a sign the caller cannot have meant, silently ignored.
pub fn sigma(mut self, sigma: f64) -> Self {
assert!(
sigma.is_finite() && sigma > 0.0,
"sigma must be finite and positive (got {sigma})"
);
self.sigma = sigma;
self
}
/// Per-event performance noise: how much a single showing scatters around
/// a competitor's **skill**.
///
/// The second noise knob, and the one that sets the scale of the whole
/// system — [`SIGMA`](crate::SIGMA) and [`GAMMA`](crate::GAMMA) are both
/// defined as multiples of it. Unlike
/// [`sigma`](HistoryBuilder::sigma), this is *aleatoric*: it is the
/// irreducible day-to-day variation, so evidence never shrinks it. It is
/// also what makes an upset possible at all — with `beta == 0` the better
/// competitor always wins.
///
/// Larger `beta` means each result carries less information, so ratings
/// move less per game and the draw margin implied by `p_draw` is wider.
///
/// # Panics
///
/// Panics unless `beta` is finite and non-negative.
///
/// Zero is allowed and meaningful — performance is then exactly skill, and
/// the fit differs measurably from a positive `beta` rather than
/// degenerating. Negative is rejected for the same reason as a negative
/// `sigma` or `Member::with_drift_scale`: `beta` enters only as `beta^2`,
/// so a negative value behaves as its absolute value and the sign is lost
/// without comment.
pub fn beta(mut self, beta: f64) -> Self {
assert!(
beta.is_finite() && beta >= 0.0,
"beta must be finite and non-negative (got {beta})"
);
self.beta = beta;
self
}
/// Set the drift model: how far skill may move between appearances.
///
/// Changes the builder's type, since `D` is a type parameter — bind the
/// result. [`ConstantDrift`] is the default; a custom [`Drift`] impl is
/// the way to express a calendar-dependent or per-competitor rule that
/// elapsed ticks alone cannot.
///
/// Not validated here: the builder cannot inspect an arbitrary
/// implementation. `converge` checks the variance each competitor actually
/// accumulates and reports `InvalidParameter` if it is negative or
/// non-finite.
pub fn drift<D2: Drift<T>>(self, drift: D2) -> HistoryBuilder<K, T, D2, O> {
HistoryBuilder {
drift,
mu: self.mu,
sigma: self.sigma,
beta: self.beta,
p_draw: self.p_draw,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
unknown_keys: self.unknown_keys,
_time: self._time,
_key: self._key,
}
}
/// Probability that two evenly-matched sides draw.
///
/// Must be in `[0.0, 1.0)`. A zero draw probability asserts that draws
/// cannot occur, so ingesting a tied outcome then fails with
/// `InferenceError::TieWithoutDrawProbability`.
///
/// # Panics
///
/// Panics if `p_draw` is outside `[0.0, 1.0)` or is NaN.
pub fn p_draw(mut self, p_draw: f64) -> Self {
assert!(
(0.0..1.0).contains(&p_draw),
"p_draw must be in [0.0, 1.0) (got {p_draw})"
);
self.p_draw = p_draw;
self
}
/// Default observation noise for scored outcomes.
///
/// # Panics
///
/// Panics if `score_sigma` is not strictly positive.
pub fn score_sigma(mut self, score_sigma: f64) -> Self {
assert!(
score_sigma.is_finite() && score_sigma > 0.0,
"score_sigma must be finite and positive (got {score_sigma})"
);
self.score_sigma = score_sigma;
self
}
/// How predictions treat a key the history has never seen.
///
/// Defaults to [`UnknownKeys::Reject`](crate::UnknownKeys::Reject), which
/// errors. Set [`UnknownKeys::Prior`](crate::UnknownKeys::Prior) to have an
/// unknown competitor answered from the configured prior instead, which
/// makes "predict a matchup involving someone new" a first-class question.
///
/// This affects predictions only. Ingestion always creates a competitor for
/// a key it has not seen, because that is what an event *is*.
pub fn unknown_keys(mut self, unknown_keys: crate::UnknownKeys) -> Self {
self.unknown_keys = unknown_keys;
self
}
/// Convergence tolerance, iteration cap, and EP damping.
///
/// # Panics
///
/// Panics if `alpha` is outside `(0.0, 1.0]`, or if `epsilon` is negative
/// or NaN. An `alpha` of zero would leave every EP update unapplied, so
/// inference would silently return the priors.
pub fn convergence(mut self, opts: ConvergenceOptions) -> Self {
assert!(
opts.alpha > 0.0 && opts.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0] (got {})",
opts.alpha
);
assert!(
opts.epsilon >= 0.0,
"convergence epsilon must be non-negative (got {})",
opts.epsilon
);
self.convergence = opts;
self
}
/// Change the time axis, keeping every other setting.
///
/// The same shape as [`HistoryBuilder::drift`] and
/// [`HistoryBuilder::observer`], which already move between type
/// parameters. Call it before setting a drift that is specific to one time
/// type, since the stored drift and observer must also be valid for `T2`.
///
/// ```
/// # use trueskill_tt::{History, Untimed};
/// let mut h = History::builder().time_type::<Untimed>().build();
/// h.record_winner(&"alice", &"bob", Untimed)?;
/// h.converge()?;
/// assert!(h.current_skill(&"alice").unwrap().mu() > 0.0);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
pub fn time_type<T2>(self) -> HistoryBuilder<K, T2, D, O>
where
T2: Time,
D: Drift<T2>,
O: Observer<T2>,
{
HistoryBuilder {
mu: self.mu,
sigma: self.sigma,
beta: self.beta,
drift: self.drift,
p_draw: self.p_draw,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
unknown_keys: self.unknown_keys,
_time: PhantomData,
_key: PhantomData,
}
}
/// Change the key type, keeping every other setting.
///
/// Replaces the former `History::builder_with_key`, which could not be
/// turbofished — `K` sat on the `impl` rather than the function, so
/// `History::builder_with_key::<String>()` was a compile error and callers
/// had to spell the whole `History::builder().key_type::<String>()`.
///
/// ```
/// # use trueskill_tt::History;
/// let mut h = History::builder().key_type::<String>().build();
/// h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)?;
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
pub fn key_type<K2: Eq + Hash + Clone>(self) -> HistoryBuilder<K2, T, D, O> {
HistoryBuilder {
mu: self.mu,
sigma: self.sigma,
beta: self.beta,
drift: self.drift,
p_draw: self.p_draw,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
unknown_keys: self.unknown_keys,
_time: PhantomData,
_key: PhantomData,
}
}
/// Attach an [`Observer`] to be called as inference progresses.
///
/// Changes the builder's type — bind the result. The history takes the
/// observer by value; to keep a handle on one that accumulates state, pass
/// an `Arc` and keep a clone, or read it back with
/// [`History::observer`] / [`History::into_observer`].
pub fn observer<O2: Observer<T>>(self, observer: O2) -> HistoryBuilder<K, T, D, O2> {
HistoryBuilder {
mu: self.mu,
sigma: self.sigma,
beta: self.beta,
drift: self.drift,
p_draw: self.p_draw,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer,
unknown_keys: self.unknown_keys,
_time: self._time,
_key: self._key,
}
}
/// Finish configuring and produce an empty [`History`].
///
/// Every parameter was validated as it was set, so this cannot fail.
pub fn build(self) -> History<K, T, D, O> {
History {
size: 0,
time_slices: Vec::new(),
competitors: CompetitorStore::new(),
keys: KeyTable::new(),
mu: self.mu,
sigma: self.sigma,
beta: self.beta,
drift: self.drift,
p_draw: self.p_draw,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
unknown_keys: self.unknown_keys,
declared: HashMap::new(),
}
}
}
/// Generic over the time axis and the key type, so a builder exists for every
/// `T: Time` rather than only for `i64`.
///
/// It used to be implemented for the `i64`/`&'static str` instantiation alone.
/// That, plus private fields and no `new`, meant a downstream crate could not
/// construct a `History` on any other time axis at all — `Untimed` and every
/// custom `Drift<T>` were public but unreachable.
impl<T: Time, K: Eq + Hash + Clone> Default for HistoryBuilder<K, T, ConstantDrift, NullObserver> {
fn default() -> Self {
Self {
mu: MU,
sigma: SIGMA,
beta: BETA,
drift: ConstantDrift::new(GAMMA),
p_draw: P_DRAW,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
observer: NullObserver,
unknown_keys: crate::UnknownKeys::default(),
_time: PhantomData,
_key: PhantomData,
}
}
}
/// Configuration a caller attached to a competitor via `Member`.
///
/// Carries *what was explicitly set* rather than a merged `Rating`, so a member
/// that sets only `drift_scale` does not also assert the default prior — which
/// would spuriously conflict with a prior seeded on an earlier event.
#[derive(Clone, Copy, Default)]
pub(crate) struct CompetitorConfig {
prior: Option<Gaussian>,
drift_scale: Option<f64>,
}
/// The joint precision over a history's appearances, with the maps needed to
/// address a competitor either at their latest appearance or at a given slice.
struct TimeExpanded {
/// Row-major precision matrix over appearances.
lambda: Vec<f64>,
/// `(row, slice)` of each competitor's latest appearance.
latest: HashMap<Index, (usize, usize)>,
/// Row of each `(competitor, slice)` appearance.
at_slice: HashMap<(Index, usize), usize>,
/// Side length of `lambda`.
width: usize,
}
/// A linear functional resolved against one time slice.
struct ResolvedTerms {
/// Coefficients over the slice's own competitors, in its ordering.
contrast: Vec<f64>,
/// Coefficients of competitors the slice has never seen, keyed by their
/// rendering. Independent of everything in the slice by construction.
///
/// A `BTreeMap` rather than a `HashMap`, and that is load-bearing. These
/// coefficients are summed, addition is not associative, and Rust seeds its
/// default hasher per process — so iterating a `HashMap` here made
/// `posterior_of` return different bits run to run on identical input.
/// Measured over 40 processes: two distinct sigma bit patterns, and five
/// distinct values from `expected_variance_reduction` spanning ~7 ULP.
/// Ordered iteration makes the sum reproducible.
unseen: BTreeMap<String, f64>,
mean: f64,
}
impl CompetitorConfig {
fn is_empty(self) -> bool {
self.prior.is_none() && self.drift_scale.is_none()
}
}
/// A fitted history: competitors, their skills over time, and the events that
/// produced them.
///
/// # Persistence
///
/// `History` is deliberately not serializable, and the event log is the
/// intended source of truth. This is a decision rather than an omission.
///
/// [`converge`](History::converge) reaches a fixed point determined by the
/// events, ratings and configuration alone — not by the message state it
/// started from, which ingestion resets anyway. So a snapshot of a fitted
/// history would carry no information the event log does not; it would be a
/// cache of the *computation*, never of the answer.
///
/// That also bounds what one could buy. Re-converging an unchanged history
/// costs a single iteration — measured at 0.91 ms against 365 ms cold on 2 000
/// events — so a snapshot would make a cold restart cheap. It would do nothing
/// for appends: adding one event moves its participants by more than a sigma
/// across their whole history, back to their first appearance, so the
/// re-convergence is real work rather than repeated work. Cost per append is
/// inherently O(history). A design that made appends cheap would be computing a
/// filtering estimate — see [`History::filtered_learning_curve`] — rather than
/// Through Time.
///
/// `tests/reconvergence_equivalence.rs` pins the path-independence this rests
/// on.
/// The top-level container: ingests events, runs forward/backward message
/// passing, and answers queries about the fit.
///
/// # Type parameters
///
/// `History<K, T, D, O>` — key type, time type, drift model, observer — all
/// defaulted, so `History` alone means `&'static str` keys on an `i64` time
/// axis with [`ConstantDrift`] and no observer, and `History<String>` is the
/// whole spelling for owned keys.
///
/// `K` is first because it is the one people change. It used to be last, so
/// naming a history in a struct field meant writing all four:
/// `History<i64, ConstantDrift, NullObserver, String>` to say "keys are
/// `String`". See #72.
pub struct History<
K: Eq + Hash + Clone = &'static str,
T: Time = i64,
D: Drift<T> = ConstantDrift,
O: Observer<T> = NullObserver,
> {
size: usize,
pub(crate) time_slices: Vec<TimeSlice<T>>,
pub(crate) competitors: CompetitorStore<T, D>,
keys: KeyTable<K>,
mu: f64,
sigma: f64,
beta: f64,
drift: D,
p_draw: f64,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
unknown_keys: crate::UnknownKeys,
/// Competitor configuration explicitly declared so far, by whichever route.
///
/// Kept separate from the applied `Rating` because a `Rating` cannot say
/// whether a value was *chosen* or inherited from the history defaults,
/// and that is exactly the distinction a conflict check needs.
declared: HashMap<Index, CompetitorConfig>,
}
impl Default for History {
fn default() -> Self {
HistoryBuilder::default().build()
}
}
impl History {
/// Start configuring a history.
///
/// The defaults are `i64` time, [`ConstantDrift`], no observer and
/// `&'static str` keys. Any of the four can be changed — the two type
/// parameters that no argument would pin are named with
/// [`HistoryBuilder::time_type`] and [`HistoryBuilder::key_type`].
pub fn builder() -> HistoryBuilder {
HistoryBuilder::default()
}
}
impl<T: Time, K: Eq + Hash + Clone> HistoryBuilder<K, T, ConstantDrift, NullObserver> {
/// A builder on any time axis and key type, with the default drift and no
/// observer.
///
/// [`History::builder`] is the common case and pins `T = i64`,
/// `K = &'static str`. Reach for this — or for the type-changing
/// [`HistoryBuilder::time_type`] / [`HistoryBuilder::key_type`] — when
/// either needs to be something else.
///
/// ```
/// # use trueskill_tt::{History, HistoryBuilder, Untimed};
/// let mut h = HistoryBuilder::<String, Untimed>::new().build();
/// h.record_winner(&"alice".to_string(), &"bob".to_string(), Untimed)?;
/// h.converge()?;
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
pub fn new() -> Self {
Self::default()
}
/// Set the drift rate, in skill units per unit of time.
///
/// Shorthand for `.drift(ConstantDrift::new(gamma))`. Drift is the
/// most-tuned parameter after `sigma` and [`GAMMA`](crate::GAMMA) is a
/// public constant, but reaching it otherwise means first discovering
/// [`ConstantDrift`] — a type a caller has no other reason to name.
///
/// Only on the `ConstantDrift` builder: `gamma` is that model's parameter,
/// not a property every [`Drift`] has. Use
/// [`drift`](HistoryBuilder::drift) for anything else.
///
/// # Panics
///
/// Panics unless `gamma` is finite and non-negative. Drift enters as
/// `gamma^2` per elapsed tick, so a negative value would behave as its
/// absolute value — the same sign-absorption already rejected for `sigma`
/// and `beta`.
pub fn gamma(self, gamma: f64) -> Self {
assert!(
gamma.is_finite() && gamma >= 0.0,
"gamma must be finite and non-negative (got {gamma}); it is only ever \
squared, so a negative value would silently behave as its absolute value"
);
self.drift(ConstantDrift::new(gamma))
}
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D, O> {
/// Promote a key to its [`Index`], creating the entry if it is new.
///
/// Crate-internal since #73: interning reserves a storage slot and nothing
/// more, and no public method ever accepted the handle it returned.
/// [`History::register`] is the public way to declare a competitor.
pub(crate) fn intern<Q>(&mut self, key: &Q) -> Index
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized,
{
self.keys.get_or_create(key)
}
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D, O> {
fn iteration(&mut self) -> (f64, f64) {
let mut step = (0.0, 0.0);
if self.time_slices.is_empty() {
return step;
}
competitor::clean(self.competitors.values_mut(), false);
for j in (0..self.time_slices.len() - 1).rev() {
for competitor in self.time_slices[j + 1].skills.keys() {
self.competitors.get_mut(competitor).unwrap().message = Some(
self.time_slices[j + 1].backward_prior_out(&competitor, &self.competitors),
);
}
let old = self.time_slices[j].posteriors();
self.time_slices[j].new_backward_info(&self.competitors);
self.observer.on_slice_processed(
&self.time_slices[j].time,
j,
self.time_slices[j].events.len(),
);
let new = self.time_slices[j].posteriors();
step = old
.iter()
.fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a])));
}
competitor::clean(self.competitors.values_mut(), false);
for j in 1..self.time_slices.len() {
for competitor in self.time_slices[j - 1].skills.keys() {
self.competitors.get_mut(competitor).unwrap().message =
Some(self.time_slices[j - 1].forward_prior_out(&competitor));
}
let old = self.time_slices[j].posteriors();
self.time_slices[j].new_forward_info(&self.competitors);
self.observer.on_slice_processed(
&self.time_slices[j].time,
j,
self.time_slices[j].events.len(),
);
let new = self.time_slices[j].posteriors();
step = old
.iter()
.fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a])));
}
if self.time_slices.len() == 1 {
let old = self.time_slices[0].posteriors();
self.time_slices[0].iteration(0, &self.competitors);
self.observer.on_slice_processed(
&self.time_slices[0].time,
0,
self.time_slices[0].events.len(),
);
let new = self.time_slices[0].posteriors();
step = old
.iter()
.fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a])));
}
step
}
/// Number of distinct time slices in the history.
#[must_use]
pub fn time_slices_len(&self) -> usize {
self.time_slices.len()
}
/// Every competitor the history knows, in the order they were first seen.
///
/// Includes competitors created by [`History::register`] that have not yet
/// appeared in an event.
///
/// Insertion order, not hash order: a `HashMap` walk differs between
/// processes, which would make anything built from this — a standings
/// table, a printed report — unreproducible.
///
/// ```
/// # use trueskill_tt::History;
/// let mut h = History::builder().build();
/// h.record_winner(&"alice", &"bob", 1)?;
/// let names: Vec<_> = h.competitors().copied().collect();
/// assert_eq!(names, ["alice", "bob"]);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
#[must_use]
pub fn competitors(&self) -> impl ExactSizeIterator<Item = &K> {
self.keys.keys()
}
/// How many competitors the history knows.
#[must_use]
pub fn competitor_count(&self) -> usize {
self.keys.len()
}
/// How many events have been ingested.
#[must_use]
pub fn event_count(&self) -> usize {
self.size
}
/// Learning curves for all competitors, keyed by their user-facing key.
#[must_use]
pub fn learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
#[cfg(feature = "rayon")]
{
use rayon::prelude::*;
let per_slice: Vec<Vec<(Index, T, Gaussian)>> = self
.time_slices
.par_iter()
.map(|ts| {
ts.skills
.iter()
.map(|(idx, sk)| (idx, ts.time, sk.posterior()))
.collect()
})
.collect();
let mut data: HashMap<K, Vec<(T, Gaussian)>> = HashMap::new();
for slice_contrib in per_slice {
for (idx, t, g) in slice_contrib {
if let Some(key) = self.keys.key(idx).cloned() {
data.entry(key).or_default().push((t, g));
}
}
}
data
}
#[cfg(not(feature = "rayon"))]
{
let mut data: HashMap<K, Vec<(T, Gaussian)>> = HashMap::new();
for slice in &self.time_slices {
for (idx, skill) in slice.skills.iter() {
if let Some(key) = self.keys.key(idx).cloned() {
data.entry(key)
.or_default()
.push((slice.time, skill.posterior()));
}
}
}
data
}
}
/// Skill estimate at the latest time slice the competitor appears in.
/// Configure a competitor before anything has been observed about them.
///
/// The configuration a competitor needs is often a property of the domain
/// rather than of any one event — "every layout is static", "this bot sits
/// at a known strength". Stating it per-event means every ingestion path
/// has to remember it, and the fluent and two-argument paths could not
/// state it at all.
///
/// ```
/// # use trueskill_tt::{History, Member};
/// let mut h = History::builder().build();
/// h.register(Member::new("layout_7").with_drift_scale(0.0))?;
///
/// // Reaches a competitor first seen through any route, including the
/// // two-argument one, which cannot carry configuration itself.
/// h.record_winner(&"player", &"layout_7", 1)?;
/// assert_eq!(h.rating(&"layout_7").unwrap().drift_scale(), 0.0);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// The competitor exists from this point on, with no appearances, so
/// [`History::rating`] can read back what was actually stored — the
/// diagnostic that was previously missing entirely.
///
/// `weight` is per-event and has no meaning here, so a `Member` carrying a
/// non-default one is rejected rather than silently ignored.
///
/// # Errors
///
/// `AlreadyRegistered` if the competitor already exists, whether from an
/// earlier `register` or from an event. `InvalidParameter` for a `weight`
/// other than 1.0, or a `drift_scale` that is negative or non-finite.
pub fn register(&mut self, member: Member<K>) -> Result<(), InferenceError>
where
K: std::fmt::Debug,
{
if member.weight != 1.0 {
return Err(InferenceError::InvalidParameter {
name: "weight",
value: member.weight,
});
}
if let Some(scale) = member.drift_scale {
if !scale.is_finite() || scale < 0.0 {
return Err(InferenceError::InvalidParameter {
name: "drift_scale",
value: scale,
});
}
}
let key = format!("{:?}", member.key);
let idx = self.keys.get_or_create(&member.key);
if self.competitors.contains(idx) {
return Err(InferenceError::AlreadyRegistered { key });
}
let mut rating = Rating::new(
Gaussian::from_ms(self.mu, self.sigma),
self.beta,
self.drift,
);
if let Some(prior) = member.prior {
rating.prior = prior;
}
if let Some(scale) = member.drift_scale {
rating.drift_scale = scale;
}
self.declared.insert(
idx,
CompetitorConfig {
prior: member.prior,
drift_scale: member.drift_scale,
},
);
self.competitors.insert(
idx,
Competitor {
rating,
message: None,
last_time: None,
},
);
Ok(())
}
/// The configuration in force for a competitor, or `None` if the history
/// has never seen them.
///
/// Reads back what was actually stored, which is what makes a
/// configuration mistake detectable from outside the crate. Every other
/// accessor returns what inference *inferred*; this returns what it was
/// told.
#[must_use]
pub fn rating<Q>(&self, key: &Q) -> Option<Rating<T, D>>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let idx = self.keys.get(key)?;
self.competitors
.contains(idx)
.then(|| self.competitors[idx].rating)
}
/// The competitor's latest posterior skill, or `None` if the history has
/// never seen the key or they have no appearances.
///
/// "Latest" is their own last appearance, which need not be the last slice
/// in the history. For everyone at once — a leaderboard — use
/// [`History::current_skills`], which is one pass rather than one per key.
///
/// This reads whatever the fit currently holds. It does not converge, and
/// it does not check that a previous `converge` succeeded.
#[must_use]
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let idx = self.keys.get(key)?;
self.time_slices
.iter()
.rev()
.find_map(|ts| ts.skills.get(idx).map(|sk| sk.posterior()))
}
/// Every competitor's latest posterior, keyed by user-facing key.
///
/// The plural of `current_skill`, and the cheap way to build a
/// leaderboard: the alternative was materialising every competitor's full
/// smoothed curve via `learning_curves` only to read the last point of
/// each.
///
/// A competitor that is registered but has never played has no posterior
/// and is absent from the map, exactly as `current_skill` returns `None`
/// for them.
#[must_use]
pub fn current_skills(&self) -> HashMap<K, Gaussian> {
let mut latest: HashMap<Index, Gaussian> = HashMap::new();
// Time order, so a later slice overwrites an earlier one.
for slice in &self.time_slices {
for (competitor, skill) in slice.skills.iter() {
latest.insert(competitor, skill.posterior());
}
}
latest
.into_iter()
.filter_map(|(competitor, posterior)| {
self.keys.key(competitor).cloned().map(|k| (k, posterior))
})
.collect()
}
/// Learning curve for a single key: (time, posterior) pairs in time order.
///
/// `None` if the history has never seen the key; `Some(vec![])` if it is
/// registered but has no appearances. The two used to be the same empty
/// `Vec`, so a typo'd key was indistinguishable from a real competitor
/// awaiting their first game.
#[must_use]
pub fn learning_curve<Q>(&self, key: &Q) -> Option<Vec<(T, Gaussian)>>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let idx = self.keys.get(key)?;
Some(
self.time_slices
.iter()
.filter_map(|ts| ts.skills.get(idx).map(|sk| (ts.time, sk.posterior())))
.collect(),
)
}
/// Filtered learning curves for all competitors, keyed by user-facing key.
///
/// Each point is the posterior using only events up to and including that
/// time — "what we knew then". Contrast `learning_curves`, whose points
/// are smoothed and so incorporate rounds played later.
///
/// Runs a full forward pass per call and caches nothing. This is the
/// entry point for multi-key work — see `filtered_learning_curve` for
/// why calling that once per key is far more expensive.
#[must_use]
pub fn filtered_learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
let mut data: HashMap<K, Vec<(T, Gaussian)>> = HashMap::new();
for (time, step) in self.filtered_pass(&HashSet::new()) {
for (competitor, posterior) in step.posteriors {
if let Some(key) = self.keys.key(competitor).cloned() {
data.entry(key).or_default().push((time, posterior));
}
}
}
data
}
/// Filtered learning curve for a single key: (time, posterior) pairs in
/// time order.
///
/// Despite mirroring `learning_curve`'s signature, this is not the cheap
/// per-key lookup that method is: it runs a full forward pass, O(events),
/// discarding every posterior but the requested key's. N keys fetched
/// this way costs O(N * events); use `filtered_learning_curves` for
/// multi-key work instead — it computes the same pass once.
///
/// `None` for an unknown key, as with `learning_curve`.
#[must_use]
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Option<Vec<(T, Gaussian)>>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
let idx = self.keys.get(key)?;
Some(
self.filtered_pass(&HashSet::new())
.into_iter()
.filter_map(|(time, step)| {
step.posteriors
.iter()
.find(|(competitor, _)| *competitor == idx)
.map(|&(_, posterior)| (time, posterior))
})
.collect(),
)
}
/// Sum per-slice evidence.
///
/// `forward` selects `skill.forward` as each event's prior instead of the
/// cavity. That is a genuine forward-only (filtering) quantity ONLY on a
/// history that has never been converged: `iteration` alternates backward
/// and forward sweeps, so from the second iteration onward the likelihood
/// feeding the forward message has already absorbed backward information.
/// For a filtering quantity that holds after convergence, use
/// `filtered_log_evidence`.
pub(crate) fn log_evidence_internal(&self, forward: bool, targets: &[Index]) -> f64 {
// Bound before the closure so it captures the store rather than all of
// `&self`: capturing `&History` would drag `KeyTable<K>` in and demand
// `K: Sync` from every caller, which the key type need not satisfy.
let competitors = &self.competitors;
#[cfg(feature = "rayon")]
{
use rayon::prelude::*;
let per_slice: Vec<f64> = self
.time_slices
.par_iter()
.map(|ts| ts.log_evidence(targets, forward, competitors))
.collect();
per_slice.into_iter().sum()
}
#[cfg(not(feature = "rayon"))]
{
self.time_slices
.iter()
.map(|ts| ts.log_evidence(targets, forward, competitors))
.sum()
}
}
/// Total log-evidence across the history.
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence_internal(false, &[])
}
/// Log-evidence restricted to time slices containing at least one of the
/// given keys. Useful for leave-one-out cross-validation.
///
/// # Errors
///
/// `UnknownKey` for any key the history has never seen.
///
/// This used to `filter_map` unknown keys away, and an empty target list
/// means *no restriction* downstream — so a list of entirely unknown keys
/// returned the **whole-history** value. Measured on a two-cohort fixture,
/// `log_evidence_for(["typo"])` returned exactly `log_evidence()`. On the
/// one workload this is documented for, that silently yields the
/// un-held-out score: a plausible number that invalidates the comparison it
/// was computed for.
pub fn log_evidence_for<Q>(&self, keys: &[&Q]) -> Result<f64, InferenceError>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized + std::fmt::Debug,
{
let targets: Vec<Index> = self.resolve_targets(keys)?.into_iter().collect();
Ok(self.log_evidence_internal(false, &targets))
}
/// Intern a key list, refusing the whole list if any key is unknown.
///
/// Shared by the two `*_for` evidence accessors so they cannot drift apart
/// on how an unknown key is reported.
fn resolve_targets<Q>(&self, keys: &[&Q]) -> Result<HashSet<Index>, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let mut targets = HashSet::with_capacity(keys.len());
for (member, key) in keys.iter().enumerate() {
let idx = self
.keys
.get(*key)
.ok_or_else(|| InferenceError::UnknownKey {
team: 0,
member,
key: format!("{key:?}"),
})?;
targets.insert(idx);
}
Ok(targets)
}
/// Walk the slices in time order carrying forward messages only.
///
/// This is the forward half of `iteration` with the backward half never
/// run. It reads `self` and mutates nothing.
///
/// `targets` restricts each step's evidence sum; the messages carried
/// forward are unaffected. See `TimeSlice::filtered_step`.
fn filtered_pass(&self, targets: &HashSet<Index>) -> Vec<(T, FilteredStep)> {
let mut messages: HashMap<Index, Gaussian> = HashMap::new();
let mut pass = Vec::with_capacity(self.time_slices.len());
for slice in &self.time_slices {
let step = slice.filtered_step(&messages, &self.competitors, targets);
for &(competitor, posterior) in &step.posteriors {
messages.insert(competitor, posterior);
}
pass.push((slice.time, step));
}
pass
}
/// Total log-evidence under forward-only (filtering) information.
///
/// Each event is scored using only what was known before that *time*,
/// which is the right quantity for prequential scoring and model
/// comparison. Events sharing a timestamp still inform each other
/// through the within-slice sweep, so within one slice this is not a
/// guarantee that event A is scored independently of simultaneous event
/// B. Contrast `log_evidence`, whose per-event priors carry information
/// from events that had not happened yet.
///
/// Runs a full forward pass per call and caches nothing. The result does
/// not depend on whether `converge` has been called.
#[must_use]
pub fn filtered_log_evidence(&self) -> f64 {
self.filtered_pass(&HashSet::new())
.iter()
.map(|(_, step)| step.log_evidence)
.sum()
}
/// Filtered log-evidence restricted to events involving at least one of
/// the given keys.
///
/// The intersection of the two axes the other three evidence accessors
/// span: forward-only *and* key-restricted, which is what per-competitor
/// prequential scoring needs. `log_evidence_for` is the smoothed
/// counterpart, and its per-event priors carry information from events
/// that had not happened yet — so it is not the quantity for scoring a
/// competitor's history *as it unfolded*.
///
/// Restricting filters which events are *scored*, not which are *run*: the
/// forward messages still absorb every event, so this is a held-out score
/// under the real history, not a score under a counterfactual one where
/// nobody else played.
///
/// Runs a full forward pass per call and caches nothing.
///
/// # Errors
///
/// `UnknownKey` for any key the history has never seen — see
/// `log_evidence_for` for why an unknown key must not be skipped.
pub fn filtered_log_evidence_for<Q>(&self, keys: &[&Q]) -> Result<f64, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let targets = self.resolve_targets(keys)?;
Ok(self
.filtered_pass(&targets)
.iter()
.map(|(_, step)| step.log_evidence)
.sum())
}
/// The configured observer.
///
/// `History` takes its observer by value, so this is how a caller inspects
/// one it did not keep a handle to. For an observer that accumulates
/// state, prefer passing an `Arc` and keeping a clone — see the
/// [`Observer`] docs.
#[must_use]
pub fn observer(&self) -> &O {
&self.observer
}
/// Consume the history and return its observer.
///
/// Useful for reclaiming a non-shared observer's accumulated state after
/// `converge` without needing interior mutability.
#[must_use]
pub fn into_observer(self) -> O {
self.observer
}
/// Every team's member skills, validated.
///
/// # Errors
///
/// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`. Unknown keys are
/// reported rather than dropped — silently skipping them would turn a team
/// of strangers into a confident-looking prediction about nobody, which is
/// the failure this replaced.
fn member_skills<Q>(&self, teams: &[&[&Q]]) -> Result<Vec<Vec<Gaussian>>, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if teams.len() < 2 {
return Err(InferenceError::NotEnoughTeams { got: teams.len() });
}
let mut gathered = Vec::with_capacity(teams.len());
for (team_idx, team) in teams.iter().enumerate() {
if team.is_empty() {
return Err(InferenceError::EmptyTeam { team: team_idx });
}
let mut members = Vec::with_capacity(team.len());
for (member_idx, key) in team.iter().enumerate() {
// A key can be missing two ways — never interned, or interned
// with no recorded skill — and both mean the same thing to a
// caller, so they take the same branch.
let skill = self.keys.get(*key).and_then(|index| {
self.time_slices
.iter()
.rev()
.find_map(|ts| ts.skills.get(index).map(|s| s.posterior()))
});
let skill = match skill {
Some(skill) => skill,
None => match self.unknown_keys {
crate::UnknownKeys::Prior => Gaussian::from_ms(self.mu, self.sigma),
crate::UnknownKeys::Reject => {
return Err(InferenceError::UnknownKey {
team: team_idx,
member: member_idx,
key: format!("{key:?}"),
});
}
},
};
// The single gate every prediction path reads skills through.
//
// `converge` refuses to report a NaN fit, but nothing stopped a
// caller ignoring that error and predicting anyway. Measured on
// a point-mass-prior history with `beta(0.0)`, after `converge`
// returned `NonFiniteResult`: `quality` gave `Ok(NaN)`,
// `predict_outcome().total()` gave `NaN`, and
// `predict_win_probabilities` gave `Ok([0.0, 0.0])` — finite,
// plausible, and summing to zero against a doc that promises
// one. That last shape is the dangerous one, and it is exactly
// what a caller checking `total() ≈ 1` would catch on the
// others and miss here.
//
// Checked on `mu` / `sigma`, not on the natural parameters.
// Measured: a point-mass posterior is `pi = inf`, which is a
// legitimate converged state that reads back as `mu = 0`,
// `sigma = 0` — a natural-parameter finiteness check rejects
// it and turns a working prediction into an error. The
// question here is whether the *usable* moments exist, and
// `mu()` / `sigma()` are what every prediction path consumes.
//
// This also catches an improper skill (`pi <= 0`), which
// `sigma()` reports as infinite. Nothing produces one as a
// stored posterior today, and a prediction from an
// uninformative skill would be meaningless if it did.
if !skill.mu().is_finite() || !skill.sigma().is_finite() {
return Err(InferenceError::NonFiniteResult {
context: "prediction read a skill with no usable mean or \
variance; the fit did not converge",
step: (skill.mu(), skill.sigma()),
});
}
members.push(skill);
}
gathered.push(members);
}
// Degenerate performances: `beta == 0` with every skill a point mass.
//
// Every prediction here is a statement about how performances *vary*,
// and in this configuration nothing varies. The consequences were three
// different wrong answers rather than one error. `quality`
// **panicked** — "cannot invert a singular matrix", from a
// `Result`-returning method, on a history that had converged cleanly —
// because the contrast covariance `beta^2 A^T A + A^T S A` is exactly
// singular. `predict_win_probabilities` returned `Ok([0.0, 0.0])`
// against a doc that promises they sum to one at `p_draw == 0`; the
// promise assumes continuous performances, where an exact tie has
// measure zero, and point masses break that assumption rather than the
// arithmetic.
//
// Checked once here, at the gate every prediction path reads skills
// through, rather than per method — the condition is the same one each
// time and it is a property of the parameters, not a numerical
// accident.
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
return Err(InferenceError::InvalidParameter {
name: "beta with point-mass skills",
value: 0.0,
});
}
Ok(gathered)
}
/// Each team's performance Gaussian, and its member count.
///
/// Performance is skill inflated by `beta`: the question a prediction
/// answers is "how will they do today", not "how good are they".
///
/// # Errors
///
/// As `member_skills`.
fn performances<Q>(
&self,
teams: &[&[&Q]],
) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let skills = self.member_skills(teams)?;
let performances = skills
.iter()
.map(|team| {
team.iter().fold(crate::N00, |acc, s| {
acc.convolve(s.forget(self.beta.powi(2)))
})
})
.collect();
let sizes = skills.iter().map(Vec::len).collect();
Ok((performances, sizes))
}
/// Draw margins per team pair.
///
/// Inference derives the margin per rank-adjacent pair from those two
/// teams' betas (`Game::likelihoods`), so prediction must too — a single
/// game-wide margin would describe a different model than the one that
/// will actually be fitted.
fn margins(&self, sizes: &[usize]) -> crate::predict::Margins {
let beta_sq = self.beta.powi(2);
let p_draw = self.p_draw;
crate::predict::Margins::new(sizes.len(), |i, j| {
if p_draw == 0.0 {
0.0
} else {
let sd = ((sizes[i] + sizes[j]) as f64 * beta_sq).sqrt();
crate::compute_margin(p_draw, sd)
}
})
}
/// How fair a matchup between these teams would be, against the fit.
///
/// Values range roughly `[0, 1]`; 1 is perfectly matched. Supports any
/// number of teams.
///
/// The method form of the free [`quality`](crate::quality), which scores a
/// hypothetical from explicit skill distributions instead. That is the rule
/// the whole family follows: a free function takes parameters, the
/// same-named `History` method asks the question against what was fitted.
///
/// It was `predict_quality` until #78 pointed out that it predicts nothing
/// — it answers "is this matchup *fair*", not "what will happen". Fair is
/// also not the same as *informative*: the two coincide for two evenly
/// matched teams and diverge elsewhere. See
/// [`History::expected_information_gain`] for the other question.
///
/// # Preconditions
///
/// Every key must already be known to the history — that is, must have
/// appeared in an ingested event. An unknown key is `UnknownKey`, not a
/// silently dropped member. If your caller cannot guarantee that, pre-filter
/// with [`History::current_skill`] or [`History::competitors`]; treating the
/// error as "no information" and substituting a neutral value turns a
/// whole-team miss into a plausible constant, which is invisible to any
/// test that does not assert on variation.
///
/// # Errors
///
/// `NotEnoughTeams`, `EmptyTeam` or `UnknownKey`.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn quality<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let groups = self.member_skills(teams)?;
let group_refs: Vec<&[Gaussian]> = groups.iter().map(Vec::as_slice).collect();
Ok(crate::quality(&group_refs, self.beta))
}
/// The joint posterior precision over the whole history, time-expanded.
///
/// A competitor's skill is not one variable but one per appearance, linked
/// by drift. That is the point of Through Time, and it is why a joint over
/// a single slice answers almost nothing: competitors are each read at
/// *their own* last appearance, and in a history with per-day or per-event
/// slices those are different slices. A 76-slice history whose last slice
/// holds one competitor can answer no pairwise question at all.
///
/// Variables are `(competitor, appearance)`. Factors are the prior on a
/// first appearance, the drift between consecutive appearances, and the
/// within-slice event contrasts. Consecutive appearances with zero drift
/// variance are the same variable rather than two joined by an infinite
/// precision, which keeps the matrix positive-definite when a competitor is
/// pinned with `drift_scale = 0`.
///
/// Returns the matrix, each competitor's row at its latest appearance, and
/// the row at each `(competitor, slice)` for time-addressed queries.
/// Drift below which two consecutive appearances are one latent variable.
///
/// The rule used to be `drift <= 0.0` exactly, and everything above it got an
/// explicit `1.0 / drift` precision. That is a representation the matrix cannot
/// hold: at `drift = 1e-16` the entry is `1e16`, and `1e16 + 0.28` rounds back
/// to `1e16`, so the prior and the event contrasts are annihilated in the
/// stored `f64` before the factorisation ever runs. Measured, `drift_scale =
/// 1e-10` returned a posterior variance **12 000x too small** — a 111x
/// overconfident interval — as `Ok`, and the band just above it returned a
/// misleading `JointUnavailable`.
///
/// Solved exactly in high precision the same system is perfectly well
/// conditioned: it converges smoothly onto the collapsed value and is flat from
/// `1e-16` down to `1e-40`. So this is a representation problem, not a
/// conditioning one, and scaling cannot fix it — symmetric (Jacobi)
/// equilibration was measured **30x worse**, because the information is already
/// gone from the assembled matrix by the time a solver sees it.
///
/// The threshold balances the two errors that trade off here. Ignoring a real
/// drift costs roughly `drift / V`; representing one costs roughly
/// `EPSILON * V / drift`, since `1 / drift` swamps the other precisions in the
/// row. They cross at `drift ~ V * sqrt(EPSILON)`, which is what this returns.
/// `V` is the competitor's own prior variance, so the threshold follows the
/// scale each competitor is actually measured on.
///
/// Ordinary drift is far above this and is unaffected: the crate's default
/// `gamma = 25/300` accumulates `0.0069` per unit time against a threshold of
/// `1.0e-6` at the default prior.
fn collapse_threshold(prior_variance: f64) -> f64 {
// sqrt(f64::EPSILON), as a const rather than a runtime sqrt.
const SQRT_EPSILON: f64 = 1.490_116_119_384_765_6e-8;
prior_variance * SQRT_EPSILON
}
fn time_expanded_joint(&self) -> TimeExpanded {
let mut latest: HashMap<Index, (usize, usize)> = HashMap::new();
let mut at_slice: HashMap<(Index, usize), usize> = HashMap::new();
// Row of a competitor's previous appearance, and the drift variance
// separating it from the current one.
let mut previous: HashMap<Index, usize> = HashMap::new();
let mut drift_links: Vec<(usize, usize, f64)> = Vec::new();
let mut first_rows: Vec<(usize, Index)> = Vec::new();
let mut n = 0usize;
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
for (competitor, elapsed) in slice.appearances() {
let rating = &self.competitors[competitor].rating;
let row = match previous.get(&competitor) {
None => {
let row = n;
n += 1;
first_rows.push((row, competitor));
row
}
Some(&prev) => {
let drift = rating.drift_variance_for_elapsed(elapsed);
if drift <= Self::collapse_threshold(rating.prior.variance()) {
// No drift, or too little to represent: the same
// latent skill, not two. See `collapse_threshold`.
prev
} else {
let row = n;
n += 1;
drift_links.push((prev, row, drift));
row
}
}
};
previous.insert(competitor, row);
latest.insert(competitor, (row, slice_idx));
at_slice.insert((competitor, slice_idx), row);
}
}
let mut lambda = vec![0.0; n * n];
for (row, competitor) in first_rows {
lambda[row * n + row] +=
1.0 / self.competitors[competitor].rating.prior.sigma().powi(2);
}
for (a, b, drift) in drift_links {
lambda[a * n + a] += 1.0 / drift;
lambda[b * n + b] += 1.0 / drift;
lambda[a * n + b] -= 1.0 / drift;
lambda[b * n + a] -= 1.0 / drift;
}
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
for (contrast, noise) in slice.scored_contrasts(&self.competitors) {
for (ia, ca) in &contrast {
let ra = at_slice[&(*ia, slice_idx)];
for (ib, cb) in &contrast {
let rb = at_slice[&(*ib, slice_idx)];
lambda[ra * n + rb] += ca * cb / noise;
}
}
}
}
TimeExpanded {
lambda,
latest,
at_slice,
width: n,
}
}
/// Resolve `terms` into a contrast over the time-expanded rows, the
/// coefficients of competitors the history has never seen, and the mean.
///
/// `row_for` picks which appearance of a competitor the caller means —
/// their latest, or the one at a given time.
fn resolve_terms<Q>(
&self,
terms: &[(&Q, f64)],
width: usize,
row_for: impl Fn(Index) -> Option<(usize, usize)>,
) -> Result<ResolvedTerms, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let mut contrast = vec![0.0; width];
let mut unseen: BTreeMap<String, f64> = BTreeMap::new();
let mut mean = 0.0;
for (member, (key, coefficient)) in terms.iter().enumerate() {
let located = self
.keys
.get(*key)
.and_then(|index| row_for(index).map(|located| (index, located)));
match located {
Some((index, (row, slice_idx))) => {
contrast[row] += coefficient;
mean += coefficient
* self.time_slices[slice_idx]
.skills
.get(index)
.expect("row came from this slice")
.posterior()
.mu();
}
None => match self.unknown_keys {
crate::UnknownKeys::Prior => {
mean += coefficient * self.mu;
*unseen.entry(format!("{key:?}")).or_insert(0.0) += coefficient;
}
crate::UnknownKeys::Reject => {
return Err(InferenceError::UnknownKey {
team: 0,
member,
key: format!("{key:?}"),
});
}
},
}
}
Ok(ResolvedTerms {
contrast,
unseen,
mean,
})
}
/// Factorise the joint posterior once, to answer many questions against it.
///
/// The factorisation is `O(n^3)` in the history's *appearances* and
/// depends only on the fit, so a caller asking about every pair in a
/// standings table, every cell in a grid, or every candidate in an
/// active-learning sweep should pay for it once rather than once per
/// question. This handle is the only way to ask those questions: `History`
/// carried one-shot wrappers that re-factorised every call, and they were
/// removed in #78 precisely because the borrow here is what makes the cost
/// visible.
///
/// A `Joint` pays it once. Each subsequent query is `O(n^2)` — one forward
/// substitution — and returns exactly what the one-shot call would.
///
/// ```
/// # use trueskill_tt::smallvec::smallvec;
/// # use trueskill_tt::{Event, History, Member, Outcome, Team};
/// # let mut h = History::builder().score_sigma(1.0).build();
/// # let round = |x, y, sx, sy, t| Event {
/// # time: t,
/// # teams: smallvec![
/// # Team::with_members([Member::new(x)]),
/// # Team::with_members([Member::new(y)]),
/// # ],
/// # outcome: Outcome::scores([sx, sy]),
/// # };
/// # h.add_events(vec![
/// # round("a", "b", 3.0, 1.0, 1),
/// # round("b", "c", 2.0, 1.0, 2),
/// # ]).unwrap();
/// # h.converge().unwrap();
/// let joint = h.joint()?;
/// for (a, b) in [("a", "b"), ("a", "c"), ("b", "c")] {
/// let gap = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)])?;
/// println!("{a} - {b}: {:.3} +/- {:.3}", gap.mu(), gap.sigma());
/// }
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// The handle borrows the history, so the borrow checker enforces what a
/// cache would otherwise have to invalidate: no events can be added and no
/// refit can run while it is alive. Drop it to release the factorisation,
/// which is `n^2` floats and is the largest thing this crate allocates.
///
/// # Errors
///
/// `JointUnavailable` if the history is empty, contains ranked events, or
/// yields a precision matrix that is not positive-definite.
pub fn joint(&self) -> Result<Joint<'_, K, T, D, O>, InferenceError> {
if self.time_slices.is_empty() {
return Err(InferenceError::JointUnavailable {
reason: "the history has no events",
});
}
if !self.time_slices.iter().all(TimeSlice::all_scored) {
return Err(InferenceError::JointUnavailable {
reason: "the history contains ranked events, whose EP factors are \
not retained after convergence",
});
}
let TimeExpanded {
lambda,
latest,
at_slice,
width,
} = self.time_expanded_joint();
let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or(
InferenceError::JointUnavailable {
reason: "the precision matrix is not positive-definite; the usual \
cause is a competitor with neither a proper prior nor any \
evidence, but an extreme prior or drift can also make the \
assembled matrix indefinite in floating point",
},
)?;
Ok(Joint {
history: self,
cholesky,
latest,
at_slice,
width,
})
}
/// Predictive distribution of the score margin between two teams.
///
/// Answers "what will the gap be, and how wide is that interval" for a
/// scored matchup, composing the three things that make it uncertain: how
/// unsure the model is about the competitors, their per-event performance
/// noise, and the observation noise on the score itself.
///
/// The interval widens as the model knows less. Measured on a fixture where
/// one opponent has forty rounds and another has one, the margin's sigma
/// goes from 2.48 to 3.38 — which is the property a caller most needs and
/// the one a hand-fitted noise law tends to lose.
///
/// # Why a margin rather than a score
///
/// The model never sees an absolute score. Scored ingestion reduces each
/// event to `score_a - score_b` before inference, so shifting every score
/// in a history by a constant produces a bit-identical fit. There is
/// therefore no information from which to predict what a competitor will
/// *score*; only what the gap between two of them will be. Asking for an
/// absolute score would return a number derived entirely from the prior,
/// which is the kind of plausible constant this crate tries not to hand out.
///
/// # Errors
///
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam`,
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
/// and `JointUnavailable` if the history is empty or holds ranked events in
/// *any* slice — not merely the latest one.
pub fn predict_margin<Q>(&self, teams: &[&[&Q]]) -> Result<Gaussian, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if teams.len() != 2 {
return Err(InferenceError::MismatchedShape {
kind: "predict_margin takes exactly 2 teams",
expected: 2,
got: teams.len(),
});
}
let mut terms: Vec<(&Q, f64)> = Vec::new();
let mut performance_noise = 0.0;
for (team_idx, team) in teams.iter().enumerate() {
if team.is_empty() {
return Err(InferenceError::EmptyTeam { team: team_idx });
}
let sign = if team_idx == 0 { 1.0 } else { -1.0 };
for key in team.iter() {
terms.push((*key, sign));
// Each member contributes its own performance noise to the
// margin regardless of which side it is on.
let beta = self
.keys
.get(*key)
.map_or(self.beta, |index| self.competitors[index].rating.beta);
performance_noise += beta * beta;
}
}
let skill_gap = self.joint()?.posterior_of(&terms)?;
let variance = skill_gap.sigma().powi(2) + performance_noise + self.score_sigma.powi(2);
Ok(Gaussian::from_mv(skill_gap.mu(), variance))
}
/// Expected information gain of running this matchup, in nats.
///
/// Answers "which comparison should I run next" rather than "who will
/// win": the outcome-weighted divergence between current beliefs and the
/// beliefs each possible result would produce. Higher means the result
/// would teach you more.
///
/// Uses each competitor's current skill as the prior, and the history's
/// own `beta`, `drift` and `p_draw`, so the outcomes weighted here are the
/// ones that would actually be fitted if the matchup were played and
/// recorded.
///
/// Distinct from [`History::quality`], which measures *fairness*.
/// The two coincide for two evenly matched competitors and diverge
/// elsewhere. See [`expected_information_gain`](crate::expected_information_gain)
/// for the scale, the analytic `ln k` ceiling, and the cost.
///
/// # Preconditions
///
/// Every key must already be known to the history — that is, must have
/// appeared in an ingested event. An unknown key is `UnknownKey`, not a
/// silently dropped member. If your caller cannot guarantee that, pre-filter
/// with [`History::current_skill`] or [`History::competitors`]; treating the
/// error as "no information" and substituting a neutral value turns a
/// whole-team miss into a plausible constant, which is invisible to any
/// test that does not assert on variation.
///
/// # Errors
///
/// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey` and `TooManyTeams` for the
/// shape of the request, `GridTooCoarse` when the performance sigmas are
/// too far apart to integrate on one grid, and anything inference returns
/// for a hypothetical outcome.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn expected_information_gain<Q>(&self, teams: &[&[&Q]]) -> Result<f64, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let skills = self.member_skills(teams)?;
let ratings: Vec<Vec<Rating<T, D>>> = skills
.iter()
.map(|team| {
team.iter()
.map(|&skill| Rating::new(skill, self.beta, self.drift))
.collect()
})
.collect();
let team_refs: Vec<&[Rating<T, D>]> = ratings.iter().map(Vec::as_slice).collect();
crate::expected_information_gain(
&team_refs,
&crate::GameOptions {
p_draw: self.p_draw,
score_sigma: self.score_sigma,
convergence: self.convergence,
},
)
}
/// `P(team i finishes strictly first)`, for every team.
///
/// Supports any number of teams. Because performances are independent
/// Gaussians, this separates into a one-dimensional integral per team —
/// no multivariate orthant probability is involved — and is evaluated by
/// adaptive quadrature to within the precision of the underlying normal
/// CDF (~1e-8).
///
/// With a zero `p_draw` these sum to one. With a positive `p_draw` the
/// shortfall is the probability that the top place is shared.
///
/// Cheap at any team count: cost grows as the square of the team count,
/// not factorially. Prefer this to [`History::predict_outcome`] when you
/// only need to know who wins.
///
/// # Preconditions
///
/// Every key must already be known to the history — that is, must have
/// appeared in an ingested event. An unknown key is `UnknownKey`, not a
/// silently dropped member. If your caller cannot guarantee that, pre-filter
/// with [`History::current_skill`] or [`History::competitors`]; treating the
/// error as "no information" and substituting a neutral value turns a
/// whole-team miss into a plausible constant, which is invisible to any
/// test that does not assert on variation.
///
/// # Errors
///
/// `NotEnoughTeams`, `EmptyTeam`, or `UnknownKey`.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn predict_win_probabilities<Q>(&self, teams: &[&[&Q]]) -> Result<Vec<f64>, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let (performances, sizes) = self.performances(teams)?;
Ok(crate::predict::win_probabilities(
&performances,
&self.margins(&sizes),
))
}
/// The full distribution over finishing orders.
///
/// Every entry is a rank vector — the shape [`crate::Outcome::ranking`]
/// takes, equal ranks meaning a tie — paired with its probability. The
/// entries are exhaustive and disjoint, so they sum to one; that identity
/// is the strongest available check on the numerics and is worth asserting
/// in tests via [`Prediction::total`].
///
/// Accounts for `p_draw`: with a positive draw probability, tied outcomes
/// carry real mass rather than being silently omitted.
///
/// # Cost
///
/// This enumerates the outcome space, which holds `n! * 2^(n-1)` events —
/// 24 at three teams, 192 at four, 1_920 at five, 23_040 at six. Each
/// costs one `O(teams * grid)` pass, so this is milliseconds at three or
/// four teams and seconds at six. Above
/// [`MAX_TEAMS_FOR_DISTRIBUTION`](crate::MAX_PREDICTED_TEAMS) it returns
/// `TooManyTeams` rather than hanging. When you need one specific ordering
/// use [`History::predict_ranking`], and when you only need the winner use
/// [`History::predict_win_probabilities`]; both stay cheap at any size.
///
/// # Preconditions
///
/// Every key must already be known to the history — that is, must have
/// appeared in an ingested event. An unknown key is `UnknownKey`, not a
/// silently dropped member. If your caller cannot guarantee that, pre-filter
/// with [`History::current_skill`] or [`History::competitors`]; treating the
/// error as "no information" and substituting a neutral value turns a
/// whole-team miss into a plausible constant, which is invisible to any
/// test that does not assert on variation.
///
/// # Errors
///
/// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey`, or `TooManyTeams`.
/// `GridTooCoarse` when the performance sigmas are too far apart to
/// integrate on one grid.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn predict_outcome<Q>(&self, teams: &[&[&Q]]) -> Result<Prediction, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if teams.len() > crate::MAX_PREDICTED_TEAMS {
return Err(InferenceError::TooManyTeams {
got: teams.len(),
max: crate::MAX_PREDICTED_TEAMS,
});
}
let (performances, sizes) = self.performances(teams)?;
Ok(Prediction::new(crate::predict::outcome_distribution(
&performances,
&self.margins(&sizes),
)?))
}
/// Probability of one specific finishing order.
///
/// `ranks` follows [`crate::Outcome::ranking`]: lower is better, and equal
/// values mean those teams tied. Teams sharing a rank may finish in any
/// internal order, so this sums over those orders rather than picking one.
///
/// Unlike [`History::predict_outcome`] this does not enumerate the outcome
/// space, so it stays cheap at any team count — use it when you know which
/// orderings you care about.
///
/// # Preconditions
///
/// Every key must already be known to the history — that is, must have
/// appeared in an ingested event. An unknown key is `UnknownKey`, not a
/// silently dropped member. If your caller cannot guarantee that, pre-filter
/// with [`History::current_skill`] or [`History::competitors`]; treating the
/// error as "no information" and substituting a neutral value turns a
/// whole-team miss into a plausible constant, which is invisible to any
/// test that does not assert on variation.
///
/// # Errors
///
/// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey`, or `MismatchedShape` if
/// `ranks` does not have one entry per team. `GridTooCoarse` when the
/// performance sigmas are too far apart to integrate on one grid.
///
/// Every prediction reads skills through one gate, which adds two errors to
/// all of them: `NonFiniteResult` if a skill has no usable mean or variance
/// — the fit did not converge — and `InvalidParameter` if `beta` is zero
/// and every skill is a point mass, leaving no performance distribution to
/// predict from.
pub fn predict_ranking<Q>(&self, teams: &[&[&Q]], ranks: &[u32]) -> Result<f64, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if ranks.len() != teams.len() {
return Err(InferenceError::MismatchedShape {
kind: "ranks vs teams",
expected: teams.len(),
got: ranks.len(),
});
}
let (performances, sizes) = self.performances(teams)?;
crate::predict::ranking_probability(&performances, &self.margins(&sizes), ranks)
}
/// Run the full forward+backward convergence loop to a fixed point.
///
/// # Stopping short is an error
///
/// Hitting `max_iter` without reaching `epsilon` returns `NotConverged`.
///
/// It used to return `Ok` with `converged: false`, which was the worst
/// available shape. A fit that stops short is *wrong by a little*: every
/// posterior is finite, the ordering looks sensible, and nothing about the
/// output says the numbers were still moving. Detection was opt-in, and
/// `let _ = h.converge()` silently opted out — which is how a real defect
/// hid in this crate's own test suite.
///
/// The default `max_iter` is [`ITERATIONS`](crate::ITERATIONS), which is
/// set high enough that reaching it means something is genuinely wrong
/// rather than that the history is merely large. Raising the cap costs
/// nothing when it is not needed, because the loop exits at `epsilon`.
///
/// Use [`History::converge_partial`] when a capped, unconverged fit is
/// what you actually want.
///
/// # Errors
///
/// `NotConverged` if the sweep hits `max_iter` with the step still above
/// `epsilon`.
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
/// broken down at that point and further iterations cannot recover, so the
/// loop stops rather than reporting a NaN step as convergence.
///
/// `InvalidParameter` if a competitor's drift model yields a negative or
/// non-finite variance — which also covers a custom [`Drift`]
/// implementation, the one case no constructor can check.
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
let report = self.converge_partial()?;
if report.converged {
Ok(report)
} else {
Err(InferenceError::NotConverged {
iterations: report.iterations,
final_step: report.final_step,
epsilon: self.convergence.epsilon,
})
}
}
/// As [`History::converge`], but a fit that stops at `max_iter` is
/// returned rather than reported as an error.
///
/// The report's `converged` flag says which happened. Use this when a
/// deliberately capped sweep is the point — a cheap approximate fit, or a
/// test that pins what a fixed number of iterations produces. Prefer
/// `converge` everywhere else: an unconverged fit that nobody checks is
/// indistinguishable from a converged one.
///
/// # Errors
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step.
///
/// `InvalidParameter` if a competitor's drift model yields a negative or
/// non-finite variance. Checked here, before any sweeping, so it applies to
/// `converge` too.
#[must_use = "this fit may have stopped at `max_iter` — check `converged`, \
or bind it to `_` to say you have decided not to"]
pub fn converge_partial(&mut self) -> Result<ConvergenceReport, InferenceError> {
use std::time::Instant;
use smallvec::SmallVec;
let opts = self.convergence;
// Drift is the one model parameter with no boundary check, because
// `HistoryBuilder::drift` is generic over `Drift<T>` and cannot inspect
// an arbitrary implementation. Validate what it actually produces
// instead, which also covers a custom impl.
//
// `ConstantDrift` returns `elapsed * gamma * gamma`, so a negative
// gamma is squared away: measured, `ConstantDrift::new(-0.0833)` gave
// results **bit identical** to `+0.0833`, the same sign-absorption
// defect already rejected for `sigma` and `beta`. A non-finite gamma
// poisons every posterior derived from it.
for slice in &self.time_slices {
for (competitor, elapsed) in slice.appearances() {
let drift = self.competitors[competitor]
.rating
.drift_variance_for_elapsed(elapsed);
if !drift.is_finite() || drift < 0.0 {
return Err(InferenceError::InvalidParameter {
name: "drift variance",
value: drift,
});
}
}
}
if self.time_slices.is_empty() {
return Ok(ConvergenceReport {
iterations: 0,
final_step: (0.0, 0.0),
log_evidence: 0.0,
converged: true,
per_iteration_time: SmallVec::new(),
});
}
let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0;
let mut per_iter: SmallVec<[std::time::Duration; 32]> = SmallVec::new();
while tuple_gt(step, opts.epsilon) && i < opts.max_iter {
let t0 = Instant::now();
step = self.iteration();
per_iter.push(t0.elapsed());
i += 1;
self.observer.on_iteration_end(i, step);
// A non-finite step means EP has broken down; further iterations
// cannot recover, and `tuple_gt` would read NaN as converged.
if !crate::step_is_finite(step) {
break;
}
}
if !crate::step_is_finite(step) {
self.observer.on_converged(i, step, false);
return Err(InferenceError::NonFiniteResult {
context: "History::converge",
step,
});
}
let converged = crate::step_converged(step, opts.epsilon);
let log_evidence = self.log_evidence_internal(false, &[]);
self.observer.on_converged(i, step, converged);
Ok(ConvergenceReport {
iterations: i,
final_step: step,
log_evidence,
converged,
per_iteration_time: per_iter,
})
}
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<K, T, D, O> {
pub(crate) fn add_events_with_prior(
&mut self,
mut composition: Vec<Vec<Vec<Index>>>,
mut results: Option<Vec<Vec<f64>>>,
times: Vec<T>,
mut weights: Option<Vec<Vec<Vec<f64>>>>,
kinds: Vec<EventKind>,
priors: HashMap<Index, CompetitorConfig>,
) -> Result<(), InferenceError> {
if results
.as_ref()
.is_some_and(|r| r.len() != composition.len())
{
let got = results.as_ref().map_or(0, Vec::len);
return Err(InferenceError::MismatchedShape {
kind: "results",
expected: composition.len(),
got,
});
}
if times.len() != composition.len() {
return Err(InferenceError::MismatchedShape {
kind: "times",
expected: composition.len(),
got: times.len(),
});
}
if weights
.as_ref()
.is_some_and(|w| w.len() != composition.len())
{
let got = weights.as_ref().map_or(0, Vec::len);
return Err(InferenceError::MismatchedShape {
kind: "weights",
expected: composition.len(),
got,
});
}
if kinds.len() != composition.len() {
return Err(InferenceError::MismatchedShape {
kind: "kinds",
expected: composition.len(),
got: kinds.len(),
});
}
// Chokepoint for event shape, for the same reason as the tie check
// below: every ingestion route lands here.
//
// `run_chain` builds one diff link per adjacent pair of teams, so a
// one-team event leaves it with an empty link vector and panics
// indexing `links[1..]` — a reachable panic from safe API, in release.
// An empty team is the quieter half: it contributes no performance,
// so a malformed event yields a finite, plausible-looking posterior
// for whoever it was matched against.
//
// Both errors already existed; they were only ever checked on the
// prediction paths, which is why ingestion could still produce them.
for teams in &composition {
if teams.len() < 2 {
return Err(InferenceError::NotEnoughTeams { got: teams.len() });
}
for (team, members) in teams.iter().enumerate() {
if members.is_empty() {
return Err(InferenceError::EmptyTeam { team });
}
}
}
// A non-finite outcome poisons the history rather than failing it:
// `converge` does report `NonFiniteResult`, but a caller who reads
// `current_skill` before converging is handed a NaN posterior with
// nothing to say it is one.
if let Some(results) = results.as_ref() {
for (event_results, kind) in results.iter().zip(kinds.iter()) {
let name = match kind {
EventKind::Ranked => "rank",
EventKind::Scored { .. } => "score",
};
for value in event_results {
if !value.is_finite() {
return Err(InferenceError::InvalidParameter {
name,
value: *value,
});
}
}
}
}
// A non-finite weight is not a weight. Measured, it behaves exactly as
// `0.0` — the member contributes nothing — while `converge` reports
// `converged: true` after one iteration with a step of `(0.0, 0.0)`.
// So a NaN arriving from a division or a parse is indistinguishable
// from a deliberate zero, and looks like a clean fit.
//
// Zero and negative weights stay accepted: both are expressible
// choices about how much a member contributes, and
// `tests/degenerate_inputs.rs` pins them deliberately. Only the values
// that are not quantities at all are rejected.
if let Some(weights) = weights.as_ref() {
for team_weights in weights.iter().flatten() {
for weight in team_weights {
if !weight.is_finite() {
return Err(InferenceError::InvalidParameter {
name: "weight",
value: *weight,
});
}
}
}
}
// Chokepoint for tie validation: every ingestion route lands here,
// including `record_draw`, which builds its results directly rather
// than going through `Outcome`.
if self.p_draw == 0.0 {
for (event_results, kind) in results.iter().flatten().zip(kinds.iter()) {
if !matches!(kind, EventKind::Ranked) {
continue;
}
if let Some(teams) = crate::first_tied_output(event_results) {
return Err(InferenceError::TieWithoutDrawProbability { teams });
}
}
}
// Cross-batch conflict. The in-batch check upstream rejects one batch
// that sets a field twice; `priors` is rebuilt per call, so without
// this a *second* batch could quietly overwrite what a first one
// declared, last-write-wins.
//
// That asymmetry cut against the invariant `tests/ingestion_equivalence.rs`
// exists to protect: the same contradictory events errored when
// batched and succeeded, order-dependently, when fed one at a time.
// Checked before anything mutates, so a rejected batch leaves the
// history untouched.
// Sorted, not `HashMap` order. This loop returns on the FIRST conflict
// it finds, so hash order decided *which* competitor the error blamed:
// measured, 15 different competitors named across 40 runs on identical
// input. The error fired every time — only its content was a lottery,
// which makes it unreproducible and sends a reader after the wrong key.
let mut conflict_scan: Vec<Index> = priors.keys().copied().collect();
conflict_scan.sort_unstable();
for competitor in &conflict_scan {
let batch = priors[competitor];
let held = self.declared.get(competitor).copied().unwrap_or_default();
if let (Some(existing), Some(new)) = (held.prior, batch.prior) {
if existing != new {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: competitor.get(),
field: "prior",
});
}
}
if let (Some(existing), Some(new)) = (held.drift_scale, batch.drift_scale) {
if existing != new {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: competitor.get(),
field: "drift_scale",
});
}
}
}
for (competitor, batch) in &priors {
let entry = self.declared.entry(*competitor).or_default();
if batch.prior.is_some() {
entry.prior = batch.prior;
}
if batch.drift_scale.is_some() {
entry.drift_scale = batch.drift_scale;
}
}
competitor::clean(self.competitors.values_mut(), true);
let mut these_competitors = Vec::with_capacity(1024);
for competitor in composition.iter().flatten().flatten() {
if these_competitors.contains(competitor) {
continue;
}
these_competitors.push(*competitor);
// From `declared` rather than `priors`: a competitor configured by
// `register` before any event has nothing in this batch's map.
let config = self.declared.get(competitor).copied().unwrap_or_default();
if self.competitors.contains(*competitor) {
// Seeding a competitor the history already knows. This used to
// be dropped on the floor: `remove` was only reached on the
// create path, so a prior applied on a competitor's very first
// event and was silently ignored ever after.
if config.is_empty() {
continue;
}
let rating = &mut self.competitors.get_mut(*competitor).unwrap().rating;
if let Some(prior) = config.prior {
rating.prior = prior;
}
if let Some(scale) = config.drift_scale {
rating.drift_scale = scale;
}
let seeded = rating.prior;
if config.prior.is_some() {
// The prior is not re-derived every pass the way drift is.
// A competitor's earliest slice has its forward message set
// to the prior once, at ingestion, and `iteration` refreshes
// only slices after the first — so without this, a late
// prior would reach the drift terms and nothing else, which
// is a subtler version of the silent drop this replaced.
//
// `clean` has just nulled every message, so the earliest
// slice's forward is exactly the prior.
for slice in &mut self.time_slices {
if let Some(skill) = slice.skills.get_mut(*competitor) {
skill.forward = seeded;
break;
}
}
}
} else {
let mut rating = Rating::new(
Gaussian::from_ms(self.mu, self.sigma),
self.beta,
self.drift,
);
if let Some(prior) = config.prior {
rating.prior = prior;
}
if let Some(scale) = config.drift_scale {
rating.drift_scale = scale;
}
self.competitors.insert(
*competitor,
Competitor {
rating,
message: None,
last_time: None,
},
);
}
}
let n = composition.len();
let o = sort_time(&times, false);
// The chunking loop below MOVES each event's data out of `composition`,
// `results` and `weights` instead of cloning it. That is only sound
// because `o` is a permutation, so every index is visited exactly once
// — visiting one twice would silently yield an empty event rather than
// failing.
debug_assert!(
{
let mut seen = vec![false; n];
o.iter()
.all(|&idx| !std::mem::replace(&mut seen[idx], true))
},
"sort_time must return a permutation of 0..{n}"
);
let mut i = 0;
let mut k = 0;
while i < n {
let mut j = i + 1;
let t = times[o[i]];
while j < n && times[o[j]] == t {
j += 1;
}
while self.time_slices.len() > k && self.time_slices[k].time < t {
let time_slice = &mut self.time_slices[k];
if k > 0 {
time_slice.new_forward_info(&self.competitors);
}
for competitor_idx in &these_competitors {
if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) {
skill.elapsed = time_slice::compute_elapsed(
self.competitors[*competitor_idx].last_time.as_ref(),
&time_slice.time,
);
let competitor = self.competitors.get_mut(*competitor_idx).unwrap();
competitor.last_time = Some(time_slice.time);
competitor.message = Some(time_slice.forward_prior_out(competitor_idx));
}
}
k += 1;
}
let composition = (i..j)
.map(|e| std::mem::take(&mut composition[o[e]]))
.collect::<Vec<_>>();
let results = results.as_mut().map(|results| {
(i..j)
.map(|e| std::mem::take(&mut results[o[e]]))
.collect::<Vec<_>>()
});
let weights = weights.as_mut().map(|weights| {
(i..j)
.map(|e| std::mem::take(&mut weights[o[e]]))
.collect::<Vec<_>>()
});
let kinds_chunk: Vec<EventKind> = (i..j).map(|e| kinds[o[e]]).collect();
if self.time_slices.len() > k && self.time_slices[k].time == t {
let time_slice = &mut self.time_slices[k];
time_slice.add_events(
composition,
results,
weights,
kinds_chunk,
&self.competitors,
);
for competitor_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(competitor_idx).unwrap();
competitor.last_time = Some(t);
competitor.message = Some(time_slice.forward_prior_out(&competitor_idx));
}
k += 1;
} else {
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
time_slice.add_events(
composition,
results,
weights,
kinds_chunk,
&self.competitors,
);
self.time_slices.insert(k, time_slice);
let time_slice = &self.time_slices[k];
for competitor_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(competitor_idx).unwrap();
competitor.last_time = Some(t);
competitor.message = Some(time_slice.forward_prior_out(&competitor_idx));
}
k += 1;
}
i = j;
}
while self.time_slices.len() > k {
let time_slice = &mut self.time_slices[k];
time_slice.new_forward_info(&self.competitors);
for competitor_idx in &these_competitors {
if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) {
skill.elapsed = time_slice::compute_elapsed(
self.competitors[*competitor_idx].last_time.as_ref(),
&time_slice.time,
);
let competitor = self.competitors.get_mut(*competitor_idx).unwrap();
competitor.last_time = Some(time_slice.time);
competitor.message = Some(time_slice.forward_prior_out(competitor_idx));
}
}
k += 1;
}
self.size += n;
Ok(())
}
/// Record a single two-competitor event that `winner` won.
///
/// # Errors
///
/// Ingests through the same path as [`History::add_events`], so it returns
/// the same errors. A two-team decisive outcome cannot tie, so
/// `TieWithoutDrawProbability` is not reachable here.
pub fn record_winner<Q>(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized,
{
let w = self.intern(winner);
let l = self.intern(loser);
self.add_events_with_prior(
vec![vec![vec![w], vec![l]]],
Some(vec![vec![1.0, 0.0]]),
vec![time],
None,
vec![EventKind::Ranked],
HashMap::new(),
)
}
/// Record a single two-competitor event that ended level.
///
/// # Errors
///
/// Ingests through the same path as [`History::add_events`]. Note
/// `TieWithoutDrawProbability` *is* reachable here: a draw needs a
/// positive `p_draw`.
pub fn record_draw<Q>(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized,
{
let a_idx = self.intern(a);
let b_idx = self.intern(b);
self.add_events_with_prior(
vec![vec![vec![a_idx], vec![b_idx]]],
Some(vec![vec![0.0, 0.0]]),
vec![time],
None,
vec![EventKind::Ranked],
HashMap::new(),
)
}
/// Start a fluent event builder for a single match at `time`.
pub fn event(&mut self, time: T) -> crate::event_builder::EventBuilder<'_, T, D, O, K> {
crate::event_builder::EventBuilder::new(self, time)
}
/// Bulk-ingest typed events.
///
/// # Errors
///
/// - `MismatchedShape` if an event's outcome does not describe the same
/// number of teams the event has. (Weights cannot mismatch here — they
/// come one-per-`Member`; that check belongs to
/// [`EventBuilder::weights`](crate::EventBuilder::weights), which builds
/// them from a separate list.)
/// - `NotEnoughTeams` for an event with fewer than two teams, and
/// `EmptyTeam` for a team with no members.
/// - `InvalidParameter` for a per-event `score_sigma` override that is not
/// strictly positive, a non-finite score, rank or weight, or a
/// `drift_scale` that is negative or non-finite.
/// - `ConflictingCompetitorConfig` if one competitor is given two different
/// values for `prior` or `drift_scale`, whether within one batch or
/// across batches.
/// - `TieWithoutDrawProbability` if an event ties two teams while the
/// history's `p_draw` is zero. This includes `Outcome::winner(w, n)` for
/// `n >= 3`, which ties every loser.
pub fn add_events<I>(&mut self, events: I) -> Result<(), InferenceError>
where
I: IntoIterator<Item = crate::event::Event<T, K>>,
{
use crate::event::Event;
let events: Vec<Event<T, K>> = events.into_iter().collect();
if events.is_empty() {
return Ok(());
}
let mut composition: Vec<Vec<Vec<Index>>> = Vec::with_capacity(events.len());
let mut results: Vec<Vec<f64>> = Vec::with_capacity(events.len());
let mut times: Vec<T> = Vec::with_capacity(events.len());
let mut weights: Vec<Vec<Vec<f64>>> = Vec::with_capacity(events.len());
let mut kinds: Vec<EventKind> = Vec::with_capacity(events.len());
let mut priors: HashMap<Index, CompetitorConfig> = HashMap::new();
for ev in events {
if ev.outcome.team_count() != ev.teams.len() {
return Err(InferenceError::MismatchedShape {
kind: "outcome vs teams",
expected: ev.teams.len(),
got: ev.outcome.team_count(),
});
}
let mut event_comp: Vec<Vec<Index>> = Vec::with_capacity(ev.teams.len());
let mut event_weights: Vec<Vec<f64>> = Vec::with_capacity(ev.teams.len());
for team in ev.teams {
let mut team_indices: Vec<Index> = Vec::with_capacity(team.members.len());
let mut team_weights: Vec<f64> = Vec::with_capacity(team.members.len());
for member in team.members {
let idx = self.keys.get_or_create(&member.key);
team_indices.push(idx);
team_weights.push(member.weight);
if let Some(scale) = member.drift_scale {
// Squaring would make a negative scale behave as its
// absolute value, so reject rather than silently
// accept a sign the caller cannot have meant.
if !scale.is_finite() || scale < 0.0 {
return Err(InferenceError::InvalidParameter {
name: "drift_scale",
value: scale,
});
}
}
// `prior` and `drift_scale` configure the competitor, not
// the event. Both land in the same entry so a member may
// set either alone.
//
// Events within a batch are not ordered, so a batch that
// sets one field twice with different values has no
// well-defined result — "last one wins" would depend on
// iteration order, which `tests/ingestion_equivalence.rs`
// exists to rule out. Repeating the *same* value is fine,
// and is the expected shape when the configuration is a
// property of the domain rather than of one event.
if member.prior.is_some() || member.drift_scale.is_some() {
let entry = priors.entry(idx).or_default();
if let Some(prior) = member.prior {
if entry.prior.is_some_and(|held| held != prior) {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: idx.get(),
field: "prior",
});
}
entry.prior = Some(prior);
}
if let Some(scale) = member.drift_scale {
if entry.drift_scale.is_some_and(|held| held != scale) {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: idx.get(),
field: "drift_scale",
});
}
entry.drift_scale = Some(scale);
}
}
}
event_comp.push(team_indices);
event_weights.push(team_weights);
}
composition.push(event_comp);
weights.push(event_weights);
let event_result: Vec<f64> = match &ev.outcome {
crate::Outcome::Ranked(ranks) => {
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
kinds.push(EventKind::Ranked);
ranks.iter().map(|&r| max_rank - r as f64).collect()
}
crate::Outcome::Scored {
scores,
score_sigma,
} => {
let resolved = score_sigma.unwrap_or(self.score_sigma);
if resolved <= 0.0 || resolved.is_nan() {
return Err(InferenceError::InvalidParameter {
name: "score_sigma",
value: resolved,
});
}
kinds.push(EventKind::Scored {
score_sigma: resolved,
});
scores.to_vec()
}
};
results.push(event_result);
times.push(ev.time);
}
let weights = if weights.is_empty() {
None
} else {
Some(weights)
};
self.add_events_with_prior(composition, Some(results), times, weights, kinds, priors)
}
}
/// Summarising rather than exhaustive.
///
/// A `History` owns every competitor's skill at every time slice, so a derived
/// `Debug` would print the entire fit — megabytes for a real history, and
/// useless in a log. This prints the shape instead. Same reasoning as `Joint`'s,
/// which omits its `n^2` factorisation.
///
/// It exists at all because without it a consumer cannot `#[derive(Debug)]` on
/// any struct holding a `History`, which is how both known consumers store it.
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> std::fmt::Debug
for History<K, T, D, O>
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("History")
.field("competitors", &self.keys.len())
.field("events", &self.size)
.field("time_slices", &self.time_slices.len())
.field("mu", &self.mu)
.field("sigma", &self.sigma)
.field("beta", &self.beta)
.field("p_draw", &self.p_draw)
.field("score_sigma", &self.score_sigma)
.field("unknown_keys", &self.unknown_keys)
.finish_non_exhaustive()
}
}
/// A factorised joint posterior, reusable across many queries.
///
/// Built by [`History::joint`]. Every question the joint answers — the width of
/// a contrast, the covariance of two, how much a candidate matchup would
/// sharpen either — is a bilinear form in the inverse precision matrix, and all
/// of them share one factorisation. That factorisation is the whole cost:
/// `O(n^3)` in the history's appearances to build, `O(n^2)` per question after.
///
/// The handle borrows the history, so no refit can run and no events can be
/// added while it is alive. That is what makes it correct without any
/// invalidation logic: there is no window in which the factorisation could
/// describe a fit that no longer exists.
///
/// # What the cost actually scales in
///
/// Not competitors, and not slices times competitors. One variable per
/// *appearance* — a competitor per slice they appear in — minus every
/// consecutive pair with no drift between them, which collapse to a single
/// latent variable.
///
/// That last clause dominates, and it is not obvious. A competitor whose drift
/// is zero contributes **one** variable however long the history: whole-history
/// `gamma = 0`, or `drift_scale = 0` on that competitor. So two fits over the
/// same events and the same slices can differ in problem size by roughly the
/// slice count, and in factorisation time by its cube. Measured by a consumer
/// on a ~2,000-node model over 76 slices:
///
/// ```text
/// career fit (gamma = 0) 787 ms per solve
/// drifting fit (gamma = 0.15) 6214 ms per solve
/// ```
///
/// Choosing between a drifting and a drift-free configuration is therefore also
/// choosing an 8x difference in query cost. [`Joint::variables`] reports the
/// number that decides it, and can be read before committing to a batch of
/// queries.
///
/// Slices a competitor sits out cost nothing: an absence is not an appearance,
/// so a competitor seen in the first and last of a hundred slices contributes
/// two variables, not a hundred.
#[must_use]
pub struct Joint<
'h,
K: Eq + Hash + Clone = &'static str,
T: Time = i64,
D: Drift<T> = ConstantDrift,
O: Observer<T> = NullObserver,
> {
history: &'h History<K, T, D, O>,
cholesky: crate::joint::Cholesky,
/// `(row, slice)` of each competitor's latest appearance.
latest: HashMap<Index, (usize, usize)>,
/// Row of each `(competitor, slice)` appearance.
at_slice: HashMap<(Index, usize), usize>,
/// Side length of the precision matrix.
width: usize,
}
/// Deliberately does not print the factorisation, which is `n^2` floats and
/// would make a `{:?}` of a large joint unreadable and slow.
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> std::fmt::Debug
for Joint<'_, K, T, D, O>
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Joint")
.field("variables", &self.width)
.finish_non_exhaustive()
}
}
impl<K: Eq + Hash + Clone, T: Time, D: Drift<T>, O: Observer<T>> Joint<'_, K, T, D, O> {
/// Number of variables in the joint: the history's appearances, after
/// collapsing consecutive pairs a competitor does not drift between.
///
/// This is what the cost scales in — `O(n^3)` to factorise, `O(n^2)` per
/// query — and it is neither the competitor count nor slices times
/// competitors. A drift-free competitor contributes one variable however
/// many slices they appear in; see the type docs for how large that
/// difference gets.
///
/// Worth reading before committing to a batch of queries: it is the one
/// number that says whether a joint over this history is affordable.
#[must_use]
pub fn variables(&self) -> usize {
self.width
}
/// Turn a resolved functional into its posterior.
///
/// The variance is `|L^-1 c|^2` over the competitors the history knows,
/// plus an independent prior variance for each competitor it does not —
/// unseen competitors are uncorrelated with everything by construction.
fn distribution(&self, resolved: &ResolvedTerms) -> Gaussian {
let y = self.cholesky.whiten(&resolved.contrast);
let prior_var = self.history.sigma * self.history.sigma;
let variance = crate::joint::bilinear(&y, &y)
+ resolved
.unseen
.values()
.map(|c| c * c * prior_var)
.sum::<f64>();
Gaussian::from_mv(resolved.mean, variance)
}
/// Posterior of a linear combination of competitors' skills.
///
/// `terms` pairs each competitor with a coefficient, so
/// `[(a, 1.0), (b, -1.0)]` is the skill *gap* between them — with the
/// covariance between the two accounted for, which is the whole reason to
/// go through the joint rather than subtract two marginals.
///
/// Each competitor is read at their own latest appearance. Use
/// [`Joint::posterior_of_at`] to anchor the reading to a moment instead.
///
/// # Errors
///
/// `UnknownKey` for a competitor the history has never seen.
pub fn posterior_of<Q>(&self, terms: &[(&Q, f64)]) -> Result<Gaussian, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let resolved = self
.history
.resolve_terms(terms, self.width, |index| self.latest.get(&index).copied())?;
Ok(self.distribution(&resolved))
}
/// Posterior of a linear combination, read as of `time`.
///
/// As [`Joint::posterior_of`], but every competitor is read at their
/// latest appearance at or before `time` — the same reading
/// [`History::learning_curve`] gives. Use it when a comparison must be
/// anchored to a moment ("how did these two stand at the end of last
/// season") rather than to wherever each competitor was last seen.
///
/// # Errors
///
/// `UnknownKey` for a competitor with no appearance at or before `time`.
pub fn posterior_of_at<Q>(
&self,
time: T,
terms: &[(&Q, f64)],
) -> Result<Gaussian, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
let as_of = self.rows_as_of(time);
let resolved = self
.history
.resolve_terms(terms, self.width, |index| as_of.get(&index).copied())?;
Ok(self.distribution(&resolved))
}
/// Latest appearance at or before `time`, per competitor.
fn rows_as_of(&self, time: T) -> HashMap<Index, (usize, usize)> {
let mut as_of: HashMap<Index, (usize, usize)> = HashMap::new();
for (slice_idx, slice) in self.history.time_slices.iter().enumerate() {
if slice.time > time {
break;
}
for (competitor, _) in slice.appearances() {
if let Some(row) = self.at_slice.get(&(competitor, slice_idx)) {
as_of.insert(competitor, (*row, slice_idx));
}
}
}
as_of
}
/// How much observing this matchup would shrink the variance of `target`.
///
/// `target` is a linear functional in the same shape
/// [`Joint::posterior_of`] takes: the question you want sharpened. The
/// answer is how much observing this matchup would shrink that question's
/// variance.
///
/// This is the shape the call is normally used in — one target, a field of
/// candidate matchups, one unchanged fit — which is why it lives on the
/// handle and the factorisation is paid once for the whole field.
///
/// # Errors
///
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam` for
/// an empty one, and `UnknownKey` for an unseen competitor.
pub fn expected_variance_reduction<Q>(
&self,
teams: &[&[&Q]],
target: &[(&Q, f64)],
) -> Result<f64, InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized + std::fmt::Debug,
{
if teams.len() != 2 {
return Err(InferenceError::MismatchedShape {
kind: "expected_variance_reduction takes exactly 2 teams",
expected: 2,
got: teams.len(),
});
}
// The candidate matchup, expressed as the same kind of linear
// functional as the target.
let mut matchup: Vec<(&Q, f64)> = Vec::new();
let mut noise = self.history.score_sigma * self.history.score_sigma;
for (team_idx, team) in teams.iter().enumerate() {
if team.is_empty() {
return Err(InferenceError::EmptyTeam { team: team_idx });
}
let sign = if team_idx == 0 { 1.0 } else { -1.0 };
for key in team.iter() {
matchup.push((*key, sign));
let beta = self
.history
.keys
.get(*key)
.map_or(self.history.beta, |index| {
self.history.competitors[index].rating.beta
});
noise += beta * beta;
}
}
let row_for = |index: Index| self.latest.get(&index).copied();
let target = self.history.resolve_terms(target, self.width, row_for)?;
let matchup = self.history.resolve_terms(&matchup, self.width, row_for)?;
let y_target = self.cholesky.whiten(&target.contrast);
let y_matchup = self.cholesky.whiten(&matchup.contrast);
let prior_var = self.history.sigma * self.history.sigma;
// Competitors outside the history are independent, so they contribute
// only where the same key appears in both functionals.
let cross_unseen: f64 = target
.unseen
.iter()
.map(|(k, tc)| tc * matchup.unseen.get(k).copied().unwrap_or(0.0) * prior_var)
.sum();
let self_unseen: f64 = matchup.unseen.values().map(|c| c * c * prior_var).sum();
let cross = crate::joint::bilinear(&y_target, &y_matchup) + cross_unseen;
let matchup_var = crate::joint::bilinear(&y_matchup, &y_matchup) + self_unseen;
Ok(cross * cross / (noise + matchup_var))
}
}
#[cfg(test)]
mod tests {
use approx::assert_ulps_eq;
use smallvec::smallvec;
use super::*;
use crate::{
ConstantDrift, EPSILON, Event, Gaussian, Member, Outcome, P_DRAW, Team,
arena::ScratchArena, game::GameRef,
};
/// #17: a slice's footprint must be O(competitors in the slice), not
/// O(largest global index it touches). The store used to be a dense
/// `Vec<Skill>` indexed by `Index.0`, so the same two-competitor games cost
/// 20,000 slots per slice when the competitors sat at the top of a large
/// roster. Measured end to end, peak RSS was 309 MB against 52 MB.
#[test]
fn per_slice_footprint_is_independent_of_index_magnitude() {
fn total_skill_slots(high_indices: bool) -> usize {
let mut h: History<String> = History::builder().key_type::<String>().build();
for i in 0..2_000 {
h.intern(&format!("k{i:05}"));
}
let (a, b) = if high_indices {
("k01998".to_string(), "k01999".to_string())
} else {
("k00000".to_string(), "k00001".to_string())
};
for time in 1..=20i64 {
h.record_winner(&a, &b, time).unwrap();
}
h.time_slices
.iter()
.map(|ts| ts.skills.allocated_slots())
.sum()
}
let low = total_skill_slots(false);
let high = total_skill_slots(true);
assert_eq!(low, high, "footprint must not depend on index magnitude");
// A dense store over a 2,000-key roster would allocate 20 x 2,000.
assert!(
high < 1_000,
"20 slices of 2 competitors allocated {high} slots"
);
}
fn make_events_1v1(
pairs: &[(&'static str, &'static str)],
outcomes: &[Outcome],
times: &[i64],
) -> Vec<Event<i64, &'static str>> {
pairs
.iter()
.copied()
.zip(outcomes.iter().cloned())
.zip(times.iter().copied())
.map(|(((a, b), outcome), time)| Event {
time,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome,
})
.collect()
}
#[test]
fn test_init() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift::new(0.15 * 25.0 / 3.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
let p0 = h.time_slices[0].posteriors();
assert_ulps_eq!(
p0[&a],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
let observed = h.time_slices[1].skills.get(a).unwrap().forward.sigma();
let gamma: f64 = 0.15 * 25.0 / 3.0;
let expected = (gamma.powi(2)
+ h.time_slices[0]
.skills
.get(a)
.unwrap()
.posterior()
.sigma()
.powi(2))
.sqrt();
assert_ulps_eq!(observed, expected, epsilon = 0.000001);
let observed = h.time_slices[1].skills.get(a).unwrap().posterior();
let w = [vec![1.0], vec![1.0]];
let p = GameRef::ranked_with_arena(
h.time_slices[1].events[0].within_priors(
false,
&h.time_slices[1].skills,
&h.competitors,
),
&[0.0, 1.0],
&w,
P_DRAW,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
)
.posteriors();
let expected = p[0][0];
assert_ulps_eq!(observed, expected, epsilon = 1e-6);
let _ = (b, c);
}
#[test]
fn test_one_batch() {
let mut h1 = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift::new(0.15 * 25.0 / 3.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("b", "c"), ("c", "a")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[1, 1, 1],
);
h1.add_events(events).unwrap();
let a = h1.keys.get("a").unwrap();
let c = h1.keys.get("c").unwrap();
assert_ulps_eq!(
h1.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(22.904409, 6.010330),
epsilon = 1e-6
);
assert_ulps_eq!(
h1.time_slices[0].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(25.110318, 5.866311),
epsilon = 1e-6
);
let _ = h1.converge().unwrap();
assert_ulps_eq!(
h1.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
h1.time_slices[0].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
let mut h2 = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift::new(25.0 / 300.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("b", "c"), ("c", "a")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h2.add_events(events).unwrap();
let a = h2.keys.get("a").unwrap();
let c = h2.keys.get("c").unwrap();
assert_ulps_eq!(
h2.time_slices[2].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(22.903522, 6.011017),
epsilon = 1e-6
);
assert_ulps_eq!(
h2.time_slices[2].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(25.110702, 5.866811),
epsilon = 1e-6
);
let _ = h2.converge().unwrap();
assert_ulps_eq!(
h2.time_slices[2].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(24.998668, 5.420053),
epsilon = 1e-6
);
assert_ulps_eq!(
h2.time_slices[2].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(25.000532, 5.419827),
epsilon = 1e-6
);
}
#[test]
fn test_learning_curves() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift::new(25.0 / 300.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("b", "c"), ("c", "a")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[5, 6, 7],
);
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
let lc_a = h.learning_curve("a").unwrap();
let lc_c = h.learning_curve("c").unwrap();
let aj_e = lc_a.len();
let cj_e = lc_c.len();
assert_eq!(lc_a[0].0, 5);
assert_eq!(lc_a[aj_e - 1].0, 7);
assert_ulps_eq!(
lc_a[aj_e - 1].1,
Gaussian::from_ms(24.998668, 5.420053),
epsilon = 1e-6
);
assert_ulps_eq!(
lc_c[cj_e - 1].1,
Gaussian::from_ms(25.000532, 5.419827),
epsilon = 1e-6
);
}
#[test]
fn test_env_ttt() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift::new(25.0 / 300.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2);
assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(25.000267, 5.419423),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(24.999197939, 5.419510957),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[2].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(25.001331690, 5.420052840),
epsilon = 1e-6
);
}
#[test]
fn test_teams() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.drift(ConstantDrift::new(0.0))
.build();
let events: Vec<Event<i64, &'static str>> = vec![
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a"), Member::new("b")]),
Team::with_members([Member::new("c"), Member::new("d")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 2,
teams: smallvec![
Team::with_members([Member::new("e"), Member::new("f")]),
Team::with_members([Member::new("b"), Member::new("c")]),
],
outcome: Outcome::winner(1, 2),
},
Event {
time: 3,
teams: smallvec![
Team::with_members([Member::new("a"), Member::new("d")]),
Team::with_members([Member::new("e"), Member::new("f")]),
],
outcome: Outcome::winner(0, 2),
},
];
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
let d = h.keys.get("d").unwrap();
let e = h.keys.get("e").unwrap();
let f = h.keys.get("f").unwrap();
let trueskill_log_evidence = h.log_evidence_internal(false, &[]);
let trueskill_log_evidence_forward = h.log_evidence_internal(true, &[]);
assert_ulps_eq!(
trueskill_log_evidence,
trueskill_log_evidence_forward,
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior().mu(),
-h.time_slices[0].skills.get(c).unwrap().posterior().mu(),
epsilon = 1e-6
);
let evidence_second_event = h.log_evidence_internal(false, &[b]).exp() * 2.0;
assert_ulps_eq!(0.5, evidence_second_event, epsilon = 1e-6);
let evidence_third_event = h.log_evidence_internal(false, &[a]).exp() * 2.0;
assert_ulps_eq!(0.669885, evidence_third_event, epsilon = 1e-6);
let _ = h.converge().unwrap();
let loocv_hat = h.log_evidence_internal(false, &[]).exp();
let p_d_m_hat = h.log_evidence_internal(true, &[]).exp();
assert_ulps_eq!(loocv_hat, 0.241027, epsilon = 1e-6);
assert_ulps_eq!(p_d_m_hat, 0.172432, epsilon = 1e-6);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
h.time_slices[0].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(c).unwrap().posterior(),
h.time_slices[0].skills.get(d).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[1].skills.get(e).unwrap().posterior(),
h.time_slices[1].skills.get(f).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(4.084902, 5.106919),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(-0.533029, 5.106919),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[2].skills.get(e).unwrap().posterior(),
Gaussian::from_ms(-3.551872, 5.154569),
epsilon = 1e-6
);
}
#[test]
fn test_add_events() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift::new(0.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
let _ = h.converge().unwrap();
assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2);
assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[2].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
let events2 = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[4, 5, 6],
);
h.add_events(events2).unwrap();
assert_eq!(h.time_slices.len(), 6);
assert_eq!(
h.time_slices
.iter()
.map(|b| b.get_composition())
.collect::<Vec<_>>(),
vec![
vec![vec![vec![a], vec![b]]],
vec![vec![vec![a], vec![c]]],
vec![vec![vec![b], vec![c]]],
vec![vec![vec![a], vec![b]]],
vec![vec![vec![a], vec![c]]],
vec![vec![vec![b], vec![c]]]
]
);
let _ = h.converge().unwrap();
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[3].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[3].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[5].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
}
#[test]
fn test_only_add_events() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift::new(0.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
let _ = h.converge().unwrap();
assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2);
assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[2].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
let events2 = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[4, 5, 6],
);
h.add_events(events2).unwrap();
assert_eq!(h.time_slices.len(), 6);
assert_eq!(
h.time_slices
.iter()
.map(|b| b.get_composition())
.collect::<Vec<_>>(),
vec![
vec![vec![vec![a], vec![b]]],
vec![vec![vec![a], vec![c]]],
vec![vec![vec![b], vec![c]]],
vec![vec![vec![a], vec![b]]],
vec![vec![vec![a], vec![c]]],
vec![vec![vec![b], vec![c]]]
]
);
let _ = h.converge().unwrap();
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[3].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[3].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[5].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
}
#[test]
fn test_log_evidence() {
use crate::ConvergenceOptions;
let mut h: History = History::builder().build();
// empty results in the old API = team 0 wins; reproduce with Outcome::winner(0,2)
let events = make_events_1v1(
&[("a", "b"), ("b", "a")],
&[Outcome::winner(0, 2), Outcome::winner(0, 2)],
&[1, 2],
);
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let p_d_m_2 = h.log_evidence_internal(false, &[]).exp() * 2.0;
assert_ulps_eq!(p_d_m_2, 0.17650911, epsilon = 1e-6);
assert_ulps_eq!(
p_d_m_2,
h.log_evidence_internal(true, &[]).exp() * 2.0,
epsilon = 1e-6
);
assert_ulps_eq!(
p_d_m_2,
h.log_evidence_internal(true, &[a]).exp() * 2.0,
epsilon = 1e-6
);
assert_ulps_eq!(
p_d_m_2,
h.log_evidence_internal(false, &[a]).exp() * 2.0,
epsilon = 1e-6
);
// Run exactly 11 iterations. `converge_partial` rather than
// `converge`: stopping at the cap is the point here, and `converge`
// now reports that as `NotConverged`.
h.convergence = ConvergenceOptions {
max_iter: 11,
epsilon: EPSILON,
alpha: 1.0,
};
let _ = h.converge_partial().unwrap();
let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt();
assert_ulps_eq!(loocv_approx_2, 0.001976774, epsilon = 0.000001);
let p_d_m_approx_2 = h.log_evidence_internal(true, &[]).exp() * 2.0;
assert!(loocv_approx_2 - p_d_m_approx_2 < 1e-4);
assert_ulps_eq!(
loocv_approx_2,
h.log_evidence_internal(true, &[b]).exp() * 2.0,
epsilon = 1e-4
);
let mut h2: History = History::builder().build();
let events = make_events_1v1(
&[("a", "b"), ("b", "a")],
&[Outcome::winner(0, 2), Outcome::winner(0, 2)],
&[1, 2],
);
h2.add_events(events).unwrap();
assert_ulps_eq!(
((0.5f64 * 0.1765).ln() / 2.0).exp(),
(h2.log_evidence_internal(false, &[]) / 2.0).exp(),
epsilon = 1e-4
);
}
#[test]
fn test_add_events_with_time() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift::new(0.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[0, 10, 20],
);
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
let events2 = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[15, 10, 0],
);
h.add_events(events2).unwrap();
assert_eq!(h.time_slices.len(), 4);
assert_eq!(
h.time_slices
.iter()
.map(|ts| ts.events.len())
.collect::<Vec<_>>(),
vec![2, 2, 1, 1]
);
assert_eq!(
h.time_slices
.iter()
.map(|b| b.get_composition())
.collect::<Vec<_>>(),
vec![
vec![vec![vec![a], vec![b]], vec![vec![b], vec![c]]],
vec![vec![vec![a], vec![c]], vec![vec![a], vec![c]]],
vec![vec![vec![a], vec![b]]],
vec![vec![vec![b], vec![c]]]
]
);
assert_eq!(
h.time_slices
.iter()
.map(|b| b.get_results())
.collect::<Vec<_>>(),
vec![
vec![vec![1.0, 0.0], vec![1.0, 0.0]],
vec![vec![0.0, 1.0], vec![0.0, 1.0]],
vec![vec![1.0, 0.0]],
vec![vec![1.0, 0.0]]
]
);
let end = h.time_slices.len() - 1;
assert_eq!(h.time_slices[0].skills.get(c).unwrap().elapsed, 0);
assert_eq!(h.time_slices[end].skills.get(c).unwrap().elapsed, 10);
assert_eq!(h.time_slices[0].skills.get(a).unwrap().elapsed, 0);
assert_eq!(h.time_slices[2].skills.get(a).unwrap().elapsed, 5);
assert_eq!(h.time_slices[0].skills.get(b).unwrap().elapsed, 0);
assert_eq!(h.time_slices[end].skills.get(b).unwrap().elapsed, 5);
let _ = h.converge().unwrap();
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior(),
h.time_slices[end].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(c).unwrap().posterior(),
h.time_slices[end].skills.get(c).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(c).unwrap().posterior(),
h.time_slices[0].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
// second scenario: team-0 wins (empty results in old API), different composition order
let mut h2: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift::new(0.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("c", "a"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[0, 10, 20],
);
h2.add_events(events).unwrap();
let _ = h2.converge().unwrap();
let a = h2.keys.get("a").unwrap();
let b = h2.keys.get("b").unwrap();
let c = h2.keys.get("c").unwrap();
let events2 = make_events_1v1(
&[("a", "b"), ("c", "a"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[15, 10, 0],
);
h2.add_events(events2).unwrap();
assert_eq!(h2.time_slices.len(), 4);
assert_eq!(
h2.time_slices
.iter()
.map(|ts| ts.events.len())
.collect::<Vec<_>>(),
vec![2, 2, 1, 1]
);
assert_eq!(
h2.time_slices
.iter()
.map(|b| b.get_composition())
.collect::<Vec<_>>(),
vec![
vec![vec![vec![a], vec![b]], vec![vec![b], vec![c]]],
vec![vec![vec![c], vec![a]], vec![vec![c], vec![a]]],
vec![vec![vec![a], vec![b]]],
vec![vec![vec![b], vec![c]]]
]
);
assert_eq!(
h2.time_slices
.iter()
.map(|b| b.get_results())
.collect::<Vec<_>>(),
vec![
vec![vec![1.0, 0.0], vec![1.0, 0.0]],
vec![vec![1.0, 0.0], vec![1.0, 0.0]],
vec![vec![1.0, 0.0]],
vec![vec![1.0, 0.0]]
]
);
let end = h2.time_slices.len() - 1;
assert_eq!(h2.time_slices[0].skills.get(c).unwrap().elapsed, 0);
assert_eq!(h2.time_slices[end].skills.get(c).unwrap().elapsed, 10);
assert_eq!(h2.time_slices[0].skills.get(a).unwrap().elapsed, 0);
assert_eq!(h2.time_slices[2].skills.get(a).unwrap().elapsed, 5);
assert_eq!(h2.time_slices[0].skills.get(b).unwrap().elapsed, 0);
assert_eq!(h2.time_slices[end].skills.get(b).unwrap().elapsed, 5);
let _ = h2.converge().unwrap();
assert_ulps_eq!(
h2.time_slices[0].skills.get(b).unwrap().posterior(),
h2.time_slices[end].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h2.time_slices[0].skills.get(c).unwrap().posterior(),
h2.time_slices[end].skills.get(c).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h2.time_slices[0].skills.get(c).unwrap().posterior(),
h2.time_slices[0].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
}
#[test]
fn test_1vs1_weighted() {
let mut h: History = History::builder()
.mu(2.0)
.sigma(6.0)
.beta(1.0)
.drift(ConstantDrift::new(0.0))
.build();
// empty results in old API = team 0 wins: a wins event 1, b wins event 2
let events: Vec<Event<i64, &'static str>> = vec![
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a").with_weight(5.0)]),
Team::with_members([Member::new("b").with_weight(4.0)]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 2,
teams: smallvec![
Team::with_members([Member::new("b").with_weight(5.0)]),
Team::with_members([Member::new("a").with_weight(4.0)]),
],
outcome: Outcome::winner(0, 2),
},
];
h.add_events(events).unwrap();
let lc_a = h.learning_curve("a").unwrap();
let lc_b = h.learning_curve("b").unwrap();
assert_ulps_eq!(
lc_a[0].1,
Gaussian::from_ms(5.537659, 4.758722),
epsilon = 1e-6
);
assert_ulps_eq!(
lc_b[0].1,
Gaussian::from_ms(-0.830127, 5.239568),
epsilon = 1e-6
);
assert_ulps_eq!(
lc_a[1].1,
Gaussian::from_ms(1.792278067, 4.099566582),
epsilon = 1e-6
);
assert_ulps_eq!(
lc_b[1].1,
Gaussian::from_ms(4.845533, 3.747616),
epsilon = 1e-6
);
let _ = h.converge().unwrap();
let lc_a = h.learning_curve("a").unwrap();
let lc_b = h.learning_curve("b").unwrap();
assert_ulps_eq!(lc_a[0].1, lc_a[0].1, epsilon = 1e-6);
assert_ulps_eq!(lc_b[0].1, lc_a[0].1, epsilon = 1e-6);
assert_ulps_eq!(lc_a[1].1, lc_a[0].1, epsilon = 1e-6);
assert_ulps_eq!(lc_b[1].1, lc_a[0].1, epsilon = 1e-6);
}
#[test]
fn test_converge_returns_report() {
use crate::ConvergenceOptions;
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
let report = h.converge().unwrap();
assert!(report.converged);
assert!(report.iterations > 0);
assert!(report.iterations < 30);
assert!(report.final_step.0 <= 1e-6);
}
#[test]
#[should_panic(expected = "score_sigma must be finite and positive")]
fn history_builder_rejects_zero_score_sigma() {
let _ = History::builder().score_sigma(0.0).build();
}
#[test]
fn history_propagates_convergence_to_inner_run_chain() {
use crate::ConvergenceOptions;
let events_for = |h: &mut History| {
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.ranking([0u32, 1, 2, 3])
.commit()
.unwrap();
};
let mut h_capped: History = History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_capped);
// A one-iteration cap is deliberate here, so the short fit is the
// result rather than an error.
let _ = h_capped.converge_partial().unwrap();
let mut h_full: History = History::builder().build();
events_for(&mut h_full);
let _ = h_full.converge().unwrap();
let curves_capped = h_capped.learning_curves();
let curves_full = h_full.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, capped_pts) in curves_capped.iter() {
let full_pts = curves_full.get(key).expect("competitor missing in full");
for (capped, full) in capped_pts.iter().zip(full_pts.iter()) {
max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs());
max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"max_iter=1 inner loop should differ from default; max_diff={max_diff}"
);
}
#[test]
fn history_with_damping_reaches_same_fixed_point_as_undamped() {
use crate::ConvergenceOptions;
let events_for = |h: &mut History| {
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.ranking([0u32, 1, 2, 3])
.commit()
.unwrap();
};
let mut h_undamped: History = History::builder().build();
events_for(&mut h_undamped);
let _ = h_undamped.converge().unwrap();
let mut h_damped: History = History::builder()
.convergence(ConvergenceOptions {
alpha: 0.5,
max_iter: 200,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_damped);
let _ = h_damped.converge().unwrap();
let curves_u = h_undamped.learning_curves();
let curves_d = h_damped.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, u_pts) in curves_u.iter() {
let d_pts = curves_d.get(key).expect("competitor missing in damped");
for (u, d) in u_pts.iter().zip(d_pts.iter()) {
max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs());
max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs());
}
}
assert!(
max_diff < 1e-3,
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
);
}
#[test]
fn outcome_scores_default_sigma_uses_history_default() {
use crate::Outcome;
// Path A: explicit sigma=0.5 via override.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_noise([3.0, 1.0], 0.5),
}])
.unwrap();
let _ = h_a.converge().unwrap();
// Path B: history-wide default 0.5, no per-event override.
let mut h_b = crate::History::builder().score_sigma(0.5).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
let _ = h_b.converge().unwrap();
// Inheritance: posteriors must be bit-equal.
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("competitor missing in path B");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at competitor {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at competitor {key:?}");
}
}
}
#[test]
fn outcome_scores_with_sigma_overrides_history_default() {
use crate::Outcome;
// Path A: history-wide default 0.5, per-event override 2.0.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_noise([3.0, 1.0], 2.0),
}])
.unwrap();
let _ = h_a.converge().unwrap();
// Path B: history-wide default 2.0, no per-event override.
let mut h_b = crate::History::builder().score_sigma(2.0).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
let _ = h_b.converge().unwrap();
// Override == default-set-to-the-override-value: bit-equal.
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("competitor missing in path B");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at competitor {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at competitor {key:?}");
}
}
// Path C: history-wide default 0.5, no override. Different sigma → different posteriors.
let mut h_c = crate::History::builder().score_sigma(0.5).build();
h_c.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
let _ = h_c.converge().unwrap();
let curves_c = h_c.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, a_pts) in curves_a.iter() {
let c_pts = curves_c.get(key).expect("competitor missing in path C");
for (a, c) in a_pts.iter().zip(c_pts.iter()) {
max_diff = max_diff.max((a.1.mu() - c.1.mu()).abs());
max_diff = max_diff.max((a.1.sigma() - c.1.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"override should produce different posteriors from inherited default; max_diff={max_diff}"
);
}
#[test]
fn event_builder_scores_with_sigma_threading() {
use crate::Outcome;
// Path A: builder fluent API with sigma override.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.event(0_i64)
.team(["a"])
.team(["b"])
.scores_with_noise([3.0, 1.0], 2.0)
.commit()
.unwrap();
let _ = h_a.converge().unwrap();
// Path B: same outcome via the explicit Outcome constructor.
let mut h_b = crate::History::builder().score_sigma(0.5).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_noise([3.0, 1.0], 2.0),
}])
.unwrap();
let _ = h_b.converge().unwrap();
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("competitor missing");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at competitor {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at competitor {key:?}");
}
}
}
}