Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07285283b6 | ||
|
|
b73cf0145a | ||
|
|
56ff01074f | ||
|
|
8d47e54a8a | ||
|
|
7de092ba12 | ||
|
|
eeb43e3be1 | ||
|
|
69ddebe21d | ||
|
|
9c39d1e681 | ||
|
|
50e11cfbfa | ||
|
|
d4af048914 | ||
|
|
bf9d964cae | ||
|
|
187aede924 | ||
|
|
4fde482e48 | ||
|
|
9e8515b7cd | ||
|
|
9506fed4b3 | ||
|
|
6030dc78de | ||
|
|
355cdb7e05 | ||
|
|
06b6a68499 | ||
|
|
c088214fed | ||
|
|
0f1a1b8911 | ||
|
|
0d32690fcc | ||
|
|
6b8bd786d7 | ||
|
|
f4e2922d59 |
@@ -0,0 +1,15 @@
|
|||||||
|
# `Cargo.toml` sets `publish = ["kellnr"]`, so `cargo publish` targets the
|
||||||
|
# private registry and refuses crates.io. Cargo needs that registry's index
|
||||||
|
# declared to resolve the name.
|
||||||
|
#
|
||||||
|
# Committed rather than left to a per-user `~/.cargo/config.toml` so the repo
|
||||||
|
# is self-contained: a fresh clone, a new machine, or CI would otherwise fail
|
||||||
|
# with
|
||||||
|
#
|
||||||
|
# error: registry index was not found in any configuration: `kellnr`
|
||||||
|
#
|
||||||
|
# Index URL only — it is not a secret. Publish tokens live in
|
||||||
|
# `~/.cargo/credentials.toml` (per-user, never committed) or, in CI, in
|
||||||
|
# `CARGO_REGISTRIES_KELLNR_TOKEN`.
|
||||||
|
[registries.kellnr]
|
||||||
|
index = "sparse+https://crates.aceofba.se/api/v1/crates/"
|
||||||
@@ -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
|
||||||
@@ -2,6 +2,57 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 0.2.0 - 2026-08-27
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- refactor!: remove the inert online flag
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: reject ties without draw probability; never report NaN as converged
|
||||||
|
- fix(quality): support any number of rating groups
|
||||||
|
- fix(evidence): accumulate in log space and floor the per-link value
|
||||||
|
- fix(history): stop reprocessing the slice that was just appended to
|
||||||
|
- fix(rayon): remove the aliasing unsafe from the parallel sweep
|
||||||
|
- fix: close out four small issues and pin #27's repro
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- docs: refresh README and CLAUDE.md; add ingest benchmark
|
||||||
|
- docs: spec for filtered (forward-only) estimates
|
||||||
|
- docs: implementation plan for filtered estimates
|
||||||
|
- docs: state filtered accessor cost and evidence semantics precisely
|
||||||
|
- docs(cargo): correct the licence note — kellnr does not require one
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: add filtered_log_evidence
|
||||||
|
- feat: add filtered learning curves
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: add CI, crate metadata, and crate-level documentation
|
||||||
|
- chore: target releases at the private kellnr registry
|
||||||
|
- chore: keep the 48 MB ATP dataset out of the published crate
|
||||||
|
- chore: dual-license MIT OR Apache-2.0
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- perf(gaussian): drop the sqrt round-trip from variance-space operations
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- refactor: unify convergence defaults, validate builders, clear dead code
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- style: make NaN rejection explicit in score_sigma validation
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: pin the invariants that make filtered estimates trustworthy
|
||||||
|
|
||||||
## 0.1.2 - 2026-06-12
|
## 0.1.2 - 2026-06-12
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
@@ -32,6 +83,10 @@ All notable changes to this project will be documented in this file.
|
|||||||
- feat(outcome): per-event score_sigma override on Outcome::Scored
|
- feat(outcome): per-event score_sigma override on Outcome::Scored
|
||||||
- feat(event_builder): expose scores_with_sigma fluent method
|
- feat(event_builder): expose scores_with_sigma fluent method
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- chore: Release trueskill-tt version 0.1.2
|
||||||
|
|
||||||
### Refactor
|
### Refactor
|
||||||
|
|
||||||
- refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
|
- refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+28
-1
@@ -1,7 +1,30 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "trueskill-tt"
|
name = "trueskill-tt"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
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"
|
||||||
|
authors = ["Anders Olsson"]
|
||||||
|
# Publishing is restricted to the private kellnr registry; this also makes
|
||||||
|
# an accidental `cargo publish` to crates.io a hard error rather than a
|
||||||
|
# irreversible mistake. Index is declared in `.cargo/config.toml`.
|
||||||
|
publish = ["kellnr"]
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["trueskill", "rating", "bayesian", "elo", "skill"]
|
||||||
|
categories = ["algorithms", "science", "game-development"]
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
# `examples/atp.csv` is a 48 MB tennis dataset — 99% of the packaged crate,
|
||||||
|
# for a library whose source is 312 KB. `examples/atp.rs` opens it by
|
||||||
|
# relative path at runtime, so excluding the data still compiles; the
|
||||||
|
# example just needs the file fetched from the repo to run.
|
||||||
|
exclude = [
|
||||||
|
"/docs",
|
||||||
|
"/benches/*.txt",
|
||||||
|
"/temp",
|
||||||
|
"/.gitea",
|
||||||
|
"/examples/atp.csv",
|
||||||
|
]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
bench = false
|
bench = false
|
||||||
@@ -22,6 +45,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 }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,3 +43,49 @@ bench:
|
|||||||
|
|
||||||
flame:
|
flame:
|
||||||
cargo flamegraph --root --example atp
|
cargo flamegraph --root --example atp
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Release workflow
|
||||||
|
#
|
||||||
|
# Publishing goes to the private kellnr registry only: `Cargo.toml` sets
|
||||||
|
# `publish = ["kellnr"]`, so an accidental `cargo publish` to crates.io is a
|
||||||
|
# hard error rather than an irreversible mistake. The index is declared in the
|
||||||
|
# committed `.cargo/config.toml`; the token is per-user and lives in
|
||||||
|
# `~/.cargo/credentials.toml` (`cargo login --registry kellnr`).
|
||||||
|
#
|
||||||
|
# Step 1: just release-plan [level] — dry run, no writes
|
||||||
|
# Step 2: just release [level] — bump, changelog, tag, publish, push
|
||||||
|
#
|
||||||
|
# LEVEL is the cargo-release bump level (default `minor`). On 0.x:
|
||||||
|
# minor -> breaking bump (0.1.2 -> 0.2.0) <- any public-API change
|
||||||
|
# patch -> additive only (0.1.2 -> 0.1.3)
|
||||||
|
# major -> reserved for the 1.0.0 jump
|
||||||
|
#
|
||||||
|
# `release.toml` regenerates CHANGELOG.md with git-cliff in a pre-release hook
|
||||||
|
# and keeps push = false; this recipe pushes last, after publish has succeeded.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Dry-run preview of the next release. Inspect the version bump and the
|
||||||
|
# "Publishing ..." line before running `just release`.
|
||||||
|
release-plan level="minor":
|
||||||
|
cargo release {{level}}
|
||||||
|
|
||||||
|
# Cut a release from a clean main: gate -> bump -> tag -> publish -> push.
|
||||||
|
release level="minor":
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$(git branch --show-current)" != "main" ]]; then
|
||||||
|
echo "error: run 'just release' from the 'main' branch" >&2; exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "error: working tree is dirty — commit or stash first" >&2; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# cargo-release only verify-compiles the packaged crate; it does not run the
|
||||||
|
# suite, and publishing is irreversible. Run the same gate CI does, which
|
||||||
|
# includes the release profile where debug_assert! is compiled out.
|
||||||
|
just ci
|
||||||
|
|
||||||
|
cargo release {{level}} --execute --no-confirm
|
||||||
|
git push --follow-tags
|
||||||
|
|||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) 2026 Anders Olsson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -96,8 +96,25 @@ 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
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under either of
|
||||||
|
|
||||||
|
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
|
||||||
|
<http://www.apache.org/licenses/LICENSE-2.0>)
|
||||||
|
- MIT license ([LICENSE-MIT](LICENSE-MIT) or
|
||||||
|
<http://opensource.org/licenses/MIT>)
|
||||||
|
|
||||||
|
at your option.
|
||||||
|
|
||||||
|
### Contribution
|
||||||
|
|
||||||
|
Unless you explicitly state otherwise, any contribution intentionally submitted
|
||||||
|
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
|
||||||
|
dual licensed as above, without any additional terms or conditions.
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -44,6 +44,11 @@ split_commits = false
|
|||||||
# Assigns commits to groups.
|
# Assigns commits to groups.
|
||||||
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
||||||
commit_parsers = [
|
commit_parsers = [
|
||||||
|
# Must precede the type parsers below: a `feat!`/`fix!`/`refactor!` subject
|
||||||
|
# matches those too, and the first match wins. Without this a breaking
|
||||||
|
# change renders as an ordinary line of its own type.
|
||||||
|
{ message = "^[a-z]+(\\(.+\\))?!:", group = "Breaking Changes" },
|
||||||
|
{ body = "BREAKING CHANGE", group = "Breaking Changes" },
|
||||||
{ message = "^feat", group = "Features" },
|
{ message = "^feat", group = "Features" },
|
||||||
{ message = "^fix", group = "Bug Fixes" },
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
{ message = "^doc", group = "Documentation" },
|
{ message = "^doc", group = "Documentation" },
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
|||||||
|
# Filtered (Forward-Only) Estimates
|
||||||
|
|
||||||
|
Closes [#19](https://git.aceofba.se/logaritmisk/trueskill-tt/issues/19).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`HistoryBuilder::online(true)` is inert. It flips a flag that reaches
|
||||||
|
`Item::within_prior` (`src/time_slice.rs:70-71`), which reads
|
||||||
|
`Skill.online` (`src/time_slice.rs:25`) — a field initialised to `N_INF`
|
||||||
|
(`src/time_slice.rs:41`) and never assigned anywhere. The online path
|
||||||
|
therefore builds every rating from the improper Gaussian, and
|
||||||
|
`log_evidence()` silently reports `n × ln(0.5)`: every game scored as a
|
||||||
|
coin flip, finite and plausible-looking.
|
||||||
|
|
||||||
|
This spec replaces the field and the flag with a **read-only forward-only
|
||||||
|
pass** over the converged history, exposed as three new public methods.
|
||||||
|
The pass reuses the production within-slice sweep verbatim rather than
|
||||||
|
reimplementing inference, and stores nothing on `Skill`.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
### Why a stored field cannot hold this quantity
|
||||||
|
|
||||||
|
The issue proposes populating `skill.online` during the forward pass,
|
||||||
|
alongside `new_forward_info` (`src/time_slice.rs:576`). That would not
|
||||||
|
work, and understanding why determines the whole design.
|
||||||
|
|
||||||
|
`new_forward_info` sets `skill.forward` from
|
||||||
|
`agents[a].receive_for_elapsed(...)`, whose `message` was written by the
|
||||||
|
previous slice's `forward_prior_out` (`src/time_slice.rs:549`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
skill.forward * skill.likelihood
|
||||||
|
```
|
||||||
|
|
||||||
|
`History::iteration` (`src/history.rs:255`) alternates a backward sweep
|
||||||
|
over slices and a forward sweep. From the second iteration onward, the
|
||||||
|
`skill.likelihood` feeding that message has already absorbed backward
|
||||||
|
information from the preceding backward sweep. So after `converge()`,
|
||||||
|
**`skill.forward` is a smoothed quantity, not a filtering one** — and any
|
||||||
|
field written from it inherits the same contamination on every sweep
|
||||||
|
after the first.
|
||||||
|
|
||||||
|
### The neighbouring trap
|
||||||
|
|
||||||
|
The same reasoning applies to the existing `forward: bool` parameter on
|
||||||
|
`log_evidence_internal` (`src/history.rs:395`). It is a genuine filtering
|
||||||
|
quantity only on a history that has never been converged. That is why the
|
||||||
|
test at `src/history.rs:1183` can assert
|
||||||
|
|
||||||
|
```rust
|
||||||
|
assert_ulps_eq!(trueskill_log_evidence, trueskill_log_evidence_online, epsilon = 1e-6);
|
||||||
|
```
|
||||||
|
|
||||||
|
— the fixture is never converged, so the forward message still equals the
|
||||||
|
cavity prior. (Note also that the local binding is named `..._online`
|
||||||
|
while the flag it passes is `forward`; the two senses were already
|
||||||
|
muddled.)
|
||||||
|
|
||||||
|
Fixing `forward: bool` is **out of scope** here; see *Out-of-scope
|
||||||
|
follow-ups*.
|
||||||
|
|
||||||
|
### Why this is worth implementing rather than deleting
|
||||||
|
|
||||||
|
The forward-only estimate has a second consumer beyond prequential model
|
||||||
|
comparison. `learning_curve()` returns post-convergence posteriors, so
|
||||||
|
every point is smoothed — the estimate at a given date incorporates
|
||||||
|
rounds played years later. On [ustat](https://git.aceofba.se/logaritmisk/ustat)'s
|
||||||
|
real data (prior μ=0, σ=6) that produces curves which start already
|
||||||
|
spread apart and barely move:
|
||||||
|
|
||||||
|
```
|
||||||
|
player first point final point
|
||||||
|
Eskil mu +3.72 sigma 1.17 mu +4.61 sigma 1.21
|
||||||
|
Anders Olsson mu +1.61 sigma 0.90 mu +1.16 sigma 0.82
|
||||||
|
LUDVIGSSON mu -2.09 sigma 1.08 mu -2.61 sigma 1.13
|
||||||
|
Anners mu -2.85 sigma 1.27 mu -2.86 sigma 1.26
|
||||||
|
```
|
||||||
|
|
||||||
|
σ at the *first* plotted point is 0.90–1.60 against a prior of 6.00. A
|
||||||
|
caller cannot reconstruct the filtered view from the public API today
|
||||||
|
except by refitting over `events[0..k]` for every k — O(n²) fits for
|
||||||
|
something one forward pass already computes.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### What ships
|
||||||
|
|
||||||
|
1. A read-only forward-only pass on `History`, walking slices in time
|
||||||
|
order and carrying its own forward messages.
|
||||||
|
2. Three public methods: `filtered_log_evidence`,
|
||||||
|
`filtered_learning_curves`, `filtered_learning_curve`.
|
||||||
|
3. Removal of `Skill.online`, `History.online`, `HistoryBuilder.online`,
|
||||||
|
`HistoryBuilder::online()`, and the `online: bool` parameter threaded
|
||||||
|
through `Item::within_prior`, `Event::within_priors`, and
|
||||||
|
`TimeSlice::log_evidence`.
|
||||||
|
4. `#[derive(Clone)]` on `Event`, `Team`, `Item`; `iterate_to_convergence`
|
||||||
|
loses its `#[cfg(test)]` gate.
|
||||||
|
5. A CHANGELOG entry recording the API break.
|
||||||
|
|
||||||
|
### What does not ship
|
||||||
|
|
||||||
|
- No change to `log_evidence()`, `log_evidence_for()`, `learning_curve()`,
|
||||||
|
`learning_curves()`, or `current_skill()`. Their values are unchanged
|
||||||
|
by this work.
|
||||||
|
- No fix to the `forward: bool` flag described above.
|
||||||
|
- No caching of pass results. Each call runs a full pass; the doc
|
||||||
|
comments say so.
|
||||||
|
- No `rayon` parallelism across slices — the pass is sequentially
|
||||||
|
dependent by construction.
|
||||||
|
- No prior-predictive accessor. The pass computes the pre-event forward
|
||||||
|
message internally, but only the filtered posterior is exposed until a
|
||||||
|
second caller needs otherwise.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
`filtered_*`, not `online_*`. "Filtered" is the standard term for the
|
||||||
|
forward-only estimate, and the crate already uses "online" for a second,
|
||||||
|
unrelated thing — incremental ingestion, which `benches/baseline.txt:128`
|
||||||
|
calls the "online-add" path. Two senses of one word in one crate is how
|
||||||
|
the present bug reads as plausible.
|
||||||
|
|
||||||
|
### The pass
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) struct FilteredStep {
|
||||||
|
log_evidence: f64,
|
||||||
|
posteriors: Vec<(Index, Gaussian)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filtered_pass(&self) -> Vec<(T, FilteredStep)>
|
||||||
|
```
|
||||||
|
|
||||||
|
`posteriors` doubles as the outgoing forward message: the scratch sweep never
|
||||||
|
writes `backward`, so it stays `N_INF`, and `Skill::posterior()` and
|
||||||
|
`forward_prior_out` are then the same product.
|
||||||
|
|
||||||
|
Walk `self.time_slices` in order, carrying
|
||||||
|
`messages: HashMap<Index, Gaussian>` — the forward message out of each
|
||||||
|
competitor's most recent appearance. For each slice:
|
||||||
|
|
||||||
|
1. **Build a scratch clone.** Same `time`, `p_draw`, `convergence`, and
|
||||||
|
cloned `events` with every `item.likelihood` reset to `N_INF`. Fresh
|
||||||
|
`SkillStore` in which, for each agent present in the real slice:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
forward = match messages.get(&agent) {
|
||||||
|
Some(msg) => msg.forget(rating.drift.variance_for_elapsed(skill.elapsed)),
|
||||||
|
None => rating.prior,
|
||||||
|
}
|
||||||
|
backward = N_INF
|
||||||
|
likelihood = N_INF
|
||||||
|
elapsed = skill.elapsed // copied from the real slice
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors `Competitor::receive_for_elapsed` (`src/competitor.rs:39`)
|
||||||
|
exactly, including its `message != N_INF` fallback to the prior.
|
||||||
|
`skill.elapsed` is reused rather than recomputed: it is maintained by
|
||||||
|
`add_events_with_prior` across out-of-order ingestion, and production
|
||||||
|
convergence already trusts it.
|
||||||
|
|
||||||
|
2. **Run the real sweep.** `scratch.iterate_to_convergence(agents)`
|
||||||
|
(`src/time_slice.rs:516`), unmodified. Fidelity comes from reusing the
|
||||||
|
production path rather than a parallel reimplementation — in
|
||||||
|
particular, a competitor appearing in two events at the same time is
|
||||||
|
handled by the same within-slice EP that `converge()` uses, not
|
||||||
|
approximated the way the current `online`/`forward` evidence paths are
|
||||||
|
(they run each event independently and sum).
|
||||||
|
|
||||||
|
3. **Harvest.** With `backward == N_INF` acting as the multiplicative
|
||||||
|
identity, `Skill::posterior()` is exactly forward × likelihood — the
|
||||||
|
filtered posterior. Slice evidence is
|
||||||
|
`scratch.events.iter().map(|e| e.log_evidence).sum()`; `apply`
|
||||||
|
(`src/time_slice.rs:162`) writes that field on every event during the
|
||||||
|
sweep.
|
||||||
|
|
||||||
|
4. **Carry forward.** `messages.insert(a, scratch.forward_prior_out(&a))`
|
||||||
|
for each agent in the slice.
|
||||||
|
|
||||||
|
Steps 1–4 are the forward half of `History::iteration`
|
||||||
|
(`src/history.rs:283-297`) with the backward half never run. The pass
|
||||||
|
touches no field of `self`.
|
||||||
|
|
||||||
|
### Public API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
|
||||||
|
pub fn filtered_log_evidence(&self) -> f64;
|
||||||
|
pub fn filtered_learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>>;
|
||||||
|
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
|
||||||
|
where
|
||||||
|
K: Borrow<Q>,
|
||||||
|
Q: Hash + Eq + ?Sized;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All take `&self` — the pass mutates nothing. Shapes deliberately mirror
|
||||||
|
`learning_curve` / `learning_curves` (`src/history.rs:325`, `:381`) so a
|
||||||
|
caller can plot smoothed and filtered curves on one chart with the same
|
||||||
|
handling code.
|
||||||
|
|
||||||
|
`filtered_learning_curve` runs the same full pass as the plural form and
|
||||||
|
collects one key; the cost is identical, only the collection differs.
|
||||||
|
Callers wanting several keys should use the plural form. Documented on
|
||||||
|
both methods.
|
||||||
|
|
||||||
|
Because the pass carries its own messages and re-runs inference, its
|
||||||
|
results **do not depend on whether `converge()` has been called**. That
|
||||||
|
is the property a stored field cannot have, and it is asserted as a test.
|
||||||
|
|
||||||
|
### Removal inventory
|
||||||
|
|
||||||
|
| Location | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/time_slice.rs:25` | delete `pub(crate) online: Gaussian` |
|
||||||
|
| `src/time_slice.rs:41` | delete `online: N_INF` from `Default` |
|
||||||
|
| `src/time_slice.rs:62,70-73` | drop `online` param and its branch from `Item::within_prior` |
|
||||||
|
| `src/time_slice.rs:110,120` | drop `online` param from `Event::within_priors` |
|
||||||
|
| `src/time_slice.rs:585,597,626,634` | drop `online` param from `TimeSlice::log_evidence`; `online \|\| forward` becomes `forward` |
|
||||||
|
| `src/history.rs:32,63,138,158,174,199,226` | delete the two `online` field declarations (`:32`, `:199`) and the five struct-literal copies |
|
||||||
|
| `src/history.rs:90-93` | delete `HistoryBuilder::online()` |
|
||||||
|
| `src/history.rs:402,410` | drop the `self.online` argument |
|
||||||
|
| `src/history.rs:1183-1189` | the `..._online` assertion becomes a `forward`-flag assertion; rename the binding to match what it tests |
|
||||||
|
|
||||||
|
`Skill` loses 16 bytes, which is a small independent win for #17.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
Every new test is mutation-proved before it counts: break the production
|
||||||
|
line it names, watch it fail for the *right* assertion, restore. A test
|
||||||
|
never observed failing is not evidence.
|
||||||
|
|
||||||
|
### The red test
|
||||||
|
|
||||||
|
On the issue's own fixture — five 1v1 games, same winner each time —
|
||||||
|
`filtered_log_evidence()` must land strictly between the two known
|
||||||
|
endpoints:
|
||||||
|
|
||||||
|
```
|
||||||
|
5 × ln(0.5) = -3.4657... (today's inert value)
|
||||||
|
< filtered
|
||||||
|
< -0.4012... (batch / smoothed evidence)
|
||||||
|
```
|
||||||
|
|
||||||
|
Two-sided, so neither "still inert" nor "accidentally smoothed" can pass.
|
||||||
|
The lower bound is right for a real reason: game one genuinely *is* a
|
||||||
|
coin flip under filtering, games two through five are not.
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
1. **Invariant to `converge()`** — `filtered_log_evidence()` and
|
||||||
|
`filtered_learning_curves()` agree before and after `converge()`. This
|
||||||
|
is exactly what `skill.forward` fails, and what makes a stored field
|
||||||
|
the wrong mechanism.
|
||||||
|
|
||||||
|
Agreement is to tolerance, not bit-identity, and the reason is worth
|
||||||
|
recording. `iteration` calls `recompute_color_groups`
|
||||||
|
(`src/time_slice.rs:369`) only when `from == 0`, so a slice built by
|
||||||
|
repeated appends keeps insertion order until the first `converge()`
|
||||||
|
reorders it. The scratch clone inherits whichever order it finds, and
|
||||||
|
greedy coloring over a permuted input can group differently, giving a
|
||||||
|
different within-slice sweep order — same EP fixed point, different
|
||||||
|
path to it. Follow the house pattern in
|
||||||
|
`tests/ingestion_equivalence.rs`: converge tightly (`max_iter: 2_000`,
|
||||||
|
`epsilon: 1e-12`) and compare within `1e-8`.
|
||||||
|
2. **Invariant to ingestion order** — events added one at a time produce
|
||||||
|
the same filtered results as the same events batched. Extends the
|
||||||
|
existing invariant in `tests/ingestion_equivalence.rs`.
|
||||||
|
3. **Single-slice exactness** — for a history with one time slice there
|
||||||
|
is no future to propagate back, so filtered results equal smoothed
|
||||||
|
results exactly.
|
||||||
|
4. **Uncertainty ordering** — for a competitor with many later games, σ
|
||||||
|
at the first filtered point is greater than σ at the first smoothed
|
||||||
|
point, and less than the prior σ. This is the ustat complaint restated
|
||||||
|
as an assertion.
|
||||||
|
5. **Degenerate inputs** — empty history yields `0.0` and empty maps;
|
||||||
|
unknown key yields an empty curve. Added to
|
||||||
|
`tests/degenerate_inputs.rs`.
|
||||||
|
|
||||||
|
### Regression net
|
||||||
|
|
||||||
|
The existing suite must be unchanged by the removals: `log_evidence()`,
|
||||||
|
`log_evidence_for()`, and every numerical golden keep their current
|
||||||
|
values, since the default `online` was already `false` and the flag was
|
||||||
|
inert.
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
- `just test` — full matrix, including the release job. `debug_assert!`
|
||||||
|
is compiled out in release, and that is where defects in this crate
|
||||||
|
have hidden before.
|
||||||
|
- `just lint` — clippy, warnings denied.
|
||||||
|
- `just fmt` — nightly.
|
||||||
|
- `just determinism` — the new pass must not perturb bit-identical
|
||||||
|
posteriors across `RAYON_NUM_THREADS` 1/2/4/8.
|
||||||
|
- `#![forbid(unsafe_code)]` stays.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Clone cost.** One slice's events are cloned per slice visited. At
|
||||||
|
ustat scale this is negligible, but the pass is O(events) allocation on
|
||||||
|
top of O(events) inference. Accepted: fidelity to the production sweep
|
||||||
|
is worth more than avoiding the clone, and no caller is on a hot path.
|
||||||
|
- **`iterate_to_convergence` leaving test-only status.** Its doc comment
|
||||||
|
claims "only used by tests"; that comment must be updated, or it
|
||||||
|
becomes the next piece of load-bearing prose that is quietly false.
|
||||||
|
- **Event order is inherited, not normalised.** The scratch clone takes
|
||||||
|
the real slice's current event order, which differs pre- and
|
||||||
|
post-`converge()` for incrementally-ingested slices (see *Invariants*).
|
||||||
|
Results agree to within convergence tolerance rather than exactly.
|
||||||
|
Normalising the order in the scratch builder would buy bit-identity at
|
||||||
|
the cost of diverging from what the real sweep does; not worth it.
|
||||||
|
|
||||||
|
**Measured after implementation, this risk is smaller than stated.**
|
||||||
|
Flipping the scratch's `color_groups_dirty` from `true` to `false`
|
||||||
|
switches it between the grouped sweep (`sweep_color_groups`) and the
|
||||||
|
sequential fallback across its entire convergence loop — a far larger
|
||||||
|
perturbation than a permuted event order — and the ingestion-order
|
||||||
|
invariance test stays green at `1e-8` under `max_iter: 2_000`,
|
||||||
|
`epsilon: 1e-12`. EP reaches the same fixed point regardless of sweep
|
||||||
|
order once driven far enough. The tolerance caveat is correct but
|
||||||
|
conservative. Note the flag itself is load-bearing: with it `false` the
|
||||||
|
scratch would take the sequential path always, diverging from the
|
||||||
|
production sweep it exists to mirror.
|
||||||
|
- **Divergence risk.** If `TimeSlice`'s sweep gains state that the
|
||||||
|
scratch construction does not initialise, the pass silently reads a
|
||||||
|
default. The scratch builder must construct `Skill` field-by-field
|
||||||
|
rather than via `..Default::default()`, so adding a field to `Skill`
|
||||||
|
is a compile error here rather than a silent wrong answer.
|
||||||
|
|
||||||
|
## Out-of-scope follow-ups
|
||||||
|
|
||||||
|
File as separate issues:
|
||||||
|
|
||||||
|
1. **`forward: bool` is only a filtering quantity pre-convergence**
|
||||||
|
(`src/history.rs:395`). Either document the constraint or fold the
|
||||||
|
flag into the new pass and delete it.
|
||||||
|
2. **`log_evidence` takes `&mut self`** (`src/history.rs:416`) but
|
||||||
|
mutates nothing. The new `filtered_*` methods take `&self`; the
|
||||||
|
asymmetry is worth removing.
|
||||||
+5
-1
@@ -1,2 +1,6 @@
|
|||||||
publish = false
|
# Publish to the registry named in Cargo.toml's `publish` list (kellnr).
|
||||||
|
publish = true
|
||||||
|
# Hold off pushing until tags and publish have both succeeded; `just release`
|
||||||
|
# pushes last.
|
||||||
|
push = false
|
||||||
pre-release-hook = ["sh", "-c", "git cliff -o CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"]
|
pre-release-hook = ["sh", "-c", "git cliff -o CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"]
|
||||||
|
|||||||
+46
-11
@@ -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.
|
||||||
@@ -67,7 +103,6 @@ impl ColorGroups {
|
|||||||
/// `Index` values that event touches. The returned `ColorGroups` has one
|
/// `Index` values that event touches. The returned `ColorGroups` has one
|
||||||
/// inner `Vec<usize>` per color, containing event indices in the order
|
/// inner `Vec<usize>` per color, containing event indices in the order
|
||||||
/// they were assigned.
|
/// they were assigned.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups
|
pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups
|
||||||
where
|
where
|
||||||
F: Fn(usize) -> I,
|
F: Fn(usize) -> I,
|
||||||
|
|||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+223
-35
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
sort_time,
|
sort_time,
|
||||||
storage::CompetitorStore,
|
storage::CompetitorStore,
|
||||||
time::Time,
|
time::Time,
|
||||||
time_slice::{self, EventKind, TimeSlice},
|
time_slice::{self, EventKind, FilteredStep, TimeSlice},
|
||||||
tuple_gt, tuple_max,
|
tuple_gt, tuple_max,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ pub struct HistoryBuilder<
|
|||||||
beta: f64,
|
beta: f64,
|
||||||
drift: D,
|
drift: D,
|
||||||
p_draw: f64,
|
p_draw: f64,
|
||||||
online: bool,
|
|
||||||
score_sigma: f64,
|
score_sigma: f64,
|
||||||
convergence: ConvergenceOptions,
|
convergence: ConvergenceOptions,
|
||||||
observer: O,
|
observer: O,
|
||||||
@@ -60,7 +59,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
sigma: self.sigma,
|
sigma: self.sigma,
|
||||||
beta: self.beta,
|
beta: self.beta,
|
||||||
p_draw: self.p_draw,
|
p_draw: self.p_draw,
|
||||||
online: self.online,
|
|
||||||
score_sigma: self.score_sigma,
|
score_sigma: self.score_sigma,
|
||||||
convergence: self.convergence,
|
convergence: self.convergence,
|
||||||
observer: self.observer,
|
observer: self.observer,
|
||||||
@@ -69,16 +67,29 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn online(mut self, online: bool) -> Self {
|
/// Default observation noise for scored outcomes.
|
||||||
self.online = online;
|
///
|
||||||
self
|
/// # 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 +99,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
|
||||||
}
|
}
|
||||||
@@ -100,7 +128,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
beta: self.beta,
|
beta: self.beta,
|
||||||
drift: self.drift,
|
drift: self.drift,
|
||||||
p_draw: self.p_draw,
|
p_draw: self.p_draw,
|
||||||
online: self.online,
|
|
||||||
score_sigma: self.score_sigma,
|
score_sigma: self.score_sigma,
|
||||||
convergence: self.convergence,
|
convergence: self.convergence,
|
||||||
observer,
|
observer,
|
||||||
@@ -120,7 +147,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
|||||||
beta: self.beta,
|
beta: self.beta,
|
||||||
drift: self.drift,
|
drift: self.drift,
|
||||||
p_draw: self.p_draw,
|
p_draw: self.p_draw,
|
||||||
online: self.online,
|
|
||||||
score_sigma: self.score_sigma,
|
score_sigma: self.score_sigma,
|
||||||
convergence: self.convergence,
|
convergence: self.convergence,
|
||||||
observer: self.observer,
|
observer: self.observer,
|
||||||
@@ -136,7 +162,6 @@ impl Default for HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str>
|
|||||||
beta: BETA,
|
beta: BETA,
|
||||||
drift: ConstantDrift(GAMMA),
|
drift: ConstantDrift(GAMMA),
|
||||||
p_draw: P_DRAW,
|
p_draw: P_DRAW,
|
||||||
online: false,
|
|
||||||
score_sigma: 1.0,
|
score_sigma: 1.0,
|
||||||
convergence: ConvergenceOptions::default(),
|
convergence: ConvergenceOptions::default(),
|
||||||
observer: NullObserver,
|
observer: NullObserver,
|
||||||
@@ -161,7 +186,6 @@ pub struct History<
|
|||||||
beta: f64,
|
beta: f64,
|
||||||
drift: D,
|
drift: D,
|
||||||
p_draw: f64,
|
p_draw: f64,
|
||||||
online: bool,
|
|
||||||
score_sigma: f64,
|
score_sigma: f64,
|
||||||
convergence: ConvergenceOptions,
|
convergence: ConvergenceOptions,
|
||||||
observer: O,
|
observer: O,
|
||||||
@@ -188,7 +212,6 @@ impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
|
|||||||
beta: BETA,
|
beta: BETA,
|
||||||
drift: ConstantDrift(GAMMA),
|
drift: ConstantDrift(GAMMA),
|
||||||
p_draw: P_DRAW,
|
p_draw: P_DRAW,
|
||||||
online: false,
|
|
||||||
score_sigma: 1.0,
|
score_sigma: 1.0,
|
||||||
convergence: ConvergenceOptions::default(),
|
convergence: ConvergenceOptions::default(),
|
||||||
observer: NullObserver,
|
observer: NullObserver,
|
||||||
@@ -220,6 +243,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,10 +300,13 @@ 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²)
|
|
||||||
/// in the number of competitors. Acceptable for T2; T3 may optimize.
|
|
||||||
pub fn learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
|
pub fn learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
|
||||||
#[cfg(feature = "rayon")]
|
#[cfg(feature = "rayon")]
|
||||||
{
|
{
|
||||||
@@ -347,14 +377,79 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn log_evidence_internal(&mut self, forward: bool, targets: &[Index]) -> f64 {
|
/// Filtered learning curves for all competitors, keyed by user-facing key.
|
||||||
|
///
|
||||||
|
/// Each point is the posterior using only events up to and including that
|
||||||
|
/// time — "what we knew then". Contrast `learning_curves`, whose points
|
||||||
|
/// are smoothed and so incorporate rounds played later.
|
||||||
|
///
|
||||||
|
/// Runs a full forward pass per call and caches nothing. This is the
|
||||||
|
/// entry point for multi-key work — see `filtered_learning_curve` for
|
||||||
|
/// why calling that once per key is far more expensive.
|
||||||
|
pub fn filtered_learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
|
||||||
|
let mut data: HashMap<K, Vec<(T, Gaussian)>> = HashMap::new();
|
||||||
|
|
||||||
|
for (time, step) in self.filtered_pass() {
|
||||||
|
for (agent, posterior) in step.posteriors {
|
||||||
|
if let Some(key) = self.keys.key(agent).cloned() {
|
||||||
|
data.entry(key).or_default().push((time, posterior));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filtered learning curve for a single key: (time, posterior) pairs in
|
||||||
|
/// time order.
|
||||||
|
///
|
||||||
|
/// Despite mirroring `learning_curve`'s signature, this is not the cheap
|
||||||
|
/// per-key lookup that method is: it runs a full forward pass, O(events),
|
||||||
|
/// discarding every posterior but the requested key's. N keys fetched
|
||||||
|
/// this way costs O(N * events); use `filtered_learning_curves` for
|
||||||
|
/// multi-key work instead — it computes the same pass once.
|
||||||
|
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
|
||||||
|
where
|
||||||
|
K: Borrow<Q>,
|
||||||
|
Q: Hash + Eq + ?Sized,
|
||||||
|
{
|
||||||
|
let Some(idx) = self.keys.get(key) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
self.filtered_pass()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(time, step)| {
|
||||||
|
step.posteriors
|
||||||
|
.iter()
|
||||||
|
.find(|(agent, _)| *agent == idx)
|
||||||
|
.map(|&(_, posterior)| (time, posterior))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum per-slice evidence.
|
||||||
|
///
|
||||||
|
/// `forward` selects `skill.forward` as each event's prior instead of the
|
||||||
|
/// cavity. That is a genuine forward-only (filtering) quantity ONLY on a
|
||||||
|
/// history that has never been converged: `iteration` alternates backward
|
||||||
|
/// and forward sweeps, so from the second iteration onward the likelihood
|
||||||
|
/// feeding the forward message has already absorbed backward information.
|
||||||
|
/// For a filtering quantity that holds after convergence, use
|
||||||
|
/// `filtered_log_evidence`.
|
||||||
|
pub(crate) fn log_evidence_internal(&self, forward: bool, targets: &[Index]) -> f64 {
|
||||||
|
// Bound before the closure so it captures the store rather than all of
|
||||||
|
// `&self`: capturing `&History` would drag `KeyTable<K>` in and demand
|
||||||
|
// `K: Sync` from every caller, which the key type need not satisfy.
|
||||||
|
let agents = &self.agents;
|
||||||
|
|
||||||
#[cfg(feature = "rayon")]
|
#[cfg(feature = "rayon")]
|
||||||
{
|
{
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
let per_slice: Vec<f64> = self
|
let per_slice: Vec<f64> = self
|
||||||
.time_slices
|
.time_slices
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.map(|ts| ts.log_evidence(self.online, targets, forward, &self.agents))
|
.map(|ts| ts.log_evidence(targets, forward, agents))
|
||||||
.collect();
|
.collect();
|
||||||
per_slice.into_iter().sum()
|
per_slice.into_iter().sum()
|
||||||
}
|
}
|
||||||
@@ -362,19 +457,19 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
{
|
{
|
||||||
self.time_slices
|
self.time_slices
|
||||||
.iter()
|
.iter()
|
||||||
.map(|ts| ts.log_evidence(self.online, targets, forward, &self.agents))
|
.map(|ts| ts.log_evidence(targets, forward, agents))
|
||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total log-evidence across the history.
|
/// Total log-evidence across the history.
|
||||||
pub fn log_evidence(&mut self) -> f64 {
|
pub fn log_evidence(&self) -> f64 {
|
||||||
self.log_evidence_internal(false, &[])
|
self.log_evidence_internal(false, &[])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log-evidence restricted to time slices containing at least one of the
|
/// Log-evidence restricted to time slices containing at least one of the
|
||||||
/// given keys. Useful for leave-one-out cross-validation.
|
/// given keys. Useful for leave-one-out cross-validation.
|
||||||
pub fn log_evidence_for<Q>(&mut self, keys: &[&Q]) -> f64
|
pub fn log_evidence_for<Q>(&self, keys: &[&Q]) -> f64
|
||||||
where
|
where
|
||||||
K: std::borrow::Borrow<Q>,
|
K: std::borrow::Borrow<Q>,
|
||||||
Q: std::hash::Hash + Eq + ?Sized,
|
Q: std::hash::Hash + Eq + ?Sized,
|
||||||
@@ -383,9 +478,59 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
self.log_evidence_internal(false, &targets)
|
self.log_evidence_internal(false, &targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Walk the slices in time order carrying forward messages only.
|
||||||
|
///
|
||||||
|
/// This is the forward half of `iteration` with the backward half never
|
||||||
|
/// run. It reads `self` and mutates nothing.
|
||||||
|
fn filtered_pass(&self) -> Vec<(T, FilteredStep)> {
|
||||||
|
let mut messages: HashMap<Index, Gaussian> = HashMap::new();
|
||||||
|
|
||||||
|
let mut pass = Vec::with_capacity(self.time_slices.len());
|
||||||
|
|
||||||
|
for slice in &self.time_slices {
|
||||||
|
let step = slice.filtered_step(&messages, &self.agents);
|
||||||
|
|
||||||
|
for &(agent, posterior) in &step.posteriors {
|
||||||
|
messages.insert(agent, posterior);
|
||||||
|
}
|
||||||
|
|
||||||
|
pass.push((slice.time, step));
|
||||||
|
}
|
||||||
|
|
||||||
|
pass
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total log-evidence under forward-only (filtering) information.
|
||||||
|
///
|
||||||
|
/// Each event is scored using only what was known before that *time*,
|
||||||
|
/// which is the right quantity for prequential scoring and model
|
||||||
|
/// comparison. Events sharing a timestamp still inform each other
|
||||||
|
/// through the within-slice sweep, so within one slice this is not a
|
||||||
|
/// guarantee that event A is scored independently of simultaneous event
|
||||||
|
/// B. Contrast `log_evidence`, whose per-event priors carry information
|
||||||
|
/// from events that had not happened yet.
|
||||||
|
///
|
||||||
|
/// Runs a full forward pass per call and caches nothing. The result does
|
||||||
|
/// not depend on whether `converge` has been called.
|
||||||
|
#[must_use]
|
||||||
|
pub fn filtered_log_evidence(&self) -> f64 {
|
||||||
|
self.filtered_pass()
|
||||||
|
.iter()
|
||||||
|
.map(|(_, step)| step.log_evidence)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 +580,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 +601,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 +671,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 +781,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 +924,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,
|
||||||
});
|
});
|
||||||
@@ -834,12 +1027,7 @@ mod tests {
|
|||||||
|
|
||||||
let w = [vec![1.0], vec![1.0]];
|
let w = [vec![1.0], vec![1.0]];
|
||||||
let p = Game::ranked_with_arena(
|
let p = Game::ranked_with_arena(
|
||||||
h.time_slices[1].events[0].within_priors(
|
h.time_slices[1].events[0].within_priors(false, &h.time_slices[1].skills, &h.agents),
|
||||||
false,
|
|
||||||
false,
|
|
||||||
&h.time_slices[1].skills,
|
|
||||||
&h.agents,
|
|
||||||
),
|
|
||||||
&[0.0, 1.0],
|
&[0.0, 1.0],
|
||||||
&w,
|
&w,
|
||||||
P_DRAW,
|
P_DRAW,
|
||||||
@@ -1079,11 +1267,11 @@ mod tests {
|
|||||||
let f = h.keys.get("f").unwrap();
|
let f = h.keys.get("f").unwrap();
|
||||||
|
|
||||||
let trueskill_log_evidence = h.log_evidence_internal(false, &[]);
|
let trueskill_log_evidence = h.log_evidence_internal(false, &[]);
|
||||||
let trueskill_log_evidence_online = h.log_evidence_internal(true, &[]);
|
let trueskill_log_evidence_forward = h.log_evidence_internal(true, &[]);
|
||||||
|
|
||||||
assert_ulps_eq!(
|
assert_ulps_eq!(
|
||||||
trueskill_log_evidence,
|
trueskill_log_evidence,
|
||||||
trueskill_log_evidence_online,
|
trueskill_log_evidence_forward,
|
||||||
epsilon = 1e-6
|
epsilon = 1e-6
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+24
-15
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Lu::decompose(self).determinant()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn adjugate(&self) -> Matrix {
|
/// Matrix inverse via LU decomposition.
|
||||||
debug_assert!(self.width == self.height);
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the matrix is not square or is singular.
|
||||||
|
pub fn inverse(&self) -> Matrix {
|
||||||
|
assert_eq!(
|
||||||
|
self.width, self.height,
|
||||||
|
"inverse requires a square matrix, got {}x{}",
|
||||||
|
self.height, self.width
|
||||||
|
);
|
||||||
|
|
||||||
let mut matrix = Matrix::new(self.height, self.width);
|
let n = self.width;
|
||||||
|
let mut inverse = Matrix::new(n, n);
|
||||||
|
|
||||||
if matrix.height == 2 {
|
if n == 0 {
|
||||||
matrix[(0, 0)] = self[(1, 1)];
|
return inverse;
|
||||||
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;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
matrix
|
inverse
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inverse(&self) -> Matrix {
|
|
||||||
let mut matrix = Matrix::new(self.width, self.height);
|
|
||||||
|
|
||||||
if self.height == self.width && self.height == 1 {
|
|
||||||
matrix[(0, 0)] = 1.0 / self[(0, 0)];
|
|
||||||
} else {
|
|
||||||
panic!("eh, okey")
|
|
||||||
}
|
|
||||||
|
|
||||||
matrix
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
+234
-91
@@ -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)]
|
||||||
@@ -23,7 +22,6 @@ pub(crate) struct Skill {
|
|||||||
backward: Gaussian,
|
backward: Gaussian,
|
||||||
likelihood: Gaussian,
|
likelihood: Gaussian,
|
||||||
pub(crate) elapsed: i64,
|
pub(crate) elapsed: i64,
|
||||||
pub(crate) online: Gaussian,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Skill {
|
impl Skill {
|
||||||
@@ -39,7 +37,6 @@ impl Default for Skill {
|
|||||||
backward: N_INF,
|
backward: N_INF,
|
||||||
likelihood: N_INF,
|
likelihood: N_INF,
|
||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
online: N_INF,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +48,7 @@ pub enum EventKind {
|
|||||||
Scored { score_sigma: f64 },
|
Scored { score_sigma: f64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct Item {
|
struct Item {
|
||||||
agent: Index,
|
agent: Index,
|
||||||
likelihood: Gaussian,
|
likelihood: Gaussian,
|
||||||
@@ -60,7 +57,6 @@ struct Item {
|
|||||||
impl Item {
|
impl Item {
|
||||||
fn within_prior<T: Time, D: Drift<T>>(
|
fn within_prior<T: Time, D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
online: bool,
|
|
||||||
forward: bool,
|
forward: bool,
|
||||||
skills: &SkillStore,
|
skills: &SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
agents: &CompetitorStore<T, D>,
|
||||||
@@ -68,9 +64,7 @@ impl Item {
|
|||||||
let r = &agents[self.agent].rating;
|
let r = &agents[self.agent].rating;
|
||||||
let skill = skills.get(self.agent).unwrap();
|
let skill = skills.get(self.agent).unwrap();
|
||||||
|
|
||||||
if online {
|
if forward {
|
||||||
Rating::new(skill.online, r.beta, r.drift)
|
|
||||||
} else if forward {
|
|
||||||
Rating::new(skill.forward, r.beta, r.drift)
|
Rating::new(skill.forward, r.beta, r.drift)
|
||||||
} else {
|
} else {
|
||||||
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
||||||
@@ -78,16 +72,16 @@ impl Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct Team {
|
struct Team {
|
||||||
items: Vec<Item>,
|
items: Vec<Item>,
|
||||||
output: f64,
|
output: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Clone, 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,
|
||||||
}
|
}
|
||||||
@@ -108,7 +102,6 @@ impl Event {
|
|||||||
|
|
||||||
pub(crate) fn within_priors<T: Time, D: Drift<T>>(
|
pub(crate) fn within_priors<T: Time, D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
online: bool,
|
|
||||||
forward: bool,
|
forward: bool,
|
||||||
skills: &SkillStore,
|
skills: &SkillStore,
|
||||||
agents: &CompetitorStore<T, D>,
|
agents: &CompetitorStore<T, D>,
|
||||||
@@ -118,25 +111,26 @@ impl Event {
|
|||||||
.map(|team| {
|
.map(|team| {
|
||||||
team.items
|
team.items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|item| item.within_prior(online, forward, skills, agents))
|
.map(|item| item.within_prior(forward, skills, agents))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
.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, skills, agents);
|
||||||
let result = self.outputs();
|
let result = self.outputs();
|
||||||
let g = match self.kind {
|
let g = match self.kind {
|
||||||
EventKind::Ranked => {
|
EventKind::Ranked => {
|
||||||
@@ -152,17 +146,58 @@ impl Event {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
EventUpdate {
|
||||||
|
log_evidence: g.log_evidence,
|
||||||
|
likelihoods: g.likelihoods,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 (t, team) in self.teams.iter_mut().enumerate() {
|
||||||
for (i, item) in team.items.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 old_likelihood = skills.get(item.agent).unwrap().likelihood;
|
||||||
let new_likelihood = (old_likelihood / item.likelihood) * g.likelihoods[t][i];
|
let new_likelihood = (old_likelihood / item.likelihood) * fresh;
|
||||||
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
||||||
item.likelihood = g.likelihoods[t][i];
|
item.likelihood = fresh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.evidence = g.evidence;
|
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>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One slice's worth of forward-only inference.
|
||||||
|
///
|
||||||
|
/// `posteriors` doubles as the outgoing forward message: the scratch sweep
|
||||||
|
/// never writes `backward`, so it stays `N_INF`, and `Skill::posterior()`
|
||||||
|
/// and `forward_prior_out` are then the same product.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct FilteredStep {
|
||||||
|
pub(crate) log_evidence: f64,
|
||||||
|
pub(crate) posteriors: Vec<(Index, Gaussian)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -174,6 +209,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 +229,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 +242,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 +266,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>>(
|
||||||
@@ -254,8 +305,9 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
*idx,
|
*idx,
|
||||||
Skill {
|
Skill {
|
||||||
forward: agents[*idx].receive(&self.time),
|
forward: agents[*idx].receive(&self.time),
|
||||||
|
backward: N_INF,
|
||||||
|
likelihood: N_INF,
|
||||||
elapsed,
|
elapsed,
|
||||||
..Default::default()
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -296,7 +348,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 +358,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,10 +371,14 @@ 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) {
|
||||||
let teams = event.within_priors(false, false, &self.skills, agents);
|
let teams = event.within_priors(false, &self.skills, agents);
|
||||||
let result = event.outputs();
|
let result = event.outputs();
|
||||||
|
|
||||||
let g = match event.kind {
|
let g = match event.kind {
|
||||||
@@ -353,7 +410,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 +420,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 +446,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
|
ARENA.with(|cell| {
|
||||||
// different memory locations — no data race.
|
let mut arena = cell.borrow_mut();
|
||||||
// 3. `self.events[range]` and `self.skills` are separate struct fields,
|
arena.reset();
|
||||||
// so the borrow splits cleanly.
|
|
||||||
let skills_addr: usize = (&mut self.skills as *mut SkillStore) as usize;
|
ev.compute(skills, agents, p_draw, convergence, &mut arena)
|
||||||
self.events[range].par_iter_mut().for_each(move |ev| {
|
})
|
||||||
// SAFETY: see above.
|
})
|
||||||
let skills: &mut SkillStore = unsafe { &mut *(skills_addr as *mut SkillStore) };
|
.collect();
|
||||||
ARENA.with(|cell| {
|
|
||||||
let mut arena = cell.borrow_mut();
|
for (ev, update) in self.events[range].iter_mut().zip(updates) {
|
||||||
arena.reset();
|
ev.apply(&mut self.skills, update);
|
||||||
ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
for ev in &mut self.events[range] {
|
for ev in &mut self.events[range] {
|
||||||
ev.iteration_direct(
|
ev.iteration_direct(
|
||||||
@@ -454,18 +509,29 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// Iterate this slice alone until its posteriors stop moving, returning
|
||||||
|
/// the number of iterations taken.
|
||||||
|
///
|
||||||
|
/// Used by `filtered_step` to drive a scratch copy of the slice, and by
|
||||||
|
/// tests. Production convergence across slices is driven by
|
||||||
|
/// `History::converge`, which calls `iteration` directly.
|
||||||
|
///
|
||||||
|
/// Honours `self.convergence`; it previously hard-coded an epsilon and a
|
||||||
|
/// 20-iteration cap that matched neither `ConvergenceOptions` nor the
|
||||||
|
/// schedule default.
|
||||||
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 +543,10 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
||||||
|
if !crate::step_is_finite(step) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
i
|
i
|
||||||
@@ -516,53 +586,121 @@ impl<T: Time> TimeSlice<T> {
|
|||||||
self.iteration(0, agents);
|
self.iteration(0, agents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run this slice's events on forward (filtering) information alone.
|
||||||
|
///
|
||||||
|
/// `incoming` holds each competitor's forward message out of their
|
||||||
|
/// previous appearance; a competitor absent from it starts at their
|
||||||
|
/// configured prior. The sweep runs on a scratch copy, so the real slice
|
||||||
|
/// is untouched — which is what makes the filtered estimates independent
|
||||||
|
/// of whether `History::converge` has run.
|
||||||
|
pub(crate) fn filtered_step<D: Drift<T>>(
|
||||||
|
&self,
|
||||||
|
incoming: &HashMap<Index, Gaussian>,
|
||||||
|
agents: &CompetitorStore<T, D>,
|
||||||
|
) -> FilteredStep {
|
||||||
|
let mut scratch = TimeSlice {
|
||||||
|
events: self.events.clone(),
|
||||||
|
skills: SkillStore::new(),
|
||||||
|
time: self.time,
|
||||||
|
p_draw: self.p_draw,
|
||||||
|
convergence: self.convergence,
|
||||||
|
arena: ScratchArena::new(),
|
||||||
|
color_groups: ColorGroups::new(),
|
||||||
|
color_groups_dirty: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
for event in &mut scratch.events {
|
||||||
|
for team in &mut event.teams {
|
||||||
|
for item in &mut team.items {
|
||||||
|
item.likelihood = N_INF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.log_evidence = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (agent, skill) in self.skills.iter() {
|
||||||
|
let rating = &agents[agent].rating;
|
||||||
|
|
||||||
|
let forward = match incoming.get(&agent) {
|
||||||
|
Some(message) => message.forget(rating.drift.variance_for_elapsed(skill.elapsed)),
|
||||||
|
None => rating.prior,
|
||||||
|
};
|
||||||
|
|
||||||
|
scratch.skills.insert(
|
||||||
|
agent,
|
||||||
|
Skill {
|
||||||
|
forward,
|
||||||
|
backward: N_INF,
|
||||||
|
likelihood: N_INF,
|
||||||
|
elapsed: skill.elapsed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
scratch.iterate_to_convergence(agents);
|
||||||
|
|
||||||
|
FilteredStep {
|
||||||
|
log_evidence: scratch.events.iter().map(|event| event.log_evidence).sum(),
|
||||||
|
posteriors: scratch
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.map(|(agent, skill)| (agent, skill.posterior()))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn log_evidence<D: Drift<T>>(
|
pub(crate) fn log_evidence<D: Drift<T>>(
|
||||||
&self,
|
&self,
|
||||||
online: bool,
|
|
||||||
targets: &[Index],
|
targets: &[Index],
|
||||||
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();
|
||||||
|
|
||||||
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
||||||
let teams = event.within_priors(online, forward, &self.skills, agents);
|
let teams = event.within_priors(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 => {
|
||||||
teams,
|
Game::ranked_with_arena(
|
||||||
&result,
|
teams,
|
||||||
&event.weights,
|
&result,
|
||||||
self.p_draw,
|
&event.weights,
|
||||||
self.convergence,
|
self.p_draw,
|
||||||
arena,
|
self.convergence,
|
||||||
)
|
arena,
|
||||||
.evidence
|
)
|
||||||
.ln(),
|
.log_evidence
|
||||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
}
|
||||||
teams,
|
EventKind::Scored { score_sigma } => {
|
||||||
&result,
|
Game::scored_with_arena(
|
||||||
&event.weights,
|
teams,
|
||||||
score_sigma,
|
&result,
|
||||||
self.convergence,
|
&event.weights,
|
||||||
arena,
|
score_sigma,
|
||||||
)
|
self.convergence,
|
||||||
.evidence
|
arena,
|
||||||
.ln(),
|
)
|
||||||
|
.log_evidence
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
if online || forward {
|
if forward {
|
||||||
self.events
|
self.events
|
||||||
.iter()
|
.iter()
|
||||||
.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 forward {
|
||||||
self.events
|
self.events
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| {
|
.filter(|event| {
|
||||||
@@ -570,7 +708,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 +720,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 +1004,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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! 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,
|
||||||
|
NullObserver, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #27's exact reproduction: a non-default key type reaching `converge`
|
||||||
|
/// with no events at all. The underflow it reported trapped in debug and
|
||||||
|
/// indexed out of bounds in release, so this must run in both profiles.
|
||||||
|
#[test]
|
||||||
|
fn converge_on_an_empty_history_with_owned_keys() {
|
||||||
|
let mut history: History<i64, ConstantDrift, NullObserver, String> =
|
||||||
|
History::builder_with_key().score_sigma(5.0).build();
|
||||||
|
|
||||||
|
let report = history.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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_history_has_no_filtered_estimates() {
|
||||||
|
let history: History = History::builder().build();
|
||||||
|
|
||||||
|
assert_eq!(history.filtered_log_evidence(), 0.0);
|
||||||
|
|
||||||
|
assert!(history.filtered_learning_curves().is_empty());
|
||||||
|
|
||||||
|
assert!(history.filtered_learning_curve("nobody").is_empty());
|
||||||
|
}
|
||||||
+5
-11
@@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! Forward-only (filtering) estimates: what the model knew at the time,
|
||||||
|
//! as opposed to the smoothed posteriors `learning_curve` reports.
|
||||||
|
|
||||||
|
use smallvec::smallvec;
|
||||||
|
use trueskill_tt::{ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||||
|
|
||||||
|
/// `games` one-on-one matches at successive times, won by "a" every time,
|
||||||
|
/// built with the given convergence options.
|
||||||
|
fn repeated_winner_with(games: i64, convergence: ConvergenceOptions) -> History {
|
||||||
|
let mut history = History::builder().convergence(convergence).build();
|
||||||
|
|
||||||
|
for time in 1..=games {
|
||||||
|
history
|
||||||
|
.add_events([Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
history
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `games` one-on-one matches at successive times, won by "a" every time.
|
||||||
|
///
|
||||||
|
/// This is the fixture from issue #19, where `online(true)` reported
|
||||||
|
/// `games * ln(0.5)`.
|
||||||
|
fn repeated_winner(games: i64) -> History {
|
||||||
|
repeated_winner_with(games, ConvergenceOptions::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default 30-iteration cap leaves a residual around 1e-6, which would
|
||||||
|
/// swamp these comparisons. Drive both sides well past the fixed point.
|
||||||
|
fn tight() -> ConvergenceOptions {
|
||||||
|
ConvergenceOptions {
|
||||||
|
max_iter: 2_000,
|
||||||
|
epsilon: 1e-12,
|
||||||
|
..ConvergenceOptions::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_evidence_sits_between_coin_flip_and_batch() {
|
||||||
|
let mut history = repeated_winner(5);
|
||||||
|
|
||||||
|
history.converge().unwrap();
|
||||||
|
|
||||||
|
let coin_flip = 5.0 * 0.5f64.ln();
|
||||||
|
let batch = history.log_evidence();
|
||||||
|
let filtered = history.filtered_log_evidence();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
filtered > coin_flip,
|
||||||
|
"filtered evidence {filtered} is at or below {coin_flip}, the all-coin-flip \
|
||||||
|
value the inert online flag reported; game one is a coin flip but games two \
|
||||||
|
through five are not"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
filtered < batch,
|
||||||
|
"filtered evidence {filtered} is not below the smoothed {batch}; filtering \
|
||||||
|
scores each game on strictly less information than smoothing does"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_first_point_is_less_certain_than_smoothed() {
|
||||||
|
let mut history = repeated_winner(12);
|
||||||
|
|
||||||
|
history.converge().unwrap();
|
||||||
|
|
||||||
|
let smoothed = history.learning_curve("a");
|
||||||
|
let filtered = history.filtered_learning_curve("a");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
smoothed.len(),
|
||||||
|
filtered.len(),
|
||||||
|
"both curves must cover the same time points"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (smoothed_time, first_smoothed) = smoothed[0];
|
||||||
|
let (filtered_time, first_filtered) = filtered[0];
|
||||||
|
|
||||||
|
assert_eq!(smoothed_time, filtered_time);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
first_filtered.sigma() > first_smoothed.sigma(),
|
||||||
|
"filtered sigma {} at the first point is not above smoothed {}; the smoother \
|
||||||
|
collapses uncertainty before the first round is drawn, which is the whole \
|
||||||
|
reason this method exists",
|
||||||
|
first_filtered.sigma(),
|
||||||
|
first_smoothed.sigma()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
first_filtered.sigma() < trueskill_tt::SIGMA,
|
||||||
|
"filtered sigma {} at the first point is not below the prior {}; one game was \
|
||||||
|
played, so some uncertainty must have been resolved",
|
||||||
|
first_filtered.sigma(),
|
||||||
|
trueskill_tt::SIGMA
|
||||||
|
);
|
||||||
|
|
||||||
|
for pair in filtered.windows(2) {
|
||||||
|
assert!(
|
||||||
|
pair[1].1.mu() > pair[0].1.mu(),
|
||||||
|
"filtered mu must climb at every step for a competitor who wins every \
|
||||||
|
game: t={} mu={} then t={} mu={}",
|
||||||
|
pair[0].0,
|
||||||
|
pair[0].1.mu(),
|
||||||
|
pair[1].0,
|
||||||
|
pair[1].1.mu()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_curves_plural_agrees_with_singular() {
|
||||||
|
let mut history = repeated_winner(4);
|
||||||
|
|
||||||
|
history.converge().unwrap();
|
||||||
|
|
||||||
|
let curves = history.filtered_learning_curves();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
curves["b"],
|
||||||
|
history.filtered_learning_curve("b"),
|
||||||
|
"the plural form must agree with the singular for the same key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_evidence_is_invariant_to_convergence() {
|
||||||
|
let mut history = repeated_winner_with(6, tight());
|
||||||
|
|
||||||
|
let before = history.filtered_log_evidence();
|
||||||
|
|
||||||
|
let report = history.converge().unwrap();
|
||||||
|
assert!(
|
||||||
|
report.converged,
|
||||||
|
"fixture must converge: {:?}",
|
||||||
|
report.final_step
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = history.filtered_log_evidence();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(before - after).abs() < 1e-8,
|
||||||
|
"filtered evidence moved across converge(): {before} -> {after}. The pass must \
|
||||||
|
carry its own forward messages; anything reading skill.forward shows exactly \
|
||||||
|
this drift, because converge() contaminates it with backward information."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_slice_filtered_matches_smoothed() {
|
||||||
|
let mut history = History::builder().convergence(tight()).build();
|
||||||
|
|
||||||
|
history
|
||||||
|
.add_events([
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("a")]),
|
||||||
|
Team::with_members([Member::new("b")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
Event {
|
||||||
|
time: 1,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new("c")]),
|
||||||
|
Team::with_members([Member::new("d")]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
history.converge().unwrap();
|
||||||
|
|
||||||
|
let smoothed = history.learning_curve("a");
|
||||||
|
let filtered = history.filtered_learning_curve("a");
|
||||||
|
|
||||||
|
assert_eq!(smoothed.len(), 1);
|
||||||
|
assert_eq!(filtered.len(), 1);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(smoothed[0].1.mu() - filtered[0].1.mu()).abs() < 1e-8
|
||||||
|
&& (smoothed[0].1.sigma() - filtered[0].1.sigma()).abs() < 1e-8,
|
||||||
|
"one slice has no future to propagate back, so filtered and smoothed must \
|
||||||
|
agree: smoothed mu={} sigma={}, filtered mu={} sigma={}",
|
||||||
|
smoothed[0].1.mu(),
|
||||||
|
smoothed[0].1.sigma(),
|
||||||
|
filtered[0].1.mu(),
|
||||||
|
filtered[0].1.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_curves_do_not_depend_on_ingestion_order() {
|
||||||
|
let events = |time: i64, winner: &'static str, loser: &'static str| Event {
|
||||||
|
time,
|
||||||
|
teams: smallvec![
|
||||||
|
Team::with_members([Member::new(winner)]),
|
||||||
|
Team::with_members([Member::new(loser)]),
|
||||||
|
],
|
||||||
|
outcome: Outcome::winner(0, 2),
|
||||||
|
};
|
||||||
|
|
||||||
|
let all = vec![
|
||||||
|
events(1, "a", "b"),
|
||||||
|
events(1, "c", "d"),
|
||||||
|
events(1, "a", "c"),
|
||||||
|
events(1, "b", "d"),
|
||||||
|
events(2, "a", "d"),
|
||||||
|
events(2, "b", "c"),
|
||||||
|
events(2, "a", "b"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut batched = History::builder().convergence(tight()).build();
|
||||||
|
batched.add_events(all.clone()).unwrap();
|
||||||
|
batched.converge().unwrap();
|
||||||
|
|
||||||
|
let mut incremental = History::builder().convergence(tight()).build();
|
||||||
|
for event in all {
|
||||||
|
incremental.add_events([event]).unwrap();
|
||||||
|
}
|
||||||
|
incremental.converge().unwrap();
|
||||||
|
|
||||||
|
let from_batched = batched.filtered_learning_curve("a");
|
||||||
|
let from_incremental = incremental.filtered_learning_curve("a");
|
||||||
|
|
||||||
|
assert_eq!(from_batched.len(), from_incremental.len());
|
||||||
|
|
||||||
|
for ((time_b, gaussian_b), (time_i, gaussian_i)) in
|
||||||
|
from_batched.iter().zip(from_incremental.iter())
|
||||||
|
{
|
||||||
|
assert_eq!(time_b, time_i);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(gaussian_b.mu() - gaussian_i.mu()).abs() < 1e-8
|
||||||
|
&& (gaussian_b.sigma() - gaussian_i.sigma()).abs() < 1e-8,
|
||||||
|
"at t={time_b}: batched mu={} sigma={}, incremental mu={} sigma={}",
|
||||||
|
gaussian_b.mu(),
|
||||||
|
gaussian_b.sigma(),
|
||||||
|
gaussian_i.mu(),
|
||||||
|
gaussian_i.sigma()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user