//! 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> = 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::() - 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::() + 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}" ); }