# T2 — New API Surface Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Land the full breaking API redesign from `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md` Section 7 "T2" — renames, typed `Event<T, K>` ingestion, generic `Time` / `Drift<T>`, `Observer` + `ConvergenceReport`, `Result<_, InferenceError>` at the boundary, and `pub factors` module — without changing numerical behavior.

**Architecture:** T2 is a tiered rename + API rewrite on top of T1's factor-graph internals. No internal inference algorithm changes. Each task leaves the crate building and tests passing on the working branch; the final commit merges as a single breaking change. Because this crate is pre-1.0 with no downstream users, intermediate commits on the T2 branch need not preserve the old API, as long as the branch-tip is consistent.

**Tech Stack:** Rust 2024 edition, `approx` crate for float comparisons, `smallvec` for fixed-size-biased event shapes, `criterion` for benchmark gates. Builds on T0 (nat-param Gaussian, dense storage, ScratchArena) and T1 (Factor/Schedule/VarStore, EpsilonOrMax schedule).

## Acceptance criteria

- All existing numerical goldens (rewritten in the new API) pass within `1e-6` tolerance.
- `cargo bench --bench batch` shows no regression vs T1 (`Batch::iteration` ≤ 23.5 µs on Apple M5 Pro) — the API rewrite is not supposed to change the hot path.
- `cargo clippy --all-targets --features approx -- -D warnings` clean.
- `cargo +nightly fmt --check` clean.
- Public API matches spec Section 4:
  - `History<T: Time, D: Drift<T>>` with `Untimed` and `i64` `Time` impls.
  - `HistoryBuilder` with `.mu().sigma().beta().drift().p_draw().convergence().observer().build()`.
  - `history.record_winner(&K, &K, T)`, `record_draw(&K, &K, T)`, `add_events(iter)`, `event(time).team([…]).weights([…]).ranking([…]).commit()`.
  - `history.converge() -> Result<ConvergenceReport, InferenceError>`.
  - `history.learning_curve(&K)`, `learning_curves()`, `current_skill(&K)`, `log_evidence()`, `log_evidence_for(&[&K])`, `predict_quality(...)`, `predict_outcome(...)`, `intern(&K) -> Index`, `lookup(&Q) -> Option<Index>`.
  - `Game::ranked`, `Game::one_v_one`, `Game::free_for_all`, `Game::custom` constructors; `Game::posteriors`, `Game::log_evidence` accessors.
  - `Observer` trait with `NullObserver` default.
  - `InferenceError` with `MismatchedShape`, `InvalidProbability`, `ConvergenceFailed`, `NegativePrecision` variants.
  - `factors` module re-exports `Factor`, `Schedule`, `VarStore`, `VarId`, `BuiltinFactor`, `EpsilonOrMax`, `ScheduleReport`.
- Renames completed: `Batch → TimeSlice`, `Player → Rating`, `Agent → Competitor`, `IndexMap → KeyTable`.
- Old API (`History::convergence(iters, eps, verbose)`, nested-Vec `add_events(composition, results, times, weights)`, `verbose: bool`, `time: bool`) removed.

## Non-goals (deferred to T3/T4)

- `Outcome::Scored` variant + `MarginFactor` (T4). For T2, `Outcome` has only `Ranked`; enum is marked `#[non_exhaustive]` so we can add `Scored` later non-breaking.
- `Damped` and `Residual` schedules (T4).
- `Send + Sync` trait bounds + Rayon parallelism (T3).
- Cross-history parallelism, dirty-bit slice skipping (T3/beyond).
- Snapshot / serde support.

## File map

**New files:**

| Path | Responsibility |
|---|---|
| `src/time.rs` | `Time` trait, `Untimed` ZST, `impl Time for i64`. |
| `src/observer.rs` | `Observer` trait, `NullObserver` ZST. |
| `src/outcome.rs` | `Outcome` enum (only `Ranked` in T2; `#[non_exhaustive]`). Convenience constructors `winner`, `draw`, `ranking`. |
| `src/event.rs` | Typed `Event<T, K>`, `Team<K>`, `Member<K>` structs for bulk ingestion. |
| `src/convergence.rs` | `ConvergenceOptions`, `ConvergenceReport`. |
| `src/key_table.rs` | `KeyTable<K>` (was `IndexMap` in `lib.rs`). |
| `src/rating.rs` | `Rating` (was `Player`). |
| `src/competitor.rs` | `Competitor` (was `Agent`). |
| `src/time_slice.rs` | `TimeSlice` (was `Batch`). |
| `src/factors.rs` | Public re-export module: `pub use crate::factor::*` + `pub use crate::schedule::*`. |
| `src/event_builder.rs` | Fluent builder returned by `History::event(T)` — `.team([…]).weights([…]).ranking([…]).commit()`. |
| `tests/equivalence.rs` | Integration tests: every old-API hardcoded golden is reproduced in the new API. |
| `tests/api_shape.rs` | Integration tests: three-tier ingestion, builder ergonomics, error cases. |

**Removed files:**

- `src/player.rs` → replaced by `rating.rs`
- `src/agent.rs` → replaced by `competitor.rs`
- `src/batch.rs` → replaced by `time_slice.rs`

**Heavily modified:**

| Path | What changes |
|---|---|
| `src/lib.rs` | Remove `IndexMap` (moved); re-export new modules; promote `factor` + `schedule` visibility via `mod factors`. |
| `src/drift.rs` | Generify `Drift` over `T: Time`. Widen `ConstantDrift` impl. |
| `src/history.rs` | Make generic over `T: Time, D: Drift<T>`. New builder methods. Three-tier ingestion. `converge()` replaces `convergence()`. New query methods. |
| `src/error.rs` | Add `MismatchedShape`, `InvalidProbability`, `ConvergenceFailed` variants. |
| `src/game.rs` | Add `ranked`, `one_v_one`, `free_for_all`, `custom` constructors and `log_evidence()` accessor. Keep the private `new_with_arena` path for History. |
| `src/storage/mod.rs` | Rename `AgentStore` → `CompetitorStore`, `SkillStore` stays. |
| `src/arena.rs` | Rename any `Player` → `Rating` references; no structural change. |

## Renaming policy

Use `git mv` for file renames so history is preserved. All type-name find-replaces happen in the same commit as the file move. Tests inside `#[cfg(test)] mod tests` are updated in-place.

---

## Task 1: Pre-flight — verify T1 green, capture baseline

**Files:** none

- [ ] **Step 1: Confirm branch + clean tree**

```bash
git status
git rev-parse --abbrev-ref HEAD
```

Expected: clean tree on `t0-numerical-parity`. If dirty, stop.

- [ ] **Step 2: Create the T2 branch**

```bash
git checkout -b t2-new-api-surface
```

- [ ] **Step 3: Confirm all tests pass**

```bash
cargo test --features approx --lib
cargo test --features approx --doc 2>&1 | tail -5
```

Expected: `53 passed; 0 failed`.

- [ ] **Step 4: Capture a fresh T1 reference bench number (hardware may differ)**

```bash
cargo bench --bench batch 2>&1 | grep "Batch::iteration"
```

Record the value — the final task will compare against it.

- [ ] **Step 5: No commit** — verification only.

---

## Task 2: Rename `IndexMap` → `KeyTable`

**Files:**
- Create: `src/key_table.rs`
- Modify: `src/lib.rs` (remove the inline `IndexMap`, re-export `KeyTable`)
- Modify: every call site across `src/**/*.rs` and tests

- [ ] **Step 1: Create `src/key_table.rs`**

```rust
use std::{
    borrow::{Borrow, ToOwned},
    collections::HashMap,
    hash::Hash,
};

use crate::Index;

/// Maps user keys to internal `Index` handles.
///
/// Renamed from the former `IndexMap` to avoid colliding with the `indexmap`
/// crate. Power users can promote `&K` to `Index` via `get_or_create` and
/// skip the lookup on subsequent hot-path calls.
#[derive(Debug)]
pub struct KeyTable<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 + ToOwned<Owned = K>>(&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()
    }
}
```

- [ ] **Step 2: Remove `IndexMap` from `src/lib.rs`**

Delete lines 57–108 (the `IndexMap` struct, impls, and `Default` impl). Remove `use std::{borrow::{Borrow, ToOwned}, collections::HashMap, hash::Hash};` if nothing else uses those imports.

Add module declaration near the other `pub mod` lines (alphabetically between `gaussian` and `player`):

```rust
pub mod key_table;
```

Add re-export:

```rust
pub use key_table::KeyTable;
```

- [ ] **Step 3: Replace all `IndexMap` references with `KeyTable`**

```bash
grep -rln 'IndexMap' src/ tests/ benches/ examples/ 2>/dev/null
```

In every file found, replace `IndexMap` → `KeyTable`. Verify no stragglers:

```bash
! grep -rn 'IndexMap' src/ tests/ benches/ examples/ 2>/dev/null
```

- [ ] **Step 4: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib
```

Expected: all 53 tests pass.

- [ ] **Step 5: Format + commit**

```bash
cargo +nightly fmt
git add src/ tests/ benches/ examples/
git commit -m "$(cat <<'EOF'
refactor(api): rename IndexMap to KeyTable

The former name collided with the popular indexmap crate. KeyTable
lives in its own module. Public API unchanged beyond the rename.

Part of T2 of docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md.
EOF
)"
```

---

## Task 3: Rename `Player` → `Rating`

**Files:**
- Rename: `src/player.rs` → `src/rating.rs`
- Modify: `src/lib.rs` (module decl + re-export)
- Modify: every call site

- [ ] **Step 1: Move the file**

```bash
git mv src/player.rs src/rating.rs
```

- [ ] **Step 2: Rename the type inside `src/rating.rs`**

Edit `src/rating.rs` — rename `Player` → `Rating` everywhere, update the doc comment to say what it is now:

```rust
use crate::{
    BETA, GAMMA,
    drift::{ConstantDrift, Drift},
    gaussian::Gaussian,
};

/// Static rating configuration: prior skill, performance noise `beta`, drift.
///
/// Renamed from `Player` in T2; `Rating` better describes the data
/// (a configuration) vs. a person (who's a `Competitor` with state).
#[derive(Clone, Copy, Debug)]
pub struct Rating<D: Drift = ConstantDrift> {
    pub(crate) prior: Gaussian,
    pub(crate) beta: f64,
    pub(crate) drift: D,
}

impl<D: Drift> Rating<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 Rating<ConstantDrift> {
    fn default() -> Self {
        Self {
            prior: Gaussian::default(),
            beta: BETA,
            drift: ConstantDrift(GAMMA),
        }
    }
}
```

- [ ] **Step 3: Update `src/lib.rs`**

Replace:
```rust
pub mod player;
...
pub use player::Player;
```

with:
```rust
pub mod rating;
...
pub use rating::Rating;
```

- [ ] **Step 4: Replace `Player` with `Rating` and `player::` with `rating::` everywhere**

Use two passes so we don't touch e.g. `player_b` or `players` — use word-boundary regex:

```bash
grep -rln '\bPlayer\b\|::player::\|mod player\|crate::player' src/ tests/ benches/ examples/ 2>/dev/null
```

For each file, edit to replace. Inside test modules, `Player::new(...)` becomes `Rating::new(...)`. The spec keeps the `Rating::new(prior, beta, drift)` signature exactly.

Note: `crate::player::Player` → `crate::rating::Rating`. Use `pub(crate)` fields are re-paths only.

Verify no stragglers:

```bash
! grep -rn '\bPlayer\b' src/ tests/ benches/ examples/ 2>/dev/null
! grep -rn 'crate::player\|mod player' src/ 2>/dev/null
```

- [ ] **Step 5: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib
```

Expected: all 53 tests pass.

- [ ] **Step 6: Format + commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
refactor(api): rename Player to Rating

The struct holds prior/beta/drift — a rating configuration, not a
person. The person-with-temporal-state is the Competitor (renamed in
the next task). Resolves Player/Agent ambiguity.

Part of T2.
EOF
)"
```

---

## Task 4: Rename `Agent` → `Competitor`

**Files:**
- Rename: `src/agent.rs` → `src/competitor.rs`
- Modify: `src/lib.rs`, `src/storage/mod.rs` (the `AgentStore` alias rename), every call site

- [ ] **Step 1: Move the file**

```bash
git mv src/agent.rs src/competitor.rs
```

- [ ] **Step 2: Rename the type inside `src/competitor.rs`**

Replace `Agent` with `Competitor` everywhere in the file. Update the `rating` import (which was `player`):

```rust
use crate::{
    N_INF,
    drift::{ConstantDrift, Drift},
    gaussian::Gaussian,
    rating::Rating,
};

/// Per-history, temporal state for someone competing.
///
/// Renamed from `Agent` in T2.
#[derive(Debug)]
pub struct Competitor<D: Drift = ConstantDrift> {
    pub rating: Rating<D>,
    pub message: Gaussian,
    pub last_time: i64,
}

impl<D: Drift> Competitor<D> {
    pub(crate) fn receive(&self, elapsed: i64) -> Gaussian {
        if self.message != N_INF {
            self.message
                .forget(self.rating.drift.variance_delta(elapsed))
        } else {
            self.rating.prior
        }
    }
}

impl Default for Competitor<ConstantDrift> {
    fn default() -> Self {
        Self {
            rating: Rating::default(),
            message: N_INF,
            last_time: i64::MIN,
        }
    }
}

pub(crate) fn clean<'a, D: Drift + 'a, A: Iterator<Item = &'a mut Competitor<D>>>(
    competitors: A,
    last_time: bool,
) {
    for c in competitors {
        c.message = N_INF;
        if last_time {
            c.last_time = i64::MIN;
        }
    }
}
```

Note: the field `player` → `rating` is an additional rename that must propagate (`self.player.X` → `self.rating.X` across the codebase).

- [ ] **Step 3: Update `src/lib.rs` and `src/storage/mod.rs`**

In `src/lib.rs`, replace `pub mod agent;` with `pub mod competitor;`.

In `src/storage/mod.rs`, rename `AgentStore` → `CompetitorStore`. Inspect the file (read it first if you haven't) and replace:
- type alias/struct `AgentStore` → `CompetitorStore`
- any `Agent<D>` generic → `Competitor<D>`
- `use crate::agent::Agent` → `use crate::competitor::Competitor`

- [ ] **Step 4: Replace all call sites**

```bash
grep -rln '\bAgent\b\|AgentStore\|agent::\|mod agent\|crate::agent\|\.player\b' src/ tests/ benches/ examples/ 2>/dev/null
```

Replace:
- `Agent` → `Competitor`
- `AgentStore` → `CompetitorStore`
- `crate::agent::` → `crate::competitor::`
- `mod agent` → `mod competitor`
- **`.player` field access** → `.rating` (search this one carefully; it's ambiguous with unrelated code in dev-deps)

For the `.player` rename, audit carefully — it should only appear inside `src/` on instances of `Competitor`. Grep-replace only inside `src/`:

```bash
grep -rln '\.player\b' src/
```

Do NOT blindly s/\.player/\.rating/ across `tests/` or `benches/` or `examples/` — these may reference external dev-dep types. Only edit matches inside `src/` that are on `Competitor` instances (verify each).

Verify:
```bash
! grep -rn '\bAgent\b\|AgentStore\|::agent::\|mod agent' src/ tests/ benches/ examples/ 2>/dev/null
```

- [ ] **Step 5: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib
```

- [ ] **Step 6: Format + commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
refactor(api): rename Agent to Competitor and .player field to .rating

Competitor holds dynamic per-history state (message, last_time) for
someone competing; its configuration lives in a Rating.

AgentStore renamed to CompetitorStore to match.

Part of T2.
EOF
)"
```

---

## Task 5: Rename `Batch` → `TimeSlice`

**Files:**
- Rename: `src/batch.rs` → `src/time_slice.rs`
- Modify: `src/lib.rs`, `src/history.rs`, every call site

- [ ] **Step 1: Move the file**

```bash
git mv src/batch.rs src/time_slice.rs
```

- [ ] **Step 2: Rename the type**

In `src/time_slice.rs`, replace the struct name `Batch` with `TimeSlice`. Update the module-level doc comment:

```rust
//! A single time step's worth of events.
//!
//! Renamed from `Batch` in T2.
```

Rename:
- `struct Batch` → `struct TimeSlice`
- `impl Batch` → `impl TimeSlice`
- `Batch::new` → `TimeSlice::new`

The `compute_elapsed` free function keeps its name.

- [ ] **Step 3: Update `src/lib.rs`**

Replace:
```rust
pub mod batch;
```

with:
```rust
pub mod time_slice;
```

If `batch::Batch` was re-exported anywhere, update to `time_slice::TimeSlice`.

- [ ] **Step 4: Replace all call sites**

```bash
grep -rln '\bBatch\b\|crate::batch::\|mod batch\|batch::' src/ tests/ benches/ examples/ 2>/dev/null
```

Replace `Batch` → `TimeSlice` and `batch::` → `time_slice::` and `mod batch` → `mod time_slice`.

Note `src/history.rs` uses the name extensively (`self.batches`, `Vec<Batch>`, function names like `new_forward_info`). For T2 we rename:
- `batches: Vec<Batch>` → `time_slices: Vec<TimeSlice>`
- The field rename must propagate — search `\.batches\b` inside `src/history.rs` and replace with `.time_slices`.

Verify:
```bash
! grep -rn '\bBatch\b\|::batch::\|mod batch' src/ tests/ benches/ examples/ 2>/dev/null
! grep -rn '\.batches\b' src/history.rs
```

- [ ] **Step 5: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib
```

Tests will still use the old nested-Vec `add_events` signature and `h.batches[…]` access patterns — those are fixed in later tasks. For now tests may need to be patched minimally to use `.time_slices[…]` so they compile. Since the inner shape is the same, this is pure find-replace.

- [ ] **Step 6: Format + commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
refactor(api): rename Batch to TimeSlice

TimeSlice says what it is: every event sharing one timestamp. The
History field batches is renamed to time_slices.

Part of T2.
EOF
)"
```

---

## Task 6: Introduce `Time` trait and `Untimed`

**Files:**
- Create: `src/time.rs`
- Modify: `src/lib.rs`

This task lands the trait. It is *not* yet wired into `History`; that happens in Task 8.

- [ ] **Step 1: Create `src/time.rs`**

```rust
//! Generic time axis for `History`.
//!
//! Users pick the `Time` type based on their domain: `Untimed` when no
//! time axis is meaningful, `i64` for integer day/second timestamps.
//! Additional impls can be added behind feature flags.

/// A timestamp on the global ordering axis.
///
/// Must be `Ord + Copy` so slices can sort events, and `'static` so
/// `History` can store it by value without lifetimes.
pub trait Time: Copy + Ord + 'static {
    /// How much time elapsed between `self` and `later`.
    ///
    /// Used by `Drift<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
    }
}
```

- [ ] **Step 2: Declare the module in `src/lib.rs`**

```rust
pub mod time;
```

and re-export:

```rust
pub use time::{Time, Untimed};
```

- [ ] **Step 3: Build**

```bash
cargo build --features approx
cargo test --features approx --lib
```

All 53 tests still pass (nothing uses `Time` yet).

- [ ] **Step 4: Commit**

```bash
cargo +nightly fmt
git add src/time.rs src/lib.rs
git commit -m "$(cat <<'EOF'
feat(api): add Time trait with Untimed and i64 impls

Foundation for generic History time axis. Untimed is the ZST case
(no drift across slices); i64 is the standard timestamp case.
Additional impls (time::OffsetDateTime, chrono) can be added behind
feature flags in follow-up work.

Part of T2.
EOF
)"
```

---

## Task 7: Generify `Drift` over `Time`

**Files:**
- Modify: `src/drift.rs`
- Modify: every `Drift` call site (`elapsed: i64` → `from: &T, to: &T`)

- [ ] **Step 1: Rewrite `src/drift.rs`**

```rust
use std::fmt::Debug;

use crate::time::Time;

/// Governs how much a competitor's skill can drift between two time points.
///
/// Generic over `T: Time` so seasonal or calendar-aware drift is expressible
/// without going through `i64`.
pub trait Drift<T: Time>: Copy + Debug {
    /// Variance added to the skill prior for elapsed time `from -> to`.
    ///
    /// Called with `from <= to`; returning zero means no drift accumulates.
    fn variance_delta(&self, from: &T, to: &T) -> f64;
}

/// Simple constant-per-unit-time drift.
///
/// For `Time = i64`: variance added is `(to - from) * gamma^2`.
/// For `Time = Untimed`: elapsed is always 0, so drift is always 0.
#[derive(Clone, Copy, Debug)]
pub struct ConstantDrift(pub f64);

impl<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
    }
}
```

- [ ] **Step 2: Update `Competitor::receive` and `clean` signatures**

In `src/competitor.rs`, `Competitor::receive` currently takes `elapsed: i64`. Change the generic bound so it can take `from: &T, to: &T`. Since `Competitor` is parameterized by `D: Drift`, we need to also parameterize by `T: Time`:

```rust
use crate::{
    N_INF,
    drift::{ConstantDrift, Drift},
    gaussian::Gaussian,
    rating::Rating,
    time::Time,
};

#[derive(Debug)]
pub struct Competitor<T: Time, 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> {
    pub(crate) fn receive(&self, now: &T) -> Gaussian {
        if self.message != N_INF {
            let elapsed_variance = match &self.last_time {
                Some(last) => self.rating.drift.variance_delta(last, now),
                None => 0.0,
            };
            self.message.forget(elapsed_variance)
        } else {
            self.rating.prior
        }
    }
}

impl Default for Competitor<i64, ConstantDrift> {
    fn default() -> Self {
        Self {
            rating: Rating::default(),
            message: N_INF,
            last_time: None,
        }
    }
}

pub(crate) fn clean<'a, T: Time + 'a, D: Drift<T> + 'a, C: Iterator<Item = &'a mut Competitor<T, D>>>(
    competitors: C,
    last_time: bool,
) {
    for c in competitors {
        c.message = N_INF;
        if last_time {
            c.last_time = None;
        }
    }
}
```

The `last_time: i64` with `i64::MIN` sentinel becomes `last_time: Option<T>` with `None` sentinel — cleaner and type-safe.

- [ ] **Step 3: Parameterize `Rating<T, D>` likewise**

In `src/rating.rs`:

```rust
use crate::{
    BETA, GAMMA,
    drift::{ConstantDrift, Drift},
    gaussian::Gaussian,
    time::Time,
};

#[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: std::marker::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: std::marker::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: std::marker::PhantomData,
        }
    }
}
```

- [ ] **Step 4: Propagate the generic through the rest of the codebase**

Every generic `<D: Drift>` becomes `<T: Time, D: Drift<T>>` where the enclosing type owns `T`. Affected:

- `CompetitorStore<D>` → `CompetitorStore<T, D>` in `src/storage/mod.rs`
- `History<D>` → `History<T, D>` in `src/history.rs` (and every fn sig inside)
- `HistoryBuilder<D>` → `HistoryBuilder<T, D>`
- `Game<D>` → `Game<T, D>` in `src/game.rs`
- `TimeSlice` may need `T` if it holds a `time: T` field (it currently holds `time: i64` — change to `time: T`)
- `agent::clean` → already updated
- `Event` (inside `time_slice.rs`) passes competitors and therefore inherits `T`

This is the single biggest refactor in T2. Expect ~100 compiler errors; work through them file by file. The compiler is your friend — each error either needs a `T: Time` parameter added, or a type argument passed.

For `Competitor::receive(elapsed: i64)` callers — now `Competitor::receive(now: &T)`. Each caller currently computes `elapsed = compute_elapsed(last_time, batch.time)`; change those to `competitor.receive(&time_slice.time)`.

- [ ] **Step 5: Fix test modules**

Tests use `i64` timestamps and `ConstantDrift` — the defaults — so most test setup is unchanged. Places that reference `.last_time == i64::MIN` become `.last_time.is_none()`. Places constructing `Competitor { last_time: i64::MIN, ... }` become `Competitor { last_time: None, ... }`.

- [ ] **Step 6: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib
```

Expected: all tests still pass, numerically identical. If numerics drift, the Drift implementation has a bug — most likely a negative elapsed was previously allowed.

- [ ] **Step 7: Format + commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
refactor(api): generify Drift, Rating, Competitor, TimeSlice, History over T: Time

Drift now takes &T -> &T and is generic over the time axis. Untimed
impls return elapsed=0. ConstantDrift impl covers all T via the Time
trait.

Competitor.last_time moves from i64 with MIN sentinel to Option<T>
with None sentinel.

Part of T2.
EOF
)"
```

---

## Task 8: Parameterize `History<T, D>` — remove `time: bool`

**Files:**
- Modify: `src/history.rs`

The `time: bool` field is a legacy encoding of "no time axis." With `T: Time`, the type system carries that information (`T = Untimed` means no drift). This task removes the bool and fixes the `!time` internal path.

- [ ] **Step 1: Remove `time: bool` from `History` and `HistoryBuilder`**

In `src/history.rs`, delete the `time: bool` field from both structs. Delete the `.time(bool)` builder method. Update the `Default` impl and `build()` to stop threading `time` through.

- [ ] **Step 2: Remove the `!self.time` branches in `add_events_with_prior`**

Every `if self.time { … } else { … }` becomes the `self.time` branch — the generic parameter makes this uniform. Specifically, `times` is always required now (it's a `Vec<T>`, not `Vec<i64>`), and `sort_time` sorts in `T` order.

Rewrite `sort_time` in `src/lib.rs` to be generic:

```rust
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 {
        x.sort_by_key(|&(_, t)| std::cmp::Reverse(t));
    } else {
        x.sort_by_key(|&(_, t)| t);
    }
    x.into_iter().map(|(i, _)| i).collect()
}
```

- [ ] **Step 3: Keep the old nested-Vec `add_events_with_prior` signature**

For now. Change its signature only enough to take `times: Vec<T>` instead of `Vec<i64>`. Users who previously called with `!time` should switch to `History::<Untimed, _>` — but the *tests* should switch to `History::<i64, _>` with explicit `[1, 2, 3, ...]` timestamps to preserve the old elapsed=1-per-event numerical behavior.

The nested-Vec signature gets fully replaced in Task 15. This keeps the test suite green between tasks.

- [ ] **Step 4: Update tests**

Tests using `.time(false)` need translation. The mechanical rule:

| Old | New |
|---|---|
| `History::builder().time(false).build()` | `History::<i64, _>::builder().build()` with explicit `vec![1, 2, 3, …]` for `times` in every `add_events` call |
| `History::default()` (was `time: true`) | `History::<i64, _>::default()` |

For every test that called `add_events(comp, results, vec![], vec![])` under `time: false`, change to `add_events(comp, results, vec![1, 2, 3, …], vec![])` where the length matches `comp.len()`. Numerics are preserved because the old `!time` branch used `i as i64 + 1` timestamps internally.

This is mechanical. Each failing test will show which translation is needed.

- [ ] **Step 5: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib
```

Expected: all tests pass with translated timestamps.

- [ ] **Step 6: Format + commit**

```bash
cargo +nightly fmt
git add src/history.rs src/lib.rs src/time_slice.rs src/storage/
git commit -m "$(cat <<'EOF'
refactor(history): parameterize History<T, D> and remove time: bool

The bool encoded 'no time axis' which is now expressed at the type
level (T = Untimed). Removed the .time() builder method. Tests that
used time(false) now use i64 timestamps 1..=n to preserve numerics.

Part of T2.
EOF
)"
```

---

## Task 9: Add `Outcome` enum

**Files:**
- Create: `src/outcome.rs`
- Modify: `src/lib.rs`

- [ ] **Step 1: Create `src/outcome.rs`**

```rust
//! Outcome of a match.
//!
//! In T2, only `Ranked` is supported; `Scored` will be added together with
//! `MarginFactor` in T4. The enum is `#[non_exhaustive]` so adding `Scored`
//! is non-breaking for downstream `match` expressions.

use smallvec::SmallVec;

/// Final outcome of a match.
///
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Outcome {
    Ranked(SmallVec<[u32; 4]>),
}

impl Outcome {
    /// `N`-team outcome where team `winner` won and everyone else tied for last.
    ///
    /// Panics if `winner >= n`.
    pub fn winner(winner: u32, n: u32) -> Self {
        assert!(winner < n, "winner index {winner} out of range 0..{n}");
        let ranks: SmallVec<[u32; 4]> = (0..n)
            .map(|i| if i == winner { 0 } else { 1 })
            .collect();
        Self::Ranked(ranks)
    }

    /// All `n` teams tied.
    pub fn draw(n: u32) -> Self {
        Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
    }

    /// Explicit per-team ranking.
    pub fn ranking<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
        Self::Ranked(ranks.into_iter().collect())
    }

    pub fn team_count(&self) -> usize {
        match self {
            Self::Ranked(r) => r.len(),
        }
    }

    pub(crate) fn as_ranks(&self) -> &[u32] {
        match self {
            Self::Ranked(r) => r,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn winner_two_teams() {
        let o = Outcome::winner(0, 2);
        assert_eq!(o.as_ranks(), &[0u32, 1]);
        assert_eq!(o.team_count(), 2);
    }

    #[test]
    fn winner_three_teams_second_wins() {
        let o = Outcome::winner(1, 3);
        assert_eq!(o.as_ranks(), &[1u32, 0, 1]);
    }

    #[test]
    fn draw_three_teams() {
        let o = Outcome::draw(3);
        assert_eq!(o.as_ranks(), &[0u32, 0, 0]);
    }

    #[test]
    fn ranking_from_iter() {
        let o = Outcome::ranking([2, 0, 1]);
        assert_eq!(o.as_ranks(), &[2u32, 0, 1]);
    }

    #[test]
    #[should_panic(expected = "winner index 2 out of range")]
    fn winner_out_of_range_panics() {
        let _ = Outcome::winner(2, 2);
    }
}
```

- [ ] **Step 2: Add `smallvec` as a dependency**

In `Cargo.toml`, under `[dependencies]`:

```toml
smallvec = "1"
```

- [ ] **Step 3: Add `pub mod outcome;` and `pub use outcome::Outcome;` to `src/lib.rs`**

- [ ] **Step 4: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib outcome
```

Expected: 5 passing tests.

- [ ] **Step 5: Format + commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
feat(api): add Outcome enum with Ranked variant

Outcome::winner(i, n), Outcome::draw(n), Outcome::ranking(iter) are
the convenience constructors. Marked #[non_exhaustive] so Scored can
be added in T4 without breaking match exhaustiveness.

Adds smallvec dependency.

Part of T2.
EOF
)"
```

---

## Task 10: Add `Event<T, K>`, `Team<K>`, `Member<K>`

**Files:**
- Create: `src/event.rs`
- Modify: `src/lib.rs`

- [ ] **Step 1: Create `src/event.rs`**

```rust
//! 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)
    }
}
```

- [ ] **Step 2: Register in `src/lib.rs`**

```rust
pub mod event;
...
pub use event::{Event, Member, Team};
```

- [ ] **Step 3: Build**

```bash
cargo build --features approx
cargo test --features approx --lib
```

- [ ] **Step 4: Format + commit**

```bash
cargo +nightly fmt
git add src/event.rs src/lib.rs
git commit -m "$(cat <<'EOF'
feat(api): add Event<T, K>, Team<K>, Member<K> typed event description

Replaces the old nested Vec<Vec<Vec<_>>> event description on the
public API boundary. Member<K>::from(K) enables ergonomic literal
lists.

Part of T2.
EOF
)"
```

---

## Task 11: Add `Observer` trait and `NullObserver`

**Files:**
- Create: `src/observer.rs`
- Modify: `src/lib.rs`

- [ ] **Step 1: Create `src/observer.rs`**

```rust
//! Observer trait for progress reporting during convergence.
//!
//! Replaces the old `verbose: bool` + `println!` path. Callers wire in any
//! observer that implements the trait; default methods are no-ops so users
//! override only what they need.

use crate::time::Time;

/// Receives progress callbacks during `History::converge`.
///
/// All methods have default no-op implementations; implement only what's
/// interesting. Send/Sync is NOT required in T2 (added in T3 along with
/// Rayon support).
pub trait Observer<T: Time> {
    /// 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 {}
```

- [ ] **Step 2: Register in `src/lib.rs`**

```rust
pub mod observer;
...
pub use observer::{NullObserver, Observer};
```

- [ ] **Step 3: Build**

```bash
cargo build --features approx
cargo test --features approx --lib
```

- [ ] **Step 4: Commit**

```bash
cargo +nightly fmt
git add src/observer.rs src/lib.rs
git commit -m "$(cat <<'EOF'
feat(api): add Observer trait and NullObserver default

Observer replaces verbose: bool with structured progress callbacks.
NullObserver is a ZST default; users override only the methods they
care about.

Send + Sync bounds deferred to T3.

Part of T2.
EOF
)"
```

---

## Task 12: Add `ConvergenceOptions` and `ConvergenceReport`; wire into history

**Files:**
- Create: `src/convergence.rs`
- Modify: `src/history.rs`, `src/lib.rs`

- [ ] **Step 1: Create `src/convergence.rs`**

```rust
//! Convergence configuration and reporting.

use std::time::Duration;

use smallvec::SmallVec;

#[derive(Clone, Copy, Debug)]
pub struct ConvergenceOptions {
    pub max_iter: usize,
    pub epsilon: f64,
}

impl Default for ConvergenceOptions {
    fn default() -> Self {
        Self {
            max_iter: crate::ITERATIONS,
            epsilon: crate::EPSILON,
        }
    }
}

/// Post-hoc summary of a `History::converge` call.
#[derive(Clone, Debug)]
pub struct ConvergenceReport {
    pub iterations: usize,
    pub final_step: (f64, f64),
    pub log_evidence: f64,
    pub converged: bool,
    pub per_iteration_time: SmallVec<[Duration; 32]>,
    pub slices_skipped: usize,
}
```

- [ ] **Step 2: Register in `src/lib.rs`**

```rust
pub mod convergence;
...
pub use convergence::{ConvergenceOptions, ConvergenceReport};
```

- [ ] **Step 3: Add `convergence: ConvergenceOptions` and `observer` to `HistoryBuilder`**

In `src/history.rs`, add to `HistoryBuilder`:

```rust
pub fn convergence(mut self, opts: ConvergenceOptions) -> Self {
    self.convergence = opts;
    self
}

pub fn observer<O2: Observer<T>>(self, observer: O2) -> HistoryBuilder<T, D, O2> { … }
```

`HistoryBuilder` gains two extra generic parameters (`O: Observer<T>` and observer storage). The simplest shape:

```rust
pub struct HistoryBuilder<T: Time = i64, D: Drift<T> = ConstantDrift, O: Observer<T> = NullObserver> {
    mu: f64,
    sigma: f64,
    beta: f64,
    drift: D,
    p_draw: f64,
    convergence: ConvergenceOptions,
    observer: O,
    _time: std::marker::PhantomData<T>,
}
```

`History<T, D, O>` gains the same `O` parameter; `observer` is stored and called from the convergence loop.

- [ ] **Step 4: Add `history.converge() -> Result<ConvergenceReport, InferenceError>`**

```rust
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
    use std::time::Instant;
    let opts = self.convergence;
    let mut step = (f64::INFINITY, f64::INFINITY);
    let mut i = 0;
    let mut per_iter: SmallVec<[Duration; 32]> = SmallVec::new();
    while crate::tuple_gt(step, opts.epsilon) && i < opts.max_iter {
        let t0 = Instant::now();
        step = self.iteration();
        per_iter.push(t0.elapsed());
        i += 1;
        self.observer.on_iteration_end(i, step);
    }
    let converged = !crate::tuple_gt(step, opts.epsilon);
    let log_evidence = self.log_evidence_impl(false, &[]);
    self.observer.on_converged(i, step, converged);
    Ok(ConvergenceReport {
        iterations: i,
        final_step: step,
        log_evidence,
        converged,
        per_iteration_time: per_iter,
        slices_skipped: 0,
    })
}
```

Keep the old `convergence(iters, eps, verbose)` method alive for one more task. It is removed in Task 20 alongside test translation.

- [ ] **Step 5: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib
```

- [ ] **Step 6: Commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
feat(api): add ConvergenceOptions, ConvergenceReport, and History::converge

HistoryBuilder gains .convergence(opts) and .observer(o). History::converge
returns a structured ConvergenceReport with per-iteration timings.

The old History::convergence(iters, eps, verbose) still works and is
removed in Task 20.

Part of T2.
EOF
)"
```

---

## Task 13: Expand `InferenceError`; convert boundary panics to `Result`

**Files:**
- Modify: `src/error.rs`
- Modify: `src/history.rs`, `src/game.rs` (where `debug_assert!` or `assert!` guard user input)

- [ ] **Step 1: Expand `InferenceError`**

Replace `src/error.rs`:

```rust
use std::fmt;

#[derive(Debug, Clone, PartialEq)]
pub enum InferenceError {
    /// Expected and actual lengths of some array-shaped input differ.
    MismatchedShape {
        kind: &'static str,
        expected: usize,
        got: usize,
    },
    /// A probability value is outside `[0, 1]`.
    InvalidProbability { value: f64 },
    /// Convergence exceeded `max_iter` without falling below `epsilon`.
    ConvergenceFailed {
        last_step: (f64, f64),
        iterations: usize,
    },
    /// Negative precision: a Gaussian with `pi < 0` slipped into an API call.
    NegativePrecision { pi: f64 },
}

impl fmt::Display for InferenceError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MismatchedShape { kind, expected, got } => {
                write!(f, "{kind}: expected length {expected}, got {got}")
            }
            Self::InvalidProbability { value } => {
                write!(f, "probability must be in [0, 1]; got {value}")
            }
            Self::ConvergenceFailed { last_step, iterations } => {
                write!(
                    f,
                    "convergence failed after {iterations} iterations; last step = {last_step:?}"
                )
            }
            Self::NegativePrecision { pi } => {
                write!(f, "precision must be non-negative; got {pi}")
            }
        }
    }
}

impl std::error::Error for InferenceError {}
```

- [ ] **Step 2: Convert boundary panics to `Result`**

Search for `debug_assert!` and `assert!` at the API boundary:

```bash
grep -rn 'debug_assert!\|^\s*assert!' src/history.rs src/game.rs
```

In `Game::new` (the internal path kept for `History`), the existing `debug_assert!`s remain — they catch invariants, not user errors. But for a public `Game::ranked` (added later in Task 19), return `InferenceError::MismatchedShape` on bad input rather than panicking.

In `History::add_events_with_prior`, the four `assert!(...)` calls at the top of the function become `Result<(), InferenceError>` returns. Change the function signature:

```rust
pub(crate) fn add_events_with_prior(
    &mut self,
    …
) -> Result<(), InferenceError> {
    if results.len() != composition.len() && !results.is_empty() {
        return Err(InferenceError::MismatchedShape {
            kind: "results",
            expected: composition.len(),
            got: results.len(),
        });
    }
    // … other checks …
    // … rest of body …
    Ok(())
}
```

Every caller must add `?`. Tests that were calling `h.add_events(…)` (the thin wrapper) now call `h.add_events(…)?` or `.unwrap()` in tests.

- [ ] **Step 3: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib
```

- [ ] **Step 4: Commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
feat(error): expand InferenceError; convert boundary asserts to Result

Adds MismatchedShape, InvalidProbability, ConvergenceFailed. History's
add_events_with_prior now returns Result<(), InferenceError>. Internal
debug_asserts for invariants stay; user-facing shape/bounds checks
become errors.

Part of T2.
EOF
)"
```

---

## Task 14: Add `record_winner` and `record_draw` convenience API

**Files:**
- Modify: `src/history.rs`

- [ ] **Step 1: Add the methods**

In `impl<T: Time, D: Drift<T>, O: Observer<T>> History<T, D, O>`:

```rust
/// Record a single 1v1 match where `winner` beat `loser` at time `time`.
pub fn record_winner<Q>(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError>
where
    K: std::borrow::Borrow<Q> + std::hash::Hash + Eq + ToOwned<Owned = K>,
    Q: std::hash::Hash + Eq + ?Sized,
{
    let w_idx = self.intern(winner);
    let l_idx = self.intern(loser);
    self.add_events_with_prior(
        vec![vec![vec![w_idx], vec![l_idx]]],
        vec![vec![1.0, 0.0]],
        vec![time],
        vec![],
        std::collections::HashMap::new(),
    )
}

/// Record a 1v1 tie.
pub fn record_draw<Q>(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError>
where
    K: std::borrow::Borrow<Q> + std::hash::Hash + Eq + ToOwned<Owned = K>,
    Q: std::hash::Hash + Eq + ?Sized,
{
    let a_idx = self.intern(a);
    let b_idx = self.intern(b);
    self.add_events_with_prior(
        vec![vec![vec![a_idx], vec![b_idx]]],
        vec![vec![0.0, 0.0]],
        vec![time],
        vec![],
        std::collections::HashMap::new(),
    )
}

/// Get or create an `Index` for a user key. See spec Section 4 "Open question 3."
pub fn intern<Q>(&mut self, key: &Q) -> Index
where
    K: std::borrow::Borrow<Q> + std::hash::Hash + Eq + ToOwned<Owned = K>,
    Q: std::hash::Hash + Eq + ?Sized,
{
    self.keys.get_or_create(key)
}

/// Look up an `Index` for a key without creating it.
pub fn lookup<Q>(&self, key: &Q) -> Option<Index>
where
    K: std::borrow::Borrow<Q> + std::hash::Hash + Eq + ToOwned<Owned = K>,
    Q: std::hash::Hash + Eq + ?Sized,
{
    self.keys.get(key)
}
```

Note: `History` does not currently hold a `KeyTable<K>`. This task requires adding `keys: KeyTable<K>` to the `History` struct (which also adds `K: Eq + Hash` bound). The generic parameter `K` is threaded through `History<T, D, O, K>`. This is a significant shape change — work through the compiler errors.

- [ ] **Step 2: Add an integration test for `record_winner`**

Create `tests/record_winner.rs`:

```rust
use trueskill_tt::{ConstantDrift, ConvergenceOptions, History};

#[test]
fn record_winner_updates_skills() {
    let mut h = History::<i64, _, _, &'static str>::builder()
        .mu(25.0)
        .sigma(25.0 / 3.0)
        .beta(25.0 / 6.0)
        .drift(ConstantDrift(25.0 / 300.0))
        .build();

    h.record_winner(&"alice", &"bob", 0).unwrap();
    h.converge().unwrap();

    let alice = h.current_skill(&"alice").unwrap();
    let bob = h.current_skill(&"bob").unwrap();

    assert!(alice.mu() > 25.0, "winner mu should rise: got {}", alice.mu());
    assert!(bob.mu() < 25.0, "loser mu should fall: got {}", bob.mu());
}
```

This test depends on `current_skill` (added in Task 17). For now, comment out the last three lines or replace with a `learning_curves()` check; come back to it in Task 17.

- [ ] **Step 3: Build + test**

```bash
cargo build --features approx
cargo test --features approx --test record_winner
```

- [ ] **Step 4: Commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
feat(api): add record_winner, record_draw, intern, lookup on History

Spec Section 4 "three-tier event ingestion" tier 2: one-off match
convenience. History now holds a KeyTable<K> internally; generic
parameter K added to History.

Part of T2.
EOF
)"
```

---

## Task 15: Replace nested-Vec `add_events` with typed `add_events(iter)`

**Files:**
- Modify: `src/history.rs`

- [ ] **Step 1: Add the new `add_events` signature**

```rust
pub fn add_events<I>(&mut self, events: I) -> Result<(), InferenceError>
where
    I: IntoIterator<Item = Event<T, K>>,
{
    // Translate each Event<T, K> into the internal composition/results/times/weights
    // triple, then delegate to add_events_with_prior (which becomes pub(crate)).
    let mut composition: Vec<Vec<Vec<Index>>> = Vec::new();
    let mut results: Vec<Vec<f64>> = Vec::new();
    let mut times: Vec<T> = Vec::new();
    let mut weights: Vec<Vec<Vec<f64>>> = Vec::new();
    let mut priors: HashMap<Index, Rating<T, D>> = HashMap::new();

    for ev in events {
        let mut event_comp: Vec<Vec<Index>> = Vec::new();
        let mut event_weights: Vec<Vec<f64>> = Vec::new();
        for team in &ev.teams {
            let mut team_indices: Vec<Index> = Vec::new();
            let mut team_weights: Vec<f64> = Vec::new();
            for member in &team.members {
                let idx = self.intern(&member.key);
                team_indices.push(idx);
                team_weights.push(member.weight);
                if let Some(prior) = member.prior {
                    priors.insert(
                        idx,
                        Rating::new(prior, self.beta, self.drift),
                    );
                }
            }
            event_comp.push(team_indices);
            event_weights.push(team_weights);
        }
        composition.push(event_comp);
        weights.push(event_weights);
        // Convert Outcome::Ranked to the legacy "lower number wins" f64 result.
        // Legacy convention: result[i] is a score; higher score = better rank.
        // We invert ranks: team with rank 0 gets highest score.
        let ranks = ev.outcome.as_ranks();
        if ranks.len() != ev.teams.len() {
            return Err(InferenceError::MismatchedShape {
                kind: "outcome ranks vs teams",
                expected: ev.teams.len(),
                got: ranks.len(),
            });
        }
        let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
        let inverted: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect();
        results.push(inverted);
        times.push(ev.time);
    }

    self.add_events_with_prior(composition, results, times, weights, priors)
}
```

Rename the old public `add_events` (nested-Vec) to `pub(crate) fn add_events_legacy` or inline it into the internal call path; delete it from the public API surface.

- [ ] **Step 2: Add an integration test for the new signature**

In `tests/api_shape.rs`:

```rust
use trueskill_tt::{Event, History, Member, Outcome, Team};
use smallvec::smallvec;

#[test]
fn add_events_bulk_via_iter() {
    let mut h = History::<i64, _, _, &'static str>::builder()
        .mu(0.0).sigma(2.0).beta(1.0).p_draw(0.0)
        .build();

    let events = vec![
        Event {
            time: 1,
            teams: smallvec![
                Team::with_members([Member::new("a")]),
                Team::with_members([Member::new("b")]),
            ],
            outcome: Outcome::winner(0, 2),
        },
        Event {
            time: 2,
            teams: smallvec![
                Team::with_members([Member::new("b")]),
                Team::with_members([Member::new("c")]),
            ],
            outcome: Outcome::winner(0, 2),
        },
    ];

    h.add_events(events).unwrap();
    let report = h.converge().unwrap();
    assert!(report.converged, "expected convergence, got {report:?}");
}
```

- [ ] **Step 3: Build + test**

```bash
cargo build --features approx
cargo test --features approx --test api_shape
```

- [ ] **Step 4: Commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
feat(api): replace nested-Vec add_events with typed add_events(iter)

The old nested shape is gone from the public API. add_events now
takes IntoIterator<Item = Event<T, K>>. Internally routes through
add_events_with_prior (now pub(crate)).

Part of T2.
EOF
)"
```

---

## Task 16: Add `history.event(time).team(...).commit()` fluent builder

**Files:**
- Create: `src/event_builder.rs`
- Modify: `src/history.rs`, `src/lib.rs`

- [ ] **Step 1: Create `src/event_builder.rs`**

```rust
//! Fluent builder for single-event ingestion.
//!
//! ```ignore
//! history
//!     .event(time)
//!     .team(["alice", "bob"]).weights([1.0, 0.7])
//!     .team(["carol"])
//!     .ranking([1, 0])
//!     .commit()?;
//! ```

use std::collections::HashMap;

use smallvec::SmallVec;

use crate::{
    InferenceError, Outcome,
    event::{Event, Member, Team},
    history::History,
    observer::Observer,
    drift::Drift,
    time::Time,
};

pub struct EventBuilder<'h, T, D, O, K>
where
    T: Time,
    D: Drift<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, default_n_teams: usize) -> Self {
        Self {
            history,
            event: Event {
                time,
                teams: SmallVec::with_capacity(default_n_teams.max(2)),
                outcome: Outcome::Ranked(SmallVec::new()),
            },
            current_team_idx: None,
        }
    }

    /// Add a team by its member keys.
    pub fn team<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.
    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.into_iter()) {
            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
    }

    /// Mark team `winner_idx` as winner of an N-way match, others tied for last.
    pub fn winner(mut self, winner_idx: u32) -> Self {
        self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32);
        self
    }

    /// All teams tied.
    pub fn draw(mut self) -> Self {
        self.event.outcome = Outcome::draw(self.event.teams.len() as u32);
        self
    }

    /// Commit the event to the history.
    pub fn commit(self) -> Result<(), InferenceError> {
        self.history.add_events(std::iter::once(self.event))
    }
}
```

- [ ] **Step 2: Add `History::event` method**

In `src/history.rs`:

```rust
pub fn event(&mut self, time: T) -> EventBuilder<'_, T, D, O, K> {
    EventBuilder::new(self, time, 2)
}
```

- [ ] **Step 3: Register the module in `src/lib.rs`**

```rust
pub mod event_builder;
pub use event_builder::EventBuilder;
```

- [ ] **Step 4: Add a test**

Append to `tests/api_shape.rs`:

```rust
#[test]
fn fluent_event_builder() {
    let mut h = History::<i64, _, _, &'static str>::builder()
        .mu(25.0).sigma(25.0 / 3.0).beta(25.0 / 6.0).p_draw(0.0)
        .build();

    h.event(1)
        .team(["alice", "bob"])
        .weights([1.0, 0.7])
        .team(["carol"])
        .ranking([1, 0])
        .commit()
        .unwrap();

    let report = h.converge().unwrap();
    assert!(report.converged);
}
```

- [ ] **Step 5: Build + test**

```bash
cargo build --features approx
cargo test --features approx --test api_shape fluent_event_builder
```

- [ ] **Step 6: Commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
feat(api): add fluent history.event(t).team(...).commit() builder

Third tier of the ingestion API (per spec Section 4). Powers one-off
events with irregular shapes where neither record_winner nor typed
add_events fits cleanly.

Part of T2.
EOF
)"
```

---

## Task 17: Query methods — `current_skill`, `learning_curve`, `log_evidence_for`, `predict_*`

**Files:**
- Modify: `src/history.rs`, `src/game.rs`, `src/lib.rs`

- [ ] **Step 1: Add `current_skill` and single-key `learning_curve` to History**

```rust
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + std::hash::Hash + Clone> History<T, D, O, K> {
    /// Skill estimate at the latest time slice the competitor appears in.
    pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
    where
        K: std::borrow::Borrow<Q>,
        Q: std::hash::Hash + Eq + ?Sized,
    {
        let idx = self.keys.get(key)?;
        self.time_slices
            .iter()
            .rev()
            .find_map(|ts| ts.skills.get(idx).map(|sk| sk.posterior()))
    }

    /// Learning curve for a single key: (time, posterior) pairs in time order.
    pub fn learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
    where
        K: std::borrow::Borrow<Q>,
        Q: std::hash::Hash + Eq + ?Sized,
    {
        let Some(idx) = self.keys.get(key) else {
            return Vec::new();
        };
        self.time_slices
            .iter()
            .filter_map(|ts| ts.skills.get(idx).map(|sk| (ts.time, sk.posterior())))
            .collect()
    }
}
```

- [ ] **Step 2: Update `learning_curves` to return `HashMap<K, Vec<(T, Gaussian)>>`**

Currently returns `HashMap<Index, Vec<(i64, Gaussian)>>`. New shape:

```rust
pub fn learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
    let mut data: HashMap<K, Vec<(T, Gaussian)>> = HashMap::new();
    for slice in &self.time_slices {
        for (idx, skill) in slice.skills.iter() {
            if let Some(key) = self.keys.key(idx).cloned() {
                data.entry(key)
                    .or_default()
                    .push((slice.time, skill.posterior()));
            }
        }
    }
    data
}
```

- [ ] **Step 3: Add `log_evidence()` and `log_evidence_for(&[&K])`**

```rust
pub fn log_evidence(&mut self) -> f64 {
    self.log_evidence_impl(false, &[])
}

pub fn log_evidence_for<Q>(&mut self, keys: &[&Q]) -> f64
where
    K: std::borrow::Borrow<Q>,
    Q: std::hash::Hash + Eq + ?Sized,
{
    let targets: Vec<Index> = keys
        .iter()
        .filter_map(|k| self.keys.get(k))
        .collect();
    self.log_evidence_impl(false, &targets)
}

fn log_evidence_impl(&mut self, forward: bool, targets: &[Index]) -> f64 {
    self.time_slices
        .iter()
        .map(|ts| ts.log_evidence(self.online, targets, forward, &self.competitors))
        .sum()
}
```

The existing public `log_evidence(forward, targets)` method is renamed to `log_evidence_impl` (pub(crate)) and the thin public wrappers take its place. The `forward` bool ("online" mode) stays internal; no public exposure planned for T2.

- [ ] **Step 4: Add `predict_quality` and `predict_outcome`**

These are light wrappers over the existing `quality()` free function plus a new "predict outcome" that runs a one-off Game without adding to history:

```rust
pub fn predict_quality(&self, teams: &[&[&K]]) -> f64
where
    K: std::borrow::Borrow<K>,
{
    let groups: Vec<Vec<Gaussian>> = teams
        .iter()
        .map(|team| {
            team.iter()
                .filter_map(|k| self.keys.get(*k))
                .filter_map(|idx| {
                    self.time_slices
                        .iter()
                        .rev()
                        .find_map(|ts| ts.skills.get(idx).map(|s| s.posterior()))
                })
                .collect()
        })
        .collect();
    let group_refs: Vec<&[Gaussian]> = groups.iter().map(|g| g.as_slice()).collect();
    crate::quality(&group_refs, self.beta)
}

pub fn predict_outcome(&self, teams: &[&[&K]]) -> Vec<f64> {
    // Win probabilities per team: apply cdf over team-perf sums.
    // For n teams, return P(team i wins over all others).
    // Minimal impl for T2: 2-team case only; multi-team deferred to T4.
    assert_eq!(teams.len(), 2, "predict_outcome T2: 2 teams only");
    let gather = |team: &[&K]| -> Gaussian {
        team.iter()
            .filter_map(|k| self.keys.get(*k))
            .filter_map(|idx| {
                self.time_slices
                    .iter()
                    .rev()
                    .find_map(|ts| ts.skills.get(idx).map(|s| s.posterior()))
            })
            .fold(crate::N00, |acc, g| acc + g.forget(self.beta.powi(2)))
    };
    let a = gather(teams[0]);
    let b = gather(teams[1]);
    let diff = a - b;
    let p_a = 1.0 - crate::cdf(0.0, diff.mu(), diff.sigma());
    vec![p_a, 1.0 - p_a]
}
```

Document `predict_outcome` as 2-team-only in T2; N-team lands in T4 alongside `Residual` schedules.

- [ ] **Step 5: Add tests**

In `tests/api_shape.rs`:

```rust
#[test]
fn current_skill_learning_curve_learning_curves() {
    let mut h = History::<i64, _, _, &'static str>::builder()
        .mu(25.0).sigma(25.0 / 3.0).beta(25.0 / 6.0).p_draw(0.0)
        .build();
    h.record_winner(&"a", &"b", 1).unwrap();
    h.record_winner(&"a", &"b", 2).unwrap();
    h.converge().unwrap();

    let a = h.current_skill(&"a").unwrap();
    assert!(a.mu() > 25.0);

    let curve = h.learning_curve(&"a");
    assert_eq!(curve.len(), 2);
    assert_eq!(curve[0].0, 1);
    assert_eq!(curve[1].0, 2);

    let all = h.learning_curves();
    assert_eq!(all.len(), 2);
    assert!(all.contains_key("a"));
}

#[test]
fn log_evidence_for_subset() {
    let mut h = History::<i64, _, _, &'static str>::builder()
        .mu(0.0).sigma(6.0).beta(1.0).p_draw(0.0)
        .build();
    h.record_winner(&"a", &"b", 1).unwrap();
    h.record_winner(&"b", &"a", 2).unwrap();
    let ev_all = h.log_evidence();
    let ev_a = h.log_evidence_for(&[&"a"]);
    assert_ne!(ev_all, ev_a);
}
```

- [ ] **Step 6: Build + test**

```bash
cargo build --features approx
cargo test --features approx --test api_shape
```

- [ ] **Step 7: Commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
feat(api): add current_skill, learning_curve (single), log_evidence_for, predict_*

learning_curves now returns HashMap<K, Vec<(T, Gaussian)>> keyed on
the user's key type. log_evidence is public zero-arg; log_evidence_for
takes a slice of keys.

predict_outcome is T2 2-team-only; N-team deferred.

Part of T2.
EOF
)"
```

---

## Task 18: Promote `Factor` / `Schedule` / `VarStore` to `pub` under a `factors` module

**Files:**
- Create: `src/factors.rs`
- Modify: `src/factor/mod.rs`, `src/factor/rank_diff.rs`, `src/factor/team_sum.rs`, `src/factor/trunc.rs`, `src/schedule.rs`, `src/lib.rs`

- [ ] **Step 1: Promote visibility inside `src/factor/mod.rs`**

Change `pub(crate) struct VarId(pub(crate) u32);` → `pub struct VarId(pub u32);`. Similarly:
- `pub(crate) struct VarStore` → `pub struct VarStore` (keep `marginals` field `pub(crate)` — implementation detail).
- `pub(crate) trait Factor` → `pub trait Factor`.
- `pub(crate) enum BuiltinFactor` → `pub enum BuiltinFactor`.
- Submodules: `pub(crate) mod team_sum;` → `pub mod team_sum;` and similarly for `rank_diff`, `trunc`.
- Inside each submodule, promote the struct and its fields/constructors from `pub(crate)` to `pub`.

Remove any `#[allow(dead_code)]` that was guarding visibility warnings.

- [ ] **Step 2: Promote `Schedule` and `EpsilonOrMax` in `src/schedule.rs`**

```rust
pub trait Schedule {
    fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport;
}

pub struct EpsilonOrMax {
    pub eps: f64,
    pub max: usize,
}
```

Remove `#[allow(dead_code)]`.

- [ ] **Step 3: Create `src/factors.rs` that re-exports the public API**

```rust
//! Factor-graph public API.
//!
//! Power users can construct custom factor graphs via `Game::custom` and
//! drive them with a custom `Schedule` implementation.

pub use crate::factor::{BuiltinFactor, Factor, VarId, VarStore};
pub use crate::factor::rank_diff::RankDiffFactor;
pub use crate::factor::team_sum::TeamSumFactor;
pub use crate::factor::trunc::TruncFactor;
pub use crate::schedule::{EpsilonOrMax, Schedule, ScheduleReport};
```

- [ ] **Step 4: Register in `src/lib.rs`**

Keep `factor` and `schedule` modules as they are (internal use), and add:

```rust
pub mod factors;
```

- [ ] **Step 5: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib
```

- [ ] **Step 6: Commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
feat(api): promote Factor/Schedule/VarStore to pub in `factors` module

Exposes the factor-graph machinery so power users can define custom
factors and schedules (see Game::custom in the next task). The
internal factor/ and schedule/ modules remain pub(crate) — user-
facing API goes through the factors module re-exports.

Part of T2.
EOF
)"
```

---

## Task 19: `Game::ranked`, `Game::one_v_one`, `Game::free_for_all`, `Game::custom`

**Files:**
- Modify: `src/game.rs`

- [ ] **Step 1: Split the internal `Game::new` into a `Game::ranked_with_arena`**

Move the current `Game::new` body into `pub(crate) fn ranked_with_arena(...) -> Self`. The public signatures become:

```rust
pub struct GameOptions {
    pub p_draw: f64,
    pub convergence: ConvergenceOptions,
}

impl Default for GameOptions {
    fn default() -> Self {
        Self {
            p_draw: crate::P_DRAW,
            convergence: ConvergenceOptions::default(),
        }
    }
}

impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
    /// Build a ranked match from borrowed team rosters and an outcome.
    ///
    /// Returns `Err(MismatchedShape)` if outcome rank count doesn't match
    /// team count. Returns `Err(InvalidProbability)` if p_draw is out of range.
    pub fn ranked(
        teams: &[&[Rating<T, D>]],
        outcome: Outcome,
        options: &GameOptions,
    ) -> Result<Self, InferenceError> {
        if !(0.0..1.0).contains(&options.p_draw) {
            return Err(InferenceError::InvalidProbability { value: options.p_draw });
        }
        if outcome.team_count() != teams.len() {
            return Err(InferenceError::MismatchedShape {
                kind: "outcome ranks vs teams",
                expected: teams.len(),
                got: outcome.team_count(),
            });
        }

        // Translate ranks to the legacy "higher f64 = better" result.
        let ranks = outcome.as_ranks();
        let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
        let result: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect();
        let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
        let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
        let mut arena = ScratchArena::new();
        let game = Self::ranked_with_arena(
            teams_owned,
            &result,
            &weights,
            options.p_draw,
            &mut arena,
        );
        Ok(game)
    }

    /// 1v1 convenience: returns posteriors `(a_post, b_post)` directly.
    pub fn one_v_one(
        a: &Rating<T, D>,
        b: &Rating<T, D>,
        outcome: Outcome,
    ) -> Result<(Gaussian, Gaussian), InferenceError> {
        let game = Self::ranked(
            &[&[*a], &[*b]],
            outcome,
            &GameOptions::default(),
        )?;
        let post = game.posteriors();
        Ok((post[0][0], post[1][0]))
    }

    /// FFA: each entry is a single rating; outcome ranks per player.
    pub fn free_for_all(
        players: &[&Rating<T, D>],
        outcome: Outcome,
        options: &GameOptions,
    ) -> Result<Self, InferenceError> {
        let teams: Vec<Vec<Rating<T, D>>> = players.iter().map(|p| vec![**p]).collect();
        let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
        Self::ranked(&team_refs, outcome, options)
    }

    /// Power-user: build a game from a user-defined factor graph.
    ///
    /// The caller owns the `VarStore` and `Vec<BuiltinFactor>`. The schedule
    /// is run once; `posteriors_from_vars` extracts team posteriors by VarId.
    pub fn custom<S: Schedule>(
        teams: Vec<Vec<Rating<T, D>>>,
        vars: VarStore,
        factors: Vec<BuiltinFactor>,
        team_perf_vars: Vec<VarId>,
        weights: Vec<Vec<f64>>,
        schedule: &S,
    ) -> Self {
        // Partial impl: run schedule, populate likelihoods, evidence.
        // Full custom-factor support expands in T4.
        let mut this = Self {
            teams,
            result: &[],        // Not used for custom — outcome encoded in factors.
            weights: &[],
            p_draw: 0.0,
            likelihoods: Vec::new(),
            evidence: 1.0,
        };
        this.run_custom(vars, factors, team_perf_vars, weights, schedule);
        this
    }

    /// Log-evidence for this game (zero if there were no Trunc factors).
    pub fn log_evidence(&self) -> f64 {
        self.evidence.ln()
    }
}
```

`Game::custom` is the spec's escape hatch for user-defined factors. For T2 it only needs to *work*; its full ergonomics land in T4. Mark it `#[doc(hidden)]` if unfinished.

- [ ] **Step 2: Add tests**

```rust
#[test]
fn game_ranked_1v1() {
    let a = Rating::new(Gaussian::from_ms(25.0, 25.0/3.0), 25.0/6.0, ConstantDrift(25.0/300.0));
    let b = Rating::new(Gaussian::from_ms(25.0, 25.0/3.0), 25.0/6.0, ConstantDrift(25.0/300.0));
    let g = Game::<i64, _>::ranked(
        &[&[a], &[b]],
        Outcome::winner(0, 2),
        &GameOptions::default(),
    ).unwrap();
    let p = g.posteriors();
    assert_ulps_eq!(p[0][0], Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6);
    assert_ulps_eq!(p[1][0], Gaussian::from_ms(20.794779, 7.194481), epsilon = 1e-6);
}

#[test]
fn game_one_v_one_shortcut() {
    let a = Rating::<i64>::new(Gaussian::from_ms(25.0, 25.0/3.0), 25.0/6.0, ConstantDrift(25.0/300.0));
    let b = Rating::<i64>::new(Gaussian::from_ms(25.0, 25.0/3.0), 25.0/6.0, ConstantDrift(25.0/300.0));
    let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap();
    assert_ulps_eq!(a_post, Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6);
    assert_ulps_eq!(b_post, Gaussian::from_ms(20.794779, 7.194481), epsilon = 1e-6);
}

#[test]
fn game_ranked_rejects_bad_p_draw() {
    let a = Rating::<i64>::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
    let err = Game::<i64, _>::ranked(
        &[&[a], &[a]],
        Outcome::winner(0, 2),
        &GameOptions { p_draw: 1.5, convergence: Default::default() },
    ).unwrap_err();
    assert!(matches!(err, InferenceError::InvalidProbability { .. }));
}
```

Note `Outcome::winner(0, 2)` makes team 0 the winner and team 1 the loser. The old test used `&[0.0, 1.0]` where higher = worse (team 1 had result 1.0 meaning worse result i.e. team 0 won). Under new convention `Outcome::winner(0, 2)` → ranks `[0, 1]` → mapped to legacy results `[1.0, 0.0]` → team 0 wins. But the old test asserted `p[0][0] = Gaussian::from_ms(20.794779, 7.194481)` (loser) and `p[1][0] = Gaussian::from_ms(29.205220, 7.194481)` (winner). That's because the old convention was higher `result` = worse position. **Double-check this mapping before committing golden values** — run the test and if p[0] vs p[1] flip, swap the expected values.

- [ ] **Step 3: Build + test**

```bash
cargo build --features approx
cargo test --features approx --lib game
```

- [ ] **Step 4: Commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
feat(api): add Game::ranked, one_v_one, free_for_all, custom constructors

Public Game API now returns Result<Self, InferenceError> on bad
inputs. GameOptions bundles p_draw and convergence config.
Game::custom is an escape hatch for user-defined factor graphs
(full ergonomics land in T4).

Part of T2.
EOF
)"
```

---

## Task 20: Translate the full test suite to the new API; delete legacy methods

**Files:**
- Modify: every `#[cfg(test)] mod tests` across `src/`
- Modify: `tests/equivalence.rs` (new file)
- Delete: `History::convergence(iters, eps, verbose)`, the legacy `add_events` wrapper

- [ ] **Step 1: Inventory remaining legacy callers**

```bash
grep -rn 'h\.convergence(\|\.add_events(vec!\|\.time(true)\|\.time(false)\|\.gamma(' src/ tests/
```

Every hit is a site that must be translated.

- [ ] **Step 2: Translation cheat-sheet**

Apply this uniformly across every test module. Example old form (from `src/history.rs`):

```rust
let mut h = History::builder()
    .mu(0.0).sigma(2.0).beta(1.0).gamma(0.0).time(false).build();
h.add_events(composition, results, vec![], vec![]);
h.convergence(ITERATIONS, EPSILON, false);
```

New form:

```rust
let mut h = History::<i64, _, _, &'static str>::builder()
    .mu(0.0).sigma(2.0).beta(1.0).drift(ConstantDrift(0.0)).build();
// Translate nested Vec<Vec<Vec<Index>>> into Vec<Event<_, &str>>
let events = translate_to_events(composition, results, /* times = 1..=n */ (1..=n).collect());
h.add_events(events).unwrap();
h.converge().unwrap();
```

Since the tests use `IndexMap::get_or_create("a")` returning `Index`, they'll need updating to either use `history.intern(&"a")` directly or use `Member::new("a")` in `Event`.

Provide a test helper in a `tests/common.rs` module:

```rust
pub fn winner_event(time: i64, w: &'static str, l: &'static str) -> Event<i64, &'static str> {
    use smallvec::smallvec;
    Event {
        time,
        teams: smallvec![
            Team::with_members([Member::new(w)]),
            Team::with_members([Member::new(l)]),
        ],
        outcome: Outcome::winner(0, 2),
    }
}

pub fn draw_event(time: i64, a: &'static str, b: &'static str) -> Event<i64, &'static str> {
    use smallvec::smallvec;
    Event {
        time,
        teams: smallvec![
            Team::with_members([Member::new(a)]),
            Team::with_members([Member::new(b)]),
        ],
        outcome: Outcome::draw(2),
    }
}
```

Then `h.add_events(vec![winner_event(1, "a", "b"), winner_event(2, "a", "c"), winner_event(3, "b", "c")]).unwrap();`.

- [ ] **Step 3: Translate each old test one at a time**

Work through `src/history.rs::tests`, `src/game.rs::tests`, `src/time_slice.rs::tests`. Each test retains its hardcoded golden values — numerical behavior is unchanged. Only the *construction* changes.

For tests that accessed `h.batches[i].skills.get(idx).unwrap().posterior()`, change to `h.learning_curve(&key)[i].1` or `h.current_skill(&key).unwrap()`.

For tests that looked at internal state (`h.batches[0].events.len()`, `get_composition()` etc.), delete those assertions if the new API doesn't expose them, or move them behind `#[cfg(test)]` accessors.

- [ ] **Step 4: Create `tests/equivalence.rs` — the regression net**

```rust
//! Verifies that the new API produces the same numerical results as the
//! hardcoded goldens from the old API (taken from the original Python port).

use approx::assert_ulps_eq;
use trueskill_tt::{
    ConstantDrift, ConvergenceOptions, Event, Gaussian, History, Member, Outcome, Team,
};
use smallvec::smallvec;

#[test]
fn test_1vs1_matches_old_golden() {
    use trueskill_tt::{Game, GameOptions, Rating};
    let a = Rating::<i64>::new(
        Gaussian::from_ms(25.0, 25.0 / 3.0),
        25.0 / 6.0,
        ConstantDrift(25.0 / 300.0),
    );
    let b = Rating::<i64>::new(
        Gaussian::from_ms(25.0, 25.0 / 3.0),
        25.0 / 6.0,
        ConstantDrift(25.0 / 300.0),
    );
    let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap();
    assert_ulps_eq!(a_post, Gaussian::from_ms(29.205220, 7.194481), epsilon = 1e-6);
    assert_ulps_eq!(b_post, Gaussian::from_ms(20.794779, 7.194481), epsilon = 1e-6);
}

#[test]
fn test_env_ttt_matches_old_golden() {
    let mut h = History::<i64, _, _, &'static str>::builder()
        .mu(25.0)
        .sigma(25.0 / 3.0)
        .beta(25.0 / 6.0)
        .drift(ConstantDrift(25.0 / 300.0))
        .convergence(ConvergenceOptions { max_iter: 30, epsilon: 1e-6 })
        .build();

    let events: Vec<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("a")]),
            Team::with_members([Member::new("c")]),
        ], outcome: Outcome::winner(1, 2) },
        Event { time: 3, teams: smallvec![
            Team::with_members([Member::new("b")]),
            Team::with_members([Member::new("c")]),
        ], outcome: Outcome::winner(0, 2) },
    ];
    h.add_events(events).unwrap();
    h.converge().unwrap();

    let a_curve = h.learning_curve(&"a");
    let b_curve = h.learning_curve(&"b");
    assert_ulps_eq!(a_curve[0].1, Gaussian::from_ms(25.000267, 5.419381), epsilon = 1e-6);
    assert_ulps_eq!(b_curve[0].1, Gaussian::from_ms(24.999465, 5.419425), epsilon = 1e-6);
}

// Repeat for test_teams, test_add_events, test_only_add_events, test_log_evidence,
// test_add_events_with_time, test_1vs1_weighted from the old history.rs tests,
// translating construction while keeping every golden value identical.
```

Port every one of the seven original history tests plus the seven game tests. Any test that can't be translated because the new API doesn't expose the needed internals — if its golden is preserved by a translated test, delete it; if not, either add a `pub(crate)` accessor for the test and flag in the spec notes.

- [ ] **Step 5: Delete legacy methods**

In `src/history.rs`, delete:
- `pub fn convergence(iters, eps, verbose) -> ((f64, f64), usize)` — replaced by `converge()`.
- The `online: bool` builder accessor, if unused externally (leave `self.online` field internal for now — it's used by `log_evidence_impl`).
- The nested-Vec public `add_events` wrapper. Only `pub(crate) fn add_events_with_prior` stays (no longer `pub`).
- `HistoryBuilder::gamma(f64)` — now users go through `.drift(ConstantDrift(g))`.

- [ ] **Step 6: Build + test**

```bash
cargo test --features approx
cargo clippy --all-targets --features approx -- -D warnings
```

All tests pass — failures here mean either a translation bug or a legitimate numeric difference. The goldens have been proven stable through T0 and T1, so any drift is translation.

- [ ] **Step 7: Commit**

```bash
cargo +nightly fmt
git add -A
git commit -m "$(cat <<'EOF'
test(api): translate full test suite to new API; delete legacy methods

Every old golden is reproduced in the new API (tests/equivalence.rs
plus in-module tests). History::convergence, the nested-Vec add_events,
.gamma(f64), .time(bool) all removed.

Part of T2.
EOF
)"
```

---

## Task 21: Final verification, benchmark capture, update CHANGELOG

**Files:**
- Modify: `benches/baseline.txt`
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Full green check**

```bash
cargo +nightly fmt --check
cargo clippy --all-targets --features approx -- -D warnings
cargo test --features approx
cargo test --features approx --doc 2>&1 | tail -5
```

All must pass.

- [ ] **Step 2: Capture T2 benchmarks**

```bash
cargo bench --bench batch 2>&1 | grep "Batch::iteration"
cargo bench --bench gaussian 2>&1 | grep "Gaussian::"
```

Compare to T1 baseline (~23 µs). Must be within 5% (23.5 µs ceiling). T2 is an API refactor; hot path should be unchanged.

- [ ] **Step 3: Append T2 block to `benches/baseline.txt`**

```
# After T2 (date, same hardware)

Batch::iteration           <X.XX> µs   (vs T1 23.010 µs: <delta>)
Gaussian::add             <X.XX> ps    (unchanged)
Gaussian::sub             <X.XX> ps    (unchanged)
Gaussian::mul             <X.XX> ps    (unchanged)
Gaussian::div             <X.XX> ps    (unchanged)
Gaussian::pi              <X.XX> ps    (unchanged)
Gaussian::tau             <X.XX> ps    (unchanged)

# Notes:
# - API-only tier; hot inference path unchanged so numerics match T1 within ULPs.
# - Public surface now matches spec Section 4.
# - Breaking changes: Batch→TimeSlice, Player→Rating, Agent→Competitor,
#   IndexMap→KeyTable; Event<T, K>/Team<K>/Member<K>/Outcome new types;
#   Time trait; Drift<T> generic; Observer + ConvergenceReport; Result<_,
#   InferenceError> at API boundary; factors module promoted to pub.
```

- [ ] **Step 4: Add CHANGELOG entry**

Read `CHANGELOG.md` (check its existing format first with `head -20 CHANGELOG.md`) and prepend a `## [Unreleased]` section listing the breaking changes bulleted from the spec.

- [ ] **Step 5: Commit**

```bash
git add benches/baseline.txt CHANGELOG.md
git commit -m "$(cat <<'EOF'
bench,docs: capture T2 final numbers and update CHANGELOG

Batch::iteration: <X.XX> µs (T1 was 23.010 µs).
API-only tier; numerics within ULPs.

Closes T2 of docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md.
EOF
)"
```

- [ ] **Step 6: Ready for review**

The branch `t2-new-api-surface` is complete. Full test suite green, clippy clean, fmt clean, bench within target. Open the PR with:

- Summary linking to spec + plan.
- Breaking-change table (spec Section 4½).
- Benchmark comparison.
- Migration notes for known downstream (there are none — crate is pre-1.0).

---

## Self-review notes

**Spec coverage** (against `docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md` Section 7 T2 checklist):

- ✅ `Rating`, `TimeSlice`, `Competitor`, `Member<K>`, `Outcome`, `Event<T, K>`, `KeyTable<K>` (Tasks 2, 3, 4, 5, 9, 10)
- ✅ `Time` trait, `History<T: Time, D: Drift<T>>` (Tasks 6, 7, 8)
- ✅ Three-tier API: `record_winner`, `event(...).team(...).commit()`, bulk `add_events(iter)` (Tasks 14, 15, 16)
- ✅ `Observer` trait + `ConvergenceReport`; `verbose: bool` deleted (Tasks 11, 12, 20)
- ✅ `panic!`/`debug_assert!` at API boundary → `Result<_, InferenceError>` (Task 13)
- ✅ `Factor`/`Schedule`/`VarStore` promoted to `pub` under `factors` module (Task 18)
- ✅ `Game::ranked`, `one_v_one`, `free_for_all`, `custom` (Task 19)
- ✅ Equivalence tests prove identical posteriors (Task 20)
- ✅ `intern` / `lookup` for `Index` exposure (Task 14)

**Deferred to later tiers (explicitly):**
- `Outcome::Scored` + `MarginFactor` — T4 (enum is `#[non_exhaustive]` so adding is non-breaking).
- `Damped`, `Residual` schedules — T4.
- `Send + Sync` bounds — T3.
- `predict_outcome` N-team — T4.
- `Game::custom` full ergonomics — T4.

**Known hazards during execution:**
- **Generic parameter explosion.** After Task 8 + 12 + 14, `History<T, D, O, K>` has four type params. Use trait-object erasure internally if ergonomics suffer. Default types (`i64`, `ConstantDrift`, `NullObserver`) keep common callsites readable.
- **`.player` → `.rating` field rename.** `grep -r '\.player'` also matches unrelated names. Audit manually inside `src/` only; **don't auto-replace across tests or dev deps.**
- **Outcome → legacy result translation.** The legacy `result` f64 convention is "higher is worse" for descending sort. Our new `Outcome::Ranked` stores ranks where 0 = best. Mapping: `max_rank - rank[i]`. If a test's golden flips winner/loser, this is the likely cause.
- **`Time = Untimed` behavior change.** Per spec, `Untimed::elapsed_to` returns 0 — no drift between slices. The old `time=false` mode implicitly used elapsed=1. Tests that used `time(false)` translate to `History::<i64, _>` with explicit `1..=n` timestamps to preserve numerics.
- **Test module translation is mechanical but big.** Expect Task 20 to be the longest task (~60–90 minutes). Work through the test files one at a time; commit after each file to keep bisectable history.

**Things outside the plan that may bite:**
- `Cargo.toml` dev-dep `trueskill-tt = { path = ".", features = ["approx"] }` — if this references removed public names (`Batch`, `Player`), add the old name temporarily as a `type Batch = TimeSlice;` alias during the rename cascade, then remove before Task 21.
- `examples/` directory. Confirm no examples exist (`ls examples/` showed nothing at plan-write time), but re-check before Task 20 and include any in the translation pass.
- README.md probably shows the old API. Update in Task 21 alongside the CHANGELOG.
