diff --git a/src/acquisition.rs b/src/acquisition.rs index 23d1eea..c21bab7 100644 --- a/src/acquisition.rs +++ b/src/acquisition.rs @@ -124,6 +124,10 @@ fn u_minus_ln1p(u: f64) -> f64 { /// - `TooManyTeams` if the outcome space is too large to enumerate; see /// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS). /// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`. +/// - `GridTooCoarse` when the performance sigmas are too far apart to +/// integrate on one grid. This comes from `outcome_distribution`, which runs +/// before any inference — so it is not covered by "anything `Game::ranked` +/// returns" below. /// - Anything [`Game::ranked`](crate::Game::ranked) returns for a hypothetical /// outcome. pub fn expected_information_gain>( diff --git a/src/history.rs b/src/history.rs index e697d25..ecc03f6 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1071,7 +1071,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History skill, None => match self.unknown_keys { crate::UnknownKeys::Prior => Gaussian::from_ms(self.mu, self.sigma), @@ -1083,12 +1083,73 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History Result where K: std::fmt::Debug, @@ -1668,6 +1735,12 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result where K: std::fmt::Debug, @@ -1722,6 +1795,12 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result, InferenceError> where K: std::fmt::Debug, @@ -1770,6 +1849,12 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result where K: std::fmt::Debug, @@ -1813,6 +1898,12 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result where K: std::fmt::Debug, @@ -1888,6 +1979,10 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result { diff --git a/tests/prediction_guards.rs b/tests/prediction_guards.rs new file mode 100644 index 0000000..7eaff7e --- /dev/null +++ b/tests/prediction_guards.rs @@ -0,0 +1,152 @@ +//! No prediction path may answer from a fit it cannot answer from. +//! +//! `converge` grew a `NonFiniteResult` guard; nothing stopped a caller from +//! ignoring that error and predicting anyway. The three failures that produced +//! were each differently wrong: `Ok(NaN)`, a panic out of a `Result`-returning +//! method, and `Ok([0.0, 0.0])` — finite, plausible, summing to zero against a +//! doc that promises one. +//! +//! Every test here has a healthy control, so none can pass by everything +//! returning `Err`. + +use trueskill_tt::{ + ConstantDrift, Event, Gaussian, History, InferenceError, Member, NullObserver, Outcome, Team, +}; + +type H = History; + +fn build(beta: f64, prior: Option, outcome: Outcome) -> H { + let mut h: H = History::builder() + .beta(beta) + .drift(ConstantDrift::new(0.0)) + .build(); + + let member = |k: &'static str| match prior { + Some(p) => Member::new(k).with_prior(p), + None => Member::new(k), + }; + + let _ = h.add_events(vec![Event { + time: 1, + teams: [ + Team::with_members([member("a")]), + Team::with_members([member("b")]), + ] + .into_iter() + .collect(), + outcome, + }]); + h +} + +/// Point-mass priors with `beta(0.0)` on a *ranked* event: `converge` reports +/// `NonFiniteResult` and the stored posteriors are `pi: NaN, tau: NaN`. +fn nan_poisoned() -> H { + let mut h = build( + 0.0, + Some(Gaussian::from_ms(0.0, 0.0)), + Outcome::winner(0, 2), + ); + let err = h.converge().expect_err("this fixture must not converge"); + assert!( + matches!(err, InferenceError::NonFiniteResult { .. }), + "{err:?}" + ); + h +} + +/// The same degenerate parameters on a *scored* event, where inference +/// converges cleanly and leaves legitimate point-mass posteriors behind. The +/// fit is fine; it is prediction that has nothing to work with. +fn degenerate_but_converged() -> H { + let mut h = build( + 0.0, + Some(Gaussian::from_ms(0.0, 0.0)), + Outcome::scores([1.0, 0.0]), + ); + h.converge().expect("this fixture converges"); + h +} + +fn healthy() -> H { + let mut h = build(1.0, None, Outcome::winner(0, 2)); + h.converge().expect("control converges"); + h +} + +macro_rules! all_predictions { + ($h:ident, $f:expr) => {{ + let teams: &[&[&&'static str]] = &[&[&"a"], &[&"b"]]; + let f = $f; + f("predict_quality", $h.predict_quality(teams).map(|_| ())); + f( + "predict_win_probabilities", + $h.predict_win_probabilities(teams).map(|_| ()), + ); + f("predict_outcome", $h.predict_outcome(teams).map(|_| ())); + f( + "predict_ranking", + $h.predict_ranking(teams, &[0, 1]).map(|_| ()), + ); + f( + "expected_information_gain", + $h.expected_information_gain(teams).map(|_| ()), + ); + }}; +} + +#[test] +fn a_nan_poisoned_fit_is_refused_by_every_prediction_path() { + let h = nan_poisoned(); + + all_predictions!(h, |name: &str, r: Result<(), InferenceError>| { + match r { + Err(InferenceError::NonFiniteResult { .. }) => {} + other => panic!("{name} answered from a NaN fit: {other:?}"), + } + }); +} + +#[test] +fn degenerate_performances_are_refused_rather_than_answered_wrongly() { + let h = degenerate_but_converged(); + + // The fit itself is sound — the posteriors are point masses, not NaN. + let skill = h.current_skill("a").expect("a played"); + assert_eq!(skill.sigma(), 0.0); + assert!(skill.mu().is_finite()); + + // `predict_quality` previously PANICKED here, out of a method that returns + // `Result`: the contrast covariance is exactly singular when beta is zero + // and every skill is a point mass. + all_predictions!(h, |name: &str, r: Result<(), InferenceError>| { + match r { + Err(InferenceError::InvalidParameter { .. }) => {} + other => panic!("{name} predicted from a degenerate fit: {other:?}"), + } + }); +} + +#[test] +fn the_control_history_answers_every_prediction() { + let h = healthy(); + + all_predictions!(h, |name: &str, r: Result<(), InferenceError>| { + assert!(r.is_ok(), "{name} failed on a healthy history: {r:?}"); + }); +} + +#[test] +fn win_probabilities_sum_to_one_on_the_control() { + // The promise the `Ok([0.0, 0.0])` case broke. Asserted on the control so + // the guard above cannot be "fixed" by making every path error. + let h = healthy(); + let p = h + .predict_win_probabilities(&[&[&"a"], &[&"b"]]) + .expect("control predicts"); + let total: f64 = p.iter().sum(); + assert!( + (total - 1.0).abs() < 1e-6, + "win probabilities sum to {total}" + ); +}