chore: add CI, crate metadata, and crate-level documentation

There was no CI of any kind — no workflows directory at all — despite a full
release pipeline (release.toml, cliff.toml, a maintained CHANGELOG, three
tagged releases). The workflow covers the feature combinations that actually
have distinct behaviour, including a release-profile job: `debug_assert!` is
compiled out there, which is exactly where the validation this branch added
has to hold, and a debug-only suite would never have seen it. Determinism is
checked at RAYON_NUM_THREADS of 1, 2, 4 and 8.

The Justfile gains test/lint/fmt/determinism recipes so the same checks run
locally with one command, and `just ci` runs the lot.

`Cargo.toml` had only name, version and edition, so `cargo publish` would
have been rejected. Added description, repository, readme, keywords,
categories, exclude, and `rust-version = "1.85"` — the edition-2024 floor,
now verified by a CI job. Two let-chains introduced earlier on this branch
would have pushed that to 1.88; they are rewritten to keep the floor where
it was.

`src/lib.rs` had no `//!` header at all, so the docs.rs landing page would
have been a bare symbol list — conspicuous given every other module has one.
It now explains what Through Time does differently, and carries three
runnable examples (which `cargo test --doc` checks, where previously there
was nothing to check), including the draw/p_draw interaction that is the
easiest way to get an error out of this crate.

`cargo publish --dry-run` now packages and verifies cleanly. The only
remaining blocker is `license`, which is yours to choose — noted as a TODO
in the manifest rather than picked unilaterally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
This commit is contained in:
2026-08-04 22:03:39 +02:00
co-authored by Claude Opus 5
parent 6030dc78de
commit 9506fed4b3
6 changed files with 233 additions and 8 deletions
+8 -4
View File
@@ -454,10 +454,14 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
got: "Outcome::Scored",
})?;
if options.p_draw == 0.0
&& let Some(tied) = crate::first_tied_pair(ranks)
{
return Err(crate::InferenceError::TieWithoutDrawProbability { teams: tied });
let tied = if options.p_draw == 0.0 {
crate::first_tied_pair(ranks)
} else {
None
};
if let Some(teams) = tied {
return Err(crate::InferenceError::TieWithoutDrawProbability { teams });
}
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
+6 -4
View File
@@ -578,10 +578,12 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
// than going through `Outcome`.
if self.p_draw == 0.0 {
for (event_results, kind) in results.iter().zip(kinds.iter()) {
if matches!(kind, EventKind::Ranked)
&& let Some(tied) = crate::first_tied_output(event_results)
{
return Err(InferenceError::TieWithoutDrawProbability { teams: tied });
if !matches!(kind, EventKind::Ranked) {
continue;
}
if let Some(teams) = crate::first_tied_output(event_results) {
return Err(InferenceError::TieWithoutDrawProbability { teams });
}
}
}
+86
View File
@@ -1,3 +1,89 @@
//! TrueSkill Through Time — Bayesian skill rating over a time axis.
//!
//! 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.
//!
//! This is a Rust port of
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
//!
//! # Getting started
//!
//! Record results, converge, then read off skills:
//!
//! ```
//! 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)?;
//!
//! let report = history.converge()?;
//! assert!(report.converged);
//!
//! let alice = history.current_skill("alice").unwrap();
//! assert!(alice.mu() > 0.0, "alice won every game she played");
//! # Ok::<(), trueskill_tt::InferenceError>(())
//! ```
//!
//! Teams, weights, explicit rankings and continuous scores go through the
//! fluent event builder:
//!
//! ```
//! 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])
//! .commit()?;
//!
//! history.converge()?;
//! # Ok::<(), trueskill_tt::InferenceError>(())
//! ```
//!
//! # Draws need a draw probability
//!
//! A `p_draw` of zero asserts that draws cannot happen, so a tied result has
//! no representable likelihood and is rejected:
//!
//! ```
//! 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 applies to [`Outcome::winner`] for three or more teams, which
//! ties every loser. Configure a positive `p_draw` for those.
//!
//! # Core types
//!
//! - [`History`] — the top-level container: ingests events, runs
//! forward/backward message passing, and answers queries.
//! - [`Gaussian`] — the probability type, stored in natural parameters
//! (`pi = 1/sigma²`, `tau = mu/sigma²`) so message passing is add/subtract.
//! - [`Game`] — one match in isolation, for scoring a hypothetical without a
//! history.
//! - [`Outcome`] — how a match ended: ranks, or continuous scores.
//! - [`Rating`] — a competitor's static configuration (prior, `beta`, drift).
//!
//! # Feature flags
//!
//! - `approx` — implements [`approx`](https://docs.rs/approx) equality traits
//! for [`Gaussian`]. Useful in tests.
//! - `rayon` — parallelises the within-slice sweep and the per-slice passes of
//! `learning_curves`/`log_evidence`. Opt-in; results stay bit-identical
//! regardless of worker count.
#![forbid(unsafe_code)]
use std::{