//! `predict_margin`: the predictive distribution of a scored matchup. use smallvec::smallvec; use trueskill_tt::{ ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team, UnknownKeys, }; fn builder( policy: UnknownKeys, ) -> History { History::builder() .mu(0.0) .sigma(6.0) .beta(1.0) .score_sigma(2.0) .drift(ConstantDrift::new(0.0)) .unknown_keys(policy) .convergence(ConvergenceOptions { max_iter: 5_000, epsilon: 1e-12, alpha: 1.0, }) .build() } fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event { Event { time: 1, teams: smallvec![ Team::with_members([Member::new(a)]), Team::with_members([Member::new(b)]), ], outcome: Outcome::scores([sa, sb]), } } /// A history where "veteran" and "regular" are well observed and "novice" /// appears once. fn fitted( policy: UnknownKeys, ) -> History { let mut h = builder(policy); let mut events: Vec<_> = (0..40) .map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0)) .collect(); events.push(round("veteran", "novice", 10.0, 6.0)); h.add_events(events).unwrap(); let _ = h.converge().unwrap(); h } /// The property #48 exists for: the interval must widen when the model knows /// less. Their hand-fitted noise law quoted the same sigma for a competitor /// with forty rounds and one with none. #[test] fn the_interval_widens_as_the_model_knows_less() { let h = fitted(UnknownKeys::Prior); let well_known = h .predict_margin(&[&[&"veteran"], &[&"regular"]]) .unwrap() .sigma(); let thin = h .predict_margin(&[&[&"veteran"], &[&"novice"]]) .unwrap() .sigma(); let unseen = h .predict_margin(&[&[&"veteran"], &[&"stranger"]]) .unwrap() .sigma(); assert!( well_known < thin && thin < unseen, "margin width should grow as evidence thins: {well_known} < {thin} < {unseen}" ); } /// #48's second requirement: an unseen competitor is a legitimate question, not /// an error, and the answer should come from the prior rather than be faked. #[test] fn an_unseen_competitor_is_answered_from_the_prior() { let h = fitted(UnknownKeys::Prior); let g = h.predict_margin(&[&[&"nobody"], &[&"no_one"]]).unwrap(); // Two unknowns: the gap is centred on zero and carries both priors plus // both performance noises plus the observation noise. assert!(g.mu().abs() < 1e-9, "mu {}", g.mu()); let expected = (2.0 * 36.0 + 2.0 * 1.0 + 4.0f64).sqrt(); assert!( (g.sigma() - expected).abs() < 1e-9, "sigma {} vs expected {expected}", g.sigma() ); } #[test] fn reject_still_rejects() { let h = fitted(UnknownKeys::Reject); assert!(matches!( h.predict_margin(&[&[&"veteran"], &[&"stranger"]]), Err(InferenceError::UnknownKey { .. }) )); } /// The margin is the *difference*, so it must be antisymmetric in the teams. #[test] fn swapping_the_teams_negates_the_margin() { let h = fitted(UnknownKeys::Prior); let forward = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap(); let reverse = h.predict_margin(&[&[&"regular"], &[&"veteran"]]).unwrap(); assert!((forward.mu() + reverse.mu()).abs() < 1e-9); assert!((forward.sigma() - reverse.sigma()).abs() < 1e-12); } /// The predictive interval must be wider than the skill gap alone: it also /// carries per-event performance noise and the observation noise. #[test] fn the_predictive_interval_exceeds_the_skill_uncertainty() { let h = fitted(UnknownKeys::Prior); let skill_gap = h .joint() .unwrap() .posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)]) .unwrap(); let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap(); assert!( (predictive.mu() - skill_gap.mu()).abs() < 1e-12, "means agree" ); // beta^2 twice plus score_sigma^2 = 2 + 4. let expected = (skill_gap.sigma().powi(2) + 6.0).sqrt(); assert!((predictive.sigma() - expected).abs() < 1e-12); assert!(predictive.sigma() > skill_gap.sigma()); } #[test] fn shape_errors_are_reported() { let h = fitted(UnknownKeys::Prior); assert!(matches!( h.predict_margin(&[&[&"veteran"]]), Err(InferenceError::MismatchedShape { expected: 2, got: 1, .. }) )); let empty: [&&str; 0] = []; assert!(matches!( h.predict_margin(&[&[&"veteran"], &empty]), Err(InferenceError::EmptyTeam { team: 1, .. }) )); }