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