Six of fifteen variants carried a `&'static str` discriminator, about
thirty magic strings between them, and the only thing a caller could do
with one was print it. Four new enums replace them:
Parameter 13 variants, replacing 9 strings in InvalidParameter
Shape 4 variants, replacing 10 in MismatchedShape
OutcomeKind 2 variants, replacing WrongOutcomeKind's three fields
CompetitorField 2 variants, replacing ConflictingCompetitorConfig's
`InvalidProbability` folds into `InvalidParameter` as
`Parameter::PDraw`. It was a bespoke variant for one scalar while every
other scalar shared `InvalidParameter`, and it omitted the parameter
name — so the same parameter had two mechanisms.
`JointUnavailable { reason: &'static str }` splits into `EmptyHistory`,
`JointRequiresScoredEvents` and `NotPositiveDefinite`. The three are
conditions a caller branches on differently — add events, use
`predict_win_probabilities`, or reconsider the priors — and telling them
apart used to mean string-matching English. One test already proved the
distinction was load-bearing: the blanket conversion mapped the
empty-history case onto the ranked one and `an_empty_history_has_no_joint`
caught it immediately.
`NonFiniteResult` splits into `NonFiniteStep { context, step }` and
`NonFiniteSkill { mu, sigma }`. One `step: (f64, f64)` field was
carrying a sweep step from `converge` and a skill's own moments from a
prediction — two situations in one variant, and a field name that could
only be right for one of them.
`InvalidParameter { name: "beta with point-mass skills" }` becomes
`NoPerformanceVariance`. It was never a parameter out of range: both
values are individually valid and it is their combination that leaves
nothing varying.
Three `Display` impls did not meet the standard the others set, and the
typed data is what makes fixing them possible:
before drift variance is invalid: NaN
after drift variance must be finite and non-negative (got NaN)
before kinds: expected length 3, got 2
after the outcome describes a different number of teams than the
event has: expected 3, got 2
before Game::ranked: expected Outcome::Ranked, got Outcome::Scored
after expected Outcome::Ranked, got Outcome::Scored; call
Game::scored for a scored outcome
`Parameter::range()` states each parameter's actual bounds, which no
`&'static str` name could have. `error::message_tests` renders every one
and asserts each is a sentence rather than a label, and that the three
above now carry a range or a next step.
The four internal `MismatchedShape` kinds — `results`, `times`, `kinds`,
and the weights array — collapse to `Shape::Internal`, whose `Display`
says plainly that reaching it is a bug in this crate. They are checks on
`add_events_with_prior`'s own parallel arrays and are unreachable
through the public API; they stay checked rather than becoming
`debug_assert!`s, because release is where this crate's defects hide.
Closes #74.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
4305 lines
162 KiB
Rust
4305 lines
162 KiB
Rust
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,
|
||
rating_rule::{FnRule, NoRule, RatingRule, StartingPoint},
|
||
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, R>`, 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,
|
||
R: RatingRule<K> = NoRule,
|
||
> {
|
||
mu: f64,
|
||
sigma: f64,
|
||
beta: f64,
|
||
drift: D,
|
||
p_draw: f64,
|
||
score_sigma: f64,
|
||
convergence: ConvergenceOptions,
|
||
observer: O,
|
||
unknown_keys: crate::UnknownKeys,
|
||
rule: R,
|
||
_time: PhantomData<T>,
|
||
_key: PhantomData<K>,
|
||
}
|
||
|
||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone, R: RatingRule<K>>
|
||
HistoryBuilder<K, T, D, O, R>
|
||
{
|
||
/// Prior mean skill.
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if `mu` is not finite. A non-finite prior mean poisons every
|
||
/// posterior derived from it: `converge` reports `NonFiniteStep`, 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, R> {
|
||
HistoryBuilder {
|
||
drift,
|
||
rule: self.rule,
|
||
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, R>
|
||
where
|
||
T2: Time,
|
||
D: Drift<T2>,
|
||
O: Observer<T2>,
|
||
{
|
||
HistoryBuilder {
|
||
rule: self.rule,
|
||
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, NoRule> {
|
||
HistoryBuilder {
|
||
// A `RatingRule<K>` cannot answer questions about `K2`, so
|
||
// changing the key type drops it. Set the key type first.
|
||
rule: NoRule,
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Supply a rule that configures competitors the history has not seen.
|
||
///
|
||
/// `register` states configuration for one competitor at a time, which
|
||
/// needs the key set up front. This states it for a *class* — "every
|
||
/// layout is static" — as one statement that cannot be forgotten on an
|
||
/// ingestion path.
|
||
///
|
||
/// Consulted once per competitor, at creation. Explicit configuration from
|
||
/// `register` or a [`Member`](crate::Member) overrides it field by field;
|
||
/// see [`RatingRule`] for why the specific beats the general here while
|
||
/// two explicit declarations that disagree stay an error.
|
||
///
|
||
/// Changes the builder's type — bind the result — and must come *after*
|
||
/// [`key_type`](HistoryBuilder::key_type), since a `RatingRule<K>` cannot
|
||
/// answer questions about a different key type.
|
||
///
|
||
/// ```
|
||
/// # use trueskill_tt::{Gaussian, History, StartingPoint};
|
||
/// let h = History::builder()
|
||
/// .default_rating_for(|key: &&'static str| {
|
||
/// key.starts_with("bot_")
|
||
/// .then(|| StartingPoint::new().drift_scale(0.0))
|
||
/// })
|
||
/// .build();
|
||
/// # let _ = h;
|
||
/// ```
|
||
pub fn default_rating_for<F>(self, rule: F) -> HistoryBuilder<K, T, D, O, FnRule<F>>
|
||
where
|
||
F: Fn(&K) -> Option<StartingPoint>,
|
||
{
|
||
HistoryBuilder {
|
||
rule: FnRule(rule),
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Supply a rule as a named type implementing [`RatingRule`].
|
||
///
|
||
/// The counterpart of [`default_rating_for`](HistoryBuilder::default_rating_for)
|
||
/// for when the resulting `History<..>` has to be written down in a struct
|
||
/// field, which a closure's type makes impossible.
|
||
pub fn rating_rule<R2: RatingRule<K>>(self, rule: R2) -> HistoryBuilder<K, T, D, O, R2> {
|
||
HistoryBuilder {
|
||
rule,
|
||
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, R> {
|
||
HistoryBuilder {
|
||
rule: self.rule,
|
||
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, R> {
|
||
History {
|
||
rule: self.rule,
|
||
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 {
|
||
rule: NoRule,
|
||
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 {
|
||
/// Precision matrix over appearances, accumulated sparsely.
|
||
lambda: crate::joint::SymmetricBuilder,
|
||
/// `(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, R>` — 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,
|
||
R: RatingRule<K> = NoRule,
|
||
> {
|
||
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,
|
||
/// Supplies a starting point for competitors nobody declared explicitly.
|
||
rule: R,
|
||
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, R: RatingRule<K>>
|
||
History<K, T, D, O, R>
|
||
{
|
||
/// 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, R: RatingRule<K>>
|
||
History<K, T, D, O, R>
|
||
{
|
||
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 {
|
||
parameter: crate::Parameter::Weight,
|
||
value: member.weight,
|
||
});
|
||
}
|
||
if let Some(scale) = member.drift_scale {
|
||
if !scale.is_finite() || scale < 0.0 {
|
||
return Err(InferenceError::InvalidParameter {
|
||
parameter: crate::Parameter::DriftScale,
|
||
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,
|
||
);
|
||
// The rule first, then the explicit values over the top of it: the
|
||
// specific beats the general, field by field. See `RatingRule`.
|
||
let from_rule = self.rule.starting_point(&member.key).unwrap_or_default();
|
||
if let Some(prior) = member.prior.or(from_rule.prior) {
|
||
rating.prior = prior;
|
||
}
|
||
if let Some(scale) = member.drift_scale.or(from_rule.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 `NonFiniteStep`: `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::NonFiniteSkill {
|
||
mu: skill.mu(),
|
||
sigma: 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::NoPerformanceVariance);
|
||
}
|
||
|
||
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: `NonFiniteSkill` if a skill has no usable mean or variance
|
||
/// — the fit did not converge — and `NoPerformanceVariance` 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 `NotPositiveDefinite`.
|
||
///
|
||
/// 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
|
||
}
|
||
|
||
/// Test-only: the joint's sparsity pattern, for the #52 measurement.
|
||
///
|
||
/// Returns `(n, adjacency)` where `adjacency[i]` holds the off-diagonal
|
||
/// nonzero columns of row `i`.
|
||
#[cfg(feature = "measure-sparsity")]
|
||
pub fn joint_pattern_for_measurement(&self) -> (usize, Vec<std::collections::HashSet<usize>>) {
|
||
let te = self.time_expanded_joint();
|
||
let n = te.width;
|
||
let mut adj = vec![std::collections::HashSet::new(); n];
|
||
for (i, j) in te.lambda.pattern() {
|
||
if i != j {
|
||
adj[i].insert(j);
|
||
}
|
||
}
|
||
(n, adj)
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// Accumulated sparsely: this matrix is ~0.19% dense at scale, and the
|
||
// dense form was 31 MB at n = 1976 and 128 MB at ustat's ~4000
|
||
// appearances, of which 99.8% was zeros. See `joint.rs` and #52.
|
||
let mut lambda = crate::joint::SymmetricBuilder::new();
|
||
|
||
for (row, competitor) in first_rows {
|
||
lambda.add(
|
||
row,
|
||
row,
|
||
1.0 / self.competitors[competitor].rating.prior.sigma().powi(2),
|
||
);
|
||
}
|
||
for (a, b, drift) in drift_links {
|
||
lambda.add(a, a, 1.0 / drift);
|
||
lambda.add(b, b, 1.0 / drift);
|
||
lambda.add(a, b, -1.0 / drift);
|
||
lambda.add(b, 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.add(ra, 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
|
||
///
|
||
/// `EmptyHistory` for a history with no events, `JointRequiresScoredEvents`
|
||
/// for one containing ranked events, and `NotPositiveDefinite` if the
|
||
/// assembled matrix is indefinite.
|
||
pub fn joint(&self) -> Result<Joint<'_, K, T, D, O, R>, InferenceError> {
|
||
if self.time_slices.is_empty() {
|
||
return Err(InferenceError::EmptyHistory);
|
||
}
|
||
if !self.time_slices.iter().all(TimeSlice::all_scored) {
|
||
return Err(InferenceError::JointRequiresScoredEvents);
|
||
}
|
||
|
||
let TimeExpanded {
|
||
lambda,
|
||
latest,
|
||
at_slice,
|
||
width,
|
||
} = self.time_expanded_joint();
|
||
|
||
let cholesky = crate::joint::Cholesky::factor(lambda, width)
|
||
.ok_or(InferenceError::NotPositiveDefinite)?;
|
||
|
||
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 `EmptyHistory` / `JointRequiresScoredEvents` — the latter if *any*
|
||
/// slice holds ranked events, 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 {
|
||
shape: crate::Shape::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: `NonFiniteSkill` if a skill has no usable mean or variance
|
||
/// — the fit did not converge — and `NoPerformanceVariance` 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: `NonFiniteSkill` if a skill has no usable mean or variance
|
||
/// — the fit did not converge — and `NoPerformanceVariance` 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: `NonFiniteSkill` if a skill has no usable mean or variance
|
||
/// — the fit did not converge — and `NoPerformanceVariance` 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: `NonFiniteSkill` if a skill has no usable mean or variance
|
||
/// — the fit did not converge — and `NoPerformanceVariance` 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 {
|
||
shape: crate::Shape::OutcomeVsTeams,
|
||
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`.
|
||
///
|
||
/// `NonFiniteStep` 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
|
||
///
|
||
/// `NonFiniteStep` 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 {
|
||
parameter: crate::Parameter::DriftVariance,
|
||
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::NonFiniteStep {
|
||
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, R: RatingRule<K>>
|
||
History<K, T, D, O, R>
|
||
{
|
||
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 {
|
||
shape: crate::Shape::Internal,
|
||
expected: composition.len(),
|
||
got,
|
||
});
|
||
}
|
||
if times.len() != composition.len() {
|
||
return Err(InferenceError::MismatchedShape {
|
||
shape: crate::Shape::Internal,
|
||
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 {
|
||
shape: crate::Shape::Weights,
|
||
expected: composition.len(),
|
||
got,
|
||
});
|
||
}
|
||
if kinds.len() != composition.len() {
|
||
return Err(InferenceError::MismatchedShape {
|
||
shape: crate::Shape::Internal,
|
||
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 `NonFiniteStep`, 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 parameter = match kind {
|
||
EventKind::Ranked => crate::Parameter::Rank,
|
||
EventKind::Scored { .. } => crate::Parameter::Score,
|
||
};
|
||
for value in event_results {
|
||
if !value.is_finite() {
|
||
return Err(InferenceError::InvalidParameter {
|
||
parameter,
|
||
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 {
|
||
parameter: crate::Parameter::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: crate::CompetitorField::Prior,
|
||
});
|
||
}
|
||
}
|
||
if let (Some(existing), Some(new)) = (held.drift_scale, batch.drift_scale) {
|
||
if existing != new {
|
||
return Err(InferenceError::ConflictingCompetitorConfig {
|
||
competitor: competitor.get(),
|
||
field: crate::CompetitorField::DriftScale,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
);
|
||
// Same precedence as `register`: the rule supplies a starting
|
||
// point, explicit configuration overrides it field by field.
|
||
let from_rule = self
|
||
.keys
|
||
.key(*competitor)
|
||
.and_then(|key| self.rule.starting_point(key))
|
||
.unwrap_or_default();
|
||
if let Some(prior) = config.prior.or(from_rule.prior) {
|
||
rating.prior = prior;
|
||
}
|
||
if let Some(scale) = config.drift_scale.or(from_rule.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(×, 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, R> {
|
||
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 {
|
||
shape: crate::Shape::OutcomeVsTeams,
|
||
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 {
|
||
parameter: crate::Parameter::DriftScale,
|
||
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: crate::CompetitorField::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: crate::CompetitorField::DriftScale,
|
||
});
|
||
}
|
||
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 {
|
||
parameter: crate::Parameter::ScoreSigma,
|
||
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, R: RatingRule<K>> std::fmt::Debug
|
||
for History<K, T, D, O, R>
|
||
{
|
||
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,
|
||
R: RatingRule<K> = NoRule,
|
||
> {
|
||
history: &'h History<K, T, D, O, R>,
|
||
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, R: RatingRule<K>> std::fmt::Debug
|
||
for Joint<'_, K, T, D, O, R>
|
||
{
|
||
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>, R: RatingRule<K>>
|
||
Joint<'_, K, T, D, O, R>
|
||
{
|
||
/// 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 {
|
||
shape: crate::Shape::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:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|