Skip to main content

gam_inference/
sample.rs

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