Skip to main content

gam_gpu/
numerics_host.rs

1//! Host-side scalar special functions shared by the CPU parity references of
2//! the GPU backends.
3//!
4//! The CUDA kernels emit their own NVRTC-visible numerics (see
5//! [`crate::numerics_device`]); this module is the matching **host** side
6//! used by the CPU parity oracles (`bms_flex_row`'s test oracle) and the
7//! CPU reference path (`pirls_row`'s probit CDF). Keeping a single definition
8//! here means the host `erfc` cannot drift between backends.
9
10/// Complementary error function `erfc(x) = 1 − erf(x)` evaluated on the host.
11///
12/// Routes to `libm::erfc`, the SunOS msun double-precision implementation
13/// (accurate to within ~1 ulp across the entire real line). The CUDA kernel
14/// side calls device `erfc`, which is itself msun-derived, so the host CPU
15/// reference matches the device path to within a ULP. The previous
16/// branchless Cody 1969 Chebyshev rational here was only ~1.2e-7 accurate
17/// in relative terms; that ate seven digits of every probit `Mills =
18/// φ/Φ = pdf / (½·erfc(-x/√2))` evaluation and made any sufficiently
19/// tight finite-difference probe of `∂neglog/∂e = -w·s·Mills` (which the
20/// analytic side computes from this same `cdf`, while the FD side
21/// differences `log cdf` and cancels the erfc bias) break against itself
22/// at the ~2e-7 floor instead of the genuine 5-point-stencil truncation
23/// floor near 1e-12.
24pub fn erfc(x: f64) -> f64 {
25    libm::erfc(x)
26}
27
28// ── Host oracle for the shared device probit numerics (issue #1175) ──────────
29//
30// The functions below are the CPU-side, device-free mirror of the CUDA source
31// in [`crate::numerics_device::PROBIT_NUMERICS_CU`]. They are written
32// LINE-FOR-LINE against that kernel source — the SAME branch structure, the
33// SAME asymptotic `erfcx` polynomial, and the SAME constants — differing only
34// in that they call the host `libm`
35// transcendentals (`erfc`/`exp`/`log`) where the kernel calls the device
36// `erfc`/`exp`/`log`. Both sides are the SunOS *msun* double-precision
37// implementations, so the host oracle matches the device to within ~1 ULP per
38// transcendental (issue #1175 items 4–5). This mirrors the #1017
39// `emulate_certified_encode_row` pattern: a CPU emulator that is BOTH the
40// fallback and the exactness oracle a device launch is pinned to.
41//
42// Correctness *without a GPU* (CPU-verifiable): the test harness below asserts
43// (a) these constants are bit-identical to the literals in the kernel source
44// (the "constants cannot drift" lock, #1175 item 4), (b) the kernel source uses
45// only msun transcendentals and no fast-math intrinsics (transcendental-parity
46// intent), and (c) the host oracle satisfies the defining probit identities to
47// a stated ULP bound. Confirming a *device launch* reproduces this oracle to
48// round-off still needs CUDA hardware.
49
50/// `1/√(2π)`, matching `INV_SQRT_2PI` in the kernel source bit-for-bit.
51pub const INV_SQRT_2PI: f64 = 0.3989422804014327;
52/// `√2`, matching `SQRT_2` in the kernel source bit-for-bit.
53pub const SQRT_2: f64 = 1.4142135623730951;
54/// `ln(2)`, matching `LN_2` in the kernel source bit-for-bit.
55pub const LN_2: f64 = 0.6931471805599453;
56/// `1/√π`, matching `inv_sqrt_pi` in the kernel source bit-for-bit.
57pub const INV_SQRT_PI: f64 = 0.5641895835477563;
58/// `√(2/π)`, matching `sqrt_2_over_pi` in the kernel source bit-for-bit.
59pub const SQRT_2_OVER_PI: f64 = 0.7978845608028654;
60
61/// Scaled complementary error function `erfcx(x) = exp(x²)·erfc(x)` for `x ≥ 0`,
62/// the host oracle for the device `erfcx_nonnegative`. Returns `0.0` at `+∞`;
63/// negative inputs and `NaN` return `NaN` because they violate the restricted
64/// domain. For `0 ≤ x < 26` evaluates `exp(x²)·erfc(x)` directly; beyond that
65/// it switches to the same six-correction asymptotic expansion as the kernel.
66///
67/// The direct branch carries `x²` exactly. Rounding the square perturbs it by
68/// a relative `ε/2`, and `exp` converts a relative perturbation of its ARGUMENT
69/// into `x²` times that in its RESULT — `5.7e-14` at the top of the branch,
70/// against the `3e-16` the asymptotic branch already delivers, so the seam at
71/// `26` was a 190x step DOWN in error into the interval every probit consumer
72/// lives in. `mul_add` is the IEEE fused operation, matching the kernel's
73/// explicit `fma` call, which `--fmad=false` does not touch (it disables
74/// CONTRACTION of a separate `a*b+c`). See `gam_math::probability` for the
75/// measurements; this branch is its line-for-line device-side twin.
76pub fn erfcx_nonnegative(x: f64) -> f64 {
77    if x.is_nan() || x < 0.0 {
78        return f64::NAN;
79    }
80    if x == f64::INFINITY {
81        return 0.0;
82    }
83    if x < 26.0 {
84        let hi = x * x;
85        let lo = x.mul_add(x, -hi);
86        let head = libm::exp(hi) * erfc(x);
87        return head.mul_add(lo, head);
88    }
89    let inv = 1.0 / x;
90    let inv2 = inv * inv;
91    let poly = 1.0
92        + inv2
93            * (-0.5
94                + inv2
95                    * (0.75
96                        + inv2
97                            * (-1.875 + inv2 * (6.5625 + inv2 * (-29.53125 + inv2 * 162.421875)))));
98    inv * poly * INV_SQRT_PI
99}
100
101/// `log Φ(x)` for the standard normal CDF, the host oracle for the device
102/// `log_ndtr`. For `x < 0` uses the `erfcx` representation
103/// `log Φ(x) = −u² + log(½·erfcx(u))`, `u = −x/√2`, keeping digits into the
104/// deep left tail; for `x ≥ 0` uses `log1p(−½·erfc(x/√2))`, retaining the
105/// negative tail after the CDF rounds to one. Propagates `±∞`/`NaN` exactly as
106/// the device path does.
107pub fn log_ndtr(x: f64) -> f64 {
108    if x == f64::INFINITY {
109        return 0.0;
110    }
111    if x == f64::NEG_INFINITY {
112        return f64::NEG_INFINITY;
113    }
114    if x.is_nan() {
115        return x;
116    }
117    if x < 0.0 {
118        let u = -x / SQRT_2;
119        let ex = erfcx_nonnegative(u);
120        -u * u + libm::log(ex) - LN_2
121    } else {
122        let upper_tail = 0.5 * erfc(x / SQRT_2);
123        libm::log1p(-upper_tail)
124    }
125}
126
127/// Joint `(log Φ(x), Mills ratio φ(x)/Φ(x))`, the host oracle for the device
128/// `log_ndtr_and_mills`. The `x < 0` branch computes the Mills ratio as
129/// `√(2/π)/erfcx(u)`, which stays finite even when `Φ(x)` underflows; the
130/// `x ≥ 0` branch forms `pdf/cdf` directly. Boundary values mirror the kernel:
131/// `(+0, +0)` at `+∞`, `(−∞, +∞)` at `−∞`, `(NaN, NaN)` at `NaN`.
132pub fn log_ndtr_and_mills(x: f64) -> (f64, f64) {
133    if x == f64::INFINITY {
134        return (0.0, 0.0);
135    }
136    if x == f64::NEG_INFINITY {
137        return (f64::NEG_INFINITY, f64::INFINITY);
138    }
139    if x.is_nan() {
140        return (x, x);
141    }
142    if x < 0.0 {
143        let u = -x / SQRT_2;
144        let ex = erfcx_nonnegative(u);
145        let log_cdf = -u * u + libm::log(ex) - LN_2;
146        let lambda = SQRT_2_OVER_PI / ex;
147        (log_cdf, lambda)
148    } else {
149        let upper_tail = 0.5 * erfc(x / SQRT_2);
150        let cdf = 1.0 - upper_tail;
151        // Same exact-square correction as `erfcx_nonnegative`. `x` is finite
152        // and non-negative here, so the residual is always a finite number.
153        let xx = x * x;
154        let pdf = INV_SQRT_2PI * libm::exp(-0.5 * xx);
155        let pdf = pdf.mul_add(-0.5 * x.mul_add(x, -xx), pdf);
156        let log_cdf = libm::log1p(-upper_tail);
157        let lambda = pdf / cdf;
158        (log_cdf, lambda)
159    }
160}
161
162/// Joint `(log Φ(x), φ(x)/Φ(x), −d²log Φ(x)/dx²)` host oracle.
163///
164/// The curvature's deep-left branch differentiates the same 32-level Laplace
165/// continued fraction as the CPU model kernel, retaining its unit limit without
166/// subtracting the nearly equal `x` and Mills ratio. The CUDA source mirrors
167/// this operation order; host/device transcendental channels are ULP-close,
168/// while the rational curvature branch is operation-for-operation identical
169/// when device FMA contraction is disabled.
170pub fn log_ndtr_mills_curvature(x: f64) -> (f64, f64, f64) {
171    let (log_cdf, lambda) = log_ndtr_and_mills(x);
172    if x.is_nan() {
173        return (log_cdf, lambda, x);
174    }
175    if x.is_infinite() {
176        return (
177            log_cdf,
178            lambda,
179            if x.is_sign_positive() { 0.0 } else { 1.0 },
180        );
181    }
182    let curvature = if x <= -4.0 {
183        let t = -x;
184        let mut q = 0.0;
185        let mut q_first = 0.0;
186        for n in (1..=32).rev() {
187            let denominator = t + q;
188            let value = f64::from(n) / denominator;
189            q_first = -value * (1.0 + q_first) / denominator;
190            q = value;
191        }
192        1.0 + q_first
193    } else {
194        lambda * (x + lambda)
195    };
196    (log_cdf, lambda, curvature)
197}
198
199#[cfg(test)]
200mod probit_parity_tests {
201    //! CPU-verifiable floating-point-order & transcendental parity harness for
202    //! the shared probit numerics (issue #1175). Everything here runs without a
203    //! GPU: it pins the host oracle constants to the kernel-source literals,
204    //! audits the kernel source for msun-only transcendentals (no fast-math),
205    //! and checks the host oracle against the defining probit identities within
206    //! stated ULP bounds. A *device* reproducing this oracle to round-off still
207    //! requires CUDA hardware and is asserted by the on-device parity gates.
208    use super::*;
209    use crate::numerics_device::PROBIT_NUMERICS_CU;
210
211    const EPS: f64 = f64::EPSILON; // 2.220446049250313e-16
212
213    /// Relative error of `got` vs `want`, expressed in ULP of `want`.
214    fn ulp(got: f64, want: f64) -> f64 {
215        if want == 0.0 {
216            (got - want).abs() / EPS
217        } else {
218            (got - want).abs() / (EPS * want.abs())
219        }
220    }
221
222    /// Extract the first f64 literal appearing after `needle` in `src`.
223    fn literal_after(src: &str, needle: &str) -> f64 {
224        let start = src
225            .find(needle)
226            .unwrap_or_else(|| panic!("kernel source is missing marker {needle:?}"))
227            + needle.len();
228        let tail = &src[start..];
229        // Skip separators between the marker and the number ('=', whitespace).
230        let num_start = tail
231            .find(|c: char| c == '-' || c == '.' || c.is_ascii_digit())
232            .unwrap_or_else(|| panic!("no numeric literal follows {needle:?}"));
233        let rest = &tail[num_start..];
234        let end = rest
235            .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')))
236            .unwrap_or(rest.len());
237        rest[..end]
238            .parse::<f64>()
239            .unwrap_or_else(|e| panic!("failed to parse literal after {needle:?}: {e}"))
240    }
241
242    /// #1175 item 4 pattern ("constants cannot drift"): every constant the host
243    /// oracle uses is bit-identical to the literal baked into the kernel source.
244    /// A one-bit edit on either side fails this immediately.
245    #[test]
246    fn host_constants_match_kernel_source_bit_for_bit() {
247        for (needle, host) in [
248            ("#define INV_SQRT_2PI", INV_SQRT_2PI),
249            ("#define SQRT_2", SQRT_2),
250            ("#define LN_2", LN_2),
251            ("inv_sqrt_pi =", INV_SQRT_PI),
252            ("sqrt_2_over_pi =", SQRT_2_OVER_PI),
253        ] {
254            let device = literal_after(PROBIT_NUMERICS_CU, needle);
255            assert_eq!(
256                device.to_bits(),
257                host.to_bits(),
258                "constant {needle:?} drifted: kernel={device:?} host={host:?}"
259            );
260        }
261    }
262
263    /// Transcendental-parity intent: the kernel evaluates its transcendentals
264    /// through the msun `erfc`/`exp`/`log` (which the host `libm` mirrors) and
265    /// contains NO fast-math intrinsic or single-precision variant. FMA
266    /// contraction is separately disabled at compile time via
267    /// `device_cache`'s `--fmad=false`; this guards the source itself.
268    #[test]
269    fn kernel_source_uses_msun_transcendentals_only() {
270        for good in ["erfc(", "exp(", "log(", "log1p("] {
271            assert!(
272                PROBIT_NUMERICS_CU.contains(good),
273                "kernel source should call msun `{good}`"
274            );
275        }
276        for bad in [
277            "__expf",
278            "__logf",
279            "expf(",
280            "logf(",
281            "erfcf(",
282            "__fdividef",
283            "__frcp",
284            "use_fast_math",
285            "ffast-math",
286            "__dmul_",
287            "__dadd_",
288            "__fmaf",
289        ] {
290            assert!(
291                !PROBIT_NUMERICS_CU.contains(bad),
292                "kernel source must not use fast-math / single-precision `{bad}`"
293            );
294        }
295    }
296
297    /// `erfc` boundary + symmetry: `erfc(0)=1` exactly and
298    /// `erfc(-x) = 2 - erfc(x)` to ≤ 2 ULP across a moderate grid.
299    #[test]
300    fn erfc_boundary_and_symmetry() {
301        assert_eq!(erfc(0.0), 1.0);
302        let mut worst = 0.0_f64;
303        for i in 0..300 {
304            let x = i as f64 * 0.01;
305            worst = worst.max(ulp(erfc(-x), 2.0 - erfc(x)));
306        }
307        assert!(worst <= 2.0, "erfc symmetry drift {worst:.3} ULP > 2");
308    }
309
310    /// Defining identity `erfcx(x)·exp(-x²) = erfc(x)` to ≤ 4 ULP for
311    /// `0 < x < 26` (the direct branch of the host oracle).
312    ///
313    /// The inverse factor has to undo the square the SAME way the branch built
314    /// it, or the identity cannot resolve anything finer than the error it is
315    /// supposed to be measuring. `exp(-fl(x*x))` is off by `x²·ε/2` — up to
316    /// `250` ULP at the top of this range — in the opposite direction from
317    /// `exp(fl(x*x))`, so writing the identity with a rounded square on both
318    /// sides makes the two errors CANCEL and the check passes identically well
319    /// whether or not the branch corrects for them. That is why this test, and
320    /// the ULP-tight one it looks like, said nothing about a 190x defect.
321    #[test]
322    fn erfcx_matches_definition() {
323        assert_eq!(erfcx_nonnegative(0.0), 1.0);
324        assert!(erfcx_nonnegative(-3.0).is_nan());
325        assert!(erfcx_nonnegative(f64::NEG_INFINITY).is_nan());
326        assert!(erfcx_nonnegative(f64::NAN).is_nan());
327        assert_eq!(erfcx_nonnegative(f64::INFINITY), 0.0);
328        let mut worst = 0.0_f64;
329        let mut x = 0.1_f64;
330        while x < 25.0 {
331            let hi = x * x;
332            let lo = x.mul_add(x, -hi);
333            let inverse_exp = libm::exp(-hi);
334            let inverse_exp = inverse_exp.mul_add(-lo, inverse_exp);
335            worst = worst.max(ulp(erfcx_nonnegative(x) * inverse_exp, erfc(x)));
336            x += 0.1;
337        }
338        assert!(worst <= 4.0, "erfcx definition drift {worst:.3} ULP > 4");
339    }
340
341    /// The identity above is self-consistent by construction; this is the
342    /// accuracy pin. References are `mpmath` at `dps=60`, on arguments whose
343    /// squares are NOT representable in `f64` — the only ones where the
344    /// rounded-square error is non-zero, and precisely the ones the shared
345    /// erfcx grid (`0.1`, `0.2`, … stepping by `0.1`) mostly misses, since a
346    /// tenth-spaced grid lands on many exactly-squaring values and the identity
347    /// check cancels the defect at the rest.
348    ///
349    /// `1.5e-15` matches the bound the CPU kernel holds itself to in
350    /// `gam_math::probability::erfcx_matches_high_precision_reference`; the two
351    /// implementations are line-for-line twins and must not drift in accuracy
352    /// any more than they may drift in constants.
353    #[test]
354    fn erfcx_matches_high_precision_reference() {
355        const TOLERANCE: f64 = 1.5e-15;
356        let refs: &[(f64, f64)] = &[
357            (0.1, 0.8964569799691267),
358            (10.5, 0.05349189974656412),
359            (14.3, 0.0393580473372741),
360            (19.7, 0.028602309402825203),
361            (23.9, 0.023585649371803793),
362            (25.9999, 0.021683668126369115),
363        ];
364        for &(x, reference) in refs {
365            let got = erfcx_nonnegative(x);
366            let rel = (got - reference).abs() / reference.abs();
367            assert!(
368                rel < TOLERANCE,
369                "erfcx({x}) = {got:.17e}, reference {reference:.17e}, rel {rel:.3e}"
370            );
371        }
372    }
373
374    #[test]
375    fn erfcx_asymptotic_switch_and_subnormal_contract_match_device_source() {
376        // `26² = 676` is exactly representable, so the plain direct form has no
377        // square residual to lose and is a legitimate oracle AT THIS ARGUMENT
378        // (and, note, only here — which is why this check never saw the
379        // rounded-square defect the accuracy pin above catches).
380        let switch = 26.0_f64;
381        assert_eq!(switch.mul_add(switch, -(switch * switch)), 0.0);
382        let direct = libm::exp(switch * switch) * erfc(switch);
383        assert!(
384            (erfcx_nonnegative(switch) / direct - 1.0).abs() < 1.0e-15,
385            "erfcx switch disagrees with direct finite identity"
386        );
387        let tail = erfcx_nonnegative(f64::MAX);
388        assert!(tail > 0.0 && tail.is_subnormal(), "erfcx(MAX)={tail:e}");
389
390        for required in [
391            "isnan(x) || x < 0.0",
392            "inv2 * 162.421875",
393            "log1p(-upper_tail)",
394            "log_ndtr_mills_curvature",
395            // The exact-square correction, on both the erfcx branch and the
396            // `log_ndtr_and_mills` pdf, must survive in the device source or
397            // the two sides stop describing one function.
398            "double lo   = fma(x, x, -hi);",
399            "fma(pdf, -0.5 * fma(x, x, -xx), pdf)",
400        ] {
401            assert!(
402                PROBIT_NUMERICS_CU.contains(required),
403                "device source lost shared tail contract `{required}`"
404            );
405        }
406        for forbidden in ["1e-300", "if (xx > 700.0)", "if (cdf > 1.0)", "1.0 / 0.0"] {
407            assert!(
408                !PROBIT_NUMERICS_CU.contains(forbidden),
409                "device source reintroduced numerical projection `{forbidden}`"
410            );
411        }
412    }
413
414    /// `log_ndtr` boundary + stable bulk Gaussian identity to ≤ 2 ULP for
415    /// `|x| ≤ 3`, and `Φ(x)+Φ(-x)=1` to ≤ 4e-16.
416    #[test]
417    fn log_ndtr_matches_log_cdf_and_reflects() {
418        assert_eq!(log_ndtr(0.0), libm::log(0.5));
419        assert_eq!(log_ndtr(f64::INFINITY), 0.0);
420        assert_eq!(log_ndtr(f64::NEG_INFINITY), f64::NEG_INFINITY);
421        assert!(log_ndtr(f64::NAN).is_nan());
422        assert!(log_ndtr(10.0) < 0.0);
423
424        let mut worst_bulk = 0.0_f64;
425        for i in -30..=30 {
426            let x = i as f64 * 0.1;
427            let expected = if x < 0.0 {
428                libm::log(0.5 * erfc(-x / SQRT_2))
429            } else {
430                libm::log1p(-0.5 * erfc(x / SQRT_2))
431            };
432            worst_bulk = worst_bulk.max(ulp(log_ndtr(x), expected));
433        }
434        assert!(
435            worst_bulk <= 2.0,
436            "log_ndtr vs log-cdf drift {worst_bulk:.3} ULP > 2"
437        );
438
439        let mut worst_refl = 0.0_f64;
440        for i in 0..60 {
441            let x = i as f64 * 0.1;
442            let s = libm::exp(log_ndtr(x)) + libm::exp(log_ndtr(-x));
443            worst_refl = worst_refl.max((s - 1.0).abs());
444        }
445        assert!(
446            worst_refl <= 4e-16,
447            "Φ(x)+Φ(-x) reflection drift {worst_refl:e} > 4e-16"
448        );
449    }
450
451    /// `log_ndtr_and_mills` agrees with `log_ndtr` on the log-CDF channel and
452    /// satisfies the Mills identity `λ(x)·Φ(x) = φ(x)` to ≤ 32 ULP for
453    /// `|x| ≤ 5`; the deep left tail stays finite (no `-∞`/`NaN`).
454    #[test]
455    fn log_ndtr_and_mills_identity_and_deep_tail() {
456        for i in -50..=50 {
457            let x = i as f64 * 0.1;
458            let (log_cdf, lambda) = log_ndtr_and_mills(x);
459            assert_eq!(
460                log_cdf.to_bits(),
461                log_ndtr(x).to_bits(),
462                "joint log-CDF channel diverged from log_ndtr at x={x}"
463            );
464            let phi = libm::exp(log_cdf);
465            let pdf = INV_SQRT_2PI * libm::exp(-0.5 * x * x);
466            assert!(
467                ulp(lambda * phi, pdf) <= 32.0,
468                "Mills identity drift {:.3} ULP > 32 at x={x}",
469                ulp(lambda * phi, pdf)
470            );
471        }
472        for &x in &[-10.0, -20.0, -30.0, -38.0] {
473            let (log_cdf, lambda) = log_ndtr_and_mills(x);
474            assert!(
475                log_cdf.is_finite() && log_cdf < 0.0,
476                "deep-tail log Φ({x}) not finite-negative: {log_cdf}"
477            );
478            assert!(
479                lambda.is_finite() && lambda > x.abs() * 0.9,
480                "deep-tail Mills({x}) should track |x|: {lambda}"
481            );
482        }
483        assert_eq!(log_ndtr_and_mills(f64::INFINITY), (0.0, 0.0));
484        assert_eq!(
485            log_ndtr_and_mills(f64::NEG_INFINITY),
486            (f64::NEG_INFINITY, f64::INFINITY)
487        );
488    }
489
490    #[test]
491    fn log_ndtr_curvature_keeps_unit_left_tail_and_matches_mills_derivative() {
492        let (_, lambda, curvature) = log_ndtr_mills_curvature(-1.0e100);
493        // Deep-left Mills tracks |x| to rounding: the value is assembled from
494        // a short transcendental chain (x/sqrt2, erfcx, reciprocal), so exact
495        // bit equality with 1e100 is one spurious ulp away on some hosts
496        // (a real A10 box measured 1.0000000000000002e100). The contract is
497        // "λ(-1e100) = 1e100 up to a few ulps", not a bit pattern.
498        let ulp = 1.0e100_f64.next_up() - 1.0e100;
499        assert!(
500            (lambda - 1.0e100).abs() <= 4.0 * ulp,
501            "deep-left Mills must track |x| to rounding: lambda={lambda:e}"
502        );
503        assert_eq!(curvature, 1.0);
504        assert_eq!(log_ndtr_mills_curvature(f64::INFINITY), (0.0, 0.0, 0.0));
505        assert_eq!(
506            log_ndtr_mills_curvature(f64::NEG_INFINITY),
507            (f64::NEG_INFINITY, f64::INFINITY, 1.0)
508        );
509        let (nan_log, nan_mills, nan_curv) = log_ndtr_mills_curvature(f64::NAN);
510        assert!(nan_log.is_nan() && nan_mills.is_nan() && nan_curv.is_nan());
511
512        let h = 1.0e-5;
513        for x in [-8.0_f64, -4.0, -2.0, 0.0, 3.0] {
514            let (_, _, analytic) = log_ndtr_mills_curvature(x);
515            let (_, left) = log_ndtr_and_mills(x - h);
516            let (_, right) = log_ndtr_and_mills(x + h);
517            let finite_difference = -(right - left) / (2.0 * h);
518            let relative = (finite_difference - analytic).abs() / analytic.abs().max(1.0e-300);
519            assert!(
520                relative < 2.0e-8,
521                "curvature fd mismatch x={x}: analytic={analytic:e}, fd={finite_difference:e}, rel={relative:e}"
522            );
523        }
524    }
525}