4 Commits
Author SHA1 Message Date
logaritmisk 901f60972e chore: Release trueskill-tt version 0.4.2 2026-09-07 22:46:54 +02:00
logaritmiskandClaude Opus 5 8116fd081f test: localise the erfc_inv tail residual to the caller's argument
Scouting crates.io for a more accurate `erfc` than `libm` turned up a
1.16e-12 relative error in `erfc_inv` at `p_draw = 0.999999`, measured
against a 70-digit `decimal` reference. It looked like too few Newton
steps. It is not: adding a fourth changed nothing.

The error is in forming the argument. `1.0 - 0.999999` is
`1.0000000000287557e-06` — 0.999999 is not representable, and
subtracting from one cancels, leaving 2.9e-11 of relative error before
`erfc_inv` is entered. Given an exactly-representable argument it
returns 1.8e-16. So the routine was never the problem, and the extra
iteration has been reverted rather than shipped as a fix for a defect
that was not there.

`puruspe::inverfc` returns the identical wrong value for the identical
reason, which is what makes the shared upstream cause obvious.

Adds a test that separates the two, and corrects a quantile constant in
`erfc_inv_matches_known_quantiles` that was recalled rather than
computed: `Phi^-1(0.9999995)` is 4.89163847569859, not
4.891638475699099. The others were checked against the same reference
and were right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 22:43:50 +02:00
logaritmiskandClaude Opus 5 17d072b2ae fix: route every transcendental through libm, and combine sigmas with hypot
Follow-on from #41, which added `libm` for `erfc`. Surveying what else
the dependency offers: its unique surface over `std` is `erf`/`erfc`,
`lgamma`/`tgamma` and Bessel functions, and only the first was ever
needed. But the survey found something better than another special
function.

`std`'s `exp` and `ln` delegate to the *system* math library. IEEE 754
specifies the basic operations and `sqrt` exactly and says nothing about
transcendentals, so those differ per platform. Measured here over 200k
inputs:

    exp: 19425/200000 differ from libm (worst 1 ulp)
    log:  9932/200000 differ

Inference is an iterative fixed point, so a one-ULP difference can change
an iteration count and move the answer by more than one ULP. Routing
every transcendental through `libm` makes a fit reproducible across
platforms — a stronger guarantee than `tests/determinism.rs`, which only
covers thread counts.

It costs nothing. `Batch::iteration` measured -2.7% [-5.7%, -0.3%] with
the whole set swapped, and not one golden moved.

Also switches the two places that combined sigmas as
`sqrt(a^2 + b^2)` to `hypot`. Squaring overflows to infinity above
~1.3e154 and flushes to zero below ~1.5e-154 — measured, the naive form
returns `inf` where `hypot` returns 1.41e160 — and `Gaussian`'s
constructors are public, so a caller can reach both ends.

Deliberately not done: rewriting the KL divergence's `ln` of a ratio via
`ln_1p`. The cancellation is real as the ratio approaches one, but
measured absolute error is at most ~1e-11 in a quantity of order 0.4
nats, so it changes nothing.

The invariant is recorded in `CLAUDE.md` and on `erfc`'s own docs, since
nothing enforces it mechanically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
2026-09-07 22:33:30 +02:00
logaritmiskandClaude Opus 5 3dd659307a 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
2026-09-07 22:25:00 +02:00
9 changed files with 135 additions and 71 deletions
+15
View File
@@ -2,12 +2,27 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 0.4.2 - 2026-09-07
### Bug Fixes
- fix: replace the erfc approximation with libm, for free
- fix: route every transcendental through libm, and combine sigmas with hypot
### Testing
- test: localise the erfc_inv tail residual to the caller's argument
## 0.4.1 - 2026-09-07 ## 0.4.1 - 2026-09-07
### Bug Fixes ### Bug Fixes
- fix: correct erfc_inv's sign error and keep evidence in log space - fix: correct erfc_inv's sign error and keep evidence in log space
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.4.1
### Testing ### Testing
- test: pin quality()'s N-group closed form, closing the README cross-check - test: pin quality()'s N-group closed form, closing the README cross-check
+6
View File
@@ -97,6 +97,12 @@ History → TimeSlice[] → Event[] → Item[]
chain underflows to zero, and `ln(0)` is `-inf`. chain underflows to zero, and `ln(0)` is `-inf`.
- **Colors are contiguous.** `recompute_color_groups` reorders events so each - **Colors are contiguous.** `recompute_color_groups` reorders events so each
color occupies one range; `ColorGroups::groups_are_contiguous` asserts it. color occupies one range; `ColorGroups::groups_are_contiguous` asserts it.
- **Transcendentals go through `libm`, not `std`.** IEEE 754 pins the basic
operations and `sqrt` but says nothing about `exp`/`log`/`erf`, and `std`
delegates to the *system* math library — measured, `f64::exp` and `libm::exp`
disagree on 9.7% of inputs by one ULP. Since inference is an iterative fixed
point, one ULP can change an iteration count. Use `libm::exp` / `libm::log` in
inference code; `f64::sqrt` is fine (IEEE specifies it). Tests may use either.
- **The crate is `#![forbid(unsafe_code)]`.** Keep it that way. - **The crate is `#![forbid(unsafe_code)]`.** Keep it that way.
- **Ingestion order must not change the answer.** Events added one at a time - **Ingestion order must not change the answer.** Events added one at a time
must converge to the same fixed point as the same events batched — see must converge to the same fixed point as the same events batched — see
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "trueskill-tt" name = "trueskill-tt"
version = "0.4.1" version = "0.4.2"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing" description = "TrueSkill Through Time: Bayesian skill rating that tracks how skill evolves over time, via Gaussian message passing"
@@ -51,6 +51,7 @@ harness = false
[dependencies] [dependencies]
approx = { version = "0.5.1", optional = true } approx = { version = "0.5.1", optional = true }
libm = "0.2.16"
rayon = { version = "1", optional = true } rayon = { version = "1", optional = true }
smallvec = "1" smallvec = "1"
+1 -1
View File
@@ -47,7 +47,7 @@ fn kl_divergence(q: Gaussian, p: Gaussian) -> f64 {
} }
let mean_gap = q.mu() - p.mu(); let mean_gap = q.mu() - p.mu();
0.5 * ((var_p / var_q).ln() + (var_q + mean_gap * mean_gap) / var_p - 1.0) 0.5 * (libm::log(var_p / var_q) + (var_q + mean_gap * mean_gap) / var_p - 1.0)
} }
/// Expected information gain of a hypothetical matchup, in nats. /// Expected information gain of a hypothetical matchup, in nats.
+4 -1
View File
@@ -74,7 +74,10 @@ impl Factor for MarginFactor {
/// far from what the model expected is exactly the observation a log-evidence /// far from what the model expected is exactly the observation a log-evidence
/// figure exists to notice. /// figure exists to notice.
fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 { fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt(); // `hypot`, not `sqrt(a^2 + b^2)`: squaring overflows to infinity above a
// sigma of ~1.3e154 and flushes to zero below ~1.5e-154, and `Gaussian`'s
// constructors are public so a caller can reach both.
let combined_sigma = cavity.sigma().hypot(sigma);
let value = ln_pdf(m_obs, cavity.mu(), combined_sigma); let value = ln_pdf(m_obs, cavity.mu(), combined_sigma);
// A degenerate cavity (infinite sigma) is the only way to reach a // A degenerate cavity (infinite sigma) is the only way to reach a
+2 -2
View File
@@ -1254,7 +1254,7 @@ mod tests {
); );
assert_ulps_eq!( assert_ulps_eq!(
p[1][0], p[1][0],
Gaussian::from_ms(19.287197, 7.243465), Gaussian::from_ms(19.287198285, 7.243465848),
epsilon = 1e-6 epsilon = 1e-6
); );
assert_ulps_eq!( assert_ulps_eq!(
@@ -1314,7 +1314,7 @@ mod tests {
assert_ulps_eq!( assert_ulps_eq!(
p[0][0], p[0][0],
Gaussian::from_ms(31.674697, 7.501180), Gaussian::from_ms(31.674698083, 7.501180037),
epsilon = 1e-6 epsilon = 1e-6
); );
assert_ulps_eq!( assert_ulps_eq!(
+3 -3
View File
@@ -1649,12 +1649,12 @@ mod tests {
); );
assert_ulps_eq!( assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior(), h.time_slices[0].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(24.999198, 5.419512), Gaussian::from_ms(24.999197939, 5.419510957),
epsilon = 1e-6 epsilon = 1e-6
); );
assert_ulps_eq!( assert_ulps_eq!(
h.time_slices[2].skills.get(b).unwrap().posterior(), h.time_slices[2].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(25.001332, 5.420054), Gaussian::from_ms(25.001331690, 5.420052840),
epsilon = 1e-6 epsilon = 1e-6
); );
} }
@@ -2285,7 +2285,7 @@ mod tests {
); );
assert_ulps_eq!( assert_ulps_eq!(
lc_a[1].1, lc_a[1].1,
Gaussian::from_ms(1.792277, 4.099566), Gaussian::from_ms(1.792278067, 4.099566582),
epsilon = 1e-6 epsilon = 1e-6
); );
assert_ulps_eq!( assert_ulps_eq!(
+86 -53
View File
@@ -214,24 +214,47 @@ impl From<Index> for usize {
} }
} }
/// Complementary error function.
///
/// # Why every transcendental in this crate goes through `libm`
///
/// IEEE 754 specifies the basic operations and `sqrt` exactly, but says nothing
/// about `exp`, `log` or `erf`. `std`'s versions delegate to the *system* math
/// library, so they differ between platforms: measured here, `f64::exp` and
/// `libm::exp` disagree on 9.7% of inputs and `f64::ln` / `libm::log` on 5.0%,
/// each by one ULP.
///
/// Inference is an iterative fixed point, so a one-ULP difference can change an
/// iteration count and therefore the answer by more than one ULP. Routing every
/// transcendental through `libm` makes a fit reproducible across platforms, not
/// just across thread counts as `tests/determinism.rs` already checks.
///
/// **So: use `libm::exp` / `libm::log` in inference code, never `f64::exp` /
/// `f64::ln`.** `sqrt` is exempt — IEEE specifies it exactly, so `f64::sqrt` is
/// already portable. Test code may use whichever is clearer.
///
/// It costs nothing: `Batch::iteration` measured -2.7% [-5.7%, -0.3%] with the
/// whole set swapped.
///
/// Delegates to `libm`, which is the Rust port of FDLIBM and accurate to about
/// one ULP. This replaced a Numerical Recipes `erfcc` rational approximation
/// whose documented bound was 1.2e-7 *relative* — measured at ~1e-7 across the
/// whole range, and the binding accuracy constraint on the entire crate.
///
/// The swap is free. 98% of the arguments inference passes here have
/// `|x| < 0.84375`, which is exactly where FDLIBM skips the exponential
/// entirely, so the longer polynomial costs nothing on the distribution that
/// actually occurs: `Batch::iteration` moved -1.6% [-4.7%, +0.9%], p = 0.31.
///
/// What it bought: `compute_margin` went from 8.4e-8 to 1.7e-16 against exact
/// quantiles, `cdf(mu, mu, sigma)` is now exactly 0.5, and `sf + cdf` sums to
/// one within a single ULP where it was 3e-8 out.
fn erfc(x: f64) -> f64 { fn erfc(x: f64) -> f64 {
let z = x.abs(); libm::erfc(x)
let t = 1.0 / (1.0 + z / 2.0);
let a = -0.82215223 + t * 0.17087277;
let b = 1.48851587 + t * a;
let c = -1.13520398 + t * b;
let d = 0.27886807 + t * c;
let e = -0.18628806 + t * d;
let f = 0.09678418 + t * e;
let g = 0.37409196 + t * f;
let h = 1.00002368 + t * g;
let r = t * (-z * z - 1.26551223 + t * h).exp();
if x >= 0.0 { r } else { 2.0 - r }
} }
/// The previous Numerical Recipes `erfcc`, kept only so the timing test can
/// compare both in one binary. Removed once the comparison is recorded.
fn erfc_inv(mut y: f64) -> f64 { fn erfc_inv(mut y: f64) -> f64 {
if y >= 2.0 { if y >= 2.0 {
return f64::NEG_INFINITY; return f64::NEG_INFINITY;
@@ -247,7 +270,7 @@ fn erfc_inv(mut y: f64) -> f64 {
y = 2.0 - y; y = 2.0 - y;
} }
let t = (-2.0 * (y / 2.0).ln()).sqrt(); let t = libm::sqrt(-2.0 * libm::log(y / 2.0));
// The leading coefficient is NEGATIVE. `rational - t` is negative here, so // The leading coefficient is NEGATIVE. `rational - t` is negative here, so
// a positive coefficient mirrors the starting point to `-x0` — the // a positive coefficient mirrors the starting point to `-x0` — the
@@ -262,7 +285,7 @@ fn erfc_inv(mut y: f64) -> f64 {
for _ in 0..3 { for _ in 0..3 {
let err = erfc(x) - y; let err = erfc(x) - y;
x += err / (FRAC_2_SQRT_PI * (-(x.powi(2))).exp() - x * err) x += err / (FRAC_2_SQRT_PI * libm::exp(-(x * x)) - x * err)
} }
if y < 1.0 { x } else { -x } if y < 1.0 { x } else { -x }
@@ -291,7 +314,7 @@ pub(crate) fn cdf(x: f64, mu: f64, sigma: f64) -> f64 {
/// away every significant digit the tail had: measured against this function, /// away every significant digit the tail had: measured against this function,
/// `1 - cdf` carries 7% error by four sigma past the mean and returns exactly /// `1 - cdf` carries 7% error by four sigma past the mean and returns exactly
/// zero beyond about 8.3 sigma — where the true value is still 1e-19 and /// zero beyond about 8.3 sigma — where the true value is still 1e-19 and
/// perfectly representable. `erfc` itself holds ~1e-7 *relative* accuracy down /// perfectly representable. `erfc` holds *relative* accuracy all the way down
/// to 1e-296, so the precision is there to keep; only the subtraction threw it /// to 1e-296, so the precision is there to keep; only the subtraction threw it
/// away. /// away.
/// ///
@@ -313,7 +336,7 @@ fn erfcx(x: f64) -> f64 {
// Below the crossover neither factor is extreme: erfc is O(1) and // Below the crossover neither factor is extreme: erfc is O(1) and
// exp(x^2) is at most e^4, so the direct product is exact enough and // exp(x^2) is at most e^4, so the direct product is exact enough and
// cheaper than the continued fraction. // cheaper than the continued fraction.
(x * x).exp() * erfc(x) libm::exp(x * x) * erfc(x)
} else { } else {
// erfcx(x) = 1/sqrt(pi) * 1/(x + (1/2)/(x + 1/(x + (3/2)/(x + ...)))), // erfcx(x) = 1/sqrt(pi) * 1/(x + (1/2)/(x + 1/(x + (3/2)/(x + ...)))),
// evaluated by backward recurrence. Converges quickly for x >= 2 and, // evaluated by backward recurrence. Converges quickly for x >= 2 and,
@@ -334,7 +357,7 @@ fn erfcx(x: f64) -> f64 {
/// 100 sigma, -500001 at 1000) are perfectly representable. /// 100 sigma, -500001 at 1000) are perfectly representable.
pub(crate) fn ln_pdf(x: f64, mu: f64, sigma: f64) -> f64 { pub(crate) fn ln_pdf(x: f64, mu: f64, sigma: f64) -> f64 {
let z = (x - mu) / sigma; let z = (x - mu) / sigma;
-(SQRT_TAU * sigma).ln() - 0.5 * z * z -libm::log(SQRT_TAU * sigma) - 0.5 * z * z
} }
/// `ln P(X > x)` for `X ~ N(mu, sigma^2)`. /// `ln P(X > x)` for `X ~ N(mu, sigma^2)`.
@@ -347,10 +370,10 @@ pub(crate) fn ln_sf(x: f64, mu: f64, sigma: f64) -> f64 {
if z > 0.0 { if z > 0.0 {
// ln(0.5 * erfc(z/sqrt2)) with erfc(y) = exp(-y^2) * erfcx(y). // ln(0.5 * erfc(z/sqrt2)) with erfc(y) = exp(-y^2) * erfcx(y).
-std::f64::consts::LN_2 - 0.5 * z * z + erfcx(z / SQRT_2).ln() -std::f64::consts::LN_2 - 0.5 * z * z + libm::log(erfcx(z / SQRT_2))
} else { } else {
// The mass here is at least a half; nothing to lose. // The mass here is at least a half; nothing to lose.
sf(x, mu, sigma).ln() libm::log(sf(x, mu, sigma))
} }
} }
@@ -376,26 +399,24 @@ pub(crate) fn ln_interval(lo: f64, hi: f64, mu: f64, sigma: f64) -> f64 {
} else { } else {
// Straddles the mean: the interval holds a non-negligible share of the // Straddles the mean: the interval holds a non-negligible share of the
// mass, so neither endpoint is near enough to 1 to cancel. // mass, so neither endpoint is near enough to 1 to cancel.
return (cdf(hi, mu, sigma) - cdf(lo, mu, sigma)) return libm::log((cdf(hi, mu, sigma) - cdf(lo, mu, sigma)).max(f64::MIN_POSITIVE));
.max(f64::MIN_POSITIVE)
.ln();
}; };
let (a, b) = (near / SQRT_2, far / SQRT_2); let (a, b) = (near / SQRT_2, far / SQRT_2);
// b > a >= 0, so this ratio of exponentials is at most 1 and cannot overflow. // b > a >= 0, so this ratio of exponentials is at most 1 and cannot overflow.
let scale = (a * a - b * b).exp(); let scale = libm::exp(a * a - b * b);
let bracket = erfcx(a) - scale * erfcx(b); let bracket = erfcx(a) - scale * erfcx(b);
if bracket <= 0.0 { if bracket <= 0.0 {
return f64::NEG_INFINITY; return f64::NEG_INFINITY;
} }
-std::f64::consts::LN_2 - a * a + bracket.ln() -std::f64::consts::LN_2 - a * a + libm::log(bracket)
} }
fn pdf(x: f64, mu: f64, sigma: f64) -> f64 { fn pdf(x: f64, mu: f64, sigma: f64) -> f64 {
let normalizer = (SQRT_TAU * sigma).powi(-1); let normalizer = (SQRT_TAU * sigma).powi(-1);
let functional = (-((x - mu).powi(2)) / (2.0 * sigma.powi(2))).exp(); let functional = libm::exp(-((x - mu) * (x - mu)) / (2.0 * sigma * sigma));
normalizer * functional normalizer * functional
} }
@@ -474,7 +495,7 @@ fn v_w(mu: f64, sigma: f64, margin: f64, tie: bool) -> (f64, f64) {
let (v, u) = if alpha > 0.0 { let (v, u) = if alpha > 0.0 {
// beta > alpha > 0, so this ratio of exponentials is at most 1 and // beta > alpha > 0, so this ratio of exponentials is at most 1 and
// cannot overflow. // cannot overflow.
let scale = (0.5 * (alpha * alpha - beta * beta)).exp(); let scale = libm::exp(0.5 * (alpha * alpha - beta * beta));
let denominator = 0.5 * (erfcx(alpha / SQRT_2) - scale * erfcx(beta / SQRT_2)); let denominator = 0.5 * (erfcx(alpha / SQRT_2) - scale * erfcx(beta / SQRT_2));
( (
@@ -662,7 +683,7 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant(); let e_arg = (-0.5 * &start * &middle.inverse() * &end).determinant();
let s_arg = ata.determinant() / middle.determinant(); let s_arg = ata.determinant() / middle.determinant();
e_arg.exp() * s_arg.sqrt() libm::exp(e_arg) * s_arg.sqrt()
} }
#[cfg(test)] #[cfg(test)]
@@ -677,9 +698,9 @@ mod tests {
} }
/// Upper-tail values of the standard normal, from published tables. The /// Upper-tail values of the standard normal, from published tables. The
/// point is not the digits — `erfc` only carries ~1e-7 relative — but that /// point is not the digits — these are 7-digit table values — but that a
/// a number comes back at all: `1 - cdf` returned exactly zero for every /// number comes back at all: `1 - cdf` returned exactly zero for every one
/// one of these. /// of these.
#[test] #[test]
fn survival_function_survives_the_far_tail() { fn survival_function_survives_the_far_tail() {
for (z, expected) in [ for (z, expected) in [
@@ -691,7 +712,7 @@ mod tests {
let got = sf(z, 0.0, 1.0); let got = sf(z, 0.0, 1.0);
assert!(got > 0.0, "sf({z}) collapsed to zero"); assert!(got > 0.0, "sf({z}) collapsed to zero");
assert!( assert!(
(got - expected).abs() / expected < 1e-6, (got - expected).abs() / expected < 1e-6, // published table values, 7 digits
"sf({z}) = {got}, expected ~{expected}" "sf({z}) = {got}, expected ~{expected}"
); );
assert_eq!( assert_eq!(
@@ -709,11 +730,8 @@ mod tests {
for z in [-4.0f64, -1.0, 0.0, 0.5, 1.0, 2.0, 3.0, 4.0] { for z in [-4.0f64, -1.0, 0.0, 0.5, 1.0, 2.0, 3.0, 4.0] {
let naive = 1.0 - cdf(z, 0.0, 1.0); let naive = 1.0 - cdf(z, 0.0, 1.0);
let direct = sf(z, 0.0, 1.0); let direct = sf(z, 0.0, 1.0);
// Bounded by `erfc`'s own ~1e-7 relative error, not by the
// subtraction: the two forms evaluate `erfc` at different points
// and the approximation is not exactly antisymmetric.
assert!( assert!(
(naive - direct).abs() < 1e-6, (naive - direct).abs() < 1e-15,
"z={z}: naive {naive} vs direct {direct}" "z={z}: naive {naive} vs direct {direct}"
); );
} }
@@ -723,9 +741,7 @@ mod tests {
fn survival_and_cdf_partition_the_mass() { fn survival_and_cdf_partition_the_mass() {
for z in [-3.0f64, -0.5, 0.0, 1.0, 2.5] { for z in [-3.0f64, -0.5, 0.0, 1.0, 2.5] {
let total = sf(z, 1.0, 2.0) + cdf(z, 1.0, 2.0); let total = sf(z, 1.0, 2.0) + cdf(z, 1.0, 2.0);
// `erfc(z) + erfc(-z) == 2` only to the accuracy of the assert!((total - 1.0).abs() < 1e-15, "z={z}: {total}");
// approximation, which is ~1e-7 relative.
assert!((total - 1.0).abs() < 1e-6, "z={z}: {total}");
} }
} }
@@ -736,7 +752,7 @@ mod tests {
let direct = (x * x).exp() * erfc(x); let direct = (x * x).exp() * erfc(x);
let scaled = erfcx(x); let scaled = erfcx(x);
assert!( assert!(
(direct - scaled).abs() / scaled < 1e-6, (direct - scaled).abs() / scaled < 1e-14,
"x={x}: direct {direct} vs erfcx {scaled}" "x={x}: direct {direct} vs erfcx {scaled}"
); );
} }
@@ -838,7 +854,7 @@ mod tests {
] { ] {
let got = SQRT_2 * erfc_inv(1.0 - p); let got = SQRT_2 * erfc_inv(1.0 - p);
assert!( assert!(
(got - exact).abs() / exact < 1e-6, (got - exact).abs() / exact < 1e-14,
"p={p}: got {got}, exact {exact}" "p={p}: got {got}, exact {exact}"
); );
} }
@@ -847,6 +863,30 @@ mod tests {
/// The draw margin must grow with the draw probability. It did not: it ran /// The draw margin must grow with the draw probability. It did not: it ran
/// 0.674 -> 1.476 -> 0.503 -> 0.982 as `p_draw` went 0.5 -> 0.9 -> 0.99 -> /// 0.674 -> 1.476 -> 0.503 -> 0.982 as `p_draw` went 0.5 -> 0.9 -> 0.99 ->
/// 0.999, which is not a rounding error but a broken function. /// 0.999, which is not a rounding error but a broken function.
/// Deep in the tail the accuracy limit is the *caller's* argument, not this
/// function.
///
/// `compute_margin(0.999999, ..)` computes `1.0 - p_draw`, and 0.999999 is
/// not representable: the subtraction cancels and leaves 2.9e-11 of
/// relative error in the argument before `erfc_inv` is even entered. Given
/// an exactly-representable argument the result is good to 1.8e-16, so this
/// is inherent to taking `p_draw` near one rather than something to fix
/// here. At `p_draw = 0.999` the whole path is still accurate to 4e-16.
///
/// Worth pinning: measured against a 70-digit reference, `puruspe`'s
/// `inverfc` returns the identical wrong value for the identical reason,
/// which is what makes it clear the fault is upstream of both.
#[test]
fn erfc_inv_is_exact_given_an_exactly_representable_argument() {
// erfc(z / sqrt2) = 1e-6 exactly, so z = Phi^-1(0.9999995).
let got = SQRT_2 * erfc_inv(1e-6);
let exact = 4.891_638_475_698_59;
assert!(
(got - exact).abs() / exact < 1e-14,
"got {got}, exact {exact}"
);
}
#[test] #[test]
fn compute_margin_is_monotone_in_the_draw_probability() { fn compute_margin_is_monotone_in_the_draw_probability() {
let mut previous = 0.0; let mut previous = 0.0;
@@ -872,7 +912,7 @@ mod tests {
// P(|X| < margin) for X ~ N(0, sd^2). // P(|X| < margin) for X ~ N(0, sd^2).
let recovered = 1.0 - 2.0 * cdf(-margin, 0.0, sd); let recovered = 1.0 - 2.0 * cdf(-margin, 0.0, sd);
assert!( assert!(
(recovered - p_draw).abs() < 1e-6, (recovered - p_draw).abs() < 1e-14,
"p_draw={p_draw} sd={sd}: recovered {recovered}" "p_draw={p_draw} sd={sd}: recovered {recovered}"
); );
} }
@@ -922,14 +962,10 @@ mod tests {
lp.exp() lp.exp()
); );
// Bounded by `erfc`'s ~1e-7, not tighter: above x = 2 `erfcx` uses
// a continued fraction accurate to ~1e-15, so the log path is the
// *more* accurate of the two and they part company at `erfc`'s
// error rather than at round-off.
let ls = ln_sf(z, 0.5, 2.0); let ls = ln_sf(z, 0.5, 2.0);
let direct = sf(z, 0.5, 2.0); let direct = sf(z, 0.5, 2.0);
assert!( assert!(
(ls.exp() - direct).abs() <= 1e-6 * direct.max(1e-300), (ls.exp() - direct).abs() <= 1e-13 * direct.max(1e-300),
"ln_sf at {z}: {} vs {direct}", "ln_sf at {z}: {} vs {direct}",
ls.exp() ls.exp()
); );
@@ -941,11 +977,8 @@ mod tests {
for mu in [-2.0f64, 0.0, 0.5, 2.0] { for mu in [-2.0f64, 0.0, 0.5, 2.0] {
let direct = cdf(1.0, mu, 1.0) - cdf(-1.0, mu, 1.0); let direct = cdf(1.0, mu, 1.0) - cdf(-1.0, mu, 1.0);
let logged = ln_interval(-1.0, 1.0, mu, 1.0).exp(); let logged = ln_interval(-1.0, 1.0, mu, 1.0).exp();
// See `log_space_helpers_agree_with_the_linear_forms_in_range`:
// the gap here is `erfc`'s own error, and the log path is the more
// accurate side of it.
assert!( assert!(
(logged - direct).abs() <= 1e-6 * direct, (logged - direct).abs() <= 1e-13 * direct,
"mu={mu}: {logged} vs {direct}" "mu={mu}: {logged} vs {direct}"
); );
} }
+16 -10
View File
@@ -33,17 +33,23 @@ pub(crate) const MAX_TEAMS_FOR_DISTRIBUTION: usize = 6;
/// Relative tolerance for the first-place integrals. /// Relative tolerance for the first-place integrals.
/// ///
/// Tightening past this buys nothing: the underlying `cdf` is a rational /// The adaptive integrator reaches the exact two-team closed form to ~1e-15 at
/// approximation with fractional error ~1.2e-7, which contributes ~6e-9 to a /// this tolerance, which is round-off for a probability. `cdf` is no longer the
/// finished probability and dominates any further quadrature refinement. /// 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; const WIN_TOLERANCE: f64 = 1e-8;
/// Nodes for the ranking grid, and the floor below which a grid is pointless. /// 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 /// The recursion converges as O(h^2), so this trades nodes against accuracy
/// closed form, 2_048 nodes leave ~1.2e-6 of discretisation error while 8_192 /// directly. Measured against the exact two-team closed form, 2_048 nodes leave
/// reach ~1e-7 — at which point the residual is the `cdf` rational /// ~1.2e-6 of discretisation error and 8_192 reach ~1e-7.
/// approximation (~2.4e-8), not the grid, and refining further buys nothing. ///
/// 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 MIN_GRID_POINTS: usize = 8_192;
const MAX_GRID_POINTS: usize = 262_144; const MAX_GRID_POINTS: usize = 262_144;
@@ -62,7 +68,7 @@ fn phi(z: f64) -> f64 {
fn density(g: Gaussian, x: f64) -> f64 { fn density(g: Gaussian, x: f64) -> f64 {
let sigma = g.sigma(); let sigma = g.sigma();
let z = (x - g.mu()) / sigma; let z = (x - g.mu()) / sigma;
(-0.5 * z * z).exp() / (sigma * (2.0 * std::f64::consts::PI).sqrt()) libm::exp(-0.5 * z * z) / (sigma * (2.0 * std::f64::consts::PI).sqrt())
} }
/// Per-pair draw margins. /// Per-pair draw margins.
@@ -503,7 +509,7 @@ mod tests {
/// Exact two-team result: `P(a first) = Phi((mu_a - mu_b - eps) / sd)`. /// Exact two-team result: `P(a first) = Phi((mu_a - mu_b - eps) / sd)`.
fn closed_form_two(a: Gaussian, b: Gaussian, eps: f64) -> (f64, f64) { fn closed_form_two(a: Gaussian, b: Gaussian, eps: f64) -> (f64, f64) {
let sd = (a.sigma().powi(2) + b.sigma().powi(2)).sqrt(); let sd = a.sigma().hypot(b.sigma());
( (
phi((a.mu() - b.mu() - eps) / sd), phi((a.mu() - b.mu() - eps) / sd),
phi((b.mu() - a.mu() - eps) / sd), phi((b.mu() - a.mu() - eps) / sd),
@@ -523,7 +529,7 @@ mod tests {
let got = win_probabilities(&perf, &flat(2, eps)); let got = win_probabilities(&perf, &flat(2, eps));
let (wa, wb) = closed_form_two(perf[0], perf[1], eps); let (wa, wb) = closed_form_two(perf[0], perf[1], eps);
assert!( 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}]" "mu=({ma},{mb}) sigma=({sa},{sb}) eps={eps}: got {got:?}, want [{wa}, {wb}]"
); );
} }