Files
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

7.9 KiB

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, predictions from a broken fit, and Gaussian's operators.

Type parameters: K comes first

K was last, so naming a history meant writing all four parameters to change the one that matters.

// 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.

// 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.

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:

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.

// 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.

// 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.