Skip to main content

gam_solve/
quadrature.rs

1//! Gauss-Hermite Quadrature for Posterior Mean Predictions
2//!
3//! This module provides functions to compute the posterior mean of predictions
4//! by integrating over the uncertainty in the linear predictor using
5//! Gauss-Hermite quadrature.
6//!
7//! # Background
8//!
9//! Standard predictions return `g⁻¹(η̂)` where `η̂` is the point estimate (mode).
10//! For curved link functions like logit or survival transforms, this differs from
11//! the posterior mean `E[g⁻¹(η)]` where `η ~ N(η̂, σ²)`.
12//!
13//! The posterior mean:
14//! - Is more conservative at extreme predictions
15//! - Accounts for parameter uncertainty in the final probability
16//!
17//! # Implementation
18//!
19//! We use Gauss-Hermite quadrature with adaptive node counts (7/15/21 points)
20//! based on latent uncertainty scale. This preserves speed in well-identified
21//! regions and improves tail accuracy for high-variance nonlinear transforms.
22//!
23//! The nodes and weights are computed at compile time using the Golub-Welsch
24//! algorithm, which finds eigenvalues of the symmetric tridiagonal Jacobi matrix.
25//!
26//! # Key Assumptions and Limitations
27//!
28//! Gaussian linear predictor: GHQ assumes the linear predictor η follows a
29//! Gaussian distribution. Under a multivariate normal posterior for β (from the
30//! Hessian), any linear combination η = Xβ is exactly Gaussian. This assumption
31//! is consistent with LAML (Laplace Approximate Marginal Likelihood) used for
32//! smoothing parameter selection.
33//!
34//! Non-Gaussian risk output: GHQ does NOT assume the risk is Gaussian. It
35//! correctly integrates through nonlinear link functions (sigmoid, survival
36//! transforms) to capture skewed risk distributions.
37//!
38//! Survival sensitivity: For survival models with double-exponential transforms
39//! (e.g., 1 - exp(-exp(η))), small differences in η are amplified in the tails.
40//! At extreme horizons, this tail sensitivity means GHQ-based intervals may be
41//! slightly underconfident. HMC would provide marginally more accurate tail
42//! quantiles at significant computational cost.
43//!
44//! B-spline local support: At any evaluation point, only ~k+1 spline basis
45//! functions are nonzero (typically 4 for cubic splines). However, the linear
46//! predictor can include main and interaction effects, so
47//! the total remains a sum of many terms.
48//!
49//! # Alternative: HMC
50//!
51//! For cases where the Gaussian assumption on η is questionable (very rare
52//! diseases with <500 cases, extreme non-Gaussianity in the coefficient
53//! posterior), Hamiltonian Monte Carlo could sample β directly and compute
54//! risk for each sample. This is 100-1000x more expensive but makes no
55//! distributional assumptions.
56//!
57//! Practical scope of "exact" special-function formulas:
58//! - Logistic-normal mean/variance can be written exactly with Faddeeva-series
59//!   representations and are useful as oracle references.
60//! - These formulas are mathematically exact representations distinct from the
61//!   GHQ-based moment computations used elsewhere in this module.
62//!
63//! Roadmap for replacing GHQ in integrated PIRLS / uncertainty propagation:
64//!
65//! 1. Probit:
66//!    If eta ~ N(mu, sigma^2), then
67//!      E[Phi(eta)] = Phi(mu / sqrt(1 + sigma^2))
68//!    exactly, with derivative
69//!      d/dmu E[Phi(eta)] = phi(mu / sqrt(1 + sigma^2)) / sqrt(1 + sigma^2).
70//!    This identity is already used by `probit_posterior_mean` below and is the
71//!    model for how integrated IRLS should eventually avoid GHQ entirely for
72//!    probit-linked updates.
73//!
74//! 2. Logit:
75//!    The logistic-normal mean admits exact convergent special-function
76//!    representations (Faddeeva / erfcx series). Those are ideal for the hot
77//!    integrated-IRLS path because they replace per-row GHQ loops with a small,
78//!    deterministic series and exact derivatives with respect to the Gaussian
79//!    mean. This module already contains an oracle-style exact evaluator
80//!    (`logit_posterior_mean_exact`) documenting the mathematics.
81//!
82//! 3. Cloglog / survival transforms:
83//!    The complementary log-log mean under Gaussian eta does not simplify to an
84//!    elementary closed form, but it does admit exact non-GHQ representations:
85//!    - as the Laplace transform of a lognormal variable,
86//!    - as characteristic-function inversion with Gamma(1 - i t),
87//!    - or as rapidly convergent erfc / asymptotic series on subdomains.
88//!    These are the natural replacements for repeated GHQ calls in
89//!    `cloglog_posterior_mean` and any survival-specific cubature path.
90//!
91//! Derivative identity used by integrated PIRLS:
92//! If eta = mu + sigma * Z with Z ~ N(0, 1), then for any smooth inverse-link f,
93//!   d/dmu E[f(eta)] = E[f'(eta)].
94//! This matters because integrated IRLS needs both
95//!   mu_bar = E[g^{-1}(eta)]
96//! and
97//!   dmu_bar / deta = d/dmu E[g^{-1}(eta)].
98//! Once a link-specific exact evaluator can return those two quantities, the
99//! PIRLS update no longer needs any quadrature-node loop in the hot path.
100//!
101//! In particular:
102//! - Probit:
103//!     f(x) = Phi(x)
104//!     E[f(eta)] = Phi(mu / sqrt(1 + sigma^2))
105//!     d/dmu E[f(eta)]
106//!       = phi(mu / sqrt(1 + sigma^2)) / sqrt(1 + sigma^2).
107//! - Cloglog:
108//!     f(x) = 1 - exp(-exp(x))
109//!     E[f(eta)] = 1 - E[exp(-X)], X = exp(eta) ~ LogNormal(mu, sigma^2),
110//!   so the mean is the complement of the lognormal Laplace transform at z = 1.
111//!   The derivative is
112//!     d/dmu E[f(eta)] = E[exp(eta - exp(eta))].
113//! - Logit:
114//!     f(x) = sigmoid(x)
115//!     d/dmu E[f(eta)] = E[sigmoid(eta) * (1 - sigmoid(eta))],
116//!   and both the mean and derivative admit exact convergent special-function
117//!   representations via Faddeeva / erfcx expansions.
118//!
119//! The current GHQ implementations remain because they are robust and general,
120//! but the intended direction is to move integrated PIRLS away from repeated
121//! quadrature-node loops whenever a link-specific exact or special-function
122//! representation is available.
123//!
124//! # Exact Object Behind Cloglog / Survival
125//!
126//! For the cloglog and Royston-Parmar-style survival transforms, the exact
127//! shared scalar object is the lognormal Laplace transform
128//!
129//!   L(z; mu, sigma) = E[exp(-z exp(eta))],   eta ~ N(mu, sigma^2),  z > 0.
130//!
131//! Writing `X = exp(eta)`, this is `E[exp(-z X)]` with
132//! `X ~ LogNormal(mu, sigma^2)`. Two exact identities organize the whole
133//! implementation:
134//!
135//! 1. Shift reduction in `z`:
136//!      L(z; mu, sigma) = L(1; mu + ln z, sigma)
137//!    because `z exp(eta) = exp(eta + ln z)`.
138//!
139//! 2. Gaussian tilting / derivative identity:
140//!      -d/dmu L(z; mu, sigma)
141//!        = z * exp(mu + sigma^2 / 2) * L(z; mu + sigma^2, sigma).
142//!
143//! These imply:
144//!
145//! - survival mean:
146//!     E[exp(-exp(eta))] = L(1; mu, sigma)
147//! - cloglog mean:
148//!     E[1 - exp(-exp(eta))] = 1 - L(1; mu, sigma)
149//! - exact derivative for integrated PIRLS:
150//!     d/dmu E[1 - exp(-exp(eta))]
151//!       = exp(mu + sigma^2 / 2) * L(1; mu + sigma^2, sigma)
152//! - second moment used in posterior variance:
153//!     E[exp(-2 exp(eta))] = L(2; mu, sigma) = L(1; mu + ln 2, sigma)
154//!
155//! So all integrated cloglog/survival quantities are just algebra on top of the
156//! same `L(z; mu, sigma)` object.
157//!
158//! # Representation Classes Used Here
159//!
160//! For `L(z; mu, sigma)`, there is no simple elementary closed form. The useful
161//! exact representations in this module are:
162//!
163//! - a real-line Gaussian expectation
164//! - a Mellin-Barnes / Bromwich contour representation involving `Gamma`
165//! - an erfc-gated Miles series in tail-dominated regimes
166//! - a real-line Clenshaw-Curtis evaluator for the central regime
167//!
168//! The production routing therefore chooses the numerically best exact or
169//! controlled representation for each regime rather than pretending that one
170//! universal formula dominates everywhere.
171
172use std::collections::HashMap;
173use std::convert::Infallible;
174use std::sync::{Arc, Mutex, OnceLock};
175
176use crate::estimate::EstimationError;
177use crate::mixture_link::{
178    beta_logistic_inverse_link_jet, component_inverse_link_jet, sas_inverse_link_jet,
179};
180use gam_math::probability::{erfcx_nonnegative, normal_logcdf};
181use gam_math::quadrature::{GaussHermiteRule, gauss_hermite_rule};
182use gam_math::special::stable_polynomial_times_exp_neg as cloglog_stable_poly_times_exp_neg;
183use gam_problem::types::{
184    GlmLikelihoodSpec, InverseLink, LinkComponent, LinkFunction, MixtureLinkState, ResponseFamily,
185    SasLinkState, StandardLink,
186};
187/// Number of quadrature points (7-point rule is exact for polynomials up to degree 13)
188const N_POINTS: usize = 7;
189const SQRT_2: f64 = std::f64::consts::SQRT_2;
190const QUADRATURE_EXP_LOG_MAX: f64 = 700.0;
191
192// Convention: finite moments saturate exp arguments at 700 and use ControlledAsymptotic mode on saturation.
193// Probability/tail kernels stay in log-space or bounded envelopes so overflow cannot turn finite targets into NaN.
194#[inline]
195fn safe_exp(x: f64) -> f64 {
196    if x.is_nan() {
197        f64::NAN
198    } else {
199        x.min(QUADRATURE_EXP_LOG_MAX).exp()
200    }
201}
202
203#[inline]
204fn safe_expwith_saturation(x: f64) -> (f64, bool) {
205    (safe_exp(x), x > QUADRATURE_EXP_LOG_MAX)
206}
207
208#[derive(Clone, Copy, Debug, Default)]
209struct Complex {
210    re: f64,
211    im: f64,
212}
213
214/// Quadrature context that owns Gauss-Hermite caches.
215pub struct QuadratureContext {
216    gh_cache: OnceLock<GaussHermiteRule>,
217    gh15_cache: OnceLock<GaussHermiteRule>,
218    gh21_cache: OnceLock<GaussHermiteRule>,
219    gh31_cache: OnceLock<GaussHermiteRule>,
220    gh51_cache: OnceLock<GaussHermiteRule>,
221    // Clenshaw-Curtis rules are constructed on demand because the node count is
222    // chosen from the certified truncation/ellipse heuristic rather than from a
223    // tiny fixed family like the GHQ rules above.
224    cc_cache: Mutex<HashMap<usize, Arc<ClenshawCurtisRule>>>,
225}
226
227#[derive(Clone, Copy, Debug, Eq, PartialEq)]
228pub enum IntegratedExpectationMode {
229    ExactClosedForm,
230    ExactSpecialFunction,
231    ControlledAsymptotic,
232    QuadratureFallback,
233}
234
235impl IntegratedExpectationMode {
236    /// Ordinal rank where higher = lower-fidelity / further from exact closed
237    /// form. Lets callers fold over a stream of modes and keep the *worst*
238    /// one with `a.rank().max(b.rank())`.
239    #[inline]
240    pub const fn rank(self) -> u8 {
241        match self {
242            Self::ExactClosedForm => 0,
243            Self::ExactSpecialFunction => 1,
244            Self::ControlledAsymptotic => 2,
245            Self::QuadratureFallback => 3,
246        }
247    }
248}
249
250#[derive(Clone, Copy, Debug)]
251pub struct IntegratedMeanDerivative {
252    pub mean: f64,
253    pub dmean_dmu: f64,
254    pub mode: IntegratedExpectationMode,
255}
256
257#[derive(Clone, Copy, Debug)]
258pub struct IntegratedInverseLinkJet {
259    pub mean: f64,
260    pub d1: f64,
261    pub d2: f64,
262    pub d3: f64,
263    pub mode: IntegratedExpectationMode,
264}
265
266#[derive(Clone, Copy, Debug)]
267pub(crate) struct IntegratedInverseLinkJet5 {
268    pub mean: f64,
269    pub d1: f64,
270    pub d2: f64,
271    pub d3: f64,
272    pub d4: f64,
273    pub d5: f64,
274    pub mode: IntegratedExpectationMode,
275}
276
277#[inline]
278pub(crate) fn validate_latent_cloglog_inputs(eta: f64, sigma: f64) -> Result<(), EstimationError> {
279    if !eta.is_finite() || !sigma.is_finite() || sigma < 0.0 {
280        crate::bail_invalid_estim!(
281            "latent cloglog jet requires finite eta and sigma >= 0, got eta={eta}, sigma={sigma}"
282        );
283    }
284    Ok::<(), _>(())
285}
286
287/// Typed integrated moments/derivative jet used by solver integration paths.
288///
289/// `variance` is the observation-model variance at the integrated mean for the
290/// associated family (for binomial links: `mean * (1 - mean)`).
291#[derive(Clone, Copy, Debug)]
292pub struct IntegratedMomentsJet {
293    pub mean: f64,
294    pub variance: f64,
295    pub d1: f64,
296    pub d2: f64,
297    pub d3: f64,
298    pub mode: IntegratedExpectationMode,
299}
300
301const LOGIT_SIGMA_DEGENERATE: f64 = 1e-10;
302const LOGIT_ERFCX_SIGMA_MIN: f64 = 2.5e-1;
303const LOGIT_TAIL_LOG_MAX: f64 = -18.0;
304const LOGIT_ERFCX_MU_MAX: f64 = 40.0;
305const LOGIT_ERFCX_SIGMA_MAX: f64 = 6.0;
306/// Latent SD above which the logistic-normal *jet* stops trusting Gauss–Hermite
307/// quadrature. The jet integrands are the localized inverse-link derivatives
308/// `sigmoid^(k)` (bumps of characteristic width O(1) in η, hence width O(1/σ) in
309/// the standardized GH coordinate). Once σ grows past ~1, GH can no longer
310/// resolve the higher derivatives. Measured 31/51-node GH relative error vs a
311/// 16384-interval Simpson reference (μ≈σ) shows the knee precisely:
312///
313/// ```text
314///   σ     d1        d2        d3
315///   0.8   2.8e-16   5.4e-13   2.1e-12
316///   1.0   4.7e-12   1.4e-10   2.1e-9     ← still excellent
317///   1.2   4.8e-10   4.5e-9    1.9e-7
318///   1.5   4.8e-8    5.9e-8    1.8e-5
319///   2.5   6.9e-5    9.2e-4    3.0e-2
320///   5.0   1.7e-3    7.8e-2    2.1e+0     ← d3 209% wrong
321/// ```
322///
323/// Adaptive Simpson, by contrast, holds ~1e-12 on every component at every σ.
324/// So at σ ≤ 1 GH is both accurate (≤ ~2e-9 on all four components) and cheap
325/// (31 nodes); beyond σ = 1 the jet is integrated by adaptive Simpson instead,
326/// with `mean`/`d1` reused verbatim from the scalar controlled backend so the
327/// scalar dispatcher and the jet agree by construction (#571 — the GH jet used
328/// to drift ~4e-3 from the scalar value at (μ=3, σ=3)).
329const LOGIT_JET_GHQ_SIGMA_MAX: f64 = 1.0;
330const CLOGLOG_SIGMA_DEGENERATE: f64 = 1e-10;
331const CLOGLOG_SIGMA_TAYLOR_MAX: f64 = 0.25;
332/// Latent SD above which the cloglog integrated jet stops trusting shifted
333/// lognormal-Laplace moment reconstruction for higher derivatives. The moments
334/// `E[u^m exp(-u)]`, `u=exp(eta)`, evaluate the survival term at
335/// `mu + m*sigma^2`; for d3 at sigma=4 that asks for a shift of 48 and loses
336/// the small k3 contribution to cancellation. Directly integrating the stable
337/// pointwise derivatives keeps the location-family jet identity intact.
338const CLOGLOG_JET_MOMENT_SIGMA_MAX: f64 = 1.0;
339const CLOGLOG_RARE_EVENT_LOG_MAX: f64 = -18.0;
340const CLOGLOG_LARGE_SIGMA_ASYMPTOTIC_MIN: f64 = 8.0;
341const CLOGLOG_POSITIVE_SATURATION_EDGE: f64 = 5.0;
342const CLOGLOG_POSITIVE_SATURATION_SIGMAS: f64 = 8.0;
343// ── Log-space survival panel (#2714) ────────────────────────────────────────
344//
345// `ln S(μ,σ)`, `S(μ,σ) = E[exp(−e^η)]`, `η ~ N(μ,σ²)`, is evaluated on ONE
346// Laplace-localized Clenshaw–Curtis panel in the standardized variable
347// `z = (η−μ)/σ`, accumulated by log-sum-exp. The panel is placed from the
348// integrand's own geometry rather than from a fixed truncation, which is what
349// makes a single rule accurate over the whole `(μ,σ)` plane.
350//
351// The log-integrand of the survival branch,
352//
353// ```text
354//   L(z) = −z²/2 − ln√(2π) − e^{μ+σz},
355//   L''(z) = −(1 + σ² e^{μ+σz}) ≤ −1,
356// ```
357//
358// is STRICTLY concave, so it is unimodal, its maximizer is the unique root of a
359// monotone equation, and the two points where it falls a requested number of
360// e-folds below that maximum bracket every part of the integral that can matter
361// at f64 precision. The complement branch `1 − S = E[1−exp(−e^η)]` has the same
362// structure (`ln(1−e^{−u})` is concave in `z`), and is used where `S → 1` so
363// `ln S` stays RELATIVELY accurate instead of merely absolutely accurate.
364//
365// What this replaces, and why it had to be replaced rather than re-thresholded:
366// `ln S` used to be assembled from a value-space ladder (Taylor / extreme
367// asymptotic / Miles erfc-series / CC / Gamma / GHQ) and then logged, with a
368// fixed-window Gumbel-mixing quadrature on `η ∈ [−40, 6]` as an underflow
369// escape hatch. Graded against a 60-digit reference on
370// `μ ∈ [−20, 12] × σ ∈ [0.002, 60]`, the worst absolute error of that composite
371// in `ln S` was **4.4e+06**, and the three worst rows came off three DIFFERENT
372// routes — the escape hatch (4.4e+06 at `(12, 0.002)`, its `Φ((η−μ)/σ)`
373// transition unresolved by a pinned 513-node grid), the Miles/CC value route
374// (82.0 at `(12, 1.0)`), and the rare-event asymptotic (`ln1p(−e^{μ+σ²/2})`,
375// 20.8× wrong at `(−50, 8)`, where `rare_log = −18` is on the gate but the
376// higher cumulants are not small). No threshold between those routes can work,
377// because no two of them are accurate on either side of a common cut; and an
378// analytic derivative cannot be the derivative of a surface whose error is a
379// step function of `(μ,σ)`, which is the #2714 stall.
380//
381// The panel rule's worst absolute error in `ln S` on the same grid is
382// **9.3e-10** — at `ln S = −5.7e6`, i.e. `1.6e-16` relative, the f64
383// representation floor of the answer itself.
384//
385/// Number of e-folds below its own maximum at which the log-integrand is cut.
386///
387/// The neglected tails are bounded by (local scale) × `exp(−DROP)` relative to
388/// the peak; at 60 that is `8.8e-27`, ten orders below the `~1e-16` relative
389/// accuracy the rule targets, so the truncation is never the binding error.
390/// Enlarging it is cheap (the panel grows like `√DROP`), which is why the
391/// margin is taken here rather than defended.
392const LOG_SURVIVAL_PANEL_LOG_DROP: f64 = 60.0;
393/// Extra e-folds per μ-derivative order, in units of `ln(2 + |z⋆|)`.
394///
395/// Order `j` multiplies the integrand by `He_j(z)`, which grows like `|z|^j`, so
396/// the point where the product falls `DROP` e-folds below its peak moves
397/// outward by `j·ln|z|`. Padding the cut by exactly that keeps the truncation
398/// bound of the tower equal to the truncation bound of the value.
399const LOG_SURVIVAL_PANEL_ORDER_LOG_DROP: f64 = 2.0;
400/// Clenshaw–Curtis nodes per unit of local-scale arclength
401/// `T = ∫ √(1 + σ² e^{μ+σz}) dz` across the panel.
402///
403/// `T` counts how many local scales the panel spans — the resolution the rule
404/// needs on the real axis. Measured node counts for `1e-13` relative accuracy
405/// over `μ ∈ [−30, 20] × σ ∈ [5e-4, 200]` never exceed `4.6·T` once the σ term
406/// below is added; the observed minimum sufficient ratio at small σ is 2.2, so
407/// this carries a 2.1× margin.
408const LOG_SURVIVAL_PANEL_ARCLENGTH_NODE_DENSITY: f64 = 4.6;
409/// Clenshaw–Curtis nodes per `√σ`.
410///
411/// `T` alone does not price large σ: `exp(−e^{μ+σz})` is entire but its
412/// modulus grows off the real axis with period `2π/σ`, so the Bernstein
413/// ellipse on which the interpolant converges shrinks as σ grows and the node
414/// count rises even though the panel does not. Measured requirement at `1e-13`:
415/// `n = 65, 97, 193, 385, 769` at `σ = 0.5, 2, 8, 60, 200`, i.e. `n − 65` is
416/// `≈ 50·√σ`; 70 carries a 1.4× margin on the fitted slope.
417const LOG_SURVIVAL_PANEL_SIGMA_NODE_SCALE: f64 = 70.0;
418/// Extra nodes per μ-derivative order (the `He_j` factor raises the polynomial
419/// degree of the integrand by `j`, and the panel widens with `j`).
420const LOG_SURVIVAL_PANEL_ORDER_NODES: f64 = 8.0;
421/// Node floor. The panel spans ~20 local scales at small σ and needs 49–65
422/// nodes there; the floor is the measured maximum of that regime.
423const LOG_SURVIVAL_PANEL_MIN_NODES: usize = 65;
424/// Node ceiling. `4.6·T + 70·√σ` reaches this at `σ ≈ 3.3e3`; beyond that the
425/// rule is capped and reports its own conditioning, which is what the
426/// derivative-tower gate reads.
427const LOG_SURVIVAL_PANEL_MAX_NODES: usize = 4097;
428/// Highest μ-derivative order the panel tower will produce.
429///
430/// `log_kernel_bundle` asks for `k + 4` rungs at most, so 8 covers every
431/// shipped consumer with room; the bound exists so the tower is a fixed-size
432/// array rather than a heap allocation on a per-row path.
433pub const LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER: usize = 8;
434/// Largest tolerated cancellation, as `ln(Σ|terms| / |Σ terms|)`, before the
435/// direct Hermite μ-derivative tower is refused.
436///
437/// The tower is a signed log-sum-exp, so its relative error is
438/// `≈ ε · Σ|terms|/|Σ terms|`; the quadrature therefore MEASURES its own
439/// conditioning and does not have to be gated on a proxy for it.
440///
441/// The value is the solve of `ε · e^cond = 1e-13`, i.e. `ln(1e-13/ε) = 6.1`:
442/// admit the tower exactly while it is at the working floor of everything
443/// around it, so switching bases can never LOSE accuracy relative to the rung
444/// basis it displaces. Verified on `μ ∈ [−20, 12] × σ ∈ [0.002, 60] ×
445/// `j ∈ [1, 4]` against a 60-digit reference: the achieved error tracks
446/// `ε·e^cond` as the model says (`≤ 1.1e-7` below 20, `≤ 1.8e-5` below 25), and
447/// every admitted row lands at `2.9e-11` or better — which is itself the f64
448/// ulp of the largest `ln|·|` on the grid, not a quadrature error.
449///
450/// This replaces a `σ ≥ 8` gate, which asked a chosen constant to stand in for
451/// exactly this quantity. The replacement is a strict superset of it: all 132
452/// grid rows with `σ ≥ 8` are admitted here, plus the moderate-σ / positive-μ
453/// region where the tower is equally well conditioned and the rung basis was
454/// being used only because `σ < 8`.
455const LOG_SURVIVAL_TOWER_MAX_LOG_CANCELLATION: f64 = 6.1;
456const SERIES_CONSECUTIVE_SMALL_TERMS: usize = 6;
457const LOGIT_MAX_TERMS: usize = 160;
458/// Documented absolute-accuracy contract of the erfcx logistic-normal
459/// backend. The series truncation bound (see `logistic_normal_series_cutoff`)
460/// is guaranteed to be below this tolerance on the mean and its μ-derivative
461/// whenever the backend
462/// returns a value. Beyond the eligibility window or when the a-priori
463/// truncation index would exceed LOGIT_MAX_TERMS, the backend rejects and
464/// the caller routes to GHQ.
465///
466/// Set to 1e-11 so that the erfcx branch only commits to a value when it
467/// can honor the sharp tolerances used by downstream consumers. Oracle and
468/// jet-match tests pin to `max_relative = 1e-10`; at 1e-11 the series rejects
469/// in the central
470/// band near (μ=1.1, σ=0.8) where the tail bound reaches ~2.6e-5 at
471/// N=160, correctly deferring to GHQ which is accurate to ~1e-13 in that
472/// regime after the QR eigenvector fix.
473const LOGIT_ERFCX_ACCURACY_TARGET: f64 = 1.0e-11;
474const CLOGLOG_MILES_ALPHA: f64 = 60.0;
475const CLOGLOG_MILES_MAX_TERMS: usize = 256;
476// Upper bound on the (log of the) peak Miles-series term magnitude under which
477// the alternating cancellation still leaves a usable result in f64.
478//
479// The Miles series for S(mu, sigma) has term magnitudes whose log peaks at
480// `peak_log(mu, sigma) ≈ α − (mu − ln α)² / (2 σ²)` near n = α. The final S is
481// O(1), so a peak of `exp(peak_log)` is summed with alternating signs and must
482// cancel down to ~1. f64 has ~53 bits, so after losing roughly peak_log/ln(2)
483// bits to cancellation, the residual carries `53 − peak_log/ln(2)` bits of
484// precision. Setting the cap at 0 means the peak term magnitude is bounded by
485// 1, so the alternating sum never reaches into regions where bits get spent on
486// cancellation at all. Outside this gate the caller drops down to CC / Gamma /
487// GHQ, which evaluate the same survival object on numerically stable grids and
488// do not depend on telescoping huge cancellations. The exit from "Miles
489// reliable" to "fall back" therefore happens at peak terms of size 1, so the
490// two backends already agree on the boundary at full f64 precision and the
491// integrated cloglog mean remains monotone in `mu` as the routing switches.
492const CLOGLOG_MILES_PEAK_LOG_MAX: f64 = 0.0;
493const CLOGLOG_GAMMA_K_REF: f64 = 0.5;
494const CLOGLOG_GAMMA_T_MAX_REF: f64 = 24.0;
495const CLOGLOG_GAMMA_H_REF: f64 = 0.01;
496// Default accuracy target for the real-line Clenshaw-Curtis cloglog backend.
497// This is intentionally looser than full machine epsilon so the node-count
498// heuristic stays practical in the central moderate/large-sigma regime.
499const CLOGLOG_CC_TOL: f64 = 1e-12;
500// If the Bernstein-ellipse-based node request exceeds this cap, the backend
501// yields to the exact Gamma reference rather than turning one hard case into a
502// slow quadrature sweep.
503const CLOGLOG_CC_NODE_CAP: usize = 1025;
504// Gamma uses a fixed composite Simpson rule on [0, T] with this many samples.
505// CC only wins if its requested node count stays comfortably below that fixed
506// complex-arithmetic workload.
507const CLOGLOG_GAMMA_SAMPLE_COUNT: usize =
508    (CLOGLOG_GAMMA_T_MAX_REF / CLOGLOG_GAMMA_H_REF) as usize + 1;
509// CC nodes are pure f64 work while Gamma nodes pay for complex log-gamma and
510// complex exponentials, so CC can still be favorable with somewhat more nodes
511// than this threshold. Keep the threshold conservative until benchmarks say
512// otherwise.
513const CLOGLOG_CC_PREFER_THRESHOLD: usize = CLOGLOG_GAMMA_SAMPLE_COUNT / 3;
514// Keep a modest floor so the mapped cosine rule is never asked to represent the
515// integrand with an undersized stencil even when the heuristic requests very
516// few nodes.
517const CLOGLOG_CC_MIN_N: usize = 17;
518
519impl QuadratureContext {
520    pub fn new() -> Self {
521        Self {
522            gh_cache: OnceLock::new(),
523            gh15_cache: OnceLock::new(),
524            gh21_cache: OnceLock::new(),
525            gh31_cache: OnceLock::new(),
526            gh51_cache: OnceLock::new(),
527            cc_cache: Mutex::new(HashMap::new()),
528        }
529    }
530
531    fn gauss_hermite(&self) -> &GaussHermiteRule {
532        self.gh_cache.get_or_init(compute_gauss_hermite)
533    }
534
535    fn gauss_hermite_n(&self, n: usize) -> &GaussHermiteRule {
536        match n {
537            // The fixed 7-point cache is served via `gauss_hermite()`. If a caller
538            // ends up here with n=7 anyway, fall back to the 15-point rule.
539            7 => self.gh15_cache.get_or_init(|| compute_gauss_hermite_n(15)),
540            15 => self.gh15_cache.get_or_init(|| compute_gauss_hermite_n(15)),
541            21 => self.gh21_cache.get_or_init(|| compute_gauss_hermite_n(21)),
542            31 => self.gh31_cache.get_or_init(|| compute_gauss_hermite_n(31)),
543            51 => self.gh51_cache.get_or_init(|| compute_gauss_hermite_n(51)),
544            _ => self.gh21_cache.get_or_init(|| compute_gauss_hermite_n(21)),
545        }
546    }
547
548    fn clenshaw_curtis_n(&self, n: usize) -> Arc<ClenshawCurtisRule> {
549        let mut cache = match self.cc_cache.lock() {
550            Ok(guard) => guard,
551            Err(poisoned) => poisoned.into_inner(),
552        };
553        cache
554            .entry(n)
555            .or_insert_with(|| Arc::new(compute_clenshaw_curtis_n(n)))
556            .clone()
557    }
558}
559
560impl Default for QuadratureContext {
561    fn default() -> Self {
562        Self::new()
563    }
564}
565
566#[derive(Clone)]
567struct ClenshawCurtisRule {
568    nodes: Vec<f64>,
569    weights: Vec<f64>,
570}
571
572fn compute_clenshaw_curtis_n(n: usize) -> ClenshawCurtisRule {
573    assert!(
574        n >= 2,
575        "Clenshaw-Curtis rule requires at least two nodes: n={n}"
576    );
577    // Classic cosine-grid Clenshaw-Curtis rule on [-1, 1].
578    //
579    // The nodes are
580    //   x_j = cos(j pi / (n - 1)),   j = 0, ..., n - 1,
581    // i.e. the Chebyshev extrema. In the usual derivation one writes x = cos θ,
582    // expands the transformed integrand in a cosine/Chebyshev series, and then
583    // integrates the interpolating polynomial exactly. That is why this rule is
584    // naturally expressed on a cosine grid and why it is a good fit for the
585    // truncated cloglog/survival real-line integral after the affine map t = A x.
586    //
587    // This implementation uses the explicit cosine-sum weight formula rather
588    // than a fast DCT construction. That is perfectly adequate here because the
589    // production node counts are modest and the rules are cached in
590    // QuadratureContext once built.
591    let m = n - 1;
592    let theta: Vec<f64> = (0..=m)
593        .map(|j| std::f64::consts::PI * (j as f64) / (m as f64))
594        .collect();
595    let nodes: Vec<f64> = theta.iter().map(|&th| th.cos()).collect();
596
597    if n == 2 {
598        return ClenshawCurtisRule {
599            nodes,
600            weights: vec![1.0, 1.0],
601        };
602    }
603
604    let mut weights = vec![0.0_f64; n];
605    let mut v = vec![1.0_f64; m - 1];
606
607    if m.is_multiple_of(2) {
608        let w0 = 1.0 / ((m * m - 1) as f64);
609        weights[0] = w0;
610        weights[m] = w0;
611        for k in 1..(m / 2) {
612            let denom = (4 * k * k - 1) as f64;
613            for j in 1..m {
614                v[j - 1] -= 2.0 * (2.0 * (k as f64) * theta[j]).cos() / denom;
615            }
616        }
617        for j in 1..m {
618            v[j - 1] -= ((m as f64) * theta[j]).cos() / ((m * m - 1) as f64);
619        }
620    } else {
621        let w0 = 1.0 / ((m * m) as f64);
622        weights[0] = w0;
623        weights[m] = w0;
624        for k in 1..=((m - 1) / 2) {
625            let denom = (4 * k * k - 1) as f64;
626            for j in 1..m {
627                v[j - 1] -= 2.0 * (2.0 * (k as f64) * theta[j]).cos() / denom;
628            }
629        }
630    }
631
632    for j in 1..m {
633        weights[j] = 2.0 * v[j - 1] / (m as f64);
634    }
635
636    // Clenshaw-Curtis on [-1, 1] is symmetric and integrates constants exactly.
637    // Enforce those invariants explicitly after the cosine-sum construction so
638    // tiny roundoff in the weight build does not leak into the cached rules.
639    for j in 0..=(m / 2) {
640        let jj = m - j;
641        let avg = 0.5 * (weights[j] + weights[jj]);
642        weights[j] = avg;
643        weights[jj] = avg;
644    }
645    let weight_sum: f64 = weights.iter().sum();
646    if weight_sum.is_finite() && weight_sum != 0.0 {
647        let scale = 2.0 / weight_sum;
648        for w in &mut weights {
649            *w *= scale;
650        }
651    }
652
653    ClenshawCurtisRule { nodes, weights }
654}
655
656fn cloglog_cc_required_nodes(mu: f64, sigma: f64, tol: f64) -> Result<usize, EstimationError> {
657    if !(mu.is_finite() && sigma.is_finite() && sigma > 0.0 && tol.is_finite() && tol > 0.0) {
658        crate::bail_invalid_estim!(
659            "CC cloglog backend requires finite mu, positive sigma, and positive tolerance"
660                .to_string(),
661        );
662    }
663
664    // This mirrors the node-count logic used by the actual CC evaluator, but
665    // exposes it as a cheap routing estimate so we can decide whether the
666    // bounded real-line cosine grid is likely to beat the fixed-work complex
667    // Gamma backend before paying to evaluate either one.
668    let p_tail = (tol / 8.0).clamp(1e-300, 0.25);
669    let a = gam_math::probability::standard_normal_quantile(p_tail)
670        .map(|z| -z)
671        .unwrap_or(8.0)
672        .max(1.0);
673
674    let ay = a * sigma;
675    let y = if ay > 0.0 {
676        1.0_f64.min(std::f64::consts::PI / (4.0 * ay))
677    } else {
678        1.0
679    };
680    let rho = y + (1.0 + y * y).sqrt();
681    let m_s = (0.5 * (a * y) * (a * y)).exp() / (2.0 * std::f64::consts::PI).sqrt();
682    let eps_quad = (tol / 4.0).max(1e-300);
683    let numer = ((8.0 * a * m_s) / ((rho - 1.0).max(1e-12) * eps_quad)).max(1.0);
684    let denom = rho.ln();
685    if !denom.is_finite() || denom <= 0.0 {
686        crate::bail_invalid_estim!("CC cloglog backend ellipse bound became degenerate");
687    }
688
689    let mut n = (1.0 + numer.ln() / denom).ceil() as usize;
690    n = n.max(CLOGLOG_CC_MIN_N);
691    if n.is_multiple_of(2) {
692        n += 1;
693    }
694    Ok(n)
695}
696
697#[inline]
698fn cloglog_should_prefer_cc(mu: f64, sigma: f64, tol: f64) -> bool {
699    // Prefer CC only when its Bernstein-ellipse node estimate stays comfortably
700    // below the fixed Simpson workload of the Gamma reference backend. That
701    // makes CC an automatic fast path for moderate central cases, while very
702    // broad or numerically awkward cases continue to use the exact
703    // Mellin-Barnes/Gamma representation.
704    match cloglog_cc_required_nodes(mu, sigma, tol) {
705        Ok(n) => n <= CLOGLOG_CC_PREFER_THRESHOLD,
706        Err(_) => false,
707    }
708}
709
710/// Fixed production rule built by the shared `O(n²)`-time, `O(n)`-space
711/// Golub-Welsch implementation.
712fn compute_gauss_hermite() -> GaussHermiteRule {
713    compute_gauss_hermite_n(N_POINTS)
714}
715
716pub(crate) fn compute_gauss_hermite_n(n: usize) -> GaussHermiteRule {
717    // SAFETY: every production caller selects n from the positive, hard-coded
718    // GHQ family {7,15,21,31,51}. A failure here means the shared deterministic
719    // eigensolver violated that construction invariant; no alternate numerical
720    // rule has been certified for the caller to recover with.
721    gauss_hermite_rule(n)
722        .unwrap_or_else(|error| panic!("shared Gauss-Hermite construction failed: {error}"))
723}
724
725/// Computes the posterior mean probability for a logistic model under
726/// Gaussian uncertainty in the linear predictor.
727///
728/// Given:
729/// - `eta`: point estimate of linear predictor (log-odds)
730/// - `se_eta`: standard error of eta (from Hessian)
731///
732/// Returns: E[sigmoid(η)] where η ~ N(eta, se_eta²)
733///
734/// When `se_eta` is zero or very small, this reduces to `sigmoid(eta)`.
735#[inline]
736pub fn logit_posterior_mean(ctx: &QuadratureContext, eta: f64, se_eta: f64) -> f64 {
737    match logit_posterior_meanwith_deriv_controlled(eta, se_eta) {
738        Ok(out) => out.mean,
739        Err(_) => integrate_normal_ghq_adaptive(ctx, eta, se_eta, sigmoid),
740    }
741}
742
743/// Computes the integrated probability AND its derivative with respect to eta.
744///
745/// For IRLS, we need both:
746/// - μ = ∫ σ(η) × N(η; m, SE²) dη
747/// - dμ/dm = ∫ σ'(η) × N(η; m, SE²) dη = ∫ σ(η)(1-σ(η)) × N(η; m, SE²) dη
748///
749/// Returns: (μ, dμ/dm)
750#[inline]
751pub fn logit_posterior_meanwith_deriv(
752    eta: f64,
753    se_eta: f64,
754) -> Result<(f64, f64), EstimationError> {
755    // Production routing for the integrated logistic-normal mean and its
756    // location derivative.
757    //
758    // The backend ladder is:
759    // - exact point-mass limit when sigma ~= 0
760    // - adaptive quadrature at small sigma, where the erfcx series is
761    //   cancellation-prone
762    // - exact erfcx/Faddeeva series on the moderate domain
763    // - a certified extreme-tail asymptotic
764    // - adaptive quadrature wherever no analytic representation carries the
765    //   required accuracy certificate.
766    let out = logit_posterior_meanwith_deriv_controlled(eta, se_eta)?;
767    Ok((out.mean, out.dmean_dmu))
768}
769
770#[inline]
771pub fn probit_posterior_meanwith_deriv_exact(mu: f64, sigma: f64) -> IntegratedMeanDerivative {
772    // Exact Gaussian-probit convolution.
773    //
774    // If eta ~ N(mu, sigma^2), then
775    //
776    //   E[Phi(eta)] = Phi(mu / sqrt(1 + sigma^2)).
777    //
778    // A clean derivation is to introduce an independent Z ~ N(0, 1):
779    //
780    //   E[Phi(eta)]
781    //     = E[P(Z <= eta | eta)]
782    //     = P(Z - eta <= 0).
783    //
784    // Because Z - eta is Gaussian with mean -mu and variance 1 + sigma^2, the
785    // probability is exactly the standard normal CDF evaluated at
786    //   mu / sqrt(1 + sigma^2).
787    //
788    // Differentiating with respect to the location parameter mu gives
789    //
790    //   d/dmu E[Phi(eta)]
791    //     = phi(mu / sqrt(1 + sigma^2)) / sqrt(1 + sigma^2),
792    //
793    // which is also the integrated slope E[phi(eta)] by the general identity
794    //
795    //   d/dmu E[f(mu + sigma Z)] = E[f'(mu + sigma Z)].
796    //
797    // So this path is genuinely exact: no node count, no truncation, and no
798    // approximation regime split.
799    if !(mu.is_finite() && sigma.is_finite()) || sigma <= 1e-12 {
800        let mean = gam_math::probability::normal_cdf(mu);
801        let dmean_dmu = gam_math::probability::normal_pdf(mu);
802        return IntegratedMeanDerivative {
803            mean,
804            dmean_dmu,
805            mode: IntegratedExpectationMode::ExactClosedForm,
806        };
807    }
808    let denom = (1.0 + sigma * sigma).sqrt();
809    let z = mu / denom;
810    IntegratedMeanDerivative {
811        mean: gam_math::probability::normal_cdf(z),
812        dmean_dmu: gam_math::probability::normal_pdf(z) / denom,
813        mode: IntegratedExpectationMode::ExactClosedForm,
814    }
815}
816
817#[inline]
818fn logistic_normal_exact_eligible(mu: f64, sigma: f64) -> bool {
819    mu.is_finite()
820        && sigma.is_finite()
821        && mu.abs() <= LOGIT_ERFCX_MU_MAX
822        && (LOGIT_ERFCX_SIGMA_MIN..=LOGIT_ERFCX_SIGMA_MAX).contains(&sigma)
823}
824
825/// A-priori truncation index for the erfcx series of the logistic-normal mean
826/// **and its μ-derivative**, or `None` when no index ≤ `LOGIT_MAX_TERMS` can
827/// certify both to `target_accuracy`.
828///
829/// The representation is
830///
831/// ```text
832/// E[sigmoid(η)] = Φ(m/s)
833///     + (1/2) · exp(-m²/(2s²))
834///       · Σ_{k≥1} (-1)^(k-1) · [erfcx((k s² + m)/(√2 s))
835///                             − erfcx((k s² − m)/(√2 s))]
836/// ```
837///
838/// with m = |μ|, s = σ > 0 (the reflection μ→−μ is applied at the callsite).
839/// The two erfcx arguments scale as k·s/√2 with a fixed offset, so both tend
840/// to +∞ linearly in k. Using the asymptotic erfcx(x) = (1/(x√π))·[1 + O(1/x²)]
841/// for large x, the k-th (signed) term and its μ-derivative have magnitudes
842///
843/// ```text
844/// |T_k|  = m · √(2/π) · exp(-m²/(2s²)) / (k² · s³)        + O(1/k⁴)
845/// |T_k'| = 2 · exp(-m²/(2s²)) · |m²−s²| / (√(2π) · s⁵ · k²) + O(1/k⁴)
846/// ```
847///
848/// Because the series alternates in sign, the truncation tail after N terms is
849/// bounded by the first omitted term — **but only once the terms are past their
850/// magnitude peak**, which sits near k ≈ m/s² (where the erfcx argument
851/// `(k s² − m)/(√2 s)` crosses zero). Below the peak the term magnitudes can
852/// *grow* with k, so the alternating-series remainder bound is invalid there;
853/// truncating before the peak would silently undersell the tail. We therefore
854/// require N to exceed the peak in addition to satisfying both tail bounds:
855///
856/// ```text
857/// |R_N(mean)|  ≤ coeff_mean  / (N+1)²   with coeff_mean  = m·√(2/π)·e^{-m²/2s²}/s³
858/// |R_N(deriv)| ≤ coeff_deriv / (N+1)²   with coeff_deriv = 2·|m²−s²|·e^{-m²/2s²}/(√(2π)·s⁵)
859/// N ≥ ⌈m/s²⌉ + 1                         (past the magnitude peak)
860/// ```
861///
862/// Solving each tail bound for the smallest admissible N and taking the maximum
863/// (also with the peak floor) yields the returned index. Reaching it bounds the
864/// leading-order truncation error of *both* outputs; the adaptive-Simpson
865/// drift-check in `logit_posterior_meanwith_deriv_controlled` remains the hard
866/// backstop for the residual higher-order terms (notably near m ≈ s, where the
867/// `|m²−s²|` derivative coefficient vanishes and the next order dominates).
868#[inline]
869fn logistic_normal_series_cutoff(mu: f64, sigma: f64, target_accuracy: f64) -> Option<usize> {
870    assert!(sigma > 0.0);
871    assert!(target_accuracy > 0.0);
872    let m = mu.abs();
873    let s = sigma;
874    let gauss = (-(m * m) / (2.0 * s * s)).exp();
875    let coeff_mean = m * (2.0_f64 / std::f64::consts::PI).sqrt() * gauss / (s * s * s);
876    let coeff_deriv =
877        2.0 * gauss * (m * m - s * s).abs() / ((2.0 * std::f64::consts::PI).sqrt() * s.powi(5));
878    // Index past which the first-omitted-term bound for a given leading
879    // coefficient drops to `target_accuracy`. A non-finite or already-tiny
880    // coefficient imposes no constraint (returns 0).
881    let asymptotic_index = |coeff: f64| -> f64 {
882        if !coeff.is_finite() || coeff <= target_accuracy {
883            0.0
884        } else {
885            (coeff / target_accuracy).sqrt() - 1.0
886        }
887    };
888    // The alternating-tail bound is only valid past the magnitude peak at
889    // k ≈ m/s²; enforce N strictly beyond it so the remainder ≤ first-omitted
890    // term argument holds for both the mean and the derivative series.
891    let peak_floor = m / (s * s) + 1.0;
892    let required = asymptotic_index(coeff_mean)
893        .max(asymptotic_index(coeff_deriv))
894        .max(peak_floor);
895    if !required.is_finite() || required > LOGIT_MAX_TERMS as f64 {
896        return None;
897    }
898    // Evaluate at least a few pairs to pick up short-range structure the
899    // asymptotic bound undersells; this only ever runs extra certified terms.
900    Some((required.ceil() as usize).max(4))
901}
902
903#[inline]
904fn stable_sigmoidwith_derivative(x: f64) -> (f64, f64) {
905    let x_clamped = x.clamp(-QUADRATURE_EXP_LOG_MAX, QUADRATURE_EXP_LOG_MAX);
906    if x_clamped != x {
907        return (sigmoid(x), 0.0);
908    }
909    if x_clamped >= 0.0 {
910        let z = (-x_clamped).exp();
911        let denom = 1.0 + z;
912        (1.0 / denom, z / (denom * denom))
913    } else {
914        let z = x_clamped.exp();
915        let denom = 1.0 + z;
916        (z / denom, z / (denom * denom))
917    }
918}
919
920#[inline]
921fn logit_tail_asymptotic(mu: f64, sigma: f64) -> Option<IntegratedMeanDerivative> {
922    // When mu is far out in either logistic tail, sigmoid(eta) is
923    // exponentially close to either exp(eta) or 1 - exp(-eta). Those Gaussian
924    // expectations collapse to lognormal moments, so we can route extreme-|mu|
925    // cases away from both erfcx and GHQ.
926    if mu <= 0.0 {
927        let log_mean = mu + 0.5 * sigma * sigma;
928        if log_mean <= LOGIT_TAIL_LOG_MAX {
929            let mean = safe_exp(log_mean);
930            return Some(IntegratedMeanDerivative {
931                mean,
932                dmean_dmu: mean,
933                mode: IntegratedExpectationMode::ControlledAsymptotic,
934            });
935        }
936    } else {
937        let log_tail = -mu + 0.5 * sigma * sigma;
938        if log_tail <= LOGIT_TAIL_LOG_MAX {
939            let tail = safe_exp(log_tail);
940            return Some(IntegratedMeanDerivative {
941                mean: 1.0 - tail,
942                dmean_dmu: tail,
943                mode: IntegratedExpectationMode::ControlledAsymptotic,
944            });
945        }
946    }
947    None
948}
949
950#[inline]
951fn scaled_erfcx_termwith_derivative(m: f64, s: f64, x: f64, dxdm: f64) -> (f64, f64) {
952    let pref = 0.5 * (-(m * m) / (2.0 * s * s)).exp();
953    if x >= 0.0 {
954        let ex = erfcx_nonnegative(x);
955        let term = pref * ex;
956        let ex_prime = 2.0 * x * ex - std::f64::consts::FRAC_2_SQRT_PI;
957        let dterm = pref * ((-m / (s * s)) * ex + ex_prime * dxdm);
958        (term, dterm)
959    } else {
960        let lead = (x * x - (m * m) / (2.0 * s * s)).exp();
961        let dlead = lead * (2.0 * x * dxdm - m / (s * s));
962        let (rest, drest) = scaled_erfcx_termwith_derivative(m, s, -x, -dxdm);
963        (lead - rest, dlead - drest)
964    }
965}
966
967pub(crate) fn logit_posterior_meanwith_deriv_exact(
968    mu: f64,
969    sigma: f64,
970) -> Result<IntegratedMeanDerivative, EstimationError> {
971    // Analytic entry point for the logistic-normal mean.
972    //
973    // The target objects are
974    //
975    //   mean(mu, sigma)   = E[sigmoid(eta)],
976    //   dmean/dmu         = E[sigmoid(eta) * (1 - sigmoid(eta))],
977    //   eta ~ N(mu, sigma^2).
978    //
979    // No single representation is numerically dominant everywhere:
980    // - sigma ~= 0 is the exact point-mass limit,
981    // - small sigma uses adaptive quadrature because the erfcx series is
982    //   cancellation-prone and a finite heat-kernel truncation is not exact,
983    // - moderate central cases prefer the exact erfcx/Faddeeva series,
984    // - and extreme tails / very large sigma prefer controlled asymptotics.
985    //
986    // Validation target for this ladder: compare against high-order GHQ
987    // (e.g. 128 nodes) on sigma in {0.01, 0.1, 1, 5, 20, 100} and mu on
988    // [-10, 10] to confirm the regime transitions.
989    if !(mu.is_finite() && sigma.is_finite()) {
990        crate::bail_invalid_estim!("logit exact expectation requires finite mu and sigma");
991    }
992    if sigma <= LOGIT_SIGMA_DEGENERATE {
993        let (mean, dmean_dmu) = stable_sigmoidwith_derivative(mu);
994        return Ok(IntegratedMeanDerivative {
995            mean,
996            dmean_dmu,
997            mode: IntegratedExpectationMode::ExactClosedForm,
998        });
999    }
1000    if let Some(out) = logit_tail_asymptotic(mu, sigma) {
1001        return Ok(out);
1002    }
1003    if logistic_normal_exact_eligible(mu, sigma)
1004        && let Ok(out) = logit_posterior_meanwith_deriv_exact_erfcx(mu, sigma)
1005    {
1006        return Ok(out);
1007    }
1008    // No analytic representation carries an accuracy certificate here: the
1009    // erfcx series was ineligible or could not certify its truncation within
1010    // LOGIT_MAX_TERMS. We deliberately return Err rather than fall back to the
1011    // Monahan-Stefanski probit approximation (Φ(μκ)), which carries ~1e-1
1012    // absolute error at moderate σ and, being returned as `Ok`, would bypass
1013    // the controlled router's drift-check and corrupt the posterior mean
1014    // (#571). The router maps this Err to the accurate adaptive-Simpson
1015    // fallback instead.
1016    Err(EstimationError::InvalidInput(
1017        "logit analytic expectation has no certified representation in this regime".to_string(),
1018    ))
1019}
1020
1021fn logit_posterior_meanwith_deriv_exact_erfcx(
1022    mu: f64,
1023    sigma: f64,
1024) -> Result<IntegratedMeanDerivative, EstimationError> {
1025    // Real-valued erfcx-series implementation for the logistic-normal mean.
1026    //
1027    //   sigmoid(x) = 1/2 + (1/2)·tanh(x/2),
1028    //
1029    // the partial-fraction expansion of tanh over its odd imaginary poles
1030    // ±i·(2n−1)π turns E[sigmoid(η)] into a convergent alternating series of
1031    // scaled-erfcx terms (see `logit_posterior_mean_exact` below for the full
1032    // derivation):
1033    //
1034    //   E[sigmoid(η)] = Φ(m/s)
1035    //     + (1/2)·exp(−m²/(2s²)) · Σ_{k≥1} (−1)^(k−1)
1036    //       · [erfcx((k s² + m)/(√2 s)) − erfcx((k s² − m)/(√2 s))],
1037    //
1038    // with m = |μ|, s = σ, and the sign of μ recovered by mean ↦ 1 − mean
1039    // below. Differentiating term-by-term in μ gives the derivative sum
1040    // produced by `scaled_erfcx_termwith_derivative`.
1041    //
1042    // The truncation index N* is chosen so that the alternating-series tail
1043    // bound for BOTH the mean and its μ-derivative, evaluated past the series
1044    // magnitude peak (see `logistic_normal_series_cutoff`), is below the
1045    // documented `LOGIT_ERFCX_ACCURACY_TARGET`. Reaching N* is thus an a-priori
1046    // estimate of accuracy for both outputs; the adaptive-Simpson drift-check
1047    // in the controlled router is the hard backstop. The only way this routine
1048    // rejects is when N* would exceed LOGIT_MAX_TERMS, at which point the
1049    // accuracy contract cannot be honored and the caller routes elsewhere.
1050    let m = mu.abs();
1051    let s = sigma;
1052    let z = SQRT_2 * s;
1053    let phi_term = gam_math::probability::normal_cdf(m / s);
1054    let phi_prime = gam_math::probability::normal_pdf(m / s) / s;
1055    let Some(max_k) = logistic_normal_series_cutoff(mu, sigma, LOGIT_ERFCX_ACCURACY_TARGET) else {
1056        crate::bail_invalid_estim!(
1057            "logit erfcx series truncation bound exceeds LOGIT_MAX_TERMS at the required accuracy"
1058                .to_string(),
1059        );
1060    };
1061
1062    let mut sum = 0.0_f64;
1063    let mut dsum = 0.0_f64;
1064    // Run to the a-priori truncation index. No empirical early exit: the pair
1065    // magnitude inside the loop decays as O(1/k³) (the leading 1/k² cancels
1066    // between consecutive-sign terms) while the truncation tail after index k
1067    // only decays as O(1/k²), so pair-magnitude is anti-conservative as an
1068    // exit criterion — stopping early when `|pair| < δ` would leave a tail
1069    // much larger than δ. `max_k` was chosen so that the tail bound itself is
1070    // below the accuracy target, and that is the stopping rule we honor here.
1071    let mut k = 1usize;
1072    while k <= max_k {
1073        for kk in [k, k + 1].into_iter().filter(|kk| *kk <= max_k) {
1074            let kf = kk as f64;
1075            let a = (kf * s * s + m) / z;
1076            let b = (kf * s * s - m) / z;
1077            let sign = if kk % 2 == 1 { 1.0 } else { -1.0 };
1078            let (va, dva) = scaled_erfcx_termwith_derivative(m, s, a, 1.0 / z);
1079            let (vb, dvb) = scaled_erfcx_termwith_derivative(m, s, b, -1.0 / z);
1080            sum += sign * (va - vb);
1081            dsum += sign * (dva - dvb);
1082        }
1083        k += 2;
1084    }
1085
1086    let mut mean = phi_term + sum;
1087    let dmean = (phi_prime + dsum).max(0.0);
1088    if mu < 0.0 {
1089        mean = 1.0 - mean;
1090    }
1091    if !(mean.is_finite() && dmean.is_finite() && dmean >= 0.0) {
1092        crate::bail_invalid_estim!("logit erfcx expectation produced non-finite values");
1093    }
1094    Ok(IntegratedMeanDerivative {
1095        mean,
1096        dmean_dmu: dmean,
1097        mode: IntegratedExpectationMode::ExactSpecialFunction,
1098    })
1099}
1100
1101/// Accurate logistic-normal mean and location-derivative via adaptive Simpson.
1102/// `sigmoid` and `sigmoid' = sigmoid·(1−sigmoid)` are smooth and bounded, so
1103/// `integrate_normal_adaptive` resolves both to ~1e-12 at every sigma — the
1104/// trusted reference / fallback when the closed-form ladder is out of regime.
1105#[inline]
1106fn logit_posterior_meanwith_deriv_quadrature(mu: f64, sigma: f64) -> IntegratedMeanDerivative {
1107    let mean = integrate_normal_adaptive(mu, sigma, |x| stable_sigmoidwith_derivative(x).0);
1108    let dmean_dmu =
1109        integrate_normal_adaptive(mu, sigma, |x| stable_sigmoidwith_derivative(x).1).max(0.0);
1110    IntegratedMeanDerivative {
1111        mean,
1112        dmean_dmu,
1113        mode: IntegratedExpectationMode::QuadratureFallback,
1114    }
1115}
1116
1117#[inline]
1118fn logit_posterior_meanwith_deriv_controlled(
1119    mu: f64,
1120    sigma: f64,
1121) -> Result<IntegratedMeanDerivative, EstimationError> {
1122    if !(mu.is_finite() && sigma.is_finite()) {
1123        crate::bail_invalid_estim!("logit integrated moments require finite mu and sigma");
1124    }
1125    let candidate = match logit_posterior_meanwith_deriv_exact(mu, sigma) {
1126        Ok(out) => out,
1127        Err(_) => return Ok(logit_posterior_meanwith_deriv_quadrature(mu, sigma)),
1128    };
1129    // Defense-in-depth drift-check. The erfcx series now sizes its truncation
1130    // from the per-output tail bounds past the magnitude peak (mean AND
1131    // derivative — see `logistic_normal_series_cutoff`), so the
1132    // `ExactSpecialFunction` candidate is accurate by construction; the
1133    // adaptive-Simpson reference confirms it and absorbs the residual
1134    // higher-order terms (e.g. near m ≈ s where the derivative coefficient
1135    // vanishes). `ControlledAsymptotic` covers only the extreme-|μ|
1136    // lognormal-collapse approximation, which is likewise confirmed against
1137    // the reference. Small-σ, exact point-mass, and erfcx-ineligible regimes
1138    // route to their mathematically appropriate implementations rather than
1139    // returning a tolerance-accepted finite Taylor truncation (#571, #2623).
1140    match candidate.mode {
1141        IntegratedExpectationMode::ExactSpecialFunction
1142        | IntegratedExpectationMode::ControlledAsymptotic => {
1143            let reference = logit_posterior_meanwith_deriv_quadrature(mu, sigma);
1144            if integrated_mean_derivative_drift_exceeds(
1145                &candidate, &reference, 1e-6, 1e-4, 1e-7, 1e-3,
1146            ) {
1147                Ok(reference)
1148            } else {
1149                Ok(candidate)
1150            }
1151        }
1152        _ => Ok(candidate),
1153    }
1154}
1155
1156#[inline]
1157fn cloglog_extreme_asymptotic(mu: f64, sigma: f64) -> Option<IntegratedMeanDerivative> {
1158    // Extreme-input ladder for the cloglog mean and its location derivative.
1159    //
1160    // Regimes:
1161    // - mu + sigma^2 / 2 << 0: rare-event tail, where 1 - exp(-exp(eta)) ~= exp(eta)
1162    // - mu - 8 sigma >> 0: survival term is numerically indistinguishable from 0
1163    //
1164    // The large-σ regime is intentionally NOT handled here (see the trailing
1165    // comment); the thresholds otherwise leave overlap with the Taylor/Miles/
1166    // Gamma branches so neighboring formulas still cover the transition band.
1167    let rare_log = mu + 0.5 * sigma * sigma;
1168    if rare_log <= CLOGLOG_RARE_EVENT_LOG_MAX {
1169        let mean = safe_exp(rare_log);
1170        return Some(IntegratedMeanDerivative {
1171            mean,
1172            dmean_dmu: mean,
1173            mode: IntegratedExpectationMode::ControlledAsymptotic,
1174        });
1175    }
1176    if mu - CLOGLOG_POSITIVE_SATURATION_SIGMAS * sigma >= CLOGLOG_POSITIVE_SATURATION_EDGE {
1177        return Some(IntegratedMeanDerivative {
1178            mean: 1.0,
1179            dmean_dmu: 0.0,
1180            mode: IntegratedExpectationMode::ControlledAsymptotic,
1181        });
1182    }
1183    // The large-σ regime (σ ≥ CLOGLOG_LARGE_SIGMA_ASYMPTOTIC_MIN) is handled
1184    // upstream in cloglog_posterior_meanwith_deriv_controlled via the accurate
1185    // log-space Gumbel survival quadrature, so this ladder no longer carries the
1186    // leading-order "sharp transition" split (it was biased low by 2–7%, #799,
1187    // and zeroed the derivative through value-space underflow, #798).
1188    None
1189}
1190
1191#[inline]
1192fn cloglog_survival_extreme_asymptotic(
1193    mu: f64,
1194    sigma: f64,
1195) -> Option<(f64, IntegratedExpectationMode)> {
1196    let rare_log = mu + 0.5 * sigma * sigma;
1197    if rare_log <= CLOGLOG_RARE_EVENT_LOG_MAX {
1198        let mean = safe_exp(rare_log);
1199        return Some((
1200            (1.0 - mean).clamp(0.0, 1.0),
1201            IntegratedExpectationMode::ControlledAsymptotic,
1202        ));
1203    }
1204    if mu - CLOGLOG_POSITIVE_SATURATION_SIGMAS * sigma >= CLOGLOG_POSITIVE_SATURATION_EDGE {
1205        // For σ < CLOGLOG_LARGE_SIGMA_ASYMPTOTIC_MIN this deep in the positive
1206        // tail S is below ~1e-300, so the value path's hard zero is exact to
1207        // f64. The genuine log-magnitude (needed by the kernel derivative path)
1208        // is recovered separately by cloglog_log_survival_term_controlled.
1209        return Some((0.0, IntegratedExpectationMode::ControlledAsymptotic));
1210    }
1211    // σ ≥ CLOGLOG_LARGE_SIGMA_ASYMPTOTIC_MIN is handled by the caller via the
1212    // accurate log-space Gumbel quadrature (replaces the biased step-model
1213    // split, #799).
1214    None
1215}
1216
1217/// One Laplace-localized Clenshaw–Curtis panel for the log-space survival
1218/// integrals at a given `(μ, σ)` and μ-derivative order.
1219///
1220/// Placement is the whole point: `z_lo`/`z_hi` are where the log-integrand
1221/// falls [`LOG_SURVIVAL_PANEL_LOG_DROP`] e-folds below its own maximum, and
1222/// `nodes` comes from the panel's local-scale arclength plus the σ-dependent
1223/// analyticity term. Value and every derivative order are evaluated on THIS
1224/// panel, so they are the same approximation surface by construction.
1225#[derive(Clone, Copy, Debug)]
1226struct LogSurvivalPanel {
1227    z_lo: f64,
1228    z_hi: f64,
1229    nodes: usize,
1230}
1231
1232/// Which branch of `S ↔ 1−S` a panel integrates.
1233///
1234/// Both are cancellation-free log-sum-exps of positive terms; the choice is
1235/// only about which one keeps `ln S` RELATIVELY accurate. Integrating `S` and
1236/// taking its log loses relative precision as `S → 1` (where `ln S → 0` is a
1237/// difference of near-equal quantities); integrating the complement and using
1238/// `ln1p(−(1−S))` does not.
1239#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1240enum LogSurvivalBranch {
1241    /// `S = E[exp(−e^η)]`, integrand `φ(z)·exp(−e^{μ+σz})`.
1242    Survival,
1243    /// `1 − S = E[1−exp(−e^η)]`, integrand `φ(z)·(−expm1(−e^{μ+σz}))`.
1244    Complement,
1245}
1246
1247impl LogSurvivalBranch {
1248    /// `ln` of the branch's integrand without the `−ln√(2π) − z²/2` Gaussian
1249    /// head, i.e. the part that carries `(μ, σ)`.
1250    #[inline]
1251    fn log_tilt(self, mu: f64, sigma: f64, z: f64) -> f64 {
1252        let u = safe_exp(mu + sigma * z);
1253        match self {
1254            Self::Survival => -u,
1255            // ln(1 − e^{−u}) = ln(−expm1(−u)); for small u this is ln u = μ+σz
1256            // to full precision, and for large u it is ≈ 0.
1257            Self::Complement => {
1258                let m = (-u).exp_m1();
1259                if m == 0.0 {
1260                    // u underflowed: 1 − e^{−u} = u to f64.
1261                    mu + sigma * z
1262                } else {
1263                    (-m).ln()
1264                }
1265            }
1266        }
1267    }
1268
1269    /// The branch's log-integrand `L(z)` (Gaussian head included, normalizing
1270    /// constant omitted — it cancels out of every root solve).
1271    #[inline]
1272    fn log_integrand(self, mu: f64, sigma: f64, z: f64) -> f64 {
1273        -0.5 * z * z + self.log_tilt(mu, sigma, z)
1274    }
1275
1276    /// `d/dz` of [`Self::log_integrand`], used only to place the peak.
1277    ///
1278    /// `Survival`: `−z − σu`. `Complement`: `−z + σu/(e^u − 1)`. Both are
1279    /// strictly decreasing in `z` (the tilt is concave), so each has a unique
1280    /// root.
1281    #[inline]
1282    fn log_integrand_slope(self, mu: f64, sigma: f64, z: f64) -> f64 {
1283        let u = safe_exp(mu + sigma * z);
1284        match self {
1285            Self::Survival => -z - sigma * u,
1286            Self::Complement => {
1287                let em1 = u.exp_m1();
1288                let tilt_slope = if em1.is_finite() && em1 > 0.0 {
1289                    sigma * u / em1
1290                } else if em1 == 0.0 {
1291                    // u underflowed: u/(e^u − 1) → 1.
1292                    sigma
1293                } else {
1294                    0.0
1295                };
1296                -z + tilt_slope
1297            }
1298        }
1299    }
1300}
1301
1302/// Maximizer of the survival branch's log-integrand: the unique root of
1303/// `z + σ e^{μ+σz} = 0`.
1304///
1305/// Solved in log form — `ln σ + μ + σz − ln(−z) = 0` on `z < 0` — so no
1306/// intermediate `e^{μ+σz}` is ever formed. That matters: the root can sit at
1307/// `z ≈ −μ/σ` with `μ` in the hundreds, where the direct form overflows on the
1308/// first Newton step and the value form of this solve simply cannot start.
1309/// The left side is strictly increasing in `z`, so a bracketed Newton is
1310/// unconditionally convergent.
1311fn log_survival_peak_z(mu: f64, sigma: f64) -> f64 {
1312    let log_sigma = sigma.ln();
1313    let residual = |z: f64| log_sigma + mu + sigma * z - (-z).ln();
1314    let mut hi = -f64::MIN_POSITIVE;
1315    let mut lo = -1.0;
1316    let mut widen = 0;
1317    while residual(lo) > 0.0 && widen < 4096 {
1318        lo *= 2.0;
1319        widen += 1;
1320        if !lo.is_finite() {
1321            return f64::MIN;
1322        }
1323    }
1324    let mut z = if lo > -1.0e6 { 0.5 * (lo + hi) } else { lo };
1325    for _ in 0..200 {
1326        let r = residual(z);
1327        if r > 0.0 {
1328            hi = z;
1329        } else {
1330            lo = z;
1331        }
1332        // d/dz [ln σ + μ + σz − ln(−z)] = σ + 1/(−z) > 0.
1333        let slope = sigma + 1.0 / (-z);
1334        let mut next = z - r / slope;
1335        if !(next > lo && next < hi) {
1336            next = 0.5 * (lo + hi);
1337        }
1338        if (next - z).abs() <= f64::EPSILON * (1.0 + z.abs()) {
1339            return next;
1340        }
1341        z = next;
1342    }
1343    z
1344}
1345
1346/// Maximizer of the complement branch's log-integrand: the unique root of
1347/// `−z + σ u/(e^u − 1) = 0`, `u = e^{μ+σz}`.
1348///
1349/// The tilt slope `σ u/(e^u−1)` falls monotonically from `σ` (at `u → 0`) to
1350/// `0`, so the root is in `(0, σ]` and plain bisection with a Newton-free
1351/// contraction is both safe and fast — no derivative of the tilt slope is
1352/// needed.
1353fn log_complement_peak_z(mu: f64, sigma: f64) -> f64 {
1354    let branch = LogSurvivalBranch::Complement;
1355    let (mut lo, mut hi) = (0.0_f64, sigma.max(f64::MIN_POSITIVE));
1356    if branch.log_integrand_slope(mu, sigma, hi) > 0.0 {
1357        return hi;
1358    }
1359    for _ in 0..200 {
1360        let mid = 0.5 * (lo + hi);
1361        if branch.log_integrand_slope(mu, sigma, mid) > 0.0 {
1362            lo = mid;
1363        } else {
1364            hi = mid;
1365        }
1366        if hi - lo <= f64::EPSILON * (1.0 + hi.abs()) {
1367            break;
1368        }
1369    }
1370    0.5 * (lo + hi)
1371}
1372
1373/// The point on one side of `z_peak` where the log-integrand has fallen `drop`
1374/// e-folds below its maximum.
1375///
1376/// The log-integrand is strictly concave, hence strictly monotone on each side
1377/// of its peak, so doubling outward until the drop is exceeded and then
1378/// bisecting is exact to the last bit and cannot be fooled by a second mode.
1379fn log_survival_panel_edge(
1380    branch: LogSurvivalBranch,
1381    mu: f64,
1382    sigma: f64,
1383    z_peak: f64,
1384    drop: f64,
1385    direction: f64,
1386) -> f64 {
1387    let peak_log = branch.log_integrand(mu, sigma, z_peak);
1388    let fallen = |z: f64| peak_log - branch.log_integrand(mu, sigma, z) >= drop;
1389    let mut step = 1.0_f64;
1390    let mut inner = z_peak;
1391    let mut outer = z_peak + direction * step;
1392    let mut widen = 0;
1393    while !fallen(outer) && widen < 4096 {
1394        inner = outer;
1395        step *= 2.0;
1396        outer = z_peak + direction * step;
1397        widen += 1;
1398        if !outer.is_finite() {
1399            return inner;
1400        }
1401    }
1402    let (mut lo, mut hi) = if direction > 0.0 {
1403        (inner, outer)
1404    } else {
1405        (outer, inner)
1406    };
1407    for _ in 0..200 {
1408        let mid = 0.5 * (lo + hi);
1409        if fallen(mid) == (direction > 0.0) {
1410            hi = mid;
1411        } else {
1412            lo = mid;
1413        }
1414        if hi - lo <= f64::EPSILON * (1.0 + mid.abs()) {
1415            break;
1416        }
1417    }
1418    if direction > 0.0 { hi } else { lo }
1419}
1420
1421/// `T = ∫ √(1 + σ² e^{μ+σz}) dz` across the panel, in closed form.
1422///
1423/// This is the panel's length measured in units of the log-integrand's own
1424/// local scale `1/√(−L'')`, i.e. how many resolution elements the rule has to
1425/// cover. With `r = √(1+σ²e^{μ+σz})` the antiderivative is
1426/// `(2r + ln((r−1)/(r+1)))/σ`; written as below it stays exact in the
1427/// `σ²e^{μ+σz} → 0` limit, where it must reduce to the plain length.
1428fn log_survival_panel_arclength(mu: f64, sigma: f64, z_lo: f64, z_hi: f64) -> f64 {
1429    let scale_root = |z: f64| (1.0 + safe_exp(2.0 * sigma.ln() + mu + sigma * z)).sqrt();
1430    let (r_lo, r_hi) = (scale_root(z_lo), scale_root(z_hi));
1431    if !(r_lo.is_finite() && r_hi.is_finite()) {
1432        return f64::INFINITY;
1433    }
1434    (z_hi - z_lo) + 2.0 / sigma * ((r_hi - r_lo) - ((1.0 + r_hi) / (1.0 + r_lo)).ln())
1435}
1436
1437/// The order the DERIVATIVE-TOWER panel is placed at, for every request that
1438/// asks for one (#2714).
1439///
1440/// The placement below widens the window (`drop`) and adds nodes with the order
1441/// it is placed for. Reading that order off whatever the caller happened to ask
1442/// for makes `σ^j ∂_μ^j S` a function of THE REQUEST as well as of `(μ, σ)` —
1443/// and the consumers ask for different orders at the same `(μ, σ)` inside one
1444/// fit. `log_kernel_bundle` passes `max_k`, which the latent row program derives
1445/// from `base_max_k + 2·max_primary_increment + max_suffix_increment`, i.e. from
1446/// whether the caller wanted a value, a gradient, a Hessian or a contracted
1447/// third: `4`, `5`, `6` and `7` on the same row. So the gradient and the Hessian
1448/// paired with it were reading the same integral off four different rules, and
1449/// the Hessian was not exactly the derivative of the gradient — the fault class
1450/// this issue is about, one level below where it was found.
1451///
1452/// Pinning the placement makes entry `j` a property of `(μ, σ)` alone.
1453///
1454/// The VALUE route pays nothing for it. `order = 0` requests keep their own
1455/// order-0 placement, which is already a function of `(μ, σ)` alone, and that is
1456/// the placement every `ln S` in the tree comes off — a bundle evaluates
1457/// `max_k + 1` of them against one tower, so the tower's `~1.2×` node count is
1458/// a few percent of the bundle and the hot path is byte-identical.
1459const LOG_SURVIVAL_TOWER_PANEL_ORDER: usize = LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER;
1460
1461/// The order a panel is PLACED at, given the order a caller asked to be
1462/// filled in.
1463///
1464/// Two placements, each a pure function of `(branch, μ, σ)`: the value surface
1465/// (`order = 0`) and the tower surface. See [`LOG_SURVIVAL_TOWER_PANEL_ORDER`].
1466/// They agree about `S` itself wherever it matters, because the one consumer of
1467/// the tower — `log_scaled_a_derivative_tower` — takes entry 0 from the value
1468/// route verbatim rather than from the tower panel.
1469#[inline]
1470fn log_survival_panel_placement_order(requested_order: usize) -> usize {
1471    if requested_order == 0 {
1472        0
1473    } else {
1474        LOG_SURVIVAL_TOWER_PANEL_ORDER
1475    }
1476}
1477
1478/// Place the panel for `(branch, μ, σ)`, given the μ-derivative order the caller
1479/// asked to have filled in.
1480///
1481/// The `order` argument reaches the geometry only through
1482/// [`log_survival_panel_placement_order`], so the panel is one of exactly two
1483/// surfaces and never a per-request one.
1484fn log_survival_panel(
1485    branch: LogSurvivalBranch,
1486    mu: f64,
1487    sigma: f64,
1488    requested_order: usize,
1489) -> LogSurvivalPanel {
1490    let order = log_survival_panel_placement_order(requested_order);
1491    let z_peak = match branch {
1492        LogSurvivalBranch::Survival => log_survival_peak_z(mu, sigma),
1493        LogSurvivalBranch::Complement => log_complement_peak_z(mu, sigma),
1494    };
1495    let drop = LOG_SURVIVAL_PANEL_LOG_DROP
1496        + LOG_SURVIVAL_PANEL_ORDER_LOG_DROP * (order as f64) * (2.0 + z_peak.abs()).ln();
1497    let z_lo = log_survival_panel_edge(branch, mu, sigma, z_peak, drop, -1.0);
1498    let z_hi = log_survival_panel_edge(branch, mu, sigma, z_peak, drop, 1.0);
1499    let arclength = log_survival_panel_arclength(mu, sigma, z_lo, z_hi);
1500    let requested = LOG_SURVIVAL_PANEL_ARCLENGTH_NODE_DENSITY * arclength
1501        + LOG_SURVIVAL_PANEL_SIGMA_NODE_SCALE * sigma.sqrt()
1502        + LOG_SURVIVAL_PANEL_ORDER_NODES * (order as f64);
1503    let mut nodes = if requested.is_finite() {
1504        (requested.ceil() as usize).clamp(
1505            LOG_SURVIVAL_PANEL_MIN_NODES,
1506            LOG_SURVIVAL_PANEL_MAX_NODES,
1507        )
1508    } else {
1509        LOG_SURVIVAL_PANEL_MAX_NODES
1510    };
1511    if nodes.is_multiple_of(2) {
1512        nodes += 1;
1513    }
1514    LogSurvivalPanel { z_lo, z_hi, nodes }
1515}
1516
1517/// One entry of the log-space survival tower: `ln|·|`, its sign, and the
1518/// cancellation the signed log-sum-exp actually suffered.
1519///
1520/// `log_cancellation = ln(Σ|terms| / |Σ terms|)` is not diagnostic decoration —
1521/// it is the rule's own error bar. A signed log-sum-exp returns a result whose
1522/// relative error is `≈ ε · Σ|terms|/|Σ terms|`, so a consumer that needs a
1523/// certified derivative reads this instead of guessing a `σ` threshold.
1524#[derive(Clone, Copy, Debug)]
1525pub struct LogSurvivalSignedValue {
1526    pub log_abs: f64,
1527    pub sign: f64,
1528    pub log_cancellation: f64,
1529}
1530
1531impl LogSurvivalSignedValue {
1532    const ZERO: Self = Self {
1533        log_abs: f64::NEG_INFINITY,
1534        sign: 0.0,
1535        log_cancellation: f64::INFINITY,
1536    };
1537}
1538
1539/// `ln S` together with `σ^j ∂_μ^j S` for `j = 0..=order`, all off ONE panel.
1540#[derive(Clone, Debug)]
1541pub struct LogSurvivalJet {
1542    /// `ln S(μ,σ)`.
1543    pub log_survival: f64,
1544    /// Entry `j` is `σ^j ∂_μ^j S` in signed-log form; entry 0 is `(ln S, +1)`.
1545    pub scaled_mu_derivatives: [LogSurvivalSignedValue; LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER + 1],
1546    /// Highest order actually filled in.
1547    pub order: usize,
1548    pub mode: IntegratedExpectationMode,
1549}
1550
1551impl LogSurvivalJet {
1552    /// The scaled μ-derivatives `σ^j ∂_μ^j S`, `j = 1..=order`, or `None` when
1553    /// the quadrature's own measured cancellation says they are not accurate
1554    /// enough to displace the rung (Touchard) basis.
1555    ///
1556    /// This asks the quadrature what cancellation it suffered rather than
1557    /// asking `σ` to stand in for it. The two derivative bases are
1558    /// complementary and both are needed: the direct tower is one integral per
1559    /// order and degrades only where that integral cancels (small σ, where
1560    /// `σ^j ∂_μ^j S` is genuinely `O(σ^j)` while the summands are `O(1)`); the
1561    /// rung basis is an alternating combination of `K_k` and degrades where
1562    /// THOSE cancel (large σ, #2610). Before #2714 the split was a `σ ≥ 8`
1563    /// constant. It is now the measured conditioning of the thing admitted.
1564    ///
1565    /// All-or-nothing on purpose: a caller either gets the whole tower on one
1566    /// surface or falls back to the rung basis, never a partial mix.
1567    pub fn certified_scaled_mu_derivatives(
1568        &self,
1569        order: usize,
1570    ) -> Option<&[LogSurvivalSignedValue]> {
1571        let prefix = self.certified_prefix_order(order)?;
1572        (prefix == order).then(|| &self.scaled_mu_derivatives[..=order])
1573    }
1574
1575    /// The largest `r ≤ order` for which entries `0..=r` are ALL certified, or
1576    /// `None` when even entry 0 is not (#2714).
1577    ///
1578    /// Certification is a prefix property — every entry is judged on its own
1579    /// finiteness, sign and measured cancellation — so "the whole tower up to
1580    /// `order`" and "the longest usable tower" are the same test read to two
1581    /// different lengths, and asking for the longest is what makes the answer
1582    /// independent of how much the caller asked for.
1583    ///
1584    /// That independence is the point. [`Self::certified_scaled_mu_derivatives`]
1585    /// refuses the whole tower when the LAST rung fails, so a consumer that
1586    /// needed rungs `0..=1` and asked for `0..=5` was denied a basis that is
1587    /// perfectly well conditioned at the rungs it actually reads — and, worse,
1588    /// two consumers reading the SAME term list at the same `(μ, σ)` could be
1589    /// routed to different bases purely because one of them also wanted a
1590    /// Hessian. Handing back the certified prefix moves that decision onto the
1591    /// term list, where it belongs.
1592    ///
1593    /// It does not weaken the "never a partial mix" rule: a tower truncated at
1594    /// `r` makes every term list needing a rung above `r` fall back WHOLE to the
1595    /// rung basis (see `latent_kernel_evaluate_terms_in_a_basis`, which refuses
1596    /// the list rather than the term). What is admitted is still one surface.
1597    pub fn certified_prefix_order(&self, order: usize) -> Option<usize> {
1598        let order = order.min(self.order);
1599        let mut certified = None;
1600        for (index, entry) in self.scaled_mu_derivatives[..=order].iter().enumerate() {
1601            let usable = entry.log_abs.is_finite()
1602                && (index == 0 || entry.sign != 0.0)
1603                && entry.log_cancellation <= LOG_SURVIVAL_TOWER_MAX_LOG_CANCELLATION;
1604            if !usable {
1605                break;
1606            }
1607            certified = Some(index);
1608        }
1609        certified
1610    }
1611}
1612
1613/// Streaming signed log-sum-exp that also accumulates `ln Σ|terms|`.
1614struct SignedLogAccumulator {
1615    running_max: f64,
1616    signed_sum: f64,
1617    abs_sum: f64,
1618}
1619
1620impl SignedLogAccumulator {
1621    #[inline]
1622    fn new() -> Self {
1623        Self {
1624            running_max: f64::NEG_INFINITY,
1625            signed_sum: 0.0,
1626            abs_sum: 0.0,
1627        }
1628    }
1629
1630    #[inline]
1631    fn push(&mut self, log_abs: f64, sign: f64) {
1632        if !log_abs.is_finite() {
1633            return;
1634        }
1635        if log_abs > self.running_max {
1636            let rescale = (self.running_max - log_abs).exp();
1637            self.signed_sum = self.signed_sum * rescale + sign;
1638            self.abs_sum = self.abs_sum * rescale + 1.0;
1639            self.running_max = log_abs;
1640        } else {
1641            let weight = (log_abs - self.running_max).exp();
1642            self.signed_sum += sign * weight;
1643            self.abs_sum += weight;
1644        }
1645    }
1646
1647    #[inline]
1648    fn finish(self) -> LogSurvivalSignedValue {
1649        if self.running_max == f64::NEG_INFINITY || self.signed_sum == 0.0 {
1650            return LogSurvivalSignedValue::ZERO;
1651        }
1652        LogSurvivalSignedValue {
1653            log_abs: self.running_max + self.signed_sum.abs().ln(),
1654            sign: self.signed_sum.signum(),
1655            log_cancellation: (self.abs_sum / self.signed_sum.abs()).ln().max(0.0),
1656        }
1657    }
1658}
1659
1660/// Evaluate `∫ He_j(z) φ(z) · tilt(z) dz` for `j = 0..=order` on one panel.
1661///
1662/// Every order reads the SAME nodes and the SAME tilt values; only the Hermite
1663/// factor differs. That is what makes the value and its derivative tower one
1664/// approximation surface rather than two that happen to agree in the middle.
1665fn log_survival_panel_moments(
1666    ctx: &QuadratureContext,
1667    branch: LogSurvivalBranch,
1668    mu: f64,
1669    sigma: f64,
1670    order: usize,
1671) -> ([LogSurvivalSignedValue; LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER + 1], usize) {
1672    let panel = log_survival_panel(branch, mu, sigma, order);
1673    let rule = ctx.clenshaw_curtis_n(panel.nodes);
1674    let half = 0.5 * (panel.z_hi - panel.z_lo);
1675    let mid = 0.5 * (panel.z_hi + panel.z_lo);
1676    let log_gaussian_norm = -0.5 * (2.0 * std::f64::consts::PI).ln();
1677    let mut accumulators: Vec<SignedLogAccumulator> =
1678        (0..=order).map(|_| SignedLogAccumulator::new()).collect();
1679    for (&node, &weight) in rule.nodes.iter().zip(rule.weights.iter()) {
1680        let z = half * node + mid;
1681        let base = (weight * half).ln() + log_gaussian_norm - 0.5 * z * z
1682            + branch.log_tilt(mu, sigma, z);
1683        if !base.is_finite() {
1684            continue;
1685        }
1686        for (j, accumulator) in accumulators.iter_mut().enumerate() {
1687            let hermite = hermite_he(j, z);
1688            if hermite == 0.0 {
1689                continue;
1690            }
1691            accumulator.push(base + hermite.abs().ln(), hermite.signum());
1692        }
1693    }
1694    let mut out = [LogSurvivalSignedValue::ZERO; LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER + 1];
1695    for (slot, accumulator) in out.iter_mut().zip(accumulators.into_iter()) {
1696        *slot = accumulator.finish();
1697    }
1698    (out, panel.nodes)
1699}
1700
1701/// The canonical log-space survival object: `ln S(μ,σ)` and its scaled
1702/// μ-derivative tower, on one panel, for every `(μ, σ)`.
1703///
1704/// The tower identity is Gaussian integration by parts in the standardized
1705/// variable: with `f(z) = exp(−e^{μ+σz})`, `∂_μ f = σ^{-1} ∂_z f`, so
1706///
1707/// ```text
1708///   σ^j ∂_μ^j S = ∫ He_j(z) φ(z) f(z) dz,
1709/// ```
1710///
1711/// because `(−∂_z)^j φ = He_j φ`. Order 0 is `S` itself, which is why the
1712/// value and the tower cannot come off different surfaces here even in
1713/// principle — they are the same sum with different Hermite weights.
1714///
1715/// Sign convention: entries are `σ^j ∂_μ^j S`, so odd orders are negative for
1716/// the survival branch (`S` decreases in `μ`).
1717pub fn log_survival_jet(
1718    ctx: &QuadratureContext,
1719    mu: f64,
1720    sigma: f64,
1721    order: usize,
1722) -> LogSurvivalJet {
1723    let order = order.min(LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER);
1724    if !(mu.is_finite() && sigma.is_finite()) || sigma <= CLOGLOG_SIGMA_DEGENERATE {
1725        // σ = 0: S = exp(−e^μ) and σ^j ∂_μ^j S = 0 for j ≥ 1 (the SCALED
1726        // derivative carries a σ^j that annihilates the finite ∂_μ^j S).
1727        let mut scaled = [LogSurvivalSignedValue::ZERO;
1728            LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER + 1];
1729        let log_survival = -safe_exp(mu);
1730        scaled[0] = LogSurvivalSignedValue {
1731            log_abs: log_survival,
1732            sign: 1.0,
1733            log_cancellation: 0.0,
1734        };
1735        return LogSurvivalJet {
1736            log_survival,
1737            scaled_mu_derivatives: scaled,
1738            order,
1739            mode: IntegratedExpectationMode::ExactClosedForm,
1740        };
1741    }
1742
1743    let (survival, _) =
1744        log_survival_panel_moments(ctx, LogSurvivalBranch::Survival, mu, sigma, order);
1745    // `ln S` from the survival branch is accurate ABSOLUTELY; as S → 1 that is
1746    // not the same as accurate relatively, because ln S → 0 is then the log of
1747    // a number whose distance from 1 carries the information. Past S ≈ 0.6 the
1748    // complement branch — an independent, equally cancellation-free panel —
1749    // supplies 1 − S directly and `ln1p` keeps every digit. Both branches are
1750    // at the f64 floor where they meet, so the handover is smooth to ~1e-16.
1751    let use_complement = survival[0].sign > 0.0 && survival[0].log_abs > -0.5;
1752    if !use_complement {
1753        let log_survival = if survival[0].sign > 0.0 {
1754            survival[0].log_abs
1755        } else {
1756            f64::NEG_INFINITY
1757        };
1758        return LogSurvivalJet {
1759            log_survival,
1760            scaled_mu_derivatives: survival,
1761            order,
1762            mode: IntegratedExpectationMode::ControlledAsymptotic,
1763        };
1764    }
1765
1766    let (complement, _) =
1767        log_survival_panel_moments(ctx, LogSurvivalBranch::Complement, mu, sigma, order);
1768    let mut scaled = survival;
1769    // `log_abs < 0` is guaranteed by the handover test above (it fires only for
1770    // S > 0.6, hence 1 − S < 0.4) and is checked rather than assumed, because
1771    // `ln1p` of anything ≤ −1 is not a number.
1772    let log_survival = if complement[0].sign > 0.0 && complement[0].log_abs < 0.0 {
1773        (-safe_exp(complement[0].log_abs)).ln_1p()
1774    } else {
1775        survival[0].log_abs
1776    };
1777    scaled[0] = LogSurvivalSignedValue {
1778        log_abs: log_survival,
1779        sign: 1.0,
1780        log_cancellation: complement[0].log_cancellation,
1781    };
1782    // For j ≥ 1, ∂_μ^j S = −∂_μ^j (1−S) exactly, and the complement integrand
1783    // is the one that stays away from a constant in this regime, so it is the
1784    // better-conditioned of the two. Take whichever reports less cancellation;
1785    // both are the same quantity to their own accuracy.
1786    for j in 1..=order {
1787        let flipped = LogSurvivalSignedValue {
1788            log_abs: complement[j].log_abs,
1789            sign: -complement[j].sign,
1790            log_cancellation: complement[j].log_cancellation,
1791        };
1792        if flipped.sign != 0.0 && flipped.log_cancellation < scaled[j].log_cancellation {
1793            scaled[j] = flipped;
1794        }
1795    }
1796    LogSurvivalJet {
1797        log_survival,
1798        scaled_mu_derivatives: scaled,
1799        order,
1800        mode: IntegratedExpectationMode::ControlledAsymptotic,
1801    }
1802}
1803
1804#[inline]
1805fn hermite_he(n: usize, x: f64) -> f64 {
1806    let mut previous = 1.0_f64;
1807    if n == 0 {
1808        return previous;
1809    }
1810    let mut current = x;
1811    for order in 1..n {
1812        let next = x * current - (order as f64) * previous;
1813        previous = current;
1814        current = next;
1815    }
1816    current
1817}
1818
1819/// `σ^j ∂^j S/∂μ^j` in signed-log coordinates, off the same panel that
1820/// [`log_survival_jet`] uses for `ln S`.
1821///
1822/// Kept as a scalar entry point for callers that want one order; it evaluates
1823/// the whole tower up to `order` because the panel is shared and the extra
1824/// Hermite weights are the cheap part of the sum.
1825///
1826/// Returns `(ln|·|, sign)` with `(−∞, 0)` for an exact zero, so the magnitude
1827/// survives value-space underflow exactly as the `ln S` path does (#798).
1828/// `order` must be at least 1; order 0 is `ln S` itself.
1829pub fn log_survival_scaled_mu_derivative(
1830    ctx: &QuadratureContext,
1831    mu: f64,
1832    sigma: f64,
1833    order: usize,
1834) -> (f64, f64) {
1835    assert!(
1836        order >= 1,
1837        "the log-space survival mu-derivative tower starts at order 1; \
1838         order 0 is ln S, which log_survival_jet returns"
1839    );
1840    let jet = log_survival_jet(ctx, mu, sigma, order);
1841    let entry = jet.scaled_mu_derivatives[order.min(jet.order)];
1842    (entry.log_abs, entry.sign)
1843}
1844
1845/// Canonical log-space survival evaluator: returns `ln S(μ,σ)` with its routing
1846/// mode. This is the log-domain twin of [`cloglog_survival_term_controlled`].
1847///
1848/// Every kernel quantity that multiplies `S` by an `exp(kμ + ½k²σ²)` prefix —
1849/// the integrated cloglog derivative, the latent-cloglog jet, the lognormal
1850/// kernel bundle — must form the product in log space through this function, so
1851/// the genuine (but f64-unrepresentable) magnitude of `S` is preserved instead
1852/// of underflowing to a hard zero that zeroes the derivative (#798).
1853///
1854/// It is ONE surface (#2714). There is deliberately no `.ln()` of a value-space
1855/// result here and no threshold between competing approximations: `ln S` is
1856/// accumulated in log space on the panel of [`log_survival_jet`] at every
1857/// `(μ, σ)` with `σ > 0`, and the only other branch is the exact `σ = 0` closed
1858/// form. What used to live here — a rare-event asymptotic, a value-route
1859/// logarithm, and a fixed-window quadrature as an underflow escape — disagreed
1860/// with each other by up to `4.4e+06` in `ln S`, and their disagreement was a
1861/// step function of `(μ, σ)`, which no analytic derivative can follow.
1862pub(crate) fn cloglog_log_survival_term_controlled(
1863    ctx: &QuadratureContext,
1864    mu: f64,
1865    sigma: f64,
1866) -> (f64, IntegratedExpectationMode) {
1867    let jet = log_survival_jet(ctx, mu, sigma, 0);
1868    (jet.log_survival, jet.mode)
1869}
1870
1871// ── Exact Gumbel survival primitives ─────────────────────────────────────
1872//
1873// The Gumbel survival function S(x) = exp(-exp(x)) is the complement of the
1874// cloglog mean μ(x) = 1 - S(x). Both are exact for ALL finite x under IEEE 754
1875// without any clamping:
1876//
1877//   x → -∞: exp(x) → 0,    S → exp(-0) = 1,  μ' → 0·1 = 0
1878//   x → +∞: exp(x) → +∞,   S → exp(-∞) = 0,  μ' → ∞·0 = 0
1879//
1880// The only subtlety is in μ': when x > 709, exp(x) overflows to +∞,
1881// and ∞ · 0 = NaN. But x - exp(x) → -∞ for any x > 0, so μ' = 0.
1882// We detect the intermediate overflow and return 0.0 exactly.
1883
1884/// Exact Gumbel survival: S(x) = exp(-exp(x)).
1885///
1886/// No clamping — IEEE 754 handles both tails correctly:
1887/// - exp(x) underflows to 0 for x < -745 → S = exp(-0) = 1.0
1888/// - exp(x) overflows to ∞ for x > 709  → S = exp(-∞) = 0.0
1889#[inline]
1890fn gumbel_survival(x: f64) -> f64 {
1891    (-safe_exp(x)).exp()
1892}
1893
1894/// Exact cloglog mean derivative: μ'(x) = exp(x) · exp(-exp(x)) = -S'(x).
1895///
1896/// Saturates the intermediate exp in the positive tail; double-exponential
1897/// decay still drives the returned derivative to 0.0.
1898#[inline]
1899fn cloglog_mean_d1_exact(x: f64) -> f64 {
1900    let ex = safe_exp(x);
1901    if ex.is_infinite() {
1902        0.0
1903    } else {
1904        ex * (-ex).exp()
1905    }
1906}
1907
1908/// Exact cloglog mean: μ(x) = 1 - exp(-exp(x)) via expm1 to avoid
1909/// catastrophic cancellation when exp(x) ≈ 0 (far negative tail).
1910///
1911/// This is the universal formula — it works for ALL finite x, not just
1912/// the negative tail.  For x > 709, exp(x) overflows but expm1(-∞) = -1,
1913/// giving μ = 1.0 exactly.
1914///
1915/// Delegates to `cloglog_negative_tail_mean` which implements the same
1916/// expm1 formulation.
1917#[inline]
1918fn cloglog_mean_exact(x: f64) -> f64 {
1919    cloglog_negative_tail_mean(x)
1920}
1921
1922// ── Cloglog negative-tail asymptotics ────────────────────────────────────
1923//
1924// For the cloglog link μ(η) = 1 − exp(−exp(η)), when η ≪ 0:
1925//   μ(η)   ≈ exp(η)                          (since exp(η)→0)
1926//   μ'(η)  = exp(η)·exp(−exp(η)) ≈ exp(η)   (since exp(−exp(η))→1)
1927//
1928// For the integrated (Gaussian-convolved) mean E[μ(η+σZ)]:
1929//   E[μ(η+σZ)] ≈ E[exp(η+σZ)] = exp(η + σ²/2)
1930//   d/dη E[μ(η+σZ)] ≈ exp(η + σ²/2)
1931//
1932// These asymptotics are accurate to O(exp(2η)) and replace the previous
1933// hard-zero derivative outside the clamp window, which introduced a
1934// discontinuity at η = −30 and discarded real (though small) derivative mass.
1935
1936/// Pointwise cloglog mean in the deep negative tail.
1937#[inline]
1938fn cloglog_negative_tail_mean(eta: f64) -> f64 {
1939    // μ(η) = 1 − exp(−exp(η)).  For η < −30, exp(η) < 1e-13, so
1940    // exp(−exp(η)) ≈ 1 − exp(η) and μ ≈ exp(η).
1941    // Direct exp avoids the intermediate exp(exp(η)) overflow path.
1942    if eta < -745.0 {
1943        // exp(-745) underflows to 0.0 in f64.
1944        0.0
1945    } else {
1946        // Use expm1(−exp(η)) = exp(−exp(η)) − 1, so μ = −expm1(−exp(η)).
1947        // This is more accurate than 1 − exp(−exp(η)) near zero.
1948        let ex = safe_exp(eta);
1949        -(-ex).exp_m1()
1950    }
1951}
1952
1953// Pointwise cloglog derivative dμ/dη in the deep negative tail:
1954// `cloglog_negative_tail_derivative` (a reference implementation retained
1955// solely for its unit test) lives inside `mod tests` below.
1956
1957#[inline]
1958fn cloglog_small_sigma_taylor(mu: f64, sigma: f64) -> IntegratedMeanDerivative {
1959    // Small-variance heat-kernel expansion for the cloglog inverse link.
1960    //
1961    // For η = μ + σ Z, Z ~ N(0,1), and any analytic f the heat-kernel
1962    // (even-moment) identity gives
1963    //
1964    //   E[f(η)] = Σ_{k≥0} σ^(2k) / (2^k · k!) · f^(2k)(μ).
1965    //
1966    // Here f(x) = 1 − exp(−exp(x)) is entire, so the series is valid
1967    // globally. Truncating at the σ⁶ term yields
1968    //
1969    //   E[f(η)]       ≈ f + (σ²/2) f'' + (σ⁴/8) f^(4) + (σ⁶/48) f^(6)
1970    //   d/dμ E[f(η)]  ≈ f' + (σ²/2) f''' + (σ⁴/8) f^(5) + (σ⁶/48) f^(7).
1971    //
1972    // Coefficients are heat-kernel weights 1/(2^k k!), not Taylor 1/(2k)!:
1973    // 1/(2²·2!) = 1/8 (not 1/4! = 1/24), 1/(2³·3!) = 1/48 (not 1/6! = 1/720).
1974    //
1975    // A single formula covers the entire real line once the constituent
1976    // evaluations are written stably:
1977    //   • f0 uses -expm1(-ex) to stay bit-exact for μ ≪ 0 (where ex ≈ 0)
1978    //   • surv = exp(-ex) underflows cleanly to 0 for μ ≫ 0, yielding
1979    //     the saturation limit f ≡ 1, f' ≡ 0 without any branch.
1980    // No separate "negative-tail" MGF approximation is needed; the Taylor
1981    // truncation error is uniformly O(σ⁶ · ex) across the whole domain.
1982    if sigma <= CLOGLOG_SIGMA_DEGENERATE {
1983        return IntegratedMeanDerivative {
1984            mean: cloglog_mean_exact(mu),
1985            dmean_dmu: cloglog_mean_d1_exact(mu),
1986            mode: IntegratedExpectationMode::ExactClosedForm,
1987        };
1988    }
1989
1990    let ex = safe_exp(mu);
1991    if !ex.is_finite() {
1992        // Non-finite μ in the positive direction saturates f to 1 and f' to 0.
1993        return IntegratedMeanDerivative {
1994            mean: 1.0,
1995            dmean_dmu: 0.0,
1996            mode: IntegratedExpectationMode::ControlledAsymptotic,
1997        };
1998    }
1999    let surv = (-ex).exp();
2000    if surv == 0.0 {
2001        // exp(-ex) underflow: positive-μ saturation, same limit.
2002        return IntegratedMeanDerivative {
2003            mean: 1.0,
2004            dmean_dmu: 0.0,
2005            mode: IntegratedExpectationMode::ControlledAsymptotic,
2006        };
2007    }
2008
2009    let s2 = sigma * sigma;
2010    let s4 = s2 * s2;
2011    let s6 = s4 * s2;
2012    let s8 = s4 * s4;
2013    let e2x = ex * ex;
2014    let e3x = e2x * ex;
2015    let e4x = e3x * ex;
2016    let e5x = e4x * ex;
2017    let e6x = e5x * ex;
2018    let e7x = e6x * ex;
2019    let e8x = e7x * ex;
2020    let e9x = e8x * ex;
2021    // -expm1(-ex) = 1 - exp(-ex) is bit-exact even when ex is subnormal.
2022    //
2023    // Derivatives of f(x) = 1 - exp(-exp(x)) follow the Stirling-second-kind
2024    // pattern: f^(n)(x) = exp(-exp(x)) * sum_{k=1..n} (-1)^(k+1) S(n,k) u^k,
2025    // where u = exp(x) and S(n,k) are Stirling numbers of the second kind:
2026    //   S(2,.) = {1, 1}
2027    //   S(3,.) = {1, 3, 1}
2028    //   S(4,.) = {1, 7, 6, 1}
2029    //   S(5,.) = {1, 15, 25, 10, 1}
2030    //   S(6,.) = {1, 31, 90, 65, 15, 1}
2031    //   S(7,.) = {1, 63, 301, 350, 140, 21, 1}
2032    //   S(8,.) = {1, 127, 966, 1701, 1050, 266, 28, 1}
2033    //   S(9,.) = {1, 255, 3025, 7770, 6951, 2646, 462, 36, 1}
2034    let f0 = -(-ex).exp_m1();
2035    let f1 = ex * surv;
2036    let f2 = surv * (ex - e2x);
2037    let f3 = surv * (ex - 3.0 * e2x + e3x);
2038    let f4 = surv * (ex - 7.0 * e2x + 6.0 * e3x - e4x);
2039    let f5 = surv * (ex - 15.0 * e2x + 25.0 * e3x - 10.0 * e4x + e5x);
2040    let f6 = surv * (ex - 31.0 * e2x + 90.0 * e3x - 65.0 * e4x + 15.0 * e5x - e6x);
2041    let f7 = surv * (ex - 63.0 * e2x + 301.0 * e3x - 350.0 * e4x + 140.0 * e5x - 21.0 * e6x + e7x);
2042    let f8 = surv
2043        * (ex - 127.0 * e2x + 966.0 * e3x - 1701.0 * e4x + 1050.0 * e5x - 266.0 * e6x + 28.0 * e7x
2044            - e8x);
2045    let f9 = surv
2046        * (ex - 255.0 * e2x + 3025.0 * e3x - 7770.0 * e4x + 6951.0 * e5x - 2646.0 * e6x
2047            + 462.0 * e7x
2048            - 36.0 * e8x
2049            + e9x);
2050    // Heat-kernel coefficients 1/(2^k k!): k=1: 1/2, k=2: 1/8, k=3: 1/48,
2051    // k=4: 1/384. Truncation after sigma^8 leaves O(sigma^10) remainder,
2052    // comfortably below 1e-12 rel at sigma = 0.1 even in the negative tail.
2053    IntegratedMeanDerivative {
2054        mean: f0 + 0.5 * s2 * f2 + (s4 / 8.0) * f4 + (s6 / 48.0) * f6 + (s8 / 384.0) * f8,
2055        dmean_dmu: (f1 + 0.5 * s2 * f3 + (s4 / 8.0) * f5 + (s6 / 48.0) * f7 + (s8 / 384.0) * f9)
2056            .max(0.0),
2057        mode: IntegratedExpectationMode::ControlledAsymptotic,
2058    }
2059}
2060
2061#[inline]
2062/// Panelized adaptive-Simpson refinement with Richardson extrapolation on a
2063/// single panel `[a, b]`. `whole` is the one-panel Simpson estimate; the panel
2064/// is bisected until the two-panel estimate agrees to `tol` (or `depth` is
2065/// exhausted), then the extrapolated value is returned.
2066fn adaptive_simpson_refine(
2067    g: &impl Fn(f64) -> f64,
2068    a: f64,
2069    b: f64,
2070    fa: f64,
2071    fb: f64,
2072    fm: f64,
2073    whole: f64,
2074    tol: f64,
2075    depth: i32,
2076) -> f64 {
2077    let m = 0.5 * (a + b);
2078    let lm = 0.5 * (a + m);
2079    let rm = 0.5 * (m + b);
2080    let flm = g(lm);
2081    let frm = g(rm);
2082    let left = (m - a) / 6.0 * (fa + 4.0 * flm + fm);
2083    let right = (b - m) / 6.0 * (fm + 4.0 * frm + fb);
2084    let est = left + right;
2085    if depth <= 0 || (est - whole).abs() <= 15.0 * tol {
2086        return est + (est - whole) / 15.0;
2087    }
2088    adaptive_simpson_refine(g, a, m, fa, fm, flm, left, 0.5 * tol, depth - 1)
2089        + adaptive_simpson_refine(g, m, b, fm, fb, frm, right, 0.5 * tol, depth - 1)
2090}
2091
2092/// Accurate Gaussian expectation `E[f(mu + sigma·Z)]`, `Z ~ N(0,1)`, via
2093/// panelized adaptive Simpson over the standardized window `u ∈ [-K, K]`.
2094///
2095/// This is the trusted fallback when the controlled special-function backends
2096/// decline. Fixed Gauss-Hermite quadrature undersamples integrands whose
2097/// features are narrow in standardized coordinates — the cloglog transition
2098/// `1 − exp(−exp(η))` has width `~1/sigma` in `u`, so once `sigma` is large it
2099/// collapses below the GHQ node spacing and most of the fixed nodes scatter
2100/// into the flat dead zone, leaving only a handful to resolve the transition
2101/// (the ~1e-3 mean / ~3.5e-3 derivative error observed at `sigma = 4`).
2102/// Adaptive Simpson instead refines panels only where the integrand curves,
2103/// resolving the transition to tolerance regardless of `sigma`. The
2104/// standard-normal density kills the tails (`φ(15) ~ 1e-49`), so the finite
2105/// window `K = 15` captures the whole integral with no analytic tail term.
2106fn integrate_normal_adaptive(mu: f64, sigma: f64, f: impl Fn(f64) -> f64) -> f64 {
2107    if !(sigma.is_finite()) || sigma < 1e-10 {
2108        return f(mu);
2109    }
2110    const K: f64 = 15.0;
2111    const INITIAL_PANELS: usize = 24;
2112    const TOL: f64 = 1e-12;
2113    const MAX_DEPTH: i32 = 40;
2114    let inv_sqrt_2pi = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
2115    // Integrand in standardized coordinates: f(mu + sigma·u) · φ(u). A coarse
2116    // initial panel grid guarantees the transition cannot fall entirely
2117    // between sampled points before adaptive refinement engages.
2118    let g = |u: f64| f(mu + sigma * u) * inv_sqrt_2pi * (-0.5 * u * u).exp();
2119    let panel = 2.0 * K / INITIAL_PANELS as f64;
2120    let mut total = 0.0;
2121    for p in 0..INITIAL_PANELS {
2122        let a = -K + p as f64 * panel;
2123        let b = a + panel;
2124        let fa = g(a);
2125        let fb = g(b);
2126        let fm = g(0.5 * (a + b));
2127        let whole = (b - a) / 6.0 * (fa + 4.0 * fm + fb);
2128        total += adaptive_simpson_refine(&g, a, b, fa, fb, fm, whole, TOL, MAX_DEPTH);
2129    }
2130    total
2131}
2132
2133fn cloglog_posterior_meanwith_deriv_quadrature(mu: f64, sigma: f64) -> IntegratedMeanDerivative {
2134    if sigma < 1e-10 {
2135        return IntegratedMeanDerivative {
2136            mean: cloglog_mean_exact(mu),
2137            dmean_dmu: cloglog_mean_d1_exact(mu),
2138            mode: IntegratedExpectationMode::ExactClosedForm,
2139        };
2140    }
2141    let mean = cloglog_mean_from_survival(survival_posterior_mean_quadrature(mu, sigma));
2142    let dmean_dmu = integrate_normal_adaptive(mu, sigma, cloglog_mean_d1_exact).max(0.0);
2143    IntegratedMeanDerivative {
2144        mean,
2145        dmean_dmu,
2146        mode: IntegratedExpectationMode::QuadratureFallback,
2147    }
2148}
2149
2150#[inline]
2151fn survival_posterior_mean_quadrature(eta: f64, se_eta: f64) -> f64 {
2152    integrate_normal_adaptive(eta, se_eta, gumbel_survival).clamp(0.0, 1.0)
2153}
2154
2155fn cloglog_survival_term_controlled(
2156    ctx: &QuadratureContext,
2157    mu: f64,
2158    sigma: f64,
2159) -> (f64, IntegratedExpectationMode) {
2160    // Shared scalar evaluator for the lognormal-Laplace object
2161    //
2162    //   S(mu, sigma) = E[exp(-exp(eta))],  eta ~ N(mu, sigma^2),
2163    //
2164    // This is the survival transform itself, and it is also the complement-core
2165    // of the cloglog inverse link:
2166    //
2167    //   cloglog mean   = 1 - S(mu, sigma)
2168    //   survival mean  = S(mu, sigma).
2169    //
2170    // The exact mathematical object behind this is the Laplace transform of a
2171    // lognormal random variable. If X = exp(eta), then X ~ LogNormal(mu,sigma^2)
2172    // and
2173    //
2174    //   S(mu, sigma) = E[exp(-X)] = L(1; mu, sigma),
2175    //
2176    // where more generally
2177    //
2178    //   L(z; mu, sigma) = E[exp(-z exp(eta))],  z > 0.
2179    //
2180    // So every path below is just a different exact or controlled evaluator for
2181    // the same scalar target.
2182    //
2183    // Routing here mirrors the production ladder used by the integrated
2184    // cloglog derivative path:
2185    // - plug-in when sigma is effectively zero
2186    // - Taylor / heat-kernel at small sigma
2187    // - explicit extreme-input asymptotics
2188    // - Miles erfc-series in tail-dominated regimes
2189    // - Clenshaw-Curtis on the truncated real integral in the central regime
2190    // - exact Gamma/Mellin-Barnes if CC would need too many nodes or misbehaves
2191    // - GHQ only as the final numerical fallback
2192    //
2193    // Validation target for the extended asymptotic routes: compare against
2194    // 256-point GHQ on representative difficult points such as
2195    // (-20, 0.1), (-5, 5), (0, 20), (10, 0.5), (10, 10), and (-0.5, 100).
2196    if !(mu.is_finite() && sigma.is_finite()) || sigma <= CLOGLOG_SIGMA_DEGENERATE {
2197        return (
2198            gumbel_survival(mu).clamp(0.0, 1.0),
2199            IntegratedExpectationMode::ExactClosedForm,
2200        );
2201    }
2202    if sigma < CLOGLOG_SIGMA_TAYLOR_MAX {
2203        let mean = cloglog_small_sigma_taylor(mu, sigma).mean;
2204        return (
2205            (1.0 - mean).clamp(0.0, 1.0),
2206            IntegratedExpectationMode::ControlledAsymptotic,
2207        );
2208    }
2209    if let Some(out) = cloglog_survival_extreme_asymptotic(mu, sigma) {
2210        return out;
2211    }
2212    if sigma >= CLOGLOG_LARGE_SIGMA_ASYMPTOTIC_MIN {
2213        // Accurate large-σ survival from the log-space survival panel,
2214        // replacing the leading-order "sharp transition" split that was biased
2215        // low by 2–7% across σ ∈ [8, 20] (#799). Exponentiating ln S here loses
2216        // nothing on the value path (S is consumed as a probability); the
2217        // log-magnitude that the kernel derivative path needs is taken straight
2218        // from cloglog_log_survival_term_controlled, which reads the same panel.
2219        let log_s = log_survival_jet(ctx, mu, sigma, 0).log_survival;
2220        return (
2221            safe_exp(log_s).clamp(0.0, 1.0),
2222            IntegratedExpectationMode::ControlledAsymptotic,
2223        );
2224    }
2225    if cloglog_survival_miles_is_reliable(mu, sigma)
2226        && let Ok(out) = cloglog_survival_miles(mu, sigma)
2227    {
2228        return (
2229            out.clamp(0.0, 1.0),
2230            IntegratedExpectationMode::ExactSpecialFunction,
2231        );
2232    }
2233    if cloglog_should_prefer_cc(mu, sigma, CLOGLOG_CC_TOL)
2234        && let Ok(out) = cloglog_survival_cc(ctx, mu, sigma, CLOGLOG_CC_TOL)
2235    {
2236        return (
2237            out.clamp(0.0, 1.0),
2238            IntegratedExpectationMode::ExactSpecialFunction,
2239        );
2240    }
2241    if let Ok(out) = cloglog_survival_gamma_reference(mu, sigma) {
2242        return (
2243            out.clamp(0.0, 1.0),
2244            IntegratedExpectationMode::ExactSpecialFunction,
2245        );
2246    }
2247    (
2248        survival_posterior_mean_quadrature(mu, sigma),
2249        IntegratedExpectationMode::QuadratureFallback,
2250    )
2251}
2252
2253#[inline]
2254fn lognormal_laplace_term_controlled(
2255    ctx: &QuadratureContext,
2256    z: f64,
2257    mu: f64,
2258    sigma: f64,
2259) -> (f64, IntegratedExpectationMode) {
2260    // Shared shift reduction for the full lognormal-Laplace family:
2261    //
2262    //   L(z; mu, sigma) = E[exp(-z exp(eta))]
2263    //                   = E[exp(-exp(eta + ln z))]
2264    //                   = L(1; mu + ln z, sigma),
2265    //
2266    // because eta + ln z is still Gaussian with the same variance and shifted
2267    // mean. This is the cleanest way to see that cloglog and Royston-Parmar
2268    // survival are really querying one object:
2269    //
2270    //   survival first moment:
2271    //     E[exp(-exp(eta))] = L(1; mu, sigma)
2272    //
2273    //   survival second moment:
2274    //     E[exp(-2 exp(eta))] = L(2; mu, sigma) = L(1; mu + ln 2, sigma)
2275    //
2276    //   cloglog mean:
2277    //     E[1 - exp(-exp(eta))] = 1 - L(1; mu, sigma)
2278    //
2279    //   cloglog derivative:
2280    //     d/dmu E[1 - exp(-exp(eta))]
2281    //       = exp(mu + sigma^2/2) L(1; mu + sigma^2, sigma).
2282    //
2283    // So this helper is the canonical scalar boundary, and every higher-level
2284    // quantity is just algebra on top of it.
2285    if !(z.is_finite() && z > 0.0) {
2286        return (f64::NAN, IntegratedExpectationMode::QuadratureFallback);
2287    }
2288    lognormal_laplace_unit_term_shared(ctx, mu + z.ln(), sigma)
2289}
2290
2291#[inline]
2292pub(crate) fn lognormal_laplace_unit_term_shared(
2293    ctx: &QuadratureContext,
2294    shifted_mu: f64,
2295    sigma: f64,
2296) -> (f64, IntegratedExpectationMode) {
2297    cloglog_survival_term_controlled(ctx, shifted_mu, sigma)
2298}
2299
2300/// Log-space twin of `lognormal_laplace_unit_term_shared`: returns
2301/// `ln L(1; shifted_mu, σ) = ln S(shifted_mu, σ)`. Used by the lognormal kernel
2302/// bundle so kernel log-magnitudes survive value-space underflow (#798).
2303#[inline]
2304pub fn lognormal_laplace_unit_log_term_shared(
2305    ctx: &QuadratureContext,
2306    shifted_mu: f64,
2307    sigma: f64,
2308) -> (f64, IntegratedExpectationMode) {
2309    cloglog_log_survival_term_controlled(ctx, shifted_mu, sigma)
2310}
2311
2312#[inline]
2313fn cloglog_survivalsecond_moment_controlled(
2314    ctx: &QuadratureContext,
2315    mu: f64,
2316    sigma: f64,
2317) -> (f64, IntegratedExpectationMode) {
2318    // If
2319    //
2320    //   S(mu, sigma) = E[exp(-exp(eta))],   eta ~ N(mu, sigma^2),
2321    //
2322    // then the survival second moment is
2323    //
2324    //   E[S(eta)^2]
2325    //     = E[exp(-2 exp(eta))]
2326    //     = L(2; mu, sigma)
2327    //     = L(1; mu + ln 2, sigma)
2328    //     = S(mu + ln 2, sigma).
2329    //
2330    // So the exact same routed scalar evaluator can be reused by shifting mu
2331    // by ln 2 rather than introducing a second quadrature-specific code path.
2332    lognormal_laplace_term_controlled(ctx, 2.0, mu, sigma)
2333}
2334
2335#[inline]
2336fn cloglog_survival_pair_controlled(
2337    ctx: &QuadratureContext,
2338    mu: f64,
2339    sigma: f64,
2340) -> (
2341    (f64, IntegratedExpectationMode),
2342    (f64, IntegratedExpectationMode),
2343) {
2344    let shiftedmu = mu + sigma * sigma;
2345
2346    // For the exact/control branches it is numerically cleaner if the mean
2347    // path S(mu, sigma) and the derivative path S(mu + sigma^2, sigma) are
2348    // evaluated on the same backend whenever possible. That keeps
2349    //
2350    //   mean       = 1 - S(mu, sigma)
2351    //   dmean/dmu  = exp(mu + sigma^2/2) * S(mu + sigma^2, sigma)
2352    //
2353    // on one approximation surface instead of mixing, for example, CC for the
2354    // base term with Gamma for the shifted term. If a paired attempt fails, we
2355    // fall back to the usual independent routing.
2356    if cloglog_survival_miles_is_reliable(mu, sigma)
2357        && cloglog_survival_miles_is_reliable(shiftedmu, sigma)
2358        && let (Ok(base), Ok(shifted)) = (
2359            cloglog_survival_miles(mu, sigma),
2360            cloglog_survival_miles(shiftedmu, sigma),
2361        )
2362    {
2363        return (
2364            (
2365                base.clamp(0.0, 1.0),
2366                IntegratedExpectationMode::ExactSpecialFunction,
2367            ),
2368            (
2369                shifted.clamp(0.0, 1.0),
2370                IntegratedExpectationMode::ExactSpecialFunction,
2371            ),
2372        );
2373    }
2374
2375    if cloglog_should_prefer_cc(mu, sigma, CLOGLOG_CC_TOL)
2376        && cloglog_should_prefer_cc(shiftedmu, sigma, CLOGLOG_CC_TOL)
2377        && let (Ok(base), Ok(shifted)) = (
2378            cloglog_survival_cc(ctx, mu, sigma, CLOGLOG_CC_TOL),
2379            cloglog_survival_cc(ctx, shiftedmu, sigma, CLOGLOG_CC_TOL),
2380        )
2381    {
2382        return (
2383            (
2384                base.clamp(0.0, 1.0),
2385                IntegratedExpectationMode::ExactSpecialFunction,
2386            ),
2387            (
2388                shifted.clamp(0.0, 1.0),
2389                IntegratedExpectationMode::ExactSpecialFunction,
2390            ),
2391        );
2392    }
2393
2394    if let (Ok(base), Ok(shifted)) = (
2395        cloglog_survival_gamma_reference(mu, sigma),
2396        cloglog_survival_gamma_reference(shiftedmu, sigma),
2397    ) {
2398        return (
2399            (
2400                base.clamp(0.0, 1.0),
2401                IntegratedExpectationMode::ExactSpecialFunction,
2402            ),
2403            (
2404                shifted.clamp(0.0, 1.0),
2405                IntegratedExpectationMode::ExactSpecialFunction,
2406            ),
2407        );
2408    }
2409
2410    (
2411        cloglog_survival_term_controlled(ctx, mu, sigma),
2412        cloglog_survival_term_controlled(ctx, shiftedmu, sigma),
2413    )
2414}
2415
2416#[inline]
2417fn cloglog_mean_from_survival(survival: f64) -> f64 {
2418    let survival = survival.clamp(0.0, 1.0);
2419    if survival > 0.5 {
2420        // When S is close to 1, form 1 - S as -expm1(log S) so the rare-event
2421        // cloglog probability keeps its low-order bits instead of collapsing to
2422        // zero through cancellation. Algebraically:
2423        //
2424        //   1 - S = -expm1(log S),
2425        //
2426        // since exp(log S) = S. This is the stable way to recover the cloglog
2427        // mean in the regime mu << 0 where S is extremely close to 1 and the
2428        // desired probability is tiny.
2429        -survival.ln().exp_m1()
2430    } else {
2431        1.0 - survival
2432    }
2433}
2434
2435#[inline]
2436fn cloglog_shift_identity_derivative(mu: f64, sigma: f64, shifted_survival: f64) -> f64 {
2437    // Exact Gaussian tilting identity:
2438    //
2439    //   d/dmu E[1 - exp(-exp(eta))]
2440    //     = exp(mu + sigma^2 / 2) * S(mu + sigma^2, sigma),
2441    //
2442    // where S is the shared survival term. The product is evaluated in the log
2443    // domain because exp(mu + sigma^2/2) can overflow even though the final
2444    // derivative is always bounded:
2445    //
2446    //   0 <= E[exp(eta - exp(eta))] <= sup_x x e^{-x} = e^{-1}.
2447    //
2448    // So any positive overflow is numerical, not mathematical, and can be
2449    // safely capped at the exact global upper bound.
2450    if !(mu.is_finite() && sigma.is_finite()) || shifted_survival <= 0.0 {
2451        return 0.0;
2452    }
2453    cloglog_shift_identity_derivative_log(mu, sigma, shifted_survival.ln())
2454}
2455
2456/// Log-domain form of [`cloglog_shift_identity_derivative`] that takes
2457/// `ln S(mu + sigma^2, sigma)` directly.
2458///
2459/// This is the underflow-safe path: when the shifted survival `S(mu+σ²,σ)` is
2460/// below the f64 floor (large σ, #798), the value form above sees
2461/// `shifted_survival == 0` and returns a spurious zero slope, whereas the
2462/// genuine derivative `exp(mu + σ²/2) · S(mu+σ², σ)` is finite and O(1) because
2463/// the huge prefix exactly compensates the tiny survival. Carrying the survival
2464/// as a log keeps that cancellation exact.
2465#[inline]
2466fn cloglog_shift_identity_derivative_log(mu: f64, sigma: f64, log_shifted_survival: f64) -> f64 {
2467    if !(mu.is_finite() && sigma.is_finite()) || log_shifted_survival == f64::NEG_INFINITY {
2468        return 0.0;
2469    }
2470    let log_derivative = mu + 0.5 * sigma * sigma + log_shifted_survival;
2471    let upper = 1.0 / std::f64::consts::E;
2472    if !log_derivative.is_finite() {
2473        // Mathematically bounded by sup_x x·e^{−x} = e^{−1}; any overflow here
2474        // is purely numerical (the exp(mu+σ²/2) prefix), so cap at the bound.
2475        return upper;
2476    }
2477    safe_exp(log_derivative).clamp(0.0, upper)
2478}
2479
2480#[inline]
2481fn log_half_erfc_stable(u: f64) -> f64 {
2482    // Stable log(0.5 * erfc(u)).
2483    //
2484    // In the Miles series, each term contains
2485    //   exp(mu n + 0.5 sigma^2 n^2) * 0.5 * erfc(u_n).
2486    // For large positive u_n, erfc(u_n) underflows long before the *whole*
2487    // term becomes negligible, so we switch to
2488    //   erfc(u) = exp(-u^2) erfcx(u),  u > 0,
2489    // and carry the -u^2 contribution in log-space. For u <= 0, erfc(u) is
2490    // O(1): 0.5*erfc(u) = Phi(-u*sqrt(2)), so log(0.5*erfc(u)) is exactly
2491    // normal_logcdf(-u*sqrt(2)) — reusing the full-precision (libm-erfc) primitive
2492    // instead of statrs::erfc (which carried ~1e-10 relative error, #932).
2493    if u > 0.0 {
2494        -u * u + (0.5 * erfcx_nonnegative(u)).ln()
2495    } else {
2496        normal_logcdf(-u * SQRT_2)
2497    }
2498}
2499
2500/// True when the Miles erfc-gated lognormal-Laplace series can be summed in
2501/// f64 without the alternating-cancellation transient destroying the result.
2502///
2503/// Background. The Miles representation of `S(mu, sigma) = E[exp(-exp(eta))]`
2504/// is a real series
2505///
2506/// ```text
2507///   S = Σ_{n≥0} (-1)^n / n! · exp(mu n + ½ σ² n²) · ½ erfc(u_n)
2508///   u_n = (mu − ln α + σ² n) / (√2 σ).
2509/// ```
2510///
2511/// For large positive `u_n`, `½ erfc(u_n)` decays like `exp(-u_n²) / (√(2π) u_n)`.
2512/// Substituting the asymptotic and using Stirling on `ln n!`, the log of the
2513/// `n`-th term magnitude reduces to
2514///
2515/// ```text
2516///   log|t_n| ≈ n ln(α / n) + n − ½ (mu − ln α)² / σ²,
2517/// ```
2518///
2519/// which is maximised at `n = α` with peak value
2520///
2521/// ```text
2522///   peak_log(mu, sigma) = α − ½ (mu − ln α)² / σ².
2523/// ```
2524///
2525/// The series telescopes down to `S ∈ [0, 1]`, so once `peak_log` exceeds
2526/// `CLOGLOG_MILES_PEAK_LOG_MAX` the partial sums sweep through magnitudes
2527/// `exp(peak_log)` and the residual after cancellation no longer carries enough
2528/// f64 precision to be a reliable answer. The fixed `|mu|/σ ≥ 3` gate that
2529/// used to guard the Miles call was a proxy for "tail-dominated", not for
2530/// "series reliable" — it misses precisely the band `mu ∈ (ln α − √(2 α σ²),
2531/// ln α + √(2 α σ²))` where the peak term blows up, and several values of mu in
2532/// that band were already empirically returning a clamped-but-wrong S
2533/// (e.g. the latent cloglog inverse link was producing μ = 0.94 instead of
2534/// μ ≈ 0.07 for `mu ≈ −3.2, σ = 1`).
2535///
2536/// This predicate replaces the proxy with the actual reliability condition.
2537/// Callers should drop to the CC / Gamma / GHQ branches when it returns false,
2538/// which all evaluate the same survival object on numerically stable grids.
2539#[inline]
2540fn cloglog_survival_miles_is_reliable(mu: f64, sigma: f64) -> bool {
2541    if !(mu.is_finite() && sigma.is_finite() && sigma > 0.0) {
2542        return false;
2543    }
2544    let alpha_ln = CLOGLOG_MILES_ALPHA.ln();
2545    let shifted = mu - alpha_ln;
2546    let peak_log = CLOGLOG_MILES_ALPHA - 0.5 * shifted * shifted / (sigma * sigma);
2547    peak_log.is_finite() && peak_log <= CLOGLOG_MILES_PEAK_LOG_MAX
2548}
2549
2550fn cloglog_survival_miles(mu: f64, sigma: f64) -> Result<f64, EstimationError> {
2551    // This routine approximates the survival term
2552    //
2553    //   S(mu, sigma) = E[exp(-exp(eta))],   eta ~ N(mu, sigma^2),
2554    //
2555    // using the Miles erfc-gated lognormal-Laplace series. Writing
2556    //
2557    //   X = exp(eta) ~ LogNormal(mu, sigma^2),
2558    //
2559    // S is the Laplace transform E[exp(-X)] evaluated at 1. Theorem-3 gives a
2560    // real series of the form
2561    //
2562    //   S(mu, sigma)
2563    //     = sum_{n>=0} (-1)^n / n!
2564    //         * exp(mu n + 0.5 sigma^2 n^2)
2565    //         * 0.5 * erfc(u_n)
2566    //
2567    //   u_n = (mu - ln(alpha) + sigma^2 n) / (sqrt(2) sigma).
2568    //
2569    // The erfc factor gates the lognormal moment term so that the product stays
2570    // finite in the tail-dominated regime where this backend is used. We only
2571    // evaluate S here; the caller forms
2572    //
2573    //   mean = 1 - S
2574    //   dmean/dmu = exp(mu + sigma^2 / 2) * S(mu + sigma^2, sigma).
2575    //
2576    // Pairwise accumulation is used because the series alternates in sign and
2577    // consecutive terms partially cancel. Grouping terms before the truncation
2578    // check produces a materially more stable stopping rule than looking at
2579    // individual terms in isolation.
2580    let alpha_ln = CLOGLOG_MILES_ALPHA.ln();
2581    let mut s_sum = 0.0_f64;
2582    let mut stable_pairs = 0usize;
2583
2584    for pair_start in (0..CLOGLOG_MILES_MAX_TERMS).step_by(2) {
2585        let mut pair_s = 0.0_f64;
2586        for n in pair_start..(pair_start + 2).min(CLOGLOG_MILES_MAX_TERMS) {
2587            let nf = n as f64;
2588            let sign = if n % 2 == 0 { 1.0 } else { -1.0 };
2589            let base_log = nf * mu + 0.5 * sigma * sigma * nf * nf
2590                - statrs::function::gamma::ln_gamma(nf + 1.0);
2591            let u = (mu - alpha_ln + sigma * sigma * nf) / (SQRT_2 * sigma);
2592            let log_half_erfc = log_half_erfc_stable(u);
2593            let term_log = base_log + log_half_erfc;
2594            if term_log > QUADRATURE_EXP_LOG_MAX {
2595                crate::bail_invalid_estim!("Miles cloglog series term exceeded finite exp range");
2596            }
2597            let term = sign * safe_exp(term_log);
2598            pair_s += term;
2599        }
2600        s_sum += pair_s;
2601
2602        let s_scale = s_sum.abs().max(1.0);
2603        if pair_s.abs() <= 2e-15 * s_scale {
2604            stable_pairs += 1;
2605            if stable_pairs >= SERIES_CONSECUTIVE_SMALL_TERMS {
2606                if s_sum.is_finite() && (-1e-10..=1.0 + 1e-10).contains(&s_sum) {
2607                    return Ok(s_sum.clamp(0.0, 1.0));
2608                }
2609                break;
2610            }
2611        } else {
2612            stable_pairs = 0;
2613        }
2614    }
2615
2616    Err(EstimationError::InvalidInput(
2617        "Miles cloglog series did not converge safely".to_string(),
2618    ))
2619}
2620
2621fn cloglog_survival_cc(
2622    ctx: &QuadratureContext,
2623    mu: f64,
2624    sigma: f64,
2625    tol: f64,
2626) -> Result<f64, EstimationError> {
2627    if !(mu.is_finite() && sigma.is_finite() && sigma > 0.0 && tol.is_finite() && tol > 0.0) {
2628        crate::bail_invalid_estim!(
2629            "CC cloglog backend requires finite mu, positive sigma, and positive tolerance"
2630                .to_string(),
2631        );
2632    }
2633
2634    // Real-line representation of the shared survival term
2635    //
2636    //   S(mu, sigma)
2637    //     = 1/sqrt(2pi) ∫ exp(-t^2/2 - exp(mu + sigma t)) dt.
2638    //
2639    // This comes directly from eta = mu + sigma Z with Z ~ N(0,1):
2640    //
2641    //   S(mu, sigma)
2642    //     = E[exp(-exp(eta))]
2643    //     = 1/sqrt(2pi) ∫ exp(-t^2/2) exp(-exp(mu + sigma t)) dt.
2644    //
2645    // We truncate to [-A, A] using the Gaussian tail bound
2646    //
2647    //   ∫_{|t| > A} phi(t) exp(-exp(mu + sigma t)) dt <= 2 Phi(-A),
2648    //
2649    // then apply Clenshaw-Curtis on [-A, A] after the affine map t = A x.
2650    //
2651    // In other words, we first turn the infinite Gaussian expectation into a
2652    // finite interval problem, and then use a Chebyshev/cosine-grid quadrature
2653    // rule on that bounded interval. The mapped nodes x_j = cos(j pi / (n - 1))
2654    // become t_j = A x_j, which concentrates points near ±A where the cosine
2655    // grid is densest.
2656    //
2657    // The node count comes from the same Bernstein-ellipse style bound used in
2658    // the math notes: we pick a conservative ellipse height y so the mapped
2659    // integrand stays analytic in a strip where the double exponential term
2660    // does not blow up, convert that to rho, and request enough cosine nodes to
2661    // make the quadrature remainder smaller than the quadrature slice of `tol`.
2662    //
2663    // This mirrors the standard Clenshaw-Curtis error picture: after mapping to
2664    // [-1, 1], analyticity in a Bernstein ellipse controls how fast the
2665    // Chebyshev coefficients decay, which in turn controls how many cosine-grid
2666    // nodes are needed.
2667    //
2668    // So this backend is still computing the exact same scalar object as the
2669    // Gamma/Mellin-Barnes path below; it just works on the real integral rather
2670    // than the Bromwich contour representation.
2671    let p_tail = (tol / 8.0).clamp(1e-300, 0.25);
2672    let a = gam_math::probability::standard_normal_quantile(p_tail)
2673        .map(|z| -z)
2674        .unwrap_or(8.0)
2675        .max(1.0);
2676    let n = cloglog_cc_required_nodes(mu, sigma, tol)?;
2677    if n > CLOGLOG_CC_NODE_CAP {
2678        crate::bail_invalid_estim!("CC cloglog backend requires too many nodes");
2679    }
2680
2681    let rule = ctx.clenshaw_curtis_n(n);
2682    let inv_sqrt_2pi = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
2683    let mut sum = 0.0_f64;
2684    let mut c = 0.0_f64;
2685    for (&x, &w) in rule.nodes.iter().zip(rule.weights.iter()) {
2686        let t = a * x;
2687        let u = mu + sigma * t;
2688        let e = safe_exp(u);
2689        let w0 = (-0.5 * t * t).exp() * inv_sqrt_2pi;
2690        let yk = w * w0 * (-e).exp() - c;
2691        let tk = sum + yk;
2692        c = (tk - sum) - yk;
2693        sum = tk;
2694    }
2695
2696    let survival = (a * sum).clamp(0.0, 1.0);
2697    if !survival.is_finite() {
2698        crate::bail_invalid_estim!("CC cloglog backend produced non-finite values");
2699    }
2700    Ok(survival)
2701}
2702
2703#[inline]
2704fn complex_add(a: Complex, b: Complex) -> Complex {
2705    Complex {
2706        re: a.re + b.re,
2707        im: a.im + b.im,
2708    }
2709}
2710
2711#[inline]
2712fn complex_sub(a: Complex, b: Complex) -> Complex {
2713    Complex {
2714        re: a.re - b.re,
2715        im: a.im - b.im,
2716    }
2717}
2718
2719#[inline]
2720fn complexmul(a: Complex, b: Complex) -> Complex {
2721    Complex {
2722        re: a.re * b.re - a.im * b.im,
2723        im: a.re * b.im + a.im * b.re,
2724    }
2725}
2726
2727#[inline]
2728fn complex_div(a: Complex, b: Complex) -> Complex {
2729    let den = (b.re * b.re + b.im * b.im).max(1e-300);
2730    Complex {
2731        re: (a.re * b.re + a.im * b.im) / den,
2732        im: (a.im * b.re - a.re * b.im) / den,
2733    }
2734}
2735
2736#[inline]
2737fn complex_abs(z: Complex) -> f64 {
2738    z.re.hypot(z.im)
2739}
2740
2741#[inline]
2742fn complex_ln(z: Complex) -> Complex {
2743    Complex {
2744        re: complex_abs(z).ln(),
2745        im: z.im.atan2(z.re),
2746    }
2747}
2748
2749#[inline]
2750fn complex_exp(z: Complex) -> Complex {
2751    let e = z.re.exp();
2752    Complex {
2753        re: e * z.im.cos(),
2754        im: e * z.im.sin(),
2755    }
2756}
2757
2758#[inline]
2759fn complex_sin(z: Complex) -> Complex {
2760    Complex {
2761        re: z.re.sin() * z.im.cosh(),
2762        im: z.re.cos() * z.im.sinh(),
2763    }
2764}
2765
2766fn complex_log_gamma_lanczos(z: Complex) -> Complex {
2767    // Reference-quality complex log-gamma for the Mellin-Barnes cloglog
2768    // backend. This is the key special-function primitive for evaluating the
2769    // exact Bromwich integral of the lognormal Laplace transform.
2770    const G: f64 = 7.0;
2771    const COEFFS: [f64; 9] = [
2772        0.999_999_999_999_809_9,
2773        676.520_368_121_885_1,
2774        -1_259.139_216_722_402_8,
2775        771.323_428_777_653_1,
2776        -176.615_029_162_140_6,
2777        12.507_343_278_686_905,
2778        -0.138_571_095_265_720_12,
2779        9.984_369_578_019_572e-6,
2780        1.505_632_735_149_311_6e-7,
2781    ];
2782
2783    if z.re < 0.5 {
2784        let piz = Complex {
2785            re: std::f64::consts::PI * z.re,
2786            im: std::f64::consts::PI * z.im,
2787        };
2788        let one_minusz = Complex {
2789            re: 1.0 - z.re,
2790            im: -z.im,
2791        };
2792        return complex_sub(
2793            complex_sub(
2794                Complex {
2795                    re: std::f64::consts::PI.ln(),
2796                    im: 0.0,
2797                },
2798                complex_ln(complex_sin(piz)),
2799            ),
2800            complex_log_gamma_lanczos(one_minusz),
2801        );
2802    }
2803
2804    let z1 = Complex {
2805        re: z.re - 1.0,
2806        im: z.im,
2807    };
2808    let mut x = Complex {
2809        re: COEFFS[0],
2810        im: 0.0,
2811    };
2812    for (i, c) in COEFFS.iter().enumerate().skip(1) {
2813        x = complex_add(
2814            x,
2815            complex_div(
2816                Complex { re: *c, im: 0.0 },
2817                Complex {
2818                    re: z1.re + i as f64,
2819                    im: z1.im,
2820                },
2821            ),
2822        );
2823    }
2824    let t = Complex {
2825        re: z1.re + G + 0.5,
2826        im: z1.im,
2827    };
2828    complex_add(
2829        complex_add(
2830            Complex {
2831                re: 0.5 * (2.0 * std::f64::consts::PI).ln(),
2832                im: 0.0,
2833            },
2834            complexmul(
2835                Complex {
2836                    re: z1.re + 0.5,
2837                    im: z1.im,
2838                },
2839                complex_ln(t),
2840            ),
2841        ),
2842        complex_sub(complex_ln(x), t),
2843    )
2844}
2845
2846// `cloglog_posterior_meanwith_deriv_gamma_reference` is a test reference
2847// implementation; it lives inside `mod tests` below.
2848
2849fn cloglog_survival_gamma_reference(mu: f64, sigma: f64) -> Result<f64, EstimationError> {
2850    if !(mu.is_finite() && sigma.is_finite()) || sigma <= 0.0 {
2851        crate::bail_invalid_estim!(
2852            "Gamma cloglog reference backend requires finite mu and positive sigma"
2853        );
2854    }
2855
2856    // Exact Mellin-Barnes / Bromwich representation for the lognormal Laplace
2857    // transform at lambda = 1:
2858    //
2859    //   S(mu, sigma)
2860    //     = E[exp(-exp(eta))],   eta ~ N(mu, sigma^2)
2861    //     = 1/pi ∫_0^∞ Re[
2862    //         Γ(k + i t)
2863    //         exp(0.5 sigma^2 (k + i t)^2 - mu (k + i t))
2864    //       ] dt,
2865    //
2866    // with k > 0 fixed on the Bromwich line. This comes from the exact
2867    // Mellin-Barnes identity
2868    //
2869    //   L(z; mu, sigma)
2870    //     = (1 / 2πi) ∫ Γ(s) z^{-s} exp(-mu s + 0.5 sigma^2 s^2) ds,
2871    //
2872    // specialized to z = 1 and then rewritten on the vertical line s = k + it.
2873    // This is the exact special-function representation of the same
2874    // lognormal-Laplace object used by the large-sigma central cloglog path.
2875    //
2876    // The surrounding cloglog code only asks this routine for S itself. The
2877    // final outputs are reconstructed outside via
2878    //
2879    //   mean       = 1 - S(mu, sigma)
2880    //   dmean/dmu  = exp(mu + sigma^2 / 2) * S(mu + sigma^2, sigma),
2881    //
2882    // so the integral below remains a scalar survival evaluator.
2883    //
2884    // Numerically, the Γ(k + i t) factor decays like exp(-pi t / 2) on the
2885    // vertical line, while the Gaussian factor contributes exp(-0.5 sigma^2
2886    // t^2) in magnitude. That makes the tail rapidly damped, which is why a
2887    // fixed composite Simpson rule on [0, T] is adequate here despite the
2888    // complex oscillation.
2889    let n = (CLOGLOG_GAMMA_T_MAX_REF / CLOGLOG_GAMMA_H_REF).round() as usize;
2890    let n = if n.is_multiple_of(2) { n } else { n + 1 };
2891    let h = CLOGLOG_GAMMA_T_MAX_REF / n as f64;
2892
2893    let eval = |t: f64| -> f64 {
2894        let z = Complex {
2895            re: CLOGLOG_GAMMA_K_REF,
2896            im: t,
2897        };
2898        let log_gamma = complex_log_gamma_lanczos(z);
2899        let z_sq = complexmul(z, z);
2900        let exponent = complex_sub(
2901            complex_add(
2902                log_gamma,
2903                Complex {
2904                    re: 0.5 * sigma * sigma * z_sq.re,
2905                    im: 0.5 * sigma * sigma * z_sq.im,
2906                },
2907            ),
2908            Complex {
2909                re: mu * z.re,
2910                im: mu * z.im,
2911            },
2912        );
2913        complex_exp(exponent).re
2914    };
2915
2916    let f0 = eval(0.0);
2917    let fn_ = eval(CLOGLOG_GAMMA_T_MAX_REF);
2918    let mut sum_s = f0 + fn_;
2919    for i in 1..n {
2920        let t = i as f64 * h;
2921        let fi = eval(t);
2922        let w = if i % 2 == 0 { 2.0 } else { 4.0 };
2923        sum_s += w * fi;
2924    }
2925    let sval = ((h / 3.0) * sum_s / std::f64::consts::PI).clamp(0.0, 1.0);
2926    if !sval.is_finite() {
2927        crate::bail_invalid_estim!("Gamma cloglog reference backend produced non-finite values");
2928    }
2929    Ok(sval)
2930}
2931
2932pub(crate) fn cloglog_posterior_meanwith_deriv_controlled(
2933    ctx: &QuadratureContext,
2934    mu: f64,
2935    sigma: f64,
2936) -> IntegratedMeanDerivative {
2937    // Final production routing for integrated cloglog under Gaussian latent
2938    // uncertainty.
2939    //
2940    // The target quantity is always
2941    //
2942    //   mean(mu, sigma)      = E[1 - exp(-exp(eta))]
2943    //   dmean/dmu            = E[exp(eta - exp(eta))],
2944    //   eta ~ N(mu, sigma^2).
2945    //
2946    // Different numerical regimes favor different exact or controlled
2947    // representations of the same lognormal-Laplace object:
2948    //
2949    // 1. sigma ~= 0
2950    //    The Gaussian collapses to a point mass, so the ordinary inverse link
2951    //    and its pointwise derivative are exact.
2952    //
2953    // 2. small sigma
2954    //    The heat-kernel / Taylor expansion is efficient and tracks the true
2955    //    integrated mean and derivative to high accuracy without invoking any
2956    //    special-function machinery.
2957    //
2958    // 3. explicit extreme-input asymptotics
2959    //    Large negative mu uses the exact lognormal first moment of exp(eta),
2960    //    very large positive mu saturates to 1, and very large sigma uses the
2961    //    transition split at eta ~= 0.
2962    //
2963    // 4. tail-dominated large sigma
2964    //    The Miles erfc-gated series is efficient because the erfc gate keeps
2965    //    the alternating lognormal-moment series short and numerically tame.
2966    //
2967    // 5. central large sigma
2968    //    The exact Mellin-Barnes / Gamma inversion is preferred, because the
2969    //    Miles series is no longer the best-behaved production representation.
2970    //
2971    // 6. final escape
2972    //    GHQ remains only as a numerical fallback if the chosen special-
2973    //    function backend returns a non-finite or non-converged result.
2974    //
2975    // This layered routing is the real conclusion of the math work: not one
2976    // magical universal formula, but one shared mathematical target with the
2977    // best evaluator chosen for each regime.
2978    // ApproxKind: NumericalApproximation — each branch carries its own
2979    // backward error bound (special-function or GHQ tail), composed so the
2980    // worst-case error is the max across regimes; documented at each branch.
2981    if !(mu.is_finite() && sigma.is_finite()) || sigma <= CLOGLOG_SIGMA_DEGENERATE {
2982        return IntegratedMeanDerivative {
2983            // cloglog_mean_exact uses expm1 to avoid 1 − 1 cancellation for
2984            // all mu, and handles exp overflow (mu > 709) via expm1(-∞) = −1.
2985            mean: cloglog_mean_exact(mu),
2986            // cloglog_mean_d1_exact is exact for all finite mu: it detects
2987            // intermediate exp overflow and returns 0.0 (correct limit).
2988            dmean_dmu: cloglog_mean_d1_exact(mu),
2989            mode: IntegratedExpectationMode::ExactClosedForm,
2990        };
2991    }
2992    if sigma >= CLOGLOG_LARGE_SIGMA_ASYMPTOTIC_MIN {
2993        // Large-σ regime: form both the mean and the location derivative from the
2994        // accurate, underflow-safe log-space survival. This replaces the biased
2995        // step-model split (mean too low by 2–7%, #799) and, because the
2996        // derivative is exp(μ + σ²/2)·S(μ+σ², σ) carried entirely in log space,
2997        // it no longer collapses to zero when S(μ+σ², σ) underflows (#798).
2998        let (log_base, base_mode) = cloglog_log_survival_term_controlled(ctx, mu, sigma);
2999        let (log_shift, shift_mode) =
3000            cloglog_log_survival_term_controlled(ctx, mu + sigma * sigma, sigma);
3001        // mean = 1 − S = −expm1(ln S), stable for S near both 0 and 1.
3002        let mean = (-log_base.exp_m1()).clamp(0.0, 1.0);
3003        let dmean = cloglog_shift_identity_derivative_log(mu, sigma, log_shift);
3004        return IntegratedMeanDerivative {
3005            mean,
3006            dmean_dmu: dmean.max(0.0),
3007            mode: worse_integrated_expectation_mode(base_mode, shift_mode),
3008        };
3009    }
3010    let candidate = if sigma < CLOGLOG_SIGMA_TAYLOR_MAX {
3011        cloglog_small_sigma_taylor(mu, sigma)
3012    } else if let Some(out) = cloglog_extreme_asymptotic(mu, sigma) {
3013        out
3014    } else {
3015        let ((survival, mode), (shifted_survival, shifted_mode)) =
3016            cloglog_survival_pair_controlled(ctx, mu, sigma);
3017        if matches!(mode, IntegratedExpectationMode::QuadratureFallback)
3018            || matches!(shifted_mode, IntegratedExpectationMode::QuadratureFallback)
3019        {
3020            return cloglog_posterior_meanwith_deriv_quadrature(mu, sigma);
3021        }
3022        let mean = cloglog_mean_from_survival(survival);
3023        let dmean = cloglog_shift_identity_derivative(mu, sigma, shifted_survival);
3024        let mode = if matches!(mode, IntegratedExpectationMode::ControlledAsymptotic)
3025            || matches!(
3026                shifted_mode,
3027                IntegratedExpectationMode::ControlledAsymptotic
3028            ) {
3029            IntegratedExpectationMode::ControlledAsymptotic
3030        } else {
3031            mode
3032        };
3033        IntegratedMeanDerivative {
3034            mean,
3035            dmean_dmu: dmean.max(0.0),
3036            mode,
3037        }
3038    };
3039    // Safety-net drift check with loose tolerances — see logit comment.
3040    // Skip for large-sigma ControlledAsymptotic: the transition approximation
3041    // legitimately diverges from 128-node GHQ by more than the drift tolerance
3042    // at sigma >= CLOGLOG_LARGE_SIGMA_ASYMPTOTIC_MIN, and the asymptotic is the
3043    // trusted answer in that regime.
3044    if matches!(
3045        candidate.mode,
3046        IntegratedExpectationMode::ControlledAsymptotic
3047    ) && sigma >= CLOGLOG_LARGE_SIGMA_ASYMPTOTIC_MIN
3048    {
3049        return candidate;
3050    }
3051    let ghq = cloglog_posterior_meanwith_deriv_quadrature(mu, sigma);
3052    // Drift tolerances tightened on the derivative absolute floor: the
3053    // Taylor truncation diverges in the positive-saturation band
3054    // (e.g. mu ~ 3, sigma ~ 0.24) because f^(n) grow near the saturation
3055    // transition, and a 1e-5 absolute floor hid 1e-6-scale errors there.
3056    // GHQ is the trusted evaluator in that regime because f' has negligible
3057    // probability outside the Gaussian 3-sigma window.
3058    if integrated_mean_derivative_drift_exceeds(&candidate, &ghq, 1e-6, 1e-4, 1e-7, 1e-3) {
3059        ghq
3060    } else {
3061        candidate
3062    }
3063}
3064
3065pub fn integrated_inverse_link_mean_and_derivative(
3066    quadctx: &QuadratureContext,
3067    link: LinkFunction,
3068    mu: f64,
3069    sigma: f64,
3070) -> Result<IntegratedMeanDerivative, EstimationError> {
3071    // Canonical dispatcher for Gaussian-uncertain inverse-link expectations.
3072    //
3073    // Every integrated PIRLS and posterior-mean prediction path reduces to the
3074    // same mathematical contract:
3075    //
3076    //   input:
3077    //     eta ~ N(mu, sigma^2)
3078    //
3079    //   output:
3080    //     mean      = E[g^{-1}(eta)]
3081    //     dmean/dmu = E[(g^{-1})'(eta)].
3082    //
3083    // The location-family identity
3084    //
3085    //   d/dmu E[f(mu + sigma Z)] = E[f'(mu + sigma Z)]
3086    //
3087    // is what makes this sufficient for PIRLS. Once a link-specific backend can
3088    // return these two quantities, the generic Fisher weight and working
3089    // response formulas do not care whether they came from:
3090    //
3091    // - exact closed form,
3092    // - exact/special-function evaluation,
3093    // - a controlled asymptotic approximation,
3094    // - or GHQ fallback.
3095    //
3096    // Centralizing the routing here keeps all link-specific special-function
3097    // mathematics local to one module instead of leaking into PIRLS or
3098    // prediction code.
3099    match link {
3100        LinkFunction::Log => {
3101            let (mean, saturated) = safe_expwith_saturation(mu + 0.5 * sigma * sigma);
3102            Ok(IntegratedMeanDerivative {
3103                mean,
3104                dmean_dmu: mean,
3105                mode: if saturated {
3106                    IntegratedExpectationMode::ControlledAsymptotic
3107                } else {
3108                    IntegratedExpectationMode::ExactClosedForm
3109                },
3110            })
3111        }
3112        LinkFunction::Probit => Ok(probit_posterior_meanwith_deriv_exact(mu, sigma)),
3113        LinkFunction::Logit => logit_posterior_meanwith_deriv_controlled(mu, sigma),
3114        LinkFunction::CLogLog => Ok(cloglog_posterior_meanwith_deriv_controlled(quadctx, mu, sigma)),
3115        LinkFunction::LogLog | LinkFunction::Cauchit => {
3116            // The outer arm restricts `link` to exactly these two variants.
3117            let component = if matches!(link, LinkFunction::LogLog) {
3118                LinkComponent::LogLog
3119            } else {
3120                LinkComponent::Cauchit
3121            };
3122            let (mean, dmean_dmu, _, _) = integrate_normal_ghq_adaptive(quadctx, mu, sigma, |x| {
3123                component_point_jet(component, x)
3124            });
3125            Ok(IntegratedMeanDerivative {
3126                mean,
3127                dmean_dmu,
3128                mode: if sigma <= 1e-10 {
3129                    IntegratedExpectationMode::ExactClosedForm
3130                } else {
3131                    IntegratedExpectationMode::QuadratureFallback
3132                },
3133            })
3134        }
3135        LinkFunction::Sas => Err(EstimationError::InvalidInput(
3136            "state-less integrated SAS moments are unsupported; use SAS-aware prediction APIs with explicit (epsilon, log_delta)".to_string(),
3137        )),
3138        LinkFunction::BetaLogistic => Err(EstimationError::InvalidInput(
3139            "state-less integrated Beta-Logistic moments are unsupported; use link-aware prediction APIs with explicit (delta, epsilon)".to_string(),
3140        )),
3141        LinkFunction::Identity => Ok(IntegratedMeanDerivative {
3142            mean: mu,
3143            dmean_dmu: 1.0,
3144            mode: IntegratedExpectationMode::ExactClosedForm,
3145        }),
3146    }
3147}
3148
3149#[inline]
3150pub fn integrated_inverse_link_jet(
3151    quadctx: &QuadratureContext,
3152    link: LinkFunction,
3153    mu: f64,
3154    sigma: f64,
3155) -> Result<IntegratedInverseLinkJet, EstimationError> {
3156    match link {
3157        LinkFunction::Log => {
3158            let (mean, saturated) = safe_expwith_saturation(mu + 0.5 * sigma * sigma);
3159            Ok(IntegratedInverseLinkJet {
3160                mean,
3161                d1: mean,
3162                d2: mean,
3163                d3: mean,
3164                mode: if saturated {
3165                    IntegratedExpectationMode::ControlledAsymptotic
3166                } else {
3167                    IntegratedExpectationMode::ExactClosedForm
3168                },
3169            })
3170        }
3171        LinkFunction::Probit => Ok(integrated_probit_jet(mu, sigma)),
3172        LinkFunction::Logit => {
3173            if sigma > LOGIT_JET_GHQ_SIGMA_MAX {
3174                // Wide σ: Gauss-Hermite under-resolves the localized
3175                // sigmoid^(k) integrands. Integrate accurately and reuse the
3176                // scalar backend's mean/d1 so the two entry points agree (#571).
3177                return logit_wide_sigma_jet(mu, sigma);
3178            }
3179            // Integrate the full pointwise jet directly: the same
3180            // Gauss-Hermite nodes evaluate component_point_jet, so mean/d1
3181            // retain their scalar-backend values to rounding and d2/d3 are
3182            // recovered analytically from the node-level jet.
3183            let (mean, d1, d2, d3) = integrate_normal_ghq_adaptive(quadctx, mu, sigma, |x| {
3184                component_point_jet(LinkComponent::Logit, x)
3185            });
3186            let mode = if sigma <= 1e-10 {
3187                IntegratedExpectationMode::ExactClosedForm
3188            } else {
3189                // Mirror the scalar controlled-path mode when it accepts the
3190                // exact erfcx backend; otherwise the node-sum above is a
3191                // quadrature fallback.
3192                match logit_posterior_meanwith_deriv_controlled(mu, sigma) {
3193                    Ok(scalar) => scalar.mode,
3194                    Err(_) => IntegratedExpectationMode::QuadratureFallback,
3195                }
3196            };
3197            Ok(IntegratedInverseLinkJet {
3198                mean,
3199                d1: d1.max(0.0),
3200                d2,
3201                d3,
3202                mode,
3203            })
3204        }
3205        LinkFunction::CLogLog => {
3206            validate_latent_cloglog_inputs(mu, sigma)?;
3207            Ok(integrated_cloglog_inverse_link_jet_controlled(
3208                quadctx, mu, sigma,
3209            ))
3210        }
3211        LinkFunction::LogLog | LinkFunction::Cauchit => {
3212            // The outer arm restricts `link` to exactly these two variants.
3213            let component = if matches!(link, LinkFunction::LogLog) {
3214                LinkComponent::LogLog
3215            } else {
3216                LinkComponent::Cauchit
3217            };
3218            let (mean, d1, d2, d3) = integrate_normal_ghq_adaptive(quadctx, mu, sigma, |x| {
3219                component_point_jet(component, x)
3220            });
3221            Ok(IntegratedInverseLinkJet {
3222                mean,
3223                d1,
3224                d2,
3225                d3,
3226                mode: if sigma <= 1e-10 {
3227                    IntegratedExpectationMode::ExactClosedForm
3228                } else {
3229                    IntegratedExpectationMode::QuadratureFallback
3230                },
3231            })
3232        }
3233        LinkFunction::Sas => Err(EstimationError::InvalidInput(
3234            "state-less integrated SAS jet is unsupported; use SAS-aware prediction APIs with explicit (epsilon, log_delta)".to_string(),
3235        )),
3236        LinkFunction::BetaLogistic => Err(EstimationError::InvalidInput(
3237            "state-less integrated Beta-Logistic jet is unsupported; use link-aware prediction APIs with explicit (delta, epsilon)".to_string(),
3238        )),
3239        LinkFunction::Identity => Ok(IntegratedInverseLinkJet {
3240            mean: mu,
3241            d1: 1.0,
3242            d2: 0.0,
3243            d3: 0.0,
3244            mode: IntegratedExpectationMode::ExactClosedForm,
3245        }),
3246    }
3247}
3248
3249/// Accurate logistic-normal jet for the wide-σ regime (σ > `LOGIT_JET_GHQ_SIGMA_MAX`)
3250/// where Gauss–Hermite can no longer resolve the localized inverse-link
3251/// derivatives. `mean` and `d1` are taken verbatim from the scalar controlled
3252/// backend, so the scalar dispatcher and the jet return identical values at
3253/// wide σ (the #571 scalar-vs-jet disagreement is closed by construction rather
3254/// than by two independent quadratures merely agreeing to a tolerance). `d2`
3255/// and `d3` are the location-derivatives `E[sigmoid''(η)]`, `E[sigmoid'''(η)]`,
3256/// integrated by the same adaptive-Simpson rule the scalar path trusts as its
3257/// reference (resolved to ~1e-12 at every σ). The returned `mode` mirrors the
3258/// scalar backend's mode for the regime.
3259#[inline]
3260fn logit_wide_sigma_jet(mu: f64, sigma: f64) -> Result<IntegratedInverseLinkJet, EstimationError> {
3261    let scalar = logit_posterior_meanwith_deriv_controlled(mu, sigma)?;
3262    let d2 = integrate_normal_adaptive(mu, sigma, |x| {
3263        component_point_jet(LinkComponent::Logit, x).2
3264    });
3265    let d3 = integrate_normal_adaptive(mu, sigma, |x| {
3266        component_point_jet(LinkComponent::Logit, x).3
3267    });
3268    Ok(IntegratedInverseLinkJet {
3269        mean: scalar.mean,
3270        d1: scalar.dmean_dmu.max(0.0),
3271        d2,
3272        d3,
3273        mode: scalar.mode,
3274    })
3275}
3276
3277#[inline]
3278fn sas_point_jet(x: f64, epsilon: f64, log_delta: f64) -> (f64, f64, f64, f64) {
3279    let jet = sas_inverse_link_jet(x, epsilon, log_delta)
3280        .expect("normal quadrature nodes must be finite");
3281    (jet.mu, jet.d1, jet.d2, jet.d3)
3282}
3283
3284#[inline]
3285fn beta_logistic_point_jet(x: f64, log_shape_center: f64, epsilon: f64) -> (f64, f64, f64, f64) {
3286    let jet = beta_logistic_inverse_link_jet(x, log_shape_center, epsilon);
3287    (jet.mu, jet.d1, jet.d2, jet.d3)
3288}
3289
3290#[inline]
3291fn worse_integrated_expectation_mode(
3292    lhs: IntegratedExpectationMode,
3293    rhs: IntegratedExpectationMode,
3294) -> IntegratedExpectationMode {
3295    if lhs.rank() >= rhs.rank() { lhs } else { rhs }
3296}
3297
3298#[inline]
3299fn integrated_scalar_drift_exceeds(
3300    candidate: f64,
3301    reference: f64,
3302    abs_tol: f64,
3303    rel_tol: f64,
3304) -> bool {
3305    if !(candidate.is_finite() && reference.is_finite()) {
3306        return true;
3307    }
3308    (candidate - reference).abs() > abs_tol.max(rel_tol * reference.abs().max(candidate.abs()))
3309}
3310
3311#[inline]
3312fn integrated_mean_derivative_drift_exceeds(
3313    candidate: &IntegratedMeanDerivative,
3314    reference: &IntegratedMeanDerivative,
3315    mean_abs_tol: f64,
3316    mean_rel_tol: f64,
3317    deriv_abs_tol: f64,
3318    deriv_rel_tol: f64,
3319) -> bool {
3320    integrated_scalar_drift_exceeds(candidate.mean, reference.mean, mean_abs_tol, mean_rel_tol)
3321        || integrated_scalar_drift_exceeds(
3322            candidate.dmean_dmu,
3323            reference.dmean_dmu,
3324            deriv_abs_tol,
3325            deriv_rel_tol,
3326        )
3327}
3328
3329#[inline]
3330fn component_point_jet(component: LinkComponent, x: f64) -> (f64, f64, f64, f64) {
3331    // Keep the point-mass quadrature kernels wired to the same inverse-link
3332    // implementation used by mixture links and survival residual distributions.
3333    let jet = component_inverse_link_jet(component, x);
3334    (jet.mu, jet.d1, jet.d2, jet.d3)
3335}
3336
3337#[inline]
3338fn integrated_mixture_component_jet(
3339    ctx: &QuadratureContext,
3340    component: LinkComponent,
3341    mu: f64,
3342    sigma: f64,
3343) -> IntegratedInverseLinkJet {
3344    // Use the same controlled backends (exact/asymptotic/special-function)
3345    // as integrated_inverse_link_jet so that the same (mu, sigma) always
3346    // produces identical d2, d3 regardless of whether it enters as a
3347    // standalone link or as a mixture component.
3348    match component {
3349        LinkComponent::Logit => integrated_inverse_link_jet(ctx, LinkFunction::Logit, mu, sigma)
3350            .unwrap_or_else(|error| {
3351                log::debug!(
3352                    "integrated logit jet at (mu={mu}, sigma={sigma}) fell back to GHQ: {error}"
3353                );
3354                integrated_logit_jet_ghq(ctx, mu, sigma)
3355            }),
3356        LinkComponent::Probit => integrated_probit_jet(mu, sigma),
3357        LinkComponent::CLogLog => integrated_cloglog_inverse_link_jet_controlled(ctx, mu, sigma),
3358        LinkComponent::LogLog | LinkComponent::Cauchit => {
3359            let (mean, d1, d2, d3) = integrate_normal_ghq_adaptive(ctx, mu, sigma, |x| {
3360                component_point_jet(component, x)
3361            });
3362            IntegratedInverseLinkJet {
3363                mean,
3364                d1: d1.max(0.0),
3365                d2,
3366                d3,
3367                mode: if sigma <= 1e-10 {
3368                    IntegratedExpectationMode::ExactClosedForm
3369                } else {
3370                    IntegratedExpectationMode::QuadratureFallback
3371                },
3372            }
3373        }
3374    }
3375}
3376
3377#[inline]
3378fn integrated_mixture_jet(
3379    ctx: &QuadratureContext,
3380    mu: f64,
3381    sigma: f64,
3382    mixture_state: &MixtureLinkState,
3383) -> Result<IntegratedInverseLinkJet, EstimationError> {
3384    // Solver-facing integrated jets in this module store eta/location
3385    // derivatives only: (mean, d/dmu, d²/dmu², d³/dmu³). Closed-form sigma
3386    // derivatives for the probit component are therefore not threaded here
3387    // because the integrated PIRLS callers do not consume them.
3388    if mixture_state.components.is_empty() {
3389        crate::bail_invalid_estim!(
3390            "integrated mixture-link jet requires at least one blended component"
3391        );
3392    }
3393    if mixture_state.components.len() != mixture_state.pi.len() {
3394        crate::bail_invalid_estim!(
3395            "integrated mixture-link jet requires matching component and weight counts"
3396        );
3397    }
3398
3399    // Validation note: compare against a 128-point direct GHQ reference for
3400    // blended(logit,probit) over w in {0.0, 0.3, 0.5, 0.7, 1.0} and
3401    // (mu, sigma) on (-5, 5) x (0.1, 10). The w=0 probit case should match
3402    // Phi(mu / sqrt(1 + sigma^2)) to machine precision.
3403    let mut mean = 0.0_f64;
3404    let mut d1 = 0.0_f64;
3405    let mut d2 = 0.0_f64;
3406    let mut d3 = 0.0_f64;
3407    let mut mode = IntegratedExpectationMode::ExactClosedForm;
3408    let mut saw_positive_weight = false;
3409
3410    for (&component, &weight) in mixture_state.components.iter().zip(mixture_state.pi.iter()) {
3411        if weight <= 0.0 {
3412            continue;
3413        }
3414        let jet = integrated_mixture_component_jet(ctx, component, mu, sigma);
3415        mean += weight * jet.mean;
3416        d1 += weight * jet.d1;
3417        d2 += weight * jet.d2;
3418        d3 += weight * jet.d3;
3419        if jet.mode.rank() > mode.rank() {
3420            mode = jet.mode;
3421        }
3422        saw_positive_weight = true;
3423    }
3424
3425    if !saw_positive_weight {
3426        crate::bail_invalid_estim!(
3427            "integrated mixture-link jet requires at least one positive component weight"
3428                .to_string(),
3429        );
3430    }
3431
3432    Ok(IntegratedInverseLinkJet {
3433        mean,
3434        d1: d1.max(0.0),
3435        d2,
3436        d3,
3437        mode,
3438    })
3439}
3440
3441#[inline]
3442fn integrated_sas_jet_ghq(
3443    ctx: &QuadratureContext,
3444    mu: f64,
3445    sigma: f64,
3446    sas_state: &SasLinkState,
3447) -> IntegratedInverseLinkJet {
3448    let (mean, d1, d2, d3) = integrate_normal_ghq_adaptive(ctx, mu, sigma, |x| {
3449        sas_point_jet(x, sas_state.epsilon, sas_state.log_delta)
3450    });
3451    IntegratedInverseLinkJet {
3452        mean,
3453        d1: d1.max(0.0),
3454        d2,
3455        d3,
3456        mode: if sigma <= 1e-10 {
3457            IntegratedExpectationMode::ExactClosedForm
3458        } else {
3459            IntegratedExpectationMode::QuadratureFallback
3460        },
3461    }
3462}
3463
3464#[inline]
3465fn integrated_beta_logistic_jet_ghq(
3466    ctx: &QuadratureContext,
3467    mu: f64,
3468    sigma: f64,
3469    beta_state: &SasLinkState,
3470) -> IntegratedInverseLinkJet {
3471    let (mean, d1, d2, d3) = integrate_normal_ghq_adaptive(ctx, mu, sigma, |x| {
3472        beta_logistic_point_jet(x, beta_state.log_delta, beta_state.epsilon)
3473    });
3474    IntegratedInverseLinkJet {
3475        mean,
3476        d1: d1.max(0.0),
3477        d2,
3478        d3,
3479        mode: if sigma <= 1e-10 {
3480            IntegratedExpectationMode::ExactClosedForm
3481        } else {
3482            IntegratedExpectationMode::QuadratureFallback
3483        },
3484    }
3485}
3486
3487/// State-aware inverse-link jet integration for Gaussian-uncertain predictors.
3488#[inline]
3489pub fn integrated_inverse_link_jetwith_state(
3490    quadctx: &QuadratureContext,
3491    link: LinkFunction,
3492    mu: f64,
3493    sigma: f64,
3494    mixture_link_state: Option<&MixtureLinkState>,
3495    sas_link_state: Option<&SasLinkState>,
3496) -> Result<IntegratedInverseLinkJet, EstimationError> {
3497    if let Some(state) = mixture_link_state {
3498        return integrated_mixture_jet(quadctx, mu, sigma, state);
3499    }
3500    if matches!(link, LinkFunction::Sas) {
3501        let sas = sas_link_state.ok_or_else(|| {
3502            EstimationError::InvalidInput(
3503                "state-less integrated SAS jet is unsupported; explicit SasLinkState is required"
3504                    .to_string(),
3505            )
3506        })?;
3507        return Ok(integrated_sas_jet_ghq(quadctx, mu, sigma, sas));
3508    }
3509    if matches!(link, LinkFunction::BetaLogistic) {
3510        let state = sas_link_state.ok_or_else(|| {
3511            EstimationError::InvalidInput(
3512                "state-less integrated Beta-Logistic jet is unsupported; explicit link state is required"
3513                    .to_string(),
3514            )
3515        })?;
3516        return Ok(integrated_beta_logistic_jet_ghq(quadctx, mu, sigma, state));
3517    }
3518    integrated_inverse_link_jet(quadctx, link, mu, sigma)
3519}
3520
3521/// Family-level integration dispatcher for Gaussian-uncertain linear predictors.
3522///
3523/// This is the solver-facing boundary: callers request integrated moments/jet by
3524/// family, while all link-specific quadrature/special-function routing stays in
3525/// the quadrature domain.
3526///
3527/// Family and scale metadata are resolved atomically from `likelihood`; a
3528/// Gamma/Tweedie response without its required scalar, or any duplicated
3529/// family/metadata scalar that disagrees, is rejected before integration.
3530#[inline]
3531pub fn integrated_family_moments_jet(
3532    quadctx: &QuadratureContext,
3533    likelihood: &GlmLikelihoodSpec,
3534    eta: f64,
3535    se_eta: f64,
3536) -> Result<IntegratedMomentsJet, EstimationError> {
3537    const PROB_EPS: f64 = 1e-12;
3538    if !(eta.is_finite() && (-700.0..=700.0).contains(&eta)) {
3539        crate::bail_invalid_estim!(
3540            "integrated moments eta must be finite and within [-700, 700]; got {eta}"
3541        );
3542    }
3543    let e = eta;
3544    let se = se_eta.max(0.0);
3545    // Pull parameterized link state from the spec itself; these helpers return
3546    // `None` for `InverseLink::Standard`, which is what every non-parameterized
3547    // dispatch arm expects.
3548    let resolved_scale = likelihood
3549        .resolved_scale()
3550        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
3551    let spec = &likelihood.spec;
3552    let mixture_link_state: Option<&MixtureLinkState> = spec.link.mixture_state();
3553    let sas_link_state: Option<&SasLinkState> = spec.link.sas_state();
3554    match &spec.response {
3555        ResponseFamily::Binomial => match &spec.link {
3556            InverseLink::Standard(StandardLink::Logit) => {
3557                let jet = integrated_inverse_link_jet(quadctx, LinkFunction::Logit, e, se)?;
3558                let mean = jet.mean;
3559                Ok(IntegratedMomentsJet {
3560                    mean,
3561                    variance: (mean * (1.0 - mean)).max(PROB_EPS),
3562                    d1: jet.d1,
3563                    d2: jet.d2,
3564                    d3: jet.d3,
3565                    mode: jet.mode,
3566                })
3567            }
3568            InverseLink::Standard(StandardLink::Probit) => {
3569                let jet = integrated_inverse_link_jet(quadctx, LinkFunction::Probit, e, se)?;
3570                let mean = jet.mean;
3571                Ok(IntegratedMomentsJet {
3572                    mean,
3573                    variance: (mean * (1.0 - mean)).max(PROB_EPS),
3574                    d1: jet.d1,
3575                    d2: jet.d2,
3576                    d3: jet.d3,
3577                    mode: jet.mode,
3578                })
3579            }
3580            InverseLink::Standard(StandardLink::CLogLog) => {
3581                let jet = integrated_inverse_link_jet(quadctx, LinkFunction::CLogLog, e, se)?;
3582                let mean = jet.mean;
3583                Ok(IntegratedMomentsJet {
3584                    mean,
3585                    variance: (mean * (1.0 - mean)).max(PROB_EPS),
3586                    d1: jet.d1,
3587                    d2: jet.d2,
3588                    d3: jet.d3,
3589                    mode: jet.mode,
3590                })
3591            }
3592            InverseLink::LatentCLogLog(_) => Err(EstimationError::InvalidInput(
3593                "Binomial+LatentCLogLog integrated moments require an explicit latent cloglog inverse-link state"
3594                    .to_string(),
3595            )),
3596            InverseLink::Sas(_) => {
3597                let jet = integrated_inverse_link_jetwith_state(
3598                    quadctx,
3599                    LinkFunction::Sas,
3600                    e,
3601                    se,
3602                    mixture_link_state,
3603                    sas_link_state,
3604                )?;
3605                let mean = jet.mean;
3606                Ok(IntegratedMomentsJet {
3607                    mean,
3608                    variance: (mean * (1.0 - mean)).max(PROB_EPS),
3609                    d1: jet.d1,
3610                    d2: jet.d2,
3611                    d3: jet.d3,
3612                    mode: jet.mode,
3613                })
3614            }
3615            InverseLink::BetaLogistic(_) => {
3616                let jet = integrated_inverse_link_jetwith_state(
3617                    quadctx,
3618                    LinkFunction::BetaLogistic,
3619                    e,
3620                    se,
3621                    mixture_link_state,
3622                    sas_link_state,
3623                )?;
3624                let mean = jet.mean;
3625                Ok(IntegratedMomentsJet {
3626                    mean,
3627                    variance: (mean * (1.0 - mean)).max(PROB_EPS),
3628                    d1: jet.d1,
3629                    d2: jet.d2,
3630                    d3: jet.d3,
3631                    mode: jet.mode,
3632                })
3633            }
3634            InverseLink::Mixture(state) => {
3635                let jet = integrated_mixture_jet(quadctx, e, se, &state)?;
3636                let mean = jet.mean;
3637                Ok(IntegratedMomentsJet {
3638                    mean,
3639                    variance: (mean * (1.0 - mean)).max(PROB_EPS),
3640                    d1: jet.d1,
3641                    d2: jet.d2,
3642                    d3: jet.d3,
3643                    mode: jet.mode,
3644                })
3645            }
3646            InverseLink::Standard(other) => Err(EstimationError::InvalidInput(format!(
3647                "Binomial response paired with unsupported standard link {other:?} for integrated moments"
3648            ))),
3649        },
3650        ResponseFamily::Gaussian => {
3651            let variance = resolved_scale
3652                .gaussian_phi()
3653                .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
3654            Ok(IntegratedMomentsJet {
3655                mean: e,
3656                variance,
3657                d1: 1.0,
3658                d2: 0.0,
3659                d3: 0.0,
3660                mode: IntegratedExpectationMode::ExactClosedForm,
3661            })
3662        }
3663        ResponseFamily::RoystonParmar => {
3664            let jet = integrated_inverse_link_jetwith_state(
3665                quadctx,
3666                LinkFunction::CLogLog,
3667                e,
3668                se,
3669                mixture_link_state,
3670                sas_link_state,
3671            )?;
3672            let mean = (1.0 - jet.mean).clamp(0.0, 1.0);
3673            Ok(IntegratedMomentsJet {
3674                mean,
3675                variance: (mean * (1.0 - mean)).max(PROB_EPS),
3676                d1: -jet.d1,
3677                d2: -jet.d2,
3678                d3: -jet.d3,
3679                mode: jet.mode,
3680            })
3681        }
3682        ResponseFamily::Beta { .. } => {
3683            let precision = resolved_scale
3684                .beta_precision()
3685                .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
3686            let jet = integrated_inverse_link_jet(quadctx, LinkFunction::Logit, e, se)?;
3687            let mean = jet.mean.clamp(PROB_EPS, 1.0 - PROB_EPS);
3688            Ok(IntegratedMomentsJet {
3689                mean,
3690                variance: (mean * (1.0 - mean) / (1.0 + precision)).max(PROB_EPS),
3691                d1: jet.d1,
3692                d2: jet.d2,
3693                d3: jet.d3,
3694                mode: jet.mode,
3695            })
3696        }
3697        ResponseFamily::Poisson
3698        | ResponseFamily::Tweedie { .. }
3699        | ResponseFamily::NegativeBinomial { .. }
3700        | ResponseFamily::Gamma => {
3701            // Log-normal MGF: E[exp(η)] = exp(e + s²/2)
3702            // d/de = exp(e + s²/2)   (same as the mean)
3703            // d²/de² = exp(e + s²/2)
3704            // d³/de³ = exp(e + s²/2)
3705            let s2 = se * se;
3706            let (mean, saturated) = safe_expwith_saturation(e + 0.5 * s2);
3707            // Observation-model variance at the integrated mean `m`, by family:
3708            //   Poisson:           Var = m                 (φ ≡ 1, pinned by mean)
3709            //   Tweedie(p):        Var = φ · m^p           (φ from `scale`)
3710            //   NegativeBinomial:  Var = m + m² / theta    (φ ≡ 1, overdispersion in theta)
3711            //   Gamma (shape k):   Var = m² / k = φ · m²   (k from `scale`, φ = 1/k)
3712            // The Tweedie φ and Gamma shape are genuine free dispersion parameters
3713            // (see `LikelihoodScaleMetadata`), so they are read from `scale` rather
3714            // than assumed unit. A Gamma/Tweedie response whose `scale` does not
3715            // carry the dispersion is a metadata bug and is rejected, not silently
3716            // collapsed to φ = 1 (issue #953).
3717            let variance = match &spec.response {
3718                ResponseFamily::Poisson => mean,
3719                ResponseFamily::Tweedie { p } => {
3720                    let phi = resolved_scale
3721                        .tweedie_phi()
3722                        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
3723                    phi * mean.powf(*p)
3724                }
3725                ResponseFamily::NegativeBinomial { .. } => {
3726                    let theta = resolved_scale.negative_binomial_theta().map_err(|error| {
3727                        EstimationError::InvalidInput(error.to_string())
3728                    })?;
3729                    mean + mean * mean / theta
3730                }
3731                ResponseFamily::Gamma => {
3732                    let phi = resolved_scale
3733                        .gamma_phi()
3734                        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
3735                    phi * mean * mean
3736                }
3737                // Unreachable: this match arm is only entered for the four families
3738                // in the enclosing `Poisson | Tweedie | NegativeBinomial | Gamma`
3739                // pattern, all handled above.
3740                other => {
3741                    return Err(EstimationError::InvalidInput(format!(
3742                        "integrated log-normal moments reached unexpected family {other:?}"
3743                    )));
3744                }
3745            };
3746            if !(variance.is_finite() && variance >= 0.0) {
3747                return Err(EstimationError::InvalidInput(format!(
3748                    "integrated {} variance is not representable: {variance:?}",
3749                    spec.response.name()
3750                )));
3751            }
3752            Ok(IntegratedMomentsJet {
3753                mean,
3754                variance,
3755                d1: mean,
3756                d2: mean,
3757                d3: mean,
3758                mode: if saturated {
3759                    IntegratedExpectationMode::ControlledAsymptotic
3760                } else {
3761                    IntegratedExpectationMode::ExactClosedForm
3762                },
3763            })
3764        }
3765    }
3766}
3767
3768/// Batch version of logit_posterior_meanwith_deriv.
3769/// Returns (mu_array, dmu_array)
3770pub fn logit_posterior_meanwith_deriv_batch(
3771    ctx: &QuadratureContext,
3772    eta: &ndarray::Array1<f64>,
3773    se_eta: &ndarray::Array1<f64>,
3774) -> Result<(ndarray::Array1<f64>, ndarray::Array1<f64>), EstimationError> {
3775    use rayon::iter::{IntoParallelIterator, ParallelIterator};
3776    let n = eta.len();
3777    // Per-row quadrature integration is independent across rows.
3778    let pairs: Result<Vec<(f64, f64)>, _> = (0..n)
3779        .into_par_iter()
3780        .map(|i| {
3781            let integrated = integrated_inverse_link_mean_and_derivative(
3782                ctx,
3783                LinkFunction::Logit,
3784                eta[i],
3785                se_eta[i],
3786            )?;
3787            Ok::<_, EstimationError>((integrated.mean, integrated.dmean_dmu))
3788        })
3789        .collect();
3790    let pairs = pairs?;
3791    let mut mu = ndarray::Array1::<f64>::zeros(n);
3792    let mut dmu = ndarray::Array1::<f64>::zeros(n);
3793    for (i, (m, d)) in pairs.into_iter().enumerate() {
3794        mu[i] = m;
3795        dmu[i] = d;
3796    }
3797
3798    Ok((mu, dmu))
3799}
3800
3801/// Computes posterior mean probabilities for a batch of predictions.
3802///
3803/// This is the vectorized version of `logit_posterior_mean`.
3804pub fn logit_posterior_mean_batch(
3805    ctx: &QuadratureContext,
3806    eta: &ndarray::Array1<f64>,
3807    se_eta: &ndarray::Array1<f64>,
3808) -> Result<ndarray::Array1<f64>, EstimationError> {
3809    use rayon::iter::{IntoParallelIterator, ParallelIterator};
3810    let n = eta.len();
3811    let values: Result<Vec<f64>, EstimationError> = (0..n)
3812        .into_par_iter()
3813        .map(|i| {
3814            integrated_inverse_link_mean_and_derivative(ctx, LinkFunction::Logit, eta[i], se_eta[i])
3815                .map(|integrated| integrated.mean)
3816        })
3817        .collect();
3818    Ok(ndarray::Array1::from_vec(values?))
3819}
3820
3821pub trait GhqValue: Sized {
3822    fn zero() -> Self;
3823    fn addweighted(&mut self, weight: f64, value: Self);
3824    fn scale(self, factor: f64) -> Self;
3825}
3826
3827impl GhqValue for f64 {
3828    #[inline]
3829    fn zero() -> Self {
3830        0.0
3831    }
3832
3833    #[inline]
3834    fn addweighted(&mut self, weight: f64, value: Self) {
3835        *self += weight * value;
3836    }
3837
3838    #[inline]
3839    fn scale(self, factor: f64) -> Self {
3840        self * factor
3841    }
3842}
3843
3844impl GhqValue for (f64, f64) {
3845    #[inline]
3846    fn zero() -> Self {
3847        (0.0, 0.0)
3848    }
3849
3850    #[inline]
3851    fn addweighted(&mut self, weight: f64, value: Self) {
3852        self.0 += weight * value.0;
3853        self.1 += weight * value.1;
3854    }
3855
3856    #[inline]
3857    fn scale(self, factor: f64) -> Self {
3858        (self.0 * factor, self.1 * factor)
3859    }
3860}
3861
3862impl GhqValue for (f64, f64, f64, f64) {
3863    #[inline]
3864    fn zero() -> Self {
3865        (0.0, 0.0, 0.0, 0.0)
3866    }
3867
3868    #[inline]
3869    fn addweighted(&mut self, weight: f64, value: Self) {
3870        self.0 += weight * value.0;
3871        self.1 += weight * value.1;
3872        self.2 += weight * value.2;
3873        self.3 += weight * value.3;
3874    }
3875
3876    #[inline]
3877    fn scale(self, factor: f64) -> Self {
3878        (
3879            self.0 * factor,
3880            self.1 * factor,
3881            self.2 * factor,
3882            self.3 * factor,
3883        )
3884    }
3885}
3886
3887impl GhqValue for (f64, f64, f64, f64, f64, f64) {
3888    #[inline]
3889    fn zero() -> Self {
3890        (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
3891    }
3892
3893    #[inline]
3894    fn addweighted(&mut self, weight: f64, value: Self) {
3895        self.0 += weight * value.0;
3896        self.1 += weight * value.1;
3897        self.2 += weight * value.2;
3898        self.3 += weight * value.3;
3899        self.4 += weight * value.4;
3900        self.5 += weight * value.5;
3901    }
3902
3903    #[inline]
3904    fn scale(self, factor: f64) -> Self {
3905        (
3906            self.0 * factor,
3907            self.1 * factor,
3908            self.2 * factor,
3909            self.3 * factor,
3910            self.4 * factor,
3911            self.5 * factor,
3912        )
3913    }
3914}
3915
3916#[inline]
3917fn integrate_normal_ghq_adaptive<F, R>(ctx: &QuadratureContext, eta: f64, se_eta: f64, f: F) -> R
3918where
3919    F: Fn(f64) -> R,
3920    R: GhqValue,
3921{
3922    if se_eta < 1e-10 {
3923        return f(eta);
3924    }
3925    let n = adaptive_point_count_from_sd(se_eta.abs());
3926    with_gh_nodesweights(ctx, n, |nodes, weights| {
3927        let scale = SQRT_2 * se_eta;
3928        let mut sum = R::zero();
3929        for i in 0..n {
3930            sum.addweighted(weights[i], f(eta + scale * nodes[i]));
3931        }
3932        sum.scale(1.0 / std::f64::consts::PI.sqrt())
3933    })
3934}
3935
3936#[inline]
3937fn integrated_probit_jet(mu: f64, sigma: f64) -> IntegratedInverseLinkJet {
3938    // If Z ~ N(mu, sigma^2), E[Phi(Z)] = Phi(mu / sqrt(1+sigma^2)).
3939    // This identity is exact at sigma=0 too, so there is no degenerate branch
3940    // and no reason to project mu. `hypot` keeps the scale finite for every
3941    // finite sigma. Once the Gaussian density underflows, all represented
3942    // derivatives are the exact zero tail limit; return before forming z^2.
3943    let s = sigma.hypot(1.0);
3944    let z = mu / s;
3945    let mean = gam_math::probability::normal_cdf(z);
3946    let pdf = gam_math::probability::normal_pdf(z);
3947    if pdf == 0.0 {
3948        return IntegratedInverseLinkJet {
3949            mean,
3950            d1: 0.0,
3951            d2: 0.0,
3952            d3: 0.0,
3953            mode: IntegratedExpectationMode::ExactClosedForm,
3954        };
3955    }
3956    IntegratedInverseLinkJet {
3957        mean,
3958        d1: pdf / s,
3959        d2: -z * pdf / (s * s),
3960        d3: (z * z - 1.0) * pdf / (s * s * s),
3961        mode: IntegratedExpectationMode::ExactClosedForm,
3962    }
3963}
3964
3965#[inline]
3966fn integrated_logit_jet_ghq(
3967    ctx: &QuadratureContext,
3968    mu: f64,
3969    sigma: f64,
3970) -> IntegratedInverseLinkJet {
3971    let (mean, d1, d2, d3) = integrate_normal_ghq_adaptive(ctx, mu, sigma, |x| {
3972        component_point_jet(LinkComponent::Logit, x)
3973    });
3974    IntegratedInverseLinkJet {
3975        mean,
3976        d1: d1.max(0.0),
3977        d2,
3978        d3,
3979        mode: if sigma <= 1e-10 {
3980            IntegratedExpectationMode::ExactClosedForm
3981        } else {
3982            IntegratedExpectationMode::QuadratureFallback
3983        },
3984    }
3985}
3986
3987#[inline]
3988fn cloglog_inverse_link_controlled_values(
3989    ctx: &QuadratureContext,
3990    mu: f64,
3991    sigma: f64,
3992    max_order: usize,
3993) -> ([f64; 6], IntegratedExpectationMode) {
3994    assert!(max_order <= 5);
3995    if sigma <= 1e-10 {
3996        let (mean, d1, d2, d3, d4, d5) = cloglog_point_jet5(mu);
3997        return (
3998            [mean, d1, d2, d3, d4, d5],
3999            IntegratedExpectationMode::ExactClosedForm,
4000        );
4001    }
4002
4003    let (k, log_k0, mode) = latent_cloglog_kernel_terms(ctx, mu, sigma, max_order);
4004    let mut values = [0.0; 6];
4005    values[0] = if log_k0.is_finite() {
4006        -log_k0.exp_m1()
4007    } else {
4008        1.0
4009    };
4010    values[1] = k[1].max(0.0);
4011    if sigma > CLOGLOG_JET_MOMENT_SIGMA_MAX {
4012        if max_order >= 2 {
4013            values[2] = integrate_normal_adaptive(mu, sigma, |x| cloglog_point_jet5(x).2);
4014        }
4015        if max_order >= 3 {
4016            values[3] = integrate_normal_adaptive(mu, sigma, |x| cloglog_point_jet5(x).3);
4017        }
4018        if max_order >= 4 {
4019            values[4] = integrate_normal_adaptive(mu, sigma, |x| cloglog_point_jet5(x).4);
4020        }
4021        if max_order >= 5 {
4022            values[5] = integrate_normal_adaptive(mu, sigma, |x| cloglog_point_jet5(x).5);
4023        }
4024        return (
4025            values,
4026            worse_integrated_expectation_mode(mode, IntegratedExpectationMode::QuadratureFallback),
4027        );
4028    }
4029    if max_order >= 2 {
4030        values[2] = k[1] - k[2];
4031    }
4032    if max_order >= 3 {
4033        values[3] = k[1] - 3.0 * k[2] + k[3];
4034    }
4035    if max_order >= 4 {
4036        values[4] = k[1] - 7.0 * k[2] + 6.0 * k[3] - k[4];
4037    }
4038    if max_order >= 5 {
4039        values[5] = k[1] - 15.0 * k[2] + 25.0 * k[3] - 10.0 * k[4] + k[5];
4040    }
4041    (values, mode)
4042}
4043
4044#[inline]
4045pub(crate) fn latent_cloglog_inverse_link_jet5_controlled(
4046    ctx: &QuadratureContext,
4047    mu: f64,
4048    sigma: f64,
4049) -> IntegratedInverseLinkJet5 {
4050    let (values, mode) = cloglog_inverse_link_controlled_values(ctx, mu, sigma, 5);
4051    IntegratedInverseLinkJet5 {
4052        mean: values[0],
4053        d1: values[1],
4054        d2: values[2],
4055        d3: values[3],
4056        d4: values[4],
4057        d5: values[5],
4058        mode,
4059    }
4060}
4061
4062/// Fifth-order latent-cloglog inverse-link jet.
4063///
4064/// Relocated here from `families::survival::lognormal_kernel` (#1135): this is
4065/// the public face of the latent-cloglog link jet, and its analytic backend
4066/// (`latent_cloglog_inverse_link_jet5_controlled`) already lives in this
4067/// quadrature module. Hosting the wrapper here lets the `solver` link layer
4068/// (`mixture_link`, `pirls`) name it via `crate::quadrature::*` instead of
4069/// importing *up* into `families::survival`. `lognormal_kernel` re-exports these
4070/// names so the in-family callers keep working.
4071#[derive(Clone, Copy, Debug)]
4072pub struct LatentCLogLogJet5 {
4073    pub mean: f64,
4074    pub d1: f64,
4075    pub d2: f64,
4076    pub d3: f64,
4077    pub d4: f64,
4078    pub d5: f64,
4079    pub mode: IntegratedExpectationMode,
4080}
4081
4082pub fn latent_cloglog_jet5(
4083    quadctx: &QuadratureContext,
4084    eta: f64,
4085    sigma: f64,
4086) -> Result<LatentCLogLogJet5, EstimationError> {
4087    validate_latent_cloglog_inputs(eta, sigma)?;
4088    // Authoritative latent cloglog backend:
4089    //
4090    // - mean through d5 are all derived from the same lognormal-Laplace kernel
4091    //   terms K_{k,1}(eta, sigma),
4092    // - every derivative order uses the same routed analytic kernel backend.
4093    let jet = latent_cloglog_inverse_link_jet5_controlled(quadctx, eta, sigma);
4094    Ok(LatentCLogLogJet5 {
4095        mean: jet.mean,
4096        d1: jet.d1,
4097        d2: jet.d2,
4098        d3: jet.d3,
4099        d4: jet.d4,
4100        d5: jet.d5,
4101        mode: jet.mode,
4102    })
4103}
4104
4105#[inline]
4106pub fn latent_cloglog_inverse_link_jet(
4107    quadctx: &QuadratureContext,
4108    eta: f64,
4109    sigma: f64,
4110) -> Result<IntegratedInverseLinkJet, EstimationError> {
4111    let jet = latent_cloglog_jet5(quadctx, eta, sigma)?;
4112    Ok(IntegratedInverseLinkJet {
4113        mean: jet.mean,
4114        d1: jet.d1,
4115        d2: jet.d2,
4116        d3: jet.d3,
4117        mode: jet.mode,
4118    })
4119}
4120
4121#[inline]
4122fn integrated_cloglog_inverse_link_jet_controlled(
4123    ctx: &QuadratureContext,
4124    mu: f64,
4125    sigma: f64,
4126) -> IntegratedInverseLinkJet {
4127    let (values, mode) = cloglog_inverse_link_controlled_values(ctx, mu, sigma, 3);
4128    IntegratedInverseLinkJet {
4129        mean: values[0],
4130        d1: values[1],
4131        d2: values[2],
4132        d3: values[3],
4133        mode,
4134    }
4135}
4136
4137#[inline]
4138fn latent_cloglog_kernel_terms(
4139    ctx: &QuadratureContext,
4140    mu: f64,
4141    sigma: f64,
4142    max_order: usize,
4143) -> ([f64; 6], f64, IntegratedExpectationMode) {
4144    let sigma2 = sigma * sigma;
4145    let mut k = [0.0; 6];
4146    let mut log_k0 = f64::NEG_INFINITY;
4147    let mut mode = IntegratedExpectationMode::ExactClosedForm;
4148
4149    for (order, out) in k.iter_mut().enumerate().take(max_order + 1) {
4150        let kf = order as f64;
4151        let shifted_mu = mu + kf * sigma2;
4152        // Carry the survival S(μ + kσ², σ) as a log so the kernel
4153        //   K_{k,1} = exp(kμ + ½k²σ²) · S(μ + kσ², σ)
4154        // keeps its true magnitude when S underflows in value space: at large σ
4155        // the k=1 shifted location μ + σ² drives S below the f64 floor, and the
4156        // old value-space `survival <= 0.0 → 0` collapse zeroed K_{1,1} (the
4157        // IRLS working slope), even though the huge exp(½σ²) prefix makes the
4158        // product finite and O(1) (#798).
4159        let (log_survival, term_mode) =
4160            cloglog_log_survival_term_controlled(ctx, shifted_mu, sigma);
4161        mode = worse_integrated_expectation_mode(mode, term_mode);
4162
4163        let log_value = kf * mu + 0.5 * kf * kf * sigma2 + log_survival;
4164        if order == 0 {
4165            log_k0 = log_value;
4166        }
4167        if !log_value.is_finite() {
4168            *out = 0.0;
4169            continue;
4170        }
4171        let upper = if order == 0 {
4172            1.0
4173        } else {
4174            let k_over_e = kf / std::f64::consts::E;
4175            k_over_e.powf(kf)
4176        };
4177        *out = safe_exp(log_value).clamp(0.0, upper);
4178    }
4179
4180    (k, log_k0, mode)
4181}
4182
4183#[inline]
4184pub fn normal_expectation_1d_adaptive<F>(
4185    ctx: &QuadratureContext,
4186    eta: f64,
4187    se_eta: f64,
4188    f: F,
4189) -> f64
4190where
4191    F: Fn(f64) -> f64,
4192{
4193    integrate_normal_ghq_adaptive(ctx, eta, se_eta, f)
4194}
4195
4196#[inline]
4197pub fn normal_expectation_1d_adaptive_pair<F>(
4198    ctx: &QuadratureContext,
4199    eta: f64,
4200    se_eta: f64,
4201    f: F,
4202) -> (f64, f64)
4203where
4204    F: Fn(f64) -> (f64, f64),
4205{
4206    integrate_normal_ghq_adaptive(ctx, eta, se_eta, f)
4207}
4208
4209fn adaptive_point_count_from_sd(max_sd: f64) -> usize {
4210    // Use a more aggressive schedule for nonlinear tail-sensitive transforms.
4211    // 7 points stays for very well-identified rows, 15/21/31 kick in earlier for
4212    // location-scale and rare-event regimes where MC checks showed larger error.
4213    // 51 nodes covers the wide-sigma regime where 31-point GHQ accumulated
4214    // noticeable error against the Faddeeva / high-res numeric references.
4215    // The moderate-sigma 31-point band was widened (1.0 → 0.5) after the
4216    // Logit jet started feeding d2/d3 through the same Hermite rule: at
4217    // σ ≈ 0.8, 21-pt d2 on the logistic-normal reaches only ~2.5e-10 rel
4218    // vs 31-pt at ~3e-13, and several downstream tests pin to 1e-10.
4219    if max_sd.is_finite() && max_sd > 2.5 {
4220        51
4221    } else if max_sd.is_finite() && max_sd > 0.5 {
4222        31
4223    } else if max_sd.is_finite() && max_sd > 0.35 {
4224        21
4225    } else if max_sd.is_finite() && max_sd > 0.1 {
4226        15
4227    } else {
4228        7
4229    }
4230}
4231
4232#[inline]
4233fn with_gh_nodesweights<R>(
4234    ctx: &QuadratureContext,
4235    n: usize,
4236    f: impl FnOnce(&[f64], &[f64]) -> R,
4237) -> R {
4238    if n == 7 {
4239        let gh = ctx.gauss_hermite();
4240        f(&gh.nodes, &gh.weights)
4241    } else {
4242        let gh = ctx.gauss_hermite_n(n);
4243        f(&gh.nodes, &gh.weights)
4244    }
4245}
4246
4247/// Stack-allocated Cholesky factor for `D x D` symmetric PSD matrices.
4248///
4249/// Returns the lower-triangular factor `L` (with strict upper triangle = 0)
4250/// such that `L L^T = cov`, or `None` if `cov` is not positive definite
4251/// (non-finite or non-positive pivot encountered).
4252///
4253/// This mirrors a standard textbook Cholesky inner loop bit-for-bit at a
4254/// single jitter level, but avoids any heap allocation — critical for
4255/// per-row GHQ where this runs once per observation.
4256#[inline]
4257fn cholesky_static<const D: usize>(cov: &[[f64; D]; D]) -> Option<[[f64; D]; D]> {
4258    let mut l = [[0.0_f64; D]; D];
4259    for i in 0..D {
4260        for j in 0..=i {
4261            let mut sum = cov[i][j];
4262            for k in 0..j {
4263                sum -= l[i][k] * l[j][k];
4264            }
4265            if i == j {
4266                if !sum.is_finite() || sum <= 0.0 {
4267                    return None;
4268                }
4269                l[i][j] = sum.sqrt();
4270            } else {
4271                l[i][j] = sum / l[j][j];
4272            }
4273        }
4274    }
4275    Some(l)
4276}
4277
4278/// Stack-allocated Cholesky with a jitter-retry ladder
4279/// (0, 1e-12, 1e-11, …, 1e-6 added to diagonal).
4280#[inline]
4281fn cholesky_static_with_jitter<const D: usize>(cov: &[[f64; D]; D]) -> Option<[[f64; D]; D]> {
4282    if D == 0 {
4283        return None;
4284    }
4285    for retry in 0..8 {
4286        let jitter = if retry == 0 {
4287            0.0
4288        } else {
4289            1e-12 * 10f64.powi(retry - 1)
4290        };
4291        if jitter == 0.0 {
4292            if let Some(l) = cholesky_static::<D>(cov) {
4293                return Some(l);
4294            }
4295        } else {
4296            let mut base = *cov;
4297            for i in 0..D {
4298                base[i][i] = cov[i][i] + jitter;
4299            }
4300            if let Some(l) = cholesky_static::<D>(&base) {
4301                return Some(l);
4302            }
4303        }
4304    }
4305    None
4306}
4307
4308#[inline]
4309fn adaptive_point_countwith_cap(max_sd: f64, max_n: usize) -> usize {
4310    adaptive_point_count_from_sd(max_sd).min(max_n)
4311}
4312
4313#[inline]
4314fn ghq_nd_integrate_try<const D: usize, F, R, E>(
4315    ctx: &QuadratureContext,
4316    mu: [f64; D],
4317    cov: [[f64; D]; D],
4318    max_n: usize,
4319    f: F,
4320) -> Result<Option<R>, E>
4321where
4322    F: Fn([f64; D]) -> Result<R, E>,
4323    R: GhqValue,
4324{
4325    let mut maxvar = 0.0_f64;
4326    for (i, row) in cov.iter().enumerate() {
4327        maxvar = maxvar.max(row[i]).max(0.0);
4328    }
4329    let n = adaptive_point_countwith_cap(maxvar.sqrt(), max_n);
4330
4331    // Sanitize variances on the stack (clamp negative diagonal to 0),
4332    // then run a stack-allocated Cholesky-with-jitter. This avoids the
4333    // `Vec<Vec<f64>>` per-row allocation that previously serialized
4334    // through the global allocator inside parallel workers.
4335    let mut cov_arr = cov;
4336    for i in 0..D {
4337        cov_arr[i][i] = cov_arr[i][i].max(0.0);
4338    }
4339    let Some(l) = cholesky_static_with_jitter::<D>(&cov_arr) else {
4340        return Ok(None);
4341    };
4342    let norm = 1.0 / std::f64::consts::PI.powf(0.5 * D as f64);
4343
4344    with_gh_nodesweights(ctx, n, |nodes, weights| {
4345        let mut acc = R::zero();
4346        let mut idx = [0usize; D];
4347        loop {
4348            let mut z = [0.0_f64; D];
4349            let mut weight = 1.0_f64;
4350            for d in 0..D {
4351                z[d] = SQRT_2 * nodes[idx[d]];
4352                weight *= weights[idx[d]];
4353            }
4354
4355            let mut x = mu;
4356            for row in 0..D {
4357                let mut dot = 0.0_f64;
4358                for (col, zc) in z.iter().enumerate().take(row + 1) {
4359                    dot += l[row][col] * *zc;
4360                }
4361                x[row] += dot;
4362            }
4363            acc.addweighted(weight, f(x)?);
4364
4365            let mut carry = true;
4366            for d in (0..D).rev() {
4367                idx[d] += 1;
4368                if idx[d] < n {
4369                    carry = false;
4370                    break;
4371                }
4372                idx[d] = 0;
4373            }
4374            if carry {
4375                break;
4376            }
4377        }
4378        Ok(Some(acc.scale(norm)))
4379    })
4380}
4381
4382#[inline]
4383fn ghq_nd_integrate<const D: usize, F, R>(
4384    ctx: &QuadratureContext,
4385    mu: [f64; D],
4386    cov: [[f64; D]; D],
4387    max_n: usize,
4388    f: F,
4389) -> Option<R>
4390where
4391    F: Fn([f64; D]) -> R,
4392    R: GhqValue,
4393{
4394    match ghq_nd_integrate_try::<D, _, R, Infallible>(ctx, mu, cov, max_n, |x| Ok(f(x))) {
4395        Ok(v) => v,
4396        Err(e) => match e {},
4397    }
4398}
4399
4400#[inline]
4401fn ghq_nd_integrate_result<const D: usize, F, R, E>(
4402    ctx: &QuadratureContext,
4403    mu: [f64; D],
4404    cov: [[f64; D]; D],
4405    max_n: usize,
4406    f: F,
4407) -> Result<Option<R>, E>
4408where
4409    F: Fn([f64; D]) -> Result<R, E>,
4410    R: GhqValue,
4411{
4412    ghq_nd_integrate_try::<D, _, R, E>(ctx, mu, cov, max_n, f)
4413}
4414
4415/// Adaptive N-dimensional GHQ expectation for correlated Gaussian latents.
4416pub fn normal_expectation_nd_adaptive<const D: usize, F>(
4417    ctx: &QuadratureContext,
4418    mu: [f64; D],
4419    cov: [[f64; D]; D],
4420    max_n: usize,
4421    f: F,
4422) -> f64
4423where
4424    F: Fn([f64; D]) -> f64,
4425{
4426    match ghq_nd_integrate::<D, _, f64>(ctx, mu, cov, max_n, &f) {
4427        Some(v) => v,
4428        None => f(mu),
4429    }
4430}
4431
4432/// Fallible adaptive N-dimensional GHQ expectation for correlated Gaussian latents.
4433pub fn normal_expectation_nd_adaptive_result<const D: usize, F, R, E>(
4434    ctx: &QuadratureContext,
4435    mu: [f64; D],
4436    cov: [[f64; D]; D],
4437    max_n: usize,
4438    f: F,
4439) -> Result<R, E>
4440where
4441    F: Fn([f64; D]) -> Result<R, E>,
4442    R: GhqValue,
4443{
4444    match ghq_nd_integrate_result::<D, _, R, E>(ctx, mu, cov, max_n, &f)? {
4445        Some(v) => Ok(v),
4446        None => f(mu),
4447    }
4448}
4449
4450/// Adaptive 2D GHQ expectation for correlated Gaussian latents with a fallible integrand.
4451pub fn normal_expectation_2d_adaptive_result<F, E>(
4452    ctx: &QuadratureContext,
4453    mu: [f64; 2],
4454    cov: [[f64; 2]; 2],
4455    f: F,
4456) -> Result<f64, E>
4457where
4458    F: Fn(f64, f64) -> Result<f64, E>,
4459{
4460    normal_expectation_nd_adaptive_result::<2, _, _, E>(ctx, mu, cov, 21, |x| f(x[0], x[1]))
4461}
4462
4463/// Adaptive 3D GHQ expectation for correlated Gaussian latents.
4464pub fn normal_expectation_3d_adaptive<F>(
4465    ctx: &QuadratureContext,
4466    mu: [f64; 3],
4467    cov: [[f64; 3]; 3],
4468    f: F,
4469) -> f64
4470where
4471    F: Fn(f64, f64, f64) -> f64,
4472{
4473    // 3D tensor GHQ grows cubically; cap nodes per axis for throughput.
4474    normal_expectation_nd_adaptive::<3, _>(ctx, mu, cov, 15, |x| f(x[0], x[1], x[2]))
4475}
4476
4477/// Closed-form posterior mean under probit link when eta is Gaussian:
4478/// E[Phi(Z)] for Z ~ N(eta, se_eta^2) = Phi(eta / sqrt(1 + se_eta^2)).
4479///
4480/// This is the template for the "integrated PIRLS without quadrature" idea:
4481/// unlike logit/cloglog, the Gaussian convolution of a probit inverse link is
4482/// analytically closed and cheap enough to evaluate as a plain vectorized
4483/// transformation. Any integrated probit update path should use this exact
4484/// identity rather than GHQ or cubature.
4485///
4486/// Derivation:
4487/// Let U ~ N(0, 1) independent of Z ~ N(eta, se_eta^2). Then
4488///   E[Phi(Z)] = P(U <= Z) = P(Z - U >= 0).
4489/// Since Z - U ~ N(eta, 1 + se_eta^2),
4490///   P(Z - U >= 0) = Phi(eta / sqrt(1 + se_eta^2)).
4491/// Differentiating with respect to eta gives
4492///   d/deta E[Phi(Z)]
4493///   = phi(eta / sqrt(1 + se_eta^2)) / sqrt(1 + se_eta^2),
4494/// which is exactly the integrated derivative IRLS would need.
4495#[inline]
4496pub fn probit_posterior_mean(eta: f64, se_eta: f64) -> f64 {
4497    if se_eta < 1e-10 {
4498        return gam_math::probability::normal_cdf(eta);
4499    }
4500    let denom = (1.0 + se_eta * se_eta).sqrt();
4501    gam_math::probability::normal_cdf(eta / denom)
4502}
4503
4504#[inline]
4505pub fn logit_posterior_meanvariance(ctx: &QuadratureContext, eta: f64, se_eta: f64) -> (f64, f64) {
4506    let (m1, m2) = integrate_normal_ghq_adaptive(ctx, eta, se_eta, |x| {
4507        let p = sigmoid(x);
4508        (p, p * p)
4509    });
4510    let m1 = m1.clamp(0.0, 1.0);
4511    let m2 = m2.clamp(0.0, 1.0);
4512    (m1, (m2 - m1 * m1).max(0.0))
4513}
4514
4515#[inline]
4516pub fn probit_posterior_meanvariance(ctx: &QuadratureContext, eta: f64, se_eta: f64) -> (f64, f64) {
4517    let m1 = probit_posterior_mean(eta, se_eta);
4518    let m2 = integrate_normal_ghq_adaptive(ctx, eta, se_eta, |x| {
4519        let p = gam_math::probability::normal_cdf(x);
4520        p * p
4521    })
4522    .clamp(0.0, 1.0);
4523    (m1, (m2 - m1 * m1).max(0.0))
4524}
4525
4526#[inline]
4527pub fn cloglog_posterior_meanvariance(
4528    ctx: &QuadratureContext,
4529    eta: f64,
4530    se_eta: f64,
4531) -> (f64, f64) {
4532    // With p(eta) = 1 - S(eta), where S(eta) = exp(-exp(eta)),
4533    //
4534    //   E[p]   = 1 - E[S]
4535    //   E[p^2] = E[(1 - S)^2] = 1 - 2 E[S] + E[S^2]
4536    //
4537    // and because
4538    //
4539    //   S(eta)^2 = exp(-2 exp(eta)) = L(2; mu, sigma) = L(1; mu + ln 2, sigma),
4540    //
4541    // the second moment is obtained by the same shared survival-term
4542    // evaluator with the exact mu -> mu + ln 2 shift. The variance then
4543    // collapses to
4544    //
4545    //   Var[p] = E[p^2] - E[p]^2 = E[S^2] - E[S]^2.
4546    //
4547    // So cloglog and survival actually share the same posterior variance under
4548    // Gaussian uncertainty; they only differ in whether the reported mean is
4549    // E[S] or 1 - E[S].
4550    // Degenerate sigma: use cloglog_mean_exact directly (see cloglog_posterior_mean).
4551    if !(eta.is_finite() && se_eta.is_finite()) || se_eta <= CLOGLOG_SIGMA_DEGENERATE {
4552        return (cloglog_mean_exact(eta), 0.0);
4553    }
4554    let (survival, _) = cloglog_survival_term_controlled(ctx, eta, se_eta);
4555    let (survival_sq, _) = cloglog_survivalsecond_moment_controlled(ctx, eta, se_eta);
4556    let mean = cloglog_mean_from_survival(survival);
4557    let variance = (survival_sq - survival * survival).max(0.0);
4558    (mean, variance)
4559}
4560
4561/// Posterior mean under cloglog inverse link:
4562/// g^{-1}(x) = 1 - exp(-exp(x)).
4563///
4564/// This now routes through the same analytic ladder used by the integrated
4565/// derivative path rather than defaulting to GHQ:
4566///
4567/// - E[1 - exp(-exp(eta))] under Gaussian eta is the complement of the
4568///   lognormal Laplace transform at z=1.
4569/// - That quantity has exact non-GHQ representations, including convergent
4570///   erfc / asymptotic series and characteristic-function inversion formulas.
4571/// - The same mathematics also covers the Royston-Parmar survival transform
4572///   S(eta) = exp(-exp(eta)), which is why this comment matters beyond binary
4573///   cloglog models.
4574///
4575/// So GHQ here is only the terminal numerical fallback, not the primary path.
4576///
4577/// Derivation of the exact target quantity:
4578/// If eta = mu + sigma Z with Z ~ N(0, 1), set X = exp(eta). Then
4579///   X ~ LogNormal(mu, sigma^2)
4580/// and
4581///   E[1 - exp(-exp(eta))] = 1 - E[exp(-X)].
4582/// So the integrated cloglog mean is exactly the complement of the Laplace
4583/// transform of a lognormal random variable at z = 1.
4584///
4585/// The integrated derivative needed by IRLS is
4586///   d/dmu E[1 - exp(-exp(eta))]
4587///   = E[exp(eta - exp(eta))],
4588/// either by differentiating inside the Gaussian expectation or directly from
4589/// f'(x) = exp(x - exp(x)).
4590///
4591/// There is no simple elementary closed form, but the object is exact and well
4592/// structured. That is why this function is a good future target for replacing
4593/// repeated GHQ with a special-function or rapidly convergent series backend.
4594#[inline]
4595pub fn cloglog_posterior_mean(ctx: &QuadratureContext, eta: f64, se_eta: f64) -> f64 {
4596    // Degenerate sigma: use cloglog_mean_exact directly to avoid precision
4597    // loss from the survival → mean conversion (gumbel_survival rounds to
4598    // 1.0 in f64 for eta ≪ 0, and cloglog_mean_from_survival(1.0) = 0.0).
4599    if !(eta.is_finite() && se_eta.is_finite()) || se_eta <= CLOGLOG_SIGMA_DEGENERATE {
4600        return cloglog_mean_exact(eta);
4601    }
4602    let (survival, _) = cloglog_survival_term_controlled(ctx, eta, se_eta);
4603    cloglog_mean_from_survival(survival)
4604}
4605
4606/// Posterior mean under the Royston-Parmar survival transform:
4607/// S(x) = exp(-exp(x)).
4608///
4609/// This is the cloglog complement:
4610///   1 - S(x) = 1 - exp(-exp(x)).
4611/// Therefore for Gaussian eta,
4612///   E[S(eta)] = E[exp(-exp(eta))]
4613/// is the same lognormal-Laplace-transform object that appears in the cloglog
4614/// path, and
4615///   E[cloglog^{-1}(eta)] = 1 - E[S(eta)].
4616///
4617/// Any future exact special-function implementation for integrated cloglog can
4618/// therefore be shared directly with survival models that use this transform.
4619#[inline]
4620pub fn survival_posterior_mean(ctx: &QuadratureContext, eta: f64, se_eta: f64) -> f64 {
4621    cloglog_survival_term_controlled(ctx, eta, se_eta)
4622        .0
4623        .clamp(0.0, 1.0)
4624}
4625
4626#[inline]
4627pub fn survival_posterior_meanvariance(
4628    ctx: &QuadratureContext,
4629    eta: f64,
4630    se_eta: f64,
4631) -> (f64, f64) {
4632    let (m1, _) = cloglog_survival_term_controlled(ctx, eta, se_eta);
4633    let (m2, _) = cloglog_survivalsecond_moment_controlled(ctx, eta, se_eta);
4634    (m1.clamp(0.0, 1.0), (m2 - m1 * m1).max(0.0))
4635}
4636
4637/// Oracle-grade exact logistic-normal mean via an accelerated Faddeeva-pole
4638/// series with a closed-form Euler–Maclaurin tail.
4639///
4640/// For η ~ N(mu, sigma^2) the logistic-normal mean admits the Faddeeva-pole
4641/// representation (tanh partial fractions + termwise Gaussian expectation,
4642/// derivation below):
4643///
4644///   E[sigmoid(η)] = 1/2 − (sqrt(2π)/σ)·Σ_{n≥1} Im w(ξ_n),
4645///     ξ_n = (i·(2n−1)π − μ)/(√2 σ),   w the Faddeeva function.
4646///
4647/// This is the documented *non-GHQ special-function route* that an optimized
4648/// integrated-logit IRLS path could eventually use in place of GHQ. It is the
4649/// crate's independent oracle for `E[sigmoid(η)]` — independent of the
4650/// production erfcx series (Representation B, `logit_posterior_meanwith_deriv_exact_erfcx`)
4651/// and of GHQ, because it routes through a *different* special function (the
4652/// Faddeeva `w`).
4653///
4654/// ## Why the naive series is not an oracle (the #1459 bug)
4655///
4656/// Taken literally the sum converges only as **O(1/N)**: for fixed μ the terms
4657/// `Im w(ξ_n)` are same-signed and decay like `−2μ/((2n−1)²π²)`, so a hard
4658/// truncation at N terms leaves a tail of `μ/(2π²N)`. The previous
4659/// implementation summed a fixed 4096 terms, leaving a
4660/// `μ/(2π²·4096) ≈ 1.236e-5·μ` bias toward 1/2 — σ-independent, μ-linear,
4661/// vanishing only at μ=0 — i.e. 4–5 orders *worse* than the cheap GHQ/erfcx
4662/// path it is meant to certify. The defect is NOT the accuracy of `w(z)`: an
4663/// exact `w` (e.g. SciPy `wofz`) exhibits the identical bias. It is the
4664/// truncation of an intrinsically slow series. Adding more terms or a more
4665/// accurate `w(z)` — the fix the original bug report hypothesised — does not
4666/// cure it (you would need ~10^13 terms for 1e-13).
4667///
4668/// ## The cure: subtract the leading asymptotic, close the tail analytically
4669///
4670/// `Im w(ξ)` has the large-|ξ| expansion
4671/// `Im[(i/√π)(1/ξ + Σ_{m≥1} c_m ξ^{−(2m+1)})]`, `c_m = (2m−1)!!/2^m`. The
4672/// leading `(i/√π)/ξ` piece is the *sole* source of the slow `O(1/N)` tail,
4673/// and — crucially — its infinite sum is available in closed form: summing
4674/// `T_n^{(0)} = Im[(i/√π)/ξ_n] = (1/√π)·Re(ξ_n)/|ξ_n|²` over all n reconstructs
4675/// exactly the point-mass limit `sigmoid(μ)` (it is precisely the tanh
4676/// partial-fraction identity). Hence the exactly-equivalent, fast form
4677///
4678///   E[sigmoid(η)] = sigmoid(μ) − (sqrt(2π)/σ)·Σ_{n≥1} (Im w(ξ_n) − T_n^{(0)}),
4679///
4680/// whose summand decays as `O(1/n³)`. The remaining sum is evaluated by
4681/// (a) the few terms with `|ξ_n| ≤ R` directly from a machine-precision
4682/// Weideman rational `w` (see `faddeeva_upper_halfplane`), and (b) the analytic
4683/// tail `Σ_{n≥a}` of the asymptotic series via Euler–Maclaurin (integral +
4684/// half-sample + the B₂ correction), each piece a closed form in `ξ_a`. The
4685/// Euler–Maclaurin tail is only entered once `2/(2n−1) ≪ 1` (the sampling of
4686/// the smooth tail integrand is fine), which holds at a σ-independent index, so
4687/// the number of directly-summed terms is bounded (≤ `FADDEEVA_TAIL_MIN_INDEX`)
4688/// regardless of σ. The result matches a dense-quadrature reference to ~1e-13
4689/// uniformly over μ∈[−20,20], σ∈[1e-6, 6+] — genuinely an oracle.
4690///
4691/// ## Equivalent erfcx (theta-image) representation
4692///
4693/// The same identity Poisson-resums to a Gaussian-fast erfcx series
4694/// (`m=|μ|, s=σ`, `erfcx(x)=exp(x²)erfc(x)`):
4695///
4696///   E[sigmoid(η)] = Φ(m/s)
4697///     + 0.5·exp(−m²/2s²)·Σ_{k≥1} (−1)^(k−1)
4698///       [ erfcx((k s² + m)/(√2 s)) − erfcx((k s² − m)/(√2 s)) ].
4699///
4700/// That is the production path's scheme (`logit_posterior_meanwith_deriv_exact_erfcx`).
4701/// It is geometric-fast but loses ~5–8 digits to cancellation at moderate σ, so
4702/// it is *not* used here: an oracle must out-resolve what it certifies, and the
4703/// accelerated-Faddeeva form above retains full f64 precision via the
4704/// closed-form `sigmoid(μ)` subtraction.
4705///
4706/// Derivation sketch (Faddeeva form):
4707/// 1) sigmoid(t) = 1/2 + 1/2 tanh(t/2)
4708/// 2) tanh has a partial-fraction expansion over odd poles ±i(2n−1)π
4709/// 3) termwise Gaussian expectation yields `E[1/(Z − i a_n)]`, Z~N(mu,sigma²)
4710/// 4) `E[1/(Z − i a)] = i√π/(√2σ)·w((i a − μ)/(√2σ))`
4711/// 5) imaginary parts summed over odd `a_n` give the stated series.
4712pub fn logit_posterior_mean_exact(mu: f64, sigma: f64) -> f64 {
4713    if !(mu.is_finite() && sigma.is_finite()) || sigma <= 0.0 {
4714        return sigmoid(mu);
4715    }
4716    if sigma < LOGIT_SIGMA_DEGENERATE {
4717        // Below this σ the point-mass limit is exact to f64 and the pole-series
4718        // coefficient √(2π)/σ amplifies round-off; `sigmoid(μ)` is the answer.
4719        return sigmoid(mu);
4720    }
4721
4722    let inv_sqrt_pi = 0.5 * std::f64::consts::FRAC_2_SQRT_PI; // 1/√π
4723    let sqrt2_sigma = SQRT_2 * sigma;
4724    let coeff = (2.0_f64 * std::f64::consts::PI).sqrt() / sigma; // √(2π)/σ
4725    let c = -mu / sqrt2_sigma; // Re ξ_n (constant in n)
4726    let beta = std::f64::consts::PI / sqrt2_sigma; // Im ξ_n = (2n−1)·beta
4727    let r2 = FADDEEVA_ASYMPTOTIC_RADIUS * FADDEEVA_ASYMPTOTIC_RADIUS;
4728
4729    // Σ_{n≥1} (Im w(ξ_n) − T_n^{(0)}), with T_n^{(0)} = (1/√π)·c/|ξ_n|² the
4730    // leading 1/ξ asymptotic of Im w. Inside the asymptotic radius use the
4731    // Weideman rational w directly; outside it use the (convergent, for
4732    // |ξ|>R) asymptotic series — they agree, but the asymptotic avoids the
4733    // catastrophic `Im w − T_n^{(0)}` cancellation that grows with |ξ|.
4734    let mut corr = 0.0_f64;
4735    let mut n = 1usize;
4736    let tail_start = loop {
4737        let b = (2.0 * (n as f64) - 1.0) * beta;
4738        let abs_xi2 = c * c + b * b;
4739        if abs_xi2 > r2 && n >= FADDEEVA_TAIL_MIN_INDEX {
4740            break n;
4741        }
4742        let xi = Complex { re: c, im: b };
4743        let d = if abs_xi2 > r2 {
4744            // Im[(i/√π)·A(ξ)] = (1/√π)·Re A(ξ)
4745            inv_sqrt_pi * faddeeva_asymptotic_a(xi).re
4746        } else {
4747            faddeeva_upper_halfplane(xi).im - inv_sqrt_pi * c / abs_xi2
4748        };
4749        corr += d;
4750        n += 1;
4751    };
4752
4753    corr += faddeeva_pole_series_em_tail(c, beta, tail_start, inv_sqrt_pi);
4754
4755    sigmoid(mu) - coeff * corr
4756}
4757
4758/// Number of directly-summed Weideman/asymptotic terms before the Euler–Maclaurin
4759/// tail takes over. Chosen so the tail integrand `Im w − T^{(0)}` is sampled
4760/// finely (`2/(2n−1) ≲ 0.02`); the count is σ-independent, bounding work.
4761const FADDEEVA_TAIL_MIN_INDEX: usize = 48;
4762/// |ξ| beyond which the Faddeeva asymptotic series is used instead of the
4763/// Weideman rational (and beyond which the tail integral is closed in form).
4764const FADDEEVA_ASYMPTOTIC_RADIUS: f64 = 7.0;
4765/// Terms of the `w(ξ) ~ (i/√π)Σ c_m ξ^{−(2m+1)}` asymptotic series. At |ξ|=R
4766/// optimal truncation is well past 14 terms, so 14 is comfortably accurate.
4767const FADDEEVA_ASYMPTOTIC_TERMS: usize = 14;
4768
4769/// `A(ξ) = Σ_{m≥1} c_m ξ^{−(2m+1)}`, `c_m = (2m−1)!!/2^m` — the Faddeeva
4770/// asymptotic series with the leading `1/ξ` term removed, so that
4771/// `w(ξ) = (i/√π)(1/ξ + A(ξ))` for large |ξ|.
4772fn faddeeva_asymptotic_a(xi: Complex) -> Complex {
4773    let inv = complex_div(Complex { re: 1.0, im: 0.0 }, xi);
4774    let inv2 = complexmul(inv, inv);
4775    let mut xp = complexmul(inv2, inv); // ξ^{−3}
4776    let mut cm = 0.5_f64; // c_1 = 1!!/2 = 1/2
4777    let mut s = Complex::default();
4778    for m in 1..=FADDEEVA_ASYMPTOTIC_TERMS {
4779        s = complex_add(
4780            s,
4781            Complex {
4782                re: cm * xp.re,
4783                im: cm * xp.im,
4784            },
4785        );
4786        cm *= (2.0 * (m as f64) + 1.0) / 2.0; // c_{m+1}/c_m = (2m+1)/2
4787        xp = complexmul(xp, inv2);
4788    }
4789    s
4790}
4791
4792/// Closed-form Euler–Maclaurin tail `Σ_{n≥a} (Im w(ξ_n) − T_n^{(0)})` of the
4793/// accelerated pole series, with `a = tail_start` and `ξ_n = c + i(2n−1)β`.
4794///
4795/// On the tail `Im w(ξ_n) − T_n^{(0)} = Im[(i/√π) A(ξ_n)]`, a smooth function of
4796/// n. Euler–Maclaurin gives `Σ_{n≥a} F(n) = ∫_a^∞ F + F(a)/2 − (B₂/2!) F'(a) −
4797/// …` (B₂ = 1/6, higher terms negligible past `FADDEEVA_TAIL_MIN_INDEX`). With
4798/// `ξ(x) = c + i(2x−1)β`, `dξ/dx = 2iβ`, every piece is closed-form in `ξ_a`:
4799///   ∫_a^∞ ξ^{−(2m+1)} dx = ξ_a^{−2m} / (4 i β m).
4800fn faddeeva_pole_series_em_tail(c: f64, beta: f64, tail_start: usize, inv_sqrt_pi: f64) -> f64 {
4801    let b_a = (2.0 * (tail_start as f64) - 1.0) * beta;
4802    let xi = Complex { re: c, im: b_a };
4803    let inv = complex_div(Complex { re: 1.0, im: 0.0 }, xi);
4804    let inv2 = complexmul(inv, inv);
4805    // 1/(4 i β m) = −i/(4 β m); 2 i β for F'(x).
4806    let two_i_beta = Complex {
4807        re: 0.0,
4808        im: 2.0 * beta,
4809    };
4810
4811    let mut s = Complex::default(); // Σ_m c_m ξ^{−2m}/(4 i β m)   (the integral)
4812    let mut a_acc = Complex::default(); // A(ξ_a) = Σ_m c_m ξ^{−(2m+1)}
4813    let mut fp_inner = Complex::default(); // Σ_m c_m·(−(2m+1)) ξ^{−(2m+2)}
4814
4815    let mut x2m = inv2; // ξ^{−2}            (m=1)
4816    let mut x2m1 = complexmul(inv2, inv); // ξ^{−3}  (m=1)
4817    let mut x2m2 = complexmul(inv2, inv2); // ξ^{−4} (m=1)
4818    let mut cm = 0.5_f64;
4819    for m in 1..=FADDEEVA_ASYMPTOTIC_TERMS {
4820        let mf = m as f64;
4821        // integral term: c_m · ξ^{−2m} · 1/(4 i β m), with 1/(4 i β m) = −i/(4βm)
4822        let inv_4ibm = Complex {
4823            re: 0.0,
4824            im: -1.0 / (4.0 * beta * mf),
4825        };
4826        s = complex_add(
4827            s,
4828            complexmul(
4829                Complex {
4830                    re: cm * x2m.re,
4831                    im: cm * x2m.im,
4832                },
4833                inv_4ibm,
4834            ),
4835        );
4836        a_acc = complex_add(
4837            a_acc,
4838            Complex {
4839                re: cm * x2m1.re,
4840                im: cm * x2m1.im,
4841            },
4842        );
4843        let fc = cm * (-(2.0 * mf + 1.0));
4844        fp_inner = complex_add(
4845            fp_inner,
4846            Complex {
4847                re: fc * x2m2.re,
4848                im: fc * x2m2.im,
4849            },
4850        );
4851        cm *= (2.0 * mf + 1.0) / 2.0;
4852        x2m = complexmul(x2m, inv2);
4853        x2m1 = complexmul(x2m1, inv2);
4854        x2m2 = complexmul(x2m2, inv2);
4855    }
4856
4857    // F(a)/2
4858    s = complex_add(
4859        s,
4860        Complex {
4861            re: 0.5 * a_acc.re,
4862            im: 0.5 * a_acc.im,
4863        },
4864    );
4865    // −(B₂/2!) F'(a) = −(1/12)·(2 i β)·fp_inner = −(i β/6)·fp_inner
4866    let fprime = complexmul(two_i_beta, fp_inner);
4867    s = complex_add(
4868        s,
4869        Complex {
4870            re: -fprime.re / 12.0,
4871            im: -fprime.im / 12.0,
4872        },
4873    );
4874
4875    // Σ_{n≥a} F(n) where each summand is Im[(i/√π)·A], i.e. (1/√π)·Re of the
4876    // bracketed sum.
4877    inv_sqrt_pi * s.re
4878}
4879
4880/// Faddeeva function `w(z) = exp(−z²)·erfc(−iz)` for Im(z) ≥ 0, via Weideman's
4881/// rational approximation [J.A.C. Weideman, *Computation of the complex error
4882/// function*, SIAM J. Numer. Anal. 31 (1994) 1497–1518].
4883///
4884/// With `L = sqrt(N/√2)` and `Z = (L + iz)/(L − iz)`,
4885///   w(z) ≈ 2·p(Z)/(L − iz)² + (1/√π)/(L − iz),
4886/// where `p` is a degree-(N−1) polynomial whose coefficients are the DFT of a
4887/// fixed `tan`-grid sampling of `exp(−t²)(L²+t²)` (Weideman, eq. for `a_n`).
4888/// At N = `FADDEEVA_WEIDEMAN_N` this is uniformly ~3e-16 accurate across the
4889/// upper half-plane, including the large-|z| tail (the `1/(L−iz)` term carries
4890/// the correct `i/(√π z)` asymptotic). Replaces the previous coarse
4891/// fixed-grid Simpson evaluator (#1459).
4892fn faddeeva_upper_halfplane(z: Complex) -> Complex {
4893    let (l, coeffs) = faddeeva_weideman_coeffs();
4894    let iz = Complex {
4895        re: -z.im,
4896        im: z.re,
4897    }; // i·z
4898    let l_minus = Complex {
4899        re: l - iz.re,
4900        im: -iz.im,
4901    }; // L − iz
4902    let l_plus = Complex {
4903        re: l + iz.re,
4904        im: iz.im,
4905    }; // L + iz
4906    let zz = complex_div(l_plus, l_minus); // Z
4907    // Horner evaluation of p(Z) (coeffs are highest-degree first).
4908    let mut p = Complex {
4909        re: coeffs[0],
4910        im: 0.0,
4911    };
4912    for &c in &coeffs[1..] {
4913        p = complex_add(complexmul(p, zz), Complex { re: c, im: 0.0 });
4914    }
4915    let l_minus_sq = complexmul(l_minus, l_minus);
4916    let term1 = complex_div(
4917        Complex {
4918            re: 2.0 * p.re,
4919            im: 2.0 * p.im,
4920        },
4921        l_minus_sq,
4922    );
4923    let inv_sqrt_pi = 0.5 * std::f64::consts::FRAC_2_SQRT_PI;
4924    let term2 = complex_div(
4925        Complex {
4926            re: inv_sqrt_pi,
4927            im: 0.0,
4928        },
4929        l_minus,
4930    );
4931    complex_add(term1, term2)
4932}
4933
4934/// Order of the Weideman rational Faddeeva approximation. N = 44 yields
4935/// ~3e-16 uniform accuracy on the upper half-plane.
4936const FADDEEVA_WEIDEMAN_N: usize = 44;
4937
4938/// Cached `(L, coefficients)` of the Weideman Faddeeva approximation. The
4939/// coefficients are `a_j = Re DFT(fftshift(f))_j / (2M)`, reversed, where
4940/// `f` samples `exp(−t²)(L²+t²)` on a `tan`-warped grid — computed once via a
4941/// direct DFT (the construction is real-output, so only the cosine transform
4942/// is needed). This reproduces the FFT-based reference coefficients to ~1e-14.
4943fn faddeeva_weideman_coeffs() -> &'static (f64, [f64; FADDEEVA_WEIDEMAN_N]) {
4944    static CACHE: OnceLock<(f64, [f64; FADDEEVA_WEIDEMAN_N])> = OnceLock::new();
4945    CACHE.get_or_init(|| {
4946        let n = FADDEEVA_WEIDEMAN_N;
4947        let l = (n as f64 / SQRT_2).sqrt();
4948        let m = 2 * n;
4949        let m2 = 2 * m; // 4N
4950        // f[0] = 0; f[idx] = exp(−t²)(L²+t²), t = L·tan(θ/2),
4951        // θ = kπ/M, k = (idx−1) − (M−1) ∈ [−M+1, M−1].
4952        let mut f = vec![0.0_f64; m2];
4953        for (idx, fi) in f.iter_mut().enumerate().skip(1) {
4954            let k = (idx as isize - 1) - (m as isize - 1);
4955            let theta = (k as f64) * std::f64::consts::PI / (m as f64);
4956            let t = l * (0.5 * theta).tan();
4957            *fi = (-t * t).exp() * (l * l + t * t);
4958        }
4959        // a_j = (1/M2)·Re Σ_p fftshift(f)[p]·exp(−2πi·j·p/M2), for j = 1..=N,
4960        // then reversed into polyval (highest-degree-first) order.
4961        let half = m2 / 2;
4962        let mut coeffs = [0.0_f64; FADDEEVA_WEIDEMAN_N];
4963        for j in 1..=n {
4964            let mut acc = 0.0_f64;
4965            for (p, _) in f.iter().enumerate() {
4966                let fp = f[(p + half) % m2];
4967                if fp != 0.0 {
4968                    acc += fp
4969                        * (-2.0 * std::f64::consts::PI * (j as f64) * (p as f64) / (m2 as f64))
4970                            .cos();
4971                }
4972            }
4973            // flipud(A[1..=N]): A[j] → coeffs[N − j]
4974            coeffs[n - j] = acc / (m2 as f64);
4975        }
4976        (l, coeffs)
4977    })
4978}
4979
4980/// Standard sigmoid function with numerical stability.
4981#[inline]
4982fn sigmoid(x: f64) -> f64 {
4983    let x_clamped = x.clamp(-QUADRATURE_EXP_LOG_MAX, QUADRATURE_EXP_LOG_MAX);
4984    1.0 / (1.0 + f64::exp(-x_clamped))
4985}
4986
4987// CLogLog Gaussian convolution via differentiated Gauss-Hermite quadrature
4988//
4989// For location-scale (GAMLSS) models with CLogLog link we need to evaluate
4990//   L(μ,σ) = E[g(μ + σZ)],  Z ~ N(0,1),  g(η) = 1 - exp(-exp(η)),
4991// together with all partial derivatives up to fourth order w.r.t. μ and σ.
4992//
4993// GHQ gives
4994//   L(μ,σ) ≈ (1/√π) Σ_m ω_m g(t_m),   t_m = μ + √2 σ x_m
4995//
4996// and by the chain rule (exact for the quadrature rule since t_m is affine
4997// in μ and σ):
4998//   ∂^a_μ ∂^b_σ L ≈ (√2)^b / √π  Σ_m ω_m x_m^b g^{(a+b)}(t_m)
4999
5000/// All partial derivatives of `L(μ,σ) = E[g(μ + σZ)]` up to fourth order,
5001/// where `g` is the CLogLog inverse link and `Z ~ N(0,1)`.
5002#[derive(Clone, Copy, Debug)]
5003pub struct CLogLogConvolutionDerivatives {
5004    // 0th order
5005    pub l: f64,
5006
5007    // 1st order
5008    pub l_mu: f64,
5009    pub l_sigma: f64,
5010
5011    // 2nd order
5012    pub l_mumu: f64,
5013    pub l_musigma: f64,
5014    pub l_sigmasigma: f64,
5015
5016    // 3rd order
5017    pub l_mumumu: f64,
5018    pub l_mumusigma: f64,
5019    pub l_musigmasigma: f64,
5020    pub l_sigmasigmasigma: f64,
5021
5022    // 4th order
5023    pub l_mumumumu: f64,
5024    pub l_mumumusigma: f64,
5025    pub l_mumusigmasigma: f64,
5026    pub l_musigmasigmasigma: f64,
5027    pub l_sigmasigmasigmasigma: f64,
5028}
5029
5030#[inline]
5031pub(crate) fn cloglog_point_jet5(t: f64) -> (f64, f64, f64, f64, f64, f64) {
5032    if t.is_nan() {
5033        return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN);
5034    }
5035    let et = safe_exp(t);
5036
5037    (
5038        -(-et).exp_m1(),
5039        cloglog_stable_poly_times_exp_neg(et, &[0.0, 1.0]),
5040        cloglog_stable_poly_times_exp_neg(et, &[0.0, 1.0, -1.0]),
5041        cloglog_stable_poly_times_exp_neg(et, &[0.0, 1.0, -3.0, 1.0]),
5042        cloglog_stable_poly_times_exp_neg(et, &[0.0, 1.0, -7.0, 6.0, -1.0]),
5043        cloglog_stable_poly_times_exp_neg(et, &[0.0, 1.0, -15.0, 25.0, -10.0, 1.0]),
5044    )
5045}
5046
5047/// CLogLog inverse link `g(t) = 1 - exp(-exp(t))` and its first four
5048/// derivatives, evaluated in a numerically stable way.
5049///
5050/// All derivatives share the common factor `h(t) = exp(t - exp(t))`:
5051/// ```text
5052///   g  (t) = 1 - exp(-exp(t))
5053///   g' (t) = h(t)
5054///   g''(t) = (1 - exp(t)) h(t)
5055///   g'''(t) = (exp(2t) - 3 exp(t) + 1) h(t)
5056///   g''''(t) = (-exp(3t) + 6 exp(2t) - 7 exp(t) + 1) h(t)
5057/// ```
5058#[inline]
5059fn cloglog_g_derivatives(t: f64) -> (f64, f64, f64, f64, f64) {
5060    let (g, g1, g2, g3, g4, _) = cloglog_point_jet5(t);
5061    (g, g1, g2, g3, g4)
5062}
5063
5064/// Compute `L(μ,σ) = E[g(μ + σZ)]` via Gauss-Hermite quadrature.
5065///
5066/// The number of GHQ nodes is determined by the `QuadratureContext` cache;
5067/// `n_nodes` selects from the available rule sizes (7, 15, 21, 31).
5068///
5069/// When `sigma` is negligibly small the function evaluates `g(mu)` directly,
5070/// bypassing quadrature.
5071pub fn cloglog_ghq_value(ctx: &QuadratureContext, mu: f64, sigma: f64, n_nodes: usize) -> f64 {
5072    if sigma.abs() < 1e-14 {
5073        let (g, _, _, _, _) = cloglog_g_derivatives(mu);
5074        return g.clamp(0.0, 1.0);
5075    }
5076    let inv_sqrt_pi = 1.0 / std::f64::consts::PI.sqrt();
5077
5078    // Adaptive (mode-centred) Gauss-Hermite quadrature (Liu & Pierce, 1994).
5079    //
5080    // Plain physicist GHQ centred at `mu` with scale `√2 σ` integrates
5081    //   L(μ,σ) = ∫ g(η) N(η; μ, σ²) dη,   g(η) = 1 − exp(−exp(η)),
5082    // but converges slowly once σ is moderate/large: the cloglog inverse link is
5083    // a stiff 0→1 *ramp* (a CDF), and a degree-(2n−1) polynomial fit of a step
5084    // against the fixed N(μ,σ²) weight leaves ~1e-6 truncation error at n=15 and
5085    // ~1e-8 at n=31 for σ≈1 — so simply doubling the order does *not* stabilise
5086    // the integral to the 1e-8 level the caller expects. Two things fix this:
5087    //
5088    //  1. Re-centre the rule on the integrand's own mode and match its curvature
5089    //     (adaptive GHQ). Let ℓ(η) = ln g(η) − (η−μ)²/(2σ²); the integrand
5090    //     q(η) = g(η) N(η;μ,σ²) ∝ exp(ℓ(η)) is strictly log-concave (g'/g is
5091    //     decreasing) with a unique mode η̂ (ℓ'(η̂)=0). With τ² = −1/ℓ''(η̂) the
5092    //     affine map η = η̂ + √2 τ t gives
5093    //       L ≈ (τ/(σ√π)) Σ_i ω_i g(η_i) exp(t_i² − (η_i−μ)²/(2σ²)),
5094    //     which improves conditioning and reduces to the plain rule as σ→0
5095    //     (η̂→μ, τ→σ). This buys ~10× accuracy.
5096    //
5097    //  2. Certify convergence by an actual order-doubling error estimate, not by
5098    //     clamping the request to one σ-derived order. `n_nodes` is the starting
5099    //     order; we then escalate up the GHQ ladder (7→15→21→31→51) and stop as
5100    //     soon as two successive orders agree to `CLOGLOG_GHQ_CONV_TOL`, returning
5101    //     the higher-order (more resolved) estimate. Earlier code instead set
5102    //     `n_eff = n_nodes.max(adaptive_point_count_from_sd(σ))`, forcing a
5103    //     requested 15 and 31 to the *same* internal order so the caller's
5104    //     order-doubling check `|I_31 − I_15|` was trivially ≈0 — it papered over
5105    //     under-resolution rather than proving it (#2063; the σ step-function it
5106    //     relied on was itself tuned to pass tests). With the mode-centred rule
5107    //     of (1) the escalation converges in 1–2 steps, so this is both honest
5108    //     and cheap.
5109    let inv_sig2 = 1.0 / (sigma * sigma);
5110    let mut eta_hat = mu;
5111    let mut converged = false;
5112    for _ in 0..100 {
5113        let (g, g1, g2, _, _, _) = cloglog_point_jet5(eta_hat);
5114        if !(g > 0.0) || !g1.is_finite() || !g2.is_finite() {
5115            break;
5116        }
5117        let r = g1 / g;
5118        let lp = r - (eta_hat - mu) * inv_sig2;
5119        let lpp = g2 / g - r * r - inv_sig2;
5120        if !lpp.is_finite() || lpp >= 0.0 {
5121            break;
5122        }
5123        let step = lp / lpp;
5124        eta_hat -= step;
5125        if step.abs() <= 1e-13 * (1.0 + eta_hat.abs()) {
5126            converged = true;
5127            break;
5128        }
5129    }
5130
5131    // Curvature at the located mode. If mode-finding failed or the curvature is
5132    // degenerate, fall back to the plain (μ-centred) rule so the value is never
5133    // worse than the classical GHQ estimate.
5134    let tau = if converged {
5135        let (g, g1, g2, _, _, _) = cloglog_point_jet5(eta_hat);
5136        if g > 0.0 {
5137            let r = g1 / g;
5138            let lpp = g2 / g - r * r - inv_sig2;
5139            let tau2 = -1.0 / lpp;
5140            if tau2.is_finite() && tau2 > 0.0 {
5141                Some(tau2.sqrt())
5142            } else {
5143                None
5144            }
5145        } else {
5146            None
5147        }
5148    } else {
5149        None
5150    };
5151
5152    // Evaluate the (mode-centred, or μ-centred fallback) rule at a single order.
5153    let eval_at = |n: usize| -> f64 {
5154        match tau {
5155            Some(tau) => {
5156                let pref = tau * inv_sqrt_pi / sigma;
5157                with_gh_nodesweights(ctx, n, |nodes, weights| {
5158                    let mut sum = 0.0_f64;
5159                    for i in 0..nodes.len() {
5160                        let t = nodes[i];
5161                        let eta_i = eta_hat + SQRT_2 * tau * t;
5162                        let (g, _, _, _, _, _) = cloglog_point_jet5(eta_i);
5163                        let dev = eta_i - mu;
5164                        sum += weights[i] * (t * t - 0.5 * dev * dev * inv_sig2).exp() * g;
5165                    }
5166                    (pref * sum).clamp(0.0, 1.0)
5167                })
5168            }
5169            None => {
5170                let scale = SQRT_2 * sigma;
5171                with_gh_nodesweights(ctx, n, |nodes, weights| {
5172                    let mut sum = 0.0_f64;
5173                    for i in 0..nodes.len() {
5174                        let t = mu + scale * nodes[i];
5175                        let (g, _, _, _, _) = cloglog_g_derivatives(t);
5176                        sum += weights[i] * g;
5177                    }
5178                    (sum * inv_sqrt_pi).clamp(0.0, 1.0)
5179                })
5180            }
5181        }
5182    };
5183
5184    // Error-driven order-doubling: start at `n_nodes` (its floor), escalate up
5185    // the ladder and return the higher-order estimate as soon as two successive
5186    // orders agree to `CLOGLOG_GHQ_CONV_TOL`; if none do, return the max-order
5187    // (most-resolved) estimate rather than assert convergence (#2063).
5188    const CLOGLOG_GHQ_ORDER_LADDER: [usize; 5] = [7, 15, 21, 31, 51];
5189    const CLOGLOG_GHQ_CONV_TOL: f64 = 1e-10;
5190    let floor = n_nodes.min(CLOGLOG_GHQ_ORDER_LADDER[CLOGLOG_GHQ_ORDER_LADDER.len() - 1]);
5191    let mut prev: Option<f64> = None;
5192    let mut result = 0.0_f64;
5193    for &n in CLOGLOG_GHQ_ORDER_LADDER.iter().filter(|&&n| n >= floor) {
5194        let cur = eval_at(n);
5195        result = cur;
5196        if let Some(p) = prev
5197            && (cur - p).abs() < CLOGLOG_GHQ_CONV_TOL
5198        {
5199            break;
5200        }
5201        prev = Some(cur);
5202    }
5203    result
5204}
5205
5206/// Compute all partial derivatives of `L(μ,σ)` up to fourth order via
5207/// differentiated Gauss-Hermite quadrature.
5208///
5209/// Uses the identity:
5210/// ```text
5211///   ∂^a_μ ∂^b_σ L ≈ (√2)^b / √π  Σ_m ω_m x_m^b g^{(a+b)}(t_m)
5212/// ```
5213///
5214/// `n_nodes` selects the GHQ rule size (7, 15, 21, or 31). For location-scale
5215/// GAMLSS applications, 21-31 nodes is recommended.
5216pub fn cloglog_ghq_derivatives(
5217    ctx: &QuadratureContext,
5218    mu: f64,
5219    sigma: f64,
5220    n_nodes: usize,
5221) -> CLogLogConvolutionDerivatives {
5222    let inv_sqrt_pi = 1.0 / std::f64::consts::PI.sqrt();
5223
5224    // When sigma is negligibly small, evaluate directly at mu.
5225    //
5226    // From ∂^a_μ ∂^b_σ L = E[Z^b] g^{(a+b)}(μ) at σ = 0, only the moments
5227    // E[Z^0]=1, E[Z^2]=1, E[Z^4]=3 survive (all odd moments vanish). So even
5228    // sigma-derivatives are NOT zero: L_σσ = g'', L_μσσ = g''', L_μμσσ = g'''',
5229    // and L_σσσσ = 3 g''''.
5230    if sigma.abs() < 1e-14 {
5231        let (g, g1, g2, g3, g4) = cloglog_g_derivatives(mu);
5232        return CLogLogConvolutionDerivatives {
5233            l: g,
5234            l_mu: g1,
5235            l_sigma: 0.0,
5236            l_mumu: g2,
5237            l_musigma: 0.0,
5238            l_sigmasigma: g2,
5239            l_mumumu: g3,
5240            l_mumusigma: 0.0,
5241            l_musigmasigma: g3,
5242            l_sigmasigmasigma: 0.0,
5243            l_mumumumu: g4,
5244            l_mumumusigma: 0.0,
5245            l_mumusigmasigma: g4,
5246            l_musigmasigmasigma: 0.0,
5247            l_sigmasigmasigmasigma: 3.0 * g4,
5248        };
5249    }
5250
5251    let scale = SQRT_2 * sigma;
5252    let sqrt2 = SQRT_2;
5253
5254    with_gh_nodesweights(ctx, n_nodes, |nodes, weights| {
5255        // Accumulators for the weighted sums. For derivative ∂^a_μ ∂^b_σ L,
5256        // we need Σ ω_m x_m^b g^{(a+b)}(t_m). We group by the order of g
5257        // derivative needed (k = a + b) and the power of x_m (= b).
5258        //
5259        // k=0: g(t_m)    — need x^0
5260        // k=1: g'(t_m)   — need x^0, x^1
5261        // k=2: g''(t_m)  — need x^0, x^1, x^2
5262        // k=3: g'''(t_m) — need x^0, x^1, x^2, x^3
5263        // k=4: g''''(t_m)— need x^0, x^1, x^2, x^3, x^4
5264
5265        // s[k][b] = Σ_m ω_m x_m^b g^{(k)}(t_m)
5266        let mut s = [[0.0_f64; 5]; 5];
5267
5268        for i in 0..nodes.len() {
5269            let x = nodes[i];
5270            let t = mu + scale * x;
5271            let (g0, g1, g2, g3, g4) = cloglog_g_derivatives(t);
5272            let w = weights[i];
5273
5274            // Powers of x_m
5275            let x2 = x * x;
5276            let x3 = x2 * x;
5277            let x4 = x3 * x;
5278
5279            // k=0: only need x^0
5280            s[0][0] += w * g0;
5281
5282            // k=1: need x^0, x^1
5283            s[1][0] += w * g1;
5284            s[1][1] += w * x * g1;
5285
5286            // k=2: need x^0, x^1, x^2
5287            s[2][0] += w * g2;
5288            s[2][1] += w * x * g2;
5289            s[2][2] += w * x2 * g2;
5290
5291            // k=3: need x^0, x^1, x^2, x^3
5292            s[3][0] += w * g3;
5293            s[3][1] += w * x * g3;
5294            s[3][2] += w * x2 * g3;
5295            s[3][3] += w * x3 * g3;
5296
5297            // k=4: need x^0, x^1, x^2, x^3, x^4
5298            s[4][0] += w * g4;
5299            s[4][1] += w * x * g4;
5300            s[4][2] += w * x2 * g4;
5301            s[4][3] += w * x3 * g4;
5302            s[4][4] += w * x4 * g4;
5303        }
5304
5305        // Now assemble derivatives using:
5306        //   ∂^a_μ ∂^b_σ L = (√2)^b / √π · s[a+b][b]
5307        let sqrt2_1 = sqrt2;
5308        let sqrt2_2 = 2.0; // (√2)^2
5309        let sqrt2_3 = 2.0 * sqrt2; // (√2)^3
5310        let sqrt2_4 = 4.0; // (√2)^4
5311
5312        CLogLogConvolutionDerivatives {
5313            // 0th: a=0, b=0 → (√2)^0 / √π · s[0][0]
5314            l: inv_sqrt_pi * s[0][0],
5315
5316            // 1st: (a=1,b=0), (a=0,b=1)
5317            l_mu: inv_sqrt_pi * s[1][0],
5318            l_sigma: inv_sqrt_pi * sqrt2_1 * s[1][1],
5319
5320            // 2nd: (a=2,b=0), (a=1,b=1), (a=0,b=2)
5321            l_mumu: inv_sqrt_pi * s[2][0],
5322            l_musigma: inv_sqrt_pi * sqrt2_1 * s[2][1],
5323            l_sigmasigma: inv_sqrt_pi * sqrt2_2 * s[2][2],
5324
5325            // 3rd: (a=3,b=0), (a=2,b=1), (a=1,b=2), (a=0,b=3)
5326            l_mumumu: inv_sqrt_pi * s[3][0],
5327            l_mumusigma: inv_sqrt_pi * sqrt2_1 * s[3][1],
5328            l_musigmasigma: inv_sqrt_pi * sqrt2_2 * s[3][2],
5329            l_sigmasigmasigma: inv_sqrt_pi * sqrt2_3 * s[3][3],
5330
5331            // 4th: (a=4,b=0), (a=3,b=1), (a=2,b=2), (a=1,b=3), (a=0,b=4)
5332            l_mumumumu: inv_sqrt_pi * s[4][0],
5333            l_mumumusigma: inv_sqrt_pi * sqrt2_1 * s[4][1],
5334            l_mumusigmasigma: inv_sqrt_pi * sqrt2_2 * s[4][2],
5335            l_musigmasigmasigma: inv_sqrt_pi * sqrt2_3 * s[4][3],
5336            l_sigmasigmasigmasigma: inv_sqrt_pi * sqrt2_4 * s[4][4],
5337        }
5338    })
5339}
5340
5341/// Convenience wrapper that uses adaptive node count based on sigma magnitude.
5342///
5343/// For small sigma, fewer nodes suffice; for large sigma, more are needed to
5344/// capture tail contributions accurately. This mirrors the adaptive strategy
5345/// used by `integrate_normal_ghq_adaptive`.
5346pub fn cloglog_ghq_derivatives_adaptive(
5347    ctx: &QuadratureContext,
5348    mu: f64,
5349    sigma: f64,
5350) -> CLogLogConvolutionDerivatives {
5351    let n = adaptive_point_count_from_sd(sigma.abs());
5352    cloglog_ghq_derivatives(ctx, mu, sigma, n)
5353}
5354
5355#[cfg(test)]
5356mod tests {
5357    use super::*;
5358    use approx::assert_relative_eq;
5359    use gam_problem::LikelihoodScaleMetadata;
5360    use gam_spec::LikelihoodSpec;
5361
5362    /// Pins `log_half_erfc_stable` (both the `u > 0` erfcx branch and the
5363    /// `u <= 0` `normal_logcdf` branch) against an external high-precision
5364    /// reference (mpmath, dps=50) for `log(0.5·erfc(u))`. Guards the #932
5365    /// root-cause fix: the `u <= 0` branch previously routed through
5366    /// `statrs::erfc` (~1e-10 relative error); the 1e-12 tolerance here fails
5367    /// on any regression to a low-accuracy complementary error function.
5368    #[test]
5369    fn log_half_erfc_stable_matches_high_precision_reference() {
5370        let refs: &[(f64, f64)] = &[
5371            (-3.0, -1.1045309498499094e-5),
5372            (-1.5, -0.017092677825984745),
5373            (-0.5, -0.27410803278438573),
5374            (0.0, -0.69314718055994531),
5375            (0.7, -1.8257336940742865),
5376            (2.0, -6.0580884451765829),
5377            (5.0, -27.89403672609738),
5378            (12.0, -147.75386135854695),
5379        ];
5380        for &(u, reference) in refs {
5381            let got = log_half_erfc_stable(u);
5382            let rel = (got - reference).abs() / reference.abs().max(1.0e-6);
5383            assert!(
5384                rel < 1.0e-12,
5385                "log_half_erfc_stable({u}) = {got:.17e}, reference {reference:.17e}, \
5386                 rel {rel:.3e} >= 1e-12"
5387            );
5388        }
5389    }
5390
5391    pub(crate) fn cloglog_posterior_meanwith_deriv_gamma_reference(
5392        mu: f64,
5393        sigma: f64,
5394    ) -> Result<IntegratedMeanDerivative, EstimationError> {
5395        // Reference: mean = 1 - S(mu, sigma), dmean/dmu = exp(mu + sigma^2/2) *
5396        // S(mu + sigma^2, sigma).
5397        let survival = cloglog_survival_gamma_reference(mu, sigma)?;
5398        let shifted_survival = cloglog_survival_gamma_reference(mu + sigma * sigma, sigma)?;
5399        let mean = cloglog_mean_from_survival(survival);
5400        let dmean = cloglog_shift_identity_derivative(mu, sigma, shifted_survival);
5401        if !(mean.is_finite() && dmean.is_finite()) {
5402            crate::bail_invalid_estim!(
5403                "Gamma cloglog reference backend produced non-finite values"
5404            );
5405        }
5406        Ok(IntegratedMeanDerivative {
5407            mean,
5408            dmean_dmu: dmean.max(0.0),
5409            mode: IntegratedExpectationMode::ExactSpecialFunction,
5410        })
5411    }
5412
5413    fn even_moment_exp_neg_x2(power: usize) -> f64 {
5414        assert!(power.is_multiple_of(2));
5415        let m = power / 2;
5416        let mut odd_double_factorial = 1.0_f64;
5417        for k in 0..m {
5418            odd_double_factorial *= (2 * k + 1) as f64;
5419        }
5420        odd_double_factorial * std::f64::consts::PI.sqrt() / 2.0_f64.powi(m as i32)
5421    }
5422
5423    fn normal_pdf(z: f64) -> f64 {
5424        (-(z * z) * 0.5).exp() / (2.0 * std::f64::consts::PI).sqrt()
5425    }
5426
5427    fn high_res_sigmoid_integral(eta: f64, se: f64) -> f64 {
5428        // Composite Simpson rule over a wide finite interval under N(0,1).
5429        let a = -12.0_f64;
5430        let b = 12.0_f64;
5431        let n = 20_000usize; // even
5432        let h = (b - a) / n as f64;
5433
5434        let integrand = |z: f64| -> f64 { sigmoid(eta + se * z) * normal_pdf(z) };
5435
5436        let mut sum = integrand(a) + integrand(b);
5437        for i in 1..n {
5438            let x = a + (i as f64) * h;
5439            if i % 2 == 0 {
5440                sum += 2.0 * integrand(x);
5441            } else {
5442                sum += 4.0 * integrand(x);
5443            }
5444        }
5445        sum * h / 3.0
5446    }
5447
5448    #[test]
5449    fn test_computed_nodes_symmetric() {
5450        // Verify computed nodes are symmetric around zero
5451        let ctx = QuadratureContext::new();
5452        let gh = ctx.gauss_hermite();
5453        for i in 0..N_POINTS / 2 {
5454            let j = N_POINTS - 1 - i;
5455            assert_relative_eq!(gh.nodes[i], -gh.nodes[j], epsilon = 1e-12);
5456        }
5457        // Middle node is expected to be zero
5458        assert_relative_eq!(gh.nodes[N_POINTS / 2], 0.0, epsilon = 1e-12);
5459    }
5460
5461    #[test]
5462    fn test_computedweights_symmetric() {
5463        // Verify computed weights are symmetric
5464        let ctx = QuadratureContext::new();
5465        let gh = ctx.gauss_hermite();
5466        for i in 0..N_POINTS / 2 {
5467            let j = N_POINTS - 1 - i;
5468            assert_relative_eq!(gh.weights[i], gh.weights[j], epsilon = 1e-12);
5469        }
5470    }
5471
5472    #[test]
5473    fn testweights_sum_to_sqrt_pi() {
5474        // Verify weights sum to sqrt(pi) for physicist's Hermite
5475        let ctx = QuadratureContext::new();
5476        let gh = ctx.gauss_hermite();
5477        let sum: f64 = gh.weights.iter().sum();
5478        assert_relative_eq!(sum, std::f64::consts::PI.sqrt(), epsilon = 1e-10);
5479    }
5480
5481    #[test]
5482    fn test_clenshaw_curtisweights_are_symmetric_and_integrate_constants() {
5483        let rule = compute_clenshaw_curtis_n(33);
5484        let m = rule.weights.len() - 1;
5485        for j in 0..=m / 2 {
5486            assert_relative_eq!(rule.nodes[j], -rule.nodes[m - j], epsilon = 1e-14);
5487            assert_relative_eq!(rule.weights[j], rule.weights[m - j], epsilon = 1e-14);
5488        }
5489        let sum: f64 = rule.weights.iter().sum();
5490        assert_relative_eq!(sum, 2.0, epsilon = 1e-14, max_relative = 1e-14);
5491    }
5492
5493    #[test]
5494    fn test_cc_preference_prefers_moderate_central_case() {
5495        assert!(cloglog_should_prefer_cc(-0.2, 0.8, CLOGLOG_CC_TOL));
5496    }
5497
5498    #[test]
5499    fn test_cc_preference_prefers_moderately_large_case() {
5500        assert!(cloglog_should_prefer_cc(0.0, 2.0, CLOGLOG_CC_TOL));
5501    }
5502
5503    #[test]
5504    fn test_cc_preference_rejects_broad_case() {
5505        assert!(!cloglog_should_prefer_cc(0.0, 5.0, CLOGLOG_CC_TOL));
5506    }
5507
5508    #[test]
5509    fn test_matches_abramowitz_stegun_7_point_gauss_hermite_constants() {
5510        // Abramowitz & Stegun 25.4, 7-point Gauss-Hermite rule for the
5511        // physicist's weight exp(-x^2). This pins both the Jacobi matrix and
5512        // the eigenvector orientation used for Golub-Welsch weights.
5513        let known_nodes = [
5514            -2.651_961_356_835_233_4,
5515            -1.673_551_628_767_471_4,
5516            -0.816_287_882_858_964_7,
5517            0.0,
5518            0.816_287_882_858_964_7,
5519            1.673_551_628_767_471_4,
5520            2.651_961_356_835_233_4,
5521        ];
5522        let knownweights = [
5523            0.000_971_781_245_099_519_1,
5524            0.054_515_582_819_127_03,
5525            0.425_607_252_610_127_8,
5526            0.810_264_617_556_807_3,
5527            0.425_607_252_610_127_8,
5528            0.054_515_582_819_127_03,
5529            0.000_971_781_245_099_519_1,
5530        ];
5531
5532        let ctx = QuadratureContext::new();
5533        let gh = ctx.gauss_hermite();
5534        for i in 0..N_POINTS {
5535            assert_relative_eq!(gh.nodes[i], known_nodes[i], epsilon = 1e-12);
5536            assert_relative_eq!(gh.weights[i], knownweights[i], epsilon = 1e-12);
5537        }
5538    }
5539
5540    #[test]
5541    fn testzero_se_returns_mode() {
5542        // When SE is zero, posterior mean is expected to equal mode
5543        let eta = 1.5;
5544        let se = 0.0;
5545        let ctx = QuadratureContext::new();
5546        let mean = logit_posterior_mean(&ctx, eta, se);
5547        let mode = sigmoid(eta);
5548        assert_relative_eq!(mean, mode, epsilon = 1e-10);
5549    }
5550
5551    #[test]
5552    fn test_symmetric_atzero() {
5553        // At eta=0 (50% probability), mean is expected to be ~50%
5554        let eta = 0.0;
5555        let se = 1.0;
5556        let ctx = QuadratureContext::new();
5557        let mean = logit_posterior_mean(&ctx, eta, se);
5558        // Due to symmetry of sigmoid around 0, mean ≈ mode
5559        assert_relative_eq!(mean, 0.5, epsilon = 0.01);
5560    }
5561
5562    #[test]
5563    fn test_shrinkage_at_extremes() {
5564        // At extreme eta, mean is expected to be pulled toward 0.5
5565        let eta = 3.0; // mode = sigmoid(3) ≈ 0.953
5566        let se = 1.0;
5567        let ctx = QuadratureContext::new();
5568        let mean = logit_posterior_mean(&ctx, eta, se);
5569        let mode = sigmoid(eta);
5570
5571        // Mean is expected to be less than mode (shrunk toward 0.5)
5572        assert!(mean < mode, "Expected mean {} < mode {}", mean, mode);
5573        // But still reasonably high
5574        assert!(mean > 0.8, "Mean {} should still be high", mean);
5575    }
5576
5577    #[test]
5578    fn test_matches_monte_carlo() {
5579        // Compare quadrature to Monte Carlo with many samples
5580        let eta = 2.0;
5581        let se = 0.8;
5582
5583        let ctx = QuadratureContext::new();
5584        let quad_mean = logit_posterior_mean(&ctx, eta, se);
5585
5586        // Monte Carlo with 100,000 samples
5587        let n_samples = 100_000;
5588        let mut mc_sum = 0.0;
5589        let mut rng_state = 12345u64; // Simple LCG for reproducibility
5590        for _ in 0..n_samples {
5591            // Box-Muller for normal samples
5592            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
5593            let u1 = ((rng_state as f64) / (u64::MAX as f64)).max(1e-10); // Prevent ln(0)
5594            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
5595            let u2 = (rng_state as f64) / (u64::MAX as f64);
5596            let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
5597            let eta_sample = eta + se * z;
5598            mc_sum += sigmoid(eta_sample);
5599        }
5600        let mc_mean = mc_sum / (n_samples as f64);
5601
5602        // Should match within Monte Carlo sampling error (~0.01)
5603        assert_relative_eq!(quad_mean, mc_mean, epsilon = 0.01);
5604    }
5605
5606    #[test]
5607    fn test_quadrature_integrates_x_squared() {
5608        // The quadrature exactly integrates x² against exp(-x²)
5609        // ∫ x² exp(-x²) dx = sqrt(π)/2
5610        let ctx = QuadratureContext::new();
5611        let gh = ctx.gauss_hermite();
5612        let mut sum = 0.0;
5613        for i in 0..N_POINTS {
5614            sum += gh.weights[i] * gh.nodes[i] * gh.nodes[i];
5615        }
5616        let expected = std::f64::consts::PI.sqrt() / 2.0;
5617        assert_relative_eq!(sum, expected, epsilon = 1e-10);
5618    }
5619
5620    #[test]
5621    fn test_quadrature_integrates_x_fourth() {
5622        // The quadrature exactly integrates x⁴ against exp(-x²)
5623        // ∫ x⁴ exp(-x²) dx = 3*sqrt(π)/4
5624        let ctx = QuadratureContext::new();
5625        let gh = ctx.gauss_hermite();
5626        let mut sum = 0.0;
5627        for i in 0..N_POINTS {
5628            let x = gh.nodes[i];
5629            sum += gh.weights[i] * x * x * x * x;
5630        }
5631        let expected = 3.0 * std::f64::consts::PI.sqrt() / 4.0;
5632        assert_relative_eq!(sum, expected, epsilon = 1e-10);
5633    }
5634
5635    #[test]
5636    fn test_moment_exactness_up_to_degree_13() {
5637        let ctx = QuadratureContext::new();
5638        let gh = ctx.gauss_hermite();
5639
5640        for degree in 0..=13usize {
5641            let approx: f64 = (0..N_POINTS)
5642                .map(|i| gh.weights[i] * gh.nodes[i].powi(degree as i32))
5643                .sum();
5644
5645            let expected = if degree % 2 == 1 {
5646                0.0
5647            } else {
5648                even_moment_exp_neg_x2(degree)
5649            };
5650
5651            let err = (approx - expected).abs();
5652            let rel_scale = approx.abs().max(expected.abs()).max(1.0);
5653            assert!(
5654                err <= 1e-10 || err / rel_scale <= 1e-10,
5655                "degree={} approx={} expected={} abs_err={}",
5656                degree,
5657                approx,
5658                expected,
5659                err
5660            );
5661        }
5662    }
5663
5664    #[test]
5665    fn test_integrated_sigmoid_matches_high_res_integral_random_pairs() {
5666        let ctx = QuadratureContext::new();
5667        let mut rng_state = 0x4d595df4d0f33173u64;
5668
5669        for _ in 0..20 {
5670            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
5671            let u_eta = (rng_state as f64) / (u64::MAX as f64);
5672            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
5673            let u_se = (rng_state as f64) / (u64::MAX as f64);
5674
5675            let eta = -6.0 + 12.0 * u_eta;
5676            let se = 0.02 + 1.5 * u_se;
5677
5678            let ghq = logit_posterior_mean(&ctx, eta, se);
5679            let numeric = high_res_sigmoid_integral(eta, se);
5680            assert_relative_eq!(ghq, numeric, epsilon = 2e-3);
5681        }
5682    }
5683
5684    #[test]
5685    fn test_logit_posterior_derivative_remains_positive_in_positive_tail() {
5686        let eta = 20.0;
5687        let se = 0.0;
5688        let (_, dmu) = logit_posterior_meanwith_deriv(eta, se)
5689            .expect("logit posterior mean derivative should evaluate");
5690        assert!(dmu > 0.0);
5691        assert!(
5692            dmu < 1e-6,
5693            "positive-tail derivative should stay tiny but nonzero, got {dmu}"
5694        );
5695    }
5696
5697    #[test]
5698    fn test_logit_posterior_derivative_matches_central_difference() {
5699        let ctx = QuadratureContext::new();
5700        let eta = 1.7;
5701        let se = 0.9;
5702        let h = 1e-5;
5703
5704        let (_, dmu) = logit_posterior_meanwith_deriv(eta, se)
5705            .expect("logit posterior mean derivative should evaluate");
5706        let mu_plus = logit_posterior_mean(&ctx, eta + h, se);
5707        let mu_minus = logit_posterior_mean(&ctx, eta - h, se);
5708        let dmufd = (mu_plus - mu_minus) / (2.0 * h);
5709
5710        assert_eq!(dmu.signum(), dmufd.signum());
5711        assert_relative_eq!(dmu, dmufd, epsilon = 5e-6, max_relative = 2e-4);
5712    }
5713
5714    /// Independent dense reference for `E[sigmoid(η)]`, `η ~ N(mu, sigma²)`,
5715    /// using composite Simpson on a wide grid under N(0,1). No Gauss–Hermite,
5716    /// no Faddeeva, no erfcx — a method-independent arbiter accurate to
5717    /// ~1e-13 for the smooth bounded integrand. This is the test the #1459
5718    /// oracle is held to.
5719    fn dense_sigmoid_normal_mean(mu: f64, sigma: f64) -> f64 {
5720        let a = -18.0_f64;
5721        let b = 18.0_f64;
5722        let n = 400_000usize; // even
5723        let h = (b - a) / n as f64;
5724        let integrand = |z: f64| -> f64 { sigmoid(mu + sigma * z) * normal_pdf(z) };
5725        let mut sum = integrand(a) + integrand(b);
5726        for i in 1..n {
5727            let z = a + (i as f64) * h;
5728            sum += if i % 2 == 0 { 2.0 } else { 4.0 } * integrand(z);
5729        }
5730        sum * h / 3.0
5731    }
5732
5733    #[test]
5734    fn test_logit_posterior_mean_exact_symmetry_identity() {
5735        // sigmoid is odd-symmetric about 1/2, so E[sigmoid(η;μ)] +
5736        // E[sigmoid(η;−μ)] = 1 exactly; the oracle must honor it to ~f64.
5737        let cases = [
5738            (-3.0, 0.5),
5739            (-1.2, 1.7),
5740            (0.0, 2.2),
5741            (2.3, 0.8),
5742            (3.0, 0.05),
5743        ];
5744        for (mu, sigma) in cases {
5745            let p = logit_posterior_mean_exact(mu, sigma);
5746            let q = logit_posterior_mean_exact(-mu, sigma);
5747            assert!(
5748                (p + q - 1.0).abs() < 1e-12,
5749                "symmetry broken at mu={mu} sigma={sigma}: p+q-1 = {:.3e}",
5750                p + q - 1.0
5751            );
5752        }
5753    }
5754
5755    #[test]
5756    fn test_logit_posterior_mean_exact_matches_high_res_integral() {
5757        // Spans small σ (where erfcx-style schemes underflow), moderate σ, and
5758        // both signs of μ. The pre-#1459 4096-term truncation failed these by
5759        // 1e-5 (μ-linear); the accelerated oracle holds 1e-10.
5760        let cases = [
5761            (-2.0, 0.4),
5762            (-0.7, 1.1),
5763            (0.8, 0.9),
5764            (2.4, 1.7),
5765            (3.0, 0.05),
5766            (3.0, 0.5),
5767            (-2.0, 2.0),
5768            (5.0, 3.0),
5769        ];
5770        for (mu, sigma) in cases {
5771            let exact = logit_posterior_mean_exact(mu, sigma);
5772            let numeric = dense_sigmoid_normal_mean(mu, sigma);
5773            assert!(
5774                (exact - numeric).abs() < 1e-10,
5775                "oracle ≠ dense reference at mu={mu} sigma={sigma}: \
5776                 exact={exact:.13} ref={numeric:.13} err={:.3e}",
5777                (exact - numeric).abs()
5778            );
5779        }
5780    }
5781
5782    /// Regression for #1459: the Faddeeva-pole oracle carried a μ-linear,
5783    /// σ-independent bias toward 1/2 of `μ/(2π²·4096) ≈ 1.236e-5·μ` because it
5784    /// hard-truncated an O(1/N) series at 4096 terms. This reproduces the exact
5785    /// table from the bug report and demands the oracle resolve `E[sigmoid(η)]`
5786    /// to 1e-10 — four orders tighter than the bug — including the diagnostic
5787    /// structure (the error was *identical* across σ at fixed μ).
5788    #[test]
5789    fn test_logit_posterior_mean_exact_no_truncation_bias_1459() {
5790        // Full Cartesian grid {1,3,-2} x {0.02,0.05,0.5,2.0} (12 cases; salvaged
5791        // from PR #1462 by HomunculusLabs — was an 8-case hand-picked subset).
5792        let table = [
5793            (1.0, 0.02),
5794            (1.0, 0.05),
5795            (1.0, 0.5),
5796            (1.0, 2.0),
5797            (3.0, 0.02),
5798            (3.0, 0.05),
5799            (3.0, 0.5),
5800            (3.0, 2.0),
5801            (-2.0, 0.02),
5802            (-2.0, 0.05),
5803            (-2.0, 0.5),
5804            (-2.0, 2.0),
5805        ];
5806        for (mu, sigma) in table {
5807            let exact = logit_posterior_mean_exact(mu, sigma);
5808            let reference = dense_sigmoid_normal_mean(mu, sigma);
5809            let err = (exact - reference).abs();
5810            assert!(
5811                err < 1e-10,
5812                "#1459 truncation bias resurfaced at mu={mu} sigma={sigma}: \
5813                 err={err:.3e} (pre-fix bias here was ~{:.2e})",
5814                mu.abs() / (2.0 * std::f64::consts::PI.powi(2) * 4096.0)
5815            );
5816        }
5817
5818        // The defining symptom: at fixed μ the old bias was constant in σ. The
5819        // fixed oracle must have *no* such σ-independent residual — the spread
5820        // of (oracle − reference) across σ at μ=3 must be ~round-off, not the
5821        // old 3.71e-5 plateau.
5822        let mu = 3.0;
5823        let errs: Vec<f64> = [0.05, 0.5, 2.0]
5824            .iter()
5825            .map(|&s| logit_posterior_mean_exact(mu, s) - dense_sigmoid_normal_mean(mu, s))
5826            .collect();
5827        for e in &errs {
5828            assert!(
5829                e.abs() < 1e-10,
5830                "residual {e:.3e} at mu=3 — old σ-independent plateau was 3.71e-5"
5831            );
5832        }
5833    }
5834
5835    /// The new Weideman Faddeeva evaluator must match known `w(z)` values to
5836    /// near machine precision on the upper half-plane. References are
5837    /// machine-precision values of `w(z)` (SciPy `wofz` / mpmath), NOT the
5838    /// crate's `erfcx_nonnegative` — which this very check revealed to be only
5839    /// ~6e-11 accurate (it inherits `statrs::erfc`'s rational-approx error),
5840    /// so using it as the reference would both slacken the bound and certify
5841    /// against a wrong value.
5842    #[test]
5843    fn test_faddeeva_weideman_matches_known_values() {
5844        // w(0) = 1.
5845        let w0 = faddeeva_upper_halfplane(Complex { re: 0.0, im: 0.0 });
5846        assert!(
5847            (w0.re - 1.0).abs() < 1e-13 && w0.im.abs() < 1e-13,
5848            "w(0)={w0:?}"
5849        );
5850        // w(i·y) is purely real and equals erfcx(y) for y>0 (reference: wofz).
5851        let on_axis = [
5852            (0.1, 0.8964569799691268),
5853            (0.5, 0.6156903441929258),
5854            (1.0, 0.427583576155807),
5855            (2.0, 0.2553956763105058),
5856            (5.0, 0.11070463773306861),
5857            (9.0, 0.06230772403777468),
5858        ];
5859        for (y, want) in on_axis {
5860            let w = faddeeva_upper_halfplane(Complex { re: 0.0, im: y });
5861            assert!(
5862                (w.re - want).abs() < 1e-13 && w.im.abs() < 1e-13,
5863                "w(i·{y}): got {w:?}, want re={want}, err={:.2e}",
5864                (w.re - want).abs()
5865            );
5866        }
5867        // Off-axis values across the upper half-plane (reference: wofz).
5868        let off_axis = [
5869            ((0.7, 1.3), (0.31327301971562715, 0.12443489420104513)),
5870            ((-1.5, 0.8), (0.21066359024766423, -0.27001624496296617)),
5871            ((3.0, 0.4), (0.030278754646989155, 0.1957320888774461)),
5872        ];
5873        for ((re, im), (wre, wim)) in off_axis {
5874            let w = faddeeva_upper_halfplane(Complex { re, im });
5875            assert!(
5876                (w.re - wre).abs() < 1e-13 && (w.im - wim).abs() < 1e-13,
5877                "w({re}+{im}i): got {w:?}, want ({wre},{wim})"
5878            );
5879        }
5880        // Large |z| (deep in the series tail, |z|≈40): must stay machine-precise,
5881        // not merely match the leading i/(√π z) asymptotic (which is only ~4e-6
5882        // accurate there). Reference: wofz(3+40i).
5883        let w = faddeeva_upper_halfplane(Complex { re: 3.0, im: 40.0 });
5884        assert!(
5885            (w.re - 0.01402158696172506).abs() < 1e-13
5886                && (w.im - 0.0010509664408184546).abs() < 1e-13,
5887            "tail value mismatch: w={w:?}"
5888        );
5889    }
5890
5891    #[test]
5892    fn test_integrated_logit_mean_close_to_exact_oracle() {
5893        // The production integrated-logit path (erfcx series + Simpson
5894        // drift-check) is ~1e-8 accurate; the oracle is now ~1e-13, so it can
5895        // certify the production path far more tightly than the old 2.5e-3.
5896        let ctx = QuadratureContext::new();
5897        let cases = [(-3.0, 0.3), (-1.0, 0.8), (0.5, 1.2), (2.8, 1.0)];
5898        for (eta, se) in cases {
5899            let ghq = logit_posterior_mean(&ctx, eta, se);
5900            let exact = logit_posterior_mean_exact(eta, se);
5901            assert!(
5902                (ghq - exact).abs() < 1e-6,
5903                "production path drifts from oracle at eta={eta} se={se}: \
5904                 ghq={ghq:.12} oracle={exact:.12} gap={:.3e}",
5905                (ghq - exact).abs()
5906            );
5907        }
5908    }
5909
5910    #[test]
5911    fn test_probit_posterior_mean_reduces_to_map_atzero_se() {
5912        let eta = 1.25;
5913        let p = probit_posterior_mean(eta, 0.0);
5914        let map = gam_math::probability::normal_cdf(eta);
5915        assert_relative_eq!(p, map, epsilon = 1e-12);
5916    }
5917
5918    #[test]
5919    fn test_probit_posterior_mean_shrinks_extremeswith_uncertainty() {
5920        let hi_eta = 3.0;
5921        let lo_eta = -3.0;
5922        let p_hi_map = probit_posterior_mean(hi_eta, 0.0);
5923        let p_hi_unc = probit_posterior_mean(hi_eta, 2.0);
5924        let p_lo_map = probit_posterior_mean(lo_eta, 0.0);
5925        let p_lo_unc = probit_posterior_mean(lo_eta, 2.0);
5926        assert!(p_hi_unc < p_hi_map);
5927        assert!(p_lo_unc > p_lo_map);
5928    }
5929
5930    #[test]
5931    fn test_survival_posterior_mean_is_bounded_and_shrinks_tail() {
5932        let ctx = QuadratureContext::new();
5933        let eta: f64 = 3.0;
5934        let map = (-(eta.exp())).exp();
5935        let pm = survival_posterior_mean(&ctx, eta, 1.5);
5936        assert!((0.0..=1.0).contains(&pm));
5937        assert!(pm > map);
5938    }
5939
5940    #[test]
5941    fn test_cloglog_and_survival_posterior_means_are_complements() {
5942        let ctx = QuadratureContext::new();
5943        let cases = [
5944            (-3.0, 0.0),
5945            (-0.2, 0.1),
5946            (0.4, 0.8),
5947            (2.0, 1.5),
5948            (10.0, 0.3),
5949            (0.0, 20.0),
5950            (10.0, 10.0),
5951            (-0.5, 100.0),
5952        ];
5953        for (eta, se) in cases {
5954            let clog = cloglog_posterior_mean(&ctx, eta, se);
5955            let surv = survival_posterior_mean(&ctx, eta, se);
5956            assert_relative_eq!(clog + surv, 1.0, epsilon = 2e-10, max_relative = 2e-10);
5957        }
5958    }
5959
5960    #[test]
5961    fn test_cloglog_and_survival_share_large_sigmaspecial_function_path() {
5962        let ctx = QuadratureContext::new();
5963        let eta = -0.2;
5964        let se = 0.8;
5965        let clog = cloglog_posterior_mean(&ctx, eta, se);
5966        let surv = survival_posterior_mean(&ctx, eta, se);
5967        let integrated =
5968            integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::CLogLog, eta, se)
5969                .expect("cloglog integrated inverse-link moments should evaluate");
5970        assert_eq!(
5971            integrated.mode,
5972            IntegratedExpectationMode::ExactSpecialFunction
5973        );
5974        assert_relative_eq!(clog, integrated.mean, epsilon = 1e-12, max_relative = 1e-12);
5975        assert_relative_eq!(clog + surv, 1.0, epsilon = 1e-10, max_relative = 1e-10);
5976    }
5977
5978    #[test]
5979    fn test_cloglog_and_survival_posteriorvariances_match() {
5980        let ctx = QuadratureContext::new();
5981        let cases = [(-3.0, 0.0), (-0.2, 0.1), (0.4, 0.8), (2.0, 1.5)];
5982        for (eta, se) in cases {
5983            let (_, clogvar) = cloglog_posterior_meanvariance(&ctx, eta, se);
5984            let (_, survvar) = survival_posterior_meanvariance(&ctx, eta, se);
5985            assert_relative_eq!(clogvar, survvar, epsilon = 1e-12, max_relative = 1e-12);
5986        }
5987    }
5988
5989    #[test]
5990    fn test_survivalvariance_uses_exactsecond_moment_shift() {
5991        let ctx = QuadratureContext::new();
5992        let eta = -0.2;
5993        let se = 0.8;
5994        let (survival, _) = cloglog_survival_term_controlled(&ctx, eta, se);
5995        let (survival_sq, _) = cloglog_survivalsecond_moment_controlled(&ctx, eta, se);
5996        let (_, variance) = survival_posterior_meanvariance(&ctx, eta, se);
5997        assert_relative_eq!(
5998            variance,
5999            (survival_sq - survival * survival).max(0.0),
6000            epsilon = 1e-12,
6001            max_relative = 1e-12
6002        );
6003    }
6004
6005    #[test]
6006    fn test_lognormal_laplace_shift_matches_explicitmu_plus_logz() {
6007        let ctx = QuadratureContext::new();
6008        let mu = -0.2;
6009        let sigma = 0.8;
6010        let z = 2.0;
6011        let shifted = lognormal_laplace_term_controlled(&ctx, z, mu, sigma);
6012        let explicit = cloglog_survival_term_controlled(&ctx, mu + z.ln(), sigma);
6013        assert_eq!(shifted.1, explicit.1);
6014        assert_relative_eq!(shifted.0, explicit.0, epsilon = 1e-12, max_relative = 1e-12);
6015    }
6016
6017    #[test]
6018    fn test_integrated_dispatch_uses_closed_form_probit() {
6019        let ctx = QuadratureContext::new();
6020        let out = integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Probit, 0.7, 1.3)
6021            .expect("probit integrated inverse-link moments should evaluate");
6022        assert_eq!(out.mode, IntegratedExpectationMode::ExactClosedForm);
6023        let direct = probit_posterior_meanwith_deriv_exact(0.7, 1.3);
6024        assert_relative_eq!(out.mean, direct.mean, epsilon = 1e-12);
6025        assert_relative_eq!(out.dmean_dmu, direct.dmean_dmu, epsilon = 1e-12);
6026    }
6027
6028    #[test]
6029    fn test_integrated_probit_jet_matches_closed_form_derivatives() {
6030        let ctx = QuadratureContext::new();
6031        let mu = 0.7;
6032        let sigma = 1.3;
6033        let out = integrated_inverse_link_jet(&ctx, LinkFunction::Probit, mu, sigma)
6034            .expect("probit integrated inverse-link jet should evaluate");
6035        let s = (1.0 + sigma * sigma).sqrt();
6036        let z = mu / s;
6037        let pdf = gam_math::probability::normal_pdf(z);
6038        assert_relative_eq!(
6039            out.mean,
6040            gam_math::probability::normal_cdf(z),
6041            epsilon = 1e-12
6042        );
6043        assert_relative_eq!(out.d1, pdf / s, epsilon = 1e-12);
6044        assert_relative_eq!(out.d2, -z * pdf / (s * s), epsilon = 1e-12);
6045        assert_relative_eq!(out.d3, (z * z - 1.0) * pdf / (s * s * s), epsilon = 1e-12);
6046    }
6047
6048    #[test]
6049    fn test_integrated_logit_jet_matches_central_differences() {
6050        // Assertion redesign (see task #21 / inference-auditor finding):
6051        // At (μ=1.1, σ=0.8) the logistic-normal erfcx alternating series has
6052        // a tail bound |R_N| ≤ |m|·√(2/π)·exp(−m²/(2s²))/((N+1)²·s³). Plugging
6053        // in gives a k=2 coefficient ≈ 0.67, so reaching the EPSILON=1e-10
6054        // accuracy contract would require N ≈ √(0.67/1e-10) − 1 ≈ 81619
6055        // terms, far beyond LOGIT_MAX_TERMS=160. The dispatcher therefore
6056        // legitimately routes this input to the GHQ fallback; the resulting
6057        // `mode` field is an implementation detail reflecting a correct
6058        // regime decision, not the property we care about. The mathematical
6059        // contract is VALUE accuracy of the mean and its μ-derivatives, so
6060        // we assert those directly against a high-resolution Simpson
6061        // reference (independent of erfcx / Taylor / asymptotics).
6062        let ctx = QuadratureContext::new();
6063        let mu = 1.1;
6064        let sigma = 0.8;
6065        let out = integrated_inverse_link_jet(&ctx, LinkFunction::Logit, mu, sigma)
6066            .expect("logit integrated inverse-link jet should evaluate");
6067        assert!(matches!(
6068            out.mode,
6069            IntegratedExpectationMode::ExactSpecialFunction
6070                | IntegratedExpectationMode::QuadratureFallback
6071        ));
6072        let (ref_mean, ref_d1, ref_d2, ref_d3) = logit_reference_jet_highres_simpson(mu, sigma);
6073        assert_relative_eq!(out.mean, ref_mean, epsilon = 1e-11, max_relative = 1e-10);
6074        assert_relative_eq!(out.d1, ref_d1, epsilon = 1e-11, max_relative = 1e-10);
6075        assert_relative_eq!(out.d2, ref_d2, epsilon = 1e-11, max_relative = 1e-10);
6076        assert_relative_eq!(out.d3, ref_d3, epsilon = 1e-11, max_relative = 1e-10);
6077    }
6078
6079    #[test]
6080    fn test_integrated_cloglog_jet_matches_central_differences() {
6081        let ctx = QuadratureContext::new();
6082        let mu = 0.4;
6083        let sigma = 0.6;
6084        let h = 1e-4;
6085        let out = integrated_inverse_link_jet(&ctx, LinkFunction::CLogLog, mu, sigma)
6086            .expect("cloglog integrated inverse-link jet should evaluate");
6087        let plus = integrated_inverse_link_jet(&ctx, LinkFunction::CLogLog, mu + h, sigma)
6088            .expect("cloglog integrated inverse-link jet should evaluate");
6089        let minus = integrated_inverse_link_jet(&ctx, LinkFunction::CLogLog, mu - h, sigma)
6090            .expect("cloglog integrated inverse-link jet should evaluate");
6091        let d1fd = (plus.mean - minus.mean) / (2.0 * h);
6092        let d2fd = (plus.d1 - minus.d1) / (2.0 * h);
6093        let d3fd = (plus.d2 - minus.d2) / (2.0 * h);
6094        assert_eq!(out.d1.signum(), d1fd.signum());
6095        assert_eq!(out.d2.signum(), d2fd.signum());
6096        assert_eq!(out.d3.signum(), d3fd.signum());
6097        assert_relative_eq!(out.d1, d1fd, epsilon = 2e-5, max_relative = 3e-4);
6098        assert_relative_eq!(out.d2, d2fd, epsilon = 4e-5, max_relative = 8e-4);
6099        assert_relative_eq!(out.d3, d3fd, epsilon = 8e-5, max_relative = 2e-3);
6100    }
6101
6102    #[test]
6103    fn test_integrated_cloglog_wide_sigma_d3_matches_simpson_and_d2_slope() {
6104        let ctx = QuadratureContext::new();
6105        let cases = [(0.0, 4.0), (-1.0, 4.0), (2.0, 3.0), (3.0, 3.0)];
6106        let h = 1e-4;
6107
6108        for (mu, sigma) in cases {
6109            let out = integrated_inverse_link_jet(&ctx, LinkFunction::CLogLog, mu, sigma)
6110                .expect("wide-sigma cloglog integrated jet should evaluate");
6111            let reference = cloglog_reference_jet_highres_simpson(mu, sigma);
6112            let plus = integrated_inverse_link_jet(&ctx, LinkFunction::CLogLog, mu + h, sigma)
6113                .expect("wide-sigma cloglog integrated jet should evaluate");
6114            let minus = integrated_inverse_link_jet(&ctx, LinkFunction::CLogLog, mu - h, sigma)
6115                .expect("wide-sigma cloglog integrated jet should evaluate");
6116            let d3fd = (plus.d2 - minus.d2) / (2.0 * h);
6117
6118            assert_eq!(out.mode, IntegratedExpectationMode::QuadratureFallback);
6119            assert_relative_eq!(out.mean, reference.0, epsilon = 4e-8, max_relative = 4e-8);
6120            assert_relative_eq!(out.d1, reference.1, epsilon = 4e-8, max_relative = 4e-8);
6121            assert_relative_eq!(out.d2, reference.2, epsilon = 2e-9, max_relative = 2e-7);
6122            assert_relative_eq!(out.d3, reference.3, epsilon = 2e-9, max_relative = 2e-7);
6123            assert_relative_eq!(out.d3, d3fd, epsilon = 2e-7, max_relative = 4e-5);
6124        }
6125    }
6126
6127    #[test]
6128    fn test_latent_cloglog_jet5_matches_higher_order_central_differences() {
6129        let ctx = QuadratureContext::new();
6130        let mu = 0.35;
6131        let sigma = 0.7;
6132        let h = 2e-4;
6133
6134        let out = latent_cloglog_inverse_link_jet5_controlled(&ctx, mu, sigma);
6135        let plus = latent_cloglog_inverse_link_jet5_controlled(&ctx, mu + h, sigma);
6136        let minus = latent_cloglog_inverse_link_jet5_controlled(&ctx, mu - h, sigma);
6137
6138        let d4fd = (plus.d3 - minus.d3) / (2.0 * h);
6139        let d5fd = (plus.d4 - minus.d4) / (2.0 * h);
6140
6141        assert_eq!(out.d4.signum(), d4fd.signum());
6142        assert_eq!(out.d5.signum(), d5fd.signum());
6143        assert_relative_eq!(out.d4, d4fd, epsilon = 2e-4, max_relative = 5e-3);
6144        assert_relative_eq!(out.d5, d5fd, epsilon = 6e-4, max_relative = 2e-2);
6145    }
6146
6147    #[test]
6148    fn test_logit_exact_derivative_matches_finite_difference() {
6149        // Assertion redesign: at (μ=1.1, σ=0.8) the erfcx series cannot
6150        // reach its EPSILON=1e-10 tail bound within LOGIT_MAX_TERMS=160
6151        // (|R_N| ≈ 0.67/(N+1)², so N* ≈ 81619), and
6152        // `logit_posterior_meanwith_deriv_exact` correctly returns Err.
6153        // The value-accuracy contract lives at the controlled dispatcher,
6154        // which falls back to GHQ when the exact series cannot honor the
6155        // contract; that is what we validate here, against an independent
6156        // high-resolution Simpson reference for BOTH the mean and its
6157        // μ-derivative (d/dμ E[sigmoid] = E[sigmoid']).
6158        let out = logit_posterior_meanwith_deriv_controlled(1.1, 0.8).expect("controlled logit");
6159        let (ref_mean, ref_d1, _, _) = logit_reference_jet_highres_simpson(1.1, 0.8);
6160        assert_relative_eq!(out.mean, ref_mean, epsilon = 1e-11, max_relative = 1e-10);
6161        assert!(out.dmean_dmu > 0.0);
6162        assert_relative_eq!(out.dmean_dmu, ref_d1, epsilon = 1e-11, max_relative = 1e-10);
6163    }
6164
6165    #[test]
6166    fn test_logit_small_sigma_returns_quadrature_truth_2623() {
6167        // A second-order heat-kernel truncation has O(sigma^4) error. The old
6168        // dispatcher computed this accurate reference but returned the
6169        // truncation whenever it was merely within 1e-6, enough to reverse a
6170        // near-tied posterior-probability pair in issue #2623. Small-sigma
6171        // logistic-normal means now return the quadrature value itself.
6172        for &(mu, sigma) in &[
6173            (-3.0, 0.05),
6174            (-0.5, 0.10),
6175            (0.0, 0.20),
6176            (0.5, 0.24),
6177            (3.0, 0.15),
6178        ] {
6179            let out =
6180                logit_posterior_meanwith_deriv_controlled(mu, sigma).expect("controlled logit");
6181            let (ref_mean, ref_d1, _, _) = logit_reference_jet_highres_simpson(mu, sigma);
6182            assert_eq!(out.mode, IntegratedExpectationMode::QuadratureFallback);
6183            assert_relative_eq!(out.mean, ref_mean, epsilon = 1e-12, max_relative = 1e-11);
6184            assert_relative_eq!(
6185                out.dmean_dmu,
6186                ref_d1,
6187                epsilon = 1e-12,
6188                max_relative = 1e-11
6189            );
6190        }
6191    }
6192
6193    #[test]
6194    fn test_logit_exact_clamped_degenerate_branch_is_locally_flat() {
6195        let out = logit_posterior_meanwith_deriv_exact(-710.0, 0.0).expect("exact logit");
6196        let h = 1e-6;
6197        let plus = logit_posterior_meanwith_deriv_exact(-710.0 + h, 0.0)
6198            .expect("exact logit plus")
6199            .mean;
6200        let minus = logit_posterior_meanwith_deriv_exact(-710.0 - h, 0.0)
6201            .expect("exact logit minus")
6202            .mean;
6203        let fd = (plus - minus) / (2.0 * h);
6204        assert_eq!(fd, 0.0);
6205        assert_eq!(out.dmean_dmu, 0.0);
6206    }
6207
6208    fn simpson_integrate<F>(a: f64, b: f64, n_intervals: usize, f: F) -> f64
6209    where
6210        F: Fn(f64) -> f64,
6211    {
6212        assert_eq!(n_intervals % 2, 0, "Simpson integration requires an even n");
6213        let h = (b - a) / n_intervals as f64;
6214        let mut sum = f(a) + f(b);
6215        for i in 1..n_intervals {
6216            let x = a + i as f64 * h;
6217            let w = if i % 2 == 0 { 2.0 } else { 4.0 };
6218            sum += w * f(x);
6219        }
6220        sum * h / 3.0
6221    }
6222
6223    fn cloglog_reference_mean_and_derivative(mu: f64, sigma: f64) -> (f64, f64) {
6224        if sigma <= CLOGLOG_SIGMA_DEGENERATE {
6225            return (cloglog_mean_exact(mu), cloglog_mean_d1_exact(mu));
6226        }
6227
6228        // Independent reference: exact pointwise cloglog mean/derivative
6229        // integrated against the Gaussian density on a window whose omitted
6230        // tail mass is below 2e-33.
6231        let z_max = 12.0;
6232        let n_intervals = 4096;
6233        let inv_sqrt_2pi = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
6234        let mean = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6235            let eta = mu + sigma * z;
6236            inv_sqrt_2pi * (-0.5 * z * z).exp() * cloglog_mean_exact(eta)
6237        });
6238        let deriv = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6239            let eta = mu + sigma * z;
6240            inv_sqrt_2pi * (-0.5 * z * z).exp() * cloglog_mean_d1_exact(eta)
6241        });
6242        (mean, deriv)
6243    }
6244
6245    /// Independent high-resolution reference for the logit posterior jet.
6246    ///
6247    /// For eta ~ N(mu, sigma^2) and f(x) = sigmoid(x), the μ-derivatives of
6248    /// E[f(eta)] equal E[f^(k)(eta)] by the location-family identity
6249    ///     d^k/dmu^k E[f(mu + sigma Z)] = E[f^(k)(mu + sigma Z)].
6250    /// We evaluate each E[f^(k)] via composite Simpson's rule on the Gaussian
6251    /// density over [-z_max, z_max] with z_max=14 (tail mass below 1e-44) and
6252    /// 16384 intervals. Simpson's error bound is (b-a)·h^4·max|f^(4)|/180;
6253    /// at h = 28/16384 ≈ 1.7e-3 this gives ~1e-13 absolute for sigmoid and its
6254    /// low-order derivatives (all bounded by constants ≤ 1 on ℝ). This is
6255    /// mathematically independent of the erfcx-series / Taylor / asymptotic
6256    /// implementations under test.
6257    fn logit_reference_jet_highres_simpson(mu: f64, sigma: f64) -> (f64, f64, f64, f64) {
6258        let z_max = 14.0;
6259        let n_intervals = 16384;
6260        let inv_sqrt_2pi = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
6261        let phi = |z: f64| inv_sqrt_2pi * (-0.5 * z * z).exp();
6262        let mean = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6263            let eta = mu + sigma * z;
6264            let (p, _, _, _) = component_point_jet(LinkComponent::Logit, eta);
6265            phi(z) * p
6266        });
6267        let d1 = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6268            let eta = mu + sigma * z;
6269            let (_, p1, _, _) = component_point_jet(LinkComponent::Logit, eta);
6270            phi(z) * p1
6271        });
6272        let d2 = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6273            let eta = mu + sigma * z;
6274            let (_, _, p2, _) = component_point_jet(LinkComponent::Logit, eta);
6275            phi(z) * p2
6276        });
6277        let d3 = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6278            let eta = mu + sigma * z;
6279            let (_, _, _, p3) = component_point_jet(LinkComponent::Logit, eta);
6280            phi(z) * p3
6281        });
6282        (mean, d1, d2, d3)
6283    }
6284
6285    fn cloglog_reference_jet_highres_simpson(mu: f64, sigma: f64) -> (f64, f64, f64, f64) {
6286        let z_max = 14.0;
6287        let n_intervals = 16384;
6288        let inv_sqrt_2pi = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
6289        let phi = |z: f64| inv_sqrt_2pi * (-0.5 * z * z).exp();
6290        let mean = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6291            let eta = mu + sigma * z;
6292            let (g, _, _, _, _, _) = cloglog_point_jet5(eta);
6293            phi(z) * g
6294        });
6295        let d1 = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6296            let eta = mu + sigma * z;
6297            let (_, g1, _, _, _, _) = cloglog_point_jet5(eta);
6298            phi(z) * g1
6299        });
6300        let d2 = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6301            let eta = mu + sigma * z;
6302            let (_, _, g2, _, _, _) = cloglog_point_jet5(eta);
6303            phi(z) * g2
6304        });
6305        let d3 = simpson_integrate(-z_max, z_max, n_intervals, |z| {
6306            let eta = mu + sigma * z;
6307            let (_, _, _, g3, _, _) = cloglog_point_jet5(eta);
6308            phi(z) * g3
6309        });
6310        (mean, d1, d2, d3)
6311    }
6312
6313    #[test]
6314    fn test_cloglog_taylor_negative_tail_matches_mathematical_target() {
6315        let mu = -40.0;
6316        let sigma = 0.1;
6317        let out = cloglog_small_sigma_taylor(mu, sigma);
6318        let (expected_mean, expected_deriv) = cloglog_reference_mean_and_derivative(mu, sigma);
6319
6320        assert!(
6321            out.dmean_dmu > 0.0,
6322            "negative-tail derivative should remain positive"
6323        );
6324        assert_relative_eq!(
6325            out.mean,
6326            expected_mean,
6327            epsilon = 1e-30,
6328            max_relative = 1e-12
6329        );
6330        assert_relative_eq!(
6331            out.dmean_dmu,
6332            expected_deriv,
6333            epsilon = 1e-30,
6334            max_relative = 1e-12
6335        );
6336    }
6337
6338    #[test]
6339    fn test_cloglog_degenerate_negative_tail_matches_pointwise_target() {
6340        let ctx = QuadratureContext::new();
6341        let mu = -40.0;
6342        let out = cloglog_posterior_meanwith_deriv_controlled(&ctx, mu, 0.0);
6343
6344        assert!(
6345            out.dmean_dmu > 0.0,
6346            "degenerate negative-tail derivative should remain positive"
6347        );
6348        assert_relative_eq!(
6349            out.mean,
6350            cloglog_mean_exact(mu),
6351            epsilon = 1e-30,
6352            max_relative = 1e-15
6353        );
6354        assert_relative_eq!(
6355            out.dmean_dmu,
6356            cloglog_mean_d1_exact(mu),
6357            epsilon = 1e-30,
6358            max_relative = 1e-15
6359        );
6360    }
6361
6362    #[test]
6363    fn test_degenerate_probit_jet_is_exact_beyond_former_clamp() {
6364        let mu = -30.1;
6365        let probit = integrated_probit_jet(mu, 0.0);
6366        let pdf = gam_math::probability::normal_pdf(mu);
6367        assert!(
6368            pdf > 0.0,
6369            "test point must have a represented Gaussian tail"
6370        );
6371        assert_eq!(probit.mean, gam_math::probability::normal_cdf(mu));
6372        assert_eq!(probit.d1, pdf);
6373        assert_eq!(probit.d2, -mu * pdf);
6374        assert_eq!(probit.d3, (mu * mu - 1.0) * pdf);
6375
6376        // eta = -710 is where the NAIVE `1/(1 + exp(-eta))` would overflow (`exp`
6377        // tops out near +709.78, so `exp(710)` is `inf` and the naive quotient
6378        // collapses to a flat zero jet). It is NOT where the logistic tail stops
6379        // being representable: `exp(-710) = 4.476e-309` is a perfectly good
6380        // subnormal, and the f64 tail survives to roughly eta = -745. The stable
6381        // implementation returns that exact tail, and `canonicalzero` keeps it on
6382        // purpose — "a nonzero subnormal is still a representable derivative and
6383        // must survive: replacing it by zero creates an artificial constant tail
6384        // and a kink at MIN_POSITIVE". Asserting zero here would have pinned the
6385        // overflow artifact this function exists to avoid.
6386        //
6387        // At this eta, `t = exp(eta)` is far below machine epsilon, so `1 + t == 1`
6388        // exactly and the whole jet collapses onto `t`:
6389        //     mu = t/(1+t) = t,  d1 = mu(1-mu) = t,
6390        //     d2 = d1(1-2mu)   = t,  d3 = d1(1-6mu+6mu^2) = t.
6391        let tail = (-710.0_f64).exp();
6392        assert!(
6393            tail > 0.0 && tail < f64::MIN_POSITIVE,
6394            "eta=-710 must sit in the subnormal tail, not underflow"
6395        );
6396        let logit = component_point_jet(LinkComponent::Logit, -710.0);
6397        assert_eq!(logit.0, tail);
6398        assert_eq!(logit.1, tail);
6399        assert_eq!(logit.2, tail);
6400        assert_eq!(logit.3, tail);
6401
6402        // Only PAST the representable tail may the jet legitimately vanish.
6403        assert_eq!(
6404            (-750.0_f64).exp(),
6405            0.0,
6406            "eta=-750 must underflow f64 for this arm to mean anything"
6407        );
6408        let underflowed = component_point_jet(LinkComponent::Logit, -750.0);
6409        assert_eq!(underflowed.1, 0.0);
6410        assert_eq!(underflowed.2, 0.0);
6411        assert_eq!(underflowed.3, 0.0);
6412    }
6413
6414    #[test]
6415    fn test_degenerate_cloglog_component_jet_preserves_smooth_negative_tail() {
6416        let eta: f64 = -40.0;
6417        let t = eta.exp();
6418        let s = (-t).exp();
6419        let cloglog = component_point_jet(LinkComponent::CLogLog, eta);
6420        let expected_mean = -(-t).exp_m1();
6421        let expected_d1 = t * s;
6422        let expected_d2 = (t - t * t) * s;
6423        let expected_d3 = (t - 3.0 * t * t + t * t * t) * s;
6424
6425        assert!(cloglog.1 > 0.0, "negative-tail d1 should remain positive");
6426        assert_relative_eq!(
6427            cloglog.0,
6428            expected_mean,
6429            epsilon = 1e-30,
6430            max_relative = 1e-15
6431        );
6432        assert_relative_eq!(
6433            cloglog.1,
6434            expected_d1,
6435            epsilon = 1e-30,
6436            max_relative = 1e-15
6437        );
6438        assert_relative_eq!(
6439            cloglog.2,
6440            expected_d2,
6441            epsilon = 1e-30,
6442            max_relative = 1e-15
6443        );
6444        assert_relative_eq!(
6445            cloglog.3,
6446            expected_d3,
6447            epsilon = 1e-30,
6448            max_relative = 1e-15
6449        );
6450    }
6451
6452    #[test]
6453    fn test_zero_sigma_logit_and_cloglog_share_component_tail_jets() {
6454        let ctx = QuadratureContext::new();
6455        for (link, component, eta) in [
6456            (LinkFunction::Logit, LinkComponent::Logit, 50.0),
6457            (LinkFunction::CLogLog, LinkComponent::CLogLog, -50.0),
6458        ] {
6459            let integrated = integrated_inverse_link_jet(&ctx, link, eta, 0.0)
6460                .expect("degenerate integrated jet");
6461            let point = component_inverse_link_jet(component, eta);
6462            assert_eq!(integrated.mode, IntegratedExpectationMode::ExactClosedForm);
6463            assert_eq!(integrated.mean, point.mu);
6464            assert_eq!(integrated.d1, point.d1);
6465            assert_eq!(integrated.d2, point.d2);
6466            assert_eq!(integrated.d3, point.d3);
6467        }
6468    }
6469
6470    #[test]
6471    fn test_cloglog_controlled_matches_mathematical_target_on_small_sigma_grid() {
6472        let ctx = QuadratureContext::new();
6473        // Cover the entire small-sigma routing region with negative-tail,
6474        // central, and saturated-positive cases. The reference is the
6475        // mathematical Gaussian expectation, not another evaluator.
6476        let cases = [
6477            (-30.0, 1e-10),
6478            (-30.0, 0.1),
6479            (-10.0, 0.24),
6480            (-3.0, 0.2),
6481            (0.0, 0.05),
6482            (0.4, 0.1),
6483            (3.0, 0.24),
6484            (10.0, 0.1),
6485            (30.0, 0.24),
6486        ];
6487
6488        for &(mu, sigma) in &cases {
6489            let approx = cloglog_posterior_meanwith_deriv_controlled(&ctx, mu, sigma);
6490            let (expected_mean, expected_deriv) = cloglog_reference_mean_and_derivative(mu, sigma);
6491            assert_relative_eq!(
6492                approx.mean,
6493                expected_mean,
6494                epsilon = 1e-12,
6495                max_relative = 2e-3
6496            );
6497            assert_relative_eq!(
6498                approx.dmean_dmu,
6499                expected_deriv,
6500                epsilon = 1e-12,
6501                max_relative = 4e-3
6502            );
6503        }
6504    }
6505
6506    #[test]
6507    fn test_cloglog_dispatch_uses_gamma_backend_for_large_sigma_central_regime() {
6508        let ctx = QuadratureContext::new();
6509        let out =
6510            integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::CLogLog, -0.2, 0.8)
6511                .expect("cloglog integrated inverse-link moments should evaluate");
6512        assert_eq!(out.mode, IntegratedExpectationMode::ExactSpecialFunction);
6513        assert!(out.mean.is_finite());
6514        assert!(out.dmean_dmu.is_finite());
6515        assert!(out.dmean_dmu >= 0.0);
6516    }
6517
6518    #[test]
6519    fn test_cloglog_dispatch_uses_large_sigma_asymptotic_without_ghq() {
6520        let ctx = QuadratureContext::new();
6521        let out =
6522            integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::CLogLog, 0.0, 20.0)
6523                .expect("cloglog integrated inverse-link moments should evaluate");
6524        assert_eq!(out.mode, IntegratedExpectationMode::ControlledAsymptotic);
6525        assert!(out.mean.is_finite());
6526        assert!(out.dmean_dmu.is_finite());
6527        assert!(out.dmean_dmu >= 0.0);
6528    }
6529
6530    #[test]
6531    fn test_cloglog_cc_matches_gamma_reference_on_central_case() {
6532        let ctx = QuadratureContext::new();
6533        let mu = -0.2;
6534        let sigma = 0.8;
6535        let cc = cloglog_survival_cc(&ctx, mu, sigma, CLOGLOG_CC_TOL).expect("cc backend");
6536        let gamma = cloglog_survival_gamma_reference(mu, sigma).expect("gamma backend");
6537        assert_relative_eq!(cc, gamma, epsilon = 5e-6, max_relative = 5e-6);
6538    }
6539
6540    #[test]
6541    fn test_cloglog_gamma_reference_matches_seeded_monte_carlo_small_case() {
6542        let mu = -0.2;
6543        let sigma = 0.8;
6544        let gamma =
6545            cloglog_posterior_meanwith_deriv_gamma_reference(mu, sigma).expect("gamma reference");
6546        let mut rng_state = 0x9e3779b97f4a7c15u64;
6547        let mut mean_mc = 0.0f64;
6548        let mut deriv_mc = 0.0f64;
6549        let n_samples = 300_000usize;
6550        for _ in 0..n_samples {
6551            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
6552            let u1 = ((rng_state as f64) / (u64::MAX as f64)).clamp(1e-12, 1.0 - 1e-12);
6553            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
6554            let u2 = ((rng_state as f64) / (u64::MAX as f64)).clamp(1e-12, 1.0 - 1e-12);
6555            let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
6556            let eta = mu + sigma * z;
6557            mean_mc += cloglog_mean_exact(eta);
6558            deriv_mc += cloglog_mean_d1_exact(eta);
6559        }
6560        mean_mc /= n_samples as f64;
6561        deriv_mc /= n_samples as f64;
6562        assert_relative_eq!(gamma.mean, mean_mc, epsilon = 2e-3, max_relative = 2e-3);
6563        assert_relative_eq!(
6564            gamma.dmean_dmu,
6565            deriv_mc,
6566            epsilon = 2e-3,
6567            max_relative = 2e-3
6568        );
6569    }
6570
6571    #[test]
6572    fn test_logit_dispatch_uses_tail_asymptotic_outside_old_guard() {
6573        let ctx = QuadratureContext::new();
6574        let out = integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Logit, 35.0, 1.0)
6575            .expect("logit integrated inverse-link moments should evaluate");
6576        assert_eq!(out.mode, IntegratedExpectationMode::ControlledAsymptotic);
6577        assert!(out.mean.is_finite());
6578        assert!(out.dmean_dmu.is_finite());
6579        assert!(out.dmean_dmu >= 0.0);
6580    }
6581
6582    #[test]
6583    fn test_logit_dispatch_prefers_erfcx_in_moderate_regime() {
6584        // Assertion redesign: this test was originally checking that the
6585        // dispatcher DOESN'T degrade to `QuadratureFallback` in the
6586        // moderate regime. The erfcx-series branch genuinely cannot meet
6587        // the EPSILON=1e-10 accuracy contract at (μ=1.1, σ=0.8) inside
6588        // LOGIT_MAX_TERMS=160 (tail bound |R_N| ≤ 0.67/(N+1)² → N* ≈ 81619),
6589        // so routing to GHQ is the correct response. The property we
6590        // actually care about is accuracy — assert it here against an
6591        // independent high-resolution Simpson reference, and document
6592        // that either ExactSpecialFunction or QuadratureFallback is an
6593        // acceptable route so long as the value is correct.
6594        let ctx = QuadratureContext::new();
6595        let out = integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Logit, 1.1, 0.8)
6596            .expect("logit integrated inverse-link moments should evaluate");
6597        assert!(matches!(
6598            out.mode,
6599            IntegratedExpectationMode::ExactSpecialFunction
6600                | IntegratedExpectationMode::QuadratureFallback
6601        ));
6602        assert!(out.mean.is_finite());
6603        assert!(out.dmean_dmu.is_finite());
6604        assert!(out.dmean_dmu >= 0.0);
6605        let (ref_mean, ref_d1, _, _) = logit_reference_jet_highres_simpson(1.1, 0.8);
6606        assert_relative_eq!(out.mean, ref_mean, epsilon = 1e-11, max_relative = 1e-10);
6607        assert_relative_eq!(out.dmean_dmu, ref_d1, epsilon = 1e-11, max_relative = 1e-10);
6608    }
6609
6610    #[test]
6611    fn test_logit_dispatch_large_sigma_uses_accurate_quadrature_not_monahan() {
6612        // Regression for #571. At (μ=0.5, σ=20) the case is erfcx-ineligible
6613        // (σ > LOGIT_ERFCX_SIGMA_MAX) and not in any tail/Taylor regime. The
6614        // old code returned the Monahan–Stefanski probit Φ(μκ) here — wrong by
6615        // ~6e-3 absolute — as a trusted `Ok`, bypassing the drift-check. The
6616        // corrected path returns `Err` from the analytic ladder, so the
6617        // controlled router routes straight to accurate adaptive-Simpson
6618        // quadrature. Assert the route is GHQ/quadrature (NOT a trusted
6619        // asymptotic) and that the value matches an independent reference.
6620        let ctx = QuadratureContext::new();
6621        let out = integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Logit, 0.5, 20.0)
6622            .expect("logit integrated inverse-link moments should evaluate");
6623        assert_eq!(out.mode, IntegratedExpectationMode::QuadratureFallback);
6624        let (ref_mean, ref_d1, _, _) = logit_reference_jet_highres_simpson(0.5, 20.0);
6625        assert_relative_eq!(out.mean, ref_mean, epsilon = 1e-9, max_relative = 1e-7);
6626        assert_relative_eq!(out.dmean_dmu, ref_d1, epsilon = 1e-9, max_relative = 1e-7);
6627        // The discarded Monahan value differs in the third decimal place; pin
6628        // that the dispatcher is NOT returning it.
6629        let kappa = (1.0 + std::f64::consts::PI * 20.0 * 20.0 / 8.0)
6630            .sqrt()
6631            .recip();
6632        let monahan_mean = gam_math::probability::normal_cdf(0.5 * kappa);
6633        assert!(
6634            (out.mean - monahan_mean).abs() > 1e-3,
6635            "dispatcher must not return the inaccurate Monahan mean {monahan_mean}; got {}",
6636            out.mean
6637        );
6638    }
6639
6640    #[test]
6641    fn test_logit_controlled_path_keeps_exact_backend_in_moderate_regime() {
6642        // Assertion redesign: the erfcx-series branch cannot honor its
6643        // EPSILON=1e-10 accuracy contract at (μ=1.1, σ=0.8) within
6644        // LOGIT_MAX_TERMS=160 (tail bound |R_N| ≤ 0.67/(N+1)² → N* ≈ 81619),
6645        // so `logit_posterior_meanwith_deriv_controlled` legitimately falls
6646        // through to GHQ. The controlled path's contract is that it returns
6647        // a correct value via *some* principled route; "which route" is an
6648        // implementation detail. We assert value accuracy against an
6649        // independent high-resolution Simpson reference, and document the
6650        // acceptable modes.
6651        let out = logit_posterior_meanwith_deriv_controlled(1.1, 0.8).expect("logit controlled");
6652        assert!(matches!(
6653            out.mode,
6654            IntegratedExpectationMode::ExactSpecialFunction
6655                | IntegratedExpectationMode::QuadratureFallback
6656        ));
6657        let (ref_mean, ref_d1, _, _) = logit_reference_jet_highres_simpson(1.1, 0.8);
6658        assert_relative_eq!(out.mean, ref_mean, epsilon = 1e-11, max_relative = 1e-10);
6659        assert_relative_eq!(out.dmean_dmu, ref_d1, epsilon = 1e-11, max_relative = 1e-10);
6660    }
6661
6662    #[test]
6663    fn test_logit_dispatch_derivative_correct_at_mu_zero_small_sigma() {
6664        // Regression for #572. On the erfcx branch at μ=0 the old mean-only
6665        // truncation cutoff returned the clamp floor (4 terms), leaving the
6666        // derivative series uncancelled: it reported dmean_dmu ≈ 0.58 at
6667        // (0, 0.3) — a factor ~2.4 too large and physically impossible, since
6668        // sigmoid'(0)=0.25 and averaging over a Gaussian can only shrink it.
6669        // The corrected cutoff sizes the truncation from the derivative tail
6670        // bound past the series peak; at small σ this exceeds LOGIT_MAX_TERMS,
6671        // so the branch honestly bails to accurate quadrature.
6672        let ctx = QuadratureContext::new();
6673        for &(mu, sigma) in &[(0.0, 0.3), (0.0, 0.4), (0.0, 0.5)] {
6674            let out =
6675                integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Logit, mu, sigma)
6676                    .expect("logit integrated inverse-link moments should evaluate");
6677            // Mean is exactly 0.5 by symmetry at μ=0.
6678            assert_relative_eq!(out.mean, 0.5, epsilon = 1e-10);
6679            // Hard physical ceiling: E[sigmoid'(η)] ≤ sigmoid'(0) = 0.25.
6680            assert!(
6681                out.dmean_dmu <= 0.25 + 1e-9,
6682                "E[sigmoid'] must not exceed 0.25 at (μ={mu}, σ={sigma}); got {}",
6683                out.dmean_dmu
6684            );
6685            let (_, ref_d1, _, _) = logit_reference_jet_highres_simpson(mu, sigma);
6686            assert_relative_eq!(out.dmean_dmu, ref_d1, epsilon = 1e-9, max_relative = 1e-6);
6687        }
6688    }
6689
6690    #[test]
6691    fn test_logit_erfcx_exact_branch_is_self_certified() {
6692        // Regression for #572: the `ExactSpecialFunction` branch must be
6693        // accurate *by itself*, not merely rescued by the controlled router's
6694        // drift-check. Call `logit_posterior_meanwith_deriv_exact` directly
6695        // (no quadrature net) in the large-|μ| band where the erfcx series
6696        // certifies within LOGIT_MAX_TERMS, and require both the mean and the
6697        // μ-derivative to match an independent high-resolution reference.
6698        for &(mu, sigma) in &[(8.0, 1.0), (10.0, 1.0), (15.0, 2.0)] {
6699            let out = logit_posterior_meanwith_deriv_exact(mu, sigma)
6700                .expect("erfcx branch should certify");
6701            assert_eq!(out.mode, IntegratedExpectationMode::ExactSpecialFunction);
6702            let (ref_mean, ref_d1, _, _) = logit_reference_jet_highres_simpson(mu, sigma);
6703            assert_relative_eq!(out.mean, ref_mean, epsilon = 1e-9, max_relative = 1e-7);
6704            assert_relative_eq!(out.dmean_dmu, ref_d1, epsilon = 1e-9, max_relative = 1e-7);
6705        }
6706        // Where the series cannot certify the derivative within LOGIT_MAX_TERMS
6707        // it must reject (Err) rather than return a wrong "exact" value — the
6708        // router then routes to quadrature. (0, 0.3) is the #572 point.
6709        assert!(
6710            logit_posterior_meanwith_deriv_exact(0.0, 0.3).is_err(),
6711            "erfcx branch must not claim ExactSpecialFunction when it cannot certify the derivative"
6712        );
6713    }
6714
6715    #[test]
6716    fn test_logit_integrated_derivative_is_even_in_mu() {
6717        // d/dμ E[sigmoid(η)] = E[sigmoid'(η)] and sigmoid' is even, so the
6718        // location-derivative is even in μ. The erfcx series works in m=|μ|;
6719        // #572 originated in a botched sign/reflection of that derivative.
6720        // Pin exact symmetry across regimes (erfcx-success, erfcx-bail/GHQ,
6721        // and tail-asymptotic).
6722        let ctx = QuadratureContext::new();
6723        for &(mu, sigma) in &[(0.3, 0.3), (1.1, 0.8), (10.0, 1.0), (3.0, 3.0), (35.0, 1.0)] {
6724            let pos =
6725                integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Logit, mu, sigma)
6726                    .expect("logit moments (+μ)");
6727            let neg =
6728                integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Logit, -mu, sigma)
6729                    .expect("logit moments (-μ)");
6730            assert_relative_eq!(
6731                pos.dmean_dmu,
6732                neg.dmean_dmu,
6733                epsilon = 1e-9,
6734                max_relative = 1e-7
6735            );
6736            // And the mean reflects: E[sigmoid] at -μ equals 1 - E[sigmoid] at μ.
6737            assert_relative_eq!(
6738                neg.mean,
6739                1.0 - pos.mean,
6740                epsilon = 1e-9,
6741                max_relative = 1e-7
6742            );
6743        }
6744    }
6745
6746    #[test]
6747    fn test_logit_dmean_dmu_equals_fd_of_mean_across_regimes() {
6748        // Regression for #571/#572 from the contract angle: the dispatcher's
6749        // returned `dmean_dmu` MUST equal d/dμ of the dispatcher's own `mean`
6750        // (the location-family identity the integrated-PIRLS Fisher weight and
6751        // working response depend on). A central finite difference of the
6752        // public `mean` is an end-to-end check that is blind to *which* internal
6753        // branch produced the value — it would have caught the #572 erfcx
6754        // derivative (2.4× too large) and any future formula that returns a
6755        // derivative inconsistent with its own mean. Grid points are chosen well
6756        // inside single regimes (away from the σ∈{0.25,6} and |μ|=40 branch
6757        // seams) so the mean is locally smooth and a tight FD is meaningful:
6758        //   - quadrature-fallback band (erfcx-eligible but un-certifiable),
6759        //   - erfcx self-certified band (large |μ|),
6760        //   - small-σ quadrature band,
6761        //   - large-σ (erfcx-ineligible) band.
6762        let ctx = QuadratureContext::new();
6763        let h = 1e-4;
6764        let cases = [
6765            (0.0, 0.8),  // quadrature fallback, μ=0 (the #572 failure family)
6766            (0.7, 0.8),  // quadrature fallback, off-center
6767            (1.5, 1.2),  // quadrature fallback
6768            (-1.1, 0.9), // quadrature fallback, μ<0 (reflection path)
6769            (8.0, 1.0),  // erfcx self-certified
6770            (10.0, 1.5), // erfcx self-certified
6771            (-9.0, 1.0), // erfcx self-certified, μ<0
6772            (0.5, 0.05), // small-σ quadrature
6773            (0.5, 20.0), // large-σ, erfcx-ineligible → quadrature
6774        ];
6775        for &(mu, sigma) in &cases {
6776            let at = |m: f64| {
6777                integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Logit, m, sigma)
6778                    .expect("logit moments")
6779            };
6780            let out = at(mu);
6781            let fd = (at(mu + h).mean - at(mu - h).mean) / (2.0 * h);
6782            assert!(
6783                (out.dmean_dmu - fd).abs() <= 1e-5,
6784                "dmean_dmu must equal d/dμ of mean at (μ={mu}, σ={sigma}): \
6785                 returned {}, FD of mean {} (mode {:?})",
6786                out.dmean_dmu,
6787                fd,
6788                out.mode
6789            );
6790            // Physical ceiling: E[sigmoid'(η)] ≤ sigmoid'(0) = 0.25 for every
6791            // (μ, σ); a Gaussian average of sigmoid' (max 0.25) can never exceed
6792            // it. The #572 bug returned 0.58 here, violating this hard bound.
6793            assert!(
6794                out.dmean_dmu <= 0.25 + 1e-9 && out.dmean_dmu >= 0.0,
6795                "dmean_dmu out of [0, 0.25] at (μ={mu}, σ={sigma}): {}",
6796                out.dmean_dmu
6797            );
6798        }
6799    }
6800
6801    #[test]
6802    fn test_logit_scalar_matches_jet_at_large_sigma() {
6803        // Regression for #571: the scalar dispatcher used to return the
6804        // Monahan probit mean (e.g. 0.9206 at (3,3)) while the jet path
6805        // integrated by GHQ returned the truth (0.8056) — two public entry
6806        // points disagreeing in the first decimal. With Monahan removed the
6807        // scalar path routes to the same quadrature, so the two must agree.
6808        let ctx = QuadratureContext::new();
6809        for &(mu, sigma) in &[(3.0, 3.0), (4.0, 4.0), (2.0, 5.0), (5.0, 5.0)] {
6810            let scalar =
6811                integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Logit, mu, sigma)
6812                    .expect("scalar logit moments");
6813            let jet = integrated_inverse_link_jet(&ctx, LinkFunction::Logit, mu, sigma)
6814                .expect("jet logit moments");
6815            // The scalar path now routes to accurate adaptive-Simpson, matching
6816            // the independent high-resolution Simpson reference (truth) to ~1e-10
6817            // — the Monahan ~0.11 error is gone.
6818            let (ref_mean, ref_d1, _, _) = logit_reference_jet_highres_simpson(mu, sigma);
6819            assert_relative_eq!(scalar.mean, ref_mean, epsilon = 1e-9, max_relative = 1e-8);
6820            assert_relative_eq!(
6821                scalar.dmean_dmu,
6822                ref_d1,
6823                epsilon = 1e-9,
6824                max_relative = 1e-8
6825            );
6826            // At wide σ the jet no longer integrates mean/d1 by Gauss-Hermite
6827            // (which under-resolves the localized sigmoid^(k) integrands and
6828            // drifted ~4e-3 from the scalar adaptive-Simpson value — the
6829            // residual #571 symptom). The jet now *reuses* the scalar backend's
6830            // mean/d1 (see `logit_wide_sigma_jet`), so the two public entry
6831            // points are identical to the bit, not merely close. Pin that
6832            // strong invariant.
6833            assert_relative_eq!(scalar.mean, jet.mean, epsilon = 1e-12, max_relative = 1e-12);
6834            assert_relative_eq!(
6835                scalar.dmean_dmu,
6836                jet.d1,
6837                epsilon = 1e-12,
6838                max_relative = 1e-12
6839            );
6840        }
6841    }
6842
6843    #[test]
6844    fn test_logit_jet_accurate_at_wide_sigma() {
6845        // Regression for the residual #571 root cause: at wide σ the 51-node
6846        // Gauss-Hermite jet under-resolves the localized sigmoid^(k) integrands
6847        // and drifts from the truth (e.g. d1 ≈ 0.0702 vs 0.0700 at (3,3)). The
6848        // jet now routes σ > LOGIT_JET_GHQ_SIGMA_MAX through adaptive Simpson.
6849        // Pin ALL FOUR jet components (mean, d1, d2, d3) to an independent
6850        // high-resolution Simpson reference across the broad-σ band. PIRLS
6851        // consumes this dispatcher directly, so there is no second jet to
6852        // synchronize.
6853        let ctx = QuadratureContext::new();
6854        for &(mu, sigma) in &[(3.0, 3.0), (4.0, 4.0), (2.0, 5.0), (5.0, 5.0), (0.5, 20.0)] {
6855            let jet = integrated_inverse_link_jet(&ctx, LinkFunction::Logit, mu, sigma)
6856                .expect("wide-σ logit jet");
6857            let (rm, rd1, rd2, rd3) = logit_reference_jet_highres_simpson(mu, sigma);
6858            assert_relative_eq!(jet.mean, rm, epsilon = 1e-8, max_relative = 1e-7);
6859            assert_relative_eq!(jet.d1, rd1, epsilon = 1e-8, max_relative = 1e-6);
6860            assert_relative_eq!(jet.d2, rd2, epsilon = 1e-8, max_relative = 1e-6);
6861            assert_relative_eq!(jet.d3, rd3, epsilon = 1e-8, max_relative = 1e-6);
6862            // d1 is the scalar backend's derivative verbatim (consistency #571).
6863            let scalar =
6864                integrated_inverse_link_mean_and_derivative(&ctx, LinkFunction::Logit, mu, sigma)
6865                    .expect("scalar logit moments");
6866            assert_relative_eq!(jet.d1, scalar.dmean_dmu, epsilon = 1e-12);
6867            assert_relative_eq!(jet.mean, scalar.mean, epsilon = 1e-12);
6868        }
6869    }
6870
6871    #[test]
6872    fn test_logit_jet_continuous_across_ghq_simpson_seam() {
6873        // The jet switches integrators at σ = LOGIT_JET_GHQ_SIGMA_MAX (GHQ at or
6874        // below, adaptive Simpson above). Both sides are accurate, so the seam
6875        // must not introduce a visible jump that would perturb PIRLS. The seam
6876        // jump is exactly (GHQ value − Simpson value) at the threshold σ, so we
6877        // evaluate BOTH integrators at the same σ to isolate that jump from the
6878        // jet's genuine σ-dependence (a 1e-6 step in σ alone moves the mean by
6879        // ~∂M/∂σ·1e-6 ≈ 6e-8, which would otherwise masquerade as a seam jump).
6880        let ctx = QuadratureContext::new();
6881        let sigma = LOGIT_JET_GHQ_SIGMA_MAX;
6882        for mu in [-2.0, -0.5, 0.0, 0.7, 1.3, 3.0] {
6883            // Dispatch path at the threshold uses GHQ (σ is not > the cutoff).
6884            let ghq = integrated_inverse_link_jet(&ctx, LinkFunction::Logit, mu, sigma)
6885                .expect("jet at seam (GHQ dispatch)");
6886            // Same σ, but forced through the adaptive-Simpson backend.
6887            let simpson = logit_wide_sigma_jet(mu, sigma).expect("jet at seam (Simpson)");
6888            // GHQ at σ=1 holds to ≤ ~2e-9 on all four components (Simpson is
6889            // ~1e-12), so the seam jump is bounded by GHQ's residual error.
6890            assert_relative_eq!(ghq.mean, simpson.mean, epsilon = 1e-9, max_relative = 1e-8);
6891            assert_relative_eq!(ghq.d1, simpson.d1, epsilon = 1e-9, max_relative = 1e-7);
6892            assert_relative_eq!(ghq.d2, simpson.d2, epsilon = 1e-9, max_relative = 1e-7);
6893            assert_relative_eq!(ghq.d3, simpson.d3, epsilon = 1e-8, max_relative = 1e-6);
6894        }
6895    }
6896
6897    #[test]
6898    fn test_logit_batch_uses_same_dispatchvalues() {
6899        let ctx = QuadratureContext::new();
6900        let eta = ndarray::array![-2.0, 0.0, 1.25, 35.0];
6901        let se = ndarray::array![0.1, 0.5, 1.0, 1.0];
6902        let batch_mean = logit_posterior_mean_batch(&ctx, &eta, &se)
6903            .expect("logit posterior mean batch should evaluate");
6904        let (batchmu, batch_dmu) = logit_posterior_meanwith_deriv_batch(&ctx, &eta, &se)
6905            .expect("logit posterior mean derivative batch should evaluate");
6906        for i in 0..eta.len() {
6907            let direct = integrated_inverse_link_mean_and_derivative(
6908                &ctx,
6909                LinkFunction::Logit,
6910                eta[i],
6911                se[i],
6912            )
6913            .expect("logit integrated inverse-link moments should evaluate");
6914            assert_relative_eq!(batch_mean[i], direct.mean, epsilon = 1e-12);
6915            assert_relative_eq!(batchmu[i], direct.mean, epsilon = 1e-12);
6916            assert_relative_eq!(batch_dmu[i], direct.dmean_dmu, epsilon = 1e-12);
6917        }
6918    }
6919
6920    #[test]
6921    fn exact_logit_small_se_branch_loses_tail_derivative() {
6922        let eta = 50.0_f64;
6923        let stable_z = (-eta).exp();
6924        let stable_dmu = stable_z / (1.0_f64 + stable_z).powi(2);
6925        assert!(stable_dmu > 0.0);
6926        let out = logit_posterior_meanwith_deriv_exact(eta, 0.0).expect("exact branch");
6927        let dmu = out.dmean_dmu;
6928        assert!(
6929            (dmu - stable_dmu).abs() < 1e-30,
6930            "exact logit small-se branch should use the stable derivative z/(1+z)^2 at eta={eta}; got {} vs {}",
6931            dmu,
6932            stable_dmu
6933        );
6934    }
6935
6936    #[test]
6937    fn integrated_family_moments_rejects_latent_cloglog_without_concrete_handler() {
6938        // With the LikelihoodSpec migration, SAS and Mixture parameterized binomial
6939        // variants carry their state through `InverseLink`, so the type system
6940        // already prevents constructing a state-less call. The only remaining
6941        // explicit error path here is `Binomial + LatentCLogLog`, which this
6942        // dispatcher reports as needing an explicit latent-cloglog state handler.
6943        let ctx = QuadratureContext::new();
6944        let latent =
6945            gam_problem::types::LatentCLogLogState::new(0.4).expect("valid latent cloglog state");
6946        let spec =
6947            LikelihoodSpec::new(ResponseFamily::Binomial, InverseLink::LatentCLogLog(latent));
6948        let likelihood = GlmLikelihoodSpec::canonical(spec);
6949        let err = integrated_family_moments_jet(&ctx, &likelihood, 0.2, 0.5)
6950            .expect_err("latent cloglog moments should error in this dispatcher");
6951        assert!(format!("{err}").contains("LatentCLogLog"));
6952    }
6953
6954    #[test]
6955    fn integrated_family_moments_supports_stateful_sas() {
6956        let ctx = QuadratureContext::new();
6957        let sas = crate::mixture_link::state_from_sasspec(gam_problem::types::SasLinkSpec {
6958            initial_epsilon: 0.3,
6959            initial_log_delta: -0.2,
6960        })
6961        .expect("sas state should reconstruct from raw parameters");
6962        let spec = LikelihoodSpec::new(ResponseFamily::Binomial, InverseLink::Sas(sas));
6963        let likelihood = GlmLikelihoodSpec::canonical(spec);
6964        let out = integrated_family_moments_jet(&ctx, &likelihood, 0.2, 0.5)
6965            .expect("stateful SAS integrated moments should evaluate");
6966        assert!(out.mean.is_finite());
6967        assert!(out.d1.is_finite());
6968        assert!(out.d2.is_finite());
6969        assert!(out.d3.is_finite());
6970        assert!(out.mean > 0.0 && out.mean < 1.0);
6971    }
6972
6973    #[test]
6974    fn integrated_family_moments_supports_pure_probit_mixture() {
6975        let ctx = QuadratureContext::new();
6976        let state = crate::mixture_link::state_fromspec(&gam_problem::types::MixtureLinkSpec {
6977            components: vec![gam_problem::types::LinkComponent::Probit],
6978            initial_rho: ndarray::Array1::<f64>::zeros(0),
6979        })
6980        .expect("single-component probit mixture state");
6981        let spec = LikelihoodSpec::new(ResponseFamily::Binomial, InverseLink::Mixture(state));
6982        let likelihood = GlmLikelihoodSpec::canonical(spec);
6983        let out = integrated_family_moments_jet(&ctx, &likelihood, 0.7, 1.3)
6984            .expect("pure probit mixture integrated moments should evaluate");
6985        let exact = integrated_probit_jet(0.7, 1.3);
6986        assert_relative_eq!(out.mean, exact.mean, epsilon = 1e-12);
6987        assert_relative_eq!(out.d1, exact.d1, epsilon = 1e-12);
6988        assert_relative_eq!(out.d2, exact.d2, epsilon = 1e-12);
6989        assert_relative_eq!(out.d3, exact.d3, epsilon = 1e-12);
6990        assert_eq!(out.mode, IntegratedExpectationMode::ExactClosedForm);
6991    }
6992
6993    #[test]
6994    fn integrated_family_moments_supports_pure_logit_mixture() {
6995        let ctx = QuadratureContext::new();
6996        let state = crate::mixture_link::state_fromspec(&gam_problem::types::MixtureLinkSpec {
6997            components: vec![gam_problem::types::LinkComponent::Logit],
6998            initial_rho: ndarray::Array1::<f64>::zeros(0),
6999        })
7000        .expect("single-component logit mixture state");
7001        let spec = LikelihoodSpec::new(ResponseFamily::Binomial, InverseLink::Mixture(state));
7002        let likelihood = GlmLikelihoodSpec::canonical(spec);
7003        let out = integrated_family_moments_jet(&ctx, &likelihood, 1.1, 0.8)
7004            .expect("pure logit mixture integrated moments should evaluate");
7005        let exact = integrated_inverse_link_jet(&ctx, LinkFunction::Logit, 1.1, 0.8)
7006            .expect("canonical integrated logit jet");
7007        assert_relative_eq!(out.mean, exact.mean, epsilon = 1e-12);
7008        assert_relative_eq!(out.d1, exact.d1, epsilon = 1e-12);
7009        assert_relative_eq!(out.d2, exact.d2, epsilon = 1e-12);
7010        assert_relative_eq!(out.d3, exact.d3, epsilon = 1e-12);
7011        assert_eq!(out.mode, exact.mode);
7012    }
7013
7014    #[test]
7015    fn integrated_family_moments_supports_stateful_mixture() {
7016        let ctx = QuadratureContext::new();
7017        let state = crate::mixture_link::state_fromspec(&gam_problem::types::MixtureLinkSpec {
7018            components: vec![
7019                gam_problem::types::LinkComponent::Logit,
7020                gam_problem::types::LinkComponent::Probit,
7021            ],
7022            initial_rho: ndarray::array![0.35],
7023        })
7024        .expect("mixture state should reconstruct from rho");
7025        let spec = LikelihoodSpec::new(
7026            ResponseFamily::Binomial,
7027            InverseLink::Mixture(state.clone()),
7028        );
7029        let likelihood = GlmLikelihoodSpec::canonical(spec);
7030        let out = integrated_family_moments_jet(&ctx, &likelihood, 0.2, 0.5)
7031            .expect("stateful mixture integrated moments should evaluate");
7032        let direct = integrated_mixture_jet(&ctx, 0.2, 0.5, &state)
7033            .expect("direct integrated mixture jet should evaluate");
7034        assert_relative_eq!(out.mean, direct.mean, epsilon = 1e-12);
7035        assert_relative_eq!(out.d1, direct.d1, epsilon = 1e-12);
7036        assert_relative_eq!(out.d2, direct.d2, epsilon = 1e-12);
7037        assert_relative_eq!(out.d3, direct.d3, epsilon = 1e-12);
7038        assert_eq!(out.mode, direct.mode);
7039    }
7040
7041    #[test]
7042    fn integrated_family_moments_use_scale_dispersion_for_tweedie_and_gamma() {
7043        // Regression for #953: the log-normal arm's observation-model variance
7044        // must read the Tweedie dispersion φ / Gamma shape k from the supplied
7045        // `LikelihoodScaleMetadata`, not assume φ = 1 (Tweedie) / k = 1 (Gamma).
7046        let ctx = QuadratureContext::new();
7047        // Deterministic small inputs; integrated mean m = exp(e + s²/2).
7048        let e = 0.3_f64;
7049        let se = 0.5_f64;
7050        let m = (e + 0.5 * se * se).exp();
7051
7052        // Tweedie p = 1.5, φ = 2: Var = φ · m^p (the old code returned m^p, i.e. φ = 1).
7053        let p = 1.5_f64;
7054        let phi = 2.0_f64;
7055        let tweedie = LikelihoodSpec::tweedie_log(p);
7056        let tweedie_likelihood = GlmLikelihoodSpec {
7057            spec: tweedie.clone(),
7058            scale: LikelihoodScaleMetadata::EstimatedTweediePhi { phi },
7059        };
7060        let out = integrated_family_moments_jet(&ctx, &tweedie_likelihood, e, se)
7061            .expect("tweedie integrated moments should evaluate");
7062        let expected = phi * m.powf(p);
7063        assert_relative_eq!(out.variance, expected, epsilon = 1e-12);
7064        // Guard against the φ = 1 regression: the corrected value is φ× the old one.
7065        assert_relative_eq!(out.variance / m.powf(p), phi, epsilon = 1e-12);
7066
7067        // Gamma shape k = 4: Var = m² / k = φ·m² with φ = 1/k (old code: m², i.e. k = 1).
7068        let shape = 4.0_f64;
7069        let gamma = LikelihoodSpec::gamma_log();
7070        let gamma_likelihood = GlmLikelihoodSpec {
7071            spec: gamma.clone(),
7072            scale: LikelihoodScaleMetadata::EstimatedGammaShape { shape },
7073        };
7074        let out = integrated_family_moments_jet(&ctx, &gamma_likelihood, e, se)
7075            .expect("gamma integrated moments should evaluate");
7076        let expected = m * m / shape;
7077        assert_relative_eq!(out.variance, expected, epsilon = 1e-12);
7078        // Guard against the k = 1 regression: the corrected value is (1/k)× the old one.
7079        assert_relative_eq!(out.variance / (m * m), 1.0 / shape, epsilon = 1e-12);
7080
7081        // Poisson is φ ≡ 1, Var = m, independent of the (unit) scale label.
7082        let poisson = LikelihoodSpec::poisson_log();
7083        let poisson_likelihood = GlmLikelihoodSpec::canonical(poisson);
7084        let out = integrated_family_moments_jet(&ctx, &poisson_likelihood, e, se)
7085            .expect("poisson integrated moments should evaluate");
7086        assert_relative_eq!(out.variance, m, epsilon = 1e-12);
7087
7088        // NB2 with theta = 3: Var = m + m²/θ, unchanged by this fix.
7089        let theta = 3.0_f64;
7090        let nb = LikelihoodSpec::negative_binomial_log(theta);
7091        let nb_likelihood = GlmLikelihoodSpec::canonical(nb);
7092        let out = integrated_family_moments_jet(&ctx, &nb_likelihood, e, se)
7093            .expect("negative-binomial integrated moments should evaluate");
7094        assert_relative_eq!(out.variance, m + m * m / theta, epsilon = 1e-12);
7095
7096        // Missing Gamma dispersion metadata is rejected, not silently φ = 1.
7097        let missing_gamma = GlmLikelihoodSpec {
7098            spec: gamma,
7099            scale: LikelihoodScaleMetadata::Unspecified,
7100        };
7101        let err = integrated_family_moments_jet(&ctx, &missing_gamma, e, se)
7102            .expect_err("gamma without a shape in the scale metadata must error");
7103        assert!(
7104            format!("{err}").contains("GammaShape"),
7105            "unexpected error message: {err}"
7106        );
7107
7108        // Likewise a Tweedie response with no dispersion φ in the metadata.
7109        let missing_tweedie = GlmLikelihoodSpec {
7110            spec: tweedie,
7111            scale: LikelihoodScaleMetadata::Unspecified,
7112        };
7113        let err = integrated_family_moments_jet(&ctx, &missing_tweedie, e, se)
7114            .expect_err("tweedie without a φ in the scale metadata must error");
7115        assert!(
7116            format!("{err}").contains("EstimatedTweediePhi"),
7117            "unexpected error message: {err}"
7118        );
7119    }
7120
7121    // Tests for CLogLog Gaussian convolution derivatives
7122
7123    #[test]
7124    fn cloglog_g_derivatives_at_zero() {
7125        let (g, g1, g2, g3, g4) = cloglog_g_derivatives(0.0);
7126        // g(0) = 1 - exp(-1)
7127        let expected_g = 1.0 - (-1.0_f64).exp();
7128        assert_relative_eq!(g, expected_g, epsilon = 1e-14);
7129        // g'(0) = exp(0 - exp(0)) = exp(-1)
7130        let e_neg1 = (-1.0_f64).exp();
7131        assert_relative_eq!(g1, e_neg1, epsilon = 1e-14);
7132        // g''(0) = (1 - 1) * exp(-1) = 0
7133        assert_relative_eq!(g2, 0.0, epsilon = 1e-14);
7134        // g'''(0) = (1 - 3 + 1) * exp(-1) = -exp(-1)
7135        assert_relative_eq!(g3, -e_neg1, epsilon = 1e-14);
7136        // g''''(0) = (-1 + 6 - 7 + 1) * exp(-1) = -exp(-1)
7137        assert_relative_eq!(g4, -e_neg1, epsilon = 1e-14);
7138    }
7139
7140    #[test]
7141    fn cloglog_g_derivatives_saturation() {
7142        // Very large t: g→1, derivatives→0
7143        let (g, g1, g2, g3, g4) = cloglog_g_derivatives(50.0);
7144        assert_relative_eq!(g, 1.0, epsilon = 1e-10);
7145        assert_eq!(g1, 0.0);
7146        assert_eq!(g2, 0.0);
7147        assert_eq!(g3, 0.0);
7148        assert_eq!(g4, 0.0);
7149
7150        // Very negative t: g ≈ exp(t), all derivatives ≈ exp(t)
7151        let (g, g1, g2, g3, g4) = cloglog_g_derivatives(-50.0);
7152        let expected = (-50.0_f64).exp();
7153        assert_relative_eq!(g, expected, max_relative = 1e-10);
7154        assert_relative_eq!(g1, expected, max_relative = 1e-10);
7155        // Higher derivatives have polynomial factors ≈ 1 for t ≪ 0
7156        assert_relative_eq!(g2, expected, max_relative = 1e-10);
7157        assert_relative_eq!(g3, expected, max_relative = 1e-10);
7158        assert_relative_eq!(g4, expected, max_relative = 1e-10);
7159    }
7160
7161    #[test]
7162    fn cloglog_ghq_value_sigma_zero_matches_pointwise() {
7163        let ctx = QuadratureContext::new();
7164        // When sigma=0, L(mu,0) = g(mu)
7165        for &mu in &[-2.0, -1.0, 0.0, 0.5, 1.5] {
7166            let val = cloglog_ghq_value(&ctx, mu, 0.0, 21);
7167            let (g, _, _, _, _) = cloglog_g_derivatives(mu);
7168            assert_relative_eq!(val, g, epsilon = 1e-14);
7169        }
7170    }
7171
7172    #[test]
7173    fn cloglog_ghq_value_bounded_zero_one() {
7174        let ctx = QuadratureContext::new();
7175        // g maps to (0,1), so the Gaussian convolution should stay in [0,1]
7176        for &mu in &[-5.0, -2.0, 0.0, 1.0, 3.0, 10.0] {
7177            for &sigma in &[0.1, 0.5, 1.0, 2.0, 5.0] {
7178                let val = cloglog_ghq_value(&ctx, mu, sigma, 31);
7179                assert!((0.0..=1.0).contains(&val), "L({mu},{sigma}) = {val}");
7180            }
7181        }
7182    }
7183
7184    #[test]
7185    fn cloglog_ghq_derivatives_sigma_zero_matches_pointwise() {
7186        let ctx = QuadratureContext::new();
7187        let mu = 0.3;
7188        let d = cloglog_ghq_derivatives(&ctx, mu, 0.0, 21);
7189        let (g, g1, g2, g3, g4) = cloglog_g_derivatives(mu);
7190        assert_relative_eq!(d.l, g, epsilon = 1e-14);
7191        assert_relative_eq!(d.l_mu, g1, epsilon = 1e-14);
7192        assert_relative_eq!(d.l_mumu, g2, epsilon = 1e-14);
7193        assert_relative_eq!(d.l_mumumu, g3, epsilon = 1e-14);
7194        assert_relative_eq!(d.l_mumumumu, g4, epsilon = 1e-14);
7195
7196        // Odd sigma-derivatives vanish at sigma=0 (odd Gaussian moments are 0).
7197        assert_eq!(d.l_sigma, 0.0);
7198        assert_eq!(d.l_musigma, 0.0);
7199        assert_eq!(d.l_mumusigma, 0.0);
7200        assert_eq!(d.l_mumumusigma, 0.0);
7201        assert_eq!(d.l_sigmasigmasigma, 0.0);
7202        assert_eq!(d.l_musigmasigmasigma, 0.0);
7203
7204        // Even sigma-derivatives carry the surviving moments E[Z^2]=1, E[Z^4]=3:
7205        //   L_σσ = g'', L_μσσ = g''', L_μμσσ = g'''', L_σσσσ = 3 g''''.
7206        assert_relative_eq!(d.l_sigmasigma, g2, epsilon = 1e-14);
7207        assert_relative_eq!(d.l_musigmasigma, g3, epsilon = 1e-14);
7208        assert_relative_eq!(d.l_mumusigmasigma, g4, epsilon = 1e-14);
7209        assert_relative_eq!(d.l_sigmasigmasigmasigma, 3.0 * g4, epsilon = 1e-14);
7210    }
7211
7212    #[test]
7213    fn cloglog_ghq_derivatives_finite_difference_mu() {
7214        // Verify ∂L/∂μ by finite differences
7215        let ctx = QuadratureContext::new();
7216        let mu = 0.5;
7217        let sigma = 0.8;
7218        let h = 1e-6;
7219        let d = cloglog_ghq_derivatives(&ctx, mu, sigma, 31);
7220        let l_plus = cloglog_ghq_value(&ctx, mu + h, sigma, 31);
7221        let l_minus = cloglog_ghq_value(&ctx, mu - h, sigma, 31);
7222        let fd_mu = (l_plus - l_minus) / (2.0 * h);
7223        assert_relative_eq!(d.l_mu, fd_mu, epsilon = 1e-5);
7224
7225        // Second derivative ∂²L/∂μ²
7226        let d_plus = cloglog_ghq_derivatives(&ctx, mu + h, sigma, 31);
7227        let d_minus = cloglog_ghq_derivatives(&ctx, mu - h, sigma, 31);
7228        let fd_mumu = (d_plus.l_mu - d_minus.l_mu) / (2.0 * h);
7229        assert_relative_eq!(d.l_mumu, fd_mumu, epsilon = 1e-4);
7230    }
7231
7232    #[test]
7233    fn cloglog_ghq_derivatives_finite_difference_sigma() {
7234        // Verify ∂L/∂σ by finite differences
7235        let ctx = QuadratureContext::new();
7236        let mu = 0.2;
7237        let sigma = 1.0;
7238        let h = 1e-6;
7239        let d = cloglog_ghq_derivatives(&ctx, mu, sigma, 31);
7240        let l_plus = cloglog_ghq_value(&ctx, mu, sigma + h, 31);
7241        let l_minus = cloglog_ghq_value(&ctx, mu, sigma - h, 31);
7242        let fd_sigma = (l_plus - l_minus) / (2.0 * h);
7243        assert_relative_eq!(d.l_sigma, fd_sigma, epsilon = 1e-5);
7244    }
7245
7246    #[test]
7247    fn cloglog_ghq_derivatives_finite_difference_cross() {
7248        // Verify ∂²L/∂μ∂σ by finite differences of ∂L/∂μ w.r.t. σ
7249        let ctx = QuadratureContext::new();
7250        let mu = -0.5;
7251        let sigma = 0.6;
7252        let h = 1e-6;
7253        let d = cloglog_ghq_derivatives(&ctx, mu, sigma, 31);
7254        let d_plus = cloglog_ghq_derivatives(&ctx, mu, sigma + h, 31);
7255        let d_minus = cloglog_ghq_derivatives(&ctx, mu, sigma - h, 31);
7256        let fd_musigma = (d_plus.l_mu - d_minus.l_mu) / (2.0 * h);
7257        assert_relative_eq!(d.l_musigma, fd_musigma, epsilon = 1e-4);
7258    }
7259
7260    #[test]
7261    fn cloglog_ghq_l_mu_nonnegative() {
7262        // g'(t) = exp(t - exp(t)) >= 0, so ∂L/∂μ = E[g'(t)] >= 0
7263        let ctx = QuadratureContext::new();
7264        for &mu in &[-3.0, -1.0, 0.0, 1.0, 3.0] {
7265            for &sigma in &[0.1, 0.5, 1.0, 2.0] {
7266                let d = cloglog_ghq_derivatives(&ctx, mu, sigma, 21);
7267                assert!(
7268                    d.l_mu >= -1e-14,
7269                    "L_mu should be non-negative at mu={mu}, sigma={sigma}: got {}",
7270                    d.l_mu
7271                );
7272            }
7273        }
7274    }
7275
7276    #[test]
7277    fn cloglog_ghq_adaptive_matches_explicit() {
7278        let ctx = QuadratureContext::new();
7279        let mu = 0.7;
7280        let sigma = 1.2;
7281        let adaptive = cloglog_ghq_derivatives_adaptive(&ctx, mu, sigma);
7282        let n = adaptive_point_count_from_sd(sigma);
7283        let explicit = cloglog_ghq_derivatives(&ctx, mu, sigma, n);
7284        assert_relative_eq!(adaptive.l, explicit.l, epsilon = 1e-15);
7285        assert_relative_eq!(adaptive.l_mu, explicit.l_mu, epsilon = 1e-15);
7286        assert_relative_eq!(adaptive.l_sigma, explicit.l_sigma, epsilon = 1e-15);
7287        assert_relative_eq!(adaptive.l_mumu, explicit.l_mumu, epsilon = 1e-15);
7288    }
7289
7290    #[test]
7291    fn cloglog_ghq_value_matches_mathematical_target_in_central_regime() {
7292        let ctx = QuadratureContext::new();
7293        for &mu in &[-1.0, 0.0, 0.5, 2.0] {
7294            for &sigma in &[0.1, 0.5, 1.0] {
7295                let ghq = cloglog_ghq_value(&ctx, mu, sigma, 51);
7296                let (expected_mean, _) = cloglog_reference_mean_and_derivative(mu, sigma);
7297                assert_relative_eq!(ghq, expected_mean, epsilon = 1e-12, max_relative = 2e-8);
7298            }
7299        }
7300    }
7301
7302    // ── Cloglog negative-tail asymptotic tests ──────────────────────────
7303
7304    #[test]
7305    fn cloglog_negative_tail_mean_matches_exact_near_transition() {
7306        // At η = −30 the exact cloglog mean is 1 − exp(−exp(−30)).
7307        // Our tail helper should agree to high relative accuracy where the
7308        // implementation transitions into the negative-tail approximation.
7309        let eta: f64 = -30.0;
7310        let exact = {
7311            let ex = eta.exp();
7312            -(-ex).exp_m1()
7313        };
7314        let tail = cloglog_negative_tail_mean(eta);
7315        assert!(
7316            (exact - tail).abs() < 1e-26 * exact.abs().max(1e-300),
7317            "tail mean at η={eta}: exact={exact:.6e} tail={tail:.6e}"
7318        );
7319    }
7320
7321    #[inline]
7322    fn cloglog_negative_tail_derivative(eta: f64) -> f64 {
7323        // dμ/dη = exp(η) · exp(−exp(η)).
7324        if eta < -745.0 {
7325            0.0
7326        } else {
7327            let ex = safe_exp(eta);
7328            (ex * (-ex).exp()).max(0.0)
7329        }
7330    }
7331
7332    #[test]
7333    fn cloglog_negative_tail_derivative_matches_exact_near_transition() {
7334        // At η = −30: dμ/dη = exp(η)·exp(−exp(η)).
7335        let eta: f64 = -30.0;
7336        let ex = eta.exp();
7337        let exact = ex * (-ex).exp();
7338        let tail = cloglog_negative_tail_derivative(eta);
7339        assert!(
7340            (exact - tail).abs() < 1e-26 * exact.abs().max(1e-300),
7341            "tail derivative at η={eta}: exact={exact:.6e} tail={tail:.6e}"
7342        );
7343    }
7344
7345    #[test]
7346    fn cloglog_negative_tail_degenerate_branch_matches_target_near_transition() {
7347        let ctx = QuadratureContext::default();
7348        let sigma = 0.0;
7349        for &mu in &[-30.001, -30.0, -29.999] {
7350            let out = cloglog_posterior_meanwith_deriv_controlled(&ctx, mu, sigma);
7351            assert_relative_eq!(
7352                out.mean,
7353                cloglog_mean_exact(mu),
7354                epsilon = 1e-28,
7355                max_relative = 1e-15
7356            );
7357            assert_relative_eq!(
7358                out.dmean_dmu,
7359                cloglog_mean_d1_exact(mu),
7360                epsilon = 1e-28,
7361                max_relative = 1e-15
7362            );
7363        }
7364    }
7365
7366    #[test]
7367    fn cloglog_negative_tail_small_sigma_branch_matches_target_near_transition() {
7368        let ctx = QuadratureContext::default();
7369        let sigma = 0.1;
7370        for &mu in &[-30.001, -30.0, -29.999] {
7371            let out = cloglog_posterior_meanwith_deriv_controlled(&ctx, mu, sigma);
7372            let (expected_mean, expected_deriv) = cloglog_reference_mean_and_derivative(mu, sigma);
7373            assert_relative_eq!(
7374                out.mean,
7375                expected_mean,
7376                epsilon = 1e-24,
7377                max_relative = 1e-10
7378            );
7379            assert_relative_eq!(
7380                out.dmean_dmu,
7381                expected_deriv,
7382                epsilon = 1e-24,
7383                max_relative = 1e-10
7384            );
7385        }
7386    }
7387
7388    /// Reference heap-based Cholesky-with-jitter, kept here as a test oracle
7389    /// so we can confirm that the new stack-allocated variant matches it
7390    /// bit-for-bit (modulo the bit-identical scalar math, which is by design).
7391    fn ref_cholesky_heap(cov: &[Vec<f64>]) -> Option<Vec<Vec<f64>>> {
7392        let n = cov.len();
7393        if n == 0 || cov.iter().any(|r| r.len() != n) {
7394            return None;
7395        }
7396        let mut base = cov.to_vec();
7397        for retry in 0..8 {
7398            let jitter = if retry == 0 {
7399                0.0
7400            } else {
7401                1e-12 * 10f64.powi(retry - 1)
7402            };
7403            if jitter > 0.0 {
7404                for i in 0..n {
7405                    base[i][i] = cov[i][i] + jitter;
7406                }
7407            }
7408            let mut l = vec![vec![0.0_f64; n]; n];
7409            let mut ok = true;
7410            for i in 0..n {
7411                for j in 0..=i {
7412                    let mut sum = base[i][j];
7413                    for k in 0..j {
7414                        sum -= l[i][k] * l[j][k];
7415                    }
7416                    if i == j {
7417                        if !sum.is_finite() || sum <= 0.0 {
7418                            ok = false;
7419                            break;
7420                        }
7421                        l[i][j] = sum.sqrt();
7422                    } else {
7423                        l[i][j] = sum / l[j][j];
7424                    }
7425                }
7426                if !ok {
7427                    break;
7428                }
7429            }
7430            if ok {
7431                return Some(l);
7432            }
7433        }
7434        None
7435    }
7436
7437    #[test]
7438    fn cholesky_static_matches_heap_d2() {
7439        // A handful of deterministic PSD 2x2 cases generated from
7440        // randomized factors: cov = A A^T + diag(eps).
7441        let cases: &[[[f64; 2]; 2]] = &[
7442            [[1.0, 0.0], [0.0, 1.0]],
7443            [[2.5, 0.3], [0.3, 0.75]],
7444            [[1.0, 0.9999], [0.9999, 1.0]],
7445            [[1e-10, 0.0], [0.0, 1e-10]],
7446            [[4.0, -1.5], [-1.5, 2.25]],
7447        ];
7448        for cov in cases {
7449            let stack = cholesky_static_with_jitter::<2>(cov).expect("stack cholesky");
7450            let heap_in: Vec<Vec<f64>> = cov.iter().map(|r| r.to_vec()).collect();
7451            let heap = ref_cholesky_heap(&heap_in).expect("heap cholesky");
7452            for i in 0..2 {
7453                for j in 0..2 {
7454                    assert_eq!(
7455                        stack[i][j].to_bits(),
7456                        heap[i][j].to_bits(),
7457                        "mismatch at ({i},{j}) for cov={cov:?}"
7458                    );
7459                }
7460            }
7461        }
7462    }
7463
7464    #[test]
7465    fn cholesky_static_matches_heap_d3() {
7466        let cases: &[[[f64; 3]; 3]] = &[
7467            [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
7468            [[2.0, 0.5, 0.1], [0.5, 1.5, -0.2], [0.1, -0.2, 0.8]],
7469            [[4.0, 1.0, 0.5], [1.0, 3.0, 0.25], [0.5, 0.25, 2.0]],
7470        ];
7471        for cov in cases {
7472            let stack = cholesky_static_with_jitter::<3>(cov).expect("stack cholesky");
7473            let heap_in: Vec<Vec<f64>> = cov.iter().map(|r| r.to_vec()).collect();
7474            let heap = ref_cholesky_heap(&heap_in).expect("heap cholesky");
7475            for i in 0..3 {
7476                for j in 0..3 {
7477                    assert_eq!(
7478                        stack[i][j].to_bits(),
7479                        heap[i][j].to_bits(),
7480                        "mismatch at ({i},{j}) for cov={cov:?}"
7481                    );
7482                }
7483            }
7484        }
7485    }
7486
7487    #[test]
7488    fn cholesky_static_d1() {
7489        let l = cholesky_static_with_jitter::<1>(&[[2.25]]).expect("d=1");
7490        assert_eq!(l[0][0], 1.5);
7491        // Tiny negative diagonal (roundoff-scale) is rescued by the
7492        // additive jitter ladder (1e-12 … 1e-6). At retry 1 the diagonal
7493        // becomes -1e-13 + 1e-12 ≈ 9e-13 > 0, so Cholesky succeeds.
7494        // The original assertion here used `-1.0`, but additive jitter
7495        // capped at 1e-6 cannot recover a diagonal of -1.0 → -1.0+1e-6
7496        // < 0 for every retry, so that assertion was unsatisfiable under
7497        // the function's documented jitter ladder. The intent of the
7498        // assertion was clearly to cover the "rescued by jitter" path,
7499        // which is what a roundoff-scale negative diagonal exercises.
7500        assert!(cholesky_static_with_jitter::<1>(&[[-1.0e-13]]).is_some());
7501        // A negative variance triggers jitter; with jitter <= 1e-6 it still
7502        // can't reach positive — should return None.
7503        assert!(cholesky_static_with_jitter::<1>(&[[-1.0e3]]).is_none());
7504    }
7505}
7506
7507#[cfg(test)]
7508mod log_survival_panel_2714_tests {
7509    use super::{
7510        LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER, LOG_SURVIVAL_PANEL_MAX_NODES,
7511        LOG_SURVIVAL_PANEL_MIN_NODES, LOG_SURVIVAL_TOWER_MAX_LOG_CANCELLATION, LogSurvivalBranch,
7512        QuadratureContext, log_survival_jet, log_survival_panel,
7513    };
7514
7515    /// `(mu, sigma, ln S)` — 60-digit mpmath reference for
7516    /// `S(mu,sigma) = E[exp(-e^eta)]`, `eta ~ N(mu, sigma^2)`.
7517    ///
7518    /// Computed with the Laplace peak factored out (integrate
7519    /// `exp(L(z) - L(z*))`, add `L(z*)` back), which is what makes the
7520    /// reference accurate ABSOLUTELY in `ln S` even where `ln S = -1.3e5`.
7521    /// The naive un-shifted high-precision integral is itself wrong by `8.7e-3`
7522    /// at `(8, 0.005)` and must not be used as an oracle here.
7523    const LOG_SURVIVAL_REFERENCE: &[(f64, f64, f64)] = &[
7524        (-30.0, 0.05, -9.369_327_311_241_221e-14),
7525        (-20.0, 0.002, -2.061_157_744_749_916_5e-9),
7526        (-20.0, 2.0, -1.522_997_352_870_166_6e-8),
7527        (-8.0, 0.15, -3.392_565_812_965_201_6e-4),
7528        (-8.0, 8.0, -1.981_213_453_495_813_4e-1),
7529        (-3.0, 0.02, -4.979_653_073_925_083_3e-2),
7530        (-3.0, 1.0, -7.722_156_176_750_611e-2),
7531        (-1.0, 0.005, -3.678_823_479_542_769_3e-1),
7532        (0.0, 0.05, -9.999_992_216_748_937e-1),
7533        (0.0, 20.0, -7.163_521_358_485_562e-1),
7534        (1.0, 0.15, -2.668_055_285_814_174_4e0),
7535        (1.8, 0.15, -5.742_720_404_435_083e0),
7536        (1.8, 0.5, -4.257_254_029_226_836e0),
7537        (3.2, 0.005, -2.452_531_812_111_085_3e1),
7538        (3.2, 0.15, -2.014_631_867_969_775_7e1),
7539        (3.2, 4.0, -1.691_825_149_278_477e0),
7540        (5.0, 0.02, -1.442_777_351_752_632_2e2),
7541        (5.0, 1.0, -1.128_111_355_370_178_3e1),
7542        (8.0, 0.002, -2.963_400_229_027_156e3),
7543        (8.0, 0.05, -1.113_594_437_164_973_2e3),
7544        (8.0, 0.5, -7.097_988_851_759_84e1),
7545        (8.0, 60.0, -8.137_859_923_376_214e-1),
7546        (12.0, 0.002, -1.289_824_339_085_843_7e5),
7547        (12.0, 0.15, -1.181_338_131_091_633_2e3),
7548        (12.0, 1.0, -5.819_669_561_598_102e1),
7549        (20.0, 0.05, -3.135_653_820_340_577_3e4),
7550        (-50.0, 8.0, -7.328_008_793_232_462e-10),
7551        (-18.0, 0.001, -1.522_998_735_970_428_8e-8),
7552    ];
7553
7554    /// #2714, the accuracy half: `ln S` on one panel is at the f64 relative
7555    /// floor everywhere, including the corners where the ladder it replaced was
7556    /// wrong by `4.4e+06`.
7557    ///
7558    /// The bar is RELATIVE (`1e-13` of `|ln S|`, floored at `1e-13` absolute so
7559    /// the near-1 rows are not graded on a vanishing denominator). That is the
7560    /// right axis: `ln S` is consumed as an additive contribution to a log
7561    /// likelihood and as the argument of exponentiated differences, so what
7562    /// must be small is its error against its own magnitude.
7563    ///
7564    /// Rows carried deliberately, with what each one would catch:
7565    /// * `(12, 0.002)` / `(8, 0.002)` — the fixed-window Gumbel escape hatch,
7566    ///   whose `Phi((eta-mu)/sigma)` transition was unresolved by 513 nodes.
7567    /// * `(12, 1.0)` / `(8, 0.5)` — the Miles/CC value route, `.ln()`-ed.
7568    /// * `(3.2, 0.15)` — the exact point #2714's diagnosis names.
7569    /// * `(-50, 8)` — the rare-event asymptotic `ln1p(-e^{mu+sigma^2/2})` at
7570    ///   its own gate `rare_log = -18`, where it is 20.8x wrong because the
7571    ///   higher cumulants are not small when sigma is large.
7572    /// * `(-30, 0.05)` / `(-18, 0.001)` — `S -> 1`, which is the complement
7573    ///   branch, i.e. relative accuracy where `ln S -> 0`.
7574    #[test]
7575    fn log_survival_matches_a_high_precision_reference_2714() {
7576        let ctx = QuadratureContext::new();
7577        let mut worst = 0.0_f64;
7578        let mut worst_at = (f64::NAN, f64::NAN);
7579        for &(mu, sigma, expected) in LOG_SURVIVAL_REFERENCE {
7580            let got = log_survival_jet(&ctx, mu, sigma, 0).log_survival;
7581            let error = (got - expected).abs() / expected.abs().max(1.0);
7582            if error > worst {
7583                worst = error;
7584                worst_at = (mu, sigma);
7585            }
7586            assert!(
7587                error <= 1.0e-13,
7588                "#2714: ln S({mu}, {sigma}) = {got:.17e} against reference \
7589                 {expected:.17e} (relative {error:.3e}); one log-space panel has \
7590                 to hold the f64 floor at every (mu, sigma), because a routed \
7591                 surface whose error is a step function of (mu, sigma) is the \
7592                 defect this replaced"
7593            );
7594        }
7595        // NON-VACUITY: the reference must actually be exercised, i.e. the grid
7596        // has to contain rows the evaluator does not reproduce for free.
7597        assert!(
7598            worst > 0.0,
7599            "#2714: every reference row reproduced bit-exactly, which means the \
7600             table is not testing the quadrature"
7601        );
7602        let (mu, sigma) = worst_at;
7603        println!("[2714] worst relative ln S error {worst:.3e} at (mu={mu}, sigma={sigma})");
7604    }
7605
7606    /// #2714, the consistency half: the analytic `sigma^j d^j S/d mu^j` tower is
7607    /// the derivative of the `ln S` this module returns.
7608    ///
7609    /// This is the assertion whose absence let the defect live. Every earlier
7610    /// gate scored an analytic derivative against ITS OWN value function; the
7611    /// stall came from the derivative being right about the true `S` while the
7612    /// value came off a different approximation. A central difference of the
7613    /// shipped `ln S` against the shipped tower cannot be satisfied by two
7614    /// implementations that agree with each other and not with the truth.
7615    #[test]
7616    fn log_survival_tower_is_the_derivative_of_the_value_2714() {
7617        let ctx = QuadratureContext::new();
7618        let mut worst = 0.0_f64;
7619        for &(mu, sigma) in &[
7620            (-8.0, 0.15),
7621            (-3.0, 0.5),
7622            (0.0, 1.0),
7623            (1.8, 0.15),
7624            (3.2, 0.15),
7625            (3.2, 2.0),
7626            (5.0, 1.0),
7627            (8.0, 4.0),
7628            (0.0, 8.0),
7629            (-3.0, 20.0),
7630        ] {
7631            let jet = log_survival_jet(&ctx, mu, sigma, 1);
7632            let first = jet.scaled_mu_derivatives[1];
7633            // d/dmu ln S = (d S/d mu)/S = sign * exp(log|sigma dS/dmu| - ln S)/sigma
7634            let analytic = first.sign * (first.log_abs - jet.log_survival).exp() / sigma;
7635            let step = (f64::EPSILON.cbrt()) * (1.0 + mu.abs());
7636            let up = log_survival_jet(&ctx, mu + step, sigma, 0).log_survival;
7637            let down = log_survival_jet(&ctx, mu - step, sigma, 0).log_survival;
7638            let numeric = (up - down) / (2.0 * step);
7639            let error = (analytic - numeric).abs() / analytic.abs().max(1.0e-3);
7640            worst = worst.max(error);
7641            assert!(
7642                error <= 1.0e-7,
7643                "#2714: d/dmu ln S at (mu={mu}, sigma={sigma}) is {analytic:.12e} \
7644                 analytically and {numeric:.12e} by central difference of the \
7645                 shipped value (relative {error:.3e}). A tower that is not the \
7646                 derivative of the value it ships with is what collapses the \
7647                 joint-Newton trust region."
7648            );
7649        }
7650        println!("[2714] worst |analytic - FD| / |analytic| = {worst:.3e}");
7651    }
7652
7653    /// #2714: `σ^j ∂_μ^j S` is a property of `(μ, σ)`, not of how many orders
7654    /// the caller asked for.
7655    ///
7656    /// The panel's window and node count are chosen from the order it is placed
7657    /// for, so reading that order off the REQUEST made the same integral come
7658    /// off a different rule per consumer. That is not hypothetical here: on one
7659    /// latent-survival row, `log_kernel_bundle` is asked for `max_k = 4`, `5`,
7660    /// `6` and `7` by the value, gradient, Hessian and contracted-third paths
7661    /// respectively, and every one of them reads entry `1` of the same tower.
7662    /// A Hessian assembled from a different `∂_a K₀` than the gradient it is
7663    /// paired with is not the derivative of that gradient.
7664    ///
7665    /// Bit-equality, not a tolerance: two placements of one rule differ by
7666    /// round-off, and round-off is exactly what a trust ratio divides by near
7667    /// its fixed point. There is nothing to be tolerant of — the numbers are
7668    /// either the same object or they are not.
7669    #[test]
7670    fn log_survival_tower_is_independent_of_the_requested_order_2714() {
7671        let ctx = QuadratureContext::new();
7672        let mut compared = 0usize;
7673        for &(mu, sigma) in &[
7674            (-20.0, 0.002),
7675            (-8.0, 0.15),
7676            (-3.0, 0.5),
7677            (0.0, 1.0),
7678            (1.8, 0.15),
7679            (3.2, 0.15),
7680            (3.2, 2.0),
7681            (5.0, 1.0),
7682            (8.0, 4.0),
7683            (0.0, 8.0),
7684            (12.0, 0.02),
7685            (-3.0, 20.0),
7686        ] {
7687            let reference = log_survival_jet(&ctx, mu, sigma, LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER);
7688            for requested in 1..=LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER {
7689                let jet = log_survival_jet(&ctx, mu, sigma, requested);
7690                for order in 1..=requested {
7691                    let got = jet.scaled_mu_derivatives[order];
7692                    let want = reference.scaled_mu_derivatives[order];
7693                    assert_eq!(
7694                        (got.log_abs.to_bits(), got.sign.to_bits()),
7695                        (want.log_abs.to_bits(), want.sign.to_bits()),
7696                        "#2714: sigma^{order} d^{order} S/d mu^{order} at (mu={mu}, \
7697                         sigma={sigma}) is {:.17e} (sign {}) when {requested} orders \
7698                         are requested and {:.17e} (sign {}) when \
7699                         {LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER} are. The tower must \
7700                         not depend on how much of it the caller wants.",
7701                        got.log_abs, got.sign, want.log_abs, want.sign
7702                    );
7703                    compared += 1;
7704                }
7705            }
7706        }
7707        // Non-vacuity: an assertion loop that never ran is a green that means
7708        // nothing, and this one is nested three deep.
7709        assert!(
7710            compared >= 12 * (LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER
7711                * (LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER + 1)
7712                / 2),
7713            "#2714: only {compared} tower entries were compared"
7714        );
7715        println!("[2714] tower order-independence: {compared} entries bit-identical");
7716    }
7717
7718    /// #2714: certification is a PREFIX, so the basis a consumer is routed to
7719    /// does not depend on how many rungs it asked for.
7720    ///
7721    /// The all-or-nothing form refused the whole tower when its last rung
7722    /// cancelled, so a consumer needing rungs `0..=1` and asking for `0..=5` was
7723    /// denied a basis that is well conditioned at every rung it reads. The
7724    /// prefix form gives it exactly the rungs the quadrature vouches for.
7725    #[test]
7726    fn log_survival_tower_certification_is_a_prefix_2714() {
7727        let ctx = QuadratureContext::new();
7728        let mut saw_truncation = false;
7729        for &(mu, sigma, ..) in LOG_SURVIVAL_TOWER_REFERENCE {
7730            let jet = log_survival_jet(&ctx, mu, sigma, LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER);
7731            let Some(prefix) = jet.certified_prefix_order(LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER)
7732            else {
7733                continue;
7734            };
7735            // Monotone in the request: asking for less can never certify less.
7736            for requested in 0..=LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER {
7737                assert_eq!(
7738                    jet.certified_prefix_order(requested),
7739                    Some(prefix.min(requested)),
7740                    "#2714: the certified prefix at (mu={mu}, sigma={sigma}) is \
7741                     {prefix} but reading it against a request of {requested} \
7742                     disagrees — the prefix must be a property of the tower, \
7743                     truncated by the request and not decided by it"
7744                );
7745                // And it agrees with the all-or-nothing reading exactly where
7746                // that reading succeeds, so nothing that used to be admitted
7747                // has been silently widened.
7748                assert_eq!(
7749                    jet.certified_scaled_mu_derivatives(requested).is_some(),
7750                    requested <= prefix,
7751                    "#2714: the whole-tower and prefix readings disagree at \
7752                     (mu={mu}, sigma={sigma}, requested={requested})"
7753                );
7754            }
7755            if prefix < LOG_SURVIVAL_MAX_MU_DERIVATIVE_ORDER {
7756                saw_truncation = true;
7757            }
7758        }
7759        // The whole point is the rows where the prefix is SHORTER than the
7760        // request; a table on which every tower certifies to the top says
7761        // nothing about the change.
7762        assert!(
7763            saw_truncation,
7764            "#2714: no reference row produced a truncated certified prefix, so \
7765             this test did not exercise the behaviour it exists for"
7766        );
7767    }
7768
7769    /// `(mu, sigma, ln|σ^j ∂_μ^j S| for j = 0..=4, sign of each)` — 50-digit
7770    /// mpmath reference for the SAME object the panel tower produces,
7771    /// `σ^j ∂_μ^j S = ∫ He_j(z) φ(z) exp(−e^{μ+σz}) dz`.
7772    ///
7773    /// Computed peak-shifted, on a window carrying 95 e-folds plus the
7774    /// order's own `j·ln(2+|z⋆|)` padding, and cross-checked at two working
7775    /// precisions and two quadrature degrees.
7776    ///
7777    /// This is the oracle the tower has never had. Its existing gate is a
7778    /// central difference of the shipped `ln S` against the shipped order-1
7779    /// entry — necessary (it is the assertion whose absence let #2714 live)
7780    /// but self-referential past order 1 and blind to orders 2–4 entirely,
7781    /// which are exactly the orders `log_kernel_bundle` asks for.
7782    const LOG_SURVIVAL_TOWER_REFERENCE: &[(f64, f64, [f64; 5], [f64; 5])] = &[
7783        (-20.0, 0.002, [-2.0611577447499165e-9, -26.214606100483358, -32.429214200966715, -38.643822303511239, -44.858430410178095], [1.0, -1.0, -1.0, -1.0, -1.0]),
7784        (-8.0, 0.15, [-0.00033925658129652018, -9.8862169612318004, -11.783683981303588, -13.681498274811226, -15.580007954561913], [1.0, -1.0, -1.0, -1.0, -1.0]),
7785        (-8.0, 8.0, [-0.19812134534958133, -1.3528211702547069, -1.452147542328205, -3.240571743395306, -0.71024921821004234], [1.0, -1.0, -1.0, 1.0, 1.0]),
7786        (-3.0, 0.02, [-0.049796530739250832, -6.9616394585654159, -10.92476204736755, -14.944640460549261, -19.104083600790619], [1.0, -1.0, -1.0, -1.0, -1.0]),
7787        (-3.0, 0.5, [-0.055971877835218903, -3.6398579829672956, -4.4066491238893504, -5.2575250298329575, -6.3338354346007994], [1.0, -1.0, -1.0, -1.0, -1.0]),
7788        (-3.0, 20.0, [-0.60124121574363753, -0.9283108111246211, -3.0420009372801215, -0.94719003039898553, -1.9515751519096107], [1.0, -1.0, -1.0, 1.0, 1.0]),
7789        (-1.0, 0.005, [-0.36788234795427693, -6.666196411633682, -12.423205395414024, -20.715002885443461, -22.768239058212055], [1.0, -1.0, -1.0, -1.0, 1.0]),
7790        (0.0, 0.05, [-0.99999922167489372, -3.9969814880950871, -13.681704026824476, -9.9897069128682089, -12.994203213800263], [1.0, -1.0, 1.0, 1.0, 1.0]),
7791        (0.0, 1.0, [-0.96297240050030377, -1.3514828821346528, -3.4776689251068834, -2.0721333282379262, -4.077937287249247], [1.0, -1.0, 1.0, 1.0, -1.0]),
7792        (0.0, 8.0, [-0.75101079589424262, -0.93383523622026295, -3.6184199118831959, -0.96301212066547202, -2.5659122387330754], [1.0, -1.0, 1.0, 1.0, -1.0]),
7793        (1.0, 0.15, [-2.6680552858141743, -3.6128621614846425, -5.0108306306587141, -8.9368697272493613, -7.5728553818569553], [1.0, -1.0, 1.0, -1.0, -1.0]),
7794        (1.8, 0.15, [-5.7427204044350835, -5.9515476490674363, -6.340239096657947, -7.0035313750765859, -8.252090685678664], [1.0, -1.0, 1.0, -1.0, 1.0]),
7795        (1.8, 0.5, [-4.2572540292268353, -3.8392878811891043, -3.6246169834513785, -3.7636817578612526, -5.1584862006168726], [1.0, -1.0, 1.0, -1.0, 1.0]),
7796        (3.2, 0.005, [-24.525318121110852, -26.624235940415906, -28.764769508877318, -30.95061498411088, -33.186272517159154], [1.0, -1.0, 1.0, -1.0, 1.0]),
7797        (3.2, 0.15, [-20.146318679697758, -19.21570557965608, -18.328812156432285, -17.490308387278464, -16.706077981177801], [1.0, -1.0, 1.0, -1.0, 1.0]),
7798        (3.2, 2.0, [-2.9809436264637528, -2.3631698967287561, -1.9865287099827521, -2.1204144743340792, -2.9045009851726548], [1.0, -1.0, 1.0, -1.0, -1.0]),
7799        (3.2, 4.0, [-1.691825149278477, -1.3630396002384465, -1.5160548511506286, -2.9955755434621241, -0.80260762836904534], [1.0, -1.0, 1.0, 1.0, -1.0]),
7800        (5.0, 1.0, [-11.281113553701783, -9.9522520507276806, -8.6794206852298702, -7.4721966945023752, -6.3443308739944037], [1.0, -1.0, 1.0, -1.0, 1.0]),
7801        (8.0, 0.05, [-1113.5944371649733, -1110.1523256471932, -1106.7108385495186, -1103.2699768904941, -1099.8297416923265], [1.0, -1.0, 1.0, -1.0, 1.0]),
7802        (8.0, 0.5, [-70.979888517598404, -68.673129437259724, -66.374650578770375, -64.084648631557826, -61.803330805347407], [1.0, -1.0, 1.0, -1.0, 1.0]),
7803        (8.0, 4.0, [-3.9201018255002301, -3.0676533191912935, -2.3815942308869494, -1.9700438387194749, -2.2785027569363032], [1.0, -1.0, 1.0, -1.0, 1.0]),
7804        (8.0, 60.0, [-0.8137859923376214, -0.92937929368933511, -2.8751091738859909, -0.95047056774657423, -1.7838093439866154], [1.0, -1.0, 1.0, 1.0, -1.0]),
7805        (12.0, 0.002, [-128982.43390858436, -128977.07394828311, -128971.7139945779, -128966.35404746883, -128960.99410695599], [1.0, -1.0, 1.0, -1.0, 1.0]),
7806        (12.0, 1.0, [-58.196695615981018, -55.917604210289579, -53.648028066976042, -51.388233991426026, -49.138505716496612], [1.0, -1.0, 1.0, -1.0, 1.0]),
7807        (-50.0, 8.0, [-7.3280087932324622e-10, -19.248520695018656, -17.486423137639009, -15.749919102771783, -14.041433160139017], [1.0, -1.0, -1.0, -1.0, -1.0]),
7808        (20.0, 0.05, [-31356.538203405772, -31351.094833958202, -31345.651481726192, -31340.208146710605, -31334.764828912308], [1.0, -1.0, 1.0, -1.0, 1.0]),
7809    ];
7810
7811    /// #2714 / #2610: the whole μ-derivative tower is graded against an
7812    /// EXTERNAL oracle, at every order `log_kernel_bundle` actually asks for.
7813    ///
7814    /// The tower's shipped gate finite-differences `ln S` against the order-1
7815    /// entry. That catches the defect this issue names, and it cannot catch a
7816    /// tower that is self-consistent and wrong at order 2, 3 or 4 — which are
7817    /// the orders `LogKernelSumJet` reads (`max_k = k + 4`) and which the
7818    /// log-σ curvature is built out of. A sign flip at order 3 would leave the
7819    /// FD gate green and turn every latent-survival Hessian upside down.
7820    ///
7821    /// Certification is graded together with accuracy on purpose: the point of
7822    /// [`LogSurvivalJet::certified_scaled_mu_derivatives`] is that admitted
7823    /// entries are AT the working floor, so "admitted" and "accurate to 1e-13"
7824    /// must be the same statement about the same rows.
7825    #[test]
7826    fn log_survival_tower_matches_a_high_precision_reference_2714() {
7827        let ctx = QuadratureContext::new();
7828        let mut worst_certified = 0.0_f64;
7829        let mut worst_certified_at = (f64::NAN, f64::NAN, 0usize);
7830        let mut certified_rows = 0usize;
7831        let mut refused_rows = 0usize;
7832        for &(mu, sigma, expected_log, expected_sign) in LOG_SURVIVAL_TOWER_REFERENCE {
7833            let jet = log_survival_jet(&ctx, mu, sigma, 4);
7834            let certified = jet.certified_scaled_mu_derivatives(4).is_some();
7835            if certified {
7836                certified_rows += 1;
7837            } else {
7838                refused_rows += 1;
7839            }
7840            if !certified {
7841                // A refused row is refused precisely because its signed sum
7842                // cancelled past the point where f64 resolves it — at
7843                // `(mu=-20, sigma=0.002)` order 4 the cancellation is ~45
7844                // nats, so neither the magnitude NOR the sign of that entry is
7845                // a number. Grading it would be grading noise; what the
7846                // refusal path owes is in the companion test below, which
7847                // checks that the cancellation it reports actually bounds the
7848                // error it made.
7849                continue;
7850            }
7851            for order in 0..=4 {
7852                let entry = jet.scaled_mu_derivatives[order];
7853                assert_eq!(
7854                    entry.sign, expected_sign[order],
7855                    "#2714: sigma^{order} d^{order} S/d mu^{order} at (mu={mu}, \
7856                     sigma={sigma}) is certified and has sign {} against the \
7857                     high-precision {}. A sign error in the tower is not an \
7858                     accuracy question — it reverses the curvature every \
7859                     latent-survival row hands the joint Newton.",
7860                    entry.sign, expected_sign[order]
7861                );
7862                // The entries are log-magnitudes, so a difference of logs IS
7863                // the relative error of the magnitude they encode. The bar is
7864                // the same one the `ln S` table uses: `1e-13` of the entry's
7865                // own magnitude, floored at `1e-13` absolute. An entry
7866                // reported as a logarithm cannot be closer to the truth than
7867                // the ulp of that logarithm, and `|ln| = 1.3e5` occurs here.
7868                let error = (entry.log_abs - expected_log[order]).abs();
7869                let bar = 1.0e-13 * expected_log[order].abs().max(1.0);
7870                assert!(
7871                    error <= bar,
7872                    "#2714: sigma^{order} d^{order} S/d mu^{order} at (mu={mu}, \
7873                     sigma={sigma}) is CERTIFIED by its own measured \
7874                     cancellation ({:.3} nats) and yet lands {error:.3e} from \
7875                     the reference against a bar of {bar:.3e}. Certification \
7876                     means 'this entry is at the working floor'; if it is not, \
7877                     the gate that replaced the sigma >= 8 constant is weaker \
7878                     than the constant was.",
7879                    entry.log_cancellation
7880                );
7881                let relative = error / expected_log[order].abs().max(1.0);
7882                if relative > worst_certified {
7883                    worst_certified = relative;
7884                    worst_certified_at = (mu, sigma, order);
7885                }
7886            }
7887        }
7888        // NON-VACUITY, both ways: the gate has to admit and it has to refuse.
7889        // A table on which everything is certified says nothing about the
7890        // refusal path, and one on which nothing is says nothing at all.
7891        assert!(
7892            certified_rows > 0 && refused_rows > 0,
7893            "#2714: the tower reference must straddle the certification gate — \
7894             got {certified_rows} certified and {refused_rows} refused rows"
7895        );
7896        let (mu, sigma, order) = worst_certified_at;
7897        println!(
7898            "[2714] tower: {certified_rows} certified / {refused_rows} refused; \
7899             worst certified error {worst_certified:.3e} at (mu={mu}, sigma={sigma}, j={order})"
7900        );
7901    }
7902
7903    /// #2714: the cancellation the tower reports is an HONEST error bar, not a
7904    /// label.
7905    ///
7906    /// `certified_scaled_mu_derivatives` replaced a `sigma >= 8` constant with
7907    /// a measured quantity, and the whole argument for that replacement is one
7908    /// error model: a signed log-sum-exp returns a result whose relative error
7909    /// is `≈ ε · Σ|terms| / |Σ terms|`, i.e. `ε · e^cancellation`. The
7910    /// admission bar `6.1` is the solve of `ε · e^cond = 1e-13` — so if the
7911    /// model over-promises, the bar is calibrated against a fiction and the
7912    /// refusal path is wrong in both directions at once.
7913    ///
7914    /// This grades the model where it is checkable, INCLUDING at rows the gate
7915    /// refuses (which the accuracy test above cannot assert on): the achieved
7916    /// error must sit under the predicted bar across the whole table, at every
7917    /// order, with a slack that covers the `√n` accumulation the single-`ε`
7918    /// model omits.
7919    #[test]
7920    fn log_survival_tower_cancellation_bounds_its_own_error_2714() {
7921        /// Slack over `ε · e^cancellation`. The model prices one rounding per
7922        /// term; an `n`-term compensationless sum accumulates like `√n · ε`,
7923        /// and `n` reaches [`LOG_SURVIVAL_PANEL_MAX_NODES`] = 4097, so `√n ≈
7924        /// 64`. Rounded up to 128 — one doubling of margin over the bound the
7925        /// model itself implies, which is the point at which a violation is a
7926        /// statement about the model rather than about the last two bits.
7927        const ERROR_MODEL_SLACK: f64 = 128.0;
7928        // Floor on the predicted bar. At zero cancellation the model predicts
7929        // `ε`, but the entries are LOGARITHMS: an entry whose `|ln|·||` is
7930        // 1.3e5 cannot be closer to the reference than the ulp of its own
7931        // magnitude, so the bar is `max(e^cancellation, |log_abs|)` — the
7932        // larger of the cancellation the sum suffered and the resolution of
7933        // the number it is reported in.
7934        let ctx = QuadratureContext::new();
7935        let mut worst_ratio = 0.0_f64;
7936        let mut worst_at = (f64::NAN, f64::NAN, 0usize);
7937        let mut observed_max_cancellation = 0.0_f64;
7938        for &(mu, sigma, expected_log, _) in LOG_SURVIVAL_TOWER_REFERENCE {
7939            let jet = log_survival_jet(&ctx, mu, sigma, 4);
7940            for order in 0..=4 {
7941                let entry = jet.scaled_mu_derivatives[order];
7942                if !entry.log_cancellation.is_finite() {
7943                    continue;
7944                }
7945                observed_max_cancellation =
7946                    observed_max_cancellation.max(entry.log_cancellation);
7947                let predicted = ERROR_MODEL_SLACK
7948                    * f64::EPSILON
7949                    * entry.log_cancellation.exp().max(entry.log_abs.abs().max(1.0));
7950                let error = (entry.log_abs - expected_log[order]).abs();
7951                let ratio = error / predicted;
7952                if ratio > worst_ratio {
7953                    worst_ratio = ratio;
7954                    worst_at = (mu, sigma, order);
7955                }
7956                assert!(
7957                    error <= predicted,
7958                    "#2714: the tower's own cancellation bar is not honest at \
7959                     (mu={mu}, sigma={sigma}, j={order}): it reports \
7960                     {:.4} nats of cancellation, which the signed-log-sum-exp \
7961                     error model prices at {predicted:.3e}, and the achieved \
7962                     error against the high-precision reference is \
7963                     {error:.3e}. The admission bar \
7964                     LOG_SURVIVAL_TOWER_MAX_LOG_CANCELLATION is derived FROM \
7965                     that model, so a model that under-predicts makes the bar \
7966                     meaningless.",
7967                    entry.log_cancellation
7968                );
7969            }
7970        }
7971        assert!(
7972            observed_max_cancellation > LOG_SURVIVAL_TOWER_MAX_LOG_CANCELLATION,
7973            "#2714: no row of the reference reaches the admission bar \
7974             ({LOG_SURVIVAL_TOWER_MAX_LOG_CANCELLATION} nats), so this test \
7975             never grades the model in the regime the bar exists for — worst \
7976             observed cancellation was {observed_max_cancellation:.3}"
7977        );
7978        let (mu, sigma, order) = worst_at;
7979        println!(
7980            "[2714] cancellation bar: worst achieved/predicted = {worst_ratio:.3e} \
7981             at (mu={mu}, sigma={sigma}, j={order}); max cancellation \
7982             {observed_max_cancellation:.3} nats"
7983        );
7984    }
7985
7986    /// #2714, the smoothness half: `ln S` has no step in it.
7987    ///
7988    /// The stall was not caused by `ln S` being inaccurate in the abstract — it
7989    /// was caused by the error being a DISCONTINUOUS function of `(mu, sigma)`,
7990    /// so that a step the model predicted from a gradient was scored by an
7991    /// objective that had jumped onto a different approximation in between.
7992    /// The instrument is Richardson agreement of the second difference at `h`
7993    /// and `2h`. For a smooth `f` both estimate `f''` and differ by
7994    /// `-h^2 f''''/4`, which for this family is a fixed `h^2/4 = 2.5e-7` of
7995    /// `f''` regardless of `mu` (`ln S ~ -e^mu`, so every derivative has the
7996    /// same magnitude). A routing step of size `d` instead moves the `h`
7997    /// estimate by `d/h^2` and the `2h` estimate by `d/4h^2`, so it cannot
7998    /// cancel: the pair disagrees by `0.75 d/h^2 = 7.5e5 d`. That is why the
7999    /// test compares two stencils rather than bounding one — the bound would
8000    /// have to know `f''`, and the disagreement does not.
8001    #[test]
8002    fn log_survival_has_no_routing_step_in_it_2714() {
8003        let ctx = QuadratureContext::new();
8004        let step = 1.0e-3_f64;
8005        for &sigma in &[0.05_f64, 0.15, 0.5, 1.0, 4.0, 8.0, 20.0] {
8006            let mut worst = 0.0_f64;
8007            let mut worst_mu = f64::NAN;
8008            let value = |mu: f64| log_survival_jet(&ctx, mu, sigma, 0).log_survival;
8009            let mut index = -100_i32;
8010            while index <= 100 {
8011                let mu = f64::from(index) * 0.1;
8012                let mid = value(mu);
8013                let fine =
8014                    (value(mu - step) - 2.0 * mid + value(mu + step)) / (step * step);
8015                let coarse = (value(mu - 2.0 * step) - 2.0 * mid + value(mu + 2.0 * step))
8016                    / (4.0 * step * step);
8017                let disagreement = (fine - coarse).abs() / (1.0 + fine.abs());
8018                if disagreement > worst {
8019                    worst = disagreement;
8020                    worst_mu = mu;
8021                }
8022                index += 1;
8023            }
8024            assert!(
8025                worst <= 2.0e-6,
8026                "#2714: the h and 2h second differences of ln S(., sigma={sigma}) \
8027                 disagree by {worst:.3e} (relative) near mu={worst_mu}, i.e. a jump \
8028                 of about {:.3e} in the value. The surface must be smooth across \
8029                 every internal routing decision, because that is precisely what \
8030                 the joint-Newton accept test differentiates.",
8031                worst * step * step / 0.75
8032            );
8033            println!("[2714] sigma={sigma}: worst Richardson disagreement {worst:.3e}");
8034        }
8035    }
8036
8037    /// #2714 / #2469: the node ladder is LIVE, and bounded.
8038    ///
8039    /// This is the successor to `..._node_ladder_is_inert_at_a_constant_513`,
8040    /// which pinned the opposite property: floor and ceiling coincided at 513,
8041    /// so `cloglog_gumbel_quad_nodes` returned a constant and the `SCALE/sigma`
8042    /// arithmetic around it reached no output. That inertness is exactly why the
8043    /// escape-hatch quadrature was wrong by `4.4e+06` at small sigma — it could
8044    /// not buy the resolution its own transition needed.
8045    ///
8046    /// The panel rule has no such suppressed dial: the count follows the
8047    /// integrand's local-scale arclength plus a `sqrt(sigma)` analyticity term,
8048    /// and both halves are load-bearing. The assertion is two-sided so a future
8049    /// change that re-pins the count fails on the mechanism.
8050    #[test]
8051    fn log_survival_panel_node_count_is_live_and_bounded_2714() {
8052        let mut counts = Vec::new();
8053        for &sigma in &[1.0e-4_f64, 1.0e-2, 0.15, 1.0, 8.0, 60.0, 1.0e3] {
8054            let panel = log_survival_panel(LogSurvivalBranch::Survival, 1.8, sigma, 0);
8055            assert!(
8056                panel.nodes >= LOG_SURVIVAL_PANEL_MIN_NODES
8057                    && panel.nodes <= LOG_SURVIVAL_PANEL_MAX_NODES,
8058                "#2714: node count {} at sigma={sigma} is outside \
8059                 [{LOG_SURVIVAL_PANEL_MIN_NODES}, {LOG_SURVIVAL_PANEL_MAX_NODES}]",
8060                panel.nodes
8061            );
8062            assert!(
8063                !panel.nodes.is_multiple_of(2),
8064                "#2714: the Clenshaw-Curtis panel needs an odd node count so the \
8065                 grid is symmetric and gap-free (sigma={sigma} gave {})",
8066                panel.nodes
8067            );
8068            assert!(
8069                panel.z_lo < panel.z_hi,
8070                "#2714: degenerate panel at sigma={sigma}"
8071            );
8072            counts.push(panel.nodes);
8073        }
8074        let (low, high) = (
8075            *counts.iter().min().expect("non-empty"),
8076            *counts.iter().max().expect("non-empty"),
8077        );
8078        assert!(
8079            high >= 4 * low,
8080            "#2714: the node ladder must actually MOVE with sigma — it spans \
8081             {low}..{high} over sigma in [1e-4, 1e3], and a ladder that does not \
8082             move is the #2469 inertness that made the escape-hatch quadrature \
8083             unable to resolve its own transition"
8084        );
8085    }
8086
8087    /// The panel is placed by the integrand, so it must bracket the peak and
8088    /// carry the requested number of e-folds on both sides. If it did not, the
8089    /// accuracy gate above could still pass by luck at the sampled points.
8090    #[test]
8091    fn log_survival_panel_brackets_its_own_peak_2714() {
8092        for &(mu, sigma) in &[
8093            (-20.0, 0.002),
8094            (-3.0, 0.15),
8095            (0.0, 1.0),
8096            (3.2, 0.15),
8097            (12.0, 0.002),
8098            (0.0, 60.0),
8099        ] {
8100            for branch in [LogSurvivalBranch::Survival, LogSurvivalBranch::Complement] {
8101                let panel = log_survival_panel(branch, mu, sigma, 0);
8102                let mid = 0.5 * (panel.z_lo + panel.z_hi);
8103                let at_mid = branch.log_integrand(mu, sigma, mid);
8104                let at_lo = branch.log_integrand(mu, sigma, panel.z_lo);
8105                let at_hi = branch.log_integrand(mu, sigma, panel.z_hi);
8106                assert!(
8107                    at_lo < at_mid && at_hi < at_mid,
8108                    "#2714: {branch:?} panel at (mu={mu}, sigma={sigma}) does not \
8109                     bracket its own maximum: L(lo)={at_lo:.6e}, L(mid)={at_mid:.6e}, \
8110                     L(hi)={at_hi:.6e}"
8111                );
8112            }
8113        }
8114    }
8115}