fix: take quality's determinant ratio in log space

`quality()` computed `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 at the crate defaults: 150 groups correct at 8.45e-53, 200
returned 0, 250 returned NaN where the truth is 9.51e-88. With a small
beta it bit far sooner: at sigma = beta = 1e-3, 60 groups returned NaN
against a true 1.32e-9 — a value nine orders of magnitude inside the
normal range. Neither `quality()` nor `History::predict_quality` caps the
group count, unlike `predict_outcome`, so those are supported calls.

`Lu::ln_abs_determinant` accumulates `ln|diagonal|` instead of
multiplying, and the call site becomes `exp(e_arg + 0.5 * ln_ratio)`.

Verified against the closed form `(beta / sqrt(beta^2 + sigma^2))^(k-1)`
rather than against recorded output, across three parameter sets and
group counts to 300: every case now agrees to 1e-11 or better, including
9.88e-324 at 300 groups, which is subnormal.

Also documents the remaining panic: every rating at zero sigma with a
zero beta makes `middle` singular and `inverse()` panics. Documented
rather than converted — nothing is uncertain there, so there is no
distribution to take the quality of.

Closes #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-09 17:33:00 +02:00
co-authored by Claude Opus 5
parent 31cf0998b0
commit f1219036b3
3 changed files with 109 additions and 2 deletions
+19 -2
View File
@@ -673,6 +673,13 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
/// 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.
///
/// Also panics with "cannot invert a singular matrix" when every rating has
/// zero sigma *and* `beta` is zero. Nothing is then uncertain, so there is no
/// distribution to take the quality of; `Gaussian::from_ms(mu, 0.0)` is a point
/// mass and its `mu()` is not even well defined. Documented rather than
/// converted, because the input has no meaningful answer rather than an
/// awkward one.
#[must_use]
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
assert!(
@@ -738,9 +745,19 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
let end = &rotated_a_matrix * &mean_matrix;
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
let s_arg = ata.determinant() / middle.determinant();
libm::exp(e_arg) * s_arg.sqrt()
// `sqrt(det(ata) / det(middle))`, taken in log space. Both determinants are
// products of `k - 1` diagonal entries, so they leave `f64`'s range long
// before their ratio does: measured at the crate defaults, 150 groups was
// correct at `8.45e-53`, 200 returned `0`, and 250 returned `NaN` where the
// true value is `9.51e-88`. With a small beta it is sharper still — at
// `sigma = beta = 1e-3`, 60 groups returned `NaN` against a true `1.32e-9`.
//
// The ratio is what the answer needs and it is representable throughout, so
// the intermediates are the only thing that ever overflowed.
let ln_s_arg = ata.ln_abs_determinant() - middle.ln_abs_determinant();
libm::exp(e_arg + 0.5 * ln_s_arg)
}
#[cfg(test)]