Commit Graph
6 Commits
Author SHA1 Message Date
logaritmiskandClaude Opus 5 fdd1539cab refactor: one word per concept
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
2026-09-09 20:54:47 +02:00
logaritmiskandClaude Opus 5 f1219036b3 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
2026-09-09 17:33:00 +02:00
logaritmiskandClaude Opus 5 c12bc830a5 feat!: name the unknown key, expose tail probabilities, flag short fits
Three issues from two downstream consumers, all small, all sharing a
theme: the crate had the information and would not hand it over.

#44 — `UnknownKey { team: 0, member: 0 }` did not say which key. A
consumer upgrading 0.1.2 -> 0.4.1 had every one of 5591 predictions
return this error, fell back to a neutral 0.5, and lost its entire
metadata model for a day. Nothing crashed and nothing logged; it was
found by sweeping an unrelated parameter and noticing the output did not
move. The 0.4.0 change that made unknown keys an error was right — the
error was just too anonymous to act on. It now carries the key's `Debug`
rendering, and its `Display` says what to do about it. The precondition
is documented on every prediction entry point, which the reporter said
would alone have saved the day.

#43 — `cdf` was `pub(crate)`, so a consumer asking "is this competitor
below the cutoff" approximated it with a `mu + z * sigma` band and had no
way to say what confidence any `z` bought. Adds
`Gaussian::probability_below` / `probability_above`. The second is
separate on purpose: `1 - cdf` collapses to exactly zero past ~8.3
sigma, and a stopping rule is evaluated precisely there. Both route
through the survival function added in 0.4.1, so this is visibility
rather than new numerics.

#50 — `ConvergenceReport` was not `#[must_use]`, so the one signal that
a fit stopped short was trivially discarded. It now is, and that
immediately found 78 sites doing exactly that — including this crate's
own ATP example, which was capped at 10 sweeps when the history needs
30. The example now reads the report and says so.

`ITERATIONS = 30` is documented as the floor it is, with the three
measurements to hand: 400 events over 100 competitors already stops
there at ~7e-3 against a 1e-6 tolerance, the ATP example needs 30 at a
much looser one, and a consumer's 2000-node model needs 76 to 161.

BREAKING CHANGE: `InferenceError::UnknownKey` gains a `key` field, and
the prediction methods now require `K: Debug` in order to fill it.

Closes #43, #50. Refs #44 — its third ask, an opt-in `UnknownKeys::Skip`
mode, is a live API question and deliberately not answered here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 23:41:03 +02:00
logaritmiskandClaude Opus 5 564969ee5d test: pin quality()'s N-group closed form, closing the README cross-check
The scan for precision defects found none in `quality()` — but it did
find that N identical teams have an exact closed form, which is a much
stronger regression net than the single two-team golden that was there.

For two identical single-player teams quality is
`sqrt(2b^2 / (2b^2 + s1^2 + s2^2))`. With the conventional parameters
that ratio is exactly 1/5, and the N-group generalisation is
`(1/5)^((n-1)/2)` — one factor per adjacent pair. Measured across
n = 2..10 the implementation matches to 1e-9, so the determinant path
that #9 rebuilt is correct over the whole range, not just at n = 2.

The n=3 and n=5 values (0.200 and 0.040) are also what the `trueskill`
Python package produces for the same configuration, which is the
cross-implementation check the README Todo has been asking for since
the redesign. Asserted separately as literals so a change to the
closed-form reasoning cannot silently carry them along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 21:46:46 +02:00
logaritmiskandClaude Opus 5 bb2a845882 feat!: N-team outcome prediction with draw mass, replacing the 2-team panic
`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
2026-09-07 14:55:11 +02:00
logaritmiskandClaude Opus 5 0d32690fcc 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
2026-08-04 21:42:04 +02:00