Files
trueskill-tt/tests/additive_model.rs
T
logaritmiskandClaude Opus 5 8dff7513f7 fix!: seal ConstantDrift's field so gamma can be validated
`gamma` enters only as `gamma * gamma`, so the sign was squared away:
measured against the old public-field form, `ConstantDrift(-0.0833)`
produced results bit identical to `ConstantDrift(0.0833)`. The sign was
neither rejected nor honoured — it vanished.

It could not be checked while the field was a public tuple position,
because there was nothing to intercept. Validating inside
`variance_for_elapsed` would have been worse: it runs in the sweep, so a
construction-time mistake would panic mid-inference, and `Gaussian::from_ms`
is a worked example of why that is the wrong place — rejecting NaN there
turned the NonFiniteResult reporting path into a crash.

So `ConstantDrift::new` is the only way in and it checks, with `gamma()`
to read the value back. 129 call sites rewritten across src, tests,
benches, examples and the README. The dated plan and spec documents under
docs/superpowers are left alone: they record what was built at the time,
and rewriting them would falsify that.

tests/constructor_validation.rs is the more valuable half. This defect
class was closed three times in one session and reopened twice, because
each fix validated the layer it had just touched and inferred the rest —
`HistoryBuilder`, then `Game`'s own entry points, then the constructors
beneath both. A per-site fix cannot notice the site nobody thought of, so
that file enumerates every public entry point taking a magnitude and
asserts each refuses negative and non-finite values.

It found an eleventh defect on its first run: `HistoryBuilder::score_sigma`
accepted infinity, because `inf > 0.0` is true and the assert only tested
positivity. Fixed, and its own `should_panic` message updated to match.

`Gaussian::from_ms` is deliberately exempt from the non-finite half, for
the reason above: a broken fit produces a NaN sigma legitimately and
`converge` must be allowed to report it.

The convergence-level drift-variance check stays and is now tested through
a custom `Drift` implementation, since `ConstantDrift` can no longer reach
it. That check is the only thing standing between a third-party `Drift`
and a NaN fit.

BREAKING CHANGE: `ConstantDrift`'s field is private. Replace
`ConstantDrift(x)` with `ConstantDrift::new(x)`, and `drift().0` with
`drift().gamma()`. `HistoryBuilder::score_sigma` now rejects infinity.

Closes #65

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-09 19:11:57 +02:00

143 lines
5.3 KiB
Rust

//! What an additive model does to uncertainty, and why "add the marginals" is
//! unsafe in one direction and merely wasteful in the other.
//!
//! Structurally this is the shape a joint player/layout model takes: every
//! observation measures a *sum* of nodes against a reference, so the data pins
//! differences and leaves the overall level to the prior. That is the classic
//! rating-scale indeterminacy, not a defect.
//!
//! The consequence for a consumer is that combining marginals is wrong in
//! opposite directions depending on the combination, which is worth pinning
//! because the unsafe direction is not the one you would guess:
//!
//! - **Differences** (`a - b`): the shared level cancels, so the exact width is
//! small — and adding marginals lands within a couple of percent of it here,
//! because the loopy underestimate offsets the ignored correlation.
//! - **Sums** (`a + b`): the shared level does *not* cancel, so the exact width
//! is large, and adding marginals is roughly five times too narrow. That is
//! overconfident, and it is the direction that publishes a claim the data
//! does not support.
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
#[test]
fn additive_structure_makes_sums_wide_and_differences_tight() {
// Structurally like ustat: every round is (player + hole) measured against
// a fixed reference. Only SUMS are pinned by the data; the split between
// player and hole is pinned only by the prior.
let players = ["p0", "p1", "p2"];
let holes = ["h0", "h1"];
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift::new(0.0))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-12,
alpha: 1.0,
})
.build();
let mut seed = 3u64;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
// true skills, so we know what the data encodes
let truth_p = [2.0, 0.0, -2.0];
let truth_h = [1.0, -1.0];
let mut events = Vec::new();
for _ in 0..60 {
let p = (rnd() as usize) % 3;
let q = (rnd() as usize) % 2;
let noise = ((rnd() % 1000) as f64 / 1000.0 - 0.5) * 2.0;
let score = truth_p[p] + truth_h[q] + noise;
events.push(Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new(players[p]), Member::new(holes[q])]),
Team::with_members([Member::new("reference")]),
],
outcome: Outcome::scores([score, 0.0]),
});
}
h.add_events(events).unwrap();
let r = h.converge().unwrap();
assert!(r.converged, "{:?}", r.final_step);
println!("\n== marginals (what current_skill reports) ==");
for k in players.iter().chain(holes.iter()) {
let g = h.current_skill(k).unwrap();
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
}
println!("\n== the same nodes via posterior_of (exact marginal) ==");
for k in players.iter().chain(holes.iter()) {
let g = h.posterior_of(&[(k, 1.0)]).unwrap();
println!(" {k}: mu {:>8.4} sigma {:>8.4}", g.mu(), g.sigma());
}
println!("\n== combinations the data actually pins ==");
for (label, terms) in [
("p0 + h0 (a round)", vec![(&"p0", 1.0), (&"h0", 1.0)]),
(
"p0 - p1 (rank two players)",
vec![(&"p0", 1.0), (&"p1", -1.0)],
),
("p0 - p2", vec![(&"p0", 1.0), (&"p2", -1.0)]),
(
"h0 - h1 (rank two holes)",
vec![(&"h0", 1.0), (&"h1", -1.0)],
),
] {
let joint = h.posterior_of(&terms).unwrap();
// what a consumer gets today by adding marginals
let naive: f64 = terms
.iter()
.map(|(k, c)| c * c * h.current_skill(*k).unwrap().sigma().powi(2))
.sum::<f64>()
.sqrt();
println!(
" {label:<28} exact sigma {:>7.4} adding marginals {:>7.4} {:>5.2}x over",
joint.sigma(),
naive,
naive / joint.sigma()
);
let ratio = naive / joint.sigma();
if label.contains('+') {
assert!(
ratio < 0.5,
"{label}: adding marginals should be badly OVERconfident for a \
sum, got {ratio:.3}x"
);
} else {
assert!(
(0.8..1.25).contains(&ratio),
"{label}: adding marginals happens to be close for a difference, \
got {ratio:.3}x"
);
}
}
// A single node in an additive model is weakly identified: its exact
// posterior is far wider than message passing reports, because the level it
// shares with its partners is pinned only by the prior.
for k in players.iter().chain(holes.iter()) {
let bp = h.current_skill(k).unwrap().sigma();
let exact = h.posterior_of(&[(k, 1.0)]).unwrap().sigma();
assert!(
exact > 3.0 * bp,
"{k}: exact marginal {exact} should be much wider than the reported \
{bp} in an additive model"
);
}
}