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:
@@ -0,0 +1,87 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The build most consumers get.
|
||||
- name: default
|
||||
features: ""
|
||||
profile: ""
|
||||
# Most numerical goldens need `approx` for assert_ulps_eq.
|
||||
- name: approx
|
||||
features: "--features approx"
|
||||
profile: ""
|
||||
# The parallel path, including tests/determinism.rs.
|
||||
- name: rayon
|
||||
features: "--features approx,rayon"
|
||||
profile: ""
|
||||
# Critical: debug_assert! is compiled out here, which is where the
|
||||
# tie/p_draw and score_sigma validation actually has to hold.
|
||||
- name: release
|
||||
features: "--features approx"
|
||||
profile: "--release"
|
||||
name: test (${{ matrix.name }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }}
|
||||
- run: cargo test ${{ matrix.profile }} ${{ matrix.features }} --doc
|
||||
|
||||
determinism:
|
||||
name: determinism across thread counts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# Posteriors must be bit-identical regardless of how many rayon workers
|
||||
# run the color-group sweep.
|
||||
- run: |
|
||||
for threads in 1 2 4 8; do
|
||||
echo "== RAYON_NUM_THREADS=$threads =="
|
||||
RAYON_NUM_THREADS=$threads cargo test --release \
|
||||
--features approx,rayon --test determinism
|
||||
done
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# rustfmt.toml uses nightly-only options (imports_granularity).
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo +nightly fmt --check
|
||||
|
||||
msrv:
|
||||
name: minimum supported Rust version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@1.85.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo check --all-targets --features approx,rayon
|
||||
+11
@@ -2,6 +2,17 @@
|
||||
name = "trueskill-tt"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
||||
repository = "https://git.aceofba.se/logaritmisk/trueskill-tt"
|
||||
readme = "README.md"
|
||||
keywords = ["trueskill", "rating", "bayesian", "elo", "skill"]
|
||||
categories = ["algorithms", "science", "game-development"]
|
||||
# TODO: pick a licence before publishing. `cargo publish` rejects a crate
|
||||
# without `license` (or `license-file`), and without one the source carries no
|
||||
# stated terms. The Rust convention is `license = "MIT OR Apache-2.0"` plus the
|
||||
# matching LICENSE-MIT / LICENSE-APACHE files.
|
||||
exclude = ["/docs", "/benches/*.txt", "/temp", "/.gitea"]
|
||||
|
||||
[lib]
|
||||
bench = false
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
alias b := bench
|
||||
alias t := test
|
||||
|
||||
# Run the full test suite across the feature combinations CI checks.
|
||||
test:
|
||||
cargo test
|
||||
cargo test --features approx
|
||||
cargo test --features approx,rayon
|
||||
cargo test --release --features approx
|
||||
|
||||
# Fast inner-loop tests.
|
||||
check:
|
||||
cargo test --features approx
|
||||
|
||||
# Posteriors must be bit-identical across rayon worker counts.
|
||||
determinism:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
for threads in 1 2 4 8; do
|
||||
echo "== RAYON_NUM_THREADS=$threads =="
|
||||
RAYON_NUM_THREADS=$threads cargo test --release \
|
||||
--features approx,rayon --test determinism
|
||||
done
|
||||
|
||||
lint:
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
# Always nightly: rustfmt.toml uses nightly-only options.
|
||||
fmt:
|
||||
cargo +nightly fmt
|
||||
|
||||
fmt-check:
|
||||
cargo +nightly fmt --check
|
||||
|
||||
# Everything CI runs.
|
||||
ci: fmt-check lint test determinism
|
||||
|
||||
store:
|
||||
cargo bench -- --save-baseline base
|
||||
|
||||
+8
-4
@@ -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
@@ -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
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user