From d2aab82c1e1c411b3aeab8710e9551697f383afe Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 24 Apr 2026 11:20:04 +0000 Subject: [PATCH] T0 + T1 + T2: engine redesign through new API surface (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements tiers T0, T1, T2 of `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`. All three tiers have landed together on this branch because they build on one another; this PR rolls them up for a single review pass. Per-tier plans: - T0: `docs/superpowers/plans/2026-04-23-t0-numerical-parity.md` - T1: `docs/superpowers/plans/2026-04-24-t1-factor-graph.md` - T2: `docs/superpowers/plans/2026-04-24-t2-new-api-surface.md` ## Summary ### T0 — Numerical parity (internal) - `Gaussian` switched to natural-parameter storage `(pi, tau)`; mul/div now ~7× faster (218 ps vs 1.57 ns). - `HashMap` → dense `Vec<_>` keyed by `Index.0` (via `AgentStore`, `SkillStore`). - `ScratchArena` eliminates per-event allocations in `Game::likelihoods`. - `InferenceError` seed type added (1 variant). - 38 → 53 tests passing through T1. - Benchmark: `Batch::iteration` 29.84 → 21.25 µs. ### T1 — Factor graph machinery (internal) - `Factor` trait + `BuiltinFactor` enum (TeamSum / RankDiff / Trunc) driving within-game inference. - `VarStore` flat storage for variable marginals. - `Schedule` trait + `EpsilonOrMax` impl replacing the hand-rolled EP loop. - `Game::likelihoods` rebuilt on the factor-graph machinery; iteration counts and goldens preserved to within 1e-6. - 53 tests passing. - Benchmark: `Batch::iteration` 23.01 µs (slight regression absorbed in T2). ### T2 — New API surface (breaking) **Renames:** - `IndexMap → KeyTable`, `Player → Rating`, `Agent → Competitor`, `Batch → TimeSlice` **New types:** - `Time` trait with `Untimed` ZST and `i64` impls; `Drift`, `Rating`, `Competitor`, `TimeSlice`, `History` all generic. - `Event`, `Team`, `Member`, `Outcome` (`Ranked` variant; `#[non_exhaustive]`). - `Observer` trait + `NullObserver`. - `ConvergenceOptions`, `ConvergenceReport`. - `GameOptions`, `OwnedGame`. **Three-tier ingestion:** - `history.record_winner(&K, &K, T)` / `record_draw(&K, &K, T)` — 1v1 convenience. - `history.add_events(iter)` — typed bulk. - `history.event(T).team([...]).weights([...]).ranking([...]).commit()` — fluent. **Query API:** `current_skill`, `learning_curve`, `learning_curves` (keyed on `K`), `log_evidence`, `log_evidence_for`, `predict_quality`, `predict_outcome`. **Game constructors:** `ranked`, `one_v_one`, `free_for_all`, `custom` — all returning `Result<_, InferenceError>`. **`factors` module:** `Factor`, `Schedule`, `VarStore`, `VarId`, `BuiltinFactor`, `EpsilonOrMax`, `ScheduleReport`, `TeamSumFactor`, `RankDiffFactor`, `TruncFactor` now public. **Errors:** `InferenceError` gains `MismatchedShape`, `InvalidProbability`, `ConvergenceFailed`; boundary panics converted to `Result`. **Removed (breaking):** `History::convergence(iters, eps, verbose)`, `HistoryBuilder::gamma(f64)`, `HistoryBuilder::time(bool)`, `History.time: bool`, `learning_curves_by_index`, nested-Vec public `add_events`. ## Behavior change (documented in CHANGELOG) `Time = Untimed` has `elapsed_to → 0`, so no drift accumulates between slices. The old `time=false` mode implicitly forced `elapsed=1` on reappearance via an `i64::MAX` sentinel — that quirk is not reproducible under a typed time axis. Tests that depended on it now use `History::` with explicit `1..=n` timestamps. One test (`test_env_ttt`) had 3 Gaussian goldens updated to reflect the corrected semantics; documented in commit `33a7d90`. ## Final numbers | Metric | Before T0 | After T2 | Delta | |---|---|---|---| | `Batch::iteration` | 29.84 µs | 21.36 µs | **-28%** | | `Gaussian::mul` | 1.57 ns | 219 ps | **-86%** | | `Gaussian::div` | 1.57 ns | 219 ps | **-86%** | | Tests passing | 38 | 90 | +52 | All other Gaussian ops unchanged (~219 ps add/sub, ~264 ps pi/tau reads). ## Test plan - [x] `cargo test --features approx` — 90/90 pass (68 lib + 10 api_shape + 6 game + 4 record_winner + 2 equivalence) - [x] `cargo clippy --all-targets --features approx -- -D warnings` — clean - [x] `cargo +nightly fmt --check` — clean - [x] `cargo bench --bench batch` — 21.36 µs - [x] `cargo bench --bench gaussian` — unchanged from T1 - [x] `cargo run --example atp --features approx` — rewritten in new API, runs clean - [x] Historical Game-level goldens preserved in `tests/equivalence.rs` - [x] Public API matches spec Section 4 (verified by integration tests in `tests/api_shape.rs`) ## Commit history ~45 commits total across T0 + T1 + T2. Each task is self-contained and individually tested; the branch is bisectable. See `git log main..t2-new-api-surface` for the full list. ## Deferred to later tiers - `Outcome::Scored` + `MarginFactor` — T4 - `Damped` / `Residual` schedules — T4 - `Send + Sync` bounds + Rayon parallelism — T3 - N-team `predict_outcome` — T4 - `Game::custom` full ergonomics — T4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://git.aceofba.se/logaritmisk/trueskill-tt/pulls/1 Co-authored-by: Anders Olsson Co-committed-by: Anders Olsson --- CHANGELOG.md | 84 + Cargo.toml | 1 + benches/baseline.txt | 100 + benches/batch.rs | 50 +- benches/gaussian.rs | 5 +- .../plans/2026-04-23-t0-numerical-parity.md | 1544 +++++++++ .../plans/2026-04-24-t1-factor-graph.md | 1658 ++++++++++ .../plans/2026-04-24-t2-new-api-surface.md | 2757 +++++++++++++++++ ...-04-23-trueskill-engine-redesign-design.md | 619 ++++ examples/atp.rs | 103 +- src/agent.rs | 47 - src/approx.rs | 12 +- src/arena.rs | 56 + src/competitor.rs | 71 + src/convergence.rs | 31 + src/drift.rs | 32 +- src/error.rs | 51 + src/event.rs | 132 + src/event_builder.rs | 94 + src/factor/mod.rs | 148 + src/factor/rank_diff.rs | 95 + src/factor/team_sum.rs | 98 + src/factor/trunc.rs | 130 + src/factors.rs | 13 + src/game.rs | 597 ++-- src/gaussian.rs | 296 +- src/history.rs | 1458 +++++---- src/key_table.rs | 72 + src/lib.rs | 140 +- src/message.rs | 81 - src/observer.rs | 48 + src/outcome.rs | 87 + src/player.rs | 32 - src/rating.rs | 46 + src/schedule.rs | 126 + src/storage/competitor_store.rs | 127 + src/storage/mod.rs | 5 + src/storage/skill_store.rs | 130 + src/time.rs | 54 + src/{batch.rs => time_slice.rs} | 200 +- tests/api_shape.rs | 225 ++ tests/equivalence.rs | 61 + tests/game.rs | 96 + tests/record_winner.rs | 54 + 44 files changed, 10541 insertions(+), 1325 deletions(-) create mode 100644 benches/baseline.txt create mode 100644 docs/superpowers/plans/2026-04-23-t0-numerical-parity.md create mode 100644 docs/superpowers/plans/2026-04-24-t1-factor-graph.md create mode 100644 docs/superpowers/plans/2026-04-24-t2-new-api-surface.md create mode 100644 docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md delete mode 100644 src/agent.rs create mode 100644 src/arena.rs create mode 100644 src/competitor.rs create mode 100644 src/convergence.rs create mode 100644 src/error.rs create mode 100644 src/event.rs create mode 100644 src/event_builder.rs create mode 100644 src/factor/mod.rs create mode 100644 src/factor/rank_diff.rs create mode 100644 src/factor/team_sum.rs create mode 100644 src/factor/trunc.rs create mode 100644 src/factors.rs create mode 100644 src/key_table.rs delete mode 100644 src/message.rs create mode 100644 src/observer.rs create mode 100644 src/outcome.rs delete mode 100644 src/player.rs create mode 100644 src/rating.rs create mode 100644 src/schedule.rs create mode 100644 src/storage/competitor_store.rs create mode 100644 src/storage/mod.rs create mode 100644 src/storage/skill_store.rs create mode 100644 src/time.rs rename src/{batch.rs => time_slice.rs} (73%) create mode 100644 tests/api_shape.rs create mode 100644 tests/equivalence.rs create mode 100644 tests/game.rs create mode 100644 tests/record_winner.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9027f98..ce3ed37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,90 @@ All notable changes to this project will be documented in this file. +## 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` — generified from the old `Drift` trait. +- `Event`, `Team`, `Member` — typed bulk-ingest event shape. +- `Outcome` (`#[non_exhaustive]`) — `Ranked(SmallVec<[u32; 4]>)` with convenience + constructors `winner`, `draw`, `ranking`. `Scored` lands in T4. +- `Observer` trait + `NullObserver` ZST — structured progress callbacks. +- `ConvergenceOptions`, `ConvergenceReport` — configuration and post-hoc summary. +- `GameOptions`, `OwnedGame` — 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>>(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` — 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` for power users. +- `History` is now fully generic with defaults + ``. + +### New `Game` API + +- `Game::ranked(&[&[Rating]], Outcome, &GameOptions) -> Result`. +- `Game::one_v_one(&Rating, &Rating, Outcome) -> Result<(Gaussian, Gaussian), _>`. +- `Game::free_for_all(&[&Rating], Outcome, &GameOptions) -> Result`. +- `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>>` public `add_events` signature — + use typed `add_events(iter)`. +- `learning_curves_by_index()` — use `learning_curves()`. + +### 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. + +### Notes + +- `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::` with explicit + `1..=n` timestamps. + ## 0.1.0 - 2026-04-23 ### Features diff --git a/Cargo.toml b/Cargo.toml index d6901d2..f0307df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ harness = false [dependencies] approx = { version = "0.5.1", optional = true } +smallvec = "1" [dev-dependencies] criterion = "0.5" diff --git a/benches/baseline.txt b/benches/baseline.txt new file mode 100644 index 0000000..26f63ae --- /dev/null +++ b/benches/baseline.txt @@ -0,0 +1,100 @@ +# Baseline numbers captured before T0 changes +# Hardware: lrrr.local / Apple M5 Pro +# Date: 2026-04-24 + +Batch::iteration 29.840 µs +Gaussian::add 219.58 ps +Gaussian::sub 219.41 ps +Gaussian::mul 1.568 ns ← hot path; target ≥1.5× improvement +Gaussian::div 1.572 ns ← hot path; target ≥1.5× improvement +Gaussian::pi 262.89 ps +Gaussian::tau 262.47 ps +Gaussian::pi_tau_combined 219.40 ps + +# After T0 (2026-04-24, same hardware) + +Batch::iteration 21.253 µs (1.40× — below 3× target; see post-mortem) +Gaussian::add 218.62 ps (1.00× — unchanged, Add/Sub use moment form) +Gaussian::sub 220.15 ps (1.00×) +Gaussian::mul 218.69 ps (7.17× — nat-param: now two f64 adds, no sqrt) +Gaussian::div 218.64 ps (7.19× — nat-param: now two f64 subs, no sqrt) +Gaussian::pi 263.19 ps (1.00× — now a field read, same cost) +Gaussian::tau 263.51 ps (1.00× — now a field read, same cost) +Gaussian::pi_tau_combined 219.13 ps (1.00×) + +# Post-mortem: Batch::iteration 1.40× vs. 3× target +# +# Root cause: the bench has 100 tiny 2-team events. Each event still allocates +# ~10 Vecs per iteration (down from ~18). The arena covers teams/diffs/ties/margins +# (was 4 Vecs, now 0 new allocs) but the following remain: +# - within_priors() returns Vec>>: 3 Vecs per event (300 total) +# - event.outputs() returns Vec: 1 Vec per event (100 total) +# - sort_perm() allocates 2 scratch Vecs: 200 total +# - Game::likelihoods = collect() allocates Vec>: 4 Vecs (400 total) +# Total remaining: ~1000 allocs per iteration call vs. ~1800 before (44% reduction). +# +# The HashMap → dense Vec win (target 2–4×) benefits the History-level forward/backward +# sweep, NOT Batch::iteration in isolation — so this bench doesn't show it. +# +# To hit ≥3× on Batch::iteration: +# - Arena-ify sort_perm (use a stack-fixed array for small n_teams) +# - Pass a within_priors output buffer through the arena +# - Make Game::likelihoods write into an arena slice rather than allocating +# These land in T1 (factor graph) when we redesign Game's internals. + +# After T1 (2026-04-24, same hardware) + +Batch::iteration 23.010 µs (1.08× vs T0 21.253 µs — slight regression) +Gaussian::add 231.23 ps (unchanged) +Gaussian::sub 235.38 ps (unchanged) +Gaussian::mul 234.55 ps (unchanged — nat-param storage) +Gaussian::div 233.27 ps (unchanged) +Gaussian::pi 272.68 ps (unchanged) +Gaussian::tau 272.73 ps (unchanged) +Gaussian::pi_tau_combined 234.xx ps (unchanged) + +# Notes: +# - Batch::iteration 23.0 µs vs target ≤ 21.5 µs (8% above target). +# Root cause: TruncFactor::propagate adds one extra Gaussian mul + div per +# diff vs the old inline EP computation. trunc Vec is still a fresh +# per-game allocation (borrow checker prevents putting it in the arena +# alongside vars). These are addressable in T2. +# - arena.team_prior, lhood_lose, lhood_win, inv_buf, sort_buf all reuse +# capacity across games (pooled in ScratchArena). sort_perm() allocation +# eliminated. message.rs deleted. +# - Gaussian operations unchanged vs T0. +# - All 53 tests pass. factor graph infrastructure (VarStore, Factor trait, +# BuiltinFactor, TruncFactor, EpsilonOrMax schedule) in place for T2. + +# After T2 (2026-04-24, same hardware) + +Batch::iteration 21.36 µs (1.07× vs T1 22.88 µs — 7% improvement) +Gaussian::add 218.97 ps (unchanged) +Gaussian::sub 218.58 ps (unchanged) +Gaussian::mul 218.59 ps (unchanged) +Gaussian::div 218.57 ps (unchanged) +Gaussian::pi 264.20 ps (unchanged) +Gaussian::tau 260.80 ps (unchanged) + +# Notes: +# - API-only tier; hot inference path unchanged. The 7% improvement on +# Batch::iteration likely comes from the typed add_events(iter) path +# being slightly more direct than the nested-Vec path it replaced +# (one less layer of composition construction per event). +# - Public surface now matches spec Section 4: +# record_winner / record_draw / add_events(iter) / event(t).team().commit() +# converge() -> Result +# learning_curve(&K) / learning_curves() / current_skill(&K) +# log_evidence() / log_evidence_for(&[&K]) +# predict_quality / predict_outcome +# Game::ranked / one_v_one / free_for_all / custom +# factors module (pub Factor/Schedule/VarStore/EpsilonOrMax/BuiltinFactor) +# - Breaking type renames: Batch→TimeSlice, Player→Rating, Agent→Competitor, +# IndexMap→KeyTable. +# - Generic over T: Time (default i64), D: Drift, O: Observer, +# K: Eq + Hash + Clone (default &'static str). +# - Legacy removed: History::convergence(iters, eps, verbose), +# HistoryBuilder::gamma(), HistoryBuilder::time(bool), History::time field, +# learning_curves_by_index(), nested-Vec public add_events(). +# - 90 tests green: 68 lib + 10 api_shape + 6 game + 4 record_winner + +# 2 equivalence. diff --git a/benches/batch.rs b/benches/batch.rs index 637a2fb..7bc0bc0 100644 --- a/benches/batch.rs +++ b/benches/batch.rs @@ -1,45 +1,27 @@ -use std::collections::HashMap; - use criterion::{Criterion, criterion_group, criterion_main}; use trueskill_tt::{ - BETA, GAMMA, IndexMap, MU, P_DRAW, SIGMA, agent::Agent, batch::Batch, drift::ConstantDrift, - gaussian::Gaussian, player::Player, + BETA, Competitor, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA, TimeSlice, drift::ConstantDrift, + gaussian::Gaussian, storage::CompetitorStore, }; fn criterion_benchmark(criterion: &mut Criterion) { - let mut index = IndexMap::new(); + let mut index_map = KeyTable::new(); - let a = index.get_or_create("a"); - let b = index.get_or_create("b"); - let c = index.get_or_create("c"); + let a = index_map.get_or_create("a"); + let b = index_map.get_or_create("b"); + let c = index_map.get_or_create("c"); - let agents = { - let mut map = HashMap::new(); + let mut agents: CompetitorStore = CompetitorStore::new(); - map.insert( - a, - Agent { - player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)), + for agent in [a, b, c] { + agents.insert( + agent, + Competitor { + rating: Rating::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)), ..Default::default() }, ); - map.insert( - b, - Agent { - player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)), - ..Default::default() - }, - ); - map.insert( - c, - Agent { - player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)), - ..Default::default() - }, - ); - - map - }; + } let mut composition = Vec::new(); let mut results = Vec::new(); @@ -51,11 +33,11 @@ fn criterion_benchmark(criterion: &mut Criterion) { weights.push(vec![vec![1.0], vec![1.0]]); } - let mut batch = Batch::new(1, P_DRAW); - batch.add_events(composition, results, weights, &agents); + let mut time_slice = TimeSlice::new(1, P_DRAW); + time_slice.add_events(composition, results, weights, &agents); criterion.bench_function("Batch::iteration", |b| { - b.iter(|| batch.iteration(0, &agents)) + b.iter(|| time_slice.iteration(0, &agents)) }); } diff --git a/benches/gaussian.rs b/benches/gaussian.rs index 87b9361..1ca1d5e 100644 --- a/benches/gaussian.rs +++ b/benches/gaussian.rs @@ -23,8 +23,11 @@ fn benchmark_gaussian_arithmetic(criterion: &mut Criterion) { }); // Benchmark division + // NOTE: numerator must have higher precision (smaller sigma) than the + // denominator in this representation; g2 (sigma=1) / g1 (sigma=8.33) is + // well-defined, whereas g1 / g2 underflows and panics in mu_sigma. criterion.bench_function("Gaussian::div", |bencher| { - bencher.iter(|| g1 / g2); + bencher.iter(|| g2 / g1); }); // Benchmark natural parameter conversions diff --git a/docs/superpowers/plans/2026-04-23-t0-numerical-parity.md b/docs/superpowers/plans/2026-04-23-t0-numerical-parity.md new file mode 100644 index 0000000..4d4be79 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-t0-numerical-parity.md @@ -0,0 +1,1544 @@ +# T0 — Numerical Parity 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:** Internally rebuild the engine's data plane for performance — switch `Gaussian` to natural-parameter storage, replace `HashMap` with dense `Vec<_>` storage, eliminate per-event allocations via a reusable `ScratchArena`, and convert the lone `panic!` in `mu_sigma` into a propagated `Result`. **No top-level public API change.** Existing test suite must still pass. + +**Architecture:** This is the first tier of a five-tier engine redesign documented in `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`. T0 is invisible to library users — same constructors, same methods, same outputs (within ULP-bounded floating-point drift) — but the internals are the new layout the rest of the redesign builds on. + +**Tech Stack:** Rust 2024 edition, criterion for benchmarks, approx for floating-point comparisons, the existing `trueskill-tt` crate. + +## Acceptance criteria + +- All existing tests pass (`cargo test --features approx`). Where natural-parameter rounding shifts a hardcoded golden value, update the golden and add a parity comment explaining the ULP-bounded drift. +- `cargo bench --bench batch` shows ≥3× speedup on `Batch::iteration` vs. baseline. +- `cargo bench --bench gaussian` compiles (it currently doesn't — see Task 2). +- `cargo clippy --all-targets` clean. +- `cargo fmt --check` clean. +- No change to the top-level public API (`History`, `HistoryBuilder`, `Game::new`, `Game::posteriors`, `Player::new`, `quality()`, `Gaussian::from_ms`, etc.). Internal `pub mod` types may change signatures (`Batch::add_events`, `Agent`) — these are exposed in `lib.rs` but were never user-facing, and the in-tree benches are the only consumers besides the engine itself. + +## File map + +**Created:** +- `src/storage/mod.rs` — module root for the dense storage types +- `src/storage/skill_store.rs` — `SkillStore`: dense `Vec`-backed replacement for `HashMap` +- `src/storage/agent_store.rs` — `AgentStore`: dense `Vec`-backed replacement for `HashMap>` +- `src/arena.rs` — `ScratchArena`: reusable scratch buffers for `Game::likelihoods` +- `benches/baseline.txt` — captured baseline numbers for the acceptance gate (committed for reference) + +**Modified:** +- `src/gaussian.rs` — switch storage to `(pi, tau)`; `mu()`/`sigma()` become public accessors; ops rewritten +- `src/lib.rs` — module declarations for `storage` and `arena`; constants `N00`/`N01`/`N_INF` keep their public values but are constructed from natural params; `mu_sigma` becomes `Result`-returning +- `src/approx.rs` — `AbsDiffEq`/`RelativeEq`/`UlpsEq` impls compare via `mu()`/`sigma()` accessors instead of fields +- `src/agent.rs` — `clean()` takes `&mut AgentStore` (changed from generic iterator); same observable behavior +- `src/batch.rs` — `Skill` map becomes `SkillStore`; `add_events`/`iteration`/etc. take `&AgentStore` instead of `&HashMap>`; `Batch` owns a `ScratchArena` +- `src/history.rs` — `agents: HashMap<…>` becomes `agents: AgentStore`; internal call sites updated +- `src/game.rs` — `Game::new` takes `&mut ScratchArena`; per-event `Vec` allocations replaced by arena slices +- `benches/gaussian.rs` — uses public `pi()`/`tau()` accessors (was broken) +- `benches/batch.rs` — passes `&AgentStore` to `Batch::add_events` (built once before the bench loop) + +**Touched (test-only golden updates — see Task 3):** +- `src/gaussian.rs` (test module) +- `src/game.rs` (test module) +- `src/batch.rs` (test module) +- `src/history.rs` (test module if affected) +- `src/lib.rs` (test module) + +--- + +## Task 1: Establish baseline benchmark numbers + +**Files:** +- Create: `benches/baseline.txt` + +The acceptance criterion is "≥3× speedup on `Batch::iteration`." Capture the floor first. + +- [ ] **Step 1: Fix the broken gaussian bench so we can capture both baselines** + +The bench currently calls private methods. Make `pi()` and `tau()` `pub` *before* changing the representation. This is the only baseline-prep change. + +Edit `src/gaussian.rs`: + +```rust +// fn pi(&self) -> f64 { +// becomes: + pub fn pi(&self) -> f64 { + +// fn tau(&self) -> f64 { +// becomes: + pub fn tau(&self) -> f64 { +``` + +Verify: `cargo bench --no-run` succeeds for both `batch` and `gaussian`. + +- [ ] **Step 2: Run baseline benchmarks and save numbers** + +```bash +cargo bench --bench batch 2>&1 | tee /tmp/bench-batch-baseline.txt +cargo bench --bench gaussian 2>&1 | tee /tmp/bench-gaussian-baseline.txt +``` + +Extract the headline numbers (lines like `Batch::iteration time: [X.XX µs Y.YY µs Z.ZZ µs]`) and write them into `benches/baseline.txt`: + +``` +# Baseline numbers captured before T0 changes +# Hardware: +# Date: 2026-04-23 + +Batch::iteration µs +Gaussian::add ns +Gaussian::sub ns +Gaussian::mul ns +Gaussian::div ns +Gaussian::pi ns +Gaussian::tau ns +Gaussian::pi_tau_combined ns +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/gaussian.rs benches/baseline.txt +git commit -m "$(cat <<'EOF' +bench: capture T0 baseline; expose pi/tau accessors + +Promotes Gaussian::pi and Gaussian::tau to public so benches/gaussian.rs +compiles, then captures the baseline numbers we'll measure T0 against. +EOF +)" +``` + +--- + +## Task 2: Switch `Gaussian` to natural-parameter storage + +**Files:** +- Modify: `src/gaussian.rs` (rewrite struct + ops) +- Modify: `src/lib.rs` (constants) +- Modify: `src/approx.rs` (compare via accessors) + +This is the core change. Operations move from "store mu/sigma, recompute pi/tau on every mul/div" to "store pi/tau, recompute mu/sigma only when read." Hot-path mul/div become pure addition/subtraction of stored fields — no `sqrt`, no `powi(-2)`, no division. + +### Mathematical mapping + +| Operation | Before (mu/sigma stored) | After (pi/tau stored) | +|---|---|---| +| `pi()` | `sigma⁻²` (compute) | field read | +| `tau()` | `mu · pi()` (compute) | field read | +| `mu()` | field read | `tau / pi` (or `0` if `pi == 0`) | +| `sigma()` | field read | `1 / √pi` (or `∞` if `pi == 0`) | +| `a * b` (factor product) | convert to nat, add, convert back | `Gaussian { pi: a.pi + b.pi, tau: a.tau + b.tau }` — pure adds | +| `a / b` (cavity) | convert to nat, sub, convert back | `Gaussian { pi: a.pi - b.pi, tau: a.tau - b.tau }` — pure subs | +| `a + b` (variance addition) | `mu1+mu2`, `√(σ1²+σ2²)` | requires moment form: convert + compute + convert back | +| `a - b` (variance addition; same shape) | `mu1-mu2`, `√(σ1²+σ2²)` | same as `+` for sigma; requires moment form | +| `a * scalar` | `mu·k`, `σ·k` | `pi: pi/k²`, `tau: tau/k` (derived: σ' = σ·k → pi' = pi/k²; mu' = mu·k → tau' = mu'·pi' = (mu·k)·(pi/k²) = (mu·pi)/k = tau/k) | +| `forget(δ²)` | `mu`, `√(σ² + δ²)` | requires moment form | +| `delta(other)` | `(|Δmu|, |Δsigma|)` | accessor-based; same observable | +| `exclude(other)` | `mu1-mu2`, `√(σ1² - σ2²)` | requires moment form | + +### Special-value table + +| Constant | Public meaning | mu/sigma form (today) | pi/tau form (after) | +|---|---|---|---| +| `N_INF` | uniform / no info | `{ mu: 0, sigma: ∞ }` | `{ pi: 0.0, tau: 0.0 }` | +| `N00` | additive identity | `{ mu: 0, sigma: 0 }` | `{ pi: f64::INFINITY, tau: 0.0 }` | +| `N01` | standard normal | `{ mu: 0, sigma: 1 }` | `{ pi: 1.0, tau: 0.0 }` | + +### Tasks + +- [ ] **Step 1: Replace `src/gaussian.rs` with the natural-parameter implementation** + +```rust +use std::ops; + +use crate::{MU, N_INF, SIGMA}; + +/// A Gaussian distribution stored in natural parameters. +/// +/// `pi = 1 / sigma^2` (precision) +/// `tau = mu * pi` (precision-adjusted mean) +/// +/// This representation makes message-passing operations (`*` and `/`) into +/// pure additions/subtractions of the stored fields, with no `sqrt` or +/// reciprocal in the hot path. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct Gaussian { + pi: f64, + tau: f64, +} + +impl Gaussian { + /// Construct from mean and standard deviation. Maintained for API + /// compatibility with the previous representation. + pub const fn from_ms(mu: f64, sigma: f64) -> Self { + if sigma == f64::INFINITY { + Self { pi: 0.0, tau: 0.0 } + } else if sigma == 0.0 { + // mu == 0 is the only N00-like usage in the codebase; we honour + // any other point-mass construction using the IEEE convention + // (tau = mu * inf = inf for nonzero mu). + Self { + pi: f64::INFINITY, + tau: if mu == 0.0 { 0.0 } else { f64::INFINITY * mu }, + } + } else { + let pi = 1.0 / (sigma * sigma); + Self { pi, tau: mu * pi } + } + } + + /// Construct directly from natural parameters. Internal helper. + #[inline] + pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self { + Self { pi, tau } + } + + #[inline] + pub fn pi(&self) -> f64 { + self.pi + } + + #[inline] + pub fn tau(&self) -> f64 { + self.tau + } + + #[inline] + pub fn mu(&self) -> f64 { + if self.pi == 0.0 { 0.0 } else { self.tau / self.pi } + } + + #[inline] + pub fn sigma(&self) -> f64 { + if self.pi == 0.0 { + f64::INFINITY + } else if self.pi.is_infinite() { + 0.0 + } else { + 1.0 / self.pi.sqrt() + } + } + + pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) { + ((self.mu() - other.mu()).abs(), (self.sigma() - other.sigma()).abs()) + } + + pub(crate) fn exclude(&self, other: Gaussian) -> Self { + let mu = self.mu() - other.mu(); + let var = self.sigma().powi(2) - other.sigma().powi(2); + Self::from_ms(mu, var.sqrt()) + } + + pub(crate) fn forget(&self, variance_delta: f64) -> Self { + let mu = self.mu(); + let var = self.sigma().powi(2) + variance_delta; + Self::from_ms(mu, var.sqrt()) + } +} + +impl Default for Gaussian { + fn default() -> Self { + Self::from_ms(MU, SIGMA) + } +} + +impl ops::Add for Gaussian { + type Output = Gaussian; + fn add(self, rhs: Gaussian) -> Self::Output { + // Variance addition: (mu1 + mu2, sqrt(s1^2 + s2^2)). + // Used for combining performance and noise; rare relative to mul/div. + let mu = self.mu() + rhs.mu(); + let var = self.sigma().powi(2) + rhs.sigma().powi(2); + Self::from_ms(mu, var.sqrt()) + } +} + +impl ops::Sub for Gaussian { + type Output = Gaussian; + fn sub(self, rhs: Gaussian) -> Self::Output { + // (mu1 - mu2, sqrt(s1^2 + s2^2)). Same sigma combination as add. + let mu = self.mu() - rhs.mu(); + let var = self.sigma().powi(2) + rhs.sigma().powi(2); + Self::from_ms(mu, var.sqrt()) + } +} + +impl ops::Mul for Gaussian { + type Output = Gaussian; + fn mul(self, rhs: Gaussian) -> Self::Output { + // Factor product: nat-param add. Hot path. + Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau) + } +} + +impl ops::Mul for Gaussian { + type Output = Gaussian; + fn mul(self, scalar: f64) -> Self::Output { + if !scalar.is_finite() { + return N_INF; + } + if scalar == 0.0 { + return N_INF; + } + // sigma' = sigma * |scalar| => pi' = pi / scalar^2 + // mu' = mu * scalar => tau' = mu' * pi' = (mu * scalar) * (pi / scalar^2) + // = (mu * pi) / scalar = tau / scalar + Self::from_natural(self.pi / (scalar * scalar), self.tau / scalar) + } +} + +impl ops::Div for Gaussian { + type Output = Gaussian; + fn div(self, rhs: Gaussian) -> Self::Output { + // Cavity: nat-param sub. Hot path. + Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau) + } +} +``` + +- [ ] **Step 2: Update `src/lib.rs` constants** + +The `pub const` constants need to remain `const`-constructible. `from_ms` is `const fn`, so just rely on it: + +```rust +// In src/lib.rs, no change needed to the lines: +pub const N01: Gaussian = Gaussian::from_ms(0.0, 1.0); +pub const N00: Gaussian = Gaussian::from_ms(0.0, 0.0); +pub const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY); +``` + +These continue to work because `from_ms` already handles `sigma == 0.0` and `sigma == f64::INFINITY` as special cases producing the right nat-param values. + +- [ ] **Step 3: Rewrite `src/approx.rs` to compare via accessors** + +The `Gaussian` struct no longer has `mu`/`sigma` as public fields; the approx impls used them directly. Switch to accessors. + +```rust +use approx::{AbsDiffEq, RelativeEq, UlpsEq}; + +use crate::gaussian::Gaussian; + +impl AbsDiffEq for Gaussian { + type Epsilon = ::Epsilon; + + fn default_epsilon() -> Self::Epsilon { + f64::default_epsilon() + } + + fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool { + f64::abs_diff_eq(&self.mu(), &other.mu(), epsilon) + && f64::abs_diff_eq(&self.sigma(), &other.sigma(), epsilon) + } +} + +impl RelativeEq for Gaussian { + fn default_max_relative() -> Self::Epsilon { + f64::default_max_relative() + } + + fn relative_eq( + &self, + other: &Self, + epsilon: Self::Epsilon, + max_relative: Self::Epsilon, + ) -> bool { + f64::relative_eq(&self.mu(), &other.mu(), epsilon, max_relative) + && f64::relative_eq(&self.sigma(), &other.sigma(), epsilon, max_relative) + } +} + +impl UlpsEq for Gaussian { + fn default_max_ulps() -> u32 { + f64::default_max_ulps() + } + + fn ulps_eq(&self, other: &Self, epsilon: Self::Epsilon, max_ulps: u32) -> bool { + f64::ulps_eq(&self.mu(), &other.mu(), epsilon, max_ulps) + && f64::ulps_eq(&self.sigma(), &other.sigma(), epsilon, max_ulps) + } +} +``` + +- [ ] **Step 4: Update `benches/gaussian.rs` (now a no-op since Task 1 made the methods public)** + +No change needed — Task 1 already promoted `pi()`/`tau()` to `pub`. After Task 2 they remain `pub` and become trivial field reads. Verify: + +```bash +cargo bench --no-run --bench gaussian +``` + +Expected: clean compile. + +- [ ] **Step 5: Search for direct field access on `Gaussian` outside `gaussian.rs` and replace with accessors** + +```bash +grep -rn '\.mu\b\|\.sigma\b' src/ benches/ | grep -v 'gaussian.rs' +``` + +For each hit, change `g.mu` → `g.mu()` and `g.sigma` → `g.sigma()`. Likely sites: + +- `src/lib.rs` — `cdf()`, `pdf()`, `compute_margin()`, `evidence()`, `approx()`, `quality()` — search for `.mu` and `.sigma` +- `src/gaussian.rs` (own tests at the bottom) + +Specifically known sites in `src/lib.rs`: +- `evidence(...)`: uses `d[e].prior.mu` and `d[e].prior.sigma` +- `approx()`: uses `n.mu`, `n.sigma` +- `quality()`: uses `rating.mu` and `rating.sigma` inside the loops + +Update each. + +- [ ] **Step 6: Run tests, fix broken golden values** + +```bash +cargo test --features approx 2>&1 | tee /tmp/t0-test-output.txt +``` + +For each `assert_eq!(g, Gaussian { mu: X, sigma: Y })` that fails: +- The struct literal pattern won't even compile (no public `mu`/`sigma` fields). Replace with `Gaussian::from_ms(X, Y)`. +- For exact `assert_eq!`, also check whether the failure is "compile error" (struct literal) or "runtime mismatch" (ULP drift). For runtime drift, capture the new value (it should differ from the old by at most a few ULPs in the last hex digit) and update the literal. + +Likely affected tests: +- `src/gaussian.rs::tests::test_add` — struct literal `Gaussian { mu: ..., sigma: ... }` +- `src/gaussian.rs::tests::test_sub` +- `src/gaussian.rs::tests::test_mul` +- `src/gaussian.rs::tests::test_div` + +Rewrite each like: + +```rust +#[test] +fn test_mul() { + let n = Gaussian::from_ms(25.0, 25.0 / 3.0); + let m = Gaussian::from_ms(0.0, 1.0); + + let result = n * m; + // Original goldens: mu = 0.35488958990536273, sigma = 0.992876838486922 + // Verify within ULPs (the natural-param mul should produce identical bits + // for these inputs — if not, capture and document the new value). + assert_eq!(result.mu(), 0.35488958990536273); + assert_eq!(result.sigma(), 0.992876838486922); +} +``` + +If a golden differs at the last few ULPs, switch to `approx::assert_ulps_eq!` with `max_ulps = 4`: + +```rust +use approx::assert_ulps_eq; +assert_ulps_eq!(result, Gaussian::from_ms(EXPECTED_MU, EXPECTED_SIGMA), max_ulps = 4); +``` + +- [ ] **Step 7: Run the full test suite, including game/batch/history** + +```bash +cargo test --features approx +``` + +Tests in `game.rs`, `batch.rs`, `history.rs` already use `assert_ulps_eq!` with `epsilon = 1e-6` so they should be tolerant. If any fail beyond `1e-6`, investigate (it would suggest a real bug in the rewrite, not just ULP drift). + +- [ ] **Step 8: Lint and format** + +```bash +cargo clippy --all-targets --features approx -- -D warnings +cargo fmt +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/gaussian.rs src/lib.rs src/approx.rs benches/gaussian.rs +# Plus any test files you adjusted goldens in +git add src/gaussian.rs # test module +git commit -m "$(cat <<'EOF' +refactor(gaussian): switch to natural-parameter storage + +Internal change: Gaussian now stores (pi, tau) rather than (mu, sigma). +Multiplication and division become pure adds/subs of stored fields, +eliminating per-op sqrt and reciprocal. mu()/sigma() become public +accessors. Add/Sub/forget/exclude continue to work in moment form +(rare relative to mul/div in the EP hot path). + +Test golden values updated where natural-parameter rounding shifts +the last few ULPs; differences bounded by 1e-6 absolute, validated +against the prior moment-form arithmetic. + +Part of T0 of docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md. +EOF +)" +``` + +--- + +## Task 3: Convert `mu_sigma` panic to `Result` + +**Files:** +- Modify: `src/lib.rs` (or wherever `mu_sigma` lives — verify with `grep`) + +The current `mu_sigma` panics on negative precision. This is an internal invariant violation and should be a structured error. + +After Task 2, `mu_sigma` may have been deleted entirely (it was a helper for the old representation's mul/div). Check first: + +```bash +grep -n 'fn mu_sigma\|mu_sigma(' src/ +``` + +- [ ] **Step 1: If `mu_sigma` still exists, replace the panic with an error type** + +If the function survived the Task 2 rewrite (it might still be referenced indirectly), replace the body: + +```rust +// In src/lib.rs (or src/gaussian.rs) +#[derive(Debug, Clone, PartialEq)] +pub enum InferenceError { + NegativePrecision { pi: f64 }, +} + +impl std::fmt::Display for InferenceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NegativePrecision { pi } => { + write!(f, "precision must be non-negative; got {pi}") + } + } + } +} + +impl std::error::Error for InferenceError {} + +fn mu_sigma(tau: f64, pi: f64) -> Result<(f64, f64), InferenceError> { + if pi > 0.0 { + Ok((tau / pi, (1.0 / pi).sqrt())) + } else if (pi + 1e-5) < 0.0 { + Err(InferenceError::NegativePrecision { pi }) + } else { + Ok((0.0, f64::INFINITY)) + } +} +``` + +If `mu_sigma` was deleted in Task 2, skip to Step 3 and just create the `InferenceError` enum. + +- [ ] **Step 2: Update callers to propagate the `Result`** + +If `mu_sigma` survived, update callers to `?` the error or unwrap with a `debug_assert!`. Hot-path callers (anything inside `propagate`/`iteration`) should be `debug_assert!` because the invariant is enforced at the API boundary. Public-API callers should propagate. + +In practice after Task 2 there should be no surviving `mu_sigma` callers; all Gaussian arithmetic is direct nat-param work. + +- [ ] **Step 3: Add `InferenceError` to public API** + +In `src/lib.rs`: + +```rust +mod error; +pub use error::InferenceError; +``` + +Create `src/error.rs`: + +```rust +#[derive(Debug, Clone, PartialEq)] +pub enum InferenceError { + NegativePrecision { pi: f64 }, +} + +impl std::fmt::Display for InferenceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NegativePrecision { pi } => { + write!(f, "precision must be non-negative; got {pi}") + } + } + } +} + +impl std::error::Error for InferenceError {} +``` + +This is the seed of the wider `InferenceError` enum that T2 will expand. Other variants come later. + +- [ ] **Step 4: Test** + +```bash +cargo test --features approx +cargo clippy --all-targets --features approx -- -D warnings +cargo fmt +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib.rs src/error.rs +git commit -m "$(cat <<'EOF' +refactor: introduce InferenceError; remove mu_sigma panic + +The 'precision should be greater than 0' panic in mu_sigma becomes +a structured InferenceError::NegativePrecision. Hot-path call sites +that have invariant guarantees use debug_assert!; API-boundary +sites propagate. + +Seed for the wider error enum that T2 will expand. +EOF +)" +``` + +--- + +## Task 4: Introduce `SkillStore` (replace `HashMap` in `Batch`) + +**Files:** +- Create: `src/storage/mod.rs` +- Create: `src/storage/skill_store.rs` +- Modify: `src/lib.rs` (declare `pub mod storage;`) +- Modify: `src/batch.rs` (use `SkillStore`) + +`Batch` currently holds `pub(crate) skills: HashMap`. Every iteration hashes and resolves indices. Replace with a dense `Vec`-backed store. + +### Design + +Two operations dominate: +1. Lookup by `Index` (very common; in inner loop) +2. Iterate over all present `(Index, Skill)` pairs (per iteration, per posterior recompute) + +Use the **dense + present mask** layout from the spec (Section 3 open question, default proposal): + +```rust +pub struct SkillStore { + skills: Vec, // dense, indexed by Index.0 + present: Vec, // parallel mask + n_present: usize, // cached count for iteration sizing +} +``` + +`get`/`get_mut` return `Option<&Skill>`/`Option<&mut Skill>`. `insert(idx, skill)` sets and marks present. Iteration is `present.iter().enumerate().filter(|(_, &p)| p).map(|(i, _)| (Index(i), &skills[i]))`. + +### Tasks + +- [ ] **Step 1: Write the test for `SkillStore`** + +Create `src/storage/skill_store.rs`. Add tests as a `#[cfg(test)] mod tests` block at the bottom. + +```rust +use crate::Index; +use crate::batch::Skill; + +#[derive(Debug, Default)] +pub struct SkillStore { + skills: Vec, + present: Vec, + n_present: usize, +} + +impl SkillStore { + pub fn new() -> Self { + Self::default() + } + + pub fn with_capacity(cap: usize) -> Self { + Self { + skills: Vec::with_capacity(cap), + present: Vec::with_capacity(cap), + n_present: 0, + } + } + + fn ensure_capacity(&mut self, idx: usize) { + if idx >= self.skills.len() { + self.skills.resize_with(idx + 1, Skill::default); + self.present.resize(idx + 1, false); + } + } + + pub fn insert(&mut self, idx: Index, skill: Skill) { + self.ensure_capacity(idx.0); + if !self.present[idx.0] { + self.n_present += 1; + } + self.skills[idx.0] = skill; + self.present[idx.0] = true; + } + + pub fn get(&self, idx: Index) -> Option<&Skill> { + if idx.0 < self.present.len() && self.present[idx.0] { + Some(&self.skills[idx.0]) + } else { + None + } + } + + 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]) + } else { + None + } + } + + pub fn contains(&self, idx: Index) -> bool { + idx.0 < self.present.len() && self.present[idx.0] + } + + pub fn len(&self) -> usize { + self.n_present + } + + pub fn is_empty(&self) -> bool { + self.n_present == 0 + } + + /// Iterate present (Index, &Skill) pairs. + pub fn iter(&self) -> impl Iterator { + self.present + .iter() + .enumerate() + .filter_map(move |(i, &p)| if p { Some((Index(i), &self.skills[i])) } else { None }) + } + + /// Iterate present (Index, &mut Skill) pairs. + pub fn iter_mut(&mut self) -> impl Iterator { + let SkillStore { skills, present, .. } = self; + skills + .iter_mut() + .zip(present.iter()) + .enumerate() + .filter_map(|(i, (s, &p))| if p { Some((Index(i), s)) } else { None }) + } + + /// Just the present indices, for cheap iteration when only keys matter. + pub fn keys(&self) -> impl Iterator + '_ { + self.present + .iter() + .enumerate() + .filter_map(|(i, &p)| if p { Some(Index(i)) } else { None }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_then_get() { + let mut store = SkillStore::new(); + let idx = Index(3); + let skill = Skill::default(); + store.insert(idx, skill); + + assert!(store.contains(idx)); + assert_eq!(store.len(), 1); + assert!(store.get(idx).is_some()); + } + + #[test] + fn missing_returns_none() { + let store = SkillStore::new(); + assert!(store.get(Index(0)).is_none()); + assert!(!store.contains(Index(42))); + } + + #[test] + fn iter_skips_absent_slots() { + let mut store = SkillStore::new(); + store.insert(Index(0), Skill::default()); + store.insert(Index(5), Skill::default()); + + let keys: Vec = store.keys().collect(); + assert_eq!(keys, vec![Index(0), Index(5)]); + } + + #[test] + fn double_insert_same_index_does_not_double_count() { + let mut store = SkillStore::new(); + store.insert(Index(2), Skill::default()); + store.insert(Index(2), Skill::default()); + assert_eq!(store.len(), 1); + } +} +``` + +- [ ] **Step 2: Create `src/storage/mod.rs`** + +```rust +mod skill_store; + +pub use skill_store::SkillStore; +``` + +- [ ] **Step 3: Declare the module in `src/lib.rs`** + +Add to the module declarations: + +```rust +pub mod storage; +``` + +(Public so the (still-public) `batch` module can return references to it; we may tighten visibility in T2.) + +- [ ] **Step 4: Run the new tests** + +```bash +cargo test --features approx storage::skill_store +``` + +Expected: 4 passing tests. + +- [ ] **Step 5: Replace `HashMap` in `Batch`** + +In `src/batch.rs`, the change is mechanical. Replace: + +```rust +pub(crate) skills: HashMap, +``` + +with: + +```rust +pub(crate) skills: SkillStore, +``` + +Add the import: + +```rust +use crate::storage::SkillStore; +``` + +Now update every use site. `HashMap` and `SkillStore` differ in: +- `map[&idx]` (panicking lookup) → `store.get(idx).expect("present")` or use `store.get_mut(idx).unwrap()` +- `map.insert(idx, val)` → `store.insert(idx, val)` (same signature) +- `map.iter()` returning `(&Index, &Skill)` → `store.iter()` returning `(Index, &Skill)` — note key is by-value; adjust borrow patterns +- `map.keys()` returning `&Index` → `store.keys()` returning `Index` +- `map.get(&idx)` → `store.get(idx)` +- `map.get_mut(&idx)` → `store.get_mut(idx)` + +Walk through `batch.rs` and update each call. Specifically (line numbers approximate): +- `Batch::posteriors` — uses `iter()` +- `Batch::add_events` — uses `get_mut()`/`insert()` +- `Batch::iteration` — uses `get_mut()`/`get()` heavily +- `Batch::convergence` — uses `posteriors()` and the deltas +- `Batch::forward_prior_out`, `Batch::backward_prior_out` — use `get()` +- `Batch::new_backward_info`, `Batch::new_forward_info` — use `iter_mut()` + +After the rewrite, the `Batch::new` constructor likely becomes: + +```rust +pub fn new(time: i64, p_draw: f64) -> Self { + Self { + events: Vec::new(), + skills: SkillStore::new(), + time, + p_draw, + } +} +``` + +- [ ] **Step 6: Run the existing batch tests** + +```bash +cargo test --features approx batch +``` + +Expected: all batch tests pass. If iteration order through `iter()` changed (ours sorts by Index; HashMap order was arbitrary), some "first event" comparisons may need adjustment — but the EP fixed point is order-independent so posteriors should match within ULPs. + +- [ ] **Step 7: Run full test suite to catch any cross-file fallout** + +```bash +cargo test --features approx +``` + +- [ ] **Step 8: Lint and format** + +```bash +cargo clippy --all-targets --features approx -- -D warnings +cargo fmt +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/storage/ src/lib.rs src/batch.rs +git commit -m "$(cat <<'EOF' +refactor(batch): replace HashMap with dense SkillStore + +Eliminates per-iteration hashing in the within-slice convergence loop +and improves cache locality. SkillStore is a Vec-backed dense store +with a parallel present mask; lookup is O(1) array indexing. + +Iteration now visits indices in ascending order (HashMap was arbitrary); +the EP fixed point is order-independent so posteriors are unchanged +within ULPs. +EOF +)" +``` + +--- + +## Task 5: Introduce `AgentStore` (replace `HashMap>` in `History`) + +**Files:** +- Create: `src/storage/agent_store.rs` +- Modify: `src/storage/mod.rs` (re-export) +- Modify: `src/agent.rs` (`clean()` signature) +- Modify: `src/batch.rs` (signatures: `add_events`, `iteration`, `new_backward_info`, `new_forward_info`, `backward_prior_out`, `log_evidence` take `&AgentStore` not `&HashMap<…>`) +- Modify: `src/history.rs` (use `AgentStore` field) +- Modify: `benches/batch.rs` (build `AgentStore` instead of `HashMap`) + +Same idea as Task 4, parameterised over `D: Drift`. + +- [ ] **Step 1: Write `AgentStore` with tests** + +Create `src/storage/agent_store.rs`: + +```rust +use crate::Index; +use crate::agent::Agent; +use crate::drift::Drift; + +#[derive(Debug)] +pub struct AgentStore { + agents: Vec>>, + n_present: usize, +} + +impl Default for AgentStore { + fn default() -> Self { + Self { + agents: Vec::new(), + n_present: 0, + } + } +} + +impl AgentStore { + pub fn new() -> Self { + Self::default() + } + + pub fn with_capacity(cap: usize) -> Self { + Self { + agents: Vec::with_capacity(cap), + n_present: 0, + } + } + + fn ensure_capacity(&mut self, idx: usize) { + if idx >= self.agents.len() { + self.agents.resize_with(idx + 1, || None); + } + } + + pub fn insert(&mut self, idx: Index, agent: Agent) { + self.ensure_capacity(idx.0); + if self.agents[idx.0].is_none() { + self.n_present += 1; + } + self.agents[idx.0] = Some(agent); + } + + pub fn get(&self, idx: Index) -> Option<&Agent> { + self.agents.get(idx.0).and_then(|slot| slot.as_ref()) + } + + pub fn get_mut(&mut self, idx: Index) -> Option<&mut Agent> { + self.agents.get_mut(idx.0).and_then(|slot| slot.as_mut()) + } + + pub fn contains(&self, idx: Index) -> bool { + self.get(idx).is_some() + } + + pub fn len(&self) -> usize { + self.n_present + } + + pub fn is_empty(&self) -> bool { + self.n_present == 0 + } + + pub fn iter(&self) -> impl Iterator)> { + self.agents + .iter() + .enumerate() + .filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a))) + } + + pub fn iter_mut(&mut self) -> impl Iterator)> { + self.agents + .iter_mut() + .enumerate() + .filter_map(|(i, slot)| slot.as_mut().map(|a| (Index(i), a))) + } + + pub fn values_mut(&mut self) -> impl Iterator> { + self.agents.iter_mut().filter_map(|s| s.as_mut()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::Agent; + use crate::drift::ConstantDrift; + use crate::player::Player; + + #[test] + fn insert_then_get_roundtrip() { + let mut store: AgentStore = AgentStore::new(); + let idx = Index(7); + let agent = Agent { + player: Player::default(), + ..Default::default() + }; + store.insert(idx, agent); + assert!(store.contains(idx)); + assert_eq!(store.len(), 1); + } + + #[test] + fn iter_in_index_order() { + let mut store: AgentStore = AgentStore::new(); + store.insert(Index(2), Agent::default()); + store.insert(Index(0), Agent::default()); + store.insert(Index(5), Agent::default()); + + let keys: Vec = store.iter().map(|(i, _)| i).collect(); + assert_eq!(keys, vec![Index(0), Index(2), Index(5)]); + } +} +``` + +- [ ] **Step 2: Re-export from `src/storage/mod.rs`** + +```rust +mod agent_store; +mod skill_store; + +pub use agent_store::AgentStore; +pub use skill_store::SkillStore; +``` + +- [ ] **Step 3: Run the new tests** + +```bash +cargo test --features approx storage::agent_store +``` + +Expected: 2 passing tests. + +- [ ] **Step 4: Update `src/agent.rs::clean`** + +Current signature: + +```rust +pub(crate) fn clean<'a, D: Drift + 'a, A: Iterator>>( + agents: A, + last_time: bool, +) { + for a in agents { + a.message = N_INF; + if last_time { + a.last_time = i64::MIN; + } + } +} +``` + +Change to take `&mut AgentStore`: + +```rust +use crate::storage::AgentStore; + +pub(crate) fn clean(agents: &mut AgentStore, last_time: bool) { + for a in agents.values_mut() { + a.message = N_INF; + if last_time { + a.last_time = i64::MIN; + } + } +} +``` + +Update call sites in `src/history.rs`: +- `agent::clean(self.agents.values_mut(), false);` → `agent::clean(&mut self.agents, false);` + +- [ ] **Step 5: Update `Batch` method signatures** + +In `src/batch.rs`, replace `&HashMap>` with `&AgentStore` in: + +- `Batch::add_events` +- `Batch::iteration` +- `Batch::convergence` +- `Batch::backward_prior_out` +- `Batch::new_backward_info` +- `Batch::new_forward_info` +- `Batch::log_evidence` +- `Item::within_prior` (also takes `&HashMap>`) +- `Event::within_priors` + +The body changes are mechanical: `agents[&idx]` → `agents.get(idx).expect(...)` (or `agents.get(idx).unwrap()` since the call sites have invariants that the index is present). + +Add the import: + +```rust +use crate::storage::{AgentStore, SkillStore}; +``` + +Remove the now-unused `use std::collections::HashMap;` if there are no other uses. + +- [ ] **Step 6: Update `History` to use `AgentStore`** + +In `src/history.rs`: + +```rust +// Change the field +pub struct History { + size: usize, + pub(crate) batches: Vec, + agents: AgentStore, // was: HashMap> + time: bool, + // ... +} + +// Change the constructors +impl Default for History { + fn default() -> Self { + Self { + // ... + agents: AgentStore::new(), + // ... + } + } +} +``` + +And in `HistoryBuilder::build`: + +```rust +pub fn build(self) -> History { + History { + // ... + agents: AgentStore::new(), + // ... + } +} +``` + +Walk through every `self.agents.foo(...)` call site in `history.rs` and replace `HashMap` semantics with `AgentStore` semantics. Likely changes: +- `self.agents.contains_key(agent)` → `self.agents.contains(*agent)` (note: `Index` is `Copy`) +- `self.agents.insert(*agent, val)` → `self.agents.insert(*agent, val)` (same signature) +- `self.agents.get_mut(agent).unwrap()` → `self.agents.get_mut(*agent).unwrap()` +- `&self.agents` passed to `Batch::*` methods now passes `&AgentStore` directly (no `.values()`/`.iter()` needed) + +- [ ] **Step 7: Update `benches/batch.rs`** + +The bench currently builds `HashMap` and passes `&agents`. Change to: + +```rust +use trueskill_tt::{ + BETA, GAMMA, IndexMap, MU, P_DRAW, SIGMA, agent::Agent, batch::Batch, drift::ConstantDrift, + gaussian::Gaussian, player::Player, storage::AgentStore, +}; + +fn criterion_benchmark(criterion: &mut Criterion) { + let mut index = IndexMap::new(); + let a = index.get_or_create("a"); + let b = index.get_or_create("b"); + let c = index.get_or_create("c"); + + let mut agents: AgentStore = AgentStore::new(); + for idx in [a, b, c] { + agents.insert( + idx, + Agent { + player: Player::new(Gaussian::from_ms(MU, SIGMA), BETA, ConstantDrift(GAMMA)), + ..Default::default() + }, + ); + } + + // ... rest unchanged, but pass &agents (now &AgentStore) +} +``` + +- [ ] **Step 8: Run all tests** + +```bash +cargo test --features approx +``` + +Likely failure mode: tests in `batch.rs` and `history.rs` build their own `HashMap` and pass it. Update those tests to build `AgentStore` instead. Search: + +```bash +grep -rn 'HashMap.*Agent\|HashMap::new.*agent' src/ +``` + +For each test, replace: + +```rust +let mut agents = HashMap::new(); +agents.insert(a, Agent { ... }); +``` + +with: + +```rust +let mut agents: AgentStore = AgentStore::new(); +agents.insert(a, Agent { ... }); +``` + +- [ ] **Step 9: Lint, format, commit** + +```bash +cargo clippy --all-targets --features approx -- -D warnings +cargo fmt +git add src/ benches/batch.rs +git commit -m "$(cat <<'EOF' +refactor: replace HashMap with dense AgentStore + +History and Batch now hold agents in a Vec>> indexed +directly by Index. Eliminates HashMap overhead in the cross-history +forward/backward sweep and within-slice iteration. + +Public Batch::* signatures now take &AgentStore instead of +&HashMap>. The benches/batch.rs and tests are +updated to build AgentStore. Top-level History API is unchanged. +EOF +)" +``` + +--- + +## Task 6: Introduce `ScratchArena` and reuse buffers across `Game::new` calls + +**Files:** +- Create: `src/arena.rs` +- Modify: `src/lib.rs` (declare module) +- Modify: `src/game.rs` (`Game::new` takes `&mut ScratchArena`; replace per-event Vecs) +- Modify: `src/batch.rs` (`Batch` owns a `ScratchArena`; passes to `Game::new`) + +`Game::likelihoods` allocates four Vecs per call: +- `team: Vec` (length `n_teams`) +- `diff: Vec` (length `n_teams - 1`) +- `tie: Vec` (length `n_teams - 1`) +- `margin: Vec` (length `n_teams - 1`) + +For 60 games × 30 iterations × cross-history convergence, that's hundreds of thousands of small allocations per converge. Replace with a reusable arena owned by the `Batch`. + +### Design + +```rust +pub struct ScratchArena { + teams: Vec, + diffs: Vec, + ties: Vec, + margins: Vec, +} +``` + +Each scratch slice is reset by `clear()` (sets `len = 0`, retains capacity). `Game::new` borrows the arena, reserves enough capacity, and writes into the buffers. Lifetimes: the arena outlives any single `Game`; `Game` borrows mutably for the duration of construction and inference. + +Easiest API: `ScratchArena` owns the buffers; `Game::new` takes `&mut ScratchArena` and reads `Vec>` (the public `likelihoods` field) before returning. The lifetime of the borrowed buffers ends with `Game::new`. + +### Tasks + +- [ ] **Step 1: Write `ScratchArena` with a small test** + +Create `src/arena.rs`: + +```rust +use crate::message::{DiffMessage, TeamMessage}; + +#[derive(Debug, Default)] +pub struct ScratchArena { + pub(crate) teams: Vec, + pub(crate) diffs: Vec, + pub(crate) ties: Vec, + pub(crate) margins: Vec, +} + +impl ScratchArena { + pub fn new() -> Self { + Self::default() + } + + pub fn with_capacity(n_teams: usize) -> Self { + Self { + teams: Vec::with_capacity(n_teams), + diffs: Vec::with_capacity(n_teams.saturating_sub(1)), + ties: Vec::with_capacity(n_teams.saturating_sub(1)), + margins: Vec::with_capacity(n_teams.saturating_sub(1)), + } + } + + pub fn reset(&mut self) { + self.teams.clear(); + self.diffs.clear(); + self.ties.clear(); + self.margins.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reset_keeps_capacity() { + let mut arena = ScratchArena::with_capacity(8); + let cap_before = arena.teams.capacity(); + arena.teams.push(TeamMessage::default()); + arena.reset(); + assert_eq!(arena.teams.len(), 0); + assert_eq!(arena.teams.capacity(), cap_before); + } +} +``` + +Note: `TeamMessage` and `DiffMessage` are currently `pub(crate)`. The `ScratchArena` lives in the engine, so `pub(crate)` access is fine — `ScratchArena`'s fields are also `pub(crate)`. + +- [ ] **Step 2: Declare the module** + +In `src/lib.rs`: + +```rust +pub mod arena; +``` + +(Public for the same reason as `storage` — `Batch` exposes it as a constructor argument; T2 can tighten.) + +- [ ] **Step 3: Run the test** + +```bash +cargo test --features approx arena +``` + +Expected: 1 passing test. + +- [ ] **Step 4: Update `Game::new` to take `&mut ScratchArena`** + +In `src/game.rs`: + +```rust +use crate::arena::ScratchArena; + +impl<'a, D: Drift> Game<'a, D> { + pub fn new( + teams: Vec>>, + result: &'a [f64], + weights: &'a [Vec], + p_draw: f64, + arena: &mut ScratchArena, + ) -> Self { + // ... same debug_assert! checks ... + + arena.reset(); + // Pre-size buffers to the exact required lengths. + // Building team/diff/tie/margin into arena buffers. + + let mut this = Self { + teams, + result, + weights, + p_draw, + likelihoods: Vec::new(), + evidence: 0.0, + }; + + this.likelihoods(arena); + this + } + + fn likelihoods(&mut self, arena: &mut ScratchArena) { + let o = sort_perm(self.result, true); + + // Build team messages into the arena instead of allocating fresh. + arena.teams.clear(); + arena.teams.extend(o.iter().map(|&e| { + let performance = self.teams[e] + .iter() + .zip(self.weights[e].iter()) + .fold(N00, |p, (player, &weight)| { + p + (player.performance() * weight) + }); + TeamMessage { + prior: performance, + ..Default::default() + } + })); + + // Same for diffs/ties/margins. Use slice references for the inner loop. + arena.diffs.clear(); + arena.diffs.extend(arena.teams.windows(2).map(|w| DiffMessage { + prior: w[0].prior - w[1].prior, + likelihood: N_INF, + })); + // ... etc, mirroring the original code ... + } +} +``` + +(Full rewrite is mechanical — replace each `Vec::new()` / `vec![…]` / `let mut x = … .collect()` with `arena.X.clear(); arena.X.extend(…)`.) + +- [ ] **Step 5: Update callers of `Game::new`** + +Search: + +```bash +grep -rn 'Game::new' src/ benches/ +``` + +Each call site passes a `&mut ScratchArena`. There are two kinds of callers: + +1. `Batch::iteration`: holds an arena per `Batch`; passes `&mut self.arena`. +2. `Batch::log_evidence`: same. +3. `History::log_evidence`: routes through `Batch::log_evidence`, which already has access. +4. Tests in `src/game.rs::tests`: each test creates a local `let mut arena = ScratchArena::new();` and passes `&mut arena` into the `Game::new` call. + +Update each. + +- [ ] **Step 6: Add `arena: ScratchArena` field to `Batch`** + +In `src/batch.rs`: + +```rust +pub struct Batch { + pub(crate) events: Vec, + pub(crate) skills: SkillStore, + pub(crate) time: i64, + p_draw: f64, + arena: ScratchArena, +} + +impl Batch { + pub fn new(time: i64, p_draw: f64) -> Self { + Self { + events: Vec::new(), + skills: SkillStore::new(), + time, + p_draw, + arena: ScratchArena::new(), + } + } +} +``` + +In `Batch::iteration`, `Batch::log_evidence`, etc., where `Game::new(...)` is called, pass `&mut self.arena`. + +- [ ] **Step 7: Run all tests** + +```bash +cargo test --features approx +``` + +The fixed point is unchanged; tests should pass without golden updates. + +- [ ] **Step 8: Lint, format** + +```bash +cargo clippy --all-targets --features approx -- -D warnings +cargo fmt +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/arena.rs src/lib.rs src/game.rs src/batch.rs +git commit -m "$(cat <<'EOF' +perf(game): reuse buffers via ScratchArena, eliminate per-event allocs + +Game::likelihoods previously allocated four Vecs (teams, diffs, ties, +margins) on every call. Batch now owns one ScratchArena reused across +all events in the slice's iteration loops; Game::new clears and writes +into the arena instead of allocating fresh. + +For a 60-event slice * 30 convergence iterations, this removes ~7200 +small allocations per converge. +EOF +)" +``` + +--- + +## Task 7: Final benchmark comparison and acceptance check + +**Files:** +- Modify: `benches/baseline.txt` (append T0 numbers) + +- [ ] **Step 1: Run final benchmarks** + +```bash +cargo bench --bench batch 2>&1 | tee /tmp/bench-batch-t0.txt +cargo bench --bench gaussian 2>&1 | tee /tmp/bench-gaussian-t0.txt +``` + +- [ ] **Step 2: Append T0 numbers to `benches/baseline.txt`** + +Add to the file: + +``` +# After T0 (date, hardware as above) + +Batch::iteration µs (speedup: x) +Gaussian::add ns (speedup: x) +Gaussian::sub ns (speedup: x) +Gaussian::mul ns (speedup: x) +Gaussian::div ns (speedup: x) +Gaussian::pi ns (now field read) +Gaussian::tau ns (now field read) +``` + +- [ ] **Step 3: Verify acceptance criterion** + +`Batch::iteration` speedup must be **≥3.0×**. If less: +- Re-run `cargo bench` to rule out noise (criterion's confidence interval should make this clear). +- Profile with `cargo flamegraph --bench batch` to find what didn't move. Common culprits: the `sort_perm` allocation, the `posteriors()` round-trip in `Batch::convergence`, the inner `Vec>` builds in `Game::posteriors` (untouched in T0). +- If the gap is real, write up a short post-mortem in the commit message and decide whether to add a remediation step or accept the lower speedup. Do not silently lower the bar. + +If `Gaussian::mul`/`Gaussian::div` did not improve substantially (~5×+ expected since they become two adds vs. four floating-point ops + a sqrt + a reciprocal), investigate. Likely cause: compiler did not inline; check `#[inline]` annotations. + +- [ ] **Step 4: Run the full test suite one last time** + +```bash +cargo test --features approx +cargo clippy --all-targets --features approx -- -D warnings +cargo fmt --check +``` + +All green. + +- [ ] **Step 5: Commit the final benchmark numbers** + +```bash +git add benches/baseline.txt +git commit -m "$(cat <<'EOF' +bench: capture T0 final numbers; X.YYx speedup on Batch::iteration + +T0 acceptance gate met: ≥3x speedup on Batch::iteration vs. baseline. +Closes the T0 tier of the engine redesign. +EOF +)" +``` + +--- + +## Self-review notes (post-plan) + +**Spec coverage:** +- ✅ Gaussian → natural parameters: Task 2 +- ✅ HashMap → dense Vec: Tasks 4 (skills) and 5 (agents) +- ✅ ScratchArena replacing per-event allocs: Task 6 +- ✅ Drop `panic!` in `mu_sigma`: Task 3 +- ✅ Acceptance: ≥3× speedup gate: Tasks 1 and 7 +- ✅ Existing tests pass within ULPs: handled in Task 2 step 6 and Task 4 step 6 +- ✅ No top-level public API change: confirmed in acceptance criteria; pub-but-internal `Batch::*` signatures shift but `History`/`Game::posteriors`/etc. unchanged +- ✅ Sparse-vs-dense per-slice open question: defaulted to dense + present mask in Task 4 per spec + +**Open items deferred to T1+:** +- Factor graph machinery (`Factor`, `VarStore`, `Schedule`) — entire scope of T1 +- Schedule / convergence reporting — T1+T5 +- Renames (`Player → Rating`, etc.) — T2 +- API surface (`Outcome`, `Member`, `Event`) — T2 +- Concurrency, color groups, Rayon — T3 +- Richer factor types — T4 + +**Things to watch during execution:** +- Iteration order through `SkillStore`/`AgentStore` is by-Index, where `HashMap` was arbitrary. EP fixed point is order-independent so posteriors should match — but evidence accumulation in the *first* iteration of `Game::likelihoods` only updates `evidence` from one diff in the loop (`if iter == 0`); if cross-event order shifts at the slice level, the per-iteration evidence may differ even though the converged posterior matches. The acceptance test is on posteriors (within ULPs); evidence delta at convergence should match. +- `pub mod batch;` and `pub mod agent;` exposing internals: T0 keeps them `pub` to avoid disturbing benches. T2 will tighten visibility. +- `mu_sigma` may be entirely deleted by Task 2 (its callers were the old `Mul`/`Div` impls). Task 3's wording handles both cases. diff --git a/docs/superpowers/plans/2026-04-24-t1-factor-graph.md b/docs/superpowers/plans/2026-04-24-t1-factor-graph.md new file mode 100644 index 0000000..232a4be --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-t1-factor-graph.md @@ -0,0 +1,1658 @@ +# T1 — Factor Graph Machinery 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:** Re-implement the within-game inference (`Game::likelihoods`) on top of an explicit factor-graph data structure (`VarStore`, `Factor` trait, `BuiltinFactor` enum, `Schedule` trait), without changing observable behavior. T1 is a pure internal restructure — public API of `Game` and `Batch` is untouched. + +**Architecture:** Variables hold their current Gaussian marginals in a flat `VarStore` indexed by `VarId`. Factors hold their outgoing messages and propagate them via `Factor::propagate(&mut VarStore) -> (f64, f64)` returning the max delta. The four built-in factors (`TeamSum`, `RankDiff`, `Trunc`) — wrapped in a `BuiltinFactor` enum to avoid dyn dispatch in the hot path — exactly reproduce the current EP algorithm. A `Schedule` trait drives factor propagation; the default `EpsilonOrMax` schedule is bit-equivalent to today's hard-coded forward+backward sweep. + +**Tech Stack:** Rust 2024 edition, criterion benchmarks, `approx` crate for floating-point comparisons. Builds on T0 (natural-parameter Gaussian, dense Vec storage, ScratchArena). + +## Acceptance criteria + +- All existing tests pass (`cargo test --features approx`). Tolerance bounded by `1e-6` (same as T0); if a test drifts beyond that, the implementation is wrong. +- Within-game iteration counts match T0 (the `EpsilonOrMax` schedule should produce identical iteration counts on identical inputs). +- `cargo bench --bench batch` shows **no regression** vs T0 (`Batch::iteration` ≤ 21.5 µs on Apple M5 Pro). Aim for parity or modest improvement. +- `cargo clippy --all-targets --features approx` clean. +- `cargo +nightly fmt --check` clean. +- Public API of `History`, `HistoryBuilder`, `Game::new`, `Game::posteriors`, `Player`, `Gaussian`, `quality()` unchanged. Internal types (`Factor`, `VarStore`, `Schedule`, `BuiltinFactor`) are `pub(crate)`. + +## Background — the algorithm we're refactoring + +The current `Game::likelihoods` implements EP for ranked teams with optional draws. For an n-team game (sorted by result, descending), the algorithm: + +1. **Build team performances** (one-shot): for each team, compute the weighted sum of player performance Gaussians (`p + (player.perf() * weight)` folded). Stored as `team[i].prior`. + +2. **Initialise diff priors**: `diff[i].prior = team[i].prior - team[i+1].prior` for adjacent ranked pairs. + +3. **EP loop** (max 10 iterations or until step ≤ 1e-6): + - Forward sweep: for each diff `e`: + - Recompute `diff[e].prior = team[e].posterior_win() - team[e+1].posterior_lose()` + - On iter 0, accumulate evidence: `self.evidence *= cdf(margin, diff.prior) bounds` + - Compute `diff[e].likelihood = approx(diff[e].prior, margin, tie) / diff[e].prior` + - Update `team[e+1].likelihood_lose = team[e].posterior_win() - diff[e].likelihood` + - Backward sweep: for each diff `e` (reverse): + - Same recompute, then update `team[e].likelihood_win = team[e+1].posterior_lose() + diff[e].likelihood` + - Track max delta across all writes. + +4. **Special-case 2-team games** (`diff.len() == 1`): the loop above doesn't execute (range is empty), so a single direct propagation is done. + +5. **Final boundary updates** that close the chain. + +6. **Compute likelihoods**: for each team, the team's "likelihood" is `likelihood_win * likelihood_lose * likelihood_draw`. From this, derive per-player likelihoods via `((m - performance.exclude(p.perf() * w)) * (1.0 / w)).forget(p.beta²)`. + +In factor-graph terms: +- `team[i].prior` is the marginal at the team-perf variable, before any messages. +- `team[i].likelihood_lose` is the outgoing message FROM the RankDiff factor TO team[i] (left-to-right). +- `team[i].likelihood_win` is the outgoing message FROM the RankDiff factor TO team[i-1] (right-to-left). +- `diff[i].likelihood` is the outgoing message FROM the Trunc factor TO diff[i]. +- `diff[i].prior` is the marginal at diff[i] from the RankDiff factor. + +T1 makes these correspondences explicit by introducing factor objects that own these messages. The math stays identical; the organization changes. + +## File map + +**Created:** +- `src/factor/mod.rs` — module root: `VarId`, `VarStore`, `Factor` trait, `BuiltinFactor` enum, `ScheduleReport` +- `src/factor/team_sum.rs` — `TeamSumFactor` (one-shot weighted sum) +- `src/factor/rank_diff.rs` — `RankDiffFactor` (linear-combination factor between two team-perf vars and a diff var) +- `src/factor/trunc.rs` — `TruncFactor` (EP truncation factor on a diff var) +- `src/schedule.rs` — `Schedule` trait + `EpsilonOrMax` impl + +**Modified:** +- `src/lib.rs` — add `pub(crate) mod factor;` and `pub(crate) mod schedule;` +- `src/game.rs` — `Game` becomes `{ vars: VarStore, factors: Vec, ... }`; `Game::likelihoods` builds the graph and runs the schedule +- `src/message.rs` — `TeamMessage` and `DiffMessage` deleted (replaced by factors and VarStore) +- `src/arena.rs` — `ScratchArena` loses the `teams`/`diffs`/`ties`/`margins` fields; gains a `VarStore`, `Vec`, and a `sort_buf: Vec` for sort_perm scratch + +**Touched (test-only):** +- `src/game.rs` (test module) — no API change, but per-iteration goldens may drift by a ULP + +## Design + +### Variables and VarStore + +```rust +// src/factor/mod.rs + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct VarId(pub(crate) u32); + +/// Flat storage for all variable marginals in one game's factor graph. +#[derive(Debug, Default)] +pub(crate) struct VarStore { + marginals: Vec, +} +``` + +`VarStore` is reset and re-populated for each `Game::new` call. It lives in the `ScratchArena` so we don't reallocate the backing buffer. + +### Factor trait + BuiltinFactor enum + +```rust +pub(crate) trait Factor { + /// Update outgoing messages and write back to the var store. + /// Returns max delta `(|Δmu|, |Δsigma|)` across writes this propagation. + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64); + + /// Optional log-evidence contribution. Default: 0.0 (no contribution). + /// Only TruncFactor has a non-trivial impl. + fn log_evidence(&self, _vars: &VarStore) -> f64 { + 0.0 + } +} + +#[derive(Debug)] +pub(crate) enum BuiltinFactor { + TeamSum(TeamSumFactor), + RankDiff(RankDiffFactor), + Trunc(TruncFactor), +} + +impl Factor for BuiltinFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + match self { + Self::TeamSum(f) => f.propagate(vars), + Self::RankDiff(f) => f.propagate(vars), + Self::Trunc(f) => f.propagate(vars), + } + } + fn log_evidence(&self, vars: &VarStore) -> f64 { + match self { + Self::Trunc(f) => f.log_evidence(vars), + _ => 0.0, + } + } +} +``` + +### TeamSumFactor + +Computes the weighted sum of player performances into a team-perf var. Inputs (player priors with beta-noise applied) are stored at construction. Runs once per game (no iteration needed). + +```rust +#[derive(Debug)] +pub(crate) struct TeamSumFactor { + /// Player performance Gaussians (pre-computed with beta noise) and their weights. + inputs: SmallVec<[(Gaussian, f64); 4]>, + /// Output: team performance variable. + out: VarId, +} + +impl Factor for TeamSumFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + let perf = self + .inputs + .iter() + .fold(N00, |acc, (g, w)| acc + (*g * *w)); + let old = vars.get(self.out); + vars.set(self.out, perf); + old.delta(perf) + } +} +``` + +### RankDiffFactor + +Maintains the constraint `diff = team_a - team_b` between three variables. In EP, this means: +- The marginal at `diff` is the convolution of the marginals at `team_a` and `team_b` (in moment form: subtraction). +- Outgoing messages `to_team_a`, `to_team_b`, `to_diff` are stored on the factor. + +In our specific algorithm (which mirrors today's code), each iteration's RankDiff propagation: +1. Reads the team-perf marginals (which already incorporate `to_team_a` / `to_team_b` from previous iterations and any draw factors). +2. Computes `diff_prior = team_a_marginal - team_b_marginal` (in EP terms, this is the cavity for the diff direction). +3. Writes the new `diff_prior` to the diff variable. +4. Returns delta against the previous diff value. + +```rust +#[derive(Debug)] +pub(crate) struct RankDiffFactor { + pub team_a: VarId, + pub team_b: VarId, + pub diff: VarId, +} + +impl Factor for RankDiffFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + let a = vars.get(self.team_a); + let b = vars.get(self.team_b); + let new_diff = a - b; + let old = vars.get(self.diff); + vars.set(self.diff, new_diff); + old.delta(new_diff) + } +} +``` + +### TruncFactor + +Applies the truncation constraint to a diff variable. Stores its outgoing message so the cavity computation gives the correct EP message. + +```rust +#[derive(Debug)] +pub(crate) struct TruncFactor { + pub diff: VarId, + pub margin: f64, + pub tie: bool, + /// Outgoing message to diff var (stored for cavity computation). + pub msg: Gaussian, + /// Cached log-evidence contribution computed on first propagation. + evidence_cached: Option, +} + +impl Factor for TruncFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + // Compute cavity (current diff marginal divided by our outgoing). + let marginal = vars.get(self.diff); + let cavity = marginal / self.msg; + + // First pass: cache the evidence contribution from the cavity. + if self.evidence_cached.is_none() { + self.evidence_cached = Some(evidence_value(cavity, self.margin, self.tie)); + } + + // Apply the truncation approximation to the cavity. + let trunc = approx(cavity, self.margin, self.tie); + + // New outgoing = trunc / cavity (so marginal = cavity * new = trunc). + let new_msg = trunc / cavity; + + let old_msg = self.msg; + self.msg = new_msg; + + // Update marginal: marginal_new = cavity * new_msg = trunc. + vars.set(self.diff, trunc); + + old_msg.delta(new_msg) + } + fn log_evidence(&self, _vars: &VarStore) -> f64 { + // Stored as raw evidence (probability), caller computes ln() if needed. + self.evidence_cached.unwrap_or(1.0).ln() + } +} + +fn evidence_value(diff: Gaussian, margin: f64, tie: bool) -> f64 { + if tie { + cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma()) + } else { + 1.0 - cdf(margin, diff.mu(), diff.sigma()) + } +} +``` + +This is the **important departure** from the current code: today's `team[i].likelihood_lose` and `team[i].likelihood_win` track three separate messages per team (one per neighbor direction × draw). In the cleaner factor-graph model, each RankDiff and Trunc factor holds its own outgoing messages. The algorithm becomes "for each iteration, propagate every factor in order, accumulate deltas." The fixed point is the same. + +**Caveat**: this is only equivalent to today's algorithm if our schedule visits factors in an equivalent order. Today's code does forward-then-backward sweeps over diff factors, with team-perf marginals updated implicitly via `posterior_win()`/`posterior_lose()` accessors that re-multiply the priors and likelihoods. In the new code, we maintain the team-perf marginals explicitly in VarStore, with each RankDiff/Trunc/draw factor's outgoing message stored on the factor. + +### Schedule + +```rust +// src/schedule.rs + +#[derive(Debug, Clone, Copy)] +pub struct ScheduleReport { + pub iterations: usize, + pub final_step: (f64, f64), + pub converged: bool, +} + +pub(crate) trait Schedule { + fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport; +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct EpsilonOrMax { + pub eps: f64, + pub max: usize, +} + +impl Default for EpsilonOrMax { + fn default() -> Self { + Self { eps: 1e-6, max: 10 } + } +} + +impl Schedule for EpsilonOrMax { + fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport { + // Special case: TeamSum factors run exactly once (their inputs don't change). + // RankDiff + Trunc factors iterate to convergence. + // The Game builder lays out factors so all TeamSums come first, followed by + // alternating RankDiff/Trunc pairs. + let n_setup = factors + .iter() + .position(|f| !matches!(f, BuiltinFactor::TeamSum(_))) + .unwrap_or(factors.len()); + + // One-shot setup phase. + for f in &mut factors[..n_setup] { + f.propagate(vars); + } + + let mut iterations = 0; + let mut final_step = (f64::INFINITY, f64::INFINITY); + let mut converged = false; + + for _ in 0..self.max { + let mut step = (0.0_f64, 0.0_f64); + + // Forward sweep through iterating factors. + for f in factors[n_setup..].iter_mut() { + let d = f.propagate(vars); + step.0 = step.0.max(d.0); + step.1 = step.1.max(d.1); + } + + // Backward sweep (reverse order). + for f in factors[n_setup..].iter_mut().rev() { + let d = f.propagate(vars); + step.0 = step.0.max(d.0); + step.1 = step.1.max(d.1); + } + + iterations += 1; + final_step = step; + + if step.0 <= self.eps && step.1 <= self.eps { + converged = true; + break; + } + } + + ScheduleReport { + iterations, + final_step, + converged, + } + } +} +``` + +**Note on iteration counts**: today's code does forward sweep then backward sweep within ONE while-loop iteration. The check `tuple_gt(step, 1e-6) && iter < 10` happens between iterations. The schedule above does the same, so iteration counts should match. + +### Game refactor + +`Game` becomes: +```rust +pub struct Game<'a, D: Drift> { + teams: Vec>>, + result: &'a [f64], + weights: &'a [Vec], + p_draw: f64, + pub(crate) likelihoods: Vec>, + pub(crate) evidence: f64, +} +``` + +Same public surface. Internally, `Game::new`: +1. Calls `arena.reset_for_game(...)` to clear VarStore + factors + sort_buf. +2. Sorts teams by result (using arena.sort_buf). +3. For each sorted team, creates a `team_perf` var (initial: N_INF) and a `TeamSumFactor` writing to it. +4. For each adjacent pair, creates a `diff` var (initial: N_INF), a `RankDiffFactor` reading both team_perfs and writing to diff, and a `TruncFactor` operating on diff. +5. Runs the schedule. +6. Computes evidence as the product of `TruncFactor::log_evidence().exp()` (in linear space, matching old `evidence` field). +7. Computes per-team likelihoods from the team-perf marginals and per-player likelihoods via the existing `exclude` logic. + +## Task list + +--- + +### Task 1: Pre-flight verification of T0 baseline + +**Files:** +- Read: `benches/baseline.txt` + +- [ ] **Step 1: Confirm tests pass on the current branch** + +```bash +cargo test --features approx --lib +``` + +Expected: `38 passed; 0 failed`. If anything is failing, do not proceed — investigate first. + +- [ ] **Step 2: Capture a fresh T0 reference number for `Batch::iteration`** + +```bash +cargo bench --bench batch 2>&1 | grep "Batch::iteration" +``` + +Expected: in the same range as `benches/baseline.txt` (~21 µs on Apple M5 Pro). If your hardware differs, note the local value as your "T0 reference." + +- [ ] **Step 3: No commit** — this is a verification-only task. + +--- + +### Task 2: Introduce `VarId` and `VarStore` + +**Files:** +- Create: `src/factor/mod.rs` +- Modify: `src/lib.rs` (declare module) + +- [ ] **Step 1: Create `src/factor/mod.rs` with the new types** + +```rust +//! Factor graph machinery for within-game inference. + +use crate::gaussian::Gaussian; + +/// Identifier for a variable in a `VarStore`. +/// +/// Variables hold the current Gaussian marginal and are owned by exactly one +/// `VarStore`. `VarId` is meaningful only within its owning store. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct VarId(pub(crate) u32); + +/// Flat storage of variable marginals. +/// +/// Variables are allocated by `alloc()` and accessed by `VarId`. The store is +/// reused across `Game::new` calls (it lives in the `ScratchArena`); call +/// `clear()` before reuse. +#[derive(Debug, Default)] +pub(crate) struct VarStore { + marginals: Vec, +} + +impl VarStore { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn clear(&mut self) { + self.marginals.clear(); + } + + pub(crate) fn len(&self) -> usize { + self.marginals.len() + } + + pub(crate) fn alloc(&mut self, init: Gaussian) -> VarId { + let id = VarId(self.marginals.len() as u32); + self.marginals.push(init); + id + } + + pub(crate) fn get(&self, id: VarId) -> Gaussian { + self.marginals[id.0 as usize] + } + + pub(crate) fn set(&mut self, id: VarId, g: Gaussian) { + self.marginals[id.0 as usize] = g; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::N_INF; + + #[test] + fn alloc_assigns_sequential_ids() { + let mut store = VarStore::new(); + let a = store.alloc(N_INF); + let b = store.alloc(N_INF); + let c = store.alloc(N_INF); + assert_eq!(a, VarId(0)); + assert_eq!(b, VarId(1)); + assert_eq!(c, VarId(2)); + assert_eq!(store.len(), 3); + } + + #[test] + fn get_returns_initial_value() { + let mut store = VarStore::new(); + let g = Gaussian::from_ms(2.5, 1.0); + let id = store.alloc(g); + assert_eq!(store.get(id), g); + } + + #[test] + fn set_updates_value() { + let mut store = VarStore::new(); + let id = store.alloc(N_INF); + let new = Gaussian::from_ms(3.0, 0.5); + store.set(id, new); + assert_eq!(store.get(id), new); + } + + #[test] + fn clear_resets_length_keeping_capacity() { + let mut store = VarStore::new(); + store.alloc(N_INF); + store.alloc(N_INF); + let cap = store.marginals.capacity(); + store.clear(); + assert_eq!(store.len(), 0); + assert_eq!(store.marginals.capacity(), cap); + } +} +``` + +- [ ] **Step 2: Declare the module in `src/lib.rs`** + +Add this line near the other module declarations (alphabetical, after `mod error`): + +```rust +pub(crate) mod factor; +``` + +- [ ] **Step 3: Run the tests** + +```bash +cargo test --features approx --lib factor::tests +``` + +Expected: 4 passing tests. + +- [ ] **Step 4: Commit** + +```bash +git add src/factor/mod.rs src/lib.rs +git commit -m "$(cat <<'EOF' +feat(factor): introduce VarId and VarStore + +Foundation types for the T1 factor graph machinery. VarStore is a +flat Vec indexed by VarId; variables are allocated by +alloc() and the store can be cleared between games to reuse capacity. + +Part of T1 of docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md. +EOF +)" +``` + +--- + +### Task 3: Define the `Factor` trait and `BuiltinFactor` enum + +**Files:** +- Modify: `src/factor/mod.rs` + +- [ ] **Step 1: Add the trait + enum definitions** + +Append to `src/factor/mod.rs` (after the `VarStore` impl and before the test module): + +```rust +/// A factor in the EP graph. +/// +/// Factors hold their own outgoing messages and propagate them by reading +/// connected variable marginals from a `VarStore` and writing back updated +/// marginals. +pub(crate) trait Factor { + /// Update outgoing messages and write back to the var store. + /// + /// Returns the max delta `(|Δmu|, |Δsigma|)` across writes this + /// propagation. Used by the `Schedule` to detect convergence. + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64); + + /// Optional log-evidence contribution. Default 0.0 (no contribution). + fn log_evidence(&self, _vars: &VarStore) -> f64 { + 0.0 + } +} + +/// Enum dispatcher for the built-in factor types. +/// +/// Using an enum instead of `Box` keeps factor data inline and +/// avoids virtual-call overhead in the hot inference loop. +#[derive(Debug)] +pub(crate) enum BuiltinFactor { + TeamSum(team_sum::TeamSumFactor), + RankDiff(rank_diff::RankDiffFactor), + Trunc(trunc::TruncFactor), +} + +impl Factor for BuiltinFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + match self { + Self::TeamSum(f) => f.propagate(vars), + Self::RankDiff(f) => f.propagate(vars), + Self::Trunc(f) => f.propagate(vars), + } + } + + fn log_evidence(&self, vars: &VarStore) -> f64 { + match self { + Self::Trunc(f) => f.log_evidence(vars), + _ => 0.0, + } + } +} + +pub(crate) mod rank_diff; +pub(crate) mod team_sum; +pub(crate) mod trunc; +``` + +- [ ] **Step 2: Create stub files for the three factor modules so the crate compiles** + +`src/factor/team_sum.rs`: +```rust +use crate::factor::{Factor, VarId, VarStore}; +use crate::gaussian::Gaussian; + +#[derive(Debug)] +pub(crate) struct TeamSumFactor { + pub(crate) inputs: Vec<(Gaussian, f64)>, + pub(crate) out: VarId, +} + +impl Factor for TeamSumFactor { + fn propagate(&mut self, _vars: &mut VarStore) -> (f64, f64) { + unimplemented!("TeamSumFactor stub — implemented in Task 4") + } +} +``` + +`src/factor/rank_diff.rs`: +```rust +use crate::factor::{Factor, VarId, VarStore}; + +#[derive(Debug)] +pub(crate) struct RankDiffFactor { + pub(crate) team_a: VarId, + pub(crate) team_b: VarId, + pub(crate) diff: VarId, +} + +impl Factor for RankDiffFactor { + fn propagate(&mut self, _vars: &mut VarStore) -> (f64, f64) { + unimplemented!("RankDiffFactor stub — implemented in Task 5") + } +} +``` + +`src/factor/trunc.rs`: +```rust +use crate::factor::{Factor, VarId, VarStore}; +use crate::gaussian::Gaussian; +use crate::N_INF; + +#[derive(Debug)] +pub(crate) struct TruncFactor { + pub(crate) diff: VarId, + pub(crate) margin: f64, + pub(crate) tie: bool, + pub(crate) msg: Gaussian, + pub(crate) evidence_cached: Option, +} + +impl TruncFactor { + pub(crate) fn new(diff: VarId, margin: f64, tie: bool) -> Self { + Self { + diff, + margin, + tie, + msg: N_INF, + evidence_cached: None, + } + } +} + +impl Factor for TruncFactor { + fn propagate(&mut self, _vars: &mut VarStore) -> (f64, f64) { + unimplemented!("TruncFactor stub — implemented in Task 6") + } +} +``` + +- [ ] **Step 3: Verify the crate still builds (the unused stubs will warn)** + +```bash +cargo build --features approx +``` + +Expected: builds successfully with warnings about unimplemented methods (because nothing calls them yet). + +- [ ] **Step 4: Commit** + +```bash +git add src/factor/ +git commit -m "$(cat <<'EOF' +feat(factor): introduce Factor trait and BuiltinFactor enum + +Adds the trait that all factors implement and the enum dispatcher +used by the schedule to drive heterogeneous factors without dynamic +dispatch in the hot loop. + +The three built-in factors (TeamSum, RankDiff, Trunc) are stubbed +out; concrete implementations follow in tasks 4-6. +EOF +)" +``` + +--- + +### Task 4: Implement `TeamSumFactor` + +**Files:** +- Modify: `src/factor/team_sum.rs` + +- [ ] **Step 1: Write the failing test** + +Replace the contents of `src/factor/team_sum.rs` with: + +```rust +use crate::factor::{Factor, VarId, VarStore}; +use crate::gaussian::Gaussian; +use crate::{N00, N_INF}; + +/// Computes the weighted sum of player performances into a team-perf var. +/// +/// Inputs are pre-computed player performance Gaussians (i.e., player priors +/// already with beta² noise added via `Player::performance()`). The factor +/// runs once per game and writes the weighted sum to the output var. +#[derive(Debug)] +pub(crate) struct TeamSumFactor { + pub(crate) inputs: Vec<(Gaussian, f64)>, + pub(crate) out: VarId, +} + +impl Factor for TeamSumFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + let perf = self + .inputs + .iter() + .fold(N00, |acc, (g, w)| acc + (*g * *w)); + let old = vars.get(self.out); + vars.set(self.out, perf); + old.delta(perf) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_player_unit_weight() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g = Gaussian::from_ms(25.0, 5.0); + let mut f = TeamSumFactor { + inputs: vec![(g, 1.0)], + out, + }; + + f.propagate(&mut vars); + let result = vars.get(out); + // N00 + (g * 1.0) = g (in moment form) + assert!((result.mu() - 25.0).abs() < 1e-12); + assert!((result.sigma() - 5.0).abs() < 1e-12); + } + + #[test] + fn two_players_summed() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g1 = Gaussian::from_ms(20.0, 3.0); + let g2 = Gaussian::from_ms(30.0, 4.0); + let mut f = TeamSumFactor { + inputs: vec![(g1, 1.0), (g2, 1.0)], + out, + }; + + f.propagate(&mut vars); + let result = vars.get(out); + // sum: mu = 20 + 30 = 50, var = 9 + 16 = 25, sigma = 5 + assert!((result.mu() - 50.0).abs() < 1e-12); + assert!((result.sigma() - 5.0).abs() < 1e-12); + } + + #[test] + fn weighted_inputs() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g = Gaussian::from_ms(10.0, 2.0); + let mut f = TeamSumFactor { + inputs: vec![(g, 2.0)], + out, + }; + + f.propagate(&mut vars); + let result = vars.get(out); + // g * 2.0: mu = 10*2 = 20, sigma = 2*2 = 4 + assert!((result.mu() - 20.0).abs() < 1e-12); + assert!((result.sigma() - 4.0).abs() < 1e-12); + } + + #[test] + fn delta_is_zero_on_repeat_propagate() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g = Gaussian::from_ms(5.0, 1.0); + let mut f = TeamSumFactor { + inputs: vec![(g, 1.0)], + out, + }; + + f.propagate(&mut vars); + let (dmu, dsig) = f.propagate(&mut vars); + assert!(dmu < 1e-12, "expected ~0 delta on repeat, got {}", dmu); + assert!(dsig < 1e-12); + } +} +``` + +- [ ] **Step 2: Run the tests — they should fail compile because the stub uses `unimplemented!`. After this rewrite they should pass.** + +```bash +cargo test --features approx --lib factor::team_sum +``` + +Expected: 4 passing tests. + +- [ ] **Step 3: Commit** + +```bash +git add src/factor/team_sum.rs +git commit -m "$(cat <<'EOF' +feat(factor): implement TeamSumFactor + +Computes the weighted sum of player performance Gaussians into a +team-performance variable. Runs once per game (no iteration needed). +EOF +)" +``` + +--- + +### Task 5: Implement `RankDiffFactor` + +**Files:** +- Modify: `src/factor/rank_diff.rs` + +- [ ] **Step 1: Replace the stub with the implementation + tests** + +```rust +use crate::factor::{Factor, VarId, VarStore}; + +/// Maintains the constraint `diff = team_a - team_b` between three vars. +/// +/// On each propagation: +/// - Reads marginals at `team_a` and `team_b` (which already incorporate any +/// incoming messages from neighboring factors). +/// - Computes `new_diff = team_a - team_b` (variance addition; see Gaussian::Sub). +/// - Writes the new marginal to `diff`. +/// - Returns the delta against the previous diff value. +/// +/// This factor does NOT store an outgoing message; the diff variable is +/// effectively replaced on each propagation. The TruncFactor on the same diff +/// var holds the EP-divide message that produces the cavity. +#[derive(Debug)] +pub(crate) struct RankDiffFactor { + pub(crate) team_a: VarId, + pub(crate) team_b: VarId, + pub(crate) diff: VarId, +} + +impl Factor for RankDiffFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + let a = vars.get(self.team_a); + let b = vars.get(self.team_b); + let new_diff = a - b; + let old = vars.get(self.diff); + vars.set(self.diff, new_diff); + old.delta(new_diff) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gaussian::Gaussian; + use crate::N_INF; + + #[test] + fn diff_of_two_known_gaussians() { + let mut vars = VarStore::new(); + let team_a = vars.alloc(Gaussian::from_ms(25.0, 3.0)); + let team_b = vars.alloc(Gaussian::from_ms(20.0, 4.0)); + let diff = vars.alloc(N_INF); + + let mut f = RankDiffFactor { + team_a, + team_b, + diff, + }; + f.propagate(&mut vars); + + let result = vars.get(diff); + // mu = 25 - 20 = 5; var = 9 + 16 = 25; sigma = 5 + assert!((result.mu() - 5.0).abs() < 1e-12); + assert!((result.sigma() - 5.0).abs() < 1e-12); + } + + #[test] + fn delta_zero_on_repeat() { + let mut vars = VarStore::new(); + let team_a = vars.alloc(Gaussian::from_ms(10.0, 2.0)); + let team_b = vars.alloc(Gaussian::from_ms(8.0, 1.0)); + let diff = vars.alloc(N_INF); + + let mut f = RankDiffFactor { + team_a, + team_b, + diff, + }; + f.propagate(&mut vars); + let (dmu, dsig) = f.propagate(&mut vars); + assert!(dmu < 1e-12); + assert!(dsig < 1e-12); + } + + #[test] + fn delta_reflects_team_change() { + let mut vars = VarStore::new(); + let team_a = vars.alloc(Gaussian::from_ms(10.0, 1.0)); + let team_b = vars.alloc(Gaussian::from_ms(0.0, 1.0)); + let diff = vars.alloc(N_INF); + + let mut f = RankDiffFactor { + team_a, + team_b, + diff, + }; + f.propagate(&mut vars); + + // change team_a, repropagate; delta should be positive + vars.set(team_a, Gaussian::from_ms(15.0, 1.0)); + let (dmu, _dsig) = f.propagate(&mut vars); + assert!(dmu > 4.0, "expected ~5 delta, got {}", dmu); + } +} +``` + +- [ ] **Step 2: Run the tests** + +```bash +cargo test --features approx --lib factor::rank_diff +``` + +Expected: 3 passing tests. + +- [ ] **Step 3: Commit** + +```bash +git add src/factor/rank_diff.rs +git commit -m "$(cat <<'EOF' +feat(factor): implement RankDiffFactor + +Maintains diff = team_a - team_b across three variables. On each +propagation, reads the team-perf marginals (which may have been +updated by neighboring factors) and computes the new diff via +Gaussian Sub (variance addition). +EOF +)" +``` + +--- + +### Task 6: Implement `TruncFactor` + +**Files:** +- Modify: `src/factor/trunc.rs` +- Modify: `src/lib.rs` (expose `cdf`, `approx` to the factor module) + +- [ ] **Step 1: Make `cdf` and `approx` `pub(crate)` so the factor can use them** + +In `src/lib.rs`, change: +```rust +fn cdf(x: f64, mu: f64, sigma: f64) -> f64 { +``` +to: +```rust +pub(crate) fn cdf(x: f64, mu: f64, sigma: f64) -> f64 { +``` + +`approx` is already `pub(crate)`, no change there. + +- [ ] **Step 2: Replace the stub with the full TruncFactor** + +```rust +use crate::factor::{Factor, VarId, VarStore}; +use crate::gaussian::Gaussian; +use crate::{N_INF, approx, cdf}; + +/// EP truncation factor on a diff variable. +/// +/// Implements the rectified-Gaussian approximation that turns a diff +/// distribution into a "this team rank-beats that team" or "tied" likelihood. +/// Stores its outgoing message to the diff variable so the cavity computation +/// produces the correct EP message on each propagation. +#[derive(Debug)] +pub(crate) struct TruncFactor { + pub(crate) diff: VarId, + pub(crate) margin: f64, + pub(crate) tie: bool, + /// Outgoing message to the diff variable (initial: N_INF, the EP identity). + pub(crate) msg: Gaussian, + /// Cached evidence (linear, not log) computed from the cavity on first propagation. + pub(crate) evidence_cached: Option, +} + +impl TruncFactor { + pub(crate) fn new(diff: VarId, margin: f64, tie: bool) -> Self { + Self { + diff, + margin, + tie, + msg: N_INF, + evidence_cached: None, + } + } +} + +impl Factor for TruncFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (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 = trunc. + let new_msg = trunc / cavity; + let old_msg = self.msg; + self.msg = new_msg; + + // Update the marginal. + vars.set(self.diff, trunc); + + old_msg.delta(new_msg) + } + + fn log_evidence(&self, _vars: &VarStore) -> f64 { + self.evidence_cached.unwrap_or(1.0).ln() + } +} + +/// P(diff > margin) for non-tie, P(|diff| < margin) for tie. +fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 { + if tie { + cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma()) + } else { + 1.0 - cdf(margin, diff.mu(), diff.sigma()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idempotent_after_convergence() { + // After enough iterations, propagate should return ~0 delta. + let mut vars = VarStore::new(); + // Set diff to a typical EP value. + let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0)); + + let mut f = TruncFactor::new(diff, 0.0, false); + + // Propagate many times; delta should drop toward 0. + let mut last = (f64::INFINITY, f64::INFINITY); + for _ in 0..20 { + last = f.propagate(&mut vars); + } + assert!(last.0 < 1e-10, "expected converged delta, got {}", last.0); + assert!(last.1 < 1e-10); + } + + #[test] + fn evidence_cached_on_first_propagate() { + let mut vars = VarStore::new(); + let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0)); + + let mut f = TruncFactor::new(diff, 0.0, false); + assert!(f.evidence_cached.is_none()); + + f.propagate(&mut vars); + assert!(f.evidence_cached.is_some()); + let first = f.evidence_cached.unwrap(); + + // Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748 + assert!(first > 0.7); + assert!(first < 0.8); + + // Subsequent propagations don't change it. + f.propagate(&mut vars); + assert_eq!(f.evidence_cached.unwrap(), first); + } + + #[test] + fn tie_evidence_uses_two_sided() { + let mut vars = VarStore::new(); + let diff = vars.alloc(Gaussian::from_ms(0.0, 2.0)); + + let mut f = TruncFactor::new(diff, 1.0, true); + f.propagate(&mut vars); + + // For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383 + let ev = f.evidence_cached.unwrap(); + assert!(ev > 0.35 && ev < 0.42); + } +} +``` + +- [ ] **Step 3: Run the tests** + +```bash +cargo test --features approx --lib factor::trunc +``` + +Expected: 3 passing tests. + +- [ ] **Step 4: Commit** + +```bash +git add src/factor/trunc.rs src/lib.rs +git commit -m "$(cat <<'EOF' +feat(factor): implement TruncFactor with cached evidence + +EP truncation factor that operates on a diff variable. Stores its +outgoing message so the cavity computation produces the correct EP +message on each propagation. The first propagation caches the +evidence contribution (cdf-bounded probability) for log_evidence(). + +Promotes lib::cdf to pub(crate) so the factor can use it. +EOF +)" +``` + +--- + +### Task 7: Define `Schedule` trait and `EpsilonOrMax` impl + +**Files:** +- Create: `src/schedule.rs` +- Modify: `src/lib.rs` (declare module + export `ScheduleReport`) + +- [ ] **Step 1: Create `src/schedule.rs`** + +```rust +//! Schedule trait and built-in implementations. +//! +//! A schedule drives factor propagation to convergence. The default +//! `EpsilonOrMax` performs one TeamSum sweep (setup) then alternating +//! forward/backward sweeps over the iterating factors until the max +//! delta drops below epsilon or `max` iterations is reached. + +use crate::factor::{BuiltinFactor, Factor, VarStore}; + +/// Result returned by a `Schedule::run` call. +#[derive(Debug, Clone, Copy)] +pub struct ScheduleReport { + pub iterations: usize, + pub final_step: (f64, f64), + pub converged: bool, +} + +/// Drives factor propagation to convergence. +pub(crate) trait Schedule { + fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport; +} + +/// Default schedule: sweep forward then backward until step ≤ eps or iter == max. +/// +/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the +/// same factor layout (TeamSums first, then alternating RankDiff/Trunc pairs). +#[derive(Debug, Clone, Copy)] +pub(crate) struct EpsilonOrMax { + pub eps: f64, + pub max: usize, +} + +impl Default for EpsilonOrMax { + fn default() -> Self { + // Matches today's hard-coded tolerance and iteration cap. + Self { eps: 1e-6, max: 10 } + } +} + +impl Schedule for EpsilonOrMax { + fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport { + // Partition: leading run of TeamSum factors run exactly once (setup). + let n_setup = factors + .iter() + .position(|f| !matches!(f, BuiltinFactor::TeamSum(_))) + .unwrap_or(factors.len()); + + for f in factors[..n_setup].iter_mut() { + f.propagate(vars); + } + + let mut iterations = 0; + let mut final_step = (f64::INFINITY, f64::INFINITY); + let mut converged = false; + + for _ in 0..self.max { + let mut step = (0.0_f64, 0.0_f64); + + // Forward sweep over iterating factors. + for f in factors[n_setup..].iter_mut() { + let d = f.propagate(vars); + step.0 = step.0.max(d.0); + step.1 = step.1.max(d.1); + } + + // Backward sweep. + for f in factors[n_setup..].iter_mut().rev() { + let d = f.propagate(vars); + step.0 = step.0.max(d.0); + step.1 = step.1.max(d.1); + } + + iterations += 1; + final_step = step; + + if step.0 <= self.eps && step.1 <= self.eps { + converged = true; + break; + } + } + + ScheduleReport { + iterations, + final_step, + converged, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::factor::team_sum::TeamSumFactor; + use crate::gaussian::Gaussian; + use crate::N_INF; + + #[test] + fn schedule_runs_setup_factors_once() { + // Single TeamSum factor; schedule should propagate it exactly once and report 0 iterations. + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let factors = vec![BuiltinFactor::TeamSum(TeamSumFactor { + inputs: vec![(Gaussian::from_ms(5.0, 1.0), 1.0)], + out, + })]; + let mut factors = factors; + let schedule = EpsilonOrMax::default(); + let report = schedule.run(&mut factors, &mut vars); + assert_eq!(report.iterations, 0); + // The team-perf var should hold the sum. + let result = vars.get(out); + assert!((result.mu() - 5.0).abs() < 1e-12); + } + + #[test] + fn report_marks_converged_when_step_below_eps() { + // Trivial setup-only graph: no iterating factors; should converge immediately. + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor { + inputs: vec![(Gaussian::from_ms(0.0, 1.0), 1.0)], + out, + })]; + let report = EpsilonOrMax::default().run(&mut factors, &mut vars); + // No iterating factors → 0 iterations, converged = false in the strict sense + // because we never entered the loop. Documented behavior: `converged` is true + // only if the inner loop ran and saw step <= eps. + // For the no-iterating-factors case, iterations == 0 and converged == false. + assert_eq!(report.iterations, 0); + } +} +``` + +- [ ] **Step 2: Declare the module + re-export in `src/lib.rs`** + +Add near the other module declarations: +```rust +pub(crate) mod schedule; +``` + +And add to the public re-exports: +```rust +pub use schedule::ScheduleReport; +``` + +- [ ] **Step 3: Run the tests** + +```bash +cargo test --features approx --lib schedule +``` + +Expected: 2 passing tests. + +- [ ] **Step 4: Commit** + +```bash +git add src/schedule.rs src/lib.rs +git commit -m "$(cat <<'EOF' +feat(schedule): add Schedule trait and EpsilonOrMax impl + +EpsilonOrMax mirrors today's Game::likelihoods loop: sweep forward +then backward over iterating factors, capped at 10 iterations or +step <= 1e-6. Setup factors (TeamSum) run exactly once before the +loop begins. + +ScheduleReport is the only public surface from this module. +EOF +)" +``` + +--- + +### Task 8: Refactor `Game` to use the factor graph + +**Files:** +- Modify: `src/game.rs` (large rewrite of `likelihoods()`) +- Modify: `src/arena.rs` (replace TeamMessage/DiffMessage buffers with VarStore + factors + sort_buf) +- Delete: `src/message.rs` (no longer needed) — actually, `pub(crate)` types may still be referenced; we'll let the compiler tell us if we can delete. + +This is the largest task in T1. It replaces the body of `Game::likelihoods` with: build the factor graph from teams/result/weights/p_draw, run the schedule, then extract per-team likelihoods. + +- [ ] **Step 1: Rewrite `src/arena.rs` — drop TeamMessage/DiffMessage buffers** + +```rust +use crate::factor::{BuiltinFactor, VarStore}; + +/// Reusable scratch buffers for `Game::likelihoods`. +/// +/// A `Batch` owns one arena; all events in the slice share it across +/// the convergence iterations. +#[derive(Debug, Default)] +pub struct ScratchArena { + pub(crate) vars: VarStore, + pub(crate) factors: Vec, + pub(crate) sort_buf: Vec, +} + +impl ScratchArena { + pub fn new() -> Self { + Self::default() + } + + #[inline] + pub(crate) fn reset(&mut self) { + self.vars.clear(); + self.factors.clear(); + self.sort_buf.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::N_INF; + + #[test] + fn reset_keeps_capacity() { + let mut arena = ScratchArena::new(); + arena.vars.alloc(N_INF); + arena.sort_buf.push(42); + let var_cap = arena.vars.marginals.capacity(); + let sort_cap = arena.sort_buf.capacity(); + arena.reset(); + assert_eq!(arena.vars.len(), 0); + assert_eq!(arena.sort_buf.len(), 0); + assert_eq!(arena.vars.marginals.capacity(), var_cap); + assert_eq!(arena.sort_buf.capacity(), sort_cap); + } +} +``` + +You'll need to make `VarStore::marginals` `pub(crate)` for the capacity check (or add a `capacity()` method). Do whichever you prefer; the test is non-essential if neither feels right. + +- [ ] **Step 2: Rewrite `Game::likelihoods` in `src/game.rs`** + +Replace the existing `likelihoods` method with: + +```rust +fn likelihoods(&mut self, arena: &mut ScratchArena) { + arena.reset(); + let result = self.result; + let weights = self.weights; + let teams = &self.teams; + let p_draw = self.p_draw; + + // 1. Sort teams by result, descending. Use arena's sort_buf as scratch. + let n_teams = teams.len(); + arena.sort_buf.clear(); + arena.sort_buf.extend(0..n_teams); + arena.sort_buf.sort_by(|&i, &j| { + result[j].partial_cmp(&result[i]).unwrap_or(std::cmp::Ordering::Equal) + }); + let order = &arena.sort_buf; + + // 2. Allocate team-perf vars in sorted order; create one TeamSumFactor each. + let mut team_vars: SmallVec<[VarId; 8]> = SmallVec::new(); + for &t_idx in order.iter() { + let var = arena.vars.alloc(N_INF); + team_vars.push(var); + let inputs: Vec<(Gaussian, f64)> = teams[t_idx] + .iter() + .zip(weights[t_idx].iter()) + .map(|(p, &w)| (p.performance(), w)) + .collect(); + arena.factors.push(BuiltinFactor::TeamSum(TeamSumFactor { + inputs, + out: var, + })); + } + + // 3. For each adjacent pair, allocate diff var and create RankDiff + Trunc factors. + let mut diff_vars: SmallVec<[VarId; 8]> = SmallVec::new(); + for w in 0..n_teams.saturating_sub(1) { + let diff = arena.vars.alloc(N_INF); + diff_vars.push(diff); + arena.factors.push(BuiltinFactor::RankDiff(RankDiffFactor { + team_a: team_vars[w], + team_b: team_vars[w + 1], + diff, + })); + let tie = result[order[w]] == result[order[w + 1]]; + let margin = if p_draw == 0.0 { + 0.0 + } else { + let beta_a: f64 = teams[order[w]].iter().map(|p| p.beta.powi(2)).sum(); + let beta_b: f64 = teams[order[w + 1]].iter().map(|p| p.beta.powi(2)).sum(); + compute_margin(p_draw, (beta_a + beta_b).sqrt()) + }; + arena.factors.push(BuiltinFactor::Trunc(TruncFactor::new(diff, margin, tie))); + } + + // 4. Run the schedule. + let report = EpsilonOrMax::default().run(&mut arena.factors, &mut arena.vars); + let _ = report; // (currently unused; future API will surface this) + + // 5. Compute evidence from cached TruncFactor evidences (in linear space, matching old field). + self.evidence = arena + .factors + .iter() + .map(|f| f.log_evidence(&arena.vars)) + .map(f64::exp) + .product(); + + // 6. Per-team likelihoods. The team-perf marginal is what `team_likelihood` was. + // Map back to original (un-sorted) team order. + let m_t_ft: SmallVec<[Gaussian; 8]> = (0..n_teams) + .map(|t_idx| { + let sorted_pos = order.iter().position(|&x| x == t_idx).unwrap(); + arena.vars.get(team_vars[sorted_pos]) + }) + .collect(); + + self.likelihoods = teams + .iter() + .zip(weights.iter()) + .zip(m_t_ft.iter()) + .map(|((p, w), &m)| { + let performance = p + .iter() + .zip(w.iter()) + .fold(N00, |acc, (player, &weight)| { + acc + (player.performance() * weight) + }); + p.iter() + .zip(w.iter()) + .map(|(player, &w)| { + ((m - performance.exclude(player.performance() * w)) * (1.0 / w)) + .forget(player.beta.powi(2)) + }) + .collect::>() + }) + .collect::>(); +} +``` + +You'll need to update the `use` statements at the top of `game.rs`: + +```rust +use crate::{ + N_INF, N00, + arena::ScratchArena, + compute_margin, + drift::Drift, + factor::{BuiltinFactor, Factor, VarId}, + factor::rank_diff::RankDiffFactor, + factor::team_sum::TeamSumFactor, + factor::trunc::TruncFactor, + gaussian::Gaussian, + player::Player, + schedule::{EpsilonOrMax, Schedule}, +}; +use smallvec::SmallVec; +``` + +You may also need to add `smallvec` to `Cargo.toml`: + +```toml +[dependencies] +smallvec = "1" +``` + +(Check `cargo tree` to see if it's already pulled in transitively; if so, no change needed.) + +- [ ] **Step 3: Try to build** + +```bash +cargo build --features approx +``` + +Expected: the build will likely fail with several errors. Fix them iteratively. Common issues: +- `message.rs` is no longer used — delete its `mod message;` declaration in `lib.rs` (and the file itself) if nothing references `TeamMessage`/`DiffMessage`. +- The `evidence()` and `tuple_max`/`tuple_gt` helpers in `lib.rs` may now be unused — remove them. +- Some imports may be dead. + +If `message.rs` deletion causes test failures, leave it and we'll clean up in T2. Mark this clearly with a comment. + +- [ ] **Step 4: Run the test suite** + +```bash +cargo test --features approx --lib +``` + +Expected: most tests pass. **Some hardcoded golden values in `game.rs::tests` and `history.rs::tests` may drift by a few ULPs** because the new schedule iterates factors in a slightly different order than today's hand-rolled loop. + +For each failing test: +1. Check the actual output (the test harness prints `left = ...` vs `right = ...`). +2. If the difference is within `1e-6` (the existing tolerance is `epsilon = 1e-6`), the test framework should pass it. If it doesn't, the difference is larger — investigate. +3. If the difference is in the last few ULPs and within `1e-5`, update the golden value with a comment explaining the T1 ULP shift. +4. If the difference is larger than `1e-5`, the implementation is wrong — review the factor logic. + +- [ ] **Step 5: Format and lint** + +```bash +cargo +nightly fmt +cargo clippy --all-targets --features approx -- -D warnings +``` + +- [ ] **Step 6: Run benchmarks** + +```bash +cargo bench --bench batch 2>&1 | grep "Batch::iteration" +cargo bench --bench gaussian 2>&1 | grep "Gaussian::" +``` + +Expected: `Batch::iteration` ≤ T0 (~21.5 µs on Apple M5 Pro). Gaussian numbers unchanged. If `Batch::iteration` regressed by more than 5%, profile and investigate before committing. + +- [ ] **Step 7: Commit (the big one)** + +```bash +git add src/game.rs src/arena.rs src/lib.rs src/factor/ src/schedule.rs +git rm src/message.rs # only if it's no longer needed +git commit -m "$(cat <<'EOF' +refactor(game): rebuild Game::likelihoods on factor-graph machinery + +Game now constructs a VarStore + Vec from its teams/ +result/weights/p_draw and runs an EpsilonOrMax schedule to drive +inference. Public API of Game (new, posteriors, likelihoods field, +evidence field) is unchanged. + +The schedule runs TeamSum factors once (setup), then alternates +forward/backward sweeps over RankDiff/Trunc factors until step <= +1e-6 or 10 iterations. Iteration counts match T0 to within ULP-bounded +floating-point drift. + +ScratchArena no longer holds TeamMessage/DiffMessage buffers; it now +holds a VarStore, a Vec, and a Vec sort_buf. + +src/message.rs deleted (TeamMessage/DiffMessage are obsolete). +EOF +)" +``` + +--- + +### Task 9: Final verification and benchmark report + +**Files:** +- Modify: `benches/baseline.txt` (append T1 numbers) + +- [ ] **Step 1: Run the full test suite** + +```bash +cargo test --features approx +cargo clippy --all-targets --features approx -- -D warnings +cargo +nightly fmt --check +``` + +All green. + +- [ ] **Step 2: Capture T1 benchmark numbers** + +```bash +cargo bench --bench batch 2>&1 | grep "Batch::iteration" +cargo bench --bench gaussian 2>&1 | grep "Gaussian::" +``` + +- [ ] **Step 3: Append T1 numbers to `benches/baseline.txt`** + +After the T0 block, add: + +``` +# After T1 (date, same hardware) + +Batch::iteration µs (vs T0 21.253 µs: ) +Gaussian::add ps (unchanged) +Gaussian::sub ps (unchanged) +Gaussian::mul ps (unchanged — nat-param storage) +Gaussian::div ps (unchanged) +Gaussian::pi ps (unchanged) +Gaussian::tau ps (unchanged) + +# Notes: +# - Acceptance criterion was "no regression vs T0"; achieved . +# - Per-iteration counts unchanged (verified by re-running test_1vs1vs1 etc.). +# - Within-game inference is now driven by EpsilonOrMax schedule over +# BuiltinFactor enum dispatch; Game::likelihoods reduced from a hand- +# rolled EP loop to a 6-step builder + schedule.run + likelihood extraction. +``` + +- [ ] **Step 4: Commit benchmark numbers** + +```bash +git add benches/baseline.txt +git commit -m "$(cat <<'EOF' +bench: capture T1 final numbers + +Batch::iteration: µs (T0 was 21.253 µs) +Gaussian::* unchanged. + +Acceptance: factor-graph refactor lands without regression. +Closes T1 tier. +EOF +)" +``` + +--- + +## Self-review notes + +**Spec coverage** (against `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md` Section 2): + +- ✅ `Factor` trait (Tasks 3, 4, 5, 6) +- ✅ `BuiltinFactor` enum (Task 3) +- ✅ `VarStore` (Task 2) +- ✅ `Schedule` trait + `EpsilonOrMax` (Task 7) +- ✅ Re-implement `Game::likelihoods` on top of factors (Task 8) +- ✅ `ScheduleReport` (Task 7, exported via `pub use`) +- ✅ Acceptance: existing tests pass, iteration counts match, no benchmark regression (Task 9) + +**Deferred to later tiers:** +- `MarginFactor`, `SynergyFactor`, `ScoreFactor` → T4 +- `Damped`, `Residual` schedules → T4 (only `EpsilonOrMax` lands in T1) +- Surfacing `ScheduleReport` to callers (currently discarded in `Game::likelihoods`) → T2 along with the Observer trait +- Factor graph for cross-history forward/backward sweep → T1 only does within-game; cross-history stays as-is +- Eliminating remaining allocations from T0 post-mortem (within_priors, Game::likelihoods output) → likely T2 with the new API surface + +**Things to watch during execution:** +- The T1 algorithm uses **true EP cavity-based message storage** in `TruncFactor` (the `msg` field), whereas T0 used the team-side message storage (`likelihood_lose` etc.). Mathematically equivalent; same fixed point. Iteration counts should match within ±1. +- `RankDiffFactor` does NOT store its own outgoing message; it always overwrites the diff variable from the team-perf marginals. This works because the only consumer of the diff var is the colocated `TruncFactor`, and the schedule alternates them. If we add a third factor on the same diff var (e.g., a `MarginFactor` in T4), `RankDiffFactor` will need to grow outgoing-message storage. Document this in the comments. +- `EpsilonOrMax::run` partitions factors by `BuiltinFactor::TeamSum`. This implicitly assumes the Game builder lays out factors in the order `[TeamSum*, RankDiff/Trunc alternating]`. Document the contract. +- The `evidence` field in Game is a linear product (matches old behavior). New Game derives it from `TruncFactor::log_evidence().exp().product()`. Floating point of `exp(ln(x))` may drift by a ULP from the original direct product; verify against existing test goldens. +- `message.rs` deletion may have cross-file consequences if anything outside `game.rs` imports `TeamMessage` or `DiffMessage`. Run `grep -rn 'TeamMessage\|DiffMessage' src/` before deleting. +- `cargo +nightly fmt` (per user preference); nightly enables `imports_granularity` and `group_imports` from `rustfmt.toml`. diff --git a/docs/superpowers/plans/2026-04-24-t2-new-api-surface.md b/docs/superpowers/plans/2026-04-24-t2-new-api-surface.md new file mode 100644 index 0000000..e4f33ac --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-t2-new-api-surface.md @@ -0,0 +1,2757 @@ +# T2 — New API Surface 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 the full breaking API redesign from `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md` Section 7 "T2" — renames, typed `Event` ingestion, generic `Time` / `Drift`, `Observer` + `ConvergenceReport`, `Result<_, InferenceError>` at the boundary, and `pub factors` module — without changing numerical behavior. + +**Architecture:** T2 is a tiered rename + API rewrite on top of T1's factor-graph internals. No internal inference algorithm changes. Each task leaves the crate building and tests passing on the working branch; the final commit merges as a single breaking change. Because this crate is pre-1.0 with no downstream users, intermediate commits on the T2 branch need not preserve the old API, as long as the branch-tip is consistent. + +**Tech Stack:** Rust 2024 edition, `approx` crate for float comparisons, `smallvec` for fixed-size-biased event shapes, `criterion` for benchmark gates. Builds on T0 (nat-param Gaussian, dense storage, ScratchArena) and T1 (Factor/Schedule/VarStore, EpsilonOrMax schedule). + +## Acceptance criteria + +- All existing numerical goldens (rewritten in the new API) pass within `1e-6` tolerance. +- `cargo bench --bench batch` shows no regression vs T1 (`Batch::iteration` ≤ 23.5 µs on Apple M5 Pro) — the API rewrite is not supposed to change the hot path. +- `cargo clippy --all-targets --features approx -- -D warnings` clean. +- `cargo +nightly fmt --check` clean. +- Public API matches spec Section 4: + - `History>` with `Untimed` and `i64` `Time` impls. + - `HistoryBuilder` with `.mu().sigma().beta().drift().p_draw().convergence().observer().build()`. + - `history.record_winner(&K, &K, T)`, `record_draw(&K, &K, T)`, `add_events(iter)`, `event(time).team([…]).weights([…]).ranking([…]).commit()`. + - `history.converge() -> Result`. + - `history.learning_curve(&K)`, `learning_curves()`, `current_skill(&K)`, `log_evidence()`, `log_evidence_for(&[&K])`, `predict_quality(...)`, `predict_outcome(...)`, `intern(&K) -> Index`, `lookup(&Q) -> Option`. + - `Game::ranked`, `Game::one_v_one`, `Game::free_for_all`, `Game::custom` constructors; `Game::posteriors`, `Game::log_evidence` accessors. + - `Observer` trait with `NullObserver` default. + - `InferenceError` with `MismatchedShape`, `InvalidProbability`, `ConvergenceFailed`, `NegativePrecision` variants. + - `factors` module re-exports `Factor`, `Schedule`, `VarStore`, `VarId`, `BuiltinFactor`, `EpsilonOrMax`, `ScheduleReport`. +- Renames completed: `Batch → TimeSlice`, `Player → Rating`, `Agent → Competitor`, `IndexMap → KeyTable`. +- Old API (`History::convergence(iters, eps, verbose)`, nested-Vec `add_events(composition, results, times, weights)`, `verbose: bool`, `time: bool`) removed. + +## Non-goals (deferred to T3/T4) + +- `Outcome::Scored` variant + `MarginFactor` (T4). For T2, `Outcome` has only `Ranked`; enum is marked `#[non_exhaustive]` so we can add `Scored` later non-breaking. +- `Damped` and `Residual` schedules (T4). +- `Send + Sync` trait bounds + Rayon parallelism (T3). +- Cross-history parallelism, dirty-bit slice skipping (T3/beyond). +- Snapshot / serde support. + +## File map + +**New files:** + +| Path | Responsibility | +|---|---| +| `src/time.rs` | `Time` trait, `Untimed` ZST, `impl Time for i64`. | +| `src/observer.rs` | `Observer` trait, `NullObserver` ZST. | +| `src/outcome.rs` | `Outcome` enum (only `Ranked` in T2; `#[non_exhaustive]`). Convenience constructors `winner`, `draw`, `ranking`. | +| `src/event.rs` | Typed `Event`, `Team`, `Member` structs for bulk ingestion. | +| `src/convergence.rs` | `ConvergenceOptions`, `ConvergenceReport`. | +| `src/key_table.rs` | `KeyTable` (was `IndexMap` in `lib.rs`). | +| `src/rating.rs` | `Rating` (was `Player`). | +| `src/competitor.rs` | `Competitor` (was `Agent`). | +| `src/time_slice.rs` | `TimeSlice` (was `Batch`). | +| `src/factors.rs` | Public re-export module: `pub use crate::factor::*` + `pub use crate::schedule::*`. | +| `src/event_builder.rs` | Fluent builder returned by `History::event(T)` — `.team([…]).weights([…]).ranking([…]).commit()`. | +| `tests/equivalence.rs` | Integration tests: every old-API hardcoded golden is reproduced in the new API. | +| `tests/api_shape.rs` | Integration tests: three-tier ingestion, builder ergonomics, error cases. | + +**Removed files:** + +- `src/player.rs` → replaced by `rating.rs` +- `src/agent.rs` → replaced by `competitor.rs` +- `src/batch.rs` → replaced by `time_slice.rs` + +**Heavily modified:** + +| Path | What changes | +|---|---| +| `src/lib.rs` | Remove `IndexMap` (moved); re-export new modules; promote `factor` + `schedule` visibility via `mod factors`. | +| `src/drift.rs` | Generify `Drift` over `T: Time`. Widen `ConstantDrift` impl. | +| `src/history.rs` | Make generic over `T: Time, D: Drift`. New builder methods. Three-tier ingestion. `converge()` replaces `convergence()`. New query methods. | +| `src/error.rs` | Add `MismatchedShape`, `InvalidProbability`, `ConvergenceFailed` variants. | +| `src/game.rs` | Add `ranked`, `one_v_one`, `free_for_all`, `custom` constructors and `log_evidence()` accessor. Keep the private `new_with_arena` path for History. | +| `src/storage/mod.rs` | Rename `AgentStore` → `CompetitorStore`, `SkillStore` stays. | +| `src/arena.rs` | Rename any `Player` → `Rating` references; no structural change. | + +## Renaming policy + +Use `git mv` for file renames so history is preserved. All type-name find-replaces happen in the same commit as the file move. Tests inside `#[cfg(test)] mod tests` are updated in-place. + +--- + +## Task 1: Pre-flight — verify T1 green, capture baseline + +**Files:** none + +- [ ] **Step 1: Confirm branch + clean tree** + +```bash +git status +git rev-parse --abbrev-ref HEAD +``` + +Expected: clean tree on `t0-numerical-parity`. If dirty, stop. + +- [ ] **Step 2: Create the T2 branch** + +```bash +git checkout -b t2-new-api-surface +``` + +- [ ] **Step 3: Confirm all tests pass** + +```bash +cargo test --features approx --lib +cargo test --features approx --doc 2>&1 | tail -5 +``` + +Expected: `53 passed; 0 failed`. + +- [ ] **Step 4: Capture a fresh T1 reference bench number (hardware may differ)** + +```bash +cargo bench --bench batch 2>&1 | grep "Batch::iteration" +``` + +Record the value — the final task will compare against it. + +- [ ] **Step 5: No commit** — verification only. + +--- + +## Task 2: Rename `IndexMap` → `KeyTable` + +**Files:** +- Create: `src/key_table.rs` +- Modify: `src/lib.rs` (remove the inline `IndexMap`, re-export `KeyTable`) +- Modify: every call site across `src/**/*.rs` and tests + +- [ ] **Step 1: Create `src/key_table.rs`** + +```rust +use std::{ + borrow::{Borrow, ToOwned}, + collections::HashMap, + hash::Hash, +}; + +use crate::Index; + +/// Maps user keys to internal `Index` handles. +/// +/// Renamed from the former `IndexMap` to avoid colliding with the `indexmap` +/// 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(HashMap); + +impl KeyTable +where + K: Eq + Hash, +{ + pub fn new() -> Self { + Self(HashMap::new()) + } + + pub fn get>(&self, k: &Q) -> Option + where + K: Borrow, + { + self.0.get(k).cloned() + } + + pub fn get_or_create>(&mut self, k: &Q) -> Index + where + K: Borrow, + { + if let Some(idx) = self.0.get(k) { + *idx + } else { + let idx = Index::from(self.0.len()); + self.0.insert(k.to_owned(), idx); + idx + } + } + + pub fn key(&self, idx: Index) -> Option<&K> { + self.0 + .iter() + .find(|&(_, value)| *value == idx) + .map(|(key, _)| key) + } + + pub fn keys(&self) -> impl Iterator { + self.0.keys() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl Default for KeyTable +where + K: Eq + Hash, +{ + fn default() -> Self { + KeyTable::new() + } +} +``` + +- [ ] **Step 2: Remove `IndexMap` from `src/lib.rs`** + +Delete lines 57–108 (the `IndexMap` struct, impls, and `Default` impl). Remove `use std::{borrow::{Borrow, ToOwned}, collections::HashMap, hash::Hash};` if nothing else uses those imports. + +Add module declaration near the other `pub mod` lines (alphabetically between `gaussian` and `player`): + +```rust +pub mod key_table; +``` + +Add re-export: + +```rust +pub use key_table::KeyTable; +``` + +- [ ] **Step 3: Replace all `IndexMap` references with `KeyTable`** + +```bash +grep -rln 'IndexMap' src/ tests/ benches/ examples/ 2>/dev/null +``` + +In every file found, replace `IndexMap` → `KeyTable`. Verify no stragglers: + +```bash +! grep -rn 'IndexMap' src/ tests/ benches/ examples/ 2>/dev/null +``` + +- [ ] **Step 4: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +Expected: all 53 tests pass. + +- [ ] **Step 5: Format + commit** + +```bash +cargo +nightly fmt +git add src/ tests/ benches/ examples/ +git commit -m "$(cat <<'EOF' +refactor(api): rename IndexMap to KeyTable + +The former name collided with the popular indexmap crate. KeyTable +lives in its own module. Public API unchanged beyond the rename. + +Part of T2 of docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md. +EOF +)" +``` + +--- + +## Task 3: Rename `Player` → `Rating` + +**Files:** +- Rename: `src/player.rs` → `src/rating.rs` +- Modify: `src/lib.rs` (module decl + re-export) +- Modify: every call site + +- [ ] **Step 1: Move the file** + +```bash +git mv src/player.rs src/rating.rs +``` + +- [ ] **Step 2: Rename the type inside `src/rating.rs`** + +Edit `src/rating.rs` — rename `Player` → `Rating` everywhere, update the doc comment to say what it is now: + +```rust +use crate::{ + BETA, GAMMA, + drift::{ConstantDrift, Drift}, + gaussian::Gaussian, +}; + +/// Static rating configuration: prior skill, performance noise `beta`, drift. +/// +/// Renamed from `Player` in T2; `Rating` better describes the data +/// (a configuration) vs. a person (who's a `Competitor` with state). +#[derive(Clone, Copy, Debug)] +pub struct Rating { + pub(crate) prior: Gaussian, + pub(crate) beta: f64, + pub(crate) drift: D, +} + +impl Rating { + pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self { + Self { prior, beta, drift } + } + + pub(crate) fn performance(&self) -> Gaussian { + self.prior.forget(self.beta.powi(2)) + } +} + +impl Default for Rating { + fn default() -> Self { + Self { + prior: Gaussian::default(), + beta: BETA, + drift: ConstantDrift(GAMMA), + } + } +} +``` + +- [ ] **Step 3: Update `src/lib.rs`** + +Replace: +```rust +pub mod player; +... +pub use player::Player; +``` + +with: +```rust +pub mod rating; +... +pub use rating::Rating; +``` + +- [ ] **Step 4: Replace `Player` with `Rating` and `player::` with `rating::` everywhere** + +Use two passes so we don't touch e.g. `player_b` or `players` — use word-boundary regex: + +```bash +grep -rln '\bPlayer\b\|::player::\|mod player\|crate::player' src/ tests/ benches/ examples/ 2>/dev/null +``` + +For each file, edit to replace. Inside test modules, `Player::new(...)` becomes `Rating::new(...)`. The spec keeps the `Rating::new(prior, beta, drift)` signature exactly. + +Note: `crate::player::Player` → `crate::rating::Rating`. Use `pub(crate)` fields are re-paths only. + +Verify no stragglers: + +```bash +! grep -rn '\bPlayer\b' src/ tests/ benches/ examples/ 2>/dev/null +! grep -rn 'crate::player\|mod player' src/ 2>/dev/null +``` + +- [ ] **Step 5: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +Expected: all 53 tests pass. + +- [ ] **Step 6: Format + commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +refactor(api): rename Player to Rating + +The struct holds prior/beta/drift — a rating configuration, not a +person. The person-with-temporal-state is the Competitor (renamed in +the next task). Resolves Player/Agent ambiguity. + +Part of T2. +EOF +)" +``` + +--- + +## Task 4: Rename `Agent` → `Competitor` + +**Files:** +- Rename: `src/agent.rs` → `src/competitor.rs` +- Modify: `src/lib.rs`, `src/storage/mod.rs` (the `AgentStore` alias rename), every call site + +- [ ] **Step 1: Move the file** + +```bash +git mv src/agent.rs src/competitor.rs +``` + +- [ ] **Step 2: Rename the type inside `src/competitor.rs`** + +Replace `Agent` with `Competitor` everywhere in the file. Update the `rating` import (which was `player`): + +```rust +use crate::{ + N_INF, + drift::{ConstantDrift, Drift}, + gaussian::Gaussian, + rating::Rating, +}; + +/// Per-history, temporal state for someone competing. +/// +/// Renamed from `Agent` in T2. +#[derive(Debug)] +pub struct Competitor { + pub rating: Rating, + pub message: Gaussian, + pub last_time: i64, +} + +impl Competitor { + pub(crate) fn receive(&self, elapsed: i64) -> Gaussian { + if self.message != N_INF { + self.message + .forget(self.rating.drift.variance_delta(elapsed)) + } else { + self.rating.prior + } + } +} + +impl Default for Competitor { + fn default() -> Self { + Self { + rating: Rating::default(), + message: N_INF, + last_time: i64::MIN, + } + } +} + +pub(crate) fn clean<'a, D: Drift + 'a, A: Iterator>>( + competitors: A, + last_time: bool, +) { + for c in competitors { + c.message = N_INF; + if last_time { + c.last_time = i64::MIN; + } + } +} +``` + +Note: the field `player` → `rating` is an additional rename that must propagate (`self.player.X` → `self.rating.X` across the codebase). + +- [ ] **Step 3: Update `src/lib.rs` and `src/storage/mod.rs`** + +In `src/lib.rs`, replace `pub mod agent;` with `pub mod competitor;`. + +In `src/storage/mod.rs`, rename `AgentStore` → `CompetitorStore`. Inspect the file (read it first if you haven't) and replace: +- type alias/struct `AgentStore` → `CompetitorStore` +- any `Agent` generic → `Competitor` +- `use crate::agent::Agent` → `use crate::competitor::Competitor` + +- [ ] **Step 4: Replace all call sites** + +```bash +grep -rln '\bAgent\b\|AgentStore\|agent::\|mod agent\|crate::agent\|\.player\b' src/ tests/ benches/ examples/ 2>/dev/null +``` + +Replace: +- `Agent` → `Competitor` +- `AgentStore` → `CompetitorStore` +- `crate::agent::` → `crate::competitor::` +- `mod agent` → `mod competitor` +- **`.player` field access** → `.rating` (search this one carefully; it's ambiguous with unrelated code in dev-deps) + +For the `.player` rename, audit carefully — it should only appear inside `src/` on instances of `Competitor`. Grep-replace only inside `src/`: + +```bash +grep -rln '\.player\b' src/ +``` + +Do NOT blindly s/\.player/\.rating/ across `tests/` or `benches/` or `examples/` — these may reference external dev-dep types. Only edit matches inside `src/` that are on `Competitor` instances (verify each). + +Verify: +```bash +! grep -rn '\bAgent\b\|AgentStore\|::agent::\|mod agent' src/ tests/ benches/ examples/ 2>/dev/null +``` + +- [ ] **Step 5: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +- [ ] **Step 6: Format + commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +refactor(api): rename Agent to Competitor and .player field to .rating + +Competitor holds dynamic per-history state (message, last_time) for +someone competing; its configuration lives in a Rating. + +AgentStore renamed to CompetitorStore to match. + +Part of T2. +EOF +)" +``` + +--- + +## Task 5: Rename `Batch` → `TimeSlice` + +**Files:** +- Rename: `src/batch.rs` → `src/time_slice.rs` +- Modify: `src/lib.rs`, `src/history.rs`, every call site + +- [ ] **Step 1: Move the file** + +```bash +git mv src/batch.rs src/time_slice.rs +``` + +- [ ] **Step 2: Rename the type** + +In `src/time_slice.rs`, replace the struct name `Batch` with `TimeSlice`. Update the module-level doc comment: + +```rust +//! A single time step's worth of events. +//! +//! Renamed from `Batch` in T2. +``` + +Rename: +- `struct Batch` → `struct TimeSlice` +- `impl Batch` → `impl TimeSlice` +- `Batch::new` → `TimeSlice::new` + +The `compute_elapsed` free function keeps its name. + +- [ ] **Step 3: Update `src/lib.rs`** + +Replace: +```rust +pub mod batch; +``` + +with: +```rust +pub mod time_slice; +``` + +If `batch::Batch` was re-exported anywhere, update to `time_slice::TimeSlice`. + +- [ ] **Step 4: Replace all call sites** + +```bash +grep -rln '\bBatch\b\|crate::batch::\|mod batch\|batch::' src/ tests/ benches/ examples/ 2>/dev/null +``` + +Replace `Batch` → `TimeSlice` and `batch::` → `time_slice::` and `mod batch` → `mod time_slice`. + +Note `src/history.rs` uses the name extensively (`self.batches`, `Vec`, function names like `new_forward_info`). For T2 we rename: +- `batches: Vec` → `time_slices: Vec` +- The field rename must propagate — search `\.batches\b` inside `src/history.rs` and replace with `.time_slices`. + +Verify: +```bash +! grep -rn '\bBatch\b\|::batch::\|mod batch' src/ tests/ benches/ examples/ 2>/dev/null +! grep -rn '\.batches\b' src/history.rs +``` + +- [ ] **Step 5: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +Tests will still use the old nested-Vec `add_events` signature and `h.batches[…]` access patterns — those are fixed in later tasks. For now tests may need to be patched minimally to use `.time_slices[…]` so they compile. Since the inner shape is the same, this is pure find-replace. + +- [ ] **Step 6: Format + commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +refactor(api): rename Batch to TimeSlice + +TimeSlice says what it is: every event sharing one timestamp. The +History field batches is renamed to time_slices. + +Part of T2. +EOF +)" +``` + +--- + +## Task 6: Introduce `Time` trait and `Untimed` + +**Files:** +- Create: `src/time.rs` +- Modify: `src/lib.rs` + +This task lands the trait. It is *not* yet wired into `History`; that happens in Task 8. + +- [ ] **Step 1: Create `src/time.rs`** + +```rust +//! Generic time axis for `History`. +//! +//! Users pick the `Time` type based on their domain: `Untimed` when no +//! time axis is meaningful, `i64` for integer day/second timestamps. +//! Additional impls can be added behind feature flags. + +/// A timestamp on the global ordering axis. +/// +/// Must be `Ord + Copy` so slices can sort events, and `'static` so +/// `History` can store it by value without lifetimes. +pub trait Time: Copy + Ord + 'static { + /// How much time elapsed between `self` and `later`. + /// + /// Used by `Drift::variance_delta` to compute skill drift. Returning + /// zero means no drift accumulates between the two points. Return value + /// must be non-negative for `self <= later`. + fn elapsed_to(&self, later: &Self) -> i64; +} + +/// Zero-sized type representing "no time axis." +/// +/// Used as the default `Time` when events are unordered. Elapsed is always 0, +/// so no drift accumulates across slices. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Untimed; + +impl Time for Untimed { + fn elapsed_to(&self, _later: &Self) -> i64 { + 0 + } +} + +impl Time for i64 { + fn elapsed_to(&self, later: &Self) -> i64 { + later - self + } +} +``` + +- [ ] **Step 2: Declare the module in `src/lib.rs`** + +```rust +pub mod time; +``` + +and re-export: + +```rust +pub use time::{Time, Untimed}; +``` + +- [ ] **Step 3: Build** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +All 53 tests still pass (nothing uses `Time` yet). + +- [ ] **Step 4: Commit** + +```bash +cargo +nightly fmt +git add src/time.rs src/lib.rs +git commit -m "$(cat <<'EOF' +feat(api): add Time trait with Untimed and i64 impls + +Foundation for generic History time axis. Untimed is the ZST case +(no drift across slices); i64 is the standard timestamp case. +Additional impls (time::OffsetDateTime, chrono) can be added behind +feature flags in follow-up work. + +Part of T2. +EOF +)" +``` + +--- + +## Task 7: Generify `Drift` over `Time` + +**Files:** +- Modify: `src/drift.rs` +- Modify: every `Drift` call site (`elapsed: i64` → `from: &T, to: &T`) + +- [ ] **Step 1: Rewrite `src/drift.rs`** + +```rust +use std::fmt::Debug; + +use crate::time::Time; + +/// Governs how much a competitor's skill can drift between two time points. +/// +/// Generic over `T: Time` so seasonal or calendar-aware drift is expressible +/// without going through `i64`. +pub trait Drift: Copy + Debug { + /// Variance added to the skill prior for elapsed time `from -> to`. + /// + /// Called with `from <= to`; returning zero means no drift accumulates. + fn variance_delta(&self, from: &T, to: &T) -> f64; +} + +/// Simple constant-per-unit-time drift. +/// +/// For `Time = i64`: variance added is `(to - from) * gamma^2`. +/// For `Time = Untimed`: elapsed is always 0, so drift is always 0. +#[derive(Clone, Copy, Debug)] +pub struct ConstantDrift(pub f64); + +impl Drift for ConstantDrift { + fn variance_delta(&self, from: &T, to: &T) -> f64 { + let elapsed = from.elapsed_to(to).max(0) as f64; + elapsed * self.0 * self.0 + } +} +``` + +- [ ] **Step 2: Update `Competitor::receive` and `clean` signatures** + +In `src/competitor.rs`, `Competitor::receive` currently takes `elapsed: i64`. Change the generic bound so it can take `from: &T, to: &T`. Since `Competitor` is parameterized by `D: Drift`, we need to also parameterize by `T: Time`: + +```rust +use crate::{ + N_INF, + drift::{ConstantDrift, Drift}, + gaussian::Gaussian, + rating::Rating, + time::Time, +}; + +#[derive(Debug)] +pub struct Competitor = ConstantDrift> { + pub rating: Rating, + pub message: Gaussian, + pub last_time: Option, +} + +impl> Competitor { + pub(crate) fn receive(&self, now: &T) -> Gaussian { + if self.message != N_INF { + let elapsed_variance = match &self.last_time { + Some(last) => self.rating.drift.variance_delta(last, now), + None => 0.0, + }; + self.message.forget(elapsed_variance) + } else { + self.rating.prior + } + } +} + +impl Default for Competitor { + fn default() -> Self { + Self { + rating: Rating::default(), + message: N_INF, + last_time: None, + } + } +} + +pub(crate) fn clean<'a, T: Time + 'a, D: Drift + 'a, C: Iterator>>( + competitors: C, + last_time: bool, +) { + for c in competitors { + c.message = N_INF; + if last_time { + c.last_time = None; + } + } +} +``` + +The `last_time: i64` with `i64::MIN` sentinel becomes `last_time: Option` with `None` sentinel — cleaner and type-safe. + +- [ ] **Step 3: Parameterize `Rating` likewise** + +In `src/rating.rs`: + +```rust +use crate::{ + BETA, GAMMA, + drift::{ConstantDrift, Drift}, + gaussian::Gaussian, + time::Time, +}; + +#[derive(Clone, Copy, Debug)] +pub struct Rating = ConstantDrift> { + pub(crate) prior: Gaussian, + pub(crate) beta: f64, + pub(crate) drift: D, + pub(crate) _time: std::marker::PhantomData, +} + +impl> Rating { + pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self { + Self { + prior, + beta, + drift, + _time: std::marker::PhantomData, + } + } + + pub(crate) fn performance(&self) -> Gaussian { + self.prior.forget(self.beta.powi(2)) + } +} + +impl Default for Rating { + fn default() -> Self { + Self { + prior: Gaussian::default(), + beta: BETA, + drift: ConstantDrift(GAMMA), + _time: std::marker::PhantomData, + } + } +} +``` + +- [ ] **Step 4: Propagate the generic through the rest of the codebase** + +Every generic `` becomes `>` where the enclosing type owns `T`. Affected: + +- `CompetitorStore` → `CompetitorStore` in `src/storage/mod.rs` +- `History` → `History` in `src/history.rs` (and every fn sig inside) +- `HistoryBuilder` → `HistoryBuilder` +- `Game` → `Game` in `src/game.rs` +- `TimeSlice` may need `T` if it holds a `time: T` field (it currently holds `time: i64` — change to `time: T`) +- `agent::clean` → already updated +- `Event` (inside `time_slice.rs`) passes competitors and therefore inherits `T` + +This is the single biggest refactor in T2. Expect ~100 compiler errors; work through them file by file. The compiler is your friend — each error either needs a `T: Time` parameter added, or a type argument passed. + +For `Competitor::receive(elapsed: i64)` callers — now `Competitor::receive(now: &T)`. Each caller currently computes `elapsed = compute_elapsed(last_time, batch.time)`; change those to `competitor.receive(&time_slice.time)`. + +- [ ] **Step 5: Fix test modules** + +Tests use `i64` timestamps and `ConstantDrift` — the defaults — so most test setup is unchanged. Places that reference `.last_time == i64::MIN` become `.last_time.is_none()`. Places constructing `Competitor { last_time: i64::MIN, ... }` become `Competitor { last_time: None, ... }`. + +- [ ] **Step 6: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +Expected: all tests still pass, numerically identical. If numerics drift, the Drift implementation has a bug — most likely a negative elapsed was previously allowed. + +- [ ] **Step 7: Format + commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +refactor(api): generify Drift, Rating, Competitor, TimeSlice, History over T: Time + +Drift now takes &T -> &T and is generic over the time axis. Untimed +impls return elapsed=0. ConstantDrift impl covers all T via the Time +trait. + +Competitor.last_time moves from i64 with MIN sentinel to Option +with None sentinel. + +Part of T2. +EOF +)" +``` + +--- + +## Task 8: Parameterize `History` — remove `time: bool` + +**Files:** +- Modify: `src/history.rs` + +The `time: bool` field is a legacy encoding of "no time axis." With `T: Time`, the type system carries that information (`T = Untimed` means no drift). This task removes the bool and fixes the `!time` internal path. + +- [ ] **Step 1: Remove `time: bool` from `History` and `HistoryBuilder`** + +In `src/history.rs`, delete the `time: bool` field from both structs. Delete the `.time(bool)` builder method. Update the `Default` impl and `build()` to stop threading `time` through. + +- [ ] **Step 2: Remove the `!self.time` branches in `add_events_with_prior`** + +Every `if self.time { … } else { … }` becomes the `self.time` branch — the generic parameter makes this uniform. Specifically, `times` is always required now (it's a `Vec`, not `Vec`), and `sort_time` sorts in `T` order. + +Rewrite `sort_time` in `src/lib.rs` to be generic: + +```rust +pub(crate) fn sort_time(xs: &[T], reverse: bool) -> Vec { + let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect(); + if reverse { + x.sort_by_key(|&(_, t)| std::cmp::Reverse(t)); + } else { + x.sort_by_key(|&(_, t)| t); + } + x.into_iter().map(|(i, _)| i).collect() +} +``` + +- [ ] **Step 3: Keep the old nested-Vec `add_events_with_prior` signature** + +For now. Change its signature only enough to take `times: Vec` instead of `Vec`. Users who previously called with `!time` should switch to `History::` — but the *tests* should switch to `History::` with explicit `[1, 2, 3, ...]` timestamps to preserve the old elapsed=1-per-event numerical behavior. + +The nested-Vec signature gets fully replaced in Task 15. This keeps the test suite green between tasks. + +- [ ] **Step 4: Update tests** + +Tests using `.time(false)` need translation. The mechanical rule: + +| Old | New | +|---|---| +| `History::builder().time(false).build()` | `History::::builder().build()` with explicit `vec![1, 2, 3, …]` for `times` in every `add_events` call | +| `History::default()` (was `time: true`) | `History::::default()` | + +For every test that called `add_events(comp, results, vec![], vec![])` under `time: false`, change to `add_events(comp, results, vec![1, 2, 3, …], vec![])` where the length matches `comp.len()`. Numerics are preserved because the old `!time` branch used `i as i64 + 1` timestamps internally. + +This is mechanical. Each failing test will show which translation is needed. + +- [ ] **Step 5: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +Expected: all tests pass with translated timestamps. + +- [ ] **Step 6: Format + commit** + +```bash +cargo +nightly fmt +git add src/history.rs src/lib.rs src/time_slice.rs src/storage/ +git commit -m "$(cat <<'EOF' +refactor(history): parameterize History and remove time: bool + +The bool encoded 'no time axis' which is now expressed at the type +level (T = Untimed). Removed the .time() builder method. Tests that +used time(false) now use i64 timestamps 1..=n to preserve numerics. + +Part of T2. +EOF +)" +``` + +--- + +## Task 9: Add `Outcome` enum + +**Files:** +- Create: `src/outcome.rs` +- Modify: `src/lib.rs` + +- [ ] **Step 1: Create `src/outcome.rs`** + +```rust +//! Outcome of a match. +//! +//! In T2, only `Ranked` is supported; `Scored` will be added together with +//! `MarginFactor` in T4. The enum is `#[non_exhaustive]` so adding `Scored` +//! is non-breaking for downstream `match` expressions. + +use smallvec::SmallVec; + +/// 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. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum Outcome { + Ranked(SmallVec<[u32; 4]>), +} + +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>(ranks: I) -> Self { + Self::Ranked(ranks.into_iter().collect()) + } + + pub fn team_count(&self) -> usize { + match self { + Self::Ranked(r) => r.len(), + } + } + + pub(crate) fn as_ranks(&self) -> &[u32] { + match self { + Self::Ranked(r) => r, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn winner_two_teams() { + let o = Outcome::winner(0, 2); + assert_eq!(o.as_ranks(), &[0u32, 1]); + assert_eq!(o.team_count(), 2); + } + + #[test] + fn winner_three_teams_second_wins() { + let o = Outcome::winner(1, 3); + assert_eq!(o.as_ranks(), &[1u32, 0, 1]); + } + + #[test] + fn draw_three_teams() { + let o = Outcome::draw(3); + assert_eq!(o.as_ranks(), &[0u32, 0, 0]); + } + + #[test] + fn ranking_from_iter() { + let o = Outcome::ranking([2, 0, 1]); + assert_eq!(o.as_ranks(), &[2u32, 0, 1]); + } + + #[test] + #[should_panic(expected = "winner index 2 out of range")] + fn winner_out_of_range_panics() { + let _ = Outcome::winner(2, 2); + } +} +``` + +- [ ] **Step 2: Add `smallvec` as a dependency** + +In `Cargo.toml`, under `[dependencies]`: + +```toml +smallvec = "1" +``` + +- [ ] **Step 3: Add `pub mod outcome;` and `pub use outcome::Outcome;` to `src/lib.rs`** + +- [ ] **Step 4: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib outcome +``` + +Expected: 5 passing tests. + +- [ ] **Step 5: Format + commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +feat(api): add Outcome enum with Ranked variant + +Outcome::winner(i, n), Outcome::draw(n), Outcome::ranking(iter) are +the convenience constructors. Marked #[non_exhaustive] so Scored can +be added in T4 without breaking match exhaustiveness. + +Adds smallvec dependency. + +Part of T2. +EOF +)" +``` + +--- + +## Task 10: Add `Event`, `Team`, `Member` + +**Files:** +- Create: `src/event.rs` +- Modify: `src/lib.rs` + +- [ ] **Step 1: Create `src/event.rs`** + +```rust +//! Typed event description for bulk ingestion. +//! +//! `Event` is the new public event shape (spec Section 4). Replaces +//! the nested `Vec>>`, `Vec>`, `Vec>>` +//! that the old `add_events_with_prior` took. + +use smallvec::SmallVec; + +use crate::{gaussian::Gaussian, outcome::Outcome, time::Time}; + +/// A single match at time `time` involving some number of teams. +#[derive(Clone, Debug)] +pub struct Event { + pub time: T, + pub teams: SmallVec<[Team; 4]>, + pub outcome: Outcome, +} + +/// A team: list of members competing together. +#[derive(Clone, Debug)] +pub struct Team { + pub members: SmallVec<[Member; 4]>, +} + +impl Team { + pub fn new() -> Self { + Self { members: SmallVec::new() } + } + + pub fn with_members>>(members: I) -> Self { + Self { members: members.into_iter().collect() } + } +} + +impl Default for Team { + fn default() -> Self { + Self::new() + } +} + +/// One member of a team, identified by user key `K`. +/// +/// `weight` defaults to 1.0; a per-event `prior` can override the competitor's +/// current skill estimate for this event only. +#[derive(Clone, Debug)] +pub struct Member { + pub key: K, + pub weight: f64, + pub prior: Option, +} + +impl Member { + pub fn new(key: K) -> Self { + Self { key, weight: 1.0, prior: None } + } + + pub fn with_weight(mut self, weight: f64) -> Self { + self.weight = weight; + self + } + + pub fn with_prior(mut self, prior: Gaussian) -> Self { + self.prior = Some(prior); + self + } +} + +/// Convenience: a member is a user key with default weight 1.0 and no prior. +impl From for Member { + fn from(key: K) -> Self { + Self::new(key) + } +} +``` + +- [ ] **Step 2: Register in `src/lib.rs`** + +```rust +pub mod event; +... +pub use event::{Event, Member, Team}; +``` + +- [ ] **Step 3: Build** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +- [ ] **Step 4: Format + commit** + +```bash +cargo +nightly fmt +git add src/event.rs src/lib.rs +git commit -m "$(cat <<'EOF' +feat(api): add Event, Team, Member typed event description + +Replaces the old nested Vec>> event description on the +public API boundary. Member::from(K) enables ergonomic literal +lists. + +Part of T2. +EOF +)" +``` + +--- + +## Task 11: Add `Observer` trait and `NullObserver` + +**Files:** +- Create: `src/observer.rs` +- Modify: `src/lib.rs` + +- [ ] **Step 1: Create `src/observer.rs`** + +```rust +//! Observer trait for progress reporting during convergence. +//! +//! Replaces the old `verbose: bool` + `println!` path. Callers wire in any +//! observer that implements the trait; default methods are no-ops so users +//! override only what they need. + +use crate::time::Time; + +/// Receives progress callbacks during `History::converge`. +/// +/// All methods have default no-op implementations; implement only what's +/// interesting. Send/Sync is NOT required in T2 (added in T3 along with +/// Rayon support). +pub trait Observer { + /// Called after each convergence iteration across the whole history. + fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {} + + /// Called after each time slice is processed within an iteration. + fn on_batch_processed(&self, _time: &T, _slice_idx: usize, _n_events: usize) {} + + /// Called once when convergence completes (or max iters is reached). + fn on_converged(&self, _iters: usize, _final_step: (f64, f64), _converged: bool) {} +} + +/// ZST no-op observer; the default when none is configured. +#[derive(Copy, Clone, Debug, Default)] +pub struct NullObserver; + +impl Observer for NullObserver {} +``` + +- [ ] **Step 2: Register in `src/lib.rs`** + +```rust +pub mod observer; +... +pub use observer::{NullObserver, Observer}; +``` + +- [ ] **Step 3: Build** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +- [ ] **Step 4: Commit** + +```bash +cargo +nightly fmt +git add src/observer.rs src/lib.rs +git commit -m "$(cat <<'EOF' +feat(api): add Observer trait and NullObserver default + +Observer replaces verbose: bool with structured progress callbacks. +NullObserver is a ZST default; users override only the methods they +care about. + +Send + Sync bounds deferred to T3. + +Part of T2. +EOF +)" +``` + +--- + +## Task 12: Add `ConvergenceOptions` and `ConvergenceReport`; wire into history + +**Files:** +- Create: `src/convergence.rs` +- Modify: `src/history.rs`, `src/lib.rs` + +- [ ] **Step 1: Create `src/convergence.rs`** + +```rust +//! Convergence configuration and reporting. + +use std::time::Duration; + +use smallvec::SmallVec; + +#[derive(Clone, Copy, Debug)] +pub struct ConvergenceOptions { + pub max_iter: usize, + pub epsilon: f64, +} + +impl Default for ConvergenceOptions { + fn default() -> Self { + Self { + max_iter: crate::ITERATIONS, + epsilon: crate::EPSILON, + } + } +} + +/// Post-hoc summary of a `History::converge` call. +#[derive(Clone, Debug)] +pub struct ConvergenceReport { + pub iterations: usize, + pub final_step: (f64, f64), + pub log_evidence: f64, + pub converged: bool, + pub per_iteration_time: SmallVec<[Duration; 32]>, + pub slices_skipped: usize, +} +``` + +- [ ] **Step 2: Register in `src/lib.rs`** + +```rust +pub mod convergence; +... +pub use convergence::{ConvergenceOptions, ConvergenceReport}; +``` + +- [ ] **Step 3: Add `convergence: ConvergenceOptions` and `observer` to `HistoryBuilder`** + +In `src/history.rs`, add to `HistoryBuilder`: + +```rust +pub fn convergence(mut self, opts: ConvergenceOptions) -> Self { + self.convergence = opts; + self +} + +pub fn observer>(self, observer: O2) -> HistoryBuilder { … } +``` + +`HistoryBuilder` gains two extra generic parameters (`O: Observer` and observer storage). The simplest shape: + +```rust +pub struct HistoryBuilder = ConstantDrift, O: Observer = NullObserver> { + mu: f64, + sigma: f64, + beta: f64, + drift: D, + p_draw: f64, + convergence: ConvergenceOptions, + observer: O, + _time: std::marker::PhantomData, +} +``` + +`History` gains the same `O` parameter; `observer` is stored and called from the convergence loop. + +- [ ] **Step 4: Add `history.converge() -> Result`** + +```rust +pub fn converge(&mut self) -> Result { + use std::time::Instant; + let opts = self.convergence; + let mut step = (f64::INFINITY, f64::INFINITY); + let mut i = 0; + let mut per_iter: SmallVec<[Duration; 32]> = SmallVec::new(); + while crate::tuple_gt(step, opts.epsilon) && i < opts.max_iter { + let t0 = Instant::now(); + step = self.iteration(); + per_iter.push(t0.elapsed()); + i += 1; + self.observer.on_iteration_end(i, step); + } + let converged = !crate::tuple_gt(step, opts.epsilon); + let log_evidence = self.log_evidence_impl(false, &[]); + self.observer.on_converged(i, step, converged); + Ok(ConvergenceReport { + iterations: i, + final_step: step, + log_evidence, + converged, + per_iteration_time: per_iter, + slices_skipped: 0, + }) +} +``` + +Keep the old `convergence(iters, eps, verbose)` method alive for one more task. It is removed in Task 20 alongside test translation. + +- [ ] **Step 5: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +- [ ] **Step 6: Commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +feat(api): add ConvergenceOptions, ConvergenceReport, and History::converge + +HistoryBuilder gains .convergence(opts) and .observer(o). History::converge +returns a structured ConvergenceReport with per-iteration timings. + +The old History::convergence(iters, eps, verbose) still works and is +removed in Task 20. + +Part of T2. +EOF +)" +``` + +--- + +## Task 13: Expand `InferenceError`; convert boundary panics to `Result` + +**Files:** +- Modify: `src/error.rs` +- Modify: `src/history.rs`, `src/game.rs` (where `debug_assert!` or `assert!` guard user input) + +- [ ] **Step 1: Expand `InferenceError`** + +Replace `src/error.rs`: + +```rust +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub enum InferenceError { + /// Expected and actual lengths of some array-shaped input differ. + MismatchedShape { + kind: &'static str, + expected: usize, + got: usize, + }, + /// A probability value is outside `[0, 1]`. + InvalidProbability { value: f64 }, + /// Convergence exceeded `max_iter` without falling below `epsilon`. + ConvergenceFailed { + last_step: (f64, f64), + iterations: usize, + }, + /// Negative precision: a Gaussian with `pi < 0` slipped into an API call. + NegativePrecision { pi: f64 }, +} + +impl fmt::Display for InferenceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MismatchedShape { kind, expected, got } => { + write!(f, "{kind}: expected length {expected}, got {got}") + } + Self::InvalidProbability { value } => { + write!(f, "probability must be in [0, 1]; got {value}") + } + Self::ConvergenceFailed { last_step, iterations } => { + write!( + f, + "convergence failed after {iterations} iterations; last step = {last_step:?}" + ) + } + Self::NegativePrecision { pi } => { + write!(f, "precision must be non-negative; got {pi}") + } + } + } +} + +impl std::error::Error for InferenceError {} +``` + +- [ ] **Step 2: Convert boundary panics to `Result`** + +Search for `debug_assert!` and `assert!` at the API boundary: + +```bash +grep -rn 'debug_assert!\|^\s*assert!' src/history.rs src/game.rs +``` + +In `Game::new` (the internal path kept for `History`), the existing `debug_assert!`s remain — they catch invariants, not user errors. But for a public `Game::ranked` (added later in Task 19), return `InferenceError::MismatchedShape` on bad input rather than panicking. + +In `History::add_events_with_prior`, the four `assert!(...)` calls at the top of the function become `Result<(), InferenceError>` returns. Change the function signature: + +```rust +pub(crate) fn add_events_with_prior( + &mut self, + … +) -> Result<(), InferenceError> { + if results.len() != composition.len() && !results.is_empty() { + return Err(InferenceError::MismatchedShape { + kind: "results", + expected: composition.len(), + got: results.len(), + }); + } + // … other checks … + // … rest of body … + Ok(()) +} +``` + +Every caller must add `?`. Tests that were calling `h.add_events(…)` (the thin wrapper) now call `h.add_events(…)?` or `.unwrap()` in tests. + +- [ ] **Step 3: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +- [ ] **Step 4: Commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +feat(error): expand InferenceError; convert boundary asserts to Result + +Adds MismatchedShape, InvalidProbability, ConvergenceFailed. History's +add_events_with_prior now returns Result<(), InferenceError>. Internal +debug_asserts for invariants stay; user-facing shape/bounds checks +become errors. + +Part of T2. +EOF +)" +``` + +--- + +## Task 14: Add `record_winner` and `record_draw` convenience API + +**Files:** +- Modify: `src/history.rs` + +- [ ] **Step 1: Add the methods** + +In `impl, O: Observer> History`: + +```rust +/// Record a single 1v1 match where `winner` beat `loser` at time `time`. +pub fn record_winner(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError> +where + K: std::borrow::Borrow + std::hash::Hash + Eq + ToOwned, + Q: std::hash::Hash + Eq + ?Sized, +{ + let w_idx = self.intern(winner); + let l_idx = self.intern(loser); + self.add_events_with_prior( + vec![vec![vec![w_idx], vec![l_idx]]], + vec![vec![1.0, 0.0]], + vec![time], + vec![], + std::collections::HashMap::new(), + ) +} + +/// Record a 1v1 tie. +pub fn record_draw(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError> +where + K: std::borrow::Borrow + std::hash::Hash + Eq + ToOwned, + Q: std::hash::Hash + Eq + ?Sized, +{ + let a_idx = self.intern(a); + let b_idx = self.intern(b); + self.add_events_with_prior( + vec![vec![vec![a_idx], vec![b_idx]]], + vec![vec![0.0, 0.0]], + vec![time], + vec![], + std::collections::HashMap::new(), + ) +} + +/// Get or create an `Index` for a user key. See spec Section 4 "Open question 3." +pub fn intern(&mut self, key: &Q) -> Index +where + K: std::borrow::Borrow + std::hash::Hash + Eq + ToOwned, + Q: std::hash::Hash + Eq + ?Sized, +{ + self.keys.get_or_create(key) +} + +/// Look up an `Index` for a key without creating it. +pub fn lookup(&self, key: &Q) -> Option +where + K: std::borrow::Borrow + std::hash::Hash + Eq + ToOwned, + Q: std::hash::Hash + Eq + ?Sized, +{ + self.keys.get(key) +} +``` + +Note: `History` does not currently hold a `KeyTable`. This task requires adding `keys: KeyTable` to the `History` struct (which also adds `K: Eq + Hash` bound). The generic parameter `K` is threaded through `History`. This is a significant shape change — work through the compiler errors. + +- [ ] **Step 2: Add an integration test for `record_winner`** + +Create `tests/record_winner.rs`: + +```rust +use trueskill_tt::{ConstantDrift, ConvergenceOptions, History}; + +#[test] +fn record_winner_updates_skills() { + let mut h = History::::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .drift(ConstantDrift(25.0 / 300.0)) + .build(); + + h.record_winner(&"alice", &"bob", 0).unwrap(); + h.converge().unwrap(); + + let alice = h.current_skill(&"alice").unwrap(); + let bob = h.current_skill(&"bob").unwrap(); + + assert!(alice.mu() > 25.0, "winner mu should rise: got {}", alice.mu()); + assert!(bob.mu() < 25.0, "loser mu should fall: got {}", bob.mu()); +} +``` + +This test depends on `current_skill` (added in Task 17). For now, comment out the last three lines or replace with a `learning_curves()` check; come back to it in Task 17. + +- [ ] **Step 3: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --test record_winner +``` + +- [ ] **Step 4: Commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +feat(api): add record_winner, record_draw, intern, lookup on History + +Spec Section 4 "three-tier event ingestion" tier 2: one-off match +convenience. History now holds a KeyTable internally; generic +parameter K added to History. + +Part of T2. +EOF +)" +``` + +--- + +## Task 15: Replace nested-Vec `add_events` with typed `add_events(iter)` + +**Files:** +- Modify: `src/history.rs` + +- [ ] **Step 1: Add the new `add_events` signature** + +```rust +pub fn add_events(&mut self, events: I) -> Result<(), InferenceError> +where + I: IntoIterator>, +{ + // Translate each Event into the internal composition/results/times/weights + // triple, then delegate to add_events_with_prior (which becomes pub(crate)). + let mut composition: Vec>> = Vec::new(); + let mut results: Vec> = Vec::new(); + let mut times: Vec = Vec::new(); + let mut weights: Vec>> = Vec::new(); + let mut priors: HashMap> = HashMap::new(); + + for ev in events { + let mut event_comp: Vec> = Vec::new(); + let mut event_weights: Vec> = Vec::new(); + for team in &ev.teams { + let mut team_indices: Vec = Vec::new(); + let mut team_weights: Vec = Vec::new(); + for member in &team.members { + let idx = self.intern(&member.key); + team_indices.push(idx); + team_weights.push(member.weight); + if let Some(prior) = member.prior { + priors.insert( + idx, + Rating::new(prior, self.beta, self.drift), + ); + } + } + event_comp.push(team_indices); + event_weights.push(team_weights); + } + composition.push(event_comp); + weights.push(event_weights); + // Convert Outcome::Ranked to the legacy "lower number wins" f64 result. + // Legacy convention: result[i] is a score; higher score = better rank. + // We invert ranks: team with rank 0 gets highest score. + let ranks = ev.outcome.as_ranks(); + if ranks.len() != ev.teams.len() { + return Err(InferenceError::MismatchedShape { + kind: "outcome ranks vs teams", + expected: ev.teams.len(), + got: ranks.len(), + }); + } + let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64; + let inverted: Vec = ranks.iter().map(|&r| max_rank - r as f64).collect(); + results.push(inverted); + times.push(ev.time); + } + + self.add_events_with_prior(composition, results, times, weights, priors) +} +``` + +Rename the old public `add_events` (nested-Vec) to `pub(crate) fn add_events_legacy` or inline it into the internal call path; delete it from the public API surface. + +- [ ] **Step 2: Add an integration test for the new signature** + +In `tests/api_shape.rs`: + +```rust +use trueskill_tt::{Event, History, Member, Outcome, Team}; +use smallvec::smallvec; + +#[test] +fn add_events_bulk_via_iter() { + let mut h = History::::builder() + .mu(0.0).sigma(2.0).beta(1.0).p_draw(0.0) + .build(); + + let events = vec![ + Event { + time: 1, + teams: smallvec![ + Team::with_members([Member::new("a")]), + Team::with_members([Member::new("b")]), + ], + outcome: Outcome::winner(0, 2), + }, + Event { + time: 2, + teams: smallvec![ + Team::with_members([Member::new("b")]), + Team::with_members([Member::new("c")]), + ], + outcome: Outcome::winner(0, 2), + }, + ]; + + h.add_events(events).unwrap(); + let report = h.converge().unwrap(); + assert!(report.converged, "expected convergence, got {report:?}"); +} +``` + +- [ ] **Step 3: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --test api_shape +``` + +- [ ] **Step 4: Commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +feat(api): replace nested-Vec add_events with typed add_events(iter) + +The old nested shape is gone from the public API. add_events now +takes IntoIterator>. Internally routes through +add_events_with_prior (now pub(crate)). + +Part of T2. +EOF +)" +``` + +--- + +## Task 16: Add `history.event(time).team(...).commit()` fluent builder + +**Files:** +- Create: `src/event_builder.rs` +- Modify: `src/history.rs`, `src/lib.rs` + +- [ ] **Step 1: Create `src/event_builder.rs`** + +```rust +//! Fluent builder for single-event ingestion. +//! +//! ```ignore +//! history +//! .event(time) +//! .team(["alice", "bob"]).weights([1.0, 0.7]) +//! .team(["carol"]) +//! .ranking([1, 0]) +//! .commit()?; +//! ``` + +use std::collections::HashMap; + +use smallvec::SmallVec; + +use crate::{ + InferenceError, Outcome, + event::{Event, Member, Team}, + history::History, + observer::Observer, + drift::Drift, + time::Time, +}; + +pub struct EventBuilder<'h, T, D, O, K> +where + T: Time, + D: Drift, + O: Observer, + K: Eq + std::hash::Hash + Clone, +{ + history: &'h mut History, + event: Event, + current_team_idx: Option, +} + +impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K> +where + T: Time, + D: Drift, + O: Observer, + K: Eq + std::hash::Hash + Clone, +{ + pub(crate) fn new(history: &'h mut History, time: T, default_n_teams: usize) -> Self { + Self { + history, + event: Event { + time, + teams: SmallVec::with_capacity(default_n_teams.max(2)), + outcome: Outcome::Ranked(SmallVec::new()), + }, + current_team_idx: None, + } + } + + /// Add a team by its member keys. + pub fn team>(mut self, keys: I) -> Self { + let members: SmallVec<[Member; 4]> = keys.into_iter().map(Member::new).collect(); + self.event.teams.push(Team { members }); + self.current_team_idx = Some(self.event.teams.len() - 1); + self + } + + /// Set per-member weights for the most recently added team. + pub fn weights>(mut self, weights: I) -> Self { + let idx = self + .current_team_idx + .expect(".weights(...) called before any .team(...)"); + let ws: Vec = weights.into_iter().collect(); + let team = &mut self.event.teams[idx]; + debug_assert_eq!( + ws.len(), + team.members.len(), + "weights length must match team size" + ); + for (m, w) in team.members.iter_mut().zip(ws.into_iter()) { + m.weight = w; + } + self + } + + /// Set explicit ranks per team (length must equal number of teams). + pub fn ranking>(mut self, ranks: I) -> Self { + self.event.outcome = Outcome::ranking(ranks); + self + } + + /// Mark team `winner_idx` as winner of an N-way match, 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); + self + } + + /// All teams tied. + pub fn draw(mut self) -> Self { + self.event.outcome = Outcome::draw(self.event.teams.len() as u32); + self + } + + /// Commit the event to the history. + pub fn commit(self) -> Result<(), InferenceError> { + self.history.add_events(std::iter::once(self.event)) + } +} +``` + +- [ ] **Step 2: Add `History::event` method** + +In `src/history.rs`: + +```rust +pub fn event(&mut self, time: T) -> EventBuilder<'_, T, D, O, K> { + EventBuilder::new(self, time, 2) +} +``` + +- [ ] **Step 3: Register the module in `src/lib.rs`** + +```rust +pub mod event_builder; +pub use event_builder::EventBuilder; +``` + +- [ ] **Step 4: Add a test** + +Append to `tests/api_shape.rs`: + +```rust +#[test] +fn fluent_event_builder() { + let mut h = History::::builder() + .mu(25.0).sigma(25.0 / 3.0).beta(25.0 / 6.0).p_draw(0.0) + .build(); + + h.event(1) + .team(["alice", "bob"]) + .weights([1.0, 0.7]) + .team(["carol"]) + .ranking([1, 0]) + .commit() + .unwrap(); + + let report = h.converge().unwrap(); + assert!(report.converged); +} +``` + +- [ ] **Step 5: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --test api_shape fluent_event_builder +``` + +- [ ] **Step 6: Commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +feat(api): add fluent history.event(t).team(...).commit() builder + +Third tier of the ingestion API (per spec Section 4). Powers one-off +events with irregular shapes where neither record_winner nor typed +add_events fits cleanly. + +Part of T2. +EOF +)" +``` + +--- + +## Task 17: Query methods — `current_skill`, `learning_curve`, `log_evidence_for`, `predict_*` + +**Files:** +- Modify: `src/history.rs`, `src/game.rs`, `src/lib.rs` + +- [ ] **Step 1: Add `current_skill` and single-key `learning_curve` to History** + +```rust +impl, O: Observer, K: Eq + std::hash::Hash + Clone> History { + /// Skill estimate at the latest time slice the competitor appears in. + pub fn current_skill(&self, key: &Q) -> Option + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + let idx = self.keys.get(key)?; + self.time_slices + .iter() + .rev() + .find_map(|ts| ts.skills.get(idx).map(|sk| sk.posterior())) + } + + /// Learning curve for a single key: (time, posterior) pairs in time order. + pub fn learning_curve(&self, key: &Q) -> Vec<(T, Gaussian)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + let Some(idx) = self.keys.get(key) else { + return Vec::new(); + }; + self.time_slices + .iter() + .filter_map(|ts| ts.skills.get(idx).map(|sk| (ts.time, sk.posterior()))) + .collect() + } +} +``` + +- [ ] **Step 2: Update `learning_curves` to return `HashMap>`** + +Currently returns `HashMap>`. New shape: + +```rust +pub fn learning_curves(&self) -> HashMap> { + let mut data: HashMap> = HashMap::new(); + for slice in &self.time_slices { + for (idx, skill) in slice.skills.iter() { + if let Some(key) = self.keys.key(idx).cloned() { + data.entry(key) + .or_default() + .push((slice.time, skill.posterior())); + } + } + } + data +} +``` + +- [ ] **Step 3: Add `log_evidence()` and `log_evidence_for(&[&K])`** + +```rust +pub fn log_evidence(&mut self) -> f64 { + self.log_evidence_impl(false, &[]) +} + +pub fn log_evidence_for(&mut self, keys: &[&Q]) -> f64 +where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, +{ + let targets: Vec = keys + .iter() + .filter_map(|k| self.keys.get(k)) + .collect(); + self.log_evidence_impl(false, &targets) +} + +fn log_evidence_impl(&mut self, forward: bool, targets: &[Index]) -> f64 { + self.time_slices + .iter() + .map(|ts| ts.log_evidence(self.online, targets, forward, &self.competitors)) + .sum() +} +``` + +The existing public `log_evidence(forward, targets)` method is renamed to `log_evidence_impl` (pub(crate)) and the thin public wrappers take its place. The `forward` bool ("online" mode) stays internal; no public exposure planned for T2. + +- [ ] **Step 4: Add `predict_quality` and `predict_outcome`** + +These are light wrappers over the existing `quality()` free function plus a new "predict outcome" that runs a one-off Game without adding to history: + +```rust +pub fn predict_quality(&self, teams: &[&[&K]]) -> f64 +where + K: std::borrow::Borrow, +{ + let groups: Vec> = teams + .iter() + .map(|team| { + team.iter() + .filter_map(|k| self.keys.get(*k)) + .filter_map(|idx| { + self.time_slices + .iter() + .rev() + .find_map(|ts| ts.skills.get(idx).map(|s| s.posterior())) + }) + .collect() + }) + .collect(); + let group_refs: Vec<&[Gaussian]> = groups.iter().map(|g| g.as_slice()).collect(); + crate::quality(&group_refs, self.beta) +} + +pub fn predict_outcome(&self, teams: &[&[&K]]) -> Vec { + // Win probabilities per team: apply cdf over team-perf sums. + // For n teams, return P(team i wins over all others). + // Minimal impl for T2: 2-team case only; multi-team deferred to T4. + assert_eq!(teams.len(), 2, "predict_outcome T2: 2 teams only"); + let gather = |team: &[&K]| -> Gaussian { + team.iter() + .filter_map(|k| self.keys.get(*k)) + .filter_map(|idx| { + self.time_slices + .iter() + .rev() + .find_map(|ts| ts.skills.get(idx).map(|s| s.posterior())) + }) + .fold(crate::N00, |acc, g| acc + g.forget(self.beta.powi(2))) + }; + let a = gather(teams[0]); + let b = gather(teams[1]); + let diff = a - b; + let p_a = 1.0 - crate::cdf(0.0, diff.mu(), diff.sigma()); + vec![p_a, 1.0 - p_a] +} +``` + +Document `predict_outcome` as 2-team-only in T2; N-team lands in T4 alongside `Residual` schedules. + +- [ ] **Step 5: Add tests** + +In `tests/api_shape.rs`: + +```rust +#[test] +fn current_skill_learning_curve_learning_curves() { + let mut h = History::::builder() + .mu(25.0).sigma(25.0 / 3.0).beta(25.0 / 6.0).p_draw(0.0) + .build(); + h.record_winner(&"a", &"b", 1).unwrap(); + h.record_winner(&"a", &"b", 2).unwrap(); + h.converge().unwrap(); + + let a = h.current_skill(&"a").unwrap(); + assert!(a.mu() > 25.0); + + let curve = h.learning_curve(&"a"); + assert_eq!(curve.len(), 2); + assert_eq!(curve[0].0, 1); + assert_eq!(curve[1].0, 2); + + let all = h.learning_curves(); + assert_eq!(all.len(), 2); + assert!(all.contains_key("a")); +} + +#[test] +fn log_evidence_for_subset() { + let mut h = History::::builder() + .mu(0.0).sigma(6.0).beta(1.0).p_draw(0.0) + .build(); + h.record_winner(&"a", &"b", 1).unwrap(); + h.record_winner(&"b", &"a", 2).unwrap(); + let ev_all = h.log_evidence(); + let ev_a = h.log_evidence_for(&[&"a"]); + assert_ne!(ev_all, ev_a); +} +``` + +- [ ] **Step 6: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --test api_shape +``` + +- [ ] **Step 7: Commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +feat(api): add current_skill, learning_curve (single), log_evidence_for, predict_* + +learning_curves now returns HashMap> keyed on +the user's key type. log_evidence is public zero-arg; log_evidence_for +takes a slice of keys. + +predict_outcome is T2 2-team-only; N-team deferred. + +Part of T2. +EOF +)" +``` + +--- + +## Task 18: Promote `Factor` / `Schedule` / `VarStore` to `pub` under a `factors` module + +**Files:** +- Create: `src/factors.rs` +- Modify: `src/factor/mod.rs`, `src/factor/rank_diff.rs`, `src/factor/team_sum.rs`, `src/factor/trunc.rs`, `src/schedule.rs`, `src/lib.rs` + +- [ ] **Step 1: Promote visibility inside `src/factor/mod.rs`** + +Change `pub(crate) struct VarId(pub(crate) u32);` → `pub struct VarId(pub u32);`. Similarly: +- `pub(crate) struct VarStore` → `pub struct VarStore` (keep `marginals` field `pub(crate)` — implementation detail). +- `pub(crate) trait Factor` → `pub trait Factor`. +- `pub(crate) enum BuiltinFactor` → `pub enum BuiltinFactor`. +- Submodules: `pub(crate) mod team_sum;` → `pub mod team_sum;` and similarly for `rank_diff`, `trunc`. +- Inside each submodule, promote the struct and its fields/constructors from `pub(crate)` to `pub`. + +Remove any `#[allow(dead_code)]` that was guarding visibility warnings. + +- [ ] **Step 2: Promote `Schedule` and `EpsilonOrMax` in `src/schedule.rs`** + +```rust +pub trait Schedule { + fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport; +} + +pub struct EpsilonOrMax { + pub eps: f64, + pub max: usize, +} +``` + +Remove `#[allow(dead_code)]`. + +- [ ] **Step 3: Create `src/factors.rs` that re-exports the public API** + +```rust +//! Factor-graph public API. +//! +//! Power users can construct custom factor graphs via `Game::custom` and +//! drive them with a custom `Schedule` implementation. + +pub use crate::factor::{BuiltinFactor, Factor, VarId, VarStore}; +pub use crate::factor::rank_diff::RankDiffFactor; +pub use crate::factor::team_sum::TeamSumFactor; +pub use crate::factor::trunc::TruncFactor; +pub use crate::schedule::{EpsilonOrMax, Schedule, ScheduleReport}; +``` + +- [ ] **Step 4: Register in `src/lib.rs`** + +Keep `factor` and `schedule` modules as they are (internal use), and add: + +```rust +pub mod factors; +``` + +- [ ] **Step 5: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib +``` + +- [ ] **Step 6: Commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +feat(api): promote Factor/Schedule/VarStore to pub in `factors` module + +Exposes the factor-graph machinery so power users can define custom +factors and schedules (see Game::custom in the next task). The +internal factor/ and schedule/ modules remain pub(crate) — user- +facing API goes through the factors module re-exports. + +Part of T2. +EOF +)" +``` + +--- + +## Task 19: `Game::ranked`, `Game::one_v_one`, `Game::free_for_all`, `Game::custom` + +**Files:** +- Modify: `src/game.rs` + +- [ ] **Step 1: Split the internal `Game::new` into a `Game::ranked_with_arena`** + +Move the current `Game::new` body into `pub(crate) fn ranked_with_arena(...) -> Self`. The public signatures become: + +```rust +pub struct GameOptions { + pub p_draw: f64, + pub convergence: ConvergenceOptions, +} + +impl Default for GameOptions { + fn default() -> Self { + Self { + p_draw: crate::P_DRAW, + convergence: ConvergenceOptions::default(), + } + } +} + +impl<'a, T: Time, D: Drift> Game<'a, T, D> { + /// Build a ranked match from borrowed team rosters and an outcome. + /// + /// Returns `Err(MismatchedShape)` if outcome rank count doesn't match + /// team count. Returns `Err(InvalidProbability)` if p_draw is out of range. + pub fn ranked( + teams: &[&[Rating]], + outcome: Outcome, + options: &GameOptions, + ) -> Result { + if !(0.0..1.0).contains(&options.p_draw) { + return Err(InferenceError::InvalidProbability { value: options.p_draw }); + } + if outcome.team_count() != teams.len() { + return Err(InferenceError::MismatchedShape { + kind: "outcome ranks vs teams", + expected: teams.len(), + got: outcome.team_count(), + }); + } + + // Translate ranks to the legacy "higher f64 = better" result. + let ranks = outcome.as_ranks(); + let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64; + let result: Vec = ranks.iter().map(|&r| max_rank - r as f64).collect(); + let teams_owned: Vec>> = teams.iter().map(|t| t.to_vec()).collect(); + let weights: Vec> = teams.iter().map(|t| vec![1.0; t.len()]).collect(); + let mut arena = ScratchArena::new(); + let game = Self::ranked_with_arena( + teams_owned, + &result, + &weights, + options.p_draw, + &mut arena, + ); + Ok(game) + } + + /// 1v1 convenience: returns posteriors `(a_post, b_post)` directly. + pub fn one_v_one( + a: &Rating, + b: &Rating, + outcome: Outcome, + ) -> Result<(Gaussian, Gaussian), InferenceError> { + let game = Self::ranked( + &[&[*a], &[*b]], + outcome, + &GameOptions::default(), + )?; + let post = game.posteriors(); + Ok((post[0][0], post[1][0])) + } + + /// FFA: each entry is a single rating; outcome ranks per player. + pub fn free_for_all( + players: &[&Rating], + outcome: Outcome, + options: &GameOptions, + ) -> Result { + let teams: Vec>> = players.iter().map(|p| vec![**p]).collect(); + let team_refs: Vec<&[Rating]> = teams.iter().map(|t| t.as_slice()).collect(); + Self::ranked(&team_refs, outcome, options) + } + + /// Power-user: build a game from a user-defined factor graph. + /// + /// The caller owns the `VarStore` and `Vec`. The schedule + /// is run once; `posteriors_from_vars` extracts team posteriors by VarId. + pub fn custom( + teams: Vec>>, + vars: VarStore, + factors: Vec, + team_perf_vars: Vec, + weights: Vec>, + schedule: &S, + ) -> Self { + // Partial impl: run schedule, populate likelihoods, evidence. + // Full custom-factor support expands in T4. + let mut this = Self { + teams, + result: &[], // Not used for custom — outcome encoded in factors. + weights: &[], + p_draw: 0.0, + likelihoods: Vec::new(), + evidence: 1.0, + }; + this.run_custom(vars, factors, team_perf_vars, weights, schedule); + this + } + + /// Log-evidence for this game (zero if there were no Trunc factors). + pub fn log_evidence(&self) -> f64 { + self.evidence.ln() + } +} +``` + +`Game::custom` is the spec's escape hatch for user-defined factors. For T2 it only needs to *work*; its full ergonomics land in T4. Mark it `#[doc(hidden)]` if unfinished. + +- [ ] **Step 2: Add tests** + +```rust +#[test] +fn game_ranked_1v1() { + let a = Rating::new(Gaussian::from_ms(25.0, 25.0/3.0), 25.0/6.0, ConstantDrift(25.0/300.0)); + let b = Rating::new(Gaussian::from_ms(25.0, 25.0/3.0), 25.0/6.0, ConstantDrift(25.0/300.0)); + let g = Game::::ranked( + &[&[a], &[b]], + Outcome::winner(0, 2), + &GameOptions::default(), + ).unwrap(); + let p = g.posteriors(); + assert_ulps_eq!(p[0][0], Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6); + assert_ulps_eq!(p[1][0], Gaussian::from_ms(20.794779, 7.194481), epsilon = 1e-6); +} + +#[test] +fn game_one_v_one_shortcut() { + let a = Rating::::new(Gaussian::from_ms(25.0, 25.0/3.0), 25.0/6.0, ConstantDrift(25.0/300.0)); + let b = Rating::::new(Gaussian::from_ms(25.0, 25.0/3.0), 25.0/6.0, ConstantDrift(25.0/300.0)); + let (a_post, b_post) = Game::::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap(); + assert_ulps_eq!(a_post, Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6); + assert_ulps_eq!(b_post, Gaussian::from_ms(20.794779, 7.194481), epsilon = 1e-6); +} + +#[test] +fn game_ranked_rejects_bad_p_draw() { + let a = Rating::::new(Gaussian::default(), 1.0, ConstantDrift(0.0)); + let err = Game::::ranked( + &[&[a], &[a]], + Outcome::winner(0, 2), + &GameOptions { p_draw: 1.5, convergence: Default::default() }, + ).unwrap_err(); + assert!(matches!(err, InferenceError::InvalidProbability { .. })); +} +``` + +Note `Outcome::winner(0, 2)` makes team 0 the winner and team 1 the loser. The old test used `&[0.0, 1.0]` where higher = worse (team 1 had result 1.0 meaning worse result i.e. team 0 won). Under new convention `Outcome::winner(0, 2)` → ranks `[0, 1]` → mapped to legacy results `[1.0, 0.0]` → team 0 wins. But the old test asserted `p[0][0] = Gaussian::from_ms(20.794779, 7.194481)` (loser) and `p[1][0] = Gaussian::from_ms(29.205220, 7.194481)` (winner). That's because the old convention was higher `result` = worse position. **Double-check this mapping before committing golden values** — run the test and if p[0] vs p[1] flip, swap the expected values. + +- [ ] **Step 3: Build + test** + +```bash +cargo build --features approx +cargo test --features approx --lib game +``` + +- [ ] **Step 4: Commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +feat(api): add Game::ranked, one_v_one, free_for_all, custom constructors + +Public Game API now returns Result on bad +inputs. GameOptions bundles p_draw and convergence config. +Game::custom is an escape hatch for user-defined factor graphs +(full ergonomics land in T4). + +Part of T2. +EOF +)" +``` + +--- + +## Task 20: Translate the full test suite to the new API; delete legacy methods + +**Files:** +- Modify: every `#[cfg(test)] mod tests` across `src/` +- Modify: `tests/equivalence.rs` (new file) +- Delete: `History::convergence(iters, eps, verbose)`, the legacy `add_events` wrapper + +- [ ] **Step 1: Inventory remaining legacy callers** + +```bash +grep -rn 'h\.convergence(\|\.add_events(vec!\|\.time(true)\|\.time(false)\|\.gamma(' src/ tests/ +``` + +Every hit is a site that must be translated. + +- [ ] **Step 2: Translation cheat-sheet** + +Apply this uniformly across every test module. Example old form (from `src/history.rs`): + +```rust +let mut h = History::builder() + .mu(0.0).sigma(2.0).beta(1.0).gamma(0.0).time(false).build(); +h.add_events(composition, results, vec![], vec![]); +h.convergence(ITERATIONS, EPSILON, false); +``` + +New form: + +```rust +let mut h = History::::builder() + .mu(0.0).sigma(2.0).beta(1.0).drift(ConstantDrift(0.0)).build(); +// Translate nested Vec>> into Vec> +let events = translate_to_events(composition, results, /* times = 1..=n */ (1..=n).collect()); +h.add_events(events).unwrap(); +h.converge().unwrap(); +``` + +Since the tests use `IndexMap::get_or_create("a")` returning `Index`, they'll need updating to either use `history.intern(&"a")` directly or use `Member::new("a")` in `Event`. + +Provide a test helper in a `tests/common.rs` module: + +```rust +pub fn winner_event(time: i64, w: &'static str, l: &'static str) -> Event { + use smallvec::smallvec; + Event { + time, + teams: smallvec![ + Team::with_members([Member::new(w)]), + Team::with_members([Member::new(l)]), + ], + outcome: Outcome::winner(0, 2), + } +} + +pub fn draw_event(time: i64, a: &'static str, b: &'static str) -> Event { + use smallvec::smallvec; + Event { + time, + teams: smallvec![ + Team::with_members([Member::new(a)]), + Team::with_members([Member::new(b)]), + ], + outcome: Outcome::draw(2), + } +} +``` + +Then `h.add_events(vec![winner_event(1, "a", "b"), winner_event(2, "a", "c"), winner_event(3, "b", "c")]).unwrap();`. + +- [ ] **Step 3: Translate each old test one at a time** + +Work through `src/history.rs::tests`, `src/game.rs::tests`, `src/time_slice.rs::tests`. Each test retains its hardcoded golden values — numerical behavior is unchanged. Only the *construction* changes. + +For tests that accessed `h.batches[i].skills.get(idx).unwrap().posterior()`, change to `h.learning_curve(&key)[i].1` or `h.current_skill(&key).unwrap()`. + +For tests that looked at internal state (`h.batches[0].events.len()`, `get_composition()` etc.), delete those assertions if the new API doesn't expose them, or move them behind `#[cfg(test)]` accessors. + +- [ ] **Step 4: Create `tests/equivalence.rs` — the regression net** + +```rust +//! Verifies that the new API produces the same numerical results as the +//! hardcoded goldens from the old API (taken from the original Python port). + +use approx::assert_ulps_eq; +use trueskill_tt::{ + ConstantDrift, ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team, +}; +use smallvec::smallvec; + +#[test] +fn test_1vs1_matches_old_golden() { + use trueskill_tt::{Game, GameOptions, Rating}; + let a = Rating::::new( + Gaussian::from_ms(25.0, 25.0 / 3.0), + 25.0 / 6.0, + ConstantDrift(25.0 / 300.0), + ); + let b = Rating::::new( + Gaussian::from_ms(25.0, 25.0 / 3.0), + 25.0 / 6.0, + ConstantDrift(25.0 / 300.0), + ); + let (a_post, b_post) = Game::::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap(); + assert_ulps_eq!(a_post, Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6); + assert_ulps_eq!(b_post, Gaussian::from_ms(20.794779, 7.194481), epsilon = 1e-6); +} + +#[test] +fn test_env_ttt_matches_old_golden() { + let mut h = History::::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .drift(ConstantDrift(25.0 / 300.0)) + .convergence(ConvergenceOptions { max_iter: 30, epsilon: 1e-6 }) + .build(); + + let events: Vec> = vec![ + Event { time: 1, teams: smallvec![ + Team::with_members([Member::new("a")]), + Team::with_members([Member::new("b")]), + ], outcome: Outcome::winner(0, 2) }, + Event { time: 2, teams: smallvec![ + Team::with_members([Member::new("a")]), + Team::with_members([Member::new("c")]), + ], outcome: Outcome::winner(1, 2) }, + Event { time: 3, teams: smallvec![ + Team::with_members([Member::new("b")]), + Team::with_members([Member::new("c")]), + ], outcome: Outcome::winner(0, 2) }, + ]; + h.add_events(events).unwrap(); + h.converge().unwrap(); + + let a_curve = h.learning_curve(&"a"); + let b_curve = h.learning_curve(&"b"); + assert_ulps_eq!(a_curve[0].1, Gaussian::from_ms(25.000267, 5.419381), epsilon = 1e-6); + assert_ulps_eq!(b_curve[0].1, Gaussian::from_ms(24.999465, 5.419425), epsilon = 1e-6); +} + +// Repeat for test_teams, test_add_events, test_only_add_events, test_log_evidence, +// test_add_events_with_time, test_1vs1_weighted from the old history.rs tests, +// translating construction while keeping every golden value identical. +``` + +Port every one of the seven original history tests plus the seven game tests. Any test that can't be translated because the new API doesn't expose the needed internals — if its golden is preserved by a translated test, delete it; if not, either add a `pub(crate)` accessor for the test and flag in the spec notes. + +- [ ] **Step 5: Delete legacy methods** + +In `src/history.rs`, delete: +- `pub fn convergence(iters, eps, verbose) -> ((f64, f64), usize)` — replaced by `converge()`. +- The `online: bool` builder accessor, if unused externally (leave `self.online` field internal for now — it's used by `log_evidence_impl`). +- The nested-Vec public `add_events` wrapper. Only `pub(crate) fn add_events_with_prior` stays (no longer `pub`). +- `HistoryBuilder::gamma(f64)` — now users go through `.drift(ConstantDrift(g))`. + +- [ ] **Step 6: Build + test** + +```bash +cargo test --features approx +cargo clippy --all-targets --features approx -- -D warnings +``` + +All tests pass — failures here mean either a translation bug or a legitimate numeric difference. The goldens have been proven stable through T0 and T1, so any drift is translation. + +- [ ] **Step 7: Commit** + +```bash +cargo +nightly fmt +git add -A +git commit -m "$(cat <<'EOF' +test(api): translate full test suite to new API; delete legacy methods + +Every old golden is reproduced in the new API (tests/equivalence.rs +plus in-module tests). History::convergence, the nested-Vec add_events, +.gamma(f64), .time(bool) all removed. + +Part of T2. +EOF +)" +``` + +--- + +## Task 21: Final verification, benchmark capture, update CHANGELOG + +**Files:** +- Modify: `benches/baseline.txt` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Full green check** + +```bash +cargo +nightly fmt --check +cargo clippy --all-targets --features approx -- -D warnings +cargo test --features approx +cargo test --features approx --doc 2>&1 | tail -5 +``` + +All must pass. + +- [ ] **Step 2: Capture T2 benchmarks** + +```bash +cargo bench --bench batch 2>&1 | grep "Batch::iteration" +cargo bench --bench gaussian 2>&1 | grep "Gaussian::" +``` + +Compare to T1 baseline (~23 µs). Must be within 5% (23.5 µs ceiling). T2 is an API refactor; hot path should be unchanged. + +- [ ] **Step 3: Append T2 block to `benches/baseline.txt`** + +``` +# After T2 (date, same hardware) + +Batch::iteration µs (vs T1 23.010 µs: ) +Gaussian::add ps (unchanged) +Gaussian::sub ps (unchanged) +Gaussian::mul ps (unchanged) +Gaussian::div ps (unchanged) +Gaussian::pi ps (unchanged) +Gaussian::tau ps (unchanged) + +# Notes: +# - API-only tier; hot inference path unchanged so numerics match T1 within ULPs. +# - Public surface now matches spec Section 4. +# - Breaking changes: Batch→TimeSlice, Player→Rating, Agent→Competitor, +# IndexMap→KeyTable; Event/Team/Member/Outcome new types; +# Time trait; Drift generic; Observer + ConvergenceReport; Result<_, +# InferenceError> at API boundary; factors module promoted to pub. +``` + +- [ ] **Step 4: Add CHANGELOG entry** + +Read `CHANGELOG.md` (check its existing format first with `head -20 CHANGELOG.md`) and prepend a `## [Unreleased]` section listing the breaking changes bulleted from the spec. + +- [ ] **Step 5: Commit** + +```bash +git add benches/baseline.txt CHANGELOG.md +git commit -m "$(cat <<'EOF' +bench,docs: capture T2 final numbers and update CHANGELOG + +Batch::iteration: µs (T1 was 23.010 µs). +API-only tier; numerics within ULPs. + +Closes T2 of docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md. +EOF +)" +``` + +- [ ] **Step 6: Ready for review** + +The branch `t2-new-api-surface` is complete. Full test suite green, clippy clean, fmt clean, bench within target. Open the PR with: + +- Summary linking to spec + plan. +- Breaking-change table (spec Section 4½). +- Benchmark comparison. +- Migration notes for known downstream (there are none — crate is pre-1.0). + +--- + +## Self-review notes + +**Spec coverage** (against `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md` Section 7 T2 checklist): + +- ✅ `Rating`, `TimeSlice`, `Competitor`, `Member`, `Outcome`, `Event`, `KeyTable` (Tasks 2, 3, 4, 5, 9, 10) +- ✅ `Time` trait, `History>` (Tasks 6, 7, 8) +- ✅ Three-tier API: `record_winner`, `event(...).team(...).commit()`, bulk `add_events(iter)` (Tasks 14, 15, 16) +- ✅ `Observer` trait + `ConvergenceReport`; `verbose: bool` deleted (Tasks 11, 12, 20) +- ✅ `panic!`/`debug_assert!` at API boundary → `Result<_, InferenceError>` (Task 13) +- ✅ `Factor`/`Schedule`/`VarStore` promoted to `pub` under `factors` module (Task 18) +- ✅ `Game::ranked`, `one_v_one`, `free_for_all`, `custom` (Task 19) +- ✅ Equivalence tests prove identical posteriors (Task 20) +- ✅ `intern` / `lookup` for `Index` exposure (Task 14) + +**Deferred to later tiers (explicitly):** +- `Outcome::Scored` + `MarginFactor` — T4 (enum is `#[non_exhaustive]` so adding is non-breaking). +- `Damped`, `Residual` schedules — T4. +- `Send + Sync` bounds — T3. +- `predict_outcome` N-team — T4. +- `Game::custom` full ergonomics — T4. + +**Known hazards during execution:** +- **Generic parameter explosion.** After Task 8 + 12 + 14, `History` has four type params. Use trait-object erasure internally if ergonomics suffer. Default types (`i64`, `ConstantDrift`, `NullObserver`) keep common callsites readable. +- **`.player` → `.rating` field rename.** `grep -r '\.player'` also matches unrelated names. Audit manually inside `src/` only; **don't auto-replace across tests or dev deps.** +- **Outcome → legacy result translation.** The legacy `result` f64 convention is "higher is worse" for descending sort. Our new `Outcome::Ranked` stores ranks where 0 = best. Mapping: `max_rank - rank[i]`. If a test's golden flips winner/loser, this is the likely cause. +- **`Time = Untimed` behavior change.** Per spec, `Untimed::elapsed_to` returns 0 — no drift between slices. The old `time=false` mode implicitly used elapsed=1. Tests that used `time(false)` translate to `History::` with explicit `1..=n` timestamps to preserve numerics. +- **Test module translation is mechanical but big.** Expect Task 20 to be the longest task (~60–90 minutes). Work through the test files one at a time; commit after each file to keep bisectable history. + +**Things outside the plan that may bite:** +- `Cargo.toml` dev-dep `trueskill-tt = { path = ".", features = ["approx"] }` — if this references removed public names (`Batch`, `Player`), add the old name temporarily as a `type Batch = TimeSlice;` alias during the rename cascade, then remove before Task 21. +- `examples/` directory. Confirm no examples exist (`ls examples/` showed nothing at plan-write time), but re-check before Task 20 and include any in the translation pass. +- README.md probably shows the old API. Update in Task 21 alongside the CHANGELOG. diff --git a/docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md b/docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md new file mode 100644 index 0000000..3f4f00b --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md @@ -0,0 +1,619 @@ +# TrueSkill-TT Engine Redesign — Design + +**Date:** 2026-04-23 +**Status:** Approved (pending implementation plan) + +## Summary + +Comprehensive redesign of the TrueSkill-TT engine targeting four orthogonal goals: + +1. **Performance** — substantially faster offline convergence and incremental online updates. +2. **Accuracy and richer match formats** — support for score margins, free-for-all with partial orders, correlated skills. +3. **Better convergence** — replace ad-hoc capped iteration with a pluggable `Schedule` trait covering all three nested loops. +4. **Better API surface** — typed event description, observer-based progress reporting, generic time axis, structured errors, ergonomic builders. + +The design is comprehensive (Approach 1 of three considered) but delivered in five tiers so each step is independently shippable and validated by benchmarks. + +## Goals & non-goals + +**Goals** + +- 10–30× speedup on the offline convergence path for representative workloads (1000+ players, 1000+ events, 30 iterations) +- Order-of-magnitude speedup on incremental "add a single event" workloads +- Pluggable factor graph allowing new factor types without engine changes +- Optional Rayon-backed parallelism on top of `Send + Sync`-correct internals +- Typed, ergonomic public API; replace nested `Vec>>` shapes with `Event` / `Team` / `Member` +- Generic time axis: `Untimed`, `i64`, or user-supplied +- Observer-based progress instead of `verbose: bool` + `println!` +- Structured `Result<_, InferenceError>` at API boundaries + +**Non-goals** + +- WebAssembly support is not a goal; we may break it if a crate or feature requires. +- No GPU offload. +- No `no_std` support. +- No persistent format / serde — possible future feature. +- No replacement of the Gaussian/EP approximation itself in this design (the underlying inference math stays the same; we change layout, dispatch, scheduling, and API around it). + +## Workload assumptions + +Baseline workload that drives perf decisions: + +- ~1000+ players +- ~1000+ events total +- ~50–60 events per time slice (per day) +- Both online (incremental adds) and offline (full convergence) are common +- Offline convergence runs frequently + +## Section 1 — Core types & traits + +The foundation everything else builds on. + +### `Gaussian` — natural-parameter storage + +Switch storage from `(mu, sigma)` to natural parameters `(pi, tau)` where `pi = sigma⁻²`, `tau = mu · pi`. Multiplication and division dominate the hot path; in nat-params they are direct adds/subs of the components, no `sqrt`. Reads of `mu`/`sigma` become accessor methods (`tau / pi`, `1.0 / pi.sqrt()`). The trade is correct because reads are vanishingly rare compared to writes in EP. + +```rust +pub struct Gaussian { pi: f64, tau: f64 } +pub const UNIFORM: Gaussian = Gaussian { pi: 0.0, tau: 0.0 }; // replaces N_INF +``` + +### `Time` trait + +Replaces the bare `i64` time field. Keeps `History` parametric. + +```rust +pub trait Time: Copy + Ord + Send + Sync + 'static { + fn elapsed_to(&self, later: &Self) -> i64; +} +pub struct Untimed; // ZST for the no-time-axis case +impl Time for Untimed { fn elapsed_to(&self, _: &Self) -> i64 { 0 } } +impl Time for i64 { fn elapsed_to(&self, later: &Self) -> i64 { later - self } } +// Optional impls behind feature flags: time::OffsetDateTime, chrono types +``` + +### `Drift` trait + +Generic over `T: Time` so seasonal/calendar-aware drift is possible without going through `i64`. + +```rust +pub trait Drift: Copy + Send + Sync { + fn variance_delta(&self, from: &T, to: &T) -> f64; +} +``` + +`ConstantDrift(f64)` impl: `to.elapsed_to(from) as f64 * gamma * gamma`. + +### `Index` and `KeyTable` + +`Index(usize)` is the handle into dense per-`History` `Vec` storage. Public, but intended for use by power users on hot paths who want to skip the `KeyTable` lookup. Casual API takes `&K`. `KeyTable` (renamed from `IndexMap`, to avoid colliding with the `indexmap` crate's type) maps user keys → `Index`. + +### `Observer` trait + +Replaces `verbose: bool` + `println!`. Default no-op impls; user overrides what they need. + +```rust +pub trait Observer: Send + Sync { + fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {} + fn on_batch_processed(&self, _time: &T, _idx: usize, _n_events: usize) {} + fn on_converged(&self, _iters: usize, _final_step: (f64, f64)) {} +} +pub struct NullObserver; +impl Observer for NullObserver {} +``` + +### Trade-offs + +- `Gaussian` natural-param representation: anyone reading `mu`/`sigma` in a hot loop pays a sqrt — but that's correct, hot reads are rare. +- `Time` as a trait (not enum) keeps it open-ended at zero runtime cost; default `History` keeps the call sites familiar. +- `Observer` is a trait (not a closure) so different sites can have different signatures without losing type safety. `NullObserver` is a ZST. + +## Section 2 — Factor graph architecture + +The current `Game::likelihoods` is a hand-rolled, hard-coded graph. To unlock richer formats and let us experiment with EP schedules, the graph itself becomes a data structure. + +### Variable / Factor model + +Variables hold their current Gaussian marginal. Factors hold their outgoing messages to each connected variable plus do the local computation. Standard EP: factor's update is "divide marginal by old outgoing → cavity → apply local approximation → multiply marginal by new outgoing." + +```rust +pub trait Factor: Send + Sync { + fn variables(&self) -> &[VarId]; + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64); // returns max delta + fn log_evidence(&self, _vars: &VarStore) -> f64 { 0.0 } +} +``` + +### Built-in factor catalog + +| Factor | Purpose | Status | +|---|---|---| +| `PerformanceFactor` | skill → performance (add β² noise, optional weight) | replaces inline `performance() * weight` | +| `TeamSumFactor` | weighted sum of player perfs → team perf | replaces inline `fold` | +| `RankDiffFactor` | (team_a perf) − (team_b perf) → diff var | currently `team[e].posterior_win() − team[e+1].posterior_lose()` | +| `TruncFactor` | EP truncation: `P(diff > margin)` or `P(|diff| < margin)` for draws | wraps current `v_w` / `approx` | +| `MarginFactor` *(future)* | use observed score margin as soft evidence | enables richer match formats | +| `SynergyFactor` *(future)* | couples teammates' skills | enables different topology | +| `ScoreFactor` *(future)* | continuous outcome (e.g., points scored) | enables score-based outcomes | + +The first four together exactly reproduce today's algorithm. The last three are extension slots. + +### Game = factor graph + schedule + +```rust +pub struct Game { + vars: VarStore, // SoA: Vec marginals + factors: FactorList, // enum dispatch over BuiltinFactor (see Open Questions) + schedule: S, +} +``` + +Lean toward **enum dispatch** (`enum BuiltinFactor { Perf(...), Sum(...), RankDiff(...), Trunc(...), ... }`) over `Box` for the built-ins: + +- avoids per-message vtable overhead in the hottest loop +- keeps factor data inline (no heap indirection) +- still allows user-defined factors via a `BuiltinFactor::Custom(Box)` variant + +### Schedule trait + +Controls iteration order and stopping. Default = current behavior (sweep forward, then backward, until ε or max iters). Pluggable so we can later try damped EP or junction-tree schedules. + +### High-level constructors + +```rust +Game::ranked(teams, results, options) // dominant case +Game::free_for_all(players, ranking) // FFA with possible ties +Game::custom(builder) // power users build their own graph +``` + +`GameOptions` carries iteration cap, epsilon, p_draw, and approximation choice. Today these are scattered between method args and module constants. + +### Trade-offs + +- Enum dispatch over trait objects for built-ins; richer factors drop in via new enum variants. +- Variables and factor messages stored as `Vec` indexed by `VarId` / edge slot — flat, cache-friendly. +- `Schedule` is a generic parameter (zero-cost); most users get default; experimentation is open. + +### Open question + +Whether `enum BuiltinFactor` will feel too closed-world. The `Custom(Box)` escape hatch helps but inner-loop perf for user factors will be slower. Acceptable for now; flagged for future revisit if it becomes a problem. + +## Section 3 — Storage layout (SoA + arenas) + +### Dense Vec keyed by `Index` + +Every `HashMap` becomes a `Vec` (or `Vec>` for sparse) indexed directly by `Index.0`. The public-facing `KeyTable` continues to map arbitrary keys → `Index`. + +### SoA at hot layers, AoS at boundaries + +The `Skill` struct stays as a public type for the API (returned from `learning_curves`, etc.), but inside `TimeSlice` we lay it out column-wise: + +```rust +struct TimeSliceSkills { + forward: Vec, // [n_agents] + backward: Vec, + likelihood: Vec, + online: Vec, + elapsed: Vec, + present: Vec, +} +``` + +Within a slice, the inner loops touch one column repeatedly across many events — keeping the column contiguous improves cache utilization and makes the eventual SIMD step (Section 6) straightforward. + +`Gaussian` itself stays as a single 16-byte struct in the `Vec`. Splitting into two parallel `Vec`s wins for pure SIMD over thousands of Gaussians but loses for the random-access patterns dominant in EP. Revisit if benchmarks demand it. + +### Arena allocator inside `Game` + +Replace per-event allocations with a `ScratchArena` reused across calls. + +```rust +pub struct ScratchArena { + var_buf: Vec, + factor_buf: Vec, // edge messages + bool_buf: Vec, + f64_buf: Vec, +} +impl ScratchArena { + fn reset(&mut self); // sets len=0, keeps capacity + fn alloc_vars(&mut self, n: usize) -> &mut [Gaussian]; +} +``` + +`TimeSlice` owns one `ScratchArena`; each event borrows it for the duration of its `Game` construction and inference. For the parallel-slice story (Section 6), each Rayon task gets its own arena. + +### Per-event storage layout + +Inside a `TimeSlice`, each event is stored column-wise as well, with `Item` inlined into team-level parallel arrays: + +```rust +struct EventStorage { + teams: SmallVec<[TeamStorage; 4]>, + outcome: Outcome, + weights: SmallVec<[SmallVec<[f64; 4]>; 4]>, + evidence: f64, +} +struct TeamStorage { + competitors: SmallVec<[Index; 4]>, // who's on the team + edge_messages: SmallVec<[Gaussian; 4]>, // outgoing message per slot + output: f64, +} +``` + +Iteration over `(competitor, edge_message)` pairs zips two slices — no per-element struct. + +### SmallVec for typical shapes + +Teams ≤ ~5 players, games ≤ ~8 teams. `SmallVec<[T; 8]>` for team membership and `SmallVec<[T; 4]>` for team rosters keeps the common case allocation-free. + +### Trade-offs + +- Dense `Vec` keyed by `Index` is faster but means agent removal needs tombstones (or just leaves slots present-but-inactive). Acceptable: TrueSkill histories rarely remove players. +- SoA at `TimeSlice` level only, not at `History` level. `History` keeps `Vec` because slices are heterogeneous in size. +- One `ScratchArena` per `TimeSlice` keeps the lifetime story simple. + +### Open question + +The `TimeSliceSkills` sketch above uses (b) **dense + present mask**: one slot per agent in the history, indexed directly by `Index`, with a `present: Vec` mask for batches the agent didn't participate in. The alternative is (a) **sparse columnar**: a `Vec` of present agents and parallel `Vec` columns of length `n_present`, with a separate lookup (binary search or auxiliary table) to find a given `Index`'s slot. + +(b) gives O(1) lookup and SIMD-friendly columns but wastes memory for sparsely populated slices. (a) is leaner per-slice but pays per-lookup cost in the inner loop. Bench both during T0 and pick. Default proposal: (b), since modern systems are memory-rich and the parallelism story is cleaner. + +## Section 4 — API surface + +### Typed event description + +```rust +pub struct Event { + pub time: T, + pub teams: SmallVec<[Team; 4]>, + pub outcome: Outcome, +} + +pub struct Team { + pub members: SmallVec<[Member; 4]>, +} + +pub struct Member { + pub key: K, + pub weight: f64, // default 1.0 + pub prior: Option, // per-event override +} + +pub enum Outcome { + Ranked(SmallVec<[u32; 4]>), // rank per team; equal ranks = tie + Scored(SmallVec<[f64; 4]>), // continuous score per team (engages MarginFactor) +} +``` + +`Outcome::winner(0)`, `Outcome::draw()`, `Outcome::ranking([0,1,2])` are convenience constructors. + +### Builders + +```rust +let mut history = History::::builder() + .mu(25.0).sigma(25.0/3.0).beta(25.0/6.0) + .drift(ConstantDrift(0.03)) + .p_draw(0.10) + .convergence(ConvergenceOptions { max_iter: 30, epsilon: 1e-6 }) + .observer(LogObserver::default()) + .build(); +``` + +For the no-time case, type inference picks `Untimed`: + +```rust +let mut history = History::::builder().build(); +``` + +### Three-tier event ingestion + +```rust +// 1. Bulk ingestion (high-throughput path) +history.add_events(events_iter)?; + +// 2. One-off match (very common in practice) +history.record_winner("alice", "bob", time)?; +history.record_draw("alice", "bob", time)?; + +// 3. Builder for irregular shapes +history.event(time) + .team(["alice", "bob"]).weights([1.0, 0.7]) + .team(["carol"]) + .ranking([1, 0]) + .commit()?; +``` + +### Convergence & queries + +```rust +let report: ConvergenceReport = history.converge()?; + +let curve: Vec<(i64, Gaussian)> = history.learning_curve(&"alice"); +let all = history.learning_curves(); // HashMap<&K, Vec<(T, Gaussian)>> +let now = history.current_skill(&"alice"); // Option + +let ev = history.log_evidence(); +let ev_for = history.log_evidence_for(&["alice", "bob"]); + +let q = history.predict_quality(&[&["alice"], &["bob"]]); +let p_win = history.predict_outcome(&[&["alice"], &["bob"]]); +``` + +### Standalone Game + +```rust +let g = Game::ranked(&[&[alice], &[bob]], Outcome::winner(0), &options); +let post = g.posteriors(); + +// Convenience +let (a, b) = Game::one_v_one(&alice, &bob, Outcome::winner(0)); +``` + +### Errors + +Replace `debug_assert!`/`panic!` at the API boundary with `Result`. + +```rust +pub enum InferenceError { + MismatchedShape { kind: &'static str, expected: usize, got: usize }, + InvalidProbability { value: f64 }, + ConvergenceFailed { last_step: (f64, f64), iterations: usize }, + NegativePrecision { pi: f64 }, +} +``` + +Hot inner loops still use `debug_assert!` for invariants the API has already enforced. + +### Trade-offs + +- Generic over user's `K`; engine works in `Index`. Public outputs use `&K`. +- `SmallVec` everywhere on the event-description path. +- Three-tier API so casual users don't drown in types and bulk users still get throughput. +- `Outcome` enum replaces the "lower number wins" `&[f64]` convention. + +### Open question + +Whether to expose `Index` directly to users via an `intern_key(&K) -> Index` method, letting hot-path callers skip the `KeyTable` lookup on every call. Recommendation: yes — public `Index` handle plus `history.lookup>(&Q) -> Option`. The casual API still takes `&K` everywhere; power users can promote to `Index` when profiling demands. + +## Section 4½ — Naming pass + +| Current | New | Rationale | +|---|---|---| +| `History` | `History` (kept) | Matches upstream; reads cleanly. | +| `Batch` | `TimeSlice` | Says what it is: every event sharing one timestamp. | +| `Player` | `Rating` | The struct holds prior/beta/drift — that's a rating configuration. Resolves the `Player`/`Agent` confusion. | +| `Agent` | `Competitor` | Holds dynamic state for someone competing in the history; fits the domain. | +| `Skill` | `Skill` (kept) | Per-time-slice skill estimate; clearer than `BatchSkill`. | +| `Item` | inlined into `TeamStorage` columns (engine) / `Member` (public) | Eliminates the per-element struct in the hot path; gives API users a clear "team member" name. | +| `Game` | `Game` (kept) | `Match` collides with Rust's `match`. | +| `Index` | `Index` (kept) | Internal handle. | +| `IndexMap` | `KeyTable` | Avoids confusion with the `indexmap` crate. | + +## Section 5 — Convergence & message scheduling + +### Three nested loops, one mechanism + +The system has three nested convergence loops: + +1. Within-game: EP sweeps over the factor graph +2. Within-time-slice: re-running games as inputs change +3. Cross-history: forward-pass then backward-pass over all slices + +All three implement `Workload`; one `Schedule` impl drives all of them. + +```rust +pub trait Schedule { + fn run(&self, workload: &mut W) -> ScheduleReport; +} + +pub trait Workload { + fn step(&mut self) -> (f64, f64); + fn snapshot_evidence(&self) -> f64 { 0.0 } +} + +pub struct ScheduleReport { + pub iterations: usize, + pub final_step: (f64, f64), + pub converged: bool, +} +``` + +### Built-in schedules + +| Schedule | Behavior | Use | +|---|---|---| +| `EpsilonOrMax { eps, max }` | Default. Sweep until `(dpi, dtau) ≤ eps` or `max` iters. | All three loops. Replicates current behavior. | +| `Damped { eps, max, alpha }` | Same, but writes `α·new + (1−α)·old`. | Stuck oscillations. | +| `Residual { eps, max }` | Priority-queue: re-update factor with largest pending delta first. | Faster convergence on uneven graphs. | +| `OneShot` | Exactly one pass, no convergence check. | Online incremental adds. | + +### Stopping in natural-param space + +Switch from `(|Δmu|, |Δsigma|) ≤ epsilon` to `(|Δpi|, |Δtau|) ≤ (eps_pi, eps_tau)`: + +- `mu` and `sigma` are on different scales; one tolerance is wrong for both +- We store in nat-params anyway — checking convergence in mu/sigma costs free sqrts +- Nat-param delta is the natural geometry of the EP fixed point + +Default `EpsilonOrMax::default()` exposes a single `epsilon` for simplicity; advanced ctor exposes both tolerances. + +### Within-game improvements + +- Replace hard-cap of 10 iterations with `GameOptions::schedule` that propagates `ScheduleReport` upward +- Fast path: graphs with no diff chain (1v1 with 1 iter sufficient) skip the loop entirely +- FFA / many-team ranks benefit from `Residual`; opt-in + +### Within-slice and cross-history improvements + +- **No more old/new HashMap snapshotting**: track deltas inline as we write under SoA +- **Per-slice dirty bits**: a `TimeSlice` whose neighbor messages haven't changed since its last full sweep doesn't need to re-run. Track `time_slice.dirty` and skip clean ones during the cross-history sweep. Big win for online-add (the locality case). + +### `ConvergenceReport` + +```rust +pub struct ConvergenceReport { + pub iterations: usize, + pub final_step: (f64, f64), + pub log_evidence: f64, + pub converged: bool, + pub per_iteration_time: SmallVec<[Duration; 32]>, + pub batches_skipped: usize, +} +``` + +`Observer` continues to receive per-iteration callbacks for live UI; `ConvergenceReport` is the post-hoc summary. + +### Trade-offs + +- One `Schedule` trait shared across loops — fewer concepts, more composable. +- Convergence checks in nat-param space — slightly different exact threshold than today; tests' epsilons re-tuned mechanically. +- Dirty-bit skipping changes iteration order vs. today; fixed point is the same, iteration counts may shift downward. +- `Residual` and `Damped` are opt-in; default behavior matches today closely. + +### Open question + +Whether `Schedule::run` should take an optional `Observer` reference. Recommendation: observation lives at a higher layer (`History::converge` calls observer hooks; `Schedule` is purely the loop driver). + +## Section 6 — Concurrency & parallelism + +### What's parallelizable + +| Operation | Parallelism | Strategy | +|---|---|---| +| `History::converge()` (full forward+backward) | Sequential across slices | Within each slice: color-group events in parallel via Rayon | +| `History::add_events(...)` | Sequential append, but ingestion of typed events into `EventStorage` parallelizes trivially | n/a | +| `History::learning_curves()` | Per-key parallel | `into_par_iter()` | +| `History::log_evidence_for(targets)` | Per-batch parallel, reduce sum | `par_iter().map(...).sum()` | +| `Game` inference | Sequential | n/a (too small to amortize Rayon overhead) | + +### Within-slice color-group parallelism + +When events are added to a slice, partition them into color groups where events in the same color touch no shared `Index`. Within a color, run events in parallel via Rayon. Across colors, run sequentially. Preserves asynchronous-EP semantics exactly. + +Alternative: synchronous EP with snapshot. All events read from a frozen skill snapshot, write deltas to thread-local buffers, barrier merges. Trivially parallel but weaker per-iteration convergence — needs damping. Available as a `Schedule` impl, opt-in. + +### `Send + Sync` requirements + +All public traits (`Time`, `Drift`, `Observer`, `Factor`, `Schedule`) require `Send + Sync`. `Observer` impls must be thread-safe (called from arbitrary worker threads). + +### Rayon as default-on feature + +`rayon` as default-on feature; with `default-features = false`, parallel paths fall back to sequential iterators behind `cfg(feature = "rayon")`. + +### Expected speedup ballpark + +For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations: + +| Source | Estimated speedup vs. today | +|---|---| +| `HashMap` → dense `Vec` | 2–4× | +| Natural-param `Gaussian`, no-sqrt mul/div | 1.5–2× | +| Pre-allocated `ScratchArena` | 1.2–1.5× | +| Color-group parallel events in slice (8 cores) | 2–4× | +| Dirty-bit slice skipping (online add case) | 5–50× | +| **Combined (offline converge)** | ~10–30× | +| **Combined (online add)** | ~50–500× depending on locality | + +These are pre-implementation estimates. Each tier validates with criterion. + +### Trade-offs + +- Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap. +- Default = asynchronous EP (preserves current semantics). Synchronous opt-in only. +- Cross-slice sweep stays sequential; no speculative parallel sweeps. +- Rayon default-on but feature-gated. + +### Open question + +Whether to expose color-group partitioning to users. Recommendation: hidden by default, escape hatch via `add_events_with_partition(...)` for power users who already know their event independence. + +## Section 7 — Migration, testing, and delivery plan + +The crate is unreleased, so version-bump ceremony doesn't apply. Tiers are sequencing of work and milestones, not releases. + +### Tier sequence + +**T0 — Numerical parity (no API change)** + +Internal-only. Public surface unchanged. + +- Switch `Gaussian` storage to natural parameters `(pi, tau)`. `mu()`/`sigma()` become accessors. +- Replace `HashMap` with dense `Vec<_>` keyed by `Index.0` everywhere. +- Introduce `ScratchArena` inside `Batch` so `Game::new` stops allocating per-event. +- Drop the `panic!` in `mu_sigma`; return `Result` propagated upward. + +**Acceptance:** existing test suite passes (bit-equal where possible, ULP-bounded where natural-param arithmetic shifts a rounding); `cargo bench` shows ≥3× win on `batch` benchmark; no API breakage. + +**T1 — Factor graph machinery (internal-only)** + +- Introduce `Factor`, `VarStore`, `Schedule` as `pub(crate)` types. +- Re-implement `Game::likelihoods()` on top of `BuiltinFactor::{Perf, TeamSum, RankDiff, Trunc}` driven by `EpsilonOrMax`. +- Replace within-game iteration tracking with `ScheduleReport`. + +**Acceptance:** existing test suite passes (ULP-bounded); within-game iteration counts unchanged; benchmarks ≥ T0. + +**T2 — New API surface (breaking)** + +All renames and the new public API land together. No half-renamed intermediate state. + +- New types: `Rating`, `TimeSlice`, `Competitor`, `Member`, `Outcome`, `Event`, `KeyTable`. +- `Time` trait introduced; `History>` is generic. +- Three-tier API surface: `record_winner`, `event(...).team(...).commit()`, bulk `add_events(iter)`. +- `Observer` trait + `ConvergenceReport`; `verbose: bool` deleted. +- `panic!`/`debug_assert!` at API boundary become `Result<_, InferenceError>`. +- Promote `Factor`/`Schedule`/`VarStore` to `pub` under a `factors` module. + +**Acceptance:** full test suite rewritten in new API; equivalence tests prove identical posteriors vs. old API on the same inputs. + +**T3 — Concurrency** + +- `Send + Sync` audit and bounds on all public traits. +- Color-group partitioning at `TimeSlice` ingestion. +- `rayon` as default-on feature with `#[cfg(feature = "rayon")]` fallback. +- Parallel paths: within-slice color groups, `learning_curves`, `log_evidence_for`. + +**Acceptance:** deterministic posteriors across `RAYON_NUM_THREADS={1,2,4,8}`; benchmarks show >2× on 8-core for offline converge. + +**T4 — Richer factor types & schedules** + +Each shipped independently after T3. + +- `MarginFactor` → enables `Outcome::Scored`. +- `Damped` and `Residual` schedules. +- `SynergyFactor`, `ScoreFactor` → same pattern when wanted. + +Each comes with its own benchmark and a worked example in `examples/`. + +### Testing strategy + +| Layer | Approach | +|---|---| +| **Numerical correctness** | Keep existing hardcoded golden values from `test_1vs1`, `test_1vs1_draw`, `test_2vs1vs2_mixed`, etc. through T0–T1 unchanged. They are a regression net against the original Python port. | +| **API parity** | T2 adds an `equivalence` test module that runs identical inputs through old vs. new construction and compares posteriors within ULPs. | +| **Property tests** | Add `proptest` for: factor graph fixed-point invariance under message order, `Outcome` round-trip, `Gaussian` mul/div associativity in nat-params, schedule convergence regardless of starting state. | +| **Determinism** | T3 adds tests that run identical input across multiple Rayon thread counts and assert identical posteriors. | +| **Benchmark gates** | Each tier has a "must not regress" gate vs. the previous tier on the existing `batch` and `gaussian` criterion suites. T0 must beat baseline by ≥3×; T1 ≥ T0; etc. | + +### Risk management + +- **T0 risk: rounding drift in tests.** Mitigation: where natural-param arithmetic legitimately changes the last ULPs, update goldens *and* simultaneously add a parity test against a snapshot taken from baseline to prove the difference is bounded. +- **T2 risk: API design mistakes.** Mitigation: review the spec and a worked example before implementing; iterate on feedback. +- **T3 risk: subtle race conditions in color-group partitioning.** Mitigation: `loom` tests for the merge step; deterministic-output assertion across thread counts. +- **Cross-tier risk: scope creep.** Each tier has a closed checklist; new ideas go to the next tier's wishlist. + +### What we're explicitly *not* doing + +- No GPU offload. +- No `no_std` support. +- No serde / persistence in this design. +- No incremental online API beyond `record_winner` / `add_events`. + +## Open questions summary + +Collected here for the review pass: + +1. **`enum BuiltinFactor` extensibility** — may feel too closed-world; revisit if user-defined factors via `Custom(Box)` become common. +2. **Sparse vs. dense per-slice skill storage** — default to dense + `present` mask; sparse columnar is the alternative. Decided by T0 benchmarks. +3. **`Index` exposure for hot paths** — expose `intern_key`/`lookup` so power users can promote `&K` to `Index` and skip the `KeyTable` lookup; casual API still takes `&K` everywhere. +4. **`Schedule::run` and observer wiring** — observation stays at higher layer (`History::converge` calls observer hooks; `Schedule` is purely the loop driver). +5. **Color-group partition exposure** — hidden by default, escape hatch via `add_events_with_partition(...)`. diff --git a/examples/atp.rs b/examples/atp.rs index 739b33f..e82c41a 100644 --- a/examples/atp.rs +++ b/examples/atp.rs @@ -1,50 +1,61 @@ use plotters::prelude::*; +use smallvec::smallvec; use time::{Date, Month}; -use trueskill_tt::{History, IndexMap}; +use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift}; fn main() { let mut csv = csv::Reader::open("examples/atp.csv").unwrap(); - let mut composition = Vec::new(); - let mut results = Vec::new(); - let mut times = Vec::new(); - let from = Date::from_calendar_date(1900, Month::January, 1).unwrap(); let time_format = time::format_description::parse("[year]-[month]-[day]").unwrap(); - let mut index_map = IndexMap::new(); + let mut events: Vec> = Vec::new(); for row in csv.records() { - if &row["double"] == "t" { - let w1_id = index_map.get_or_create(&row["w1_id"]); - let w2_id = index_map.get_or_create(&row["w2_id"]); - - let l1_id = index_map.get_or_create(&row["l1_id"]); - let l2_id = index_map.get_or_create(&row["l2_id"]); - - composition.push(vec![vec![w1_id, w2_id], vec![l1_id, l2_id]]); - } else { - let w1_id = index_map.get_or_create(&row["w1_id"]); - - let l1_id = index_map.get_or_create(&row["l1_id"]); - - composition.push(vec![vec![w1_id], vec![l1_id]]); - } - - results.push(vec![1.0, 0.0]); - let date = Date::parse(&row["time_start"], &time_format).unwrap(); + let time = (date - from).whole_days(); - times.push((date - from).whole_days()); + if &row["double"] == "t" { + events.push(Event { + time, + teams: smallvec![ + Team::with_members([ + Member::new(row["w1_id"].to_owned()), + Member::new(row["w2_id"].to_owned()), + ]), + Team::with_members([ + Member::new(row["l1_id"].to_owned()), + Member::new(row["l2_id"].to_owned()), + ]), + ], + outcome: Outcome::winner(0, 2), + }); + } else { + events.push(Event { + time, + teams: smallvec![ + Team::with_members([Member::new(row["w1_id"].to_owned())]), + Team::with_members([Member::new(row["l1_id"].to_owned())]), + ], + outcome: Outcome::winner(0, 2), + }); + } } - let mut hist = History::builder().sigma(1.6).gamma(0.036).build(); + let mut hist: History = History::builder_with_key() + .sigma(1.6) + .drift(ConstantDrift(0.036)) + .convergence(trueskill_tt::ConvergenceOptions { + max_iter: 10, + epsilon: 0.01, + }) + .build(); - hist.add_events(composition, results, times, vec![]); - hist.convergence(10, 0.01, true); + hist.add_events(events).unwrap(); + hist.converge().unwrap(); let players = [ - ("aggasi", "a092", 38800), + ("aggasi", "a092", 38800i64), ("borg", "b058", 30300), ("connors", "c044", 31250), ("courier", "c243", 35750), @@ -61,21 +72,16 @@ fn main() { ("wilander", "w023", 32600), ]; - let curves = hist.learning_curves(); - let mut x_spec = (f64::MAX, f64::MIN); let mut y_spec = (f64::MAX, f64::MIN); - for (id, cutoff) in players - .iter() - .map(|&(_, id, cutoff)| (index_map.get_or_create(id), cutoff)) - { - for (ts, gs) in &curves[&id] { - if *ts >= cutoff { + for &(_, id, cutoff) in &players { + for (ts, gs) in hist.learning_curve(id) { + if ts >= cutoff { continue; } - let ts = *ts as f64; + let ts = ts as f64; if ts < x_spec.0 { x_spec.0 = ts; @@ -85,8 +91,8 @@ fn main() { x_spec.1 = ts; } - let upper = gs.mu + gs.sigma; - let lower = gs.mu - gs.sigma; + let upper = gs.mu() + gs.sigma(); + let lower = gs.mu() - gs.sigma(); if lower < y_spec.0 { y_spec.0 = lower; @@ -111,24 +117,19 @@ fn main() { chart.configure_mesh().draw().unwrap(); - for (idx, (player, id, cutoff)) in players - .iter() - .map(|&(player, id, cutoff)| (player, index_map.get_or_create(id), cutoff)) - .enumerate() - { + for (idx, &(player, id, cutoff)) in players.iter().enumerate() { let mut data = Vec::new(); let mut upper = Vec::new(); let mut lower = Vec::new(); - for (ts, gs) in curves[&id].iter() { - if *ts >= cutoff { + for (ts, gs) in hist.learning_curve(id) { + if ts >= cutoff { continue; } - data.push((*ts as f64, gs.mu)); - - upper.push((*ts as f64, gs.mu + gs.sigma)); - lower.push((*ts as f64, gs.mu - gs.sigma)); + data.push((ts as f64, gs.mu())); + upper.push((ts as f64, gs.mu() + gs.sigma())); + lower.push((ts as f64, gs.mu() - gs.sigma())); } let color = Palette99::pick(idx); diff --git a/src/agent.rs b/src/agent.rs deleted file mode 100644 index e8073b9..0000000 --- a/src/agent.rs +++ /dev/null @@ -1,47 +0,0 @@ -use crate::{ - N_INF, - drift::{ConstantDrift, Drift}, - gaussian::Gaussian, - player::Player, -}; - -#[derive(Debug)] -pub struct Agent { - pub player: Player, - pub message: Gaussian, - pub last_time: i64, -} - -impl Agent { - pub(crate) fn receive(&self, elapsed: i64) -> Gaussian { - if self.message != N_INF { - self.message - .forget(self.player.drift.variance_delta(elapsed)) - } else { - self.player.prior - } - } -} - -impl Default for Agent { - fn default() -> Self { - Self { - player: Player::default(), - message: N_INF, - last_time: i64::MIN, - } - } -} - -pub(crate) fn clean<'a, D: Drift + 'a, A: Iterator>>( - agents: A, - last_time: bool, -) { - for a in agents { - a.message = N_INF; - - if last_time { - a.last_time = i64::MIN; - } - } -} diff --git a/src/approx.rs b/src/approx.rs index f187be9..e69f77b 100644 --- a/src/approx.rs +++ b/src/approx.rs @@ -10,8 +10,8 @@ impl AbsDiffEq for Gaussian { } fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool { - f64::abs_diff_eq(&self.mu, &other.mu, epsilon) - && f64::abs_diff_eq(&self.sigma, &other.sigma, epsilon) + f64::abs_diff_eq(&self.mu(), &other.mu(), epsilon) + && f64::abs_diff_eq(&self.sigma(), &other.sigma(), epsilon) } } @@ -26,8 +26,8 @@ impl RelativeEq for Gaussian { epsilon: Self::Epsilon, max_relative: Self::Epsilon, ) -> bool { - f64::relative_eq(&self.mu, &other.mu, epsilon, max_relative) - && f64::relative_eq(&self.sigma, &other.sigma, epsilon, max_relative) + f64::relative_eq(&self.mu(), &other.mu(), epsilon, max_relative) + && f64::relative_eq(&self.sigma(), &other.sigma(), epsilon, max_relative) } } @@ -37,7 +37,7 @@ impl UlpsEq for Gaussian { } fn ulps_eq(&self, other: &Self, epsilon: Self::Epsilon, max_ulps: u32) -> bool { - f64::ulps_eq(&self.mu, &other.mu, epsilon, max_ulps) - && f64::ulps_eq(&self.sigma, &other.sigma, epsilon, max_ulps) + f64::ulps_eq(&self.mu(), &other.mu(), epsilon, max_ulps) + && f64::ulps_eq(&self.sigma(), &other.sigma(), epsilon, max_ulps) } } diff --git a/src/arena.rs b/src/arena.rs new file mode 100644 index 0000000..7d8a319 --- /dev/null +++ b/src/arena.rs @@ -0,0 +1,56 @@ +use crate::{factor::VarStore, gaussian::Gaussian}; + +/// Reusable scratch buffers for `Game::likelihoods`. +/// +/// A `TimeSlice` owns one arena; all events in the slice share it across +/// the convergence iterations. All Vecs are cleared (not dropped) on +/// `reset()` so their heap capacity is reused across games. +#[derive(Debug, Default)] +pub struct ScratchArena { + pub(crate) vars: VarStore, + pub(crate) sort_buf: Vec, + pub(crate) inv_buf: Vec, + pub(crate) team_prior: Vec, + pub(crate) lhood_lose: Vec, + pub(crate) lhood_win: Vec, +} + +impl ScratchArena { + pub fn new() -> Self { + Self::default() + } + + #[inline] + pub(crate) fn reset(&mut self) { + self.vars.clear(); + self.sort_buf.clear(); + self.inv_buf.clear(); + self.team_prior.clear(); + self.lhood_lose.clear(); + self.lhood_win.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{N_INF, gaussian::Gaussian}; + + #[test] + fn reset_keeps_capacity() { + let mut arena = ScratchArena::new(); + arena.vars.alloc(N_INF); + arena.sort_buf.push(42); + arena.team_prior.push(Gaussian::from_ms(0.0, 1.0)); + let var_cap = arena.vars.marginals.capacity(); + let sort_cap = arena.sort_buf.capacity(); + let prior_cap = arena.team_prior.capacity(); + arena.reset(); + assert_eq!(arena.vars.len(), 0); + assert_eq!(arena.sort_buf.len(), 0); + assert_eq!(arena.team_prior.len(), 0); + assert_eq!(arena.vars.marginals.capacity(), var_cap); + assert_eq!(arena.sort_buf.capacity(), sort_cap); + assert_eq!(arena.team_prior.capacity(), prior_cap); + } +} diff --git a/src/competitor.rs b/src/competitor.rs new file mode 100644 index 0000000..78b44a5 --- /dev/null +++ b/src/competitor.rs @@ -0,0 +1,71 @@ +use crate::{ + N_INF, + drift::{ConstantDrift, Drift}, + gaussian::Gaussian, + rating::Rating, + time::Time, +}; + +/// Per-history, temporal state for someone competing. +/// +/// Renamed from `Agent` in T2; the former `.player` field is now +/// `.rating` to match the `Player → Rating` rename. +#[derive(Debug)] +pub struct Competitor = ConstantDrift> { + pub rating: Rating, + pub message: Gaussian, + pub last_time: Option, +} + +impl> Competitor { + /// Compute the message received at time `now`, with drift accumulated + /// from `self.last_time` (if any) to `now`. + pub(crate) fn receive(&self, now: &T) -> Gaussian { + if self.message != N_INF { + let elapsed_variance = match &self.last_time { + Some(last) => self.rating.drift.variance_delta(last, now), + None => 0.0, + }; + self.message.forget(elapsed_variance) + } else { + self.rating.prior + } + } + + /// Compute the message using a pre-cached elapsed count (in `Time::elapsed_to` units). + /// + /// Used in convergence sweeps where the elapsed was cached at slice-construction time + /// and should not be recomputed from `last_time` (which may have shifted). + pub(crate) fn receive_for_elapsed(&self, elapsed: i64) -> Gaussian { + if self.message != N_INF { + self.message + .forget(self.rating.drift.variance_for_elapsed(elapsed)) + } else { + self.rating.prior + } + } +} + +impl Default for Competitor { + fn default() -> Self { + Self { + rating: Rating::default(), + message: N_INF, + last_time: None, + } + } +} + +pub(crate) fn clean<'a, T, D, C>(competitors: C, last_time: bool) +where + T: Time + 'a, + D: Drift + 'a, + C: Iterator>, +{ + for c in competitors { + c.message = N_INF; + if last_time { + c.last_time = None; + } + } +} diff --git a/src/convergence.rs b/src/convergence.rs new file mode 100644 index 0000000..d03359c --- /dev/null +++ b/src/convergence.rs @@ -0,0 +1,31 @@ +//! Convergence configuration and reporting. + +use std::time::Duration; + +use smallvec::SmallVec; + +#[derive(Clone, Copy, Debug)] +pub struct ConvergenceOptions { + pub max_iter: usize, + pub epsilon: f64, +} + +impl Default for ConvergenceOptions { + fn default() -> Self { + Self { + max_iter: crate::ITERATIONS, + epsilon: crate::EPSILON, + } + } +} + +/// Post-hoc summary of a `History::converge` call. +#[derive(Clone, Debug)] +pub struct ConvergenceReport { + pub iterations: usize, + pub final_step: (f64, f64), + pub log_evidence: f64, + pub converged: bool, + pub per_iteration_time: SmallVec<[Duration; 32]>, + pub slices_skipped: usize, +} diff --git a/src/drift.rs b/src/drift.rs index 5c7107e..57e684a 100644 --- a/src/drift.rs +++ b/src/drift.rs @@ -1,14 +1,36 @@ use std::fmt::Debug; -pub trait Drift: Copy + Debug { - fn variance_delta(&self, elapsed: i64) -> f64; +use crate::time::Time; + +/// Governs how much a competitor's skill can drift between two time points. +/// +/// Generic over `T: Time` so seasonal or calendar-aware drift is expressible +/// without going through `i64`. +pub trait Drift: Copy + Debug { + /// Variance added to the skill prior for elapsed time `from -> to`. + /// + /// Called with `from <= to`; returning zero means no drift accumulates. + fn variance_delta(&self, from: &T, to: &T) -> f64; + + /// Variance added for a pre-computed elapsed count (in the same units as + /// `T::elapsed_to`). Used where the elapsed is already cached as `i64`. + fn variance_for_elapsed(&self, elapsed: i64) -> f64; } +/// Simple constant-per-unit-time drift. +/// +/// For `Time = i64`: variance added is `(to - from) * gamma^2`. +/// For `Time = Untimed`: elapsed is always 0, so drift is always 0. #[derive(Clone, Copy, Debug)] pub struct ConstantDrift(pub f64); -impl Drift for ConstantDrift { - fn variance_delta(&self, elapsed: i64) -> f64 { - elapsed as f64 * self.0 * self.0 +impl Drift for ConstantDrift { + fn variance_delta(&self, from: &T, to: &T) -> f64 { + let elapsed = from.elapsed_to(to).max(0) as f64; + elapsed * self.0 * self.0 + } + + fn variance_for_elapsed(&self, elapsed: i64) -> f64 { + elapsed.max(0) as f64 * self.0 * self.0 } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e32a124 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,51 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub enum InferenceError { + /// Expected and actual lengths of some array-shaped input differ. + MismatchedShape { + kind: &'static str, + expected: usize, + got: usize, + }, + /// A probability value is outside `[0, 1]`. + InvalidProbability { value: f64 }, + /// Convergence exceeded `max_iter` without falling below `epsilon`. + ConvergenceFailed { + last_step: (f64, f64), + iterations: usize, + }, + /// Negative precision: a Gaussian with `pi < 0` slipped into an API call. + NegativePrecision { pi: f64 }, +} + +impl fmt::Display for InferenceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MismatchedShape { + kind, + expected, + got, + } => { + write!(f, "{kind}: expected length {expected}, got {got}") + } + Self::InvalidProbability { value } => { + write!(f, "probability must be in [0, 1]; got {value}") + } + Self::ConvergenceFailed { + last_step, + iterations, + } => { + write!( + f, + "convergence failed after {iterations} iterations; last step = {last_step:?}" + ) + } + Self::NegativePrecision { pi } => { + write!(f, "precision must be non-negative; got {pi}") + } + } + } +} + +impl std::error::Error for InferenceError {} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..9e1579c --- /dev/null +++ b/src/event.rs @@ -0,0 +1,132 @@ +//! Typed event description for bulk ingestion. +//! +//! `Event` is the new public event shape (spec Section 4). Replaces +//! the nested `Vec>>`, `Vec>`, `Vec>>` +//! that the old `add_events_with_prior` took. + +use smallvec::SmallVec; + +use crate::{gaussian::Gaussian, outcome::Outcome, time::Time}; + +/// A single match at time `time` involving some number of teams. +#[derive(Clone, Debug)] +pub struct Event { + pub time: T, + pub teams: SmallVec<[Team; 4]>, + pub outcome: Outcome, +} + +/// A team: list of members competing together. +#[derive(Clone, Debug)] +pub struct Team { + pub members: SmallVec<[Member; 4]>, +} + +impl Team { + pub fn new() -> Self { + Self { + members: SmallVec::new(), + } + } + + pub fn with_members>>(members: I) -> Self { + Self { + members: members.into_iter().collect(), + } + } +} + +impl Default for Team { + fn default() -> Self { + Self::new() + } +} + +/// One member of a team, identified by user key `K`. +/// +/// `weight` defaults to 1.0; a per-event `prior` can override the competitor's +/// current skill estimate for this event only. +#[derive(Clone, Debug)] +pub struct Member { + pub key: K, + pub weight: f64, + pub prior: Option, +} + +impl Member { + pub fn new(key: K) -> Self { + Self { + key, + weight: 1.0, + prior: None, + } + } + + pub fn with_weight(mut self, weight: f64) -> Self { + self.weight = weight; + self + } + + pub fn with_prior(mut self, prior: Gaussian) -> Self { + self.prior = Some(prior); + self + } +} + +/// Convenience: a member is a user key with default weight 1.0 and no prior. +impl From for Member { + fn from(key: K) -> Self { + Self::new(key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Outcome; + + #[test] + fn member_new_has_unit_weight_no_prior() { + let m = Member::new("alice"); + assert_eq!(m.key, "alice"); + assert_eq!(m.weight, 1.0); + assert!(m.prior.is_none()); + } + + #[test] + fn member_builder_methods_chain() { + let m = Member::new("alice") + .with_weight(0.5) + .with_prior(Gaussian::from_ms(20.0, 5.0)); + assert_eq!(m.weight, 0.5); + assert!(m.prior.is_some()); + } + + #[test] + fn member_from_key() { + let m: Member<&str> = "bob".into(); + assert_eq!(m.key, "bob"); + assert_eq!(m.weight, 1.0); + } + + #[test] + fn team_with_members_collects() { + let t: Team<&str> = Team::with_members([Member::new("a"), Member::new("b")]); + assert_eq!(t.members.len(), 2); + } + + #[test] + fn event_construction() { + use smallvec::smallvec; + let e: Event = Event { + time: 1, + teams: smallvec![ + Team::with_members([Member::new("a")]), + Team::with_members([Member::new("b")]), + ], + outcome: Outcome::winner(0, 2), + }; + assert_eq!(e.teams.len(), 2); + assert_eq!(e.time, 1); + } +} diff --git a/src/event_builder.rs b/src/event_builder.rs new file mode 100644 index 0000000..d415e16 --- /dev/null +++ b/src/event_builder.rs @@ -0,0 +1,94 @@ +use smallvec::SmallVec; + +use crate::{ + InferenceError, Outcome, + drift::Drift, + event::{Event, Member, Team}, + history::History, + observer::Observer, + time::Time, +}; + +pub struct EventBuilder<'h, T, D, O, K> +where + T: Time, + D: Drift, + O: Observer, + K: Eq + std::hash::Hash + Clone, +{ + history: &'h mut History, + event: Event, + current_team_idx: Option, +} + +impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K> +where + T: Time, + D: Drift, + O: Observer, + K: Eq + std::hash::Hash + Clone, +{ + pub(crate) fn new(history: &'h mut History, time: T) -> Self { + Self { + history, + event: Event { + time, + teams: SmallVec::new(), + outcome: Outcome::Ranked(SmallVec::new()), + }, + current_team_idx: None, + } + } + + /// Add a team by its member keys (weight 1.0 each, no prior overrides). + pub fn team>(mut self, keys: I) -> Self { + let members: SmallVec<[Member; 4]> = keys.into_iter().map(Member::new).collect(); + self.event.teams.push(Team { members }); + self.current_team_idx = Some(self.event.teams.len() - 1); + self + } + + /// Set per-member weights for the most recently added team. + /// + /// Panics in debug builds if called before `.team(...)` or if the length + /// doesn't match the team's member count. + pub fn weights>(mut self, weights: I) -> Self { + let idx = self + .current_team_idx + .expect(".weights(...) called before any .team(...)"); + let ws: Vec = weights.into_iter().collect(); + let team = &mut self.event.teams[idx]; + debug_assert_eq!( + ws.len(), + team.members.len(), + "weights length must match team size" + ); + for (m, w) in team.members.iter_mut().zip(ws) { + m.weight = w; + } + self + } + + /// Set explicit ranks per team (length must equal number of teams). + pub fn ranking>(mut self, ranks: I) -> Self { + self.event.outcome = Outcome::ranking(ranks); + 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); + self + } + + /// All teams tied. + pub fn draw(mut self) -> Self { + self.event.outcome = Outcome::draw(self.event.teams.len() as u32); + self + } + + /// Commit the event to the history. + pub fn commit(self) -> Result<(), InferenceError> { + self.history.add_events(std::iter::once(self.event)) + } +} diff --git a/src/factor/mod.rs b/src/factor/mod.rs new file mode 100644 index 0000000..da72dbd --- /dev/null +++ b/src/factor/mod.rs @@ -0,0 +1,148 @@ +//! Factor graph machinery for within-game inference. + +use crate::gaussian::Gaussian; + +/// Identifier for a variable in a `VarStore`. +/// +/// Variables hold the current Gaussian marginal and are owned by exactly one +/// `VarStore`. `VarId` is meaningful only within its owning store. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct VarId(pub u32); + +/// Flat storage of variable marginals. +/// +/// Variables are allocated by `alloc()` and accessed by `VarId`. The store is +/// reused across `Game::ranked_with_arena` calls (it lives in the `ScratchArena`); call +/// `clear()` before reuse. +#[derive(Debug, Default)] +pub struct VarStore { + pub(crate) marginals: Vec, +} + +impl VarStore { + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.marginals.clear(); + } + + pub fn len(&self) -> usize { + self.marginals.len() + } + + pub fn is_empty(&self) -> bool { + self.marginals.is_empty() + } + + pub fn alloc(&mut self, init: Gaussian) -> VarId { + let id = VarId(self.marginals.len() as u32); + self.marginals.push(init); + id + } + + pub fn get(&self, id: VarId) -> Gaussian { + self.marginals[id.0 as usize] + } + + pub fn set(&mut self, id: VarId, g: Gaussian) { + self.marginals[id.0 as usize] = g; + } +} + +/// A factor in the EP graph. +/// +/// Factors hold their own outgoing messages and propagate them by reading +/// connected variable marginals from a `VarStore` and writing back updated +/// marginals. +pub trait Factor { + /// Update outgoing messages and write back to the var store. + /// + /// Returns the max delta `(|Δmu|, |Δsigma|)` across writes this + /// propagation. Used by the `Schedule` to detect convergence. + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64); + + /// Optional log-evidence contribution. Default 0.0 (no contribution). + fn log_evidence(&self, _vars: &VarStore) -> f64 { + 0.0 + } +} + +/// Enum dispatcher for the built-in factor types. +/// +/// Using an enum instead of `Box` keeps factor data inline and +/// avoids virtual-call overhead in the hot inference loop. +#[derive(Debug)] +pub enum BuiltinFactor { + TeamSum(team_sum::TeamSumFactor), + RankDiff(rank_diff::RankDiffFactor), + Trunc(trunc::TruncFactor), +} + +impl Factor for BuiltinFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + match self { + Self::TeamSum(f) => f.propagate(vars), + Self::RankDiff(f) => f.propagate(vars), + Self::Trunc(f) => f.propagate(vars), + } + } + + fn log_evidence(&self, vars: &VarStore) -> f64 { + match self { + Self::Trunc(f) => f.log_evidence(vars), + _ => 0.0, + } + } +} + +pub mod rank_diff; +pub mod team_sum; +pub mod trunc; + +#[cfg(test)] +mod tests { + use super::*; + use crate::N_INF; + + #[test] + fn alloc_assigns_sequential_ids() { + let mut store = VarStore::new(); + let a = store.alloc(N_INF); + let b = store.alloc(N_INF); + let c = store.alloc(N_INF); + assert_eq!(a, VarId(0)); + assert_eq!(b, VarId(1)); + assert_eq!(c, VarId(2)); + assert_eq!(store.len(), 3); + } + + #[test] + fn get_returns_initial_value() { + let mut store = VarStore::new(); + let g = Gaussian::from_ms(2.5, 1.0); + let id = store.alloc(g); + assert_eq!(store.get(id), g); + } + + #[test] + fn set_updates_value() { + let mut store = VarStore::new(); + let id = store.alloc(N_INF); + let new = Gaussian::from_ms(3.0, 0.5); + store.set(id, new); + assert_eq!(store.get(id), new); + } + + #[test] + fn clear_resets_length_keeping_capacity() { + let mut store = VarStore::new(); + store.alloc(N_INF); + store.alloc(N_INF); + let cap = store.marginals.capacity(); + store.clear(); + assert_eq!(store.len(), 0); + assert_eq!(store.marginals.capacity(), cap); + } +} diff --git a/src/factor/rank_diff.rs b/src/factor/rank_diff.rs new file mode 100644 index 0000000..ce64a95 --- /dev/null +++ b/src/factor/rank_diff.rs @@ -0,0 +1,95 @@ +use crate::factor::{Factor, VarId, VarStore}; + +/// Maintains the constraint `diff = team_a - team_b` between three vars. +/// +/// On each propagation: +/// - Reads marginals at `team_a` and `team_b` (which already incorporate any +/// incoming messages from neighboring factors). +/// - Computes `new_diff = team_a - team_b` (variance addition; see Gaussian::Sub). +/// - Writes the new marginal to `diff`. +/// - Returns the delta against the previous diff value. +/// +/// This factor does NOT store an outgoing message; the diff variable is +/// effectively replaced on each propagation. The TruncFactor on the same diff +/// var holds the EP-divide message that produces the cavity. +#[derive(Debug)] +pub struct RankDiffFactor { + pub team_a: VarId, + pub team_b: VarId, + pub diff: VarId, +} + +impl Factor for RankDiffFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + let a = vars.get(self.team_a); + let b = vars.get(self.team_b); + let new_diff = a - b; + let old = vars.get(self.diff); + vars.set(self.diff, new_diff); + old.delta(new_diff) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{N_INF, gaussian::Gaussian}; + + #[test] + fn diff_of_two_known_gaussians() { + let mut vars = VarStore::new(); + let team_a = vars.alloc(Gaussian::from_ms(25.0, 3.0)); + let team_b = vars.alloc(Gaussian::from_ms(20.0, 4.0)); + let diff = vars.alloc(N_INF); + + let mut f = RankDiffFactor { + team_a, + team_b, + diff, + }; + f.propagate(&mut vars); + + let result = vars.get(diff); + // mu = 25 - 20 = 5; var = 9 + 16 = 25; sigma = 5 + assert!((result.mu() - 5.0).abs() < 1e-12); + assert!((result.sigma() - 5.0).abs() < 1e-12); + } + + #[test] + fn delta_zero_on_repeat() { + let mut vars = VarStore::new(); + let team_a = vars.alloc(Gaussian::from_ms(10.0, 2.0)); + let team_b = vars.alloc(Gaussian::from_ms(8.0, 1.0)); + let diff = vars.alloc(N_INF); + + let mut f = RankDiffFactor { + team_a, + team_b, + diff, + }; + f.propagate(&mut vars); + let (dmu, dsig) = f.propagate(&mut vars); + assert!(dmu < 1e-12); + assert!(dsig < 1e-12); + } + + #[test] + fn delta_reflects_team_change() { + let mut vars = VarStore::new(); + let team_a = vars.alloc(Gaussian::from_ms(10.0, 1.0)); + let team_b = vars.alloc(Gaussian::from_ms(0.0, 1.0)); + let diff = vars.alloc(N_INF); + + let mut f = RankDiffFactor { + team_a, + team_b, + diff, + }; + f.propagate(&mut vars); + + // change team_a, repropagate; delta should be positive + vars.set(team_a, Gaussian::from_ms(15.0, 1.0)); + let (dmu, _dsig) = f.propagate(&mut vars); + assert!(dmu > 4.0, "expected ~5 delta, got {}", dmu); + } +} diff --git a/src/factor/team_sum.rs b/src/factor/team_sum.rs new file mode 100644 index 0000000..a110141 --- /dev/null +++ b/src/factor/team_sum.rs @@ -0,0 +1,98 @@ +use crate::{ + N00, + factor::{Factor, VarId, VarStore}, + gaussian::Gaussian, +}; + +/// Computes the weighted sum of player performances into a team-perf var. +/// +/// Inputs are pre-computed player performance Gaussians (i.e., rating priors +/// already with beta² noise added via `Rating::performance()`). The factor +/// runs once per game and writes the weighted sum to the output var. +#[derive(Debug)] +pub struct TeamSumFactor { + pub inputs: Vec<(Gaussian, f64)>, + pub out: VarId, +} + +impl Factor for TeamSumFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { + let perf = self.inputs.iter().fold(N00, |acc, (g, w)| acc + (*g * *w)); + let old = vars.get(self.out); + vars.set(self.out, perf); + old.delta(perf) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::N_INF; + + #[test] + fn single_player_unit_weight() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g = Gaussian::from_ms(25.0, 5.0); + let mut f = TeamSumFactor { + inputs: vec![(g, 1.0)], + out, + }; + + f.propagate(&mut vars); + let result = vars.get(out); + assert!((result.mu() - 25.0).abs() < 1e-12); + assert!((result.sigma() - 5.0).abs() < 1e-12); + } + + #[test] + fn two_players_summed() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g1 = Gaussian::from_ms(20.0, 3.0); + let g2 = Gaussian::from_ms(30.0, 4.0); + let mut f = TeamSumFactor { + inputs: vec![(g1, 1.0), (g2, 1.0)], + out, + }; + + f.propagate(&mut vars); + let result = vars.get(out); + // sum: mu = 20 + 30 = 50, var = 9 + 16 = 25, sigma = 5 + assert!((result.mu() - 50.0).abs() < 1e-12); + assert!((result.sigma() - 5.0).abs() < 1e-12); + } + + #[test] + fn weighted_inputs() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g = Gaussian::from_ms(10.0, 2.0); + let mut f = TeamSumFactor { + inputs: vec![(g, 2.0)], + out, + }; + + f.propagate(&mut vars); + let result = vars.get(out); + // g * 2.0: mu = 10*2 = 20, sigma = 2*2 = 4 + assert!((result.mu() - 20.0).abs() < 1e-12); + assert!((result.sigma() - 4.0).abs() < 1e-12); + } + + #[test] + fn delta_is_zero_on_repeat_propagate() { + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let g = Gaussian::from_ms(5.0, 1.0); + let mut f = TeamSumFactor { + inputs: vec![(g, 1.0)], + out, + }; + + f.propagate(&mut vars); + let (dmu, dsig) = f.propagate(&mut vars); + assert!(dmu < 1e-12, "expected ~0 delta on repeat, got {}", dmu); + assert!(dsig < 1e-12); + } +} diff --git a/src/factor/trunc.rs b/src/factor/trunc.rs new file mode 100644 index 0000000..6090a39 --- /dev/null +++ b/src/factor/trunc.rs @@ -0,0 +1,130 @@ +use crate::{ + N_INF, approx, cdf, + factor::{Factor, VarId, VarStore}, + gaussian::Gaussian, +}; + +/// EP truncation factor on a diff variable. +/// +/// Implements the rectified-Gaussian approximation that turns a diff +/// distribution into a "this team rank-beats that team" or "tied" likelihood. +/// Stores its outgoing message to the diff variable so the cavity computation +/// produces the correct EP message on each propagation. +#[derive(Debug)] +pub struct TruncFactor { + pub diff: VarId, + pub margin: f64, + pub tie: bool, + /// Outgoing message to the diff variable (initial: N_INF, the EP identity). + pub(crate) msg: Gaussian, + /// Cached evidence (linear, not log) computed from the cavity on first propagation. + pub(crate) evidence_cached: Option, +} + +impl TruncFactor { + pub fn new(diff: VarId, margin: f64, tie: bool) -> Self { + Self { + diff, + margin, + tie, + msg: N_INF, + evidence_cached: None, + } + } +} + +impl Factor for TruncFactor { + fn propagate(&mut self, vars: &mut VarStore) -> (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 old_msg = self.msg; + self.msg = new_msg; + + // Update the marginal: marginal_new = cavity * new_msg = trunc. + vars.set(self.diff, trunc); + + old_msg.delta(new_msg) + } + + fn log_evidence(&self, _vars: &VarStore) -> f64 { + self.evidence_cached.unwrap_or(1.0).ln() + } +} + +/// P(diff > margin) for non-tie, P(|diff| < margin) for tie. +fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 { + if tie { + cdf(margin, diff.mu(), diff.sigma()) - cdf(-margin, diff.mu(), diff.sigma()) + } else { + 1.0 - cdf(margin, diff.mu(), diff.sigma()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::factor::VarStore; + + #[test] + fn idempotent_after_convergence() { + // After enough iterations, propagate should return ~0 delta. + let mut vars = VarStore::new(); + let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0)); + + let mut f = TruncFactor::new(diff, 0.0, false); + + // Propagate many times; delta should drop toward 0. + let mut last = (f64::INFINITY, f64::INFINITY); + for _ in 0..20 { + last = f.propagate(&mut vars); + } + assert!(last.0 < 1e-10, "expected converged delta, got {}", last.0); + assert!(last.1 < 1e-10); + } + + #[test] + fn evidence_cached_on_first_propagate() { + let mut vars = VarStore::new(); + let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0)); + + let mut f = TruncFactor::new(diff, 0.0, false); + assert!(f.evidence_cached.is_none()); + + f.propagate(&mut vars); + assert!(f.evidence_cached.is_some()); + let first = f.evidence_cached.unwrap(); + + // Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748 + assert!(first > 0.7); + assert!(first < 0.8); + + // Subsequent propagations don't change it. + f.propagate(&mut vars); + assert_eq!(f.evidence_cached.unwrap(), first); + } + + #[test] + fn tie_evidence_uses_two_sided() { + let mut vars = VarStore::new(); + let diff = vars.alloc(Gaussian::from_ms(0.0, 2.0)); + + let mut f = TruncFactor::new(diff, 1.0, true); + f.propagate(&mut vars); + + // For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383 + let ev = f.evidence_cached.unwrap(); + assert!(ev > 0.35 && ev < 0.42); + } +} diff --git a/src/factors.rs b/src/factors.rs new file mode 100644 index 0000000..162ca68 --- /dev/null +++ b/src/factors.rs @@ -0,0 +1,13 @@ +//! Factor-graph public API. +//! +//! Power users can construct custom factor graphs via `Game::custom` (T2 +//! minimal; full ergonomics in T4) and drive them with custom `Schedule` +//! implementations. + +pub use crate::{ + factor::{ + BuiltinFactor, Factor, VarId, VarStore, rank_diff::RankDiffFactor, team_sum::TeamSumFactor, + trunc::TruncFactor, + }, + schedule::{EpsilonOrMax, Schedule, ScheduleReport}, +}; diff --git a/src/game.rs b/src/game.rs index c82bdf1..16be834 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,16 +1,85 @@ +use std::cmp::Ordering; + use crate::{ - N_INF, N00, approx, compute_margin, + N_INF, N00, + arena::ScratchArena, + compute_margin, drift::Drift, - evidence, + factor::{Factor, trunc::TruncFactor}, gaussian::Gaussian, - message::{DiffMessage, TeamMessage}, - player::Player, - sort_perm, tuple_gt, tuple_max, + rating::Rating, + time::Time, + tuple_gt, tuple_max, }; +#[derive(Clone, Copy, Debug)] +pub struct GameOptions { + pub p_draw: f64, + pub convergence: crate::ConvergenceOptions, +} + +impl Default for GameOptions { + fn default() -> Self { + Self { + p_draw: crate::P_DRAW, + convergence: crate::ConvergenceOptions::default(), + } + } +} + +/// 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` owns its inputs so it can +/// be returned freely from public constructors. #[derive(Debug)] -pub struct Game<'a, D: Drift> { - teams: Vec>>, +#[allow(dead_code)] +pub struct OwnedGame> { + teams: Vec>>, + result: Vec, + weights: Vec>, + p_draw: f64, + pub(crate) likelihoods: Vec>, + pub(crate) evidence: f64, +} + +impl> OwnedGame { + pub(crate) fn new( + teams: Vec>>, + result: Vec, + weights: Vec>, + p_draw: f64, + ) -> 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; + Self { + teams, + result, + weights, + p_draw, + likelihoods, + evidence, + } + } + + pub fn posteriors(&self) -> Vec> { + self.likelihoods + .iter() + .zip(self.teams.iter()) + .map(|(l, t)| l.iter().zip(t.iter()).map(|(&l, r)| l * r.prior).collect()) + .collect() + } + + pub fn log_evidence(&self) -> f64 { + self.evidence.ln() + } +} + +#[derive(Debug)] +pub struct Game<'a, T: Time = i64, D: Drift = crate::drift::ConstantDrift> { + teams: Vec>>, result: &'a [f64], weights: &'a [Vec], p_draw: f64, @@ -18,18 +87,18 @@ pub struct Game<'a, D: Drift> { pub(crate) evidence: f64, } -impl<'a, D: Drift> Game<'a, D> { - pub fn new( - teams: Vec>>, +impl<'a, T: Time, D: Drift> Game<'a, T, D> { + pub(crate) fn ranked_with_arena( + teams: Vec>>, result: &'a [f64], weights: &'a [Vec], p_draw: f64, + arena: &mut ScratchArena, ) -> Self { debug_assert!( - (result.len() == teams.len()), + result.len() == teams.len(), "result must have the same length as teams" ); - debug_assert!( weights .iter() @@ -37,19 +106,17 @@ impl<'a, D: Drift> Game<'a, D> { .all(|(w, t)| w.len() == t.len()), "weights must have the same dimensions as teams" ); - debug_assert!( (0.0..1.0).contains(&p_draw), - "draw probability.must be >= 0.0 and < 1.0" + "draw probability must be >= 0.0 and < 1.0" ); - debug_assert!( p_draw > 0.0 || { let mut r = result.to_vec(); r.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap()); r.windows(2).all(|w| w[0] != w[1]) }, - "draw must be > 0.0 if there is teams with draw" + "draw must be > 0.0 if there are teams with draw" ); let mut this = Self { @@ -61,124 +128,144 @@ impl<'a, D: Drift> Game<'a, D> { evidence: 0.0, }; - this.likelihoods(); - + this.likelihoods(arena); this } - fn likelihoods(&mut self) { - let o = sort_perm(self.result, true); + fn likelihoods(&mut self, arena: &mut ScratchArena) { + arena.reset(); - let mut team = o - .iter() - .map(|&e| { - let performance = self.teams[e] - .iter() - .zip(self.weights[e].iter()) - .fold(N00, |p, (player, &weight)| { - p + (player.performance() * weight) - }); + let n_teams = self.teams.len(); - TeamMessage { - prior: performance, - ..Default::default() - } - }) - .collect::>(); + // 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] + .partial_cmp(&self.result[i]) + .unwrap_or(Ordering::Equal) + }); - let mut diff = team - .windows(2) - .map(|w| DiffMessage { - prior: w[0].prior - w[1].prior, - likelihood: N_INF, - }) - .collect::>(); + // 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() + .zip(self.weights[t].iter()) + .fold(N00, |p, (player, &w)| p + (player.performance() * w)) + })); - let tie = o - .windows(2) - .map(|e| self.result[e[0]] == self.result[e[1]]) - .collect::>(); - - let margin = if self.p_draw == 0.0 { - vec![0.0; o.len() - 1] - } else { - o.windows(2) - .map(|w| { - let a: f64 = self.teams[w[0]].iter().map(|a| a.beta.powi(2)).sum(); - let b: f64 = self.teams[w[1]].iter().map(|a| a.beta.powi(2)).sum(); + let n_diffs = n_teams.saturating_sub(1); + // One TruncFactor per adjacent sorted-team pair; each owns a diff VarId. + // trunc stays local (fresh state per game; Vec capacity is typically small). + let mut trunc: Vec = (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()) - }) - .collect::>() - }; + }; + let vid = arena.vars.alloc(N_INF); + TruncFactor::new(vid, margin, tie) + }) + .collect(); - self.evidence = 1.0; + // 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 { - step = (0.0, 0.0); + step = (0.0_f64, 0.0_f64); - for e in 0..diff.len() - 1 { - diff[e].prior = team[e].posterior_win() - team[e + 1].posterior_lose(); + // Forward sweep: diffs 0 .. n_diffs-2 (all but the last). + for (e, tf) in trunc[..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(tf.diff, raw * tf.msg); + let d = tf.propagate(&mut arena.vars); + step = tuple_max(step, d); - if iter == 0 { - self.evidence *= evidence(&diff, &margin, &tie, e); - } - - diff[e].likelihood = approx(diff[e].prior, margin[e], tie[e]) / diff[e].prior; - let likelihood_lose = team[e].posterior_win() - diff[e].likelihood; - step = tuple_max(step, team[e + 1].likelihood_lose.delta(likelihood_lose)); - team[e + 1].likelihood_lose = likelihood_lose; + let new_ll = pw - tf.msg; + step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll)); + arena.lhood_lose[e + 1] = new_ll; } - for e in (1..diff.len()).rev() { - diff[e].prior = team[e].posterior_win() - team[e + 1].posterior_lose(); + // Backward sweep: diffs n_diffs-1 .. 1 (reverse, all but the first). + for (rev_i, tf) in trunc[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(tf.diff, raw * tf.msg); + let d = tf.propagate(&mut arena.vars); + step = tuple_max(step, d); - if iter == 0 && e == diff.len() - 1 { - self.evidence *= evidence(&diff, &margin, &tie, e); - } - - diff[e].likelihood = approx(diff[e].prior, margin[e], tie[e]) / diff[e].prior; - let likelihood_win = team[e + 1].posterior_lose() + diff[e].likelihood; - step = tuple_max(step, team[e].likelihood_win.delta(likelihood_win)); - team[e].likelihood_win = likelihood_win; + let new_lw = pl + tf.msg; + step = tuple_max(step, arena.lhood_win[e].delta(new_lw)); + arena.lhood_win[e] = new_lw; } iter += 1; } - if diff.len() == 1 { - self.evidence = evidence(&diff, &margin, &tie, 0); - - diff[0].prior = team[0].posterior_win() - team[1].posterior_lose(); - diff[0].likelihood = approx(diff[0].prior, margin[0], tie[0]) / diff[0].prior; + // Special case: exactly 1 diff (2-team game); loop body was empty. + 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(trunc[0].diff, raw * trunc[0].msg); + trunc[0].propagate(&mut arena.vars); } - let t_end = team.len() - 1; - let d_end = diff.len() - 1; + // Boundary updates: close the chain at both ends. + if n_diffs > 0 { + let pl1 = arena.team_prior[1] * arena.lhood_win[1]; + arena.lhood_win[0] = pl1 + trunc[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 - trunc[n_diffs - 1].msg; + } - team[0].likelihood_win = team[1].posterior_lose() + diff[0].likelihood; - team[t_end].likelihood_lose = team[t_end - 1].posterior_win() - diff[d_end].likelihood; + // Evidence = product of per-diff evidences (each cached on first propagation). + self.evidence = trunc + .iter() + .map(|t| t.evidence_cached.unwrap_or(1.0)) + .product(); - let m_t_ft = o.into_iter().map(|e| team[e].likelihood()); + // Inverse permutation: inv_buf[orig_i] = sorted_i. + 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()) - .zip(m_t_ft) - .map(|((p, w), m)| { - let performance = p.iter().zip(w.iter()).fold(N00, |p, (player, &weight)| { - p + (player.performance() * weight) - }); - - p.iter() - .zip(w.iter()) - .map(|(p, &w)| { - ((m - performance.exclude(p.performance() * w)) * (1.0 / w)) - .forget(p.beta.powi(2)) + .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::>() }) @@ -197,6 +284,68 @@ impl<'a, D: Drift> Game<'a, D> { }) .collect::>() } + + pub fn log_evidence(&self) -> f64 { + self.evidence.ln() + } +} + +impl> Game<'_, T, D> { + pub fn ranked( + teams: &[&[Rating]], + outcome: crate::Outcome, + options: &GameOptions, + ) -> Result, crate::InferenceError> { + if !(0.0..1.0).contains(&options.p_draw) { + return Err(crate::InferenceError::InvalidProbability { + value: options.p_draw, + }); + } + if outcome.team_count() != teams.len() { + return Err(crate::InferenceError::MismatchedShape { + kind: "outcome ranks vs teams", + expected: teams.len(), + got: outcome.team_count(), + }); + } + + let ranks = outcome.as_ranks(); + let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64; + let result: Vec = ranks.iter().map(|&r| max_rank - r as f64).collect(); + let teams_owned: Vec>> = teams.iter().map(|t| t.to_vec()).collect(); + let weights: Vec> = teams.iter().map(|t| vec![1.0; t.len()]).collect(); + + Ok(OwnedGame::new(teams_owned, result, weights, options.p_draw)) + } + + pub fn one_v_one( + a: &Rating, + b: &Rating, + outcome: crate::Outcome, + ) -> Result<(Gaussian, Gaussian), crate::InferenceError> { + let game = Self::ranked(&[&[*a], &[*b]], outcome, &GameOptions::default())?; + let post = game.posteriors(); + Ok((post[0][0], post[1][0])) + } + + pub fn free_for_all( + players: &[&Rating], + outcome: crate::Outcome, + options: &GameOptions, + ) -> Result, crate::InferenceError> { + let teams: Vec>> = players.iter().map(|p| vec![**p]).collect(); + let team_refs: Vec<&[Rating]> = teams.iter().map(|t| t.as_slice()).collect(); + Self::ranked(&team_refs, outcome, options) + } + + #[doc(hidden)] + pub fn custom( + factors: &mut [crate::factors::BuiltinFactor], + vars: &mut crate::factors::VarStore, + schedule: &S, + ) -> crate::factors::ScheduleReport { + schedule.run(factors, vars) + } } #[cfg(test)] @@ -204,23 +353,31 @@ mod tests { use ::approx::assert_ulps_eq; use super::*; - use crate::{ConstantDrift, GAMMA, Gaussian, N_INF, Player}; + use crate::{ConstantDrift, GAMMA, Gaussian, N_INF, Rating, arena::ScratchArena}; + + type R = Rating; #[test] fn test_1vs1() { - let t_a = Player::new( + let t_a = R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); - let t_b = Player::new( + let t_b = R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let w = [vec![1.0], vec![1.0]]; - let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 1.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![vec![t_a], vec![t_b]], + &[0.0, 1.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); let a = p[0][0]; @@ -229,19 +386,25 @@ mod tests { assert_ulps_eq!(a, Gaussian::from_ms(20.794779, 7.194481), epsilon = 1e-6); assert_ulps_eq!(b, Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6); - let t_a = Player::new( + let t_a = R::new( Gaussian::from_ms(29.0, 1.0), 25.0 / 6.0, ConstantDrift(GAMMA), ); - let t_b = Player::new( + let t_b = R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(GAMMA), ); let w = [vec![1.0], vec![1.0]]; - let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 1.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![vec![t_a], vec![t_b]], + &[0.0, 1.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); let a = p[0][0]; @@ -250,11 +413,17 @@ mod tests { assert_ulps_eq!(a, Gaussian::from_ms(28.896475, 0.996604), epsilon = 1e-6); assert_ulps_eq!(b, Gaussian::from_ms(32.189211, 6.062063), epsilon = 1e-6); - let t_a = Player::new(Gaussian::from_ms(1.139, 0.531), 1.0, ConstantDrift(0.2125)); - let t_b = Player::new(Gaussian::from_ms(15.568, 0.51), 1.0, ConstantDrift(0.2125)); + let t_a = R::new(Gaussian::from_ms(1.139, 0.531), 1.0, ConstantDrift(0.2125)); + let t_b = R::new(Gaussian::from_ms(15.568, 0.51), 1.0, ConstantDrift(0.2125)); let w = [vec![1.0], vec![1.0]]; - let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 1.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![vec![t_a], vec![t_b]], + &[0.0, 1.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); assert_eq!(g.likelihoods[0][0], N_INF); assert_eq!(g.likelihoods[1][0], N_INF); @@ -263,17 +432,17 @@ mod tests { #[test] fn test_1vs1vs1() { let teams = vec![ - vec![Player::new( + vec![R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), )], - vec![Player::new( + vec![R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), )], - vec![Player::new( + vec![R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), @@ -281,7 +450,13 @@ mod tests { ]; let w = [vec![1.0], vec![1.0], vec![1.0]]; - let g = Game::new(teams.clone(), &[1.0, 2.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + teams.clone(), + &[1.0, 2.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); let a = p[0][0]; @@ -291,7 +466,13 @@ mod tests { assert_ulps_eq!(b, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6); let w = [vec![1.0], vec![1.0], vec![1.0]]; - let g = Game::new(teams.clone(), &[2.0, 1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + teams.clone(), + &[2.0, 1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); let a = p[0][0]; @@ -301,33 +482,40 @@ 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::new(teams, &[1.0, 2.0, 0.0], &w, 0.5); + let g = Game::ranked_with_arena(teams, &[1.0, 2.0, 0.0], &w, 0.5, &mut ScratchArena::new()); let p = g.posteriors(); let a = p[0][0]; let b = p[1][0]; let c = p[2][0]; - assert_ulps_eq!(a, Gaussian::from_ms(24.999999, 6.092561), epsilon = 1e-6); + // T1 ULP shift: mu rounds to 25.0 (was 24.999999) under natural-parameter storage. + assert_ulps_eq!(a, Gaussian::from_ms(25.0, 6.092561), epsilon = 1e-6); assert_ulps_eq!(b, Gaussian::from_ms(33.379314, 6.483575), epsilon = 1e-6); assert_ulps_eq!(c, Gaussian::from_ms(16.620685, 6.483575), epsilon = 1e-6); } #[test] fn test_1vs1_draw() { - let t_a = Player::new( + let t_a = R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); - let t_b = Player::new( + let t_b = R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let w = [vec![1.0], vec![1.0]]; - let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 0.0], &w, 0.25); + let g = Game::ranked_with_arena( + vec![vec![t_a], vec![t_b]], + &[0.0, 0.0], + &w, + 0.25, + &mut ScratchArena::new(), + ); let p = g.posteriors(); let a = p[0][0]; @@ -336,19 +524,25 @@ mod tests { 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); - let t_a = Player::new( + let t_a = R::new( Gaussian::from_ms(25.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); - let t_b = Player::new( + let t_b = R::new( Gaussian::from_ms(29.0, 2.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let w = [vec![1.0], vec![1.0]]; - let g = Game::new(vec![vec![t_a], vec![t_b]], &[0.0, 0.0], &w, 0.25); + let g = Game::ranked_with_arena( + vec![vec![t_a], vec![t_b]], + &[0.0, 0.0], + &w, + 0.25, + &mut ScratchArena::new(), + ); let p = g.posteriors(); let a = p[0][0]; @@ -360,28 +554,29 @@ mod tests { #[test] fn test_1vs1vs1_draw() { - let t_a = Player::new( + let t_a = R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); - let t_b = Player::new( + let t_b = R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); - let t_c = Player::new( + let t_c = R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let w = [vec![1.0], vec![1.0], vec![1.0]]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![vec![t_a], vec![t_b], vec![t_c]], &[0.0, 0.0, 0.0], &w, 0.25, + &mut ScratchArena::new(), ); let p = g.posteriors(); @@ -389,32 +584,35 @@ mod tests { let b = p[1][0]; let c = p[2][0]; - assert_ulps_eq!(a, Gaussian::from_ms(24.999999, 5.729068), epsilon = 1e-6); - assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 5.707423), epsilon = 1e-6); - assert_ulps_eq!(c, Gaussian::from_ms(24.999999, 5.729068), epsilon = 1e-6); + // Goldens updated for natural-parameter storage: mu rounds to 25.0 (was 24.999999), + // sigma shifts by ~3e-7 ULPs (within 1e-6 of original). Both bounded differences. + assert_ulps_eq!(a, Gaussian::from_ms(25.0, 5.729069), epsilon = 1e-6); + assert_ulps_eq!(b, Gaussian::from_ms(25.0, 5.707424), epsilon = 1e-6); + assert_ulps_eq!(c, Gaussian::from_ms(25.0, 5.729069), epsilon = 1e-6); - let t_a = Player::new( + let t_a = R::new( Gaussian::from_ms(25.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); - let t_b = Player::new( + let t_b = R::new( Gaussian::from_ms(25.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); - let t_c = Player::new( + let t_c = R::new( Gaussian::from_ms(29.0, 2.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ); let w = [vec![1.0], vec![1.0], vec![1.0]]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![vec![t_a], vec![t_b], vec![t_c]], &[0.0, 0.0, 0.0], &w, 0.25, + &mut ScratchArena::new(), ); let p = g.posteriors(); @@ -430,29 +628,29 @@ mod tests { #[test] fn test_2vs1vs2_mixed() { let t_a = vec![ - Player::new( + R::new( Gaussian::from_ms(12.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ), - Player::new( + R::new( Gaussian::from_ms(18.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ), ]; - let t_b = vec![Player::new( + let t_b = vec![R::new( Gaussian::from_ms(30.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), )]; let t_c = vec![ - Player::new( + R::new( Gaussian::from_ms(14.0, 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), ), - Player::new( + R::new( Gaussian::from_ms(16., 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), @@ -460,7 +658,13 @@ mod tests { ]; let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]]; - let g = Game::new(vec![t_a, t_b, t_c], &[1.0, 0.0, 0.0], &w, 0.25); + let g = Game::ranked_with_arena( + vec![t_a, t_b, t_c], + &[1.0, 0.0, 0.0], + &w, + 0.25, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!(p[0][0], Gaussian::from_ms(13.051, 2.864), epsilon = 1e-3); @@ -475,19 +679,25 @@ mod tests { let w_a = vec![1.0]; let w_b = vec![2.0]; - let t_a = vec![Player::new( + let t_a = vec![R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), )]; - let t_b = vec![Player::new( + let t_b = vec![R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), )]; let w = [w_a, w_b]; - let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![t_a.clone(), t_b.clone()], + &[1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!( @@ -505,7 +715,13 @@ mod tests { let w_b = vec![0.7]; let w = [w_a, w_b]; - let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![t_a.clone(), t_b.clone()], + &[1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!( @@ -523,7 +739,13 @@ mod tests { let w_b = vec![0.7]; let w = [w_a, w_b]; - let g = Game::new(vec![t_a, t_b], &[1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![t_a, t_b], + &[1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!( @@ -540,19 +762,17 @@ mod tests { let w_a = vec![1.0]; let w_b = vec![0.0]; - let t_a = vec![Player::new( - Gaussian::from_ms(2.0, 6.0), - 1.0, - ConstantDrift(0.0), - )]; - let t_b = vec![Player::new( - Gaussian::from_ms(2.0, 6.0), - 1.0, - ConstantDrift(0.0), - )]; + let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))]; + let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))]; let w = [w_a, w_b]; - let g = Game::new(vec![t_a, t_b], &[1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![t_a, t_b], + &[1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!( @@ -569,19 +789,17 @@ mod tests { let w_a = vec![1.0]; let w_b = vec![-1.0]; - let t_a = vec![Player::new( - Gaussian::from_ms(2.0, 6.0), - 1.0, - ConstantDrift(0.0), - )]; - let t_b = vec![Player::new( - Gaussian::from_ms(2.0, 6.0), - 1.0, - ConstantDrift(0.0), - )]; + let t_a = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))]; + let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))]; let w = [w_a, w_b]; - let g = Game::new(vec![t_a, t_b], &[1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![t_a, t_b], + &[1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!(p[0][0], p[1][0], epsilon = 1e-6); @@ -590,12 +808,12 @@ mod tests { #[test] fn test_2vs2_weighted() { let t_a = vec![ - Player::new( + R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), ), - Player::new( + R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), @@ -604,12 +822,12 @@ mod tests { let w_a = vec![0.4, 0.8]; let t_b = vec![ - Player::new( + R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), ), - Player::new( + R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), @@ -618,7 +836,13 @@ mod tests { let w_b = vec![0.9, 0.6]; let w = [w_a, w_b]; - let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![t_a.clone(), t_b.clone()], + &[1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!( @@ -646,7 +870,13 @@ mod tests { let w_b = vec![0.7, 0.4]; let w = [w_a, w_b]; - let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![t_a.clone(), t_b.clone()], + &[1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!( @@ -674,7 +904,13 @@ mod tests { let w_b = vec![0.7, 2.4]; let w = [w_a, w_b]; - let g = Game::new(vec![t_a.clone(), t_b.clone()], &[1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![t_a.clone(), t_b.clone()], + &[1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!( @@ -699,10 +935,10 @@ mod tests { ); let w = [vec![1.0, 1.0], vec![1.0]]; - let g = Game::new( + let g = Game::ranked_with_arena( vec![ t_a.clone(), - vec![Player::new( + vec![R::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(0.0), @@ -711,6 +947,7 @@ mod tests { &[1.0, 0.0], &w, 0.0, + &mut ScratchArena::new(), ); let post_2vs1 = g.posteriors(); @@ -718,7 +955,13 @@ mod tests { let w_b = vec![1.0, 0.0]; let w = [w_a, w_b]; - let g = Game::new(vec![t_a, t_b.clone()], &[1.0, 0.0], &w, 0.0); + let g = Game::ranked_with_arena( + vec![t_a, t_b.clone()], + &[1.0, 0.0], + &w, + 0.0, + &mut ScratchArena::new(), + ); let p = g.posteriors(); assert_ulps_eq!(p[0][0], post_2vs1[0][0], epsilon = 1e-6); diff --git a/src/gaussian.rs b/src/gaussian.rs index 8e43099..09873bf 100644 --- a/src/gaussian.rs +++ b/src/gaussian.rs @@ -2,143 +2,159 @@ use std::ops; use crate::{MU, N_INF, SIGMA}; +/// A Gaussian distribution stored in natural parameters. +/// +/// `pi = 1 / sigma^2` (precision) +/// `tau = mu * pi` (precision-adjusted mean) +/// +/// Multiplication and division in message passing become pure adds/subs of +/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and +/// `sigma()` are accessors computed on demand. #[derive(Clone, Copy, PartialEq, Debug)] pub struct Gaussian { - pub mu: f64, - pub sigma: f64, + pi: f64, + tau: f64, } impl Gaussian { + /// Construct from mean and standard deviation. pub const fn from_ms(mu: f64, sigma: f64) -> Self { - Gaussian { mu, sigma } - } - - fn pi(&self) -> f64 { - if self.sigma > 0.0 { - self.sigma.powi(-2) + if sigma == f64::INFINITY { + Self { pi: 0.0, tau: 0.0 } + } else if sigma == 0.0 { + // Point mass at mu. tau = mu * pi = mu * inf. + // For mu == 0 this is 0; for mu != 0 it is inf * mu = inf (IEEE). + // Only N00 (mu=0, sigma=0) is used in practice. + Self { + pi: f64::INFINITY, + tau: if mu == 0.0 { 0.0 } else { f64::INFINITY }, + } } else { - f64::INFINITY + let pi = 1.0 / (sigma * sigma); + Self { pi, tau: mu * pi } } } - fn tau(&self) -> f64 { - if self.sigma > 0.0 { - self.mu * self.pi() + /// Construct directly from natural parameters. + #[inline] + pub(crate) const fn from_natural(pi: f64, tau: f64) -> Self { + Self { pi, tau } + } + + #[inline] + pub fn pi(&self) -> f64 { + self.pi + } + + #[inline] + pub fn tau(&self) -> f64 { + self.tau + } + + #[inline] + pub fn mu(&self) -> f64 { + if self.pi == 0.0 { + 0.0 } else { + self.tau / self.pi + } + } + + #[inline] + pub fn sigma(&self) -> f64 { + if self.pi == 0.0 { f64::INFINITY + } else if self.pi.is_infinite() { + 0.0 + } else { + 1.0 / self.pi.sqrt() } } - pub(crate) fn delta(&self, m: Gaussian) -> (f64, f64) { - ((self.mu - m.mu).abs(), (self.sigma - m.sigma).abs()) + pub(crate) fn delta(&self, other: Gaussian) -> (f64, f64) { + ( + (self.mu() - other.mu()).abs(), + (self.sigma() - other.sigma()).abs(), + ) } - pub(crate) fn exclude(&self, m: Gaussian) -> Self { - Self { - mu: self.mu - m.mu, - sigma: (self.sigma.powi(2) - m.sigma.powi(2)).sqrt(), + pub(crate) fn exclude(&self, other: Gaussian) -> Self { + let var = self.sigma().powi(2) - other.sigma().powi(2); + 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); } + let mu = self.mu() - other.mu(); + Self::from_ms(mu, var.sqrt()) } pub(crate) fn forget(&self, variance_delta: f64) -> Self { - Self { - mu: self.mu, - sigma: (self.sigma.powi(2) + variance_delta).sqrt(), - } + let var = self.sigma().powi(2) + variance_delta; + Self::from_ms(self.mu(), var.sqrt()) } } impl Default for Gaussian { fn default() -> Self { - Self { - mu: MU, - sigma: SIGMA, - } + Self::from_ms(MU, SIGMA) } } impl ops::Add for Gaussian { type Output = 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 { - Gaussian { - mu: self.mu + rhs.mu, - sigma: (self.sigma.powi(2) + rhs.sigma.powi(2)).sqrt(), - } + let mu = self.mu() + rhs.mu(); + let var = self.sigma().powi(2) + rhs.sigma().powi(2); + Self::from_ms(mu, var.sqrt()) } } impl ops::Sub for Gaussian { type Output = Gaussian; - + /// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add. fn sub(self, rhs: Gaussian) -> Self::Output { - Gaussian { - mu: self.mu - rhs.mu, - sigma: (self.sigma.powi(2) + rhs.sigma.powi(2)).sqrt(), - } + let mu = self.mu() - rhs.mu(); + let var = self.sigma().powi(2) + rhs.sigma().powi(2); + Self::from_ms(mu, var.sqrt()) } } impl ops::Mul for Gaussian { type Output = Gaussian; - + /// Factor product: nat-param add. Hot path — two f64 additions, no sqrt. fn mul(self, rhs: Gaussian) -> Self::Output { - let (mu, sigma) = if self.sigma == 0.0 || rhs.sigma == 0.0 { - let mu = self.mu / (self.sigma.powi(2) / rhs.sigma.powi(2) + 1.0) - + rhs.mu / (rhs.sigma.powi(2) / self.sigma.powi(2) + 1.0); - - let sigma = (1.0 / ((1.0 / self.sigma.powi(2)) + (1.0 / rhs.sigma.powi(2)))).sqrt(); - - (mu, sigma) - } else { - mu_sigma(self.tau() + rhs.tau(), self.pi() + rhs.pi()) - }; - - Gaussian { mu, sigma } + Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau) } } impl ops::Mul for Gaussian { type Output = Gaussian; - - fn mul(self, rhs: f64) -> Self::Output { - if rhs.is_finite() { - Self { - mu: self.mu * rhs, - sigma: self.sigma * rhs, - } - } else { - N_INF + fn mul(self, scalar: f64) -> Self::Output { + if !scalar.is_finite() { + return N_INF; } + 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); + } + // sigma' = sigma * |scalar| => pi' = pi / scalar² + // mu' = mu * scalar => tau' = tau / scalar + Self::from_natural(self.pi / (scalar * scalar), self.tau / scalar) } } impl ops::Div for Gaussian { type Output = Gaussian; - + /// Cavity: nat-param sub. Hot path — two f64 subtractions, no sqrt. fn div(self, rhs: Gaussian) -> Self::Output { - let (mu, sigma) = if self.sigma == 0.0 || rhs.sigma == 0.0 { - let mu = self.mu / (1.0 - self.sigma.powi(2) / rhs.sigma.powi(2)) - + rhs.mu / (rhs.sigma.powi(2) / self.sigma.powi(2) - 1.0); - - let sigma = (1.0 / ((1.0 / self.sigma.powi(2)) - (1.0 / rhs.sigma.powi(2)))).sqrt(); - - (mu, sigma) - } else { - mu_sigma(self.tau() - rhs.tau(), self.pi() - rhs.pi()) - }; - - Gaussian { mu, sigma } - } -} - -fn mu_sigma(tau: f64, pi: f64) -> (f64, f64) { - if pi > 0.0 { - (tau / pi, (1.0 / pi).sqrt()) - } else if (pi + 1e-5) < 0.0 { - panic!("precision should be greater than 0"); - } else { - (0.0, f64::INFINITY) + Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau) } } @@ -148,85 +164,71 @@ mod tests { #[test] fn test_add() { - let n = Gaussian { - mu: 25.0, - sigma: 25.0 / 3.0, - }; - - let m = Gaussian { - mu: 0.0, - sigma: 1.0, - }; - - assert_eq!( - n + m, - Gaussian { - mu: 25.0, - sigma: 8.393118874676116 - } - ); + let n = Gaussian::from_ms(25.0, 25.0 / 3.0); + let m = Gaussian::from_ms(0.0, 1.0); + let r = n + m; + assert!((r.mu() - 25.0).abs() < 1e-12); + assert!((r.sigma() - 8.393118874676116).abs() < 1e-10); } #[test] fn test_sub() { - let n = Gaussian { - mu: 25.0, - sigma: 25.0 / 3.0, - }; - - let m = Gaussian { - mu: 1.0, - sigma: 1.0, - }; - - assert_eq!( - n - m, - Gaussian { - mu: 24.0, - sigma: 8.393118874676116 - } - ); + let n = Gaussian::from_ms(25.0, 25.0 / 3.0); + let m = Gaussian::from_ms(1.0, 1.0); + let r = n - m; + assert!((r.mu() - 24.0).abs() < 1e-12); + assert!((r.sigma() - 8.393118874676116).abs() < 1e-10); } #[test] fn test_mul() { - let n = Gaussian { - mu: 25.0, - sigma: 25.0 / 3.0, - }; - - let m = Gaussian { - mu: 0.0, - sigma: 1.0, - }; - - assert_eq!( - n * m, - Gaussian { - mu: 0.35488958990536273, - sigma: 0.992876838486922 - } - ); + let n = Gaussian::from_ms(25.0, 25.0 / 3.0); + let m = Gaussian::from_ms(0.0, 1.0); + let r = n * m; + assert!((r.mu() - 0.35488958990536273).abs() < 1e-10); + assert!((r.sigma() - 0.992876838486922).abs() < 1e-10); } #[test] fn test_div() { - let n = Gaussian { - mu: 25.0, - sigma: 25.0 / 3.0, - }; + let n = Gaussian::from_ms(25.0, 25.0 / 3.0); + let m = Gaussian::from_ms(0.0, 1.0); + let r = m / n; + assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10); + assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10); + } - let m = Gaussian { - mu: 0.0, - sigma: 1.0, - }; + #[test] + fn test_n00_is_add_identity() { + // N00 (sigma=0) is the additive identity for the variance-convolution Add op. + // N_INF (sigma=inf) is the identity for the EP-product Mul op. + let g = Gaussian::from_ms(3.0, 2.0); + let n00 = Gaussian::from_ms(0.0, 0.0); + let r = n00 + g; + assert!((r.mu() - g.mu()).abs() < 1e-12); + assert!((r.sigma() - g.sigma()).abs() < 1e-12); + } - assert_eq!( - m / n, - Gaussian { - mu: -0.3652597402597402, - sigma: 1.0072787050317253 - } - ); + #[test] + fn test_mul_is_factor_product() { + // n * m in nat-params should be pi_n + pi_m, tau_n + tau_m + let n = Gaussian::from_ms(2.0, 3.0); + let m = Gaussian::from_ms(1.0, 2.0); + let r = n * m; + let expected_pi = n.pi() + m.pi(); + let expected_tau = n.tau() + m.tau(); + assert!((r.pi() - expected_pi).abs() < 1e-15); + assert!((r.tau() - expected_tau).abs() < 1e-15); + } + + #[test] + fn test_div_is_cavity() { + let n = Gaussian::from_ms(2.0, 1.0); + let m = Gaussian::from_ms(1.0, 2.0); + let r = n / m; + let expected_pi = n.pi() - m.pi(); + let expected_tau = n.tau() - m.tau(); + assert!((r.pi() - expected_pi).abs() < 1e-15); + assert!((r.tau() - expected_tau).abs() < 1e-15); } } diff --git a/src/history.rs b/src/history.rs index 76a8f24..5191929 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1,32 +1,42 @@ -use std::collections::HashMap; +use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData}; use crate::{ BETA, GAMMA, Index, MU, N_INF, P_DRAW, SIGMA, - agent::{self, Agent}, - batch::{self, Batch}, + competitor::{self, Competitor}, + convergence::{ConvergenceOptions, ConvergenceReport}, drift::{ConstantDrift, Drift}, + error::InferenceError, gaussian::Gaussian, - player::Player, - sort_time, tuple_gt, tuple_max, + key_table::KeyTable, + observer::{NullObserver, Observer}, + rating::Rating, + sort_time, + storage::CompetitorStore, + time::Time, + time_slice::{self, TimeSlice}, + tuple_gt, tuple_max, }; #[derive(Clone)] -pub struct HistoryBuilder { - time: bool, +pub struct HistoryBuilder< + T: Time = i64, + D: Drift = ConstantDrift, + O: Observer = NullObserver, + K: Eq + Hash + Clone = &'static str, +> { mu: f64, sigma: f64, beta: f64, drift: D, p_draw: f64, online: bool, + convergence: ConvergenceOptions, + observer: O, + _time: PhantomData, + _key: PhantomData, } -impl HistoryBuilder { - pub fn time(mut self, time: bool) -> Self { - self.time = time; - self - } - +impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder { pub fn mu(mut self, mu: f64) -> Self { self.mu = mu; self @@ -42,15 +52,18 @@ impl HistoryBuilder { self } - pub fn drift(self, drift: D2) -> HistoryBuilder { + pub fn drift>(self, drift: D2) -> HistoryBuilder { HistoryBuilder { drift, - time: self.time, mu: self.mu, sigma: self.sigma, beta: self.beta, p_draw: self.p_draw, online: self.online, + convergence: self.convergence, + observer: self.observer, + _time: self._time, + _key: self._key, } } @@ -64,127 +77,177 @@ impl HistoryBuilder { self } - pub fn build(self) -> History { - History { - size: 0, - batches: Vec::new(), - agents: HashMap::new(), - time: self.time, + pub fn convergence(mut self, opts: ConvergenceOptions) -> Self { + self.convergence = opts; + self + } + + pub fn observer>(self, observer: O2) -> HistoryBuilder { + HistoryBuilder { mu: self.mu, sigma: self.sigma, beta: self.beta, drift: self.drift, p_draw: self.p_draw, online: self.online, + convergence: self.convergence, + observer, + _time: self._time, + _key: self._key, + } + } + + pub fn build(self) -> History { + History { + size: 0, + time_slices: Vec::new(), + agents: CompetitorStore::new(), + keys: KeyTable::new(), + mu: self.mu, + sigma: self.sigma, + beta: self.beta, + drift: self.drift, + p_draw: self.p_draw, + online: self.online, + convergence: self.convergence, + observer: self.observer, } } } -impl HistoryBuilder { - pub fn gamma(mut self, gamma: f64) -> Self { - self.drift = ConstantDrift(gamma); - self - } -} - -impl Default for HistoryBuilder { +impl Default for HistoryBuilder { fn default() -> Self { Self { - time: true, mu: MU, sigma: SIGMA, beta: BETA, drift: ConstantDrift(GAMMA), p_draw: P_DRAW, online: false, + convergence: ConvergenceOptions::default(), + observer: NullObserver, + _time: PhantomData, + _key: PhantomData, } } } -pub struct History { +pub struct History< + T: Time = i64, + D: Drift = ConstantDrift, + O: Observer = NullObserver, + K: Eq + Hash + Clone = &'static str, +> { size: usize, - pub(crate) batches: Vec, - agents: HashMap>, - time: bool, + pub(crate) time_slices: Vec>, + pub(crate) agents: CompetitorStore, + keys: KeyTable, mu: f64, sigma: f64, beta: f64, drift: D, p_draw: f64, online: bool, + convergence: ConvergenceOptions, + observer: O, } -impl Default for History { +impl Default for History { fn default() -> Self { - Self { - size: 0, - batches: Vec::new(), - agents: HashMap::new(), - time: true, + HistoryBuilder::default().build() + } +} + +impl History { + pub fn builder() -> HistoryBuilder { + HistoryBuilder::default() + } +} + +impl History { + /// Like `builder()` but uses a custom key type `K` instead of the default `&'static str`. + pub fn builder_with_key() -> HistoryBuilder { + HistoryBuilder { mu: MU, sigma: SIGMA, beta: BETA, drift: ConstantDrift(GAMMA), p_draw: P_DRAW, online: false, + convergence: ConvergenceOptions::default(), + observer: NullObserver, + _time: PhantomData, + _key: PhantomData, } } } -impl History { - pub fn builder() -> HistoryBuilder { - HistoryBuilder::default() +impl, O: Observer, K: Eq + Hash + Clone> History { + pub fn intern(&mut self, key: &Q) -> Index + where + K: Borrow, + Q: Hash + Eq + ToOwned + ?Sized, + { + self.keys.get_or_create(key) + } + + pub fn lookup(&self, key: &Q) -> Option + where + K: Borrow, + Q: Hash + Eq + ToOwned + ?Sized, + { + self.keys.get(key) } } -impl History { +impl, O: Observer, K: Eq + Hash + Clone> History { fn iteration(&mut self) -> (f64, f64) { let mut step = (0.0, 0.0); - agent::clean(self.agents.values_mut(), false); + competitor::clean(self.agents.values_mut(), false); - for j in (0..self.batches.len() - 1).rev() { - for agent in self.batches[j + 1].skills.keys() { + for j in (0..self.time_slices.len() - 1).rev() { + for agent in self.time_slices[j + 1].skills.keys() { self.agents.get_mut(agent).unwrap().message = - self.batches[j + 1].backward_prior_out(agent, &self.agents); + self.time_slices[j + 1].backward_prior_out(&agent, &self.agents); } - let old = self.batches[j].posteriors(); + let old = self.time_slices[j].posteriors(); - self.batches[j].new_backward_info(&self.agents); + self.time_slices[j].new_backward_info(&self.agents); - let new = self.batches[j].posteriors(); + let new = self.time_slices[j].posteriors(); step = old .iter() .fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a]))); } - agent::clean(self.agents.values_mut(), false); + competitor::clean(self.agents.values_mut(), false); - for j in 1..self.batches.len() { - for agent in self.batches[j - 1].skills.keys() { + for j in 1..self.time_slices.len() { + for agent in self.time_slices[j - 1].skills.keys() { self.agents.get_mut(agent).unwrap().message = - self.batches[j - 1].forward_prior_out(agent); + self.time_slices[j - 1].forward_prior_out(&agent); } - let old = self.batches[j].posteriors(); + let old = self.time_slices[j].posteriors(); - self.batches[j].new_forward_info(&self.agents); + self.time_slices[j].new_forward_info(&self.agents); - let new = self.batches[j].posteriors(); + let new = self.time_slices[j].posteriors(); step = old .iter() .fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a]))); } - if self.batches.len() == 1 { - let old = self.batches[0].posteriors(); + if self.time_slices.len() == 1 { + let old = self.time_slices[0].posteriors(); - self.batches[0].iteration(0, &self.agents); + self.time_slices[0].iteration(0, &self.agents); - let new = self.batches[0].posteriors(); + let new = self.time_slices[0].posteriors(); step = old .iter() @@ -194,98 +257,183 @@ impl History { step } - pub fn convergence( - &mut self, - iterations: usize, - epsilon: f64, - verbose: bool, - ) -> ((f64, f64), usize) { - let mut step = (f64::INFINITY, f64::INFINITY); - let mut i = 0; - - while tuple_gt(step, epsilon) && i < iterations { - if verbose { - print!("Iteration = {}", i); - } - - step = self.iteration(); - - i += 1; - - if verbose { - println!(", step = {:?}", step); - } - } - - if verbose { - println!("End"); - } - - (step, i) - } - - pub fn learning_curves(&self) -> HashMap> { - let mut data: HashMap> = HashMap::new(); - - for b in &self.batches { - for (agent, skill) in b.skills.iter() { - let point = (b.time, skill.posterior()); - - if let Some(entry) = data.get_mut(agent) { - entry.push(point); - } else { - data.insert(*agent, vec![point]); + /// 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> { + let mut data: HashMap> = HashMap::new(); + for slice in &self.time_slices { + for (idx, skill) in slice.skills.iter() { + if let Some(key) = self.keys.key(idx).cloned() { + data.entry(key) + .or_default() + .push((slice.time, skill.posterior())); } } } - data } - pub fn log_evidence(&mut self, forward: bool, targets: &[Index]) -> f64 { - self.batches + /// Skill estimate at the latest time slice the competitor appears in. + pub fn current_skill(&self, key: &Q) -> Option + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + let idx = self.keys.get(key)?; + self.time_slices .iter() - .map(|batch| batch.log_evidence(self.online, targets, forward, &self.agents)) + .rev() + .find_map(|ts| ts.skills.get(idx).map(|sk| sk.posterior())) + } + + /// Learning curve for a single key: (time, posterior) pairs in time order. + pub fn learning_curve(&self, key: &Q) -> Vec<(T, Gaussian)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + let Some(idx) = self.keys.get(key) else { + return Vec::new(); + }; + self.time_slices + .iter() + .filter_map(|ts| ts.skills.get(idx).map(|sk| (ts.time, sk.posterior()))) + .collect() + } + + pub(crate) fn log_evidence_internal(&mut self, forward: bool, targets: &[Index]) -> f64 { + self.time_slices + .iter() + .map(|ts| ts.log_evidence(self.online, targets, forward, &self.agents)) .sum() } - pub fn add_events( - &mut self, - composition: Vec>>, - results: Vec>, - times: Vec, - weights: Vec>>, - ) { - self.add_events_with_prior(composition, results, times, weights, HashMap::new()) + /// Total log-evidence across the history. + pub fn log_evidence(&mut self) -> f64 { + self.log_evidence_internal(false, &[]) } - pub fn add_events_with_prior( + /// 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(&mut self, keys: &[&Q]) -> f64 + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + let targets: Vec = keys.iter().filter_map(|k| self.keys.get(*k)).collect(); + self.log_evidence_internal(false, &targets) + } + + /// Draw-probability quality metric for the given teams (key slices). + /// + /// Values range roughly [0, 1]; 1 == perfectly matched. + pub fn predict_quality(&self, teams: &[&[&K]]) -> f64 { + let groups: Vec> = teams + .iter() + .map(|team| { + team.iter() + .filter_map(|k| self.keys.get(*k)) + .filter_map(|idx| { + self.time_slices + .iter() + .rev() + .find_map(|ts| ts.skills.get(idx).map(|s| s.posterior())) + }) + .collect() + }) + .collect(); + let group_refs: Vec<&[Gaussian]> = groups.iter().map(|g| g.as_slice()).collect(); + crate::quality(&group_refs, self.beta) + } + + /// 2-team win probability: returns `[P(team0 wins), P(team1 wins)]`. + /// + /// Panics if `teams.len() != 2`. N-team support lands in T4. + pub fn predict_outcome(&self, teams: &[&[&K]]) -> Vec { + assert_eq!(teams.len(), 2, "predict_outcome T2: 2 teams only"); + let gather = |team: &[&K]| -> Gaussian { + team.iter() + .filter_map(|k| self.keys.get(*k)) + .filter_map(|idx| { + self.time_slices + .iter() + .rev() + .find_map(|ts| ts.skills.get(idx).map(|s| s.posterior())) + }) + .fold(crate::N00, |acc, g| acc + g.forget(self.beta.powi(2))) + }; + let a = gather(teams[0]); + let b = gather(teams[1]); + let diff = a - b; + let p_a = 1.0 - crate::cdf(0.0, diff.mu(), diff.sigma()); + vec![p_a, 1.0 - p_a] + } + + /// Run the full forward+backward convergence loop and return a summary. + pub fn converge(&mut self) -> Result { + use std::time::Instant; + + use smallvec::SmallVec; + + let opts = self.convergence; + let mut step = (f64::INFINITY, f64::INFINITY); + let mut i = 0; + let mut per_iter: SmallVec<[std::time::Duration; 32]> = SmallVec::new(); + while tuple_gt(step, opts.epsilon) && i < opts.max_iter { + let t0 = Instant::now(); + step = self.iteration(); + per_iter.push(t0.elapsed()); + i += 1; + self.observer.on_iteration_end(i, step); + } + let converged = !tuple_gt(step, opts.epsilon); + let log_evidence = self.log_evidence_internal(false, &[]); + self.observer.on_converged(i, step, converged); + Ok(ConvergenceReport { + iterations: i, + final_step: step, + log_evidence, + converged, + per_iteration_time: per_iter, + slices_skipped: 0, + }) + } +} + +impl, O: Observer, K: Eq + Hash + Clone> History { + pub(crate) fn add_events_with_prior( &mut self, composition: Vec>>, results: Vec>, - times: Vec, + times: Vec, weights: Vec>>, - mut priors: HashMap>, - ) { - assert!(times.is_empty() || self.time, "length(times)>0 but !h.time"); - assert!( - !times.is_empty() || !self.time, - "length(times)==0 but h.time" - ); - assert!( - results.is_empty() || results.len() == composition.len(), - "(length(results) > 0) & (length(composition) != length(results))" - ); - assert!( - times.is_empty() || times.len() == composition.len(), - "length(times) > 0) & (length(composition) != length(times))" - ); - assert!( - weights.is_empty() || weights.len() == composition.len(), - "(length(weights) > 0) & (length(composition) != length(weights))" - ); + mut priors: HashMap>, + ) -> Result<(), InferenceError> { + if !results.is_empty() && results.len() != composition.len() { + return Err(InferenceError::MismatchedShape { + kind: "results", + expected: composition.len(), + got: results.len(), + }); + } + if times.len() != composition.len() { + return Err(InferenceError::MismatchedShape { + kind: "times", + expected: composition.len(), + got: times.len(), + }); + } + if !weights.is_empty() && weights.len() != composition.len() { + return Err(InferenceError::MismatchedShape { + kind: "weights", + expected: composition.len(), + got: weights.len(), + }); + } - agent::clean(self.agents.values_mut(), true); + competitor::clean(self.agents.values_mut(), true); let mut this_agent = Vec::with_capacity(1024); @@ -296,61 +444,56 @@ impl History { this_agent.push(*agent); - if !self.agents.contains_key(agent) { + if !self.agents.contains(*agent) { self.agents.insert( *agent, - Agent { - player: priors.remove(agent).unwrap_or_else(|| { - Player::new( + Competitor { + rating: priors.remove(agent).unwrap_or_else(|| { + Rating::new( Gaussian::from_ms(self.mu, self.sigma), self.beta, self.drift, ) }), message: N_INF, - last_time: i64::MIN, + last_time: None, }, ); } } let n = composition.len(); - let o = if self.time { - sort_time(×, false) - } else { - (0..composition.len()).collect::>() - }; + let o = sort_time(×, false); let mut i = 0; let mut k = 0; while i < n { let mut j = i + 1; - let t = if self.time { times[o[i]] } else { i as i64 + 1 }; + let t = times[o[i]]; - while self.time && j < n && times[o[j]] == t { + while j < n && times[o[j]] == t { j += 1; } - while (!self.time && (self.size > k)) - || (self.time && self.batches.len() > k && self.batches[k].time < t) - { - let batch = &mut self.batches[k]; + while self.time_slices.len() > k && self.time_slices[k].time < t { + let time_slice = &mut self.time_slices[k]; if k > 0 { - batch.new_forward_info(&self.agents); + time_slice.new_forward_info(&self.agents); } - // TODO: Is it faster to iterate over agents in batch instead? for agent_idx in &this_agent { - if let Some(skill) = batch.skills.get_mut(agent_idx) { - skill.elapsed = - batch::compute_elapsed(self.agents[agent_idx].last_time, batch.time); + if let Some(skill) = time_slice.skills.get_mut(*agent_idx) { + skill.elapsed = time_slice::compute_elapsed( + self.agents[*agent_idx].last_time.as_ref(), + &time_slice.time, + ); - let agent = self.agents.get_mut(agent_idx).unwrap(); + let agent = self.agents.get_mut(*agent_idx).unwrap(); - agent.last_time = if self.time { batch.time } else { i64::MAX }; - agent.message = batch.forward_prior_out(agent_idx); + agent.last_time = Some(time_slice.time); + agent.message = time_slice.forward_prior_out(agent_idx); } } @@ -373,29 +516,29 @@ impl History { (i..j).map(|e| weights[o[e]].clone()).collect::>() }; - if self.time && self.batches.len() > k && self.batches[k].time == t { - let batch = &mut self.batches[k]; - batch.add_events(composition, results, weights, &self.agents); + if self.time_slices.len() > k && self.time_slices[k].time == t { + let time_slice = &mut self.time_slices[k]; + time_slice.add_events(composition, results, weights, &self.agents); - for agent_idx in batch.skills.keys() { + for agent_idx in time_slice.skills.keys() { let agent = self.agents.get_mut(agent_idx).unwrap(); - agent.last_time = if self.time { t } else { i64::MAX }; - agent.message = batch.forward_prior_out(agent_idx); + agent.last_time = Some(t); + agent.message = time_slice.forward_prior_out(&agent_idx); } } else { - let mut batch: Batch = Batch::new(t, self.p_draw); - batch.add_events(composition, results, weights, &self.agents); + let mut time_slice = TimeSlice::new(t, self.p_draw); + time_slice.add_events(composition, results, weights, &self.agents); - self.batches.insert(k, batch); + self.time_slices.insert(k, time_slice); - let batch = &self.batches[k]; + let time_slice = &self.time_slices[k]; - for agent_idx in batch.skills.keys() { + for agent_idx in time_slice.skills.keys() { let agent = self.agents.get_mut(agent_idx).unwrap(); - agent.last_time = if self.time { t } else { i64::MAX }; - agent.message = batch.forward_prior_out(agent_idx); + agent.last_time = Some(t); + agent.message = time_slice.forward_prior_out(&agent_idx); } k += 1; @@ -404,21 +547,22 @@ impl History { i = j; } - while self.time && self.batches.len() > k { - let batch = &mut self.batches[k]; + while self.time_slices.len() > k { + let time_slice = &mut self.time_slices[k]; - batch.new_forward_info(&self.agents); + time_slice.new_forward_info(&self.agents); - // TODO: Is it faster to iterate over agents in batch instead? for agent_idx in &this_agent { - if let Some(skill) = batch.skills.get_mut(agent_idx) { - skill.elapsed = - batch::compute_elapsed(self.agents[agent_idx].last_time, batch.time); + if let Some(skill) = time_slice.skills.get_mut(*agent_idx) { + skill.elapsed = time_slice::compute_elapsed( + self.agents[*agent_idx].last_time.as_ref(), + &time_slice.time, + ); - let agent = self.agents.get_mut(agent_idx).unwrap(); + let agent = self.agents.get_mut(*agent_idx).unwrap(); - agent.last_time = if self.time { batch.time } else { i64::MAX }; - agent.message = batch.forward_prior_out(agent_idx); + agent.last_time = Some(time_slice.time); + agent.message = time_slice.forward_prior_out(agent_idx); } } @@ -426,49 +570,160 @@ impl History { } self.size += n; + Ok(()) + } + + pub fn record_winner(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError> + where + K: Borrow, + Q: Hash + Eq + ToOwned + ?Sized, + { + let w = self.intern(winner); + let l = self.intern(loser); + self.add_events_with_prior( + vec![vec![vec![w], vec![l]]], + vec![vec![1.0, 0.0]], + vec![time], + vec![], + HashMap::new(), + ) + } + + pub fn record_draw(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError> + where + K: Borrow, + Q: Hash + Eq + ToOwned + ?Sized, + { + let a_idx = self.intern(a); + let b_idx = self.intern(b); + self.add_events_with_prior( + vec![vec![vec![a_idx], vec![b_idx]]], + vec![vec![0.0, 0.0]], + vec![time], + vec![], + HashMap::new(), + ) + } + + /// Start a fluent event builder for a single match at `time`. + pub fn event(&mut self, time: T) -> crate::event_builder::EventBuilder<'_, T, D, O, K> { + crate::event_builder::EventBuilder::new(self, time) + } + + /// Bulk-ingest typed events. + pub fn add_events(&mut self, events: I) -> Result<(), InferenceError> + where + I: IntoIterator>, + { + use crate::event::Event; + let events: Vec> = events.into_iter().collect(); + if events.is_empty() { + return Ok(()); + } + + let mut composition: Vec>> = Vec::with_capacity(events.len()); + let mut results: Vec> = Vec::with_capacity(events.len()); + let mut times: Vec = Vec::with_capacity(events.len()); + let mut weights: Vec>> = Vec::with_capacity(events.len()); + let mut priors: HashMap> = HashMap::new(); + + for ev in events { + let ranks = ev.outcome.as_ranks(); + if ranks.len() != ev.teams.len() { + return Err(InferenceError::MismatchedShape { + kind: "outcome ranks vs teams", + expected: ev.teams.len(), + got: ranks.len(), + }); + } + + let mut event_comp: Vec> = Vec::with_capacity(ev.teams.len()); + let mut event_weights: Vec> = Vec::with_capacity(ev.teams.len()); + + for team in ev.teams { + let mut team_indices: Vec = Vec::with_capacity(team.members.len()); + let mut team_weights: Vec = Vec::with_capacity(team.members.len()); + for member in team.members { + let idx = self.keys.get_or_create(&member.key); + team_indices.push(idx); + team_weights.push(member.weight); + if let Some(prior) = member.prior { + priors.insert(idx, Rating::new(prior, self.beta, self.drift)); + } + } + event_comp.push(team_indices); + event_weights.push(team_weights); + } + composition.push(event_comp); + weights.push(event_weights); + + let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64; + let inverted: Vec = ranks.iter().map(|&r| max_rank - r as f64).collect(); + results.push(inverted); + times.push(ev.time); + } + + self.add_events_with_prior(composition, results, times, weights, priors) } } #[cfg(test)] mod tests { use approx::assert_ulps_eq; + use smallvec::smallvec; use super::*; - use crate::{ConstantDrift, EPSILON, Game, Gaussian, ITERATIONS, IndexMap, P_DRAW, Player}; + use crate::{ + ConstantDrift, EPSILON, Event, Game, Gaussian, Member, Outcome, P_DRAW, Team, + arena::ScratchArena, + }; + + fn make_events_1v1( + pairs: &[(&'static str, &'static str)], + outcomes: &[Outcome], + times: &[i64], + ) -> Vec> { + pairs + .iter() + .copied() + .zip(outcomes.iter().cloned()) + .zip(times.iter().copied()) + .map(|(((a, b), outcome), time)| Event { + time, + teams: smallvec![ + Team::with_members([Member::new(a)]), + Team::with_members([Member::new(b)]), + ], + outcome, + }) + .collect() + } #[test] fn test_init() { - let mut index_map = IndexMap::new(); + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .drift(ConstantDrift(0.15 * 25.0 / 3.0)) + .build(); - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); - let c = index_map.get_or_create("c"); + let events = make_events_1v1( + &[("a", "b"), ("a", "c"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(1, 2), + Outcome::winner(0, 2), + ], + &[1, 2, 3], + ); + h.add_events(events).unwrap(); - let composition = vec![ - vec![vec![a], vec![b]], - vec![vec![a], vec![c]], - vec![vec![b], vec![c]], - ]; - let results = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]; + let a = h.keys.get("a").unwrap(); + let b = h.keys.get("b").unwrap(); + let c = h.keys.get("c").unwrap(); - let mut priors = HashMap::new(); - - for agent in [a, b, c] { - priors.insert( - agent, - Player::new( - Gaussian::from_ms(25.0, 25.0 / 3.0), - 25.0 / 6.0, - ConstantDrift(0.15 * 25.0 / 3.0), - ), - ); - } - - let mut h = History::default(); - - h.add_events_with_prior(composition, results, vec![1, 2, 3], vec![], priors); - - let p0 = h.batches[0].posteriors(); + let p0 = h.time_slices[0].posteriors(); assert_ulps_eq!( p0[&a], @@ -476,129 +731,131 @@ mod tests { epsilon = 1e-6 ); - let observed = h.batches[1].skills[&a].forward.sigma; + let observed = h.time_slices[1].skills.get(a).unwrap().forward.sigma(); let gamma: f64 = 0.15 * 25.0 / 3.0; - let expected = (gamma.powi(2) + h.batches[0].skills[&a].posterior().sigma.powi(2)).sqrt(); + let expected = (gamma.powi(2) + + h.time_slices[0] + .skills + .get(a) + .unwrap() + .posterior() + .sigma() + .powi(2)) + .sqrt(); assert_ulps_eq!(observed, expected, epsilon = 0.000001); - let observed = h.batches[1].skills[&a].posterior(); + let observed = h.time_slices[1].skills.get(a).unwrap().posterior(); let w = [vec![1.0], vec![1.0]]; - let p = Game::new( - h.batches[1].events[0].within_priors(false, false, &h.batches[1].skills, &h.agents), + let p = Game::ranked_with_arena( + h.time_slices[1].events[0].within_priors( + false, + false, + &h.time_slices[1].skills, + &h.agents, + ), &[0.0, 1.0], &w, P_DRAW, + &mut ScratchArena::new(), ) .posteriors(); let expected = p[0][0]; assert_ulps_eq!(observed, expected, epsilon = 1e-6); + + let _ = (b, c); } #[test] fn test_one_batch() { - let mut index_map = IndexMap::new(); + let mut h1 = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .drift(ConstantDrift(0.15 * 25.0 / 3.0)) + .build(); - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); - let c = index_map.get_or_create("c"); + let events = make_events_1v1( + &[("a", "b"), ("b", "c"), ("c", "a")], + &[ + Outcome::winner(0, 2), + Outcome::winner(0, 2), + Outcome::winner(0, 2), + ], + &[1, 1, 1], + ); + h1.add_events(events).unwrap(); - let composition = vec![ - vec![vec![a], vec![b]], - vec![vec![b], vec![c]], - vec![vec![c], vec![a]], - ]; - let results = vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]; - let times = vec![1, 1, 1]; - - let mut priors = HashMap::new(); - - for agent in [a, b, c] { - priors.insert( - agent, - Player::new( - Gaussian::from_ms(25.0, 25.0 / 3.0), - 25.0 / 6.0, - ConstantDrift(0.15 * 25.0 / 3.0), - ), - ); - } - - let mut h1 = History::default(); - - h1.add_events_with_prior(composition, results, times, vec![], priors); + let a = h1.keys.get("a").unwrap(); + let c = h1.keys.get("c").unwrap(); assert_ulps_eq!( - h1.batches[0].skills[&a].posterior(), + h1.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(22.904409, 6.010330), epsilon = 1e-6 ); assert_ulps_eq!( - h1.batches[0].skills[&c].posterior(), + h1.time_slices[0].skills.get(c).unwrap().posterior(), Gaussian::from_ms(25.110318, 5.866311), epsilon = 1e-6 ); - h1.convergence(ITERATIONS, EPSILON, false); + h1.converge().unwrap(); assert_ulps_eq!( - h1.batches[0].skills[&a].posterior(), + h1.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(25.000000, 5.419212), epsilon = 1e-6 ); assert_ulps_eq!( - h1.batches[0].skills[&c].posterior(), + h1.time_slices[0].skills.get(c).unwrap().posterior(), Gaussian::from_ms(25.000000, 5.419212), epsilon = 1e-6 ); - let composition = vec![ - vec![vec![a], vec![b]], - vec![vec![b], vec![c]], - vec![vec![c], vec![a]], - ]; - let results = vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]; - let times = vec![1, 2, 3]; + let mut h2 = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .drift(ConstantDrift(25.0 / 300.0)) + .build(); - let mut priors = HashMap::new(); + let events = make_events_1v1( + &[("a", "b"), ("b", "c"), ("c", "a")], + &[ + Outcome::winner(0, 2), + Outcome::winner(0, 2), + Outcome::winner(0, 2), + ], + &[1, 2, 3], + ); + h2.add_events(events).unwrap(); - for agent in [a, b, c] { - priors.insert( - agent, - Player::new( - Gaussian::from_ms(25.0, 25.0 / 3.0), - 25.0 / 6.0, - ConstantDrift(25.0 / 300.0), - ), - ); - } - - let mut h2 = History::default(); - - h2.add_events_with_prior(composition, results, times, vec![], priors); + let a = h2.keys.get("a").unwrap(); + let c = h2.keys.get("c").unwrap(); assert_ulps_eq!( - h2.batches[2].skills[&a].posterior(), + h2.time_slices[2].skills.get(a).unwrap().posterior(), Gaussian::from_ms(22.903522, 6.011017), epsilon = 1e-6 ); assert_ulps_eq!( - h2.batches[2].skills[&c].posterior(), + h2.time_slices[2].skills.get(c).unwrap().posterior(), Gaussian::from_ms(25.110702, 5.866811), epsilon = 1e-6 ); - h2.convergence(ITERATIONS, EPSILON, false); + h2.converge().unwrap(); assert_ulps_eq!( - h2.batches[2].skills[&a].posterior(), + h2.time_slices[2].skills.get(a).unwrap().posterior(), Gaussian::from_ms(24.998668, 5.420053), epsilon = 1e-6 ); assert_ulps_eq!( - h2.batches[2].skills[&c].posterior(), + h2.time_slices[2].skills.get(c).unwrap().posterior(), Gaussian::from_ms(25.000532, 5.419827), epsilon = 1e-6 ); @@ -606,53 +863,41 @@ mod tests { #[test] fn test_learning_curves() { - let mut index_map = IndexMap::new(); + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .drift(ConstantDrift(25.0 / 300.0)) + .build(); - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); - let c = index_map.get_or_create("c"); + let events = make_events_1v1( + &[("a", "b"), ("b", "c"), ("c", "a")], + &[ + Outcome::winner(0, 2), + Outcome::winner(0, 2), + Outcome::winner(0, 2), + ], + &[5, 6, 7], + ); + h.add_events(events).unwrap(); + h.converge().unwrap(); - let composition = vec![ - vec![vec![a], vec![b]], - vec![vec![b], vec![c]], - vec![vec![c], vec![a]], - ]; - let results = vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]; - let times = vec![5, 6, 7]; + let lc_a = h.learning_curve("a"); + let lc_c = h.learning_curve("c"); - let mut priors = HashMap::new(); + let aj_e = lc_a.len(); + let cj_e = lc_c.len(); - for agent in [a, b, c] { - priors.insert( - agent, - Player::new( - Gaussian::from_ms(25.0, 25.0 / 3.0), - 25.0 / 6.0, - ConstantDrift(25.0 / 300.0), - ), - ); - } - - let mut h = History::default(); - - h.add_events_with_prior(composition, results, times, vec![], priors); - h.convergence(ITERATIONS, EPSILON, false); - - let lc = h.learning_curves(); - - let aj_e = lc[&a].len(); - let cj_e = lc[&c].len(); - - assert_eq!(lc[&a][0].0, 5); - assert_eq!(lc[&a][aj_e - 1].0, 7); + assert_eq!(lc_a[0].0, 5); + assert_eq!(lc_a[aj_e - 1].0, 7); assert_ulps_eq!( - lc[&a][aj_e - 1].1, + lc_a[aj_e - 1].1, Gaussian::from_ms(24.998668, 5.420053), epsilon = 1e-6 ); assert_ulps_eq!( - lc[&c][cj_e - 1].1, + lc_c[cj_e - 1].1, Gaussian::from_ms(25.000532, 5.419827), epsilon = 1e-6 ); @@ -660,81 +905,95 @@ mod tests { #[test] fn test_env_ttt() { - let mut index_map = IndexMap::new(); - - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); - let c = index_map.get_or_create("c"); - - let composition = vec![ - vec![vec![a], vec![b]], - vec![vec![a], vec![c]], - vec![vec![b], vec![c]], - ]; - let results = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]; - let mut h = History::builder() .mu(25.0) .sigma(25.0 / 3.0) .beta(25.0 / 6.0) - .gamma(25.0 / 300.0) - .time(false) + .drift(ConstantDrift(25.0 / 300.0)) .build(); - h.add_events(composition, results, vec![], vec![]); + let events = make_events_1v1( + &[("a", "b"), ("a", "c"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(1, 2), + Outcome::winner(0, 2), + ], + &[1, 2, 3], + ); + h.add_events(events).unwrap(); + h.converge().unwrap(); - h.convergence(ITERATIONS, EPSILON, false); + let a = h.keys.get("a").unwrap(); + let b = h.keys.get("b").unwrap(); + let c = h.keys.get("c").unwrap(); - assert_eq!(h.batches[2].skills[&b].elapsed, 1); - assert_eq!(h.batches[2].skills[&c].elapsed, 1); + assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2); + assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1); assert_ulps_eq!( - h.batches[0].skills[&a].posterior(), - Gaussian::from_ms(25.000267, 5.419381), + h.time_slices[0].skills.get(a).unwrap().posterior(), + Gaussian::from_ms(25.000267, 5.419423), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&b].posterior(), - Gaussian::from_ms(24.999465, 5.419425), + h.time_slices[0].skills.get(b).unwrap().posterior(), + Gaussian::from_ms(24.999198, 5.419512), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[2].skills[&b].posterior(), - Gaussian::from_ms(25.000532, 5.419696), + h.time_slices[2].skills.get(b).unwrap().posterior(), + Gaussian::from_ms(25.001332, 5.420054), epsilon = 1e-6 ); } #[test] fn test_teams() { - let mut index_map = IndexMap::new(); - - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); - let c = index_map.get_or_create("c"); - let d = index_map.get_or_create("d"); - let e = index_map.get_or_create("e"); - let f = index_map.get_or_create("f"); - - let composition = vec![ - vec![vec![a, b], vec![c, d]], - vec![vec![e, f], vec![b, c]], - vec![vec![a, d], vec![e, f]], - ]; - let results = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]; - - let mut h = History::builder() + let mut h: History = History::builder() .mu(0.0) .sigma(6.0) .beta(1.0) - .gamma(0.0) - .time(false) + .drift(ConstantDrift(0.0)) .build(); - h.add_events(composition, results, vec![], vec![]); + let events: Vec> = vec![ + Event { + time: 1, + teams: smallvec![ + Team::with_members([Member::new("a"), Member::new("b")]), + Team::with_members([Member::new("c"), Member::new("d")]), + ], + outcome: Outcome::winner(0, 2), + }, + Event { + time: 2, + teams: smallvec![ + Team::with_members([Member::new("e"), Member::new("f")]), + Team::with_members([Member::new("b"), Member::new("c")]), + ], + outcome: Outcome::winner(1, 2), + }, + Event { + time: 3, + teams: smallvec![ + Team::with_members([Member::new("a"), Member::new("d")]), + Team::with_members([Member::new("e"), Member::new("f")]), + ], + outcome: Outcome::winner(0, 2), + }, + ]; + h.add_events(events).unwrap(); - let trueskill_log_evidence = h.log_evidence(false, &[]); - let trueskill_log_evidence_online = h.log_evidence(true, &[]); + let a = h.keys.get("a").unwrap(); + let b = h.keys.get("b").unwrap(); + let c = h.keys.get("c").unwrap(); + let d = h.keys.get("d").unwrap(); + let e = h.keys.get("e").unwrap(); + 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, &[]); assert_ulps_eq!( trueskill_log_evidence, @@ -743,53 +1002,53 @@ mod tests { ); assert_ulps_eq!( - h.batches[0].skills[&b].posterior().mu, - -1.0 * h.batches[0].skills[&c].posterior().mu, + h.time_slices[0].skills.get(b).unwrap().posterior().mu(), + -h.time_slices[0].skills.get(c).unwrap().posterior().mu(), epsilon = 1e-6 ); - let evidence_second_event = h.log_evidence(false, &[b]).exp() * 2.0; + let evidence_second_event = h.log_evidence_internal(false, &[b]).exp() * 2.0; assert_ulps_eq!(0.5, evidence_second_event, epsilon = 1e-6); - let evidence_third_event = h.log_evidence(false, &[a]).exp() * 2.0; + let evidence_third_event = h.log_evidence_internal(false, &[a]).exp() * 2.0; assert_ulps_eq!(0.669885, evidence_third_event, epsilon = 1e-6); - h.convergence(ITERATIONS, EPSILON, false); + h.converge().unwrap(); - let loocv_hat = h.log_evidence(false, &[]).exp(); - let p_d_m_hat = h.log_evidence(true, &[]).exp(); + let loocv_hat = h.log_evidence_internal(false, &[]).exp(); + let p_d_m_hat = h.log_evidence_internal(true, &[]).exp(); assert_ulps_eq!(loocv_hat, 0.241027, epsilon = 1e-6); assert_ulps_eq!(p_d_m_hat, 0.172432, epsilon = 1e-6); assert_ulps_eq!( - h.batches[0].skills[&a].posterior(), - h.batches[0].skills[&b].posterior(), + h.time_slices[0].skills.get(a).unwrap().posterior(), + h.time_slices[0].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&c].posterior(), - h.batches[0].skills[&d].posterior(), + h.time_slices[0].skills.get(c).unwrap().posterior(), + h.time_slices[0].skills.get(d).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[1].skills[&e].posterior(), - h.batches[1].skills[&f].posterior(), + h.time_slices[1].skills.get(e).unwrap().posterior(), + h.time_slices[1].skills.get(f).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&a].posterior(), + h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(4.084902, 5.106919), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&c].posterior(), + h.time_slices[0].skills.get(c).unwrap().posterior(), Gaussian::from_ms(-0.533029, 5.106919), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[2].skills[&e].posterior(), + h.time_slices[2].skills.get(e).unwrap().posterior(), Gaussian::from_ms(-3.551872, 5.154569), epsilon = 1e-6 ); @@ -797,56 +1056,64 @@ mod tests { #[test] fn test_add_events() { - let mut index_map = IndexMap::new(); - - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); - let c = index_map.get_or_create("c"); - - let composition = vec![ - vec![vec![a], vec![b]], - vec![vec![a], vec![c]], - vec![vec![b], vec![c]], - ]; - let results = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]; - - let mut h = History::builder() + let mut h: History = History::builder() .mu(0.0) .sigma(2.0) .beta(1.0) - .gamma(0.0) - .time(false) + .drift(ConstantDrift(0.0)) .build(); - h.add_events(composition.clone(), results.clone(), vec![], vec![]); + let events = make_events_1v1( + &[("a", "b"), ("a", "c"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(1, 2), + Outcome::winner(0, 2), + ], + &[1, 2, 3], + ); + h.add_events(events).unwrap(); - h.convergence(ITERATIONS, EPSILON, false); + let a = h.keys.get("a").unwrap(); + let b = h.keys.get("b").unwrap(); + let c = h.keys.get("c").unwrap(); - assert_eq!(h.batches[2].skills[&b].elapsed, 1); - assert_eq!(h.batches[2].skills[&c].elapsed, 1); + h.converge().unwrap(); + + assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2); + assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1); assert_ulps_eq!( - h.batches[0].skills[&a].posterior(), + h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&b].posterior(), + h.time_slices[0].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[2].skills[&b].posterior(), + h.time_slices[2].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); - h.add_events(composition, results, vec![], vec![]); + let events2 = make_events_1v1( + &[("a", "b"), ("a", "c"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(1, 2), + Outcome::winner(0, 2), + ], + &[4, 5, 6], + ); + h.add_events(events2).unwrap(); - assert_eq!(h.batches.len(), 6); + assert_eq!(h.time_slices.len(), 6); assert_eq!( - h.batches + h.time_slices .iter() .map(|b| b.get_composition()) .collect::>(), @@ -860,25 +1127,25 @@ mod tests { ] ); - h.convergence(ITERATIONS, EPSILON, false); + h.converge().unwrap(); assert_ulps_eq!( - h.batches[0].skills[&a].posterior(), + h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[3].skills[&a].posterior(), + h.time_slices[3].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[3].skills[&b].posterior(), + h.time_slices[3].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[5].skills[&b].posterior(), + h.time_slices[5].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); @@ -886,56 +1153,64 @@ mod tests { #[test] fn test_only_add_events() { - let mut index_map = IndexMap::new(); - - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); - let c = index_map.get_or_create("c"); - - let composition = vec![ - vec![vec![a], vec![b]], - vec![vec![a], vec![c]], - vec![vec![b], vec![c]], - ]; - let results = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]; - - let mut h = History::builder() + let mut h: History = History::builder() .mu(0.0) .sigma(2.0) .beta(1.0) - .gamma(0.0) - .time(false) + .drift(ConstantDrift(0.0)) .build(); - h.add_events(composition.clone(), results.clone(), vec![], vec![]); + let events = make_events_1v1( + &[("a", "b"), ("a", "c"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(1, 2), + Outcome::winner(0, 2), + ], + &[1, 2, 3], + ); + h.add_events(events).unwrap(); - h.convergence(ITERATIONS, EPSILON, false); + let a = h.keys.get("a").unwrap(); + let b = h.keys.get("b").unwrap(); + let c = h.keys.get("c").unwrap(); - assert_eq!(h.batches[2].skills[&b].elapsed, 1); - assert_eq!(h.batches[2].skills[&c].elapsed, 1); + h.converge().unwrap(); + + assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2); + assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1); assert_ulps_eq!( - h.batches[0].skills[&a].posterior(), + h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&b].posterior(), + h.time_slices[0].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[2].skills[&b].posterior(), + h.time_slices[2].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 1.300610), epsilon = 1e-6 ); - h.add_events(composition, results, vec![], vec![]); + let events2 = make_events_1v1( + &[("a", "b"), ("a", "c"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(1, 2), + Outcome::winner(0, 2), + ], + &[4, 5, 6], + ); + h.add_events(events2).unwrap(); - assert_eq!(h.batches.len(), 6); + assert_eq!(h.time_slices.len(), 6); assert_eq!( - h.batches + h.time_slices .iter() .map(|b| b.get_composition()) .collect::>(), @@ -949,25 +1224,25 @@ mod tests { ] ); - h.convergence(ITERATIONS, EPSILON, false); + h.converge().unwrap(); assert_ulps_eq!( - h.batches[0].skills[&a].posterior(), + h.time_slices[0].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[3].skills[&a].posterior(), + h.time_slices[3].skills.get(a).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[3].skills[&b].posterior(), + h.time_slices[3].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[5].skills[&b].posterior(), + h.time_slices[5].skills.get(b).unwrap().posterior(), Gaussian::from_ms(0.000000, 0.931236), epsilon = 1e-6 ); @@ -975,108 +1250,125 @@ mod tests { #[test] fn test_log_evidence() { - let mut index_map = IndexMap::new(); + use crate::ConvergenceOptions; - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); + let mut h: History = History::builder().build(); - let composition = vec![vec![vec![a], vec![b]], vec![vec![b], vec![a]]]; + // empty results in the old API = team 0 wins; reproduce with Outcome::winner(0,2) + let events = make_events_1v1( + &[("a", "b"), ("b", "a")], + &[Outcome::winner(0, 2), Outcome::winner(0, 2)], + &[1, 2], + ); + h.add_events(events).unwrap(); - let mut h = History::builder().time(false).build(); + let a = h.keys.get("a").unwrap(); + let b = h.keys.get("b").unwrap(); - h.add_events(composition.clone(), vec![], vec![], vec![]); - - let p_d_m_2 = h.log_evidence(false, &[]).exp() * 2.0; + let p_d_m_2 = h.log_evidence_internal(false, &[]).exp() * 2.0; assert_ulps_eq!(p_d_m_2, 0.17650911, epsilon = 1e-6); assert_ulps_eq!( p_d_m_2, - h.log_evidence(true, &[]).exp() * 2.0, + h.log_evidence_internal(true, &[]).exp() * 2.0, epsilon = 1e-6 ); assert_ulps_eq!( p_d_m_2, - h.log_evidence(true, &[a]).exp() * 2.0, + h.log_evidence_internal(true, &[a]).exp() * 2.0, epsilon = 1e-6 ); assert_ulps_eq!( p_d_m_2, - h.log_evidence(false, &[a]).exp() * 2.0, + h.log_evidence_internal(false, &[a]).exp() * 2.0, epsilon = 1e-6 ); - h.convergence(11, EPSILON, false); + // run exactly 11 iterations (old test used convergence(11, ...)) + h.convergence = ConvergenceOptions { + max_iter: 11, + epsilon: EPSILON, + }; + h.converge().unwrap(); - let loocv_approx_2 = h.log_evidence(false, &[]).exp().sqrt(); + let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt(); assert_ulps_eq!(loocv_approx_2, 0.001976774, epsilon = 0.000001); - let p_d_m_approx_2 = h.log_evidence(true, &[]).exp() * 2.0; + let p_d_m_approx_2 = h.log_evidence_internal(true, &[]).exp() * 2.0; assert!(loocv_approx_2 - p_d_m_approx_2 < 1e-4); assert_ulps_eq!( loocv_approx_2, - h.log_evidence(true, &[b]).exp() * 2.0, + h.log_evidence_internal(true, &[b]).exp() * 2.0, epsilon = 1e-4 ); - let mut h = History::builder().time(false).build(); + let mut h2: History = History::builder().build(); - h.add_events(composition, vec![], vec![], vec![]); + let events = make_events_1v1( + &[("a", "b"), ("b", "a")], + &[Outcome::winner(0, 2), Outcome::winner(0, 2)], + &[1, 2], + ); + h2.add_events(events).unwrap(); assert_ulps_eq!( ((0.5f64 * 0.1765).ln() / 2.0).exp(), - (h.log_evidence(false, &[]) / 2.0).exp(), + (h2.log_evidence_internal(false, &[]) / 2.0).exp(), epsilon = 1e-4 ); } #[test] fn test_add_events_with_time() { - let mut index_map = IndexMap::new(); - - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); - let c = index_map.get_or_create("c"); - - let composition = vec![ - vec![vec![a], vec![b]], - vec![vec![a], vec![c]], - vec![vec![b], vec![c]], - ]; - let results = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]; - - let mut h = History::builder() + let mut h: History = History::builder() .mu(0.0) .sigma(2.0) .beta(1.0) - .gamma(0.0) + .drift(ConstantDrift(0.0)) .build(); - h.add_events( - composition.clone(), - results.clone(), - vec![0, 10, 20], - vec![], + let events = make_events_1v1( + &[("a", "b"), ("a", "c"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(1, 2), + Outcome::winner(0, 2), + ], + &[0, 10, 20], ); + h.add_events(events).unwrap(); + h.converge().unwrap(); - h.convergence(ITERATIONS, EPSILON, false); + let a = h.keys.get("a").unwrap(); + let b = h.keys.get("b").unwrap(); + let c = h.keys.get("c").unwrap(); - h.add_events(composition, results, vec![15, 10, 0], vec![]); + let events2 = make_events_1v1( + &[("a", "b"), ("a", "c"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(1, 2), + Outcome::winner(0, 2), + ], + &[15, 10, 0], + ); + h.add_events(events2).unwrap(); - assert_eq!(h.batches.len(), 4); + assert_eq!(h.time_slices.len(), 4); assert_eq!( - h.batches + h.time_slices .iter() - .map(|batch| batch.events.len()) + .map(|ts| ts.events.len()) .collect::>(), vec![2, 2, 1, 1] ); assert_eq!( - h.batches + h.time_slices .iter() .map(|b| b.get_composition()) .collect::>(), @@ -1089,7 +1381,7 @@ mod tests { ); assert_eq!( - h.batches + h.time_slices .iter() .map(|b| b.get_results()) .collect::>(), @@ -1101,70 +1393,85 @@ mod tests { ] ); - let end = h.batches.len() - 1; + let end = h.time_slices.len() - 1; - assert_eq!(h.batches[0].skills[&c].elapsed, 0); - assert_eq!(h.batches[end].skills[&c].elapsed, 10); + assert_eq!(h.time_slices[0].skills.get(c).unwrap().elapsed, 0); + assert_eq!(h.time_slices[end].skills.get(c).unwrap().elapsed, 10); - assert_eq!(h.batches[0].skills[&a].elapsed, 0); - assert_eq!(h.batches[2].skills[&a].elapsed, 5); + assert_eq!(h.time_slices[0].skills.get(a).unwrap().elapsed, 0); + assert_eq!(h.time_slices[2].skills.get(a).unwrap().elapsed, 5); - assert_eq!(h.batches[0].skills[&b].elapsed, 0); - assert_eq!(h.batches[end].skills[&b].elapsed, 5); + assert_eq!(h.time_slices[0].skills.get(b).unwrap().elapsed, 0); + assert_eq!(h.time_slices[end].skills.get(b).unwrap().elapsed, 5); - h.convergence(ITERATIONS, EPSILON, false); + h.converge().unwrap(); assert_ulps_eq!( - h.batches[0].skills[&b].posterior(), - h.batches[end].skills[&b].posterior(), + h.time_slices[0].skills.get(b).unwrap().posterior(), + h.time_slices[end].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&c].posterior(), - h.batches[end].skills[&c].posterior(), + h.time_slices[0].skills.get(c).unwrap().posterior(), + h.time_slices[end].skills.get(c).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&c].posterior(), - h.batches[0].skills[&b].posterior(), + h.time_slices[0].skills.get(c).unwrap().posterior(), + h.time_slices[0].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); - // --------------------------------------- + // second scenario: team-0 wins (empty results in old API), different composition order - let composition = vec![ - vec![vec![a], vec![b]], - vec![vec![c], vec![a]], - vec![vec![b], vec![c]], - ]; - - let mut h = History::builder() + let mut h2: History = History::builder() .mu(0.0) .sigma(2.0) .beta(1.0) - .gamma(0.0) + .drift(ConstantDrift(0.0)) .build(); - h.add_events(composition.clone(), vec![], vec![0, 10, 20], vec![]); + let events = make_events_1v1( + &[("a", "b"), ("c", "a"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(0, 2), + Outcome::winner(0, 2), + ], + &[0, 10, 20], + ); + h2.add_events(events).unwrap(); + h2.converge().unwrap(); - h.convergence(ITERATIONS, EPSILON, false); + let a = h2.keys.get("a").unwrap(); + let b = h2.keys.get("b").unwrap(); + let c = h2.keys.get("c").unwrap(); - h.add_events(composition, vec![], vec![15, 10, 0], vec![]); + let events2 = make_events_1v1( + &[("a", "b"), ("c", "a"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(0, 2), + Outcome::winner(0, 2), + ], + &[15, 10, 0], + ); + h2.add_events(events2).unwrap(); - assert_eq!(h.batches.len(), 4); + assert_eq!(h2.time_slices.len(), 4); assert_eq!( - h.batches + h2.time_slices .iter() - .map(|batch| batch.events.len()) + .map(|ts| ts.events.len()) .collect::>(), vec![2, 2, 1, 1] ); assert_eq!( - h.batches + h2.time_slices .iter() .map(|b| b.get_composition()) .collect::>(), @@ -1177,7 +1484,7 @@ mod tests { ); assert_eq!( - h.batches + h2.time_slices .iter() .map(|b| b.get_results()) .collect::>(), @@ -1189,88 +1496,133 @@ mod tests { ] ); - let end = h.batches.len() - 1; + let end = h2.time_slices.len() - 1; - assert_eq!(h.batches[0].skills[&c].elapsed, 0); - assert_eq!(h.batches[end].skills[&c].elapsed, 10); + assert_eq!(h2.time_slices[0].skills.get(c).unwrap().elapsed, 0); + assert_eq!(h2.time_slices[end].skills.get(c).unwrap().elapsed, 10); - assert_eq!(h.batches[0].skills[&a].elapsed, 0); - assert_eq!(h.batches[2].skills[&a].elapsed, 5); + assert_eq!(h2.time_slices[0].skills.get(a).unwrap().elapsed, 0); + assert_eq!(h2.time_slices[2].skills.get(a).unwrap().elapsed, 5); - assert_eq!(h.batches[0].skills[&b].elapsed, 0); - assert_eq!(h.batches[end].skills[&b].elapsed, 5); + assert_eq!(h2.time_slices[0].skills.get(b).unwrap().elapsed, 0); + assert_eq!(h2.time_slices[end].skills.get(b).unwrap().elapsed, 5); - h.convergence(ITERATIONS, EPSILON, false); + h2.converge().unwrap(); assert_ulps_eq!( - h.batches[0].skills[&b].posterior(), - h.batches[end].skills[&b].posterior(), + h2.time_slices[0].skills.get(b).unwrap().posterior(), + h2.time_slices[end].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&c].posterior(), - h.batches[end].skills[&c].posterior(), + h2.time_slices[0].skills.get(c).unwrap().posterior(), + h2.time_slices[end].skills.get(c).unwrap().posterior(), epsilon = 1e-6 ); assert_ulps_eq!( - h.batches[0].skills[&c].posterior(), - h.batches[0].skills[&b].posterior(), + h2.time_slices[0].skills.get(c).unwrap().posterior(), + h2.time_slices[0].skills.get(b).unwrap().posterior(), epsilon = 1e-6 ); } #[test] fn test_1vs1_weighted() { - let mut index_map = IndexMap::new(); - - let a = index_map.get_or_create("a"); - let b = index_map.get_or_create("b"); - - let composition = vec![vec![vec![a], vec![b]], vec![vec![b], vec![a]]]; - let weights = vec![vec![vec![5.0], vec![4.0]], vec![vec![5.0], vec![4.0]]]; - - let mut h = History::builder() + let mut h: History = History::builder() .mu(2.0) .sigma(6.0) .beta(1.0) - .gamma(0.0) - .time(false) + .drift(ConstantDrift(0.0)) .build(); - h.add_events(composition, vec![], vec![], weights); + // empty results in old API = team 0 wins: a wins event 1, b wins event 2 + let events: Vec> = vec![ + Event { + time: 1, + teams: smallvec![ + Team::with_members([Member::new("a").with_weight(5.0)]), + Team::with_members([Member::new("b").with_weight(4.0)]), + ], + outcome: Outcome::winner(0, 2), + }, + Event { + time: 2, + teams: smallvec![ + Team::with_members([Member::new("b").with_weight(5.0)]), + Team::with_members([Member::new("a").with_weight(4.0)]), + ], + outcome: Outcome::winner(0, 2), + }, + ]; + h.add_events(events).unwrap(); - let lc = h.learning_curves(); + let lc_a = h.learning_curve("a"); + let lc_b = h.learning_curve("b"); assert_ulps_eq!( - lc[&a][0].1, + lc_a[0].1, Gaussian::from_ms(5.537659, 4.758722), epsilon = 1e-6 ); assert_ulps_eq!( - lc[&b][0].1, + lc_b[0].1, Gaussian::from_ms(-0.830127, 5.239568), epsilon = 1e-6 ); assert_ulps_eq!( - lc[&a][1].1, + lc_a[1].1, Gaussian::from_ms(1.792277, 4.099566), epsilon = 1e-6 ); assert_ulps_eq!( - lc[&b][1].1, + lc_b[1].1, Gaussian::from_ms(4.845533, 3.747616), epsilon = 1e-6 ); - h.convergence(ITERATIONS, EPSILON, false); + h.converge().unwrap(); - let lc = h.learning_curves(); + let lc_a = h.learning_curve("a"); + let lc_b = h.learning_curve("b"); - assert_ulps_eq!(lc[&a][0].1, lc[&a][0].1, epsilon = 1e-6); - assert_ulps_eq!(lc[&b][0].1, lc[&a][0].1, epsilon = 1e-6); - assert_ulps_eq!(lc[&a][1].1, lc[&a][0].1, epsilon = 1e-6); - assert_ulps_eq!(lc[&b][1].1, lc[&a][0].1, epsilon = 1e-6); + assert_ulps_eq!(lc_a[0].1, lc_a[0].1, epsilon = 1e-6); + assert_ulps_eq!(lc_b[0].1, lc_a[0].1, epsilon = 1e-6); + assert_ulps_eq!(lc_a[1].1, lc_a[0].1, epsilon = 1e-6); + assert_ulps_eq!(lc_b[1].1, lc_a[0].1, epsilon = 1e-6); + } + + #[test] + fn test_converge_returns_report() { + use crate::ConvergenceOptions; + + let mut h: History = History::builder() + .mu(0.0) + .sigma(2.0) + .beta(1.0) + .drift(ConstantDrift(0.0)) + .convergence(ConvergenceOptions { + max_iter: 30, + epsilon: 1e-6, + }) + .build(); + + let events = make_events_1v1( + &[("a", "b"), ("a", "c"), ("b", "c")], + &[ + Outcome::winner(0, 2), + Outcome::winner(1, 2), + Outcome::winner(0, 2), + ], + &[1, 2, 3], + ); + h.add_events(events).unwrap(); + + let report = h.converge().unwrap(); + assert!(report.converged); + assert!(report.iterations > 0); + assert!(report.iterations < 30); + assert!(report.final_step.0 <= 1e-6); } } diff --git a/src/key_table.rs b/src/key_table.rs new file mode 100644 index 0000000..9ddd3ae --- /dev/null +++ b/src/key_table.rs @@ -0,0 +1,72 @@ +use std::{ + borrow::{Borrow, ToOwned}, + collections::HashMap, + hash::Hash, +}; + +use crate::Index; + +/// Maps user keys to internal `Index` handles. +/// +/// Renamed from the former `IndexMap` to avoid colliding with the `indexmap` +/// 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(HashMap); + +impl KeyTable +where + K: Eq + Hash, +{ + pub fn new() -> Self { + Self(HashMap::new()) + } + + pub fn get(&self, k: &Q) -> Option + where + K: Borrow, + { + self.0.get(k).cloned() + } + + pub fn get_or_create>(&mut self, k: &Q) -> Index + where + K: Borrow, + { + if let Some(idx) = self.0.get(k) { + *idx + } else { + let idx = Index::from(self.0.len()); + self.0.insert(k.to_owned(), idx); + idx + } + } + + pub fn key(&self, idx: Index) -> Option<&K> { + self.0 + .iter() + .find(|&(_, value)| *value == idx) + .map(|(key, _)| key) + } + + pub fn keys(&self) -> impl Iterator { + self.0.keys() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl Default for KeyTable +where + K: Eq + Hash, +{ + fn default() -> Self { + KeyTable::new() + } +} diff --git a/src/lib.rs b/src/lib.rs index 7b45803..e6c7d41 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,30 +1,49 @@ use std::{ - borrow::{Borrow, ToOwned}, cmp::Reverse, - collections::HashMap, f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2}, - hash::Hash, }; -pub mod agent; #[cfg(feature = "approx")] mod approx; -pub mod batch; +pub(crate) mod arena; +mod time; +mod time_slice; +pub use time_slice::TimeSlice; +mod competitor; +mod convergence; pub mod drift; +mod error; +mod event; +mod event_builder; +pub(crate) mod factor; +pub mod factors; mod game; pub mod gaussian; mod history; +mod key_table; mod matrix; -mod message; -pub mod player; +mod observer; +mod outcome; +mod rating; +pub(crate) mod schedule; +pub mod storage; +pub use competitor::Competitor; +pub use convergence::{ConvergenceOptions, ConvergenceReport}; pub use drift::{ConstantDrift, Drift}; -pub use game::Game; +pub use error::InferenceError; +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 key_table::KeyTable; use matrix::Matrix; -use message::DiffMessage; -pub use player::Player; +pub use observer::{NullObserver, Observer}; +pub use outcome::Outcome; +pub use rating::Rating; +pub use schedule::ScheduleReport; +pub use time::{Time, Untimed}; pub const BETA: f64 = 1.0; pub const MU: f64 = 0.0; @@ -49,61 +68,6 @@ impl From for Index { } } -pub struct IndexMap(HashMap); - -impl IndexMap -where - K: Eq + Hash, -{ - pub fn new() -> Self { - Self(HashMap::new()) - } - - pub fn get(&self, k: &Q) -> Option - where - K: Borrow, - Q: Hash + Eq + ToOwned, - { - self.0.get(k).cloned() - } - - pub fn get_or_create(&mut self, k: &Q) -> Index - where - K: Borrow, - Q: Hash + Eq + ToOwned, - { - if let Some(idx) = self.0.get(k) { - *idx - } else { - let idx = Index::from(self.0.len()); - - self.0.insert(k.to_owned(), idx); - - idx - } - } - - pub fn key(&self, idx: Index) -> Option<&K> { - self.0 - .iter() - .find(|&(_, value)| *value == idx) - .map(|(key, _)| key) - } - - pub fn keys(&self) -> impl Iterator { - self.0.keys() - } -} - -impl Default for IndexMap -where - K: Eq + Hash, -{ - fn default() -> Self { - IndexMap::new() - } -} - fn erfc(x: f64) -> f64 { let z = x.abs(); let t = 1.0 / (1.0 + z / 2.0); @@ -158,7 +122,7 @@ fn compute_margin(p_draw: f64, sd: f64) -> f64 { ppf(0.5 - p_draw / 2.0, 0.0, sd).abs() } -fn cdf(x: f64, mu: f64, sigma: f64) -> f64 { +pub(crate) fn cdf(x: f64, mu: f64, sigma: f64) -> f64 { let z = -(x - mu) / (sigma * SQRT_2); 0.5 * erfc(z) @@ -203,9 +167,9 @@ fn trunc(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) { } pub(crate) fn approx(n: Gaussian, margin: f64, tie: bool) -> Gaussian { - let (mu, sigma) = trunc(n.mu, n.sigma, margin, tie); + let (mu, sigma) = trunc(n.mu(), n.sigma(), margin, tie); - Gaussian { mu, sigma } + Gaussian::from_ms(mu, sigma) } pub(crate) fn tuple_max(v1: (f64, f64), v2: (f64, f64)) -> (f64, f64) { @@ -219,39 +183,18 @@ pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool { t.0 > e || t.1 > e } -pub(crate) fn sort_perm(x: &[f64], reverse: bool) -> Vec { - let mut v = x.iter().enumerate().collect::>(); +pub(crate) fn sort_time(xs: &[T], reverse: bool) -> Vec { + let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect(); if reverse { - v.sort_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap()); + x.sort_by_key(|&(_, t)| Reverse(t)); } else { - v.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()); - } - - v.into_iter().map(|(i, _)| i).collect() -} - -pub(crate) fn sort_time(xs: &[i64], reverse: bool) -> Vec { - let mut x = xs.iter().enumerate().collect::>(); - - if reverse { - x.sort_by_key(|&(_, x)| Reverse(x)); - } else { - x.sort_by_key(|&(_, x)| x); + x.sort_by_key(|&(_, t)| t); } x.into_iter().map(|(i, _)| i).collect() } -pub(crate) fn evidence(d: &[DiffMessage], margin: &[f64], tie: &[bool], e: usize) -> f64 { - if tie[e] { - cdf(margin[e], d[e].prior.mu, d[e].prior.sigma) - - cdf(-margin[e], d[e].prior.mu, d[e].prior.sigma) - } else { - 1.0 - cdf(margin[e], d[e].prior.mu, d[e].prior.sigma) - } -} - /// Calculates the match quality of the given rating groups. A result is the draw probability in the association pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 { let flatten_ratings = rating_groups @@ -266,13 +209,13 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 { let mut mean_matrix = Matrix::new(length, 1); for (i, rating) in flatten_ratings.iter().enumerate() { - mean_matrix[(i, 0)] = rating.mu; + mean_matrix[(i, 0)] = rating.mu(); } let mut variance_matrix = Matrix::new(length, length); for (i, rating) in flatten_ratings.iter().enumerate() { - variance_matrix[(i, i)] = rating.sigma.powi(2); + variance_matrix[(i, i)] = rating.sigma().powi(2); } let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length); @@ -320,14 +263,9 @@ mod tests { use super::*; - #[test] - fn test_sort_perm() { - assert_eq!(sort_perm(&[0.0, 1.0, 2.0, 0.0], true), vec![2, 1, 0, 3]); - } - #[test] fn test_sort_time() { - assert_eq!(sort_time(&[0, 1, 2, 0], true), vec![2, 1, 0, 3]); + assert_eq!(sort_time(&[0i64, 1, 2, 0], true), vec![2, 1, 0, 3]); } #[test] diff --git a/src/message.rs b/src/message.rs deleted file mode 100644 index c6fd9bc..0000000 --- a/src/message.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::{N_INF, gaussian::Gaussian}; - -pub(crate) struct TeamMessage { - pub(crate) prior: Gaussian, - pub(crate) likelihood_lose: Gaussian, - pub(crate) likelihood_win: Gaussian, - pub(crate) likelihood_draw: Gaussian, -} - -impl TeamMessage { - /* - pub(crate) fn p(&self) -> Gaussian { - self.prior * self.likelihood_lose * self.likelihood_win * self.likelihood_draw - } - */ - - #[inline] - pub(crate) fn posterior_win(&self) -> Gaussian { - self.prior * self.likelihood_lose * self.likelihood_draw - } - - #[inline] - pub(crate) fn posterior_lose(&self) -> Gaussian { - self.prior * self.likelihood_win * self.likelihood_draw - } - - #[inline] - pub(crate) fn likelihood(&self) -> Gaussian { - self.likelihood_win * self.likelihood_lose * self.likelihood_draw - } -} - -impl Default for TeamMessage { - fn default() -> Self { - Self { - prior: N_INF, - likelihood_lose: N_INF, - likelihood_win: N_INF, - likelihood_draw: N_INF, - } - } -} - -/* -pub(crate) struct DrawMessage { - pub(crate) prior: Gaussian, - pub(crate) prior_team: Gaussian, - pub(crate) likelihood_lose: Gaussian, - pub(crate) likelihood_win: Gaussian, -} - -impl DrawMessage { - pub(crate) fn p(&self) -> Gaussian { - self.prior_team * self.likelihood_lose * self.likelihood_win - } - - pub(crate) fn posterior_win(&self) -> Gaussian { - self.prior_team * self.likelihood_lose - } - - pub(crate) fn posterior_lose(&self) -> Gaussian { - self.prior_team * self.likelihood_win - } - - pub(crate) fn likelihood(&self) -> Gaussian { - self.likelihood_win * self.likelihood_lose - } -} -*/ -pub(crate) struct DiffMessage { - pub(crate) prior: Gaussian, - pub(crate) likelihood: Gaussian, -} - -impl DiffMessage { - /* - pub(crate) fn p(&self) -> Gaussian { - self.prior * self.likelihood - } - */ -} diff --git a/src/observer.rs b/src/observer.rs new file mode 100644 index 0000000..223948b --- /dev/null +++ b/src/observer.rs @@ -0,0 +1,48 @@ +//! Observer trait for progress reporting during convergence. +//! +//! Replaces the old `verbose: bool` + `println!` path. Callers wire in any +//! observer that implements the trait; default methods are no-ops so users +//! override only what they need. + +use crate::time::Time; + +/// Receives progress callbacks during `History::converge`. +/// +/// All methods have default no-op implementations; implement only what's +/// interesting. Send/Sync is NOT required in T2 (added in T3 along with +/// Rayon support). +pub trait Observer { + /// Called after each convergence iteration across the whole history. + fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {} + + /// Called after each time slice is processed within an iteration. + fn on_batch_processed(&self, _time: &T, _slice_idx: usize, _n_events: usize) {} + + /// Called once when convergence completes (or max iters is reached). + fn on_converged(&self, _iters: usize, _final_step: (f64, f64), _converged: bool) {} +} + +/// ZST no-op observer; the default when none is configured. +#[derive(Copy, Clone, Debug, Default)] +pub struct NullObserver; + +impl Observer for NullObserver {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn null_observer_compiles_for_i64() { + let o = NullObserver; + >::on_iteration_end(&o, 1, (0.0, 0.0)); + >::on_converged(&o, 5, (1e-6, 1e-6), true); + } + + #[test] + fn null_observer_compiles_for_untimed() { + use crate::Untimed; + let o = NullObserver; + >::on_iteration_end(&o, 1, (0.0, 0.0)); + } +} diff --git a/src/outcome.rs b/src/outcome.rs new file mode 100644 index 0000000..a57c26d --- /dev/null +++ b/src/outcome.rs @@ -0,0 +1,87 @@ +//! Outcome of a match. +//! +//! In T2, only `Ranked` is supported; `Scored` will be added together with +//! `MarginFactor` in T4. The enum is `#[non_exhaustive]` so adding `Scored` +//! is non-breaking for downstream `match` expressions. + +use smallvec::SmallVec; + +/// 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. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum Outcome { + Ranked(SmallVec<[u32; 4]>), +} + +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>(ranks: I) -> Self { + Self::Ranked(ranks.into_iter().collect()) + } + + pub fn team_count(&self) -> usize { + match self { + Self::Ranked(r) => r.len(), + } + } + + #[allow(dead_code)] + pub(crate) fn as_ranks(&self) -> &[u32] { + match self { + Self::Ranked(r) => r, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn winner_two_teams() { + let o = Outcome::winner(0, 2); + assert_eq!(o.as_ranks(), &[0u32, 1]); + assert_eq!(o.team_count(), 2); + } + + #[test] + fn winner_three_teams_second_wins() { + let o = Outcome::winner(1, 3); + assert_eq!(o.as_ranks(), &[1u32, 0, 1]); + } + + #[test] + fn draw_three_teams() { + let o = Outcome::draw(3); + assert_eq!(o.as_ranks(), &[0u32, 0, 0]); + } + + #[test] + fn ranking_from_iter() { + let o = Outcome::ranking([2, 0, 1]); + assert_eq!(o.as_ranks(), &[2u32, 0, 1]); + } + + #[test] + #[should_panic(expected = "winner index 2 out of range")] + fn winner_out_of_range_panics() { + let _ = Outcome::winner(2, 2); + } +} diff --git a/src/player.rs b/src/player.rs deleted file mode 100644 index c4ebe33..0000000 --- a/src/player.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::{ - BETA, GAMMA, - drift::{ConstantDrift, Drift}, - gaussian::Gaussian, -}; - -#[derive(Clone, Copy, Debug)] -pub struct Player { - pub(crate) prior: Gaussian, - pub(crate) beta: f64, - pub(crate) drift: D, -} - -impl Player { - pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self { - Self { prior, beta, drift } - } - - pub(crate) fn performance(&self) -> Gaussian { - self.prior.forget(self.beta.powi(2)) - } -} - -impl Default for Player { - fn default() -> Self { - Self { - prior: Gaussian::default(), - beta: BETA, - drift: ConstantDrift(GAMMA), - } - } -} diff --git a/src/rating.rs b/src/rating.rs new file mode 100644 index 0000000..3530e24 --- /dev/null +++ b/src/rating.rs @@ -0,0 +1,46 @@ +use std::marker::PhantomData; + +use crate::{ + BETA, GAMMA, + drift::{ConstantDrift, Drift}, + gaussian::Gaussian, + time::Time, +}; + +/// Static rating configuration: prior skill, performance noise `beta`, drift. +/// +/// Renamed from `Player` in T2; `Rating` better describes the data +/// (a configuration) vs. a person (who's a `Competitor` with state). +#[derive(Clone, Copy, Debug)] +pub struct Rating = ConstantDrift> { + pub(crate) prior: Gaussian, + pub(crate) beta: f64, + pub(crate) drift: D, + pub(crate) _time: PhantomData, +} + +impl> Rating { + pub fn new(prior: Gaussian, beta: f64, drift: D) -> Self { + Self { + prior, + beta, + drift, + _time: PhantomData, + } + } + + pub(crate) fn performance(&self) -> Gaussian { + self.prior.forget(self.beta.powi(2)) + } +} + +impl Default for Rating { + fn default() -> Self { + Self { + prior: Gaussian::default(), + beta: BETA, + drift: ConstantDrift(GAMMA), + _time: PhantomData, + } + } +} diff --git a/src/schedule.rs b/src/schedule.rs new file mode 100644 index 0000000..2c98fc1 --- /dev/null +++ b/src/schedule.rs @@ -0,0 +1,126 @@ +//! Schedule trait and built-in implementations. +//! +//! A schedule drives factor propagation to convergence. The default +//! `EpsilonOrMax` performs one TeamSum sweep (setup) then alternating +//! forward/backward sweeps over the iterating factors until the max +//! delta drops below epsilon or `max` iterations is reached. + +use crate::factor::{BuiltinFactor, Factor, VarStore}; + +/// Result returned by a `Schedule::run` call. +#[derive(Debug, Clone, Copy)] +pub struct ScheduleReport { + pub iterations: usize, + pub final_step: (f64, f64), + pub converged: bool, +} + +/// Drives factor propagation to convergence. +pub trait Schedule { + fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport; +} + +/// Default schedule: sweep forward then backward until step ≤ eps or iter == max. +/// +/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the +/// same factor layout (TeamSums first, then alternating RankDiff/Trunc pairs). +#[derive(Debug, Clone, Copy)] +pub struct EpsilonOrMax { + pub eps: f64, + pub max: usize, +} + +impl Default for EpsilonOrMax { + fn default() -> Self { + // Matches today's hard-coded tolerance and iteration cap. + Self { eps: 1e-6, max: 10 } + } +} + +impl Schedule for EpsilonOrMax { + fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport { + // Partition: leading run of TeamSum factors run exactly once (setup). + let n_setup = factors + .iter() + .position(|f| !matches!(f, BuiltinFactor::TeamSum(_))) + .unwrap_or(factors.len()); + + for f in factors[..n_setup].iter_mut() { + f.propagate(vars); + } + + let mut iterations = 0; + let mut final_step = (f64::INFINITY, f64::INFINITY); + let mut converged = false; + + if n_setup < factors.len() { + for _ in 0..self.max { + let mut step = (0.0_f64, 0.0_f64); + + // Forward sweep over iterating factors. + for f in factors[n_setup..].iter_mut() { + let d = f.propagate(vars); + step.0 = step.0.max(d.0); + step.1 = step.1.max(d.1); + } + + // Backward sweep. + for f in factors[n_setup..].iter_mut().rev() { + let d = f.propagate(vars); + step.0 = step.0.max(d.0); + step.1 = step.1.max(d.1); + } + + iterations += 1; + final_step = step; + + if step.0 <= self.eps && step.1 <= self.eps { + converged = true; + break; + } + } + } + + ScheduleReport { + iterations, + final_step, + converged, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{N_INF, factor::team_sum::TeamSumFactor, gaussian::Gaussian}; + + #[test] + fn schedule_runs_setup_factors_once() { + // Single TeamSum factor; schedule should propagate it exactly once and report 0 iterations. + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor { + inputs: vec![(Gaussian::from_ms(5.0, 1.0), 1.0)], + out, + })]; + let schedule = EpsilonOrMax::default(); + let report = schedule.run(&mut factors, &mut vars); + assert_eq!(report.iterations, 0); + // The team-perf var should hold the sum. + let result = vars.get(out); + assert!((result.mu() - 5.0).abs() < 1e-12); + } + + #[test] + fn report_marks_converged_when_no_iterating_factors() { + // No iterating factors → 0 iterations, converged stays false (loop never ran). + let mut vars = VarStore::new(); + let out = vars.alloc(N_INF); + let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor { + inputs: vec![(Gaussian::from_ms(0.0, 1.0), 1.0)], + out, + })]; + let report = EpsilonOrMax::default().run(&mut factors, &mut vars); + assert_eq!(report.iterations, 0); + } +} diff --git a/src/storage/competitor_store.rs b/src/storage/competitor_store.rs new file mode 100644 index 0000000..25f72aa --- /dev/null +++ b/src/storage/competitor_store.rs @@ -0,0 +1,127 @@ +use crate::{Index, competitor::Competitor, drift::Drift, time::Time}; + +/// Dense Vec-backed store for competitor state in History. +/// +/// Indexed directly by Index.0, eliminating HashMap hashing in the +/// forward/backward sweep. Uses `Vec>>` so slots can be +/// absent without an explicit present mask. +#[derive(Debug)] +pub struct CompetitorStore = crate::drift::ConstantDrift> { + competitors: Vec>>, + n_present: usize, +} + +impl> Default for CompetitorStore { + fn default() -> Self { + Self { + competitors: Vec::new(), + n_present: 0, + } + } +} + +impl> CompetitorStore { + pub fn new() -> Self { + Self::default() + } + + fn ensure_capacity(&mut self, idx: usize) { + if idx >= self.competitors.len() { + self.competitors.resize_with(idx + 1, || None); + } + } + + pub fn insert(&mut self, idx: Index, competitor: Competitor) { + self.ensure_capacity(idx.0); + if self.competitors[idx.0].is_none() { + self.n_present += 1; + } + self.competitors[idx.0] = Some(competitor); + } + + pub fn get(&self, idx: Index) -> Option<&Competitor> { + self.competitors.get(idx.0).and_then(|slot| slot.as_ref()) + } + + pub fn get_mut(&mut self, idx: Index) -> Option<&mut Competitor> { + self.competitors + .get_mut(idx.0) + .and_then(|slot| slot.as_mut()) + } + + pub fn contains(&self, idx: Index) -> bool { + self.get(idx).is_some() + } + + pub fn len(&self) -> usize { + self.n_present + } + + pub fn is_empty(&self) -> bool { + self.n_present == 0 + } + + pub fn iter(&self) -> impl Iterator)> { + self.competitors + .iter() + .enumerate() + .filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a))) + } + + pub fn iter_mut(&mut self) -> impl Iterator)> { + self.competitors + .iter_mut() + .enumerate() + .filter_map(|(i, slot)| slot.as_mut().map(|a| (Index(i), a))) + } + + pub fn values_mut(&mut self) -> impl Iterator> { + self.competitors.iter_mut().filter_map(|s| s.as_mut()) + } +} + +impl> std::ops::Index for CompetitorStore { + type Output = Competitor; + fn index(&self, idx: Index) -> &Competitor { + self.get(idx).expect("competitor not found at index") + } +} + +impl> std::ops::IndexMut for CompetitorStore { + fn index_mut(&mut self, idx: Index) -> &mut Competitor { + self.get_mut(idx).expect("competitor not found at index") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{competitor::Competitor, drift::ConstantDrift}; + + #[test] + fn insert_then_get() { + let mut store: CompetitorStore = CompetitorStore::new(); + let idx = Index(7); + store.insert(idx, Competitor::default()); + assert!(store.contains(idx)); + assert_eq!(store.len(), 1); + assert!(store.get(idx).is_some()); + } + + #[test] + fn iter_in_index_order() { + let mut store: CompetitorStore = CompetitorStore::new(); + store.insert(Index(2), Competitor::default()); + store.insert(Index(0), Competitor::default()); + store.insert(Index(5), Competitor::default()); + let keys: Vec = store.iter().map(|(i, _)| i).collect(); + assert_eq!(keys, vec![Index(0), Index(2), Index(5)]); + } + + #[test] + fn index_operator_works() { + let mut store: CompetitorStore = CompetitorStore::new(); + store.insert(Index(3), Competitor::default()); + let _ = &store[Index(3)]; + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..1d91129 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,5 @@ +mod competitor_store; +mod skill_store; + +pub use competitor_store::CompetitorStore; +pub(crate) use skill_store::SkillStore; diff --git a/src/storage/skill_store.rs b/src/storage/skill_store.rs new file mode 100644 index 0000000..0c3632f --- /dev/null +++ b/src/storage/skill_store.rs @@ -0,0 +1,130 @@ +use crate::{Index, time_slice::Skill}; + +/// Dense Vec-backed store for per-agent skill state within a TimeSlice. +/// +/// Indexed directly by Index.0, eliminating HashMap hashing in the inner +/// convergence loop. Uses a parallel `present` mask so iteration skips +/// absent slots without incurring per-slot Option overhead in the hot path. +#[derive(Debug, Default)] +pub struct SkillStore { + skills: Vec, + present: Vec, + n_present: usize, +} + +impl SkillStore { + pub fn new() -> Self { + Self::default() + } + + fn ensure_capacity(&mut self, idx: usize) { + if idx >= self.skills.len() { + self.skills.resize_with(idx + 1, Skill::default); + self.present.resize(idx + 1, false); + } + } + + pub fn insert(&mut self, idx: Index, skill: Skill) { + self.ensure_capacity(idx.0); + if !self.present[idx.0] { + self.n_present += 1; + } + self.skills[idx.0] = skill; + self.present[idx.0] = true; + } + + pub fn get(&self, idx: Index) -> Option<&Skill> { + if idx.0 < self.present.len() && self.present[idx.0] { + Some(&self.skills[idx.0]) + } else { + None + } + } + + 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]) + } else { + None + } + } + + #[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 { + self.present.iter().enumerate().filter_map(|(i, &p)| { + if p { + Some((Index(i), &self.skills[i])) + } else { + None + } + }) + } + + pub fn iter_mut(&mut self) -> impl Iterator { + self.skills + .iter_mut() + .zip(self.present.iter()) + .enumerate() + .filter_map(|(i, (s, &p))| if p { Some((Index(i), s)) } else { None }) + } + + pub fn keys(&self) -> impl Iterator + '_ { + self.present + .iter() + .enumerate() + .filter_map(|(i, &p)| if p { Some(Index(i)) } else { None }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_then_get() { + let mut store = SkillStore::new(); + let idx = Index(3); + store.insert(idx, Skill::default()); + assert!(store.contains(idx)); + assert_eq!(store.len(), 1); + assert!(store.get(idx).is_some()); + } + + #[test] + fn missing_returns_none() { + let store = SkillStore::new(); + assert!(store.get(Index(0)).is_none()); + assert!(!store.contains(Index(42))); + } + + #[test] + fn iter_skips_absent_slots() { + let mut store = SkillStore::new(); + store.insert(Index(0), Skill::default()); + store.insert(Index(5), Skill::default()); + let keys: Vec = store.keys().collect(); + assert_eq!(keys, vec![Index(0), Index(5)]); + } + + #[test] + fn double_insert_does_not_double_count() { + let mut store = SkillStore::new(); + store.insert(Index(2), Skill::default()); + store.insert(Index(2), Skill::default()); + assert_eq!(store.len(), 1); + } +} diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..813ff39 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,54 @@ +//! Generic time axis for `History`. +//! +//! Users pick the `Time` type based on their domain: `Untimed` when no +//! time axis is meaningful, `i64` for integer day/second timestamps. +//! Additional impls can be added behind feature flags. + +/// A timestamp on the global ordering axis. +/// +/// Must be `Ord + Copy` so slices can sort events, and `'static` so +/// `History` can store it by value without lifetimes. +pub trait Time: Copy + Ord + 'static { + /// How much time elapsed between `self` and `later`. + /// + /// Used by `Drift::variance_delta` to compute skill drift. Returning + /// zero means no drift accumulates between the two points. Return value + /// must be non-negative for `self <= later`. + fn elapsed_to(&self, later: &Self) -> i64; +} + +/// Zero-sized type representing "no time axis." +/// +/// Used as the default `Time` when events are unordered. Elapsed is always 0, +/// so no drift accumulates across slices. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Untimed; + +impl Time for Untimed { + fn elapsed_to(&self, _later: &Self) -> i64 { + 0 + } +} + +impl Time for i64 { + fn elapsed_to(&self, later: &Self) -> i64 { + later - self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn untimed_elapsed_is_zero() { + assert_eq!(Untimed.elapsed_to(&Untimed), 0); + } + + #[test] + fn i64_elapsed_is_difference() { + assert_eq!(5i64.elapsed_to(&10), 5); + assert_eq!(10i64.elapsed_to(&5), -5); + assert_eq!(0i64.elapsed_to(&0), 0); + } +} diff --git a/src/batch.rs b/src/time_slice.rs similarity index 73% rename from src/batch.rs rename to src/time_slice.rs index 4e2ebf4..c1d48fb 100644 --- a/src/batch.rs +++ b/src/time_slice.rs @@ -1,7 +1,18 @@ +//! A single time step's worth of events. +//! +//! Renamed from `Batch` in T2. + use std::collections::HashMap; use crate::{ - Index, N_INF, agent::Agent, drift::Drift, game::Game, gaussian::Gaussian, player::Player, + Index, N_INF, + arena::ScratchArena, + drift::Drift, + game::Game, + gaussian::Gaussian, + rating::Rating, + storage::{CompetitorStore, SkillStore}, + time::Time, tuple_gt, tuple_max, }; @@ -39,22 +50,22 @@ struct Item { } impl Item { - fn within_prior( + fn within_prior>( &self, online: bool, forward: bool, - skills: &HashMap, - agents: &HashMap>, - ) -> Player { - let r = &agents[&self.agent].player; - let skill = &skills[&self.agent]; + skills: &SkillStore, + agents: &CompetitorStore, + ) -> Rating { + let r = &agents[self.agent].rating; + let skill = skills.get(self.agent).unwrap(); if online { - Player::new(skill.online, r.beta, r.drift) + Rating::new(skill.online, r.beta, r.drift) } else if forward { - Player::new(skill.forward, r.beta, r.drift) + Rating::new(skill.forward, r.beta, r.drift) } else { - Player::new(skill.posterior() / self.likelihood, r.beta, r.drift) + Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift) } } } @@ -80,13 +91,13 @@ impl Event { .collect::>() } - pub(crate) fn within_priors( + pub(crate) fn within_priors>( &self, online: bool, forward: bool, - skills: &HashMap, - agents: &HashMap>, - ) -> Vec>> { + skills: &SkillStore, + agents: &CompetitorStore, + ) -> Vec>> { self.teams .iter() .map(|team| { @@ -100,29 +111,31 @@ impl Event { } #[derive(Debug)] -pub struct Batch { +pub struct TimeSlice { pub(crate) events: Vec, - pub(crate) skills: HashMap, - pub(crate) time: i64, + pub(crate) skills: SkillStore, + pub(crate) time: T, p_draw: f64, + arena: ScratchArena, } -impl Batch { - pub fn new(time: i64, p_draw: f64) -> Self { +impl TimeSlice { + pub fn new(time: T, p_draw: f64) -> Self { Self { events: Vec::new(), - skills: HashMap::new(), + skills: SkillStore::new(), time, p_draw, + arena: ScratchArena::new(), } } - pub fn add_events( + pub fn add_events>( &mut self, composition: Vec>>, results: Vec>, weights: Vec>>, - agents: &HashMap>, + agents: &CompetitorStore, ) { let mut unique = Vec::with_capacity(10); @@ -137,16 +150,16 @@ impl Batch { }); for idx in this_agent { - let elapsed = compute_elapsed(agents[&idx].last_time, self.time); + let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time); - if let Some(skill) = self.skills.get_mut(idx) { + if let Some(skill) = self.skills.get_mut(*idx) { skill.elapsed = elapsed; - skill.forward = agents[&idx].receive(elapsed); + skill.forward = agents[*idx].receive(&self.time); } else { self.skills.insert( *idx, Skill { - forward: agents[&idx].receive(elapsed), + forward: agents[*idx].receive(&self.time), elapsed, ..Default::default() }, @@ -204,23 +217,28 @@ impl Batch { pub(crate) fn posteriors(&self) -> HashMap { self.skills .iter() - .map(|(&idx, skill)| (idx, skill.posterior())) + .map(|(idx, skill)| (idx, skill.posterior())) .collect::>() } - pub fn iteration(&mut self, from: usize, agents: &HashMap>) { + pub fn iteration>(&mut self, from: usize, agents: &CompetitorStore) { for event in self.events.iter_mut().skip(from) { let teams = event.within_priors(false, false, &self.skills, agents); let result = event.outputs(); - let g = Game::new(teams, &result, &event.weights, self.p_draw); + let g = Game::ranked_with_arena( + teams, + &result, + &event.weights, + self.p_draw, + &mut self.arena, + ); for (t, team) in event.teams.iter_mut().enumerate() { for (i, item) in team.items.iter_mut().enumerate() { - self.skills.get_mut(&item.agent).unwrap().likelihood = - (self.skills[&item.agent].likelihood / item.likelihood) - * g.likelihoods[t][i]; - + let old_likelihood = self.skills.get(item.agent).unwrap().likelihood; + let new_likelihood = (old_likelihood / item.likelihood) * g.likelihoods[t][i]; + self.skills.get_mut(item.agent).unwrap().likelihood = new_likelihood; item.likelihood = g.likelihoods[t][i]; } } @@ -230,7 +248,7 @@ impl Batch { } #[allow(dead_code)] - pub(crate) fn convergence(&mut self, agents: &HashMap>) -> usize { + pub(crate) fn convergence>(&mut self, agents: &CompetitorStore) -> usize { let epsilon = 1e-6; let iterations = 20; @@ -255,56 +273,60 @@ impl Batch { } pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian { - let skill = &self.skills[agent]; - + let skill = self.skills.get(*agent).unwrap(); skill.forward * skill.likelihood } - pub(crate) fn backward_prior_out( + pub(crate) fn backward_prior_out>( &self, agent: &Index, - agents: &HashMap>, + agents: &CompetitorStore, ) -> Gaussian { - let skill = &self.skills[agent]; + let skill = self.skills.get(*agent).unwrap(); let n = skill.likelihood * skill.backward; - - n.forget(agents[agent].player.drift.variance_delta(skill.elapsed)) + n.forget( + agents[*agent] + .rating + .drift + .variance_for_elapsed(skill.elapsed), + ) } - pub(crate) fn new_backward_info(&mut self, agents: &HashMap>) { + pub(crate) fn new_backward_info>(&mut self, agents: &CompetitorStore) { for (agent, skill) in self.skills.iter_mut() { skill.backward = agents[agent].message; } - self.iteration(0, agents); } - pub(crate) fn new_forward_info(&mut self, agents: &HashMap>) { + pub(crate) fn new_forward_info>(&mut self, agents: &CompetitorStore) { for (agent, skill) in self.skills.iter_mut() { - skill.forward = agents[agent].receive(skill.elapsed); + skill.forward = agents[agent].receive_for_elapsed(skill.elapsed); } - self.iteration(0, agents); } - pub(crate) fn log_evidence( + pub(crate) fn log_evidence>( &self, online: bool, targets: &[Index], forward: bool, - agents: &HashMap>, + agents: &CompetitorStore, ) -> f64 { + // log_evidence is infrequent; a local arena avoids needing &mut self. + let mut arena = ScratchArena::new(); + if targets.is_empty() { if online || forward { self.events .iter() - .enumerate() - .map(|(_, event)| { - Game::new( + .map(|event| { + Game::ranked_with_arena( event.within_priors(online, forward, &self.skills, agents), &event.outputs(), &event.weights, self.p_draw, + &mut arena, ) .evidence .ln() @@ -325,11 +347,12 @@ impl Batch { .any(|item| targets.contains(&item.agent)) }) .map(|(_, event)| { - Game::new( + Game::ranked_with_arena( event.within_priors(online, forward, &self.skills, agents), &event.outputs(), &event.weights, self.p_draw, + &mut arena, ) .evidence .ln() @@ -377,14 +400,8 @@ impl Batch { } } -pub(crate) fn compute_elapsed(last_time: i64, actual_time: i64) -> i64 { - if last_time == i64::MIN { - 0 - } else if last_time == i64::MAX { - 1 - } else { - actual_time - last_time - } +pub(crate) fn compute_elapsed(last: Option<&T>, current: &T) -> i64 { + last.map(|l| l.elapsed_to(current).max(0)).unwrap_or(0) } #[cfg(test)] @@ -392,11 +409,14 @@ mod tests { use approx::assert_ulps_eq; use super::*; - use crate::{IndexMap, agent::Agent, drift::ConstantDrift, player::Player}; + use crate::{ + KeyTable, competitor::Competitor, drift::ConstantDrift, rating::Rating, + storage::CompetitorStore, + }; #[test] fn test_one_event_each() { - let mut index_map = IndexMap::new(); + let mut index_map = KeyTable::new(); let a = index_map.get_or_create("a"); let b = index_map.get_or_create("b"); @@ -405,13 +425,13 @@ mod tests { let e = index_map.get_or_create("e"); let f = index_map.get_or_create("f"); - let mut agents = HashMap::new(); + let mut agents: CompetitorStore = CompetitorStore::new(); for agent in [a, b, c, d, e, f] { agents.insert( agent, - Agent { - player: Player::new( + Competitor { + rating: Rating::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), @@ -421,9 +441,9 @@ mod tests { ); } - let mut batch = Batch::new(0, 0.0); + let mut time_slice = TimeSlice::new(0i64, 0.0); - batch.add_events( + time_slice.add_events( vec![ vec![vec![a], vec![b]], vec![vec![c], vec![d]], @@ -434,7 +454,7 @@ mod tests { &agents, ); - let post = batch.posteriors(); + let post = time_slice.posteriors(); assert_ulps_eq!( post[&a], @@ -467,12 +487,12 @@ mod tests { epsilon = 1e-6 ); - assert_eq!(batch.convergence(&agents), 1); + assert_eq!(time_slice.convergence(&agents), 1); } #[test] fn test_same_strength() { - let mut index_map = IndexMap::new(); + let mut index_map = KeyTable::new(); let a = index_map.get_or_create("a"); let b = index_map.get_or_create("b"); @@ -481,13 +501,13 @@ mod tests { let e = index_map.get_or_create("e"); let f = index_map.get_or_create("f"); - let mut agents = HashMap::new(); + let mut agents: CompetitorStore = CompetitorStore::new(); for agent in [a, b, c, d, e, f] { agents.insert( agent, - Agent { - player: Player::new( + Competitor { + rating: Rating::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), @@ -497,9 +517,9 @@ mod tests { ); } - let mut batch = Batch::new(0, 0.0); + let mut time_slice = TimeSlice::new(0i64, 0.0); - batch.add_events( + time_slice.add_events( vec![ vec![vec![a], vec![b]], vec![vec![a], vec![c]], @@ -510,7 +530,7 @@ mod tests { &agents, ); - let post = batch.posteriors(); + let post = time_slice.posteriors(); assert_ulps_eq!( post[&a], @@ -528,9 +548,9 @@ mod tests { epsilon = 1e-6 ); - assert!(batch.convergence(&agents) > 1); + assert!(time_slice.convergence(&agents) > 1); - let post = batch.posteriors(); + let post = time_slice.posteriors(); assert_ulps_eq!( post[&a], @@ -551,7 +571,7 @@ mod tests { #[test] fn test_add_events() { - let mut index_map = IndexMap::new(); + let mut index_map = KeyTable::new(); let a = index_map.get_or_create("a"); let b = index_map.get_or_create("b"); @@ -560,13 +580,13 @@ mod tests { let e = index_map.get_or_create("e"); let f = index_map.get_or_create("f"); - let mut agents = HashMap::new(); + let mut agents: CompetitorStore = CompetitorStore::new(); for agent in [a, b, c, d, e, f] { agents.insert( agent, - Agent { - player: Player::new( + Competitor { + rating: Rating::new( Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift(25.0 / 300.0), @@ -576,9 +596,9 @@ mod tests { ); } - let mut batch = Batch::new(0, 0.0); + let mut time_slice = TimeSlice::new(0i64, 0.0); - batch.add_events( + time_slice.add_events( vec![ vec![vec![a], vec![b]], vec![vec![a], vec![c]], @@ -589,9 +609,9 @@ mod tests { &agents, ); - batch.convergence(&agents); + time_slice.convergence(&agents); - let post = batch.posteriors(); + let post = time_slice.posteriors(); assert_ulps_eq!( post[&a], @@ -609,7 +629,7 @@ mod tests { epsilon = 1e-6 ); - batch.add_events( + time_slice.add_events( vec![ vec![vec![a], vec![b]], vec![vec![a], vec![c]], @@ -620,11 +640,11 @@ mod tests { &agents, ); - assert_eq!(batch.events.len(), 6); + assert_eq!(time_slice.events.len(), 6); - batch.convergence(&agents); + time_slice.convergence(&agents); - let post = batch.posteriors(); + let post = time_slice.posteriors(); assert_ulps_eq!( post[&a], diff --git a/tests/api_shape.rs b/tests/api_shape.rs new file mode 100644 index 0000000..676d568 --- /dev/null +++ b/tests/api_shape.rs @@ -0,0 +1,225 @@ +//! Tests for the new T2 public API surface: typed add_events(iter) and the +//! fluent event builder (added in Task 16). + +use smallvec::smallvec; +use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team}; + +#[test] +fn add_events_bulk_via_iter() { + let mut h = History::builder() + .mu(0.0) + .sigma(2.0) + .beta(1.0) + .p_draw(0.0) + .drift(ConstantDrift(0.0)) + .convergence(ConvergenceOptions { + max_iter: 30, + epsilon: 1e-6, + }) + .build(); + + let events: Vec> = vec![ + Event { + time: 1, + teams: smallvec![ + Team::with_members([Member::new("a")]), + Team::with_members([Member::new("b")]), + ], + outcome: Outcome::winner(0, 2), + }, + Event { + time: 2, + teams: smallvec![ + Team::with_members([Member::new("b")]), + Team::with_members([Member::new("c")]), + ], + outcome: Outcome::winner(0, 2), + }, + ]; + + h.add_events(events).unwrap(); + let report = h.converge().unwrap(); + assert!(report.converged); + assert!(h.lookup(&"a").is_some()); + assert!(h.lookup(&"b").is_some()); + assert!(h.lookup(&"c").is_some()); +} + +#[test] +fn add_events_draw() { + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .p_draw(0.25) + .drift(ConstantDrift(25.0 / 300.0)) + .build(); + + let events: Vec> = vec![Event { + time: 1, + teams: smallvec![ + Team::with_members([Member::new("alice")]), + Team::with_members([Member::new("bob")]), + ], + outcome: Outcome::draw(2), + }]; + h.add_events(events).unwrap(); + h.converge().unwrap(); +} + +#[test] +fn add_events_rejects_mismatched_outcome_ranks() { + use trueskill_tt::InferenceError; + let mut h: History = History::builder().build(); + let events: Vec> = vec![Event { + time: 1, + teams: smallvec![ + Team::with_members([Member::new("a")]), + Team::with_members([Member::new("b")]), + ], + outcome: Outcome::ranking([0, 1, 2]), // 3 ranks but 2 teams + }]; + let err = h.add_events(events).unwrap_err(); + assert!(matches!(err, InferenceError::MismatchedShape { .. })); +} + +#[test] +fn fluent_event_builder_basic() { + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .p_draw(0.0) + .build(); + + h.event(1) + .team(["alice", "bob"]) + .weights([1.0, 0.7]) + .team(["carol"]) + .ranking([1, 0]) + .commit() + .unwrap(); + + let report = h.converge().unwrap(); + assert!(report.converged); + assert!(h.lookup(&"alice").is_some()); + assert!(h.lookup(&"bob").is_some()); + assert!(h.lookup(&"carol").is_some()); +} + +#[test] +fn fluent_event_builder_winner_convenience() { + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .p_draw(0.0) + .build(); + + h.event(1) + .team(["alice"]) + .team(["bob"]) + .winner(0) + .commit() + .unwrap(); + h.converge().unwrap(); +} + +#[test] +fn fluent_event_builder_draw() { + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .p_draw(0.25) + .build(); + + h.event(1) + .team(["alice"]) + .team(["bob"]) + .draw() + .commit() + .unwrap(); + h.converge().unwrap(); +} + +#[test] +fn current_skill_and_learning_curve() { + use trueskill_tt::History; + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .p_draw(0.0) + .build(); + h.record_winner(&"a", &"b", 1).unwrap(); + h.record_winner(&"a", &"b", 2).unwrap(); + h.converge().unwrap(); + + let a = h.current_skill(&"a").unwrap(); + assert!(a.mu() > 25.0); + let b = h.current_skill(&"b").unwrap(); + assert!(b.mu() < 25.0); + + let a_curve = h.learning_curve(&"a"); + assert_eq!(a_curve.len(), 2); + assert_eq!(a_curve[0].0, 1); + assert_eq!(a_curve[1].0, 2); + + let all = h.learning_curves(); + assert_eq!(all.len(), 2); + assert!(all.contains_key("a")); + assert!(all.contains_key("b")); +} + +#[test] +fn log_evidence_total_vs_subset() { + use trueskill_tt::{ConstantDrift, History}; + let mut h = History::builder() + .mu(0.0) + .sigma(6.0) + .beta(1.0) + .p_draw(0.0) + .drift(ConstantDrift(0.0)) + .build(); + h.record_winner(&"a", &"b", 1).unwrap(); + h.record_winner(&"b", &"a", 2).unwrap(); + let total = h.log_evidence(); + let a_only = h.log_evidence_for(&[&"a"]); + assert!(total.is_finite()); + assert!(a_only.is_finite()); +} + +#[test] +fn predict_quality_two_teams() { + use trueskill_tt::History; + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .p_draw(0.0) + .build(); + h.record_winner(&"a", &"b", 1).unwrap(); + h.converge().unwrap(); + + let q = h.predict_quality(&[&[&"a"], &[&"b"]]); + assert!(q > 0.0 && q <= 1.0); +} + +#[test] +fn predict_outcome_two_teams_sums_to_one() { + use trueskill_tt::History; + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .p_draw(0.0) + .build(); + h.record_winner(&"a", &"b", 1).unwrap(); + h.converge().unwrap(); + + let p = h.predict_outcome(&[&[&"a"], &[&"b"]]); + assert_eq!(p.len(), 2); + assert!((p[0] + p[1] - 1.0).abs() < 1e-9); + assert!(p[0] > p[1]); +} diff --git a/tests/equivalence.rs b/tests/equivalence.rs new file mode 100644 index 0000000..222d7dd --- /dev/null +++ b/tests/equivalence.rs @@ -0,0 +1,61 @@ +//! Equivalence tests: every historical golden from the pre-T2 tests is +//! reproduced here at the integration level via the new public API. +//! +//! The in-crate tests in `src/history.rs::tests` and +//! `src/time_slice.rs::tests` are the primary regression net for numerical +//! behavior. This file provides Game-level goldens that stand alone and are +//! more naturally expressed as integration tests. + +use approx::assert_ulps_eq; +use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating}; + +type R = Rating; + +fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R { + R::new(Gaussian::from_ms(mu, sigma), beta, ConstantDrift(gamma)) +} + +#[test] +fn game_1v1_golden_matches_historical() { + let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0); + let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0); + let (a_post, b_post) = Game::::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap(); + // Historical golden from pre-T2 test_1vs1 (team 0 wins): + assert_ulps_eq!( + a_post, + Gaussian::from_ms(29.205220, 7.194481), + epsilon = 1e-6 + ); + assert_ulps_eq!( + b_post, + Gaussian::from_ms(20.794779, 7.194481), + epsilon = 1e-6 + ); +} + +#[test] +fn game_1v1_draw_golden() { + let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0); + let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0); + let g = Game::::ranked( + &[&[a], &[b]], + Outcome::draw(2), + &GameOptions { + p_draw: 0.25, + convergence: Default::default(), + }, + ) + .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 + ); +} diff --git a/tests/game.rs b/tests/game.rs new file mode 100644 index 0000000..0769436 --- /dev/null +++ b/tests/game.rs @@ -0,0 +1,96 @@ +use trueskill_tt::{ + ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, InferenceError, Outcome, Rating, +}; + +type R = Rating; + +fn default_rating() -> R { + R::new( + Gaussian::from_ms(25.0, 25.0 / 3.0), + 25.0 / 6.0, + ConstantDrift(25.0 / 300.0), + ) +} + +#[test] +fn game_ranked_1v1_golden() { + let a = default_rating(); + let b = default_rating(); + let g = Game::::ranked( + &[&[a], &[b]], + Outcome::winner(0, 2), + &GameOptions::default(), + ) + .unwrap(); + let p = g.posteriors(); + assert!(p[0][0].mu() > 25.0); + assert!(p[1][0].mu() < 25.0); + assert!((p[0][0].sigma() - p[1][0].sigma()).abs() < 1e-6); +} + +#[test] +fn game_one_v_one_shortcut() { + let a = default_rating(); + let b = default_rating(); + let (a_post, b_post) = Game::::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap(); + assert!(a_post.mu() > 25.0); + assert!(b_post.mu() < 25.0); +} + +#[test] +fn game_ranked_rejects_bad_p_draw() { + let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0)); + let err = Game::::ranked( + &[&[a], &[a]], + Outcome::winner(0, 2), + &GameOptions { + p_draw: 1.5, + convergence: ConvergenceOptions::default(), + }, + ) + .unwrap_err(); + assert!(matches!(err, InferenceError::InvalidProbability { .. })); +} + +#[test] +fn game_ranked_rejects_mismatched_ranks() { + let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0)); + let err = Game::::ranked( + &[&[a], &[a]], + Outcome::ranking([0, 1, 2]), + &GameOptions::default(), + ) + .unwrap_err(); + assert!(matches!(err, InferenceError::MismatchedShape { .. })); +} + +#[test] +fn game_free_for_all_three_players() { + let a = default_rating(); + let b = default_rating(); + let c = default_rating(); + let g = Game::::free_for_all( + &[&a, &b, &c], + Outcome::ranking([0, 1, 2]), + &GameOptions::default(), + ) + .unwrap(); + let p = g.posteriors(); + assert_eq!(p.len(), 3); + assert!(p[0][0].mu() > p[1][0].mu()); + assert!(p[1][0].mu() > p[2][0].mu()); +} + +#[test] +fn game_log_evidence_is_finite() { + let a = default_rating(); + let b = default_rating(); + let g = Game::::ranked( + &[&[a], &[b]], + Outcome::winner(0, 2), + &GameOptions::default(), + ) + .unwrap(); + assert!(g.log_evidence().is_finite()); + assert!(g.log_evidence() < 0.0); +} diff --git a/tests/record_winner.rs b/tests/record_winner.rs new file mode 100644 index 0000000..ae18058 --- /dev/null +++ b/tests/record_winner.rs @@ -0,0 +1,54 @@ +use trueskill_tt::{ConstantDrift, ConvergenceOptions, History}; + +#[test] +fn record_winner_builds_history() { + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .drift(ConstantDrift(25.0 / 300.0)) + .convergence(ConvergenceOptions { + max_iter: 30, + epsilon: 1e-6, + }) + .build(); + + h.record_winner(&"alice", &"bob", 1).unwrap(); + h.converge().unwrap(); + + let a_idx = h.lookup(&"alice").unwrap(); + let b_idx = h.lookup(&"bob").unwrap(); + + assert_ne!(a_idx, b_idx); +} + +#[test] +fn intern_is_idempotent() { + let mut h: History = History::builder().build(); + let a1 = h.intern(&"alice"); + let a2 = h.intern(&"alice"); + assert_eq!(a1, a2); +} + +#[test] +fn lookup_returns_none_for_missing() { + let h: History = History::builder().build(); + assert!(h.lookup(&"nobody").is_none()); +} + +#[test] +fn record_draw_with_p_draw_set() { + let mut h = History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .drift(ConstantDrift(25.0 / 300.0)) + .p_draw(0.25) + .build(); + + h.record_draw(&"alice", &"bob", 1).unwrap(); + h.converge().unwrap(); + + assert!(h.lookup(&"alice").is_some()); + assert!(h.lookup(&"bob").is_some()); +}