fix!: report an unresolvable prediction grid instead of clamping
`grid_shape` asked for 12 nodes across the narrowest feature and then clamped to MAX_GRID_POINTS with no detection that the request was not met. Past `step/sigma ~ 1.7` the trapezoid rule stops resolving the density, and the result is unbounded: sigma_a step/sig_a P(a first) exact total 2.0e-3 0.86 0.515953 0.515953 1.000000 1.0e-3 1.72 0.517185 0.515953 1.002388 1.0e-4 17.17 2.791336 0.515953 5.410065 A probability of 2.79. Reachable through `predict_outcome` with a pinned reference competitor — a documented pattern — where `predict_outcome` and `predict_win_probabilities` disagreed 44x and `predict_outcome` was the wrong one. There is no useful answer on the far side of that cliff, so this reports `GridTooCoarse` rather than guessing, and the message points at `predict_win_probabilities`, which answers the same matchup through adaptive quadrature and is accurate there to 1e-13. The floor is 4 nodes per feature rather than the 12 requested, because the request carries margin: measured accurate to 2.2e-12 at 1.4 nodes per sigma and wrong by 1.2e-3 at 0.7. This also fixes the `ln k` ceiling violation. `expected_information_gain` weights `probability * divergence`, so probabilities of 3.97 and 2.62 made it return 3.237828 nats against `ln 2 = 0.693147` — 4.67x over. The crate's docs call that ceiling its sharpest test and record a prototype once returning 4.77 nats; it was live again by a different route. The new sweep then caught a second, independent defect: `kl_divergence` returned NEGATIVE values, worst -5.55e-17, exactly one ULP of its `- 1.0`. Rewritten as `0.5*(u - ln1p(u)) + gap^2/(2*var_p)` with `u = var_q/var_p - 1`, so both terms are non-negative by construction. It is also more accurate where it matters: at `u = 1e-9` the old form returned 0.0 where the true value is 2.5e-19, and well-conditioned cases are unchanged. tests/prediction_bounds.rs sweeps rather than spot-checks, because a single fixture cannot defend a bound like this — the previous check passed throughout. It asserts the sweep still reaches the coarse-grid regime, so it cannot quietly stop testing the case it was written for. BREAKING CHANGE: `predict_outcome`, `predict_ranking` and `expected_information_gain` return `GridTooCoarse` for matchups whose performance sigmas are too far apart to integrate on one grid. They previously returned wrong answers, including probabilities above 1. Closes #55, closes #56 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
@@ -131,6 +131,28 @@ pub enum InferenceError {
|
||||
AlreadyRegistered { key: String },
|
||||
/// A prediction was given a team with no members.
|
||||
EmptyTeam { team: usize },
|
||||
/// The prediction grid cannot resolve the narrowest feature in the matchup.
|
||||
///
|
||||
/// `predict_outcome` and `predict_ranking` integrate every team's density
|
||||
/// on one shared grid, whose resolution is set by the narrowest sigma (or a
|
||||
/// narrower draw margin). When the widest and narrowest are far enough
|
||||
/// apart, resolving the narrow one across the wide one's support needs more
|
||||
/// nodes than the grid is allowed to hold.
|
||||
///
|
||||
/// Reported rather than clamped. Clamping is what this replaced, and it
|
||||
/// returned probabilities greater than one — measured, a `P` of 2.79 and a
|
||||
/// `Prediction::total()` of 5.41 — because the trapezoid rule stops
|
||||
/// resolving a density once the step exceeds roughly 1.7 of its sigma.
|
||||
///
|
||||
/// `predict_win_probabilities` answers the same matchup through adaptive
|
||||
/// quadrature and is accurate here; use it when only the per-team win
|
||||
/// probabilities are needed.
|
||||
GridTooCoarse {
|
||||
/// Nodes required to resolve the narrowest feature.
|
||||
needed: usize,
|
||||
/// Nodes the grid may hold.
|
||||
max: usize,
|
||||
},
|
||||
/// A joint posterior was requested where one cannot be formed exactly.
|
||||
JointUnavailable { reason: &'static str },
|
||||
/// Fewer than two teams were supplied to a prediction.
|
||||
@@ -219,6 +241,15 @@ impl fmt::Display for InferenceError {
|
||||
Self::EmptyTeam { team } => {
|
||||
write!(f, "team {team} has no members")
|
||||
}
|
||||
Self::GridTooCoarse { needed, max } => {
|
||||
write!(
|
||||
f,
|
||||
"the prediction grid needs {needed} nodes to resolve the narrowest \
|
||||
team's density across the widest team's support, but may hold only \
|
||||
{max}; the sigmas in this matchup are too far apart to integrate on \
|
||||
one grid. Use predict_win_probabilities, which is accurate here"
|
||||
)
|
||||
}
|
||||
Self::JointUnavailable { reason } => {
|
||||
write!(f, "no exact joint posterior is available: {reason}")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user