//! Stopping short of convergence is an error, not a flag on a success. //! //! A fit that hits `max_iter` is wrong by a little: every rating is finite, //! the ordering looks sensible, and nothing in the numbers says they were //! still moving. When that was `Ok` with `converged: false`, detecting it was //! opt-in and `let _ = h.converge()` was the natural way to opt out — which is //! how a real defect once hid in this crate's own suite. use smallvec::smallvec; use trueskill_tt::{ ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team, }; type H = History; fn duel(a: &'static str, b: &'static str, t: i64) -> Event { Event { time: t, teams: smallvec![ Team::with_members([Member::new(a)]), Team::with_members([Member::new(b)]), ], outcome: Outcome::scores([3.0, 1.0]), } } fn capped(max_iter: usize) -> H { History::builder() .mu(0.0) .sigma(6.0) .beta(1.0) .score_sigma(2.0) .drift(ConstantDrift::new(0.5)) .convergence(ConvergenceOptions { max_iter, epsilon: 1e-13, alpha: 1.0, }) .build() } fn fill(h: &mut H) { h.add_events((1..=6).map(|t| duel("a", "b", t)).collect::>()) .unwrap(); } #[test] fn hitting_the_cap_is_an_error() { let mut h = capped(1); fill(&mut h); let err = h.converge().unwrap_err(); match err { InferenceError::NotConverged { iterations, final_step, epsilon, } => { assert_eq!(iterations, 1); assert!( final_step.0 > epsilon || final_step.1 > epsilon, "{final_step:?}" ); } other => panic!("expected NotConverged, got {other:?}"), } } /// The message has to name what to do about it, since the fit looks fine. #[test] fn the_error_says_how_to_fix_it() { let mut h = capped(1); fill(&mut h); let text = h.converge().unwrap_err().to_string(); assert!(text.contains("did not converge in 1 iterations"), "{text}"); assert!(text.contains("max_iter"), "{text}"); assert!(text.contains("alpha"), "{text}"); } /// The escape hatch: a deliberately capped fit is still reachable. #[test] fn converge_partial_returns_the_short_fit() { let mut h = capped(1); fill(&mut h); let report = h.converge_partial().unwrap(); assert_eq!(report.iterations, 1); assert!(!report.converged); assert!(h.current_skill(&"a").is_some()); } /// Both agree when the fit does converge, so the strict path costs nothing. #[test] fn the_two_agree_on_a_converged_fit() { let mut strict = capped(20_000); fill(&mut strict); let a = strict.converge().unwrap(); let mut partial = capped(20_000); fill(&mut partial); let b = partial.converge_partial().unwrap(); assert!(a.converged && b.converged); assert_eq!(a.iterations, b.iterations); assert_eq!(a.final_step, b.final_step); } /// The default cap must be high enough that an ordinary history clears it. /// At the old value of 30 this history stopped short and said nothing. #[test] fn the_default_cap_clears_an_ordinary_history() { let mut h: History = History::builder() .key_type::() .mu(0.0) .sigma(6.0) .beta(1.0) .score_sigma(2.0) .drift(ConstantDrift::new(0.05)) .build(); let mut events = Vec::new(); for t in 0..20i64 { for j in 0..8usize { let k = (t as usize) * 8 + j; events.push(Event { time: t, teams: smallvec![ Team::with_members([Member::new(format!("p{}", k % 100))]), Team::with_members([Member::new(format!("p{}", (k + 37) % 100))]), ], outcome: Outcome::scores([3.0, 1.0]), }); } } h.add_events(events).unwrap(); let report = h .converge() .expect("an ordinary history must converge by default"); assert!( report.iterations > 30, "needed {} sweeps", report.iterations ); assert!(report.iterations < trueskill_tt::ITERATIONS); } /// An empty history converges trivially rather than erroring. #[test] fn an_empty_history_converges() { let mut h = capped(1); let report = h.converge().unwrap(); assert!(report.converged); assert_eq!(report.iterations, 0); }