docs: document the whole public surface and deny(missing_docs)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-09 21:53:50 +02:00
co-authored by Claude Opus 5
parent 78810c0344
commit 31564b71a0
13 changed files with 664 additions and 67 deletions
+80 -24
View File
@@ -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<T: Time, D: Drift<T>, O: Observer<T>, 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<D2: Drift<T>>(self, drift: D2) -> HistoryBuilder<T, D2, O, K> {
HistoryBuilder {
drift,
@@ -248,6 +272,12 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, 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<O2: Observer<T>>(self, observer: O2) -> HistoryBuilder<T, D, O2, K> {
HistoryBuilder {
mu: self.mu,
@@ -264,6 +294,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, 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<T, D, O, K> {
History {
size: 0,
@@ -417,6 +450,12 @@ impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
}
impl History<i64, ConstantDrift, NullObserver, &'static str> {
/// 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<i64, ConstantDrift, NullObserver, &'static str> {
HistoryBuilder::default()
}
@@ -444,6 +483,11 @@ impl<T: Time, K: Eq + Hash + Clone> HistoryBuilder<T, ConstantDrift, NullObserve
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
/// 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<Q>(&mut self, key: &Q) -> Index
where
K: Borrow<Q>,
@@ -452,6 +496,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
self.keys.get_or_create(key)
}
/// Resolve an existing key to its [`Index`], or `None` if the history has
/// never seen it.
///
/// The read-only counterpart of [`History::intern`]: it never creates.
#[must_use]
pub fn lookup<Q>(&self, key: &Q) -> Option<Index>
where
@@ -731,6 +779,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
.then(|| self.competitors[idx].rating)
}
/// The competitor's latest posterior skill, or `None` if the history has
/// never seen the key or they have no appearances.
///
/// "Latest" is their own last appearance, which need not be the last slice
/// in the history. For everyone at once — a leaderboard — use
/// [`History::current_skills`], which is one pass rather than one per key.
///
/// This reads whatever the fit currently holds. It does not converge, and
/// it does not check that a previous `converge` succeeded.
#[must_use]
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
where
@@ -1144,8 +1201,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
// accident.
if self.beta == 0.0 && gathered.iter().flatten().all(|g| g.sigma() == 0.0) {
return Err(InferenceError::InvalidParameter {
name: "beta is zero and every skill is a point mass, so there is \
no performance distribution to predict from",
name: "beta with point-mass skills",
value: 0.0,
});
}
@@ -1160,7 +1216,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
///
/// # Errors
///
/// As [`History::member_skills`].
/// As `member_skills`.
fn performances(&self, teams: &[&[&K]]) -> Result<(Vec<Gaussian>, Vec<usize>), InferenceError>
where
K: std::fmt::Debug,
@@ -1566,7 +1622,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
/// substitution — and returns exactly what the one-shot call would.
///
/// ```
/// # use smallvec::smallvec;
/// # use trueskill_tt::smallvec::smallvec;
/// # use trueskill_tt::{Event, History, Member, Outcome, Team};
/// # let mut h = History::builder().score_sigma(1.0).build();
/// # let round = |x, y, sx, sy, t| Event {
@@ -1928,7 +1984,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
///
/// 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
/// rating is finite, the ordering looks sensible, and nothing about the
/// 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.
@@ -2248,14 +2304,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
competitor::clean(self.competitors.values_mut(), true);
let mut this_agent = Vec::with_capacity(1024);
let mut these_competitors = Vec::with_capacity(1024);
for competitor in composition.iter().flatten().flatten() {
if this_agent.contains(competitor) {
if these_competitors.contains(competitor) {
continue;
}
this_agent.push(*competitor);
these_competitors.push(*competitor);
// From `declared` rather than `priors`: a competitor configured by
// `register` before any event has nothing in this batch's map.
@@ -2355,17 +2411,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
time_slice.new_forward_info(&self.competitors);
}
for agent_idx in &this_agent {
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
for competitor_idx in &these_competitors {
if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) {
skill.elapsed = time_slice::compute_elapsed(
self.competitors[*agent_idx].last_time.as_ref(),
self.competitors[*competitor_idx].last_time.as_ref(),
&time_slice.time,
);
let competitor = self.competitors.get_mut(*agent_idx).unwrap();
let competitor = self.competitors.get_mut(*competitor_idx).unwrap();
competitor.last_time = Some(time_slice.time);
competitor.message = Some(time_slice.forward_prior_out(agent_idx));
competitor.message = Some(time_slice.forward_prior_out(competitor_idx));
}
}
@@ -2400,11 +2456,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
&self.competitors,
);
for agent_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(agent_idx).unwrap();
for competitor_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(competitor_idx).unwrap();
competitor.last_time = Some(t);
competitor.message = Some(time_slice.forward_prior_out(&agent_idx));
competitor.message = Some(time_slice.forward_prior_out(&competitor_idx));
}
k += 1;
@@ -2422,11 +2478,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
let time_slice = &self.time_slices[k];
for agent_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(agent_idx).unwrap();
for competitor_idx in time_slice.skills.keys() {
let competitor = self.competitors.get_mut(competitor_idx).unwrap();
competitor.last_time = Some(t);
competitor.message = Some(time_slice.forward_prior_out(&agent_idx));
competitor.message = Some(time_slice.forward_prior_out(&competitor_idx));
}
k += 1;
@@ -2440,17 +2496,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
time_slice.new_forward_info(&self.competitors);
for agent_idx in &this_agent {
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
for competitor_idx in &these_competitors {
if let Some(skill) = time_slice.skills.get_mut(*competitor_idx) {
skill.elapsed = time_slice::compute_elapsed(
self.competitors[*agent_idx].last_time.as_ref(),
self.competitors[*competitor_idx].last_time.as_ref(),
&time_slice.time,
);
let competitor = self.competitors.get_mut(*agent_idx).unwrap();
let competitor = self.competitors.get_mut(*competitor_idx).unwrap();
competitor.last_time = Some(time_slice.time);
competitor.message = Some(time_slice.forward_prior_out(agent_idx));
competitor.message = Some(time_slice.forward_prior_out(competitor_idx));
}
}