# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands ```bash just test # Full suite across every feature combination CI checks just check # Fast inner loop: cargo test --features approx just lint # clippy, warnings denied just fmt # ALWAYS nightly — rustfmt.toml uses nightly-only options just determinism # Bit-identical posteriors at RAYON_NUM_THREADS 1/2/4/8 just ci # Everything CI runs cargo test --lib # A single test by name cargo bench # Criterion benchmarks ``` **Run tests in release too.** `debug_assert!` is compiled out there, and that is where several defects have hidden — a debug-only run is not evidence. `just test` includes a release job. ### Feature flags - `approx` — `approx::AbsDiffEq` etc. for `Gaussian`. Most numerical goldens need it. - `rayon` — opt-in parallel within-slice sweep and per-slice query passes. ## Architecture A Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py): Bayesian skill rating that infers skill at every point in time, propagating evidence both forward and backward across a history. ### Data flow Ingestion (public types, `event.rs`): ``` Event → Team[] → Member[] ``` `History::add_events` flattens that into indices; teams survive only as grouping, not as a value. Inference then runs on the internal shapes: ``` History → TimeSlice[] → Event[] → Item[] ↓ Game (factor graph) → Schedule → BuiltinFactor[] ``` - **`History`** (`history.rs`) — top level. Interns keys, groups events into `TimeSlice`s by time, runs the forward/backward sweep in `converge()`, and answers `learning_curves()`, `current_skill()`, `log_evidence()`, `predict_quality()`, `predict_outcome()`. Built via `HistoryBuilder`. - **`TimeSlice`** (`time_slice.rs`) — all events at one time. Owns a `SkillStore` and a `ScratchArena`; `iteration()` sweeps its events, using `ColorGroups` to partition independent ones. - **`Event`** — two distinct types, do not confuse them. The *public* ingestion `Event` is in `event.rs` (with `Team`/`Member`); the *internal* `pub(crate) Event` in `time_slice.rs` is one match during inference, where `compute()` runs inference reading skills immutably and `apply()` folds the result back. That split is what lets a color group run in parallel with no `unsafe`. - **`Game`** (`game.rs`) — a single match's factor graph. `run_chain` builds the diff chain between rank-adjacent teams and drives it to convergence. - **`Gaussian`** (`gaussian.rs`) — natural parameters (`pi = 1/sigma²`, `tau = mu/sigma²`). `Mul`/`Div` are the EP product/cavity: pure adds and subtracts. Variance-space ops (`Add`, `Sub`, `exclude`, `forget`) go through `from_mv`/`variance()` and take no square root. - **`factor/`** — `TeamSumFactor`, `RankDiffFactor`, `TruncFactor` (ranked), `MarginFactor` (scored), over a flat `VarStore`. `BuiltinFactor` dispatches by enum rather than `dyn`. - **`Schedule`** (`schedule.rs`) — drives factor propagation. `EpsilonOrMax` is the only implementation. - **`Competitor`** (`competitor.rs`) — per-history temporal state (`message`, `last_time`). **`Rating`** (`rating.rs`) — static config (prior, `beta`, drift). - **`storage/`** — `SkillStore` (per slice, `pub(crate)`) and `CompetitorStore` (per history, public), both indexed by `Index`. The module is `pub`, but only `CompetitorStore` is reachable from outside the crate. - **`KeyTable`** (`key_table.rs`) — user key ↔ `Index`, both directions O(1). - **`Drift`** (`drift.rs`) / **`Time`** (`time.rs`) — traits. `Time` is a *trait* (`i64`, `Untimed`), not an enum. - **`lib.rs`** — public exports, global defaults (`MU`, `SIGMA`, `BETA`, `GAMMA`, `P_DRAW`, `EPSILON`, `ITERATIONS`), and the standalone `quality()`. The `cdf()` / `erfc()` helpers live here too but are `pub(crate)` and private respectively — not public API. ### Invariants worth knowing - **A tie needs `p_draw > 0`.** With `p_draw == 0.0` the truncation margin is zero and the two-sided tie update evaluates `0/0`. Ingestion rejects such events with `InferenceError::TieWithoutDrawProbability`. This includes `Outcome::winner(w, n)` for `n >= 3`, which ties every loser. - **NaN is never convergence.** Comparisons against NaN are all false, so `tuple_gt` reads NaN as "below epsilon". Use `step_converged` / `step_is_finite`, never `!tuple_gt(..)` alone. - **Evidence accumulates in log space.** A linear product over a long diff chain underflows to zero, and `ln(0)` is `-inf`. - **Colors are contiguous.** `recompute_color_groups` reorders events so each color occupies one range; `ColorGroups::groups_are_contiguous` asserts it. - **The crate is `#![forbid(unsafe_code)]`.** Keep it that way. - **Ingestion order must not change the answer.** Events added one at a time must converge to the same fixed point as the same events batched — see `tests/ingestion_equivalence.rs`. ### Testing notes - Numerical goldens are cross-validated against the Python/Julia reference. Some are *convergence residuals*, not exact values; treat a small movement as suspicious but check whether the new value is closer to the analytic truth (symmetric fixtures converge to their prior mean exactly) before assuming a regression. - `tests/degenerate_inputs.rs` covers empty/boundary/error paths, `tests/ingestion_equivalence.rs` covers batching order, `tests/quality.rs` covers N-group quality, `tests/determinism.rs` covers thread counts.