diff --git a/src/history.rs b/src/history.rs index bd86277..6aaeb9e 100644 --- a/src/history.rs +++ b/src/history.rs @@ -947,6 +947,40 @@ impl, O: Observer, K: Eq + Hash + Clone> History f64 { + // sqrt(f64::EPSILON), as a const rather than a runtime sqrt. + const SQRT_EPSILON: f64 = 1.490_116_119_384_765_6e-8; + prior_variance * SQRT_EPSILON + } + fn time_expanded_joint(&self) -> TimeExpanded { let mut latest: HashMap = HashMap::new(); let mut at_slice: HashMap<(Index, usize), usize> = HashMap::new(); @@ -969,8 +1003,9 @@ impl, O: Observer, K: Eq + Hash + Clone> History { let drift = rating.drift_variance_for_elapsed(elapsed); - if drift <= 0.0 { - // No drift: the same latent skill, not two. + if drift <= Self::collapse_threshold(rating.prior.variance()) { + // No drift, or too little to represent: the same + // latent skill, not two. See `collapse_threshold`. prev } else { let row = n; @@ -1278,8 +1313,10 @@ impl, O: Observer, K: Eq + Hash + Clone> History f64 { + let mut h: History = History::builder_with_key() + .mu(0.0) + .sigma(6.0) + .beta(1.0) + .score_sigma(2.0) + .drift(ConstantDrift(0.5)) + .convergence(ConvergenceOptions { + max_iter: 20_000, + epsilon: 1e-13, + alpha: 1.0, + }) + .build(); + let mut events = Vec::new(); + for t in 0..15i64 { + for k in 0..4usize { + let x = format!("p{}", (t as usize * 4 + k) % 8); + let y = format!("p{}", (t as usize * 4 + k + 3) % 8); + events.push(Event { + time: t, + teams: smallvec![ + Team::with_members([Member::new(x).with_drift_scale(scale)]), + Team::with_members([Member::new(y).with_drift_scale(scale)]), + ], + outcome: Outcome::scores([3.0, 1.0]), + }); + } + } + h.add_events(events).unwrap(); + assert!(h.converge().unwrap().converged); + let (a, b) = ("p0".to_string(), "p1".to_string()); + let joint = h + .joint() + .expect("a tiny drift must not make the joint unavailable"); + let g = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).unwrap(); + g.sigma() * g.sigma() + } + + let collapsed = variance(0.0); + + // Below the threshold every scale must reach the collapsed answer exactly, + // and none may error. + for scale in [1e-3, 1e-4, 1e-6, 1e-8, 1e-10, 1e-12] { + let v = variance(scale); + assert_eq!( + v.to_bits(), + collapsed.to_bits(), + "drift_scale {scale:e}: {v} vs collapsed {collapsed}" + ); + } + + // Above it, real drift is still modelled — otherwise this test would pass + // by collapsing everything. + let drifting = variance(1e-2); + assert!( + (drifting - collapsed).abs() / collapsed > 1e-5, + "a drift of 1e-2 must still move the answer: {drifting} vs {collapsed}" + ); +}