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