fix: replace the erfc approximation with libm, for free
#41 asked whether the Numerical Recipes `erfcc` approximation — 1.2e-7 relative, and the binding accuracy constraint on the whole crate — was worth replacing, given it sits in the inference hot loop. It is, and it costs nothing. Measured, against an independent incomplete-gamma reference: range previous (NR) libm [-3, 0] 7.95e-8 2.15e-14 [0, 0.5] 8.69e-8 4.70e-14 [0.5, 2] 9.38e-8 1.24e-12 [2, 6] 1.04e-7 5.20e-14 [6, 26] 1.07e-7 1.75e-13 erfc(0) 1.00000003 1.0 (exactly) |erfc(z)+erfc(-z)-2| 6.00e-8 2.22e-16 Performance, on `benches/batch.rs`: change [-3.34% +2.26%], p = 0.89 — no change detected. That result is counterintuitive, because libm's erfc is 1.65x slower when swept uniformly over [-2.5, 2.5]. The sweep was the wrong input distribution. Capturing the arguments inference actually passes: |x|<0.5 96.16% 0.5-0.84 2.05% 0.84-1.25 1.24% 1.25-2 0.54% 2-6 0.00% 98% fall below 0.84375, which is exactly where FDLIBM skips the exponential entirely — while the NR form always pays for one. On the real trace libm is the faster of the two (3.23 vs 3.78 ns/call). An ad-hoc `Instant` harness reported a 16% end-to-end speedup; that was an artifact of its own setup allocating and leaking per run, and criterion's verdict of "no change" is the one to believe. What it bought: - `compute_margin` against exact quantiles: 8.4e-8 -> 1.7e-16. - `cdf(mu, mu, sigma)` is now exactly 0.5; it was 1.5e-8 out. - `sf + cdf` sums to one within a ULP, from 3e-8. - `erfcx`'s two branches now agree to round-off across the crossover rather than to 1e-7, so the log-space evidence path and the linear one are consistent. - Ten test tolerances tightened from 1e-6 to 1e-13..1e-15, and the prediction floor is now the integrator's rather than `cdf`'s. Five goldens moved, by 2.4e-9 to 6e-7 — the magnitude of the removed error, and `test_env_ttt`'s mu still rounds to the same six decimals. Re-recorded with more digits so future drift stays visible. Verified as movement toward truth per the goldens policy: every value now derives from a primitive checked against an independent reference and satisfying the exact identities, which the previous one did not. Adds `libm` — zero transitive dependencies, rust-lang maintained. Closes #41 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+14
-8
@@ -33,17 +33,23 @@ pub(crate) const MAX_TEAMS_FOR_DISTRIBUTION: usize = 6;
|
||||
|
||||
/// Relative tolerance for the first-place integrals.
|
||||
///
|
||||
/// Tightening past this buys nothing: the underlying `cdf` is a rational
|
||||
/// approximation with fractional error ~1.2e-7, which contributes ~6e-9 to a
|
||||
/// finished probability and dominates any further quadrature refinement.
|
||||
/// The adaptive integrator reaches the exact two-team closed form to ~1e-15 at
|
||||
/// this tolerance, which is round-off for a probability. `cdf` is no longer the
|
||||
/// limit — it went to ~1 ULP when `erfc` moved to `libm` — so this is the
|
||||
/// integrator's own floor.
|
||||
const WIN_TOLERANCE: f64 = 1e-8;
|
||||
|
||||
/// Nodes for the ranking grid, and the floor below which a grid is pointless.
|
||||
///
|
||||
/// The recursion converges as O(h^2). Measured against the exact two-team
|
||||
/// closed form, 2_048 nodes leave ~1.2e-6 of discretisation error while 8_192
|
||||
/// reach ~1e-7 — at which point the residual is the `cdf` rational
|
||||
/// approximation (~2.4e-8), not the grid, and refining further buys nothing.
|
||||
/// The recursion converges as O(h^2), so this trades nodes against accuracy
|
||||
/// directly. Measured against the exact two-team closed form, 2_048 nodes leave
|
||||
/// ~1.2e-6 of discretisation error and 8_192 reach ~1e-7.
|
||||
///
|
||||
/// Unlike the adaptive path there is no approximation floor underneath this any
|
||||
/// more — `cdf` is accurate to ~1 ULP since `erfc` moved to `libm` — so the
|
||||
/// error here is purely the grid, and a caller who needs more can only get it
|
||||
/// by paying for more nodes. 8_192 is the accuracy/cost point chosen, not a
|
||||
/// point where refining stops helping.
|
||||
const MIN_GRID_POINTS: usize = 8_192;
|
||||
const MAX_GRID_POINTS: usize = 262_144;
|
||||
|
||||
@@ -523,7 +529,7 @@ mod tests {
|
||||
let got = win_probabilities(&perf, &flat(2, eps));
|
||||
let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
|
||||
assert!(
|
||||
(got[0] - wa).abs() < 1e-7 && (got[1] - wb).abs() < 1e-7,
|
||||
(got[0] - wa).abs() < 1e-12 && (got[1] - wb).abs() < 1e-12,
|
||||
"mu=({ma},{mb}) sigma=({sa},{sb}) eps={eps}: got {got:?}, want [{wa}, {wb}]"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user