Files
trueskill-tt/tests/predict_margin.rs
T
logaritmiskandClaude Opus 5 c866210c65 feat: add History::predict_margin for scored matchups
#48: every predict_* answers "who wins", and a consumer recording scores
never asks that. It wants the interval on the result, and having none it
hand-fitted a noise law whose fitted node weight came out at 0.0 — so
the quoted sigma was 5.83 whether the competitor had forty rounds or
none, against real residual spreads of 5.8 and 12.44.

`predict_margin` composes the three things that make a scored result
uncertain: the joint posterior over the competitors, their per-event
performance noise, and the observation noise on the score. It widens as
the model knows less — measured, sigma 2.48 against an opponent with
forty rounds, 3.38 against one seen once, wider still against one never
seen — which is the property the hand-fitted law lost.

It is a margin, not a score, and that is not a shortcut. Scored
ingestion reduces every event to `score_a - score_b` before inference,
so the absolute level is discarded: shifting every score in a history by
+100 or -1000 produces a bit-identical fit, verified. There is no
information from which to predict what a competitor will *score*.
Returning one would be a number derived entirely from the prior, which
is exactly the plausible constant this crate keeps finding and removing.

`posterior_of` now honours `UnknownKeys::Prior`, which gives #48 its
second requirement — "I have never seen this competitor, here is the
prior-informed answer". An unseen competitor shares no event with the
slice, so it is independent by construction and its variance is additive
rather than part of the solve.

Worth recording for expectations: for a *margin* the joint buys little
over adding marginals (2.4798 against 2.5112 here), because a margin is
a difference and differences are where the loopy underestimate and the
ignored correlation cancel. The gain here is having a predictive
distribution at all. `posterior_of`'s correlation handling earns its
keep on sums and single nodes instead — see tests/additive_model.rs.

Closes #48

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

154 lines
4.7 KiB
Rust

//! `predict_margin`: the predictive distribution of a scored matchup.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
UnknownKeys,
};
fn builder(
policy: UnknownKeys,
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.0))
.unknown_keys(policy)
.convergence(ConvergenceOptions {
max_iter: 5_000,
epsilon: 1e-12,
alpha: 1.0,
})
.build()
}
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([sa, sb]),
}
}
/// A history where "veteran" and "regular" are well observed and "novice"
/// appears once.
fn fitted(
policy: UnknownKeys,
) -> History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str> {
let mut h = builder(policy);
let mut events: Vec<_> = (0..40)
.map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0))
.collect();
events.push(round("veteran", "novice", 10.0, 6.0));
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
h
}
/// The property #48 exists for: the interval must widen when the model knows
/// less. Their hand-fitted noise law quoted the same sigma for a competitor
/// with forty rounds and one with none.
#[test]
fn the_interval_widens_as_the_model_knows_less() {
let h = fitted(UnknownKeys::Prior);
let well_known = h
.predict_margin(&[&[&"veteran"], &[&"regular"]])
.unwrap()
.sigma();
let thin = h
.predict_margin(&[&[&"veteran"], &[&"novice"]])
.unwrap()
.sigma();
let unseen = h
.predict_margin(&[&[&"veteran"], &[&"stranger"]])
.unwrap()
.sigma();
assert!(
well_known < thin && thin < unseen,
"margin width should grow as evidence thins: {well_known} < {thin} < {unseen}"
);
}
/// #48's second requirement: an unseen competitor is a legitimate question, not
/// an error, and the answer should come from the prior rather than be faked.
#[test]
fn an_unseen_competitor_is_answered_from_the_prior() {
let h = fitted(UnknownKeys::Prior);
let g = h.predict_margin(&[&[&"nobody"], &[&"no_one"]]).unwrap();
// Two unknowns: the gap is centred on zero and carries both priors plus
// both performance noises plus the observation noise.
assert!(g.mu().abs() < 1e-9, "mu {}", g.mu());
let expected = (2.0 * 36.0 + 2.0 * 1.0 + 4.0f64).sqrt();
assert!(
(g.sigma() - expected).abs() < 1e-9,
"sigma {} vs expected {expected}",
g.sigma()
);
}
#[test]
fn reject_still_rejects() {
let h = fitted(UnknownKeys::Reject);
assert!(matches!(
h.predict_margin(&[&[&"veteran"], &[&"stranger"]]),
Err(InferenceError::UnknownKey { .. })
));
}
/// The margin is the *difference*, so it must be antisymmetric in the teams.
#[test]
fn swapping_the_teams_negates_the_margin() {
let h = fitted(UnknownKeys::Prior);
let forward = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
let reverse = h.predict_margin(&[&[&"regular"], &[&"veteran"]]).unwrap();
assert!((forward.mu() + reverse.mu()).abs() < 1e-9);
assert!((forward.sigma() - reverse.sigma()).abs() < 1e-12);
}
/// The predictive interval must be wider than the skill gap alone: it also
/// carries per-event performance noise and the observation noise.
#[test]
fn the_predictive_interval_exceeds_the_skill_uncertainty() {
let h = fitted(UnknownKeys::Prior);
let skill_gap = h
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
.unwrap();
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
assert!(
(predictive.mu() - skill_gap.mu()).abs() < 1e-12,
"means agree"
);
// beta^2 twice plus score_sigma^2 = 2 + 4.
let expected = (skill_gap.sigma().powi(2) + 6.0).sqrt();
assert!((predictive.sigma() - expected).abs() < 1e-12);
assert!(predictive.sigma() > skill_gap.sigma());
}
#[test]
fn shape_errors_are_reported() {
let h = fitted(UnknownKeys::Prior);
assert!(matches!(
h.predict_margin(&[&[&"veteran"]]),
Err(InferenceError::MismatchedShape {
expected: 2,
got: 1,
..
})
));
let empty: [&&str; 0] = [];
assert!(matches!(
h.predict_margin(&[&[&"veteran"], &empty]),
Err(InferenceError::EmptyTeam { team: 1 })
));
}