From 31564b71a084a88171a353b525f0cdb9fccf52c1 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Wed, 9 Sep 2026 21:53:50 +0200 Subject: [PATCH] docs: document the whole public surface and deny(missing_docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 80 undocumented public items, including three that are first contact: `History::current_skill` — the method the crate's own first example calls — `EventBuilder`, the type `h.event(t)` hands you, and `Gaussian::mu()`. Now zero, and `#![deny(missing_docs)]` keeps it that way. Several docs are measurements rather than readings of the code: - `Outcome::Ranked` says ranks are used ordinally, so `[0, 1, 2]` and `[0, 5, 90]` are the same observation. Measured: bit-identical posteriors for both. - `OwnedGame::log_evidence` says two identically-rated competitors give exactly `ln(0.5)`. Written as a doctest, so it runs. - `Member::weight` says zero and negative are accepted. Measured. - `ConvergenceReport::final_step` is `(|Δmu|, |Δsigma|)` in skill units, NOT natural parameters. That one had to be traced through `Gaussian::delta` rather than assumed from the neighbouring vocabulary. - `GameOptions::score_sigma` rejects non-positive and NaN but accepts `+inf`, which is what the guard actually says. README: it is the front door for a crate on a private registry, and it opened with a link dump followed by 130 lines on drift. The first `record_winner → converge → current_skill` block was at line 226 of 307. It now leads with what the crate is, an install line, a quickstart, a "which entry point?" table, and the `converge`-is-strict rationale that was the crate's most opinionated recent decision and went unmentioned. The two canonical examples disagreed on spelling (`History::default()` vs `History::builder().build()`, `current_skill("a")` vs `current_skill(&"a")`); they now agree. Five new README blocks are doctested, taking the suite from 19 to 25. `pub use smallvec;`. Four public items name `SmallVec` in their signatures, and the only `Joint` example failed to compile from a consumer crate with `unresolved import smallvec` — the dependency was in the API but not reachable. Both worked examples now use the re-export, so they teach the path that works downstream. Vocabulary, from #75: "agent" was a fourth word for competitor, 200 occurrences, and it had reached public signatures before #73 un-exported `TimeSlice`. Now zero. Closes #77. Refs #75. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ --- README.md | 174 +++++++++++++++++++++++++++++++++++++------ examples/atp.rs | 5 +- examples/scored.rs | 3 +- src/convergence.rs | 49 ++++++++++++ src/error.rs | 97 ++++++++++++++++++++++-- src/event.rs | 58 +++++++++++++++ src/event_builder.rs | 27 +++++++ src/game.rs | 68 +++++++++++++++++ src/gaussian.rs | 22 ++++++ src/history.rs | 104 ++++++++++++++++++++------ src/lib.rs | 71 ++++++++++++++++++ src/outcome.rs | 25 +++++++ src/time_slice.rs | 28 ++++--- 13 files changed, 664 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 6f75eca..0c4bce5 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,142 @@ # TrueSkill - Through Time -Rust port of [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py). +Bayesian skill rating over a time axis. -## Other implementations +Where plain TrueSkill gives each competitor one running estimate, TrueSkill +Through Time treats a whole history as a single model and infers skill *at every +point in time*. Evidence flows both directions: a result today sharpens the +estimate of who someone was last year, so early estimates stop being frozen +guesses and comparisons across eras become meaningful. -- [ttt-scala](https://github.com/ankurdave/ttt-scala) -- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis) -- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl) -- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R) -- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf) -- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/) +A Rust port of +[TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py). + +## Install + +```toml +[dependencies] +trueskill-tt = "0.8" +``` + +Optional features, both off by default: + +- `approx` — `approx`'s equality traits for `Gaussian`. Useful in tests. +- `rayon` — parallelises the within-slice sweep and the per-slice passes of + `learning_curves` / `log_evidence`. Results stay bit-identical regardless of + worker count; `just determinism` asserts it at 1, 2, 4 and 8 threads. + +## Quickstart + +Record results, converge, then read off skills. + +```rust +use trueskill_tt::History; + +let mut history = History::default(); + +history.record_winner(&"alice", &"bob", 1)?; +history.record_winner(&"bob", &"carol", 2)?; +history.record_winner(&"alice", &"carol", 3)?; + +history.converge()?; + +let alice = history.current_skill("alice").unwrap(); +assert!(alice.mu() > 0.0, "alice won every game she played"); +# Ok::<(), trueskill_tt::InferenceError>(()) +``` + +The third argument is the time. It is what makes this Through Time rather than +plain TrueSkill: skill is inferred at each of those moments, not once at the +end. `learning_curve` reads the whole trajectory back. + +```rust +# use trueskill_tt::History; +# let mut history = History::default(); +# history.record_winner(&"alice", &"bob", 1)?; +# history.record_winner(&"bob", &"carol", 2)?; +# history.record_winner(&"alice", &"carol", 3)?; +# history.converge()?; +// `None` means the key is unknown; `Some(vec![])` means known but unplayed. +let curve = history.learning_curve("alice").unwrap(); +for (time, skill) in &curve { + println!("t={time}: {:.2} ± {:.2}", skill.mu(), skill.sigma()); +} + +// Everyone's latest posterior in one pass — the leaderboard query. +let latest = history.current_skills(); +assert_eq!(latest.len(), 3); +# Ok::<(), trueskill_tt::InferenceError>(()) +``` + +## Teams, rankings and draws + +Anything beyond one-versus-one goes through the fluent event builder. An event +is only recorded by the terminal `.commit()`. + +```rust +use trueskill_tt::History; + +let mut history = History::builder().p_draw(0.1).build(); + +history + .event(1) + .team(["alice", "bob"]) + .team(["carol", "dave"]) + .ranking([0, 1]) // lower is better; equal values are a tie + .commit()?; + +history.converge()?; +# Ok::<(), trueskill_tt::InferenceError>(()) +``` + +**A tie needs a positive `p_draw`.** A `p_draw` of zero asserts draws cannot +happen, so a tied result has no representable likelihood and is rejected rather +than fitted to something else: + +```rust +use trueskill_tt::{History, InferenceError}; + +let mut history = History::default(); // p_draw defaults to 0.0 +let err = history.record_draw(&"alice", &"bob", 1).unwrap_err(); +assert!(matches!(err, InferenceError::TieWithoutDrawProbability { .. })); +``` + +This also catches `Outcome::winner(w, n)` for three or more teams, which ties +every loser. + +## Which entry point? + +| You want to | Use | +|---|---| +| One match, two competitors | `record_winner` / `record_draw` | +| Teams, explicit ranks, scores, per-member weights | `history.event(t)…commit()` | +| A batch you already have as values | `add_events(iter)` | +| Score a hypothetical with no history at all | `Game` | + +`Game` is the odd one out and worth being explicit about: it is a single match's +factor graph, it does not participate in a `History`, and nothing it computes is +remembered. Reach for it to evaluate a matchup in isolation; reach for `History` +for everything that accumulates. + +## `converge` is strict + +`converge` returns `Err(NotConverged)` if the sweep hits `max_iter` with the +step still above `epsilon`, and `Err(NonFiniteResult)` if a sweep produces NaN. + +It used to return `Ok` with `converged: false`, which was the worst available +shape. A fit that stops short is *wrong by a little*: every posterior is finite, +the ordering looks sensible, and nothing about the output says the numbers were +still moving. Detection was opt-in, and `let _ = h.converge()` silently opted +out — which is how a real defect hid in this crate's own test suite. + +The default `max_iter` is high enough that reaching it means something is +genuinely wrong rather than that the history is large; the loop exits at +`epsilon` long before, so raising the cap costs nothing when it is not needed. +Use `converge_partial` when a deliberately capped, unconverged fit is the point. + +Predictions are strict for the same reason: every `predict_*` method reads +skills through one gate that refuses a NaN-poisoned fit, rather than returning a +plausible number computed from it. ## Drift @@ -228,11 +355,11 @@ certain because it knows less. ```rust use trueskill_tt::History; -let mut h = History::builder().build(); +let mut h = History::default(); h.record_winner(&"alice", &"bob", 1).unwrap(); -let _ = h.converge().unwrap(); +h.converge().unwrap(); -let skill = h.current_skill(&"alice").unwrap(); +let skill = h.current_skill("alice").unwrap(); // "How sure am I that this is below the cutoff?" — a probability, not a // `mu + z * sigma` band whose confidence drifts as sigma changes. @@ -254,7 +381,7 @@ what you believe now and what you would believe afterwards. ```rust use trueskill_tt::History; -let mut h = History::builder().build(); +let mut h = History::default(); for t in 1..=10 { h.record_winner(&"veteran", &"regular", t).unwrap(); h.record_winner(&"regular", &"veteran", t + 100).unwrap(); @@ -278,16 +405,21 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is `O(n² × outcomes)` passes — shortlist with `quality()` or `predict_win_probabilities` first, then score only the shortlist. -## Todo +## Other implementations -- [x] Implement approx for Gaussian -- [x] Add more tests from `TrueSkillThroughTime.jl` -- [x] Generalise a time axis — `Time` is now a trait (`Untimed`, `i64`), not an enum -- [x] Add examples (`examples/atp.rs`, `examples/scored.rs`) -- [x] Add Observer (`Observer` / `NullObserver`) -- [x] Benchmark the inference loop (`benches/batch.rs`, `benches/history_converge.rs`, `benches/ingest.rs`) -- [x] N-team `predict_outcome` with draw mass, and `expected_information_gain` -- [x] Cross-check `quality()` against [sublee/trueskill](https://github.com/sublee/trueskill/tree/master) — N identical teams follow the closed form `(1/5)^((n-1)/2)` for the conventional parameters, asserted for n = 2..10, and the n=3/n=5 values (0.200, 0.040) match the reference package +- [ttt-scala](https://github.com/ankurdave/ttt-scala) +- [ChessAnalysis #F](https://github.com/lucasmaystre/ChessAnalysis) +- [TrueSkillThroughTime.jl](https://github.com/glandfried/TrueSkillThroughTime.jl) +- [TrueSkillThroughTime.R](https://github.com/glandfried/TrueSkillThroughTime.R) +- [TrueSkill Through Time: Revisiting the History of Chess](https://www.microsoft.com/en-us/research/wp-content/uploads/2008/01/NIPS2007_0931.pdf) +- [TrueSkill Through Time. The full scientific documentation](https://glandfried.github.io/publication/landfried2021-learning/) + +## Status + +Every box on the old todo list is ticked, so it has been retired; open work +lives in the issue tracker instead. The crate is in use and the API is still +moving — breaking changes are batched into minor releases rather than dribbled +out, and `CHANGELOG.md` records them. ## License diff --git a/examples/atp.rs b/examples/atp.rs index fbd34d4..2274ef1 100644 --- a/examples/atp.rs +++ b/examples/atp.rs @@ -1,7 +1,8 @@ use plotters::prelude::*; -use smallvec::smallvec; use time::{Date, Month}; -use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift}; +use trueskill_tt::{ + Event, History, Member, Outcome, Team, drift::ConstantDrift, smallvec::smallvec, +}; fn main() { let mut csv = csv::Reader::open("examples/atp.csv").unwrap(); diff --git a/examples/scored.rs b/examples/scored.rs index 3566306..246f789 100644 --- a/examples/scored.rs +++ b/examples/scored.rs @@ -6,8 +6,7 @@ //! //! Run with: `cargo run --example scored --release` -use smallvec::smallvec; -use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team}; +use trueskill_tt::{ConstantDrift, Event, History, Member, Outcome, Team, smallvec::smallvec}; fn main() { let mut h = History::builder() diff --git a/src/convergence.rs b/src/convergence.rs index 04d8b55..660edd9 100644 --- a/src/convergence.rs +++ b/src/convergence.rs @@ -4,9 +4,30 @@ use std::time::Duration; use smallvec::SmallVec; +/// The stopping rule for the fixed-point loops, plus how hard they are damped. +/// +/// Set once per history through +/// [`HistoryBuilder::convergence`](crate::HistoryBuilder::convergence), and +/// carried by `GameOptions` for a single match scored without a history. The +/// defaults are the crate's globals: [`ITERATIONS`](crate::ITERATIONS), +/// [`EPSILON`](crate::EPSILON), and undamped EP. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ConvergenceOptions { + /// Hard cap on full forward+backward sweeps. + /// + /// A runaway guard, not a budget: the loop exits as soon as the step falls + /// to `epsilon`, so raising this costs nothing on a history that converges. + /// Reaching it is + /// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged). pub max_iter: usize, + /// Convergence threshold, in skill units. + /// + /// The sweep stops once *both* components of the step — the largest change + /// a whole iteration made to any competitor's posterior mean, and to any + /// posterior standard deviation — are at or below this. Larger values stop + /// sooner and further from the fixed point. Must be non-negative; NaN is + /// rejected, since every comparison against it is false and the loop would + /// read it as converged. pub epsilon: f64, /// EP damping factor in natural-parameter space: each per-factor /// update inside a single game writes `α·new + (1−α)·old`. `1.0` is @@ -70,10 +91,38 @@ impl Default for ConvergenceOptions { /// not be, and `converged` is what says so. #[derive(Clone, Debug, PartialEq)] pub struct ConvergenceReport { + /// Full forward+backward sweeps actually run. `0` for a history with no + /// time slices, which is converged trivially. pub iterations: usize, + /// How far the last sweep still moved the fit, as `(mean, standard + /// deviation)`. + /// + /// Not natural parameters: each component is a componentwise maximum of + /// `|Δmu|` and `|Δsigma|` over every competitor posterior the sweep + /// touched, so both are in skill units and both are non-negative. Each is + /// compared against `epsilon` separately — `converged` means neither + /// exceeds it. `(0.0, 0.0)` for a history with no time slices. pub final_step: (f64, f64), + /// Natural log of the model evidence for the whole history at this fit, + /// summed over every time slice. + /// + /// The same quantity + /// [`History::log_evidence`](crate::History::log_evidence) returns, taken + /// once the sweep has stopped. Only comparable between fits of the same + /// events; higher means the model explains them better. pub log_evidence: f64, + /// Whether the sweep reached `epsilon` rather than stopping at `max_iter`. + /// + /// Always `true` from [`History::converge`](crate::History::converge), + /// which reports the other case as `NotConverged`. From + /// [`History::converge_partial`](crate::History::converge_partial) this is + /// the only thing that distinguishes a finished fit from a capped one. pub converged: bool, + /// Wall-clock time each sweep took, in the order they ran. + /// + /// One entry per iteration, so its length equals `iterations`; empty for a + /// history with no time slices. It times the sweeps only, so the final + /// log-evidence pass is not in any entry. pub per_iteration_time: SmallVec<[Duration; 32]>, } diff --git a/src/error.rs b/src/error.rs index f1f04f2..5ee07e7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -39,36 +39,71 @@ pub enum UnknownKeys { Prior, } +/// Every way ingestion, inference or prediction can refuse to answer. +/// +/// The crate reports rather than repairs. An input it cannot represent, a fit +/// that never reached its fixed point, a quadrature it cannot resolve — each +/// comes back here instead of as a clamped, skipped or truncated result that +/// would still look like a number. Several variants exist precisely because the +/// silent version was measured and found to return a plausible wrong answer. +/// +/// The enum and most of its variants are `#[non_exhaustive]`: new cases and new +/// fields are additive, so match with a `_` arm and construct through the +/// library rather than by literal. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum InferenceError { /// Expected and actual lengths of some array-shaped input differ. #[non_exhaustive] MismatchedShape { + /// Which input disagreed, as a short label — `"ranks vs teams"`, + /// `"weights"`, `"times"`. kind: &'static str, + /// The length it had to have, taken from whatever it must line up with + /// (usually the event's team count). expected: usize, + /// The length actually supplied. got: usize, }, /// An `Outcome` of the wrong variant was supplied for the requested inference. #[non_exhaustive] WrongOutcomeKind { + /// The call that rejected the outcome, e.g. `"Game::ranked"`. context: &'static str, + /// The [`Outcome`](crate::Outcome) variant that call needs, by name. expected: &'static str, + /// The variant actually supplied, by name. got: &'static str, }, /// A probability value is outside `[0, 1]`. #[non_exhaustive] - InvalidProbability { value: f64 }, + InvalidProbability { + /// The value supplied, as it fell outside `[0, 1]`. Today only + /// `p_draw` reaches here. + value: f64, + }, /// A scalar parameter is outside its valid range. #[non_exhaustive] - InvalidParameter { name: &'static str, value: f64 }, + InvalidParameter { + /// The parameter, spelled as the API spells it — `"alpha"`, + /// `"epsilon"`, `"score_sigma"`, `"drift_scale"`, `"drift variance"`. + name: &'static str, + /// The value supplied for it. Out of that parameter's range, or NaN, + /// which fails every range comparison and is rejected on that basis. + value: f64, + }, /// An event contains tied teams, but the draw probability is zero. /// /// A zero draw probability asserts that draws cannot occur, so a tied /// result has no representable likelihood. Configure a positive `p_draw` /// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties. #[non_exhaustive] - TieWithoutDrawProbability { teams: (usize, usize) }, + TieWithoutDrawProbability { + /// Positions in the event's team list of the first tied pair, lowest + /// index first. Only one pair is reported — the event is rejected + /// whole, so enumerating the rest would add nothing. + teams: (usize, usize), + }, /// The convergence sweep hit `max_iter` with the step still above /// `epsilon`. /// @@ -84,8 +119,15 @@ pub enum InferenceError { /// returns the short fit instead when that is genuinely what is wanted. #[non_exhaustive] NotConverged { + /// Full forward+backward sweeps run before the loop gave up. iterations: usize, + /// How far the last sweep still moved the fit, as + /// `(largest change in a mean, largest change in a standard + /// deviation)` over every competitor posterior it touched — the same + /// quantity as + /// [`ConvergenceReport::final_step`](crate::ConvergenceReport). final_step: (f64, f64), + /// The threshold both components of `final_step` had to reach. epsilon: f64, }, /// Inference produced a non-finite value (NaN or infinity). @@ -94,7 +136,12 @@ pub enum InferenceError { /// and must not be treated as a converged estimate. #[non_exhaustive] NonFiniteResult { + /// Where the breakdown was caught — `"History::converge"` for a sweep, + /// or a phrase naming the prediction that read an unusable skill. context: &'static str, + /// The offending pair, at least one component of which is NaN or + /// infinite. From `converge` it is the sweep's step; from a prediction + /// it is the skill's own `(mu, sigma)`. step: (f64, f64), }, /// One batch declared two different values for the same competitor's @@ -108,7 +155,12 @@ pub enum InferenceError { /// when a competitor's configuration is a property of the domain. #[non_exhaustive] ConflictingCompetitorConfig { + /// The competitor's interned [`Index`](crate::Index) as a raw `usize`, + /// not the user key — the batch is already flattened to indices by the + /// time the conflict is detectable. competitor: usize, + /// Which piece of configuration was declared twice: `"prior"` or + /// `"drift_scale"`. field: &'static str, }, /// A prediction referenced a key the history has no skill for. @@ -123,8 +175,16 @@ pub enum InferenceError { /// neutral value — turns the whole thing into a plausible constant. #[non_exhaustive] UnknownKey { + /// Position of the offending team in the supplied matchup. `0` on the + /// queries that take a flat list of keys rather than teams, where + /// there is only one list to index into. team: usize, + /// Position of the offending key within that team, or within the flat + /// key list. member: usize, + /// The key's `Debug` rendering, captured because `K` is only required + /// to be `Debug` — see the variant docs for why the indices alone are + /// not enough. key: String, }, /// `History::register` was called for a competitor that already exists. @@ -138,10 +198,16 @@ pub enum InferenceError { /// To change an existing competitor's configuration, supply it on an event /// through `Member`; that refits the whole history. #[non_exhaustive] - AlreadyRegistered { key: String }, + AlreadyRegistered { + /// The already-known competitor's key, in its `Debug` rendering. + key: String, + }, /// A prediction was given a team with no members. #[non_exhaustive] - EmptyTeam { team: usize }, + EmptyTeam { + /// Position of the memberless team in the supplied list. + team: usize, + }, /// The prediction grid cannot resolve the narrowest feature in the matchup. /// /// `predict_outcome` and `predict_ranking` integrate every team's density @@ -167,10 +233,19 @@ pub enum InferenceError { }, /// A joint posterior was requested where one cannot be formed exactly. #[non_exhaustive] - JointUnavailable { reason: &'static str }, + JointUnavailable { + /// Why no exact joint exists here: the history has no events, it holds + /// ranked events whose EP factors are not retained past convergence, or + /// the assembled precision matrix is not positive-definite. + reason: &'static str, + }, /// Fewer than two teams were supplied to a prediction. #[non_exhaustive] - NotEnoughTeams { got: usize }, + NotEnoughTeams { + /// How many teams the prediction was actually given. Two is the + /// minimum: there is nothing to compare against with fewer. + got: usize, + }, /// The full outcome distribution was requested for too many teams. /// /// Each realisation sorts into exactly one (order, tie-pattern) event, so @@ -180,7 +255,13 @@ pub enum InferenceError { /// `predict_ranking`, or for `predict_win_probabilities`, both of which /// stay cheap at any team count. #[non_exhaustive] - TooManyTeams { got: usize, max: usize }, + TooManyTeams { + /// How many teams the outcome distribution was asked for. + got: usize, + /// The largest team count that will be enumerated, + /// [`MAX_PREDICTED_TEAMS`](crate::MAX_PREDICTED_TEAMS). + max: usize, + }, } impl fmt::Display for InferenceError { diff --git a/src/event.rs b/src/event.rs index 15d119f..b26d827 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,8 +13,25 @@ use crate::{gaussian::Gaussian, outcome::Outcome, time::Time}; /// A single match at time `time` involving some number of teams. #[derive(Clone, Debug, PartialEq)] pub struct Event { + /// When the match happened, on the history's time axis. + /// + /// Events sharing a `time` land in the same time slice and are fitted + /// together, so nothing distinguishes their order. Drift is driven by the + /// gap between a competitor's *consecutive appearances*, not by the gap + /// between slices, so a competitor idle across several slices accumulates + /// the whole span at once when it next plays. pub time: T, + /// The teams that took part, positionally aligned with `outcome`: team `i` + /// here is the team `outcome` ranks or scores at index `i`. + /// + /// Ingestion rejects fewer than two teams (`NotEnoughTeams`) and any team + /// with no members (`EmptyTeam`). pub teams: SmallVec<[Team; 4]>, + /// How the match ended: ranks (lower is better) or per-team scores (higher + /// is better), one entry per entry of `teams`. + /// + /// A tie — two equal ranks — needs a positive `p_draw`, otherwise + /// ingestion fails with `TieWithoutDrawProbability`. pub outcome: Outcome, } @@ -22,16 +39,31 @@ pub struct Event { #[derive(Clone, Debug, PartialEq)] #[must_use] pub struct Team { + /// The competitors playing together, in no significant order: the team's + /// performance is the weight-scaled sum over its members, which does not + /// depend on how they are listed. + /// + /// Must be non-empty — an empty team contributes no performance at all, so + /// ingestion rejects it with `EmptyTeam` rather than returning a plausible + /// posterior for whoever it was matched against. pub members: SmallVec<[Member; 4]>, } impl Team { + /// A team with no members yet, to be filled through the public `members` + /// field. + /// + /// Committing it while still empty is an `EmptyTeam` error. pub fn new() -> Self { Self { members: SmallVec::new(), } } + /// A team of exactly these competitors. + /// + /// Members must be built already — `Member::from(key)` covers the common + /// case of a plain key at default weight with no overrides. pub fn with_members>>(members: I) -> Self { Self { members: members.into_iter().collect(), @@ -64,8 +96,26 @@ impl Default for Team { #[derive(Clone, Debug, PartialEq)] #[must_use] pub struct Member { + /// The competitor's identity. Equal keys across events are the same + /// competitor: `History` interns each distinct key to an internal `Index` + /// the first time it sees it, and every later appearance resolves to that + /// same competitor's temporal state. pub key: K, + /// This member's share of the team's performance, for this event only. + /// + /// The team's performance is the sum of `weight × member performance`, so + /// `1.0` is a full share and `0.5` counts the member half; the message + /// coming back to the member is divided by the same weight. Defaults to + /// `1.0`. + /// + /// Must be finite — a NaN or infinite weight is `InvalidParameter` at + /// ingestion. Zero and negative are accepted, both being expressible in + /// the same arithmetic. pub weight: f64, + /// Starting skill for this competitor, replacing the history's `mu`/`sigma` + /// default. `None` keeps the history default. + /// + /// Competitor configuration, not a per-event value; see the type docs. pub prior: Option, /// Multiplier on the drift *variance* this competitor accumulates. /// `None` means 1.0. @@ -73,6 +123,8 @@ pub struct Member { } impl Member { + /// A competitor taking a full share of its team's performance, with no + /// configuration overrides: the history's prior and drift apply. pub fn new(key: K) -> Self { Self { key, @@ -82,6 +134,12 @@ impl Member { } } + /// Change how much of the team's performance this member accounts for. + /// + /// Unlike `prior` and `drift_scale`, this is genuinely per-event: the same + /// key can carry a different weight in every event it appears in, which is + /// what makes it usable for partial participation — a substitute who + /// played half the match, a doubles partner credited unequally. pub fn with_weight(mut self, weight: f64) -> Self { self.weight = weight; self diff --git a/src/event_builder.rs b/src/event_builder.rs index 6ed9255..dddc2de 100644 --- a/src/event_builder.rs +++ b/src/event_builder.rs @@ -9,6 +9,33 @@ use crate::{ time::Time, }; +/// One match under construction, handed back by [`History::event`]. +/// +/// Describes a single event a piece at a time — teams, then per-member weights +/// if they differ, then how it ended — instead of assembling an +/// [`Event`] value and passing it to [`History::add_events`]. The two routes +/// ingest through the same chokepoint and accept the same things; this one just +/// reads better for a single match written by hand. +/// +/// The builder borrows the history mutably and nothing reaches it until +/// [`EventBuilder::commit`]. A builder that is dropped instead ingests +/// nothing at all, silently — hence the `#[must_use]`, which is the only +/// warning you get. `commit` is also where validation surfaces: the setters +/// return `Self` to keep the chain fluent, so a mismatch such as a weight list +/// the wrong length is recorded while building and returned as an error from +/// `commit`. +/// +/// ``` +/// # use trueskill_tt::History; +/// let mut h = History::builder().build(); +/// h.event(1) +/// .team(["alice", "bob"]) +/// .team(["carol"]) +/// .ranking([0, 1]) +/// .commit()?; +/// assert_eq!(h.event_count(), 1); +/// # Ok::<(), trueskill_tt::InferenceError>(()) +/// ``` #[must_use = "an event is only recorded by `.commit()`; a dropped builder \ silently ingests nothing"] pub struct EventBuilder<'h, T, D, O, K> diff --git a/src/game.rs b/src/game.rs index 9f9838f..852d44f 100644 --- a/src/game.rs +++ b/src/game.rs @@ -70,8 +70,25 @@ impl DiffFactor { /// how much the engine trusts the observed score margin (smaller σ = more trust). #[derive(Clone, Copy, Debug, PartialEq)] pub struct GameOptions { + /// Probability the model assigns to two teams drawing, which sets the width + /// of the truncation band around a tie. Must be in `[0.0, 1.0)`; defaults + /// to [`P_DRAW`](crate::P_DRAW). + /// + /// At `0.0` the band has zero width, so a ranked outcome that ties two + /// teams has no representable likelihood and [`Game::ranked`] rejects it + /// with `TieWithoutDrawProbability`. pub p_draw: f64, + /// Standard deviation of the observation noise on an observed score margin, + /// used only by [`Game::scored`], which rejects a non-positive or NaN value + /// with `InvalidParameter`. Defaults to `1.0`. + /// + /// It is in the units of the scores themselves, and says how much of a + /// margin the model reads as skill rather than noise: a small sigma takes + /// the margin near-literally, a large one barely moves the ratings. pub score_sigma: f64, + /// Stopping rule and damping for the within-game message-passing loop: + /// iterate until the largest message change falls below `epsilon`, or + /// `max_iter` passes, with each update damped by `alpha`. pub convergence: crate::ConvergenceOptions, } @@ -91,6 +108,9 @@ impl Default for GameOptions { /// History's internal state), `OwnedGame` owns the team ratings, so it /// can be returned freely from public constructors. The inference inputs /// themselves are not retained — nothing reads them back. +/// +/// A fitted single match, and nothing more: see [`Game`] for why that is not +/// the same as a step of a [`History`](crate::History). #[derive(Debug)] #[must_use] pub struct OwnedGame> { @@ -145,6 +165,13 @@ impl> OwnedGame { } } + /// Updated skill belief for every competitor, as `[team][member]` in the + /// order the teams and members were passed in. + /// + /// Each is the competitor's own prior multiplied by the likelihood this one + /// match produced for it — so it reflects this match and the rating handed + /// in, and nothing else. Feeding it back as the next match's prior is the + /// caller's job; that is what a [`History`](crate::History) automates. #[must_use] pub fn posteriors(&self) -> Vec> { self.likelihoods @@ -154,12 +181,48 @@ impl> OwnedGame { .collect() } + /// Natural log of how probable this outcome was under the priors, summed + /// over the diff chain's links. + /// + /// Higher means the result was less surprising, so it doubles as a + /// closeness measure — two identically-rated competitors give exactly + /// `ln(0.5)`, either of them being equally likely to win: + /// + /// ``` + /// # use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating}; + /// let r = Rating::new(Gaussian::from_ms(25.0, 25.0 / 3.0), 25.0 / 6.0, ConstantDrift::new(0.0)); + /// let g = Game::::ranked(&[&[r], &[r]], Outcome::winner(0, 2), &GameOptions::default())?; + /// assert!((g.log_evidence() - 0.5_f64.ln()).abs() < 1e-12); + /// # Ok::<(), trueskill_tt::InferenceError>(()) + /// ``` + /// + /// Accumulated in log space because the linear product over a long chain + /// underflows to zero, and `ln(0.0)` is `-inf`. #[must_use] pub fn log_evidence(&self) -> f64 { self.log_evidence } } +/// One match's factor graph, fitted on its own. +/// +/// Rate a single match against ratings you already hold and get the updated +/// beliefs straight back. There is no history behind it: nothing is stored, +/// nothing propagates backward, and the priors you hand in are the only +/// evidence used. That makes it the wrong tool for the thing this crate exists +/// for — [`History`](crate::History) is what infers skill *through time*, +/// revising past estimates as later matches arrive, and a sequence of `Game`s +/// chained by hand is a forward-only filter, not the same answer. +/// +/// Reach for `Game` when a history would be overkill or unavailable: a +/// one-off matchup, replaying a rating step from stored numbers, checking the +/// engine against a reference, or a caller that keeps its own persistence and +/// only wants the update rule. +/// +/// The type is mostly a namespace. Its constructors — [`Game::ranked`], +/// [`Game::scored`], [`Game::one_v_one`], [`Game::free_for_all`] — return an +/// [`OwnedGame`], because `Game<'a, …>` borrows the result and weight slices +/// that `History` keeps internally and so cannot be handed out. #[derive(Debug)] pub struct Game<'a, T: Time = i64, D: Drift = crate::drift::ConstantDrift> { teams: Vec>>, @@ -413,6 +476,9 @@ impl<'a, T: Time, D: Drift> Game<'a, T, D> { self.likelihoods = likelihoods; } + /// Updated skill belief for every competitor, as `[team][member]` in the + /// order the teams and members were passed in — prior times this match's + /// likelihood, exactly as [`OwnedGame::posteriors`]. #[must_use] pub fn posteriors(&self) -> Vec> { self.likelihoods @@ -427,6 +493,8 @@ impl<'a, T: Time, D: Drift> Game<'a, T, D> { .collect::>() } + /// Natural log of how probable this outcome was under the priors, summed + /// over the diff chain's links — as [`OwnedGame::log_evidence`]. #[must_use] pub fn log_evidence(&self) -> f64 { self.log_evidence diff --git a/src/gaussian.rs b/src/gaussian.rs index a789b8f..4663713 100644 --- a/src/gaussian.rs +++ b/src/gaussian.rs @@ -100,18 +100,34 @@ impl Gaussian { Self { pi, tau } } + /// Precision, `1 / sigma^2` — one of the two natural parameters. + /// + /// This is the representation the type actually stores, which is why the EP + /// product and cavity (`Mul` / `Div`) are plain adds and subtracts. Larger + /// means more certain; `0.0` is an improper, uninformative message and + /// `inf` is a point mass. #[inline] #[must_use] pub fn pi(&self) -> f64 { self.pi } + /// Precision-adjusted mean, `mu / sigma^2` — the other natural parameter. + /// + /// Stored rather than derived, for the same reason as [`Gaussian::pi`]. + /// Meaningful only alongside `pi`: on its own it is not a location. #[inline] #[must_use] pub fn tau(&self) -> f64 { self.tau } + /// Mean skill: the point estimate. + /// + /// Derived from the natural parameters as `tau / pi`. An improper message + /// (`pi <= 0`) has no defined mean and reports `0.0` — see + /// [`Gaussian::sigma`], which reports `inf` for the same state, and read + /// the two together before treating a mean as informative. #[inline] #[must_use] pub fn mu(&self) -> f64 { @@ -141,6 +157,12 @@ impl Gaussian { } } + /// Standard deviation: how unsure this estimate is. + /// + /// Derived as `1 / sqrt(pi)`. An improper message (`pi <= 0`) reports + /// `inf`, and a point mass (`pi == inf`) reports `0.0` — both are real + /// states rather than error codes, and both are legitimate for a converged + /// fit with degenerate parameters. #[inline] #[must_use] pub fn sigma(&self) -> f64 { diff --git a/src/history.rs b/src/history.rs index ecc03f6..b531507 100644 --- a/src/history.rs +++ b/src/history.rs @@ -24,6 +24,19 @@ use crate::{ tuple_gt, tuple_max, }; +/// Configures a [`History`] before any events are added. +/// +/// Everything a history needs that is not an event lives here: the prior +/// (`mu`, `sigma`), the performance noise `beta`, the draw probability, the +/// drift model, the convergence settings, the observer, and what to do about +/// an unknown key. None of them can be changed after `build`, because they +/// define the model the fit is of. +/// +/// Two of the setters change the builder's *type* rather than a field — +/// [`HistoryBuilder::drift`] and [`HistoryBuilder::observer`] — so bind the +/// result rather than calling them on a `&mut`. [`HistoryBuilder::time_type`] +/// and [`HistoryBuilder::key_type`] exist for the same reason: to name a type +/// parameter that nothing in the call chain would otherwise infer. #[derive(Clone, Debug)] #[must_use = "a builder does nothing until `.build()`"] pub struct HistoryBuilder< @@ -99,6 +112,17 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< self } + /// Set the drift model: how far skill may move between appearances. + /// + /// Changes the builder's type, since `D` is a type parameter — bind the + /// result. [`ConstantDrift`] is the default; a custom [`Drift`] impl is + /// the way to express a calendar-dependent or per-competitor rule that + /// elapsed ticks alone cannot. + /// + /// Not validated here: the builder cannot inspect an arbitrary + /// implementation. `converge` checks the variance each competitor actually + /// accumulates and reports `InvalidParameter` if it is negative or + /// non-finite. pub fn drift>(self, drift: D2) -> HistoryBuilder { HistoryBuilder { drift, @@ -248,6 +272,12 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< } } + /// Attach an [`Observer`] to be called as inference progresses. + /// + /// Changes the builder's type — bind the result. The history takes the + /// observer by value; to keep a handle on one that accumulates state, pass + /// an `Arc` and keep a clone, or read it back with + /// [`History::observer`] / [`History::into_observer`]. pub fn observer>(self, observer: O2) -> HistoryBuilder { HistoryBuilder { mu: self.mu, @@ -264,6 +294,9 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< } } + /// Finish configuring and produce an empty [`History`]. + /// + /// Every parameter was validated as it was set, so this cannot fail. pub fn build(self) -> History { History { size: 0, @@ -417,6 +450,12 @@ impl Default for History { } impl History { + /// Start configuring a history. + /// + /// The defaults are `i64` time, [`ConstantDrift`], no observer and + /// `&'static str` keys. Any of the four can be changed — the two type + /// parameters that no argument would pin are named with + /// [`HistoryBuilder::time_type`] and [`HistoryBuilder::key_type`]. pub fn builder() -> HistoryBuilder { HistoryBuilder::default() } @@ -444,6 +483,11 @@ impl HistoryBuilder, O: Observer, K: Eq + Hash + Clone> History { + /// Promote a key to its [`Index`], creating the entry if it is new. + /// + /// Interning a key does not register a competitor or give them a rating — + /// it only reserves the slot. Use [`History::register`] to declare a + /// competitor's configuration up front. pub fn intern(&mut self, key: &Q) -> Index where K: Borrow, @@ -452,6 +496,10 @@ impl, O: Observer, K: Eq + Hash + Clone> History(&self, key: &Q) -> Option where @@ -731,6 +779,15 @@ impl, O: Observer, K: Eq + Hash + Clone> History(&self, key: &Q) -> Option where @@ -1144,8 +1201,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History Result<(Vec, Vec), InferenceError> where K: std::fmt::Debug, @@ -1566,7 +1622,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History 0`, or ingestion rejects the event with + /// [`InferenceError::TieWithoutDrawProbability`](crate::InferenceError::TieWithoutDrawProbability). + /// + /// Only the ordering and the equalities are used. Ranks need not be dense + /// or start at zero: inference sorts the teams and compares rank-adjacent + /// pairs against a margin set by `p_draw`, so `[0, 1, 2]` and `[0, 5, 90]` + /// are the same observation. A gap does not mean a bigger win — use + /// `Scored` when the size of the difference is evidence. Ranked(SmallVec<[u32; 4]>), + /// A continuous finish: one score per team, higher is better. + /// + /// Unlike `Ranked`, the *sizes* of the differences are evidence. Teams are + /// sorted by score and each adjacent pair's observed gap is fed to a + /// `MarginFactor` as a measurement with standard deviation `sigma`, so + /// beating a team by ten says more than beating them by one. #[non_exhaustive] Scored { + /// Per-team scores, in the order the teams were given; higher is + /// better. Must have one entry per team, and every entry finite. scores: SmallVec<[f64; 4]>, /// Per-event noise override. `None` means inherit /// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`. @@ -104,6 +123,12 @@ impl Outcome { } } + /// How many teams this outcome describes — the number of ranks, or of + /// scores. + /// + /// Ingestion checks it against the event's own team list and rejects a + /// disagreement with `MismatchedShape`, so this is the cheap way to check + /// an outcome built elsewhere before committing the event. #[must_use] pub fn team_count(&self) -> usize { match self { diff --git a/src/time_slice.rs b/src/time_slice.rs index 844fbc4..5a5e1bd 100644 --- a/src/time_slice.rs +++ b/src/time_slice.rs @@ -95,7 +95,7 @@ pub(crate) struct Event { } impl Event { - pub(crate) fn iter_agents(&self) -> impl Iterator + '_ { + pub(crate) fn iter_competitors(&self) -> impl Iterator + '_ { self.teams .iter() .flat_map(|t| t.items.iter().map(|it| it.competitor)) @@ -255,7 +255,7 @@ impl TimeSlice { } let cg = color_greedy(n, |ev_idx| { - self.events[ev_idx].iter_agents().collect::>() + self.events[ev_idx].iter_competitors().collect::>() }); let mut reordered: Vec = Vec::with_capacity(n); @@ -292,7 +292,7 @@ impl TimeSlice { ) { let mut unique = Vec::with_capacity(10); - let this_agent = composition.iter().flatten().flatten().filter(|idx| { + let these_competitors = composition.iter().flatten().flatten().filter(|idx| { if !unique.contains(idx) { unique.push(*idx); @@ -302,7 +302,7 @@ impl TimeSlice { false }); - for idx in this_agent { + for idx in these_competitors { let elapsed = compute_elapsed(competitors[*idx].last_time.as_ref(), &self.time); let forward = competitors[*idx].receive(&self.time); @@ -1235,14 +1235,22 @@ mod tests { // Events at positions 0 and 1 (color 0) must be disjoint — verify by // checking that the competitor sets of self.events[0] and self.events[1] do // not include the competitor at self.events[2]. - let agents_in_ev2: Vec = ts.events[2].iter_agents().collect(); - let agents_in_ev0: Vec = ts.events[0].iter_agents().collect(); - let agents_in_ev1: Vec = ts.events[1].iter_agents().collect(); + let competitors_in_ev2: Vec = ts.events[2].iter_competitors().collect(); + let competitors_in_ev0: Vec = ts.events[0].iter_competitors().collect(); + let competitors_in_ev1: Vec = ts.events[1].iter_competitors().collect(); // ev0 and ev1 must be disjoint from each other (color-0 invariant). - assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag))); + assert!( + competitors_in_ev0 + .iter() + .all(|ag| !competitors_in_ev1.contains(ag)) + ); // ev2 must share an competitor with ev0 or ev1 (it needed its own color). - let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag)); - let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag)); + let ev2_overlaps_ev0 = competitors_in_ev2 + .iter() + .any(|ag| competitors_in_ev0.contains(ag)); + let ev2_overlaps_ev1 = competitors_in_ev2 + .iter() + .any(|ag| competitors_in_ev1.contains(ag)); assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1); } }