1use std::collections::HashMap;
12
13use faer::Side;
14use ndarray::{Array1, Array2, ArrayView1, ArrayView2, s};
15use rand::{RngExt, SeedableRng};
16
17use super::hmc_io::{
18 FamilyNutsInputs, GlmFlatInputs, LinkWiggleFamilyParams, LinkWiggleSplineArtifacts,
19 SurvivalFlatInputs, explicit_fit_hessian_for_whitening, run_link_wiggle_nuts_sampling,
20 run_nuts_sampling_flattened_family, run_survival_nuts_sampling_flattened, validate_nuts_config,
21};
22pub use super::hmc_io::{NutsConfig, NutsResult};
23use crate::formula_dsl::{LinkWiggleFormulaSpec, parse_formula};
24use crate::model::{
25 FittedModel as SavedModel, PredictModelClass, SavedLinkWiggleRuntime,
26 load_survival_time_basis_config_from_model,
27};
28use gam_linalg::faer_ndarray::FaerCholesky;
29use gam_linalg::triangular::back_substitution_lower_transpose_guarded_into;
30use gam_models::survival::construction::{
31 SurvivalLikelihoodMode, add_survival_time_derivative_guard_offset, build_survival_time_basis,
32 build_survival_time_offsets_for_likelihood, evaluate_survival_time_basis_row,
33 normalize_survival_time_pair, resolved_survival_time_basis_config_from_build,
34 survival_derivative_guard_for_likelihood,
35};
36use gam_models::survival::predict::{
37 fit_result_from_saved_model_for_prediction, require_saved_survival_likelihood_mode,
38 resolve_saved_survival_time_columns, resolve_termspec_for_prediction,
39 saved_baseline_timewiggle_components, saved_survival_runtime_baseline_config,
40};
41use gam_models::survival::royston_parmar::{self, RoystonParmarInputs};
42use gam_models::survival::{
43 PenaltyBlock, PenaltyBlocks, SurvivalMonotonicityPenalty, SurvivalSpec,
44};
45use gam_models::wiggle::{
46 append_selected_wiggle_function_penalties, buildwiggle_block_input_from_knots,
47 canonical_wiggle_function_penalties, split_wiggle_penalty_orders,
48};
49use gam_problem::types::{InverseLink, LikelihoodSpec, ResponseFamily, StandardLink};
50use gam_runtime::resource::{MemoryGovernor, ResourcePolicy, rows_for_target_bytes};
51use gam_solve::estimate::{BlockRole, validate_all_finite};
52use gam_terms::smooth::build_term_collection_design;
53use gam_terms::smooth::{LinearCoefficientGeometry, weighted_blockwise_penalty_sum};
54use gam_terms::term_builder::resolve_role_col;
55
56fn sampling_sqrt_covariance_scale(
57 fit: &gam_solve::estimate::UnifiedFitResult,
58 context: &str,
59) -> Result<f64, String> {
60 let scale = fit
61 .coefficient_covariance_scale()
62 .map_err(|err| format!("{context}: cannot resolve coefficient-covariance scale: {err}"))?;
63 if !(scale.is_finite() && scale > 0.0) {
64 return Err(format!(
65 "{context}: posterior sampling requires a finite strictly-positive coefficient-covariance scale, got {scale}"
66 ));
67 }
68 Ok(scale.sqrt())
69}
70
71fn resolved_fit_dispersion(
72 fit: &gam_solve::estimate::UnifiedFitResult,
73 context: &str,
74) -> Result<gam_problem::Dispersion, String> {
75 if let Some(dispersion) = fit.dispersion() {
76 return Ok(dispersion);
77 }
78 let family = fit.likelihood_family.as_ref().ok_or_else(|| {
79 format!("{context}: fit has no engine-level family and no scalar dispersion")
80 })?;
81 let likelihood = gam_problem::GlmLikelihoodSpec::try_new(family.clone(), fit.likelihood_scale)
82 .map_err(|err| format!("{context}: invalid fitted likelihood scale: {err}"))?;
83 let profiled_standard_deviation = matches!(
84 likelihood
85 .resolved_scale()
86 .map_err(|err| format!("{context}: invalid fitted likelihood scale: {err}"))?,
87 gam_problem::ResolvedLikelihoodScale::ProfiledGaussian
88 )
89 .then_some(fit.standard_deviation);
90 gam_solve::estimate::dispersion_from_likelihood(&likelihood, profiled_standard_deviation)
91 .map_err(|err| format!("{context}: cannot resolve fitted dispersion: {err}"))
92}
93
94const SURVIVAL_DESIGN_LIVE_COPIES: usize = 2 * 3;
97
98fn stream_design_into(
103 design: &gam_linalg::matrix::DesignMatrix,
104 mut out: ndarray::ArrayViewMut2<'_, f64>,
105 row_chunk_target_bytes: usize,
106 context: &str,
107) -> Result<(), String> {
108 if out.dim() != (design.nrows(), design.ncols()) {
109 return Err(format!(
110 "{context}: output shape {}x{} does not match design {}x{}",
111 out.nrows(),
112 out.ncols(),
113 design.nrows(),
114 design.ncols(),
115 ));
116 }
117 let chunk_rows = rows_for_target_bytes(row_chunk_target_bytes, design.ncols())
118 .max(1)
119 .min(design.nrows().max(1));
120 for start in (0..design.nrows()).step_by(chunk_rows) {
121 let end = (start + chunk_rows).min(design.nrows());
122 design
123 .row_chunk_into(start..end, out.slice_mut(s![start..end, ..]))
124 .map_err(|error| format!("{context}: {error}"))?;
125 }
126 Ok(())
127}
128
129pub fn saved_baseline_timewiggle_spec(
134 model: &SavedModel,
135) -> Result<Option<LinkWiggleFormulaSpec>, String> {
136 model
137 .saved_baseline_time_wiggle()
138 .map_err(|e| e.to_string())
139 .map(|runtime| {
140 runtime.map(|saved| LinkWiggleFormulaSpec {
141 degree: saved.degree,
142 num_internal_knots: saved.knots.len().saturating_sub(2 * (saved.degree + 1)),
143 penalty_orders: saved.penalty_orders,
144 double_penalty: saved.double_penalty,
145 })
146 })
147}
148
149fn weighted_penalty_matrix(
150 penalties: &[Array2<f64>],
151 lambdas: ArrayView1<'_, f64>,
152) -> Result<Array2<f64>, String> {
153 if penalties.len() != lambdas.len() {
154 return Err(format!(
155 "penalty/lambda mismatch: {} penalties vs {} lambdas",
156 penalties.len(),
157 lambdas.len()
158 ));
159 }
160 if penalties.is_empty() {
161 return Err("cannot sample without at least one penalty block".to_string());
162 }
163 let p = penalties[0].nrows();
164 let mut out = Array2::<f64>::zeros((p, p));
165 for (k, s) in penalties.iter().enumerate() {
166 if s.nrows() != p || s.ncols() != p {
167 return Err(format!(
168 "penalty block {k} shape mismatch: got {}x{}, expected {}x{}",
169 s.nrows(),
170 s.ncols(),
171 p,
172 p
173 ));
174 }
175 let lam = lambdas[k];
176 out += &(s * lam);
177 }
178 Ok(out)
179}
180
181fn saved_link_wiggle_penalty_matrix(
187 runtime: &SavedLinkWiggleRuntime,
188 lambdas: ArrayView1<'_, f64>,
189 expected_dimension: usize,
190) -> Result<Array2<f64>, String> {
191 let metadata = runtime.penalty_metadata.as_ref().ok_or_else(|| {
192 "standard link-wiggle sampling requires saved canonical penalty metadata; refit".to_string()
193 })?;
194 let canonical = canonical_wiggle_function_penalties(
195 &Array1::from_vec(runtime.knots.clone()),
196 runtime.degree,
197 &metadata.derivative_orders,
198 metadata.double_penalty,
199 )
200 .map_err(|error| format!("saved link-wiggle penalty reconstruction failed: {error}"))?;
201 if canonical.metadata != *metadata {
202 return Err(format!(
203 "saved link-wiggle penalty topology {:?} disagrees with canonical topology {:?}",
204 metadata.blocks, canonical.metadata.blocks,
205 ));
206 }
207 if canonical.matrices.len() != lambdas.len() {
208 return Err(format!(
209 "saved link-wiggle penalty/lambda mismatch: canonical topology has {} blocks but fit stores {} lambdas",
210 canonical.matrices.len(),
211 lambdas.len(),
212 ));
213 }
214 for (index, matrix) in canonical.matrices.iter().enumerate() {
215 if matrix.dim() != (expected_dimension, expected_dimension) {
216 return Err(format!(
217 "saved link-wiggle penalty block {index} is {}x{} but fitted LinkWiggle coordinate has dimension {expected_dimension}",
218 matrix.nrows(),
219 matrix.ncols(),
220 ));
221 }
222 }
223 weighted_penalty_matrix(&canonical.matrices, lambdas)
224}
225
226fn validate_explicit_link_wiggle_joint_hessian(
227 hessian: &Array2<f64>,
228 expected_dim: usize,
229) -> Result<(), String> {
230 if hessian.nrows() != expected_dim || hessian.ncols() != expected_dim {
231 return Err(format!(
232 "link-wiggle sample: explicit joint Hessian is {}x{} but expected {}x{}",
233 hessian.nrows(),
234 hessian.ncols(),
235 expected_dim,
236 expected_dim,
237 ));
238 }
239 validate_all_finite(
240 "link-wiggle explicit joint Hessian",
241 hessian.iter().copied(),
242 )?;
243 let mut max_abs = 0.0_f64;
244 for r in 0..expected_dim {
245 for c in 0..expected_dim {
246 max_abs = max_abs.max(hessian[[r, c]].abs());
247 let scale = hessian[[r, c]].abs().max(hessian[[c, r]].abs()).max(1.0);
248 if (hessian[[r, c]] - hessian[[c, r]]).abs() > 1e-9 * scale {
249 return Err(format!(
250 "link-wiggle sample: explicit joint Hessian is not symmetric at ({r},{c})"
251 ));
252 }
253 }
254 }
255 if max_abs == 0.0 {
256 return Err("link-wiggle sample: explicit joint Hessian is all zeros; refit with exact Hessian export"
257 .to_string());
258 }
259 Ok(())
260}
261
262fn saved_prior_weights(
269 model: &SavedModel,
270 data: ArrayView2<'_, f64>,
271 col_map: &HashMap<String, usize>,
272) -> Result<Array1<f64>, String> {
273 match model.weight_column.as_deref() {
274 Some(name) => {
275 let idx = resolve_role_col(col_map, name, "weights")?;
276 let w = data.column(idx).to_owned();
277 if !w.iter().all(|v| v.is_finite() && *v >= 0.0) {
278 return Err(format!(
279 "sample: prior-weights column '{name}' contains negative or non-finite values"
280 ));
281 }
282 Ok(w)
283 }
284 None => Ok(Array1::ones(data.nrows())),
285 }
286}
287
288fn saved_offset(
294 model: &SavedModel,
295 data: ArrayView2<'_, f64>,
296 col_map: &HashMap<String, usize>,
297) -> Result<Option<Array1<f64>>, String> {
298 match model.offset_column.as_deref() {
299 Some(name) => {
300 let idx = resolve_role_col(col_map, name, "offset")?;
301 Ok(Some(data.column(idx).to_owned()))
302 }
303 None => Ok(None),
304 }
305}
306
307fn refresh_negbin_theta_for_sampling(
325 likelihood: &mut LikelihoodSpec,
326 scale: gam_problem::types::LikelihoodScaleMetadata,
327) {
328 if let ResponseFamily::NegativeBinomial { theta, .. } = &mut likelihood.response {
329 if let Some(theta_hat) = scale.negbin_theta() {
330 *theta = theta_hat;
331 }
332 }
333}
334
335fn likelihood_spec_for_saved_model(model: &SavedModel) -> Result<LikelihoodSpec, String> {
339 Ok(model.likelihood())
340}
341
342const DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA: f64 = 1e-2;
347
348#[inline]
349const fn splitmix64(x: u64) -> u64 {
350 gam_linalg::utils::splitmix64_hash(x)
351}
352
353#[inline]
354const fn chain_stream_seed(seed: u64, chain: usize, stream: u64) -> u64 {
355 splitmix64(seed ^ stream ^ ((chain as u64).wrapping_mul(0xD1B5_4A32_D192_ED03)))
356}
357
358pub fn sample_saved_model(
377 model: &SavedModel,
378 data: ArrayView2<'_, f64>,
379 col_map: &HashMap<String, usize>,
380 training_headers: Option<&Vec<String>>,
381 cfg: &NutsConfig,
382) -> Result<NutsResult, String> {
383 validate_nuts_config(cfg).map_err(String::from)?;
392 let likelihood = likelihood_spec_for_saved_model(model)?;
393 match model.predict_model_class() {
394 PredictModelClass::Survival => {
395 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
401 if matches!(
402 saved_likelihood_mode,
403 SurvivalLikelihoodMode::Latent
404 | SurvivalLikelihoodMode::LatentBinary
405 | SurvivalLikelihoodMode::LocationScale
406 ) {
407 laplace_gaussian_fallback(model, cfg, "survival posterior fallback")
408 } else {
409 sample_survival(model, data, col_map, training_headers, cfg)
410 }
411 }
412 PredictModelClass::Standard => {
413 if matches!(likelihood.response, ResponseFamily::Beta { .. }) {
422 laplace_gaussian_fallback(model, cfg, "beta-regression posterior fallback")
423 } else {
424 sample_standard(model, data, col_map, training_headers, likelihood, cfg)
425 }
426 }
427 PredictModelClass::GaussianLocationScale => {
437 laplace_gaussian_fallback(model, cfg, "gaussian location-scale posterior")
438 }
439 PredictModelClass::BinomialLocationScale => {
440 laplace_gaussian_fallback(model, cfg, "binomial location-scale posterior")
441 }
442 PredictModelClass::DispersionLocationScale => {
443 laplace_gaussian_fallback(model, cfg, "dispersion location-scale posterior")
444 }
445 PredictModelClass::BernoulliMarginalSlope => {
446 laplace_gaussian_fallback(model, cfg, "bernoulli marginal-slope posterior")
447 }
448 PredictModelClass::TransformationNormal => {
449 sample_transformation_normal_constrained(model, cfg)
454 }
455 }
456}
457
458pub fn laplace_gaussian_fallback(
472 model: &SavedModel,
473 cfg: &NutsConfig,
474 rationale: &'static str,
475) -> Result<NutsResult, String> {
476 validate_nuts_config(cfg).map_err(String::from)?;
481 let fit = fit_result_from_saved_model_for_prediction(model)?;
482 let mode = fit.beta.clone();
483 let p = mode.len();
484 if p == 0 {
485 return Err(format!(
486 "{rationale}: cannot sample from an empty coefficient vector"
487 ));
488 }
489 let h = fit.penalized_hessian().ok_or_else(|| {
490 format!(
491 "{rationale}: posterior fallback requires the explicit penalised Hessian; \
492 refit with exact geometry export to enable posterior sampling for this class."
493 )
494 })?;
495 let sqrt_cov_scale = sampling_sqrt_covariance_scale(&fit, rationale)?;
512 if h.nrows() != p || h.ncols() != p {
513 return Err(format!(
514 "{rationale}: penalised Hessian is {}x{}, expected {}x{}",
515 h.nrows(),
516 h.ncols(),
517 p,
518 p
519 ));
520 }
521 let chol = h.cholesky(Side::Lower).map_err(|err| {
522 format!("{rationale}: Cholesky factorisation of the penalised Hessian failed: {err:?}")
523 })?;
524 let l = chol.lower_triangular();
525
526 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
530 let mut samples = Array2::<f64>::zeros((n_total, p));
531 let mut eps = Array1::<f64>::zeros(p);
532 let mut delta = Array1::<f64>::zeros(p);
533 for chain in 0..cfg.n_chains {
534 let mut rng = rand::rngs::StdRng::seed_from_u64(chain_stream_seed(
535 cfg.seed,
536 chain,
537 0xA0B7_6C5D_E431_298F,
538 ));
539 for draw in 0..cfg.n_samples {
540 let k = chain * cfg.n_samples + draw;
541 for i in 0..p {
542 eps[i] = sample_standard_normal(&mut rng);
543 }
544 back_substitution_lower_transpose_guarded_into(&l, &eps, &mut delta);
545 for i in 0..p {
546 samples[(k, i)] = mode[i] + sqrt_cov_scale * delta[i];
551 }
552 }
553 }
554
555 let posterior_mean = samples
556 .mean_axis(ndarray::Axis(0))
557 .unwrap_or_else(|| Array1::<f64>::zeros(p));
558 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
559
560 Ok(NutsResult {
561 samples,
562 posterior_mean,
563 posterior_std,
564 rhat: 1.0,
565 ess: n_total as f64,
566 converged: true,
567 })
568}
569
570fn sample_transformation_normal_constrained(
583 model: &SavedModel,
584 cfg: &NutsConfig,
585) -> Result<NutsResult, String> {
586 const RATIONALE: &str = "transformation-normal constrained posterior";
587 const MAX_REJECTION_FACTOR: usize = 1000;
591
592 validate_nuts_config(cfg).map_err(String::from)?;
593 let fit = fit_result_from_saved_model_for_prediction(model)?;
594 let mode = fit.beta.clone();
595 let p = mode.len();
596 if p == 0 {
597 return Err(format!(
598 "{RATIONALE}: cannot sample from an empty coefficient vector"
599 ));
600 }
601
602 let geometry = model.transformation_geometry.as_ref().ok_or_else(|| {
603 format!("{RATIONALE}: missing the direct-α geometry record; refit (gam#2306)")
604 })?;
605 let carrier = model.transformation_cone_carrier.as_ref().ok_or_else(|| {
606 format!(
607 "{RATIONALE}: missing the monotonicity-cone carrier (transformation_cone_carrier); \
608 refit to persist the cone so constrained sampling can certify draws"
609 )
610 })?;
611 let n = geometry.cone_carrier_row_count;
612 let p_cov = geometry.cone_carrier_covariate_width;
613 let p_resp = geometry.shape_coordinate_count + 1;
614 if carrier.len() != n.saturating_mul(p_cov) {
615 return Err(format!(
616 "{RATIONALE}: cone carrier length {} != {n} rows x {p_cov} covariate columns",
617 carrier.len()
618 ));
619 }
620 if p != p_resp.saturating_mul(p_cov) {
621 return Err(format!(
622 "{RATIONALE}: coefficient length {p} != p_resp {p_resp} x p_cov {p_cov}; the saved \
623 coefficient block does not match the persisted cone geometry"
624 ));
625 }
626 let psi = Array2::from_shape_vec((n, p_cov), carrier.clone())
627 .map_err(|err| format!("{RATIONALE}: cone carrier reshape to {n}x{p_cov} failed: {err}"))?;
628
629 let is_feasible = |beta: &Array1<f64>| -> bool {
632 for k in 1..p_resp {
633 let block = beta.slice(ndarray::s![k * p_cov..(k + 1) * p_cov]);
634 if psi.dot(&block).iter().any(|value| *value < 0.0) {
635 return false;
636 }
637 }
638 true
639 };
640 if !is_feasible(&mode) {
641 return Err(format!(
642 "{RATIONALE}: the fitted mode violates the monotonicity cone Γ ≥ 0 — the saved model is \
643 not a valid monotone transformation; refit"
644 ));
645 }
646
647 let h = fit.penalized_hessian().ok_or_else(|| {
648 format!(
649 "{RATIONALE}: requires the explicit penalised Hessian; refit with exact geometry export"
650 )
651 })?;
652 let sqrt_cov_scale = sampling_sqrt_covariance_scale(&fit, RATIONALE)?;
653 if h.nrows() != p || h.ncols() != p {
654 return Err(format!(
655 "{RATIONALE}: penalised Hessian is {}x{}, expected {p}x{p}",
656 h.nrows(),
657 h.ncols()
658 ));
659 }
660 let chol = h.cholesky(Side::Lower).map_err(|err| {
661 format!("{RATIONALE}: Cholesky factorisation of the penalised Hessian failed: {err:?}")
662 })?;
663 let l = chol.lower_triangular();
664
665 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
666 let mut samples = Array2::<f64>::zeros((n_total, p));
667 let mut eps = Array1::<f64>::zeros(p);
668 let mut delta = Array1::<f64>::zeros(p);
669 let mut draw = Array1::<f64>::zeros(p);
670 let attempts_cap = cfg
671 .n_samples
672 .saturating_mul(MAX_REJECTION_FACTOR)
673 .max(MAX_REJECTION_FACTOR);
674 let mut total_attempts: u64 = 0;
675 let mut total_accepted: u64 = 0;
676
677 for chain in 0..cfg.n_chains {
678 let mut rng = rand::rngs::StdRng::seed_from_u64(chain_stream_seed(
679 cfg.seed,
680 chain,
681 0xA0B7_6C5D_E431_298F,
682 ));
683 let mut accepted_in_chain = 0usize;
684 let mut attempts_in_chain = 0usize;
685 while accepted_in_chain < cfg.n_samples {
686 if attempts_in_chain >= attempts_cap {
687 let rate = total_accepted as f64 / (total_attempts.max(1) as f64);
688 return Err(format!(
689 "{RATIONALE}: acceptance collapsed — {total_accepted} accepted of \
690 {total_attempts} draws (rate {rate:.3e}); the fit hugs the monotonicity-cone \
691 boundary so its truncated posterior cannot be rejection-sampled within \
692 {attempts_cap} draws per chain. Refit or widen the certified response support."
693 ));
694 }
695 attempts_in_chain += 1;
696 total_attempts += 1;
697 for i in 0..p {
698 eps[i] = sample_standard_normal(&mut rng);
699 }
700 back_substitution_lower_transpose_guarded_into(&l, &eps, &mut delta);
701 for i in 0..p {
702 draw[i] = mode[i] + sqrt_cov_scale * delta[i];
703 }
704 if is_feasible(&draw) {
705 let k = chain * cfg.n_samples + accepted_in_chain;
706 samples.row_mut(k).assign(&draw);
707 accepted_in_chain += 1;
708 total_accepted += 1;
709 }
710 }
711 }
712
713 let posterior_mean = samples
714 .mean_axis(ndarray::Axis(0))
715 .unwrap_or_else(|| Array1::<f64>::zeros(p));
716 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
717 Ok(NutsResult {
720 samples,
721 posterior_mean,
722 posterior_std,
723 rhat: 1.0,
724 ess: n_total as f64,
725 converged: true,
726 })
727}
728
729#[inline]
730fn sample_standard_normal<R: rand::Rng + ?Sized>(rng: &mut R) -> f64 {
731 let u1 = rng.random::<f64>().max(1e-16);
735 let u2 = rng.random::<f64>();
736 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
737}
738
739fn sample_standard(
740 model: &SavedModel,
741 data: ArrayView2<'_, f64>,
742 col_map: &HashMap<String, usize>,
743 training_headers: Option<&Vec<String>>,
744 mut likelihood: LikelihoodSpec,
745 cfg: &NutsConfig,
746) -> Result<NutsResult, String> {
747 let needs_constraint_aware_sampler = model.resolved_termspec.as_ref().is_some_and(|ts| {
763 ts.linear_terms.iter().any(|term| {
764 !matches!(
765 term.coefficient_geometry,
766 LinearCoefficientGeometry::Unconstrained
767 ) || term.coefficient_min.is_some()
768 || term.coefficient_max.is_some()
769 }) || ts
770 .smooth_terms
771 .iter()
772 .any(|term| !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None))
773 });
774 if likelihood.is_gaussian_identity() && !needs_constraint_aware_sampler {
775 return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
776 }
777 if model.has_link_wiggle() {
778 if likelihood.is_gaussian_identity() {
786 return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
787 }
788 return sample_standard_link_wiggle(
789 model,
790 data,
791 col_map,
792 training_headers,
793 likelihood,
794 cfg,
795 );
796 }
797 let parsed = parse_formula(&model.formula)?;
798 let y_col = resolve_role_col(col_map, &parsed.response, "response")?;
799 let y = data.column(y_col).to_owned();
800 let spec = resolve_termspec_for_prediction(
801 &model.resolved_termspec,
802 training_headers,
803 col_map,
804 "resolved_termspec",
805 )?;
806 let design = build_term_collection_design(data, &spec)
807 .map_err(|e| format!("failed to build term collection design: {e}"))?;
808
809 let has_bounded = spec.linear_terms.iter().any(|term| {
838 matches!(
839 term.coefficient_geometry,
840 LinearCoefficientGeometry::Bounded { .. }
841 )
842 });
843 if has_bounded {
844 let bounded_columns: Vec<gam_models::fit_orchestration::drivers::BoundedSampleColumn> =
849 spec.linear_terms
850 .iter()
851 .enumerate()
852 .filter_map(|(j, term)| match term.coefficient_geometry {
853 LinearCoefficientGeometry::Bounded { min, max, .. } => Some(
854 gam_models::fit_orchestration::drivers::BoundedSampleColumn {
855 col_idx: design.intercept_range.end + j,
856 min,
857 max,
858 },
859 ),
860 LinearCoefficientGeometry::Unconstrained => None,
861 })
862 .collect();
863 return sample_standard_bounded(model, cfg, &bounded_columns);
864 }
865
866 if let Some(constraints) = design
876 .linear_constraints
877 .as_ref()
878 .filter(|c| c.a.nrows() > 0)
879 {
880 return sample_standard_truncated(model, cfg, constraints, &design.design);
881 }
882
883 if likelihood.is_gaussian_identity() {
885 return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
886 }
887
888 let weights = saved_prior_weights(model, data, col_map)?;
891 let dense_design_hmc = design
892 .design
893 .try_to_dense_governed("saved standard model HMC design")
894 .map_err(|error| error.to_string())?;
895 let p = dense_design_hmc.ncols();
896 let sampler_design_copy_reservation = MemoryGovernor::global()
900 .try_reserve_dense_f64(
901 dense_design_hmc.nrows(),
902 dense_design_hmc.ncols(),
903 "saved standard model sampler design copy",
904 )
905 .map_err(|error| error.to_string())?;
906 let fit = fit_result_from_saved_model_for_prediction(model)?;
907 refresh_negbin_theta_for_sampling(&mut likelihood, fit.likelihood_scale);
918 if fit.beta.len() != p {
919 return Err(format!(
920 "standard sample: saved model has {} coefficients but rebuilt design has {} columns",
921 fit.beta.len(),
922 p,
923 ));
924 }
925 if fit.lambdas.len() != design.penalties.len() {
926 return Err(format!(
927 "standard sample: saved model has {} lambdas but rebuilt design has {} penalties",
928 fit.lambdas.len(),
929 design.penalties.len(),
930 ));
931 }
932 let penalty =
933 weighted_blockwise_penalty_sum(&design.penalties, fit.lambdas.as_slice().unwrap(), p);
934
935 let saved_offset_vec = saved_offset(model, data, col_map)?;
936 let base_offset =
937 saved_offset_vec.unwrap_or_else(|| Array1::<f64>::zeros(design.design.nrows()));
938 let offset_vec = design
939 .compose_offset(base_offset.view(), "saved standard model sampling")
940 .map_err(|error| error.to_string())?;
941
942 let result = run_nuts_sampling_flattened_family(
943 likelihood,
944 FamilyNutsInputs::Glm(GlmFlatInputs {
945 x: dense_design_hmc.view(),
946 y: y.view(),
947 weights: weights.view(),
948 penalty_matrix: penalty.view(),
949 mode: fit.beta.view(),
950 hessian: explicit_fit_hessian_for_whitening(&fit, p, "saved standard model")?.view(),
951 likelihood_scale: fit.likelihood_scale,
952 dispersion: resolved_fit_dispersion(&fit, "standard saved-model NUTS")?,
956 firth_bias_reduction: fit.artifacts.firth_bias_reduction,
960 offset: Some(offset_vec.view()),
961 }),
962 cfg,
963 )
964 .map_err(|e| format!("NUTS sampling failed: {e}"));
965 drop(sampler_design_copy_reservation);
966 result
967}
968
969fn sample_standard_bounded(
979 model: &SavedModel,
980 cfg: &NutsConfig,
981 bounded_columns: &[gam_models::fit_orchestration::drivers::BoundedSampleColumn],
982) -> Result<NutsResult, String> {
983 validate_nuts_config(cfg).map_err(String::from)?;
984 let fit = fit_result_from_saved_model_for_prediction(model)?;
985 let mode = fit.beta.clone();
986 let p = mode.len();
987 if p == 0 {
988 return Err(
989 "standard bounded-coefficient posterior: cannot sample from an empty coefficient vector"
990 .to_string(),
991 );
992 }
993 let user_hessian =
998 explicit_fit_hessian_for_whitening(&fit, p, "saved standard bounded-coefficient model")?;
999 let sqrt_cov_scale =
1006 sampling_sqrt_covariance_scale(&fit, "standard bounded-coefficient posterior")?;
1007 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
1008 let samples = gam_models::fit_orchestration::drivers::sample_bounded_latent_posterior_internal(
1009 &mode,
1010 user_hessian,
1011 bounded_columns,
1012 n_total,
1013 sqrt_cov_scale,
1014 chain_stream_seed(cfg.seed, 0, 0xB0DD_ED5E_ED90_1A7Cu64),
1015 )
1016 .map_err(|e| format!("standard bounded-coefficient posterior sampling failed: {e}"))?;
1017
1018 let posterior_mean = samples
1019 .mean_axis(ndarray::Axis(0))
1020 .unwrap_or_else(|| Array1::<f64>::zeros(p));
1021 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
1022
1023 Ok(NutsResult {
1024 samples,
1025 posterior_mean,
1026 posterior_std,
1027 rhat: 1.0,
1028 ess: n_total as f64,
1029 converged: true,
1030 })
1031}
1032
1033fn sample_standard_truncated(
1050 model: &SavedModel,
1051 cfg: &NutsConfig,
1052 constraints: &gam_solve::pirls::LinearInequalityConstraints,
1053 design: &gam_linalg::matrix::DesignMatrix,
1054) -> Result<NutsResult, String> {
1055 validate_nuts_config(cfg).map_err(String::from)?;
1056 let fit = fit_result_from_saved_model_for_prediction(model)?;
1057 let mode = fit.beta.clone();
1058 let p = mode.len();
1059 if p == 0 {
1060 return Err(
1061 "standard constrained-coefficient posterior: cannot sample from an empty coefficient \
1062 vector"
1063 .to_string(),
1064 );
1065 }
1066 let penalized_hessian =
1075 explicit_fit_hessian_for_whitening(&fit, p, "saved standard constrained model")?;
1076 let sqrt_cov_scale =
1077 sampling_sqrt_covariance_scale(&fit, "standard constrained-coefficient posterior")?;
1078
1079 let mode_scale = mode.iter().map(|v| v.abs()).fold(1.0_f64, f64::max);
1086 let min_slack = (constraints.a.dot(&mode) - &constraints.b)
1087 .iter()
1088 .cloned()
1089 .fold(f64::INFINITY, f64::min);
1090 let center = if min_slack > 1e-8 * mode_scale {
1091 mode.clone()
1092 } else {
1093 let geometry = fit.geometry.as_ref().ok_or_else(|| {
1094 "standard constrained-coefficient posterior: an inequality constraint is active at \
1095 the mode but the saved model carries no working geometry to recover the \
1096 unconstrained Gaussian center; refit with exact geometry export"
1097 .to_string()
1098 })?;
1099 let working = geometry.working.as_ref().ok_or_else(|| {
1100 "standard constrained-coefficient posterior: an inequality constraint is active at \
1101 the mode but the saved coefficient geometry has no owned single-diagonal working \
1102 evidence; Exact-Newton and multi-parameter fits cannot reconstruct the \
1103 unconstrained Gaussian center from row geometry"
1104 .to_string()
1105 })?;
1106 let n = design.nrows();
1107 if working.weights.len() != n || working.response.len() != n {
1108 return Err(format!(
1109 "standard constrained-coefficient posterior: saved working geometry has {} rows \
1110 but the rebuilt design has {n}",
1111 working.weights.len(),
1112 ));
1113 }
1114 let wz = &working.weights * &working.response;
1115 let rhs = design.transpose_vector_multiply(&wz);
1116 let chol = penalized_hessian.cholesky(Side::Lower).map_err(|e| {
1117 format!(
1118 "standard constrained-coefficient posterior: Cholesky of the penalised Hessian \
1119 failed while recovering the unconstrained center: {e:?}"
1120 )
1121 })?;
1122 gam_linalg::triangular::cholesky_solve_vector(&chol.lower_triangular(), &rhs)
1123 };
1124
1125 let samples = crate::truncated_gaussian::sample_truncated_gaussian_posterior(
1126 ¢er,
1127 &mode,
1128 &penalized_hessian,
1129 sqrt_cov_scale,
1130 constraints,
1131 cfg.n_samples,
1132 cfg.n_chains,
1133 chain_stream_seed(cfg.seed, 0, 0x7290_C047_5D6E_B14Du64),
1134 )?;
1135 let posterior_mean = samples
1136 .mean_axis(ndarray::Axis(0))
1137 .unwrap_or_else(|| Array1::<f64>::zeros(p));
1138 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
1139
1140 let mut chains = ndarray::Array3::<f64>::zeros((cfg.n_chains, cfg.n_samples, p));
1145 for chain in 0..cfg.n_chains {
1146 for draw in 0..cfg.n_samples {
1147 let row = chain * cfg.n_samples + draw;
1148 for j in 0..p {
1149 chains[(chain, draw, j)] = samples[(row, j)];
1150 }
1151 }
1152 }
1153 let (rhat, ess) = super::hmc_io::compute_split_rhat_and_ess(&chains);
1154 let converged = rhat < 1.1 && ess > 100.0;
1155
1156 Ok(NutsResult {
1157 samples,
1158 posterior_mean,
1159 posterior_std,
1160 rhat,
1161 ess,
1162 converged,
1163 })
1164}
1165
1166fn sample_standard_link_wiggle(
1167 model: &SavedModel,
1168 data: ArrayView2<'_, f64>,
1169 col_map: &HashMap<String, usize>,
1170 training_headers: Option<&Vec<String>>,
1171 likelihood: LikelihoodSpec,
1172 cfg: &NutsConfig,
1173) -> Result<NutsResult, String> {
1174 let parsed = parse_formula(&model.formula)?;
1175 let y_col = resolve_role_col(col_map, &parsed.response, "response")?;
1176 let y = data.column(y_col).to_owned();
1177
1178 let spec = resolve_termspec_for_prediction(
1179 &model.resolved_termspec,
1180 training_headers,
1181 col_map,
1182 "resolved_termspec",
1183 )?;
1184 let design = build_term_collection_design(data, &spec)
1185 .map_err(|e| format!("failed to build term collection design: {e}"))?;
1186 let p_main = design.design.ncols();
1187
1188 let fit = fit_result_from_saved_model_for_prediction(model)?;
1189 let wiggle_runtime = model
1190 .saved_prediction_runtime()?
1191 .link_wiggle
1192 .ok_or_else(|| "link-wiggle model is missing wiggle runtime metadata".to_string())?;
1193 let mode_beta = fit
1194 .block_by_role(BlockRole::Mean)
1195 .ok_or_else(|| "standard link-wiggle model is missing Mean coefficient block".to_string())?
1196 .beta
1197 .clone();
1198 let mode_theta = fit
1199 .block_by_role(BlockRole::LinkWiggle)
1200 .ok_or_else(|| {
1201 "standard link-wiggle model is missing LinkWiggle coefficient block".to_string()
1202 })?
1203 .beta
1204 .clone();
1205 let p_wiggle = mode_theta.len();
1206 let p_total = mode_beta.len() + p_wiggle;
1207
1208 if mode_beta.len() != p_main {
1209 return Err(format!(
1210 "link-wiggle sample: saved mean block has {} coefficients but rebuilt design has {} columns",
1211 mode_beta.len(),
1212 p_main,
1213 ));
1214 }
1215 if fit.beta.len() != p_total {
1216 return Err(format!(
1217 "link-wiggle sample: saved beta has {} coefficients but design has {} main + {} wiggle = {} total",
1218 fit.beta.len(),
1219 p_main,
1220 p_wiggle,
1221 p_total,
1222 ));
1223 }
1224
1225 let hessian = &fit
1226 .geometry
1227 .as_ref()
1228 .ok_or_else(|| {
1229 "link-wiggle model is missing explicit joint Hessian geometry; refit with exact Hessian export"
1230 .to_string()
1231 })?
1232 .penalized_hessian;
1233 validate_explicit_link_wiggle_joint_hessian(hessian, p_total)?;
1234
1235 let n_base_penalties = design.penalties.len();
1236 let base_lambdas = fit
1237 .block_by_role(BlockRole::Mean)
1238 .ok_or_else(|| "standard link-wiggle model is missing Mean block lambdas".to_string())?
1239 .lambdas
1240 .view();
1241 if base_lambdas.len() != n_base_penalties {
1242 return Err(format!(
1243 "link-wiggle sample: mean block has {} lambdas but rebuilt design has {} base penalties",
1244 base_lambdas.len(),
1245 n_base_penalties,
1246 ));
1247 }
1248
1249 let penalty_base =
1250 weighted_blockwise_penalty_sum(&design.penalties, base_lambdas.as_slice().unwrap(), p_main);
1251
1252 let wiggle_lambdas_owned = fit
1253 .lambdas_linkwiggle()
1254 .ok_or_else(|| "standard link-wiggle model is missing LinkWiggle lambdas".to_string())?;
1255 let wiggle_lambdas = wiggle_lambdas_owned.view();
1256 let degree = wiggle_runtime.degree;
1257 let knot_arr = Array1::from_vec(wiggle_runtime.knots.clone());
1258
1259 let penalty_link = saved_link_wiggle_penalty_matrix(&wiggle_runtime, wiggle_lambdas, p_wiggle)?;
1260
1261 let weights = saved_prior_weights(model, data, col_map)?;
1266 let saved_offset_vec = saved_offset(model, data, col_map)?;
1267 let base_offset =
1268 saved_offset_vec.unwrap_or_else(|| Array1::<f64>::zeros(design.design.nrows()));
1269 let offset_vec = design
1270 .compose_offset(base_offset.view(), "saved link-wiggle model sampling")
1271 .map_err(|error| error.to_string())?;
1272
1273 let q0 = design.design.dot(&mode_beta) + &offset_vec;
1274 let (q0_min, q0_max) = q0
1275 .iter()
1276 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
1277 (lo.min(v), hi.max(v))
1278 });
1279
1280 let spline = LinkWiggleSplineArtifacts {
1281 knot_range: (q0_min, q0_max),
1282 knot_vector: knot_arr,
1283 degree,
1284 };
1285
1286 let family = match (&likelihood.response, &likelihood.link) {
1291 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {
1292 LinkWiggleFamilyParams::BinomialLogit
1293 }
1294 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {
1295 LinkWiggleFamilyParams::BinomialProbit
1296 }
1297 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {
1298 LinkWiggleFamilyParams::BinomialCLogLog
1299 }
1300 (ResponseFamily::Gaussian, _) => LinkWiggleFamilyParams::Gaussian {
1301 sigma: fit.standard_deviation,
1302 },
1303 (ResponseFamily::Poisson, _) => LinkWiggleFamilyParams::PoissonLog,
1304 (ResponseFamily::Tweedie { p }, _) => LinkWiggleFamilyParams::TweedieLog {
1305 power: *p,
1306 phi: fit.likelihood_scale.fixed_phi().ok_or_else(|| {
1307 "link-wiggle Tweedie sampling requires resolved dispersion metadata".to_string()
1308 })?,
1309 },
1310 (ResponseFamily::NegativeBinomial { .. }, _) => {
1311 LinkWiggleFamilyParams::NegativeBinomialLog {
1312 theta: fit.likelihood_scale.negbin_theta().ok_or_else(|| {
1313 "link-wiggle negative-binomial sampling requires resolved theta metadata"
1314 .to_string()
1315 })?,
1316 }
1317 }
1318 (ResponseFamily::Gamma, _) => LinkWiggleFamilyParams::GammaLog {
1319 shape: fit.likelihood_scale.gamma_shape().ok_or_else(|| {
1320 "link-wiggle Gamma sampling requires resolved shape metadata".to_string()
1321 })?,
1322 },
1323 _ => {
1324 return Err(format!(
1325 "NUTS sampling with link wiggle is not supported for family {}",
1326 likelihood.pretty_name()
1327 ));
1328 }
1329 };
1330
1331 let wiggle_nuts_dense = design
1332 .design
1333 .try_to_dense_governed("saved link-wiggle HMC design")
1334 .map_err(|error| error.to_string())?;
1335 let sampler_design_copy_reservation = MemoryGovernor::global()
1339 .try_reserve_dense_f64(
1340 wiggle_nuts_dense.nrows(),
1341 wiggle_nuts_dense.ncols(),
1342 "saved link-wiggle sampler design copy",
1343 )
1344 .map_err(|error| error.to_string())?;
1345 let result = run_link_wiggle_nuts_sampling(
1346 wiggle_nuts_dense.view(),
1347 y.view(),
1348 weights.view(),
1349 Some(offset_vec.view()),
1350 penalty_base.view(),
1351 penalty_link.view(),
1352 mode_beta.view(),
1353 mode_theta.view(),
1354 hessian.view(),
1355 spline,
1356 family,
1357 cfg,
1358 )
1359 .map_err(|e| format!("link-wiggle NUTS sampling failed: {e}"));
1360 drop(sampler_design_copy_reservation);
1361 result
1362}
1363
1364fn sample_survival(
1365 model: &SavedModel,
1366 data: ArrayView2<'_, f64>,
1367 col_map: &HashMap<String, usize>,
1368 training_headers: Option<&Vec<String>>,
1369 cfg: &NutsConfig,
1370) -> Result<NutsResult, String> {
1371 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1372 if matches!(
1373 saved_likelihood_mode,
1374 SurvivalLikelihoodMode::Latent
1375 | SurvivalLikelihoodMode::LatentBinary
1376 | SurvivalLikelihoodMode::LocationScale
1377 ) {
1378 return laplace_gaussian_fallback(model, cfg, "survival posterior fallback");
1379 }
1380 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1389 let exit_col = time_cols.exit_col;
1390 let eventname = model
1391 .survival_event
1392 .as_ref()
1393 .ok_or_else(|| "survival model missing event column metadata".to_string())?;
1394 let event_col = resolve_role_col(col_map, eventname, "event")?;
1395 let termspec = resolve_termspec_for_prediction(
1396 &model.resolved_termspec,
1397 training_headers,
1398 col_map,
1399 "resolved_termspec",
1400 )?;
1401 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1402 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1403 let cov_design = build_term_collection_design(cov_input, &termspec)
1404 .map_err(|e| format!("failed to build survival design: {e}"))?;
1405 let n = data.nrows();
1406 let p_cov = cov_design.design.ncols();
1407 let mut age_entry = Array1::<f64>::zeros(n);
1408 let mut age_exit = Array1::<f64>::zeros(n);
1409 let mut event_target = Array1::<u8>::zeros(n);
1410 let event_competing = Array1::<u8>::zeros(n);
1411 let weights = Array1::<f64>::ones(n);
1412 for i in 0..n {
1413 let (t0, t1) = normalize_survival_time_pair(
1414 time_cols.row_entry_time(data, i),
1415 data[[i, exit_col]],
1416 i,
1417 )?;
1418 age_entry[i] = t0;
1419 age_exit[i] = t1;
1420 event_target[i] = if data[[i, event_col]] >= 0.5 { 1 } else { 0 };
1421 }
1422 let time_cfg = load_survival_time_basis_config_from_model(model)?;
1423 let time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
1424 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
1425 &time_build.basisname,
1426 time_build.degree,
1427 time_build.knots.as_ref(),
1428 time_build.keep_cols.as_ref(),
1429 time_build.smooth_lambda,
1430 )?;
1431 let time_anchor_row = if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1432 let time_anchor = model
1433 .survival_time_anchor
1434 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1435 Some(evaluate_survival_time_basis_row(
1436 time_anchor,
1437 &resolved_time_cfg,
1438 )?)
1439 } else {
1440 None
1441 };
1442 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
1443 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
1444 build_survival_time_offsets_for_likelihood(
1445 &age_entry,
1446 &age_exit,
1447 &baseline_cfg,
1448 saved_likelihood_mode,
1449 None,
1450 )?;
1451 if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1452 let time_anchor = model
1453 .survival_time_anchor
1454 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1455 add_survival_time_derivative_guard_offset(
1456 &age_entry,
1457 &age_exit,
1458 time_anchor,
1459 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
1460 &mut eta_offset_entry,
1461 &mut eta_offset_exit,
1462 &mut derivative_offset_exit,
1463 )?;
1464 }
1465 eta_offset_entry += &cov_design.affine_offset;
1469 eta_offset_exit += &cov_design.affine_offset;
1470 let saved_timewiggle = saved_baseline_timewiggle_components(
1471 &eta_offset_entry,
1472 &eta_offset_exit,
1473 &derivative_offset_exit,
1474 model,
1475 )?;
1476 let p_time = time_build.x_exit_time.ncols();
1477 let p_timewiggle = saved_timewiggle
1478 .as_ref()
1479 .map(|(_, exit, _)| exit.ncols())
1480 .unwrap_or(0);
1481 let p = p_time
1482 .checked_add(p_timewiggle)
1483 .and_then(|width| width.checked_add(p_cov))
1484 .ok_or_else(|| "saved survival sampler design width overflow".to_string())?;
1485 let survival_design_reservation = MemoryGovernor::global()
1490 .try_reserve_dense_f64_copies(
1491 n,
1492 p,
1493 SURVIVAL_DESIGN_LIVE_COPIES,
1494 "saved survival sampler design live set",
1495 )
1496 .map_err(|error| error.to_string())?;
1497 let mut x_entry = Array2::<f64>::zeros((n, p));
1498 let mut x_exit = Array2::<f64>::zeros((n, p));
1499 let mut x_derivative = Array2::<f64>::zeros((n, p));
1500 let row_chunk_target_bytes = ResourcePolicy::default_library().row_chunk_target_bytes;
1501 if p_time > 0 {
1502 stream_design_into(
1503 &time_build.x_entry_time,
1504 x_entry.slice_mut(s![.., ..p_time]),
1505 row_chunk_target_bytes,
1506 "saved survival entry-time design",
1507 )?;
1508 stream_design_into(
1509 &time_build.x_exit_time,
1510 x_exit.slice_mut(s![.., ..p_time]),
1511 row_chunk_target_bytes,
1512 "saved survival exit-time design",
1513 )?;
1514 stream_design_into(
1515 &time_build.x_derivative_time,
1516 x_derivative.slice_mut(s![.., ..p_time]),
1517 row_chunk_target_bytes,
1518 "saved survival derivative-time design",
1519 )?;
1520 if let Some(anchor_row) = time_anchor_row.as_ref() {
1521 if anchor_row.len() != p_time {
1522 return Err(format!(
1523 "survival time anchoring column mismatch: design={p_time}, anchor={}",
1524 anchor_row.len(),
1525 ));
1526 }
1527 for mut row in x_entry.slice_mut(s![.., ..p_time]).rows_mut() {
1528 row -= &anchor_row.view();
1529 }
1530 for mut row in x_exit.slice_mut(s![.., ..p_time]).rows_mut() {
1531 row -= &anchor_row.view();
1532 }
1533 }
1534 }
1535 if let Some((entry_w, exit_w, deriv_w)) = saved_timewiggle.as_ref()
1536 && p_timewiggle > 0
1537 {
1538 x_entry
1539 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1540 .assign(entry_w);
1541 x_exit
1542 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1543 .assign(exit_w);
1544 x_derivative
1545 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1546 .assign(deriv_w);
1547 }
1548 if p_cov > 0 {
1549 let cov_range = (p_time + p_timewiggle)..(p_time + p_timewiggle + p_cov);
1550 stream_design_into(
1551 &cov_design.design,
1552 x_entry.slice_mut(s![.., cov_range.clone()]),
1553 row_chunk_target_bytes,
1554 "saved survival covariate design",
1555 )?;
1556 x_exit
1557 .slice_mut(s![.., cov_range.clone()])
1558 .assign(&x_entry.slice(s![.., cov_range]));
1559 }
1560 drop(cov_design);
1563 let mut penalty_blocks: Vec<PenaltyBlock> = Vec::new();
1564 for (idx, s) in time_build.penalties.iter().enumerate() {
1565 if s.nrows() == p_time && s.ncols() == p_time {
1566 penalty_blocks.push(PenaltyBlock {
1567 matrix: s.clone(),
1568 lambda: time_build
1569 .smooth_lambda
1570 .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1571 range: 0..p_time,
1572 nullspace_dim: time_build.nullspace_dims.get(idx).copied().unwrap_or(0),
1573 });
1574 }
1575 }
1576 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
1577 if let Some((_, exit_w, _)) = saved_timewiggle.as_ref() {
1578 let start = p_time;
1579 let end = start + exit_w.ncols();
1580 let wiggle_lambda_offset = penalty_blocks.len();
1581 let wiggle_cfg = saved_baseline_timewiggle_spec(model)?.ok_or_else(|| {
1582 "saved baseline-timewiggle model missing baseline-timewiggle metadata".to_string()
1583 })?;
1584 let wiggle_degree = wiggle_cfg.degree;
1585 let wiggle_knots =
1586 Array1::from_vec(model.baseline_timewiggle_knots.clone().ok_or_else(|| {
1587 "saved baseline-timewiggle model missing baseline_timewiggle_knots".to_string()
1588 })?);
1589 let mut seed = Array1::<f64>::zeros(2 * n);
1590 for i in 0..n {
1591 seed[i] = eta_offset_entry[i];
1592 seed[n + i] = eta_offset_exit[i];
1593 }
1594 let (primary_order, extra_orders) =
1595 split_wiggle_penalty_orders(2, &wiggle_cfg.penalty_orders)?;
1596 let mut block = buildwiggle_block_input_from_knots(
1597 seed.view(),
1598 &wiggle_knots,
1599 wiggle_degree,
1600 primary_order,
1601 wiggle_cfg.double_penalty,
1602 )?;
1603 append_selected_wiggle_function_penalties(
1604 &mut block,
1605 &wiggle_knots,
1606 wiggle_degree,
1607 &extra_orders,
1608 )
1609 .map_err(|e| format!("baseline-timewiggle penalty reconstruction failed: {e}"))?;
1610 for (widx, s) in block.penalties.iter().enumerate() {
1611 let s = match s {
1612 gam_solve::estimate::PenaltySpec::Block { local, .. } => local,
1613 gam_solve::estimate::PenaltySpec::Dense(m)
1614 | gam_solve::estimate::PenaltySpec::DenseWithMean { matrix: m, .. } => m,
1615 };
1616 if s.nrows() == exit_w.ncols() && s.ncols() == exit_w.ncols() {
1617 penalty_blocks.push(PenaltyBlock {
1618 matrix: s.clone(),
1619 lambda: time_build
1620 .smooth_lambda
1621 .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1622 range: start..end,
1623 nullspace_dim: block.nullspace_dims.get(widx).copied().unwrap_or(0),
1624 });
1625 }
1626 }
1627 for (local_idx, block_penalty) in penalty_blocks[wiggle_lambda_offset..]
1628 .iter_mut()
1629 .enumerate()
1630 {
1631 if let Some(&lam) = fit_saved.lambdas.get(wiggle_lambda_offset + local_idx) {
1632 block_penalty.lambda = lam;
1633 }
1634 }
1635 }
1636 drop(saved_timewiggle);
1639 let ridge_lambda = model.survivalridge_lambda.ok_or_else(|| {
1640 "saved survival model is missing survivalridge_lambda; refusing to \
1641 pick a load-time default (the historical 1e-4 fallback silently \
1642 disagreed with the 1e-6 fit-time default). Refit."
1643 .to_string()
1644 })?;
1645 let ridge_range_start = if time_build.basisname == "linear" && !model.has_baseline_time_wiggle()
1646 {
1647 1
1648 } else {
1649 0
1650 };
1651 drop(time_build);
1654 if ridge_lambda > 0.0 && p > ridge_range_start {
1655 let dim = p - ridge_range_start;
1656 let mut ridge = Array2::<f64>::zeros((dim, dim));
1657 for d in 0..dim {
1658 ridge[[d, d]] = 1.0;
1659 }
1660 penalty_blocks.push(PenaltyBlock {
1661 matrix: ridge,
1662 lambda: ridge_lambda,
1663 range: ridge_range_start..p,
1664 nullspace_dim: 0,
1665 });
1666 }
1667 for (idx, block) in penalty_blocks.iter_mut().enumerate() {
1668 if let Some(&lam) = fit_saved.lambdas.get(idx) {
1669 block.lambda = lam;
1670 }
1671 }
1672 let penalties = PenaltyBlocks::new(penalty_blocks);
1673 let survivalspec = match model
1674 .survivalspec
1675 .as_deref()
1676 .unwrap_or("net")
1677 .to_ascii_lowercase()
1678 .as_str()
1679 {
1680 "net" => SurvivalSpec::Net,
1681 "crude" => {
1682 return Err("saved survival spec 'crude' is not supported by the one-hazard survival engine; refit or export a net survival model for this path"
1683 .to_string());
1684 }
1685 other => {
1686 return Err(format!("unsupported saved survival spec '{other}'"));
1687 }
1688 };
1689 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 0.0 };
1690 let mut model_surv = royston_parmar::working_model_from_flattened(
1691 penalties.clone(),
1692 monotonicity,
1693 survivalspec,
1694 RoystonParmarInputs {
1695 age_entry: age_entry.view(),
1696 age_exit: age_exit.view(),
1697 event_target: event_target.view(),
1698 event_competing: event_competing.view(),
1699 weights: weights.view(),
1700 x_entry: x_entry.view(),
1701 x_exit: x_exit.view(),
1702 x_derivative: x_derivative.view(),
1703 monotonicity_constraint_rows: None,
1704 monotonicity_constraint_offsets: None,
1705 eta_offset_entry: Some(eta_offset_entry.view()),
1706 eta_offset_exit: Some(eta_offset_exit.view()),
1707 derivative_offset_exit: Some(derivative_offset_exit.view()),
1708 },
1709 )
1710 .map_err(|e| format!("failed to construct survival model: {e}"))?;
1711 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull {
1712 model_surv
1713 .set_structural_monotonicity(true, p_time + p_timewiggle)
1714 .map_err(|e| format!("failed to enable structural monotonicity: {e}"))?;
1715 }
1716 let beta0 = fit_saved.beta.clone();
1717 let survival_hessian_reservation = MemoryGovernor::global()
1718 .try_reserve_dense_f64(p, p, "saved survival sampler Hessian")
1719 .map_err(|error| error.to_string())?;
1720 let hessian = {
1721 let state = model_surv
1722 .update_state(&beta0)
1723 .map_err(|e| format!("failed to evaluate survival state: {e}"))?;
1724 match state.hessian {
1725 gam_linalg::matrix::SymmetricMatrix::Dense(hessian) => hessian,
1728 gam_linalg::matrix::SymmetricMatrix::Sparse(hessian) => {
1731 gam_linalg::matrix::SymmetricMatrix::Sparse(hessian).to_dense()
1732 }
1733 }
1734 };
1735 drop(model_surv);
1738 let result = run_survival_nuts_sampling_flattened(
1739 SurvivalFlatInputs {
1740 age_entry: age_entry.view(),
1741 age_exit: age_exit.view(),
1742 event_target: event_target.view(),
1743 event_competing: event_competing.view(),
1744 weights: weights.view(),
1745 x_entry: x_entry.view(),
1746 x_exit: x_exit.view(),
1747 x_derivative: x_derivative.view(),
1748 eta_offset_entry: Some(eta_offset_entry.view()),
1749 eta_offset_exit: Some(eta_offset_exit.view()),
1750 derivative_offset_exit: Some(derivative_offset_exit.view()),
1751 },
1752 penalties,
1753 monotonicity,
1754 survivalspec,
1755 saved_likelihood_mode != SurvivalLikelihoodMode::Weibull,
1756 p_time + p_timewiggle,
1757 beta0.view(),
1758 hessian.view(),
1759 cfg,
1760 )
1761 .map_err(|e| format!("survival NUTS sampling failed: {e}"));
1762 drop(survival_hessian_reservation);
1763 drop(survival_design_reservation);
1764 result
1765}
1766
1767#[cfg(test)]
1768mod tests {
1769 use super::*;
1770 use gam_linalg::matrix::{DenseDesignMatrix, DenseDesignOperator, LinearOperator};
1771 use gam_models::wiggle::WigglePenaltyBlockKind;
1772 use gam_problem::types::LikelihoodScaleMetadata;
1773
1774 #[test]
1780 fn standard_link_wiggle_sampling_penalty_matches_fit_value_gradient_hessian_2306() {
1781 let a = -1.3_f64;
1782 let b = 2.6_f64;
1783 let width = b - a;
1784 let mut knot_values = vec![a; 4];
1785 knot_values.extend([
1786 a + 0.03 * width,
1787 a + 0.21 * width,
1788 a + 0.22 * width,
1789 a + 0.68 * width,
1790 a + 0.94 * width,
1791 ]);
1792 knot_values.extend(vec![b; 4]);
1793 let knots = Array1::from_vec(knot_values);
1794 let derivative_orders = [1usize, 2, 3];
1795 let fit_penalties =
1796 canonical_wiggle_function_penalties(&knots, 3, &derivative_orders, true)
1797 .expect("fit-time canonical penalties");
1798 assert_eq!(
1799 fit_penalties.metadata.blocks,
1800 vec![
1801 WigglePenaltyBlockKind::Roughness {
1802 derivative_order: 1,
1803 },
1804 WigglePenaltyBlockKind::Roughness {
1805 derivative_order: 2,
1806 },
1807 WigglePenaltyBlockKind::Roughness {
1808 derivative_order: 3,
1809 },
1810 ],
1811 "order-one primary roughness has no null space, so double penalty emits no fake ridge",
1812 );
1813 let p = fit_penalties.matrices[0].nrows();
1814 let runtime = SavedLinkWiggleRuntime {
1815 knots: knots.to_vec(),
1816 degree: 3,
1817 penalty_metadata: Some(fit_penalties.metadata.clone()),
1818 beta: vec![0.0; p],
1819 index_shift: None,
1820 };
1821 let lambdas = Array1::from_vec(vec![0.7, 1.3, 2.1]);
1822 let sampled_hessian = saved_link_wiggle_penalty_matrix(&runtime, lambdas.view(), p)
1823 .expect("sampling rebuilds the fit topology");
1824 let mut fitted_hessian = Array2::<f64>::zeros((p, p));
1825 for (matrix, &lambda) in fit_penalties.matrices.iter().zip(lambdas.iter()) {
1826 fitted_hessian.scaled_add(lambda, matrix);
1827 }
1828
1829 let theta = Array1::from_shape_fn(p, |index| 0.15 + 0.07 * index as f64);
1830 let fitted_gradient = fitted_hessian.dot(&theta);
1831 let sampled_gradient = sampled_hessian.dot(&theta);
1832 let fitted_value = 0.5 * theta.dot(&fitted_gradient);
1833 let sampled_value = 0.5 * theta.dot(&sampled_gradient);
1834 let max_hessian_error = sampled_hessian
1835 .iter()
1836 .zip(fitted_hessian.iter())
1837 .map(|(sampled, fitted)| (sampled - fitted).abs())
1838 .fold(0.0_f64, f64::max);
1839 let max_gradient_error = sampled_gradient
1840 .iter()
1841 .zip(fitted_gradient.iter())
1842 .map(|(sampled, fitted)| (sampled - fitted).abs())
1843 .fold(0.0_f64, f64::max);
1844 assert!(
1845 max_hessian_error < 1.0e-12,
1846 "penalty Hessian drift: {max_hessian_error}"
1847 );
1848 assert!(
1849 max_gradient_error < 1.0e-12,
1850 "penalty gradient drift: {max_gradient_error}"
1851 );
1852 assert!(
1853 (sampled_value - fitted_value).abs() < 1.0e-12,
1854 "penalty target drift"
1855 );
1856
1857 let mismatch_lambdas = Array1::from_vec(vec![0.7, 1.3]);
1858 let mismatch = saved_link_wiggle_penalty_matrix(&runtime, mismatch_lambdas.view(), p)
1859 .expect_err("lambda count mismatch must be rejected, never padded");
1860 assert!(mismatch.contains("3 blocks but fit stores 2 lambdas"));
1861 }
1862
1863 struct ChunkOnlySampleDesign {
1864 values: Array2<f64>,
1865 row_chunk_calls: std::sync::atomic::AtomicUsize,
1866 fail_rows: bool,
1867 }
1868
1869 impl LinearOperator for ChunkOnlySampleDesign {
1870 fn nrows(&self) -> usize {
1871 self.values.nrows()
1872 }
1873
1874 fn ncols(&self) -> usize {
1875 self.values.ncols()
1876 }
1877
1878 fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
1879 self.values.dot(vector)
1880 }
1881
1882 fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
1883 self.values.t().dot(vector)
1884 }
1885
1886 fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
1887 if weights.len() != self.nrows() {
1888 return Err(format!(
1889 "weight vector has {} entries for {} design rows",
1890 weights.len(),
1891 self.nrows()
1892 ));
1893 }
1894 Ok(Array2::zeros((self.ncols(), self.ncols())))
1895 }
1896 }
1897
1898 impl DenseDesignOperator for ChunkOnlySampleDesign {
1899 fn row_chunk_into(
1900 &self,
1901 rows: std::ops::Range<usize>,
1902 mut out: ndarray::ArrayViewMut2<'_, f64>,
1903 ) -> Result<(), gam_runtime::resource::MatrixMaterializationError> {
1904 self.row_chunk_calls
1905 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1906 if self.fail_rows {
1907 return Err(
1908 gam_runtime::resource::MatrixMaterializationError::MissingRowChunk {
1909 context: "ChunkOnlySampleDesign test refusal",
1910 },
1911 );
1912 }
1913 out.assign(&self.values.slice(s![rows, ..]));
1914 Ok(())
1915 }
1916
1917 fn to_dense(&self) -> Array2<f64> {
1918 panic!("stream_design_into must never call to_dense")
1919 }
1920 }
1921
1922 #[test]
1923 fn survival_design_streaming_uses_row_chunks_and_target_slice() {
1924 let values = Array2::from_shape_fn((5, 3), |(i, j)| (10 * i + j) as f64);
1925 let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1926 values: values.clone(),
1927 row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1928 fail_rows: false,
1929 });
1930 let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(
1931 std::sync::Arc::clone(&operator),
1932 ));
1933 let mut assembled = Array2::<f64>::from_elem((5, 5), -1.0);
1934
1935 stream_design_into(
1936 &design,
1937 assembled.slice_mut(s![.., 1..4]),
1938 2 * 3 * std::mem::size_of::<f64>(),
1939 "streaming regression",
1940 )
1941 .expect("row-chunk assembly succeeds");
1942
1943 assert_eq!(assembled.slice(s![.., 1..4]), values.view());
1944 assert!(assembled.column(0).iter().all(|&value| value == -1.0));
1945 assert!(assembled.column(4).iter().all(|&value| value == -1.0));
1946 assert_eq!(
1947 operator
1948 .row_chunk_calls
1949 .load(std::sync::atomic::Ordering::SeqCst),
1950 3,
1951 );
1952 }
1953
1954 #[test]
1955 fn survival_design_streaming_propagates_typed_row_refusal() {
1956 let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1957 values: Array2::zeros((2, 2)),
1958 row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1959 fail_rows: true,
1960 });
1961 let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(operator));
1962 let mut assembled = Array2::<f64>::zeros((2, 2));
1963
1964 let error = stream_design_into(
1965 &design,
1966 assembled.view_mut(),
1967 std::mem::size_of::<f64>(),
1968 "streaming refusal regression",
1969 )
1970 .expect_err("row-chunk refusal must remain fallible");
1971
1972 assert!(error.contains("streaming refusal regression"));
1973 assert!(error.contains("ChunkOnlySampleDesign test refusal"));
1974 }
1975
1976 #[test]
1987 fn refresh_negbin_theta_reads_theta_hat_not_seed() {
1988 let mut likelihood = LikelihoodSpec::negative_binomial_log(1.0);
1991 let scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.97 };
1992
1993 refresh_negbin_theta_for_sampling(&mut likelihood, scale);
1994
1995 match likelihood.response {
1996 ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
1997 theta, 2.97,
1998 "NB NUTS must sample at theta_hat (#1463), not the seed theta=1.0"
1999 ),
2000 other => panic!("expected NegativeBinomial response, got {other:?}"),
2001 }
2002 }
2003
2004 #[test]
2008 fn refresh_negbin_theta_fixed_theta_is_preserved() {
2009 let mut likelihood = LikelihoodSpec::negative_binomial_log_fixed(4.25);
2010 let scale = LikelihoodScaleMetadata::FixedNegBinTheta { theta: 4.25 };
2011
2012 refresh_negbin_theta_for_sampling(&mut likelihood, scale);
2013
2014 match likelihood.response {
2015 ResponseFamily::NegativeBinomial { theta, theta_fixed } => {
2016 assert_eq!(theta, 4.25, "fixed NB theta must survive the refresh");
2017 assert!(theta_fixed, "theta_fixed flag must be preserved");
2018 }
2019 other => panic!("expected NegativeBinomial response, got {other:?}"),
2020 }
2021 }
2022
2023 #[test]
2027 fn refresh_negbin_theta_falls_back_to_seed_when_unfitted() {
2028 let mut likelihood = LikelihoodSpec::negative_binomial_log(3.5);
2029 refresh_negbin_theta_for_sampling(
2031 &mut likelihood,
2032 LikelihoodScaleMetadata::ProfiledGaussian,
2033 );
2034
2035 match likelihood.response {
2036 ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
2037 theta, 3.5,
2038 "with no fitted theta the NB seed must be kept verbatim"
2039 ),
2040 other => panic!("expected NegativeBinomial response, got {other:?}"),
2041 }
2042 }
2043
2044 #[test]
2048 fn refresh_negbin_theta_leaves_non_nb_families_untouched() {
2049 let mut poisson = LikelihoodSpec::poisson_log();
2050 let before = poisson.response.clone();
2051 refresh_negbin_theta_for_sampling(
2052 &mut poisson,
2053 LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 9.0 },
2054 );
2055 assert_eq!(
2056 poisson.response, before,
2057 "Poisson response must be untouched by the NB theta refresh"
2058 );
2059 }
2060}