Files
trueskill-tt/tests/prediction.rs
T
logaritmiskandClaude Opus 5 13a395fdc9 refactor!: scores_with_noise, and History::quality
Two names that described the wrong thing.

`scores_with_sigma(scores, sigma)` reads as "these scores have prior
sigma 2.0". The quantity is observation noise on the score *margin*, in
the units of the scores, and it is spelled `score_sigma` at every config
site — `HistoryBuilder::score_sigma`, `GameOptions::score_sigma`,
`EventKind::Scored { score_sigma }` — so this was the one place the
crate used a third meaning of "sigma" for it. Its own doc had to
disambiguate itself: "`sigma` overrides `HistoryBuilder::score_sigma`".
`scores_with_noise(scores, score_sigma)` on both `Outcome` and
`EventBuilder`.

`predict_quality` predicts nothing. Its own doc says it answers "is this
matchup *fair*", not "what will happen", and the `predict_*` family is
otherwise exactly the methods returning a probability or a distribution
over outcomes. `History::quality` also makes the free/method pair
consistent: free `quality` pairs with `History::quality` the way free
`expected_information_gain` already pairs with
`History::expected_information_gain`. The rule that was already being
followed and never stated — a free function scores a hypothetical from
explicit parameters, the same-named method asks it against the fit — is
now written on the method.

Closes #75. Refs #78 (part 4).

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

416 lines
14 KiB
Rust

//! Prediction API: N-team outcomes, draw mass, and the error paths that used
//! to be panics or silent wrong answers.
use trueskill_tt::{History, InferenceError, MAX_PREDICTED_TEAMS};
fn history_with(names: &[&'static str], p_draw: f64) -> History {
let mut h = History::builder().p_draw(p_draw).build();
// Give every competitor a recorded skill by playing a small round robin.
for pair in names.windows(2) {
h.record_winner(&pair[0], &pair[1], 1).unwrap();
}
let _ = h.converge().unwrap();
h
}
#[test]
fn unknown_keys_are_reported_not_silently_dropped() {
let h = history_with(&["a", "b"], 0.0);
let err = h
.predict_outcome(&[&[&"a"], &[&"ghost"]])
.expect_err("an unknown key must not yield a confident prediction");
assert!(
matches!(
&err,
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
if key == "\"ghost\""
),
"{err:?}"
);
// Every prediction entry point, not just one.
assert!(
h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]])
.is_err()
);
assert!(h.quality(&[&[&"a"], &[&"ghost"]]).is_err());
assert!(h.predict_ranking(&[&[&"a"], &[&"ghost"]], &[0, 1]).is_err());
}
#[test]
fn an_entirely_unknown_team_is_an_error() {
let h = history_with(&["a", "b"], 0.0);
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
assert!(
matches!(
&err,
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
if key == "\"x\""
),
"{err:?}"
);
}
#[test]
fn degenerate_team_shapes_are_errors_rather_than_panics() {
let h = history_with(&["a", "b"], 0.0);
assert!(matches!(
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
InferenceError::NotEnoughTeams { got: 1, .. }
),);
// An empty team list cannot infer the key type — nothing in `&[]` names it.
// The annotation is the cost of `predict_*` being generic over the borrowed
// key, and it only bites on the degenerate call.
let none: &[&[&str]] = &[];
assert!(matches!(
h.predict_outcome(none).unwrap_err(),
InferenceError::NotEnoughTeams { got: 0, .. }
),);
assert!(matches!(
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
InferenceError::EmptyTeam { team: 1, .. }
));
}
#[test]
fn more_than_two_teams_no_longer_panics() {
let h = history_with(&["a", "b", "c"], 0.0);
let p = h
.predict_outcome(&[&[&"a"], &[&"b"], &[&"c"]])
.expect("three teams must be supported");
assert!((p.total() - 1.0).abs() < 1e-6, "total = {}", p.total());
// Three teams, no draws possible: exactly the six strict orderings.
assert_eq!(p.outcomes().len(), 6);
}
#[test]
fn the_outcome_space_is_capped_rather_than_hanging() {
let names: Vec<&'static str> = vec!["a", "b", "c", "d", "e", "f", "g", "h"];
let h = history_with(&names, 0.0);
let teams: Vec<&[&&'static str]> = Vec::new();
let _ = teams;
let too_many: Vec<Vec<&&str>> = names.iter().map(|n| vec![n]).collect();
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
let err = h.predict_outcome(&refs).unwrap_err();
assert!(matches!(
err,
InferenceError::TooManyTeams { got: 8, max, .. } if max == MAX_PREDICTED_TEAMS
));
// The cheap paths stay available at any size.
let wins = h.predict_win_probabilities(&refs).unwrap();
assert_eq!(wins.len(), 8);
assert!(
(wins.iter().sum::<f64>() - 1.0).abs() < 1e-6,
"win probabilities must still sum to one: {wins:?}"
);
}
/// The defect that made every draw-enabled prediction wrong: `[p, 1 - p]`
/// allocated no mass to a draw even with `p_draw > 0`.
#[test]
fn a_draw_carries_probability_mass_when_p_draw_is_positive() {
let h = history_with(&["a", "b"], 0.25);
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
let draw = p.probability_of(&[0, 0]);
assert!(draw > 0.0, "a draw-enabled model must give draws mass");
assert!((p.total() - 1.0).abs() < 1e-6, "total = {}", p.total());
let wins = p.win_probabilities();
assert!(
(wins.iter().sum::<f64>() + draw - 1.0).abs() < 1e-6,
"wins {wins:?} plus draw {draw} must be the whole space"
);
assert!(
(p.shared_first_place() - draw).abs() < 1e-12,
"a two-team draw is a shared first place"
);
}
#[test]
fn a_zero_draw_probability_admits_no_ties() {
let h = history_with(&["a", "b"], 0.0);
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
assert_eq!(p.probability_of(&[0, 0]), 0.0);
assert!(p.shared_first_place() < 1e-12);
}
/// The two routes to a win probability run through entirely different
/// algorithms — adaptive quadrature versus the enumerated chain recursion —
/// so agreement between them is a real cross-check, not a tautology.
#[test]
fn the_cheap_and_exhaustive_paths_agree() {
for p_draw in [0.0, 0.1] {
let h = history_with(&["a", "b", "c"], p_draw);
let teams: &[&[&&str]] = &[&[&"a"], &[&"b"], &[&"c"]];
let cheap = h.predict_win_probabilities(teams).unwrap();
let exhaustive = h.predict_outcome(teams).unwrap().win_probabilities();
for (i, (a, b)) in cheap.iter().zip(&exhaustive).enumerate() {
assert!(
(a - b).abs() < 1e-6,
"p_draw={p_draw} team {i}: quadrature {a} vs enumeration {b}"
);
}
}
}
#[test]
fn predict_ranking_agrees_with_the_distribution() {
let h = history_with(&["a", "b", "c"], 0.1);
let teams: &[&[&&str]] = &[&[&"a"], &[&"b"], &[&"c"]];
let dist = h.predict_outcome(teams).unwrap();
for (ranks, expected) in dist.outcomes() {
let direct = h.predict_ranking(teams, ranks).unwrap();
assert!(
(direct - expected).abs() < 1e-9,
"ranks {ranks:?}: {direct} vs {expected}"
);
}
}
#[test]
fn predict_ranking_checks_its_shape() {
let h = history_with(&["a", "b"], 0.0);
let err = h
.predict_ranking(&[&[&"a"], &[&"b"]], &[0, 1, 2])
.unwrap_err();
assert!(matches!(
err,
InferenceError::MismatchedShape {
expected: 2,
got: 3,
..
}
));
}
#[test]
fn the_stronger_competitor_is_favoured() {
let mut h = History::builder().build();
for t in 1..=10 {
h.record_winner(&"strong", &"weak", t).unwrap();
}
let _ = h.converge().unwrap();
let p = h.predict_outcome(&[&[&"strong"], &[&"weak"]]).unwrap();
let (best, _) = p.most_likely().expect("a most likely outcome");
assert_eq!(best, &[0, 1], "the winner should be favoured");
let wins = p.win_probabilities();
assert!(wins[0] > wins[1], "{wins:?}");
}
/// Unequal team sizes change the draw margin, because inference derives it
/// from the teams' betas. Prediction has to follow, or it describes a
/// different model than the one that will be fitted.
#[test]
fn team_size_affects_the_prediction() {
let mut h = History::builder().p_draw(0.2).build();
h.event(1)
.team(["a", "b"])
.team(["c"])
.winner(0)
.commit()
.unwrap();
let _ = h.converge().unwrap();
let p = h.predict_outcome(&[&[&"a", &"b"], &[&"c"]]).unwrap();
assert!((p.total() - 1.0).abs() < 1e-6, "total = {}", p.total());
assert!(p.probability_of(&[0, 0]) > 0.0);
}
// ---------------------------------------------------------------------------
// Expected information gain
// ---------------------------------------------------------------------------
/// The whole point of #39: "which comparison should I run next?" is a
/// different question from "who will win?" or "is this fair?".
#[test]
fn information_gain_prefers_the_uncertain_pairing() {
let mut h = History::builder().build();
// "known" and "rival" have played a lot; "newcomer" has played once.
for t in 1..=15 {
h.record_winner(&"known", &"rival", t).unwrap();
h.record_winner(&"rival", &"known", t + 100).unwrap();
}
h.record_winner(&"known", &"newcomer", 500).unwrap();
let _ = h.converge().unwrap();
let settled = h
.expected_information_gain(&[&[&"known"], &[&"rival"]])
.unwrap();
let unknown = h
.expected_information_gain(&[&[&"known"], &[&"newcomer"]])
.unwrap();
assert!(
unknown > settled,
"pairing against the newcomer should teach more: {unknown} vs {settled}"
);
}
/// The analytic ceiling, through the `History` entry point rather than the
/// standalone one.
#[test]
fn information_gain_respects_the_entropy_ceiling() {
let h = history_with(&["a", "b", "c"], 0.0);
let two = h.expected_information_gain(&[&[&"a"], &[&"b"]]).unwrap();
assert!(
(0.0..=std::f64::consts::LN_2).contains(&two),
"two-team EIG {two} outside [0, ln 2]"
);
let three = h
.expected_information_gain(&[&[&"a"], &[&"b"], &[&"c"]])
.unwrap();
assert!(
(0.0..=6.0f64.ln()).contains(&three),
"three-team EIG {three} outside [0, ln 6]"
);
}
#[test]
fn information_gain_reports_unknown_keys() {
let h = history_with(&["a", "b"], 0.0);
assert!(matches!(
&h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
.unwrap_err(),
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
if key == "\"ghost\""
));
}
/// A draw-enabled history has three outcomes to weigh rather than two, so the
/// draw branch must actually be reachable through this path.
#[test]
fn information_gain_accounts_for_draws() {
let with_draws = history_with(&["a", "b"], 0.25);
let g = with_draws
.expected_information_gain(&[&[&"a"], &[&"b"]])
.unwrap();
assert!(g > 0.0 && g <= 3.0f64.ln(), "{g}");
// The draw outcome carries mass, so it is genuinely being weighed.
let dist = with_draws.predict_outcome(&[&[&"a"], &[&"b"]]).unwrap();
assert!(dist.probability_of(&[0, 0]) > 0.0);
}
/// The defect that cost a consumer a day: `UnknownKey { team: 0, member: 0 }`
/// says nothing about *which* key is unknown, so the natural handling — log it,
/// fall back to a neutral value — converts a total miss into a plausible
/// constant. The key has to be in the error, and in its `Display`.
#[test]
fn unknown_key_names_the_key_it_could_not_find() {
let h = history_with(&["a", "b"], 0.0);
let err = h.predict_outcome(&[&[&"a"], &[&"never_seen"]]).unwrap_err();
match &err {
InferenceError::UnknownKey { key, .. } => {
assert!(
key.contains("never_seen"),
"the error should name the key, got {key}"
);
}
other => panic!("expected UnknownKey, got {other:?}"),
}
let rendered = err.to_string();
assert!(
rendered.contains("never_seen"),
"Display should name the key: {rendered}"
);
assert!(
rendered.contains("pre-filter"),
"Display should say what to do about it: {rendered}"
);
}
// ---------------------------------------------------------------------------
// UnknownKeys policy
// ---------------------------------------------------------------------------
fn history_with_policy(names: &[&'static str], policy: trueskill_tt::UnknownKeys) -> History {
let mut h = History::builder().unknown_keys(policy).build();
for pair in names.windows(2) {
h.record_winner(&pair[0], &pair[1], 1).unwrap();
}
let _ = h.converge().unwrap();
h
}
#[test]
fn reject_is_the_default() {
let h = history_with(&["a", "b"], 0.0);
assert!(matches!(
h.predict_outcome(&[&[&"a"], &[&"ghost"]]),
Err(InferenceError::UnknownKey { .. })
));
}
#[test]
fn prior_answers_instead_of_erroring() {
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
let p = h
.predict_outcome(&[&[&"a"], &[&"ghost"]])
.expect("Prior should answer rather than reject");
assert!((p.total() - 1.0).abs() < 1e-6);
}
/// Two competitors the model has never seen are genuinely a coin flip. The
/// point is that this is now *derived* rather than a constant a caller
/// substitutes after swallowing an error.
#[test]
fn two_unknown_competitors_are_an_honest_coin_flip() {
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
let wins = h
.predict_win_probabilities(&[&[&"nobody"], &[&"no_one"]])
.unwrap();
assert!((wins[0] - 0.5).abs() < 1e-9, "{wins:?}");
assert!((wins[1] - 0.5).abs() < 1e-9, "{wins:?}");
}
/// The property that rules out a `Skip` mode: an unknown member must make a
/// team *less* certain, never more. Skipping would drop the member's variance
/// from the sum and narrow the team, which is backwards.
#[test]
fn an_unknown_member_widens_its_team_rather_than_narrowing_it() {
let h = history_with_policy(&["a", "b", "c"], trueskill_tt::UnknownKeys::Prior);
// "a" alone against "b" — then "a" plus an unknown partner against "b".
let solo = h.predict_win_probabilities(&[&[&"a"], &[&"b"]]).unwrap();
let with_unknown = h
.predict_win_probabilities(&[&[&"a", &"stranger"], &[&"b"]])
.unwrap();
// Adding an unknown partner pulls the outcome toward even, because the
// team's performance spread grew.
assert!(
(with_unknown[0] - 0.5).abs() < (solo[0] - 0.5).abs(),
"an unknown partner should make the result less certain: solo {solo:?}, \
with unknown {with_unknown:?}"
);
}
#[test]
fn prior_reaches_every_prediction_entry_point() {
let h = history_with_policy(&["a", "b"], trueskill_tt::UnknownKeys::Prior);
let teams: &[&[&&str]] = &[&[&"a"], &[&"ghost"]];
assert!(h.quality(teams).is_ok());
assert!(h.predict_win_probabilities(teams).is_ok());
assert!(h.predict_outcome(teams).is_ok());
assert!(h.predict_ranking(teams, &[0, 1]).is_ok());
assert!(h.expected_information_gain(teams).is_ok());
}