//! Per-competitor drift scaling via `Member::with_drift_scale`. //! //! The scale multiplies the *variance* the history's `Drift` contributes for //! that competitor, so `scale` is in the same units as `gamma`: //! `ConstantDrift::new(g)` at `scale = s` behaves as `ConstantDrift::new(g * s)` would. //! `scale = 0.0` pins a competitor still — an anchor, a rating floor, a course //! difficulty — while everyone around them keeps drifting. use smallvec::smallvec; use trueskill_tt::{ ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome, Team, }; type Fit = History; const CONVERGENCE: ConvergenceOptions = ConvergenceOptions { max_iter: 64, epsilon: 1e-9, alpha: 1.0, }; /// Two events separated by a long gap, so drift has room to matter. fn distant_pair(anchor_scale: Option) -> Vec> { let anchor = |s: Option| match s { Some(scale) => Member::new("anchor").with_drift_scale(scale), None => Member::new("anchor"), }; vec![ Event { time: 0, teams: smallvec![ Team::with_members([anchor(anchor_scale)]), Team::with_members([Member::new("player")]), ], outcome: Outcome::winner(0, 2), }, Event { time: 1000, teams: smallvec![ Team::with_members([anchor(anchor_scale)]), Team::with_members([Member::new("player")]), ], outcome: Outcome::winner(1, 2), }, ] } fn fit(events: Vec>, gamma: f64) -> Fit { let mut h = History::builder() .mu(25.0) .sigma(25.0 / 3.0) .beta(25.0 / 6.0) .p_draw(0.0) .drift(ConstantDrift::new(gamma)) .convergence(CONVERGENCE) .build(); h.add_events(events).unwrap(); let _ = h.converge().unwrap(); h } fn curve(h: &Fit, key: &str) -> Vec<(i64, Gaussian)> { let mut c = h.learning_curves().remove(key).expect("key in curves"); c.sort_by_key(|(t, _)| *t); c } /// A competitor at `scale = 0.0` is one latent skill observed twice, so the /// posterior is the same distribution at both times — and strictly tighter /// than the same competitor left to drift. #[test] fn zero_scale_pins_a_competitor_still() { let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0); let drifting = fit(distant_pair(None), 25.0 / 300.0); let pinned_curve = curve(&pinned, "anchor"); assert_eq!(pinned_curve.len(), 2); let (t0, first) = pinned_curve[0]; let (t1, second) = pinned_curve[1]; assert_eq!((t0, t1), (0, 1000)); assert!( (first.sigma() - second.sigma()).abs() < 1e-9, "a pinned competitor's uncertainty must not move between t=0 and t=1000: \ {} vs {}", first.sigma(), second.sigma() ); assert!( (first.mu() - second.mu()).abs() < 1e-9, "a pinned competitor's mean must not move: {} vs {}", first.mu(), second.mu() ); let drifting_curve = curve(&drifting, "anchor"); assert!( drifting_curve[0].1.sigma() > first.sigma() + 1e-6, "drift must leave the anchor less certain than pinning does: {} vs {}", drifting_curve[0].1.sigma(), first.sigma() ); } /// The scale is composable with `gamma`: scaling every competitor by `s` is /// exactly the same fit as scaling the history's drift by `s`. #[test] fn scale_is_equivalent_to_scaling_gamma() { let scaled: Vec> = vec![ Event { time: 0, teams: smallvec![ Team::with_members([Member::new("a").with_drift_scale(0.5)]), Team::with_members([Member::new("b").with_drift_scale(0.5)]), ], outcome: Outcome::winner(0, 2), }, Event { time: 400, teams: smallvec![ Team::with_members([Member::new("b").with_drift_scale(0.5)]), Team::with_members([Member::new("a").with_drift_scale(0.5)]), ], outcome: Outcome::winner(0, 2), }, ]; let plain: Vec> = vec![ Event { time: 0, teams: smallvec![ Team::with_members([Member::new("a")]), Team::with_members([Member::new("b")]), ], outcome: Outcome::winner(0, 2), }, Event { time: 400, teams: smallvec![ Team::with_members([Member::new("b")]), Team::with_members([Member::new("a")]), ], outcome: Outcome::winner(0, 2), }, ]; let by_scale = fit(scaled, 0.3); let by_gamma = fit(plain, 0.15); for key in ["a", "b"] { let lhs = curve(&by_scale, key); let rhs = curve(&by_gamma, key); assert_eq!(lhs.len(), rhs.len()); for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) { assert_eq!(t_l, t_r); assert!( (g_l.mu() - g_r.mu()).abs() < 1e-9 && (g_l.sigma() - g_r.sigma()).abs() < 1e-9, "ConstantDrift::new(0.3) at scale 0.5 must equal ConstantDrift::new(0.15) for {key} at \ t={t_l}: ({}, {}) vs ({}, {})", g_l.mu(), g_l.sigma(), g_r.mu(), g_r.sigma() ); } } } /// `None` means 1.0: an explicit unit scale changes nothing. #[test] fn unset_scale_matches_an_explicit_unit_scale() { let implicit = fit(distant_pair(None), 25.0 / 300.0); let explicit = fit(distant_pair(Some(1.0)), 25.0 / 300.0); for key in ["anchor", "player"] { let lhs = curve(&implicit, key); let rhs = curve(&explicit, key); assert_eq!(lhs.len(), rhs.len()); for ((t_l, g_l), (t_r, g_r)) in lhs.iter().zip(rhs.iter()) { assert_eq!(t_l, t_r); assert_eq!( (g_l.mu(), g_l.sigma()), (g_r.mu(), g_r.sigma()), "an explicit scale of 1.0 must be bit-identical to leaving it unset, \ for {key} at t={t_l}" ); } } } /// The use case from the issue: a static difficulty alongside drifting players, /// in one graph. The anchor must hold still without absorbing drift through its /// neighbours, and everything must stay finite. #[test] fn mixed_static_and_drifting_graph_converges() { let mut events: Vec> = Vec::new(); let players = ["p0", "p1", "p2"]; for (i, p) in players.iter().cycle().take(9).enumerate() { events.push(Event { time: (i as i64) * 100, teams: smallvec![ Team::with_members([Member::new(*p)]), Team::with_members([Member::new("layout").with_drift_scale(0.0)]), ], outcome: Outcome::winner((i % 2) as u32, 2), }); } let mut h = History::builder() .mu(25.0) .sigma(25.0 / 3.0) .beta(25.0 / 6.0) .p_draw(0.0) .drift(ConstantDrift::new(25.0 / 300.0)) .convergence(CONVERGENCE) .build(); h.add_events(events).unwrap(); let report = h.converge().unwrap(); assert!(report.converged, "mixed graph must converge: {report:?}"); let curves = h.learning_curves(); for (key, points) in &curves { for (t, g) in points { assert!( g.mu().is_finite() && g.sigma().is_finite() && g.sigma() > 0.0, "{key} at t={t} is not a usable posterior: mu={}, sigma={}", g.mu(), g.sigma() ); } } let layout = curve(&h, "layout"); assert_eq!(layout.len(), 9); let (_, first) = layout[0]; for (t, g) in &layout { assert!( (g.sigma() - first.sigma()).abs() < 1e-9, "a static layout must not accumulate uncertainty; t={t} has sigma {} vs {}", g.sigma(), first.sigma() ); } let p0 = curve(&h, "p0"); assert!( p0.last().unwrap().1.sigma() > 0.0, "a drifting player should still have a proper posterior" ); } fn reject(scale: f64) -> InferenceError { let mut h = History::builder() .drift(ConstantDrift::new(25.0 / 300.0)) .build(); let events: Vec> = vec![Event { time: 0, teams: smallvec![ Team::with_members([Member::new("a").with_drift_scale(scale)]), Team::with_members([Member::new("b")]), ], outcome: Outcome::winner(0, 2), }]; h.add_events(events) .expect_err("an out-of-range drift_scale must be rejected") } #[test] fn negative_scale_is_rejected() { assert!(matches!( reject(-1.0), InferenceError::InvalidParameter { name: "drift_scale", value, .. } if value == -1.0 )); } #[test] fn non_finite_scale_is_rejected() { for scale in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { assert!( matches!( reject(scale), InferenceError::InvalidParameter { name: "drift_scale", .. } ), "a drift_scale of {scale} must be rejected as an invalid parameter" ); } } /// The scale must reach the filtering pass too, not just `converge()`. /// `filtered_learning_curves` runs its own drift application, so a pinned /// competitor has to stay pinned there as well. #[test] fn zero_scale_pins_a_competitor_in_the_filtered_pass() { let pinned = fit(distant_pair(Some(0.0)), 25.0 / 300.0); let drifting = fit(distant_pair(None), 25.0 / 300.0); let filtered = |h: &Fit| -> Vec<(i64, Gaussian)> { let mut c = h .filtered_learning_curves() .remove("anchor") .expect("anchor in filtered curves"); c.sort_by_key(|(t, _)| *t); c }; let pinned_curve = filtered(&pinned); let drifting_curve = filtered(&drifting); assert_eq!(pinned_curve.len(), 2); assert_eq!(drifting_curve.len(), 2); assert!( pinned_curve[1].1.sigma() < pinned_curve[0].1.sigma(), "a pinned competitor's filtered uncertainty must shrink with a second \ observation, not be re-inflated by drift: {} then {}", pinned_curve[0].1.sigma(), pinned_curve[1].1.sigma() ); assert!( pinned_curve[1].1.sigma() < drifting_curve[1].1.sigma() - 1e-6, "pinning must leave the filtered estimate tighter than drifting does: \ {} vs {}", pinned_curve[1].1.sigma(), drifting_curve[1].1.sigma() ); } /// `drift_scale` is competitor configuration, and configuration supplied for a /// competitor the history already knows is now *applied* rather than dropped. /// /// This test previously asserted the opposite. It was written as a deliberate /// change-detector — "moving the capture would be a visible break, not a silent /// one" — and that is exactly what happened: the capture moved, and the /// assertion inverted rather than being deleted. /// /// Because configuration lives on the competitor and `converge` refits from /// competitor state, a late pin applies to the *whole* history, not just to /// events after it. So a scale set on the second batch must reach the same fit /// as one set from the very first event. #[test] fn drift_scale_applies_when_set_after_first_appearance() { let mut late = History::builder() .mu(25.0) .sigma(25.0 / 3.0) .beta(25.0 / 6.0) .p_draw(0.0) .drift(ConstantDrift::new(25.0 / 300.0)) .convergence(CONVERGENCE) .build(); // First batch creates "anchor" with the default scale. late.add_events(vec![Event { time: 0, teams: smallvec![ Team::with_members([Member::new("anchor")]), Team::with_members([Member::new("player")]), ], outcome: Outcome::winner(0, 2), }]) .unwrap(); // Second batch asks for a pin. No longer too late. late.add_events(vec![Event { time: 1000, teams: smallvec![ Team::with_members([Member::new("anchor").with_drift_scale(0.0)]), Team::with_members([Member::new("player")]), ], outcome: Outcome::winner(1, 2), }]) .unwrap(); let _ = late.converge().unwrap(); let applied = curve(&late, "anchor"); let pinned_from_the_start = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor"); let never_pinned = curve(&fit(distant_pair(None), 25.0 / 300.0), "anchor"); for ((t_l, g_l), (t_r, g_r)) in applied.iter().zip(pinned_from_the_start.iter()) { assert_eq!(t_l, t_r); assert!( (g_l.sigma() - g_r.sigma()).abs() < 1e-9, "a late pin should refit the whole history: t={t_l}, {} vs {}", g_l.sigma(), g_r.sigma() ); } // And it must actually have done something. assert!( applied .iter() .zip(never_pinned.iter()) .any(|((_, a), (_, b))| (a.sigma() - b.sigma()).abs() > 1e-9), "the pin had no effect at all — the silent drop is back" ); } /// Re-declaring the same configuration must be inert. This is the shape a /// caller gets when the configuration is a property of the domain — "layouts /// are static" — so every ingestion path repeats it on every event. /// /// Both histories see exactly the same events; only how many times the scale /// is declared differs. #[test] fn repeating_the_same_configuration_changes_nothing() { let events = |declare_every_time: bool| { let anchor = |first: bool| { if first || declare_every_time { Member::new("anchor").with_drift_scale(0.0) } else { Member::new("anchor") } }; vec![ Event { time: 0, teams: smallvec![ Team::with_members([anchor(true)]), Team::with_members([Member::new("player")]), ], outcome: Outcome::winner(0, 2), }, Event { time: 1000, teams: smallvec![ Team::with_members([anchor(false)]), Team::with_members([Member::new("player")]), ], outcome: Outcome::winner(1, 2), }, ] }; let once = curve(&fit(events(false), 25.0 / 300.0), "anchor"); let every_time = curve(&fit(events(true), 25.0 / 300.0), "anchor"); for ((t_l, a), (t_r, b)) in once.iter().zip(every_time.iter()) { assert_eq!(t_l, t_r); assert!( (a.sigma() - b.sigma()).abs() < 1e-12, "t={t_l}: declaring the same scale repeatedly changed the fit, {} vs {}", a.sigma(), b.sigma() ); } } #[test] fn a_batch_that_contradicts_itself_is_rejected() { let mut h = History::builder().convergence(CONVERGENCE).build(); let err = h .add_events(vec![ Event { time: 0, teams: smallvec![ Team::with_members([Member::new("anchor").with_drift_scale(0.0)]), Team::with_members([Member::new("player")]), ], outcome: Outcome::winner(0, 2), }, Event { time: 1, teams: smallvec![ Team::with_members([Member::new("anchor").with_drift_scale(1.0)]), Team::with_members([Member::new("player")]), ], outcome: Outcome::winner(0, 2), }, ]) .expect_err("two different scales for one competitor in one batch"); assert!( matches!( err, InferenceError::ConflictingCompetitorConfig { field: "drift_scale", .. } ), "got {err:?}" ); }