Skip to main content

gam_inference/
sample.rs

1//! Library-side orchestration for NUTS posterior sampling from a saved model.
2//!
3//! The CLI's `gam sample` subcommand and the Python `Model.sample(...)` API
4//! both call into [`sample_saved_model`], which dispatches on the saved
5//! model's class (standard GLM, standard with link-wiggle, or survival) and
6//! returns a fully-converged [`NutsResult`] over the original coefficient
7//! space. Gaussian identity standard models are sampled from the saved
8//! closed-form posterior, conditioning on the training fit rather than any
9//! prediction rows supplied by the caller.
10
11use std::collections::HashMap;
12
13use faer::Side;
14use ndarray::{Array1, Array2, ArrayView1, ArrayView2, s};
15use rand::{RngExt, SeedableRng};
16
17use super::hmc_io::{
18    FamilyNutsInputs, GlmFlatInputs, LinkWiggleFamilyParams, LinkWiggleSplineArtifacts,
19    SurvivalFlatInputs, explicit_fit_hessian_for_whitening, run_link_wiggle_nuts_sampling,
20    run_nuts_sampling_flattened_family, run_survival_nuts_sampling_flattened, validate_nuts_config,
21};
22pub use super::hmc_io::{NutsConfig, NutsResult};
23use crate::formula_dsl::{LinkWiggleFormulaSpec, parse_formula};
24use crate::model::{
25    FittedModel as SavedModel, PredictModelClass, SavedLinkWiggleRuntime,
26    load_survival_time_basis_config_from_model,
27};
28use gam_linalg::faer_ndarray::FaerCholesky;
29use gam_linalg::triangular::back_substitution_lower_transpose_guarded_into;
30use gam_models::survival::construction::{
31    SurvivalLikelihoodMode, add_survival_time_derivative_guard_offset, build_survival_time_basis,
32    build_survival_time_offsets_for_likelihood, evaluate_survival_time_basis_row,
33    normalize_survival_time_pair, resolved_survival_time_basis_config_from_build,
34    survival_derivative_guard_for_likelihood,
35};
36use gam_models::survival::predict::{
37    fit_result_from_saved_model_for_prediction, require_saved_survival_likelihood_mode,
38    resolve_saved_survival_time_columns, resolve_termspec_for_prediction,
39    saved_baseline_timewiggle_components, saved_survival_runtime_baseline_config,
40};
41use gam_models::survival::royston_parmar::{self, RoystonParmarInputs};
42use gam_models::survival::{
43    PenaltyBlock, PenaltyBlocks, SurvivalMonotonicityPenalty, SurvivalSpec,
44};
45use gam_models::wiggle::{
46    append_selected_wiggle_function_penalties, buildwiggle_block_input_from_knots,
47    canonical_wiggle_function_penalties, split_wiggle_penalty_orders,
48};
49use gam_problem::types::{InverseLink, LikelihoodSpec, ResponseFamily, StandardLink};
50use gam_runtime::resource::{MemoryGovernor, ResourcePolicy, rows_for_target_bytes};
51use gam_solve::estimate::{BlockRole, validate_all_finite};
52use gam_terms::smooth::build_term_collection_design;
53use gam_terms::smooth::{LinearCoefficientGeometry, weighted_blockwise_penalty_sum};
54use gam_terms::term_builder::resolve_role_col;
55
56fn sampling_sqrt_covariance_scale(
57    fit: &gam_solve::estimate::UnifiedFitResult,
58    context: &str,
59) -> Result<f64, String> {
60    let scale = fit
61        .coefficient_covariance_scale()
62        .map_err(|err| format!("{context}: cannot resolve coefficient-covariance scale: {err}"))?;
63    if !(scale.is_finite() && scale > 0.0) {
64        return Err(format!(
65            "{context}: posterior sampling requires a finite strictly-positive coefficient-covariance scale, got {scale}"
66        ));
67    }
68    Ok(scale.sqrt())
69}
70
71fn resolved_fit_dispersion(
72    fit: &gam_solve::estimate::UnifiedFitResult,
73    context: &str,
74) -> Result<gam_problem::Dispersion, String> {
75    if let Some(dispersion) = fit.dispersion() {
76        return Ok(dispersion);
77    }
78    let family = fit.likelihood_family.as_ref().ok_or_else(|| {
79        format!("{context}: fit has no engine-level family and no scalar dispersion")
80    })?;
81    let likelihood = gam_problem::GlmLikelihoodSpec::try_new(family.clone(), fit.likelihood_scale)
82        .map_err(|err| format!("{context}: invalid fitted likelihood scale: {err}"))?;
83    let profiled_standard_deviation = matches!(
84        likelihood
85            .resolved_scale()
86            .map_err(|err| format!("{context}: invalid fitted likelihood scale: {err}"))?,
87        gam_problem::ResolvedLikelihoodScale::ProfiledGaussian
88    )
89    .then_some(fit.standard_deviation);
90    gam_solve::estimate::dispersion_from_likelihood(&likelihood, profiled_standard_deviation)
91        .map_err(|err| format!("{context}: cannot resolve fitted dispersion: {err}"))
92}
93
94/// Entry, exit, and derivative designs are live both in the caller's final
95/// assembly and in the current WorkingModelSurvival owner.
96const SURVIVAL_DESIGN_LIVE_COPIES: usize = 2 * 3;
97
98/// Stream a design into caller-owned storage without forming an intermediate
99/// full dense matrix. The caller owns the reservation for `out`; this helper
100/// only bounds the transient row work and preserves lazy/sparse backing until
101/// the final consumer layout is assembled.
102fn stream_design_into(
103    design: &gam_linalg::matrix::DesignMatrix,
104    mut out: ndarray::ArrayViewMut2<'_, f64>,
105    row_chunk_target_bytes: usize,
106    context: &str,
107) -> Result<(), String> {
108    if out.dim() != (design.nrows(), design.ncols()) {
109        return Err(format!(
110            "{context}: output shape {}x{} does not match design {}x{}",
111            out.nrows(),
112            out.ncols(),
113            design.nrows(),
114            design.ncols(),
115        ));
116    }
117    let chunk_rows = rows_for_target_bytes(row_chunk_target_bytes, design.ncols())
118        .max(1)
119        .min(design.nrows().max(1));
120    for start in (0..design.nrows()).step_by(chunk_rows) {
121        let end = (start + chunk_rows).min(design.nrows());
122        design
123            .row_chunk_into(start..end, out.slice_mut(s![start..end, ..]))
124            .map_err(|error| format!("{context}: {error}"))?;
125    }
126    Ok(())
127}
128
129/// Reconstruct the `LinkWiggleFormulaSpec` from a saved model's
130/// baseline-time-wiggle runtime, returning `None` when the model has no
131/// time-wiggle component. Re-exported because the survival fitter's tests
132/// exercise the spec independently of running NUTS.
133pub fn saved_baseline_timewiggle_spec(
134    model: &SavedModel,
135) -> Result<Option<LinkWiggleFormulaSpec>, String> {
136    model
137        .saved_baseline_time_wiggle()
138        .map_err(|e| e.to_string())
139        .map(|runtime| {
140            runtime.map(|saved| LinkWiggleFormulaSpec {
141                degree: saved.degree,
142                num_internal_knots: saved.knots.len().saturating_sub(2 * (saved.degree + 1)),
143                penalty_orders: saved.penalty_orders,
144                double_penalty: saved.double_penalty,
145            })
146        })
147}
148
149fn weighted_penalty_matrix(
150    penalties: &[Array2<f64>],
151    lambdas: ArrayView1<'_, f64>,
152) -> Result<Array2<f64>, String> {
153    if penalties.len() != lambdas.len() {
154        return Err(format!(
155            "penalty/lambda mismatch: {} penalties vs {} lambdas",
156            penalties.len(),
157            lambdas.len()
158        ));
159    }
160    if penalties.is_empty() {
161        return Err("cannot sample without at least one penalty block".to_string());
162    }
163    let p = penalties[0].nrows();
164    let mut out = Array2::<f64>::zeros((p, p));
165    for (k, s) in penalties.iter().enumerate() {
166        if s.nrows() != p || s.ncols() != p {
167            return Err(format!(
168                "penalty block {k} shape mismatch: got {}x{}, expected {}x{}",
169                s.nrows(),
170                s.ncols(),
171                p,
172                p
173            ));
174        }
175        let lam = lambdas[k];
176        out += &(s * lam);
177    }
178    Ok(out)
179}
180
181/// Rebuild the standard link-wiggle penalty from the exact semantic metadata
182/// persisted by fitting. The canonical constructor is shared with fit-time
183/// block assembly, and every count, shape, and ordered block kind is checked
184/// before lambdas are applied. There is intentionally no inferred order,
185/// skipped block, or zero-matrix completion path.
186fn saved_link_wiggle_penalty_matrix(
187    runtime: &SavedLinkWiggleRuntime,
188    lambdas: ArrayView1<'_, f64>,
189    expected_dimension: usize,
190) -> Result<Array2<f64>, String> {
191    let metadata = runtime.penalty_metadata.as_ref().ok_or_else(|| {
192        "standard link-wiggle sampling requires saved canonical penalty metadata; refit".to_string()
193    })?;
194    let canonical = canonical_wiggle_function_penalties(
195        &Array1::from_vec(runtime.knots.clone()),
196        runtime.degree,
197        &metadata.derivative_orders,
198        metadata.double_penalty,
199    )
200    .map_err(|error| format!("saved link-wiggle penalty reconstruction failed: {error}"))?;
201    if canonical.metadata != *metadata {
202        return Err(format!(
203            "saved link-wiggle penalty topology {:?} disagrees with canonical topology {:?}",
204            metadata.blocks, canonical.metadata.blocks,
205        ));
206    }
207    if canonical.matrices.len() != lambdas.len() {
208        return Err(format!(
209            "saved link-wiggle penalty/lambda mismatch: canonical topology has {} blocks but fit stores {} lambdas",
210            canonical.matrices.len(),
211            lambdas.len(),
212        ));
213    }
214    for (index, matrix) in canonical.matrices.iter().enumerate() {
215        if matrix.dim() != (expected_dimension, expected_dimension) {
216            return Err(format!(
217                "saved link-wiggle penalty block {index} is {}x{} but fitted LinkWiggle coordinate has dimension {expected_dimension}",
218                matrix.nrows(),
219                matrix.ncols(),
220            ));
221        }
222    }
223    weighted_penalty_matrix(&canonical.matrices, lambdas)
224}
225
226fn validate_explicit_link_wiggle_joint_hessian(
227    hessian: &Array2<f64>,
228    expected_dim: usize,
229) -> Result<(), String> {
230    if hessian.nrows() != expected_dim || hessian.ncols() != expected_dim {
231        return Err(format!(
232            "link-wiggle sample: explicit joint Hessian is {}x{} but expected {}x{}",
233            hessian.nrows(),
234            hessian.ncols(),
235            expected_dim,
236            expected_dim,
237        ));
238    }
239    validate_all_finite(
240        "link-wiggle explicit joint Hessian",
241        hessian.iter().copied(),
242    )?;
243    let mut max_abs = 0.0_f64;
244    for r in 0..expected_dim {
245        for c in 0..expected_dim {
246            max_abs = max_abs.max(hessian[[r, c]].abs());
247            let scale = hessian[[r, c]].abs().max(hessian[[c, r]].abs()).max(1.0);
248            if (hessian[[r, c]] - hessian[[c, r]]).abs() > 1e-9 * scale {
249                return Err(format!(
250                    "link-wiggle sample: explicit joint Hessian is not symmetric at ({r},{c})"
251                ));
252            }
253        }
254    }
255    if max_abs == 0.0 {
256        return Err("link-wiggle sample: explicit joint Hessian is all zeros; refit with exact Hessian export"
257                    .to_string());
258    }
259    Ok(())
260}
261
262/// Resolve the fitted prior-weights column for saved-model sampling.
263///
264/// The fit optimized a weighted likelihood; reconstructing the target with
265/// unit weights samples a DIFFERENT posterior — an intercept-only Bernoulli
266/// with `(y, w) = (1, 100), (0, 1)` has its weighted mode at `log 100`, not 0
267/// (#2245 finding 16). `None` weight column means the fit was unweighted.
268fn saved_prior_weights(
269    model: &SavedModel,
270    data: ArrayView2<'_, f64>,
271    col_map: &HashMap<String, usize>,
272) -> Result<Array1<f64>, String> {
273    match model.weight_column.as_deref() {
274        Some(name) => {
275            let idx = resolve_role_col(col_map, name, "weights")?;
276            let w = data.column(idx).to_owned();
277            if !w.iter().all(|v| v.is_finite() && *v >= 0.0) {
278                return Err(format!(
279                    "sample: prior-weights column '{name}' contains negative or non-finite values"
280                ));
281            }
282            Ok(w)
283        }
284        None => Ok(Array1::ones(data.nrows())),
285    }
286}
287
288/// Re-apply the offset the model was fit with so the posterior targets the
289/// same `η = Xβ + offset` as the fit and predict paths. The diagnostic loader
290/// keeps the saved offset column in the frame; dropping the offset silently
291/// sampled the wrong target for any `--offset-column` GLM (#882, #2245
292/// finding 16).
293fn saved_offset(
294    model: &SavedModel,
295    data: ArrayView2<'_, f64>,
296    col_map: &HashMap<String, usize>,
297) -> Result<Option<Array1<f64>>, String> {
298    match model.offset_column.as_deref() {
299        Some(name) => {
300            let idx = resolve_role_col(col_map, name, "offset")?;
301            Ok(Some(data.column(idx).to_owned()))
302        }
303        None => Ok(None),
304    }
305}
306
307/// Refresh the Negative-Binomial overdispersion `theta` on the sampling
308/// likelihood spec from the fit's jointly-estimated `theta_hat` before the NUTS
309/// dispatch reads it (#1463).
310///
311/// The construction seed stored on the family spec (`theta: 1.0`) only seeds the
312/// inner solve. NB carries unit REML scale and records its fitted overdispersion
313/// in `likelihood_scale` (`EstimatedNegBinTheta` / `FixedNegBinTheta`), *not* in
314/// the REML dispersion. The NUTS NB log-likelihood / score
315/// (`src/inference/hmc.rs`) reads `theta` straight off this spec, so leaving the
316/// seed in place over-states `Var(y) = μ + μ²/θ` and inflates every
317/// coefficient's posterior SD ~1.4–1.5× (the HMC sibling of the replicate-path
318/// bug #1124). This mirrors the canonical replicate picker
319/// [`crate::generative::family_noise_parameter`]'s `negbin_theta().or(seed)`:
320/// when the scale records a fitted `theta_hat`, use it; otherwise keep the
321/// existing seed. `theta_fixed` NB carries the user's exact value in both the
322/// spec and the scale metadata, so this refresh is a no-op there. Non-NB
323/// families are left untouched.
324fn refresh_negbin_theta_for_sampling(
325    likelihood: &mut LikelihoodSpec,
326    scale: gam_problem::types::LikelihoodScaleMetadata,
327) {
328    if let ResponseFamily::NegativeBinomial { theta, .. } = &mut likelihood.response {
329        if let Some(theta_hat) = scale.negbin_theta() {
330            *theta = theta_hat;
331        }
332    }
333}
334
335/// Build a `LikelihoodSpec` for a saved model. Saved models already carry the
336/// response distribution and parameterized link state together, so sampling can
337/// dispatch directly on the cloned spec.
338fn likelihood_spec_for_saved_model(model: &SavedModel) -> Result<LikelihoodSpec, String> {
339    Ok(model.likelihood())
340}
341
342/// Default smoothing strength `λ` applied to a reconstructed penalty block when
343/// the saved model carries no fitted `smooth_lambda`. A mild penalty: enough to
344/// regularize the reconstructed-for-prediction design without materially
345/// reshaping the saved fit. Fitted lambdas, when present, always override this.
346const DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA: f64 = 1e-2;
347
348#[inline]
349const fn splitmix64(x: u64) -> u64 {
350    gam_linalg::utils::splitmix64_hash(x)
351}
352
353#[inline]
354const fn chain_stream_seed(seed: u64, chain: usize, stream: u64) -> u64 {
355    splitmix64(seed ^ stream ^ ((chain as u64).wrapping_mul(0xD1B5_4A32_D192_ED03)))
356}
357
358/// Run NUTS posterior sampling over a saved model.
359///
360/// Dispatches on `model.predict_model_class()`:
361///
362/// * `Standard`: Gaussian identity models use the exact saved
363///   `N(mode, φ·H⁻¹)` posterior, where `mode`, `φ`, and `H` all come from the
364///   training fit. Other standard GLMs run NUTS from the saved mode,
365///   smoothing parameters, dispersion, and whitening curvature rather than
366///   refitting/reselecting them on the caller-supplied rows. Link-wiggle
367///   models take a specialised joint-space path that preserves the basis
368///   chain rule.
369/// * `Survival`: rebuilds the survival design (Royston-Parmar baseline +
370///   wiggle + covariate blocks) on the supplied data, evaluates the mode,
371///   and runs the survival-flat NUTS path. Latent and location-scale modes
372///   are explicitly rejected here.
373/// * Other model classes (location-scale GLM, bernoulli marginal-slope,
374///   transformation-normal) return a "not implemented" error matching the
375///   CLI surface.
376pub fn sample_saved_model(
377    model: &SavedModel,
378    data: ArrayView2<'_, f64>,
379    col_map: &HashMap<String, usize>,
380    training_headers: Option<&Vec<String>>,
381    cfg: &NutsConfig,
382) -> Result<NutsResult, String> {
383    // Issue #399: degenerate draw/chain counts (`samples=0` / `chains=0`, and
384    // the `samples < 4` counts the split-R-hat engine path cannot handle) must
385    // surface as one typed `InvalidConfig` error before any sampler runs —
386    // identically across *every* model class. Validating here, at the single
387    // public dispatch point, guarantees that the NUTS path, the auto-selected
388    // Pólya-Gamma Gibbs path, and the Laplace-Gaussian fallback all reject the
389    // same inputs the same way (previously the fallback silently accepted them
390    // via `.max(1)` while NUTS errored — a divergent contract on one API).
391    validate_nuts_config(cfg).map_err(String::from)?;
392    let likelihood = likelihood_spec_for_saved_model(model)?;
393    match model.predict_model_class() {
394        PredictModelClass::Survival => {
395            // Latent / latent-binary / location-scale survival likelihoods
396            // have no exact NUTS implementation in the engine yet; fall
397            // through to the Laplace-Gaussian fallback so callers still
398            // get a posterior they can predict with. Royston-Parmar /
399            // Weibull / marginal-slope survival use the exact path.
400            let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
401            if matches!(
402                saved_likelihood_mode,
403                SurvivalLikelihoodMode::Latent
404                    | SurvivalLikelihoodMode::LatentBinary
405                    | SurvivalLikelihoodMode::LocationScale
406            ) {
407                laplace_gaussian_fallback(model, cfg, "survival posterior fallback")
408            } else {
409                sample_survival(model, data, col_map, training_headers, cfg)
410            }
411        }
412        PredictModelClass::Standard => {
413            // Most `Standard` GLM families (Gaussian, Poisson, Gamma, Tweedie,
414            // Negative-Binomial, binomial logit/probit/cloglog) have an exact
415            // NUTS implementation and run through `sample_standard`. Beta
416            // regression is the one `Standard` family the engine cannot sample
417            // with NUTS (`hmc_io.rs` returns a hard error for it). Rather than
418            // aborting the whole `sample` command, route it to the same
419            // Laplace-Gaussian fallback every other NUTS-unsupported model
420            // class already uses, so callers still get a usable posterior.
421            if matches!(likelihood.response, ResponseFamily::Beta { .. }) {
422                laplace_gaussian_fallback(model, cfg, "beta-regression posterior fallback")
423            } else {
424                sample_standard(model, data, col_map, training_headers, likelihood, cfg)
425            }
426        }
427        // For classes where the Rust core doesn't yet have an exact NUTS
428        // implementation we fall back to drawing from the Laplace
429        // (Gaussian) approximation of the posterior around the fitted
430        // joint mode, using the saved penalised Hessian. This is the
431        // standard "Bayesian credible interval" surface used by mgcv
432        // and similar packages: it drops higher-order posterior shape
433        // but lets every downstream consumer (credible intervals,
434        // posterior predictive, etc.) keep working uniformly across
435        // model classes.
436        PredictModelClass::GaussianLocationScale => {
437            laplace_gaussian_fallback(model, cfg, "gaussian location-scale posterior")
438        }
439        PredictModelClass::BinomialLocationScale => {
440            laplace_gaussian_fallback(model, cfg, "binomial location-scale posterior")
441        }
442        PredictModelClass::DispersionLocationScale => {
443            laplace_gaussian_fallback(model, cfg, "dispersion location-scale posterior")
444        }
445        PredictModelClass::BernoulliMarginalSlope => {
446            laplace_gaussian_fallback(model, cfg, "bernoulli marginal-slope posterior")
447        }
448        PredictModelClass::TransformationNormal => {
449            // The CTN posterior is the Laplace Gaussian TRUNCATED to the
450            // monotonicity cone Γ = Ψ Aᵀ ≥ 0 (gam#2306 §5); draw it by rejection
451            // rather than the unconstrained Gaussian fallback, which would put
452            // mass on non-monotone (invalid) transformations.
453            sample_transformation_normal_constrained(model, cfg)
454        }
455    }
456}
457
458/// Draw iid samples from `N(mode, H^{-1})` using the saved penalised
459/// Hessian `H = L L^T`.
460///
461/// We solve `L^T δ = ε` for each iid `ε ~ N(0, I)` and report
462/// `β = mode + δ`. The resulting draws are unbiased samples of the
463/// Laplace-Gaussian approximation: their finite-sample mean / std
464/// converge to `(mode, diag(H^{-1})^{1/2})` and the implied credible
465/// bands match the surface that closed-form posterior tooling in
466/// `mgcv` and `gam` itself uses for prediction intervals.
467///
468/// `rationale` is a short label appearing in error messages so callers
469/// can tell which class fell back to this path. We mark `rhat = 1.0`
470/// and `ess = n_total` because the draws are iid by construction.
471pub fn laplace_gaussian_fallback(
472    model: &SavedModel,
473    cfg: &NutsConfig,
474    rationale: &'static str,
475) -> Result<NutsResult, String> {
476    // Defense in depth: this is `pub`, so guard the same degenerate
477    // draw/chain counts the NUTS / PG paths reject (issue #399) rather than
478    // papering over `n_chains == 0` / `n_samples == 0` with `.max(1)`, which
479    // would silently fabricate draws the caller never asked for.
480    validate_nuts_config(cfg).map_err(String::from)?;
481    let fit = fit_result_from_saved_model_for_prediction(model)?;
482    let mode = fit.beta.clone();
483    let p = mode.len();
484    if p == 0 {
485        return Err(format!(
486            "{rationale}: cannot sample from an empty coefficient vector"
487        ));
488    }
489    let h = fit.penalized_hessian().ok_or_else(|| {
490        format!(
491            "{rationale}: posterior fallback requires the explicit penalised Hessian; \
492             refit with exact geometry export to enable posterior sampling for this class."
493        )
494    })?;
495    // `penalized_hessian` is stored unscaled. To draw Laplace
496    // approximations of `N(mode, cov_scale·H⁻¹)` we solve `Lᵀ δ = ε` (so
497    // `Var(δ) = H⁻¹`) and then rescale by `√cov_scale`, where `cov_scale`
498    // is the *coefficient-covariance* scale the fit uses for `Vb` — exactly
499    // the quantity `summary()`'s Wald SE is built from. This is `σ̂²` for a
500    // profiled Gaussian and `1.0` for every family whose IRLS working weight
501    // already folds the dispersion / full Fisher information into the stored
502    // `H` (Binomial / Poisson / Gamma / Beta / Negative-Binomial / Tweedie),
503    // so `Vb = H⁻¹` needs no extra dispersion factor. Using the dispersion's
504    // `√φ` here instead would double-count the dispersion for Beta, whose
505    // `dispersion()` is `Known(1/(1+φ))` even though its `cov_scale` is `1.0`,
506    // shrinking every posterior SD by `√(1/(1+φ))` (gam#1722). For the
507    // profiled Gaussian `cov_scale == σ̂² == φ`, so this matches the previous
508    // `√φ` behaviour exactly; it only changes (fixes) Beta. This keeps the
509    // draw spread identical to the reported `summary().std_error`, like the
510    // sibling bounded-coefficient path (gam#1514).
511    let sqrt_cov_scale = sampling_sqrt_covariance_scale(&fit, rationale)?;
512    if h.nrows() != p || h.ncols() != p {
513        return Err(format!(
514            "{rationale}: penalised Hessian is {}x{}, expected {}x{}",
515            h.nrows(),
516            h.ncols(),
517            p,
518            p
519        ));
520    }
521    let chol = h.cholesky(Side::Lower).map_err(|err| {
522        format!("{rationale}: Cholesky factorisation of the penalised Hessian failed: {err:?}")
523    })?;
524    let l = chol.lower_triangular();
525
526    // `validate_nuts_config` above guarantees `n_chains >= 1` and
527    // `n_samples >= 4`, so the draw grid is always non-empty and densely
528    // filled — no `.max(1)` clamping or bounds guard is needed.
529    let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
530    let mut samples = Array2::<f64>::zeros((n_total, p));
531    let mut eps = Array1::<f64>::zeros(p);
532    let mut delta = Array1::<f64>::zeros(p);
533    for chain in 0..cfg.n_chains {
534        let mut rng = rand::rngs::StdRng::seed_from_u64(chain_stream_seed(
535            cfg.seed,
536            chain,
537            0xA0B7_6C5D_E431_298F,
538        ));
539        for draw in 0..cfg.n_samples {
540            let k = chain * cfg.n_samples + draw;
541            for i in 0..p {
542                eps[i] = sample_standard_normal(&mut rng);
543            }
544            back_substitution_lower_transpose_guarded_into(&l, &eps, &mut delta);
545            for i in 0..p {
546                // `delta` has covariance H⁻¹; multiplying by `√cov_scale`
547                // produces a draw with covariance `cov_scale·H⁻¹`, matching
548                // the coefficient covariance `Vb` the rest of inference (and
549                // `summary()`'s Wald SE) assumes.
550                samples[(k, i)] = mode[i] + sqrt_cov_scale * delta[i];
551            }
552        }
553    }
554
555    let posterior_mean = samples
556        .mean_axis(ndarray::Axis(0))
557        .unwrap_or_else(|| Array1::<f64>::zeros(p));
558    let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
559
560    Ok(NutsResult {
561        samples,
562        posterior_mean,
563        posterior_std,
564        rhat: 1.0,
565        ess: n_total as f64,
566        converged: true,
567    })
568}
569
570/// Draw constrained transformation-normal posterior samples by rejection from
571/// the Laplace Gaussian `N(mode, cov_scale·H⁻¹)`, keeping only draws inside the
572/// monotonicity cone `Γ = Ψ Aᵀ ≥ 0` (gam#2306 §5).
573///
574/// The truncated posterior IS the model: a draw whose realized shape field has
575/// any negative entry is a non-monotone transformation and not a member of the
576/// parameter space, so rejection is exact sampling from the correct target (no
577/// projection, no clamping). The fitted mode is strictly interior — the
578/// monotonicity floor keeps `h' > 0` — so acceptance is high for a well-fit
579/// model. If acceptance collapses (a pathological fit hugging the cone boundary)
580/// we refuse (typed) with the measured acceptance rate rather than silently
581/// returning unconstrained draws or spending an unbounded draw budget.
582fn sample_transformation_normal_constrained(
583    model: &SavedModel,
584    cfg: &NutsConfig,
585) -> Result<NutsResult, String> {
586    const RATIONALE: &str = "transformation-normal constrained posterior";
587    // Hard per-chain draw cap (no wall-clock budget): if a chain cannot fill its
588    // sample quota within `n_samples · MAX_REJECTION_FACTOR` draws the fit hugs
589    // the cone boundary and we refuse with the measured rate.
590    const MAX_REJECTION_FACTOR: usize = 1000;
591
592    validate_nuts_config(cfg).map_err(String::from)?;
593    let fit = fit_result_from_saved_model_for_prediction(model)?;
594    let mode = fit.beta.clone();
595    let p = mode.len();
596    if p == 0 {
597        return Err(format!(
598            "{RATIONALE}: cannot sample from an empty coefficient vector"
599        ));
600    }
601
602    let geometry = model.transformation_geometry.as_ref().ok_or_else(|| {
603        format!("{RATIONALE}: missing the direct-α geometry record; refit (gam#2306)")
604    })?;
605    let carrier = model.transformation_cone_carrier.as_ref().ok_or_else(|| {
606        format!(
607            "{RATIONALE}: missing the monotonicity-cone carrier (transformation_cone_carrier); \
608             refit to persist the cone so constrained sampling can certify draws"
609        )
610    })?;
611    let n = geometry.cone_carrier_row_count;
612    let p_cov = geometry.cone_carrier_covariate_width;
613    let p_resp = geometry.shape_coordinate_count + 1;
614    if carrier.len() != n.saturating_mul(p_cov) {
615        return Err(format!(
616            "{RATIONALE}: cone carrier length {} != {n} rows x {p_cov} covariate columns",
617            carrier.len()
618        ));
619    }
620    if p != p_resp.saturating_mul(p_cov) {
621        return Err(format!(
622            "{RATIONALE}: coefficient length {p} != p_resp {p_resp} x p_cov {p_cov}; the saved \
623             coefficient block does not match the persisted cone geometry"
624        ));
625    }
626    let psi = Array2::from_shape_vec((n, p_cov), carrier.clone())
627        .map_err(|err| format!("{RATIONALE}: cone carrier reshape to {n}x{p_cov} failed: {err}"))?;
628
629    // Feasibility: the realized shape field of each monotone (non-location) row
630    // `k` is `Γ_k = Ψ · A_{k,:} ≥ 0` on every certified training row.
631    let is_feasible = |beta: &Array1<f64>| -> bool {
632        for k in 1..p_resp {
633            let block = beta.slice(ndarray::s![k * p_cov..(k + 1) * p_cov]);
634            if psi.dot(&block).iter().any(|value| *value < 0.0) {
635                return false;
636            }
637        }
638        true
639    };
640    if !is_feasible(&mode) {
641        return Err(format!(
642            "{RATIONALE}: the fitted mode violates the monotonicity cone Γ ≥ 0 — the saved model is \
643             not a valid monotone transformation; refit"
644        ));
645    }
646
647    let h = fit.penalized_hessian().ok_or_else(|| {
648        format!(
649            "{RATIONALE}: requires the explicit penalised Hessian; refit with exact geometry export"
650        )
651    })?;
652    let sqrt_cov_scale = sampling_sqrt_covariance_scale(&fit, RATIONALE)?;
653    if h.nrows() != p || h.ncols() != p {
654        return Err(format!(
655            "{RATIONALE}: penalised Hessian is {}x{}, expected {p}x{p}",
656            h.nrows(),
657            h.ncols()
658        ));
659    }
660    let chol = h.cholesky(Side::Lower).map_err(|err| {
661        format!("{RATIONALE}: Cholesky factorisation of the penalised Hessian failed: {err:?}")
662    })?;
663    let l = chol.lower_triangular();
664
665    let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
666    let mut samples = Array2::<f64>::zeros((n_total, p));
667    let mut eps = Array1::<f64>::zeros(p);
668    let mut delta = Array1::<f64>::zeros(p);
669    let mut draw = Array1::<f64>::zeros(p);
670    let attempts_cap = cfg
671        .n_samples
672        .saturating_mul(MAX_REJECTION_FACTOR)
673        .max(MAX_REJECTION_FACTOR);
674    let mut total_attempts: u64 = 0;
675    let mut total_accepted: u64 = 0;
676
677    for chain in 0..cfg.n_chains {
678        let mut rng = rand::rngs::StdRng::seed_from_u64(chain_stream_seed(
679            cfg.seed,
680            chain,
681            0xA0B7_6C5D_E431_298F,
682        ));
683        let mut accepted_in_chain = 0usize;
684        let mut attempts_in_chain = 0usize;
685        while accepted_in_chain < cfg.n_samples {
686            if attempts_in_chain >= attempts_cap {
687                let rate = total_accepted as f64 / (total_attempts.max(1) as f64);
688                return Err(format!(
689                    "{RATIONALE}: acceptance collapsed — {total_accepted} accepted of \
690                     {total_attempts} draws (rate {rate:.3e}); the fit hugs the monotonicity-cone \
691                     boundary so its truncated posterior cannot be rejection-sampled within \
692                     {attempts_cap} draws per chain. Refit or widen the certified response support."
693                ));
694            }
695            attempts_in_chain += 1;
696            total_attempts += 1;
697            for i in 0..p {
698                eps[i] = sample_standard_normal(&mut rng);
699            }
700            back_substitution_lower_transpose_guarded_into(&l, &eps, &mut delta);
701            for i in 0..p {
702                draw[i] = mode[i] + sqrt_cov_scale * delta[i];
703            }
704            if is_feasible(&draw) {
705                let k = chain * cfg.n_samples + accepted_in_chain;
706                samples.row_mut(k).assign(&draw);
707                accepted_in_chain += 1;
708                total_accepted += 1;
709            }
710        }
711    }
712
713    let posterior_mean = samples
714        .mean_axis(ndarray::Axis(0))
715        .unwrap_or_else(|| Array1::<f64>::zeros(p));
716    let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
717    // Accepted draws are iid from the truncated posterior by construction, so the
718    // chains are exact-independent: rhat = 1 and ess = n_total.
719    Ok(NutsResult {
720        samples,
721        posterior_mean,
722        posterior_std,
723        rhat: 1.0,
724        ess: n_total as f64,
725        converged: true,
726    })
727}
728
729#[inline]
730fn sample_standard_normal<R: rand::Rng + ?Sized>(rng: &mut R) -> f64 {
731    // Box-Muller transform — sufficient for posterior-mean-style sampling.
732    // The same construction is used by the NUTS warmup; keeping it in
733    // sync avoids two divergent gaussian RNG paths inside the engine.
734    let u1 = rng.random::<f64>().max(1e-16);
735    let u2 = rng.random::<f64>();
736    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
737}
738
739fn sample_standard(
740    model: &SavedModel,
741    data: ArrayView2<'_, f64>,
742    col_map: &HashMap<String, usize>,
743    training_headers: Option<&Vec<String>>,
744    mut likelihood: LikelihoodSpec,
745    cfg: &NutsConfig,
746) -> Result<NutsResult, String> {
747    // A coefficient that needs a *constraint-aware* posterior sampler must not
748    // take the gaussian-identity closed-form Laplace shortcut: that shortcut
749    // draws an unconstrained `N(mode, φ·H⁻¹)`, which for an active bound puts
750    // ~half its mass on the forbidden side of the boundary. Three geometries
751    // qualify, and all reproduce on a default gaussian model:
752    //   * a `bounded(x, min, max)` interval transform (#1508) — sampled on its
753    //     latent logit scale by `sample_standard_bounded`;
754    //   * a `nonnegative()`/`nonpositive()`/`linear(min,max)`/`constrain()` box
755    //     bound on a parametric coefficient (#1507); and
756    //   * a monotone/convex/concave shape cone on a spline (#1509).
757    // The latter two are sampled from the truncated Gaussian below. Detect all
758    // three cheaply from the saved termspec so the common, fully-unconstrained
759    // gaussian path keeps its fast exact fallback without building the design;
760    // the precise dispatch (and the authoritative `design.linear_constraints`
761    // check) happens after the design is assembled.
762    let needs_constraint_aware_sampler = model.resolved_termspec.as_ref().is_some_and(|ts| {
763        ts.linear_terms.iter().any(|term| {
764            !matches!(
765                term.coefficient_geometry,
766                LinearCoefficientGeometry::Unconstrained
767            ) || term.coefficient_min.is_some()
768                || term.coefficient_max.is_some()
769        }) || ts
770            .smooth_terms
771            .iter()
772            .any(|term| !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None))
773    });
774    if likelihood.is_gaussian_identity() && !needs_constraint_aware_sampler {
775        return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
776    }
777    if model.has_link_wiggle() {
778        // A Gaussian-identity link-wiggle model is sampled from its saved
779        // closed-form joint Laplace posterior (the mean and wiggle coefficients
780        // are jointly Gaussian); only the non-Gaussian wiggle posterior needs
781        // the dedicated link-wiggle NUTS path. Preserved from the original
782        // dispatch, where the Gaussian-identity shortcut ran ahead of the
783        // wiggle branch and so claimed Gaussian wiggle models for the
784        // closed-form path.
785        if likelihood.is_gaussian_identity() {
786            return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
787        }
788        return sample_standard_link_wiggle(
789            model,
790            data,
791            col_map,
792            training_headers,
793            likelihood,
794            cfg,
795        );
796    }
797    let parsed = parse_formula(&model.formula)?;
798    let y_col = resolve_role_col(col_map, &parsed.response, "response")?;
799    let y = data.column(y_col).to_owned();
800    let spec = resolve_termspec_for_prediction(
801        &model.resolved_termspec,
802        training_headers,
803        col_map,
804        "resolved_termspec",
805    )?;
806    let design = build_term_collection_design(data, &spec)
807        .map_err(|e| format!("failed to build term collection design: {e}"))?;
808
809    // ---- Constraint-aware posterior dispatch -------------------------------
810    //
811    // A coefficient subject to an *active* constraint sits on the boundary of
812    // its feasible region, so a plain unconstrained draw `N(mode, φ·H⁻¹)`
813    // places ~half its mass on the forbidden side. The constrained geometry
814    // must therefore be reconstructed *here*, ahead of both the
815    // Gaussian-identity closed-form shortcut and the GLM-NUTS fallback —
816    // neither of which is aware of the feasible region (#1507/#1508/#1509).
817    // The fit pins the point estimate correctly; only the posterior was blind.
818
819    // (1) bounded() interval coefficients are not sampled by the GLM-NUTS path.
820    // That path runs the Hamiltonian over the *raw* linear design with the
821    // saved user-scale mode, treating every coefficient as an unconstrained,
822    // Gaussian-penalized parameter. Bounded terms are fit through a custom
823    // family that drives eta via an interval transform `beta = min + (max-min)·
824    // sigmoid(theta)` of an unconstrained latent `theta`. The posterior is
825    // Gaussian on that *latent* scale (which is exactly where the fit treats the
826    // coefficient as a locally-quadratic, unconstrained parameter), so the
827    // correct draws are `theta ~ N(theta_mode, H_latent^{-1})` pushed forward
828    // through the interval map — never a Gaussian on the user scale, which can
829    // place mass outside [min,max] and discards the boundary-induced skew. The
830    // saved fit exports the user-scale mode and user-scale penalized Hessian;
831    // `sample_bounded_latent_posterior_internal` reconstructs the latent
832    // geometry via the exact inverse delta-method (`H_latent = J H_user J`) and
833    // returns user-scale draws that always lie strictly inside the interval.
834    // This must precede the Gaussian-identity shortcut: a Gaussian `bounded()`
835    // model would otherwise take the closed-form path and emit a user-scale
836    // Gaussian that spills outside the interval (#1508).
837    let has_bounded = spec.linear_terms.iter().any(|term| {
838        matches!(
839            term.coefficient_geometry,
840            LinearCoefficientGeometry::Bounded { .. }
841        )
842    });
843    if has_bounded {
844        // Mirror the fit-time layout: linear coefficient `j` lives at column
845        // `intercept_range.end + j` of the model's coefficient vector. Bounds
846        // are on the original (user/data) scale, which is also the scale the
847        // saved beta and penalized Hessian live on.
848        let bounded_columns: Vec<gam_models::fit_orchestration::drivers::BoundedSampleColumn> =
849            spec.linear_terms
850                .iter()
851                .enumerate()
852                .filter_map(|(j, term)| match term.coefficient_geometry {
853                    LinearCoefficientGeometry::Bounded { min, max, .. } => Some(
854                        gam_models::fit_orchestration::drivers::BoundedSampleColumn {
855                            col_idx: design.intercept_range.end + j,
856                            min,
857                            max,
858                        },
859                    ),
860                    LinearCoefficientGeometry::Unconstrained => None,
861                })
862                .collect();
863        return sample_standard_bounded(model, cfg, &bounded_columns);
864    }
865
866    // (2) box / shape *inequality* constraints — `nonnegative()` /
867    // `linear(min,max)` / `constrain()` box bounds on a parametric coefficient
868    // (#1507) and the monotone/convex/concave shape cone `γ_j ≥ 0` on a spline
869    // (#1509). Both are reconstructed by `build_term_collection_design` into a
870    // single `A β ≥ b` polytope in the saved coefficient coordinate system, so
871    // one truncated-Gaussian sampler covers them uniformly. Like `bounded()`,
872    // this must precede the Gaussian-identity shortcut so a constrained
873    // Gaussian model is sampled inside its feasible region rather than from the
874    // boundary-centred unconstrained Gaussian.
875    if let Some(constraints) = design
876        .linear_constraints
877        .as_ref()
878        .filter(|c| c.a.nrows() > 0)
879    {
880        return sample_standard_truncated(model, cfg, constraints, &design.design);
881    }
882
883    // (3) unconstrained Gaussian identity — saved closed-form Laplace posterior.
884    if likelihood.is_gaussian_identity() {
885        return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
886    }
887
888    // (4) unconstrained non-Gaussian GLM — exact NUTS over the raw design,
889    // under the SAME prior weights the fit optimized (#2245 finding 16).
890    let weights = saved_prior_weights(model, data, col_map)?;
891    let dense_design_hmc = design
892        .design
893        .try_to_dense_governed("saved standard model HMC design")
894        .map_err(|error| error.to_string())?;
895    let p = dense_design_hmc.ncols();
896    // Both current dense sampler routes retain one additional n×p design:
897    // NUTS owns an Arc copy and Pólya-Gamma owns its row-scaled workspace.
898    // Reserve that simultaneous copy now and keep the charge through the call.
899    let sampler_design_copy_reservation = MemoryGovernor::global()
900        .try_reserve_dense_f64(
901            dense_design_hmc.nrows(),
902            dense_design_hmc.ncols(),
903            "saved standard model sampler design copy",
904        )
905        .map_err(|error| error.to_string())?;
906    let fit = fit_result_from_saved_model_for_prediction(model)?;
907    // Refresh the NB overdispersion `theta` from the fit's jointly-estimated
908    // `theta_hat` before sampling. The construction seed stored on the family
909    // spec (`theta: 1.0`) only seeds the inner solve; the NUTS NB log-likelihood
910    // / score (`src/inference/hmc.rs`) reads `theta` straight off this spec, so
911    // leaving the seed in place over-states `Var(y) = μ + μ²/θ` and inflates
912    // every coefficient's posterior SD (#1463 — the HMC sibling of the
913    // replicate-path bug #1124). `theta_fixed` NB carries the user's exact value
914    // in both the spec and the scale metadata, so this refresh is a no-op there.
915    // Mirrors how the replicate path reads `theta_hat` via the canonical
916    // `family_noise_parameter` helper (`negbin_theta().or(seed)`).
917    refresh_negbin_theta_for_sampling(&mut likelihood, fit.likelihood_scale);
918    if fit.beta.len() != p {
919        return Err(format!(
920            "standard sample: saved model has {} coefficients but rebuilt design has {} columns",
921            fit.beta.len(),
922            p,
923        ));
924    }
925    if fit.lambdas.len() != design.penalties.len() {
926        return Err(format!(
927            "standard sample: saved model has {} lambdas but rebuilt design has {} penalties",
928            fit.lambdas.len(),
929            design.penalties.len(),
930        ));
931    }
932    let penalty =
933        weighted_blockwise_penalty_sum(&design.penalties, fit.lambdas.as_slice().unwrap(), p);
934
935    let saved_offset_vec = saved_offset(model, data, col_map)?;
936    let base_offset =
937        saved_offset_vec.unwrap_or_else(|| Array1::<f64>::zeros(design.design.nrows()));
938    let offset_vec = design
939        .compose_offset(base_offset.view(), "saved standard model sampling")
940        .map_err(|error| error.to_string())?;
941
942    let result = run_nuts_sampling_flattened_family(
943        likelihood,
944        FamilyNutsInputs::Glm(GlmFlatInputs {
945            x: dense_design_hmc.view(),
946            y: y.view(),
947            weights: weights.view(),
948            penalty_matrix: penalty.view(),
949            mode: fit.beta.view(),
950            hessian: explicit_fit_hessian_for_whitening(&fit, p, "saved standard model")?.view(),
951            likelihood_scale: fit.likelihood_scale,
952            // Forward the saved training dispersion so NUTS whitening uses the
953            // posterior scale selected at fit time; fixed-scale families remain
954            // a no-op.
955            dispersion: resolved_fit_dispersion(&fit, "standard saved-model NUTS")?,
956            // The fit's optimized target: dropping the Jeffreys term Φ(β)
957            // from a Firth fit samples a different posterior (#2245
958            // finding 16). Persisted on the fit artifacts at fit time.
959            firth_bias_reduction: fit.artifacts.firth_bias_reduction,
960            offset: Some(offset_vec.view()),
961        }),
962        cfg,
963    )
964    .map_err(|e| format!("NUTS sampling failed: {e}"));
965    drop(sampler_design_copy_reservation);
966    result
967}
968
969/// Exact posterior draws for a standard GLM with `bounded()` coefficients.
970///
971/// The bounded coefficients are sampled on their natural latent (logit) scale —
972/// where the Laplace approximation is Gaussian — and every draw is pushed
973/// through the exact interval map so user-scale draws always lie strictly inside
974/// `[min, max]` and carry the boundary-induced skew. Non-bounded coefficients
975/// are drawn as the ordinary Gaussian Laplace component of the same joint
976/// posterior, so cross-coefficient correlations with the bounded columns are
977/// preserved (the latent precision is the full `H_latent = J H_user J`).
978fn sample_standard_bounded(
979    model: &SavedModel,
980    cfg: &NutsConfig,
981    bounded_columns: &[gam_models::fit_orchestration::drivers::BoundedSampleColumn],
982) -> Result<NutsResult, String> {
983    validate_nuts_config(cfg).map_err(String::from)?;
984    let fit = fit_result_from_saved_model_for_prediction(model)?;
985    let mode = fit.beta.clone();
986    let p = mode.len();
987    if p == 0 {
988        return Err(
989            "standard bounded-coefficient posterior: cannot sample from an empty coefficient vector"
990                .to_string(),
991        );
992    }
993    // The bounded fit exports the UNSCALED user-scale penalized Hessian; the
994    // latent sampler reconstructs the latent precision from it via the exact
995    // inverse delta-method. (`explicit_fit_hessian_for_whitening` returns this
996    // same user-scale penalized Hessian for a saved standard fit.)
997    let user_hessian =
998        explicit_fit_hessian_for_whitening(&fit, p, "saved standard bounded-coefficient model")?;
999    // The exported Hessian carries unit implicit dispersion, so the latent
1000    // posterior covariance is `cov_scale·H_latent⁻¹` with `cov_scale` the
1001    // coefficient-covariance scale the fit used for `Vb` (`σ̂²` for a profiled
1002    // Gaussian, `1` for fixed-scale Binomial). Re-applying `√cov_scale` here
1003    // keeps the draw spread identical to the reported `summary().std_error`
1004    // (gam#1514); the truncated-constraint path does the analogous √φ lift.
1005    let sqrt_cov_scale =
1006        sampling_sqrt_covariance_scale(&fit, "standard bounded-coefficient posterior")?;
1007    let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
1008    let samples = gam_models::fit_orchestration::drivers::sample_bounded_latent_posterior_internal(
1009        &mode,
1010        user_hessian,
1011        bounded_columns,
1012        n_total,
1013        sqrt_cov_scale,
1014        chain_stream_seed(cfg.seed, 0, 0xB0DD_ED5E_ED90_1A7Cu64),
1015    )
1016    .map_err(|e| format!("standard bounded-coefficient posterior sampling failed: {e}"))?;
1017
1018    let posterior_mean = samples
1019        .mean_axis(ndarray::Axis(0))
1020        .unwrap_or_else(|| Array1::<f64>::zeros(p));
1021    let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
1022
1023    Ok(NutsResult {
1024        samples,
1025        posterior_mean,
1026        posterior_std,
1027        rhat: 1.0,
1028        ess: n_total as f64,
1029        converged: true,
1030    })
1031}
1032
1033/// Exact posterior draws for a standard GLM whose coefficients carry linear
1034/// *inequality* constraints `A β ≥ b` — `nonnegative()` / `linear(min,max)` /
1035/// `constrain()` box bounds on a parametric term (#1507) and the
1036/// monotone/convex/concave shape cone `γ_j ≥ 0` on a spline (#1509).
1037///
1038/// The posterior is the Laplace Gaussian `N(mode, φ·H⁻¹)` *truncated* to the
1039/// feasible polytope. For a Gaussian-identity model this is the exact
1040/// posterior; for a non-Gaussian GLM it is the constraint-respecting Laplace
1041/// approximation — the same modelling choice the `bounded()` term makes. The
1042/// draws are produced by exact reflective Hamiltonian Monte Carlo
1043/// ([`crate::truncated_gaussian`]), so every draw is feasible and each draw's
1044/// marginal law is exactly the truncated Gaussian. Successive draws are only
1045/// independent when the quarter-period trajectory hits no wall; whenever a
1046/// constraint is active at the mode the trajectory reflects on every draw and
1047/// consecutive draws are autocorrelated, so `rhat`/`ess` are MEASURED with the
1048/// split-chain Gelman–Rubin diagnostic rather than asserted.
1049fn sample_standard_truncated(
1050    model: &SavedModel,
1051    cfg: &NutsConfig,
1052    constraints: &gam_solve::pirls::LinearInequalityConstraints,
1053    design: &gam_linalg::matrix::DesignMatrix,
1054) -> Result<NutsResult, String> {
1055    validate_nuts_config(cfg).map_err(String::from)?;
1056    let fit = fit_result_from_saved_model_for_prediction(model)?;
1057    let mode = fit.beta.clone();
1058    let p = mode.len();
1059    if p == 0 {
1060        return Err(
1061            "standard constrained-coefficient posterior: cannot sample from an empty coefficient \
1062             vector"
1063                .to_string(),
1064        );
1065    }
1066    // The saved standard fit exports the unscaled user-scale penalised Hessian
1067    // `H`; the truncated sampler whitens with its Cholesky and re-applies the
1068    // √(coefficient covariance scale) so the posterior covariance is
1069    // `cov_scale·H⁻¹`, identical to the unconstrained Gaussian/bounded paths
1070    // (#679): the scale is φ for Gaussian-like families and 1 for
1071    // Gamma/Tweedie/NB, whose IRLS weights already carry the full Fisher
1072    // information — re-applying the response φ there would shrink or inflate
1073    // every constrained interval by √φ.
1074    let penalized_hessian =
1075        explicit_fit_hessian_for_whitening(&fit, p, "saved standard constrained model")?;
1076    let sqrt_cov_scale =
1077        sampling_sqrt_covariance_scale(&fit, "standard constrained-coefficient posterior")?;
1078
1079    // Recover the UNCONSTRAINED Gaussian center of the local quadratic (#2245
1080    // finding 20). A Gaussian truncated to the polytope stays centred at its
1081    // pre-truncation mean `H⁻¹X′Wz`; the boundary KKT mode is not that mean.
1082    // With every constraint strictly inactive at the mode the KKT gradient is
1083    // zero and the two coincide, so the geometry solve is only required when a
1084    // constraint is active.
1085    let mode_scale = mode.iter().map(|v| v.abs()).fold(1.0_f64, f64::max);
1086    let min_slack = (constraints.a.dot(&mode) - &constraints.b)
1087        .iter()
1088        .cloned()
1089        .fold(f64::INFINITY, f64::min);
1090    let center = if min_slack > 1e-8 * mode_scale {
1091        mode.clone()
1092    } else {
1093        let geometry = fit.geometry.as_ref().ok_or_else(|| {
1094            "standard constrained-coefficient posterior: an inequality constraint is active at \
1095             the mode but the saved model carries no working geometry to recover the \
1096             unconstrained Gaussian center; refit with exact geometry export"
1097                .to_string()
1098        })?;
1099        let working = geometry.working.as_ref().ok_or_else(|| {
1100            "standard constrained-coefficient posterior: an inequality constraint is active at \
1101             the mode but the saved coefficient geometry has no owned single-diagonal working \
1102             evidence; Exact-Newton and multi-parameter fits cannot reconstruct the \
1103             unconstrained Gaussian center from row geometry"
1104                .to_string()
1105        })?;
1106        let n = design.nrows();
1107        if working.weights.len() != n || working.response.len() != n {
1108            return Err(format!(
1109                "standard constrained-coefficient posterior: saved working geometry has {} rows \
1110                 but the rebuilt design has {n}",
1111                working.weights.len(),
1112            ));
1113        }
1114        let wz = &working.weights * &working.response;
1115        let rhs = design.transpose_vector_multiply(&wz);
1116        let chol = penalized_hessian.cholesky(Side::Lower).map_err(|e| {
1117            format!(
1118                "standard constrained-coefficient posterior: Cholesky of the penalised Hessian \
1119                 failed while recovering the unconstrained center: {e:?}"
1120            )
1121        })?;
1122        gam_linalg::triangular::cholesky_solve_vector(&chol.lower_triangular(), &rhs)
1123    };
1124
1125    let samples = crate::truncated_gaussian::sample_truncated_gaussian_posterior(
1126        &center,
1127        &mode,
1128        &penalized_hessian,
1129        sqrt_cov_scale,
1130        constraints,
1131        cfg.n_samples,
1132        cfg.n_chains,
1133        chain_stream_seed(cfg.seed, 0, 0x7290_C047_5D6E_B14Du64),
1134    )?;
1135    let posterior_mean = samples
1136        .mean_axis(ndarray::Axis(0))
1137        .unwrap_or_else(|| Array1::<f64>::zeros(p));
1138    let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
1139
1140    // Reflective HMC draws are iid only while no wall is hit; an active
1141    // constraint at the mode makes every trajectory reflect, correlating
1142    // consecutive draws. Measure the diagnostics instead of asserting the
1143    // iid triple (the sampler stacks rows chain-major: chain*n_samples+draw).
1144    let mut chains = ndarray::Array3::<f64>::zeros((cfg.n_chains, cfg.n_samples, p));
1145    for chain in 0..cfg.n_chains {
1146        for draw in 0..cfg.n_samples {
1147            let row = chain * cfg.n_samples + draw;
1148            for j in 0..p {
1149                chains[(chain, draw, j)] = samples[(row, j)];
1150            }
1151        }
1152    }
1153    let (rhat, ess) = super::hmc_io::compute_split_rhat_and_ess(&chains);
1154    let converged = rhat < 1.1 && ess > 100.0;
1155
1156    Ok(NutsResult {
1157        samples,
1158        posterior_mean,
1159        posterior_std,
1160        rhat,
1161        ess,
1162        converged,
1163    })
1164}
1165
1166fn sample_standard_link_wiggle(
1167    model: &SavedModel,
1168    data: ArrayView2<'_, f64>,
1169    col_map: &HashMap<String, usize>,
1170    training_headers: Option<&Vec<String>>,
1171    likelihood: LikelihoodSpec,
1172    cfg: &NutsConfig,
1173) -> Result<NutsResult, String> {
1174    let parsed = parse_formula(&model.formula)?;
1175    let y_col = resolve_role_col(col_map, &parsed.response, "response")?;
1176    let y = data.column(y_col).to_owned();
1177
1178    let spec = resolve_termspec_for_prediction(
1179        &model.resolved_termspec,
1180        training_headers,
1181        col_map,
1182        "resolved_termspec",
1183    )?;
1184    let design = build_term_collection_design(data, &spec)
1185        .map_err(|e| format!("failed to build term collection design: {e}"))?;
1186    let p_main = design.design.ncols();
1187
1188    let fit = fit_result_from_saved_model_for_prediction(model)?;
1189    let wiggle_runtime = model
1190        .saved_prediction_runtime()?
1191        .link_wiggle
1192        .ok_or_else(|| "link-wiggle model is missing wiggle runtime metadata".to_string())?;
1193    let mode_beta = fit
1194        .block_by_role(BlockRole::Mean)
1195        .ok_or_else(|| "standard link-wiggle model is missing Mean coefficient block".to_string())?
1196        .beta
1197        .clone();
1198    let mode_theta = fit
1199        .block_by_role(BlockRole::LinkWiggle)
1200        .ok_or_else(|| {
1201            "standard link-wiggle model is missing LinkWiggle coefficient block".to_string()
1202        })?
1203        .beta
1204        .clone();
1205    let p_wiggle = mode_theta.len();
1206    let p_total = mode_beta.len() + p_wiggle;
1207
1208    if mode_beta.len() != p_main {
1209        return Err(format!(
1210            "link-wiggle sample: saved mean block has {} coefficients but rebuilt design has {} columns",
1211            mode_beta.len(),
1212            p_main,
1213        ));
1214    }
1215    if fit.beta.len() != p_total {
1216        return Err(format!(
1217            "link-wiggle sample: saved beta has {} coefficients but design has {} main + {} wiggle = {} total",
1218            fit.beta.len(),
1219            p_main,
1220            p_wiggle,
1221            p_total,
1222        ));
1223    }
1224
1225    let hessian = &fit
1226        .geometry
1227        .as_ref()
1228        .ok_or_else(|| {
1229            "link-wiggle model is missing explicit joint Hessian geometry; refit with exact Hessian export"
1230                .to_string()
1231        })?
1232        .penalized_hessian;
1233    validate_explicit_link_wiggle_joint_hessian(hessian, p_total)?;
1234
1235    let n_base_penalties = design.penalties.len();
1236    let base_lambdas = fit
1237        .block_by_role(BlockRole::Mean)
1238        .ok_or_else(|| "standard link-wiggle model is missing Mean block lambdas".to_string())?
1239        .lambdas
1240        .view();
1241    if base_lambdas.len() != n_base_penalties {
1242        return Err(format!(
1243            "link-wiggle sample: mean block has {} lambdas but rebuilt design has {} base penalties",
1244            base_lambdas.len(),
1245            n_base_penalties,
1246        ));
1247    }
1248
1249    let penalty_base =
1250        weighted_blockwise_penalty_sum(&design.penalties, base_lambdas.as_slice().unwrap(), p_main);
1251
1252    let wiggle_lambdas_owned = fit
1253        .lambdas_linkwiggle()
1254        .ok_or_else(|| "standard link-wiggle model is missing LinkWiggle lambdas".to_string())?;
1255    let wiggle_lambdas = wiggle_lambdas_owned.view();
1256    let degree = wiggle_runtime.degree;
1257    let knot_arr = Array1::from_vec(wiggle_runtime.knots.clone());
1258
1259    let penalty_link = saved_link_wiggle_penalty_matrix(&wiggle_runtime, wiggle_lambdas, p_wiggle)?;
1260
1261    // Fitted prior weights and offset, so the sampled target is exactly the
1262    // fitted model's posterior (#2245 finding 16). The offset also enters the
1263    // wiggle abscissa q₀ = Xβ + offset below, matching the target's basis
1264    // evaluation.
1265    let weights = saved_prior_weights(model, data, col_map)?;
1266    let saved_offset_vec = saved_offset(model, data, col_map)?;
1267    let base_offset =
1268        saved_offset_vec.unwrap_or_else(|| Array1::<f64>::zeros(design.design.nrows()));
1269    let offset_vec = design
1270        .compose_offset(base_offset.view(), "saved link-wiggle model sampling")
1271        .map_err(|error| error.to_string())?;
1272
1273    let q0 = design.design.dot(&mode_beta) + &offset_vec;
1274    let (q0_min, q0_max) = q0
1275        .iter()
1276        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
1277            (lo.min(v), hi.max(v))
1278        });
1279
1280    let spline = LinkWiggleSplineArtifacts {
1281        knot_range: (q0_min, q0_max),
1282        knot_vector: knot_arr,
1283        degree,
1284    };
1285
1286    // Typed per-family likelihood parameters (#2245 finding 15): each family
1287    // names exactly what its log-likelihood needs, read from the FITTED scale
1288    // metadata. The historical single `scale` slot let Tweedie dispersion φ be
1289    // consumed as the variance power p — a different posterior entirely.
1290    let family = match (&likelihood.response, &likelihood.link) {
1291        (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {
1292            LinkWiggleFamilyParams::BinomialLogit
1293        }
1294        (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {
1295            LinkWiggleFamilyParams::BinomialProbit
1296        }
1297        (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {
1298            LinkWiggleFamilyParams::BinomialCLogLog
1299        }
1300        (ResponseFamily::Gaussian, _) => LinkWiggleFamilyParams::Gaussian {
1301            sigma: fit.standard_deviation,
1302        },
1303        (ResponseFamily::Poisson, _) => LinkWiggleFamilyParams::PoissonLog,
1304        (ResponseFamily::Tweedie { p }, _) => LinkWiggleFamilyParams::TweedieLog {
1305            power: *p,
1306            phi: fit.likelihood_scale.fixed_phi().ok_or_else(|| {
1307                "link-wiggle Tweedie sampling requires resolved dispersion metadata".to_string()
1308            })?,
1309        },
1310        (ResponseFamily::NegativeBinomial { .. }, _) => {
1311            LinkWiggleFamilyParams::NegativeBinomialLog {
1312                theta: fit.likelihood_scale.negbin_theta().ok_or_else(|| {
1313                    "link-wiggle negative-binomial sampling requires resolved theta metadata"
1314                        .to_string()
1315                })?,
1316            }
1317        }
1318        (ResponseFamily::Gamma, _) => LinkWiggleFamilyParams::GammaLog {
1319            shape: fit.likelihood_scale.gamma_shape().ok_or_else(|| {
1320                "link-wiggle Gamma sampling requires resolved shape metadata".to_string()
1321            })?,
1322        },
1323        _ => {
1324            return Err(format!(
1325                "NUTS sampling with link wiggle is not supported for family {}",
1326                likelihood.pretty_name()
1327            ));
1328        }
1329    };
1330
1331    let wiggle_nuts_dense = design
1332        .design
1333        .try_to_dense_governed("saved link-wiggle HMC design")
1334        .map_err(|error| error.to_string())?;
1335    // LinkWigglePosterior owns one n×p copy for the duration of NUTS. Charge
1336    // that simultaneous allocation before entering the sampler; the governed
1337    // source matrix retains its own charge independently.
1338    let sampler_design_copy_reservation = MemoryGovernor::global()
1339        .try_reserve_dense_f64(
1340            wiggle_nuts_dense.nrows(),
1341            wiggle_nuts_dense.ncols(),
1342            "saved link-wiggle sampler design copy",
1343        )
1344        .map_err(|error| error.to_string())?;
1345    let result = run_link_wiggle_nuts_sampling(
1346        wiggle_nuts_dense.view(),
1347        y.view(),
1348        weights.view(),
1349        Some(offset_vec.view()),
1350        penalty_base.view(),
1351        penalty_link.view(),
1352        mode_beta.view(),
1353        mode_theta.view(),
1354        hessian.view(),
1355        spline,
1356        family,
1357        cfg,
1358    )
1359    .map_err(|e| format!("link-wiggle NUTS sampling failed: {e}"));
1360    drop(sampler_design_copy_reservation);
1361    result
1362}
1363
1364fn sample_survival(
1365    model: &SavedModel,
1366    data: ArrayView2<'_, f64>,
1367    col_map: &HashMap<String, usize>,
1368    training_headers: Option<&Vec<String>>,
1369    cfg: &NutsConfig,
1370) -> Result<NutsResult, String> {
1371    let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1372    if matches!(
1373        saved_likelihood_mode,
1374        SurvivalLikelihoodMode::Latent
1375            | SurvivalLikelihoodMode::LatentBinary
1376            | SurvivalLikelihoodMode::LocationScale
1377    ) {
1378        return laplace_gaussian_fallback(model, cfg, "survival posterior fallback");
1379    }
1380    // `survival_entry == None` is the right-censored shorthand
1381    // `Surv(time, event)`: training synthesized a zero entry column,
1382    // and posterior sampling must do the same so artifacts fit with
1383    // the shorthand are first-class through `gam sample` /
1384    // `model.sample` just like `gam predict` already handles them in
1385    // `run_predict_survival`. The resolution flows through the shared
1386    // `resolve_saved_survival_time_columns` helper so every consumer
1387    // of saved survival metadata applies the same fallback contract.
1388    let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1389    let exit_col = time_cols.exit_col;
1390    let eventname = model
1391        .survival_event
1392        .as_ref()
1393        .ok_or_else(|| "survival model missing event column metadata".to_string())?;
1394    let event_col = resolve_role_col(col_map, eventname, "event")?;
1395    let termspec = resolve_termspec_for_prediction(
1396        &model.resolved_termspec,
1397        training_headers,
1398        col_map,
1399        "resolved_termspec",
1400    )?;
1401    let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1402    let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1403    let cov_design = build_term_collection_design(cov_input, &termspec)
1404        .map_err(|e| format!("failed to build survival design: {e}"))?;
1405    let n = data.nrows();
1406    let p_cov = cov_design.design.ncols();
1407    let mut age_entry = Array1::<f64>::zeros(n);
1408    let mut age_exit = Array1::<f64>::zeros(n);
1409    let mut event_target = Array1::<u8>::zeros(n);
1410    let event_competing = Array1::<u8>::zeros(n);
1411    let weights = Array1::<f64>::ones(n);
1412    for i in 0..n {
1413        let (t0, t1) = normalize_survival_time_pair(
1414            time_cols.row_entry_time(data, i),
1415            data[[i, exit_col]],
1416            i,
1417        )?;
1418        age_entry[i] = t0;
1419        age_exit[i] = t1;
1420        event_target[i] = if data[[i, event_col]] >= 0.5 { 1 } else { 0 };
1421    }
1422    let time_cfg = load_survival_time_basis_config_from_model(model)?;
1423    let time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
1424    let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
1425        &time_build.basisname,
1426        time_build.degree,
1427        time_build.knots.as_ref(),
1428        time_build.keep_cols.as_ref(),
1429        time_build.smooth_lambda,
1430    )?;
1431    let time_anchor_row = if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1432        let time_anchor = model
1433            .survival_time_anchor
1434            .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1435        Some(evaluate_survival_time_basis_row(
1436            time_anchor,
1437            &resolved_time_cfg,
1438        )?)
1439    } else {
1440        None
1441    };
1442    let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
1443    let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
1444        build_survival_time_offsets_for_likelihood(
1445            &age_entry,
1446            &age_exit,
1447            &baseline_cfg,
1448            saved_likelihood_mode,
1449            None,
1450        )?;
1451    if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1452        let time_anchor = model
1453            .survival_time_anchor
1454            .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1455        add_survival_time_derivative_guard_offset(
1456            &age_entry,
1457            &age_exit,
1458            time_anchor,
1459            survival_derivative_guard_for_likelihood(saved_likelihood_mode),
1460            &mut eta_offset_entry,
1461            &mut eta_offset_exit,
1462            &mut derivative_offset_exit,
1463        )?;
1464    }
1465    // A covariate term's inhomogeneous boundary lift contributes to both
1466    // cumulative-hazard evaluations. It is independent of time, so it does
1467    // not contribute to the time derivative channel.
1468    eta_offset_entry += &cov_design.affine_offset;
1469    eta_offset_exit += &cov_design.affine_offset;
1470    let saved_timewiggle = saved_baseline_timewiggle_components(
1471        &eta_offset_entry,
1472        &eta_offset_exit,
1473        &derivative_offset_exit,
1474        model,
1475    )?;
1476    let p_time = time_build.x_exit_time.ncols();
1477    let p_timewiggle = saved_timewiggle
1478        .as_ref()
1479        .map(|(_, exit, _)| exit.ncols())
1480        .unwrap_or(0);
1481    let p = p_time
1482        .checked_add(p_timewiggle)
1483        .and_then(|width| width.checked_add(p_cov))
1484        .ok_or_else(|| "saved survival sampler design width overflow".to_string())?;
1485    // At peak, the three assembled designs coexist with the three owned copies
1486    // inside WorkingModelSurvival. The fit-state model and the NUTS target are
1487    // constructed sequentially below, so this is the complete peak of final
1488    // n×p design copies. Reserve it atomically before any final assembly.
1489    let survival_design_reservation = MemoryGovernor::global()
1490        .try_reserve_dense_f64_copies(
1491            n,
1492            p,
1493            SURVIVAL_DESIGN_LIVE_COPIES,
1494            "saved survival sampler design live set",
1495        )
1496        .map_err(|error| error.to_string())?;
1497    let mut x_entry = Array2::<f64>::zeros((n, p));
1498    let mut x_exit = Array2::<f64>::zeros((n, p));
1499    let mut x_derivative = Array2::<f64>::zeros((n, p));
1500    let row_chunk_target_bytes = ResourcePolicy::default_library().row_chunk_target_bytes;
1501    if p_time > 0 {
1502        stream_design_into(
1503            &time_build.x_entry_time,
1504            x_entry.slice_mut(s![.., ..p_time]),
1505            row_chunk_target_bytes,
1506            "saved survival entry-time design",
1507        )?;
1508        stream_design_into(
1509            &time_build.x_exit_time,
1510            x_exit.slice_mut(s![.., ..p_time]),
1511            row_chunk_target_bytes,
1512            "saved survival exit-time design",
1513        )?;
1514        stream_design_into(
1515            &time_build.x_derivative_time,
1516            x_derivative.slice_mut(s![.., ..p_time]),
1517            row_chunk_target_bytes,
1518            "saved survival derivative-time design",
1519        )?;
1520        if let Some(anchor_row) = time_anchor_row.as_ref() {
1521            if anchor_row.len() != p_time {
1522                return Err(format!(
1523                    "survival time anchoring column mismatch: design={p_time}, anchor={}",
1524                    anchor_row.len(),
1525                ));
1526            }
1527            for mut row in x_entry.slice_mut(s![.., ..p_time]).rows_mut() {
1528                row -= &anchor_row.view();
1529            }
1530            for mut row in x_exit.slice_mut(s![.., ..p_time]).rows_mut() {
1531                row -= &anchor_row.view();
1532            }
1533        }
1534    }
1535    if let Some((entry_w, exit_w, deriv_w)) = saved_timewiggle.as_ref()
1536        && p_timewiggle > 0
1537    {
1538        x_entry
1539            .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1540            .assign(entry_w);
1541        x_exit
1542            .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1543            .assign(exit_w);
1544        x_derivative
1545            .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1546            .assign(deriv_w);
1547    }
1548    if p_cov > 0 {
1549        let cov_range = (p_time + p_timewiggle)..(p_time + p_timewiggle + p_cov);
1550        stream_design_into(
1551            &cov_design.design,
1552            x_entry.slice_mut(s![.., cov_range.clone()]),
1553            row_chunk_target_bytes,
1554            "saved survival covariate design",
1555        )?;
1556        x_exit
1557            .slice_mut(s![.., cov_range.clone()])
1558            .assign(&x_entry.slice(s![.., cov_range]));
1559    }
1560    // The final assembly now owns every covariate column needed by sampling.
1561    // Release the rebuilt term collection before allocating model-owned copies.
1562    drop(cov_design);
1563    let mut penalty_blocks: Vec<PenaltyBlock> = Vec::new();
1564    for (idx, s) in time_build.penalties.iter().enumerate() {
1565        if s.nrows() == p_time && s.ncols() == p_time {
1566            penalty_blocks.push(PenaltyBlock {
1567                matrix: s.clone(),
1568                lambda: time_build
1569                    .smooth_lambda
1570                    .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1571                range: 0..p_time,
1572                nullspace_dim: time_build.nullspace_dims.get(idx).copied().unwrap_or(0),
1573            });
1574        }
1575    }
1576    let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
1577    if let Some((_, exit_w, _)) = saved_timewiggle.as_ref() {
1578        let start = p_time;
1579        let end = start + exit_w.ncols();
1580        let wiggle_lambda_offset = penalty_blocks.len();
1581        let wiggle_cfg = saved_baseline_timewiggle_spec(model)?.ok_or_else(|| {
1582            "saved baseline-timewiggle model missing baseline-timewiggle metadata".to_string()
1583        })?;
1584        let wiggle_degree = wiggle_cfg.degree;
1585        let wiggle_knots =
1586            Array1::from_vec(model.baseline_timewiggle_knots.clone().ok_or_else(|| {
1587                "saved baseline-timewiggle model missing baseline_timewiggle_knots".to_string()
1588            })?);
1589        let mut seed = Array1::<f64>::zeros(2 * n);
1590        for i in 0..n {
1591            seed[i] = eta_offset_entry[i];
1592            seed[n + i] = eta_offset_exit[i];
1593        }
1594        let (primary_order, extra_orders) =
1595            split_wiggle_penalty_orders(2, &wiggle_cfg.penalty_orders)?;
1596        let mut block = buildwiggle_block_input_from_knots(
1597            seed.view(),
1598            &wiggle_knots,
1599            wiggle_degree,
1600            primary_order,
1601            wiggle_cfg.double_penalty,
1602        )?;
1603        append_selected_wiggle_function_penalties(
1604            &mut block,
1605            &wiggle_knots,
1606            wiggle_degree,
1607            &extra_orders,
1608        )
1609        .map_err(|e| format!("baseline-timewiggle penalty reconstruction failed: {e}"))?;
1610        for (widx, s) in block.penalties.iter().enumerate() {
1611            let s = match s {
1612                gam_solve::estimate::PenaltySpec::Block { local, .. } => local,
1613                gam_solve::estimate::PenaltySpec::Dense(m)
1614                | gam_solve::estimate::PenaltySpec::DenseWithMean { matrix: m, .. } => m,
1615            };
1616            if s.nrows() == exit_w.ncols() && s.ncols() == exit_w.ncols() {
1617                penalty_blocks.push(PenaltyBlock {
1618                    matrix: s.clone(),
1619                    lambda: time_build
1620                        .smooth_lambda
1621                        .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1622                    range: start..end,
1623                    nullspace_dim: block.nullspace_dims.get(widx).copied().unwrap_or(0),
1624                });
1625            }
1626        }
1627        for (local_idx, block_penalty) in penalty_blocks[wiggle_lambda_offset..]
1628            .iter_mut()
1629            .enumerate()
1630        {
1631            if let Some(&lam) = fit_saved.lambdas.get(wiggle_lambda_offset + local_idx) {
1632                block_penalty.lambda = lam;
1633            }
1634        }
1635    }
1636    // Wiggle columns and their penalty blocks have been copied into their final
1637    // owners; the three source matrices must not overlap the sampler copies.
1638    drop(saved_timewiggle);
1639    let ridge_lambda = model.survivalridge_lambda.ok_or_else(|| {
1640        "saved survival model is missing survivalridge_lambda; refusing to \
1641         pick a load-time default (the historical 1e-4 fallback silently \
1642         disagreed with the 1e-6 fit-time default). Refit."
1643            .to_string()
1644    })?;
1645    let ridge_range_start = if time_build.basisname == "linear" && !model.has_baseline_time_wiggle()
1646    {
1647        1
1648    } else {
1649        0
1650    };
1651    // All time columns and penalty metadata are now represented in the final
1652    // assembly. Drop the three source designs before constructing the model.
1653    drop(time_build);
1654    if ridge_lambda > 0.0 && p > ridge_range_start {
1655        let dim = p - ridge_range_start;
1656        let mut ridge = Array2::<f64>::zeros((dim, dim));
1657        for d in 0..dim {
1658            ridge[[d, d]] = 1.0;
1659        }
1660        penalty_blocks.push(PenaltyBlock {
1661            matrix: ridge,
1662            lambda: ridge_lambda,
1663            range: ridge_range_start..p,
1664            nullspace_dim: 0,
1665        });
1666    }
1667    for (idx, block) in penalty_blocks.iter_mut().enumerate() {
1668        if let Some(&lam) = fit_saved.lambdas.get(idx) {
1669            block.lambda = lam;
1670        }
1671    }
1672    let penalties = PenaltyBlocks::new(penalty_blocks);
1673    let survivalspec = match model
1674        .survivalspec
1675        .as_deref()
1676        .unwrap_or("net")
1677        .to_ascii_lowercase()
1678        .as_str()
1679    {
1680        "net" => SurvivalSpec::Net,
1681        "crude" => {
1682            return Err("saved survival spec 'crude' is not supported by the one-hazard survival engine; refit or export a net survival model for this path"
1683                        .to_string());
1684        }
1685        other => {
1686            return Err(format!("unsupported saved survival spec '{other}'"));
1687        }
1688    };
1689    let monotonicity = SurvivalMonotonicityPenalty { tolerance: 0.0 };
1690    let mut model_surv = royston_parmar::working_model_from_flattened(
1691        penalties.clone(),
1692        monotonicity,
1693        survivalspec,
1694        RoystonParmarInputs {
1695            age_entry: age_entry.view(),
1696            age_exit: age_exit.view(),
1697            event_target: event_target.view(),
1698            event_competing: event_competing.view(),
1699            weights: weights.view(),
1700            x_entry: x_entry.view(),
1701            x_exit: x_exit.view(),
1702            x_derivative: x_derivative.view(),
1703            monotonicity_constraint_rows: None,
1704            monotonicity_constraint_offsets: None,
1705            eta_offset_entry: Some(eta_offset_entry.view()),
1706            eta_offset_exit: Some(eta_offset_exit.view()),
1707            derivative_offset_exit: Some(derivative_offset_exit.view()),
1708        },
1709    )
1710    .map_err(|e| format!("failed to construct survival model: {e}"))?;
1711    if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull {
1712        model_surv
1713            .set_structural_monotonicity(true, p_time + p_timewiggle)
1714            .map_err(|e| format!("failed to enable structural monotonicity: {e}"))?;
1715    }
1716    let beta0 = fit_saved.beta.clone();
1717    let survival_hessian_reservation = MemoryGovernor::global()
1718        .try_reserve_dense_f64(p, p, "saved survival sampler Hessian")
1719        .map_err(|error| error.to_string())?;
1720    let hessian = {
1721        let state = model_surv
1722            .update_state(&beta0)
1723            .map_err(|e| format!("failed to evaluate survival state: {e}"))?;
1724        match state.hessian {
1725            // The survival working state currently produces a dense Hessian.
1726            // Move it instead of cloning it through SymmetricMatrix::to_dense.
1727            gam_linalg::matrix::SymmetricMatrix::Dense(hessian) => hessian,
1728            // Preserve exactness if that implementation becomes sparse: the
1729            // p×p reservation above was acquired before this expansion.
1730            gam_linalg::matrix::SymmetricMatrix::Sparse(hessian) => {
1731                gam_linalg::matrix::SymmetricMatrix::Sparse(hessian).to_dense()
1732            }
1733        }
1734    };
1735    // The fit-state model owns three n×p copies. Release them before NUTS
1736    // constructs its own three copies, keeping the reserved peak at six.
1737    drop(model_surv);
1738    let result = run_survival_nuts_sampling_flattened(
1739        SurvivalFlatInputs {
1740            age_entry: age_entry.view(),
1741            age_exit: age_exit.view(),
1742            event_target: event_target.view(),
1743            event_competing: event_competing.view(),
1744            weights: weights.view(),
1745            x_entry: x_entry.view(),
1746            x_exit: x_exit.view(),
1747            x_derivative: x_derivative.view(),
1748            eta_offset_entry: Some(eta_offset_entry.view()),
1749            eta_offset_exit: Some(eta_offset_exit.view()),
1750            derivative_offset_exit: Some(derivative_offset_exit.view()),
1751        },
1752        penalties,
1753        monotonicity,
1754        survivalspec,
1755        saved_likelihood_mode != SurvivalLikelihoodMode::Weibull,
1756        p_time + p_timewiggle,
1757        beta0.view(),
1758        hessian.view(),
1759        cfg,
1760    )
1761    .map_err(|e| format!("survival NUTS sampling failed: {e}"));
1762    drop(survival_hessian_reservation);
1763    drop(survival_design_reservation);
1764    result
1765}
1766
1767#[cfg(test)]
1768mod tests {
1769    use super::*;
1770    use gam_linalg::matrix::{DenseDesignMatrix, DenseDesignOperator, LinearOperator};
1771    use gam_models::wiggle::WigglePenaltyBlockKind;
1772    use gam_problem::types::LikelihoodScaleMetadata;
1773
1774    /// #2306: fit and sampling must consume the same exact function-space
1775    /// penalty, including order and lambda topology. The highly nonuniform knot
1776    /// vector makes an unweighted coefficient-difference reconstruction
1777    /// observably different. With primary order one the anchored I-spline
1778    /// roughness is full rank, so `double_penalty=true` must not invent a ridge.
1779    #[test]
1780    fn standard_link_wiggle_sampling_penalty_matches_fit_value_gradient_hessian_2306() {
1781        let a = -1.3_f64;
1782        let b = 2.6_f64;
1783        let width = b - a;
1784        let mut knot_values = vec![a; 4];
1785        knot_values.extend([
1786            a + 0.03 * width,
1787            a + 0.21 * width,
1788            a + 0.22 * width,
1789            a + 0.68 * width,
1790            a + 0.94 * width,
1791        ]);
1792        knot_values.extend(vec![b; 4]);
1793        let knots = Array1::from_vec(knot_values);
1794        let derivative_orders = [1usize, 2, 3];
1795        let fit_penalties =
1796            canonical_wiggle_function_penalties(&knots, 3, &derivative_orders, true)
1797                .expect("fit-time canonical penalties");
1798        assert_eq!(
1799            fit_penalties.metadata.blocks,
1800            vec![
1801                WigglePenaltyBlockKind::Roughness {
1802                    derivative_order: 1,
1803                },
1804                WigglePenaltyBlockKind::Roughness {
1805                    derivative_order: 2,
1806                },
1807                WigglePenaltyBlockKind::Roughness {
1808                    derivative_order: 3,
1809                },
1810            ],
1811            "order-one primary roughness has no null space, so double penalty emits no fake ridge",
1812        );
1813        let p = fit_penalties.matrices[0].nrows();
1814        let runtime = SavedLinkWiggleRuntime {
1815            knots: knots.to_vec(),
1816            degree: 3,
1817            penalty_metadata: Some(fit_penalties.metadata.clone()),
1818            beta: vec![0.0; p],
1819            index_shift: None,
1820        };
1821        let lambdas = Array1::from_vec(vec![0.7, 1.3, 2.1]);
1822        let sampled_hessian = saved_link_wiggle_penalty_matrix(&runtime, lambdas.view(), p)
1823            .expect("sampling rebuilds the fit topology");
1824        let mut fitted_hessian = Array2::<f64>::zeros((p, p));
1825        for (matrix, &lambda) in fit_penalties.matrices.iter().zip(lambdas.iter()) {
1826            fitted_hessian.scaled_add(lambda, matrix);
1827        }
1828
1829        let theta = Array1::from_shape_fn(p, |index| 0.15 + 0.07 * index as f64);
1830        let fitted_gradient = fitted_hessian.dot(&theta);
1831        let sampled_gradient = sampled_hessian.dot(&theta);
1832        let fitted_value = 0.5 * theta.dot(&fitted_gradient);
1833        let sampled_value = 0.5 * theta.dot(&sampled_gradient);
1834        let max_hessian_error = sampled_hessian
1835            .iter()
1836            .zip(fitted_hessian.iter())
1837            .map(|(sampled, fitted)| (sampled - fitted).abs())
1838            .fold(0.0_f64, f64::max);
1839        let max_gradient_error = sampled_gradient
1840            .iter()
1841            .zip(fitted_gradient.iter())
1842            .map(|(sampled, fitted)| (sampled - fitted).abs())
1843            .fold(0.0_f64, f64::max);
1844        assert!(
1845            max_hessian_error < 1.0e-12,
1846            "penalty Hessian drift: {max_hessian_error}"
1847        );
1848        assert!(
1849            max_gradient_error < 1.0e-12,
1850            "penalty gradient drift: {max_gradient_error}"
1851        );
1852        assert!(
1853            (sampled_value - fitted_value).abs() < 1.0e-12,
1854            "penalty target drift"
1855        );
1856
1857        let mismatch_lambdas = Array1::from_vec(vec![0.7, 1.3]);
1858        let mismatch = saved_link_wiggle_penalty_matrix(&runtime, mismatch_lambdas.view(), p)
1859            .expect_err("lambda count mismatch must be rejected, never padded");
1860        assert!(mismatch.contains("3 blocks but fit stores 2 lambdas"));
1861    }
1862
1863    struct ChunkOnlySampleDesign {
1864        values: Array2<f64>,
1865        row_chunk_calls: std::sync::atomic::AtomicUsize,
1866        fail_rows: bool,
1867    }
1868
1869    impl LinearOperator for ChunkOnlySampleDesign {
1870        fn nrows(&self) -> usize {
1871            self.values.nrows()
1872        }
1873
1874        fn ncols(&self) -> usize {
1875            self.values.ncols()
1876        }
1877
1878        fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
1879            self.values.dot(vector)
1880        }
1881
1882        fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
1883            self.values.t().dot(vector)
1884        }
1885
1886        fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
1887            if weights.len() != self.nrows() {
1888                return Err(format!(
1889                    "weight vector has {} entries for {} design rows",
1890                    weights.len(),
1891                    self.nrows()
1892                ));
1893            }
1894            Ok(Array2::zeros((self.ncols(), self.ncols())))
1895        }
1896    }
1897
1898    impl DenseDesignOperator for ChunkOnlySampleDesign {
1899        fn row_chunk_into(
1900            &self,
1901            rows: std::ops::Range<usize>,
1902            mut out: ndarray::ArrayViewMut2<'_, f64>,
1903        ) -> Result<(), gam_runtime::resource::MatrixMaterializationError> {
1904            self.row_chunk_calls
1905                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1906            if self.fail_rows {
1907                return Err(
1908                    gam_runtime::resource::MatrixMaterializationError::MissingRowChunk {
1909                        context: "ChunkOnlySampleDesign test refusal",
1910                    },
1911                );
1912            }
1913            out.assign(&self.values.slice(s![rows, ..]));
1914            Ok(())
1915        }
1916
1917        fn to_dense(&self) -> Array2<f64> {
1918            panic!("stream_design_into must never call to_dense")
1919        }
1920    }
1921
1922    #[test]
1923    fn survival_design_streaming_uses_row_chunks_and_target_slice() {
1924        let values = Array2::from_shape_fn((5, 3), |(i, j)| (10 * i + j) as f64);
1925        let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1926            values: values.clone(),
1927            row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1928            fail_rows: false,
1929        });
1930        let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(
1931            std::sync::Arc::clone(&operator),
1932        ));
1933        let mut assembled = Array2::<f64>::from_elem((5, 5), -1.0);
1934
1935        stream_design_into(
1936            &design,
1937            assembled.slice_mut(s![.., 1..4]),
1938            2 * 3 * std::mem::size_of::<f64>(),
1939            "streaming regression",
1940        )
1941        .expect("row-chunk assembly succeeds");
1942
1943        assert_eq!(assembled.slice(s![.., 1..4]), values.view());
1944        assert!(assembled.column(0).iter().all(|&value| value == -1.0));
1945        assert!(assembled.column(4).iter().all(|&value| value == -1.0));
1946        assert_eq!(
1947            operator
1948                .row_chunk_calls
1949                .load(std::sync::atomic::Ordering::SeqCst),
1950            3,
1951        );
1952    }
1953
1954    #[test]
1955    fn survival_design_streaming_propagates_typed_row_refusal() {
1956        let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1957            values: Array2::zeros((2, 2)),
1958            row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1959            fail_rows: true,
1960        });
1961        let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(operator));
1962        let mut assembled = Array2::<f64>::zeros((2, 2));
1963
1964        let error = stream_design_into(
1965            &design,
1966            assembled.view_mut(),
1967            std::mem::size_of::<f64>(),
1968            "streaming refusal regression",
1969        )
1970        .expect_err("row-chunk refusal must remain fallible");
1971
1972        assert!(error.contains("streaming refusal regression"));
1973        assert!(error.contains("ChunkOnlySampleDesign test refusal"));
1974    }
1975
1976    /// #1463: the NB NUTS path must sample at the fit's jointly-estimated
1977    /// `theta_hat`, not the construction seed `theta = 1.0`. The seed only seeds
1978    /// the inner solve; the NUTS NB log-likelihood/score reads `theta` straight
1979    /// off the sampling `LikelihoodSpec`, so unless we refresh it from the scale
1980    /// metadata the posterior is drawn at the wrong overdispersion and every
1981    /// coefficient's posterior SD inflates ~1.4–1.5×.
1982    ///
1983    /// Pre-fix, `sample_standard` forwarded the seed unchanged: this assertion
1984    /// would read `theta == 1.0` and fail. With the refresh in place the seam
1985    /// rewrites the spec to `theta_hat`.
1986    #[test]
1987    fn refresh_negbin_theta_reads_theta_hat_not_seed() {
1988        // Spec carries the construction seed theta = 1.0; the fit estimated a
1989        // very different theta_hat = 2.97 and recorded it in the scale metadata.
1990        let mut likelihood = LikelihoodSpec::negative_binomial_log(1.0);
1991        let scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.97 };
1992
1993        refresh_negbin_theta_for_sampling(&mut likelihood, scale);
1994
1995        match likelihood.response {
1996            ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
1997                theta, 2.97,
1998                "NB NUTS must sample at theta_hat (#1463), not the seed theta=1.0"
1999            ),
2000            other => panic!("expected NegativeBinomial response, got {other:?}"),
2001        }
2002    }
2003
2004    /// A fixed-theta NB fit records the user's exact `theta` in both the spec and
2005    /// the scale metadata, so the refresh is a no-op that still lands on the
2006    /// fixed value (never the inner-solve seed of an estimated fit).
2007    #[test]
2008    fn refresh_negbin_theta_fixed_theta_is_preserved() {
2009        let mut likelihood = LikelihoodSpec::negative_binomial_log_fixed(4.25);
2010        let scale = LikelihoodScaleMetadata::FixedNegBinTheta { theta: 4.25 };
2011
2012        refresh_negbin_theta_for_sampling(&mut likelihood, scale);
2013
2014        match likelihood.response {
2015            ResponseFamily::NegativeBinomial { theta, theta_fixed } => {
2016                assert_eq!(theta, 4.25, "fixed NB theta must survive the refresh");
2017                assert!(theta_fixed, "theta_fixed flag must be preserved");
2018            }
2019            other => panic!("expected NegativeBinomial response, got {other:?}"),
2020        }
2021    }
2022
2023    /// When the fit recorded no NB theta (non-NB scale metadata), the refresh
2024    /// must leave the spec's seed untouched — mirroring the canonical replicate
2025    /// picker's `negbin_theta().or(seed)`.
2026    #[test]
2027    fn refresh_negbin_theta_falls_back_to_seed_when_unfitted() {
2028        let mut likelihood = LikelihoodSpec::negative_binomial_log(3.5);
2029        // ProfiledGaussian carries no negbin_theta, so the accessor returns None.
2030        refresh_negbin_theta_for_sampling(
2031            &mut likelihood,
2032            LikelihoodScaleMetadata::ProfiledGaussian,
2033        );
2034
2035        match likelihood.response {
2036            ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
2037                theta, 3.5,
2038                "with no fitted theta the NB seed must be kept verbatim"
2039            ),
2040            other => panic!("expected NegativeBinomial response, got {other:?}"),
2041        }
2042    }
2043
2044    /// Non-NB families must be completely unaffected by the NB refresh, even when
2045    /// the scale metadata happens to carry an NB theta — the match guards on the
2046    /// response family, so Poisson/Gamma/etc. are left untouched.
2047    #[test]
2048    fn refresh_negbin_theta_leaves_non_nb_families_untouched() {
2049        let mut poisson = LikelihoodSpec::poisson_log();
2050        let before = poisson.response.clone();
2051        refresh_negbin_theta_for_sampling(
2052            &mut poisson,
2053            LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 9.0 },
2054        );
2055        assert_eq!(
2056            poisson.response, before,
2057            "Poisson response must be untouched by the NB theta refresh"
2058        );
2059    }
2060}