fix!: reject malformed games at the Game boundary too
I fixed this at `History`'s ingestion chokepoint and said the boundary was complete. It was not. `Game` is a separate public entry point that does not pass through that chokepoint, and every one of the same four defects was still live there: Game::ranked(&[&[a]], ..) -> PANIC at src/game.rs:317 Game::scored(&[&[a]], ..) -> PANIC at src/game.rs:317 Game::ranked(&[&[], &[a]]) -> Ok, finite posterior for the opponent Game::scored(.., [NaN, 1]) -> Ok The same panic, from safe API, in release. Fixing one path and generalising from it is exactly the mistake that produced the latest-slice joint bug: validating on the shape that cannot expose the problem, then reporting the property as held. `Game::validate_teams` is shared by `ranked` and `scored`, with the non-finite score check in `scored` alongside it. Ranks need no equivalent — they are `u32`. `one_v_one` and `free_for_all` build their teams internally and are unaffected; a test asserts all three well-formed constructors still succeed, so the check cannot quietly widen. BREAKING CHANGE: `Game::ranked` and `Game::scored` return `NotEnoughTeams`, `EmptyTeam` or `InvalidParameter` for inputs they previously panicked on or silently accepted. Refs #18, #26 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+39
@@ -431,6 +431,29 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
/// Reject the team shapes inference cannot represent.
|
||||
///
|
||||
/// `run_chain` builds one diff link per adjacent pair of teams, so fewer
|
||||
/// than two teams leaves it indexing `links[1..]` on an empty vector — a
|
||||
/// panic, in release, from safe API. An empty team is the quiet half: it
|
||||
/// contributes no performance, so a malformed game returns a finite,
|
||||
/// plausible-looking posterior for whoever it was matched against.
|
||||
///
|
||||
/// `History` validates the same two things at its own ingestion
|
||||
/// chokepoint. `Game` is a separate public entry point that does not pass
|
||||
/// through it, so it needs its own check rather than inheriting one.
|
||||
fn validate_teams(teams: &[&[Rating<T, D>]]) -> Result<(), crate::InferenceError> {
|
||||
if teams.len() < 2 {
|
||||
return Err(crate::InferenceError::NotEnoughTeams { got: teams.len() });
|
||||
}
|
||||
for (team, members) in teams.iter().enumerate() {
|
||||
if members.is_empty() {
|
||||
return Err(crate::InferenceError::EmptyTeam { team });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
||||
@@ -442,12 +465,15 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
||||
/// `p_draw` is zero: the truncation margin is then zero and the two-sided
|
||||
/// tie update evaluates `0/0`.
|
||||
/// - `NotEnoughTeams` for fewer than two teams, and `EmptyTeam` for a team
|
||||
/// with no members.
|
||||
pub fn ranked(
|
||||
teams: &[&[Rating<T, D>]],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
options.convergence.validate()?;
|
||||
Self::validate_teams(teams)?;
|
||||
if !(0.0..1.0).contains(&options.p_draw) {
|
||||
return Err(crate::InferenceError::InvalidProbability {
|
||||
value: options.p_draw,
|
||||
@@ -499,12 +525,15 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
/// or is NaN, or if `options.convergence` is out of range.
|
||||
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
|
||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
|
||||
/// - `NotEnoughTeams` for fewer than two teams, `EmptyTeam` for a team with
|
||||
/// no members, and `InvalidParameter` for a non-finite score.
|
||||
pub fn scored(
|
||||
teams: &[&[Rating<T, D>]],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
options.convergence.validate()?;
|
||||
Self::validate_teams(teams)?;
|
||||
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "score_sigma",
|
||||
@@ -526,6 +555,16 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
got: "Outcome::Ranked",
|
||||
})?
|
||||
.to_vec();
|
||||
// A non-finite score poisons the chain rather than failing it. Ranks
|
||||
// need no equivalent: they are `u32`.
|
||||
for value in &scores {
|
||||
if !value.is_finite() {
|
||||
return Err(crate::InferenceError::InvalidParameter {
|
||||
name: "score",
|
||||
value: *value,
|
||||
});
|
||||
}
|
||||
}
|
||||
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
|
||||
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||
Ok(OwnedGame::new_scored(
|
||||
|
||||
+112
@@ -138,3 +138,115 @@ fn one_v_one_honours_convergence_options() {
|
||||
let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap();
|
||||
assert!(a_post.mu() > 25.0);
|
||||
}
|
||||
|
||||
/// `Game` is a public entry point that does not pass through `History`'s
|
||||
/// ingestion chokepoint, so it needs its own boundary — and did not have one.
|
||||
///
|
||||
/// A one-team game panicked at `src/game.rs:317` with "range start index 1 out
|
||||
/// of range for slice of length 0", in release, from safe API. This is the
|
||||
/// same defect `tests/ingestion_shape.rs` covers for `History`; fixing that
|
||||
/// path left this one open, because they share no validation.
|
||||
mod malformed_games {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_one_team_ranked_game_is_an_error_not_a_panic() {
|
||||
let a = default_rating();
|
||||
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_team_scored_game_is_an_error_not_a_panic() {
|
||||
let a = default_rating();
|
||||
let err = Game::<i64, _>::scored(
|
||||
&[&[a]],
|
||||
Outcome::scores([1.0]),
|
||||
&GameOptions {
|
||||
score_sigma: 1.0,
|
||||
..GameOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_team_game_is_an_error() {
|
||||
let err =
|
||||
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The quiet half: an empty team contributed no performance, so the game
|
||||
/// returned a finite posterior for its opponent as though it had won one.
|
||||
#[test]
|
||||
fn an_empty_team_is_an_error() {
|
||||
let a = default_rating();
|
||||
let err =
|
||||
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0 }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_finite_score_is_an_error() {
|
||||
let a = default_rating();
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let err = Game::<i64, _>::scored(
|
||||
&[&[a], &[a]],
|
||||
Outcome::scores([bad, 1.0]),
|
||||
&GameOptions {
|
||||
score_sigma: 1.0,
|
||||
..GameOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::InvalidParameter { name: "score", .. }),
|
||||
"{bad}: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `free_for_all` and `one_v_one` build their teams internally, so they
|
||||
/// must keep working — the check must not catch well-formed games.
|
||||
#[test]
|
||||
fn well_formed_games_are_untouched() {
|
||||
let a = default_rating();
|
||||
assert!(
|
||||
Game::<i64, _>::ranked(
|
||||
&[&[a], &[a]],
|
||||
Outcome::winner(0, 2),
|
||||
&GameOptions::default()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
Game::<i64, _>::free_for_all(
|
||||
&[&a, &a, &a],
|
||||
Outcome::ranking([0, 1, 2]),
|
||||
&GameOptions::default()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
Game::<i64, _>::one_v_one(&a, &a, Outcome::winner(0, 2), &GameOptions::default())
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user