Merge branch 'fix/game-boundary'
Reject malformed games at the Game entry point, which does not pass through History's ingestion chokepoint. 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> {
|
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
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// - `InvalidParameter` if `options.convergence` is out of range — an
|
/// - `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
|
/// - `TieWithoutDrawProbability` if the outcome ties two teams while
|
||||||
/// `p_draw` is zero: the truncation margin is then zero and the two-sided
|
/// `p_draw` is zero: the truncation margin is then zero and the two-sided
|
||||||
/// tie update evaluates `0/0`.
|
/// tie update evaluates `0/0`.
|
||||||
|
/// - `NotEnoughTeams` for fewer than two teams, and `EmptyTeam` for a team
|
||||||
|
/// with no members.
|
||||||
pub fn ranked(
|
pub fn ranked(
|
||||||
teams: &[&[Rating<T, D>]],
|
teams: &[&[Rating<T, D>]],
|
||||||
outcome: crate::Outcome,
|
outcome: crate::Outcome,
|
||||||
options: &GameOptions,
|
options: &GameOptions,
|
||||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||||
options.convergence.validate()?;
|
options.convergence.validate()?;
|
||||||
|
Self::validate_teams(teams)?;
|
||||||
if !(0.0..1.0).contains(&options.p_draw) {
|
if !(0.0..1.0).contains(&options.p_draw) {
|
||||||
return Err(crate::InferenceError::InvalidProbability {
|
return Err(crate::InferenceError::InvalidProbability {
|
||||||
value: options.p_draw,
|
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.
|
/// or is NaN, or if `options.convergence` is out of range.
|
||||||
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
|
/// - `MismatchedShape` if the outcome's score count differs from `teams.len()`.
|
||||||
/// - `WrongOutcomeKind` if `outcome` is not `Outcome::Scored`.
|
/// - `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(
|
pub fn scored(
|
||||||
teams: &[&[Rating<T, D>]],
|
teams: &[&[Rating<T, D>]],
|
||||||
outcome: crate::Outcome,
|
outcome: crate::Outcome,
|
||||||
options: &GameOptions,
|
options: &GameOptions,
|
||||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||||
options.convergence.validate()?;
|
options.convergence.validate()?;
|
||||||
|
Self::validate_teams(teams)?;
|
||||||
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
if options.score_sigma <= 0.0 || options.score_sigma.is_nan() {
|
||||||
return Err(crate::InferenceError::InvalidParameter {
|
return Err(crate::InferenceError::InvalidParameter {
|
||||||
name: "score_sigma",
|
name: "score_sigma",
|
||||||
@@ -526,6 +555,16 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
got: "Outcome::Ranked",
|
got: "Outcome::Ranked",
|
||||||
})?
|
})?
|
||||||
.to_vec();
|
.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 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();
|
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
|
||||||
Ok(OwnedGame::new_scored(
|
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();
|
let (a_post, _) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2), &options).unwrap();
|
||||||
assert!(a_post.mu() > 25.0);
|
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