diff --git a/README.md b/README.md index 02bfcaf..3ef8f48 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is - [x] Add Observer (`Observer` / `NullObserver`) - [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`) - [x] N-team `predict_outcome` with draw mass, and `expected_information_gain` -- [ ] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N-group support works and is covered by invariants, but no reference values are asserted +- [x] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N identical teams follow the closed form `(1/5)^((n-1)/2)` for the conventional parameters, asserted for n = 2..10, and the n=3/n=5 values (0.200, 0.040) match the reference package ## License diff --git a/tests/quality.rs b/tests/quality.rs index 3dcea0f..0fb138e 100644 --- a/tests/quality.rs +++ b/tests/quality.rs @@ -117,3 +117,50 @@ fn history_predict_quality_supports_three_teams() { ); 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> = (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> = (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> = (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); +}