//! Every public entry point that takes a magnitude, in one place. //! //! This defect class was closed three times in one session and reopened twice, //! because each fix validated the layer it had just touched and inferred the //! rest: `HistoryBuilder` first, then `Game`'s own entry points, then the //! constructors beneath both. A per-site fix cannot notice the site nobody //! thought of. //! //! So this enumerates them. `sigma`, `beta` and `gamma` all enter inference //! only as squares, which means a negative value does not fail — it behaves as //! its absolute value, bit for bit, and the sign vanishes with no diagnostic. //! Non-finite values poison every posterior derived from them. //! //! Adding a public constructor that takes one of these and not adding it here //! is the failure this file exists to make harder. use std::panic::{AssertUnwindSafe, catch_unwind}; use trueskill_tt::{ConstantDrift, Gaussian, History, Member, Outcome, Rating}; /// Did the entry point refuse the value, by panic or by `Err`? fn refuses(f: impl FnOnce() -> bool) -> bool { catch_unwind(AssertUnwindSafe(f)).unwrap_or(true) } /// One entry point, as a name and a closure that applies a value to it. type Case = (&'static str, Box bool>); /// Entry points that must reject a negative magnitude. /// /// Each closure returns `true` if it refused by returning an error; a panic is /// also a refusal and is caught. #[test] fn every_magnitude_parameter_rejects_a_negative_value() { let cases: Vec = vec![ ( "Gaussian::from_ms(sigma)", Box::new(|v| { let _ = Gaussian::from_ms(25.0, v); false }), ), ( "Rating::new(beta)", Box::new(|v| { let _ = Rating::::new( Gaussian::default(), v, ConstantDrift::new(0.0), ); false }), ), ( "ConstantDrift::new(gamma)", Box::new(|v| { let _ = ConstantDrift::new(v); false }), ), ( "HistoryBuilder::sigma", Box::new(|v| { let _ = History::builder().sigma(v); false }), ), ( "HistoryBuilder::beta", Box::new(|v| { let _ = History::builder().beta(v); false }), ), ( "HistoryBuilder::score_sigma", Box::new(|v| { let _ = History::builder().score_sigma(v); false }), ), ( "HistoryBuilder::p_draw", Box::new(|v| { let _ = History::builder().p_draw(v); false }), ), ( "Member::with_drift_scale (at ingestion)", Box::new(|v| { let mut h = History::builder().build(); h.add_events(vec![trueskill_tt::Event { time: 1i64, teams: smallvec::smallvec![ trueskill_tt::Team::with_members([Member::new("a").with_drift_scale(v)]), trueskill_tt::Team::with_members([Member::new("b")]), ], outcome: Outcome::winner(0, 2), }]) .is_err() }), ), ( "Outcome::scores_with_noise (at ingestion)", Box::new(|v| { let mut h = History::builder().build(); h.add_events(vec![trueskill_tt::Event { time: 1i64, teams: smallvec::smallvec![ trueskill_tt::Team::with_members([Member::new("a")]), trueskill_tt::Team::with_members([Member::new("b")]), ], outcome: Outcome::scores_with_noise([3.0, 1.0], v), }]) .is_err() }), ), ]; let mut accepted = Vec::new(); for (name, f) in &cases { if !refuses(|| f(-1.0)) { accepted.push(*name); } } assert!( accepted.is_empty(), "these accepted a negative magnitude, which is squared away silently \ rather than honoured or refused:\n {}", accepted.join("\n ") ); } /// Same set, for NaN and infinity. /// /// `Gaussian::from_ms` is deliberately absent: a broken fit produces a NaN /// sigma legitimately and `converge` reports it as `NonFiniteResult`. Rejecting /// it in the constructor turned that reporting path into a panic inside /// inference — see the comment on `from_ms`. #[test] fn every_magnitude_parameter_rejects_a_non_finite_value() { let cases: Vec = vec![ ( "Rating::new(beta)", Box::new(|v| { let _ = Rating::::new( Gaussian::default(), v, ConstantDrift::new(0.0), ); false }), ), ( "ConstantDrift::new(gamma)", Box::new(|v| { let _ = ConstantDrift::new(v); false }), ), ( "HistoryBuilder::sigma", Box::new(|v| { let _ = History::builder().sigma(v); false }), ), ( "HistoryBuilder::beta", Box::new(|v| { let _ = History::builder().beta(v); false }), ), ( "HistoryBuilder::mu", Box::new(|v| { let _ = History::builder().mu(v); false }), ), ( "HistoryBuilder::score_sigma", Box::new(|v| { let _ = History::builder().score_sigma(v); false }), ), ( "HistoryBuilder::p_draw", Box::new(|v| { let _ = History::builder().p_draw(v); false }), ), ]; let mut accepted = Vec::new(); for (name, f) in &cases { for bad in [f64::NAN, f64::INFINITY] { if !refuses(|| f(bad)) { accepted.push(format!("{name} accepted {bad}")); } } } assert!( accepted.is_empty(), "these accepted a non-finite magnitude:\n {}", accepted.join("\n ") ); } /// The suite must not pass by refusing everything. #[test] fn ordinary_values_are_still_accepted() { let _ = Gaussian::from_ms(25.0, 8.33); let _ = Rating::::new(Gaussian::default(), 4.17, ConstantDrift::new(0.05)); let _ = ConstantDrift::new(0.0833); let _ = History::builder() .mu(25.0) .sigma(8.33) .beta(4.17) .score_sigma(1.0) .p_draw(0.1); // Zero beta and zero gamma are legitimate, not degenerate. let _ = ConstantDrift::new(0.0); let _ = Rating::::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0)); }