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