Files
trueskill-tt/tests/validation.rs
T
logaritmiskandClaude Opus 5 4f6360128d feat!: validate mu, sigma and beta on HistoryBuilder
The last three unvalidated setters, beside `p_draw`, `score_sigma` and
`convergence`, which all assert eagerly. Measured before choosing bounds:

  beta = 0        -> works, pi 0.0211 (vs 0.0193 at the default)
  beta = -4.17    -> bit-identical to +4.17
  sigma = -8.33   -> bit-identical to +8.33
  sigma = 0       -> NonFiniteResult, current_skill returns tau: NaN
  sigma = inf     -> same
  mu = NaN        -> same

So the bounds are not the obvious ones. `beta = 0` is legitimate and
meaningful — performance is then exactly skill, and the fit moves
measurably rather than degenerating — so zero is allowed and a test pins
that it reaches a different answer, since "allowed" would otherwise be
indistinguishable from "unchecked".

The negative cases are the quiet ones. `sigma` and `beta` enter inference
only as squares, so a negative value behaves as its absolute value and
the sign is dropped without comment. That is the same defect
`Member::with_drift_scale` already rejects, for the reason already
written there.

The non-finite cases are detected today — `converge` reports
NonFiniteResult — but a caller who reads `current_skill` first is handed
`tau: NaN`, so rejecting at the boundary is what actually closes it.

BREAKING CHANGE: `HistoryBuilder::mu`, `sigma` and `beta` now panic on
values they previously accepted, matching the existing behaviour of
`p_draw`, `score_sigma` and `convergence`.

Refs #18

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

258 lines
8.2 KiB
Rust

//! Input validation must hold in **release**, where `debug_assert!` is gone.
//!
//! The engine guards itself with `debug_assert!`, which documents invariants
//! but vanishes in the profile users actually ship. Anything reachable from the
//! public API has to be rejected with an `InferenceError` instead, at the
//! boundary, rather than becoming NaN or an out-of-bounds panic deep inside
//! `run_chain`.
//!
//! `GameOptions` and `ConvergenceOptions` both have public fields, so the
//! eager asserts on `HistoryBuilder` do not cover the `Game` constructors —
//! a caller can build the options struct directly.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Game, GameOptions, Gaussian, History, InferenceError,
Member, Outcome, Rating, Team,
};
type R = Rating<i64, ConstantDrift>;
fn rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(0.0),
)
}
fn options_with_alpha(alpha: f64) -> GameOptions {
GameOptions {
convergence: ConvergenceOptions {
alpha,
..ConvergenceOptions::default()
},
..GameOptions::default()
}
}
/// `alpha == 0.0` leaves every EP update unapplied, so inference silently
/// returns the priors — the worst possible failure, since the output looks
/// entirely reasonable.
#[test]
fn ranked_rejects_a_zero_damping_factor() {
let (a, b) = (rating(), rating());
let err = Game::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&options_with_alpha(0.0),
)
.expect_err("alpha = 0 must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
"got {err:?}"
);
}
#[test]
fn ranked_rejects_an_out_of_range_damping_factor() {
let (a, b) = (rating(), rating());
for alpha in [-0.5, 1.5, f64::NAN] {
let err = Game::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&options_with_alpha(alpha),
)
.expect_err("alpha out of (0, 1] must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
"alpha={alpha}: got {err:?}"
);
}
}
#[test]
fn scored_rejects_a_bad_damping_factor() {
let (a, b) = (rating(), rating());
let err = Game::<i64, _>::scored(
&[&[a], &[b]],
Outcome::scores([21.0, 9.0]),
&options_with_alpha(0.0),
)
.expect_err("alpha = 0 must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { name: "alpha", .. }),
"got {err:?}"
);
}
/// Already covered by `Game::ranked`, asserted here so the release-mode
/// guarantee is stated in one place.
#[test]
fn ranked_rejects_an_out_of_range_draw_probability() {
let (a, b) = (rating(), rating());
for p_draw in [-0.5, 1.0, 1.5] {
let options = GameOptions {
p_draw,
..GameOptions::default()
};
assert!(
Game::<i64, _>::ranked(&[&[a], &[b]], Outcome::winner(0, 2), &options).is_err(),
"p_draw={p_draw} must be rejected"
);
}
}
#[test]
fn scored_rejects_a_non_positive_noise() {
let (a, b) = (rating(), rating());
for score_sigma in [0.0, -1.0, f64::NAN] {
let options = GameOptions {
score_sigma,
..GameOptions::default()
};
assert!(
Game::<i64, _>::scored(&[&[a], &[b]], Outcome::scores([21.0, 9.0]), &options).is_err(),
"score_sigma={score_sigma} must be rejected"
);
}
}
/// A tie with no draw probability makes the truncation margin zero and the
/// two-sided update evaluate 0/0. Ingestion must refuse it.
#[test]
fn ingestion_rejects_a_tie_without_a_draw_probability() {
let mut h = History::builder().p_draw(0.0).build();
let err = h
.add_events(vec![Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::draw(2),
}])
.expect_err("a tie with p_draw = 0 must be rejected");
assert!(
matches!(err, InferenceError::TieWithoutDrawProbability { .. }),
"got {err:?}"
);
}
/// `Outcome::scores_with_sigma` documents that a non-positive sigma is
/// accepted at construction and rejected at ingestion.
#[test]
fn ingestion_rejects_a_non_positive_per_event_score_sigma() {
for sigma in [0.0, -1.0, f64::NAN] {
let mut h = History::builder().build();
let err = h
.add_events(vec![Event {
time: 0,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([21.0, 9.0], sigma),
}])
.expect_err("a non-positive per-event sigma must be rejected");
assert!(
matches!(err, InferenceError::InvalidParameter { .. }),
"sigma={sigma}: got {err:?}"
);
}
}
/// Per-team weights must match that team's membership. The top-level length
/// checks in ingestion do not cover the inner dimension.
#[test]
fn ingestion_rejects_weights_that_do_not_match_their_team() {
let mut h = History::builder().build();
let mut team = Team::with_members([Member::new("a"), Member::new("b")]);
team.members[0].weight = 1.0;
let err = h
.event(0)
.team(["a", "b"])
.team(["c"])
// Three weights for a two-member team.
.weights([1.0, 1.0, 1.0])
.winner(0)
.commit()
.expect_err("a weight/member length mismatch must be rejected");
assert!(
matches!(err, InferenceError::MismatchedShape { .. }),
"got {err:?}"
);
}
/// `mu`, `sigma` and `beta` were the last unvalidated setters on
/// `HistoryBuilder`, next to `p_draw`, `score_sigma` and `convergence`, which
/// all assert eagerly.
///
/// Two of the rejected values are the quiet kind. A negative `sigma` or `beta`
/// enters inference only as its square, so it produced bit-identical results
/// to the positive value — the sign was dropped without comment.
mod builder_parameters {
use trueskill_tt::History;
#[test]
#[should_panic(expected = "mu must be finite")]
fn a_non_finite_mu_is_rejected() {
let _ = History::builder().mu(f64::NAN);
}
#[test]
#[should_panic(expected = "sigma must be finite and positive")]
fn a_zero_sigma_is_rejected() {
let _ = History::builder().sigma(0.0);
}
#[test]
#[should_panic(expected = "sigma must be finite and positive")]
fn a_negative_sigma_is_rejected() {
let _ = History::builder().sigma(-8.33);
}
#[test]
#[should_panic(expected = "sigma must be finite and positive")]
fn an_infinite_sigma_is_rejected() {
let _ = History::builder().sigma(f64::INFINITY);
}
#[test]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_negative_beta_is_rejected() {
let _ = History::builder().beta(-4.17);
}
#[test]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_non_finite_beta_is_rejected() {
let _ = History::builder().beta(f64::NAN);
}
/// Zero beta is deliberately allowed: performance is then exactly skill.
/// It has to reach a different fit than a positive beta, or "allowed"
/// would just mean "not checked".
#[test]
fn a_zero_beta_is_allowed_and_changes_the_fit() {
let fit = |beta: f64| {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(beta)
.build();
h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap();
h.current_skill(&"a").unwrap()
};
let zero = fit(0.0);
let positive = fit(25.0 / 6.0);
assert!(zero.pi().is_finite() && zero.pi() > 0.0);
assert!(
(zero.pi() - positive.pi()).abs() > 1e-6,
"zero beta must not merely be ignored: {zero:?} vs {positive:?}"
);
}
}