fix: reject ties without draw probability; never report NaN as converged
A tie with `p_draw == 0.0` produced NaN posteriors in release builds and `converge()` reported `converged: true`, because every comparison against NaN is false and `tuple_gt` therefore read NaN as "below epsilon". Two independent defects, fixed together: - Ingestion now rejects tied outcomes when the draw probability is zero, promoting the existing `debug_assert!` in `Game::ranked_with_arena` to a real `InferenceError::TieWithoutDrawProbability`. Validation sits in `add_events_with_prior`, the chokepoint every route reaches — including `record_draw`, which bypasses `Outcome` entirely. - `converge()` treats a non-finite step as failure and returns `InferenceError::NonFiniteResult` rather than claiming convergence. Also in this change: - `History::converge()` on an empty history returned a `usize` underflow panic from `0..len()-1`; it now short-circuits to a zero-iteration report. - `Outcome::scores_with_sigma` no longer panics on a non-positive sigma; the value is validated at ingestion so callers get an error instead. - `InferenceError` gains `WrongOutcomeKind`, replacing the misuse of `MismatchedShape` for variant mismatches (which rendered as the nonsense "expected length 0, got 0"), and is now `#[non_exhaustive]`. Note `Outcome::winner(w, n)` for n >= 3 ties every loser, so those events now require a positive `p_draw`. They previously returned NaN. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub enum InferenceError {
|
pub enum InferenceError {
|
||||||
/// Expected and actual lengths of some array-shaped input differ.
|
/// Expected and actual lengths of some array-shaped input differ.
|
||||||
MismatchedShape {
|
MismatchedShape {
|
||||||
@@ -8,15 +9,35 @@ pub enum InferenceError {
|
|||||||
expected: usize,
|
expected: usize,
|
||||||
got: usize,
|
got: usize,
|
||||||
},
|
},
|
||||||
|
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
||||||
|
WrongOutcomeKind {
|
||||||
|
context: &'static str,
|
||||||
|
expected: &'static str,
|
||||||
|
got: &'static str,
|
||||||
|
},
|
||||||
/// A probability value is outside `[0, 1]`.
|
/// A probability value is outside `[0, 1]`.
|
||||||
InvalidProbability { value: f64 },
|
InvalidProbability { value: f64 },
|
||||||
/// A scalar parameter is outside its valid range.
|
/// A scalar parameter is outside its valid range.
|
||||||
InvalidParameter { name: &'static str, value: f64 },
|
InvalidParameter { name: &'static str, value: f64 },
|
||||||
|
/// An event contains tied teams, but the draw probability is zero.
|
||||||
|
///
|
||||||
|
/// A zero draw probability asserts that draws cannot occur, so a tied
|
||||||
|
/// result has no representable likelihood. Configure a positive `p_draw`
|
||||||
|
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
||||||
|
TieWithoutDrawProbability { teams: (usize, usize) },
|
||||||
/// Convergence exceeded `max_iter` without falling below `epsilon`.
|
/// Convergence exceeded `max_iter` without falling below `epsilon`.
|
||||||
ConvergenceFailed {
|
ConvergenceFailed {
|
||||||
last_step: (f64, f64),
|
last_step: (f64, f64),
|
||||||
iterations: usize,
|
iterations: usize,
|
||||||
},
|
},
|
||||||
|
/// Inference produced a non-finite value (NaN or infinity).
|
||||||
|
///
|
||||||
|
/// Indicates numerical breakdown; the resulting skills are meaningless
|
||||||
|
/// and must not be treated as a converged estimate.
|
||||||
|
NonFiniteResult {
|
||||||
|
context: &'static str,
|
||||||
|
step: (f64, f64),
|
||||||
|
},
|
||||||
/// Negative precision: a Gaussian with `pi < 0` slipped into an API call.
|
/// Negative precision: a Gaussian with `pi < 0` slipped into an API call.
|
||||||
NegativePrecision { pi: f64 },
|
NegativePrecision { pi: f64 },
|
||||||
}
|
}
|
||||||
@@ -31,9 +52,29 @@ impl fmt::Display for InferenceError {
|
|||||||
} => {
|
} => {
|
||||||
write!(f, "{kind}: expected length {expected}, got {got}")
|
write!(f, "{kind}: expected length {expected}, got {got}")
|
||||||
}
|
}
|
||||||
|
Self::WrongOutcomeKind {
|
||||||
|
context,
|
||||||
|
expected,
|
||||||
|
got,
|
||||||
|
} => {
|
||||||
|
write!(f, "{context}: expected {expected}, got {got}")
|
||||||
|
}
|
||||||
Self::InvalidProbability { value } => {
|
Self::InvalidProbability { value } => {
|
||||||
write!(f, "probability must be in [0, 1]; got {value}")
|
write!(f, "probability must be in [0, 1]; got {value}")
|
||||||
}
|
}
|
||||||
|
Self::TieWithoutDrawProbability { teams } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"teams {} and {} are tied, but p_draw is 0.0; set a positive draw probability to admit ties",
|
||||||
|
teams.0, teams.1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::NonFiniteResult { context, step } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{context}: inference produced a non-finite result (step = {step:?})"
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::InvalidParameter { name, value } => {
|
Self::InvalidParameter { name, value } => {
|
||||||
write!(f, "{name} is invalid: {value}")
|
write!(f, "{name} is invalid: {value}")
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-9
@@ -458,11 +458,18 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
|
|
||||||
let ranks = outcome
|
let ranks = outcome
|
||||||
.as_ranks()
|
.as_ranks()
|
||||||
.ok_or(crate::InferenceError::MismatchedShape {
|
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||||
kind: "Game::ranked requires Outcome::Ranked",
|
context: "Game::ranked",
|
||||||
expected: 0,
|
expected: "Outcome::Ranked",
|
||||||
got: 0,
|
got: "Outcome::Scored",
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if options.p_draw == 0.0
|
||||||
|
&& let Some(tied) = crate::first_tied_pair(ranks)
|
||||||
|
{
|
||||||
|
return Err(crate::InferenceError::TieWithoutDrawProbability { teams: tied });
|
||||||
|
}
|
||||||
|
|
||||||
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
|
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
|
||||||
let result: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect();
|
let result: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).collect();
|
||||||
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();
|
||||||
@@ -497,10 +504,10 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
|||||||
}
|
}
|
||||||
let scores = outcome
|
let scores = outcome
|
||||||
.as_scores()
|
.as_scores()
|
||||||
.ok_or(crate::InferenceError::MismatchedShape {
|
.ok_or(crate::InferenceError::WrongOutcomeKind {
|
||||||
kind: "Game::scored requires Outcome::Scored",
|
context: "Game::scored",
|
||||||
expected: 0,
|
expected: "Outcome::Scored",
|
||||||
got: 0,
|
got: "Outcome::Ranked",
|
||||||
})?
|
})?
|
||||||
.to_vec();
|
.to_vec();
|
||||||
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();
|
||||||
@@ -1124,7 +1131,10 @@ mod tests {
|
|||||||
&GameOptions::default(),
|
&GameOptions::default(),
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, crate::InferenceError::MismatchedShape { .. }));
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
crate::InferenceError::WrongOutcomeKind { .. }
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+53
-5
@@ -220,6 +220,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
fn iteration(&mut self) -> (f64, f64) {
|
fn iteration(&mut self) -> (f64, f64) {
|
||||||
let mut step = (0.0, 0.0);
|
let mut step = (0.0, 0.0);
|
||||||
|
|
||||||
|
if self.time_slices.is_empty() {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
competitor::clean(self.agents.values_mut(), false);
|
competitor::clean(self.agents.values_mut(), false);
|
||||||
|
|
||||||
for j in (0..self.time_slices.len() - 1).rev() {
|
for j in (0..self.time_slices.len() - 1).rev() {
|
||||||
@@ -435,6 +439,18 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
let opts = self.convergence;
|
let opts = self.convergence;
|
||||||
|
|
||||||
|
if self.time_slices.is_empty() {
|
||||||
|
return Ok(ConvergenceReport {
|
||||||
|
iterations: 0,
|
||||||
|
final_step: (0.0, 0.0),
|
||||||
|
log_evidence: 0.0,
|
||||||
|
converged: true,
|
||||||
|
per_iteration_time: SmallVec::new(),
|
||||||
|
slices_skipped: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let mut step = (f64::INFINITY, f64::INFINITY);
|
let mut step = (f64::INFINITY, f64::INFINITY);
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
let mut per_iter: SmallVec<[std::time::Duration; 32]> = SmallVec::new();
|
let mut per_iter: SmallVec<[std::time::Duration; 32]> = SmallVec::new();
|
||||||
@@ -444,8 +460,24 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
per_iter.push(t0.elapsed());
|
per_iter.push(t0.elapsed());
|
||||||
i += 1;
|
i += 1;
|
||||||
self.observer.on_iteration_end(i, step);
|
self.observer.on_iteration_end(i, step);
|
||||||
|
|
||||||
|
// A non-finite step means EP has broken down; further iterations
|
||||||
|
// cannot recover, and `tuple_gt` would read NaN as converged.
|
||||||
|
if !crate::step_is_finite(step) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
let converged = !tuple_gt(step, opts.epsilon);
|
}
|
||||||
|
|
||||||
|
if !crate::step_is_finite(step) {
|
||||||
|
self.observer.on_converged(i, step, false);
|
||||||
|
|
||||||
|
return Err(InferenceError::NonFiniteResult {
|
||||||
|
context: "History::converge",
|
||||||
|
step,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let converged = crate::step_converged(step, opts.epsilon);
|
||||||
let log_evidence = self.log_evidence_internal(false, &[]);
|
let log_evidence = self.log_evidence_internal(false, &[]);
|
||||||
self.observer.on_converged(i, step, converged);
|
self.observer.on_converged(i, step, converged);
|
||||||
Ok(ConvergenceReport {
|
Ok(ConvergenceReport {
|
||||||
@@ -498,6 +530,19 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chokepoint for tie validation: every ingestion route lands here,
|
||||||
|
// including `record_draw`, which builds its results directly rather
|
||||||
|
// than going through `Outcome`.
|
||||||
|
if self.p_draw == 0.0 {
|
||||||
|
for (event_results, kind) in results.iter().zip(kinds.iter()) {
|
||||||
|
if matches!(kind, EventKind::Ranked)
|
||||||
|
&& let Some(tied) = crate::first_tied_output(event_results)
|
||||||
|
{
|
||||||
|
return Err(InferenceError::TieWithoutDrawProbability { teams: tied });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
competitor::clean(self.agents.values_mut(), true);
|
competitor::clean(self.agents.values_mut(), true);
|
||||||
|
|
||||||
let mut this_agent = Vec::with_capacity(1024);
|
let mut this_agent = Vec::with_capacity(1024);
|
||||||
@@ -734,10 +779,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
}
|
}
|
||||||
crate::Outcome::Scored { scores, sigma } => {
|
crate::Outcome::Scored { scores, sigma } => {
|
||||||
let resolved = sigma.unwrap_or(self.score_sigma);
|
let resolved = sigma.unwrap_or(self.score_sigma);
|
||||||
debug_assert!(
|
if !(resolved > 0.0) {
|
||||||
resolved > 0.0,
|
return Err(InferenceError::InvalidParameter {
|
||||||
"resolved score_sigma must be > 0.0 (got {resolved})"
|
name: "score_sigma",
|
||||||
);
|
value: resolved,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
kinds.push(EventKind::Scored {
|
kinds.push(EventKind::Scored {
|
||||||
score_sigma: resolved,
|
score_sigma: resolved,
|
||||||
});
|
});
|
||||||
|
|||||||
+50
@@ -184,6 +184,56 @@ pub(crate) fn tuple_gt(t: (f64, f64), e: f64) -> bool {
|
|||||||
t.0 > e || t.1 > e
|
t.0 > e || t.1 > e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a convergence step is finite in both components.
|
||||||
|
///
|
||||||
|
/// A NaN step means EP broke down numerically. Because every comparison
|
||||||
|
/// against NaN is false, `tuple_gt` reads NaN as "below epsilon" — so
|
||||||
|
/// convergence checks must test finiteness explicitly rather than inferring
|
||||||
|
/// success from `!tuple_gt(..)`.
|
||||||
|
pub(crate) fn step_is_finite(t: (f64, f64)) -> bool {
|
||||||
|
t.0.is_finite() && t.1.is_finite()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a step counts as converged: finite *and* within `epsilon`.
|
||||||
|
pub(crate) fn step_converged(t: (f64, f64), epsilon: f64) -> bool {
|
||||||
|
step_is_finite(t) && !tuple_gt(t, epsilon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indices of the first pair of teams sharing a rank, if any.
|
||||||
|
///
|
||||||
|
/// A tie is only representable when the draw probability is positive: with
|
||||||
|
/// `p_draw == 0.0` the truncation margin collapses to zero and the two-sided
|
||||||
|
/// tie update evaluates `0/0`. Callers use this to reject such events before
|
||||||
|
/// they reach inference.
|
||||||
|
pub(crate) fn first_tied_pair(ranks: &[u32]) -> Option<(usize, usize)> {
|
||||||
|
for (i, a) in ranks.iter().enumerate() {
|
||||||
|
for (j, b) in ranks.iter().enumerate().skip(i + 1) {
|
||||||
|
if a == b {
|
||||||
|
return Some((i, j));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As `first_tied_pair`, but over the engine's internal `f64` outputs.
|
||||||
|
///
|
||||||
|
/// Ranks reach the engine already converted to descending `f64` outputs, and
|
||||||
|
/// `Game` decides a tie by exact equality of those values — so this mirrors
|
||||||
|
/// the comparison inference itself performs.
|
||||||
|
pub(crate) fn first_tied_output(outputs: &[f64]) -> Option<(usize, usize)> {
|
||||||
|
for (i, a) in outputs.iter().enumerate() {
|
||||||
|
for (j, b) in outputs.iter().enumerate().skip(i + 1) {
|
||||||
|
if a == b {
|
||||||
|
return Some((i, j));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
||||||
let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect();
|
let mut x: Vec<(usize, T)> = xs.iter().enumerate().map(|(i, &t)| (i, t)).collect();
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -57,9 +57,11 @@ impl Outcome {
|
|||||||
|
|
||||||
/// Explicit per-team continuous scores with a per-event noise override.
|
/// Explicit per-team continuous scores with a per-event noise override.
|
||||||
///
|
///
|
||||||
/// `sigma` must be `> 0.0`; debug-asserts otherwise.
|
/// `sigma` must be `> 0.0`. Constructing an `Outcome` with a non-positive
|
||||||
|
/// or NaN sigma is allowed; the value is rejected with
|
||||||
|
/// `InferenceError::InvalidParameter` when the event is ingested, so
|
||||||
|
/// callers get an error rather than a panic.
|
||||||
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
|
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
|
||||||
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})");
|
|
||||||
Self::Scored {
|
Self::Scored {
|
||||||
scores: scores.into_iter().collect(),
|
scores: scores.into_iter().collect(),
|
||||||
sigma: Some(sigma),
|
sigma: Some(sigma),
|
||||||
@@ -169,9 +171,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Construction accepts any sigma; the value is validated at ingestion so
|
||||||
|
/// callers receive an `InferenceError` rather than a panic. See
|
||||||
|
/// `tests/degenerate_inputs.rs::scored_event_rejects_non_positive_sigma`.
|
||||||
#[test]
|
#[test]
|
||||||
#[should_panic(expected = "score_sigma must be > 0.0")]
|
fn scores_with_sigma_defers_validation_to_ingestion() {
|
||||||
fn scores_with_sigma_rejects_zero() {
|
let o = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
|
||||||
let _ = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
|
match o {
|
||||||
|
Outcome::Scored { sigma, .. } => assert_eq!(sigma, Some(0.0)),
|
||||||
|
Outcome::Ranked(_) => panic!("expected Scored variant"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! 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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user