fix(quality): support any number of rating groups

`quality()` was two-group-only in three separate ways, and
`History::predict_quality` inherited all of them.

- The contrast-matrix column counter tracked two positions with two
  variables that only agree on the first row, so three or more groups wrote
  past the end of the row and panicked with an out-of-bounds index. The
  negative block always begins immediately after the positive one, so the
  second counter is unnecessary.
- `Matrix::inverse` was implemented only for the 1x1 case and otherwise
  `panic!("eh, okey")`. It now uses LU decomposition with partial pivoting,
  which also replaces the recursive cofactor `determinant` — that was O(n!)
  and allocated a `Vec` per minor, so a 10-team match needed 362,880 terms.
- Degenerate inputs (zero groups, one group, empty groups) underflowed or
  produced NaN. They now assert with a message naming the requirement.

`Matrix` also gains dimension checks on multiply/add and bounds checks on
indexing, and loses the now-unused `adjugate`/`minor` cofactor path.

The two-group golden is unchanged. N-group behaviour is covered by
invariants — permutation invariance, and quality falling as a skill gap
widens — since no reference values were available to compare against; the
sublee/trueskill cross-check remains open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
This commit is contained in:
2026-08-04 21:42:04 +02:00
co-authored by Claude Opus 5
parent 6b8bd786d7
commit 0d32690fcc
4 changed files with 467 additions and 126 deletions
+23 -6
View File
@@ -247,7 +247,26 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
}
/// Calculates the match quality of the given rating groups. A result is the draw probability in the association
///
/// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
/// perfectly balanced match.
///
/// # Panics
///
/// Panics if fewer than two rating groups are supplied, or if any group is
/// empty — match quality is a property of a contest between at least two
/// non-empty sides.
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
assert!(
rating_groups.len() >= 2,
"quality() requires at least 2 rating groups, got {}",
rating_groups.len()
);
assert!(
rating_groups.iter().all(|group| !group.is_empty()),
"quality() requires every rating group to be non-empty"
);
let flatten_ratings = rating_groups
.iter()
.flat_map(|group| group.iter())
@@ -271,8 +290,10 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length);
// Row `row` contrasts group `row` (+weight) against group `row + 1`
// (-weight). `t` is the column where the current group's players start;
// the negative block begins immediately after it.
let mut t = 0;
let mut x = 0;
for (row, group) in rating_groups.windows(2).enumerate() {
let current = group[0];
@@ -280,17 +301,13 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
for n in t..t + current.len() {
rotated_a_matrix[(row, n)] = flatten_weights[n];
x += 1;
}
t += current.len();
for n in x..x + next.len() {
for n in t..t + next.len() {
rotated_a_matrix[(row, n)] = -flatten_weights[n];
}
x += next.len();
}
let a_matrix = rotated_a_matrix.transpose();