1use std::collections::HashMap;
12
13use faer::Side;
14use ndarray::{Array1, Array2, ArrayView2, s};
15use rand::{RngExt, SeedableRng};
16
17use super::hmc_io::{
18 FamilyNutsInputs, GlmFlatInputs, SurvivalFlatInputs, explicit_fit_hessian_for_whitening,
19 run_nuts_sampling_flattened_family, run_survival_nuts_sampling_flattened, validate_nuts_config,
20};
21pub use super::hmc_io::{NutsConfig, NutsResult};
22use crate::formula_dsl::{LinkWiggleFormulaSpec, parse_formula};
23use crate::model::{
24 FittedModel as SavedModel, PredictModelClass, load_survival_time_basis_config_from_model,
25};
26use gam_linalg::faer_ndarray::FaerCholesky;
27use gam_linalg::triangular::back_substitution_lower_transpose_guarded_into;
28use gam_models::survival::construction::{
29 SurvivalLikelihoodMode, add_survival_time_derivative_guard_offset, build_survival_time_basis,
30 build_survival_time_offsets_for_likelihood, evaluate_survival_time_basis_row,
31 normalize_survival_time_pair, resolved_survival_time_basis_config_from_build,
32 survival_derivative_guard_for_likelihood,
33};
34use gam_models::survival::predict::{
35 fit_result_from_saved_model_for_prediction, require_saved_survival_likelihood_mode,
36 resolve_saved_survival_time_columns, resolve_termspec_for_prediction,
37 saved_baseline_timewiggle_components, saved_survival_runtime_baseline_config,
38};
39use gam_models::survival::royston_parmar::{self, RoystonParmarInputs};
40use gam_models::survival::{
41 PenaltyBlock, PenaltyBlocks, SurvivalMonotonicityPenalty, SurvivalSpec,
42};
43use gam_models::wiggle::{buildwiggle_block_input_from_orders, split_wiggle_penalty_orders};
44use gam_problem::types::{LikelihoodSpec, ResponseFamily};
45use gam_runtime::resource::{MemoryGovernor, ResourcePolicy, rows_for_target_bytes};
46use gam_solve::estimate::validate_all_finite;
47use gam_terms::smooth::build_term_collection_design;
48use gam_terms::smooth::{LinearCoefficientGeometry, weighted_blockwise_penalty_sum};
49use gam_terms::term_builder::resolve_role_col;
50
51fn sampling_sqrt_covariance_scale(
52 fit: &gam_solve::estimate::UnifiedFitResult,
53 context: &str,
54) -> Result<f64, String> {
55 let scale = fit
56 .coefficient_covariance_scale()
57 .map_err(|err| format!("{context}: cannot resolve coefficient-covariance scale: {err}"))?;
58 if !(scale.is_finite() && scale > 0.0) {
59 return Err(format!(
60 "{context}: posterior sampling requires a finite strictly-positive coefficient-covariance scale, got {scale}"
61 ));
62 }
63 Ok(scale.sqrt())
64}
65
66fn resolved_fit_dispersion(
67 fit: &gam_solve::estimate::UnifiedFitResult,
68 context: &str,
69) -> Result<gam_problem::Dispersion, String> {
70 if let Some(dispersion) = fit.dispersion() {
71 return Ok(dispersion);
72 }
73 let family = fit.likelihood_family.as_ref().ok_or_else(|| {
74 format!("{context}: fit has no engine-level family and no scalar dispersion")
75 })?;
76 let likelihood = gam_problem::GlmLikelihoodSpec::try_new(family.clone(), fit.likelihood_scale)
77 .map_err(|err| format!("{context}: invalid fitted likelihood scale: {err}"))?;
78 let profiled_standard_deviation = matches!(
79 likelihood
80 .resolved_scale()
81 .map_err(|err| format!("{context}: invalid fitted likelihood scale: {err}"))?,
82 gam_problem::ResolvedLikelihoodScale::ProfiledGaussian
83 )
84 .then_some(fit.standard_deviation);
85 gam_solve::estimate::dispersion_from_likelihood(&likelihood, profiled_standard_deviation)
86 .map_err(|err| format!("{context}: cannot resolve fitted dispersion: {err}"))
87}
88
89const SURVIVAL_DESIGN_LIVE_COPIES: usize = 2 * 3;
92
93fn stream_design_into(
98 design: &gam_linalg::matrix::DesignMatrix,
99 mut out: ndarray::ArrayViewMut2<'_, f64>,
100 row_chunk_target_bytes: usize,
101 context: &str,
102) -> Result<(), String> {
103 if out.dim() != (design.nrows(), design.ncols()) {
104 return Err(format!(
105 "{context}: output shape {}x{} does not match design {}x{}",
106 out.nrows(),
107 out.ncols(),
108 design.nrows(),
109 design.ncols(),
110 ));
111 }
112 let chunk_rows = rows_for_target_bytes(row_chunk_target_bytes, design.ncols())
113 .max(1)
114 .min(design.nrows().max(1));
115 for start in (0..design.nrows()).step_by(chunk_rows) {
116 let end = (start + chunk_rows).min(design.nrows());
117 design
118 .row_chunk_into(start..end, out.slice_mut(s![start..end, ..]))
119 .map_err(|error| format!("{context}: {error}"))?;
120 }
121 Ok(())
122}
123
124pub fn saved_baseline_timewiggle_spec(
129 model: &SavedModel,
130) -> Result<Option<LinkWiggleFormulaSpec>, String> {
131 model
132 .saved_baseline_time_wiggle()
133 .map_err(|e| e.to_string())
134 .map(|runtime| {
135 runtime.map(|saved| LinkWiggleFormulaSpec {
136 degree: saved.degree,
137 num_internal_knots: saved.knots.len().saturating_sub(2 * (saved.degree + 1)),
138 penalty_orders: saved.penalty_orders,
139 double_penalty: saved.double_penalty,
140 })
141 })
142}
143
144fn saved_prior_weights(
151 model: &SavedModel,
152 data: ArrayView2<'_, f64>,
153 col_map: &HashMap<String, usize>,
154) -> Result<Array1<f64>, String> {
155 match model.weight_column.as_deref() {
156 Some(name) => {
157 let idx = resolve_role_col(col_map, name, "weights")?;
158 let w = data.column(idx).to_owned();
159 if !w.iter().all(|v| v.is_finite() && *v >= 0.0) {
160 return Err(format!(
161 "sample: prior-weights column '{name}' contains negative or non-finite values"
162 ));
163 }
164 Ok(w)
165 }
166 None => Ok(Array1::ones(data.nrows())),
167 }
168}
169
170fn saved_offset(
176 model: &SavedModel,
177 data: ArrayView2<'_, f64>,
178 col_map: &HashMap<String, usize>,
179) -> Result<Option<Array1<f64>>, String> {
180 match model.offset_column.as_deref() {
181 Some(name) => {
182 let idx = resolve_role_col(col_map, name, "offset")?;
183 Ok(Some(data.column(idx).to_owned()))
184 }
185 None => Ok(None),
186 }
187}
188
189fn refresh_negbin_theta_for_sampling(
207 likelihood: &mut LikelihoodSpec,
208 scale: gam_problem::types::LikelihoodScaleMetadata,
209) {
210 if let ResponseFamily::NegativeBinomial { theta, .. } = &mut likelihood.response {
211 if let Some(theta_hat) = scale.negbin_theta() {
212 *theta = theta_hat;
213 }
214 }
215}
216
217fn likelihood_spec_for_saved_model(model: &SavedModel) -> Result<LikelihoodSpec, String> {
221 Ok(model.likelihood())
222}
223
224const DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA: f64 = 1e-2;
229
230#[inline]
231const fn splitmix64(x: u64) -> u64 {
232 gam_linalg::utils::splitmix64_hash(x)
233}
234
235#[inline]
236const fn chain_stream_seed(seed: u64, chain: usize, stream: u64) -> u64 {
237 splitmix64(seed ^ stream ^ ((chain as u64).wrapping_mul(0xD1B5_4A32_D192_ED03)))
238}
239
240pub fn sample_saved_model(
259 model: &SavedModel,
260 data: ArrayView2<'_, f64>,
261 col_map: &HashMap<String, usize>,
262 training_headers: Option<&Vec<String>>,
263 cfg: &NutsConfig,
264) -> Result<NutsResult, String> {
265 validate_nuts_config(cfg).map_err(String::from)?;
274 let likelihood = likelihood_spec_for_saved_model(model)?;
275 match model.predict_model_class() {
276 PredictModelClass::Survival => {
277 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
283 if matches!(
284 saved_likelihood_mode,
285 SurvivalLikelihoodMode::Latent
286 | SurvivalLikelihoodMode::LatentBinary
287 | SurvivalLikelihoodMode::LocationScale
288 ) {
289 constrained_laplace_fallback(model, cfg, "survival posterior fallback")
290 } else {
291 sample_survival(model, data, col_map, training_headers, cfg)
292 }
293 }
294 PredictModelClass::Standard => {
295 if matches!(likelihood.response, ResponseFamily::Beta { .. }) {
304 constrained_laplace_fallback(model, cfg, "beta-regression posterior fallback")
305 } else {
306 sample_standard(model, data, col_map, training_headers, likelihood, cfg)
307 }
308 }
309 PredictModelClass::GaussianLocationScale => {
319 constrained_laplace_fallback(model, cfg, "gaussian location-scale posterior")
320 }
321 PredictModelClass::BinomialLocationScale => {
322 constrained_laplace_fallback(model, cfg, "binomial location-scale posterior")
323 }
324 PredictModelClass::DispersionLocationScale => {
325 constrained_laplace_fallback(model, cfg, "dispersion location-scale posterior")
326 }
327 PredictModelClass::BernoulliMarginalSlope => {
328 constrained_laplace_fallback(model, cfg, "bernoulli marginal-slope posterior")
329 }
330 PredictModelClass::TransformationNormal => {
331 sample_transformation_normal_constrained(model, cfg)
336 }
337 }
338}
339
340pub fn laplace_gaussian_fallback(
354 model: &SavedModel,
355 cfg: &NutsConfig,
356 rationale: &'static str,
357) -> Result<NutsResult, String> {
358 validate_nuts_config(cfg).map_err(String::from)?;
363 let fit = fit_result_from_saved_model_for_prediction(model)?;
364 let mode = fit.beta.clone();
365 let p = mode.len();
366 if p == 0 {
367 return Err(format!(
368 "{rationale}: cannot sample from an empty coefficient vector"
369 ));
370 }
371 let h = fit.penalized_hessian().ok_or_else(|| {
372 format!(
373 "{rationale}: posterior fallback requires the explicit penalised Hessian; \
374 refit with exact geometry export to enable posterior sampling for this class."
375 )
376 })?;
377 let sqrt_cov_scale = sampling_sqrt_covariance_scale(&fit, rationale)?;
394 if h.nrows() != p || h.ncols() != p {
395 return Err(format!(
396 "{rationale}: penalised Hessian is {}x{}, expected {}x{}",
397 h.nrows(),
398 h.ncols(),
399 p,
400 p
401 ));
402 }
403 let chol = h.cholesky(Side::Lower).map_err(|err| {
404 format!("{rationale}: Cholesky factorisation of the penalised Hessian failed: {err:?}")
405 })?;
406 let l = chol.lower_triangular();
407
408 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
412 let mut samples = Array2::<f64>::zeros((n_total, p));
413 let mut eps = Array1::<f64>::zeros(p);
414 let mut delta = Array1::<f64>::zeros(p);
415 for chain in 0..cfg.n_chains {
416 let mut rng = rand::rngs::StdRng::seed_from_u64(chain_stream_seed(
417 cfg.seed,
418 chain,
419 0xA0B7_6C5D_E431_298F,
420 ));
421 for draw in 0..cfg.n_samples {
422 let k = chain * cfg.n_samples + draw;
423 for i in 0..p {
424 eps[i] = sample_standard_normal(&mut rng);
425 }
426 back_substitution_lower_transpose_guarded_into(&l, &eps, &mut delta);
427 for i in 0..p {
428 samples[(k, i)] = mode[i] + sqrt_cov_scale * delta[i];
433 }
434 }
435 }
436
437 let posterior_mean = samples
438 .mean_axis(ndarray::Axis(0))
439 .unwrap_or_else(|| Array1::<f64>::zeros(p));
440 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
441
442 Ok(NutsResult {
443 samples,
444 posterior_mean,
445 posterior_std,
446 rhat: 1.0,
447 ess: n_total as f64,
448 converged: true,
449 })
450}
451
452fn sample_transformation_normal_constrained(
465 model: &SavedModel,
466 cfg: &NutsConfig,
467) -> Result<NutsResult, String> {
468 const RATIONALE: &str = "transformation-normal constrained posterior";
469 const MAX_REJECTION_FACTOR: usize = 1000;
473
474 validate_nuts_config(cfg).map_err(String::from)?;
475 let fit = fit_result_from_saved_model_for_prediction(model)?;
476 let mode = fit.beta.clone();
477 let p = mode.len();
478 if p == 0 {
479 return Err(format!(
480 "{RATIONALE}: cannot sample from an empty coefficient vector"
481 ));
482 }
483
484 let geometry = model.transformation_geometry.as_ref().ok_or_else(|| {
485 format!("{RATIONALE}: missing the direct-α geometry record; refit (gam#2306)")
486 })?;
487 let carrier = model.transformation_cone_carrier.as_ref().ok_or_else(|| {
488 format!(
489 "{RATIONALE}: missing the monotonicity-cone carrier (transformation_cone_carrier); \
490 refit to persist the cone so constrained sampling can certify draws"
491 )
492 })?;
493 let n = geometry.cone_carrier_row_count;
494 let p_cov = geometry.cone_carrier_covariate_width;
495 let p_resp = geometry.shape_coordinate_count + 1;
496 if carrier.len() != n.saturating_mul(p_cov) {
497 return Err(format!(
498 "{RATIONALE}: cone carrier length {} != {n} rows x {p_cov} covariate columns",
499 carrier.len()
500 ));
501 }
502 if p != p_resp.saturating_mul(p_cov) {
503 return Err(format!(
504 "{RATIONALE}: coefficient length {p} != p_resp {p_resp} x p_cov {p_cov}; the saved \
505 coefficient block does not match the persisted cone geometry"
506 ));
507 }
508 let psi = Array2::from_shape_vec((n, p_cov), carrier.clone())
509 .map_err(|err| format!("{RATIONALE}: cone carrier reshape to {n}x{p_cov} failed: {err}"))?;
510
511 let is_feasible = |beta: &Array1<f64>| -> bool {
514 for k in 1..p_resp {
515 let block = beta.slice(ndarray::s![k * p_cov..(k + 1) * p_cov]);
516 if psi.dot(&block).iter().any(|value| *value < 0.0) {
517 return false;
518 }
519 }
520 true
521 };
522 if !is_feasible(&mode) {
523 return Err(format!(
524 "{RATIONALE}: the fitted mode violates the monotonicity cone Γ ≥ 0 — the saved model is \
525 not a valid monotone transformation; refit"
526 ));
527 }
528
529 let h = fit.penalized_hessian().ok_or_else(|| {
530 format!(
531 "{RATIONALE}: requires the explicit penalised Hessian; refit with exact geometry export"
532 )
533 })?;
534 let sqrt_cov_scale = sampling_sqrt_covariance_scale(&fit, RATIONALE)?;
535 if h.nrows() != p || h.ncols() != p {
536 return Err(format!(
537 "{RATIONALE}: penalised Hessian is {}x{}, expected {p}x{p}",
538 h.nrows(),
539 h.ncols()
540 ));
541 }
542 let chol = h.cholesky(Side::Lower).map_err(|err| {
543 format!("{RATIONALE}: Cholesky factorisation of the penalised Hessian failed: {err:?}")
544 })?;
545 let l = chol.lower_triangular();
546
547 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
548 let mut samples = Array2::<f64>::zeros((n_total, p));
549 let mut eps = Array1::<f64>::zeros(p);
550 let mut delta = Array1::<f64>::zeros(p);
551 let mut draw = Array1::<f64>::zeros(p);
552 let attempts_cap = cfg
553 .n_samples
554 .saturating_mul(MAX_REJECTION_FACTOR)
555 .max(MAX_REJECTION_FACTOR);
556 let mut total_attempts: u64 = 0;
557 let mut total_accepted: u64 = 0;
558
559 for chain in 0..cfg.n_chains {
560 let mut rng = rand::rngs::StdRng::seed_from_u64(chain_stream_seed(
561 cfg.seed,
562 chain,
563 0xA0B7_6C5D_E431_298F,
564 ));
565 let mut accepted_in_chain = 0usize;
566 let mut attempts_in_chain = 0usize;
567 while accepted_in_chain < cfg.n_samples {
568 if attempts_in_chain >= attempts_cap {
569 let rate = total_accepted as f64 / (total_attempts.max(1) as f64);
570 return Err(format!(
571 "{RATIONALE}: acceptance collapsed — {total_accepted} accepted of \
572 {total_attempts} draws (rate {rate:.3e}); the fit hugs the monotonicity-cone \
573 boundary so its truncated posterior cannot be rejection-sampled within \
574 {attempts_cap} draws per chain. Refit or widen the certified response support."
575 ));
576 }
577 attempts_in_chain += 1;
578 total_attempts += 1;
579 for i in 0..p {
580 eps[i] = sample_standard_normal(&mut rng);
581 }
582 back_substitution_lower_transpose_guarded_into(&l, &eps, &mut delta);
583 for i in 0..p {
584 draw[i] = mode[i] + sqrt_cov_scale * delta[i];
585 }
586 if is_feasible(&draw) {
587 let k = chain * cfg.n_samples + accepted_in_chain;
588 samples.row_mut(k).assign(&draw);
589 accepted_in_chain += 1;
590 total_accepted += 1;
591 }
592 }
593 }
594
595 let posterior_mean = samples
596 .mean_axis(ndarray::Axis(0))
597 .unwrap_or_else(|| Array1::<f64>::zeros(p));
598 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
599 Ok(NutsResult {
602 samples,
603 posterior_mean,
604 posterior_std,
605 rhat: 1.0,
606 ess: n_total as f64,
607 converged: true,
608 })
609}
610
611#[inline]
612fn sample_standard_normal<R: rand::Rng + ?Sized>(rng: &mut R) -> f64 {
613 let u1 = rng.random::<f64>().max(1e-16);
617 let u2 = rng.random::<f64>();
618 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
619}
620
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622enum StandardPosteriorRoute {
623 BoundedLatent,
624 InequalityTruncated,
625 GaussianClosedForm,
626 UnconstrainedNuts,
627}
628
629fn standard_posterior_route(
630 has_bounded: bool,
631 declares_linear_inequality: bool,
632 has_link_wiggle: bool,
633 has_persisted_inequality: bool,
634 gaussian_identity: bool,
635) -> Result<StandardPosteriorRoute, String> {
636 if has_bounded && has_persisted_inequality {
637 return Err(
638 "standard posterior sampling does not support a model that combines bounded() latent \
639 coordinates with linear inequality constraints"
640 .to_string(),
641 );
642 }
643 if has_persisted_inequality {
644 return Ok(StandardPosteriorRoute::InequalityTruncated);
645 }
646 if has_link_wiggle || declares_linear_inequality {
647 return Err(
648 "standard constrained-coefficient posterior: the fitted model declares inequality \
649 constraints but has no persisted inequality-truncated posterior identity; refit with \
650 the current schema"
651 .to_string(),
652 );
653 }
654 if has_bounded {
655 return Ok(StandardPosteriorRoute::BoundedLatent);
656 }
657 if gaussian_identity {
658 return Ok(StandardPosteriorRoute::GaussianClosedForm);
659 }
660 Ok(StandardPosteriorRoute::UnconstrainedNuts)
661}
662
663#[derive(Clone, Copy, Debug, PartialEq, Eq)]
664enum LaplaceFallbackRoute {
665 InequalityTruncated,
666 UnconstrainedGaussian,
667}
668
669fn laplace_fallback_route(
687 declares_linear_inequality: bool,
688 has_link_wiggle: bool,
689 has_persisted_inequality: bool,
690) -> Result<LaplaceFallbackRoute, String> {
691 if has_persisted_inequality {
692 return Ok(LaplaceFallbackRoute::InequalityTruncated);
693 }
694 if has_link_wiggle || declares_linear_inequality {
695 return Err(
696 "the fitted model declares inequality constraints but carries no persisted \
697 inequality-truncated posterior identity, so a Laplace-Gaussian draw would put mass \
698 outside the cone the fit certified (a negative monotone-wiggle coefficient is a \
699 non-monotone warp the model cannot produce); refit with the current schema, or read \
700 the covariance decline this fit recorded"
701 .to_string(),
702 );
703 }
704 Ok(LaplaceFallbackRoute::UnconstrainedGaussian)
705}
706
707fn constrained_laplace_fallback(
716 model: &SavedModel,
717 cfg: &NutsConfig,
718 rationale: &'static str,
719) -> Result<NutsResult, String> {
720 validate_nuts_config(cfg).map_err(String::from)?;
721 let fit = fit_result_from_saved_model_for_prediction(model)?;
722 let declares_linear_inequality = model
728 .resolved_termspec
729 .as_ref()
730 .map(|saved_spec| {
731 saved_spec
732 .linear_terms
733 .iter()
734 .any(|term| term.coefficient_min.is_some() || term.coefficient_max.is_some())
735 || saved_spec
736 .smooth_terms
737 .iter()
738 .any(|term| !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None))
739 })
740 .unwrap_or(false);
741 let has_persisted_inequality = fit
742 .geometry
743 .as_ref()
744 .and_then(|geometry| geometry.constrained_posterior.as_ref())
745 .is_some();
746 let route = laplace_fallback_route(
747 declares_linear_inequality,
748 model.has_link_wiggle(),
749 has_persisted_inequality,
750 )
751 .map_err(|reason| format!("{rationale}: {reason}"))?;
752 match route {
753 LaplaceFallbackRoute::InequalityTruncated => {
754 let geometry = fit.geometry.as_ref().ok_or_else(|| {
764 format!("{rationale}: a persisted inequality identity requires a coefficient geometry")
765 })?;
766 if !geometry.coefficient_gauge.is_identity() {
767 return Err(format!(
768 "{rationale}: the fit carries an inequality-truncated posterior in a gauged \
769 coefficient frame, and the truncated draw is only defined where that frame \
770 is the one the reported coefficients live in; sampling here would return \
771 draws in the wrong coordinates, so it is declined rather than approximated"
772 ));
773 }
774 sample_standard_truncated(&fit, cfg)
775 }
776 LaplaceFallbackRoute::UnconstrainedGaussian => {
777 laplace_gaussian_fallback(model, cfg, rationale)
778 }
779 }
780}
781
782fn sample_standard(
783 model: &SavedModel,
784 data: ArrayView2<'_, f64>,
785 col_map: &HashMap<String, usize>,
786 training_headers: Option<&Vec<String>>,
787 mut likelihood: LikelihoodSpec,
788 cfg: &NutsConfig,
789) -> Result<NutsResult, String> {
790 let fit = fit_result_from_saved_model_for_prediction(model)?;
791 let saved_spec = model.resolved_termspec.as_ref().ok_or_else(|| {
792 "standard posterior sampling requires a frozen fitted term specification; refit".to_string()
793 })?;
794 let has_bounded = saved_spec.linear_terms.iter().any(|term| {
795 matches!(
796 term.coefficient_geometry,
797 LinearCoefficientGeometry::Bounded { .. }
798 )
799 });
800 let declares_linear_inequality = saved_spec
801 .linear_terms
802 .iter()
803 .any(|term| term.coefficient_min.is_some() || term.coefficient_max.is_some())
804 || saved_spec
805 .smooth_terms
806 .iter()
807 .any(|term| !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None));
808 let constrained_posterior = fit
809 .geometry
810 .as_ref()
811 .and_then(|geometry| geometry.constrained_posterior.as_ref());
812
813 let route = standard_posterior_route(
818 has_bounded,
819 declares_linear_inequality,
820 model.has_link_wiggle(),
821 constrained_posterior.is_some(),
822 likelihood.is_gaussian_identity(),
823 )?;
824 match route {
825 StandardPosteriorRoute::InequalityTruncated => {
826 return sample_standard_truncated(&fit, cfg);
827 }
828 StandardPosteriorRoute::GaussianClosedForm => {
829 return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
830 }
831 StandardPosteriorRoute::BoundedLatent | StandardPosteriorRoute::UnconstrainedNuts => {}
832 }
833
834 let parsed = parse_formula(&model.formula)?;
835 let y_col = resolve_role_col(col_map, &parsed.response, "response")?;
836 let y = data.column(y_col).to_owned();
837 let spec = resolve_termspec_for_prediction(
838 &model.resolved_termspec,
839 training_headers,
840 col_map,
841 "resolved_termspec",
842 )?;
843 let design = build_term_collection_design(data, &spec)
844 .map_err(|e| format!("failed to build term collection design: {e}"))?;
845
846 if route == StandardPosteriorRoute::BoundedLatent {
850 let bounded_columns: Vec<gam_models::fit_orchestration::drivers::BoundedSampleColumn> =
851 spec.linear_terms
852 .iter()
853 .enumerate()
854 .filter_map(|(j, term)| match term.coefficient_geometry {
855 LinearCoefficientGeometry::Bounded { min, max, .. } => Some(
856 gam_models::fit_orchestration::drivers::BoundedSampleColumn {
857 col_idx: design.intercept_range.end + j,
858 min,
859 max,
860 },
861 ),
862 LinearCoefficientGeometry::Unconstrained => None,
863 })
864 .collect();
865 return sample_standard_bounded(model, cfg, &bounded_columns);
866 }
867
868 let weights = saved_prior_weights(model, data, col_map)?;
871 let dense_design_hmc = design
872 .design
873 .try_to_dense_governed("saved standard model HMC design")
874 .map_err(|error| error.to_string())?;
875 let p = dense_design_hmc.ncols();
876 let sampler_design_copy_reservation = MemoryGovernor::global()
880 .try_reserve_dense_f64(
881 dense_design_hmc.nrows(),
882 dense_design_hmc.ncols(),
883 "saved standard model sampler design copy",
884 )
885 .map_err(|error| error.to_string())?;
886 refresh_negbin_theta_for_sampling(&mut likelihood, fit.likelihood_scale);
897 if fit.beta.len() != p {
898 return Err(format!(
899 "standard sample: saved model has {} coefficients but rebuilt design has {} columns",
900 fit.beta.len(),
901 p,
902 ));
903 }
904 if fit.lambdas.len() != design.penalties.len() {
905 return Err(format!(
906 "standard sample: saved model has {} lambdas but rebuilt design has {} penalties",
907 fit.lambdas.len(),
908 design.penalties.len(),
909 ));
910 }
911 let penalty =
912 weighted_blockwise_penalty_sum(&design.penalties, fit
913 .lambdas
914 .as_slice()
915 .expect("owned Array1 is contiguous, so as_slice always succeeds"), p);
916
917 let saved_offset_vec = saved_offset(model, data, col_map)?;
918 let base_offset =
919 saved_offset_vec.unwrap_or_else(|| Array1::<f64>::zeros(design.design.nrows()));
920 let offset_vec = design
921 .compose_offset(base_offset.view(), "saved standard model sampling")
922 .map_err(|error| error.to_string())?;
923
924 let result = run_nuts_sampling_flattened_family(
925 likelihood,
926 FamilyNutsInputs::Glm(GlmFlatInputs {
927 x: dense_design_hmc.view(),
928 y: y.view(),
929 weights: weights.view(),
930 penalty_matrix: penalty.view(),
931 mode: fit.beta.view(),
932 hessian: explicit_fit_hessian_for_whitening(&fit, p, "saved standard model")?.view(),
933 likelihood_scale: fit.likelihood_scale,
934 dispersion: resolved_fit_dispersion(&fit, "standard saved-model NUTS")?,
935 firth_bias_reduction: fit.artifacts.firth_bias_reduction,
936 offset: Some(offset_vec.view()),
937 }),
938 cfg,
939 )
940 .map_err(|e| format!("NUTS sampling failed: {e}"));
941 drop(sampler_design_copy_reservation);
942 result
943}
944
945fn sample_standard_bounded(
955 model: &SavedModel,
956 cfg: &NutsConfig,
957 bounded_columns: &[gam_models::fit_orchestration::drivers::BoundedSampleColumn],
958) -> Result<NutsResult, String> {
959 validate_nuts_config(cfg).map_err(String::from)?;
960 let fit = fit_result_from_saved_model_for_prediction(model)?;
961 let mode = fit.beta.clone();
962 let p = mode.len();
963 if p == 0 {
964 return Err(
965 "standard bounded-coefficient posterior: cannot sample from an empty coefficient vector"
966 .to_string(),
967 );
968 }
969 let user_hessian =
974 explicit_fit_hessian_for_whitening(&fit, p, "saved standard bounded-coefficient model")?;
975 let sqrt_cov_scale =
982 sampling_sqrt_covariance_scale(&fit, "standard bounded-coefficient posterior")?;
983 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
984 let samples = gam_models::fit_orchestration::drivers::sample_bounded_latent_posterior_internal(
985 &mode,
986 user_hessian,
987 bounded_columns,
988 n_total,
989 sqrt_cov_scale,
990 chain_stream_seed(cfg.seed, 0, 0xB0DD_ED5E_ED90_1A7Cu64),
991 )
992 .map_err(|e| format!("standard bounded-coefficient posterior sampling failed: {e}"))?;
993
994 let posterior_mean = samples
995 .mean_axis(ndarray::Axis(0))
996 .unwrap_or_else(|| Array1::<f64>::zeros(p));
997 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
998
999 Ok(NutsResult {
1000 samples,
1001 posterior_mean,
1002 posterior_std,
1003 rhat: 1.0,
1004 ess: n_total as f64,
1005 converged: true,
1006 })
1007}
1008
1009fn sample_standard_truncated(
1026 fit: &gam_solve::estimate::UnifiedFitResult,
1027 cfg: &NutsConfig,
1028) -> Result<NutsResult, String> {
1029 validate_nuts_config(cfg).map_err(String::from)?;
1030 let geometry = fit.geometry.as_ref().ok_or_else(|| {
1036 "standard constrained-coefficient posterior: saved fit has no coefficient geometry"
1037 .to_string()
1038 })?;
1039 let constrained = geometry.constrained_posterior.as_ref().ok_or_else(|| {
1040 "standard constrained-coefficient posterior: saved fit has constraints but no persisted \
1041 inequality-truncated posterior identity; refit with the current schema"
1042 .to_string()
1043 })?;
1044 let mode = constrained.mode.clone();
1045 let center = constrained.unconstrained_center()?.clone();
1046 let p = mode.len();
1047 if p == 0 {
1048 return Err(
1049 "standard constrained-coefficient posterior: cannot sample from an empty coefficient \
1050 vector"
1051 .to_string(),
1052 );
1053 }
1054 let penalized_hessian =
1063 explicit_fit_hessian_for_whitening(&fit, p, "saved standard constrained model")?;
1064 let sqrt_cov_scale =
1065 sampling_sqrt_covariance_scale(&fit, "standard constrained-coefficient posterior")?;
1066
1067 let active_samples = crate::truncated_gaussian::sample_truncated_gaussian_posterior(
1068 ¢er,
1069 &mode,
1070 &penalized_hessian,
1071 sqrt_cov_scale,
1072 &constrained.constraints,
1073 cfg.n_samples,
1074 cfg.n_chains,
1075 chain_stream_seed(cfg.seed, 0, 0x7290_C047_5D6E_B14Du64),
1076 )?;
1077 let mut chains = ndarray::Array3::<f64>::zeros((cfg.n_chains, cfg.n_samples, p));
1084 for chain in 0..cfg.n_chains {
1085 for draw in 0..cfg.n_samples {
1086 let row = chain * cfg.n_samples + draw;
1087 for j in 0..p {
1088 chains[(chain, draw, j)] = active_samples[(row, j)];
1089 }
1090 }
1091 }
1092 let (rhat, ess) = super::hmc_io::compute_split_rhat_and_ess(&chains);
1093 let converged = rhat < 1.1 && ess > 100.0;
1094
1095 let samples = lift_active_samples_to_saved(active_samples, &geometry.coefficient_gauge)?;
1100 let raw_p = samples.ncols();
1101 if raw_p != fit.beta.len() {
1102 return Err(format!(
1103 "standard constrained-coefficient posterior: gauge lifted {raw_p} coefficients but \
1104 the saved fit reports {}",
1105 fit.beta.len(),
1106 ));
1107 }
1108 let posterior_mean = samples
1109 .mean_axis(ndarray::Axis(0))
1110 .unwrap_or_else(|| Array1::<f64>::zeros(raw_p));
1111 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
1112
1113 Ok(NutsResult {
1114 samples,
1115 posterior_mean,
1116 posterior_std,
1117 rhat,
1118 ess,
1119 converged,
1120 })
1121}
1122
1123fn lift_active_samples_to_saved(
1124 active_samples: Array2<f64>,
1125 gauge: &gam_problem::gauge::Gauge,
1126) -> Result<Array2<f64>, String> {
1127 gauge
1128 .validate()
1129 .map_err(|reason| format!("constrained posterior gauge is invalid: {reason}"))?;
1130 if active_samples.ncols() != gauge.reduced_total() {
1131 return Err(format!(
1132 "constrained posterior produced {} active coefficients but the gauge expects {}",
1133 active_samples.ncols(),
1134 gauge.reduced_total(),
1135 ));
1136 }
1137 if gauge.is_identity() {
1138 return Ok(active_samples);
1139 }
1140 let mut saved = active_samples.dot(&gauge.t_full.t());
1141 for mut draw in saved.rows_mut() {
1142 draw += &gauge.affine_shift;
1143 }
1144 validate_all_finite(
1145 "saved-coordinate constrained posterior draws",
1146 saved.iter().copied(),
1147 )?;
1148 Ok(saved)
1149}
1150
1151fn sample_survival(
1152 model: &SavedModel,
1153 data: ArrayView2<'_, f64>,
1154 col_map: &HashMap<String, usize>,
1155 training_headers: Option<&Vec<String>>,
1156 cfg: &NutsConfig,
1157) -> Result<NutsResult, String> {
1158 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1159 if matches!(
1160 saved_likelihood_mode,
1161 SurvivalLikelihoodMode::Latent
1162 | SurvivalLikelihoodMode::LatentBinary
1163 | SurvivalLikelihoodMode::LocationScale
1164 ) {
1165 return constrained_laplace_fallback(model, cfg, "survival posterior fallback");
1166 }
1167 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1176 let exit_col = time_cols.exit_col;
1177 let eventname = model
1178 .survival_event
1179 .as_ref()
1180 .ok_or_else(|| "survival model missing event column metadata".to_string())?;
1181 let event_col = resolve_role_col(col_map, eventname, "event")?;
1182 let termspec = resolve_termspec_for_prediction(
1183 &model.resolved_termspec,
1184 training_headers,
1185 col_map,
1186 "resolved_termspec",
1187 )?;
1188 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1189 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1190 let cov_design = build_term_collection_design(cov_input, &termspec)
1191 .map_err(|e| format!("failed to build survival design: {e}"))?;
1192 let n = data.nrows();
1193 let p_cov = cov_design.design.ncols();
1194 let mut age_entry = Array1::<f64>::zeros(n);
1195 let mut age_exit = Array1::<f64>::zeros(n);
1196 let mut event_target = Array1::<u8>::zeros(n);
1197 let event_competing = Array1::<u8>::zeros(n);
1198 let weights = Array1::<f64>::ones(n);
1199 for i in 0..n {
1200 let (t0, t1) = normalize_survival_time_pair(
1201 time_cols.row_entry_time(data, i),
1202 data[[i, exit_col]],
1203 i,
1204 )?;
1205 age_entry[i] = t0;
1206 age_exit[i] = t1;
1207 event_target[i] = if data[[i, event_col]] >= 0.5 { 1 } else { 0 };
1208 }
1209 let time_cfg = load_survival_time_basis_config_from_model(model)?;
1210 let time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
1211 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
1212 &time_build.basisname,
1213 time_build.degree,
1214 time_build.knots.as_ref(),
1215 time_build.keep_cols.as_ref(),
1216 time_build.smooth_lambda,
1217 )?;
1218 let time_anchor_row = if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1219 let time_anchor = model
1220 .survival_time_anchor
1221 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1222 Some(evaluate_survival_time_basis_row(
1223 time_anchor,
1224 &resolved_time_cfg,
1225 )?)
1226 } else {
1227 None
1228 };
1229 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
1230 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
1231 build_survival_time_offsets_for_likelihood(
1232 &age_entry,
1233 &age_exit,
1234 &baseline_cfg,
1235 saved_likelihood_mode,
1236 None,
1237 )?;
1238 if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1239 let time_anchor = model
1240 .survival_time_anchor
1241 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1242 add_survival_time_derivative_guard_offset(
1243 &age_entry,
1244 &age_exit,
1245 time_anchor,
1246 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
1247 &mut eta_offset_entry,
1248 &mut eta_offset_exit,
1249 &mut derivative_offset_exit,
1250 )?;
1251 }
1252 eta_offset_entry += &cov_design.affine_offset;
1256 eta_offset_exit += &cov_design.affine_offset;
1257 let saved_timewiggle = saved_baseline_timewiggle_components(
1258 &eta_offset_entry,
1259 &eta_offset_exit,
1260 &derivative_offset_exit,
1261 model,
1262 )?;
1263 let p_time = time_build.x_exit_time.ncols();
1264 let p_timewiggle = saved_timewiggle
1265 .as_ref()
1266 .map(|(_, exit, _)| exit.ncols())
1267 .unwrap_or(0);
1268 let p = p_time
1269 .checked_add(p_timewiggle)
1270 .and_then(|width| width.checked_add(p_cov))
1271 .ok_or_else(|| "saved survival sampler design width overflow".to_string())?;
1272 let survival_design_reservation = MemoryGovernor::global()
1277 .try_reserve_dense_f64_copies(
1278 n,
1279 p,
1280 SURVIVAL_DESIGN_LIVE_COPIES,
1281 "saved survival sampler design live set",
1282 )
1283 .map_err(|error| error.to_string())?;
1284 let mut x_entry = Array2::<f64>::zeros((n, p));
1285 let mut x_exit = Array2::<f64>::zeros((n, p));
1286 let mut x_derivative = Array2::<f64>::zeros((n, p));
1287 let row_chunk_target_bytes = ResourcePolicy::default_library().row_chunk_target_bytes;
1288 if p_time > 0 {
1289 stream_design_into(
1290 &time_build.x_entry_time,
1291 x_entry.slice_mut(s![.., ..p_time]),
1292 row_chunk_target_bytes,
1293 "saved survival entry-time design",
1294 )?;
1295 stream_design_into(
1296 &time_build.x_exit_time,
1297 x_exit.slice_mut(s![.., ..p_time]),
1298 row_chunk_target_bytes,
1299 "saved survival exit-time design",
1300 )?;
1301 stream_design_into(
1302 &time_build.x_derivative_time,
1303 x_derivative.slice_mut(s![.., ..p_time]),
1304 row_chunk_target_bytes,
1305 "saved survival derivative-time design",
1306 )?;
1307 if let Some(anchor_row) = time_anchor_row.as_ref() {
1308 if anchor_row.len() != p_time {
1309 return Err(format!(
1310 "survival time anchoring column mismatch: design={p_time}, anchor={}",
1311 anchor_row.len(),
1312 ));
1313 }
1314 for mut row in x_entry.slice_mut(s![.., ..p_time]).rows_mut() {
1315 row -= &anchor_row.view();
1316 }
1317 for mut row in x_exit.slice_mut(s![.., ..p_time]).rows_mut() {
1318 row -= &anchor_row.view();
1319 }
1320 }
1321 }
1322 if let Some((entry_w, exit_w, deriv_w)) = saved_timewiggle.as_ref()
1323 && p_timewiggle > 0
1324 {
1325 x_entry
1326 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1327 .assign(entry_w);
1328 x_exit
1329 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1330 .assign(exit_w);
1331 x_derivative
1332 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1333 .assign(deriv_w);
1334 }
1335 if p_cov > 0 {
1336 let cov_range = (p_time + p_timewiggle)..(p_time + p_timewiggle + p_cov);
1337 stream_design_into(
1338 &cov_design.design,
1339 x_entry.slice_mut(s![.., cov_range.clone()]),
1340 row_chunk_target_bytes,
1341 "saved survival covariate design",
1342 )?;
1343 x_exit
1344 .slice_mut(s![.., cov_range.clone()])
1345 .assign(&x_entry.slice(s![.., cov_range]));
1346 }
1347 drop(cov_design);
1350 let mut penalty_blocks: Vec<PenaltyBlock> = Vec::new();
1351 for (idx, s) in time_build.penalties.iter().enumerate() {
1352 if s.nrows() == p_time && s.ncols() == p_time {
1353 penalty_blocks.push(PenaltyBlock {
1354 matrix: s.clone(),
1355 lambda: time_build
1356 .smooth_lambda
1357 .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1358 range: 0..p_time,
1359 nullspace_dim: time_build.nullspace_dims.get(idx).copied().unwrap_or(0),
1360 });
1361 }
1362 }
1363 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
1364 if let Some((_, exit_w, _)) = saved_timewiggle.as_ref() {
1365 let start = p_time;
1366 let end = start + exit_w.ncols();
1367 let wiggle_lambda_offset = penalty_blocks.len();
1368 let wiggle_cfg = saved_baseline_timewiggle_spec(model)?.ok_or_else(|| {
1369 "saved baseline-timewiggle model missing baseline-timewiggle metadata".to_string()
1370 })?;
1371 let wiggle_degree = wiggle_cfg.degree;
1372 let wiggle_knots =
1373 Array1::from_vec(model.baseline_timewiggle_knots.clone().ok_or_else(|| {
1374 "saved baseline-timewiggle model missing baseline_timewiggle_knots".to_string()
1375 })?);
1376 let mut seed = Array1::<f64>::zeros(2 * n);
1377 for i in 0..n {
1378 seed[i] = eta_offset_entry[i];
1379 seed[n + i] = eta_offset_exit[i];
1380 }
1381 let (primary_order, extra_orders) =
1382 split_wiggle_penalty_orders(2, &wiggle_cfg.penalty_orders)?;
1383 let mut derivative_orders = Vec::with_capacity(1 + extra_orders.len());
1384 derivative_orders.push(primary_order);
1385 derivative_orders.extend(extra_orders);
1386 let block = buildwiggle_block_input_from_orders(
1391 seed.view(),
1392 &wiggle_knots,
1393 wiggle_degree,
1394 &derivative_orders,
1395 wiggle_cfg.double_penalty,
1396 )
1397 .map_err(|e| format!("baseline-timewiggle penalty reconstruction failed: {e}"))?;
1398 for (widx, s) in block.penalties.iter().enumerate() {
1399 let s = match s {
1400 gam_solve::estimate::PenaltySpec::Block { local, .. } => local,
1401 gam_solve::estimate::PenaltySpec::Dense(m)
1402 | gam_solve::estimate::PenaltySpec::DenseWithMean { matrix: m, .. } => m,
1403 };
1404 if s.nrows() == exit_w.ncols() && s.ncols() == exit_w.ncols() {
1405 penalty_blocks.push(PenaltyBlock {
1406 matrix: s.clone(),
1407 lambda: time_build
1408 .smooth_lambda
1409 .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1410 range: start..end,
1411 nullspace_dim: block.nullspace_dims.get(widx).copied().unwrap_or(0),
1412 });
1413 }
1414 }
1415 for (local_idx, block_penalty) in penalty_blocks[wiggle_lambda_offset..]
1416 .iter_mut()
1417 .enumerate()
1418 {
1419 if let Some(&lam) = fit_saved.lambdas.get(wiggle_lambda_offset + local_idx) {
1420 block_penalty.lambda = lam;
1421 }
1422 }
1423 }
1424 drop(saved_timewiggle);
1427 let ridge_lambda = model.survivalridge_lambda.ok_or_else(|| {
1428 "saved survival model is missing survivalridge_lambda; refusing to \
1429 pick a load-time default (the historical 1e-4 fallback silently \
1430 disagreed with the 1e-6 fit-time default). Refit."
1431 .to_string()
1432 })?;
1433 let ridge_range_start = if time_build.basisname == "linear" && !model.has_baseline_time_wiggle()
1434 {
1435 1
1436 } else {
1437 0
1438 };
1439 drop(time_build);
1442 if ridge_lambda > 0.0 && p > ridge_range_start {
1443 let dim = p - ridge_range_start;
1444 let mut ridge = Array2::<f64>::zeros((dim, dim));
1445 for d in 0..dim {
1446 ridge[[d, d]] = 1.0;
1447 }
1448 penalty_blocks.push(PenaltyBlock {
1449 matrix: ridge,
1450 lambda: ridge_lambda,
1451 range: ridge_range_start..p,
1452 nullspace_dim: 0,
1453 });
1454 }
1455 for (idx, block) in penalty_blocks.iter_mut().enumerate() {
1456 if let Some(&lam) = fit_saved.lambdas.get(idx) {
1457 block.lambda = lam;
1458 }
1459 }
1460 let penalties = PenaltyBlocks::new(penalty_blocks);
1461 let survivalspec = match model
1462 .survivalspec
1463 .as_deref()
1464 .unwrap_or("net")
1465 .to_ascii_lowercase()
1466 .as_str()
1467 {
1468 "net" => SurvivalSpec::Net,
1469 "crude" => {
1470 return Err("saved survival spec 'crude' is not supported by the one-hazard survival engine; refit or export a net survival model for this path"
1471 .to_string());
1472 }
1473 other => {
1474 return Err(format!("unsupported saved survival spec '{other}'"));
1475 }
1476 };
1477 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 0.0 };
1478 let mut model_surv = royston_parmar::working_model_from_flattened(
1479 penalties.clone(),
1480 monotonicity,
1481 survivalspec,
1482 RoystonParmarInputs {
1483 age_entry: age_entry.view(),
1484 age_exit: age_exit.view(),
1485 event_target: event_target.view(),
1486 event_competing: event_competing.view(),
1487 weights: weights.view(),
1488 x_entry: x_entry.view(),
1489 x_exit: x_exit.view(),
1490 x_derivative: x_derivative.view(),
1491 monotonicity_constraint_rows: None,
1492 monotonicity_constraint_offsets: None,
1493 eta_offset_entry: Some(eta_offset_entry.view()),
1494 eta_offset_exit: Some(eta_offset_exit.view()),
1495 derivative_offset_exit: Some(derivative_offset_exit.view()),
1496 },
1497 )
1498 .map_err(|e| format!("failed to construct survival model: {e}"))?;
1499 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull {
1500 model_surv
1501 .set_structural_monotonicity(true, p_time + p_timewiggle)
1502 .map_err(|e| format!("failed to enable structural monotonicity: {e}"))?;
1503 }
1504 let beta0 = fit_saved.beta.clone();
1505 let survival_hessian_reservation = MemoryGovernor::global()
1506 .try_reserve_dense_f64(p, p, "saved survival sampler Hessian")
1507 .map_err(|error| error.to_string())?;
1508 let hessian = {
1509 let state = model_surv
1510 .update_state(&beta0)
1511 .map_err(|e| format!("failed to evaluate survival state: {e}"))?;
1512 match state.hessian {
1513 gam_linalg::matrix::SymmetricMatrix::Dense(hessian) => hessian,
1516 gam_linalg::matrix::SymmetricMatrix::Sparse(hessian) => {
1519 gam_linalg::matrix::SymmetricMatrix::Sparse(hessian).to_dense()
1520 }
1521 }
1522 };
1523 drop(model_surv);
1526 let result = run_survival_nuts_sampling_flattened(
1527 SurvivalFlatInputs {
1528 age_entry: age_entry.view(),
1529 age_exit: age_exit.view(),
1530 event_target: event_target.view(),
1531 event_competing: event_competing.view(),
1532 weights: weights.view(),
1533 x_entry: x_entry.view(),
1534 x_exit: x_exit.view(),
1535 x_derivative: x_derivative.view(),
1536 eta_offset_entry: Some(eta_offset_entry.view()),
1537 eta_offset_exit: Some(eta_offset_exit.view()),
1538 derivative_offset_exit: Some(derivative_offset_exit.view()),
1539 },
1540 penalties,
1541 monotonicity,
1542 survivalspec,
1543 saved_likelihood_mode != SurvivalLikelihoodMode::Weibull,
1544 p_time + p_timewiggle,
1545 beta0.view(),
1546 hessian.view(),
1547 cfg,
1548 )
1549 .map_err(|e| format!("survival NUTS sampling failed: {e}"));
1550 drop(survival_hessian_reservation);
1551 drop(survival_design_reservation);
1552 result
1553}
1554
1555#[cfg(test)]
1556mod tests {
1557 use super::*;
1558 use gam_linalg::matrix::{DenseDesignMatrix, DenseDesignOperator, LinearOperator};
1559 use gam_problem::types::LikelihoodScaleMetadata;
1560
1561 #[test]
1562 fn link_wiggle_dispatch_requires_and_consumes_the_persisted_cone() {
1563 assert_eq!(
1564 standard_posterior_route(false, false, true, true, true)
1565 .expect("saved link-wiggle cone"),
1566 StandardPosteriorRoute::InequalityTruncated,
1567 "a Gaussian link wiggle must reach the cone before the closed-form shortcut",
1568 );
1569 assert_eq!(
1570 standard_posterior_route(false, false, true, true, false)
1571 .expect("saved link-wiggle cone"),
1572 StandardPosteriorRoute::InequalityTruncated,
1573 "a non-Gaussian link wiggle consumes the same fitted Laplace posterior",
1574 );
1575 let missing = standard_posterior_route(false, false, true, false, true)
1576 .expect_err("a link wiggle without persisted constraint geometry must refuse");
1577 assert!(missing.contains("no persisted inequality-truncated posterior identity"));
1578 assert_eq!(
1579 standard_posterior_route(false, false, false, false, true)
1580 .expect("unconstrained Gaussian"),
1581 StandardPosteriorRoute::GaussianClosedForm,
1582 "the unconstrained Gaussian fast path must remain unchanged",
1583 );
1584 }
1585
1586 #[test]
1587 fn constrained_draw_lift_uses_the_saved_affine_gauge() {
1588 let active = ndarray::array![[1.0, 2.0], [-3.0, 4.0]];
1589 let gauge = gam_problem::gauge::Gauge::from_block_transform_with_shift(
1590 ndarray::array![[1.0, 0.0], [0.0, 2.0], [1.0, -1.0]],
1591 ndarray::array![0.5, -1.0, 3.0],
1592 );
1593 let saved = lift_active_samples_to_saved(active, &gauge).expect("valid affine sample lift");
1594 assert_eq!(saved, ndarray::array![[1.5, 3.0, 2.0], [-2.5, 7.0, -4.0]]);
1595 }
1596
1597 struct ChunkOnlySampleDesign {
1598 values: Array2<f64>,
1599 row_chunk_calls: std::sync::atomic::AtomicUsize,
1600 fail_rows: bool,
1601 }
1602
1603 impl LinearOperator for ChunkOnlySampleDesign {
1604 fn nrows(&self) -> usize {
1605 self.values.nrows()
1606 }
1607
1608 fn ncols(&self) -> usize {
1609 self.values.ncols()
1610 }
1611
1612 fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
1613 self.values.dot(vector)
1614 }
1615
1616 fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
1617 self.values.t().dot(vector)
1618 }
1619
1620 fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
1621 if weights.len() != self.nrows() {
1622 return Err(format!(
1623 "weight vector has {} entries for {} design rows",
1624 weights.len(),
1625 self.nrows()
1626 ));
1627 }
1628 Ok(Array2::zeros((self.ncols(), self.ncols())))
1629 }
1630 }
1631
1632 impl DenseDesignOperator for ChunkOnlySampleDesign {
1633 fn row_chunk_into(
1634 &self,
1635 rows: std::ops::Range<usize>,
1636 mut out: ndarray::ArrayViewMut2<'_, f64>,
1637 ) -> Result<(), gam_runtime::resource::MatrixMaterializationError> {
1638 self.row_chunk_calls
1639 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1640 if self.fail_rows {
1641 return Err(
1642 gam_runtime::resource::MatrixMaterializationError::MissingRowChunk {
1643 context: "ChunkOnlySampleDesign test refusal",
1644 },
1645 );
1646 }
1647 out.assign(&self.values.slice(s![rows, ..]));
1648 Ok(())
1649 }
1650
1651 fn to_dense(&self) -> Array2<f64> {
1652 panic!("stream_design_into must never call to_dense")
1653 }
1654 }
1655
1656 #[test]
1657 fn survival_design_streaming_uses_row_chunks_and_target_slice() {
1658 let values = Array2::from_shape_fn((5, 3), |(i, j)| (10 * i + j) as f64);
1659 let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1660 values: values.clone(),
1661 row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1662 fail_rows: false,
1663 });
1664 let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(
1665 std::sync::Arc::clone(&operator),
1666 ));
1667 let mut assembled = Array2::<f64>::from_elem((5, 5), -1.0);
1668
1669 stream_design_into(
1670 &design,
1671 assembled.slice_mut(s![.., 1..4]),
1672 2 * 3 * std::mem::size_of::<f64>(),
1673 "streaming regression",
1674 )
1675 .expect("row-chunk assembly succeeds");
1676
1677 assert_eq!(assembled.slice(s![.., 1..4]), values.view());
1678 assert!(assembled.column(0).iter().all(|&value| value == -1.0));
1679 assert!(assembled.column(4).iter().all(|&value| value == -1.0));
1680 assert_eq!(
1681 operator
1682 .row_chunk_calls
1683 .load(std::sync::atomic::Ordering::SeqCst),
1684 3,
1685 );
1686 }
1687
1688 #[test]
1689 fn survival_design_streaming_propagates_typed_row_refusal() {
1690 let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1691 values: Array2::zeros((2, 2)),
1692 row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1693 fail_rows: true,
1694 });
1695 let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(operator));
1696 let mut assembled = Array2::<f64>::zeros((2, 2));
1697
1698 let error = stream_design_into(
1699 &design,
1700 assembled.view_mut(),
1701 std::mem::size_of::<f64>(),
1702 "streaming refusal regression",
1703 )
1704 .expect_err("row-chunk refusal must remain fallible");
1705
1706 assert!(error.contains("streaming refusal regression"));
1707 assert!(error.contains("ChunkOnlySampleDesign test refusal"));
1708 }
1709
1710 #[test]
1721 fn refresh_negbin_theta_reads_theta_hat_not_seed() {
1722 let mut likelihood = LikelihoodSpec::negative_binomial_log(1.0);
1725 let scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.97 };
1726
1727 refresh_negbin_theta_for_sampling(&mut likelihood, scale);
1728
1729 match likelihood.response {
1730 ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
1731 theta, 2.97,
1732 "NB NUTS must sample at theta_hat (#1463), not the seed theta=1.0"
1733 ),
1734 other => panic!("expected NegativeBinomial response, got {other:?}"),
1735 }
1736 }
1737
1738 #[test]
1742 fn refresh_negbin_theta_fixed_theta_is_preserved() {
1743 let mut likelihood = LikelihoodSpec::negative_binomial_log_fixed(4.25);
1744 let scale = LikelihoodScaleMetadata::FixedNegBinTheta { theta: 4.25 };
1745
1746 refresh_negbin_theta_for_sampling(&mut likelihood, scale);
1747
1748 match likelihood.response {
1749 ResponseFamily::NegativeBinomial { theta, theta_fixed } => {
1750 assert_eq!(theta, 4.25, "fixed NB theta must survive the refresh");
1751 assert!(theta_fixed, "theta_fixed flag must be preserved");
1752 }
1753 other => panic!("expected NegativeBinomial response, got {other:?}"),
1754 }
1755 }
1756
1757 #[test]
1761 fn refresh_negbin_theta_falls_back_to_seed_when_unfitted() {
1762 let mut likelihood = LikelihoodSpec::negative_binomial_log(3.5);
1763 refresh_negbin_theta_for_sampling(
1765 &mut likelihood,
1766 LikelihoodScaleMetadata::ProfiledGaussian,
1767 );
1768
1769 match likelihood.response {
1770 ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
1771 theta, 3.5,
1772 "with no fitted theta the NB seed must be kept verbatim"
1773 ),
1774 other => panic!("expected NegativeBinomial response, got {other:?}"),
1775 }
1776 }
1777
1778 #[test]
1782 fn refresh_negbin_theta_leaves_non_nb_families_untouched() {
1783 let mut poisson = LikelihoodSpec::poisson_log();
1784 let before = poisson.response.clone();
1785 refresh_negbin_theta_for_sampling(
1786 &mut poisson,
1787 LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 9.0 },
1788 );
1789 assert_eq!(
1790 poisson.response, before,
1791 "Poisson response must be untouched by the NB theta refresh"
1792 );
1793 }
1794
1795
1796 #[test]
1802 fn a_persisted_cone_routes_the_fallback_arms_to_the_truncated_law() {
1803 for &declares in &[false, true] {
1804 for &wiggle in &[false, true] {
1805 assert_eq!(
1806 laplace_fallback_route(declares, wiggle, true),
1807 Ok(LaplaceFallbackRoute::InequalityTruncated),
1808 "a persisted inequality identity is the dispatch authority \
1809 (declares={declares}, wiggle={wiggle})"
1810 );
1811 }
1812 }
1813 }
1814
1815 #[test]
1826 fn a_declared_cone_with_no_persisted_identity_is_refused_not_approximated() {
1827 for &(declares, wiggle) in &[(true, false), (false, true), (true, true)] {
1828 let route = laplace_fallback_route(declares, wiggle, false);
1829 let error = route.expect_err(
1830 "a declared cone without its persisted identity has no admissible draw",
1831 );
1832 assert!(
1833 error.contains("no persisted inequality-truncated posterior identity"),
1834 "the refusal must name what is missing, got: {error}"
1835 );
1836 assert!(
1837 error.contains("outside the cone"),
1838 "the refusal must name the consequence, got: {error}"
1839 );
1840 }
1841 }
1842
1843 #[test]
1847 fn a_model_declaring_no_cone_keeps_the_unconstrained_gaussian_fallback() {
1848 assert_eq!(
1849 laplace_fallback_route(false, false, false),
1850 Ok(LaplaceFallbackRoute::UnconstrainedGaussian)
1851 );
1852 }
1853
1854 #[test]
1860 fn the_fallback_route_agrees_with_the_standard_route_on_every_shared_state() {
1861 for &declares in &[false, true] {
1862 for &wiggle in &[false, true] {
1863 for &persisted in &[false, true] {
1864 let standard = standard_posterior_route(false, declares, wiggle, persisted, false);
1865 let fallback = laplace_fallback_route(declares, wiggle, persisted);
1866 match (standard, fallback) {
1867 (Ok(StandardPosteriorRoute::InequalityTruncated), Ok(other)) => assert_eq!(
1868 other,
1869 LaplaceFallbackRoute::InequalityTruncated,
1870 "declares={declares} wiggle={wiggle} persisted={persisted}"
1871 ),
1872 (Ok(StandardPosteriorRoute::UnconstrainedNuts), Ok(other)) => assert_eq!(
1873 other,
1874 LaplaceFallbackRoute::UnconstrainedGaussian,
1875 "the unconstrained state differs only in HOW it draws, not in \
1876 whether the cone applies (declares={declares} wiggle={wiggle})"
1877 ),
1878 (Err(_), Err(_)) => {}
1879 (standard, fallback) => panic!(
1880 "the two public routes disagree at declares={declares} \
1881 wiggle={wiggle} persisted={persisted}: {standard:?} vs {fallback:?}"
1882 ),
1883 }
1884 }
1885 }
1886 }
1887 }
1888}