30 Commits

Author SHA1 Message Date
logaritmisk 68be7ab5b7 test(history): end-to-end ConvergenceOptions propagation tests
Two integration tests on a 4-team ranked event:
- max_iter=1 set on HistoryBuilder produces measurably different
  posteriors than default, proving the inner loop honors the
  propagated max_iter
- alpha=0.5 with extra iterations reaches the same fixed point as
  alpha=1.0, proving damping doesn't break correctness on the History
  path

Also updates the alpha doc comment to clarify it applies only to the
within-game EP loop, not the outer cross-history sweep.
2026-05-08 15:34:58 +02:00
logaritmisk 824b7f50b0 feat(time_slice): inference callsites read self.convergence
The three Game::*_with_arena callsites in time_slice.rs (in
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
run_event closure, and Event::iteration_direct via parameter) now use
the propagated ConvergenceOptions instead of hardcoded ::default().
sweep_color_groups (both rayon and non-rayon paths) forwards
self.convergence into Event::iteration_direct.

Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
HistoryBuilder::convergence(opts) now actually reach the within-game
inference loop. Bit-equal for users on default options.

Removes the temporary #[allow(dead_code)] on TimeSlice::convergence
that was added in the prior commit.
2026-05-08 15:32:25 +02:00
logaritmisk 872f91797d refactor(time_slice): add convergence field, rename iterate_to_convergence
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
set at construction. TimeSlice::new now takes it as a third parameter
(breaking change to the pub constructor, acceptable in 0.1.x).
History::add_events_with_prior passes self.convergence so the propagated
value reaches every TimeSlice. The pre-existing convergence-the-method
is renamed to iterate_to_convergence to disambiguate from the new
convergence-the-field.

The field is wired but not yet read by inference -- the three
Game::*_with_arena callsites in time_slice.rs still hardcode
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
the propagated value equals the hardcoded value end-to-end.

Also updated benches/batch.rs which has a fourth TimeSlice::new
callsite (not enumerated in the plan -- only src/ files were).
2026-05-08 15:29:39 +02:00
logaritmisk 6e453b6845 docs: implementation plan for History → TimeSlice plumbing
Three tasks: TimeSlice gains convergence field + method rename +
History passes self.convergence (atomic), three inference callsites
read self.convergence, and end-to-end tests + alpha doc-comment update.
2026-05-08 15:26:38 +02:00
logaritmisk 965ea7ed3c docs: spec for History → TimeSlice ConvergenceOptions plumbing
Closes the gap between HistoryBuilder::convergence(opts) and the
within-game inference loop. TimeSlice gains a convergence field;
History passes self.convergence at construction; the three
Game::*_with_arena callsites in time_slice.rs read it. Also renames
TimeSlice::convergence the method (now iterate_to_convergence) to
disambiguate from the new field.

Pure plumbing — no new public API, no behavioral change for users on
default options. Makes Damped EP reachable through the History path.
2026-05-08 15:23:11 +02:00
logaritmisk dbce69f350 test(game): integration tests for ConvergenceOptions behavior
Two end-to-end tests on a 4-team ranked game:
- max_iter=1 produces measurably different posteriors than the default,
  proving run_chain reads convergence.max_iter
- alpha=0.5 with extra iterations reaches the same fixed point as
  alpha=1.0, proving damping doesn't break convergence on benign graphs
2026-05-08 15:13:23 +02:00
logaritmisk 0705986929 feat(game): plumb ConvergenceOptions through to run_chain
Game and OwnedGame gain a convergence: ConvergenceOptions field set at
construction. Game::{ranked,scored} forward options.convergence into
OwnedGame::{new,new_scored} (previously dropped on the floor).
{ranked,scored}_with_arena take it as a parameter. run_chain reads
self.convergence.{epsilon, max_iter, alpha} instead of hardcoded
1e-6 / 10 / undamped. DiffFactor::propagate gains an alpha parameter
and dispatches into Trunc/MarginFactor::propagate_with_alpha.

In-tree callsites in src/time_slice.rs and src/history.rs pass
ConvergenceOptions::default(). Pre-existing T2 fallout in tests,
benches, and the atp example (struct literals missing the new alpha
field) is fixed by adding alpha: 1.0 so the workspace builds clean.
Default alpha is 1.0, so all 96 lib + 27 integration test goldens
remain bit-equal.
2026-05-08 15:10:35 +02:00
logaritmisk aacaa60baa feat(factor): add MarginFactor::propagate_with_alpha for EP damping
Mirrors TruncFactor: inherent damped-propagate method, trait impl
delegates with α=1.0. Existing goldens unchanged because cavity*new_msg
equals the previous marginal write when α=1.0.
2026-05-08 15:03:45 +02:00
logaritmisk fcfe0ffe37 feat(factor): add TruncFactor::propagate_with_alpha for EP damping
Inherent method that applies α-damping to the outgoing message via
Gaussian::damp_natural. The Factor trait impl delegates with α=1.0,
preserving today's behavior bit-equal. Variable write switched from
`trunc` to `cavity * damped` — algebraically identical when α=1.0
(cavity * new_msg = trunc by construction); reflects partial-update
math when α<1.0.
2026-05-08 15:02:09 +02:00
logaritmisk 0fa4e7d277 feat(convergence): add ConvergenceOptions::alpha damping field
Adds an EP damping coefficient defaulting to 1.0 (undamped). Will be
read by run_chain in a follow-up commit. By itself this commit changes
no behavior — existing constructors using ..Default::default() pick up
the new field automatically.
2026-05-08 15:00:34 +02:00
logaritmisk 0dd7dab266 feat(gaussian): add damp_natural helper for EP damping
Computes α·new + (1−α)·self in natural-parameter space. Will be used
by TruncFactor and MarginFactor to support opt-in EP damping via
ConvergenceOptions::alpha.
2026-05-08 14:59:18 +02:00
logaritmisk 43cc6d82f9 docs: implementation plan for game-local Damped EP
Six tasks: Gaussian::damp_natural helper, ConvergenceOptions::alpha
field, TruncFactor and MarginFactor propagate_with_alpha pair, DiffFactor
+ Game integration (the big task — must land atomically), and
end-to-end tests for max_iter and alpha behavior.
2026-05-08 14:57:41 +02:00
logaritmisk 48a6049dc6 docs: spec for game-local Damped EP
Smallest-scope realisation of spec §"Built-in schedules" Damped: a
ConvergenceOptions::alpha field plumbed through run_chain to a new
Gaussian::damp_natural helper applied inside TruncFactor and
MarginFactor's propagate. alpha=1.0 default keeps every existing
golden bit-equal; alpha<1.0 stabilises oscillating fixed-point loops
on hard graphs.

Defers Schedule trait integration, nat-param convergence switch,
oscillation auto-detect, Residual/OneShot, and Synergy/ScoreFactor —
each gets its own future plan.
2026-05-08 14:52:36 +02:00
logaritmisk 1445c08896 docs: fix stale numerics in t4-margin-factor plan
The plan's prose quoted Z_cav ≈ 0.046827 and log_evidence ≈ -3.0613,
which diverged from the values asserted by the shipped test in
src/factor/mod.rs (-3.062235327364623). Update prose and the matching
code comment to 0.04678 / -3.0622.
2026-05-08 14:37:58 +02:00
logaritmisk f6a83e4dc6 refactor: make BuiltinFactor::log_evidence match exhaustive
Replace the `_ => 0.0` wildcard with explicit
`Self::TeamSum(_) | Self::RankDiff(_) => 0.0`. No behavioral change;
future variants now produce a compile error instead of being silently
absorbed by the wildcard.
2026-05-08 14:37:13 +02:00
logaritmisk 68b589b965 refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
Both methods were 95-line near-duplicates differing only in the closure
that builds the per-diff DiffFactor. Extract the shared body as a
private run_chain<F>(&self, arena, make_link) helper that returns
(evidence, likelihoods); the two callers shrink to ~10 lines each.

Pure code-shape change: posteriors and evidence remain bit-equal; all
existing tests (lib + integration) pass unchanged.
2026-05-08 14:36:35 +02:00
logaritmisk 7481c31ad8 docs: implementation plan for post-T4-MarginFactor tech debt cleanup
Three-task plan covering the run_chain dedup, exhaustive BuiltinFactor
log_evidence match, and stale-numerics fix in the T4 plan doc.
2026-05-08 14:28:10 +02:00
logaritmisk a69a3004b2 docs: spec for post-T4-MarginFactor tech debt cleanup
Three independent cleanups: dedupe Game::likelihoods and likelihoods_scored
via a run_chain helper taking a make_link closure, make BuiltinFactor's
log_evidence match exhaustive, and fix stale numerics in the T4 plan doc.
2026-05-08 14:24:48 +02:00
logaritmisk dbaad0e7d2 fix: release generated CHANGELOG at the wrong location 2026-04-27 09:02:38 +02:00
logaritmisk 8069941a81 chore: Release trueskill-tt version 0.1.1 2026-04-27 09:01:46 +02:00
logaritmisk 8b53cacd64 T4 (MarginFactor): scored outcomes via Gaussian-margin EP evidence
Adds soft Gaussian-observation evidence on the per-pair diff variable,
enabling continuous score margins as a richer alternative to ranks.

Public API:
- `Outcome::Scored([scores])` (non-breaking enum extension under
  `#[non_exhaustive]`).
- `Game::scored(teams, outcome, options)` constructor parallel to
  `Game::ranked`.
- `EventBuilder::scores([...])` fluent helper.
- `HistoryBuilder::score_sigma(σ)` knob (default 1.0, validated > 0).
- `GameOptions::score_sigma`.
- `EventKind` re-exported from `lib.rs` (annotated `#[non_exhaustive]`).
- New `InferenceError::InvalidParameter { name, value }` variant.

Internals:
- `MarginFactor` (`factor/margin.rs`): Gaussian observation factor that
  closes in one EP step; cavity-cached log-evidence mirrors `TruncFactor`.
- `BuiltinFactor::Margin` dispatch arm.
- `DiffFactor` enum in `game.rs` lets `Game::likelihoods` and the new
  `likelihoods_scored` share the per-pair link abstraction.
- Per-event `EventKind { Ranked, Scored { score_sigma } }` routed through
  `TimeSlice::add_events`, `iteration_direct`, and `log_evidence`.

Tests: 88 lib + 27 integration (4 new in `tests/scored.rs`); existing
goldens byte-identical.  Bench: `benches/scored.rs` baseline ~960µs for
60 events × 20-player pool with default convergence.

Plan: docs/superpowers/plans/2026-04-27-t4-margin-factor.md
Spec item marked Done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:47:36 +02:00
logaritmisk 6bf3e7e294 T3: rayon-backed concurrency (opt-in) (#2)
Implements T3 of `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md` Section 6. Plan: `docs/superpowers/plans/2026-04-24-t3-concurrency.md` (11 tasks).

## Summary

### Breaking

- `Send + Sync` bounds added to public traits: `Time`, `Drift<T>`, `Observer<T>`, `Factor`, `Schedule`. All built-in impls satisfy these via auto-derive; downstream custom impls will need the bounds.

### New

- Opt-in `rayon` cargo feature. When enabled:
  - Within-slice event iteration runs color-group events in parallel via `par_iter_mut` (`TimeSlice::sweep_color_groups`).
  - `History::learning_curves` computes per-slice posteriors in parallel; merges sequentially in slice order.
  - `History::log_evidence` / `log_evidence_for` use per-slice parallel computation with deterministic sequential reduction (sum in slice order) — bit-identical to the sequential baseline.
- `ColorGroups` infrastructure (`src/color_group.rs`) with greedy graph coloring. Events sharing no `Index` go into the same color group; events in the same group can run concurrently without touching each other's skills.
- `tests/determinism.rs` asserts bit-identical posteriors across `RAYON_NUM_THREADS={1, 2, 4, 8}`.
- `benches/history_converge.rs` measures end-to-end convergence on three workload shapes.

## Performance

### Sequential (no rayon, default build)

| Metric | Before T3 | After T3 | Delta |
|---|---|---|---|
| `Batch::iteration` | 22.88 µs | 23.23 µs | **+1.5%** (noise) |
| `Gaussian::*` | ≈218–264 ps | ≈236 ps | within noise |

**No sequential regression.** Default build is as fast as T2.

### Parallel (`--features rayon`, Apple M5 Pro, auto thread count)

| Workload | Sequential | Parallel | Speedup |
|---|---:|---:|---:|
| 500 events / 100 competitors / 10 per slice | 4.03 ms | 4.24 ms | **1.0×** |
| 2000 events / 200 competitors / 20 per slice | 20.18 ms | 19.82 ms | **1.0×** |
| 5000 events / 50000 competitors / 1 slice | 11.88 ms | 9.10 ms | **1.3×** |

### ⚠️ The spec's >=2× target was not met on realistic workloads.

T3's within-slice color-group parallelism only shows material benefit when a slice holds many events AND the competitor pool is large enough to give the greedy coloring room to partition. Typical TrueSkill workloads (tens of events per slice) don't fit that profile — rayon's task-spawn overhead dominates.

**Cross-slice parallelism (dirty-bit slice skipping per spec Section 5) is the natural next step** for real-workload speedup and would deliver the spec's ~50–500× online-add speedup. Deferred to a future tier.

## Determinism

`tests/determinism.rs` runs a 200-event history at thread counts {1, 2, 4, 8} via `rayon::ThreadPoolBuilder::install` and asserts every `(time, posterior)` pair has bit-identical `mu` and `sigma` (compared via `f64::to_bits()`). Passes.

## Internals

- Parallel path uses an `unsafe` block to concurrently write to `SkillStore` from color-group-disjoint events. Soundness rests on the color-group invariant (events in the same color touch no shared `Index`), guaranteed by construction in `TimeSlice::recompute_color_groups`. Sequential path unchanged from T2.
- `RAYON_THRESHOLD = 64` — color groups smaller than this fall back to sequential inside `sweep_color_groups` to avoid task-spawn overhead.
- Thread-local `ScratchArena` per rayon worker thread.

## Test plan

- [x] `cargo test --features approx` — 96 tests pass (74 lib + 22 integration)
- [x] `cargo test --features approx,rayon` — 97 tests pass (+1 determinism)
- [x] `cargo clippy --all-targets --features approx -- -D warnings` — clean
- [x] `cargo clippy --all-targets --features approx,rayon -- -D warnings` — clean
- [x] `cargo +nightly fmt --check` — clean
- [x] `cargo bench --bench batch --features approx` — 23.23 µs (no regression vs T2)
- [x] `cargo bench --bench history_converge --features approx,rayon` — runs on all three workloads
- [x] Bit-identical posteriors across `RAYON_NUM_THREADS={1, 2, 4, 8}` — verified

## Commit history

13 commits on `t3-concurrency`. Each task is self-contained and bisectable. See `git log main..t3-concurrency` for the full list.

## Deferred

- **Cross-slice parallelism** (dirty-bit slice skipping) — the path that would actually speed up typical TrueSkill workloads.
- **Default-on `rayon` feature** — spec called for default-on; we keep it opt-in until the feature proves stable in production use.
- **Synchronous-EP schedule with barrier merge** — alternative parallel strategy per spec Section 6.
- **`MarginFactor` / `Outcome::Scored`** — T4.
- **`Damped` / `Residual` schedules** — T4.
- **N-team `predict_outcome`** — T4.
- **`Game::custom` full ergonomics** — T4.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #2
Co-authored-by: Anders Olsson <anders.e.olsson@gmail.com>
Co-committed-by: Anders Olsson <anders.e.olsson@gmail.com>
2026-04-24 13:01:01 +00:00
logaritmisk d2aab82c1e T0 + T1 + T2: engine redesign through new API surface (#1)
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<Index, _>` → dense `Vec<_>` keyed by `Index.0` (via `AgentStore<D>`, `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<T>`, `Rating<T, D>`, `Competitor<T, D>`, `TimeSlice<T>`, `History<T, D, O, K>` all generic.
- `Event<T, K>`, `Team<K>`, `Member<K>`, `Outcome` (`Ranked` variant; `#[non_exhaustive]`).
- `Observer<T>` trait + `NullObserver`.
- `ConvergenceOptions`, `ConvergenceReport`.
- `GameOptions`, `OwnedGame<T, D>`.

**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::<i64, _>` 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: #1
Co-authored-by: Anders Olsson <anders.e.olsson@gmail.com>
Co-committed-by: Anders Olsson <anders.e.olsson@gmail.com>
2026-04-24 11:20:04 +00:00
logaritmisk a14df02089 chore: do not publish 2026-04-23 20:26:52 +02:00
logaritmisk 0d266b4428 chore: make cargo release add CHANGELOG.md before commit 2026-04-23 20:26:16 +02:00
logaritmisk a4b4e5e8fa chore: clean up 2026-04-23 20:24:10 +02:00
logaritmisk 04d5478ee4 style: cargo fmt 2026-04-23 20:23:13 +02:00
logaritmisk 480467ac32 chore: added cliff.toml, release.toml and rustfmt.toml 2026-04-23 20:22:27 +02:00
logaritmisk dc47964310 added benchmark 2026-03-23 14:55:18 +01:00
logaritmisk 61a5507f5c remove notepad 2026-03-23 14:21:23 +01:00
70 changed files with 19312 additions and 1988 deletions
+3
View File
@@ -4,3 +4,6 @@
/temp
.justfile
*.svg
NOTEPAD.md
/.claude
+100
View File
@@ -0,0 +1,100 @@
# Changelog
All notable changes to this project will be documented in this file.
## 0.1.1 - 2026-04-27
### Other (unconventional)
- T0 + T1 + T2: engine redesign through new API surface (#1)
- T3: rayon-backed concurrency (opt-in) (#2)
- T4 (MarginFactor): scored outcomes via Gaussian-margin EP evidence
## 0.1.0 - 2026-04-23
### Features
- feat: added a Drift trait and a "default" ConstantDrift implementation
### Miscellaneous Tasks
- chore: added cliff.toml, release.toml and rustfmt.toml
- chore: clean up
- chore: make cargo release add CHANGELOG.md before commit
- chore: do not publish
### Other (unconventional)
- Initial commit.
- Begin working on batch.
- Passing tests for Batch
- Working on History struct. First test is passing.
- More test passing for History
- Added more functions to History
- Remove Display impl, better to use Debug
- Use flatten instead of flat_map
- Handle case where there is no time
- It works, or so it seems
- Use PlayerIndex instead of String
- Inline a lot of functions
- Refactor some code
- Refactor some stuff
- Port from julia version instead
- More things, better things, awesome
- More tests, more code
- More things, more tests
- Fix tests
- More tests
- More tests
- Added builder for History, and start migrating test to use builder instead.
- Update test to use builder
- Remove unused code
- Use and Index struct instead of str and String for player id
- Update example so now it works, and thats, well, good
- Update test to use assert_ulps_eq
- Fixed test
- Change time to use i64 instead of u64
- Small change
- Clean up example
- Update crates and added methods to get a key or all keys in an IndexMap
- Added a get function to IndexMap
- Agents doens't have to be behind a mutable reference in within_prior
- Agents doens't have to be behind a mutable reference in within_priors
- Refactor so we can see if there is any way to improve the performance
- Fix clippy warning
- More refactoring
- Remove warnings and refactor some code
- Added benchmark for Batch
- Added default implementation for TeamMessage
- Remove unused mut reference
- Make it more rusty
- More rustifying
- Small refactor
- Rename d to diff, and t to team
- Added more links to readme
- Fix broken link in README
- Update crates
- Clean up
- Dry my eyes
- Remove unnecessary allocations
- Fix clippy warning
- Refactor history
- Rename variables
- Move stuff around
- Added quality function
- Make quality a free standing function instead
- Improve performance
- Change assert to debug_assert
- Added todo to readme, and documentation for quality function
- Basic test for quality
- Ignore temp folder
- Update edition
- Small changes for new 2024 edition
- remove notepad
- added benchmark
### Styling
- style: cargo fmt
<!-- generated by git-cliff -->
+1
View File
@@ -35,6 +35,7 @@ History → Batch[] → Game[] → teams/players
- **`Player`** (`player.rs`) — static configuration: prior `Gaussian`, `beta` (performance noise), `gamma` (skill drift per time unit).
- **`Gaussian`** (`gaussian.rs`) — core probability type. Stored as natural parameters (`pi = 1/sigma²`, `tau = mu/sigma²`). Arithmetic ops implement message multiplication/division in the factor graph.
- **`message.rs`** — `TeamMessage` and `DiffMessage`: intermediate factor graph messages used inside `Game`.
- **`MarginFactor`** (`factor/margin.rs`) — Gaussian observation factor on a diff variable; engaged by `Outcome::Scored`.
- **`lib.rs`** — exports the public API (`Game`, `Gaussian`, `History`, `Player`) and standalone functions (`quality()`, `pdf()`, `cdf()`, `erfc()`). Also defines global defaults: `MU=0.0`, `SIGMA=6.0`, `BETA=1.0`, `GAMMA=0.03`, `P_DRAW=0.0`, `EPSILON=1e-6`, `ITERATIONS=30`.
### Key design points
+19 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "trueskill-tt"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
[lib]
@@ -10,8 +10,26 @@ bench = false
name = "batch"
harness = false
[[bench]]
name = "gaussian"
harness = false
[[bench]]
name = "history_converge"
harness = false
[[bench]]
name = "scored"
harness = false
[dependencies]
approx = { version = "0.5.1", optional = true }
rayon = { version = "1", optional = true }
smallvec = "1"
[features]
approx = ["dep:approx"]
rayon = ["dep:rayon"]
[dev-dependencies]
criterion = "0.5"
+10
View File
@@ -0,0 +1,10 @@
alias b := bench
store:
cargo bench -- --save-baseline base
bench:
cargo bench -- --baseline base
flame:
cargo flamegraph --root --example atp
-16
View File
@@ -1,16 +0,0 @@
# History
```rust
let mut history = History::new();
let agent_a = history.new_agent();
let agent_b = history.new_agent_with_prior(Prior::new(Gaussian::default(), BETA, GAMMA));
```
```rust
trait Team {
fn players(&self) -> impl Iterator<Item = P>;
fn weights(&self) -> impl Iterator<Item = f64>;
fn score(&self) -> u16;
}
```
+21
View File
@@ -71,6 +71,27 @@ let h = History::builder()
.build();
```
## Scored outcomes
Use `Outcome::scores([...])` when you have continuous per-team scores rather
than just ranks. Adjacent score margins flow into a `MarginFactor` that adds
soft Gaussian evidence about the latent performance diff. Configure
`HistoryBuilder::score_sigma(σ)` to control how much you trust the margins
(smaller σ = more trust).
```rust
use trueskill_tt::{History, Outcome};
let mut h = History::builder().score_sigma(2.0).build();
h.event(1)
.team(["alice"])
.team(["bob"])
.scores([21.0, 9.0])
.commit()
.unwrap();
h.converge().unwrap();
```
## Todo
- [x] Implement approx for Gaussian
+132
View File
@@ -0,0 +1,132 @@
# 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<Vec<Player<D>>>: 3 Vecs per event (300 total)
# - event.outputs() returns Vec<f64>: 1 Vec per event (100 total)
# - sort_perm() allocates 2 scratch Vecs: 200 total
# - Game::likelihoods = collect() allocates Vec<Vec<Gaussian>>: 4 Vecs (400 total)
# Total remaining: ~1000 allocs per iteration call vs. ~1800 before (44% reduction).
#
# The HashMap → dense Vec win (target 24×) 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<ConvergenceReport, InferenceError>
# 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<T>, O: Observer<T>,
# 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.
# After T3 (2026-04-24, same hardware)
Batch::iteration (seq, no rayon) 23.23 µs (matches T2 baseline; no regression)
Batch::iteration (rayon, small slice) 24.57 µs (within noise; small workloads pay rayon overhead)
Gaussian::add 236.62 ps (unchanged)
Gaussian::sub 236.43 ps (unchanged)
Gaussian::mul 237.05 ps (unchanged)
Gaussian::div 236.07 ps (unchanged)
# End-to-end history_converge benchmark (Apple M5 Pro, RAYON_NUM_THREADS=auto):
# workload seq rayon speedup
# 500 events, 100 competitors, 10/slice 4.03 ms 4.24 ms 1.0x
# 2000 events, 200 competitors, 20/slice 20.18 ms 19.82 ms 1.0x
# 5000 events, 50000 competitors, 1 slice 11.88 ms 9.10 ms 1.3x
#
# Notes:
# - T3's within-slice color-group parallelism only materializes a speedup
# when a slice holds many events with disjoint competitor sets. Typical
# TrueSkill workloads (tens of events per slice) don't show measurable
# benefit from rayon.
# - The pre-revert SmallVec experiment hit 2x on the 5000-event workload
# but regressed sequential Batch::iteration by 28%. The tradeoff wasn't
# worth it for typical workloads — ShipVec<[_; 8]> inline size (1 KB per
# Game struct) hurt cache locality on the hot path.
# - Cross-slice parallelism (dirty-bit slice skipping per spec Section 5)
# is the natural next step for realistic TrueSkill workloads and would
# deliver the spec's ~50-500x online-add speedup. Deferred to T4+.
# - Determinism verified: tests/determinism.rs asserts bit-identical
# posteriors across RAYON_NUM_THREADS={1, 2, 4, 8}.
# - Send + Sync bounds added on Time, Drift<T>, Observer<T>, Factor, Schedule.
# - Rayon is opt-in via `--features rayon`. Default build is unchanged from T2.
+18 -34
View File
@@ -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, ConvergenceOptions, EventKind, 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<i64, ConstantDrift> = 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,13 @@ 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 kinds = vec![EventKind::Ranked; composition.len()];
let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
time_slice.add_events(composition, results, weights, kinds, &agents);
criterion.bench_function("Batch::iteration", |b| {
b.iter(|| batch.iteration(0, &agents))
b.iter(|| time_slice.iteration(0, &agents))
});
}
+53
View File
@@ -0,0 +1,53 @@
use criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::gaussian::Gaussian;
fn benchmark_gaussian_arithmetic(criterion: &mut Criterion) {
// Define test Gaussians
let g1 = Gaussian::from_ms(25.0, 25.0 / 3.0);
let g2 = Gaussian::from_ms(0.0, 1.0);
let g3 = Gaussian::from_ms(1.0, 1.0);
// Benchmark addition
criterion.bench_function("Gaussian::add", |bencher| {
bencher.iter(|| g1 + g2);
});
// Benchmark subtraction
criterion.bench_function("Gaussian::sub", |bencher| {
bencher.iter(|| g1 - g3);
});
// Benchmark multiplication
criterion.bench_function("Gaussian::mul", |bencher| {
bencher.iter(|| g1 * g2);
});
// 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(|| g2 / g1);
});
// Benchmark natural parameter conversions
criterion.bench_function("Gaussian::pi", |bencher| {
bencher.iter(|| g1.pi());
});
criterion.bench_function("Gaussian::tau", |bencher| {
bencher.iter(|| g1.tau());
});
// Benchmark combined pi/tau operations (used in mul/div)
criterion.bench_function("Gaussian::pi_tau_combined", |bencher| {
bencher.iter(|| {
let pi = g1.pi();
let tau = g1.tau();
(pi, tau)
});
});
}
criterion_group!(benches, benchmark_gaussian_arithmetic);
criterion_main!(benches);
+117
View File
@@ -0,0 +1,117 @@
//! End-to-end History::converge benchmark.
//!
//! Workload shapes designed to expose rayon's within-slice color-group
//! parallelism. Events in the same color group are processed in parallel
//! via direct-write with disjoint index sets (no data races). Color groups
//! smaller than a threshold fall back to the sequential path to avoid
//! rayon overhead on small workloads.
//!
//! On Apple M5 Pro, the P-core count (6) is the optimal thread count.
//! The rayon thread pool is initialised to `min(P-cores, available)` to
//! avoid scheduling onto the slower E-cores.
//!
//! ## Results (Apple M5 Pro, 2026-04-24, after SmallVec revert)
//!
//! | Workload | Sequential | Parallel | Speedup |
//! |---------------------------------------------|------------:|-----------:|--------:|
//! | History::converge/500x100@10perslice | 4.03 ms | 4.24 ms | 1.0× |
//! | History::converge/2000x200@20perslice | 20.18 ms | 19.82 ms | 1.0× |
//! | History::converge/1v1-5000x50000@5000perslice| 11.88 ms | 9.10 ms | 1.3× |
//!
//! T3 acceptance gate: ≥2× speedup on at least one workload — NOT achieved after revert.
//! The SmallVec storage that enabled the 2× gate caused a +28% regression in the
//! sequential Batch::iteration benchmark and was reverted. Small workloads still fall
//! below the RAYON_THRESHOLD (64 events/color) and run sequentially with near-zero overhead.
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use smallvec::smallvec;
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
};
fn build_history_1v1(
n_events: usize,
n_competitors: usize,
events_per_slice: usize,
seed: u64,
) -> History<i64, ConstantDrift, NullObserver, String> {
let mut rng = seed;
let mut next = || {
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
rng
};
let mut h = History::<i64, _, _, String>::builder_with_key()
.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,
alpha: 1.0,
})
.build();
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(n_events);
for ev_i in 0..n_events {
let a = (next() as usize) % n_competitors;
let mut b = (next() as usize) % n_competitors;
while b == a {
b = (next() as usize) % n_competitors;
}
events.push(Event {
time: (ev_i as i64 / events_per_slice as i64) + 1,
teams: smallvec![
Team::with_members([Member::new(format!("p{a}"))]),
Team::with_members([Member::new(format!("p{b}"))]),
],
outcome: Outcome::winner((next() % 2) as u32, 2),
});
}
h.add_events(events).unwrap();
h
}
fn bench_converge(c: &mut Criterion) {
// Two original task workloads (small per-slice event count;
// fall below RAYON_THRESHOLD so sequential path runs — near-zero overhead).
c.bench_function("History::converge/500x100@10perslice", |b| {
b.iter_batched(
|| build_history_1v1(500, 100, 10, 42),
|mut h| {
h.converge().unwrap();
},
BatchSize::SmallInput,
);
});
c.bench_function("History::converge/2000x200@20perslice", |b| {
b.iter_batched(
|| build_history_1v1(2000, 200, 20, 42),
|mut h| {
h.converge().unwrap();
},
BatchSize::SmallInput,
);
});
// Large single-slice workload: 5000 events, 50000 competitors.
// All events in one slice → color-0 gets ~4900 disjoint events, well above
// the 64-event RAYON_THRESHOLD. 30 iterations × 1 slice = 30 sweeps, each
// parallelised across P-core threads. Shows ≥2× speedup.
c.bench_function("History::converge/1v1-5000x50000@5000perslice", |b| {
b.iter_batched(
|| build_history_1v1(5000, 50000, 5000, 42),
|mut h| {
h.converge().unwrap();
},
BatchSize::SmallInput,
);
});
}
criterion_group!(benches, bench_converge);
criterion_main!(benches);
+38
View File
@@ -0,0 +1,38 @@
use criterion::{Criterion, criterion_group, criterion_main};
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn bench_scored_history(c: &mut Criterion) {
c.bench_function("scored_history_60_events_30_iter", |bencher| {
bencher.iter(|| {
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.03))
.score_sigma(2.0)
.build();
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(60);
for i in 0..60 {
let a = format!("p{}", i % 20);
let b = format!("p{}", (i + 7) % 20);
let s_a = (i as f64 * 0.3).sin().abs() * 21.0;
let s_b = (i as f64 * 0.3).cos().abs() * 21.0;
events.push(Event {
time: 1 + (i / 6) as i64,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome: Outcome::scores([s_a, s_b]),
});
}
h.add_events(events).unwrap();
h.converge().unwrap();
});
});
}
criterion_group!(benches, bench_scored_history);
criterion_main!(benches);
+14
View File
@@ -0,0 +1,14 @@
Finished `bench` profile [optimized + debuginfo] target(s) in 0.02s
Running benches/scored.rs (target/release/deps/scored-988d1798504ff7d2)
Gnuplot not found, using plotters backend
Benchmarking scored_history_60_events_30_iter
Benchmarking scored_history_60_events_30_iter: Warming up for 3.0000 s
Benchmarking scored_history_60_events_30_iter: Collecting 100 samples in estimated 9.7418 s (10k iterations)
Benchmarking scored_history_60_events_30_iter: Analyzing
scored_history_60_events_30_iter
time: [959.36 µs 962.68 µs 966.13 µs]
Found 11 outliers among 100 measurements (11.00%)
1 (1.00%) low mild
5 (5.00%) high mild
5 (5.00%) high severe
+65
View File
@@ -0,0 +1,65 @@
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[changelog]
# A Tera template to be rendered as the changelog's header.
# See https://keats.github.io/tera/docs/#introduction
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# A Tera template to be rendered for each release in the changelog.
# See https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## Unreleased
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | trim_end }}\
{% endfor %}
{% endfor %}\n
"""
# A Tera template to be rendered as the changelog's footer.
# See https://keats.github.io/tera/docs/#introduction
footer = """
<!-- generated by git-cliff -->
"""
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
[git]
# Parse commits according to the conventional commits specification.
# See https://www.conventionalcommits.org
conventional_commits = false
# Exclude commits that do not match the conventional commits specification.
filter_unconventional = false
# Split commits on newlines, treating each line as an individual commit.
split_commits = false
# An array of regex based parsers for extracting data from the commit message.
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^doc", group = "Documentation" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactor" },
{ message = "^style", group = "Styling" },
{ message = "^test", group = "Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore", group = "Miscellaneous Tasks" },
{ body = ".*security", group = "Security" },
{ body = ".*", group = "Other (unconventional)" },
]
# Exclude commits that are not matched by any commit parser.
filter_commits = false
# Order releases topologically instead of chronologically.
topo_order = false
# Order of commits in each group/release within the changelog.
# Allowed values: newest, oldest
sort_commits = "oldest"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,593 @@
# History → TimeSlice ConvergenceOptions Plumbing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Thread `ConvergenceOptions` from `History` through `TimeSlice` to the three `Game::*_with_arena` callsites in `time_slice.rs`, so users who set `HistoryBuilder::convergence(opts)` actually get those options applied to within-game inference (including Damped's `alpha`).
**Architecture:** `TimeSlice<T>` gains a `convergence: ConvergenceOptions` field set at construction. `History::add_events_with_prior` passes `self.convergence`. The three `Game::*_with_arena` callsites in `time_slice.rs` swap their hardcoded `ConvergenceOptions::default()` for the propagated value. The pre-existing `TimeSlice::convergence` method is renamed to `iterate_to_convergence` to disambiguate from the new field. No new public API on `History` or `HistoryBuilder``convergence(opts)` already exists and works.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-history-convergence-plumbing-design.md`
## Pre-flight context for the implementer
- `HistoryBuilder::convergence(opts)` already exists at `src/history.rs:91`. `History` already stores `convergence: ConvergenceOptions` at `src/history.rs:166`. `History::converge()` already reads `self.convergence.{epsilon, max_iter}` at `src/history.rs:437-447` for the OUTER cross-history loop.
- `TimeSlice<T>` is at `src/time_slice.rs:172-180`. Currently has fields `events`, `skills`, `time`, `p_draw`, `arena`, `color_groups`. No convergence field yet.
- `TimeSlice::new(time, p_draw)` at `src/time_slice.rs:183-192` is `pub`. Five test callsites use it with `(0i64, 0.0)`. One production callsite in `History::add_events_with_prior` at `src/history.rs:597` uses `(t, self.p_draw)`.
- Three callsites in `time_slice.rs` call `Game::*_with_arena` with hardcoded `crate::ConvergenceOptions::default()`:
- `Event::iteration_direct` at `src/time_slice.rs:131-169` — does NOT have `&self` access to a TimeSlice. Currently takes `(skills, agents, p_draw, arena)`. Needs to gain a `convergence` parameter.
- `TimeSlice::iteration` at `src/time_slice.rs:322-363` — has `&mut self`, so reads `self.convergence` directly.
- `TimeSlice::log_evidence` at `src/time_slice.rs:505-540` — has `&self`, so reads `self.convergence` directly.
- The rayon path in `sweep_color_groups` at `src/time_slice.rs:376-423` uses a `move` closure capturing `p_draw` by value. The same pattern applies to `convergence` (it's `Copy`, so captures cleanly).
- `TimeSlice::convergence` (the **method** at `src/time_slice.rs:447`) shares its name with the new field. Rust technically allows this (different namespaces), but it's a readability hazard — must be renamed. The method is called from 4 test sites in `time_slice.rs` (lines 693, 755, 817, 851). It is NOT called from `history.rs`.
- `ConvergenceOptions` is `Copy + Clone + Debug`. Pass by value everywhere.
## File map
| File | Why touched |
|---|---|
| `src/time_slice.rs` | TimeSlice gains `convergence` field, `new` signature change, rename `convergence` method, three callsites read `self.convergence`, `Event::iteration_direct` gains parameter, rayon closure captures it |
| `src/history.rs` | `add_events_with_prior` passes `self.convergence` to `TimeSlice::new`; two integration tests added; alpha doc-comment update happens in `convergence.rs` not here |
| `src/convergence.rs` | One-sentence addition to `alpha` doc comment clarifying within-game-only scope |
---
### Task 1: TimeSlice gains `convergence` field; signature/rename land atomically
This task does five things atomically — they cannot land separately because intermediate states won't compile:
1. Add `pub(crate) convergence: ConvergenceOptions` field to `TimeSlice<T>`.
2. Change `TimeSlice::new` signature to take `convergence: ConvergenceOptions` as the third parameter.
3. Update the production callsite in `History::add_events_with_prior` (`src/history.rs:597`) to pass `self.convergence`.
4. Update the five test callsites in `src/time_slice.rs` (lines 646, 723, 803, 901 — the four with `TimeSlice::new(0i64, 0.0)`, plus the one inside the test module's `iterate_through_color_groups` test if it exists; locate via `grep -n "TimeSlice::new" src/time_slice.rs`).
5. Rename the existing `pub(crate) fn convergence` method (at `src/time_slice.rs:447`) to `iterate_to_convergence`. Update its 4 in-file call sites.
After this task the convergence field is wired but **unused** by inference (Task 2 makes the three Game callsites read it). All existing tests must pass bit-equal because the propagated value still equals `ConvergenceOptions::default()` end-to-end.
**Files:**
- Modify: `src/time_slice.rs`
- Modify: `src/history.rs:597`
- [ ] **Step 1: Locate all `TimeSlice::new` and `convergence`-method callsites**
Run:
```bash
grep -n "TimeSlice::new\|\.convergence(" src/time_slice.rs src/history.rs
```
Expected: 1 production callsite of `TimeSlice::new` in `history.rs`, 5 test callsites in `time_slice.rs`, and 4 method-style `.convergence(` calls in `time_slice.rs` test module. (No `.convergence(` calls in `history.rs` — those are field accesses.)
Save the line numbers — you'll need them in Step 4 and Step 6.
- [ ] **Step 2: Add the `convergence` field to `TimeSlice<T>`**
In `src/time_slice.rs`, modify the `TimeSlice<T>` struct (currently at `src/time_slice.rs:172-180`):
```rust
#[derive(Debug)]
pub struct TimeSlice<T: Time = i64> {
pub(crate) events: Vec<Event>,
pub(crate) skills: SkillStore,
pub(crate) time: T,
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
arena: ScratchArena,
pub(crate) color_groups: ColorGroups,
}
```
Code won't compile until Step 3.
- [ ] **Step 3: Change `TimeSlice::new` signature**
In `src/time_slice.rs`, replace the existing `pub fn new` (currently at `src/time_slice.rs:183-192`) with:
```rust
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
Self {
events: Vec::new(),
skills: SkillStore::new(),
time,
p_draw,
convergence,
arena: ScratchArena::new(),
color_groups: ColorGroups::new(),
}
}
```
- [ ] **Step 4: Update the production callsite in `history.rs`**
In `src/history.rs:597`, replace:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw);
```
with:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
```
- [ ] **Step 5: Update test callsites of `TimeSlice::new`**
Run `cargo build --tests` to surface every remaining compile error. Each error is a `TimeSlice::new(time, p_draw)` callsite missing the third argument. The fix: add `crate::ConvergenceOptions::default(),` (inside `src/time_slice.rs` test modules use the path relative to where `ConvergenceOptions` is in scope — if it's not imported in that test mod, add `use crate::ConvergenceOptions;` at the top of the mod and pass `ConvergenceOptions::default()`).
Example transformation. Before:
```rust
let mut time_slice = TimeSlice::new(0i64, 0.0);
```
After:
```rust
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
```
Apply to all 5 test callsites identified in Step 1. Repeat `cargo build --tests` until it succeeds.
- [ ] **Step 6: Rename the `convergence` method to `iterate_to_convergence`**
In `src/time_slice.rs`, find the method definition at `src/time_slice.rs:447`:
```rust
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
```
Rename to:
```rust
pub(crate) fn iterate_to_convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
```
Then update the 4 call sites (located in Step 1 — `time_slice.rs:693, 755, 817, 851` or wherever your grep found them). At each site, replace `time_slice.convergence(&agents)` with `time_slice.iterate_to_convergence(&agents)`.
- [ ] **Step 7: Build and run the full test suite**
Run: `cargo build && cargo test --lib`
Expected: all 98 lib tests pass. Bit-equal goldens — the convergence field is wired but the three inference callsites still hardcode `ConvergenceOptions::default()` (Task 2 changes that), and the propagated default equals what was hardcoded before, so behavior is identical.
If any test fails: investigate. The most likely cause is a missed `TimeSlice::new` callsite or a `.convergence(` call site that needs renaming.
- [ ] **Step 8: Run integration tests**
Run: `cargo test`
Expected: all 27 integration tests still pass.
- [ ] **Step 9: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 10: Commit**
```bash
git add src/time_slice.rs src/history.rs
git commit -m "$(cat <<'EOF'
refactor(time_slice): add convergence field, rename iterate_to_convergence
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
set at construction. TimeSlice::new now takes it as a third parameter
(breaking change to the pub constructor, acceptable in 0.1.x).
History::add_events_with_prior passes self.convergence so the propagated
value reaches every TimeSlice. The pre-existing convergence-the-method
is renamed to iterate_to_convergence to disambiguate from the new
convergence-the-field.
The field is wired but not yet read by inference — the three
Game::*_with_arena callsites in time_slice.rs still hardcode
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
the propagated value equals the hardcoded value end-to-end.
EOF
)"
```
---
### Task 2: Read `self.convergence` at the three inference callsites
This task switches the three `Game::*_with_arena` callsites in `time_slice.rs` from hardcoded `ConvergenceOptions::default()` to the propagated `self.convergence` (or for `Event::iteration_direct`, a passed-in parameter). After this task, Damped EP set on `HistoryBuilder` actually reaches the within-game loop.
**Files:**
- Modify: `src/time_slice.rs` (only)
- [ ] **Step 1: Add a `convergence` parameter to `Event::iteration_direct`**
In `src/time_slice.rs`, modify the existing `iteration_direct` signature (currently at `src/time_slice.rs:131-137`):
```rust
fn iteration_direct<T: Time, D: Drift<T>>(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<T, D>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) {
```
Inside the body (around `src/time_slice.rs:140-156`), replace both `crate::ConvergenceOptions::default()` arguments with `convergence`:
```rust
let g = match self.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&self.weights,
p_draw,
convergence,
arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&self.weights,
score_sigma,
convergence,
arena,
),
};
```
- [ ] **Step 2: Update the rayon path in `sweep_color_groups` (cfg=rayon)**
In `src/time_slice.rs`, the rayon-feature `sweep_color_groups` (currently at `src/time_slice.rs:376-423`) captures `p_draw` by value into a `move` closure and calls `ev.iteration_direct(skills, agents, p_draw, &mut arena)`. Capture `convergence` the same way and pass it:
Above the rayon `for_each` at the line `let p_draw = self.p_draw;`, add:
```rust
let convergence = self.convergence;
```
Then update the call inside the closure (currently `ev.iteration_direct(skills, agents, p_draw, &mut arena);`):
```rust
ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
```
The `else` branch (sequential fallback) at `src/time_slice.rs:417-421` calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` — also update:
```rust
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
```
(Note: this branch reads `self.convergence` directly because no `move` closure is involved here.)
- [ ] **Step 3: Update the non-rayon path in `sweep_color_groups`**
In `src/time_slice.rs`, the `#[cfg(not(feature = "rayon"))]` `sweep_color_groups` (currently at `src/time_slice.rs:428-444`) calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` at `src/time_slice.rs:441`. Replace with:
```rust
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
```
- [ ] **Step 4: Update `TimeSlice::iteration`'s sequential branch**
In `src/time_slice.rs`, modify `TimeSlice::iteration` (at `src/time_slice.rs:322-363`). The sequential branch (when `from > 0 || self.color_groups.is_empty()`) has two `Game::*_with_arena` callsites at `src/time_slice.rs:330-346` that hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
```rust
let g = match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
&mut self.arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
&mut self.arena,
),
};
```
- [ ] **Step 5: Update `TimeSlice::log_evidence`**
In `src/time_slice.rs`, modify `TimeSlice::log_evidence` (at `src/time_slice.rs:505-540`). The two `Game::*_with_arena` callsites in the inner `run_event` closure at `src/time_slice.rs:519-538` hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
```rust
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
let teams = event.within_priors(online, forward, &self.skills, agents);
let result = event.outputs();
match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
arena,
)
.evidence
.ln(),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
arena,
)
.evidence
.ln(),
}
};
```
(`self.convergence` is `Copy`, so the closure captures it by value naturally without needing a `let` binding outside.)
- [ ] **Step 6: Build and run the full test suite — bit-equal regression net**
Run: `cargo build && cargo test --lib`
Expected: all 98 lib tests still pass. Bit-equal goldens — every existing test uses `History::default()` or `HistoryBuilder::default()` (which sets `convergence = ConvergenceOptions::default()`), so the propagated value equals what the hardcoded default was. No test exercises a non-default convergence through History today, so no behavior changes.
If any test fails: investigate. The most likely cause is a stale `crate::ConvergenceOptions::default()` call missed in steps 1-5 — re-grep with `grep -n "ConvergenceOptions::default" src/time_slice.rs` to find any remaining hardcoded sites.
- [ ] **Step 7: Run integration tests**
Run: `cargo test`
Expected: all 27 integration tests still pass.
- [ ] **Step 8: Confirm no `crate::ConvergenceOptions::default()` remains in time_slice.rs**
Run: `grep -n "ConvergenceOptions::default" src/time_slice.rs`
Expected: only test-mod hits (in `TimeSlice::new(0i64, 0.0, ConvergenceOptions::default())` callsites from Task 1 step 5). NO production-code hits in `Event::iteration_direct`, `sweep_color_groups`, `TimeSlice::iteration`, or `TimeSlice::log_evidence`.
- [ ] **Step 9: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 10: Commit**
```bash
git add src/time_slice.rs
git commit -m "$(cat <<'EOF'
feat(time_slice): inference callsites read self.convergence
The three Game::*_with_arena callsites in time_slice.rs (in
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
run_event closure, and Event::iteration_direct via parameter) now use
the propagated ConvergenceOptions instead of hardcoded ::default().
sweep_color_groups (both rayon and non-rayon paths) forwards
self.convergence into Event::iteration_direct.
Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
HistoryBuilder::convergence(opts) now actually reach the within-game
inference loop. Bit-equal for users on default options.
EOF
)"
```
---
### Task 3: Doc-comment update + end-to-end integration tests
**Files:**
- Modify: `src/convergence.rs` (alpha doc comment)
- Modify: `src/history.rs` (two integration tests in the existing `#[cfg(test)] mod tests` block)
- [ ] **Step 1: Update `ConvergenceOptions::alpha` doc comment**
In `src/convergence.rs`, find the existing doc comment on the `alpha` field. Replace it with:
```rust
/// EP damping factor in natural-parameter space: each per-factor
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
/// undamped (default); `< 1.0` stabilises oscillating fixed-point
/// loops at the cost of more iterations. Must be in `(0.0, 1.0]`.
///
/// Applies only to the within-game EP loop (`run_chain`). The outer
/// `History::converge` cross-history sweep is undamped regardless of
/// this value — cross-slice damping is a different concept and not
/// in scope.
pub alpha: f64,
```
- [ ] **Step 2: Locate the `#[cfg(test)] mod tests` block in `src/history.rs`**
Run: `grep -n "#\[cfg(test)\]" src/history.rs`
Identify the test module (there should be one near the bottom of the file). Read the imports at the top of that module so the new tests can reuse the existing test helpers and scope.
- [ ] **Step 3: Write the failing tests**
Add the following two tests at the end of the test module in `src/history.rs` (just before the module's closing `}`):
```rust
#[test]
fn history_propagates_convergence_to_inner_run_chain() {
use crate::ConvergenceOptions;
// 4-team ranked game; each event needs more than one inner EP iter
// to fully converge.
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
crate::observer::NullObserver, &'static str>| {
for &name in &["a", "b", "c", "d"] {
h.new_agent(name);
}
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.commit()
.unwrap();
};
let mut h_capped = crate::History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_capped);
h_capped.converge().unwrap();
let mut h_full = crate::History::builder().build();
events_for(&mut h_full);
h_full.converge().unwrap();
let curves_capped = h_capped.learning_curves();
let curves_full = h_full.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, capped_pts) in curves_capped.iter() {
let full_pts = curves_full.get(key).expect("agent missing in full");
for (capped, full) in capped_pts.iter().zip(full_pts.iter()) {
max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs());
max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"max_iter=1 inner loop should differ from default; max_diff={max_diff}"
);
}
#[test]
fn history_with_damping_reaches_same_fixed_point_as_undamped() {
use crate::ConvergenceOptions;
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
crate::observer::NullObserver, &'static str>| {
for &name in &["a", "b", "c", "d"] {
h.new_agent(name);
}
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.commit()
.unwrap();
};
let mut h_undamped = crate::History::builder().build();
events_for(&mut h_undamped);
h_undamped.converge().unwrap();
let mut h_damped = crate::History::builder()
.convergence(ConvergenceOptions {
alpha: 0.5,
max_iter: 200,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_damped);
h_damped.converge().unwrap();
let curves_u = h_undamped.learning_curves();
let curves_d = h_damped.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, u_pts) in curves_u.iter() {
let d_pts = curves_d.get(key).expect("agent missing in damped");
for (u, d) in u_pts.iter().zip(d_pts.iter()) {
max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs());
max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs());
}
}
assert!(
max_diff < 1e-3,
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
);
}
```
If the import or method names (e.g. `History::builder()`, `event(...).team(...).commit()`, `learning_curves()`, `new_agent(...)`) don't match what's available in the test module, look at neighboring tests for the exact builder/event-construction pattern in current use and mirror it. The structure (build two Histories, add identical events, compare curves) is the contract; the surface syntax must follow what already works in this test file.
- [ ] **Step 4: Run the new tests**
Run: `cargo test --lib history_propagates_convergence_to_inner_run_chain history_with_damping_reaches_same_fixed_point_as_undamped`
Expected: 2 passed.
**Fallback if Test 1 fails** (`max_iter=1` produces the same posteriors as default — meaning the inner loop converges in one iteration on this graph): replace `max_iter: 1` with `max_iter: 0`. With `max_iter = 0` the inner loop body runs zero times, guaranteeing different posteriors than convergence.
**Fallback if Test 2 fails** (`max_diff` exceeds `1e-3`): raise `max_iter: 200` to `max_iter: 500`. Heavier damping needs more iterations to reach the same fixed point.
If neither fallback works, STOP and report BLOCKED with the actual `max_diff` and the iteration counts tried.
- [ ] **Step 5: Run the full test suite**
Run: `cargo test --lib && cargo test`
Expected: lib count = 100 (was 98), integration count = 27 (unchanged), all passing.
- [ ] **Step 6: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 7: Commit**
```bash
git add src/convergence.rs src/history.rs
git commit -m "$(cat <<'EOF'
test(history): end-to-end ConvergenceOptions propagation tests
Two integration tests on a 4-team ranked event:
- max_iter=1 set on HistoryBuilder produces measurably different
posteriors than default, proving the inner loop honors the
propagated max_iter
- alpha=0.5 with extra iterations reaches the same fixed point as
alpha=1.0, proving damping doesn't break correctness on the History
path
Also updates the alpha doc comment to clarify it applies only to the
within-game EP loop, not the outer cross-history sweep.
EOF
)"
```
---
## Self-review (writer's note)
**Spec coverage:**
- Spec § "What ships" item 1 (TimeSlice convergence field) → Task 1 step 2 ✓
- Spec § "What ships" item 2 (TimeSlice::new signature) → Task 1 step 3 ✓
- Spec § "What ships" item 3 (History passes self.convergence) → Task 1 step 4 ✓
- Spec § "What ships" item 4 (Event::iteration_direct gains parameter) → Task 2 step 1 ✓
- Spec § "What ships" item 4 (callers pass self.convergence) → Task 2 steps 2, 3 ✓
- Spec § "What ships" item 5 (TimeSlice::convergence-method reads field) → Task 2 step 4 ✓
- Spec § "What ships" item 6 (log_evidence reads field) → Task 2 step 5 ✓
- Spec § "What ships" item 7 (test callsite updates) → Task 1 step 5 ✓
- Spec § "Design" rename method → Task 1 step 6 ✓
- Spec § "Risks" alpha doc-comment update → Task 3 step 1 ✓
- Spec § "Testing strategy" §1 (regression net) → Tasks 1 step 7, 2 step 6, 3 step 5 ✓
- Spec § "Testing strategy" §2 (history_propagates_convergence) → Task 3 step 3 test 1 ✓
- Spec § "Testing strategy" §2 (history_with_damping_reaches_same_fixed_point) → Task 3 step 3 test 2 ✓
**Out-of-scope items correctly absent:** No new `History`/`HistoryBuilder` methods, no `ConvergenceOptions` split, no `Damped` Schedule impl, no nat-param convergence switch.
**Type / signature consistency:**
- `TimeSlice::new(time, p_draw, convergence: ConvergenceOptions)` — Task 1 step 3 (def) and Task 1 step 4-5 (call sites) match ✓
- `iteration_direct(skills, agents, p_draw, convergence, arena)` — Task 2 step 1 (def) and steps 2, 3 (call sites) match ✓
- `iterate_to_convergence` — Task 1 step 6 ✓
- All `self.convergence` reads are field accesses, not method calls (the rename in Task 1 step 6 prevents ambiguity) ✓
**Two tasks (1 and 2) split rationale:** Task 1 wires the field but the inference path still uses hardcoded defaults (no behavioral change). Task 2 makes the field actually drive inference (behavioral change for non-default users). Each task is independently committable and the test suite is bit-equal at every checkpoint.
**No placeholders detected.**
@@ -0,0 +1,444 @@
# Tech Debt Cleanup Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Land three independent post-T4-MarginFactor cleanups: dedupe `Game::likelihoods` and `Game::likelihoods_scored` via a `run_chain` helper, make `BuiltinFactor::log_evidence` exhaustive, and fix stale numerics in the T4 plan doc.
**Architecture:** Pure code-shape and doc fixes. No public-API change, no behavioral change, no new dependencies. The dedup is a pure refactor — bit-equal posteriors and evidence against existing test goldens. The exhaustive match is a future-proofing change with no runtime effect. The doc fix is two number swaps in prose plus one matching code-comment swap.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-tech-debt-cleanup-design.md`
## File map
| File | Why touched |
|---|---|
| `src/game.rs` | Add `run_chain` helper; rewrite `likelihoods` and `likelihoods_scored` to call it |
| `src/factor/mod.rs` | Make `BuiltinFactor::log_evidence` match exhaustive |
| `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` | Fix two stale prose numbers and one matching code comment |
---
### Task 1: Extract `run_chain` helper, dedupe both likelihoods methods
**Files:**
- Modify: `src/game.rs:236-485` (replace both `likelihoods` and `likelihoods_scored` with one helper + two thin callers)
**Context for the implementer (read this before touching anything):**
`OwnedGame<T, D>` (defined at `src/game.rs:83-92`) holds `teams`, `result`, `weights`, `p_draw`, plus mutable output fields `likelihoods: Vec<Vec<Gaussian>>` and `evidence: f64`. Two private methods on `Game<'a, T, D>` (the borrowed sibling at `src/game.rs:148-156`) compute likelihoods:
- `likelihoods(&mut self, arena: &mut ScratchArena)` — ranked outcomes; `src/game.rs:236-371`
- `likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` — scored outcomes; `src/game.rs:373-485`
The two are bit-identical except for the closure that builds the per-diff `DiffFactor` (defined at `src/game.rs:20-54`). `DiffFactor` has two variants: `Trunc(TruncFactor)` for ranked, `Margin(MarginFactor)` for scored.
The shared body does, in order: `arena.reset()`, sort teams descending by `result` into `arena.sort_buf`, fill `arena.team_prior`, build `links: Vec<DiffFactor>` (the differing block), resize `arena.lhood_lose` / `arena.lhood_win` to `N_INF`, run a forward+backward sweep with a max-iter-10 fixed-point loop guarded by `tuple_gt(step, 1e-6)`, handle the `n_diffs == 1` special case, do boundary updates, multiply per-diff `evidence()` into `self.evidence`, build the inverse permutation in `arena.inv_buf`, then build `self.likelihoods` from the per-team `lhood_win * lhood_lose` and per-player `performance().exclude(...).forget(beta²)` math.
**Refactor target:**
```rust
fn run_chain<F>(
&self,
arena: &mut ScratchArena,
mut make_link: F,
) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
{ /* the entire shared body, returning (evidence, likelihoods) */ }
```
Helper takes `&self` (not `&mut self`) so the closure can capture `&self.result`, `&self.teams`, `&self.weights`, `&self.p_draw` without conflicting with the helper's own immutable borrow. The arena is borrowed `&mut` independently.
The closure is invoked once per diff index `i ∈ 0..n_diffs`, after `arena.sort_buf` is filled. It receives `i`, `&arena.sort_buf[..]`, and `&mut arena.vars` so it can `alloc(N_INF)` the diff `VarId`. It returns the constructed `DiffFactor`.
The two callers shrink to:
```rust
fn likelihoods(&mut self, arena: &mut ScratchArena) {
let p_draw = self.p_draw;
let result = &self.result;
let teams = &self.teams;
let (evidence, likelihoods) = Self::dummy_to_satisfy_borrowck(/* see below */);
// ... assigns self.evidence and self.likelihoods
}
```
Wait — actually borrow-checker note: calling `self.run_chain(arena, |i, sort_buf, vars| { use_self_fields })` from a `&mut self` method is **fine** because `run_chain` takes `&self` and the closure captures `&self` immutably. Both share an immutable reborrow of `*self`. The arena is a separate `&mut` borrow. Verify the implementer doesn't accidentally make `run_chain` take `&mut self`.
**Why a closure (not a trait, not a two-phase build).** A closure keeps caller-specific state (`p_draw`, `score_sigma`, beta sums) inline at the call site with zero ceremony. A trait would require a stateful builder per call. A two-phase build (caller produces `Vec<DiffFactor>` first, helper does the rest) would either re-do the sort or split arena ownership awkwardly between the phases.
---
- [ ] **Step 1: Run the existing test suite to capture the baseline**
Run: `cargo test --lib`
Expected: all tests pass. Note the count (should be 88+ lib tests) — the refactor must keep this number unchanged with all green.
- [ ] **Step 2: Open `src/game.rs` and add the `run_chain` helper**
Inside `impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> { ... }` (the block starting at `src/game.rs:158`), add `run_chain` immediately above the existing `likelihoods` method (so above line 236). Use exactly this body — it is the merge of the two existing methods with the differing block replaced by the closure call:
```rust
fn run_chain<F>(
&self,
arena: &mut ScratchArena,
mut make_link: F,
) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
{
arena.reset();
let n_teams = self.teams.len();
arena.sort_buf.extend(0..n_teams);
arena.sort_buf.sort_by(|&i, &j| {
self.result[j]
.partial_cmp(&self.result[i])
.unwrap_or(Ordering::Equal)
});
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
self.teams[t]
.iter()
.zip(self.weights[t].iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
}));
let n_diffs = n_teams.saturating_sub(1);
let mut links: Vec<DiffFactor> = (0..n_diffs)
.map(|i| make_link(i, &arena.sort_buf, &mut arena.vars))
.collect();
arena.lhood_lose.resize(n_teams, N_INF);
arena.lhood_win.resize(n_teams, N_INF);
let mut step = (f64::INFINITY, f64::INFINITY);
let mut iter = 0;
while tuple_gt(step, 1e-6) && iter < 10 {
step = (0.0_f64, 0.0_f64);
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
step = tuple_max(step, d);
let new_ll = pw - lf.msg();
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
arena.lhood_lose[e + 1] = new_ll;
}
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
step = tuple_max(step, d);
let new_lw = pl + lf.msg();
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
arena.lhood_win[e] = new_lw;
}
iter += 1;
}
if n_diffs == 1 {
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
- (arena.team_prior[1] * arena.lhood_win[1]);
arena.vars.set(links[0].diff(), raw * links[0].msg());
links[0].propagate(&mut arena.vars);
}
if n_diffs > 0 {
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
arena.lhood_win[0] = pl1 + links[0].msg();
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
}
let evidence: f64 = links.iter().map(|l| l.evidence()).product();
arena.inv_buf.resize(n_teams, 0);
for (si, &orig_i) in arena.sort_buf.iter().enumerate() {
arena.inv_buf[orig_i] = si;
}
let likelihoods = self
.teams
.iter()
.zip(self.weights.iter())
.enumerate()
.map(|(orig_i, (players, weights))| {
let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si] * arena.lhood_lose[si];
let performance = players
.iter()
.zip(weights.iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
players
.iter()
.zip(weights.iter())
.map(|(player, &w)| {
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
.forget(player.beta.powi(2))
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
(evidence, likelihoods)
}
```
- [ ] **Step 3: Replace `likelihoods` body with a thin caller**
In `src/game.rs`, replace the entire body of `fn likelihoods(&mut self, arena: &mut ScratchArena)` (currently lines 236-371 — replace from the opening `{` to the closing `}` of that method) with:
```rust
fn likelihoods(&mut self, arena: &mut ScratchArena) {
let p_draw = self.p_draw;
// Capture pointers to fields the closure reads, to keep borrow scopes tight.
// Closure captures &self.result and &self.teams (both immutable) and the
// &mut arena passed in via run_chain — disjoint from `&self`.
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
let margin = if p_draw == 0.0 {
0.0
} else {
let a: f64 = self.teams[sort_buf[i]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
let b: f64 = self.teams[sort_buf[i + 1]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
compute_margin(p_draw, (a + b).sqrt())
};
let vid = vars.alloc(N_INF);
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
});
self.evidence = evidence;
self.likelihoods = likelihoods;
}
```
(Capturing `p_draw` as a local binding before the closure avoids a `self.p_draw` borrow inside; it's a `Copy` `f64` so this is free.)
- [ ] **Step 4: Replace `likelihoods_scored` body with a thin caller**
In `src/game.rs`, replace the entire body of `fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` (currently lines 373-485) with:
```rust
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
// After descending-by-score sort, m_obs >= 0 for every adjacent pair.
let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
let vid = vars.alloc(N_INF);
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
});
self.evidence = evidence;
self.likelihoods = likelihoods;
}
```
- [ ] **Step 5: Build to confirm it compiles**
Run: `cargo build`
Expected: compiles cleanly. If the borrow checker complains that the closure conflicts with `self.run_chain(...)`, the most likely cause is `run_chain` accidentally being `&mut self` — confirm its signature is `fn run_chain<F>(&self, arena: &mut ScratchArena, mut make_link: F) -> (f64, Vec<Vec<Gaussian>>)`. If that's correct and there's still a conflict, double-check the closure's captures: it should capture `&self.result` and `&self.teams` (immutable), `p_draw: f64` by value (Copy), and `score_sigma: f64` by value (Copy). It must NOT touch `&mut self` in any form.
- [ ] **Step 6: Run the full library test suite — must be all green, same count as Step 1**
Run: `cargo test --lib`
Expected: same number of tests as Step 1, all pass. Bit-equal goldens — every existing assertion (`test_1vs1`, `test_1vs1_draw`, `test_2vs1vs2_mixed`, MarginFactor end-to-end tests, etc.) must pass unchanged. If ANY test fails, the refactor is wrong; revert and re-inspect.
- [ ] **Step 7: Run integration tests too**
Run: `cargo test`
Expected: all integration tests pass (28 noted in commit `8b53cac`).
- [ ] **Step 8: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
Expected: no diffs from fmt, no clippy warnings.
- [ ] **Step 9: Commit**
```bash
git add src/game.rs
git commit -m "$(cat <<'EOF'
refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
Both methods were 95-line near-duplicates differing only in the closure
that builds the per-diff DiffFactor. Extract the shared body as a
private run_chain<F>(&self, arena, make_link) helper that returns
(evidence, likelihoods); the two callers shrink to ~10 lines each.
Pure code-shape change: posteriors and evidence remain bit-equal; all
existing tests (lib + integration) pass unchanged.
EOF
)"
```
---
### Task 2: Make `BuiltinFactor::log_evidence` match exhaustive
**Files:**
- Modify: `src/factor/mod.rs:94-100` (the `log_evidence` impl on `BuiltinFactor`)
- [ ] **Step 1: Open `src/factor/mod.rs` and replace the `log_evidence` body**
Replace the existing impl:
```rust
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
_ => 0.0,
}
}
```
with:
```rust
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
}
}
```
- [ ] **Step 2: Build and run tests**
Run: `cargo build && cargo test --lib`
Expected: compiles cleanly, all tests pass. Behavior is unchanged — `TeamSum` and `RankDiff` still return `0.0`, but a future variant will now produce a non-exhaustive-match error instead of being silently swallowed.
- [ ] **Step 3: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
Expected: no diffs, no warnings.
- [ ] **Step 4: Commit**
```bash
git add src/factor/mod.rs
git commit -m "$(cat <<'EOF'
refactor: make BuiltinFactor::log_evidence match exhaustive
Replace the `_ => 0.0` wildcard with explicit
`Self::TeamSum(_) | Self::RankDiff(_) => 0.0`. No behavioral change;
future variants now produce a compile error instead of being silently
absorbed by the wildcard.
EOF
)"
```
---
### Task 3: Fix stale numerics in T4 plan doc
**Files:**
- Modify: `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` (lines 52 and 185)
The shipped test in `src/factor/mod.rs:163,166` asserts:
```
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
```
The plan's prose at line 52 quotes pre-shipped values that no longer match. This task fixes the prose and the matching code-comment. The full-precision assertion blocks elsewhere in the plan are out of scope (they belong to the plan-as-written, and the spec's fix table only listed the rounded prose values).
- [ ] **Step 1: Update the prose at line 52**
Open `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`. Find the line:
```
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.046827`. So `log_evidence ≈ -3.0613`.
```
Replace with:
```
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.04678`. So `log_evidence ≈ -3.0622`.
```
- [ ] **Step 2: Update the matching code-comment at line 185**
In the same file, find:
```
// pdf(5, 0, sqrt(37)) ≈ 0.046827
```
Replace with:
```
// pdf(5, 0, sqrt(37)) ≈ 0.04678
```
- [ ] **Step 3: Verify nothing else changed**
Run: `git diff docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
Expected: exactly three lines changed (one prose line containing both numbers, one comment line). Nothing else should be touched.
- [ ] **Step 4: Commit**
```bash
git add docs/superpowers/plans/2026-04-27-t4-margin-factor.md
git commit -m "$(cat <<'EOF'
docs: fix stale numerics in t4-margin-factor plan
The plan's prose quoted Z_cav ≈ 0.046827 and log_evidence ≈ -3.0613,
which diverged from the values asserted by the shipped test in
src/factor/mod.rs (-3.062235327364623). Update prose and the matching
code comment to 0.04678 / -3.0622.
EOF
)"
```
---
## Self-review (writer's note)
Spec coverage:
- Spec Item 1 (dedupe `likelihoods`/`likelihoods_scored`) → Task 1 ✓
- Spec Item 2 (exhaustive `BuiltinFactor::log_evidence`) → Task 2 ✓
- Spec Item 3 (stale numerics in T4 plan) → Task 3 ✓
- Spec out-of-scope items (`DiffFactor` collapse, per-event `score_sigma`) — correctly absent ✓
Verification gates per the spec ("each item commits independently and ships behind a green `cargo test --lib`"): every task ends in fmt + clippy + tests + commit. Task 1 additionally runs `cargo test` for integration coverage.
Type / signature consistency:
- `run_chain` signature appears identically in the context header and Step 2 body ✓
- Closure type `FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor` matches across Step 2 (definition) and Steps 3/4 (call sites) ✓
- `DiffFactor::Trunc` / `DiffFactor::Margin` constructors match `src/game.rs:20-23` definitions ✓
No placeholders detected.
@@ -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**
- 1030× 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<Vec<Vec<_>>>` shapes with `Event<T, K>` / `Team<K>` / `Member<K>`
- 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
- ~5060 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<T>` trait
Generic over `T: Time` so seasonal/calendar-aware drift is possible without going through `i64`.
```rust
pub trait Drift<T: Time>: 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<K>`
`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<K>` (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<T: Time>: 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<T: Time> Observer<T> 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<i64, _>` 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<S: Schedule = DefaultSchedule> {
vars: VarStore, // SoA: Vec<Gaussian> marginals
factors: FactorList, // enum dispatch over BuiltinFactor (see Open Questions)
schedule: S,
}
```
Lean toward **enum dispatch** (`enum BuiltinFactor { Perf(...), Sum(...), RankDiff(...), Trunc(...), ... }`) over `Box<dyn Factor>` 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<dyn Factor>)` 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<Gaussian>` 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<dyn Factor>)` 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<Index, T>` becomes a `Vec<T>` (or `Vec<Option<T>>` for sparse) indexed directly by `Index.0`. The public-facing `KeyTable<K>` 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<Gaussian>, // [n_agents]
backward: Vec<Gaussian>,
likelihood: Vec<Gaussian>,
online: Vec<Gaussian>,
elapsed: Vec<i64>,
present: Vec<bool>,
}
```
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<Gaussian>`. Splitting into two parallel `Vec<f64>`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<Gaussian>,
factor_buf: Vec<Gaussian>, // edge messages
bool_buf: Vec<bool>,
f64_buf: Vec<f64>,
}
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<T>` 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<TimeSlice>` 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<bool>` mask for batches the agent didn't participate in. The alternative is (a) **sparse columnar**: a `Vec<Index>` of present agents and parallel `Vec<Gaussian>` 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<T: Time, K> {
pub time: T,
pub teams: SmallVec<[Team<K>; 4]>,
pub outcome: Outcome,
}
pub struct Team<K> {
pub members: SmallVec<[Member<K>; 4]>,
}
pub struct Member<K> {
pub key: K,
pub weight: f64, // default 1.0
pub prior: Option<Rating>, // 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::<i64, _>::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::<Untimed, _>::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<Gaussian>
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: Borrow<K>>(&Q) -> Option<Index>`. 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<K>` (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<W: Workload>(&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` | 24× |
| Natural-param `Gaussian`, no-sqrt mul/div | 1.52× |
| Pre-allocated `ScratchArena` | 1.21.5× |
| Color-group parallel events in slice (8 cores) | 24× |
| Dirty-bit slice skipping (online add case) | 550× |
| **Combined (offline converge)** | ~1030× |
| **Combined (online add)** | ~50500× 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<Index, _>` 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<K>`, `Outcome`, `Event<T, K>`, `KeyTable<K>`.
- `Time` trait introduced; `History<T: Time, D: Drift<T>>` 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`. **Done** (see `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`).
- `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 T0T1 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<dyn Factor>)` 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(...)`.
@@ -0,0 +1,320 @@
# Damped EP — Game-Local Damping
## Summary
Add an opt-in EP damping knob to within-game inference. Users set
`ConvergenceOptions::alpha < 1.0` to damp message updates and stabilise
oscillating fixed-point loops on hard graphs. `alpha = 1.0` (the default)
is bit-equal to today.
This is the smallest-scope realisation of the spec's `Damped` schedule:
**game-local**, not plumbed through the `Schedule` trait. The `Schedule`
trait is shipped infrastructure that `run_chain` does not currently call;
wiring `Schedule` into game inference is a separate future task. This
design touches only what the user can actually reach via `GameOptions`.
## Scope
### What ships
1. New field `ConvergenceOptions::alpha: f64` (default `1.0`).
2. `run_chain` reads `options.convergence.{epsilon, max_iter, alpha}`
instead of the hardcoded `1e-6` / `10` / undamped — fixes the existing
latent bug where the first two were already on `GameOptions` but never
read by inference.
3. `Gaussian::damp_natural(self, new, alpha) -> Gaussian` — public helper
computing `α·new + (1−α)·self` in natural-parameter space.
4. `TruncFactor` and `MarginFactor` gain inherent
`propagate_with_alpha(&mut self, vars, alpha) -> (f64, f64)`. Their
`Factor::propagate` impls become one-line delegations passing
`alpha = 1.0`.
5. `DiffFactor::propagate` (game-private enum at `src/game.rs:20-54`)
gains an `alpha: f64` parameter and dispatches into the underlying
factor's `propagate_with_alpha`.
### What does not ship
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait stays as
it is; integration with `run_chain` is a separate task.
- No nat-param convergence switch. `(|Δmu|, |Δsigma|)` stays the
delta basis (matches today). The spec's "stopping in natural-param
space" wants its own design pass and test re-tuning.
- No oscillation auto-detect. `alpha` is user-supplied and constant for
the duration of a `run_chain` call.
- No `Residual`, `OneShot`, or `SynergyFactor` / `ScoreFactor` work —
separate future plans.
## Design
### `ConvergenceOptions::alpha`
```rust
// src/convergence.rs
#[derive(Clone, Copy, Debug)]
pub struct ConvergenceOptions {
pub max_iter: usize,
pub epsilon: f64,
pub alpha: f64,
}
impl Default for ConvergenceOptions {
fn default() -> Self {
Self {
max_iter: crate::ITERATIONS,
epsilon: crate::EPSILON,
alpha: 1.0,
}
}
}
```
`alpha = 1.0` ⇒ undamped (bit-equal to today). Recommended starting
point if a graph oscillates: `0.5``0.7`. Values approaching `0.0` make
each step tinier and slow convergence; `alpha = 0.0` is degenerate
(factor never updates). Validation in `run_chain`:
```rust
debug_assert!(
opts.convergence.alpha > 0.0 && opts.convergence.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0]"
);
```
### `Gaussian::damp_natural`
```rust
impl Gaussian {
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
///
/// Used by within-game schedules to stabilise oscillating fixed-point
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
/// `alpha < 1.0` shrinks each per-step update.
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(),
alpha * new.tau() + (1.0 - alpha) * self.tau(),
)
}
}
```
Public on `Gaussian`. The name encodes the WHY (EP damping); the doc
comment fixes the math. No new dependency.
The existing `Mul<f64> for Gaussian` is **distribution scaling**
(`sigma → sigma·|scalar|`), not nat-param interpolation, so it can't be
reused here.
### `TruncFactor::propagate_with_alpha`
```rust
impl TruncFactor {
pub(crate) fn propagate_with_alpha(
&mut self,
vars: &mut VarStore,
alpha: f64,
) -> (f64, f64) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
}
let trunc = approx(cavity, self.margin, self.tie);
let new_msg = trunc / cavity;
let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg;
self.msg = damped;
// marginal_new = cavity * stored_msg (NOT cavity * new_msg with damping)
vars.set(self.diff, cavity * damped);
old_msg.delta(damped)
}
}
impl Factor for TruncFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0)
}
}
```
Two important points:
- The variable receives `cavity * damped` (i.e. `cavity * self.msg`),
not `trunc`. With `alpha = 1.0` these are equal (since
`cavity * new_msg = trunc` by construction), so today's behaviour is
preserved bit-equal. With `alpha < 1.0` the marginal reflects the
partially-applied update.
- The reported delta is `old_msg.delta(damped)` — delta of the actually
stored message, not of the raw `new_msg`. This is the textbook EP
damping convention: the convergence loop measures the trajectory it
is actually walking.
`MarginFactor` follows the same shape, with its own
`propagate_with_alpha` body (the existing `propagate` math, with the
`damp_natural` step inserted in the same place and the var write
switched to `cavity * damped`).
### `DiffFactor::propagate` signature
```rust
// src/game.rs
impl DiffFactor {
pub(crate) fn propagate(
&mut self,
vars: &mut VarStore,
alpha: f64,
) -> (f64, f64) {
match self {
Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
}
}
}
```
`DiffFactor` is `pub(crate)` and only used inside `run_chain`, so the
signature change has no public-API impact.
### `run_chain` changes
Inside `Game::run_chain` (`src/game.rs:236-348`):
1. Capture `let alpha = opts.convergence.alpha;` once at the top
(avoids repeated `opts.convergence.alpha` lookups in the hot loop).
2. Replace the loop guard
`while tuple_gt(step, 1e-6) && iter < 10`
with
`while tuple_gt(step, opts.convergence.epsilon) && iter < opts.convergence.max_iter`.
3. Replace each `lf.propagate(&mut arena.vars)` call site (three of
them: forward sweep, backward sweep, `n_diffs == 1` special case)
with `lf.propagate(&mut arena.vars, alpha)`.
The threading of `opts: &GameOptions` into `run_chain` is the only
new caller obligation. Today `run_chain` doesn't take `opts`; the two
callers (`likelihoods`, `likelihoods_scored`) currently invoke it
without options. Both will need to pass the options through. The
`Game<'a, T, D>` struct does not currently hold `GameOptions`; the
options are constructed and discarded around the call to
`{ranked,scored}_with_arena`. So:
- `Game::ranked_with_arena` and `Game::scored_with_arena` already
receive `p_draw` / `score_sigma` as scalar params; we extend them to
accept `&ConvergenceOptions` (or the full `&GameOptions`) too.
- `likelihoods` / `likelihoods_scored` either store the options on
`Game` or accept them as method parameters and forward to
`run_chain`.
The simplest plumbing: store `convergence: ConvergenceOptions` as a
field on `Game<'a, T, D>` and `OwnedGame<T, D>` populated at
construction time. Then `run_chain` can read it from `&self`.
## Convergence semantics
With `alpha < 1.0` the per-step update shrinks; convergence may take
more iterations to reach the same `epsilon` threshold. Users who damp
should also raise `max_iter` accordingly. Documentation example:
```rust
let mut opts = GameOptions::default();
opts.convergence.alpha = 0.5;
opts.convergence.max_iter = 30;
```
## Testing strategy
### Regression net (no new file)
The existing 88 lib tests and 27 integration tests are the bit-equal
regression net. With `alpha = 1.0` (the default), every assertion must
pass unchanged. If any test fails, the damping path leaked into the
undamped trajectory.
### New tests
1. **`Gaussian::damp_natural` arithmetic**
(`src/gaussian.rs` test mod):
- `α = 1.0` returns `new` exactly (bit-equal `pi` and `tau`).
- `α = 0.0` returns `self` exactly.
- `α = 0.5`: pi and tau are exact midpoints in nat-param space.
- Three asserts, no new file.
2. **`TruncFactor::propagate_with_alpha` shrinks the step**
(`src/factor/trunc.rs` test mod):
- Set up a TruncFactor step. Run `propagate_with_alpha(α=1.0)` once,
record `delta_undamped` and the resulting `self.msg`.
- Reset to a fresh factor at the same starting state. Run
`propagate_with_alpha(α=0.5)` once, record `delta_damped` and
`damped_msg`.
- Assert: `damped_msg.pi()` equals `0.5 * undamped_msg.pi() + 0.5 * initial_msg.pi()` within 1e-12 (and same for `tau`).
- Assert: `delta_damped.0 <= delta_undamped.0` (mu-delta is no larger; the relationship is monotone in `α` but not strictly `0.5×` for the `delta()` function which is `(|Δmu|, |Δsigma|)`).
3. **`MarginFactor::propagate_with_alpha` parity**
(`src/factor/margin.rs` test mod):
- Same shape as #2, on a `MarginFactor` step.
4. **`run_chain` honours `ConvergenceOptions::max_iter`**
(in an existing or new game-level test):
- Construct a 4-team ranked game that normally converges in ~5 iterations.
- Set `opts.convergence.max_iter = 1`. Assert the per-iteration
`step` returned (or observable indirectly via posterior delta vs.
the converged answer) is non-zero — i.e. the loop stopped early.
- Set `opts.convergence.max_iter = 30`. Assert posteriors match the
baseline within `epsilon`.
5. **Damping default is `1.0` and produces bit-equal output**
(smoke test, can be a single assertion in an existing test):
- `assert_eq!(ConvergenceOptions::default().alpha, 1.0);`
- Existing goldens prove the bit-equality.
No oscillation-stabilisation test (would require constructing a
pathological graph specifically to oscillate; out of scope for a
minimal ship).
## Verification gates
Per task:
```bash
cargo +nightly fmt
cargo clippy --all-targets -- -D warnings
cargo test --lib
cargo test
```
All must succeed. Test count grows by exactly the new tests above
(roughly +58 lib tests).
## Risks
- **Marginal-update change is subtle.** Switching the variable write
from `trunc` to `cavity * damped` is intentionally a no-op when
`alpha = 1.0` (since `cavity * new_msg = trunc`), but it changes the
arithmetic path. If `Gaussian` arithmetic has any non-associativity
in floating-point that the old form happened to dodge, goldens could
shift by 1 ULP. Mitigation: TDD — write the regression test (run all
existing tests with `alpha = 1.0`) **first**, before changing the
variable-write line.
- **`run_chain` signature change ripples to two callers.** Trivial
but must be done atomically with the field addition on `Game` /
`OwnedGame`.
- **`alpha` validation only in debug builds.** A release build will
silently accept `alpha = 0.0` or `alpha > 1.0` and produce nonsense.
This matches the existing pattern (`debug_assert!` for input
validation in `Game::ranked_with_arena`); upgrading to `Result` is
out of scope.
## Out-of-scope follow-ups (logged for future plans)
- Wire `Schedule` into `run_chain` (so `Damped` lands as a real
`Schedule` impl alongside `EpsilonOrMax`).
- Switch convergence check to `(|Δpi|, |Δtau|)` per spec
§"Stopping in natural-param space".
- Oscillation auto-detect (engage `alpha < 1.0` only after N
non-monotone steps).
- `Residual` schedule (priority queue).
- `SynergyFactor`, `ScoreFactor` (new EP factor types).
@@ -0,0 +1,232 @@
# History → TimeSlice ConvergenceOptions Plumbing
## Summary
Make `History`'s already-public `ConvergenceOptions` (set via
`HistoryBuilder::convergence(...)`) actually reach the within-game
inference loop. Today it's read by the outer `History::converge` sweep
but dropped on the floor when constructing `TimeSlice`s, so users who
opt in to `alpha < 1.0` (Damped EP) on a `History` get nothing — the
inner `run_chain` calls inside `TimeSlice` hardcode
`ConvergenceOptions::default()`.
This spec closes the gap with one focused change: thread
`ConvergenceOptions` from `History` through `TimeSlice` to the three
`Game::*_with_arena` callsites in `time_slice.rs`. No new types, no new
public methods on `History` or `HistoryBuilder` — the user-facing API
already exists.
## Background
After T5 (commit `0705986`) of the Damped EP plan,
`Game::*_with_arena` accepts `convergence: ConvergenceOptions` and
`run_chain` reads `self.convergence.{epsilon, max_iter, alpha}`.
`HistoryBuilder` already has a `convergence(opts)` method (`history.rs:91`)
that stores onto a field on `History`. `History::converge` reads
`self.convergence.{max_iter, epsilon}` for its outer cross-history loop
(`history.rs:437-447`).
The break is here, in `History::add_events_with_prior` at `history.rs:597`:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw);
```
`self.convergence` is not passed. `TimeSlice` has no convergence field.
The three callsites in `time_slice.rs` that build `Game::*_with_arena`
fall back to `ConvergenceOptions::default()`:
- `Event::iteration_direct` (`time_slice.rs:138-156`)
- `TimeSlice::convergence` (`time_slice.rs:332-345`)
- `TimeSlice::log_evidence` (`time_slice.rs:521-538`)
## Scope
### What ships
1. `TimeSlice<T>` gains a `pub(crate) convergence: ConvergenceOptions`
field set at construction.
2. `TimeSlice::new` signature becomes
`pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self`.
3. `History::add_events_with_prior` (`history.rs:597`) passes
`self.convergence` when constructing new `TimeSlice`s.
4. `Event::iteration_direct` gains a `convergence: ConvergenceOptions`
parameter and forwards it to the `Game::*_with_arena` callsite.
The two callers (`TimeSlice::iteration` at `time_slice.rs:419` and
`:441`) pass `self.convergence`.
5. `TimeSlice::convergence` (the method, not the field) replaces its
hardcoded `crate::ConvergenceOptions::default()` with
`self.convergence`.
6. `TimeSlice::log_evidence` does the same.
7. Five test callsites of `TimeSlice::new(time, p_draw)` updated
mechanically to `TimeSlice::new(time, p_draw, ConvergenceOptions::default())`.
### What does not ship
- No split of `ConvergenceOptions` into outer/inner fields. The
conflation (one `max_iter` covers both the cross-history sweep and
the per-game EP iteration cap) is the user-confirmed design.
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait is still
not integrated into `run_chain`.
- No nat-param convergence switch.
- No oscillation auto-detect.
- No new `History` or `HistoryBuilder` methods. `convergence(opts)`
already exists and works.
- No changes to `History::converge` — the outer-loop semantics are
unchanged (it already reads `self.convergence`).
## Design
### `TimeSlice<T>` field
```rust
// src/time_slice.rs
pub struct TimeSlice<T: Time = i64> {
// ... existing fields ...
p_draw: f64,
pub(crate) convergence: ConvergenceOptions,
// ... existing fields ...
}
```
### `TimeSlice::new`
```rust
impl<T: Time> TimeSlice<T> {
pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self {
Self {
// ... existing initialisation ...
p_draw,
convergence,
// ...
}
}
}
```
### `History::add_events_with_prior` — single-line fix
At `src/history.rs:597`:
```rust
// before
let mut time_slice = TimeSlice::new(t, self.p_draw);
// after
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
```
### `Event::iteration_direct` parameter
```rust
// src/time_slice.rs
impl Event {
pub(crate) fn iteration_direct(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<i64, ConstantDrift>,
p_draw: f64,
convergence: ConvergenceOptions,
arena: &mut ScratchArena,
) -> /* existing return */ {
// ... existing body, with the Game::*_with_arena calls
// using `convergence` instead of ConvergenceOptions::default() ...
}
}
```
The two callers — `TimeSlice::iteration` at `time_slice.rs:419` and
`:441` — already have `&mut self` access, so they pass
`self.convergence`.
### `TimeSlice::convergence` method (not the field)
The method `pub(crate) fn convergence<D>(&mut self, agents: ...) -> usize`
at `time_slice.rs:447` shares its name with the new field. Rust allows
this (methods and fields live in different namespaces), but it's a
readability hazard. Rename the method to `iterate_to_convergence` to
disambiguate.
This is one rename, six callsites in `history.rs` and the test module.
### Field semantics
`History` keeps the single shared `ConvergenceOptions` struct. The same
`max_iter` covers both the outer sweep and each inner per-game loop.
The same `epsilon` covers both stopping criteria. The `alpha` field is
read only inside `run_chain` (the inner loop); the outer loop
intentionally ignores `alpha` because cross-history damping is a
different mathematical concept and not in scope.
## Testing strategy
### Regression net
The existing 98 lib + 27 integration tests are the bit-equal regression
net. Default `ConvergenceOptions` is unchanged
(`max_iter=30, epsilon=1e-6, alpha=1.0`), and `TimeSlice` was already
using exactly that since T5. The only behavioural difference is for
users who actually pass non-default options through
`HistoryBuilder::convergence(...)` — and there are no current tests that
do that **and** compare posteriors, so all goldens stay bit-equal.
### New tests
1. **`history_propagates_convergence_to_inner_run_chain`** (in
`src/history.rs` test module):
- Build a History with `convergence(ConvergenceOptions { max_iter: 1, ..Default::default() })`.
- Add a small batch of events that needs more than one inner EP iteration to converge (e.g. a 4-team game per slice).
- `converge()`, capture posteriors.
- Build a fresh History with default options on the same events.
- `converge()`, capture posteriors.
- Assert the two sets of posteriors differ measurably (max diff > 1e-6).
- Proves the inner loop honours the propagated `max_iter`. Today (without this change) the assertion would fail because both Histories use default inside.
2. **`history_with_damping_reaches_same_fixed_point_as_undamped`** (same
test module):
- Build a History with `convergence(ConvergenceOptions { alpha: 0.5, max_iter: 200, ..Default::default() })`.
- Same events as above.
- `converge()`, capture posteriors.
- Build a default-options History on the same events.
- `converge()`, capture posteriors.
- Assert per-player posteriors agree within 1e-3.
- Proves damping doesn't break convergence on the History path.
If the second test's max diff is too large, raise `max_iter` further
(damping needs more iterations to reach the same fixed point).
## Verification gates
```bash
cargo +nightly fmt
cargo clippy --all-targets -- -D warnings
cargo test --lib
cargo test
```
All must succeed. Test count grows by exactly 2 (the two new tests).
## Risks
- **`TimeSlice::new` is `pub`.** Adding the third parameter is a
breaking change to a public constructor. In a 0.1.x crate this is
acceptable, but flag it in the commit message.
- **`TimeSlice::convergence` method rename.** Renaming
`convergence``iterate_to_convergence` touches `history.rs` and the
TimeSlice test module. The rename is mechanical and improves
readability where the field and method would otherwise share a name.
- **Cross-history alpha semantics.** A user who sets `alpha = 0.5` on
a `History` gets damping inside every per-game loop, but the outer
`History::converge` sweep is undamped. This is the correct semantic
(alpha is a within-EP-graph concept) but it's worth documenting in
the `ConvergenceOptions::alpha` doc comment so users don't expect
cross-slice damping. Add one sentence to the existing doc comment.
## Out-of-scope follow-ups
- Wire `Schedule` trait into `run_chain` — Damped becomes a `Schedule`
impl alongside `EpsilonOrMax`.
- Per-loop `ConvergenceOptions` split (outer / inner).
- `Residual` schedule.
- Per-event `EventKind::Scored.score_sigma` override (still
history-wide today).
@@ -0,0 +1,134 @@
# Tech Debt Cleanup — Post-T4-MarginFactor
## Summary
Three small, independent cleanups left behind by the T4-MarginFactor merge
(`8b53cac`). All three are pure code-shape or doc fixes. No public-API change,
no numerics change, no new behavior.
This batch deliberately excludes the `DiffFactor``BuiltinFactor` overlap
collapse (architectural change kept separate) and per-event `score_sigma`
override (a feature, not debt).
## Scope
### Item 1 — Deduplicate `Game::likelihoods` and `Game::likelihoods_scored`
**Current state.** `src/game.rs:236-371` and `src/game.rs:373-485` are 95-line
near-duplicates of each other. They differ in exactly one block: the closure
that maps a diff index to a `DiffFactor`. The ranked path builds
`DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))` with `margin`/`tie`
derived from `p_draw` and adjacent-result equality. The scored path builds
`DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))` with `m_obs`
the observed score gap. Everything else — sort, `team_prior`, sweep loop,
boundary updates, evidence product, posterior `likelihoods` — is bit-identical.
**Refactor.** Extract a private helper on `OwnedGame<T, D>`:
```rust
fn run_chain<F>(
&self,
arena: &mut ScratchArena,
make_link: F,
) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut VarStore) -> DiffFactor,
```
The closure receives the diff index `i`, the descending-by-result sort
permutation `&arena.sort_buf`, and `&mut arena.vars` for `alloc(N_INF)`. It
returns the `DiffFactor` for that diff slot.
The helper takes `&self` (not `&mut self`) and returns
`(evidence, likelihoods)`. Each caller writes the results back to its own
`self.evidence` and `self.likelihoods` fields. The `&self` choice matters: the
closure captures `&self.result` / `&self.teams` / `&self.weights` / `&self.p_draw`
freely without conflicting with the helper's own immutable borrow.
The two public methods shrink from ~125 lines each to ~10 lines that just
construct the closure.
**Why a closure (not a trait or two-phase build).** A closure keeps all
caller-specific state (`p_draw`, `score_sigma`, beta sums for margin) inline at
the call site. A trait would require a stateful object per call; a two-phase
build (caller produces the `Vec<DiffFactor>` first, helper does the rest) would
either re-do the sort or split state ownership awkwardly between phases.
### Item 2 — Make `BuiltinFactor::log_evidence` exhaustive
**Current state.** `src/factor/mod.rs:94-100` uses a `_ => 0.0` wildcard for
`TeamSum` and `RankDiff`. When a future variant lands (e.g. `SynergyFactor`),
the wildcard silently absorbs it instead of forcing a deliberate decision.
**Refactor.**
```rust
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
}
}
```
No behavioral change. Future variants now produce a non-exhaustive-match
compile error.
### Item 3 — Fix stale numerics in T4 plan doc
**Current state.** `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
contains two numbers that diverge from the values asserted by the shipped test
in `src/factor/mod.rs:163,166`.
**Fix.**
| Doc value (wrong) | Implementation value (correct) |
|---|---|
| `0.046827` | `0.04678` |
| `-3.0613` | `-3.0622` |
Pure docs change. Verified by reading the asserted constants in the test.
## Out of scope
- **`DiffFactor``BuiltinFactor` overlap.** Both enums list `Trunc` and
`Margin` variants. Collapsing into `BuiltinFactor::Diff(DiffFactor)` is
defensible but is an architectural change that wants its own design pass.
`DiffFactor` represents a real semantic subset (factors that operate on a
diff variable in a chain); the duplication is two enum variants, not a
large block of code.
- **Per-event `EventKind::Scored.score_sigma` override.** Today
`score_sigma` is history-wide (set on `HistoryBuilder::score_sigma`). A
per-event override is a real feature ask, not tech debt.
## Verification
Each item commits independently and ships behind a green `cargo test --lib`
run. The dedup is a pure code-shape change: posteriors and evidence must be
**bit-equal** (not ULP-bounded) against the existing 88+28 test goldens.
Per-item gate before committing:
```bash
cargo +nightly fmt
cargo clippy
cargo test --lib
```
## Commit shape
Three commits, one per item, each independently revertable:
1. `refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain`
2. `refactor: make BuiltinFactor::log_evidence match exhaustive`
3. `docs: fix stale numerics in t4-margin-factor plan`
## Risks
- **Borrow-checker friction in Item 1.** The closure captures fields of
`&self` while the helper iterates over arena state. Mitigation: helper is
`&self` (not `&mut self`); arena passed as `&mut ScratchArena` separately.
Disjoint borrows.
- **Compile error in Item 2 if a new variant ships before this lands.**
Trivial follow-on; the whole point is to surface that signal.
+59 -55
View File
@@ -1,50 +1,62 @@
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<Event<i64, String>> = 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<i64, _, _, String> = History::builder_with_key()
.sigma(1.6)
.drift(ConstantDrift(0.036))
.convergence(trueskill_tt::ConvergenceOptions {
max_iter: 10,
epsilon: 0.01,
alpha: 1.0,
})
.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 +73,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 +92,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 +118,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);
@@ -159,10 +161,12 @@ fn main() {
}
mod csv {
use std::fs::File;
use std::io::{self, BufRead, BufReader, Lines};
use std::ops;
use std::path::Path;
use std::{
fs::File,
io::{self, BufRead, BufReader, Lines},
ops,
path::Path,
};
pub struct Reader {
header_map: Vec<String>,
+59
View File
@@ -0,0 +1,59 @@
//! Worked example: continuous-score outcomes via `Outcome::Scored`.
//!
//! Three players play a small round-robin where the score margin matters,
//! not just who won. We show how `score_sigma` controls how much weight
//! the engine places on the observed margin.
//!
//! Run with: `cargo run --example scored --release`
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
fn main() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.03))
.score_sigma(2.0) // tune to data; smaller = trust margins more
.build();
let events: Vec<Event<i64, &'static str>> = vec![
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("alice")]),
Team::with_members([Member::new("bob")]),
],
outcome: Outcome::scores([21.0, 9.0]),
},
Event {
time: 2,
teams: smallvec![
Team::with_members([Member::new("bob")]),
Team::with_members([Member::new("carol")]),
],
outcome: Outcome::scores([21.0, 18.0]),
},
Event {
time: 3,
teams: smallvec![
Team::with_members([Member::new("alice")]),
Team::with_members([Member::new("carol")]),
],
outcome: Outcome::scores([21.0, 21.0]),
},
];
h.add_events(events).unwrap();
let report = h.converge().unwrap();
println!(
"converged={}, iterations={}, log_evidence={:.4}",
report.converged, report.iterations, report.log_evidence
);
for who in &["alice", "bob", "carol"] {
let s = h.current_skill(who).unwrap();
println!("{:>6}: mu={:>7.3} sigma={:.3}", who, s.mu(), s.sigma());
}
}
-64
View File
@@ -1,64 +0,0 @@
vars: {
d2-config: {
layout-engine: elk
# Terminal theme code
theme-id: 300
}
}
History: {
shape: class
agents: "HashMap<Index, Agent>"
batches: "Vec<Batch>"
}
Batch: {
shape: class
skills: "HashMap<Index, Skill>"
events: "Vec<Event>"
time: "i64"
p_draw: "f64"
}
Event: {
shape: class
teams: "Vec<Team>"
weights: "Vec<Vec<f64>>"
evidence: "f64"
}
Team: {
shape: class
items: "Vec<Item>"
output: "f64"
}
Item: {
shape: class
agent: "Index"
likelihood: "Gaussian"
}
Skill: {
shape: class
forward: "Gaussian"
backward: "Gaussian"
likelihood: "Gaussian"
elapsed: "i64"
online: "Gaussian"
}
History -> Batch
Batch -> Skill
Batch -> Event
Event -> Team
Team -> Item
+2
View File
@@ -0,0 +1,2 @@
publish = false
pre-release-hook = ["sh", "-c", "git cliff -o CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"]
+2
View File
@@ -0,0 +1,2 @@
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
-47
View File
@@ -1,47 +0,0 @@
use crate::{
N_INF,
drift::{ConstantDrift, Drift},
gaussian::Gaussian,
player::Player,
};
#[derive(Debug)]
pub struct Agent<D: Drift = ConstantDrift> {
pub player: Player<D>,
pub message: Gaussian,
pub last_time: i64,
}
impl<D: Drift> Agent<D> {
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<ConstantDrift> {
fn default() -> Self {
Self {
player: Player::default(),
message: N_INF,
last_time: i64::MIN,
}
}
}
pub(crate) fn clean<'a, D: Drift + 'a, A: Iterator<Item = &'a mut Agent<D>>>(
agents: A,
last_time: bool,
) {
for a in agents {
a.message = N_INF;
if last_time {
a.last_time = i64::MIN;
}
}
}
+6 -6
View File
@@ -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)
}
}
+56
View File
@@ -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<usize>,
pub(crate) inv_buf: Vec<usize>,
pub(crate) team_prior: Vec<Gaussian>,
pub(crate) lhood_lose: Vec<Gaussian>,
pub(crate) lhood_win: Vec<Gaussian>,
}
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);
}
}
-646
View File
@@ -1,646 +0,0 @@
use std::collections::HashMap;
use crate::{
Index, N_INF, agent::Agent, drift::Drift, game::Game, gaussian::Gaussian, player::Player,
tuple_gt, tuple_max,
};
#[derive(Debug)]
pub(crate) struct Skill {
pub(crate) forward: Gaussian,
backward: Gaussian,
likelihood: Gaussian,
pub(crate) elapsed: i64,
pub(crate) online: Gaussian,
}
impl Skill {
pub(crate) fn posterior(&self) -> Gaussian {
self.likelihood * self.backward * self.forward
}
}
impl Default for Skill {
fn default() -> Self {
Self {
forward: N_INF,
backward: N_INF,
likelihood: N_INF,
elapsed: 0,
online: N_INF,
}
}
}
#[derive(Debug)]
struct Item {
agent: Index,
likelihood: Gaussian,
}
impl Item {
fn within_prior<D: Drift>(
&self,
online: bool,
forward: bool,
skills: &HashMap<Index, Skill>,
agents: &HashMap<Index, Agent<D>>,
) -> Player<D> {
let r = &agents[&self.agent].player;
let skill = &skills[&self.agent];
if online {
Player::new(skill.online, r.beta, r.drift)
} else if forward {
Player::new(skill.forward, r.beta, r.drift)
} else {
Player::new(skill.posterior() / self.likelihood, r.beta, r.drift)
}
}
}
#[derive(Debug)]
struct Team {
items: Vec<Item>,
output: f64,
}
#[derive(Debug)]
pub(crate) struct Event {
teams: Vec<Team>,
evidence: f64,
weights: Vec<Vec<f64>>,
}
impl Event {
fn outputs(&self) -> Vec<f64> {
self.teams
.iter()
.map(|team| team.output)
.collect::<Vec<_>>()
}
pub(crate) fn within_priors<D: Drift>(
&self,
online: bool,
forward: bool,
skills: &HashMap<Index, Skill>,
agents: &HashMap<Index, Agent<D>>,
) -> Vec<Vec<Player<D>>> {
self.teams
.iter()
.map(|team| {
team.items
.iter()
.map(|item| item.within_prior(online, forward, skills, agents))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
}
#[derive(Debug)]
pub struct Batch {
pub(crate) events: Vec<Event>,
pub(crate) skills: HashMap<Index, Skill>,
pub(crate) time: i64,
p_draw: f64,
}
impl Batch {
pub fn new(time: i64, p_draw: f64) -> Self {
Self {
events: Vec::new(),
skills: HashMap::new(),
time,
p_draw,
}
}
pub fn add_events<D: Drift>(
&mut self,
composition: Vec<Vec<Vec<Index>>>,
results: Vec<Vec<f64>>,
weights: Vec<Vec<Vec<f64>>>,
agents: &HashMap<Index, Agent<D>>,
) {
let mut unique = Vec::with_capacity(10);
let this_agent = composition.iter().flatten().flatten().filter(|idx| {
if !unique.contains(idx) {
unique.push(*idx);
return true;
}
false
});
for idx in this_agent {
let elapsed = compute_elapsed(agents[&idx].last_time, self.time);
if let Some(skill) = self.skills.get_mut(idx) {
skill.elapsed = elapsed;
skill.forward = agents[&idx].receive(elapsed);
} else {
self.skills.insert(
*idx,
Skill {
forward: agents[&idx].receive(elapsed),
elapsed,
..Default::default()
},
);
}
}
let events = composition.iter().enumerate().map(|(e, event)| {
let teams = event
.iter()
.enumerate()
.map(|(t, team)| {
let items = team
.iter()
.map(|&agent| Item {
agent,
likelihood: N_INF,
})
.collect::<Vec<_>>();
Team {
items,
output: if results.is_empty() {
(event.len() - (t + 1)) as f64
} else {
results[e][t]
},
}
})
.collect::<Vec<_>>();
let weights = if weights.is_empty() {
teams
.iter()
.map(|team| vec![1.0; team.items.len()])
.collect::<Vec<_>>()
} else {
weights[e].clone()
};
Event {
teams,
evidence: 0.0,
weights,
}
});
let from = self.events.len();
self.events.extend(events);
self.iteration(from, agents);
}
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
self.skills
.iter()
.map(|(&idx, skill)| (idx, skill.posterior()))
.collect::<HashMap<_, _>>()
}
pub fn iteration<D: Drift>(&mut self, from: usize, agents: &HashMap<Index, Agent<D>>) {
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);
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];
item.likelihood = g.likelihoods[t][i];
}
}
event.evidence = g.evidence;
}
}
#[allow(dead_code)]
pub(crate) fn convergence<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) -> usize {
let epsilon = 1e-6;
let iterations = 20;
let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0;
while tuple_gt(step, epsilon) && i < iterations {
let old = self.posteriors();
self.iteration(0, agents);
let new = self.posteriors();
step = old.iter().fold((0.0, 0.0), |step, (a, old)| {
tuple_max(step, old.delta(new[a]))
});
i += 1;
}
i
}
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian {
let skill = &self.skills[agent];
skill.forward * skill.likelihood
}
pub(crate) fn backward_prior_out<D: Drift>(
&self,
agent: &Index,
agents: &HashMap<Index, Agent<D>>,
) -> Gaussian {
let skill = &self.skills[agent];
let n = skill.likelihood * skill.backward;
n.forget(agents[agent].player.drift.variance_delta(skill.elapsed))
}
pub(crate) fn new_backward_info<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) {
for (agent, skill) in self.skills.iter_mut() {
skill.backward = agents[agent].message;
}
self.iteration(0, agents);
}
pub(crate) fn new_forward_info<D: Drift>(&mut self, agents: &HashMap<Index, Agent<D>>) {
for (agent, skill) in self.skills.iter_mut() {
skill.forward = agents[agent].receive(skill.elapsed);
}
self.iteration(0, agents);
}
pub(crate) fn log_evidence<D: Drift>(
&self,
online: bool,
targets: &[Index],
forward: bool,
agents: &HashMap<Index, Agent<D>>,
) -> f64 {
if targets.is_empty() {
if online || forward {
self.events
.iter()
.enumerate()
.map(|(_, event)| {
Game::new(
event.within_priors(online, forward, &self.skills, agents),
&event.outputs(),
&event.weights,
self.p_draw,
)
.evidence
.ln()
})
.sum()
} else {
self.events.iter().map(|event| event.evidence.ln()).sum()
}
} else if online || forward {
self.events
.iter()
.enumerate()
.filter(|(_, event)| {
event
.teams
.iter()
.flat_map(|team| &team.items)
.any(|item| targets.contains(&item.agent))
})
.map(|(_, event)| {
Game::new(
event.within_priors(online, forward, &self.skills, agents),
&event.outputs(),
&event.weights,
self.p_draw,
)
.evidence
.ln()
})
.sum()
} else {
self.events
.iter()
.filter(|event| {
event
.teams
.iter()
.flat_map(|team| &team.items)
.any(|item| targets.contains(&item.agent))
})
.map(|event| event.evidence.ln())
.sum()
}
}
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
self.events
.iter()
.map(|event| {
event
.teams
.iter()
.map(|team| team.items.iter().map(|item| item.agent).collect::<Vec<_>>())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
pub fn get_results(&self) -> Vec<Vec<f64>> {
self.events
.iter()
.map(|event| {
event
.teams
.iter()
.map(|team| team.output)
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
}
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
}
}
#[cfg(test)]
mod tests {
use approx::assert_ulps_eq;
use crate::{IndexMap, agent::Agent, drift::ConstantDrift, player::Player};
use super::*;
#[test]
fn test_one_event_each() {
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 mut agents = HashMap::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
Agent {
player: Player::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut batch = Batch::new(0, 0.0);
batch.add_events(
vec![
vec![vec![a], vec![b]],
vec![vec![c], vec![d]],
vec![vec![e], vec![f]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
&agents,
);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&d],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&e],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&f],
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
assert_eq!(batch.convergence(&agents), 1);
}
#[test]
fn test_same_strength() {
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 mut agents = HashMap::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
Agent {
player: Player::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut batch = Batch::new(0, 0.0);
batch.add_events(
vec![
vec![vec![a], vec![b]],
vec![vec![a], vec![c]],
vec![vec![b], vec![c]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
&agents,
);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(24.960978, 6.298544),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(27.095590, 6.010330),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(24.889681, 5.866311),
epsilon = 1e-6
);
assert!(batch.convergence(&agents) > 1);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
}
#[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 d = index_map.get_or_create("d");
let e = index_map.get_or_create("e");
let f = index_map.get_or_create("f");
let mut agents = HashMap::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
Agent {
player: Player::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut batch = Batch::new(0, 0.0);
batch.add_events(
vec![
vec![vec![a], vec![b]],
vec![vec![a], vec![c]],
vec![vec![b], vec![c]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
&agents,
);
batch.convergence(&agents);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
batch.add_events(
vec![
vec![vec![a], vec![b]],
vec![vec![a], vec![c]],
vec![vec![b], vec![c]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
&agents,
);
assert_eq!(batch.events.len(), 6);
batch.convergence(&agents);
let post = batch.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(25.000003, 3.880150),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000003, 3.880150),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000003, 3.880150),
epsilon = 1e-6
);
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Greedy graph coloring for within-slice event independence.
//!
//! Events sharing no `Index` can be processed in parallel under async-EP
//! semantics. This module partitions a list of events into "colors" such
//! that events of the same color touch disjoint index sets.
//!
//! The algorithm is greedy: for each event in ingestion order, place it in
//! the lowest-numbered color whose existing members share no `Index`. If
//! no existing color accepts the event, open a new color.
//!
//! Complexity: O(n × c × m) where n is events, c is colors (small, ≤ 5 in
//! practice), and m is average team size.
use std::collections::HashSet;
use crate::Index;
/// Partition of event indices into color groups.
///
/// Each inner `Vec<usize>` holds the indices (into the original events
/// array) of events assigned to one color. Colors are iterated in ascending
/// order by convention.
#[derive(Clone, Debug, Default)]
pub(crate) struct ColorGroups {
pub(crate) groups: Vec<Vec<usize>>,
}
impl ColorGroups {
#[allow(dead_code)]
pub(crate) fn new() -> Self {
Self::default()
}
#[allow(dead_code)]
pub(crate) fn n_colors(&self) -> usize {
self.groups.len()
}
#[allow(dead_code)]
pub(crate) fn is_empty(&self) -> bool {
self.groups.is_empty()
}
/// Total event count across all colors.
#[allow(dead_code)]
pub(crate) fn total_events(&self) -> usize {
self.groups.iter().map(|g| g.len()).sum()
}
/// Contiguous index range for one color after events have been reordered
/// into color-contiguous positions by `TimeSlice::recompute_color_groups`.
#[allow(dead_code)]
pub(crate) fn color_range(&self, color_idx: usize) -> std::ops::Range<usize> {
let group = &self.groups[color_idx];
if group.is_empty() {
return 0..0;
}
let start = *group.first().unwrap();
let end = *group.last().unwrap() + 1;
start..end
}
}
/// Compute color groups greedily.
///
/// `index_set(ev_idx)` yields, for each event index, the iterator of
/// `Index` values that event touches. The returned `ColorGroups` has one
/// inner `Vec<usize>` per color, containing event indices in the order
/// they were assigned.
#[allow(dead_code)]
pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups
where
F: Fn(usize) -> I,
I: IntoIterator<Item = Index>,
{
let mut groups: Vec<Vec<usize>> = Vec::new();
let mut members: Vec<HashSet<Index>> = Vec::new();
for ev_idx in 0..n_events {
let ev_members: HashSet<Index> = index_set(ev_idx).into_iter().collect();
// Find first color whose member-set is disjoint from this event's indices.
let chosen = members.iter().position(|m| m.is_disjoint(&ev_members));
let color_idx = match chosen {
Some(c) => c,
None => {
groups.push(Vec::new());
members.push(HashSet::new());
groups.len() - 1
}
};
groups[color_idx].push(ev_idx);
members[color_idx].extend(ev_members);
}
ColorGroups { groups }
}
#[cfg(test)]
mod tests {
use super::*;
fn idx(i: usize) -> Index {
Index::from(i)
}
#[test]
fn single_event_gets_one_color() {
let cg = color_greedy(1, |_| vec![idx(0), idx(1)]);
assert_eq!(cg.n_colors(), 1);
assert_eq!(cg.groups[0], vec![0]);
}
#[test]
fn disjoint_events_share_a_color() {
let cg = color_greedy(2, |i| match i {
0 => vec![idx(0), idx(1)],
1 => vec![idx(2), idx(3)],
_ => unreachable!(),
});
assert_eq!(cg.n_colors(), 1);
assert_eq!(cg.groups[0], vec![0, 1]);
}
#[test]
fn overlapping_events_need_separate_colors() {
let cg = color_greedy(2, |i| match i {
0 => vec![idx(0), idx(1)],
1 => vec![idx(1), idx(2)],
_ => unreachable!(),
});
assert_eq!(cg.n_colors(), 2);
assert_eq!(cg.groups[0], vec![0]);
assert_eq!(cg.groups[1], vec![1]);
}
#[test]
fn three_events_two_colors() {
// Event 0: {0, 1}; event 1: {2, 3}; event 2: {0, 2}.
// Greedy: ev0→c0, ev1→c0 (disjoint), ev2 overlaps both→c1.
let cg = color_greedy(3, |i| match i {
0 => vec![idx(0), idx(1)],
1 => vec![idx(2), idx(3)],
2 => vec![idx(0), idx(2)],
_ => unreachable!(),
});
assert_eq!(cg.n_colors(), 2);
assert_eq!(cg.groups[0], vec![0, 1]);
assert_eq!(cg.groups[1], vec![2]);
}
#[test]
fn total_events_counts_correctly() {
let cg = color_greedy(4, |_| vec![idx(0)]);
// All events touch index 0 → 4 distinct colors.
assert_eq!(cg.n_colors(), 4);
assert_eq!(cg.total_events(), 4);
}
}
+71
View File
@@ -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<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub rating: Rating<T, D>,
pub message: Gaussian,
pub last_time: Option<T>,
}
impl<T: Time, D: Drift<T>> Competitor<T, D> {
/// 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<i64, ConstantDrift> {
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<T> + 'a,
C: Iterator<Item = &'a mut Competitor<T, D>>,
{
for c in competitors {
c.message = N_INF;
if last_time {
c.last_time = None;
}
}
}
+53
View File
@@ -0,0 +1,53 @@
//! 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,
/// EP damping factor in natural-parameter space: each per-factor
/// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
/// undamped (default); `< 1.0` stabilises oscillating fixed-point
/// loops at the cost of more iterations. Must be in `(0.0, 1.0]`.
///
/// Applies only to the within-game EP loop (`run_chain`). The outer
/// `History::converge` cross-history sweep is undamped regardless of
/// this value — cross-slice damping is a different concept and not
/// in scope.
pub alpha: f64,
}
impl Default for ConvergenceOptions {
fn default() -> Self {
Self {
max_iter: crate::ITERATIONS,
epsilon: crate::EPSILON,
alpha: 1.0,
}
}
}
/// 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,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_alpha_is_one_for_undamped_behavior() {
let opts = ConvergenceOptions::default();
assert_eq!(opts.alpha, 1.0);
}
}
+27 -5
View File
@@ -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<T: Time>: Copy + Debug + Send + Sync {
/// 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<T: Time> Drift<T> 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
}
}
+56
View File
@@ -0,0 +1,56 @@
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 },
/// A scalar parameter is outside its valid range.
InvalidParameter { name: &'static str, 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::InvalidParameter { name, value } => {
write!(f, "{name} is invalid: {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 {}
+132
View File
@@ -0,0 +1,132 @@
//! Typed event description for bulk ingestion.
//!
//! `Event<T, K>` is the new public event shape (spec Section 4). Replaces
//! the nested `Vec<Vec<Vec<Index>>>`, `Vec<Vec<f64>>`, `Vec<Vec<Vec<f64>>>`
//! 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<T: Time, K> {
pub time: T,
pub teams: SmallVec<[Team<K>; 4]>,
pub outcome: Outcome,
}
/// A team: list of members competing together.
#[derive(Clone, Debug)]
pub struct Team<K> {
pub members: SmallVec<[Member<K>; 4]>,
}
impl<K> Team<K> {
pub fn new() -> Self {
Self {
members: SmallVec::new(),
}
}
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
Self {
members: members.into_iter().collect(),
}
}
}
impl<K> Default for Team<K> {
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<K> {
pub key: K,
pub weight: f64,
pub prior: Option<Gaussian>,
}
impl<K> Member<K> {
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<K> From<K> for Member<K> {
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<i64, &str> = 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);
}
}
+100
View File
@@ -0,0 +1,100 @@
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<T>,
O: Observer<T>,
K: Eq + std::hash::Hash + Clone,
{
history: &'h mut History<T, D, O, K>,
event: Event<T, K>,
current_team_idx: Option<usize>,
}
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
where
T: Time,
D: Drift<T>,
O: Observer<T>,
K: Eq + std::hash::Hash + Clone,
{
pub(crate) fn new(history: &'h mut History<T, D, O, K>, 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<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
let members: SmallVec<[Member<K>; 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<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
let idx = self
.current_team_idx
.expect(".weights(...) called before any .team(...)");
let ws: Vec<f64> = 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<I: IntoIterator<Item = u32>>(mut self, ranks: I) -> Self {
self.event.outcome = Outcome::ranking(ranks);
self
}
/// Set explicit per-team continuous scores; higher = better.
pub fn scores<I: IntoIterator<Item = f64>>(mut self, scores: I) -> Self {
self.event.outcome = crate::Outcome::scores(scores);
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))
}
}
+177
View File
@@ -0,0 +1,177 @@
use crate::{
N_INF,
factor::{Factor, VarId, VarStore},
gaussian::Gaussian,
pdf,
};
/// Gaussian observation factor on a diff variable.
///
/// Encodes the soft evidence `m_obs ~ N(diff, sigma²)`. The outgoing message
/// to `diff` is the constant `N(m_obs, sigma²)`, so this factor converges in a
/// single propagation: subsequent calls return a zero delta.
#[derive(Debug)]
pub struct MarginFactor {
pub diff: VarId,
pub m_obs: f64,
pub sigma: f64,
pub(crate) msg: Gaussian,
pub(crate) evidence_cached: Option<f64>,
}
impl MarginFactor {
pub fn new(diff: VarId, m_obs: f64, sigma: f64) -> Self {
debug_assert!(sigma > 0.0, "score sigma must be positive");
Self {
diff,
m_obs,
sigma,
msg: N_INF,
evidence_cached: None,
}
}
}
impl MarginFactor {
/// Propagate this factor's message, optionally damping the update in
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.m_obs, self.sigma));
}
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg;
self.msg = damped;
vars.set(self.diff, cavity * damped);
old_msg.delta(damped)
}
}
impl Factor for MarginFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0)
}
fn log_evidence(&self, _vars: &VarStore) -> f64 {
self.evidence_cached.unwrap_or(1.0).ln()
}
}
fn cavity_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt();
pdf(m_obs, cavity.mu(), combined_sigma)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_propagate_writes_tilted_marginal() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0);
f.propagate(&mut vars);
let result = vars.get(diff);
// pi = 1/36 + 1 ≈ 1.027778; tau = 0 + 5 = 5
// mu = 5 / 1.027778 ≈ 4.864865; sigma = 1/sqrt(1.027778) ≈ 0.986394
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
assert!((result.sigma() - 0.986393923832144).abs() < 1e-12);
}
#[test]
fn converges_in_one_step() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0);
f.propagate(&mut vars);
let (dmu, dsig) = f.propagate(&mut vars);
assert!(
dmu < 1e-12,
"expected ~0 delta on second propagate, got {dmu}"
);
assert!(dsig < 1e-12);
}
#[test]
fn evidence_cached_on_first_propagate() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0);
assert!(f.evidence_cached.is_none());
f.propagate(&mut vars);
let z = f.evidence_cached.unwrap();
// pdf(5, 0, sqrt(37)) ≈ 0.046783
assert!((z - 0.04678300292616668).abs() < 1e-10);
// Subsequent propagations don't change it.
f.propagate(&mut vars);
assert_eq!(f.evidence_cached.unwrap(), z);
}
#[test]
fn log_evidence_matches_cached_ln() {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0);
f.propagate(&mut vars);
let logz = f.log_evidence(&vars);
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
}
#[test]
fn propagate_with_alpha_one_matches_undamped_propagate() {
let mut vars_a = VarStore::new();
let diff_a = vars_a.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_a = MarginFactor::new(diff_a, 5.0, 1.0);
let delta_a = f_a.propagate(&mut vars_a);
let result_a = vars_a.get(diff_a);
let mut vars_b = VarStore::new();
let diff_b = vars_b.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_b = MarginFactor::new(diff_b, 5.0, 1.0);
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
let result_b = vars_b.get(diff_b);
assert_eq!(result_a.pi(), result_b.pi());
assert_eq!(result_a.tau(), result_b.tau());
assert_eq!(delta_a, delta_b);
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
}
#[test]
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
// Run undamped to capture (initial_msg, undamped_new_msg).
let mut vars_full = VarStore::new();
let diff_full = vars_full.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_full = MarginFactor::new(diff_full, 5.0, 1.0);
let initial_msg_pi = f_full.msg.pi();
let initial_msg_tau = f_full.msg.tau();
f_full.propagate(&mut vars_full);
let undamped_msg_pi = f_full.msg.pi();
let undamped_msg_tau = f_full.msg.tau();
// Run damped at α = 0.5 from the same initial state.
let mut vars_half = VarStore::new();
let diff_half = vars_half.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_half = MarginFactor::new(diff_half, 5.0, 1.0);
f_half.propagate_with_alpha(&mut vars_half, 0.5);
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! 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<Gaussian>,
}
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: Send + Sync {
/// 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<dyn Factor>` 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),
Margin(margin::MarginFactor),
}
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),
Self::Margin(f) => f.propagate(vars),
}
}
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
}
}
}
pub mod margin;
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);
}
#[test]
fn builtin_factor_dispatches_to_margin() {
use super::margin::MarginFactor;
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = BuiltinFactor::Margin(MarginFactor::new(diff, 5.0, 1.0));
f.propagate(&mut vars);
let result = vars.get(diff);
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
let logz = f.log_evidence(&vars);
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
}
}
+95
View File
@@ -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);
}
}
+98
View File
@@ -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);
}
}
+183
View File
@@ -0,0 +1,183 @@
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<f64>,
}
impl TruncFactor {
pub fn new(diff: VarId, margin: f64, tie: bool) -> Self {
Self {
diff,
margin,
tie,
msg: N_INF,
evidence_cached: None,
}
}
}
impl TruncFactor {
/// Propagate this factor's message, optionally damping the update in
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
}
let trunc = approx(cavity, self.margin, self.tie);
let new_msg = trunc / cavity;
let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg;
self.msg = damped;
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
// `trunc` (since cavity * new_msg = trunc by construction); with
// alpha < 1.0 it reflects the partially-applied update.
vars.set(self.diff, cavity * damped);
old_msg.delta(damped)
}
}
impl Factor for TruncFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0)
}
fn log_evidence(&self, _vars: &VarStore) -> f64 {
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);
}
#[test]
fn propagate_with_alpha_one_matches_undamped_propagate() {
let mut vars_a = VarStore::new();
let diff_a = vars_a.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_a = TruncFactor::new(diff_a, 0.0, false);
let delta_a = f_a.propagate(&mut vars_a);
let result_a = vars_a.get(diff_a);
let mut vars_b = VarStore::new();
let diff_b = vars_b.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_b = TruncFactor::new(diff_b, 0.0, false);
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
let result_b = vars_b.get(diff_b);
assert_eq!(result_a.pi(), result_b.pi());
assert_eq!(result_a.tau(), result_b.tau());
assert_eq!(delta_a, delta_b);
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
}
#[test]
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
// Run undamped to capture (initial_msg, undamped_new_msg).
let mut vars_full = VarStore::new();
let diff_full = vars_full.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_full = TruncFactor::new(diff_full, 0.0, false);
let initial_msg_pi = f_full.msg.pi();
let initial_msg_tau = f_full.msg.tau();
f_full.propagate(&mut vars_full);
let undamped_msg_pi = f_full.msg.pi();
let undamped_msg_tau = f_full.msg.tau();
// Run damped at α = 0.5 from the same initial state.
let mut vars_half = VarStore::new();
let diff_half = vars_half.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_half = TruncFactor::new(diff_half, 0.0, false);
f_half.propagate_with_alpha(&mut vars_half, 0.5);
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
}
}
+13
View File
@@ -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, margin::MarginFactor, rank_diff::RankDiffFactor,
team_sum::TeamSumFactor, trunc::TruncFactor,
},
schedule::{EpsilonOrMax, Schedule, ScheduleReport},
};
+875 -185
View File
File diff suppressed because it is too large Load Diff
+197 -154
View File
@@ -2,143 +2,171 @@ 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)
} else {
f64::INFINITY
}
}
fn tau(&self) -> f64 {
if self.sigma > 0.0 {
self.mu * self.pi()
} else {
f64::INFINITY
}
}
pub(crate) fn delta(&self, m: Gaussian) -> (f64, f64) {
((self.mu - m.mu).abs(), (self.sigma - m.sigma).abs())
}
pub(crate) fn exclude(&self, m: Gaussian) -> Self {
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 {
mu: self.mu - m.mu,
sigma: (self.sigma.powi(2) - m.sigma.powi(2)).sqrt(),
pi: f64::INFINITY,
tau: if mu == 0.0 { 0.0 } else { f64::INFINITY },
}
} else {
let pi = 1.0 / (sigma * sigma);
Self { pi, tau: mu * 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, other: Gaussian) -> (f64, f64) {
(
(self.mu() - other.mu()).abs(),
(self.sigma() - other.sigma()).abs(),
)
}
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())
}
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
///
/// Used by within-game inference to stabilise oscillating fixed-point
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
/// `alpha < 1.0` shrinks each per-step update.
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(),
alpha * new.tau() + (1.0 - alpha) * self.tau(),
)
}
}
impl Default for Gaussian {
fn default() -> Self {
Self {
mu: MU,
sigma: SIGMA,
}
Self::from_ms(MU, SIGMA)
}
}
impl ops::Add<Gaussian> 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<Gaussian> 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<Gaussian> 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<f64> 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,
fn mul(self, scalar: f64) -> Self::Output {
if !scalar.is_finite() {
return N_INF;
}
} else {
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<Gaussian> 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 +176,100 @@ 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);
}
#[test]
fn damp_natural_alpha_one_returns_new() {
let old = Gaussian::from_ms(1.0, 2.0);
let new = Gaussian::from_ms(5.0, 0.5);
let damped = old.damp_natural(new, 1.0);
assert_eq!(damped.pi(), new.pi());
assert_eq!(damped.tau(), new.tau());
}
#[test]
fn damp_natural_alpha_zero_returns_self() {
let old = Gaussian::from_ms(1.0, 2.0);
let new = Gaussian::from_ms(5.0, 0.5);
let damped = old.damp_natural(new, 0.0);
assert_eq!(damped.pi(), old.pi());
assert_eq!(damped.tau(), old.tau());
}
#[test]
fn damp_natural_alpha_half_is_midpoint_in_natural_params() {
let old = Gaussian::from_ms(1.0, 2.0);
let new = Gaussian::from_ms(5.0, 0.5);
let damped = old.damp_natural(new, 0.5);
let expected_pi = 0.5 * new.pi() + 0.5 * old.pi();
let expected_tau = 0.5 * new.tau() + 0.5 * old.tau();
assert!((damped.pi() - expected_pi).abs() < 1e-12);
assert!((damped.tau() - expected_tau).abs() < 1e-12);
}
}
+1082 -549
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -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<K>(HashMap<K, Index>);
impl<K> KeyTable<K>
where
K: Eq + Hash,
{
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
where
K: Borrow<Q>,
{
self.0.get(k).cloned()
}
pub fn get_or_create<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(&mut self, k: &Q) -> Index
where
K: Borrow<Q>,
{
if let Some(idx) = self.0.get(k) {
*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<Item = &K> {
self.0.keys()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl<K> Default for KeyTable<K>
where
K: Eq + Hash,
{
fn default() -> Self {
KeyTable::new()
}
}
+44 -103
View File
@@ -1,28 +1,50 @@
use std::borrow::{Borrow, ToOwned};
use std::cmp::Reverse;
use std::collections::HashMap;
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2};
use std::hash::Hash;
use std::{
cmp::Reverse,
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
};
pub mod agent;
#[cfg(feature = "approx")]
mod approx;
pub mod batch;
pub(crate) mod arena;
mod time;
mod time_slice;
pub use time_slice::{EventKind, TimeSlice};
mod color_group;
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;
@@ -47,61 +69,6 @@ impl From<usize> for Index {
}
}
pub struct IndexMap<K>(HashMap<K, Index>);
impl<K> IndexMap<K>
where
K: Eq + Hash,
{
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<Index>
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K>,
{
self.0.get(k).cloned()
}
pub fn get_or_create<Q: ?Sized>(&mut self, k: &Q) -> Index
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K>,
{
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<Item = &K> {
self.0.keys()
}
}
impl<K> Default for IndexMap<K>
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);
@@ -156,7 +123,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)
@@ -201,9 +168,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) {
@@ -217,39 +184,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<usize> {
let mut v = x.iter().enumerate().collect::<Vec<_>>();
pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect();
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<usize> {
let mut x = xs.iter().enumerate().collect::<Vec<_>>();
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
@@ -264,13 +210,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);
@@ -318,14 +264,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]
-81
View File
@@ -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
}
*/
}
+47
View File
@@ -0,0 +1,47 @@
//! 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.
pub trait Observer<T: Time>: Send + Sync {
/// 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<T: Time> Observer<T> for NullObserver {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_observer_compiles_for_i64() {
let o = NullObserver;
<NullObserver as Observer<i64>>::on_iteration_end(&o, 1, (0.0, 0.0));
<NullObserver as Observer<i64>>::on_converged(&o, 5, (1e-6, 1e-6), true);
}
#[test]
fn null_observer_compiles_for_untimed() {
use crate::Untimed;
let o = NullObserver;
<NullObserver as Observer<Untimed>>::on_iteration_end(&o, 1, (0.0, 0.0));
}
}
+125
View File
@@ -0,0 +1,125 @@
//! Outcome of a match.
//!
//! `Ranked(ranks)` for ordinal results; `Scored(scores)` for continuous
//! per-team scores (engages `MarginFactor` in the engine).
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.
///
/// `Scored(scores)`: higher score = better. Adjacent (sorted) pairs feed
/// observed margins to `MarginFactor`. `scores.len()` must equal the number
/// of teams in the event.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Outcome {
Ranked(SmallVec<[u32; 4]>),
Scored(SmallVec<[f64; 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<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
Self::Ranked(ranks.into_iter().collect())
}
/// Explicit per-team continuous scores; higher = better.
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored(scores.into_iter().collect())
}
pub fn team_count(&self) -> usize {
match self {
Self::Ranked(r) => r.len(),
Self::Scored(s) => s.len(),
}
}
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
match self {
Self::Ranked(r) => Some(r),
Self::Scored(_) => None,
}
}
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
match self {
Self::Scored(s) => Some(s),
Self::Ranked(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn winner_two_teams() {
let o = Outcome::winner(0, 2);
assert_eq!(o.as_ranks(), Some(&[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(), Some(&[1u32, 0, 1][..]));
}
#[test]
fn draw_three_teams() {
let o = Outcome::draw(3);
assert_eq!(o.as_ranks(), Some(&[0u32, 0, 0][..]));
}
#[test]
fn ranking_from_iter() {
let o = Outcome::ranking([2, 0, 1]);
assert_eq!(o.as_ranks(), Some(&[2u32, 0, 1][..]));
}
#[test]
#[should_panic(expected = "winner index 2 out of range")]
fn winner_out_of_range_panics() {
let _ = Outcome::winner(2, 2);
}
#[test]
fn scored_two_teams() {
let o = Outcome::scores([10.0, 4.0]);
assert_eq!(o.team_count(), 2);
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
assert_eq!(o.as_ranks(), None);
}
#[test]
fn scored_team_count_matches_input() {
let o = Outcome::scores([3.0, 1.0, 2.0, 0.0]);
assert_eq!(o.team_count(), 4);
}
#[test]
fn ranked_as_scores_returns_none() {
let o = Outcome::winner(0, 2);
assert!(o.as_scores().is_none());
assert!(o.as_ranks().is_some());
}
}
-32
View File
@@ -1,32 +0,0 @@
use crate::{
BETA, GAMMA,
drift::{ConstantDrift, Drift},
gaussian::Gaussian,
};
#[derive(Clone, Copy, Debug)]
pub struct Player<D: Drift = ConstantDrift> {
pub(crate) prior: Gaussian,
pub(crate) beta: f64,
pub(crate) drift: D,
}
impl<D: Drift> Player<D> {
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<ConstantDrift> {
fn default() -> Self {
Self {
prior: Gaussian::default(),
beta: BETA,
drift: ConstantDrift(GAMMA),
}
}
}
+46
View File
@@ -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<T: Time = i64, D: Drift<T> = ConstantDrift> {
pub(crate) prior: Gaussian,
pub(crate) beta: f64,
pub(crate) drift: D,
pub(crate) _time: PhantomData<T>,
}
impl<T: Time, D: Drift<T>> Rating<T, D> {
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<i64, ConstantDrift> {
fn default() -> Self {
Self {
prior: Gaussian::default(),
beta: BETA,
drift: ConstantDrift(GAMMA),
_time: PhantomData,
}
}
}
+126
View File
@@ -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: Send + Sync {
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);
}
}
+127
View File
@@ -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<Option<Competitor<T, D>>>` so slots can be
/// absent without an explicit present mask.
#[derive(Debug)]
pub struct CompetitorStore<T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
competitors: Vec<Option<Competitor<T, D>>>,
n_present: usize,
}
impl<T: Time, D: Drift<T>> Default for CompetitorStore<T, D> {
fn default() -> Self {
Self {
competitors: Vec::new(),
n_present: 0,
}
}
}
impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
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<T, D>) {
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<T, D>> {
self.competitors.get(idx.0).and_then(|slot| slot.as_ref())
}
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Competitor<T, D>> {
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<Item = (Index, &Competitor<T, D>)> {
self.competitors
.iter()
.enumerate()
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Competitor<T, D>)> {
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<Item = &mut Competitor<T, D>> {
self.competitors.iter_mut().filter_map(|s| s.as_mut())
}
}
impl<T: Time, D: Drift<T>> std::ops::Index<Index> for CompetitorStore<T, D> {
type Output = Competitor<T, D>;
fn index(&self, idx: Index) -> &Competitor<T, D> {
self.get(idx).expect("competitor not found at index")
}
}
impl<T: Time, D: Drift<T>> std::ops::IndexMut<Index> for CompetitorStore<T, D> {
fn index_mut(&mut self, idx: Index) -> &mut Competitor<T, D> {
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<i64, ConstantDrift> = 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<i64, ConstantDrift> = CompetitorStore::new();
store.insert(Index(2), Competitor::default());
store.insert(Index(0), Competitor::default());
store.insert(Index(5), Competitor::default());
let keys: Vec<Index> = 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<i64, ConstantDrift> = CompetitorStore::new();
store.insert(Index(3), Competitor::default());
let _ = &store[Index(3)];
}
}
+5
View File
@@ -0,0 +1,5 @@
mod competitor_store;
mod skill_store;
pub use competitor_store::CompetitorStore;
pub(crate) use skill_store::SkillStore;
+130
View File
@@ -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<Skill>,
present: Vec<bool>,
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<Item = (Index, &Skill)> {
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<Item = (Index, &mut Skill)> {
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<Item = Index> + '_ {
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<Index> = 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);
}
}
+54
View File
@@ -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 + Send + Sync + 'static {
/// How much time elapsed between `self` and `later`.
///
/// Used by `Drift<T>::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);
}
}
+949
View File
@@ -0,0 +1,949 @@
//! A single time step's worth of events.
//!
//! Renamed from `Batch` in T2.
use std::collections::HashMap;
use crate::{
Index, N_INF,
arena::ScratchArena,
color_group::ColorGroups,
drift::Drift,
game::Game,
gaussian::Gaussian,
rating::Rating,
storage::{CompetitorStore, SkillStore},
time::Time,
tuple_gt, tuple_max,
};
#[derive(Debug)]
pub(crate) struct Skill {
pub(crate) forward: Gaussian,
backward: Gaussian,
likelihood: Gaussian,
pub(crate) elapsed: i64,
pub(crate) online: Gaussian,
}
impl Skill {
pub(crate) fn posterior(&self) -> Gaussian {
self.likelihood * self.backward * self.forward
}
}
impl Default for Skill {
fn default() -> Self {
Self {
forward: N_INF,
backward: N_INF,
likelihood: N_INF,
elapsed: 0,
online: N_INF,
}
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum EventKind {
Ranked,
Scored { score_sigma: f64 },
}
#[derive(Debug)]
struct Item {
agent: Index,
likelihood: Gaussian,
}
impl Item {
fn within_prior<T: Time, D: Drift<T>>(
&self,
online: bool,
forward: bool,
skills: &SkillStore,
agents: &CompetitorStore<T, D>,
) -> Rating<T, D> {
let r = &agents[self.agent].rating;
let skill = skills.get(self.agent).unwrap();
if online {
Rating::new(skill.online, r.beta, r.drift)
} else if forward {
Rating::new(skill.forward, r.beta, r.drift)
} else {
Rating::new(skill.posterior() / self.likelihood, r.beta, r.drift)
}
}
}
#[derive(Debug)]
struct Team {
items: Vec<Item>,
output: f64,
}
#[derive(Debug)]
pub(crate) struct Event {
teams: Vec<Team>,
evidence: f64,
weights: Vec<Vec<f64>>,
kind: EventKind,
}
impl Event {
pub(crate) fn iter_agents(&self) -> impl Iterator<Item = Index> + '_ {
self.teams
.iter()
.flat_map(|t| t.items.iter().map(|it| it.agent))
}
fn outputs(&self) -> Vec<f64> {
self.teams
.iter()
.map(|team| team.output)
.collect::<Vec<_>>()
}
pub(crate) fn within_priors<T: Time, D: Drift<T>>(
&self,
online: bool,
forward: bool,
skills: &SkillStore,
agents: &CompetitorStore<T, D>,
) -> Vec<Vec<Rating<T, D>>> {
self.teams
.iter()
.map(|team| {
team.items
.iter()
.map(|item| item.within_prior(online, forward, skills, agents))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
/// Direct in-loop update: mutates self and `skills` inline with no
/// intermediate allocation. Used by both the sequential sweep path and,
/// via unsafe, by the parallel rayon path for events in the same color
/// group (which have disjoint agent sets — see `sweep_color_groups`).
fn iteration_direct<T: Time, D: Drift<T>>(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<T, D>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) {
let teams = self.within_priors(false, false, skills, agents);
let result = self.outputs();
let g = match self.kind {
EventKind::Ranked => {
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, convergence, arena)
}
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&self.weights,
score_sigma,
convergence,
arena,
),
};
for (t, team) in self.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() {
let old_likelihood = skills.get(item.agent).unwrap().likelihood;
let new_likelihood = (old_likelihood / item.likelihood) * g.likelihoods[t][i];
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
item.likelihood = g.likelihoods[t][i];
}
}
self.evidence = g.evidence;
}
}
#[derive(Debug)]
pub struct TimeSlice<T: Time = i64> {
pub(crate) events: Vec<Event>,
pub(crate) skills: SkillStore,
pub(crate) time: T,
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
arena: ScratchArena,
pub(crate) color_groups: ColorGroups,
}
impl<T: Time> TimeSlice<T> {
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
Self {
events: Vec::new(),
skills: SkillStore::new(),
time,
p_draw,
convergence,
arena: ScratchArena::new(),
color_groups: ColorGroups::new(),
}
}
/// Recompute the color-group partition and reorder `self.events` into
/// color-contiguous ranges. After this call, `self.color_groups.groups[c]`
/// contains a contiguous ascending range of indices in `self.events`.
pub(crate) fn recompute_color_groups(&mut self) {
use crate::color_group::color_greedy;
let n = self.events.len();
if n == 0 {
self.color_groups = ColorGroups::new();
return;
}
let cg = color_greedy(n, |ev_idx| {
self.events[ev_idx].iter_agents().collect::<Vec<_>>()
});
let mut reordered: Vec<Event> = Vec::with_capacity(n);
let mut new_groups: Vec<Vec<usize>> = Vec::with_capacity(cg.groups.len());
let mut taken: Vec<Option<Event>> = self.events.drain(..).map(Some).collect();
for group in &cg.groups {
let mut new_indices: Vec<usize> = Vec::with_capacity(group.len());
for &old_idx in group {
let ev = taken[old_idx].take().expect("event already taken");
new_indices.push(reordered.len());
reordered.push(ev);
}
new_groups.push(new_indices);
}
self.events = reordered;
self.color_groups = ColorGroups { groups: new_groups };
}
pub fn add_events<D: Drift<T>>(
&mut self,
composition: Vec<Vec<Vec<Index>>>,
results: Vec<Vec<f64>>,
weights: Vec<Vec<Vec<f64>>>,
kinds: Vec<EventKind>,
agents: &CompetitorStore<T, D>,
) {
let mut unique = Vec::with_capacity(10);
let this_agent = composition.iter().flatten().flatten().filter(|idx| {
if !unique.contains(idx) {
unique.push(*idx);
return true;
}
false
});
for idx in this_agent {
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time);
if let Some(skill) = self.skills.get_mut(*idx) {
skill.elapsed = elapsed;
skill.forward = agents[*idx].receive(&self.time);
} else {
self.skills.insert(
*idx,
Skill {
forward: agents[*idx].receive(&self.time),
elapsed,
..Default::default()
},
);
}
}
let events = composition.iter().enumerate().map(|(e, event)| {
let teams = event
.iter()
.enumerate()
.map(|(t, team)| {
let items = team
.iter()
.map(|&agent| Item {
agent,
likelihood: N_INF,
})
.collect::<Vec<_>>();
Team {
items,
output: if results.is_empty() {
(event.len() - (t + 1)) as f64
} else {
results[e][t]
},
}
})
.collect::<Vec<_>>();
let weights = if weights.is_empty() {
teams
.iter()
.map(|team| vec![1.0; team.items.len()])
.collect::<Vec<_>>()
} else {
weights[e].clone()
};
Event {
teams,
evidence: 0.0,
weights,
kind: kinds[e],
}
});
let from = self.events.len();
self.events.extend(events);
self.iteration(from, agents);
self.recompute_color_groups();
}
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
self.skills
.iter()
.map(|(idx, skill)| (idx, skill.posterior()))
.collect::<HashMap<_, _>>()
}
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
if from > 0 || self.color_groups.is_empty() {
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
for event in self.events.iter_mut().skip(from) {
let teams = event.within_priors(false, false, &self.skills, agents);
let result = event.outputs();
let g = match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
&mut self.arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
&mut self.arena,
),
};
for (t, team) in event.teams.iter_mut().enumerate() {
for (i, item) in team.items.iter_mut().enumerate() {
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];
}
}
event.evidence = g.evidence;
}
} else {
self.sweep_color_groups(agents);
}
}
/// Full event sweep using the color-group partition. Colors are processed
/// sequentially; within each color the inner loop is parallel under rayon.
///
/// Events within each color group touch disjoint agent sets (guaranteed by
/// the greedy coloring). This lets each rayon thread write directly to its
/// events' skill likelihoods without a deferred-apply step, matching the
/// sequential path's allocation profile. The unsafe block is sound because:
/// 1. `self.events[range]` and `self.skills` are separate fields → disjoint.
/// 2. Events in the same color group access disjoint `Index` values in
/// `self.skills`, so concurrent writes land on different memory locations.
/// 3. Each event only writes to its own items' likelihoods (no sharing).
#[cfg(feature = "rayon")]
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
use rayon::prelude::*;
thread_local! {
static ARENA: std::cell::RefCell<ScratchArena> =
std::cell::RefCell::new(ScratchArena::new());
}
// Minimum color-group size to justify rayon's task-spawn overhead.
// Below this threshold, process events sequentially to avoid regression
// on small per-slice workloads.
const RAYON_THRESHOLD: usize = 64;
for color_idx in 0..self.color_groups.groups.len() {
let group_len = self.color_groups.groups[color_idx].len();
if group_len == 0 {
continue;
}
let range = self.color_groups.color_range(color_idx);
let p_draw = self.p_draw;
let convergence = self.convergence;
if group_len >= RAYON_THRESHOLD {
// Obtain a raw pointer from the unique `&mut self.skills` reference.
// Casting back to `&mut` inside the closure is sound because:
// 1. The pointer originates from a `&mut` — no aliasing with shared refs.
// 2. Events in the same color group touch disjoint `Index` slots in the
// underlying Vec, so concurrent writes from different threads land on
// different memory locations — no data race.
// 3. `self.events[range]` and `self.skills` are separate struct fields,
// so the borrow splits cleanly.
let skills_addr: usize = (&mut self.skills as *mut SkillStore) as usize;
self.events[range].par_iter_mut().for_each(move |ev| {
// SAFETY: see above.
let skills: &mut SkillStore = unsafe { &mut *(skills_addr as *mut SkillStore) };
ARENA.with(|cell| {
let mut arena = cell.borrow_mut();
arena.reset();
ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
});
});
} else {
for ev in &mut self.events[range] {
ev.iteration_direct(
&mut self.skills,
agents,
p_draw,
self.convergence,
&mut self.arena,
);
}
}
}
}
/// Full event sweep using the color-group partition, sequential direct-write path.
/// Events within each color group are updated inline — no EventOutput allocation —
/// matching the T2 performance profile.
#[cfg(not(feature = "rayon"))]
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
for color_idx in 0..self.color_groups.groups.len() {
if self.color_groups.groups[color_idx].is_empty() {
continue;
}
let range = self.color_groups.color_range(color_idx);
// Borrow self.events as a mutable slice for this color range.
// self.skills and self.arena are separate fields — disjoint borrows are
// allowed within a single method body.
let p_draw = self.p_draw;
for ev in &mut self.events[range] {
ev.iteration_direct(
&mut self.skills,
agents,
p_draw,
self.convergence,
&mut self.arena,
);
}
}
}
#[allow(dead_code)]
pub(crate) fn iterate_to_convergence<D: Drift<T>>(
&mut self,
agents: &CompetitorStore<T, D>,
) -> usize {
let epsilon = 1e-6;
let iterations = 20;
let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0;
while tuple_gt(step, epsilon) && i < iterations {
let old = self.posteriors();
self.iteration(0, agents);
let new = self.posteriors();
step = old.iter().fold((0.0, 0.0), |step, (a, old)| {
tuple_max(step, old.delta(new[a]))
});
i += 1;
}
i
}
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian {
let skill = self.skills.get(*agent).unwrap();
skill.forward * skill.likelihood
}
pub(crate) fn backward_prior_out<D: Drift<T>>(
&self,
agent: &Index,
agents: &CompetitorStore<T, D>,
) -> Gaussian {
let skill = self.skills.get(*agent).unwrap();
let n = skill.likelihood * skill.backward;
n.forget(
agents[*agent]
.rating
.drift
.variance_for_elapsed(skill.elapsed),
)
}
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
for (agent, skill) in self.skills.iter_mut() {
skill.backward = agents[agent].message;
}
self.iteration(0, agents);
}
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
for (agent, skill) in self.skills.iter_mut() {
skill.forward = agents[agent].receive_for_elapsed(skill.elapsed);
}
self.iteration(0, agents);
}
pub(crate) fn log_evidence<D: Drift<T>>(
&self,
online: bool,
targets: &[Index],
forward: bool,
agents: &CompetitorStore<T, D>,
) -> f64 {
// log_evidence is infrequent; a local arena avoids needing &mut self.
let mut arena = ScratchArena::new();
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
let teams = event.within_priors(online, forward, &self.skills, agents);
let result = event.outputs();
match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
arena,
)
.evidence
.ln(),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
arena,
)
.evidence
.ln(),
}
};
if targets.is_empty() {
if online || forward {
self.events
.iter()
.map(|event| run_event(event, &mut arena))
.sum()
} else {
self.events.iter().map(|event| event.evidence.ln()).sum()
}
} else if online || forward {
self.events
.iter()
.filter(|event| {
event
.teams
.iter()
.flat_map(|team| &team.items)
.any(|item| targets.contains(&item.agent))
})
.map(|event| run_event(event, &mut arena))
.sum()
} else {
self.events
.iter()
.filter(|event| {
event
.teams
.iter()
.flat_map(|team| &team.items)
.any(|item| targets.contains(&item.agent))
})
.map(|event| event.evidence.ln())
.sum()
}
}
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
self.events
.iter()
.map(|event| {
event
.teams
.iter()
.map(|team| team.items.iter().map(|item| item.agent).collect::<Vec<_>>())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
pub fn get_results(&self) -> Vec<Vec<f64>> {
self.events
.iter()
.map(|event| {
event
.teams
.iter()
.map(|team| team.output)
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
}
}
pub(crate) fn compute_elapsed<T: Time>(last: Option<&T>, current: &T) -> i64 {
last.map(|l| l.elapsed_to(current).max(0)).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use approx::assert_ulps_eq;
use super::*;
use crate::{
KeyTable, competitor::Competitor, drift::ConstantDrift, rating::Rating,
storage::CompetitorStore,
};
#[test]
fn test_one_event_each() {
let mut index_map = KeyTable::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 mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
Competitor {
rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
time_slice.add_events(
vec![
vec![vec![a], vec![b]],
vec![vec![c], vec![d]],
vec![vec![e], vec![f]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
vec![EventKind::Ranked; 3],
&agents,
);
let post = time_slice.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&d],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&e],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&f],
Gaussian::from_ms(20.794779, 7.194481),
epsilon = 1e-6
);
assert_eq!(time_slice.iterate_to_convergence(&agents), 1);
}
#[test]
fn test_same_strength() {
let mut index_map = KeyTable::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 mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
Competitor {
rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
time_slice.add_events(
vec![
vec![vec![a], vec![b]],
vec![vec![a], vec![c]],
vec![vec![b], vec![c]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
vec![EventKind::Ranked; 3],
&agents,
);
let post = time_slice.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(24.960978, 6.298544),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(27.095590, 6.010330),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(24.889681, 5.866311),
epsilon = 1e-6
);
assert!(time_slice.iterate_to_convergence(&agents) > 1);
let post = time_slice.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
}
#[test]
fn test_add_events() {
let mut index_map = KeyTable::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 mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
for agent in [a, b, c, d, e, f] {
agents.insert(
agent,
Competitor {
rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
time_slice.add_events(
vec![
vec![vec![a], vec![b]],
vec![vec![a], vec![c]],
vec![vec![b], vec![c]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
vec![EventKind::Ranked; 3],
&agents,
);
time_slice.iterate_to_convergence(&agents);
let post = time_slice.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
time_slice.add_events(
vec![
vec![vec![a], vec![b]],
vec![vec![a], vec![c]],
vec![vec![b], vec![c]],
],
vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]],
vec![],
vec![EventKind::Ranked; 3],
&agents,
);
assert_eq!(time_slice.events.len(), 6);
time_slice.iterate_to_convergence(&agents);
let post = time_slice.posteriors();
assert_ulps_eq!(
post[&a],
Gaussian::from_ms(25.000003, 3.880150),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000003, 3.880150),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000003, 3.880150),
epsilon = 1e-6
);
}
#[test]
fn time_slice_color_groups_reorders_events() {
// ev0: [a, b]; ev1: [c, d]; ev2: [a, c]
// Greedy coloring: ev0→c0, ev1→c0 (disjoint), ev2→c1 (overlaps both).
// After recompute_color_groups, physical order is [ev0, ev1, ev2]
// and groups == [[0, 1], [2]].
let mut index_map = KeyTable::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 mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
for agent in [a, b, c, d] {
agents.insert(
agent,
Competitor {
rating: Rating::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
),
..Default::default()
},
);
}
let mut ts = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
ts.add_events(
vec![
vec![vec![a], vec![b]],
vec![vec![c], vec![d]],
vec![vec![a], vec![c]],
],
vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]],
vec![],
vec![EventKind::Ranked; 3],
&agents,
);
assert_eq!(ts.color_groups.n_colors(), 2);
assert_eq!(ts.color_groups.groups[0], vec![0, 1]);
assert_eq!(ts.color_groups.groups[1], vec![2]);
assert_eq!(ts.color_groups.color_range(0), 0..2);
assert_eq!(ts.color_groups.color_range(1), 2..3);
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
// checking that the agent sets of self.events[0] and self.events[1] do
// not include the agent at self.events[2].
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag)));
// ev2 must share an agent with ev0 or ev1 (it needed its own color).
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! 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,
alpha: 1.0,
})
.build();
let events: Vec<Event<i64, &'static str>> = 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<Event<i64, &'static str>> = 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<Event<i64, &'static str>> = 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]);
}
#[test]
fn fluent_event_builder_scores() {
use trueskill_tt::ConstantDrift;
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.0))
.build();
h.event(1)
.team(["alice"])
.team(["bob"])
.scores([12.0, 4.0])
.commit()
.unwrap();
h.converge().unwrap();
let a = h.current_skill(&"alice").unwrap();
let b = h.current_skill(&"bob").unwrap();
assert!(a.mu() > b.mu());
}
+101
View File
@@ -0,0 +1,101 @@
//! Determinism tests: identical posteriors across RAYON_NUM_THREADS
//! values. Only compiled with the `rayon` feature.
#![cfg(feature = "rayon")]
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
/// Build a deterministic workload using a simple LCG (no external rand crate).
fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
let mut h = History::<i64, _, _, String>::builder_with_key()
.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,
alpha: 1.0,
})
.build();
// LCG for deterministic pseudo-random ints.
let mut rng = seed;
let mut next = || {
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
rng
};
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(200);
for ev_i in 0..200 {
let a = (next() % 40) as usize;
let mut b = (next() % 40) as usize;
while b == a {
b = (next() % 40) as usize;
}
// ~10 events per slice so color groups have material parallelism.
events.push(Event {
time: (ev_i as i64 / 10) + 1,
teams: smallvec![
Team::with_members([Member::new(format!("p{a}"))]),
Team::with_members([Member::new(format!("p{b}"))]),
],
outcome: Outcome::winner((next() % 2) as u32, 2),
});
}
h.add_events(events).unwrap();
h.converge().unwrap();
// Sample one competitor's curve for the comparison.
h.learning_curve("p0")
}
#[test]
fn posteriors_identical_across_thread_counts() {
let sizes = [1usize, 2, 4, 8];
let mut results: Vec<Vec<(i64, trueskill_tt::Gaussian)>> = Vec::new();
for &n in &sizes {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build()
.expect("rayon pool build");
let curve = pool.install(|| build_and_converge(42));
results.push(curve);
}
let reference = &results[0];
for (i, curve) in results.iter().enumerate().skip(1) {
assert_eq!(
curve.len(),
reference.len(),
"curve length differs at {n} threads",
n = sizes[i],
);
for (j, (&(t_ref, g_ref), &(t, g))) in reference.iter().zip(curve.iter()).enumerate() {
assert_eq!(
t_ref,
t,
"time point {j} differs at {n} threads: ref={t_ref} vs got={t}",
n = sizes[i],
);
assert_eq!(
g_ref.mu().to_bits(),
g.mu().to_bits(),
"mu bits differ at {n} threads, time {t}: ref={ref_mu} got={got_mu}",
n = sizes[i],
ref_mu = g_ref.mu(),
got_mu = g.mu(),
);
assert_eq!(
g_ref.sigma().to_bits(),
g.sigma().to_bits(),
"sigma bits differ at {n} threads, time {t}: ref={ref_sigma} got={got_sigma}",
n = sizes[i],
ref_sigma = g_ref.sigma(),
got_sigma = g.sigma(),
);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
//! 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<i64, ConstantDrift>;
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::<i64, _>::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::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::draw(2),
&GameOptions {
p_draw: 0.25,
score_sigma: 1.0,
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
);
}
+97
View File
@@ -0,0 +1,97 @@
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, InferenceError, Outcome, Rating,
};
type R = Rating<i64, ConstantDrift>;
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::<i64, _>::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::<i64, _>::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::<i64, _>::ranked(
&[&[a], &[a]],
Outcome::winner(0, 2),
&GameOptions {
p_draw: 1.5,
score_sigma: 1.0,
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::<i64, _>::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::<i64, _>::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::<i64, _>::ranked(
&[&[a], &[b]],
Outcome::winner(0, 2),
&GameOptions::default(),
)
.unwrap();
assert!(g.log_evidence().is_finite());
assert!(g.log_evidence() < 0.0);
}
+55
View File
@@ -0,0 +1,55 @@
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,
alpha: 1.0,
})
.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());
}
+139
View File
@@ -0,0 +1,139 @@
//! Integration tests for `Outcome::Scored` routing through `History::add_events`.
use smallvec::smallvec;
use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team};
#[test]
fn scored_two_team_one_event_pulls_winner_up() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.score_sigma(1.0)
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores([10.0, 4.0]),
}];
h.add_events(events).unwrap();
let mu_a = h.current_skill(&"a").unwrap().mu();
let mu_b = h.current_skill(&"b").unwrap().mu();
assert!(
mu_a > 0.0,
"winner mu should be pulled up; got mu_a = {mu_a}"
);
assert!(
mu_b < 0.0,
"loser mu should be pulled down; got mu_b = {mu_b}"
);
assert!(
mu_a > mu_b,
"winner mu should exceed loser mu; got mu_a = {mu_a}, mu_b = {mu_b}"
);
}
#[test]
fn scored_zero_margin_treats_as_tie() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.score_sigma(1.0)
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores([5.0, 5.0]),
}];
h.add_events(events).unwrap();
let mu_a = h.current_skill(&"a").unwrap().mu();
let mu_b = h.current_skill(&"b").unwrap().mu();
let sigma_a = h.current_skill(&"a").unwrap().sigma();
// Equal scores: posterior means stay symmetric around the prior mean.
assert!(
(mu_a - mu_b).abs() < 1e-9,
"equal scores should leave mu_a == mu_b; got {mu_a} vs {mu_b}"
);
assert!(
mu_a.abs() < 1e-9,
"equal scores against equal priors should leave mu near zero; got {mu_a}"
);
// A zero-margin scored event still reduces uncertainty.
assert!(
sigma_a < 2.0,
"expected sigma to tighten below prior 2.0; got {}",
sigma_a
);
}
#[test]
fn scored_three_team_partial_order() {
let mut h: History = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.score_sigma(1.0)
.build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
Team::with_members([Member::new("c")]),
],
outcome: Outcome::scores([9.0, 5.0, 1.0]),
}];
h.add_events(events).unwrap();
let mu_a = h.current_skill(&"a").unwrap().mu();
let mu_b = h.current_skill(&"b").unwrap().mu();
let mu_c = h.current_skill(&"c").unwrap().mu();
assert!(
mu_a > mu_b,
"team with highest score should rank highest; mu_a = {mu_a}, mu_b = {mu_b}"
);
assert!(
mu_b > mu_c,
"middle score should outrank lowest; mu_b = {mu_b}, mu_c = {mu_c}"
);
}
#[test]
fn scored_rejects_outcome_team_count_mismatch() {
use trueskill_tt::InferenceError;
let mut h: History = History::builder().build();
let events: Vec<Event<i64, &'static str>> = vec![Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a")]),
Team::with_members([Member::new("b")]),
],
outcome: Outcome::scores([10.0, 4.0, 1.0]), // 3 scores, 2 teams
}];
let err = h.add_events(events).unwrap_err();
assert!(
matches!(err, InferenceError::MismatchedShape { .. }),
"expected MismatchedShape error, got {err:?}"
);
}