# TrueSkill-TT Engine Redesign — Design

**Date:** 2026-04-23
**Status:** Approved (pending implementation plan)

## Summary

Comprehensive redesign of the TrueSkill-TT engine targeting four orthogonal goals:

1. **Performance** — substantially faster offline convergence and incremental online updates.
2. **Accuracy and richer match formats** — support for score margins, free-for-all with partial orders, correlated skills.
3. **Better convergence** — replace ad-hoc capped iteration with a pluggable `Schedule` trait covering all three nested loops.
4. **Better API surface** — typed event description, observer-based progress reporting, generic time axis, structured errors, ergonomic builders.

The design is comprehensive (Approach 1 of three considered) but delivered in five tiers so each step is independently shippable and validated by benchmarks.

## Goals & non-goals

**Goals**

- 10–30× speedup on the offline convergence path for representative workloads (1000+ players, 1000+ events, 30 iterations)
- Order-of-magnitude speedup on incremental "add a single event" workloads
- Pluggable factor graph allowing new factor types without engine changes
- Optional Rayon-backed parallelism on top of `Send + Sync`-correct internals
- Typed, ergonomic public API; replace nested `Vec<Vec<Vec<_>>>` shapes with `Event<T, K>` / `Team<K>` / `Member<K>`
- Generic time axis: `Untimed`, `i64`, or user-supplied
- Observer-based progress instead of `verbose: bool` + `println!`
- Structured `Result<_, InferenceError>` at API boundaries

**Non-goals**

- WebAssembly support is not a goal; we may break it if a crate or feature requires.
- No GPU offload.
- No `no_std` support.
- No persistent format / serde — possible future feature.
- No replacement of the Gaussian/EP approximation itself in this design (the underlying inference math stays the same; we change layout, dispatch, scheduling, and API around it).

## Workload assumptions

Baseline workload that drives perf decisions:

- ~1000+ players
- ~1000+ events total
- ~50–60 events per time slice (per day)
- Both online (incremental adds) and offline (full convergence) are common
- Offline convergence runs frequently

## Section 1 — Core types & traits

The foundation everything else builds on.

### `Gaussian` — natural-parameter storage

Switch storage from `(mu, sigma)` to natural parameters `(pi, tau)` where `pi = sigma⁻²`, `tau = mu · pi`. Multiplication and division dominate the hot path; in nat-params they are direct adds/subs of the components, no `sqrt`. Reads of `mu`/`sigma` become accessor methods (`tau / pi`, `1.0 / pi.sqrt()`). The trade is correct because reads are vanishingly rare compared to writes in EP.

```rust
pub struct Gaussian { pi: f64, tau: f64 }
pub const UNIFORM: Gaussian = Gaussian { pi: 0.0, tau: 0.0 }; // replaces N_INF
```

### `Time` trait

Replaces the bare `i64` time field. Keeps `History` parametric.

```rust
pub trait Time: Copy + Ord + Send + Sync + 'static {
    fn elapsed_to(&self, later: &Self) -> i64;
}
pub struct Untimed; // ZST for the no-time-axis case
impl Time for Untimed { fn elapsed_to(&self, _: &Self) -> i64 { 0 } }
impl Time for i64 { fn elapsed_to(&self, later: &Self) -> i64 { later - self } }
// Optional impls behind feature flags: time::OffsetDateTime, chrono types
```

### `Drift<T>` trait

Generic over `T: Time` so seasonal/calendar-aware drift is possible without going through `i64`.

```rust
pub trait Drift<T: Time>: Copy + Send + Sync {
    fn variance_delta(&self, from: &T, to: &T) -> f64;
}
```

`ConstantDrift(f64)` impl: `to.elapsed_to(from) as f64 * gamma * gamma`.

### `Index` and `KeyTable<K>`

`Index(usize)` is the handle into dense per-`History` `Vec` storage. Public, but intended for use by power users on hot paths who want to skip the `KeyTable` lookup. Casual API takes `&K`. `KeyTable<K>` (renamed from `IndexMap`, to avoid colliding with the `indexmap` crate's type) maps user keys → `Index`.

### `Observer` trait

Replaces `verbose: bool` + `println!`. Default no-op impls; user overrides what they need.

```rust
pub trait Observer<T: Time>: Send + Sync {
    fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {}
    fn on_batch_processed(&self, _time: &T, _idx: usize, _n_events: usize) {}
    fn on_converged(&self, _iters: usize, _final_step: (f64, f64)) {}
}
pub struct NullObserver;
impl<T: Time> Observer<T> for NullObserver {}
```

### Trade-offs

- `Gaussian` natural-param representation: anyone reading `mu`/`sigma` in a hot loop pays a sqrt — but that's correct, hot reads are rare.
- `Time` as a trait (not enum) keeps it open-ended at zero runtime cost; default `History<i64, _>` keeps the call sites familiar.
- `Observer` is a trait (not a closure) so different sites can have different signatures without losing type safety. `NullObserver` is a ZST.

## Section 2 — Factor graph architecture

The current `Game::likelihoods` is a hand-rolled, hard-coded graph. To unlock richer formats and let us experiment with EP schedules, the graph itself becomes a data structure.

### Variable / Factor model

Variables hold their current Gaussian marginal. Factors hold their outgoing messages to each connected variable plus do the local computation. Standard EP: factor's update is "divide marginal by old outgoing → cavity → apply local approximation → multiply marginal by new outgoing."

```rust
pub trait Factor: Send + Sync {
    fn variables(&self) -> &[VarId];
    fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64); // returns max delta
    fn log_evidence(&self, _vars: &VarStore) -> f64 { 0.0 }
}
```

### Built-in factor catalog

| Factor | Purpose | Status |
|---|---|---|
| `PerformanceFactor` | skill → performance (add β² noise, optional weight) | replaces inline `performance() * weight` |
| `TeamSumFactor` | weighted sum of player perfs → team perf | replaces inline `fold` |
| `RankDiffFactor` | (team_a perf) − (team_b perf) → diff var | currently `team[e].posterior_win() − team[e+1].posterior_lose()` |
| `TruncFactor` | EP truncation: `P(diff > margin)` or `P(|diff| < margin)` for draws | wraps current `v_w` / `approx` |
| `MarginFactor` *(future)* | use observed score margin as soft evidence | enables richer match formats |
| `SynergyFactor` *(future)* | couples teammates' skills | enables different topology |
| `ScoreFactor` *(future)* | continuous outcome (e.g., points scored) | enables score-based outcomes |

The first four together exactly reproduce today's algorithm. The last three are extension slots.

### Game = factor graph + schedule

```rust
pub struct Game<S: Schedule = DefaultSchedule> {
    vars: VarStore,            // SoA: Vec<Gaussian> marginals
    factors: FactorList,       // enum dispatch over BuiltinFactor (see Open Questions)
    schedule: S,
}
```

Lean toward **enum dispatch** (`enum BuiltinFactor { Perf(...), Sum(...), RankDiff(...), Trunc(...), ... }`) over `Box<dyn Factor>` for the built-ins:

- avoids per-message vtable overhead in the hottest loop
- keeps factor data inline (no heap indirection)
- still allows user-defined factors via a `BuiltinFactor::Custom(Box<dyn Factor>)` variant

### Schedule trait

Controls iteration order and stopping. Default = current behavior (sweep forward, then backward, until ε or max iters). Pluggable so we can later try damped EP or junction-tree schedules.

### High-level constructors

```rust
Game::ranked(teams, results, options)    // dominant case
Game::free_for_all(players, ranking)     // FFA with possible ties
Game::custom(builder)                    // power users build their own graph
```

`GameOptions` carries iteration cap, epsilon, p_draw, and approximation choice. Today these are scattered between method args and module constants.

### Trade-offs

- Enum dispatch over trait objects for built-ins; richer factors drop in via new enum variants.
- Variables and factor messages stored as `Vec<Gaussian>` indexed by `VarId` / edge slot — flat, cache-friendly.
- `Schedule` is a generic parameter (zero-cost); most users get default; experimentation is open.

### Open question

Whether `enum BuiltinFactor` will feel too closed-world. The `Custom(Box<dyn Factor>)` escape hatch helps but inner-loop perf for user factors will be slower. Acceptable for now; flagged for future revisit if it becomes a problem.

## Section 3 — Storage layout (SoA + arenas)

### Dense Vec keyed by `Index`

Every `HashMap<Index, T>` becomes a `Vec<T>` (or `Vec<Option<T>>` for sparse) indexed directly by `Index.0`. The public-facing `KeyTable<K>` continues to map arbitrary keys → `Index`.

### SoA at hot layers, AoS at boundaries

The `Skill` struct stays as a public type for the API (returned from `learning_curves`, etc.), but inside `TimeSlice` we lay it out column-wise:

```rust
struct TimeSliceSkills {
    forward:    Vec<Gaussian>,   // [n_agents]
    backward:   Vec<Gaussian>,
    likelihood: Vec<Gaussian>,
    online:     Vec<Gaussian>,
    elapsed:    Vec<i64>,
    present:    Vec<bool>,
}
```

Within a slice, the inner loops touch one column repeatedly across many events — keeping the column contiguous improves cache utilization and makes the eventual SIMD step (Section 6) straightforward.

`Gaussian` itself stays as a single 16-byte struct in the `Vec<Gaussian>`. Splitting into two parallel `Vec<f64>`s wins for pure SIMD over thousands of Gaussians but loses for the random-access patterns dominant in EP. Revisit if benchmarks demand it.

### Arena allocator inside `Game`

Replace per-event allocations with a `ScratchArena` reused across calls.

```rust
pub struct ScratchArena {
    var_buf:     Vec<Gaussian>,
    factor_buf:  Vec<Gaussian>,    // edge messages
    bool_buf:    Vec<bool>,
    f64_buf:     Vec<f64>,
}
impl ScratchArena {
    fn reset(&mut self);                    // sets len=0, keeps capacity
    fn alloc_vars(&mut self, n: usize) -> &mut [Gaussian];
}
```

`TimeSlice` owns one `ScratchArena`; each event borrows it for the duration of its `Game` construction and inference. For the parallel-slice story (Section 6), each Rayon task gets its own arena.

### Per-event storage layout

Inside a `TimeSlice`, each event is stored column-wise as well, with `Item` inlined into team-level parallel arrays:

```rust
struct EventStorage {
    teams:   SmallVec<[TeamStorage; 4]>,
    outcome: Outcome,
    weights: SmallVec<[SmallVec<[f64; 4]>; 4]>,
    evidence: f64,
}
struct TeamStorage {
    competitors:   SmallVec<[Index; 4]>,    // who's on the team
    edge_messages: SmallVec<[Gaussian; 4]>, // outgoing message per slot
    output:        f64,
}
```

Iteration over `(competitor, edge_message)` pairs zips two slices — no per-element struct.

### SmallVec for typical shapes

Teams ≤ ~5 players, games ≤ ~8 teams. `SmallVec<[T; 8]>` for team membership and `SmallVec<[T; 4]>` for team rosters keeps the common case allocation-free.

### Trade-offs

- Dense `Vec<T>` keyed by `Index` is faster but means agent removal needs tombstones (or just leaves slots present-but-inactive). Acceptable: TrueSkill histories rarely remove players.
- SoA at `TimeSlice` level only, not at `History` level. `History` keeps `Vec<TimeSlice>` because slices are heterogeneous in size.
- One `ScratchArena` per `TimeSlice` keeps the lifetime story simple.

### Open question

The `TimeSliceSkills` sketch above uses (b) **dense + present mask**: one slot per agent in the history, indexed directly by `Index`, with a `present: Vec<bool>` mask for batches the agent didn't participate in. The alternative is (a) **sparse columnar**: a `Vec<Index>` of present agents and parallel `Vec<Gaussian>` columns of length `n_present`, with a separate lookup (binary search or auxiliary table) to find a given `Index`'s slot.

(b) gives O(1) lookup and SIMD-friendly columns but wastes memory for sparsely populated slices. (a) is leaner per-slice but pays per-lookup cost in the inner loop. Bench both during T0 and pick. Default proposal: (b), since modern systems are memory-rich and the parallelism story is cleaner.

## Section 4 — API surface

### Typed event description

```rust
pub struct Event<T: Time, K> {
    pub time: T,
    pub teams: SmallVec<[Team<K>; 4]>,
    pub outcome: Outcome,
}

pub struct Team<K> {
    pub members: SmallVec<[Member<K>; 4]>,
}

pub struct Member<K> {
    pub key: K,
    pub weight: f64,                 // default 1.0
    pub prior: Option<Rating>,       // per-event override
}

pub enum Outcome {
    Ranked(SmallVec<[u32; 4]>),  // rank per team; equal ranks = tie
    Scored(SmallVec<[f64; 4]>),  // continuous score per team (engages MarginFactor)
}
```

`Outcome::winner(0)`, `Outcome::draw()`, `Outcome::ranking([0,1,2])` are convenience constructors.

### Builders

```rust
let mut history = History::<i64, _>::builder()
    .mu(25.0).sigma(25.0/3.0).beta(25.0/6.0)
    .drift(ConstantDrift(0.03))
    .p_draw(0.10)
    .convergence(ConvergenceOptions { max_iter: 30, epsilon: 1e-6 })
    .observer(LogObserver::default())
    .build();
```

For the no-time case, type inference picks `Untimed`:

```rust
let mut history = History::<Untimed, _>::builder().build();
```

### Three-tier event ingestion

```rust
// 1. Bulk ingestion (high-throughput path)
history.add_events(events_iter)?;

// 2. One-off match (very common in practice)
history.record_winner("alice", "bob", time)?;
history.record_draw("alice", "bob", time)?;

// 3. Builder for irregular shapes
history.event(time)
    .team(["alice", "bob"]).weights([1.0, 0.7])
    .team(["carol"])
    .ranking([1, 0])
    .commit()?;
```

### Convergence & queries

```rust
let report: ConvergenceReport = history.converge()?;

let curve: Vec<(i64, Gaussian)> = history.learning_curve(&"alice");
let all = history.learning_curves();           // HashMap<&K, Vec<(T, Gaussian)>>
let now = history.current_skill(&"alice");     // Option<Gaussian>

let ev = history.log_evidence();
let ev_for = history.log_evidence_for(&["alice", "bob"]);

let q = history.predict_quality(&[&["alice"], &["bob"]]);
let p_win = history.predict_outcome(&[&["alice"], &["bob"]]);
```

### Standalone Game

```rust
let g = Game::ranked(&[&[alice], &[bob]], Outcome::winner(0), &options);
let post = g.posteriors();

// Convenience
let (a, b) = Game::one_v_one(&alice, &bob, Outcome::winner(0));
```

### Errors

Replace `debug_assert!`/`panic!` at the API boundary with `Result`.

```rust
pub enum InferenceError {
    MismatchedShape { kind: &'static str, expected: usize, got: usize },
    InvalidProbability { value: f64 },
    ConvergenceFailed { last_step: (f64, f64), iterations: usize },
    NegativePrecision { pi: f64 },
}
```

Hot inner loops still use `debug_assert!` for invariants the API has already enforced.

### Trade-offs

- Generic over user's `K`; engine works in `Index`. Public outputs use `&K`.
- `SmallVec` everywhere on the event-description path.
- Three-tier API so casual users don't drown in types and bulk users still get throughput.
- `Outcome` enum replaces the "lower number wins" `&[f64]` convention.

### Open question

Whether to expose `Index` directly to users via an `intern_key(&K) -> Index` method, letting hot-path callers skip the `KeyTable` lookup on every call. Recommendation: yes — public `Index` handle plus `history.lookup<Q: Borrow<K>>(&Q) -> Option<Index>`. The casual API still takes `&K` everywhere; power users can promote to `Index` when profiling demands.

## Section 4½ — Naming pass

| Current | New | Rationale |
|---|---|---|
| `History` | `History` (kept) | Matches upstream; reads cleanly. |
| `Batch` | `TimeSlice` | Says what it is: every event sharing one timestamp. |
| `Player` | `Rating` | The struct holds prior/beta/drift — that's a rating configuration. Resolves the `Player`/`Agent` confusion. |
| `Agent` | `Competitor` | Holds dynamic state for someone competing in the history; fits the domain. |
| `Skill` | `Skill` (kept) | Per-time-slice skill estimate; clearer than `BatchSkill`. |
| `Item` | inlined into `TeamStorage` columns (engine) / `Member<K>` (public) | Eliminates the per-element struct in the hot path; gives API users a clear "team member" name. |
| `Game` | `Game` (kept) | `Match` collides with Rust's `match`. |
| `Index` | `Index` (kept) | Internal handle. |
| `IndexMap` | `KeyTable` | Avoids confusion with the `indexmap` crate. |

## Section 5 — Convergence & message scheduling

### Three nested loops, one mechanism

The system has three nested convergence loops:

1. Within-game: EP sweeps over the factor graph
2. Within-time-slice: re-running games as inputs change
3. Cross-history: forward-pass then backward-pass over all slices

All three implement `Workload`; one `Schedule` impl drives all of them.

```rust
pub trait Schedule {
    fn run<W: Workload>(&self, workload: &mut W) -> ScheduleReport;
}

pub trait Workload {
    fn step(&mut self) -> (f64, f64);
    fn snapshot_evidence(&self) -> f64 { 0.0 }
}

pub struct ScheduleReport {
    pub iterations: usize,
    pub final_step: (f64, f64),
    pub converged: bool,
}
```

### Built-in schedules

| Schedule | Behavior | Use |
|---|---|---|
| `EpsilonOrMax { eps, max }` | Default. Sweep until `(dpi, dtau) ≤ eps` or `max` iters. | All three loops. Replicates current behavior. |
| `Damped { eps, max, alpha }` | Same, but writes `α·new + (1−α)·old`. | Stuck oscillations. |
| `Residual { eps, max }` | Priority-queue: re-update factor with largest pending delta first. | Faster convergence on uneven graphs. |
| `OneShot` | Exactly one pass, no convergence check. | Online incremental adds. |

### Stopping in natural-param space

Switch from `(|Δmu|, |Δsigma|) ≤ epsilon` to `(|Δpi|, |Δtau|) ≤ (eps_pi, eps_tau)`:

- `mu` and `sigma` are on different scales; one tolerance is wrong for both
- We store in nat-params anyway — checking convergence in mu/sigma costs free sqrts
- Nat-param delta is the natural geometry of the EP fixed point

Default `EpsilonOrMax::default()` exposes a single `epsilon` for simplicity; advanced ctor exposes both tolerances.

### Within-game improvements

- Replace hard-cap of 10 iterations with `GameOptions::schedule` that propagates `ScheduleReport` upward
- Fast path: graphs with no diff chain (1v1 with 1 iter sufficient) skip the loop entirely
- FFA / many-team ranks benefit from `Residual`; opt-in

### Within-slice and cross-history improvements

- **No more old/new HashMap snapshotting**: track deltas inline as we write under SoA
- **Per-slice dirty bits**: a `TimeSlice` whose neighbor messages haven't changed since its last full sweep doesn't need to re-run. Track `time_slice.dirty` and skip clean ones during the cross-history sweep. Big win for online-add (the locality case).

### `ConvergenceReport`

```rust
pub struct ConvergenceReport {
    pub iterations: usize,
    pub final_step: (f64, f64),
    pub log_evidence: f64,
    pub converged: bool,
    pub per_iteration_time: SmallVec<[Duration; 32]>,
    pub batches_skipped: usize,
}
```

`Observer` continues to receive per-iteration callbacks for live UI; `ConvergenceReport` is the post-hoc summary.

### Trade-offs

- One `Schedule` trait shared across loops — fewer concepts, more composable.
- Convergence checks in nat-param space — slightly different exact threshold than today; tests' epsilons re-tuned mechanically.
- Dirty-bit skipping changes iteration order vs. today; fixed point is the same, iteration counts may shift downward.
- `Residual` and `Damped` are opt-in; default behavior matches today closely.

### Open question

Whether `Schedule::run` should take an optional `Observer` reference. Recommendation: observation lives at a higher layer (`History::converge` calls observer hooks; `Schedule` is purely the loop driver).

## Section 6 — Concurrency & parallelism

### What's parallelizable

| Operation | Parallelism | Strategy |
|---|---|---|
| `History::converge()` (full forward+backward) | Sequential across slices | Within each slice: color-group events in parallel via Rayon |
| `History::add_events(...)` | Sequential append, but ingestion of typed events into `EventStorage` parallelizes trivially | n/a |
| `History::learning_curves()` | Per-key parallel | `into_par_iter()` |
| `History::log_evidence_for(targets)` | Per-batch parallel, reduce sum | `par_iter().map(...).sum()` |
| `Game` inference | Sequential | n/a (too small to amortize Rayon overhead) |

### Within-slice color-group parallelism

When events are added to a slice, partition them into color groups where events in the same color touch no shared `Index`. Within a color, run events in parallel via Rayon. Across colors, run sequentially. Preserves asynchronous-EP semantics exactly.

Alternative: synchronous EP with snapshot. All events read from a frozen skill snapshot, write deltas to thread-local buffers, barrier merges. Trivially parallel but weaker per-iteration convergence — needs damping. Available as a `Schedule` impl, opt-in.

### `Send + Sync` requirements

All public traits (`Time`, `Drift`, `Observer`, `Factor`, `Schedule`) require `Send + Sync`. `Observer` impls must be thread-safe (called from arbitrary worker threads).

### Rayon as default-on feature

`rayon` as default-on feature; with `default-features = false`, parallel paths fall back to sequential iterators behind `cfg(feature = "rayon")`.

### Expected speedup ballpark

For 1000 players, 60 events/slice × 1000 slices, 30 convergence iterations:

| Source | Estimated speedup vs. today |
|---|---|
| `HashMap` → dense `Vec` | 2–4× |
| Natural-param `Gaussian`, no-sqrt mul/div | 1.5–2× |
| Pre-allocated `ScratchArena` | 1.2–1.5× |
| Color-group parallel events in slice (8 cores) | 2–4× |
| Dirty-bit slice skipping (online add case) | 5–50× |
| **Combined (offline converge)** | ~10–30× |
| **Combined (online add)** | ~50–500× depending on locality |

These are pre-implementation estimates. Each tier validates with criterion.

### Trade-offs

- Color-group parallelism requires up-front graph coloring at ingestion. Cost: linear in events, run once per `add_events`. Cheap.
- Default = asynchronous EP (preserves current semantics). Synchronous opt-in only.
- Cross-slice sweep stays sequential; no speculative parallel sweeps.
- Rayon default-on but feature-gated.

### Open question

Whether to expose color-group partitioning to users. Recommendation: hidden by default, escape hatch via `add_events_with_partition(...)` for power users who already know their event independence.

## Section 7 — Migration, testing, and delivery plan

The crate is unreleased, so version-bump ceremony doesn't apply. Tiers are sequencing of work and milestones, not releases.

### Tier sequence

**T0 — Numerical parity (no API change)**

Internal-only. Public surface unchanged.

- Switch `Gaussian` storage to natural parameters `(pi, tau)`. `mu()`/`sigma()` become accessors.
- Replace `HashMap<Index, _>` with dense `Vec<_>` keyed by `Index.0` everywhere.
- Introduce `ScratchArena` inside `Batch` so `Game::new` stops allocating per-event.
- Drop the `panic!` in `mu_sigma`; return `Result` propagated upward.

**Acceptance:** existing test suite passes (bit-equal where possible, ULP-bounded where natural-param arithmetic shifts a rounding); `cargo bench` shows ≥3× win on `batch` benchmark; no API breakage.

**T1 — Factor graph machinery (internal-only)**

- Introduce `Factor`, `VarStore`, `Schedule` as `pub(crate)` types.
- Re-implement `Game::likelihoods()` on top of `BuiltinFactor::{Perf, TeamSum, RankDiff, Trunc}` driven by `EpsilonOrMax`.
- Replace within-game iteration tracking with `ScheduleReport`.

**Acceptance:** existing test suite passes (ULP-bounded); within-game iteration counts unchanged; benchmarks ≥ T0.

**T2 — New API surface (breaking)**

All renames and the new public API land together. No half-renamed intermediate state.

- New types: `Rating`, `TimeSlice`, `Competitor`, `Member<K>`, `Outcome`, `Event<T, K>`, `KeyTable<K>`.
- `Time` trait introduced; `History<T: Time, D: Drift<T>>` is generic.
- Three-tier API surface: `record_winner`, `event(...).team(...).commit()`, bulk `add_events(iter)`.
- `Observer` trait + `ConvergenceReport`; `verbose: bool` deleted.
- `panic!`/`debug_assert!` at API boundary become `Result<_, InferenceError>`.
- Promote `Factor`/`Schedule`/`VarStore` to `pub` under a `factors` module.

**Acceptance:** full test suite rewritten in new API; equivalence tests prove identical posteriors vs. old API on the same inputs.

**T3 — Concurrency**

- `Send + Sync` audit and bounds on all public traits.
- Color-group partitioning at `TimeSlice` ingestion.
- `rayon` as default-on feature with `#[cfg(feature = "rayon")]` fallback.
- Parallel paths: within-slice color groups, `learning_curves`, `log_evidence_for`.

**Acceptance:** deterministic posteriors across `RAYON_NUM_THREADS={1,2,4,8}`; benchmarks show >2× on 8-core for offline converge.

**T4 — Richer factor types & schedules**

Each shipped independently after T3.

- `MarginFactor` → enables `Outcome::Scored`. **Done** (see `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`).
- `Damped` and `Residual` schedules.
- `SynergyFactor`, `ScoreFactor` → same pattern when wanted.

Each comes with its own benchmark and a worked example in `examples/`.

### Testing strategy

| Layer | Approach |
|---|---|
| **Numerical correctness** | Keep existing hardcoded golden values from `test_1vs1`, `test_1vs1_draw`, `test_2vs1vs2_mixed`, etc. through T0–T1 unchanged. They are a regression net against the original Python port. |
| **API parity** | T2 adds an `equivalence` test module that runs identical inputs through old vs. new construction and compares posteriors within ULPs. |
| **Property tests** | Add `proptest` for: factor graph fixed-point invariance under message order, `Outcome` round-trip, `Gaussian` mul/div associativity in nat-params, schedule convergence regardless of starting state. |
| **Determinism** | T3 adds tests that run identical input across multiple Rayon thread counts and assert identical posteriors. |
| **Benchmark gates** | Each tier has a "must not regress" gate vs. the previous tier on the existing `batch` and `gaussian` criterion suites. T0 must beat baseline by ≥3×; T1 ≥ T0; etc. |

### Risk management

- **T0 risk: rounding drift in tests.** Mitigation: where natural-param arithmetic legitimately changes the last ULPs, update goldens *and* simultaneously add a parity test against a snapshot taken from baseline to prove the difference is bounded.
- **T2 risk: API design mistakes.** Mitigation: review the spec and a worked example before implementing; iterate on feedback.
- **T3 risk: subtle race conditions in color-group partitioning.** Mitigation: `loom` tests for the merge step; deterministic-output assertion across thread counts.
- **Cross-tier risk: scope creep.** Each tier has a closed checklist; new ideas go to the next tier's wishlist.

### What we're explicitly *not* doing

- No GPU offload.
- No `no_std` support.
- No serde / persistence in this design.
- No incremental online API beyond `record_winner` / `add_events`.

## Open questions summary

Collected here for the review pass:

1. **`enum BuiltinFactor` extensibility** — may feel too closed-world; revisit if user-defined factors via `Custom(Box<dyn Factor>)` become common.
2. **Sparse vs. dense per-slice skill storage** — default to dense + `present` mask; sparse columnar is the alternative. Decided by T0 benchmarks.
3. **`Index` exposure for hot paths** — expose `intern_key`/`lookup` so power users can promote `&K` to `Index` and skip the `KeyTable` lookup; casual API still takes `&K` everywhere.
4. **`Schedule::run` and observer wiring** — observation stays at higher layer (`History::converge` calls observer hooks; `Schedule` is purely the loop driver).
5. **Color-group partition exposure** — hidden by default, escape hatch via `add_events_with_partition(...)`.
