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, load_survival_time_basis_config_from_model,
26};
27use gam_linalg::faer_ndarray::FaerCholesky;
28use gam_linalg::triangular::back_substitution_lower_transpose_guarded_into;
29use gam_models::survival::construction::{
30 SurvivalLikelihoodMode, add_survival_time_derivative_guard_offset, build_survival_time_basis,
31 build_survival_time_offsets_for_likelihood, evaluate_survival_time_basis_row,
32 normalize_survival_time_pair, resolved_survival_time_basis_config_from_build,
33 survival_derivative_guard_for_likelihood,
34};
35use gam_models::survival::predict::{
36 fit_result_from_saved_model_for_prediction, require_saved_survival_likelihood_mode,
37 resolve_saved_survival_time_columns, resolve_termspec_for_prediction,
38 saved_baseline_timewiggle_components, saved_survival_runtime_baseline_config,
39};
40use gam_models::survival::royston_parmar::{self, RoystonParmarInputs};
41use gam_models::survival::{
42 PenaltyBlock, PenaltyBlocks, SurvivalMonotonicityPenalty, SurvivalSpec,
43};
44use gam_models::wiggle::{
45 append_selected_wiggle_penalty_orders, buildwiggle_block_input_from_knots,
46 split_wiggle_penalty_orders,
47};
48use gam_problem::types::{InverseLink, LikelihoodSpec, ResponseFamily, StandardLink};
49use gam_runtime::resource::{MemoryGovernor, ResourcePolicy, rows_for_target_bytes};
50use gam_solve::estimate::{BlockRole, validate_all_finite};
51use gam_terms::basis::create_difference_penalty_matrix;
52use gam_terms::smooth::build_term_collection_design;
53use gam_terms::smooth::{LinearCoefficientGeometry, weighted_blockwise_penalty_sum};
54use gam_terms::term_builder::resolve_role_col;
55
56const SURVIVAL_DESIGN_LIVE_COPIES: usize = 2 * 3;
59
60fn stream_design_into(
65 design: &gam_linalg::matrix::DesignMatrix,
66 mut out: ndarray::ArrayViewMut2<'_, f64>,
67 row_chunk_target_bytes: usize,
68 context: &str,
69) -> Result<(), String> {
70 if out.dim() != (design.nrows(), design.ncols()) {
71 return Err(format!(
72 "{context}: output shape {}x{} does not match design {}x{}",
73 out.nrows(),
74 out.ncols(),
75 design.nrows(),
76 design.ncols(),
77 ));
78 }
79 let chunk_rows = rows_for_target_bytes(row_chunk_target_bytes, design.ncols())
80 .max(1)
81 .min(design.nrows().max(1));
82 for start in (0..design.nrows()).step_by(chunk_rows) {
83 let end = (start + chunk_rows).min(design.nrows());
84 design
85 .row_chunk_into(start..end, out.slice_mut(s![start..end, ..]))
86 .map_err(|error| format!("{context}: {error}"))?;
87 }
88 Ok(())
89}
90
91pub fn saved_baseline_timewiggle_spec(
96 model: &SavedModel,
97) -> Result<Option<LinkWiggleFormulaSpec>, String> {
98 model
99 .saved_baseline_time_wiggle()
100 .map_err(|e| e.to_string())
101 .map(|runtime| {
102 runtime.map(|saved| LinkWiggleFormulaSpec {
103 degree: saved.degree,
104 num_internal_knots: saved.knots.len().saturating_sub(2 * (saved.degree + 1)),
105 penalty_orders: saved.penalty_orders,
106 double_penalty: saved.double_penalty,
107 })
108 })
109}
110
111fn weighted_penalty_matrix(
112 penalties: &[Array2<f64>],
113 lambdas: ArrayView1<'_, f64>,
114) -> Result<Array2<f64>, String> {
115 if penalties.len() != lambdas.len() {
116 return Err(format!(
117 "penalty/lambda mismatch: {} penalties vs {} lambdas",
118 penalties.len(),
119 lambdas.len()
120 ));
121 }
122 if penalties.is_empty() {
123 return Err("cannot sample without at least one penalty block".to_string());
124 }
125 let p = penalties[0].nrows();
126 let mut out = Array2::<f64>::zeros((p, p));
127 for (k, s) in penalties.iter().enumerate() {
128 if s.nrows() != p || s.ncols() != p {
129 return Err(format!(
130 "penalty block {k} shape mismatch: got {}x{}, expected {}x{}",
131 s.nrows(),
132 s.ncols(),
133 p,
134 p
135 ));
136 }
137 let lam = lambdas[k];
138 out += &(s * lam);
139 }
140 Ok(out)
141}
142
143fn validate_explicit_link_wiggle_joint_hessian(
144 hessian: &Array2<f64>,
145 expected_dim: usize,
146) -> Result<(), String> {
147 if hessian.nrows() != expected_dim || hessian.ncols() != expected_dim {
148 return Err(format!(
149 "link-wiggle sample: explicit joint Hessian is {}x{} but expected {}x{}",
150 hessian.nrows(),
151 hessian.ncols(),
152 expected_dim,
153 expected_dim,
154 ));
155 }
156 validate_all_finite(
157 "link-wiggle explicit joint Hessian",
158 hessian.iter().copied(),
159 )?;
160 let mut max_abs = 0.0_f64;
161 for r in 0..expected_dim {
162 for c in 0..expected_dim {
163 max_abs = max_abs.max(hessian[[r, c]].abs());
164 let scale = hessian[[r, c]].abs().max(hessian[[c, r]].abs()).max(1.0);
165 if (hessian[[r, c]] - hessian[[c, r]]).abs() > 1e-9 * scale {
166 return Err(format!(
167 "link-wiggle sample: explicit joint Hessian is not symmetric at ({r},{c})"
168 ));
169 }
170 }
171 }
172 if max_abs == 0.0 {
173 return Err("link-wiggle sample: explicit joint Hessian is all zeros; refit with exact Hessian export"
174 .to_string());
175 }
176 Ok(())
177}
178
179fn saved_prior_weights(
186 model: &SavedModel,
187 data: ArrayView2<'_, f64>,
188 col_map: &HashMap<String, usize>,
189) -> Result<Array1<f64>, String> {
190 match model.weight_column.as_deref() {
191 Some(name) => {
192 let idx = resolve_role_col(col_map, name, "weights")?;
193 let w = data.column(idx).to_owned();
194 if !w.iter().all(|v| v.is_finite() && *v >= 0.0) {
195 return Err(format!(
196 "sample: prior-weights column '{name}' contains negative or non-finite values"
197 ));
198 }
199 Ok(w)
200 }
201 None => Ok(Array1::ones(data.nrows())),
202 }
203}
204
205fn saved_offset(
211 model: &SavedModel,
212 data: ArrayView2<'_, f64>,
213 col_map: &HashMap<String, usize>,
214) -> Result<Option<Array1<f64>>, String> {
215 match model.offset_column.as_deref() {
216 Some(name) => {
217 let idx = resolve_role_col(col_map, name, "offset")?;
218 Ok(Some(data.column(idx).to_owned()))
219 }
220 None => Ok(None),
221 }
222}
223
224fn refresh_negbin_theta_for_sampling(
242 likelihood: &mut LikelihoodSpec,
243 scale: gam_problem::types::LikelihoodScaleMetadata,
244) {
245 if let ResponseFamily::NegativeBinomial { theta, .. } = &mut likelihood.response {
246 if let Some(theta_hat) = scale.negbin_theta() {
247 *theta = theta_hat;
248 }
249 }
250}
251
252fn likelihood_spec_for_saved_model(model: &SavedModel) -> Result<LikelihoodSpec, String> {
256 Ok(model.likelihood())
257}
258
259const DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA: f64 = 1e-2;
264
265#[inline]
266const fn splitmix64(x: u64) -> u64 {
267 gam_linalg::utils::splitmix64_hash(x)
268}
269
270#[inline]
271const fn chain_stream_seed(seed: u64, chain: usize, stream: u64) -> u64 {
272 splitmix64(seed ^ stream ^ ((chain as u64).wrapping_mul(0xD1B5_4A32_D192_ED03)))
273}
274
275pub fn sample_saved_model(
294 model: &SavedModel,
295 data: ArrayView2<'_, f64>,
296 col_map: &HashMap<String, usize>,
297 training_headers: Option<&Vec<String>>,
298 cfg: &NutsConfig,
299) -> Result<NutsResult, String> {
300 validate_nuts_config(cfg).map_err(String::from)?;
309 let likelihood = likelihood_spec_for_saved_model(model)?;
310 match model.predict_model_class() {
311 PredictModelClass::Survival => {
312 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
318 if matches!(
319 saved_likelihood_mode,
320 SurvivalLikelihoodMode::Latent
321 | SurvivalLikelihoodMode::LatentBinary
322 | SurvivalLikelihoodMode::LocationScale
323 ) {
324 laplace_gaussian_fallback(model, cfg, "survival posterior fallback")
325 } else {
326 sample_survival(model, data, col_map, training_headers, cfg)
327 }
328 }
329 PredictModelClass::Standard => {
330 if matches!(likelihood.response, ResponseFamily::Beta { .. }) {
339 laplace_gaussian_fallback(model, cfg, "beta-regression posterior fallback")
340 } else {
341 sample_standard(model, data, col_map, training_headers, likelihood, cfg)
342 }
343 }
344 PredictModelClass::GaussianLocationScale => {
354 laplace_gaussian_fallback(model, cfg, "gaussian location-scale posterior")
355 }
356 PredictModelClass::BinomialLocationScale => {
357 laplace_gaussian_fallback(model, cfg, "binomial location-scale posterior")
358 }
359 PredictModelClass::DispersionLocationScale => {
360 laplace_gaussian_fallback(model, cfg, "dispersion location-scale posterior")
361 }
362 PredictModelClass::BernoulliMarginalSlope => {
363 laplace_gaussian_fallback(model, cfg, "bernoulli marginal-slope posterior")
364 }
365 PredictModelClass::TransformationNormal => {
366 laplace_gaussian_fallback(model, cfg, "transformation-normal posterior")
367 }
368 }
369}
370
371pub fn laplace_gaussian_fallback(
385 model: &SavedModel,
386 cfg: &NutsConfig,
387 rationale: &'static str,
388) -> Result<NutsResult, String> {
389 validate_nuts_config(cfg).map_err(String::from)?;
394 let fit = fit_result_from_saved_model_for_prediction(model)?;
395 let mode = fit.beta.clone();
396 let p = mode.len();
397 if p == 0 {
398 return Err(format!(
399 "{rationale}: cannot sample from an empty coefficient vector"
400 ));
401 }
402 let h = fit.penalized_hessian().ok_or_else(|| {
403 format!(
404 "{rationale}: posterior fallback requires the explicit penalised Hessian; \
405 refit with exact geometry export to enable posterior sampling for this class."
406 )
407 })?;
408 let sqrt_cov_scale = fit.coefficient_covariance_scale().max(0.0).sqrt();
425 if h.nrows() != p || h.ncols() != p {
426 return Err(format!(
427 "{rationale}: penalised Hessian is {}x{}, expected {}x{}",
428 h.nrows(),
429 h.ncols(),
430 p,
431 p
432 ));
433 }
434 let chol = h.cholesky(Side::Lower).map_err(|err| {
435 format!("{rationale}: Cholesky factorisation of the penalised Hessian failed: {err:?}")
436 })?;
437 let l = chol.lower_triangular();
438
439 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
443 let mut samples = Array2::<f64>::zeros((n_total, p));
444 let mut eps = Array1::<f64>::zeros(p);
445 let mut delta = Array1::<f64>::zeros(p);
446 for chain in 0..cfg.n_chains {
447 let mut rng = rand::rngs::StdRng::seed_from_u64(chain_stream_seed(
448 cfg.seed,
449 chain,
450 0xA0B7_6C5D_E431_298F,
451 ));
452 for draw in 0..cfg.n_samples {
453 let k = chain * cfg.n_samples + draw;
454 for i in 0..p {
455 eps[i] = sample_standard_normal(&mut rng);
456 }
457 back_substitution_lower_transpose_guarded_into(&l, &eps, &mut delta);
458 for i in 0..p {
459 samples[(k, i)] = mode[i] + sqrt_cov_scale * delta[i];
464 }
465 }
466 }
467
468 let posterior_mean = samples
469 .mean_axis(ndarray::Axis(0))
470 .unwrap_or_else(|| Array1::<f64>::zeros(p));
471 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
472
473 Ok(NutsResult {
474 samples,
475 posterior_mean,
476 posterior_std,
477 rhat: 1.0,
478 ess: n_total as f64,
479 converged: true,
480 })
481}
482
483#[inline]
484fn sample_standard_normal<R: rand::Rng + ?Sized>(rng: &mut R) -> f64 {
485 let u1 = rng.random::<f64>().max(1e-16);
489 let u2 = rng.random::<f64>();
490 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
491}
492
493fn sample_standard(
494 model: &SavedModel,
495 data: ArrayView2<'_, f64>,
496 col_map: &HashMap<String, usize>,
497 training_headers: Option<&Vec<String>>,
498 mut likelihood: LikelihoodSpec,
499 cfg: &NutsConfig,
500) -> Result<NutsResult, String> {
501 let needs_constraint_aware_sampler = model.resolved_termspec.as_ref().is_some_and(|ts| {
517 ts.linear_terms.iter().any(|term| {
518 !matches!(
519 term.coefficient_geometry,
520 LinearCoefficientGeometry::Unconstrained
521 ) || term.coefficient_min.is_some()
522 || term.coefficient_max.is_some()
523 }) || ts
524 .smooth_terms
525 .iter()
526 .any(|term| !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None))
527 });
528 if likelihood.is_gaussian_identity() && !needs_constraint_aware_sampler {
529 return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
530 }
531 if model.has_link_wiggle() {
532 if likelihood.is_gaussian_identity() {
540 return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
541 }
542 return sample_standard_link_wiggle(
543 model,
544 data,
545 col_map,
546 training_headers,
547 likelihood,
548 cfg,
549 );
550 }
551 let parsed = parse_formula(&model.formula)?;
552 let y_col = resolve_role_col(col_map, &parsed.response, "response")?;
553 let y = data.column(y_col).to_owned();
554 let spec = resolve_termspec_for_prediction(
555 &model.resolved_termspec,
556 training_headers,
557 col_map,
558 "resolved_termspec",
559 )?;
560 let design = build_term_collection_design(data, &spec)
561 .map_err(|e| format!("failed to build term collection design: {e}"))?;
562
563 let has_bounded = spec.linear_terms.iter().any(|term| {
592 matches!(
593 term.coefficient_geometry,
594 LinearCoefficientGeometry::Bounded { .. }
595 )
596 });
597 if has_bounded {
598 let bounded_columns: Vec<gam_models::fit_orchestration::drivers::BoundedSampleColumn> =
603 spec.linear_terms
604 .iter()
605 .enumerate()
606 .filter_map(|(j, term)| match term.coefficient_geometry {
607 LinearCoefficientGeometry::Bounded { min, max, .. } => Some(
608 gam_models::fit_orchestration::drivers::BoundedSampleColumn {
609 col_idx: design.intercept_range.end + j,
610 min,
611 max,
612 },
613 ),
614 LinearCoefficientGeometry::Unconstrained => None,
615 })
616 .collect();
617 return sample_standard_bounded(model, cfg, &bounded_columns);
618 }
619
620 if let Some(constraints) = design
630 .linear_constraints
631 .as_ref()
632 .filter(|c| c.a.nrows() > 0)
633 {
634 return sample_standard_truncated(model, cfg, constraints, &design.design);
635 }
636
637 if likelihood.is_gaussian_identity() {
639 return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
640 }
641
642 let weights = saved_prior_weights(model, data, col_map)?;
645 let dense_design_hmc = design
646 .design
647 .try_to_dense_governed("saved standard model HMC design")
648 .map_err(|error| error.to_string())?;
649 let p = dense_design_hmc.ncols();
650 let sampler_design_copy_reservation = MemoryGovernor::global()
654 .try_reserve_dense_f64(
655 dense_design_hmc.nrows(),
656 dense_design_hmc.ncols(),
657 "saved standard model sampler design copy",
658 )
659 .map_err(|error| error.to_string())?;
660 let fit = fit_result_from_saved_model_for_prediction(model)?;
661 refresh_negbin_theta_for_sampling(&mut likelihood, fit.likelihood_scale);
672 if fit.beta.len() != p {
673 return Err(format!(
674 "standard sample: saved model has {} coefficients but rebuilt design has {} columns",
675 fit.beta.len(),
676 p,
677 ));
678 }
679 if fit.lambdas.len() != design.penalties.len() {
680 return Err(format!(
681 "standard sample: saved model has {} lambdas but rebuilt design has {} penalties",
682 fit.lambdas.len(),
683 design.penalties.len(),
684 ));
685 }
686 let penalty =
687 weighted_blockwise_penalty_sum(&design.penalties, fit.lambdas.as_slice().unwrap(), p);
688
689 let offset_vec = saved_offset(model, data, col_map)?;
690
691 let result = run_nuts_sampling_flattened_family(
692 likelihood,
693 FamilyNutsInputs::Glm(GlmFlatInputs {
694 x: dense_design_hmc.view(),
695 y: y.view(),
696 weights: weights.view(),
697 penalty_matrix: penalty.view(),
698 mode: fit.beta.view(),
699 hessian: explicit_fit_hessian_for_whitening(&fit, p, "saved standard model")?.view(),
700 gamma_shape: fit.likelihood_scale.gamma_shape(),
701 dispersion: fit.dispersion().unwrap_or_default(),
705 firth_bias_reduction: fit.artifacts.firth_bias_reduction,
709 offset: offset_vec.as_ref().map(|o| o.view()),
710 }),
711 cfg,
712 )
713 .map_err(|e| format!("NUTS sampling failed: {e}"));
714 drop(sampler_design_copy_reservation);
715 result
716}
717
718fn sample_standard_bounded(
728 model: &SavedModel,
729 cfg: &NutsConfig,
730 bounded_columns: &[gam_models::fit_orchestration::drivers::BoundedSampleColumn],
731) -> Result<NutsResult, String> {
732 validate_nuts_config(cfg).map_err(String::from)?;
733 let fit = fit_result_from_saved_model_for_prediction(model)?;
734 let mode = fit.beta.clone();
735 let p = mode.len();
736 if p == 0 {
737 return Err(
738 "standard bounded-coefficient posterior: cannot sample from an empty coefficient vector"
739 .to_string(),
740 );
741 }
742 let user_hessian =
747 explicit_fit_hessian_for_whitening(&fit, p, "saved standard bounded-coefficient model")?;
748 let sqrt_cov_scale = fit.coefficient_covariance_scale().max(0.0).sqrt();
755 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
756 let samples = gam_models::fit_orchestration::drivers::sample_bounded_latent_posterior_internal(
757 &mode,
758 user_hessian,
759 bounded_columns,
760 n_total,
761 sqrt_cov_scale,
762 chain_stream_seed(cfg.seed, 0, 0xB0DD_ED5E_ED90_1A7Cu64),
763 )
764 .map_err(|e| format!("standard bounded-coefficient posterior sampling failed: {e}"))?;
765
766 let posterior_mean = samples
767 .mean_axis(ndarray::Axis(0))
768 .unwrap_or_else(|| Array1::<f64>::zeros(p));
769 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
770
771 Ok(NutsResult {
772 samples,
773 posterior_mean,
774 posterior_std,
775 rhat: 1.0,
776 ess: n_total as f64,
777 converged: true,
778 })
779}
780
781fn sample_standard_truncated(
798 model: &SavedModel,
799 cfg: &NutsConfig,
800 constraints: &gam_solve::pirls::LinearInequalityConstraints,
801 design: &gam_linalg::matrix::DesignMatrix,
802) -> Result<NutsResult, String> {
803 validate_nuts_config(cfg).map_err(String::from)?;
804 let fit = fit_result_from_saved_model_for_prediction(model)?;
805 let mode = fit.beta.clone();
806 let p = mode.len();
807 if p == 0 {
808 return Err(
809 "standard constrained-coefficient posterior: cannot sample from an empty coefficient \
810 vector"
811 .to_string(),
812 );
813 }
814 let penalized_hessian =
823 explicit_fit_hessian_for_whitening(&fit, p, "saved standard constrained model")?;
824 let sqrt_cov_scale = fit.coefficient_covariance_scale().max(0.0).sqrt();
825
826 let mode_scale = mode.iter().map(|v| v.abs()).fold(1.0_f64, f64::max);
833 let min_slack = (constraints.a.dot(&mode) - &constraints.b)
834 .iter()
835 .cloned()
836 .fold(f64::INFINITY, f64::min);
837 let center = if min_slack > 1e-8 * mode_scale {
838 mode.clone()
839 } else {
840 let geometry = fit.geometry.as_ref().ok_or_else(|| {
841 "standard constrained-coefficient posterior: an inequality constraint is active at \
842 the mode but the saved model carries no working geometry to recover the \
843 unconstrained Gaussian center; refit with exact geometry export"
844 .to_string()
845 })?;
846 let n = design.nrows();
847 if geometry.working_weights.len() != n || geometry.working_response.len() != n {
848 return Err(format!(
849 "standard constrained-coefficient posterior: saved working geometry has {} rows \
850 but the rebuilt design has {n}",
851 geometry.working_weights.len(),
852 ));
853 }
854 let wz = &geometry.working_weights * &geometry.working_response;
855 let rhs = design.transpose_vector_multiply(&wz);
856 let chol = penalized_hessian.cholesky(Side::Lower).map_err(|e| {
857 format!(
858 "standard constrained-coefficient posterior: Cholesky of the penalised Hessian \
859 failed while recovering the unconstrained center: {e:?}"
860 )
861 })?;
862 gam_linalg::triangular::cholesky_solve_vector(&chol.lower_triangular(), &rhs)
863 };
864
865 let samples = crate::truncated_gaussian::sample_truncated_gaussian_posterior(
866 ¢er,
867 &mode,
868 &penalized_hessian,
869 sqrt_cov_scale,
870 constraints,
871 cfg.n_samples,
872 cfg.n_chains,
873 chain_stream_seed(cfg.seed, 0, 0x7290_C047_5D6E_B14Du64),
874 )?;
875 let posterior_mean = samples
876 .mean_axis(ndarray::Axis(0))
877 .unwrap_or_else(|| Array1::<f64>::zeros(p));
878 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
879
880 let mut chains = ndarray::Array3::<f64>::zeros((cfg.n_chains, cfg.n_samples, p));
885 for chain in 0..cfg.n_chains {
886 for draw in 0..cfg.n_samples {
887 let row = chain * cfg.n_samples + draw;
888 for j in 0..p {
889 chains[(chain, draw, j)] = samples[(row, j)];
890 }
891 }
892 }
893 let (rhat, ess) = super::hmc_io::compute_split_rhat_and_ess(&chains);
894 let converged = rhat < 1.1 && ess > 100.0;
895
896 Ok(NutsResult {
897 samples,
898 posterior_mean,
899 posterior_std,
900 rhat,
901 ess,
902 converged,
903 })
904}
905
906fn sample_standard_link_wiggle(
907 model: &SavedModel,
908 data: ArrayView2<'_, f64>,
909 col_map: &HashMap<String, usize>,
910 training_headers: Option<&Vec<String>>,
911 likelihood: LikelihoodSpec,
912 cfg: &NutsConfig,
913) -> Result<NutsResult, String> {
914 let parsed = parse_formula(&model.formula)?;
915 let y_col = resolve_role_col(col_map, &parsed.response, "response")?;
916 let y = data.column(y_col).to_owned();
917
918 let spec = resolve_termspec_for_prediction(
919 &model.resolved_termspec,
920 training_headers,
921 col_map,
922 "resolved_termspec",
923 )?;
924 let design = build_term_collection_design(data, &spec)
925 .map_err(|e| format!("failed to build term collection design: {e}"))?;
926 let p_main = design.design.ncols();
927
928 let fit = fit_result_from_saved_model_for_prediction(model)?;
929 let wiggle_runtime = model
930 .saved_prediction_runtime()?
931 .link_wiggle
932 .ok_or_else(|| "link-wiggle model is missing wiggle runtime metadata".to_string())?;
933 let mode_beta = fit
934 .block_by_role(BlockRole::Mean)
935 .ok_or_else(|| "standard link-wiggle model is missing Mean coefficient block".to_string())?
936 .beta
937 .clone();
938 let mode_theta = fit
939 .block_by_role(BlockRole::LinkWiggle)
940 .ok_or_else(|| {
941 "standard link-wiggle model is missing LinkWiggle coefficient block".to_string()
942 })?
943 .beta
944 .clone();
945 let p_wiggle = mode_theta.len();
946 let p_total = mode_beta.len() + p_wiggle;
947
948 if mode_beta.len() != p_main {
949 return Err(format!(
950 "link-wiggle sample: saved mean block has {} coefficients but rebuilt design has {} columns",
951 mode_beta.len(),
952 p_main,
953 ));
954 }
955 if fit.beta.len() != p_total {
956 return Err(format!(
957 "link-wiggle sample: saved beta has {} coefficients but design has {} main + {} wiggle = {} total",
958 fit.beta.len(),
959 p_main,
960 p_wiggle,
961 p_total,
962 ));
963 }
964
965 let hessian = &fit
966 .geometry
967 .as_ref()
968 .ok_or_else(|| {
969 "link-wiggle model is missing explicit joint Hessian geometry; refit with exact Hessian export"
970 .to_string()
971 })?
972 .penalized_hessian;
973 validate_explicit_link_wiggle_joint_hessian(hessian, p_total)?;
974
975 let n_base_penalties = design.penalties.len();
976 let base_lambdas = fit
977 .block_by_role(BlockRole::Mean)
978 .ok_or_else(|| "standard link-wiggle model is missing Mean block lambdas".to_string())?
979 .lambdas
980 .view();
981 if base_lambdas.len() != n_base_penalties {
982 return Err(format!(
983 "link-wiggle sample: mean block has {} lambdas but rebuilt design has {} base penalties",
984 base_lambdas.len(),
985 n_base_penalties,
986 ));
987 }
988
989 let penalty_base =
990 weighted_blockwise_penalty_sum(&design.penalties, base_lambdas.as_slice().unwrap(), p_main);
991
992 let wiggle_lambdas_owned = fit
993 .lambdas_linkwiggle()
994 .ok_or_else(|| "standard link-wiggle model is missing LinkWiggle lambdas".to_string())?;
995 let wiggle_lambdas = wiggle_lambdas_owned.view();
996 let degree = wiggle_runtime.degree;
997 let knot_arr = Array1::from_vec(wiggle_runtime.knots.clone());
998
999 let mut wiggle_penalties = Vec::new();
1000 let default_orders = [2usize];
1001 let n_wiggle_lambdas = wiggle_lambdas.len();
1002 for k in 0..n_wiggle_lambdas {
1003 let order = if k < default_orders.len() {
1004 default_orders[k]
1005 } else {
1006 k + 1
1007 };
1008 if order >= p_wiggle {
1009 continue;
1010 }
1011 let penalty = create_difference_penalty_matrix(p_wiggle, order, None)
1012 .map_err(|e| format!("wiggle difference penalty failed: {e}"))?;
1013 wiggle_penalties.push(penalty);
1014 }
1015 while wiggle_penalties.len() < n_wiggle_lambdas {
1016 wiggle_penalties.push(Array2::zeros((p_wiggle, p_wiggle)));
1017 }
1018
1019 let penalty_link = weighted_penalty_matrix(&wiggle_penalties, wiggle_lambdas)?;
1020
1021 let weights = saved_prior_weights(model, data, col_map)?;
1026 let offset_vec = saved_offset(model, data, col_map)?;
1027
1028 let mut q0 = design.design.dot(&mode_beta);
1029 if let Some(offset) = offset_vec.as_ref() {
1030 q0 += offset;
1031 }
1032 let (q0_min, q0_max) = q0
1033 .iter()
1034 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
1035 (lo.min(v), hi.max(v))
1036 });
1037
1038 let spline = LinkWiggleSplineArtifacts {
1039 knot_range: (q0_min, q0_max),
1040 knot_vector: knot_arr,
1041 degree,
1042 };
1043
1044 let family = match (&likelihood.response, &likelihood.link) {
1049 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {
1050 LinkWiggleFamilyParams::BinomialLogit
1051 }
1052 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {
1053 LinkWiggleFamilyParams::BinomialProbit
1054 }
1055 (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {
1056 LinkWiggleFamilyParams::BinomialCLogLog
1057 }
1058 (ResponseFamily::Gaussian, _) => LinkWiggleFamilyParams::Gaussian {
1059 sigma: fit.standard_deviation,
1060 },
1061 (ResponseFamily::Poisson, _) => LinkWiggleFamilyParams::PoissonLog,
1062 (ResponseFamily::Tweedie { p }, _) => LinkWiggleFamilyParams::TweedieLog {
1063 power: *p,
1064 phi: fit.likelihood_scale.fixed_phi().unwrap_or(1.0),
1065 },
1066 (ResponseFamily::NegativeBinomial { theta, .. }, _) => {
1067 LinkWiggleFamilyParams::NegativeBinomialLog {
1068 theta: fit.likelihood_scale.negbin_theta().unwrap_or(*theta),
1069 }
1070 }
1071 (ResponseFamily::Gamma, _) => LinkWiggleFamilyParams::GammaLog {
1072 shape: fit
1073 .likelihood_scale
1074 .gamma_shape()
1075 .unwrap_or(fit.standard_deviation),
1076 },
1077 _ => {
1078 return Err(format!(
1079 "NUTS sampling with link wiggle is not supported for family {}",
1080 likelihood.pretty_name()
1081 ));
1082 }
1083 };
1084
1085 let wiggle_nuts_dense = design
1086 .design
1087 .try_to_dense_governed("saved link-wiggle HMC design")
1088 .map_err(|error| error.to_string())?;
1089 let sampler_design_copy_reservation = MemoryGovernor::global()
1093 .try_reserve_dense_f64(
1094 wiggle_nuts_dense.nrows(),
1095 wiggle_nuts_dense.ncols(),
1096 "saved link-wiggle sampler design copy",
1097 )
1098 .map_err(|error| error.to_string())?;
1099 let result = run_link_wiggle_nuts_sampling(
1100 wiggle_nuts_dense.view(),
1101 y.view(),
1102 weights.view(),
1103 offset_vec.as_ref().map(|o| o.view()),
1104 penalty_base.view(),
1105 penalty_link.view(),
1106 mode_beta.view(),
1107 mode_theta.view(),
1108 hessian.view(),
1109 spline,
1110 family,
1111 cfg,
1112 )
1113 .map_err(|e| format!("link-wiggle NUTS sampling failed: {e}"));
1114 drop(sampler_design_copy_reservation);
1115 result
1116}
1117
1118fn sample_survival(
1119 model: &SavedModel,
1120 data: ArrayView2<'_, f64>,
1121 col_map: &HashMap<String, usize>,
1122 training_headers: Option<&Vec<String>>,
1123 cfg: &NutsConfig,
1124) -> Result<NutsResult, String> {
1125 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1126 if matches!(
1127 saved_likelihood_mode,
1128 SurvivalLikelihoodMode::Latent
1129 | SurvivalLikelihoodMode::LatentBinary
1130 | SurvivalLikelihoodMode::LocationScale
1131 ) {
1132 return laplace_gaussian_fallback(model, cfg, "survival posterior fallback");
1133 }
1134 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1143 let exit_col = time_cols.exit_col;
1144 let eventname = model
1145 .survival_event
1146 .as_ref()
1147 .ok_or_else(|| "survival model missing event column metadata".to_string())?;
1148 let event_col = resolve_role_col(col_map, eventname, "event")?;
1149 let termspec = resolve_termspec_for_prediction(
1150 &model.resolved_termspec,
1151 training_headers,
1152 col_map,
1153 "resolved_termspec",
1154 )?;
1155 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1156 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1157 let cov_design = build_term_collection_design(cov_input, &termspec)
1158 .map_err(|e| format!("failed to build survival design: {e}"))?;
1159 let n = data.nrows();
1160 let p_cov = cov_design.design.ncols();
1161 let mut age_entry = Array1::<f64>::zeros(n);
1162 let mut age_exit = Array1::<f64>::zeros(n);
1163 let mut event_target = Array1::<u8>::zeros(n);
1164 let event_competing = Array1::<u8>::zeros(n);
1165 let weights = Array1::<f64>::ones(n);
1166 for i in 0..n {
1167 let (t0, t1) = normalize_survival_time_pair(
1168 time_cols.row_entry_time(data, i),
1169 data[[i, exit_col]],
1170 i,
1171 )?;
1172 age_entry[i] = t0;
1173 age_exit[i] = t1;
1174 event_target[i] = if data[[i, event_col]] >= 0.5 { 1 } else { 0 };
1175 }
1176 let time_cfg = load_survival_time_basis_config_from_model(model)?;
1177 let time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
1178 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
1179 &time_build.basisname,
1180 time_build.degree,
1181 time_build.knots.as_ref(),
1182 time_build.keep_cols.as_ref(),
1183 time_build.smooth_lambda,
1184 )?;
1185 let time_anchor_row = if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1186 let time_anchor = model
1187 .survival_time_anchor
1188 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1189 Some(evaluate_survival_time_basis_row(
1190 time_anchor,
1191 &resolved_time_cfg,
1192 )?)
1193 } else {
1194 None
1195 };
1196 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
1197 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
1198 build_survival_time_offsets_for_likelihood(
1199 &age_entry,
1200 &age_exit,
1201 &baseline_cfg,
1202 saved_likelihood_mode,
1203 None,
1204 )?;
1205 if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1206 let time_anchor = model
1207 .survival_time_anchor
1208 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1209 add_survival_time_derivative_guard_offset(
1210 &age_entry,
1211 &age_exit,
1212 time_anchor,
1213 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
1214 &mut eta_offset_entry,
1215 &mut eta_offset_exit,
1216 &mut derivative_offset_exit,
1217 )?;
1218 }
1219 let saved_timewiggle = saved_baseline_timewiggle_components(
1220 &eta_offset_entry,
1221 &eta_offset_exit,
1222 &derivative_offset_exit,
1223 model,
1224 )?;
1225 let p_time = time_build.x_exit_time.ncols();
1226 let p_timewiggle = saved_timewiggle
1227 .as_ref()
1228 .map(|(_, exit, _)| exit.ncols())
1229 .unwrap_or(0);
1230 let p = p_time
1231 .checked_add(p_timewiggle)
1232 .and_then(|width| width.checked_add(p_cov))
1233 .ok_or_else(|| "saved survival sampler design width overflow".to_string())?;
1234 let survival_design_reservation = MemoryGovernor::global()
1239 .try_reserve_dense_f64_copies(
1240 n,
1241 p,
1242 SURVIVAL_DESIGN_LIVE_COPIES,
1243 "saved survival sampler design live set",
1244 )
1245 .map_err(|error| error.to_string())?;
1246 let mut x_entry = Array2::<f64>::zeros((n, p));
1247 let mut x_exit = Array2::<f64>::zeros((n, p));
1248 let mut x_derivative = Array2::<f64>::zeros((n, p));
1249 let row_chunk_target_bytes = ResourcePolicy::default_library().row_chunk_target_bytes;
1250 if p_time > 0 {
1251 stream_design_into(
1252 &time_build.x_entry_time,
1253 x_entry.slice_mut(s![.., ..p_time]),
1254 row_chunk_target_bytes,
1255 "saved survival entry-time design",
1256 )?;
1257 stream_design_into(
1258 &time_build.x_exit_time,
1259 x_exit.slice_mut(s![.., ..p_time]),
1260 row_chunk_target_bytes,
1261 "saved survival exit-time design",
1262 )?;
1263 stream_design_into(
1264 &time_build.x_derivative_time,
1265 x_derivative.slice_mut(s![.., ..p_time]),
1266 row_chunk_target_bytes,
1267 "saved survival derivative-time design",
1268 )?;
1269 if let Some(anchor_row) = time_anchor_row.as_ref() {
1270 if anchor_row.len() != p_time {
1271 return Err(format!(
1272 "survival time anchoring column mismatch: design={p_time}, anchor={}",
1273 anchor_row.len(),
1274 ));
1275 }
1276 for mut row in x_entry.slice_mut(s![.., ..p_time]).rows_mut() {
1277 row -= &anchor_row.view();
1278 }
1279 for mut row in x_exit.slice_mut(s![.., ..p_time]).rows_mut() {
1280 row -= &anchor_row.view();
1281 }
1282 }
1283 }
1284 if let Some((entry_w, exit_w, deriv_w)) = saved_timewiggle.as_ref()
1285 && p_timewiggle > 0
1286 {
1287 x_entry
1288 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1289 .assign(entry_w);
1290 x_exit
1291 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1292 .assign(exit_w);
1293 x_derivative
1294 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1295 .assign(deriv_w);
1296 }
1297 if p_cov > 0 {
1298 let cov_range = (p_time + p_timewiggle)..(p_time + p_timewiggle + p_cov);
1299 stream_design_into(
1300 &cov_design.design,
1301 x_entry.slice_mut(s![.., cov_range.clone()]),
1302 row_chunk_target_bytes,
1303 "saved survival covariate design",
1304 )?;
1305 x_exit
1306 .slice_mut(s![.., cov_range.clone()])
1307 .assign(&x_entry.slice(s![.., cov_range]));
1308 }
1309 drop(cov_design);
1312 let mut penalty_blocks: Vec<PenaltyBlock> = Vec::new();
1313 for (idx, s) in time_build.penalties.iter().enumerate() {
1314 if s.nrows() == p_time && s.ncols() == p_time {
1315 penalty_blocks.push(PenaltyBlock {
1316 matrix: s.clone(),
1317 lambda: time_build
1318 .smooth_lambda
1319 .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1320 range: 0..p_time,
1321 nullspace_dim: time_build.nullspace_dims.get(idx).copied().unwrap_or(0),
1322 });
1323 }
1324 }
1325 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
1326 if let Some((_, exit_w, _)) = saved_timewiggle.as_ref() {
1327 let start = p_time;
1328 let end = start + exit_w.ncols();
1329 let wiggle_lambda_offset = penalty_blocks.len();
1330 let wiggle_cfg = saved_baseline_timewiggle_spec(model)?.ok_or_else(|| {
1331 "saved baseline-timewiggle model missing baseline-timewiggle metadata".to_string()
1332 })?;
1333 let wiggle_degree = wiggle_cfg.degree;
1334 let wiggle_knots =
1335 Array1::from_vec(model.baseline_timewiggle_knots.clone().ok_or_else(|| {
1336 "saved baseline-timewiggle model missing baseline_timewiggle_knots".to_string()
1337 })?);
1338 let mut seed = Array1::<f64>::zeros(2 * n);
1339 for i in 0..n {
1340 seed[i] = eta_offset_entry[i];
1341 seed[n + i] = eta_offset_exit[i];
1342 }
1343 let (primary_order, extra_orders) =
1344 split_wiggle_penalty_orders(2, &wiggle_cfg.penalty_orders);
1345 let mut block = buildwiggle_block_input_from_knots(
1346 seed.view(),
1347 &wiggle_knots,
1348 wiggle_degree,
1349 primary_order,
1350 wiggle_cfg.double_penalty,
1351 )?;
1352 append_selected_wiggle_penalty_orders(&mut block, &extra_orders)
1353 .map_err(|e| format!("baseline-timewiggle penalty reconstruction failed: {e}"))?;
1354 for (widx, s) in block.penalties.iter().enumerate() {
1355 let s = match s {
1356 gam_solve::estimate::PenaltySpec::Block { local, .. } => local,
1357 gam_solve::estimate::PenaltySpec::Dense(m)
1358 | gam_solve::estimate::PenaltySpec::DenseWithMean { matrix: m, .. } => m,
1359 };
1360 if s.nrows() == exit_w.ncols() && s.ncols() == exit_w.ncols() {
1361 penalty_blocks.push(PenaltyBlock {
1362 matrix: s.clone(),
1363 lambda: time_build
1364 .smooth_lambda
1365 .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1366 range: start..end,
1367 nullspace_dim: block.nullspace_dims.get(widx).copied().unwrap_or(0),
1368 });
1369 }
1370 }
1371 for (local_idx, block_penalty) in penalty_blocks[wiggle_lambda_offset..]
1372 .iter_mut()
1373 .enumerate()
1374 {
1375 if let Some(&lam) = fit_saved.lambdas.get(wiggle_lambda_offset + local_idx) {
1376 block_penalty.lambda = lam;
1377 }
1378 }
1379 }
1380 drop(saved_timewiggle);
1383 let ridge_lambda = model.survivalridge_lambda.ok_or_else(|| {
1384 "saved survival model is missing survivalridge_lambda; refusing to \
1385 pick a load-time default (the historical 1e-4 fallback silently \
1386 disagreed with the 1e-6 fit-time default). Refit."
1387 .to_string()
1388 })?;
1389 let ridge_range_start = if time_build.basisname == "linear" && !model.has_baseline_time_wiggle()
1390 {
1391 1
1392 } else {
1393 0
1394 };
1395 drop(time_build);
1398 if ridge_lambda > 0.0 && p > ridge_range_start {
1399 let dim = p - ridge_range_start;
1400 let mut ridge = Array2::<f64>::zeros((dim, dim));
1401 for d in 0..dim {
1402 ridge[[d, d]] = 1.0;
1403 }
1404 penalty_blocks.push(PenaltyBlock {
1405 matrix: ridge,
1406 lambda: ridge_lambda,
1407 range: ridge_range_start..p,
1408 nullspace_dim: 0,
1409 });
1410 }
1411 for (idx, block) in penalty_blocks.iter_mut().enumerate() {
1412 if let Some(&lam) = fit_saved.lambdas.get(idx) {
1413 block.lambda = lam;
1414 }
1415 }
1416 let penalties = PenaltyBlocks::new(penalty_blocks);
1417 let survivalspec = match model
1418 .survivalspec
1419 .as_deref()
1420 .unwrap_or("net")
1421 .to_ascii_lowercase()
1422 .as_str()
1423 {
1424 "net" => SurvivalSpec::Net,
1425 "crude" => {
1426 return Err("saved survival spec 'crude' is not supported by the one-hazard survival engine; refit or export a net survival model for this path"
1427 .to_string());
1428 }
1429 other => {
1430 return Err(format!("unsupported saved survival spec '{other}'"));
1431 }
1432 };
1433 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 0.0 };
1434 let mut model_surv = royston_parmar::working_model_from_flattened(
1435 penalties.clone(),
1436 monotonicity,
1437 survivalspec,
1438 RoystonParmarInputs {
1439 age_entry: age_entry.view(),
1440 age_exit: age_exit.view(),
1441 event_target: event_target.view(),
1442 event_competing: event_competing.view(),
1443 weights: weights.view(),
1444 x_entry: x_entry.view(),
1445 x_exit: x_exit.view(),
1446 x_derivative: x_derivative.view(),
1447 monotonicity_constraint_rows: None,
1448 monotonicity_constraint_offsets: None,
1449 eta_offset_entry: Some(eta_offset_entry.view()),
1450 eta_offset_exit: Some(eta_offset_exit.view()),
1451 derivative_offset_exit: Some(derivative_offset_exit.view()),
1452 },
1453 )
1454 .map_err(|e| format!("failed to construct survival model: {e}"))?;
1455 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull {
1456 model_surv
1457 .set_structural_monotonicity(true, p_time + p_timewiggle)
1458 .map_err(|e| format!("failed to enable structural monotonicity: {e}"))?;
1459 }
1460 let beta0 = fit_saved.beta.clone();
1461 let survival_hessian_reservation = MemoryGovernor::global()
1462 .try_reserve_dense_f64(p, p, "saved survival sampler Hessian")
1463 .map_err(|error| error.to_string())?;
1464 let hessian = {
1465 let state = model_surv
1466 .update_state(&beta0)
1467 .map_err(|e| format!("failed to evaluate survival state: {e}"))?;
1468 match state.hessian {
1469 gam_linalg::matrix::SymmetricMatrix::Dense(hessian) => hessian,
1472 gam_linalg::matrix::SymmetricMatrix::Sparse(hessian) => {
1475 gam_linalg::matrix::SymmetricMatrix::Sparse(hessian).to_dense()
1476 }
1477 }
1478 };
1479 drop(model_surv);
1482 let result = run_survival_nuts_sampling_flattened(
1483 SurvivalFlatInputs {
1484 age_entry: age_entry.view(),
1485 age_exit: age_exit.view(),
1486 event_target: event_target.view(),
1487 event_competing: event_competing.view(),
1488 weights: weights.view(),
1489 x_entry: x_entry.view(),
1490 x_exit: x_exit.view(),
1491 x_derivative: x_derivative.view(),
1492 eta_offset_entry: Some(eta_offset_entry.view()),
1493 eta_offset_exit: Some(eta_offset_exit.view()),
1494 derivative_offset_exit: Some(derivative_offset_exit.view()),
1495 },
1496 penalties,
1497 monotonicity,
1498 survivalspec,
1499 saved_likelihood_mode != SurvivalLikelihoodMode::Weibull,
1500 p_time + p_timewiggle,
1501 beta0.view(),
1502 hessian.view(),
1503 cfg,
1504 )
1505 .map_err(|e| format!("survival NUTS sampling failed: {e}"));
1506 drop(survival_hessian_reservation);
1507 drop(survival_design_reservation);
1508 result
1509}
1510
1511#[cfg(test)]
1512mod tests {
1513 use super::*;
1514 use gam_linalg::matrix::{DenseDesignMatrix, DenseDesignOperator, LinearOperator};
1515 use gam_problem::types::LikelihoodScaleMetadata;
1516
1517 struct ChunkOnlySampleDesign {
1518 values: Array2<f64>,
1519 row_chunk_calls: std::sync::atomic::AtomicUsize,
1520 fail_rows: bool,
1521 }
1522
1523 impl LinearOperator for ChunkOnlySampleDesign {
1524 fn nrows(&self) -> usize {
1525 self.values.nrows()
1526 }
1527
1528 fn ncols(&self) -> usize {
1529 self.values.ncols()
1530 }
1531
1532 fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
1533 self.values.dot(vector)
1534 }
1535
1536 fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
1537 self.values.t().dot(vector)
1538 }
1539
1540 fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
1541 if weights.len() != self.nrows() {
1542 return Err(format!(
1543 "weight vector has {} entries for {} design rows",
1544 weights.len(),
1545 self.nrows()
1546 ));
1547 }
1548 Ok(Array2::zeros((self.ncols(), self.ncols())))
1549 }
1550 }
1551
1552 impl DenseDesignOperator for ChunkOnlySampleDesign {
1553 fn row_chunk_into(
1554 &self,
1555 rows: std::ops::Range<usize>,
1556 mut out: ndarray::ArrayViewMut2<'_, f64>,
1557 ) -> Result<(), gam_runtime::resource::MatrixMaterializationError> {
1558 self.row_chunk_calls
1559 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1560 if self.fail_rows {
1561 return Err(
1562 gam_runtime::resource::MatrixMaterializationError::MissingRowChunk {
1563 context: "ChunkOnlySampleDesign test refusal",
1564 },
1565 );
1566 }
1567 out.assign(&self.values.slice(s![rows, ..]));
1568 Ok(())
1569 }
1570
1571 fn to_dense(&self) -> Array2<f64> {
1572 panic!("stream_design_into must never call to_dense")
1573 }
1574 }
1575
1576 #[test]
1577 fn survival_design_streaming_uses_row_chunks_and_target_slice() {
1578 let values = Array2::from_shape_fn((5, 3), |(i, j)| (10 * i + j) as f64);
1579 let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1580 values: values.clone(),
1581 row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1582 fail_rows: false,
1583 });
1584 let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(
1585 std::sync::Arc::clone(&operator),
1586 ));
1587 let mut assembled = Array2::<f64>::from_elem((5, 5), -1.0);
1588
1589 stream_design_into(
1590 &design,
1591 assembled.slice_mut(s![.., 1..4]),
1592 2 * 3 * std::mem::size_of::<f64>(),
1593 "streaming regression",
1594 )
1595 .expect("row-chunk assembly succeeds");
1596
1597 assert_eq!(assembled.slice(s![.., 1..4]), values.view());
1598 assert!(assembled.column(0).iter().all(|&value| value == -1.0));
1599 assert!(assembled.column(4).iter().all(|&value| value == -1.0));
1600 assert_eq!(
1601 operator
1602 .row_chunk_calls
1603 .load(std::sync::atomic::Ordering::SeqCst),
1604 3,
1605 );
1606 }
1607
1608 #[test]
1609 fn survival_design_streaming_propagates_typed_row_refusal() {
1610 let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1611 values: Array2::zeros((2, 2)),
1612 row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1613 fail_rows: true,
1614 });
1615 let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(operator));
1616 let mut assembled = Array2::<f64>::zeros((2, 2));
1617
1618 let error = stream_design_into(
1619 &design,
1620 assembled.view_mut(),
1621 std::mem::size_of::<f64>(),
1622 "streaming refusal regression",
1623 )
1624 .expect_err("row-chunk refusal must remain fallible");
1625
1626 assert!(error.contains("streaming refusal regression"));
1627 assert!(error.contains("ChunkOnlySampleDesign test refusal"));
1628 }
1629
1630 #[test]
1641 fn refresh_negbin_theta_reads_theta_hat_not_seed() {
1642 let mut likelihood = LikelihoodSpec::negative_binomial_log(1.0);
1645 let scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.97 };
1646
1647 refresh_negbin_theta_for_sampling(&mut likelihood, scale);
1648
1649 match likelihood.response {
1650 ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
1651 theta, 2.97,
1652 "NB NUTS must sample at theta_hat (#1463), not the seed theta=1.0"
1653 ),
1654 other => panic!("expected NegativeBinomial response, got {other:?}"),
1655 }
1656 }
1657
1658 #[test]
1662 fn refresh_negbin_theta_fixed_theta_is_preserved() {
1663 let mut likelihood = LikelihoodSpec::negative_binomial_log_fixed(4.25);
1664 let scale = LikelihoodScaleMetadata::FixedNegBinTheta { theta: 4.25 };
1665
1666 refresh_negbin_theta_for_sampling(&mut likelihood, scale);
1667
1668 match likelihood.response {
1669 ResponseFamily::NegativeBinomial { theta, theta_fixed } => {
1670 assert_eq!(theta, 4.25, "fixed NB theta must survive the refresh");
1671 assert!(theta_fixed, "theta_fixed flag must be preserved");
1672 }
1673 other => panic!("expected NegativeBinomial response, got {other:?}"),
1674 }
1675 }
1676
1677 #[test]
1681 fn refresh_negbin_theta_falls_back_to_seed_when_unfitted() {
1682 let mut likelihood = LikelihoodSpec::negative_binomial_log(3.5);
1683 refresh_negbin_theta_for_sampling(
1685 &mut likelihood,
1686 LikelihoodScaleMetadata::ProfiledGaussian,
1687 );
1688
1689 match likelihood.response {
1690 ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
1691 theta, 3.5,
1692 "with no fitted theta the NB seed must be kept verbatim"
1693 ),
1694 other => panic!("expected NegativeBinomial response, got {other:?}"),
1695 }
1696 }
1697
1698 #[test]
1702 fn refresh_negbin_theta_leaves_non_nb_families_untouched() {
1703 let mut poisson = LikelihoodSpec::poisson_log();
1704 let before = poisson.response.clone();
1705 refresh_negbin_theta_for_sampling(
1706 &mut poisson,
1707 LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 9.0 },
1708 );
1709 assert_eq!(
1710 poisson.response, before,
1711 "Poisson response must be untouched by the NB theta refresh"
1712 );
1713 }
1714}