`predict_outcome` asserted `teams.len() == 2` and returned `[p, 1 - p]`, allocating no probability to a draw even with `p_draw > 0`. For a draw-enabled model the numbers were simply wrong, at any team count. It now returns `Result<Prediction, InferenceError>` and supports N teams. Two algorithms, both deterministic: - Who finishes first. Performances are independent Gaussians, so this separates into a one-dimensional integral per team rather than a multivariate orthant probability. Adaptive Gauss-Kronrod evaluates it to ~1e-15, matching the exact two-team closed form. - A specific finishing order. The factor graph only constrains rank-adjacent teams, so a full order is a chain of local constraints, not a general orthant integral. That chain collapses into a sequential recursion over cumulative integrals: O(teams * grid) per order. Fixed-node Gauss-Hermite is the obvious tool for the first and is a trap: when a rival's sigma is small the CDF product becomes a step narrower than the node spacing, and the nodes step over it. Measured 4.4e-4 off the closed form on a mildly skewed matchup and 1.7e-2 on a small-sigma one, while still returning something that looks like a probability. Adaptive refinement is what makes that case safe, and `win_probabilities_survive_a_rival_with_a_tiny_sigma` pins it down. The acceptance test is an identity rather than a golden: the outcome space is exhaustive and disjoint, so the probabilities sum to one. Any drift is integration error and nothing else. Gauss-Hermite failed it at 4.4e-4; this holds to ~1e-9. Also from #21: unknown keys are now reported rather than dropped, so a team of strangers can no longer produce a confident-looking prediction. `predict_quality` returns `Result` for the same reason. BREAKING CHANGE: `predict_outcome` returns `Result<Prediction, _>` instead of `Vec<f64>`; `predict_quality` returns `Result<f64, _>`. Refs #21, #39 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
120 lines
3.4 KiB
Rust
120 lines
3.4 KiB
Rust
//! `quality()` beyond two rating groups.
|
|
//!
|
|
//! 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 rating groups")]
|
|
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 rating groups")]
|
|
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();
|
|
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}");
|
|
}
|