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 | ||
|
|
2b5d3b1687 | ||
|
|
e4ff46f45c | ||
|
|
7742b2b891 | ||
|
|
52482eea5f | ||
|
|
b46e7f068d | ||
|
|
d1d6b5136c | ||
|
|
46625d247a | ||
|
|
68be7ab5b7 | ||
|
|
824b7f50b0 | ||
|
|
872f91797d | ||
|
|
6e453b6845 | ||
|
|
965ea7ed3c | ||
|
|
dbce69f350 | ||
|
|
0705986929 | ||
|
|
aacaa60baa | ||
|
|
fcfe0ffe37 | ||
|
|
0fa4e7d277 | ||
|
|
0dd7dab266 | ||
|
|
43cc6d82f9 | ||
|
|
48a6049dc6 | ||
|
|
1445c08896 | ||
|
|
f6a83e4dc6 | ||
|
|
68b589b965 | ||
|
|
7481c31ad8 | ||
|
|
a69a3004b2 | ||
|
|
dbaad0e7d2 |
@@ -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
|
||||
+95
-128
@@ -2,149 +2,114 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## Unreleased — T3 concurrency
|
||||
## 0.2.0 - 2026-08-27
|
||||
|
||||
Adds rayon-backed parallel paths per Section 6 of
|
||||
`docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`.
|
||||
### Breaking Changes
|
||||
|
||||
### Breaking
|
||||
- refactor!: remove the inert online flag
|
||||
|
||||
- `Send + Sync` bounds added to public traits: `Time`, `Drift<T>`,
|
||||
`Observer<T>`, `Factor`, `Schedule`. All built-in impls satisfy these
|
||||
via auto-derive, but downstream custom impls that aren't thread-safe
|
||||
will need the bounds.
|
||||
### Bug Fixes
|
||||
|
||||
### New
|
||||
- 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
|
||||
|
||||
- Opt-in `rayon` cargo feature. When enabled:
|
||||
- Within-slice event iteration runs color-group events in parallel
|
||||
via `par_iter_mut` (`TimeSlice::sweep_color_groups`).
|
||||
- `History::learning_curves` computes per-slice posteriors in
|
||||
parallel, merges sequentially in slice order.
|
||||
- `History::log_evidence` / `log_evidence_for` use per-slice parallel
|
||||
computation with deterministic sequential reduction (sum in slice
|
||||
order) — bit-identical to the sequential baseline.
|
||||
- `ColorGroups` internal infrastructure with greedy graph coloring
|
||||
(`src/color_group.rs`). Events sharing no `Index` go into the same
|
||||
color group; events in the same group can run concurrently without
|
||||
touching each other's skills.
|
||||
- `tests/determinism.rs` asserts bit-identical posteriors across
|
||||
`RAYON_NUM_THREADS={1, 2, 4, 8}`.
|
||||
- `benches/history_converge.rs` measures end-to-end convergence on
|
||||
three workload shapes.
|
||||
### Documentation
|
||||
|
||||
### Performance notes
|
||||
- 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
|
||||
|
||||
- Default build (no rayon): `Batch::iteration` 23.23 µs — no regression
|
||||
vs T2.
|
||||
- With `--features rayon`:
|
||||
- 500 events / 100 competitors / 10 per slice: 1.0× speedup.
|
||||
- 2000 events / 200 competitors / 20 per slice: 1.0× speedup.
|
||||
- 5000 events in one slice / 50k competitors: **1.3× speedup.**
|
||||
- The spec targeted >2× speedup on 8-core offline converge. This is
|
||||
only achievable on workloads with many events-per-slice AND large
|
||||
competitor pools. **Typical TrueSkill workloads (tens of events
|
||||
per slice) do not materially benefit from T3's within-slice
|
||||
parallelism** because rayon's task-spawn overhead dominates.
|
||||
- Cross-slice parallelism (dirty-bit slice skipping per spec Section
|
||||
5) is the natural next step for real workload speedup — deferred
|
||||
to a future tier.
|
||||
### Features
|
||||
|
||||
### Internals
|
||||
- feat: add filtered_log_evidence
|
||||
- feat: add filtered learning curves
|
||||
|
||||
- The parallel path uses an `unsafe` block to concurrently write to
|
||||
`SkillStore` from color-group-disjoint events. Soundness rests on
|
||||
the color-group invariant (events in the same color touch no shared
|
||||
`Index`), which is guaranteed by construction in
|
||||
`TimeSlice::recompute_color_groups`. Sequential path unchanged.
|
||||
- `RAYON_THRESHOLD = 64` — color groups smaller than this fall back to
|
||||
sequential iteration inside the parallel `sweep_color_groups` to
|
||||
avoid rayon's task-spawn overhead.
|
||||
- Thread-local `ScratchArena` per rayon worker thread.
|
||||
### Miscellaneous Tasks
|
||||
|
||||
## Unreleased — T2 new API surface
|
||||
|
||||
Breaking: every renamed type and the new public API land together per
|
||||
`docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`
|
||||
Section 7 "T2".
|
||||
|
||||
### Breaking renames
|
||||
|
||||
- `Batch` → `TimeSlice`
|
||||
- `Player` → `Rating` (and the `.player` field on `Competitor` is now `.rating`)
|
||||
- `Agent` → `Competitor`
|
||||
- `IndexMap` → `KeyTable`
|
||||
- `History` field `.batches` → `.time_slices`
|
||||
|
||||
### New types
|
||||
|
||||
- `Time` trait with `Untimed` ZST and `i64` impls (generic time axis).
|
||||
- `Drift<T: Time>` — generified from the old `Drift` trait.
|
||||
- `Event<T, K>`, `Team<K>`, `Member<K>` — typed bulk-ingest event shape.
|
||||
- `Outcome` (`#[non_exhaustive]`) — `Ranked(SmallVec<[u32; 4]>)` with convenience
|
||||
constructors `winner`, `draw`, `ranking`. `Scored` lands in T4.
|
||||
- `Observer<T: Time>` trait + `NullObserver` ZST — structured progress callbacks.
|
||||
- `ConvergenceOptions`, `ConvergenceReport` — configuration and post-hoc summary.
|
||||
- `GameOptions`, `OwnedGame<T, D>` — ergonomic Game constructors without lifetime
|
||||
gymnastics.
|
||||
- `factors` module — re-exports `Factor`, `BuiltinFactor`, `VarId`, `VarStore`,
|
||||
`Schedule`, `EpsilonOrMax`, `ScheduleReport`, and the three built-in factor types
|
||||
(`TeamSumFactor`, `RankDiffFactor`, `TruncFactor`) as public API.
|
||||
|
||||
### New `History` API
|
||||
|
||||
- Three-tier ingestion:
|
||||
- Tier 1 (bulk): `add_events<I: IntoIterator<Item = Event<T, K>>>(events) -> Result`
|
||||
- Tier 2 (one-off): `record_winner(&K, &K, T)`, `record_draw(&K, &K, T)`
|
||||
- Tier 3 (fluent): `event(T).team([...]).weights([...]).ranking([...]).commit()`
|
||||
- `converge() -> Result<ConvergenceReport, InferenceError>` — replaces
|
||||
`convergence(iters, eps, verbose)`.
|
||||
- `current_skill(&K)`, `learning_curve(&K)`, `learning_curves()` (now keyed on `K`).
|
||||
- `log_evidence()` zero-arg, `log_evidence_for(&[&K])`.
|
||||
- `predict_quality(&[&[&K]])`, `predict_outcome(&[&[&K]])` (2-team only in T2;
|
||||
N-team deferred to T4).
|
||||
- `intern(&Q)` / `lookup(&Q)` expose the internal `KeyTable<K>` for power users.
|
||||
- `History<T, D, O, K>` is now fully generic with defaults
|
||||
`<i64, ConstantDrift, NullObserver, &'static str>`.
|
||||
|
||||
### New `Game` API
|
||||
|
||||
- `Game::ranked(&[&[Rating]], Outcome, &GameOptions) -> Result<OwnedGame, _>`.
|
||||
- `Game::one_v_one(&Rating, &Rating, Outcome) -> Result<(Gaussian, Gaussian), _>`.
|
||||
- `Game::free_for_all(&[&Rating], Outcome, &GameOptions) -> Result<OwnedGame, _>`.
|
||||
- `Game::custom(...)` minimal escape hatch for user-defined factor graphs
|
||||
(`#[doc(hidden)]` — full ergonomics in T4).
|
||||
- `Game::log_evidence()` and `OwnedGame::log_evidence()` accessors.
|
||||
|
||||
### Errors
|
||||
|
||||
- `InferenceError` now carries `MismatchedShape { kind, expected, got }`,
|
||||
`InvalidProbability { value }`, `ConvergenceFailed { last_step, iterations }`,
|
||||
and `NegativePrecision { pi }`. Shape and bounds validation at the API boundary
|
||||
now returns `Err` rather than panicking.
|
||||
|
||||
### Removed (breaking)
|
||||
|
||||
- `History::convergence(iters, eps, verbose)` — use `converge()`.
|
||||
- `HistoryBuilder::gamma(f64)` — use `.drift(ConstantDrift(g))`.
|
||||
- `HistoryBuilder::time(bool)` and `History.time: bool` — use the `Time` type parameter.
|
||||
- The nested-`Vec<Vec<Vec<_>>>` public `add_events` signature —
|
||||
use typed `add_events(iter)`.
|
||||
- `learning_curves_by_index()` — use `learning_curves()`.
|
||||
- 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
|
||||
|
||||
`Batch::iteration` bench: **21.36 µs** (T1 was 22.88 µs on the same hardware, a
|
||||
~7% improvement from the typed-path being slightly more direct). Gaussian
|
||||
operations unchanged.
|
||||
- perf(gaussian): drop the sqrt round-trip from variance-space operations
|
||||
|
||||
### Notes
|
||||
### Refactor
|
||||
|
||||
- `Time = Untimed` returns `elapsed_to → 0` — **behavior change** from the old
|
||||
`time=false` mode, which implicitly generated `elapsed=1` per event via an
|
||||
`i64::MAX` sentinel in `Agent.last_time`. Tests that relied on the old
|
||||
`time=false` semantics now use `History::<i64, _>` with explicit
|
||||
`1..=n` timestamps.
|
||||
- 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
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: release generated CHANGELOG at the wrong location
|
||||
- fix(gaussian): treat non-positive precision as improper in mu()/sigma()
|
||||
|
||||
### Documentation
|
||||
|
||||
- docs: spec for post-T4-MarginFactor tech debt cleanup
|
||||
- docs: implementation plan for post-T4-MarginFactor tech debt cleanup
|
||||
- docs: fix stale numerics in t4-margin-factor plan
|
||||
- docs: spec for game-local Damped EP
|
||||
- docs: implementation plan for game-local Damped EP
|
||||
- docs: spec for History → TimeSlice ConvergenceOptions plumbing
|
||||
- docs: implementation plan for History → TimeSlice plumbing
|
||||
- docs: spec for per-event score_sigma override
|
||||
- docs: implementation plan for per-event score_sigma override
|
||||
|
||||
### Features
|
||||
|
||||
- feat(gaussian): add damp_natural helper for EP damping
|
||||
- feat(convergence): add ConvergenceOptions::alpha damping field
|
||||
- feat(factor): add TruncFactor::propagate_with_alpha for EP damping
|
||||
- feat(factor): add MarginFactor::propagate_with_alpha for EP damping
|
||||
- feat(game): plumb ConvergenceOptions through to run_chain
|
||||
- feat(time_slice): inference callsites read self.convergence
|
||||
- feat(outcome): per-event score_sigma override on Outcome::Scored
|
||||
- feat(event_builder): expose scores_with_sigma fluent method
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- chore: Release trueskill-tt version 0.1.2
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
|
||||
- refactor: make BuiltinFactor::log_evidence match exhaustive
|
||||
- refactor(time_slice): add convergence field, rename iterate_to_convergence
|
||||
|
||||
### Testing
|
||||
|
||||
- test(game): integration tests for ConvergenceOptions behavior
|
||||
- test(history): end-to-end ConvergenceOptions propagation tests
|
||||
- test(history): end-to-end per-event score_sigma override tests
|
||||
|
||||
## 0.1.1 - 2026-04-27
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- chore: Release trueskill-tt version 0.1.1
|
||||
|
||||
### Other (unconventional)
|
||||
|
||||
- T0 + T1 + T2: engine redesign through new API surface (#1)
|
||||
- T3: rayon-backed concurrency (opt-in) (#2)
|
||||
- T4 (MarginFactor): scored outcomes via Gaussian-margin EP evidence
|
||||
|
||||
## 0.1.0 - 2026-04-23
|
||||
|
||||
@@ -156,6 +121,8 @@ operations unchanged.
|
||||
|
||||
- chore: added cliff.toml, release.toml and rustfmt.toml
|
||||
- chore: clean up
|
||||
- chore: make cargo release add CHANGELOG.md before commit
|
||||
- chore: do not publish
|
||||
|
||||
### Other (unconventional)
|
||||
|
||||
|
||||
@@ -5,42 +5,96 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cargo build # Build the library
|
||||
cargo test --lib # Run all library tests
|
||||
cargo test --lib <test_name> # Run a single test by name
|
||||
cargo test --lib -- --nocapture # Run tests with stdout output
|
||||
cargo clippy # Lint
|
||||
cargo bench # Run benchmarks (criterion)
|
||||
just test # Full suite across every feature combination CI checks
|
||||
just check # Fast inner loop: cargo test --features approx
|
||||
just lint # clippy, warnings denied
|
||||
just fmt # ALWAYS nightly — rustfmt.toml uses nightly-only options
|
||||
just determinism # Bit-identical posteriors at RAYON_NUM_THREADS 1/2/4/8
|
||||
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`:
|
||||
```bash
|
||||
cargo test --features approx
|
||||
```
|
||||
**Run tests in release too.** `debug_assert!` is compiled out there, and that
|
||||
is where several defects have hidden — a debug-only run is not evidence.
|
||||
`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
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
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()`.
|
||||
- **`Batch`** (`batch.rs`) — all games at a single time step. Runs `iteration()` to update skill estimates via `Game::posteriors()`, collecting `Skill` distributions per player.
|
||||
- **`Game`** (`game.rs`) — a single match. Given teams (slices of `Gaussian`), computes posterior skill distributions using Gaussian factor graphs and `message.rs` helpers.
|
||||
- **`Agent`** (`agent.rs`) — wraps a `Player` with temporal state (`last_time`, `message`). `receive()` applies time-decay (`gamma`) when the player reappears after a gap.
|
||||
- **`Player`** (`player.rs`) — static configuration: prior `Gaussian`, `beta` (performance noise), `gamma` (skill drift per time unit).
|
||||
- **`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.
|
||||
- **`message.rs`** — `TeamMessage` and `DiffMessage`: intermediate factor graph messages used inside `Game`.
|
||||
- **`MarginFactor`** (`factor/margin.rs`) — Gaussian observation factor on a diff variable; engaged by `Outcome::Scored`.
|
||||
- **`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`.
|
||||
- **`History`** (`history.rs`) — top level. Interns keys, groups events into
|
||||
`TimeSlice`s by time, runs the forward/backward sweep in `converge()`, and
|
||||
answers `learning_curves()`, `current_skill()`, `log_evidence()`,
|
||||
`predict_quality()`, `predict_outcome()`. Built via `HistoryBuilder`.
|
||||
- **`TimeSlice`** (`time_slice.rs`) — all events at one time. Owns a
|
||||
`SkillStore` and a `ScratchArena`; `iteration()` sweeps its events, using
|
||||
`ColorGroups` to partition independent ones.
|
||||
- **`Event`** (`time_slice.rs`) — one match. `compute()` runs inference reading
|
||||
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.
|
||||
- Convergence is measured by the maximum `delta()` across all skill distributions; iteration stops when below `EPSILON` or after `ITERATIONS` rounds.
|
||||
- The `approx` feature gates `AbsDiffEq` on `Gaussian` for use in tests — the feature is optional and only needed for approximate equality assertions.
|
||||
- `time` in `History`/`Batch` is currently an `f64`; the README notes it needs to become an enum to support richer temporal states.
|
||||
- **A tie needs `p_draw > 0`.** With `p_draw == 0.0` the truncation margin is
|
||||
zero and the two-sided tie update evaluates `0/0`. Ingestion rejects such
|
||||
events with `InferenceError::TieWithoutDrawProbability`. This includes
|
||||
`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]
|
||||
name = "trueskill-tt"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
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]
|
||||
bench = false
|
||||
@@ -22,6 +45,10 @@ harness = false
|
||||
name = "scored"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "ingest"
|
||||
harness = false
|
||||
|
||||
[dependencies]
|
||||
approx = { version = "0.5.1", optional = true }
|
||||
rayon = { version = "1", optional = true }
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
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:
|
||||
cargo bench -- --save-baseline base
|
||||
@@ -8,3 +43,49 @@ bench:
|
||||
|
||||
flame:
|
||||
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] Add more tests from `TrueSkillThroughTime.jl`
|
||||
- [ ] Add tests for `quality()` (Use [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) as reference)
|
||||
- [ ] Benchmark Batch::iteration()
|
||||
- [ ] Time needs to be an enum so we can have multiple states (see `batch::compute_elapsed()`)
|
||||
- [ ] Add examples (use same TrueSkillThroughTime.(py|jl))
|
||||
- [ ] Add Observer (see [argmin](https://docs.rs/argmin/latest/argmin/core/trait.Observe.html) for inspiration)
|
||||
- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum
|
||||
- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`)
|
||||
- [x] Add Observer (`Observer` / `NullObserver`)
|
||||
- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`)
|
||||
- [ ] 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.
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use trueskill_tt::{
|
||||
BETA, Competitor, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA, TimeSlice,
|
||||
drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
|
||||
BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA,
|
||||
TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
|
||||
};
|
||||
|
||||
fn criterion_benchmark(criterion: &mut Criterion) {
|
||||
@@ -35,7 +35,7 @@ fn criterion_benchmark(criterion: &mut Criterion) {
|
||||
|
||||
let kinds = vec![EventKind::Ranked; composition.len()];
|
||||
|
||||
let mut time_slice = TimeSlice::new(1, P_DRAW);
|
||||
let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
|
||||
time_slice.add_events(composition, results, weights, kinds, &agents);
|
||||
|
||||
criterion.bench_function("Batch::iteration", |b| {
|
||||
|
||||
@@ -51,6 +51,7 @@ fn build_history_1v1(
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
@@ -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.
|
||||
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
||||
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 = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^doc", group = "Documentation" },
|
||||
|
||||
@@ -49,7 +49,7 @@ A Gaussian `N(m, σ)` constructed via `Gaussian::from_ms(m, σ)`. Multiplication
|
||||
**Concrete numerical check for tests:** With cavity `N(0, 6)` and observation `m_obs=5, σ=1`:
|
||||
- `D_cav.pi = 1/36 ≈ 0.027778`, `D_cav.tau = 0`.
|
||||
- New marginal: `pi = 0.027778 + 1 = 1.027778`, `tau = 0 + 5 = 5`. So `mu = 5 / 1.027778 ≈ 4.864865`, `sigma = 1/sqrt(1.027778) ≈ 0.986394`.
|
||||
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.046827`. So `log_evidence ≈ -3.0613`.
|
||||
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.04678`. So `log_evidence ≈ -3.0622`.
|
||||
|
||||
---
|
||||
|
||||
@@ -182,7 +182,7 @@ mod tests {
|
||||
|
||||
f.propagate(&mut vars);
|
||||
let z = f.evidence_cached.unwrap();
|
||||
// pdf(5, 0, sqrt(37)) ≈ 0.046827
|
||||
// pdf(5, 0, sqrt(37)) ≈ 0.04678
|
||||
assert!((z - 0.04682752233851171).abs() < 1e-10);
|
||||
|
||||
// Subsequent propagations don't change it.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,593 @@
|
||||
# History → TimeSlice ConvergenceOptions Plumbing Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Thread `ConvergenceOptions` from `History` through `TimeSlice` to the three `Game::*_with_arena` callsites in `time_slice.rs`, so users who set `HistoryBuilder::convergence(opts)` actually get those options applied to within-game inference (including Damped's `alpha`).
|
||||
|
||||
**Architecture:** `TimeSlice<T>` gains a `convergence: ConvergenceOptions` field set at construction. `History::add_events_with_prior` passes `self.convergence`. The three `Game::*_with_arena` callsites in `time_slice.rs` swap their hardcoded `ConvergenceOptions::default()` for the propagated value. The pre-existing `TimeSlice::convergence` method is renamed to `iterate_to_convergence` to disambiguate from the new field. No new public API on `History` or `HistoryBuilder` — `convergence(opts)` already exists and works.
|
||||
|
||||
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
|
||||
|
||||
---
|
||||
|
||||
## Spec reference
|
||||
|
||||
`docs/superpowers/specs/2026-05-08-history-convergence-plumbing-design.md`
|
||||
|
||||
## Pre-flight context for the implementer
|
||||
|
||||
- `HistoryBuilder::convergence(opts)` already exists at `src/history.rs:91`. `History` already stores `convergence: ConvergenceOptions` at `src/history.rs:166`. `History::converge()` already reads `self.convergence.{epsilon, max_iter}` at `src/history.rs:437-447` for the OUTER cross-history loop.
|
||||
- `TimeSlice<T>` is at `src/time_slice.rs:172-180`. Currently has fields `events`, `skills`, `time`, `p_draw`, `arena`, `color_groups`. No convergence field yet.
|
||||
- `TimeSlice::new(time, p_draw)` at `src/time_slice.rs:183-192` is `pub`. Five test callsites use it with `(0i64, 0.0)`. One production callsite in `History::add_events_with_prior` at `src/history.rs:597` uses `(t, self.p_draw)`.
|
||||
- Three callsites in `time_slice.rs` call `Game::*_with_arena` with hardcoded `crate::ConvergenceOptions::default()`:
|
||||
- `Event::iteration_direct` at `src/time_slice.rs:131-169` — does NOT have `&self` access to a TimeSlice. Currently takes `(skills, agents, p_draw, arena)`. Needs to gain a `convergence` parameter.
|
||||
- `TimeSlice::iteration` at `src/time_slice.rs:322-363` — has `&mut self`, so reads `self.convergence` directly.
|
||||
- `TimeSlice::log_evidence` at `src/time_slice.rs:505-540` — has `&self`, so reads `self.convergence` directly.
|
||||
- The rayon path in `sweep_color_groups` at `src/time_slice.rs:376-423` uses a `move` closure capturing `p_draw` by value. The same pattern applies to `convergence` (it's `Copy`, so captures cleanly).
|
||||
- `TimeSlice::convergence` (the **method** at `src/time_slice.rs:447`) shares its name with the new field. Rust technically allows this (different namespaces), but it's a readability hazard — must be renamed. The method is called from 4 test sites in `time_slice.rs` (lines 693, 755, 817, 851). It is NOT called from `history.rs`.
|
||||
- `ConvergenceOptions` is `Copy + Clone + Debug`. Pass by value everywhere.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Why touched |
|
||||
|---|---|
|
||||
| `src/time_slice.rs` | TimeSlice gains `convergence` field, `new` signature change, rename `convergence` method, three callsites read `self.convergence`, `Event::iteration_direct` gains parameter, rayon closure captures it |
|
||||
| `src/history.rs` | `add_events_with_prior` passes `self.convergence` to `TimeSlice::new`; two integration tests added; alpha doc-comment update happens in `convergence.rs` not here |
|
||||
| `src/convergence.rs` | One-sentence addition to `alpha` doc comment clarifying within-game-only scope |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: TimeSlice gains `convergence` field; signature/rename land atomically
|
||||
|
||||
This task does five things atomically — they cannot land separately because intermediate states won't compile:
|
||||
|
||||
1. Add `pub(crate) convergence: ConvergenceOptions` field to `TimeSlice<T>`.
|
||||
2. Change `TimeSlice::new` signature to take `convergence: ConvergenceOptions` as the third parameter.
|
||||
3. Update the production callsite in `History::add_events_with_prior` (`src/history.rs:597`) to pass `self.convergence`.
|
||||
4. Update the five test callsites in `src/time_slice.rs` (lines 646, 723, 803, 901 — the four with `TimeSlice::new(0i64, 0.0)`, plus the one inside the test module's `iterate_through_color_groups` test if it exists; locate via `grep -n "TimeSlice::new" src/time_slice.rs`).
|
||||
5. Rename the existing `pub(crate) fn convergence` method (at `src/time_slice.rs:447`) to `iterate_to_convergence`. Update its 4 in-file call sites.
|
||||
|
||||
After this task the convergence field is wired but **unused** by inference (Task 2 makes the three Game callsites read it). All existing tests must pass bit-equal because the propagated value still equals `ConvergenceOptions::default()` end-to-end.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/time_slice.rs`
|
||||
- Modify: `src/history.rs:597`
|
||||
|
||||
- [ ] **Step 1: Locate all `TimeSlice::new` and `convergence`-method callsites**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "TimeSlice::new\|\.convergence(" src/time_slice.rs src/history.rs
|
||||
```
|
||||
|
||||
Expected: 1 production callsite of `TimeSlice::new` in `history.rs`, 5 test callsites in `time_slice.rs`, and 4 method-style `.convergence(` calls in `time_slice.rs` test module. (No `.convergence(` calls in `history.rs` — those are field accesses.)
|
||||
|
||||
Save the line numbers — you'll need them in Step 4 and Step 6.
|
||||
|
||||
- [ ] **Step 2: Add the `convergence` field to `TimeSlice<T>`**
|
||||
|
||||
In `src/time_slice.rs`, modify the `TimeSlice<T>` struct (currently at `src/time_slice.rs:172-180`):
|
||||
|
||||
```rust
|
||||
#[derive(Debug)]
|
||||
pub struct TimeSlice<T: Time = i64> {
|
||||
pub(crate) events: Vec<Event>,
|
||||
pub(crate) skills: SkillStore,
|
||||
pub(crate) time: T,
|
||||
p_draw: f64,
|
||||
pub(crate) convergence: crate::ConvergenceOptions,
|
||||
arena: ScratchArena,
|
||||
pub(crate) color_groups: ColorGroups,
|
||||
}
|
||||
```
|
||||
|
||||
Code won't compile until Step 3.
|
||||
|
||||
- [ ] **Step 3: Change `TimeSlice::new` signature**
|
||||
|
||||
In `src/time_slice.rs`, replace the existing `pub fn new` (currently at `src/time_slice.rs:183-192`) with:
|
||||
|
||||
```rust
|
||||
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
|
||||
Self {
|
||||
events: Vec::new(),
|
||||
skills: SkillStore::new(),
|
||||
time,
|
||||
p_draw,
|
||||
convergence,
|
||||
arena: ScratchArena::new(),
|
||||
color_groups: ColorGroups::new(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the production callsite in `history.rs`**
|
||||
|
||||
In `src/history.rs:597`, replace:
|
||||
|
||||
```rust
|
||||
let mut time_slice = TimeSlice::new(t, self.p_draw);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update test callsites of `TimeSlice::new`**
|
||||
|
||||
Run `cargo build --tests` to surface every remaining compile error. Each error is a `TimeSlice::new(time, p_draw)` callsite missing the third argument. The fix: add `crate::ConvergenceOptions::default(),` (inside `src/time_slice.rs` test modules use the path relative to where `ConvergenceOptions` is in scope — if it's not imported in that test mod, add `use crate::ConvergenceOptions;` at the top of the mod and pass `ConvergenceOptions::default()`).
|
||||
|
||||
Example transformation. Before:
|
||||
|
||||
```rust
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0);
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```rust
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
|
||||
```
|
||||
|
||||
Apply to all 5 test callsites identified in Step 1. Repeat `cargo build --tests` until it succeeds.
|
||||
|
||||
- [ ] **Step 6: Rename the `convergence` method to `iterate_to_convergence`**
|
||||
|
||||
In `src/time_slice.rs`, find the method definition at `src/time_slice.rs:447`:
|
||||
|
||||
```rust
|
||||
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
|
||||
```
|
||||
|
||||
Rename to:
|
||||
|
||||
```rust
|
||||
pub(crate) fn iterate_to_convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
|
||||
```
|
||||
|
||||
Then update the 4 call sites (located in Step 1 — `time_slice.rs:693, 755, 817, 851` or wherever your grep found them). At each site, replace `time_slice.convergence(&agents)` with `time_slice.iterate_to_convergence(&agents)`.
|
||||
|
||||
- [ ] **Step 7: Build and run the full test suite**
|
||||
|
||||
Run: `cargo build && cargo test --lib`
|
||||
|
||||
Expected: all 98 lib tests pass. Bit-equal goldens — the convergence field is wired but the three inference callsites still hardcode `ConvergenceOptions::default()` (Task 2 changes that), and the propagated default equals what was hardcoded before, so behavior is identical.
|
||||
|
||||
If any test fails: investigate. The most likely cause is a missed `TimeSlice::new` callsite or a `.convergence(` call site that needs renaming.
|
||||
|
||||
- [ ] **Step 8: Run integration tests**
|
||||
|
||||
Run: `cargo test`
|
||||
|
||||
Expected: all 27 integration tests still pass.
|
||||
|
||||
- [ ] **Step 9: Format and lint**
|
||||
|
||||
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||
|
||||
Expected: no diff, no warnings.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add src/time_slice.rs src/history.rs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(time_slice): add convergence field, rename iterate_to_convergence
|
||||
|
||||
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
|
||||
set at construction. TimeSlice::new now takes it as a third parameter
|
||||
(breaking change to the pub constructor, acceptable in 0.1.x).
|
||||
History::add_events_with_prior passes self.convergence so the propagated
|
||||
value reaches every TimeSlice. The pre-existing convergence-the-method
|
||||
is renamed to iterate_to_convergence to disambiguate from the new
|
||||
convergence-the-field.
|
||||
|
||||
The field is wired but not yet read by inference — the three
|
||||
Game::*_with_arena callsites in time_slice.rs still hardcode
|
||||
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
|
||||
the propagated value equals the hardcoded value end-to-end.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Read `self.convergence` at the three inference callsites
|
||||
|
||||
This task switches the three `Game::*_with_arena` callsites in `time_slice.rs` from hardcoded `ConvergenceOptions::default()` to the propagated `self.convergence` (or for `Event::iteration_direct`, a passed-in parameter). After this task, Damped EP set on `HistoryBuilder` actually reaches the within-game loop.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/time_slice.rs` (only)
|
||||
|
||||
- [ ] **Step 1: Add a `convergence` parameter to `Event::iteration_direct`**
|
||||
|
||||
In `src/time_slice.rs`, modify the existing `iteration_direct` signature (currently at `src/time_slice.rs:131-137`):
|
||||
|
||||
```rust
|
||||
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,
|
||||
) {
|
||||
```
|
||||
|
||||
Inside the body (around `src/time_slice.rs:140-156`), replace both `crate::ConvergenceOptions::default()` arguments with `convergence`:
|
||||
|
||||
```rust
|
||||
let g = match self.kind {
|
||||
EventKind::Ranked => Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&self.weights,
|
||||
p_draw,
|
||||
convergence,
|
||||
arena,
|
||||
),
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&self.weights,
|
||||
score_sigma,
|
||||
convergence,
|
||||
arena,
|
||||
),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the rayon path in `sweep_color_groups` (cfg=rayon)**
|
||||
|
||||
In `src/time_slice.rs`, the rayon-feature `sweep_color_groups` (currently at `src/time_slice.rs:376-423`) captures `p_draw` by value into a `move` closure and calls `ev.iteration_direct(skills, agents, p_draw, &mut arena)`. Capture `convergence` the same way and pass it:
|
||||
|
||||
Above the rayon `for_each` at the line `let p_draw = self.p_draw;`, add:
|
||||
|
||||
```rust
|
||||
let convergence = self.convergence;
|
||||
```
|
||||
|
||||
Then update the call inside the closure (currently `ev.iteration_direct(skills, agents, p_draw, &mut arena);`):
|
||||
|
||||
```rust
|
||||
ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
|
||||
```
|
||||
|
||||
The `else` branch (sequential fallback) at `src/time_slice.rs:417-421` calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` — also update:
|
||||
|
||||
```rust
|
||||
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
|
||||
```
|
||||
|
||||
(Note: this branch reads `self.convergence` directly because no `move` closure is involved here.)
|
||||
|
||||
- [ ] **Step 3: Update the non-rayon path in `sweep_color_groups`**
|
||||
|
||||
In `src/time_slice.rs`, the `#[cfg(not(feature = "rayon"))]` `sweep_color_groups` (currently at `src/time_slice.rs:428-444`) calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` at `src/time_slice.rs:441`. Replace with:
|
||||
|
||||
```rust
|
||||
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `TimeSlice::iteration`'s sequential branch**
|
||||
|
||||
In `src/time_slice.rs`, modify `TimeSlice::iteration` (at `src/time_slice.rs:322-363`). The sequential branch (when `from > 0 || self.color_groups.is_empty()`) has two `Game::*_with_arena` callsites at `src/time_slice.rs:330-346` that hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
|
||||
|
||||
```rust
|
||||
let g = match event.kind {
|
||||
EventKind::Ranked => Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
self.convergence,
|
||||
&mut self.arena,
|
||||
),
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
score_sigma,
|
||||
self.convergence,
|
||||
&mut self.arena,
|
||||
),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `TimeSlice::log_evidence`**
|
||||
|
||||
In `src/time_slice.rs`, modify `TimeSlice::log_evidence` (at `src/time_slice.rs:505-540`). The two `Game::*_with_arena` callsites in the inner `run_event` closure at `src/time_slice.rs:519-538` hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
|
||||
|
||||
```rust
|
||||
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
||||
let teams = event.within_priors(online, forward, &self.skills, agents);
|
||||
let result = event.outputs();
|
||||
match event.kind {
|
||||
EventKind::Ranked => Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
self.convergence,
|
||||
arena,
|
||||
)
|
||||
.evidence
|
||||
.ln(),
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
score_sigma,
|
||||
self.convergence,
|
||||
arena,
|
||||
)
|
||||
.evidence
|
||||
.ln(),
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
(`self.convergence` is `Copy`, so the closure captures it by value naturally without needing a `let` binding outside.)
|
||||
|
||||
- [ ] **Step 6: Build and run the full test suite — bit-equal regression net**
|
||||
|
||||
Run: `cargo build && cargo test --lib`
|
||||
|
||||
Expected: all 98 lib tests still pass. Bit-equal goldens — every existing test uses `History::default()` or `HistoryBuilder::default()` (which sets `convergence = ConvergenceOptions::default()`), so the propagated value equals what the hardcoded default was. No test exercises a non-default convergence through History today, so no behavior changes.
|
||||
|
||||
If any test fails: investigate. The most likely cause is a stale `crate::ConvergenceOptions::default()` call missed in steps 1-5 — re-grep with `grep -n "ConvergenceOptions::default" src/time_slice.rs` to find any remaining hardcoded sites.
|
||||
|
||||
- [ ] **Step 7: Run integration tests**
|
||||
|
||||
Run: `cargo test`
|
||||
|
||||
Expected: all 27 integration tests still pass.
|
||||
|
||||
- [ ] **Step 8: Confirm no `crate::ConvergenceOptions::default()` remains in time_slice.rs**
|
||||
|
||||
Run: `grep -n "ConvergenceOptions::default" src/time_slice.rs`
|
||||
|
||||
Expected: only test-mod hits (in `TimeSlice::new(0i64, 0.0, ConvergenceOptions::default())` callsites from Task 1 step 5). NO production-code hits in `Event::iteration_direct`, `sweep_color_groups`, `TimeSlice::iteration`, or `TimeSlice::log_evidence`.
|
||||
|
||||
- [ ] **Step 9: Format and lint**
|
||||
|
||||
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||
|
||||
Expected: no diff, no warnings.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add src/time_slice.rs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(time_slice): inference callsites read self.convergence
|
||||
|
||||
The three Game::*_with_arena callsites in time_slice.rs (in
|
||||
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
|
||||
run_event closure, and Event::iteration_direct via parameter) now use
|
||||
the propagated ConvergenceOptions instead of hardcoded ::default().
|
||||
sweep_color_groups (both rayon and non-rayon paths) forwards
|
||||
self.convergence into Event::iteration_direct.
|
||||
|
||||
Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
|
||||
HistoryBuilder::convergence(opts) now actually reach the within-game
|
||||
inference loop. Bit-equal for users on default options.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Doc-comment update + end-to-end integration tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/convergence.rs` (alpha doc comment)
|
||||
- Modify: `src/history.rs` (two integration tests in the existing `#[cfg(test)] mod tests` block)
|
||||
|
||||
- [ ] **Step 1: Update `ConvergenceOptions::alpha` doc comment**
|
||||
|
||||
In `src/convergence.rs`, find the existing doc comment on the `alpha` field. Replace it with:
|
||||
|
||||
```rust
|
||||
/// EP damping factor in natural-parameter space: each per-factor
|
||||
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
|
||||
/// undamped (default); `< 1.0` stabilises oscillating fixed-point
|
||||
/// loops at the cost of more iterations. Must be in `(0.0, 1.0]`.
|
||||
///
|
||||
/// Applies only to the within-game EP loop (`run_chain`). The outer
|
||||
/// `History::converge` cross-history sweep is undamped regardless of
|
||||
/// this value — cross-slice damping is a different concept and not
|
||||
/// in scope.
|
||||
pub alpha: f64,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Locate the `#[cfg(test)] mod tests` block in `src/history.rs`**
|
||||
|
||||
Run: `grep -n "#\[cfg(test)\]" src/history.rs`
|
||||
|
||||
Identify the test module (there should be one near the bottom of the file). Read the imports at the top of that module so the new tests can reuse the existing test helpers and scope.
|
||||
|
||||
- [ ] **Step 3: Write the failing tests**
|
||||
|
||||
Add the following two tests at the end of the test module in `src/history.rs` (just before the module's closing `}`):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn history_propagates_convergence_to_inner_run_chain() {
|
||||
use crate::ConvergenceOptions;
|
||||
|
||||
// 4-team ranked game; each event needs more than one inner EP iter
|
||||
// to fully converge.
|
||||
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
|
||||
crate::observer::NullObserver, &'static str>| {
|
||||
for &name in &["a", "b", "c", "d"] {
|
||||
h.new_agent(name);
|
||||
}
|
||||
h.event(0)
|
||||
.team(["a"])
|
||||
.team(["b"])
|
||||
.team(["c"])
|
||||
.team(["d"])
|
||||
.commit()
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
let mut h_capped = crate::History::builder()
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 1,
|
||||
..ConvergenceOptions::default()
|
||||
})
|
||||
.build();
|
||||
events_for(&mut h_capped);
|
||||
h_capped.converge().unwrap();
|
||||
|
||||
let mut h_full = crate::History::builder().build();
|
||||
events_for(&mut h_full);
|
||||
h_full.converge().unwrap();
|
||||
|
||||
let curves_capped = h_capped.learning_curves();
|
||||
let curves_full = h_full.learning_curves();
|
||||
|
||||
let mut max_diff: f64 = 0.0;
|
||||
for (key, capped_pts) in curves_capped.iter() {
|
||||
let full_pts = curves_full.get(key).expect("agent missing in full");
|
||||
for (capped, full) in capped_pts.iter().zip(full_pts.iter()) {
|
||||
max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs());
|
||||
max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
max_diff > 1e-6,
|
||||
"max_iter=1 inner loop should differ from default; max_diff={max_diff}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_with_damping_reaches_same_fixed_point_as_undamped() {
|
||||
use crate::ConvergenceOptions;
|
||||
|
||||
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
|
||||
crate::observer::NullObserver, &'static str>| {
|
||||
for &name in &["a", "b", "c", "d"] {
|
||||
h.new_agent(name);
|
||||
}
|
||||
h.event(0)
|
||||
.team(["a"])
|
||||
.team(["b"])
|
||||
.team(["c"])
|
||||
.team(["d"])
|
||||
.commit()
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
let mut h_undamped = crate::History::builder().build();
|
||||
events_for(&mut h_undamped);
|
||||
h_undamped.converge().unwrap();
|
||||
|
||||
let mut h_damped = crate::History::builder()
|
||||
.convergence(ConvergenceOptions {
|
||||
alpha: 0.5,
|
||||
max_iter: 200,
|
||||
..ConvergenceOptions::default()
|
||||
})
|
||||
.build();
|
||||
events_for(&mut h_damped);
|
||||
h_damped.converge().unwrap();
|
||||
|
||||
let curves_u = h_undamped.learning_curves();
|
||||
let curves_d = h_damped.learning_curves();
|
||||
|
||||
let mut max_diff: f64 = 0.0;
|
||||
for (key, u_pts) in curves_u.iter() {
|
||||
let d_pts = curves_d.get(key).expect("agent missing in damped");
|
||||
for (u, d) in u_pts.iter().zip(d_pts.iter()) {
|
||||
max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs());
|
||||
max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
max_diff < 1e-3,
|
||||
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If the import or method names (e.g. `History::builder()`, `event(...).team(...).commit()`, `learning_curves()`, `new_agent(...)`) don't match what's available in the test module, look at neighboring tests for the exact builder/event-construction pattern in current use and mirror it. The structure (build two Histories, add identical events, compare curves) is the contract; the surface syntax must follow what already works in this test file.
|
||||
|
||||
- [ ] **Step 4: Run the new tests**
|
||||
|
||||
Run: `cargo test --lib history_propagates_convergence_to_inner_run_chain history_with_damping_reaches_same_fixed_point_as_undamped`
|
||||
|
||||
Expected: 2 passed.
|
||||
|
||||
**Fallback if Test 1 fails** (`max_iter=1` produces the same posteriors as default — meaning the inner loop converges in one iteration on this graph): replace `max_iter: 1` with `max_iter: 0`. With `max_iter = 0` the inner loop body runs zero times, guaranteeing different posteriors than convergence.
|
||||
|
||||
**Fallback if Test 2 fails** (`max_diff` exceeds `1e-3`): raise `max_iter: 200` to `max_iter: 500`. Heavier damping needs more iterations to reach the same fixed point.
|
||||
|
||||
If neither fallback works, STOP and report BLOCKED with the actual `max_diff` and the iteration counts tried.
|
||||
|
||||
- [ ] **Step 5: Run the full test suite**
|
||||
|
||||
Run: `cargo test --lib && cargo test`
|
||||
|
||||
Expected: lib count = 100 (was 98), integration count = 27 (unchanged), all passing.
|
||||
|
||||
- [ ] **Step 6: Format and lint**
|
||||
|
||||
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||
|
||||
Expected: no diff, no warnings.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/convergence.rs src/history.rs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test(history): end-to-end ConvergenceOptions propagation tests
|
||||
|
||||
Two integration tests on a 4-team ranked event:
|
||||
- max_iter=1 set on HistoryBuilder produces measurably different
|
||||
posteriors than default, proving the inner loop honors the
|
||||
propagated max_iter
|
||||
- alpha=0.5 with extra iterations reaches the same fixed point as
|
||||
alpha=1.0, proving damping doesn't break correctness on the History
|
||||
path
|
||||
|
||||
Also updates the alpha doc comment to clarify it applies only to the
|
||||
within-game EP loop, not the outer cross-history sweep.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review (writer's note)
|
||||
|
||||
**Spec coverage:**
|
||||
- Spec § "What ships" item 1 (TimeSlice convergence field) → Task 1 step 2 ✓
|
||||
- Spec § "What ships" item 2 (TimeSlice::new signature) → Task 1 step 3 ✓
|
||||
- Spec § "What ships" item 3 (History passes self.convergence) → Task 1 step 4 ✓
|
||||
- Spec § "What ships" item 4 (Event::iteration_direct gains parameter) → Task 2 step 1 ✓
|
||||
- Spec § "What ships" item 4 (callers pass self.convergence) → Task 2 steps 2, 3 ✓
|
||||
- Spec § "What ships" item 5 (TimeSlice::convergence-method reads field) → Task 2 step 4 ✓
|
||||
- Spec § "What ships" item 6 (log_evidence reads field) → Task 2 step 5 ✓
|
||||
- Spec § "What ships" item 7 (test callsite updates) → Task 1 step 5 ✓
|
||||
- Spec § "Design" rename method → Task 1 step 6 ✓
|
||||
- Spec § "Risks" alpha doc-comment update → Task 3 step 1 ✓
|
||||
- Spec § "Testing strategy" §1 (regression net) → Tasks 1 step 7, 2 step 6, 3 step 5 ✓
|
||||
- Spec § "Testing strategy" §2 (history_propagates_convergence) → Task 3 step 3 test 1 ✓
|
||||
- Spec § "Testing strategy" §2 (history_with_damping_reaches_same_fixed_point) → Task 3 step 3 test 2 ✓
|
||||
|
||||
**Out-of-scope items correctly absent:** No new `History`/`HistoryBuilder` methods, no `ConvergenceOptions` split, no `Damped` Schedule impl, no nat-param convergence switch.
|
||||
|
||||
**Type / signature consistency:**
|
||||
- `TimeSlice::new(time, p_draw, convergence: ConvergenceOptions)` — Task 1 step 3 (def) and Task 1 step 4-5 (call sites) match ✓
|
||||
- `iteration_direct(skills, agents, p_draw, convergence, arena)` — Task 2 step 1 (def) and steps 2, 3 (call sites) match ✓
|
||||
- `iterate_to_convergence` — Task 1 step 6 ✓
|
||||
- All `self.convergence` reads are field accesses, not method calls (the rename in Task 1 step 6 prevents ambiguity) ✓
|
||||
|
||||
**Two tasks (1 and 2) split rationale:** Task 1 wires the field but the inference path still uses hardcoded defaults (no behavioral change). Task 2 makes the field actually drive inference (behavioral change for non-default users). Each task is independently committable and the test suite is bit-equal at every checkpoint.
|
||||
|
||||
**No placeholders detected.**
|
||||
@@ -0,0 +1,540 @@
|
||||
# Per-Event `score_sigma` Override Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let users specify a per-event score-sigma override on `Outcome::Scored`, defaulting to `HistoryBuilder::score_sigma` when not set.
|
||||
|
||||
**Architecture:** `Outcome::Scored` becomes a struct variant with an `Option<f64>` `sigma` field. `History::add_events` resolves `sigma.unwrap_or(self.score_sigma)` at ingest time, so downstream `EventKind::Scored.score_sigma` stays a plain `f64` and `TimeSlice` / `run_chain` need zero changes. Two new constructors (`Outcome::scores_with_sigma` and `EventBuilder::scores_with_sigma`) cover the override path; existing `scores(...)` keeps its signature.
|
||||
|
||||
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test`.
|
||||
|
||||
---
|
||||
|
||||
## Spec reference
|
||||
|
||||
`docs/superpowers/specs/2026-05-08-per-event-score-sigma-design.md`
|
||||
|
||||
## File map
|
||||
|
||||
| File | Why touched |
|
||||
|---|---|
|
||||
| `src/outcome.rs` | `Outcome::Scored` variant becomes a struct; pattern matches in `team_count`, `as_scores`, `as_ranks`; new `scores_with_sigma` constructor; existing `scores` constructor body adapts |
|
||||
| `src/history.rs` | The single ingest pattern match at `:735` resolves `sigma.unwrap_or(self.score_sigma)`; three new end-to-end tests |
|
||||
| `src/event_builder.rs` | New `scores_with_sigma` builder method |
|
||||
|
||||
## Pre-flight context for the implementer
|
||||
|
||||
- `Outcome` is `pub`. Currently a tuple-variant enum at `src/outcome.rs:18-21`. Changing `Scored(SmallVec)` → `Scored { scores, sigma }` is a breaking change to a public variant shape, acceptable in 0.1.x.
|
||||
- Pattern-match callsite inventory across the workspace (verified by grep): only ONE site destructures the variant — `src/history.rs:735` (`crate::Outcome::Scored(scores) => { ... }`). Every other reference is either a constructor call (`Outcome::scores(...)`) or a string literal in a doc/error message. The constructors keep their existing signatures, so callsites don't need updating.
|
||||
- `Outcome::scores(I)` constructor at `src/outcome.rs:44`: keep the signature `pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self`. Only the body changes (it now builds `Self::Scored { scores: ..., sigma: None }`).
|
||||
- `as_scores`, `as_ranks`, `team_count` accessors at `src/outcome.rs:48-67`: their public signatures stay the same. Internal pattern matches adapt mechanically.
|
||||
- `EventBuilder::scores(I)` at `src/event_builder.rs:79-82`: keep unchanged. The new `scores_with_sigma(I, f64)` lives next to it.
|
||||
- `History::score_sigma` at `src/history.rs:165`: still the history-wide default. `HistoryBuilder::score_sigma(s)` builder method at `src/history.rs:82-89` stays as-is.
|
||||
- `EventKind::Scored { score_sigma: f64 }` at `src/time_slice.rs:51`: already per-event-shaped. Don't touch.
|
||||
- Test baseline: 100 lib + 27 integration tests, all passing.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `Outcome::Scored` becomes a struct variant + constructors
|
||||
|
||||
This is the foundational shape change. After this task: the new variant compiles, both `scores` and `scores_with_sigma` work on `Outcome` directly, but `History::add_events` (the only consumer that destructures the variant) hasn't yet been updated — Task 2 handles that.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/outcome.rs` (variant shape, three pattern-match arms, two existing tests, three new tests, two constructors)
|
||||
|
||||
- [ ] **Step 1: Write failing tests for the new constructor**
|
||||
|
||||
In `src/outcome.rs`, inside the existing `#[cfg(test)] mod tests` block, add at the end:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn scores_with_sigma_round_trips() {
|
||||
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5);
|
||||
assert_eq!(o.team_count(), 2);
|
||||
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scores_constructor_leaves_sigma_unset() {
|
||||
// After the variant change, the public Outcome::scores constructor
|
||||
// must build with sigma: None. We assert this indirectly via a match
|
||||
// on the variant.
|
||||
let o = Outcome::scores([3.0, 1.0]);
|
||||
match o {
|
||||
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
|
||||
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scores_with_sigma_sets_sigma_some() {
|
||||
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
|
||||
match o {
|
||||
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)),
|
||||
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "score_sigma must be > 0.0")]
|
||||
fn scores_with_sigma_rejects_zero() {
|
||||
let _ = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the new tests to verify they fail**
|
||||
|
||||
Run: `cargo test --lib outcome::tests`
|
||||
|
||||
Expected: 4 errors. The first three fail to compile (no `scores_with_sigma` function; pattern destructure on `Scored { ... }` doesn't match the current tuple variant). The last fails because `scores_with_sigma` doesn't exist.
|
||||
|
||||
- [ ] **Step 3: Change the variant shape and update the constructor + accessors**
|
||||
|
||||
In `src/outcome.rs`, replace the entire `Outcome` enum and `impl Outcome` block (currently `src/outcome.rs:16-68`) with:
|
||||
|
||||
```rust
|
||||
/// Final outcome of a match.
|
||||
///
|
||||
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
|
||||
/// teams. `ranks.len()` must equal the number of teams in the event.
|
||||
///
|
||||
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
|
||||
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
|
||||
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
|
||||
/// when `Some`; `None` inherits the history default.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Outcome {
|
||||
Ranked(SmallVec<[u32; 4]>),
|
||||
Scored {
|
||||
scores: SmallVec<[f64; 4]>,
|
||||
/// Per-event noise override. `None` means inherit
|
||||
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
||||
sigma: Option<f64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
/// `n`-team outcome where team `winner` won and everyone else tied for last.
|
||||
///
|
||||
/// Panics if `winner >= n`.
|
||||
pub fn winner(winner: u32, n: u32) -> Self {
|
||||
assert!(winner < n, "winner index {winner} out of range 0..{n}");
|
||||
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
|
||||
Self::Ranked(ranks)
|
||||
}
|
||||
|
||||
/// All `n` teams tied.
|
||||
pub fn draw(n: u32) -> Self {
|
||||
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
|
||||
}
|
||||
|
||||
/// Explicit per-team ranking.
|
||||
pub fn ranking<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
|
||||
Self::Ranked(ranks.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Explicit per-team continuous scores; higher = better.
|
||||
/// Inherits `HistoryBuilder::score_sigma` for the noise model.
|
||||
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
||||
Self::Scored {
|
||||
scores: scores.into_iter().collect(),
|
||||
sigma: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit per-team continuous scores with a per-event noise override.
|
||||
///
|
||||
/// `sigma` must be `> 0.0`; debug-asserts otherwise.
|
||||
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 {
|
||||
scores: scores.into_iter().collect(),
|
||||
sigma: Some(sigma),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn team_count(&self) -> usize {
|
||||
match self {
|
||||
Self::Ranked(r) => r.len(),
|
||||
Self::Scored { scores, .. } => scores.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
|
||||
match self {
|
||||
Self::Ranked(r) => Some(r),
|
||||
Self::Scored { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
|
||||
match self {
|
||||
Self::Scored { scores, .. } => Some(scores),
|
||||
Self::Ranked(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the new tests**
|
||||
|
||||
Run: `cargo test --lib outcome::tests`
|
||||
|
||||
Expected: all outcome tests pass (the 6 pre-existing tests + 4 new = 10 total in the outcome tests module).
|
||||
|
||||
If any pre-existing test fails, the issue is in this task — not Task 2. Most likely cause: a pattern-match arm in the rewritten `impl Outcome` block doesn't compile. Re-check the struct-variant destructure syntax (`Self::Scored { scores, .. }` for read-only access; `Self::Scored { scores, sigma }` when both fields are needed).
|
||||
|
||||
- [ ] **Step 5: Update `History::add_events` ingest arm to destructure the new variant**
|
||||
|
||||
The variant change from Step 3 breaks the existing `Outcome::Scored(scores)` pattern match in `src/history.rs:735`. Fix it now (in the same commit) — the codebase must build at every commit boundary.
|
||||
|
||||
In `src/history.rs`, find the `crate::Outcome::Scored(scores) => { ... }` arm (currently at `src/history.rs:735-740`). Replace with:
|
||||
|
||||
```rust
|
||||
crate::Outcome::Scored { scores, sigma } => {
|
||||
let resolved = sigma.unwrap_or(self.score_sigma);
|
||||
debug_assert!(
|
||||
resolved > 0.0,
|
||||
"resolved score_sigma must be > 0.0 (got {resolved})"
|
||||
);
|
||||
kinds.push(EventKind::Scored {
|
||||
score_sigma: resolved,
|
||||
});
|
||||
scores.to_vec()
|
||||
}
|
||||
```
|
||||
|
||||
The surrounding `match &ev.outcome { ... }` and the surrounding flow (the `ranks` arm above, the `results.push(event_result);` below) stay unchanged.
|
||||
|
||||
- [ ] **Step 6: Run the full library test suite — bit-equal regression net**
|
||||
|
||||
Run: `cargo build && cargo test --lib && cargo test`
|
||||
|
||||
Expected: clean build. All 100 lib + 27 integration tests pass. Bit-equal goldens — every existing scored-event constructor uses the no-override path (`Outcome::scores(...)` or `EventBuilder::scores(...)`), which now resolves to `sigma: None → resolved = self.score_sigma`, exactly equal to the previous behavior.
|
||||
|
||||
If unexpected additional compile errors surface (any site pattern-matching `Outcome::Scored(...)` outside the 735 arm), STOP and report — the plan's inventory is wrong, surface that as a finding before continuing.
|
||||
|
||||
If any existing test fails: investigate. Most likely cause is a typo in the new pattern arms (Step 3) or the resolution rule (Step 5). The override path isn't exercised yet by any existing test, so the only thing that can break is the inheritance path.
|
||||
|
||||
- [ ] **Step 7: Format and lint**
|
||||
|
||||
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||
|
||||
Expected: no diff, no warnings.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/outcome.rs src/history.rs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(outcome): per-event score_sigma override on Outcome::Scored
|
||||
|
||||
Outcome::Scored shape changes from tuple to struct:
|
||||
{ scores, sigma: Option<f64> }. New constructor scores_with_sigma
|
||||
sets sigma=Some(s) and debug-asserts s > 0.0; existing scores(I)
|
||||
constructor keeps its signature and builds with sigma=None internally.
|
||||
team_count, as_scores, as_ranks accessor pattern matches updated.
|
||||
|
||||
History::add_events resolves sigma.unwrap_or(self.score_sigma) at the
|
||||
ingest arm, so downstream EventKind::Scored stays a plain f64 and
|
||||
TimeSlice / run_chain need zero changes.
|
||||
|
||||
Breaking change to the public Outcome::Scored variant shape
|
||||
(acceptable in 0.1.x). Bit-equal for callers using the no-override
|
||||
path because the resolution falls through to self.score_sigma exactly
|
||||
as before.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `EventBuilder::scores_with_sigma` builder method
|
||||
|
||||
The override path is fully wired by Task 1, but it's only reachable via the `Outcome::scores_with_sigma` constructor (passed into `History::add_events` directly). The fluent-builder ergonomic — `h.event(t).team(...).scores_with_sigma(scores, sigma).commit()` — needs one new method on `EventBuilder`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/event_builder.rs` (new builder method)
|
||||
|
||||
- [ ] **Step 1: Add the EventBuilder method**
|
||||
|
||||
In `src/event_builder.rs`, find the existing `scores` method (currently at `src/event_builder.rs:79-82`). Immediately below it (still inside `impl<'h, T, D, O, K> EventBuilder<...>`), add:
|
||||
|
||||
```rust
|
||||
/// Set explicit per-team continuous scores with a per-event noise override.
|
||||
///
|
||||
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
|
||||
/// Must be `> 0.0`; debug-asserts otherwise via `Outcome::scores_with_sigma`.
|
||||
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
|
||||
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and run the test suite**
|
||||
|
||||
Run: `cargo build && cargo test --lib && cargo test`
|
||||
|
||||
Expected: clean build, all 100 lib + 27 integration tests pass. The new method is additive — no behavior changes for existing tests.
|
||||
|
||||
- [ ] **Step 3: Format and lint**
|
||||
|
||||
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||
|
||||
Expected: no diff, no warnings.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/event_builder.rs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(event_builder): expose scores_with_sigma fluent method
|
||||
|
||||
Adds EventBuilder::scores_with_sigma, the fluent-builder ergonomic
|
||||
mirror of Outcome::scores_with_sigma. Lets users write
|
||||
h.event(t).team(...).team(...).scores_with_sigma([..], sigma).commit()
|
||||
to set a per-event score_sigma override.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: End-to-end integration tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/history.rs` (three new tests in the existing `#[cfg(test)] mod tests` block at the bottom)
|
||||
|
||||
- [ ] **Step 1: Locate the test module**
|
||||
|
||||
Run: `grep -n "^#\[cfg(test)\]" src/history.rs`
|
||||
|
||||
Identify the test module (there should be one near the bottom of the file). Read its imports and look at neighboring tests to see the existing builder/event-construction pattern in current use. Mirror that pattern in the new tests below — the surface syntax (`History::builder()`, `event(t).team(...)`, `learning_curves()`, etc.) must match what already works in this file.
|
||||
|
||||
- [ ] **Step 2: Write the failing tests**
|
||||
|
||||
Add the following three tests at the end of the existing `#[cfg(test)] mod tests` block in `src/history.rs` (just before the module's closing `}`):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn outcome_scores_default_sigma_uses_history_default() {
|
||||
use crate::Outcome;
|
||||
|
||||
// Path A: explicit sigma=0.5 via override.
|
||||
let mut h_a = crate::History::builder().score_sigma(0.5).build();
|
||||
h_a.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores_with_sigma([3.0, 1.0], 0.5),
|
||||
}])
|
||||
.unwrap();
|
||||
h_a.converge().unwrap();
|
||||
|
||||
// Path B: history-wide default 0.5, no per-event override.
|
||||
let mut h_b = crate::History::builder().score_sigma(0.5).build();
|
||||
h_b.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
}])
|
||||
.unwrap();
|
||||
h_b.converge().unwrap();
|
||||
|
||||
// Inheritance: posteriors must be bit-equal.
|
||||
let curves_a = h_a.learning_curves();
|
||||
let curves_b = h_b.learning_curves();
|
||||
for (key, a_pts) in curves_a.iter() {
|
||||
let b_pts = curves_b.get(key).expect("agent missing in path B");
|
||||
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
|
||||
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
|
||||
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_scores_with_sigma_overrides_history_default() {
|
||||
use crate::Outcome;
|
||||
|
||||
// Path A: history-wide default 0.5, per-event override 2.0.
|
||||
let mut h_a = crate::History::builder().score_sigma(0.5).build();
|
||||
h_a.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
|
||||
}])
|
||||
.unwrap();
|
||||
h_a.converge().unwrap();
|
||||
|
||||
// Path B: history-wide default 2.0, no per-event override.
|
||||
let mut h_b = crate::History::builder().score_sigma(2.0).build();
|
||||
h_b.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
}])
|
||||
.unwrap();
|
||||
h_b.converge().unwrap();
|
||||
|
||||
// Override == default-set-to-the-override-value: bit-equal.
|
||||
let curves_a = h_a.learning_curves();
|
||||
let curves_b = h_b.learning_curves();
|
||||
for (key, a_pts) in curves_a.iter() {
|
||||
let b_pts = curves_b.get(key).expect("agent missing in path B");
|
||||
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
|
||||
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
|
||||
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Path C: history-wide default 0.5, no override. Different sigma → different posteriors.
|
||||
let mut h_c = crate::History::builder().score_sigma(0.5).build();
|
||||
h_c.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
}])
|
||||
.unwrap();
|
||||
h_c.converge().unwrap();
|
||||
|
||||
let curves_c = h_c.learning_curves();
|
||||
let mut max_diff: f64 = 0.0;
|
||||
for (key, a_pts) in curves_a.iter() {
|
||||
let c_pts = curves_c.get(key).expect("agent missing in path C");
|
||||
for (a, c) in a_pts.iter().zip(c_pts.iter()) {
|
||||
max_diff = max_diff.max((a.1.mu() - c.1.mu()).abs());
|
||||
max_diff = max_diff.max((a.1.sigma() - c.1.sigma()).abs());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
max_diff > 1e-6,
|
||||
"override should produce different posteriors from inherited default; max_diff={max_diff}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_builder_scores_with_sigma_threading() {
|
||||
use crate::Outcome;
|
||||
|
||||
// Path A: builder fluent API with sigma override.
|
||||
let mut h_a = crate::History::builder().score_sigma(0.5).build();
|
||||
h_a.event(0_i64)
|
||||
.team(["a"])
|
||||
.team(["b"])
|
||||
.scores_with_sigma([3.0, 1.0], 2.0)
|
||||
.commit()
|
||||
.unwrap();
|
||||
h_a.converge().unwrap();
|
||||
|
||||
// Path B: same outcome via the explicit Outcome constructor.
|
||||
let mut h_b = crate::History::builder().score_sigma(0.5).build();
|
||||
h_b.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
|
||||
}])
|
||||
.unwrap();
|
||||
h_b.converge().unwrap();
|
||||
|
||||
let curves_a = h_a.learning_curves();
|
||||
let curves_b = h_b.learning_curves();
|
||||
for (key, a_pts) in curves_a.iter() {
|
||||
let b_pts = curves_b.get(key).expect("agent missing");
|
||||
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
|
||||
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
|
||||
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the surface API (e.g. `History::add_events`, `Event { time, teams, outcome }`, `Team::with_members`, `Member::new`, `event(...).team(...).commit()`, `learning_curves()`) doesn't exactly match what's available in the test module, look at neighboring tests for the patterns currently in use and adjust. The CONTRACT is: build two Histories that should produce identical posteriors, run them, compare. The surface syntax must follow what compiles in this file.
|
||||
|
||||
- [ ] **Step 3: Run the new tests**
|
||||
|
||||
Run: `cargo test --lib outcome_scores_default_sigma_uses_history_default outcome_scores_with_sigma_overrides_history_default event_builder_scores_with_sigma_threading`
|
||||
|
||||
Expected: 3 passed.
|
||||
|
||||
**Fallback if Test 2's `max_diff > 1e-6` fails** (sigma=0.5 vs sigma=2.0 produces nearly identical posteriors — unlikely on a single 2-team scored event, but possible if the priors dominate): use a larger gap, e.g. `Outcome::scores_with_sigma([3.0, 1.0], 5.0)` vs `Outcome::scores([3.0, 1.0])` with `score_sigma(0.5)`. The point is to prove the resolution path actually engages — any sigma gap that produces a measurable posterior difference is fine.
|
||||
|
||||
- [ ] **Step 4: Run the full test suite**
|
||||
|
||||
Run: `cargo test --lib && cargo test`
|
||||
|
||||
Expected: lib count = 103 (was 100, +3), integration count = 27 (unchanged), all passing.
|
||||
|
||||
- [ ] **Step 5: Format and lint**
|
||||
|
||||
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
|
||||
|
||||
Expected: no diff, no warnings.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/history.rs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test(history): end-to-end per-event score_sigma override tests
|
||||
|
||||
Three integration tests on a 2-team scored event:
|
||||
- inheritance: Outcome::scores(...) with no override produces
|
||||
bit-equal posteriors to the same outcome wrapped in
|
||||
scores_with_sigma(scores, history.score_sigma)
|
||||
- override-supersedes-default: scores_with_sigma(scores, X) with
|
||||
history score_sigma(Y) produces bit-equal posteriors to
|
||||
scores(...) with history score_sigma(X), AND differs measurably
|
||||
from scores(...) with history score_sigma(Y)
|
||||
- builder threading: EventBuilder::scores_with_sigma reaches the
|
||||
ingest path identically to the Outcome constructor
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review (writer's note)
|
||||
|
||||
**Spec coverage:**
|
||||
- Spec § "What ships" item 1 (Scored becomes struct variant) → Task 1 step 3 ✓
|
||||
- Spec § "What ships" item 2 (scores_with_sigma constructor) → Task 1 step 3 ✓
|
||||
- Spec § "What ships" item 3 (EventBuilder::scores_with_sigma) → Task 2 step 1 ✓
|
||||
- Spec § "What ships" item 4 (sigma resolution at ingest) → Task 1 step 5 ✓
|
||||
- Spec § "What ships" item 5 (pattern-match update inventory) → Task 1 step 5 (single site at history.rs:735) ✓
|
||||
- Spec § "Validation" (debug_assert at constructor) → Task 1 step 3 (in `scores_with_sigma`) ✓
|
||||
- Spec § "Validation" (debug_assert at ingest) → Task 1 step 5 ✓
|
||||
- Spec § "Testing strategy" §1 (regression net) → Task 1 step 6, Task 2 step 2, Task 3 step 4 ✓
|
||||
- Spec § "Testing strategy" §2 test 1 (default-uses-history-default) → Task 3 step 2 test 1 ✓
|
||||
- Spec § "Testing strategy" §2 test 2 (override-supersedes-default) → Task 3 step 2 test 2 ✓
|
||||
- Spec § "Testing strategy" §2 test 3 (builder threading) → Task 3 step 2 test 3 ✓
|
||||
|
||||
**Out-of-scope items correctly absent:** No `EventKind::Scored` change, no `TimeSlice`/`run_chain` changes, no `Game::scored` standalone API change, no deprecation of `HistoryBuilder::score_sigma`.
|
||||
|
||||
**Type / signature consistency:**
|
||||
- `Outcome::Scored { scores: SmallVec<[f64; 4]>, sigma: Option<f64> }` — Task 1 step 3 (def) and Task 1 step 5 (destructure) match ✓
|
||||
- `Outcome::scores_with_sigma<I>(scores: I, sigma: f64) -> Outcome` — Task 1 step 3 (def) and Task 2 step 1 (call) match ✓
|
||||
- `EventBuilder::scores_with_sigma<I>(mut self, scores: I, sigma: f64) -> Self` — Task 2 step 1 (def) and Task 3 step 2 test 3 (call) match ✓
|
||||
- `sigma.unwrap_or(self.score_sigma)` resolution rule — Task 1 step 5 ✓
|
||||
|
||||
**Task split rationale:** Task 1 lands the foundational shape change AND the ingest resolution atomically — every commit boundary builds and tests pass bit-equal. Task 2 is the small additive EventBuilder method, separated for review-focus reasons (it's the user-facing fluent API exposure). Task 3 is purely additive integration tests. Each task is independently committable; no intermediate non-building state.
|
||||
|
||||
**No placeholders detected.**
|
||||
@@ -0,0 +1,444 @@
|
||||
# Tech Debt Cleanup Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Land three independent post-T4-MarginFactor cleanups: dedupe `Game::likelihoods` and `Game::likelihoods_scored` via a `run_chain` helper, make `BuiltinFactor::log_evidence` exhaustive, and fix stale numerics in the T4 plan doc.
|
||||
|
||||
**Architecture:** Pure code-shape and doc fixes. No public-API change, no behavioral change, no new dependencies. The dedup is a pure refactor — bit-equal posteriors and evidence against existing test goldens. The exhaustive match is a future-proofing change with no runtime effect. The doc fix is two number swaps in prose plus one matching code-comment swap.
|
||||
|
||||
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
|
||||
|
||||
---
|
||||
|
||||
## Spec reference
|
||||
|
||||
`docs/superpowers/specs/2026-05-08-tech-debt-cleanup-design.md`
|
||||
|
||||
## File map
|
||||
|
||||
| File | Why touched |
|
||||
|---|---|
|
||||
| `src/game.rs` | Add `run_chain` helper; rewrite `likelihoods` and `likelihoods_scored` to call it |
|
||||
| `src/factor/mod.rs` | Make `BuiltinFactor::log_evidence` match exhaustive |
|
||||
| `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` | Fix two stale prose numbers and one matching code comment |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract `run_chain` helper, dedupe both likelihoods methods
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/game.rs:236-485` (replace both `likelihoods` and `likelihoods_scored` with one helper + two thin callers)
|
||||
|
||||
**Context for the implementer (read this before touching anything):**
|
||||
|
||||
`OwnedGame<T, D>` (defined at `src/game.rs:83-92`) holds `teams`, `result`, `weights`, `p_draw`, plus mutable output fields `likelihoods: Vec<Vec<Gaussian>>` and `evidence: f64`. Two private methods on `Game<'a, T, D>` (the borrowed sibling at `src/game.rs:148-156`) compute likelihoods:
|
||||
|
||||
- `likelihoods(&mut self, arena: &mut ScratchArena)` — ranked outcomes; `src/game.rs:236-371`
|
||||
- `likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` — scored outcomes; `src/game.rs:373-485`
|
||||
|
||||
The two are bit-identical except for the closure that builds the per-diff `DiffFactor` (defined at `src/game.rs:20-54`). `DiffFactor` has two variants: `Trunc(TruncFactor)` for ranked, `Margin(MarginFactor)` for scored.
|
||||
|
||||
The shared body does, in order: `arena.reset()`, sort teams descending by `result` into `arena.sort_buf`, fill `arena.team_prior`, build `links: Vec<DiffFactor>` (the differing block), resize `arena.lhood_lose` / `arena.lhood_win` to `N_INF`, run a forward+backward sweep with a max-iter-10 fixed-point loop guarded by `tuple_gt(step, 1e-6)`, handle the `n_diffs == 1` special case, do boundary updates, multiply per-diff `evidence()` into `self.evidence`, build the inverse permutation in `arena.inv_buf`, then build `self.likelihoods` from the per-team `lhood_win * lhood_lose` and per-player `performance().exclude(...).forget(beta²)` math.
|
||||
|
||||
**Refactor target:**
|
||||
|
||||
```rust
|
||||
fn run_chain<F>(
|
||||
&self,
|
||||
arena: &mut ScratchArena,
|
||||
mut make_link: F,
|
||||
) -> (f64, Vec<Vec<Gaussian>>)
|
||||
where
|
||||
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
|
||||
{ /* the entire shared body, returning (evidence, likelihoods) */ }
|
||||
```
|
||||
|
||||
Helper takes `&self` (not `&mut self`) so the closure can capture `&self.result`, `&self.teams`, `&self.weights`, `&self.p_draw` without conflicting with the helper's own immutable borrow. The arena is borrowed `&mut` independently.
|
||||
|
||||
The closure is invoked once per diff index `i ∈ 0..n_diffs`, after `arena.sort_buf` is filled. It receives `i`, `&arena.sort_buf[..]`, and `&mut arena.vars` so it can `alloc(N_INF)` the diff `VarId`. It returns the constructed `DiffFactor`.
|
||||
|
||||
The two callers shrink to:
|
||||
|
||||
```rust
|
||||
fn likelihoods(&mut self, arena: &mut ScratchArena) {
|
||||
let p_draw = self.p_draw;
|
||||
let result = &self.result;
|
||||
let teams = &self.teams;
|
||||
let (evidence, likelihoods) = Self::dummy_to_satisfy_borrowck(/* see below */);
|
||||
// ... assigns self.evidence and self.likelihoods
|
||||
}
|
||||
```
|
||||
|
||||
Wait — actually borrow-checker note: calling `self.run_chain(arena, |i, sort_buf, vars| { use_self_fields })` from a `&mut self` method is **fine** because `run_chain` takes `&self` and the closure captures `&self` immutably. Both share an immutable reborrow of `*self`. The arena is a separate `&mut` borrow. Verify the implementer doesn't accidentally make `run_chain` take `&mut self`.
|
||||
|
||||
**Why a closure (not a trait, not a two-phase build).** A closure keeps caller-specific state (`p_draw`, `score_sigma`, beta sums) inline at the call site with zero ceremony. A trait would require a stateful builder per call. A two-phase build (caller produces `Vec<DiffFactor>` first, helper does the rest) would either re-do the sort or split arena ownership awkwardly between the phases.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Step 1: Run the existing test suite to capture the baseline**
|
||||
|
||||
Run: `cargo test --lib`
|
||||
|
||||
Expected: all tests pass. Note the count (should be 88+ lib tests) — the refactor must keep this number unchanged with all green.
|
||||
|
||||
- [ ] **Step 2: Open `src/game.rs` and add the `run_chain` helper**
|
||||
|
||||
Inside `impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> { ... }` (the block starting at `src/game.rs:158`), add `run_chain` immediately above the existing `likelihoods` method (so above line 236). Use exactly this body — it is the merge of the two existing methods with the differing block replaced by the closure call:
|
||||
|
||||
```rust
|
||||
fn run_chain<F>(
|
||||
&self,
|
||||
arena: &mut ScratchArena,
|
||||
mut make_link: F,
|
||||
) -> (f64, Vec<Vec<Gaussian>>)
|
||||
where
|
||||
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
|
||||
{
|
||||
arena.reset();
|
||||
|
||||
let n_teams = self.teams.len();
|
||||
|
||||
arena.sort_buf.extend(0..n_teams);
|
||||
arena.sort_buf.sort_by(|&i, &j| {
|
||||
self.result[j]
|
||||
.partial_cmp(&self.result[i])
|
||||
.unwrap_or(Ordering::Equal)
|
||||
});
|
||||
|
||||
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
|
||||
self.teams[t]
|
||||
.iter()
|
||||
.zip(self.weights[t].iter())
|
||||
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
|
||||
}));
|
||||
|
||||
let n_diffs = n_teams.saturating_sub(1);
|
||||
|
||||
let mut links: Vec<DiffFactor> = (0..n_diffs)
|
||||
.map(|i| make_link(i, &arena.sort_buf, &mut arena.vars))
|
||||
.collect();
|
||||
|
||||
arena.lhood_lose.resize(n_teams, N_INF);
|
||||
arena.lhood_win.resize(n_teams, N_INF);
|
||||
|
||||
let mut step = (f64::INFINITY, f64::INFINITY);
|
||||
let mut iter = 0;
|
||||
|
||||
while tuple_gt(step, 1e-6) && iter < 10 {
|
||||
step = (0.0_f64, 0.0_f64);
|
||||
|
||||
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let d = lf.propagate(&mut arena.vars);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_ll = pw - lf.msg();
|
||||
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
|
||||
arena.lhood_lose[e + 1] = new_ll;
|
||||
}
|
||||
|
||||
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
|
||||
let e = n_diffs - 1 - rev_i;
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let d = lf.propagate(&mut arena.vars);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_lw = pl + lf.msg();
|
||||
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
|
||||
arena.lhood_win[e] = new_lw;
|
||||
}
|
||||
|
||||
iter += 1;
|
||||
}
|
||||
|
||||
if n_diffs == 1 {
|
||||
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
|
||||
- (arena.team_prior[1] * arena.lhood_win[1]);
|
||||
arena.vars.set(links[0].diff(), raw * links[0].msg());
|
||||
links[0].propagate(&mut arena.vars);
|
||||
}
|
||||
|
||||
if n_diffs > 0 {
|
||||
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
|
||||
arena.lhood_win[0] = pl1 + links[0].msg();
|
||||
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
|
||||
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
|
||||
}
|
||||
|
||||
let evidence: f64 = links.iter().map(|l| l.evidence()).product();
|
||||
|
||||
arena.inv_buf.resize(n_teams, 0);
|
||||
for (si, &orig_i) in arena.sort_buf.iter().enumerate() {
|
||||
arena.inv_buf[orig_i] = si;
|
||||
}
|
||||
|
||||
let likelihoods = self
|
||||
.teams
|
||||
.iter()
|
||||
.zip(self.weights.iter())
|
||||
.enumerate()
|
||||
.map(|(orig_i, (players, weights))| {
|
||||
let si = arena.inv_buf[orig_i];
|
||||
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
||||
let performance = players
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
|
||||
players
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(player, &w)| {
|
||||
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
|
||||
.forget(player.beta.powi(2))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(evidence, likelihoods)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `likelihoods` body with a thin caller**
|
||||
|
||||
In `src/game.rs`, replace the entire body of `fn likelihoods(&mut self, arena: &mut ScratchArena)` (currently lines 236-371 — replace from the opening `{` to the closing `}` of that method) with:
|
||||
|
||||
```rust
|
||||
fn likelihoods(&mut self, arena: &mut ScratchArena) {
|
||||
let p_draw = self.p_draw;
|
||||
// Capture pointers to fields the closure reads, to keep borrow scopes tight.
|
||||
// Closure captures &self.result and &self.teams (both immutable) and the
|
||||
// &mut arena passed in via run_chain — disjoint from `&self`.
|
||||
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
|
||||
let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
|
||||
let margin = if p_draw == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let a: f64 = self.teams[sort_buf[i]]
|
||||
.iter()
|
||||
.map(|p| p.beta.powi(2))
|
||||
.sum();
|
||||
let b: f64 = self.teams[sort_buf[i + 1]]
|
||||
.iter()
|
||||
.map(|p| p.beta.powi(2))
|
||||
.sum();
|
||||
compute_margin(p_draw, (a + b).sqrt())
|
||||
};
|
||||
let vid = vars.alloc(N_INF);
|
||||
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
|
||||
});
|
||||
self.evidence = evidence;
|
||||
self.likelihoods = likelihoods;
|
||||
}
|
||||
```
|
||||
|
||||
(Capturing `p_draw` as a local binding before the closure avoids a `self.p_draw` borrow inside; it's a `Copy` `f64` so this is free.)
|
||||
|
||||
- [ ] **Step 4: Replace `likelihoods_scored` body with a thin caller**
|
||||
|
||||
In `src/game.rs`, replace the entire body of `fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` (currently lines 373-485) with:
|
||||
|
||||
```rust
|
||||
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
|
||||
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
|
||||
// After descending-by-score sort, m_obs >= 0 for every adjacent pair.
|
||||
let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
|
||||
let vid = vars.alloc(N_INF);
|
||||
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
|
||||
});
|
||||
self.evidence = evidence;
|
||||
self.likelihoods = likelihoods;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build to confirm it compiles**
|
||||
|
||||
Run: `cargo build`
|
||||
|
||||
Expected: compiles cleanly. If the borrow checker complains that the closure conflicts with `self.run_chain(...)`, the most likely cause is `run_chain` accidentally being `&mut self` — confirm its signature is `fn run_chain<F>(&self, arena: &mut ScratchArena, mut make_link: F) -> (f64, Vec<Vec<Gaussian>>)`. If that's correct and there's still a conflict, double-check the closure's captures: it should capture `&self.result` and `&self.teams` (immutable), `p_draw: f64` by value (Copy), and `score_sigma: f64` by value (Copy). It must NOT touch `&mut self` in any form.
|
||||
|
||||
- [ ] **Step 6: Run the full library test suite — must be all green, same count as Step 1**
|
||||
|
||||
Run: `cargo test --lib`
|
||||
|
||||
Expected: same number of tests as Step 1, all pass. Bit-equal goldens — every existing assertion (`test_1vs1`, `test_1vs1_draw`, `test_2vs1vs2_mixed`, MarginFactor end-to-end tests, etc.) must pass unchanged. If ANY test fails, the refactor is wrong; revert and re-inspect.
|
||||
|
||||
- [ ] **Step 7: Run integration tests too**
|
||||
|
||||
Run: `cargo test`
|
||||
|
||||
Expected: all integration tests pass (28 noted in commit `8b53cac`).
|
||||
|
||||
- [ ] **Step 8: Format and lint**
|
||||
|
||||
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
|
||||
|
||||
Expected: no diffs from fmt, no clippy warnings.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/game.rs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
|
||||
|
||||
Both methods were 95-line near-duplicates differing only in the closure
|
||||
that builds the per-diff DiffFactor. Extract the shared body as a
|
||||
private run_chain<F>(&self, arena, make_link) helper that returns
|
||||
(evidence, likelihoods); the two callers shrink to ~10 lines each.
|
||||
|
||||
Pure code-shape change: posteriors and evidence remain bit-equal; all
|
||||
existing tests (lib + integration) pass unchanged.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Make `BuiltinFactor::log_evidence` match exhaustive
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/factor/mod.rs:94-100` (the `log_evidence` impl on `BuiltinFactor`)
|
||||
|
||||
- [ ] **Step 1: Open `src/factor/mod.rs` and replace the `log_evidence` body**
|
||||
|
||||
Replace the existing impl:
|
||||
|
||||
```rust
|
||||
fn log_evidence(&self, vars: &VarStore) -> f64 {
|
||||
match self {
|
||||
Self::Trunc(f) => f.log_evidence(vars),
|
||||
Self::Margin(f) => f.log_evidence(vars),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
fn log_evidence(&self, vars: &VarStore) -> f64 {
|
||||
match self {
|
||||
Self::Trunc(f) => f.log_evidence(vars),
|
||||
Self::Margin(f) => f.log_evidence(vars),
|
||||
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and run tests**
|
||||
|
||||
Run: `cargo build && cargo test --lib`
|
||||
|
||||
Expected: compiles cleanly, all tests pass. Behavior is unchanged — `TeamSum` and `RankDiff` still return `0.0`, but a future variant will now produce a non-exhaustive-match error instead of being silently swallowed.
|
||||
|
||||
- [ ] **Step 3: Format and lint**
|
||||
|
||||
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
|
||||
|
||||
Expected: no diffs, no warnings.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/factor/mod.rs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor: make BuiltinFactor::log_evidence match exhaustive
|
||||
|
||||
Replace the `_ => 0.0` wildcard with explicit
|
||||
`Self::TeamSum(_) | Self::RankDiff(_) => 0.0`. No behavioral change;
|
||||
future variants now produce a compile error instead of being silently
|
||||
absorbed by the wildcard.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Fix stale numerics in T4 plan doc
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` (lines 52 and 185)
|
||||
|
||||
The shipped test in `src/factor/mod.rs:163,166` asserts:
|
||||
|
||||
```
|
||||
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
|
||||
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
|
||||
```
|
||||
|
||||
The plan's prose at line 52 quotes pre-shipped values that no longer match. This task fixes the prose and the matching code-comment. The full-precision assertion blocks elsewhere in the plan are out of scope (they belong to the plan-as-written, and the spec's fix table only listed the rounded prose values).
|
||||
|
||||
- [ ] **Step 1: Update the prose at line 52**
|
||||
|
||||
Open `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`. Find the line:
|
||||
|
||||
```
|
||||
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.046827`. So `log_evidence ≈ -3.0613`.
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```
|
||||
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.04678`. So `log_evidence ≈ -3.0622`.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the matching code-comment at line 185**
|
||||
|
||||
In the same file, find:
|
||||
|
||||
```
|
||||
// pdf(5, 0, sqrt(37)) ≈ 0.046827
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```
|
||||
// pdf(5, 0, sqrt(37)) ≈ 0.04678
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify nothing else changed**
|
||||
|
||||
Run: `git diff docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
|
||||
|
||||
Expected: exactly three lines changed (one prose line containing both numbers, one comment line). Nothing else should be touched.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-04-27-t4-margin-factor.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs: fix stale numerics in t4-margin-factor plan
|
||||
|
||||
The plan's prose quoted Z_cav ≈ 0.046827 and log_evidence ≈ -3.0613,
|
||||
which diverged from the values asserted by the shipped test in
|
||||
src/factor/mod.rs (-3.062235327364623). Update prose and the matching
|
||||
code comment to 0.04678 / -3.0622.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review (writer's note)
|
||||
|
||||
Spec coverage:
|
||||
- Spec Item 1 (dedupe `likelihoods`/`likelihoods_scored`) → Task 1 ✓
|
||||
- Spec Item 2 (exhaustive `BuiltinFactor::log_evidence`) → Task 2 ✓
|
||||
- Spec Item 3 (stale numerics in T4 plan) → Task 3 ✓
|
||||
- Spec out-of-scope items (`DiffFactor` collapse, per-event `score_sigma`) — correctly absent ✓
|
||||
|
||||
Verification gates per the spec ("each item commits independently and ships behind a green `cargo test --lib`"): every task ends in fmt + clippy + tests + commit. Task 1 additionally runs `cargo test` for integration coverage.
|
||||
|
||||
Type / signature consistency:
|
||||
- `run_chain` signature appears identically in the context header and Step 2 body ✓
|
||||
- Closure type `FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor` matches across Step 2 (definition) and Steps 3/4 (call sites) ✓
|
||||
- `DiffFactor::Trunc` / `DiffFactor::Margin` constructors match `src/game.rs:20-23` definitions ✓
|
||||
|
||||
No placeholders detected.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,320 @@
|
||||
# Damped EP — Game-Local Damping
|
||||
|
||||
## Summary
|
||||
|
||||
Add an opt-in EP damping knob to within-game inference. Users set
|
||||
`ConvergenceOptions::alpha < 1.0` to damp message updates and stabilise
|
||||
oscillating fixed-point loops on hard graphs. `alpha = 1.0` (the default)
|
||||
is bit-equal to today.
|
||||
|
||||
This is the smallest-scope realisation of the spec's `Damped` schedule:
|
||||
**game-local**, not plumbed through the `Schedule` trait. The `Schedule`
|
||||
trait is shipped infrastructure that `run_chain` does not currently call;
|
||||
wiring `Schedule` into game inference is a separate future task. This
|
||||
design touches only what the user can actually reach via `GameOptions`.
|
||||
|
||||
## Scope
|
||||
|
||||
### What ships
|
||||
|
||||
1. New field `ConvergenceOptions::alpha: f64` (default `1.0`).
|
||||
2. `run_chain` reads `options.convergence.{epsilon, max_iter, alpha}`
|
||||
instead of the hardcoded `1e-6` / `10` / undamped — fixes the existing
|
||||
latent bug where the first two were already on `GameOptions` but never
|
||||
read by inference.
|
||||
3. `Gaussian::damp_natural(self, new, alpha) -> Gaussian` — public helper
|
||||
computing `α·new + (1−α)·self` in natural-parameter space.
|
||||
4. `TruncFactor` and `MarginFactor` gain inherent
|
||||
`propagate_with_alpha(&mut self, vars, alpha) -> (f64, f64)`. Their
|
||||
`Factor::propagate` impls become one-line delegations passing
|
||||
`alpha = 1.0`.
|
||||
5. `DiffFactor::propagate` (game-private enum at `src/game.rs:20-54`)
|
||||
gains an `alpha: f64` parameter and dispatches into the underlying
|
||||
factor's `propagate_with_alpha`.
|
||||
|
||||
### What does not ship
|
||||
|
||||
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait stays as
|
||||
it is; integration with `run_chain` is a separate task.
|
||||
- No nat-param convergence switch. `(|Δmu|, |Δsigma|)` stays the
|
||||
delta basis (matches today). The spec's "stopping in natural-param
|
||||
space" wants its own design pass and test re-tuning.
|
||||
- No oscillation auto-detect. `alpha` is user-supplied and constant for
|
||||
the duration of a `run_chain` call.
|
||||
- No `Residual`, `OneShot`, or `SynergyFactor` / `ScoreFactor` work —
|
||||
separate future plans.
|
||||
|
||||
## Design
|
||||
|
||||
### `ConvergenceOptions::alpha`
|
||||
|
||||
```rust
|
||||
// src/convergence.rs
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConvergenceOptions {
|
||||
pub max_iter: usize,
|
||||
pub epsilon: f64,
|
||||
pub alpha: f64,
|
||||
}
|
||||
|
||||
impl Default for ConvergenceOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iter: crate::ITERATIONS,
|
||||
epsilon: crate::EPSILON,
|
||||
alpha: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`alpha = 1.0` ⇒ undamped (bit-equal to today). Recommended starting
|
||||
point if a graph oscillates: `0.5`–`0.7`. Values approaching `0.0` make
|
||||
each step tinier and slow convergence; `alpha = 0.0` is degenerate
|
||||
(factor never updates). Validation in `run_chain`:
|
||||
|
||||
```rust
|
||||
debug_assert!(
|
||||
opts.convergence.alpha > 0.0 && opts.convergence.alpha <= 1.0,
|
||||
"convergence alpha must be in (0.0, 1.0]"
|
||||
);
|
||||
```
|
||||
|
||||
### `Gaussian::damp_natural`
|
||||
|
||||
```rust
|
||||
impl Gaussian {
|
||||
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
|
||||
///
|
||||
/// Used by within-game schedules to stabilise oscillating fixed-point
|
||||
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
||||
/// `alpha < 1.0` shrinks each per-step update.
|
||||
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
||||
Gaussian::from_natural(
|
||||
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
||||
alpha * new.tau() + (1.0 - alpha) * self.tau(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Public on `Gaussian`. The name encodes the WHY (EP damping); the doc
|
||||
comment fixes the math. No new dependency.
|
||||
|
||||
The existing `Mul<f64> for Gaussian` is **distribution scaling**
|
||||
(`sigma → sigma·|scalar|`), not nat-param interpolation, so it can't be
|
||||
reused here.
|
||||
|
||||
### `TruncFactor::propagate_with_alpha`
|
||||
|
||||
```rust
|
||||
impl TruncFactor {
|
||||
pub(crate) fn propagate_with_alpha(
|
||||
&mut self,
|
||||
vars: &mut VarStore,
|
||||
alpha: f64,
|
||||
) -> (f64, f64) {
|
||||
let marginal = vars.get(self.diff);
|
||||
let cavity = marginal / self.msg;
|
||||
|
||||
if self.evidence_cached.is_none() {
|
||||
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
|
||||
}
|
||||
|
||||
let trunc = approx(cavity, self.margin, self.tie);
|
||||
let new_msg = trunc / cavity;
|
||||
|
||||
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||
let old_msg = self.msg;
|
||||
self.msg = damped;
|
||||
|
||||
// marginal_new = cavity * stored_msg (NOT cavity * new_msg with damping)
|
||||
vars.set(self.diff, cavity * damped);
|
||||
|
||||
old_msg.delta(damped)
|
||||
}
|
||||
}
|
||||
|
||||
impl Factor for TruncFactor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||
self.propagate_with_alpha(vars, 1.0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two important points:
|
||||
|
||||
- The variable receives `cavity * damped` (i.e. `cavity * self.msg`),
|
||||
not `trunc`. With `alpha = 1.0` these are equal (since
|
||||
`cavity * new_msg = trunc` by construction), so today's behaviour is
|
||||
preserved bit-equal. With `alpha < 1.0` the marginal reflects the
|
||||
partially-applied update.
|
||||
- The reported delta is `old_msg.delta(damped)` — delta of the actually
|
||||
stored message, not of the raw `new_msg`. This is the textbook EP
|
||||
damping convention: the convergence loop measures the trajectory it
|
||||
is actually walking.
|
||||
|
||||
`MarginFactor` follows the same shape, with its own
|
||||
`propagate_with_alpha` body (the existing `propagate` math, with the
|
||||
`damp_natural` step inserted in the same place and the var write
|
||||
switched to `cavity * damped`).
|
||||
|
||||
### `DiffFactor::propagate` signature
|
||||
|
||||
```rust
|
||||
// src/game.rs
|
||||
impl DiffFactor {
|
||||
pub(crate) fn propagate(
|
||||
&mut self,
|
||||
vars: &mut VarStore,
|
||||
alpha: f64,
|
||||
) -> (f64, f64) {
|
||||
match self {
|
||||
Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
|
||||
Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`DiffFactor` is `pub(crate)` and only used inside `run_chain`, so the
|
||||
signature change has no public-API impact.
|
||||
|
||||
### `run_chain` changes
|
||||
|
||||
Inside `Game::run_chain` (`src/game.rs:236-348`):
|
||||
|
||||
1. Capture `let alpha = opts.convergence.alpha;` once at the top
|
||||
(avoids repeated `opts.convergence.alpha` lookups in the hot loop).
|
||||
2. Replace the loop guard
|
||||
`while tuple_gt(step, 1e-6) && iter < 10`
|
||||
with
|
||||
`while tuple_gt(step, opts.convergence.epsilon) && iter < opts.convergence.max_iter`.
|
||||
3. Replace each `lf.propagate(&mut arena.vars)` call site (three of
|
||||
them: forward sweep, backward sweep, `n_diffs == 1` special case)
|
||||
with `lf.propagate(&mut arena.vars, alpha)`.
|
||||
|
||||
The threading of `opts: &GameOptions` into `run_chain` is the only
|
||||
new caller obligation. Today `run_chain` doesn't take `opts`; the two
|
||||
callers (`likelihoods`, `likelihoods_scored`) currently invoke it
|
||||
without options. Both will need to pass the options through. The
|
||||
`Game<'a, T, D>` struct does not currently hold `GameOptions`; the
|
||||
options are constructed and discarded around the call to
|
||||
`{ranked,scored}_with_arena`. So:
|
||||
|
||||
- `Game::ranked_with_arena` and `Game::scored_with_arena` already
|
||||
receive `p_draw` / `score_sigma` as scalar params; we extend them to
|
||||
accept `&ConvergenceOptions` (or the full `&GameOptions`) too.
|
||||
- `likelihoods` / `likelihoods_scored` either store the options on
|
||||
`Game` or accept them as method parameters and forward to
|
||||
`run_chain`.
|
||||
|
||||
The simplest plumbing: store `convergence: ConvergenceOptions` as a
|
||||
field on `Game<'a, T, D>` and `OwnedGame<T, D>` populated at
|
||||
construction time. Then `run_chain` can read it from `&self`.
|
||||
|
||||
## Convergence semantics
|
||||
|
||||
With `alpha < 1.0` the per-step update shrinks; convergence may take
|
||||
more iterations to reach the same `epsilon` threshold. Users who damp
|
||||
should also raise `max_iter` accordingly. Documentation example:
|
||||
|
||||
```rust
|
||||
let mut opts = GameOptions::default();
|
||||
opts.convergence.alpha = 0.5;
|
||||
opts.convergence.max_iter = 30;
|
||||
```
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Regression net (no new file)
|
||||
|
||||
The existing 88 lib tests and 27 integration tests are the bit-equal
|
||||
regression net. With `alpha = 1.0` (the default), every assertion must
|
||||
pass unchanged. If any test fails, the damping path leaked into the
|
||||
undamped trajectory.
|
||||
|
||||
### New tests
|
||||
|
||||
1. **`Gaussian::damp_natural` arithmetic**
|
||||
(`src/gaussian.rs` test mod):
|
||||
- `α = 1.0` returns `new` exactly (bit-equal `pi` and `tau`).
|
||||
- `α = 0.0` returns `self` exactly.
|
||||
- `α = 0.5`: pi and tau are exact midpoints in nat-param space.
|
||||
- Three asserts, no new file.
|
||||
|
||||
2. **`TruncFactor::propagate_with_alpha` shrinks the step**
|
||||
(`src/factor/trunc.rs` test mod):
|
||||
- Set up a TruncFactor step. Run `propagate_with_alpha(α=1.0)` once,
|
||||
record `delta_undamped` and the resulting `self.msg`.
|
||||
- Reset to a fresh factor at the same starting state. Run
|
||||
`propagate_with_alpha(α=0.5)` once, record `delta_damped` and
|
||||
`damped_msg`.
|
||||
- Assert: `damped_msg.pi()` equals `0.5 * undamped_msg.pi() + 0.5 * initial_msg.pi()` within 1e-12 (and same for `tau`).
|
||||
- Assert: `delta_damped.0 <= delta_undamped.0` (mu-delta is no larger; the relationship is monotone in `α` but not strictly `0.5×` for the `delta()` function which is `(|Δmu|, |Δsigma|)`).
|
||||
|
||||
3. **`MarginFactor::propagate_with_alpha` parity**
|
||||
(`src/factor/margin.rs` test mod):
|
||||
- Same shape as #2, on a `MarginFactor` step.
|
||||
|
||||
4. **`run_chain` honours `ConvergenceOptions::max_iter`**
|
||||
(in an existing or new game-level test):
|
||||
- Construct a 4-team ranked game that normally converges in ~5 iterations.
|
||||
- Set `opts.convergence.max_iter = 1`. Assert the per-iteration
|
||||
`step` returned (or observable indirectly via posterior delta vs.
|
||||
the converged answer) is non-zero — i.e. the loop stopped early.
|
||||
- Set `opts.convergence.max_iter = 30`. Assert posteriors match the
|
||||
baseline within `epsilon`.
|
||||
|
||||
5. **Damping default is `1.0` and produces bit-equal output**
|
||||
(smoke test, can be a single assertion in an existing test):
|
||||
- `assert_eq!(ConvergenceOptions::default().alpha, 1.0);`
|
||||
- Existing goldens prove the bit-equality.
|
||||
|
||||
No oscillation-stabilisation test (would require constructing a
|
||||
pathological graph specifically to oscillate; out of scope for a
|
||||
minimal ship).
|
||||
|
||||
## Verification gates
|
||||
|
||||
Per task:
|
||||
|
||||
```bash
|
||||
cargo +nightly fmt
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test --lib
|
||||
cargo test
|
||||
```
|
||||
|
||||
All must succeed. Test count grows by exactly the new tests above
|
||||
(roughly +5–8 lib tests).
|
||||
|
||||
## Risks
|
||||
|
||||
- **Marginal-update change is subtle.** Switching the variable write
|
||||
from `trunc` to `cavity * damped` is intentionally a no-op when
|
||||
`alpha = 1.0` (since `cavity * new_msg = trunc`), but it changes the
|
||||
arithmetic path. If `Gaussian` arithmetic has any non-associativity
|
||||
in floating-point that the old form happened to dodge, goldens could
|
||||
shift by 1 ULP. Mitigation: TDD — write the regression test (run all
|
||||
existing tests with `alpha = 1.0`) **first**, before changing the
|
||||
variable-write line.
|
||||
- **`run_chain` signature change ripples to two callers.** Trivial
|
||||
but must be done atomically with the field addition on `Game` /
|
||||
`OwnedGame`.
|
||||
- **`alpha` validation only in debug builds.** A release build will
|
||||
silently accept `alpha = 0.0` or `alpha > 1.0` and produce nonsense.
|
||||
This matches the existing pattern (`debug_assert!` for input
|
||||
validation in `Game::ranked_with_arena`); upgrading to `Result` is
|
||||
out of scope.
|
||||
|
||||
## Out-of-scope follow-ups (logged for future plans)
|
||||
|
||||
- Wire `Schedule` into `run_chain` (so `Damped` lands as a real
|
||||
`Schedule` impl alongside `EpsilonOrMax`).
|
||||
- Switch convergence check to `(|Δpi|, |Δtau|)` per spec
|
||||
§"Stopping in natural-param space".
|
||||
- Oscillation auto-detect (engage `alpha < 1.0` only after N
|
||||
non-monotone steps).
|
||||
- `Residual` schedule (priority queue).
|
||||
- `SynergyFactor`, `ScoreFactor` (new EP factor types).
|
||||
@@ -0,0 +1,232 @@
|
||||
# History → TimeSlice ConvergenceOptions Plumbing
|
||||
|
||||
## Summary
|
||||
|
||||
Make `History`'s already-public `ConvergenceOptions` (set via
|
||||
`HistoryBuilder::convergence(...)`) actually reach the within-game
|
||||
inference loop. Today it's read by the outer `History::converge` sweep
|
||||
but dropped on the floor when constructing `TimeSlice`s, so users who
|
||||
opt in to `alpha < 1.0` (Damped EP) on a `History` get nothing — the
|
||||
inner `run_chain` calls inside `TimeSlice` hardcode
|
||||
`ConvergenceOptions::default()`.
|
||||
|
||||
This spec closes the gap with one focused change: thread
|
||||
`ConvergenceOptions` from `History` through `TimeSlice` to the three
|
||||
`Game::*_with_arena` callsites in `time_slice.rs`. No new types, no new
|
||||
public methods on `History` or `HistoryBuilder` — the user-facing API
|
||||
already exists.
|
||||
|
||||
## Background
|
||||
|
||||
After T5 (commit `0705986`) of the Damped EP plan,
|
||||
`Game::*_with_arena` accepts `convergence: ConvergenceOptions` and
|
||||
`run_chain` reads `self.convergence.{epsilon, max_iter, alpha}`.
|
||||
`HistoryBuilder` already has a `convergence(opts)` method (`history.rs:91`)
|
||||
that stores onto a field on `History`. `History::converge` reads
|
||||
`self.convergence.{max_iter, epsilon}` for its outer cross-history loop
|
||||
(`history.rs:437-447`).
|
||||
|
||||
The break is here, in `History::add_events_with_prior` at `history.rs:597`:
|
||||
|
||||
```rust
|
||||
let mut time_slice = TimeSlice::new(t, self.p_draw);
|
||||
```
|
||||
|
||||
`self.convergence` is not passed. `TimeSlice` has no convergence field.
|
||||
The three callsites in `time_slice.rs` that build `Game::*_with_arena`
|
||||
fall back to `ConvergenceOptions::default()`:
|
||||
|
||||
- `Event::iteration_direct` (`time_slice.rs:138-156`)
|
||||
- `TimeSlice::convergence` (`time_slice.rs:332-345`)
|
||||
- `TimeSlice::log_evidence` (`time_slice.rs:521-538`)
|
||||
|
||||
## Scope
|
||||
|
||||
### What ships
|
||||
|
||||
1. `TimeSlice<T>` gains a `pub(crate) convergence: ConvergenceOptions`
|
||||
field set at construction.
|
||||
2. `TimeSlice::new` signature becomes
|
||||
`pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self`.
|
||||
3. `History::add_events_with_prior` (`history.rs:597`) passes
|
||||
`self.convergence` when constructing new `TimeSlice`s.
|
||||
4. `Event::iteration_direct` gains a `convergence: ConvergenceOptions`
|
||||
parameter and forwards it to the `Game::*_with_arena` callsite.
|
||||
The two callers (`TimeSlice::iteration` at `time_slice.rs:419` and
|
||||
`:441`) pass `self.convergence`.
|
||||
5. `TimeSlice::convergence` (the method, not the field) replaces its
|
||||
hardcoded `crate::ConvergenceOptions::default()` with
|
||||
`self.convergence`.
|
||||
6. `TimeSlice::log_evidence` does the same.
|
||||
7. Five test callsites of `TimeSlice::new(time, p_draw)` updated
|
||||
mechanically to `TimeSlice::new(time, p_draw, ConvergenceOptions::default())`.
|
||||
|
||||
### What does not ship
|
||||
|
||||
- No split of `ConvergenceOptions` into outer/inner fields. The
|
||||
conflation (one `max_iter` covers both the cross-history sweep and
|
||||
the per-game EP iteration cap) is the user-confirmed design.
|
||||
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait is still
|
||||
not integrated into `run_chain`.
|
||||
- No nat-param convergence switch.
|
||||
- No oscillation auto-detect.
|
||||
- No new `History` or `HistoryBuilder` methods. `convergence(opts)`
|
||||
already exists and works.
|
||||
- No changes to `History::converge` — the outer-loop semantics are
|
||||
unchanged (it already reads `self.convergence`).
|
||||
|
||||
## Design
|
||||
|
||||
### `TimeSlice<T>` field
|
||||
|
||||
```rust
|
||||
// src/time_slice.rs
|
||||
pub struct TimeSlice<T: Time = i64> {
|
||||
// ... existing fields ...
|
||||
p_draw: f64,
|
||||
pub(crate) convergence: ConvergenceOptions,
|
||||
// ... existing fields ...
|
||||
}
|
||||
```
|
||||
|
||||
### `TimeSlice::new`
|
||||
|
||||
```rust
|
||||
impl<T: Time> TimeSlice<T> {
|
||||
pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self {
|
||||
Self {
|
||||
// ... existing initialisation ...
|
||||
p_draw,
|
||||
convergence,
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `History::add_events_with_prior` — single-line fix
|
||||
|
||||
At `src/history.rs:597`:
|
||||
|
||||
```rust
|
||||
// before
|
||||
let mut time_slice = TimeSlice::new(t, self.p_draw);
|
||||
|
||||
// after
|
||||
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
|
||||
```
|
||||
|
||||
### `Event::iteration_direct` parameter
|
||||
|
||||
```rust
|
||||
// src/time_slice.rs
|
||||
impl Event {
|
||||
pub(crate) fn iteration_direct(
|
||||
&mut self,
|
||||
skills: &mut SkillStore,
|
||||
agents: &CompetitorStore<i64, ConstantDrift>,
|
||||
p_draw: f64,
|
||||
convergence: ConvergenceOptions,
|
||||
arena: &mut ScratchArena,
|
||||
) -> /* existing return */ {
|
||||
// ... existing body, with the Game::*_with_arena calls
|
||||
// using `convergence` instead of ConvergenceOptions::default() ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The two callers — `TimeSlice::iteration` at `time_slice.rs:419` and
|
||||
`:441` — already have `&mut self` access, so they pass
|
||||
`self.convergence`.
|
||||
|
||||
### `TimeSlice::convergence` method (not the field)
|
||||
|
||||
The method `pub(crate) fn convergence<D>(&mut self, agents: ...) -> usize`
|
||||
at `time_slice.rs:447` shares its name with the new field. Rust allows
|
||||
this (methods and fields live in different namespaces), but it's a
|
||||
readability hazard. Rename the method to `iterate_to_convergence` to
|
||||
disambiguate.
|
||||
|
||||
This is one rename, six callsites in `history.rs` and the test module.
|
||||
|
||||
### Field semantics
|
||||
|
||||
`History` keeps the single shared `ConvergenceOptions` struct. The same
|
||||
`max_iter` covers both the outer sweep and each inner per-game loop.
|
||||
The same `epsilon` covers both stopping criteria. The `alpha` field is
|
||||
read only inside `run_chain` (the inner loop); the outer loop
|
||||
intentionally ignores `alpha` because cross-history damping is a
|
||||
different mathematical concept and not in scope.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Regression net
|
||||
|
||||
The existing 98 lib + 27 integration tests are the bit-equal regression
|
||||
net. Default `ConvergenceOptions` is unchanged
|
||||
(`max_iter=30, epsilon=1e-6, alpha=1.0`), and `TimeSlice` was already
|
||||
using exactly that since T5. The only behavioural difference is for
|
||||
users who actually pass non-default options through
|
||||
`HistoryBuilder::convergence(...)` — and there are no current tests that
|
||||
do that **and** compare posteriors, so all goldens stay bit-equal.
|
||||
|
||||
### New tests
|
||||
|
||||
1. **`history_propagates_convergence_to_inner_run_chain`** (in
|
||||
`src/history.rs` test module):
|
||||
- Build a History with `convergence(ConvergenceOptions { max_iter: 1, ..Default::default() })`.
|
||||
- Add a small batch of events that needs more than one inner EP iteration to converge (e.g. a 4-team game per slice).
|
||||
- `converge()`, capture posteriors.
|
||||
- Build a fresh History with default options on the same events.
|
||||
- `converge()`, capture posteriors.
|
||||
- Assert the two sets of posteriors differ measurably (max diff > 1e-6).
|
||||
- Proves the inner loop honours the propagated `max_iter`. Today (without this change) the assertion would fail because both Histories use default inside.
|
||||
|
||||
2. **`history_with_damping_reaches_same_fixed_point_as_undamped`** (same
|
||||
test module):
|
||||
- Build a History with `convergence(ConvergenceOptions { alpha: 0.5, max_iter: 200, ..Default::default() })`.
|
||||
- Same events as above.
|
||||
- `converge()`, capture posteriors.
|
||||
- Build a default-options History on the same events.
|
||||
- `converge()`, capture posteriors.
|
||||
- Assert per-player posteriors agree within 1e-3.
|
||||
- Proves damping doesn't break convergence on the History path.
|
||||
|
||||
If the second test's max diff is too large, raise `max_iter` further
|
||||
(damping needs more iterations to reach the same fixed point).
|
||||
|
||||
## Verification gates
|
||||
|
||||
```bash
|
||||
cargo +nightly fmt
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test --lib
|
||||
cargo test
|
||||
```
|
||||
|
||||
All must succeed. Test count grows by exactly 2 (the two new tests).
|
||||
|
||||
## Risks
|
||||
|
||||
- **`TimeSlice::new` is `pub`.** Adding the third parameter is a
|
||||
breaking change to a public constructor. In a 0.1.x crate this is
|
||||
acceptable, but flag it in the commit message.
|
||||
- **`TimeSlice::convergence` method rename.** Renaming
|
||||
`convergence` → `iterate_to_convergence` touches `history.rs` and the
|
||||
TimeSlice test module. The rename is mechanical and improves
|
||||
readability where the field and method would otherwise share a name.
|
||||
- **Cross-history alpha semantics.** A user who sets `alpha = 0.5` on
|
||||
a `History` gets damping inside every per-game loop, but the outer
|
||||
`History::converge` sweep is undamped. This is the correct semantic
|
||||
(alpha is a within-EP-graph concept) but it's worth documenting in
|
||||
the `ConvergenceOptions::alpha` doc comment so users don't expect
|
||||
cross-slice damping. Add one sentence to the existing doc comment.
|
||||
|
||||
## Out-of-scope follow-ups
|
||||
|
||||
- Wire `Schedule` trait into `run_chain` — Damped becomes a `Schedule`
|
||||
impl alongside `EpsilonOrMax`.
|
||||
- Per-loop `ConvergenceOptions` split (outer / inner).
|
||||
- `Residual` schedule.
|
||||
- Per-event `EventKind::Scored.score_sigma` override (still
|
||||
history-wide today).
|
||||
@@ -0,0 +1,292 @@
|
||||
# Per-Event `score_sigma` Override
|
||||
|
||||
## Summary
|
||||
|
||||
Let users specify a per-event noise override on `Outcome::Scored`.
|
||||
Today every scored event in a `History` shares the single
|
||||
`HistoryBuilder::score_sigma` value (default `1.0`); a user who wants
|
||||
to say "this match was a clean blowout, trust the margin more" or
|
||||
"this one was a disrupted scrappy game, trust it less" has no way to
|
||||
do so.
|
||||
|
||||
The override is resolved at ingest time and stored as a plain `f64`
|
||||
on the existing `EventKind::Scored { score_sigma }` payload, so
|
||||
`TimeSlice` and `run_chain` need zero changes. The work is purely on
|
||||
the public API surface: `Outcome::Scored` becomes a struct variant
|
||||
with an `Option<f64> sigma` field; two builder methods on `Outcome`
|
||||
and `EventBuilder` cover the explicit-override path.
|
||||
|
||||
## Background
|
||||
|
||||
`Outcome::Scored(SmallVec<[f64; 4]>)` is the public per-team-score
|
||||
variant (`src/outcome.rs:20`). It's constructed via
|
||||
`Outcome::scores(I)` (`src/outcome.rs:44`) or
|
||||
`EventBuilder::scores(I)` (`src/event_builder.rs:79`).
|
||||
|
||||
When `History::add_events` ingests a Scored outcome, it always uses
|
||||
the history-wide default:
|
||||
|
||||
```rust
|
||||
// src/history.rs:735-740
|
||||
crate::Outcome::Scored(scores) => {
|
||||
kinds.push(EventKind::Scored {
|
||||
score_sigma: self.score_sigma,
|
||||
});
|
||||
scores.to_vec()
|
||||
}
|
||||
```
|
||||
|
||||
The downstream `EventKind::Scored { score_sigma: f64 }`
|
||||
(`src/time_slice.rs:51`) is already per-event-shaped — every Event
|
||||
carries its own copy. The constraint is purely at the ingest boundary.
|
||||
|
||||
This was flagged as deferred tech debt during the T4-MarginFactor
|
||||
work: "EventKind::Scored.score_sigma payload is always history-wide
|
||||
today; per-event override deferred."
|
||||
|
||||
## Scope
|
||||
|
||||
### What ships
|
||||
|
||||
1. `Outcome::Scored` becomes a struct variant:
|
||||
`Scored { scores: SmallVec<[f64; 4]>, sigma: Option<f64> }`.
|
||||
`None` = use history default; `Some(s)` = override.
|
||||
2. New constructor `Outcome::scores_with_sigma(scores, sigma)` on
|
||||
`Outcome`. Existing `Outcome::scores(I)` keeps the same shape but
|
||||
builds with `sigma: None`.
|
||||
3. New builder method `EventBuilder::scores_with_sigma(scores, sigma)`
|
||||
on `EventBuilder`.
|
||||
4. `History::add_events` resolves `sigma.unwrap_or(self.score_sigma)`
|
||||
when converting an `Outcome::Scored` to `EventKind::Scored`.
|
||||
5. Mechanical pattern-match updates at every site that destructures
|
||||
`Outcome::Scored(...)` as a tuple. Estimate ~5–10 sites across
|
||||
`src/`, `tests/`, `examples/`, `benches/`.
|
||||
|
||||
### What does not ship
|
||||
|
||||
- No change to `EventKind::Scored` (already per-event).
|
||||
- No change to `TimeSlice` or `run_chain`.
|
||||
- No change to `Game::scored` standalone API
|
||||
(it still takes `score_sigma` via `GameOptions::score_sigma`).
|
||||
- No deprecation of `HistoryBuilder::score_sigma` — the history-wide
|
||||
default is still useful as a common-case fallback.
|
||||
|
||||
## Design
|
||||
|
||||
### `Outcome` enum change
|
||||
|
||||
```rust
|
||||
// src/outcome.rs
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Outcome {
|
||||
Ranked(SmallVec<[u32; 4]>),
|
||||
Scored {
|
||||
scores: SmallVec<[f64; 4]>,
|
||||
/// Per-event noise override. `None` means inherit
|
||||
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
||||
sigma: Option<f64>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The variant shape changes from tuple to struct. Pattern matches that
|
||||
extract the scores switch from `Outcome::Scored(scores)` to
|
||||
`Outcome::Scored { scores, .. }` (or `{ scores, sigma }` where the
|
||||
sigma is needed).
|
||||
|
||||
### `Outcome` constructors
|
||||
|
||||
```rust
|
||||
impl Outcome {
|
||||
/// Per-team continuous scores; uses HistoryBuilder::score_sigma default.
|
||||
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
||||
Self::Scored {
|
||||
scores: scores.into_iter().collect(),
|
||||
sigma: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-team scores with explicit per-event noise override.
|
||||
///
|
||||
/// `sigma` must be > 0.0; debug_assert.
|
||||
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 {
|
||||
scores: scores.into_iter().collect(),
|
||||
sigma: Some(sigma),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Outcome::scores(I)` keeps the existing function signature exactly —
|
||||
its only behavioural change is the internal struct construction. The
|
||||
existing `as_scores()`, `team_count()`, etc. accessors keep their
|
||||
public signatures (they return `Option<&[f64]>` and `usize`); their
|
||||
internal pattern matches update mechanically.
|
||||
|
||||
### `EventBuilder` method
|
||||
|
||||
```rust
|
||||
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
|
||||
where
|
||||
T: Time,
|
||||
D: Drift<T>,
|
||||
O: Observer<T>,
|
||||
K: Eq + std::hash::Hash + Clone,
|
||||
{
|
||||
/// Per-team scores; uses HistoryBuilder::score_sigma default.
|
||||
pub fn scores<I: IntoIterator<Item = f64>>(mut self, scores: I) -> Self {
|
||||
self.event.outcome = crate::Outcome::scores(scores);
|
||||
self
|
||||
}
|
||||
|
||||
/// Per-team scores with explicit per-event noise override.
|
||||
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(
|
||||
mut self,
|
||||
scores: I,
|
||||
sigma: f64,
|
||||
) -> Self {
|
||||
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
|
||||
self
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The existing `.scores(...)` builder method stays — its body changes
|
||||
trivially because `Outcome::scores(I)` still has the same signature.
|
||||
`.scores_with_sigma(...)` is the new method.
|
||||
|
||||
### Sigma resolution
|
||||
|
||||
In `History::add_events` at `src/history.rs:735`:
|
||||
|
||||
```rust
|
||||
crate::Outcome::Scored { scores, sigma } => {
|
||||
let resolved = sigma.unwrap_or(self.score_sigma);
|
||||
debug_assert!(
|
||||
resolved > 0.0,
|
||||
"resolved score_sigma must be > 0.0 (got {resolved})"
|
||||
);
|
||||
kinds.push(EventKind::Scored {
|
||||
score_sigma: resolved,
|
||||
});
|
||||
scores.to_vec()
|
||||
}
|
||||
```
|
||||
|
||||
Resolution at ingest time means downstream code keeps a plain `f64`.
|
||||
No `Option` propagates further.
|
||||
|
||||
### Validation
|
||||
|
||||
- `Outcome::scores_with_sigma(_, sigma)` debug-asserts `sigma > 0.0`
|
||||
at construction.
|
||||
- `History::add_events` debug-asserts the resolved sigma is `> 0.0`
|
||||
(catches both inherited and overridden paths).
|
||||
- `HistoryBuilder::score_sigma(s)` keeps its existing positive
|
||||
assertion.
|
||||
|
||||
The default sigma at the History level (`1.0`) is positive, so an
|
||||
event with `sigma = None` against a default-built History always
|
||||
passes the resolved-sigma assertion trivially.
|
||||
|
||||
### Pattern-match update inventory
|
||||
|
||||
Every site that destructures `Outcome::Scored(_)` as a tuple needs
|
||||
updating. Known sites:
|
||||
|
||||
- `src/outcome.rs`: the `team_count()`, `as_scores()`, `as_ranks()`
|
||||
match arms (`src/outcome.rs:51`, `:58`, `:64`).
|
||||
- `src/history.rs:735`: the conversion arm (this is also where the
|
||||
resolution rule lands).
|
||||
- Any test in `src/outcome.rs` test mod that constructs
|
||||
`Outcome::Scored(...)` literally.
|
||||
- Any callsite in `src/`, `tests/`, `examples/`, `benches/`,
|
||||
`src/game.rs` that pattern-matches the variant.
|
||||
|
||||
The compiler surfaces every site at `cargo build`. Locating them is
|
||||
mechanical.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Regression net
|
||||
|
||||
Existing 100 lib + 27 integration tests are the bit-equal regression
|
||||
net for the `sigma = None` path. Every existing test that uses
|
||||
`Outcome::scores(...)` or `EventBuilder::scores(...)` should
|
||||
continue to produce identical posteriors — the resolved sigma equals
|
||||
the history default (which equals what the hardcoded path produced).
|
||||
|
||||
### New tests
|
||||
|
||||
Three additions in the `src/history.rs` test module:
|
||||
|
||||
1. **`outcome_scores_default_sigma_uses_history_default`** — build a
|
||||
History with `score_sigma(0.5)`, add a 2-team event via
|
||||
`Outcome::scores([3.0, 1.0])` (no override), capture posteriors.
|
||||
Build a second History identical except using
|
||||
`Outcome::scores_with_sigma([3.0, 1.0], 0.5)` (override matches
|
||||
default). Assert posteriors are bit-equal across the two paths.
|
||||
|
||||
2. **`outcome_scores_with_sigma_overrides_history_default`** — build a
|
||||
History with `score_sigma(0.5)`, add an event via
|
||||
`Outcome::scores_with_sigma([3.0, 1.0], 2.0)`. Build a second
|
||||
History with `score_sigma(2.0)` and add the same event via
|
||||
`Outcome::scores([3.0, 1.0])`. Assert posteriors are bit-equal.
|
||||
Then build a third History with `score_sigma(0.5)` and add via
|
||||
`Outcome::scores([3.0, 1.0])` (no override). Assert this third
|
||||
one's posteriors differ measurably from the override path
|
||||
(max diff > 1e-6) — proves the override actually changes
|
||||
inference.
|
||||
|
||||
3. **`event_builder_scores_with_sigma_threading`** — same shape as
|
||||
#2 but constructed via the fluent builder
|
||||
`h.event(0).team(["a"]).team(["b"]).scores_with_sigma([3.0, 1.0], 2.0).commit()`.
|
||||
Proves the builder method works end-to-end.
|
||||
|
||||
### Pattern-match update test impact
|
||||
|
||||
Existing tests in `src/outcome.rs` that construct
|
||||
`Outcome::Scored(...)` literally need updating to the struct shape.
|
||||
Mechanical change; no new tests required.
|
||||
|
||||
## Verification gates
|
||||
|
||||
```bash
|
||||
cargo +nightly fmt
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test --lib
|
||||
cargo test
|
||||
```
|
||||
|
||||
Test count grows by 3.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Public API breaking change.** `Outcome::Scored` variant shape
|
||||
changes from tuple to struct. Any downstream consumer
|
||||
pattern-matching on the tuple form breaks. In a 0.1.x crate this
|
||||
is acceptable; flag it in the commit message.
|
||||
- **Mechanical breadth.** The pattern-match updates touch several
|
||||
files. They're all caught by the compiler so the risk is low, but
|
||||
the diff will look bigger than the actual logical change.
|
||||
- **Two ways to do the same thing.** `Outcome::scores_with_sigma(..)`
|
||||
and `EventBuilder::scores_with_sigma(..)` both produce the same
|
||||
outcome. This is intentional — the constructor is the underlying
|
||||
primitive; the builder method is the ergonomic wrapper. Same
|
||||
pattern as the existing `Outcome::scores(..)` /
|
||||
`EventBuilder::scores(..)` pair.
|
||||
|
||||
## Out-of-scope follow-ups
|
||||
|
||||
- Per-event override of other config currently history-wide
|
||||
(`p_draw`, drift, beta) — same architectural pattern would apply
|
||||
but each is its own design decision.
|
||||
- Validation upgrade from `debug_assert!` to a `Result` at the
|
||||
Outcome construction boundary.
|
||||
- Schedule trait integration with `run_chain`, `Residual` schedule,
|
||||
`SynergyFactor` (still pending from the larger spec).
|
||||
@@ -0,0 +1,134 @@
|
||||
# Tech Debt Cleanup — Post-T4-MarginFactor
|
||||
|
||||
## Summary
|
||||
|
||||
Three small, independent cleanups left behind by the T4-MarginFactor merge
|
||||
(`8b53cac`). All three are pure code-shape or doc fixes. No public-API change,
|
||||
no numerics change, no new behavior.
|
||||
|
||||
This batch deliberately excludes the `DiffFactor` ↔ `BuiltinFactor` overlap
|
||||
collapse (architectural change kept separate) and per-event `score_sigma`
|
||||
override (a feature, not debt).
|
||||
|
||||
## Scope
|
||||
|
||||
### Item 1 — Deduplicate `Game::likelihoods` and `Game::likelihoods_scored`
|
||||
|
||||
**Current state.** `src/game.rs:236-371` and `src/game.rs:373-485` are 95-line
|
||||
near-duplicates of each other. They differ in exactly one block: the closure
|
||||
that maps a diff index to a `DiffFactor`. The ranked path builds
|
||||
`DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))` with `margin`/`tie`
|
||||
derived from `p_draw` and adjacent-result equality. The scored path builds
|
||||
`DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))` with `m_obs`
|
||||
the observed score gap. Everything else — sort, `team_prior`, sweep loop,
|
||||
boundary updates, evidence product, posterior `likelihoods` — is bit-identical.
|
||||
|
||||
**Refactor.** Extract a private helper on `OwnedGame<T, D>`:
|
||||
|
||||
```rust
|
||||
fn run_chain<F>(
|
||||
&self,
|
||||
arena: &mut ScratchArena,
|
||||
make_link: F,
|
||||
) -> (f64, Vec<Vec<Gaussian>>)
|
||||
where
|
||||
F: FnMut(usize, &[usize], &mut VarStore) -> DiffFactor,
|
||||
```
|
||||
|
||||
The closure receives the diff index `i`, the descending-by-result sort
|
||||
permutation `&arena.sort_buf`, and `&mut arena.vars` for `alloc(N_INF)`. It
|
||||
returns the `DiffFactor` for that diff slot.
|
||||
|
||||
The helper takes `&self` (not `&mut self`) and returns
|
||||
`(evidence, likelihoods)`. Each caller writes the results back to its own
|
||||
`self.evidence` and `self.likelihoods` fields. The `&self` choice matters: the
|
||||
closure captures `&self.result` / `&self.teams` / `&self.weights` / `&self.p_draw`
|
||||
freely without conflicting with the helper's own immutable borrow.
|
||||
|
||||
The two public methods shrink from ~125 lines each to ~10 lines that just
|
||||
construct the closure.
|
||||
|
||||
**Why a closure (not a trait or two-phase build).** A closure keeps all
|
||||
caller-specific state (`p_draw`, `score_sigma`, beta sums for margin) inline at
|
||||
the call site. A trait would require a stateful object per call; a two-phase
|
||||
build (caller produces the `Vec<DiffFactor>` first, helper does the rest) would
|
||||
either re-do the sort or split state ownership awkwardly between phases.
|
||||
|
||||
### Item 2 — Make `BuiltinFactor::log_evidence` exhaustive
|
||||
|
||||
**Current state.** `src/factor/mod.rs:94-100` uses a `_ => 0.0` wildcard for
|
||||
`TeamSum` and `RankDiff`. When a future variant lands (e.g. `SynergyFactor`),
|
||||
the wildcard silently absorbs it instead of forcing a deliberate decision.
|
||||
|
||||
**Refactor.**
|
||||
|
||||
```rust
|
||||
fn log_evidence(&self, vars: &VarStore) -> f64 {
|
||||
match self {
|
||||
Self::Trunc(f) => f.log_evidence(vars),
|
||||
Self::Margin(f) => f.log_evidence(vars),
|
||||
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No behavioral change. Future variants now produce a non-exhaustive-match
|
||||
compile error.
|
||||
|
||||
### Item 3 — Fix stale numerics in T4 plan doc
|
||||
|
||||
**Current state.** `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
|
||||
contains two numbers that diverge from the values asserted by the shipped test
|
||||
in `src/factor/mod.rs:163,166`.
|
||||
|
||||
**Fix.**
|
||||
|
||||
| Doc value (wrong) | Implementation value (correct) |
|
||||
|---|---|
|
||||
| `0.046827` | `0.04678` |
|
||||
| `-3.0613` | `-3.0622` |
|
||||
|
||||
Pure docs change. Verified by reading the asserted constants in the test.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **`DiffFactor` ↔ `BuiltinFactor` overlap.** Both enums list `Trunc` and
|
||||
`Margin` variants. Collapsing into `BuiltinFactor::Diff(DiffFactor)` is
|
||||
defensible but is an architectural change that wants its own design pass.
|
||||
`DiffFactor` represents a real semantic subset (factors that operate on a
|
||||
diff variable in a chain); the duplication is two enum variants, not a
|
||||
large block of code.
|
||||
- **Per-event `EventKind::Scored.score_sigma` override.** Today
|
||||
`score_sigma` is history-wide (set on `HistoryBuilder::score_sigma`). A
|
||||
per-event override is a real feature ask, not tech debt.
|
||||
|
||||
## Verification
|
||||
|
||||
Each item commits independently and ships behind a green `cargo test --lib`
|
||||
run. The dedup is a pure code-shape change: posteriors and evidence must be
|
||||
**bit-equal** (not ULP-bounded) against the existing 88+28 test goldens.
|
||||
|
||||
Per-item gate before committing:
|
||||
|
||||
```bash
|
||||
cargo +nightly fmt
|
||||
cargo clippy
|
||||
cargo test --lib
|
||||
```
|
||||
|
||||
## Commit shape
|
||||
|
||||
Three commits, one per item, each independently revertable:
|
||||
|
||||
1. `refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain`
|
||||
2. `refactor: make BuiltinFactor::log_evidence match exhaustive`
|
||||
3. `docs: fix stale numerics in t4-margin-factor plan`
|
||||
|
||||
## Risks
|
||||
|
||||
- **Borrow-checker friction in Item 1.** The closure captures fields of
|
||||
`&self` while the helper iterates over arena state. Mitigation: helper is
|
||||
`&self` (not `&mut self`); arena passed as `&mut ScratchArena` separately.
|
||||
Disjoint borrows.
|
||||
- **Compile error in Item 2 if a new variant ships before this lands.**
|
||||
Trivial follow-on; the whole point is to surface that signal.
|
||||
@@ -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.
|
||||
@@ -48,6 +48,7 @@ fn main() {
|
||||
.convergence(trueskill_tt::ConvergenceOptions {
|
||||
max_iter: 10,
|
||||
epsilon: 0.01,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
+6
-2
@@ -1,2 +1,6 @@
|
||||
publish = false
|
||||
pre-release-hook = ["sh", "-c", "git cliff -o ../CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"]
|
||||
# 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"]
|
||||
|
||||
+46
-11
@@ -26,39 +26,75 @@ pub(crate) struct ColorGroups {
|
||||
}
|
||||
|
||||
impl ColorGroups {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn new() -> Self {
|
||||
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 {
|
||||
self.groups.is_empty()
|
||||
}
|
||||
|
||||
/// Total event count across all colors.
|
||||
#[allow(dead_code)]
|
||||
/// Number of distinct colors in the partition. Test-only.
|
||||
#[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 {
|
||||
self.groups.iter().map(|g| g.len()).sum()
|
||||
}
|
||||
|
||||
/// Contiguous index range for one color after events have been reordered
|
||||
/// 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> {
|
||||
let group = &self.groups[color_idx];
|
||||
if group.is_empty() {
|
||||
return 0..0;
|
||||
}
|
||||
|
||||
let start = *group.first().unwrap();
|
||||
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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -67,7 +103,6 @@ impl ColorGroups {
|
||||
/// `Index` values that event touches. The returned `ColorGroups` has one
|
||||
/// inner `Vec<usize>` per color, containing event indices in the order
|
||||
/// they were assigned.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups
|
||||
where
|
||||
F: Fn(usize) -> I,
|
||||
|
||||
@@ -8,6 +8,16 @@ use smallvec::SmallVec;
|
||||
pub struct ConvergenceOptions {
|
||||
pub max_iter: usize,
|
||||
pub epsilon: f64,
|
||||
/// EP damping factor in natural-parameter space: each per-factor
|
||||
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
|
||||
/// undamped (default); `< 1.0` stabilises oscillating fixed-point
|
||||
/// loops at the cost of more iterations. Must be in `(0.0, 1.0]`.
|
||||
///
|
||||
/// Applies only to the within-game EP loop (`run_chain`). The outer
|
||||
/// `History::converge` cross-history sweep is undamped regardless of
|
||||
/// this value — cross-slice damping is a different concept and not
|
||||
/// in scope.
|
||||
pub alpha: f64,
|
||||
}
|
||||
|
||||
impl Default for ConvergenceOptions {
|
||||
@@ -15,6 +25,7 @@ impl Default for ConvergenceOptions {
|
||||
Self {
|
||||
max_iter: crate::ITERATIONS,
|
||||
epsilon: crate::EPSILON,
|
||||
alpha: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,3 +40,14 @@ pub struct ConvergenceReport {
|
||||
pub per_iteration_time: SmallVec<[Duration; 32]>,
|
||||
pub slices_skipped: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_alpha_is_one_for_undamped_behavior() {
|
||||
let opts = ConvergenceOptions::default();
|
||||
assert_eq!(opts.alpha, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum InferenceError {
|
||||
/// Expected and actual lengths of some array-shaped input differ.
|
||||
MismatchedShape {
|
||||
@@ -8,15 +9,35 @@ pub enum InferenceError {
|
||||
expected: 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]`.
|
||||
InvalidProbability { value: f64 },
|
||||
/// A scalar parameter is outside its valid range.
|
||||
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`.
|
||||
ConvergenceFailed {
|
||||
last_step: (f64, f64),
|
||||
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.
|
||||
NegativePrecision { pi: f64 },
|
||||
}
|
||||
@@ -31,9 +52,29 @@ impl fmt::Display for InferenceError {
|
||||
} => {
|
||||
write!(f, "{kind}: expected length {expected}, got {got}")
|
||||
}
|
||||
Self::WrongOutcomeKind {
|
||||
context,
|
||||
expected,
|
||||
got,
|
||||
} => {
|
||||
write!(f, "{context}: expected {expected}, got {got}")
|
||||
}
|
||||
Self::InvalidProbability { 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 } => {
|
||||
write!(f, "{name} is invalid: {value}")
|
||||
}
|
||||
|
||||
@@ -81,6 +81,15 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
/// Set explicit per-team continuous scores with a per-event noise override.
|
||||
///
|
||||
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
|
||||
/// Must be `> 0.0`; debug-asserts otherwise via `Outcome::scores_with_sigma`.
|
||||
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
|
||||
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
|
||||
self
|
||||
}
|
||||
|
||||
/// Mark team `winner_idx` as winner; others tied for last.
|
||||
pub fn winner(mut self, winner_idx: u32) -> Self {
|
||||
self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32);
|
||||
|
||||
+65
-7
@@ -32,8 +32,11 @@ impl MarginFactor {
|
||||
}
|
||||
}
|
||||
|
||||
impl Factor for MarginFactor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||
impl MarginFactor {
|
||||
/// Propagate this factor's message, optionally damping the update in
|
||||
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
|
||||
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
||||
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
||||
let marginal = vars.get(self.diff);
|
||||
let cavity = marginal / self.msg;
|
||||
|
||||
@@ -42,12 +45,18 @@ impl Factor for MarginFactor {
|
||||
}
|
||||
|
||||
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
|
||||
let new_marginal = cavity * new_msg;
|
||||
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||
let old_msg = self.msg;
|
||||
self.msg = new_msg;
|
||||
vars.set(self.diff, new_marginal);
|
||||
self.msg = damped;
|
||||
vars.set(self.diff, cavity * damped);
|
||||
|
||||
old_msg.delta(new_msg)
|
||||
old_msg.delta(damped)
|
||||
}
|
||||
}
|
||||
|
||||
impl Factor for MarginFactor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||
self.propagate_with_alpha(vars, 1.0)
|
||||
}
|
||||
|
||||
fn log_evidence(&self, _vars: &VarStore) -> f64 {
|
||||
@@ -55,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 {
|
||||
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)]
|
||||
@@ -120,4 +133,49 @@ mod tests {
|
||||
let logz = f.log_evidence(&vars);
|
||||
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_with_alpha_one_matches_undamped_propagate() {
|
||||
let mut vars_a = VarStore::new();
|
||||
let diff_a = vars_a.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||
let mut f_a = MarginFactor::new(diff_a, 5.0, 1.0);
|
||||
let delta_a = f_a.propagate(&mut vars_a);
|
||||
let result_a = vars_a.get(diff_a);
|
||||
|
||||
let mut vars_b = VarStore::new();
|
||||
let diff_b = vars_b.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||
let mut f_b = MarginFactor::new(diff_b, 5.0, 1.0);
|
||||
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
|
||||
let result_b = vars_b.get(diff_b);
|
||||
|
||||
assert_eq!(result_a.pi(), result_b.pi());
|
||||
assert_eq!(result_a.tau(), result_b.tau());
|
||||
assert_eq!(delta_a, delta_b);
|
||||
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
|
||||
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
|
||||
// Run undamped to capture (initial_msg, undamped_new_msg).
|
||||
let mut vars_full = VarStore::new();
|
||||
let diff_full = vars_full.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||
let mut f_full = MarginFactor::new(diff_full, 5.0, 1.0);
|
||||
let initial_msg_pi = f_full.msg.pi();
|
||||
let initial_msg_tau = f_full.msg.tau();
|
||||
f_full.propagate(&mut vars_full);
|
||||
let undamped_msg_pi = f_full.msg.pi();
|
||||
let undamped_msg_tau = f_full.msg.tau();
|
||||
|
||||
// Run damped at α = 0.5 from the same initial state.
|
||||
let mut vars_half = VarStore::new();
|
||||
let diff_half = vars_half.alloc(Gaussian::from_ms(0.0, 6.0));
|
||||
let mut f_half = MarginFactor::new(diff_half, 5.0, 1.0);
|
||||
f_half.propagate_with_alpha(&mut vars_half, 0.5);
|
||||
|
||||
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
|
||||
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
|
||||
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
|
||||
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ impl Factor for BuiltinFactor {
|
||||
match self {
|
||||
Self::Trunc(f) => f.log_evidence(vars),
|
||||
Self::Margin(f) => f.log_evidence(vars),
|
||||
_ => 0.0,
|
||||
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-13
@@ -33,29 +33,37 @@ impl TruncFactor {
|
||||
}
|
||||
}
|
||||
|
||||
impl Factor for TruncFactor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||
impl TruncFactor {
|
||||
/// Propagate this factor's message, optionally damping the update in
|
||||
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
|
||||
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
|
||||
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
|
||||
let marginal = vars.get(self.diff);
|
||||
// Cavity: marginal divided by our outgoing message.
|
||||
let cavity = marginal / self.msg;
|
||||
|
||||
// First-time-only: cache the evidence contribution from the cavity.
|
||||
if self.evidence_cached.is_none() {
|
||||
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
|
||||
}
|
||||
|
||||
// Apply the truncation approximation to the cavity.
|
||||
let trunc = approx(cavity, self.margin, self.tie);
|
||||
|
||||
// New outgoing message such that cavity * new_msg = trunc.
|
||||
let new_msg = trunc / cavity;
|
||||
|
||||
let damped = self.msg.damp_natural(new_msg, alpha);
|
||||
let old_msg = self.msg;
|
||||
self.msg = new_msg;
|
||||
self.msg = damped;
|
||||
|
||||
// Update the marginal: marginal_new = cavity * new_msg = trunc.
|
||||
vars.set(self.diff, trunc);
|
||||
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
|
||||
// `trunc` (since cavity * new_msg = trunc by construction); with
|
||||
// alpha < 1.0 it reflects the partially-applied update.
|
||||
vars.set(self.diff, cavity * damped);
|
||||
|
||||
old_msg.delta(new_msg)
|
||||
old_msg.delta(damped)
|
||||
}
|
||||
}
|
||||
|
||||
impl Factor for TruncFactor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
|
||||
self.propagate_with_alpha(vars, 1.0)
|
||||
}
|
||||
|
||||
fn log_evidence(&self, _vars: &VarStore) -> f64 {
|
||||
@@ -64,12 +72,20 @@ impl Factor for TruncFactor {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if tie {
|
||||
let raw = if tie {
|
||||
cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma())
|
||||
} else {
|
||||
1.0 - cdf(margin, diff.mu(), diff.sigma())
|
||||
}
|
||||
};
|
||||
|
||||
raw.clamp(f64::MIN_POSITIVE, 1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -127,4 +143,49 @@ mod tests {
|
||||
let ev = f.evidence_cached.unwrap();
|
||||
assert!(ev > 0.35 && ev < 0.42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_with_alpha_one_matches_undamped_propagate() {
|
||||
let mut vars_a = VarStore::new();
|
||||
let diff_a = vars_a.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||
let mut f_a = TruncFactor::new(diff_a, 0.0, false);
|
||||
let delta_a = f_a.propagate(&mut vars_a);
|
||||
let result_a = vars_a.get(diff_a);
|
||||
|
||||
let mut vars_b = VarStore::new();
|
||||
let diff_b = vars_b.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||
let mut f_b = TruncFactor::new(diff_b, 0.0, false);
|
||||
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
|
||||
let result_b = vars_b.get(diff_b);
|
||||
|
||||
assert_eq!(result_a.pi(), result_b.pi());
|
||||
assert_eq!(result_a.tau(), result_b.tau());
|
||||
assert_eq!(delta_a, delta_b);
|
||||
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
|
||||
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
|
||||
// Run undamped to capture (initial_msg, undamped_new_msg).
|
||||
let mut vars_full = VarStore::new();
|
||||
let diff_full = vars_full.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||
let mut f_full = TruncFactor::new(diff_full, 0.0, false);
|
||||
let initial_msg_pi = f_full.msg.pi();
|
||||
let initial_msg_tau = f_full.msg.tau();
|
||||
f_full.propagate(&mut vars_full);
|
||||
let undamped_msg_pi = f_full.msg.pi();
|
||||
let undamped_msg_tau = f_full.msg.tau();
|
||||
|
||||
// Run damped at α = 0.5 from the same initial state.
|
||||
let mut vars_half = VarStore::new();
|
||||
let diff_half = vars_half.alloc(Gaussian::from_ms(2.0, 3.0));
|
||||
let mut f_half = TruncFactor::new(diff_half, 0.0, false);
|
||||
f_half.propagate_with_alpha(&mut vars_half, 0.5);
|
||||
|
||||
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
|
||||
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
|
||||
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
|
||||
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
+276
-199
@@ -37,18 +37,28 @@ 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 {
|
||||
Self::Trunc(f) => f.evidence_cached.unwrap_or(1.0),
|
||||
Self::Margin(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).ln(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn propagate(&mut self, vars: &mut crate::factor::VarStore) -> (f64, f64) {
|
||||
use crate::factor::Factor;
|
||||
pub(crate) fn propagate(
|
||||
&mut self,
|
||||
vars: &mut crate::factor::VarStore,
|
||||
alpha: f64,
|
||||
) -> (f64, f64) {
|
||||
match self {
|
||||
Self::Trunc(f) => f.propagate(vars),
|
||||
Self::Margin(f) => f.propagate(vars),
|
||||
Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
|
||||
Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,17 +88,14 @@ impl Default for GameOptions {
|
||||
/// Owned variant of `Game` returned by public constructors.
|
||||
///
|
||||
/// 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
|
||||
/// be returned freely from public constructors.
|
||||
/// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
|
||||
/// can be returned freely from public constructors. The inference inputs
|
||||
/// themselves are not retained — nothing reads them back.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub struct OwnedGame<T: Time, D: Drift<T>> {
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
result: Vec<f64>,
|
||||
weights: Vec<Vec<f64>>,
|
||||
p_draw: f64,
|
||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
||||
pub(crate) evidence: f64,
|
||||
pub(crate) log_evidence: f64,
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
@@ -97,18 +104,21 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
result: Vec<f64>,
|
||||
weights: Vec<Vec<f64>>,
|
||||
p_draw: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
) -> Self {
|
||||
let mut arena = ScratchArena::new();
|
||||
let g = Game::ranked_with_arena(teams.clone(), &result, &weights, p_draw, &mut arena);
|
||||
let likelihoods = g.likelihoods;
|
||||
let evidence = g.evidence;
|
||||
let g = Game::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&result,
|
||||
&weights,
|
||||
p_draw,
|
||||
convergence,
|
||||
&mut arena,
|
||||
);
|
||||
Self {
|
||||
teams,
|
||||
result,
|
||||
weights,
|
||||
p_draw,
|
||||
likelihoods,
|
||||
evidence,
|
||||
likelihoods: g.likelihoods,
|
||||
log_evidence: g.log_evidence,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,18 +127,21 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
scores: Vec<f64>,
|
||||
weights: Vec<Vec<f64>>,
|
||||
score_sigma: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
) -> Self {
|
||||
let mut arena = ScratchArena::new();
|
||||
let g = Game::scored_with_arena(teams.clone(), &scores, &weights, score_sigma, &mut arena);
|
||||
let likelihoods = g.likelihoods;
|
||||
let evidence = g.evidence;
|
||||
let g = Game::scored_with_arena(
|
||||
teams.clone(),
|
||||
&scores,
|
||||
&weights,
|
||||
score_sigma,
|
||||
convergence,
|
||||
&mut arena,
|
||||
);
|
||||
Self {
|
||||
teams,
|
||||
result: scores,
|
||||
weights,
|
||||
p_draw: 0.0,
|
||||
likelihoods,
|
||||
evidence,
|
||||
likelihoods: g.likelihoods,
|
||||
log_evidence: g.log_evidence,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +154,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
}
|
||||
|
||||
pub fn log_evidence(&self) -> f64 {
|
||||
self.evidence.ln()
|
||||
self.log_evidence
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,8 +164,9 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||
result: &'a [f64],
|
||||
weights: &'a [Vec<f64>],
|
||||
p_draw: f64,
|
||||
pub(crate) convergence: crate::ConvergenceOptions,
|
||||
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> {
|
||||
@@ -161,6 +175,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
result: &'a [f64],
|
||||
weights: &'a [Vec<f64>],
|
||||
p_draw: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
arena: &mut ScratchArena,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
@@ -186,14 +201,19 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
},
|
||||
"draw must be > 0.0 if there are teams with draw"
|
||||
);
|
||||
debug_assert!(
|
||||
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
|
||||
"convergence alpha must be in (0.0, 1.0]"
|
||||
);
|
||||
|
||||
let mut this = Self {
|
||||
teams,
|
||||
result,
|
||||
weights,
|
||||
p_draw,
|
||||
convergence,
|
||||
likelihoods: Vec::new(),
|
||||
evidence: 0.0,
|
||||
log_evidence: 0.0,
|
||||
};
|
||||
|
||||
this.likelihoods(arena);
|
||||
@@ -205,6 +225,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
scores: &'a [f64],
|
||||
weights: &'a [Vec<f64>],
|
||||
score_sigma: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
arena: &mut ScratchArena,
|
||||
) -> Self {
|
||||
debug_assert!(
|
||||
@@ -219,26 +240,37 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
"weights must have the same dimensions as teams"
|
||||
);
|
||||
debug_assert!(score_sigma > 0.0, "score_sigma must be positive");
|
||||
debug_assert!(
|
||||
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
|
||||
"convergence alpha must be in (0.0, 1.0]"
|
||||
);
|
||||
|
||||
let mut this = Self {
|
||||
teams,
|
||||
result: scores,
|
||||
weights,
|
||||
p_draw: 0.0,
|
||||
convergence,
|
||||
likelihoods: Vec::new(),
|
||||
evidence: 0.0,
|
||||
log_evidence: 0.0,
|
||||
};
|
||||
|
||||
this.likelihoods_scored(arena, score_sigma);
|
||||
this
|
||||
}
|
||||
|
||||
fn likelihoods(&mut self, arena: &mut ScratchArena) {
|
||||
fn run_chain<F>(&self, arena: &mut ScratchArena, mut make_link: F) -> (f64, Vec<Vec<Gaussian>>)
|
||||
where
|
||||
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
|
||||
{
|
||||
arena.reset();
|
||||
|
||||
let alpha = self.convergence.alpha;
|
||||
let epsilon = self.convergence.epsilon;
|
||||
let max_iter = self.convergence.max_iter;
|
||||
|
||||
let n_teams = self.teams.len();
|
||||
|
||||
// Sort teams by result descending; reuse arena.sort_buf to avoid allocation.
|
||||
arena.sort_buf.extend(0..n_teams);
|
||||
arena.sort_buf.sort_by(|&i, &j| {
|
||||
self.result[j]
|
||||
@@ -246,7 +278,6 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
.unwrap_or(Ordering::Equal)
|
||||
});
|
||||
|
||||
// Team performance priors written into arena buffer (capacity reused across games).
|
||||
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
|
||||
self.teams[t]
|
||||
.iter()
|
||||
@@ -256,46 +287,25 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
|
||||
let n_diffs = n_teams.saturating_sub(1);
|
||||
|
||||
// One DiffFactor per adjacent sorted-team pair; each owns a diff VarId.
|
||||
// links stays local (fresh state per game; Vec capacity is typically small).
|
||||
let mut links: Vec<DiffFactor> = (0..n_diffs)
|
||||
.map(|i| {
|
||||
let tie = self.result[arena.sort_buf[i]] == self.result[arena.sort_buf[i + 1]];
|
||||
let margin = if self.p_draw == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let a: f64 = self.teams[arena.sort_buf[i]]
|
||||
.iter()
|
||||
.map(|p| p.beta.powi(2))
|
||||
.sum();
|
||||
let b: f64 = self.teams[arena.sort_buf[i + 1]]
|
||||
.iter()
|
||||
.map(|p| p.beta.powi(2))
|
||||
.sum();
|
||||
compute_margin(self.p_draw, (a + b).sqrt())
|
||||
};
|
||||
let vid = arena.vars.alloc(N_INF);
|
||||
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
|
||||
})
|
||||
.map(|i| make_link(i, &arena.sort_buf, &mut arena.vars))
|
||||
.collect();
|
||||
|
||||
// Per-team messages from neighbouring RankDiff factors (replaces TeamMessage).
|
||||
arena.lhood_lose.resize(n_teams, N_INF);
|
||||
arena.lhood_win.resize(n_teams, N_INF);
|
||||
|
||||
let mut step = (f64::INFINITY, f64::INFINITY);
|
||||
let mut iter = 0;
|
||||
|
||||
while tuple_gt(step, 1e-6) && iter < 10 {
|
||||
while tuple_gt(step, epsilon) && iter < max_iter {
|
||||
step = (0.0_f64, 0.0_f64);
|
||||
|
||||
// Forward sweep: diffs 0 .. n_diffs-2 (all but the last).
|
||||
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let d = lf.propagate(&mut arena.vars);
|
||||
let d = lf.propagate(&mut arena.vars, alpha);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_ll = pw - lf.msg();
|
||||
@@ -303,14 +313,13 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
arena.lhood_lose[e + 1] = new_ll;
|
||||
}
|
||||
|
||||
// Backward sweep: diffs n_diffs-1 .. 1 (reverse, all but the first).
|
||||
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
|
||||
let e = n_diffs - 1 - rev_i;
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let d = lf.propagate(&mut arena.vars);
|
||||
let d = lf.propagate(&mut arena.vars, alpha);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_lw = pl + lf.msg();
|
||||
@@ -326,7 +335,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
|
||||
- (arena.team_prior[1] * arena.lhood_win[1]);
|
||||
arena.vars.set(links[0].diff(), raw * links[0].msg());
|
||||
links[0].propagate(&mut arena.vars);
|
||||
links[0].propagate(&mut arena.vars, alpha);
|
||||
}
|
||||
|
||||
// Boundary updates: close the chain at both ends.
|
||||
@@ -337,8 +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();
|
||||
}
|
||||
|
||||
// Evidence = product of per-diff evidences (each cached on first propagation).
|
||||
self.evidence = 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.
|
||||
arena.inv_buf.resize(n_teams, 0);
|
||||
@@ -346,7 +354,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
arena.inv_buf[orig_i] = si;
|
||||
}
|
||||
|
||||
self.likelihoods = self
|
||||
let likelihoods = self
|
||||
.teams
|
||||
.iter()
|
||||
.zip(self.weights.iter())
|
||||
@@ -354,10 +362,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
.map(|(orig_i, (players, weights))| {
|
||||
let si = arena.inv_buf[orig_i];
|
||||
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
||||
let performance = players
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
|
||||
// Already folded into `team_prior` at the top of the chain,
|
||||
// indexed by sorted position.
|
||||
let performance = arena.team_prior[si];
|
||||
players
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
@@ -368,120 +375,38 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(log_evidence, likelihoods)
|
||||
}
|
||||
|
||||
fn likelihoods(&mut self, arena: &mut ScratchArena) {
|
||||
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 margin = if self.p_draw == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let a: f64 = self.teams[sort_buf[i]].iter().map(|p| p.beta.powi(2)).sum();
|
||||
let b: f64 = self.teams[sort_buf[i + 1]]
|
||||
.iter()
|
||||
.map(|p| p.beta.powi(2))
|
||||
.sum();
|
||||
compute_margin(self.p_draw, (a + b).sqrt())
|
||||
};
|
||||
let vid = vars.alloc(N_INF);
|
||||
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
|
||||
});
|
||||
self.log_evidence = log_evidence;
|
||||
self.likelihoods = likelihoods;
|
||||
}
|
||||
|
||||
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
|
||||
arena.reset();
|
||||
|
||||
let n_teams = self.teams.len();
|
||||
|
||||
arena.sort_buf.extend(0..n_teams);
|
||||
arena.sort_buf.sort_by(|&i, &j| {
|
||||
self.result[j]
|
||||
.partial_cmp(&self.result[i])
|
||||
.unwrap_or(Ordering::Equal)
|
||||
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 vid = vars.alloc(N_INF);
|
||||
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
|
||||
});
|
||||
|
||||
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
|
||||
self.teams[t]
|
||||
.iter()
|
||||
.zip(self.weights[t].iter())
|
||||
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
|
||||
}));
|
||||
|
||||
let n_diffs = n_teams.saturating_sub(1);
|
||||
|
||||
let mut links: Vec<DiffFactor> = (0..n_diffs)
|
||||
.map(|i| {
|
||||
// After descending-by-score sort, m_obs >= 0 for every adjacent pair.
|
||||
let m_obs = self.result[arena.sort_buf[i]] - self.result[arena.sort_buf[i + 1]];
|
||||
let vid = arena.vars.alloc(N_INF);
|
||||
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
|
||||
})
|
||||
.collect();
|
||||
|
||||
arena.lhood_lose.resize(n_teams, N_INF);
|
||||
arena.lhood_win.resize(n_teams, N_INF);
|
||||
|
||||
let mut step = (f64::INFINITY, f64::INFINITY);
|
||||
let mut iter = 0;
|
||||
|
||||
while tuple_gt(step, 1e-6) && iter < 10 {
|
||||
step = (0.0_f64, 0.0_f64);
|
||||
|
||||
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let d = lf.propagate(&mut arena.vars);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_ll = pw - lf.msg();
|
||||
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
|
||||
arena.lhood_lose[e + 1] = new_ll;
|
||||
}
|
||||
|
||||
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
|
||||
let e = n_diffs - 1 - rev_i;
|
||||
let pw = arena.team_prior[e] * arena.lhood_lose[e];
|
||||
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
|
||||
let raw = pw - pl;
|
||||
arena.vars.set(lf.diff(), raw * lf.msg());
|
||||
let d = lf.propagate(&mut arena.vars);
|
||||
step = tuple_max(step, d);
|
||||
|
||||
let new_lw = pl + lf.msg();
|
||||
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
|
||||
arena.lhood_win[e] = new_lw;
|
||||
}
|
||||
|
||||
iter += 1;
|
||||
}
|
||||
|
||||
if n_diffs == 1 {
|
||||
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
|
||||
- (arena.team_prior[1] * arena.lhood_win[1]);
|
||||
arena.vars.set(links[0].diff(), raw * links[0].msg());
|
||||
links[0].propagate(&mut arena.vars);
|
||||
}
|
||||
|
||||
if n_diffs > 0 {
|
||||
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
|
||||
arena.lhood_win[0] = pl1 + links[0].msg();
|
||||
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
|
||||
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
|
||||
}
|
||||
|
||||
self.evidence = links.iter().map(|l| l.evidence()).product();
|
||||
|
||||
arena.inv_buf.resize(n_teams, 0);
|
||||
for (si, &orig_i) in arena.sort_buf.iter().enumerate() {
|
||||
arena.inv_buf[orig_i] = si;
|
||||
}
|
||||
|
||||
self.likelihoods = self
|
||||
.teams
|
||||
.iter()
|
||||
.zip(self.weights.iter())
|
||||
.enumerate()
|
||||
.map(|(orig_i, (players, weights))| {
|
||||
let si = arena.inv_buf[orig_i];
|
||||
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
||||
let performance = players
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
|
||||
players
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(player, &w)| {
|
||||
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
|
||||
.forget(player.beta.powi(2))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.log_evidence = log_evidence;
|
||||
self.likelihoods = likelihoods;
|
||||
}
|
||||
|
||||
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||
@@ -498,7 +423,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
}
|
||||
|
||||
pub fn log_evidence(&self) -> f64 {
|
||||
self.evidence.ln()
|
||||
self.log_evidence
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,17 +448,34 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
|
||||
let ranks = outcome
|
||||
.as_ranks()
|
||||
.ok_or(crate::InferenceError::MismatchedShape {
|
||||
kind: "Game::ranked requires Outcome::Ranked",
|
||||
expected: 0,
|
||||
got: 0,
|
||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||
context: "Game::ranked",
|
||||
expected: "Outcome::Ranked",
|
||||
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 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 weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||
|
||||
Ok(OwnedGame::new(teams_owned, result, weights, options.p_draw))
|
||||
Ok(OwnedGame::new(
|
||||
teams_owned,
|
||||
result,
|
||||
weights,
|
||||
options.p_draw,
|
||||
options.convergence,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn scored(
|
||||
@@ -556,10 +498,10 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
}
|
||||
let scores = outcome
|
||||
.as_scores()
|
||||
.ok_or(crate::InferenceError::MismatchedShape {
|
||||
kind: "Game::scored requires Outcome::Scored",
|
||||
expected: 0,
|
||||
got: 0,
|
||||
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||
context: "Game::scored",
|
||||
expected: "Outcome::Scored",
|
||||
got: "Outcome::Ranked",
|
||||
})?
|
||||
.to_vec();
|
||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||
@@ -569,6 +511,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
scores,
|
||||
weights,
|
||||
options.score_sigma,
|
||||
options.convergence,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -630,6 +573,7 @@ mod tests {
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -657,6 +601,7 @@ mod tests {
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -676,6 +621,7 @@ mod tests {
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
|
||||
@@ -709,6 +655,7 @@ mod tests {
|
||||
&[1.0, 2.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -725,6 +672,7 @@ mod tests {
|
||||
&[2.0, 1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -736,7 +684,14 @@ mod tests {
|
||||
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::ranked_with_arena(teams, &[1.0, 2.0, 0.0], &w, 0.5, &mut ScratchArena::new());
|
||||
let g = Game::ranked_with_arena(
|
||||
teams,
|
||||
&[1.0, 2.0, 0.0],
|
||||
&w,
|
||||
0.5,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
|
||||
let a = p[0][0];
|
||||
@@ -768,6 +723,7 @@ mod tests {
|
||||
&[0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -775,8 +731,12 @@ mod tests {
|
||||
let a = p[0][0];
|
||||
let b = p[1][0];
|
||||
|
||||
assert_ulps_eq!(a, Gaussian::from_ms(24.999999, 6.469480), epsilon = 1e-6);
|
||||
assert_ulps_eq!(b, Gaussian::from_ms(24.999999, 6.469480), epsilon = 1e-6);
|
||||
// Two identical competitors drawing must land on their shared prior
|
||||
// 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(
|
||||
Gaussian::from_ms(25.0, 3.0),
|
||||
@@ -795,6 +755,7 @@ mod tests {
|
||||
&[0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -830,6 +791,7 @@ mod tests {
|
||||
&[0.0, 0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -866,6 +828,7 @@ mod tests {
|
||||
&[0.0, 0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -917,6 +880,7 @@ mod tests {
|
||||
&[1.0, 0.0, 0.0],
|
||||
&w,
|
||||
0.25,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -950,6 +914,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -974,6 +939,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -998,6 +964,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1025,6 +992,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1052,6 +1020,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1071,8 +1040,8 @@ mod tests {
|
||||
let mut t = DiffFactor::Trunc(TruncFactor::new(dt, 0.0, false));
|
||||
let mut m = DiffFactor::Margin(MarginFactor::new(dm, 5.0, 1.0));
|
||||
|
||||
let _ = t.propagate(&mut vars);
|
||||
let _ = m.propagate(&mut vars);
|
||||
let _ = t.propagate(&mut vars, 1.0);
|
||||
let _ = m.propagate(&mut vars, 1.0);
|
||||
|
||||
// Smoke: both diffs got written; their msgs are non-N_INF.
|
||||
assert!(t.msg().pi() > 0.0);
|
||||
@@ -1093,7 +1062,11 @@ mod tests {
|
||||
let weights = [vec![1.0], vec![1.0]];
|
||||
let mut arena = ScratchArena::new();
|
||||
let g = Game::scored_with_arena(
|
||||
teams, &result, &weights, 1.0, // score_sigma
|
||||
teams,
|
||||
&result,
|
||||
&weights,
|
||||
1.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut arena,
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1112,7 +1085,8 @@ mod tests {
|
||||
vec![vec![prior], vec![prior]],
|
||||
&result,
|
||||
&weights,
|
||||
0.1, // tighter score_sigma
|
||||
0.1,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut arena2,
|
||||
);
|
||||
let p_tight = g_tight.posteriors();
|
||||
@@ -1155,7 +1129,10 @@ mod tests {
|
||||
&GameOptions::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, crate::InferenceError::MismatchedShape { .. }));
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::InferenceError::WrongOutcomeKind { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1220,6 +1197,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1254,6 +1232,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1288,6 +1267,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1326,6 +1306,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let post_2vs1 = g.posteriors();
|
||||
@@ -1339,6 +1320,7 @@ mod tests {
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
);
|
||||
let p = g.posteriors();
|
||||
@@ -1348,4 +1330,99 @@ mod tests {
|
||||
assert_ulps_eq!(p[1][0], post_2vs1[1][0], epsilon = 1e-6);
|
||||
assert_ulps_eq!(p[1][1], t_b[1].prior, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_chain_honours_max_iter_in_convergence_options() {
|
||||
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
|
||||
let result = vec![3.0, 2.0, 1.0, 0.0];
|
||||
let weights = vec![vec![1.0]; 4];
|
||||
|
||||
// Capped at 1 iteration: cannot fully propagate down a 4-team chain.
|
||||
let mut arena = ScratchArena::new();
|
||||
let g_capped = Game::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&result,
|
||||
&weights,
|
||||
0.0,
|
||||
crate::ConvergenceOptions {
|
||||
max_iter: 1,
|
||||
..crate::ConvergenceOptions::default()
|
||||
},
|
||||
&mut arena,
|
||||
);
|
||||
let posteriors_capped = g_capped.posteriors();
|
||||
|
||||
// Same inputs, plenty of iterations: fully converged.
|
||||
let mut arena = ScratchArena::new();
|
||||
let g_full = Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&weights,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut arena,
|
||||
);
|
||||
let posteriors_full = g_full.posteriors();
|
||||
|
||||
// The two posteriors should differ — capped did not converge.
|
||||
let mut max_diff: f64 = 0.0;
|
||||
for (team_capped, team_full) in posteriors_capped.iter().zip(posteriors_full.iter()) {
|
||||
for (g_capped, g_full) in team_capped.iter().zip(team_full.iter()) {
|
||||
max_diff = max_diff.max((g_capped.mu() - g_full.mu()).abs());
|
||||
max_diff = max_diff.max((g_capped.sigma() - g_full.sigma()).abs());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
max_diff > 1e-6,
|
||||
"max_iter=1 should differ from full convergence; max_diff={max_diff}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_chain_with_damping_converges_to_same_posterior() {
|
||||
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
|
||||
let result = vec![3.0, 2.0, 1.0, 0.0];
|
||||
let weights = vec![vec![1.0]; 4];
|
||||
|
||||
let mut arena = ScratchArena::new();
|
||||
let g_undamped = Game::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&result,
|
||||
&weights,
|
||||
0.0,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut arena,
|
||||
);
|
||||
let posteriors_undamped = g_undamped.posteriors();
|
||||
|
||||
// alpha=0.5 with extra iterations: should reach the same fixed point.
|
||||
let mut arena = ScratchArena::new();
|
||||
let g_damped = Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&weights,
|
||||
0.0,
|
||||
crate::ConvergenceOptions {
|
||||
alpha: 0.5,
|
||||
max_iter: 100,
|
||||
..crate::ConvergenceOptions::default()
|
||||
},
|
||||
&mut arena,
|
||||
);
|
||||
let posteriors_damped = g_damped.posteriors();
|
||||
|
||||
let mut max_diff: f64 = 0.0;
|
||||
for (team_u, team_d) in posteriors_undamped.iter().zip(posteriors_damped.iter()) {
|
||||
for (g_u, g_d) in team_u.iter().zip(team_d.iter()) {
|
||||
max_diff = max_diff.max((g_u.mu() - g_d.mu()).abs());
|
||||
max_diff = max_diff.max((g_u.sigma() - g_d.sigma()).abs());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
max_diff < 1e-4,
|
||||
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+117
-15
@@ -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.
|
||||
#[inline]
|
||||
pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self {
|
||||
@@ -53,16 +75,38 @@ impl Gaussian {
|
||||
|
||||
#[inline]
|
||||
pub fn mu(&self) -> f64 {
|
||||
if self.pi == 0.0 {
|
||||
// A non-positive precision is an improper (uninformative) Gaussian — its mean is
|
||||
// undefined. Treat it like `pi == 0` and return 0. EP message cancellation can land
|
||||
// `pi` on a tiny negative value (round-off of exactly zero); without this guard
|
||||
// `tau / pi` would yield a spurious finite mean.
|
||||
if self.pi <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
self.tau / self.pi
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
pub fn sigma(&self) -> f64 {
|
||||
if self.pi == 0.0 {
|
||||
// A non-positive precision is improper → infinite standard deviation. Guarding
|
||||
// `pi <= 0.0` (not just `== 0.0`) keeps `1.0 / pi.sqrt()` from returning NaN when EP
|
||||
// cancellation produces a tiny negative precision (round-off of exactly zero).
|
||||
if self.pi <= 0.0 {
|
||||
f64::INFINITY
|
||||
} else if self.pi.is_infinite() {
|
||||
0.0
|
||||
@@ -79,22 +123,33 @@ impl Gaussian {
|
||||
}
|
||||
|
||||
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 {
|
||||
// When sigma_self ≈ sigma_other (including ULP-level rounding differences
|
||||
// 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
|
||||
// mu() = inf/inf = NaN. Returning N00 is correct: when both Gaussians
|
||||
// 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 {
|
||||
let var = self.sigma().powi(2) + variance_delta;
|
||||
Self::from_ms(self.mu(), var.sqrt())
|
||||
Self::from_mv(self.mu(), self.variance() + variance_delta)
|
||||
}
|
||||
|
||||
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
|
||||
///
|
||||
/// Used by within-game inference to stabilise oscillating fixed-point
|
||||
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
||||
/// `alpha < 1.0` shrinks each per-step update.
|
||||
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
||||
Gaussian::from_natural(
|
||||
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
||||
alpha * new.tau() + (1.0 - alpha) * self.tau(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,9 +164,7 @@ impl ops::Add<Gaussian> for Gaussian {
|
||||
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)).
|
||||
/// Used for combining performance and noise; rare relative to mul/div.
|
||||
fn add(self, rhs: Gaussian) -> Self::Output {
|
||||
let mu = self.mu() + rhs.mu();
|
||||
let var = self.sigma().powi(2) + rhs.sigma().powi(2);
|
||||
Self::from_ms(mu, var.sqrt())
|
||||
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +172,7 @@ impl ops::Sub<Gaussian> for Gaussian {
|
||||
type Output = Gaussian;
|
||||
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add.
|
||||
fn sub(self, rhs: Gaussian) -> Self::Output {
|
||||
let mu = self.mu() - rhs.mu();
|
||||
let var = self.sigma().powi(2) + rhs.sigma().powi(2);
|
||||
Self::from_ms(mu, var.sqrt())
|
||||
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +193,7 @@ impl ops::Mul<f64> for Gaussian {
|
||||
if scalar == 0.0 {
|
||||
// Scaling by 0 collapses to a point mass at 0 (sigma' = 0, mu' = 0).
|
||||
// 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²
|
||||
// mu' = mu * scalar => tau' = tau / scalar
|
||||
@@ -162,6 +213,28 @@ impl ops::Div<Gaussian> for Gaussian {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_positive_precision_is_improper_not_nan() {
|
||||
// EP message cancellation can leave `pi` a tiny negative (round-off of exactly zero).
|
||||
// Such a Gaussian is improper/uninformative: mu() must be 0 and sigma() infinite, not
|
||||
// NaN. A NaN here propagates through the moment-space `Sub` in the game chain and
|
||||
// poisons every skill in the slice.
|
||||
let tiny_neg = Gaussian::from_natural(-5.55e-17, -8.88e-16);
|
||||
assert_eq!(tiny_neg.mu(), 0.0);
|
||||
assert!(tiny_neg.sigma().is_infinite());
|
||||
|
||||
// A frankly-negative precision is treated the same way.
|
||||
let neg = Gaussian::from_natural(-1.0, 2.0);
|
||||
assert_eq!(neg.mu(), 0.0);
|
||||
assert!(neg.sigma().is_infinite());
|
||||
|
||||
// Subtracting such a message must not produce NaN (the original failure path).
|
||||
let proper = Gaussian::from_ms(9.75, 1.256);
|
||||
let diff = proper - tiny_neg;
|
||||
assert!(diff.pi().is_finite() && !diff.pi().is_nan());
|
||||
assert!(diff.tau().is_finite() && !diff.tau().is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add() {
|
||||
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
|
||||
@@ -231,4 +304,33 @@ mod tests {
|
||||
assert!((r.pi() - expected_pi).abs() < 1e-15);
|
||||
assert!((r.tau() - expected_tau).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damp_natural_alpha_one_returns_new() {
|
||||
let old = Gaussian::from_ms(1.0, 2.0);
|
||||
let new = Gaussian::from_ms(5.0, 0.5);
|
||||
let damped = old.damp_natural(new, 1.0);
|
||||
assert_eq!(damped.pi(), new.pi());
|
||||
assert_eq!(damped.tau(), new.tau());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damp_natural_alpha_zero_returns_self() {
|
||||
let old = Gaussian::from_ms(1.0, 2.0);
|
||||
let new = Gaussian::from_ms(5.0, 0.5);
|
||||
let damped = old.damp_natural(new, 0.0);
|
||||
assert_eq!(damped.pi(), old.pi());
|
||||
assert_eq!(damped.tau(), old.tau());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damp_natural_alpha_half_is_midpoint_in_natural_params() {
|
||||
let old = Gaussian::from_ms(1.0, 2.0);
|
||||
let new = Gaussian::from_ms(5.0, 0.5);
|
||||
let damped = old.damp_natural(new, 0.5);
|
||||
let expected_pi = 0.5 * new.pi() + 0.5 * old.pi();
|
||||
let expected_tau = 0.5 * new.tau() + 0.5 * old.tau();
|
||||
assert!((damped.pi() - expected_pi).abs() < 1e-12);
|
||||
assert!((damped.tau() - expected_tau).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
+472
-34
@@ -13,7 +13,7 @@ use crate::{
|
||||
sort_time,
|
||||
storage::CompetitorStore,
|
||||
time::Time,
|
||||
time_slice::{self, EventKind, TimeSlice},
|
||||
time_slice::{self, EventKind, FilteredStep, TimeSlice},
|
||||
tuple_gt, tuple_max,
|
||||
};
|
||||
|
||||
@@ -29,7 +29,6 @@ pub struct HistoryBuilder<
|
||||
beta: f64,
|
||||
drift: D,
|
||||
p_draw: f64,
|
||||
online: bool,
|
||||
score_sigma: f64,
|
||||
convergence: ConvergenceOptions,
|
||||
observer: O,
|
||||
@@ -60,7 +59,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
sigma: self.sigma,
|
||||
beta: self.beta,
|
||||
p_draw: self.p_draw,
|
||||
online: self.online,
|
||||
score_sigma: self.score_sigma,
|
||||
convergence: self.convergence,
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
pub fn online(mut self, online: bool) -> Self {
|
||||
self.online = online;
|
||||
self
|
||||
}
|
||||
|
||||
/// Default observation noise for scored outcomes.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `score_sigma` is not strictly positive.
|
||||
pub fn score_sigma(mut self, score_sigma: f64) -> Self {
|
||||
assert!(
|
||||
score_sigma > 0.0,
|
||||
@@ -88,7 +99,24 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -100,7 +128,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
beta: self.beta,
|
||||
drift: self.drift,
|
||||
p_draw: self.p_draw,
|
||||
online: self.online,
|
||||
score_sigma: self.score_sigma,
|
||||
convergence: self.convergence,
|
||||
observer,
|
||||
@@ -120,7 +147,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
beta: self.beta,
|
||||
drift: self.drift,
|
||||
p_draw: self.p_draw,
|
||||
online: self.online,
|
||||
score_sigma: self.score_sigma,
|
||||
convergence: self.convergence,
|
||||
observer: self.observer,
|
||||
@@ -136,7 +162,6 @@ impl Default for HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str>
|
||||
beta: BETA,
|
||||
drift: ConstantDrift(GAMMA),
|
||||
p_draw: P_DRAW,
|
||||
online: false,
|
||||
score_sigma: 1.0,
|
||||
convergence: ConvergenceOptions::default(),
|
||||
observer: NullObserver,
|
||||
@@ -161,7 +186,6 @@ pub struct History<
|
||||
beta: f64,
|
||||
drift: D,
|
||||
p_draw: f64,
|
||||
online: bool,
|
||||
score_sigma: f64,
|
||||
convergence: ConvergenceOptions,
|
||||
observer: O,
|
||||
@@ -188,7 +212,6 @@ impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
|
||||
beta: BETA,
|
||||
drift: ConstantDrift(GAMMA),
|
||||
p_draw: P_DRAW,
|
||||
online: false,
|
||||
score_sigma: 1.0,
|
||||
convergence: ConvergenceOptions::default(),
|
||||
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) {
|
||||
let mut step = (0.0, 0.0);
|
||||
|
||||
if self.time_slices.is_empty() {
|
||||
return step;
|
||||
}
|
||||
|
||||
competitor::clean(self.agents.values_mut(), false);
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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)>> {
|
||||
#[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()
|
||||
}
|
||||
|
||||
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")]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
let per_slice: Vec<f64> = self
|
||||
.time_slices
|
||||
.par_iter()
|
||||
.map(|ts| ts.log_evidence(self.online, targets, forward, &self.agents))
|
||||
.map(|ts| ts.log_evidence(targets, forward, agents))
|
||||
.collect();
|
||||
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
|
||||
.iter()
|
||||
.map(|ts| ts.log_evidence(self.online, targets, forward, &self.agents))
|
||||
.map(|ts| ts.log_evidence(targets, forward, agents))
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Total log-evidence across the history.
|
||||
pub fn log_evidence(&mut self) -> f64 {
|
||||
pub fn log_evidence(&self) -> f64 {
|
||||
self.log_evidence_internal(false, &[])
|
||||
}
|
||||
|
||||
/// Log-evidence restricted to time slices containing at least one of the
|
||||
/// 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
|
||||
K: std::borrow::Borrow<Q>,
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// 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 {
|
||||
let groups: Vec<Vec<Gaussian>> = teams
|
||||
.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;
|
||||
|
||||
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 i = 0;
|
||||
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());
|
||||
i += 1;
|
||||
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, &[]);
|
||||
self.observer.on_converged(i, step, converged);
|
||||
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);
|
||||
|
||||
let mut this_agent = Vec::with_capacity(1024);
|
||||
@@ -593,8 +781,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
agent.last_time = Some(t);
|
||||
agent.message = time_slice.forward_prior_out(&agent_idx);
|
||||
}
|
||||
|
||||
k += 1;
|
||||
} else {
|
||||
let mut time_slice = TimeSlice::new(t, self.p_draw);
|
||||
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
|
||||
time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents);
|
||||
|
||||
self.time_slices.insert(k, time_slice);
|
||||
@@ -732,9 +922,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
kinds.push(EventKind::Ranked);
|
||||
ranks.iter().map(|&r| max_rank - r as f64).collect()
|
||||
}
|
||||
crate::Outcome::Scored(scores) => {
|
||||
crate::Outcome::Scored { scores, sigma } => {
|
||||
let resolved = sigma.unwrap_or(self.score_sigma);
|
||||
if resolved <= 0.0 || resolved.is_nan() {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
name: "score_sigma",
|
||||
value: resolved,
|
||||
});
|
||||
}
|
||||
|
||||
kinds.push(EventKind::Scored {
|
||||
score_sigma: self.score_sigma,
|
||||
score_sigma: resolved,
|
||||
});
|
||||
scores.to_vec()
|
||||
}
|
||||
@@ -829,15 +1027,11 @@ mod tests {
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let p = Game::ranked_with_arena(
|
||||
h.time_slices[1].events[0].within_priors(
|
||||
false,
|
||||
false,
|
||||
&h.time_slices[1].skills,
|
||||
&h.agents,
|
||||
),
|
||||
h.time_slices[1].events[0].within_priors(false, &h.time_slices[1].skills, &h.agents),
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
P_DRAW,
|
||||
crate::ConvergenceOptions::default(),
|
||||
&mut ScratchArena::new(),
|
||||
)
|
||||
.posteriors();
|
||||
@@ -1073,11 +1267,11 @@ mod tests {
|
||||
let f = h.keys.get("f").unwrap();
|
||||
|
||||
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!(
|
||||
trueskill_log_evidence,
|
||||
trueskill_log_evidence_online,
|
||||
trueskill_log_evidence_forward,
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
@@ -1368,6 +1562,7 @@ mod tests {
|
||||
h.convergence = ConvergenceOptions {
|
||||
max_iter: 11,
|
||||
epsilon: EPSILON,
|
||||
alpha: 1.0,
|
||||
};
|
||||
h.converge().unwrap();
|
||||
|
||||
@@ -1685,6 +1880,7 @@ mod tests {
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
@@ -1711,4 +1907,246 @@ mod tests {
|
||||
fn history_builder_rejects_zero_score_sigma() {
|
||||
let _ = History::builder().score_sigma(0.0).build();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_propagates_convergence_to_inner_run_chain() {
|
||||
use crate::ConvergenceOptions;
|
||||
|
||||
let events_for =
|
||||
|h: &mut History<i64, ConstantDrift, crate::observer::NullObserver, &'static str>| {
|
||||
h.event(0)
|
||||
.team(["a"])
|
||||
.team(["b"])
|
||||
.team(["c"])
|
||||
.team(["d"])
|
||||
.ranking([0u32, 1, 2, 3])
|
||||
.commit()
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
let mut h_capped: History<i64, _, _, &'static str> = History::builder()
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 1,
|
||||
..ConvergenceOptions::default()
|
||||
})
|
||||
.build();
|
||||
events_for(&mut h_capped);
|
||||
h_capped.converge().unwrap();
|
||||
|
||||
let mut h_full: History<i64, _, _, &'static str> = History::builder().build();
|
||||
events_for(&mut h_full);
|
||||
h_full.converge().unwrap();
|
||||
|
||||
let curves_capped = h_capped.learning_curves();
|
||||
let curves_full = h_full.learning_curves();
|
||||
|
||||
let mut max_diff: f64 = 0.0;
|
||||
for (key, capped_pts) in curves_capped.iter() {
|
||||
let full_pts = curves_full.get(key).expect("agent missing in full");
|
||||
for (capped, full) in capped_pts.iter().zip(full_pts.iter()) {
|
||||
max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs());
|
||||
max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
max_diff > 1e-6,
|
||||
"max_iter=1 inner loop should differ from default; max_diff={max_diff}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_with_damping_reaches_same_fixed_point_as_undamped() {
|
||||
use crate::ConvergenceOptions;
|
||||
|
||||
let events_for =
|
||||
|h: &mut History<i64, ConstantDrift, crate::observer::NullObserver, &'static str>| {
|
||||
h.event(0)
|
||||
.team(["a"])
|
||||
.team(["b"])
|
||||
.team(["c"])
|
||||
.team(["d"])
|
||||
.ranking([0u32, 1, 2, 3])
|
||||
.commit()
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
let mut h_undamped: History<i64, _, _, &'static str> = History::builder().build();
|
||||
events_for(&mut h_undamped);
|
||||
h_undamped.converge().unwrap();
|
||||
|
||||
let mut h_damped: History<i64, _, _, &'static str> = History::builder()
|
||||
.convergence(ConvergenceOptions {
|
||||
alpha: 0.5,
|
||||
max_iter: 200,
|
||||
..ConvergenceOptions::default()
|
||||
})
|
||||
.build();
|
||||
events_for(&mut h_damped);
|
||||
h_damped.converge().unwrap();
|
||||
|
||||
let curves_u = h_undamped.learning_curves();
|
||||
let curves_d = h_damped.learning_curves();
|
||||
|
||||
let mut max_diff: f64 = 0.0;
|
||||
for (key, u_pts) in curves_u.iter() {
|
||||
let d_pts = curves_d.get(key).expect("agent missing in damped");
|
||||
for (u, d) in u_pts.iter().zip(d_pts.iter()) {
|
||||
max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs());
|
||||
max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
max_diff < 1e-3,
|
||||
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_scores_default_sigma_uses_history_default() {
|
||||
use crate::Outcome;
|
||||
|
||||
// Path A: explicit sigma=0.5 via override.
|
||||
let mut h_a = crate::History::builder().score_sigma(0.5).build();
|
||||
h_a.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores_with_sigma([3.0, 1.0], 0.5),
|
||||
}])
|
||||
.unwrap();
|
||||
h_a.converge().unwrap();
|
||||
|
||||
// Path B: history-wide default 0.5, no per-event override.
|
||||
let mut h_b = crate::History::builder().score_sigma(0.5).build();
|
||||
h_b.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
}])
|
||||
.unwrap();
|
||||
h_b.converge().unwrap();
|
||||
|
||||
// Inheritance: posteriors must be bit-equal.
|
||||
let curves_a = h_a.learning_curves();
|
||||
let curves_b = h_b.learning_curves();
|
||||
for (key, a_pts) in curves_a.iter() {
|
||||
let b_pts = curves_b.get(key).expect("agent missing in path B");
|
||||
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
|
||||
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
|
||||
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_scores_with_sigma_overrides_history_default() {
|
||||
use crate::Outcome;
|
||||
|
||||
// Path A: history-wide default 0.5, per-event override 2.0.
|
||||
let mut h_a = crate::History::builder().score_sigma(0.5).build();
|
||||
h_a.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
|
||||
}])
|
||||
.unwrap();
|
||||
h_a.converge().unwrap();
|
||||
|
||||
// Path B: history-wide default 2.0, no per-event override.
|
||||
let mut h_b = crate::History::builder().score_sigma(2.0).build();
|
||||
h_b.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
}])
|
||||
.unwrap();
|
||||
h_b.converge().unwrap();
|
||||
|
||||
// Override == default-set-to-the-override-value: bit-equal.
|
||||
let curves_a = h_a.learning_curves();
|
||||
let curves_b = h_b.learning_curves();
|
||||
for (key, a_pts) in curves_a.iter() {
|
||||
let b_pts = curves_b.get(key).expect("agent missing in path B");
|
||||
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
|
||||
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
|
||||
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Path C: history-wide default 0.5, no override. Different sigma → different posteriors.
|
||||
let mut h_c = crate::History::builder().score_sigma(0.5).build();
|
||||
h_c.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
}])
|
||||
.unwrap();
|
||||
h_c.converge().unwrap();
|
||||
|
||||
let curves_c = h_c.learning_curves();
|
||||
let mut max_diff: f64 = 0.0;
|
||||
for (key, a_pts) in curves_a.iter() {
|
||||
let c_pts = curves_c.get(key).expect("agent missing in path C");
|
||||
for (a, c) in a_pts.iter().zip(c_pts.iter()) {
|
||||
max_diff = max_diff.max((a.1.mu() - c.1.mu()).abs());
|
||||
max_diff = max_diff.max((a.1.sigma() - c.1.sigma()).abs());
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
max_diff > 1e-6,
|
||||
"override should produce different posteriors from inherited default; max_diff={max_diff}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_builder_scores_with_sigma_threading() {
|
||||
use crate::Outcome;
|
||||
|
||||
// Path A: builder fluent API with sigma override.
|
||||
let mut h_a = crate::History::builder().score_sigma(0.5).build();
|
||||
h_a.event(0_i64)
|
||||
.team(["a"])
|
||||
.team(["b"])
|
||||
.scores_with_sigma([3.0, 1.0], 2.0)
|
||||
.commit()
|
||||
.unwrap();
|
||||
h_a.converge().unwrap();
|
||||
|
||||
// Path B: same outcome via the explicit Outcome constructor.
|
||||
let mut h_b = crate::History::builder().score_sigma(0.5).build();
|
||||
h_b.add_events([crate::Event {
|
||||
time: 0_i64,
|
||||
teams: smallvec::smallvec![
|
||||
crate::Team::with_members([crate::Member::new("a")]),
|
||||
crate::Team::with_members([crate::Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
|
||||
}])
|
||||
.unwrap();
|
||||
h_b.converge().unwrap();
|
||||
|
||||
let curves_a = h_a.learning_curves();
|
||||
let curves_b = h_b.learning_curves();
|
||||
for (key, a_pts) in curves_a.iter() {
|
||||
let b_pts = curves_b.get(key).expect("agent missing");
|
||||
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
|
||||
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
|
||||
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-15
@@ -12,59 +12,68 @@ use crate::Index;
|
||||
/// crate. Power users can promote `&K` to `Index` via `get_or_create` and
|
||||
/// skip the lookup on subsequent hot-path calls.
|
||||
#[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>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
K: Eq + Hash + Clone,
|
||||
{
|
||||
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>
|
||||
where
|
||||
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
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
{
|
||||
if let Some(idx) = self.0.get(k) {
|
||||
if let Some(idx) = self.forward.get(k) {
|
||||
*idx
|
||||
} else {
|
||||
let idx = Index::from(self.0.len());
|
||||
self.0.insert(k.to_owned(), idx);
|
||||
let idx = Index::from(self.reverse.len());
|
||||
let owned = k.to_owned();
|
||||
self.reverse.push(owned.clone());
|
||||
self.forward.insert(owned, idx);
|
||||
idx
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self, idx: Index) -> Option<&K> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|&(_, value)| *value == idx)
|
||||
.map(|(key, _)| key)
|
||||
self.reverse.get(idx.0)
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> impl Iterator<Item = &K> {
|
||||
self.0.keys()
|
||||
self.forward.keys()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
self.reverse.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
self.reverse.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> Default for KeyTable<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
K: Eq + Hash + Clone,
|
||||
{
|
||||
fn default() -> Self {
|
||||
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::{
|
||||
cmp::Reverse,
|
||||
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 game::{Game, GameOptions, OwnedGame};
|
||||
pub use gaussian::Gaussian;
|
||||
pub use history::History;
|
||||
pub use history::{History, HistoryBuilder};
|
||||
pub use key_table::KeyTable;
|
||||
use matrix::Matrix;
|
||||
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)]
|
||||
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 {
|
||||
fn from(ix: usize) -> Self {
|
||||
Self(ix)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Index> for usize {
|
||||
fn from(idx: Index) -> Self {
|
||||
idx.0
|
||||
}
|
||||
}
|
||||
|
||||
fn erfc(x: f64) -> f64 {
|
||||
let z = x.abs();
|
||||
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
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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
|
||||
///
|
||||
/// 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 {
|
||||
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
|
||||
.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);
|
||||
|
||||
// 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 x = 0;
|
||||
|
||||
for (row, group) in rating_groups.windows(2).enumerate() {
|
||||
let current = group[0];
|
||||
@@ -230,17 +406,13 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
||||
|
||||
for n in t..t + current.len() {
|
||||
rotated_a_matrix[(row, n)] = flatten_weights[n];
|
||||
|
||||
x += 1;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
x += next.len();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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)]
|
||||
pub struct Matrix {
|
||||
data: Box<[f64]>,
|
||||
@@ -31,6 +15,107 @@ pub struct Matrix {
|
||||
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 {
|
||||
pub fn new(height: usize, width: usize) -> Matrix {
|
||||
Matrix {
|
||||
@@ -52,73 +137,59 @@ impl Matrix {
|
||||
matrix
|
||||
}
|
||||
|
||||
pub fn minor(&self, row_n: usize, col_n: usize) -> Matrix {
|
||||
let mut matrix = Matrix::new(self.height - 1, self.width - 1);
|
||||
|
||||
let mut nr = 0;
|
||||
|
||||
for r in 0..self.height {
|
||||
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
|
||||
}
|
||||
|
||||
/// Determinant of a square matrix. The 0×0 determinant is 1 by convention
|
||||
/// (the empty product).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the matrix is not square.
|
||||
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 {
|
||||
debug_assert!(self.width == self.height);
|
||||
/// Matrix inverse via LU decomposition.
|
||||
///
|
||||
/// # 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 {
|
||||
matrix[(0, 0)] = self[(1, 1)];
|
||||
matrix[(0, 1)] = -self[(0, 1)];
|
||||
matrix[(1, 0)] = -self[(1, 0)];
|
||||
matrix[(1, 1)] = self[(0, 0)];
|
||||
} else {
|
||||
for r in 0..matrix.height {
|
||||
for c in 0..matrix.width {
|
||||
let sign = if (r + c) % 2 == 0 { 1.0 } else { -1.0 };
|
||||
if n == 0 {
|
||||
return inverse;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
inverse
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,20 +197,62 @@ impl ops::Index<(usize, usize)> for Matrix {
|
||||
type Output = f64;
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::IndexMut<(usize, usize)> for Matrix {
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
fn mul(self, rhs: &'a Matrix) -> Matrix {
|
||||
fn mul(self, rhs: &Matrix) -> Matrix {
|
||||
let mut matrix = Matrix::new(rhs.height, rhs.width);
|
||||
|
||||
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;
|
||||
|
||||
fn mul(self, rhs: &'a Matrix) -> Matrix {
|
||||
let mut matrix = Matrix::new(self.height, rhs.width);
|
||||
|
||||
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
|
||||
fn mul(self, rhs: &Matrix) -> Matrix {
|
||||
multiply(&self, rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ops::Mul<&'a Matrix> for &'a Matrix {
|
||||
impl ops::Mul<&Matrix> for &Matrix {
|
||||
type Output = Matrix;
|
||||
|
||||
fn mul(self, rhs: &'a Matrix) -> Matrix {
|
||||
let mut matrix = Matrix::new(self.height, rhs.width);
|
||||
|
||||
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
|
||||
fn mul(self, rhs: &Matrix) -> Matrix {
|
||||
multiply(self, rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ops::Add<&'a Matrix> for &'a Matrix {
|
||||
impl ops::Add<&Matrix> for &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);
|
||||
|
||||
for r in 0..matrix.height {
|
||||
@@ -211,3 +305,106 @@ impl<'a> ops::Add<&'a Matrix> for &'a 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)]);
|
||||
}
|
||||
}
|
||||
|
||||
+70
-10
@@ -1,7 +1,7 @@
|
||||
//! Outcome of a match.
|
||||
//!
|
||||
//! `Ranked(ranks)` for ordinal results; `Scored(scores)` for continuous
|
||||
//! per-team scores (engages `MarginFactor` in the engine).
|
||||
//! `Ranked(ranks)` for ordinal results; `Scored { scores, sigma }` for
|
||||
//! continuous per-team scores (engages `MarginFactor` in the engine).
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
@@ -10,14 +10,20 @@ use smallvec::SmallVec;
|
||||
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
|
||||
/// teams. `ranks.len()` must equal the number of teams in the event.
|
||||
///
|
||||
/// `Scored(scores)`: higher score = better. Adjacent (sorted) pairs feed
|
||||
/// observed margins to `MarginFactor`. `scores.len()` must equal the number
|
||||
/// of teams in the event.
|
||||
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
|
||||
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
|
||||
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
|
||||
/// when `Some`; `None` inherits the history default.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Outcome {
|
||||
Ranked(SmallVec<[u32; 4]>),
|
||||
Scored(SmallVec<[f64; 4]>),
|
||||
Scored {
|
||||
scores: SmallVec<[f64; 4]>,
|
||||
/// Per-event noise override. `None` means inherit
|
||||
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
|
||||
sigma: Option<f64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
@@ -41,27 +47,44 @@ impl Outcome {
|
||||
}
|
||||
|
||||
/// Explicit per-team continuous scores; higher = better.
|
||||
/// Inherits `HistoryBuilder::score_sigma` for the noise model.
|
||||
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
|
||||
Self::Scored(scores.into_iter().collect())
|
||||
Self::Scored {
|
||||
scores: scores.into_iter().collect(),
|
||||
sigma: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit per-team continuous scores with a per-event noise override.
|
||||
///
|
||||
/// `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 {
|
||||
Self::Scored {
|
||||
scores: scores.into_iter().collect(),
|
||||
sigma: Some(sigma),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn team_count(&self) -> usize {
|
||||
match self {
|
||||
Self::Ranked(r) => r.len(),
|
||||
Self::Scored(s) => s.len(),
|
||||
Self::Scored { scores, .. } => scores.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
|
||||
match self {
|
||||
Self::Ranked(r) => Some(r),
|
||||
Self::Scored(_) => None,
|
||||
Self::Scored { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
|
||||
match self {
|
||||
Self::Scored(s) => Some(s),
|
||||
Self::Scored { scores, .. } => Some(scores),
|
||||
Self::Ranked(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -122,4 +145,41 @@ mod tests {
|
||||
assert!(o.as_scores().is_none());
|
||||
assert!(o.as_ranks().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scores_with_sigma_round_trips() {
|
||||
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5);
|
||||
assert_eq!(o.team_count(), 2);
|
||||
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scores_constructor_leaves_sigma_unset() {
|
||||
let o = Outcome::scores([3.0, 1.0]);
|
||||
match o {
|
||||
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
|
||||
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scores_with_sigma_sets_sigma_some() {
|
||||
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
|
||||
match o {
|
||||
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)),
|
||||
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn scores_with_sigma_defers_validation_to_ingestion() {
|
||||
let o = 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 {
|
||||
self.prior.forget(self.beta.powi(2))
|
||||
}
|
||||
|
||||
+31
-5
@@ -32,8 +32,17 @@ pub struct EpsilonOrMax {
|
||||
|
||||
impl Default for EpsilonOrMax {
|
||||
fn default() -> Self {
|
||||
// Matches today's hard-coded tolerance and iteration cap.
|
||||
Self { eps: 1e-6, max: 10 }
|
||||
// Derived from `ConvergenceOptions` so there is one source of truth for
|
||||
// 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 final_step = (f64::INFINITY, f64::INFINITY);
|
||||
let mut converged = false;
|
||||
// With no iterating factors the graph is already at its fixed point:
|
||||
// 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() {
|
||||
final_step = (f64::INFINITY, f64::INFINITY);
|
||||
converged = false;
|
||||
for _ in 0..self.max {
|
||||
let mut step = (0.0_f64, 0.0_f64);
|
||||
|
||||
@@ -113,7 +128,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 out = vars.alloc(N_INF);
|
||||
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
|
||||
@@ -122,5 +138,15 @@ mod tests {
|
||||
})];
|
||||
let report = EpsilonOrMax::default().run(&mut factors, &mut vars);
|
||||
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> {
|
||||
if idx.0 < self.present.len() && self.present[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)> {
|
||||
self.present.iter().enumerate().filter_map(|(i, &p)| {
|
||||
if p {
|
||||
|
||||
+281
-102
@@ -14,7 +14,6 @@ use crate::{
|
||||
rating::Rating,
|
||||
storage::{CompetitorStore, SkillStore},
|
||||
time::Time,
|
||||
tuple_gt, tuple_max,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -23,7 +22,6 @@ pub(crate) struct Skill {
|
||||
backward: Gaussian,
|
||||
likelihood: Gaussian,
|
||||
pub(crate) elapsed: i64,
|
||||
pub(crate) online: Gaussian,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
@@ -39,7 +37,6 @@ impl Default for Skill {
|
||||
backward: N_INF,
|
||||
likelihood: N_INF,
|
||||
elapsed: 0,
|
||||
online: N_INF,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +48,7 @@ pub enum EventKind {
|
||||
Scored { score_sigma: f64 },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
struct Item {
|
||||
agent: Index,
|
||||
likelihood: Gaussian,
|
||||
@@ -60,7 +57,6 @@ struct Item {
|
||||
impl Item {
|
||||
fn within_prior<T: Time, D: Drift<T>>(
|
||||
&self,
|
||||
online: bool,
|
||||
forward: bool,
|
||||
skills: &SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
@@ -68,9 +64,7 @@ impl Item {
|
||||
let r = &agents[self.agent].rating;
|
||||
let skill = skills.get(self.agent).unwrap();
|
||||
|
||||
if online {
|
||||
Rating::new(skill.online, r.beta, r.drift)
|
||||
} else if forward {
|
||||
if forward {
|
||||
Rating::new(skill.forward, r.beta, r.drift)
|
||||
} else {
|
||||
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
|
||||
@@ -78,16 +72,16 @@ impl Item {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
struct Team {
|
||||
items: Vec<Item>,
|
||||
output: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Event {
|
||||
teams: Vec<Team>,
|
||||
evidence: f64,
|
||||
log_evidence: f64,
|
||||
weights: Vec<Vec<f64>>,
|
||||
kind: EventKind,
|
||||
}
|
||||
@@ -108,7 +102,6 @@ impl Event {
|
||||
|
||||
pub(crate) fn within_priors<T: Time, D: Drift<T>>(
|
||||
&self,
|
||||
online: bool,
|
||||
forward: bool,
|
||||
skills: &SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
@@ -118,66 +111,125 @@ impl Event {
|
||||
.map(|team| {
|
||||
team.items
|
||||
.iter()
|
||||
.map(|item| item.within_prior(online, forward, skills, agents))
|
||||
.map(|item| item.within_prior(forward, skills, agents))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Direct in-loop update: mutates self and `skills` inline with no
|
||||
/// intermediate allocation. Used by both the sequential sweep path and,
|
||||
/// via unsafe, by the parallel rayon path for events in the same color
|
||||
/// group (which have disjoint agent sets — see `sweep_color_groups`).
|
||||
/// Run inference for this event and return its per-item likelihoods.
|
||||
///
|
||||
/// Reads `skills` immutably and does not touch `self`, so every event in
|
||||
/// a color group can run concurrently without any aliasing question —
|
||||
/// the mutation is deferred to `apply`.
|
||||
fn compute<T: Time, D: Drift<T>>(
|
||||
&self,
|
||||
skills: &SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
p_draw: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
arena: &mut ScratchArena,
|
||||
) -> EventUpdate {
|
||||
let teams = self.within_priors(false, skills, agents);
|
||||
let result = self.outputs();
|
||||
let g = match self.kind {
|
||||
EventKind::Ranked => {
|
||||
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, convergence, arena)
|
||||
}
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&self.weights,
|
||||
score_sigma,
|
||||
convergence,
|
||||
arena,
|
||||
),
|
||||
};
|
||||
|
||||
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 (i, item) in team.items.iter_mut().enumerate() {
|
||||
let fresh = update.likelihoods[t][i];
|
||||
let old_likelihood = skills.get(item.agent).unwrap().likelihood;
|
||||
let new_likelihood = (old_likelihood / item.likelihood) * fresh;
|
||||
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
||||
item.likelihood = fresh;
|
||||
}
|
||||
}
|
||||
|
||||
self.log_evidence = update.log_evidence;
|
||||
}
|
||||
|
||||
/// Compute and apply in one step — the sequential sweep.
|
||||
fn iteration_direct<T: Time, D: Drift<T>>(
|
||||
&mut self,
|
||||
skills: &mut SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
p_draw: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
arena: &mut ScratchArena,
|
||||
) {
|
||||
let teams = self.within_priors(false, false, skills, agents);
|
||||
let result = self.outputs();
|
||||
let g = match self.kind {
|
||||
EventKind::Ranked => {
|
||||
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, arena)
|
||||
}
|
||||
EventKind::Scored { score_sigma } => {
|
||||
Game::scored_with_arena(teams, &result, &self.weights, score_sigma, arena)
|
||||
}
|
||||
};
|
||||
|
||||
for (t, team) in self.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
let old_likelihood = skills.get(item.agent).unwrap().likelihood;
|
||||
let new_likelihood = (old_likelihood / item.likelihood) * g.likelihoods[t][i];
|
||||
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
||||
item.likelihood = g.likelihoods[t][i];
|
||||
}
|
||||
}
|
||||
|
||||
self.evidence = g.evidence;
|
||||
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)]
|
||||
pub struct TimeSlice<T: Time = i64> {
|
||||
pub(crate) events: Vec<Event>,
|
||||
pub(crate) skills: SkillStore,
|
||||
pub(crate) time: T,
|
||||
p_draw: f64,
|
||||
pub(crate) convergence: crate::ConvergenceOptions,
|
||||
arena: ScratchArena,
|
||||
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> {
|
||||
pub fn new(time: T, p_draw: f64) -> Self {
|
||||
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
|
||||
Self {
|
||||
events: Vec::new(),
|
||||
skills: SkillStore::new(),
|
||||
time,
|
||||
p_draw,
|
||||
convergence,
|
||||
arena: ScratchArena::new(),
|
||||
color_groups: ColorGroups::new(),
|
||||
color_groups_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +242,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
let n = self.events.len();
|
||||
if n == 0 {
|
||||
self.color_groups = ColorGroups::new();
|
||||
self.color_groups_dirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,6 +266,12 @@ impl<T: Time> TimeSlice<T> {
|
||||
|
||||
self.events = reordered;
|
||||
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>>(
|
||||
@@ -246,8 +305,9 @@ impl<T: Time> TimeSlice<T> {
|
||||
*idx,
|
||||
Skill {
|
||||
forward: agents[*idx].receive(&self.time),
|
||||
backward: N_INF,
|
||||
likelihood: N_INF,
|
||||
elapsed,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -288,7 +348,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
|
||||
Event {
|
||||
teams,
|
||||
evidence: 0.0,
|
||||
log_evidence: 0.0,
|
||||
weights,
|
||||
kind: kinds[e],
|
||||
}
|
||||
@@ -298,8 +358,9 @@ impl<T: Time> TimeSlice<T> {
|
||||
|
||||
self.events.extend(events);
|
||||
|
||||
self.color_groups_dirty = true;
|
||||
|
||||
self.iteration(from, agents);
|
||||
self.recompute_color_groups();
|
||||
}
|
||||
|
||||
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
|
||||
@@ -310,10 +371,14 @@ impl<T: Time> TimeSlice<T> {
|
||||
}
|
||||
|
||||
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() {
|
||||
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
|
||||
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 g = match event.kind {
|
||||
@@ -322,6 +387,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
self.convergence,
|
||||
&mut self.arena,
|
||||
),
|
||||
EventKind::Scored { score_sigma } => Game::scored_with_arena(
|
||||
@@ -329,6 +395,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
&result,
|
||||
&event.weights,
|
||||
score_sigma,
|
||||
self.convergence,
|
||||
&mut self.arena,
|
||||
),
|
||||
};
|
||||
@@ -343,7 +410,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
}
|
||||
}
|
||||
|
||||
event.evidence = g.evidence;
|
||||
event.log_evidence = g.log_evidence;
|
||||
}
|
||||
} else {
|
||||
self.sweep_color_groups(agents);
|
||||
@@ -353,14 +420,13 @@ impl<T: Time> TimeSlice<T> {
|
||||
/// Full event sweep using the color-group partition. Colors are processed
|
||||
/// sequentially; within each color the inner loop is parallel under rayon.
|
||||
///
|
||||
/// Events within each color group touch disjoint agent sets (guaranteed by
|
||||
/// the greedy coloring). This lets each rayon thread write directly to its
|
||||
/// events' skill likelihoods without a deferred-apply step, matching the
|
||||
/// sequential path's allocation profile. The unsafe block is sound because:
|
||||
/// 1. `self.events[range]` and `self.skills` are separate fields → disjoint.
|
||||
/// 2. Events in the same color group access disjoint `Index` values in
|
||||
/// `self.skills`, so concurrent writes land on different memory locations.
|
||||
/// 3. Each event only writes to its own items' likelihoods (no sharing).
|
||||
/// Events in one color group touch disjoint agent sets, so none of them
|
||||
/// can observe another's writes. That makes the sweep separable: inference
|
||||
/// runs concurrently over shared `&self.skills`, and the resulting updates
|
||||
/// are folded in afterwards in index order. Splitting it this way needs no
|
||||
/// `unsafe` and no aliasing argument, and it keeps results bit-identical
|
||||
/// across thread counts because the apply order does not depend on which
|
||||
/// worker finished first.
|
||||
#[cfg(feature = "rayon")]
|
||||
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
use rayon::prelude::*;
|
||||
@@ -380,31 +446,37 @@ impl<T: Time> TimeSlice<T> {
|
||||
if group_len == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let range = self.color_groups.color_range(color_idx);
|
||||
let p_draw = self.p_draw;
|
||||
let convergence = self.convergence;
|
||||
|
||||
if group_len >= RAYON_THRESHOLD {
|
||||
// Obtain a raw pointer from the unique `&mut self.skills` reference.
|
||||
// Casting back to `&mut` inside the closure is sound because:
|
||||
// 1. The pointer originates from a `&mut` — no aliasing with shared refs.
|
||||
// 2. Events in the same color group touch disjoint `Index` slots in the
|
||||
// underlying Vec, so concurrent writes from different threads land on
|
||||
// different memory locations — no data race.
|
||||
// 3. `self.events[range]` and `self.skills` are separate struct fields,
|
||||
// so the borrow splits cleanly.
|
||||
let skills_addr: usize = (&mut self.skills as *mut SkillStore) as usize;
|
||||
self.events[range].par_iter_mut().for_each(move |ev| {
|
||||
// SAFETY: see above.
|
||||
let skills: &mut SkillStore = unsafe { &mut *(skills_addr as *mut SkillStore) };
|
||||
ARENA.with(|cell| {
|
||||
let mut arena = cell.borrow_mut();
|
||||
arena.reset();
|
||||
ev.iteration_direct(skills, agents, p_draw, &mut arena);
|
||||
});
|
||||
});
|
||||
let skills = &self.skills;
|
||||
let updates: Vec<EventUpdate> = self.events[range.clone()]
|
||||
.par_iter()
|
||||
.map(|ev| {
|
||||
ARENA.with(|cell| {
|
||||
let mut arena = cell.borrow_mut();
|
||||
arena.reset();
|
||||
|
||||
ev.compute(skills, agents, p_draw, convergence, &mut arena)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (ev, update) in self.events[range].iter_mut().zip(updates) {
|
||||
ev.apply(&mut self.skills, update);
|
||||
}
|
||||
} else {
|
||||
for ev in &mut self.events[range] {
|
||||
ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);
|
||||
ev.iteration_direct(
|
||||
&mut self.skills,
|
||||
agents,
|
||||
p_draw,
|
||||
self.convergence,
|
||||
&mut self.arena,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,20 +498,40 @@ impl<T: Time> TimeSlice<T> {
|
||||
// allowed within a single method body.
|
||||
let p_draw = self.p_draw;
|
||||
for ev in &mut self.events[range] {
|
||||
ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);
|
||||
ev.iteration_direct(
|
||||
&mut self.skills,
|
||||
agents,
|
||||
p_draw,
|
||||
self.convergence,
|
||||
&mut self.arena,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
|
||||
let epsilon = 1e-6;
|
||||
let iterations = 20;
|
||||
/// 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>>(
|
||||
&mut self,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) -> usize {
|
||||
use crate::{tuple_gt, tuple_max};
|
||||
|
||||
let epsilon = self.convergence.epsilon;
|
||||
let max_iter = self.convergence.max_iter;
|
||||
|
||||
let mut step = (f64::INFINITY, f64::INFINITY);
|
||||
let mut i = 0;
|
||||
|
||||
while tuple_gt(step, epsilon) && i < iterations {
|
||||
while tuple_gt(step, epsilon) && i < max_iter {
|
||||
let old = self.posteriors();
|
||||
|
||||
self.iteration(0, agents);
|
||||
@@ -451,6 +543,10 @@ impl<T: Time> TimeSlice<T> {
|
||||
});
|
||||
|
||||
i += 1;
|
||||
|
||||
if !crate::step_is_finite(step) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
i
|
||||
@@ -490,43 +586,121 @@ impl<T: Time> TimeSlice<T> {
|
||||
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>>(
|
||||
&self,
|
||||
online: bool,
|
||||
targets: &[Index],
|
||||
forward: bool,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
) -> 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.
|
||||
let mut arena = ScratchArena::new();
|
||||
|
||||
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();
|
||||
match event.kind {
|
||||
EventKind::Ranked => {
|
||||
Game::ranked_with_arena(teams, &result, &event.weights, self.p_draw, arena)
|
||||
.evidence
|
||||
.ln()
|
||||
Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
self.convergence,
|
||||
arena,
|
||||
)
|
||||
.log_evidence
|
||||
}
|
||||
EventKind::Scored { score_sigma } => {
|
||||
Game::scored_with_arena(teams, &result, &event.weights, score_sigma, arena)
|
||||
.evidence
|
||||
.ln()
|
||||
Game::scored_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
score_sigma,
|
||||
self.convergence,
|
||||
arena,
|
||||
)
|
||||
.log_evidence
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
if online || forward {
|
||||
if forward {
|
||||
self.events
|
||||
.iter()
|
||||
.map(|event| run_event(event, &mut arena))
|
||||
.sum()
|
||||
} 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
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
@@ -534,7 +708,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
.teams
|
||||
.iter()
|
||||
.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))
|
||||
.sum()
|
||||
@@ -546,9 +720,9 @@ impl<T: Time> TimeSlice<T> {
|
||||
.teams
|
||||
.iter()
|
||||
.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()
|
||||
}
|
||||
}
|
||||
@@ -621,7 +795,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0);
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
|
||||
|
||||
time_slice.add_events(
|
||||
vec![
|
||||
@@ -668,7 +842,7 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
assert_eq!(time_slice.convergence(&agents), 1);
|
||||
assert_eq!(time_slice.iterate_to_convergence(&agents), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -698,7 +872,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0);
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
|
||||
|
||||
time_slice.add_events(
|
||||
vec![
|
||||
@@ -730,7 +904,7 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
assert!(time_slice.convergence(&agents) > 1);
|
||||
assert!(time_slice.iterate_to_convergence(&agents) > 1);
|
||||
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
@@ -778,7 +952,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0);
|
||||
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
|
||||
|
||||
time_slice.add_events(
|
||||
vec![
|
||||
@@ -792,7 +966,7 @@ mod tests {
|
||||
&agents,
|
||||
);
|
||||
|
||||
time_slice.convergence(&agents);
|
||||
time_slice.iterate_to_convergence(&agents);
|
||||
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
@@ -826,23 +1000,28 @@ mod tests {
|
||||
|
||||
assert_eq!(time_slice.events.len(), 6);
|
||||
|
||||
time_slice.convergence(&agents);
|
||||
time_slice.iterate_to_convergence(&agents);
|
||||
|
||||
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!(
|
||||
post[&a],
|
||||
Gaussian::from_ms(25.000003, 3.880150),
|
||||
Gaussian::from_ms(25.000001, 3.880150),
|
||||
epsilon = 1e-6
|
||||
);
|
||||
assert_ulps_eq!(
|
||||
post[&b],
|
||||
Gaussian::from_ms(25.000003, 3.880150),
|
||||
Gaussian::from_ms(25.000001, 3.880150),
|
||||
epsilon = 1e-6
|
||||
);
|
||||
assert_ulps_eq!(
|
||||
post[&c],
|
||||
Gaussian::from_ms(25.000003, 3.880150),
|
||||
Gaussian::from_ms(25.000001, 3.880150),
|
||||
epsilon = 1e-6
|
||||
);
|
||||
}
|
||||
@@ -876,7 +1055,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let mut ts = TimeSlice::new(0i64, 0.0);
|
||||
let mut ts = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
|
||||
|
||||
ts.add_events(
|
||||
vec![
|
||||
|
||||
@@ -15,6 +15,7 @@ fn add_events_bulk_via_iter() {
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -16,6 +16,7 @@ fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
+5
-11
@@ -48,15 +48,9 @@ fn game_1v1_draw_golden() {
|
||||
)
|
||||
.unwrap();
|
||||
let p = g.posteriors();
|
||||
// Historical golden from pre-T2 test_1vs1_draw:
|
||||
assert_ulps_eq!(
|
||||
p[0][0],
|
||||
Gaussian::from_ms(24.999999, 6.469480),
|
||||
epsilon = 1e-6
|
||||
);
|
||||
assert_ulps_eq!(
|
||||
p[1][0],
|
||||
Gaussian::from_ms(24.999999, 6.469480),
|
||||
epsilon = 1e-6
|
||||
);
|
||||
// Historical golden from pre-T2 test_1vs1_draw. The mean is 25.0 exactly
|
||||
// by symmetry — two identical competitors drawing cannot move apart — and
|
||||
// the reference's 24.999999 is that value transcribed to six decimals.
|
||||
assert_ulps_eq!(p[0][0], Gaussian::from_ms(25.0, 6.469480), epsilon = 1e-6);
|
||||
assert_ulps_eq!(p[1][0], Gaussian::from_ms(25.0, 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,71 @@
|
||||
//! Regression: a single time slice with many distinct competitors must converge to finite
|
||||
//! skills. Before the `pi <= 0` guard in `Gaussian::mu()/sigma()`, EP message cancellation
|
||||
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
|
||||
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
|
||||
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS, NullObserver};
|
||||
|
||||
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() >> 33) as usize % n
|
||||
}
|
||||
fn coin(&mut self) -> bool {
|
||||
self.next() & 1 == 0
|
||||
}
|
||||
}
|
||||
|
||||
fn nan_after_fit(players: usize) -> usize {
|
||||
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder_with_key()
|
||||
.beta(1.0)
|
||||
.sigma(6.0)
|
||||
.drift(ConstantDrift(0.1))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: ITERATIONS,
|
||||
epsilon: EPSILON,
|
||||
..Default::default()
|
||||
})
|
||||
.build();
|
||||
|
||||
let ids: Vec<String> = (0..players).map(|i| format!("p{i:04}")).collect();
|
||||
let mut rng = Lcg(1);
|
||||
for _ in 0..(players * 4) {
|
||||
let a = rng.below(players);
|
||||
let mut b = rng.below(players - 1);
|
||||
if b >= a {
|
||||
b += 1;
|
||||
}
|
||||
let (w, l) = if rng.coin() { (a, b) } else { (b, a) };
|
||||
h.record_winner(&ids[w], &ids[l], 0).unwrap();
|
||||
}
|
||||
h.converge().unwrap();
|
||||
|
||||
ids.iter()
|
||||
.filter(|id| {
|
||||
h.current_skill(id.as_str())
|
||||
.map(|g| !g.mu().is_finite() || !g.sigma().is_finite())
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_competitors_converge_to_finite_skills() {
|
||||
// The NaN regression onset was between 70 and 80 competitors; 250 is comfortably past it
|
||||
// and in the range of a real ranking dataset.
|
||||
for players in [12usize, 75, 150, 250] {
|
||||
assert_eq!(
|
||||
nan_after_fit(players),
|
||||
0,
|
||||
"{players}-competitor history produced NaN skills"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -10,6 +10,7 @@ fn record_winner_builds_history() {
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user