docs: complete the public API documentation contract

Closes the last open item in #25. `cargo clippy -W missing_errors_doc
-W missing_panics_doc -W must_use_candidate -W doc_markdown` went from 56
warnings to zero.

The 13 hand-written sections name the actual variants each function returns
rather than gesturing at "an error". Establishing that meant reading the error
paths — `Game::ranked` alone returns four distinct variants, and `record_draw`
can hit TieWithoutDrawProbability where `record_winner` provably cannot, since
a two-team decisive outcome has nothing to tie. Documenting those as
interchangeable would have been worse than leaving them undocumented, because
a reader would trust it.

Two existing doc comments already described panics in prose but not under a
`# Panics` heading, so neither rustdoc nor clippy surfaced them:
`Outcome::winner` and `EventBuilder::weights`. Both now carry the heading, and
`Outcome::winner` gained the note that it ties every loser, so `n >= 3` needs a
positive p_draw — the crate's easiest error to hit by accident.

The 43 mechanical fixes (31 `#[must_use]` on pure accessors, 11 missing
backticks) were applied with `cargo clippy --fix`. `#[must_use]` on Gaussian's
arithmetic and on `posteriors()` matters: discarding those results is always a
bug, and until now nothing said so.

Also documented why `[profile.release] debug = true` exists — cargo-flamegraph
needs the symbols, and library profile settings are ignored downstream, so it
reads as an oversight without the note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
This commit is contained in:
2026-08-27 17:43:31 +02:00
co-authored by Claude Opus 5
parent 07285283b6
commit 9b2c2b38c8
17 changed files with 129 additions and 13 deletions
+4
View File
@@ -65,6 +65,10 @@ plotters-backend = "0.3"
time = { version = "0.3", features = ["parsing"] }
trueskill-tt = { path = ".", features = ["approx"] }
# Debug symbols in release are for `just flame` (cargo-flamegraph), which needs
# them to symbolicate. Profile settings in a library are ignored by downstream
# consumers, so these only affect local builds — this is deliberate, not an
# oversight.
[profile.release]
debug = true
+1
View File
@@ -23,6 +23,7 @@ pub struct Team<K> {
}
impl<K> Team<K> {
#[must_use]
pub fn new() -> Self {
Self {
members: SmallVec::new(),
+8 -2
View File
@@ -50,8 +50,10 @@ where
/// Set per-member weights for the most recently added team.
///
/// Panics in debug builds if called before `.team(...)` or if the length
/// doesn't match the team's member count.
/// # Panics
///
/// Panics if called before any `.team(...)`. In debug builds, also panics
/// if the number of weights does not match the team's member count.
pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
let idx = self
.current_team_idx
@@ -103,6 +105,10 @@ where
}
/// Commit the event to the history.
///
/// # Errors
///
/// Forwards to [`History::add_events`] and returns its errors.
pub fn commit(self) -> Result<(), InferenceError> {
self.history.add_events(std::iter::once(self.event))
}
+1
View File
@@ -20,6 +20,7 @@ pub struct MarginFactor {
}
impl MarginFactor {
#[must_use]
pub fn new(diff: VarId, m_obs: f64, sigma: f64) -> Self {
debug_assert!(sigma > 0.0, "score sigma must be positive");
Self {
+4
View File
@@ -20,6 +20,7 @@ pub struct VarStore {
}
impl VarStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
@@ -28,10 +29,12 @@ impl VarStore {
self.marginals.clear();
}
#[must_use]
pub fn len(&self) -> usize {
self.marginals.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.marginals.is_empty()
}
@@ -42,6 +45,7 @@ impl VarStore {
id
}
#[must_use]
pub fn get(&self, id: VarId) -> Gaussian {
self.marginals[id.0 as usize]
}
+2 -2
View File
@@ -5,12 +5,12 @@ use crate::factor::{Factor, VarId, VarStore};
/// On each propagation:
/// - Reads marginals at `team_a` and `team_b` (which already incorporate any
/// incoming messages from neighboring factors).
/// - Computes `new_diff = team_a - team_b` (variance addition; see Gaussian::Sub).
/// - Computes `new_diff = team_a - team_b` (variance addition; see `Gaussian::Sub`).
/// - Writes the new marginal to `diff`.
/// - Returns the delta against the previous diff value.
///
/// This factor does NOT store an outgoing message; the diff variable is
/// effectively replaced on each propagation. The TruncFactor on the same diff
/// effectively replaced on each propagation. The `TruncFactor` on the same diff
/// var holds the EP-divide message that produces the cavity.
#[derive(Debug)]
pub struct RankDiffFactor {
+2 -1
View File
@@ -15,13 +15,14 @@ pub struct TruncFactor {
pub diff: VarId,
pub margin: f64,
pub tie: bool,
/// Outgoing message to the diff variable (initial: N_INF, the EP identity).
/// Outgoing message to the diff variable (initial: `N_INF`, the EP identity).
pub(crate) msg: Gaussian,
/// Cached evidence (linear, not log) computed from the cavity on first propagation.
pub(crate) evidence_cached: Option<f64>,
}
impl TruncFactor {
#[must_use]
pub fn new(diff: VarId, margin: f64, tie: bool) -> Self {
Self {
diff,
+28
View File
@@ -145,6 +145,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
}
}
#[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
.iter()
@@ -153,6 +154,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
.collect()
}
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
}
@@ -409,6 +411,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
self.likelihoods = likelihoods;
}
#[must_use]
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
self.likelihoods
.iter()
@@ -422,12 +425,21 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.collect::<Vec<_>>()
}
#[must_use]
pub fn log_evidence(&self) -> f64 {
self.log_evidence
}
}
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
/// # Errors
///
/// - `InvalidProbability` if `options.p_draw` is outside `[0.0, 1.0)`.
/// - `MismatchedShape` if the outcome's rank count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Ranked`.
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
/// `p_draw` is zero: the truncation margin is then zero and the two-sided
/// tie update evaluates `0/0`.
pub fn ranked(
teams: &[&[Rating<T, D>]],
outcome: crate::Outcome,
@@ -478,6 +490,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
))
}
/// # Errors
///
/// - `InvalidParameter` if `options.score_sigma` is not strictly positive,
/// or is NaN.
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
pub fn scored(
teams: &[&[Rating<T, D>]],
outcome: crate::Outcome,
@@ -515,6 +533,12 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
))
}
/// # Errors
///
/// Delegates to [`Game::ranked`] with default options, so it returns the
/// same errors — in practice `WrongOutcomeKind` for a non-ranked outcome,
/// or `TieWithoutDrawProbability` for a draw, since the default `p_draw`
/// applies rather than one you chose.
pub fn one_v_one(
a: &Rating<T, D>,
b: &Rating<T, D>,
@@ -525,6 +549,10 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
Ok((post[0][0], post[1][0]))
}
/// # Errors
///
/// Wraps each player in a one-member team and delegates to
/// [`Game::ranked`], so it returns the same errors.
pub fn free_for_all(
players: &[&Rating<T, D>],
outcome: crate::Outcome,
+6
View File
@@ -18,6 +18,7 @@ pub struct Gaussian {
impl Gaussian {
/// Construct from mean and standard deviation.
#[must_use]
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
if sigma == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 }
@@ -64,16 +65,19 @@ impl Gaussian {
}
#[inline]
#[must_use]
pub fn pi(&self) -> f64 {
self.pi
}
#[inline]
#[must_use]
pub fn tau(&self) -> f64 {
self.tau
}
#[inline]
#[must_use]
pub fn mu(&self) -> f64 {
// A non-positive precision is an improper (uninformative) Gaussian — its mean is
// undefined. Treat it like `pi == 0` and return 0. EP message cancellation can land
@@ -102,6 +106,7 @@ impl Gaussian {
}
#[inline]
#[must_use]
pub fn sigma(&self) -> f64 {
// A non-positive precision is improper → infinite standard deviation. Guarding
// `pi <= 0.0` (not just `== 0.0`) keeps `1.0 / pi.sqrt()` from returning NaN when EP
@@ -145,6 +150,7 @@ impl Gaussian {
/// Used by within-game inference to stabilise oscillating fixed-point
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
/// `alpha < 1.0` shrinks each per-step update.
#[must_use]
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(),
+41 -1
View File
@@ -198,6 +198,7 @@ impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
}
impl History<i64, ConstantDrift, NullObserver, &'static str> {
#[must_use]
pub fn builder() -> HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
HistoryBuilder::default()
}
@@ -205,6 +206,7 @@ impl History<i64, ConstantDrift, NullObserver, &'static str> {
impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
/// Like `builder()` but uses a custom key type `K` instead of the default `&'static str`.
#[must_use]
pub fn builder_with_key() -> HistoryBuilder<i64, ConstantDrift, NullObserver, K> {
HistoryBuilder {
mu: MU,
@@ -552,7 +554,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// 2-team win probability: returns `[P(team0 wins), P(team1 wins)]`.
///
/// Panics if `teams.len() != 2`. N-team support lands in T4.
/// N-team support lands in T4.
///
/// # Panics
///
/// Panics if `teams.len() != 2`.
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Vec<f64> {
assert_eq!(teams.len(), 2, "predict_outcome T2: 2 teams only");
let gather = |team: &[&K]| -> Gaussian {
@@ -574,6 +580,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
/// Run the full forward+backward convergence loop and return a summary.
///
/// Failing to reach `epsilon` within `max_iter` is not an error: the
/// returned report carries `converged: false` and the final step.
///
/// # Errors
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
/// broken down at that point and further iterations cannot recover, so the
/// loop stops rather than reporting a NaN step as convergence.
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
use std::time::Instant;
@@ -830,6 +845,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
Ok(())
}
/// Record a single two-competitor event that `winner` won.
///
/// # Errors
///
/// Ingests through the same path as [`History::add_events`], so it returns
/// the same errors. A two-team decisive outcome cannot tie, so
/// `TieWithoutDrawProbability` is not reachable here.
pub fn record_winner<Q>(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError>
where
K: Borrow<Q>,
@@ -847,6 +869,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
)
}
/// Record a single two-competitor event that ended level.
///
/// # Errors
///
/// Ingests through the same path as [`History::add_events`]. Note
/// `TieWithoutDrawProbability` *is* reachable here: a draw needs a
/// positive `p_draw`.
pub fn record_draw<Q>(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError>
where
K: Borrow<Q>,
@@ -870,6 +899,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
}
/// Bulk-ingest typed events.
///
/// # Errors
///
/// - `MismatchedShape` if an event's outcome does not describe the same
/// number of teams the event has, or if per-member weights do not match
/// the team's membership.
/// - `InvalidParameter` if a per-event `score_sigma` override is not
/// strictly positive.
/// - `TieWithoutDrawProbability` if an event ties two teams while the
/// history's `p_draw` is zero. This includes `Outcome::winner(w, n)` for
/// `n >= 3`, which ties every loser.
pub fn add_events<I>(&mut self, events: I) -> Result<(), InferenceError>
where
I: IntoIterator<Item = crate::event::Event<T, K>>,
+4
View File
@@ -25,6 +25,7 @@ impl<K> KeyTable<K>
where
K: Eq + Hash + Clone,
{
#[must_use]
pub fn new() -> Self {
Self {
forward: HashMap::new(),
@@ -54,6 +55,7 @@ where
}
}
#[must_use]
pub fn key(&self, idx: Index) -> Option<&K> {
self.reverse.get(idx.0)
}
@@ -62,10 +64,12 @@ where
self.forward.keys()
}
#[must_use]
pub fn len(&self) -> usize {
self.reverse.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.reverse.is_empty()
}
+3 -2
View File
@@ -1,6 +1,6 @@
//! TrueSkill Through Time — Bayesian skill rating over a time axis.
//! `TrueSkill` Through Time — Bayesian skill rating over a time axis.
//!
//! Where plain TrueSkill gives each competitor one running estimate, TrueSkill
//! 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
@@ -361,6 +361,7 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
/// Panics if fewer than two rating groups are supplied, or if any group is
/// empty — match quality is a property of a contest between at least two
/// non-empty sides.
#[must_use]
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
assert!(
rating_groups.len() >= 2,
+8
View File
@@ -29,7 +29,13 @@ pub enum Outcome {
impl Outcome {
/// `n`-team outcome where team `winner` won and everyone else tied for last.
///
/// Note this ties every loser, so for `n >= 3` it needs a positive
/// `p_draw` — see `InferenceError::TieWithoutDrawProbability`.
///
/// # Panics
///
/// Panics if `winner >= n`.
#[must_use]
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();
@@ -37,6 +43,7 @@ impl Outcome {
}
/// All `n` teams tied.
#[must_use]
pub fn draw(n: u32) -> Self {
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
}
@@ -68,6 +75,7 @@ impl Outcome {
}
}
#[must_use]
pub fn team_count(&self) -> usize {
match self {
Self::Ranked(r) => r.len(),
+2 -2
View File
@@ -1,7 +1,7 @@
//! Schedule trait and built-in implementations.
//!
//! A schedule drives factor propagation to convergence. The default
//! `EpsilonOrMax` performs one TeamSum sweep (setup) then alternating
//! `EpsilonOrMax` performs one `TeamSum` sweep (setup) then alternating
//! forward/backward sweeps over the iterating factors until the max
//! delta drops below epsilon or `max` iterations is reached.
@@ -23,7 +23,7 @@ pub trait Schedule: Send + Sync {
/// Default schedule: sweep forward then backward until step ≤ eps or iter == max.
///
/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the
/// same factor layout (TeamSums first, then alternating RankDiff/Trunc pairs).
/// same factor layout (`TeamSums` first, then alternating RankDiff/Trunc pairs).
#[derive(Debug, Clone, Copy)]
pub struct EpsilonOrMax {
pub eps: f64,
+6 -1
View File
@@ -2,7 +2,7 @@ use crate::{Index, competitor::Competitor, drift::Drift, time::Time};
/// Dense Vec-backed store for competitor state in History.
///
/// Indexed directly by Index.0, eliminating HashMap hashing in the
/// Indexed directly by Index.0, eliminating `HashMap` hashing in the
/// forward/backward sweep. Uses `Vec<Option<Competitor<T, D>>>` so slots can be
/// absent without an explicit present mask.
#[derive(Debug)]
@@ -21,6 +21,7 @@ impl<T: Time, D: Drift<T>> Default for CompetitorStore<T, D> {
}
impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
@@ -39,6 +40,7 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
self.competitors[idx.0] = Some(competitor);
}
#[must_use]
pub fn get(&self, idx: Index) -> Option<&Competitor<T, D>> {
self.competitors.get(idx.0).and_then(|slot| slot.as_ref())
}
@@ -49,14 +51,17 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
.and_then(|slot| slot.as_mut())
}
#[must_use]
pub fn contains(&self, idx: Index) -> bool {
self.get(idx).is_some()
}
#[must_use]
pub fn len(&self) -> usize {
self.n_present
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.n_present == 0
}
+2 -2
View File
@@ -1,8 +1,8 @@
use crate::{Index, time_slice::Skill};
/// Dense Vec-backed store for per-agent skill state within a TimeSlice.
/// Dense Vec-backed store for per-agent skill state within a `TimeSlice`.
///
/// Indexed directly by Index.0, eliminating HashMap hashing in the inner
/// Indexed directly by Index.0, eliminating `HashMap` hashing in the inner
/// convergence loop. Uses a parallel `present` mask so iteration skips
/// absent slots without incurring per-slot Option overhead in the hot path.
#[derive(Debug, Default)]
+7
View File
@@ -370,6 +370,13 @@ impl<T: Time> TimeSlice<T> {
.collect::<HashMap<_, _>>()
}
/// Sweep this slice's events once, starting at index `from`.
///
/// # Panics
///
/// Panics if an event references a competitor with no entry in this
/// slice's skill store. `add_events` inserts one for every participant, so
/// this cannot happen for slices built through the public API.
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
if from == 0 && self.color_groups_dirty {
self.recompute_color_groups();