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