//! Regression: a single time slice with many distinct competitors must converge to finite //! skills. Before the `pi <= 0` guard in `Gaussian::mu()/sigma()`, EP message cancellation //! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the //! moment-space `Sub` in the game chain propagated into every skill once the slice grew past //! ~75 competitors (e.g. a real ranking dataset with hundreds of players). use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS, NullObserver}; /// Tiny deterministic LCG — avoids a dev-dependency on `rand`. struct Lcg(u64); impl Lcg { fn next(&mut self) -> u64 { self.0 = self .0 .wrapping_mul(6364136223846793005) .wrapping_add(1442695040888963407); self.0 } fn below(&mut self, n: usize) -> usize { (self.next() >> 33) as usize % n } fn coin(&mut self) -> bool { self.next() & 1 == 0 } } fn nan_after_fit(players: usize) -> usize { let mut h: History = History::builder_with_key() .beta(1.0) .sigma(6.0) .drift(ConstantDrift(0.1)) .convergence(ConvergenceOptions { max_iter: ITERATIONS, epsilon: EPSILON, ..Default::default() }) .build(); let ids: Vec = (0..players).map(|i| format!("p{i:04}")).collect(); let mut rng = Lcg(1); for _ in 0..(players * 4) { let a = rng.below(players); let mut b = rng.below(players - 1); if b >= a { b += 1; } let (w, l) = if rng.coin() { (a, b) } else { (b, a) }; h.record_winner(&ids[w], &ids[l], 0).unwrap(); } h.converge().unwrap(); ids.iter() .filter(|id| { h.current_skill(id.as_str()) .map(|g| !g.mu().is_finite() || !g.sigma().is_finite()) .unwrap_or(true) }) .count() } #[test] fn many_competitors_converge_to_finite_skills() { // The NaN regression onset was between 70 and 80 competitors; 250 is comfortably past it // and in the range of a real ranking dataset. for players in [12usize, 75, 150, 250] { assert_eq!( nan_after_fit(players), 0, "{players}-competitor history produced NaN skills" ); } }