Files
trueskill-tt/tests/quality.rs
T
logaritmiskandClaude Opus 5 13a395fdc9 refactor!: scores_with_noise, and History::quality
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
2026-09-09 23:23:12 +02:00

213 lines
7.6 KiB
Rust

//! `quality()` beyond two teams.
//!
//! The historical golden (two equal singletons) is asserted in
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
//! which previously panicked with an out-of-bounds index at 3+ groups.
use trueskill_tt::{Gaussian, quality};
const BETA: f64 = 25.0 / 3.0 / 2.0;
fn rating(mu: f64, sigma: f64) -> Gaussian {
Gaussian::from_ms(mu, sigma)
}
#[test]
fn three_equal_groups_is_finite_and_in_range() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r], &[r], &[r]], BETA);
assert!(q.is_finite(), "quality must be finite, got {q}");
assert!((0.0..=1.0).contains(&q), "quality out of range: {q}");
}
#[test]
fn quality_supports_many_groups() {
let r = rating(25.0, 3.0);
for n in 2..=8 {
let holders: Vec<[Gaussian; 1]> = (0..n).map(|_| [r]).collect();
let groups: Vec<&[Gaussian]> = holders.iter().map(|g| g.as_slice()).collect();
let q = quality(&groups, BETA);
assert!(q.is_finite(), "n={n}: quality must be finite, got {q}");
assert!((0.0..=1.0).contains(&q), "n={n}: out of range: {q}");
}
}
/// Equal-strength groups are the best-matched case: introducing a skill gap
/// must lower quality.
#[test]
fn imbalance_lowers_quality() {
let strong = rating(40.0, 3.0);
let average = rating(25.0, 3.0);
let balanced = quality(&[&[average], &[average], &[average]], BETA);
let lopsided = quality(&[&[strong], &[average], &[average]], BETA);
assert!(
lopsided < balanced,
"expected imbalanced quality {lopsided} < balanced {balanced}"
);
}
/// Quality is a property of the multiset of groups, not their order.
#[test]
fn quality_is_permutation_invariant() {
let a = rating(30.0, 2.0);
let b = rating(25.0, 3.0);
let c = rating(20.0, 4.0);
let forward = quality(&[&[a], &[b], &[c]], BETA);
let reversed = quality(&[&[c], &[b], &[a]], BETA);
assert!(
(forward - reversed).abs() < 1e-9,
"permutation changed quality: {forward} vs {reversed}"
);
}
#[test]
fn multi_player_groups_work() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r, r], &[r, r], &[r, r]], BETA);
assert!(q.is_finite());
assert!((0.0..=1.0).contains(&q));
}
#[test]
fn uneven_group_sizes_work() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r, r], &[r], &[r, r, r]], BETA);
assert!(q.is_finite(), "got {q}");
assert!((0.0..=1.0).contains(&q), "got {q}");
}
#[test]
#[should_panic(expected = "at least 2 teams")]
fn single_group_panics_with_clear_message() {
let r = rating(25.0, 3.0);
let _ = quality(&[&[r]], BETA);
}
#[test]
#[should_panic(expected = "at least 2 teams")]
fn zero_groups_panics_with_clear_message() {
let _ = quality(&[], BETA);
}
#[test]
#[should_panic(expected = "non-empty")]
fn empty_group_panics_with_clear_message() {
let r = rating(25.0, 3.0);
let _ = quality(&[&[r], &[]], BETA);
}
#[test]
fn history_predict_quality_supports_three_teams() {
use trueskill_tt::History;
let mut h = History::default();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"b", &"c", 2).unwrap();
let _ = h.converge().unwrap();
let q = h.quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
assert!(q.is_finite(), "3-team quality must be finite, got {q}");
assert!((0.0..=1.0).contains(&q), "out of range: {q}");
}
/// `quality()` for N identical teams has a closed form, which pins the N-group
/// determinant path across the whole range rather than at a single golden.
///
/// For two identical single-player teams the standard result is
/// `sqrt(2b^2 / (2b^2 + s1^2 + s2^2))`. With the conventional parameters
/// (`sigma = 25/3`, `beta = 25/6`) that ratio is exactly `1/5`, and the N-group
/// generalisation is `(1/5)^((n-1)/2)` — one factor per adjacent pair.
///
/// The n=3 and n=5 values this produces (0.200 and 0.040) are also what the
/// `trueskill` Python package returns for the same configuration, so this
/// doubles as the cross-implementation check the README asked for.
#[test]
fn quality_of_identical_teams_follows_its_closed_form() {
let g = Gaussian::from_ms(25.0, 25.0 / 3.0);
let beta = 25.0 / 6.0;
for n in 2..=10usize {
let groups: Vec<Vec<Gaussian>> = (0..n).map(|_| vec![g]).collect();
let refs: Vec<&[Gaussian]> = groups.iter().map(Vec::as_slice).collect();
let got = quality(&refs, beta);
let expected = 0.2f64.powf((n - 1) as f64 / 2.0);
assert!(
(got - expected).abs() / expected < 1e-9,
"n={n}: quality {got}, closed form {expected}"
);
}
}
/// Spot-check against the two values the `trueskill` Python package is known
/// to produce for this configuration, stated as literals so a future change to
/// the closed-form reasoning above cannot quietly take these with it.
#[test]
fn quality_matches_the_reference_implementation() {
let g = Gaussian::from_ms(25.0, 25.0 / 3.0);
let beta = 25.0 / 6.0;
let three: Vec<Vec<Gaussian>> = (0..3).map(|_| vec![g]).collect();
let refs: Vec<&[Gaussian]> = three.iter().map(Vec::as_slice).collect();
assert!((quality(&refs, beta) - 0.200).abs() < 1e-9);
let five: Vec<Vec<Gaussian>> = (0..5).map(|_| vec![g]).collect();
let refs: Vec<&[Gaussian]> = five.iter().map(Vec::as_slice).collect();
assert!((quality(&refs, beta) - 0.040).abs() < 1e-9);
}
/// `quality()` used to compute `det(ata) / det(middle)` in linear space. Both
/// are products of `k - 1` diagonal entries, so they leave `f64`'s range long
/// before their ratio does — and the ratio is the only thing the answer needs.
///
/// Measured before the fix: at the crate defaults 150 groups was correct, 200
/// returned `0`, and 250 returned `NaN` where the truth is `9.51e-88`. With a
/// small beta it bit sooner — `sigma = beta = 1e-3` returned `NaN` at 60 groups
/// against a true `1.32e-9`, a value that is entirely ordinary.
///
/// For `k` single-member groups with equal means the answer has a closed form,
/// `(beta / sqrt(beta^2 + sigma^2))^(k-1)`, so this checks against arithmetic
/// rather than against a recorded output.
#[test]
fn quality_matches_its_closed_form_past_the_overflow_point() {
for (sigma, beta) in [(25.0 / 3.0, 25.0 / 6.0), (1e-3, 1e-3), (50.0, 25.0 / 6.0)] {
let rating = vec![Gaussian::from_ms(25.0, sigma)];
for k in [2usize, 50, 60, 150, 200, 250, 300] {
let groups: Vec<&[Gaussian]> = (0..k).map(|_| rating.as_slice()).collect();
let got = quality(&groups, beta);
let expected = (beta / (beta * beta + sigma * sigma).sqrt()).powi(k as i32 - 1);
assert!(
got.is_finite(),
"sigma {sigma}, beta {beta}, {k} groups: got {got}"
);
// Subnormal results have no relative precision left to check.
if expected > f64::MIN_POSITIVE {
let rel = ((got - expected) / expected).abs();
assert!(
rel < 1e-11,
"sigma {sigma}, beta {beta}, {k} groups: got {got:e}, \
closed form {expected:e}, rel {rel:e}"
);
}
}
}
}
/// The overflow was in the intermediates, never in the answer: every value
/// above is an ordinary float. This pins the specific case that returned `NaN`
/// where the true answer is nine orders of magnitude inside the normal range.
#[test]
fn a_small_beta_does_not_overflow_at_sixty_groups() {
let rating = vec![Gaussian::from_ms(25.0, 1e-3)];
let groups: Vec<&[Gaussian]> = (0..60).map(|_| rating.as_slice()).collect();
let got = quality(&groups, 1e-3);
assert!((got - 1.317_089e-9).abs() / 1.317_089e-9 < 1e-6, "{got:e}");
}