3 Commits
Author SHA1 Message Date
logaritmisk 2cba3d10f6 chore: Release trueskill-tt version 0.9.0 2026-09-10 07:50:43 +02:00
logaritmisk 5d9501307e Merge chore/release-0.9.0: migration guide and changelog cleanup 2026-09-10 07:47:20 +02:00
logaritmiskandClaude Opus 5 327324c411 docs: add a migration guide for 0.9.0, and drop merge noise from the changelog
Twenty-one breaking changes in one release, with a live consumer. The
changelog lists them; `MIGRATING.md` says what to do about them, leading
with the three that change what an existing, *compiling* call returns —
unknown keys, predictions from a broken fit, and `Gaussian`'s operators
— since those are the ones the compiler will not find for you.

Every "after" snippet was compiled, not written from memory, and doing
so caught three errors in my own guide:

- `log_evidence_for(&[&"alice"])` does not compile at `K = String`. The
  right spelling is `&["alice"]`, which works at *both* key types —
  checked, because a guide that is right for half its readers is worse
  than no guide.
- the same for `filtered_log_evidence_for`
- the `Analysis<'h> { joint: Joint<'h> }` example needs a history at the
  default key type; pairing it with a `History<String>` does not compile

git-cliff skips merge commits now. Every branch lands with `--no-ff`, so
a release's merges outnumber its real commits and say nothing the merged
ones do not — 0.9.0's changelog had fourteen lines of them under "Other
(unconventional)". `ci:` commits get a group instead of falling through
to that catch-all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-10 07:47:19 +02:00
6 changed files with 277 additions and 12 deletions
+61 -10
View File
@@ -2,6 +2,65 @@
All notable changes to this project will be documented in this file.
## 0.9.0 - 2026-09-10
### Breaking Changes
- fix!: propagate NaN through the convergence reduction
- fix!: collapse a drift too small to represent, on a relative threshold
- fix!: report an unresolvable prediction grid instead of clamping
- fix!: validate the constructors below HistoryBuilder
- fix!: seal ConstantDrift's field so gamma can be validated
- fix!: make the Time generic reachable
- refactor!: un-export six types that no caller could reach
- fix!: correct eight wrong `# Errors` sections and seal the error variants
- fix!: per-key queries report unknown keys instead of a plausible constant
- fix!: no prediction path answers from a fit it cannot answer from
- docs!: one name for score noise, and say which of beta/sigma to turn
- fix!: non_exhaustive on ConvergenceReport, and not on the options structs
- feat!: prediction and joint queries take borrowed keys
- feat!: Game is the type you get, and one_v_one returns one
- feat!: Gaussian's EP operations stop wearing arithmetic's clothes
- refactor!: retire Index, intern and lookup
- refactor!: scores_with_noise, and History::quality
- refactor!: the joint is reached through Joint, not mirrored on History
- refactor!: K comes first in History, HistoryBuilder and Joint
- perf!: sparse Cholesky with an AMD ordering for the joint
- refactor!: typed discriminators for InferenceError
### Bug Fixes
- fix: take quality's determinant ratio in log space
- fix: keep the truncated variance representable in the far tail
- fix: route the last three transcendentals through libm, and enforce it
- fix: make posterior_of reproducible across processes
- fix: warn on dropped builders and values; stop exporting EP internals
### CI
- ci: measure the runner's own benchmark variance, and fix the joint bench
### Documentation
- docs: document the whole public surface and deny(missing_docs)
- docs: add a migration guide for 0.9.0, and drop merge noise from the changelog
### Features
- feat: add the missing trait impls and make `#[must_use]` consistent
- feat: complete the evidence matrix and add current_skills
- feat: PartialEq on the config types, and pin the public trait impls
- feat: HistoryBuilder::default_rating_for, a rule instead of a roll call
### Refactor
- refactor: one word per concept
### Testing
- test: scale the ceiling sweep by build profile
- test: make the determinism test exercise the parallel sweep
## 0.8.0 - 2026-09-08
### Breaking Changes
@@ -25,13 +84,9 @@ All notable changes to this project will be documented in this file.
- feat: add EventBuilder::members for per-member configuration
### Other (unconventional)
### Miscellaneous Tasks
- Merge branch 'fix/ingestion-shape'
- Merge branch 'feat/convergence-strictness'
- Merge branch 'fix/non-finite-weights'
- Merge branch 'test/close-coverage-gaps'
- Merge branch 'fix/game-boundary'
- chore: Release trueskill-tt version 0.8.0
### Testing
@@ -47,10 +102,6 @@ All notable changes to this project will be documented in this file.
- chore: Release trueskill-tt version 0.7.0
### Other (unconventional)
- Merge branch 'feat/joint-handle'
## 0.6.0 - 2026-09-08
### Breaking Changes
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "trueskill-tt"
version = "0.8.0"
version = "0.9.0"
edition = "2024"
rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
+204
View File
@@ -0,0 +1,204 @@
# Migrating
## 0.8.0 → 0.9.0
Twenty-one breaking changes. Nearly all of them are mechanical, and the
compiler finds every one — nothing here changes behaviour silently.
Three exceptions are worth reading before you start, because they change
what an existing, compiling call *returns*: [unknown keys](#unknown-keys-are-reported-not-skipped),
[predictions from a broken fit](#predictions-refuse-a-fit-they-cannot-answer-from),
and [`Gaussian`'s operators](#gaussians-operators-are-gone).
### Type parameters: `K` comes first
`K` was last, so naming a history meant writing all four parameters to change
the one that matters.
```rust
// before
struct Ladder { history: History<i64, ConstantDrift, NullObserver, String> }
struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> }
// after
struct Ladder { history: History<String> }
struct Analysis<'h> { joint: Joint<'h> }
```
`History<K, T, D, O, R>` — key, time, drift, observer, rating rule — all
defaulted. `HistoryBuilder` matches. There is a fifth parameter now (`R`), and
you will never write it unless you use `default_rating_for`.
`HistoryBuilder::<Untimed, _, _, String>::new()` becomes
`HistoryBuilder::<String, Untimed>::new()`.
### Predictions and joint queries take borrowed keys
At `K = String` a string literal used to be impossible, and asking "who wins"
cost four allocations of temporaries that all had to outlive the call.
```rust
// before, at K = String
let ta = vec![a.to_string()];
let ra: Vec<&String> = ta.iter().collect();
let tb = vec![b.to_string()];
let rb: Vec<&String> = tb.iter().collect();
let teams: Vec<&[&String]> = vec![&ra, &rb];
h.predict_win_probabilities(&teams)?;
// after, at either key type
h.predict_win_probabilities(&[&["alice"], &["bob"]])?;
h.posterior_of(&[("alice", 1.0), ("bob", -1.0)])?;
```
At `K = &'static str` the old `&[&[&"a"]]` spelling still compiles — `Q` infers
to `&str` and the two shapes coincide — so this is only a break for owned keys,
where nothing compiled before.
One cost: `predict_outcome(&[])` can no longer infer the key type. Annotate it,
`let none: &[&[&str]] = &[];`. It bites only on that degenerate call.
### Unknown keys are reported, not skipped
**Read this one.** `log_evidence_for` used to `filter_map` unknown keys away,
and an empty target list means *no restriction* downstream — so a list of
entirely unknown keys returned the **whole-history** value. Measured:
`log_evidence_for(["typo"])` returned exactly `log_evidence()`. On the one
workload it is documented for, leave-one-out cross-validation, that is the
un-held-out score.
```rust
let e = h.log_evidence_for(&["alice"])?; // now Result
let curve = h.learning_curve("alice"); // now Option
```
Note `&["alice"]`, not `&[&"alice"]`. These take borrowed keys like the
prediction methods, so one spelling works at both key types.
`learning_curve` and `filtered_learning_curve` return `Option`: `None` is "never
heard of this key", `Some(vec![])` is "known, has not played". They used to be
the same empty `Vec`.
### Predictions refuse a fit they cannot answer from
**Read this one too.** `converge` already refused to report a NaN fit, but
nothing stopped a caller ignoring that error and predicting anyway. On a
NaN-poisoned fit, `quality` returned `Ok(NaN)`, `predict_outcome().total()` was
`NaN`, and `predict_win_probabilities` returned `Ok([0.0, 0.0])` — finite,
plausible, and summing to zero against a doc promising one.
Every `predict_*` path now returns `Err(NonFiniteSkill { .. })` there, and
`Err(NoPerformanceVariance)` when `beta` is zero and every skill is a point
mass. If you were ignoring `converge`'s error, you will start seeing these.
### `Gaussian`'s operators are gone
`Mul`, `Div`, `Add` and `Sub` were the EP product, cavity and variance-space
convolutions, not arithmetic — `N(10,2) * N(4,3)` is `N(8.15, 1.66)`, and
`a / c` could leave a negative precision whose `mu()` printed a confident `0`.
They are `pub(crate)` inherent methods now. The public surface is `from_ms`,
`from_mv`, `mu`, `sigma`, `variance`, `probability_below`, `probability_above`;
`pi()` and `tau()` are internal. If you compared fits bit-for-bit on
`(pi, tau)`, compare `(mu, variance)` — same information, still exact.
### `Game` is the type you get
`Game::ranked` returned an `OwnedGame`, so `let g: Game = Game::ranked(..)?` did
not compile. Names swapped: `Game<T, D>` is public, `OwnedGame` is gone.
`one_v_one` returns a `Game` rather than `(Gaussian, Gaussian)`, so it can be
asked for `log_evidence()` like its siblings. For the old shape:
```rust
let post = Game::one_v_one(&a, &b, outcome, &opts)?.posteriors();
let (a_post, b_post) = (post[0][0], post[1][0]);
```
### The joint is reached through `Joint`
`History::posterior_of`, `posterior_of_at` and `expected_variance_reduction`
were one-shot wrappers that re-factorised on every call. They are gone.
```rust
// before — pays for the factorisation twice
let a = h.posterior_of(&terms)?;
let b = h.posterior_of(&other)?;
// after — pays once, and the borrow says so
let joint = h.joint()?;
let a = joint.posterior_of(&terms)?;
let b = joint.posterior_of(&other)?;
```
### `InferenceError` is typed
Six variants carried `&'static str` discriminators. Four enums replace them:
`Parameter`, `Shape`, `OutcomeKind`, `CompetitorField`.
```rust
// before
InferenceError::InvalidParameter { name: "drift_scale", value }
InferenceError::MismatchedShape { kind: "ranks vs teams", .. }
InferenceError::WrongOutcomeKind { context, expected, got } // three &str
// after
InferenceError::InvalidParameter { parameter: Parameter::DriftScale, value }
InferenceError::MismatchedShape { shape: Shape::OutcomeVsTeams, .. }
InferenceError::WrongOutcomeKind { expected: OutcomeKind::Ranked, got }
```
Variants that split or merged:
| before | after |
|---|---|
| `InvalidProbability { value }` | `InvalidParameter { parameter: Parameter::PDraw, value }` |
| `JointUnavailable { reason }` | `EmptyHistory`, `JointRequiresScoredEvents`, `NotPositiveDefinite` |
| `NonFiniteResult { context, step }` | `NonFiniteStep { context, step }` (convergence), `NonFiniteSkill { mu, sigma }` (prediction) |
Every struct variant is `#[non_exhaustive]`, so `match` with a `..` and
construct through the library.
### Renames
| before | after |
|---|---|
| `History::predict_quality` | `History::quality` |
| `Outcome::scores_with_sigma` | `Outcome::scores_with_noise` |
| `EventBuilder::scores_with_sigma` | `EventBuilder::scores_with_noise` |
| `Outcome::Scored { sigma }` | `Outcome::Scored { score_sigma }` |
| `OwnedGame` | `Game` |
### Removed
`History::intern`, `History::lookup` and `Index`. Nothing public ever accepted
an `Index`, so there was nothing to do with one. `current_skill`, `rating` and
`learning_curve` answer "does this history know this key" and all take a
borrowed key.
`TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`, `Competitor` and `N01`
are no longer exported. None was obtainable from a `History`.
### Warnings, not errors
`#[must_use]` now sits on the value types, so a dropped `EventBuilder` — an
event you forgot to `.commit()`, previously a silent no-op — warns. So do
dropped `Team`, `Member`, `Outcome` and `Joint` values. A `-D warnings` build
will need updating.
### Nothing to do, but worth knowing
The joint factorisation is sparse with an AMD fill-reducing ordering:
**745 ms → 1.11 ms** on a 1976-appearance fixture, and near-linear scaling where
it was cubic. Results are unchanged; `feral-amd` is a new dependency (two
crates, both `#![forbid(unsafe_code)]`).
`HistoryBuilder::gamma(f64)` is shorthand for
`.drift(ConstantDrift::new(gamma))`.
`History::current_skills()` is the leaderboard query — every competitor's latest
posterior in one pass, rather than a full smoothed curve each.
`History::filtered_log_evidence_for(&["alice"])` completes the evidence matrix:
forward-only *and* key-restricted, which is what per-competitor prequential
scoring needs.
+2 -1
View File
@@ -419,7 +419,8 @@ expensive than `quality()`. Scoring every pairing among `n` competitors is
Every box on the old todo list is ticked, so it has been retired; open work
lives in the issue tracker instead. The crate is in use and the API is still
moving — breaking changes are batched into minor releases rather than dribbled
out, and `CHANGELOG.md` records them.
out. `CHANGELOG.md` lists them and [`MIGRATING.md`](MIGRATING.md) explains what
to do about them.
## License
+5
View File
@@ -58,6 +58,11 @@ commit_parsers = [
{ message = "^test", group = "Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore", group = "Miscellaneous Tasks" },
{ message = "^ci", group = "CI" },
# Every branch lands with `--no-ff`, so a release's merge commits outnumber
# its real ones and say nothing the merged commits do not. They were
# filling an "Other (unconventional)" section with 14 lines of noise.
{ message = "^Merge ", skip = true },
{ body = ".*security", group = "Security" },
{ body = ".*", group = "Other (unconventional)" },
]
+4
View File
@@ -9,6 +9,10 @@
//! This is a Rust port of
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
//!
//! Upgrading? `MIGRATING.md` in the repository root covers every breaking
//! change, with the ones that alter what an existing call *returns* called out
//! first.
//!
//! # Getting started
//!
//! Record results, converge, then read off skills: