`K` is the one type parameter people change, and it was last. Naming a
history in a struct field meant writing all four to say one thing:
struct Ladder { history: History<i64, ConstantDrift, NullObserver, String> }
struct Analysis<'h> { joint: Joint<'h, i64, ConstantDrift, NullObserver, &'static str> }
Now:
struct Ladder { history: History<String> }
struct Analysis<'h> { joint: Joint<'h> }
`History<K, T, D, O>`, all four defaulted. Bounds may reference later
parameters, so `D: Drift<T> = ConstantDrift` is legal in third position.
`Joint` gains the same defaults, so `Joint<'h, String>` spells it.
72 call sites swapped, and the reorder makes most of them shorter: 18
now read `History<String>` and the `&'static str` ones read `History`.
The two turbofished builders shrink from
`HistoryBuilder::<Untimed, _, _, String>::new()` to
`HistoryBuilder::<String, Untimed>::new()`.
`Joint` keeps `O` structurally, defaulted rather than removed. #72 notes
it never touches the observer, which is true — but it borrows the whole
`&'h History<K, T, D, O>` and calls `History::resolve_terms`, so dropping
the parameter means either a view type or moving that method off
`History`. The default already buys the entire user-visible benefit,
which was the spelling.
Refs #72.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`posterior_of`, `posterior_of_at` and `expected_variance_reduction`
existed twice: once on `Joint`, and once on `History` as one-shot
wrappers whose whole body was `self.joint()?.<same>(..)`.
The wrappers re-factorised on every call — their own docs said so,
warning the reader to take a `Joint` instead — and they were what
smuggled the scored-only precondition onto the flat surface. A user
following the quickstart builds a ranked history, sees `posterior_of` in
the method list, and it never works. `h.joint()?.posterior_of(..)` is
one call longer and tells the truth: you need a joint, and a joint needs
a scored history.
That leaves three tiers instead of a flat surface with a hidden
precondition: `History` fits and reads, `predict_*` forecasts, `Joint`
answers exact joint questions.
`predict_margin` was itself calling `self.posterior_of`; it goes through
`self.joint()?` directly now.
The `Joint` methods' docs referred back to the wrappers for their real
content ("Identical to `History::posterior_of`, without re-paying the
factorisation"), so they now carry it: what a linear functional means,
which appearance each competitor is read at, and why
`expected_variance_reduction` belongs on the handle.
`tests/joint_handle.rs` had three tests comparing the wrapper against
the handle. That comparison is gone, but the property behind it is not —
they now compare a *reused* joint against a *fresh* one per question,
which is the actual correctness claim behind caching the factorisation
(#51), without the wrapper in the middle.
Closes#78.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
Two names that described the wrong thing.
`scores_with_sigma(scores, sigma)` reads as "these scores have prior
sigma 2.0". The quantity is observation noise on the score *margin*, in
the units of the scores, and it is spelled `score_sigma` at every config
site — `HistoryBuilder::score_sigma`, `GameOptions::score_sigma`,
`EventKind::Scored { score_sigma }` — so this was the one place the
crate used a third meaning of "sigma" for it. Its own doc had to
disambiguate itself: "`sigma` overrides `HistoryBuilder::score_sigma`".
`scores_with_noise(scores, score_sigma)` on both `Outcome` and
`EventBuilder`.
`predict_quality` predicts nothing. Its own doc says it answers "is this
matchup *fair*", not "what will happen", and the `predict_*` family is
otherwise exactly the methods returning a probability or a distribution
over outcomes. `History::quality` also makes the free/method pair
consistent: free `quality` pairs with `History::quality` the way free
`expected_information_gain` already pairs with
`History::expected_information_gain`. The rule that was already being
followed and never stated — a free function scores a hypothetical from
explicit parameters, the same-named method asks it against the fit — is
now written on the method.
Closes#75. Refs #78 (part 4).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`Index` was public, `History::intern` and `History::lookup` returned
one, and no public method anywhere accepted one. It was a handle with
nowhere to go — and `key_table.rs` advertised the hot-path story it was
meant to enable ("power users can promote `&K` to `Index` and skip the
lookup"), which was never reachable through the public API.
It also shadowed `std::ops::Index`, which `CompetitorStore` implements,
so `use trueskill_tt::*` alongside `use std::ops::*` collided.
All three are `pub(crate)` now. `intern` stays internal because
ingestion needs it; `lookup` is gone entirely, since `current_skill`,
`rating` and `learning_curve` already answer "does this history know
this key" and all three take a borrowed key.
The three tests that used them asserted things a caller cannot observe.
They now assert what the interning bought:
- `record_winner_creates_two_competitors` compares posteriors instead of
comparing two opaque indices for inequality.
- `intern_is_idempotent` becomes `a_repeated_key_is_one_competitor` — a
key appearing in two events gives one competitor with a two-point
learning curve, which is the observable form of the same claim.
- `lookup_returns_none_for_missing` becomes `an_unknown_key_is_unknown`.
Closes#73.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`Gaussian` publicly implemented `Mul`, `Div`, `Add` and `Sub`. They were
the EP product, cavity and variance-space convolutions, and every one of
them lies to a reader who takes the operator at face value:
a = N(10, 2) b = N(4, 3) c = N(1, 1)
a * b N(8.15, 1.66) not 40
a - b sigma GREW, 2 -> sqrt(4 + 9)
a * N(1, 0) mu = NaN "multiply by one"
a / c pi = -0.75 mu() prints a confident 0
The last is this crate's signature defect on a public operator. `Div` is
the cavity and can legitimately leave a negative precision, which is not
a distribution — and `mu()`/`sigma()` guard `pi <= 0` and report `0.0`
and `inf`, so it comes back as a plausible number with no panic, no
`Debug` marker and nothing to test against.
The four impls are now `pub(crate)` inherent methods that say what they
do: `ep_product`, `cavity`, `convolve`, `convolve_diff`, plus `scale`
for the one operation that genuinely is arithmetic. Nothing in a user's
workflow needed operator syntax; inference did, and it still has it.
`pi()` and `tau()` follow. Storing natural parameters is a performance
decision — it makes message passing two adds — not a contract. The
public surface is now exactly: `from_ms`, `from_mv`, `mu`, `sigma`,
`variance`, `probability_below`, `probability_above`. `from_mv` and
`variance` are promoted from `pub(crate)`; they are the honest pair for
callers who already hold a variance and should not pay a round trip
through the square root.
Four integration tests asserted bit-identity on `(pi, tau)`. They assert
it on `(mu, variance)` instead — still `assert_eq!`, still exact, and
`1/pi` and `tau/pi` are deterministic, so bit-equal natural parameters
give bit-equal moments. `a_nan_sigma_passes_through_from_ms` drops its
`|| g.pi().is_nan()` half: `sigma()` substitutes for `pi <= 0` and
`pi == inf`, so NaN survives to it only from a NaN precision.
`benches/gaussian.rs` is deleted. It timed two f64 additions through the
public operators, and keeping those public solely to feed it is the same
thing #73 objected to when a benchmark was dictating five public types.
The paths it covered are exercised by `batch` and `history_converge`
through the real call chain.
Closes#71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`lib.rs` advertised `Game` in "Core types" as "one match in isolation".
It had no public constructor: every `Game::*` returned `OwnedGame`, so
`let g: Game = Game::ranked(..)?` did not compile.
Names swapped. The public type is the owned one — `Game<T, D>`, no
lifetime — and the borrowing form is `pub(crate) GameRef<'a, T, D>`,
which is what it always was: an implementation detail about whether the
result and weight slices are borrowed from `History`'s storage. That
distinction meant nothing to someone scoring one match, and it showed
the module's surface twice in rustdoc, since both types carried
`posteriors()` / `log_evidence()`.
`one_v_one` returned `(Gaussian, Gaussian)` while every sibling returned
a game, making it the one constructor you could not ask for
`log_evidence()`. It returns `Self` now; `.posteriors()` recovers the old
shape, and the test that covers it now also asserts the evidence of two
identical ratings is exactly `ln(0.5)`.
`ranked`, `scored` and `free_for_all` had `# Errors` as their entire
doc, so rustdoc's index rendered the error list as the summary. They
have summary lines, and `Game` has a worked example — it was advertised
as a core type with none anywhere in the crate.
Closes#69.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`&[&[&K]]` was the worst shape in the API. At `K = String` — the
realistic case, where names arrive owned from a database or CSV — a
string literal was *impossible*, and asking "who wins" cost six lines
and four allocations of temporaries that all had to outlive the call:
let ta = vec![a.to_string()];
let ra: Vec<&String> = ta.iter().collect();
...
self.history.predict_win_probabilities(&teams)
All seven `predict_*` / `expected_*` methods, `posterior_of`,
`posterior_of_at` and the `Joint` mirrors are now generic over the
borrowed key, the same way `current_skill` and `learning_curve` already
were. `member_skills` and `resolve_terms` only ever did two things with
a key — `keys.get` and `format!("{key:?}")` — and neither needed `K`.
h.predict_win_probabilities(&[&["alice"], &["bob"]]) // K = String
h.predict_win_probabilities(&[&["alice"], &["bob"]]) // K = &'static str
h.posterior_of(&[("alice", 1.0), ("bob", -1.0)])
One spelling for both key types, and `K: Debug` becomes `Q: Debug`, so a
key type no longer has to be `Debug` to run a prediction. The old
`&[&[&"a"]]` spelling still compiles at the default key type, where `Q`
infers to `&str` and the two shapes coincide.
The one cost: `predict_outcome(&[])` can no longer infer `Q` — nothing
in an empty slice names it. It needs an annotation, and only on that
degenerate call.
`lookup` carried `ToOwned<Owned = K>`, copy-pasted from `intern`, which
genuinely needs it to create the entry. `lookup` never creates, and its
five neighbours all accept `h.f("alice")` already. Dropping the bound
strictly widens what compiles.
`HistoryBuilder::gamma` is shorthand for `.drift(ConstantDrift::new(g))`.
Drift is the most-tuned parameter after `sigma` and `GAMMA` is a public
constant, but setting it meant first discovering `ConstantDrift`, a type
a caller has no other reason to name. On the `ConstantDrift` builder
only, since `gamma` is that model's parameter rather than something
every `Drift` has, and rejecting a negative value for the same reason as
`sigma` and `beta`: it enters squared.
Refs #72 (items 2 and 3, plus the gamma shorthand; the type-parameter
reorder in item 1 is still open).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`ConvergenceReport` is only ever constructed by `converge` /
`converge_partial`, so marking it costs a caller nothing and makes a
future field additive.
`ConvergenceOptions` and `GameOptions` deliberately stay constructible,
against #74's recommendation, because trying it turned up a cost the
issue did not anticipate. `Default::default` is not a `const fn`, so
`#[non_exhaustive]` + `..Default::default()` — the pattern that makes
marking an options struct cheap — does not work in a `const`:
error[E0639]: cannot create non-exhaustive struct using struct expression
--> tests/competitor_config.rs:13:41
|
13 | const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
`ConvergenceOptions` is `Copy` and a natural const; there is no
workaround from outside the crate. That cost is permanent, and adding a
field is a one-time major bump. The reasoning is recorded on the type so
the next person does not rediscover it.
Refs #74.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
"sigma" named three unrelated quantities: the prior standard deviation,
a distribution's own SD, and the observation noise on an observed score
margin. The third was already `score_sigma` at every config site —
`HistoryBuilder::score_sigma`, `GameOptions::score_sigma`,
`EventKind::Scored { score_sigma }` — and plain `sigma` only on
`Outcome::Scored`'s field and constructor parameter, whose own doc had
to disambiguate itself with "`sigma` overrides
`HistoryBuilder::score_sigma`". Now `score_sigma` everywhere.
The `Outcome::scores_with_sigma` / `EventBuilder::scores_with_sigma`
*method* names are left alone: renaming them is a naming choice rather
than a consistency fix, and #75 offers two candidates.
`HistoryBuilder::beta` and `::sigma` now say which is which. #75 calls
this the single most load-bearing undocumented distinction in the crate,
and it is right: nothing told a reader that `sigma` is epistemic — what
the model does not yet know, which evidence shrinks — while `beta` is
aleatoric, the day-to-day scatter no amount of evidence removes. Both
docs now name the symptom that should send you to that knob rather than
the other.
Refs #75.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
80 undocumented public items, including three that are first contact:
`History::current_skill` — the method the crate's own first example calls
— `EventBuilder`, the type `h.event(t)` hands you, and `Gaussian::mu()`.
Now zero, and `#![deny(missing_docs)]` keeps it that way.
Several docs are measurements rather than readings of the code:
- `Outcome::Ranked` says ranks are used ordinally, so `[0, 1, 2]` and
`[0, 5, 90]` are the same observation. Measured: bit-identical
posteriors for both.
- `OwnedGame::log_evidence` says two identically-rated competitors give
exactly `ln(0.5)`. Written as a doctest, so it runs.
- `Member::weight` says zero and negative are accepted. Measured.
- `ConvergenceReport::final_step` is `(|Δmu|, |Δsigma|)` in skill units,
NOT natural parameters. That one had to be traced through
`Gaussian::delta` rather than assumed from the neighbouring vocabulary.
- `GameOptions::score_sigma` rejects non-positive and NaN but accepts
`+inf`, which is what the guard actually says.
README: it is the front door for a crate on a private registry, and it
opened with a link dump followed by 130 lines on drift. The first
`record_winner → converge → current_skill` block was at line 226 of 307.
It now leads with what the crate is, an install line, a quickstart, a
"which entry point?" table, and the `converge`-is-strict rationale that
was the crate's most opinionated recent decision and went unmentioned.
The two canonical examples disagreed on spelling (`History::default()`
vs `History::builder().build()`, `current_skill("a")` vs
`current_skill(&"a")`); they now agree. Five new README blocks are
doctested, taking the suite from 19 to 25.
`pub use smallvec;`. Four public items name `SmallVec` in their
signatures, and the only `Joint` example failed to compile from a
consumer crate with `unresolved import smallvec` — the dependency was in
the API but not reachable. Both worked examples now use the re-export,
so they teach the path that works downstream.
Vocabulary, from #75: "agent" was a fourth word for competitor, 200
occurrences, and it had reached public signatures before #73 un-exported
`TimeSlice`. Now zero.
Closes#77. Refs #75.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`converge` refuses to report a NaN fit. Nothing stopped a caller from
ignoring that error and predicting anyway, and every prediction path was
differently wrong when they did. Measured on a point-mass-prior history
with `beta(0.0)`, after `converge` returned `NonFiniteResult`:
predict_quality = Ok(NaN)
predict_outcome().total() = NaN
predict_win_probabilities = Ok([0.0, 0.0])
The third is the dangerous one: finite, plausible, and summing to zero
against a doc that promises one at `p_draw == 0`. A caller checking
`total() ≈ 1` catches the second and misses it.
The same parameters on a *scored* event converge cleanly and leave
legitimate point-mass posteriors. There `predict_quality` **panicked** —
"cannot invert a singular matrix", out of a method returning `Result` —
because the contrast covariance `beta²AᵀA + AᵀSA` is exactly singular,
and `predict_win_probabilities` again returned `Ok([0.0, 0.0])`. That
promise assumes continuous performances, where an exact tie has measure
zero; point masses break the assumption, not the arithmetic.
Both checks now live at `member_skills`, the one gate every prediction
path reads skills through, rather than being repeated per method.
The finiteness check is on `mu` / `sigma`, not on the natural parameters.
The first attempt checked `pi` and `tau`, and measurement showed it
rejected a *legitimate* point mass — `pi = inf`, `mu = 0`, `sigma = 0` —
turning a working prediction into an error. The question is whether the
usable moments exist, and those are what predictions consume.
Docs: `converge_partial` omitted the drift-variance `InvalidParameter` it
validates before sweeping, and the free `expected_information_gain`
omitted `GridTooCoarse`, which comes from `outcome_distribution` and so
is not covered by its "anything `Game::ranked` returns" clause.
Refs #78 (parts 1 and 2; the layering and `predict_quality` rename
questions are still open).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`Rating` already derived `PartialEq`, but that derive is only reachable
through `D: PartialEq` — and `ConstantDrift`, the crate's own only
`Drift` impl, did not satisfy it. So the derive was there and unusable.
Found by writing the comparison from a consumer's position rather than
reading the derive list.
`ConstantDrift`, `ConvergenceOptions` and `GameOptions` now derive
`PartialEq`. All three are pure configuration; comparing two is the
natural thing to want and nothing about them makes equality ambiguous.
`tests/trait_impls.rs` pins the surface, written the way the failure was
reported: a consumer struct that *holds* a `History` and derives
`Debug`. It also asserts `History`'s `Debug` summarises rather than
dumping its skill stores, so a future derive cannot quietly replace the
hand-written impl.
`Clone` on `History` stays off. It is a decision, not an omission: a
history owns every slice's skill store and arena, so cloning one is
proportional to the whole fit, and no consumer has wanted it.
Closes#76.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`h.event(1).team(["x"]).team(["y"]).ranking([0, 1]);` without the
terminal `.commit()` was a silent no-op: no warning, no error, and the
next thing the caller does is converge an empty history and read `None`
skills. `EventBuilder` already carried a `#[must_use]`; the value types
around it did not, so the same silence covered `Team::with_members`,
`Member::new`, `Outcome::*`, `Joint` and `Prediction::outcomes`.
`#[must_use]` now goes on the *types* rather than being sprinkled over
methods, which covers every constructor and builder setter at once and
gives the crate a rule where it previously had a list. Verified by
compiling a program that drops each one and reading the warnings back,
rather than by assuming the attribute took.
Visibility, from #73: `Gaussian::damp_natural` was reachable from
outside the crate despite being an EP damping internal called only from
`src/factor/`. The stray `pub fn`s inside the private `time_slice`,
`key_table` and `matrix` modules are now `pub(crate)`, so their
visibility states what it means instead of relying on the module being
private.
`storage/mod.rs` and `factor/mod.rs` become `storage.rs` and
`factor.rs`.
Closes#67. Refs #73.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
Three of four corners of the evidence matrix existed. The missing one was
forward-only *and* key-restricted — which is exactly what per-competitor
prequential scoring needs, the intersection of the two workloads
`log_evidence_for` and `filtered_log_evidence` are each documented for.
`filtered_log_evidence_for` fills it. It is not
`log_evidence_internal(true, targets)`: that path selects `skill.forward`
as the prior, which stops being a filtering quantity once `iteration`
has run a backward sweep. It goes through `filtered_pass` like its
unrestricted sibling, with the restriction applied to which events are
*scored*, never to which are *run* — so it is a held-out score under the
real history, not a score under a counterfactual one where nobody else
played.
Key resolution for both `*_for` accessors now shares `resolve_targets`,
so they cannot drift apart on how an unknown key is reported.
`current_skills` is the plural of `current_skill`. Building a
leaderboard previously meant materialising every competitor's full
smoothed curve via `learning_curves` and reading the last point of each.
Tests carry controls in both directions: naming every competitor must
recover the unrestricted value (catching a filter that drops too much),
and the restricted forward-only value must differ from the restricted
smoothed one (catching an alias).
Refs #70.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
Two accessors answered a question about a key the history had never seen
with a well-formed value indistinguishable from a real answer.
`log_evidence_for` filter_map'd unknown keys away. An empty target list
means "no restriction" downstream, so a list of *entirely* unknown keys
returned the whole-history evidence: measured on a two-cohort fixture,
`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, a plausible number that silently
invalidates the comparison it was computed for. It now returns
`Err(UnknownKey)` naming the offending position.
`learning_curve` and `filtered_learning_curve` returned an empty `Vec`
both for a typo'd key and for a competitor who is registered but has not
played yet. They now return `Option`, so `None` is "never heard of it"
and `Some(vec![])` is "known, no appearances".
Tests carry a control case in each direction, so they cannot pass by
everything returning the same thing.
Closes#66, closes#70.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
Three vocabulary collisions, from #75.
**"rating" meant three things**, one the opposite of the exported type.
`Rating` is documented as static *configuration* — "this returns what it
was told", against every other accessor's "what inference inferred". But
`quality`'s parameter was `rating_groups: &[&[Gaussian]]` and its prose
said "rating groups" four times, where "rating" means a *posterior* — the
one thing `Rating` is documented not to be. Two error messages used it
that way too.
So a reader who learned `Rating = config` passed `Rating` values to
`quality`, which takes `Gaussian`; and one who learned "rating = what
comes out" was baffled that `h.rating(&k)` is not their skill.
"rating" is now reserved for the type. `quality(teams: &[&[Gaussian]])`,
and "every rating is finite" became "every posterior is finite".
**"agent" was a private fourth name for a competitor** — ~200 identifiers
against 236 uses of "competitor", and it leaked into two `pub` signatures
on `TimeSlice`. Now that #73 has made those internal this is a pure
rename, so the crate has one word for the entity throughout.
**"player" survived in one public signature** — `free_for_all(players:)`
plus two doc lines. Renamed, along with three internal closure bindings.
Doc examples that use "player" as a *key* are left alone: that is a
user's data, not the crate's vocabulary.
The panic-message expectations in tests/quality.rs moved with the prose,
which is the point of asserting on message text — the tests caught the
rename rather than papering over it.
Not touched: "performance" (always skill widened by beta), "skill",
"member" and "team" are each used for exactly one thing already.
Refs #75
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
Documentation (#78). Every item below was measured against the code
rather than read:
- `expected_information_gain` and `predict_ranking` had `# Errors`
immediately followed by `# Preconditions`, with the error list stranded
at the bottom of the latter — rustdoc rendered a BLANK Errors section on
both. The heading now sits with its content.
- `predict_outcome`, `predict_ranking` and the free
`expected_information_gain` all omitted `GridTooCoarse`.
- `predict_margin` claimed `JointUnavailable` "if the LATEST slice holds
ranked events". Measured with an early ranked slice and a late scored
one: it fails. The condition is *any* slice.
- `add_events` documented three errors and can return five more; it also
claimed a weights `MismatchedShape` that is unreachable through it,
since weights arrive one-per-`Member`. That check belongs to
`EventBuilder::weights`, and the doc now says so.
- `converge` and `converge_partial` both omitted the drift-variance
`InvalidParameter`.
`History` gains a hand-written `Debug` (#76). Summarising, not
exhaustive — a derived one would print every competitor's skill at every
slice. It exists because without it a consumer cannot `#[derive(Debug)]`
on any struct holding a `History`, which is how both known consumers
store one.
`#[non_exhaustive]` on all 17 `InferenceError` struct variants and on
`Outcome::Scored` (#74). The enum carried the attribute; no variant did,
so adding a field to any of them — and downstream construction of any of
them — were both in the public contract. This crate added two variants in
two days.
The options structs are deliberately NOT sealed. `ConvergenceOptions` and
`GameOptions` are constructed by struct literal at 65 sites of which only
8 use `..default()`, and specifying all three convergence fields is a
natural complete statement rather than a partial one. That is a real
trade-off rather than an oversight, and it is left as a decision on #74.
Also spells `UnknownKeys::Reject` explicitly at both sites that
wildcarded it. `#[non_exhaustive]` on your own enum gives no exhaustiveness
safety net if you then match `_`.
Sealing the variants pushed ten test sites from constructing errors to
`matches!`, which is the better assertion anyway — an `assert_eq!` against
a constructed error breaks whenever a field is added, which is the exact
fragility the attribute exists to prevent.
BREAKING CHANGE: `InferenceError`'s struct variants and `Outcome::Scored`
are `#[non_exhaustive]` — downstream patterns need `..` and downstream
construction is no longer possible.
Refs #78, #76, #74
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
Trait coverage (#76), all additive:
History Debug is still absent - see below
HistoryBuilder + Debug (it derived Clone but not Debug)
Rating + PartialEq (Gaussian had it; Rating is a Gaussian
plus three scalars and had none)
Event/Team/Member + PartialEq (input value types with no way to compare
them, which made round-trip tests awkward)
ConvergenceReport + PartialEq
`#[must_use]` (#67). The coverage had no rule: `filtered_log_evidence`
had it and `log_evidence` did not; `rating` had it and `current_skill`
did not; `Rating::with_drift_scale` had it and `Member::with_drift_scale`
did not.
Now on the types — `EventBuilder`, `HistoryBuilder`, `Prediction`,
`Gaussian`, `OwnedGame` — which covers most method returns at once, plus
the `History` accessors individually.
`EventBuilder` gets a message, because a dropped builder is the worst
case in the set: measured, `h.event(1).team(["x"]).team(["y"]).winner(0)`
without `.commit()` leaves `time_slices_len() == 0` and every skill
`None`, with no warning at all.
And `ConvergenceReport`'s `#[must_use]` moves off the TYPE onto
`converge_partial`, where its stated reason is true. It read "from
`converge_partial` this may describe a fit that stopped at max_iter" but
fired on `converge` too — where that is false, since `converge` returns
`Err(NotConverged)` in exactly that case. So the crate's own front-page
example warned, and every quickstart had to write `let _ =`. Verified
from a consumer crate: `h.converge()?;` now compiles clean.
Marking the types made eight method-level attributes redundant, which
clippy's `double_must_use` caught — that is the type-level marker doing
its job, and the eight are removed.
Refs #76, #67
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`, `Competitor` and
the `storage` module were all public and none was obtainable from a
`History` — `time_slices`, `agents` and `keys` are all private or
`pub(crate)`. `TimeSlice` was the worst: `new`, `add_events`, `iteration`,
`get_composition` and `get_results` were `pub` on a type you could only
build standalone and never feed back into anything.
Their sole consumer outside `src/` was `benches/batch.rs`, so a benchmark
was dictating six public types. It is rewritten against the public API: a
single-slice history's `converge` calls exactly the same per-slice sweep,
so capping at one iteration measures the same code path.
`N01` had zero references in the entire repository, including inside the
crate; removed. `N00` and `N_INF` are EP identities (`Add` and `Mul`) and
are now `pub(crate)` — a user reaching for `N_INF` as "an unknown
competitor's prior" would get an improper distribution whose `mu()`
silently reports 0.0.
Adds the accessors their absence forced people around, from #70:
`competitors()`, `competitor_count()` and `event_count()` (`size` had no
accessor at all). Answering "who is best" previously meant materialising
every competitor's full smoothed curve to read the last point of each.
`KeyTable::keys` now iterates the dense reverse table rather than the
forward `HashMap`, so `competitors()` yields insertion order rather than
per-process hash order — the same hazard as #62, caught before it could
reach a caller building a standings table.
Two `CompetitorStore` methods (`is_empty`, `iter_mut`) had no callers
anywhere and are gone; four more are now `#[cfg(test)]`, which is what
they always were in practice.
Worth recording a mistake: I first deleted `get_composition`/`get_results`
on the strength of a "never used" warning, and the build broke — the
warning came from the plain-lib target, where `#[cfg(test)]` callers in
history.rs are not compiled. A dead-code warning from one target is not
evidence about the others.
BREAKING CHANGE: `TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`,
`Competitor`, the `storage` module, `N01`, `N00` and `N_INF` are no longer
public.
Refs #73, #70
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`History<T: Time, ..>` has always been generic over the time axis,
`Untimed` has always been exported, and `Drift<T>` is generic specifically
so that "seasonal or calendar-aware drift is expressible without going
through i64". None of it was reachable from a downstream crate.
Every construction route pinned `T = i64`: `History::builder()`,
`History::builder_with_key()`, and the only `Default` impl on
`HistoryBuilder`. Its fields are private and it had no `new`. So all three
escape routes failed to compile, and a consumer with domain timestamps
had to convert to i64 — which is the exact thing the parameter exists to
avoid. One of `History`'s four type parameters was paid for at every
signature and could never be varied.
`Default` is now generic over `T` and `K`, `HistoryBuilder::new()` exists,
and `time_type::<T2>()` / `key_type::<K2>()` join `drift` and `observer`
as type-changing setters:
History::builder().time_type::<Untimed>().build()
History::builder().key_type::<String>().build()
HistoryBuilder::<Season, _, _, String>::new().build()
`key_type` replaces `builder_with_key`, which could not be turbofished —
`K` sat on the impl rather than the function, so callers had to spell
`History::<i64, _, _, String>::builder_with_key()`. 18 call sites across
15 files migrated.
tests/time_axis.rs is the part that matters. NOTHING in the repository
constructed a non-i64 history, which is precisely why this survived, so
the fix is only half done without a test that exercises the generic. It
defines a `Season(u16)` time type and a `SeasonalDrift` that accumulates
between seasons but not within one — the calendar-aware case the trait's
docs cite — and checks the whole path: fit, converge, and read a learning
curve whose times come back as `Season`, not as integers.
Two of the six tests are controls rather than assertions about output.
`Untimed` must ignore drift entirely, since elapsed is always zero, so
gamma 0.0 and gamma 5.0 must agree bit for bit. And a custom `Drift` must
actually widen a gap across seasons, or the test above would pass whether
or not the drift was consulted at all.
The README's ticked "Generalise a time axis" box is now true.
BREAKING CHANGE: `History::builder_with_key()` is removed. Use
`History::builder().key_type::<K>()`.
Closes#68
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`gamma` enters only as `gamma * gamma`, so the sign was squared away:
measured against the old public-field form, `ConstantDrift(-0.0833)`
produced results bit identical to `ConstantDrift(0.0833)`. The sign was
neither rejected nor honoured — it vanished.
It could not be checked while the field was a public tuple position,
because there was nothing to intercept. Validating inside
`variance_for_elapsed` would have been worse: it runs in the sweep, so a
construction-time mistake would panic mid-inference, and `Gaussian::from_ms`
is a worked example of why that is the wrong place — rejecting NaN there
turned the NonFiniteResult reporting path into a crash.
So `ConstantDrift::new` is the only way in and it checks, with `gamma()`
to read the value back. 129 call sites rewritten across src, tests,
benches, examples and the README. The dated plan and spec documents under
docs/superpowers are left alone: they record what was built at the time,
and rewriting them would falsify that.
tests/constructor_validation.rs is the more valuable half. This defect
class was closed three times in one session and reopened twice, because
each fix validated the layer it had just touched and inferred the rest —
`HistoryBuilder`, then `Game`'s own entry points, then the constructors
beneath both. A per-site fix cannot notice the site nobody thought of, so
that file enumerates every public entry point taking a magnitude and
asserts each refuses negative and non-finite values.
It found an eleventh defect on its first run: `HistoryBuilder::score_sigma`
accepted infinity, because `inf > 0.0` is true and the assert only tested
positivity. Fixed, and its own `should_panic` message updated to match.
`Gaussian::from_ms` is deliberately exempt from the non-finite half, for
the reason above: a broken fit produces a NaN sigma legitimately and
`converge` must be allowed to report it.
The convergence-level drift-variance check stays and is now tested through
a custom `Drift` implementation, since `ConstantDrift` can no longer reach
it. That check is the only thing standing between a third-party `Drift`
and a NaN fit.
BREAKING CHANGE: `ConstantDrift`'s field is private. Replace
`ConstantDrift(x)` with `ConstantDrift::new(x)`, and `drift().0` with
`drift().gamma()`. `HistoryBuilder::score_sigma` now rejects infinity.
Closes#65
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
It proved less than it appeared to. `sweep_color_groups` takes its
`par_iter` branch only for colour groups of at least RAYON_THRESHOLD (64)
events, and a colour group is a subset of ONE slice's events. The fixture
built 20 slices of 10, so the branch was unreachable — the test named the
parallel path and ran the sequential one.
It also compared one competitor's curve out of forty, and never compared
log_evidence, final_step or iterations.
The new fixture reaches the branch by construction: within a slice every
event uses a disjoint competitor pair, so greedy colouring puts all 96 in
colour 0. Competitors recur across slices, so the fit keeps temporal
coupling and drift rather than degenerating into independent duels.
Verified by instrumenting `sweep_color_groups`: 872 sweeps, one colour
group of 96 each, parallel branch taken all 872 times.
Worth recording how that verification went, because I nearly drew the
opposite conclusion. My first two instrumented runs printed nothing and I
read that as "the branch is still unreachable" — but `cargo test` captures
stderr without `--nocapture`, so the probe was invisible, not absent. An
instrument that cannot report is indistinguishable from a negative result.
Now compares every competitor's curve plus log_evidence, final_step and
iterations, and asserts the curve count so it cannot silently go back to
measuring almost nothing. A companion test pins EVENTS_PER_SLICE against
the threshold, so shrinking the fixture fails loudly rather than quietly
returning the suite to the sequential path.
Cross-process coverage is separate, in tests/cross_process_determinism.rs
(#62) — an in-process test cannot see hasher-order effects at all, since
every sample shares one seed.
Closes#64
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`ResolvedTerms::unseen` was a `HashMap<String, f64>` and three float
reductions iterated it. Addition is not associative and Rust seeds its
default hasher per process, so `posterior_of` returned different bits run
to run on identical input: measured over 40 processes, two distinct sigma
bit patterns, and five distinct values from `expected_variance_reduction`
spanning about 7 ULP.
A `BTreeMap` fixes it by construction. 40/40 identical after, 24/16
before.
The cross-batch conflict scan had the same cause with a different
symptom. It returns on the FIRST conflict, so hash order decided WHICH
competitor the error blamed — 15 different competitors named across 40
runs on identical input. The error fired every time; only its content was
a lottery, which sends a reader after the wrong key. Now scanned in
sorted order.
Magnitude was 1-7 ULP throughout, so no decision changes. The cost was
reproducibility: a golden test over these would flake at a low rate,
which is the worst kind of CI failure to diagnose.
tests/cross_process_determinism.rs re-executes the test binary and
compares bits, because an in-process test CANNOT see this — every sample
in one process shares one hasher seed. That is not hypothetical:
tests/determinism.rs compares four thread counts inside one process and
passed throughout while this was live.
Tuning that fixture took a measurement. Coefficients spread over nine
decades detected the bug in roughly one run in forty, because the small
terms fall below the running total's ULP and are absorbed whatever the
order. Comparable magnitudes keep every term able to change the last
bits: 5 of 5 attempts detected it, with 3 to 38 of 40 runs differing.
Verified non-vacuous by reverting the BTreeMap and watching it fail.
Closes#62
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
CLAUDE.md requires transcendentals to go through libm rather than std,
because std delegates to the system math library and the two disagree by
one ULP often enough to change an iteration count in a fixed point.
Three production sites did not:
factor/margin.rs:84 cavity.sigma().hypot(sigma) 12.136% of 1e6 inputs
factor/margin.rs:92 f64::MIN_POSITIVE.ln() 3.437%
factor/trunc.rs:98 f64::MIN_POSITIVE.ln() same
`hypot` is the material one: it is on the path of every scored event, and
its divergence rate is HIGHER than the 9.7% the rule cites for `exp` as
its own justification. The two `ln` calls happen to agree bit-for-bit on
this host, which is exactly the platform dependence the rule exists to
remove.
The `hypot` choice itself was right and stays — the comment above it
explains why, and it is measured: naive sqrt(a^2 + b^2) overflows to inf
at 1e200 and flushes to zero at 1e-200 where hypot does neither. Only the
implementation moves.
tests/libm_rule.rs enforces it. The rule was stated plainly in CLAUDE.md
and still violated three times, so prose is evidently not sufficient. The
test strips `#[cfg(test)]` items by brace matching, plus comments and
string literals so prose is not mistaken for a call, then scans for std
method spellings. `sqrt` is exempt: IEEE 754 specifies it, so std and
libm cannot disagree.
Confirmed non-vacuous by reintroducing the `hypot` violation and watching
it fail with the offending line, then pass again on restore. Two further
tests pin the stripper itself, since a stripper that removed everything
would make the guard pass on anything.
Closes#63
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
0.8.0 closed the sign-absorption defect at `HistoryBuilder::mu/sigma/beta`
and at both ingestion paths. It was still open one layer down, in the
constructors those paths call. Measured, all bit identical to their
positive counterparts:
Gaussian::from_ms(25.0, -8.33) == from_ms(25.0, +8.33)
Rating::new(_, -4.17, _) == Rating::new(_, +4.17, _)
ConstantDrift(-0.0833) == ConstantDrift(+0.0833)
sigma, beta and gamma enter only as squares, so the sign vanished without
comment. Worst of the set: `Rating::new(_, NaN, _)` reached `Game::ranked`
which returned **Ok** carrying `Gaussian { pi: NaN, tau: NaN }` — no
`converge` on that path to catch it.
`from_ms` and `Rating::new` now reject. `ConstantDrift` cannot: the field
is public and positional, so there is no constructor to intercept, and
sealing it would break every `ConstantDrift(x)` for a case whose resulting
model is perfectly valid. Documented instead. Its non-finite half IS
rejected — `converge` validates the drift variance each competitor
accumulates, which also covers a custom `Drift` impl.
Two things the tests caught that I had wrong:
NaN sigma must PASS `from_ms`. My first version rejected it, and two
existing tests went red immediately: a broken fit legitimately produces a
NaN sigma from `sqrt` of a negative truncated variance, and the design is
to propagate that to `NonFiniteResult`. Rejecting it turned the reporting
path into a panic inside inference. Written as
`sigma >= 0.0 || sigma.is_nan()` so the intent is explicit rather than
hidden in a negated comparison.
Very small sigma is also not rejected, and that is deliberate: `approx`
produces small truncated sigmas legitimately. `pi = 1/sigma^2` leaves
f64's range below ~1.5e-154 and `tau = mu*pi` overflows sooner, at a
threshold that depends on mu — so there is a band where pi is finite and
only tau is not. Both land on the existing point-mass representation.
Documented, including that such a Gaussian is not equal to itself and can
make two identical declarations report as conflicting.
BREAKING CHANGE: `Gaussian::from_ms` panics on a negative sigma, and
`Rating::new` panics unless beta is finite and non-negative. `converge`
returns `InvalidParameter` for a non-finite drift variance.
Closes#61
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`v_w` returned `w` and let `trunc` form `1 - w`. `w` tends to 1 out in
the tail, so that subtraction lost about log10(alpha^2) digits — and the
quantity it was destroying is perfectly representable.
Two separate cancellations, fixed separately.
The non-tie half: `half_line_truncation` now returns `1 - w` computed
symbolically rather than as `1 - v*gap`. With `alpha*gap = 1 - inv^2*b`
the leading ones cancel on paper instead of in floating point. Measured
against the exact truncated variance:
alpha before after
1e6 8.9e-5 rel 0.0 rel (exact)
1e8 returns 0.0 0.0 rel (exact)
At 1e8 the old form gave `sigma_trunc = 0`, and `from_ms(mu, 0.0)` is a
point mass whose `mu()` is inf/inf = NaN. `beta(1e-8).sigma(1e-8)` with
priors 1000 apart went from Err + NaN skills to a finite fit.
The tie half is a different subtraction — `w = v^2 - u`, where both grow
as alpha^2 while their difference stays O(1). The existing escape hatch
could not cover it: it keys on `alpha * width >= HALF_LINE_WINDOW`, how
many window-widths from the mean the window sits, and a NARROW window
fails that however deep it is. Measured at alpha 1e6 with a 1e-6 window
it kept four digits and returned `1 - w = -2.4e-4` where the truth is
+2.8e-13. One step earlier it was quietly wrong instead: `1 - w = 1.0`
exactly, a truncation reported as a no-op, where the truth was 5e-17.
Over a narrow window the density is a truncated exponential in
`s = (x - alpha)/width`, whose mean and variance are closed forms, so
`v = alpha + width*m(t)` and `1 - w = width^2 * V(t)` with no large
subtraction at all. Validated against high-precision quadrature: v exact
to 4e-10, `1 - w` to 4e-10 across the region it is used in.
The crossover is on `alpha / width` rather than on either alone, because
that ratio is what says how many digits the subtraction has left — and
the approximation is most accurate exactly where the subtraction is
worst, since both improve as the window narrows.
Defaults are bit-identical (pi 0.02398318151216503 before and after).
Tests: the three reproductions from the issue, the narrow-window form
against pinned quadrature values, and a continuity sweep across all three
tie branches — a misplaced crossover is the real risk here, and a jump at
a boundary is visible even without pinning absolute values.
Closes#60
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`quality()` computed `det(ata) / det(middle)` in linear space. Both are
products of `k - 1` diagonal entries, so they leave f64's range long
before their ratio does — and the ratio is the only thing the answer
needs.
Measured at the crate defaults: 150 groups correct at 8.45e-53, 200
returned 0, 250 returned NaN where the truth is 9.51e-88. With a small
beta it bit far sooner: at sigma = beta = 1e-3, 60 groups returned NaN
against a true 1.32e-9 — a value nine orders of magnitude inside the
normal range. Neither `quality()` nor `History::predict_quality` caps the
group count, unlike `predict_outcome`, so those are supported calls.
`Lu::ln_abs_determinant` accumulates `ln|diagonal|` instead of
multiplying, and the call site becomes `exp(e_arg + 0.5 * ln_ratio)`.
Verified against the closed form `(beta / sqrt(beta^2 + sigma^2))^(k-1)`
rather than against recorded output, across three parameter sets and
group counts to 300: every case now agrees to 1e-11 or better, including
9.88e-324 at 300 groups, which is subnormal.
Also documents the remaining panic: every rating at zero sigma with a
zero beta makes `middle` singular and `inverse()` panics. Documented
rather than converted — nothing is uncertain there, so there is no
distribution to take the quality of.
Closes#59
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
Each sample runs a full inference pass per outcome, and that is about
19x faster in release: 20 000 samples take 12.1s released against 23s
for 2 000 in debug.
`just test` runs three debug feature combinations and one release one, so
a fixed sample count pays the slow price three times and the fast one
once — exactly backwards. Scaling by `cfg!(debug_assertions)` puts the
search where it is cheap:
debug 1 000 samples 11.7s
release 50 000 samples 31.6s
Across the whole `just test` that is 67s against 70s before, for 25x the
samples. The debug run proves the sweep compiles and holds; the release
run is the one that actually searches.
Not moving the suite to release-only, which was the alternative
considered. `debug_assert!` is compiled out in release, and this crate
documents that as load-bearing — several defects have hidden there — so
dropping the debug runs would trade one class of coverage for another
rather than adding any.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`grid_shape` asked for 12 nodes across the narrowest feature and then
clamped to MAX_GRID_POINTS with no detection that the request was not
met. Past `step/sigma ~ 1.7` the trapezoid rule stops resolving the
density, and the result is unbounded:
sigma_a step/sig_a P(a first) exact total
2.0e-3 0.86 0.515953 0.515953 1.000000
1.0e-3 1.72 0.517185 0.515953 1.002388
1.0e-4 17.17 2.791336 0.515953 5.410065
A probability of 2.79. Reachable through `predict_outcome` with a pinned
reference competitor — a documented pattern — where `predict_outcome` and
`predict_win_probabilities` disagreed 44x and `predict_outcome` was the
wrong one.
There is no useful answer on the far side of that cliff, so this reports
`GridTooCoarse` rather than guessing, and the message points at
`predict_win_probabilities`, which answers the same matchup through
adaptive quadrature and is accurate there to 1e-13. The floor is 4 nodes
per feature rather than the 12 requested, because the request carries
margin: measured accurate to 2.2e-12 at 1.4 nodes per sigma and wrong by
1.2e-3 at 0.7.
This also fixes the `ln k` ceiling violation. `expected_information_gain`
weights `probability * divergence`, so probabilities of 3.97 and 2.62
made it return 3.237828 nats against `ln 2 = 0.693147` — 4.67x over. The
crate's docs call that ceiling its sharpest test and record a prototype
once returning 4.77 nats; it was live again by a different route.
The new sweep then caught a second, independent defect: `kl_divergence`
returned NEGATIVE values, worst -5.55e-17, exactly one ULP of its
`- 1.0`. Rewritten as `0.5*(u - ln1p(u)) + gap^2/(2*var_p)` with
`u = var_q/var_p - 1`, so both terms are non-negative by construction.
It is also more accurate where it matters: at `u = 1e-9` the old form
returned 0.0 where the true value is 2.5e-19, and well-conditioned cases
are unchanged.
tests/prediction_bounds.rs sweeps rather than spot-checks, because a
single fixture cannot defend a bound like this — the previous check
passed throughout. It asserts the sweep still reaches the coarse-grid
regime, so it cannot quietly stop testing the case it was written for.
BREAKING CHANGE: `predict_outcome`, `predict_ranking` and
`expected_information_gain` return `GridTooCoarse` for matchups whose
performance sigmas are too far apart to integrate on one grid. They
previously returned wrong answers, including probabilities above 1.
Closes#55, closes#56
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`time_expanded_joint` collapsed consecutive appearances only at
`drift <= 0.0` exactly. Anything smaller-but-positive got an explicit
`1.0 / drift` precision, which the matrix cannot hold: at `drift = 1e-16`
the entry is `1e16`, and `1e16 + 0.28` rounds back to `1e16`, so the
prior and the event contrasts are annihilated in the stored f64 before
the factorisation ever runs.
Measured, 8 competitors over 15 slices:
drift_scale before after
1e-6 1.3e-3 relative error exact
1e-7..1e-9 Err(JointUnavailable) exact
1e-10 12 200x TOO SMALL, as Ok exact
At 1e-10 the caller was handed sigma = 0.0055 where the truth is 0.6108
— a 111x overconfident interval, returned as a success.
This is representation, not conditioning. Solved in 200-digit precision
the same system converges smoothly onto the collapsed value and is flat
from 1e-16 to 1e-40, so the quantity is perfectly well conditioned. That
also rules out the obvious fix: symmetric (Jacobi) equilibration measured
30x WORSE, because the information is gone from the assembled matrix
before any solver sees it. The fix has to be at assembly.
The threshold balances the two errors that trade off. Ignoring a real
drift costs about `drift / V`; representing one costs about
`EPSILON * V / drift`. They cross at `V * sqrt(EPSILON)`, scaled to each
competitor's own prior variance.
Ordinary drift is far above it and unaffected — the default gamma
accumulates 0.0069 per unit time against a threshold of 1.0e-6 — and the
test asserts both halves: everything below the threshold reaches the
collapsed answer bit-identically, and a drift of 1e-2 still moves it, so
the test cannot pass by collapsing everything.
Also corrects the `JointUnavailable` message, which asserted "a
competitor has neither a proper prior nor any evidence" for a fixture
where every competitor had both.
BREAKING CHANGE: a drift variance below `prior_variance * sqrt(EPSILON)`
now collapses two appearances into one latent variable. Affected fits
previously returned a badly wrong variance or an error.
Closes#57
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
`tuple_max` compared with a plain `>`, which is false against NaN, so a
NaN accumulator was replaced by the next finite delta. The fold runs over
`TimeSlice::posteriors()`, a HashMap, so whether a NaN survived to `step`
depended on per-process hash order.
Measured before, four competitors in one slice with one pathological
pair, same binary and input, 30 separate processes:
16 Ok converged=true, iterations=1, a = Gaussian { pi: NaN, tau: NaN }
14 Err NonFiniteResult
After: 30/30 Err. A coin flip on whether a NaN fit was reported as an
error or as a successful, converged fit — inside the guard whose entire
purpose is "NaN is never convergence".
`f64::max` would not have fixed it. It also ignores NaN by design, which
is the same defect wearing a standard-library name, and a test pins that
we do not use it.
`Gaussian::delta` had to be fixed FIRST, and that ordering is the whole
subtlety. Two identical improper messages produced `(0.0, NaN)` — not
from `mu()`, which is guarded and returns 0.0, but from `inf - inf` in
the sigma component. That NaN is reachable in ordinary healthy inference:
once a pairing is more than about nine cavity-sigma apart the truncation
is a no-op and the chain compares one identity message against another.
Propagating NaN without fixing `delta` would therefore have turned
correct fits into NonFiniteResult errors. `delta` now answers the
identical-message case in natural space before touching the accessors.
My first version of the `delta` test asserted `mu()` was NaN. It is not;
the accessor guards `pi <= 0.0`. The test caught my own wrong premise,
and the doc comment is corrected to match.
BREAKING CHANGE: a fit that produced NaN in a non-final reduction
position previously returned `Ok` with `converged: true` and a NaN
posterior; it now returns `Err(NonFiniteResult)`. That was always the
documented intent.
Closes#58
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ