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
+2 -2
View File
@@ -30,7 +30,7 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
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<T: Time, D: Drift<T>> Competitor<T, D> {
/// 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,
}
}
+35 -3
View File
@@ -45,13 +45,20 @@ impl<K> Default for Team<K> {
/// 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<K> {
pub key: K,
pub weight: f64,
pub prior: Option<Gaussian>,
/// Multiplier on the drift *variance* this competitor accumulates.
/// `None` means 1.0.
pub drift_scale: Option<f64>,
}
impl<K> Member<K> {
@@ -60,6 +67,7 @@ impl<K> Member<K> {
key,
weight: 1.0,
prior: None,
drift_scale: None,
}
}
@@ -68,10 +76,31 @@ impl<K> Member<K> {
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]
+31 -2
View File
@@ -968,8 +968,37 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let idx = self.keys.get_or_create(&member.key);
team_indices.push(idx);
team_weights.push(member.weight);
if let Some(prior) = member.prior {
priors.insert(idx, Rating::new(prior, self.beta, self.drift));
if let Some(scale) = member.drift_scale {
// Squaring would make a negative scale behave as its
// absolute value, so reject rather than silently
// accept a sign the caller cannot have meant.
if !scale.is_finite() || scale < 0.0 {
return Err(InferenceError::InvalidParameter {
name: "drift_scale",
value: scale,
});
}
}
// `prior` and `drift_scale` are competitor configuration,
// captured here and consumed at competitor creation. Both
// land in the same entry so a member may set either alone.
if member.prior.is_some() || member.drift_scale.is_some() {
let rating = priors.entry(idx).or_insert_with(|| {
Rating::new(
Gaussian::from_ms(self.mu, self.sigma),
self.beta,
self.drift,
)
});
if let Some(prior) = member.prior {
rating.prior = prior;
}
if let Some(scale) = member.drift_scale {
rating.drift_scale = scale;
}
}
}
event_comp.push(team_indices);
+37
View File
@@ -16,6 +16,9 @@ pub struct Rating<T: Time = i64, D: Drift<T> = 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<T>,
}
@@ -25,10 +28,21 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
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<T: Time, D: Drift<T>> Rating<T, D> {
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<i64, ConstantDrift> {
prior: Gaussian::default(),
beta: BETA,
drift: ConstantDrift(GAMMA),
drift_scale: 1.0,
_time: PhantomData,
}
}
+4 -4
View File
@@ -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<T: Time> TimeSlice<T> {
n.forget(
agents[*agent]
.rating
.drift
.variance_for_elapsed(skill.elapsed),
.drift_variance_for_elapsed(skill.elapsed),
)
}
@@ -645,7 +645,7 @@ impl<T: Time> TimeSlice<T> {
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,
};