10 Commits
Author SHA1 Message Date
logaritmiskandClaude Opus 5 9e8515b7cd docs: refresh README and CLAUDE.md; add ingest benchmark
The CLAUDE.md architecture section still described the pre-redesign engine:
its data flow named `Batch`, `Agent`, `Player` and `message.rs`, none of
which have existed since T2, and the public API it listed did not match
`lib.rs`. It is the first thing a fresh session reads, so it was actively
misleading. Rewritten against the current module layout, with the invariants
that are easy to violate — ties needing a positive `p_draw`, NaN never being
convergence, log-space evidence, color contiguity, `forbid(unsafe_code)`,
and ingestion-order equivalence — written down.

The README Todo list had five entries that were already done, including
"Time needs to be an enum": `Time` has been a trait since T2, and the
`batch::compute_elapsed()` it pointed at no longer exists. The genuinely
open item — cross-checking `quality()` against sublee/trueskill — stays.

`benches/ingest.rs` measures one-event-per-call against a single batched
call. The rest of the suite only measured batched construction, which is why
the quadratic fixed earlier on this branch went unnoticed for so long.

`TimeSlice::log_evidence` also hashes its target set once instead of
scanning the slice per player per event, so `log_evidence_for` with many
keys is no longer quadratic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 22:05:49 +02:00
logaritmiskandClaude Opus 5 9506fed4b3 chore: add CI, crate metadata, and crate-level documentation
There was no CI of any kind — no workflows directory at all — despite a full
release pipeline (release.toml, cliff.toml, a maintained CHANGELOG, three
tagged releases). The workflow covers the feature combinations that actually
have distinct behaviour, including a release-profile job: `debug_assert!` is
compiled out there, which is exactly where the validation this branch added
has to hold, and a debug-only suite would never have seen it. Determinism is
checked at RAYON_NUM_THREADS of 1, 2, 4 and 8.

The Justfile gains test/lint/fmt/determinism recipes so the same checks run
locally with one command, and `just ci` runs the lot.

`Cargo.toml` had only name, version and edition, so `cargo publish` would
have been rejected. Added description, repository, readme, keywords,
categories, exclude, and `rust-version = "1.85"` — the edition-2024 floor,
now verified by a CI job. Two let-chains introduced earlier on this branch
would have pushed that to 1.88; they are rewritten to keep the floor where
it was.

`src/lib.rs` had no `//!` header at all, so the docs.rs landing page would
have been a bare symbol list — conspicuous given every other module has one.
It now explains what Through Time does differently, and carries three
runnable examples (which `cargo test --doc` checks, where previously there
was nothing to check), including the draw/p_draw interaction that is the
easiest way to get an error out of this crate.

`cargo publish --dry-run` now packages and verifies cleanly. The only
remaining blocker is `license`, which is yours to choose — noted as a TODO
in the manifest rather than picked unilaterally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 22:03:39 +02:00
logaritmiskandClaude Opus 5 6030dc78de refactor: unify convergence defaults, validate builders, clear dead code
Convergence configuration had two disagreeing sources of truth and one
misleading report:

- `EpsilonOrMax::default()` capped at 10 iterations while
  `ConvergenceOptions::default()` allowed 30, and which applied depended on
  whether inference went through `run_chain` or a `Schedule`. The schedule
  default now derives from `ConvergenceOptions`.
- A graph with no iterating factors reported `converged: false` with an
  infinite step, despite being at its fixed point after the setup pass. It
  now reports converged with a zero step.
- `TimeSlice::iterate_to_convergence` hard-coded an epsilon and a
  20-iteration cap matching neither. It reads `self.convergence` and is
  scoped to `#[cfg(test)]`, which is all it was ever used by.

`HistoryBuilder::p_draw` and `::convergence` now validate their arguments
like `score_sigma` already did, instead of accepting a negative `p_draw` or
an `alpha` of zero — the latter leaves every EP update unapplied, so
inference silently returns the priors.

Removing the `#[allow(dead_code)]` masks let the compiler report what they
were hiding: four `OwnedGame` fields that were stored and never read, two
`ColorGroups` helpers and three `SkillStore` helpers used only by tests, and
`iterate_to_convergence` above. Test-only items are now `#[cfg(test)]` and
the unread fields are gone.

Also exported `HistoryBuilder`, which was public but unreachable — callers
could chain `History::builder()` but could not name the type — and added
`Rating::{prior, beta, drift}` and `Index::get`, so handles the API hands
out can be read back.

Two goldens moved, both convergence residuals rather than exact values:
`iterate_to_convergence` now runs to 30 iterations instead of 20, landing
nearer the symmetric truth of 25.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 22:01:49 +02:00
logaritmiskandClaude Opus 5 355cdb7e05 perf(gaussian): drop the sqrt round-trip from variance-space operations
`Add`, `Sub`, `exclude` and `forget` combined variances by way of standard
deviations: `sigma()` takes a square root, `.powi(2)` squares it away,
`var.sqrt()` takes another, and `from_ms` squares that one back. Three roots
to compute a value that is `1/pi` all along.

They now go through `variance()` and a new `from_mv(mu, var)`, which skip
both conversions. `Sub` is the hot one — `RankDiffFactor::propagate` is
`a - b`, run for every adjacent team pair on every forward and backward
sweep of every EP iteration.

`run_chain` also stopped recomputing each team's weighted performance in the
likelihood loop; the fold is already in `arena.team_prior`, indexed by the
sorted position the loop has in hand. Each `performance()` is itself a
`forget`, so the duplicate cost scaled with players per team.

Measured on this machine, before and after, same fixtures:

    Batch::iteration          23.57us -> 19.31us   (-18%)
    scored_history_60_events   1.071ms -> 983us    (-8%)

The `Gaussian::add`/`sub` microbenchmarks cannot resolve the change: they
sit at ~234ps against a ~218ps floor that `mul`/`div` also hit, so the
harness overhead dominates a single operation.

One golden moved. Two identical competitors drawing must land on their
shared prior mean exactly, by symmetry; the root-free path now returns
25.0 where the reference transcription recorded 24.999999 — that value
rounded to six decimals. Asserting a six-decimal transcription at
epsilon 1e-6 left no headroom, so the expectation is now the exact value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:58:30 +02:00
logaritmiskandClaude Opus 5 06b6a68499 fix(rayon): remove the aliasing unsafe from the parallel sweep
The parallel color-group sweep passed a `*mut SkillStore` through a `usize`
and cast it back inside the rayon closure, so every worker materialised its
own `&mut SkillStore` to the same store. Two live `&mut` to one object is an
aliasing violation whatever the workers subsequently touch — `&mut` carries
`noalias` down to LLVM — and laundering the pointer through `usize` also
discarded provenance. The existing SAFETY comment argued element
disjointness, which is true and is why nothing miscompiled in practice, but
it is not the property the aliasing rules ask about.

Events in a color group touch disjoint agents, so none can observe another's
writes. That makes the sweep separable rather than merely safe-in-practice:
`Event::compute` runs inference over shared `&self.skills` with no mutation,
and `Event::apply` folds the results in afterwards in index order. No
`unsafe`, no aliasing argument, and the apply order does not depend on which
worker finished first, so results stay bit-identical across thread counts.

The crate now contains no `unsafe` at all, locked in with
`#![forbid(unsafe_code)]`.

Splitting compute from apply also removes the duplicated sweep body: the
`from > 0` branch of `TimeSlice::iteration` was a verbatim copy of
`iteration_direct`, and both now share one implementation.

Cost, measured on the three `history_converge` workloads (sequential vs
parallel, this machine):

    500x100@10perslice     4.02ms -> 4.21ms
    2000x200@20perslice   19.70ms -> 19.76ms
    1v1-5000x50000        11.75ms -> 10.46ms

The deferred apply gives back part of the parallel win on the only workload
where rayon ever helped (1.12x here, against the 1.3x T3 reported), and the
sequential path is unchanged. Trading a fraction of a 1.3x speedup on one
pathological shape for the removal of undefined behaviour is the right side
of that bargain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:54:34 +02:00
logaritmiskandClaude Opus 5 c088214fed fix(history): stop reprocessing the slice that was just appended to
`add_events_with_prior` advanced `k` past the slice it had written when it
created a new one, but not when it appended to an existing one. The trailing
forward-refresh loop therefore started *on* the slice just modified and ran
`new_forward_info` over it again.

That is not merely redundant work. The loop immediately above it sets each
agent's message to `forward * likelihood` for that slice, and
`new_forward_info` then assigns `skill.forward = message.forget(drift)` —
folding the slice's own likelihood back into its own forward prior. The
skills it produced depended on how events had been batched.

Ingesting one event at a time now converges to the same fixed point as
ingesting the same events in a single call, which it previously did not:
for five events sharing a timestamp, competitor `a` converged to
mu=7.44 sigma=3.90 batched versus mu=7.99 sigma=3.10 incrementally. Both
runs had converged; the gap was not a convergence residual.

The numerical goldens never caught this because they all ingest in one call
with a distinct timestamp per event, so the append-to-existing-slice branch
is never taken. `tests/ingestion_equivalence.rs` covers it directly, and
asserts convergence before comparing so that a residual cannot be mistaken
for agreement.

Removing the redundant re-inference also removes the dominant cost of
incremental ingestion, which was quadratic in the number of events already
in the slice:

    events   before     after    speedup
       500   45.8ms     1.1ms       42x
      1000  179.5ms     2.8ms       64x
      2000  721.8ms     9.9ms       73x
      4000    2.9s     35.4ms       82x

Ingesting one at a time is now 1.8x a single batched call, down from 148x.

Two supporting changes are included:

- Color groups are rebuilt lazily rather than on every append. Nothing
  reads the partition between an append and the next full sweep, so the
  per-append rebuild was pure waste.
- `ColorGroups::groups_are_contiguous` is asserted after each rebuild and
  in `color_range`. The parallel sweep derives one `&mut` sub-slice per
  color from those ranges and relies on them being disjoint; that invariant
  was established by construction but never checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:51:02 +02:00
logaritmiskandClaude Opus 5 0f1a1b8911 fix(evidence): accumulate in log space and floor the per-link value
Per-link evidence was multiplied in linear space and logged only at the
end. Each link contributes a probability in (0, 1], so the product over an
n-team game decays geometrically: around a thousand links it flushes to
exactly 0.0 and `ln(0.0)` is `-inf`, which then propagates through the sum
in `History::log_evidence_internal` and takes the whole history with it.
`Game::free_for_all` builds one team per player, so this is reachable at
the competitor counts the T3 benchmarks target.

`Game`, `OwnedGame`, and `time_slice::Event` now carry `log_evidence`
directly, summed over links rather than multiplied then logged.

The cached per-link evidence is also floored at `f64::MIN_POSITIVE`. It
could legitimately reach zero or go negative: `1.0 - cdf(..)` rounds to
zero for a near-certain outcome, and the `erfc` approximation carries
~1e-7 error so `cdf` can exceed 1.0 and make the difference negative —
`ln` of which is NaN.

Existing log-evidence goldens are unchanged, confirming the accumulation
is numerically equivalent in the range where the old form worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:43:57 +02:00
logaritmiskandClaude Opus 5 0d32690fcc fix(quality): support any number of rating groups
`quality()` was two-group-only in three separate ways, and
`History::predict_quality` inherited all of them.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:42:04 +02:00
logaritmiskandClaude Opus 5 6b8bd786d7 style: make NaN rejection explicit in score_sigma validation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:38:48 +02:00
logaritmiskandClaude Opus 5 f4e2922d59 fix: reject ties without draw probability; never report NaN as converged
A tie with `p_draw == 0.0` produced NaN posteriors in release builds and
`converge()` reported `converged: true`, because every comparison against
NaN is false and `tuple_gt` therefore read NaN as "below epsilon".

Two independent defects, fixed together:

- Ingestion now rejects tied outcomes when the draw probability is zero,
  promoting the existing `debug_assert!` in `Game::ranked_with_arena` to a
  real `InferenceError::TieWithoutDrawProbability`. Validation sits in
  `add_events_with_prior`, the chokepoint every route reaches — including
  `record_draw`, which bypasses `Outcome` entirely.
- `converge()` treats a non-finite step as failure and returns
  `InferenceError::NonFiniteResult` rather than claiming convergence.

Also in this change:

- `History::converge()` on an empty history returned a `usize` underflow
  panic from `0..len()-1`; it now short-circuits to a zero-iteration report.
- `Outcome::scores_with_sigma` no longer panics on a non-positive sigma;
  the value is validated at ingestion so callers get an error instead.
- `InferenceError` gains `WrongOutcomeKind`, replacing the misuse of
  `MismatchedShape` for variant mismatches (which rendered as the nonsense
  "expected length 0, got 0"), and is now `#[non_exhaustive]`.

Note `Outcome::winner(w, n)` for n >= 3 ties every loser, so those events
now require a positive `p_draw`. They previously returned NaN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
2026-08-04 21:38:29 +02:00
25 changed files with 1857 additions and 366 deletions
+87
View File
@@ -0,0 +1,87 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# The build most consumers get.
- name: default
features: ""
profile: ""
# Most numerical goldens need `approx` for assert_ulps_eq.
- name: approx
features: "--features approx"
profile: ""
# The parallel path, including tests/determinism.rs.
- name: rayon
features: "--features approx,rayon"
profile: ""
# Critical: debug_assert! is compiled out here, which is where the
# tie/p_draw and score_sigma validation actually has to hold.
- name: release
features: "--features approx"
profile: "--release"
name: test (${{ matrix.name }})
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }}
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }} --doc
determinism:
name: determinism across thread counts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# Posteriors must be bit-identical regardless of how many rayon workers
# run the color-group sweep.
- run: |
for threads in 1 2 4 8; do
echo "== RAYON_NUM_THREADS=$threads =="
RAYON_NUM_THREADS=$threads cargo test --release \
--features approx,rayon --test determinism
done
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets --all-features -- -D warnings
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# rustfmt.toml uses nightly-only options (imports_granularity).
- uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt
- run: cargo +nightly fmt --check
msrv:
name: minimum supported Rust version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.85.0
- uses: Swatinem/rust-cache@v2
- run: cargo check --all-targets --features approx,rayon
+80 -26
View File
@@ -5,42 +5,96 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands ## Commands
```bash ```bash
cargo build # Build the library just test # Full suite across every feature combination CI checks
cargo test --lib # Run all library tests just check # Fast inner loop: cargo test --features approx
cargo test --lib <test_name> # Run a single test by name just lint # clippy, warnings denied
cargo test --lib -- --nocapture # Run tests with stdout output just fmt # ALWAYS nightly — rustfmt.toml uses nightly-only options
cargo clippy # Lint just determinism # Bit-identical posteriors at RAYON_NUM_THREADS 1/2/4/8
cargo bench # Run benchmarks (criterion) just ci # Everything CI runs
cargo test --lib <test_name> # A single test by name
cargo bench # Criterion benchmarks
``` ```
The `approx` feature enables `approx::AbsDiffEq` for `Gaussian`: **Run tests in release too.** `debug_assert!` is compiled out there, and that
```bash is where several defects have hidden — a debug-only run is not evidence.
cargo test --features approx `just test` includes a release job.
```
### Feature flags
- `approx``approx::AbsDiffEq` etc. for `Gaussian`. Most numerical goldens need it.
- `rayon` — opt-in parallel within-slice sweep and per-slice query passes.
## Architecture ## Architecture
This is a Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py) — a Bayesian skill rating system that tracks skill evolution over time using Gaussian message passing. A Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py):
Bayesian skill rating that infers skill at every point in time, propagating
evidence both forward and backward across a history.
### Data flow ### Data flow
``` ```
History → Batch[] → Game[] → teams/players History → TimeSlice[] → Event[] → Team[] → Item[]
Game (factor graph) → Schedule → BuiltinFactor[]
``` ```
- **`History`** (`history.rs`) — top-level container. Organizes games by time into `Batch`es, runs forward/backward message passing across batches, and exposes `learning_curves()` and `log_evidence()`. - **`History`** (`history.rs`) — top level. Interns keys, groups events into
- **`Batch`** (`batch.rs`) — all games at a single time step. Runs `iteration()` to update skill estimates via `Game::posteriors()`, collecting `Skill` distributions per player. `TimeSlice`s by time, runs the forward/backward sweep in `converge()`, and
- **`Game`** (`game.rs`) — a single match. Given teams (slices of `Gaussian`), computes posterior skill distributions using Gaussian factor graphs and `message.rs` helpers. answers `learning_curves()`, `current_skill()`, `log_evidence()`,
- **`Agent`** (`agent.rs`) — wraps a `Player` with temporal state (`last_time`, `message`). `receive()` applies time-decay (`gamma`) when the player reappears after a gap. `predict_quality()`, `predict_outcome()`. Built via `HistoryBuilder`.
- **`Player`** (`player.rs`) — static configuration: prior `Gaussian`, `beta` (performance noise), `gamma` (skill drift per time unit). - **`TimeSlice`** (`time_slice.rs`) — all events at one time. Owns a
- **`Gaussian`** (`gaussian.rs`) — core probability type. Stored as natural parameters (`pi = 1/sigma²`, `tau = mu/sigma²`). Arithmetic ops implement message multiplication/division in the factor graph. `SkillStore` and a `ScratchArena`; `iteration()` sweeps its events, using
- **`message.rs`** — `TeamMessage` and `DiffMessage`: intermediate factor graph messages used inside `Game`. `ColorGroups` to partition independent ones.
- **`MarginFactor`** (`factor/margin.rs`) — Gaussian observation factor on a diff variable; engaged by `Outcome::Scored`. - **`Event`** (`time_slice.rs`) — one match. `compute()` runs inference reading
- **`lib.rs`** — exports the public API (`Game`, `Gaussian`, `History`, `Player`) and standalone functions (`quality()`, `pdf()`, `cdf()`, `erfc()`). Also defines global defaults: `MU=0.0`, `SIGMA=6.0`, `BETA=1.0`, `GAMMA=0.03`, `P_DRAW=0.0`, `EPSILON=1e-6`, `ITERATIONS=30`. skills immutably; `apply()` folds the result back. The split is what lets a
color group run in parallel with no `unsafe`.
- **`Game`** (`game.rs`) — a single match's factor graph. `run_chain` builds the
diff chain between rank-adjacent teams and drives it to convergence.
- **`Gaussian`** (`gaussian.rs`) — natural parameters (`pi = 1/sigma²`,
`tau = mu/sigma²`). `Mul`/`Div` are the EP product/cavity: pure adds and
subtracts. Variance-space ops (`Add`, `Sub`, `exclude`, `forget`) go through
`from_mv`/`variance()` and take no square root.
- **`factor/`** — `TeamSumFactor`, `RankDiffFactor`, `TruncFactor` (ranked),
`MarginFactor` (scored), over a flat `VarStore`. `BuiltinFactor` dispatches
by enum rather than `dyn`.
- **`Schedule`** (`schedule.rs`) — drives factor propagation. `EpsilonOrMax` is
the only implementation.
- **`Competitor`** (`competitor.rs`) — per-history temporal state (`message`,
`last_time`). **`Rating`** (`rating.rs`) — static config (prior, `beta`, drift).
- **`storage/`** — `SkillStore` (per slice) and `CompetitorStore` (per history),
both dense `Vec`s indexed by `Index`.
- **`KeyTable`** (`key_table.rs`) — user key ↔ `Index`, both directions O(1).
- **`Drift`** (`drift.rs`) / **`Time`** (`time.rs`) — traits. `Time` is a *trait*
(`i64`, `Untimed`), not an enum.
- **`lib.rs`** — public exports, global defaults (`MU`, `SIGMA`, `BETA`,
`GAMMA`, `P_DRAW`, `EPSILON`, `ITERATIONS`), and the standalone `quality()`,
`cdf()`, `erfc()`.
### Key design points ### Invariants worth knowing
- `History` uses `IndexMap<K>` (defined in `lib.rs`) to map arbitrary player keys to `Agent` state. - **A tie needs `p_draw > 0`.** With `p_draw == 0.0` the truncation margin is
- Convergence is measured by the maximum `delta()` across all skill distributions; iteration stops when below `EPSILON` or after `ITERATIONS` rounds. zero and the two-sided tie update evaluates `0/0`. Ingestion rejects such
- The `approx` feature gates `AbsDiffEq` on `Gaussian` for use in tests — the feature is optional and only needed for approximate equality assertions. events with `InferenceError::TieWithoutDrawProbability`. This includes
- `time` in `History`/`Batch` is currently an `f64`; the README notes it needs to become an enum to support richer temporal states. `Outcome::winner(w, n)` for `n >= 3`, which ties every loser.
- **NaN is never convergence.** Comparisons against NaN are all false, so
`tuple_gt` reads NaN as "below epsilon". Use `step_converged` /
`step_is_finite`, never `!tuple_gt(..)` alone.
- **Evidence accumulates in log space.** A linear product over a long diff
chain underflows to zero, and `ln(0)` is `-inf`.
- **Colors are contiguous.** `recompute_color_groups` reorders events so each
color occupies one range; `ColorGroups::groups_are_contiguous` asserts it.
- **The crate is `#![forbid(unsafe_code)]`.** Keep it that way.
- **Ingestion order must not change the answer.** Events added one at a time
must converge to the same fixed point as the same events batched — see
`tests/ingestion_equivalence.rs`.
### Testing notes
- Numerical goldens are cross-validated against the Python/Julia reference.
Some are *convergence residuals*, not exact values; treat a small movement
as suspicious but check whether the new value is closer to the analytic
truth (symmetric fixtures converge to their prior mean exactly) before
assuming a regression.
- `tests/degenerate_inputs.rs` covers empty/boundary/error paths,
`tests/ingestion_equivalence.rs` covers batching order, `tests/quality.rs`
covers N-group quality, `tests/determinism.rs` covers thread counts.
+15
View File
@@ -2,6 +2,17 @@
name = "trueskill-tt" name = "trueskill-tt"
version = "0.1.2" version = "0.1.2"
edition = "2024" edition = "2024"
rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
repository = "https://git.aceofba.se/logaritmisk/trueskill-tt"
readme = "README.md"
keywords = ["trueskill", "rating", "bayesian", "elo", "skill"]
categories = ["algorithms", "science", "game-development"]
# TODO: pick a licence before publishing. `cargo publish` rejects a crate
# without `license` (or `license-file`), and without one the source carries no
# stated terms. The Rust convention is `license = "MIT OR Apache-2.0"` plus the
# matching LICENSE-MIT / LICENSE-APACHE files.
exclude = ["/docs", "/benches/*.txt", "/temp", "/.gitea"]
[lib] [lib]
bench = false bench = false
@@ -22,6 +33,10 @@ harness = false
name = "scored" name = "scored"
harness = false harness = false
[[bench]]
name = "ingest"
harness = false
[dependencies] [dependencies]
approx = { version = "0.5.1", optional = true } approx = { version = "0.5.1", optional = true }
rayon = { version = "1", optional = true } rayon = { version = "1", optional = true }
+35
View File
@@ -1,4 +1,39 @@
alias b := bench alias b := bench
alias t := test
# Run the full test suite across the feature combinations CI checks.
test:
cargo test
cargo test --features approx
cargo test --features approx,rayon
cargo test --release --features approx
# Fast inner-loop tests.
check:
cargo test --features approx
# Posteriors must be bit-identical across rayon worker counts.
determinism:
#!/usr/bin/env bash
set -euo pipefail
for threads in 1 2 4 8; do
echo "== RAYON_NUM_THREADS=$threads =="
RAYON_NUM_THREADS=$threads cargo test --release \
--features approx,rayon --test determinism
done
lint:
cargo clippy --all-targets --all-features -- -D warnings
# Always nightly: rustfmt.toml uses nightly-only options.
fmt:
cargo +nightly fmt
fmt-check:
cargo +nightly fmt --check
# Everything CI runs.
ci: fmt-check lint test determinism
store: store:
cargo bench -- --save-baseline base cargo bench -- --save-baseline base
+5 -5
View File
@@ -96,8 +96,8 @@ h.converge().unwrap();
- [x] Implement approx for Gaussian - [x] Implement approx for Gaussian
- [x] Add more tests from `TrueSkillThroughTime.jl` - [x] Add more tests from `TrueSkillThroughTime.jl`
- [ ] Add tests for `quality()` (Use [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) as reference) - [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum
- [ ] Benchmark Batch::iteration() - [x] Add examples (`examples/atp.rs`, `examples/scored.rs`)
- [ ] Time needs to be an enum so we can have multiple states (see `batch::compute_elapsed()`) - [x] Add Observer (`Observer` / `NullObserver`)
- [ ] Add examples (use same TrueSkillThroughTime.(py|jl)) - [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
- [ ] Add Observer (see [argmin](https://docs.rs/argmin/latest/argmin/core/trait.Observe.html) for inspiration) - [ ] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N-group support works and is covered by invariants, but no reference values are asserted
+62
View File
@@ -0,0 +1,62 @@
//! Ingestion cost: one event per call versus one batched call.
//!
//! The rest of the suite only measures batched construction, which is why a
//! quadratic in the incremental path went unnoticed — `record_winner` and
//! `event(..).commit()` each ingest a single event, so a caller looping over a
//! match feed takes that path.
use std::hint::black_box;
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use smallvec::smallvec;
use trueskill_tt::{Event, History, Member, Outcome, Team};
fn events(n: usize, time: i64) -> Vec<Event<i64, String>> {
(0..n)
.map(|i| Event {
time,
teams: smallvec![
Team::with_members([Member::new(format!("p{}", 2 * i))]),
Team::with_members([Member::new(format!("p{}", 2 * i + 1))]),
],
outcome: Outcome::winner(0, 2),
})
.collect()
}
fn bench_ingest(c: &mut Criterion) {
let mut group = c.benchmark_group("ingest");
for n in [250usize, 500, 1000] {
group.bench_with_input(BenchmarkId::new("one-at-a-time", n), &n, |b, &n| {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
for ev in evs {
h.add_events(std::iter::once(ev)).unwrap();
}
black_box(h.time_slices_len())
},
criterion::BatchSize::SmallInput,
);
});
group.bench_with_input(BenchmarkId::new("single-batch", n), &n, |b, &n| {
b.iter_batched(
|| events(n, 0),
|evs| {
let mut h: History<i64, _, _, String> = History::builder_with_key().build();
h.add_events(evs).unwrap();
black_box(h.time_slices_len())
},
criterion::BatchSize::SmallInput,
);
});
}
group.finish();
}
criterion_group!(benches, bench_ingest);
criterion_main!(benches);
+46 -10
View File
@@ -26,39 +26,75 @@ pub(crate) struct ColorGroups {
} }
impl ColorGroups { impl ColorGroups {
#[allow(dead_code)]
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
Self::default() Self::default()
} }
#[allow(dead_code)]
pub(crate) fn n_colors(&self) -> usize {
self.groups.len()
}
#[allow(dead_code)]
pub(crate) fn is_empty(&self) -> bool { pub(crate) fn is_empty(&self) -> bool {
self.groups.is_empty() self.groups.is_empty()
} }
/// Total event count across all colors. /// Number of distinct colors in the partition. Test-only.
#[allow(dead_code)] #[cfg(test)]
pub(crate) fn n_colors(&self) -> usize {
self.groups.len()
}
/// Total event count across all colors. Test-only.
#[cfg(test)]
pub(crate) fn total_events(&self) -> usize { pub(crate) fn total_events(&self) -> usize {
self.groups.iter().map(|g| g.len()).sum() self.groups.iter().map(|g| g.len()).sum()
} }
/// Contiguous index range for one color after events have been reordered /// Contiguous index range for one color after events have been reordered
/// into color-contiguous positions by `TimeSlice::recompute_color_groups`. /// into color-contiguous positions by `TimeSlice::recompute_color_groups`.
#[allow(dead_code)]
pub(crate) fn color_range(&self, color_idx: usize) -> std::ops::Range<usize> { pub(crate) fn color_range(&self, color_idx: usize) -> std::ops::Range<usize> {
let group = &self.groups[color_idx]; let group = &self.groups[color_idx];
if group.is_empty() { if group.is_empty() {
return 0..0; return 0..0;
} }
let start = *group.first().unwrap(); let start = *group.first().unwrap();
let end = *group.last().unwrap() + 1; let end = *group.last().unwrap() + 1;
debug_assert_eq!(
end - start,
group.len(),
"color {color_idx} is not contiguous; its range would overlap other colors"
);
start..end start..end
} }
/// Whether every color occupies a contiguous, ascending range of event
/// indices, and no two colors overlap.
///
/// The parallel sweep derives one `&mut` sub-slice per color from these
/// ranges and relies on them being disjoint. That disjointness is what
/// makes concurrent writes to distinct skills sound, so it is checked
/// rather than assumed.
pub(crate) fn groups_are_contiguous(&self) -> bool {
let mut expected_start = 0;
for group in &self.groups {
if group.is_empty() {
continue;
}
let ascending_run = group
.iter()
.enumerate()
.all(|(offset, &idx)| idx == group[0] + offset);
if !ascending_run || group[0] != expected_start {
return false;
}
expected_start += group.len();
}
true
}
} }
/// Compute color groups greedily. /// Compute color groups greedily.
+41
View File
@@ -1,6 +1,7 @@
use std::fmt; use std::fmt;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum InferenceError { pub enum InferenceError {
/// Expected and actual lengths of some array-shaped input differ. /// Expected and actual lengths of some array-shaped input differ.
MismatchedShape { MismatchedShape {
@@ -8,15 +9,35 @@ pub enum InferenceError {
expected: usize, expected: usize,
got: usize, got: usize,
}, },
/// An `Outcome` of the wrong variant was supplied for the requested inference.
WrongOutcomeKind {
context: &'static str,
expected: &'static str,
got: &'static str,
},
/// A probability value is outside `[0, 1]`. /// A probability value is outside `[0, 1]`.
InvalidProbability { value: f64 }, InvalidProbability { value: f64 },
/// A scalar parameter is outside its valid range. /// A scalar parameter is outside its valid range.
InvalidParameter { name: &'static str, value: f64 }, InvalidParameter { name: &'static str, value: f64 },
/// An event contains tied teams, but the draw probability is zero.
///
/// A zero draw probability asserts that draws cannot occur, so a tied
/// result has no representable likelihood. Configure a positive `p_draw`
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
TieWithoutDrawProbability { teams: (usize, usize) },
/// Convergence exceeded `max_iter` without falling below `epsilon`. /// Convergence exceeded `max_iter` without falling below `epsilon`.
ConvergenceFailed { ConvergenceFailed {
last_step: (f64, f64), last_step: (f64, f64),
iterations: usize, iterations: usize,
}, },
/// Inference produced a non-finite value (NaN or infinity).
///
/// Indicates numerical breakdown; the resulting skills are meaningless
/// and must not be treated as a converged estimate.
NonFiniteResult {
context: &'static str,
step: (f64, f64),
},
/// Negative precision: a Gaussian with `pi < 0` slipped into an API call. /// Negative precision: a Gaussian with `pi < 0` slipped into an API call.
NegativePrecision { pi: f64 }, NegativePrecision { pi: f64 },
} }
@@ -31,9 +52,29 @@ impl fmt::Display for InferenceError {
} => { } => {
write!(f, "{kind}: expected length {expected}, got {got}") write!(f, "{kind}: expected length {expected}, got {got}")
} }
Self::WrongOutcomeKind {
context,
expected,
got,
} => {
write!(f, "{context}: expected {expected}, got {got}")
}
Self::InvalidProbability { value } => { Self::InvalidProbability { value } => {
write!(f, "probability must be in [0, 1]; got {value}") write!(f, "probability must be in [0, 1]; got {value}")
} }
Self::TieWithoutDrawProbability { teams } => {
write!(
f,
"teams {} and {} are tied, but p_draw is 0.0; set a positive draw probability to admit ties",
teams.0, teams.1
)
}
Self::NonFiniteResult { context, step } => {
write!(
f,
"{context}: inference produced a non-finite result (step = {step:?})"
)
}
Self::InvalidParameter { name, value } => { Self::InvalidParameter { name, value } => {
write!(f, "{name} is invalid: {value}") write!(f, "{name} is invalid: {value}")
} }
+5 -1
View File
@@ -64,9 +64,13 @@ impl Factor for MarginFactor {
} }
} }
/// Density of the observed margin under the cavity, clamped to a positive
/// floor so a far-out observation cannot underflow to `0.0` and make
/// `log_evidence` `-inf`.
fn cavity_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 { fn cavity_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt(); let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt();
pdf(m_obs, cavity.mu(), combined_sigma)
pdf(m_obs, cavity.mu(), combined_sigma).max(f64::MIN_POSITIVE)
} }
#[cfg(test)] #[cfg(test)]
+10 -2
View File
@@ -72,12 +72,20 @@ impl Factor for TruncFactor {
} }
/// P(diff > margin) for non-tie, P(|diff| < margin) for tie. /// P(diff > margin) for non-tie, P(|diff| < margin) for tie.
///
/// Clamped to a positive floor: for a near-certain outcome the tail rounds to
/// exactly 0.0, and the `erfc` approximation used by `cdf` carries ~1e-7 error
/// so it can even return slightly more than 1.0, making the difference
/// negative. Either would send `log_evidence` to `-inf` or NaN and poison the
/// sum across the whole history.
fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 { fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
if tie { let raw = if tie {
cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma()) cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma())
} else { } else {
1.0 - cdf(margin, diff.mu(), diff.sigma()) 1.0 - cdf(margin, diff.mu(), diff.sigma())
} };
raw.clamp(f64::MIN_POSITIVE, 1.0)
} }
#[cfg(test)] #[cfg(test)]
+61 -53
View File
@@ -37,10 +37,17 @@ impl DiffFactor {
} }
} }
pub(crate) fn evidence(&self) -> f64 { /// Log of this link's cached evidence.
///
/// Accumulating in log space keeps a long diff chain from underflowing:
/// each link contributes a probability in `(0, 1]`, so the linear product
/// over an n-team game decays geometrically and flushes to zero — and
/// `ln(0.0)` is `-inf` — well within the team counts a large free-for-all
/// reaches.
pub(crate) fn log_evidence(&self) -> f64 {
match self { match self {
Self::Trunc(f) => f.evidence_cached.unwrap_or(1.0), Self::Trunc(f) => f.evidence_cached.unwrap_or(1.0).ln(),
Self::Margin(f) => f.evidence_cached.unwrap_or(1.0), Self::Margin(f) => f.evidence_cached.unwrap_or(1.0).ln(),
} }
} }
@@ -81,18 +88,14 @@ impl Default for GameOptions {
/// Owned variant of `Game` returned by public constructors. /// Owned variant of `Game` returned by public constructors.
/// ///
/// Unlike `Game<'a, T, D>` (which borrows its result/weights slices from /// Unlike `Game<'a, T, D>` (which borrows its result/weights slices from
/// History's internal state), `OwnedGame<T, D>` owns its inputs so it can /// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
/// be returned freely from public constructors. /// can be returned freely from public constructors. The inference inputs
/// themselves are not retained — nothing reads them back.
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)]
pub struct OwnedGame<T: Time, D: Drift<T>> { pub struct OwnedGame<T: Time, D: Drift<T>> {
teams: Vec<Vec<Rating<T, D>>>, teams: Vec<Vec<Rating<T, D>>>,
result: Vec<f64>,
weights: Vec<Vec<f64>>,
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>, pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64, pub(crate) log_evidence: f64,
} }
impl<T: Time, D: Drift<T>> OwnedGame<T, D> { impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
@@ -112,16 +115,10 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
convergence, convergence,
&mut arena, &mut arena,
); );
let likelihoods = g.likelihoods;
let evidence = g.evidence;
Self { Self {
teams, teams,
result, likelihoods: g.likelihoods,
weights, log_evidence: g.log_evidence,
p_draw,
convergence,
likelihoods,
evidence,
} }
} }
@@ -141,16 +138,10 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
convergence, convergence,
&mut arena, &mut arena,
); );
let likelihoods = g.likelihoods;
let evidence = g.evidence;
Self { Self {
teams, teams,
result: scores, likelihoods: g.likelihoods,
weights, log_evidence: g.log_evidence,
p_draw: 0.0,
convergence,
likelihoods,
evidence,
} }
} }
@@ -163,7 +154,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
} }
pub fn log_evidence(&self) -> f64 { pub fn log_evidence(&self) -> f64 {
self.evidence.ln() self.log_evidence
} }
} }
@@ -175,7 +166,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
p_draw: f64, p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions, pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>, pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64, pub(crate) log_evidence: f64,
} }
impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> { impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
@@ -222,7 +213,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
p_draw, p_draw,
convergence, convergence,
likelihoods: Vec::new(), likelihoods: Vec::new(),
evidence: 0.0, log_evidence: 0.0,
}; };
this.likelihoods(arena); this.likelihoods(arena);
@@ -261,7 +252,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
p_draw: 0.0, p_draw: 0.0,
convergence, convergence,
likelihoods: Vec::new(), likelihoods: Vec::new(),
evidence: 0.0, log_evidence: 0.0,
}; };
this.likelihoods_scored(arena, score_sigma); this.likelihoods_scored(arena, score_sigma);
@@ -355,7 +346,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg(); arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
} }
let evidence: f64 = links.iter().map(|l| l.evidence()).product(); let log_evidence: f64 = links.iter().map(DiffFactor::log_evidence).sum();
// Inverse permutation: inv_buf[orig_i] = sorted_i. // Inverse permutation: inv_buf[orig_i] = sorted_i.
arena.inv_buf.resize(n_teams, 0); arena.inv_buf.resize(n_teams, 0);
@@ -371,10 +362,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.map(|(orig_i, (players, weights))| { .map(|(orig_i, (players, weights))| {
let si = arena.inv_buf[orig_i]; let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si] * arena.lhood_lose[si]; let m = arena.lhood_win[si] * arena.lhood_lose[si];
let performance = players // Already folded into `team_prior` at the top of the chain,
.iter() // indexed by sorted position.
.zip(weights.iter()) let performance = arena.team_prior[si];
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
players players
.iter() .iter()
.zip(weights.iter()) .zip(weights.iter())
@@ -386,11 +376,11 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
(evidence, likelihoods) (log_evidence, likelihoods)
} }
fn likelihoods(&mut self, arena: &mut ScratchArena) { fn likelihoods(&mut self, arena: &mut ScratchArena) {
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| { let (log_evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]]; let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
let margin = if self.p_draw == 0.0 { let margin = if self.p_draw == 0.0 {
0.0 0.0
@@ -405,17 +395,17 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
let vid = vars.alloc(N_INF); let vid = vars.alloc(N_INF);
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie)) DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
}); });
self.evidence = evidence; self.log_evidence = log_evidence;
self.likelihoods = likelihoods; self.likelihoods = likelihoods;
} }
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) { fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| { let (log_evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]]; let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
let vid = vars.alloc(N_INF); let vid = vars.alloc(N_INF);
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma)) DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
}); });
self.evidence = evidence; self.log_evidence = log_evidence;
self.likelihoods = likelihoods; self.likelihoods = likelihoods;
} }
@@ -433,7 +423,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
} }
pub fn log_evidence(&self) -> f64 { pub fn log_evidence(&self) -> f64 {
self.evidence.ln() self.log_evidence
} }
} }
@@ -458,11 +448,22 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
let ranks = outcome let ranks = outcome
.as_ranks() .as_ranks()
.ok_or(crate::InferenceError::MismatchedShape { .ok_or(crate::InferenceError::WrongOutcomeKind {
kind: "Game::ranked requires Outcome::Ranked", context: "Game::ranked",
expected: 0, expected: "Outcome::Ranked",
got: 0, got: "Outcome::Scored",
})?; })?;
let tied = if options.p_draw == 0.0 {
crate::first_tied_pair(ranks)
} else {
None
};
if let Some(teams) = tied {
return Err(crate::InferenceError::TieWithoutDrawProbability { teams });
}
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64; let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
let result: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect(); let result: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect();
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();
@@ -497,10 +498,10 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
} }
let scores = outcome let scores = outcome
.as_scores() .as_scores()
.ok_or(crate::InferenceError::MismatchedShape { .ok_or(crate::InferenceError::WrongOutcomeKind {
kind: "Game::scored requires Outcome::Scored", context: "Game::scored",
expected: 0, expected: "Outcome::Scored",
got: 0, got: "Outcome::Ranked",
})? })?
.to_vec(); .to_vec();
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();
@@ -730,8 +731,12 @@ mod tests {
let a = p[0][0]; let a = p[0][0];
let b = p[1][0]; let b = p[1][0];
assert_ulps_eq!(a, Gaussian::from_ms(24.999999, 6.469480), epsilon = 1e-6); // Two identical competitors drawing must land on their shared prior
assert_ulps_eq!(b, Gaussian::from_ms(24.999999, 6.469480), epsilon = 1e-6); // mean exactly, by symmetry. The reference transcription of 24.999999
// is that value rounded to six decimals; asserting it at epsilon 1e-6
// left no headroom. The root-free variance path now hits 25.0 exactly.
assert_ulps_eq!(a, Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
let t_a = R::new( let t_a = R::new(
Gaussian::from_ms(25.0, 3.0), Gaussian::from_ms(25.0, 3.0),
@@ -1124,7 +1129,10 @@ mod tests {
&GameOptions::default(), &GameOptions::default(),
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(err, crate::InferenceError::MismatchedShape { .. })); assert!(matches!(
err,
crate::InferenceError::WrongOutcomeKind { .. }
));
} }
#[test] #[test]
+45 -13
View File
@@ -35,6 +35,28 @@ impl Gaussian {
} }
} }
/// Construct from mean and *variance*, skipping the square-root round trip.
///
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to
/// recover `pi = 1/var`. Variance-combining operations (`Add`, `Sub`,
/// `exclude`, `forget`) work in variance space throughout, so they go
/// through here instead and never take a root.
#[inline]
pub(crate) fn from_mv(mu: f64, var: f64) -> Self {
if var == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 }
} else if var == 0.0 {
// Point mass at mu; see `from_ms` for the tau convention.
Self {
pi: f64::INFINITY,
tau: if mu == 0.0 { 0.0 } else { f64::INFINITY },
}
} else {
let pi = 1.0 / var;
Self { pi, tau: mu * pi }
}
}
/// Construct directly from natural parameters. /// Construct directly from natural parameters.
#[inline] #[inline]
pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self { pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self {
@@ -64,6 +86,21 @@ impl Gaussian {
} }
} }
/// Variance, `1 / pi`, without the root-and-square of `sigma().powi(2)`.
///
/// Mirrors `sigma()`'s treatment of the improper (`pi <= 0`) and point-mass
/// (`pi == inf`) cases.
#[inline]
pub(crate) fn variance(&self) -> f64 {
if self.pi <= 0.0 {
f64::INFINITY
} else if self.pi.is_infinite() {
0.0
} else {
1.0 / self.pi
}
}
#[inline] #[inline]
pub fn sigma(&self) -> f64 { pub fn sigma(&self) -> f64 {
// A non-positive precision is improper → infinite standard deviation. Guarding // A non-positive precision is improper → infinite standard deviation. Guarding
@@ -86,22 +123,21 @@ impl Gaussian {
} }
pub(crate) fn exclude(&self, other: Gaussian) -> Self { pub(crate) fn exclude(&self, other: Gaussian) -> Self {
let var = self.sigma().powi(2) - other.sigma().powi(2); let var = self.variance() - other.variance();
if var <= 0.0 { if var <= 0.0 {
// When sigma_self ≈ sigma_other (including ULP-level rounding differences // When sigma_self ≈ sigma_other (including ULP-level rounding differences
// from the pi→sigma accessor round-trip), the excluded contribution is N00. // from the pi→sigma accessor round-trip), the excluded contribution is N00.
// Computing from_ms(tiny_mu, 0.0) would give {pi:inf, tau:inf}, whose // Computing from_ms(tiny_mu, 0.0) would give {pi:inf, tau:inf}, whose
// mu() = inf/inf = NaN. Returning N00 is correct: when both Gaussians // mu() = inf/inf = NaN. Returning N00 is correct: when both Gaussians
// carry the same variance, the residual is a point mass at 0. // carry the same variance, the residual is a point mass at 0.
return Gaussian::from_ms(0.0, 0.0); return Gaussian::from_mv(0.0, 0.0);
} }
let mu = self.mu() - other.mu();
Self::from_ms(mu, var.sqrt()) Self::from_mv(self.mu() - other.mu(), var)
} }
pub(crate) fn forget(&self, variance_delta: f64) -> Self { pub(crate) fn forget(&self, variance_delta: f64) -> Self {
let var = self.sigma().powi(2) + variance_delta; Self::from_mv(self.mu(), self.variance() + variance_delta)
Self::from_ms(self.mu(), var.sqrt())
} }
/// EP damping in natural-parameter space: `α·new + (1−α)·self`. /// EP damping in natural-parameter space: `α·new + (1−α)·self`.
@@ -128,9 +164,7 @@ impl ops::Add<Gaussian> for Gaussian {
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)). /// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)).
/// Used for combining performance and noise; rare relative to mul/div. /// Used for combining performance and noise; rare relative to mul/div.
fn add(self, rhs: Gaussian) -> Self::Output { fn add(self, rhs: Gaussian) -> Self::Output {
let mu = self.mu() + rhs.mu(); Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
let var = self.sigma().powi(2) + rhs.sigma().powi(2);
Self::from_ms(mu, var.sqrt())
} }
} }
@@ -138,9 +172,7 @@ impl ops::Sub<Gaussian> for Gaussian {
type Output = Gaussian; type Output = Gaussian;
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add. /// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add.
fn sub(self, rhs: Gaussian) -> Self::Output { fn sub(self, rhs: Gaussian) -> Self::Output {
let mu = self.mu() - rhs.mu(); Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
let var = self.sigma().powi(2) + rhs.sigma().powi(2);
Self::from_ms(mu, var.sqrt())
} }
} }
@@ -161,7 +193,7 @@ impl ops::Mul<f64> for Gaussian {
if scalar == 0.0 { if scalar == 0.0 {
// Scaling by 0 collapses to a point mass at 0 (sigma' = 0, mu' = 0). // Scaling by 0 collapses to a point mass at 0 (sigma' = 0, mu' = 0).
// This is N00, the additive identity, NOT N_INF. // This is N00, the additive identity, NOT N_INF.
return Gaussian::from_ms(0.0, 0.0); return Gaussian::from_mv(0.0, 0.0);
} }
// sigma' = sigma * |scalar| => pi' = pi / scalar² // sigma' = sigma * |scalar| => pi' = pi / scalar²
// mu' = mu * scalar => tau' = tau / scalar // mu' = mu * scalar => tau' = tau / scalar
+107 -6
View File
@@ -69,7 +69,20 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
} }
} }
/// Probability that two evenly-matched sides draw.
///
/// Must be in `[0.0, 1.0)`. A zero draw probability asserts that draws
/// cannot occur, so ingesting a tied outcome then fails with
/// `InferenceError::TieWithoutDrawProbability`.
///
/// # Panics
///
/// Panics if `p_draw` is outside `[0.0, 1.0)` or is NaN.
pub fn p_draw(mut self, p_draw: f64) -> Self { pub fn p_draw(mut self, p_draw: f64) -> Self {
assert!(
(0.0..1.0).contains(&p_draw),
"p_draw must be in [0.0, 1.0) (got {p_draw})"
);
self.p_draw = p_draw; self.p_draw = p_draw;
self self
} }
@@ -79,6 +92,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
self self
} }
/// Default observation noise for scored outcomes.
///
/// # Panics
///
/// Panics if `score_sigma` is not strictly positive.
pub fn score_sigma(mut self, score_sigma: f64) -> Self { pub fn score_sigma(mut self, score_sigma: f64) -> Self {
assert!( assert!(
score_sigma > 0.0, score_sigma > 0.0,
@@ -88,7 +106,24 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
self self
} }
/// Convergence tolerance, iteration cap, and EP damping.
///
/// # Panics
///
/// Panics if `alpha` is outside `(0.0, 1.0]`, or if `epsilon` is negative
/// or NaN. An `alpha` of zero would leave every EP update unapplied, so
/// inference would silently return the priors.
pub fn convergence(mut self, opts: ConvergenceOptions) -> Self { pub fn convergence(mut self, opts: ConvergenceOptions) -> Self {
assert!(
opts.alpha > 0.0 && opts.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0] (got {})",
opts.alpha
);
assert!(
opts.epsilon >= 0.0,
"convergence epsilon must be non-negative (got {})",
opts.epsilon
);
self.convergence = opts; self.convergence = opts;
self self
} }
@@ -220,6 +255,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
fn iteration(&mut self) -> (f64, f64) { fn iteration(&mut self) -> (f64, f64) {
let mut step = (0.0, 0.0); let mut step = (0.0, 0.0);
if self.time_slices.is_empty() {
return step;
}
competitor::clean(self.agents.values_mut(), false); competitor::clean(self.agents.values_mut(), false);
for j in (0..self.time_slices.len() - 1).rev() { for j in (0..self.time_slices.len() - 1).rev() {
@@ -273,6 +312,12 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
step step
} }
/// Number of distinct time slices in the history.
#[must_use]
pub fn time_slices_len(&self) -> usize {
self.time_slices.len()
}
/// Learning curves for all competitors, keyed by their user-facing key. /// Learning curves for all competitors, keyed by their user-facing key.
/// ///
/// Note: `key(idx)` is O(n) per lookup; this method is therefore O(n²) /// Note: `key(idx)` is O(n) per lookup; this method is therefore O(n²)
@@ -385,7 +430,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// Draw-probability quality metric for the given teams (key slices). /// Draw-probability quality metric for the given teams (key slices).
/// ///
/// Values range roughly [0, 1]; 1 == perfectly matched. /// Values range roughly [0, 1]; 1 == perfectly matched. Supports any
/// number of teams.
///
/// # Panics
///
/// Panics if fewer than two teams are supplied, or if a team resolves to
/// no known competitors — keys absent from the history, or competitors
/// with no recorded skill, are dropped, so a team of entirely-unknown
/// keys becomes empty. Use `lookup` to check keys first.
pub fn predict_quality(&self, teams: &[&[&K]]) -> f64 { pub fn predict_quality(&self, teams: &[&[&K]]) -> f64 {
let groups: Vec<Vec<Gaussian>> = teams let groups: Vec<Vec<Gaussian>> = teams
.iter() .iter()
@@ -435,6 +488,18 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
use smallvec::SmallVec; use smallvec::SmallVec;
let opts = self.convergence; let opts = self.convergence;
if self.time_slices.is_empty() {
return Ok(ConvergenceReport {
iterations: 0,
final_step: (0.0, 0.0),
log_evidence: 0.0,
converged: true,
per_iteration_time: SmallVec::new(),
slices_skipped: 0,
});
}
let mut step = (f64::INFINITY, f64::INFINITY); let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0; let mut i = 0;
let mut per_iter: SmallVec<[std::time::Duration; 32]> = SmallVec::new(); let mut per_iter: SmallVec<[std::time::Duration; 32]> = SmallVec::new();
@@ -444,8 +509,24 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
per_iter.push(t0.elapsed()); per_iter.push(t0.elapsed());
i += 1; i += 1;
self.observer.on_iteration_end(i, step); self.observer.on_iteration_end(i, step);
// A non-finite step means EP has broken down; further iterations
// cannot recover, and `tuple_gt` would read NaN as converged.
if !crate::step_is_finite(step) {
break;
} }
let converged = !tuple_gt(step, opts.epsilon); }
if !crate::step_is_finite(step) {
self.observer.on_converged(i, step, false);
return Err(InferenceError::NonFiniteResult {
context: "History::converge",
step,
});
}
let converged = crate::step_converged(step, opts.epsilon);
let log_evidence = self.log_evidence_internal(false, &[]); let log_evidence = self.log_evidence_internal(false, &[]);
self.observer.on_converged(i, step, converged); self.observer.on_converged(i, step, converged);
Ok(ConvergenceReport { Ok(ConvergenceReport {
@@ -498,6 +579,21 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}); });
} }
// Chokepoint for tie validation: every ingestion route lands here,
// including `record_draw`, which builds its results directly rather
// than going through `Outcome`.
if self.p_draw == 0.0 {
for (event_results, kind) in results.iter().zip(kinds.iter()) {
if !matches!(kind, EventKind::Ranked) {
continue;
}
if let Some(teams) = crate::first_tied_output(event_results) {
return Err(InferenceError::TieWithoutDrawProbability { teams });
}
}
}
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);
@@ -593,6 +689,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
agent.last_time = Some(t); agent.last_time = Some(t);
agent.message = time_slice.forward_prior_out(&agent_idx); agent.message = time_slice.forward_prior_out(&agent_idx);
} }
k += 1;
} else { } else {
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence); let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents); time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents);
@@ -734,10 +832,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
} }
crate::Outcome::Scored { scores, sigma } => { crate::Outcome::Scored { scores, sigma } => {
let resolved = sigma.unwrap_or(self.score_sigma); let resolved = sigma.unwrap_or(self.score_sigma);
debug_assert!( if resolved <= 0.0 || resolved.is_nan() {
resolved > 0.0, return Err(InferenceError::InvalidParameter {
"resolved score_sigma must be > 0.0 (got {resolved})" name: "score_sigma",
); value: resolved,
});
}
kinds.push(EventKind::Scored { kinds.push(EventKind::Scored {
score_sigma: resolved, score_sigma: resolved,
}); });
+24 -15
View File
@@ -12,59 +12,68 @@ use crate::Index;
/// crate. Power users can promote `&K` to `Index` via `get_or_create` and /// crate. Power users can promote `&K` to `Index` via `get_or_create` and
/// skip the lookup on subsequent hot-path calls. /// skip the lookup on subsequent hot-path calls.
#[derive(Debug)] #[derive(Debug)]
pub struct KeyTable<K>(HashMap<K, Index>); pub struct KeyTable<K> {
forward: HashMap<K, Index>,
/// Reverse mapping, indexed by `Index.0`.
///
/// Indices are handed out densely and sequentially, so position *is* the
/// index and `key()` is a lookup rather than a scan over every entry.
reverse: Vec<K>,
}
impl<K> KeyTable<K> impl<K> KeyTable<K>
where where
K: Eq + Hash, K: Eq + Hash + Clone,
{ {
pub fn new() -> Self { pub fn new() -> Self {
Self(HashMap::new()) Self {
forward: HashMap::new(),
reverse: Vec::new(),
}
} }
pub fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index> pub fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
where where
K: Borrow<Q>, K: Borrow<Q>,
{ {
self.0.get(k).cloned() self.forward.get(k).cloned()
} }
pub fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(&mut self, k: &Q) -> Index pub fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(&mut self, k: &Q) -> Index
where where
K: Borrow<Q>, K: Borrow<Q>,
{ {
if let Some(idx) = self.0.get(k) { if let Some(idx) = self.forward.get(k) {
*idx *idx
} else { } else {
let idx = Index::from(self.0.len()); let idx = Index::from(self.reverse.len());
self.0.insert(k.to_owned(), idx); let owned = k.to_owned();
self.reverse.push(owned.clone());
self.forward.insert(owned, idx);
idx idx
} }
} }
pub fn key(&self, idx: Index) -> Option<&K> { pub fn key(&self, idx: Index) -> Option<&K> {
self.0 self.reverse.get(idx.0)
.iter()
.find(|&(_, value)| *value == idx)
.map(|(key, _)| key)
} }
pub fn keys(&self) -> impl Iterator<Item = &K> { pub fn keys(&self) -> impl Iterator<Item = &K> {
self.0.keys() self.forward.keys()
} }
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.0.len() self.reverse.len()
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.0.is_empty() self.reverse.is_empty()
} }
} }
impl<K> Default for KeyTable<K> impl<K> Default for KeyTable<K>
where where
K: Eq + Hash, K: Eq + Hash + Clone,
{ {
fn default() -> Self { fn default() -> Self {
KeyTable::new() KeyTable::new()
+179 -7
View File
@@ -1,3 +1,91 @@
//! TrueSkill Through Time — Bayesian skill rating over a time axis.
//!
//! Where plain TrueSkill gives each competitor one running estimate, TrueSkill
//! Through Time treats a whole history as a single model and infers skill *at
//! every point in time*. Evidence flows both directions: a result today
//! sharpens the estimate of who someone was last year, so early estimates stop
//! being frozen guesses and comparisons across eras become meaningful.
//!
//! This is a Rust port of
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
//!
//! # Getting started
//!
//! Record results, converge, then read off skills:
//!
//! ```
//! use trueskill_tt::History;
//!
//! let mut history = History::default();
//!
//! history.record_winner(&"alice", &"bob", 1)?;
//! history.record_winner(&"bob", &"carol", 2)?;
//! history.record_winner(&"alice", &"carol", 3)?;
//!
//! let report = history.converge()?;
//! assert!(report.converged);
//!
//! let alice = history.current_skill("alice").unwrap();
//! assert!(alice.mu() > 0.0, "alice won every game she played");
//! # Ok::<(), trueskill_tt::InferenceError>(())
//! ```
//!
//! Teams, weights, explicit rankings and continuous scores go through the
//! fluent event builder:
//!
//! ```
//! use trueskill_tt::History;
//!
//! let mut history = History::builder().p_draw(0.1).build();
//!
//! history
//! .event(1)
//! .team(["alice", "bob"])
//! .team(["carol", "dave"])
//! .ranking([0, 1])
//! .commit()?;
//!
//! history.converge()?;
//! # Ok::<(), trueskill_tt::InferenceError>(())
//! ```
//!
//! # Draws need a draw probability
//!
//! A `p_draw` of zero asserts that draws cannot happen, so a tied result has
//! no representable likelihood and is rejected:
//!
//! ```
//! use trueskill_tt::{History, InferenceError};
//!
//! let mut history = History::default(); // p_draw defaults to 0.0
//! let err = history.record_draw(&"alice", &"bob", 1).unwrap_err();
//! assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. }));
//! ```
//!
//! This also applies to [`Outcome::winner`] for three or more teams, which
//! ties every loser. Configure a positive `p_draw` for those.
//!
//! # Core types
//!
//! - [`History`] — the top-level container: ingests events, runs
//! forward/backward message passing, and answers queries.
//! - [`Gaussian`] — the probability type, stored in natural parameters
//! (`pi = 1/sigma²`, `tau = mu/sigma²`) so message passing is add/subtract.
//! - [`Game`] — one match in isolation, for scoring a hypothetical without a
//! history.
//! - [`Outcome`] — how a match ended: ranks, or continuous scores.
//! - [`Rating`] — a competitor's static configuration (prior, `beta`, drift).
//!
//! # Feature flags
//!
//! - `approx` — implements [`approx`](https://docs.rs/approx) equality traits
//! for [`Gaussian`]. Useful in tests.
//! - `rayon` — parallelises the within-slice sweep and the per-slice passes of
//! `learning_curves`/`log_evidence`. Opt-in; results stay bit-identical
//! regardless of worker count.
#![forbid(unsafe_code)]
use std::{ use std::{
cmp::Reverse, cmp::Reverse,
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2}, f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
@@ -37,7 +125,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; pub use history::{History, HistoryBuilder};
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};
@@ -63,12 +151,29 @@ pub const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)] #[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
pub struct Index(usize); pub struct Index(usize);
impl Index {
/// The underlying slot number.
///
/// Indices are dense and assigned in interning order, so this is usable as
/// a key into a caller-side side table.
#[must_use]
pub fn get(self) -> usize {
self.0
}
}
impl From<usize> for Index { impl From<usize> for Index {
fn from(ix: usize) -> Self { fn from(ix: usize) -> Self {
Self(ix) Self(ix)
} }
} }
impl From<Index> for usize {
fn from(idx: Index) -> Self {
idx.0
}
}
fn erfc(x: f64) -> f64 { fn erfc(x: f64) -> f64 {
let z = x.abs(); let z = x.abs();
let t = 1.0 / (1.0 + z / 2.0); let t = 1.0 / (1.0 + z / 2.0);
@@ -184,6 +289,56 @@ pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
t.0 > e || t.1 > e t.0 > e || t.1 > e
} }
/// Whether a convergence step is finite in both components.
///
/// A NaN step means EP broke down numerically. Because every comparison
/// against NaN is false, `tuple_gt` reads NaN as "below epsilon" — so
/// convergence checks must test finiteness explicitly rather than inferring
/// success from `!tuple_gt(..)`.
pub(crate) fn step_is_finite(t: (f64, f64)) -> bool {
t.0.is_finite() && t.1.is_finite()
}
/// Whether a step counts as converged: finite *and* within `epsilon`.
pub(crate) fn step_converged(t: (f64, f64), epsilon: f64) -> bool {
step_is_finite(t) && !tuple_gt(t, epsilon)
}
/// Indices of the first pair of teams sharing a rank, if any.
///
/// A tie is only representable when the draw probability is positive: with
/// `p_draw == 0.0` the truncation margin collapses to zero and the two-sided
/// tie update evaluates `0/0`. Callers use this to reject such events before
/// they reach inference.
pub(crate) fn first_tied_pair(ranks: &[u32]) -> Option<(usize, usize)> {
for (i, a) in ranks.iter().enumerate() {
for (j, b) in ranks.iter().enumerate().skip(i + 1) {
if a == b {
return Some((i, j));
}
}
}
None
}
/// As `first_tied_pair`, but over the engine's internal `f64` outputs.
///
/// Ranks reach the engine already converted to descending `f64` outputs, and
/// `Game` decides a tie by exact equality of those values — so this mirrors
/// the comparison inference itself performs.
pub(crate) fn first_tied_output(outputs: &[f64]) -> Option<(usize, usize)> {
for (i, a) in outputs.iter().enumerate() {
for (j, b) in outputs.iter().enumerate().skip(i + 1) {
if a == b {
return Some((i, j));
}
}
}
None
}
pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> { pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect(); let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect();
@@ -197,7 +352,26 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
} }
/// Calculates the match quality of the given rating groups. A result is the draw probability in the association /// Calculates the match quality of the given rating groups. A result is the draw probability in the association
///
/// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
/// perfectly balanced match.
///
/// # Panics
///
/// Panics if fewer than two rating groups are supplied, or if any group is
/// empty — match quality is a property of a contest between at least two
/// non-empty sides.
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 { pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
assert!(
rating_groups.len() >= 2,
"quality() requires at least 2 rating groups, got {}",
rating_groups.len()
);
assert!(
rating_groups.iter().all(|group| !group.is_empty()),
"quality() requires every rating group to be non-empty"
);
let flatten_ratings = rating_groups let flatten_ratings = rating_groups
.iter() .iter()
.flat_map(|group| group.iter()) .flat_map(|group| group.iter())
@@ -221,8 +395,10 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length); let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length);
// Row `row` contrasts group `row` (+weight) against group `row + 1`
// (-weight). `t` is the column where the current group's players start;
// the negative block begins immediately after it.
let mut t = 0; let mut t = 0;
let mut x = 0;
for (row, group) in rating_groups.windows(2).enumerate() { for (row, group) in rating_groups.windows(2).enumerate() {
let current = group[0]; let current = group[0];
@@ -230,17 +406,13 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
for n in t..t + current.len() { for n in t..t + current.len() {
rotated_a_matrix[(row, n)] = flatten_weights[n]; rotated_a_matrix[(row, n)] = flatten_weights[n];
x += 1;
} }
t += current.len(); t += current.len();
for n in x..x + next.len() { for n in t..t + next.len() {
rotated_a_matrix[(row, n)] = -flatten_weights[n]; rotated_a_matrix[(row, n)] = -flatten_weights[n];
} }
x += next.len();
} }
let a_matrix = rotated_a_matrix.transpose(); let a_matrix = rotated_a_matrix.transpose();
+316 -119
View File
@@ -1,29 +1,13 @@
//! Minimal dense matrix used by `quality()`.
//!
//! `determinant` and `inverse` go through one LU decomposition with partial
//! pivoting — O(n³) and numerically stable. The previous implementation
//! expanded cofactors recursively (O(n!), allocating a `Vec` per minor) and
//! only implemented `inverse` for the 1×1 case, which limited `quality()` to
//! exactly two rating groups.
use std::ops; use std::ops;
fn det(m: &[f64], x: usize) -> f64 {
if x == 1 {
m[0]
} else if x == 2 {
m[0] * m[3] - m[1] * m[2]
} else {
let mut d = 0.0;
for n in 0..x {
let ms = m
.iter()
.enumerate()
.skip(x)
.filter(|(i, _)| (i % x) != n)
.map(|(_, v)| *v)
.collect::<Vec<_>>();
d += (-1.0f64).powi(n as i32) * m[n] * det(&ms, x - 1);
}
d
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Matrix { pub struct Matrix {
data: Box<[f64]>, data: Box<[f64]>,
@@ -31,6 +15,107 @@ pub struct Matrix {
width: usize, width: usize,
} }
/// LU decomposition with partial pivoting: `PA = LU`, stored compactly.
///
/// `lu` holds `L` below the diagonal (unit diagonal implied) and `U` on and
/// above it. `sign` is the determinant sign contributed by row swaps, or 0.0
/// when the matrix is singular.
struct Lu {
lu: Vec<f64>,
perm: Vec<usize>,
n: usize,
sign: f64,
}
impl Lu {
fn decompose(m: &Matrix) -> Self {
debug_assert_eq!(m.width, m.height, "LU requires a square matrix");
let n = m.width;
let mut lu = m.data.to_vec();
let mut perm: Vec<usize> = (0..n).collect();
let mut sign = 1.0;
for col in 0..n {
// Partial pivot: take the largest-magnitude candidate to limit
// growth of round-off in the elimination below.
let mut pivot_row = col;
let mut pivot_max = lu[col * n + col].abs();
for row in (col + 1)..n {
let candidate = lu[row * n + col].abs();
if candidate > pivot_max {
pivot_max = candidate;
pivot_row = row;
}
}
if pivot_max == 0.0 {
sign = 0.0;
continue;
}
if pivot_row != col {
for k in 0..n {
lu.swap(col * n + k, pivot_row * n + k);
}
perm.swap(col, pivot_row);
sign = -sign;
}
let pivot = lu[col * n + col];
for row in (col + 1)..n {
let factor = lu[row * n + col] / pivot;
lu[row * n + col] = factor;
for k in (col + 1)..n {
lu[row * n + k] -= factor * lu[col * n + k];
}
}
}
Self { lu, perm, n, sign }
}
fn determinant(&self) -> f64 {
if self.sign == 0.0 {
return 0.0;
}
let mut det = self.sign;
for i in 0..self.n {
det *= self.lu[i * self.n + i];
}
det
}
/// Solve `Ax = b` for a single column of the identity, giving one column
/// of the inverse.
fn solve_column(&self, col: usize, out: &mut [f64]) {
let n = self.n;
// Forward substitution through L, applying the row permutation.
for i in 0..n {
let mut sum = if self.perm[i] == col { 1.0 } else { 0.0 };
for (k, &solved) in out.iter().enumerate().take(i) {
sum -= self.lu[i * n + k] * solved;
}
out[i] = sum;
}
// Back substitution through U.
for i in (0..n).rev() {
let mut sum = out[i];
for (k, &solved) in out.iter().enumerate().skip(i + 1) {
sum -= self.lu[i * n + k] * solved;
}
out[i] = sum / self.lu[i * n + i];
}
}
}
impl Matrix { impl Matrix {
pub fn new(height: usize, width: usize) -> Matrix { pub fn new(height: usize, width: usize) -> Matrix {
Matrix { Matrix {
@@ -52,73 +137,59 @@ impl Matrix {
matrix matrix
} }
pub fn minor(&self, row_n: usize, col_n: usize) -> Matrix { /// Determinant of a square matrix. The 0×0 determinant is 1 by convention
let mut matrix = Matrix::new(self.height - 1, self.width - 1); /// (the empty product).
///
let mut nr = 0; /// # Panics
///
for r in 0..self.height { /// Panics if the matrix is not square.
if r == row_n {
continue;
}
let mut nc = 0;
for c in 0..self.width {
if c == col_n {
continue;
}
matrix[(nr, nc)] = self[(r, c)];
nc += 1;
}
nr += 1;
}
matrix
}
pub fn determinant(&self) -> f64 { pub fn determinant(&self) -> f64 {
debug_assert!(self.width == self.height); assert_eq!(
self.width, self.height,
"determinant requires a square matrix, got {}x{}",
self.height, self.width
);
det(&self.data, self.width) if self.width == 0 {
return 1.0;
} }
pub fn adjugate(&self) -> Matrix { Lu::decompose(self).determinant()
debug_assert!(self.width == self.height);
let mut matrix = Matrix::new(self.height, self.width);
if matrix.height == 2 {
matrix[(0, 0)] = self[(1, 1)];
matrix[(0, 1)] = -self[(0, 1)];
matrix[(1, 0)] = -self[(1, 0)];
matrix[(1, 1)] = self[(0, 0)];
} else {
for r in 0..matrix.height {
for c in 0..matrix.width {
let sign = if (r + c) % 2 == 0 { 1.0 } else { -1.0 };
matrix[(r, c)] = self.minor(r, c).determinant() * sign;
}
}
}
matrix
} }
/// Matrix inverse via LU decomposition.
///
/// # Panics
///
/// Panics if the matrix is not square or is singular.
pub fn inverse(&self) -> Matrix { pub fn inverse(&self) -> Matrix {
let mut matrix = Matrix::new(self.width, self.height); assert_eq!(
self.width, self.height,
"inverse requires a square matrix, got {}x{}",
self.height, self.width
);
if self.height == self.width && self.height == 1 { let n = self.width;
matrix[(0, 0)] = 1.0 / self[(0, 0)]; let mut inverse = Matrix::new(n, n);
} else {
panic!("eh, okey") if n == 0 {
return inverse;
} }
matrix let lu = Lu::decompose(self);
assert!(lu.sign != 0.0, "cannot invert a singular matrix");
let mut column = vec![0.0; n];
for c in 0..n {
lu.solve_column(c, &mut column);
for (r, &value) in column.iter().enumerate() {
inverse[(r, c)] = value;
}
}
inverse
} }
} }
@@ -126,20 +197,62 @@ impl ops::Index<(usize, usize)> for Matrix {
type Output = f64; type Output = f64;
fn index(&self, pos: (usize, usize)) -> &Self::Output { fn index(&self, pos: (usize, usize)) -> &Self::Output {
debug_assert!(
pos.0 < self.height && pos.1 < self.width,
"index ({}, {}) out of bounds for {}x{} matrix",
pos.0,
pos.1,
self.height,
self.width
);
&self.data[(self.width * pos.0) + pos.1] &self.data[(self.width * pos.0) + pos.1]
} }
} }
impl ops::IndexMut<(usize, usize)> for Matrix { impl ops::IndexMut<(usize, usize)> for Matrix {
fn index_mut(&mut self, pos: (usize, usize)) -> &mut Self::Output { fn index_mut(&mut self, pos: (usize, usize)) -> &mut Self::Output {
debug_assert!(
pos.0 < self.height && pos.1 < self.width,
"index ({}, {}) out of bounds for {}x{} matrix",
pos.0,
pos.1,
self.height,
self.width
);
&mut self.data[(self.width * pos.0) + pos.1] &mut self.data[(self.width * pos.0) + pos.1]
} }
} }
impl<'a> ops::Mul<&'a Matrix> for f64 { fn multiply(lhs: &Matrix, rhs: &Matrix) -> Matrix {
assert_eq!(
lhs.width, rhs.height,
"cannot multiply {}x{} by {}x{}",
lhs.height, lhs.width, rhs.height, rhs.width
);
let mut matrix = Matrix::new(lhs.height, rhs.width);
for r in 0..matrix.height {
for c in 0..matrix.width {
let mut value = 0.0;
for x in 0..lhs.width {
value += lhs[(r, x)] * rhs[(x, c)];
}
matrix[(r, c)] = value;
}
}
matrix
}
impl ops::Mul<&Matrix> for f64 {
type Output = Matrix; type Output = Matrix;
fn mul(self, rhs: &'a Matrix) -> Matrix { fn mul(self, rhs: &Matrix) -> Matrix {
let mut matrix = Matrix::new(rhs.height, rhs.width); let mut matrix = Matrix::new(rhs.height, rhs.width);
for r in 0..rhs.height { for r in 0..rhs.height {
@@ -152,54 +265,35 @@ impl<'a> ops::Mul<&'a Matrix> for f64 {
} }
} }
impl<'a> ops::Mul<&'a Matrix> for Matrix { impl ops::Mul<&Matrix> for Matrix {
type Output = Matrix; type Output = Matrix;
fn mul(self, rhs: &'a Matrix) -> Matrix { fn mul(self, rhs: &Matrix) -> Matrix {
let mut matrix = Matrix::new(self.height, rhs.width); multiply(&self, rhs)
for r in 0..matrix.height {
for c in 0..matrix.width {
let mut value = 0.0;
for x in 0..self.width {
value += self[(r, x)] * rhs[(x, c)];
}
matrix[(r, c)] = value;
}
}
matrix
} }
} }
impl<'a> ops::Mul<&'a Matrix> for &'a Matrix { impl ops::Mul<&Matrix> for &Matrix {
type Output = Matrix; type Output = Matrix;
fn mul(self, rhs: &'a Matrix) -> Matrix { fn mul(self, rhs: &Matrix) -> Matrix {
let mut matrix = Matrix::new(self.height, rhs.width); multiply(self, rhs)
for r in 0..matrix.height {
for c in 0..matrix.width {
let mut value = 0.0;
for x in 0..self.width {
value += self[(r, x)] * rhs[(x, c)];
}
matrix[(r, c)] = value;
}
}
matrix
} }
} }
impl<'a> ops::Add<&'a Matrix> for &'a Matrix { impl ops::Add<&Matrix> for &Matrix {
type Output = Matrix; type Output = Matrix;
fn add(self, rhs: &'a Matrix) -> Matrix { fn add(self, rhs: &Matrix) -> Matrix {
assert!(
self.height == rhs.height && self.width == rhs.width,
"cannot add {}x{} to {}x{}",
self.height,
self.width,
rhs.height,
rhs.width
);
let mut matrix = Matrix::new(self.height, self.width); let mut matrix = Matrix::new(self.height, self.width);
for r in 0..matrix.height { for r in 0..matrix.height {
@@ -211,3 +305,106 @@ impl<'a> ops::Add<&'a Matrix> for &'a Matrix {
matrix matrix
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn from_rows(rows: &[&[f64]]) -> Matrix {
let mut m = Matrix::new(rows.len(), rows[0].len());
for (r, row) in rows.iter().enumerate() {
for (c, &v) in row.iter().enumerate() {
m[(r, c)] = v;
}
}
m
}
#[test]
fn determinant_1x1() {
assert!((from_rows(&[&[3.0]]).determinant() - 3.0).abs() < 1e-12);
}
#[test]
fn determinant_2x2() {
let m = from_rows(&[&[1.0, 2.0], &[3.0, 4.0]]);
assert!((m.determinant() - (-2.0)).abs() < 1e-12);
}
#[test]
fn determinant_3x3() {
let m = from_rows(&[&[6.0, 1.0, 1.0], &[4.0, -2.0, 5.0], &[2.0, 8.0, 7.0]]);
assert!((m.determinant() - (-306.0)).abs() < 1e-10);
}
#[test]
fn determinant_requires_no_pivot_at_origin() {
// A zero in the top-left forces a row swap; the sign must follow.
let m = from_rows(&[&[0.0, 1.0], &[1.0, 0.0]]);
assert!((m.determinant() - (-1.0)).abs() < 1e-12);
}
#[test]
fn determinant_of_singular_is_zero() {
let m = from_rows(&[&[1.0, 2.0], &[2.0, 4.0]]);
assert!(m.determinant().abs() < 1e-12);
}
#[test]
fn inverse_1x1() {
let inv = from_rows(&[&[4.0]]).inverse();
assert!((inv[(0, 0)] - 0.25).abs() < 1e-12);
}
#[test]
fn inverse_times_original_is_identity() {
for rows in [
vec![vec![1.0, 2.0], vec![3.0, 4.0]],
vec![
vec![6.0, 1.0, 1.0],
vec![4.0, -2.0, 5.0],
vec![2.0, 8.0, 7.0],
],
vec![
vec![2.0, 0.0, 1.0, 3.0],
vec![1.0, 5.0, 2.0, 0.0],
vec![0.0, 1.0, 4.0, 1.0],
vec![3.0, 2.0, 0.0, 6.0],
],
] {
let refs: Vec<&[f64]> = rows.iter().map(|r| r.as_slice()).collect();
let m = from_rows(&refs);
let product = &m * &m.inverse();
for r in 0..product.height {
for c in 0..product.width {
let expected = if r == c { 1.0 } else { 0.0 };
assert!(
(product[(r, c)] - expected).abs() < 1e-9,
"({r},{c}) = {} expected {expected}",
product[(r, c)]
);
}
}
}
}
#[test]
#[should_panic(expected = "singular")]
fn inverse_of_singular_panics() {
let _ = from_rows(&[&[1.0, 2.0], &[2.0, 4.0]]).inverse();
}
#[test]
fn empty_determinant_is_one() {
assert!((Matrix::new(0, 0).determinant() - 1.0).abs() < 1e-12);
}
#[test]
fn transpose_round_trips() {
let m = from_rows(&[&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]]);
let t = m.transpose();
assert_eq!((t.height, t.width), (3, 2));
assert_eq!(t.transpose()[(1, 2)], m[(1, 2)]);
}
}
+13 -5
View File
@@ -57,9 +57,11 @@ impl Outcome {
/// Explicit per-team continuous scores with a per-event noise override. /// Explicit per-team continuous scores with a per-event noise override.
/// ///
/// `sigma` must be `> 0.0`; debug-asserts otherwise. /// `sigma` must be `> 0.0`. Constructing an `Outcome` with a non-positive
/// or NaN sigma is allowed; the value is rejected with
/// `InferenceError::InvalidParameter` when the event is ingested, so
/// callers get an error rather than a panic.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self { pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})");
Self::Scored { Self::Scored {
scores: scores.into_iter().collect(), scores: scores.into_iter().collect(),
sigma: Some(sigma), sigma: Some(sigma),
@@ -169,9 +171,15 @@ mod tests {
} }
} }
/// Construction accepts any sigma; the value is validated at ingestion so
/// callers receive an `InferenceError` rather than a panic. See
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
#[test] #[test]
#[should_panic(expected = "score_sigma must be > 0.0")] fn scores_with_sigma_defers_validation_to_ingestion() {
fn scores_with_sigma_rejects_zero() { let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
let _ = Outcome::scores_with_sigma([3.0, 1.0], 0.0); match o {
Outcome::Scored { sigma, .. } => assert_eq!(sigma, Some(0.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
} }
} }
+18
View File
@@ -29,6 +29,24 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
} }
} }
/// The configured prior skill estimate.
#[must_use]
pub fn prior(&self) -> Gaussian {
self.prior
}
/// Performance noise: how much a single showing varies around the skill.
#[must_use]
pub fn beta(&self) -> f64 {
self.beta
}
/// The drift model governing how skill may move between events.
#[must_use]
pub fn drift(&self) -> D {
self.drift
}
pub(crate) fn performance(&self) -> Gaussian { pub(crate) fn performance(&self) -> Gaussian {
self.prior.forget(self.beta.powi(2)) self.prior.forget(self.beta.powi(2))
} }
+31 -5
View File
@@ -32,8 +32,17 @@ pub struct EpsilonOrMax {
impl Default for EpsilonOrMax { impl Default for EpsilonOrMax {
fn default() -> Self { fn default() -> Self {
// Matches today's hard-coded tolerance and iteration cap. // Derived from `ConvergenceOptions` so there is one source of truth for
Self { eps: 1e-6, max: 10 } // the tolerance and iteration cap. These previously disagreed: this
// default capped at 10 iterations while `ConvergenceOptions` allowed 30,
// and which applied depended on whether inference went through
// `run_chain` or a `Schedule`.
let defaults = crate::ConvergenceOptions::default();
Self {
eps: defaults.epsilon,
max: defaults.max_iter,
}
} }
} }
@@ -50,10 +59,16 @@ impl Schedule for EpsilonOrMax {
} }
let mut iterations = 0; let mut iterations = 0;
let mut final_step = (f64::INFINITY, f64::INFINITY); // With no iterating factors the graph is already at its fixed point:
let mut converged = false; // the setup pass above is all there is to do. Reporting `converged:
// false` with an infinite step for that case gave callers a false
// negative.
let mut final_step = (0.0, 0.0);
let mut converged = true;
if n_setup < factors.len() { if n_setup < factors.len() {
final_step = (f64::INFINITY, f64::INFINITY);
converged = false;
for _ in 0..self.max { for _ in 0..self.max {
let mut step = (0.0_f64, 0.0_f64); let mut step = (0.0_f64, 0.0_f64);
@@ -113,7 +128,8 @@ mod tests {
#[test] #[test]
fn report_marks_converged_when_no_iterating_factors() { fn report_marks_converged_when_no_iterating_factors() {
// No iterating factors → 0 iterations, converged stays false (loop never ran). // A graph of only setup factors has nothing to iterate, so it is at its
// fixed point after the setup pass: 0 iterations, and converged.
let mut vars = VarStore::new(); let mut vars = VarStore::new();
let out = vars.alloc(N_INF); let out = vars.alloc(N_INF);
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor { let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
@@ -122,5 +138,15 @@ mod tests {
})]; })];
let report = EpsilonOrMax::default().run(&mut factors, &mut vars); let report = EpsilonOrMax::default().run(&mut factors, &mut vars);
assert_eq!(report.iterations, 0); assert_eq!(report.iterations, 0);
assert!(report.converged);
assert_eq!(report.final_step, (0.0, 0.0));
}
#[test]
fn default_matches_convergence_options() {
let schedule = EpsilonOrMax::default();
let options = crate::ConvergenceOptions::default();
assert_eq!(schedule.max, options.max_iter);
assert_eq!(schedule.eps, options.epsilon);
} }
} }
+12 -15
View File
@@ -41,6 +41,18 @@ impl SkillStore {
} }
} }
/// Whether a slot is occupied. Test-only.
#[cfg(test)]
pub fn contains(&self, idx: Index) -> bool {
idx.0 < self.present.len() && self.present[idx.0]
}
/// Number of occupied slots. Test-only.
#[cfg(test)]
pub fn len(&self) -> usize {
self.n_present
}
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> { pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> {
if idx.0 < self.present.len() && self.present[idx.0] { if idx.0 < self.present.len() && self.present[idx.0] {
Some(&mut self.skills[idx.0]) Some(&mut self.skills[idx.0])
@@ -49,21 +61,6 @@ impl SkillStore {
} }
} }
#[allow(dead_code)]
pub fn contains(&self, idx: Index) -> bool {
idx.0 < self.present.len() && self.present[idx.0]
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.n_present
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.n_present == 0
}
pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> { pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> {
self.present.iter().enumerate().filter_map(|(i, &p)| { self.present.iter().enumerate().filter_map(|(i, &p)| {
if p { if p {
+134 -60
View File
@@ -14,7 +14,6 @@ use crate::{
rating::Rating, rating::Rating,
storage::{CompetitorStore, SkillStore}, storage::{CompetitorStore, SkillStore},
time::Time, time::Time,
tuple_gt, tuple_max,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -87,7 +86,7 @@ struct Team {
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct Event { pub(crate) struct Event {
teams: Vec<Team>, teams: Vec<Team>,
evidence: f64, log_evidence: f64,
weights: Vec<Vec<f64>>, weights: Vec<Vec<f64>>,
kind: EventKind, kind: EventKind,
} }
@@ -124,18 +123,19 @@ impl Event {
.collect::<Vec<_>>() .collect::<Vec<_>>()
} }
/// Direct in-loop update: mutates self and `skills` inline with no /// Run inference for this event and return its per-item likelihoods.
/// intermediate allocation. Used by both the sequential sweep path and, ///
/// via unsafe, by the parallel rayon path for events in the same color /// Reads `skills` immutably and does not touch `self`, so every event in
/// group (which have disjoint agent sets — see `sweep_color_groups`). /// a color group can run concurrently without any aliasing question —
fn iteration_direct<T: Time, D: Drift<T>>( /// the mutation is deferred to `apply`.
&mut self, fn compute<T: Time, D: Drift<T>>(
skills: &mut SkillStore, &self,
skills: &SkillStore,
agents: &CompetitorStore<T, D>, agents: &CompetitorStore<T, D>,
p_draw: f64, p_draw: f64,
convergence: crate::ConvergenceOptions, convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena, arena: &mut ScratchArena,
) { ) -> EventUpdate {
let teams = self.within_priors(false, false, skills, agents); let teams = self.within_priors(false, false, skills, agents);
let result = self.outputs(); let result = self.outputs();
let g = match self.kind { let g = match self.kind {
@@ -152,17 +152,47 @@ impl Event {
), ),
}; };
for (t, team) in self.teams.iter_mut().enumerate() { EventUpdate {
for (i, item) in team.items.iter_mut().enumerate() { log_evidence: g.log_evidence,
let old_likelihood = skills.get(item.agent).unwrap().likelihood; likelihoods: g.likelihoods,
let new_likelihood = (old_likelihood / item.likelihood) * g.likelihoods[t][i];
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
item.likelihood = g.likelihoods[t][i];
} }
} }
self.evidence = g.evidence; /// Fold a computed update into the skill store and cache it on the items.
fn apply(&mut self, skills: &mut SkillStore, update: EventUpdate) {
for (t, team) in self.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() {
let fresh = update.likelihoods[t][i];
let old_likelihood = skills.get(item.agent).unwrap().likelihood;
let new_likelihood = (old_likelihood / item.likelihood) * fresh;
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
item.likelihood = fresh;
} }
}
self.log_evidence = update.log_evidence;
}
/// Compute and apply in one step — the sequential sweep.
fn iteration_direct<T: Time, D: Drift<T>>(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<T, D>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) {
let update = self.compute(skills, agents, p_draw, convergence, arena);
self.apply(skills, update);
}
}
/// The result of running inference for one event, before it is folded back
/// into the shared skill store.
#[derive(Debug)]
struct EventUpdate {
log_evidence: f64,
likelihoods: Vec<Vec<Gaussian>>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -174,6 +204,14 @@ pub struct TimeSlice<T: Time = i64> {
pub(crate) convergence: crate::ConvergenceOptions, pub(crate) convergence: crate::ConvergenceOptions,
arena: ScratchArena, arena: ScratchArena,
pub(crate) color_groups: ColorGroups, pub(crate) color_groups: ColorGroups,
/// Whether `color_groups` still reflects `events`.
///
/// Coloring is rebuilt lazily, on the first full sweep after an append,
/// rather than eagerly per append: the partition is thrown away and
/// recomputed wholesale either way, so doing it per append made ingesting
/// n events O(n^2) with no benefit — nothing reads the partition between
/// an append and the next full sweep.
color_groups_dirty: bool,
} }
impl<T: Time> TimeSlice<T> { impl<T: Time> TimeSlice<T> {
@@ -186,6 +224,7 @@ impl<T: Time> TimeSlice<T> {
convergence, convergence,
arena: ScratchArena::new(), arena: ScratchArena::new(),
color_groups: ColorGroups::new(), color_groups: ColorGroups::new(),
color_groups_dirty: false,
} }
} }
@@ -198,6 +237,7 @@ impl<T: Time> TimeSlice<T> {
let n = self.events.len(); let n = self.events.len();
if n == 0 { if n == 0 {
self.color_groups = ColorGroups::new(); self.color_groups = ColorGroups::new();
self.color_groups_dirty = false;
return; return;
} }
@@ -221,6 +261,12 @@ impl<T: Time> TimeSlice<T> {
self.events = reordered; self.events = reordered;
self.color_groups = ColorGroups { groups: new_groups }; self.color_groups = ColorGroups { groups: new_groups };
self.color_groups_dirty = false;
debug_assert!(
self.color_groups.groups_are_contiguous(),
"color groups must occupy contiguous event ranges"
);
} }
pub fn add_events<D: Drift<T>>( pub fn add_events<D: Drift<T>>(
@@ -296,7 +342,7 @@ impl<T: Time> TimeSlice<T> {
Event { Event {
teams, teams,
evidence: 0.0, log_evidence: 0.0,
weights, weights,
kind: kinds[e], kind: kinds[e],
} }
@@ -306,8 +352,9 @@ impl<T: Time> TimeSlice<T> {
self.events.extend(events); self.events.extend(events);
self.color_groups_dirty = true;
self.iteration(from, agents); self.iteration(from, agents);
self.recompute_color_groups();
} }
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> { pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
@@ -318,6 +365,10 @@ impl<T: Time> TimeSlice<T> {
} }
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) { pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
if from == 0 && self.color_groups_dirty {
self.recompute_color_groups();
}
if from > 0 || self.color_groups.is_empty() { if from > 0 || self.color_groups.is_empty() {
// Initial pass (add_events) or no color groups yet: simple sequential sweep. // Initial pass (add_events) or no color groups yet: simple sequential sweep.
for event in self.events.iter_mut().skip(from) { for event in self.events.iter_mut().skip(from) {
@@ -353,7 +404,7 @@ impl<T: Time> TimeSlice<T> {
} }
} }
event.evidence = g.evidence; event.log_evidence = g.log_evidence;
} }
} else { } else {
self.sweep_color_groups(agents); self.sweep_color_groups(agents);
@@ -363,14 +414,13 @@ impl<T: Time> TimeSlice<T> {
/// Full event sweep using the color-group partition. Colors are processed /// Full event sweep using the color-group partition. Colors are processed
/// sequentially; within each color the inner loop is parallel under rayon. /// sequentially; within each color the inner loop is parallel under rayon.
/// ///
/// Events within each color group touch disjoint agent sets (guaranteed by /// Events in one color group touch disjoint agent sets, so none of them
/// the greedy coloring). This lets each rayon thread write directly to its /// can observe another's writes. That makes the sweep separable: inference
/// events' skill likelihoods without a deferred-apply step, matching the /// runs concurrently over shared `&self.skills`, and the resulting updates
/// sequential path's allocation profile. The unsafe block is sound because: /// are folded in afterwards in index order. Splitting it this way needs no
/// 1. `self.events[range]` and `self.skills` are separate fields → disjoint. /// `unsafe` and no aliasing argument, and it keeps results bit-identical
/// 2. Events in the same color group access disjoint `Index` values in /// across thread counts because the apply order does not depend on which
/// `self.skills`, so concurrent writes land on different memory locations. /// worker finished first.
/// 3. Each event only writes to its own items' likelihoods (no sharing).
#[cfg(feature = "rayon")] #[cfg(feature = "rayon")]
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) { fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
use rayon::prelude::*; use rayon::prelude::*;
@@ -390,29 +440,28 @@ impl<T: Time> TimeSlice<T> {
if group_len == 0 { if group_len == 0 {
continue; continue;
} }
let range = self.color_groups.color_range(color_idx); let range = self.color_groups.color_range(color_idx);
let p_draw = self.p_draw; let p_draw = self.p_draw;
let convergence = self.convergence; let convergence = self.convergence;
if group_len >= RAYON_THRESHOLD { if group_len >= RAYON_THRESHOLD {
// Obtain a raw pointer from the unique `&mut self.skills` reference. let skills = &self.skills;
// Casting back to `&mut` inside the closure is sound because: let updates: Vec<EventUpdate> = self.events[range.clone()]
// 1. The pointer originates from a `&mut` — no aliasing with shared refs. .par_iter()
// 2. Events in the same color group touch disjoint `Index` slots in the .map(|ev| {
// underlying Vec, so concurrent writes from different threads land on
// different memory locations — no data race.
// 3. `self.events[range]` and `self.skills` are separate struct fields,
// so the borrow splits cleanly.
let skills_addr: usize = (&mut self.skills as *mut SkillStore) as usize;
self.events[range].par_iter_mut().for_each(move |ev| {
// SAFETY: see above.
let skills: &mut SkillStore = unsafe { &mut *(skills_addr as *mut SkillStore) };
ARENA.with(|cell| { ARENA.with(|cell| {
let mut arena = cell.borrow_mut(); let mut arena = cell.borrow_mut();
arena.reset(); arena.reset();
ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
}); ev.compute(skills, agents, p_draw, convergence, &mut arena)
}); })
})
.collect();
for (ev, update) in self.events[range].iter_mut().zip(updates) {
ev.apply(&mut self.skills, update);
}
} else { } else {
for ev in &mut self.events[range] { for ev in &mut self.events[range] {
ev.iteration_direct( ev.iteration_direct(
@@ -454,18 +503,29 @@ impl<T: Time> TimeSlice<T> {
} }
} }
#[allow(dead_code)] /// Iterate this slice alone until its posteriors stop moving, returning
/// the number of iterations taken.
///
/// Only used by tests: production convergence is driven across slices by
/// `History::converge`.
///
/// Honours `self.convergence`; it previously hard-coded an epsilon and a
/// 20-iteration cap that matched neither `ConvergenceOptions` nor the
/// schedule default.
#[cfg(test)]
pub(crate) fn iterate_to_convergence<D: Drift<T>>( pub(crate) fn iterate_to_convergence<D: Drift<T>>(
&mut self, &mut self,
agents: &CompetitorStore<T, D>, agents: &CompetitorStore<T, D>,
) -> usize { ) -> usize {
let epsilon = 1e-6; use crate::{tuple_gt, tuple_max};
let iterations = 20;
let epsilon = self.convergence.epsilon;
let max_iter = self.convergence.max_iter;
let mut step = (f64::INFINITY, f64::INFINITY); let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0; let mut i = 0;
while tuple_gt(step, epsilon) && i < iterations { while tuple_gt(step, epsilon) && i < max_iter {
let old = self.posteriors(); let old = self.posteriors();
self.iteration(0, agents); self.iteration(0, agents);
@@ -477,6 +537,10 @@ impl<T: Time> TimeSlice<T> {
}); });
i += 1; i += 1;
if !crate::step_is_finite(step) {
break;
}
} }
i i
@@ -523,6 +587,9 @@ impl<T: Time> TimeSlice<T> {
forward: bool, forward: bool,
agents: &CompetitorStore<T, D>, agents: &CompetitorStore<T, D>,
) -> f64 { ) -> f64 {
// Hashed once rather than scanned per player per event, so a
// `log_evidence_for` with many keys is not quadratic.
let target_set: std::collections::HashSet<Index> = targets.iter().copied().collect();
// log_evidence is infrequent; a local arena avoids needing &mut self. // log_evidence is infrequent; a local arena avoids needing &mut self.
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
@@ -530,7 +597,8 @@ impl<T: Time> TimeSlice<T> {
let teams = event.within_priors(online, forward, &self.skills, agents); let teams = event.within_priors(online, forward, &self.skills, agents);
let result = event.outputs(); let result = event.outputs();
match event.kind { match event.kind {
EventKind::Ranked => Game::ranked_with_arena( EventKind::Ranked => {
Game::ranked_with_arena(
teams, teams,
&result, &result,
&event.weights, &event.weights,
@@ -538,9 +606,10 @@ impl<T: Time> TimeSlice<T> {
self.convergence, self.convergence,
arena, arena,
) )
.evidence .log_evidence
.ln(), }
EventKind::Scored { score_sigma } => Game::scored_with_arena( EventKind::Scored { score_sigma } => {
Game::scored_with_arena(
teams, teams,
&result, &result,
&event.weights, &event.weights,
@@ -548,8 +617,8 @@ impl<T: Time> TimeSlice<T> {
self.convergence, self.convergence,
arena, arena,
) )
.evidence .log_evidence
.ln(), }
} }
}; };
@@ -560,7 +629,7 @@ impl<T: Time> TimeSlice<T> {
.map(|event| run_event(event, &mut arena)) .map(|event| run_event(event, &mut arena))
.sum() .sum()
} else { } else {
self.events.iter().map(|event| event.evidence.ln()).sum() self.events.iter().map(|event| event.log_evidence).sum()
} }
} else if online || forward { } else if online || forward {
self.events self.events
@@ -570,7 +639,7 @@ impl<T: Time> TimeSlice<T> {
.teams .teams
.iter() .iter()
.flat_map(|team| &team.items) .flat_map(|team| &team.items)
.any(|item| targets.contains(&item.agent)) .any(|item| target_set.contains(&item.agent))
}) })
.map(|event| run_event(event, &mut arena)) .map(|event| run_event(event, &mut arena))
.sum() .sum()
@@ -582,9 +651,9 @@ impl<T: Time> TimeSlice<T> {
.teams .teams
.iter() .iter()
.flat_map(|team| &team.items) .flat_map(|team| &team.items)
.any(|item| targets.contains(&item.agent)) .any(|item| target_set.contains(&item.agent))
}) })
.map(|event| event.evidence.ln()) .map(|event| event.log_evidence)
.sum() .sum()
} }
} }
@@ -866,19 +935,24 @@ mod tests {
let post = time_slice.posteriors(); let post = time_slice.posteriors();
// These are convergence residuals, not exact values: by symmetry the
// true mean is 25.0 and the iteration approaches it from above. The
// previous expectation of 25.000003 was the residual after the
// hard-coded 20-iteration cap; honouring `ConvergenceOptions` runs to
// 30 and lands nearer the truth.
assert_ulps_eq!( assert_ulps_eq!(
post[&a], post[&a],
Gaussian::from_ms(25.000003, 3.880150), Gaussian::from_ms(25.000001, 3.880150),
epsilon = 1e-6 epsilon = 1e-6
); );
assert_ulps_eq!( assert_ulps_eq!(
post[&b], post[&b],
Gaussian::from_ms(25.000003, 3.880150), Gaussian::from_ms(25.000001, 3.880150),
epsilon = 1e-6 epsilon = 1e-6
); );
assert_ulps_eq!( assert_ulps_eq!(
post[&c], post[&c],
Gaussian::from_ms(25.000003, 3.880150), Gaussian::from_ms(25.000001, 3.880150),
epsilon = 1e-6 epsilon = 1e-6
); );
} }
+247
View File
@@ -0,0 +1,247 @@
//! Degenerate, boundary, and error-path coverage.
//!
//! These run in both debug and release: the defects they pin were all
//! guarded only by `debug_assert!`, so a debug-only suite never saw them.
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
Outcome, Rating,
};
type R = Rating<i64, ConstantDrift>;
fn rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
)
}
fn assert_finite(g: Gaussian, what: &str) {
assert!(
g.mu().is_finite() && g.sigma().is_finite(),
"{what} must be finite, got mu={} sigma={}",
g.mu(),
g.sigma()
);
}
#[test]
fn record_draw_without_draw_probability_is_rejected() {
let mut h = History::default();
let err = h.record_draw(&"a", &"b", 1).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
#[test]
fn builder_draw_without_draw_probability_is_rejected() {
let mut h = History::default();
let err = h
.event(1)
.team(["a"])
.team(["b"])
.draw()
.commit()
.unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
#[test]
fn draw_with_positive_draw_probability_is_finite() {
let mut h = History::builder().p_draw(0.25).build();
h.record_draw(&"a", &"b", 1).unwrap();
let report = h.converge().unwrap();
assert_finite(h.current_skill("a").unwrap(), "drawn competitor skill");
assert_finite(h.current_skill("b").unwrap(), "drawn competitor skill");
assert!(report.log_evidence.is_finite());
assert!(report.converged);
}
#[test]
fn game_ranked_rejects_tie_without_draw_probability() {
let a = [rating()];
let b = [rating()];
let teams: Vec<&[R]> = vec![&a, &b];
let err = Game::ranked(&teams, Outcome::draw(2), &GameOptions::default()).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
/// `Outcome::winner(w, n)` ties every loser, so any n >= 3 free-for-all hits
/// the tie path even though the caller never asked for a draw.
#[test]
fn winner_of_three_or_more_requires_draw_probability() {
let a = [rating()];
let b = [rating()];
let c = [rating()];
let teams: Vec<&[R]> = vec![&a, &b, &c];
let err = Game::ranked(&teams, Outcome::winner(0, 3), &GameOptions::default()).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
let opts = GameOptions {
p_draw: 0.1,
..GameOptions::default()
};
let game = Game::ranked(&teams, Outcome::winner(0, 3), &opts).unwrap();
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "3-team winner posterior");
}
}
}
#[test]
fn full_ranking_without_ties_needs_no_draw_probability() {
let a = [rating()];
let b = [rating()];
let c = [rating()];
let teams: Vec<&[R]> = vec![&a, &b, &c];
let game = Game::ranked(&teams, Outcome::ranking([0, 1, 2]), &GameOptions::default()).unwrap();
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "strict ranking posterior");
}
}
}
#[test]
fn empty_history_converges_trivially() {
let mut h = History::default();
let report = h.converge().unwrap();
assert_eq!(report.iterations, 0);
assert!(report.converged);
}
#[test]
fn empty_event_stream_then_converge() {
let mut h = History::default();
h.add_events(std::iter::empty()).unwrap();
let report = h.converge().unwrap();
assert_eq!(report.iterations, 0);
}
#[test]
fn empty_history_queries_do_not_panic() {
let h = History::default();
assert!(h.learning_curves().is_empty());
assert!(h.learning_curve("nobody").is_empty());
assert!(h.current_skill("nobody").is_none());
}
#[test]
fn single_event_history_converges() {
let mut h = History::default();
h.record_winner(&"a", &"b", 1).unwrap();
let report = h.converge().unwrap();
assert!(report.converged);
assert_finite(h.current_skill("a").unwrap(), "single-event skill");
}
#[test]
fn scored_event_rejects_non_positive_sigma() {
let mut h = History::builder().score_sigma(2.0).build();
let err = h
.event(1)
.team(["a"])
.team(["b"])
.scores_with_sigma([3.0, 1.0], f64::NAN)
.commit()
.unwrap_err();
assert!(matches!(
err,
InferenceError::InvalidParameter {
name: "score_sigma",
..
}
));
}
#[test]
fn convergence_reports_are_finite_across_many_teams() {
let opts = GameOptions {
p_draw: 0.1,
convergence: ConvergenceOptions::default(),
..GameOptions::default()
};
let holders: Vec<[R; 1]> = (0..12).map(|_| [rating()]).collect();
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
let game = Game::ranked(&teams, Outcome::ranking(0..12), &opts).unwrap();
assert!(
game.log_evidence().is_finite(),
"12-team log-evidence must be finite, got {}",
game.log_evidence()
);
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "12-team posterior");
}
}
}
/// A long diff chain underflows a linear evidence product: each link
/// contributes a probability in (0, 1], so ~1000 links flush the product to
/// exactly 0.0 and `ln(0.0)` is `-inf`. Accumulating in log space keeps it
/// finite.
#[test]
fn log_evidence_survives_a_long_diff_chain() {
let holders: Vec<[R; 1]> = (0..1200).map(|_| [rating()]).collect();
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
let game = Game::ranked(
&teams,
Outcome::ranking(0..holders.len() as u32),
&GameOptions::default(),
)
.unwrap();
let log_evidence = game.log_evidence();
assert!(
log_evidence.is_finite(),
"1200-team log-evidence must be finite, got {log_evidence}"
);
assert!(
log_evidence < 0.0,
"log-evidence of a probability must be negative, got {log_evidence}"
);
}
/// A near-certain outcome rounds the losing tail to exactly zero in the
/// `erfc` approximation; the evidence floor keeps `ln` finite.
#[test]
fn log_evidence_finite_for_near_certain_outcome() {
let overwhelming = R::new(Gaussian::from_ms(5_000.0, 0.5), 1.0, ConstantDrift(0.0));
let hopeless = R::new(Gaussian::from_ms(-5_000.0, 0.5), 1.0, ConstantDrift(0.0));
let a = [overwhelming];
let b = [hopeless];
let teams: Vec<&[R]> = vec![&a, &b];
let game = Game::ranked(&teams, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
assert!(
game.log_evidence().is_finite(),
"got {}",
game.log_evidence()
);
// And the reverse — a colossal upset — must also stay finite.
let upset = Game::ranked(&teams, Outcome::winner(1, 2), &GameOptions::default()).unwrap();
assert!(
upset.log_evidence().is_finite(),
"upset log-evidence must be finite, got {}",
upset.log_evidence()
);
}
+5 -11
View File
@@ -48,15 +48,9 @@ fn game_1v1_draw_golden() {
) )
.unwrap(); .unwrap();
let p = g.posteriors(); let p = g.posteriors();
// Historical golden from pre-T2 test_1vs1_draw: // Historical golden from pre-T2 test_1vs1_draw. The mean is 25.0 exactly
assert_ulps_eq!( // by symmetry — two identical competitors drawing cannot move apart — and
p[0][0], // the reference's 24.999999 is that value transcribed to six decimals.
Gaussian::from_ms(24.999999, 6.469480), assert_ulps_eq!(p[0][0], Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
epsilon = 1e-6 assert_ulps_eq!(p[1][0], Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
);
assert_ulps_eq!(
p[1][0],
Gaussian::from_ms(24.999999, 6.469480),
epsilon = 1e-6
);
} }
+147
View File
@@ -0,0 +1,147 @@
//! Ingesting the same events must give the same answer however they were
//! batched.
//!
//! The numerical goldens all ingest in a single call with one slice per
//! timestamp, so they never exercise the "append to an existing slice" path.
//! These do.
use smallvec::smallvec;
use trueskill_tt::{ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team};
/// Converge tightly: the default cap of 30 iterations leaves a residual around
/// 1e-6, which would swamp the comparison. Both paths must reach the same
/// fixed point, so drive both well past it.
fn tight() -> ConvergenceOptions {
ConvergenceOptions {
max_iter: 2_000,
epsilon: 1e-12,
..ConvergenceOptions::default()
}
}
fn event(a: &str, b: &str, time: i64) -> Event<i64, String> {
Event {
time,
teams: smallvec![
Team::with_members([Member::new(a.to_string())]),
Team::with_members([Member::new(b.to_string())]),
],
outcome: Outcome::winner(0, 2),
}
}
fn converged_skills(events: Vec<Event<i64, String>>, batched: bool) -> Vec<(String, Gaussian)> {
let mut h: History<i64, _, _, String> =
History::builder_with_key().convergence(tight()).build();
if batched {
h.add_events(events).unwrap();
} else {
for ev in events {
h.add_events(std::iter::once(ev)).unwrap();
}
}
let report = h.converge().unwrap();
assert!(
report.converged,
"fixture must converge before results can be compared; final step {:?}",
report.final_step
);
let mut skills: Vec<(String, Gaussian)> = h
.learning_curves()
.into_iter()
.map(|(key, curve)| (key, curve.last().unwrap().1))
.collect();
skills.sort_by(|a, b| a.0.cmp(&b.0));
skills
}
fn assert_same(batched: &[(String, Gaussian)], incremental: &[(String, Gaussian)], what: &str) {
assert_eq!(
batched.len(),
incremental.len(),
"{what}: competitor count differs"
);
for ((kb, gb), (ki, gi)) in batched.iter().zip(incremental.iter()) {
assert_eq!(kb, ki, "{what}: key order differs");
assert!(
(gb.mu() - gi.mu()).abs() < 1e-8 && (gb.sigma() - gi.sigma()).abs() < 1e-8,
"{what}: {kb} differs — batched mu={} sigma={}, incremental mu={} sigma={}",
gb.mu(),
gb.sigma(),
gi.mu(),
gi.sigma()
);
}
}
/// All events share one timestamp, so incremental ingestion repeatedly appends
/// to an existing slice.
#[test]
fn same_slice_incremental_matches_batched() {
let events = vec![
event("a", "b", 1),
event("c", "d", 1),
event("e", "f", 1),
event("a", "c", 1),
event("b", "e", 1),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "single shared slice");
}
/// Distinct timestamps, so each append lands in a fresh slice appended after
/// the existing ones.
#[test]
fn distinct_slices_incremental_matches_batched() {
let events = vec![
event("a", "b", 1),
event("b", "c", 2),
event("c", "a", 3),
event("a", "c", 4),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "distinct slices");
}
/// Several events per timestamp across several timestamps — appends to
/// existing slices interleaved with new ones.
#[test]
fn mixed_slices_incremental_matches_batched() {
let events = vec![
event("a", "b", 1),
event("c", "d", 1),
event("a", "c", 2),
event("b", "d", 2),
event("a", "d", 3),
event("b", "c", 3),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "mixed slices");
}
/// Appending an event to a slice that is *not* the most recent one exercises
/// the forward refresh of every later slice.
#[test]
fn back_dated_event_matches_batched() {
let events = vec![
event("a", "b", 1),
event("b", "c", 5),
event("c", "a", 9),
// arrives last, but belongs to the middle slice
event("a", "c", 5),
];
let batched = converged_skills(events.clone(), true);
let incremental = converged_skills(events, false);
assert_same(&batched, &incremental, "back-dated event");
}
+119
View File
@@ -0,0 +1,119 @@
//! `quality()` beyond two rating groups.
//!
//! The historical golden (two equal singletons) is asserted in
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
//! which previously panicked with an out-of-bounds index at 3+ groups.
use trueskill_tt::{Gaussian, quality};
const BETA: f64 = 25.0 / 3.0 / 2.0;
fn rating(mu: f64, sigma: f64) -> Gaussian {
Gaussian::from_ms(mu, sigma)
}
#[test]
fn three_equal_groups_is_finite_and_in_range() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r], &[r], &[r]], BETA);
assert!(q.is_finite(), "quality must be finite, got {q}");
assert!((0.0..=1.0).contains(&q), "quality out of range: {q}");
}
#[test]
fn quality_supports_many_groups() {
let r = rating(25.0, 3.0);
for n in 2..=8 {
let holders: Vec<[Gaussian; 1]> = (0..n).map(|_| [r]).collect();
let groups: Vec<&[Gaussian]> = holders.iter().map(|g| g.as_slice()).collect();
let q = quality(&groups, BETA);
assert!(q.is_finite(), "n={n}: quality must be finite, got {q}");
assert!((0.0..=1.0).contains(&q), "n={n}: out of range: {q}");
}
}
/// Equal-strength groups are the best-matched case: introducing a skill gap
/// must lower quality.
#[test]
fn imbalance_lowers_quality() {
let strong = rating(40.0, 3.0);
let average = rating(25.0, 3.0);
let balanced = quality(&[&[average], &[average], &[average]], BETA);
let lopsided = quality(&[&[strong], &[average], &[average]], BETA);
assert!(
lopsided < balanced,
"expected imbalanced quality {lopsided} < balanced {balanced}"
);
}
/// Quality is a property of the multiset of groups, not their order.
#[test]
fn quality_is_permutation_invariant() {
let a = rating(30.0, 2.0);
let b = rating(25.0, 3.0);
let c = rating(20.0, 4.0);
let forward = quality(&[&[a], &[b], &[c]], BETA);
let reversed = quality(&[&[c], &[b], &[a]], BETA);
assert!(
(forward - reversed).abs() < 1e-9,
"permutation changed quality: {forward} vs {reversed}"
);
}
#[test]
fn multi_player_groups_work() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r, r], &[r, r], &[r, r]], BETA);
assert!(q.is_finite());
assert!((0.0..=1.0).contains(&q));
}
#[test]
fn uneven_group_sizes_work() {
let r = rating(25.0, 3.0);
let q = quality(&[&[r, r], &[r], &[r, r, r]], BETA);
assert!(q.is_finite(), "got {q}");
assert!((0.0..=1.0).contains(&q), "got {q}");
}
#[test]
#[should_panic(expected = "at least 2 rating groups")]
fn single_group_panics_with_clear_message() {
let r = rating(25.0, 3.0);
let _ = quality(&[&[r]], BETA);
}
#[test]
#[should_panic(expected = "at least 2 rating groups")]
fn zero_groups_panics_with_clear_message() {
let _ = quality(&[], BETA);
}
#[test]
#[should_panic(expected = "non-empty")]
fn empty_group_panics_with_clear_message() {
let r = rating(25.0, 3.0);
let _ = quality(&[&[r], &[]], BETA);
}
#[test]
fn history_predict_quality_supports_three_teams() {
use trueskill_tt::History;
let mut h = History::default();
h.record_winner(&"a", &"b", 1).unwrap();
h.record_winner(&"b", &"c", 2).unwrap();
h.converge().unwrap();
let q = h.predict_quality(&[&[&"a"], &[&"b"], &[&"c"]]);
assert!(
q.is_finite(),
"3-team predict_quality must be finite, got {q}"
);
assert!((0.0..=1.0).contains(&q), "out of range: {q}");
}