1use std::collections::HashMap;
12
13use faer::Side;
14use ndarray::{Array1, Array2, ArrayView2, s};
15use rand::{RngExt, SeedableRng};
16
17use super::hmc_io::{
18 FamilyNutsInputs, GlmFlatInputs, SurvivalFlatInputs, explicit_fit_hessian_for_whitening,
19 run_nuts_sampling_flattened_family, run_survival_nuts_sampling_flattened, validate_nuts_config,
20};
21pub use super::hmc_io::{NutsConfig, NutsResult};
22use crate::formula_dsl::{LinkWiggleFormulaSpec, parse_formula};
23use crate::model::{
24 FittedModel as SavedModel, PredictModelClass, load_survival_time_basis_config_from_model,
25};
26use gam_linalg::faer_ndarray::FaerCholesky;
27use gam_linalg::triangular::back_substitution_lower_transpose_guarded_into;
28use gam_models::survival::construction::{
29 SurvivalLikelihoodMode, add_survival_time_derivative_guard_offset, build_survival_time_basis,
30 build_survival_time_offsets_for_likelihood, evaluate_survival_time_basis_row,
31 normalize_survival_time_pair, resolved_survival_time_basis_config_from_build,
32 survival_derivative_guard_for_likelihood,
33};
34use gam_models::survival::predict::{
35 fit_result_from_saved_model_for_prediction, require_saved_survival_likelihood_mode,
36 resolve_saved_survival_time_columns, resolve_termspec_for_prediction,
37 saved_baseline_timewiggle_components, saved_survival_runtime_baseline_config,
38};
39use gam_models::survival::royston_parmar::{self, RoystonParmarInputs};
40use gam_models::survival::{
41 PenaltyBlock, PenaltyBlocks, SurvivalMonotonicityPenalty, SurvivalSpec,
42};
43use gam_models::wiggle::{
44 append_selected_wiggle_function_penalties, buildwiggle_block_input_from_knots,
45 split_wiggle_penalty_orders,
46};
47use gam_problem::types::{LikelihoodSpec, ResponseFamily};
48use gam_runtime::resource::{MemoryGovernor, ResourcePolicy, rows_for_target_bytes};
49use gam_solve::estimate::validate_all_finite;
50use gam_terms::smooth::build_term_collection_design;
51use gam_terms::smooth::{LinearCoefficientGeometry, weighted_blockwise_penalty_sum};
52use gam_terms::term_builder::resolve_role_col;
53
54fn sampling_sqrt_covariance_scale(
55 fit: &gam_solve::estimate::UnifiedFitResult,
56 context: &str,
57) -> Result<f64, String> {
58 let scale = fit
59 .coefficient_covariance_scale()
60 .map_err(|err| format!("{context}: cannot resolve coefficient-covariance scale: {err}"))?;
61 if !(scale.is_finite() && scale > 0.0) {
62 return Err(format!(
63 "{context}: posterior sampling requires a finite strictly-positive coefficient-covariance scale, got {scale}"
64 ));
65 }
66 Ok(scale.sqrt())
67}
68
69fn resolved_fit_dispersion(
70 fit: &gam_solve::estimate::UnifiedFitResult,
71 context: &str,
72) -> Result<gam_problem::Dispersion, String> {
73 if let Some(dispersion) = fit.dispersion() {
74 return Ok(dispersion);
75 }
76 let family = fit.likelihood_family.as_ref().ok_or_else(|| {
77 format!("{context}: fit has no engine-level family and no scalar dispersion")
78 })?;
79 let likelihood = gam_problem::GlmLikelihoodSpec::try_new(family.clone(), fit.likelihood_scale)
80 .map_err(|err| format!("{context}: invalid fitted likelihood scale: {err}"))?;
81 let profiled_standard_deviation = matches!(
82 likelihood
83 .resolved_scale()
84 .map_err(|err| format!("{context}: invalid fitted likelihood scale: {err}"))?,
85 gam_problem::ResolvedLikelihoodScale::ProfiledGaussian
86 )
87 .then_some(fit.standard_deviation);
88 gam_solve::estimate::dispersion_from_likelihood(&likelihood, profiled_standard_deviation)
89 .map_err(|err| format!("{context}: cannot resolve fitted dispersion: {err}"))
90}
91
92const SURVIVAL_DESIGN_LIVE_COPIES: usize = 2 * 3;
95
96fn stream_design_into(
101 design: &gam_linalg::matrix::DesignMatrix,
102 mut out: ndarray::ArrayViewMut2<'_, f64>,
103 row_chunk_target_bytes: usize,
104 context: &str,
105) -> Result<(), String> {
106 if out.dim() != (design.nrows(), design.ncols()) {
107 return Err(format!(
108 "{context}: output shape {}x{} does not match design {}x{}",
109 out.nrows(),
110 out.ncols(),
111 design.nrows(),
112 design.ncols(),
113 ));
114 }
115 let chunk_rows = rows_for_target_bytes(row_chunk_target_bytes, design.ncols())
116 .max(1)
117 .min(design.nrows().max(1));
118 for start in (0..design.nrows()).step_by(chunk_rows) {
119 let end = (start + chunk_rows).min(design.nrows());
120 design
121 .row_chunk_into(start..end, out.slice_mut(s![start..end, ..]))
122 .map_err(|error| format!("{context}: {error}"))?;
123 }
124 Ok(())
125}
126
127pub fn saved_baseline_timewiggle_spec(
132 model: &SavedModel,
133) -> Result<Option<LinkWiggleFormulaSpec>, String> {
134 model
135 .saved_baseline_time_wiggle()
136 .map_err(|e| e.to_string())
137 .map(|runtime| {
138 runtime.map(|saved| LinkWiggleFormulaSpec {
139 degree: saved.degree,
140 num_internal_knots: saved.knots.len().saturating_sub(2 * (saved.degree + 1)),
141 penalty_orders: saved.penalty_orders,
142 double_penalty: saved.double_penalty,
143 })
144 })
145}
146
147fn saved_prior_weights(
154 model: &SavedModel,
155 data: ArrayView2<'_, f64>,
156 col_map: &HashMap<String, usize>,
157) -> Result<Array1<f64>, String> {
158 match model.weight_column.as_deref() {
159 Some(name) => {
160 let idx = resolve_role_col(col_map, name, "weights")?;
161 let w = data.column(idx).to_owned();
162 if !w.iter().all(|v| v.is_finite() && *v >= 0.0) {
163 return Err(format!(
164 "sample: prior-weights column '{name}' contains negative or non-finite values"
165 ));
166 }
167 Ok(w)
168 }
169 None => Ok(Array1::ones(data.nrows())),
170 }
171}
172
173fn saved_offset(
179 model: &SavedModel,
180 data: ArrayView2<'_, f64>,
181 col_map: &HashMap<String, usize>,
182) -> Result<Option<Array1<f64>>, String> {
183 match model.offset_column.as_deref() {
184 Some(name) => {
185 let idx = resolve_role_col(col_map, name, "offset")?;
186 Ok(Some(data.column(idx).to_owned()))
187 }
188 None => Ok(None),
189 }
190}
191
192fn refresh_negbin_theta_for_sampling(
210 likelihood: &mut LikelihoodSpec,
211 scale: gam_problem::types::LikelihoodScaleMetadata,
212) {
213 if let ResponseFamily::NegativeBinomial { theta, .. } = &mut likelihood.response {
214 if let Some(theta_hat) = scale.negbin_theta() {
215 *theta = theta_hat;
216 }
217 }
218}
219
220fn likelihood_spec_for_saved_model(model: &SavedModel) -> Result<LikelihoodSpec, String> {
224 Ok(model.likelihood())
225}
226
227const DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA: f64 = 1e-2;
232
233#[inline]
234const fn splitmix64(x: u64) -> u64 {
235 gam_linalg::utils::splitmix64_hash(x)
236}
237
238#[inline]
239const fn chain_stream_seed(seed: u64, chain: usize, stream: u64) -> u64 {
240 splitmix64(seed ^ stream ^ ((chain as u64).wrapping_mul(0xD1B5_4A32_D192_ED03)))
241}
242
243pub fn sample_saved_model(
262 model: &SavedModel,
263 data: ArrayView2<'_, f64>,
264 col_map: &HashMap<String, usize>,
265 training_headers: Option<&Vec<String>>,
266 cfg: &NutsConfig,
267) -> Result<NutsResult, String> {
268 validate_nuts_config(cfg).map_err(String::from)?;
277 let likelihood = likelihood_spec_for_saved_model(model)?;
278 match model.predict_model_class() {
279 PredictModelClass::Survival => {
280 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
286 if matches!(
287 saved_likelihood_mode,
288 SurvivalLikelihoodMode::Latent
289 | SurvivalLikelihoodMode::LatentBinary
290 | SurvivalLikelihoodMode::LocationScale
291 ) {
292 laplace_gaussian_fallback(model, cfg, "survival posterior fallback")
293 } else {
294 sample_survival(model, data, col_map, training_headers, cfg)
295 }
296 }
297 PredictModelClass::Standard => {
298 if matches!(likelihood.response, ResponseFamily::Beta { .. }) {
307 laplace_gaussian_fallback(model, cfg, "beta-regression posterior fallback")
308 } else {
309 sample_standard(model, data, col_map, training_headers, likelihood, cfg)
310 }
311 }
312 PredictModelClass::GaussianLocationScale => {
322 laplace_gaussian_fallback(model, cfg, "gaussian location-scale posterior")
323 }
324 PredictModelClass::BinomialLocationScale => {
325 laplace_gaussian_fallback(model, cfg, "binomial location-scale posterior")
326 }
327 PredictModelClass::DispersionLocationScale => {
328 laplace_gaussian_fallback(model, cfg, "dispersion location-scale posterior")
329 }
330 PredictModelClass::BernoulliMarginalSlope => {
331 laplace_gaussian_fallback(model, cfg, "bernoulli marginal-slope posterior")
332 }
333 PredictModelClass::TransformationNormal => {
334 sample_transformation_normal_constrained(model, cfg)
339 }
340 }
341}
342
343pub fn laplace_gaussian_fallback(
357 model: &SavedModel,
358 cfg: &NutsConfig,
359 rationale: &'static str,
360) -> Result<NutsResult, String> {
361 validate_nuts_config(cfg).map_err(String::from)?;
366 let fit = fit_result_from_saved_model_for_prediction(model)?;
367 let mode = fit.beta.clone();
368 let p = mode.len();
369 if p == 0 {
370 return Err(format!(
371 "{rationale}: cannot sample from an empty coefficient vector"
372 ));
373 }
374 let h = fit.penalized_hessian().ok_or_else(|| {
375 format!(
376 "{rationale}: posterior fallback requires the explicit penalised Hessian; \
377 refit with exact geometry export to enable posterior sampling for this class."
378 )
379 })?;
380 let sqrt_cov_scale = sampling_sqrt_covariance_scale(&fit, rationale)?;
397 if h.nrows() != p || h.ncols() != p {
398 return Err(format!(
399 "{rationale}: penalised Hessian is {}x{}, expected {}x{}",
400 h.nrows(),
401 h.ncols(),
402 p,
403 p
404 ));
405 }
406 let chol = h.cholesky(Side::Lower).map_err(|err| {
407 format!("{rationale}: Cholesky factorisation of the penalised Hessian failed: {err:?}")
408 })?;
409 let l = chol.lower_triangular();
410
411 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
415 let mut samples = Array2::<f64>::zeros((n_total, p));
416 let mut eps = Array1::<f64>::zeros(p);
417 let mut delta = Array1::<f64>::zeros(p);
418 for chain in 0..cfg.n_chains {
419 let mut rng = rand::rngs::StdRng::seed_from_u64(chain_stream_seed(
420 cfg.seed,
421 chain,
422 0xA0B7_6C5D_E431_298F,
423 ));
424 for draw in 0..cfg.n_samples {
425 let k = chain * cfg.n_samples + draw;
426 for i in 0..p {
427 eps[i] = sample_standard_normal(&mut rng);
428 }
429 back_substitution_lower_transpose_guarded_into(&l, &eps, &mut delta);
430 for i in 0..p {
431 samples[(k, i)] = mode[i] + sqrt_cov_scale * delta[i];
436 }
437 }
438 }
439
440 let posterior_mean = samples
441 .mean_axis(ndarray::Axis(0))
442 .unwrap_or_else(|| Array1::<f64>::zeros(p));
443 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
444
445 Ok(NutsResult {
446 samples,
447 posterior_mean,
448 posterior_std,
449 rhat: 1.0,
450 ess: n_total as f64,
451 converged: true,
452 })
453}
454
455fn sample_transformation_normal_constrained(
468 model: &SavedModel,
469 cfg: &NutsConfig,
470) -> Result<NutsResult, String> {
471 const RATIONALE: &str = "transformation-normal constrained posterior";
472 const MAX_REJECTION_FACTOR: usize = 1000;
476
477 validate_nuts_config(cfg).map_err(String::from)?;
478 let fit = fit_result_from_saved_model_for_prediction(model)?;
479 let mode = fit.beta.clone();
480 let p = mode.len();
481 if p == 0 {
482 return Err(format!(
483 "{RATIONALE}: cannot sample from an empty coefficient vector"
484 ));
485 }
486
487 let geometry = model.transformation_geometry.as_ref().ok_or_else(|| {
488 format!("{RATIONALE}: missing the direct-α geometry record; refit (gam#2306)")
489 })?;
490 let carrier = model.transformation_cone_carrier.as_ref().ok_or_else(|| {
491 format!(
492 "{RATIONALE}: missing the monotonicity-cone carrier (transformation_cone_carrier); \
493 refit to persist the cone so constrained sampling can certify draws"
494 )
495 })?;
496 let n = geometry.cone_carrier_row_count;
497 let p_cov = geometry.cone_carrier_covariate_width;
498 let p_resp = geometry.shape_coordinate_count + 1;
499 if carrier.len() != n.saturating_mul(p_cov) {
500 return Err(format!(
501 "{RATIONALE}: cone carrier length {} != {n} rows x {p_cov} covariate columns",
502 carrier.len()
503 ));
504 }
505 if p != p_resp.saturating_mul(p_cov) {
506 return Err(format!(
507 "{RATIONALE}: coefficient length {p} != p_resp {p_resp} x p_cov {p_cov}; the saved \
508 coefficient block does not match the persisted cone geometry"
509 ));
510 }
511 let psi = Array2::from_shape_vec((n, p_cov), carrier.clone())
512 .map_err(|err| format!("{RATIONALE}: cone carrier reshape to {n}x{p_cov} failed: {err}"))?;
513
514 let is_feasible = |beta: &Array1<f64>| -> bool {
517 for k in 1..p_resp {
518 let block = beta.slice(ndarray::s![k * p_cov..(k + 1) * p_cov]);
519 if psi.dot(&block).iter().any(|value| *value < 0.0) {
520 return false;
521 }
522 }
523 true
524 };
525 if !is_feasible(&mode) {
526 return Err(format!(
527 "{RATIONALE}: the fitted mode violates the monotonicity cone Γ ≥ 0 — the saved model is \
528 not a valid monotone transformation; refit"
529 ));
530 }
531
532 let h = fit.penalized_hessian().ok_or_else(|| {
533 format!(
534 "{RATIONALE}: requires the explicit penalised Hessian; refit with exact geometry export"
535 )
536 })?;
537 let sqrt_cov_scale = sampling_sqrt_covariance_scale(&fit, RATIONALE)?;
538 if h.nrows() != p || h.ncols() != p {
539 return Err(format!(
540 "{RATIONALE}: penalised Hessian is {}x{}, expected {p}x{p}",
541 h.nrows(),
542 h.ncols()
543 ));
544 }
545 let chol = h.cholesky(Side::Lower).map_err(|err| {
546 format!("{RATIONALE}: Cholesky factorisation of the penalised Hessian failed: {err:?}")
547 })?;
548 let l = chol.lower_triangular();
549
550 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
551 let mut samples = Array2::<f64>::zeros((n_total, p));
552 let mut eps = Array1::<f64>::zeros(p);
553 let mut delta = Array1::<f64>::zeros(p);
554 let mut draw = Array1::<f64>::zeros(p);
555 let attempts_cap = cfg
556 .n_samples
557 .saturating_mul(MAX_REJECTION_FACTOR)
558 .max(MAX_REJECTION_FACTOR);
559 let mut total_attempts: u64 = 0;
560 let mut total_accepted: u64 = 0;
561
562 for chain in 0..cfg.n_chains {
563 let mut rng = rand::rngs::StdRng::seed_from_u64(chain_stream_seed(
564 cfg.seed,
565 chain,
566 0xA0B7_6C5D_E431_298F,
567 ));
568 let mut accepted_in_chain = 0usize;
569 let mut attempts_in_chain = 0usize;
570 while accepted_in_chain < cfg.n_samples {
571 if attempts_in_chain >= attempts_cap {
572 let rate = total_accepted as f64 / (total_attempts.max(1) as f64);
573 return Err(format!(
574 "{RATIONALE}: acceptance collapsed — {total_accepted} accepted of \
575 {total_attempts} draws (rate {rate:.3e}); the fit hugs the monotonicity-cone \
576 boundary so its truncated posterior cannot be rejection-sampled within \
577 {attempts_cap} draws per chain. Refit or widen the certified response support."
578 ));
579 }
580 attempts_in_chain += 1;
581 total_attempts += 1;
582 for i in 0..p {
583 eps[i] = sample_standard_normal(&mut rng);
584 }
585 back_substitution_lower_transpose_guarded_into(&l, &eps, &mut delta);
586 for i in 0..p {
587 draw[i] = mode[i] + sqrt_cov_scale * delta[i];
588 }
589 if is_feasible(&draw) {
590 let k = chain * cfg.n_samples + accepted_in_chain;
591 samples.row_mut(k).assign(&draw);
592 accepted_in_chain += 1;
593 total_accepted += 1;
594 }
595 }
596 }
597
598 let posterior_mean = samples
599 .mean_axis(ndarray::Axis(0))
600 .unwrap_or_else(|| Array1::<f64>::zeros(p));
601 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
602 Ok(NutsResult {
605 samples,
606 posterior_mean,
607 posterior_std,
608 rhat: 1.0,
609 ess: n_total as f64,
610 converged: true,
611 })
612}
613
614#[inline]
615fn sample_standard_normal<R: rand::Rng + ?Sized>(rng: &mut R) -> f64 {
616 let u1 = rng.random::<f64>().max(1e-16);
620 let u2 = rng.random::<f64>();
621 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
622}
623
624#[derive(Clone, Copy, Debug, PartialEq, Eq)]
625enum StandardPosteriorRoute {
626 BoundedLatent,
627 InequalityTruncated,
628 GaussianClosedForm,
629 UnconstrainedNuts,
630}
631
632fn standard_posterior_route(
633 has_bounded: bool,
634 declares_linear_inequality: bool,
635 has_link_wiggle: bool,
636 has_persisted_inequality: bool,
637 gaussian_identity: bool,
638) -> Result<StandardPosteriorRoute, String> {
639 if has_bounded && has_persisted_inequality {
640 return Err(
641 "standard posterior sampling does not support a model that combines bounded() latent \
642 coordinates with linear inequality constraints"
643 .to_string(),
644 );
645 }
646 if has_persisted_inequality {
647 return Ok(StandardPosteriorRoute::InequalityTruncated);
648 }
649 if has_link_wiggle || declares_linear_inequality {
650 return Err(
651 "standard constrained-coefficient posterior: the fitted model declares inequality \
652 constraints but has no persisted inequality-truncated posterior identity; refit with \
653 the current schema"
654 .to_string(),
655 );
656 }
657 if has_bounded {
658 return Ok(StandardPosteriorRoute::BoundedLatent);
659 }
660 if gaussian_identity {
661 return Ok(StandardPosteriorRoute::GaussianClosedForm);
662 }
663 Ok(StandardPosteriorRoute::UnconstrainedNuts)
664}
665
666fn sample_standard(
667 model: &SavedModel,
668 data: ArrayView2<'_, f64>,
669 col_map: &HashMap<String, usize>,
670 training_headers: Option<&Vec<String>>,
671 mut likelihood: LikelihoodSpec,
672 cfg: &NutsConfig,
673) -> Result<NutsResult, String> {
674 let fit = fit_result_from_saved_model_for_prediction(model)?;
675 let saved_spec = model.resolved_termspec.as_ref().ok_or_else(|| {
676 "standard posterior sampling requires a frozen fitted term specification; refit"
677 .to_string()
678 })?;
679 let has_bounded = saved_spec.linear_terms.iter().any(|term| {
680 matches!(
681 term.coefficient_geometry,
682 LinearCoefficientGeometry::Bounded { .. }
683 )
684 });
685 let declares_linear_inequality = saved_spec
686 .linear_terms
687 .iter()
688 .any(|term| term.coefficient_min.is_some() || term.coefficient_max.is_some())
689 || saved_spec
690 .smooth_terms
691 .iter()
692 .any(|term| !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None));
693 let constrained_posterior = fit
694 .geometry
695 .as_ref()
696 .and_then(|geometry| geometry.constrained_posterior.as_ref());
697
698 let route = standard_posterior_route(
703 has_bounded,
704 declares_linear_inequality,
705 model.has_link_wiggle(),
706 constrained_posterior.is_some(),
707 likelihood.is_gaussian_identity(),
708 )?;
709 match route {
710 StandardPosteriorRoute::InequalityTruncated => {
711 return sample_standard_truncated(&fit, cfg);
712 }
713 StandardPosteriorRoute::GaussianClosedForm => {
714 return laplace_gaussian_fallback(model, cfg, "standard gaussian posterior");
715 }
716 StandardPosteriorRoute::BoundedLatent | StandardPosteriorRoute::UnconstrainedNuts => {}
717 }
718
719 let parsed = parse_formula(&model.formula)?;
720 let y_col = resolve_role_col(col_map, &parsed.response, "response")?;
721 let y = data.column(y_col).to_owned();
722 let spec = resolve_termspec_for_prediction(
723 &model.resolved_termspec,
724 training_headers,
725 col_map,
726 "resolved_termspec",
727 )?;
728 let design = build_term_collection_design(data, &spec)
729 .map_err(|e| format!("failed to build term collection design: {e}"))?;
730
731 if route == StandardPosteriorRoute::BoundedLatent {
735 let bounded_columns: Vec<gam_models::fit_orchestration::drivers::BoundedSampleColumn> =
736 spec.linear_terms
737 .iter()
738 .enumerate()
739 .filter_map(|(j, term)| match term.coefficient_geometry {
740 LinearCoefficientGeometry::Bounded { min, max, .. } => Some(
741 gam_models::fit_orchestration::drivers::BoundedSampleColumn {
742 col_idx: design.intercept_range.end + j,
743 min,
744 max,
745 },
746 ),
747 LinearCoefficientGeometry::Unconstrained => None,
748 })
749 .collect();
750 return sample_standard_bounded(model, cfg, &bounded_columns);
751 }
752
753 let weights = saved_prior_weights(model, data, col_map)?;
756 let dense_design_hmc = design
757 .design
758 .try_to_dense_governed("saved standard model HMC design")
759 .map_err(|error| error.to_string())?;
760 let p = dense_design_hmc.ncols();
761 let sampler_design_copy_reservation = MemoryGovernor::global()
765 .try_reserve_dense_f64(
766 dense_design_hmc.nrows(),
767 dense_design_hmc.ncols(),
768 "saved standard model sampler design copy",
769 )
770 .map_err(|error| error.to_string())?;
771 refresh_negbin_theta_for_sampling(&mut likelihood, fit.likelihood_scale);
782 if fit.beta.len() != p {
783 return Err(format!(
784 "standard sample: saved model has {} coefficients but rebuilt design has {} columns",
785 fit.beta.len(),
786 p,
787 ));
788 }
789 if fit.lambdas.len() != design.penalties.len() {
790 return Err(format!(
791 "standard sample: saved model has {} lambdas but rebuilt design has {} penalties",
792 fit.lambdas.len(),
793 design.penalties.len(),
794 ));
795 }
796 let penalty =
797 weighted_blockwise_penalty_sum(&design.penalties, fit.lambdas.as_slice().unwrap(), p);
798
799 let saved_offset_vec = saved_offset(model, data, col_map)?;
800 let base_offset =
801 saved_offset_vec.unwrap_or_else(|| Array1::<f64>::zeros(design.design.nrows()));
802 let offset_vec = design
803 .compose_offset(base_offset.view(), "saved standard model sampling")
804 .map_err(|error| error.to_string())?;
805
806 let result = run_nuts_sampling_flattened_family(
807 likelihood,
808 FamilyNutsInputs::Glm(GlmFlatInputs {
809 x: dense_design_hmc.view(),
810 y: y.view(),
811 weights: weights.view(),
812 penalty_matrix: penalty.view(),
813 mode: fit.beta.view(),
814 hessian: explicit_fit_hessian_for_whitening(&fit, p, "saved standard model")?.view(),
815 likelihood_scale: fit.likelihood_scale,
816 dispersion: resolved_fit_dispersion(&fit, "standard saved-model NUTS")?,
817 firth_bias_reduction: fit.artifacts.firth_bias_reduction,
818 offset: Some(offset_vec.view()),
819 }),
820 cfg,
821 )
822 .map_err(|e| format!("NUTS sampling failed: {e}"));
823 drop(sampler_design_copy_reservation);
824 result
825}
826
827fn sample_standard_bounded(
837 model: &SavedModel,
838 cfg: &NutsConfig,
839 bounded_columns: &[gam_models::fit_orchestration::drivers::BoundedSampleColumn],
840) -> Result<NutsResult, String> {
841 validate_nuts_config(cfg).map_err(String::from)?;
842 let fit = fit_result_from_saved_model_for_prediction(model)?;
843 let mode = fit.beta.clone();
844 let p = mode.len();
845 if p == 0 {
846 return Err(
847 "standard bounded-coefficient posterior: cannot sample from an empty coefficient vector"
848 .to_string(),
849 );
850 }
851 let user_hessian =
856 explicit_fit_hessian_for_whitening(&fit, p, "saved standard bounded-coefficient model")?;
857 let sqrt_cov_scale =
864 sampling_sqrt_covariance_scale(&fit, "standard bounded-coefficient posterior")?;
865 let n_total = cfg.n_samples.saturating_mul(cfg.n_chains);
866 let samples = gam_models::fit_orchestration::drivers::sample_bounded_latent_posterior_internal(
867 &mode,
868 user_hessian,
869 bounded_columns,
870 n_total,
871 sqrt_cov_scale,
872 chain_stream_seed(cfg.seed, 0, 0xB0DD_ED5E_ED90_1A7Cu64),
873 )
874 .map_err(|e| format!("standard bounded-coefficient posterior sampling failed: {e}"))?;
875
876 let posterior_mean = samples
877 .mean_axis(ndarray::Axis(0))
878 .unwrap_or_else(|| Array1::<f64>::zeros(p));
879 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
880
881 Ok(NutsResult {
882 samples,
883 posterior_mean,
884 posterior_std,
885 rhat: 1.0,
886 ess: n_total as f64,
887 converged: true,
888 })
889}
890
891fn sample_standard_truncated(
908 fit: &gam_solve::estimate::UnifiedFitResult,
909 cfg: &NutsConfig,
910) -> Result<NutsResult, String> {
911 validate_nuts_config(cfg).map_err(String::from)?;
912 let geometry = fit.geometry.as_ref().ok_or_else(|| {
918 "standard constrained-coefficient posterior: saved fit has no coefficient geometry"
919 .to_string()
920 })?;
921 let constrained = geometry.constrained_posterior.as_ref().ok_or_else(|| {
922 "standard constrained-coefficient posterior: saved fit has constraints but no persisted \
923 inequality-truncated posterior identity; refit with the current schema"
924 .to_string()
925 })?;
926 let mode = constrained.mode.clone();
927 let center = constrained.unconstrained_center.clone();
928 let p = mode.len();
929 if p == 0 {
930 return Err(
931 "standard constrained-coefficient posterior: cannot sample from an empty coefficient \
932 vector"
933 .to_string(),
934 );
935 }
936 let penalized_hessian =
945 explicit_fit_hessian_for_whitening(&fit, p, "saved standard constrained model")?;
946 let sqrt_cov_scale =
947 sampling_sqrt_covariance_scale(&fit, "standard constrained-coefficient posterior")?;
948
949 let active_samples = crate::truncated_gaussian::sample_truncated_gaussian_posterior(
950 ¢er,
951 &mode,
952 &penalized_hessian,
953 sqrt_cov_scale,
954 &constrained.constraints,
955 cfg.n_samples,
956 cfg.n_chains,
957 chain_stream_seed(cfg.seed, 0, 0x7290_C047_5D6E_B14Du64),
958 )?;
959 let mut chains = ndarray::Array3::<f64>::zeros((cfg.n_chains, cfg.n_samples, p));
966 for chain in 0..cfg.n_chains {
967 for draw in 0..cfg.n_samples {
968 let row = chain * cfg.n_samples + draw;
969 for j in 0..p {
970 chains[(chain, draw, j)] = active_samples[(row, j)];
971 }
972 }
973 }
974 let (rhat, ess) = super::hmc_io::compute_split_rhat_and_ess(&chains);
975 let converged = rhat < 1.1 && ess > 100.0;
976
977 let samples =
982 lift_active_samples_to_saved(active_samples, &geometry.coefficient_gauge)?;
983 let raw_p = samples.ncols();
984 if raw_p != fit.beta.len() {
985 return Err(format!(
986 "standard constrained-coefficient posterior: gauge lifted {raw_p} coefficients but \
987 the saved fit reports {}",
988 fit.beta.len(),
989 ));
990 }
991 let posterior_mean = samples
992 .mean_axis(ndarray::Axis(0))
993 .unwrap_or_else(|| Array1::<f64>::zeros(raw_p));
994 let posterior_std = samples.std_axis(ndarray::Axis(0), 1.0);
995
996 Ok(NutsResult {
997 samples,
998 posterior_mean,
999 posterior_std,
1000 rhat,
1001 ess,
1002 converged,
1003 })
1004}
1005
1006fn lift_active_samples_to_saved(
1007 active_samples: Array2<f64>,
1008 gauge: &gam_problem::gauge::Gauge,
1009) -> Result<Array2<f64>, String> {
1010 gauge
1011 .validate()
1012 .map_err(|reason| format!("constrained posterior gauge is invalid: {reason}"))?;
1013 if active_samples.ncols() != gauge.reduced_total() {
1014 return Err(format!(
1015 "constrained posterior produced {} active coefficients but the gauge expects {}",
1016 active_samples.ncols(),
1017 gauge.reduced_total(),
1018 ));
1019 }
1020 if gauge.is_identity() {
1021 return Ok(active_samples);
1022 }
1023 let mut saved = active_samples.dot(&gauge.t_full.t());
1024 for mut draw in saved.rows_mut() {
1025 draw += &gauge.affine_shift;
1026 }
1027 validate_all_finite(
1028 "saved-coordinate constrained posterior draws",
1029 saved.iter().copied(),
1030 )?;
1031 Ok(saved)
1032}
1033
1034fn sample_survival(
1035 model: &SavedModel,
1036 data: ArrayView2<'_, f64>,
1037 col_map: &HashMap<String, usize>,
1038 training_headers: Option<&Vec<String>>,
1039 cfg: &NutsConfig,
1040) -> Result<NutsResult, String> {
1041 let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1042 if matches!(
1043 saved_likelihood_mode,
1044 SurvivalLikelihoodMode::Latent
1045 | SurvivalLikelihoodMode::LatentBinary
1046 | SurvivalLikelihoodMode::LocationScale
1047 ) {
1048 return laplace_gaussian_fallback(model, cfg, "survival posterior fallback");
1049 }
1050 let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1059 let exit_col = time_cols.exit_col;
1060 let eventname = model
1061 .survival_event
1062 .as_ref()
1063 .ok_or_else(|| "survival model missing event column metadata".to_string())?;
1064 let event_col = resolve_role_col(col_map, eventname, "event")?;
1065 let termspec = resolve_termspec_for_prediction(
1066 &model.resolved_termspec,
1067 training_headers,
1068 col_map,
1069 "resolved_termspec",
1070 )?;
1071 let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1072 let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1073 let cov_design = build_term_collection_design(cov_input, &termspec)
1074 .map_err(|e| format!("failed to build survival design: {e}"))?;
1075 let n = data.nrows();
1076 let p_cov = cov_design.design.ncols();
1077 let mut age_entry = Array1::<f64>::zeros(n);
1078 let mut age_exit = Array1::<f64>::zeros(n);
1079 let mut event_target = Array1::<u8>::zeros(n);
1080 let event_competing = Array1::<u8>::zeros(n);
1081 let weights = Array1::<f64>::ones(n);
1082 for i in 0..n {
1083 let (t0, t1) = normalize_survival_time_pair(
1084 time_cols.row_entry_time(data, i),
1085 data[[i, exit_col]],
1086 i,
1087 )?;
1088 age_entry[i] = t0;
1089 age_exit[i] = t1;
1090 event_target[i] = if data[[i, event_col]] >= 0.5 { 1 } else { 0 };
1091 }
1092 let time_cfg = load_survival_time_basis_config_from_model(model)?;
1093 let time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
1094 let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
1095 &time_build.basisname,
1096 time_build.degree,
1097 time_build.knots.as_ref(),
1098 time_build.keep_cols.as_ref(),
1099 time_build.smooth_lambda,
1100 )?;
1101 let time_anchor_row = if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1102 let time_anchor = model
1103 .survival_time_anchor
1104 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1105 Some(evaluate_survival_time_basis_row(
1106 time_anchor,
1107 &resolved_time_cfg,
1108 )?)
1109 } else {
1110 None
1111 };
1112 let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
1113 let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
1114 build_survival_time_offsets_for_likelihood(
1115 &age_entry,
1116 &age_exit,
1117 &baseline_cfg,
1118 saved_likelihood_mode,
1119 None,
1120 )?;
1121 if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
1122 let time_anchor = model
1123 .survival_time_anchor
1124 .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
1125 add_survival_time_derivative_guard_offset(
1126 &age_entry,
1127 &age_exit,
1128 time_anchor,
1129 survival_derivative_guard_for_likelihood(saved_likelihood_mode),
1130 &mut eta_offset_entry,
1131 &mut eta_offset_exit,
1132 &mut derivative_offset_exit,
1133 )?;
1134 }
1135 eta_offset_entry += &cov_design.affine_offset;
1139 eta_offset_exit += &cov_design.affine_offset;
1140 let saved_timewiggle = saved_baseline_timewiggle_components(
1141 &eta_offset_entry,
1142 &eta_offset_exit,
1143 &derivative_offset_exit,
1144 model,
1145 )?;
1146 let p_time = time_build.x_exit_time.ncols();
1147 let p_timewiggle = saved_timewiggle
1148 .as_ref()
1149 .map(|(_, exit, _)| exit.ncols())
1150 .unwrap_or(0);
1151 let p = p_time
1152 .checked_add(p_timewiggle)
1153 .and_then(|width| width.checked_add(p_cov))
1154 .ok_or_else(|| "saved survival sampler design width overflow".to_string())?;
1155 let survival_design_reservation = MemoryGovernor::global()
1160 .try_reserve_dense_f64_copies(
1161 n,
1162 p,
1163 SURVIVAL_DESIGN_LIVE_COPIES,
1164 "saved survival sampler design live set",
1165 )
1166 .map_err(|error| error.to_string())?;
1167 let mut x_entry = Array2::<f64>::zeros((n, p));
1168 let mut x_exit = Array2::<f64>::zeros((n, p));
1169 let mut x_derivative = Array2::<f64>::zeros((n, p));
1170 let row_chunk_target_bytes = ResourcePolicy::default_library().row_chunk_target_bytes;
1171 if p_time > 0 {
1172 stream_design_into(
1173 &time_build.x_entry_time,
1174 x_entry.slice_mut(s![.., ..p_time]),
1175 row_chunk_target_bytes,
1176 "saved survival entry-time design",
1177 )?;
1178 stream_design_into(
1179 &time_build.x_exit_time,
1180 x_exit.slice_mut(s![.., ..p_time]),
1181 row_chunk_target_bytes,
1182 "saved survival exit-time design",
1183 )?;
1184 stream_design_into(
1185 &time_build.x_derivative_time,
1186 x_derivative.slice_mut(s![.., ..p_time]),
1187 row_chunk_target_bytes,
1188 "saved survival derivative-time design",
1189 )?;
1190 if let Some(anchor_row) = time_anchor_row.as_ref() {
1191 if anchor_row.len() != p_time {
1192 return Err(format!(
1193 "survival time anchoring column mismatch: design={p_time}, anchor={}",
1194 anchor_row.len(),
1195 ));
1196 }
1197 for mut row in x_entry.slice_mut(s![.., ..p_time]).rows_mut() {
1198 row -= &anchor_row.view();
1199 }
1200 for mut row in x_exit.slice_mut(s![.., ..p_time]).rows_mut() {
1201 row -= &anchor_row.view();
1202 }
1203 }
1204 }
1205 if let Some((entry_w, exit_w, deriv_w)) = saved_timewiggle.as_ref()
1206 && p_timewiggle > 0
1207 {
1208 x_entry
1209 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1210 .assign(entry_w);
1211 x_exit
1212 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1213 .assign(exit_w);
1214 x_derivative
1215 .slice_mut(s![.., p_time..(p_time + p_timewiggle)])
1216 .assign(deriv_w);
1217 }
1218 if p_cov > 0 {
1219 let cov_range = (p_time + p_timewiggle)..(p_time + p_timewiggle + p_cov);
1220 stream_design_into(
1221 &cov_design.design,
1222 x_entry.slice_mut(s![.., cov_range.clone()]),
1223 row_chunk_target_bytes,
1224 "saved survival covariate design",
1225 )?;
1226 x_exit
1227 .slice_mut(s![.., cov_range.clone()])
1228 .assign(&x_entry.slice(s![.., cov_range]));
1229 }
1230 drop(cov_design);
1233 let mut penalty_blocks: Vec<PenaltyBlock> = Vec::new();
1234 for (idx, s) in time_build.penalties.iter().enumerate() {
1235 if s.nrows() == p_time && s.ncols() == p_time {
1236 penalty_blocks.push(PenaltyBlock {
1237 matrix: s.clone(),
1238 lambda: time_build
1239 .smooth_lambda
1240 .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1241 range: 0..p_time,
1242 nullspace_dim: time_build.nullspace_dims.get(idx).copied().unwrap_or(0),
1243 });
1244 }
1245 }
1246 let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
1247 if let Some((_, exit_w, _)) = saved_timewiggle.as_ref() {
1248 let start = p_time;
1249 let end = start + exit_w.ncols();
1250 let wiggle_lambda_offset = penalty_blocks.len();
1251 let wiggle_cfg = saved_baseline_timewiggle_spec(model)?.ok_or_else(|| {
1252 "saved baseline-timewiggle model missing baseline-timewiggle metadata".to_string()
1253 })?;
1254 let wiggle_degree = wiggle_cfg.degree;
1255 let wiggle_knots =
1256 Array1::from_vec(model.baseline_timewiggle_knots.clone().ok_or_else(|| {
1257 "saved baseline-timewiggle model missing baseline_timewiggle_knots".to_string()
1258 })?);
1259 let mut seed = Array1::<f64>::zeros(2 * n);
1260 for i in 0..n {
1261 seed[i] = eta_offset_entry[i];
1262 seed[n + i] = eta_offset_exit[i];
1263 }
1264 let (primary_order, extra_orders) =
1265 split_wiggle_penalty_orders(2, &wiggle_cfg.penalty_orders)?;
1266 let mut block = buildwiggle_block_input_from_knots(
1267 seed.view(),
1268 &wiggle_knots,
1269 wiggle_degree,
1270 primary_order,
1271 wiggle_cfg.double_penalty,
1272 )?;
1273 append_selected_wiggle_function_penalties(
1274 &mut block,
1275 &wiggle_knots,
1276 wiggle_degree,
1277 &extra_orders,
1278 )
1279 .map_err(|e| format!("baseline-timewiggle penalty reconstruction failed: {e}"))?;
1280 for (widx, s) in block.penalties.iter().enumerate() {
1281 let s = match s {
1282 gam_solve::estimate::PenaltySpec::Block { local, .. } => local,
1283 gam_solve::estimate::PenaltySpec::Dense(m)
1284 | gam_solve::estimate::PenaltySpec::DenseWithMean { matrix: m, .. } => m,
1285 };
1286 if s.nrows() == exit_w.ncols() && s.ncols() == exit_w.ncols() {
1287 penalty_blocks.push(PenaltyBlock {
1288 matrix: s.clone(),
1289 lambda: time_build
1290 .smooth_lambda
1291 .unwrap_or(DEFAULT_RECONSTRUCTED_SMOOTH_LAMBDA),
1292 range: start..end,
1293 nullspace_dim: block.nullspace_dims.get(widx).copied().unwrap_or(0),
1294 });
1295 }
1296 }
1297 for (local_idx, block_penalty) in penalty_blocks[wiggle_lambda_offset..]
1298 .iter_mut()
1299 .enumerate()
1300 {
1301 if let Some(&lam) = fit_saved.lambdas.get(wiggle_lambda_offset + local_idx) {
1302 block_penalty.lambda = lam;
1303 }
1304 }
1305 }
1306 drop(saved_timewiggle);
1309 let ridge_lambda = model.survivalridge_lambda.ok_or_else(|| {
1310 "saved survival model is missing survivalridge_lambda; refusing to \
1311 pick a load-time default (the historical 1e-4 fallback silently \
1312 disagreed with the 1e-6 fit-time default). Refit."
1313 .to_string()
1314 })?;
1315 let ridge_range_start = if time_build.basisname == "linear" && !model.has_baseline_time_wiggle()
1316 {
1317 1
1318 } else {
1319 0
1320 };
1321 drop(time_build);
1324 if ridge_lambda > 0.0 && p > ridge_range_start {
1325 let dim = p - ridge_range_start;
1326 let mut ridge = Array2::<f64>::zeros((dim, dim));
1327 for d in 0..dim {
1328 ridge[[d, d]] = 1.0;
1329 }
1330 penalty_blocks.push(PenaltyBlock {
1331 matrix: ridge,
1332 lambda: ridge_lambda,
1333 range: ridge_range_start..p,
1334 nullspace_dim: 0,
1335 });
1336 }
1337 for (idx, block) in penalty_blocks.iter_mut().enumerate() {
1338 if let Some(&lam) = fit_saved.lambdas.get(idx) {
1339 block.lambda = lam;
1340 }
1341 }
1342 let penalties = PenaltyBlocks::new(penalty_blocks);
1343 let survivalspec = match model
1344 .survivalspec
1345 .as_deref()
1346 .unwrap_or("net")
1347 .to_ascii_lowercase()
1348 .as_str()
1349 {
1350 "net" => SurvivalSpec::Net,
1351 "crude" => {
1352 return Err("saved survival spec 'crude' is not supported by the one-hazard survival engine; refit or export a net survival model for this path"
1353 .to_string());
1354 }
1355 other => {
1356 return Err(format!("unsupported saved survival spec '{other}'"));
1357 }
1358 };
1359 let monotonicity = SurvivalMonotonicityPenalty { tolerance: 0.0 };
1360 let mut model_surv = royston_parmar::working_model_from_flattened(
1361 penalties.clone(),
1362 monotonicity,
1363 survivalspec,
1364 RoystonParmarInputs {
1365 age_entry: age_entry.view(),
1366 age_exit: age_exit.view(),
1367 event_target: event_target.view(),
1368 event_competing: event_competing.view(),
1369 weights: weights.view(),
1370 x_entry: x_entry.view(),
1371 x_exit: x_exit.view(),
1372 x_derivative: x_derivative.view(),
1373 monotonicity_constraint_rows: None,
1374 monotonicity_constraint_offsets: None,
1375 eta_offset_entry: Some(eta_offset_entry.view()),
1376 eta_offset_exit: Some(eta_offset_exit.view()),
1377 derivative_offset_exit: Some(derivative_offset_exit.view()),
1378 },
1379 )
1380 .map_err(|e| format!("failed to construct survival model: {e}"))?;
1381 if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull {
1382 model_surv
1383 .set_structural_monotonicity(true, p_time + p_timewiggle)
1384 .map_err(|e| format!("failed to enable structural monotonicity: {e}"))?;
1385 }
1386 let beta0 = fit_saved.beta.clone();
1387 let survival_hessian_reservation = MemoryGovernor::global()
1388 .try_reserve_dense_f64(p, p, "saved survival sampler Hessian")
1389 .map_err(|error| error.to_string())?;
1390 let hessian = {
1391 let state = model_surv
1392 .update_state(&beta0)
1393 .map_err(|e| format!("failed to evaluate survival state: {e}"))?;
1394 match state.hessian {
1395 gam_linalg::matrix::SymmetricMatrix::Dense(hessian) => hessian,
1398 gam_linalg::matrix::SymmetricMatrix::Sparse(hessian) => {
1401 gam_linalg::matrix::SymmetricMatrix::Sparse(hessian).to_dense()
1402 }
1403 }
1404 };
1405 drop(model_surv);
1408 let result = run_survival_nuts_sampling_flattened(
1409 SurvivalFlatInputs {
1410 age_entry: age_entry.view(),
1411 age_exit: age_exit.view(),
1412 event_target: event_target.view(),
1413 event_competing: event_competing.view(),
1414 weights: weights.view(),
1415 x_entry: x_entry.view(),
1416 x_exit: x_exit.view(),
1417 x_derivative: x_derivative.view(),
1418 eta_offset_entry: Some(eta_offset_entry.view()),
1419 eta_offset_exit: Some(eta_offset_exit.view()),
1420 derivative_offset_exit: Some(derivative_offset_exit.view()),
1421 },
1422 penalties,
1423 monotonicity,
1424 survivalspec,
1425 saved_likelihood_mode != SurvivalLikelihoodMode::Weibull,
1426 p_time + p_timewiggle,
1427 beta0.view(),
1428 hessian.view(),
1429 cfg,
1430 )
1431 .map_err(|e| format!("survival NUTS sampling failed: {e}"));
1432 drop(survival_hessian_reservation);
1433 drop(survival_design_reservation);
1434 result
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439 use super::*;
1440 use gam_linalg::matrix::{DenseDesignMatrix, DenseDesignOperator, LinearOperator};
1441 use gam_problem::types::LikelihoodScaleMetadata;
1442
1443 #[test]
1444 fn link_wiggle_dispatch_requires_and_consumes_the_persisted_cone() {
1445 assert_eq!(
1446 standard_posterior_route(false, false, true, true, true)
1447 .expect("saved link-wiggle cone"),
1448 StandardPosteriorRoute::InequalityTruncated,
1449 "a Gaussian link wiggle must reach the cone before the closed-form shortcut",
1450 );
1451 assert_eq!(
1452 standard_posterior_route(false, false, true, true, false)
1453 .expect("saved link-wiggle cone"),
1454 StandardPosteriorRoute::InequalityTruncated,
1455 "a non-Gaussian link wiggle consumes the same fitted Laplace posterior",
1456 );
1457 let missing = standard_posterior_route(false, false, true, false, true)
1458 .expect_err("a link wiggle without persisted constraint geometry must refuse");
1459 assert!(missing.contains("no persisted inequality-truncated posterior identity"));
1460 assert_eq!(
1461 standard_posterior_route(false, false, false, false, true)
1462 .expect("unconstrained Gaussian"),
1463 StandardPosteriorRoute::GaussianClosedForm,
1464 "the unconstrained Gaussian fast path must remain unchanged",
1465 );
1466 }
1467
1468 #[test]
1469 fn constrained_draw_lift_uses_the_saved_affine_gauge() {
1470 let active = ndarray::array![[1.0, 2.0], [-3.0, 4.0]];
1471 let gauge = gam_problem::gauge::Gauge::from_block_transform_with_shift(
1472 ndarray::array![[1.0, 0.0], [0.0, 2.0], [1.0, -1.0]],
1473 ndarray::array![0.5, -1.0, 3.0],
1474 );
1475 let saved =
1476 lift_active_samples_to_saved(active, &gauge).expect("valid affine sample lift");
1477 assert_eq!(
1478 saved,
1479 ndarray::array![[1.5, 3.0, 2.0], [-2.5, 7.0, -4.0]]
1480 );
1481 }
1482
1483 struct ChunkOnlySampleDesign {
1484 values: Array2<f64>,
1485 row_chunk_calls: std::sync::atomic::AtomicUsize,
1486 fail_rows: bool,
1487 }
1488
1489 impl LinearOperator for ChunkOnlySampleDesign {
1490 fn nrows(&self) -> usize {
1491 self.values.nrows()
1492 }
1493
1494 fn ncols(&self) -> usize {
1495 self.values.ncols()
1496 }
1497
1498 fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
1499 self.values.dot(vector)
1500 }
1501
1502 fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
1503 self.values.t().dot(vector)
1504 }
1505
1506 fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
1507 if weights.len() != self.nrows() {
1508 return Err(format!(
1509 "weight vector has {} entries for {} design rows",
1510 weights.len(),
1511 self.nrows()
1512 ));
1513 }
1514 Ok(Array2::zeros((self.ncols(), self.ncols())))
1515 }
1516 }
1517
1518 impl DenseDesignOperator for ChunkOnlySampleDesign {
1519 fn row_chunk_into(
1520 &self,
1521 rows: std::ops::Range<usize>,
1522 mut out: ndarray::ArrayViewMut2<'_, f64>,
1523 ) -> Result<(), gam_runtime::resource::MatrixMaterializationError> {
1524 self.row_chunk_calls
1525 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1526 if self.fail_rows {
1527 return Err(
1528 gam_runtime::resource::MatrixMaterializationError::MissingRowChunk {
1529 context: "ChunkOnlySampleDesign test refusal",
1530 },
1531 );
1532 }
1533 out.assign(&self.values.slice(s![rows, ..]));
1534 Ok(())
1535 }
1536
1537 fn to_dense(&self) -> Array2<f64> {
1538 panic!("stream_design_into must never call to_dense")
1539 }
1540 }
1541
1542 #[test]
1543 fn survival_design_streaming_uses_row_chunks_and_target_slice() {
1544 let values = Array2::from_shape_fn((5, 3), |(i, j)| (10 * i + j) as f64);
1545 let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1546 values: values.clone(),
1547 row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1548 fail_rows: false,
1549 });
1550 let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(
1551 std::sync::Arc::clone(&operator),
1552 ));
1553 let mut assembled = Array2::<f64>::from_elem((5, 5), -1.0);
1554
1555 stream_design_into(
1556 &design,
1557 assembled.slice_mut(s![.., 1..4]),
1558 2 * 3 * std::mem::size_of::<f64>(),
1559 "streaming regression",
1560 )
1561 .expect("row-chunk assembly succeeds");
1562
1563 assert_eq!(assembled.slice(s![.., 1..4]), values.view());
1564 assert!(assembled.column(0).iter().all(|&value| value == -1.0));
1565 assert!(assembled.column(4).iter().all(|&value| value == -1.0));
1566 assert_eq!(
1567 operator
1568 .row_chunk_calls
1569 .load(std::sync::atomic::Ordering::SeqCst),
1570 3,
1571 );
1572 }
1573
1574 #[test]
1575 fn survival_design_streaming_propagates_typed_row_refusal() {
1576 let operator = std::sync::Arc::new(ChunkOnlySampleDesign {
1577 values: Array2::zeros((2, 2)),
1578 row_chunk_calls: std::sync::atomic::AtomicUsize::new(0),
1579 fail_rows: true,
1580 });
1581 let design = gam_linalg::matrix::DesignMatrix::Dense(DenseDesignMatrix::from(operator));
1582 let mut assembled = Array2::<f64>::zeros((2, 2));
1583
1584 let error = stream_design_into(
1585 &design,
1586 assembled.view_mut(),
1587 std::mem::size_of::<f64>(),
1588 "streaming refusal regression",
1589 )
1590 .expect_err("row-chunk refusal must remain fallible");
1591
1592 assert!(error.contains("streaming refusal regression"));
1593 assert!(error.contains("ChunkOnlySampleDesign test refusal"));
1594 }
1595
1596 #[test]
1607 fn refresh_negbin_theta_reads_theta_hat_not_seed() {
1608 let mut likelihood = LikelihoodSpec::negative_binomial_log(1.0);
1611 let scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.97 };
1612
1613 refresh_negbin_theta_for_sampling(&mut likelihood, scale);
1614
1615 match likelihood.response {
1616 ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
1617 theta, 2.97,
1618 "NB NUTS must sample at theta_hat (#1463), not the seed theta=1.0"
1619 ),
1620 other => panic!("expected NegativeBinomial response, got {other:?}"),
1621 }
1622 }
1623
1624 #[test]
1628 fn refresh_negbin_theta_fixed_theta_is_preserved() {
1629 let mut likelihood = LikelihoodSpec::negative_binomial_log_fixed(4.25);
1630 let scale = LikelihoodScaleMetadata::FixedNegBinTheta { theta: 4.25 };
1631
1632 refresh_negbin_theta_for_sampling(&mut likelihood, scale);
1633
1634 match likelihood.response {
1635 ResponseFamily::NegativeBinomial { theta, theta_fixed } => {
1636 assert_eq!(theta, 4.25, "fixed NB theta must survive the refresh");
1637 assert!(theta_fixed, "theta_fixed flag must be preserved");
1638 }
1639 other => panic!("expected NegativeBinomial response, got {other:?}"),
1640 }
1641 }
1642
1643 #[test]
1647 fn refresh_negbin_theta_falls_back_to_seed_when_unfitted() {
1648 let mut likelihood = LikelihoodSpec::negative_binomial_log(3.5);
1649 refresh_negbin_theta_for_sampling(
1651 &mut likelihood,
1652 LikelihoodScaleMetadata::ProfiledGaussian,
1653 );
1654
1655 match likelihood.response {
1656 ResponseFamily::NegativeBinomial { theta, .. } => assert_eq!(
1657 theta, 3.5,
1658 "with no fitted theta the NB seed must be kept verbatim"
1659 ),
1660 other => panic!("expected NegativeBinomial response, got {other:?}"),
1661 }
1662 }
1663
1664 #[test]
1668 fn refresh_negbin_theta_leaves_non_nb_families_untouched() {
1669 let mut poisson = LikelihoodSpec::poisson_log();
1670 let before = poisson.response.clone();
1671 refresh_negbin_theta_for_sampling(
1672 &mut poisson,
1673 LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 9.0 },
1674 );
1675 assert_eq!(
1676 poisson.response, before,
1677 "Poisson response must be untouched by the NB theta refresh"
1678 );
1679 }
1680}