Documentation (#78). Every item below was measured against the code rather than read: - `expected_information_gain` and `predict_ranking` had `# Errors` immediately followed by `# Preconditions`, with the error list stranded at the bottom of the latter — rustdoc rendered a BLANK Errors section on both. The heading now sits with its content. - `predict_outcome`, `predict_ranking` and the free `expected_information_gain` all omitted `GridTooCoarse`. - `predict_margin` claimed `JointUnavailable` "if the LATEST slice holds ranked events". Measured with an early ranked slice and a late scored one: it fails. The condition is *any* slice. - `add_events` documented three errors and can return five more; it also claimed a weights `MismatchedShape` that is unreachable through it, since weights arrive one-per-`Member`. That check belongs to `EventBuilder::weights`, and the doc now says so. - `converge` and `converge_partial` both omitted the drift-variance `InvalidParameter`. `History` gains a hand-written `Debug` (#76). Summarising, not exhaustive — a derived one would print every competitor's skill at every slice. It exists because without it a consumer cannot `#[derive(Debug)]` on any struct holding a `History`, which is how both known consumers store one. `#[non_exhaustive]` on all 17 `InferenceError` struct variants and on `Outcome::Scored` (#74). The enum carried the attribute; no variant did, so adding a field to any of them — and downstream construction of any of them — were both in the public contract. This crate added two variants in two days. The options structs are deliberately NOT sealed. `ConvergenceOptions` and `GameOptions` are constructed by struct literal at 65 sites of which only 8 use `..default()`, and specifying all three convergence fields is a natural complete statement rather than a partial one. That is a real trade-off rather than an oversight, and it is left as a decision on #74. Also spells `UnknownKeys::Reject` explicitly at both sites that wildcarded it. `#[non_exhaustive]` on your own enum gives no exhaustiveness safety net if you then match `_`. Sealing the variants pushed ten test sites from constructing errors to `matches!`, which is the better assertion anyway — an `assert_eq!` against a constructed error breaks whenever a field is added, which is the exact fragility the attribute exists to prevent. BREAKING CHANGE: `InferenceError`'s struct variants and `Outcome::Scored` are `#[non_exhaustive]` — downstream patterns need `..` and downstream construction is no longer possible. Refs #78, #76, #74 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
498 lines
16 KiB
Rust
498 lines
16 KiB
Rust
//! 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::new(g)` at `scale = s` behaves as `ConstantDrift::new(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::new(gamma))
|
|
.convergence(CONVERGENCE)
|
|
.build();
|
|
|
|
h.add_events(events).unwrap();
|
|
let _ = 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::new(0.3) at scale 0.5 must equal ConstantDrift::new(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::new(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::new(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!(matches!(
|
|
reject(-1.0),
|
|
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
|
|
if 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, and configuration supplied for a
|
|
/// competitor the history already knows is now *applied* rather than dropped.
|
|
///
|
|
/// This test previously asserted the opposite. It was written as a deliberate
|
|
/// change-detector — "moving the capture would be a visible break, not a silent
|
|
/// one" — and that is exactly what happened: the capture moved, and the
|
|
/// assertion inverted rather than being deleted.
|
|
///
|
|
/// Because configuration lives on the competitor and `converge` refits from
|
|
/// competitor state, a late pin applies to the *whole* history, not just to
|
|
/// events after it. So a scale set on the second batch must reach the same fit
|
|
/// as one set from the very first event.
|
|
#[test]
|
|
fn drift_scale_applies_when_set_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::new(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. No longer too late.
|
|
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();
|
|
let _ = late.converge().unwrap();
|
|
|
|
let applied = curve(&late, "anchor");
|
|
let pinned_from_the_start = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor");
|
|
let never_pinned = curve(&fit(distant_pair(None), 25.0 / 300.0), "anchor");
|
|
|
|
for ((t_l, g_l), (t_r, g_r)) in applied.iter().zip(pinned_from_the_start.iter()) {
|
|
assert_eq!(t_l, t_r);
|
|
assert!(
|
|
(g_l.sigma() - g_r.sigma()).abs() < 1e-9,
|
|
"a late pin should refit the whole history: t={t_l}, {} vs {}",
|
|
g_l.sigma(),
|
|
g_r.sigma()
|
|
);
|
|
}
|
|
|
|
// And it must actually have done something.
|
|
assert!(
|
|
applied
|
|
.iter()
|
|
.zip(never_pinned.iter())
|
|
.any(|((_, a), (_, b))| (a.sigma() - b.sigma()).abs() > 1e-9),
|
|
"the pin had no effect at all — the silent drop is back"
|
|
);
|
|
}
|
|
|
|
/// Re-declaring the same configuration must be inert. This is the shape a
|
|
/// caller gets when the configuration is a property of the domain — "layouts
|
|
/// are static" — so every ingestion path repeats it on every event.
|
|
///
|
|
/// Both histories see exactly the same events; only how many times the scale
|
|
/// is declared differs.
|
|
#[test]
|
|
fn repeating_the_same_configuration_changes_nothing() {
|
|
let events = |declare_every_time: bool| {
|
|
let anchor = |first: bool| {
|
|
if first || declare_every_time {
|
|
Member::new("anchor").with_drift_scale(0.0)
|
|
} else {
|
|
Member::new("anchor")
|
|
}
|
|
};
|
|
vec![
|
|
Event {
|
|
time: 0,
|
|
teams: smallvec![
|
|
Team::with_members([anchor(true)]),
|
|
Team::with_members([Member::new("player")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
},
|
|
Event {
|
|
time: 1000,
|
|
teams: smallvec![
|
|
Team::with_members([anchor(false)]),
|
|
Team::with_members([Member::new("player")]),
|
|
],
|
|
outcome: Outcome::winner(1, 2),
|
|
},
|
|
]
|
|
};
|
|
|
|
let once = curve(&fit(events(false), 25.0 / 300.0), "anchor");
|
|
let every_time = curve(&fit(events(true), 25.0 / 300.0), "anchor");
|
|
|
|
for ((t_l, a), (t_r, b)) in once.iter().zip(every_time.iter()) {
|
|
assert_eq!(t_l, t_r);
|
|
assert!(
|
|
(a.sigma() - b.sigma()).abs() < 1e-12,
|
|
"t={t_l}: declaring the same scale repeatedly changed the fit, {} vs {}",
|
|
a.sigma(),
|
|
b.sigma()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_batch_that_contradicts_itself_is_rejected() {
|
|
let mut h = History::builder().convergence(CONVERGENCE).build();
|
|
|
|
let err = h
|
|
.add_events(vec![
|
|
Event {
|
|
time: 0,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("anchor").with_drift_scale(0.0)]),
|
|
Team::with_members([Member::new("player")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
},
|
|
Event {
|
|
time: 1,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("anchor").with_drift_scale(1.0)]),
|
|
Team::with_members([Member::new("player")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
},
|
|
])
|
|
.expect_err("two different scales for one competitor in one batch");
|
|
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
InferenceError::ConflictingCompetitorConfig {
|
|
field: "drift_scale",
|
|
..
|
|
}
|
|
),
|
|
"got {err:?}"
|
|
);
|
|
}
|