diff --git a/Cargo.toml b/Cargo.toml index 87b6d4d..6f706e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/src/event.rs b/src/event.rs index 9e1579c..4e69b7d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -23,6 +23,7 @@ pub struct Team { } impl Team { + #[must_use] pub fn new() -> Self { Self { members: SmallVec::new(), diff --git a/src/event_builder.rs b/src/event_builder.rs index 15c077a..b5fcb54 100644 --- a/src/event_builder.rs +++ b/src/event_builder.rs @@ -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>(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)) } diff --git a/src/factor/margin.rs b/src/factor/margin.rs index 4d5d078..3721552 100644 --- a/src/factor/margin.rs +++ b/src/factor/margin.rs @@ -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 { diff --git a/src/factor/mod.rs b/src/factor/mod.rs index 85e9060..04a2dc5 100644 --- a/src/factor/mod.rs +++ b/src/factor/mod.rs @@ -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] } diff --git a/src/factor/rank_diff.rs b/src/factor/rank_diff.rs index ce64a95..d36f568 100644 --- a/src/factor/rank_diff.rs +++ b/src/factor/rank_diff.rs @@ -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 { diff --git a/src/factor/trunc.rs b/src/factor/trunc.rs index 4e825a9..f2d04a3 100644 --- a/src/factor/trunc.rs +++ b/src/factor/trunc.rs @@ -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, } impl TruncFactor { + #[must_use] pub fn new(diff: VarId, margin: f64, tie: bool) -> Self { Self { diff, diff --git a/src/game.rs b/src/game.rs index 0d86359..938588e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -145,6 +145,7 @@ impl> OwnedGame { } } + #[must_use] pub fn posteriors(&self) -> Vec> { self.likelihoods .iter() @@ -153,6 +154,7 @@ impl> OwnedGame { .collect() } + #[must_use] pub fn log_evidence(&self) -> f64 { self.log_evidence } @@ -409,6 +411,7 @@ impl<'a, T: Time, D: Drift> Game<'a, T, D> { self.likelihoods = likelihoods; } + #[must_use] pub fn posteriors(&self) -> Vec> { self.likelihoods .iter() @@ -422,12 +425,21 @@ impl<'a, T: Time, D: Drift> Game<'a, T, D> { .collect::>() } + #[must_use] pub fn log_evidence(&self) -> f64 { self.log_evidence } } impl> 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]], outcome: crate::Outcome, @@ -478,6 +490,12 @@ impl> 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]], outcome: crate::Outcome, @@ -515,6 +533,12 @@ impl> 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, b: &Rating, @@ -525,6 +549,10 @@ impl> 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], outcome: crate::Outcome, diff --git a/src/gaussian.rs b/src/gaussian.rs index 08d17b1..52fa2b7 100644 --- a/src/gaussian.rs +++ b/src/gaussian.rs @@ -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(), diff --git a/src/history.rs b/src/history.rs index 813ebc0..8f021c6 100644 --- a/src/history.rs +++ b/src/history.rs @@ -198,6 +198,7 @@ impl Default for History { } impl History { + #[must_use] pub fn builder() -> HistoryBuilder { HistoryBuilder::default() } @@ -205,6 +206,7 @@ impl History { impl History { /// Like `builder()` but uses a custom key type `K` instead of the default `&'static str`. + #[must_use] pub fn builder_with_key() -> HistoryBuilder { HistoryBuilder { mu: MU, @@ -552,7 +554,11 @@ impl, O: Observer, K: Eq + Hash + Clone> History Vec { assert_eq!(teams.len(), 2, "predict_outcome T2: 2 teams only"); let gather = |team: &[&K]| -> Gaussian { @@ -574,6 +580,15 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result { use std::time::Instant; @@ -830,6 +845,13 @@ impl, O: Observer, K: Eq + Hash + Clone> History(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError> where K: Borrow, @@ -847,6 +869,13 @@ impl, O: Observer, K: Eq + Hash + Clone> History(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError> where K: Borrow, @@ -870,6 +899,17 @@ impl, O: Observer, K: Eq + Hash + Clone> History= 3`, which ties every loser. pub fn add_events(&mut self, events: I) -> Result<(), InferenceError> where I: IntoIterator>, diff --git a/src/key_table.rs b/src/key_table.rs index af6e14c..40953d6 100644 --- a/src/key_table.rs +++ b/src/key_table.rs @@ -25,6 +25,7 @@ impl KeyTable 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() } diff --git a/src/lib.rs b/src/lib.rs index 75be276..5813d0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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(xs: &[T], reverse: bool) -> Vec { /// 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, diff --git a/src/outcome.rs b/src/outcome.rs index 17777d1..3200b75 100644 --- a/src/outcome.rs +++ b/src/outcome.rs @@ -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(), diff --git a/src/schedule.rs b/src/schedule.rs index a0606b3..0614907 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -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, diff --git a/src/storage/competitor_store.rs b/src/storage/competitor_store.rs index 25f72aa..6ac7789 100644 --- a/src/storage/competitor_store.rs +++ b/src/storage/competitor_store.rs @@ -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>>` so slots can be /// absent without an explicit present mask. #[derive(Debug)] @@ -21,6 +21,7 @@ impl> Default for CompetitorStore { } impl> CompetitorStore { + #[must_use] pub fn new() -> Self { Self::default() } @@ -39,6 +40,7 @@ impl> CompetitorStore { self.competitors[idx.0] = Some(competitor); } + #[must_use] pub fn get(&self, idx: Index) -> Option<&Competitor> { self.competitors.get(idx.0).and_then(|slot| slot.as_ref()) } @@ -49,14 +51,17 @@ impl> CompetitorStore { .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 } diff --git a/src/storage/skill_store.rs b/src/storage/skill_store.rs index 5732641..00bcf21 100644 --- a/src/storage/skill_store.rs +++ b/src/storage/skill_store.rs @@ -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)] diff --git a/src/time_slice.rs b/src/time_slice.rs index 0704849..e58211b 100644 --- a/src/time_slice.rs +++ b/src/time_slice.rs @@ -370,6 +370,13 @@ impl TimeSlice { .collect::>() } + /// 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>(&mut self, from: usize, agents: &CompetitorStore) { if from == 0 && self.color_groups_dirty { self.recompute_color_groups();