diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d8ef7e9 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 1cc081b..6e74888 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/Justfile b/Justfile index dd2a448..5a22ca3 100644 --- a/Justfile +++ b/Justfile @@ -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 diff --git a/src/game.rs b/src/game.rs index 7fc8f84..0d86359 100644 --- a/src/game.rs +++ b/src/game.rs @@ -454,10 +454,14 @@ impl> 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; diff --git a/src/history.rs b/src/history.rs index ee8b904..3cfec49 100644 --- a/src/history.rs +++ b/src/history.rs @@ -578,10 +578,12 @@ impl, O: Observer, K: Eq + Hash + Clone> History 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::{