fix: correct erfc_inv's sign error and keep evidence in log space

A systematic scan for precision defects, following the tail-precision
work in 7341669. Three findings; the first is a correctness bug in a
released version.

1. `erfc_inv`'s initial guess had the wrong sign. Numerical Recipes'
   `inverfc` uses -0.70711 as the leading coefficient; this used
   +FRAC_1_SQRT_2. Since `rational - t` is negative, that put Newton on
   the mirror image of the root, and three fixed iterations could not
   cross back. Measured against exact standard-normal quantiles:

       p_draw   old rel err   new rel err
       0.50        1.46e-7       8.40e-8
       0.90        1.02e-1       8.63e-9
       0.95        3.06e-1       1.91e-8
       0.99        8.05e-1       5.89e-9

   `compute_margin` inherited it, so the draw margin was wrong for any
   `p_draw` above about 0.6 and *non-monotone* above 0.9 — it ran
   0.674, 1.476, 0.503, 0.982 as p_draw went 0.5, 0.9, 0.99, 0.999. A
   history configured for a 0.99 draw rate was being fitted at 0.385.
   Note it was slightly wrong everywhere, not only in the tail.

2. `MarginFactor` computed a density and clamped it. `pdf` underflows
   past ~38 sigma, so `ln` of the clamped zero reported -708 nats
   however far out the score actually was: 4292 nats adrift at 100
   sigma, and unbounded beyond. This is the same defect as the one
   fixed in `TruncFactor`, one file over, on the scored-outcome path.

3. `TruncFactor` still bottomed out past ~38 sigma even after 7341669
   removed the cancellation, because the linear probability itself
   underflows there.

2 and 3 are fixed the same way: factors cache a *log* evidence, built
from new `ln_pdf`, `ln_sf` and `ln_interval` helpers that factor the
shared exponential out analytically via the `erfcx` added earlier.
Nothing underflows, at any separation.

One golden moved. `test_1vs1vs1` runs at `p_draw = 0.5`, so it goes
through `compute_margin`; its 1e-6-place values shifted. Verified as
movement *toward* analytic truth by comparing both the old and new
inverse against exact quantiles, per the goldens policy in CLAUDE.md —
not re-baselined on faith.

Two test tolerances are asserted at 1e-6 rather than tighter because
above x = 2 `erfcx` uses a continued fraction accurate to ~1e-15 while
`erfc` carries ~1e-7, so the log path is the more accurate of the two
and they part company at `erfc`'s error. That floor is tracked in #41.

Refs #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:
2026-09-07 21:45:03 +02:00
co-authored by Claude Opus 5
parent 2a48d10aa9
commit 683813ec10
4 changed files with 329 additions and 65 deletions
+28 -16
View File
@@ -2,7 +2,7 @@ use crate::{
N_INF,
factor::{Factor, VarId, VarStore},
gaussian::Gaussian,
pdf,
ln_pdf,
};
/// Gaussian observation factor on a diff variable.
@@ -16,7 +16,7 @@ pub struct MarginFactor {
pub m_obs: f64,
pub sigma: f64,
pub(crate) msg: Gaussian,
pub(crate) evidence_cached: Option<f64>,
pub(crate) log_evidence_cached: Option<f64>,
}
impl MarginFactor {
@@ -28,7 +28,7 @@ impl MarginFactor {
m_obs,
sigma,
msg: N_INF,
evidence_cached: None,
log_evidence_cached: None,
}
}
}
@@ -41,8 +41,8 @@ impl MarginFactor {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.m_obs, self.sigma));
if self.log_evidence_cached.is_none() {
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.m_obs, self.sigma));
}
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
@@ -61,17 +61,29 @@ impl Factor for MarginFactor {
}
fn log_evidence(&self, _vars: &VarStore) -> f64 {
self.evidence_cached.unwrap_or(1.0).ln()
self.log_evidence_cached.unwrap_or(0.0)
}
}
/// Density of the observed margin under the cavity, clamped to a positive
/// floor so a far-out observation cannot underflow to `0.0` and make
/// `log_evidence` `-inf`.
fn cavity_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
/// `ln` of the observed margin's density under the cavity.
///
/// Computed in log space rather than as `pdf(..).ln()`. The density underflows
/// to zero past about 38 sigma of separation, and clamping that to
/// `f64::MIN_POSITIVE` reported -708 nats however far out the observation
/// actually was — 4292 nats adrift at 100 sigma, and unbounded beyond. A score
/// far from what the model expected is exactly the observation a log-evidence
/// figure exists to notice.
fn cavity_log_evidence(cavity: Gaussian, m_obs: f64, sigma: f64) -> f64 {
let combined_sigma = (cavity.sigma().powi(2) + sigma.powi(2)).sqrt();
let value = ln_pdf(m_obs, cavity.mu(), combined_sigma);
pdf(m_obs, cavity.mu(), combined_sigma).max(f64::MIN_POSITIVE)
// A degenerate cavity (infinite sigma) is the only way to reach a
// non-finite result; fall back to the old floor rather than emit -inf.
if value.is_finite() {
value
} else {
f64::MIN_POSITIVE.ln()
}
}
#[cfg(test)]
@@ -113,16 +125,16 @@ mod tests {
let mut vars = VarStore::new();
let diff = vars.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f = MarginFactor::new(diff, 5.0, 1.0);
assert!(f.evidence_cached.is_none());
assert!(f.log_evidence_cached.is_none());
f.propagate(&mut vars);
let z = f.evidence_cached.unwrap();
// pdf(5, 0, sqrt(37)) 0.046783
assert!((z - 0.04678300292616668).abs() < 1e-10);
let z = f.log_evidence_cached.unwrap();
// ln pdf(5, 0, sqrt(37)) = ln(0.046783...)
assert!((z.exp() - 0.04678300292616668).abs() < 1e-10);
// Subsequent propagations don't change it.
f.propagate(&mut vars);
assert_eq!(f.evidence_cached.unwrap(), z);
assert_eq!(f.log_evidence_cached.unwrap(), z);
}
#[test]
+65 -44
View File
@@ -1,8 +1,8 @@
use crate::{
N_INF, approx, cdf,
N_INF, approx,
factor::{Factor, VarId, VarStore},
gaussian::Gaussian,
sf,
ln_interval, ln_sf,
};
/// EP truncation factor on a diff variable.
@@ -19,7 +19,7 @@ pub struct TruncFactor {
/// Outgoing message to the diff variable (initial: `N_INF`, the EP identity).
pub(crate) msg: Gaussian,
/// Cached evidence (linear, not log) computed from the cavity on first propagation.
pub(crate) evidence_cached: Option<f64>,
pub(crate) log_evidence_cached: Option<f64>,
}
impl TruncFactor {
@@ -30,7 +30,7 @@ impl TruncFactor {
margin,
tie,
msg: N_INF,
evidence_cached: None,
log_evidence_cached: None,
}
}
}
@@ -43,8 +43,8 @@ impl TruncFactor {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
if self.log_evidence_cached.is_none() {
self.log_evidence_cached = Some(cavity_log_evidence(cavity, self.margin, self.tie));
}
let trunc = approx(cavity, self.margin, self.tie);
@@ -69,38 +69,34 @@ impl Factor for TruncFactor {
}
fn log_evidence(&self, _vars: &VarStore) -> f64 {
self.evidence_cached.unwrap_or(1.0).ln()
self.log_evidence_cached.unwrap_or(0.0)
}
}
/// P(diff > margin) for non-tie, P(|diff| < margin) for tie.
/// `ln P(diff > margin)` for a win, `ln P(|diff| < margin)` for a tie.
///
/// Both branches pick whichever tail keeps their terms *small*, because the
/// alternative is subtracting two numbers that both approach 1. That
/// subtraction is not a rounding detail: it loses every digit of an unlikely
/// outcome's evidence, and an unlikely outcome is precisely the one worth
/// scoring. `1 - cdf` returned exactly zero past ~8.3 sigma, where the true
/// probability is 1e-19; clamped, that reached `log_evidence` as -708 instead
/// of -43.
///
/// The clamp remains as a guard rather than a workaround: `erfc` carries ~1e-7
/// relative error, so a probability of exactly 1 can still come back a hair
/// above it, and `ln` of a negative would poison the sum for the whole history.
fn cavity_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
/// Computed in log space throughout. Two earlier shapes both lost the tail:
/// `1 - cdf(..)` cancelled away every digit of an unlikely outcome, and even
/// once that was fixed the linear probability underflows to zero past about 38
/// sigma, where clamping reported -708 nats regardless of the truth. An upset
/// is the observation a log-evidence figure exists to notice, so it has to stay
/// exact precisely where it is smallest.
fn cavity_log_evidence(diff: Gaussian, margin: f64, tie: bool) -> f64 {
let (mu, sigma) = (diff.mu(), diff.sigma());
let raw = if tie {
if mu < -margin {
// Both CDFs sit against 1 here; both survival terms are small.
sf(-margin, mu, sigma) - sf(margin, mu, sigma)
let value = if tie {
ln_interval(-margin, margin, mu, sigma)
} else {
cdf(margin, mu, sigma) - cdf(-margin, mu, sigma)
}
} else {
sf(margin, mu, sigma)
ln_sf(margin, mu, sigma)
};
raw.clamp(f64::MIN_POSITIVE, 1.0)
// A degenerate cavity is the only route to a non-finite result; keep the
// old floor for it rather than letting -inf poison the whole history's sum.
if value.is_finite() {
value
} else {
f64::MIN_POSITIVE.ln()
}
}
#[cfg(test)]
@@ -131,19 +127,19 @@ mod tests {
let diff = vars.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f = TruncFactor::new(diff, 0.0, false);
assert!(f.evidence_cached.is_none());
assert!(f.log_evidence_cached.is_none());
f.propagate(&mut vars);
assert!(f.evidence_cached.is_some());
let first = f.evidence_cached.unwrap();
assert!(f.log_evidence_cached.is_some());
let first = f.log_evidence_cached.unwrap();
// Evidence should be P(diff > 0) for diff ~ N(2, 9) ≈ 0.748
assert!(first > 0.7);
assert!(first < 0.8);
assert!(first.exp() > 0.7);
assert!(first.exp() < 0.8);
// Subsequent propagations don't change it.
f.propagate(&mut vars);
assert_eq!(f.evidence_cached.unwrap(), first);
assert_eq!(f.log_evidence_cached.unwrap(), first);
}
/// The defect this guards: `1 - cdf` collapsed to zero for a surprising
@@ -154,7 +150,7 @@ mod tests {
#[test]
fn evidence_of_an_upset_is_not_flattened_to_the_clamp_floor() {
// diff ~ N(-9, 1) with margin 0: the favoured side lost by nine sigma.
let evidence = cavity_evidence(Gaussian::from_ms(-9.0, 1.0), 0.0, false);
let evidence = cavity_log_evidence(Gaussian::from_ms(-9.0, 1.0), 0.0, false).exp();
assert!(
evidence > f64::MIN_POSITIVE,
@@ -175,23 +171,48 @@ mod tests {
/// Evidence must stay finite and positive however extreme the mismatch,
/// since `log_evidence` sums across the whole history and one `-inf` or
/// `NaN` poisons all of it.
///
/// Finiteness alone is too weak a bar — the clamped version was finite too,
/// and wrong by hundreds of nats. `log_evidence_tracks_the_analytic_tail`
/// below is the assertion that actually holds this up.
#[test]
fn evidence_stays_positive_and_finite_at_any_separation() {
for mu in [-300.0f64, -50.0, -9.0, 0.0, 9.0, 50.0, 300.0] {
for tie in [false, true] {
let e = cavity_evidence(Gaussian::from_ms(mu, 1.0), 1.0, tie);
let ln_e = cavity_log_evidence(Gaussian::from_ms(mu, 1.0), 1.0, tie);
assert!(
e.is_finite() && e > 0.0 && e <= 1.0,
"mu={mu} tie={tie}: evidence {e} is not a probability"
);
assert!(
e.ln().is_finite(),
"mu={mu} tie={tie}: ln evidence is not finite"
ln_e.is_finite() && ln_e <= 0.0,
"mu={mu} tie={tie}: log evidence {ln_e} is not a log-probability"
);
}
}
}
/// The clamp used to floor everything past ~38 sigma at `ln(MIN_POSITIVE)`
/// = -708, however far out the real observation was. In log space the
/// answer is a polynomial and stays exact: at 1000 sigma the truth is about
/// -500_000 nats, and -708 is not a rounding error.
#[test]
fn log_evidence_tracks_the_analytic_tail() {
for mu in [-40.0f64, -60.0, -100.0, -1000.0] {
// P(diff > 0) for diff ~ N(mu, 1), mu far below zero.
let got = cavity_log_evidence(Gaussian::from_ms(mu, 1.0), 0.0, false);
// ln Phi(mu) ~ -mu^2/2 - ln(-mu) - ln(sqrt(2 pi)) for mu << 0.
let z = -mu;
let approx = -0.5 * z * z - z.ln() - (2.0 * std::f64::consts::PI).sqrt().ln();
assert!(
got < f64::MIN_POSITIVE.ln(),
"mu={mu}: {got} is still stuck on the old clamp floor"
);
assert!(
(got - approx).abs() / approx.abs() < 1e-3,
"mu={mu}: got {got}, asymptotic expectation {approx}"
);
}
}
#[test]
fn tie_evidence_uses_two_sided() {
let mut vars = VarStore::new();
@@ -201,7 +222,7 @@ mod tests {
f.propagate(&mut vars);
// For diff ~ N(0, 4), tie=true with margin=1: P(-1 < diff < 1) ≈ 0.383
let ev = f.evidence_cached.unwrap();
let ev = f.log_evidence_cached.unwrap().exp();
assert!(ev > 0.35 && ev < 0.42);
}
+10 -4
View File
@@ -46,8 +46,8 @@ impl DiffFactor {
/// reaches.
pub(crate) fn log_evidence(&self) -> f64 {
match self {
Self::Trunc(f) => f.evidence_cached.unwrap_or(1.0).ln(),
Self::Margin(f) => f.evidence_cached.unwrap_or(1.0).ln(),
Self::Trunc(f) => f.log_evidence_cached.unwrap_or(0.0),
Self::Margin(f) => f.log_evidence_cached.unwrap_or(0.0),
}
}
@@ -733,9 +733,15 @@ mod tests {
let c = p[2][0];
// T1 ULP shift: mu rounds to 25.0 (was 24.999999) under natural-parameter storage.
//
// The 1e-6-place values moved when `erfc_inv`'s sign error was fixed:
// this case runs at `p_draw = 0.5`, so it goes through `compute_margin`,
// and the margin is now 8.4e-8 from the exact quantile where it was
// 1.46e-7. Verified as movement *toward* analytic truth, not a
// regression — see `erfc_inv_matches_known_quantiles`.
assert_ulps_eq!(a, Gaussian::from_ms(25.0, 6.092561), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(33.379314, 6.483575), epsilon = 1e-6);
assert_ulps_eq!(c, Gaussian::from_ms(16.620685, 6.483575), epsilon = 1e-6);
assert_ulps_eq!(b, Gaussian::from_ms(33.379315, 6.483576), epsilon = 1e-6);
assert_ulps_eq!(c, Gaussian::from_ms(16.620685, 6.483576), epsilon = 1e-6);
}
#[test]
+226 -1
View File
@@ -249,7 +249,15 @@ fn erfc_inv(mut y: f64) -> f64 {
let t = (-2.0 * (y / 2.0).ln()).sqrt();
let mut x = FRAC_1_SQRT_2 * ((2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t);
// The leading coefficient is NEGATIVE. `rational - t` is negative here, so
// a positive coefficient mirrors the starting point to `-x0` — the
// reflection of the root. Newton then has to cross the origin to get back,
// which a fixed iteration count does not manage: measured against the true
// value, `erfc_inv(0.1)` returned 1.044 instead of 1.16309, and the error
// grew as y shrank until `compute_margin` stopped being monotone in
// `p_draw` altogether.
let mut x =
-FRAC_1_SQRT_2 * ((2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t);
for _ in 0..3 {
let err = erfc(x) - y;
@@ -318,6 +326,73 @@ fn erfcx(x: f64) -> f64 {
}
}
/// `ln` of the normal density at `x`.
///
/// The density itself underflows to zero past about 38 sigma, and `ln` of a
/// clamped zero is -708 whatever the truth was. The log form is a polynomial:
/// it stays exact at any separation, and the values it produces (-5001 nats at
/// 100 sigma, -500001 at 1000) are perfectly representable.
pub(crate) fn ln_pdf(x: f64, mu: f64, sigma: f64) -> f64 {
let z = (x - mu) / sigma;
-(SQRT_TAU * sigma).ln() - 0.5 * z * z
}
/// `ln P(X > x)` for `X ~ N(mu, sigma^2)`.
///
/// In the upper tail the `exp(-z^2 / 2)` common to the tail integral is
/// factored out analytically via `erfcx`, so this never underflows — where
/// `sf(..).ln()` bottoms out at -708 once `erfc` itself reaches zero.
pub(crate) fn ln_sf(x: f64, mu: f64, sigma: f64) -> f64 {
let z = (x - mu) / sigma;
if z > 0.0 {
// 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()
} else {
// The mass here is at least a half; nothing to lose.
sf(x, mu, sigma).ln()
}
}
/// `ln P(lo < X < hi)` for `X ~ N(mu, sigma^2)`.
///
/// When the interval sits in a tail both endpoint probabilities underflow
/// together, so their difference is taken in scaled form with the shared
/// exponential factored out. When it straddles the mean nothing is small and
/// the direct difference is exact.
pub(crate) fn ln_interval(lo: f64, hi: f64, mu: f64, sigma: f64) -> f64 {
let z_lo = (lo - mu) / sigma;
let z_hi = (hi - mu) / sigma;
if z_hi <= z_lo {
return f64::NEG_INFINITY;
}
// Fold a lower-tail interval onto the upper tail; the normal is symmetric.
let (near, far) = if z_lo >= 0.0 {
(z_lo, z_hi)
} else if z_hi <= 0.0 {
(-z_hi, -z_lo)
} else {
// Straddles the mean: the interval holds a non-negligible share of the
// mass, so neither endpoint is near enough to 1 to cancel.
return (cdf(hi, mu, sigma) - cdf(lo, mu, sigma))
.max(f64::MIN_POSITIVE)
.ln();
};
let (a, b) = (near / SQRT_2, far / SQRT_2);
// b > a >= 0, so this ratio of exponentials is at most 1 and cannot overflow.
let scale = (a * a - b * b).exp();
let bracket = erfcx(a) - scale * erfcx(b);
if bracket <= 0.0 {
return f64::NEG_INFINITY;
}
-std::f64::consts::LN_2 - a * a + bracket.ln()
}
fn pdf(x: f64, mu: f64, sigma: f64) -> f64 {
let normalizer = (SQRT_TAU * sigma).powi(-1);
let functional = (-((x - mu).powi(2)) / (2.0 * sigma.powi(2))).exp();
@@ -746,6 +821,156 @@ mod tests {
}
}
/// `erfc_inv`'s initial guess had the wrong sign, putting Newton on the
/// mirror image of the root. Three fixed iterations could not cross back,
/// so the error grew as the argument shrank: at `p_draw = 0.99` the margin
/// came out 0.503 where the answer is 2.576.
#[test]
fn erfc_inv_matches_known_quantiles() {
// sqrt(2) * erfc_inv(1 - p) is the standard normal quantile
// Phi^-1((1 + p) / 2).
for (p, exact) in [
(0.5f64, 0.674_489_750_196_081_7f64),
(0.9, 1.644_853_626_951_472_7),
(0.95, 1.959_963_984_540_054_2),
(0.99, 2.575_829_303_548_9),
(0.999, 3.290_526_731_491_896_4),
] {
let got = SQRT_2 * erfc_inv(1.0 - p);
assert!(
(got - exact).abs() / exact < 1e-6,
"p={p}: got {got}, exact {exact}"
);
}
}
/// 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.999, which is not a rounding error but a broken function.
#[test]
fn compute_margin_is_monotone_in_the_draw_probability() {
let mut previous = 0.0;
for p_draw in [
0.001f64, 0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99, 0.999, 0.9999,
] {
let margin = compute_margin(p_draw, 1.0);
assert!(
margin > previous,
"p_draw={p_draw}: margin {margin} did not exceed {previous}"
);
previous = margin;
}
}
/// Round-tripping the margin back through the model's own CDF must recover
/// the draw probability it was built from.
#[test]
fn compute_margin_round_trips_through_the_cdf() {
for p_draw in [0.001f64, 0.1, 0.5, 0.9, 0.99, 0.999] {
for sd in [0.5f64, 1.0, 5.892_557] {
let margin = compute_margin(p_draw, sd);
// P(|X| < margin) for X ~ N(0, sd^2).
let recovered = 1.0 - 2.0 * cdf(-margin, 0.0, sd);
assert!(
(recovered - p_draw).abs() < 1e-6,
"p_draw={p_draw} sd={sd}: recovered {recovered}"
);
}
}
}
/// `ln_pdf`, `ln_sf` and `ln_interval` exist so evidence stays exact where
/// the linear forms underflow. Past ~38 sigma the linear value is zero and
/// its log is whatever floor it was clamped to.
#[test]
fn log_space_helpers_stay_exact_where_the_linear_forms_underflow() {
for z in [40.0f64, 60.0, 100.0, 1000.0] {
assert_eq!(pdf(z, 0.0, 1.0), 0.0, "pdf should underflow at {z}");
assert_eq!(sf(z, 0.0, 1.0), 0.0, "sf should underflow at {z}");
let lp = ln_pdf(z, 0.0, 1.0);
let expected_lp = -(SQRT_TAU).ln() - 0.5 * z * z;
assert!(
(lp - expected_lp).abs() < 1e-9,
"ln_pdf({z}) = {lp}, expected {expected_lp}"
);
let ls = ln_sf(z, 0.0, 1.0);
// ln Phi(-z) ~ -z^2/2 - ln(z) - ln(sqrt(2 pi)) for large z.
let approx = -0.5 * z * z - z.ln() - SQRT_TAU.ln();
assert!(
(ls - approx).abs() / approx.abs() < 1e-3,
"ln_sf({z}) = {ls}, asymptote {approx}"
);
assert!(
ls < f64::MIN_POSITIVE.ln(),
"ln_sf({z}) still on the clamp floor"
);
}
}
/// Where nothing underflows, the log helpers must agree with the direct
/// forms exactly enough that nothing else in the crate shifts.
#[test]
fn log_space_helpers_agree_with_the_linear_forms_in_range() {
for z in [-3.0f64, -1.0, 0.0, 1.0, 2.0, 5.0, 10.0, 20.0] {
let lp = ln_pdf(z, 0.5, 2.0);
let direct_pdf = pdf(z, 0.5, 2.0);
assert!(
(lp.exp() - direct_pdf).abs() <= 1e-12 * direct_pdf,
"ln_pdf at {z}: {} vs {direct_pdf}",
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 direct = sf(z, 0.5, 2.0);
assert!(
(ls.exp() - direct).abs() <= 1e-6 * direct.max(1e-300),
"ln_sf at {z}: {} vs {direct}",
ls.exp()
);
}
}
#[test]
fn ln_interval_matches_the_direct_difference_when_nothing_is_small() {
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 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!(
(logged - direct).abs() <= 1e-6 * direct,
"mu={mu}: {logged} vs {direct}"
);
}
}
/// A window far out in the tail: both endpoints underflow together, so the
/// difference has to be taken in scaled form.
#[test]
fn ln_interval_survives_a_window_deep_in_the_tail() {
for mu in [-50.0f64, -100.0, -1000.0] {
let logged = ln_interval(-1.0, 1.0, mu, 1.0);
assert!(logged.is_finite(), "mu={mu}: {logged}");
assert!(
logged < f64::MIN_POSITIVE.ln(),
"mu={mu}: {logged} is stuck on the clamp floor"
);
// Dominated by the near edge: ln P ~ ln Phi(-(|mu| - 1)).
let near = ln_sf(-1.0, mu, 1.0);
assert!(
(logged - near).abs() < 5.0,
"mu={mu}: {logged} strays from the near-edge tail {near}"
);
}
}
#[test]
fn test_quality() {
let a = Gaussian::from_ms(25.0, 3.0);