diff --git a/src/game.rs b/src/game.rs index cc58c4a..0977a48 100644 --- a/src/game.rs +++ b/src/game.rs @@ -431,6 +431,29 @@ impl<'a, T: Time, D: Drift> Game<'a, T, D> { } impl> 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]]) -> 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> 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]], outcome: crate::Outcome, options: &GameOptions, ) -> Result, 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> 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]], outcome: crate::Outcome, options: &GameOptions, ) -> Result, 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> 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>> = teams.iter().map(|t| t.to_vec()).collect(); let weights: Vec> = teams.iter().map(|t| vec![1.0; t.len()]).collect(); Ok(OwnedGame::new_scored( diff --git a/tests/game.rs b/tests/game.rs index 7fe7140..8f53dac 100644 --- a/tests/game.rs +++ b/tests/game.rs @@ -138,3 +138,115 @@ fn one_v_one_honours_convergence_options() { let (a_post, _) = Game::::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::::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::::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::::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::::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::::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::::ranked( + &[&[a], &[a]], + Outcome::winner(0, 2), + &GameOptions::default() + ) + .is_ok() + ); + assert!( + Game::::free_for_all( + &[&a, &a, &a], + Outcome::ranking([0, 1, 2]), + &GameOptions::default() + ) + .is_ok() + ); + assert!( + Game::::one_v_one(&a, &a, Outcome::winner(0, 2), &GameOptions::default()) + .is_ok() + ); + } +}