Three issues from two downstream consumers, all small, all sharing a theme: the crate had the information and would not hand it over. #44 — `UnknownKey { team: 0, member: 0 }` did not say which key. A consumer upgrading 0.1.2 -> 0.4.1 had every one of 5591 predictions return this error, fell back to a neutral 0.5, and lost its entire metadata model for a day. Nothing crashed and nothing logged; it was found by sweeping an unrelated parameter and noticing the output did not move. The 0.4.0 change that made unknown keys an error was right — the error was just too anonymous to act on. It now carries the key's `Debug` rendering, and its `Display` says what to do about it. The precondition is documented on every prediction entry point, which the reporter said would alone have saved the day. #43 — `cdf` was `pub(crate)`, so a consumer asking "is this competitor below the cutoff" approximated it with a `mu + z * sigma` band and had no way to say what confidence any `z` bought. Adds `Gaussian::probability_below` / `probability_above`. The second is separate on purpose: `1 - cdf` collapses to exactly zero past ~8.3 sigma, and a stopping rule is evaluated precisely there. Both route through the survival function added in 0.4.1, so this is visibility rather than new numerics. #50 — `ConvergenceReport` was not `#[must_use]`, so the one signal that a fit stopped short was trivially discarded. It now is, and that immediately found 78 sites doing exactly that — including this crate's own ATP example, which was capped at 10 sweeps when the history needs 30. The example now reads the report and says so. `ITERATIONS = 30` is documented as the floor it is, with the three measurements to hand: 400 events over 100 competitors already stops there at ~7e-3 against a 1e-6 tolerance, the ATP example needs 30 at a much looser one, and a consumer's 2000-node model needs 76 to 161. BREAKING CHANGE: `InferenceError::UnknownKey` gains a `key` field, and the prediction methods now require `K: Debug` in order to fill it. Closes #43, #50. Refs #44 — its third ask, an opt-in `UnknownKeys::Skip` mode, is a live API question and deliberately not answered here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
340 lines
11 KiB
Rust
340 lines
11 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_eq!(
|
|
err,
|
|
InferenceError::UnknownKey {
|
|
team: 1,
|
|
member: 0,
|
|
key: "\"ghost\"".to_owned(),
|
|
}
|
|
);
|
|
|
|
// Every prediction entry point, not just one.
|
|
assert!(
|
|
h.predict_win_probabilities(&[&[&"a"], &[&"ghost"]])
|
|
.is_err()
|
|
);
|
|
assert!(h.predict_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_eq!(
|
|
err,
|
|
InferenceError::UnknownKey {
|
|
team: 1,
|
|
member: 0,
|
|
key: "\"x\"".to_owned(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn degenerate_team_shapes_are_errors_rather_than_panics() {
|
|
let h = history_with(&["a", "b"], 0.0);
|
|
|
|
assert_eq!(
|
|
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
|
|
InferenceError::NotEnoughTeams { got: 1 }
|
|
);
|
|
assert_eq!(
|
|
h.predict_outcome(&[]).unwrap_err(),
|
|
InferenceError::NotEnoughTeams { got: 0 }
|
|
);
|
|
assert_eq!(
|
|
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_eq!(
|
|
err,
|
|
InferenceError::TooManyTeams {
|
|
got: 8,
|
|
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_eq!(
|
|
h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
|
|
.unwrap_err(),
|
|
InferenceError::UnknownKey {
|
|
team: 1,
|
|
member: 0,
|
|
key: "\"ghost\"".to_owned(),
|
|
}
|
|
);
|
|
}
|
|
|
|
/// 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}"
|
|
);
|
|
}
|