Files
trueskill-tt/src/history.rs
T
logaritmiskandClaude Opus 5 c12bc830a5 feat!: name the unknown key, expose tail probabilities, flag short fits
Three issues from two downstream consumers, all small, all sharing a
theme: the crate had the information and would not hand it over.

#44 — `UnknownKey { team: 0, member: 0 }` did not say which key. A
consumer upgrading 0.1.2 -> 0.4.1 had every one of 5591 predictions
return this error, fell back to a neutral 0.5, and lost its entire
metadata model for a day. Nothing crashed and nothing logged; it was
found by sweeping an unrelated parameter and noticing the output did not
move. The 0.4.0 change that made unknown keys an error was right — the
error was just too anonymous to act on. It now carries the key's `Debug`
rendering, and its `Display` says what to do about it. The precondition
is documented on every prediction entry point, which the reporter said
would alone have saved the day.

#43 — `cdf` was `pub(crate)`, so a consumer asking "is this competitor
below the cutoff" approximated it with a `mu + z * sigma` band and had no
way to say what confidence any `z` bought. Adds
`Gaussian::probability_below` / `probability_above`. The second is
separate on purpose: `1 - cdf` collapses to exactly zero past ~8.3
sigma, and a stopping rule is evaluated precisely there. Both route
through the survival function added in 0.4.1, so this is visibility
rather than new numerics.

#50 — `ConvergenceReport` was not `#[must_use]`, so the one signal that
a fit stopped short was trivially discarded. It now is, and that
immediately found 78 sites doing exactly that — including this crate's
own ATP example, which was capped at 10 sweeps when the history needs
30. The example now reads the report and says so.

`ITERATIONS = 30` is documented as the floor it is, with the three
measurements to hand: 400 events over 100 competitors already stops
there at ~7e-3 against a 1e-6 tolerance, the ATP example needs 30 at a
much looser one, and a consumer's 2000-node model needs 76 to 161.

BREAKING CHANGE: `InferenceError::UnknownKey` gains a `key` field, and
the prediction methods now require `K: Debug` in order to fill it.

Closes #43, #50. Refs #44 — its third ask, an opt-in `UnknownKeys::Skip`
mode, is a live API question and deliberately not answered here.

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

2662 lines
90 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::HashMap, hash::Hash, marker::PhantomData};
use crate::{
BETA, GAMMA, Index, MU, P_DRAW, SIGMA,
competitor::{self, Competitor},
convergence::{ConvergenceOptions, ConvergenceReport},
drift::{ConstantDrift, Drift},
error::InferenceError,
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,
};
#[derive(Clone)]
pub struct HistoryBuilder<
T: Time = i64,
D: Drift<T> = ConstantDrift,
O: Observer<T> = NullObserver,
K: Eq + Hash + Clone = &'static str,
> {
mu: f64,
sigma: f64,
beta: f64,
drift: D,
p_draw: f64,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
_time: PhantomData<T>,
_key: PhantomData<K>,
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<T, D, O, K> {
pub fn mu(mut self, mu: f64) -> Self {
self.mu = mu;
self
}
pub fn sigma(mut self, sigma: f64) -> Self {
self.sigma = sigma;
self
}
pub fn beta(mut self, beta: f64) -> Self {
self.beta = beta;
self
}
pub fn drift<D2: Drift<T>>(self, drift: D2) -> HistoryBuilder<T, D2, O, K> {
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,
_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 > 0.0,
"score_sigma must be positive (got {score_sigma})"
);
self.score_sigma = score_sigma;
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
}
pub fn observer<O2: Observer<T>>(self, observer: O2) -> HistoryBuilder<T, D, O2, K> {
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,
_time: self._time,
_key: self._key,
}
}
pub fn build(self) -> History<T, D, O, K> {
History {
size: 0,
time_slices: Vec::new(),
agents: 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,
}
}
}
impl Default for HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
fn default() -> Self {
Self {
mu: MU,
sigma: SIGMA,
beta: BETA,
drift: ConstantDrift(GAMMA),
p_draw: P_DRAW,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
observer: NullObserver,
_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>,
}
impl CompetitorConfig {
fn is_empty(self) -> bool {
self.prior.is_none() && self.drift_scale.is_none()
}
}
pub struct History<
T: Time = i64,
D: Drift<T> = ConstantDrift,
O: Observer<T> = NullObserver,
K: Eq + Hash + Clone = &'static str,
> {
size: usize,
pub(crate) time_slices: Vec<TimeSlice<T>>,
pub(crate) agents: CompetitorStore<T, D>,
keys: KeyTable<K>,
mu: f64,
sigma: f64,
beta: f64,
drift: D,
p_draw: f64,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
}
impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
fn default() -> Self {
HistoryBuilder::default().build()
}
}
impl History<i64, ConstantDrift, NullObserver, &'static str> {
#[must_use]
pub fn builder() -> HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
HistoryBuilder::default()
}
}
impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
/// Like `builder()` but uses a custom key type `K` instead of the default `&'static str`.
#[must_use]
pub fn builder_with_key() -> HistoryBuilder<i64, ConstantDrift, NullObserver, K> {
HistoryBuilder {
mu: MU,
sigma: SIGMA,
beta: BETA,
drift: ConstantDrift(GAMMA),
p_draw: P_DRAW,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
observer: NullObserver,
_time: PhantomData,
_key: PhantomData,
}
}
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
pub 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)
}
pub fn lookup<Q>(&self, key: &Q) -> Option<Index>
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized,
{
self.keys.get(key)
}
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
fn iteration(&mut self) -> (f64, f64) {
let mut step = (0.0, 0.0);
if self.time_slices.is_empty() {
return step;
}
competitor::clean(self.agents.values_mut(), false);
for j in (0..self.time_slices.len() - 1).rev() {
for agent in self.time_slices[j + 1].skills.keys() {
self.agents.get_mut(agent).unwrap().message =
Some(self.time_slices[j + 1].backward_prior_out(&agent, &self.agents));
}
let old = self.time_slices[j].posteriors();
self.time_slices[j].new_backward_info(&self.agents);
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.agents.values_mut(), false);
for j in 1..self.time_slices.len() {
for agent in self.time_slices[j - 1].skills.keys() {
self.agents.get_mut(agent).unwrap().message =
Some(self.time_slices[j - 1].forward_prior_out(&agent));
}
let old = self.time_slices[j].posteriors();
self.time_slices[j].new_forward_info(&self.agents);
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.agents);
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()
}
/// Learning curves for all competitors, keyed by their user-facing key.
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.
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()))
}
/// Learning curve for a single key: (time, posterior) pairs in time order.
pub fn learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let Some(idx) = self.keys.get(key) else {
return Vec::new();
};
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.
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() {
for (agent, posterior) in step.posteriors {
if let Some(key) = self.keys.key(agent).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.
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
let Some(idx) = self.keys.get(key) else {
return Vec::new();
};
self.filtered_pass()
.into_iter()
.filter_map(|(time, step)| {
step.posteriors
.iter()
.find(|(agent, _)| *agent == 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 agents = &self.agents;
#[cfg(feature = "rayon")]
{
use rayon::prelude::*;
let per_slice: Vec<f64> = self
.time_slices
.par_iter()
.map(|ts| ts.log_evidence(targets, forward, agents))
.collect();
per_slice.into_iter().sum()
}
#[cfg(not(feature = "rayon"))]
{
self.time_slices
.iter()
.map(|ts| ts.log_evidence(targets, forward, agents))
.sum()
}
}
/// Total log-evidence across the history.
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.
pub fn log_evidence_for<Q>(&self, keys: &[&Q]) -> f64
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let targets: Vec<Index> = keys.iter().filter_map(|k| self.keys.get(*k)).collect();
self.log_evidence_internal(false, &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.
fn filtered_pass(&self) -> 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.agents);
for &(agent, posterior) in &step.posteriors {
messages.insert(agent, 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()
.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(&self, teams: &[&[&K]]) -> Result<Vec<Vec<Gaussian>>, InferenceError>
where
K: 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() {
let unknown = InferenceError::UnknownKey {
team: team_idx,
member: member_idx,
key: format!("{key:?}"),
};
let index = self.keys.get(*key).ok_or(unknown.clone())?;
members.push(
self.time_slices
.iter()
.rev()
.find_map(|ts| ts.skills.get(index).map(|s| s.posterior()))
.ok_or(unknown)?,
);
}
gathered.push(members);
}
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 [`History::member_skills`].
fn performances(&self, teams: &[&[&K]]) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError>
where
K: std::fmt::Debug,
{
let skills = self.member_skills(teams)?;
let performances = skills
.iter()
.map(|team| {
team.iter()
.fold(crate::N00, |acc, s| acc + 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)
}
})
}
/// Draw-probability quality metric for the given teams (key slices).
///
/// Values range roughly `[0, 1]`; 1 == perfectly matched. Supports any
/// number of teams.
///
/// Note this answers "is this matchup *fair*", which is not the same as
/// "is this matchup *informative*" — the two coincide for two evenly
/// matched teams and diverge elsewhere.
///
/// # 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::lookup`] or [`History::current_skill`]; 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`.
pub fn predict_quality(&self, teams: &[&[&K]]) -> Result<f64, InferenceError>
where
K: 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))
}
/// 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::predict_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.
///
/// # Errors
///
/// # 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::lookup`] or [`History::current_skill`]; 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.
///
/// As [`History::member_skills`], plus `TooManyTeams` and anything
/// inference returns for a hypothetical outcome.
pub fn expected_information_gain(&self, teams: &[&[&K]]) -> Result<f64, InferenceError>
where
K: 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::lookup`] or [`History::current_skill`]; 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`.
pub fn predict_win_probabilities(&self, teams: &[&[&K]]) -> Result<Vec<f64>, InferenceError>
where
K: 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::lookup`] or [`History::current_skill`]; 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`.
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Result<Prediction, InferenceError>
where
K: 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.
///
/// # Errors
///
/// # 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::lookup`] or [`History::current_skill`]; 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.
///
/// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey`, or `MismatchedShape` if
/// `ranks` does not have one entry per team.
pub fn predict_ranking(&self, teams: &[&[&K]], ranks: &[u32]) -> Result<f64, InferenceError>
where
K: 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)?;
Ok(crate::predict::ranking_probability(
&performances,
&self.margins(&sizes),
ranks,
))
}
/// Run the full forward+backward convergence loop and return a summary.
///
/// Failing to reach `epsilon` within `max_iter` is not an error: the
/// returned report carries `converged: false` and the final step.
///
/// # Errors
///
/// `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.
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
use std::time::Instant;
use smallvec::SmallVec;
let opts = self.convergence;
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<T, D, O, K> {
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 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 });
}
}
}
competitor::clean(self.agents.values_mut(), true);
let mut this_agent = Vec::with_capacity(1024);
for agent in composition.iter().flatten().flatten() {
if this_agent.contains(agent) {
continue;
}
this_agent.push(*agent);
let config = priors.get(agent).copied().unwrap_or_default();
if self.agents.contains(*agent) {
// 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.agents.get_mut(*agent).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(*agent) {
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.agents.insert(
*agent,
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.agents);
}
for agent_idx in &this_agent {
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
skill.elapsed = time_slice::compute_elapsed(
self.agents[*agent_idx].last_time.as_ref(),
&time_slice.time,
);
let agent = self.agents.get_mut(*agent_idx).unwrap();
agent.last_time = Some(time_slice.time);
agent.message = Some(time_slice.forward_prior_out(agent_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.agents);
for agent_idx in time_slice.skills.keys() {
let agent = self.agents.get_mut(agent_idx).unwrap();
agent.last_time = Some(t);
agent.message = Some(time_slice.forward_prior_out(&agent_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.agents);
self.time_slices.insert(k, time_slice);
let time_slice = &self.time_slices[k];
for agent_idx in time_slice.skills.keys() {
let agent = self.agents.get_mut(agent_idx).unwrap();
agent.last_time = Some(t);
agent.message = Some(time_slice.forward_prior_out(&agent_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.agents);
for agent_idx in &this_agent {
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
skill.elapsed = time_slice::compute_elapsed(
self.agents[*agent_idx].last_time.as_ref(),
&time_slice.time,
);
let agent = self.agents.get_mut(*agent_idx).unwrap();
agent.last_time = Some(time_slice.time);
agent.message = Some(time_slice.forward_prior_out(agent_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, or if per-member weights do not match
/// the team's membership.
/// - `InvalidParameter` if a per-event `score_sigma` override is not
/// strictly positive.
/// - `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, sigma } => {
let resolved = 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)
}
}
#[cfg(test)]
mod tests {
use approx::assert_ulps_eq;
use smallvec::smallvec;
use super::*;
use crate::{
ConstantDrift, EPSILON, Event, Game, Gaussian, Member, Outcome, P_DRAW, Team,
arena::ScratchArena,
};
/// #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<i64, ConstantDrift, NullObserver, String> =
History::builder_with_key().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(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 = Game::ranked_with_arena(
h.time_slices[1].events[0].within_priors(false, &h.time_slices[1].skills, &h.agents),
&[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(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(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(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");
let lc_c = h.learning_curve("c");
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(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<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.drift(ConstantDrift(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<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(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<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(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<i64, _, _, &'static str> = 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 (old test used convergence(11, ...))
h.convergence = ConvergenceOptions {
max_iter: 11,
epsilon: EPSILON,
alpha: 1.0,
};
let _ = h.converge().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<i64, _, _, &'static str> = 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<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(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<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(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<i64, _, _, &'static str> = History::builder()
.mu(2.0)
.sigma(6.0)
.beta(1.0)
.drift(ConstantDrift(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");
let lc_b = h.learning_curve("b");
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");
let lc_b = h.learning_curve("b");
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<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(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 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<i64, ConstantDrift, crate::observer::NullObserver, &'static str>| {
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.ranking([0u32, 1, 2, 3])
.commit()
.unwrap();
};
let mut h_capped: History<i64, _, _, &'static str> = History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_capped);
let _ = h_capped.converge().unwrap();
let mut h_full: History<i64, _, _, &'static str> = 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("agent 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<i64, ConstantDrift, crate::observer::NullObserver, &'static str>| {
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.ranking([0u32, 1, 2, 3])
.commit()
.unwrap();
};
let mut h_undamped: History<i64, _, _, &'static str> = History::builder().build();
events_for(&mut h_undamped);
let _ = h_undamped.converge().unwrap();
let mut h_damped: History<i64, _, _, &'static str> = 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("agent 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_sigma([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("agent 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 agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {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_sigma([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("agent 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 agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {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("agent 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_sigma([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_sigma([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("agent missing");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
}
}