Files
trueskill-tt/tests/degenerate_inputs.rs
T
logaritmiskandClaude Opus 5 1ac3b21db5 fix: enforce EventBuilder weight/team length in release
Part of #18. `EventBuilder::weights` guarded the length match with a
`debug_assert!`, so release builds accepted a mismatch, silently dropped the
weights, and ingested the event anyway. That is the exact shape #18 is about:
validation that exists only where it is least needed.

The setters return `Self` to keep the chain fluent, so they cannot return a
`Result`. The builder now records the first failure and `commit` returns it as
`MismatchedShape`. The weights are not applied on mismatch either, so a
partially-weighted team cannot reach the history by another route.

Two tests in tests/degenerate_inputs.rs, whose CI job runs in release — which
is the only place the old behaviour differed.

The second test needed strengthening before it was worth anything. As first
written it committed a ONE-team event, which ingestion rejects for an unrelated
reason, so it passed under a mutation that disabled the whole check. It now
uses two teams, so ingestion would otherwise succeed and the assertion is
actually load-bearing. Both tests were then mutation-proved together: disabling
the error path in `commit` fails both in release.

#18 stays open. The remaining debug_asserts live in `ranked_with_arena` and
`scored_with_arena`, and promoting those means threading `Result` up through
`Event::compute`, `TimeSlice::iteration`, `log_evidence` and `filtered_step` —
which lands on the public API as `log_evidence() -> Result<f64>` and
`filtered_learning_curve() -> Result<...>`. That is a trade-off about what the
query API should look like, not a mechanical change, so it is not mine to
decide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2026-08-27 17:53:37 +02:00

321 lines
9.3 KiB
Rust

//! Degenerate, boundary, and error-path coverage.
//!
//! These run in both debug and release: the defects they pin were all
//! guarded only by `debug_assert!`, so a debug-only suite never saw them.
use trueskill_tt::{
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, History, InferenceError,
NullObserver, Outcome, Rating,
};
type R = Rating<i64, ConstantDrift>;
fn rating() -> R {
R::new(
Gaussian::from_ms(25.0, 25.0 / 3.0),
25.0 / 6.0,
ConstantDrift(25.0 / 300.0),
)
}
fn assert_finite(g: Gaussian, what: &str) {
assert!(
g.mu().is_finite() && g.sigma().is_finite(),
"{what} must be finite, got mu={} sigma={}",
g.mu(),
g.sigma()
);
}
#[test]
fn record_draw_without_draw_probability_is_rejected() {
let mut h = History::default();
let err = h.record_draw(&"a", &"b", 1).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
#[test]
fn builder_draw_without_draw_probability_is_rejected() {
let mut h = History::default();
let err = h
.event(1)
.team(["a"])
.team(["b"])
.draw()
.commit()
.unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
#[test]
fn draw_with_positive_draw_probability_is_finite() {
let mut h = History::builder().p_draw(0.25).build();
h.record_draw(&"a", &"b", 1).unwrap();
let report = h.converge().unwrap();
assert_finite(h.current_skill("a").unwrap(), "drawn competitor skill");
assert_finite(h.current_skill("b").unwrap(), "drawn competitor skill");
assert!(report.log_evidence.is_finite());
assert!(report.converged);
}
#[test]
fn game_ranked_rejects_tie_without_draw_probability() {
let a = [rating()];
let b = [rating()];
let teams: Vec<&[R]> = vec![&a, &b];
let err = Game::ranked(&teams, Outcome::draw(2), &GameOptions::default()).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
}
/// `Outcome::winner(w, n)` ties every loser, so any n >= 3 free-for-all hits
/// the tie path even though the caller never asked for a draw.
#[test]
fn winner_of_three_or_more_requires_draw_probability() {
let a = [rating()];
let b = [rating()];
let c = [rating()];
let teams: Vec<&[R]> = vec![&a, &b, &c];
let err = Game::ranked(&teams, Outcome::winner(0, 3), &GameOptions::default()).unwrap_err();
assert!(matches!(
err,
InferenceError::TieWithoutDrawProbability { .. }
));
let opts = GameOptions {
p_draw: 0.1,
..GameOptions::default()
};
let game = Game::ranked(&teams, Outcome::winner(0, 3), &opts).unwrap();
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "3-team winner posterior");
}
}
}
#[test]
fn full_ranking_without_ties_needs_no_draw_probability() {
let a = [rating()];
let b = [rating()];
let c = [rating()];
let teams: Vec<&[R]> = vec![&a, &b, &c];
let game = Game::ranked(&teams, Outcome::ranking([0, 1, 2]), &GameOptions::default()).unwrap();
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "strict ranking posterior");
}
}
}
#[test]
fn empty_history_converges_trivially() {
let mut h = History::default();
let report = h.converge().unwrap();
assert_eq!(report.iterations, 0);
assert!(report.converged);
}
/// Issue #27's exact reproduction: a non-default key type reaching `converge`
/// with no events at all. The underflow it reported trapped in debug and
/// indexed out of bounds in release, so this must run in both profiles.
#[test]
fn converge_on_an_empty_history_with_owned_keys() {
let mut history: History<i64, ConstantDrift, NullObserver, String> =
History::builder_with_key().score_sigma(5.0).build();
let report = history.converge().unwrap();
assert_eq!(report.iterations, 0);
assert!(report.converged);
}
/// A weights/team length mismatch used to be a `debug_assert!`, so release
/// builds ingested the event with the weights silently unapplied. This file's
/// CI job runs in release too, which is the point of pinning it here.
#[test]
fn event_builder_rejects_a_weights_length_mismatch() {
let mut h = History::default();
let err = h
.event(1)
.team(["a"])
.weights([1.0, 2.0])
.team(["b"])
.winner(0)
.commit()
.unwrap_err();
assert!(
matches!(
err,
InferenceError::MismatchedShape {
kind: "weights",
expected: 1,
got: 2,
}
),
"expected a weights MismatchedShape, got {err:?}"
);
}
/// The mismatch must not be applied even partially — a half-weighted team
/// reaching the history would be worse than the error.
#[test]
fn event_builder_weights_mismatch_leaves_the_history_untouched() {
let mut h = History::default();
// Two teams, so ingestion would otherwise succeed — a one-team event is
// rejected for an unrelated reason and would pass this vacuously.
let _ = h
.event(1)
.team(["a"])
.weights([1.0, 2.0])
.team(["b"])
.winner(0)
.commit();
assert!(h.learning_curve("a").is_empty());
}
#[test]
fn empty_event_stream_then_converge() {
let mut h = History::default();
h.add_events(std::iter::empty()).unwrap();
let report = h.converge().unwrap();
assert_eq!(report.iterations, 0);
}
#[test]
fn empty_history_queries_do_not_panic() {
let h = History::default();
assert!(h.learning_curves().is_empty());
assert!(h.learning_curve("nobody").is_empty());
assert!(h.current_skill("nobody").is_none());
}
#[test]
fn single_event_history_converges() {
let mut h = History::default();
h.record_winner(&"a", &"b", 1).unwrap();
let report = h.converge().unwrap();
assert!(report.converged);
assert_finite(h.current_skill("a").unwrap(), "single-event skill");
}
#[test]
fn scored_event_rejects_non_positive_sigma() {
let mut h = History::builder().score_sigma(2.0).build();
let err = h
.event(1)
.team(["a"])
.team(["b"])
.scores_with_sigma([3.0, 1.0], f64::NAN)
.commit()
.unwrap_err();
assert!(matches!(
err,
InferenceError::InvalidParameter {
name: "score_sigma",
..
}
));
}
#[test]
fn convergence_reports_are_finite_across_many_teams() {
let opts = GameOptions {
p_draw: 0.1,
convergence: ConvergenceOptions::default(),
..GameOptions::default()
};
let holders: Vec<[R; 1]> = (0..12).map(|_| [rating()]).collect();
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
let game = Game::ranked(&teams, Outcome::ranking(0..12), &opts).unwrap();
assert!(
game.log_evidence().is_finite(),
"12-team log-evidence must be finite, got {}",
game.log_evidence()
);
for team in game.posteriors() {
for skill in team {
assert_finite(skill, "12-team posterior");
}
}
}
/// A long diff chain underflows a linear evidence product: each link
/// contributes a probability in (0, 1], so ~1000 links flush the product to
/// exactly 0.0 and `ln(0.0)` is `-inf`. Accumulating in log space keeps it
/// finite.
#[test]
fn log_evidence_survives_a_long_diff_chain() {
let holders: Vec<[R; 1]> = (0..1200).map(|_| [rating()]).collect();
let teams: Vec<&[R]> = holders.iter().map(|t| t.as_slice()).collect();
let game = Game::ranked(
&teams,
Outcome::ranking(0..holders.len() as u32),
&GameOptions::default(),
)
.unwrap();
let log_evidence = game.log_evidence();
assert!(
log_evidence.is_finite(),
"1200-team log-evidence must be finite, got {log_evidence}"
);
assert!(
log_evidence < 0.0,
"log-evidence of a probability must be negative, got {log_evidence}"
);
}
/// A near-certain outcome rounds the losing tail to exactly zero in the
/// `erfc` approximation; the evidence floor keeps `ln` finite.
#[test]
fn log_evidence_finite_for_near_certain_outcome() {
let overwhelming = R::new(Gaussian::from_ms(5_000.0, 0.5), 1.0, ConstantDrift(0.0));
let hopeless = R::new(Gaussian::from_ms(-5_000.0, 0.5), 1.0, ConstantDrift(0.0));
let a = [overwhelming];
let b = [hopeless];
let teams: Vec<&[R]> = vec![&a, &b];
let game = Game::ranked(&teams, Outcome::winner(0, 2), &GameOptions::default()).unwrap();
assert!(
game.log_evidence().is_finite(),
"got {}",
game.log_evidence()
);
// And the reverse — a colossal upset — must also stay finite.
let upset = Game::ranked(&teams, Outcome::winner(1, 2), &GameOptions::default()).unwrap();
assert!(
upset.log_evidence().is_finite(),
"upset log-evidence must be finite, got {}",
upset.log_evidence()
);
}
#[test]
fn empty_history_has_no_filtered_estimates() {
let history: History = History::builder().build();
assert_eq!(history.filtered_log_evidence(), 0.0);
assert!(history.filtered_learning_curves().is_empty());
assert!(history.filtered_learning_curve("nobody").is_empty());
}