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"
|
name = "trueskill-tt"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
edition = "2024"
|
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]
|
[lib]
|
||||||
bench = false
|
bench = false
|
||||||
|
|||||||
@@ -1,4 +1,39 @@
|
|||||||
alias b := bench
|
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:
|
store:
|
||||||
cargo bench -- --save-baseline base
|
cargo bench -- --save-baseline base
|
||||||
|
|||||||
+8
-4
@@ -454,10 +454,14 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
got: "Outcome::Scored",
|
got: "Outcome::Scored",
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if options.p_draw == 0.0
|
let tied = if options.p_draw == 0.0 {
|
||||||
&& let Some(tied) = crate::first_tied_pair(ranks)
|
crate::first_tied_pair(ranks)
|
||||||
{
|
} else {
|
||||||
return Err(crate::InferenceError::TieWithoutDrawProbability { teams: tied });
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(teams) = tied {
|
||||||
|
return Err(crate::InferenceError::TieWithoutDrawProbability { teams });
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
|
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`.
|
// than going through `Outcome`.
|
||||||
if self.p_draw == 0.0 {
|
if self.p_draw == 0.0 {
|
||||||
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
||||||
if matches!(kind, EventKind::Ranked)
|
if !matches!(kind, EventKind::Ranked) {
|
||||||
&& let Some(tied) = crate::first_tied_output(event_results)
|
continue;
|
||||||
{
|
}
|
||||||
return Err(InferenceError::TieWithoutDrawProbability { teams: tied });
|
|
||||||
|
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)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
|
|||||||
Reference in New Issue
Block a user