The two gaps #26 named that were never filled. NonFiniteResult had no test at all — the name appeared in `tests/` only inside a doc comment, and it is the sub-claim in that issue's title. It turns out to be very much reachable, and from *finite* inputs: sigma at 1e300, beta at 1e300, sigma at 1e-300, score_sigma at 1e-300, and scores at 1e308 all overflow inside inference, where the boundary checks cannot see them. That matters because the failure is silent by default — NaN fails every comparison, so a naive `step < epsilon` reads a NaN step as converged, which is why the crate has `step_converged`/`step_is_finite`. Pinned from outside, including that `converge_partial` does not launder a breakdown into an `Ok`, and with a control asserting merely extreme parameters still converge so the suite cannot pass by always failing. Color-group disjointness was #26's fourth acceptance criterion and had only five hand-written cases. Now a proptest over three shapes: a dense pool where collisions force colors to multiply, a sparse one where most events are independent, and repeated members within a single event. Two of my first assertions were wrong about the code rather than the reverse. A competitor named twice *within* one event is not a collision — `color_greedy` collects each event's members into a set for that reason. And contiguity is not a property of `color_greedy`: it holds only after `recompute_color_groups` reorders events so each color occupies one range. The test now asserts what is actually promised — that the reorder is always *possible*, since the parallel sweep slices `&mut` sub-ranges from those groups and overlapping ranges would be unsound. Refs #26 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
118 lines
4.4 KiB
Rust
118 lines
4.4 KiB
Rust
//! Inference must report numerical breakdown rather than call it convergence.
|
|
//!
|
|
//! The boundary rejects inputs that are *not numbers*, but finite inputs can
|
|
//! still overflow during inference — `beta.powi(2)` at 1e300 is infinite, and
|
|
//! infinity minus infinity is NaN. `NonFiniteResult` is the guard for that, and
|
|
//! it matters because the alternative is silent: NaN fails every comparison, so
|
|
//! a naive `step < epsilon` check reads a NaN step as *converged*.
|
|
//!
|
|
//! That is why the crate has `step_converged` / `step_is_finite` rather than
|
|
//! `!tuple_gt(..)`. These tests pin the guard from outside.
|
|
|
|
use smallvec::smallvec;
|
|
use trueskill_tt::{Event, Gaussian, History, InferenceError, Member, Outcome, Team};
|
|
|
|
fn scored_fit(
|
|
sigma: f64,
|
|
beta: f64,
|
|
score_sigma: f64,
|
|
scores: [f64; 2],
|
|
) -> Result<bool, InferenceError> {
|
|
let mut h = History::builder()
|
|
.mu(0.0)
|
|
.sigma(sigma)
|
|
.beta(beta)
|
|
.score_sigma(score_sigma)
|
|
.build();
|
|
h.add_events(vec![Event {
|
|
time: 1i64,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("a")]),
|
|
Team::with_members([Member::new("b")]),
|
|
],
|
|
outcome: Outcome::scores(scores),
|
|
}])?;
|
|
h.converge().map(|r| r.converged)
|
|
}
|
|
|
|
/// Every one of these is built from finite, individually legal parameters. The
|
|
/// overflow happens inside inference, which is exactly the case the boundary
|
|
/// checks cannot catch.
|
|
///
|
|
/// Matched rather than merely `is_err()`: an assertion that only checks "some
|
|
/// error" would keep passing if these started failing at the boundary for an
|
|
/// unrelated reason, and would then be testing nothing.
|
|
#[test]
|
|
fn overflow_during_inference_is_reported_not_hidden() {
|
|
let cases: [(&str, f64, f64, f64, [f64; 2]); 5] = [
|
|
("huge sigma", 1e300, 1.0, 1.0, [3.0, 1.0]),
|
|
("huge beta", 6.0, 1e300, 1.0, [3.0, 1.0]),
|
|
("tiny sigma", 1e-300, 1.0, 1.0, [3.0, 1.0]),
|
|
("tiny score_sigma", 6.0, 1.0, 1e-300, [3.0, 1.0]),
|
|
("huge scores", 6.0, 1.0, 1.0, [1e308, -1e308]),
|
|
];
|
|
|
|
for (name, sigma, beta, score_sigma, scores) in cases {
|
|
match scored_fit(sigma, beta, score_sigma, scores) {
|
|
Err(InferenceError::NonFiniteResult { context, step }) => {
|
|
assert_eq!(context, "History::converge", "{name}");
|
|
assert!(
|
|
!step.0.is_finite() || !step.1.is_finite(),
|
|
"{name}: reported NonFiniteResult with a finite step {step:?}"
|
|
);
|
|
}
|
|
other => panic!("{name}: expected NonFiniteResult, got {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The trap the invariant exists for: NaN fails every comparison, so a naive
|
|
/// `step < epsilon` test reads a NaN step as converged. A breakdown must never
|
|
/// come back as a successful fit.
|
|
#[test]
|
|
fn a_broken_fit_is_never_reported_as_converged() {
|
|
let mut h = History::builder().build();
|
|
h.add_events(vec![Event {
|
|
time: 1i64,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
|
|
Team::with_members([Member::new("b")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
}])
|
|
.unwrap();
|
|
|
|
let err = h.converge().unwrap_err();
|
|
assert!(
|
|
matches!(err, InferenceError::NonFiniteResult { .. }),
|
|
"a breakdown must not be reported as convergence: {err:?}"
|
|
);
|
|
|
|
// `converge_partial` must not launder it into an `Ok` either — the
|
|
// permissive path is permissive about *stopping short*, not about NaN.
|
|
let mut h2 = History::builder().build();
|
|
h2.add_events(vec![Event {
|
|
time: 1i64,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(1e300, 1e-300))]),
|
|
Team::with_members([Member::new("b")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
}])
|
|
.unwrap();
|
|
assert!(matches!(
|
|
h2.converge_partial().unwrap_err(),
|
|
InferenceError::NonFiniteResult { .. }
|
|
));
|
|
}
|
|
|
|
/// The neighbouring case, so the tests above cannot pass by the fit simply
|
|
/// always failing: ordinary extreme-but-workable parameters still converge.
|
|
#[test]
|
|
fn merely_extreme_parameters_still_converge() {
|
|
assert!(scored_fit(1e6, 1.0, 1.0, [3.0, 1.0]).unwrap());
|
|
assert!(scored_fit(1e-6, 1.0, 1.0, [3.0, 1.0]).unwrap());
|
|
assert!(scored_fit(6.0, 1.0, 1e6, [3.0, 1.0]).unwrap());
|
|
assert!(scored_fit(6.0, 1.0, 1.0, [1e150, -1e150]).unwrap());
|
|
}
|