fix: keep the truncated variance representable in the far tail

`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
This commit is contained in:
2026-09-09 17:42:16 +02:00
co-authored by Claude Opus 5
parent f1219036b3
commit 6139061740
2 changed files with 224 additions and 11 deletions
+53 -1
View File
@@ -10,7 +10,9 @@
//! `!tuple_gt(..)`. These tests pin the guard from outside.
use smallvec::smallvec;
use trueskill_tt::{Event, Gaussian, History, InferenceError, Member, Outcome, Team};
use trueskill_tt::{
ConstantDrift, Event, Gaussian, History, InferenceError, Member, Outcome, Team,
};
fn scored_fit(
sigma: f64,
@@ -163,3 +165,53 @@ fn a_nan_competitor_is_not_masked_by_a_healthy_one() {
"{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:?}"
);
}
}