fix!: correct eight wrong # Errors sections and seal the error variants
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
This commit is contained in:
@@ -54,6 +54,7 @@ fn hitting_the_cap_is_an_error() {
|
||||
iterations,
|
||||
final_step,
|
||||
epsilon,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(iterations, 1);
|
||||
assert!(
|
||||
|
||||
@@ -160,6 +160,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
|
||||
kind: "weights",
|
||||
expected: 1,
|
||||
got: 2,
|
||||
..
|
||||
}
|
||||
),
|
||||
"expected a weights MismatchedShape, got {err:?}"
|
||||
|
||||
@@ -277,13 +277,11 @@ fn reject(scale: f64) -> InferenceError {
|
||||
|
||||
#[test]
|
||||
fn negative_scale_is_rejected() {
|
||||
assert_eq!(
|
||||
assert!(matches!(
|
||||
reject(-1.0),
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
value: -1.0
|
||||
}
|
||||
);
|
||||
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
|
||||
if value == -1.0
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -135,7 +135,8 @@ fn weights_still_guards_a_members_team() {
|
||||
InferenceError::MismatchedShape {
|
||||
kind: "weights",
|
||||
expected: 2,
|
||||
got: 1
|
||||
got: 1,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{err:?}"
|
||||
|
||||
+4
-4
@@ -155,7 +155,7 @@ mod malformed_games {
|
||||
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -173,7 +173,7 @@ mod malformed_games {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -184,7 +184,7 @@ mod malformed_games {
|
||||
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -198,7 +198,7 @@ mod malformed_games {
|
||||
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0 }),
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ fn a_one_team_event_is_an_error_not_a_panic() {
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ fn a_zero_team_event_is_an_error() {
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ fn an_empty_team_is_an_error_rather_than_a_free_win() {
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0 }),
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
// Nothing was recorded, so the history is still empty.
|
||||
@@ -93,7 +93,7 @@ fn an_empty_team_is_reported_by_position() {
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 1 }),
|
||||
matches!(err, InferenceError::EmptyTeam { team: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -170,7 +170,7 @@ fn the_event_builder_inherits_the_shape_checks() {
|
||||
let mut h = history();
|
||||
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ fn overflow_during_inference_is_reported_not_hidden() {
|
||||
|
||||
for (name, sigma, beta, score_sigma, scores) in cases {
|
||||
match scored_fit(sigma, beta, score_sigma, scores) {
|
||||
Err(InferenceError::NonFiniteResult { context, step }) => {
|
||||
Err(InferenceError::NonFiniteResult { context, step, .. }) => {
|
||||
assert_eq!(context, "History::converge", "{name}");
|
||||
assert!(
|
||||
!step.0.is_finite() || !step.1.is_finite(),
|
||||
|
||||
@@ -148,6 +148,6 @@ fn shape_errors_are_reported() {
|
||||
let empty: [&&str; 0] = [];
|
||||
assert!(matches!(
|
||||
h.predict_margin(&[&[&"veteran"], &empty]),
|
||||
Err(InferenceError::EmptyTeam { team: 1 })
|
||||
Err(InferenceError::EmptyTeam { team: 1, .. })
|
||||
));
|
||||
}
|
||||
|
||||
+31
-37
@@ -20,13 +20,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
||||
let err = h
|
||||
.predict_outcome(&[&[&"a"], &[&"ghost"]])
|
||||
.expect_err("an unknown key must not yield a confident prediction");
|
||||
assert_eq!(
|
||||
err,
|
||||
InferenceError::UnknownKey {
|
||||
team: 1,
|
||||
member: 0,
|
||||
key: "\"ghost\"".to_owned(),
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
&err,
|
||||
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||
if key == "\"ghost\""
|
||||
),
|
||||
"{err:?}"
|
||||
);
|
||||
|
||||
// Every prediction entry point, not just one.
|
||||
@@ -42,13 +42,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
||||
fn an_entirely_unknown_team_is_an_error() {
|
||||
let h = history_with(&["a", "b"], 0.0);
|
||||
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
InferenceError::UnknownKey {
|
||||
team: 1,
|
||||
member: 0,
|
||||
key: "\"x\"".to_owned(),
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
&err,
|
||||
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||
if key == "\"x\""
|
||||
),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,18 +56,18 @@ fn an_entirely_unknown_team_is_an_error() {
|
||||
fn degenerate_team_shapes_are_errors_rather_than_panics() {
|
||||
let h = history_with(&["a", "b"], 0.0);
|
||||
|
||||
assert_eq!(
|
||||
assert!(matches!(
|
||||
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
|
||||
InferenceError::NotEnoughTeams { got: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
InferenceError::NotEnoughTeams { got: 1, .. }
|
||||
),);
|
||||
assert!(matches!(
|
||||
h.predict_outcome(&[]).unwrap_err(),
|
||||
InferenceError::NotEnoughTeams { got: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
InferenceError::NotEnoughTeams { got: 0, .. }
|
||||
),);
|
||||
assert!(matches!(
|
||||
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
|
||||
InferenceError::EmptyTeam { team: 1 }
|
||||
);
|
||||
InferenceError::EmptyTeam { team: 1, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -93,13 +93,10 @@ fn the_outcome_space_is_capped_rather_than_hanging() {
|
||||
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
|
||||
|
||||
let err = h.predict_outcome(&refs).unwrap_err();
|
||||
assert_eq!(
|
||||
assert!(matches!(
|
||||
err,
|
||||
InferenceError::TooManyTeams {
|
||||
got: 8,
|
||||
max: MAX_PREDICTED_TEAMS
|
||||
}
|
||||
);
|
||||
InferenceError::TooManyTeams { got: 8, max, .. } if max == MAX_PREDICTED_TEAMS
|
||||
));
|
||||
|
||||
// The cheap paths stay available at any size.
|
||||
let wins = h.predict_win_probabilities(&refs).unwrap();
|
||||
@@ -282,15 +279,12 @@ fn information_gain_respects_the_entropy_ceiling() {
|
||||
#[test]
|
||||
fn information_gain_reports_unknown_keys() {
|
||||
let h = history_with(&["a", "b"], 0.0);
|
||||
assert_eq!(
|
||||
h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
|
||||
assert!(matches!(
|
||||
&h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
|
||||
.unwrap_err(),
|
||||
InferenceError::UnknownKey {
|
||||
team: 1,
|
||||
member: 0,
|
||||
key: "\"ghost\"".to_owned(),
|
||||
}
|
||||
);
|
||||
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||
if key == "\"ghost\""
|
||||
));
|
||||
}
|
||||
|
||||
/// A draw-enabled history has three outcomes to weigh rather than two, so the
|
||||
|
||||
@@ -154,7 +154,7 @@ fn the_known_ceiling_violation_no_longer_answers_wrongly() {
|
||||
gain <= 2.0_f64.ln() + 1e-9,
|
||||
"returned {gain}, over the ln 2 ceiling"
|
||||
),
|
||||
Err(InferenceError::GridTooCoarse { needed, max }) => {
|
||||
Err(InferenceError::GridTooCoarse { needed, max, .. }) => {
|
||||
assert!(needed > max, "needed {needed} should exceed max {max}");
|
||||
}
|
||||
Err(e) => panic!("unexpected error {e:?}"),
|
||||
|
||||
Reference in New Issue
Block a user