2 Commits
Author SHA1 Message Date
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
4 changed files with 215 additions and 1 deletions
+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: