Skip to main content

gam_inference/
hmc_io.rs

1//! NUTS Sampler using general-mcmc
2//!
3//! This module provides NUTS (No-U-Turn Sampler) for honest uncertainty
4//! quantification after PIRLS convergence.
5//!
6//! # Design
7//!
8//! Since general-mcmc's NUTS uses an identity mass matrix, we whiten the
9//! parameter space using the Cholesky decomposition of the inverse Hessian:
10//!
11//! - Transform: β = μ + L @ z  (where L L^T = H^{-1})
12//! - The whitened space has unit covariance, so NUTS mixes efficiently
13//! - Samples are un-transformed back to the original space
14//!
15//! # Analytical Gradients
16//!
17//! We override `unnorm_logp_and_grad` to compute gradients analytically using
18//! ndarray, avoiding burn's autodiff overhead. The gradient computation mirrors
19//! the true log-posterior gradient (not the PIRLS working gradient).
20//!
21//! # Memory Efficiency
22//!
23//! Large data (design matrix, response, etc.) is wrapped in `Arc` to allow
24//! sharing across chains without duplication when general-mcmc clones the target.
25
26use crate::gpu_polya_gamma::{PgSeed, PolyaGammaBatchInput};
27use faer::Side;
28use gam_linalg::faer_ndarray::{
29    FaerCholesky, FaerEigh, fast_ab, fast_ata_into, fast_atv, fast_av, fast_av_into,
30};
31use gam_linalg::matrix::DesignMatrix;
32use gam_linalg::triangular::back_substitution_lower_transpose_guarded_into;
33use gam_problem::types::{
34    GlmLikelihoodSpec, InverseLink, LikelihoodScaleMetadata, LikelihoodSpec,
35    ResolvedLikelihoodScale, ResponseFamily, RhoPrior, StandardLink, is_valid_tweedie_power,
36};
37use gam_solve::estimate::reml::FirthDenseOperator;
38use gam_solve::estimate::reml::penalty_logdet::PenaltyPseudologdet;
39use gam_solve::estimate::{UnifiedFitResult, validate_explicit_dense_hessian_for_whitening};
40use gam_solve::mixture_link::{
41    InverseLinkKernel, LinkParamPartials, inverse_link_jet_for_inverse_link, softmax_last_fixedzero,
42};
43use gam_terms::construction::CanonicalPenalty;
44use general_mcmc::generic_hmc::HamiltonianTarget;
45pub use general_mcmc::generic_nuts::NUTSMassMatrixConfig;
46use general_mcmc::generic_nuts::{GenericNUTS, MassMatrixAdaptation};
47use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, Axis, s};
48use rand::{RngExt, SeedableRng, rngs::StdRng};
49use serde::{Deserialize, Serialize};
50use std::cell::RefCell;
51use std::fmt;
52use std::sync::{Arc, Mutex};
53
54/// Binomial families whose inverse link has a Fisher-weight jet
55/// (`fisher_weight_jet5`) support the Jeffreys/Firth term. This is the
56/// link-general set shared with the REML/PIRLS Firth operator; the canonical
57/// logit case is unchanged.
58#[inline]
59fn likelihood_spec_supports_firth(spec: &LikelihoodSpec) -> bool {
60    spec.supports_firth()
61}
62
63/// Inverse link to evaluate the Fisher working weight with for the Jeffreys
64/// term. Returns `None` for unsupported specs.
65#[inline]
66fn likelihood_spec_jeffreys_link(spec: &LikelihoodSpec) -> Option<InverseLink> {
67    if likelihood_spec_supports_firth(spec) {
68        Some(spec.link.clone())
69    } else {
70        None
71    }
72}
73
74/// Typed error variants for the HMC / NUTS sampling module.
75///
76/// External-facing helpers in this module continue to return
77/// `Result<_, String>`; this enum is materialized internally and converted
78/// at the public boundary via `.map_err(String::from)` so that the error
79/// text remains byte-identical to the previous `format!` output.
80#[derive(Debug, Clone)]
81pub enum HmcError {
82    /// Sampler state (penalty / Hessian / mode / posterior values) contains
83    /// NaN or Inf where finiteness is required.
84    NonFiniteState { reason: String },
85    /// Configuration value (e.g. `target_accept`, unit-weight requirement)
86    /// is out of range or otherwise invalid.
87    InvalidConfig { reason: String },
88    /// Dimensions of the supplied matrices / vectors are inconsistent.
89    DimensionMismatch { reason: String },
90    /// Firth/Jeffreys correction was requested for a family that does not
91    /// support it.
92    FirthUnsupported { reason: String },
93    /// Inverse-link state does not match the requested likelihood family in
94    /// the joint (β, ρ) sampler.
95    LinkMismatch { reason: String },
96    /// Likelihood family is not implemented in the current sampling path.
97    UnsupportedFamily { reason: String },
98    /// Sampling produced no usable output (empty kept set, non-finite
99    /// summary statistic, etc.).
100    SamplingFailed { reason: String },
101}
102
103impl fmt::Display for HmcError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            HmcError::NonFiniteState { reason }
107            | HmcError::InvalidConfig { reason }
108            | HmcError::DimensionMismatch { reason }
109            | HmcError::FirthUnsupported { reason }
110            | HmcError::LinkMismatch { reason }
111            | HmcError::UnsupportedFamily { reason }
112            | HmcError::SamplingFailed { reason } => f.write_str(reason),
113        }
114    }
115}
116
117impl From<HmcError> for String {
118    fn from(err: HmcError) -> String {
119        err.to_string()
120    }
121}
122
123/// Upper bound on the autocorrelation lag summed in the effective-sample-size
124/// estimate. The Geyer initial-positive-sequence sum normally self-truncates
125/// long before this, but a hard cap bounds the `O(n·lag)` work for very long
126/// chains where the autocorrelation tail is numerical noise.
127const MAX_AUTOCORRELATION_LAG: usize = 1000;
128
129/// Floor on the lag-0 autocovariance (chain variance) used as the denominator in
130/// the autocorrelation ratios, guarding against division by zero for a chain
131/// that is numerically constant.
132const AUTOCOVARIANCE_FLOOR: f64 = 1e-16;
133
134/// Compute split-chain R-hat and ESS using the Gelman-Rubin diagnostic.
135///
136/// This is the standard split-chain formulation (no rank normalization).
137/// Returns (max_rhat, min_ess) across dimensions.
138pub(crate) fn compute_split_rhat_and_ess(samples: &Array3<f64>) -> (f64, f64) {
139    let n_chains = samples.shape()[0];
140    let n_samples = samples.shape()[1];
141    let dim = samples.shape()[2];
142
143    if n_chains < 2 || n_samples < 4 {
144        return (1.0, n_chains as f64 * n_samples as f64 * 0.5);
145    }
146
147    // Split each chain in half to detect non-stationarity
148    let half = n_samples / 2;
149    let n_split_chains = n_chains * 2;
150    let n_split_samples = half;
151
152    let mut max_rhat = 0.0f64;
153    let mut min_ess = f64::INFINITY;
154
155    #[inline]
156    fn splitvalue(
157        samples: &Array3<f64>,
158        n_chains: usize,
159        half: usize,
160        dim: usize,
161        sc: usize,
162        t: usize,
163    ) -> f64 {
164        let chain = sc % n_chains;
165        if sc < n_chains {
166            samples[[chain, t, dim]]
167        } else {
168            samples[[chain, half + t, dim]]
169        }
170    }
171
172    fn ess_from_split_dimension(
173        samples: &Array3<f64>,
174        n_chains: usize,
175        half: usize,
176        dim: usize,
177    ) -> f64 {
178        let m = n_chains * 2;
179        let n = half;
180        if m == 0 || n < 4 {
181            return (m * n).max(1) as f64;
182        }
183
184        let mut means = vec![0.0_f64; m];
185        let mut gamma0 = vec![0.0_f64; m];
186        for sc in 0..m {
187            let mut sum = 0.0;
188            for t in 0..n {
189                sum += splitvalue(samples, n_chains, half, dim, sc, t);
190            }
191            let mean = sum / n as f64;
192            means[sc] = mean;
193            let mut g0 = 0.0;
194            for t in 0..n {
195                let d = splitvalue(samples, n_chains, half, dim, sc, t) - mean;
196                g0 += d * d;
197            }
198            gamma0[sc] = (g0 / n as f64).max(AUTOCOVARIANCE_FLOOR);
199        }
200
201        let max_lag = (n - 1).min(MAX_AUTOCORRELATION_LAG);
202        let mut tau = 1.0_f64;
203        let mut lag = 1usize;
204        while lag < max_lag {
205            let mut pair = 0.0_f64;
206            for l in [lag, lag + 1] {
207                if l > max_lag {
208                    continue;
209                }
210                let mut rho_l = 0.0;
211                for sc in 0..m {
212                    let mu = means[sc];
213                    let mut cov = 0.0;
214                    let denom = (n - l) as f64;
215                    for t in 0..(n - l) {
216                        let x0 = splitvalue(samples, n_chains, half, dim, sc, t);
217                        let x1 = splitvalue(samples, n_chains, half, dim, sc, t + l);
218                        cov += (x0 - mu) * (x1 - mu);
219                    }
220                    cov /= denom;
221                    rho_l += cov / gamma0[sc];
222                }
223                rho_l /= m as f64;
224                pair += rho_l;
225            }
226            if !pair.is_finite() || pair <= 0.0 {
227                break;
228            }
229            tau += 2.0 * pair;
230            lag += 2;
231        }
232        if !tau.is_finite() || tau <= 0.0 {
233            return 1.0;
234        }
235        let total = (m * n) as f64;
236        (total / tau).clamp(1.0, total)
237    }
238
239    let mut chain_means = vec![0.0_f64; n_split_chains];
240    let mut chainvars = vec![0.0_f64; n_split_chains];
241    for d in 0..dim {
242        for chain in 0..n_chains {
243            // First half
244            let mut sum1 = 0.0;
245            for i in 0..half {
246                sum1 += samples[[chain, i, d]];
247            }
248            let mean1 = sum1 / half as f64;
249            let mut var1 = 0.0;
250            for i in 0..half {
251                let diff = samples[[chain, i, d]] - mean1;
252                var1 += diff * diff;
253            }
254            var1 /= (half - 1).max(1) as f64;
255            let first_idx = chain;
256            chain_means[first_idx] = mean1;
257            chainvars[first_idx] = var1;
258
259            // Second half
260            let mut sum2 = 0.0;
261            for i in half..(2 * half) {
262                sum2 += samples[[chain, i, d]];
263            }
264            let mean2 = sum2 / half as f64;
265            let mut var2 = 0.0;
266            for i in half..(2 * half) {
267                let diff = samples[[chain, i, d]] - mean2;
268                var2 += diff * diff;
269            }
270            var2 /= (half - 1).max(1) as f64;
271            let second_idx = n_chains + chain;
272            chain_means[second_idx] = mean2;
273            chainvars[second_idx] = var2;
274        }
275
276        // Within-chain variance W
277        let w: f64 = chainvars.iter().copied().sum::<f64>() / n_split_chains as f64;
278
279        // Between-chain variance B
280        let overall_mean: f64 = chain_means.iter().copied().sum::<f64>() / n_split_chains as f64;
281        let b: f64 = chain_means
282            .iter()
283            .map(|m| (m - overall_mean).powi(2))
284            .sum::<f64>()
285            * n_split_samples as f64
286            / (n_split_chains - 1) as f64;
287
288        // Estimated variance
289        let var_hat = (n_split_samples as f64 - 1.0) / n_split_samples as f64 * w
290            + b / n_split_samples as f64;
291
292        // R-hat
293        let rhat_d = if w > 1e-10 { (var_hat / w).sqrt() } else { 1.0 };
294        max_rhat = max_rhat.max(rhat_d);
295
296        // Real ESS via split-chain autocorrelation with Geyer IPS truncation.
297        let ess_d = ess_from_split_dimension(samples, n_chains, half, d);
298        min_ess = min_ess.min(ess_d);
299    }
300
301    (max_rhat, min_ess.max(1.0))
302}
303
304/// Solve L^T * X = I where L is lower triangular.
305///
306/// Returns X = L^{-T} (the inverse transpose of L).
307///
308/// This is the correct way to compute the whitening transform matrix:
309/// Given H = L L^T (Cholesky), we need W where W W^T = H^{-1}
310/// Since H^{-1} = L^{-T} L^{-1}, we have W = L^{-T}.
311///
312/// Implementation strategy (math-equivalent to back-substitution on L^T):
313/// We compute L^{-1} column-wise via forward substitution on L, then the
314/// result is `L^{-1}` transposed. Forward-substituting column `c` of L^{-1}
315/// uses `L`'s rows (which are contiguous in row-major `Array2`), giving
316/// stride-1 inner loops instead of the strided `l[[j, i]]` (column-major
317/// access pattern) and double-indexed writes of the original. We also
318/// exploit the triangular structure of `L^{-1}` (entries above the diagonal
319/// are zero), skipping ~half of the inner work compared to the previous
320/// version which traversed `i = (0..dim).rev()` for every column.
321///
322/// Total cost: ~dim^3 / 6 multiply-adds (down from dim^3 / 2), with all
323/// inner loops on contiguous slices.
324fn solve_upper_triangular_transpose(l: &Array2<f64>, dim: usize) -> Array2<f64> {
325    let mut result = Array2::<f64>::zeros((dim, dim));
326    if dim == 0 {
327        return result;
328    }
329
330    // Pull contiguous row slice access from L (row-major standard layout).
331    // Falls back to a one-time owned copy if `l` is not standard-layout
332    // (e.g. a transposed view); both branches feed the same inner loop.
333    let l_owned;
334    let l_rows: &[f64] = if let Some(s) = l.as_slice() {
335        s
336    } else {
337        l_owned = l.to_owned();
338        l_owned
339            .as_slice()
340            .expect("owned standard-layout Array2 has contiguous storage")
341    };
342
343    // Scratch column for L^{-1}[:, col]; reused across columns.
344    let mut y = vec![0.0_f64; dim];
345
346    for col in 0..dim {
347        // Forward-substitute L * y = e_col. y[i] = 0 for i < col.
348        // Diagonal term:
349        let d_col = l_rows[col * dim + col];
350        let inv_d_col = if d_col.abs() > 1e-15 {
351            1.0 / d_col
352        } else {
353            0.0
354        };
355        y[col] = inv_d_col;
356
357        // Below-diagonal entries: y[i] = -(sum_{j=col..i} L[i,j] * y[j]) / L[i,i].
358        // Each inner loop is a stride-1 dot product on row `i` of L (contiguous).
359        for i in (col + 1)..dim {
360            let row_off = i * dim;
361            let l_row = &l_rows[row_off + col..row_off + i];
362            let y_seg = &y[col..i];
363            // Both operands are contiguous slices of equal length; the loop
364            // is a straight-line stride-1 reduction the optimizer can
365            // auto-vectorize.
366            let mut sum = 0.0_f64;
367            for k in 0..l_row.len() {
368                sum += l_row[k] * y_seg[k];
369            }
370            let d = l_rows[row_off + i];
371            y[i] = if d.abs() > 1e-15 { -sum / d } else { 0.0 };
372        }
373
374        // Write the column into result transposed: result[col, i] = y[i] for i >= col.
375        // result[i, col] is left at zero for i < col (upper-triangular L^{-T}).
376        // That matches `result[col, i]` filling row `col` from column `col` rightward.
377        let res_row_start = col * dim + col;
378        let res_row = &mut result.as_slice_mut().expect("owned Array2 contiguous")
379            [res_row_start..res_row_start + (dim - col)];
380        for (k, slot) in res_row.iter_mut().enumerate() {
381            *slot = y[col + k];
382        }
383
384        // Clear scratch positions we wrote, so the next column starts clean above.
385        for slot in &mut y[col..dim] {
386            *slot = 0.0;
387        }
388    }
389
390    result
391}
392
393struct WhiteningTransform {
394    chol: Array2<f64>,
395    chol_t: Array2<f64>,
396}
397
398fn hessian_whitening_transform(
399    hessian: ArrayView2<f64>,
400    dim: usize,
401    cov_scale: f64,
402    cholesky_error_prefix: &str,
403) -> Result<WhiteningTransform, String> {
404    if !(cov_scale.is_finite() && cov_scale > 0.0) {
405        return Err(format!(
406            "whitening covariance scale must be finite and strictly positive, got {cov_scale}"
407        ));
408    }
409    let hessian_owned = hessian.to_owned();
410    gam_linalg::utils::certified_spd_factorize(&hessian_owned, cholesky_error_prefix)
411        .map_err(|error| error.to_string())?;
412    let chol_factor = hessian_owned
413        .cholesky(Side::Lower)
414        .map_err(|e| format!("{cholesky_error_prefix}: {:?}", e))?;
415    let l_h = chol_factor.lower_triangular();
416    let mut chol = solve_upper_triangular_transpose(&l_h, dim);
417    let sqrt_cov_scale = cov_scale.sqrt();
418    if (sqrt_cov_scale - 1.0).abs() > 0.0 {
419        chol.mapv_inplace(|v| v * sqrt_cov_scale);
420    }
421    let chol_t = chol.t().to_owned();
422    Ok(WhiteningTransform { chol, chol_t })
423}
424
425/// Shared data for NUTS posterior (wrapped in Arc to prevent cloning).
426///
427/// This struct holds read-only data that is shared across all chains.
428/// Using Arc prevents memory explosion when general-mcmc clones the target.
429#[derive(Clone)]
430struct SharedData {
431    /// Design matrix X [n_samples, dim]
432    x: Arc<Array2<f64>>,
433    /// Response vector y [n_samples]
434    y: Arc<Array1<f64>>,
435    /// Observation/case weights [n_samples]
436    weights: Arc<Array1<f64>>,
437    /// MAP estimate (mode) μ [dim]
438    mode: Arc<Array1<f64>>,
439    /// Fixed additive offset on the linear predictor: η = Xβ + offset
440    /// [n_samples]. `None` when the model was fit without an offset (the common
441    /// case), avoiding a per-step O(n) add of zeros. The offset shifts η only —
442    /// it is constant in β, so ∂η/∂β = X is unchanged and no gradient,
443    /// Hessian, or penalty term is affected. Dropping it (the historical
444    /// behaviour) silently sampled the wrong posterior for any `--offset-column`
445    /// fit (#882).
446    offset: Option<Arc<Array1<f64>>>,
447    /// Fully resolved family, link, and scale metadata consumed by the exact
448    /// shared PIRLS/HMC row oracle. Keeping one typed object prevents Gamma
449    /// shape, Tweedie power, NB theta, and response dispersion from sharing an
450    /// ambiguous scalar slot.
451    likelihood: GlmLikelihoodSpec,
452    /// Number of samples
453    n_samples: usize,
454    /// Number of coefficients
455    dim: usize,
456}
457
458thread_local! {
459    static NUTS_RESIDUAL_SCRATCH: RefCell<Array1<f64>> = RefCell::new(Array1::zeros(0));
460}
461
462/// Resolve and certify the scale metadata consumed by an HMC target.
463///
464/// The fitted likelihood remains the source of the covariance-scale contract,
465/// while the returned target likelihood makes the actual fixed data-term scale
466/// explicit. In particular, a profiled Gaussian target is evaluated at its
467/// fitted `phi` and a Gamma supplied as fixed dispersion is converted to its
468/// exact reciprocal shape. No family parameter is inferred from an unrelated
469/// slot and no unit default exists.
470fn resolve_hmc_likelihood(
471    likelihood: GlmLikelihoodSpec,
472    dispersion: gam_solve::model_types::Dispersion,
473) -> Result<(GlmLikelihoodSpec, f64), HmcError> {
474    let resolved_scale = likelihood
475        .resolved_scale()
476        .map_err(|error| HmcError::InvalidConfig {
477            reason: format!("HMC likelihood scale metadata is unresolved: {error}"),
478        })?;
479    let phi = dispersion.phi();
480    let inv_phi = dispersion
481        .reciprocal()
482        .map_err(|error| HmcError::InvalidConfig {
483            reason: format!("HMC likelihood requires a finite positive dispersion: {error}"),
484        })?;
485
486    if matches!(resolved_scale, ResolvedLikelihoodScale::ProfiledGaussian) {
487        if !dispersion.is_estimated() {
488            return Err(HmcError::InvalidConfig {
489                reason: "profiled-Gaussian HMC requires an estimated fitted dispersion".to_string(),
490            });
491        }
492    } else {
493        // The standard-deviation argument is consulted only by the profiled
494        // Gaussian branch handled above. Every resolved non-profiled family
495        // derives its response dispersion entirely from typed metadata.
496        let expected = gam_solve::estimate::dispersion_from_likelihood(&likelihood, None).map_err(
497            |error| HmcError::InvalidConfig {
498                reason: format!("HMC likelihood scale metadata is inconsistent: {error}"),
499            },
500        )?;
501        if expected.phi().to_bits() != phi.to_bits()
502            || expected.is_estimated() != dispersion.is_estimated()
503        {
504            return Err(HmcError::InvalidConfig {
505                reason: format!(
506                    "HMC dispersion {dispersion:?} disagrees with likelihood metadata dispersion {expected:?}"
507                ),
508            });
509        }
510    }
511
512    match (&likelihood.spec.response, &likelihood.spec.link) {
513        (ResponseFamily::Gaussian, InverseLink::Standard(StandardLink::Identity))
514        | (ResponseFamily::Gamma, InverseLink::Standard(StandardLink::Log))
515        | (ResponseFamily::Poisson, InverseLink::Standard(StandardLink::Log))
516        | (ResponseFamily::Tweedie { .. }, InverseLink::Standard(StandardLink::Log))
517        | (ResponseFamily::NegativeBinomial { .. }, InverseLink::Standard(StandardLink::Log))
518        | (ResponseFamily::Beta { .. }, InverseLink::Standard(StandardLink::Logit))
519        | (ResponseFamily::Binomial, _) => {}
520        (family, link) => {
521            return Err(HmcError::LinkMismatch {
522                reason: format!(
523                    "HMC response family {} is incompatible with inverse link {link:?}",
524                    family.name()
525                ),
526            });
527        }
528    }
529
530    let cov_scale = likelihood
531        .coefficient_covariance_scale(phi)
532        .map_err(|error| HmcError::InvalidConfig {
533            reason: format!("HMC coefficient-covariance scale is unresolved: {error}"),
534        })?;
535    if !(cov_scale.is_finite() && cov_scale > 0.0) {
536        return Err(HmcError::InvalidConfig {
537            reason: format!(
538                "HMC coefficient-covariance scale must be finite and positive, got {cov_scale}"
539            ),
540        });
541    }
542
543    let mut target = likelihood;
544    target.scale = match (&target.spec.response, target.scale) {
545        (ResponseFamily::Gaussian, _) => LikelihoodScaleMetadata::FixedDispersion { phi },
546        (ResponseFamily::Gamma, LikelihoodScaleMetadata::FixedDispersion { .. }) => {
547            LikelihoodScaleMetadata::FixedGammaShape { shape: inv_phi }
548        }
549        (_, scale) => scale,
550    };
551    Ok((target, cov_scale))
552}
553
554fn validate_hmc_arrays(
555    x: ArrayView2<f64>,
556    y: ArrayView1<f64>,
557    weights: ArrayView1<f64>,
558    penalty: ArrayView2<f64>,
559    mode: ArrayView1<f64>,
560    hessian: ArrayView2<f64>,
561    context: &str,
562) -> Result<(), HmcError> {
563    let n = x.nrows();
564    let p = x.ncols();
565    if y.len() != n || weights.len() != n {
566        return Err(HmcError::DimensionMismatch {
567            reason: format!(
568                "{context}: row mismatch X={n}, y={}, weights={}",
569                y.len(),
570                weights.len()
571            ),
572        });
573    }
574    if mode.len() != p
575        || penalty.nrows() != p
576        || penalty.ncols() != p
577        || hessian.nrows() != p
578        || hessian.ncols() != p
579    {
580        return Err(HmcError::DimensionMismatch {
581            reason: format!(
582                "{context}: coefficient geometry mismatch X columns={p}, mode={}, penalty={:?}, hessian={:?}",
583                mode.len(),
584                penalty.dim(),
585                hessian.dim(),
586            ),
587        });
588    }
589    for (name, values) in [
590        ("design", x.iter()),
591        ("penalty", penalty.iter()),
592        ("hessian", hessian.iter()),
593    ] {
594        if let Some((index, value)) = values.enumerate().find(|(_, value)| !value.is_finite()) {
595            return Err(HmcError::NonFiniteState {
596                reason: format!(
597                    "{context}: {name} has non-finite entry {value} at flat index {index}"
598                ),
599            });
600        }
601    }
602    if let Some((index, value)) = mode
603        .iter()
604        .enumerate()
605        .find(|(_, value)| !value.is_finite())
606    {
607        return Err(HmcError::NonFiniteState {
608            reason: format!("{context}: mode has non-finite entry {value} at index {index}"),
609        });
610    }
611    if let Some((index, weight)) = weights
612        .iter()
613        .enumerate()
614        .find(|(_, weight)| !(weight.is_finite() && **weight >= 0.0))
615    {
616        return Err(HmcError::InvalidConfig {
617            reason: format!(
618                "{context}: observation weight at row {index} must be finite and non-negative, got {weight}"
619            ),
620        });
621    }
622    for row in 0..p {
623        for col in (row + 1)..p {
624            if penalty[[row, col]] != penalty[[col, row]] {
625                return Err(HmcError::InvalidConfig {
626                    reason: format!(
627                        "{context}: penalty must be exactly symmetric; ({row},{col})={} but ({col},{row})={}",
628                        penalty[[row, col]],
629                        penalty[[col, row]],
630                    ),
631                });
632            }
633        }
634    }
635    Ok(())
636}
637
638fn first_non_finite<'a>(values: impl IntoIterator<Item = &'a f64>) -> Option<(usize, f64)> {
639    values
640        .into_iter()
641        .copied()
642        .enumerate()
643        .find(|(_, value)| !value.is_finite())
644}
645
646/// Whitened-coordinate target for the No-U-Turn HMC sampler.
647///
648/// The posterior over β is reparameterized via `β = L z` where `L Lᵀ = H⁻¹`
649/// (Cholesky factor of the inverse posterior Hessian at the MAP), so that
650/// in `z`-coordinates the local curvature is approximately the identity.
651/// The struct holds the shared design, the whitening factor `L` and its
652/// transpose (for gradient chain-rule pull-back `∇_z = Lᵀ ∇_β`), the
653/// family-specific log-likelihood adapter, and a precomputed
654/// `M = Lᵀ S L` so the smoothing penalty `−½ βᵀSβ` becomes the cheap
655/// quadratic `−½ zᵀMz` inside the leapfrog hot loop.  Optionally adds
656/// the identifiable-subspace Firth/Jeffreys term to keep posterior modes
657/// away from infinity under separation.
658pub struct NutsPosterior {
659    /// Shared read-only data (Arc prevents duplication)
660    data: SharedData,
661    /// Transform: L where L L^T = H^{-1} (computed from Hessian)
662    /// This is the inverse-transpose of the Cholesky of H.
663    chol: Array2<f64>,
664    /// L^T for gradient chain rule: ∇z = L^T @ ∇_β
665    chol_t: Array2<f64>,
666    /// Whether to add the identifiable-subspace Jeffreys/Firth term to the
667    /// target
668    firth_enabled: bool,
669    /// Precomputed whitened-penalty operator `M = L^T S L` (dim×dim, symmetric
670    /// positive-semidefinite). The penalty term in z-coordinates is
671    ///   −0.5 βᵀSβ = −[c0 + (Lᵀ S μ)ᵀ z + 0.5 zᵀ M z],
672    /// so its z-gradient is just `−(L^T S μ + M z)` — no per-step `S·β` matvec
673    /// or `L^T·∇_β penalty` map is needed.
674    penalty_z_quad: Array2<f64>,
675    /// Precomputed `Lᵀ S μ` (length dim) — z-space gradient contribution from
676    /// the linear-in-z portion of the penalty.
677    penalty_z_lin: Array1<f64>,
678    /// Precomputed `0.5 μᵀ S μ` (scalar) — constant term of the penalty.
679    penalty_z_const: f64,
680    /// Coefficient-covariance scale `cov_scale` (#679/#680 invariant): the
681    /// `Vb = cov_scale·H⁻¹` multiplier. `σ̂²` for profiled Gaussian, `1.0` for
682    /// every weight-carries-dispersion family. Drives both the whitening
683    /// (`L Lᵀ = cov_scale·H⁻¹`) and the target penalty weight
684    /// (`penalty_scale = 1/cov_scale`).
685    cov_scale: f64,
686}
687
688impl NutsPosterior {
689    /// Creates a new posterior target from ndarray data.
690    ///
691    /// # Arguments
692    /// * `x` - Design matrix [n_samples, dim]
693    /// * `y` - Response vector \[n_samples\]
694    /// * `weights` - Observation/case weights \[n_samples\]
695    /// * `penalty_matrix` - Combined penalty S [dim, dim]
696    /// * `mode` - MAP estimate μ \[dim\]
697    /// * `hessian` - Hessian H [dim, dim] (NOT the inverse!)
698    /// * `nuts_family` - Family for log-likelihood computation
699    ///
700    /// # Numerical Stability
701    /// Accepts the Hessian directly and computes L = (chol(H))^{-T} via
702    /// triangular solves, which is more stable than explicitly inverting H.
703    pub fn new(
704        x: ArrayView2<f64>,
705        y: ArrayView1<f64>,
706        weights: ArrayView1<f64>,
707        penalty_matrix: ArrayView2<f64>,
708        mode: ArrayView1<f64>,
709        hessian: ArrayView2<f64>,
710        likelihood: GlmLikelihoodSpec,
711        dispersion: gam_solve::model_types::Dispersion,
712        offset: Option<ArrayView1<f64>>,
713        firth_enabled: bool,
714    ) -> Result<Self, String> {
715        let n_samples = x.nrows();
716        let dim = x.ncols();
717
718        validate_hmc_arrays(x, y, weights, penalty_matrix, mode, hessian, "NUTS")
719            .map_err(String::from)?;
720        let (likelihood, cov_scale) =
721            resolve_hmc_likelihood(likelihood, dispersion).map_err(String::from)?;
722        if let Some(offset) = offset.as_ref() {
723            if offset.len() != n_samples {
724                return Err(HmcError::DimensionMismatch {
725                    reason: format!(
726                        "NUTS offset length {} does not match {n_samples} observations",
727                        offset.len()
728                    ),
729                }
730                .into());
731            }
732            if let Some((row, value)) = offset
733                .iter()
734                .enumerate()
735                .find(|(_, value)| !value.is_finite())
736            {
737                return Err(HmcError::NonFiniteState {
738                    reason: format!("NUTS offset has non-finite value {value} at row {row}"),
739                }
740                .into());
741            }
742        }
743        validate_firth_likelihood_support(&likelihood.spec, firth_enabled).map_err(String::from)?;
744        if likelihood.spec.is_binomial() {
745            validate_binary_responses("binomial NUTS", &y, &weights).map_err(String::from)?;
746        }
747        if matches!(
748            likelihood.spec.response,
749            ResponseFamily::NegativeBinomial { .. }
750        ) {
751            validate_count_responses("negative-binomial NUTS", &y, &weights)
752                .map_err(String::from)?;
753        }
754        let mut eta_at_mode = x.dot(&mode);
755        if let Some(offset) = offset.as_ref() {
756            eta_at_mode += offset;
757        }
758        let mut score_at_mode = Array1::zeros(n_samples);
759        gam_solve::pirls::eta_log_likelihood_value_and_score_into(
760            y,
761            &eta_at_mode,
762            &likelihood,
763            &likelihood.spec.link,
764            weights,
765            &mut score_at_mode,
766        )
767        .map_err(|error| format!("NUTS likelihood is invalid at the fitted mode: {error}"))?;
768
769        // Whitening metric: `L Lᵀ` must equal the posterior covariance the
770        // sampler reproduces, `Vb = cov_scale · H⁻¹` (#679/#680 invariant), so
771        // scale `L` by `√cov_scale`. Only the profiled-Gaussian model carries a
772        // non-unit scale (σ̂² = `dispersion.phi()`); every weight-carries-
773        // dispersion family (Gamma/Tweedie/NB) already folds its dispersion into
774        // the stored `H`, so `cov_scale == 1` and this is a no-op. This replaces
775        // a previous `sqrt_phi()` multiply that wrongly scaled Gamma (and any
776        // φ-bearing family) by `√φ`, mis-preconditioning against `φ·H⁻¹`.
777        let whitening = hessian_whitening_transform(
778            hessian,
779            dim,
780            cov_scale,
781            "Hessian Cholesky decomposition failed",
782        )?;
783        let chol = whitening.chol;
784        let chol_t = whitening.chol_t;
785
786        // Precompute the whitened penalty operator and constants so that the
787        // penalty contribution to logp/grad becomes a single symv against z.
788        // Math identity (β = μ + L z, L L^T = H^{-1}):
789        //   0.5 β^T S β = 0.5 μ^T S μ + (L^T S μ)^T z + 0.5 z^T (L^T S L) z
790        // and ∇_z [0.5 β^T S β] = L^T S μ + (L^T S L) z.
791        // This replaces three matvecs per leapfrog step (S·β, L·z used only
792        // for that purpose, and L^T·∇_β penalty) with one dim×dim symv.
793        let penalty_owned = penalty_matrix.to_owned();
794        let mode_owned = mode.to_owned();
795        let s_mu = penalty_owned.dot(&mode_owned);
796        let penalty_z_const = 0.5 * mode_owned.dot(&s_mu);
797        let penalty_z_lin = chol_t.dot(&s_mu);
798        // M = L^T S L = chol_t · (S · chol). Computed in two GEMMs at
799        // construction time only.
800        let s_chol = penalty_owned.dot(&chol);
801        let penalty_z_quad = chol_t.dot(&s_chol);
802
803        let data = SharedData {
804            x: Arc::new(x.to_owned()),
805            y: Arc::new(y.to_owned()),
806            weights: Arc::new(weights.to_owned()),
807            mode: Arc::new(mode_owned),
808            offset: offset.map(|values| Arc::new(values.to_owned())),
809            likelihood,
810            n_samples,
811            dim,
812        };
813
814        Ok(Self {
815            data,
816            chol,
817            chol_t,
818            firth_enabled,
819            penalty_z_quad,
820            penalty_z_lin,
821            penalty_z_const,
822            cov_scale,
823        })
824    }
825
826    fn compute_logp_and_grad_nd_into(
827        &self,
828        z: &Array1<f64>,
829        residual: &mut Array1<f64>,
830        grad: &mut Array1<f64>,
831    ) -> f64 {
832        // === Step 1: Transform z (whitened) -> β (original) ===
833        // β = μ + L @ z
834        let beta = self.data.mode.as_ref() + &self.chol.dot(z);
835
836        // === Step 2: Compute η = X @ β (+ offset) ===
837        let mut eta = gam_linalg::faer_ndarray::fast_av(self.data.x.as_ref(), &beta);
838        if let Some(offset) = self.data.offset.as_ref() {
839            eta += offset.as_ref();
840        }
841
842        // === Step 3: Compute log-likelihood and gradient ===
843        let (ll, mut grad_ll_beta) = match self.family_logp_and_grad_into(&eta, residual) {
844            Ok(value) => value,
845            Err(error) => {
846                log::warn!("[NUTS] likelihood target is unrepresentable: {error}");
847                grad.fill(0.0);
848                return f64::NEG_INFINITY;
849            }
850        };
851
852        let mut firth_logdet = 0.0;
853        if self.firth_enabled {
854            match firth_jeffreys_logp_and_grad(&self.data.likelihood.spec, &self.data, &eta) {
855                Ok((value, grad_beta_firth)) => {
856                    firth_logdet = value;
857                    grad_ll_beta += &grad_beta_firth;
858                }
859                Err(err) => {
860                    log::warn!(
861                        "[NUTS/Firth] Jeffreys target became invalid at the current state: {}",
862                        err
863                    );
864                    grad.fill(0.0);
865                    return f64::NEG_INFINITY;
866                }
867            }
868        }
869
870        // === Step 4: Penalty in z-coordinates (precomputed; see `new`) ===
871        //   −0.5 βᵀ S β  =  −[c0 + lᵀ z + 0.5 zᵀ M z]
872        //   ∇_z (−0.5 βᵀ S β) = −(l + M z)
873        // where l = L^T S μ, M = L^T S L, c0 = 0.5 μᵀ S μ.
874        // This single dim×dim symmetric matvec replaces both the per-step
875        // S·β multiply and the L^T·∇_β penalty chain-rule multiply, and lets
876        // the penalty value, β-gradient and chain rule fuse into one pass.
877        //
878        // Penalty weight in the un-whitened β-target
879        // `log p(β) = loglik(β) − penalty_scale · ½ βᵀSβ`. The invariant is
880        // `Vb = cov_scale · H⁻¹` with `H = XᵀWX + S` (penalty added unscaled),
881        // so the target curvature must equal `Vb⁻¹ = H/cov_scale`. The
882        // likelihood already supplies `−∇²ℓ = (data Fisher info)/cov_scale`
883        // (explicitly `/σ²` for profiled Gaussian, implicitly via the working
884        // weight / the `shape ≡ 1/φ` encoded by the resolved Gamma metadata for
885        // the dispersion-carrying families), so the penalty must match it:
886        //   penalty_scale = 1/cov_scale.
887        // That is `1/σ²` for profiled Gaussian and exactly `1.0` for
888        // Gamma/Tweedie/NB/Poisson/Binomial. The previous code used
889        // the response-dispersion reciprocal for GammaLog (= shape = 1/φ ≠ 1), which
890        // double-counted the dispersion in the sampled posterior (#680); the
891        // statistical dispersion `φ` is NOT `1/cov_scale` for Gamma because it
892        // already lives inside `W`.
893        let penalty_scale = 1.0 / self.cov_scale;
894        let mz = self.penalty_z_quad.dot(z);
895        let lin_term = self.penalty_z_lin.dot(z);
896        let quad_term = 0.5 * z.dot(&mz);
897        let penalty = penalty_scale * (self.penalty_z_const + lin_term + quad_term);
898
899        // === Step 5: z-space gradient ===
900        // ∇z log p = L^T ∇_β ℓ  −  penalty_scale · (l + M z)
901        fast_av_into(&self.chol_t, &grad_ll_beta, grad);
902        // gradz -= penalty_scale · (penalty_z_lin + M z); fused parallel update.
903        let lin_view = self.penalty_z_lin.view();
904        ndarray::Zip::from(grad)
905            .and(&lin_view)
906            .and(&mz)
907            .par_for_each(|g, &l, &m| {
908                *g -= penalty_scale * (l + m);
909            });
910
911        ll + firth_logdet - penalty
912    }
913
914    fn family_logp_and_grad_into(
915        &self,
916        eta: &Array1<f64>,
917        residual: &mut Array1<f64>,
918    ) -> Result<(f64, Array1<f64>), String> {
919        exact_glm_logp_and_grad_into(&self.data, eta, residual)
920    }
921
922    /// Get the Cholesky factor L for un-whitening samples
923    pub fn chol(&self) -> &Array2<f64> {
924        &self.chol
925    }
926
927    /// Get the mode
928    pub fn mode(&self) -> &Array1<f64> {
929        &self.data.mode
930    }
931
932    /// Get dimension
933    pub fn dim(&self) -> usize {
934        self.data.dim
935    }
936}
937
938#[inline]
939fn validate_firth_likelihood_support(
940    likelihood: &LikelihoodSpec,
941    firth_enabled: bool,
942) -> Result<(), HmcError> {
943    if firth_enabled && !likelihood_spec_supports_firth(likelihood) {
944        return Err(HmcError::FirthUnsupported {
945            reason: format!(
946                "Joint HMC with Firth requires a Binomial inverse link with a Fisher-weight jet; {} does not support it",
947                likelihood.pretty_name()
948            ),
949        });
950    }
951    Ok::<(), _>(())
952}
953
954/// Wrap the workspace count-response contract in this crate's error type.
955///
956/// The predicate, the row scan and the message all belong to
957/// [`gam_solve::pirls::certify_count_responses`]. This crate previously carried
958/// its own copy of all three, and the copy's integrality test was a `1e-9`
959/// tolerance against the canonical exact `y == y.round()` -- so a response of
960/// `3.0 + 5e-10` was admitted for joint HMC and refused by P-IRLS on the same
961/// data and family, while both reported the identical message text.
962fn validate_count_responses(
963    family: &str,
964    y: &ArrayView1<'_, f64>,
965    weights: &ArrayView1<'_, f64>,
966) -> Result<(), HmcError> {
967    gam_solve::pirls::certify_count_responses(y, weights, family)
968        .map_err(|reason| HmcError::InvalidConfig { reason })
969}
970
971fn validate_binary_responses(
972    family: &str,
973    y: &ArrayView1<'_, f64>,
974    weights: &ArrayView1<'_, f64>,
975) -> Result<(), HmcError> {
976    for (i, (&yi, &wi)) in y.iter().zip(weights.iter()).enumerate() {
977        if wi > 0.0 && !(yi == 0.0 || yi == 1.0) {
978            return Err(HmcError::InvalidConfig {
979                reason: format!(
980                    "{family} response must be exactly 0 or 1 at positive-weight row {i}; got {yi}"
981                ),
982            });
983        }
984    }
985    Ok(())
986}
987
988/// Compute the identifiable-subspace Jeffreys/Firth contribution and its
989/// β-gradient.
990///
991/// HMC uses the same `FirthDenseOperator` as the REML exact-gradient path.
992/// The operator owns the reduced identifiable Fisher factorization, the
993/// Jeffreys log-determinant, and the analytic β-gradient.
994///
995/// Takes the full `LikelihoodSpec` — not a `NutsFamily` — because the
996/// Jeffreys determinant is built from the *inverse link's* Fisher-weight
997/// jet: at η = 0 the logit weight is 1/4 while probit's is 2/π, so
998/// collapsing every binomial link to logit produces the wrong determinant
999/// and gradient for probit / cloglog / adaptive (SAS, mixture) links.
1000fn firth_jeffreys_logp_and_grad(
1001    likelihood: &LikelihoodSpec,
1002    data: &SharedData,
1003    eta: &Array1<f64>,
1004) -> Result<(f64, Array1<f64>), HmcError> {
1005    if eta.len() != data.n_samples {
1006        return Err(HmcError::DimensionMismatch {
1007            reason: format!(
1008                "Firth Jeffreys term eta length {} != number of samples {}",
1009                eta.len(),
1010                data.n_samples
1011            ),
1012        });
1013    }
1014    if data.dim == 0 || data.n_samples == 0 {
1015        return Ok((0.0, Array1::zeros(data.dim)));
1016    }
1017    validate_firth_likelihood_support(likelihood, true)?;
1018    if data.weights.iter().all(|w| *w == 0.0) {
1019        return Ok((0.0, Array1::zeros(data.dim)));
1020    }
1021
1022    let jeffreys_link =
1023        likelihood_spec_jeffreys_link(likelihood).ok_or_else(|| HmcError::FirthUnsupported {
1024            reason: format!(
1025                "Firth Jeffreys term has no Fisher-weight jet for {}",
1026                likelihood.pretty_name()
1027            ),
1028        })?;
1029    let op = if data.weights.iter().all(|&w| w == 1.0) {
1030        FirthDenseOperator::build_for_link(&jeffreys_link, data.x.as_ref(), eta)
1031    } else {
1032        FirthDenseOperator::build_with_observation_weights_for_link(
1033            &jeffreys_link,
1034            data.x.as_ref(),
1035            eta,
1036            data.weights.view(),
1037        )
1038    }
1039    .map_err(|e| HmcError::SamplingFailed {
1040        reason: format!("Firth Jeffreys operator failed: {e}"),
1041    })?;
1042    Ok(op.jeffreys_logdet_and_beta_gradient())
1043}
1044
1045// ============================================================================
1046// Shared family log-likelihood helpers
1047// ============================================================================
1048//
1049// Freestanding functions for computing ℓ(y|β) and ∇_β ℓ for each supported
1050// family. Used by both `NutsPosterior` (fixed-ρ β-only sampling) and
1051// `JointBetaRhoPosterior` (joint β+ρ sampling).
1052
1053fn exact_glm_logp_and_grad_for_likelihood_into(
1054    likelihood: &GlmLikelihoodSpec,
1055    data: &SharedData,
1056    eta: &Array1<f64>,
1057    residual: &mut Array1<f64>,
1058) -> Result<(f64, Array1<f64>), String> {
1059    let value = gam_solve::pirls::eta_log_likelihood_value_and_score_into(
1060        data.y.view(),
1061        eta,
1062        likelihood,
1063        &likelihood.spec.link,
1064        data.weights.view(),
1065        residual,
1066    )
1067    .map_err(|error| error.to_string())?;
1068    Ok((value, fast_atv(data.x.as_ref(), residual)))
1069}
1070
1071fn exact_glm_logp_and_grad_into(
1072    data: &SharedData,
1073    eta: &Array1<f64>,
1074    residual: &mut Array1<f64>,
1075) -> Result<(f64, Array1<f64>), String> {
1076    exact_glm_logp_and_grad_for_likelihood_into(&data.likelihood, data, eta, residual)
1077}
1078
1079#[derive(Clone, Debug)]
1080struct BinomialLinkTerms {
1081    log_mu: f64,
1082    log1m_mu: f64,
1083    dmu_dlink: Vec<f64>,
1084}
1085
1086#[inline]
1087fn log_terms_from_mu_and_dmu(
1088    mu: f64,
1089    dmu_deta: f64,
1090    dmu_dlink: Vec<f64>,
1091) -> Result<BinomialLinkTerms, String> {
1092    if !(mu.is_finite() && (0.0..=1.0).contains(&mu) && dmu_deta.is_finite()) {
1093        return Err(format!(
1094            "binomial inverse link returned invalid mu/deta derivative: mu={mu}, dmu_deta={dmu_deta}"
1095        ));
1096    }
1097    let log_mu = if mu == 0.0 {
1098        f64::NEG_INFINITY
1099    } else {
1100        mu.ln()
1101    };
1102    let one_minus_mu = 1.0 - mu;
1103    let log1m_mu = if one_minus_mu == 0.0 {
1104        f64::NEG_INFINITY
1105    } else {
1106        one_minus_mu.ln()
1107    };
1108    Ok(BinomialLinkTerms {
1109        log_mu,
1110        log1m_mu,
1111        dmu_dlink,
1112    })
1113}
1114
1115#[inline]
1116fn binomial_link_terms(
1117    inverse_link: &InverseLink,
1118    eta: f64,
1119    n_link_params: usize,
1120) -> Result<BinomialLinkTerms, String> {
1121    let jet =
1122        inverse_link_jet_for_inverse_link(inverse_link, eta).map_err(|err| err.to_string())?;
1123    let mut dmu_dlink = vec![0.0; n_link_params];
1124    if n_link_params > 0 {
1125        match inverse_link
1126            .param_partials(eta)
1127            .map_err(|err| err.to_string())?
1128        {
1129            Some(LinkParamPartials::Sas(partials)) => {
1130                if n_link_params != 2 {
1131                    return Err(format!(
1132                        "SAS/Beta-Logistic link parameter dimension mismatch: expected 2, got {n_link_params}"
1133                    ));
1134                }
1135                dmu_dlink[0] = partials.djet_depsilon.mu;
1136                dmu_dlink[1] = partials.djet_dlog_delta.mu;
1137            }
1138            Some(LinkParamPartials::Mixture(partials)) => {
1139                if partials.djet_drho.len() != n_link_params {
1140                    return Err(format!(
1141                        "mixture link parameter dimension mismatch: expected {}, got {n_link_params}",
1142                        partials.djet_drho.len()
1143                    ));
1144                }
1145                for (slot, partial) in dmu_dlink.iter_mut().zip(partials.djet_drho.iter()) {
1146                    *slot = partial.mu;
1147                }
1148            }
1149            None => {
1150                return Err(format!(
1151                    "joint HMC expected {n_link_params} adaptive link parameters, but the inverse link exposes none"
1152                ));
1153            }
1154        }
1155    }
1156    log_terms_from_mu_and_dmu(jet.mu, jet.d1, dmu_dlink)
1157}
1158
1159fn joint_binomial_logp_grad_and_link_grad(
1160    inverse_link: &InverseLink,
1161    data: &SharedData,
1162    eta: &Array1<f64>,
1163    n_link_params: usize,
1164) -> Result<(f64, Array1<f64>, Array1<f64>), String> {
1165    let n = data.n_samples;
1166    let mut likelihood = data.likelihood.clone();
1167    likelihood.spec = LikelihoodSpec::new(ResponseFamily::Binomial, inverse_link.clone());
1168    let mut residual = Array1::<f64>::zeros(n);
1169    let (ll, grad_beta) =
1170        exact_glm_logp_and_grad_for_likelihood_into(&likelihood, data, eta, &mut residual)?;
1171
1172    // Only the adaptive-link pullback remains local. The likelihood value and
1173    // eta score above come from the shared exact row oracle; these partials use
1174    // the same inverse-link jet and therefore cannot select a different mean.
1175    use rayon::iter::{IntoParallelIterator, ParallelIterator};
1176    let per_row: Result<Vec<Vec<f64>>, String> = (0..n)
1177        .into_par_iter()
1178        .map(|i| {
1179            let y_i = data.y[i];
1180            let w_i = data.weights[i];
1181            if w_i == 0.0 {
1182                return Ok(vec![0.0; n_link_params]);
1183            }
1184            let terms = binomial_link_terms(inverse_link, eta[i], n_link_params)?;
1185            let (sign, log_probability) = if y_i == 1.0 {
1186                (1.0, terms.log_mu)
1187            } else if y_i == 0.0 {
1188                (-1.0, terms.log1m_mu)
1189            } else {
1190                return Err(format!(
1191                    "binomial joint HMC response must be exactly 0 or 1 after validation; got {y_i}"
1192                ));
1193            };
1194            terms
1195                .dmu_dlink
1196                .into_iter()
1197                .map(|dmu| {
1198                    if dmu == 0.0 {
1199                        return Ok(0.0);
1200                    }
1201                    if !dmu.is_finite() {
1202                        return Err(format!(
1203                            "binomial adaptive-link derivative is non-finite at row {i}: {dmu}"
1204                        ));
1205                    }
1206                    let log_abs = w_i.ln() + dmu.abs().ln() - log_probability;
1207                    let value = sign * dmu.signum() * log_abs.exp();
1208                    if value.is_finite() {
1209                        Ok(value)
1210                    } else {
1211                        Err(format!(
1212                            "binomial adaptive-link score is unrepresentable at row {i}"
1213                        ))
1214                    }
1215                })
1216                .collect()
1217        })
1218        .collect();
1219    let per_row = per_row?;
1220    let mut grad_link = Array1::<f64>::zeros(n_link_params);
1221    for grad_link_i in per_row {
1222        for (slot, value) in grad_link.iter_mut().zip(grad_link_i.iter()) {
1223            *slot += *value;
1224        }
1225    }
1226
1227    Ok((ll, grad_beta, grad_link))
1228}
1229
1230fn joint_family_logp_grad_and_link_grad(
1231    likelihood: &LikelihoodSpec,
1232    data: &SharedData,
1233    eta: &Array1<f64>,
1234    n_link_params: usize,
1235) -> Result<(f64, Array1<f64>, Array1<f64>), String> {
1236    match (&likelihood.response, &likelihood.link) {
1237        (ResponseFamily::Binomial, InverseLink::Standard(_)) => {
1238            let (ll, grad) = joint_family_logp_and_grad(likelihood, data, eta)?;
1239            Ok((ll, grad, Array1::zeros(n_link_params)))
1240        }
1241        (
1242            ResponseFamily::Binomial,
1243            InverseLink::LatentCLogLog(_)
1244            | InverseLink::Sas(_)
1245            | InverseLink::BetaLogistic(_)
1246            | InverseLink::Mixture(_),
1247        ) => joint_binomial_logp_grad_and_link_grad(&likelihood.link, data, eta, n_link_params),
1248        _ => {
1249            let (ll, grad) = joint_family_logp_and_grad(likelihood, data, eta)?;
1250            Ok((ll, grad, Array1::zeros(n_link_params)))
1251        }
1252    }
1253}
1254
1255fn joint_family_logp_and_grad(
1256    likelihood: &LikelihoodSpec,
1257    data: &SharedData,
1258    eta: &Array1<f64>,
1259) -> Result<(f64, Array1<f64>), String> {
1260    if likelihood.response != data.likelihood.spec.response {
1261        return Err(HmcError::InvalidConfig {
1262            reason: format!(
1263                "joint HMC step response {:?} disagrees with resolved response {:?}",
1264                likelihood.response, data.likelihood.spec.response
1265            ),
1266        }
1267        .into());
1268    }
1269    let mut resolved = data.likelihood.clone();
1270    resolved.spec = likelihood.clone();
1271    let mut residual = Array1::zeros(data.n_samples);
1272    exact_glm_logp_and_grad_for_likelihood_into(&resolved, data, eta, &mut residual)
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277
1278    /// Whitened log-posterior target with analytical gradients.
1279    ///
1280    /// Uses Arc for shared data to prevent memory explosion when cloned for chains.
1281    /// Uses faer for numerically stable Cholesky decomposition.
1282    /// Family mode for NUTS log-likelihood computation.
1283    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1284    pub enum NutsFamily {
1285        Gaussian,
1286        BinomialLogit,
1287        BinomialProbit,
1288        PoissonLog,
1289        GammaLog,
1290    }
1291
1292    impl NutsFamily {
1293        #[inline]
1294        fn likelihood_spec(self) -> LikelihoodSpec {
1295            match self {
1296                Self::Gaussian => LikelihoodSpec {
1297                    response: ResponseFamily::Gaussian,
1298                    link: InverseLink::Standard(StandardLink::Identity),
1299                },
1300                Self::BinomialLogit => LikelihoodSpec {
1301                    response: ResponseFamily::Binomial,
1302                    link: InverseLink::Standard(StandardLink::Logit),
1303                },
1304                Self::BinomialProbit => LikelihoodSpec {
1305                    response: ResponseFamily::Binomial,
1306                    link: InverseLink::Standard(StandardLink::Probit),
1307                },
1308                Self::PoissonLog => LikelihoodSpec {
1309                    response: ResponseFamily::Poisson,
1310                    link: InverseLink::Standard(StandardLink::Log),
1311                },
1312                Self::GammaLog => LikelihoodSpec {
1313                    response: ResponseFamily::Gamma,
1314                    link: InverseLink::Standard(StandardLink::Log),
1315                },
1316            }
1317        }
1318    }
1319
1320    use super::{
1321        FamilyNutsInputs, GlmFlatInputs, JointBetaRhoInputs, JointBetaRhoPosterior, NutsConfig,
1322        NutsPosterior, NutsResult, SharedData, exact_glm_logp_and_grad_into,
1323        firth_jeffreys_logp_and_grad, joint_family_logp_and_grad,
1324        laplace_directional_cubic_diagnostic, laplace_skewness_threshold,
1325        laplace_trustworthiness_from_skewness, run_joint_beta_rho_sampling,
1326        run_logit_polya_gamma_gibbs, run_nuts_sampling_flattened_family,
1327    };
1328    use gam_linalg::matrix::DesignMatrix;
1329    use gam_models::survival::{PenaltyBlocks, SurvivalMonotonicityPenalty, SurvivalSpec};
1330    use gam_problem::types::{
1331        GlmLikelihoodSpec, InverseLink, LikelihoodScaleMetadata, LikelihoodSpec,
1332        LogLikelihoodNormalization, ResponseFamily, RhoPrior, StandardLink,
1333    };
1334    use gam_solve::estimate::{
1335        BlockRole, FitGeometry, FitInference, FittedBlock, FittedLinkState, UnifiedFitResult,
1336        UnifiedFitResultParts,
1337    };
1338    use gam_terms::construction::CanonicalPenalty;
1339    use general_mcmc::generic_hmc::HamiltonianTarget;
1340    use ndarray::{Array1, Array2, array};
1341    use std::sync::Arc;
1342
1343    #[test]
1344    fn posterior_interval_uses_shared_linear_quantiles() {
1345        let result = NutsResult {
1346            samples: array![[0.0], [1.0], [2.0], [3.0]],
1347            posterior_mean: array![1.5],
1348            posterior_std: array![1.0],
1349            rhat: 1.0,
1350            ess: 4.0,
1351            converged: true,
1352        };
1353
1354        let (lower, upper) = result.posterior_interval_of(|row| row[0], 25.0, 75.0);
1355
1356        assert!((lower - 0.75).abs() < 1e-12, "lower = {lower}");
1357        assert!((upper - 2.25).abs() < 1e-12, "upper = {upper}");
1358    }
1359
1360    impl NutsPosterior {
1361        /// Test-only allocation wrapper around `compute_logp_and_grad_nd_into`.
1362        pub(super) fn compute_logp_and_grad_nd(&self, z: &Array1<f64>) -> (f64, Array1<f64>) {
1363            let mut residual = Array1::<f64>::zeros(self.data.n_samples);
1364            let mut grad = Array1::<f64>::zeros(z.len());
1365            let logp = self.compute_logp_and_grad_nd_into(z, &mut residual, &mut grad);
1366            (logp, grad)
1367        }
1368    }
1369
1370    impl JointBetaRhoPosterior {
1371        /// Test-only allocation wrapper around `compute_joint_logp_and_grad_into`.
1372        pub(super) fn compute_joint_logp_and_grad(
1373            &self,
1374            params: &Array1<f64>,
1375        ) -> (f64, Array1<f64>) {
1376            let total_dim = self.n_beta + self.n_rho + self.n_link_params;
1377            let mut grad = Array1::<f64>::zeros(total_dim);
1378            let logp = self.compute_joint_logp_and_grad_into(params, &mut grad);
1379            (logp, grad)
1380        }
1381    }
1382
1383    fn nuts_test_likelihood(family: NutsFamily, parameter: f64) -> GlmLikelihoodSpec {
1384        let spec = family.likelihood_spec();
1385        let scale = match family {
1386            NutsFamily::Gaussian => LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
1387            NutsFamily::GammaLog => {
1388                LikelihoodScaleMetadata::EstimatedGammaShape { shape: parameter }
1389            }
1390            NutsFamily::BinomialLogit | NutsFamily::BinomialProbit | NutsFamily::PoissonLog => {
1391                LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 }
1392            }
1393        };
1394        GlmLikelihoodSpec { spec, scale }
1395    }
1396
1397    fn exact_eta_geometry(
1398        likelihood: &GlmLikelihoodSpec,
1399        y: &Array1<f64>,
1400        weights: &Array1<f64>,
1401        eta: &Array1<f64>,
1402    ) -> Result<(f64, Array1<f64>), String> {
1403        let mut score = Array1::zeros(eta.len());
1404        let value = gam_solve::pirls::eta_log_likelihood_value_and_score_into(
1405            y.view(),
1406            eta,
1407            likelihood,
1408            &likelihood.spec.link,
1409            weights.view(),
1410            &mut score,
1411        )
1412        .map_err(|error| error.to_string())?;
1413        Ok((value, score))
1414    }
1415
1416    fn hmc_test_fit(
1417        blocks: Vec<FittedBlock>,
1418        inference: Option<FitInference>,
1419        geometry: Option<FitGeometry>,
1420    ) -> UnifiedFitResult {
1421        let lambdas = Array1::zeros(0);
1422        UnifiedFitResult::try_from_parts(UnifiedFitResultParts {
1423            blocks,
1424            training_sample_size: 16,
1425            log_lambdas: lambdas.clone(),
1426            lambdas,
1427            likelihood_family: Some(LikelihoodSpec::new(
1428                ResponseFamily::Gaussian,
1429                InverseLink::Standard(StandardLink::Identity),
1430            )),
1431            likelihood_scale: LikelihoodScaleMetadata::ProfiledGaussian,
1432            log_likelihood_normalization: LogLikelihoodNormalization::Full,
1433            log_likelihood: -1.0,
1434            deviance: 2.0,
1435            reml_score: Some(0.0),
1436            stable_penalty_term: 0.0,
1437            penalized_objective: Some(0.0),
1438            used_device: false,
1439            // Fixed-fit semantics (outer_iterations = 0): these hand-built
1440            // fixtures carry no analytic stationarity certificate, and the
1441            // #2255 assembly gate correctly refuses an
1442            // iterations-ran-but-uncertified state. The fixtures never
1443            // exercised an outer search; declaring them fixed-ρ fits states
1444            // what they actually are.
1445            outer_iterations: 0,
1446            outer_converged: true,
1447            outer_gradient_norm: None,
1448            standard_deviation: 1.0,
1449            covariance_conditional: None,
1450            covariance_corrected: None,
1451            inference,
1452            fitted_link: FittedLinkState::Standard(None),
1453            geometry,
1454            block_states: Vec::new(),
1455            pirls_status: gam_solve::pirls::PirlsStatus::Converged,
1456            max_abs_eta: 0.0,
1457            constraint_kkt: None,
1458            artifacts: Default::default(),
1459            inner_cycles: 0,
1460        })
1461        .expect("valid HMC handoff test fit")
1462    }
1463
1464    #[test]
1465    fn hmc_whitening_consumes_standard_fit_inference_hessian() {
1466        let hessian = array![[2.0, 0.1], [0.1, 1.6]];
1467        let fit = hmc_test_fit(
1468            vec![FittedBlock {
1469                beta: array![0.05, -0.1],
1470                role: BlockRole::Mean,
1471                edf: 2.0,
1472                lambdas: Array1::zeros(0),
1473            }],
1474            Some(FitInference {
1475                edf_by_block: vec![],
1476                penalty_block_trace: vec![],
1477                edf_total: 2.0,
1478                smoothing_correction: None,
1479                smoothing_correction_method: None,
1480                smoothing_correction_first_order: None,
1481                smoothing_correction_method_first_order: None,
1482                penalized_hessian: hessian.clone().into(),
1483                reparam_qs: None,
1484                dispersion: gam_solve::estimate::Dispersion::UNIT,
1485                beta_covariance: None,
1486                beta_standard_errors: None,
1487                beta_covariance_corrected: None,
1488                beta_standard_errors_corrected: None,
1489                beta_covariance_frequentist: None,
1490                coefficient_influence: None,
1491                weighted_gram: None,
1492                bias_correction_beta: None,
1493                bias_correction_jacobian: None,
1494            }),
1495            None,
1496        );
1497
1498        let explicit = super::explicit_fit_hessian_for_whitening(&fit, 2, "standard fit")
1499            .expect("standard fit exports explicit Hessian");
1500        assert_eq!(explicit, &hessian);
1501
1502        let x = array![[1.0, 0.0], [1.0, 0.5], [1.0, -0.5]];
1503        let y = array![0.0, 0.2, -0.1];
1504        let weights = Array1::ones(3);
1505        let penalty = Array2::eye(2);
1506        NutsPosterior::new(
1507            x.view(),
1508            y.view(),
1509            weights.view(),
1510            penalty.view(),
1511            fit.beta.view(),
1512            explicit.view(),
1513            nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
1514            gam_solve::estimate::Dispersion::UNIT,
1515            None,
1516            false,
1517        )
1518        .expect("HMC target whitens with upstream Hessian");
1519    }
1520
1521    #[test]
1522    fn hmc_whitening_consumes_blockwise_geometry_hessian() {
1523        let hessian = array![[3.0, 0.2], [0.2, 2.0]];
1524        let fit = hmc_test_fit(
1525            vec![
1526                FittedBlock {
1527                    beta: array![0.1],
1528                    role: BlockRole::Location,
1529                    edf: 1.0,
1530                    lambdas: Array1::zeros(0),
1531                },
1532                FittedBlock {
1533                    beta: array![-0.2],
1534                    role: BlockRole::Scale,
1535                    edf: 1.0,
1536                    lambdas: Array1::zeros(0),
1537                },
1538            ],
1539            None,
1540            Some(FitGeometry {
1541                coefficient_gauge: gam_problem::Gauge::identity(&[1, 1]),
1542                penalized_hessian: hessian.clone().into(),
1543                constrained_posterior: None,
1544                working: None,
1545            }),
1546        );
1547
1548        let explicit = super::explicit_fit_hessian_for_whitening(&fit, 2, "blockwise fit")
1549            .expect("blockwise fit exports materialized Hessian");
1550        assert_eq!(explicit, &hessian);
1551    }
1552
1553    #[test]
1554    fn hmc_whitening_rejects_covariance_only_fit_without_synthesizing_hessian() {
1555        let fit = UnifiedFitResult::try_from_parts(UnifiedFitResultParts {
1556            blocks: vec![FittedBlock {
1557                beta: array![0.0],
1558                role: BlockRole::Mean,
1559                edf: 1.0,
1560                lambdas: Array1::zeros(0),
1561            }],
1562            training_sample_size: 16,
1563            log_lambdas: Array1::zeros(0),
1564            lambdas: Array1::zeros(0),
1565            likelihood_family: Some(LikelihoodSpec::new(
1566                ResponseFamily::Gaussian,
1567                InverseLink::Standard(StandardLink::Identity),
1568            )),
1569            likelihood_scale: LikelihoodScaleMetadata::ProfiledGaussian,
1570            log_likelihood_normalization: LogLikelihoodNormalization::Full,
1571            log_likelihood: -1.0,
1572            deviance: 2.0,
1573            reml_score: Some(0.0),
1574            stable_penalty_term: 0.0,
1575            penalized_objective: Some(0.0),
1576            used_device: false,
1577            // Fixed-fit semantics (outer_iterations = 0): these hand-built
1578            // fixtures carry no analytic stationarity certificate, and the
1579            // #2255 assembly gate correctly refuses an
1580            // iterations-ran-but-uncertified state. The fixtures never
1581            // exercised an outer search; declaring them fixed-ρ fits states
1582            // what they actually are.
1583            outer_iterations: 0,
1584            outer_converged: true,
1585            outer_gradient_norm: None,
1586            standard_deviation: 1.0,
1587            covariance_conditional: Some(array![[0.5]]),
1588            covariance_corrected: None,
1589            inference: None,
1590            fitted_link: FittedLinkState::Standard(None),
1591            geometry: None,
1592            block_states: Vec::new(),
1593            pirls_status: gam_solve::pirls::PirlsStatus::Converged,
1594            max_abs_eta: 0.0,
1595            constraint_kkt: None,
1596            artifacts: Default::default(),
1597            inner_cycles: 0,
1598        })
1599        .expect("covariance-only fit can exist for prediction");
1600
1601        let err = super::explicit_fit_hessian_for_whitening(&fit, 1, "covariance-only fit")
1602            .expect_err("HMC must not invert covariance as a Hessian fallback");
1603        assert!(
1604            err.contains("missing an explicit penalized Hessian"),
1605            "unexpected error: {err}"
1606        );
1607    }
1608
1609    #[test]
1610    fn log1pexp_is_finite_for_extreme_eta() {
1611        assert!(gam_linalg::utils::stable_softplus(1000.0).is_finite());
1612        assert!(gam_linalg::utils::stable_softplus(-1000.0).is_finite());
1613        assert!((gam_linalg::utils::stable_softplus(-1000.0) - 0.0).abs() < 1e-12);
1614    }
1615
1616    #[test]
1617    fn sigmoid_stable_behaves_at_extremes() {
1618        let hi = gam_linalg::utils::stable_logistic(1000.0);
1619        let lo = gam_linalg::utils::stable_logistic(-1000.0);
1620        assert!((1.0 - 1e-12..=1.0).contains(&hi));
1621        assert!((0.0..=1e-12).contains(&lo));
1622    }
1623
1624    #[test]
1625    fn exact_hmc_family_surfaces_share_value_and_eta_score() {
1626        let fixed = |spec: LikelihoodSpec, scale: LikelihoodScaleMetadata| GlmLikelihoodSpec {
1627            spec,
1628            scale,
1629        };
1630        let cases = vec![
1631            (
1632                fixed(
1633                    LikelihoodSpec::gaussian_identity(),
1634                    LikelihoodScaleMetadata::FixedDispersion { phi: 2.0 },
1635                ),
1636                array![f64::NAN, -0.4, 1.7],
1637            ),
1638            (
1639                GlmLikelihoodSpec::canonical(LikelihoodSpec::binomial_logit()),
1640                array![f64::NAN, 1.0, 0.0],
1641            ),
1642            (
1643                GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1644                    ResponseFamily::Binomial,
1645                    InverseLink::Standard(StandardLink::Probit),
1646                )),
1647                array![f64::NAN, 1.0, 0.0],
1648            ),
1649            (
1650                GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1651                    ResponseFamily::Binomial,
1652                    InverseLink::Standard(StandardLink::CLogLog),
1653                )),
1654                array![f64::NAN, 1.0, 0.0],
1655            ),
1656            (
1657                GlmLikelihoodSpec::canonical(LikelihoodSpec::poisson_log()),
1658                array![f64::NAN, 0.0, 3.0],
1659            ),
1660            (
1661                fixed(
1662                    LikelihoodSpec::new(
1663                        ResponseFamily::Gamma,
1664                        InverseLink::Standard(StandardLink::Log),
1665                    ),
1666                    LikelihoodScaleMetadata::FixedGammaShape { shape: 3.0 },
1667                ),
1668                array![f64::NAN, 0.4, 2.2],
1669            ),
1670            (
1671                fixed(
1672                    LikelihoodSpec::new(
1673                        ResponseFamily::Tweedie { p: 1.4 },
1674                        InverseLink::Standard(StandardLink::Log),
1675                    ),
1676                    LikelihoodScaleMetadata::FixedDispersion { phi: 0.7 },
1677                ),
1678                array![f64::NAN, 0.0, 2.2],
1679            ),
1680            (
1681                fixed(
1682                    LikelihoodSpec::new(
1683                        ResponseFamily::NegativeBinomial {
1684                            theta: 2.5,
1685                            theta_fixed: true,
1686                        },
1687                        InverseLink::Standard(StandardLink::Log),
1688                    ),
1689                    LikelihoodScaleMetadata::FixedNegBinTheta { theta: 2.5 },
1690                ),
1691                array![f64::NAN, 0.0, 3.0],
1692            ),
1693            (
1694                fixed(
1695                    LikelihoodSpec::new(
1696                        ResponseFamily::Beta { phi: 12.0 },
1697                        InverseLink::Standard(StandardLink::Logit),
1698                    ),
1699                    LikelihoodScaleMetadata::EstimatedBetaPhi { phi: 12.0 },
1700                ),
1701                array![f64::NAN, 0.2, 0.8],
1702            ),
1703        ];
1704        let weights = array![0.0, 1.0e-300, 1.25];
1705        let eta = array![0.0, -0.35, 0.6];
1706        for (likelihood, y) in cases {
1707            let (value, score) = exact_eta_geometry(&likelihood, &y, &weights, &eta)
1708                .unwrap_or_else(|error| panic!("{}: {error}", likelihood.spec.pretty_name()));
1709            assert!(value.is_finite());
1710            // A zero prior weight must contribute EXACTLY zero — not merely
1711            // something small — which is why this is an equality against 0.0
1712            // and not a tolerance. The SIGN of that zero is not part of the
1713            // contract: production reaches it as `-0.0` (bit pattern 2^63,
1714            // which is what `left: 9223372036854775808` in the old failure
1715            // was) through a negated product, and `-0.0 == 0.0` is precisely
1716            // the numerical statement being made. Comparing `to_bits()`
1717            // promoted an IEEE sign bit into a correctness claim; `== 0.0`
1718            // still rejects every nonzero, including subnormals.
1719            assert_eq!(
1720                score[0],
1721                0.0,
1722                "{} must erase a zero-weight row's score exactly",
1723                likelihood.spec.pretty_name()
1724            );
1725            assert!(
1726                score[1] != 0.0 && score[1].is_finite(),
1727                "{} erased a positive tiny weight",
1728                likelihood.spec.pretty_name()
1729            );
1730            let eps = 1.0e-6;
1731            let mut plus = eta.clone();
1732            let mut minus = eta.clone();
1733            plus[2] += eps;
1734            minus[2] -= eps;
1735            let (vp, _) = exact_eta_geometry(&likelihood, &y, &weights, &plus).unwrap();
1736            let (vm, _) = exact_eta_geometry(&likelihood, &y, &weights, &minus).unwrap();
1737            let fd = (vp - vm) / (2.0 * eps);
1738            let tolerance = 2.0e-5 * (1.0 + fd.abs());
1739            assert!(
1740                (score[2] - fd).abs() <= tolerance,
1741                "{} value/score mismatch: analytic={} fd={fd}",
1742                likelihood.spec.pretty_name(),
1743                score[2]
1744            );
1745        }
1746    }
1747
1748    #[test]
1749    fn exact_hmc_family_tails_are_finite_when_the_surface_is_representable() {
1750        let tail_cases = [
1751            (
1752                GlmLikelihoodSpec::canonical(LikelihoodSpec::poisson_log()),
1753                array![0.0],
1754                array![-1000.0],
1755            ),
1756            (
1757                GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1758                    ResponseFamily::Binomial,
1759                    InverseLink::Standard(StandardLink::Probit),
1760                )),
1761                array![1.0],
1762                array![-30.0],
1763            ),
1764            (
1765                GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1766                    ResponseFamily::Binomial,
1767                    InverseLink::Standard(StandardLink::CLogLog),
1768                )),
1769                array![1.0],
1770                array![-1000.0],
1771            ),
1772            (
1773                GlmLikelihoodSpec {
1774                    spec: LikelihoodSpec::new(
1775                        ResponseFamily::Gamma,
1776                        InverseLink::Standard(StandardLink::Log),
1777                    ),
1778                    scale: LikelihoodScaleMetadata::FixedGammaShape { shape: 1.0 },
1779                },
1780                array![1.0],
1781                array![1.0e308],
1782            ),
1783            (
1784                GlmLikelihoodSpec {
1785                    spec: LikelihoodSpec::new(
1786                        ResponseFamily::NegativeBinomial {
1787                            theta: 1.0,
1788                            theta_fixed: true,
1789                        },
1790                        InverseLink::Standard(StandardLink::Log),
1791                    ),
1792                    scale: LikelihoodScaleMetadata::FixedNegBinTheta { theta: 1.0 },
1793                },
1794                array![2.0],
1795                array![1.0e308],
1796            ),
1797            (
1798                GlmLikelihoodSpec {
1799                    spec: LikelihoodSpec::new(
1800                        ResponseFamily::Beta { phi: 8.0 },
1801                        InverseLink::Standard(StandardLink::Logit),
1802                    ),
1803                    scale: LikelihoodScaleMetadata::EstimatedBetaPhi { phi: 8.0 },
1804                },
1805                array![0.2],
1806                array![-1000.0],
1807            ),
1808        ];
1809        for (likelihood, y, eta) in tail_cases {
1810            let (value, score) = exact_eta_geometry(&likelihood, &y, &array![1.0], &eta)
1811                .unwrap_or_else(|error| {
1812                    panic!(
1813                        "{} tail should be representable: {error}",
1814                        likelihood.spec.pretty_name()
1815                    )
1816                });
1817            assert!(value.is_finite());
1818            assert!(score[0].is_finite());
1819        }
1820    }
1821
1822    #[test]
1823    fn hmc_scale_resolution_rejects_inconsistent_metadata_without_defaults() {
1824        let inconsistent_nb = GlmLikelihoodSpec {
1825            spec: LikelihoodSpec::new(
1826                ResponseFamily::NegativeBinomial {
1827                    theta: 2.0,
1828                    theta_fixed: true,
1829                },
1830                InverseLink::Standard(StandardLink::Log),
1831            ),
1832            scale: LikelihoodScaleMetadata::FixedNegBinTheta { theta: 3.0 },
1833        };
1834        assert!(
1835            super::resolve_hmc_likelihood(
1836                inconsistent_nb,
1837                gam_solve::model_types::Dispersion::UNIT,
1838            )
1839            .is_err()
1840        );
1841
1842        let unresolved_gamma = GlmLikelihoodSpec {
1843            spec: LikelihoodSpec::new(
1844                ResponseFamily::Gamma,
1845                InverseLink::Standard(StandardLink::Log),
1846            ),
1847            scale: LikelihoodScaleMetadata::Unspecified,
1848        };
1849        assert!(
1850            super::resolve_hmc_likelihood(
1851                unresolved_gamma,
1852                gam_solve::model_types::Dispersion::UNIT,
1853            )
1854            .is_err()
1855        );
1856    }
1857
1858    #[test]
1859    fn cloglog_log_mu_uses_complementary_loglog_inverse_link() {
1860        let eta = -1.0_f64;
1861        let likelihood = GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1862            ResponseFamily::Binomial,
1863            InverseLink::Standard(StandardLink::CLogLog),
1864        ));
1865        let (ll_y1, score) =
1866            exact_eta_geometry(&likelihood, &array![1.0], &array![1.0], &array![eta])
1867                .expect("valid eta");
1868        let residual_y1 = score[0];
1869        let expected = (1.0 - (-eta.exp()).exp()).ln();
1870        let wrong_log_one_minus_exp_eta = (1.0 - eta.exp()).ln();
1871
1872        assert!((ll_y1 - expected).abs() < 1e-14);
1873        assert!((ll_y1 - wrong_log_one_minus_exp_eta).abs() > 0.5);
1874
1875        let eps = 1e-6;
1876        let (lp, _) =
1877            exact_eta_geometry(&likelihood, &array![1.0], &array![1.0], &array![eta + eps])
1878                .expect("valid eta");
1879        let (lm, _) =
1880            exact_eta_geometry(&likelihood, &array![1.0], &array![1.0], &array![eta - eps])
1881                .expect("valid eta");
1882        let fd = (lp - lm) / (2.0 * eps);
1883        assert!(
1884            (residual_y1 - fd).abs() < 1e-9,
1885            "cloglog residual is not the derivative of log μ: analytic={residual_y1}, fd={fd}"
1886        );
1887    }
1888
1889    #[test]
1890    fn finite_eta_beyond_the_old_support_window_keeps_its_valid_log_density() {
1891        // A Poisson row with y = 0 at η = −701 has log-likelihood
1892        // −exp(−701) ≈ 0 — a perfectly valid, essentially maximal density.
1893        // The old hard-coded ±700 window declared it impossible (−∞),
1894        // truncating the sampled posterior at an arbitrary boundary.
1895        let data = SharedData {
1896            x: Arc::new(array![[1.0]]),
1897            y: Arc::new(array![0.0]),
1898            weights: Arc::new(array![1.0]),
1899            mode: Arc::new(array![0.0]),
1900            offset: None,
1901            likelihood: nuts_test_likelihood(NutsFamily::PoissonLog, 1.0),
1902            n_samples: 1,
1903            dim: 1,
1904        };
1905        let eta = array![-701.0];
1906        let mut eta_score = Array1::zeros(1);
1907        let (ll, grad) = exact_glm_logp_and_grad_into(&data, &eta, &mut eta_score)
1908            .expect("representable Poisson tail");
1909        assert!(
1910            ll.is_finite() && ll.abs() < 1e-300,
1911            "Poisson y=0, eta=-701 must keep its ~0 log-density, got {ll}"
1912        );
1913        assert!(grad[0].is_finite());
1914
1915        // Deep cloglog left tail: exp(η) underflows below η ≈ −745, but the
1916        // exact limits are log μ → η and d(log μ)/dη → 1.
1917        let cloglog = GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1918            ResponseFamily::Binomial,
1919            InverseLink::Standard(StandardLink::CLogLog),
1920        ));
1921        let (ll_tail, score_tail) =
1922            exact_eta_geometry(&cloglog, &array![1.0], &array![1.0], &array![-750.0])
1923                .expect("finite eta is valid");
1924        let res_tail = score_tail[0];
1925        assert!(
1926            (ll_tail - (-750.0)).abs() < 1e-9,
1927            "cloglog log-density must approach eta in the deep left tail, got {ll_tail}"
1928        );
1929        assert!((res_tail - 1.0).abs() < 1e-9, "residual limit is 1");
1930
1931        // Genuine binary64 exhaustion (y > 0 against an overflowing mean) is
1932        // still rejected as −∞ with a zero gradient.
1933        let data_pos = SharedData {
1934            y: Arc::new(array![3.0]),
1935            ..data
1936        };
1937        let mut overflow_score = Array1::zeros(1);
1938        assert!(
1939            exact_glm_logp_and_grad_into(&data_pos, &array![710.0], &mut overflow_score).is_err(),
1940            "unrepresentable Poisson tail must fail atomically"
1941        );
1942    }
1943
1944    /// #2245 finding 16: saved-model sampling must reconstruct the fitted
1945    /// *weighted* likelihood, not a unit-weight one. The intercept-only
1946    /// Bernoulli with `(y, w) = (1, 100), (0, 1)` has weighted score
1947    /// `dℓ/dη = 100·(1 − μ) − 1·μ = 100 − 101·μ`, which vanishes at
1948    /// `μ = 100/101`, i.e. the weighted mode `η* = log 100`. Reconstructing the
1949    /// target with `weights = ones` (the historical bug) instead centres it at
1950    /// `η = 0`. Pinning the weighted kernel here guards the `saved_prior_weights`
1951    /// plumbing in `sample.rs` against a silent regression to the unweighted
1952    /// posterior.
1953    #[test]
1954    fn weighted_bernoulli_target_is_centered_at_the_weighted_mode() {
1955        let data = SharedData {
1956            x: Arc::new(array![[1.0], [1.0]]),
1957            y: Arc::new(array![1.0, 0.0]),
1958            weights: Arc::new(array![100.0, 1.0]),
1959            mode: Arc::new(array![0.0]),
1960            offset: None,
1961            likelihood: nuts_test_likelihood(NutsFamily::BinomialLogit, 1.0),
1962            n_samples: 2,
1963            dim: 1,
1964        };
1965        // At the weighted MLE η* = log 100 the score is (numerically) zero.
1966        let eta_star = 100.0_f64.ln();
1967        let mut score_star = Array1::zeros(2);
1968        let (_, grad_star) =
1969            exact_glm_logp_and_grad_into(&data, &array![eta_star, eta_star], &mut score_star)
1970                .expect("weighted logit geometry");
1971        assert!(
1972            grad_star[0].abs() < 1e-9,
1973            "weighted Bernoulli score must vanish at log 100, got {}",
1974            grad_star[0]
1975        );
1976        // At the *unweighted* mode η = 0 the weighted score is 100 − 101·0.5 =
1977        // 49.5 ≫ 0: the unit-weight reconstruction targets the wrong posterior.
1978        let mut score_zero = Array1::zeros(2);
1979        let (_, grad_zero) =
1980            exact_glm_logp_and_grad_into(&data, &array![0.0, 0.0], &mut score_zero)
1981                .expect("weighted logit geometry");
1982        assert!(
1983            (grad_zero[0] - 49.5).abs() < 1e-9,
1984            "unit-weight point must carry a large positive weighted score, got {}",
1985            grad_zero[0]
1986        );
1987    }
1988
1989    #[test]
1990    fn nuts_logitgradient_matches_finite_difference() {
1991        let x = array![[1.0, -0.5], [0.2, 0.7], [-1.0, 0.3], [0.5, -1.2]];
1992        let y = array![1.0, 0.0, 1.0, 0.0];
1993        let w = array![1.0, 1.5, 0.8, 1.2];
1994        let penalty = array![[0.4, 0.0], [0.0, 0.6]];
1995        let mode = array![0.1, -0.2];
1996        let hessian = array![[2.0, 0.2], [0.2, 1.7]]; // SPD
1997
1998        let posterior = NutsPosterior::new(
1999            x.view(),
2000            y.view(),
2001            w.view(),
2002            penalty.view(),
2003            mode.view(),
2004            hessian.view(),
2005            nuts_test_likelihood(NutsFamily::BinomialLogit, 1.0),
2006            gam_solve::estimate::Dispersion::UNIT,
2007            None,
2008            true,
2009        )
2010        .expect("posterior");
2011
2012        let z = array![0.15, -0.35];
2013        let (_, grad) = posterior.compute_logp_and_grad_nd(&z);
2014
2015        let eps = 1e-6;
2016        for j in 0..z.len() {
2017            let mut z_plus = z.clone();
2018            let mut z_minus = z.clone();
2019            z_plus[j] += eps;
2020            z_minus[j] -= eps;
2021            let (lp, _) = posterior.compute_logp_and_grad_nd(&z_plus);
2022            let (lm, _) = posterior.compute_logp_and_grad_nd(&z_minus);
2023            let fd = (lp - lm) / (2.0 * eps);
2024            assert_eq!(
2025                grad[j].signum(),
2026                fd.signum(),
2027                "gradient sign mismatch at {}: analytic={}, fd={}",
2028                j,
2029                grad[j],
2030                fd
2031            );
2032            assert!(
2033                (grad[j] - fd).abs() < 1e-5,
2034                "gradient mismatch at {}: analytic={}, fd={}",
2035                j,
2036                grad[j],
2037                fd
2038            );
2039        }
2040    }
2041
2042    #[test]
2043    fn gamma_log_logp_and_grad_uses_fitted_shape() {
2044        let x = array![[1.0_f64], [1.0_f64]];
2045        let y = array![1.5_f64, 2.5_f64];
2046        let weights = array![1.0_f64, 2.0_f64];
2047        let eta = array![0.2_f64, 0.4_f64];
2048        let shape = 3.5_f64;
2049        let data = SharedData {
2050            x: Arc::new(x.clone()),
2051            y: Arc::new(y.clone()),
2052            weights: Arc::new(weights.clone()),
2053            mode: Arc::new(Array1::zeros(1)),
2054            offset: None,
2055            likelihood: nuts_test_likelihood(NutsFamily::GammaLog, shape),
2056            n_samples: x.nrows(),
2057            dim: x.ncols(),
2058        };
2059
2060        let mut eta_score = Array1::zeros(eta.len());
2061        let (ll, grad) =
2062            exact_glm_logp_and_grad_into(&data, &eta, &mut eta_score).expect("Gamma geometry");
2063
2064        let mut expected_ll = 0.0;
2065        let mut expected_score = 0.0;
2066        for i in 0..eta.len() {
2067            let mu = eta[i].exp();
2068            let ratio = y[i] / mu;
2069            expected_ll -= weights[i] * shape * (ratio - 1.0 - ratio.ln());
2070            expected_score += weights[i] * shape * (y[i] / mu - 1.0);
2071        }
2072
2073        assert!((ll - expected_ll).abs() < 1e-12);
2074        assert_eq!(grad.len(), 1);
2075        assert!((grad[0] - expected_score).abs() < 1e-12);
2076    }
2077
2078    /// Gamma observed information at the mode, `Xᵀ diag(w·ν·y/μ) X`, where the
2079    /// per-point curvature `w·ν·y/μ` is exactly `−∂/∂η` of the analytic score
2080    /// slot `w·ν·(y/μ − 1)` used by `gamma_log_logp_and_grad`.
2081    fn gamma_log_observed_information(
2082        x: &Array2<f64>,
2083        mode: &Array1<f64>,
2084        y: &Array1<f64>,
2085        weights: &Array1<f64>,
2086        shape: f64,
2087    ) -> Array2<f64> {
2088        let p = x.ncols();
2089        let eta = x.dot(mode);
2090        let mut h = Array2::<f64>::zeros((p, p));
2091        for i in 0..x.nrows() {
2092            let mu = eta[i].exp();
2093            let wt = weights[i] * shape * y[i] / mu;
2094            for a in 0..p {
2095                for b in 0..p {
2096                    h[[a, b]] += wt * x[[i, a]] * x[[i, b]];
2097                }
2098            }
2099        }
2100        h
2101    }
2102
2103    /// Regression for #680: the whitened GammaLog NUTS target must reproduce
2104    /// the #679 coefficient-covariance contract `Vb = H⁻¹` (scale `1.0`), NOT
2105    /// the dispersion-double-counted `(1/ν)(XᵀΛX + S)⁻¹`.
2106    ///
2107    /// We set the stored Hessian to the *true* penalized curvature of the
2108    /// target at the mode, `H = Xᵀ diag(w·ν·y/μ) X + S` (Gamma observed
2109    /// information + the penalty added **unscaled** — exactly the #679 `H`).
2110    /// The whitened target's curvature in z at the mode is `Lᵀ Hβ L`. The fix
2111    /// makes `L Lᵀ = H⁻¹` and `Hβ = H`, so this is the identity. The pre-fix
2112    /// code scaled the penalty by `ν` and the whitening by `√φ`, turning the
2113    /// z-curvature into `φ·(I + (ν−1)·L_H⁻¹ S L_H⁻ᵀ) ≠ I` (for ν=4 the
2114    /// diagonal collapses toward ~0.25, never 1).
2115    #[test]
2116    fn gamma_log_nuts_target_curvature_matches_unscaled_hessian_issue_680() {
2117        let x = array![[1.0, -0.7], [1.0, 0.3], [1.0, 1.1], [1.0, -0.2], [1.0, 0.8],];
2118        let mode = array![0.4_f64, -0.6_f64];
2119        let y = array![1.2_f64, 0.7, 2.3, 0.9, 1.6];
2120        let weights = array![1.0_f64, 1.5, 0.8, 1.2, 1.0];
2121        // ν = 1/φ = 4 ⇒ φ = 0.25: a large, easily-detectable double-count.
2122        let shape = 4.0_f64;
2123        let p = x.ncols();
2124
2125        let h_data = gamma_log_observed_information(&x, &mode, &y, &weights, shape);
2126        // A genuine PD smoothing penalty so the ×ν double-count is detectable.
2127        let s = array![[0.5_f64, 0.1], [0.1, 0.9]];
2128        let hessian = &h_data + &s;
2129
2130        let target = NutsPosterior::new(
2131            x.view(),
2132            y.view(),
2133            weights.view(),
2134            s.view(),
2135            mode.view(),
2136            hessian.view(),
2137            nuts_test_likelihood(NutsFamily::GammaLog, shape),
2138            gam_solve::estimate::Dispersion::estimated(1.0 / shape).unwrap(),
2139            None,
2140            false,
2141        )
2142        .expect("GammaLog NUTS target builds");
2143
2144        // z-space precision at the mode (z = 0) via central differences of the
2145        // analytic gradient: `−∂(∇_z logp)/∂z = Lᵀ Hβ L`. Correct value: I.
2146        let eps = 1e-6;
2147        let z0 = Array1::<f64>::zeros(p);
2148        let mut hz = Array2::<f64>::zeros((p, p));
2149        for j in 0..p {
2150            let mut zp = z0.clone();
2151            let mut zm = z0.clone();
2152            zp[j] += eps;
2153            zm[j] -= eps;
2154            let (_, gp) = target.compute_logp_and_grad_nd(&zp);
2155            let (_, gm) = target.compute_logp_and_grad_nd(&zm);
2156            for a in 0..p {
2157                hz[[a, j]] = -(gp[a] - gm[a]) / (2.0 * eps);
2158            }
2159        }
2160
2161        for a in 0..p {
2162            for b in 0..p {
2163                let expected = if a == b { 1.0 } else { 0.0 };
2164                assert!(
2165                    (hz[[a, b]] - expected).abs() < 1e-4,
2166                    "z-curvature[{a},{b}] = {} (expected {expected}); a non-identity \
2167                     value means the GammaLog target re-introduced the #680 dispersion \
2168                     double-count (penalty ×ν and/or whitening ×√φ)",
2169                    hz[[a, b]]
2170                );
2171            }
2172        }
2173        // Trace = p (identity) rejects the φ-scaled `φ·tr(...)` signature.
2174        let trace: f64 = (0..p).map(|i| hz[[i, i]]).sum();
2175        assert!(
2176            (trace - p as f64).abs() < 1e-3,
2177            "z-curvature trace {trace} ≠ {p}: dispersion double-count signature"
2178        );
2179    }
2180
2181    /// Regression for #680 (whitening half, isolated): for a weight-carries-
2182    /// dispersion family the whitening must satisfy `L Lᵀ = H⁻¹` — i.e.
2183    /// `cov_scale = 1` — so the sampler whitens against the same `H⁻¹` it
2184    /// targets. The pre-fix Gamma path scaled `L` by `√φ`, giving
2185    /// `L Lᵀ = φ·H⁻¹` and `chol·cholᵀ·H = φ·I ≠ I`.
2186    #[test]
2187    fn gamma_log_nuts_whitening_targets_unscaled_inverse_hessian_issue_680() {
2188        let x = array![[1.0, -0.4], [1.0, 0.6], [1.0, 0.1], [1.0, 1.3]];
2189        let mode = array![0.2_f64, 0.3_f64];
2190        let y = array![0.8_f64, 1.7, 1.1, 2.2];
2191        let weights = array![1.0_f64, 1.0, 1.5, 0.7];
2192        let shape = 6.25_f64; // φ = 0.16
2193        let p = x.ncols();
2194        let s = array![[0.3_f64, 0.0], [0.0, 0.7]];
2195        let hessian = &gamma_log_observed_information(&x, &mode, &y, &weights, shape) + &s;
2196
2197        let target = NutsPosterior::new(
2198            x.view(),
2199            y.view(),
2200            weights.view(),
2201            s.view(),
2202            mode.view(),
2203            hessian.view(),
2204            nuts_test_likelihood(NutsFamily::GammaLog, shape),
2205            gam_solve::estimate::Dispersion::estimated(1.0 / shape).unwrap(),
2206            None,
2207            false,
2208        )
2209        .expect("GammaLog NUTS target builds");
2210
2211        // chol = L with L Lᵀ = H⁻¹  ⇒  (L Lᵀ) H = I.
2212        let l = target.chol();
2213        let llt = l.dot(&l.t());
2214        let prod = llt.dot(&hessian);
2215        for a in 0..p {
2216            for b in 0..p {
2217                let expected = if a == b { 1.0 } else { 0.0 };
2218                assert!(
2219                    (prod[[a, b]] - expected).abs() < 1e-8,
2220                    "L Lᵀ H[{a},{b}] = {} (expected {expected}); a φ·I result means \
2221                     the Gamma whitening still scales by √φ (#680)",
2222                    prod[[a, b]]
2223                );
2224            }
2225        }
2226    }
2227
2228    #[test]
2229    fn firth_jeffreys_logit_is_finite_for_rank_deficient_design() {
2230        let x = array![
2231            [1.0, -0.5, 1.0],
2232            [1.0, 0.3, 1.0],
2233            [1.0, 0.8, 1.0],
2234            [1.0, -1.2, 1.0],
2235        ];
2236        let y = array![1.0, 0.0, 1.0, 0.0];
2237        let weights = array![1.0, 2.0, 0.5, 1.5];
2238        let eta = array![0.2, -0.1, 0.4, -0.3];
2239
2240        let data = SharedData {
2241            x: Arc::new(x.clone()),
2242            y: Arc::new(y),
2243            weights: Arc::new(weights.clone()),
2244            mode: Arc::new(Array1::zeros(x.ncols())),
2245            offset: None,
2246            likelihood: nuts_test_likelihood(NutsFamily::BinomialLogit, 1.0),
2247            n_samples: x.nrows(),
2248            dim: x.ncols(),
2249        };
2250
2251        let (value, grad) =
2252            firth_jeffreys_logp_and_grad(&NutsFamily::BinomialLogit.likelihood_spec(), &data, &eta)
2253                .expect("firth");
2254
2255        assert!(value.is_finite());
2256        assert_eq!(grad.len(), x.ncols());
2257        assert!(grad.iter().all(|v| v.is_finite()));
2258
2259        // The Jeffreys term is link-general: at the same eta the probit
2260        // Fisher weight differs from logit (2/pi vs 1/4 at eta = 0), so the
2261        // determinants must differ — a hard-coded logit correction would
2262        // make these equal (finding 19, #2245).
2263        let (value_probit, grad_probit) = firth_jeffreys_logp_and_grad(
2264            &NutsFamily::BinomialProbit.likelihood_spec(),
2265            &data,
2266            &eta,
2267        )
2268        .expect("probit firth");
2269        assert!(value_probit.is_finite());
2270        assert!(
2271            (value_probit - value).abs() > 1e-6,
2272            "probit and logit Jeffreys log-determinants must differ: {value_probit} vs {value}"
2273        );
2274        assert!(grad_probit.iter().all(|v| v.is_finite()));
2275    }
2276
2277    #[test]
2278    fn logit_pg_gibbs_returns_finite_samples() {
2279        let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2280        let y = array![1.0, 0.0, 1.0, 0.0];
2281        let w = array![1.0, 1.0, 1.0, 1.0];
2282        let penalty = array![[0.2, 0.0], [0.0, 0.4]];
2283        let mode = array![0.0, 0.0];
2284        let cfg = NutsConfig {
2285            n_samples: 30,
2286            nwarmup: 30,
2287            n_chains: 2,
2288            target_accept: 0.8,
2289            seed: 123,
2290        };
2291        let out = run_logit_polya_gamma_gibbs(
2292            x.view(),
2293            y.view(),
2294            w.view(),
2295            penalty.view(),
2296            mode.view(),
2297            &cfg,
2298        )
2299        .expect("pg gibbs should run");
2300        assert_eq!(out.samples.ncols(), 2);
2301        assert_eq!(out.samples.nrows(), cfg.n_samples * cfg.n_chains);
2302        assert!(out.samples.iter().all(|v| v.is_finite()));
2303        assert!(out.posterior_mean.iter().all(|v| v.is_finite()));
2304        assert!(
2305            out.posterior_std
2306                .iter()
2307                .all(|value| value.is_finite() && *value > 0.0),
2308            "every sampled coefficient must have positive posterior spread"
2309        );
2310        let eta = x.dot(&out.posterior_mean);
2311        let posterior_nll: f64 = eta
2312            .iter()
2313            .zip(y.iter())
2314            .map(|(&eta_i, &y_i)| gam_linalg::utils::stable_softplus(eta_i) - y_i * eta_i)
2315            .sum();
2316        let zero_nll = x.nrows() as f64 * std::f64::consts::LN_2;
2317        assert!(
2318            posterior_nll < zero_nll,
2319            "posterior mean must discriminate the planted binary responses: {posterior_nll} !< {zero_nll}"
2320        );
2321    }
2322
2323    #[test]
2324    fn family_pg_dispatch_rejects_non_bernoulli_response() {
2325        let x = array![[1.0], [1.0]];
2326        let y = array![2.0, 0.0];
2327        let w = array![1.0, 1.0];
2328        let penalty = array![[0.1]];
2329        let mode = array![0.0];
2330        let non_spd_hessian = array![[0.0]];
2331        let cfg = NutsConfig {
2332            n_samples: 1,
2333            nwarmup: 1,
2334            n_chains: 1,
2335            target_accept: 0.8,
2336            seed: 321,
2337        };
2338
2339        let result = run_nuts_sampling_flattened_family(
2340            LikelihoodSpec::binomial_logit(),
2341            FamilyNutsInputs::Glm(GlmFlatInputs {
2342                x: x.view(),
2343                y: y.view(),
2344                weights: w.view(),
2345                penalty_matrix: penalty.view(),
2346                mode: mode.view(),
2347                hessian: non_spd_hessian.view(),
2348                likelihood_scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2349                dispersion: gam_solve::model_types::Dispersion::UNIT,
2350                firth_bias_reduction: false,
2351                offset: None,
2352            }),
2353            &cfg,
2354        );
2355
2356        let err = result.err().expect("PG dispatch should reject count rows");
2357        assert!(
2358            err.contains("response must be exactly 0 or 1"),
2359            "unexpected error: {err}"
2360        );
2361    }
2362
2363    #[test]
2364    fn family_dispatch_uses_pg_gibbs_for_standard_logit() {
2365        let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2366        let y = array![1.0, 0.0, 1.0, 0.0];
2367        let w = array![1.0, 1.0, 1.0, 1.0];
2368        let penalty = array![[0.2, 0.0], [0.0, 0.4]];
2369        let mode = array![0.0, 0.0];
2370        let non_spdhessian = array![[0.0, 0.0], [0.0, 0.0]];
2371        let cfg = NutsConfig {
2372            n_samples: 20,
2373            nwarmup: 20,
2374            n_chains: 2,
2375            target_accept: 0.8,
2376            seed: 456,
2377        };
2378        let out = run_nuts_sampling_flattened_family(
2379            LikelihoodSpec {
2380                response: ResponseFamily::Binomial,
2381                link: InverseLink::Standard(StandardLink::Logit),
2382            },
2383            FamilyNutsInputs::Glm(GlmFlatInputs {
2384                x: x.view(),
2385                y: y.view(),
2386                weights: w.view(),
2387                penalty_matrix: penalty.view(),
2388                mode: mode.view(),
2389                hessian: non_spdhessian.view(),
2390                likelihood_scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2391                dispersion: gam_solve::estimate::Dispersion::UNIT,
2392                firth_bias_reduction: false,
2393                offset: None,
2394            }),
2395            &cfg,
2396        )
2397        .expect("dispatch should use PG Gibbs and not require Hessian factorization");
2398        assert_eq!(out.samples.nrows(), cfg.n_samples * cfg.n_chains);
2399        assert!(out.samples.iter().all(|v| v.is_finite()));
2400    }
2401
2402    #[test]
2403    fn family_dispatch_routes_probit_to_nuts_path() {
2404        let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2405        let y = array![1.0, 0.0, 1.0, 0.0];
2406        let w = array![1.0, 1.0, 1.0, 1.0];
2407        let penalty = array![[0.2, 0.0], [0.0, 0.4]];
2408        let mode = array![0.0, 0.0];
2409        let non_spdhessian = array![[0.0, 0.0], [0.0, 0.0]];
2410        let cfg = NutsConfig {
2411            n_samples: 20,
2412            nwarmup: 20,
2413            n_chains: 2,
2414            target_accept: 0.8,
2415            seed: 654,
2416        };
2417
2418        let err = match run_nuts_sampling_flattened_family(
2419            LikelihoodSpec {
2420                response: ResponseFamily::Binomial,
2421                link: InverseLink::Standard(StandardLink::Probit),
2422            },
2423            FamilyNutsInputs::Glm(GlmFlatInputs {
2424                x: x.view(),
2425                y: y.view(),
2426                weights: w.view(),
2427                penalty_matrix: penalty.view(),
2428                mode: mode.view(),
2429                hessian: non_spdhessian.view(),
2430                likelihood_scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2431                dispersion: gam_solve::estimate::Dispersion::UNIT,
2432                firth_bias_reduction: false,
2433                offset: None,
2434            }),
2435            &cfg,
2436        ) {
2437            Ok(_) => panic!("non-SPD Hessian should fail after probit routes to the NUTS path"),
2438            Err(err) => err,
2439        };
2440
2441        assert!(
2442            err.contains("Hessian Cholesky decomposition failed"),
2443            "unexpected error: {err}"
2444        );
2445    }
2446
2447    #[test]
2448    fn family_dispatch_rejects_nonbinomial_firth_family() {
2449        let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2450        let y = array![1.0, 2.0, 0.0, 3.0];
2451        let w = array![1.0, 1.0, 1.0, 1.0];
2452        let penalty = array![[0.2, 0.0], [0.0, 0.4]];
2453        let mode = array![0.0, 0.0];
2454        let hessian = array![[1.5, 0.1], [0.1, 1.2]];
2455        let cfg = NutsConfig {
2456            n_samples: 20,
2457            nwarmup: 20,
2458            n_chains: 2,
2459            target_accept: 0.8,
2460            seed: 111,
2461        };
2462
2463        let err = match run_nuts_sampling_flattened_family(
2464            LikelihoodSpec {
2465                response: ResponseFamily::Poisson,
2466                link: InverseLink::Standard(StandardLink::Log),
2467            },
2468            FamilyNutsInputs::Glm(GlmFlatInputs {
2469                x: x.view(),
2470                y: y.view(),
2471                weights: w.view(),
2472                penalty_matrix: penalty.view(),
2473                mode: mode.view(),
2474                hessian: hessian.view(),
2475                likelihood_scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2476                dispersion: gam_solve::estimate::Dispersion::UNIT,
2477                firth_bias_reduction: true,
2478                offset: None,
2479            }),
2480            &cfg,
2481        ) {
2482            Ok(_) => panic!("Poisson Firth should be rejected explicitly"),
2483            Err(err) => err,
2484        };
2485
2486        assert!(
2487            err.contains(
2488                "NUTS with Firth requires a Binomial inverse link with a Fisher-weight jet"
2489            ),
2490            "unexpected error: {err}"
2491        );
2492    }
2493
2494    #[test]
2495    fn run_nuts_sampling_rejects_invalid_target_accept() {
2496        let x = array![[1.0], [1.0], [1.0]];
2497        let y = array![0.5, -0.5, 1.0];
2498        let weights = array![1.0, 1.0, 1.0];
2499        let penalty = array![[0.25]];
2500        let mode = array![0.0];
2501        let hessian = array![[1.25]];
2502        let cfg = NutsConfig {
2503            n_samples: 10,
2504            nwarmup: 10,
2505            n_chains: 1,
2506            target_accept: 1.0,
2507            seed: 222,
2508        };
2509
2510        let err = super::run_nuts_sampling(
2511            x.view(),
2512            y.view(),
2513            weights.view(),
2514            penalty.view(),
2515            mode.view(),
2516            hessian.view(),
2517            nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
2518            gam_solve::estimate::Dispersion::UNIT,
2519            false,
2520            None,
2521            &cfg,
2522        )
2523        .expect_err("invalid target_accept should be rejected before sampling");
2524
2525        assert!(
2526            err.contains("target_accept must be finite and lie in (0, 1)"),
2527            "unexpected error: {err}"
2528        );
2529    }
2530
2531    #[test]
2532    fn run_nuts_sampling_rejects_zero_or_too_few_samples() {
2533        // Issue #399: `samples=0` (and `samples` in {1, 2, 3}) reached the
2534        // engine and panicked across the FFI boundary in `general-mcmc`'s
2535        // `.expect(...)` (empty stack / "split R-hat and ESS require at least 2
2536        // split chains and 2 draws per split chain"). The up-front guard must
2537        // reject anything below the split-R-hat-defined minimum of 4 draws with
2538        // a clean typed error *before* the sampler is constructed.
2539        let x = array![[1.0], [1.0], [1.0]];
2540        let y = array![0.5, -0.5, 1.0];
2541        let weights = array![1.0, 1.0, 1.0];
2542        let penalty = array![[0.25]];
2543        let mode = array![0.0];
2544        let hessian = array![[1.25]];
2545
2546        for bad_samples in [0usize, 1, 2, 3] {
2547            let cfg = NutsConfig {
2548                n_samples: bad_samples,
2549                nwarmup: 10,
2550                n_chains: 2,
2551                target_accept: 0.8,
2552                seed: 222,
2553            };
2554
2555            let err = super::run_nuts_sampling(
2556                x.view(),
2557                y.view(),
2558                weights.view(),
2559                penalty.view(),
2560                mode.view(),
2561                hessian.view(),
2562                nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
2563                gam_solve::estimate::Dispersion::UNIT,
2564                false,
2565                None,
2566                &cfg,
2567            )
2568            .expect_err("too-few samples must be rejected before sampling");
2569
2570            assert!(
2571                err.contains("n_samples must be >= 4"),
2572                "n_samples={bad_samples} gave unexpected error: {err}"
2573            );
2574        }
2575    }
2576
2577    #[test]
2578    fn polya_gamma_gibbs_rejects_degenerate_counts_but_accepts_single_chain() {
2579        // Issue #399 (missed path): the canonical unit-weight Bernoulli-logit
2580        // GAM auto-selects the hand-rolled Pólya-Gamma Gibbs sampler, NOT the
2581        // general-mcmc NUTS engine. Pre-fix that path never validated
2582        // n_samples/n_chains, so `chains=0` / `samples=0` silently returned a
2583        // degenerate empty `(0, p)` posterior instead of the typed error the
2584        // NUTS path raised — a divergent contract on one public API. Assert PG
2585        // now rejects the degenerate counts up front, and (mirroring NUTS)
2586        // still accepts a single chain.
2587        let x = array![[1.0], [1.0], [1.0], [1.0]];
2588        let y = array![1.0, 0.0, 1.0, 0.0];
2589        let weights = array![1.0, 1.0, 1.0, 1.0];
2590        let penalty = array![[0.25]];
2591        let mode = array![0.0];
2592
2593        let zero_chain_cfg = NutsConfig {
2594            n_samples: 20,
2595            nwarmup: 10,
2596            n_chains: 0,
2597            target_accept: 0.8,
2598            seed: 7,
2599        };
2600        let err = super::run_logit_polya_gamma_gibbs(
2601            x.view(),
2602            y.view(),
2603            weights.view(),
2604            penalty.view(),
2605            mode.view(),
2606            &zero_chain_cfg,
2607        )
2608        .expect_err("PG Gibbs must reject zero chains up front, not return an empty posterior");
2609        assert!(
2610            err.contains("n_chains must be >= 1"),
2611            "PG n_chains=0 gave unexpected error: {err}"
2612        );
2613
2614        let zero_sample_cfg = NutsConfig {
2615            n_samples: 0,
2616            nwarmup: 10,
2617            n_chains: 2,
2618            target_accept: 0.8,
2619            seed: 7,
2620        };
2621        let err = super::run_logit_polya_gamma_gibbs(
2622            x.view(),
2623            y.view(),
2624            weights.view(),
2625            penalty.view(),
2626            mode.view(),
2627            &zero_sample_cfg,
2628        )
2629        .expect_err("PG Gibbs must reject zero samples up front, not return an empty posterior");
2630        assert!(
2631            err.contains("n_samples must be >= 4"),
2632            "PG n_samples=0 gave unexpected error: {err}"
2633        );
2634
2635        let single_chain_cfg = NutsConfig {
2636            n_samples: 20,
2637            nwarmup: 10,
2638            n_chains: 1,
2639            target_accept: 0.8,
2640            seed: 7,
2641        };
2642        let result = super::run_logit_polya_gamma_gibbs(
2643            x.view(),
2644            y.view(),
2645            weights.view(),
2646            penalty.view(),
2647            mode.view(),
2648            &single_chain_cfg,
2649        )
2650        .expect("PG Gibbs must accept a single chain and return draws");
2651        assert_eq!(
2652            result.samples.nrows(),
2653            20,
2654            "single-chain PG run should return all 20 requested draws"
2655        );
2656    }
2657
2658    #[test]
2659    fn run_nuts_sampling_rejects_zero_chains_but_accepts_single_chain() {
2660        // Issue #399: only `chains=0` is degenerate — it produces an empty
2661        // initial-position vector and panics in `ndarray::stack`, so it must be
2662        // rejected up front with a typed error.
2663        //
2664        // A *single* chain, by contrast, is a supported, tested configuration
2665        // (`tests/test_sample_seed_is_reproducible.py`,
2666        // `tests/test_posterior_save_no_extension_roundtrip.py`,
2667        // `tests/test_penalty_sampling_survival_diagnostics_regressions.py` all
2668        // sample with `chains=1`): the engine splits each chain in half, so one
2669        // chain still yields the two split-chains the R-hat path needs, and
2670        // `compute_split_rhat_and_ess` early-returns gracefully for
2671        // `n_chains < 2`. The original #399 fix wrongly raised the floor to 2
2672        // and regressed those tests; this asserts `chains=1` *returns draws*.
2673        let x = array![[1.0], [1.0], [1.0]];
2674        let y = array![0.5, -0.5, 1.0];
2675        let weights = array![1.0, 1.0, 1.0];
2676        let penalty = array![[0.25]];
2677        let mode = array![0.0];
2678        let hessian = array![[1.25]];
2679
2680        let zero_chain_cfg = NutsConfig {
2681            n_samples: 50,
2682            nwarmup: 10,
2683            n_chains: 0,
2684            target_accept: 0.8,
2685            seed: 222,
2686        };
2687        let err = super::run_nuts_sampling(
2688            x.view(),
2689            y.view(),
2690            weights.view(),
2691            penalty.view(),
2692            mode.view(),
2693            hessian.view(),
2694            nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
2695            gam_solve::estimate::Dispersion::UNIT,
2696            false,
2697            None,
2698            &zero_chain_cfg,
2699        )
2700        .expect_err("zero chains must be rejected before sampling");
2701        assert!(
2702            err.contains("n_chains must be >= 1"),
2703            "n_chains=0 gave unexpected error: {err}"
2704        );
2705
2706        let single_chain_cfg = NutsConfig {
2707            n_samples: 50,
2708            nwarmup: 10,
2709            n_chains: 1,
2710            target_accept: 0.8,
2711            seed: 222,
2712        };
2713        let result = super::run_nuts_sampling(
2714            x.view(),
2715            y.view(),
2716            weights.view(),
2717            penalty.view(),
2718            mode.view(),
2719            hessian.view(),
2720            nuts_test_likelihood(NutsFamily::Gaussian, 1.0),
2721            gam_solve::estimate::Dispersion::UNIT,
2722            false,
2723            None,
2724            &single_chain_cfg,
2725        )
2726        .expect("a single chain is a supported configuration and must return draws");
2727        assert_eq!(
2728            result.samples.nrows(),
2729            50,
2730            "single-chain run should return all 50 requested draws"
2731        );
2732    }
2733
2734    #[test]
2735    fn joint_hmc_boundary_rejects_nonbinomial_firth_family() {
2736        let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
2737        let y = array![1.0, 2.0, 0.0, 3.0];
2738        let w = array![1.0, 1.0, 1.0, 1.0];
2739        let hessian = array![[1.5, 0.1], [0.1, 1.2]];
2740        let penalty_root = array![[0.4, 0.0], [0.0, 0.6]];
2741        let mode = array![0.0, 0.0];
2742        let rho_mode = array![0.0];
2743        let cfg = NutsConfig {
2744            n_samples: 20,
2745            nwarmup: 20,
2746            n_chains: 2,
2747            target_accept: 0.8,
2748            seed: 111,
2749        };
2750
2751        let inputs = JointBetaRhoInputs {
2752            x: x.view(),
2753            y: y.view(),
2754            weights: w.view(),
2755            likelihood: GlmLikelihoodSpec {
2756                spec: LikelihoodSpec {
2757                    response: ResponseFamily::Poisson,
2758                    link: InverseLink::Standard(StandardLink::Log),
2759                },
2760                scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2761            },
2762            dispersion: gam_solve::model_types::Dispersion::UNIT,
2763            offset: None,
2764            mode: mode.view(),
2765            hessian: hessian.view(),
2766            penalty_roots: vec![CanonicalPenalty::from_dense_root(
2767                penalty_root.clone(),
2768                penalty_root.ncols(),
2769            )],
2770            rho_mode: rho_mode.view(),
2771            rho_prior: RhoPrior::default(),
2772            firth_bias_reduction: true,
2773            trigger_skewness: 0.75,
2774        };
2775
2776        let err = match run_joint_beta_rho_sampling(&inputs, &cfg) {
2777            Ok(_) => panic!("Poisson joint HMC Firth should be rejected explicitly"),
2778            Err(err) => err,
2779        };
2780
2781        assert!(
2782            err.contains(
2783                "Joint HMC with Firth requires a Binomial inverse link with a Fisher-weight jet"
2784            ),
2785            "unexpected error: {err}"
2786        );
2787    }
2788
2789    #[test]
2790    fn joint_hmc_uses_combined_penalty_logdet_for_overlapping_penalties() {
2791        let x = array![[0.0, 0.0]];
2792        let y = array![0.0];
2793        let w = array![0.0];
2794        let mode = array![0.0, 0.0];
2795        let hessian = array![[1.0, 0.0], [0.0, 1.0]];
2796        let rho_mode = array![0.0, 0.0];
2797        let penalty_1 = array![[1.0, 0.0], [0.0, 1.0]];
2798        let penalty_2 = array![[2.0_f64.sqrt(), 0.0], [0.0, 1.0]];
2799        let target = JointBetaRhoPosterior::new(
2800            x.view(),
2801            y.view(),
2802            w.view(),
2803            mode.view(),
2804            hessian.view(),
2805            vec![
2806                CanonicalPenalty::from_dense_root(penalty_1, 2),
2807                CanonicalPenalty::from_dense_root(penalty_2, 2),
2808            ],
2809            rho_mode.view(),
2810            GlmLikelihoodSpec {
2811                spec: LikelihoodSpec::gaussian_identity(),
2812                scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2813            },
2814            gam_solve::model_types::Dispersion::UNIT,
2815            None,
2816            RhoPrior::Flat,
2817            false,
2818        )
2819        .expect("joint target");
2820
2821        let params = array![0.0, 0.0, 0.0, 0.0];
2822        let (_, grad) = target.compute_joint_logp_and_grad(&params);
2823        assert!(
2824            (grad[2] - 5.0 / 12.0).abs() < 1.0e-10,
2825            "expected overlapping-penalty gradient 5/12, got {}",
2826            grad[2]
2827        );
2828        assert!(
2829            (grad[3] - 7.0 / 12.0).abs() < 1.0e-10,
2830            "expected overlapping-penalty gradient 7/12, got {}",
2831            grad[3]
2832        );
2833    }
2834
2835    #[test]
2836    fn joint_hmc_target_does_not_depend_on_rho_mode_when_prior_is_fixed() {
2837        let x = array![[0.0]];
2838        let y = array![0.0];
2839        let w = array![0.0];
2840        let mode = array![0.0];
2841        let hessian = array![[1.0]];
2842        let penalty = CanonicalPenalty::from_dense_root(array![[1.0]], 1);
2843        let prior = RhoPrior::Normal {
2844            mean: 0.25,
2845            sd: 1.7,
2846        };
2847
2848        let target_a = JointBetaRhoPosterior::new(
2849            x.view(),
2850            y.view(),
2851            w.view(),
2852            mode.view(),
2853            hessian.view(),
2854            vec![penalty.clone()],
2855            array![0.0].view(),
2856            GlmLikelihoodSpec {
2857                spec: LikelihoodSpec::gaussian_identity(),
2858                scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2859            },
2860            gam_solve::model_types::Dispersion::UNIT,
2861            None,
2862            prior.clone(),
2863            false,
2864        )
2865        .expect("target a");
2866        let target_b = JointBetaRhoPosterior::new(
2867            x.view(),
2868            y.view(),
2869            w.view(),
2870            mode.view(),
2871            hessian.view(),
2872            vec![penalty],
2873            array![2.5].view(),
2874            GlmLikelihoodSpec {
2875                spec: LikelihoodSpec::gaussian_identity(),
2876                scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
2877            },
2878            gam_solve::model_types::Dispersion::UNIT,
2879            None,
2880            prior,
2881            false,
2882        )
2883        .expect("target b");
2884
2885        let params = array![0.0, -0.4];
2886        let (lp_a, grad_a) = target_a.compute_joint_logp_and_grad(&params);
2887        let (lp_b, grad_b) = target_b.compute_joint_logp_and_grad(&params);
2888        assert!((lp_a - lp_b).abs() < 1.0e-12);
2889        for i in 0..grad_a.len() {
2890            assert!(
2891                (grad_a[i] - grad_b[i]).abs() < 1.0e-12,
2892                "rho_mode leaked into target gradient at {}: {} vs {}",
2893                i,
2894                grad_a[i],
2895                grad_b[i]
2896            );
2897        }
2898    }
2899
2900    #[test]
2901    fn joint_hmc_target_retains_fit_dispersion_and_offset() {
2902        // A Gaussian fit with sigma^2 = 4 and a fixed offset of 1.5: the joint
2903        // target at beta = 0 must be -0.5 * (1/phi) * (y - offset)^2. The old
2904        // target hard-coded phi = 1 and eta = X beta, i.e. -0.5 * y^2 — four
2905        // times the likelihood curvature at a shifted center (finding 18,
2906        // #2245). No penalties / flat prior isolate the likelihood term.
2907        let x = array![[1.0]];
2908        let y = array![2.0];
2909        let w = array![1.0];
2910        let mode = array![0.0];
2911        let hessian = array![[1.0]];
2912        let offset = array![1.5];
2913        let target = JointBetaRhoPosterior::new(
2914            x.view(),
2915            y.view(),
2916            w.view(),
2917            mode.view(),
2918            hessian.view(),
2919            Vec::new(),
2920            Array1::<f64>::zeros(0).view(),
2921            GlmLikelihoodSpec::canonical(LikelihoodSpec {
2922                response: ResponseFamily::Gaussian,
2923                link: InverseLink::Standard(StandardLink::Identity),
2924            }),
2925            gam_solve::model_types::Dispersion::estimated(4.0).unwrap(),
2926            Some(offset.view()),
2927            RhoPrior::Flat,
2928            false,
2929        )
2930        .expect("joint target");
2931
2932        let params = array![0.0];
2933        let (logp, _) = target.compute_joint_logp_and_grad(&params);
2934        let expected = -0.5 * (1.0 / 4.0) * (2.0_f64 - 1.5).powi(2);
2935        assert!(
2936            (logp - expected).abs() < 1e-12,
2937            "joint target must keep phi and offset: logp = {logp}, expected {expected}"
2938        );
2939    }
2940
2941    #[test]
2942    fn joint_hmc_binomial_sas_uses_runtime_link_state() {
2943        let x = array![[1.0], [1.0]];
2944        let y = array![1.0, 0.0];
2945        let weights = array![1.0, 1.0];
2946        let eta = array![0.3, -0.2];
2947        let sas_state =
2948            gam_solve::mixture_link::state_from_sasspec(gam_problem::types::SasLinkSpec {
2949                initial_epsilon: 0.4,
2950                initial_log_delta: -0.2,
2951            })
2952            .expect("sas state");
2953        let data = SharedData {
2954            x: Arc::new(x),
2955            y: Arc::new(y),
2956            weights: Arc::new(weights),
2957            mode: Arc::new(Array1::zeros(1)),
2958            offset: None,
2959            likelihood: nuts_test_likelihood(NutsFamily::BinomialLogit, 1.0),
2960            n_samples: 2,
2961            dim: 1,
2962        };
2963
2964        let (ll_sas, _) = joint_family_logp_and_grad(
2965            &LikelihoodSpec {
2966                response: ResponseFamily::Binomial,
2967                link: InverseLink::Sas(sas_state),
2968            },
2969            &data,
2970            &eta,
2971        )
2972        .expect("sas joint logp");
2973        let (ll_logit, _) = joint_family_logp_and_grad(
2974            &LikelihoodSpec {
2975                response: ResponseFamily::Binomial,
2976                link: InverseLink::Standard(StandardLink::Logit),
2977            },
2978            &data,
2979            &eta,
2980        )
2981        .expect("logit joint logp");
2982
2983        assert!(
2984            (ll_sas - ll_logit).abs() > 1.0e-6,
2985            "adaptive SAS link should not collapse to the logit likelihood"
2986        );
2987    }
2988
2989    #[test]
2990    fn directional_cubic_diagnostic_is_rotation_invariant_for_hessian_eigenvectors() {
2991        let x = array![[1.0, 0.5], [-0.3, 1.4], [0.8, -1.1]];
2992        let c = array![0.7, -0.5, 0.2];
2993        let h = array![[4.0, 0.0], [0.0, 1.0]];
2994        let theta = std::f64::consts::FRAC_PI_4;
2995        let q = array![[theta.cos(), -theta.sin()], [theta.sin(), theta.cos()],];
2996        let x_rot = x.dot(&q);
2997        let h_rot = q.t().dot(&h).dot(&q);
2998
2999        let (base_max, base_vals) = laplace_directional_cubic_diagnostic(
3000            &h,
3001            &DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(x)),
3002            &c,
3003            true,
3004        )
3005        .expect("base diagnostic");
3006        let (rot_max, rot_vals) = laplace_directional_cubic_diagnostic(
3007            &h_rot,
3008            &DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(x_rot)),
3009            &c,
3010            true,
3011        )
3012        .expect("rotated diagnostic");
3013
3014        let mut base_abs: Vec<f64> = base_vals.iter().map(|v| v.abs()).collect();
3015        let mut rot_abs: Vec<f64> = rot_vals.iter().map(|v| v.abs()).collect();
3016        base_abs.sort_by(|a, b| a.partial_cmp(b).expect("finite compare"));
3017        rot_abs.sort_by(|a, b| a.partial_cmp(b).expect("finite compare"));
3018
3019        assert!((base_max - rot_max).abs() < 1.0e-10);
3020        for i in 0..base_abs.len() {
3021            assert!(
3022                (base_abs[i] - rot_abs[i]).abs() < 1.0e-10,
3023                "directional diagnostic changed under rotation at {}: {} vs {}",
3024                i,
3025                base_abs[i],
3026                rot_abs[i]
3027            );
3028        }
3029    }
3030
3031    /// The batched contraction must return, direction for direction, what the
3032    /// single-direction contraction returns.
3033    ///
3034    /// Batching is a pure performance change, so the only thing that can go
3035    /// wrong is arithmetic: a transposed direction matrix, a row panel that
3036    /// drops or double-counts observations, or a sparse scatter that misroutes
3037    /// a nonzero. Each of those produces a WRONG cubic rather than a slow one,
3038    /// and the caller only compares it against a threshold — so a silent error
3039    /// here shows up as a correction that engages when it should not, or vice
3040    /// versa. Both storage arms are checked against the same reference, on a
3041    /// design tall enough to cross the row-panel boundary.
3042    #[test]
3043    fn batched_directional_cubics_match_the_single_direction_contraction() {
3044        use super::{directional_cubic_contraction, directional_cubic_contractions};
3045        use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix};
3046
3047        let n = 37;
3048        let p = 5;
3049        // Deterministic, well-conditioned, and NOT symmetric in any way that
3050        // would let a transposition slip through unnoticed.
3051        let x = Array2::from_shape_fn((n, p), |(i, j)| {
3052            ((i as f64) * 0.37 + (j as f64) * 1.13).sin() * (1.0 + j as f64 * 0.25)
3053        });
3054        let c = Array1::from_shape_fn(n, |i| 0.6 - 0.05 * (i as f64) + ((i % 3) as f64) * 0.4);
3055        let directions = Array2::from_shape_fn((p, 4), |(j, r)| {
3056            ((j as f64) * 0.91 - (r as f64) * 0.44).cos()
3057        });
3058
3059        // A sparse twin of the same matrix: identical entries, different
3060        // storage, so both arms must land on the same numbers.
3061        use faer::sparse::{SparseColMat, Triplet};
3062        let mut triplets = Vec::new();
3063        for i in 0..n {
3064            for j in 0..p {
3065                if x[[i, j]] != 0.0 {
3066                    triplets.push(Triplet::new(i, j, x[[i, j]]));
3067                }
3068            }
3069        }
3070        let dense = DesignMatrix::Dense(DenseDesignMatrix::from(x.clone()));
3071        let sparse = DesignMatrix::Sparse(gam_linalg::matrix::SparseDesignMatrix::new(
3072            SparseColMat::try_new_from_triplets(n, p, &triplets).expect("sparse twin"),
3073        ));
3074
3075        for (label, design) in [("dense", &dense), ("sparse", &sparse)] {
3076            let batched = directional_cubic_contractions(design, &c, &directions.view());
3077            for r in 0..directions.ncols() {
3078                let reference =
3079                    directional_cubic_contraction(design, &c, &directions.column(r).view());
3080                assert!(
3081                    (batched[r] - reference).abs() <= 1.0e-9 * reference.abs().max(1.0),
3082                    "{label} arm disagreed on direction {r}: batched {} vs reference {}",
3083                    batched[r],
3084                    reference
3085                );
3086            }
3087        }
3088    }
3089
3090    /// Verify that joint HMC and REML compute identical penalty logdet
3091    /// derivatives for the same penalty system. This catches any divergence
3092    /// between the two code paths.
3093    #[test]
3094    fn joint_hmc_penalty_logdet_agrees_with_reml_path() {
3095        use gam_solve::estimate::reml::penalty_logdet::PenaltyPseudologdet;
3096
3097        // Two overlapping 3x3 penalties with non-trivial lambdas.
3098        let root_1 = array![[1.0, 0.5, 0.0], [0.0, 0.8, 0.3]];
3099        let root_2 = array![[0.0, 0.7, 0.0], [0.0, 0.0, 1.2]];
3100        let cp1 = CanonicalPenalty::from_dense_root(root_1, 3);
3101        let cp2 = CanonicalPenalty::from_dense_root(root_2, 3);
3102        let lambdas = [2.5_f64, 0.8];
3103        let penalties = [cp1.clone(), cp2.clone()];
3104
3105        // REML path: PenaltyPseudologdet directly.
3106        let pld =
3107            PenaltyPseudologdet::from_penalties(&penalties, &lambdas, 0.0, 3).expect("reml pld");
3108        let reml_value = pld.value();
3109        let (reml_d1, reml_d2) = pld.rho_derivatives_from_penalties(&penalties, &lambdas);
3110
3111        // Joint HMC path: build a JointBetaRhoPosterior and extract the
3112        // penalty logdet contribution. We isolate it by using zero data
3113        // (so likelihood = 0, penalty quadratic = 0) and Flat rho prior.
3114        let x = Array2::<f64>::zeros((1, 3));
3115        let y = array![0.0];
3116        let w = array![0.0];
3117        let mode = Array1::<f64>::zeros(3);
3118        let hessian = Array2::<f64>::eye(3);
3119        let rho = Array1::from_vec(lambdas.iter().map(|l| l.ln()).collect());
3120        let target = JointBetaRhoPosterior::new(
3121            x.view(),
3122            y.view(),
3123            w.view(),
3124            mode.view(),
3125            hessian.view(),
3126            vec![cp1, cp2],
3127            rho.view(),
3128            GlmLikelihoodSpec {
3129                spec: LikelihoodSpec::gaussian_identity(),
3130                scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
3131            },
3132            gam_solve::model_types::Dispersion::UNIT,
3133            None,
3134            RhoPrior::Flat,
3135            false,
3136        )
3137        .expect("joint target");
3138
3139        // Evaluate at beta=0, rho=ln(lambdas).
3140        let mut params = Array1::<f64>::zeros(3 + 2);
3141        params[3] = rho[0];
3142        params[4] = rho[1];
3143        let (logp, grad) = target.compute_joint_logp_and_grad(&params);
3144
3145        // logp should be 0.5 * reml_value (likelihood=0, prior=0, quadratic=0).
3146        assert!(
3147            (logp - 0.5 * reml_value).abs() < 1.0e-8,
3148            "joint HMC logdet value {} vs REML 0.5*{} = {}",
3149            logp,
3150            reml_value,
3151            0.5 * reml_value,
3152        );
3153
3154        // grad[3..5] should be 0.5 * reml_d1.
3155        for k in 0..2 {
3156            assert!(
3157                (grad[3 + k] - 0.5 * reml_d1[k]).abs() < 1.0e-8,
3158                "joint HMC logdet gradient[{}] = {} vs REML 0.5*{} = {}",
3159                k,
3160                grad[3 + k],
3161                reml_d1[k],
3162                0.5 * reml_d1[k],
3163            );
3164        }
3165
3166        // Sanity: second derivatives are available from REML but not directly
3167        // from a single HMC gradient call; just verify they're symmetric.
3168        assert!(
3169            (reml_d2[[0, 1]] - reml_d2[[1, 0]]).abs() < 1.0e-12,
3170            "REML penalty logdet Hessian not symmetric"
3171        );
3172    }
3173
3174    /// Verify the family-gating invariant: every LikelihoodSpec that
3175    /// joint_family_logp_and_grad accepts produces a result (not an error
3176    /// about missing implementation). Every family it rejects returns an
3177    /// explicit error. No family is silently remapped to a different one.
3178    #[test]
3179    fn joint_hmc_family_gating_never_remaps() {
3180        let eta = array![0.1, -0.1];
3181        let data_for = |spec: &LikelihoodSpec| SharedData {
3182            x: Arc::new(array![[1.0], [1.0]]),
3183            y: Arc::new(match &spec.response {
3184                ResponseFamily::Binomial => array![1.0, 0.0],
3185                ResponseFamily::Gamma | ResponseFamily::Beta { .. } => array![1.0, 0.5],
3186                _ => array![1.0, 0.0],
3187            }),
3188            weights: Arc::new(array![1.0, 1.0]),
3189            mode: Arc::new(Array1::zeros(1)),
3190            offset: None,
3191            // `joint_family_logp_and_grad` is the already-resolved row
3192            // oracle, not the fit-to-posterior scale resolver. A fitted
3193            // profiled Gaussian is concretized to its fitted phi before it
3194            // reaches this function; use that same target representation here
3195            // instead of asking the row oracle to invent a dispersion.
3196            likelihood: if matches!(&spec.response, ResponseFamily::Gaussian) {
3197                GlmLikelihoodSpec {
3198                    spec: spec.clone(),
3199                    scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
3200                }
3201            } else {
3202                GlmLikelihoodSpec::canonical(spec.clone())
3203            },
3204            n_samples: 2,
3205            dim: 1,
3206        };
3207
3208        // These families must succeed with their own inverse link.
3209        let accepted = [
3210            LikelihoodSpec {
3211                response: ResponseFamily::Binomial,
3212                link: InverseLink::Standard(StandardLink::Logit),
3213            },
3214            LikelihoodSpec {
3215                response: ResponseFamily::Binomial,
3216                link: InverseLink::Standard(StandardLink::Probit),
3217            },
3218            LikelihoodSpec {
3219                response: ResponseFamily::Binomial,
3220                link: InverseLink::Standard(StandardLink::CLogLog),
3221            },
3222            LikelihoodSpec {
3223                response: ResponseFamily::Gaussian,
3224                link: InverseLink::Standard(StandardLink::Identity),
3225            },
3226            LikelihoodSpec {
3227                response: ResponseFamily::Poisson,
3228                link: InverseLink::Standard(StandardLink::Log),
3229            },
3230            LikelihoodSpec {
3231                response: ResponseFamily::Gamma,
3232                link: InverseLink::Standard(StandardLink::Log),
3233            },
3234        ];
3235        for spec in &accepted {
3236            let data = data_for(spec);
3237            let result = joint_family_logp_and_grad(spec, &data, &eta);
3238            assert!(
3239                result.is_ok(),
3240                "spec {:?} should be accepted but got error: {:?}",
3241                spec,
3242                result.err(),
3243            );
3244        }
3245
3246        // SAS/BetaLogistic/Mixture must succeed with their real link state,
3247        // NOT be remapped to logit.
3248        let sas_state =
3249            gam_solve::mixture_link::state_from_sasspec(gam_problem::types::SasLinkSpec {
3250                initial_epsilon: 0.0,
3251                initial_log_delta: 0.0,
3252            })
3253            .expect("sas state");
3254        let adaptive = [
3255            LikelihoodSpec {
3256                response: ResponseFamily::Binomial,
3257                link: InverseLink::Sas(sas_state),
3258            },
3259            LikelihoodSpec {
3260                response: ResponseFamily::Binomial,
3261                link: InverseLink::BetaLogistic(
3262                    gam_solve::mixture_link::state_from_sasspec(gam_problem::types::SasLinkSpec {
3263                        initial_epsilon: 0.0,
3264                        initial_log_delta: 0.0,
3265                    })
3266                    .expect("bl state"),
3267                ),
3268            },
3269        ];
3270        for spec in &adaptive {
3271            let data = data_for(spec);
3272            let result = joint_family_logp_and_grad(spec, &data, &eta);
3273            assert!(
3274                result.is_ok(),
3275                "adaptive spec {:?} should be accepted with its real link",
3276                spec,
3277            );
3278        }
3279
3280        // RoystonParmar must be explicitly rejected (not silently remapped).
3281        let rp = LikelihoodSpec {
3282            response: ResponseFamily::RoystonParmar,
3283            link: InverseLink::Standard(StandardLink::Logit),
3284        };
3285        let rp_data = data_for(&rp);
3286        let rp_result = joint_family_logp_and_grad(&rp, &rp_data, &eta);
3287        assert!(
3288            rp_result.is_err(),
3289            "RoystonParmar should be rejected, not silently accepted"
3290        );
3291    }
3292
3293    /// The power-iteration refinement should find non-Gaussianity at least
3294    /// as large as the eigenvector-only pass (it's a supremum search).
3295    #[test]
3296    fn directional_cubic_power_iteration_finds_larger_or_equal_skewness() {
3297        // Construct a design where the maximum |gamma| occurs off-axis.
3298        // A single row with asymmetric structure makes the cubic form
3299        // peak between eigenvectors.
3300        let x = array![
3301            [2.0, 1.0],
3302            [-1.0, 2.0],
3303            [0.5, -0.5],
3304            [1.5, 0.3],
3305            [-0.8, 1.7],
3306        ];
3307        let c = array![1.0, -0.5, 0.3, -0.7, 0.4];
3308        let h = array![[3.0, 1.0], [1.0, 2.0]];
3309
3310        let (max_val, eigenvector_vals) = laplace_directional_cubic_diagnostic(
3311            &h,
3312            &DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(x)),
3313            &c,
3314            true,
3315        )
3316        .expect("diagnostic");
3317
3318        // max_val should be >= max of eigenvector-only values.
3319        let eig_max = eigenvector_vals
3320            .iter()
3321            .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3322        assert!(
3323            max_val >= eig_max - 1.0e-12,
3324            "power iteration result {} should be >= eigenvector max {}",
3325            max_val,
3326            eig_max,
3327        );
3328    }
3329
3330    #[test]
3331    fn laplace_trustworthiness_is_block_local_and_threshold_shrinks_with_n() {
3332        // Two directions: one nearly Gaussian (tiny skewness), one strongly
3333        // skewed. The adaptive verdict must flag ONLY the skewed direction —
3334        // this is the block-local behavior #784 requires (keep cheap Laplace
3335        // where the Gaussian summary holds, correct only the curvature-heavy
3336        // block).
3337        let skew = array![0.01, 0.9];
3338
3339        // At a modest effective sample size the skewed direction dominates the
3340        // Laplace floor and must be flagged; the near-Gaussian one must not.
3341        let verdict = laplace_trustworthiness_from_skewness(&skew, 100.0);
3342        assert_eq!(
3343            verdict.untrustworthy_directions,
3344            vec![1],
3345            "only the strongly-skewed direction should be flagged (block-local)",
3346        );
3347        assert!(verdict.fallback_required());
3348        assert!((verdict.max_abs_skewness - 0.9).abs() < 1e-12);
3349
3350        // The threshold must SHRINK as n grows (Laplace gets stricter): a
3351        // direction tolerated at small n becomes untrustworthy at large n,
3352        // because the Gaussian floor it must beat is O(1/n).
3353        let t_small = laplace_skewness_threshold(25.0);
3354        let t_large = laplace_skewness_threshold(10_000.0);
3355        assert!(
3356            t_large < t_small,
3357            "validity threshold must tighten with sample size: {t_large} !< {t_small}",
3358        );
3359
3360        // Degenerate / empty curvature support => everything trustworthy
3361        // (nothing for the Gaussian summary to be wrong about).
3362        let none = laplace_trustworthiness_from_skewness(&skew, 0.0);
3363        assert!(!none.fallback_required());
3364        assert!(none.threshold.is_infinite());
3365    }
3366
3367    /// Synthetic block-excess oracle: an anharmonicity `ΔF(t) = a·Σ_k t_k⁴`
3368    /// whose per-direction strength carries unit ρ-sensitivity, so
3369    /// `∂ΔF/∂ρ_k = a·t_k⁴`. `a = 0` is a pure Gaussian block (exactly zero
3370    /// excess and zero ρ-gradient — the consistency anchor); `a > 0` is the
3371    /// quartic correction oracle the importance sampler is checked against.
3372    struct AnharmonicBlock {
3373        lambdas: Array1<f64>,
3374        a: f64,
3375    }
3376    impl super::BlockExcessTarget for AnharmonicBlock {
3377        fn block_dim(&self) -> usize {
3378            self.lambdas.len()
3379        }
3380        fn rho_dim(&self) -> usize {
3381            self.lambdas.len()
3382        }
3383        fn block_curvatures(&self) -> &Array1<f64> {
3384            &self.lambdas
3385        }
3386        fn excess(&self, t: &Array1<f64>) -> f64 {
3387            self.a * t.iter().map(|&x| x.powi(4)).sum::<f64>()
3388        }
3389        fn excess_rho_gradient(&self, t: &Array1<f64>) -> Array1<f64> {
3390            t.mapv(|x| self.a * x.powi(4))
3391        }
3392        fn displaced_neg_score(&self, t: &Array1<f64>) -> Result<Array1<f64>, String> {
3393            // The synthetic oracle has no observation rows: its ΔF carries no
3394            // deviance channel, so the per-row score moment is empty and the
3395            // (b)–(d) channel assembly contracts against nothing.
3396            assert_eq!(t.len(), self.block_dim(), "displacement dim mismatch");
3397            Ok(Array1::zeros(0))
3398        }
3399        fn base_neg_score(&self) -> Result<Array1<f64>, String> {
3400            Ok(Array1::zeros(0))
3401        }
3402    }
3403
3404    #[test]
3405    fn block_quadrature_marginal_is_zero_for_gaussian_block() {
3406        // A purely Gaussian block has ΔF ≡ 0, so the quadrature correction (the
3407        // log-ratio of true to Laplace block free energy) must be exactly 0,
3408        // with a zero ρ-gradient. This is the consistency anchor: where the
3409        // Gaussian summary holds, the fallback is a no-op.
3410        let target = AnharmonicBlock {
3411            lambdas: array![2.0, 0.5],
3412            a: 0.0,
3413        };
3414        let out = super::block_quadrature_marginal_correction(&target).expect("correction");
3415        assert!(
3416            out.value.abs() < 1e-12,
3417            "Gaussian block value {}",
3418            out.value
3419        );
3420        assert!(out.rho_gradient.iter().all(|&g| g.abs() < 1e-12));
3421        assert!(out.node_count > 0);
3422        assert_eq!(out.quadrature_error, 0.0);
3423    }
3424
3425    #[test]
3426    fn block_quadrature_marginal_recovers_analytic_quartic_correction() {
3427        // 1-D block with a quartic excess ΔF(t) = a t⁴ (a small positive
3428        // anharmonicity). Then exp(Δ_b) = E_{t~N(0,1/λ)}[exp(−a t⁴)], a known
3429        // 1-D integral the deterministic rule must recover. We check Δ_b
3430        // matches a high-accuracy deterministic quadrature of the same
3431        // expectation, and that Δ_b < 0 (an added quartic penalty makes the
3432        // true block mass *smaller* than the Gaussian's).
3433        let lambda = 3.0_f64;
3434        let a = 0.05_f64;
3435        let target = AnharmonicBlock {
3436            lambdas: array![lambda],
3437            a,
3438        };
3439        let out = super::block_quadrature_marginal_correction(&target).expect("correction");
3440
3441        // Deterministic reference: Δ_b = log E_{t~N(0,1/λ)}[exp(−a t⁴)] via a
3442        // fine trapezoid rule over the Gaussian density.
3443        let sigma = (1.0 / lambda).sqrt();
3444        let steps = 20_001;
3445        let lo = -8.0 * sigma;
3446        let hi = 8.0 * sigma;
3447        let h = (hi - lo) / (steps as f64 - 1.0);
3448        let mut integral = 0.0_f64;
3449        for i in 0..steps {
3450            let tt = lo + h * i as f64;
3451            let gauss = (-(tt * tt) / (2.0 * sigma * sigma)).exp()
3452                / (sigma * (2.0 * std::f64::consts::PI).sqrt());
3453            let w = if i == 0 || i == steps - 1 { 0.5 } else { 1.0 };
3454            integral += w * gauss * (-a * tt.powi(4)).exp() * h;
3455        }
3456        let reference = integral.ln();
3457        assert!(
3458            (out.value - reference).abs() < 5e-3,
3459            "quadrature Δ_b {} vs reference {}",
3460            out.value,
3461            reference,
3462        );
3463        assert!(out.value < 0.0, "quartic penalty must shrink block mass");
3464    }
3465
3466    /// A block target whose excess and per-row score are driven by real design
3467    /// matvecs `s = X·(V_b·t)` — the SAME structure as the production
3468    /// `Gam784BlockTarget` — so it can compute those matvecs either serially
3469    /// (one `fast_av` per draw) or batched (one GEMM over all draws), toggled by
3470    /// `batched`. The two must yield a bit-for-bit (to FP-reassociation
3471    /// tolerance) identical correction: that is exactly the #1082 batching
3472    /// contract — GEMM changes HOW the matvec is computed, never WHAT.
3473    struct MatvecBlock {
3474        lambdas: Array1<f64>,
3475        x: Array2<f64>,
3476        v_b: Array2<f64>,
3477        y: Array1<f64>,
3478        batched: bool,
3479    }
3480    impl MatvecBlock {
3481        fn s_of(&self, t: &Array1<f64>) -> Array1<f64> {
3482            let delta = self.v_b.dot(t);
3483            gam_linalg::faer_ndarray::fast_av(&self.x, &delta)
3484        }
3485        // A smooth, finite, family-like excess + per-row score built from `s`.
3486        fn excess_and_ngs(&self, s: &Array1<f64>) -> (f64, Array1<f64>) {
3487            let mut excess = 0.0;
3488            let mut ngs = Array1::<f64>::zeros(s.len());
3489            for i in 0..s.len() {
3490                let mu = (self.y[i] + s[i]).tanh();
3491                excess += 0.5 * s[i] * s[i] - 0.1 * mu;
3492                ngs[i] = mu - self.y[i];
3493            }
3494            (excess, ngs)
3495        }
3496    }
3497    impl super::BlockExcessTarget for MatvecBlock {
3498        fn block_dim(&self) -> usize {
3499            self.lambdas.len()
3500        }
3501        fn rho_dim(&self) -> usize {
3502            self.lambdas.len()
3503        }
3504        fn block_curvatures(&self) -> &Array1<f64> {
3505            &self.lambdas
3506        }
3507        fn excess(&self, t: &Array1<f64>) -> f64 {
3508            self.excess_and_ngs(&self.s_of(t)).0
3509        }
3510        fn excess_rho_gradient(&self, t: &Array1<f64>) -> Array1<f64> {
3511            t.mapv(|x| 0.01 * x)
3512        }
3513        fn displaced_neg_score(&self, t: &Array1<f64>) -> Result<Array1<f64>, String> {
3514            Ok(self.excess_and_ngs(&self.s_of(t)).1)
3515        }
3516        fn base_neg_score(&self) -> Result<Array1<f64>, String> {
3517            Ok(self
3518                .excess_and_ngs(&self.s_of(&Array1::zeros(self.block_dim())))
3519                .1)
3520        }
3521        fn excess_with_displaced_neg_score_batch(
3522            &self,
3523            draws: &Array2<f64>,
3524        ) -> Vec<(f64, Option<Array1<f64>>)> {
3525            if !self.batched {
3526                // Serial reference: per-column, exactly the default path.
3527                let mut out = Vec::with_capacity(draws.ncols());
3528                let mut t = Array1::<f64>::zeros(draws.nrows());
3529                for s in 0..draws.ncols() {
3530                    t.assign(&draws.column(s));
3531                    out.push(self.excess_with_displaced_neg_score(&t));
3532                }
3533                return out;
3534            }
3535            // Batched: Δ = V_b·T then S = X·Δ as two GEMMs, then per-column.
3536            let delta_all = gam_linalg::faer_ndarray::fast_ab(&self.v_b, draws);
3537            let s_all = gam_linalg::faer_ndarray::fast_ab(&self.x, &delta_all);
3538            (0..draws.ncols())
3539                .map(|c| {
3540                    let (e, ngs) = self.excess_and_ngs(&s_all.column(c).to_owned());
3541                    if e.is_finite() {
3542                        (e, Some(ngs))
3543                    } else {
3544                        (e, None)
3545                    }
3546                })
3547                .collect()
3548        }
3549    }
3550
3551    #[test]
3552    fn block_quadrature_marginal_batched_matches_serial_matvec() {
3553        // Real design / block-frame matvecs, large enough that the GEMM path is
3554        // actually taken (n, p ≥ faer threshold). The batched override must give
3555        // the same correction value, ρ-gradient, and moments as the serial path.
3556        let n = 80usize;
3557        let p = 40usize;
3558        let m = 3usize;
3559        let mut x = Array2::<f64>::zeros((n, p));
3560        for i in 0..n {
3561            for j in 0..p {
3562                x[(i, j)] = ((i * 7 + j * 13) % 11) as f64 * 0.05 - 0.25;
3563            }
3564        }
3565        let mut v_b = Array2::<f64>::zeros((p, m));
3566        for i in 0..p {
3567            for r in 0..m {
3568                v_b[(i, r)] = ((i * 3 + r * 5) % 7) as f64 * 0.1 - 0.3;
3569            }
3570        }
3571        let y: Array1<f64> = (0..n).map(|i| ((i % 5) as f64) * 0.2).collect();
3572        let lambdas = array![2.0, 1.0, 0.5];
3573
3574        let serial = super::block_quadrature_marginal_correction(&MatvecBlock {
3575            lambdas: lambdas.clone(),
3576            x: x.clone(),
3577            v_b: v_b.clone(),
3578            y: y.clone(),
3579            batched: false,
3580        })
3581        .expect("serial");
3582        let batched = super::block_quadrature_marginal_correction(&MatvecBlock {
3583            lambdas,
3584            x,
3585            v_b,
3586            y,
3587            batched: true,
3588        })
3589        .expect("batched");
3590
3591        assert_eq!(serial.node_count, batched.node_count);
3592        assert!(
3593            (serial.value - batched.value).abs() <= 1e-10 * (1.0 + serial.value.abs()),
3594            "value serial {} vs batched {}",
3595            serial.value,
3596            batched.value
3597        );
3598        for k in 0..serial.rho_gradient.len() {
3599            assert!(
3600                (serial.rho_gradient[k] - batched.rho_gradient[k]).abs()
3601                    <= 1e-10 * (1.0 + serial.rho_gradient[k].abs()),
3602                "rho_gradient[{k}] serial {} vs batched {}",
3603                serial.rho_gradient[k],
3604                batched.rho_gradient[k]
3605            );
3606        }
3607        let ms = serial.moments.expect("serial moments");
3608        let mb = batched.moments.expect("batched moments");
3609        for (a, b) in ms.e_t.iter().zip(mb.e_t.iter()) {
3610            assert!((a - b).abs() <= 1e-10 * (1.0 + a.abs()), "e_t {a} vs {b}");
3611        }
3612        for (a, b) in ms.e_neg_score.iter().zip(mb.e_neg_score.iter()) {
3613            assert!(
3614                (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
3615                "e_neg_score {a} vs {b}"
3616            );
3617        }
3618        for (a, b) in ms.e_t_neg_score.iter().zip(mb.e_t_neg_score.iter()) {
3619            assert!(
3620                (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
3621                "e_t_neg_score {a} vs {b}"
3622            );
3623        }
3624    }
3625
3626    #[test]
3627    fn logit_pg_rao_blackwell_rejects_non_bernoulli_response() {
3628        let x = array![[1.0], [1.0]];
3629        let y = array![0.25, 1.0];
3630        let w = array![1.0, 1.0];
3631        let penalty = array![[0.1]];
3632        let mode = array![0.0];
3633        let roots = vec![array![[0.1_f64.sqrt()]]];
3634        let cfg = NutsConfig {
3635            n_samples: 1,
3636            nwarmup: 1,
3637            n_chains: 1,
3638            target_accept: 0.8,
3639            seed: 654,
3640        };
3641
3642        let result = super::estimate_logit_pg_rao_blackwell_terms(
3643            x.view(),
3644            y.view(),
3645            w.view(),
3646            penalty.view(),
3647            mode.view(),
3648            &roots,
3649            &cfg,
3650        );
3651
3652        let err = result
3653            .err()
3654            .expect("PG Rao-Blackwell should reject proportion rows");
3655        assert!(
3656            err.contains("response must be exactly 0 or 1"),
3657            "unexpected error: {err}"
3658        );
3659    }
3660
3661    #[test]
3662    fn logit_pg_rao_blackwell_matches_beta_quadratic_moment_sanity() {
3663        let x = array![[1.0, 0.2], [1.0, -0.1], [1.0, 1.2], [1.0, -0.7]];
3664        let y = array![1.0, 0.0, 1.0, 0.0];
3665        let w = array![1.0, 1.0, 1.0, 1.0];
3666        let penalty = array![[0.2, 0.0], [0.0, 0.4]];
3667        let mode = array![0.0, 0.0];
3668        let roots = vec![array![[0.2_f64.sqrt(), 0.0], [0.0, 0.4_f64.sqrt()]]];
3669        let cfg = NutsConfig {
3670            n_samples: 120,
3671            nwarmup: 80,
3672            n_chains: 2,
3673            target_accept: 0.8,
3674            seed: 901,
3675        };
3676
3677        let gibbs = run_logit_polya_gamma_gibbs(
3678            x.view(),
3679            y.view(),
3680            w.view(),
3681            penalty.view(),
3682            mode.view(),
3683            &cfg,
3684        )
3685        .expect("pg gibbs should run");
3686        let mc_quad = gibbs
3687            .samples
3688            .rows()
3689            .into_iter()
3690            .map(|beta| {
3691                let sb = penalty.dot(&beta.to_owned());
3692                beta.dot(&sb)
3693            })
3694            .sum::<f64>()
3695            / (gibbs.samples.nrows() as f64);
3696
3697        let rb = super::estimate_logit_pg_rao_blackwell_terms(
3698            x.view(),
3699            y.view(),
3700            w.view(),
3701            penalty.view(),
3702            mode.view(),
3703            &roots,
3704            &cfg,
3705        )
3706        .expect("rao-blackwell PG should run");
3707
3708        let diff = (rb[0] - mc_quad).abs();
3709        assert!(
3710            diff < 0.35,
3711            "Rao-Blackwell vs beta-moment mismatch too large: rb={}, mc={}, diff={}",
3712            rb[0],
3713            mc_quad,
3714            diff
3715        );
3716    }
3717
3718    #[test]
3719    fn survival_hmc_structural_monotonic_returns_finitevalues() {
3720        let age_entry = array![1.0];
3721        let age_exit = array![2.0];
3722        let event_target = array![1u8];
3723        let event_competing = array![0u8];
3724        let sampleweight = array![1.0];
3725        let x_entry = array![[1.0, 0.2]];
3726        let x_exit = array![[1.0, 0.6]];
3727        let x_derivative = array![[0.0, 1.0]];
3728        let penalties = PenaltyBlocks::new(Vec::new());
3729        let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3730        let mode = array![0.0, 0.0];
3731        let hessian = Array2::<f64>::eye(2);
3732
3733        let posterior = super::survival_hmc::SurvivalPosterior::new(
3734            age_entry.view(),
3735            age_exit.view(),
3736            event_target.view(),
3737            event_competing.view(),
3738            sampleweight.view(),
3739            x_entry.view(),
3740            x_exit.view(),
3741            x_derivative.view(),
3742            None,
3743            None,
3744            None,
3745            penalties,
3746            monotonicity,
3747            SurvivalSpec::Net,
3748            true,
3749            2,
3750            mode.view(),
3751            hessian.view(),
3752        )
3753        .expect("construct survival posterior");
3754
3755        let position = array![0.0, 0.0];
3756        let mut grad = Array1::<f64>::zeros(2);
3757        let logp = HamiltonianTarget::logp_and_grad(&posterior, &position, &mut grad);
3758        assert!(logp.is_finite());
3759        assert!(grad.iter().all(|v| v.is_finite()));
3760    }
3761
3762    #[test]
3763    fn survival_hmc_structural_monotonic_differs_from_linear_geometry() {
3764        let age_entry = array![1.0];
3765        let age_exit = array![2.0];
3766        let event_target = array![1u8];
3767        let event_competing = array![0u8];
3768        let sampleweight = array![1.0];
3769        let x_entry = array![[0.2, 0.1]];
3770        let x_exit = array![[0.6, 0.3]];
3771        let x_derivative = array![[1.0, 0.0]];
3772        let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3773        let mode = array![0.0, 0.0];
3774        let hessian = Array2::<f64>::eye(2);
3775        let z = array![std::f64::consts::LN_2, 0.0];
3776
3777        let posterior_linear = super::survival_hmc::SurvivalPosterior::new(
3778            age_entry.view(),
3779            age_exit.view(),
3780            event_target.view(),
3781            event_competing.view(),
3782            sampleweight.view(),
3783            x_entry.view(),
3784            x_exit.view(),
3785            x_derivative.view(),
3786            None,
3787            None,
3788            None,
3789            PenaltyBlocks::new(Vec::new()),
3790            monotonicity,
3791            SurvivalSpec::Net,
3792            false,
3793            0,
3794            mode.view(),
3795            hessian.view(),
3796        )
3797        .expect("construct linear posterior");
3798        let mut grad_linear = Array1::<f64>::zeros(2);
3799        HamiltonianTarget::logp_and_grad(&posterior_linear, &z, &mut grad_linear);
3800
3801        let posterior_struct = super::survival_hmc::SurvivalPosterior::new(
3802            age_entry.view(),
3803            age_exit.view(),
3804            event_target.view(),
3805            event_competing.view(),
3806            sampleweight.view(),
3807            x_entry.view(),
3808            x_exit.view(),
3809            x_derivative.view(),
3810            None,
3811            None,
3812            None,
3813            PenaltyBlocks::new(Vec::new()),
3814            monotonicity,
3815            SurvivalSpec::Net,
3816            true,
3817            2,
3818            mode.view(),
3819            hessian.view(),
3820        )
3821        .expect("construct structural posterior");
3822        let mut grad_struct = Array1::<f64>::zeros(2);
3823        HamiltonianTarget::logp_and_grad(&posterior_struct, &z, &mut grad_struct);
3824
3825        assert!(
3826            (grad_struct[0] - grad_linear[0]).abs() > 1e-6,
3827            "expected structural and linear fallback gradients to differ"
3828        );
3829        assert!(grad_struct[0].is_finite());
3830        assert!(grad_linear[0].is_finite());
3831    }
3832
3833    #[test]
3834    fn survival_hmc_fallback_barrier_rejects_offsets_below_monotonicity_threshold() {
3835        let age_entry = array![1.0];
3836        let age_exit = array![2.0];
3837        let event_target = array![1u8];
3838        let event_competing = array![0u8];
3839        let sampleweight = array![1.0];
3840        let x_entry = array![[1.0, 0.0]];
3841        let x_exit = array![[1.0, 0.0]];
3842        // Zero derivative design so derivative_offset_exit drives d_eta/dt.
3843        let x_derivative = array![[0.0, 0.0]];
3844        let penalties = PenaltyBlocks::new(Vec::new());
3845        let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3846        let mode = array![0.0, 0.0];
3847        let hessian = Array2::<f64>::eye(2);
3848        let z = array![0.0, 0.0];
3849
3850        let posterior_no_offset = super::survival_hmc::SurvivalPosterior::new(
3851            age_entry.view(),
3852            age_exit.view(),
3853            event_target.view(),
3854            event_competing.view(),
3855            sampleweight.view(),
3856            x_entry.view(),
3857            x_exit.view(),
3858            x_derivative.view(),
3859            None,
3860            None,
3861            Some(array![0.0].view()),
3862            penalties.clone(),
3863            monotonicity,
3864            SurvivalSpec::Net,
3865            false,
3866            0,
3867            mode.view(),
3868            hessian.view(),
3869        )
3870        .expect("construct posterior without derivative offset");
3871        let mut grad_no_offset = Array1::<f64>::zeros(2);
3872        let logp_no_offset =
3873            HamiltonianTarget::logp_and_grad(&posterior_no_offset, &z, &mut grad_no_offset);
3874
3875        let posteriorwith_offset = super::survival_hmc::SurvivalPosterior::new(
3876            age_entry.view(),
3877            age_exit.view(),
3878            event_target.view(),
3879            event_competing.view(),
3880            sampleweight.view(),
3881            x_entry.view(),
3882            x_exit.view(),
3883            x_derivative.view(),
3884            None,
3885            None,
3886            Some(array![2.0].view()),
3887            penalties,
3888            monotonicity,
3889            SurvivalSpec::Net,
3890            false,
3891            0,
3892            mode.view(),
3893            hessian.view(),
3894        )
3895        .expect("construct posterior with derivative offset");
3896        let mut gradwith_offset = Array1::<f64>::zeros(2);
3897        let logpwith_offset =
3898            HamiltonianTarget::logp_and_grad(&posteriorwith_offset, &z, &mut gradwith_offset);
3899
3900        assert!(!logp_no_offset.is_finite());
3901        assert!(!logpwith_offset.is_finite());
3902        assert!(grad_no_offset.iter().all(|v| *v == 0.0));
3903        assert!(gradwith_offset.iter().all(|v| *v == 0.0));
3904    }
3905
3906    #[test]
3907    fn survival_hmc_fallback_barrier_becomes_finite_once_offset_clears_guard() {
3908        let age_entry = array![1.0];
3909        let age_exit = array![2.0];
3910        let event_target = array![1u8];
3911        let event_competing = array![0u8];
3912        let sampleweight = array![1.0];
3913        let x_entry = array![[1.0, 0.0]];
3914        let x_exit = array![[1.0, 0.0]];
3915        let x_derivative = array![[0.0, 0.0]];
3916        let penalties = PenaltyBlocks::new(Vec::new());
3917        let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3918        let mode = array![0.0, 0.0];
3919        let hessian = Array2::<f64>::eye(2);
3920        let z = array![0.0, 0.0];
3921
3922        let posterior_below_guard = super::survival_hmc::SurvivalPosterior::new(
3923            age_entry.view(),
3924            age_exit.view(),
3925            event_target.view(),
3926            event_competing.view(),
3927            sampleweight.view(),
3928            x_entry.view(),
3929            x_exit.view(),
3930            x_derivative.view(),
3931            None,
3932            None,
3933            Some(array![2.0].view()),
3934            penalties.clone(),
3935            monotonicity,
3936            SurvivalSpec::Net,
3937            false,
3938            0,
3939            mode.view(),
3940            hessian.view(),
3941        )
3942        .expect("construct posterior below derivative guard");
3943        let mut grad_below_guard = Array1::<f64>::zeros(2);
3944        let logp_below_guard =
3945            HamiltonianTarget::logp_and_grad(&posterior_below_guard, &z, &mut grad_below_guard);
3946
3947        let posterior_above_guard = super::survival_hmc::SurvivalPosterior::new(
3948            age_entry.view(),
3949            age_exit.view(),
3950            event_target.view(),
3951            event_competing.view(),
3952            sampleweight.view(),
3953            x_entry.view(),
3954            x_exit.view(),
3955            x_derivative.view(),
3956            None,
3957            None,
3958            Some(array![3.1].view()),
3959            penalties,
3960            monotonicity,
3961            SurvivalSpec::Net,
3962            false,
3963            0,
3964            mode.view(),
3965            hessian.view(),
3966        )
3967        .expect("construct posterior above derivative guard");
3968        let mut grad_above_guard = Array1::<f64>::zeros(2);
3969        let logp_above_guard =
3970            HamiltonianTarget::logp_and_grad(&posterior_above_guard, &z, &mut grad_above_guard);
3971
3972        assert!(!logp_below_guard.is_finite());
3973        assert!(logp_above_guard.is_finite());
3974        assert!(grad_below_guard.iter().all(|v| *v == 0.0));
3975        assert!(grad_above_guard.iter().all(|v| v.is_finite()));
3976    }
3977
3978    #[test]
3979    fn survival_hmc_structural_monotonic_handles_sparse_multirow_geometry() {
3980        let age_entry = array![1.0, 1.2];
3981        let age_exit = array![2.0, 2.4];
3982        let event_target = array![1u8, 1u8];
3983        let event_competing = array![0u8, 0u8];
3984        let sampleweight = array![1.0, 1.0];
3985        let x_entry = array![[0.1, 0.0, 0.2], [0.2, 0.1, 0.2]];
3986        let x_exit = array![[0.4, 0.2, 0.3], [0.6, 0.1, 0.3]];
3987        // First row constrains only column 0, second row constrains columns 0 and 1.
3988        let x_derivative = array![[1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
3989        let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
3990        let mode = array![4.0, 2.0, 0.0];
3991        let hessian = Array2::<f64>::eye(3);
3992        let z = array![0.05, -0.1, 0.15];
3993
3994        let posterior = super::survival_hmc::SurvivalPosterior::new(
3995            age_entry.view(),
3996            age_exit.view(),
3997            event_target.view(),
3998            event_competing.view(),
3999            sampleweight.view(),
4000            x_entry.view(),
4001            x_exit.view(),
4002            x_derivative.view(),
4003            None,
4004            None,
4005            None,
4006            PenaltyBlocks::new(Vec::new()),
4007            monotonicity,
4008            SurvivalSpec::Net,
4009            true,
4010            2,
4011            mode.view(),
4012            hessian.view(),
4013        )
4014        .expect("construct structural posterior");
4015
4016        let mut grad = Array1::<f64>::zeros(3);
4017        let logp = HamiltonianTarget::logp_and_grad(&posterior, &z, &mut grad);
4018        assert!(logp.is_finite());
4019        assert!(grad.iter().all(|v| v.is_finite()));
4020        let h = 1e-6;
4021        for axis in 0..z.len() {
4022            let mut plus = z.clone();
4023            let mut minus = z.clone();
4024            plus[axis] += h;
4025            minus[axis] -= h;
4026            let mut plus_grad = Array1::<f64>::zeros(3);
4027            let mut minus_grad = Array1::<f64>::zeros(3);
4028            let plus_logp = HamiltonianTarget::logp_and_grad(&posterior, &plus, &mut plus_grad);
4029            let minus_logp = HamiltonianTarget::logp_and_grad(&posterior, &minus, &mut minus_grad);
4030            let finite_difference = (plus_logp - minus_logp) / (2.0 * h);
4031            assert!(
4032                (grad[axis] - finite_difference).abs() <= 2e-5 * finite_difference.abs().max(1.0),
4033                "structural sparse HMC gradient[{axis}]: analytic={}, finite_difference={finite_difference}",
4034                grad[axis]
4035            );
4036        }
4037    }
4038}
4039
4040/// Implement HamiltonianTarget for NUTS with analytical gradients.
4041impl HamiltonianTarget<Array1<f64>> for NutsPosterior {
4042    fn logp_and_grad(&self, position: &Array1<f64>, grad: &mut Array1<f64>) -> f64 {
4043        NUTS_RESIDUAL_SCRATCH.with(|scratch| {
4044            let mut residual = scratch.borrow_mut();
4045            if residual.len() != self.data.n_samples {
4046                *residual = Array1::<f64>::zeros(self.data.n_samples);
4047            }
4048            self.compute_logp_and_grad_nd_into(position, &mut residual, grad)
4049        })
4050    }
4051}
4052
4053/// Configuration for NUTS sampling.
4054#[derive(Clone, Debug, Serialize, Deserialize)]
4055pub struct NutsConfig {
4056    /// Number of samples to collect (after warmup)
4057    pub n_samples: usize,
4058    /// Number of warmup samples to discard
4059    pub nwarmup: usize,
4060    /// Number of parallel chains
4061    pub n_chains: usize,
4062    /// Target acceptance probability (0.6-0.9 recommended)
4063    pub target_accept: f64,
4064    /// Seed for deterministic chain initialization
4065    #[serde(default = "default_nuts_seed")]
4066    pub seed: u64,
4067}
4068
4069fn default_nuts_seed() -> u64 {
4070    42
4071}
4072
4073fn validate_nuts_target_accept(target_accept: f64) -> Result<(), HmcError> {
4074    if target_accept.is_finite() && target_accept > 0.0 && target_accept < 1.0 {
4075        Ok(())
4076    } else {
4077        Err(HmcError::InvalidConfig {
4078            reason: format!(
4079                "NUTS target_accept must be finite and lie in (0, 1), got {target_accept}"
4080            ),
4081        })
4082    }
4083}
4084
4085/// Minimum number of post-warmup draws per chain that keeps the split-R-hat /
4086/// ESS machinery well-defined. Each chain is split in half for the
4087/// Gelman-Rubin diagnostic (`compute_split_rhat_and_ess` and the engine's own
4088/// run-stats path), so both halves need at least two draws, i.e. four draws
4089/// total. Below this the engine `.expect(...)` calls (empty-stack / "split
4090/// R-hat and ESS require at least 2 split chains and 2 draws per split chain")
4091/// panic across the FFI boundary instead of returning a typed error.
4092const MIN_NUTS_SAMPLES: usize = 4;
4093
4094/// Minimum number of parallel chains. With zero chains the engine receives an
4095/// empty initial-position vector and panics in `ndarray::stack` (and the
4096/// Laplace fallback would produce an empty `(0, p)` posterior). A *single*
4097/// chain is well-defined and is a supported, tested configuration: the engine
4098/// splits each chain in half for the diagnostic, so one chain still yields the
4099/// two split-chains the R-hat path needs, and `compute_split_rhat_and_ess`
4100/// gracefully early-returns for `n_chains < 2`. We therefore only reject the
4101/// genuinely-degenerate `n_chains == 0`.
4102const MIN_NUTS_CHAINS: usize = 1;
4103
4104/// Validate the draw / chain counts of a NUTS configuration up front, mirroring
4105/// `validate_nuts_target_accept`, so that out-of-range values surface as a typed
4106/// `HmcError::InvalidConfig` *before* the sampling engine is constructed rather
4107/// than as a panic caught at the FFI boundary.
4108fn validate_nuts_draws(config: &NutsConfig) -> Result<(), HmcError> {
4109    if config.n_chains < MIN_NUTS_CHAINS {
4110        return Err(HmcError::InvalidConfig {
4111            reason: format!(
4112                "NUTS n_chains must be >= {MIN_NUTS_CHAINS}; with zero chains the \
4113                 sampler has no initial positions to run, got {}",
4114                config.n_chains
4115            ),
4116        });
4117    }
4118    if config.n_samples < MIN_NUTS_SAMPLES {
4119        return Err(HmcError::InvalidConfig {
4120            reason: format!(
4121                "NUTS n_samples must be >= {MIN_NUTS_SAMPLES} so split-R-hat / ESS \
4122                 diagnostics are defined, got {}",
4123                config.n_samples
4124            ),
4125        });
4126    }
4127    Ok(())
4128}
4129
4130/// Full up-front validation of a NUTS configuration shared by every sampling
4131/// entry point (dense NUTS, link-wiggle, joint (β, ρ), survival, the
4132/// auto-selected Pólya-Gamma Gibbs path, and the Laplace-Gaussian fallback).
4133pub(crate) fn validate_nuts_config(config: &NutsConfig) -> Result<(), HmcError> {
4134    validate_nuts_target_accept(config.target_accept)?;
4135    validate_nuts_draws(config)?;
4136    Ok(())
4137}
4138
4139#[inline]
4140fn splitmix64(x: u64) -> u64 {
4141    gam_linalg::utils::splitmix64_hash(x)
4142}
4143
4144#[inline]
4145fn chain_stream_seed(seed: u64, chain: usize, stream: u64) -> u64 {
4146    splitmix64(seed ^ stream ^ ((chain as u64).wrapping_mul(0xD1B5_4A32_D192_ED03)))
4147}
4148
4149#[inline]
4150fn nuts_transition_seed(seed: u64, stream: u64) -> u64 {
4151    splitmix64(seed ^ stream ^ 0xA24B_AED4_963E_E407)
4152}
4153
4154#[inline]
4155fn gibbs_pg_seed(seed: u64, chain: usize, stream: u64, iter: usize) -> u64 {
4156    chain_stream_seed(
4157        seed,
4158        chain,
4159        stream ^ ((iter as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)),
4160    )
4161}
4162
4163fn draw_logit_pg1_omega(
4164    shapes: ArrayView1<'_, u32>,
4165    tilts: ArrayView1<'_, f64>,
4166    seed: u64,
4167    out: &mut Array1<f64>,
4168) -> Result<(), String> {
4169    if out.len() != tilts.len() {
4170        return Err(HmcError::DimensionMismatch {
4171            reason: "draw_logit_pg1_omega: output length mismatch".to_string(),
4172        }
4173        .into());
4174    }
4175    let draws = crate::gpu_polya_gamma::draw_batch(PolyaGammaBatchInput {
4176        shapes,
4177        tilts,
4178        seed: PgSeed(seed),
4179    })?;
4180    out.assign(&draws);
4181    out.mapv_inplace(|v| v.max(1.0e-12));
4182    Ok(())
4183}
4184
4185/// Parameter dimension above which the posterior is treated as "high-dimensional"
4186/// for the purpose of the more conservative sampler heuristics below: a higher
4187/// target-acceptance floor (smaller leapfrog steps) and stronger mass-matrix
4188/// regularization. The boundary matches the `dense_max_dim` cap at which the
4189/// engine stops attempting dense mass-matrix adaptation.
4190const HIGH_DIM_THRESHOLD: usize = 50;
4191
4192/// Target-acceptance floor enforced for high-dimensional posteriors
4193/// (`dim > HIGH_DIM_THRESHOLD`). NUTS efficiency degrades faster with too-large
4194/// steps in high dimensions, so we refuse to honor a requested accept below this.
4195const HIGH_DIM_TARGET_ACCEPT_FLOOR: f64 = 0.92;
4196/// Target-acceptance floor for low-dimensional posteriors.
4197const LOW_DIM_TARGET_ACCEPT_FLOOR: f64 = 0.90;
4198/// Upper bound on the effective target acceptance. Pushing target accept toward
4199/// 1 collapses the step size and stalls mixing, so we cap the requested value.
4200const MAX_TARGET_ACCEPT: f64 = 0.95;
4201
4202/// Minimum warmup length below which mass-matrix adaptation is disabled: the
4203/// windowed (Stan-style) adaptation schedule needs enough warmup iterations to
4204/// populate its initial / terminal buffers, otherwise the estimated metric is
4205/// noise. With fewer warmup steps the sampler runs on the identity metric.
4206const MIN_WARMUP_FOR_MASS_ADAPT: usize = 80;
4207
4208/// Largest parameter dimension for which the engine attempts *dense* mass-matrix
4209/// adaptation; above this it falls back to a diagonal metric (an `O(p²)` dense
4210/// metric is neither affordable nor reliably estimable from limited warmup).
4211const DENSE_MASS_MATRIX_MAX_DIM: usize = 75;
4212
4213/// Mass-matrix ridge (added to the diagonal of the estimated metric) for the
4214/// general (mean-family) sampler. The high-dimensional value is larger because
4215/// the warmup metric estimate is noisier relative to its scale as `p` grows.
4216const MASS_REGULARIZE_HIGH_DIM: f64 = 0.14;
4217const MASS_REGULARIZE_LOW_DIM: f64 = 0.10;
4218/// Mass-matrix ridge for survival posteriors, which are frequently skewed by
4219/// censoring / rare events and so warrant a heavier ridge than the mean family.
4220const SURVIVAL_MASS_REGULARIZE_HIGH_DIM: f64 = 0.18;
4221const SURVIVAL_MASS_REGULARIZE_LOW_DIM: f64 = 0.12;
4222
4223/// Jitter added during mass-matrix inversion to keep the metric strictly
4224/// positive-definite against round-off in the warmup covariance estimate.
4225const MASS_MATRIX_JITTER: f64 = 1e-5;
4226
4227#[inline]
4228fn robust_target_accept(requested: f64, dim: usize) -> f64 {
4229    let floor = if dim > HIGH_DIM_THRESHOLD {
4230        HIGH_DIM_TARGET_ACCEPT_FLOOR
4231    } else {
4232        LOW_DIM_TARGET_ACCEPT_FLOOR
4233    };
4234    requested.max(floor).min(MAX_TARGET_ACCEPT)
4235}
4236
4237fn jittered_initial_positions(
4238    config: &NutsConfig,
4239    dim: usize,
4240    scale: f64,
4241    stream: u64,
4242) -> Vec<Array1<f64>> {
4243    (0..config.n_chains)
4244        .map(|chain| {
4245            let mut rng = StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, stream));
4246            Array1::from_shape_fn(dim, |_| sample_standard_normal(&mut rng) * scale)
4247        })
4248        .collect()
4249}
4250
4251fn robust_mass_matrix_config(dim: usize, nwarmup: usize) -> NUTSMassMatrixConfig {
4252    if nwarmup < MIN_WARMUP_FOR_MASS_ADAPT {
4253        return NUTSMassMatrixConfig::disabled();
4254    }
4255    let start_buffer = (nwarmup / 8).clamp(35, 180);
4256    let end_buffer = (nwarmup / 5).clamp(50, 250);
4257    let initial_window = (nwarmup / 20).clamp(10, 60);
4258    NUTSMassMatrixConfig {
4259        adaptation: MassMatrixAdaptation::Diagonal,
4260        start_buffer,
4261        end_buffer,
4262        initial_window,
4263        regularize: if dim > HIGH_DIM_THRESHOLD {
4264            MASS_REGULARIZE_HIGH_DIM
4265        } else {
4266            MASS_REGULARIZE_LOW_DIM
4267        },
4268        jitter: MASS_MATRIX_JITTER,
4269        dense_max_dim: DENSE_MASS_MATRIX_MAX_DIM,
4270    }
4271}
4272
4273fn robust_survival_mass_matrix_config(dim: usize, nwarmup: usize) -> NUTSMassMatrixConfig {
4274    if nwarmup < MIN_WARMUP_FOR_MASS_ADAPT {
4275        return NUTSMassMatrixConfig::disabled();
4276    }
4277    // Survival posteriors with censoring/rare events are often skewed; this
4278    // configuration uses diagonal adaptation.
4279    let start_buffer = (nwarmup / 7).clamp(40, 200);
4280    let end_buffer = (nwarmup / 4).clamp(60, 280);
4281    let initial_window = (nwarmup / 20).clamp(10, 60);
4282    NUTSMassMatrixConfig {
4283        adaptation: MassMatrixAdaptation::Diagonal,
4284        start_buffer,
4285        end_buffer,
4286        initial_window,
4287        regularize: if dim > HIGH_DIM_THRESHOLD {
4288            SURVIVAL_MASS_REGULARIZE_HIGH_DIM
4289        } else {
4290            SURVIVAL_MASS_REGULARIZE_LOW_DIM
4291        },
4292        jitter: MASS_MATRIX_JITTER,
4293        dense_max_dim: DENSE_MASS_MATRIX_MAX_DIM,
4294    }
4295}
4296
4297impl Default for NutsConfig {
4298    fn default() -> Self {
4299        Self {
4300            n_samples: 1000,
4301            nwarmup: 500,
4302            n_chains: 4,
4303            target_accept: 0.9,
4304            seed: 42,
4305        }
4306    }
4307}
4308
4309impl NutsConfig {
4310    /// Create a config with sample counts tuned for the model dimension.
4311    ///
4312    /// Higher dimensions need more samples because:
4313    /// - ESS decreases with dimension (autocorrelation grows)
4314    /// - Split R-hat needs enough samples per chain to be meaningful
4315    ///
4316    /// Rule of thumb: target 100 effective samples per parameter.
4317    pub fn for_dimension(n_params: usize) -> Self {
4318        // ESS ≈ n_samples / (1 + 2τ) where τ ≈ sqrt(dim) for well-tuned NUTS
4319        let effective_autocorr = (n_params as f64).sqrt().max(1.0);
4320
4321        // Target: at least 100 effective samples per parameter
4322        let target_ess = 100 * n_params;
4323
4324        // Samples needed = ESS * (1 + 2τ), with 1.5x safety factor
4325        let raw_samples = (target_ess as f64 * (1.0 + 2.0 * effective_autocorr) * 1.5) as usize;
4326
4327        // Clamp to reasonable range [500, 10000]
4328        let n_samples = raw_samples.clamp(500, 10_000);
4329
4330        // Warmup ≈ samples (standard practice for adaptation)
4331        let nwarmup = n_samples;
4332
4333        // More chains for higher dims (better R-hat estimation)
4334        let n_chains = if n_params > 50 { 4 } else { 2 };
4335
4336        Self {
4337            n_samples,
4338            nwarmup,
4339            n_chains,
4340            target_accept: 0.9,
4341            seed: 42,
4342        }
4343    }
4344}
4345
4346/// Result of NUTS sampling.
4347#[derive(Clone, Debug)]
4348pub struct NutsResult {
4349    /// Coefficient samples in ORIGINAL space: shape (n_total_samples, n_coeffs)
4350    pub samples: Array2<f64>,
4351    /// Posterior mean
4352    pub posterior_mean: Array1<f64>,
4353    /// Posterior standard deviation
4354    pub posterior_std: Array1<f64>,
4355    /// R-hat convergence diagnostic
4356    pub rhat: f64,
4357    /// Effective sample size
4358    pub ess: f64,
4359    /// Whether sampling converged (R-hat < 1.1)
4360    pub converged: bool,
4361}
4362
4363#[derive(Clone, Copy)]
4364struct NutsConvergenceThresholds {
4365    max_rhat: f64,
4366    min_ess: Option<f64>,
4367}
4368
4369impl NutsConvergenceThresholds {
4370    #[inline]
4371    fn converged(self, rhat: f64, ess: f64) -> bool {
4372        let rhat_ok = rhat < self.max_rhat;
4373        match self.min_ess {
4374            Some(min_ess) => rhat_ok && ess > min_ess,
4375            None => rhat_ok,
4376        }
4377    }
4378}
4379
4380fn run_whitened_nuts_samples<Target>(
4381    target: Target,
4382    initial_positions: Vec<Array1<f64>>,
4383    config: &NutsConfig,
4384    dim: usize,
4385    mass_cfg: NUTSMassMatrixConfig,
4386    transition_seed_stream: u64,
4387    sampling_error_label: &str,
4388) -> Result<(Array3<f64>, String), String>
4389where
4390    Target: HamiltonianTarget<Array1<f64>> + Sync + Send,
4391{
4392    let mut sampler = GenericNUTS::new_with_mass_matrix(
4393        target,
4394        initial_positions,
4395        robust_target_accept(config.target_accept, dim),
4396        mass_cfg,
4397    )
4398    .set_seed(nuts_transition_seed(config.seed, transition_seed_stream));
4399
4400    let (samples_array, run_stats) = sampler
4401        .run_progress(config.n_samples, config.nwarmup)
4402        .map_err(|e| format!("{sampling_error_label}: {e}"))?;
4403    Ok((samples_array, run_stats.to_string()))
4404}
4405
4406fn unwhiten_samples(
4407    samples_array: &Array3<f64>,
4408    mode: &Array1<f64>,
4409    chol: &Array2<f64>,
4410    dim: usize,
4411    z_start: usize,
4412) -> Array2<f64> {
4413    let shape = samples_array.shape();
4414    let n_chains = shape[0];
4415    let n_samples_out = shape[1];
4416    let total_samples = n_chains * n_samples_out;
4417
4418    let mut samples = Array2::<f64>::zeros((total_samples, dim));
4419    let mut z_buffer = Array1::<f64>::zeros(dim);
4420    for chain in 0..n_chains {
4421        for sample_i in 0..n_samples_out {
4422            let zview = samples_array.slice(ndarray::s![chain, sample_i, z_start..z_start + dim]);
4423            z_buffer.assign(&zview);
4424            let beta = mode + &chol.dot(&z_buffer);
4425            let sample_idx = chain * n_samples_out + sample_i;
4426            samples.row_mut(sample_idx).assign(&beta);
4427        }
4428    }
4429
4430    samples
4431}
4432
4433fn summarize_unwhitened_nuts_samples(
4434    samples: Array2<f64>,
4435    samples_array: &Array3<f64>,
4436    empty_mean: Array1<f64>,
4437    convergence: NutsConvergenceThresholds,
4438) -> NutsResult {
4439    let posterior_mean = samples.mean_axis(Axis(0)).unwrap_or(empty_mean);
4440    let posterior_std = samples.std_axis(Axis(0), 0.0);
4441    let (rhat, ess) = compute_split_rhat_and_ess(samples_array);
4442    let converged = convergence.converged(rhat, ess);
4443
4444    NutsResult {
4445        samples,
4446        posterior_mean,
4447        posterior_std,
4448        rhat,
4449        ess,
4450        converged,
4451    }
4452}
4453
4454fn run_whitened_nuts_result<Target>(
4455    target: Target,
4456    mode: &Array1<f64>,
4457    chol: &Array2<f64>,
4458    initial_positions: Vec<Array1<f64>>,
4459    config: &NutsConfig,
4460    dim: usize,
4461    mass_cfg: NUTSMassMatrixConfig,
4462    transition_seed_stream: u64,
4463    sampling_error_label: &str,
4464    empty_mean: Array1<f64>,
4465    convergence: NutsConvergenceThresholds,
4466) -> Result<(NutsResult, String), String>
4467where
4468    Target: HamiltonianTarget<Array1<f64>> + Sync + Send,
4469{
4470    let (samples_array, run_stats) = run_whitened_nuts_samples(
4471        target,
4472        initial_positions,
4473        config,
4474        dim,
4475        mass_cfg,
4476        transition_seed_stream,
4477        sampling_error_label,
4478    )?;
4479    let samples = unwhiten_samples(&samples_array, mode, chol, dim, 0);
4480    let result =
4481        summarize_unwhitened_nuts_samples(samples, &samples_array, empty_mean, convergence);
4482    Ok((result, run_stats))
4483}
4484
4485impl NutsResult {
4486    /// Computes the posterior mean of a function applied to coefficients.
4487    /// Returns 0.0 if samples is empty to avoid divide-by-zero.
4488    pub fn posterior_mean_of<F>(&self, f: F) -> f64
4489    where
4490        F: Fn(ArrayView1<f64>) -> f64 + Sync,
4491    {
4492        let n = self.samples.nrows();
4493        if n == 0 {
4494            return 0.0;
4495        }
4496        // Posterior mean of a sample-function: deterministic parallel reduction over rows.
4497        // `f: Fn(ArrayView1) -> f64` is shared-access so safe across threads.
4498        let sum: f64 = gam_linalg::pairwise_reduce::par_pairwise_sum(n, |i| f(self.samples.row(i)));
4499        sum / n as f64
4500    }
4501
4502    /// Computes percentiles of a function applied to coefficients.
4503    pub fn posterior_interval_of<F>(&self, f: F, lower_pct: f64, upper_pct: f64) -> (f64, f64)
4504    where
4505        F: Fn(ArrayView1<f64>) -> f64,
4506    {
4507        let n = self.samples.nrows();
4508        if n == 0 {
4509            return (0.0, 0.0);
4510        }
4511        let mut values: Vec<f64> = (0..n).map(|i| f(self.samples.row(i))).collect();
4512        values.sort_by(f64::total_cmp);
4513
4514        (
4515            gam_math::quantile::quantile_from_sorted(&values, lower_pct / 100.0),
4516            gam_math::quantile::quantile_from_sorted(&values, upper_pct / 100.0),
4517        )
4518    }
4519}
4520
4521#[inline]
4522fn sample_standard_normal<R: rand::Rng + ?Sized>(rng: &mut R) -> f64 {
4523    // Box-Muller requires U1 in the open interval (0, 1). Reject the single
4524    // exactly-zero lattice point instead of projecting an interval of valid
4525    // uniforms onto an arbitrary floor, which creates an atom and truncates
4526    // the normal tail.
4527    let u1 = loop {
4528        let draw = rng.random::<f64>();
4529        if draw > 0.0 {
4530            break draw;
4531        }
4532    };
4533    let u2 = rng.random::<f64>();
4534    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
4535}
4536
4537/// Runs a Pólya-Gamma Gibbs sampler for Bernoulli-logit models.
4538///
4539/// This sampler is gradient-free: each iteration alternates
4540/// 1) ω_i | β, y ~ PG(1, x_i^T β), and
4541/// 2) β | ω, y ~ N(Q^{-1} b, Q^{-1}), with Q = S + X^T diag(ω) X, b = X^T(y - 1/2).
4542///
4543/// For weighted data, this implementation is defined for weights ≈ 1.0 because it
4544/// samples PG(1,·) latent variables.
4545pub fn run_logit_polya_gamma_gibbs(
4546    x: ArrayView2<f64>,
4547    y: ArrayView1<f64>,
4548    weights: ArrayView1<f64>,
4549    penalty_matrix: ArrayView2<f64>,
4550    mode: ArrayView1<f64>,
4551    config: &NutsConfig,
4552) -> Result<NutsResult, String> {
4553    let n = x.nrows();
4554    let p = x.ncols();
4555    if y.len() != n || weights.len() != n {
4556        return Err(HmcError::DimensionMismatch {
4557            reason: "run_logit_polya_gamma_gibbs: input length mismatch".to_string(),
4558        }
4559        .into());
4560    }
4561    if mode.len() != p || penalty_matrix.nrows() != p || penalty_matrix.ncols() != p {
4562        return Err(HmcError::DimensionMismatch {
4563            reason: "run_logit_polya_gamma_gibbs: coefficient/penalty dimension mismatch"
4564                .to_string(),
4565        }
4566        .into());
4567    }
4568    if !weights.iter().all(|w| (*w - 1.0).abs() <= 1e-10) {
4569        return Err(HmcError::InvalidConfig {
4570            reason: "run_logit_polya_gamma_gibbs requires unit weights (PG(1,·)); use NUTS for non-unit weights".to_string(),
4571        }
4572        .into());
4573    }
4574    validate_binary_responses("run_logit_polya_gamma_gibbs", &y, &weights).map_err(String::from)?;
4575    // Issue #399: the auto-selected PG-Gibbs path is reached for the canonical
4576    // unit-weight Bernoulli-logit GAM. Without this guard, `n_chains == 0` /
4577    // `n_samples == 0` would not panic but silently return a degenerate empty
4578    // `(0, p)` posterior, diverging from the typed error the NUTS path raises
4579    // for the same inputs. Route it through the shared validator so every
4580    // `Model.sample` surface rejects degenerate draw/chain counts identically.
4581    validate_nuts_config(config).map_err(String::from)?;
4582
4583    let n_iter = config.nwarmup + config.n_samples;
4584
4585    // b = X^T (y - 1/2), constant across iterations.
4586    let kappa = y.mapv(|v| v - 0.5);
4587    let rhs_b = fast_atv(&x, &kappa);
4588
4589    let mut samples_array = Array3::<f64>::zeros((config.n_chains, config.n_samples, p));
4590    let mut eta = Array1::<f64>::zeros(n);
4591    let mut omega = Array1::<f64>::ones(n);
4592    let pg_shapes = Array1::<u32>::from_elem(n, 1);
4593    let mut xw = x.to_owned();
4594    let mut xt_omega_x = Array2::<f64>::zeros((p, p));
4595    let penalty = penalty_matrix.to_owned();
4596    let mut q = Array2::<f64>::zeros((p, p));
4597    let mut mean = Array1::<f64>::zeros(p);
4598    let mut z = Array1::<f64>::zeros(p);
4599    let mut noise = Array1::<f64>::zeros(p);
4600
4601    for chain in 0..config.n_chains {
4602        let mut init_rng =
4603            StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0xB3C4_5A1F_8E9D_7632));
4604        let mut draw_rng =
4605            StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0x17A9_26D5_4C1B_E083));
4606        let mut beta = mode.to_owned();
4607        // Small jitter so chains are not perfectly coupled.
4608        for j in 0..p {
4609            beta[j] += 0.05 * sample_standard_normal(&mut init_rng);
4610        }
4611
4612        for iter in 0..n_iter {
4613            eta.assign(&gam_linalg::faer_ndarray::fast_av(&x, &beta));
4614            draw_logit_pg1_omega(
4615                pg_shapes.view(),
4616                eta.view(),
4617                gibbs_pg_seed(config.seed, chain, 0x4D94_DF4E_5D72_81AB, iter),
4618                &mut omega,
4619            )?;
4620
4621            // Build Xweighted = diag(sqrt(ω)) X and compute X^T Ω X via faer GEMM.
4622            // Per-row scaling is fully independent across rows.
4623            ndarray::Zip::from(xw.rows_mut())
4624                .and(x.rows())
4625                .and(&omega)
4626                .par_for_each(|mut xw_row, x_row, omega_i| {
4627                    let s = omega_i.sqrt();
4628                    for j in 0..p {
4629                        xw_row[j] = x_row[j] * s;
4630                    }
4631                });
4632            fast_ata_into(&xw, &mut xt_omega_x);
4633
4634            q.assign(&penalty);
4635            q += &xt_omega_x;
4636
4637            // β | ω,y ~ N(Q^{-1} b, Q^{-1})
4638            let factor = q
4639                .cholesky(Side::Lower)
4640                .map_err(|e| format!("PG Gibbs failed to factor Q: {:?}", e))?;
4641            mean.assign(&factor.solvevec(&rhs_b));
4642
4643            for j in 0..p {
4644                z[j] = sample_standard_normal(&mut draw_rng);
4645            }
4646            let l = factor.lower_triangular();
4647            back_substitution_lower_transpose_guarded_into(&l, &z, &mut noise);
4648            beta.assign(&(&mean + &noise));
4649
4650            if iter >= config.nwarmup {
4651                let keep_idx = iter - config.nwarmup;
4652                samples_array
4653                    .slice_mut(ndarray::s![chain, keep_idx, ..])
4654                    .assign(&beta);
4655            }
4656        }
4657    }
4658
4659    let total_samples = config.n_chains * config.n_samples;
4660    let mut samples = Array2::<f64>::zeros((total_samples, p));
4661    for chain in 0..config.n_chains {
4662        for s in 0..config.n_samples {
4663            let idx = chain * config.n_samples + s;
4664            samples
4665                .row_mut(idx)
4666                .assign(&samples_array.slice(ndarray::s![chain, s, ..]));
4667        }
4668    }
4669
4670    let posterior_mean = samples
4671        .mean_axis(Axis(0))
4672        .unwrap_or_else(|| Array1::zeros(p));
4673    let posterior_std = samples.std_axis(Axis(0), 0.0);
4674    let (rhat, ess) = if config.n_chains >= 2 && config.n_samples >= 4 {
4675        compute_split_rhat_and_ess(&samples_array)
4676    } else {
4677        (1.0, (total_samples as f64) * 0.5)
4678    };
4679    let converged = rhat < 1.1 && ess > 100.0;
4680
4681    Ok(NutsResult {
4682        samples,
4683        posterior_mean,
4684        posterior_std,
4685        rhat,
4686        ess,
4687        converged,
4688    })
4689}
4690
4691/// Estimate E_{ω|y,ρ}[ tr(S_k Q^{-1}) + μᵀ S_k μ ] with PG Gibbs + Rao-Blackwellization.
4692///
4693/// For each retained Gibbs state ω:
4694///   Q = S + Xᵀ diag(ω) X,  μ = Q^{-1} Xᵀ(y-1/2),
4695/// and with S_k = R_kᵀ R_k:
4696///   tr(S_k Q^{-1}) + μᵀ S_k μ
4697/// = tr(R_k Q^{-1} R_kᵀ) + ||R_k μ||².
4698///
4699/// Returns one expectation per penalty block k, averaged over retained draws.
4700pub fn estimate_logit_pg_rao_blackwell_terms(
4701    x: ArrayView2<f64>,
4702    y: ArrayView1<f64>,
4703    weights: ArrayView1<f64>,
4704    penalty_matrix: ArrayView2<f64>,
4705    mode: ArrayView1<f64>,
4706    penalty_roots: &[Array2<f64>],
4707    config: &NutsConfig,
4708) -> Result<Array1<f64>, String> {
4709    let n = x.nrows();
4710    let p = x.ncols();
4711    if y.len() != n || weights.len() != n {
4712        return Err(HmcError::DimensionMismatch {
4713            reason: "estimate_logit_pg_rao_blackwell_terms: input length mismatch".to_string(),
4714        }
4715        .into());
4716    }
4717    if mode.len() != p || penalty_matrix.nrows() != p || penalty_matrix.ncols() != p {
4718        return Err(HmcError::DimensionMismatch {
4719            reason: "estimate_logit_pg_rao_blackwell_terms: coefficient/penalty dimension mismatch"
4720                .to_string(),
4721        }
4722        .into());
4723    }
4724    if !weights.iter().all(|w| (*w - 1.0).abs() <= 1e-10) {
4725        return Err(HmcError::InvalidConfig {
4726            reason: "estimate_logit_pg_rao_blackwell_terms requires unit weights (PG(1,·))"
4727                .to_string(),
4728        }
4729        .into());
4730    }
4731    validate_binary_responses("estimate_logit_pg_rao_blackwell_terms", &y, &weights)
4732        .map_err(String::from)?;
4733    if penalty_roots.iter().any(|r| r.ncols() != p) {
4734        return Err(HmcError::DimensionMismatch {
4735            reason: "estimate_logit_pg_rao_blackwell_terms: root width mismatch".to_string(),
4736        }
4737        .into());
4738    }
4739    // Precompute transposed root blocks once:
4740    //   R_k^T is the RHS used for batched solves Q X = R_k^T.
4741    let penalty_roots_t: Vec<Array2<f64>> =
4742        penalty_roots.iter().map(|r| r.t().to_owned()).collect();
4743
4744    let n_iter = config.nwarmup + config.n_samples;
4745
4746    // Logistic PG identity uses kappa_i = y_i - 1/2 so that
4747    // b = X^T kappa in the Gaussian conditional for beta|omega.
4748    let kappa = y.mapv(|v| v - 0.5);
4749    let rhs_b = fast_atv(&x, &kappa);
4750
4751    let penalty = penalty_matrix.to_owned();
4752    let mut eta = Array1::<f64>::zeros(n);
4753    let mut omega = Array1::<f64>::ones(n);
4754    let pg_shapes = Array1::<u32>::from_elem(n, 1);
4755    let mut xw = x.to_owned();
4756    let mut xt_omega_x = Array2::<f64>::zeros((p, p));
4757    let mut q = Array2::<f64>::zeros((p, p));
4758    let mut mean = Array1::<f64>::zeros(p);
4759    let mut rb_sum = Array1::<f64>::zeros(penalty_roots.len());
4760    let mut z = Array1::<f64>::zeros(p);
4761    let mut noise = Array1::<f64>::zeros(p);
4762
4763    let mut kept = 0usize;
4764    for chain in 0..config.n_chains {
4765        let mut init_rng =
4766            StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0x28F0_7B65_1A4D_C93E));
4767        let mut draw_rng =
4768            StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0xC642_6E35_B5A9_1D80));
4769        let mut beta = mode.to_owned();
4770        for j in 0..p {
4771            beta[j] += 0.05 * sample_standard_normal(&mut init_rng);
4772        }
4773
4774        for iter in 0..n_iter {
4775            eta.assign(&gam_linalg::faer_ndarray::fast_av(&x, &beta));
4776            draw_logit_pg1_omega(
4777                pg_shapes.view(),
4778                eta.view(),
4779                gibbs_pg_seed(config.seed, chain, 0x83F1_56C9_A7E0_2D4B, iter),
4780                &mut omega,
4781            )?;
4782
4783            ndarray::Zip::from(xw.rows_mut())
4784                .and(x.rows())
4785                .and(&omega)
4786                .par_for_each(|mut xw_row, x_row, &omega_i| {
4787                    let s = omega_i.sqrt();
4788                    for j in 0..p {
4789                        xw_row[j] = x_row[j] * s;
4790                    }
4791                });
4792            fast_ata_into(&xw, &mut xt_omega_x);
4793
4794            // Conditional precision:
4795            //   Q = S + X^T diag(omega) X.
4796            q.assign(&penalty);
4797            q += &xt_omega_x;
4798
4799            let factor = q
4800                .cholesky(Side::Lower)
4801                .map_err(|e| format!("PG Rao-Blackwell failed to factor Q: {:?}", e))?;
4802            // Conditional mean:
4803            //   mu = Q^{-1} b,  b = X^T(y - 1/2).
4804            mean.assign(&factor.solvevec(&rhs_b));
4805
4806            // Draw beta for the next Gibbs state.
4807            for j in 0..p {
4808                z[j] = sample_standard_normal(&mut draw_rng);
4809            }
4810            let l = factor.lower_triangular();
4811            back_substitution_lower_transpose_guarded_into(&l, &z, &mut noise);
4812            beta.assign(&(&mean + &noise));
4813
4814            if iter < config.nwarmup {
4815                continue;
4816            }
4817            kept += 1;
4818
4819            for (k, r_k) in penalty_roots.iter().enumerate() {
4820                if r_k.nrows() == 0 {
4821                    continue;
4822                }
4823
4824                // mu^T S_k mu via root form S_k = R_k^T R_k.
4825                let rmu = r_k.dot(&mean);
4826                let mu_quad = rmu.dot(&rmu);
4827
4828                // Batched trace solve:
4829                //   V_k = Q^{-1} R_k^T  (single multi-RHS solve)
4830                // then tr(R_k Q^{-1} R_k^T) = <R_k, V_k^T>_F.
4831                let solved_mat = factor.solve_mat(&penalty_roots_t[k]); // (p, r_k)
4832                let solved_t = solved_mat.t();
4833                let mut trace_term = 0.0_f64;
4834                for (&a, &b) in r_k.iter().zip(solved_t.iter()) {
4835                    trace_term += a * b;
4836                }
4837
4838                rb_sum[k] += trace_term + mu_quad;
4839            }
4840        }
4841    }
4842
4843    if kept == 0 {
4844        return Err(HmcError::SamplingFailed {
4845            reason: "estimate_logit_pg_rao_blackwell_terms: no retained samples".to_string(),
4846        }
4847        .into());
4848    }
4849    let out = rb_sum.mapv(|v| v / (kept as f64));
4850    if !out.iter().all(|v| v.is_finite()) {
4851        return Err(HmcError::NonFiniteState {
4852            reason: "estimate_logit_pg_rao_blackwell_terms: non-finite expectation".to_string(),
4853        }
4854        .into());
4855    }
4856    Ok(out)
4857}
4858
4859/// Runs NUTS sampling using general-mcmc with whitened parameter space.
4860///
4861/// # Arguments
4862/// * `x` - Design matrix [n_samples, dim]
4863/// * `y` - Response vector [n_samples]
4864/// * `weights` - Observation/case weights [n_samples]
4865/// * `penalty_matrix` - Combined penalty S [dim, dim]
4866/// * `mode` - MAP estimate μ [dim]
4867/// * `hessian` - Penalized Hessian H [dim, dim] (NOT the inverse!)
4868/// * `likelihood` - Exact family, inverse-link, and fitted scale metadata
4869/// * `firth_bias_reduction` - Whether Firth bias reduction was used in training
4870/// * `config` - NUTS configuration
4871pub(crate) fn run_nuts_sampling(
4872    x: ArrayView2<f64>,
4873    y: ArrayView1<f64>,
4874    weights: ArrayView1<f64>,
4875    penalty_matrix: ArrayView2<f64>,
4876    mode: ArrayView1<f64>,
4877    hessian: ArrayView2<f64>,
4878    likelihood: GlmLikelihoodSpec,
4879    dispersion: gam_solve::model_types::Dispersion,
4880    firth_bias_reduction: bool,
4881    offset: Option<ArrayView1<f64>>,
4882    config: &NutsConfig,
4883) -> Result<NutsResult, String> {
4884    validate_firth_likelihood_support(&likelihood.spec, firth_bias_reduction)
4885        .map_err(String::from)?;
4886    validate_nuts_config(config).map_err(String::from)?;
4887    let dim = mode.len();
4888
4889    // Create posterior target with analytical gradients. When Firth is enabled,
4890    // this target includes the identifiable-subspace Jeffreys term.
4891    let target = NutsPosterior::new(
4892        x,
4893        y,
4894        weights,
4895        penalty_matrix,
4896        mode,
4897        hessian,
4898        likelihood,
4899        dispersion,
4900        offset,
4901        firth_bias_reduction,
4902    )?;
4903
4904    // Get Cholesky factor for un-whitening samples later
4905    let chol = target.chol().clone();
4906    let mode_arr = target.mode().clone();
4907
4908    let initial_positions = jittered_initial_positions(config, dim, 0.1, 0x0F65_83B2_BC71_4D9E);
4909    let mass_cfg = robust_mass_matrix_config(dim, config.nwarmup);
4910    let (result, run_stats) = run_whitened_nuts_result(
4911        target,
4912        &mode_arr,
4913        &chol,
4914        initial_positions,
4915        config,
4916        dim,
4917        mass_cfg,
4918        0xF1D3_C2B5_A697_804E,
4919        "NUTS sampling failed",
4920        Array1::zeros(dim),
4921        NutsConvergenceThresholds {
4922            max_rhat: 1.1,
4923            min_ess: Some(100.0),
4924        },
4925    )?;
4926    log::info!("NUTS sampling complete: {}", run_stats);
4927
4928    Ok(result)
4929}
4930
4931/// Penalty subtracted from the log-density when the `ρ`-criterion closure
4932/// reports an infeasible / non-finite point during Tier-2 `ρ`-posterior NUTS
4933/// (#938). The fallback density is the whitened standard normal shifted down by
4934/// this constant, so the sampler sees a smooth, coercive pull back toward the
4935/// feasible region around `ρ̂` instead of a `-inf` cliff.
4936const RHO_NUTS_INFEASIBLE_LOGP_PENALTY: f64 = 1.0e8;
4937
4938/// Tier-2 of the exact marginal-smoothing inference stack (#938): the whitened
4939/// `ρ`-criterion Hamiltonian target.
4940///
4941/// This reuses the module's β-level whitening design ONE LEVEL UP: the target
4942/// log-density is `logp(ρ) = −(criterion(ρ) − criterion(ρ̂))` — i.e.
4943/// `π(ρ|y) ∝ exp(−LAML(ρ))`, the exact profiled criterion the outer optimizer
4944/// minimizes — expressed in the whitened coordinates `ρ = ρ̂ + L z` with
4945/// `L Lᵀ = H_ρ⁻¹` built from the exact outer Hessian at `ρ̂`. The gradient is
4946/// the caller's exact profiled `ρ`-gradient pushed through the chain rule:
4947/// `∇_z logp = −Lᵀ ∇_ρ criterion`.
4948///
4949/// The criterion closure is `FnMut` (each evaluation is one warm inner profile
4950/// solve with interior caches), so it is serialized behind a `Mutex`; chains
4951/// take turns evaluating, which also keeps the inner warm-start trajectory
4952/// coherent.
4953struct WhitenedRhoCriterionTarget<F> {
4954    /// `ρ ↦ (criterion(ρ), ∇_ρ criterion(ρ))`; `None` marks an infeasible point.
4955    criterion_and_grad: Mutex<F>,
4956    /// `ρ̂`, the converged smoothing parameters (the whitening center).
4957    mode: Array1<f64>,
4958    /// `L` with `L Lᵀ = H_ρ⁻¹`: maps whitened `z` to `ρ = ρ̂ + L z`.
4959    chol: Array2<f64>,
4960    /// `Lᵀ`, for the gradient chain rule.
4961    chol_t: Array2<f64>,
4962    /// `criterion(ρ̂)`, subtracted for numerical stability (cancels in MCMC).
4963    cost_hat: f64,
4964}
4965
4966impl<F> HamiltonianTarget<Array1<f64>> for WhitenedRhoCriterionTarget<F>
4967where
4968    F: FnMut(&Array1<f64>) -> Option<(f64, Array1<f64>)> + Send,
4969{
4970    fn logp_and_grad(&self, position: &Array1<f64>, grad: &mut Array1<f64>) -> f64 {
4971        let rho = &self.mode + &self.chol.dot(position);
4972        let eval = {
4973            let mut criterion = self
4974                .criterion_and_grad
4975                .lock()
4976                .expect("rho-criterion mutex poisoned");
4977            (*criterion)(&rho)
4978        };
4979        match eval {
4980            Some((cost, g))
4981                if cost.is_finite()
4982                    && g.len() == position.len()
4983                    && g.iter().all(|v| v.is_finite()) =>
4984            {
4985                let grad_z = self.chol_t.dot(&g);
4986                for (gi, &v) in grad.iter_mut().zip(grad_z.iter()) {
4987                    *gi = -v;
4988                }
4989                -(cost - self.cost_hat)
4990            }
4991            _ => {
4992                // Infeasible criterion: smooth coercive fallback toward ρ̂.
4993                let mut quad = 0.0;
4994                for (gi, &zi) in grad.iter_mut().zip(position.iter()) {
4995                    *gi = -zi;
4996                    quad += zi * zi;
4997                }
4998                -0.5 * quad - RHO_NUTS_INFEASIBLE_LOGP_PENALTY
4999            }
5000        }
5001    }
5002}
5003
5004/// Run NUTS over the smoothing parameters `ρ` with the exact profiled criterion
5005/// and gradient (#938 Tier 2).
5006///
5007/// * `rho_hat` — converged `ρ̂` (the whitening center and chain seed).
5008/// * `outer_hessian` — exact finite symmetric positive-definite outer Hessian
5009///   `H_ρ` at `ρ̂`, factored without perturbation for whitening.
5010/// * `criterion_and_grad` — `ρ ↦ (LAML(ρ), ∇_ρ LAML(ρ))`, both exact; `None`
5011///   for infeasible `ρ`. Each call is one warm inner profile solve.
5012/// * `config` — sampler configuration; determinism comes from `config.seed`
5013///   through the same splitmix64 chain/transition streams as every other NUTS
5014///   entry point (no clock, no global RNG).
5015///
5016/// Returns draws in the ORIGINAL `ρ` space (un-whitened), with split-R̂/ESS
5017/// diagnostics.
5018pub fn run_rho_criterion_nuts<F>(
5019    rho_hat: ArrayView1<f64>,
5020    outer_hessian: ArrayView2<f64>,
5021    mut criterion_and_grad: F,
5022    config: &NutsConfig,
5023) -> Result<NutsResult, String>
5024where
5025    F: FnMut(&Array1<f64>) -> Option<(f64, Array1<f64>)> + Send,
5026{
5027    validate_nuts_config(config).map_err(String::from)?;
5028    let dim = rho_hat.len();
5029    if dim == 0 {
5030        return Err("rho-posterior NUTS: zero-dimensional rho".to_string());
5031    }
5032    if outer_hessian.nrows() != dim || outer_hessian.ncols() != dim {
5033        return Err(format!(
5034            "rho-posterior NUTS: outer Hessian shape {:?} does not match rho dim {dim}",
5035            outer_hessian.dim()
5036        ));
5037    }
5038
5039    let mode = rho_hat.to_owned();
5040    let whitening = hessian_whitening_transform(
5041        outer_hessian,
5042        dim,
5043        1.0,
5044        "rho-posterior NUTS: outer-Hessian Cholesky failed",
5045    )?;
5046
5047    let cost_hat = match criterion_and_grad(&mode) {
5048        Some((cost, _)) if cost.is_finite() => cost,
5049        _ => {
5050            return Err(
5051                "rho-posterior NUTS: criterion is infeasible at rho_hat itself".to_string(),
5052            );
5053        }
5054    };
5055
5056    let chol = whitening.chol;
5057    let target = WhitenedRhoCriterionTarget {
5058        criterion_and_grad: Mutex::new(criterion_and_grad),
5059        mode: mode.clone(),
5060        chol: chol.clone(),
5061        chol_t: whitening.chol_t,
5062        cost_hat,
5063    };
5064    let initial_positions = jittered_initial_positions(config, dim, 0.1, 0x3D8A_91C4_E27B_5F60);
5065    // The rho target is already whitened by the exact outer Hessian at rho_hat,
5066    // so the local mass matrix in z-space is identity. Re-adapting a diagonal or
5067    // dense metric during warmup would spend expensive profile solves estimating
5068    // curvature we have already supplied analytically.
5069    let mass_cfg = NUTSMassMatrixConfig::disabled();
5070    let (result, run_stats) = run_whitened_nuts_result(
5071        target,
5072        &mode,
5073        &chol,
5074        initial_positions,
5075        config,
5076        dim,
5077        mass_cfg,
5078        0x6B42_E9A1_05D7_C83F,
5079        "rho-posterior NUTS sampling failed",
5080        mode.clone(),
5081        NutsConvergenceThresholds {
5082            max_rhat: 1.1,
5083            min_ess: None,
5084        },
5085    )?;
5086    log::info!("rho-posterior NUTS (#938 tier 2): sampling complete dim={dim} {run_stats}");
5087    Ok(result)
5088}
5089
5090/// Flattened numeric inputs for GLM-family NUTS sampling.
5091pub struct GlmFlatInputs<'a> {
5092    pub x: ArrayView2<'a, f64>,
5093    pub y: ArrayView1<'a, f64>,
5094    pub weights: ArrayView1<'a, f64>,
5095    pub penalty_matrix: ArrayView2<'a, f64>,
5096    pub mode: ArrayView1<'a, f64>,
5097    pub hessian: ArrayView2<'a, f64>,
5098    /// Fitted scale metadata paired with the `LikelihoodSpec` passed to the
5099    /// flattened entry point. Family parameters must agree exactly with their
5100    /// metadata; construction never supplies a unit/shape default.
5101    pub likelihood_scale: LikelihoodScaleMetadata,
5102    /// Dispersion parameter φ used to scale the likelihood and the
5103    /// whitening Cholesky. For fixed-scale families (Binomial, Poisson)
5104    /// this is exact unit dispersion and has no numerical effect;
5105    /// for Gaussian / Gamma it carries the estimated `phi` so that the
5106    /// sampler targets the φ-scaled posterior covariance `Vb = φ·H⁻¹`.
5107    /// See `inference::dispersion_cov` for the ownership invariants.
5108    pub dispersion: gam_solve::model_types::Dispersion,
5109    pub firth_bias_reduction: bool,
5110    /// Fixed additive offset on the linear predictor (η = Xβ + offset), or
5111    /// `None` for an offset-free fit. Carried so posterior sampling targets the
5112    /// same η the model was fit and predicts on; omitting it sampled the wrong
5113    /// posterior for any `--offset-column` model (#882).
5114    pub offset: Option<ArrayView1<'a, f64>>,
5115}
5116
5117/// Flat survival inputs for engine-facing HMC APIs.
5118pub struct SurvivalFlatInputs<'a> {
5119    pub age_entry: ArrayView1<'a, f64>,
5120    pub age_exit: ArrayView1<'a, f64>,
5121    pub event_target: ArrayView1<'a, u8>,
5122    pub event_competing: ArrayView1<'a, u8>,
5123    pub weights: ArrayView1<'a, f64>,
5124    pub x_entry: ArrayView2<'a, f64>,
5125    pub x_exit: ArrayView2<'a, f64>,
5126    pub x_derivative: ArrayView2<'a, f64>,
5127    pub eta_offset_entry: Option<ArrayView1<'a, f64>>,
5128    pub eta_offset_exit: Option<ArrayView1<'a, f64>>,
5129    pub derivative_offset_exit: Option<ArrayView1<'a, f64>>,
5130}
5131
5132/// Flattened numeric inputs for Royston-Parmar NUTS sampling.
5133pub struct SurvivalNutsInputs<'a> {
5134    pub flat: SurvivalFlatInputs<'a>,
5135    pub penalties: gam_models::survival::PenaltyBlocks,
5136    pub monotonicity: gam_models::survival::SurvivalMonotonicityPenalty,
5137    pub spec: gam_models::survival::SurvivalSpec,
5138    pub structurally_monotonic: bool,
5139    pub structural_time_columns: usize,
5140    pub mode: ArrayView1<'a, f64>,
5141    pub hessian: ArrayView2<'a, f64>,
5142}
5143
5144/// Family-dispatched flattened NUTS inputs.
5145pub enum FamilyNutsInputs<'a> {
5146    Glm(GlmFlatInputs<'a>),
5147    Survival(Box<SurvivalNutsInputs<'a>>),
5148}
5149
5150/// Return the explicit fitted penalized Hessian used for HMC/NUTS whitening.
5151///
5152/// This is the only supported upstream-to-HMC curvature handoff: callers must
5153/// pass a dense Hessian (or an already materialized exact operator stored as a
5154/// dense Hessian) exported by the fitter. We deliberately do not synthesize a
5155/// numerical Hessian and do not invert `beta_covariance` as a compatibility
5156/// fallback, because either path can silently whiten against curvature that the
5157/// upstream fit never certified.
5158pub fn explicit_fit_hessian_for_whitening<'a>(
5159    fit: &'a UnifiedFitResult,
5160    expected_dim: usize,
5161    label: &str,
5162) -> Result<&'a Array2<f64>, String> {
5163    let hessian = fit.penalized_hessian().ok_or_else(|| {
5164        format!(
5165            "{label}: fit result is missing an explicit penalized Hessian for HMC/NUTS whitening"
5166        )
5167    })?;
5168    validate_explicit_dense_hessian_for_whitening(
5169        &format!("{label} penalized Hessian"),
5170        hessian,
5171        expected_dim,
5172    )
5173    .map_err(|err| err.to_string())?;
5174    Ok(hessian)
5175}
5176
5177/// Family-agnostic flattened NUTS entrypoint across all supported likelihood families.
5178pub fn run_nuts_sampling_flattened_family(
5179    likelihood: LikelihoodSpec,
5180    inputs: FamilyNutsInputs<'_>,
5181    config: &NutsConfig,
5182) -> Result<NutsResult, String> {
5183    let resolved_glm_likelihood = match &inputs {
5184        FamilyNutsInputs::Glm(glm) => Some(GlmLikelihoodSpec {
5185            spec: likelihood.clone(),
5186            scale: glm.likelihood_scale,
5187        }),
5188        FamilyNutsInputs::Survival(_) => None,
5189    };
5190    if let (Some(resolved), FamilyNutsInputs::Glm(glm)) =
5191        (resolved_glm_likelihood.as_ref(), &inputs)
5192    {
5193        // Validate family/scale ownership before dispatch, including the PG
5194        // branch that does not construct a NutsPosterior.
5195        resolve_hmc_likelihood(resolved.clone(), glm.dispersion).map_err(String::from)?;
5196    }
5197    if let FamilyNutsInputs::Glm(glm) = &inputs
5198        && glm.firth_bias_reduction
5199        && !likelihood_spec_supports_firth(&likelihood)
5200    {
5201        return Err(HmcError::FirthUnsupported {
5202            reason: format!(
5203                "NUTS with Firth requires a Binomial inverse link with a Fisher-weight jet; {} does not support it",
5204                likelihood.pretty_name()
5205            ),
5206        }
5207        .into());
5208    }
5209
5210    match (likelihood.response.clone(), likelihood.link.clone(), inputs) {
5211        (
5212            ResponseFamily::Gaussian,
5213            InverseLink::Standard(StandardLink::Identity),
5214            FamilyNutsInputs::Glm(glm),
5215        ) => run_nuts_sampling(
5216            glm.x,
5217            glm.y,
5218            glm.weights,
5219            glm.penalty_matrix,
5220            glm.mode,
5221            glm.hessian,
5222            resolved_glm_likelihood
5223                .clone()
5224                .expect("GLM match arm has resolved likelihood"),
5225            glm.dispersion,
5226            glm.firth_bias_reduction,
5227            glm.offset,
5228            config,
5229        ),
5230        (
5231            ResponseFamily::Binomial,
5232            InverseLink::Standard(StandardLink::Logit),
5233            FamilyNutsInputs::Glm(glm),
5234        ) => {
5235            // Auto-select PG Gibbs when assumptions hold; otherwise fall back to NUTS.
5236            // This gives gradient-free posterior draws for standard Bernoulli logit GAMs.
5237            // The Pólya-Gamma augmentation here assumes η = Xβ (no offset); an
5238            // offset model routes to NUTS, which carries the offset through
5239            // `glm.offset` (#882). PG-with-offset is a valid but separate scheme
5240            // we deliberately do not duplicate.
5241            if !glm.firth_bias_reduction
5242                && glm.offset.is_none()
5243                && glm.weights.iter().all(|w| (*w - 1.0).abs() <= 1e-10)
5244            {
5245                run_logit_polya_gamma_gibbs(
5246                    glm.x,
5247                    glm.y,
5248                    glm.weights,
5249                    glm.penalty_matrix,
5250                    glm.mode,
5251                    config,
5252                )
5253            } else {
5254                run_nuts_sampling(
5255                    glm.x,
5256                    glm.y,
5257                    glm.weights,
5258                    glm.penalty_matrix,
5259                    glm.mode,
5260                    glm.hessian,
5261                    resolved_glm_likelihood
5262                        .clone()
5263                        .expect("GLM match arm has resolved likelihood"),
5264                    glm.dispersion,
5265                    glm.firth_bias_reduction,
5266                    glm.offset,
5267                    config,
5268                )
5269            }
5270        }
5271        (
5272            ResponseFamily::Binomial,
5273            InverseLink::Standard(StandardLink::Probit),
5274            FamilyNutsInputs::Glm(glm),
5275        ) => run_nuts_sampling(
5276            glm.x,
5277            glm.y,
5278            glm.weights,
5279            glm.penalty_matrix,
5280            glm.mode,
5281            glm.hessian,
5282            resolved_glm_likelihood
5283                .clone()
5284                .expect("GLM match arm has resolved likelihood"),
5285            glm.dispersion,
5286            glm.firth_bias_reduction,
5287            glm.offset,
5288            config,
5289        ),
5290        (
5291            ResponseFamily::Binomial,
5292            InverseLink::Standard(StandardLink::CLogLog),
5293            FamilyNutsInputs::Glm(glm),
5294        ) => run_nuts_sampling(
5295            glm.x,
5296            glm.y,
5297            glm.weights,
5298            glm.penalty_matrix,
5299            glm.mode,
5300            glm.hessian,
5301            resolved_glm_likelihood
5302                .clone()
5303                .expect("GLM match arm has resolved likelihood"),
5304            glm.dispersion,
5305            glm.firth_bias_reduction,
5306            glm.offset,
5307            config,
5308        ),
5309        (
5310            ResponseFamily::Binomial,
5311            InverseLink::LatentCLogLog(_),
5312            FamilyNutsInputs::Glm(glm),
5313        ) => run_nuts_sampling(
5314            glm.x,
5315            glm.y,
5316            glm.weights,
5317            glm.penalty_matrix,
5318            glm.mode,
5319            glm.hessian,
5320            resolved_glm_likelihood
5321                .clone()
5322                .expect("GLM match arm has resolved likelihood"),
5323            glm.dispersion,
5324            glm.firth_bias_reduction,
5325            glm.offset,
5326            config,
5327        ),
5328        (ResponseFamily::Binomial, InverseLink::Mixture(_), FamilyNutsInputs::Glm(_)) => Err(
5329            "BinomialMixture NUTS is not implemented yet; use fit_gam/predict_gam for blended inverse-link models"
5330                .to_string(),
5331        ),
5332        (ResponseFamily::Binomial, InverseLink::Sas(_), FamilyNutsInputs::Glm(_)) => Err(
5333            "BinomialSas NUTS is not implemented yet; use fit_gam/predict_gam for SAS-link models"
5334                .to_string(),
5335        ),
5336        (ResponseFamily::Binomial, InverseLink::BetaLogistic(_), FamilyNutsInputs::Glm(_)) => Err(
5337            "BinomialBetaLogistic NUTS is not implemented yet; use fit_gam/predict_gam for beta-logistic-link models"
5338                .to_string(),
5339        ),
5340        (ResponseFamily::Binomial, InverseLink::Standard(_), FamilyNutsInputs::Glm(_)) => Err(
5341            "NUTS sampling is not implemented for this binomial inverse link".to_string(),
5342        ),
5343        (ResponseFamily::RoystonParmar, _, FamilyNutsInputs::Survival(survival)) => {
5344            survival_hmc::run_survival_nuts_sampling(
5345                survival.flat.age_entry,
5346                survival.flat.age_exit,
5347                survival.flat.event_target,
5348                survival.flat.event_competing,
5349                survival.flat.weights,
5350                survival.flat.x_entry,
5351                survival.flat.x_exit,
5352                survival.flat.x_derivative,
5353                survival.flat.eta_offset_entry,
5354                survival.flat.eta_offset_exit,
5355                survival.flat.derivative_offset_exit,
5356                survival.penalties,
5357                survival.monotonicity,
5358                survival.spec,
5359                survival.structurally_monotonic,
5360                survival.structural_time_columns,
5361                survival.mode,
5362                survival.hessian,
5363                config,
5364            )
5365        }
5366        (ResponseFamily::RoystonParmar, _, FamilyNutsInputs::Glm(_)) => Err(
5367            "RoystonParmar family requires FamilyNutsInputs::Survival flattened inputs".to_string(),
5368        ),
5369        (_, _, FamilyNutsInputs::Survival(_)) => Err(
5370            "Survival flattened inputs are only valid for the Royston-Parmar response family"
5371                .to_string(),
5372        ),
5373        (ResponseFamily::Poisson, _, FamilyNutsInputs::Glm(glm)) => run_nuts_sampling(
5374            glm.x,
5375            glm.y,
5376            glm.weights,
5377            glm.penalty_matrix,
5378            glm.mode,
5379            glm.hessian,
5380            resolved_glm_likelihood
5381                .clone()
5382                .expect("GLM match arm has resolved likelihood"),
5383            glm.dispersion,
5384            glm.firth_bias_reduction,
5385            glm.offset,
5386            config,
5387        ),
5388        (ResponseFamily::Tweedie { p }, _, FamilyNutsInputs::Glm(glm)) => {
5389            // Family mapping: Tweedie payload p is passed through the family-parameter slot.
5390            // The Tweedie dispersion phi remains in glm.dispersion, matching REML.
5391            if !is_valid_tweedie_power(p) {
5392                return Err(format!(
5393                    "Tweedie variance power must be finite and strictly between 1 and 2; got {p}"
5394                ));
5395            }
5396            run_nuts_sampling(
5397                glm.x,
5398                glm.y,
5399                glm.weights,
5400                glm.penalty_matrix,
5401                glm.mode,
5402                glm.hessian,
5403                resolved_glm_likelihood
5404                    .clone()
5405                    .expect("GLM match arm has resolved likelihood"),
5406                glm.dispersion,
5407                glm.firth_bias_reduction,
5408                glm.offset,
5409                config,
5410            )
5411        }
5412        (ResponseFamily::NegativeBinomial { .. }, _, FamilyNutsInputs::Glm(glm)) => {
5413            // Family mapping: NegativeBinomial payload theta is passed through the family slot.
5414            // NB dispersion scale is unit; theta is not derived from fixed_phi.
5415            run_nuts_sampling(
5416                glm.x,
5417                glm.y,
5418                glm.weights,
5419                glm.penalty_matrix,
5420                glm.mode,
5421                glm.hessian,
5422                resolved_glm_likelihood
5423                    .clone()
5424                    .expect("GLM match arm has resolved likelihood"),
5425                glm.dispersion,
5426                glm.firth_bias_reduction,
5427                glm.offset,
5428                config,
5429            )
5430        }
5431        (
5432            ResponseFamily::Beta { .. },
5433            InverseLink::Standard(StandardLink::Logit),
5434            FamilyNutsInputs::Glm(glm),
5435        ) => run_nuts_sampling(
5436            glm.x,
5437            glm.y,
5438            glm.weights,
5439            glm.penalty_matrix,
5440            glm.mode,
5441            glm.hessian,
5442            resolved_glm_likelihood
5443                .clone()
5444                .expect("GLM match arm has resolved likelihood"),
5445            glm.dispersion,
5446            glm.firth_bias_reduction,
5447            glm.offset,
5448            config,
5449        ),
5450        (ResponseFamily::Beta { .. }, _, FamilyNutsInputs::Glm(_)) => {
5451            Err("beta-regression NUTS requires the logit inverse link".to_string())
5452        }
5453        (ResponseFamily::Gamma, _, FamilyNutsInputs::Glm(glm)) => run_nuts_sampling(
5454            glm.x,
5455            glm.y,
5456            glm.weights,
5457            glm.penalty_matrix,
5458            glm.mode,
5459            glm.hessian,
5460            resolved_glm_likelihood
5461                .clone()
5462                .expect("GLM match arm has resolved likelihood"),
5463            glm.dispersion,
5464            glm.firth_bias_reduction,
5465            glm.offset,
5466            config,
5467        ),
5468        (ResponseFamily::Gaussian, _, FamilyNutsInputs::Glm(_)) => Err(
5469            "NUTS sampling is only implemented for Gaussian with identity link".to_string(),
5470        ),
5471    }
5472}
5473
5474// ============================================================================
5475// Joint (β, ρ) HMC for Skewed Posteriors
5476// ============================================================================
5477//
5478// When the Laplace approximation to the marginal likelihood is unreliable
5479// (high posterior skewness), we bypass LAML entirely and sample from the
5480// joint posterior p(β, ρ | y) ∝ p(y|β) p(β|ρ) p(ρ).
5481//
5482// The joint log-posterior is:
5483//   log p(β, ρ | y) = ℓ(y|β) + Φ(β) [if Firth]
5484//                    - 0.5 β'S(ρ)β + 0.5 log|S(ρ)|_+ + log p(ρ) + const
5485//
5486// Gradients:
5487//   ∇_β: ∇_β ℓ + ∇_β Φ(β) [if Firth] - S(ρ) β
5488//   ∂/∂ρ_k: -0.5 λ_k β'S_k β + 0.5 tr(S_+⁻¹ A_k) + ∂log p(ρ)/∂ρ_k
5489//
5490// This completely avoids the Laplace approximation. When Firth bias reduction
5491// is active, the sampled target also includes the Jeffreys term Φ(β) in
5492// addition to the smoothing-parameter prior.
5493
5494/// Directional cubic non-Gaussianity diagnostic for the Laplace approximation.
5495///
5496/// For each positive-curvature Hessian eigenpair `(lambda_r, v_r)`, this computes
5497///
5498///   gamma_r = T[v_r, v_r, v_r] / lambda_r^(3/2)
5499///            = Σ_i c_i (x_i^T v_r)^3 / lambda_r^(3/2),
5500///
5501/// and reports `max_r |gamma_r|`. This is invariant to arbitrary coordinate
5502/// relabeling and uses the full directional cubic contraction rather than only
5503/// diagonal tensor entries.
5504/// `refine_supremum` controls Phase 2, the cubic power-iteration that sharpens
5505/// the returned scalar `max_abs` toward the true supremum of `|γ(u)|` over the
5506/// H-unit sphere (which can exceed the per-eigenvector maximum). That scalar is
5507/// the ONLY thing Phase 2 affects — the per-direction `directional` vector,
5508/// which drives [`laplace_trustworthiness_from_skewness`]'s direction selection
5509/// AND its own internally-recomputed `max_abs_skewness`, comes entirely from
5510/// Phase 1. The #784 block-local REML correction
5511/// (`block_local_sampled_correction`) consumes `directional` and uses `max_abs`
5512/// only for a `> 0` finiteness guard that Phase 1 already satisfies, so it
5513/// passes `false` and skips Phase 2's multi-probe O(probes·iters·np) refinement
5514/// on every inner evaluation. Diagnostic callers that report the true supremum
5515/// pass `true`.
5516pub fn laplace_directional_cubic_diagnostic(
5517    hessian: &Array2<f64>,
5518    design: &DesignMatrix,
5519    c_weights: &Array1<f64>,
5520    refine_supremum: bool,
5521) -> Result<(f64, Array1<f64>), String> {
5522    let p = hessian.nrows();
5523    if p == 0 || hessian.ncols() != p {
5524        return Ok((0.0, Array1::zeros(0)));
5525    }
5526
5527    let sym_h = (hessian + &hessian.t()) * 0.5;
5528    let (evals, evecs) = sym_h
5529        .eigh(Side::Lower)
5530        .map_err(|e| format!("directional cubic diagnostic eigendecomposition failed: {e}"))?;
5531    let max_eval = evals.iter().fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
5532    let tol = (max_eval * 1.0e-12).max(1.0e-14);
5533    let mut directional = Array1::<f64>::zeros(p);
5534    let mut max_abs = 0.0_f64;
5535
5536    // Build the whitening transform L^{-1} where H = L L^T, so that
5537    // the standardized cubic along whitened direction u is:
5538    //   gamma(u) = T[L^{-T}u, L^{-T}u, L^{-T}u]  for ||u||=1
5539    // Eigenvector directions v_r satisfy u_r = lambda_r^{1/2} v_r (after
5540    // appropriate normalization), so gamma_r = T[v_r,v_r,v_r] / lambda_r^{3/2}.
5541
5542    // Phase 1: evaluate gamma_r for all positive-curvature eigenvectors.
5543    //
5544    // Every direction here contracts the SAME design against a different
5545    // eigenvector, so the whole phase is one `X V` product. Issuing it as p
5546    // independent GEMVs re-streamed `X` from memory p times and made this the
5547    // single largest cost in a fit profile; batching it hands faer a GEMM that
5548    // reuses each row of `X` across all directions at once.
5549    let positive: Vec<usize> = (0..p).filter(|&r| evals[r] > tol).collect();
5550    if !positive.is_empty() {
5551        let mut directions = Array2::<f64>::zeros((p, positive.len()));
5552        for (slot, &r) in positive.iter().enumerate() {
5553            directions.column_mut(slot).assign(&evecs.column(r));
5554        }
5555        let cubics = directional_cubic_contractions(design, c_weights, &directions.view());
5556        for (slot, &r) in positive.iter().enumerate() {
5557            let gamma = cubics[slot] / evals[r].powf(1.5);
5558            directional[r] = if gamma.is_finite() { gamma } else { 0.0 };
5559            max_abs = max_abs.max(directional[r].abs());
5560        }
5561    }
5562
5563    // Phase 2: power-iteration refinement in whitened space.
5564    //
5565    // The supremum of |gamma(u)| over ||u||_H=1 can exceed the max over
5566    // eigenvectors. We approximate it with a few rounds of cubic power
5567    // iteration: given current direction v, the gradient of T[v,v,v] w.r.t.
5568    // v on the H-unit sphere is 3 T[·,v,v] projected onto the tangent space.
5569    // Since T[·,v,v] = X^T diag(c_i (x_i^T v)^2) which is a matrix-vector
5570    // product, each iteration is O(np).
5571    //
5572    // We seed from the eigenvector with largest |gamma_r| and also from a
5573    // few random probe directions.
5574    if refine_supremum && p >= 2 {
5575        // Build H^{-1/2} columns for whitening: H^{-1/2} = V diag(1/sqrt(lam)) V^T
5576        // We need it to map whitened u -> original v = H^{-1/2} u, and
5577        // H^{1/2} to project back: H^{1/2} v = V diag(sqrt(lam)) V^T v.
5578        let positive_mask: Vec<bool> = evals.iter().map(|&ev| ev > tol).collect();
5579        let n_pos = positive_mask.iter().filter(|&&m| m).count();
5580        if n_pos >= 2 {
5581            let max_abs_from_probes = cubic_power_iteration_refinement(
5582                design,
5583                c_weights,
5584                &evals,
5585                &evecs,
5586                &positive_mask,
5587                n_pos,
5588            );
5589            if max_abs_from_probes > max_abs {
5590                max_abs = max_abs_from_probes;
5591            }
5592        }
5593    }
5594
5595    Ok((max_abs, directional))
5596}
5597
5598/// Row-panel height for the batched contraction, chosen so one panel of
5599/// projections stays inside a few MiB regardless of how many directions are
5600/// batched: `rows × k ≲ 2^21` doubles (16 MiB).
5601const CUBIC_PANEL_DOUBLES: usize = 1 << 21;
5602
5603/// Compute `T[v_r,v_r,v_r] = Σ_i c_i (x_iᵀ v_r)³` for EVERY column `v_r` of
5604/// `directions` (p × k) in one pass.
5605///
5606/// The single-direction [`directional_cubic_contraction`] forms `X v`, so
5607/// calling it once per direction forms `X v_1, …, X v_k` — which is the GEMM
5608/// `X V` spelled as k separate GEMVs. The diagnostic's phase 1 does exactly
5609/// that over every positive-curvature eigenvector, so the whole O(n·p²) step
5610/// was running at BLAS-2 intensity: each GEMV re-streams all of `X` from
5611/// memory to reuse a single vector. Forming the product once lets the rows of
5612/// `X` be reused across all k directions while they are in cache, which is the
5613/// entire difference between a memory-bound and a compute-bound kernel.
5614///
5615/// Rows are processed in panels so the intermediate never scales with `n·k`.
5616fn directional_cubic_contractions(
5617    design: &DesignMatrix,
5618    c_weights: &Array1<f64>,
5619    directions: &ArrayView2<f64>,
5620) -> Array1<f64> {
5621    let k = directions.ncols();
5622    let mut cubics = Array1::<f64>::zeros(k);
5623    if k == 0 {
5624        return cubics;
5625    }
5626    match design.as_sparse() {
5627        Some(x_sparse) => {
5628            // One structural pass over the CSC nonzeros scatters into all k
5629            // projection columns at once, instead of k passes that each walk
5630            // the same index arrays.
5631            let (symbolic, values) = x_sparse.as_ref().parts();
5632            let col_ptr = symbolic.col_ptr();
5633            let row_idx = symbolic.row_idx();
5634            let rows = x_sparse.nrows().min(c_weights.len());
5635            if rows == 0 {
5636                return cubics;
5637            }
5638            let panel = (CUBIC_PANEL_DOUBLES / k).clamp(1, rows);
5639            let mut start = 0;
5640            while start < rows {
5641                let stop = (start + panel).min(rows);
5642                let mut projections = Array2::<f64>::zeros((stop - start, k));
5643                for col in 0..x_sparse.ncols() {
5644                    let coeffs = directions.row(col);
5645                    for ptr in col_ptr[col]..col_ptr[col + 1] {
5646                        let row = row_idx[ptr];
5647                        if row < start || row >= stop {
5648                            continue;
5649                        }
5650                        let value = values[ptr];
5651                        let mut target = projections.row_mut(row - start);
5652                        for r in 0..k {
5653                            target[r] += value * coeffs[r];
5654                        }
5655                    }
5656                }
5657                for (offset, i) in (start..stop).enumerate() {
5658                    let weight = c_weights[i];
5659                    let row = projections.row(offset);
5660                    for r in 0..k {
5661                        cubics[r] += weight * row[r].powi(3);
5662                    }
5663                }
5664                start = stop;
5665            }
5666        }
5667        None => {
5668            let x_dense = design.to_dense_cow();
5669            let x_dense = x_dense.as_ref();
5670            let rows = x_dense.nrows().min(c_weights.len());
5671            if rows == 0 {
5672                return cubics;
5673            }
5674            let panel = (CUBIC_PANEL_DOUBLES / k).clamp(1, rows);
5675            let mut start = 0;
5676            while start < rows {
5677                let stop = (start + panel).min(rows);
5678                let projections = fast_ab(&x_dense.slice(s![start..stop, ..]), directions);
5679                for (offset, i) in (start..stop).enumerate() {
5680                    let weight = c_weights[i];
5681                    let row = projections.row(offset);
5682                    for r in 0..k {
5683                        cubics[r] += weight * row[r].powi(3);
5684                    }
5685                }
5686                start = stop;
5687            }
5688        }
5689    }
5690    for value in cubics.iter_mut() {
5691        if !value.is_finite() {
5692            *value = 0.0;
5693        }
5694    }
5695    cubics
5696}
5697
5698/// Compute T[v,v,v] = Σ_i c_i (x_i^T v)^3 for a given direction v.
5699fn directional_cubic_contraction(
5700    design: &DesignMatrix,
5701    c_weights: &Array1<f64>,
5702    v: &ArrayView1<f64>,
5703) -> f64 {
5704    match design.as_sparse() {
5705        Some(x_sparse) => {
5706            let (symbolic, values) = x_sparse.as_ref().parts();
5707            let col_ptr = symbolic.col_ptr();
5708            let row_idx = symbolic.row_idx();
5709            let mut row_scores = vec![0.0_f64; x_sparse.nrows()];
5710            for col in 0..x_sparse.ncols() {
5711                let coeff = v[col];
5712                for ptr in col_ptr[col]..col_ptr[col + 1] {
5713                    row_scores[row_idx[ptr]] += values[ptr] * coeff;
5714                }
5715            }
5716            let mut cubic = 0.0_f64;
5717            for i in 0..row_scores.len().min(c_weights.len()) {
5718                cubic += c_weights[i] * row_scores[i].powi(3);
5719            }
5720            cubic
5721        }
5722        None => {
5723            let x_dense = design.to_dense_cow();
5724            let x_dense = x_dense.as_ref();
5725            let rows = x_dense.nrows().min(c_weights.len());
5726            if rows == 0 {
5727                return 0.0;
5728            }
5729            // `x_i · v` for every row IS `X v`. Issuing it as `rows` separate
5730            // 1-D dots leaves ndarray on its scalar `dot_generic` fallback —
5731            // this crate builds ndarray without the `blas` feature, so its
5732            // `dot` never reaches a GEMV kernel. A profile of a temporal fit
5733            // put 44% of total runtime in that one symbol, called from here
5734            // and from `directional_cubic_gradient` under the power iteration
5735            // below. One faer GEMV does the same arithmetic against the SIMD
5736            // microkernels. The sparse arm above already batches this way.
5737            let projections = fast_av(&x_dense.slice(s![..rows, ..]), v);
5738            let mut cubic = 0.0_f64;
5739            for i in 0..rows {
5740                cubic += c_weights[i] * projections[i].powi(3);
5741            }
5742            cubic
5743        }
5744    }
5745}
5746
5747/// Compute the gradient of T[v,v,v] w.r.t. v:  3 X^T diag(c_i (x_i^T v)^2) 1.
5748/// More precisely: ∂/∂v T[v,v,v] = 3 Σ_i c_i (x_i^T v)^2 x_i.
5749fn directional_cubic_gradient(
5750    design: &DesignMatrix,
5751    c_weights: &Array1<f64>,
5752    v: &Array1<f64>,
5753) -> Array1<f64> {
5754    let p = v.len();
5755    match design.as_sparse() {
5756        Some(x_sparse) => {
5757            let (symbolic, values) = x_sparse.as_ref().parts();
5758            let col_ptr = symbolic.col_ptr();
5759            let row_idx = symbolic.row_idx();
5760            let n = x_sparse.nrows();
5761            let mut row_scores = vec![0.0_f64; n];
5762            for col in 0..x_sparse.ncols() {
5763                let coeff = v[col];
5764                for ptr in col_ptr[col]..col_ptr[col + 1] {
5765                    row_scores[row_idx[ptr]] += values[ptr] * coeff;
5766                }
5767            }
5768            // quadratic weights: 3 c_i (x_i^T v)^2
5769            let mut quad_weights = vec![0.0_f64; n];
5770            for i in 0..n.min(c_weights.len()) {
5771                quad_weights[i] = 3.0 * c_weights[i] * row_scores[i] * row_scores[i];
5772            }
5773            // X^T quad_weights
5774            let mut grad = Array1::<f64>::zeros(p);
5775            for col in 0..x_sparse.ncols() {
5776                let mut acc = 0.0_f64;
5777                for ptr in col_ptr[col]..col_ptr[col + 1] {
5778                    acc += values[ptr] * quad_weights[row_idx[ptr]];
5779                }
5780                grad[col] = acc;
5781            }
5782            grad
5783        }
5784        None => {
5785            let x_dense = design.to_dense_cow();
5786            let x_dense = x_dense.as_ref();
5787            let rows = x_dense.nrows().min(c_weights.len());
5788            if rows == 0 {
5789                return Array1::<f64>::zeros(p);
5790            }
5791            // Same two products the sparse arm above forms explicitly:
5792            // `X v` for the projections, then `Xᵀ w` for the gradient. Written
5793            // row-at-a-time this was a scalar 1-D dot plus a hand-rolled
5794            // `grad += w · row` inner loop, both of which show up in a fit
5795            // profile (`dot_generic` and `scaled_add`, together the single
5796            // largest cost in a temporal fit). Two faer GEMVs replace the
5797            // whole nest.
5798            let x_rows = x_dense.slice(s![..rows, ..]);
5799            let projections = fast_av(&x_rows, v);
5800            let mut quad_weights = Array1::<f64>::zeros(rows);
5801            for i in 0..rows {
5802                quad_weights[i] = 3.0 * c_weights[i] * projections[i] * projections[i];
5803            }
5804            fast_atv(&x_rows, &quad_weights)
5805        }
5806    }
5807}
5808
5809/// Power-iteration refinement for the supremum of |gamma(u)| over ||u||_H = 1.
5810///
5811/// Seeds from the best eigenvector direction plus deterministic probe
5812/// directions constructed from pairs of eigenvectors. Runs a few Riemannian
5813/// gradient ascent steps on the whitened unit sphere.
5814fn cubic_power_iteration_refinement(
5815    design: &DesignMatrix,
5816    c_weights: &Array1<f64>,
5817    evals: &Array1<f64>,
5818    evecs: &Array2<f64>,
5819    positive_mask: &[bool],
5820    n_pos: usize,
5821) -> f64 {
5822    let p = evals.len();
5823    let max_probes = 8;
5824    let max_iters = 5;
5825
5826    // Helper: convert whitened u -> original v = Σ_r (u_r / sqrt(lam_r)) * evec_r
5827    // (only over positive eigenspace).
5828    let to_original = |u: &Array1<f64>| -> Array1<f64> {
5829        let mut v = Array1::<f64>::zeros(p);
5830        let mut idx = 0;
5831        for r in 0..p {
5832            if positive_mask[r] {
5833                let scale = u[idx] / evals[r].sqrt();
5834                let col = evecs.column(r);
5835                for j in 0..p {
5836                    v[j] += scale * col[j];
5837                }
5838                idx += 1;
5839            }
5840        }
5841        v
5842    };
5843
5844    // Helper: project original-space vector to whitened: u_j = sqrt(lam_r) (evec_r^T g)
5845    let to_whitened = |g: &Array1<f64>| -> Array1<f64> {
5846        let mut u = Array1::<f64>::zeros(n_pos);
5847        let mut idx = 0;
5848        for r in 0..p {
5849            if positive_mask[r] {
5850                u[idx] = evals[r].sqrt() * evecs.column(r).dot(g);
5851                idx += 1;
5852            }
5853        }
5854        u
5855    };
5856
5857    // Evaluate |gamma(u)| for whitened direction u.
5858    let eval_gamma = |u: &Array1<f64>| -> f64 {
5859        let norm = u.dot(u).sqrt();
5860        if norm < 1e-30 {
5861            return 0.0;
5862        }
5863        let u_normed: Array1<f64> = u / norm;
5864        let v = to_original(&u_normed);
5865        // gamma = T[v,v,v] since v already has ||v||_H = 1
5866        let cubic = directional_cubic_contraction(design, c_weights, &v.view());
5867        if cubic.is_finite() { cubic.abs() } else { 0.0 }
5868    };
5869
5870    // One step of Riemannian gradient ascent on the whitened sphere for |T[v,v,v]|.
5871    let refine_step = |u: &Array1<f64>| -> Array1<f64> {
5872        let norm = u.dot(u).sqrt();
5873        if norm < 1e-30 {
5874            return u.clone();
5875        }
5876        let u_normed: Array1<f64> = u / norm;
5877        let v = to_original(&u_normed);
5878        // Gradient of T[v,v,v] w.r.t. v in original space
5879        let grad_v = directional_cubic_gradient(design, c_weights, &v);
5880        // Map to whitened space
5881        let mut grad_u = to_whitened(&grad_v);
5882        // Project onto tangent plane of sphere: grad - (grad . u) u
5883        let dot = grad_u.dot(&u_normed);
5884        grad_u.scaled_add(-dot, &u_normed);
5885        // Sign: we want to maximize |T|, so follow sign(T) * grad
5886        let cubic_val = directional_cubic_contraction(design, c_weights, &v.view());
5887        let sign = if cubic_val >= 0.0 { 1.0 } else { -1.0 };
5888        let step_size = 0.3;
5889        let mut u_new = &u_normed + &(&grad_u * (sign * step_size));
5890        let new_norm = u_new.dot(&u_new).sqrt();
5891        if new_norm > 1e-30 {
5892            u_new /= new_norm;
5893        }
5894        u_new
5895    };
5896
5897    let mut best = 0.0_f64;
5898
5899    // Build seed directions:
5900    // (a) The eigenvector with largest |gamma_r| (already computed by caller,
5901    //     but we re-derive the whitened form here).
5902    // (b) Deterministic probe directions from pairs of top eigenvectors:
5903    //     (e_i + e_j) / sqrt(2) and (e_i - e_j) / sqrt(2) in whitened space.
5904    let mut seeds: Vec<Array1<f64>> = Vec::with_capacity(max_probes);
5905
5906    // Seed (a): each eigenvector is a standard basis vector in whitened space.
5907    // Find the one with largest |gamma|.
5908    let mut best_eig_idx = 0;
5909    let mut best_eig_gamma = 0.0_f64;
5910    for j in 0..n_pos {
5911        let mut u = Array1::<f64>::zeros(n_pos);
5912        u[j] = 1.0;
5913        let g = eval_gamma(&u);
5914        if g > best_eig_gamma {
5915            best_eig_gamma = g;
5916            best_eig_idx = j;
5917        }
5918    }
5919    best = best.max(best_eig_gamma);
5920    let mut u_best = Array1::<f64>::zeros(n_pos);
5921    u_best[best_eig_idx] = 1.0;
5922    seeds.push(u_best);
5923
5924    // Seed (b): pairwise combinations of the top few eigenvectors.
5925    let n_top = n_pos.min(4);
5926    for i in 0..n_top {
5927        for j in (i + 1)..n_top {
5928            if seeds.len() >= max_probes {
5929                break;
5930            }
5931            let inv_sqrt2 = std::f64::consts::FRAC_1_SQRT_2;
5932            let mut u_plus = Array1::<f64>::zeros(n_pos);
5933            u_plus[i] = inv_sqrt2;
5934            u_plus[j] = inv_sqrt2;
5935            seeds.push(u_plus);
5936            if seeds.len() < max_probes {
5937                let mut u_minus = Array1::<f64>::zeros(n_pos);
5938                u_minus[i] = inv_sqrt2;
5939                u_minus[j] = -inv_sqrt2;
5940                seeds.push(u_minus);
5941            }
5942        }
5943    }
5944
5945    // Run power iteration from each seed.
5946    for seed in &seeds {
5947        let mut u = seed.clone();
5948        for _ in 0..max_iters {
5949            u = refine_step(&u);
5950        }
5951        let g = eval_gamma(&u);
5952        best = best.max(g);
5953    }
5954
5955    best
5956}
5957
5958// ───────────────── #1521 laplace-sampler contract re-exports ─────────────────
5959//
5960// The neutral DATA carriers + the caller-supplied [`BlockExcessTarget`]
5961// evaluator + the pure threshold math were contract-downed to the neutral
5962// `gam-problem` crate (#1521) so gam-solve (whose `Gam784BlockTarget`
5963// IMPLEMENTS `BlockExcessTarget`) and this gam-inference-tier sampler share one
5964// set of types without an SCC edge. The COMPUTATION (NUTS, importance sampling,
5965// the directional-cubic eigen diagnostic) stays UP in this module and
5966// constructs these types under their original names via this re-export.
5967pub use gam_problem::laplace_sampler_contract::{
5968    BLOCK_GH_MAX_DIM, BlockExcessTarget, BlockQuadratureMarginal, BlockQuadratureMoments,
5969    LaplaceTrustworthiness, laplace_skewness_threshold,
5970    laplace_trustworthiness_from_skewness,
5971};
5972
5973/// Monolith (gam-inference-tier) implementor of the contract-downed
5974/// [`LaplaceMarginalCorrector`](gam_problem::laplace_sampler_contract::LaplaceMarginalCorrector):
5975/// wraps the `hmc_io` directional-cubic eigen diagnostic and the
5976/// deterministic #784 block correction.
5977pub struct HmcIoLaplaceMarginalCorrector;
5978
5979impl gam_problem::laplace_sampler_contract::LaplaceMarginalCorrector
5980    for HmcIoLaplaceMarginalCorrector
5981{
5982    fn directional_cubic_diagnostic(
5983        &self,
5984        hessian: &Array2<f64>,
5985        design: &DesignMatrix,
5986        c_weights: &Array1<f64>,
5987        refine_supremum: bool,
5988    ) -> Result<(f64, Array1<f64>), String> {
5989        laplace_directional_cubic_diagnostic(hessian, design, c_weights, refine_supremum)
5990    }
5991
5992    fn block_quadrature_marginal_correction(
5993        &self,
5994        target: &dyn BlockExcessTarget,
5995    ) -> Result<BlockQuadratureMarginal, String> {
5996        block_quadrature_marginal_correction(target)
5997    }
5998}
5999
6000/// Evaluate the block-local marginal correction `Δ_b` and its ρ-gradient by
6001/// deterministic Gauss-Hermite quadrature against the local Laplace Gaussian
6002/// (issue #784).
6003///
6004/// # Math
6005///
6006/// Integrate `t ~ q = N(0, diag(1/λ_r))` (the local Laplace Gaussian in the
6007/// block subspace; standard-normal nodes `z_s` give `t_{s,r}=z_{s,r}/√λ_r`).
6008/// With the non-Gaussian remainder `ΔF` defined on [`BlockExcessTarget`],
6009///
6010///   exp(Δ_b) = E_q[ exp(−ΔF(t)) ],
6011///
6012/// computed via a numerically-stable weighted log-sum-exp. The ρ-gradient follows
6013/// from differentiating `Δ_b = log E_q[e^{−ΔF}]` (the `q`-Gaussian normalizer
6014/// `½Σ log(2π/λ_r)` cancels against `A_Lap`, leaving only the `ΔF` channel):
6015///
6016///   ∂Δ_b/∂ρ_k = E_p[ −∂ΔF/∂ρ_k ],   p ∝ q·e^{−ΔF},
6017///
6018/// i.e. the normalized quadrature average of `−∂ΔF/∂ρ_k`. Because value,
6019/// gradient, and all envelope moments come from the same nodes and target, they
6020/// are mutually consistent — the contract the outer REML needs.
6021///
6022/// The five-node rule is exact for standard-normal polynomials through degree
6023/// nine. A separate three-node rule (degree five) supplies a deterministic
6024/// rule-difference estimate for the realized non-polynomial integrand; the
6025/// caller admits the correction only when that difference resolves both `Δ_b`
6026/// and the `O(1/n_eff)` Laplace floor.
6027pub fn block_quadrature_marginal_correction<T: BlockExcessTarget + ?Sized>(
6028    target: &T,
6029) -> Result<BlockQuadratureMarginal, String> {
6030    let m = target.block_dim();
6031    let k = target.rho_dim();
6032    if m == 0 {
6033        return Ok(BlockQuadratureMarginal {
6034            value: 0.0,
6035            rho_gradient: Array1::zeros(k),
6036            quadrature_error: 0.0,
6037            node_count: 0,
6038            moments: None,
6039        });
6040    }
6041    let lambdas = target.block_curvatures();
6042    if lambdas.len() != m {
6043        return Err(format!(
6044            "block_quadrature_marginal_correction: block_curvatures len {} != block_dim {m}",
6045            lambdas.len()
6046        ));
6047    }
6048    let inv_sqrt_lambda: Array1<f64> = lambdas.mapv(|l| {
6049        if l > 0.0 {
6050            1.0 / l.sqrt()
6051        } else {
6052            // A non-positive block curvature means the mode is not a strict
6053            // minimum in this direction; the Laplace Gaussian is undefined
6054            // there. Reject rather than fabricate a correction.
6055            f64::NAN
6056        }
6057    });
6058    if inv_sqrt_lambda.iter().any(|v| !v.is_finite()) {
6059        return Err(
6060            "block_quadrature_marginal_correction: non-positive block curvature (mode is not a \
6061             strict local minimum in an integrated direction)"
6062                .to_string(),
6063        );
6064    }
6065    if m > BLOCK_GH_MAX_DIM {
6066        return Err(format!(
6067            "block-local Gauss-Hermite correction supports at most {BLOCK_GH_MAX_DIM} \
6068             curvature-heavy directions, got {m}"
6069        ));
6070    }
6071
6072    let fine_rule = crate::rho_posterior::standard_normal_gh_rule(5)
6073        .expect("the five-node standard-normal Gauss-Hermite rule is built in");
6074    let mut fine_nodes = Vec::new();
6075    crate::rho_posterior::enumerate_gh_product(
6076        m,
6077        fine_rule,
6078        0,
6079        &mut Array1::zeros(m),
6080        0.0,
6081        &mut fine_nodes,
6082    );
6083    let node_count = fine_nodes.len();
6084
6085    // Streaming, numerically-stable accumulation of the weighted log-sum-exp value,
6086    // the explicit gradient channel `E_p[−∂ΔF/∂ρ]`, AND the gradient-channel
6087    // moments `E_p[t]`, `E_p[t tᵀ]`, `E_p[ngs]`, `E_p[t ⊗ ngs]` needed by the
6088    // exact (b)–(d) channel assembly (gradient exactness contract above).
6089    // Weights are kept relative to a running maximum log-weight: whenever a
6090    // new maximum arrives, every accumulator is rescaled by
6091    // `exp(max_old − max_new) ≤ 1`, so each per-draw relative weight is ≤ 1
6092    // and the sums never overflow. Infeasible / divergent draws contribute
6093    // zero weight rather than poisoning the estimate.
6094    let n_obs = target.base_neg_score()?.len();
6095    let mut max_lw = f64::NEG_INFINITY;
6096    let mut sum_w = 0.0_f64;
6097    let mut grad_acc = Array1::<f64>::zeros(k);
6098    let mut e_t_acc = Array1::<f64>::zeros(m);
6099    let mut e_tt_acc = Array2::<f64>::zeros((m, m));
6100    let mut e_ngs_acc = Array1::<f64>::zeros(n_obs);
6101    let mut e_t_ngs_acc = Array2::<f64>::zeros((n_obs, m));
6102
6103    // Materialize all transformed quadrature nodes into the columns of `draws`
6104    // (`m × n_draws`). The per-node design matvec `s = X_t·(V_b·t_s)` is batched
6105    // into two BLAS-3 products over all columns at once (the #1082 hot path),
6106    // instead of separate BLAS-2 matvecs.
6107    let mut draws = Array2::<f64>::zeros((m, node_count));
6108    for (s, (z, _)) in fine_nodes.iter().enumerate() {
6109        for r in 0..m {
6110            draws[(r, s)] = z[r] * inv_sqrt_lambda[r];
6111        }
6112    }
6113    let batched = target.excess_with_displaced_neg_score_batch(&draws);
6114
6115    let mut t = Array1::<f64>::zeros(m);
6116    for (sidx, (excess, displaced_ngs)) in batched.into_iter().enumerate() {
6117        t.assign(&draws.column(sidx));
6118        if !excess.is_finite() {
6119            continue;
6120        }
6121        let Some(ngs) = displaced_ngs else {
6122            // A finite excess always carries a score; absence means infeasible.
6123            continue;
6124        };
6125        let lw = fine_nodes[sidx].1 - excess;
6126        if lw > max_lw {
6127            // exp(−∞ − lw) = 0 zeroes the (empty) accumulators on the first
6128            // feasible draw, so no special-casing is needed.
6129            let rescale = (max_lw - lw).exp();
6130            sum_w *= rescale;
6131            grad_acc *= rescale;
6132            e_t_acc *= rescale;
6133            e_tt_acc *= rescale;
6134            e_ngs_acc *= rescale;
6135            e_t_ngs_acc *= rescale;
6136            max_lw = lw;
6137        }
6138        let w = (lw - max_lw).exp();
6139        sum_w += w;
6140        // Explicit channel: −∂ΔF/∂ρ.
6141        grad_acc.scaled_add(-w, &target.excess_rho_gradient(&t));
6142        // Moment channels (score already computed in the fused call above).
6143        if ngs.len() != n_obs {
6144            return Err(format!(
6145                "block_quadrature_marginal_correction: displaced_neg_score len {} != {n_obs}",
6146                ngs.len()
6147            ));
6148        }
6149        e_t_acc.scaled_add(w, &t);
6150        e_ngs_acc.scaled_add(w, &ngs);
6151        for r in 0..m {
6152            let wt_r = w * t[r];
6153            for q in 0..m {
6154                e_tt_acc[(q, r)] += wt_r * t[q];
6155            }
6156            e_t_ngs_acc.column_mut(r).scaled_add(wt_r, &ngs);
6157        }
6158    }
6159    if !max_lw.is_finite() {
6160        return Err(
6161            "block_quadrature_marginal_correction: all fine quadrature nodes were infeasible"
6162                .to_string(),
6163        );
6164    }
6165    let value = max_lw + sum_w.ln();
6166    // Self-normalized importance-weighted gradient E_p[−∂ΔF/∂ρ] and moments.
6167    let (rho_gradient, moments) = if sum_w > 0.0 {
6168        (
6169            grad_acc / sum_w,
6170            Some(BlockQuadratureMoments {
6171                e_t: e_t_acc / sum_w,
6172                e_tt: e_tt_acc / sum_w,
6173                e_neg_score: e_ngs_acc / sum_w,
6174                e_t_neg_score: e_t_ngs_acc / sum_w,
6175            }),
6176        )
6177    } else {
6178        (Array1::zeros(k), None)
6179    };
6180    // Paired-rule error estimate: repeat only the scalar integral with the
6181    // three-node (degree-five) product rule. The fine/coarse difference is in
6182    // the same log-marginal units as Δ_b and is deterministic across rho.
6183    let coarse_rule = crate::rho_posterior::standard_normal_gh_rule(3)
6184        .expect("the three-node standard-normal Gauss-Hermite rule is built in");
6185    let mut coarse_nodes = Vec::new();
6186    crate::rho_posterior::enumerate_gh_product(
6187        m,
6188        coarse_rule,
6189        0,
6190        &mut Array1::zeros(m),
6191        0.0,
6192        &mut coarse_nodes,
6193    );
6194    let mut coarse_draws = Array2::<f64>::zeros((m, coarse_nodes.len()));
6195    for (s, (z, _)) in coarse_nodes.iter().enumerate() {
6196        for r in 0..m {
6197            coarse_draws[(r, s)] = z[r] * inv_sqrt_lambda[r];
6198        }
6199    }
6200    let coarse_values = target.excess_batch(&coarse_draws);
6201    let coarse_max = coarse_values
6202        .iter()
6203        .enumerate()
6204        .filter_map(|(idx, excess)| {
6205            excess.is_finite().then_some(coarse_nodes[idx].1 - excess)
6206        })
6207        .fold(f64::NEG_INFINITY, f64::max);
6208    if !coarse_max.is_finite() {
6209        return Err(
6210            "block_quadrature_marginal_correction: every coarse quadrature node was infeasible"
6211                .to_string(),
6212        );
6213    }
6214    let coarse_sum = coarse_values
6215        .iter()
6216        .enumerate()
6217        .filter_map(|(idx, excess)| {
6218            excess
6219                .is_finite()
6220                .then_some((coarse_nodes[idx].1 - excess - coarse_max).exp())
6221        })
6222        .sum::<f64>();
6223    let coarse_value = coarse_max + coarse_sum.ln();
6224    let quadrature_error = (value - coarse_value).abs();
6225
6226    if !value.is_finite() || rho_gradient.iter().any(|v| !v.is_finite()) {
6227        return Err(
6228            "block_quadrature_marginal_correction: produced a non-finite correction or gradient"
6229                .to_string(),
6230        );
6231    }
6232    if let Some(mo) = moments.as_ref()
6233        && (mo.e_t.iter().any(|v| !v.is_finite())
6234            || mo.e_tt.iter().any(|v| !v.is_finite())
6235            || mo.e_neg_score.iter().any(|v| !v.is_finite())
6236            || mo.e_t_neg_score.iter().any(|v| !v.is_finite()))
6237    {
6238        return Err(
6239            "block_quadrature_marginal_correction: produced non-finite gradient-channel moments"
6240                .to_string(),
6241        );
6242    }
6243
6244    Ok(BlockQuadratureMarginal {
6245        value,
6246        rho_gradient,
6247        quadrature_error,
6248        node_count,
6249        moments,
6250    })
6251}
6252
6253/// Result of joint (β, ρ) sampling.
6254#[derive(Clone, Debug)]
6255pub struct JointBetaRhoResult {
6256    /// Coefficient samples: shape (n_total_samples, n_beta)
6257    pub beta_samples: Array2<f64>,
6258    /// Log-smoothing parameter samples: shape (n_total_samples, n_rho)
6259    pub rho_samples: Array2<f64>,
6260    /// Posterior mean of β
6261    pub beta_mean: Array1<f64>,
6262    /// Adaptive inverse-link parameter samples: shape (n_total_samples, n_link_params)
6263    pub link_param_samples: Array2<f64>,
6264    /// Posterior mean of adaptive inverse-link parameters
6265    pub link_param_mean: Array1<f64>,
6266    /// Posterior mean of ρ
6267    pub rho_mean: Array1<f64>,
6268    /// R-hat diagnostic
6269    pub rhat: f64,
6270    /// Effective sample size
6271    pub ess: f64,
6272    /// Whether sampling converged
6273    pub converged: bool,
6274    /// Max skewness that triggered this sampling
6275    pub trigger_skewness: f64,
6276}
6277
6278/// Joint (β, ρ) posterior target for NUTS.
6279///
6280/// Samples from p(β, ρ | y) ∝ p(y|β) p(β|ρ) p(ρ) directly,
6281/// completely bypassing the Laplace approximation.
6282///
6283/// The parameter vector is [z_β; ρ] where z_β = L⁻¹(β - μ) is the
6284/// whitened β, ρ is the raw log-smoothing parameters, and adaptive inverse-link
6285/// parameters follow when the binomial link has fitted shape/mixing parameters.
6286struct JointBetaRhoPosterior {
6287    data: SharedData,
6288    /// L where LL' = H⁻¹ (whitening for β block)
6289    chol: Array2<f64>,
6290    /// L' for chain rule
6291    chol_t: Array2<f64>,
6292    /// Joint likelihood specification (response + parameterized link).
6293    likelihood: LikelihoodSpec,
6294    /// Weight of every quadratic penalty relative to the fitted likelihood.
6295    /// This is `1 / coefficient_covariance_scale`: non-unit only for the
6296    /// profiled Gaussian convention whose stored Hessian is scale-free.
6297    penalty_scale: f64,
6298    /// Dimension of β
6299    n_beta: usize,
6300    /// Dimension of ρ
6301    n_rho: usize,
6302    /// Dimension of adaptive inverse-link parameters
6303    n_link_params: usize,
6304    /// LAML-converged adaptive inverse-link parameters (used only to initialize chains)
6305    link_param_mode: Array1<f64>,
6306    /// Canonical penalties in the transformed basis.
6307    penalty_canonical: Vec<gam_terms::construction::CanonicalPenalty>,
6308    /// Fixed prior on rho used by the sampled target.
6309    rho_prior: RhoPrior,
6310    /// LAML-converged ρ (used only to initialize chains)
6311    rho_mode: Array1<f64>,
6312    /// Whether to add the identifiable-subspace Jeffreys/Firth term to the
6313    /// target
6314    firth_enabled: bool,
6315    /// One-deep cache for the structural penalty pseudo-logdet and its
6316    /// ρ-gradient. NUTS tree-doubling and U-turn checks repeatedly evaluate
6317    /// the joint log-posterior at the same `rho` bytes, so a single-slot
6318    /// cache keyed on the exact f64 bit pattern of `rho` avoids redundant
6319    /// SVD/eigendecompositions inside `PenaltyPseudologdet::from_penalties`.
6320    /// `Mutex` (not `RefCell`) because chains share the target via
6321    /// `Arc<Target>` and run in parallel via rayon.
6322    penalty_logdet_cache: Mutex<Option<(u64, f64, Array1<f64>)>>,
6323}
6324
6325impl JointBetaRhoPosterior {
6326    fn new(
6327        x: ArrayView2<f64>,
6328        y: ArrayView1<f64>,
6329        weights: ArrayView1<f64>,
6330        mode: ArrayView1<f64>,
6331        hessian: ArrayView2<f64>,
6332        penalty_canonical: Vec<gam_terms::construction::CanonicalPenalty>,
6333        rho_mode: ArrayView1<f64>,
6334        likelihood: GlmLikelihoodSpec,
6335        dispersion: gam_solve::model_types::Dispersion,
6336        offset: Option<ArrayView1<f64>>,
6337        rho_prior: RhoPrior,
6338        firth_enabled: bool,
6339    ) -> Result<Self, String> {
6340        let n_samples = x.nrows();
6341        let n_beta = x.ncols();
6342        let n_rho = penalty_canonical.len();
6343
6344        if y.len() != n_samples
6345            || weights.len() != n_samples
6346            || mode.len() != n_beta
6347            || hessian.dim() != (n_beta, n_beta)
6348        {
6349            return Err(HmcError::DimensionMismatch {
6350                reason: format!(
6351                    "Joint HMC geometry mismatch: X={}x{}, y={}, weights={}, mode={}, hessian={:?}",
6352                    n_samples,
6353                    n_beta,
6354                    y.len(),
6355                    weights.len(),
6356                    mode.len(),
6357                    hessian.dim(),
6358                ),
6359            }
6360            .into());
6361        }
6362        for (label, invalid) in [
6363            ("design", first_non_finite(x.iter())),
6364            ("mode", first_non_finite(mode.iter())),
6365            ("hessian", first_non_finite(hessian.iter())),
6366        ] {
6367            if let Some((index, value)) = invalid {
6368                return Err(HmcError::NonFiniteState {
6369                    reason: format!(
6370                        "Joint HMC {label} has non-finite value {value} at flat index {index}"
6371                    ),
6372                }
6373                .into());
6374            }
6375        }
6376        if let Some((row, weight)) = weights
6377            .iter()
6378            .copied()
6379            .enumerate()
6380            .find(|(_, weight)| !(weight.is_finite() && *weight >= 0.0))
6381        {
6382            return Err(HmcError::InvalidConfig {
6383                reason: format!(
6384                    "Joint HMC weight at row {row} must be finite and non-negative, got {weight}"
6385                ),
6386            }
6387            .into());
6388        }
6389        for (index, penalty) in penalty_canonical.iter().enumerate() {
6390            if penalty.total_dim != n_beta
6391                || penalty.col_range.end > n_beta
6392                || penalty.col_range.start > penalty.col_range.end
6393                || penalty.root.ncols() != penalty.col_range.len()
6394            {
6395                return Err(HmcError::DimensionMismatch {
6396                    reason: format!(
6397                        "Joint HMC penalty {index} has total_dim={}, range={:?}, root={:?}, expected total dimension {n_beta}",
6398                        penalty.total_dim,
6399                        penalty.col_range,
6400                        penalty.root.dim(),
6401                    ),
6402                }
6403                .into());
6404            }
6405            if let Some((flat_index, value)) = first_non_finite(penalty.root.iter()) {
6406                return Err(HmcError::NonFiniteState {
6407                    reason: format!(
6408                        "Joint HMC penalty {index} root has non-finite value {value} at flat index {flat_index}"
6409                    ),
6410                }
6411                .into());
6412            }
6413        }
6414
6415        if let Some(offset) = offset.as_ref() {
6416            if offset.len() != n_samples {
6417                return Err(HmcError::DimensionMismatch {
6418                    reason: format!(
6419                        "Joint HMC offset length {} does not match {} observations",
6420                        offset.len(),
6421                        n_samples
6422                    ),
6423                }
6424                .into());
6425            }
6426            if !offset.iter().all(|v| v.is_finite()) {
6427                return Err(HmcError::NonFiniteState {
6428                    reason: "Joint HMC offset contains NaN or Inf values".to_string(),
6429                }
6430                .into());
6431            }
6432        }
6433
6434        if rho_mode.len() != n_rho {
6435            return Err(HmcError::DimensionMismatch {
6436                reason: format!(
6437                    "rho_mode length {} != penalty count {}",
6438                    rho_mode.len(),
6439                    n_rho
6440                ),
6441            }
6442            .into());
6443        }
6444
6445        match (&likelihood.spec.response, &likelihood.spec.link) {
6446            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {}
6447            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {}
6448            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {}
6449            (ResponseFamily::Binomial, InverseLink::LatentCLogLog(_)) => {}
6450            (ResponseFamily::Binomial, InverseLink::Sas(_)) => {}
6451            (ResponseFamily::Binomial, InverseLink::BetaLogistic(_)) => {}
6452            (ResponseFamily::Binomial, InverseLink::Mixture(_)) => {}
6453            (ResponseFamily::Binomial, InverseLink::Standard(other)) => {
6454                return Err(HmcError::LinkMismatch {
6455                    reason: format!(
6456                        "Joint HMC binomial response requires a binomial-compatible inverse link; got {:?}",
6457                        other
6458                    ),
6459                }
6460                .into());
6461            }
6462            (ResponseFamily::Gaussian, InverseLink::Standard(StandardLink::Identity)) => {}
6463            (ResponseFamily::Gaussian, _) => {
6464                return Err(HmcError::LinkMismatch {
6465                    reason: "Joint HMC Gaussian requires an identity inverse link".to_string(),
6466                }
6467                .into());
6468            }
6469            (
6470                ResponseFamily::Poisson
6471                | ResponseFamily::Tweedie { .. }
6472                | ResponseFamily::NegativeBinomial { .. }
6473                | ResponseFamily::Gamma,
6474                InverseLink::Standard(StandardLink::Log),
6475            ) => {}
6476            (
6477                ResponseFamily::Poisson
6478                | ResponseFamily::Tweedie { .. }
6479                | ResponseFamily::NegativeBinomial { .. }
6480                | ResponseFamily::Gamma,
6481                _,
6482            ) => {
6483                return Err(HmcError::LinkMismatch {
6484                    reason: "Joint HMC log-link family requires a log inverse link".to_string(),
6485                }
6486                .into());
6487            }
6488            (ResponseFamily::Beta { .. }, InverseLink::Standard(StandardLink::Logit)) => {}
6489            (ResponseFamily::Beta { .. }, _) => {
6490                return Err(HmcError::LinkMismatch {
6491                    reason: "Joint HMC Beta requires a logit inverse link".to_string(),
6492                }
6493                .into());
6494            }
6495            (ResponseFamily::RoystonParmar, _) => {
6496                return Err(HmcError::UnsupportedFamily {
6497                    reason: "Joint HMC fallback is not implemented for RoystonParmar".to_string(),
6498                }
6499                .into());
6500            }
6501        }
6502
6503        validate_firth_likelihood_support(&likelihood.spec, firth_enabled).map_err(String::from)?;
6504        if matches!(
6505            likelihood.spec.response,
6506            ResponseFamily::NegativeBinomial { .. }
6507        ) {
6508            validate_count_responses("negative-binomial joint HMC", &y, &weights)
6509                .map_err(String::from)?;
6510        }
6511        if likelihood.spec.is_binomial() {
6512            validate_binary_responses("binomial joint HMC", &y, &weights).map_err(String::from)?;
6513        }
6514        let (likelihood, cov_scale) =
6515            resolve_hmc_likelihood(likelihood, dispersion).map_err(String::from)?;
6516        let mut eta_at_mode = x.dot(&mode);
6517        if let Some(offset) = offset.as_ref() {
6518            eta_at_mode += offset;
6519        }
6520        let mut score_at_mode = Array1::zeros(n_samples);
6521        gam_solve::pirls::eta_log_likelihood_value_and_score_into(
6522            y,
6523            &eta_at_mode,
6524            &likelihood,
6525            &likelihood.spec.link,
6526            weights,
6527            &mut score_at_mode,
6528        )
6529        .map_err(|error| format!("joint HMC likelihood is invalid at the fitted mode: {error}"))?;
6530
6531        let whitening = hessian_whitening_transform(
6532            hessian,
6533            n_beta,
6534            cov_scale,
6535            "Joint HMC: Hessian Cholesky failed",
6536        )?;
6537        let chol = whitening.chol;
6538        let chol_t = whitening.chol_t;
6539
6540        let data = SharedData {
6541            x: Arc::new(x.to_owned()),
6542            y: Arc::new(y.to_owned()),
6543            weights: Arc::new(weights.to_owned()),
6544            mode: Arc::new(mode.to_owned()),
6545            // The fit's offset and dispersion are fixed likelihood state: the
6546            // joint target must retain both or it samples a different model —
6547            // hard-coding φ = 1 gave a Gaussian fit with σ² = 4 four times its
6548            // true likelihood curvature, and dropping the offset shifted every
6549            // offset model's posterior (finding 18, #2245). The family kernels
6550            // consume the exact resolved metadata below; no scalar default or
6551            // family-parameter side channel exists.
6552            offset: offset.map(|o| Arc::new(o.to_owned())),
6553            likelihood: likelihood.clone(),
6554            n_samples,
6555            dim: n_beta,
6556        };
6557        let link_param_mode = Self::link_param_mode(&likelihood.spec.link);
6558
6559        Ok(Self {
6560            data,
6561            chol,
6562            chol_t,
6563            likelihood: likelihood.spec,
6564            penalty_scale: 1.0 / cov_scale,
6565            n_beta,
6566            n_rho,
6567            n_link_params: link_param_mode.len(),
6568            link_param_mode,
6569            penalty_canonical,
6570            rho_prior,
6571            rho_mode: rho_mode.to_owned(),
6572            firth_enabled,
6573            penalty_logdet_cache: Mutex::new(None),
6574        })
6575    }
6576
6577    /// FNV-1a hash over the raw f64 bit pattern of `rho`.
6578    ///
6579    /// NUTS leapfrog / tree-doubling / U-turn checks revisit identical
6580    /// position vectors byte-for-byte, so exact-equality on `to_bits()`
6581    /// captures the dominant repetition pattern without any tolerance.
6582    #[inline]
6583    fn hash_rho(rho: ndarray::ArrayView1<f64>) -> u64 {
6584        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
6585        for &x in rho.iter() {
6586            h ^= x.to_bits();
6587            h = h.wrapping_mul(0x0000_0100_0000_01b3);
6588        }
6589        h
6590    }
6591
6592    fn link_param_mode(inverse_link: &InverseLink) -> Array1<f64> {
6593        match inverse_link {
6594            InverseLink::Sas(state) | InverseLink::BetaLogistic(state) => {
6595                Array1::from_vec(vec![state.epsilon, state.log_delta])
6596            }
6597            InverseLink::Mixture(state) => state.rho.clone(),
6598            InverseLink::Standard(_) | InverseLink::LatentCLogLog(_) => Array1::zeros(0),
6599        }
6600    }
6601
6602    fn inverse_link_with_params(
6603        &self,
6604        link_params: ndarray::ArrayView1<'_, f64>,
6605    ) -> Result<InverseLink, String> {
6606        match &self.likelihood.link {
6607            InverseLink::Sas(_) => {
6608                if link_params.len() != 2 {
6609                    return Err(format!(
6610                        "SAS link parameter length must be 2, got {}",
6611                        link_params.len()
6612                    ));
6613                }
6614                Ok(InverseLink::Sas(
6615                    gam_solve::mixture_link::sas_link_state_from_raw(
6616                        link_params[0],
6617                        link_params[1],
6618                    )?,
6619                ))
6620            }
6621            InverseLink::BetaLogistic(_) => {
6622                if link_params.len() != 2 || !link_params.iter().all(|v| v.is_finite()) {
6623                    return Err(
6624                        "Beta-Logistic link parameters must be finite with length 2".to_string()
6625                    );
6626                }
6627                Ok(InverseLink::BetaLogistic(
6628                    gam_problem::types::SasLinkState {
6629                        epsilon: link_params[0],
6630                        log_delta: link_params[1],
6631                        delta: link_params[1].exp(),
6632                    },
6633                ))
6634            }
6635            InverseLink::Mixture(state) => {
6636                let rho = link_params.to_owned();
6637                Ok(InverseLink::Mixture(gam_problem::types::MixtureLinkState {
6638                    components: state.components.clone(),
6639                    pi: softmax_last_fixedzero(&rho),
6640                    rho,
6641                }))
6642            }
6643            InverseLink::Standard(_) | InverseLink::LatentCLogLog(_) => {
6644                Ok(self.likelihood.link.clone())
6645            }
6646        }
6647    }
6648
6649    /// Compute the joint log-posterior and gradient.
6650    ///
6651    /// The joint log-posterior is:
6652    ///   log p(β, ρ | y) = ℓ(y|β) + ½ log|I(β)| [if Firth]
6653    ///                    − ½β'S(ρ)β + ½ log|S(ρ)|₊ + log p(ρ) + const
6654    ///
6655    /// This is NOT the REML/LAML objective (which integrates out β). Here β is
6656    /// an explicit parameter being sampled, evaluated at arbitrary values — not
6657    /// just at the mode β̂(ρ).
6658    ///
6659    /// Parameter vector layout: [z_β (whitened, length n_beta); ρ (length n_rho);
6660    /// adaptive inverse-link params (length n_link_params)]
6661    fn compute_joint_logp_and_grad_into(
6662        &self,
6663        params: &Array1<f64>,
6664        out_grad: &mut Array1<f64>,
6665    ) -> f64 {
6666        let n_beta = self.n_beta;
6667        let n_rho = self.n_rho;
6668        let n_link_params = self.n_link_params;
6669
6670        // Split parameter vector — keep as views to avoid two per-step
6671        // `to_owned()` allocations of size n_beta and n_rho.
6672        let z = params.slice(ndarray::s![..n_beta]);
6673        let rho = params.slice(ndarray::s![n_beta..n_beta + n_rho]);
6674        let link_params = params.slice(ndarray::s![n_beta + n_rho..]);
6675        let lambdas = match gam_problem::checked_exp_log_strengths(rho.iter().copied()) {
6676            Ok(values) => Array1::from_vec(values),
6677            Err(_) => {
6678                out_grad.fill(0.0);
6679                return f64::NEG_INFINITY;
6680            }
6681        };
6682
6683        let inverse_link = match self.inverse_link_with_params(link_params) {
6684            Ok(link) => link,
6685            Err(err) => {
6686                log::warn!(
6687                    "[Joint HMC] adaptive inverse-link parameters are invalid: {}",
6688                    err
6689                );
6690                out_grad.fill(0.0);
6691                return f64::NEG_INFINITY;
6692            }
6693        };
6694
6695        // Un-whiten: β = μ + L z
6696        let beta = self.data.mode.as_ref() + &self.chol.dot(&z);
6697
6698        // η = X β (+ fixed fit-time offset)
6699        let mut eta = gam_linalg::faer_ndarray::fast_av(self.data.x.as_ref(), &beta);
6700        if let Some(offset) = self.data.offset.as_ref() {
6701            eta += offset.as_ref();
6702        }
6703
6704        // ---- Log-likelihood ℓ(y|β) and ∇_β ℓ ----
6705        let step_likelihood = LikelihoodSpec {
6706            response: self.likelihood.response.clone(),
6707            link: inverse_link,
6708        };
6709        let (ll, mut grad_ll_beta, grad_link) = match joint_family_logp_grad_and_link_grad(
6710            &step_likelihood,
6711            &self.data,
6712            &eta,
6713            n_link_params,
6714        ) {
6715            Ok(value) => value,
6716            Err(err) => {
6717                log::warn!(
6718                    "[Joint HMC] likelihood target became invalid at the current state: {}",
6719                    err
6720                );
6721                out_grad.fill(0.0);
6722                return f64::NEG_INFINITY;
6723            }
6724        };
6725
6726        let mut firth_logdet = 0.0;
6727        if self.firth_enabled {
6728            // The Jeffreys determinant must use the *sampled* inverse link's
6729            // Fisher-weight jet — the same `step_likelihood` (with the current
6730            // adaptive link parameters) the log-likelihood above was evaluated
6731            // under. Hard-coding logit gave probit/cloglog/SAS/mixture targets
6732            // the wrong determinant and gradient (finding 19, #2245).
6733            match firth_jeffreys_logp_and_grad(&step_likelihood, &self.data, &eta) {
6734                Ok((value, grad_beta_firth)) => {
6735                    firth_logdet = value;
6736                    grad_ll_beta += &grad_beta_firth;
6737                }
6738                Err(err) => {
6739                    log::warn!(
6740                        "[Joint HMC/Firth] Jeffreys target became invalid at the current state: {}",
6741                        err
6742                    );
6743                    out_grad.fill(0.0);
6744                    return f64::NEG_INFINITY;
6745                }
6746            }
6747        }
6748
6749        // ---- Penalty: -0.5 β'S(ρ)β ----
6750        // S(ρ) = Σ_k λ_k S_k where S_k = R_k'R_k (precomputed in penalty_matrices).
6751        // Uses penalty_roots for the efficient ||R_k β||² form.
6752        let mut penalty_val = 0.0;
6753        let mut s_beta = Array1::<f64>::zeros(n_beta);
6754        let mut grad_rho = Array1::<f64>::zeros(n_rho);
6755
6756        // Reuse one max-rank scratch buffer for r_beta = R_k · β_block across
6757        // all penalty blocks instead of allocating a fresh Array1 per block
6758        // per HMC step.
6759        let max_rank = self
6760            .penalty_canonical
6761            .iter()
6762            .map(|cp| cp.rank())
6763            .max()
6764            .unwrap_or(0);
6765        let mut r_beta_scratch = Array1::<f64>::zeros(max_rank);
6766
6767        for (k, cp) in self.penalty_canonical.iter().enumerate() {
6768            // Block-local quadratic: β'S_k β via root
6769            let r = &cp.col_range;
6770            let beta_block = beta.slice(ndarray::s![r.start..r.end]);
6771            let rank_k = cp.rank();
6772            gam_linalg::faer_ndarray::fast_av_view_into(
6773                &cp.root,
6774                &beta_block,
6775                r_beta_scratch.slice_mut(ndarray::s![..rank_k]),
6776            );
6777            let r_beta = r_beta_scratch.slice(ndarray::s![..rank_k]);
6778            let quad_k = r_beta.dot(&r_beta);
6779            penalty_val += 0.5 * self.penalty_scale * lambdas[k] * quad_k;
6780
6781            // Accumulate S(ρ)β for β-gradient — block-local
6782            for a in 0..cp.block_dim() {
6783                let val: f64 = (0..rank_k).map(|row| cp.root[[row, a]] * r_beta[row]).sum();
6784                s_beta[r.start + a] += self.penalty_scale * lambdas[k] * val;
6785            }
6786
6787            // ρ_k gradient from penalty
6788            grad_rho[k] = -0.5 * self.penalty_scale * lambdas[k] * quad_k;
6789        }
6790
6791        // ---- Structural penalty log-determinant: +0.5 log|S(ρ)|₊ and ρ-derivatives ----
6792        //
6793        // One-deep cache keyed on the exact f64 bits of `rho`: NUTS tree
6794        // doubling revisits identical positions byte-for-byte, so an
6795        // exact-equality cache eliminates the dominant SVD/eigendecomp
6796        // cost in `PenaltyPseudologdet::from_penalties` across leapfrog
6797        // half-steps.
6798        let log_det_s = if self.penalty_canonical.is_empty() {
6799            0.0
6800        } else {
6801            let rho_hash = Self::hash_rho(rho);
6802            let cached = self.penalty_logdet_cache.lock().ok().and_then(|guard| {
6803                guard.as_ref().and_then(|(h, v, g)| {
6804                    if *h == rho_hash && g.len() == n_rho {
6805                        for k in 0..n_rho {
6806                            grad_rho[k] += 0.5 * g[k];
6807                        }
6808                        Some(*v)
6809                    } else {
6810                        None
6811                    }
6812                })
6813            });
6814            if let Some(hit) = cached {
6815                hit
6816            } else {
6817                match PenaltyPseudologdet::from_penalties(
6818                    &self.penalty_canonical,
6819                    lambdas.as_slice().unwrap_or(&[]),
6820                    0.0,
6821                    n_beta,
6822                ) {
6823                    Ok(pld) => {
6824                        let (det1, _) = pld.rho_derivatives_from_penalties(
6825                            &self.penalty_canonical,
6826                            lambdas.as_slice().unwrap_or(&[]),
6827                        );
6828                        let value = pld.value();
6829                        if let Ok(mut guard) = self.penalty_logdet_cache.lock() {
6830                            *guard = Some((rho_hash, value, det1.clone()));
6831                        }
6832                        for k in 0..n_rho {
6833                            grad_rho[k] += 0.5 * det1[k];
6834                        }
6835                        value
6836                    }
6837                    Err(err) => {
6838                        log::warn!(
6839                            "[Joint HMC] structural penalty logdet became invalid at the current state: {}",
6840                            err
6841                        );
6842                        out_grad.fill(0.0);
6843                        return f64::NEG_INFINITY;
6844                    }
6845                }
6846            }
6847        };
6848
6849        // ---- Prior on ρ ----
6850        let mut rho_prior = 0.0;
6851        match &self.rho_prior {
6852            RhoPrior::Flat => {}
6853            RhoPrior::Normal { mean, sd } => {
6854                let inv_var = 1.0 / (*sd * *sd);
6855                for k in 0..n_rho {
6856                    let d = rho[k] - *mean;
6857                    rho_prior -= 0.5 * inv_var * d * d;
6858                    grad_rho[k] -= inv_var * d;
6859                }
6860            }
6861            RhoPrior::GammaPrecision { shape, rate } => {
6862                for k in 0..n_rho {
6863                    let lambda = lambdas[k];
6864                    // Density over sampled rho includes the e^rho Jacobian (Gamma is on lambda = e^rho).
6865                    rho_prior += *shape * rho[k] - *rate * lambda;
6866                    grad_rho[k] += *shape - *rate * lambda;
6867                }
6868            }
6869            RhoPrior::PenalizedComplexity { upper, tail_prob } => {
6870                if !pc_prior_params_valid(*upper, *tail_prob) {
6871                    out_grad.fill(0.0);
6872                    return f64::NEG_INFINITY;
6873                }
6874                let theta = -tail_prob.ln() / *upper;
6875                for k in 0..n_rho {
6876                    // log p(ρ) = const − ρ/2 − θ exp(−ρ/2).
6877                    let e = (-0.5 * rho[k]).exp();
6878                    rho_prior += -0.5 * rho[k] - theta * e;
6879                    grad_rho[k] += -0.5 + 0.5 * theta * e;
6880                }
6881            }
6882            RhoPrior::Independent(priors) => {
6883                if priors.len() != n_rho {
6884                    out_grad.fill(0.0);
6885                    return f64::NEG_INFINITY;
6886                }
6887                for k in 0..n_rho {
6888                    match &priors[k] {
6889                        RhoPrior::Flat => {}
6890                        RhoPrior::Normal { mean, sd } => {
6891                            let inv_var = 1.0 / (*sd * *sd);
6892                            let d = rho[k] - *mean;
6893                            rho_prior -= 0.5 * inv_var * d * d;
6894                            grad_rho[k] -= inv_var * d;
6895                        }
6896                        RhoPrior::GammaPrecision { shape, rate } => {
6897                            let lambda = lambdas[k];
6898                            // Density over sampled rho includes the e^rho Jacobian (Gamma is on lambda = e^rho).
6899                            rho_prior += *shape * rho[k] - *rate * lambda;
6900                            grad_rho[k] += *shape - *rate * lambda;
6901                        }
6902                        RhoPrior::PenalizedComplexity { upper, tail_prob } => {
6903                            if !pc_prior_params_valid(*upper, *tail_prob) {
6904                                out_grad.fill(0.0);
6905                                return f64::NEG_INFINITY;
6906                            }
6907                            let theta = -tail_prob.ln() / *upper;
6908                            let e = (-0.5 * rho[k]).exp();
6909                            rho_prior += -0.5 * rho[k] - theta * e;
6910                            grad_rho[k] += -0.5 + 0.5 * theta * e;
6911                        }
6912                        RhoPrior::Independent(_) => {
6913                            out_grad.fill(0.0);
6914                            return f64::NEG_INFINITY;
6915                        }
6916                    }
6917                }
6918            }
6919        }
6920
6921        // ---- Assemble ----
6922        let logp = ll + firth_logdet - penalty_val + 0.5 * log_det_s + rho_prior;
6923
6924        // β-gradient in original space: ∇_β ℓ - S(ρ)β
6925        let grad_beta = &grad_ll_beta - &s_beta;
6926
6927        // Combined gradient: [∇_z; ∇_ρ; ∇_link]
6928        gam_linalg::faer_ndarray::fast_av_view_into(
6929            &self.chol_t,
6930            &grad_beta,
6931            out_grad.slice_mut(ndarray::s![..n_beta]),
6932        );
6933        out_grad
6934            .slice_mut(ndarray::s![n_beta..n_beta + n_rho])
6935            .assign(&grad_rho);
6936        out_grad
6937            .slice_mut(ndarray::s![n_beta + n_rho..])
6938            .assign(&grad_link);
6939
6940        logp
6941    }
6942}
6943
6944/// Penalized-complexity hyperparameters are usable iff `upper` is finite and
6945/// strictly positive and `tail_prob` is a probability in the open `(0, 1)`.
6946/// Mirrors the validation in the shared `rho_prior_eval` engine; an invalid
6947/// configuration repels the sampler (`-∞` potential) rather than producing a
6948/// non-finite gradient.
6949fn pc_prior_params_valid(upper: f64, tail_prob: f64) -> bool {
6950    upper.is_finite() && upper > 0.0 && tail_prob.is_finite() && tail_prob > 0.0 && tail_prob < 1.0
6951}
6952
6953impl HamiltonianTarget<Array1<f64>> for JointBetaRhoPosterior {
6954    fn logp_and_grad(&self, position: &Array1<f64>, grad: &mut Array1<f64>) -> f64 {
6955        self.compute_joint_logp_and_grad_into(position, grad)
6956    }
6957}
6958
6959/// Inputs for joint (β, ρ) sampling.
6960pub struct JointBetaRhoInputs<'a> {
6961    pub x: ArrayView2<'a, f64>,
6962    pub y: ArrayView1<'a, f64>,
6963    pub weights: ArrayView1<'a, f64>,
6964    pub likelihood: GlmLikelihoodSpec,
6965    /// Fitted dispersion φ, exactly as the flat NUTS path carries it: the
6966    /// estimated σ² for a profiled Gaussian and the Tweedie φ; `Known(1.0)`
6967    /// for families whose working weight already folds the dispersion in.
6968    /// The joint target is the fitted model's posterior only if this matches
6969    /// the fit.
6970    pub dispersion: gam_solve::model_types::Dispersion,
6971    /// Fixed additive offset on the linear predictor (η = Xβ + offset), or
6972    /// `None` for an offset-free fit. Dropping a fit-time offset shifts the
6973    /// sampled posterior of every offset model.
6974    pub offset: Option<ArrayView1<'a, f64>>,
6975    pub mode: ArrayView1<'a, f64>,
6976    pub hessian: ArrayView2<'a, f64>,
6977    pub penalty_roots: Vec<CanonicalPenalty>,
6978    pub rho_mode: ArrayView1<'a, f64>,
6979    pub rho_prior: RhoPrior,
6980    pub firth_bias_reduction: bool,
6981    /// Max posterior skewness that triggered this sampling
6982    pub trigger_skewness: f64,
6983}
6984
6985/// Run joint (β, ρ) NUTS sampling.
6986///
6987/// This is the automatic fallback when the Laplace approximation has high
6988/// skewness. It samples from the true joint posterior, completely bypassing
6989/// the Laplace approximation for smoothing parameter selection.
6990pub fn run_joint_beta_rho_sampling(
6991    inputs: &JointBetaRhoInputs<'_>,
6992    config: &NutsConfig,
6993) -> Result<JointBetaRhoResult, String> {
6994    validate_firth_likelihood_support(&inputs.likelihood.spec, inputs.firth_bias_reduction)
6995        .map_err(String::from)?;
6996    validate_nuts_config(config).map_err(String::from)?;
6997    let n_beta = inputs.mode.len();
6998    let n_rho = inputs.penalty_roots.len();
6999    let n_link_params = JointBetaRhoPosterior::link_param_mode(&inputs.likelihood.spec.link).len();
7000    let total_dim = n_beta + n_rho + n_link_params;
7001
7002    log::info!(
7003        "[Joint HMC] Sampling (β, ρ, link) jointly: {} β-params + {} ρ-params + {} link-params = {} total (triggered by skewness {:.3})",
7004        n_beta,
7005        n_rho,
7006        n_link_params,
7007        total_dim,
7008        inputs.trigger_skewness,
7009    );
7010
7011    let target = JointBetaRhoPosterior::new(
7012        inputs.x,
7013        inputs.y,
7014        inputs.weights,
7015        inputs.mode,
7016        inputs.hessian,
7017        inputs.penalty_roots.clone(),
7018        inputs.rho_mode,
7019        inputs.likelihood.clone(),
7020        inputs.dispersion,
7021        inputs.offset,
7022        inputs.rho_prior.clone(),
7023        inputs.firth_bias_reduction,
7024    )?;
7025
7026    let chol = target.chol.clone();
7027    let mode_arr = target.data.mode.clone();
7028    let rho_mode = target.rho_mode.clone();
7029    let link_param_mode = target.link_param_mode.clone();
7030
7031    // Initialize chains: z_β at 0 (= mode), ρ at rho_mode, link params at fitted state.
7032    let initial_positions: Vec<Array1<f64>> = (0..config.n_chains)
7033        .map(|chain| {
7034            let mut rng =
7035                StdRng::seed_from_u64(chain_stream_seed(config.seed, chain, 0x9B51_6E37_F2D0_A48C));
7036            let mut pos = Array1::<f64>::zeros(total_dim);
7037            // Small jitter for β (whitened space)
7038            for j in 0..n_beta {
7039                pos[j] = sample_standard_normal(&mut rng) * 0.1;
7040            }
7041            // Small jitter for ρ around mode
7042            for k in 0..n_rho {
7043                pos[n_beta + k] = rho_mode[k] + sample_standard_normal(&mut rng) * 0.2;
7044            }
7045            // Small jitter for adaptive link parameters around fitted state
7046            for k in 0..n_link_params {
7047                pos[n_beta + n_rho + k] =
7048                    link_param_mode[k] + sample_standard_normal(&mut rng) * 0.05;
7049            }
7050            pos
7051        })
7052        .collect();
7053
7054    // Keep warmup covariance phase-local: diagonal windows are less likely to
7055    // encode cross-block covariance from a transient mode switch.
7056    let mass_cfg = robust_mass_matrix_config(total_dim, config.nwarmup);
7057
7058    let (samples_array, run_stats) = run_whitened_nuts_samples(
7059        target,
7060        initial_positions,
7061        config,
7062        total_dim,
7063        mass_cfg,
7064        0x63AF_175B_D820_C94E,
7065        "Joint (β,ρ) NUTS sampling failed",
7066    )?;
7067    log::info!("[Joint HMC] Sampling complete: {}", run_stats);
7068
7069    // Unpack samples
7070    let shape = samples_array.shape();
7071    let n_chains = shape[0];
7072    let n_samples_out = shape[1];
7073    let total_samples = n_chains * n_samples_out;
7074
7075    let beta_samples = unwhiten_samples(&samples_array, mode_arr.as_ref(), &chol, n_beta, 0);
7076    let mut rho_samples = Array2::<f64>::zeros((total_samples, n_rho));
7077    let mut link_param_samples = Array2::<f64>::zeros((total_samples, n_link_params));
7078
7079    for chain in 0..n_chains {
7080        for sample_i in 0..n_samples_out {
7081            let sample_idx = chain * n_samples_out + sample_i;
7082            let zview = samples_array.slice(ndarray::s![chain, sample_i, ..]);
7083
7084            // ρ and adaptive link parameters are stored directly
7085            let rho_slice = zview.slice(ndarray::s![n_beta..n_beta + n_rho]);
7086            rho_samples.row_mut(sample_idx).assign(&rho_slice);
7087            let link_slice = zview.slice(ndarray::s![n_beta + n_rho..]);
7088            link_param_samples.row_mut(sample_idx).assign(&link_slice);
7089        }
7090    }
7091
7092    let beta_mean = beta_samples
7093        .mean_axis(Axis(0))
7094        .unwrap_or_else(|| Array1::zeros(n_beta));
7095    let rho_mean = rho_samples
7096        .mean_axis(Axis(0))
7097        .unwrap_or_else(|| Array1::zeros(n_rho));
7098    let link_param_mean = link_param_samples
7099        .mean_axis(Axis(0))
7100        .unwrap_or_else(|| Array1::zeros(n_link_params));
7101
7102    let (rhat, ess) = compute_split_rhat_and_ess(&samples_array);
7103
7104    let converged = NutsConvergenceThresholds {
7105        max_rhat: 1.1,
7106        min_ess: Some(50.0),
7107    }
7108    .converged(rhat, ess);
7109    if !converged {
7110        log::warn!(
7111            "[Joint HMC] Convergence warning: R-hat={:.3}, ESS={:.1}",
7112            rhat,
7113            ess,
7114        );
7115    }
7116
7117    Ok(JointBetaRhoResult {
7118        beta_samples,
7119        rho_samples,
7120        beta_mean,
7121        link_param_samples,
7122        link_param_mean,
7123        rho_mean,
7124        rhat,
7125        ess,
7126        converged,
7127        trigger_skewness: inputs.trigger_skewness,
7128    })
7129}
7130
7131// ============================================================================
7132// Survival Model HMC Support
7133// ============================================================================
7134
7135mod survival_hmc {
7136    use super::*;
7137    use gam_models::survival::{
7138        PenaltyBlocks, SurvivalEngineInputs, SurvivalMonotonicityPenalty, SurvivalSpec,
7139        WorkingModelSurvival,
7140    };
7141
7142    /// Shared data for survival NUTS posterior (wrapped in Arc to prevent cloning).
7143    #[derive(Clone)]
7144    struct SharedSurvivalData {
7145        /// Exact survival model in original spline coordinates.
7146        base_model: Arc<WorkingModelSurvival>,
7147        /// MAP estimate in coefficient coordinates.
7148        mode: Arc<Array1<f64>>,
7149    }
7150
7151    /// Whitened log-posterior target for survival models with analytical gradients.
7152    #[derive(Clone)]
7153    pub struct SurvivalPosterior {
7154        /// Shared read-only data (Arc prevents duplication)
7155        data: SharedSurvivalData,
7156        /// Transform: L where L L^T = H^{-1}
7157        chol: Array2<f64>,
7158        /// L^T for gradient chain rule: ∇z = L^T @ ∇_β
7159        chol_t: Array2<f64>,
7160    }
7161
7162    impl SurvivalPosterior {
7163        /// Creates a new survival posterior target.
7164        pub fn new(
7165            age_entry: ArrayView1<'_, f64>,
7166            age_exit: ArrayView1<'_, f64>,
7167            event_target: ArrayView1<'_, u8>,
7168            event_competing: ArrayView1<'_, u8>,
7169            sampleweight: ArrayView1<'_, f64>,
7170            x_entry: ArrayView2<'_, f64>,
7171            x_exit: ArrayView2<'_, f64>,
7172            x_derivative: ArrayView2<'_, f64>,
7173            offset_eta_entry: Option<ArrayView1<'_, f64>>,
7174            offset_eta_exit: Option<ArrayView1<'_, f64>>,
7175            offset_derivative_exit: Option<ArrayView1<'_, f64>>,
7176            penalties: PenaltyBlocks,
7177            monotonicity: SurvivalMonotonicityPenalty,
7178            spec: SurvivalSpec,
7179            structurally_monotonic: bool,
7180            structural_time_columns: usize,
7181            mode: ArrayView1<f64>,
7182            hessian: ArrayView2<f64>,
7183        ) -> Result<Self, String> {
7184            let n = age_entry.len();
7185            let off_eta_entry = offset_eta_entry
7186                .map(|v| v.to_owned())
7187                .unwrap_or_else(|| Array1::zeros(n));
7188            let off_eta_exit = offset_eta_exit
7189                .map(|v| v.to_owned())
7190                .unwrap_or_else(|| Array1::zeros(n));
7191            let off_deriv_exit = offset_derivative_exit
7192                .map(|v| v.to_owned())
7193                .unwrap_or_else(|| Array1::zeros(n));
7194
7195            let mut base_model = WorkingModelSurvival::from_engine_inputswith_offsets(
7196                SurvivalEngineInputs {
7197                    age_entry,
7198                    age_exit,
7199                    event_target,
7200                    event_competing,
7201                    sampleweight,
7202                    x_entry,
7203                    x_exit,
7204                    x_derivative,
7205                    monotonicity_constraint_rows: None,
7206                    monotonicity_constraint_offsets: None,
7207                },
7208                Some(gam_models::survival::SurvivalBaselineOffsets {
7209                    eta_entry: off_eta_entry.view(),
7210                    eta_exit: off_eta_exit.view(),
7211                    derivative_exit: off_deriv_exit.view(),
7212                }),
7213                penalties,
7214                monotonicity,
7215                spec,
7216            )
7217            .map_err(|e| format!("Survival state construction failed: {:?}", e))?;
7218            if structurally_monotonic {
7219                base_model
7220                    .set_structural_monotonicity(true, structural_time_columns)
7221                    .map_err(|e| {
7222                        format!("Failed to enable structural monotonicity in survival HMC: {e}")
7223                    })?;
7224            }
7225
7226            let sampler_mode = mode.to_owned();
7227            let dim = sampler_mode.len();
7228
7229            let whitening = hessian_whitening_transform(
7230                hessian,
7231                dim,
7232                1.0,
7233                "Hessian Cholesky decomposition failed",
7234            )?;
7235            let chol = whitening.chol;
7236            let chol_t = whitening.chol_t;
7237
7238            let data = SharedSurvivalData {
7239                base_model: Arc::new(base_model),
7240                mode: Arc::new(sampler_mode),
7241            };
7242
7243            Ok(Self { data, chol, chol_t })
7244        }
7245
7246        fn compute_logp_and_grad_into(
7247            &self,
7248            z: &Array1<f64>,
7249            grad: &mut Array1<f64>,
7250        ) -> Result<f64, String> {
7251            let sampler_position = self.data.mode.as_ref() + &self.chol.dot(z);
7252            let state = self
7253                .data
7254                .base_model
7255                .update_state(&sampler_position)
7256                .map_err(|e| format!("Survival state update failed: {:?}", e))?;
7257            let logp = state.log_likelihood - state.penalty_term;
7258            let grad_beta = state.gradient.mapv(|g| -g);
7259            fast_av_into(&self.chol_t, &grad_beta, grad);
7260            Ok(logp)
7261        }
7262
7263        /// Get the Cholesky factor L for un-whitening samples
7264        pub fn chol(&self) -> &Array2<f64> {
7265            &self.chol
7266        }
7267
7268        /// Get the mode
7269        pub fn mode(&self) -> &Array1<f64> {
7270            &self.data.mode
7271        }
7272    }
7273
7274    impl HamiltonianTarget<Array1<f64>> for SurvivalPosterior {
7275        fn logp_and_grad(&self, position: &Array1<f64>, grad: &mut Array1<f64>) -> f64 {
7276            match self.compute_logp_and_grad_into(position, grad) {
7277                Ok(logp) => logp,
7278                Err(e) => {
7279                    log::warn!("Survival posterior evaluation failed: {}", e);
7280                    grad.fill(0.0);
7281                    f64::NEG_INFINITY
7282                }
7283            }
7284        }
7285    }
7286
7287    /// Runs NUTS sampling for survival models with whitened parameter space.
7288    pub(crate) fn run_survival_nuts_sampling(
7289        age_entry: ArrayView1<'_, f64>,
7290        age_exit: ArrayView1<'_, f64>,
7291        event_target: ArrayView1<'_, u8>,
7292        event_competing: ArrayView1<'_, u8>,
7293        sampleweight: ArrayView1<'_, f64>,
7294        x_entry: ArrayView2<'_, f64>,
7295        x_exit: ArrayView2<'_, f64>,
7296        x_derivative: ArrayView2<'_, f64>,
7297        eta_offset_entry: Option<ArrayView1<'_, f64>>,
7298        eta_offset_exit: Option<ArrayView1<'_, f64>>,
7299        derivative_offset_exit: Option<ArrayView1<'_, f64>>,
7300        penalties: PenaltyBlocks,
7301        monotonicity: SurvivalMonotonicityPenalty,
7302        spec: SurvivalSpec,
7303        structurally_monotonic: bool,
7304        structural_time_columns: usize,
7305        mode: ArrayView1<f64>,
7306        hessian: ArrayView2<f64>,
7307        config: &NutsConfig,
7308    ) -> Result<NutsResult, String> {
7309        validate_nuts_config(config).map_err(String::from)?;
7310        // Create posterior target
7311        let target = SurvivalPosterior::new(
7312            age_entry,
7313            age_exit,
7314            event_target,
7315            event_competing,
7316            sampleweight,
7317            x_entry,
7318            x_exit,
7319            x_derivative,
7320            eta_offset_entry,
7321            eta_offset_exit,
7322            derivative_offset_exit,
7323            penalties,
7324            monotonicity,
7325            spec,
7326            structurally_monotonic,
7327            structural_time_columns,
7328            mode,
7329            hessian,
7330        )?;
7331
7332        // Get Cholesky factor for un-whitening samples later
7333        let chol = target.chol().clone();
7334        let mode_arr = target.mode().clone();
7335        let dim = mode_arr.len();
7336
7337        let initial_positions = jittered_initial_positions(config, dim, 0.1, 0xEC2D_7A9B_4051_F638);
7338
7339        let mass_cfg = robust_survival_mass_matrix_config(dim, config.nwarmup);
7340        let (result, run_stats) = run_whitened_nuts_result(
7341            target,
7342            &mode_arr,
7343            &chol,
7344            initial_positions,
7345            config,
7346            dim,
7347            mass_cfg,
7348            0x731B_60D4_AE52_9C8F,
7349            "NUTS sampling failed",
7350            Array1::zeros(dim),
7351            NutsConvergenceThresholds {
7352                max_rhat: 1.1,
7353                min_ess: None,
7354            },
7355        )?;
7356
7357        log::info!("Survival NUTS sampling complete: {}", run_stats);
7358
7359        Ok(result)
7360    }
7361}
7362
7363/// Engine-facing flattened survival NUTS entrypoint.
7364pub fn run_survival_nuts_sampling_flattened<'a>(
7365    flat: SurvivalFlatInputs<'a>,
7366    penalties: gam_models::survival::PenaltyBlocks,
7367    monotonicity: gam_models::survival::SurvivalMonotonicityPenalty,
7368    spec: gam_models::survival::SurvivalSpec,
7369    structurally_monotonic: bool,
7370    structural_time_columns: usize,
7371    mode: ArrayView1<'a, f64>,
7372    hessian: ArrayView2<'a, f64>,
7373    config: &NutsConfig,
7374) -> Result<NutsResult, String> {
7375    run_nuts_sampling_flattened_family(
7376        LikelihoodSpec {
7377            response: ResponseFamily::RoystonParmar,
7378            link: InverseLink::Standard(StandardLink::Identity),
7379        },
7380        FamilyNutsInputs::Survival(Box::new(SurvivalNutsInputs {
7381            flat,
7382            penalties,
7383            monotonicity,
7384            spec,
7385            structurally_monotonic,
7386            structural_time_columns,
7387            mode,
7388            hessian,
7389        })),
7390        config,
7391    )
7392}