feat: allow drift to vary per competitor via Member::with_drift_scale

Drift was a property of the History, so every competitor drifted at the
same rate and a fixed reference point could not share a graph with moving
competitors. A bot at a known strength, a rating floor, a course
difficulty — all of them drifted along with the players.

Member::with_drift_scale(s) multiplies the drift *variance* a 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.
A scalar rather than a per-competitor Drift keeps History's single D type
parameter untouched and stays Copy. 0.0 pins a competitor still.

The scale lives on Rating, beside the drift it scales, and is applied
only through Rating::drift_variance_delta / drift_variance_for_elapsed.
Making those the sole entry points means a caller cannot reach the raw
drift and silently skip a competitor's scale — the filtered pass was
exactly that bug during development, caught because its test was written
before the wiring.

Like with_prior, the scale is competitor configuration captured at first
appearance rather than a per-event override; a competitor that is static
is static, and a scale that changed between events would make the skill
trajectory hard to interpret. Member's docs claimed prior was a per-event
override, which the code has never done — corrected here.

A negative scale is rejected rather than squared into its absolute value,
and a non-finite one rejected outright, both as InvalidParameter.

None means 1.0, so no existing call site changes and no existing fit
moves. Adding a public field to Member does break struct-literal
construction downstream.

Closes #34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014b6wy2q8rnFK8U8GPJVQNU
This commit is contained in:
2026-09-01 06:29:02 +02:00
co-authored by Claude Opus 5
parent 1a88678384
commit 617bc07f6f
7 changed files with 541 additions and 11 deletions
+402
View File
@@ -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<i64, ConstantDrift, NullObserver, &'static str>;
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<f64>) -> Vec<Event<i64, &'static str>> {
let anchor = |s: Option<f64>| 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<Event<i64, &'static str>>, 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<Event<i64, &'static str>> = 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<Event<i64, &'static str>> = 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<Event<i64, &'static str>> = 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<Event<i64, &'static str>> = 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"
);
}