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:
@@ -71,6 +71,36 @@ let h = History::builder()
|
|||||||
.build();
|
.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
|
## Scored outcomes
|
||||||
|
|
||||||
Use `Outcome::scores([...])` when you have continuous per-team scores rather
|
Use `Outcome::scores([...])` when you have continuous per-team scores rather
|
||||||
|
|||||||
+2
-2
@@ -30,7 +30,7 @@ impl<T: Time, D: Drift<T>> Competitor<T, D> {
|
|||||||
match self.message {
|
match self.message {
|
||||||
Some(message) => {
|
Some(message) => {
|
||||||
let elapsed_variance = match &self.last_time {
|
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,
|
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).
|
/// and should not be recomputed from `last_time` (which may have shifted).
|
||||||
pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian {
|
pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian {
|
||||||
match self.message {
|
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,
|
None => self.rating.prior,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-3
@@ -45,13 +45,20 @@ impl<K> Default for Team<K> {
|
|||||||
|
|
||||||
/// One member of a team, identified by user key `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
|
/// `weight` applies per event and defaults to 1.0.
|
||||||
/// current skill estimate for this event only.
|
///
|
||||||
|
/// `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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Member<K> {
|
pub struct Member<K> {
|
||||||
pub key: K,
|
pub key: K,
|
||||||
pub weight: f64,
|
pub weight: f64,
|
||||||
pub prior: Option<Gaussian>,
|
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> {
|
impl<K> Member<K> {
|
||||||
@@ -60,6 +67,7 @@ impl<K> Member<K> {
|
|||||||
key,
|
key,
|
||||||
weight: 1.0,
|
weight: 1.0,
|
||||||
prior: None,
|
prior: None,
|
||||||
|
drift_scale: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +76,31 @@ impl<K> Member<K> {
|
|||||||
self
|
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 {
|
pub fn with_prior(mut self, prior: Gaussian) -> Self {
|
||||||
self.prior = Some(prior);
|
self.prior = Some(prior);
|
||||||
self
|
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.
|
/// 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.key, "alice");
|
||||||
assert_eq!(m.weight, 1.0);
|
assert_eq!(m.weight, 1.0);
|
||||||
assert!(m.prior.is_none());
|
assert!(m.prior.is_none());
|
||||||
|
assert!(m.drift_scale.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn member_builder_methods_chain() {
|
fn member_builder_methods_chain() {
|
||||||
let m = Member::new("alice")
|
let m = Member::new("alice")
|
||||||
.with_weight(0.5)
|
.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_eq!(m.weight, 0.5);
|
||||||
assert!(m.prior.is_some());
|
assert!(m.prior.is_some());
|
||||||
|
assert_eq!(m.drift_scale, Some(0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+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);
|
let idx = self.keys.get_or_create(&member.key);
|
||||||
team_indices.push(idx);
|
team_indices.push(idx);
|
||||||
team_weights.push(member.weight);
|
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);
|
event_comp.push(team_indices);
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
|||||||
pub(crate) prior: Gaussian,
|
pub(crate) prior: Gaussian,
|
||||||
pub(crate) beta: f64,
|
pub(crate) beta: f64,
|
||||||
pub(crate) drift: D,
|
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>,
|
pub(crate) _time: PhantomData<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +28,21 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
|
|||||||
prior,
|
prior,
|
||||||
beta,
|
beta,
|
||||||
drift,
|
drift,
|
||||||
|
drift_scale: 1.0,
|
||||||
_time: PhantomData,
|
_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.
|
/// The configured prior skill estimate.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn prior(&self) -> Gaussian {
|
pub fn prior(&self) -> Gaussian {
|
||||||
@@ -47,6 +61,28 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
|
|||||||
self.drift
|
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 {
|
pub(crate) fn performance(&self) -> Gaussian {
|
||||||
self.prior.forget(self.beta.powi(2))
|
self.prior.forget(self.beta.powi(2))
|
||||||
}
|
}
|
||||||
@@ -58,6 +94,7 @@ impl Default for Rating<i64, ConstantDrift> {
|
|||||||
prior: Gaussian::default(),
|
prior: Gaussian::default(),
|
||||||
beta: BETA,
|
beta: BETA,
|
||||||
drift: ConstantDrift(GAMMA),
|
drift: ConstantDrift(GAMMA),
|
||||||
|
drift_scale: 1.0,
|
||||||
_time: PhantomData,
|
_time: PhantomData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -72,9 +72,10 @@ impl Item {
|
|||||||
let skill = skills.at(self.slot);
|
let skill = skills.at(self.slot);
|
||||||
|
|
||||||
if forward {
|
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 {
|
} else {
|
||||||
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
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(
|
n.forget(
|
||||||
agents[*agent]
|
agents[*agent]
|
||||||
.rating
|
.rating
|
||||||
.drift
|
.drift_variance_for_elapsed(skill.elapsed),
|
||||||
.variance_for_elapsed(skill.elapsed),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,7 +645,7 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
let rating = &agents[agent].rating;
|
let rating = &agents[agent].rating;
|
||||||
|
|
||||||
let forward = match incoming.get(&agent) {
|
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,
|
None => rating.prior,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user