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>
This commit was merged in pull request #1.
This commit is contained in:
2026-04-24 11:20:04 +00:00
committed by logaritmisk
parent a14df02089
commit d2aab82c1e
44 changed files with 10541 additions and 1325 deletions

View File

@@ -1,50 +1,61 @@
use plotters::prelude::*;
use smallvec::smallvec;
use time::{Date, Month};
use trueskill_tt::{History, IndexMap};
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift};
fn main() {
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
let mut composition = Vec::new();
let mut results = Vec::new();
let mut times = Vec::new();
let from = Date::from_calendar_date(1900, Month::January, 1).unwrap();
let time_format = time::format_description::parse("[year]-[month]-[day]").unwrap();
let mut index_map = IndexMap::new();
let mut events: Vec<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,
})
.build();
hist.add_events(composition, results, times, vec![]);
hist.convergence(10, 0.01, true);
hist.add_events(events).unwrap();
hist.converge().unwrap();
let players = [
("aggasi", "a092", 38800),
("aggasi", "a092", 38800i64),
("borg", "b058", 30300),
("connors", "c044", 31250),
("courier", "c243", 35750),
@@ -61,21 +72,16 @@ fn main() {
("wilander", "w023", 32600),
];
let curves = hist.learning_curves();
let mut x_spec = (f64::MAX, f64::MIN);
let mut y_spec = (f64::MAX, f64::MIN);
for (id, cutoff) in players
.iter()
.map(|&(_, id, cutoff)| (index_map.get_or_create(id), cutoff))
{
for (ts, gs) in &curves[&id] {
if *ts >= cutoff {
for &(_, id, cutoff) in &players {
for (ts, gs) in hist.learning_curve(id) {
if ts >= cutoff {
continue;
}
let ts = *ts as f64;
let ts = ts as f64;
if ts < x_spec.0 {
x_spec.0 = ts;
@@ -85,8 +91,8 @@ fn main() {
x_spec.1 = ts;
}
let upper = gs.mu + gs.sigma;
let lower = gs.mu - gs.sigma;
let upper = gs.mu() + gs.sigma();
let lower = gs.mu() - gs.sigma();
if lower < y_spec.0 {
y_spec.0 = lower;
@@ -111,24 +117,19 @@ fn main() {
chart.configure_mesh().draw().unwrap();
for (idx, (player, id, cutoff)) in players
.iter()
.map(|&(player, id, cutoff)| (player, index_map.get_or_create(id), cutoff))
.enumerate()
{
for (idx, &(player, id, cutoff)) in players.iter().enumerate() {
let mut data = Vec::new();
let mut upper = Vec::new();
let mut lower = Vec::new();
for (ts, gs) in curves[&id].iter() {
if *ts >= cutoff {
for (ts, gs) in hist.learning_curve(id) {
if ts >= cutoff {
continue;
}
data.push((*ts as f64, gs.mu));
upper.push((*ts as f64, gs.mu + gs.sigma));
lower.push((*ts as f64, gs.mu - gs.sigma));
data.push((ts as f64, gs.mu()));
upper.push((ts as f64, gs.mu() + gs.sigma()));
lower.push((ts as f64, gs.mu() - gs.sigma()));
}
let color = Palette99::pick(idx);