Two names that described the wrong thing.
`scores_with_sigma(scores, sigma)` reads as "these scores have prior
sigma 2.0". The quantity is observation noise on the score *margin*, in
the units of the scores, and it is spelled `score_sigma` at every config
site — `HistoryBuilder::score_sigma`, `GameOptions::score_sigma`,
`EventKind::Scored { score_sigma }` — so this was the one place the
crate used a third meaning of "sigma" for it. Its own doc had to
disambiguate itself: "`sigma` overrides `HistoryBuilder::score_sigma`".
`scores_with_noise(scores, score_sigma)` on both `Outcome` and
`EventBuilder`.
`predict_quality` predicts nothing. Its own doc says it answers "is this
matchup *fair*", not "what will happen", and the `predict_*` family is
otherwise exactly the methods returning a probability or a distribution
over outcomes. `History::quality` also makes the free/method pair
consistent: free `quality` pairs with `History::quality` the way free
`expected_information_gain` already pairs with
`History::expected_information_gain`. The rule that was already being
followed and never stated — a free function scores a hypothetical from
explicit parameters, the same-named method asks it against the fit — is
now written on the method.
Closes #75. Refs #78 (part 4).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
233 lines
7.1 KiB
Rust
233 lines
7.1 KiB
Rust
//! Every public entry point that takes a magnitude, in one place.
|
|
//!
|
|
//! 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` first, then `Game`'s own entry points, then the
|
|
//! constructors beneath both. A per-site fix cannot notice the site nobody
|
|
//! thought of.
|
|
//!
|
|
//! So this enumerates them. `sigma`, `beta` and `gamma` all enter inference
|
|
//! only as squares, which means a negative value does not fail — it behaves as
|
|
//! its absolute value, bit for bit, and the sign vanishes with no diagnostic.
|
|
//! Non-finite values poison every posterior derived from them.
|
|
//!
|
|
//! Adding a public constructor that takes one of these and not adding it here
|
|
//! is the failure this file exists to make harder.
|
|
|
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
|
|
use trueskill_tt::{ConstantDrift, Gaussian, History, Member, Outcome, Rating};
|
|
|
|
/// Did the entry point refuse the value, by panic or by `Err`?
|
|
fn refuses(f: impl FnOnce() -> bool) -> bool {
|
|
catch_unwind(AssertUnwindSafe(f)).unwrap_or(true)
|
|
}
|
|
|
|
/// One entry point, as a name and a closure that applies a value to it.
|
|
type Case = (&'static str, Box<dyn Fn(f64) -> bool>);
|
|
|
|
/// Entry points that must reject a negative magnitude.
|
|
///
|
|
/// Each closure returns `true` if it refused by returning an error; a panic is
|
|
/// also a refusal and is caught.
|
|
#[test]
|
|
fn every_magnitude_parameter_rejects_a_negative_value() {
|
|
let cases: Vec<Case> = vec![
|
|
(
|
|
"Gaussian::from_ms(sigma)",
|
|
Box::new(|v| {
|
|
let _ = Gaussian::from_ms(25.0, v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"Rating::new(beta)",
|
|
Box::new(|v| {
|
|
let _ = Rating::<i64, ConstantDrift>::new(
|
|
Gaussian::default(),
|
|
v,
|
|
ConstantDrift::new(0.0),
|
|
);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"ConstantDrift::new(gamma)",
|
|
Box::new(|v| {
|
|
let _ = ConstantDrift::new(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"HistoryBuilder::sigma",
|
|
Box::new(|v| {
|
|
let _ = History::builder().sigma(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"HistoryBuilder::beta",
|
|
Box::new(|v| {
|
|
let _ = History::builder().beta(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"HistoryBuilder::score_sigma",
|
|
Box::new(|v| {
|
|
let _ = History::builder().score_sigma(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"HistoryBuilder::p_draw",
|
|
Box::new(|v| {
|
|
let _ = History::builder().p_draw(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"Member::with_drift_scale (at ingestion)",
|
|
Box::new(|v| {
|
|
let mut h = History::builder().build();
|
|
h.add_events(vec![trueskill_tt::Event {
|
|
time: 1i64,
|
|
teams: smallvec::smallvec![
|
|
trueskill_tt::Team::with_members([Member::new("a").with_drift_scale(v)]),
|
|
trueskill_tt::Team::with_members([Member::new("b")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
}])
|
|
.is_err()
|
|
}),
|
|
),
|
|
(
|
|
"Outcome::scores_with_noise (at ingestion)",
|
|
Box::new(|v| {
|
|
let mut h = History::builder().build();
|
|
h.add_events(vec![trueskill_tt::Event {
|
|
time: 1i64,
|
|
teams: smallvec::smallvec![
|
|
trueskill_tt::Team::with_members([Member::new("a")]),
|
|
trueskill_tt::Team::with_members([Member::new("b")]),
|
|
],
|
|
outcome: Outcome::scores_with_noise([3.0, 1.0], v),
|
|
}])
|
|
.is_err()
|
|
}),
|
|
),
|
|
];
|
|
|
|
let mut accepted = Vec::new();
|
|
for (name, f) in &cases {
|
|
if !refuses(|| f(-1.0)) {
|
|
accepted.push(*name);
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
accepted.is_empty(),
|
|
"these accepted a negative magnitude, which is squared away silently \
|
|
rather than honoured or refused:\n {}",
|
|
accepted.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// Same set, for NaN and infinity.
|
|
///
|
|
/// `Gaussian::from_ms` is deliberately absent: a broken fit produces a NaN
|
|
/// sigma legitimately and `converge` reports it as `NonFiniteResult`. Rejecting
|
|
/// it in the constructor turned that reporting path into a panic inside
|
|
/// inference — see the comment on `from_ms`.
|
|
#[test]
|
|
fn every_magnitude_parameter_rejects_a_non_finite_value() {
|
|
let cases: Vec<Case> = vec![
|
|
(
|
|
"Rating::new(beta)",
|
|
Box::new(|v| {
|
|
let _ = Rating::<i64, ConstantDrift>::new(
|
|
Gaussian::default(),
|
|
v,
|
|
ConstantDrift::new(0.0),
|
|
);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"ConstantDrift::new(gamma)",
|
|
Box::new(|v| {
|
|
let _ = ConstantDrift::new(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"HistoryBuilder::sigma",
|
|
Box::new(|v| {
|
|
let _ = History::builder().sigma(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"HistoryBuilder::beta",
|
|
Box::new(|v| {
|
|
let _ = History::builder().beta(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"HistoryBuilder::mu",
|
|
Box::new(|v| {
|
|
let _ = History::builder().mu(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"HistoryBuilder::score_sigma",
|
|
Box::new(|v| {
|
|
let _ = History::builder().score_sigma(v);
|
|
false
|
|
}),
|
|
),
|
|
(
|
|
"HistoryBuilder::p_draw",
|
|
Box::new(|v| {
|
|
let _ = History::builder().p_draw(v);
|
|
false
|
|
}),
|
|
),
|
|
];
|
|
|
|
let mut accepted = Vec::new();
|
|
for (name, f) in &cases {
|
|
for bad in [f64::NAN, f64::INFINITY] {
|
|
if !refuses(|| f(bad)) {
|
|
accepted.push(format!("{name} accepted {bad}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
accepted.is_empty(),
|
|
"these accepted a non-finite magnitude:\n {}",
|
|
accepted.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// The suite must not pass by refusing everything.
|
|
#[test]
|
|
fn ordinary_values_are_still_accepted() {
|
|
let _ = Gaussian::from_ms(25.0, 8.33);
|
|
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 4.17, ConstantDrift::new(0.05));
|
|
let _ = ConstantDrift::new(0.0833);
|
|
let _ = History::builder()
|
|
.mu(25.0)
|
|
.sigma(8.33)
|
|
.beta(4.17)
|
|
.score_sigma(1.0)
|
|
.p_draw(0.1);
|
|
|
|
// Zero beta and zero gamma are legitimate, not degenerate.
|
|
let _ = ConstantDrift::new(0.0);
|
|
let _ = Rating::<i64, ConstantDrift>::new(Gaussian::default(), 0.0, ConstantDrift::new(0.0));
|
|
}
|