`v_w` returned `w` and let `trunc` form `1 - w`. `w` tends to 1 out in the tail, so that subtraction lost about log10(alpha^2) digits — and the quantity it was destroying is perfectly representable. Two separate cancellations, fixed separately. The non-tie half: `half_line_truncation` now returns `1 - w` computed symbolically rather than as `1 - v*gap`. With `alpha*gap = 1 - inv^2*b` the leading ones cancel on paper instead of in floating point. Measured against the exact truncated variance: alpha before after 1e6 8.9e-5 rel 0.0 rel (exact) 1e8 returns 0.0 0.0 rel (exact) At 1e8 the old form gave `sigma_trunc = 0`, and `from_ms(mu, 0.0)` is a point mass whose `mu()` is inf/inf = NaN. `beta(1e-8).sigma(1e-8)` with priors 1000 apart went from Err + NaN skills to a finite fit. The tie half is a different subtraction — `w = v^2 - u`, where both grow as alpha^2 while their difference stays O(1). The existing escape hatch could not cover it: it keys on `alpha * width >= HALF_LINE_WINDOW`, how many window-widths from the mean the window sits, and a NARROW window fails that however deep it is. Measured at alpha 1e6 with a 1e-6 window it kept four digits and returned `1 - w = -2.4e-4` where the truth is +2.8e-13. One step earlier it was quietly wrong instead: `1 - w = 1.0` exactly, a truncation reported as a no-op, where the truth was 5e-17. Over a narrow window the density is a truncated exponential in `s = (x - alpha)/width`, whose mean and variance are closed forms, so `v = alpha + width*m(t)` and `1 - w = width^2 * V(t)` with no large subtraction at all. Validated against high-precision quadrature: v exact to 4e-10, `1 - w` to 4e-10 across the region it is used in. The crossover is on `alpha / width` rather than on either alone, because that ratio is what says how many digits the subtraction has left — and the approximation is most accurate exactly where the subtraction is worst, since both improve as the window narrows. Defaults are bit-identical (pi 0.02398318151216503 before and after). Tests: the three reproductions from the issue, the narrow-window form against pinned quadrature values, and a continuity sweep across all three tie branches — a misplaced crossover is the real risk here, and a jump at a boundary is visible even without pinning absolute values. Closes #60 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
218 lines
8.0 KiB
Rust
218 lines
8.0 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::{
|
|
ConstantDrift, 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());
|
|
}
|
|
|
|
/// A NaN in one competitor must not be masked by a healthy competitor reduced
|
|
/// after it.
|
|
///
|
|
/// The convergence step is a fold over a `HashMap`, so which competitor is
|
|
/// reduced last is per-process hash order. Before the fix, `tuple_max` dropped
|
|
/// a NaN accumulator in favour of the next finite delta and this returned
|
|
/// `Ok(converged: true)` with a NaN posterior in **16 of 30 runs** on identical
|
|
/// input. Deterministic now, but note this test can only ever sample one hash
|
|
/// order per run — the ordering guarantee itself is pinned by
|
|
/// `tuple_max_propagates_a_nan_from_any_position` in the crate's unit tests.
|
|
#[test]
|
|
fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
|
|
let mut h = History::builder()
|
|
.mu(0.0)
|
|
.sigma(6.0)
|
|
.beta(1.0)
|
|
.p_draw(0.1)
|
|
.build();
|
|
h.add_events(vec![
|
|
Event {
|
|
time: 1i64,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, 1e-200))]),
|
|
Team::with_members([Member::new("b")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
},
|
|
// A healthy pair in the same slice, to be reduced alongside the NaN.
|
|
Event {
|
|
time: 1i64,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("c")]),
|
|
Team::with_members([Member::new("d")]),
|
|
],
|
|
outcome: Outcome::winner(0, 2),
|
|
},
|
|
])
|
|
.unwrap();
|
|
|
|
let err = h
|
|
.converge()
|
|
.expect_err("a NaN fit must never be reported as converged");
|
|
assert!(
|
|
matches!(err, InferenceError::NonFiniteResult { .. }),
|
|
"{err:?}"
|
|
);
|
|
}
|
|
|
|
/// A tie observed with a narrow draw margin between far-apart competitors must
|
|
/// produce a fit, not NaN skills.
|
|
///
|
|
/// The tie branch forms the truncated variance from `v^2 - u`, and both grow as
|
|
/// `alpha^2` while their difference stays `O(1)`. Deep enough into the tail
|
|
/// that subtraction had four digits left: measured, it returned `1 - w`
|
|
/// negative and `sqrt` of it was NaN. The half-line escape hatch did not cover
|
|
/// it, because that keys on how many window-widths from the mean the window
|
|
/// sits and a narrow window fails that however deep it is.
|
|
///
|
|
/// These parameters are ordinary for a precise-scoring domain, and the
|
|
/// neighbouring wider-margin case always worked — so this was a cliff, not
|
|
/// "extreme inputs break".
|
|
#[test]
|
|
fn a_narrow_draw_margin_far_into_the_tail_still_fits() {
|
|
for (beta, p_draw, sd, gap) in [
|
|
(1e-2, 1e-8, 1e-2, 10.0),
|
|
(1e-3, 1e-9, 1e-3, 1.0),
|
|
(1e-4, 1e-12, 1e-4, 1.0),
|
|
] {
|
|
let mut h = History::builder()
|
|
.mu(0.0)
|
|
.sigma(sd)
|
|
.beta(beta)
|
|
.p_draw(p_draw)
|
|
.drift(ConstantDrift(0.0))
|
|
.build();
|
|
h.add_events(vec![Event {
|
|
time: 1i64,
|
|
teams: smallvec![
|
|
Team::with_members([Member::new("a").with_prior(Gaussian::from_ms(0.0, sd))]),
|
|
Team::with_members([Member::new("b").with_prior(Gaussian::from_ms(gap, sd))]),
|
|
],
|
|
outcome: Outcome::draw(2),
|
|
}])
|
|
.unwrap();
|
|
|
|
let report = h
|
|
.converge()
|
|
.unwrap_or_else(|e| panic!("beta {beta:e}, p_draw {p_draw:e}: {e:?}"));
|
|
assert!(report.converged);
|
|
|
|
let skill = h.current_skill(&"a").unwrap();
|
|
assert!(
|
|
skill.mu().is_finite() && skill.sigma().is_finite() && skill.sigma() > 0.0,
|
|
"beta {beta:e}, p_draw {p_draw:e}: {skill:?}"
|
|
);
|
|
}
|
|
}
|