fix!: collapse a drift too small to represent, on a relative threshold
`time_expanded_joint` collapsed consecutive appearances only at `drift <= 0.0` exactly. Anything smaller-but-positive got an explicit `1.0 / drift` precision, which the matrix cannot hold: at `drift = 1e-16` the entry is `1e16`, and `1e16 + 0.28` rounds back to `1e16`, so the prior and the event contrasts are annihilated in the stored f64 before the factorisation ever runs. Measured, 8 competitors over 15 slices: drift_scale before after 1e-6 1.3e-3 relative error exact 1e-7..1e-9 Err(JointUnavailable) exact 1e-10 12 200x TOO SMALL, as Ok exact At 1e-10 the caller was handed sigma = 0.0055 where the truth is 0.6108 — a 111x overconfident interval, returned as a success. This is representation, not conditioning. Solved in 200-digit precision the same system converges smoothly onto the collapsed value and is flat from 1e-16 to 1e-40, so the quantity is perfectly well conditioned. That also rules out the obvious fix: symmetric (Jacobi) equilibration measured 30x WORSE, because the information is gone from the assembled matrix before any solver sees it. The fix has to be at assembly. The threshold balances the two errors that trade off. Ignoring a real drift costs about `drift / V`; representing one costs about `EPSILON * V / drift`. They cross at `V * sqrt(EPSILON)`, scaled to each competitor's own prior variance. Ordinary drift is far above it and unaffected — the default gamma accumulates 0.0069 per unit time against a threshold of 1.0e-6 — and the test asserts both halves: everything below the threshold reaches the collapsed answer bit-identically, and a drift of 1e-2 still moves it, so the test cannot pass by collapsing everything. Also corrects the `JointUnavailable` message, which asserted "a competitor has neither a proper prior nor any evidence" for a fixture where every competitor had both. BREAKING CHANGE: a drift variance below `prior_variance * sqrt(EPSILON)` now collapses two appearances into one latent variable. Affected fits previously returned a badly wrong variance or an error. Closes #57 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
@@ -265,3 +265,75 @@ fn unseen_competitors_match_the_one_shot_path() {
|
||||
assert_eq!(one_shot.pi(), cached.pi());
|
||||
assert_eq!(one_shot.tau(), cached.tau());
|
||||
}
|
||||
|
||||
/// A drift too small to represent must collapse, not corrupt the matrix.
|
||||
///
|
||||
/// The collapse rule used to fire only at `drift <= 0.0` exactly. Anything
|
||||
/// smaller-but-positive got an explicit `1.0 / drift` precision, and at
|
||||
/// `drift = 1e-16` that entry is `1e16` — so `1e16 + 0.28` rounds back to
|
||||
/// `1e16` and the prior and contrasts are annihilated in the stored `f64`.
|
||||
///
|
||||
/// Measured before the fix, at `drift_scale = 1e-10` this returned a variance
|
||||
/// **12 000x too small** (a 111x overconfident interval) as `Ok`, with a band
|
||||
/// just above it returning a misleading `JointUnavailable`.
|
||||
#[test]
|
||||
fn a_drift_too_small_to_represent_collapses_rather_than_corrupting() {
|
||||
fn variance(scale: f64) -> f64 {
|
||||
let mut h: History<i64, ConstantDrift, _, String> = History::builder_with_key()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.score_sigma(2.0)
|
||||
.drift(ConstantDrift(0.5))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 20_000,
|
||||
epsilon: 1e-13,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.build();
|
||||
let mut events = Vec::new();
|
||||
for t in 0..15i64 {
|
||||
for k in 0..4usize {
|
||||
let x = format!("p{}", (t as usize * 4 + k) % 8);
|
||||
let y = format!("p{}", (t as usize * 4 + k + 3) % 8);
|
||||
events.push(Event {
|
||||
time: t,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(x).with_drift_scale(scale)]),
|
||||
Team::with_members([Member::new(y).with_drift_scale(scale)]),
|
||||
],
|
||||
outcome: Outcome::scores([3.0, 1.0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
h.add_events(events).unwrap();
|
||||
assert!(h.converge().unwrap().converged);
|
||||
let (a, b) = ("p0".to_string(), "p1".to_string());
|
||||
let joint = h
|
||||
.joint()
|
||||
.expect("a tiny drift must not make the joint unavailable");
|
||||
let g = joint.posterior_of(&[(&a, 1.0), (&b, -1.0)]).unwrap();
|
||||
g.sigma() * g.sigma()
|
||||
}
|
||||
|
||||
let collapsed = variance(0.0);
|
||||
|
||||
// Below the threshold every scale must reach the collapsed answer exactly,
|
||||
// and none may error.
|
||||
for scale in [1e-3, 1e-4, 1e-6, 1e-8, 1e-10, 1e-12] {
|
||||
let v = variance(scale);
|
||||
assert_eq!(
|
||||
v.to_bits(),
|
||||
collapsed.to_bits(),
|
||||
"drift_scale {scale:e}: {v} vs collapsed {collapsed}"
|
||||
);
|
||||
}
|
||||
|
||||
// Above it, real drift is still modelled — otherwise this test would pass
|
||||
// by collapsing everything.
|
||||
let drifting = variance(1e-2);
|
||||
assert!(
|
||||
(drifting - collapsed).abs() / collapsed > 1e-5,
|
||||
"a drift of 1e-2 must still move the answer: {drifting} vs {collapsed}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user