#[non_exhaustive] on the enum only stops an exhaustive match over variants. Adding a field to any variant, and downstream construction of any variant, are both in the public contract. This crate added NotConverged and GridTooCoarse in the last two days, so that matters.
Same for Outcome::Scored { scores, sigma } despite the enum being marked, and for ConvergenceOptions, GameOptions, ConvergenceReport, Event, Team, Member, all of which have public fields and no marker.
Fix:#[non_exhaustive] on every struct variant and on the options/report structs. They already impl Default, so ..Default::default() gives the ergonomic path. This is the single change most worth bundling now — every future field addition is otherwise a major bump.
2. Six of fifteen variants use a &'static str discriminator, carrying ~30 magic strings
Variant
distinct string values
MismatchedShape { kind }
10
InvalidParameter { name }
9
WrongOutcomeKind { context, expected, got }
3 fields × 2 sites
JointUnavailable { reason }
3 prose sentences
ConflictingCompetitorConfig { field }
2
NonFiniteResult { context }
1
Three concrete problems:
(a) MismatchedShape.kind mixes two levels. Four values name an internal array — "results", "times", "kinds", from the private add_events_with_prior chokepoint a caller has never heard of. Two are full English sentences duplicating the numeric fields: "predict_margin takes exactly 2 teams" alongside expected: 2. Nothing programmatic can be done with any of them, and Display renders kinds: expected length 3, got 2 — meaningless in a log.
(b) InvalidParameter { name, value: f64 } cannot carry a non-scalar.Outcome::try_winner already contorts through it with value: f64::from(winner) for a u32 index. A prior check has no representable value at all.
(c) InvalidProbability { value } is a bespoke variant for one scalar while every other scalar shares InvalidParameter — and it omits the parameter name. Meanwhile HistoryBuilder::p_drawpanics on the identical value, so the same parameter has two mechanisms and one has its own variant.
(d) JointUnavailable { reason } is prose where three variants belong. The three reasons are conditions a caller would branch on differently: empty history (add events), ranked events present (use predict_win_probabilities), not positive-definite (numerical). Today matches!(e, JointUnavailable { .. }) is all you can do; distinguishing them means string-matching English.
Proposed:
#[non_exhaustive]pubenumParameter{Mu,Sigma,Beta,PDraw,ScoreSigma,Alpha,Epsilon,Weight,DriftScale,DriftVariance,Score,Rank,Prior,WinnerIndex}#[non_exhaustive]pubenumInferenceError{#[non_exhaustive]InvalidParameter{parameter: Parameter,value: f64},// absorbs InvalidProbability
#[non_exhaustive]MismatchedShape{what: Shape,expected: usize,got: usize},#[non_exhaustive]WrongOutcomeKind{expected: OutcomeKind,got: OutcomeKind},// drop `context`
#[non_exhaustive]EmptyHistory,// was JointUnavailable
#[non_exhaustive]JointRequiresScoredEvents,// was JointUnavailable
#[non_exhaustive]NotPositiveDefinite,// was JointUnavailable
..}
Keep the Display prose identical — it is genuinely good — but move the discriminating text into the Display match arms rather than into the data.
3. Three Display impls do not meet the standard the others set
The good ones tell the caller what to do, and are why this is worth fixing rather than accepting:
did not converge in {iterations} iterations: final step {final_step:?} is still above epsilon {epsilon}; raise max_iter, or damp with alpha < 1.0 if it is oscillating
the prediction grid needs {needed} nodes … but may hold only {max}; … Use predict_win_probabilities, which is accurate here
The exceptions:
InvalidParameter → {name} is invalid: {value}. Renders drift variance is invalid: NaN — no valid range, no remedy, no location. Every neighbour states one.
WrongOutcomeKind → accurate, but does not say the obvious next step (call Game::scored), unlike every other message in the file.
4. UnknownKeys is #[non_exhaustive] but wildcarded internally
history.rs:850 and :1104 both match UnknownKeys::Prior => … then _ => Err(UnknownKey). Adding a third variant compiles silently and behaves as Reject. #[non_exhaustive] on your own enum gives no exhaustiveness safety net if you then wildcard it. Spell Reject explicitly at both sites — free, breaks nothing.
Breaks: downstream match on these variants' fields; struct-literal construction of ConvergenceOptions/GameOptions (needs ..Default::default()). This is the largest break in the audit and the reason to do it in one release.
Found by an API audit, 2026-09-09.
## 1. The variants are not `#[non_exhaustive]`, so every future field is a breaking change
`error.rs:43` marks the **enum**. No variant is marked. From a consumer crate, both of these compile today:
```rust
if let InferenceError::NotConverged { iterations, final_step, epsilon } = &e { .. }
let g = InferenceError::GridTooCoarse { needed: 9, max: 3 }; // constructed downstream
```
`#[non_exhaustive]` on the enum only stops an exhaustive `match` over variants. Adding a field to any variant, and downstream construction of any variant, are both in the public contract. This crate added `NotConverged` and `GridTooCoarse` in the last two days, so that matters.
Same for `Outcome::Scored { scores, sigma }` despite the enum being marked, and for `ConvergenceOptions`, `GameOptions`, `ConvergenceReport`, `Event`, `Team`, `Member`, all of which have public fields and no marker.
**Fix:** `#[non_exhaustive]` on every struct variant and on the options/report structs. They already `impl Default`, so `..Default::default()` gives the ergonomic path. This is the single change most worth bundling now — every future field addition is otherwise a major bump.
## 2. Six of fifteen variants use a `&'static str` discriminator, carrying ~30 magic strings
| Variant | distinct string values |
|---|---|
| `MismatchedShape { kind }` | **10** |
| `InvalidParameter { name }` | **9** |
| `WrongOutcomeKind { context, expected, got }` | 3 fields × 2 sites |
| `JointUnavailable { reason }` | 3 prose sentences |
| `ConflictingCompetitorConfig { field }` | 2 |
| `NonFiniteResult { context }` | 1 |
Three concrete problems:
**(a) `MismatchedShape.kind` mixes two levels.** Four values name an internal array — `"results"`, `"times"`, `"kinds"`, from the private `add_events_with_prior` chokepoint a caller has never heard of. Two are full English sentences duplicating the numeric fields: `"predict_margin takes exactly 2 teams"` alongside `expected: 2`. Nothing programmatic can be done with any of them, and `Display` renders `kinds: expected length 3, got 2` — meaningless in a log.
**(b) `InvalidParameter { name, value: f64 }` cannot carry a non-scalar.** `Outcome::try_winner` already contorts through it with `value: f64::from(winner)` for a `u32` index. A `prior` check has no representable value at all.
**(c) `InvalidProbability { value }` is a bespoke variant for one scalar** while every other scalar shares `InvalidParameter` — and it omits the parameter name. Meanwhile `HistoryBuilder::p_draw` *panics* on the identical value, so the same parameter has two mechanisms and one has its own variant.
**(d) `JointUnavailable { reason }` is prose where three variants belong.** The three reasons are conditions a caller would branch on differently: empty history (add events), ranked events present (use `predict_win_probabilities`), not positive-definite (numerical). Today `matches!(e, JointUnavailable { .. })` is all you can do; distinguishing them means string-matching English.
**Proposed:**
```rust
#[non_exhaustive]
pub enum Parameter { Mu, Sigma, Beta, PDraw, ScoreSigma, Alpha, Epsilon,
Weight, DriftScale, DriftVariance, Score, Rank, Prior, WinnerIndex }
#[non_exhaustive]
pub enum InferenceError {
#[non_exhaustive] InvalidParameter { parameter: Parameter, value: f64 }, // absorbs InvalidProbability
#[non_exhaustive] MismatchedShape { what: Shape, expected: usize, got: usize },
#[non_exhaustive] WrongOutcomeKind { expected: OutcomeKind, got: OutcomeKind }, // drop `context`
#[non_exhaustive] EmptyHistory, // was JointUnavailable
#[non_exhaustive] JointRequiresScoredEvents, // was JointUnavailable
#[non_exhaustive] NotPositiveDefinite, // was JointUnavailable
..
}
```
Keep the `Display` prose identical — it is genuinely good — but move the discriminating text into the `Display` match arms rather than into the data.
## 3. Three `Display` impls do not meet the standard the others set
The good ones tell the caller what to do, and are why this is worth fixing rather than accepting:
> `did not converge in {iterations} iterations: final step {final_step:?} is still above epsilon {epsilon}; raise max_iter, or damp with alpha < 1.0 if it is oscillating`
> `the prediction grid needs {needed} nodes … but may hold only {max}; … Use predict_win_probabilities, which is accurate here`
The exceptions:
- `InvalidParameter` → `{name} is invalid: {value}`. Renders `drift variance is invalid: NaN` — no valid range, no remedy, no location. Every neighbour states one.
- `MismatchedShape` → `{kind}: expected length {expected}, got {got}` — see (a).
- `WrongOutcomeKind` → accurate, but does not say the obvious next step (*call `Game::scored`*), unlike every other message in the file.
## 4. `UnknownKeys` is `#[non_exhaustive]` but wildcarded internally
`history.rs:850` and `:1104` both match `UnknownKeys::Prior => …` then `_ => Err(UnknownKey)`. Adding a third variant compiles silently and behaves as `Reject`. `#[non_exhaustive]` on your *own* enum gives no exhaustiveness safety net if you then wildcard it. Spell `Reject` explicitly at both sites — free, breaks nothing.
**Breaks:** downstream `match` on these variants' fields; struct-literal construction of `ConvergenceOptions`/`GameOptions` (needs `..Default::default()`). This is the largest break in the audit and the reason to do it in one release.
Found by an API audit, 2026-09-09.
Items 1 and 4 are resolved; 2 and 3 are a design change I am not making unilaterally.
1 — variants done, options structs deliberately not
Every struct variant of InferenceError carries #[non_exhaustive] (that landed in the earlier api/cleanup work), and Outcome::Scored does too. ConvergenceReport is marked as of 6a893ff (merged as da55d2a) — it is only ever constructed by converge / converge_partial, so it costs a caller nothing.
ConvergenceOptions and GameOptions are staying constructible, against this issue's recommendation. I tried it, and it turns up a cost the issue did not anticipate:
Default::default is not a const fn, so the ..Default::default() escape hatch — the whole reason marking an options struct is supposed to be cheap — does not exist in a const. ConvergenceOptions is Copy and a natural const; there is no workaround from outside the crate. Weighed against that, adding a field later is a one-time major bump, and this crate has been cutting those anyway. The reasoning is recorded on the type so it does not get rediscovered.
The other structs the issue lists — Event, Team, Member — are worse candidates still: struct-literal construction of Event is the documented ingestion path, it appears in the README, and Event has no Default to fall back to.
4 — already fine
Both sites (member_skills and the linear-combination path) spell UnknownKeys::Reject explicitly. No wildcard remains.
2 and 3 — left open, and they are one change not two
The typed-discriminator redesign is right, and item 3 depends on it: InvalidParameter's Display cannot state a valid range or a remedy without knowing which parameter, and getting that from a &'static str means matching on magic strings in the Display impl — trading one stringly-typed site for another. Parameter, Shape and OutcomeKind are what make item 3 writable.
Two notes from working nearby:
NonFiniteResult { context, step } picked up a second meaning in #78: from converge the pair is the sweep step, from a prediction it is the offending skill's own (mu, sigma). The field name step is now wrong half the time. Whatever this issue does to the enum should split that or rename the field.
UnknownKey { team, member, key } carries team: 0 on the flat-key paths (resolve_targets, the linear-combination path), where member indexes a flat key list and there is no team. Documented as such for now, but it is the same shape of problem: one variant serving two situations.
Three new &'static str values have appeared since the audit — "prediction read a skill with no usable mean or variance…" and "beta with point-mass skills" — so the count in the table is a floor, not a ceiling.
Items 1 and 4 are resolved; 2 and 3 are a design change I am not making unilaterally.
## 1 — variants done, options structs deliberately not
Every struct variant of `InferenceError` carries `#[non_exhaustive]` (that landed in the earlier `api/cleanup` work), and `Outcome::Scored` does too. `ConvergenceReport` is marked as of 6a893ff (merged as da55d2a) — it is only ever constructed by `converge` / `converge_partial`, so it costs a caller nothing.
**`ConvergenceOptions` and `GameOptions` are staying constructible, against this issue's recommendation.** I tried it, and it turns up a cost the issue did not anticipate:
```
error[E0639]: cannot create non-exhaustive struct using struct expression
--> tests/competitor_config.rs:13:41
|
13 | const CONVERGENCE: ConvergenceOptions = ConvergenceOptions {
```
`Default::default` is not a `const fn`, so the `..Default::default()` escape hatch — the whole reason marking an options struct is supposed to be cheap — **does not exist in a `const`**. `ConvergenceOptions` is `Copy` and a natural const; there is no workaround from outside the crate. Weighed against that, adding a field later is a one-time major bump, and this crate has been cutting those anyway. The reasoning is recorded on the type so it does not get rediscovered.
The other structs the issue lists — `Event`, `Team`, `Member` — are worse candidates still: struct-literal construction of `Event` is the documented ingestion path, it appears in the README, and `Event` has no `Default` to fall back to.
## 4 — already fine
Both sites (`member_skills` and the linear-combination path) spell `UnknownKeys::Reject` explicitly. No wildcard remains.
## 2 and 3 — left open, and they are one change not two
The typed-discriminator redesign is right, and item 3 depends on it: `InvalidParameter`'s `Display` cannot state a valid range or a remedy without knowing *which* parameter, and getting that from a `&'static str` means matching on magic strings in the `Display` impl — trading one stringly-typed site for another. `Parameter`, `Shape` and `OutcomeKind` are what make item 3 writable.
Two notes from working nearby:
- `NonFiniteResult { context, step }` picked up a second meaning in #78: from `converge` the pair is the sweep step, from a prediction it is the offending skill's own `(mu, sigma)`. The field name `step` is now wrong half the time. Whatever this issue does to the enum should split that or rename the field.
- `UnknownKey { team, member, key }` carries `team: 0` on the flat-key paths (`resolve_targets`, the linear-combination path), where `member` indexes a flat key list and there is no team. Documented as such for now, but it is the same shape of problem: one variant serving two situations.
Three new `&'static str` values have appeared since the audit — `"prediction read a skill with no usable mean or variance…"` and `"beta with point-mass skills"` — so the count in the table is a floor, not a ceiling.
Items 2 and 3 are done, so this closes. 061c481 (merged as 61da3ac).
Four enums replace the thirty-odd magic strings, close to what you sketched:
Parameter// 13 variants, replacing 9 strings in InvalidParameter
Shape// 4 variants, replacing 10 in MismatchedShape
OutcomeKind// 2 variants, replacing WrongOutcomeKind's three fields
CompetitorField// 2 variants, replacing ConflictingCompetitorConfig's
InvalidProbability folds into InvalidParameter as Parameter::PDraw, as you proposed — it was a bespoke variant for one scalar that also omitted the parameter name.
JointUnavailable splits into EmptyHistory, JointRequiresScoredEvents, NotPositiveDefinite. One test proved immediately that the distinction was load-bearing: my blanket conversion mapped the empty-history case onto the ranked one, and an_empty_history_has_no_joint failed on the spot. Under the old prose field that would have been a passing test.
Two splits beyond the issue
NonFiniteResult → NonFiniteStep { context, step } and NonFiniteSkill { mu, sigma }, as flagged in my earlier comment. One step: (f64, f64) was carrying a sweep step from converge and a skill's own moments from a prediction; no field name is right for both.
InvalidParameter { name: "beta with point-mass skills" } → NoPerformanceVariance. That was never a parameter out of range — both values are individually valid, and it is their combination that leaves nothing varying. Putting it in Parameter would have made the enum a bag of conditions.
Item 3, which item 2 is what makes possible
before drift variance is invalid: NaN
after drift variance must be finite and non-negative (got NaN)
before kinds: expected length 3, got 2
after the outcome describes a different number of teams than the event has:
expected 3, got 2
before Game::ranked: expected Outcome::Ranked, got Outcome::Scored
after expected Outcome::Ranked, got Outcome::Scored; call Game::scored for a
scored outcome
Parameter::range() states each parameter's actual bounds, which is exactly what a &'static str name could not do. error::message_tests renders every message and asserts each is a sentence rather than a label — including that none of them still contains the old is invalid: shape — and that the three above now carry a range or a next step.
The four internal MismatchedShape kinds
results, times, kinds and the weights array collapse to Shape::Internal, whose Display says plainly that reaching it is a bug in this crate. They are checks on add_events_with_prior's own parallel arrays and unreachable through the public API. I kept them as checked errors rather than turning them into debug_assert!s: release is where this crate's defects have tended to hide, and the CLAUDE.md rule about running tests in release exists for that reason.
Item 1 (#[non_exhaustive]) and item 4 (the UnknownKeys wildcard) were resolved earlier — see the previous comment for why the options structs deliberately stay constructible.
Items 2 and 3 are done, so this closes. 061c481 (merged as 61da3ac).
Four enums replace the thirty-odd magic strings, close to what you sketched:
```rust
Parameter // 13 variants, replacing 9 strings in InvalidParameter
Shape // 4 variants, replacing 10 in MismatchedShape
OutcomeKind // 2 variants, replacing WrongOutcomeKind's three fields
CompetitorField // 2 variants, replacing ConflictingCompetitorConfig's
```
`InvalidProbability` folds into `InvalidParameter` as `Parameter::PDraw`, as you proposed — it was a bespoke variant for one scalar that also omitted the parameter name.
`JointUnavailable` splits into `EmptyHistory`, `JointRequiresScoredEvents`, `NotPositiveDefinite`. **One test proved immediately that the distinction was load-bearing:** my blanket conversion mapped the empty-history case onto the ranked one, and `an_empty_history_has_no_joint` failed on the spot. Under the old prose field that would have been a passing test.
## Two splits beyond the issue
`NonFiniteResult` → `NonFiniteStep { context, step }` and `NonFiniteSkill { mu, sigma }`, as flagged in my earlier comment. One `step: (f64, f64)` was carrying a sweep step from `converge` and a skill's own moments from a prediction; no field name is right for both.
`InvalidParameter { name: "beta with point-mass skills" }` → `NoPerformanceVariance`. That was never a parameter out of range — both values are individually valid, and it is their *combination* that leaves nothing varying. Putting it in `Parameter` would have made the enum a bag of conditions.
## Item 3, which item 2 is what makes possible
```
before drift variance is invalid: NaN
after drift variance must be finite and non-negative (got NaN)
before kinds: expected length 3, got 2
after the outcome describes a different number of teams than the event has:
expected 3, got 2
before Game::ranked: expected Outcome::Ranked, got Outcome::Scored
after expected Outcome::Ranked, got Outcome::Scored; call Game::scored for a
scored outcome
```
`Parameter::range()` states each parameter's actual bounds, which is exactly what a `&'static str` name could not do. `error::message_tests` renders every message and asserts each is a sentence rather than a label — including that none of them still contains the old `is invalid:` shape — and that the three above now carry a range or a next step.
## The four internal `MismatchedShape` kinds
`results`, `times`, `kinds` and the weights array collapse to `Shape::Internal`, whose `Display` says plainly that reaching it is a bug in this crate. They are checks on `add_events_with_prior`'s own parallel arrays and unreachable through the public API. I kept them as checked errors rather than turning them into `debug_assert!`s: release is where this crate's defects have tended to hide, and the CLAUDE.md rule about running tests in release exists for that reason.
Item 1 (`#[non_exhaustive]`) and item 4 (the `UnknownKeys` wildcard) were resolved earlier — see the previous comment for why the options structs deliberately stay constructible.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
1. The variants are not
#[non_exhaustive], so every future field is a breaking changeerror.rs:43marks the enum. No variant is marked. From a consumer crate, both of these compile today:#[non_exhaustive]on the enum only stops an exhaustivematchover variants. Adding a field to any variant, and downstream construction of any variant, are both in the public contract. This crate addedNotConvergedandGridTooCoarsein the last two days, so that matters.Same for
Outcome::Scored { scores, sigma }despite the enum being marked, and forConvergenceOptions,GameOptions,ConvergenceReport,Event,Team,Member, all of which have public fields and no marker.Fix:
#[non_exhaustive]on every struct variant and on the options/report structs. They alreadyimpl Default, so..Default::default()gives the ergonomic path. This is the single change most worth bundling now — every future field addition is otherwise a major bump.2. Six of fifteen variants use a
&'static strdiscriminator, carrying ~30 magic stringsMismatchedShape { kind }InvalidParameter { name }WrongOutcomeKind { context, expected, got }JointUnavailable { reason }ConflictingCompetitorConfig { field }NonFiniteResult { context }Three concrete problems:
(a)
MismatchedShape.kindmixes two levels. Four values name an internal array —"results","times","kinds", from the privateadd_events_with_priorchokepoint a caller has never heard of. Two are full English sentences duplicating the numeric fields:"predict_margin takes exactly 2 teams"alongsideexpected: 2. Nothing programmatic can be done with any of them, andDisplayrenderskinds: expected length 3, got 2— meaningless in a log.(b)
InvalidParameter { name, value: f64 }cannot carry a non-scalar.Outcome::try_winneralready contorts through it withvalue: f64::from(winner)for au32index. Apriorcheck has no representable value at all.(c)
InvalidProbability { value }is a bespoke variant for one scalar while every other scalar sharesInvalidParameter— and it omits the parameter name. MeanwhileHistoryBuilder::p_drawpanics on the identical value, so the same parameter has two mechanisms and one has its own variant.(d)
JointUnavailable { reason }is prose where three variants belong. The three reasons are conditions a caller would branch on differently: empty history (add events), ranked events present (usepredict_win_probabilities), not positive-definite (numerical). Todaymatches!(e, JointUnavailable { .. })is all you can do; distinguishing them means string-matching English.Proposed:
Keep the
Displayprose identical — it is genuinely good — but move the discriminating text into theDisplaymatch arms rather than into the data.3. Three
Displayimpls do not meet the standard the others setThe good ones tell the caller what to do, and are why this is worth fixing rather than accepting:
The exceptions:
InvalidParameter→{name} is invalid: {value}. Rendersdrift variance is invalid: NaN— no valid range, no remedy, no location. Every neighbour states one.MismatchedShape→{kind}: expected length {expected}, got {got}— see (a).WrongOutcomeKind→ accurate, but does not say the obvious next step (callGame::scored), unlike every other message in the file.4.
UnknownKeysis#[non_exhaustive]but wildcarded internallyhistory.rs:850and:1104both matchUnknownKeys::Prior => …then_ => Err(UnknownKey). Adding a third variant compiles silently and behaves asReject.#[non_exhaustive]on your own enum gives no exhaustiveness safety net if you then wildcard it. SpellRejectexplicitly at both sites — free, breaks nothing.Breaks: downstream
matchon these variants' fields; struct-literal construction ofConvergenceOptions/GameOptions(needs..Default::default()). This is the largest break in the audit and the reason to do it in one release.Found by an API audit, 2026-09-09.
Items 1 and 4 are resolved; 2 and 3 are a design change I am not making unilaterally.
1 — variants done, options structs deliberately not
Every struct variant of
InferenceErrorcarries#[non_exhaustive](that landed in the earlierapi/cleanupwork), andOutcome::Scoreddoes too.ConvergenceReportis marked as of6a893ff(merged asda55d2a) — it is only ever constructed byconverge/converge_partial, so it costs a caller nothing.ConvergenceOptionsandGameOptionsare staying constructible, against this issue's recommendation. I tried it, and it turns up a cost the issue did not anticipate:Default::defaultis not aconst fn, so the..Default::default()escape hatch — the whole reason marking an options struct is supposed to be cheap — does not exist in aconst.ConvergenceOptionsisCopyand a natural const; there is no workaround from outside the crate. Weighed against that, adding a field later is a one-time major bump, and this crate has been cutting those anyway. The reasoning is recorded on the type so it does not get rediscovered.The other structs the issue lists —
Event,Team,Member— are worse candidates still: struct-literal construction ofEventis the documented ingestion path, it appears in the README, andEventhas noDefaultto fall back to.4 — already fine
Both sites (
member_skillsand the linear-combination path) spellUnknownKeys::Rejectexplicitly. No wildcard remains.2 and 3 — left open, and they are one change not two
The typed-discriminator redesign is right, and item 3 depends on it:
InvalidParameter'sDisplaycannot state a valid range or a remedy without knowing which parameter, and getting that from a&'static strmeans matching on magic strings in theDisplayimpl — trading one stringly-typed site for another.Parameter,ShapeandOutcomeKindare what make item 3 writable.Two notes from working nearby:
NonFiniteResult { context, step }picked up a second meaning in #78: fromconvergethe pair is the sweep step, from a prediction it is the offending skill's own(mu, sigma). The field namestepis now wrong half the time. Whatever this issue does to the enum should split that or rename the field.UnknownKey { team, member, key }carriesteam: 0on the flat-key paths (resolve_targets, the linear-combination path), wherememberindexes a flat key list and there is no team. Documented as such for now, but it is the same shape of problem: one variant serving two situations.Three new
&'static strvalues have appeared since the audit —"prediction read a skill with no usable mean or variance…"and"beta with point-mass skills"— so the count in the table is a floor, not a ceiling.Items 2 and 3 are done, so this closes.
061c481(merged as61da3ac).Four enums replace the thirty-odd magic strings, close to what you sketched:
InvalidProbabilityfolds intoInvalidParameterasParameter::PDraw, as you proposed — it was a bespoke variant for one scalar that also omitted the parameter name.JointUnavailablesplits intoEmptyHistory,JointRequiresScoredEvents,NotPositiveDefinite. One test proved immediately that the distinction was load-bearing: my blanket conversion mapped the empty-history case onto the ranked one, andan_empty_history_has_no_jointfailed on the spot. Under the old prose field that would have been a passing test.Two splits beyond the issue
NonFiniteResult→NonFiniteStep { context, step }andNonFiniteSkill { mu, sigma }, as flagged in my earlier comment. Onestep: (f64, f64)was carrying a sweep step fromconvergeand a skill's own moments from a prediction; no field name is right for both.InvalidParameter { name: "beta with point-mass skills" }→NoPerformanceVariance. That was never a parameter out of range — both values are individually valid, and it is their combination that leaves nothing varying. Putting it inParameterwould have made the enum a bag of conditions.Item 3, which item 2 is what makes possible
Parameter::range()states each parameter's actual bounds, which is exactly what a&'static strname could not do.error::message_testsrenders every message and asserts each is a sentence rather than a label — including that none of them still contains the oldis invalid:shape — and that the three above now carry a range or a next step.The four internal
MismatchedShapekindsresults,times,kindsand the weights array collapse toShape::Internal, whoseDisplaysays plainly that reaching it is a bug in this crate. They are checks onadd_events_with_prior's own parallel arrays and unreachable through the public API. I kept them as checked errors rather than turning them intodebug_assert!s: release is where this crate's defects have tended to hide, and the CLAUDE.md rule about running tests in release exists for that reason.Item 1 (
#[non_exhaustive]) and item 4 (theUnknownKeyswildcard) were resolved earlier — see the previous comment for why the options structs deliberately stay constructible.