diff --git a/README.md b/README.md index 1bf7e34..f43d224 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,36 @@ let h = History::builder() .build(); ``` +### Per-competitor drift + +A `History` has one drift model, but individual competitors can scale it. +`Member::with_drift_scale(s)` multiplies the drift *variance* that competitor +accumulates, so `s` is in the same units as `gamma`: `ConstantDrift(g)` at +scale `s` behaves exactly as `ConstantDrift(g * s)` would, for that competitor +alone. + +`0.0` pins a competitor still. That is what makes a **fixed reference point** +expressible in the same graph as moving competitors — a bot at a known +strength, a rating floor, a course difficulty: + +```rust +let events = vec![Event { + time: 0, + teams: smallvec![ + Team::with_members([Member::new("player")]), + // A course does not improve. Pin it, and the round's evidence + // lands on the player instead of being split between the two. + Team::with_members([Member::new("layout_7").with_drift_scale(0.0)]), + ], + outcome: Outcome::winner(0, 2), +}]; +``` + +Like `with_prior`, the scale is **competitor configuration captured at first +appearance** — setting it on a key the history already knows has no effect. It +must be finite and non-negative; ingestion otherwise fails with +`InferenceError::InvalidParameter`. + ## Scored outcomes Use `Outcome::scores([...])` when you have continuous per-team scores rather diff --git a/src/competitor.rs b/src/competitor.rs index a98de47..3b7a4a3 100644 --- a/src/competitor.rs +++ b/src/competitor.rs @@ -30,7 +30,7 @@ impl> Competitor { match self.message { Some(message) => { let elapsed_variance = match &self.last_time { - Some(last) => self.rating.drift.variance_delta(last, now), + Some(last) => self.rating.drift_variance_delta(last, now), None => 0.0, }; @@ -46,7 +46,7 @@ impl> Competitor { /// and should not be recomputed from `last_time` (which may have shifted). pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian { match self.message { - Some(message) => message.forget(self.rating.drift.variance_for_elapsed(elapsed)), + Some(message) => message.forget(self.rating.drift_variance_for_elapsed(elapsed)), None => self.rating.prior, } } diff --git a/src/event.rs b/src/event.rs index 4e69b7d..1faad02 100644 --- a/src/event.rs +++ b/src/event.rs @@ -45,13 +45,20 @@ impl Default for Team { /// One member of a team, identified by user key `K`. /// -/// `weight` defaults to 1.0; a per-event `prior` can override the competitor's -/// current skill estimate for this event only. +/// `weight` applies per event and defaults to 1.0. +/// +/// `prior` and `drift_scale` are **competitor configuration**, not per-event +/// values: both are captured when the competitor is first created and ignored +/// on every later appearance. Setting either on a key the history already knows +/// has no effect. #[derive(Clone, Debug)] pub struct Member { pub key: K, pub weight: f64, pub prior: Option, + /// Multiplier on the drift *variance* this competitor accumulates. + /// `None` means 1.0. + pub drift_scale: Option, } impl Member { @@ -60,6 +67,7 @@ impl Member { key, weight: 1.0, prior: None, + drift_scale: None, } } @@ -68,10 +76,31 @@ impl Member { self } + /// Set this competitor's starting skill estimate. + /// + /// Captured at the competitor's first appearance; see the type docs. pub fn with_prior(mut self, prior: Gaussian) -> Self { self.prior = Some(prior); self } + + /// Scale how fast this competitor drifts, relative to the history's drift. + /// + /// The scale multiplies the drift *variance*, so it is in the same units as + /// `gamma`: `ConstantDrift(g)` at `scale = s` behaves exactly as + /// `ConstantDrift(g * s)` would for this competitor alone. + /// + /// `0.0` pins the competitor still — useful for a reference point that + /// shares a scale with moving competitors but should not itself move: a bot + /// at a known strength, a rating floor, a course difficulty. + /// + /// Captured at the competitor's first appearance; see the type docs. + /// Must be finite and non-negative, or ingestion fails with + /// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter). + pub fn with_drift_scale(mut self, scale: f64) -> Self { + self.drift_scale = Some(scale); + self + } } /// Convenience: a member is a user key with default weight 1.0 and no prior. @@ -92,15 +121,18 @@ mod tests { assert_eq!(m.key, "alice"); assert_eq!(m.weight, 1.0); assert!(m.prior.is_none()); + assert!(m.drift_scale.is_none()); } #[test] fn member_builder_methods_chain() { let m = Member::new("alice") .with_weight(0.5) - .with_prior(Gaussian::from_ms(20.0, 5.0)); + .with_prior(Gaussian::from_ms(20.0, 5.0)) + .with_drift_scale(0.0); assert_eq!(m.weight, 0.5); assert!(m.prior.is_some()); + assert_eq!(m.drift_scale, Some(0.0)); } #[test] diff --git a/src/history.rs b/src/history.rs index 7a2d82d..965cf33 100644 --- a/src/history.rs +++ b/src/history.rs @@ -968,8 +968,37 @@ impl, O: Observer, K: Eq + Hash + Clone> History = ConstantDrift> { pub(crate) prior: Gaussian, pub(crate) beta: f64, pub(crate) drift: D, + /// Multiplier on the drift *variance* this competitor accumulates; 1.0 is + /// the neutral default. Set per competitor via `Member::with_drift_scale`. + pub(crate) drift_scale: f64, pub(crate) _time: PhantomData, } @@ -25,10 +28,21 @@ impl> Rating { prior, beta, drift, + drift_scale: 1.0, _time: PhantomData, } } + /// Scale how fast this competitor drifts, relative to `drift`. + /// + /// Multiplies the drift *variance*, so the scale is in the same units as + /// `gamma`. `0.0` pins the competitor still. + #[must_use] + pub fn with_drift_scale(mut self, drift_scale: f64) -> Self { + self.drift_scale = drift_scale; + self + } + /// The configured prior skill estimate. #[must_use] pub fn prior(&self) -> Gaussian { @@ -47,6 +61,28 @@ impl> Rating { self.drift } + /// This competitor's multiplier on the drift variance; 1.0 is neutral. + #[must_use] + pub fn drift_scale(&self) -> f64 { + self.drift_scale + } + + /// Drift variance accumulated over `from -> to`, scaled for this competitor. + /// + /// The single place the scale is applied for a `Time`-typed span. Callers + /// must go through this rather than `self.drift` directly, so a competitor's + /// scale cannot be silently skipped. + pub(crate) fn drift_variance_delta(&self, from: &T, to: &T) -> f64 { + self.drift.variance_delta(from, to) * self.drift_scale * self.drift_scale + } + + /// Drift variance for a cached elapsed count, scaled for this competitor. + /// + /// The counterpart of `drift_variance_delta` for the cached-elapsed paths. + pub(crate) fn drift_variance_for_elapsed(&self, elapsed: i64) -> f64 { + self.drift.variance_for_elapsed(elapsed) * self.drift_scale * self.drift_scale + } + pub(crate) fn performance(&self) -> Gaussian { self.prior.forget(self.beta.powi(2)) } @@ -58,6 +94,7 @@ impl Default for Rating { prior: Gaussian::default(), beta: BETA, drift: ConstantDrift(GAMMA), + drift_scale: 1.0, _time: PhantomData, } } diff --git a/src/time_slice.rs b/src/time_slice.rs index aa68985..2467675 100644 --- a/src/time_slice.rs +++ b/src/time_slice.rs @@ -72,9 +72,10 @@ impl Item { let skill = skills.at(self.slot); if forward { - Rating::new(skill.forward, r.beta, r.drift) + Rating::new(skill.forward, r.beta, r.drift).with_drift_scale(r.drift_scale) } else { Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift) + .with_drift_scale(r.drift_scale) } } } @@ -589,8 +590,7 @@ impl TimeSlice { n.forget( agents[*agent] .rating - .drift - .variance_for_elapsed(skill.elapsed), + .drift_variance_for_elapsed(skill.elapsed), ) } @@ -645,7 +645,7 @@ impl TimeSlice { let rating = &agents[agent].rating; let forward = match incoming.get(&agent) { - Some(message) => message.forget(rating.drift.variance_for_elapsed(skill.elapsed)), + Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)), None => rating.prior, }; diff --git a/tests/drift_scale.rs b/tests/drift_scale.rs new file mode 100644 index 0000000..c73444c --- /dev/null +++ b/tests/drift_scale.rs @@ -0,0 +1,402 @@ +//! 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(g)` at `scale = s` behaves as `ConstantDrift(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, + NullObserver, 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(gamma)) + .convergence(CONVERGENCE) + .build(); + + h.add_events(events).unwrap(); + 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(0.3) at scale 0.5 must equal ConstantDrift(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(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(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_eq!( + reject(-1.0), + InferenceError::InvalidParameter { + name: "drift_scale", + 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 captured at first appearance, the +/// same as `prior` — a later `with_drift_scale` on a key the history already +/// knows is ignored. This guards that decision rather than driving it: the +/// behaviour falls out of where the capture happens, and the point of the test +/// is that moving the capture would be a visible break, not a silent one. +#[test] +fn drift_scale_is_ignored_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(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. Too late: the competitor already exists. + 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(); + late.converge().unwrap(); + + let ignored = curve(&late, "anchor"); + let drifting = curve(&fit(distant_pair(None), 25.0 / 300.0), "anchor"); + + for ((t_l, g_l), (t_r, g_r)) in ignored.iter().zip(drifting.iter()) { + assert_eq!(t_l, t_r); + assert!( + (g_l.sigma() - g_r.sigma()).abs() < 1e-9, + "a scale set after first appearance must be ignored, leaving the fit \ + identical to one that never set it: t={t_l}, {} vs {}", + g_l.sigma(), + g_r.sigma() + ); + } + + let pinned = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor"); + assert!( + (ignored[1].1.sigma() - pinned[1].1.sigma()).abs() > 1e-6, + "sanity: the pinned fit must actually differ, or the assertion above is vacuous" + ); +}