diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..b02397a --- /dev/null +++ b/MIGRATING.md @@ -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 } +struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> } + +// after +struct Ladder { history: History } +struct Analysis<'h> { joint: Joint<'h> } +``` + +`History` — 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::::new()` becomes +`HistoryBuilder::::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` 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. diff --git a/README.md b/README.md index 7f21482..d7195a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cliff.toml b/cliff.toml index 5dce1aa..1d5902a 100644 --- a/cliff.toml +++ b/cliff.toml @@ -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)" }, ] diff --git a/src/lib.rs b/src/lib.rs index 1ebee34..70198a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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: