//! 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}" ); }