19 Commits
Author SHA1 Message Date
logaritmisk 7da2328692 chore: Release trueskill-tt version 0.8.0 2026-09-08 21:18:53 +02:00
logaritmiskandClaude Opus 5 a73afa5f24 Merge branch 'fix/game-boundary'
Reject malformed games at the Game entry point, which does not pass
through History's ingestion chokepoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:13:02 +02:00
logaritmiskandClaude Opus 5 eebf8aacd3 fix!: reject malformed games at the Game boundary too
I fixed this at `History`'s ingestion chokepoint and said the boundary
was complete. It was not. `Game` is a separate public entry point that
does not pass through that chokepoint, and every one of the same four
defects was still live there:

  Game::ranked(&[&[a]], ..)  -> PANIC at src/game.rs:317
  Game::scored(&[&[a]], ..)  -> PANIC at src/game.rs:317
  Game::ranked(&[&[], &[a]]) -> Ok, finite posterior for the opponent
  Game::scored(.., [NaN, 1]) -> Ok

The same panic, from safe API, in release. Fixing one path and
generalising from it is exactly the mistake that produced the
latest-slice joint bug: validating on the shape that cannot expose the
problem, then reporting the property as held.

`Game::validate_teams` is shared by `ranked` and `scored`, with the
non-finite score check in `scored` alongside it. Ranks need no equivalent
— they are `u32`.

`one_v_one` and `free_for_all` build their teams internally and are
unaffected; a test asserts all three well-formed constructors still
succeed, so the check cannot quietly widen.

BREAKING CHANGE: `Game::ranked` and `Game::scored` return
`NotEnoughTeams`, `EmptyTeam` or `InvalidParameter` for inputs they
previously panicked on or silently accepted.

Refs #18, #26

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:13:02 +02:00
logaritmiskandClaude Opus 5 4e9aa6bdc1 Merge branch 'test/close-coverage-gaps'
Cover non-finite results and color-group disjointness, closing the two
test gaps #26 named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:09:25 +02:00
logaritmiskandClaude Opus 5 a18df521eb test: cover non-finite results and color-group disjointness
The two gaps #26 named that were never filled.

NonFiniteResult had no test at all — the name appeared in `tests/` only
inside a doc comment, and it is the sub-claim in that issue's title. It
turns out to be very much reachable, and from *finite* inputs: sigma at
1e300, beta at 1e300, sigma at 1e-300, score_sigma at 1e-300, and scores
at 1e308 all overflow inside inference, where the boundary checks cannot
see them. That matters because the failure is silent by default — NaN
fails every comparison, so a naive `step < epsilon` reads a NaN step as
converged, which is why the crate has `step_converged`/`step_is_finite`.
Pinned from outside, including that `converge_partial` does not launder a
breakdown into an `Ok`, and with a control asserting merely extreme
parameters still converge so the suite cannot pass by always failing.

Color-group disjointness was #26's fourth acceptance criterion and had
only five hand-written cases. Now a proptest over three shapes: a dense
pool where collisions force colors to multiply, a sparse one where most
events are independent, and repeated members within a single event.

Two of my first assertions were wrong about the code rather than the
reverse. A competitor named twice *within* one event is not a collision —
`color_greedy` collects each event's members into a set for that reason.
And contiguity is not a property of `color_greedy`: it holds only after
`recompute_color_groups` reorders events so each color occupies one
range. The test now asserts what is actually promised — that the reorder
is always *possible*, since the parallel sweep slices `&mut` sub-ranges
from those groups and overlapping ranges would be unsound.

Refs #26

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:09:25 +02:00
logaritmiskandClaude Opus 5 1e4b589a9c Merge branch 'fix/non-finite-weights'
Reject non-finite weights at ingestion, completing the malformed-input
boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:03:17 +02:00
logaritmiskandClaude Opus 5 8b20e0c560 fix!: reject non-finite weights at ingestion
Measured, a NaN weight behaved exactly as `0.0`:

  weight NaN -> Ok, converged: true, 1 iteration, step (0.0, 0.0)
                skill pi 0.027777777777777776, tau 0.0
  weight 0.0 -> Ok, same skill, bit for bit

So a NaN arriving from a division or a parse was indistinguishable from
a deliberate zero, and the fit reported itself as cleanly converged.

Worth correcting an earlier description of this: the event does not
vanish. The member contributes nothing, which is precisely what weight
zero means, and that equivalence is what makes it undetectable rather
than merely wrong.

Zero and negative weights stay accepted. Both are expressible choices
about how much a member contributes, and tests/degenerate_inputs.rs pins
their behaviour deliberately; only values that are not quantities at all
are rejected. A test asserts they still ingest, so the new check cannot
quietly widen.

This completes the boundary: every malformed input that previously
produced a plausible answer — a one-team event, an empty team, a
non-finite score, a non-finite weight — now fails where it enters.

BREAKING CHANGE: an event carrying a non-finite weight returns
`InvalidParameter` instead of silently treating that member as weightless.

Refs #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:03:16 +02:00
logaritmiskandClaude Opus 5 862779ae34 Merge branch 'feat/convergence-strictness'
Make a short fit an error, raise the default iteration cap, validate the
remaining HistoryBuilder parameters, add History::register and
History::rating, reject competitor config conflicts across batches, and
document what the joint's cost scales in.

Closes #50

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 21:00:39 +02:00
logaritmiskandClaude Opus 5 f692906ce4 docs: state what the joint's cost actually scales in
A consumer measured an 8x difference in solve time between two fits over
the same events, the same slices and the same ~2,000 nodes:

  career fit   (gamma = 0)      787 ms
  drifting fit (gamma = 0.15)  6214 ms

Entirely the collapse rule. A competitor with zero drift contributes one
variable however long the history, so a drift-free fit's joint is smaller
than a drifting one's by roughly the slice count — and to factorise, by
its cube. Choosing a drift configuration is therefore also choosing a
query cost, and nothing said so.

Documented on `Joint`, on `Joint::variables` and on `posterior_of`, with
the measurement. `variables()` is named as the number that decides
affordability, since it can be read before committing to a batch.

Also states the thing the consumer proposed as a future optimisation,
because it is already true: an absence is not an appearance, so a
competitor seen in the first and last of a hundred slices contributes two
variables rather than a hundred. The matrix is already as small as the
model allows on that axis.

tests/joint_handle.rs pins the mechanism — ten slices, two competitors,
twenty variables drifting against two at `gamma = 0` — so a change to the
collapse rule cannot quietly remove the property the docs now promise.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 19:45:51 +02:00
logaritmiskandClaude Opus 5 e493f47e99 feat!: add History::register and History::rating, and reject config conflicts across batches
Three things #38 asked for, on a premise that had half dissolved. The
issue argued from "captured at first appearance", "missing it is silent"
and "missing it is permanent"; 8c087ad made configuration apply whenever
supplied and refit the whole history, so two of those are already gone.
What survived is the literal title — no way to say it before the first
event — plus the absence of any way to check.

`register(Member)` states configuration before anything is observed. It
takes the same `Member` ingestion takes, so there is one vocabulary
rather than two, and it creates the competitor immediately, which is what
makes it observable. It reaches a competitor first seen through
`record_winner`, the route #37 deliberately did not extend.

`rating(&key)` reads back what was stored. Every other accessor reports
what inference inferred; this reports what it was told, which is what
makes a configuration mistake detectable from outside the crate at all.

Conflicting configuration is now an error across batches, not only within
one. The `priors` map is rebuilt per `add_events` call, so a second batch
silently overwrote what a first declared, last-write-wins. That cut
directly against the invariant tests/ingestion_equivalence.rs exists to
protect: the same contradictory events errored when batched and
succeeded, order-dependently, when fed one at a time. Detection lives on
a new `declared` map on `History`, because a `Rating` cannot say whether
a value was chosen or inherited from the defaults — which is exactly the
distinction the check needs. Checked before anything mutates, so a
rejected batch leaves the history untouched.

`register` rejects a non-default `weight` rather than ignoring it. Weight
is per-event and has no meaning on a registration, and silently dropping
a field the caller set is the defect this whole area keeps producing.

The declarative `default_rating_for` closure is not here. It is the
better answer for ustat's actual case — thousands of keys matching a
rule, rather than enumerated — but it adds a `Fn` parameter to `History`,
which the issue itself flags as in tension with the crate's posture. That
wants its own decision rather than riding along.

BREAKING CHANGE: two different values for one competitor's `prior` or
`drift_scale` supplied across separate `add_events` calls now return
`ConflictingCompetitorConfig` instead of silently taking the later one.

Refs #38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 16:11:09 +02:00
logaritmiskandClaude Opus 5 4f6360128d feat!: validate mu, sigma and beta on HistoryBuilder
The last three unvalidated setters, beside `p_draw`, `score_sigma` and
`convergence`, which all assert eagerly. Measured before choosing bounds:

  beta = 0        -> works, pi 0.0211 (vs 0.0193 at the default)
  beta = -4.17    -> bit-identical to +4.17
  sigma = -8.33   -> bit-identical to +8.33
  sigma = 0       -> NonFiniteResult, current_skill returns tau: NaN
  sigma = inf     -> same
  mu = NaN        -> same

So the bounds are not the obvious ones. `beta = 0` is legitimate and
meaningful — performance is then exactly skill, and the fit moves
measurably rather than degenerating — so zero is allowed and a test pins
that it reaches a different answer, since "allowed" would otherwise be
indistinguishable from "unchecked".

The negative cases are the quiet ones. `sigma` and `beta` enter inference
only as squares, so a negative value behaves as its absolute value and
the sign is dropped without comment. That is the same defect
`Member::with_drift_scale` already rejects, for the reason already
written there.

The non-finite cases are detected today — `converge` reports
NonFiniteResult — but a caller who reads `current_skill` first is handed
`tau: NaN`, so rejecting at the boundary is what actually closes it.

BREAKING CHANGE: `HistoryBuilder::mu`, `sigma` and `beta` now panic on
values they previously accepted, matching the existing behaviour of
`p_draw`, `score_sigma` and `convergence`.

Refs #18

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 16:06:37 +02:00
logaritmiskandClaude Opus 5 eff63dfa2a feat!: make a short fit an error and raise the default iteration cap
`ITERATIONS` was 30, and overrunning it returned `Ok` with
`converged: false`. Both halves were wrong.

The cap is a runaway guard, not a budget: the sweep exits as soon as the
step falls below `epsilon`, so a high cap costs nothing on a history that
converges. Measured on one needing four sweeps, `max_iter` 30 and
100_000 both finish in 4 iterations and ~130us. So 30 could never make
anything faster — it could only stop a healthy history early, and it did:
160 events over 100 competitors already needs 42.

Not scaled to the history, because iteration count tracks how loopy the
graph is rather than how big it is. At a fixed 320 events over 40 slices,
varying only the competitors sharing them: 3 competitors needs 2_789
sweeps, 10 needs 1_068, 100 needs 90, 400 needs 2. Three orders of
magnitude on identical event and slice counts, so any formula in those
two numbers would be badly wrong on some real shape. A single value set
high enough that reaching it means oscillation is the honest version.

With the cap raised, stopping at it means something is genuinely wrong,
so `converge` now returns `InferenceError::NotConverged` rather than a
flag on a success. A short fit is wrong by a little — every rating
finite, the ordering sensible, nothing saying the numbers were still
moving — and a flag has to be checked while `let _ = h.converge()` is the
natural way not to. That is not hypothetical: it is how a real defect hid
in this crate's own test suite.

`converge_partial` returns the short fit for callers who want one. Only a
single existing test needed it, which is the evidence that a capped fit
is a deliberate choice rather than the common case.

Also corrects the `ITERATIONS` docs, which claimed convergence cost is
"roughly linear in the cap". It is linear in the iterations actually run.

BREAKING CHANGE: `History::converge` returns `Err(NotConverged)` where it
previously returned `Ok` with `converged: false`. Callers that want the
old behaviour should use `History::converge_partial`. The default
`max_iter` changes from 30 to 10_000, so a history that was silently
truncated will now converge properly and its numbers will move.

Closes #50

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 16:04:22 +02:00
logaritmiskandClaude Opus 5 7c6965c6a9 Merge branch 'fix/ingestion-shape'
Reject malformed events at the ingestion boundary, add
EventBuilder::members, and record the rayon opt-in deviation.

Closes #5
Closes #37

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 15:53:13 +02:00
logaritmiskandClaude Opus 5 911b48faba feat: add EventBuilder::members for per-member configuration
`EventBuilder` could set weights and nothing else, so `prior` and
`drift_scale` were reachable only through the typed
`Event`/`Team`/`Member` shape plus `add_events`. Which ingestion route a
competitor arrived through decided whether it could be configured.

`members(...)` takes `Member` values directly, so `Member`'s own builder
expresses everything. `team(...)` stays the common case.

One escape hatch rather than `priors` and `drift_scales` setters beside
`weights`, as the issue suggested and then argued against itself: a
parallel array per field means a parallel length check per field, and
each one is a new way to get the lengths wrong. `Member` already has a
builder; this just lets the fluent path reach it.

`record_winner`/`record_draw` are deliberately left alone. They are the
two-argument convenience path, and extending them would be a breaking
signature change. The issue's reason for wanting them extended has also
weakened: it said a competitor arriving through them was "permanently
stuck on the history defaults", and since 8c087ad that is no longer true
— a later `add_events` carrying the `Member` refits the whole history.
Measured, late configuration through that route reaches mu 40.000000000,
identical to configuring from the start.

Refs #37

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 12:32:00 +02:00
logaritmiskandClaude Opus 5 f57784c141 docs: record the rayon opt-in deviation in spec section 6
Issue #5 asked for a decision, not an implementation: either flip rayon
to default-on, or record why the spec was deviated from and close.

Opt-in stands. The measured speedups are 1.0x realistic / 1.3x
pathological (#4), so default-on would cost every downstream user a
thread pool and a dependency for approximately nothing.

The condition the decision was waiting on cannot be met: #5 was blocked
on re-measuring after cross-slice dirty-bit skipping landed, and #4 was
closed by removing the inert slices_skipped field rather than by
implementing it. There is no forthcoming measurement to wait for.

Also corrects the spec's own reasoning. It cited an unsafe concurrent
write through SkillStore as a cost of going default-on; the crate is
forbid(unsafe_code) and the compute/apply split avoids that entirely.
The case for opt-in is the measurements, not a safety argument.

Closes #5

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 12:29:40 +02:00
logaritmiskandClaude Opus 5 8e4d6a637d fix: reject malformed events at the ingestion boundary
A one-team event reached `run_chain`, which builds one diff link per
adjacent pair of teams, leaving it to index `links[1..]` on an empty
vector. That panicked with "range start index 1 out of range for slice
of length 0" — from `History::add_events`, in a release build, through
entirely safe API.

An empty team was the quieter half of the same gap. It contributes no
performance, so a malformed event converged and handed back a finite,
plausible-looking posterior for whoever it was matched against. That is
this crate's characteristic defect: a public surface reporting a
constant that looks like an answer.

A non-finite score was the third. `converge` did report NonFiniteResult,
so it was detected — but a caller reading `current_skill` before
converging was handed `tau: NaN` with nothing to say so.

`NotEnoughTeams` and `EmptyTeam` already existed. They were checked on
the prediction paths and nowhere else, which is exactly why ingestion
could still manufacture the states they describe. The checks go in
`add_events_with_prior` alongside the tie check, for the same reason
that one is there: every ingestion route lands on it, so `record_winner`,
`record_draw` and `EventBuilder` inherit them rather than each needing
their own.

Also corrects documentation that had been stating the opposite of the
code since 8c087ad in 0.4.0. README.md and the `with_prior` /
`with_drift_scale` doc comments all still said competitor configuration
was "captured at first appearance" and had "no effect" on a known key.
It now applies whenever supplied and refits the whole history. A reader
would have concluded late configuration was impossible and built a
workaround for a limitation that does not exist. CI compiles README code
blocks but not prose, which is why it survived three releases.

The comment in tests/degenerate_inputs.rs claiming a one-team event was
"rejected for an unrelated reason" was wrong when written — it panicked.

Refs #18, #26

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 12:29:14 +02:00
logaritmisk 82eff740b6 chore: Release trueskill-tt version 0.7.0 2026-09-08 10:27:21 +02:00
logaritmiskandClaude Opus 5 c1b1c6c7d7 Merge branch 'feat/joint-handle'
Factorise the joint once with History::joint, so a batch of queries pays
the O(n^3) Cholesky once rather than once per question.

Closes #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 10:24:09 +02:00
logaritmiskandClaude Opus 5 1bb6bb31d8 feat: factorise the joint once with History::joint
`posterior_of`, `posterior_of_at` and `expected_variance_reduction` each
built the joint precision matrix, factorised it, asked one question and
threw it away. The factorisation is O(n^3) in the history's appearances
and depends only on the fit, so a caller asking about every pair in a
standings table, every cell in a grid, or every candidate in an
active-learning sweep paid for the same factorisation once per question.

`History::joint()` returns a `Joint` handle that pays it once. Measured
on 1976 appearances, 90 queries: 68.4s one-shot against 745ms factorise
plus 93ms of queries — 81.6x, with bit-identical answers. Per query,
Criterion at 480 appearances: 9.0ms one-shot against 48us cached, 187x.

The handle borrows the history, which is what makes it correct with no
invalidation logic: the borrow checker forbids adding events or refitting
while it is alive, so there is no window in which the factorisation could
describe a fit that no longer exists. It also makes the lifetime of the
n^2 factor explicit rather than parking it in the history forever — at
4000 appearances that is 128MB, which is not something to cache silently.

Every question the joint answers turns out to be a bilinear form,

    c^T A^-1 a = (L^-1 c) . (L^-1 a)

so no caller ever needs L^-1 c itself. Replacing the general solve with a
forward substitution drops the back substitution as wasted work, halving
a query, and removes a failure mode: a variance as `c . (A^-1 c)` is a
difference of products that can round negative, where `|L^-1 c|^2` is a
sum of squares and cannot.

The one-shot calls are unchanged in cost and now delegate to the handle,
so the two paths cannot drift apart. tests/joint_handle.rs asserts they
agree bit for bit, including at pinned times, under UnknownKeys::Prior,
and across candidate matchups.

Refs #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-08 07:53:51 +02:00
23 changed files with 2638 additions and 247 deletions
+53
View File
@@ -2,12 +2,65 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 0.8.0 - 2026-09-08
### Breaking Changes
- feat!: make a short fit an error and raise the default iteration cap
- feat!: validate mu, sigma and beta on HistoryBuilder
- feat!: add History::register and History::rating, and reject config conflicts across batches
- fix!: reject non-finite weights at ingestion
- fix!: reject malformed games at the Game boundary too
### Bug Fixes
- fix: reject malformed events at the ingestion boundary
### Documentation
- docs: record the rayon opt-in deviation in spec section 6
- docs: state what the joint's cost actually scales in
### Features
- feat: add EventBuilder::members for per-member configuration
### Other (unconventional)
- Merge branch 'fix/ingestion-shape'
- Merge branch 'feat/convergence-strictness'
- Merge branch 'fix/non-finite-weights'
- Merge branch 'test/close-coverage-gaps'
- Merge branch 'fix/game-boundary'
### Testing
- test: cover non-finite results and color-group disjointness
## 0.7.0 - 2026-09-08
### Features
- feat: factorise the joint once with History::joint
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.7.0
### Other (unconventional)
- Merge branch 'feat/joint-handle'
## 0.6.0 - 2026-09-08 ## 0.6.0 - 2026-09-08
### Breaking Changes ### Breaking Changes
- fix!: make the joint span slices, not just the latest one - fix!: make the joint span slices, not just the latest one
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.6.0
## 0.5.0 - 2026-09-08 ## 0.5.0 - 2026-09-08
### Breaking Changes ### Breaking Changes
+5 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "trueskill-tt" name = "trueskill-tt"
version = "0.6.0" version = "0.8.0"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing" description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
@@ -79,3 +79,7 @@ debug = true
[profile.dev] [profile.dev]
debug = true debug = true
[[bench]]
name = "joint"
harness = false
+13 -7
View File
@@ -134,14 +134,20 @@ h.add_events(vec![Event {
h.converge().unwrap(); h.converge().unwrap();
``` ```
Like `with_prior`, the scale is **competitor configuration captured at first Like `with_prior`, the scale is **competitor configuration, not a per-event
appearance** — setting it on a key the history already knows has no effect. It value**: it applies to the competitor for the whole history, and it applies
must be finite and non-negative; ingestion otherwise fails with whenever it is supplied — including on a key the history already knows.
`InferenceError::InvalidParameter`. Configuring one late still refits the whole history rather than taking effect
only from that event onward, because `converge` refits from competitor state.
Repeating the same value is inert; supplying two *different* values for one
competitor within a single batch is `InferenceError::ConflictingCompetitorConfig`,
since events in a batch have no order. The scale must be finite and
non-negative; ingestion otherwise fails with `InferenceError::InvalidParameter`.
Note that the fluent `EventBuilder` (`h.event(t).team([...])`) sets weights but The fluent `EventBuilder` reaches this too: `.team([...])` is the common case
not `drift_scale` or `prior`; those need the typed `Event` / `Team` / `Member` and leaves both unset, while `.members([...])` takes `Member` values directly,
shape shown above. so `h.event(t).members([Member::new("layout_7").with_drift_scale(0.0)])` is
equivalent to the typed shape above.
## Scored outcomes ## Scored outcomes
+71
View File
@@ -0,0 +1,71 @@
//! Cost of the joint posterior: factorising versus querying.
//!
//! The split is the whole point of `History::joint`. Factorising is `O(n^3)` in
//! the history's appearances and depends only on the fit; a query is `O(n^2)`
//! and depends only on the question. `posterior_of_one_shot` pays both every
//! time, `joint_query` pays only the second.
use criterion::{Criterion, criterion_group, criterion_main};
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
/// 30 slices of 8 duels: 480 appearances over 100 competitors.
fn fitted() -> History<i64, ConstantDrift, trueskill_tt::NullObserver, String> {
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.05))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-10,
alpha: 1.0,
})
.build();
let mut events: Vec<Event<i64, String>> = Vec::new();
let mut k = 0usize;
for t in 0..30i64 {
for _ in 0..8 {
k += 1;
events.push(Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(format!("p{}", k % 100))]),
Team::with_members([Member::new(format!("p{}", (k + 37) % 100))]),
],
outcome: Outcome::scores([
(k as f64 * 0.3).sin().abs() * 20.0,
(k as f64 * 0.3).cos().abs() * 20.0,
]),
});
}
}
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
h
}
fn bench_joint(c: &mut Criterion) {
let h = fitted();
let a = "p0".to_string();
let b = "p1".to_string();
let terms = [(&a, 1.0), (&b, -1.0)];
c.bench_function("joint_factorise_480_appearances", |bencher| {
bencher.iter(|| std::hint::black_box(h.joint().unwrap().variables()));
});
c.bench_function("posterior_of_one_shot_480_appearances", |bencher| {
bencher.iter(|| std::hint::black_box(h.posterior_of(&terms).unwrap()));
});
let joint = h.joint().unwrap();
c.bench_function("joint_query_480_appearances", |bencher| {
bencher.iter(|| std::hint::black_box(joint.posterior_of(&terms).unwrap()));
});
}
criterion_group!(benches, bench_joint);
criterion_main!(benches);
@@ -500,6 +500,26 @@ All public traits (`Time`, `Drift`, `Observer`, `Factor`, `Schedule`) require `S
`rayon` as default-on feature; with `default-features = false`, parallel paths fall back to sequential iterators behind `cfg(feature = "rayon")`. `rayon` as default-on feature; with `default-features = false`, parallel paths fall back to sequential iterators behind `cfg(feature = "rayon")`.
> **Not implemented. Deliberate deviation, decided 2026-09-08 (issue #5).**
>
> `rayon` ships **opt-in**: `Cargo.toml` has no `default = [...]` key. The
> measured speedups are 1.0x on realistic workloads and 1.3x on a pathological
> one (issue #4), because typical slices hold too few events to amortize
> rayon's task-spawn overhead. Default-on would hand every downstream user a
> thread pool and a dependency for approximately no gain.
>
> This section made the trade conditional on cross-slice dirty-bit skipping
> landing and changing the parallel story. It did not land: #4 was closed on
> 2026-08-27 by removing the inert `ConvergenceReport::slices_skipped` field
> rather than by implementing the mechanism, so the re-measurement this was
> waiting on will not arrive.
>
> The "Trade-offs" note below also cited an `unsafe` concurrent-write path
> through `SkillStore` as a cost of default-on. That cost does not exist: the
> crate is `#![forbid(unsafe_code)]`, and the compute/apply split on the
> internal `Event` is what lets a color group run in parallel without it. The
> case for opt-in rests on the measurements alone.
### Expected speedup ballpark ### Expected speedup ballpark
For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations: For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations:
@@ -521,7 +541,7 @@ These are pre-implementation estimates. Each tier validates with criterion.
- Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap. - Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap.
- Default = asynchronous EP (preserves current semantics). Synchronous opt-in only. - Default = asynchronous EP (preserves current semantics). Synchronous opt-in only.
- Cross-slice sweep stays sequential; no speculative parallel sweeps. - Cross-slice sweep stays sequential; no speculative parallel sweeps.
- Rayon default-on but feature-gated. - Rayon default-on but feature-gated. **Superseded — shipped opt-in; see the deviation note in Section 6.**
### Open question ### Open question
+118
View File
@@ -191,3 +191,121 @@ mod tests {
assert_eq!(cg.total_events(), 4); assert_eq!(cg.total_events(), 4);
} }
} }
#[cfg(test)]
mod properties {
use std::collections::HashSet;
use proptest::prelude::*;
use super::*;
/// The property the whole parallel sweep rests on: two events sharing a
/// competitor must never land in the same color, because a color group is
/// run concurrently and two events touching one competitor would race.
///
/// Hand-written cases cover the shapes someone thought of. This covers the
/// ones nobody did — the correctness of `sweep_color_groups` depends on it
/// holding for every input, not for five.
fn check(events: &[Vec<usize>]) {
let groups = color_greedy(events.len(), |ev| {
events[ev]
.iter()
.copied()
.map(Index::from)
.collect::<Vec<_>>()
});
// Disjointness *between events* within a color. Deduplicated per
// event, because one event legitimately naming a competitor twice is
// not a collision — `color_greedy` collects each event's members into
// a set for exactly that reason.
for color in 0..groups.n_colors() {
let mut seen: HashSet<usize> = HashSet::new();
for &ev in &groups.groups[color] {
let members: HashSet<usize> = events[ev].iter().copied().collect();
for competitor in members {
assert!(
seen.insert(competitor),
"competitor {competitor} shared by two events in color {color}"
);
}
}
}
// Every event is assigned exactly once. Without this, a partition that
// dropped events would satisfy disjointness trivially.
let mut assigned: Vec<usize> = groups.groups.iter().flatten().copied().collect();
assigned.sort_unstable();
assert_eq!(assigned, (0..events.len()).collect::<Vec<_>>());
assert_eq!(groups.total_events(), events.len());
// No empty colors: one would waste a sweep and make `n_colors`
// misleading.
for (color, group) in groups.groups.iter().enumerate() {
assert!(!group.is_empty(), "color {color} is empty");
}
// Contiguity is not a property of `color_greedy` — it holds only after
// `recompute_color_groups` reorders the events so each color occupies
// one range. What must always hold is that the reorder is *possible*:
// relabelling events in group order yields contiguous groups. The
// parallel sweep slices `&mut` sub-ranges from those, so if this ever
// failed the reorder would produce overlapping ranges.
let mut next = 0usize;
let relabelled: Vec<Vec<usize>> = groups
.groups
.iter()
.map(|group| {
group
.iter()
.map(|_| {
let i = next;
next += 1;
i
})
.collect()
})
.collect();
assert!(ColorGroups { groups: relabelled }.groups_are_contiguous());
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
/// Small competitor pool, so collisions are common and colors are
/// forced to multiply.
#[test]
fn colors_are_disjoint_on_a_dense_pool(
events in prop::collection::vec(
prop::collection::vec(0usize..6, 1..4),
0..20,
)
) {
check(&events);
}
/// Wide pool, so most events are independent and land in one color.
#[test]
fn colors_are_disjoint_on_a_sparse_pool(
events in prop::collection::vec(
prop::collection::vec(0usize..200, 1..6),
0..30,
)
) {
check(&events);
}
/// Repeated competitors within one event must not confuse the
/// member-set bookkeeping.
#[test]
fn colors_are_disjoint_with_repeated_members(
events in prop::collection::vec(
prop::collection::vec(0usize..3, 1..8),
0..15,
)
) {
check(&events);
}
}
}
+10 -3
View File
@@ -62,10 +62,17 @@ impl Default for ConvergenceOptions {
} }
/// Post-hoc summary of a `History::converge` call. /// Post-hoc summary of a `History::converge` call.
///
/// From [`History::converge`](crate::History::converge) this always describes a
/// converged fit — stopping at `max_iter` is
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
/// From [`History::converge_partial`](crate::History::converge_partial) it may
/// not be, and `converged` is what says so.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
#[must_use = "a ConvergenceReport carries `converged`, and a fit that stopped \ #[must_use = "from `converge_partial` this may describe a fit that stopped at \
at `max_iter` is wrong by a little rather than loudly broken — \ `max_iter`, which is wrong by a little rather than loudly \
check it, or bind it to `_` to say you have decided not to"] broken — check `converged`, or bind it to `_` to say you have \
decided not to"]
pub struct ConvergenceReport { pub struct ConvergenceReport {
pub iterations: usize, pub iterations: usize,
pub final_step: (f64, f64), pub final_step: (f64, f64),
+49
View File
@@ -64,6 +64,24 @@ pub enum InferenceError {
/// result has no representable likelihood. Configure a positive `p_draw` /// result has no representable likelihood. Configure a positive `p_draw`
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties. /// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
TieWithoutDrawProbability { teams: (usize, usize) }, TieWithoutDrawProbability { teams: (usize, usize) },
/// The convergence sweep hit `max_iter` with the step still above
/// `epsilon`.
///
/// A fit that stops short is wrong by a little, which is the worst
/// available failure: every rating is finite, the ordering looks sensible,
/// and nothing in the numbers says they were still moving. Reported rather
/// than returned as a flag on an `Ok`, because a flag has to be checked
/// and `let _ = h.converge()` is the natural way not to.
///
/// Either the history needs more iterations — raise `max_iter` — or it is
/// oscillating rather than converging, in which case `alpha < 1.0` damps
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
/// returns the short fit instead when that is genuinely what is wanted.
NotConverged {
iterations: usize,
final_step: (f64, f64),
epsilon: f64,
},
/// Inference produced a non-finite value (NaN or infinity). /// Inference produced a non-finite value (NaN or infinity).
/// ///
/// Indicates numerical breakdown; the resulting skills are meaningless /// Indicates numerical breakdown; the resulting skills are meaningless
@@ -100,6 +118,17 @@ pub enum InferenceError {
member: usize, member: usize,
key: String, key: String,
}, },
/// `History::register` was called for a competitor that already exists.
///
/// Registration states a competitor's configuration before anything has
/// been observed about them, so a competitor that already exists has
/// already been configured — by an earlier `register`, or by an event that
/// created them. Silently overwriting would reintroduce exactly the
/// order-dependence registration exists to remove.
///
/// To change an existing competitor's configuration, supply it on an event
/// through `Member`; that refits the whole history.
AlreadyRegistered { key: String },
/// A prediction was given a team with no members. /// A prediction was given a team with no members.
EmptyTeam { team: usize }, EmptyTeam { team: usize },
/// A joint posterior was requested where one cannot be formed exactly. /// A joint posterior was requested where one cannot be formed exactly.
@@ -144,6 +173,18 @@ impl fmt::Display for InferenceError {
teams.0, teams.1 teams.0, teams.1
) )
} }
Self::NotConverged {
iterations,
final_step,
epsilon,
} => {
write!(
f,
"did not converge in {iterations} iterations: final step {final_step:?} \
is still above epsilon {epsilon}; raise max_iter, or damp with \
alpha < 1.0 if it is oscillating"
)
}
Self::NonFiniteResult { context, step } => { Self::NonFiniteResult { context, step } => {
write!( write!(
f, f,
@@ -167,6 +208,14 @@ impl fmt::Display for InferenceError {
with `lookup` or `current_skill` if that is not guaranteed)" with `lookup` or `current_skill` if that is not guaranteed)"
) )
} }
Self::AlreadyRegistered { key } => {
write!(
f,
"competitor {key} is already registered; registration states \
configuration before anything is observed, so re-registering \
would silently overwrite it"
)
}
Self::EmptyTeam { team } => { Self::EmptyTeam { team } => {
write!(f, "team {team} has no members") write!(f, "team {team} has no members")
} }
+5 -2
View File
@@ -88,7 +88,9 @@ impl<K> Member<K> {
/// Set this competitor's starting skill estimate. /// Set this competitor's starting skill estimate.
/// ///
/// Captured at the competitor's first appearance; see the type docs. /// Competitor configuration, not a per-event value: it applies for the
/// whole history and applies whenever it is supplied, including on a key
/// the history already knows. See the type docs.
pub fn with_prior(mut self, prior: Gaussian) -> Self { pub fn with_prior(mut self, prior: Gaussian) -> Self {
self.prior = Some(prior); self.prior = Some(prior);
self self
@@ -104,7 +106,8 @@ impl<K> Member<K> {
/// shares a scale with moving competitors but should not itself move: a bot /// shares a scale with moving competitors but should not itself move: a bot
/// at a known strength, a rating floor, a course difficulty. /// at a known strength, a rating floor, a course difficulty.
/// ///
/// Captured at the competitor's first appearance; see the type docs. /// Applies for the whole history and whenever it is supplied, including on
/// a key the history already knows; see the type docs.
/// Must be finite and non-negative, or ingestion fails with /// Must be finite and non-negative, or ingestion fails with
/// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter). /// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter).
pub fn with_drift_scale(mut self, scale: f64) -> Self { pub fn with_drift_scale(mut self, scale: f64) -> Self {
+36
View File
@@ -50,6 +50,8 @@ where
} }
/// Add a team by its member keys (weight 1.0 each, no prior overrides). /// Add a team by its member keys (weight 1.0 each, no prior overrides).
///
/// Use [`EventBuilder::members`] to set `prior` or `drift_scale`.
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self { pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect(); let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
self.event.teams.push(Team { members }); self.event.teams.push(Team { members });
@@ -57,6 +59,40 @@ where
self self
} }
/// Add a team from fully-specified [`Member`] values.
///
/// [`EventBuilder::team`] is the common case and builds members with
/// `Member::new`, which leaves `prior` and `drift_scale` unset. This is the
/// escape hatch for when they matter:
///
/// ```
/// # use trueskill_tt::{Gaussian, History, Member};
/// # let mut h = History::builder().build();
/// h.event(0)
/// .team(["player"])
/// .members([Member::new("layout_7")
/// .with_drift_scale(0.0)
/// .with_prior(Gaussian::from_ms(0.0, 1.0))])
/// .ranking([0, 1])
/// .commit()?;
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// One method rather than a `priors` and a `drift_scales` setter beside
/// `weights`: those would have to grow a parallel array — and a parallel
/// length check — every time `Member` gains a field, and each one would be
/// a new way to get the lengths wrong. `Member`'s own builder already
/// expresses all of it.
///
/// `prior` and `drift_scale` are competitor configuration rather than
/// per-event values; see [`Member`] for what that means for a key the
/// history already knows.
pub fn members<I: IntoIterator<Item = Member<K>>>(mut self, members: I) -> Self {
self.event.teams.push(Team::with_members(members));
self.current_team_idx = Some(self.event.teams.len() - 1);
self
}
/// Set per-member weights for the most recently added team. /// Set per-member weights for the most recently added team.
/// ///
/// A length mismatch is recorded and returned by [`EventBuilder::commit`] /// A length mismatch is recorded and returned by [`EventBuilder::commit`]
+39
View File
@@ -431,6 +431,29 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
} }
impl<T: Time, D: Drift<T>> Game<'_, T, D> { impl<T: Time, D: Drift<T>> Game<'_, T, D> {
/// Reject the team shapes inference cannot represent.
///
/// `run_chain` builds one diff link per adjacent pair of teams, so fewer
/// than two teams leaves it indexing `links[1..]` on an empty vector — a
/// panic, in release, from safe API. An empty team is the quiet half: it
/// contributes no performance, so a malformed game returns a finite,
/// plausible-looking posterior for whoever it was matched against.
///
/// `History` validates the same two things at its own ingestion
/// chokepoint. `Game` is a separate public entry point that does not pass
/// through it, so it needs its own check rather than inheriting one.
fn validate_teams(teams: &[&[Rating<T, D>]]) -> Result<(), crate::InferenceError> {
if teams.len() < 2 {
return Err(crate::InferenceError::NotEnoughTeams { got: teams.len() });
}
for (team, members) in teams.iter().enumerate() {
if members.is_empty() {
return Err(crate::InferenceError::EmptyTeam { team });
}
}
Ok(())
}
/// # Errors /// # Errors
/// ///
/// - `InvalidParameter` if `options.convergence` is out of range — an /// - `InvalidParameter` if `options.convergence` is out of range — an
@@ -442,12 +465,15 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
/// - `TieWithoutDrawProbability` if the outcome ties two teams while /// - `TieWithoutDrawProbability` if the outcome ties two teams while
/// `p_draw` is zero: the truncation margin is then zero and the two-sided /// `p_draw` is zero: the truncation margin is then zero and the two-sided
/// tie update evaluates `0/0`. /// tie update evaluates `0/0`.
/// - `NotEnoughTeams` for fewer than two teams, and `EmptyTeam` for a team
/// with no members.
pub fn ranked( pub fn ranked(
teams: &[&[Rating<T, D>]], teams: &[&[Rating<T, D>]],
outcome: crate::Outcome, outcome: crate::Outcome,
options: &GameOptions, options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> { ) -> Result<OwnedGame<T, D>, crate::InferenceError> {
options.convergence.validate()?; options.convergence.validate()?;
Self::validate_teams(teams)?;
if !(0.0..1.0).contains(&options.p_draw) { if !(0.0..1.0).contains(&options.p_draw) {
return Err(crate::InferenceError::InvalidProbability { return Err(crate::InferenceError::InvalidProbability {
value: options.p_draw, value: options.p_draw,
@@ -499,12 +525,15 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
/// or is NaN, or if `options.convergence` is out of range. /// or is NaN, or if `options.convergence` is out of range.
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`. /// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`. /// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
/// - `NotEnoughTeams` for fewer than two teams, `EmptyTeam` for a team with
/// no members, and `InvalidParameter` for a non-finite score.
pub fn scored( pub fn scored(
teams: &[&[Rating<T, D>]], teams: &[&[Rating<T, D>]],
outcome: crate::Outcome, outcome: crate::Outcome,
options: &GameOptions, options: &GameOptions,
) -> Result<OwnedGame<T, D>, crate::InferenceError> { ) -> Result<OwnedGame<T, D>, crate::InferenceError> {
options.convergence.validate()?; options.convergence.validate()?;
Self::validate_teams(teams)?;
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() { if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
return Err(crate::InferenceError::InvalidParameter { return Err(crate::InferenceError::InvalidParameter {
name: "score_sigma", name: "score_sigma",
@@ -526,6 +555,16 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
got: "Outcome::Ranked", got: "Outcome::Ranked",
})? })?
.to_vec(); .to_vec();
// A non-finite score poisons the chain rather than failing it. Ranks
// need no equivalent: they are `u32`.
for value in &scores {
if !value.is_finite() {
return Err(crate::InferenceError::InvalidParameter {
name: "score",
value: *value,
});
}
}
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect(); let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect(); let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
Ok(OwnedGame::new_scored( Ok(OwnedGame::new_scored(
+624 -154
View File
@@ -6,6 +6,7 @@ use crate::{
convergence::{ConvergenceOptions, ConvergenceReport}, convergence::{ConvergenceOptions, ConvergenceReport},
drift::{ConstantDrift, Drift}, drift::{ConstantDrift, Drift},
error::InferenceError, error::InferenceError,
event::Member,
gaussian::Gaussian, gaussian::Gaussian,
key_table::KeyTable, key_table::KeyTable,
observer::{NullObserver, Observer}, observer::{NullObserver, Observer},
@@ -39,17 +40,55 @@ pub struct HistoryBuilder<
} }
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<T, D, O, K> { impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<T, D, O, K> {
/// Prior mean skill.
///
/// # Panics
///
/// Panics if `mu` is not finite. A non-finite prior mean poisons every
/// posterior derived from it: `converge` reports `NonFiniteResult`, but a
/// caller who reads `current_skill` first is handed `tau: NaN`.
pub fn mu(mut self, mu: f64) -> Self { pub fn mu(mut self, mu: f64) -> Self {
assert!(mu.is_finite(), "mu must be finite (got {mu})");
self.mu = mu; self.mu = mu;
self self
} }
/// Prior standard deviation.
///
/// # Panics
///
/// Panics unless `sigma` is finite and strictly positive.
///
/// Zero and infinity both give a prior precision that is not a number, and
/// the whole fit comes back NaN. A *negative* sigma is the quieter half:
/// it is only ever squared, so `-8.33` produces bit-identical results to
/// `8.33` — a sign the caller cannot have meant, silently ignored.
pub fn sigma(mut self, sigma: f64) -> Self { pub fn sigma(mut self, sigma: f64) -> Self {
assert!(
sigma.is_finite() && sigma > 0.0,
"sigma must be finite and positive (got {sigma})"
);
self.sigma = sigma; self.sigma = sigma;
self self
} }
/// Per-event performance noise.
///
/// # Panics
///
/// Panics unless `beta` is finite and non-negative.
///
/// Zero is allowed and meaningful — performance is then exactly skill, and
/// the fit differs measurably from a positive `beta` rather than
/// degenerating. Negative is rejected for the same reason as a negative
/// `sigma` or `Member::with_drift_scale`: `beta` enters only as `beta^2`,
/// so a negative value behaves as its absolute value and the sign is lost
/// without comment.
pub fn beta(mut self, beta: f64) -> Self { pub fn beta(mut self, beta: f64) -> Self {
assert!(
beta.is_finite() && beta >= 0.0,
"beta must be finite and non-negative (got {beta})"
);
self.beta = beta; self.beta = beta;
self self
} }
@@ -169,6 +208,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
convergence: self.convergence, convergence: self.convergence,
observer: self.observer, observer: self.observer,
unknown_keys: self.unknown_keys, unknown_keys: self.unknown_keys,
declared: HashMap::new(),
} }
} }
} }
@@ -276,6 +316,12 @@ pub struct History<
convergence: ConvergenceOptions, convergence: ConvergenceOptions,
observer: O, observer: O,
unknown_keys: crate::UnknownKeys, unknown_keys: crate::UnknownKeys,
/// Competitor configuration explicitly declared so far, by whichever route.
///
/// Kept separate from the applied `Rating` because a `Rating` cannot say
/// whether a value was *chosen* or inherited from the history defaults,
/// and that is exactly the distinction a conflict check needs.
declared: HashMap<Index, CompetitorConfig>,
} }
impl Default for History<i64, ConstantDrift, NullObserver, &'static str> { impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
@@ -455,6 +501,111 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
} }
/// Skill estimate at the latest time slice the competitor appears in. /// Skill estimate at the latest time slice the competitor appears in.
/// Configure a competitor before anything has been observed about them.
///
/// The configuration a competitor needs is often a property of the domain
/// rather than of any one event — "every layout is static", "this bot sits
/// at a known strength". Stating it per-event means every ingestion path
/// has to remember it, and the fluent and two-argument paths could not
/// state it at all.
///
/// ```
/// # use trueskill_tt::{History, Member};
/// let mut h = History::builder().build();
/// h.register(Member::new("layout_7").with_drift_scale(0.0))?;
///
/// // Reaches a competitor first seen through any route, including the
/// // two-argument one, which cannot carry configuration itself.
/// h.record_winner(&"player", &"layout_7", 1)?;
/// assert_eq!(h.rating(&"layout_7").unwrap().drift_scale(), 0.0);
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// The competitor exists from this point on, with no appearances, so
/// [`History::rating`] can read back what was actually stored — the
/// diagnostic that was previously missing entirely.
///
/// `weight` is per-event and has no meaning here, so a `Member` carrying a
/// non-default one is rejected rather than silently ignored.
///
/// # Errors
///
/// `AlreadyRegistered` if the competitor already exists, whether from an
/// earlier `register` or from an event. `InvalidParameter` for a `weight`
/// other than 1.0, or a `drift_scale` that is negative or non-finite.
pub fn register(&mut self, member: Member<K>) -> Result<(), InferenceError>
where
K: std::fmt::Debug,
{
if member.weight != 1.0 {
return Err(InferenceError::InvalidParameter {
name: "weight",
value: member.weight,
});
}
if let Some(scale) = member.drift_scale {
if !scale.is_finite() || scale < 0.0 {
return Err(InferenceError::InvalidParameter {
name: "drift_scale",
value: scale,
});
}
}
let key = format!("{:?}", member.key);
let idx = self.keys.get_or_create(&member.key);
if self.agents.contains(idx) {
return Err(InferenceError::AlreadyRegistered { key });
}
let mut rating = Rating::new(
Gaussian::from_ms(self.mu, self.sigma),
self.beta,
self.drift,
);
if let Some(prior) = member.prior {
rating.prior = prior;
}
if let Some(scale) = member.drift_scale {
rating.drift_scale = scale;
}
self.declared.insert(
idx,
CompetitorConfig {
prior: member.prior,
drift_scale: member.drift_scale,
},
);
self.agents.insert(
idx,
Competitor {
rating,
message: None,
last_time: None,
},
);
Ok(())
}
/// The configuration in force for a competitor, or `None` if the history
/// has never seen them.
///
/// Reads back what was actually stored, which is what makes a
/// configuration mistake detectable from outside the crate. Every other
/// accessor returns what inference *inferred*; this returns what it was
/// told.
#[must_use]
pub fn rating<Q>(&self, key: &Q) -> Option<Rating<T, D>>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let idx = self.keys.get(key)?;
self.agents.contains(idx).then(|| self.agents[idx].rating)
}
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian> pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
where where
K: std::borrow::Borrow<Q>, K: std::borrow::Borrow<Q>,
@@ -950,6 +1101,22 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// per-day or per-event slices, competitors are rarely all present in any /// per-day or per-event slices, competitors are rarely all present in any
/// one of them. Use [`History::posterior_of_at`] to pin a time instead. /// one of them. Use [`History::posterior_of_at`] to pin a time instead.
/// ///
/// # Asking more than one question
///
/// This factorises the joint, uses it once, and throws it away. The
/// factorisation is the expensive part and it depends only on the fit, so
/// asking `n` questions this way pays for it `n` times. Take a
/// [`Joint`] with [`History::joint`] instead — the answers are identical,
/// and only the first one pays.
///
/// # Cost
///
/// A dense solve over the history's *appearances*, not its competitors. A
/// drift-free competitor collapses to a single variable however long the
/// history, so the same events can differ enormously in cost depending on
/// the drift configuration — see [`Joint`], which also amortises this
/// across many questions.
///
/// # Limitations /// # Limitations
/// ///
/// Exact only for a history whose events are all scored, because a scored /// Exact only for a history whose events are all scored, because a scored
@@ -959,10 +1126,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// so a history containing ranked events returns `JointUnavailable` rather /// so a history containing ranked events returns `JointUnavailable` rather
/// than a plausible wrong number. /// than a plausible wrong number.
/// ///
/// Cost is a dense solve over the history's *appearances*, not its
/// competitors: a competitor contributes one variable per slice it appears
/// in, minus any consecutive pair with no drift between them.
///
/// # Errors /// # Errors
/// ///
/// `UnknownKey` for a competitor the history has never seen, and /// `UnknownKey` for a competitor the history has never seen, and
@@ -972,42 +1135,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
where where
K: std::fmt::Debug, K: std::fmt::Debug,
{ {
if self.time_slices.is_empty() { self.joint()?.posterior_of(terms)
return Err(InferenceError::JointUnavailable {
reason: "the history has no events",
});
}
if !self.time_slices.iter().all(TimeSlice::all_scored) {
return Err(InferenceError::JointUnavailable {
reason: "the history contains ranked events, whose EP factors are \
not retained after convergence",
});
}
let TimeExpanded {
lambda,
latest,
width,
..
} = self.time_expanded_joint();
let ResolvedTerms {
contrast,
unseen,
mean,
} = self.resolve_terms(terms, width, |index| latest.get(&index).copied())?;
let z =
crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable {
reason: "the precision matrix is not positive-definite, which means \
a competitor has neither a proper prior nor any evidence",
})?;
let prior_var = self.sigma * self.sigma;
let variance: f64 = contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>()
+ unseen.values().map(|c| c * c * prior_var).sum::<f64>();
Ok(Gaussian::from_mv(mean, variance))
} }
/// Posterior of a linear combination, read as of `time`. /// Posterior of a linear combination, read as of `time`.
@@ -1018,6 +1146,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// stand at the end of last season" — rather than to wherever each /// stand at the end of last season" — rather than to wherever each
/// competitor was last seen. /// competitor was last seen.
/// ///
/// As with [`History::posterior_of`], this factorises the joint for one
/// question; [`History::joint`] amortises that across many.
///
/// # Errors /// # Errors
/// ///
/// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no /// As [`History::posterior_of`], plus `UnknownKey` for a competitor with no
@@ -1026,54 +1157,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
where where
K: std::fmt::Debug, K: std::fmt::Debug,
{ {
if self.time_slices.is_empty() { self.joint()?.posterior_of_at(time, terms)
return Err(InferenceError::JointUnavailable {
reason: "the history has no events",
});
}
if !self.time_slices.iter().all(TimeSlice::all_scored) {
return Err(InferenceError::JointUnavailable {
reason: "the history contains ranked events, whose EP factors are \
not retained after convergence",
});
}
let TimeExpanded {
lambda,
at_slice,
width,
..
} = self.time_expanded_joint();
// Latest appearance at or before `time`, per competitor.
let mut as_of: HashMap<Index, (usize, usize)> = HashMap::new();
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
if slice.time > time {
break;
}
for (agent, _) in slice.appearances() {
if let Some(row) = at_slice.get(&(agent, slice_idx)) {
as_of.insert(agent, (*row, slice_idx));
}
}
}
let ResolvedTerms {
contrast,
unseen,
mean,
} = self.resolve_terms(terms, width, |index| as_of.get(&index).copied())?;
let z =
crate::joint::solve_spd(lambda, &contrast).ok_or(InferenceError::JointUnavailable {
reason: "the precision matrix is not positive-definite",
})?;
let prior_var = self.sigma * self.sigma;
let variance: f64 = contrast.iter().zip(&z).map(|(c, z)| c * z).sum::<f64>()
+ unseen.values().map(|c| c * c * prior_var).sum::<f64>();
Ok(Gaussian::from_mv(mean, variance))
} }
/// How much observing this matchup would shrink the variance of `target`. /// How much observing this matchup would shrink the variance of `target`.
@@ -1089,6 +1173,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// score. It is also far cheaper: one linear solve rather than a full /// score. It is also far cheaper: one linear solve rather than a full
/// inference pass per possible outcome. /// inference pass per possible outcome.
/// ///
/// Scoring a field of candidates is the whole point of this call, and each
/// candidate is one question against an unchanged fit — so use
/// [`Joint::expected_variance_reduction`] for anything past a single
/// candidate, or pay for the factorisation once per candidate.
///
/// # There is no expectation to take /// # There is no expectation to take
/// ///
/// Observing a scored event is a rank-one update to the precision matrix, /// Observing a scored event is a rank-one update to the precision matrix,
@@ -1118,14 +1207,56 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
where where
K: std::fmt::Debug, K: std::fmt::Debug,
{ {
if teams.len() != 2 { self.joint()?.expected_variance_reduction(teams, target)
return Err(InferenceError::MismatchedShape {
kind: "expected_variance_reduction takes exactly 2 teams",
expected: 2,
got: teams.len(),
});
} }
/// Factorise the joint posterior once, to answer many questions against it.
///
/// [`History::posterior_of`] and its neighbours each build and factorise
/// the joint, use it once, and drop it. The factorisation is `O(n^3)` in
/// the history's *appearances* and depends only on the fit, so a caller
/// asking about every pair in a standings table, every cell in a grid, or
/// every candidate in an active-learning sweep pays for the same
/// factorisation once per question.
///
/// A `Joint` pays it once. Each subsequent query is `O(n^2)` — one forward
/// substitution — and returns exactly what the one-shot call would.
///
/// ```
/// # use smallvec::smallvec;
/// # use trueskill_tt::{Event, History, Member, Outcome, Team};
/// # let mut h = History::builder().score_sigma(1.0).build();
/// # let round = |x, y, sx, sy, t| Event {
/// # time: t,
/// # teams: smallvec![
/// # Team::with_members([Member::new(x)]),
/// # Team::with_members([Member::new(y)]),
/// # ],
/// # outcome: Outcome::scores([sx, sy]),
/// # };
/// # h.add_events(vec![
/// # round("a", "b", 3.0, 1.0, 1),
/// # round("b", "c", 2.0, 1.0, 2),
/// # ]).unwrap();
/// # h.converge().unwrap();
/// let joint = h.joint()?;
/// for (a, b) in [("a", "b"), ("a", "c"), ("b", "c")] {
/// let gap = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)])?;
/// println!("{a} - {b}: {:.3} +/- {:.3}", gap.mu(), gap.sigma());
/// }
/// # Ok::<(), trueskill_tt::InferenceError>(())
/// ```
///
/// The handle borrows the history, so the borrow checker enforces what a
/// cache would otherwise have to invalidate: no events can be added and no
/// refit can run while it is alive. Drop it to release the factorisation,
/// which is `n^2` floats and is the largest thing this crate allocates.
///
/// # Errors
///
/// `JointUnavailable` if the history is empty, contains ranked events, or
/// yields a precision matrix that is not positive-definite.
pub fn joint(&self) -> Result<Joint<'_, T, D, O, K>, InferenceError> {
if self.time_slices.is_empty() { if self.time_slices.is_empty() {
return Err(InferenceError::JointUnavailable { return Err(InferenceError::JointUnavailable {
reason: "the history has no events", reason: "the history has no events",
@@ -1138,70 +1269,28 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}); });
} }
// The candidate matchup, expressed as the same kind of linear
// functional as the target.
let mut matchup: Vec<(&K, f64)> = Vec::new();
let mut noise = self.score_sigma * self.score_sigma;
for (team_idx, team) in teams.iter().enumerate() {
if team.is_empty() {
return Err(InferenceError::EmptyTeam { team: team_idx });
}
let sign = if team_idx == 0 { 1.0 } else { -1.0 };
for key in team.iter() {
matchup.push((*key, sign));
let beta = self
.keys
.get(*key)
.map_or(self.beta, |index| self.agents[index].rating.beta);
noise += beta * beta;
}
}
let TimeExpanded { let TimeExpanded {
lambda, lambda,
latest, latest,
at_slice,
width, width,
..
} = self.time_expanded_joint(); } = self.time_expanded_joint();
let target = self.resolve_terms(target, width, |i| latest.get(&i).copied())?; let cholesky = crate::joint::Cholesky::factor(lambda, width).ok_or(
let matchup = self.resolve_terms(&matchup, width, |i| latest.get(&i).copied())?;
let (target_contrast, target_unseen) = (target.contrast, target.unseen);
let (matchup_contrast, matchup_unseen) = (matchup.contrast, matchup.unseen);
// One solve: z = L^-1 a serves both inner products, since
// c^T L^-1 a = c^T z and a^T L^-1 a = a^T z.
let z = crate::joint::solve_spd(lambda, &matchup_contrast).ok_or(
InferenceError::JointUnavailable { InferenceError::JointUnavailable {
reason: "the precision matrix is not positive-definite", reason: "the precision matrix is not positive-definite, which means \
a competitor has neither a proper prior nor any evidence",
}, },
)?; )?;
let prior_var = self.sigma * self.sigma; Ok(Joint {
// Competitors outside the history are independent, so they contribute history: self,
// only where the same key appears in both functionals. cholesky,
let cross_unseen: f64 = target_unseen latest,
.iter() at_slice,
.map(|(k, tc)| tc * matchup_unseen.get(k).copied().unwrap_or(0.0) * prior_var) width,
.sum(); })
let self_unseen: f64 = matchup_unseen.values().map(|c| c * c * prior_var).sum();
let cross: f64 = target_contrast
.iter()
.zip(&z)
.map(|(c, z)| c * z)
.sum::<f64>()
+ cross_unseen;
let matchup_var: f64 = matchup_contrast
.iter()
.zip(&z)
.map(|(a, z)| a * z)
.sum::<f64>()
+ self_unseen;
Ok(cross * cross / (noise + matchup_var))
} }
/// Predictive distribution of the score margin between two teams. /// Predictive distribution of the score margin between two teams.
/// ///
/// Answers "what will the gap be, and how wide is that interval" for a /// Answers "what will the gap be, and how wide is that interval" for a
@@ -1460,17 +1549,62 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
)) ))
} }
/// Run the full forward+backward convergence loop and return a summary. /// Run the full forward+backward convergence loop to a fixed point.
/// ///
/// Failing to reach `epsilon` within `max_iter` is not an error: the /// # Stopping short is an error
/// returned report carries `converged: false` and the final step. ///
/// Hitting `max_iter` without reaching `epsilon` returns `NotConverged`.
///
/// It used to return `Ok` with `converged: false`, which was the worst
/// available shape. A fit that stops short is *wrong by a little*: every
/// rating is finite, the ordering looks sensible, and nothing about the
/// output says the numbers were still moving. Detection was opt-in, and
/// `let _ = h.converge()` silently opted out — which is how a real defect
/// hid in this crate's own test suite.
///
/// The default `max_iter` is [`ITERATIONS`](crate::ITERATIONS), which is
/// set high enough that reaching it means something is genuinely wrong
/// rather than that the history is merely large. Raising the cap costs
/// nothing when it is not needed, because the loop exits at `epsilon`.
///
/// Use [`History::converge_partial`] when a capped, unconverged fit is
/// what you actually want.
/// ///
/// # Errors /// # Errors
/// ///
/// `NotConverged` if the sweep hits `max_iter` with the step still above
/// `epsilon`.
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has /// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
/// broken down at that point and further iterations cannot recover, so the /// broken down at that point and further iterations cannot recover, so the
/// loop stops rather than reporting a NaN step as convergence. /// loop stops rather than reporting a NaN step as convergence.
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> { pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
let report = self.converge_partial()?;
if report.converged {
Ok(report)
} else {
Err(InferenceError::NotConverged {
iterations: report.iterations,
final_step: report.final_step,
epsilon: self.convergence.epsilon,
})
}
}
/// As [`History::converge`], but a fit that stops at `max_iter` is
/// returned rather than reported as an error.
///
/// The report's `converged` flag says which happened. Use this when a
/// deliberately capped sweep is the point — a cheap approximate fit, or a
/// test that pins what a fixed number of iterations produces. Prefer
/// `converge` everywhere else: an unconverged fit that nobody checks is
/// indistinguishable from a converged one.
///
/// # Errors
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step.
pub fn converge_partial(&mut self) -> Result<ConvergenceReport, InferenceError> {
use std::time::Instant; use std::time::Instant;
use smallvec::SmallVec; use smallvec::SmallVec;
@@ -1575,6 +1709,73 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}); });
} }
// Chokepoint for event shape, for the same reason as the tie check
// below: every ingestion route lands here.
//
// `run_chain` builds one diff link per adjacent pair of teams, so a
// one-team event leaves it with an empty link vector and panics
// indexing `links[1..]` — a reachable panic from safe API, in release.
// An empty team is the quieter half: it contributes no performance,
// so a malformed event yields a finite, plausible-looking posterior
// for whoever it was matched against.
//
// Both errors already existed; they were only ever checked on the
// prediction paths, which is why ingestion could still produce them.
for teams in &composition {
if teams.len() < 2 {
return Err(InferenceError::NotEnoughTeams { got: teams.len() });
}
for (team, members) in teams.iter().enumerate() {
if members.is_empty() {
return Err(InferenceError::EmptyTeam { team });
}
}
}
// A non-finite outcome poisons the history rather than failing it:
// `converge` does report `NonFiniteResult`, but a caller who reads
// `current_skill` before converging is handed a NaN posterior with
// nothing to say it is one.
if let Some(results) = results.as_ref() {
for (event_results, kind) in results.iter().zip(kinds.iter()) {
let name = match kind {
EventKind::Ranked => "rank",
EventKind::Scored { .. } => "score",
};
for value in event_results {
if !value.is_finite() {
return Err(InferenceError::InvalidParameter {
name,
value: *value,
});
}
}
}
}
// A non-finite weight is not a weight. Measured, it behaves exactly as
// `0.0` — the member contributes nothing — while `converge` reports
// `converged: true` after one iteration with a step of `(0.0, 0.0)`.
// So a NaN arriving from a division or a parse is indistinguishable
// from a deliberate zero, and looks like a clean fit.
//
// Zero and negative weights stay accepted: both are expressible
// choices about how much a member contributes, and
// `tests/degenerate_inputs.rs` pins them deliberately. Only the values
// that are not quantities at all are rejected.
if let Some(weights) = weights.as_ref() {
for team_weights in weights.iter().flatten() {
for weight in team_weights {
if !weight.is_finite() {
return Err(InferenceError::InvalidParameter {
name: "weight",
value: *weight,
});
}
}
}
}
// Chokepoint for tie validation: every ingestion route lands here, // Chokepoint for tie validation: every ingestion route lands here,
// including `record_draw`, which builds its results directly rather // including `record_draw`, which builds its results directly rather
// than going through `Outcome`. // than going through `Outcome`.
@@ -1590,6 +1791,47 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
} }
} }
// Cross-batch conflict. The in-batch check upstream rejects one batch
// that sets a field twice; `priors` is rebuilt per call, so without
// this a *second* batch could quietly overwrite what a first one
// declared, last-write-wins.
//
// That asymmetry cut against the invariant `tests/ingestion_equivalence.rs`
// exists to protect: the same contradictory events errored when
// batched and succeeded, order-dependently, when fed one at a time.
// Checked before anything mutates, so a rejected batch leaves the
// history untouched.
for (agent, batch) in &priors {
let held = self.declared.get(agent).copied().unwrap_or_default();
if let (Some(existing), Some(new)) = (held.prior, batch.prior) {
if existing != new {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: agent.get(),
field: "prior",
});
}
}
if let (Some(existing), Some(new)) = (held.drift_scale, batch.drift_scale) {
if existing != new {
return Err(InferenceError::ConflictingCompetitorConfig {
competitor: agent.get(),
field: "drift_scale",
});
}
}
}
for (agent, batch) in &priors {
let entry = self.declared.entry(*agent).or_default();
if batch.prior.is_some() {
entry.prior = batch.prior;
}
if batch.drift_scale.is_some() {
entry.drift_scale = batch.drift_scale;
}
}
competitor::clean(self.agents.values_mut(), true); competitor::clean(self.agents.values_mut(), true);
let mut this_agent = Vec::with_capacity(1024); let mut this_agent = Vec::with_capacity(1024);
@@ -1601,7 +1843,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
this_agent.push(*agent); this_agent.push(*agent);
let config = priors.get(agent).copied().unwrap_or_default(); // From `declared` rather than `priors`: a competitor configured by
// `register` before any event has nothing in this batch's map.
let config = self.declared.get(agent).copied().unwrap_or_default();
if self.agents.contains(*agent) { if self.agents.contains(*agent) {
// Seeding a competitor the history already knows. This used to // Seeding a competitor the history already knows. This used to
@@ -1980,6 +2224,228 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
} }
} }
/// A factorised joint posterior, reusable across many queries.
///
/// Built by [`History::joint`]. Every question the joint answers — the width of
/// a contrast, the covariance of two, how much a candidate matchup would
/// sharpen either — is a bilinear form in the inverse precision matrix, and all
/// of them share one factorisation. That factorisation is the whole cost:
/// `O(n^3)` in the history's appearances to build, `O(n^2)` per question after.
///
/// The handle borrows the history, so no refit can run and no events can be
/// added while it is alive. That is what makes it correct without any
/// invalidation logic: there is no window in which the factorisation could
/// describe a fit that no longer exists.
///
/// # What the cost actually scales in
///
/// Not competitors, and not slices times competitors. One variable per
/// *appearance* — a competitor per slice they appear in — minus every
/// consecutive pair with no drift between them, which collapse to a single
/// latent variable.
///
/// That last clause dominates, and it is not obvious. A competitor whose drift
/// is zero contributes **one** variable however long the history: whole-history
/// `gamma = 0`, or `drift_scale = 0` on that competitor. So two fits over the
/// same events and the same slices can differ in problem size by roughly the
/// slice count, and in factorisation time by its cube. Measured by a consumer
/// on a ~2,000-node model over 76 slices:
///
/// ```text
/// career fit (gamma = 0) 787 ms per solve
/// drifting fit (gamma = 0.15) 6214 ms per solve
/// ```
///
/// Choosing between a drifting and a drift-free configuration is therefore also
/// choosing an 8x difference in query cost. [`Joint::variables`] reports the
/// number that decides it, and can be read before committing to a batch of
/// queries.
///
/// Slices a competitor sits out cost nothing: an absence is not an appearance,
/// so a competitor seen in the first and last of a hundred slices contributes
/// two variables, not a hundred.
pub struct Joint<'h, T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> {
history: &'h History<T, D, O, K>,
cholesky: crate::joint::Cholesky,
/// `(row, slice)` of each competitor's latest appearance.
latest: HashMap<Index, (usize, usize)>,
/// Row of each `(competitor, slice)` appearance.
at_slice: HashMap<(Index, usize), usize>,
/// Side length of the precision matrix.
width: usize,
}
/// Deliberately does not print the factorisation, which is `n^2` floats and
/// would make a `{:?}` of a large joint unreadable and slow.
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> std::fmt::Debug
for Joint<'_, T, D, O, K>
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Joint")
.field("variables", &self.width)
.finish_non_exhaustive()
}
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D, O, K> {
/// Number of variables in the joint: the history's appearances, after
/// collapsing consecutive pairs a competitor does not drift between.
///
/// This is what the cost scales in — `O(n^3)` to factorise, `O(n^2)` per
/// query — and it is neither the competitor count nor slices times
/// competitors. A drift-free competitor contributes one variable however
/// many slices they appear in; see the type docs for how large that
/// difference gets.
///
/// Worth reading before committing to a batch of queries: it is the one
/// number that says whether a joint over this history is affordable.
#[must_use]
pub fn variables(&self) -> usize {
self.width
}
/// Turn a resolved functional into its posterior.
///
/// The variance is `|L^-1 c|^2` over the competitors the history knows,
/// plus an independent prior variance for each competitor it does not —
/// unseen competitors are uncorrelated with everything by construction.
fn distribution(&self, resolved: &ResolvedTerms) -> Gaussian {
let y = self.cholesky.whiten(&resolved.contrast);
let prior_var = self.history.sigma * self.history.sigma;
let variance = crate::joint::bilinear(&y, &y)
+ resolved
.unseen
.values()
.map(|c| c * c * prior_var)
.sum::<f64>();
Gaussian::from_mv(resolved.mean, variance)
}
/// Posterior of a linear combination of competitors' skills.
///
/// Identical to [`History::posterior_of`], including which appearance each
/// competitor is read at, without re-paying the factorisation.
///
/// # Errors
///
/// `UnknownKey` for a competitor the history has never seen.
pub fn posterior_of(&self, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError>
where
K: std::fmt::Debug,
{
let resolved = self
.history
.resolve_terms(terms, self.width, |index| self.latest.get(&index).copied())?;
Ok(self.distribution(&resolved))
}
/// Posterior of a linear combination, read as of `time`.
///
/// Identical to [`History::posterior_of_at`] without re-paying the
/// factorisation.
///
/// # Errors
///
/// `UnknownKey` for a competitor with no appearance at or before `time`.
pub fn posterior_of_at(&self, time: T, terms: &[(&K, f64)]) -> Result<Gaussian, InferenceError>
where
K: std::fmt::Debug,
{
let as_of = self.rows_as_of(time);
let resolved = self
.history
.resolve_terms(terms, self.width, |index| as_of.get(&index).copied())?;
Ok(self.distribution(&resolved))
}
/// Latest appearance at or before `time`, per competitor.
fn rows_as_of(&self, time: T) -> HashMap<Index, (usize, usize)> {
let mut as_of: HashMap<Index, (usize, usize)> = HashMap::new();
for (slice_idx, slice) in self.history.time_slices.iter().enumerate() {
if slice.time > time {
break;
}
for (agent, _) in slice.appearances() {
if let Some(row) = self.at_slice.get(&(agent, slice_idx)) {
as_of.insert(agent, (*row, slice_idx));
}
}
}
as_of
}
/// How much observing this matchup would shrink the variance of `target`.
///
/// Identical to [`History::expected_variance_reduction`] without re-paying
/// the factorisation, which is the shape this call is normally used in:
/// one target, a field of candidate matchups, one unchanged fit.
///
/// # Errors
///
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam` for
/// an empty one, and `UnknownKey` for an unseen competitor.
pub fn expected_variance_reduction(
&self,
teams: &[&[&K]],
target: &[(&K, f64)],
) -> Result<f64, InferenceError>
where
K: std::fmt::Debug,
{
if teams.len() != 2 {
return Err(InferenceError::MismatchedShape {
kind: "expected_variance_reduction takes exactly 2 teams",
expected: 2,
got: teams.len(),
});
}
// The candidate matchup, expressed as the same kind of linear
// functional as the target.
let mut matchup: Vec<(&K, f64)> = Vec::new();
let mut noise = self.history.score_sigma * self.history.score_sigma;
for (team_idx, team) in teams.iter().enumerate() {
if team.is_empty() {
return Err(InferenceError::EmptyTeam { team: team_idx });
}
let sign = if team_idx == 0 { 1.0 } else { -1.0 };
for key in team.iter() {
matchup.push((*key, sign));
let beta = self
.history
.keys
.get(*key)
.map_or(self.history.beta, |index| {
self.history.agents[index].rating.beta
});
noise += beta * beta;
}
}
let row_for = |index: Index| self.latest.get(&index).copied();
let target = self.history.resolve_terms(target, self.width, row_for)?;
let matchup = self.history.resolve_terms(&matchup, self.width, row_for)?;
let y_target = self.cholesky.whiten(&target.contrast);
let y_matchup = self.cholesky.whiten(&matchup.contrast);
let prior_var = self.history.sigma * self.history.sigma;
// Competitors outside the history are independent, so they contribute
// only where the same key appears in both functionals.
let cross_unseen: f64 = target
.unseen
.iter()
.map(|(k, tc)| tc * matchup.unseen.get(k).copied().unwrap_or(0.0) * prior_var)
.sum();
let self_unseen: f64 = matchup.unseen.values().map(|c| c * c * prior_var).sum();
let cross = crate::joint::bilinear(&y_target, &y_matchup) + cross_unseen;
let matchup_var = crate::joint::bilinear(&y_matchup, &y_matchup) + self_unseen;
Ok(cross * cross / (noise + matchup_var))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use approx::assert_ulps_eq; use approx::assert_ulps_eq;
@@ -2636,13 +3102,15 @@ mod tests {
epsilon = 1e-6 epsilon = 1e-6
); );
// run exactly 11 iterations (old test used convergence(11, ...)) // Run exactly 11 iterations. `converge_partial` rather than
// `converge`: stopping at the cap is the point here, and `converge`
// now reports that as `NotConverged`.
h.convergence = ConvergenceOptions { h.convergence = ConvergenceOptions {
max_iter: 11, max_iter: 11,
epsilon: EPSILON, epsilon: EPSILON,
alpha: 1.0, alpha: 1.0,
}; };
let _ = h.converge().unwrap(); let _ = h.converge_partial().unwrap();
let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt(); let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt();
@@ -3009,7 +3477,9 @@ mod tests {
}) })
.build(); .build();
events_for(&mut h_capped); events_for(&mut h_capped);
let _ = h_capped.converge().unwrap(); // A one-iteration cap is deliberate here, so the short fit is the
// result rather than an error.
let _ = h_capped.converge_partial().unwrap();
let mut h_full: History<i64, _, _, &'static str> = History::builder().build(); let mut h_full: History<i64, _, _, &'static str> = History::builder().build();
events_for(&mut h_full); events_for(&mut h_full);
+100 -47
View File
@@ -1,34 +1,54 @@
//! Posterior of a linear combination of competitors. //! Cholesky factorisation of a joint precision matrix.
//! //!
//! Every accessor on `History` returns a per-competitor marginal, and almost //! Every question the joint answers is a *bilinear form* in the precision
//! nothing a consumer publishes is one competitor: "can we tell these two //! matrix's inverse — the variance of a contrast is `c^T L^-1 c`, and the
//! apart" is a difference, "what was this round worth" is a sum. Combining //! covariance of two contrasts is `c^T L^-1 a`. None of them wants `L^-1 c`
//! marginals means assuming the competitors are independent, and they are //! itself, which is what makes the shape here worth stating explicitly.
//! correlated through every event they share — which is the mechanism the model
//! exists to exploit.
//! //!
//! Measured on a five-competitor round robin, the exact correlation is +0.857, //! Writing the precision as `A = L L^T`,
//! so `sqrt(sa^2 + sb^2)` overstates the width of a difference by 2.6x. //!
//! ```text
//! c^T A^-1 a = c^T L^-T L^-1 a = (L^-1 c) . (L^-1 a)
//! ```
//!
//! so a single forward substitution per contrast answers everything, and the
//! back substitution a general solve would do is wasted work. That halves the
//! cost of a query, and it removes a failure mode: a variance computed as
//! `c . (A^-1 c)` is a difference of products that can round to a small
//! negative number, where the same quantity as `|L^-1 c|^2` is a sum of
//! squares and cannot.
//!
//! Factorising is `O(n^3)` and whitening is `O(n^2)`, so the split also
//! matters structurally: the expensive half depends only on the fit, and is
//! shared across every query a [`Joint`](crate::Joint) answers.
/// Solve `A z = b` for a symmetric positive-definite `A`, by Cholesky. /// A factorised symmetric positive-definite matrix, reusable across queries.
/// pub(crate) struct Cholesky {
/// `a` is row-major and is consumed as scratch. /// Lower triangle of `L`, row-major `n * n`. The upper triangle is
/// /// leftover scratch from the factorisation and is never read.
/// Returns `None` if the matrix is not positive-definite, which for a precision l: Vec<f64>,
/// matrix means the model is improper — a competitor with no prior and no n: usize,
/// evidence. }
pub(crate) fn solve_spd(mut a: Vec<f64>, b: &[f64]) -> Option<Vec<f64>> {
let n = b.len(); impl Cholesky {
/// Factorise `a` (row-major, `n * n`, symmetric) into `L L^T`.
///
/// `a` is consumed as scratch.
///
/// Returns `None` if the matrix is not positive-definite, which for a
/// precision matrix means the model is improper — a competitor with
/// neither a proper prior nor any evidence.
pub(crate) fn factor(mut a: Vec<f64>, n: usize) -> Option<Self> {
debug_assert_eq!(a.len(), n * n); debug_assert_eq!(a.len(), n * n);
// In-place Cholesky: A = L L^T, lower triangle.
for j in 0..n { for j in 0..n {
let mut d = a[j * n + j]; let mut d = a[j * n + j];
for k in 0..j { for k in 0..j {
d -= a[j * n + k] * a[j * n + k]; d -= a[j * n + k] * a[j * n + k];
} }
// Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here too, // Explicit rather than `!(d > 0.0)`: a NaN pivot must fail here
// and a negated comparison would let it through as "not positive". // too, and a negated comparison would let it through as "not
// positive".
if d.is_nan() || d <= 0.0 { if d.is_nan() || d <= 0.0 {
return None; return None;
} }
@@ -44,56 +64,89 @@ pub(crate) fn solve_spd(mut a: Vec<f64>, b: &[f64]) -> Option<Vec<f64>> {
} }
} }
// Forward substitution, then back substitution. Some(Self { l: a, n })
let mut z = b.to_vec();
for i in 0..n {
let mut s = z[i];
for k in 0..i {
s -= a[i * n + k] * z[k];
}
z[i] = s / a[i * n + i];
}
for i in (0..n).rev() {
let mut s = z[i];
for k in i + 1..n {
s -= a[k * n + i] * z[k];
}
z[i] = s / a[i * n + i];
} }
Some(z) /// Whiten a contrast: `y = L^-1 b`.
///
/// The point of the result is the dot product, not the vector: for two
/// contrasts `b` and `b'`, `y . y'` is `b^T A^-1 b'`. See the module docs.
pub(crate) fn whiten(&self, b: &[f64]) -> Vec<f64> {
debug_assert_eq!(b.len(), self.n);
let n = self.n;
let mut y = b.to_vec();
for i in 0..n {
// Folded from `y[i]` rather than summed and subtracted once, so the
// accumulation order matches a plain substitution loop exactly.
let row = &self.l[i * n..i * n + i];
let s = row
.iter()
.zip(&y[..i])
.fold(y[i], |acc, (l, v)| acc - l * v);
y[i] = s / self.l[i * n + i];
}
y
}
}
/// `b^T A^-1 b'`, given the two whitened contrasts.
pub(crate) fn bilinear(y: &[f64], y_prime: &[f64]) -> f64 {
y.iter().zip(y_prime).map(|(a, b)| a * b).sum()
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// `[[4, 1], [1, 3]] z = [1, 2]` has `z = [1/11, 7/11]`, so the quadratic
/// form `b^T A^-1 b` is `1 * 1/11 + 2 * 7/11 = 15/11`.
#[test] #[test]
fn solves_a_known_system() { fn reproduces_a_known_quadratic_form() {
// [[4, 1], [1, 3]] z = [1, 2] => z = [1/11, 7/11] let c = Cholesky::factor(vec![4.0, 1.0, 1.0, 3.0], 2).unwrap();
let a = vec![4.0, 1.0, 1.0, 3.0]; let y = c.whiten(&[1.0, 2.0]);
let z = solve_spd(a, &[1.0, 2.0]).unwrap(); assert!((bilinear(&y, &y) - 15.0 / 11.0).abs() < 1e-12);
assert!((z[0] - 1.0 / 11.0).abs() < 1e-12, "{z:?}");
assert!((z[1] - 7.0 / 11.0).abs() < 1e-12, "{z:?}");
} }
/// Whitening `e_i` recovers the inverse's diagonal, which is the variance
/// of a single variable.
#[test] #[test]
fn recovers_the_inverse_diagonal() { fn recovers_the_inverse_diagonal() {
// A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is // A = [[2, -1, 0], [-1, 2, -1], [0, -1, 2]]; inverse diagonal is
// [0.75, 1.0, 0.75]. // [0.75, 1.0, 0.75].
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0]; let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap();
for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() { for (i, expected) in [0.75, 1.0, 0.75].into_iter().enumerate() {
let mut e = vec![0.0; 3]; let mut e = vec![0.0; 3];
e[i] = 1.0; e[i] = 1.0;
let z = solve_spd(a.clone(), &e).unwrap(); let y = c.whiten(&e);
assert!((z[i] - expected).abs() < 1e-12, "row {i}: {z:?}"); assert!((bilinear(&y, &y) - expected).abs() < 1e-12, "row {i}");
} }
} }
/// The off-diagonal bilinear form is symmetric and matches the inverse.
#[test]
fn recovers_an_off_diagonal_covariance() {
// Same A; (A^-1)_{0,1} = 0.5.
let a = vec![2.0, -1.0, 0.0, -1.0, 2.0, -1.0, 0.0, -1.0, 2.0];
let c = Cholesky::factor(a, 3).unwrap();
let y0 = c.whiten(&[1.0, 0.0, 0.0]);
let y1 = c.whiten(&[0.0, 1.0, 0.0]);
assert!((bilinear(&y0, &y1) - 0.5).abs() < 1e-12);
assert!((bilinear(&y1, &y0) - 0.5).abs() < 1e-12);
}
/// A variance can never come out negative, because it is a sum of squares.
#[test]
fn a_quadratic_form_is_never_negative() {
let a = vec![1e12, 1e12 - 1.0, 1e12 - 1.0, 1e12];
let c = Cholesky::factor(a, 2).unwrap();
let y = c.whiten(&[1.0, -1.0]);
assert!(bilinear(&y, &y) >= 0.0);
}
#[test] #[test]
fn rejects_a_non_positive_definite_matrix() { fn rejects_a_non_positive_definite_matrix() {
// Singular: the second row is a multiple of the first. // Singular: the second row is a multiple of the first.
let a = vec![1.0, 2.0, 2.0, 4.0]; assert!(Cholesky::factor(vec![1.0, 2.0, 2.0, 4.0], 2).is_none());
assert!(solve_spd(a, &[1.0, 1.0]).is_none());
} }
} }
+36 -15
View File
@@ -141,7 +141,7 @@ pub use event::{Event, Member, Team};
pub use event_builder::EventBuilder; pub use event_builder::EventBuilder;
pub use game::{Game, GameOptions, OwnedGame}; pub use game::{Game, GameOptions, OwnedGame};
pub use gaussian::Gaussian; pub use gaussian::Gaussian;
pub use history::{History, HistoryBuilder}; pub use history::{History, HistoryBuilder, Joint};
pub use key_table::KeyTable; pub use key_table::KeyTable;
use matrix::Matrix; use matrix::Matrix;
pub use observer::{NullObserver, Observer}; pub use observer::{NullObserver, Observer};
@@ -158,22 +158,43 @@ pub const P_DRAW: f64 = 0.0;
pub const EPSILON: f64 = 1e-6; pub const EPSILON: f64 = 1e-6;
/// Default cap on convergence sweeps. /// Default cap on convergence sweeps.
/// ///
/// **This is a floor, not a recommendation.** It is adequate for small /// **A runaway guard, not a budget.** The sweep exits as soon as the step falls
/// histories and is quickly outgrown: a history of 400 events over 100 /// below `epsilon`, so the cap is never reached by a history that converges and
/// competitors already stops here with a final step of ~7e-3 against the 1e-6 /// raising it costs nothing. Measured on a history that needs four sweeps:
/// default tolerance — four orders of magnitude short — and a dense joint model
/// of ~2,000 nodes over ~3,300 events has been measured needing 76 to 161.
/// ///
/// Overrunning it is not an error, and deliberately so: `converge` returns a /// ```text
/// [`ConvergenceReport`] whose `converged` flag says what happened. But a fit /// max_iter 30: 4 iterations, 129.9 us
/// that stopped short is *wrong by a little*, which is the worst available /// max_iter 100_000: 4 iterations, 131.9 us
/// failure — every rating is finite and ordered sensibly, and nothing in the /// ```
/// numbers themselves says they were still moving. Read the report; the type is
/// `#[must_use]` for that reason.
/// ///
/// Raise it via [`ConvergenceOptions`]. Convergence cost is roughly linear in /// This was `30` until it was measured, and 30 truncated ordinary healthy
/// the cap, and for anything but a toy the extra sweeps are milliseconds. /// histories: 160 events over 100 competitors already needs 42. Because a short
pub const ITERATIONS: usize = 30; /// fit is finite and sensibly ordered, that was invisible.
///
/// # Why it is not scaled to the history
///
/// The obvious improvement — pick the cap from the node or event count — does
/// not work, because iteration count is driven by how *loopy* the graph is
/// rather than how big it is. At a fixed 320 events over 40 slices, varying
/// only the number of competitors sharing them:
///
/// ```text
/// competitors appearances each iterations
/// 3 213 2_789
/// 10 64 1_068
/// 50 12.8 206
/// 100 6.4 90
/// 400 1.6 2
/// ```
///
/// Three orders of magnitude apart on identical event and slice counts. Any
/// formula in those two numbers would be badly wrong on some real shape, so the
/// cap is a single value set high enough that reaching it means the fit is
/// oscillating rather than merely large.
///
/// Reaching it is [`InferenceError::NotConverged`]. See
/// [`History::converge`](crate::History::converge).
pub const ITERATIONS: usize = 10_000;
/// Largest team count `History::predict_outcome` will enumerate. /// Largest team count `History::predict_outcome` will enumerate.
/// ///
+152
View File
@@ -0,0 +1,152 @@
//! Stopping short of convergence is an error, not a flag on a success.
//!
//! A fit that hits `max_iter` is wrong by a little: every rating is finite,
//! the ordering looks sensible, and nothing in the numbers says they were
//! still moving. When that was `Ok` with `converged: false`, detecting it was
//! opt-in and `let _ = h.converge()` was the natural way to opt out — which is
//! how a real defect once hid in this crate's own suite.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
};
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
fn duel(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([3.0, 1.0]),
}
}
fn capped(max_iter: usize) -> H {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.5))
.convergence(ConvergenceOptions {
max_iter,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
fn fill(h: &mut H) {
h.add_events((1..=6).map(|t| duel("a", "b", t)).collect::<Vec<_>>())
.unwrap();
}
#[test]
fn hitting_the_cap_is_an_error() {
let mut h = capped(1);
fill(&mut h);
let err = h.converge().unwrap_err();
match err {
InferenceError::NotConverged {
iterations,
final_step,
epsilon,
} => {
assert_eq!(iterations, 1);
assert!(
final_step.0 > epsilon || final_step.1 > epsilon,
"{final_step:?}"
);
}
other => panic!("expected NotConverged, got {other:?}"),
}
}
/// The message has to name what to do about it, since the fit looks fine.
#[test]
fn the_error_says_how_to_fix_it() {
let mut h = capped(1);
fill(&mut h);
let text = h.converge().unwrap_err().to_string();
assert!(text.contains("did not converge in 1 iterations"), "{text}");
assert!(text.contains("max_iter"), "{text}");
assert!(text.contains("alpha"), "{text}");
}
/// The escape hatch: a deliberately capped fit is still reachable.
#[test]
fn converge_partial_returns_the_short_fit() {
let mut h = capped(1);
fill(&mut h);
let report = h.converge_partial().unwrap();
assert_eq!(report.iterations, 1);
assert!(!report.converged);
assert!(h.current_skill(&"a").is_some());
}
/// Both agree when the fit does converge, so the strict path costs nothing.
#[test]
fn the_two_agree_on_a_converged_fit() {
let mut strict = capped(20_000);
fill(&mut strict);
let a = strict.converge().unwrap();
let mut partial = capped(20_000);
fill(&mut partial);
let b = partial.converge_partial().unwrap();
assert!(a.converged && b.converged);
assert_eq!(a.iterations, b.iterations);
assert_eq!(a.final_step, b.final_step);
}
/// The default cap must be high enough that an ordinary history clears it.
/// At the old value of 30 this history stopped short and said nothing.
#[test]
fn the_default_cap_clears_an_ordinary_history() {
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.05))
.build();
let mut events = Vec::new();
for t in 0..20i64 {
for j in 0..8usize {
let k = (t as usize) * 8 + j;
events.push(Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(format!("p{}", k % 100))]),
Team::with_members([Member::new(format!("p{}", (k + 37) % 100))]),
],
outcome: Outcome::scores([3.0, 1.0]),
});
}
}
h.add_events(events).unwrap();
let report = h
.converge()
.expect("an ordinary history must converge by default");
assert!(
report.iterations > 30,
"needed {} sweeps",
report.iterations
);
assert!(report.iterations < trueskill_tt::ITERATIONS);
}
/// An empty history converges trivially rather than erroring.
#[test]
fn an_empty_history_converges() {
let mut h = capped(1);
let report = h.converge().unwrap();
assert!(report.converged);
assert_eq!(report.iterations, 0);
}
+3 -2
View File
@@ -170,8 +170,9 @@ fn event_builder_rejects_a_weights_length_mismatch() {
fn event_builder_weights_mismatch_leaves_the_history_untouched() { fn event_builder_weights_mismatch_leaves_the_history_untouched() {
let mut h = History::default(); let mut h = History::default();
// Two teams, so ingestion would otherwise succeed — a one-team event is // Two teams, so ingestion would otherwise succeed. A one-team event is
// rejected for an unrelated reason and would pass this vacuously. // rejected as `NotEnoughTeams` before the weights are ever examined, so
// building this with one team would pass vacuously.
let _ = h let _ = h
.event(1) .event(1)
.team(["a"]) .team(["a"])
+193
View File
@@ -0,0 +1,193 @@
//! `EventBuilder::members` must reach exactly what the typed path reaches.
//!
//! Before this existed, `EventBuilder` could set weights and nothing else, so
//! `prior` and `drift_scale` were expressible only through `Event`/`Team`/
//! `Member` + `add_events`. Which ingestion route a competitor arrived through
//! decided whether it could be configured at all.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
Team,
};
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
fn history() -> H {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.5))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
const PRIOR: Gaussian = Gaussian::from_ms(3.0, 1.5);
/// The contract that makes the escape hatch worth having: same configuration,
/// same fit, bit for bit.
#[test]
fn members_matches_the_typed_path_exactly() {
let mut typed = history();
typed
.add_events(vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("player")]),
Team::with_members([Member::new("layout_7")
.with_drift_scale(0.0)
.with_prior(PRIOR)]),
],
outcome: Outcome::scores([5.0, 2.0]),
}])
.unwrap();
assert!(typed.converge().unwrap().converged);
let mut fluent = history();
fluent
.event(1)
.team(["player"])
.members([Member::new("layout_7")
.with_drift_scale(0.0)
.with_prior(PRIOR)])
.scores([5.0, 2.0])
.commit()
.unwrap();
assert!(fluent.converge().unwrap().converged);
for key in ["player", "layout_7"] {
let a = typed.current_skill(&key).unwrap();
let b = fluent.current_skill(&key).unwrap();
assert_eq!(a.pi(), b.pi(), "{key} pi");
assert_eq!(a.tau(), b.tau(), "{key} tau");
}
}
/// The configuration has to actually take effect, not merely round-trip: a
/// competitor pinned with `drift_scale = 0.0` must not move across slices,
/// where an unpinned one does.
///
/// The comparison is against a control rather than against a fixed epsilon.
/// Pinned marginals are not bit-identical across slices — each slice combines
/// its own forward and backward messages, so the arithmetic order differs and
/// the last bit moves. What "pinned" promises is that no drift variance
/// accumulates, and the control is what makes that measurable.
#[test]
fn a_drift_scale_set_through_members_is_applied() {
fn spread(h: &H, key: &'static str) -> f64 {
let curve = h.learning_curve(&key);
assert!(curve.len() >= 2, "{key}: expected several appearances");
let (lo, hi) = curve.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (_, g)| {
(lo.min(g.sigma()), hi.max(g.sigma()))
});
(hi - lo) / hi
}
let mut h = history();
for t in 1..=4 {
h.event(t)
.team(["player"])
.members([Member::new("pinned").with_drift_scale(0.0)])
.scores([5.0, 2.0])
.commit()
.unwrap();
// Same shape, no pinning: the control.
h.event(t)
.team(["rival"])
.team(["drifting"])
.scores([5.0, 2.0])
.commit()
.unwrap();
}
assert!(h.converge().unwrap().converged);
let pinned = spread(&h, "pinned");
let drifting = spread(&h, "drifting");
assert!(pinned < 1e-9, "pinned competitor moved: {pinned:e}");
assert!(
drifting > 1e-3,
"control did not move, so the test proves nothing: {drifting:e}"
);
}
/// `weights` still applies to a team added through `members`, and still
/// records a mismatch rather than partially applying it.
#[test]
fn weights_still_guards_a_members_team() {
let mut h = history();
let err = h
.event(1)
.team(["a"])
.members([Member::new("b"), Member::new("c")])
.weights([1.0])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(
err,
InferenceError::MismatchedShape {
kind: "weights",
expected: 2,
got: 1
}
),
"{err:?}"
);
assert!(h.current_skill(&"b").is_none(), "nothing may reach history");
}
/// An invalid `drift_scale` surfaces from `commit`, not from a panic and not
/// silently.
#[test]
fn an_invalid_drift_scale_surfaces_from_commit() {
for bad in [-1.0, f64::NAN, f64::INFINITY] {
let mut h = history();
let err = h
.event(1)
.team(["a"])
.members([Member::new("b").with_drift_scale(bad)])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
name: "drift_scale",
..
}
),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"b").is_none(), "{bad} reached the history");
}
}
/// `members` and `team` compose in either order.
#[test]
fn members_and_team_interleave() {
let mut h = history();
h.event(1)
.members([Member::new("a").with_prior(PRIOR)])
.team(["b"])
.scores([3.0, 1.0])
.commit()
.unwrap();
h.event(2)
.team(["b"])
.members([Member::new("c").with_prior(PRIOR)])
.scores([2.0, 4.0])
.commit()
.unwrap();
assert!(h.converge().unwrap().converged);
for key in ["a", "b", "c"] {
assert!(h.current_skill(&key).is_some(), "{key} missing");
}
}
+112
View File
@@ -138,3 +138,115 @@ fn one_v_one_honours_convergence_options() {
let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap(); let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap();
assert!(a_post.mu() > 25.0); assert!(a_post.mu() > 25.0);
} }
/// `Game` is a public entry point that does not pass through `History`'s
/// ingestion chokepoint, so it needs its own boundary — and did not have one.
///
/// A one-team game panicked at `src/game.rs:317` with "range start index 1 out
/// of range for slice of length 0", in release, from safe API. This is the
/// same defect `tests/ingestion_shape.rs` covers for `History`; fixing that
/// path left this one open, because they share no validation.
mod malformed_games {
use super::*;
#[test]
fn a_one_team_ranked_game_is_an_error_not_a_panic() {
let a = default_rating();
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
"{err:?}"
);
}
#[test]
fn a_one_team_scored_game_is_an_error_not_a_panic() {
let a = default_rating();
let err = Game::<i64, _>::scored(
&[&[a]],
Outcome::scores([1.0]),
&GameOptions {
score_sigma: 1.0,
..GameOptions::default()
},
)
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
"{err:?}"
);
}
#[test]
fn a_zero_team_game_is_an_error() {
let err =
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
"{err:?}"
);
}
/// The quiet half: an empty team contributed no performance, so the game
/// returned a finite posterior for its opponent as though it had won one.
#[test]
fn an_empty_team_is_an_error() {
let a = default_rating();
let err =
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 0 }),
"{err:?}"
);
}
#[test]
fn a_non_finite_score_is_an_error() {
let a = default_rating();
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let err = Game::<i64, _>::scored(
&[&[a], &[a]],
Outcome::scores([bad, 1.0]),
&GameOptions {
score_sigma: 1.0,
..GameOptions::default()
},
)
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
"{bad}: {err:?}"
);
}
}
/// `free_for_all` and `one_v_one` build their teams internally, so they
/// must keep working — the check must not catch well-formed games.
#[test]
fn well_formed_games_are_untouched() {
let a = default_rating();
assert!(
Game::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::winner(0, 2),
&GameOptions::default()
)
.is_ok()
);
assert!(
Game::<i64, _>::free_for_all(
&[&a, &a, &a],
Outcome::ranking([0, 1, 2]),
&GameOptions::default()
)
.is_ok()
);
assert!(
Game::<i64, _>::one_v_one(&a, &a, Outcome::winner(0, 2), &GameOptions::default())
.is_ok()
);
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Malformed events must be rejected at the ingestion boundary.
//!
//! Every case here was reachable from safe public API in a release build. Two
//! of them are the two shapes this crate's defects keep taking: a panic from
//! deep inside inference, and a finite, plausible-looking posterior computed
//! from an event that should never have been accepted.
//!
//! `InferenceError::NotEnoughTeams` and `EmptyTeam` already existed when these
//! were found — they were checked on the prediction paths and nowhere else, so
//! ingestion could still manufacture the states they describe.
use smallvec::smallvec;
use trueskill_tt::{Event, History, InferenceError, Member, Outcome, Team};
type Ev = Event<i64, &'static str>;
fn history() -> History<i64, trueskill_tt::ConstantDrift, trueskill_tt::NullObserver, &'static str>
{
History::builder().score_sigma(1.0).build()
}
fn teams(names: &[&[&'static str]]) -> smallvec::SmallVec<[Team<&'static str>; 4]> {
names
.iter()
.map(|team| Team::with_members(team.iter().map(|k| Member::new(*k))))
.collect()
}
/// The regression this file exists for: `run_chain` builds one diff link per
/// adjacent pair of teams, so a one-team event left it indexing `links[1..]`
/// on an empty vector and panicked — in release, from `History::add_events`.
#[test]
fn a_one_team_event_is_an_error_not_a_panic() {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: teams(&[&["a"]]),
outcome: Outcome::winner(0, 1),
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
"{err:?}"
);
}
#[test]
fn a_zero_team_event_is_an_error() {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: smallvec![],
outcome: Outcome::ranking([]),
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
"{err:?}"
);
}
/// The quiet half. An empty team contributes no performance, so before this
/// was rejected the event converged and handed back a finite posterior for its
/// opponent — a plausible constant computed from nothing.
#[test]
fn an_empty_team_is_an_error_rather_than_a_free_win() {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: teams(&[&[], &["b"]]),
outcome: Outcome::winner(0, 2),
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 0 }),
"{err:?}"
);
// Nothing was recorded, so the history is still empty.
assert!(h.current_skill(&"b").is_none());
}
#[test]
fn an_empty_team_is_reported_by_position() {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: teams(&[&["a"], &[]]),
outcome: Outcome::winner(0, 2),
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::EmptyTeam { team: 1 }),
"{err:?}"
);
}
/// A NaN score used to ingest cleanly. `converge` reported `NonFiniteResult`,
/// but a caller who read `current_skill` first was handed `tau: NaN` with
/// nothing to say so.
#[test]
fn a_non_finite_score_is_rejected_at_ingestion() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut h = history();
let err = h
.add_events(vec![Ev {
time: 1,
teams: teams(&[&["a"], &["b"]]),
outcome: Outcome::scores([bad, 0.0]),
}])
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"a").is_none(), "{bad} was recorded anyway");
}
}
/// A non-finite weight behaved exactly as `0.0` — the member contributed
/// nothing — while `converge` reported `converged: true` after one iteration
/// with a step of `(0.0, 0.0)`. So a NaN arriving from a division or a parse
/// was indistinguishable from a deliberate zero, and looked like a clean fit.
#[test]
fn a_non_finite_weight_is_rejected_at_ingestion() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut h = history();
let err = h
.event(1)
.team(["a"])
.weights([bad])
.team(["b"])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
"{bad}: {err:?}"
);
assert!(h.current_skill(&"a").is_none(), "{bad} reached the history");
}
}
/// Zero and negative weights are expressible choices about how much a member
/// contributes, not malformed input, and `tests/degenerate_inputs.rs` pins
/// their behaviour deliberately. Rejecting non-finite values must not catch
/// them too.
#[test]
fn zero_and_negative_weights_still_ingest() {
for w in [0.0, -1.0, 0.5] {
let mut h = history();
h.event(1)
.team(["a"])
.weights([w])
.team(["b"])
.winner(0)
.commit()
.unwrap_or_else(|e| panic!("weight {w} should ingest: {e:?}"));
assert!(h.current_skill(&"a").is_some(), "weight {w}");
}
}
/// The fluent builder routes through the same chokepoint, so it inherits the
/// checks rather than needing its own.
#[test]
fn the_event_builder_inherits_the_shape_checks() {
let mut h = history();
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
assert!(
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
"{err:?}"
);
}
/// A well-formed event is untouched by any of this.
#[test]
fn a_well_formed_event_still_ingests() {
let mut h = history();
h.add_events(vec![Ev {
time: 1,
teams: teams(&[&["a"], &["b"]]),
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
assert!(h.converge().unwrap().converged);
assert!(h.current_skill(&"a").unwrap().mu() > h.current_skill(&"b").unwrap().mu());
}
+267
View File
@@ -0,0 +1,267 @@
//! `History::joint` factorises once and answers many questions.
//!
//! The contract that matters is *identity*: a `Joint` must return exactly what
//! the one-shot call returns, bit for bit. A faster path that quietly disagreed
//! with the slow one would be worse than no fast path — a caller would get
//! different numbers depending on how many questions they happened to ask.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, InferenceError, Member, Outcome, Team,
UnknownKeys,
};
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
fn duel(a: &'static str, b: &'static str, t: i64, sa: f64, sb: f64) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([sa, sb]),
}
}
fn ranked(a: &'static str, b: &'static str, t: i64) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::winner(0, 2),
}
}
fn history(unknown: UnknownKeys) -> H {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.5))
.unknown_keys(unknown)
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
/// Several slices, competitors with different last appearances, so `latest`
/// and `at_slice` both have work to do.
fn fitted(unknown: UnknownKeys) -> H {
let mut h = history(unknown);
h.add_events(vec![
duel("a", "b", 1, 5.0, 2.0),
duel("c", "d", 1, 3.0, 3.5),
duel("a", "c", 2, 6.0, 1.0),
duel("b", "d", 3, 4.0, 3.0),
duel("a", "d", 4, 7.0, 2.0),
duel("b", "c", 5, 2.0, 4.0),
])
.unwrap();
let report = h.converge().unwrap();
assert!(report.converged, "fixture must converge");
h
}
const PAIRS: [(&str, &str); 6] = [
("a", "b"),
("a", "c"),
("a", "d"),
("b", "c"),
("b", "d"),
("c", "d"),
];
#[test]
fn a_joint_answers_exactly_what_the_one_shot_call_does() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.pi(), cached.pi(), "{a} - {b}");
assert_eq!(one_shot.tau(), cached.tau(), "{a} - {b}");
}
}
#[test]
fn a_joint_agrees_at_a_pinned_time_too() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
for time in 1..=5 {
for (a, b) in PAIRS {
let terms = [(&a, 1.0), (&b, -1.0)];
let one_shot = h.posterior_of_at(time, &terms);
let cached = joint.posterior_of_at(time, &terms);
match (one_shot, cached) {
(Ok(x), Ok(y)) => {
assert_eq!(x.pi(), y.pi(), "t={time} {a} - {b}");
assert_eq!(x.tau(), y.tau(), "t={time} {a} - {b}");
}
(Err(x), Err(y)) => assert_eq!(x, y, "t={time} {a} - {b}"),
(x, y) => panic!("t={time} {a} - {b}: disagreed on success: {x:?} vs {y:?}"),
}
}
}
}
#[test]
fn a_joint_scores_candidate_matchups_identically() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
let (a, b) = ("a", "b");
let target = [(&a, 1.0), (&b, -1.0)];
for (x, y) in PAIRS {
let teams: [&[&&str]; 2] = [&[&x], &[&y]];
let one_shot = h.expected_variance_reduction(&teams, &target).unwrap();
let cached = joint.expected_variance_reduction(&teams, &target).unwrap();
assert_eq!(one_shot, cached, "{x} vs {y}");
}
}
/// The whole point: a competitor appears once per slice, so the joint is over
/// appearances rather than competitors, and a caller sizing a batch needs to
/// know which.
#[test]
fn variables_counts_appearances_not_competitors() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
// Four competitors, twelve appearances across five slices, all with
// positive drift between them, so no two collapse.
assert_eq!(joint.variables(), 12);
}
/// How much the collapse is worth, which is the part a caller has to plan
/// around: a drift-free competitor contributes **one** variable however long
/// the history, so the same events at `gamma = 0` and `gamma > 0` differ by
/// roughly the slice count in problem size — and by its cube in solve time.
///
/// Reported by a consumer as an 8x difference in solve time on a ~2,000-node,
/// 76-slice model (787 ms career against 6,214 ms drifting). This pins the
/// mechanism behind that so a change to the collapse rule cannot quietly
/// remove it.
#[test]
fn drift_free_competitors_shrink_the_joint_by_the_slice_count() {
fn variables(gamma: f64) -> usize {
let mut h = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(gamma))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
h.add_events(
(1..=10)
.map(|t| duel("a", "b", t, 5.0, 2.0))
.collect::<Vec<_>>(),
)
.unwrap();
let _ = h.converge().unwrap();
h.joint().unwrap().variables()
}
let drifting = variables(0.5);
let career = variables(0.0);
// Two competitors over ten slices: twenty appearances, or two variables.
assert_eq!(drifting, 20);
assert_eq!(career, 2);
assert_eq!(
drifting / career,
10,
"collapse should track the slice count"
);
}
/// With `drift = 0` consecutive appearances are the same latent variable, so
/// the joint is smaller than the appearance count.
#[test]
fn pinned_competitors_collapse_consecutive_appearances() {
let mut h = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.0))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build();
h.add_events(vec![
duel("a", "b", 1, 5.0, 2.0),
duel("a", "b", 2, 4.0, 3.0),
duel("a", "b", 3, 6.0, 1.0),
])
.unwrap();
assert!(h.converge().unwrap().converged);
assert_eq!(h.joint().unwrap().variables(), 2);
}
#[test]
fn a_ranked_history_has_no_exact_joint() {
let mut h = history(UnknownKeys::Reject);
h.add_events(vec![duel("a", "b", 1, 5.0, 2.0), ranked("a", "b", 2)])
.unwrap();
let _ = h.converge().unwrap();
assert!(matches!(
h.joint().unwrap_err(),
InferenceError::JointUnavailable { .. }
));
}
#[test]
fn an_empty_history_has_no_joint() {
let h = history(UnknownKeys::Reject);
assert!(matches!(
h.joint().unwrap_err(),
InferenceError::JointUnavailable { .. }
));
}
/// Unknown keys are decided per query, not when the joint is factorised — the
/// factorisation does not depend on the question.
#[test]
fn unknown_keys_are_rejected_per_query() {
let h = fitted(UnknownKeys::Reject);
let joint = h.joint().unwrap();
let (a, z) = ("a", "nobody");
assert!(matches!(
joint.posterior_of(&[(&a, 1.0), (&z, -1.0)]).unwrap_err(),
InferenceError::UnknownKey { .. }
));
// The handle is still usable afterwards.
let b = "b";
assert!(joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).is_ok());
}
/// Under `Prior`, an unseen competitor is independent of everything in the
/// history, and the cached path must add the same prior variance the one-shot
/// path does.
#[test]
fn unseen_competitors_match_the_one_shot_path() {
let h = fitted(UnknownKeys::Prior);
let joint = h.joint().unwrap();
let (a, z) = ("a", "nobody");
let terms = [(&a, 1.0), (&z, -1.0)];
let one_shot = h.posterior_of(&terms).unwrap();
let cached = joint.posterior_of(&terms).unwrap();
assert_eq!(one_shot.pi(), cached.pi());
assert_eq!(one_shot.tau(), cached.tau());
}
+117
View File
@@ -0,0 +1,117 @@
//! Inference must report numerical breakdown rather than call it convergence.
//!
//! The boundary rejects inputs that are *not numbers*, but finite inputs can
//! still overflow during inference — `beta.powi(2)` at 1e300 is infinite, and
//! infinity minus infinity is NaN. `NonFiniteResult` is the guard for that, and
//! it matters because the alternative is silent: NaN fails every comparison, so
//! a naive `step < epsilon` check reads a NaN step as *converged*.
//!
//! That is why the crate has `step_converged` / `step_is_finite` rather than
//! `!tuple_gt(..)`. These tests pin the guard from outside.
use smallvec::smallvec;
use trueskill_tt::{Event, Gaussian, History, InferenceError, Member, Outcome, Team};
fn scored_fit(
sigma: f64,
beta: f64,
score_sigma: f64,
scores: [f64; 2],
) -> Result<bool, InferenceError> {
let mut h = History::builder()
.mu(0.0)
.sigma(sigma)
.beta(beta)
.score_sigma(score_sigma)
.build();
h.add_events(vec![Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores(scores),
}])?;
h.converge().map(|r| r.converged)
}
/// Every one of these is built from finite, individually legal parameters. The
/// overflow happens inside inference, which is exactly the case the boundary
/// checks cannot catch.
///
/// Matched rather than merely `is_err()`: an assertion that only checks "some
/// error" would keep passing if these started failing at the boundary for an
/// unrelated reason, and would then be testing nothing.
#[test]
fn overflow_during_inference_is_reported_not_hidden() {
let cases: [(&str, f64, f64, f64, [f64; 2]); 5] = [
("huge sigma", 1e300, 1.0, 1.0, [3.0, 1.0]),
("huge beta", 6.0, 1e300, 1.0, [3.0, 1.0]),
("tiny sigma", 1e-300, 1.0, 1.0, [3.0, 1.0]),
("tiny score_sigma", 6.0, 1.0, 1e-300, [3.0, 1.0]),
("huge scores", 6.0, 1.0, 1.0, [1e308, -1e308]),
];
for (name, sigma, beta, score_sigma, scores) in cases {
match scored_fit(sigma, beta, score_sigma, scores) {
Err(InferenceError::NonFiniteResult { context, step }) => {
assert_eq!(context, "History::converge", "{name}");
assert!(
!step.0.is_finite() || !step.1.is_finite(),
"{name}: reported NonFiniteResult with a finite step {step:?}"
);
}
other => panic!("{name}: expected NonFiniteResult, got {other:?}"),
}
}
}
/// The trap the invariant exists for: NaN fails every comparison, so a naive
/// `step < epsilon` test reads a NaN step as converged. A breakdown must never
/// come back as a successful fit.
#[test]
fn a_broken_fit_is_never_reported_as_converged() {
let mut h = History::builder().build();
h.add_events(vec![Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
let err = h.converge().unwrap_err();
assert!(
matches!(err, InferenceError::NonFiniteResult { .. }),
"a breakdown must not be reported as convergence: {err:?}"
);
// `converge_partial` must not launder it into an `Ok` either — the
// permissive path is permissive about *stopping short*, not about NaN.
let mut h2 = History::builder().build();
h2.add_events(vec![Event {
time: 1i64,
teams: smallvec![
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::winner(0, 2),
}])
.unwrap();
assert!(matches!(
h2.converge_partial().unwrap_err(),
InferenceError::NonFiniteResult { .. }
));
}
/// The neighbouring case, so the tests above cannot pass by the fit simply
/// always failing: ordinary extreme-but-workable parameters still converge.
#[test]
fn merely_extreme_parameters_still_converge() {
assert!(scored_fit(1e6, 1.0, 1.0, [3.0, 1.0]).unwrap());
assert!(scored_fit(1e-6, 1.0, 1.0, [3.0, 1.0]).unwrap());
assert!(scored_fit(6.0, 1.0, 1e6, [3.0, 1.0]).unwrap());
assert!(scored_fit(6.0, 1.0, 1.0, [1e150, -1e150]).unwrap());
}
+338
View File
@@ -0,0 +1,338 @@
//! Configuring a competitor before anything is observed about them.
//!
//! The configuration a competitor needs is usually a property of the domain —
//! "every layout is static" — not of whichever event happens to mention them
//! first. Stating it per-event meant every ingestion path had to remember it,
//! and two of the four paths could not state it at all.
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, Gaussian, History, InferenceError, Member, Outcome,
Team,
};
type H = History<i64, ConstantDrift, trueskill_tt::NullObserver, &'static str>;
const PINNED: Gaussian = Gaussian::from_ms(2.0, 0.5);
fn history() -> H {
History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.score_sigma(2.0)
.drift(ConstantDrift(0.5))
.convergence(ConvergenceOptions {
max_iter: 20_000,
epsilon: 1e-13,
alpha: 1.0,
})
.build()
}
fn duel(
a: &'static str,
b: &'static str,
t: i64,
m: Option<Member<&'static str>>,
) -> Event<i64, &'static str> {
Event {
time: t,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([m.unwrap_or_else(|| Member::new(b))]),
],
outcome: Outcome::scores([5.0, 2.0]),
}
}
fn skills(h: &H) -> Vec<(&'static str, Gaussian)> {
["player", "layout"]
.into_iter()
.map(|k| (k, h.current_skill(&k).unwrap()))
.collect()
}
/// The headline contract.
#[test]
fn registering_matches_configuring_on_the_first_event() {
let configured = {
let mut h = history();
h.add_events(vec![
duel(
"player",
"layout",
1,
Some(
Member::new("layout")
.with_drift_scale(0.0)
.with_prior(PINNED),
),
),
duel("player", "layout", 2, None),
])
.unwrap();
let _ = h.converge().unwrap();
h
};
let registered = {
let mut h = history();
h.register(
Member::new("layout")
.with_drift_scale(0.0)
.with_prior(PINNED),
)
.unwrap();
h.add_events(vec![
duel("player", "layout", 1, None),
duel("player", "layout", 2, None),
])
.unwrap();
let _ = h.converge().unwrap();
h
};
for ((k, a), (_, b)) in skills(&configured).into_iter().zip(skills(&registered)) {
assert_eq!(a.pi(), b.pi(), "{k} pi");
assert_eq!(a.tau(), b.tau(), "{k} tau");
}
}
/// The case `EventBuilder` and the typed path cannot reach: a competitor whose
/// first appearance arrives through the two-argument convenience route.
#[test]
fn registration_reaches_a_competitor_first_seen_through_record_winner() {
let mut h = history();
h.register(
Member::new("layout")
.with_drift_scale(0.0)
.with_prior(PINNED),
)
.unwrap();
h.record_winner(&"player", &"layout", 1).unwrap();
h.record_winner(&"player", &"layout", 2).unwrap();
let _ = h.converge().unwrap();
let rating = h.rating(&"layout").unwrap();
assert_eq!(rating.drift_scale(), 0.0);
assert_eq!(rating.prior().mu(), PINNED.mu());
// Pinned means pinned: no drift across the two slices.
let curve = h.learning_curve(&"layout");
assert!(curve.len() >= 2);
let widest = curve
.iter()
.map(|(_, g)| g.sigma())
.fold(f64::MIN, f64::max);
let narrowest = curve
.iter()
.map(|(_, g)| g.sigma())
.fold(f64::MAX, f64::min);
assert!(
(widest - narrowest) / widest < 1e-9,
"{narrowest} .. {widest}"
);
}
#[test]
fn registering_a_known_competitor_is_an_error() {
let mut h = history();
h.record_winner(&"player", &"layout", 1).unwrap();
let err = h.register(Member::new("layout")).unwrap_err();
assert!(
matches!(err, InferenceError::AlreadyRegistered { .. }),
"{err:?}"
);
}
#[test]
fn registering_twice_is_an_error() {
let mut h = history();
h.register(Member::new("layout").with_drift_scale(0.0))
.unwrap();
let err = h
.register(Member::new("layout").with_drift_scale(1.0))
.unwrap_err();
assert!(
matches!(err, InferenceError::AlreadyRegistered { .. }),
"{err:?}"
);
// The first registration stands.
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
}
/// `weight` is per-event and meaningless here, so it is rejected rather than
/// dropped — dropping it silently is the defect class this whole area keeps
/// producing.
#[test]
fn a_weight_on_a_registration_is_rejected() {
let mut h = history();
let err = h
.register(Member::new("layout").with_weight(0.5))
.unwrap_err();
assert!(
matches!(err, InferenceError::InvalidParameter { name: "weight", .. }),
"{err:?}"
);
}
#[test]
fn an_invalid_drift_scale_on_a_registration_is_rejected() {
for bad in [-1.0, f64::NAN, f64::INFINITY] {
let mut h = history();
let err = h
.register(Member::new("layout").with_drift_scale(bad))
.unwrap_err();
assert!(
matches!(
err,
InferenceError::InvalidParameter {
name: "drift_scale",
..
}
),
"{bad}: {err:?}"
);
}
}
/// Registration makes the fit independent of the order events arrive in,
/// which is what the per-event shape could not guarantee.
#[test]
fn registration_makes_the_fit_order_independent() {
let build = |reversed: bool| {
let mut h = history();
h.register(
Member::new("layout")
.with_drift_scale(0.0)
.with_prior(PINNED),
)
.unwrap();
let mut events = vec![
duel("player", "layout", 1, None),
duel("player", "layout", 2, None),
duel("player", "layout", 3, None),
];
if reversed {
events.reverse();
}
h.add_events(events).unwrap();
let _ = h.converge().unwrap();
h
};
let forward = build(false);
let backward = build(true);
for ((k, a), (_, b)) in skills(&forward).into_iter().zip(skills(&backward)) {
assert_eq!(a.pi(), b.pi(), "{k} pi");
assert_eq!(a.tau(), b.tau(), "{k} tau");
}
}
/// `rating` is the read-back that made a configuration mistake detectable from
/// outside the crate at all. Every other accessor reports what inference
/// inferred; this reports what it was told.
#[test]
fn rating_reads_back_what_was_stored() {
let mut h = history();
assert!(h.rating(&"nobody").is_none());
h.register(
Member::new("layout")
.with_drift_scale(0.25)
.with_prior(PINNED),
)
.unwrap();
let r = h.rating(&"layout").unwrap();
assert_eq!(r.drift_scale(), 0.25);
assert_eq!(r.prior().pi(), PINNED.pi());
assert_eq!(r.prior().tau(), PINNED.tau());
// A competitor created by an event reports the history defaults.
h.record_winner(&"player", &"layout", 1).unwrap();
assert_eq!(h.rating(&"player").unwrap().drift_scale(), 1.0);
}
/// The decision this issue turned on: two different values for one competitor
/// are an error whether they arrive in one batch or two.
///
/// Last-write-wins across batches cut against the invariant
/// `tests/ingestion_equivalence.rs` protects — the same contradictory events
/// errored when batched and succeeded, order-dependently, one at a time.
mod conflicting_configuration {
use super::*;
fn seed(scale: f64) -> Event<i64, &'static str> {
duel(
"player",
"layout",
1,
Some(Member::new("layout").with_drift_scale(scale)),
)
}
#[test]
fn within_one_batch_is_an_error() {
let mut h = history();
let err = h.add_events(vec![seed(0.0), seed(1.0)]).unwrap_err();
assert!(
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: "drift_scale",
..
}
),
"{err:?}"
);
}
#[test]
fn across_two_batches_is_also_an_error() {
let mut h = history();
h.add_events(vec![seed(0.0)]).unwrap();
let err = h.add_events(vec![seed(1.0)]).unwrap_err();
assert!(
matches!(
err,
InferenceError::ConflictingCompetitorConfig {
field: "drift_scale",
..
}
),
"{err:?}"
);
// Rejected before anything mutates: the first declaration stands.
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
}
/// Repeating the *same* value stays inert, which is the expected shape
/// when the configuration is a property of the domain.
#[test]
fn repeating_the_same_value_is_inert() {
let mut h = history();
h.add_events(vec![seed(0.0)]).unwrap();
h.add_events(vec![seed(0.0)]).unwrap();
assert_eq!(h.rating(&"layout").unwrap().drift_scale(), 0.0);
}
/// A registration and a later event that agree are fine; one that
/// disagrees is the same error.
#[test]
fn a_registration_conflicts_with_a_later_event() {
let mut h = history();
h.register(Member::new("layout").with_drift_scale(0.0))
.unwrap();
h.add_events(vec![seed(0.0)]).unwrap();
let mut h2 = history();
h2.register(Member::new("layout").with_drift_scale(0.0))
.unwrap();
let err = h2.add_events(vec![seed(1.0)]).unwrap_err();
assert!(
matches!(err, InferenceError::ConflictingCompetitorConfig { .. }),
"{err:?}"
);
}
}
+71
View File
@@ -184,3 +184,74 @@ fn ingestion_rejects_weights_that_do_not_match_their_team() {
"got {err:?}" "got {err:?}"
); );
} }
/// `mu`, `sigma` and `beta` were the last unvalidated setters on
/// `HistoryBuilder`, next to `p_draw`, `score_sigma` and `convergence`, which
/// all assert eagerly.
///
/// Two of the rejected values are the quiet kind. A negative `sigma` or `beta`
/// enters inference only as its square, so it produced bit-identical results
/// to the positive value — the sign was dropped without comment.
mod builder_parameters {
use trueskill_tt::History;
#[test]
#[should_panic(expected = "mu must be finite")]
fn a_non_finite_mu_is_rejected() {
let _ = History::builder().mu(f64::NAN);
}
#[test]
#[should_panic(expected = "sigma must be finite and positive")]
fn a_zero_sigma_is_rejected() {
let _ = History::builder().sigma(0.0);
}
#[test]
#[should_panic(expected = "sigma must be finite and positive")]
fn a_negative_sigma_is_rejected() {
let _ = History::builder().sigma(-8.33);
}
#[test]
#[should_panic(expected = "sigma must be finite and positive")]
fn an_infinite_sigma_is_rejected() {
let _ = History::builder().sigma(f64::INFINITY);
}
#[test]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_negative_beta_is_rejected() {
let _ = History::builder().beta(-4.17);
}
#[test]
#[should_panic(expected = "beta must be finite and non-negative")]
fn a_non_finite_beta_is_rejected() {
let _ = History::builder().beta(f64::NAN);
}
/// Zero beta is deliberately allowed: performance is then exactly skill.
/// It has to reach a different fit than a positive beta, or "allowed"
/// would just mean "not checked".
#[test]
fn a_zero_beta_is_allowed_and_changes_the_fit() {
let fit = |beta: f64| {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(beta)
.build();
h.record_winner(&"a", &"b", 1).unwrap();
let _ = h.converge().unwrap();
h.current_skill(&"a").unwrap()
};
let zero = fit(0.0);
let positive = fit(25.0 / 6.0);
assert!(zero.pi().is_finite() && zero.pi() > 0.0);
assert!(
(zero.pi() - positive.pi()).abs() > 1e-6,
"zero beta must not merely be ignored: {zero:?} vs {positive:?}"
);
}
}