Files
trueskill-tt/tests/game.rs
T
logaritmiskandClaude Opus 5 061c481aad refactor!: typed discriminators for InferenceError
Six of fifteen variants carried a `&'static str` discriminator, about
thirty magic strings between them, and the only thing a caller could do
with one was print it. Four new enums replace them:

    Parameter        13 variants, replacing 9 strings in InvalidParameter
    Shape             4 variants, replacing 10 in MismatchedShape
    OutcomeKind       2 variants, replacing WrongOutcomeKind's three fields
    CompetitorField   2 variants, replacing ConflictingCompetitorConfig's

`InvalidProbability` folds into `InvalidParameter` as
`Parameter::PDraw`. It was a bespoke variant for one scalar while every
other scalar shared `InvalidParameter`, and it omitted the parameter
name — so the same parameter had two mechanisms.

`JointUnavailable { reason: &'static str }` splits into `EmptyHistory`,
`JointRequiresScoredEvents` and `NotPositiveDefinite`. The three are
conditions a caller branches on differently — add events, use
`predict_win_probabilities`, or reconsider the priors — and telling them
apart used to mean string-matching English. One test already proved the
distinction was load-bearing: the blanket conversion mapped the
empty-history case onto the ranked one and `an_empty_history_has_no_joint`
caught it immediately.

`NonFiniteResult` splits into `NonFiniteStep { context, step }` and
`NonFiniteSkill { mu, sigma }`. One `step: (f64, f64)` field was
carrying a sweep step from `converge` and a skill's own moments from a
prediction — two situations in one variant, and a field name that could
only be right for one of them.

`InvalidParameter { name: "beta with point-mass skills" }` becomes
`NoPerformanceVariance`. It was never a parameter out of range: both
values are individually valid and it is their combination that leaves
nothing varying.

Three `Display` impls did not meet the standard the others set, and the
typed data is what makes fixing them possible:

    before  drift variance is invalid: NaN
    after   drift variance must be finite and non-negative (got NaN)

    before  kinds: expected length 3, got 2
    after   the outcome describes a different number of teams than the
            event has: expected 3, got 2

    before  Game::ranked: expected Outcome::Ranked, got Outcome::Scored
    after   expected Outcome::Ranked, got Outcome::Scored; call
            Game::scored for a scored outcome

`Parameter::range()` states each parameter's actual bounds, which no
`&'static str` name could have. `error::message_tests` renders every one
and asserts each is a sentence rather than a label, and that the three
above now carry a range or a next step.

The four internal `MismatchedShape` kinds — `results`, `times`, `kinds`,
and the weights array — collapse to `Shape::Internal`, whose `Display`
says plainly that reaching it is a bug in this crate. They are checks on
`add_events_with_prior`'s own parallel arrays and are unreachable
through the public API; they stay checked rather than becoming
`debug_assert!`s, because release is where this crate's defects hide.

Closes #74.

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

269 lines
8.1 KiB
Rust

use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, InferenceError, Outcome, Rating,
};
type R = Rating<i64, ConstantDrift>;
fn default_rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift::new(25.0 / 300.0),
)
}
#[test]
fn game_ranked_1v1_golden() {
let a = default_rating();
let b = default_rating();
let g = Game::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&GameOptions::default(),
)
.unwrap();
let p = g.posteriors();
assert!(p[0][0].mu() > 25.0);
assert!(p[1][0].mu() < 25.0);
assert!((p[0][0].sigma() - p[1][0].sigma()).abs() < 1e-6);
}
#[test]
fn game_one_v_one_shortcut() {
let a = default_rating();
let b = default_rating();
let game =
Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
let post = game.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
assert!(a_post.mu() > 25.0);
assert!(b_post.mu() < 25.0);
// It returns a game like every other constructor, so evidence is askable.
// Two identical ratings make either result equally likely.
assert!((game.log_evidence() - 0.5_f64.ln()).abs() < 1e-12);
}
#[test]
fn game_ranked_rejects_bad_p_draw() {
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
let err = Game::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::winner(0, 2),
&GameOptions {
p_draw: 1.5,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
},
)
.unwrap_err();
assert!(matches!(err, InferenceError::InvalidParameter { .. }));
}
#[test]
fn game_ranked_rejects_mismatched_ranks() {
let a = R::new(Gaussian::default(), 1.0, ConstantDrift::new(0.0));
let err = Game::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::ranking([0, 1, 2]),
&GameOptions::default(),
)
.unwrap_err();
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
}
#[test]
fn game_free_for_all_three_players() {
let a = default_rating();
let b = default_rating();
let c = default_rating();
let g = Game::<i64, _>::free_for_all(
&[&a, &b, &c],
Outcome::ranking([0, 1, 2]),
&GameOptions::default(),
)
.unwrap();
let p = g.posteriors();
assert_eq!(p.len(), 3);
assert!(p[0][0].mu() > p[1][0].mu());
assert!(p[1][0].mu() > p[2][0].mu());
}
#[test]
fn game_log_evidence_is_finite() {
let a = default_rating();
let b = default_rating();
let g = Game::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&GameOptions::default(),
)
.unwrap();
assert!(g.log_evidence().is_finite());
assert!(g.log_evidence() < 0.0);
}
/// `one_v_one` used to hardcode `GameOptions::default()`, so a 1v1 could
/// never set `p_draw` and a drawn 1v1 was unreachable through it.
#[test]
fn one_v_one_honours_the_draw_probability_it_is_given() {
let a = default_rating();
let b = default_rating();
// Default options still reject a draw, because the default p_draw is zero.
let err = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &GameOptions::default())
.expect_err("a draw needs a positive p_draw");
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
// With a draw probability supplied it succeeds — which was impossible
// before the signature took options.
let options = GameOptions {
p_draw: 0.25,
..GameOptions::default()
};
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::draw(2), &options)
.expect("a draw is representable once p_draw is positive")
.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
// A symmetric draw leaves the means alone and sharpens both sides.
assert!((a_post.mu() - b_post.mu()).abs() < 1e-9);
assert!(a_post.sigma() < 25.0 / 3.0);
}
/// Convergence options reach the 1v1 path too, not just `p_draw`.
#[test]
fn one_v_one_honours_convergence_options() {
let a = default_rating();
let b = default_rating();
let options = GameOptions {
convergence: ConvergenceOptions::default(),
..GameOptions::default()
};
let post = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options)
.unwrap()
.posteriors();
assert!(post[0][0].mu() > 25.0);
}
/// `Game` is a public entry point that does not pass through `History`'s
/// ingestion chokepoint, so it needs its own boundary — and did not have one.
///
/// A one-team game panicked at `src/game.rs:317` with "range start index 1 out
/// of range for slice of length 0", in release, from safe API. This is the
/// same defect `tests/ingestion_shape.rs` covers for `History`; fixing that
/// path left this one open, because they share no validation.
mod malformed_games {
use super::*;
#[test]
fn a_one_team_ranked_game_is_an_error_not_a_panic() {
let a = default_rating();
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
#[test]
fn a_one_team_scored_game_is_an_error_not_a_panic() {
let a = default_rating();
let err = Game::<i64, _>::scored(
&[&[a]],
Outcome::scores([1.0]),
&GameOptions {
score_sigma: 1.0,
..GameOptions::default()
},
)
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
"{err:?}"
);
}
#[test]
fn a_zero_team_game_is_an_error() {
let err =
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
"{err:?}"
);
}
/// The quiet half: an empty team contributed no performance, so the game
/// returned a finite posterior for its opponent as though it had won one.
#[test]
fn an_empty_team_is_an_error() {
let a = default_rating();
let err =
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
"{err:?}"
);
}
#[test]
fn a_non_finite_score_is_an_error() {
let a = default_rating();
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let err = Game::<i64, _>::scored(
&[&[a], &[a]],
Outcome::scores([bad, 1.0]),
&GameOptions {
score_sigma: 1.0,
..GameOptions::default()
},
)
.unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
parameter: trueskill_tt::Parameter::Score,
..
}
),
"{bad}: {err:?}"
);
}
}
/// `free_for_all` and `one_v_one` build their teams internally, so they
/// must keep working — the check must not catch well-formed games.
#[test]
fn well_formed_games_are_untouched() {
let a = default_rating();
assert!(
Game::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::winner(0, 2),
&GameOptions::default()
)
.is_ok()
);
assert!(
Game::<i64, _>::free_for_all(
&[&a, &a, &a],
Outcome::ranking([0, 1, 2]),
&GameOptions::default()
)
.is_ok()
);
assert!(
Game::<i64, _>::one_v_one(&a, &a, Outcome::winner(0, 2), &GameOptions::default())
.is_ok()
);
}
}