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:
+31
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user