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
993pub fn state_fromspec(spec: &MixtureLinkSpec) -> Result<MixtureLinkState, String> {
994    validate_mixturespec(spec)?;
995    let pi = softmax_last_fixedzero(&spec.initial_rho);
996    Ok(MixtureLinkState {
997        components: spec.components.clone(),
998        rho: spec.initial_rho.clone(),
999        pi,
1000    })
1001}
1002
1003#[inline]
1004pub fn component_inverse_link_jet(component: LinkComponent, eta: f64) -> InverseLinkJet {
1005    canonicalize_jet(match component {
1006        LinkComponent::Logit => {
1007            let jet = logit_inverse_link_jet5(eta);
1008            InverseLinkJet {
1009                mu: jet.mu,
1010                d1: jet.d1,
1011                d2: jet.d2,
1012                d3: jet.d3,
1013            }
1014        }
1015        LinkComponent::Probit => probit_jet(eta),
1016        LinkComponent::CLogLog => {
1017            if eta.is_nan() {
1018                return InverseLinkJet {
1019                    mu: f64::NAN,
1020                    d1: f64::NAN,
1021                    d2: f64::NAN,
1022                    d3: f64::NAN,
1023                };
1024            }
1025            let t = eta.exp();
1026            if !t.is_finite() {
1027                return InverseLinkJet {
1028                    mu: 1.0,
1029                    d1: 0.0,
1030                    d2: 0.0,
1031                    d3: 0.0,
1032                };
1033            }
1034            InverseLinkJet {
1035                mu: -(-t).exp_m1(),
1036                d1: stable_nonnegative_poly_times_exp_neg(t, &[0.0, 1.0]),
1037                d2: stable_nonnegative_poly_times_exp_neg(t, &[0.0, 1.0, -1.0]),
1038                d3: stable_nonnegative_poly_times_exp_neg(t, &[0.0, 1.0, -3.0, 1.0]),
1039            }
1040        }
1041        LinkComponent::LogLog => {
1042            if eta.is_nan() {
1043                return InverseLinkJet {
1044                    mu: f64::NAN,
1045                    d1: f64::NAN,
1046                    d2: f64::NAN,
1047                    d3: f64::NAN,
1048                };
1049            }
1050            let r = (-eta).exp();
1051            if !r.is_finite() {
1052                return InverseLinkJet {
1053                    mu: 0.0,
1054                    d1: 0.0,
1055                    d2: 0.0,
1056                    d3: 0.0,
1057                };
1058            }
1059            InverseLinkJet {
1060                mu: (-r).exp(),
1061                d1: stable_nonnegative_poly_times_exp_neg(r, &[0.0, 1.0]),
1062                d2: stable_nonnegative_poly_times_exp_neg(r, &[0.0, -1.0, 1.0]),
1063                d3: stable_nonnegative_poly_times_exp_neg(r, &[0.0, 1.0, -3.0, 1.0]),
1064            }
1065        }
1066        LinkComponent::Cauchit => {
1067            if eta.is_nan() {
1068                return InverseLinkJet {
1069                    mu: f64::NAN,
1070                    d1: f64::NAN,
1071                    d2: f64::NAN,
1072                    d3: f64::NAN,
1073                };
1074            }
1075            let den = 1.0 + eta * eta;
1076            let d1 = if eta.is_finite() {
1077                1.0 / (std::f64::consts::PI * den)
1078            } else {
1079                0.0
1080            };
1081            let d2 = if eta.is_finite() {
1082                -2.0 * eta / (std::f64::consts::PI * den * den)
1083            } else {
1084                0.0
1085            };
1086            let d3 = if eta.is_finite() {
1087                (6.0 * eta * eta - 2.0) / (std::f64::consts::PI * den * den * den)
1088            } else {
1089                0.0
1090            };
1091            InverseLinkJet {
1092                mu: 0.5 + eta.atan() / std::f64::consts::PI,
1093                d1,
1094                d2,
1095                d3,
1096            }
1097        }
1098    })
1099}
1100
1101impl InverseLinkKernel for ProbitLinkKernel {
1102    #[inline]
1103    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1104        Ok(component_inverse_link_jet(LinkComponent::Probit, eta))
1105    }
1106}
1107
1108impl InverseLinkKernel for LogitLinkKernel {
1109    #[inline]
1110    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1111        Ok(component_inverse_link_jet(LinkComponent::Logit, eta))
1112    }
1113}
1114
1115impl InverseLinkKernel for CLogLogLinkKernel {
1116    #[inline]
1117    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1118        Ok(component_inverse_link_jet(LinkComponent::CLogLog, eta))
1119    }
1120}
1121
1122impl InverseLinkKernel for LogLogLinkKernel {
1123    #[inline]
1124    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1125        Ok(component_inverse_link_jet(LinkComponent::LogLog, eta))
1126    }
1127}
1128
1129impl InverseLinkKernel for CauchitLinkKernel {
1130    #[inline]
1131    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1132        Ok(component_inverse_link_jet(LinkComponent::Cauchit, eta))
1133    }
1134}
1135
1136impl InverseLinkKernel for LinkComponent {
1137    #[inline]
1138    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1139        Ok(component_inverse_link_jet(*self, eta))
1140    }
1141}
1142
1143impl InverseLinkKernel for LinkFunction {
1144    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1145        match self {
1146            LinkFunction::Logit => LogitLinkKernel.jet(eta),
1147            LinkFunction::Probit => ProbitLinkKernel.jet(eta),
1148            LinkFunction::CLogLog => CLogLogLinkKernel.jet(eta),
1149            LinkFunction::LogLog => LogLogLinkKernel.jet(eta),
1150            LinkFunction::Cauchit => CauchitLinkKernel.jet(eta),
1151            LinkFunction::Identity => Ok(InverseLinkJet {
1152                mu: eta,
1153                d1: 1.0,
1154                d2: 0.0,
1155                d3: 0.0,
1156            }),
1157            LinkFunction::Log => {
1158                // A projected value with unprojected exp derivatives is not a jet:
1159                // outside the projection interval the value is constant but the
1160                // old implementation returned a nonzero derivative. Evaluate the
1161                // exact exponential on the declared solver domain and refuse every
1162                // other eta through the typed error channel. Public response-scale
1163                // transforms remain unrestricted and use their separate exact-exp
1164                // path below (issue #963).
1165                let e = log_link_solver_exp(eta)?;
1166                Ok(InverseLinkJet {
1167                    mu: e,
1168                    d1: e,
1169                    d2: e,
1170                    d3: e,
1171                })
1172            }
1173            LinkFunction::Sas => Err(EstimationError::InvalidInput(
1174                "LinkFunction::Sas inverse-link requires explicit SAS link state".to_string(),
1175            )),
1176            LinkFunction::BetaLogistic => Err(EstimationError::InvalidInput(
1177                "LinkFunction::BetaLogistic inverse-link requires explicit Beta-Logistic link state"
1178                    .to_string(),
1179            )),
1180        }
1181    }
1182}
1183
1184impl InverseLinkKernel for SasLinkState {
1185    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1186        sas_inverse_link_jet(eta, self.epsilon, self.log_delta)
1187    }
1188
1189    fn param_partials(&self, eta: f64) -> Result<Option<LinkParamPartials>, EstimationError> {
1190        Ok(Some(LinkParamPartials::Sas(
1191            sas_inverse_link_jetwith_param_partials(eta, self.epsilon, self.log_delta)?,
1192        )))
1193    }
1194}
1195
1196#[derive(Clone, Copy, Debug)]
1197pub struct BetaLogisticKernel {
1198    /// Unconstrained log of the geometric-mean beta shape — the raw optimization
1199    /// parameter `SasLinkState::log_delta`, NOT the derived `SasLinkState::delta`.
1200    pub log_shape_center: f64,
1201    pub epsilon: f64,
1202}
1203
1204impl InverseLinkKernel for BetaLogisticKernel {
1205    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1206        Ok(beta_logistic_inverse_link_jet(
1207            eta,
1208            self.log_shape_center,
1209            self.epsilon,
1210        ))
1211    }
1212
1213    fn param_partials(&self, eta: f64) -> Result<Option<LinkParamPartials>, EstimationError> {
1214        Ok(Some(LinkParamPartials::Sas(
1215            beta_logistic_inverse_link_jetwith_param_partials(
1216                eta,
1217                self.log_shape_center,
1218                self.epsilon,
1219            ),
1220        )))
1221    }
1222}
1223
1224impl InverseLinkKernel for MixtureLinkState {
1225    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1226        Ok(mixture_inverse_link_jet(self, eta))
1227    }
1228
1229    fn param_partials(&self, eta: f64) -> Result<Option<LinkParamPartials>, EstimationError> {
1230        Ok(Some(LinkParamPartials::Mixture(
1231            mixture_inverse_link_jetwith_rho_partials(self, eta),
1232        )))
1233    }
1234}
1235
1236impl InverseLinkKernel for InverseLink {
1237    fn jet(&self, eta: f64) -> Result<InverseLinkJet, EstimationError> {
1238        match self {
1239            InverseLink::Standard(StandardLink::Logit) => LogitLinkKernel.jet(eta),
1240            InverseLink::Standard(StandardLink::Probit) => ProbitLinkKernel.jet(eta),
1241            InverseLink::Standard(StandardLink::CLogLog) => CLogLogLinkKernel.jet(eta),
1242            InverseLink::Standard(StandardLink::LogLog) => LogLogLinkKernel.jet(eta),
1243            InverseLink::Standard(StandardLink::Cauchit) => CauchitLinkKernel.jet(eta),
1244            InverseLink::Standard(StandardLink::Identity) => LinkFunction::Identity.jet(eta),
1245            InverseLink::Standard(StandardLink::Log) => LinkFunction::Log.jet(eta),
1246            InverseLink::LatentCLogLog(state) => latent_cloglog_point_jet(state, eta),
1247            InverseLink::Sas(state) => state.jet(eta),
1248            InverseLink::BetaLogistic(state) => BetaLogisticKernel {
1249                log_shape_center: state.log_delta,
1250                epsilon: state.epsilon,
1251            }
1252            .jet(eta),
1253            InverseLink::Mixture(state) => state.jet(eta),
1254        }
1255    }
1256
1257    fn param_partials(&self, eta: f64) -> Result<Option<LinkParamPartials>, EstimationError> {
1258        match self {
1259            InverseLink::Standard(_) => Ok(None),
1260            InverseLink::LatentCLogLog(_) => Ok(None),
1261            InverseLink::Sas(state) => state.param_partials(eta),
1262            InverseLink::BetaLogistic(state) => BetaLogisticKernel {
1263                log_shape_center: state.log_delta,
1264                epsilon: state.epsilon,
1265            }
1266            .param_partials(eta),
1267            InverseLink::Mixture(state) => state.param_partials(eta),
1268        }
1269    }
1270}
1271
1272/// Central family-aware inverse-link jet dispatch.
1273///
1274/// For `BinomialSas` and `BinomialMixture`, required state must be provided.
1275/// The standard log link is defined here only on the inclusive solver domain
1276/// [`LOG_LINK_SOLVER_ETA_MIN`] through [`LOG_LINK_SOLVER_ETA_MAX`]; inputs
1277/// outside it return [`EstimationError::InverseLinkDomainViolation`].
1278pub fn inverse_link_jet_for_inverse_link(
1279    link: &InverseLink,
1280    eta: f64,
1281) -> Result<InverseLinkJet, EstimationError> {
1282    link.jet(eta)
1283}
1284
1285/// Specialized `(mu, d1)` inverse-link evaluation that skips the d2/d3
1286/// polynomial chain used by the full jet. Numerical semantics are preserved:
1287/// the returned `mu` and `d1` are bit-identical to the corresponding fields of
1288/// `inverse_link_jet_for_inverse_link(link, eta)?` for every supported link.
1289///
1290/// For latent cloglog the underlying lognormal-Laplace kernel produces all
1291/// orders together, so this falls back to the full jet for that branch — the
1292/// savings come from the parameterised polynomial links (SAS, beta-logistic,
1293/// mixture) and the simple analytic links where d2/d3 are pure waste.
1294/// Standard-log inputs obey the same solver domain as the full jet.
1295pub fn inverse_link_mu_d1_for_inverse_link(
1296    link: &InverseLink,
1297    eta: f64,
1298) -> Result<(f64, f64), EstimationError> {
1299    match link {
1300        InverseLink::Standard(link_fn) => Ok(link_function_mu_d1(link_fn.as_link_function(), eta)?),
1301        InverseLink::LatentCLogLog(state) => {
1302            let jet = latent_cloglog_point_jet(state, eta)?;
1303            Ok((jet.mu, jet.d1))
1304        }
1305        InverseLink::Sas(state) => sas_inverse_link_mu_d1(eta, state.epsilon, state.log_delta),
1306        InverseLink::BetaLogistic(state) => Ok(beta_logistic_inverse_link_mu_d1(
1307            eta,
1308            state.log_delta,
1309            state.epsilon,
1310        )),
1311        InverseLink::Mixture(state) => Ok(mixture_inverse_link_mu_d1(state, eta)),
1312    }
1313}
1314
1315/// Stable complement `1 - mu(eta)` for a Bernoulli inverse link, evaluated
1316/// directly from `eta` rather than as `1.0 - mu`.
1317///
1318/// The forward `mu` rounds to exactly `1.0` in f64 far inside the tail — cloglog
1319/// at `eta ≈ 3.62`, probit at `eta ≈ 8.29` — after which the naive `1.0 - mu` is
1320/// a hard zero even though the true complement is a representable quantity down
1321/// to `~1e-300`. Both the Bernoulli variance `mu(1-mu)` and the working residual
1322/// `y - mu` depend on that complement, so recovering it exactly is what lets a
1323/// saturating cloglog/probit row proceed instead of being refused. This mirrors
1324/// the tail-complement already carried on the canonical logit path.
1325///
1326/// Each link with a cancellation-free closed form for `1 - mu` uses it; links
1327/// without one fall back to `1.0 - mu` (unchanged behaviour). The complement is
1328/// clamped into `[0, 1]` only against round-off just past the boundary.
1329pub(crate) fn inverse_link_complement_for_inverse_link(
1330    link: &InverseLink,
1331    eta: f64,
1332    mu: f64,
1333) -> f64 {
1334    let raw = match link {
1335        InverseLink::Standard(link_fn) => standard_link_complement(*link_fn, eta, mu),
1336        InverseLink::Sas(state) => sas_link_complement(eta, state.epsilon, state.log_delta, mu),
1337        InverseLink::BetaLogistic(state) => {
1338            beta_logistic_link_complement(eta, state.log_delta, state.epsilon, mu)
1339        }
1340        InverseLink::Mixture(state) => mixture_link_complement(state, eta, mu),
1341        // The latent-cloglog mean is a lognormal-Laplace quadrature
1342        // (`latent_cloglog_jet5`), whose kernel reports `mean` and its
1343        // derivatives but not the complementary `E[exp(-Z e^eta)]` the exact
1344        // complement would need. Until that kernel exposes the survival output,
1345        // the naive complement leaves this link's saturation behaviour exactly as
1346        // it was, so it retains the `V = mu(1-mu) -> 0` limitation the sibling
1347        // links no longer have.
1348        InverseLink::LatentCLogLog(_) => 1.0 - mu,
1349    };
1350    if raw.is_nan() {
1351        raw
1352    } else {
1353        raw.clamp(0.0, 1.0)
1354    }
1355}
1356
1357/// Cancellation-free `1 - mu(eta)` for the standard Bernoulli links.
1358#[inline]
1359fn standard_link_complement(link: StandardLink, eta: f64, mu: f64) -> f64 {
1360    match link {
1361        StandardLink::Probit => {
1362            // 1 - Phi(eta) = Phi(-eta); the reflected CDF keeps the tiny upper-tail
1363            // mass that `1 - Phi(eta)` cancels away.
1364            if eta.is_nan() {
1365                f64::NAN
1366            } else if eta == f64::INFINITY {
1367                0.0
1368            } else if eta == f64::NEG_INFINITY {
1369                1.0
1370            } else {
1371                normal_cdf(-eta)
1372            }
1373        }
1374        StandardLink::CLogLog => {
1375            // mu = 1 - exp(-exp(eta))  =>  1 - mu = exp(-exp(eta)).
1376            if eta.is_nan() {
1377                f64::NAN
1378            } else {
1379                let t = eta.exp();
1380                if !t.is_finite() { 0.0 } else { (-t).exp() }
1381            }
1382        }
1383        StandardLink::LogLog => {
1384            // mu = exp(-exp(-eta))  =>  1 - mu = -expm1(-exp(-eta)).
1385            if eta.is_nan() {
1386                f64::NAN
1387            } else {
1388                let r = (-eta).exp();
1389                if !r.is_finite() { 1.0 } else { -(-r).exp_m1() }
1390            }
1391        }
1392        StandardLink::Cauchit => {
1393            // mu = 1/2 + atan(eta)/pi  =>  1 - mu = 1/2 - atan(eta)/pi. For eta > 0
1394            // this cancels toward zero; atan(1/eta) = pi/2 - atan(eta) exactly, so
1395            // 1 - mu = atan(1/eta)/pi with no loss.
1396            if eta.is_nan() {
1397                f64::NAN
1398            } else if !eta.is_finite() {
1399                if eta > 0.0 { 0.0 } else { 1.0 }
1400            } else if eta > 0.0 {
1401                (1.0 / eta).atan() / std::f64::consts::PI
1402            } else {
1403                0.5 - eta.atan() / std::f64::consts::PI
1404            }
1405        }
1406        // Logit carries its own tail complement on the canonical path; identity
1407        // and log are not Bernoulli-variance links. The naive complement is
1408        // exact enough for these here.
1409        StandardLink::Logit | StandardLink::Identity | StandardLink::Log => 1.0 - mu,
1410    }
1411}
1412
1413/// Cancellation-free `1 - mu(eta)` for the beta-logistic inverse link.
1414///
1415/// `mu = I_x(a, b)` with `x = logistic(eta)`, so the exact complement is the
1416/// regularized incomplete beta's own reflection identity
1417/// `1 - I_x(a, b) = I_{1-x}(b, a)`, and `1 - x = logistic(-eta)` is already
1418/// carried alongside `x` by [`logistic_uwith_derivatives`]. On the saturated side
1419/// (`use_upper_tail`) the forward map computes `mu` AS `1 - beta_reg(b, a, 1-x)`,
1420/// so the complement is that `beta_reg` call with no subtraction at all — the
1421/// tail mass the forward `1 - ...` throws away. On the other side `mu` is small
1422/// and `1.0 - mu` loses nothing.
1423#[inline]
1424fn beta_logistic_link_complement(eta: f64, log_delta: f64, epsilon: f64, mu: f64) -> f64 {
1425    let logistic = logistic_uwith_derivatives(eta);
1426    if logistic.ln_u.is_nan() || logistic.ln_one_minus_u.is_nan() {
1427        return f64::NAN;
1428    }
1429    if logistic.ln_u == f64::NEG_INFINITY {
1430        return 1.0;
1431    }
1432    if logistic.ln_one_minus_u == f64::NEG_INFINITY {
1433        return 0.0;
1434    }
1435    let (a, b) = beta_logistic_shapes(log_delta, epsilon);
1436    if logistic.use_upper_tail {
1437        beta_reg(b, a, logistic.one_minus_u)
1438    } else {
1439        1.0 - mu
1440    }
1441}
1442
1443/// Cancellation-free `1 - mu(eta)` for the mixture inverse link.
1444///
1445/// `mu = sum_i pi_i mu_i`, so
1446/// `1 - mu = (1 - sum_i pi_i) + sum_i pi_i (1 - mu_i)` — exact for any weight
1447/// vector, and each component is one of the standard bounded links whose own
1448/// complement is already cancellation-free. Summing the component TAILS keeps a
1449/// mixture whose components all saturate from returning a hard zero: every
1450/// `mu_i` rounds to `1.0` while every `1 - mu_i` is still representable.
1451#[inline]
1452fn mixture_link_complement(state: &MixtureLinkState, eta: f64, mu: f64) -> f64 {
1453    let k = state.components.len().min(state.pi.len());
1454    let mut weight_total = 0.0_f64;
1455    let mut complement = 0.0_f64;
1456    for i in 0..k {
1457        let (mu_i, _) = component_inverse_link_mu_d1(state.components[i], eta);
1458        if mu_i.is_nan() {
1459            return f64::NAN;
1460        }
1461        let component_complement =
1462            standard_link_complement(state.components[i].as_standard_link(), eta, mu_i);
1463        if component_complement.is_nan() {
1464            return f64::NAN;
1465        }
1466        weight_total += state.pi[i];
1467        complement += state.pi[i] * component_complement;
1468    }
1469    if k == 0 {
1470        return 1.0 - mu;
1471    }
1472    (1.0 - weight_total) + complement
1473}
1474
1475/// Cancellation-free `1 - mu` for the SAS inverse link. `mu = Phi(z)` with
1476/// `z = sinh(smooth_bound(delta*asinh(eta) + epsilon, SAS_U_CLAMP))`, so the exact
1477/// complement is `Phi(-z)`, mirroring the `sas_inverse_link_mu_d1` forward map.
1478/// `Phi(-z)` keeps the tiny upper-tail mass that `1 - Phi(z)` cancels; it
1479/// underflows to `0` only once the row is genuinely fully saturated (`z` at the
1480/// `SAS_U_CLAMP` sinh scale), which is the correct value there. `Phi(-z)` is
1481/// always in `[0, 1]`, so no clamp is needed.
1482///
1483/// The latent `asinh(eta)` is taken through the overflow-free [`asinh_jet5`]
1484/// value — exactly as `sas_inverse_link_mu_d1` does — NOT the raw `f64::asinh`.
1485/// The library `asinh` forms `x·x` internally, which overflows to `±∞` for
1486/// `|eta| > 1.34e154` even though `asinh(±f64::MAX) ≈ ±710` is finite and well
1487/// inside range. With a compressing `delta < 1` the true latent `delta·asinh`
1488/// then stays in the map's identity interior (finite, unsaturated `mu`), so an
1489/// overflowing complement would saturate to `±B` and return `Phi(∓sinh B) ∈
1490/// {0,1}` — disagreeing with the forward `1 - mu` by up to ~0.15 and poisoning
1491/// any `log(1 - mu)` tail term at extreme eta (#2389).
1492#[inline]
1493pub(crate) fn sas_link_complement(eta: f64, epsilon: f64, log_delta: f64, mu: f64) -> f64 {
1494    let eta = match finite_inverse_link_eta("SAS inverse link complement", eta) {
1495        Ok(value) => value,
1496        Err(_) => return 1.0 - mu,
1497    };
1498    let delta = sas_delta_from_raw_log_delta(log_delta);
1499    if epsilon.abs() < 1e-12 && (delta - 1.0).abs() < 1e-12 {
1500        return standard_link_complement(StandardLink::Probit, eta, mu);
1501    }
1502    let u_raw = delta * asinh_jet5(eta).value + epsilon;
1503    let u = smooth_bound_jet(u_raw, SAS_U_CLAMP).g;
1504    normal_cdf(-u.sinh())
1505}
1506
1507/// The SAS link's latent probit argument `z` and its first `eta` derivative.
1508///
1509/// `mu = Phi(z)` with `z = sinh(smooth_bound(delta·asinh(eta) + epsilon,
1510/// SAS_U_CLAMP))`, exactly as [`sas_inverse_link_mu_d1`] evaluates it — this
1511/// returns the *pre-probit* pair instead of applying `Phi`.
1512///
1513/// A consumer that needs `ln mu` or `ln(1 - mu)` must have this pair, not `mu`:
1514/// past `z ≈ -38` the mean underflows to exactly `0.0` and past `z ≈ +8.3` the
1515/// complement does, so `mu.ln()` / `(1 - mu).ln()` are `-inf` on a row whose
1516/// true log-probability is a perfectly ordinary finite number (`ln Phi(-59.45)
1517/// = -1774.6`). Evaluating `ln Phi(±z)` from `z` keeps the whole saturating
1518/// band representable, which is what the standard probit link already does
1519/// through `signed_probit_logcdf_and_mills_ratio` and what SAS — the *same*
1520/// probit CDF, reparameterized — had no route to.
1521///
1522/// The derivative is the chain factor `dz/deta`; a consumer converts a
1523/// `d/dz` score into a `d/deta` score by multiplying by it. Inside the
1524/// `smooth_bound` saturation band `dz/deta` is exactly `0`, which is the
1525/// correct value: `mu` is genuinely constant in `eta` there.
1526///
1527/// At `epsilon = 0, delta = 1` this returns `(eta, 1.0)` bitwise, so a SAS link
1528/// at its identity parameters and the standard probit link produce the *same*
1529/// geometry rather than two numerically different ones.
1530pub(crate) fn sas_latent_probit_argument(
1531    eta: f64,
1532    epsilon: f64,
1533    log_delta: f64,
1534) -> Result<(f64, f64), EstimationError> {
1535    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
1536    let delta = sas_delta_from_raw_log_delta(log_delta);
1537    if epsilon.abs() < 1e-12 && (delta - 1.0).abs() < 1e-12 {
1538        return Ok((eta, 1.0));
1539    }
1540    let asinh = asinh_jet5(eta);
1541    let u_raw = delta * asinh.value + epsilon;
1542    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
1543    let u = sb.g;
1544    let c = u.cosh();
1545    let r1 = delta * asinh.d1;
1546    let u1 = sb.d1 * r1;
1547    Ok((u.sinh(), c * u1))
1548}
1549
1550fn link_function_mu_d1(link: LinkFunction, eta: f64) -> Result<(f64, f64), EstimationError> {
1551    match link {
1552        LinkFunction::Identity => Ok((eta, 1.0)),
1553        LinkFunction::Log => {
1554            // Keep the fast seam mathematically identical to the full jet: exact
1555            // exp and exact exp derivative on the same declared solver domain.
1556            let e = log_link_solver_exp(eta)?;
1557            Ok((e, e))
1558        }
1559        LinkFunction::Logit => Ok(component_inverse_link_mu_d1(LinkComponent::Logit, eta)),
1560        LinkFunction::Probit => Ok(component_inverse_link_mu_d1(LinkComponent::Probit, eta)),
1561        LinkFunction::CLogLog => Ok(component_inverse_link_mu_d1(LinkComponent::CLogLog, eta)),
1562        LinkFunction::LogLog => Ok(component_inverse_link_mu_d1(LinkComponent::LogLog, eta)),
1563        LinkFunction::Cauchit => Ok(component_inverse_link_mu_d1(LinkComponent::Cauchit, eta)),
1564        LinkFunction::Sas => Err(EstimationError::InvalidInput(
1565            "LinkFunction::Sas inverse-link requires explicit SAS link state".to_string(),
1566        )),
1567        LinkFunction::BetaLogistic => Err(EstimationError::InvalidInput(
1568            "LinkFunction::BetaLogistic inverse-link requires explicit Beta-Logistic link state"
1569                .to_string(),
1570        )),
1571    }
1572}
1573
1574#[inline]
1575fn component_inverse_link_mu_d1(component: LinkComponent, eta: f64) -> (f64, f64) {
1576    // The full per-component jet already factors `mu` and `d1` exactly the same
1577    // way the higher orders are derived, so we either reuse the cheap closed
1578    // forms directly (Logit/Probit/CLogLog/LogLog/Cauchit) or fall back to the
1579    // existing canonicalised jet for the few cases without a separate fast
1580    // path — bit-identical to `component_inverse_link_jet(...).{mu,d1}`.
1581    match component {
1582        LinkComponent::Logit => {
1583            let jet = logit_inverse_link_jet5(eta);
1584            (jet.mu, canonicalzero(jet.d1))
1585        }
1586        LinkComponent::Probit => {
1587            if eta.is_nan() {
1588                return (f64::NAN, f64::NAN);
1589            }
1590            if eta == f64::INFINITY {
1591                return (1.0, 0.0);
1592            }
1593            if eta == f64::NEG_INFINITY {
1594                return (0.0, 0.0);
1595            }
1596            let phi = normal_pdf(eta);
1597            (normal_cdf(eta), canonicalzero(phi))
1598        }
1599        LinkComponent::CLogLog => {
1600            if eta.is_nan() {
1601                return (f64::NAN, f64::NAN);
1602            }
1603            let t = eta.exp();
1604            if !t.is_finite() {
1605                return (1.0, 0.0);
1606            }
1607            (
1608                -(-t).exp_m1(),
1609                canonicalzero(stable_nonnegative_poly_times_exp_neg(t, &[0.0, 1.0])),
1610            )
1611        }
1612        LinkComponent::LogLog => {
1613            if eta.is_nan() {
1614                return (f64::NAN, f64::NAN);
1615            }
1616            let r = (-eta).exp();
1617            if !r.is_finite() {
1618                return (0.0, 0.0);
1619            }
1620            (
1621                (-r).exp(),
1622                canonicalzero(stable_nonnegative_poly_times_exp_neg(r, &[0.0, 1.0])),
1623            )
1624        }
1625        LinkComponent::Cauchit => {
1626            if eta.is_nan() {
1627                return (f64::NAN, f64::NAN);
1628            }
1629            let den = 1.0 + eta * eta;
1630            let d1 = if eta.is_finite() {
1631                1.0 / (std::f64::consts::PI * den)
1632            } else {
1633                0.0
1634            };
1635            (0.5 + eta.atan() / std::f64::consts::PI, canonicalzero(d1))
1636        }
1637    }
1638}
1639
1640fn sas_inverse_link_mu_d1(
1641    eta: f64,
1642    epsilon: f64,
1643    log_delta: f64,
1644) -> Result<(f64, f64), EstimationError> {
1645    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
1646    let delta_id = sas_delta_from_raw_log_delta(log_delta);
1647    if epsilon.abs() < 1e-12 && (delta_id - 1.0).abs() < 1e-12 {
1648        return Ok(component_inverse_link_mu_d1(LinkComponent::Probit, eta));
1649    }
1650    let asinh = asinh_jet5(eta);
1651    let delta = delta_id;
1652    let u_raw = delta * asinh.value + epsilon;
1653    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
1654    let u = sb.g;
1655    let g1 = sb.d1;
1656    let s = u.sinh();
1657    let c = u.cosh();
1658    let z = s;
1659    let r1 = delta * asinh.d1;
1660    let u1 = g1 * r1;
1661    let z1 = c * u1;
1662    // `mu = Phi(z)` and `d1 = phi(z) * z1`, the same closed forms used by the
1663    // full jet via `chain_inverse_link_jet(probit_jet(z), z1, _, _)`.
1664    let base = probit_jet(z);
1665    Ok((base.mu, canonicalzero(base.d1 * z1)))
1666}
1667
1668fn beta_logistic_inverse_link_mu_d1(eta: f64, delta: f64, epsilon: f64) -> (f64, f64) {
1669    let logistic = logistic_uwith_derivatives(eta);
1670    let (a, b) = beta_logistic_shapes(delta, epsilon);
1671    let mu = beta_reg_logistic(a, b, logistic);
1672    let log_d1 = beta_logistic_log_d1(a, b, logistic);
1673    (mu, log_d1.exp())
1674}
1675
1676fn mixture_inverse_link_mu_d1(state: &MixtureLinkState, eta: f64) -> (f64, f64) {
1677    let mut mu = 0.0_f64;
1678    let mut d1 = 0.0_f64;
1679    let k = state.components.len().min(state.pi.len());
1680    for i in 0..k {
1681        let (mu_i, d1_i) = component_inverse_link_mu_d1(state.components[i], eta);
1682        let w = state.pi[i];
1683        mu += w * mu_i;
1684        d1 += w * d1_i;
1685    }
1686    (mu, d1)
1687}
1688
1689#[derive(Clone, Copy)]
1690enum PdfDerivativeOrder {
1691    Third,
1692    Fourth,
1693}
1694
1695impl PdfDerivativeOrder {
1696    fn probit(self, eta: f64) -> f64 {
1697        match self {
1698            Self::Third => probit_pdfthird_derivative(eta),
1699            Self::Fourth => probit_pdffourth_derivative(eta),
1700        }
1701    }
1702
1703    fn component(self, component: LinkComponent, eta: f64) -> f64 {
1704        match self {
1705            Self::Third => component_inverse_link_pdfthird_derivative(component, eta),
1706            Self::Fourth => component_inverse_link_pdffourth_derivative(component, eta),
1707        }
1708    }
1709
1710    fn latent_cloglog(self, eta: f64, latent_sd: f64) -> Result<f64, EstimationError> {
1711        let jet = latent_cloglog_jet5(latent_cloglog_quadctx(), eta, latent_sd)?;
1712        Ok(match self {
1713            Self::Third => jet.d4,
1714            Self::Fourth => jet.d5,
1715        })
1716    }
1717
1718    fn sas(self, eta: f64, epsilon: f64, log_delta: f64) -> Result<f64, EstimationError> {
1719        match self {
1720            Self::Third => sas_inverse_link_pdfthird_derivative(eta, epsilon, log_delta),
1721            Self::Fourth => sas_inverse_link_pdffourth_derivative(eta, epsilon, log_delta),
1722        }
1723    }
1724
1725    fn beta_logistic(self, eta: f64, log_shape_center: f64, epsilon: f64) -> f64 {
1726        match self {
1727            Self::Third => {
1728                beta_logistic_inverse_link_pdfthird_derivative(eta, log_shape_center, epsilon)
1729            }
1730            Self::Fourth => {
1731                beta_logistic_inverse_link_pdffourth_derivative(eta, log_shape_center, epsilon)
1732            }
1733        }
1734    }
1735}
1736
1737fn inverse_link_pdf_derivative_for_inverse_link(
1738    link: &InverseLink,
1739    eta: f64,
1740    order: PdfDerivativeOrder,
1741) -> Result<f64, EstimationError> {
1742    match link {
1743        InverseLink::Standard(StandardLink::Identity) => Ok(0.0),
1744        InverseLink::Standard(StandardLink::Log) => log_link_solver_exp(eta),
1745        InverseLink::Standard(StandardLink::Probit) => Ok(order.probit(eta)),
1746        InverseLink::Standard(StandardLink::Logit) => {
1747            Ok(order.component(LinkComponent::Logit, eta))
1748        }
1749        InverseLink::Standard(StandardLink::CLogLog) => {
1750            Ok(order.component(LinkComponent::CLogLog, eta))
1751        }
1752        InverseLink::Standard(StandardLink::LogLog) => {
1753            Ok(order.component(LinkComponent::LogLog, eta))
1754        }
1755        InverseLink::Standard(StandardLink::Cauchit) => {
1756            Ok(order.component(LinkComponent::Cauchit, eta))
1757        }
1758        InverseLink::LatentCLogLog(state) => order.latent_cloglog(eta, state.latent_sd),
1759        InverseLink::Sas(state) => order.sas(eta, state.epsilon, state.log_delta),
1760        InverseLink::BetaLogistic(state) => {
1761            Ok(order.beta_logistic(eta, state.log_delta, state.epsilon))
1762        }
1763        InverseLink::Mixture(state) => Ok(state
1764            .components
1765            .iter()
1766            .zip(state.pi.iter())
1767            .map(|(&component, &weight)| weight * order.component(component, eta))
1768            .sum()),
1769    }
1770}
1771
1772pub fn inverse_link_pdfthird_derivative_for_inverse_link(
1773    link: &InverseLink,
1774    eta: f64,
1775) -> Result<f64, EstimationError> {
1776    // This dispatch returns the fourth eta-derivative of the inverse-link CDF,
1777    // equivalently the third derivative of the inverse-link density
1778    //
1779    //   f(eta) = d/deta mu(eta).
1780    //
1781    // It is used downstream as the `f'''` input in
1782    //
1783    //   d³/deta³ log f = f'''/f - 3 f'f''/f² + 2(f')³/f³.
1784    //
1785    // Mixture links preserve linearity:
1786    //
1787    //   mu = sum_j pi_j mu_j
1788    //   => f''' = sum_j pi_j f_j'''
1789    //
1790    // because the mixture weights `pi_j` are constant with respect to `eta`.
1791    // Standard-log inputs outside the declared solver domain return the same
1792    // typed refusal as the lower-order jet seams.
1793    inverse_link_pdf_derivative_for_inverse_link(link, eta, PdfDerivativeOrder::Third)
1794}
1795
1796/// Fifth derivative of the inverse-link CDF (= fourth derivative of the PDF).
1797///
1798/// Extends `inverse_link_pdfthird_derivative_for_inverse_link` by one order.
1799/// Used for the outer REML Hessian Q[v_k, v_l] term in survival models,
1800/// specifically the `m1 * u_{abcd}` Arbogast contribution.
1801/// Standard-log inputs obey the same solver domain as every lower-order seam.
1802pub fn inverse_link_pdffourth_derivative_for_inverse_link(
1803    link: &InverseLink,
1804    eta: f64,
1805) -> Result<f64, EstimationError> {
1806    inverse_link_pdf_derivative_for_inverse_link(link, eta, PdfDerivativeOrder::Fourth)
1807}
1808
1809#[inline]
1810/// Exact Royston-Parmar survival jet `S(eta) = exp(-exp(eta))` for every finite
1811/// `f64` eta. Scaled polynomial tails preserve representable derivatives after
1812/// the survival value itself underflows; non-finite eta is a typed refusal.
1813fn royston_parmar_inverse_link_jet(eta: f64) -> Result<InverseLinkJet, EstimationError> {
1814    let eta = finite_inverse_link_eta("Royston-Parmar survival inverse link", eta)?;
1815    let hazard = eta.exp();
1816    let survival = (-hazard).exp();
1817    // For S(eta) = exp(-h), h = exp(eta), each derivative is a polynomial in
1818    // nonnegative h times exp(-h). Evaluate that product in its scaled form so
1819    // neither h^k nor h itself can create an inf*0 tail. If exp(eta) overflows,
1820    // the helper returns the exact asymptotic derivative limit 0.
1821    let d1 = -stable_nonnegative_poly_times_exp_neg(hazard, &[0.0, 1.0]);
1822    let d2 = stable_nonnegative_poly_times_exp_neg(hazard, &[0.0, -1.0, 1.0]);
1823    let d3 = stable_nonnegative_poly_times_exp_neg(hazard, &[0.0, -1.0, 3.0, -1.0]);
1824    Ok(InverseLinkJet {
1825        mu: survival,
1826        d1: canonicalzero(d1),
1827        d2: canonicalzero(d2),
1828        d3: canonicalzero(d3),
1829    })
1830}
1831
1832/// Exact-public log inverse-link jet: `mu = d1 = d2 = d3 = exp(η)` with no
1833/// solver-domain restriction. The solver-internal sibling evaluates the same
1834/// exact expression only on [`LOG_LINK_SOLVER_ETA_MIN`] through
1835/// [`LOG_LINK_SOLVER_ETA_MAX`] and returns a typed refusal outside it; see issue
1836/// #963. Every derivative of `exp` is `exp`, so all four jet slots carry the
1837/// same value — finite wherever representable, `0.0` on underflow, and `+∞` on
1838/// overflow.
1839#[inline]
1840fn log_inverse_link_jet_exact(eta: f64) -> InverseLinkJet {
1841    let e = eta.exp();
1842    InverseLinkJet {
1843        mu: e,
1844        d1: e,
1845        d2: e,
1846        d3: e,
1847    }
1848}
1849
1850/// EXACT public inverse-link jet for response-scale prediction outputs.
1851///
1852/// Identical to `inverse_link_jet_for_family` for every link EXCEPT the
1853/// standard `Log` link, where it accepts every IEEE input while the shared
1854/// solver derivative seam accepts only its declared domain. For example,
1855/// `eta = 705` remains a valid public prediction (`exp(705) ≈ 1.5e306`) but is
1856/// a typed solver-domain refusal. Public predictions
1857/// (`FamilyStrategy::inverse_link_jet`/`inverse_link_array`, the predict mean +
1858/// delta-method SE path) therefore route here. Within the inclusive solver
1859/// domain the two paths are byte-identical because both evaluate bare
1860/// `exp(eta)` (issue #963).
1861pub fn inverse_link_jet_for_family_public(
1862    spec: &LikelihoodSpec,
1863    eta: f64,
1864) -> Result<InverseLinkJet, EstimationError> {
1865    if matches!(spec.response, ResponseFamily::RoystonParmar) {
1866        return royston_parmar_inverse_link_jet(eta);
1867    }
1868    if let InverseLink::Standard(StandardLink::Log) = spec.link {
1869        return Ok(log_inverse_link_jet_exact(eta));
1870    }
1871    spec.link.jet(eta)
1872}
1873
1874#[inline]
1875pub fn mixture_inverse_link_jet(state: &MixtureLinkState, eta: f64) -> InverseLinkJet {
1876    let mut mu = 0.0_f64;
1877    let mut d1 = 0.0_f64;
1878    let mut d2 = 0.0_f64;
1879    let mut d3 = 0.0_f64;
1880    let k = state.components.len().min(state.pi.len());
1881    for i in 0..k {
1882        let jet = component_inverse_link_jet(state.components[i], eta);
1883        let w = state.pi[i];
1884        mu += w * jet.mu;
1885        d1 += w * jet.d1;
1886        d2 += w * jet.d2;
1887        d3 += w * jet.d3;
1888    }
1889    InverseLinkJet { mu, d1, d2, d3 }
1890}
1891
1892/// Computes mixture jet and exact partial derivatives wrt free softmax logits.
1893///
1894/// Uses identities:
1895///   d mu     / d rho_j = pi_j (mu_j     - mu)
1896///   d mu'    / d rho_j = pi_j (mu_j'    - mu')
1897///   d mu''   / d rho_j = pi_j (mu_j''   - mu'')
1898///   d mu'''  / d rho_j = pi_j (mu_j'''  - mu''')
1899pub fn mixture_inverse_link_jetwith_rho_partials(
1900    state: &MixtureLinkState,
1901    eta: f64,
1902) -> MixtureJetWithRhoPartials {
1903    let k = state.components.len().min(state.pi.len());
1904    let m = k.saturating_sub(1);
1905    let mut djet_drho = vec![
1906        InverseLinkJet {
1907            mu: 0.0,
1908            d1: 0.0,
1909            d2: 0.0,
1910            d3: 0.0,
1911        };
1912        m
1913    ];
1914    let jet = mixture_inverse_link_jetwith_rho_partials_into(state, eta, &mut djet_drho);
1915    // If `g_j = pi_j (f_j - f_mix)`, differentiating once more gives
1916    //
1917    //   H_jk = (1[j=k] - pi_k) g_j - pi_j g_k.
1918    //
1919    // This form reuses the first derivatives already in `djet_drho`, avoids
1920    // dividing by a possibly tiny mixture weight, and is algebraically
1921    // symmetric even though floating-point evaluation visits `(j,k)` in one
1922    // direction. Fill one triangle and mirror it bit-for-bit so downstream PSD
1923    // certification receives an exactly symmetric matrix.
1924    let mut d2mu_drho2 = Array2::<f64>::zeros((m, m));
1925    let mut d2d1_drho2 = Array2::<f64>::zeros((m, m));
1926    let mut d2d2_drho2 = Array2::<f64>::zeros((m, m));
1927    for j in 0..m {
1928        for k in j..m {
1929            let diagonal = if j == k { 1.0 } else { 0.0 };
1930            let mu = (diagonal - state.pi[k]) * djet_drho[j].mu
1931                - state.pi[j] * djet_drho[k].mu;
1932            let d1 = (diagonal - state.pi[k]) * djet_drho[j].d1
1933                - state.pi[j] * djet_drho[k].d1;
1934            let d2 = (diagonal - state.pi[k]) * djet_drho[j].d2
1935                - state.pi[j] * djet_drho[k].d2;
1936            d2mu_drho2[[j, k]] = mu;
1937            d2mu_drho2[[k, j]] = mu;
1938            d2d1_drho2[[j, k]] = d1;
1939            d2d1_drho2[[k, j]] = d1;
1940            d2d2_drho2[[j, k]] = d2;
1941            d2d2_drho2[[k, j]] = d2;
1942        }
1943    }
1944    MixtureJetWithRhoPartials {
1945        jet,
1946        djet_drho,
1947        d2mu_drho2,
1948        d2d1_drho2,
1949        d2d2_drho2,
1950    }
1951}
1952
1953/// Computes mixture jet and writes exact rho partial jets into `out` (length >= K-1).
1954/// This avoids heap allocation in hot loops.
1955pub fn mixture_inverse_link_jetwith_rho_partials_into(
1956    state: &MixtureLinkState,
1957    eta: f64,
1958    out: &mut [InverseLinkJet],
1959) -> InverseLinkJet {
1960    let k = state.components.len().min(state.pi.len());
1961    let m = k.saturating_sub(1);
1962    assert!(
1963        out.len() >= m,
1964        "rho-partial output buffer too small: got {}, need {}",
1965        out.len(),
1966        m
1967    );
1968    let mut mixed = InverseLinkJet {
1969        mu: 0.0,
1970        d1: 0.0,
1971        d2: 0.0,
1972        d3: 0.0,
1973    };
1974    for i in 0..k {
1975        let jet_i = component_inverse_link_jet(state.components[i], eta);
1976        let w = state.pi[i];
1977        mixed.mu += w * jet_i.mu;
1978        mixed.d1 += w * jet_i.d1;
1979        mixed.d2 += w * jet_i.d2;
1980        mixed.d3 += w * jet_i.d3;
1981        // Cache the first K-1 component jets directly in the output buffer so
1982        // we don't recompute them in the partial loop.
1983        if i < m {
1984            out[i] = jet_i;
1985        }
1986    }
1987    for j in 0..m {
1988        let pi_j = state.pi[j];
1989        let cj = out[j];
1990        out[j] = InverseLinkJet {
1991            mu: pi_j * (cj.mu - mixed.mu),
1992            d1: pi_j * (cj.d1 - mixed.d1),
1993            d2: pi_j * (cj.d2 - mixed.d2),
1994            d3: pi_j * (cj.d3 - mixed.d3),
1995        };
1996    }
1997    mixed
1998}
1999
2000#[derive(Clone, Copy)]
2001struct LogisticU {
2002    u: f64,
2003    one_minus_u: f64,
2004    ln_u: f64,
2005    ln_one_minus_u: f64,
2006    du: f64,
2007    use_upper_tail: bool,
2008}
2009
2010#[inline]
2011fn logistic_uwith_derivatives(eta: f64) -> LogisticU {
2012    let ln_u = -gam_linalg::utils::stable_softplus(-eta);
2013    let ln_one_minus_u = -gam_linalg::utils::stable_softplus(eta);
2014    let u = ln_u.exp();
2015    let one_minus_u = ln_one_minus_u.exp();
2016    let du = (ln_u + ln_one_minus_u).exp();
2017    LogisticU {
2018        u,
2019        one_minus_u,
2020        ln_u,
2021        ln_one_minus_u,
2022        du,
2023        use_upper_tail: eta >= 0.0,
2024    }
2025}
2026
2027#[inline]
2028fn beta_reg_logistic(a: f64, b: f64, logistic: LogisticU) -> f64 {
2029    if logistic.ln_u.is_nan() || logistic.ln_one_minus_u.is_nan() {
2030        return f64::NAN;
2031    }
2032    if logistic.ln_u == f64::NEG_INFINITY {
2033        return 0.0;
2034    }
2035    if logistic.ln_one_minus_u == f64::NEG_INFINITY {
2036        return 1.0;
2037    }
2038    if logistic.use_upper_tail {
2039        1.0 - beta_reg(b, a, logistic.one_minus_u)
2040    } else {
2041        beta_reg(a, b, logistic.u)
2042    }
2043}
2044
2045#[derive(Clone, Copy)]
2046struct BetaShapePartials {
2047    value: f64,
2048    da: f64,
2049    db: f64,
2050    daa: f64,
2051    dab: f64,
2052    dbb: f64,
2053}
2054
2055impl BetaShapePartials {
2056    #[inline]
2057    fn constant(value: f64) -> Self {
2058        Self {
2059            value,
2060            da: 0.0,
2061            db: 0.0,
2062            daa: 0.0,
2063            dab: 0.0,
2064            dbb: 0.0,
2065        }
2066    }
2067}
2068
2069#[inline]
2070fn beta_reg_with_shape_partials_logistic(
2071    a: f64,
2072    b: f64,
2073    logistic: LogisticU,
2074) -> BetaShapePartials {
2075    if logistic.ln_u.is_nan() || logistic.ln_one_minus_u.is_nan() {
2076        return BetaShapePartials {
2077            value: f64::NAN,
2078            da: f64::NAN,
2079            db: f64::NAN,
2080            daa: f64::NAN,
2081            dab: f64::NAN,
2082            dbb: f64::NAN,
2083        };
2084    }
2085    if logistic.use_upper_tail {
2086        let tail = beta_reg_with_shape_partials(b, a, logistic.one_minus_u);
2087        BetaShapePartials {
2088            value: 1.0 - tail.value,
2089            da: -tail.db,
2090            db: -tail.da,
2091            daa: -tail.dbb,
2092            dab: -tail.dab,
2093            dbb: -tail.daa,
2094        }
2095    } else {
2096        beta_reg_with_shape_partials(a, b, logistic.u)
2097    }
2098}
2099
2100#[inline]
2101fn beta_logistic_log_d1(a: f64, b: f64, logistic: LogisticU) -> f64 {
2102    a * logistic.ln_u + b * logistic.ln_one_minus_u - ln_beta(a, b)
2103}
2104
2105#[derive(Clone, Copy)]
2106struct ShapeDual {
2107    v: f64,
2108    da: f64,
2109    db: f64,
2110    daa: f64,
2111    dab: f64,
2112    dbb: f64,
2113}
2114
2115impl ShapeDual {
2116    #[inline]
2117    fn constant(v: f64) -> Self {
2118        Self {
2119            v,
2120            da: 0.0,
2121            db: 0.0,
2122            daa: 0.0,
2123            dab: 0.0,
2124            dbb: 0.0,
2125        }
2126    }
2127
2128    #[inline]
2129    fn from_value_partials(v: f64, da: f64, db: f64) -> Self {
2130        Self {
2131            v,
2132            da,
2133            db,
2134            daa: 0.0,
2135            dab: 0.0,
2136            dbb: 0.0,
2137        }
2138    }
2139
2140    #[inline]
2141    fn clamp_small(self, floor: f64) -> Self {
2142        if self.v.abs() < floor {
2143            Self::constant(floor)
2144        } else {
2145            self
2146        }
2147    }
2148}
2149
2150impl std::ops::Add for ShapeDual {
2151    type Output = Self;
2152
2153    #[inline]
2154    fn add(self, rhs: Self) -> Self {
2155        Self {
2156            v: self.v + rhs.v,
2157            da: self.da + rhs.da,
2158            db: self.db + rhs.db,
2159            daa: self.daa + rhs.daa,
2160            dab: self.dab + rhs.dab,
2161            dbb: self.dbb + rhs.dbb,
2162        }
2163    }
2164}
2165
2166impl std::ops::Sub for ShapeDual {
2167    type Output = Self;
2168
2169    #[inline]
2170    fn sub(self, rhs: Self) -> Self {
2171        Self {
2172            v: self.v - rhs.v,
2173            da: self.da - rhs.da,
2174            db: self.db - rhs.db,
2175            daa: self.daa - rhs.daa,
2176            dab: self.dab - rhs.dab,
2177            dbb: self.dbb - rhs.dbb,
2178        }
2179    }
2180}
2181
2182impl std::ops::Mul for ShapeDual {
2183    type Output = Self;
2184
2185    #[inline]
2186    fn mul(self, rhs: Self) -> Self {
2187        Self {
2188            v: self.v * rhs.v,
2189            da: self.da * rhs.v + self.v * rhs.da,
2190            db: self.db * rhs.v + self.v * rhs.db,
2191            daa: self.daa * rhs.v + 2.0 * self.da * rhs.da + self.v * rhs.daa,
2192            dab: self.dab * rhs.v
2193                + self.da * rhs.db
2194                + self.db * rhs.da
2195                + self.v * rhs.dab,
2196            dbb: self.dbb * rhs.v + 2.0 * self.db * rhs.db + self.v * rhs.dbb,
2197        }
2198    }
2199}
2200
2201impl std::ops::Div for ShapeDual {
2202    type Output = Self;
2203
2204    #[inline]
2205    fn div(self, rhs: Self) -> Self {
2206        let inv = 1.0 / rhs.v;
2207        let inv2 = inv * inv;
2208        let inv3 = inv2 * inv;
2209        let reciprocal = Self {
2210            v: inv,
2211            da: -rhs.da * inv2,
2212            db: -rhs.db * inv2,
2213            daa: 2.0 * rhs.da * rhs.da * inv3 - rhs.daa * inv2,
2214            dab: 2.0 * rhs.da * rhs.db * inv3 - rhs.dab * inv2,
2215            dbb: 2.0 * rhs.db * rhs.db * inv3 - rhs.dbb * inv2,
2216        };
2217        self * reciprocal
2218    }
2219}
2220
2221impl std::ops::Neg for ShapeDual {
2222    type Output = Self;
2223
2224    #[inline]
2225    fn neg(self) -> Self {
2226        ShapeDual {
2227            v: -self.v,
2228            da: -self.da,
2229            db: -self.db,
2230            daa: -self.daa,
2231            dab: -self.dab,
2232            dbb: -self.dbb,
2233        }
2234    }
2235}
2236
2237#[inline]
2238fn shape_dual(v: f64) -> ShapeDual {
2239    ShapeDual::constant(v)
2240}
2241
2242// Analytic shape partials for I_x(a,b), obtained by differentiating the same
2243// regularized-beta continued fraction used by statrs. The normalizing term uses
2244// d log B(a,b) / da = psi(a) - psi(a+b) and likewise for b.
2245fn beta_reg_with_shape_partials(a0: f64, b0: f64, x0: f64) -> BetaShapePartials {
2246    if x0 <= 0.0 {
2247        return BetaShapePartials::constant(0.0);
2248    }
2249    if x0 >= 1.0 {
2250        return BetaShapePartials::constant(1.0);
2251    }
2252
2253    let symm_transform = x0 >= (a0 + 1.0) / (a0 + b0 + 2.0);
2254    let (a, b, x) = if symm_transform {
2255        (
2256            ShapeDual::from_value_partials(b0, 0.0, 1.0),
2257            ShapeDual::from_value_partials(a0, 1.0, 0.0),
2258            1.0 - x0,
2259        )
2260    } else {
2261        (
2262            ShapeDual::from_value_partials(a0, 1.0, 0.0),
2263            ShapeDual::from_value_partials(b0, 0.0, 1.0),
2264            x0,
2265        )
2266    };
2267
2268    let ln_x = x.ln();
2269    let ln_1mx = (1.0 - x).ln();
2270    let psi_ab = digamma(a.v + b.v);
2271    let log_bt = statrs::function::gamma::ln_gamma(a.v + b.v)
2272        - statrs::function::gamma::ln_gamma(a.v)
2273        - statrs::function::gamma::ln_gamma(b.v)
2274        + a.v * ln_x
2275        + b.v * ln_1mx;
2276    let bt_v = log_bt.exp();
2277    let log_bt_a = psi_ab - digamma(a.v) + ln_x;
2278    let log_bt_b = psi_ab - digamma(b.v) + ln_1mx;
2279    let trigamma_ab = trigamma(a.v + b.v);
2280    let log_bt_aa = trigamma_ab - trigamma(a.v);
2281    let log_bt_ab = trigamma_ab;
2282    let log_bt_bb = trigamma_ab - trigamma(b.v);
2283    let log_bt_da = log_bt_a * a.da + log_bt_b * b.da;
2284    let log_bt_db = log_bt_a * a.db + log_bt_b * b.db;
2285    let log_bt_daa = log_bt_aa * a.da * a.da
2286        + 2.0 * log_bt_ab * a.da * b.da
2287        + log_bt_bb * b.da * b.da
2288        + log_bt_a * a.daa
2289        + log_bt_b * b.daa;
2290    let log_bt_dab = log_bt_aa * a.da * a.db
2291        + log_bt_ab * (a.da * b.db + b.da * a.db)
2292        + log_bt_bb * b.da * b.db
2293        + log_bt_a * a.dab
2294        + log_bt_b * b.dab;
2295    let log_bt_dbb = log_bt_aa * a.db * a.db
2296        + 2.0 * log_bt_ab * a.db * b.db
2297        + log_bt_bb * b.db * b.db
2298        + log_bt_a * a.dbb
2299        + log_bt_b * b.dbb;
2300    let bt = ShapeDual {
2301        v: bt_v,
2302        da: bt_v * log_bt_da,
2303        db: bt_v * log_bt_db,
2304        daa: bt_v * (log_bt_da * log_bt_da + log_bt_daa),
2305        dab: bt_v * (log_bt_da * log_bt_db + log_bt_dab),
2306        dbb: bt_v * (log_bt_db * log_bt_db + log_bt_dbb),
2307    };
2308
2309    let eps = 0.00000000000000011102230246251565;
2310    let fpmin = f64::MIN_POSITIVE / eps;
2311    let one = shape_dual(1.0);
2312    let qab = a + b;
2313    let qap = a + one;
2314    let qam = a - one;
2315    let mut c = one;
2316    let mut d = (one - qab * shape_dual(x) / qap).clamp_small(fpmin);
2317    d = one / d;
2318    let mut h = d;
2319
2320    for m in 1..141 {
2321        let mf = f64::from(m);
2322        let m2 = mf * 2.0;
2323        let md = shape_dual(mf);
2324        let m2d = shape_dual(m2);
2325        let mut aa = md * (b - md) * shape_dual(x) / ((qam + m2d) * (a + m2d));
2326        d = (one + aa * d).clamp_small(fpmin);
2327        c = (one + aa / c).clamp_small(fpmin);
2328        d = one / d;
2329        h = h * d * c;
2330
2331        aa = (a + md).neg() * (qab + md) * shape_dual(x) / ((a + m2d) * (qap + m2d));
2332        d = (one + aa * d).clamp_small(fpmin);
2333        c = (one + aa / c).clamp_small(fpmin);
2334        d = one / d;
2335        let del = d * c;
2336        h = h * del;
2337
2338        if (del.v - 1.0).abs() <= eps {
2339            let reg = bt * h / a;
2340            return if symm_transform {
2341                BetaShapePartials {
2342                    value: 1.0 - reg.v,
2343                    da: -reg.da,
2344                    db: -reg.db,
2345                    daa: -reg.daa,
2346                    dab: -reg.dab,
2347                    dbb: -reg.dbb,
2348                }
2349            } else {
2350                BetaShapePartials {
2351                    value: reg.v,
2352                    da: reg.da,
2353                    db: reg.db,
2354                    daa: reg.daa,
2355                    dab: reg.dab,
2356                    dbb: reg.dbb,
2357                }
2358            };
2359        }
2360    }
2361    let reg = bt * h / a;
2362    if symm_transform {
2363        BetaShapePartials {
2364            value: 1.0 - reg.v,
2365            da: -reg.da,
2366            db: -reg.db,
2367            daa: -reg.daa,
2368            dab: -reg.dab,
2369            dbb: -reg.dbb,
2370        }
2371    } else {
2372        BetaShapePartials {
2373            value: reg.v,
2374            da: reg.da,
2375            db: reg.db,
2376            daa: reg.daa,
2377            dab: reg.dab,
2378            dbb: reg.dbb,
2379        }
2380    }
2381}
2382
2383/// Beta-Logistic inverse-link jet for:
2384///   u = logistic(eta)
2385///   a = exp(log_shape_center - epsilon), b = exp(log_shape_center + epsilon)
2386///   mu = I_u(a, b)
2387///
2388/// NOTE: `log_shape_center` is the *unconstrained* log of the geometric-mean
2389/// beta shape (so a·b = exp(2·log_shape_center)). Callers must pass the raw
2390/// optimization parameter `SasLinkState::log_delta`, NOT the derived positive
2391/// `SasLinkState::delta = exp(log_shape_center)`.
2392/// Bounded beta shapes and the first two derivatives of each with respect to
2393/// its own log-shape argument.
2394///
2395/// `log a = g(s)` with `s = log_shape_center − epsilon`, `log b = g(t)` with
2396/// `t = log_shape_center + epsilon`, `g = smooth_bound_jet(·,
2397/// BETA_LOGISTIC_LOG_SHAPE_BOUND)`. Returns `(a, b, ∂a/∂s, ∂b/∂t, ∂²a/∂s²,
2398/// ∂²b/∂t²)`. Because `s` and `t` are affine in the two optimization
2399/// parameters with unit coefficients, those are the only chain factors any
2400/// caller needs — every existing `a`/`b` in a derivative expression becomes
2401/// the corresponding derivative entry here.
2402///
2403/// On the interior `g` is the exact identity (`g′ = 1`, `g″ = 0`), so all six
2404/// returned values are bitwise what the unbounded form produced.
2405#[inline]
2406fn beta_logistic_shape_jets(
2407    log_shape_center: f64,
2408    epsilon: f64,
2409) -> (f64, f64, f64, f64, f64, f64) {
2410    let gs = smooth_bound_jet(log_shape_center - epsilon, BETA_LOGISTIC_LOG_SHAPE_BOUND);
2411    let gt = smooth_bound_jet(log_shape_center + epsilon, BETA_LOGISTIC_LOG_SHAPE_BOUND);
2412    let a = gs.g.exp();
2413    let b = gt.g.exp();
2414    (
2415        a,
2416        b,
2417        a * gs.d1,
2418        b * gt.d1,
2419        a * (gs.d1 * gs.d1 + gs.d2),
2420        b * (gt.d1 * gt.d1 + gt.d2),
2421    )
2422}
2423
2424/// Value-only form of [`beta_logistic_shape_jets`].
2425#[inline]
2426fn beta_logistic_shapes(log_shape_center: f64, epsilon: f64) -> (f64, f64) {
2427    let (a, b, ..) = beta_logistic_shape_jets(log_shape_center, epsilon);
2428    (a, b)
2429}
2430
2431pub fn beta_logistic_inverse_link_jet(
2432    eta: f64,
2433    log_shape_center: f64,
2434    epsilon: f64,
2435) -> InverseLinkJet {
2436    let logistic = logistic_uwith_derivatives(eta);
2437    let (a, b) = beta_logistic_shapes(log_shape_center, epsilon);
2438    let mu = beta_reg_logistic(a, b, logistic);
2439    let log_d1 = beta_logistic_log_d1(a, b, logistic);
2440    let d1 = log_d1.exp();
2441    let t = a * logistic.one_minus_u - b * logistic.u;
2442    let d2 = d1 * t;
2443    let d3 = d1 * (t * t - (a + b) * logistic.du);
2444    InverseLinkJet { mu, d1, d2, d3 }
2445}
2446
2447pub fn beta_logistic_inverse_link_pdfthird_derivative(
2448    eta: f64,
2449    log_shape_center: f64,
2450    epsilon: f64,
2451) -> f64 {
2452    // Beta-logistic link:
2453    //
2454    //   u = logistic(eta),
2455    //   d1 = C * u^a (1-u)^b,
2456    //   t  = a(1-u) - b u,
2457    //   c  = a + b,
2458    //
2459    // so
2460    //
2461    //   d2 = d1 * t
2462    //   d3 = d1 * (t² - c u')
2463    //
2464    // with `u' = u(1-u)`.
2465    //
2466    // Differentiate once more:
2467    //
2468    //   d4 = d/deta[d1 (t² - c u')]
2469    //      = d1' (t² - c u') + d1 (2 t t' - c u'')
2470    //      = d1 [ t(t² - c u') - 2 c t u' - c u'' ]
2471    //      = d1 [ t³ - 3 c t u' - c u'' ],
2472    //
2473    // since `t' = -c u'`.
2474    let logistic = logistic_uwith_derivatives(eta);
2475    let (a, b) = beta_logistic_shapes(log_shape_center, epsilon);
2476    let log_d1 = beta_logistic_log_d1(a, b, logistic);
2477    let d1 = log_d1.exp();
2478    let c = a + b;
2479    let t = a * logistic.one_minus_u - b * logistic.u;
2480    let u2 = logistic.du * (logistic.one_minus_u - logistic.u);
2481    d1 * (t * t * t - 3.0 * c * t * logistic.du - c * u2)
2482}
2483
2484/// Fifth derivative of the beta-logistic inverse-link CDF (= 4th deriv of PDF).
2485///
2486/// With `P_4 = t^3 - 3ct*u' - c*u''` giving `d4 = d1 * P_4`, the next order is:
2487///
2488///   d5 = d1 * [t^4 - 6c*t^2*u' - 4c*t*u'' + 3c^2*u'^2 - c*u''']
2489///
2490/// where u' = u(1-u), u'' = u'(1-2u), u''' = u''(1-2u) - 2*u'^2.
2491pub fn beta_logistic_inverse_link_pdffourth_derivative(
2492    eta: f64,
2493    log_shape_center: f64,
2494    epsilon: f64,
2495) -> f64 {
2496    let logistic = logistic_uwith_derivatives(eta);
2497    let (a, b) = beta_logistic_shapes(log_shape_center, epsilon);
2498    let log_d1 = beta_logistic_log_d1(a, b, logistic);
2499    let d1 = log_d1.exp();
2500    let c = a + b;
2501    let t = a * logistic.one_minus_u - b * logistic.u;
2502    let u2 = logistic.du * (logistic.one_minus_u - logistic.u);
2503    let u3 = u2 * (logistic.one_minus_u - logistic.u) - 2.0 * logistic.du * logistic.du;
2504    let t2 = t * t;
2505    d1 * (t2 * t2 - 6.0 * c * t2 * logistic.du - 4.0 * c * t * u2
2506        + 3.0 * c * c * logistic.du * logistic.du
2507        - c * u3)
2508}
2509
2510pub fn beta_logistic_inverse_link_jetwith_param_partials(
2511    eta: f64,
2512    log_shape_center: f64,
2513    epsilon: f64,
2514) -> SasJetWithParamPartials {
2515    let logistic = logistic_uwith_derivatives(eta);
2516    // `da`/`db` are `∂a/∂s`, `∂b/∂t`; `daa`/`dbb` the second derivatives. On the
2517    // interior they equal `a`, `b`, `a`, `b`, which is what the unbounded form
2518    // used directly.
2519    let (a, b, da, db, daa, dbb) = beta_logistic_shape_jets(log_shape_center, epsilon);
2520    let shape = beta_reg_with_shape_partials_logistic(a, b, logistic);
2521    let mu = shape.value;
2522    let dmu_dlog_shape_center = da * shape.da + db * shape.db;
2523    let dmu_depsilon = -da * shape.da + db * shape.db;
2524    let log_d1 = beta_logistic_log_d1(a, b, logistic);
2525    let d1 = log_d1.exp();
2526    let t = a * logistic.one_minus_u - b * logistic.u;
2527    let d2 = d1 * t;
2528    let k = t * t - (a + b) * logistic.du;
2529    let d3 = d1 * k;
2530    let jet = InverseLinkJet { mu, d1, d2, d3 };
2531
2532    let psi_a = digamma(a);
2533    let psi_b = digamma(b);
2534    let psi_ab = digamma(a + b);
2535    let la = logistic.ln_u - psi_a + psi_ab;
2536    let lb = logistic.ln_one_minus_u - psi_b + psi_ab;
2537
2538    let partials_for = |a_p: f64, b_p: f64, dmu: f64| -> InverseLinkJet {
2539        let logd1_p = a_p * la + b_p * lb;
2540        let d1_p = d1 * logd1_p;
2541        let t_p = a_p * logistic.one_minus_u - b_p * logistic.u;
2542        let d2_p = d1_p * t + d1 * t_p;
2543        let k_p = 2.0 * t * t_p - (a_p + b_p) * logistic.du;
2544        let d3_p = d1_p * k + d1 * k_p;
2545        InverseLinkJet {
2546            mu: dmu,
2547            d1: d1_p,
2548            d2: d2_p,
2549            d3: d3_p,
2550        }
2551    };
2552    let djet_dlog_shape_center = partials_for(da, db, dmu_dlog_shape_center);
2553    let djet_depsilon = partials_for(-da, db, dmu_depsilon);
2554    // Parameter order is `(epsilon, log_shape_center)`. The beta shapes obey
2555    // `a=exp(l-e)`, `b=exp(l+e)`, so their first and second parameter jets are
2556    // closed form. Contract those jets with the exact `(a,b)` Hessian of the
2557    // regularized beta CDF for `mu`, and with the exact log-density Hessian for
2558    // `d1`. No numerical differencing or profile replay enters this path.
2559    let a_first = [-da, da];
2560    let b_first = [db, db];
2561    let a_second = [[daa, -daa], [-daa, daa]];
2562    let b_second = [[dbb, dbb], [dbb, dbb]];
2563    let logd1_first = [
2564        a_first[0] * la + b_first[0] * lb,
2565        a_first[1] * la + b_first[1] * lb,
2566    ];
2567    let trigamma_ab = trigamma(a + b);
2568    let la_a = trigamma_ab - trigamma(a);
2569    let la_b = trigamma_ab;
2570    let lb_a = trigamma_ab;
2571    let lb_b = trigamma_ab - trigamma(b);
2572    // `t = a(1−u) − b u` is linear in the shapes, so its parameter jets follow
2573    // the same first/second shape derivatives with no new special functions.
2574    let t_first = [
2575        a_first[0] * logistic.one_minus_u - b_first[0] * logistic.u,
2576        a_first[1] * logistic.one_minus_u - b_first[1] * logistic.u,
2577    ];
2578    let mut d2mu_dparams2 = Array2::<f64>::zeros((2, 2));
2579    let mut d2d1_dparams2 = Array2::<f64>::zeros((2, 2));
2580    let mut d2d2_dparams2 = Array2::<f64>::zeros((2, 2));
2581    for j in 0..2 {
2582        for k in j..2 {
2583            let mu_jk = shape.daa * a_first[j] * a_first[k]
2584                + shape.dab
2585                    * (a_first[j] * b_first[k] + b_first[j] * a_first[k])
2586                + shape.dbb * b_first[j] * b_first[k]
2587                + shape.da * a_second[j][k]
2588                + shape.db * b_second[j][k];
2589            let logd1_jk = la_a * a_first[j] * a_first[k]
2590                + la_b * a_first[j] * b_first[k]
2591                + lb_a * b_first[j] * a_first[k]
2592                + lb_b * b_first[j] * b_first[k]
2593                + la * a_second[j][k]
2594                + lb * b_second[j][k];
2595            let d1_jk = d1 * (logd1_first[j] * logd1_first[k] + logd1_jk);
2596            // d2 = d1·t  ⇒  d2_jk = d1_jk·t + d1_j·t_k + d1_k·t_j + d1·t_jk,
2597            // with d1_j = d1·logd1_first[j] (#2665).
2598            let t_jk = a_second[j][k] * logistic.one_minus_u - b_second[j][k] * logistic.u;
2599            let d2_jk = d1_jk * t
2600                + d1 * logd1_first[j] * t_first[k]
2601                + d1 * logd1_first[k] * t_first[j]
2602                + d1 * t_jk;
2603            d2mu_dparams2[[j, k]] = mu_jk;
2604            d2mu_dparams2[[k, j]] = mu_jk;
2605            d2d1_dparams2[[j, k]] = d1_jk;
2606            d2d1_dparams2[[k, j]] = d1_jk;
2607            d2d2_dparams2[[j, k]] = d2_jk;
2608            d2d2_dparams2[[k, j]] = d2_jk;
2609        }
2610    }
2611    SasJetWithParamPartials {
2612        jet,
2613        djet_depsilon,
2614        djet_dlog_delta: djet_dlog_shape_center,
2615        d2mu_dparams2,
2616        d2d1_dparams2,
2617        d2d2_dparams2,
2618    }
2619}
2620
2621/// SAS inverse-link jet for:
2622///   mu(eta) = Phi(sinh(smooth_bound(delta * asinh(eta) + epsilon, SAS_U_CLAMP))),
2623///   delta = exp(smooth_bound(log_delta, SAS_LOG_DELTA_BOUND)).
2624/// `smooth_bound` is the interior-exact bounded latent map (see
2625/// `smooth_bound_jet`); on the interior it is the identity, so this reduces to
2626/// the pure probit jet exactly at `epsilon=0, delta=1`.
2627///
2628/// The mathematical solver domain is every finite `f64` eta. Non-finite eta
2629/// returns [`EstimationError::InverseLinkDomainViolation`]; no value is
2630/// substituted. The asinh derivatives are evaluated through a scaled jet so
2631/// both finite endpoints of the domain remain numerically well defined.
2632pub fn sas_inverse_link_jet(
2633    eta: f64,
2634    epsilon: f64,
2635    log_delta: f64,
2636) -> Result<InverseLinkJet, EstimationError> {
2637    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
2638    let delta_id = sas_delta_from_raw_log_delta(log_delta);
2639    if epsilon.abs() < 1e-12 && (delta_id - 1.0).abs() < 1e-12 {
2640        return Ok(component_inverse_link_jet(LinkComponent::Probit, eta));
2641    }
2642    let asinh = asinh_jet5(eta);
2643    let delta = delta_id;
2644    let u_raw = delta * asinh.value + epsilon;
2645    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
2646    let u = sb.g;
2647    let g1 = sb.d1;
2648    let g2 = sb.d2;
2649    let g3 = sb.d3;
2650    let s = u.sinh();
2651    let c = u.cosh();
2652    let z = s;
2653    let r1 = delta * asinh.d1;
2654    let r2 = delta * asinh.d2;
2655    let r3 = delta * asinh.d3;
2656    let u1 = g1 * r1;
2657    let u2 = g2 * r1 * r1 + g1 * r2;
2658    let u3 = g3 * r1 * r1 * r1 + 3.0 * g2 * r1 * r2 + g1 * r3;
2659    let z1 = c * u1;
2660    let z2 = s * u1 * u1 + c * u2;
2661    let z3 = c * u1 * u1 * u1 + 3.0 * s * u1 * u2 + c * u3;
2662    let base = probit_jet(z);
2663    Ok(chain_inverse_link_jet(base, z1, z2, z3))
2664}
2665
2666/// Fourth eta derivative of the SAS inverse-link CDF on the same finite domain
2667/// as [`sas_inverse_link_jet`].
2668pub fn sas_inverse_link_pdfthird_derivative(
2669    eta: f64,
2670    epsilon: f64,
2671    log_delta: f64,
2672) -> Result<f64, EstimationError> {
2673    // SAS link with bounded latent transform:
2674    //
2675    //   a  = asinh(eta),
2676    //   u  = smooth_bound(delta * a + epsilon),
2677    //   z  = sinh(u),
2678    //   mu = Phi(z).
2679    //
2680    // Write:
2681    //
2682    //   z1 = z'
2683    //   z2 = z''
2684    //   z3 = z'''
2685    //   z4 = z''''.
2686    //
2687    // Since `mu' = phi(z) z1`, repeated differentiation factors through the
2688    // standard normal Hermite-polynomial identities:
2689    //
2690    //   mu''   = phi(z) [ z2 - z z1² ]
2691    //
2692    //   mu'''  = phi(z) [ z3 - 3 z z1 z2 + (z² - 1) z1³ ]
2693    //          = phi(z) k3
2694    //
2695    //   mu'''' = phi(z) [ k4 - z z1 k3 ],
2696    //
2697    // where `k4` is the derivative of `k3` after collecting like terms. The
2698    // code below computes `u1..u4`, then `z1..z4`, then `k3` and `k4`, exactly
2699    // matching that chain.
2700    //
2701    // The needed fourth derivative of `u(eta)` is obtained from the nested
2702    // composition `u(eta) = g(r(eta))` with
2703    //   g = smooth_bound, r = delta * asinh(eta) + epsilon:
2704    //
2705    //   u4 = g'''' r1^4 + 6 g''' r1² r2 + 3 g'' r2² + 4 g'' r1 r3 + g' r4,
2706    //
2707    // which is the standard scalar Arbogast expansion for order four.
2708    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
2709    let asinh = asinh_jet5(eta);
2710    let delta = sas_delta_from_raw_log_delta(log_delta);
2711    let u_raw = delta * asinh.value + epsilon;
2712    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
2713    let u = sb.g;
2714    let g1 = sb.d1;
2715    let g2 = sb.d2;
2716    let g3 = sb.d3;
2717    let g4 = sb.d4;
2718    let s = u.sinh();
2719    let c = u.cosh();
2720    let z = s;
2721    let base = probit_jet(z);
2722    let r1 = delta * asinh.d1;
2723    let r2 = delta * asinh.d2;
2724    let r3 = delta * asinh.d3;
2725    let r4 = delta * asinh.d4;
2726    let u1 = g1 * r1;
2727    let u2 = g2 * r1 * r1 + g1 * r2;
2728    let u3 = g3 * r1 * r1 * r1 + 3.0 * g2 * r1 * r2 + g1 * r3;
2729    let u4 = g4 * r1.powi(4)
2730        + 6.0 * g3 * r1 * r1 * r2
2731        + 3.0 * g2 * r2 * r2
2732        + 4.0 * g2 * r1 * r3
2733        + g1 * r4;
2734    let z1 = c * u1;
2735    let z2 = s * u1 * u1 + c * u2;
2736    let z3 = c * u1 * u1 * u1 + 3.0 * s * u1 * u2 + c * u3;
2737    let z4 =
2738        s * u1.powi(4) + 6.0 * c * u1 * u1 * u2 + 3.0 * s * u2 * u2 + 4.0 * s * u1 * u3 + c * u4;
2739    let base4 = probit_pdfthird_derivative(z);
2740    let out = base4 * z1.powi(4)
2741        + 6.0 * base.d3 * z1 * z1 * z2
2742        + 3.0 * base.d2 * z2 * z2
2743        + 4.0 * base.d2 * z1 * z3
2744        + base.d1 * z4;
2745    Ok(canonicalzero(out))
2746}
2747
2748/// Fifth derivative of the SAS inverse-link CDF (= fourth derivative of the PDF).
2749///
2750/// Extends `sas_inverse_link_pdfthird_derivative` by one more derivative order,
2751/// using the same composition chain u(eta) = g(r(eta)), z = sinh(u), mu = Phi(z).
2752///
2753/// The Arbogast expansion at order 5 for u(eta) = g(r(eta)) is:
2754///   u5 = g5 r1^5 + 10 g4 r1^3 r2 + 15 g3 r1 r2^2 + 10 g3 r1^2 r3
2755///        + 10 g2 r2 r3 + 5 g2 r1 r4 + g1 r5
2756///
2757/// The z = sinh(u) expansion at order 5 is the standard Arbogast for sinh:
2758///   z5 = c*u1^5 + 10*s*u1^3*u2 + 15*c*u1*u2^2 + 10*c*u1^2*u3
2759///        + 10*s*u2*u3 + 5*s*u1*u4 + c*u5
2760///
2761/// The mu = Phi(z) expansion at order 5 uses probit derivatives:
2762///   mu^(5) = Phi5*z1^5 + 10*Phi4*z1^3*z2 + 15*Phi3*z1*z2^2 + 10*Phi3*z1^2*z3
2763///            + 10*Phi2*z2*z3 + 5*Phi2*z1*z4 + Phi1*z5
2764///
2765/// Non-finite eta is rejected by the shared SAS finite-domain contract.
2766pub fn sas_inverse_link_pdffourth_derivative(
2767    eta: f64,
2768    epsilon: f64,
2769    log_delta: f64,
2770) -> Result<f64, EstimationError> {
2771    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
2772    let asinh = asinh_jet5(eta);
2773    let delta = sas_delta_from_raw_log_delta(log_delta);
2774    let u_raw = delta * asinh.value + epsilon;
2775    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
2776    let u = sb.g;
2777    let g1 = sb.d1;
2778    let g2 = sb.d2;
2779    let g3 = sb.d3;
2780    let g4 = sb.d4;
2781    let g5 = sb.d5;
2782    let s = u.sinh();
2783    let c = u.cosh();
2784    let z = s;
2785
2786    // Probit derivatives at z.
2787    let base = probit_jet(z);
2788    let phi3 = probit_pdfthird_derivative(z); // Phi^{(4)}
2789    let phi4 = probit_pdffourth_derivative(z); // Phi^{(5)}
2790
2791    let r1 = delta * asinh.d1;
2792    let r2 = delta * asinh.d2;
2793    let r3 = delta * asinh.d3;
2794    let r4 = delta * asinh.d4;
2795    let r5 = delta * asinh.d5;
2796
2797    // u1..u5 via Arbogast for g(r(eta)).
2798    let u1 = g1 * r1;
2799    let u2 = g2 * r1 * r1 + g1 * r2;
2800    let u3 = g3 * r1 * r1 * r1 + 3.0 * g2 * r1 * r2 + g1 * r3;
2801    let u4 = g4 * r1.powi(4)
2802        + 6.0 * g3 * r1 * r1 * r2
2803        + 3.0 * g2 * r2 * r2
2804        + 4.0 * g2 * r1 * r3
2805        + g1 * r4;
2806    let u5 = g5 * r1.powi(5)
2807        + 10.0 * g4 * r1 * r1 * r1 * r2
2808        + 15.0 * g3 * r1 * r2 * r2
2809        + 10.0 * g3 * r1 * r1 * r3
2810        + 10.0 * g2 * r2 * r3
2811        + 5.0 * g2 * r1 * r4
2812        + g1 * r5;
2813
2814    // z1..z5 via Arbogast for sinh(u(eta)).
2815    let z1 = c * u1;
2816    let z2 = s * u1 * u1 + c * u2;
2817    let z3 = c * u1 * u1 * u1 + 3.0 * s * u1 * u2 + c * u3;
2818    let z4 =
2819        s * u1.powi(4) + 6.0 * c * u1 * u1 * u2 + 3.0 * s * u2 * u2 + 4.0 * s * u1 * u3 + c * u4;
2820    let z5 = c * u1.powi(5)
2821        + 10.0 * s * u1 * u1 * u1 * u2
2822        + 15.0 * c * u1 * u2 * u2
2823        + 10.0 * c * u1 * u1 * u3
2824        + 10.0 * s * u2 * u3
2825        + 5.0 * s * u1 * u4
2826        + c * u5;
2827
2828    // mu^(5) = Phi^(5)*z1^5 + 10*Phi^(4)*z1^3*z2 + 15*Phi^(3)*z1*z2^2
2829    //        + 10*Phi^(3)*z1^2*z3 + 10*Phi^(2)*z2*z3 + 5*Phi^(2)*z1*z4 + Phi^(1)*z5
2830    let out = phi4 * z1.powi(5)
2831        + 10.0 * phi3 * z1 * z1 * z1 * z2
2832        + 15.0 * base.d3 * z1 * z2 * z2
2833        + 10.0 * base.d3 * z1 * z1 * z3
2834        + 10.0 * base.d2 * z2 * z3
2835        + 5.0 * base.d2 * z1 * z4
2836        + base.d1 * z5;
2837    Ok(canonicalzero(out))
2838}
2839
2840/// SAS eta jet plus epsilon/log-delta partial jets. This is fallible for the
2841/// same reason as the value jet: eta must be finite, and no non-finite eta is
2842/// silently replaced.
2843pub fn sas_inverse_link_jetwith_param_partials(
2844    eta: f64,
2845    epsilon: f64,
2846    log_delta: f64,
2847) -> Result<SasJetWithParamPartials, EstimationError> {
2848    let eta = finite_inverse_link_eta("SAS inverse link", eta)?;
2849    let asinh = asinh_jet5(eta);
2850    let ld_sb = smooth_bound_jet(log_delta, SAS_LOG_DELTA_BOUND);
2851    let (ld_eff, dld_eff_draw) = (ld_sb.g, ld_sb.d1);
2852    let d2ld_eff_draw2 = ld_sb.d2;
2853    let delta = ld_eff.exp();
2854    let ddelta_draw = delta * dld_eff_draw;
2855    let d2delta_draw2 = delta * (dld_eff_draw * dld_eff_draw + d2ld_eff_draw2);
2856    let u_raw = delta * asinh.value + epsilon;
2857    let sb = smooth_bound_jet(u_raw, SAS_U_CLAMP);
2858    let u = sb.g;
2859    let g1 = sb.d1;
2860    let g2 = sb.d2;
2861    let g3 = sb.d3;
2862    let g4 = sb.d4;
2863    let s = u.sinh();
2864    let c = u.cosh();
2865    let z = s;
2866    let a1 = asinh.d1;
2867    let a2 = asinh.d2;
2868    let a3 = asinh.d3;
2869    let r1 = delta * a1;
2870    let r2 = delta * a2;
2871    let r3 = delta * a3;
2872    let u1 = g1 * r1;
2873    let u2 = g2 * r1 * r1 + g1 * r2;
2874    let u3 = g3 * r1 * r1 * r1 + 3.0 * g2 * r1 * r2 + g1 * r3;
2875    let z1 = c * u1;
2876    let z2 = s * u1 * u1 + c * u2;
2877    let z3 = c * u1 * u1 * u1 + 3.0 * s * u1 * u2 + c * u3;
2878
2879    let base = probit_jet(z);
2880    let jet = chain_inverse_link_jet(base, z1, z2, z3);
2881
2882    // Generic chain for parameter t:
2883    // u_t, u1_t, u2_t, u3_t -> z_t,z1_t,z2_t,z3_t -> mu_t,d1_t,d2_t,d3_t
2884    let param_partials = |u_t: f64, u1_t: f64, u2_t: f64, u3_t: f64| -> InverseLinkJet {
2885        let z_t = c * u_t;
2886        let z1_t = s * u_t * u1 + c * u1_t;
2887        let z2_t = c * u_t * u1 * u1 + 2.0 * s * u1 * u1_t + s * u_t * u2 + c * u2_t;
2888        let z3_t = s * u_t * u1 * u1 * u1
2889            + 3.0 * c * u1 * u1 * u1_t
2890            + 3.0 * c * u_t * u1 * u2
2891            + 3.0 * s * (u1_t * u2 + u1 * u2_t)
2892            + s * u_t * u3
2893            + c * u3_t;
2894
2895        InverseLinkJet {
2896            mu: base.d1 * z_t,
2897            d1: base.d2 * z_t * z1 + base.d1 * z1_t,
2898            d2: base.d3 * z_t * z1 * z1
2899                + 2.0 * base.d2 * z1 * z1_t
2900                + base.d2 * z_t * z2
2901                + base.d1 * z2_t,
2902            d3: probit_pdfthird_derivative(z) * z_t * z1.powi(3)
2903                + 3.0 * base.d3 * z1 * z1 * z1_t
2904                + 3.0 * base.d3 * z_t * z1 * z2
2905                + 3.0 * base.d2 * (z1_t * z2 + z1 * z2_t)
2906                + base.d2 * z_t * z3
2907                + base.d1 * z3_t,
2908        }
2909    };
2910
2911    // epsilon partials (raw_u_t = +1).
2912    let rt_eps = 1.0;
2913    let r1t_eps = 0.0;
2914    let r2t_eps = 0.0;
2915    let r3t_eps = 0.0;
2916    let u_eps = g1 * rt_eps;
2917    let u1_eps = g2 * rt_eps * r1 + g1 * r1t_eps;
2918    let u2_eps = g3 * rt_eps * r1 * r1 + 2.0 * g2 * r1 * r1t_eps + g2 * rt_eps * r2 + g1 * r2t_eps;
2919    let u3_eps = g4 * rt_eps * r1 * r1 * r1
2920        + 3.0 * g3 * r1 * r1 * r1t_eps
2921        + 3.0 * g3 * rt_eps * r1 * r2
2922        + 3.0 * g2 * (r1t_eps * r2 + r1 * r2t_eps)
2923        + g2 * rt_eps * r3
2924        + g1 * r3t_eps;
2925    let djet_depsilon = param_partials(u_eps, u1_eps, u2_eps, u3_eps);
2926
2927    // raw log-delta partials (through smooth bounded effective log-delta).
2928    let rt_ld = ddelta_draw * asinh.value;
2929    let r1t_ld = ddelta_draw * a1;
2930    let r2t_ld = ddelta_draw * a2;
2931    let r3t_ld = ddelta_draw * a3;
2932    let u_ld = g1 * rt_ld;
2933    let u1_ld = g2 * rt_ld * r1 + g1 * r1t_ld;
2934    let u2_ld = g3 * rt_ld * r1 * r1 + 2.0 * g2 * r1 * r1t_ld + g2 * rt_ld * r2 + g1 * r2t_ld;
2935    let u3_ld = g4 * rt_ld * r1 * r1 * r1
2936        + 3.0 * g3 * r1 * r1 * r1t_ld
2937        + 3.0 * g3 * rt_ld * r1 * r2
2938        + 3.0 * g2 * (r1t_ld * r2 + r1 * r2t_ld)
2939        + g2 * rt_ld * r3
2940        + g1 * r3t_ld;
2941    let djet_dlog_delta = param_partials(u_ld, u1_ld, u2_ld, u3_ld);
2942
2943    // Exact parameter Hessians. `mu` and `d1` enter the scalar
2944    // log-survival/log-density terms; `d2` is carried as well because the
2945    // observed working weight `W_obs` of the outer link-parameter Hessian
2946    // depends on it (#2665, see `build_link_ext_pair_callback`). Parameter
2947    // order is `(epsilon, raw_log_delta)`.
2948    let r_t = [rt_eps, rt_ld];
2949    let r1_t = [r1t_eps, r1t_ld];
2950    let r2_t = [r2t_eps, r2t_ld];
2951    let r_tt = [[0.0, 0.0], [0.0, d2delta_draw2 * asinh.value]];
2952    let r1_tt = [[0.0, 0.0], [0.0, d2delta_draw2 * a1]];
2953    let r2_tt = [[0.0, 0.0], [0.0, d2delta_draw2 * a2]];
2954    let u_t = [u_eps, u_ld];
2955    let u1_t = [u1_eps, u1_ld];
2956    let u2_t = [u2_eps, u2_ld];
2957    let z_t = [c * u_t[0], c * u_t[1]];
2958    let z1_t = [
2959        s * u_t[0] * u1 + c * u1_t[0],
2960        s * u_t[1] * u1 + c * u1_t[1],
2961    ];
2962    let z2_t = [
2963        c * u_t[0] * u1 * u1 + 2.0 * s * u1 * u1_t[0] + s * u_t[0] * u2 + c * u2_t[0],
2964        c * u_t[1] * u1 * u1 + 2.0 * s * u1 * u1_t[1] + s * u_t[1] * u2 + c * u2_t[1],
2965    ];
2966    let phi3 = probit_pdfthird_derivative(z);
2967    let mut d2mu_dparams2 = Array2::<f64>::zeros((2, 2));
2968    let mut d2d1_dparams2 = Array2::<f64>::zeros((2, 2));
2969    let mut d2d2_dparams2 = Array2::<f64>::zeros((2, 2));
2970    for j in 0..2 {
2971        for k in j..2 {
2972            let u_jk = g2 * r_t[j] * r_t[k] + g1 * r_tt[j][k];
2973            let u1_jk = g3 * r_t[j] * r_t[k] * r1
2974                + g2 * r_tt[j][k] * r1
2975                + g2 * r_t[j] * r1_t[k]
2976                + g2 * r_t[k] * r1_t[j]
2977                + g1 * r1_tt[j][k];
2978            // u2 = g2 r1² + g1 r2, differentiated twice in the parameters.
2979            let u2_jk = g4 * r_t[j] * r_t[k] * r1 * r1
2980                + g3 * r_tt[j][k] * r1 * r1
2981                + 2.0 * g3 * r_t[j] * r1 * r1_t[k]
2982                + 2.0 * g3 * r_t[k] * r1 * r1_t[j]
2983                + 2.0 * g2 * (r1_t[j] * r1_t[k] + r1 * r1_tt[j][k])
2984                + g3 * r_t[j] * r_t[k] * r2
2985                + g2 * r_tt[j][k] * r2
2986                + g2 * r_t[j] * r2_t[k]
2987                + g2 * r_t[k] * r2_t[j]
2988                + g1 * r2_tt[j][k];
2989            let z_jk = s * u_t[j] * u_t[k] + c * u_jk;
2990            let z1_jk = c * u_t[j] * u_t[k] * u1
2991                + s * u_jk * u1
2992                + s * u_t[j] * u1_t[k]
2993                + s * u_t[k] * u1_t[j]
2994                + c * u1_jk;
2995            // z2 = sinh(u) u1² + cosh(u) u2, differentiated twice.
2996            let z2_jk = s * u_t[j] * u_t[k] * u1 * u1
2997                + c * u_jk * u1 * u1
2998                + 2.0 * c * u_t[j] * u1 * u1_t[k]
2999                + 2.0 * c * u_t[k] * u1 * u1_t[j]
3000                + 2.0 * s * (u1_t[j] * u1_t[k] + u1 * u1_jk)
3001                + c * u_t[j] * u_t[k] * u2
3002                + s * u_jk * u2
3003                + s * u_t[j] * u2_t[k]
3004                + s * u_t[k] * u2_t[j]
3005                + c * u2_jk;
3006            let mu_jk = base.d2 * z_t[j] * z_t[k] + base.d1 * z_jk;
3007            let d1_jk = base.d3 * z_t[j] * z_t[k] * z1
3008                + base.d2 * z_jk * z1
3009                + base.d2 * z_t[j] * z1_t[k]
3010                + base.d2 * z_t[k] * z1_t[j]
3011                + base.d1 * z1_jk;
3012            // d2 = Phi''(z) z1² + Phi'(z) z2, differentiated twice; `phi3` is
3013            // the next probit derivative in the same ladder.
3014            let d2_jk = phi3 * z_t[j] * z_t[k] * z1 * z1
3015                + base.d3 * z_jk * z1 * z1
3016                + 2.0 * base.d3 * z_t[j] * z1 * z1_t[k]
3017                + 2.0 * base.d3 * z_t[k] * z1 * z1_t[j]
3018                + 2.0 * base.d2 * (z1_t[j] * z1_t[k] + z1 * z1_jk)
3019                + base.d3 * z_t[j] * z_t[k] * z2
3020                + base.d2 * z_jk * z2
3021                + base.d2 * z_t[j] * z2_t[k]
3022                + base.d2 * z_t[k] * z2_t[j]
3023                + base.d1 * z2_jk;
3024            d2mu_dparams2[[j, k]] = mu_jk;
3025            d2mu_dparams2[[k, j]] = mu_jk;
3026            d2d1_dparams2[[j, k]] = d1_jk;
3027            d2d1_dparams2[[k, j]] = d1_jk;
3028            d2d2_dparams2[[j, k]] = d2_jk;
3029            d2d2_dparams2[[k, j]] = d2_jk;
3030        }
3031    }
3032
3033    Ok(SasJetWithParamPartials {
3034        jet,
3035        djet_depsilon,
3036        djet_dlog_delta,
3037        d2mu_dparams2,
3038        d2d1_dparams2,
3039        d2d2_dparams2,
3040    })
3041}
3042
3043#[cfg(test)]
3044mod tests {
3045    use super::*;
3046    use gam_problem::{InverseLink, LikelihoodSpec, LinkComponent, MixtureLinkSpec, SasLinkState};
3047
3048    fn assert_finite_eta_domain_error(
3049        error: EstimationError,
3050        expected_link: &'static str,
3051        eta: f64,
3052    ) {
3053        match error {
3054            EstimationError::InverseLinkDomainViolation {
3055                link,
3056                eta: rejected,
3057                lower,
3058                upper,
3059            } => {
3060                assert_eq!(link, expected_link);
3061                if eta.is_nan() {
3062                    assert!(rejected.is_nan());
3063                } else {
3064                    assert_eq!(rejected, eta);
3065                }
3066                assert_eq!(lower, -f64::MAX);
3067                assert_eq!(upper, f64::MAX);
3068            }
3069            other => panic!("expected typed finite-eta domain refusal, got {other}"),
3070        }
3071    }
3072
3073    #[test]
3074    fn log_link_solver_value_gradient_is_consistent_near_both_domain_edges() {
3075        let link = InverseLink::Standard(StandardLink::Log);
3076        let h = 1.0e-5;
3077        for eta in [
3078            LOG_LINK_SOLVER_ETA_MIN + 1.0,
3079            0.0,
3080            LOG_LINK_SOLVER_ETA_MAX - 1.0,
3081        ] {
3082            let jet = inverse_link_jet_for_inverse_link(&link, eta).expect("interior jet");
3083            let eta_plus = eta + h;
3084            let eta_minus = eta - h;
3085            let mu_plus = inverse_link_jet_for_inverse_link(&link, eta_plus)
3086                .expect("plus jet")
3087                .mu;
3088            let mu_minus = inverse_link_jet_for_inverse_link(&link, eta_minus)
3089                .expect("minus jet")
3090                .mu;
3091            let finite_difference = (mu_plus - mu_minus) / (eta_plus - eta_minus);
3092            let relative_error = ((finite_difference - jet.d1) / jet.d1).abs();
3093            assert!(
3094                relative_error < 5.0e-10,
3095                "log-link value/gradient mismatch at eta={eta}: analytic={}, finite_difference={}, relative_error={relative_error}",
3096                jet.d1,
3097                finite_difference
3098            );
3099        }
3100    }
3101
3102    #[test]
3103    fn subnormal_inverse_link_derivatives_are_preserved_not_plateaued() {
3104        let left_eta = -743.0_f64;
3105        let left_scale = left_eta.exp();
3106        assert!(left_scale > 0.0 && left_scale < f64::MIN_POSITIVE);
3107        let left = logit_inverse_link_jet5(left_eta);
3108        for (order, derivative) in [left.d1, left.d2, left.d3, left.d4, left.d5]
3109            .into_iter()
3110            .enumerate()
3111        {
3112            assert!(
3113                derivative > 0.0 && derivative < f64::MIN_POSITIVE,
3114                "left-tail logit derivative order {} lost its represented subnormal: {derivative}",
3115                order + 1
3116            );
3117        }
3118
3119        let right_eta = 743.0_f64;
3120        let right_scale = (-right_eta).exp();
3121        assert!(right_scale > 0.0 && right_scale < f64::MIN_POSITIVE);
3122        let right = logit_inverse_link_jet5(right_eta);
3123        for (order, derivative, sign) in [
3124            (1, right.d1, 1.0),
3125            (2, right.d2, -1.0),
3126            (3, right.d3, 1.0),
3127            (4, right.d4, -1.0),
3128            (5, right.d5, 1.0),
3129        ] {
3130            assert_eq!(derivative.signum(), sign, "wrong order-{order} tail sign");
3131            assert!(
3132                derivative.abs() > 0.0 && derivative.abs() < f64::MIN_POSITIVE,
3133                "right-tail logit derivative order {order} lost its represented subnormal: {derivative}"
3134            );
3135        }
3136
3137        let royston_eta = 735.0_f64.ln();
3138        let royston = royston_parmar_inverse_link_jet(royston_eta)
3139            .expect("finite Royston-Parmar subnormal-tail eta");
3140        assert!(
3141            royston.d1 < 0.0 && royston.d1.abs() < f64::MIN_POSITIVE,
3142            "Royston-Parmar exact tail derivative must retain its subnormal: {}",
3143            royston.d1
3144        );
3145    }
3146
3147    #[test]
3148    fn sas_all_derivative_seams_refuse_nonfinite_eta_with_one_typed_contract() {
3149        let state = sas_link_state_from_raw(0.25, -0.35).expect("SAS state");
3150        let link = InverseLink::Sas(state);
3151        for eta in [f64::NEG_INFINITY, f64::INFINITY, f64::NAN] {
3152            assert_finite_eta_domain_error(
3153                sas_inverse_link_jet(eta, state.epsilon, state.log_delta)
3154                    .expect_err("SAS full jet must refuse"),
3155                "SAS inverse link",
3156                eta,
3157            );
3158            assert_finite_eta_domain_error(
3159                sas_inverse_link_mu_d1(eta, state.epsilon, state.log_delta)
3160                    .expect_err("SAS mu/d1 must refuse"),
3161                "SAS inverse link",
3162                eta,
3163            );
3164            assert_finite_eta_domain_error(
3165                sas_inverse_link_pdfthird_derivative(eta, state.epsilon, state.log_delta)
3166                    .expect_err("SAS fourth derivative must refuse"),
3167                "SAS inverse link",
3168                eta,
3169            );
3170            assert_finite_eta_domain_error(
3171                sas_inverse_link_pdffourth_derivative(eta, state.epsilon, state.log_delta)
3172                    .expect_err("SAS fifth derivative must refuse"),
3173                "SAS inverse link",
3174                eta,
3175            );
3176            assert_finite_eta_domain_error(
3177                sas_inverse_link_jetwith_param_partials(eta, state.epsilon, state.log_delta)
3178                    .expect_err("SAS parameter partials must refuse"),
3179                "SAS inverse link",
3180                eta,
3181            );
3182            assert_finite_eta_domain_error(
3183                inverse_link_jet_for_inverse_link(&link, eta).expect_err("SAS kernel must refuse"),
3184                "SAS inverse link",
3185                eta,
3186            );
3187            assert_finite_eta_domain_error(
3188                inverse_link_mu_d1_for_inverse_link(&link, eta)
3189                    .expect_err("SAS fast dispatch must refuse"),
3190                "SAS inverse link",
3191                eta,
3192            );
3193            assert_finite_eta_domain_error(
3194                inverse_link_pdfthird_derivative_for_inverse_link(&link, eta)
3195                    .expect_err("SAS fourth-derivative dispatch must refuse"),
3196                "SAS inverse link",
3197                eta,
3198            );
3199            assert_finite_eta_domain_error(
3200                inverse_link_pdffourth_derivative_for_inverse_link(&link, eta)
3201                    .expect_err("SAS fifth-derivative dispatch must refuse"),
3202                "SAS inverse link",
3203                eta,
3204            );
3205        }
3206    }
3207
3208    #[test]
3209    fn sas_jets_are_finite_at_both_finite_f64_domain_edges() {
3210        let state = sas_link_state_from_raw(0.25, -0.35).expect("SAS state");
3211        for eta in [-f64::MAX, f64::MAX] {
3212            let jet = sas_inverse_link_jet(eta, state.epsilon, state.log_delta)
3213                .expect("finite SAS boundary jet");
3214            let partials =
3215                sas_inverse_link_jetwith_param_partials(eta, state.epsilon, state.log_delta)
3216                    .expect("finite SAS boundary partials");
3217            let h4 = sas_inverse_link_pdfthird_derivative(eta, state.epsilon, state.log_delta)
3218                .expect("finite SAS boundary fourth derivative");
3219            let h5 = sas_inverse_link_pdffourth_derivative(eta, state.epsilon, state.log_delta)
3220                .expect("finite SAS boundary fifth derivative");
3221            for value in [
3222                jet.mu,
3223                jet.d1,
3224                jet.d2,
3225                jet.d3,
3226                partials.jet.mu,
3227                partials.jet.d1,
3228                partials.jet.d2,
3229                partials.jet.d3,
3230                partials.djet_depsilon.mu,
3231                partials.djet_depsilon.d1,
3232                partials.djet_depsilon.d2,
3233                partials.djet_depsilon.d3,
3234                partials.djet_dlog_delta.mu,
3235                partials.djet_dlog_delta.d1,
3236                partials.djet_dlog_delta.d2,
3237                partials.djet_dlog_delta.d3,
3238                h4,
3239                h5,
3240            ] {
3241                assert!(
3242                    value.is_finite(),
3243                    "non-finite SAS boundary jet at eta={eta}: {value}"
3244                );
3245            }
3246        }
3247    }
3248
3249    #[test]
3250    fn royston_parmar_exact_jet_has_no_former_minus_thirty_plateau() {
3251        let left =
3252            royston_parmar_inverse_link_jet(-30.0 - 1.0e-6).expect("finite Royston-Parmar eta");
3253        let center = royston_parmar_inverse_link_jet(-30.0).expect("finite Royston-Parmar eta");
3254        let right =
3255            royston_parmar_inverse_link_jet(-30.0 + 1.0e-6).expect("finite Royston-Parmar eta");
3256
3257        assert!(
3258            left.d1 < 0.0,
3259            "the exact left tail must not be a constant plateau"
3260        );
3261        assert!(center.d1 < 0.0 && right.d1 < 0.0);
3262        let left_relative = ((left.d1 - center.d1) / center.d1).abs();
3263        let right_relative = ((right.d1 - center.d1) / center.d1).abs();
3264        assert!(
3265            left_relative < 2.0e-6,
3266            "left derivative kink: {left_relative}"
3267        );
3268        assert!(
3269            right_relative < 2.0e-6,
3270            "right derivative kink: {right_relative}"
3271        );
3272
3273        for eta in [-f64::MAX, -40.0, -30.0, 0.0, 7.0, 30.0, 40.0, f64::MAX] {
3274            let jet = royston_parmar_inverse_link_jet(eta).expect("finite Royston-Parmar eta");
3275            for value in [jet.mu, jet.d1, jet.d2, jet.d3] {
3276                assert!(
3277                    value.is_finite(),
3278                    "non-finite Royston-Parmar jet at eta={eta}: {value}"
3279                );
3280            }
3281        }
3282    }
3283
3284    #[test]
3285    fn mixture_jet_rho_partials_matchfd() {
3286        let spec = MixtureLinkSpec {
3287            components: vec![
3288                LinkComponent::Probit,
3289                LinkComponent::Logit,
3290                LinkComponent::CLogLog,
3291                LinkComponent::Cauchit,
3292            ],
3293            initial_rho: Array1::from_vec(vec![0.3, -0.6, 0.2]),
3294        };
3295        let state = state_fromspec(&spec).expect("state");
3296        let eta = 0.35;
3297        let out = mixture_inverse_link_jetwith_rho_partials(&state, eta);
3298        let h = 1e-6;
3299        for j in 0..state.rho.len() {
3300            let mut rp = state.rho.clone();
3301            rp[j] += h;
3302            let sp = MixtureLinkSpec {
3303                components: state.components.clone(),
3304                initial_rho: rp,
3305            };
3306            let jp = mixture_inverse_link_jet(&state_fromspec(&sp).expect("sp"), eta);
3307            let mut rm = state.rho.clone();
3308            rm[j] -= h;
3309            let sm = MixtureLinkSpec {
3310                components: state.components.clone(),
3311                initial_rho: rm,
3312            };
3313            let jm = mixture_inverse_link_jet(&state_fromspec(&sm).expect("sm"), eta);
3314            let fd = InverseLinkJet {
3315                mu: (jp.mu - jm.mu) / (2.0 * h),
3316                d1: (jp.d1 - jm.d1) / (2.0 * h),
3317                d2: (jp.d2 - jm.d2) / (2.0 * h),
3318                d3: (jp.d3 - jm.d3) / (2.0 * h),
3319            };
3320            let an = out.djet_drho[j];
3321            assert_eq!(an.mu.signum(), fd.mu.signum());
3322            assert_eq!(an.d1.signum(), fd.d1.signum());
3323            assert_eq!(an.d2.signum(), fd.d2.signum());
3324            assert_eq!(an.d3.signum(), fd.d3.signum());
3325            assert!((an.mu - fd.mu).abs() < 1e-6);
3326            assert!((an.d1 - fd.d1).abs() < 1e-6);
3327            assert!((an.d2 - fd.d2).abs() < 1e-6);
3328            assert!((an.d3 - fd.d3).abs() < 1e-6);
3329        }
3330    }
3331
3332    /// #2665: `d2d2_*` is the parameter Hessian of `d2 = d²mu/deta²`, the
3333    /// ingredient the observed working weight `W_obs` depends on. Each entry is
3334    /// differenced against the *analytic first* partial `djet_*.d2` (itself
3335    /// FD-gated by `sas_param_partials_matchfd` above), so a sign or chain
3336    /// error in the new second-order ladder cannot hide behind the truncation
3337    /// error of a second difference of the raw value.
3338    #[test]
3339    fn sas_d2d2_param_hessian_matchesfd_of_the_first_partial() {
3340        let eta = 0.37;
3341        let epsilon = -0.12;
3342        let log_delta = 0.21;
3343        let h = 1e-6;
3344        let out = sas_inverse_link_jetwith_param_partials(eta, epsilon, log_delta)
3345            .expect("finite SAS eta");
3346        let at = |e: f64, l: f64| {
3347            sas_inverse_link_jetwith_param_partials(eta, e, l).expect("finite SAS eta")
3348        };
3349        // Column 0 = d/d(epsilon), column 1 = d/d(raw log delta).
3350        let d_eps = [
3351            (at(epsilon + h, log_delta).djet_depsilon.d2
3352                - at(epsilon - h, log_delta).djet_depsilon.d2)
3353                / (2.0 * h),
3354            (at(epsilon, log_delta + h).djet_depsilon.d2
3355                - at(epsilon, log_delta - h).djet_depsilon.d2)
3356                / (2.0 * h),
3357        ];
3358        let d_ld = [
3359            (at(epsilon + h, log_delta).djet_dlog_delta.d2
3360                - at(epsilon - h, log_delta).djet_dlog_delta.d2)
3361                / (2.0 * h),
3362            (at(epsilon, log_delta + h).djet_dlog_delta.d2
3363                - at(epsilon, log_delta - h).djet_dlog_delta.d2)
3364                / (2.0 * h),
3365        ];
3366        for (idx, reference) in [((0, 0), d_eps[0]), ((0, 1), d_eps[1])] {
3367            let got = out.d2d2_dparams2[[idx.0, idx.1]];
3368            assert!(
3369                (got - reference).abs() < 1e-5 * (1.0 + reference.abs()),
3370                "d2d2_dparams2{idx:?} = {got:e} against FD {reference:e}"
3371            );
3372        }
3373        for (idx, reference) in [((1, 0), d_ld[0]), ((1, 1), d_ld[1])] {
3374            let got = out.d2d2_dparams2[[idx.0, idx.1]];
3375            assert!(
3376                (got - reference).abs() < 1e-5 * (1.0 + reference.abs()),
3377                "d2d2_dparams2{idx:?} = {got:e} against FD {reference:e}"
3378            );
3379        }
3380        assert_eq!(out.d2d2_dparams2[[0, 1]], out.d2d2_dparams2[[1, 0]]);
3381    }
3382
3383    /// Same gate for the beta-logistic flexible link, whose `d2 = d1·t` chain
3384    /// is a different derivation from the SAS probit ladder.
3385    #[test]
3386    fn beta_logistic_d2d2_param_hessian_matchesfd_of_the_first_partial() {
3387        let eta = -0.29;
3388        let epsilon = 0.18;
3389        let log_shape_center = 0.24;
3390        let h = 1e-6;
3391        let out =
3392            beta_logistic_inverse_link_jetwith_param_partials(eta, log_shape_center, epsilon);
3393        let at =
3394            |e: f64, l: f64| beta_logistic_inverse_link_jetwith_param_partials(eta, l, e);
3395        let reference = [
3396            [
3397                (at(epsilon + h, log_shape_center).djet_depsilon.d2
3398                    - at(epsilon - h, log_shape_center).djet_depsilon.d2)
3399                    / (2.0 * h),
3400                (at(epsilon, log_shape_center + h).djet_depsilon.d2
3401                    - at(epsilon, log_shape_center - h).djet_depsilon.d2)
3402                    / (2.0 * h),
3403            ],
3404            [
3405                (at(epsilon + h, log_shape_center).djet_dlog_delta.d2
3406                    - at(epsilon - h, log_shape_center).djet_dlog_delta.d2)
3407                    / (2.0 * h),
3408                (at(epsilon, log_shape_center + h).djet_dlog_delta.d2
3409                    - at(epsilon, log_shape_center - h).djet_dlog_delta.d2)
3410                    / (2.0 * h),
3411            ],
3412        ];
3413        for j in 0..2 {
3414            for k in 0..2 {
3415                let got = out.d2d2_dparams2[[j, k]];
3416                let want = reference[j][k];
3417                assert!(
3418                    (got - want).abs() < 1e-4 * (1.0 + want.abs()),
3419                    "beta-logistic d2d2_dparams2[{j},{k}] = {got:e} against FD {want:e}"
3420                );
3421            }
3422        }
3423    }
3424
3425    /// Same gate for the mixture link's free-logit coordinates.
3426    #[test]
3427    fn mixture_d2d2_rho_hessian_matchesfd_of_the_first_partial() {
3428        let state = state_fromspec(&MixtureLinkSpec {
3429            components: vec![
3430                LinkComponent::Probit,
3431                LinkComponent::Logit,
3432                LinkComponent::CLogLog,
3433            ],
3434            initial_rho: Array1::from_vec(vec![0.31, -0.17]),
3435        })
3436        .expect("valid three-component mixture");
3437        let eta = 0.42;
3438        let h = 1e-6;
3439        let out = mixture_inverse_link_jetwith_rho_partials(&state, eta);
3440        let m = state.rho.len();
3441        let shifted = |j: usize, delta: f64| {
3442            let mut rho = state.rho.clone();
3443            rho[j] += delta;
3444            state_fromspec(&MixtureLinkSpec {
3445                components: state.components.clone(),
3446                initial_rho: rho,
3447            })
3448            .expect("valid shifted mixture")
3449        };
3450        for j in 0..m {
3451            for k in 0..m {
3452                let plus = mixture_inverse_link_jetwith_rho_partials(&shifted(k, h), eta);
3453                let minus = mixture_inverse_link_jetwith_rho_partials(&shifted(k, -h), eta);
3454                let want = (plus.djet_drho[j].d2 - minus.djet_drho[j].d2) / (2.0 * h);
3455                let got = out.d2d2_drho2[[j, k]];
3456                assert!(
3457                    (got - want).abs() < 1e-5 * (1.0 + want.abs()),
3458                    "mixture d2d2_drho2[{j},{k}] = {got:e} against FD {want:e}"
3459                );
3460            }
3461        }
3462    }
3463
3464    #[test]
3465    fn mixture_second_partials_obey_equal_weight_two_component_identity() {
3466        let state = state_fromspec(&MixtureLinkSpec {
3467            components: vec![LinkComponent::Probit, LinkComponent::Logit],
3468            initial_rho: Array1::from_vec(vec![0.0]),
3469        })
3470        .expect("valid two-component mixture");
3471        let out = mixture_inverse_link_jetwith_rho_partials(&state, 0.37);
3472        // For two components, f''(rho)=pi(1-pi)(1-2pi)(f0-f1), so both
3473        // response channels have exactly zero curvature at the equal-weight
3474        // coordinate rho=0. This checks the analytic softmax Hessian without
3475        // using a finite-difference oracle.
3476        assert_eq!(out.d2mu_drho2.dim(), (1, 1));
3477        assert_eq!(out.d2d1_drho2.dim(), (1, 1));
3478        assert_eq!(out.d2mu_drho2[[0, 0]], 0.0);
3479        assert_eq!(out.d2d1_drho2[[0, 0]], 0.0);
3480    }
3481
3482    #[test]
3483    fn sas_param_partials_matchfd() {
3484        let eta = 0.37;
3485        let epsilon = -0.12;
3486        let log_delta = 0.21;
3487        let out = sas_inverse_link_jetwith_param_partials(eta, epsilon, log_delta)
3488            .expect("finite SAS eta");
3489        let h = 1e-6;
3490
3491        let ep_p = sas_inverse_link_jet(eta, epsilon + h, log_delta).expect("finite SAS eta");
3492        let ep_m = sas_inverse_link_jet(eta, epsilon - h, log_delta).expect("finite SAS eta");
3493        let fd_ep = InverseLinkJet {
3494            mu: (ep_p.mu - ep_m.mu) / (2.0 * h),
3495            d1: (ep_p.d1 - ep_m.d1) / (2.0 * h),
3496            d2: (ep_p.d2 - ep_m.d2) / (2.0 * h),
3497            d3: (ep_p.d3 - ep_m.d3) / (2.0 * h),
3498        };
3499        assert_eq!(out.djet_depsilon.mu.signum(), fd_ep.mu.signum());
3500        assert_eq!(out.djet_depsilon.d1.signum(), fd_ep.d1.signum());
3501        assert_eq!(out.djet_depsilon.d2.signum(), fd_ep.d2.signum());
3502        assert_eq!(out.djet_depsilon.d3.signum(), fd_ep.d3.signum());
3503        assert!((out.djet_depsilon.mu - fd_ep.mu).abs() < 5e-5);
3504        assert!((out.djet_depsilon.d1 - fd_ep.d1).abs() < 5e-5);
3505        assert!((out.djet_depsilon.d2 - fd_ep.d2).abs() < 5e-5);
3506        assert!((out.djet_depsilon.d3 - fd_ep.d3).abs() < 5e-4);
3507
3508        let ld_p = sas_inverse_link_jet(eta, epsilon, log_delta + h).expect("finite SAS eta");
3509        let ld_m = sas_inverse_link_jet(eta, epsilon, log_delta - h).expect("finite SAS eta");
3510        let fd_ld = InverseLinkJet {
3511            mu: (ld_p.mu - ld_m.mu) / (2.0 * h),
3512            d1: (ld_p.d1 - ld_m.d1) / (2.0 * h),
3513            d2: (ld_p.d2 - ld_m.d2) / (2.0 * h),
3514            d3: (ld_p.d3 - ld_m.d3) / (2.0 * h),
3515        };
3516        assert_eq!(out.djet_dlog_delta.mu.signum(), fd_ld.mu.signum());
3517        assert_eq!(out.djet_dlog_delta.d1.signum(), fd_ld.d1.signum());
3518        assert_eq!(out.djet_dlog_delta.d2.signum(), fd_ld.d2.signum());
3519        assert_eq!(out.djet_dlog_delta.d3.signum(), fd_ld.d3.signum());
3520        assert!((out.djet_dlog_delta.mu - fd_ld.mu).abs() < 5e-5);
3521        assert!((out.djet_dlog_delta.d1 - fd_ld.d1).abs() < 5e-5);
3522        assert!((out.djet_dlog_delta.d2 - fd_ld.d2).abs() < 5e-5);
3523        assert!((out.djet_dlog_delta.d3 - fd_ld.d3).abs() < 5e-4);
3524    }
3525
3526    #[test]
3527    fn sas_second_partials_have_exact_center_identities() {
3528        let out = sas_inverse_link_jetwith_param_partials(0.0, 0.0, 0.0)
3529            .expect("finite SAS center");
3530        let phi0 = normal_pdf(0.0);
3531        // At the reduction center (η=0, ε=0, log_δ=0) the bounded latent map is on
3532        // its exact-identity interior, so `u = ε + asinh(η)/… ` composes with no
3533        // bounded-map curvature. The ε–ε second partial of `d1 = μ'` is therefore
3534        // the TRUE sinh-arcsinh value 0 — not the old `-2·φ(0)/SAS_U_CLAMP²`, which
3535        // was precisely the spurious `tanh` third-derivative `g'''(0) = -2/B²` that
3536        // this fix removes. Expanding `d1(ε) = φ(sinh ε)·cosh ε = φ(0)(1 + O(ε⁴))`
3537        // at η=0 confirms `∂²d1/∂ε² = 0` exactly.
3538        assert_eq!(out.d2mu_dparams2, Array2::<f64>::zeros((2, 2)));
3539        assert_eq!(out.d2d1_dparams2[[0, 1]], out.d2d1_dparams2[[1, 0]]);
3540        assert_eq!(
3541            out.d2d1_dparams2[[0, 0]], 0.0,
3542            "ε–ε ∂²(μ') must be exactly zero at the identity-interior center"
3543        );
3544        assert!((out.d2d1_dparams2[[1, 1]] - phi0).abs() < 1.0e-15);
3545        assert_eq!(out.d2d1_dparams2[[0, 1]], 0.0);
3546    }
3547
3548    /// #1876 closability isolation gate. The SAS-link binomial FAMILY score at
3549    /// fixed η is `∂ℓ/∂ε = a1 · ∂μ/∂ε`, with the binomial score
3550    /// `a1 = w(y/μ − (1−y)/(1−μ))` and `∂μ/∂ε = djet_depsilon.mu`. This proves
3551    /// that single source is correct — in SIGN and MAGNITUDE — against an
3552    /// independent finite difference of the row log-likelihood over a whole
3553    /// (η, ε, log_δ) grid and both responses (`sas_param_partials_matchfd` only
3554    /// checks the pointwise link partials at one point; this composes them into
3555    /// the objective-level family score).
3556    ///
3557    /// Part 2 reproduces the issue's own symptom deterministically: plant
3558    /// ε*=0.38, δ*=1 and use expected fractional responses yᵢ=μ*(ηᵢ). By the
3559    /// score identity the summed data-fit ∂ℓ/∂ε then vanishes exactly at ε* and
3560    /// the negative-log-likelihood profile is minimized there — the summed
3561    /// ∂(NLL)/∂ε is strongly negative below ε* (pushes ε UP toward the truth),
3562    /// zero at ε*, strongly positive above, and STRICTLY increasing through it.
3563    /// That strict monotonicity is exactly what distinguishes +κ/skew from −κ,
3564    /// the sign-blindness #1876 reported. With the family derivative certified
3565    /// here, any wrong-sign ε recovery is provably the OUTER REML envelope path
3566    /// (the capped-β̂ / KKT-residual clobber fixed in 574129459), not this
3567    /// derivative.
3568    #[test]
3569    fn sas_family_score_depsilon_matches_fd_and_reproduces_profile_1876() {
3570        let h = 1e-6;
3571        let mu_at = |eta: f64, eps: f64, ld: f64| {
3572            sas_inverse_link_jet(eta, eps, ld)
3573                .expect("finite SAS eta")
3574                .mu
3575        };
3576        let dmu_deps = |eta: f64, eps: f64, ld: f64| {
3577            sas_inverse_link_jetwith_param_partials(eta, eps, ld)
3578                .expect("finite SAS eta")
3579                .djet_depsilon
3580                .mu
3581        };
3582        // Binomial row log-likelihood and score dℓ/dμ (== link_binomial_aux.a1).
3583        let row_ll = |y: f64, w: f64, mu: f64| w * (y * mu.ln() + (1.0 - y) * (1.0 - mu).ln());
3584        let a1 = |y: f64, w: f64, mu: f64| w * (y / mu - (1.0 - y) / (1.0 - mu));
3585
3586        // ── Part 1: pointwise family-score correctness across a grid. ──
3587        let etas = [-1.0, -0.6, -0.2, 0.15, 0.5, 0.9];
3588        let epsilons = [-0.5, -0.2, 0.0, 0.3, 0.6];
3589        let log_deltas = [-0.3, 0.0, 0.4];
3590        for &eta in &etas {
3591            for &eps in &epsilons {
3592                for &ld in &log_deltas {
3593                    let mu0 = mu_at(eta, eps, ld);
3594                    // Stay in the numerically comfortable interior; the far tail
3595                    // is covered by `sas_jet_extreme_inputs_stay_finite`.
3596                    if !(0.02..=0.98).contains(&mu0) {
3597                        continue;
3598                    }
3599                    let dmu = dmu_deps(eta, eps, ld);
3600                    for &y in &[0.0_f64, 1.0] {
3601                        let analytic = a1(y, 1.0, mu0) * dmu; // dℓ/dε
3602                        let fd = (row_ll(y, 1.0, mu_at(eta, eps + h, ld))
3603                            - row_ll(y, 1.0, mu_at(eta, eps - h, ld)))
3604                            / (2.0 * h);
3605                        let scale = analytic.abs().max(fd.abs()).max(1.0);
3606                        assert_eq!(
3607                            analytic.signum(),
3608                            fd.signum(),
3609                            "∂ℓ/∂ε sign mismatch η={eta} ε={eps} log_δ={ld} y={y}: \
3610                             analytic={analytic:e} fd={fd:e}"
3611                        );
3612                        assert!(
3613                            (analytic - fd).abs() < 1e-5 * scale,
3614                            "∂ℓ/∂ε magnitude mismatch η={eta} ε={eps} log_δ={ld} y={y}: \
3615                             analytic={analytic:e} fd={fd:e}"
3616                        );
3617                    }
3618                }
3619            }
3620        }
3621
3622        // ── Part 2: summed-profile symptom reproduction (deterministic). ──
3623        let eps_true = 0.38;
3624        let ld_true = 0.0;
3625        let etas_ds: Vec<f64> = (0..21).map(|i| -1.0 + 0.1 * i as f64).collect();
3626        let y: Vec<f64> = etas_ds
3627            .iter()
3628            .map(|&e| mu_at(e, eps_true, ld_true))
3629            .collect();
3630
3631        // Summed ∂(NLL)/∂ε = −Σ a1·∂μ/∂ε, analytic and by FD of the summed NLL.
3632        let grad_nll = |eps: f64| -> (f64, f64) {
3633            let mut analytic = 0.0;
3634            let mut nll_p = 0.0;
3635            let mut nll_m = 0.0;
3636            for (i, &eta) in etas_ds.iter().enumerate() {
3637                let mu0 = mu_at(eta, eps, ld_true);
3638                analytic += -a1(y[i], 1.0, mu0) * dmu_deps(eta, eps, ld_true);
3639                nll_p += -row_ll(y[i], 1.0, mu_at(eta, eps + h, ld_true));
3640                nll_m += -row_ll(y[i], 1.0, mu_at(eta, eps - h, ld_true));
3641            }
3642            (analytic, (nll_p - nll_m) / (2.0 * h))
3643        };
3644
3645        // (a) analytic == FD at every probe ε.
3646        for &eps in &[0.0, eps_true, 0.6] {
3647            let (analytic, fd) = grad_nll(eps);
3648            let scale = analytic.abs().max(fd.abs()).max(1.0);
3649            assert!(
3650                (analytic - fd).abs() < 1e-4 * scale,
3651                "summed ∂NLL/∂ε analytic≠fd at ε={eps}: {analytic:e} vs {fd:e}"
3652            );
3653        }
3654        // (b) minimum exactly at the planted ε*: strictly increasing through it.
3655        let (g_below, _) = grad_nll(0.0);
3656        let (g_at, _) = grad_nll(eps_true);
3657        let (g_above, _) = grad_nll(0.6);
3658        assert!(
3659            g_below < -1.0,
3660            "expected strongly negative ∂NLL/∂ε below ε* (pushes ε up toward truth), got {g_below:e}"
3661        );
3662        assert!(
3663            g_at.abs() < 1e-6,
3664            "expected ≈0 ∂NLL/∂ε at the planted ε* (score identity), got {g_at:e}"
3665        );
3666        assert!(
3667            g_above > 1.0,
3668            "expected strongly positive ∂NLL/∂ε above ε*, got {g_above:e}"
3669        );
3670        assert!(
3671            g_below < g_at && g_at < g_above,
3672            "∂NLL/∂ε must strictly increase through ε* (distinguishes ±ε): \
3673             {g_below:e} < {g_at:e} < {g_above:e}"
3674        );
3675    }
3676
3677    #[test]
3678    fn sas_jet_extreme_inputs_stay_finite() {
3679        let cases = [
3680            (-1e6, 0.0, 0.0),
3681            (1e6, 0.0, 0.0),
3682            (3.0, 12.0, 12.0),
3683            (-3.0, -12.0, -12.0),
3684            (0.5, 40.0, 10.0),
3685            (0.5, -40.0, -10.0),
3686        ];
3687        for (eta, eps, log_delta) in cases {
3688            let j = sas_inverse_link_jet(eta, eps, log_delta).expect("finite SAS eta");
3689            assert!(j.mu.is_finite());
3690            assert!(j.d1.is_finite());
3691            assert!(j.d2.is_finite());
3692            assert!(j.d3.is_finite());
3693            let p = sas_inverse_link_jetwith_param_partials(eta, eps, log_delta)
3694                .expect("finite SAS eta");
3695            assert!(p.djet_depsilon.mu.is_finite());
3696            assert!(p.djet_depsilon.d1.is_finite());
3697            assert!(p.djet_depsilon.d2.is_finite());
3698            assert!(p.djet_depsilon.d3.is_finite());
3699            assert!(p.djet_dlog_delta.mu.is_finite());
3700            assert!(p.djet_dlog_delta.d1.is_finite());
3701            assert!(p.djet_dlog_delta.d2.is_finite());
3702            assert!(p.djet_dlog_delta.d3.is_finite());
3703        }
3704    }
3705
3706    #[test]
3707    fn sas_param_partials_remain_finite_in_extreme_region() {
3708        let eta = 10.0;
3709        let epsilon = -60.0;
3710        let log_delta = 40.0;
3711        let j = sas_inverse_link_jetwith_param_partials(eta, epsilon, log_delta)
3712            .expect("finite SAS eta");
3713        assert!(j.djet_depsilon.mu.is_finite());
3714        assert!(j.djet_depsilon.d1.is_finite());
3715        assert!(j.djet_depsilon.d2.is_finite());
3716        assert!(j.djet_depsilon.d3.is_finite());
3717        assert!(j.djet_dlog_delta.mu.is_finite());
3718        assert!(j.djet_dlog_delta.d1.is_finite());
3719        assert!(j.djet_dlog_delta.d2.is_finite());
3720        assert!(j.djet_dlog_delta.d3.is_finite());
3721    }
3722
3723    #[test]
3724    fn sas_eta_jets_matchfd() {
3725        let eta = -0.43;
3726        let epsilon = 0.27;
3727        let log_delta = -0.31;
3728        let h = 1e-5;
3729        let j0 = sas_inverse_link_jet(eta, epsilon, log_delta).expect("finite SAS eta");
3730        let jp = sas_inverse_link_jet(eta + h, epsilon, log_delta).expect("finite SAS eta");
3731        let jm = sas_inverse_link_jet(eta - h, epsilon, log_delta).expect("finite SAS eta");
3732        let d1fd = (jp.mu - jm.mu) / (2.0 * h);
3733        let d2fd = (jp.d1 - jm.d1) / (2.0 * h);
3734        let d3fd = (jp.d2 - jm.d2) / (2.0 * h);
3735        assert_eq!(j0.d1.signum(), d1fd.signum());
3736        assert_eq!(j0.d2.signum(), d2fd.signum());
3737        assert_eq!(j0.d3.signum(), d3fd.signum());
3738        assert!((j0.d1 - d1fd).abs() < 5e-5);
3739        assert!((j0.d2 - d2fd).abs() < 2e-4);
3740        assert!((j0.d3 - d3fd).abs() < 1e-3);
3741    }
3742
3743    #[test]
3744    fn beta_logistic_reduces_to_logit_at_delta0_epsilon0() {
3745        let etas = [-40.0, -30.0, -5.0, 0.42, 5.0, 30.0, 40.0];
3746        for eta in etas {
3747            let j_bl = beta_logistic_inverse_link_jet(eta, 0.0, 0.0);
3748            let expected_mu = gam_linalg::utils::stable_logistic(eta);
3749            let expected_d1 = (-gam_linalg::utils::stable_softplus(-eta)
3750                - gam_linalg::utils::stable_softplus(eta))
3751            .exp();
3752            assert!(
3753                (j_bl.mu - expected_mu).abs() <= 1e-15 * expected_mu.abs().max(1.0),
3754                "mu mismatch at eta={eta}: got {}, expected {}",
3755                j_bl.mu,
3756                expected_mu
3757            );
3758            assert!(
3759                (j_bl.d1 - expected_d1).abs() <= 1e-12 * expected_d1.abs().max(f64::MIN_POSITIVE),
3760                "d1 mismatch at eta={eta}: got {}, expected {}",
3761                j_bl.d1,
3762                expected_d1
3763            );
3764            assert!(j_bl.d1 > 0.0, "d1 should stay positive at eta={eta}");
3765        }
3766
3767        let eta = 0.42;
3768        let j_bl = beta_logistic_inverse_link_jet(eta, 0.0, 0.0);
3769        let j_logit = component_inverse_link_jet(LinkComponent::Logit, eta);
3770        assert!((j_bl.d2 - j_logit.d2).abs() < 1e-10);
3771        assert!((j_bl.d3 - j_logit.d3).abs() < 1e-10);
3772    }
3773
3774    #[test]
3775    fn beta_logistic_eta_jets_matchfd() {
3776        let eta = -0.31;
3777        let delta = 0.27;
3778        let epsilon = -0.19;
3779        let h = 1e-5;
3780        let j0 = beta_logistic_inverse_link_jet(eta, delta, epsilon);
3781        let jp = beta_logistic_inverse_link_jet(eta + h, delta, epsilon);
3782        let jm = beta_logistic_inverse_link_jet(eta - h, delta, epsilon);
3783        let d1fd = (jp.mu - jm.mu) / (2.0 * h);
3784        let d2fd = (jp.d1 - jm.d1) / (2.0 * h);
3785        let d3fd = (jp.d2 - jm.d2) / (2.0 * h);
3786        assert_eq!(j0.d1.signum(), d1fd.signum());
3787        assert_eq!(j0.d2.signum(), d2fd.signum());
3788        assert_eq!(j0.d3.signum(), d3fd.signum());
3789        assert!((j0.d1 - d1fd).abs() < 5e-5);
3790        assert!((j0.d2 - d2fd).abs() < 5e-5);
3791        assert!((j0.d3 - d3fd).abs() < 2e-4);
3792    }
3793
3794    #[test]
3795    fn standard_kernel_structs_match_component_jets() {
3796        let eta = 0.73;
3797        assert_eq!(
3798            ProbitLinkKernel.jet(eta).expect("probit"),
3799            component_inverse_link_jet(LinkComponent::Probit, eta)
3800        );
3801        assert_eq!(
3802            LogitLinkKernel.jet(eta).expect("logit"),
3803            component_inverse_link_jet(LinkComponent::Logit, eta)
3804        );
3805        assert_eq!(
3806            CLogLogLinkKernel.jet(eta).expect("cloglog"),
3807            component_inverse_link_jet(LinkComponent::CLogLog, eta)
3808        );
3809        assert_eq!(
3810            LogLogLinkKernel.jet(eta).expect("loglog"),
3811            component_inverse_link_jet(LinkComponent::LogLog, eta)
3812        );
3813        assert_eq!(
3814            CauchitLinkKernel.jet(eta).expect("cauchit"),
3815            component_inverse_link_jet(LinkComponent::Cauchit, eta)
3816        );
3817    }
3818
3819    #[test]
3820    fn all_component_eta_jets_matchfd() {
3821        let components = [
3822            LinkComponent::Logit,
3823            LinkComponent::Probit,
3824            LinkComponent::CLogLog,
3825            LinkComponent::LogLog,
3826            LinkComponent::Cauchit,
3827        ];
3828        let points = [-3.0, -1.1, -0.2, 0.0, 0.7, 1.8, 3.2];
3829        let h = 1e-5;
3830        for c in components {
3831            for &eta in &points {
3832                let j0 = component_inverse_link_jet(c, eta);
3833                let jp = component_inverse_link_jet(c, eta + h);
3834                let jm = component_inverse_link_jet(c, eta - h);
3835                let d1fd = (jp.mu - jm.mu) / (2.0 * h);
3836                let d2fd = (jp.d1 - jm.d1) / (2.0 * h);
3837                let d3fd = (jp.d2 - jm.d2) / (2.0 * h);
3838                let d1_tol = if matches!(c, LinkComponent::CLogLog | LinkComponent::LogLog) {
3839                    1.2e-4
3840                } else {
3841                    5e-5
3842                };
3843                let d2_tol = if matches!(c, LinkComponent::CLogLog | LinkComponent::LogLog) {
3844                    4e-4
3845                } else {
3846                    1.2e-4
3847                };
3848                let d3_tol = if matches!(c, LinkComponent::CLogLog | LinkComponent::LogLog) {
3849                    1.2e-3
3850                } else {
3851                    4e-4
3852                };
3853                if j0.d1.abs().max(d1fd.abs()) > 1e-10 {
3854                    assert_eq!(
3855                        j0.d1.signum(),
3856                        d1fd.signum(),
3857                        "d1 sign mismatch for {c:?} eta={eta}"
3858                    );
3859                }
3860                if j0.d2.abs().max(d2fd.abs()) > 1e-10 {
3861                    assert_eq!(
3862                        j0.d2.signum(),
3863                        d2fd.signum(),
3864                        "d2 sign mismatch for {c:?} eta={eta}: analytic={} fd={}",
3865                        j0.d2,
3866                        d2fd
3867                    );
3868                }
3869                if j0.d3.abs().max(d3fd.abs()) > 1e-10 {
3870                    assert_eq!(
3871                        j0.d3.signum(),
3872                        d3fd.signum(),
3873                        "d3 sign mismatch for {c:?} eta={eta}"
3874                    );
3875                }
3876                assert!(
3877                    (j0.d1 - d1fd).abs() < d1_tol,
3878                    "d1 mismatch for {c:?} eta={eta}: analytic={} fd={}",
3879                    j0.d1,
3880                    d1fd
3881                );
3882                assert!(
3883                    (j0.d2 - d2fd).abs() < d2_tol,
3884                    "d2 mismatch for {c:?} eta={eta}: analytic={} fd={}",
3885                    j0.d2,
3886                    d2fd
3887                );
3888                assert!(
3889                    (j0.d3 - d3fd).abs() < d3_tol,
3890                    "d3 mismatch for {c:?} eta={eta}: analytic={} fd={}",
3891                    j0.d3,
3892                    d3fd
3893                );
3894            }
3895        }
3896    }
3897
3898    #[test]
3899    fn sas_center_matches_probit_at_delta1_epsilon0() {
3900        // `(ε=0, δ=1)` takes the fast probit reduction path, which returns the
3901        // probit jet bitwise. This pins that contract exactly (no tolerance).
3902        let etas = [-3.0, -1.2, -0.3, 0.0, 0.4, 1.7, 3.0];
3903        for eta in etas {
3904            let sas = sas_inverse_link_jet(eta, 0.0, 0.0).expect("finite SAS eta");
3905            let probit = ProbitLinkKernel.jet(eta).expect("probit");
3906            assert_eq!(sas.mu.to_bits(), probit.mu.to_bits(), "mu at eta={eta}");
3907            assert_eq!(sas.d1.to_bits(), probit.d1.to_bits(), "d1 at eta={eta}");
3908            assert_eq!(sas.d2.to_bits(), probit.d2.to_bits(), "d2 at eta={eta}");
3909            assert_eq!(sas.d3.to_bits(), probit.d3.to_bits(), "d3 at eta={eta}");
3910        }
3911    }
3912
3913    /// #2389 regression: the interior-exact bounded latent map makes the
3914    /// `SAS(ε=0, δ=1) ≡ probit` reduction hold on the FULL composition path (not
3915    /// just the fast short-circuit), and removes the `μ(ε)` cliff at `ε=0`.
3916    ///
3917    /// The old `B·tanh(x/B)` distorted the latent by `~1e-4` at every interior
3918    /// point, so (1) the full path returned `Φ(sinh(tanh_bound(asinh η))) ≈
3919    /// Φ(0.99987·η)` — off probit by `~2e-4` in μ — and (2) crossing the
3920    /// `|ε|<1e-12` fast-path threshold jumped μ between the two surfaces by that
3921    /// same `2e-4`. Both are gone: the full path is machine-exact probit, and
3922    /// μ is smooth through `ε=0`.
3923    #[test]
3924    fn sas_probit_reduction_is_exact_on_full_path_and_smooth_across_epsilon_zero() {
3925        let etas = [-3.0, -1.2, -0.3, 0.0, 0.4, 1.7, 3.0];
3926        for &eta in &etas {
3927            let probit = ProbitLinkKernel.jet(eta).expect("probit");
3928            // Full composition path, just outside the fast-path window in ε. With
3929            // the identity interior, `sinh(smooth_bound(asinh η)) = η` exactly, so
3930            // the whole jet collapses to probit up to sinh∘asinh round-off.
3931            for &eps in &[1e-11_f64, -1e-11] {
3932                let sas = sas_inverse_link_jet(eta, eps, 0.0).expect("finite SAS eta");
3933                // ε=1e-11 shifts the latent by 1e-11, a first-order μ move of
3934                // φ(η)·1e-11 ≲ 4e-12; everything beyond that must be < 1e-11.
3935                assert!(
3936                    (sas.mu - probit.mu).abs() < 1e-11,
3937                    "full-path μ off probit at eta={eta} eps={eps}: {} vs {}",
3938                    sas.mu,
3939                    probit.mu
3940                );
3941                assert!(
3942                    (sas.d1 - probit.d1).abs() < 1e-10,
3943                    "full-path d1 off probit at eta={eta} eps={eps}"
3944                );
3945            }
3946            // No cliff at ε=0: the fast-path value (ε exactly 0) and the full-path
3947            // values on either side agree to first order — the jump is O(ε), not
3948            // the old O(2e-4) surface gap.
3949            let center = sas_inverse_link_jet(eta, 0.0, 0.0).expect("fast path").mu;
3950            let lo = sas_inverse_link_jet(eta, -1e-11, 0.0).expect("full path").mu;
3951            let hi = sas_inverse_link_jet(eta, 1e-11, 0.0).expect("full path").mu;
3952            assert!(
3953                (hi - center).abs() < 1e-11 && (center - lo).abs() < 1e-11,
3954                "μ cliff across ε=0 at eta={eta}: lo={lo} center={center} hi={hi}"
3955            );
3956        }
3957    }
3958
3959    /// #2389 design point (b): all-order FD gate on the interior-exact bounded
3960    /// latent map's jet tower, at interior / splice / saturation points. The map
3961    /// underpins every SAS evaluation, yet the SAS-level tests only reach its
3962    /// interior (the splice needs `|δ·asinh(η)+ε| ∈ (0.8B, 1.2B)`, i.e. η≈1e17).
3963    /// This pins `smooth_bound_jet` directly: exact identities in the two flat
3964    /// regimes and at the seams, odd symmetry, non-expansiveness, and a
3965    /// derivative ladder (`d_{k} = d/dx d_{k-1}`) through fifth order in the
3966    /// splice — where a wrong smoothstep coefficient would otherwise hide.
3967    #[test]
3968    fn smooth_bound_jet_tower_is_c5_and_fd_exact() {
3969        let b = SAS_U_CLAMP;
3970        let a = SPLICE_INTERIOR_FRAC * b; // 40
3971        let c = (2.0 - SPLICE_INTERIOR_FRAC) * b; // 60
3972        let jet = |x: f64| smooth_bound_jet(x, b);
3973
3974        // Interior |x| ≤ a: exact identity, every higher derivative exactly 0.
3975        for &x in &[0.0, 0.5, 17.3, a - 1e-9, a] {
3976            let j = jet(x);
3977            assert_eq!(j.g, x, "interior identity value at x={x}");
3978            assert_eq!(j.d1, 1.0, "interior d1 at x={x}");
3979            assert_eq!((j.d2, j.d3, j.d4, j.d5), (0.0, 0.0, 0.0, 0.0));
3980        }
3981        // Saturation |x| ≥ c: exact ±B plateau, every derivative exactly 0.
3982        for &x in &[c, c + 1e-9, 75.0, 1e12, f64::MAX] {
3983            let j = jet(x);
3984            assert_eq!(j.g, b, "saturation value at x={x}");
3985            assert_eq!((j.d1, j.d2, j.d3, j.d4, j.d5), (0.0, 0.0, 0.0, 0.0, 0.0));
3986        }
3987        // Seam value + compactness: g(c) = B exactly (the c = 2B − a closure).
3988        assert_eq!(jet(c).g, b);
3989
3990        // Odd symmetry: g,d2,d4 flip sign; d1,d3,d5 are even.
3991        for &x in &[3.0, a - 2.0, 44.0, 50.0, 56.0, 100.0] {
3992            let p = jet(x);
3993            let m = jet(-x);
3994            assert_eq!(m.g, -p.g, "g odd at x={x}");
3995            assert_eq!(m.d1, p.d1, "d1 even at x={x}");
3996            assert_eq!(m.d2, -p.d2, "d2 odd at x={x}");
3997            assert_eq!(m.d3, p.d3, "d3 even at x={x}");
3998            assert_eq!(m.d4, -p.d4, "d4 odd at x={x}");
3999            assert_eq!(m.d5, p.d5, "d5 even at x={x}");
4000        }
4001
4002        // Non-expansive + monotone + bounded across the whole range.
4003        for i in 0..=240 {
4004            let x = -80.0 + 160.0 * (i as f64) / 240.0;
4005            let j = jet(x);
4006            assert!((0.0..=1.0).contains(&j.d1), "d1 out of [0,1] at x={x}: {}", j.d1);
4007            assert!(j.g.abs() <= b + 1e-12, "|g| exceeds B at x={x}: {}", j.g);
4008        }
4009
4010        // FD derivative ladder through fifth order, at splice-INTERIOR points
4011        // (kept ≥ 5 away from both seams so the O(h²) truncation stays small and
4012        // the h-stencil never straddles a regime change). d_k must be the
4013        // x-derivative of d_{k-1}; per-order tolerances track the realistic
4014        // central-difference error, still orders of magnitude below the O(1)
4015        // shift any wrong smoothstep coefficient would produce.
4016        let h = 0.02;
4017        for &x0 in &[45.0, 47.5, 50.0, 52.5, 55.0, -47.5, -52.5] {
4018            let jp = jet(x0 + h);
4019            let jm = jet(x0 - h);
4020            let j0 = jet(x0);
4021            let fd = |hi: f64, lo: f64| (hi - lo) / (2.0 * h);
4022            let checks = [
4023                ("d1", j0.d1, fd(jp.g, jm.g), 2e-6),
4024                ("d2", j0.d2, fd(jp.d1, jm.d1), 2e-5),
4025                ("d3", j0.d3, fd(jp.d2, jm.d2), 2e-4),
4026                ("d4", j0.d4, fd(jp.d3, jm.d3), 2e-3),
4027                ("d5", j0.d5, fd(jp.d4, jm.d4), 2e-2),
4028            ];
4029            for (name, analytic, numeric, tol) in checks {
4030                assert!(
4031                    (analytic - numeric).abs() < tol * (1.0 + analytic.abs()),
4032                    "smooth_bound {name} FD mismatch at x={x0}: analytic={analytic:e} fd={numeric:e}"
4033                );
4034            }
4035        }
4036    }
4037
4038    /// #2389 general-case follow-up: the cancellation-free SAS complement must
4039    /// live on the SAME latent surface as the forward inverse-link, so that
4040    /// `μ + (1−μ) = 1` holds to full precision across the ENTIRE finite-`f64`
4041    /// eta domain — including `|η| > 1.34e154`, where `η·η` overflows.
4042    ///
4043    /// The forward map routes `asinh` through the overflow-free [`asinh_jet5`]
4044    /// (`hypot`-based value with an asymptotic `ln|η|+ln2` fallback), but
4045    /// `sas_link_complement` used the raw `f64::asinh`, whose internal `x·x`
4046    /// overflows to `+∞` near the domain edge. With a compressing `δ<1` the true
4047    /// latent `δ·asinh(η)` stays deep in the map's identity interior (finite,
4048    /// unsaturated μ), yet the overflowing complement drove `u_raw→∞`, saturated
4049    /// to `±B`, and returned `Φ(∓sinh B)=0` — a full ~0.15 disagreement with the
4050    /// forward `1−μ`, silently poisoning any `log(1−μ)` tail term at extreme η.
4051    #[test]
4052    fn sas_link_complement_mirrors_forward_map_across_finite_domain() {
4053        let params = [
4054            (0.0, 0.0),     // fast probit reduction
4055            (0.25, -0.35),  // generic interior skew/scale
4056            (0.0, -6.0),    // strong compression δ≈2.5e-3
4057            (-0.5, -6.0),
4058            (0.3, 6.0),     // strong dilation δ≈4e2
4059        ];
4060        let etas = [
4061            -f64::MAX,
4062            -1e160, // η·η overflows here; asinh(η)≈-369 is finite and in range
4063            -1e6,
4064            -3.0,
4065            -0.2,
4066            0.0,
4067            0.4,
4068            3.0,
4069            1e6,
4070            1e160,
4071            f64::MAX,
4072        ];
4073        for &(epsilon, log_delta) in &params {
4074            for &eta in &etas {
4075                let mu = sas_inverse_link_jet(eta, epsilon, log_delta)
4076                    .expect("finite SAS eta")
4077                    .mu;
4078                let complement = sas_link_complement(eta, epsilon, log_delta, mu);
4079                assert!(
4080                    complement.is_finite() && (0.0..=1.0).contains(&complement),
4081                    "SAS complement out of [0,1] at η={eta} ε={epsilon} log_δ={log_delta}: {complement}"
4082                );
4083                // μ + (1−μ) = 1 on one consistent surface. Both endpoints are
4084                // Φ(±z) for the same z, so equality holds to erfc round-off.
4085                let sum = mu + complement;
4086                assert!(
4087                    (sum - 1.0).abs() < 1e-12,
4088                    "SAS complement off the forward surface at η={eta} ε={epsilon} \
4089                     log_δ={log_delta}: μ={mu} complement={complement} μ+comp={sum}"
4090                );
4091            }
4092        }
4093    }
4094
4095    /// #2685: the beta-logistic shapes must live in a compact set, and the
4096    /// bound must be the interior-exact map — not a clamp bolted onto `eta`.
4097    ///
4098    /// The measured runaway point is the outer BFGS checkpoint the CLI reports
4099    /// on the committed parametric fixture: `theta = [-0.4584861947563609,
4100    /// -5.246934642530043]`. Unbounded, that is `a = 8.2e-3`, `b = 3.3e-3` —
4101    /// shapes that put essentially all beta mass at `u in {0, 1}`, so `mu(eta)`
4102    /// is nearly flat in the interior and the inner P-IRLS answers by pushing
4103    /// `eta` to `1075*ln 2 = 745.1332191019412`, the smallest `f64` at which
4104    /// `exp(-eta)` underflows to exactly `0.0`.
4105    #[test]
4106    fn beta_logistic_shapes_are_bounded_and_interior_exact_2685() {
4107        // Interior: bitwise identity with the unbounded form it replaced.
4108        for &(center, epsilon) in &[(0.0, 0.0), (0.5, -0.25), (-0.7, 0.3)] {
4109            let (a, b) = beta_logistic_shapes(center, epsilon);
4110            assert_eq!(a.to_bits(), (center - epsilon).exp().to_bits());
4111            assert_eq!(b.to_bits(), (center + epsilon).exp().to_bits());
4112        }
4113
4114        let floor = (-BETA_LOGISTIC_LOG_SHAPE_BOUND).exp();
4115        let ceiling = BETA_LOGISTIC_LOG_SHAPE_BOUND.exp();
4116        // The measured runaway theta, and its mirror image on the large-shape side.
4117        for &(center, epsilon) in &[
4118            (-5.246_934_642_530_043_f64, -0.458_486_194_756_360_9_f64),
4119            (5.246_934_642_530_043, 0.458_486_194_756_360_9),
4120            (-40.0, 12.0),
4121            (40.0, -12.0),
4122        ] {
4123            let (a, b) = beta_logistic_shapes(center, epsilon);
4124            assert!(
4125                (floor..=ceiling).contains(&a) && (floor..=ceiling).contains(&b),
4126                "beta-logistic shapes escaped [{floor:e}, {ceiling:e}] at                  (center={center}, epsilon={epsilon}): a={a:e} b={b:e}"
4127            );
4128        }
4129
4130        // The point of the bound is that the link keeps its sensitivity: at the
4131        // runaway theta the unbounded link's slope at eta=0 was 2.4e-3 (a ~100x
4132        // collapse from the canonical logit's 0.25), which is what forced the
4133        // inner solve out to the eta rail. Bounded, it cannot collapse.
4134        let jet = beta_logistic_inverse_link_jet(
4135            0.0,
4136            -5.246_934_642_530_043,
4137            -0.458_486_194_756_360_9,
4138        );
4139        assert!(
4140            jet.d1 > 1.0e-2,
4141            "bounded beta-logistic link sensitivity collapsed at the runaway              theta: d1={} (canonical logit is 0.25)",
4142            jet.d1
4143        );
4144    }
4145
4146    /// #2685: the bounded map's chain rule reaches the analytic parameter
4147    /// partials. Evaluated strictly inside the splice — where `g' != 1` and
4148    /// `g'' != 0`, so a missing chain factor cannot cancel — unlike
4149    /// `beta_logistic_param_partials_matchfd`, which sits on the interior where
4150    /// the bound is the identity and the test is blind to it by construction.
4151    #[test]
4152    fn beta_logistic_param_partials_matchfd_inside_the_shape_splice_2685() {
4153        let bound = BETA_LOGISTIC_LOG_SHAPE_BOUND;
4154        let eta = -0.41;
4155        // s = center - epsilon = 0.85*B and t = center + epsilon = 1.15*B, both
4156        // strictly between the splice endpoints 0.8*B and 1.2*B.
4157        let center = bound;
4158        let epsilon = 0.15 * bound;
4159        assert!((0.8 * bound..1.2 * bound).contains(&(center - epsilon)));
4160        assert!((0.8 * bound..1.2 * bound).contains(&(center + epsilon)));
4161
4162        let out = beta_logistic_inverse_link_jetwith_param_partials(eta, center, epsilon);
4163        let h = 1e-6;
4164        let fd = |plus: InverseLinkJet, minus: InverseLinkJet| InverseLinkJet {
4165            mu: (plus.mu - minus.mu) / (2.0 * h),
4166            d1: (plus.d1 - minus.d1) / (2.0 * h),
4167            d2: (plus.d2 - minus.d2) / (2.0 * h),
4168            d3: (plus.d3 - minus.d3) / (2.0 * h),
4169        };
4170        let fd_center = fd(
4171            beta_logistic_inverse_link_jet(eta, center + h, epsilon),
4172            beta_logistic_inverse_link_jet(eta, center - h, epsilon),
4173        );
4174        let fd_epsilon = fd(
4175            beta_logistic_inverse_link_jet(eta, center, epsilon + h),
4176            beta_logistic_inverse_link_jet(eta, center, epsilon - h),
4177        );
4178        // The central difference of a C5 map at h=1e-6 resolves to ~1e-10
4179        // absolute plus a 1e-10 relative rounding floor on each component.
4180        let close = |analytic: f64, numeric: f64, what: &str| {
4181            let tol = 1.0e-7 + 1.0e-5 * analytic.abs().max(numeric.abs());
4182            assert!(
4183                (analytic - numeric).abs() <= tol,
4184                "{what}: analytic={analytic:e} fd={numeric:e} tol={tol:e}"
4185            );
4186        };
4187        close(out.djet_dlog_delta.mu, fd_center.mu, "dmu/dcenter");
4188        close(out.djet_dlog_delta.d1, fd_center.d1, "dd1/dcenter");
4189        close(out.djet_dlog_delta.d2, fd_center.d2, "dd2/dcenter");
4190        close(out.djet_dlog_delta.d3, fd_center.d3, "dd3/dcenter");
4191        close(out.djet_depsilon.mu, fd_epsilon.mu, "dmu/depsilon");
4192        close(out.djet_depsilon.d1, fd_epsilon.d1, "dd1/depsilon");
4193        close(out.djet_depsilon.d2, fd_epsilon.d2, "dd2/depsilon");
4194        close(out.djet_depsilon.d3, fd_epsilon.d3, "dd3/depsilon");
4195
4196        // And the derivatives are not trivially zero here, so the comparison
4197        // above was free to disagree.
4198        assert!(out.djet_dlog_delta.mu.abs() > 1.0e-6);
4199        assert!(out.djet_depsilon.mu.abs() > 1.0e-6);
4200    }
4201
4202    #[test]
4203    fn beta_logistic_param_partials_matchfd() {
4204        let eta = -0.41;
4205        let delta = 0.23;
4206        let epsilon = -0.17;
4207        let out = beta_logistic_inverse_link_jetwith_param_partials(eta, delta, epsilon);
4208        let h = 1e-6;
4209
4210        let dp = beta_logistic_inverse_link_jet(eta, delta + h, epsilon);
4211        let dm = beta_logistic_inverse_link_jet(eta, delta - h, epsilon);
4212        let fd_delta = InverseLinkJet {
4213            mu: (dp.mu - dm.mu) / (2.0 * h),
4214            d1: (dp.d1 - dm.d1) / (2.0 * h),
4215            d2: (dp.d2 - dm.d2) / (2.0 * h),
4216            d3: (dp.d3 - dm.d3) / (2.0 * h),
4217        };
4218        assert_eq!(out.djet_dlog_delta.mu.signum(), fd_delta.mu.signum());
4219        assert_eq!(out.djet_dlog_delta.d1.signum(), fd_delta.d1.signum());
4220        assert_eq!(out.djet_dlog_delta.d2.signum(), fd_delta.d2.signum());
4221        assert_eq!(out.djet_dlog_delta.d3.signum(), fd_delta.d3.signum());
4222        assert!((out.djet_dlog_delta.mu - fd_delta.mu).abs() < 5e-5);
4223        assert!((out.djet_dlog_delta.d1 - fd_delta.d1).abs() < 5e-5);
4224        assert!((out.djet_dlog_delta.d2 - fd_delta.d2).abs() < 1.2e-4);
4225        assert!((out.djet_dlog_delta.d3 - fd_delta.d3).abs() < 4e-4);
4226
4227        let ep = beta_logistic_inverse_link_jet(eta, delta, epsilon + h);
4228        let em = beta_logistic_inverse_link_jet(eta, delta, epsilon - h);
4229        let fd_epsilon = InverseLinkJet {
4230            mu: (ep.mu - em.mu) / (2.0 * h),
4231            d1: (ep.d1 - em.d1) / (2.0 * h),
4232            d2: (ep.d2 - em.d2) / (2.0 * h),
4233            d3: (ep.d3 - em.d3) / (2.0 * h),
4234        };
4235        assert_eq!(out.djet_depsilon.mu.signum(), fd_epsilon.mu.signum());
4236        assert_eq!(out.djet_depsilon.d1.signum(), fd_epsilon.d1.signum());
4237        assert_eq!(out.djet_depsilon.d2.signum(), fd_epsilon.d2.signum());
4238        assert_eq!(out.djet_depsilon.d3.signum(), fd_epsilon.d3.signum());
4239        assert!((out.djet_depsilon.mu - fd_epsilon.mu).abs() < 5e-5);
4240        assert!((out.djet_depsilon.d1 - fd_epsilon.d1).abs() < 5e-5);
4241        assert!((out.djet_depsilon.d2 - fd_epsilon.d2).abs() < 1.2e-4);
4242        assert!((out.djet_depsilon.d3 - fd_epsilon.d3).abs() < 4e-4);
4243    }
4244
4245    #[test]
4246    fn beta_logistic_second_partials_obey_center_symmetry() {
4247        let out = beta_logistic_inverse_link_jetwith_param_partials(0.0, 0.37, 0.0);
4248        // At eta=0 and epsilon=0, a=b for every log-shape center, hence
4249        // I_{1/2}(a,a)=1/2 identically. Its pure epsilon and pure log-shape
4250        // second derivatives vanish by complement symmetry, while the density
4251        // is even in epsilon and therefore has zero mixed derivative there.
4252        assert_eq!(out.d2mu_dparams2[[0, 1]], out.d2mu_dparams2[[1, 0]]);
4253        assert_eq!(out.d2d1_dparams2[[0, 1]], out.d2d1_dparams2[[1, 0]]);
4254        assert!(out.d2mu_dparams2[[0, 0]].abs() < 1.0e-12);
4255        assert!(out.d2mu_dparams2[[1, 1]].abs() < 1.0e-12);
4256        assert!(out.d2d1_dparams2[[0, 1]].abs() < 1.0e-12);
4257    }
4258
4259    #[test]
4260    fn beta_logistic_left_tail_uses_unclamped_log_space() {
4261        let eta = -40.0_f64;
4262        let delta = 0.2_f64;
4263        let epsilon = -0.1_f64;
4264        let a = (delta - epsilon).exp();
4265        let b = (delta + epsilon).exp();
4266        let expected_mu = beta_reg(a, b, eta.exp());
4267        let out = beta_logistic_inverse_link_jet(eta, delta, epsilon);
4268
4269        assert!(
4270            (out.mu - expected_mu).abs() <= 1e-12 * expected_mu.abs().max(f64::MIN_POSITIVE),
4271            "left-tail mu mismatch: got {}, expected {}",
4272            out.mu,
4273            expected_mu
4274        );
4275        assert!(out.d1 > 0.0);
4276        assert!(out.d2 > 0.0);
4277        assert!(out.d3 > 0.0);
4278        assert!(out.d1 < 1e-20);
4279
4280        let partials = beta_logistic_inverse_link_jetwith_param_partials(eta, delta, epsilon);
4281        assert!(partials.jet.d1 > 0.0);
4282        assert!(partials.jet.d2 > 0.0);
4283        assert!(partials.jet.d3 > 0.0);
4284        assert!(partials.djet_dlog_delta.d1.is_finite());
4285        assert!(partials.djet_depsilon.d1.is_finite());
4286    }
4287
4288    #[test]
4289    fn beta_logistic_mu_is_symmetric_in_logistic_tails() {
4290        let delta = 0.2;
4291        let epsilon = -0.35;
4292        let etas = [-40.0, -30.0, -5.0, -0.42, 0.0, 0.42, 5.0, 30.0, 40.0];
4293        for eta in etas {
4294            let left = beta_logistic_inverse_link_jet(eta, delta, epsilon).mu;
4295            let right = 1.0 - beta_logistic_inverse_link_jet(-eta, delta, -epsilon).mu;
4296            assert!(
4297                (left - right).abs() <= 1e-14,
4298                "symmetry mismatch at eta={eta}: left={left}, right={right}"
4299            );
4300        }
4301    }
4302
4303    #[test]
4304    fn inverse_link_pdfthird_derivative_matches_d3_finite_difference() {
4305        let sas = InverseLink::Sas(sas_link_state_from_raw(-0.25, 0.35).expect("sas state"));
4306        let beta_logistic = InverseLink::BetaLogistic(SasLinkState {
4307            epsilon: 0.18,
4308            log_delta: -0.22,
4309            delta: (-0.22_f64).exp(),
4310        });
4311        let mixture = InverseLink::Mixture(
4312            state_fromspec(&MixtureLinkSpec {
4313                components: vec![
4314                    LinkComponent::Probit,
4315                    LinkComponent::Logit,
4316                    LinkComponent::CLogLog,
4317                    LinkComponent::Cauchit,
4318                ],
4319                initial_rho: Array1::from_vec(vec![0.35, -0.45, 0.2]),
4320            })
4321            .expect("mixture state"),
4322        );
4323        let links = [
4324            InverseLink::Standard(StandardLink::Probit),
4325            InverseLink::Standard(StandardLink::Logit),
4326            InverseLink::Standard(StandardLink::CLogLog),
4327            sas,
4328            beta_logistic,
4329            mixture,
4330        ];
4331        let etas = [-1.1, -0.2, 0.6];
4332        let h = 1e-5;
4333
4334        for link in &links {
4335            for &eta in &etas {
4336                let jp = inverse_link_jet_for_inverse_link(link, eta + h).expect("jet+");
4337                let jm = inverse_link_jet_for_inverse_link(link, eta - h).expect("jet-");
4338                let d4fd = (jp.d3 - jm.d3) / (2.0 * h);
4339                let d4 = inverse_link_pdfthird_derivative_for_inverse_link(link, eta)
4340                    .expect("analytic d4");
4341                assert_eq!(
4342                    d4.signum(),
4343                    d4fd.signum(),
4344                    "d4 sign mismatch for {:?} at eta={eta}: analytic={} fd={}",
4345                    link,
4346                    d4,
4347                    d4fd
4348                );
4349                assert!(
4350                    (d4 - d4fd).abs() < 5e-3,
4351                    "d4 mismatch for {:?} at eta={eta}: analytic={} fd={}",
4352                    link,
4353                    d4,
4354                    d4fd
4355                );
4356            }
4357        }
4358    }
4359
4360    #[test]
4361    fn cloglog_large_finite_eta_should_saturate_without_nan_derivatives() {
4362        let eta = 800.0;
4363        let jet = component_inverse_link_jet(LinkComponent::CLogLog, eta);
4364        assert_eq!(jet.mu, 1.0);
4365        assert!(
4366            jet.d1 == 0.0,
4367            "for mu(eta)=1-exp(-exp(eta)), dmu/deta = exp(eta-exp(eta)) and should underflow to 0 at eta={eta}; got d1={}",
4368            jet.d1
4369        );
4370        assert!(
4371            jet.d2 == 0.0,
4372            "the saturated cloglog second derivative should also be 0 at eta={eta}; got d2={}",
4373            jet.d2
4374        );
4375        assert!(
4376            jet.d3 == 0.0,
4377            "the saturated cloglog third derivative should also be 0 at eta={eta}; got d3={}",
4378            jet.d3
4379        );
4380
4381        let d4 = inverse_link_pdfthird_derivative_for_inverse_link(
4382            &InverseLink::Standard(StandardLink::CLogLog),
4383            eta,
4384        )
4385        .expect("cloglog d4");
4386        assert!(
4387            d4 == 0.0,
4388            "the saturated cloglog fourth derivative should also be 0 at eta={eta}; got d4={d4}"
4389        );
4390    }
4391
4392    #[test]
4393    fn loglog_large_negative_finite_eta_should_saturate_without_nan_derivatives() {
4394        let eta = -800.0;
4395        let jet = component_inverse_link_jet(LinkComponent::LogLog, eta);
4396        assert_eq!(jet.mu, 0.0);
4397        assert!(
4398            jet.d1 == 0.0,
4399            "for mu(eta)=exp(-exp(-eta)), dmu/deta = exp(-eta-exp(-eta)) and should underflow to 0 at eta={eta}; got d1={}",
4400            jet.d1
4401        );
4402        assert!(
4403            jet.d2 == 0.0,
4404            "the saturated loglog second derivative should also be 0 at eta={eta}; got d2={}",
4405            jet.d2
4406        );
4407        assert!(
4408            jet.d3 == 0.0,
4409            "the saturated loglog third derivative should also be 0 at eta={eta}; got d3={}",
4410            jet.d3
4411        );
4412
4413        let d4 = inverse_link_pdfthird_derivative_for_inverse_link(
4414            &InverseLink::Mixture(
4415                state_fromspec(&MixtureLinkSpec {
4416                    components: vec![LinkComponent::LogLog, LinkComponent::Probit],
4417                    initial_rho: Array1::from_vec(vec![12.0]),
4418                })
4419                .expect("mixture state"),
4420            ),
4421            eta,
4422        )
4423        .expect("loglog mixture d4");
4424        assert!(
4425            d4.is_finite(),
4426            "even a nearly pure loglog mixture should not produce NaN fourth derivatives at eta={eta}; got d4={d4}"
4427        );
4428    }
4429
4430    #[test]
4431    fn logit_tail_derivatives_should_match_stable_closed_forms() {
4432        let eta = 50.0_f64;
4433        let z = (-eta).exp();
4434        let denom = 1.0_f64 + z;
4435        let stable_d1 = z / denom.powi(2);
4436        let stable_d2 = z * (z - 1.0) / denom.powi(3);
4437        let stable_d3 = z * (z * z - 4.0 * z + 1.0) / denom.powi(4);
4438        let stable_d4 = z * (z * z * z - 11.0 * z * z + 11.0 * z - 1.0) / denom.powi(5);
4439        let stable_d5 =
4440            z * (z * z * z * z - 26.0 * z * z * z + 66.0 * z * z - 26.0 * z + 1.0) / denom.powi(6);
4441
4442        assert!(stable_d1 > 0.0);
4443        assert!(stable_d2 < 0.0);
4444        assert!(stable_d3 > 0.0);
4445        assert!(stable_d4 < 0.0);
4446        assert!(stable_d5 > 0.0);
4447
4448        let jet = component_inverse_link_jet(LinkComponent::Logit, eta);
4449        assert!(
4450            (jet.d1 - stable_d1).abs() < 1e-30,
4451            "logit d1 should equal the stable tail formula z/(1+z)^2 at eta={eta}; got {} vs {}",
4452            jet.d1,
4453            stable_d1
4454        );
4455        assert!(
4456            (jet.d2 - stable_d2).abs() < 1e-30,
4457            "logit d2 should equal the stable tail formula z(z-1)/(1+z)^3 at eta={eta}; got {} vs {}",
4458            jet.d2,
4459            stable_d2
4460        );
4461        assert!(
4462            (jet.d3 - stable_d3).abs() < 1e-30,
4463            "logit d3 should equal the stable tail formula z(z^2-4z+1)/(1+z)^4 at eta={eta}; got {} vs {}",
4464            jet.d3,
4465            stable_d3
4466        );
4467
4468        let d4 = inverse_link_pdfthird_derivative_for_inverse_link(
4469            &InverseLink::Standard(StandardLink::Logit),
4470            eta,
4471        )
4472        .expect("logit d4");
4473        assert!(
4474            (d4 - stable_d4).abs() < 1e-30,
4475            "logit d4 should equal the stable tail formula z(z^3-11z^2+11z-1)/(1+z)^5 at eta={eta}; got {} vs {}",
4476            d4,
4477            stable_d4
4478        );
4479
4480        let d5 = inverse_link_pdffourth_derivative_for_inverse_link(
4481            &InverseLink::Standard(StandardLink::Logit),
4482            eta,
4483        )
4484        .expect("logit d5");
4485        assert!(
4486            (d5 - stable_d5).abs() < 1e-30,
4487            "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 {}",
4488            d5,
4489            stable_d5
4490        );
4491    }
4492
4493    #[test]
4494    fn cloglog_negative_tail_value_should_match_expm1_form() {
4495        let eta = -50.0_f64;
4496        let t = eta.exp();
4497        let stable_mu = -(-t).exp_m1();
4498        assert!(stable_mu > 0.0);
4499
4500        let jet = component_inverse_link_jet(LinkComponent::CLogLog, eta);
4501        assert!(
4502            (jet.mu - stable_mu).abs() < 1e-30,
4503            "cloglog mu should equal -expm1(-exp(eta)) in the negative tail at eta={eta}; got {} vs {}",
4504            jet.mu,
4505            stable_mu
4506        );
4507    }
4508
4509    #[test]
4510    fn non_logit_probit_fisher_weight_jets_match_finite_differences() {
4511        fn rel_err(a: f64, b: f64) -> f64 {
4512            (a - b).abs() / a.abs().max(b.abs()).max(1.0e-8)
4513        }
4514
4515        let cases = [
4516            (LinkComponent::CLogLog, [-3.0_f64, -0.5, 0.4, 1.5]),
4517            (LinkComponent::LogLog, [-1.5_f64, -0.4, 0.5, 3.0]),
4518            (LinkComponent::Cauchit, [-3.0_f64, -0.7, 0.6, 3.0]),
4519        ];
4520        for (component, etas) in cases {
4521            for eta in etas {
4522                let (w, w1, w2, w3, w4) = component_fisher_weight_jet5(component, eta);
4523                let jet = component_inverse_link_jet(component, eta);
4524                let expected = jet.d1 * jet.d1 / (jet.mu * (1.0 - jet.mu));
4525                assert!(
4526                    rel_err(w, expected) < 1.0e-12,
4527                    "{component:?} Fisher weight mismatch at eta={eta}: got {w}, expected {expected}"
4528                );
4529
4530                let h = 1.0e-4;
4531                let fd1 = (component_fisher_weight_jet5(component, eta + h).0
4532                    - component_fisher_weight_jet5(component, eta - h).0)
4533                    / (2.0 * h);
4534                let fd2 = (component_fisher_weight_jet5(component, eta + h).1
4535                    - component_fisher_weight_jet5(component, eta - h).1)
4536                    / (2.0 * h);
4537                let fd3 = (component_fisher_weight_jet5(component, eta + h).2
4538                    - component_fisher_weight_jet5(component, eta - h).2)
4539                    / (2.0 * h);
4540                let fd4 = (component_fisher_weight_jet5(component, eta + h).3
4541                    - component_fisher_weight_jet5(component, eta - h).3)
4542                    / (2.0 * h);
4543
4544                assert!(
4545                    rel_err(w1, fd1) < 1.0e-5,
4546                    "{component:?} W' mismatch at eta={eta}: {w1} vs {fd1}"
4547                );
4548                assert!(
4549                    rel_err(w2, fd2) < 1.0e-5,
4550                    "{component:?} W'' mismatch at eta={eta}: {w2} vs {fd2}"
4551                );
4552                assert!(
4553                    rel_err(w3, fd3) < 5.0e-5,
4554                    "{component:?} W''' mismatch at eta={eta}: {w3} vs {fd3}"
4555                );
4556                assert!(
4557                    rel_err(w4, fd4) < 5.0e-4,
4558                    "{component:?} W'''' mismatch at eta={eta}: {w4} vs {fd4}"
4559                );
4560            }
4561        }
4562    }
4563
4564    #[test]
4565    fn mixture_fisher_weight_jet_covers_loglog_and_cauchit_components() {
4566        let state = state_fromspec(&MixtureLinkSpec {
4567            components: vec![
4568                LinkComponent::CLogLog,
4569                LinkComponent::LogLog,
4570                LinkComponent::Cauchit,
4571            ],
4572            initial_rho: Array1::from_vec(vec![0.3, -0.2]),
4573        })
4574        .expect("mixture state");
4575        let link = InverseLink::Mixture(state);
4576        assert!(
4577            link.has_fisher_weight_jet(),
4578            "anchored mixtures with loglog/cauchit components must remain eligible for Firth"
4579        );
4580        assert!(
4581            LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()).supports_firth(),
4582            "Firth support should use the mixture inverse-link Fisher jet, not standalone LinkFunction coverage"
4583        );
4584
4585        for eta in [-2.0_f64, -0.25, 0.75, 2.5] {
4586            let (w, w1, w2, w3, w4) =
4587                fisher_weight_jet5_for_inverse_link(&link, eta).expect("mixture Fisher jet");
4588            for value in [w, w1, w2, w3, w4] {
4589                assert!(
4590                    value.is_finite(),
4591                    "mixture Fisher weight jet should be finite at eta={eta}; got {value}"
4592                );
4593            }
4594            assert!(
4595                w > 0.0,
4596                "mixture Fisher working weight should be positive away from saturated tails at eta={eta}; got {w}"
4597            );
4598        }
4599    }
4600
4601    #[test]
4602    fn loglog_fifth_derivative_should_match_closed_form_sign() {
4603        let eta = 0.0_f64;
4604        let r = (-eta).exp();
4605        let expected =
4606            (-r).exp() * (r - 15.0 * r * r + 25.0 * r.powi(3) - 10.0 * r.powi(4) + r.powi(5));
4607        let d5 = component_inverse_link_pdffourth_derivative(LinkComponent::LogLog, eta);
4608        assert!(
4609            (d5 - expected).abs() < 1e-15,
4610            "loglog d5 should equal exp(-r) * (r - 15r^2 + 25r^3 - 10r^4 + r^5) at eta={eta}; got {d5} vs {expected}"
4611        );
4612        assert!(d5 > 0.0, "loglog d5 should be positive at eta=0; got {d5}");
4613    }
4614}