Three vocabulary collisions, from #75. **"rating" meant three things**, one the opposite of the exported type. `Rating` is documented as static *configuration* — "this returns what it was told", against every other accessor's "what inference inferred". But `quality`'s parameter was `rating_groups: &[&[Gaussian]]` and its prose said "rating groups" four times, where "rating" means a *posterior* — the one thing `Rating` is documented not to be. Two error messages used it that way too. So a reader who learned `Rating = config` passed `Rating` values to `quality`, which takes `Gaussian`; and one who learned "rating = what comes out" was baffled that `h.rating(&k)` is not their skill. "rating" is now reserved for the type. `quality(teams: &[&[Gaussian]])`, and "every rating is finite" became "every posterior is finite". **"agent" was a private fourth name for a competitor** — ~200 identifiers against 236 uses of "competitor", and it leaked into two `pub` signatures on `TimeSlice`. Now that #73 has made those internal this is a pure rename, so the crate has one word for the entity throughout. **"player" survived in one public signature** — `free_for_all(players:)` plus two doc lines. Renamed, along with three internal closure bindings. Doc examples that use "player" as a *key* are left alone: that is a user's data, not the crate's vocabulary. The panic-message expectations in tests/quality.rs moved with the prose, which is the point of asserting on message text — the tests caught the rename rather than papering over it. Not touched: "performance" (always skill widened by beta), "skill", "member" and "team" are each used for exactly one thing already. Refs #75 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
216 lines
7.7 KiB
Rust
216 lines
7.7 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.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]).unwrap();
|
|
assert!(
|
|
q.is_finite(),
|
|
"3-team predict_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}");
|
|
}
|