Compare commits
3
Commits
61da3aca33
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cba3d10f6 | ||
|
|
5d9501307e | ||
|
|
327324c411 |
+61
-10
@@ -2,6 +2,65 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## 0.8.0 - 2026-09-08
|
||||||
|
|
||||||
### Breaking Changes
|
### 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
|
- feat: add EventBuilder::members for per-member configuration
|
||||||
|
|
||||||
### Other (unconventional)
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
- Merge branch 'fix/ingestion-shape'
|
- chore: Release trueskill-tt version 0.8.0
|
||||||
- Merge branch 'feat/convergence-strictness'
|
|
||||||
- Merge branch 'fix/non-finite-weights'
|
|
||||||
- Merge branch 'test/close-coverage-gaps'
|
|
||||||
- Merge branch 'fix/game-boundary'
|
|
||||||
|
|
||||||
### Testing
|
### 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
|
- chore: Release trueskill-tt version 0.7.0
|
||||||
|
|
||||||
### Other (unconventional)
|
|
||||||
|
|
||||||
- Merge branch 'feat/joint-handle'
|
|
||||||
|
|
||||||
## 0.6.0 - 2026-09-08
|
## 0.6.0 - 2026-09-08
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "trueskill-tt"
|
name = "trueskill-tt"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
|
||||||
|
|||||||
+204
@@ -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.
|
||||||
@@ -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
|
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
|
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
|
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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ commit_parsers = [
|
|||||||
{ message = "^test", group = "Testing" },
|
{ message = "^test", group = "Testing" },
|
||||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||||
{ message = "^chore", group = "Miscellaneous Tasks" },
|
{ 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 = ".*security", group = "Security" },
|
||||||
{ body = ".*", group = "Other (unconventional)" },
|
{ body = ".*", group = "Other (unconventional)" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
//! This is a Rust port of
|
//! This is a Rust port of
|
||||||
//! [TrueSkillThroughTime.py](https://github.com/glandfried/TrueSkillThroughTime.py).
|
//! [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
|
//! # Getting started
|
||||||
//!
|
//!
|
||||||
//! Record results, converge, then read off skills:
|
//! Record results, converge, then read off skills:
|
||||||
|
|||||||
Reference in New Issue
Block a user