Skip to main content

gam_solve/
mixture_link.rs

1use crate::estimate::EstimationError;
2use crate::quadrature::latent_cloglog_jet5;
3use gam_math::{
4    jet_tower::trigamma,
5    probability::{normal_cdf, normal_pdf},
6};
7use gam_math::special::stable_polynomial_times_exp_neg as stable_nonnegative_poly_times_exp_neg;
8use gam_problem::{
9    InverseLink, LatentCLogLogState, LikelihoodSpec, LinkComponent, LinkFunction, MixtureLinkSpec,
10    MixtureLinkState, ResponseFamily, SasLinkSpec, SasLinkState, StandardLink,
11};
12use ndarray::{Array1, Array2};
13use statrs::function::beta::{beta_reg, ln_beta};
14use statrs::function::gamma::digamma;
15use std::ops::Neg;
16use std::sync::OnceLock;
17
18const SAS_U_CLAMP: f64 = 50.0;
19/// Inclusive eta domain for the solver's standard log inverse-link derivative
20/// seams. Within this conservative IEEE-754-safe interval, `exp(eta)` is finite,
21/// positive, and normal, so the value and every analytic derivative are exactly
22/// the same operation. Solver callers must reject steps outside this domain;
23/// silently projecting eta would define a different, nonsmooth link.
24pub const LOG_LINK_SOLVER_ETA_MIN: f64 = -700.0;
25/// Inclusive upper endpoint of the standard log-link solver domain.
26pub const LOG_LINK_SOLVER_ETA_MAX: f64 = 700.0;
27/// Bound B used by the bounded sinh-arcsinh log-delta parameterisation:
28/// `delta = exp(B * tanh(raw_log_delta / B))`. Exposed for the outer-strategy
29/// edge-barrier helpers in `solver/estimate.rs` that previously had to
30/// hard-code the same `12.0` with a "must match" comment.
31pub(crate) const SAS_LOG_DELTA_BOUND: f64 = 12.0;
32
33#[inline]
34fn latent_cloglog_quadctx() -> &'static crate::quadrature::QuadratureContext {
35    static QUADCTX: OnceLock<crate::quadrature::QuadratureContext> = OnceLock::new();
36    QUADCTX.get_or_init(crate::quadrature::QuadratureContext::new)
37}
38
39#[inline]
40fn latent_cloglog_point_jet(
41    state: &LatentCLogLogState,
42    eta: f64,
43) -> Result<InverseLinkJet, EstimationError> {
44    let jet = latent_cloglog_jet5(latent_cloglog_quadctx(), eta, state.latent_sd)?;
45    Ok(InverseLinkJet {
46        mu: jet.mean,
47        d1: jet.d1,
48        d2: jet.d2,
49        d3: jet.d3,
50    })
51}
52
53#[inline]
54pub(crate) fn log_link_solver_exp(eta: f64) -> Result<f64, EstimationError> {
55    if !(LOG_LINK_SOLVER_ETA_MIN..=LOG_LINK_SOLVER_ETA_MAX).contains(&eta) {
56        return Err(EstimationError::InverseLinkDomainViolation {
57            link: "standard log inverse link",
58            eta,
59            lower: LOG_LINK_SOLVER_ETA_MIN,
60            upper: LOG_LINK_SOLVER_ETA_MAX,
61        });
62    }
63    Ok(eta.exp())
64}
65
66#[inline]
67fn finite_inverse_link_eta(link: &'static str, eta: f64) -> Result<f64, EstimationError> {
68    if !eta.is_finite() {
69        return Err(EstimationError::InverseLinkDomainViolation {
70            link,
71            eta,
72            lower: -f64::MAX,
73            upper: f64::MAX,
74        });
75    }
76    Ok(eta)
77}
78
79#[derive(Clone, Copy)]
80struct AsinhJet5 {
81    value: f64,
82    d1: f64,
83    d2: f64,
84    d3: f64,
85    d4: f64,
86    d5: f64,
87}
88
89/// Exact eta derivatives of `asinh(eta)`, factored through `hypot` so powers
90/// of a large finite eta never form `inf * 0` in the derivative tails.
91#[inline]
92fn asinh_jet5(eta: f64) -> AsinhJet5 {
93    let q = eta.hypot(1.0);
94    let inv_q = q.recip();
95    let inv_q2 = inv_q * inv_q;
96    let inv_q3 = inv_q2 * inv_q;
97    let inv_q4 = inv_q2 * inv_q2;
98    let inv_q5 = inv_q4 * inv_q;
99    let t = eta / q;
100    let t2 = t * t;
101    let t4 = t2 * t2;
102    // `f64::asinh` computes `ln(x + sqrt(x*x + 1))`, whose `x*x` overflows to
103    // `inf` near `±f64::MAX` even though `asinh(±f64::MAX) ≈ ±710.48` is finite
104    // and well inside range. An `inf` value here would poison the far-tail SAS
105    // jet with `0·∞` once the bounded map saturates (`g^(k)=0 · inf`), so fall
106    // back to the overflow-free asymptotic `sign(x)·(ln|x| + ln 2)` — exact to
107    // full f64 precision wherever `x*x` overflows — when the library result is
108    // non-finite. In the finite region this is bit-identical to `asinh`.
109    let value = {
110        let v = eta.asinh();
111        if v.is_finite() {
112            v
113        } else {
114            eta.signum() * (eta.abs().ln() + std::f64::consts::LN_2)
115        }
116    };
117    AsinhJet5 {
118        value,
119        d1: inv_q,
120        d2: -t * inv_q2,
121        d3: (2.0 * t2 - inv_q2) * inv_q3,
122        d4: t * (9.0 * inv_q2 - 6.0 * t2) * inv_q4,
123        d5: (9.0 * inv_q4 - 72.0 * t2 * inv_q2 + 24.0 * t4) * inv_q5,
124    }
125}
126
127#[derive(Clone, Copy, Debug, PartialEq)]
128pub struct InverseLinkJet {
129    pub mu: f64,
130    pub d1: f64,
131    pub d2: f64,
132    pub d3: f64,
133}
134
135#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct LogitJet5 {
137    pub mu: f64,
138    pub d1: f64,
139    pub d2: f64,
140    pub d3: f64,
141    pub d4: f64,
142    pub d5: f64,
143}
144
145#[inline]
146fn canonicalzero(v: f64) -> f64 {
147    // Normalize the two IEEE zero encodings for deterministic jets without
148    // changing their mathematical support. A nonzero subnormal is still a
149    // representable derivative and must survive: replacing it by zero creates
150    // an artificial constant tail and a kink at MIN_POSITIVE.
151    if v == 0.0 { 0.0 } else { v }
152}
153
154#[inline]
155fn canonicalize_jet(mut jet: InverseLinkJet) -> InverseLinkJet {
156    jet.d1 = canonicalzero(jet.d1);
157    jet.d2 = canonicalzero(jet.d2);
158    jet.d3 = canonicalzero(jet.d3);
159    jet
160}
161
162#[inline]
163pub fn logit_inverse_link_jet5(eta: f64) -> LogitJet5 {
164    if eta.is_nan() {
165        return LogitJet5 {
166            mu: f64::NAN,
167            d1: f64::NAN,
168            d2: f64::NAN,
169            d3: f64::NAN,
170            d4: f64::NAN,
171            d5: f64::NAN,
172        };
173    }
174    if eta == f64::INFINITY {
175        return LogitJet5 {
176            mu: 1.0,
177            d1: 0.0,
178            d2: 0.0,
179            d3: 0.0,
180            d4: 0.0,
181            d5: 0.0,
182        };
183    }
184    if eta == f64::NEG_INFINITY {
185        return LogitJet5 {
186            mu: 0.0,
187            d1: 0.0,
188            d2: 0.0,
189            d3: 0.0,
190            d4: 0.0,
191            d5: 0.0,
192        };
193    }
194
195    let jet = if eta >= 0.0 {
196        let z = (-eta).exp();
197        let opz = 1.0 + z;
198        let opz2 = opz * opz;
199        let opz3 = opz2 * opz;
200        let opz4 = opz3 * opz;
201        let opz5 = opz4 * opz;
202        let opz6 = opz5 * opz;
203        let z2 = z * z;
204        let z3 = z2 * z;
205        let z4 = z3 * z;
206        LogitJet5 {
207            mu: 1.0 / opz,
208            d1: z / opz2,
209            d2: z * (z - 1.0) / opz3,
210            d3: z * (z2 - 4.0 * z + 1.0) / opz4,
211            d4: z * (z3 - 11.0 * z2 + 11.0 * z - 1.0) / opz5,
212            d5: z * (z4 - 26.0 * z3 + 66.0 * z2 - 26.0 * z + 1.0) / opz6,
213        }
214    } else {
215        let z = eta.exp();
216        let opz = 1.0 + z;
217        let opz2 = opz * opz;
218        let opz3 = opz2 * opz;
219        let opz4 = opz3 * opz;
220        let opz5 = opz4 * opz;
221        let opz6 = opz5 * opz;
222        let z2 = z * z;
223        let z3 = z2 * z;
224        let z4 = z3 * z;
225        LogitJet5 {
226            mu: z / opz,
227            d1: z / opz2,
228            d2: z * (1.0 - z) / opz3,
229            d3: z * (1.0 - 4.0 * z + z2) / opz4,
230            d4: z * (1.0 - 11.0 * z + 11.0 * z2 - z3) / opz5,
231            d5: z * (1.0 - 26.0 * z + 66.0 * z2 - 26.0 * z3 + z4) / opz6,
232        }
233    };
234    LogitJet5 {
235        mu: jet.mu,
236        d1: canonicalzero(jet.d1),
237        d2: canonicalzero(jet.d2),
238        d3: canonicalzero(jet.d3),
239        d4: canonicalzero(jet.d4),
240        d5: canonicalzero(jet.d5),
241    }
242}
243
244#[inline]
245fn probit_jet(eta: f64) -> InverseLinkJet {
246    // Exact probit semantics:
247    //
248    //   mu(eta) = Phi(eta),
249    //   mu'     = phi(eta),
250    //   mu''    = -eta * phi(eta),
251    //   mu'''   = (eta^2 - 1) * phi(eta).
252    //
253    // `normal_cdf` now evaluates the exact special-function form
254    // Phi(x) = 0.5 * erfc(-x / sqrt(2)), so the jet can and should use the
255    // matching closed-form Gaussian identities directly.
256    if eta.is_nan() {
257        return InverseLinkJet {
258            mu: f64::NAN,
259            d1: f64::NAN,
260            d2: f64::NAN,
261            d3: f64::NAN,
262        };
263    }
264    if eta == f64::INFINITY {
265        return InverseLinkJet {
266            mu: 1.0,
267            d1: 0.0,
268            d2: 0.0,
269            d3: 0.0,
270        };
271    }
272    if eta == f64::NEG_INFINITY {
273        return InverseLinkJet {
274            mu: 0.0,
275            d1: 0.0,
276            d2: 0.0,
277            d3: 0.0,
278        };
279    }
280    let x = eta;
281    let phi = normal_pdf(x);
282    if phi == 0.0 {
283        return InverseLinkJet {
284            mu: normal_cdf(x),
285            d1: 0.0,
286            d2: 0.0,
287            d3: 0.0,
288        };
289    }
290    InverseLinkJet {
291        mu: normal_cdf(x),
292        d1: phi,
293        d2: -x * phi,
294        d3: (x * x - 1.0) * phi,
295    }
296}
297
298#[inline]
299fn probit_pdfthird_derivative(eta: f64) -> f64 {
300    // Since d1 = mu' = phi(eta), this returns
301    //
302    //   d³/deta³ d1 = mu'''' = -(eta³ - 3 eta) phi(eta).
303    if eta.is_nan() {
304        return f64::NAN;
305    }
306    if !eta.is_finite() {
307        return 0.0;
308    }
309    let x = eta;
310    let phi = normal_pdf(x);
311    if phi == 0.0 {
312        return 0.0;
313    }
314    canonicalzero(-(x * x * x - 3.0 * x) * phi)
315}
316
317#[inline]
318fn probit_pdffourth_derivative(eta: f64) -> f64 {
319    // mu''''' = Phi^{(5)}(eta) = (eta^4 - 6*eta^2 + 3) * phi(eta).
320    if eta.is_nan() {
321        return f64::NAN;
322    }
323    if !eta.is_finite() {
324        return 0.0;
325    }
326    let x = eta;
327    let phi = normal_pdf(x);
328    if phi == 0.0 {
329        return 0.0;
330    }
331    canonicalzero((x * x * x * x - 6.0 * x * x + 3.0) * phi)
332}
333
334/// Multiply two 5-term truncated Taylor series (coefficients `a_k = g^(k)/k!`,
335/// `k = 0..=4`) and return the truncated product coefficients.
336#[inline]
337fn taylor5_mul(a: &[f64; 5], b: &[f64; 5]) -> [f64; 5] {
338    let mut c = [0.0_f64; 5];
339    for i in 0..5 {
340        let ai = a[i];
341        if ai == 0.0 {
342            continue;
343        }
344        for j in 0..(5 - i) {
345            c[i + j] += ai * b[j];
346        }
347    }
348    c
349}
350
351/// Reciprocal of a 5-term truncated Taylor series with nonzero constant term.
352#[inline]
353fn taylor5_inv(a: &[f64; 5]) -> [f64; 5] {
354    let mut b = [0.0_f64; 5];
355    b[0] = 1.0 / a[0];
356    for k in 1..5 {
357        let mut s = 0.0_f64;
358        for j in 1..=k {
359            s += a[j] * b[k - j];
360        }
361        b[k] = -s * b[0];
362    }
363    b
364}
365
366/// 5-jet (value + four eta-derivatives) of the GLM Fisher working weight
367/// `W(eta) = mu'(eta)^2 / V(mu(eta))` for the requested standard link, returned
368/// as `(W, W', W'', W''', W'''')`.
369///
370/// For the canonical logit link this is exactly the binomial weight
371/// `W = mu(1 - mu) = mu'`, whose eta-derivatives are the higher derivatives of
372/// the inverse-link jet (`W^(k) = mu^(k+1)`); the dispatch returns
373/// `logit_inverse_link_jet5`'s `d1..d5` byte-for-byte so the existing Firth
374/// logit path is numerically unchanged.
375///
376/// Noncanonical Bernoulli links use the same truncated Taylor-series quotient:
377/// assemble the inverse-link jet through `mu^(5)`, square the `mu'` series, and
378/// divide by the Bernoulli variance series `mu(1-mu)`. As the variance
379/// denominator saturates to zero in either tail, the weight and all derivatives
380/// saturate to zero, matching the inverse-link jet convention.
381pub(crate) fn fisher_weight_jet5(link: StandardLink, eta: f64) -> (f64, f64, f64, f64, f64) {
382    match link {
383        StandardLink::Logit => {
384            let jet = logit_inverse_link_jet5(eta);
385            (jet.d1, jet.d2, jet.d3, jet.d4, jet.d5)
386        }
387        StandardLink::Probit => probit_fisher_weight_jet5(eta),
388        StandardLink::CLogLog => component_fisher_weight_jet5(LinkComponent::CLogLog, eta),
389        StandardLink::LogLog => component_fisher_weight_jet5(LinkComponent::LogLog, eta),
390        StandardLink::Cauchit => component_fisher_weight_jet5(LinkComponent::Cauchit, eta),
391        StandardLink::Identity | StandardLink::Log => (0.0, 0.0, 0.0, 0.0, 0.0),
392    }
393}
394
395pub(crate) fn fisher_weight_jet5_for_inverse_link(
396    link: &InverseLink,
397    eta: f64,
398) -> Result<(f64, f64, f64, f64, f64), EstimationError> {
399    match link {
400        InverseLink::Standard(link) => Ok(fisher_weight_jet5(*link, eta)),
401        InverseLink::LatentCLogLog(_)
402        | InverseLink::Sas(_)
403        | InverseLink::BetaLogistic(_)
404        | InverseLink::Mixture(_) => {
405            let jet = link.jet(eta)?;
406            let d4 = inverse_link_pdfthird_derivative_for_inverse_link(link, eta)?;
407            let d5 = inverse_link_pdffourth_derivative_for_inverse_link(link, eta)?;
408            Ok(fisher_weight_jet5_from_inverse_link_derivatives(
409                jet.mu, jet.d1, jet.d2, jet.d3, d4, d5,
410            ))
411        }
412    }
413}
414
415#[inline]
416fn component_fisher_weight_jet5(component: LinkComponent, eta: f64) -> (f64, f64, f64, f64, f64) {
417    let jet = component_inverse_link_jet(component, eta);
418    let d4 = component_inverse_link_pdfthird_derivative(component, eta);
419    let d5 = component_inverse_link_pdffourth_derivative(component, eta);
420    fisher_weight_jet5_from_inverse_link_derivatives(jet.mu, jet.d1, jet.d2, jet.d3, d4, d5)
421}
422
423#[inline]
424fn fisher_weight_jet5_from_inverse_link_derivatives(
425    mu: f64,
426    d1: f64,
427    d2: f64,
428    d3: f64,
429    d4: f64,
430    d5: f64,
431) -> (f64, f64, f64, f64, f64) {
432    if [mu, d1, d2, d3, d4, d5].iter().any(|v| v.is_nan()) {
433        return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN);
434    }
435    let variance = mu * (1.0 - mu);
436    if !(variance > 0.0) || !variance.is_finite() {
437        return (0.0, 0.0, 0.0, 0.0, 0.0);
438    }
439
440    let factorial = [1.0_f64, 1.0, 2.0, 6.0, 24.0];
441    let mu_d = [mu, d1, d2, d3, d4];
442    let one_minus_mu_d = [1.0 - mu, -d1, -d2, -d3, -d4];
443    let dmu_d = [d1, d2, d3, d4, d5];
444    let mut mu_t = [0.0_f64; 5];
445    let mut one_minus_mu_t = [0.0_f64; 5];
446    let mut dmu_t = [0.0_f64; 5];
447    for k in 0..5 {
448        let inv_fact = 1.0 / factorial[k];
449        mu_t[k] = mu_d[k] * inv_fact;
450        one_minus_mu_t[k] = one_minus_mu_d[k] * inv_fact;
451        dmu_t[k] = dmu_d[k] * inv_fact;
452    }
453    let num_t = taylor5_mul(&dmu_t, &dmu_t);
454    let den_t = taylor5_mul(&mu_t, &one_minus_mu_t);
455    if !(den_t[0] > 0.0) || !den_t[0].is_finite() {
456        return (0.0, 0.0, 0.0, 0.0, 0.0);
457    }
458    let w_t = taylor5_mul(&num_t, &taylor5_inv(&den_t));
459    (
460        canonicalzero(w_t[0] * factorial[0]),
461        canonicalzero(w_t[1] * factorial[1]),
462        canonicalzero(w_t[2] * factorial[2]),
463        canonicalzero(w_t[3] * factorial[3]),
464        canonicalzero(w_t[4] * factorial[4]),
465    )
466}
467
468/// Probit Bernoulli Fisher-weight 5-jet `W = phi^2 / (Phi (1 - Phi))` and its
469/// first four eta-derivatives. See [`fisher_weight_jet5`].
470#[inline]
471fn probit_fisher_weight_jet5(eta: f64) -> (f64, f64, f64, f64, f64) {
472    if eta.is_nan() {
473        return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN);
474    }
475    if !eta.is_finite() {
476        return (0.0, 0.0, 0.0, 0.0, 0.0);
477    }
478    let x = eta;
479    let p = normal_cdf(x);
480    // Compute the complement directly via Phi(-x) rather than `1 - Phi(x)`:
481    // in the positive tail `Phi(x)` rounds to 1.0 and `1 - Phi(x)` cancels to
482    // zero, whereas `Phi(-x)` retains the accurate (tiny) tail mass.
483    let q = normal_cdf(-x);
484    let phi = normal_pdf(x);
485    // Saturated tail: the denominator Phi(1-Phi) has underflowed to zero (or
486    // would divide by zero); the working weight and all derivatives go to zero.
487    if !(p > 0.0) || !(q > 0.0) || p * q <= 0.0 {
488        return (0.0, 0.0, 0.0, 0.0, 0.0);
489    }
490    // Gaussian derivative ladder: phi^(k) for k = 0..=4 using phi' = -x phi.
491    let phi1 = -x * phi;
492    let phi2 = (x * x - 1.0) * phi;
493    let phi3 = -(x * x * x - 3.0 * x) * phi;
494    let phi4 = (x * x * x * x - 6.0 * x * x + 3.0) * phi;
495    // Derivative arrays (d^k/deta^k) for f = phi, p = Phi, q = 1 - Phi.
496    // p^(0) = Phi, p^(k>=1) = phi^(k-1); q is the negated complement.
497    let f_d = [phi, phi1, phi2, phi3, phi4];
498    let p_d = [p, phi, phi1, phi2, phi3];
499    let q_d = [q, -phi, -phi1, -phi2, -phi3];
500    // Convert derivative arrays to Taylor coefficients a_k = g^(k)/k!.
501    let factorial = [1.0_f64, 1.0, 2.0, 6.0, 24.0];
502    let mut f_t = [0.0_f64; 5];
503    let mut p_t = [0.0_f64; 5];
504    let mut q_t = [0.0_f64; 5];
505    for k in 0..5 {
506        let inv_fact = 1.0 / factorial[k];
507        f_t[k] = f_d[k] * inv_fact;
508        p_t[k] = p_d[k] * inv_fact;
509        q_t[k] = q_d[k] * inv_fact;
510    }
511    let num_t = taylor5_mul(&f_t, &f_t);
512    let den_t = taylor5_mul(&p_t, &q_t);
513    let w_t = taylor5_mul(&num_t, &taylor5_inv(&den_t));
514    // Back to derivatives W^(k) = w_t[k] * k!.
515    (
516        canonicalzero(w_t[0] * factorial[0]),
517        canonicalzero(w_t[1] * factorial[1]),
518        canonicalzero(w_t[2] * factorial[2]),
519        canonicalzero(w_t[3] * factorial[3]),
520        canonicalzero(w_t[4] * factorial[4]),
521    )
522}
523
524#[inline]
525fn chain_inverse_link_jet(base: InverseLinkJet, z1: f64, z2: f64, z3: f64) -> InverseLinkJet {
526    InverseLinkJet {
527        mu: base.mu,
528        d1: base.d1 * z1,
529        d2: base.d2 * z1 * z1 + base.d1 * z2,
530        d3: base.d3 * z1 * z1 * z1 + 3.0 * base.d2 * z1 * z2 + base.d1 * z3,
531    }
532}
533
534#[inline]
535fn component_inverse_link_pdfthird_derivative(component: LinkComponent, eta: f64) -> f64 {
536    match component {
537        LinkComponent::Probit => probit_pdfthird_derivative(eta),
538        LinkComponent::Logit => logit_inverse_link_jet5(eta).d4,
539        LinkComponent::CLogLog => {
540            // CLogLog link:
541            //   mu = 1 - exp(-t),  t = exp(eta),  d1 = t exp(-t).
542            //
543            // Repeated differentiation closes in the basis `d1 * poly(t)`:
544            //   d2 = d1(-t + 1)
545            //   d3 = d1(t² - 3t + 1)
546            //   d4 = d1(-t³ + 6t² - 7t + 1).
547            if eta.is_nan() {
548                return f64::NAN;
549            }
550            if !eta.is_finite() {
551                return 0.0;
552            }
553            let t = eta.exp();
554            canonicalzero(stable_nonnegative_poly_times_exp_neg(
555                t,
556                &[0.0, 1.0, -7.0, 6.0, -1.0],
557            ))
558        }
559        LinkComponent::LogLog => {
560            // LogLog link is the reflected cloglog family with `r = exp(-eta)`:
561            //   mu = exp(-r), d1 = mu r,
562            // and again higher derivatives are `d1 * poly(r)`:
563            //   d2 = d1(r - 1)
564            //   d3 = d1(r² - 3r + 1)
565            //   d4 = d1(r³ - 6r² + 7r - 1).
566            if eta.is_nan() {
567                return f64::NAN;
568            }
569            if !eta.is_finite() {
570                return 0.0;
571            }
572            let r = (-eta).exp();
573            canonicalzero(stable_nonnegative_poly_times_exp_neg(
574                r,
575                &[0.0, -1.0, 7.0, -6.0, 1.0],
576            ))
577        }
578        LinkComponent::Cauchit => {
579            // Cauchit link:
580            //   mu = 1/2 + atan(eta)/pi,
581            //   d1 = 1 / [pi (1+eta²)].
582            //
583            // Differentiating three more times gives
584            //
585            //   d4 = 24 eta (1-eta²) / [pi (1+eta²)^4].
586            if eta.is_nan() {
587                return f64::NAN;
588            }
589            if !eta.is_finite() {
590                return 0.0;
591            }
592            let denom = 1.0 + eta * eta;
593            24.0 * eta * (1.0 - eta * eta) / (std::f64::consts::PI * denom.powi(4))
594        }
595    }
596}
597
598/// Fifth derivative of a component inverse-link CDF (= fourth derivative of PDF).
599/// Extends `component_inverse_link_pdfthird_derivative` by one derivative order.
600#[inline]
601fn component_inverse_link_pdffourth_derivative(component: LinkComponent, eta: f64) -> f64 {
602    match component {
603        LinkComponent::Probit => probit_pdffourth_derivative(eta),
604        LinkComponent::Logit => logit_inverse_link_jet5(eta).d5,
605        LinkComponent::CLogLog => {
606            // Exact closed form:
607            //   d5 = exp(-t) * (t - 15t^2 + 25t^3 - 10t^4 + t^5)
608            //      = d1 * (1 - 15t + 25t^2 - 10t^3 + t^4),
609            // where t = exp(eta).
610            if eta.is_nan() {
611                return f64::NAN;
612            }
613            if !eta.is_finite() {
614                return 0.0;
615            }
616            let t = eta.exp();
617            canonicalzero(stable_nonnegative_poly_times_exp_neg(
618                t,
619                &[0.0, 1.0, -15.0, 25.0, -10.0, 1.0],
620            ))
621        }
622        LinkComponent::LogLog => {
623            // Exact closed form:
624            //   d5 = exp(-r) * (r - 15r^2 + 25r^3 - 10r^4 + r^5)
625            //      = d1 * (1 - 15r + 25r^2 - 10r^3 + r^4),
626            // where r = exp(-eta).
627            if eta.is_nan() {
628                return f64::NAN;
629            }
630            if !eta.is_finite() {
631                return 0.0;
632            }
633            let r = (-eta).exp();
634            canonicalzero(stable_nonnegative_poly_times_exp_neg(
635                r,
636                &[0.0, 1.0, -15.0, 25.0, -10.0, 1.0],
637            ))
638        }
639        LinkComponent::Cauchit => {
640            // d5 = 24(1 - 10eta^2 + 5eta^4) / [pi * (1+eta^2)^5]
641            if eta.is_nan() {
642                return f64::NAN;
643            }
644            if !eta.is_finite() {
645                return 0.0;
646            }
647            let e2 = eta * eta;
648            let denom = 1.0 + e2;
649            24.0 * (1.0 - 10.0 * e2 + 5.0 * e2 * e2) / (std::f64::consts::PI * denom.powi(5))
650        }
651    }
652}
653
654#[derive(Clone, Debug, PartialEq)]
655pub struct MixtureJetWithRhoPartials {
656    pub jet: InverseLinkJet,
657    /// Partial derivatives wrt free logits rho_j, j in [0, K-2].
658    /// Each entry stores derivatives of (mu, d1, d2, d3) wrt one rho_j.
659    pub djet_drho: Vec<InverseLinkJet>,
660    /// Exact symmetric Hessian of `mu` in the free-logit coordinates.
661    pub d2mu_drho2: Array2<f64>,
662    /// Exact symmetric Hessian of `d1 = dmu/deta` in the free-logit coordinates.
663    pub d2d1_drho2: Array2<f64>,
664}
665
666#[derive(Clone, Debug, PartialEq)]
667pub struct SasJetWithParamPartials {
668    pub jet: InverseLinkJet,
669    pub djet_depsilon: InverseLinkJet,
670    pub djet_dlog_delta: InverseLinkJet,
671    /// Exact symmetric Hessian of `mu` in `(epsilon, raw_log_delta)` order.
672    /// For Beta-Logistic the second coordinate is its unbounded
673    /// `log_shape_center`, matching the shared optimizer state field.
674    pub d2mu_dparams2: Array2<f64>,
675    /// Exact symmetric Hessian of `d1 = dmu/deta` in the same parameter order.
676    pub d2d1_dparams2: Array2<f64>,
677}
678
679#[derive(Clone, Debug, PartialEq)]
680pub enum LinkParamPartials {
681    Mixture(MixtureJetWithRhoPartials),
682    Sas(SasJetWithParamPartials),
683}
684
685/// Trait-based inverse-link kernel interface.
686///
687/// Implementors provide pointwise inverse-link derivatives wrt `eta`:
688/// `F(eta), F'(eta), F''(eta), F'''(eta)`.
689/// Optionally they may expose parameter partials used by outer-loop optimization.
690pub trait InverseLinkKernel {
691    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError>;
692
693    fn param_partials(&self, eta: f64) -> Result<Option<LinkParamPartials>, EstimationError> {
694        assert!(eta.is_finite(), "eta must be finite");
695        Ok(None)
696    }
697}
698
699#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
700pub struct ProbitLinkKernel;
701
702#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
703pub struct LogitLinkKernel;
704
705#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
706pub struct CLogLogLinkKernel;
707
708#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
709pub struct LogLogLinkKernel;
710
711#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
712pub struct CauchitLinkKernel;
713
714/// Construct SAS state from raw optimizer parameters using the same bounded
715/// transform used everywhere in fitting/evaluation.
716///
717/// A free function rather than an inherent `SasLinkState::new` because the
718/// bounded `delta` transform is solver-side math, so the constructor is hosted
719/// here next to the transform rather than on the type. `SasLinkState`'s fields
720/// are `pub`, so it builds directly.
721pub fn sas_link_state_from_raw(
722    raw_epsilon: f64,
723    raw_log_delta: f64,
724) -> Result<SasLinkState, String> {
725    if !raw_epsilon.is_finite() || !raw_log_delta.is_finite() {
726        return Err("SAS link parameters must be finite".to_string());
727    }
728    Ok(SasLinkState {
729        epsilon: raw_epsilon,
730        log_delta: raw_log_delta,
731        delta: sas_delta_from_raw_log_delta(raw_log_delta),
732    })
733}
734
735pub fn state_from_sasspec(spec: SasLinkSpec) -> Result<SasLinkState, String> {
736    sas_link_state_from_raw(spec.initial_epsilon, spec.initial_log_delta)
737}
738
739pub fn state_from_beta_logisticspec(spec: SasLinkSpec) -> Result<SasLinkState, String> {
740    if !spec.initial_epsilon.is_finite() || !spec.initial_log_delta.is_finite() {
741        return Err("Beta-Logistic link parameters must be finite".to_string());
742    }
743    // For Beta-Logistic, `log_delta` is the unconstrained log geometric-mean beta
744    // shape (the kernels' `log_shape_center`). Evaluation consumes `log_delta`,
745    // never `delta`, but keep the shared `SasLinkState::delta` field on the same
746    // bounded SAS parameterization used by `state_from_sasspec` so constructing a
747    // state from a large finite raw log-delta cannot overflow this derived field.
748    let log_shape_center = spec.initial_log_delta;
749    Ok(SasLinkState {
750        epsilon: spec.initial_epsilon,
751        log_delta: log_shape_center,
752        delta: sas_delta_from_raw_log_delta(log_shape_center),
753    })
754}
755
756/// Interior half-width fraction of the bounded latent map. The map is the EXACT
757/// identity on `|x| <= SPLICE_INTERIOR_FRAC * B`; the compact-support saturation
758/// splice then runs from there to `±B` at `(2 - SPLICE_INTERIOR_FRAC) * B`.
759const SPLICE_INTERIOR_FRAC: f64 = 0.8;
760
761/// Value and first five derivatives of the bounded latent map `g` at one point.
762#[derive(Clone, Copy, Debug)]
763struct SmoothBoundJet {
764    g: f64,
765    d1: f64,
766    d2: f64,
767    d3: f64,
768    d4: f64,
769    d5: f64,
770}
771
772/// Interior-exact bounded latent map for the sinh-arcsinh link, replacing the
773/// everywhere-soft `B·tanh(x/B)`.
774///
775/// `tanh` distorts *every* interior point by a relative `(x/B)²/3` — at the mild
776/// SAS point `η=−1, ε=0, δ=1` that is a `~1e-4` perturbation of the latent, which
777/// (a) breaks the `SAS(ε=0, δ=1) ≡ probit` reduction identity by `~2e-4` in `μ`
778/// and (b) leaves a spurious optimizer-visible kink where the fast probit path
779/// (`|ε|<1e-12 ∧ |δ−1|<1e-12`) meets the full composition. This map removes both:
780/// it is the exact identity on the whole interior, so the reduction is exact and
781/// the surfaces agree bitwise across `ε=0`.
782///
783/// Three regions (odd in `x`; `B = bound`, `a = 0.8B`, `c = 1.2B`):
784///
785///   |x| ≤ a:      g(x) = x            (exact identity — every g^(k≥2) is 0)
786///   a < |x| < c:  C⁵ splice of x → ±B
787///   |x| ≥ c:      g(x) = ±B           (compact support — every g^(k≥1) is 0)
788///
789/// On the splice `g'(x) = 1 − S(w)`, `w = (|x|−a)/(c−a)`, with `S` the order-4
790/// smoothstep `70w⁹−315w⁸+540w⁷−420w⁶+126w⁵` (its first four derivatives vanish
791/// at `w = 0, 1`), so `g` is C⁵ at both seams — the order the SAS jet tower needs.
792/// `S` is symmetric with `∫₀¹S = ½`, so matching `g(c)=B` fixes `c = 2B − a` with
793/// no free constant and keeps `g` non-expansive (`0 ≤ g′ ≤ 1`) and monotone.
794///
795/// Compact support (rather than an asymptotic `tanh` tail) makes the fully
796/// saturated regime *exact*: `g ≡ ±B` with every derivative identically zero, so
797/// a saturated row contributes exactly zero Fisher weight and, crucially, the
798/// `g^(k)=0` factors annihilate the `0·∞` that an overflowing `asinh(±f64::MAX)`
799/// would otherwise inject into the far-tail jet.
800#[inline]
801fn smooth_bound_jet(value: f64, bound: f64) -> SmoothBoundJet {
802    let b = bound.max(f64::EPSILON);
803    let a = SPLICE_INTERIOR_FRAC * b; // interior half-width
804    let l = 2.0 * (b - a); // splice width; c = a + l = (2 - frac) * b
805    let ax = value.abs();
806    if ax <= a {
807        // Interior: exact identity. The value carries the sign of `value`.
808        return SmoothBoundJet {
809            g: value,
810            d1: 1.0,
811            d2: 0.0,
812            d3: 0.0,
813            d4: 0.0,
814            d5: 0.0,
815        };
816    }
817    let sign = if value < 0.0 { -1.0 } else { 1.0 };
818    if ax >= a + l {
819        // Compact-support saturation: g ≡ ±B, every derivative exactly zero.
820        return SmoothBoundJet {
821            g: sign * b,
822            d1: 0.0,
823            d2: 0.0,
824            d3: 0.0,
825            d4: 0.0,
826            d5: 0.0,
827        };
828    }
829    // Splice seam. `w ∈ (0, 1)`; `S` is the order-4 smoothstep and `Sp..Spppp`
830    // its w-derivatives (all vanish at the endpoints, giving C⁵ seams).
831    let w = (ax - a) / l;
832    let w2 = w * w;
833    let w3 = w2 * w;
834    let w4 = w3 * w;
835    let w5 = w4 * w;
836    let w6 = w5 * w;
837    let w7 = w6 * w;
838    let w8 = w7 * w;
839    let w9 = w8 * w;
840    let w10 = w9 * w;
841    let s = 70.0 * w9 - 315.0 * w8 + 540.0 * w7 - 420.0 * w6 + 126.0 * w5;
842    let sp = 630.0 * w8 - 2520.0 * w7 + 3780.0 * w6 - 2520.0 * w5 + 630.0 * w4;
843    let spp = 5040.0 * w7 - 17640.0 * w6 + 22680.0 * w5 - 12600.0 * w4 + 2520.0 * w3;
844    let sppp = 35280.0 * w6 - 105840.0 * w5 + 113400.0 * w4 - 50400.0 * w3 + 7560.0 * w2;
845    let spppp = 211680.0 * w5 - 529200.0 * w4 + 453600.0 * w3 - 151200.0 * w2 + 15120.0 * w;
846    // `I(w) = ∫₀ʷ (1 − S)` is the nonneg-branch value offset above `a`.
847    let iw = w - 7.0 * w10 + 35.0 * w9 - 67.5 * w8 + 60.0 * w7 - 21.0 * w6;
848    let g0 = a + l * iw;
849    // `g'(x) = 1 − S(w)`; higher x-orders differentiate `−S(w)` through the `1/l`
850    // chain, then odd symmetry sets the parities (value/d2/d4 odd; d1/d3/d5 even).
851    let l2 = l * l;
852    SmoothBoundJet {
853        g: sign * g0,
854        d1: 1.0 - s,
855        d2: sign * (-sp / l),
856        d3: -spp / l2,
857        d4: sign * (-sppp / (l2 * l)),
858        d5: -spppp / (l2 * l2),
859    }
860}
861
862#[inline]
863fn sas_effective_log_delta(raw_log_delta: f64) -> (f64, f64) {
864    let sb = smooth_bound_jet(raw_log_delta, SAS_LOG_DELTA_BOUND);
865    (sb.g, sb.d1)
866}
867
868#[inline]
869fn sas_delta_from_raw_log_delta(raw_log_delta: f64) -> f64 {
870    let (ld_eff, _) = sas_effective_log_delta(raw_log_delta);
871    ld_eff.exp()
872}
873
874pub fn validate_mixturespec(spec: &MixtureLinkSpec) -> Result<(), String> {
875    if spec.components.is_empty() {
876        return Err("mixture link requires at least 1 component".to_string());
877    }
878    if spec.initial_rho.len() + 1 != spec.components.len() {
879        return Err(format!(
880            "mixture link rho length mismatch: expected {}, got {}",
881            spec.components.len() - 1,
882            spec.initial_rho.len()
883        ));
884    }
885    for i in 0..spec.components.len() {
886        for j in (i + 1)..spec.components.len() {
887            if spec.components[i] == spec.components[j] {
888                return Err("mixture link components must be unique".to_string());
889            }
890        }
891    }
892    // `LinkComponent` admits two variants (Cauchit, LogLog) that have no matching
893    // `LinkFunction` entry. When two or more components are *blended*, the mixture-link
894    // pipeline projects the blend back onto a single `LinkFunction` value for downstream
895    // solver/IO bookkeeping (see `InverseLink::link_function`), so a multi-component blend
896    // composed solely of components without a LinkFunction representative would silently
897    // lie about its projected link. We therefore require any genuine *blend* (two or more
898    // components) to contain at least one Logit/Probit/CLogLog "anchor" so the projection
899    // is meaningful, and reject e.g. a blend of only {Cauchit, LogLog}.
900    //
901    // A *single-component* spec is not a blend at all: it is that one link, with weight
902    // 1.0 and no free mixing logits. `LinkComponent::LogLog` / `LinkComponent::Cauchit`
903    // implement their inverse link and derivative jets exactly, so a single-component
904    // `{LogLog}` / `{Cauchit}` spec is a fully-defined standalone link and is accepted
905    // here (this is how survival `--link loglog` / `--link cauchit` are represented).
906    let has_anchor = spec.components.iter().any(|component| {
907        matches!(
908            component,
909            LinkComponent::Logit | LinkComponent::Probit | LinkComponent::CLogLog
910        )
911    });
912    if !has_anchor && spec.components.len() > 1 {
913        let unsupported: Vec<&str> = spec
914            .components
915            .iter()
916            .map(|component| component.name())
917            .collect();
918        return Err(format!(
919            "mixture link components {{{}}} are unsupported: at least one component \
920             must map to a LinkFunction variant (logit/probit/cloglog) so the mixture's \
921             projected LinkFunction is well defined; cauchit and loglog have no \
922             LinkFunction representative",
923            unsupported.join(", ")
924        ));
925    }
926    Ok(())
927}
928
929pub fn softmax_last_fixedzero(rho: &Array1<f64>) -> Array1<f64> {
930    let k = rho.len() + 1;
931    let mut logits = Vec::with_capacity(k);
932    let mut maxv = 0.0_f64;
933    for &v in rho {
934        maxv = maxv.max(v);
935        logits.push(v);
936    }
937    maxv = maxv.max(0.0);
938    logits.push(0.0);
939
940    let mut sum = 0.0_f64;
941    let mut exps = vec![0.0_f64; k];
942    for i in 0..k {
943        let e = (logits[i] - maxv).exp();
944        exps[i] = e;
945        sum += e;
946    }
947    if !sum.is_finite() || sum <= 0.0 {
948        return Array1::from_elem(k, 1.0 / k as f64);
949    }
950    let inv = 1.0 / sum;
951    Array1::from_iter(exps.into_iter().map(|v| v * inv))
952}
953
954/// Returns softmax weights and Jacobian wrt free logits (last logit fixed at zero).
955/// Jacobian shape is (K, K-1): d pi_k / d rho_j.
956pub fn softmaxwith_jacobian_last_fixedzero(
957    rho: &Array1<f64>,
958) -> (Array1<f64>, ndarray::Array2<f64>) {
959    let pi = softmax_last_fixedzero(rho);
960    let k = pi.len();
961    let m = k.saturating_sub(1);
962    let mut jac = ndarray::Array2::<f64>::zeros((k, m));
963    for j in 0..m {
964        let pi_j = pi[j];
965        for kk in 0..k {
966            let delta = if kk == j { 1.0 } else { 0.0 };
967            jac[[kk, j]] = pi[kk] * (delta - pi_j);
968        }
969    }
970    (pi, jac)
971}
972
973pub fn state_fromspec(spec: &MixtureLinkSpec) -> Result<MixtureLinkState, String> {
974    validate_mixturespec(spec)?;
975    let pi = softmax_last_fixedzero(&spec.initial_rho);
976    Ok(MixtureLinkState {
977        components: spec.components.clone(),
978        rho: spec.initial_rho.clone(),
979        pi,
980    })
981}
982
983#[inline]
984pub fn component_inverse_link_jet(component: LinkComponent, eta: f64) -> InverseLinkJet {
985    canonicalize_jet(match component {
986        LinkComponent::Logit => {
987            let jet = logit_inverse_link_jet5(eta);
988            InverseLinkJet {
989                mu: jet.mu,
990                d1: jet.d1,
991                d2: jet.d2,
992                d3: jet.d3,
993            }
994        }
995        LinkComponent::Probit => probit_jet(eta),
996        LinkComponent::CLogLog => {
997            if eta.is_nan() {
998                return InverseLinkJet {
999                    mu: f64::NAN,
1000                    d1: f64::NAN,
1001                    d2: f64::NAN,
1002                    d3: f64::NAN,
1003                };
1004            }
1005            let t = eta.exp();
1006            if !t.is_finite() {
1007                return InverseLinkJet {
1008                    mu: 1.0,
1009                    d1: 0.0,
1010                    d2: 0.0,
1011                    d3: 0.0,
1012                };
1013            }
1014            InverseLinkJet {
1015                mu: -(-t).exp_m1(),
1016                d1: stable_nonnegative_poly_times_exp_neg(t, &[0.0, 1.0]),
1017                d2: stable_nonnegative_poly_times_exp_neg(t, &[0.0, 1.0, -1.0]),
1018                d3: stable_nonnegative_poly_times_exp_neg(t, &[0.0, 1.0, -3.0, 1.0]),
1019            }
1020        }
1021        LinkComponent::LogLog => {
1022            if eta.is_nan() {
1023                return InverseLinkJet {
1024                    mu: f64::NAN,
1025                    d1: f64::NAN,
1026                    d2: f64::NAN,
1027                    d3: f64::NAN,
1028                };
1029            }
1030            let r = (-eta).exp();
1031            if !r.is_finite() {
1032                return InverseLinkJet {
1033                    mu: 0.0,
1034                    d1: 0.0,
1035                    d2: 0.0,
1036                    d3: 0.0,
1037                };
1038            }
1039            InverseLinkJet {
1040                mu: (-r).exp(),
1041                d1: stable_nonnegative_poly_times_exp_neg(r, &[0.0, 1.0]),
1042                d2: stable_nonnegative_poly_times_exp_neg(r, &[0.0, -1.0, 1.0]),
1043                d3: stable_nonnegative_poly_times_exp_neg(r, &[0.0, 1.0, -3.0, 1.0]),
1044            }
1045        }
1046        LinkComponent::Cauchit => {
1047            if eta.is_nan() {
1048                return InverseLinkJet {
1049                    mu: f64::NAN,
1050                    d1: f64::NAN,
1051                    d2: f64::NAN,
1052                    d3: f64::NAN,
1053                };
1054            }
1055            let den = 1.0 + eta * eta;
1056            let d1 = if eta.is_finite() {
1057                1.0 / (std::f64::consts::PI * den)
1058            } else {
1059                0.0
1060            };
1061            let d2 = if eta.is_finite() {
1062                -2.0 * eta / (std::f64::consts::PI * den * den)
1063            } else {
1064                0.0
1065            };
1066            let d3 = if eta.is_finite() {
1067                (6.0 * eta * eta - 2.0) / (std::f64::consts::PI * den * den * den)
1068            } else {
1069                0.0
1070            };
1071            InverseLinkJet {
1072                mu: 0.5 + eta.atan() / std::f64::consts::PI,
1073                d1,
1074                d2,
1075                d3,
1076            }
1077        }
1078    })
1079}
1080
1081impl InverseLinkKernel for ProbitLinkKernel {
1082    #[inline]
1083    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1084        Ok(component_inverse_link_jet(LinkComponent::Probit, eta))
1085    }
1086}
1087
1088impl InverseLinkKernel for LogitLinkKernel {
1089    #[inline]
1090    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1091        Ok(component_inverse_link_jet(LinkComponent::Logit, eta))
1092    }
1093}
1094
1095impl InverseLinkKernel for CLogLogLinkKernel {
1096    #[inline]
1097    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1098        Ok(component_inverse_link_jet(LinkComponent::CLogLog, eta))
1099    }
1100}
1101
1102impl InverseLinkKernel for LogLogLinkKernel {
1103    #[inline]
1104    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1105        Ok(component_inverse_link_jet(LinkComponent::LogLog, eta))
1106    }
1107}
1108
1109impl InverseLinkKernel for CauchitLinkKernel {
1110    #[inline]
1111    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1112        Ok(component_inverse_link_jet(LinkComponent::Cauchit, eta))
1113    }
1114}
1115
1116impl InverseLinkKernel for LinkComponent {
1117    #[inline]
1118    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1119        Ok(component_inverse_link_jet(*self, eta))
1120    }
1121}
1122
1123impl InverseLinkKernel for LinkFunction {
1124    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1125        match self {
1126            LinkFunction::Logit => LogitLinkKernel.jet(eta),
1127            LinkFunction::Probit => ProbitLinkKernel.jet(eta),
1128            LinkFunction::CLogLog => CLogLogLinkKernel.jet(eta),
1129            LinkFunction::LogLog => LogLogLinkKernel.jet(eta),
1130            LinkFunction::Cauchit => CauchitLinkKernel.jet(eta),
1131            LinkFunction::Identity => Ok(InverseLinkJet {
1132                mu: eta,
1133                d1: 1.0,
1134                d2: 0.0,
1135                d3: 0.0,
1136            }),
1137            LinkFunction::Log => {
1138                // A projected value with unprojected exp derivatives is not a jet:
1139                // outside the projection interval the value is constant but the
1140                // old implementation returned a nonzero derivative. Evaluate the
1141                // exact exponential on the declared solver domain and refuse every
1142                // other eta through the typed error channel. Public response-scale
1143                // transforms remain unrestricted and use their separate exact-exp
1144                // path below (issue #963).
1145                let e = log_link_solver_exp(eta)?;
1146                Ok(InverseLinkJet {
1147                    mu: e,
1148                    d1: e,
1149                    d2: e,
1150                    d3: e,
1151                })
1152            }
1153            LinkFunction::Sas => Err(EstimationError::InvalidInput(
1154                "LinkFunction::Sas inverse-link requires explicit SAS link state".to_string(),
1155            )),
1156            LinkFunction::BetaLogistic => Err(EstimationError::InvalidInput(
1157                "LinkFunction::BetaLogistic inverse-link requires explicit Beta-Logistic link state"
1158                    .to_string(),
1159            )),
1160        }
1161    }
1162}
1163
1164impl InverseLinkKernel for SasLinkState {
1165    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1166        sas_inverse_link_jet(eta, self.epsilon, self.log_delta)
1167    }
1168
1169    fn param_partials(&self, eta: f64) -> Result<Option<LinkParamPartials>, EstimationError> {
1170        Ok(Some(LinkParamPartials::Sas(
1171            sas_inverse_link_jetwith_param_partials(eta, self.epsilon, self.log_delta)?,
1172        )))
1173    }
1174}
1175
1176#[derive(Clone, Copy, Debug)]
1177pub struct BetaLogisticKernel {
1178    /// Unconstrained log of the geometric-mean beta shape — the raw optimization
1179    /// parameter `SasLinkState::log_delta`, NOT the derived `SasLinkState::delta`.
1180    pub log_shape_center: f64,
1181    pub epsilon: f64,
1182}
1183
1184impl InverseLinkKernel for BetaLogisticKernel {
1185    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1186        Ok(beta_logistic_inverse_link_jet(
1187            eta,
1188            self.log_shape_center,
1189            self.epsilon,
1190        ))
1191    }
1192
1193    fn param_partials(&self, eta: f64) -> Result<Option<LinkParamPartials>, EstimationError> {
1194        Ok(Some(LinkParamPartials::Sas(
1195            beta_logistic_inverse_link_jetwith_param_partials(
1196                eta,
1197                self.log_shape_center,
1198                self.epsilon,
1199            ),
1200        )))
1201    }
1202}
1203
1204impl InverseLinkKernel for MixtureLinkState {
1205    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1206        Ok(mixture_inverse_link_jet(self, eta))
1207    }
1208
1209    fn param_partials(&self, eta: f64) -> Result<Option<LinkParamPartials>, EstimationError> {
1210        Ok(Some(LinkParamPartials::Mixture(
1211            mixture_inverse_link_jetwith_rho_partials(self, eta),
1212        )))
1213    }
1214}
1215
1216impl InverseLinkKernel for InverseLink {
1217    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1218        match self {
1219            InverseLink::Standard(StandardLink::Logit) => LogitLinkKernel.jet(eta),
1220            InverseLink::Standard(StandardLink::Probit) => ProbitLinkKernel.jet(eta),
1221            InverseLink::Standard(StandardLink::CLogLog) => CLogLogLinkKernel.jet(eta),
1222            InverseLink::Standard(StandardLink::LogLog) => LogLogLinkKernel.jet(eta),
1223            InverseLink::Standard(StandardLink::Cauchit) => CauchitLinkKernel.jet(eta),
1224            InverseLink::Standard(StandardLink::Identity) => LinkFunction::Identity.jet(eta),
1225            InverseLink::Standard(StandardLink::Log) => LinkFunction::Log.jet(eta),
1226            InverseLink::LatentCLogLog(state) => latent_cloglog_point_jet(state, eta),
1227            InverseLink::Sas(state) => state.jet(eta),
1228            InverseLink::BetaLogistic(state) => BetaLogisticKernel {
1229                log_shape_center: state.log_delta,
1230                epsilon: state.epsilon,
1231            }
1232            .jet(eta),
1233            InverseLink::Mixture(state) => state.jet(eta),
1234        }
1235    }
1236
1237    fn param_partials(&self, eta: f64) -> Result<Option<LinkParamPartials>, EstimationError> {
1238        match self {
1239            InverseLink::Standard(_) => Ok(None),
1240            InverseLink::LatentCLogLog(_) => Ok(None),
1241            InverseLink::Sas(state) => state.param_partials(eta),
1242            InverseLink::BetaLogistic(state) => BetaLogisticKernel {
1243                log_shape_center: state.log_delta,
1244                epsilon: state.epsilon,
1245            }
1246            .param_partials(eta),
1247            InverseLink::Mixture(state) => state.param_partials(eta),
1248        }
1249    }
1250}
1251
1252/// Central family-aware inverse-link jet dispatch.
1253///
1254/// For `BinomialSas` and `BinomialMixture`, required state must be provided.
1255/// The standard log link is defined here only on the inclusive solver domain
1256/// [`LOG_LINK_SOLVER_ETA_MIN`] through [`LOG_LINK_SOLVER_ETA_MAX`]; inputs
1257/// outside it return [`EstimationError::InverseLinkDomainViolation`].
1258pub fn inverse_link_jet_for_inverse_link(
1259    link: &InverseLink,
1260    eta: f64,
1261) -> Result<InverseLinkJet, EstimationError> {
1262    link.jet(eta)
1263}
1264
1265/// Specialized `(mu, d1)` inverse-link evaluation that skips the d2/d3
1266/// polynomial chain used by the full jet. Numerical semantics are preserved:
1267/// the returned `mu` and `d1` are bit-identical to the corresponding fields of
1268/// `inverse_link_jet_for_inverse_link(link, eta)?` for every supported link.
1269///
1270/// For latent cloglog the underlying lognormal-Laplace kernel produces all
1271/// orders together, so this falls back to the full jet for that branch — the
1272/// savings come from the parameterised polynomial links (SAS, beta-logistic,
1273/// mixture) and the simple analytic links where d2/d3 are pure waste.
1274/// Standard-log inputs obey the same solver domain as the full jet.
1275pub fn inverse_link_mu_d1_for_inverse_link(
1276    link: &InverseLink,
1277    eta: f64,
1278) -> Result<(f64, f64), EstimationError> {
1279    match link {
1280        InverseLink::Standard(link_fn) => Ok(link_function_mu_d1(link_fn.as_link_function(), eta)?),
1281        InverseLink::LatentCLogLog(state) => {
1282            let jet = latent_cloglog_point_jet(state, eta)?;
1283            Ok((jet.mu, jet.d1))
1284        }
1285        InverseLink::Sas(state) => sas_inverse_link_mu_d1(eta, state.epsilon, state.log_delta),
1286        InverseLink::BetaLogistic(state) => Ok(beta_logistic_inverse_link_mu_d1(
1287            eta,
1288            state.log_delta,
1289            state.epsilon,
1290        )),
1291        InverseLink::Mixture(state) => Ok(mixture_inverse_link_mu_d1(state, eta)),
1292    }
1293}
1294
1295/// Stable complement `1 - mu(eta)` for a Bernoulli inverse link, evaluated
1296/// directly from `eta` rather than as `1.0 - mu`.
1297///
1298/// The forward `mu` rounds to exactly `1.0` in f64 far inside the tail — cloglog
1299/// at `eta ≈ 3.62`, probit at `eta ≈ 8.29` — after which the naive `1.0 - mu` is
1300/// a hard zero even though the true complement is a representable quantity down
1301/// to `~1e-300`. Both the Bernoulli variance `mu(1-mu)` and the working residual
1302/// `y - mu` depend on that complement, so recovering it exactly is what lets a
1303/// saturating cloglog/probit row proceed instead of being refused. This mirrors
1304/// the tail-complement already carried on the canonical logit path.
1305///
1306/// Each link with a cancellation-free closed form for `1 - mu` uses it; links
1307/// without one fall back to `1.0 - mu` (unchanged behaviour). The complement is
1308/// clamped into `[0, 1]` only against round-off just past the boundary.
1309pub(crate) fn inverse_link_complement_for_inverse_link(
1310    link: &InverseLink,
1311    eta: f64,
1312    mu: f64,
1313) -> f64 {
1314    let raw = match link {
1315        InverseLink::Standard(link_fn) => standard_link_complement(*link_fn, eta, mu),
1316        InverseLink::Sas(state) => sas_link_complement(eta, state.epsilon, state.log_delta, mu),
1317        // No cancellation-free closed form wired for these yet; the naive
1318        // complement leaves their saturation behaviour exactly as it was.
1319        InverseLink::LatentCLogLog(_) | InverseLink::BetaLogistic(_) | InverseLink::Mixture(_) => {
1320            1.0 - mu
1321        }
1322    };
1323    if raw.is_nan() {
1324        raw
1325    } else {
1326        raw.clamp(0.0, 1.0)
1327    }
1328}
1329
1330/// Cancellation-free `1 - mu(eta)` for the standard Bernoulli links.
1331#[inline]
1332fn standard_link_complement(link: StandardLink, eta: f64, mu: f64) -> f64 {
1333    match link {
1334        StandardLink::Probit => {
1335            // 1 - Phi(eta) = Phi(-eta); the reflected CDF keeps the tiny upper-tail
1336            // mass that `1 - Phi(eta)` cancels away.
1337            if eta.is_nan() {
1338                f64::NAN
1339            } else if eta == f64::INFINITY {
1340                0.0
1341            } else if eta == f64::NEG_INFINITY {
1342                1.0
1343            } else {
1344                normal_cdf(-eta)
1345            }
1346        }
1347        StandardLink::CLogLog => {
1348            // mu = 1 - exp(-exp(eta))  =>  1 - mu = exp(-exp(eta)).
1349            if eta.is_nan() {
1350                f64::NAN
1351            } else {
1352                let t = eta.exp();
1353                if !t.is_finite() { 0.0 } else { (-t).exp() }
1354            }
1355        }
1356        StandardLink::LogLog => {
1357            // mu = exp(-exp(-eta))  =>  1 - mu = -expm1(-exp(-eta)).
1358            if eta.is_nan() {
1359                f64::NAN
1360            } else {
1361                let r = (-eta).exp();
1362                if !r.is_finite() { 1.0 } else { -(-r).exp_m1() }
1363            }
1364        }
1365        StandardLink::Cauchit => {
1366            // mu = 1/2 + atan(eta)/pi  =>  1 - mu = 1/2 - atan(eta)/pi. For eta > 0
1367            // this cancels toward zero; atan(1/eta) = pi/2 - atan(eta) exactly, so
1368            // 1 - mu = atan(1/eta)/pi with no loss.
1369            if eta.is_nan() {
1370                f64::NAN
1371            } else if !eta.is_finite() {
1372                if eta > 0.0 { 0.0 } else { 1.0 }
1373            } else if eta > 0.0 {
1374                (1.0 / eta).atan() / std::f64::consts::PI
1375            } else {
1376                0.5 - eta.atan() / std::f64::consts::PI
1377            }
1378        }
1379        // Logit carries its own tail complement on the canonical path; identity
1380        // and log are not Bernoulli-variance links. The naive complement is
1381        // exact enough for these here.
1382        StandardLink::Logit | StandardLink::Identity | StandardLink::Log => 1.0 - mu,
1383    }
1384}
1385
1386/// Cancellation-free `1 - mu` for the SAS inverse link. `mu = Phi(z)` with
1387/// `z = sinh(smooth_bound(delta*asinh(eta) + epsilon, SAS_U_CLAMP))`, so the exact
1388/// complement is `Phi(-z)`, mirroring the `sas_inverse_link_mu_d1` forward map.
1389/// `Phi(-z)` keeps the tiny upper-tail mass that `1 - Phi(z)` cancels; it
1390/// underflows to `0` only once the row is genuinely fully saturated (`z` at the
1391/// `SAS_U_CLAMP` sinh scale), which is the correct value there. `Phi(-z)` is
1392/// always in `[0, 1]`, so no clamp is needed.
1393///
1394/// The latent `asinh(eta)` is taken through the overflow-free [`asinh_jet5`]
1395/// value — exactly as `sas_inverse_link_mu_d1` does — NOT the raw `f64::asinh`.
1396/// The library `asinh` forms `x·x` internally, which overflows to `±∞` for
1397/// `|eta| > 1.34e154` even though `asinh(±f64::MAX) ≈ ±710` is finite and well
1398/// inside range. With a compressing `delta < 1` the true latent `delta·asinh`
1399/// then stays in the map's identity interior (finite, unsaturated `mu`), so an
1400/// overflowing complement would saturate to `±B` and return `Phi(∓sinh B) ∈
1401/// {0,1}` — disagreeing with the forward `1 - mu` by up to ~0.15 and poisoning
1402/// any `log(1 - mu)` tail term at extreme eta (#2389).
1403#[inline]
1404pub(crate) fn sas_link_complement(eta: f64, epsilon: f64, log_delta: f64, mu: f64) -> f64 {
1405    let eta = match finite_inverse_link_eta("SAS inverse link complement", eta) {
1406        Ok(value) => value,
1407        Err(_) => return 1.0 - mu,
1408    };
1409    let delta = sas_delta_from_raw_log_delta(log_delta);
1410    if epsilon.abs() < 1e-12 && (delta - 1.0).abs() < 1e-12 {
1411        return standard_link_complement(StandardLink::Probit, eta, mu);
1412    }
1413    let u_raw = delta * asinh_jet5(eta).value + epsilon;
1414    let u = smooth_bound_jet(u_raw, SAS_U_CLAMP).g;
1415    normal_cdf(-u.sinh())
1416}
1417
1418fn link_function_mu_d1(link: LinkFunction, eta: f64) -> Result<(f64, f64), EstimationError> {
1419    match link {
1420        LinkFunction::Identity => Ok((eta, 1.0)),
1421        LinkFunction::Log => {
1422            // Keep the fast seam mathematically identical to the full jet: exact
1423            // exp and exact exp derivative on the same declared solver domain.
1424            let e = log_link_solver_exp(eta)?;
1425            Ok((e, e))
1426        }
1427        LinkFunction::Logit => Ok(component_inverse_link_mu_d1(LinkComponent::Logit, eta)),
1428        LinkFunction::Probit => Ok(component_inverse_link_mu_d1(LinkComponent::Probit, eta)),
1429        LinkFunction::CLogLog => Ok(component_inverse_link_mu_d1(LinkComponent::CLogLog, eta)),
1430        LinkFunction::LogLog => Ok(component_inverse_link_mu_d1(LinkComponent::LogLog, eta)),
1431        LinkFunction::Cauchit => Ok(component_inverse_link_mu_d1(LinkComponent::Cauchit, eta)),
1432        LinkFunction::Sas => Err(EstimationError::InvalidInput(
1433            "LinkFunction::Sas inverse-link requires explicit SAS link state".to_string(),
1434        )),
1435        LinkFunction::BetaLogistic => Err(EstimationError::InvalidInput(
1436            "LinkFunction::BetaLogistic inverse-link requires explicit Beta-Logistic link state"
1437                .to_string(),
1438        )),
1439    }
1440}
1441
1442#[inline]
1443fn component_inverse_link_mu_d1(component: LinkComponent, eta: f64) -> (f64, f64) {
1444    // The full per-component jet already factors `mu` and `d1` exactly the same
1445    // way the higher orders are derived, so we either reuse the cheap closed
1446    // forms directly (Logit/Probit/CLogLog/LogLog/Cauchit) or fall back to the
1447    // existing canonicalised jet for the few cases without a separate fast
1448    // path — bit-identical to `component_inverse_link_jet(...).{mu,d1}`.
1449    match component {
1450        LinkComponent::Logit => {
1451            let jet = logit_inverse_link_jet5(eta);
1452            (jet.mu, canonicalzero(jet.d1))
1453        }
1454        LinkComponent::Probit => {
1455            if eta.is_nan() {
1456                return (f64::NAN, f64::NAN);
1457            }
1458            if eta == f64::INFINITY {
1459                return (1.0, 0.0);
1460            }
1461            if eta == f64::NEG_INFINITY {
1462                return (0.0, 0.0);
1463            }
1464            let phi = normal_pdf(eta);
1465            (normal_cdf(eta), canonicalzero(phi))
1466        }
1467        LinkComponent::CLogLog => {
1468            if eta.is_nan() {
1469                return (f64::NAN, f64::NAN);
1470            }
1471            let t = eta.exp();
1472            if !t.is_finite() {
1473                return (1.0, 0.0);
1474            }
1475            (
1476                -(-t).exp_m1(),
1477                canonicalzero(stable_nonnegative_poly_times_exp_neg(t, &[0.0, 1.0])),
1478            )
1479        }
1480        LinkComponent::LogLog => {
1481            if eta.is_nan() {
1482                return (f64::NAN, f64::NAN);
1483            }
1484            let r = (-eta).exp();
1485            if !r.is_finite() {
1486                return (0.0, 0.0);
1487            }
1488            (
1489                (-r).exp(),
1490                canonicalzero(stable_nonnegative_poly_times_exp_neg(r, &[0.0, 1.0])),
1491            )
1492        }
1493        LinkComponent::Cauchit => {
1494            if eta.is_nan() {
1495                return (f64::NAN, f64::NAN);
1496            }
1497            let den = 1.0 + eta * eta;
1498            let d1 = if eta.is_finite() {
1499                1.0 / (std::f64::consts::PI * den)
1500            } else {
1501                0.0
1502            };
1503            (0.5 + eta.atan() / std::f64::consts::PI, canonicalzero(d1))
1504        }
1505    }
1506}
1507
1508fn sas_inverse_link_mu_d1(
1509    eta: f64,
1510    epsilon: f64,
1511    log_delta: f64,
1512) -> Result<(f64, f64), EstimationError> {
1513    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
1514    let delta_id = sas_delta_from_raw_log_delta(log_delta);
1515    if epsilon.abs() < 1e-12 && (delta_id - 1.0).abs() < 1e-12 {
1516        return Ok(component_inverse_link_mu_d1(LinkComponent::Probit, eta));
1517    }
1518    let asinh = asinh_jet5(eta);
1519    let delta = delta_id;
1520    let u_raw = delta * asinh.value + epsilon;
1521    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
1522    let u = sb.g;
1523    let g1 = sb.d1;
1524    let s = u.sinh();
1525    let c = u.cosh();
1526    let z = s;
1527    let r1 = delta * asinh.d1;
1528    let u1 = g1 * r1;
1529    let z1 = c * u1;
1530    // `mu = Phi(z)` and `d1 = phi(z) * z1`, the same closed forms used by the
1531    // full jet via `chain_inverse_link_jet(probit_jet(z), z1, _, _)`.
1532    let base = probit_jet(z);
1533    Ok((base.mu, canonicalzero(base.d1 * z1)))
1534}
1535
1536fn beta_logistic_inverse_link_mu_d1(eta: f64, delta: f64, epsilon: f64) -> (f64, f64) {
1537    let logistic = logistic_uwith_derivatives(eta);
1538    let a = (delta - epsilon).exp();
1539    let b = (delta + epsilon).exp();
1540    let mu = beta_reg_logistic(a, b, logistic);
1541    let log_d1 = beta_logistic_log_d1(a, b, logistic);
1542    (mu, log_d1.exp())
1543}
1544
1545fn mixture_inverse_link_mu_d1(state: &MixtureLinkState, eta: f64) -> (f64, f64) {
1546    let mut mu = 0.0_f64;
1547    let mut d1 = 0.0_f64;
1548    let k = state.components.len().min(state.pi.len());
1549    for i in 0..k {
1550        let (mu_i, d1_i) = component_inverse_link_mu_d1(state.components[i], eta);
1551        let w = state.pi[i];
1552        mu += w * mu_i;
1553        d1 += w * d1_i;
1554    }
1555    (mu, d1)
1556}
1557
1558#[derive(Clone, Copy)]
1559enum PdfDerivativeOrder {
1560    Third,
1561    Fourth,
1562}
1563
1564impl PdfDerivativeOrder {
1565    fn probit(self, eta: f64) -> f64 {
1566        match self {
1567            Self::Third => probit_pdfthird_derivative(eta),
1568            Self::Fourth => probit_pdffourth_derivative(eta),
1569        }
1570    }
1571
1572    fn component(self, component: LinkComponent, eta: f64) -> f64 {
1573        match self {
1574            Self::Third => component_inverse_link_pdfthird_derivative(component, eta),
1575            Self::Fourth => component_inverse_link_pdffourth_derivative(component, eta),
1576        }
1577    }
1578
1579    fn latent_cloglog(self, eta: f64, latent_sd: f64) -> Result<f64, EstimationError> {
1580        let jet = latent_cloglog_jet5(latent_cloglog_quadctx(), eta, latent_sd)?;
1581        Ok(match self {
1582            Self::Third => jet.d4,
1583            Self::Fourth => jet.d5,
1584        })
1585    }
1586
1587    fn sas(self, eta: f64, epsilon: f64, log_delta: f64) -> Result<f64, EstimationError> {
1588        match self {
1589            Self::Third => sas_inverse_link_pdfthird_derivative(eta, epsilon, log_delta),
1590            Self::Fourth => sas_inverse_link_pdffourth_derivative(eta, epsilon, log_delta),
1591        }
1592    }
1593
1594    fn beta_logistic(self, eta: f64, log_shape_center: f64, epsilon: f64) -> f64 {
1595        match self {
1596            Self::Third => {
1597                beta_logistic_inverse_link_pdfthird_derivative(eta, log_shape_center, epsilon)
1598            }
1599            Self::Fourth => {
1600                beta_logistic_inverse_link_pdffourth_derivative(eta, log_shape_center, epsilon)
1601            }
1602        }
1603    }
1604}
1605
1606fn inverse_link_pdf_derivative_for_inverse_link(
1607    link: &InverseLink,
1608    eta: f64,
1609    order: PdfDerivativeOrder,
1610) -> Result<f64, EstimationError> {
1611    match link {
1612        InverseLink::Standard(StandardLink::Identity) => Ok(0.0),
1613        InverseLink::Standard(StandardLink::Log) => log_link_solver_exp(eta),
1614        InverseLink::Standard(StandardLink::Probit) => Ok(order.probit(eta)),
1615        InverseLink::Standard(StandardLink::Logit) => {
1616            Ok(order.component(LinkComponent::Logit, eta))
1617        }
1618        InverseLink::Standard(StandardLink::CLogLog) => {
1619            Ok(order.component(LinkComponent::CLogLog, eta))
1620        }
1621        InverseLink::Standard(StandardLink::LogLog) => {
1622            Ok(order.component(LinkComponent::LogLog, eta))
1623        }
1624        InverseLink::Standard(StandardLink::Cauchit) => {
1625            Ok(order.component(LinkComponent::Cauchit, eta))
1626        }
1627        InverseLink::LatentCLogLog(state) => order.latent_cloglog(eta, state.latent_sd),
1628        InverseLink::Sas(state) => order.sas(eta, state.epsilon, state.log_delta),
1629        InverseLink::BetaLogistic(state) => {
1630            Ok(order.beta_logistic(eta, state.log_delta, state.epsilon))
1631        }
1632        InverseLink::Mixture(state) => Ok(state
1633            .components
1634            .iter()
1635            .zip(state.pi.iter())
1636            .map(|(&component, &weight)| weight * order.component(component, eta))
1637            .sum()),
1638    }
1639}
1640
1641pub fn inverse_link_pdfthird_derivative_for_inverse_link(
1642    link: &InverseLink,
1643    eta: f64,
1644) -> Result<f64, EstimationError> {
1645    // This dispatch returns the fourth eta-derivative of the inverse-link CDF,
1646    // equivalently the third derivative of the inverse-link density
1647    //
1648    //   f(eta) = d/deta mu(eta).
1649    //
1650    // It is used downstream as the `f'''` input in
1651    //
1652    //   d³/deta³ log f = f'''/f - 3 f'f''/f² + 2(f')³/f³.
1653    //
1654    // Mixture links preserve linearity:
1655    //
1656    //   mu = sum_j pi_j mu_j
1657    //   => f''' = sum_j pi_j f_j'''
1658    //
1659    // because the mixture weights `pi_j` are constant with respect to `eta`.
1660    // Standard-log inputs outside the declared solver domain return the same
1661    // typed refusal as the lower-order jet seams.
1662    inverse_link_pdf_derivative_for_inverse_link(link, eta, PdfDerivativeOrder::Third)
1663}
1664
1665/// Fifth derivative of the inverse-link CDF (= fourth derivative of the PDF).
1666///
1667/// Extends `inverse_link_pdfthird_derivative_for_inverse_link` by one order.
1668/// Used for the outer REML Hessian Q[v_k, v_l] term in survival models,
1669/// specifically the `m1 * u_{abcd}` Arbogast contribution.
1670/// Standard-log inputs obey the same solver domain as every lower-order seam.
1671pub fn inverse_link_pdffourth_derivative_for_inverse_link(
1672    link: &InverseLink,
1673    eta: f64,
1674) -> Result<f64, EstimationError> {
1675    inverse_link_pdf_derivative_for_inverse_link(link, eta, PdfDerivativeOrder::Fourth)
1676}
1677
1678#[inline]
1679/// Exact Royston-Parmar survival jet `S(eta) = exp(-exp(eta))` for every finite
1680/// `f64` eta. Scaled polynomial tails preserve representable derivatives after
1681/// the survival value itself underflows; non-finite eta is a typed refusal.
1682fn royston_parmar_inverse_link_jet(eta: f64) -> Result<InverseLinkJet, EstimationError> {
1683    let eta = finite_inverse_link_eta("Royston-Parmar survival inverse link", eta)?;
1684    let hazard = eta.exp();
1685    let survival = (-hazard).exp();
1686    // For S(eta) = exp(-h), h = exp(eta), each derivative is a polynomial in
1687    // nonnegative h times exp(-h). Evaluate that product in its scaled form so
1688    // neither h^k nor h itself can create an inf*0 tail. If exp(eta) overflows,
1689    // the helper returns the exact asymptotic derivative limit 0.
1690    let d1 = -stable_nonnegative_poly_times_exp_neg(hazard, &[0.0, 1.0]);
1691    let d2 = stable_nonnegative_poly_times_exp_neg(hazard, &[0.0, -1.0, 1.0]);
1692    let d3 = stable_nonnegative_poly_times_exp_neg(hazard, &[0.0, -1.0, 3.0, -1.0]);
1693    Ok(InverseLinkJet {
1694        mu: survival,
1695        d1: canonicalzero(d1),
1696        d2: canonicalzero(d2),
1697        d3: canonicalzero(d3),
1698    })
1699}
1700
1701pub fn inverse_link_jet_for_family(
1702    spec: &LikelihoodSpec,
1703    eta: f64,
1704) -> Result<InverseLinkJet, EstimationError> {
1705    // RoystonParmar uses its own analytic survival inverse link irrespective of
1706    // the (nominal `Identity`) link slot carried in the spec.
1707    if matches!(spec.response, ResponseFamily::RoystonParmar) {
1708        return royston_parmar_inverse_link_jet(eta);
1709    }
1710    spec.link.jet(eta)
1711}
1712
1713/// Exact-public log inverse-link jet: `mu = d1 = d2 = d3 = exp(η)` with no
1714/// solver-domain restriction. The solver-internal sibling evaluates the same
1715/// exact expression only on [`LOG_LINK_SOLVER_ETA_MIN`] through
1716/// [`LOG_LINK_SOLVER_ETA_MAX`] and returns a typed refusal outside it; see issue
1717/// #963. Every derivative of `exp` is `exp`, so all four jet slots carry the
1718/// same value — finite wherever representable, `0.0` on underflow, and `+∞` on
1719/// overflow.
1720#[inline]
1721fn log_inverse_link_jet_exact(eta: f64) -> InverseLinkJet {
1722    let e = eta.exp();
1723    InverseLinkJet {
1724        mu: e,
1725        d1: e,
1726        d2: e,
1727        d3: e,
1728    }
1729}
1730
1731/// EXACT public inverse-link jet for response-scale prediction outputs.
1732///
1733/// Identical to [`inverse_link_jet_for_family`] for every link EXCEPT the
1734/// standard `Log` link, where it accepts every IEEE input while the shared
1735/// solver derivative seam accepts only its declared domain. For example,
1736/// `eta = 705` remains a valid public prediction (`exp(705) ≈ 1.5e306`) but is
1737/// a typed solver-domain refusal. Public predictions
1738/// (`FamilyStrategy::inverse_link_jet`/`inverse_link_array`, the predict mean +
1739/// delta-method SE path) therefore route here. Within the inclusive solver
1740/// domain the two paths are byte-identical because both evaluate bare
1741/// `exp(eta)` (issue #963).
1742pub fn inverse_link_jet_for_family_public(
1743    spec: &LikelihoodSpec,
1744    eta: f64,
1745) -> Result<InverseLinkJet, EstimationError> {
1746    if matches!(spec.response, ResponseFamily::RoystonParmar) {
1747        return royston_parmar_inverse_link_jet(eta);
1748    }
1749    if let InverseLink::Standard(StandardLink::Log) = spec.link {
1750        return Ok(log_inverse_link_jet_exact(eta));
1751    }
1752    spec.link.jet(eta)
1753}
1754
1755#[inline]
1756pub fn mixture_inverse_link_jet(state: &MixtureLinkState, eta: f64) -> InverseLinkJet {
1757    let mut mu = 0.0_f64;
1758    let mut d1 = 0.0_f64;
1759    let mut d2 = 0.0_f64;
1760    let mut d3 = 0.0_f64;
1761    let k = state.components.len().min(state.pi.len());
1762    for i in 0..k {
1763        let jet = component_inverse_link_jet(state.components[i], eta);
1764        let w = state.pi[i];
1765        mu += w * jet.mu;
1766        d1 += w * jet.d1;
1767        d2 += w * jet.d2;
1768        d3 += w * jet.d3;
1769    }
1770    InverseLinkJet { mu, d1, d2, d3 }
1771}
1772
1773/// Computes mixture jet and exact partial derivatives wrt free softmax logits.
1774///
1775/// Uses identities:
1776///   d mu     / d rho_j = pi_j (mu_j     - mu)
1777///   d mu'    / d rho_j = pi_j (mu_j'    - mu')
1778///   d mu''   / d rho_j = pi_j (mu_j''   - mu'')
1779///   d mu'''  / d rho_j = pi_j (mu_j'''  - mu''')
1780pub fn mixture_inverse_link_jetwith_rho_partials(
1781    state: &MixtureLinkState,
1782    eta: f64,
1783) -> MixtureJetWithRhoPartials {
1784    let k = state.components.len().min(state.pi.len());
1785    let m = k.saturating_sub(1);
1786    let mut djet_drho = vec![
1787        InverseLinkJet {
1788            mu: 0.0,
1789            d1: 0.0,
1790            d2: 0.0,
1791            d3: 0.0,
1792        };
1793        m
1794    ];
1795    let jet = mixture_inverse_link_jetwith_rho_partials_into(state, eta, &mut djet_drho);
1796    // If `g_j = pi_j (f_j - f_mix)`, differentiating once more gives
1797    //
1798    //   H_jk = (1[j=k] - pi_k) g_j - pi_j g_k.
1799    //
1800    // This form reuses the first derivatives already in `djet_drho`, avoids
1801    // dividing by a possibly tiny mixture weight, and is algebraically
1802    // symmetric even though floating-point evaluation visits `(j,k)` in one
1803    // direction. Fill one triangle and mirror it bit-for-bit so downstream PSD
1804    // certification receives an exactly symmetric matrix.
1805    let mut d2mu_drho2 = Array2::<f64>::zeros((m, m));
1806    let mut d2d1_drho2 = Array2::<f64>::zeros((m, m));
1807    for j in 0..m {
1808        for k in j..m {
1809            let diagonal = if j == k { 1.0 } else { 0.0 };
1810            let mu = (diagonal - state.pi[k]) * djet_drho[j].mu
1811                - state.pi[j] * djet_drho[k].mu;
1812            let d1 = (diagonal - state.pi[k]) * djet_drho[j].d1
1813                - state.pi[j] * djet_drho[k].d1;
1814            d2mu_drho2[[j, k]] = mu;
1815            d2mu_drho2[[k, j]] = mu;
1816            d2d1_drho2[[j, k]] = d1;
1817            d2d1_drho2[[k, j]] = d1;
1818        }
1819    }
1820    MixtureJetWithRhoPartials {
1821        jet,
1822        djet_drho,
1823        d2mu_drho2,
1824        d2d1_drho2,
1825    }
1826}
1827
1828/// Computes mixture jet and writes exact rho partial jets into `out` (length >= K-1).
1829/// This avoids heap allocation in hot loops.
1830pub fn mixture_inverse_link_jetwith_rho_partials_into(
1831    state: &MixtureLinkState,
1832    eta: f64,
1833    out: &mut [InverseLinkJet],
1834) -> InverseLinkJet {
1835    let k = state.components.len().min(state.pi.len());
1836    let m = k.saturating_sub(1);
1837    assert!(
1838        out.len() >= m,
1839        "rho-partial output buffer too small: got {}, need {}",
1840        out.len(),
1841        m
1842    );
1843    let mut mixed = InverseLinkJet {
1844        mu: 0.0,
1845        d1: 0.0,
1846        d2: 0.0,
1847        d3: 0.0,
1848    };
1849    for i in 0..k {
1850        let jet_i = component_inverse_link_jet(state.components[i], eta);
1851        let w = state.pi[i];
1852        mixed.mu += w * jet_i.mu;
1853        mixed.d1 += w * jet_i.d1;
1854        mixed.d2 += w * jet_i.d2;
1855        mixed.d3 += w * jet_i.d3;
1856        // Cache the first K-1 component jets directly in the output buffer so
1857        // we don't recompute them in the partial loop.
1858        if i < m {
1859            out[i] = jet_i;
1860        }
1861    }
1862    for j in 0..m {
1863        let pi_j = state.pi[j];
1864        let cj = out[j];
1865        out[j] = InverseLinkJet {
1866            mu: pi_j * (cj.mu - mixed.mu),
1867            d1: pi_j * (cj.d1 - mixed.d1),
1868            d2: pi_j * (cj.d2 - mixed.d2),
1869            d3: pi_j * (cj.d3 - mixed.d3),
1870        };
1871    }
1872    mixed
1873}
1874
1875#[derive(Clone, Copy)]
1876struct LogisticU {
1877    u: f64,
1878    one_minus_u: f64,
1879    ln_u: f64,
1880    ln_one_minus_u: f64,
1881    du: f64,
1882    use_upper_tail: bool,
1883}
1884
1885#[inline]
1886fn logistic_uwith_derivatives(eta: f64) -> LogisticU {
1887    let ln_u = -gam_linalg::utils::stable_softplus(-eta);
1888    let ln_one_minus_u = -gam_linalg::utils::stable_softplus(eta);
1889    let u = ln_u.exp();
1890    let one_minus_u = ln_one_minus_u.exp();
1891    let du = (ln_u + ln_one_minus_u).exp();
1892    LogisticU {
1893        u,
1894        one_minus_u,
1895        ln_u,
1896        ln_one_minus_u,
1897        du,
1898        use_upper_tail: eta >= 0.0,
1899    }
1900}
1901
1902#[inline]
1903fn beta_reg_logistic(a: f64, b: f64, logistic: LogisticU) -> f64 {
1904    if logistic.ln_u.is_nan() || logistic.ln_one_minus_u.is_nan() {
1905        return f64::NAN;
1906    }
1907    if logistic.ln_u == f64::NEG_INFINITY {
1908        return 0.0;
1909    }
1910    if logistic.ln_one_minus_u == f64::NEG_INFINITY {
1911        return 1.0;
1912    }
1913    if logistic.use_upper_tail {
1914        1.0 - beta_reg(b, a, logistic.one_minus_u)
1915    } else {
1916        beta_reg(a, b, logistic.u)
1917    }
1918}
1919
1920#[derive(Clone, Copy)]
1921struct BetaShapePartials {
1922    value: f64,
1923    da: f64,
1924    db: f64,
1925    daa: f64,
1926    dab: f64,
1927    dbb: f64,
1928}
1929
1930impl BetaShapePartials {
1931    #[inline]
1932    fn constant(value: f64) -> Self {
1933        Self {
1934            value,
1935            da: 0.0,
1936            db: 0.0,
1937            daa: 0.0,
1938            dab: 0.0,
1939            dbb: 0.0,
1940        }
1941    }
1942}
1943
1944#[inline]
1945fn beta_reg_with_shape_partials_logistic(
1946    a: f64,
1947    b: f64,
1948    logistic: LogisticU,
1949) -> BetaShapePartials {
1950    if logistic.ln_u.is_nan() || logistic.ln_one_minus_u.is_nan() {
1951        return BetaShapePartials {
1952            value: f64::NAN,
1953            da: f64::NAN,
1954            db: f64::NAN,
1955            daa: f64::NAN,
1956            dab: f64::NAN,
1957            dbb: f64::NAN,
1958        };
1959    }
1960    if logistic.use_upper_tail {
1961        let tail = beta_reg_with_shape_partials(b, a, logistic.one_minus_u);
1962        BetaShapePartials {
1963            value: 1.0 - tail.value,
1964            da: -tail.db,
1965            db: -tail.da,
1966            daa: -tail.dbb,
1967            dab: -tail.dab,
1968            dbb: -tail.daa,
1969        }
1970    } else {
1971        beta_reg_with_shape_partials(a, b, logistic.u)
1972    }
1973}
1974
1975#[inline]
1976fn beta_logistic_log_d1(a: f64, b: f64, logistic: LogisticU) -> f64 {
1977    a * logistic.ln_u + b * logistic.ln_one_minus_u - ln_beta(a, b)
1978}
1979
1980#[derive(Clone, Copy)]
1981struct ShapeDual {
1982    v: f64,
1983    da: f64,
1984    db: f64,
1985    daa: f64,
1986    dab: f64,
1987    dbb: f64,
1988}
1989
1990impl ShapeDual {
1991    #[inline]
1992    fn constant(v: f64) -> Self {
1993        Self {
1994            v,
1995            da: 0.0,
1996            db: 0.0,
1997            daa: 0.0,
1998            dab: 0.0,
1999            dbb: 0.0,
2000        }
2001    }
2002
2003    #[inline]
2004    fn from_value_partials(v: f64, da: f64, db: f64) -> Self {
2005        Self {
2006            v,
2007            da,
2008            db,
2009            daa: 0.0,
2010            dab: 0.0,
2011            dbb: 0.0,
2012        }
2013    }
2014
2015    #[inline]
2016    fn clamp_small(self, floor: f64) -> Self {
2017        if self.v.abs() < floor {
2018            Self::constant(floor)
2019        } else {
2020            self
2021        }
2022    }
2023}
2024
2025impl std::ops::Add for ShapeDual {
2026    type Output = Self;
2027
2028    #[inline]
2029    fn add(self, rhs: Self) -> Self {
2030        Self {
2031            v: self.v + rhs.v,
2032            da: self.da + rhs.da,
2033            db: self.db + rhs.db,
2034            daa: self.daa + rhs.daa,
2035            dab: self.dab + rhs.dab,
2036            dbb: self.dbb + rhs.dbb,
2037        }
2038    }
2039}
2040
2041impl std::ops::Sub for ShapeDual {
2042    type Output = Self;
2043
2044    #[inline]
2045    fn sub(self, rhs: Self) -> Self {
2046        Self {
2047            v: self.v - rhs.v,
2048            da: self.da - rhs.da,
2049            db: self.db - rhs.db,
2050            daa: self.daa - rhs.daa,
2051            dab: self.dab - rhs.dab,
2052            dbb: self.dbb - rhs.dbb,
2053        }
2054    }
2055}
2056
2057impl std::ops::Mul for ShapeDual {
2058    type Output = Self;
2059
2060    #[inline]
2061    fn mul(self, rhs: Self) -> Self {
2062        Self {
2063            v: self.v * rhs.v,
2064            da: self.da * rhs.v + self.v * rhs.da,
2065            db: self.db * rhs.v + self.v * rhs.db,
2066            daa: self.daa * rhs.v + 2.0 * self.da * rhs.da + self.v * rhs.daa,
2067            dab: self.dab * rhs.v
2068                + self.da * rhs.db
2069                + self.db * rhs.da
2070                + self.v * rhs.dab,
2071            dbb: self.dbb * rhs.v + 2.0 * self.db * rhs.db + self.v * rhs.dbb,
2072        }
2073    }
2074}
2075
2076impl std::ops::Div for ShapeDual {
2077    type Output = Self;
2078
2079    #[inline]
2080    fn div(self, rhs: Self) -> Self {
2081        let inv = 1.0 / rhs.v;
2082        let inv2 = inv * inv;
2083        let inv3 = inv2 * inv;
2084        let reciprocal = Self {
2085            v: inv,
2086            da: -rhs.da * inv2,
2087            db: -rhs.db * inv2,
2088            daa: 2.0 * rhs.da * rhs.da * inv3 - rhs.daa * inv2,
2089            dab: 2.0 * rhs.da * rhs.db * inv3 - rhs.dab * inv2,
2090            dbb: 2.0 * rhs.db * rhs.db * inv3 - rhs.dbb * inv2,
2091        };
2092        self * reciprocal
2093    }
2094}
2095
2096impl std::ops::Neg for ShapeDual {
2097    type Output = Self;
2098
2099    #[inline]
2100    fn neg(self) -> Self {
2101        ShapeDual {
2102            v: -self.v,
2103            da: -self.da,
2104            db: -self.db,
2105            daa: -self.daa,
2106            dab: -self.dab,
2107            dbb: -self.dbb,
2108        }
2109    }
2110}
2111
2112#[inline]
2113fn shape_dual(v: f64) -> ShapeDual {
2114    ShapeDual::constant(v)
2115}
2116
2117// Analytic shape partials for I_x(a,b), obtained by differentiating the same
2118// regularized-beta continued fraction used by statrs. The normalizing term uses
2119// d log B(a,b) / da = psi(a) - psi(a+b) and likewise for b.
2120fn beta_reg_with_shape_partials(a0: f64, b0: f64, x0: f64) -> BetaShapePartials {
2121    if x0 <= 0.0 {
2122        return BetaShapePartials::constant(0.0);
2123    }
2124    if x0 >= 1.0 {
2125        return BetaShapePartials::constant(1.0);
2126    }
2127
2128    let symm_transform = x0 >= (a0 + 1.0) / (a0 + b0 + 2.0);
2129    let (a, b, x) = if symm_transform {
2130        (
2131            ShapeDual::from_value_partials(b0, 0.0, 1.0),
2132            ShapeDual::from_value_partials(a0, 1.0, 0.0),
2133            1.0 - x0,
2134        )
2135    } else {
2136        (
2137            ShapeDual::from_value_partials(a0, 1.0, 0.0),
2138            ShapeDual::from_value_partials(b0, 0.0, 1.0),
2139            x0,
2140        )
2141    };
2142
2143    let ln_x = x.ln();
2144    let ln_1mx = (1.0 - x).ln();
2145    let psi_ab = digamma(a.v + b.v);
2146    let log_bt = statrs::function::gamma::ln_gamma(a.v + b.v)
2147        - statrs::function::gamma::ln_gamma(a.v)
2148        - statrs::function::gamma::ln_gamma(b.v)
2149        + a.v * ln_x
2150        + b.v * ln_1mx;
2151    let bt_v = log_bt.exp();
2152    let log_bt_a = psi_ab - digamma(a.v) + ln_x;
2153    let log_bt_b = psi_ab - digamma(b.v) + ln_1mx;
2154    let trigamma_ab = trigamma(a.v + b.v);
2155    let log_bt_aa = trigamma_ab - trigamma(a.v);
2156    let log_bt_ab = trigamma_ab;
2157    let log_bt_bb = trigamma_ab - trigamma(b.v);
2158    let log_bt_da = log_bt_a * a.da + log_bt_b * b.da;
2159    let log_bt_db = log_bt_a * a.db + log_bt_b * b.db;
2160    let log_bt_daa = log_bt_aa * a.da * a.da
2161        + 2.0 * log_bt_ab * a.da * b.da
2162        + log_bt_bb * b.da * b.da
2163        + log_bt_a * a.daa
2164        + log_bt_b * b.daa;
2165    let log_bt_dab = log_bt_aa * a.da * a.db
2166        + log_bt_ab * (a.da * b.db + b.da * a.db)
2167        + log_bt_bb * b.da * b.db
2168        + log_bt_a * a.dab
2169        + log_bt_b * b.dab;
2170    let log_bt_dbb = log_bt_aa * a.db * a.db
2171        + 2.0 * log_bt_ab * a.db * b.db
2172        + log_bt_bb * b.db * b.db
2173        + log_bt_a * a.dbb
2174        + log_bt_b * b.dbb;
2175    let bt = ShapeDual {
2176        v: bt_v,
2177        da: bt_v * log_bt_da,
2178        db: bt_v * log_bt_db,
2179        daa: bt_v * (log_bt_da * log_bt_da + log_bt_daa),
2180        dab: bt_v * (log_bt_da * log_bt_db + log_bt_dab),
2181        dbb: bt_v * (log_bt_db * log_bt_db + log_bt_dbb),
2182    };
2183
2184    let eps = 0.00000000000000011102230246251565;
2185    let fpmin = f64::MIN_POSITIVE / eps;
2186    let one = shape_dual(1.0);
2187    let qab = a + b;
2188    let qap = a + one;
2189    let qam = a - one;
2190    let mut c = one;
2191    let mut d = (one - qab * shape_dual(x) / qap).clamp_small(fpmin);
2192    d = one / d;
2193    let mut h = d;
2194
2195    for m in 1..141 {
2196        let mf = f64::from(m);
2197        let m2 = mf * 2.0;
2198        let md = shape_dual(mf);
2199        let m2d = shape_dual(m2);
2200        let mut aa = md * (b - md) * shape_dual(x) / ((qam + m2d) * (a + m2d));
2201        d = (one + aa * d).clamp_small(fpmin);
2202        c = (one + aa / c).clamp_small(fpmin);
2203        d = one / d;
2204        h = h * d * c;
2205
2206        aa = (a + md).neg() * (qab + md) * shape_dual(x) / ((a + m2d) * (qap + m2d));
2207        d = (one + aa * d).clamp_small(fpmin);
2208        c = (one + aa / c).clamp_small(fpmin);
2209        d = one / d;
2210        let del = d * c;
2211        h = h * del;
2212
2213        if (del.v - 1.0).abs() <= eps {
2214            let reg = bt * h / a;
2215            return if symm_transform {
2216                BetaShapePartials {
2217                    value: 1.0 - reg.v,
2218                    da: -reg.da,
2219                    db: -reg.db,
2220                    daa: -reg.daa,
2221                    dab: -reg.dab,
2222                    dbb: -reg.dbb,
2223                }
2224            } else {
2225                BetaShapePartials {
2226                    value: reg.v,
2227                    da: reg.da,
2228                    db: reg.db,
2229                    daa: reg.daa,
2230                    dab: reg.dab,
2231                    dbb: reg.dbb,
2232                }
2233            };
2234        }
2235    }
2236    let reg = bt * h / a;
2237    if symm_transform {
2238        BetaShapePartials {
2239            value: 1.0 - reg.v,
2240            da: -reg.da,
2241            db: -reg.db,
2242            daa: -reg.daa,
2243            dab: -reg.dab,
2244            dbb: -reg.dbb,
2245        }
2246    } else {
2247        BetaShapePartials {
2248            value: reg.v,
2249            da: reg.da,
2250            db: reg.db,
2251            daa: reg.daa,
2252            dab: reg.dab,
2253            dbb: reg.dbb,
2254        }
2255    }
2256}
2257
2258/// Beta-Logistic inverse-link jet for:
2259///   u = logistic(eta)
2260///   a = exp(log_shape_center - epsilon), b = exp(log_shape_center + epsilon)
2261///   mu = I_u(a, b)
2262///
2263/// NOTE: `log_shape_center` is the *unconstrained* log of the geometric-mean
2264/// beta shape (so a·b = exp(2·log_shape_center)). Callers must pass the raw
2265/// optimization parameter `SasLinkState::log_delta`, NOT the derived positive
2266/// `SasLinkState::delta = exp(log_shape_center)`.
2267pub fn beta_logistic_inverse_link_jet(
2268    eta: f64,
2269    log_shape_center: f64,
2270    epsilon: f64,
2271) -> InverseLinkJet {
2272    let logistic = logistic_uwith_derivatives(eta);
2273    let a = (log_shape_center - epsilon).exp();
2274    let b = (log_shape_center + epsilon).exp();
2275    let mu = beta_reg_logistic(a, b, logistic);
2276    let log_d1 = beta_logistic_log_d1(a, b, logistic);
2277    let d1 = log_d1.exp();
2278    let t = a * logistic.one_minus_u - b * logistic.u;
2279    let d2 = d1 * t;
2280    let d3 = d1 * (t * t - (a + b) * logistic.du);
2281    InverseLinkJet { mu, d1, d2, d3 }
2282}
2283
2284pub fn beta_logistic_inverse_link_pdfthird_derivative(
2285    eta: f64,
2286    log_shape_center: f64,
2287    epsilon: f64,
2288) -> f64 {
2289    // Beta-logistic link:
2290    //
2291    //   u = logistic(eta),
2292    //   d1 = C * u^a (1-u)^b,
2293    //   t  = a(1-u) - b u,
2294    //   c  = a + b,
2295    //
2296    // so
2297    //
2298    //   d2 = d1 * t
2299    //   d3 = d1 * (t² - c u')
2300    //
2301    // with `u' = u(1-u)`.
2302    //
2303    // Differentiate once more:
2304    //
2305    //   d4 = d/deta[d1 (t² - c u')]
2306    //      = d1' (t² - c u') + d1 (2 t t' - c u'')
2307    //      = d1 [ t(t² - c u') - 2 c t u' - c u'' ]
2308    //      = d1 [ t³ - 3 c t u' - c u'' ],
2309    //
2310    // since `t' = -c u'`.
2311    let logistic = logistic_uwith_derivatives(eta);
2312    let a = (log_shape_center - epsilon).exp();
2313    let b = (log_shape_center + epsilon).exp();
2314    let log_d1 = beta_logistic_log_d1(a, b, logistic);
2315    let d1 = log_d1.exp();
2316    let c = a + b;
2317    let t = a * logistic.one_minus_u - b * logistic.u;
2318    let u2 = logistic.du * (logistic.one_minus_u - logistic.u);
2319    d1 * (t * t * t - 3.0 * c * t * logistic.du - c * u2)
2320}
2321
2322/// Fifth derivative of the beta-logistic inverse-link CDF (= 4th deriv of PDF).
2323///
2324/// With `P_4 = t^3 - 3ct*u' - c*u''` giving `d4 = d1 * P_4`, the next order is:
2325///
2326///   d5 = d1 * [t^4 - 6c*t^2*u' - 4c*t*u'' + 3c^2*u'^2 - c*u''']
2327///
2328/// where u' = u(1-u), u'' = u'(1-2u), u''' = u''(1-2u) - 2*u'^2.
2329pub fn beta_logistic_inverse_link_pdffourth_derivative(
2330    eta: f64,
2331    log_shape_center: f64,
2332    epsilon: f64,
2333) -> f64 {
2334    let logistic = logistic_uwith_derivatives(eta);
2335    let a = (log_shape_center - epsilon).exp();
2336    let b = (log_shape_center + epsilon).exp();
2337    let log_d1 = beta_logistic_log_d1(a, b, logistic);
2338    let d1 = log_d1.exp();
2339    let c = a + b;
2340    let t = a * logistic.one_minus_u - b * logistic.u;
2341    let u2 = logistic.du * (logistic.one_minus_u - logistic.u);
2342    let u3 = u2 * (logistic.one_minus_u - logistic.u) - 2.0 * logistic.du * logistic.du;
2343    let t2 = t * t;
2344    d1 * (t2 * t2 - 6.0 * c * t2 * logistic.du - 4.0 * c * t * u2
2345        + 3.0 * c * c * logistic.du * logistic.du
2346        - c * u3)
2347}
2348
2349pub fn beta_logistic_inverse_link_jetwith_param_partials(
2350    eta: f64,
2351    log_shape_center: f64,
2352    epsilon: f64,
2353) -> SasJetWithParamPartials {
2354    let logistic = logistic_uwith_derivatives(eta);
2355    let a = (log_shape_center - epsilon).exp();
2356    let b = (log_shape_center + epsilon).exp();
2357    let shape = beta_reg_with_shape_partials_logistic(a, b, logistic);
2358    let mu = shape.value;
2359    let dmu_dlog_shape_center = a * shape.da + b * shape.db;
2360    let dmu_depsilon = -a * shape.da + b * shape.db;
2361    let log_d1 = beta_logistic_log_d1(a, b, logistic);
2362    let d1 = log_d1.exp();
2363    let t = a * logistic.one_minus_u - b * logistic.u;
2364    let d2 = d1 * t;
2365    let k = t * t - (a + b) * logistic.du;
2366    let d3 = d1 * k;
2367    let jet = InverseLinkJet { mu, d1, d2, d3 };
2368
2369    let psi_a = digamma(a);
2370    let psi_b = digamma(b);
2371    let psi_ab = digamma(a + b);
2372    let la = logistic.ln_u - psi_a + psi_ab;
2373    let lb = logistic.ln_one_minus_u - psi_b + psi_ab;
2374
2375    let partials_for = |a_p: f64, b_p: f64, dmu: f64| -> InverseLinkJet {
2376        let logd1_p = a_p * la + b_p * lb;
2377        let d1_p = d1 * logd1_p;
2378        let t_p = a_p * logistic.one_minus_u - b_p * logistic.u;
2379        let d2_p = d1_p * t + d1 * t_p;
2380        let k_p = 2.0 * t * t_p - (a_p + b_p) * logistic.du;
2381        let d3_p = d1_p * k + d1 * k_p;
2382        InverseLinkJet {
2383            mu: dmu,
2384            d1: d1_p,
2385            d2: d2_p,
2386            d3: d3_p,
2387        }
2388    };
2389    let djet_dlog_shape_center = partials_for(a, b, dmu_dlog_shape_center);
2390    let djet_depsilon = partials_for(-a, b, dmu_depsilon);
2391    // Parameter order is `(epsilon, log_shape_center)`. The beta shapes obey
2392    // `a=exp(l-e)`, `b=exp(l+e)`, so their first and second parameter jets are
2393    // closed form. Contract those jets with the exact `(a,b)` Hessian of the
2394    // regularized beta CDF for `mu`, and with the exact log-density Hessian for
2395    // `d1`. No numerical differencing or profile replay enters this path.
2396    let a_first = [-a, a];
2397    let b_first = [b, b];
2398    let a_second = [[a, -a], [-a, a]];
2399    let b_second = [[b, b], [b, b]];
2400    let logd1_first = [
2401        a_first[0] * la + b_first[0] * lb,
2402        a_first[1] * la + b_first[1] * lb,
2403    ];
2404    let trigamma_ab = trigamma(a + b);
2405    let la_a = trigamma_ab - trigamma(a);
2406    let la_b = trigamma_ab;
2407    let lb_a = trigamma_ab;
2408    let lb_b = trigamma_ab - trigamma(b);
2409    let mut d2mu_dparams2 = Array2::<f64>::zeros((2, 2));
2410    let mut d2d1_dparams2 = Array2::<f64>::zeros((2, 2));
2411    for j in 0..2 {
2412        for k in j..2 {
2413            let mu_jk = shape.daa * a_first[j] * a_first[k]
2414                + shape.dab
2415                    * (a_first[j] * b_first[k] + b_first[j] * a_first[k])
2416                + shape.dbb * b_first[j] * b_first[k]
2417                + shape.da * a_second[j][k]
2418                + shape.db * b_second[j][k];
2419            let logd1_jk = la_a * a_first[j] * a_first[k]
2420                + la_b * a_first[j] * b_first[k]
2421                + lb_a * b_first[j] * a_first[k]
2422                + lb_b * b_first[j] * b_first[k]
2423                + la * a_second[j][k]
2424                + lb * b_second[j][k];
2425            let d1_jk = d1 * (logd1_first[j] * logd1_first[k] + logd1_jk);
2426            d2mu_dparams2[[j, k]] = mu_jk;
2427            d2mu_dparams2[[k, j]] = mu_jk;
2428            d2d1_dparams2[[j, k]] = d1_jk;
2429            d2d1_dparams2[[k, j]] = d1_jk;
2430        }
2431    }
2432    SasJetWithParamPartials {
2433        jet,
2434        djet_depsilon,
2435        djet_dlog_delta: djet_dlog_shape_center,
2436        d2mu_dparams2,
2437        d2d1_dparams2,
2438    }
2439}
2440
2441/// SAS inverse-link jet for:
2442///   mu(eta) = Phi(sinh(smooth_bound(delta * asinh(eta) + epsilon, SAS_U_CLAMP))),
2443///   delta = exp(smooth_bound(log_delta, SAS_LOG_DELTA_BOUND)).
2444/// `smooth_bound` is the interior-exact bounded latent map (see
2445/// [`smooth_bound_jet`]); on the interior it is the identity, so this reduces to
2446/// the pure probit jet exactly at `epsilon=0, delta=1`.
2447///
2448/// The mathematical solver domain is every finite `f64` eta. Non-finite eta
2449/// returns [`EstimationError::InverseLinkDomainViolation`]; no value is
2450/// substituted. The asinh derivatives are evaluated through a scaled jet so
2451/// both finite endpoints of the domain remain numerically well defined.
2452pub fn sas_inverse_link_jet(
2453    eta: f64,
2454    epsilon: f64,
2455    log_delta: f64,
2456) -> Result<InverseLinkJet, EstimationError> {
2457    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
2458    let delta_id = sas_delta_from_raw_log_delta(log_delta);
2459    if epsilon.abs() < 1e-12 && (delta_id - 1.0).abs() < 1e-12 {
2460        return Ok(component_inverse_link_jet(LinkComponent::Probit, eta));
2461    }
2462    let asinh = asinh_jet5(eta);
2463    let delta = delta_id;
2464    let u_raw = delta * asinh.value + epsilon;
2465    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
2466    let u = sb.g;
2467    let g1 = sb.d1;
2468    let g2 = sb.d2;
2469    let g3 = sb.d3;
2470    let s = u.sinh();
2471    let c = u.cosh();
2472    let z = s;
2473    let r1 = delta * asinh.d1;
2474    let r2 = delta * asinh.d2;
2475    let r3 = delta * asinh.d3;
2476    let u1 = g1 * r1;
2477    let u2 = g2 * r1 * r1 + g1 * r2;
2478    let u3 = g3 * r1 * r1 * r1 + 3.0 * g2 * r1 * r2 + g1 * r3;
2479    let z1 = c * u1;
2480    let z2 = s * u1 * u1 + c * u2;
2481    let z3 = c * u1 * u1 * u1 + 3.0 * s * u1 * u2 + c * u3;
2482    let base = probit_jet(z);
2483    Ok(chain_inverse_link_jet(base, z1, z2, z3))
2484}
2485
2486/// Fourth eta derivative of the SAS inverse-link CDF on the same finite domain
2487/// as [`sas_inverse_link_jet`].
2488pub fn sas_inverse_link_pdfthird_derivative(
2489    eta: f64,
2490    epsilon: f64,
2491    log_delta: f64,
2492) -> Result<f64, EstimationError> {
2493    // SAS link with bounded latent transform:
2494    //
2495    //   a  = asinh(eta),
2496    //   u  = smooth_bound(delta * a + epsilon),
2497    //   z  = sinh(u),
2498    //   mu = Phi(z).
2499    //
2500    // Write:
2501    //
2502    //   z1 = z'
2503    //   z2 = z''
2504    //   z3 = z'''
2505    //   z4 = z''''.
2506    //
2507    // Since `mu' = phi(z) z1`, repeated differentiation factors through the
2508    // standard normal Hermite-polynomial identities:
2509    //
2510    //   mu''   = phi(z) [ z2 - z z1² ]
2511    //
2512    //   mu'''  = phi(z) [ z3 - 3 z z1 z2 + (z² - 1) z1³ ]
2513    //          = phi(z) k3
2514    //
2515    //   mu'''' = phi(z) [ k4 - z z1 k3 ],
2516    //
2517    // where `k4` is the derivative of `k3` after collecting like terms. The
2518    // code below computes `u1..u4`, then `z1..z4`, then `k3` and `k4`, exactly
2519    // matching that chain.
2520    //
2521    // The needed fourth derivative of `u(eta)` is obtained from the nested
2522    // composition `u(eta) = g(r(eta))` with
2523    //   g = smooth_bound, r = delta * asinh(eta) - epsilon:
2524    //
2525    //   u4 = g'''' r1^4 + 6 g''' r1² r2 + 3 g'' r2² + 4 g'' r1 r3 + g' r4,
2526    //
2527    // which is the standard scalar Arbogast expansion for order four.
2528    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
2529    let asinh = asinh_jet5(eta);
2530    let delta = sas_delta_from_raw_log_delta(log_delta);
2531    let u_raw = delta * asinh.value + epsilon;
2532    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
2533    let u = sb.g;
2534    let g1 = sb.d1;
2535    let g2 = sb.d2;
2536    let g3 = sb.d3;
2537    let g4 = sb.d4;
2538    let s = u.sinh();
2539    let c = u.cosh();
2540    let z = s;
2541    let base = probit_jet(z);
2542    let r1 = delta * asinh.d1;
2543    let r2 = delta * asinh.d2;
2544    let r3 = delta * asinh.d3;
2545    let r4 = delta * asinh.d4;
2546    let u1 = g1 * r1;
2547    let u2 = g2 * r1 * r1 + g1 * r2;
2548    let u3 = g3 * r1 * r1 * r1 + 3.0 * g2 * r1 * r2 + g1 * r3;
2549    let u4 = g4 * r1.powi(4)
2550        + 6.0 * g3 * r1 * r1 * r2
2551        + 3.0 * g2 * r2 * r2
2552        + 4.0 * g2 * r1 * r3
2553        + g1 * r4;
2554    let z1 = c * u1;
2555    let z2 = s * u1 * u1 + c * u2;
2556    let z3 = c * u1 * u1 * u1 + 3.0 * s * u1 * u2 + c * u3;
2557    let z4 =
2558        s * u1.powi(4) + 6.0 * c * u1 * u1 * u2 + 3.0 * s * u2 * u2 + 4.0 * s * u1 * u3 + c * u4;
2559    let base4 = probit_pdfthird_derivative(z);
2560    let out = base4 * z1.powi(4)
2561        + 6.0 * base.d3 * z1 * z1 * z2
2562        + 3.0 * base.d2 * z2 * z2
2563        + 4.0 * base.d2 * z1 * z3
2564        + base.d1 * z4;
2565    Ok(canonicalzero(out))
2566}
2567
2568/// Fifth derivative of the SAS inverse-link CDF (= fourth derivative of the PDF).
2569///
2570/// Extends `sas_inverse_link_pdfthird_derivative` by one more derivative order,
2571/// using the same composition chain u(eta) = g(r(eta)), z = sinh(u), mu = Phi(z).
2572///
2573/// The Arbogast expansion at order 5 for u(eta) = g(r(eta)) is:
2574///   u5 = g5 r1^5 + 10 g4 r1^3 r2 + 15 g3 r1 r2^2 + 10 g3 r1^2 r3
2575///        + 10 g2 r2 r3 + 5 g2 r1 r4 + g1 r5
2576///
2577/// The z = sinh(u) expansion at order 5 is the standard Arbogast for sinh:
2578///   z5 = c*u1^5 + 10*s*u1^3*u2 + 15*c*u1*u2^2 + 10*c*u1^2*u3
2579///        + 10*s*u2*u3 + 5*s*u1*u4 + c*u5
2580///
2581/// The mu = Phi(z) expansion at order 5 uses probit derivatives:
2582///   mu^(5) = Phi5*z1^5 + 10*Phi4*z1^3*z2 + 15*Phi3*z1*z2^2 + 10*Phi3*z1^2*z3
2583///            + 10*Phi2*z2*z3 + 5*Phi2*z1*z4 + Phi1*z5
2584///
2585/// Non-finite eta is rejected by the shared SAS finite-domain contract.
2586pub fn sas_inverse_link_pdffourth_derivative(
2587    eta: f64,
2588    epsilon: f64,
2589    log_delta: f64,
2590) -> Result<f64, EstimationError> {
2591    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
2592    let asinh = asinh_jet5(eta);
2593    let delta = sas_delta_from_raw_log_delta(log_delta);
2594    let u_raw = delta * asinh.value + epsilon;
2595    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
2596    let u = sb.g;
2597    let g1 = sb.d1;
2598    let g2 = sb.d2;
2599    let g3 = sb.d3;
2600    let g4 = sb.d4;
2601    let g5 = sb.d5;
2602    let s = u.sinh();
2603    let c = u.cosh();
2604    let z = s;
2605
2606    // Probit derivatives at z.
2607    let base = probit_jet(z);
2608    let phi3 = probit_pdfthird_derivative(z); // Phi^{(4)}
2609    let phi4 = probit_pdffourth_derivative(z); // Phi^{(5)}
2610
2611    let r1 = delta * asinh.d1;
2612    let r2 = delta * asinh.d2;
2613    let r3 = delta * asinh.d3;
2614    let r4 = delta * asinh.d4;
2615    let r5 = delta * asinh.d5;
2616
2617    // u1..u5 via Arbogast for g(r(eta)).
2618    let u1 = g1 * r1;
2619    let u2 = g2 * r1 * r1 + g1 * r2;
2620    let u3 = g3 * r1 * r1 * r1 + 3.0 * g2 * r1 * r2 + g1 * r3;
2621    let u4 = g4 * r1.powi(4)
2622        + 6.0 * g3 * r1 * r1 * r2
2623        + 3.0 * g2 * r2 * r2
2624        + 4.0 * g2 * r1 * r3
2625        + g1 * r4;
2626    let u5 = g5 * r1.powi(5)
2627        + 10.0 * g4 * r1 * r1 * r1 * r2
2628        + 15.0 * g3 * r1 * r2 * r2
2629        + 10.0 * g3 * r1 * r1 * r3
2630        + 10.0 * g2 * r2 * r3
2631        + 5.0 * g2 * r1 * r4
2632        + g1 * r5;
2633
2634    // z1..z5 via Arbogast for sinh(u(eta)).
2635    let z1 = c * u1;
2636    let z2 = s * u1 * u1 + c * u2;
2637    let z3 = c * u1 * u1 * u1 + 3.0 * s * u1 * u2 + c * u3;
2638    let z4 =
2639        s * u1.powi(4) + 6.0 * c * u1 * u1 * u2 + 3.0 * s * u2 * u2 + 4.0 * s * u1 * u3 + c * u4;
2640    let z5 = c * u1.powi(5)
2641        + 10.0 * s * u1 * u1 * u1 * u2
2642        + 15.0 * c * u1 * u2 * u2
2643        + 10.0 * c * u1 * u1 * u3
2644        + 10.0 * s * u2 * u3
2645        + 5.0 * s * u1 * u4
2646        + c * u5;
2647
2648    // mu^(5) = Phi^(5)*z1^5 + 10*Phi^(4)*z1^3*z2 + 15*Phi^(3)*z1*z2^2
2649    //        + 10*Phi^(3)*z1^2*z3 + 10*Phi^(2)*z2*z3 + 5*Phi^(2)*z1*z4 + Phi^(1)*z5
2650    let out = phi4 * z1.powi(5)
2651        + 10.0 * phi3 * z1 * z1 * z1 * z2
2652        + 15.0 * base.d3 * z1 * z2 * z2
2653        + 10.0 * base.d3 * z1 * z1 * z3
2654        + 10.0 * base.d2 * z2 * z3
2655        + 5.0 * base.d2 * z1 * z4
2656        + base.d1 * z5;
2657    Ok(canonicalzero(out))
2658}
2659
2660/// SAS eta jet plus epsilon/log-delta partial jets. This is fallible for the
2661/// same reason as the value jet: eta must be finite, and no non-finite eta is
2662/// silently replaced.
2663pub fn sas_inverse_link_jetwith_param_partials(
2664    eta: f64,
2665    epsilon: f64,
2666    log_delta: f64,
2667) -> Result<SasJetWithParamPartials, EstimationError> {
2668    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
2669    let asinh = asinh_jet5(eta);
2670    let ld_sb = smooth_bound_jet(log_delta, SAS_LOG_DELTA_BOUND);
2671    let (ld_eff, dld_eff_draw) = (ld_sb.g, ld_sb.d1);
2672    let d2ld_eff_draw2 = ld_sb.d2;
2673    let delta = ld_eff.exp();
2674    let ddelta_draw = delta * dld_eff_draw;
2675    let d2delta_draw2 = delta * (dld_eff_draw * dld_eff_draw + d2ld_eff_draw2);
2676    let u_raw = delta * asinh.value + epsilon;
2677    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
2678    let u = sb.g;
2679    let g1 = sb.d1;
2680    let g2 = sb.d2;
2681    let g3 = sb.d3;
2682    let g4 = sb.d4;
2683    let s = u.sinh();
2684    let c = u.cosh();
2685    let z = s;
2686    let a1 = asinh.d1;
2687    let a2 = asinh.d2;
2688    let a3 = asinh.d3;
2689    let r1 = delta * a1;
2690    let r2 = delta * a2;
2691    let r3 = delta * a3;
2692    let u1 = g1 * r1;
2693    let u2 = g2 * r1 * r1 + g1 * r2;
2694    let u3 = g3 * r1 * r1 * r1 + 3.0 * g2 * r1 * r2 + g1 * r3;
2695    let z1 = c * u1;
2696    let z2 = s * u1 * u1 + c * u2;
2697    let z3 = c * u1 * u1 * u1 + 3.0 * s * u1 * u2 + c * u3;
2698
2699    let base = probit_jet(z);
2700    let jet = chain_inverse_link_jet(base, z1, z2, z3);
2701
2702    // Generic chain for parameter t:
2703    // u_t, u1_t, u2_t, u3_t -> z_t,z1_t,z2_t,z3_t -> mu_t,d1_t,d2_t,d3_t
2704    let param_partials = |u_t: f64, u1_t: f64, u2_t: f64, u3_t: f64| -> InverseLinkJet {
2705        let z_t = c * u_t;
2706        let z1_t = s * u_t * u1 + c * u1_t;
2707        let z2_t = c * u_t * u1 * u1 + 2.0 * s * u1 * u1_t + s * u_t * u2 + c * u2_t;
2708        let z3_t = s * u_t * u1 * u1 * u1
2709            + 3.0 * c * u1 * u1 * u1_t
2710            + 3.0 * c * u_t * u1 * u2
2711            + 3.0 * s * (u1_t * u2 + u1 * u2_t)
2712            + s * u_t * u3
2713            + c * u3_t;
2714
2715        InverseLinkJet {
2716            mu: base.d1 * z_t,
2717            d1: base.d2 * z_t * z1 + base.d1 * z1_t,
2718            d2: base.d3 * z_t * z1 * z1
2719                + 2.0 * base.d2 * z1 * z1_t
2720                + base.d2 * z_t * z2
2721                + base.d1 * z2_t,
2722            d3: probit_pdfthird_derivative(z) * z_t * z1.powi(3)
2723                + 3.0 * base.d3 * z1 * z1 * z1_t
2724                + 3.0 * base.d3 * z_t * z1 * z2
2725                + 3.0 * base.d2 * (z1_t * z2 + z1 * z2_t)
2726                + base.d2 * z_t * z3
2727                + base.d1 * z3_t,
2728        }
2729    };
2730
2731    // epsilon partials (raw_u_t = +1).
2732    let rt_eps = 1.0;
2733    let r1t_eps = 0.0;
2734    let r2t_eps = 0.0;
2735    let r3t_eps = 0.0;
2736    let u_eps = g1 * rt_eps;
2737    let u1_eps = g2 * rt_eps * r1 + g1 * r1t_eps;
2738    let u2_eps = g3 * rt_eps * r1 * r1 + 2.0 * g2 * r1 * r1t_eps + g2 * rt_eps * r2 + g1 * r2t_eps;
2739    let u3_eps = g4 * rt_eps * r1 * r1 * r1
2740        + 3.0 * g3 * r1 * r1 * r1t_eps
2741        + 3.0 * g3 * rt_eps * r1 * r2
2742        + 3.0 * g2 * (r1t_eps * r2 + r1 * r2t_eps)
2743        + g2 * rt_eps * r3
2744        + g1 * r3t_eps;
2745    let djet_depsilon = param_partials(u_eps, u1_eps, u2_eps, u3_eps);
2746
2747    // raw log-delta partials (through smooth bounded effective log-delta).
2748    let rt_ld = ddelta_draw * asinh.value;
2749    let r1t_ld = ddelta_draw * a1;
2750    let r2t_ld = ddelta_draw * a2;
2751    let r3t_ld = ddelta_draw * a3;
2752    let u_ld = g1 * rt_ld;
2753    let u1_ld = g2 * rt_ld * r1 + g1 * r1t_ld;
2754    let u2_ld = g3 * rt_ld * r1 * r1 + 2.0 * g2 * r1 * r1t_ld + g2 * rt_ld * r2 + g1 * r2t_ld;
2755    let u3_ld = g4 * rt_ld * r1 * r1 * r1
2756        + 3.0 * g3 * r1 * r1 * r1t_ld
2757        + 3.0 * g3 * rt_ld * r1 * r2
2758        + 3.0 * g2 * (r1t_ld * r2 + r1 * r2t_ld)
2759        + g2 * rt_ld * r3
2760        + g1 * r3t_ld;
2761    let djet_dlog_delta = param_partials(u_ld, u1_ld, u2_ld, u3_ld);
2762
2763    // Exact parameter Hessians needed by the profiled likelihood. Only `mu`
2764    // and `d1` enter the scalar log-survival/log-density terms, so carry the
2765    // two-variable chain to second parameter order without constructing unused
2766    // Hessians for d2/d3. Parameter order is `(epsilon, raw_log_delta)`.
2767    let r_t = [rt_eps, rt_ld];
2768    let r1_t = [r1t_eps, r1t_ld];
2769    let r_tt = [[0.0, 0.0], [0.0, d2delta_draw2 * asinh.value]];
2770    let r1_tt = [[0.0, 0.0], [0.0, d2delta_draw2 * a1]];
2771    let u_t = [u_eps, u_ld];
2772    let u1_t = [u1_eps, u1_ld];
2773    let z_t = [c * u_t[0], c * u_t[1]];
2774    let z1_t = [
2775        s * u_t[0] * u1 + c * u1_t[0],
2776        s * u_t[1] * u1 + c * u1_t[1],
2777    ];
2778    let mut d2mu_dparams2 = Array2::<f64>::zeros((2, 2));
2779    let mut d2d1_dparams2 = Array2::<f64>::zeros((2, 2));
2780    for j in 0..2 {
2781        for k in j..2 {
2782            let u_jk = g2 * r_t[j] * r_t[k] + g1 * r_tt[j][k];
2783            let u1_jk = g3 * r_t[j] * r_t[k] * r1
2784                + g2 * r_tt[j][k] * r1
2785                + g2 * r_t[j] * r1_t[k]
2786                + g2 * r_t[k] * r1_t[j]
2787                + g1 * r1_tt[j][k];
2788            let z_jk = s * u_t[j] * u_t[k] + c * u_jk;
2789            let z1_jk = c * u_t[j] * u_t[k] * u1
2790                + s * u_jk * u1
2791                + s * u_t[j] * u1_t[k]
2792                + s * u_t[k] * u1_t[j]
2793                + c * u1_jk;
2794            let mu_jk = base.d2 * z_t[j] * z_t[k] + base.d1 * z_jk;
2795            let d1_jk = base.d3 * z_t[j] * z_t[k] * z1
2796                + base.d2 * z_jk * z1
2797                + base.d2 * z_t[j] * z1_t[k]
2798                + base.d2 * z_t[k] * z1_t[j]
2799                + base.d1 * z1_jk;
2800            d2mu_dparams2[[j, k]] = mu_jk;
2801            d2mu_dparams2[[k, j]] = mu_jk;
2802            d2d1_dparams2[[j, k]] = d1_jk;
2803            d2d1_dparams2[[k, j]] = d1_jk;
2804        }
2805    }
2806
2807    Ok(SasJetWithParamPartials {
2808        jet,
2809        djet_depsilon,
2810        djet_dlog_delta,
2811        d2mu_dparams2,
2812        d2d1_dparams2,
2813    })
2814}
2815
2816#[cfg(test)]
2817mod tests {
2818    use super::*;
2819    use gam_problem::{InverseLink, LikelihoodSpec, LinkComponent, MixtureLinkSpec, SasLinkState};
2820
2821    fn assert_log_link_domain_error(error: EstimationError, eta: f64) {
2822        match error {
2823            EstimationError::InverseLinkDomainViolation {
2824                link,
2825                eta: rejected,
2826                lower,
2827                upper,
2828            } => {
2829                assert_eq!(link, "standard log inverse link");
2830                if eta.is_nan() {
2831                    assert!(rejected.is_nan());
2832                } else {
2833                    assert_eq!(rejected, eta);
2834                }
2835                assert_eq!(lower, LOG_LINK_SOLVER_ETA_MIN);
2836                assert_eq!(upper, LOG_LINK_SOLVER_ETA_MAX);
2837            }
2838            other => panic!("expected typed log-link domain refusal, got {other}"),
2839        }
2840    }
2841
2842    fn assert_finite_eta_domain_error(
2843        error: EstimationError,
2844        expected_link: &'static str,
2845        eta: f64,
2846    ) {
2847        match error {
2848            EstimationError::InverseLinkDomainViolation {
2849                link,
2850                eta: rejected,
2851                lower,
2852                upper,
2853            } => {
2854                assert_eq!(link, expected_link);
2855                if eta.is_nan() {
2856                    assert!(rejected.is_nan());
2857                } else {
2858                    assert_eq!(rejected, eta);
2859                }
2860                assert_eq!(lower, -f64::MAX);
2861                assert_eq!(upper, f64::MAX);
2862            }
2863            other => panic!("expected typed finite-eta domain refusal, got {other}"),
2864        }
2865    }
2866
2867    #[test]
2868    fn log_link_solver_boundaries_are_inclusive_exact_exp_jets() {
2869        let link = InverseLink::Standard(StandardLink::Log);
2870        let spec = LikelihoodSpec::poisson_log();
2871        for eta in [LOG_LINK_SOLVER_ETA_MIN, LOG_LINK_SOLVER_ETA_MAX] {
2872            let expected = eta.exp();
2873            assert!(expected.is_finite() && expected > 0.0);
2874
2875            let jet = inverse_link_jet_for_inverse_link(&link, eta).expect("boundary jet");
2876            assert_eq!(
2877                LinkFunction::Log.jet(eta).expect("kernel boundary jet"),
2878                jet
2879            );
2880            assert_eq!(jet.mu, expected);
2881            assert_eq!(jet.d1, expected);
2882            assert_eq!(jet.d2, expected);
2883            assert_eq!(jet.d3, expected);
2884
2885            assert_eq!(
2886                inverse_link_mu_d1_for_inverse_link(&link, eta).expect("boundary mu/d1"),
2887                (expected, expected)
2888            );
2889            assert_eq!(
2890                inverse_link_pdfthird_derivative_for_inverse_link(&link, eta)
2891                    .expect("boundary fourth derivative"),
2892                expected
2893            );
2894            assert_eq!(
2895                inverse_link_pdffourth_derivative_for_inverse_link(&link, eta)
2896                    .expect("boundary fifth derivative"),
2897                expected
2898            );
2899            assert_eq!(
2900                inverse_link_jet_for_family(&spec, eta).expect("boundary family jet"),
2901                jet
2902            );
2903        }
2904    }
2905
2906    #[test]
2907    fn log_link_solver_seams_refuse_every_eta_outside_the_declared_domain() {
2908        let link = InverseLink::Standard(StandardLink::Log);
2909        let spec = LikelihoodSpec::poisson_log();
2910        let just_below = f64::from_bits(LOG_LINK_SOLVER_ETA_MIN.to_bits() + 1);
2911        let just_above = f64::from_bits(LOG_LINK_SOLVER_ETA_MAX.to_bits() + 1);
2912
2913        for eta in [
2914            just_below,
2915            just_above,
2916            f64::NEG_INFINITY,
2917            f64::INFINITY,
2918            f64::NAN,
2919        ] {
2920            assert_log_link_domain_error(
2921                inverse_link_jet_for_inverse_link(&link, eta).expect_err("full jet must refuse"),
2922                eta,
2923            );
2924            assert_log_link_domain_error(
2925                LinkFunction::Log
2926                    .jet(eta)
2927                    .expect_err("kernel jet must refuse"),
2928                eta,
2929            );
2930            assert_log_link_domain_error(
2931                inverse_link_mu_d1_for_inverse_link(&link, eta)
2932                    .expect_err("mu/d1 seam must refuse"),
2933                eta,
2934            );
2935            assert_log_link_domain_error(
2936                inverse_link_pdfthird_derivative_for_inverse_link(&link, eta)
2937                    .expect_err("fourth derivative seam must refuse"),
2938                eta,
2939            );
2940            assert_log_link_domain_error(
2941                inverse_link_pdffourth_derivative_for_inverse_link(&link, eta)
2942                    .expect_err("fifth derivative seam must refuse"),
2943                eta,
2944            );
2945            assert_log_link_domain_error(
2946                inverse_link_jet_for_family(&spec, eta).expect_err("family jet seam must refuse"),
2947                eta,
2948            );
2949        }
2950    }
2951
2952    #[test]
2953    fn log_link_solver_value_gradient_is_consistent_near_both_domain_edges() {
2954        let link = InverseLink::Standard(StandardLink::Log);
2955        let h = 1.0e-5;
2956        for eta in [
2957            LOG_LINK_SOLVER_ETA_MIN + 1.0,
2958            0.0,
2959            LOG_LINK_SOLVER_ETA_MAX - 1.0,
2960        ] {
2961            let jet = inverse_link_jet_for_inverse_link(&link, eta).expect("interior jet");
2962            let eta_plus = eta + h;
2963            let eta_minus = eta - h;
2964            let mu_plus = inverse_link_jet_for_inverse_link(&link, eta_plus)
2965                .expect("plus jet")
2966                .mu;
2967            let mu_minus = inverse_link_jet_for_inverse_link(&link, eta_minus)
2968                .expect("minus jet")
2969                .mu;
2970            let finite_difference = (mu_plus - mu_minus) / (eta_plus - eta_minus);
2971            let relative_error = ((finite_difference - jet.d1) / jet.d1).abs();
2972            assert!(
2973                relative_error < 5.0e-10,
2974                "log-link value/gradient mismatch at eta={eta}: analytic={}, finite_difference={}, relative_error={relative_error}",
2975                jet.d1,
2976                finite_difference
2977            );
2978        }
2979    }
2980
2981    #[test]
2982    fn subnormal_inverse_link_derivatives_are_preserved_not_plateaued() {
2983        let left_eta = -743.0_f64;
2984        let left_scale = left_eta.exp();
2985        assert!(left_scale > 0.0 && left_scale < f64::MIN_POSITIVE);
2986        let left = logit_inverse_link_jet5(left_eta);
2987        for (order, derivative) in [left.d1, left.d2, left.d3, left.d4, left.d5]
2988            .into_iter()
2989            .enumerate()
2990        {
2991            assert!(
2992                derivative > 0.0 && derivative < f64::MIN_POSITIVE,
2993                "left-tail logit derivative order {} lost its represented subnormal: {derivative}",
2994                order + 1
2995            );
2996        }
2997
2998        let right_eta = 743.0_f64;
2999        let right_scale = (-right_eta).exp();
3000        assert!(right_scale > 0.0 && right_scale < f64::MIN_POSITIVE);
3001        let right = logit_inverse_link_jet5(right_eta);
3002        for (order, derivative, sign) in [
3003            (1, right.d1, 1.0),
3004            (2, right.d2, -1.0),
3005            (3, right.d3, 1.0),
3006            (4, right.d4, -1.0),
3007            (5, right.d5, 1.0),
3008        ] {
3009            assert_eq!(derivative.signum(), sign, "wrong order-{order} tail sign");
3010            assert!(
3011                derivative.abs() > 0.0 && derivative.abs() < f64::MIN_POSITIVE,
3012                "right-tail logit derivative order {order} lost its represented subnormal: {derivative}"
3013            );
3014        }
3015
3016        let royston_eta = 735.0_f64.ln();
3017        let royston = royston_parmar_inverse_link_jet(royston_eta)
3018            .expect("finite Royston-Parmar subnormal-tail eta");
3019        assert!(
3020            royston.d1 < 0.0 && royston.d1.abs() < f64::MIN_POSITIVE,
3021            "Royston-Parmar exact tail derivative must retain its subnormal: {}",
3022            royston.d1
3023        );
3024    }
3025
3026    #[test]
3027    fn sas_all_derivative_seams_refuse_nonfinite_eta_with_one_typed_contract() {
3028        let state = sas_link_state_from_raw(0.25, -0.35).expect("SAS state");
3029        let link = InverseLink::Sas(state);
3030        for eta in [f64::NEG_INFINITY, f64::INFINITY, f64::NAN] {
3031            assert_finite_eta_domain_error(
3032                sas_inverse_link_jet(eta, state.epsilon, state.log_delta)
3033                    .expect_err("SAS full jet must refuse"),
3034                "SAS inverse link",
3035                eta,
3036            );
3037            assert_finite_eta_domain_error(
3038                sas_inverse_link_mu_d1(eta, state.epsilon, state.log_delta)
3039                    .expect_err("SAS mu/d1 must refuse"),
3040                "SAS inverse link",
3041                eta,
3042            );
3043            assert_finite_eta_domain_error(
3044                sas_inverse_link_pdfthird_derivative(eta, state.epsilon, state.log_delta)
3045                    .expect_err("SAS fourth derivative must refuse"),
3046                "SAS inverse link",
3047                eta,
3048            );
3049            assert_finite_eta_domain_error(
3050                sas_inverse_link_pdffourth_derivative(eta, state.epsilon, state.log_delta)
3051                    .expect_err("SAS fifth derivative must refuse"),
3052                "SAS inverse link",
3053                eta,
3054            );
3055            assert_finite_eta_domain_error(
3056                sas_inverse_link_jetwith_param_partials(eta, state.epsilon, state.log_delta)
3057                    .expect_err("SAS parameter partials must refuse"),
3058                "SAS inverse link",
3059                eta,
3060            );
3061            assert_finite_eta_domain_error(
3062                inverse_link_jet_for_inverse_link(&link, eta).expect_err("SAS kernel must refuse"),
3063                "SAS inverse link",
3064                eta,
3065            );
3066            assert_finite_eta_domain_error(
3067                inverse_link_mu_d1_for_inverse_link(&link, eta)
3068                    .expect_err("SAS fast dispatch must refuse"),
3069                "SAS inverse link",
3070                eta,
3071            );
3072            assert_finite_eta_domain_error(
3073                inverse_link_pdfthird_derivative_for_inverse_link(&link, eta)
3074                    .expect_err("SAS fourth-derivative dispatch must refuse"),
3075                "SAS inverse link",
3076                eta,
3077            );
3078            assert_finite_eta_domain_error(
3079                inverse_link_pdffourth_derivative_for_inverse_link(&link, eta)
3080                    .expect_err("SAS fifth-derivative dispatch must refuse"),
3081                "SAS inverse link",
3082                eta,
3083            );
3084        }
3085    }
3086
3087    #[test]
3088    fn sas_jets_are_finite_at_both_finite_f64_domain_edges() {
3089        let state = sas_link_state_from_raw(0.25, -0.35).expect("SAS state");
3090        for eta in [-f64::MAX, f64::MAX] {
3091            let jet = sas_inverse_link_jet(eta, state.epsilon, state.log_delta)
3092                .expect("finite SAS boundary jet");
3093            let partials =
3094                sas_inverse_link_jetwith_param_partials(eta, state.epsilon, state.log_delta)
3095                    .expect("finite SAS boundary partials");
3096            let h4 = sas_inverse_link_pdfthird_derivative(eta, state.epsilon, state.log_delta)
3097                .expect("finite SAS boundary fourth derivative");
3098            let h5 = sas_inverse_link_pdffourth_derivative(eta, state.epsilon, state.log_delta)
3099                .expect("finite SAS boundary fifth derivative");
3100            for value in [
3101                jet.mu,
3102                jet.d1,
3103                jet.d2,
3104                jet.d3,
3105                partials.jet.mu,
3106                partials.jet.d1,
3107                partials.jet.d2,
3108                partials.jet.d3,
3109                partials.djet_depsilon.mu,
3110                partials.djet_depsilon.d1,
3111                partials.djet_depsilon.d2,
3112                partials.djet_depsilon.d3,
3113                partials.djet_dlog_delta.mu,
3114                partials.djet_dlog_delta.d1,
3115                partials.djet_dlog_delta.d2,
3116                partials.djet_dlog_delta.d3,
3117                h4,
3118                h5,
3119            ] {
3120                assert!(
3121                    value.is_finite(),
3122                    "non-finite SAS boundary jet at eta={eta}: {value}"
3123                );
3124            }
3125        }
3126    }
3127
3128    #[test]
3129    fn royston_parmar_exact_jet_has_no_former_minus_thirty_plateau() {
3130        let left =
3131            royston_parmar_inverse_link_jet(-30.0 - 1.0e-6).expect("finite Royston-Parmar eta");
3132        let center = royston_parmar_inverse_link_jet(-30.0).expect("finite Royston-Parmar eta");
3133        let right =
3134            royston_parmar_inverse_link_jet(-30.0 + 1.0e-6).expect("finite Royston-Parmar eta");
3135
3136        assert!(
3137            left.d1 < 0.0,
3138            "the exact left tail must not be a constant plateau"
3139        );
3140        assert!(center.d1 < 0.0 && right.d1 < 0.0);
3141        let left_relative = ((left.d1 - center.d1) / center.d1).abs();
3142        let right_relative = ((right.d1 - center.d1) / center.d1).abs();
3143        assert!(
3144            left_relative < 2.0e-6,
3145            "left derivative kink: {left_relative}"
3146        );
3147        assert!(
3148            right_relative < 2.0e-6,
3149            "right derivative kink: {right_relative}"
3150        );
3151
3152        for eta in [-f64::MAX, -40.0, -30.0, 0.0, 7.0, 30.0, 40.0, f64::MAX] {
3153            let jet = royston_parmar_inverse_link_jet(eta).expect("finite Royston-Parmar eta");
3154            for value in [jet.mu, jet.d1, jet.d2, jet.d3] {
3155                assert!(
3156                    value.is_finite(),
3157                    "non-finite Royston-Parmar jet at eta={eta}: {value}"
3158                );
3159            }
3160        }
3161    }
3162
3163    #[test]
3164    fn royston_parmar_seams_refuse_nonfinite_eta_instead_of_clamping() {
3165        let spec = LikelihoodSpec::new(
3166            ResponseFamily::RoystonParmar,
3167            InverseLink::Standard(StandardLink::Identity),
3168        );
3169        for eta in [f64::NEG_INFINITY, f64::INFINITY, f64::NAN] {
3170            assert_finite_eta_domain_error(
3171                royston_parmar_inverse_link_jet(eta)
3172                    .expect_err("direct Royston-Parmar jet must refuse"),
3173                "Royston-Parmar survival inverse link",
3174                eta,
3175            );
3176            assert_finite_eta_domain_error(
3177                inverse_link_jet_for_family(&spec, eta)
3178                    .expect_err("solver Royston-Parmar jet must refuse"),
3179                "Royston-Parmar survival inverse link",
3180                eta,
3181            );
3182            assert_finite_eta_domain_error(
3183                inverse_link_jet_for_family_public(&spec, eta)
3184                    .expect_err("public Royston-Parmar jet must refuse"),
3185                "Royston-Parmar survival inverse link",
3186                eta,
3187            );
3188        }
3189    }
3190
3191    #[test]
3192    fn softmax_jacobian_matchesfd() {
3193        let rho = Array1::from_vec(vec![0.7, -1.2, 0.4]);
3194        let (pi, jac) = softmaxwith_jacobian_last_fixedzero(&rho);
3195        let h = 1e-6;
3196        for j in 0..rho.len() {
3197            let mut rp = rho.clone();
3198            rp[j] += h;
3199            let mut rm = rho.clone();
3200            rm[j] -= h;
3201            let pp = softmax_last_fixedzero(&rp);
3202            let pm = softmax_last_fixedzero(&rm);
3203            let fd = (&pp - &pm).mapv(|v| v / (2.0 * h));
3204            for k in 0..pi.len() {
3205                let err = (jac[[k, j]] - fd[k]).abs();
3206                assert_eq!(
3207                    jac[[k, j]].signum(),
3208                    fd[k].signum(),
3209                    "jac sign mismatch at ({k},{j}): analytic={} fd={}",
3210                    jac[[k, j]],
3211                    fd[k]
3212                );
3213                assert!(err < 5e-6, "jac mismatch at ({k},{j}): err={err:e}");
3214            }
3215        }
3216    }
3217
3218    #[test]
3219    fn mixture_jet_rho_partials_matchfd() {
3220        let spec = MixtureLinkSpec {
3221            components: vec![
3222                LinkComponent::Probit,
3223                LinkComponent::Logit,
3224                LinkComponent::CLogLog,
3225                LinkComponent::Cauchit,
3226            ],
3227            initial_rho: Array1::from_vec(vec![0.3, -0.6, 0.2]),
3228        };
3229        let state = state_fromspec(&spec).expect("state");
3230        let eta = 0.35;
3231        let out = mixture_inverse_link_jetwith_rho_partials(&state, eta);
3232        let h = 1e-6;
3233        for j in 0..state.rho.len() {
3234            let mut rp = state.rho.clone();
3235            rp[j] += h;
3236            let sp = MixtureLinkSpec {
3237                components: state.components.clone(),
3238                initial_rho: rp,
3239            };
3240            let jp = mixture_inverse_link_jet(&state_fromspec(&sp).expect("sp"), eta);
3241            let mut rm = state.rho.clone();
3242            rm[j] -= h;
3243            let sm = MixtureLinkSpec {
3244                components: state.components.clone(),
3245                initial_rho: rm,
3246            };
3247            let jm = mixture_inverse_link_jet(&state_fromspec(&sm).expect("sm"), eta);
3248            let fd = InverseLinkJet {
3249                mu: (jp.mu - jm.mu) / (2.0 * h),
3250                d1: (jp.d1 - jm.d1) / (2.0 * h),
3251                d2: (jp.d2 - jm.d2) / (2.0 * h),
3252                d3: (jp.d3 - jm.d3) / (2.0 * h),
3253            };
3254            let an = out.djet_drho[j];
3255            assert_eq!(an.mu.signum(), fd.mu.signum());
3256            assert_eq!(an.d1.signum(), fd.d1.signum());
3257            assert_eq!(an.d2.signum(), fd.d2.signum());
3258            assert_eq!(an.d3.signum(), fd.d3.signum());
3259            assert!((an.mu - fd.mu).abs() < 1e-6);
3260            assert!((an.d1 - fd.d1).abs() < 1e-6);
3261            assert!((an.d2 - fd.d2).abs() < 1e-6);
3262            assert!((an.d3 - fd.d3).abs() < 1e-6);
3263        }
3264    }
3265
3266    #[test]
3267    fn mixture_second_partials_obey_equal_weight_two_component_identity() {
3268        let state = state_fromspec(&MixtureLinkSpec {
3269            components: vec![LinkComponent::Probit, LinkComponent::Logit],
3270            initial_rho: Array1::from_vec(vec![0.0]),
3271        })
3272        .expect("valid two-component mixture");
3273        let out = mixture_inverse_link_jetwith_rho_partials(&state, 0.37);
3274        // For two components, f''(rho)=pi(1-pi)(1-2pi)(f0-f1), so both
3275        // response channels have exactly zero curvature at the equal-weight
3276        // coordinate rho=0. This checks the analytic softmax Hessian without
3277        // using a finite-difference oracle.
3278        assert_eq!(out.d2mu_drho2.dim(), (1, 1));
3279        assert_eq!(out.d2d1_drho2.dim(), (1, 1));
3280        assert_eq!(out.d2mu_drho2[[0, 0]], 0.0);
3281        assert_eq!(out.d2d1_drho2[[0, 0]], 0.0);
3282    }
3283
3284    #[test]
3285    fn sas_param_partials_matchfd() {
3286        let eta = 0.37;
3287        let epsilon = -0.12;
3288        let log_delta = 0.21;
3289        let out = sas_inverse_link_jetwith_param_partials(eta, epsilon, log_delta)
3290            .expect("finite SAS eta");
3291        let h = 1e-6;
3292
3293        let ep_p = sas_inverse_link_jet(eta, epsilon + h, log_delta).expect("finite SAS eta");
3294        let ep_m = sas_inverse_link_jet(eta, epsilon - h, log_delta).expect("finite SAS eta");
3295        let fd_ep = InverseLinkJet {
3296            mu: (ep_p.mu - ep_m.mu) / (2.0 * h),
3297            d1: (ep_p.d1 - ep_m.d1) / (2.0 * h),
3298            d2: (ep_p.d2 - ep_m.d2) / (2.0 * h),
3299            d3: (ep_p.d3 - ep_m.d3) / (2.0 * h),
3300        };
3301        assert_eq!(out.djet_depsilon.mu.signum(), fd_ep.mu.signum());
3302        assert_eq!(out.djet_depsilon.d1.signum(), fd_ep.d1.signum());
3303        assert_eq!(out.djet_depsilon.d2.signum(), fd_ep.d2.signum());
3304        assert_eq!(out.djet_depsilon.d3.signum(), fd_ep.d3.signum());
3305        assert!((out.djet_depsilon.mu - fd_ep.mu).abs() < 5e-5);
3306        assert!((out.djet_depsilon.d1 - fd_ep.d1).abs() < 5e-5);
3307        assert!((out.djet_depsilon.d2 - fd_ep.d2).abs() < 5e-5);
3308        assert!((out.djet_depsilon.d3 - fd_ep.d3).abs() < 5e-4);
3309
3310        let ld_p = sas_inverse_link_jet(eta, epsilon, log_delta + h).expect("finite SAS eta");
3311        let ld_m = sas_inverse_link_jet(eta, epsilon, log_delta - h).expect("finite SAS eta");
3312        let fd_ld = InverseLinkJet {
3313            mu: (ld_p.mu - ld_m.mu) / (2.0 * h),
3314            d1: (ld_p.d1 - ld_m.d1) / (2.0 * h),
3315            d2: (ld_p.d2 - ld_m.d2) / (2.0 * h),
3316            d3: (ld_p.d3 - ld_m.d3) / (2.0 * h),
3317        };
3318        assert_eq!(out.djet_dlog_delta.mu.signum(), fd_ld.mu.signum());
3319        assert_eq!(out.djet_dlog_delta.d1.signum(), fd_ld.d1.signum());
3320        assert_eq!(out.djet_dlog_delta.d2.signum(), fd_ld.d2.signum());
3321        assert_eq!(out.djet_dlog_delta.d3.signum(), fd_ld.d3.signum());
3322        assert!((out.djet_dlog_delta.mu - fd_ld.mu).abs() < 5e-5);
3323        assert!((out.djet_dlog_delta.d1 - fd_ld.d1).abs() < 5e-5);
3324        assert!((out.djet_dlog_delta.d2 - fd_ld.d2).abs() < 5e-5);
3325        assert!((out.djet_dlog_delta.d3 - fd_ld.d3).abs() < 5e-4);
3326    }
3327
3328    #[test]
3329    fn sas_second_partials_have_exact_center_identities() {
3330        let out = sas_inverse_link_jetwith_param_partials(0.0, 0.0, 0.0)
3331            .expect("finite SAS center");
3332        let phi0 = normal_pdf(0.0);
3333        // At the reduction center (η=0, ε=0, log_δ=0) the bounded latent map is on
3334        // its exact-identity interior, so `u = ε + asinh(η)/… ` composes with no
3335        // bounded-map curvature. The ε–ε second partial of `d1 = μ'` is therefore
3336        // the TRUE sinh-arcsinh value 0 — not the old `-2·φ(0)/SAS_U_CLAMP²`, which
3337        // was precisely the spurious `tanh` third-derivative `g'''(0) = -2/B²` that
3338        // this fix removes. Expanding `d1(ε) = φ(sinh ε)·cosh ε = φ(0)(1 + O(ε⁴))`
3339        // at η=0 confirms `∂²d1/∂ε² = 0` exactly.
3340        assert_eq!(out.d2mu_dparams2, Array2::<f64>::zeros((2, 2)));
3341        assert_eq!(out.d2d1_dparams2[[0, 1]], out.d2d1_dparams2[[1, 0]]);
3342        assert_eq!(
3343            out.d2d1_dparams2[[0, 0]], 0.0,
3344            "ε–ε ∂²(μ') must be exactly zero at the identity-interior center"
3345        );
3346        assert!((out.d2d1_dparams2[[1, 1]] - phi0).abs() < 1.0e-15);
3347        assert_eq!(out.d2d1_dparams2[[0, 1]], 0.0);
3348    }
3349
3350    /// #1876 closability isolation gate. The SAS-link binomial FAMILY score at
3351    /// fixed η is `∂ℓ/∂ε = a1 · ∂μ/∂ε`, with the binomial score
3352    /// `a1 = w(y/μ − (1−y)/(1−μ))` and `∂μ/∂ε = djet_depsilon.mu`. This proves
3353    /// that single source is correct — in SIGN and MAGNITUDE — against an
3354    /// independent finite difference of the row log-likelihood over a whole
3355    /// (η, ε, log_δ) grid and both responses (`sas_param_partials_matchfd` only
3356    /// checks the pointwise link partials at one point; this composes them into
3357    /// the objective-level family score).
3358    ///
3359    /// Part 2 reproduces the issue's own symptom deterministically: plant
3360    /// ε*=0.38, δ*=1 and use expected fractional responses yᵢ=μ*(ηᵢ). By the
3361    /// score identity the summed data-fit ∂ℓ/∂ε then vanishes exactly at ε* and
3362    /// the negative-log-likelihood profile is minimized there — the summed
3363    /// ∂(NLL)/∂ε is strongly negative below ε* (pushes ε UP toward the truth),
3364    /// zero at ε*, strongly positive above, and STRICTLY increasing through it.
3365    /// That strict monotonicity is exactly what distinguishes +κ/skew from −κ,
3366    /// the sign-blindness #1876 reported. With the family derivative certified
3367    /// here, any wrong-sign ε recovery is provably the OUTER REML envelope path
3368    /// (the capped-β̂ / KKT-residual clobber fixed in 574129459), not this
3369    /// derivative.
3370    #[test]
3371    fn sas_family_score_depsilon_matches_fd_and_reproduces_profile_1876() {
3372        let h = 1e-6;
3373        let mu_at = |eta: f64, eps: f64, ld: f64| {
3374            sas_inverse_link_jet(eta, eps, ld)
3375                .expect("finite SAS eta")
3376                .mu
3377        };
3378        let dmu_deps = |eta: f64, eps: f64, ld: f64| {
3379            sas_inverse_link_jetwith_param_partials(eta, eps, ld)
3380                .expect("finite SAS eta")
3381                .djet_depsilon
3382                .mu
3383        };
3384        // Binomial row log-likelihood and score dℓ/dμ (== link_binomial_aux.a1).
3385        let row_ll = |y: f64, w: f64, mu: f64| w * (y * mu.ln() + (1.0 - y) * (1.0 - mu).ln());
3386        let a1 = |y: f64, w: f64, mu: f64| w * (y / mu - (1.0 - y) / (1.0 - mu));
3387
3388        // ── Part 1: pointwise family-score correctness across a grid. ──
3389        let etas = [-1.0, -0.6, -0.2, 0.15, 0.5, 0.9];
3390        let epsilons = [-0.5, -0.2, 0.0, 0.3, 0.6];
3391        let log_deltas = [-0.3, 0.0, 0.4];
3392        for &eta in &etas {
3393            for &eps in &epsilons {
3394                for &ld in &log_deltas {
3395                    let mu0 = mu_at(eta, eps, ld);
3396                    // Stay in the numerically comfortable interior; the far tail
3397                    // is covered by `sas_jet_extreme_inputs_stay_finite`.
3398                    if !(0.02..=0.98).contains(&mu0) {
3399                        continue;
3400                    }
3401                    let dmu = dmu_deps(eta, eps, ld);
3402                    for &y in &[0.0_f64, 1.0] {
3403                        let analytic = a1(y, 1.0, mu0) * dmu; // dℓ/dε
3404                        let fd = (row_ll(y, 1.0, mu_at(eta, eps + h, ld))
3405                            - row_ll(y, 1.0, mu_at(eta, eps - h, ld)))
3406                            / (2.0 * h);
3407                        let scale = analytic.abs().max(fd.abs()).max(1.0);
3408                        assert_eq!(
3409                            analytic.signum(),
3410                            fd.signum(),
3411                            "∂ℓ/∂ε sign mismatch η={eta} ε={eps} log_δ={ld} y={y}: \
3412                             analytic={analytic:e} fd={fd:e}"
3413                        );
3414                        assert!(
3415                            (analytic - fd).abs() < 1e-5 * scale,
3416                            "∂ℓ/∂ε magnitude mismatch η={eta} ε={eps} log_δ={ld} y={y}: \
3417                             analytic={analytic:e} fd={fd:e}"
3418                        );
3419                    }
3420                }
3421            }
3422        }
3423
3424        // ── Part 2: summed-profile symptom reproduction (deterministic). ──
3425        let eps_true = 0.38;
3426        let ld_true = 0.0;
3427        let etas_ds: Vec<f64> = (0..21).map(|i| -1.0 + 0.1 * i as f64).collect();
3428        let y: Vec<f64> = etas_ds
3429            .iter()
3430            .map(|&e| mu_at(e, eps_true, ld_true))
3431            .collect();
3432
3433        // Summed ∂(NLL)/∂ε = −Σ a1·∂μ/∂ε, analytic and by FD of the summed NLL.
3434        let grad_nll = |eps: f64| -> (f64, f64) {
3435            let mut analytic = 0.0;
3436            let mut nll_p = 0.0;
3437            let mut nll_m = 0.0;
3438            for (i, &eta) in etas_ds.iter().enumerate() {
3439                let mu0 = mu_at(eta, eps, ld_true);
3440                analytic += -a1(y[i], 1.0, mu0) * dmu_deps(eta, eps, ld_true);
3441                nll_p += -row_ll(y[i], 1.0, mu_at(eta, eps + h, ld_true));
3442                nll_m += -row_ll(y[i], 1.0, mu_at(eta, eps - h, ld_true));
3443            }
3444            (analytic, (nll_p - nll_m) / (2.0 * h))
3445        };
3446
3447        // (a) analytic == FD at every probe ε.
3448        for &eps in &[0.0, eps_true, 0.6] {
3449            let (analytic, fd) = grad_nll(eps);
3450            let scale = analytic.abs().max(fd.abs()).max(1.0);
3451            assert!(
3452                (analytic - fd).abs() < 1e-4 * scale,
3453                "summed ∂NLL/∂ε analytic≠fd at ε={eps}: {analytic:e} vs {fd:e}"
3454            );
3455        }
3456        // (b) minimum exactly at the planted ε*: strictly increasing through it.
3457        let (g_below, _) = grad_nll(0.0);
3458        let (g_at, _) = grad_nll(eps_true);
3459        let (g_above, _) = grad_nll(0.6);
3460        assert!(
3461            g_below < -1.0,
3462            "expected strongly negative ∂NLL/∂ε below ε* (pushes ε up toward truth), got {g_below:e}"
3463        );
3464        assert!(
3465            g_at.abs() < 1e-6,
3466            "expected ≈0 ∂NLL/∂ε at the planted ε* (score identity), got {g_at:e}"
3467        );
3468        assert!(
3469            g_above > 1.0,
3470            "expected strongly positive ∂NLL/∂ε above ε*, got {g_above:e}"
3471        );
3472        assert!(
3473            g_below < g_at && g_at < g_above,
3474            "∂NLL/∂ε must strictly increase through ε* (distinguishes ±ε): \
3475             {g_below:e} < {g_at:e} < {g_above:e}"
3476        );
3477    }
3478
3479    #[test]
3480    fn sas_jet_extreme_inputs_stay_finite() {
3481        let cases = [
3482            (-1e6, 0.0, 0.0),
3483            (1e6, 0.0, 0.0),
3484            (3.0, 12.0, 12.0),
3485            (-3.0, -12.0, -12.0),
3486            (0.5, 40.0, 10.0),
3487            (0.5, -40.0, -10.0),
3488        ];
3489        for (eta, eps, log_delta) in cases {
3490            let j = sas_inverse_link_jet(eta, eps, log_delta).expect("finite SAS eta");
3491            assert!(j.mu.is_finite());
3492            assert!(j.d1.is_finite());
3493            assert!(j.d2.is_finite());
3494            assert!(j.d3.is_finite());
3495            let p = sas_inverse_link_jetwith_param_partials(eta, eps, log_delta)
3496                .expect("finite SAS eta");
3497            assert!(p.djet_depsilon.mu.is_finite());
3498            assert!(p.djet_depsilon.d1.is_finite());
3499            assert!(p.djet_depsilon.d2.is_finite());
3500            assert!(p.djet_depsilon.d3.is_finite());
3501            assert!(p.djet_dlog_delta.mu.is_finite());
3502            assert!(p.djet_dlog_delta.d1.is_finite());
3503            assert!(p.djet_dlog_delta.d2.is_finite());
3504            assert!(p.djet_dlog_delta.d3.is_finite());
3505        }
3506    }
3507
3508    #[test]
3509    fn sas_param_partials_remain_finite_in_extreme_region() {
3510        let eta = 10.0;
3511        let epsilon = -60.0;
3512        let log_delta = 40.0;
3513        let j = sas_inverse_link_jetwith_param_partials(eta, epsilon, log_delta)
3514            .expect("finite SAS eta");
3515        assert!(j.djet_depsilon.mu.is_finite());
3516        assert!(j.djet_depsilon.d1.is_finite());
3517        assert!(j.djet_depsilon.d2.is_finite());
3518        assert!(j.djet_depsilon.d3.is_finite());
3519        assert!(j.djet_dlog_delta.mu.is_finite());
3520        assert!(j.djet_dlog_delta.d1.is_finite());
3521        assert!(j.djet_dlog_delta.d2.is_finite());
3522        assert!(j.djet_dlog_delta.d3.is_finite());
3523    }
3524
3525    #[test]
3526    fn sas_eta_jets_matchfd() {
3527        let eta = -0.43;
3528        let epsilon = 0.27;
3529        let log_delta = -0.31;
3530        let h = 1e-5;
3531        let j0 = sas_inverse_link_jet(eta, epsilon, log_delta).expect("finite SAS eta");
3532        let jp = sas_inverse_link_jet(eta + h, epsilon, log_delta).expect("finite SAS eta");
3533        let jm = sas_inverse_link_jet(eta - h, epsilon, log_delta).expect("finite SAS eta");
3534        let d1fd = (jp.mu - jm.mu) / (2.0 * h);
3535        let d2fd = (jp.d1 - jm.d1) / (2.0 * h);
3536        let d3fd = (jp.d2 - jm.d2) / (2.0 * h);
3537        assert_eq!(j0.d1.signum(), d1fd.signum());
3538        assert_eq!(j0.d2.signum(), d2fd.signum());
3539        assert_eq!(j0.d3.signum(), d3fd.signum());
3540        assert!((j0.d1 - d1fd).abs() < 5e-5);
3541        assert!((j0.d2 - d2fd).abs() < 2e-4);
3542        assert!((j0.d3 - d3fd).abs() < 1e-3);
3543    }
3544
3545    #[test]
3546    fn family_dispatch_resolves_parameterized_links_from_spec() {
3547        // After the LikelihoodSpec migration, the dispatch no longer needs
3548        // out-of-band state arguments — the parameterized link state lives on
3549        // `spec.link`. Pin the dispatch against the direct stateful kernels.
3550        let sas_state = sas_link_state_from_raw(0.0, 0.0).expect("sas state");
3551        let expected_sas =
3552            sas_inverse_link_jet(0.1, sas_state.epsilon, sas_state.log_delta).expect("direct SAS");
3553        let sas_spec = gam_problem::LikelihoodSpec {
3554            response: gam_problem::ResponseFamily::Binomial,
3555            link: InverseLink::Sas(sas_state),
3556        };
3557        let sas_jet = inverse_link_jet_for_family(&sas_spec, 0.1).expect("sas jet");
3558        assert_eq!(sas_jet.mu.to_bits(), expected_sas.mu.to_bits());
3559        assert_eq!(sas_jet.d1.to_bits(), expected_sas.d1.to_bits());
3560
3561        let mix_state = MixtureLinkState {
3562            components: vec![LinkComponent::Logit, LinkComponent::Probit],
3563            rho: ndarray::array![0.0],
3564            pi: ndarray::array![0.5, 0.5],
3565        };
3566        let expected_mix = mixture_inverse_link_jet(&mix_state, 0.1);
3567        let mix_spec = gam_problem::LikelihoodSpec {
3568            response: gam_problem::ResponseFamily::Binomial,
3569            link: InverseLink::Mixture(mix_state),
3570        };
3571        let mix_jet = inverse_link_jet_for_family(&mix_spec, 0.1).expect("mix jet");
3572        assert_eq!(mix_jet.mu.to_bits(), expected_mix.mu.to_bits());
3573        assert_eq!(mix_jet.d1.to_bits(), expected_mix.d1.to_bits());
3574    }
3575
3576    #[test]
3577    fn beta_logistic_reduces_to_logit_at_delta0_epsilon0() {
3578        let etas = [-40.0, -30.0, -5.0, 0.42, 5.0, 30.0, 40.0];
3579        for eta in etas {
3580            let j_bl = beta_logistic_inverse_link_jet(eta, 0.0, 0.0);
3581            let expected_mu = gam_linalg::utils::stable_logistic(eta);
3582            let expected_d1 = (-gam_linalg::utils::stable_softplus(-eta)
3583                - gam_linalg::utils::stable_softplus(eta))
3584            .exp();
3585            assert!(
3586                (j_bl.mu - expected_mu).abs() <= 1e-15 * expected_mu.abs().max(1.0),
3587                "mu mismatch at eta={eta}: got {}, expected {}",
3588                j_bl.mu,
3589                expected_mu
3590            );
3591            assert!(
3592                (j_bl.d1 - expected_d1).abs() <= 1e-12 * expected_d1.abs().max(f64::MIN_POSITIVE),
3593                "d1 mismatch at eta={eta}: got {}, expected {}",
3594                j_bl.d1,
3595                expected_d1
3596            );
3597            assert!(j_bl.d1 > 0.0, "d1 should stay positive at eta={eta}");
3598        }
3599
3600        let eta = 0.42;
3601        let j_bl = beta_logistic_inverse_link_jet(eta, 0.0, 0.0);
3602        let j_logit = component_inverse_link_jet(LinkComponent::Logit, eta);
3603        assert!((j_bl.d2 - j_logit.d2).abs() < 1e-10);
3604        assert!((j_bl.d3 - j_logit.d3).abs() < 1e-10);
3605    }
3606
3607    #[test]
3608    fn beta_logistic_eta_jets_matchfd() {
3609        let eta = -0.31;
3610        let delta = 0.27;
3611        let epsilon = -0.19;
3612        let h = 1e-5;
3613        let j0 = beta_logistic_inverse_link_jet(eta, delta, epsilon);
3614        let jp = beta_logistic_inverse_link_jet(eta + h, delta, epsilon);
3615        let jm = beta_logistic_inverse_link_jet(eta - h, delta, epsilon);
3616        let d1fd = (jp.mu - jm.mu) / (2.0 * h);
3617        let d2fd = (jp.d1 - jm.d1) / (2.0 * h);
3618        let d3fd = (jp.d2 - jm.d2) / (2.0 * h);
3619        assert_eq!(j0.d1.signum(), d1fd.signum());
3620        assert_eq!(j0.d2.signum(), d2fd.signum());
3621        assert_eq!(j0.d3.signum(), d3fd.signum());
3622        assert!((j0.d1 - d1fd).abs() < 5e-5);
3623        assert!((j0.d2 - d2fd).abs() < 5e-5);
3624        assert!((j0.d3 - d3fd).abs() < 2e-4);
3625    }
3626
3627    #[test]
3628    fn standard_kernel_structs_match_component_jets() {
3629        let eta = 0.73;
3630        assert_eq!(
3631            ProbitLinkKernel.jet(eta).expect("probit"),
3632            component_inverse_link_jet(LinkComponent::Probit, eta)
3633        );
3634        assert_eq!(
3635            LogitLinkKernel.jet(eta).expect("logit"),
3636            component_inverse_link_jet(LinkComponent::Logit, eta)
3637        );
3638        assert_eq!(
3639            CLogLogLinkKernel.jet(eta).expect("cloglog"),
3640            component_inverse_link_jet(LinkComponent::CLogLog, eta)
3641        );
3642        assert_eq!(
3643            LogLogLinkKernel.jet(eta).expect("loglog"),
3644            component_inverse_link_jet(LinkComponent::LogLog, eta)
3645        );
3646        assert_eq!(
3647            CauchitLinkKernel.jet(eta).expect("cauchit"),
3648            component_inverse_link_jet(LinkComponent::Cauchit, eta)
3649        );
3650    }
3651
3652    #[test]
3653    fn all_component_eta_jets_matchfd() {
3654        let components = [
3655            LinkComponent::Logit,
3656            LinkComponent::Probit,
3657            LinkComponent::CLogLog,
3658            LinkComponent::LogLog,
3659            LinkComponent::Cauchit,
3660        ];
3661        let points = [-3.0, -1.1, -0.2, 0.0, 0.7, 1.8, 3.2];
3662        let h = 1e-5;
3663        for c in components {
3664            for &eta in &points {
3665                let j0 = component_inverse_link_jet(c, eta);
3666                let jp = component_inverse_link_jet(c, eta + h);
3667                let jm = component_inverse_link_jet(c, eta - h);
3668                let d1fd = (jp.mu - jm.mu) / (2.0 * h);
3669                let d2fd = (jp.d1 - jm.d1) / (2.0 * h);
3670                let d3fd = (jp.d2 - jm.d2) / (2.0 * h);
3671                let d1_tol = if matches!(c, LinkComponent::CLogLog | LinkComponent::LogLog) {
3672                    1.2e-4
3673                } else {
3674                    5e-5
3675                };
3676                let d2_tol = if matches!(c, LinkComponent::CLogLog | LinkComponent::LogLog) {
3677                    4e-4
3678                } else {
3679                    1.2e-4
3680                };
3681                let d3_tol = if matches!(c, LinkComponent::CLogLog | LinkComponent::LogLog) {
3682                    1.2e-3
3683                } else {
3684                    4e-4
3685                };
3686                if j0.d1.abs().max(d1fd.abs()) > 1e-10 {
3687                    assert_eq!(
3688                        j0.d1.signum(),
3689                        d1fd.signum(),
3690                        "d1 sign mismatch for {c:?} eta={eta}"
3691                    );
3692                }
3693                if j0.d2.abs().max(d2fd.abs()) > 1e-10 {
3694                    assert_eq!(
3695                        j0.d2.signum(),
3696                        d2fd.signum(),
3697                        "d2 sign mismatch for {c:?} eta={eta}: analytic={} fd={}",
3698                        j0.d2,
3699                        d2fd
3700                    );
3701                }
3702                if j0.d3.abs().max(d3fd.abs()) > 1e-10 {
3703                    assert_eq!(
3704                        j0.d3.signum(),
3705                        d3fd.signum(),
3706                        "d3 sign mismatch for {c:?} eta={eta}"
3707                    );
3708                }
3709                assert!(
3710                    (j0.d1 - d1fd).abs() < d1_tol,
3711                    "d1 mismatch for {c:?} eta={eta}: analytic={} fd={}",
3712                    j0.d1,
3713                    d1fd
3714                );
3715                assert!(
3716                    (j0.d2 - d2fd).abs() < d2_tol,
3717                    "d2 mismatch for {c:?} eta={eta}: analytic={} fd={}",
3718                    j0.d2,
3719                    d2fd
3720                );
3721                assert!(
3722                    (j0.d3 - d3fd).abs() < d3_tol,
3723                    "d3 mismatch for {c:?} eta={eta}: analytic={} fd={}",
3724                    j0.d3,
3725                    d3fd
3726                );
3727            }
3728        }
3729    }
3730
3731    #[test]
3732    fn sas_center_matches_probit_at_delta1_epsilon0() {
3733        // `(ε=0, δ=1)` takes the fast probit reduction path, which returns the
3734        // probit jet bitwise. This pins that contract exactly (no tolerance).
3735        let etas = [-3.0, -1.2, -0.3, 0.0, 0.4, 1.7, 3.0];
3736        for eta in etas {
3737            let sas = sas_inverse_link_jet(eta, 0.0, 0.0).expect("finite SAS eta");
3738            let probit = ProbitLinkKernel.jet(eta).expect("probit");
3739            assert_eq!(sas.mu.to_bits(), probit.mu.to_bits(), "mu at eta={eta}");
3740            assert_eq!(sas.d1.to_bits(), probit.d1.to_bits(), "d1 at eta={eta}");
3741            assert_eq!(sas.d2.to_bits(), probit.d2.to_bits(), "d2 at eta={eta}");
3742            assert_eq!(sas.d3.to_bits(), probit.d3.to_bits(), "d3 at eta={eta}");
3743        }
3744    }
3745
3746    /// #2389 regression: the interior-exact bounded latent map makes the
3747    /// `SAS(ε=0, δ=1) ≡ probit` reduction hold on the FULL composition path (not
3748    /// just the fast short-circuit), and removes the `μ(ε)` cliff at `ε=0`.
3749    ///
3750    /// The old `B·tanh(x/B)` distorted the latent by `~1e-4` at every interior
3751    /// point, so (1) the full path returned `Φ(sinh(tanh_bound(asinh η))) ≈
3752    /// Φ(0.99987·η)` — off probit by `~2e-4` in μ — and (2) crossing the
3753    /// `|ε|<1e-12` fast-path threshold jumped μ between the two surfaces by that
3754    /// same `2e-4`. Both are gone: the full path is machine-exact probit, and
3755    /// μ is smooth through `ε=0`.
3756    #[test]
3757    fn sas_probit_reduction_is_exact_on_full_path_and_smooth_across_epsilon_zero() {
3758        let etas = [-3.0, -1.2, -0.3, 0.0, 0.4, 1.7, 3.0];
3759        for &eta in &etas {
3760            let probit = ProbitLinkKernel.jet(eta).expect("probit");
3761            // Full composition path, just outside the fast-path window in ε. With
3762            // the identity interior, `sinh(smooth_bound(asinh η)) = η` exactly, so
3763            // the whole jet collapses to probit up to sinh∘asinh round-off.
3764            for &eps in &[1e-11_f64, -1e-11] {
3765                let sas = sas_inverse_link_jet(eta, eps, 0.0).expect("finite SAS eta");
3766                // ε=1e-11 shifts the latent by 1e-11, a first-order μ move of
3767                // φ(η)·1e-11 ≲ 4e-12; everything beyond that must be < 1e-11.
3768                assert!(
3769                    (sas.mu - probit.mu).abs() < 1e-11,
3770                    "full-path μ off probit at eta={eta} eps={eps}: {} vs {}",
3771                    sas.mu,
3772                    probit.mu
3773                );
3774                assert!(
3775                    (sas.d1 - probit.d1).abs() < 1e-10,
3776                    "full-path d1 off probit at eta={eta} eps={eps}"
3777                );
3778            }
3779            // No cliff at ε=0: the fast-path value (ε exactly 0) and the full-path
3780            // values on either side agree to first order — the jump is O(ε), not
3781            // the old O(2e-4) surface gap.
3782            let center = sas_inverse_link_jet(eta, 0.0, 0.0).expect("fast path").mu;
3783            let lo = sas_inverse_link_jet(eta, -1e-11, 0.0).expect("full path").mu;
3784            let hi = sas_inverse_link_jet(eta, 1e-11, 0.0).expect("full path").mu;
3785            assert!(
3786                (hi - center).abs() < 1e-11 && (center - lo).abs() < 1e-11,
3787                "μ cliff across ε=0 at eta={eta}: lo={lo} center={center} hi={hi}"
3788            );
3789        }
3790    }
3791
3792    /// #2389 design point (b): all-order FD gate on the interior-exact bounded
3793    /// latent map's jet tower, at interior / splice / saturation points. The map
3794    /// underpins every SAS evaluation, yet the SAS-level tests only reach its
3795    /// interior (the splice needs `|δ·asinh(η)+ε| ∈ (0.8B, 1.2B)`, i.e. η≈1e17).
3796    /// This pins `smooth_bound_jet` directly: exact identities in the two flat
3797    /// regimes and at the seams, odd symmetry, non-expansiveness, and a
3798    /// derivative ladder (`d_{k} = d/dx d_{k-1}`) through fifth order in the
3799    /// splice — where a wrong smoothstep coefficient would otherwise hide.
3800    #[test]
3801    fn smooth_bound_jet_tower_is_c5_and_fd_exact() {
3802        let b = SAS_U_CLAMP;
3803        let a = SPLICE_INTERIOR_FRAC * b; // 40
3804        let c = (2.0 - SPLICE_INTERIOR_FRAC) * b; // 60
3805        let jet = |x: f64| smooth_bound_jet(x, b);
3806
3807        // Interior |x| ≤ a: exact identity, every higher derivative exactly 0.
3808        for &x in &[0.0, 0.5, 17.3, a - 1e-9, a] {
3809            let j = jet(x);
3810            assert_eq!(j.g, x, "interior identity value at x={x}");
3811            assert_eq!(j.d1, 1.0, "interior d1 at x={x}");
3812            assert_eq!((j.d2, j.d3, j.d4, j.d5), (0.0, 0.0, 0.0, 0.0));
3813        }
3814        // Saturation |x| ≥ c: exact ±B plateau, every derivative exactly 0.
3815        for &x in &[c, c + 1e-9, 75.0, 1e12, f64::MAX] {
3816            let j = jet(x);
3817            assert_eq!(j.g, b, "saturation value at x={x}");
3818            assert_eq!((j.d1, j.d2, j.d3, j.d4, j.d5), (0.0, 0.0, 0.0, 0.0, 0.0));
3819        }
3820        // Seam value + compactness: g(c) = B exactly (the c = 2B − a closure).
3821        assert_eq!(jet(c).g, b);
3822
3823        // Odd symmetry: g,d2,d4 flip sign; d1,d3,d5 are even.
3824        for &x in &[3.0, a - 2.0, 44.0, 50.0, 56.0, 100.0] {
3825            let p = jet(x);
3826            let m = jet(-x);
3827            assert_eq!(m.g, -p.g, "g odd at x={x}");
3828            assert_eq!(m.d1, p.d1, "d1 even at x={x}");
3829            assert_eq!(m.d2, -p.d2, "d2 odd at x={x}");
3830            assert_eq!(m.d3, p.d3, "d3 even at x={x}");
3831            assert_eq!(m.d4, -p.d4, "d4 odd at x={x}");
3832            assert_eq!(m.d5, p.d5, "d5 even at x={x}");
3833        }
3834
3835        // Non-expansive + monotone + bounded across the whole range.
3836        for i in 0..=240 {
3837            let x = -80.0 + 160.0 * (i as f64) / 240.0;
3838            let j = jet(x);
3839            assert!((0.0..=1.0).contains(&j.d1), "d1 out of [0,1] at x={x}: {}", j.d1);
3840            assert!(j.g.abs() <= b + 1e-12, "|g| exceeds B at x={x}: {}", j.g);
3841        }
3842
3843        // FD derivative ladder through fifth order, at splice-INTERIOR points
3844        // (kept ≥ 5 away from both seams so the O(h²) truncation stays small and
3845        // the h-stencil never straddles a regime change). d_k must be the
3846        // x-derivative of d_{k-1}; per-order tolerances track the realistic
3847        // central-difference error, still orders of magnitude below the O(1)
3848        // shift any wrong smoothstep coefficient would produce.
3849        let h = 0.02;
3850        for &x0 in &[45.0, 47.5, 50.0, 52.5, 55.0, -47.5, -52.5] {
3851            let jp = jet(x0 + h);
3852            let jm = jet(x0 - h);
3853            let j0 = jet(x0);
3854            let fd = |hi: f64, lo: f64| (hi - lo) / (2.0 * h);
3855            let checks = [
3856                ("d1", j0.d1, fd(jp.g, jm.g), 2e-6),
3857                ("d2", j0.d2, fd(jp.d1, jm.d1), 2e-5),
3858                ("d3", j0.d3, fd(jp.d2, jm.d2), 2e-4),
3859                ("d4", j0.d4, fd(jp.d3, jm.d3), 2e-3),
3860                ("d5", j0.d5, fd(jp.d4, jm.d4), 2e-2),
3861            ];
3862            for (name, analytic, numeric, tol) in checks {
3863                assert!(
3864                    (analytic - numeric).abs() < tol * (1.0 + analytic.abs()),
3865                    "smooth_bound {name} FD mismatch at x={x0}: analytic={analytic:e} fd={numeric:e}"
3866                );
3867            }
3868        }
3869    }
3870
3871    /// #2389 general-case follow-up: the cancellation-free SAS complement must
3872    /// live on the SAME latent surface as the forward inverse-link, so that
3873    /// `μ + (1−μ) = 1` holds to full precision across the ENTIRE finite-`f64`
3874    /// eta domain — including `|η| > 1.34e154`, where `η·η` overflows.
3875    ///
3876    /// The forward map routes `asinh` through the overflow-free [`asinh_jet5`]
3877    /// (`hypot`-based value with an asymptotic `ln|η|+ln2` fallback), but
3878    /// `sas_link_complement` used the raw `f64::asinh`, whose internal `x·x`
3879    /// overflows to `+∞` near the domain edge. With a compressing `δ<1` the true
3880    /// latent `δ·asinh(η)` stays deep in the map's identity interior (finite,
3881    /// unsaturated μ), yet the overflowing complement drove `u_raw→∞`, saturated
3882    /// to `±B`, and returned `Φ(∓sinh B)=0` — a full ~0.15 disagreement with the
3883    /// forward `1−μ`, silently poisoning any `log(1−μ)` tail term at extreme η.
3884    #[test]
3885    fn sas_link_complement_mirrors_forward_map_across_finite_domain() {
3886        let params = [
3887            (0.0, 0.0),     // fast probit reduction
3888            (0.25, -0.35),  // generic interior skew/scale
3889            (0.0, -6.0),    // strong compression δ≈2.5e-3
3890            (-0.5, -6.0),
3891            (0.3, 6.0),     // strong dilation δ≈4e2
3892        ];
3893        let etas = [
3894            -f64::MAX,
3895            -1e160, // η·η overflows here; asinh(η)≈-369 is finite and in range
3896            -1e6,
3897            -3.0,
3898            -0.2,
3899            0.0,
3900            0.4,
3901            3.0,
3902            1e6,
3903            1e160,
3904            f64::MAX,
3905        ];
3906        for &(epsilon, log_delta) in &params {
3907            for &eta in &etas {
3908                let mu = sas_inverse_link_jet(eta, epsilon, log_delta)
3909                    .expect("finite SAS eta")
3910                    .mu;
3911                let complement = sas_link_complement(eta, epsilon, log_delta, mu);
3912                assert!(
3913                    complement.is_finite() && (0.0..=1.0).contains(&complement),
3914                    "SAS complement out of [0,1] at η={eta} ε={epsilon} log_δ={log_delta}: {complement}"
3915                );
3916                // μ + (1−μ) = 1 on one consistent surface. Both endpoints are
3917                // Φ(±z) for the same z, so equality holds to erfc round-off.
3918                let sum = mu + complement;
3919                assert!(
3920                    (sum - 1.0).abs() < 1e-12,
3921                    "SAS complement off the forward surface at η={eta} ε={epsilon} \
3922                     log_δ={log_delta}: μ={mu} complement={complement} μ+comp={sum}"
3923                );
3924            }
3925        }
3926    }
3927
3928    #[test]
3929    fn beta_logistic_param_partials_matchfd() {
3930        let eta = -0.41;
3931        let delta = 0.23;
3932        let epsilon = -0.17;
3933        let out = beta_logistic_inverse_link_jetwith_param_partials(eta, delta, epsilon);
3934        let h = 1e-6;
3935
3936        let dp = beta_logistic_inverse_link_jet(eta, delta + h, epsilon);
3937        let dm = beta_logistic_inverse_link_jet(eta, delta - h, epsilon);
3938        let fd_delta = InverseLinkJet {
3939            mu: (dp.mu - dm.mu) / (2.0 * h),
3940            d1: (dp.d1 - dm.d1) / (2.0 * h),
3941            d2: (dp.d2 - dm.d2) / (2.0 * h),
3942            d3: (dp.d3 - dm.d3) / (2.0 * h),
3943        };
3944        assert_eq!(out.djet_dlog_delta.mu.signum(), fd_delta.mu.signum());
3945        assert_eq!(out.djet_dlog_delta.d1.signum(), fd_delta.d1.signum());
3946        assert_eq!(out.djet_dlog_delta.d2.signum(), fd_delta.d2.signum());
3947        assert_eq!(out.djet_dlog_delta.d3.signum(), fd_delta.d3.signum());
3948        assert!((out.djet_dlog_delta.mu - fd_delta.mu).abs() < 5e-5);
3949        assert!((out.djet_dlog_delta.d1 - fd_delta.d1).abs() < 5e-5);
3950        assert!((out.djet_dlog_delta.d2 - fd_delta.d2).abs() < 1.2e-4);
3951        assert!((out.djet_dlog_delta.d3 - fd_delta.d3).abs() < 4e-4);
3952
3953        let ep = beta_logistic_inverse_link_jet(eta, delta, epsilon + h);
3954        let em = beta_logistic_inverse_link_jet(eta, delta, epsilon - h);
3955        let fd_epsilon = InverseLinkJet {
3956            mu: (ep.mu - em.mu) / (2.0 * h),
3957            d1: (ep.d1 - em.d1) / (2.0 * h),
3958            d2: (ep.d2 - em.d2) / (2.0 * h),
3959            d3: (ep.d3 - em.d3) / (2.0 * h),
3960        };
3961        assert_eq!(out.djet_depsilon.mu.signum(), fd_epsilon.mu.signum());
3962        assert_eq!(out.djet_depsilon.d1.signum(), fd_epsilon.d1.signum());
3963        assert_eq!(out.djet_depsilon.d2.signum(), fd_epsilon.d2.signum());
3964        assert_eq!(out.djet_depsilon.d3.signum(), fd_epsilon.d3.signum());
3965        assert!((out.djet_depsilon.mu - fd_epsilon.mu).abs() < 5e-5);
3966        assert!((out.djet_depsilon.d1 - fd_epsilon.d1).abs() < 5e-5);
3967        assert!((out.djet_depsilon.d2 - fd_epsilon.d2).abs() < 1.2e-4);
3968        assert!((out.djet_depsilon.d3 - fd_epsilon.d3).abs() < 4e-4);
3969    }
3970
3971    #[test]
3972    fn beta_logistic_second_partials_obey_center_symmetry() {
3973        let out = beta_logistic_inverse_link_jetwith_param_partials(0.0, 0.37, 0.0);
3974        // At eta=0 and epsilon=0, a=b for every log-shape center, hence
3975        // I_{1/2}(a,a)=1/2 identically. Its pure epsilon and pure log-shape
3976        // second derivatives vanish by complement symmetry, while the density
3977        // is even in epsilon and therefore has zero mixed derivative there.
3978        assert_eq!(out.d2mu_dparams2[[0, 1]], out.d2mu_dparams2[[1, 0]]);
3979        assert_eq!(out.d2d1_dparams2[[0, 1]], out.d2d1_dparams2[[1, 0]]);
3980        assert!(out.d2mu_dparams2[[0, 0]].abs() < 1.0e-12);
3981        assert!(out.d2mu_dparams2[[1, 1]].abs() < 1.0e-12);
3982        assert!(out.d2d1_dparams2[[0, 1]].abs() < 1.0e-12);
3983    }
3984
3985    #[test]
3986    fn beta_logistic_left_tail_uses_unclamped_log_space() {
3987        let eta = -40.0_f64;
3988        let delta = 0.2_f64;
3989        let epsilon = -0.1_f64;
3990        let a = (delta - epsilon).exp();
3991        let b = (delta + epsilon).exp();
3992        let expected_mu = beta_reg(a, b, eta.exp());
3993        let out = beta_logistic_inverse_link_jet(eta, delta, epsilon);
3994
3995        assert!(
3996            (out.mu - expected_mu).abs() <= 1e-12 * expected_mu.abs().max(f64::MIN_POSITIVE),
3997            "left-tail mu mismatch: got {}, expected {}",
3998            out.mu,
3999            expected_mu
4000        );
4001        assert!(out.d1 > 0.0);
4002        assert!(out.d2 > 0.0);
4003        assert!(out.d3 > 0.0);
4004        assert!(out.d1 < 1e-20);
4005
4006        let partials = beta_logistic_inverse_link_jetwith_param_partials(eta, delta, epsilon);
4007        assert!(partials.jet.d1 > 0.0);
4008        assert!(partials.jet.d2 > 0.0);
4009        assert!(partials.jet.d3 > 0.0);
4010        assert!(partials.djet_dlog_delta.d1.is_finite());
4011        assert!(partials.djet_depsilon.d1.is_finite());
4012    }
4013
4014    #[test]
4015    fn beta_logistic_mu_is_symmetric_in_logistic_tails() {
4016        let delta = 0.2;
4017        let epsilon = -0.35;
4018        let etas = [-40.0, -30.0, -5.0, -0.42, 0.0, 0.42, 5.0, 30.0, 40.0];
4019        for eta in etas {
4020            let left = beta_logistic_inverse_link_jet(eta, delta, epsilon).mu;
4021            let right = 1.0 - beta_logistic_inverse_link_jet(-eta, delta, -epsilon).mu;
4022            assert!(
4023                (left - right).abs() <= 1e-14,
4024                "symmetry mismatch at eta={eta}: left={left}, right={right}"
4025            );
4026        }
4027    }
4028
4029    #[test]
4030    fn inverse_link_pdfthird_derivative_matches_d3_finite_difference() {
4031        let sas = InverseLink::Sas(sas_link_state_from_raw(-0.25, 0.35).expect("sas state"));
4032        let beta_logistic = InverseLink::BetaLogistic(SasLinkState {
4033            epsilon: 0.18,
4034            log_delta: -0.22,
4035            delta: (-0.22_f64).exp(),
4036        });
4037        let mixture = InverseLink::Mixture(
4038            state_fromspec(&MixtureLinkSpec {
4039                components: vec![
4040                    LinkComponent::Probit,
4041                    LinkComponent::Logit,
4042                    LinkComponent::CLogLog,
4043                    LinkComponent::Cauchit,
4044                ],
4045                initial_rho: Array1::from_vec(vec![0.35, -0.45, 0.2]),
4046            })
4047            .expect("mixture state"),
4048        );
4049        let links = [
4050            InverseLink::Standard(StandardLink::Probit),
4051            InverseLink::Standard(StandardLink::Logit),
4052            InverseLink::Standard(StandardLink::CLogLog),
4053            sas,
4054            beta_logistic,
4055            mixture,
4056        ];
4057        let etas = [-1.1, -0.2, 0.6];
4058        let h = 1e-5;
4059
4060        for link in &links {
4061            for &eta in &etas {
4062                let jp = inverse_link_jet_for_inverse_link(link, eta + h).expect("jet+");
4063                let jm = inverse_link_jet_for_inverse_link(link, eta - h).expect("jet-");
4064                let d4fd = (jp.d3 - jm.d3) / (2.0 * h);
4065                let d4 = inverse_link_pdfthird_derivative_for_inverse_link(link, eta)
4066                    .expect("analytic d4");
4067                assert_eq!(
4068                    d4.signum(),
4069                    d4fd.signum(),
4070                    "d4 sign mismatch for {:?} at eta={eta}: analytic={} fd={}",
4071                    link,
4072                    d4,
4073                    d4fd
4074                );
4075                assert!(
4076                    (d4 - d4fd).abs() < 5e-3,
4077                    "d4 mismatch for {:?} at eta={eta}: analytic={} fd={}",
4078                    link,
4079                    d4,
4080                    d4fd
4081                );
4082            }
4083        }
4084    }
4085
4086    #[test]
4087    fn cloglog_large_finite_eta_should_saturate_without_nan_derivatives() {
4088        let eta = 800.0;
4089        let jet = component_inverse_link_jet(LinkComponent::CLogLog, eta);
4090        assert_eq!(jet.mu, 1.0);
4091        assert!(
4092            jet.d1 == 0.0,
4093            "for mu(eta)=1-exp(-exp(eta)), dmu/deta = exp(eta-exp(eta)) and should underflow to 0 at eta={eta}; got d1={}",
4094            jet.d1
4095        );
4096        assert!(
4097            jet.d2 == 0.0,
4098            "the saturated cloglog second derivative should also be 0 at eta={eta}; got d2={}",
4099            jet.d2
4100        );
4101        assert!(
4102            jet.d3 == 0.0,
4103            "the saturated cloglog third derivative should also be 0 at eta={eta}; got d3={}",
4104            jet.d3
4105        );
4106
4107        let d4 = inverse_link_pdfthird_derivative_for_inverse_link(
4108            &InverseLink::Standard(StandardLink::CLogLog),
4109            eta,
4110        )
4111        .expect("cloglog d4");
4112        assert!(
4113            d4 == 0.0,
4114            "the saturated cloglog fourth derivative should also be 0 at eta={eta}; got d4={d4}"
4115        );
4116    }
4117
4118    #[test]
4119    fn loglog_large_negative_finite_eta_should_saturate_without_nan_derivatives() {
4120        let eta = -800.0;
4121        let jet = component_inverse_link_jet(LinkComponent::LogLog, eta);
4122        assert_eq!(jet.mu, 0.0);
4123        assert!(
4124            jet.d1 == 0.0,
4125            "for mu(eta)=exp(-exp(-eta)), dmu/deta = exp(-eta-exp(-eta)) and should underflow to 0 at eta={eta}; got d1={}",
4126            jet.d1
4127        );
4128        assert!(
4129            jet.d2 == 0.0,
4130            "the saturated loglog second derivative should also be 0 at eta={eta}; got d2={}",
4131            jet.d2
4132        );
4133        assert!(
4134            jet.d3 == 0.0,
4135            "the saturated loglog third derivative should also be 0 at eta={eta}; got d3={}",
4136            jet.d3
4137        );
4138
4139        let d4 = inverse_link_pdfthird_derivative_for_inverse_link(
4140            &InverseLink::Mixture(
4141                state_fromspec(&MixtureLinkSpec {
4142                    components: vec![LinkComponent::LogLog, LinkComponent::Probit],
4143                    initial_rho: Array1::from_vec(vec![12.0]),
4144                })
4145                .expect("mixture state"),
4146            ),
4147            eta,
4148        )
4149        .expect("loglog mixture d4");
4150        assert!(
4151            d4.is_finite(),
4152            "even a nearly pure loglog mixture should not produce NaN fourth derivatives at eta={eta}; got d4={d4}"
4153        );
4154    }
4155
4156    #[test]
4157    fn logit_tail_derivatives_should_match_stable_closed_forms() {
4158        let eta = 50.0_f64;
4159        let z = (-eta).exp();
4160        let denom = 1.0_f64 + z;
4161        let stable_d1 = z / denom.powi(2);
4162        let stable_d2 = z * (z - 1.0) / denom.powi(3);
4163        let stable_d3 = z * (z * z - 4.0 * z + 1.0) / denom.powi(4);
4164        let stable_d4 = z * (z * z * z - 11.0 * z * z + 11.0 * z - 1.0) / denom.powi(5);
4165        let stable_d5 =
4166            z * (z * z * z * z - 26.0 * z * z * z + 66.0 * z * z - 26.0 * z + 1.0) / denom.powi(6);
4167
4168        assert!(stable_d1 > 0.0);
4169        assert!(stable_d2 < 0.0);
4170        assert!(stable_d3 > 0.0);
4171        assert!(stable_d4 < 0.0);
4172        assert!(stable_d5 > 0.0);
4173
4174        let jet = component_inverse_link_jet(LinkComponent::Logit, eta);
4175        assert!(
4176            (jet.d1 - stable_d1).abs() < 1e-30,
4177            "logit d1 should equal the stable tail formula z/(1+z)^2 at eta={eta}; got {} vs {}",
4178            jet.d1,
4179            stable_d1
4180        );
4181        assert!(
4182            (jet.d2 - stable_d2).abs() < 1e-30,
4183            "logit d2 should equal the stable tail formula z(z-1)/(1+z)^3 at eta={eta}; got {} vs {}",
4184            jet.d2,
4185            stable_d2
4186        );
4187        assert!(
4188            (jet.d3 - stable_d3).abs() < 1e-30,
4189            "logit d3 should equal the stable tail formula z(z^2-4z+1)/(1+z)^4 at eta={eta}; got {} vs {}",
4190            jet.d3,
4191            stable_d3
4192        );
4193
4194        let d4 = inverse_link_pdfthird_derivative_for_inverse_link(
4195            &InverseLink::Standard(StandardLink::Logit),
4196            eta,
4197        )
4198        .expect("logit d4");
4199        assert!(
4200            (d4 - stable_d4).abs() < 1e-30,
4201            "logit d4 should equal the stable tail formula z(z^3-11z^2+11z-1)/(1+z)^5 at eta={eta}; got {} vs {}",
4202            d4,
4203            stable_d4
4204        );
4205
4206        let d5 = inverse_link_pdffourth_derivative_for_inverse_link(
4207            &InverseLink::Standard(StandardLink::Logit),
4208            eta,
4209        )
4210        .expect("logit d5");
4211        assert!(
4212            (d5 - stable_d5).abs() < 1e-30,
4213            "logit d5 should equal the stable tail formula z(z^4-26z^3+66z^2-26z+1)/(1+z)^6 at eta={eta}; got {} vs {}",
4214            d5,
4215            stable_d5
4216        );
4217    }
4218
4219    #[test]
4220    fn cloglog_negative_tail_value_should_match_expm1_form() {
4221        let eta = -50.0_f64;
4222        let t = eta.exp();
4223        let stable_mu = -(-t).exp_m1();
4224        assert!(stable_mu > 0.0);
4225
4226        let jet = component_inverse_link_jet(LinkComponent::CLogLog, eta);
4227        assert!(
4228            (jet.mu - stable_mu).abs() < 1e-30,
4229            "cloglog mu should equal -expm1(-exp(eta)) in the negative tail at eta={eta}; got {} vs {}",
4230            jet.mu,
4231            stable_mu
4232        );
4233    }
4234
4235    #[test]
4236    fn non_logit_probit_fisher_weight_jets_match_finite_differences() {
4237        fn rel_err(a: f64, b: f64) -> f64 {
4238            (a - b).abs() / a.abs().max(b.abs()).max(1.0e-8)
4239        }
4240
4241        let cases = [
4242            (LinkComponent::CLogLog, [-3.0_f64, -0.5, 0.4, 1.5]),
4243            (LinkComponent::LogLog, [-1.5_f64, -0.4, 0.5, 3.0]),
4244            (LinkComponent::Cauchit, [-3.0_f64, -0.7, 0.6, 3.0]),
4245        ];
4246        for (component, etas) in cases {
4247            for eta in etas {
4248                let (w, w1, w2, w3, w4) = component_fisher_weight_jet5(component, eta);
4249                let jet = component_inverse_link_jet(component, eta);
4250                let expected = jet.d1 * jet.d1 / (jet.mu * (1.0 - jet.mu));
4251                assert!(
4252                    rel_err(w, expected) < 1.0e-12,
4253                    "{component:?} Fisher weight mismatch at eta={eta}: got {w}, expected {expected}"
4254                );
4255
4256                let h = 1.0e-4;
4257                let fd1 = (component_fisher_weight_jet5(component, eta + h).0
4258                    - component_fisher_weight_jet5(component, eta - h).0)
4259                    / (2.0 * h);
4260                let fd2 = (component_fisher_weight_jet5(component, eta + h).1
4261                    - component_fisher_weight_jet5(component, eta - h).1)
4262                    / (2.0 * h);
4263                let fd3 = (component_fisher_weight_jet5(component, eta + h).2
4264                    - component_fisher_weight_jet5(component, eta - h).2)
4265                    / (2.0 * h);
4266                let fd4 = (component_fisher_weight_jet5(component, eta + h).3
4267                    - component_fisher_weight_jet5(component, eta - h).3)
4268                    / (2.0 * h);
4269
4270                assert!(
4271                    rel_err(w1, fd1) < 1.0e-5,
4272                    "{component:?} W' mismatch at eta={eta}: {w1} vs {fd1}"
4273                );
4274                assert!(
4275                    rel_err(w2, fd2) < 1.0e-5,
4276                    "{component:?} W'' mismatch at eta={eta}: {w2} vs {fd2}"
4277                );
4278                assert!(
4279                    rel_err(w3, fd3) < 5.0e-5,
4280                    "{component:?} W''' mismatch at eta={eta}: {w3} vs {fd3}"
4281                );
4282                assert!(
4283                    rel_err(w4, fd4) < 5.0e-4,
4284                    "{component:?} W'''' mismatch at eta={eta}: {w4} vs {fd4}"
4285                );
4286            }
4287        }
4288    }
4289
4290    #[test]
4291    fn mixture_fisher_weight_jet_covers_loglog_and_cauchit_components() {
4292        let state = state_fromspec(&MixtureLinkSpec {
4293            components: vec![
4294                LinkComponent::CLogLog,
4295                LinkComponent::LogLog,
4296                LinkComponent::Cauchit,
4297            ],
4298            initial_rho: Array1::from_vec(vec![0.3, -0.2]),
4299        })
4300        .expect("mixture state");
4301        let link = InverseLink::Mixture(state);
4302        assert!(
4303            link.has_fisher_weight_jet(),
4304            "anchored mixtures with loglog/cauchit components must remain eligible for Firth"
4305        );
4306        assert!(
4307            LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()).supports_firth(),
4308            "Firth support should use the mixture inverse-link Fisher jet, not standalone LinkFunction coverage"
4309        );
4310
4311        for eta in [-2.0_f64, -0.25, 0.75, 2.5] {
4312            let (w, w1, w2, w3, w4) =
4313                fisher_weight_jet5_for_inverse_link(&link, eta).expect("mixture Fisher jet");
4314            for value in [w, w1, w2, w3, w4] {
4315                assert!(
4316                    value.is_finite(),
4317                    "mixture Fisher weight jet should be finite at eta={eta}; got {value}"
4318                );
4319            }
4320            assert!(
4321                w > 0.0,
4322                "mixture Fisher working weight should be positive away from saturated tails at eta={eta}; got {w}"
4323            );
4324        }
4325    }
4326
4327    #[test]
4328    fn loglog_fifth_derivative_should_match_closed_form_sign() {
4329        let eta = 0.0_f64;
4330        let r = (-eta).exp();
4331        let expected =
4332            (-r).exp() * (r - 15.0 * r * r + 25.0 * r.powi(3) - 10.0 * r.powi(4) + r.powi(5));
4333        let d5 = component_inverse_link_pdffourth_derivative(LinkComponent::LogLog, eta);
4334        assert!(
4335            (d5 - expected).abs() < 1e-15,
4336            "loglog d5 should equal exp(-r) * (r - 15r^2 + 25r^3 - 10r^4 + r^5) at eta={eta}; got {d5} vs {expected}"
4337        );
4338        assert!(d5 > 0.0, "loglog d5 should be positive at eta=0; got {d5}");
4339    }
4340}