`K` is the one type parameter people change, and it was last. Naming a
history in a struct field meant writing all four to say one thing:
struct Ladder { history: History<i64, ConstantDrift, NullObserver, String> }
struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> }
Now:
struct Ladder { history: History<String> }
struct Analysis<'h> { joint: Joint<'h> }
`History<K, T, D, O>`, all four defaulted. Bounds may reference later
parameters, so `D: Drift<T> = ConstantDrift` is legal in third position.
`Joint` gains the same defaults, so `Joint<'h, String>` spells it.
72 call sites swapped, and the reorder makes most of them shorter: 18
now read `History<String>` and the `&'static str` ones read `History`.
The two turbofished builders shrink from
`HistoryBuilder::<Untimed, _, _, String>::new()` to
`HistoryBuilder::<String, Untimed>::new()`.
`Joint` keeps `O` structurally, defaulted rather than removed. #72 notes
it never touches the observer, which is true — but it borrows the whole
`&'h History<K, T, D, O>` and calls `History::resolve_terms`, so dropping
the parameter means either a view type or moving that method off
`History`. The default already buys the entire user-visible benefit,
which was the spelling.
Refs #72.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
152 lines
4.7 KiB
Rust
152 lines
4.7 KiB
Rust
//! `predict_margin`: the predictive distribution of a scored matchup.
|
|
|
|
use smallvec::smallvec;
|
|
use trueskill_tt::{
|
|
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
|
|
UnknownKeys,
|
|
};
|
|
|
|
fn builder(policy: UnknownKeys) -> History {
|
|
History::builder()
|
|
.mu(0.0)
|
|
.sigma(6.0)
|
|
.beta(1.0)
|
|
.score_sigma(2.0)
|
|
.drift(ConstantDrift::new(0.0))
|
|
.unknown_keys(policy)
|
|
.convergence(ConvergenceOptions {
|
|
max_iter: 5_000,
|
|
epsilon: 1e-12,
|
|
alpha: 1.0,
|
|
})
|
|
.build()
|
|
}
|
|
|
|
fn round(a: &'static str, b: &'static str, sa: f64, sb: f64) -> Event<i64, &'static str> {
|
|
Event {
|
|
time: 1,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new(a)]),
|
|
Team::with_members([Member::new(b)]),
|
|
],
|
|
outcome: Outcome::scores([sa, sb]),
|
|
}
|
|
}
|
|
|
|
/// A history where "veteran" and "regular" are well observed and "novice"
|
|
/// appears once.
|
|
fn fitted(policy: UnknownKeys) -> History {
|
|
let mut h = builder(policy);
|
|
let mut events: Vec<_> = (0..40)
|
|
.map(|t| round("veteran", "regular", 10.0 + f64::from(t % 3), 5.0))
|
|
.collect();
|
|
events.push(round("veteran", "novice", 10.0, 6.0));
|
|
h.add_events(events).unwrap();
|
|
let _ = h.converge().unwrap();
|
|
h
|
|
}
|
|
|
|
/// The property #48 exists for: the interval must widen when the model knows
|
|
/// less. Their hand-fitted noise law quoted the same sigma for a competitor
|
|
/// with forty rounds and one with none.
|
|
#[test]
|
|
fn the_interval_widens_as_the_model_knows_less() {
|
|
let h = fitted(UnknownKeys::Prior);
|
|
|
|
let well_known = h
|
|
.predict_margin(&[&[&"veteran"], &[&"regular"]])
|
|
.unwrap()
|
|
.sigma();
|
|
let thin = h
|
|
.predict_margin(&[&[&"veteran"], &[&"novice"]])
|
|
.unwrap()
|
|
.sigma();
|
|
let unseen = h
|
|
.predict_margin(&[&[&"veteran"], &[&"stranger"]])
|
|
.unwrap()
|
|
.sigma();
|
|
|
|
assert!(
|
|
well_known < thin && thin < unseen,
|
|
"margin width should grow as evidence thins: {well_known} < {thin} < {unseen}"
|
|
);
|
|
}
|
|
|
|
/// #48's second requirement: an unseen competitor is a legitimate question, not
|
|
/// an error, and the answer should come from the prior rather than be faked.
|
|
#[test]
|
|
fn an_unseen_competitor_is_answered_from_the_prior() {
|
|
let h = fitted(UnknownKeys::Prior);
|
|
let g = h.predict_margin(&[&[&"nobody"], &[&"no_one"]]).unwrap();
|
|
|
|
// Two unknowns: the gap is centred on zero and carries both priors plus
|
|
// both performance noises plus the observation noise.
|
|
assert!(g.mu().abs() < 1e-9, "mu {}", g.mu());
|
|
let expected = (2.0 * 36.0 + 2.0 * 1.0 + 4.0f64).sqrt();
|
|
assert!(
|
|
(g.sigma() - expected).abs() < 1e-9,
|
|
"sigma {} vs expected {expected}",
|
|
g.sigma()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reject_still_rejects() {
|
|
let h = fitted(UnknownKeys::Reject);
|
|
assert!(matches!(
|
|
h.predict_margin(&[&[&"veteran"], &[&"stranger"]]),
|
|
Err(InferenceError::UnknownKey { .. })
|
|
));
|
|
}
|
|
|
|
/// The margin is the *difference*, so it must be antisymmetric in the teams.
|
|
#[test]
|
|
fn swapping_the_teams_negates_the_margin() {
|
|
let h = fitted(UnknownKeys::Prior);
|
|
let forward = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
|
let reverse = h.predict_margin(&[&[&"regular"], &[&"veteran"]]).unwrap();
|
|
|
|
assert!((forward.mu() + reverse.mu()).abs() < 1e-9);
|
|
assert!((forward.sigma() - reverse.sigma()).abs() < 1e-12);
|
|
}
|
|
|
|
/// The predictive interval must be wider than the skill gap alone: it also
|
|
/// carries per-event performance noise and the observation noise.
|
|
#[test]
|
|
fn the_predictive_interval_exceeds_the_skill_uncertainty() {
|
|
let h = fitted(UnknownKeys::Prior);
|
|
let skill_gap = h
|
|
.joint()
|
|
.unwrap()
|
|
.posterior_of(&[(&"veteran", 1.0), (&"regular", -1.0)])
|
|
.unwrap();
|
|
let predictive = h.predict_margin(&[&[&"veteran"], &[&"regular"]]).unwrap();
|
|
|
|
assert!(
|
|
(predictive.mu() - skill_gap.mu()).abs() < 1e-12,
|
|
"means agree"
|
|
);
|
|
// beta^2 twice plus score_sigma^2 = 2 + 4.
|
|
let expected = (skill_gap.sigma().powi(2) + 6.0).sqrt();
|
|
assert!((predictive.sigma() - expected).abs() < 1e-12);
|
|
assert!(predictive.sigma() > skill_gap.sigma());
|
|
}
|
|
|
|
#[test]
|
|
fn shape_errors_are_reported() {
|
|
let h = fitted(UnknownKeys::Prior);
|
|
assert!(matches!(
|
|
h.predict_margin(&[&[&"veteran"]]),
|
|
Err(InferenceError::MismatchedShape {
|
|
expected: 2,
|
|
got: 1,
|
|
..
|
|
})
|
|
));
|
|
let empty: [&&str; 0] = [];
|
|
assert!(matches!(
|
|
h.predict_margin(&[&[&"veteran"], &empty]),
|
|
Err(InferenceError::EmptyTeam { team: 1, .. })
|
|
));
|
|
}
|