1use super::family::*;
2use super::gradient_paths::*;
3use super::hessian_paths::{new_cell_moment_cache_stats, new_cell_moment_lru_cache};
4use super::install_flex::validate_spec;
5use super::*;
6use crate::marginal_slope_orthogonal::influence_absorber_log_lambda;
7use faer::Side;
8use gam_linalg::faer_ndarray::{FaerEigh, fast_ab, fast_atb, fast_xt_diag_x};
9
10pub(crate) const BMS_PROBIT_SEPARATION_ETA_INF: f64 = 35.0;
25
26pub(super) const GAUGE_PRIORITY_ANCHOR: u8 = 200;
41pub(super) const GAUGE_PRIORITY_MARGINAL: u8 = 150;
44pub(super) const GAUGE_PRIORITY_LOGSLOPE: u8 = 120;
46pub(super) const GAUGE_PRIORITY_CANDIDATE_FLEX: u8 = 100;
49pub(super) const GAUGE_PRIORITY_SCORE_WARP_DEV: u8 = 80;
52pub(super) const GAUGE_PRIORITY_DEVIATION_DEFAULT: u8 = 70;
56pub(super) const GAUGE_PRIORITY_LINK_DEV: u8 = 60;
58
59pub(crate) const EXACT_SPATIAL_OUTER_TOL_FLOOR: f64 = 1e-6;
65
66pub struct BmsMarginalJacobian {
105 pub marginal_dense: Arc<Array2<f64>>,
107 pub logslope_dense: Arc<Array2<f64>>,
109 pub offset_m: Array1<f64>,
110 pub offset_s: Array1<f64>,
111 pub p_marginal: usize,
113}
114
115impl BmsMarginalJacobian {
116 pub fn new(
117 marginal_dense: Arc<Array2<f64>>,
118 logslope_dense: Arc<Array2<f64>>,
119 offset_m: Array1<f64>,
120 offset_s: Array1<f64>,
121 p_marginal: usize,
122 ) -> Self {
123 Self {
124 marginal_dense,
125 logslope_dense,
126 offset_m,
127 offset_s,
128 p_marginal,
129 }
130 }
131}
132
133impl BlockEffectiveJacobian for BmsMarginalJacobian {
134 fn effective_jacobian_rows(
135 &self,
136 state: &FamilyLinearizationState<'_>,
137 rows: std::ops::Range<usize>,
138 ) -> Result<Array2<f64>, String> {
139 let beta = state.beta;
140 let s = state.probit_frailty_scale;
141 let p_m = self.p_marginal;
142 let p_s_block = self.logslope_dense.ncols();
143 let beta_s_raw = if beta.len() > p_m {
144 &beta[p_m..]
145 } else {
146 &[][..]
147 };
148 let p_s_use = p_s_block.min(beta_s_raw.len());
149 let beta_s = &beta_s_raw[..p_s_use];
150 let n = self.marginal_dense.nrows();
151 let rows = rows.start.min(n)..rows.end.min(n);
152 let p_block = self.marginal_dense.ncols();
153
154 let mut out = Array2::<f64>::zeros((rows.end - rows.start, p_block));
164 for i in rows.clone() {
165 let g_i = self.offset_s[i]
166 + self
167 .logslope_dense
168 .row(i)
169 .slice(ndarray::s![..p_s_use])
170 .dot(&ArrayView1::from(beta_s));
171 let sg = s * g_i;
172 let c_i = (1.0 + sg * sg).sqrt();
173 let m_row = self.marginal_dense.row(i);
175 out.row_mut(i - rows.start).assign(&m_row.mapv(|x| c_i * x));
176 }
177 Ok(out)
178 }
179
180 fn n_outputs(&self) -> usize {
181 1
182 }
183
184 fn locks_raw_width_reduction(&self) -> bool {
185 true
195 }
196}
197
198pub struct BmsLogslopeJacobian {
210 pub marginal_dense: Arc<Array2<f64>>,
212 pub logslope_dense: Arc<Array2<f64>>,
214 pub offset_m: Array1<f64>,
215 pub offset_s: Array1<f64>,
216 pub z: Arc<Array1<f64>>,
217 pub p_marginal: usize,
219}
220
221impl BmsLogslopeJacobian {
222 pub fn new(
223 marginal_dense: Arc<Array2<f64>>,
224 logslope_dense: Arc<Array2<f64>>,
225 offset_m: Array1<f64>,
226 offset_s: Array1<f64>,
227 z: Arc<Array1<f64>>,
228 p_marginal: usize,
229 ) -> Self {
230 Self {
231 marginal_dense,
232 logslope_dense,
233 offset_m,
234 offset_s,
235 z,
236 p_marginal,
237 }
238 }
239}
240
241impl BlockEffectiveJacobian for BmsLogslopeJacobian {
242 fn effective_jacobian_rows(
243 &self,
244 state: &FamilyLinearizationState<'_>,
245 rows: std::ops::Range<usize>,
246 ) -> Result<Array2<f64>, String> {
247 let beta = state.beta;
248 let s = state.probit_frailty_scale;
249 let p_m = self.p_marginal;
250 let p_m_use = p_m.min(beta.len());
251 let beta_m = &beta[..p_m_use];
252 let beta_s_raw = if beta.len() > p_m {
253 &beta[p_m..]
254 } else {
255 &[][..]
256 };
257 let p_s_block = self.logslope_dense.ncols();
258 let p_s_use = p_s_block.min(beta_s_raw.len());
259 let beta_s = &beta_s_raw[..p_s_use];
260 let n = self.logslope_dense.nrows();
261 let rows = rows.start.min(n)..rows.end.min(n);
262
263 let mut out = Array2::<f64>::zeros((rows.end - rows.start, p_s_block));
276 for i in rows.clone() {
277 let q_i = self.offset_m[i]
278 + self
279 .marginal_dense
280 .row(i)
281 .slice(ndarray::s![..p_m_use])
282 .dot(&ArrayView1::from(beta_m));
283 let g_i = self.offset_s[i]
284 + self
285 .logslope_dense
286 .row(i)
287 .slice(ndarray::s![..p_s_use])
288 .dot(&ArrayView1::from(beta_s));
289 let sg = s * g_i;
290 let c_i = (1.0 + sg * sg).sqrt();
291 let z_i = self.z[i];
292 let factor = q_i * s * s * g_i / c_i + s * z_i;
294 let g_row = self.logslope_dense.row(i);
296 out.row_mut(i - rows.start)
297 .assign(&g_row.mapv(|x| factor * x));
298 }
299 Ok(out)
300 }
301
302 fn n_outputs(&self) -> usize {
303 1
304 }
305
306 fn locks_raw_width_reduction(&self) -> bool {
307 true
318 }
319}
320
321pub(crate) fn widen_marginal_dense_with_influence(
331 marginal_dense: &Arc<Array2<f64>>,
332 influence_columns: Option<&Array2<f64>>,
333) -> Result<Arc<Array2<f64>>, String> {
334 let Some(z_infl) = influence_columns else {
335 return Ok(Arc::clone(marginal_dense));
336 };
337 let n = marginal_dense.nrows();
338 if z_infl.nrows() != n {
339 return Err(format!(
340 "influence block: residualised columns have {} rows, marginal design has {n}",
341 z_infl.nrows()
342 ));
343 }
344 let p_m = marginal_dense.ncols();
345 let p1 = z_infl.ncols();
346 let mut widened = Array2::<f64>::zeros((n, p_m + p1));
347 widened
348 .slice_mut(s![.., ..p_m])
349 .assign(marginal_dense.as_ref());
350 widened.slice_mut(s![.., p_m..]).assign(z_infl);
351 Ok(Arc::new(widened))
352}
353
354pub(crate) const LOGSLOPE_REDUCED_BASIS_RELATIVE_TOL: f64 = 1.0e-6;
362
363#[derive(Debug, Clone)]
415pub(super) struct ReducedLogslopeReparam {
416 transform: Array2<f64>,
419}
420
421impl ReducedLogslopeReparam {
422 #[inline]
424 pub(super) fn original_cols(&self) -> usize {
425 self.transform.nrows()
426 }
427
428 #[inline]
430 pub(super) fn reduced_cols(&self) -> usize {
431 self.transform.ncols()
432 }
433
434 pub(super) fn recover_original_logslope_beta(
438 &self,
439 beta_reduced: &Array1<f64>,
440 ) -> Result<Array1<f64>, String> {
441 if beta_reduced.len() != self.reduced_cols() {
442 return Err(format!(
443 "reduced logslope reparam: β' length ({}) != reduced width ({})",
444 beta_reduced.len(),
445 self.reduced_cols()
446 ));
447 }
448 Ok(self.transform.dot(beta_reduced))
449 }
450}
451
452fn build_reduced_logslope_reparam(
463 marginal_design: &TermCollectionDesign,
464 logslope_design: &TermCollectionDesign,
465 z: &Array1<f64>,
466 row_metric: &Array1<f64>,
467 marginal_offset: &Array1<f64>,
468 logslope_offset: &Array1<f64>,
469 marginal_baseline: f64,
470 logslope_baseline: f64,
471 probit_scale: f64,
472) -> Result<Option<ReducedLogslopeReparam>, String> {
473 let marginal = marginal_design
474 .design
475 .try_to_dense_arc("build_reduced_logslope_reparam::marginal")?;
476 let logslope = logslope_design
477 .design
478 .try_to_dense_arc("build_reduced_logslope_reparam::logslope")?;
479 let n = marginal.nrows();
480 if logslope.nrows() != n
481 || z.len() != n
482 || row_metric.len() != n
483 || marginal_offset.len() != n
484 || logslope_offset.len() != n
485 {
486 return Err(format!(
487 "reduced logslope reparam row mismatch: marginal={}, logslope={}, z={}, row_metric={}, marginal_offset={}, logslope_offset={}",
488 marginal.nrows(),
489 logslope.nrows(),
490 z.len(),
491 row_metric.len(),
492 marginal_offset.len(),
493 logslope_offset.len(),
494 ));
495 }
496 let p_m = marginal.ncols();
497 let p_g = logslope.ncols();
498 if p_m == 0 || p_g == 0 {
499 return Ok(None);
500 }
501 if !marginal_baseline.is_finite()
502 || !logslope_baseline.is_finite()
503 || !probit_scale.is_finite()
504 || probit_scale <= 0.0
505 || z.iter().any(|v| !v.is_finite())
506 || row_metric.iter().any(|v| !v.is_finite() || *v < 0.0)
507 || marginal_offset.iter().any(|v| !v.is_finite())
508 || logslope_offset.iter().any(|v| !v.is_finite())
509 {
510 return Err(
511 "reduced logslope reparam requires finite pilot geometry and finite non-negative row metric"
512 .to_string(),
513 );
514 }
515
516 match reduced_logslope_transform_effective(
527 marginal.view(),
528 logslope.view(),
529 z,
530 row_metric,
531 marginal_offset,
532 logslope_offset,
533 marginal_baseline,
534 logslope_baseline,
535 probit_scale,
536 )? {
537 ReducedLogslopeOutcome::Reduced(transform) => {
538 Ok(Some(ReducedLogslopeReparam { transform }))
539 }
540 ReducedLogslopeOutcome::FullRank => Ok(None),
541 ReducedLogslopeOutcome::FullyConfounded => Err(
542 "BMS score-slope block is fully confounded with the marginal index: every \
543 effective logslope direction diag(f)·G·v is W-explained by the effective \
544 marginal span at the rigid pilot, so the data identify only the sum of the \
545 marginal and score-slope surfaces and the smoothing penalty would select an \
546 arbitrary decomposition between them. Refusing to fit; remove the score-slope \
547 terms or supply covariates that separate them from the marginal index."
548 .to_string(),
549 ),
550 }
551}
552
553#[derive(Debug)]
560pub(crate) enum ReducedLogslopeOutcome {
561 FullRank,
564 Reduced(Array2<f64>),
567 FullyConfounded,
570}
571
572pub(crate) fn reduced_logslope_transform_effective(
592 marginal: ArrayView2<'_, f64>,
593 logslope: ArrayView2<'_, f64>,
594 z: &Array1<f64>,
595 row_metric: &Array1<f64>,
596 marginal_offset: &Array1<f64>,
597 logslope_offset: &Array1<f64>,
598 marginal_baseline: f64,
599 logslope_baseline: f64,
600 probit_scale: f64,
601) -> Result<ReducedLogslopeOutcome, String> {
602 let n = marginal.nrows();
603 let p_m = marginal.ncols();
604 let p_g = logslope.ncols();
605 if p_m == 0 || p_g == 0 {
606 return Ok(ReducedLogslopeOutcome::FullRank);
607 }
608
609 let mut m_eff = Array2::<f64>::zeros((n, p_m));
611 let mut g_eff = Array2::<f64>::zeros((n, p_g));
612 for i in 0..n {
613 let q_i = marginal_offset[i] + marginal_baseline;
614 let g_i = logslope_offset[i] + logslope_baseline;
615 let sg = probit_scale * g_i;
616 let c_i = (1.0 + sg * sg).sqrt();
617 let f_i = q_i * probit_scale * probit_scale * g_i / c_i + probit_scale * z[i];
618 for j in 0..p_m {
619 m_eff[[i, j]] = c_i * marginal[[i, j]];
620 }
621 for j in 0..p_g {
622 g_eff[[i, j]] = f_i * logslope[[i, j]];
623 }
624 }
625
626 let c_gram = fast_xt_diag_x(&g_eff, row_metric);
629 let energy_scale = (0..p_g).map(|i| c_gram[[i, i]]).fold(0.0_f64, f64::max);
630 if !energy_scale.is_finite() {
631 return Err(
632 "reduced logslope reparam: effective logslope Gram produced non-finite energy"
633 .to_string(),
634 );
635 }
636 if energy_scale <= 0.0 {
637 return Ok(ReducedLogslopeOutcome::FullyConfounded);
640 }
641
642 let mut a_gram = fast_xt_diag_x(&m_eff, row_metric);
646 let a_scale = (0..p_m).map(|i| a_gram[[i, i]]).fold(0.0_f64, f64::max);
647 let a_ridge = (a_scale * LOGSLOPE_REDUCED_BASIS_RELATIVE_TOL).max(f64::EPSILON);
648 for i in 0..p_m {
649 a_gram[[i, i]] += a_ridge;
650 }
651
652 let b_cross = gam_linalg::faer_ndarray::fast_xt_diag_y(&m_eff, row_metric, &g_eff);
654 let a_view = gam_linalg::faer_ndarray::FaerArrayView::new(&a_gram);
655 let a_factor =
656 gam_linalg::faer_ndarray::factorize_symmetricwith_fallback(a_view.as_ref(), Side::Lower)
657 .map_err(|e| {
658 format!(
659 "reduced logslope reparam: effective marginal Gram factorization failed: {e}"
660 )
661 })?;
662 let b_view = gam_linalg::faer_ndarray::FaerArrayView::new(&b_cross);
663 let solved = a_factor.solve(b_view.as_ref()); let a_inv_b = Array2::from_shape_fn((p_m, p_g), |(i, j)| solved[(i, j)]);
665 let schur = fast_atb(&b_cross, &a_inv_b); let mut stt = &c_gram - &schur;
667 stt = (&stt + &stt.t()) * 0.5;
668 if stt.iter().any(|v| !v.is_finite()) {
669 return Err(
670 "reduced logslope reparam: effective Schur Gram produced non-finite entries"
671 .to_string(),
672 );
673 }
674
675 let (evals, evecs) = stt
676 .eigh(Side::Lower)
677 .map_err(|e| format!("reduced logslope reparam: eigendecomposition failed: {e:?}"))?;
678 let tol = energy_scale * LOGSLOPE_REDUCED_BASIS_RELATIVE_TOL;
682 let mut kept: Vec<usize> = (0..evals.len()).filter(|&i| evals[i] > tol).collect();
683 kept.sort_by(|&a, &b| {
684 evals[b]
685 .partial_cmp(&evals[a])
686 .unwrap_or(std::cmp::Ordering::Equal)
687 });
688 let r = kept.len();
689 if r == p_g {
695 return Ok(ReducedLogslopeOutcome::FullRank);
696 }
697 if r == 0 {
698 return Ok(ReducedLogslopeOutcome::FullyConfounded);
699 }
700 let mut transform = Array2::<f64>::zeros((p_g, r));
701 for (out_col, &src) in kept.iter().enumerate() {
702 transform.column_mut(out_col).assign(&evecs.column(src));
703 }
704 if transform.iter().any(|v| !v.is_finite()) {
705 return Err(
706 "reduced logslope reparam: reduced transform produced non-finite entries".to_string(),
707 );
708 }
709 Ok(ReducedLogslopeOutcome::Reduced(transform))
710}
711
712fn reparameterize_logslope_design_reduced(
719 logslope_design: &TermCollectionDesign,
720 reparam: &ReducedLogslopeReparam,
721) -> Result<TermCollectionDesign, String> {
722 let g = logslope_design
723 .design
724 .try_to_dense_arc("reparameterize_logslope_design_reduced::logslope")?;
725 let p_g = g.ncols();
726 if p_g != reparam.original_cols() {
727 return Err(format!(
728 "reduced logslope reparam width mismatch: design has {p_g} cols, transform expects {}",
729 reparam.original_cols()
730 ));
731 }
732 let t = &reparam.transform;
733 let r = reparam.reduced_cols();
734 let g_reduced = fast_ab(&g, t);
736
737 let mut new_penalties: Vec<gam_terms::smooth::BlockwisePenalty> =
740 Vec::with_capacity(logslope_design.penalties.len());
741 let mut new_nullspace_dims: Vec<usize> = Vec::with_capacity(logslope_design.penalties.len());
742 for bp in &logslope_design.penalties {
743 let mut full = Array2::<f64>::zeros((p_g, p_g));
744 full.slice_mut(s![bp.col_range.clone(), bp.col_range.clone()])
745 .assign(&bp.local);
746 let st = fast_ab(&full, t); let mut s_reduced = fast_atb(t, &st); s_reduced = (&s_reduced + &s_reduced.t()) * 0.5;
750 let (evals, _) = s_reduced
752 .eigh(Side::Lower)
753 .map_err(|e| format!("reduced logslope penalty eigendecomposition failed: {e:?}"))?;
754 let max_eval = evals.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
755 let pen_tol = (max_eval * 1.0e-12).max(f64::EPSILON);
756 let rank = evals.iter().filter(|&&v| v.abs() > pen_tol).count();
757 let nullspace_dim = r.saturating_sub(rank);
758 new_penalties.push(gam_terms::smooth::BlockwisePenalty::new(0..r, s_reduced));
759 new_nullspace_dims.push(nullspace_dim);
760 }
761
762 let new_design = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(g_reduced));
763 Ok(TermCollectionDesign {
769 design: new_design,
770 affine_offset: logslope_design.affine_offset.clone(),
774 penalties: new_penalties,
775 nullspace_dims: new_nullspace_dims,
776 penaltyinfo: Vec::new(),
777 dropped_penaltyinfo: Vec::new(),
778 coefficient_lower_bounds: None,
779 linear_constraints: None,
780 intercept_range: 0..0,
781 linear_ranges: Vec::new(),
782 linear_function_masses: Vec::new(),
783 random_effect_ranges: Vec::new(),
784 random_effect_levels: Vec::new(),
785 smooth: gam_terms::smooth::SmoothDesign {
786 term_designs: Vec::new(),
787 penalties: Vec::new(),
788 nullspace_dims: Vec::new(),
789 penaltyinfo: Vec::new(),
790 dropped_penaltyinfo: Vec::new(),
791 terms: Vec::new(),
792 coefficient_lower_bounds: None,
793 linear_constraints: None,
794 },
795 })
796}
797
798pub(crate) fn marginal_penalties_with_influence_ridge(
821 design: &TermCollectionDesign,
822 rho_marginal: &Array1<f64>,
823 influence_columns: Option<&Array2<f64>>,
824) -> Result<(Vec<PenaltyMatrix>, Vec<usize>, Array1<f64>), String> {
825 let p_m = design.design.ncols();
826 let p1 = influence_columns.map(|z| z.ncols()).unwrap_or(0);
827 let total_dim = p_m + p1;
828 let expected_rho = design.penalties.len() + usize::from(p1 > 0);
829 if rho_marginal.len() != expected_rho {
830 return Err(format!(
831 "marginal rho width {} != smooth penalties {} + absorber slot {}",
832 rho_marginal.len(),
833 design.penalties.len(),
834 usize::from(p1 > 0),
835 ));
836 }
837 let mut penalties: Vec<PenaltyMatrix> = design
840 .penalties
841 .iter()
842 .map(|bp| bp.to_penalty_matrix(total_dim))
843 .collect();
844 let mut nullspace_dims = design.nullspace_dims.clone();
845 let log_lambdas = rho_marginal.to_vec();
846
847 if p1 > 0 {
853 penalties.push(PenaltyMatrix::Blockwise {
854 local: Array2::<f64>::eye(p1),
855 col_range: p_m..total_dim,
856 total_dim,
857 });
858 nullspace_dims.push(0);
859 }
860
861 Ok((penalties, nullspace_dims, Array1::from_vec(log_lambdas)))
862}
863
864pub(crate) fn widen_marginal_beta_hint(
867 beta_hint: Option<Array1<f64>>,
868 p_marginal_widened: usize,
869) -> Option<Array1<f64>> {
870 beta_hint.map(|hint| {
871 if hint.len() == p_marginal_widened {
872 hint
873 } else {
874 let mut widened = Array1::<f64>::zeros(p_marginal_widened);
875 let copy = hint.len().min(p_marginal_widened);
876 widened
877 .slice_mut(s![..copy])
878 .assign(&hint.slice(s![..copy]));
879 widened
880 }
881 })
882}
883
884fn marginal_fitted_eta_sup_norm(design: &TermCollectionDesign, masked_beta: &Array1<f64>) -> f64 {
894 let x = &design.design;
895 let n = x.nrows();
896 if n == 0 || x.ncols() == 0 {
897 return 0.0;
898 }
899 let mut sup = 0.0_f64;
900 for row in 0..n {
901 let eta = x.dot_row_view(row, masked_beta.view());
902 if eta.is_finite() {
903 sup = sup.max(eta.abs());
904 }
905 }
906 sup
907}
908
909fn marginal_design_beta(
912 design: &TermCollectionDesign,
913 block_beta: ArrayView1<'_, f64>,
914) -> Array1<f64> {
915 let ncols = design.design.ncols();
916 let mut masked = Array1::<f64>::zeros(ncols);
917 let copy = ncols.min(block_beta.len());
918 masked
919 .slice_mut(s![..copy])
920 .assign(&block_beta.slice(s![..copy]));
921 masked
922}
923
924fn mask_parametric_columns(
930 design: &TermCollectionDesign,
931 spec: &TermCollectionSpec,
932 full: &Array1<f64>,
933) -> Array1<f64> {
934 let ncols = design.design.ncols();
935 let mut masked = Array1::<f64>::zeros(ncols);
936 if design.intercept_range.len() == 1 {
937 let idx = design.intercept_range.start;
938 if idx < ncols {
939 masked[idx] = full[idx];
940 }
941 }
942 for (linear, (_, range)) in spec.linear_terms.iter().zip(design.linear_ranges.iter()) {
943 if linear.double_penalty {
944 continue;
945 }
946 for col in range.clone() {
947 if col < ncols {
948 masked[col] = full[col];
949 }
950 }
951 }
952 masked
953}
954
955pub(crate) fn bernoulli_marginal_slope_runaway_error_from_beta(
966 block_beta: ArrayView1<'_, f64>,
967 design: &TermCollectionDesign,
968 spec: &TermCollectionSpec,
969 inner_converged: bool,
970 eval_label: &str,
971) -> Option<String> {
972 let full_beta = marginal_design_beta(design, block_beta);
973 let parametric_beta = mask_parametric_columns(design, spec, &full_beta);
974
975 let eta_parametric = marginal_fitted_eta_sup_norm(design, ¶metric_beta);
976 let eta_full = marginal_fitted_eta_sup_norm(design, &full_beta);
977
978 let (eta_inf, explanation) = if eta_parametric >= BMS_PROBIT_SEPARATION_ETA_INF {
979 (
980 eta_parametric,
981 "an unpenalized parametric marginal direction has no stable finite probit optimum and its fitted predictor has run to the probit underflow scale",
982 )
983 } else if eta_full >= BMS_PROBIT_SEPARATION_ETA_INF {
984 (
985 eta_full,
986 "a marginal direction is trading off against the logslope surface; this is the under-constrained marginal/logslope coupling that appears when the score is correlated with the shared surface covariates",
987 )
988 } else {
989 return None;
993 };
994
995 let inner_status = if inner_converged {
996 "the inner solve reached a KKT certificate at this separation-scale predictor"
997 } else {
998 "the inner solve failed while already carrying a separation-scale predictor"
999 };
1000 let beta_abs = full_beta
1002 .iter()
1003 .copied()
1004 .filter(|v| v.is_finite())
1005 .fold(0.0_f64, |acc, v| acc.max(v.abs()));
1006
1007 Some(format!(
1008 "bernoulli marginal-slope probit marginal/logslope runaway detected in block \
1009 'marginal_surface' during {eval_label}: the fitted marginal predictor has \
1010 |η|∞={eta_inf:.3e} (numerical-degeneracy threshold \
1011 {BMS_PROBIT_SEPARATION_ETA_INF:.1}; raw |β|∞={beta_abs:.3e} is reported for \
1012 context only and does not gate this diagnostic). The joint design is \
1013 identifiable; {explanation}. {inner_status}. The robust Jeffreys curvature \
1014 path is already installed for this fit, so this diagnostic means the current \
1015 coupled surface still drives the linear predictor to the probit underflow \
1016 scale rather than a request for an external bias-reduction prior. Reduce or \
1017 reparameterize the coupled marginal/logslope surface, or use a \
1018 lower-dimensional logslope interaction. This is not a \
1019 Matérn/Duchon polynomial-nullspace or cross-block gauge-priority \
1020 failure."
1021 ))
1022}
1023
1024pub(crate) fn bernoulli_marginal_slope_runaway_error(
1025 warm_start: &CustomFamilyWarmStart,
1026 design: &TermCollectionDesign,
1027 spec: &TermCollectionSpec,
1028 inner_converged: bool,
1029 eval_label: &str,
1030) -> Option<String> {
1031 let block_beta = warm_start.block_beta_view(0)?;
1032 bernoulli_marginal_slope_runaway_error_from_beta(
1033 block_beta,
1034 design,
1035 spec,
1036 inner_converged,
1037 eval_label,
1038 )
1039}
1040
1041#[cfg(test)]
1042mod runaway_tests {
1043 use super::*;
1044 use gam_linalg::faer_ndarray::{
1045 FaerArrayView, factorize_symmetricwith_fallback, fast_xt_diag_y,
1046 };
1047 use gam_terms::smooth::{LinearCoefficientGeometry, LinearTermSpec};
1048
1049 pub(crate) fn marginal_logslope_overlap_penalty(
1055 marginal_design: &DesignMatrix,
1056 logslope_design: &DesignMatrix,
1057 z: &Array1<f64>,
1058 row_metric: &Array1<f64>,
1059 marginal_offset: &Array1<f64>,
1060 logslope_offset: &Array1<f64>,
1061 marginal_baseline: f64,
1062 logslope_baseline: f64,
1063 probit_scale: f64,
1064 ) -> Result<Option<Array2<f64>>, String> {
1065 let marginal =
1066 marginal_design.try_to_dense_arc("marginal_logslope_overlap_penalty::marginal")?;
1067 let logslope =
1068 logslope_design.try_to_dense_arc("marginal_logslope_overlap_penalty::logslope")?;
1069 let n = marginal.nrows();
1070 if logslope.nrows() != n
1071 || z.len() != n
1072 || row_metric.len() != n
1073 || marginal_offset.len() != n
1074 || logslope_offset.len() != n
1075 {
1076 return Err(format!(
1077 "marginal/logslope overlap penalty row mismatch: marginal={}, logslope={}, z={}, row_metric={}, marginal_offset={}, logslope_offset={}",
1078 marginal.nrows(),
1079 logslope.nrows(),
1080 z.len(),
1081 row_metric.len(),
1082 marginal_offset.len(),
1083 logslope_offset.len(),
1084 ));
1085 }
1086 let p_m = marginal.ncols();
1087 let p_g = logslope.ncols();
1088 if p_m == 0 || p_g == 0 {
1089 return Ok(None);
1090 }
1091 if !marginal_baseline.is_finite()
1092 || !logslope_baseline.is_finite()
1093 || !probit_scale.is_finite()
1094 || probit_scale <= 0.0
1095 || z.iter().any(|v| !v.is_finite())
1096 || row_metric.iter().any(|v| !v.is_finite() || *v < 0.0)
1097 || marginal_offset.iter().any(|v| !v.is_finite())
1098 || logslope_offset.iter().any(|v| !v.is_finite())
1099 {
1100 return Err(
1101 "marginal/logslope overlap penalty requires finite pilot geometry and finite non-negative row metric"
1102 .to_string(),
1103 );
1104 }
1105
1106 let mut marginal_effective = Array2::<f64>::zeros((n, p_m));
1107 let mut effective_logslope = Array2::<f64>::zeros((n, p_g));
1108 for i in 0..n {
1109 let q_i = marginal_offset[i] + marginal_baseline;
1110 let g_i = logslope_offset[i] + logslope_baseline;
1111 let sg = probit_scale * g_i;
1112 let c_i = (1.0 + sg * sg).sqrt();
1113 let logslope_factor =
1114 q_i * probit_scale * probit_scale * g_i / c_i + probit_scale * z[i];
1115 for j in 0..p_m {
1116 marginal_effective[[i, j]] = c_i * marginal[[i, j]];
1117 }
1118 for j in 0..p_g {
1119 effective_logslope[[i, j]] = logslope_factor * logslope[[i, j]];
1120 }
1121 }
1122 if effective_logslope.iter().all(|v| v.abs() <= f64::EPSILON) {
1123 return Ok(None);
1124 }
1125
1126 let mut gram = fast_xt_diag_x(&effective_logslope, row_metric);
1127 let gram_scale = gram.diag().iter().copied().fold(0.0_f64, f64::max);
1128 if !gram_scale.is_finite() || gram_scale <= 0.0 {
1129 return Ok(None);
1130 }
1131 let projection_ridge = (gram_scale * 1.0e-10).max(f64::EPSILON);
1132 for i in 0..p_g {
1133 gram[[i, i]] += projection_ridge;
1134 }
1135 let cross = fast_xt_diag_y(&effective_logslope, row_metric, &marginal_effective);
1136 let gram_view = FaerArrayView::new(&gram);
1137 let factor = factorize_symmetricwith_fallback(gram_view.as_ref(), Side::Lower)
1138 .map_err(|e| format!("marginal/logslope overlap Gram factorization failed: {e}"))?;
1139 let rhsview = FaerArrayView::new(&cross);
1140 let coeffs_mat = factor.solve(rhsview.as_ref());
1141 let coeffs = Array2::from_shape_fn((p_g, p_m), |(i, j)| coeffs_mat[(i, j)]);
1142 let projected_marginal = fast_ab(&effective_logslope, &coeffs);
1143 let mut penalty = fast_xt_diag_y(&marginal_effective, row_metric, &projected_marginal);
1144 penalty = (&penalty + &penalty.t()) * 0.5;
1145 let max_abs = penalty.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
1146 if !max_abs.is_finite() || max_abs <= 1.0e-12 {
1147 return Ok(None);
1148 }
1149 Ok(Some(penalty))
1150 }
1151
1152 #[test]
1160 pub(crate) fn effective_reduction_drops_score_weighted_confound_raw_audit_misses() {
1161 let m = Array2::<f64>::from_shape_vec((3, 1), vec![1.0, 1.0, 1.0]).unwrap();
1163 let g = Array2::<f64>::from_shape_vec((3, 2), vec![1.0, 1.0, 2.0, 2.0, 3.0, 9.0]).unwrap();
1164 let z = Array1::from_vec(vec![1.0, 0.5, 1.0 / 3.0]);
1165 let w = Array1::<f64>::ones(3);
1166 let zero = Array1::<f64>::zeros(3);
1167
1168 let reparam = match reduced_logslope_transform_effective(
1171 m.view(),
1172 g.view(),
1173 &z,
1174 &w,
1175 &zero,
1176 &zero,
1177 0.0,
1178 0.0,
1179 1.0,
1180 )
1181 .expect("effective reduction must succeed")
1182 {
1183 ReducedLogslopeOutcome::Reduced(t) => t,
1184 other => panic!(
1185 "effective audit must reduce the score-weighted confound (raw audit would not), got {}",
1186 match other {
1187 ReducedLogslopeOutcome::FullRank => "FullRank",
1188 ReducedLogslopeOutcome::FullyConfounded => "FullyConfounded",
1189 ReducedLogslopeOutcome::Reduced(_) => unreachable!(),
1190 }
1191 ),
1192 };
1193 assert_eq!(
1194 reparam.ncols(),
1195 1,
1196 "exactly one effective-identifiable logslope direction should survive"
1197 );
1198
1199 let g_eff = {
1203 let mut e = Array2::<f64>::zeros((3, 2));
1204 for i in 0..3 {
1205 for j in 0..2 {
1206 e[[i, j]] = z[i] * g[[i, j]];
1207 }
1208 }
1209 e
1210 };
1211 let img = g_eff.dot(&reparam.column(0));
1212 let mean = img.iter().sum::<f64>() / 3.0;
1213 let var = img.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / 3.0;
1214 assert!(
1215 var > 1.0e-6,
1216 "kept direction must be the identifiable (non-constant) effective column, var={var}"
1217 );
1218 }
1219
1220 #[test]
1227 pub(crate) fn effective_reduction_fully_confounded_single_column_is_distinct_outcome() {
1228 let m = Array2::<f64>::from_shape_vec((3, 1), vec![1.0, 1.0, 1.0]).unwrap();
1229 let g = Array2::<f64>::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1230 let z = Array1::from_vec(vec![1.0, 0.5, 1.0 / 3.0]);
1231 let w = Array1::<f64>::ones(3);
1232 let zero = Array1::<f64>::zeros(3);
1233 let outcome = reduced_logslope_transform_effective(
1234 m.view(),
1235 g.view(),
1236 &z,
1237 &w,
1238 &zero,
1239 &zero,
1240 0.0,
1241 0.0,
1242 1.0,
1243 )
1244 .expect("effective reduction must succeed");
1245 assert!(
1246 matches!(outcome, ReducedLogslopeOutcome::FullyConfounded),
1247 "fully effective-confounded logslope must surface the distinct FullyConfounded outcome"
1248 );
1249 }
1250
1251 #[test]
1254 pub(crate) fn effective_reduction_no_confound_returns_none() {
1255 let m = Array2::<f64>::from_shape_vec((3, 1), vec![1.0, 1.0, 1.0]).unwrap();
1256 let g = Array2::<f64>::from_shape_vec((3, 2), vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0]).unwrap();
1258 let z = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1259 let w = Array1::<f64>::ones(3);
1260 let zero = Array1::<f64>::zeros(3);
1261 let outcome = reduced_logslope_transform_effective(
1262 m.view(),
1263 g.view(),
1264 &z,
1265 &w,
1266 &zero,
1267 &zero,
1268 0.0,
1269 0.0,
1270 1.0,
1271 )
1272 .expect("effective reduction must succeed");
1273 assert!(
1274 matches!(outcome, ReducedLogslopeOutcome::FullRank),
1275 "no effective confound ⇒ FullRank (raw design kept unchanged)"
1276 );
1277 }
1278
1279 #[test]
1280 pub(crate) fn spatial_joint_setup_counts_only_learned_penalties_in_rho() {
1281 let data = Array2::<f64>::zeros((3, 1));
1282 let empty_terms = TermCollectionSpec {
1283 linear_terms: Vec::new(),
1284 random_effect_terms: Vec::new(),
1285 smooth_terms: Vec::new(),
1286 };
1287 let setup = joint_setup(
1288 data.view(),
1289 &empty_terms,
1290 &empty_terms,
1291 2,
1292 3,
1293 Some(2.5),
1294 &[0.4],
1295 &SpatialLengthScaleOptimizationOptions::default(),
1296 )
1297 .expect("empty spatial geometry is valid");
1298
1299 assert_eq!(
1300 setup.rho_dim(),
1301 6,
1302 "BMS spatial setup rho holds every learned marginal/logslope/auxiliary penalty; the #461 absorber ridge occupies the trailing marginal slot"
1303 );
1304 assert_eq!(
1305 setup.theta0()[1],
1306 2.5,
1307 "absorber ridge seeds the trailing marginal rho coordinate at the ln(n) leakage scale"
1308 );
1309 }
1310
1311 #[test]
1312 pub(crate) fn overlap_penalty_targets_score_weighted_logslope_span() {
1313 let marginal = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
1314 Array2::from_shape_vec((4, 1), vec![0.0, 1.0, 2.0, 3.0]).unwrap(),
1315 ));
1316 let logslope = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
1317 Array2::from_shape_vec((4, 1), vec![1.0, 1.0, 1.0, 1.0]).unwrap(),
1318 ));
1319 let z = Array1::from_vec(vec![0.0, 1.0, 2.0, 3.0]);
1320 let row_metric = Array1::ones(4);
1321 let offsets = Array1::zeros(4);
1322
1323 let penalty = marginal_logslope_overlap_penalty(
1324 &marginal,
1325 &logslope,
1326 &z,
1327 &row_metric,
1328 &offsets,
1329 &offsets,
1330 0.0,
1331 0.0,
1332 1.0,
1333 )
1334 .expect("overlap penalty should build")
1335 .expect("marginal signal lies in the pilot logslope Jacobian span");
1336
1337 assert_eq!(penalty.dim(), (1, 1));
1338 assert!((penalty[[0, 0]] - 14.0).abs() < 1.0e-6);
1339 }
1340
1341 #[test]
1342 pub(crate) fn overlap_penalty_skips_weight_orthogonal_channels() {
1343 let marginal = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
1344 Array2::from_shape_vec((4, 1), vec![-1.0, 1.0, -1.0, 1.0]).unwrap(),
1345 ));
1346 let logslope = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
1347 Array2::from_shape_vec((4, 1), vec![1.0, 1.0, 1.0, 1.0]).unwrap(),
1348 ));
1349 let z = Array1::ones(4);
1350 let row_metric = Array1::ones(4);
1351 let offsets = Array1::zeros(4);
1352
1353 let penalty = marginal_logslope_overlap_penalty(
1354 &marginal,
1355 &logslope,
1356 &z,
1357 &row_metric,
1358 &offsets,
1359 &offsets,
1360 0.0,
1361 0.0,
1362 1.0,
1363 )
1364 .expect("overlap penalty should build");
1365
1366 assert!(penalty.is_none());
1367 }
1368
1369 fn dense_marginal_design(
1377 x: Array2<f64>,
1378 intercept_range: std::ops::Range<usize>,
1379 linear_ranges: Vec<(String, std::ops::Range<usize>)>,
1380 ) -> TermCollectionDesign {
1381 let nrows = x.nrows();
1382 TermCollectionDesign {
1383 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(x)),
1384 affine_offset: Array1::zeros(nrows),
1385 penalties: Vec::new(),
1386 nullspace_dims: Vec::new(),
1387 penaltyinfo: Vec::new(),
1388 dropped_penaltyinfo: Vec::new(),
1389 coefficient_lower_bounds: None,
1390 linear_constraints: None,
1391 intercept_range,
1392 linear_ranges,
1393 linear_function_masses: Vec::new(),
1394 random_effect_ranges: Vec::new(),
1395 random_effect_levels: Vec::new(),
1396 smooth: gam_terms::smooth::SmoothDesign {
1397 term_designs: Vec::new(),
1398 penalties: Vec::new(),
1399 nullspace_dims: Vec::new(),
1400 penaltyinfo: Vec::new(),
1401 dropped_penaltyinfo: Vec::new(),
1402 terms: Vec::new(),
1403 coefficient_lower_bounds: None,
1404 linear_constraints: None,
1405 },
1406 }
1407 }
1408
1409 fn linear_term(name: &str, feature_col: usize) -> LinearTermSpec {
1410 LinearTermSpec {
1411 name: name.to_string(),
1412 feature_col,
1413 feature_cols: vec![feature_col],
1414 categorical_levels: vec![],
1415 double_penalty: false,
1416 coefficient_geometry: LinearCoefficientGeometry::default(),
1417 coefficient_min: None,
1418 coefficient_max: None,
1419 frozen_function_mass: None,
1420 }
1421 }
1422
1423 fn empty_spec() -> TermCollectionSpec {
1424 TermCollectionSpec {
1425 linear_terms: Vec::new(),
1426 random_effect_terms: Vec::new(),
1427 smooth_terms: Vec::new(),
1428 }
1429 }
1430
1431 #[test]
1438 pub(crate) fn runaway_guard_silent_when_huge_beta_cancels_to_bounded_eta() {
1439 let x = Array2::<f64>::from_shape_vec((4, 2), vec![1.0; 8]).unwrap();
1441 let design = dense_marginal_design(x, 0..0, Vec::new());
1442 let beta = Array1::from_vec(vec![60.0, -60.0]);
1443
1444 let msg = bernoulli_marginal_slope_runaway_error_from_beta(
1445 beta.view(),
1446 &design,
1447 &empty_spec(),
1448 true,
1449 "regression-fixture",
1450 );
1451 assert!(
1452 msg.is_none(),
1453 "huge cancelling β with bounded fitted η must NOT trip the runaway guard; got {msg:?}"
1454 );
1455 }
1456
1457 #[test]
1461 pub(crate) fn runaway_guard_fires_when_fitted_eta_exceeds_threshold() {
1462 let x = Array2::<f64>::from_shape_vec((3, 1), vec![1.0, 1.0, 1.0]).unwrap();
1463 let design = dense_marginal_design(x, 0..0, Vec::new());
1464 let beta = Array1::from_vec(vec![40.0]);
1465
1466 let msg = bernoulli_marginal_slope_runaway_error_from_beta(
1467 beta.view(),
1468 &design,
1469 &empty_spec(),
1470 true,
1471 "separation-fixture",
1472 )
1473 .expect("fitted |η|∞=40 ≥ 35 must trip the runaway guard");
1474
1475 assert!(msg.contains("marginal/logslope runaway"));
1476 assert!(msg.contains("|η|∞"));
1477 assert!(msg.contains("4.000e1"));
1478 assert!(msg.contains("score is correlated with the shared surface covariates"));
1479 assert!(msg.contains("not a Matérn/Duchon polynomial-nullspace"));
1480 assert!(msg.contains("KKT certificate"));
1481 }
1482
1483 #[test]
1487 pub(crate) fn runaway_guard_names_unpenalized_parametric_direction_via_fitted_eta() {
1488 let x = Array2::<f64>::from_shape_vec((3, 1), vec![1.0, 1.0, 1.0]).unwrap();
1489 let design = dense_marginal_design(x, 0..0, vec![("sex".to_string(), 0..1)]);
1490 let mut spec = empty_spec();
1491 spec.linear_terms.push(linear_term("sex", 0));
1492 let beta = Array1::from_vec(vec![41.0]);
1493
1494 let msg = bernoulli_marginal_slope_runaway_error_from_beta(
1495 beta.view(),
1496 &design,
1497 &spec,
1498 true,
1499 "parametric-fixture",
1500 )
1501 .expect("parametric fitted |η|∞=41 ≥ 35 must trip the runaway guard");
1502
1503 assert!(msg.contains("unpenalized parametric marginal direction"));
1504 assert!(msg.contains("|η|∞"));
1505 assert!(msg.contains("robust Jeffreys curvature path is already installed"));
1506 assert!(msg.contains("not a Matérn/Duchon polynomial-nullspace"));
1507 }
1508
1509 #[test]
1513 pub(crate) fn runaway_guard_silent_for_nonconverged_but_bounded_eta() {
1514 let x = Array2::<f64>::from_shape_vec((3, 1), vec![1.0, 1.0, 1.0]).unwrap();
1515 let design = dense_marginal_design(x, 0..0, Vec::new());
1516 let beta = Array1::from_vec(vec![5.0]);
1517
1518 let msg = bernoulli_marginal_slope_runaway_error_from_beta(
1519 beta.view(),
1520 &design,
1521 &empty_spec(),
1522 false,
1523 "nonconverged-fixture",
1524 );
1525 assert!(
1526 msg.is_none(),
1527 "bounded fitted η must not raise the separation error even when the inner solve did not converge; got {msg:?}"
1528 );
1529 }
1530
1531 #[test]
1534 pub(crate) fn runaway_guard_fires_for_nonconverged_separating_eta() {
1535 let x = Array2::<f64>::from_shape_vec((3, 1), vec![1.0, 1.0, 1.0]).unwrap();
1536 let design = dense_marginal_design(x, 0..0, Vec::new());
1537 let beta = Array1::from_vec(vec![50.0]);
1538
1539 let msg = bernoulli_marginal_slope_runaway_error_from_beta(
1540 beta.view(),
1541 &design,
1542 &empty_spec(),
1543 false,
1544 "nonconverged-separating-fixture",
1545 )
1546 .expect("separating |η|∞ at non-convergence must still trip the guard");
1547
1548 assert!(msg.contains(
1549 "the inner solve failed while already carrying a separation-scale predictor"
1550 ));
1551 }
1552
1553 #[test]
1566 pub(crate) fn bms_block_jacobians_self_compute_at_audit_empty_beta_nonzero_logslope_baseline() {
1567 use std::sync::Arc;
1568 let n = 4usize;
1569 let marginal =
1570 Arc::new(Array2::<f64>::from_shape_vec((n, 1), vec![1.0, 1.0, 1.0, 1.0]).unwrap());
1571 let logslope =
1572 Arc::new(Array2::<f64>::from_shape_vec((n, 1), vec![1.0, 1.0, 1.0, 1.0]).unwrap());
1573 let offset_m = Array1::<f64>::zeros(n);
1574 let g_baseline = 0.3_f64;
1577 let offset_s = Array1::<f64>::from_elem(n, g_baseline);
1578 let z = Arc::new(Array1::from_vec(vec![-0.7, 0.2, 0.9, 1.4]));
1579 let s = 1.0_f64;
1580
1581 let beta: Vec<f64> = Vec::new();
1583 let state = FamilyLinearizationState {
1584 beta: &beta,
1585 family_scalars: None,
1586 channel_hessian: None,
1587 probit_frailty_scale: s,
1588 };
1589
1590 let marginal_jac = BmsMarginalJacobian::new(
1591 Arc::clone(&marginal),
1592 Arc::clone(&logslope),
1593 offset_m.clone(),
1594 offset_s.clone(),
1595 1,
1596 );
1597 let j_m = marginal_jac
1598 .effective_jacobian_rows(&state, 0..n)
1599 .expect("BMS marginal Jacobian must self-compute at audit empty β (gam#370)");
1600 let c_expected = (1.0 + (s * g_baseline).powi(2)).sqrt();
1602 assert_eq!(j_m.dim(), (n, 1));
1603 for i in 0..n {
1604 assert!(
1605 (j_m[[i, 0]] - c_expected).abs() < 1e-12,
1606 "marginal J[{i}] = {} != closed-form c_i = {c_expected}",
1607 j_m[[i, 0]]
1608 );
1609 }
1610
1611 let logslope_jac = BmsLogslopeJacobian::new(
1612 Arc::clone(&marginal),
1613 Arc::clone(&logslope),
1614 offset_m,
1615 offset_s,
1616 Arc::clone(&z),
1617 1,
1618 );
1619 let j_s = logslope_jac
1620 .effective_jacobian_rows(&state, 0..n)
1621 .expect("BMS logslope Jacobian must self-compute at audit empty β (gam#370)");
1622 assert_eq!(j_s.dim(), (n, 1));
1625 for i in 0..n {
1626 let expected = s * z[i];
1627 assert!(
1628 (j_s[[i, 0]] - expected).abs() < 1e-12,
1629 "logslope J[{i}] = {} != closed-form factor {expected}",
1630 j_s[[i, 0]]
1631 );
1632 assert!(j_s[[i, 0]].is_finite());
1633 }
1634 }
1635}
1636
1637pub(crate) fn build_marginal_blockspec_bms(
1638 design: &TermCollectionDesign,
1639 baseline: f64,
1640 offset: &Array1<f64>,
1641 rho: Array1<f64>,
1642 beta_hint: Option<Array1<f64>>,
1643 logslope_design: &TermCollectionDesign,
1644 logslope_offset: &Array1<f64>,
1645 logslope_baseline: f64,
1646 p_marginal: usize,
1647 influence_columns: Option<&Array2<f64>>,
1648) -> Result<ParameterBlockSpec, String> {
1649 let offset_m = offset + baseline;
1650 let offset_s = logslope_offset + logslope_baseline;
1651 let raw_marginal_dense = design
1652 .design
1653 .try_to_dense_arc("build_marginal_blockspec_bms::marginal")?;
1654 let marginal_dense =
1655 widen_marginal_dense_with_influence(&raw_marginal_dense, influence_columns)?;
1656 let logslope_dense = logslope_design
1657 .design
1658 .try_to_dense_arc("build_marginal_blockspec_bms::logslope")?;
1659 let callback: Arc<dyn BlockEffectiveJacobian> = Arc::new(BmsMarginalJacobian {
1660 marginal_dense: Arc::clone(&marginal_dense),
1661 logslope_dense,
1662 offset_m: offset_m.clone(),
1663 offset_s,
1664 p_marginal,
1665 });
1666 let (penalties, nullspace_dims, initial_log_lambdas) =
1667 marginal_penalties_with_influence_ridge(design, &rho, influence_columns)?;
1668 Ok(ParameterBlockSpec {
1669 name: "marginal_surface".to_string(),
1670 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
1671 (*marginal_dense).clone(),
1672 )),
1673 offset: offset_m,
1674 penalties,
1675 nullspace_dims,
1676 initial_log_lambdas,
1677 initial_beta: widen_marginal_beta_hint(beta_hint, p_marginal),
1678 gauge_priority: GAUGE_PRIORITY_MARGINAL,
1691 jacobian_callback: Some(callback),
1692 stacked_design: None,
1693 stacked_offset: None,
1694 })
1695}
1696
1697pub(crate) fn build_logslope_blockspec_bms(
1698 design: &TermCollectionDesign,
1699 baseline: f64,
1700 offset: &Array1<f64>,
1701 rho: Array1<f64>,
1702 beta_hint: Option<Array1<f64>>,
1703 marginal_design: &TermCollectionDesign,
1704 marginal_offset: &Array1<f64>,
1705 marginal_baseline: f64,
1706 z: Arc<Array1<f64>>,
1707 p_marginal: usize,
1708 influence_columns: Option<&Array2<f64>>,
1709) -> Result<ParameterBlockSpec, String> {
1710 let offset_s = offset + baseline;
1711 let offset_m = marginal_offset + marginal_baseline;
1712 let raw_marginal_dense = marginal_design
1713 .design
1714 .try_to_dense_arc("build_logslope_blockspec_bms::marginal")?;
1715 let marginal_dense =
1720 widen_marginal_dense_with_influence(&raw_marginal_dense, influence_columns)?;
1721 let logslope_dense = design
1722 .design
1723 .try_to_dense_arc("build_logslope_blockspec_bms::logslope")?;
1724 let callback: Arc<dyn BlockEffectiveJacobian> = Arc::new(BmsLogslopeJacobian {
1725 marginal_dense,
1726 logslope_dense: Arc::clone(&logslope_dense),
1727 offset_m,
1728 offset_s: offset_s.clone(),
1729 z,
1730 p_marginal,
1731 });
1732 Ok(ParameterBlockSpec {
1733 name: "logslope_surface".to_string(),
1734 design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
1735 (*logslope_dense).clone(),
1736 )),
1737 offset: offset_s,
1738 penalties: design.penalties_as_penalty_matrix(),
1739 nullspace_dims: design.nullspace_dims.clone(),
1740 initial_log_lambdas: rho,
1741 initial_beta: beta_hint,
1742 gauge_priority: GAUGE_PRIORITY_LOGSLOPE,
1750 jacobian_callback: Some(callback),
1751 stacked_design: None,
1752 stacked_offset: None,
1753 })
1754}
1755
1756pub(crate) fn build_deviation_aux_blockspec(
1757 name: &str,
1758 prepared: &DeviationPrepared,
1759 rho: Array1<f64>,
1760 beta_hint: Option<Array1<f64>>,
1761) -> Result<ParameterBlockSpec, String> {
1762 let mut block = prepared.block.clone();
1763 block.initial_log_lambdas = Some(rho);
1764 let candidate_beta = beta_hint.or_else(|| Some(Array1::<f64>::zeros(block.design.ncols())));
1765 block.initial_beta = candidate_beta
1766 .map(|beta| {
1767 let zero = Array1::<f64>::zeros(beta.len());
1768 project_monotone_feasible_beta(&prepared.runtime, &zero, &beta, name)
1769 })
1770 .transpose()?;
1771 let mut spec = block.intospec(name)?;
1772 spec.gauge_priority = match name {
1782 "link_dev" => GAUGE_PRIORITY_LINK_DEV,
1783 "score_warp_dev" => GAUGE_PRIORITY_SCORE_WARP_DEV,
1790 _ => GAUGE_PRIORITY_DEVIATION_DEFAULT,
1791 };
1792 Ok(spec)
1793}
1794
1795pub(crate) fn push_deviation_aux_blockspecs(
1796 blocks: &mut Vec<ParameterBlockSpec>,
1797 rho: &Array1<f64>,
1798 cursor: &mut usize,
1799 score_warp_prepared: Option<&DeviationPrepared>,
1800 link_dev_prepared: Option<&DeviationPrepared>,
1801 score_warp_beta_hint: Option<Array1<f64>>,
1802 link_dev_beta_hint: Option<Array1<f64>>,
1803) -> Result<(), String> {
1804 fn take_rho_slice(
1805 rho: &Array1<f64>,
1806 cursor: &mut usize,
1807 count: usize,
1808 block_name: &str,
1809 ) -> Result<Array1<f64>, String> {
1810 let start = *cursor;
1811 let end = start.checked_add(count).ok_or_else(|| {
1812 format!("{block_name} penalty-rho range overflow: start={start}, count={count}")
1813 })?;
1814 if end > rho.len() {
1815 return Err(format!(
1816 "{block_name} penalty-rho range {start}..{end} exceeds rho length {}",
1817 rho.len()
1818 ));
1819 }
1820 let slice = rho.slice(s![start..end]).to_owned();
1821 *cursor = end;
1822 Ok(slice)
1823 }
1824
1825 if let Some(prepared) = score_warp_prepared {
1826 let rho_h = take_rho_slice(
1827 rho,
1828 cursor,
1829 prepared.block.penalties.len(),
1830 "score_warp_dev",
1831 )?;
1832 blocks.push(build_deviation_aux_blockspec(
1833 "score_warp_dev",
1834 prepared,
1835 rho_h,
1836 score_warp_beta_hint,
1837 )?);
1838 }
1839 if let Some(prepared) = link_dev_prepared {
1840 let rho_w = take_rho_slice(rho, cursor, prepared.block.penalties.len(), "link_dev")?;
1841 blocks.push(build_deviation_aux_blockspec(
1842 "link_dev",
1843 prepared,
1844 rho_w,
1845 link_dev_beta_hint,
1846 )?);
1847 }
1848 Ok(())
1849}
1850
1851#[cfg(test)]
1852mod deviation_penalty_layout_tests {
1853 use super::*;
1854
1855 fn prepared_with_penalty_orders(orders: Vec<usize>) -> DeviationPrepared {
1856 let seed = Array1::linspace(-1.0, 1.0, 48);
1857 let config = DeviationBlockConfig {
1858 degree: 3,
1859 num_internal_knots: 6,
1860 penalty_order: *orders.first().expect("test requires a penalty order"),
1861 penalty_orders: orders,
1862 double_penalty: false,
1863 monotonicity_eps: 0.0,
1864 };
1865 build_score_warp_deviation_block_from_seed(&seed, &config)
1866 .expect("test deviation block must build")
1867 }
1868
1869 #[test]
1870 fn composed_score_link_influence_rho_layout_advances_by_emitted_counts_2315() {
1871 let score_warp = prepared_with_penalty_orders(vec![1, 2]);
1876 let link_dev = prepared_with_penalty_orders(vec![1, 2, 3]);
1877 assert_eq!(score_warp.block.penalties.len(), 2);
1878 assert_eq!(link_dev.block.penalties.len(), 3);
1879
1880 let rho = Array1::from_vec(vec![-101.0, 11.0, 12.0, 21.0, 22.0, 23.0, 31.0]);
1883 let mut cursor = 1usize;
1884 let mut blocks = Vec::new();
1885 push_deviation_aux_blockspecs(
1886 &mut blocks,
1887 &rho,
1888 &mut cursor,
1889 Some(&score_warp),
1890 Some(&link_dev),
1891 None,
1892 None,
1893 )
1894 .expect("composed deviation layout must be realized");
1895
1896 assert_eq!(blocks.len(), 2);
1897 assert_eq!(blocks[0].name, "score_warp_dev");
1898 assert_eq!(
1899 blocks[0].initial_log_lambdas.as_slice(),
1900 Some(&[11.0, 12.0][..])
1901 );
1902 assert_eq!(blocks[1].name, "link_dev");
1903 assert_eq!(
1904 blocks[1].initial_log_lambdas.as_slice(),
1905 Some(&[21.0, 22.0, 23.0][..])
1906 );
1907 assert_eq!(
1908 cursor, 6,
1909 "the next consumer must start after every emitted deviation penalty"
1910 );
1911
1912 let influence_rho = rho.slice(s![cursor..cursor + 1]).to_owned();
1913 assert_eq!(influence_rho.as_slice(), Some(&[31.0][..]));
1914 assert_ne!(
1915 influence_rho[0], blocks[1].initial_log_lambdas[0],
1916 "the influence absorber must not reuse link_dev's first rho coordinate"
1917 );
1918 }
1919}
1920
1921fn inner_fit(
1922 family: &BernoulliMarginalSlopeFamily,
1923 blocks: &[ParameterBlockSpec],
1924 options: &BlockwiseFitOptions,
1925) -> Result<UnifiedFitResult, String> {
1926 let mut options = options.clone();
1927 options.use_outer_hessian = false;
1932 options.outer_tol = options.outer_tol.max(2.0e-5);
1933 fit_custom_family(family, blocks, &options).map_err(|e| e.to_string())
1934}
1935
1936fn inner_fit_from_certified_outer(
1937 family: &BernoulliMarginalSlopeFamily,
1938 blocks: &[ParameterBlockSpec],
1939 options: &BlockwiseFitOptions,
1940 mode: crate::custom_family::CustomFamilyOwnedMode,
1941 theta: &Array1<f64>,
1942 outer: &gam_solve::rho_optimizer::CertifiedOuterResult,
1943) -> Result<UnifiedFitResult, String> {
1944 let mut options = crate::outer_subsample::exact_outer_options_for_row_set(
1945 options,
1946 &crate::row_kernel::RowSet::All,
1947 );
1948 options.use_outer_hessian = false;
1949 options.outer_tol = options.outer_tol.max(2.0e-5);
1950 fit_custom_family_fixed_log_lambdas_from_owned_mode(
1951 family, blocks, &options, mode, theta, outer,
1952 )
1953 .map_err(|error| error.to_string())
1954}
1955
1956pub fn fit_bernoulli_marginal_slope_terms(
1957 data: ArrayView2<'_, f64>,
1958 spec: BernoulliMarginalSlopeTermSpec,
1959 options: &BlockwiseFitOptions,
1960 kappa_options: &SpatialLengthScaleOptimizationOptions,
1961 policy: &gam_runtime::resource::ResourcePolicy,
1962) -> Result<BernoulliMarginalSlopeFitResult, String> {
1963 let mut spec = spec;
1964 let data_view = data;
1965 validate_spec(data_view, &spec)?;
1966 let mjs_frozen_marginal =
1975 gam_terms::smooth::freeze_measure_jet_length_scale_learning(&mut spec.marginalspec);
1976 let mjs_frozen_logslope =
1977 gam_terms::smooth::freeze_measure_jet_length_scale_learning(&mut spec.logslopespec);
1978 if mjs_frozen_marginal + mjs_frozen_logslope > 0 {
1979 log::info!(
1980 "[BMS spatial] froze measure-jet length-scale learning on {} marginal + {} log-slope \
1981 term(s): the coupled surface keeps ℓ at its conditioned auto value (#1116)",
1982 mjs_frozen_marginal,
1983 mjs_frozen_logslope
1984 );
1985 }
1986 let mut effective_kappa_options = kappa_options.clone();
1987 let kappa_locked_marginal =
1997 gam_terms::smooth::all_spatial_terms_kappa_fixed(&spec.marginalspec);
1998 let kappa_locked_logslope =
1999 gam_terms::smooth::all_spatial_terms_kappa_fixed(&spec.logslopespec);
2000 if effective_kappa_options.enabled && kappa_locked_marginal && kappa_locked_logslope {
2001 log::info!(
2002 "[BMS spatial] disabling κ/ψ optimization: every spatial term has an \
2003 explicit length_scale and no anisotropy; user-supplied kernel scale is fixed"
2004 );
2005 effective_kappa_options.enabled = false;
2006 }
2007 let flex_spatial_pilot_path = (spec.score_warp.is_some() || spec.link_dev.is_some())
2008 && spec.y.len() >= BMS_FLEX_SPATIAL_OUTER_PILOT_ROW_THRESHOLD
2009 && effective_kappa_options.enabled;
2010 if flex_spatial_pilot_path {
2011 let marginal_terms = spatial_length_scale_term_indices(&spec.marginalspec);
2012 let logslope_terms = spatial_length_scale_term_indices(&spec.logslopespec);
2013 let marginal_updates = apply_spatial_anisotropy_pilot_initializer(
2014 data_view,
2015 &mut spec.marginalspec,
2016 &marginal_terms,
2017 effective_kappa_options.pilot_subsample_threshold,
2018 &effective_kappa_options,
2019 )
2020 .map_err(|error| error.to_string())?;
2021 let logslope_updates = apply_spatial_anisotropy_pilot_initializer(
2022 data_view,
2023 &mut spec.logslopespec,
2024 &logslope_terms,
2025 effective_kappa_options.pilot_subsample_threshold,
2026 &effective_kappa_options,
2027 )
2028 .map_err(|error| error.to_string())?;
2029 effective_kappa_options.enabled = false;
2030 log::info!(
2031 "[BMS spatial] n={} flex=true pilot_geometry_updates={} iterative_spatial_outer=false reason=large-flex-spatial-pilot",
2032 spec.y.len(),
2033 marginal_updates + logslope_updates,
2034 );
2035 }
2036 let (z_standardized, z_normalization) = standardize_latent_z_with_policy(
2037 &spec.z,
2038 &spec.weights,
2039 "bernoulli-marginal-slope",
2040 &spec.latent_z_policy,
2041 )?;
2042 spec.z = z_standardized;
2043 let sigma_learnable = matches!(
2044 &spec.frailty,
2045 FrailtySpec::GaussianShift {
2046 scale: FrailtyScale::Learned { .. }
2047 }
2048 );
2049 let initial_sigma = match &spec.frailty {
2050 FrailtySpec::GaussianShift {
2051 scale: FrailtyScale::Fixed { sigma },
2052 } => Some(*sigma),
2053 FrailtySpec::GaussianShift {
2054 scale: FrailtyScale::Learned { initial_sigma },
2055 } => Some(*initial_sigma),
2056 FrailtySpec::None => None,
2057 FrailtySpec::HazardMultiplier { .. } => {
2058 return Err(
2059 "internal: validate_spec should have rejected unsupported marginal-slope frailty"
2060 .to_string(),
2061 );
2062 }
2063 };
2064 let probit_scale = probit_frailty_scale(initial_sigma);
2065 let (_raw_joint_designs, mut joint_specs) = build_term_collection_designs_and_freeze_joint(
2066 data_view,
2067 &[spec.marginalspec.clone(), spec.logslopespec.clone()],
2068 )
2069 .map_err(|e| e.to_string())?;
2070 let marginalspec_boot = joint_specs.remove(0);
2071 let logslopespec_boot = joint_specs.remove(0);
2072 let (mut joint_designs, _) = build_term_collection_designs_and_freeze_joint(
2087 data_view,
2088 &[marginalspec_boot.clone(), logslopespec_boot.clone()],
2089 )
2090 .map_err(|e| format!("failed to rebuild frozen probe BMS joint designs: {e}"))?;
2091 let marginal_design = joint_designs.remove(0);
2092 let logslope_design = joint_designs.remove(0);
2093 spec.marginal_offset = marginal_design
2094 .compose_offset(spec.marginal_offset.view(), "BMS marginal block")
2095 .map_err(|error| error.to_string())?;
2096 spec.logslope_offset = logslope_design
2097 .compose_offset(spec.logslope_offset.view(), "BMS logslope block")
2098 .map_err(|error| error.to_string())?;
2099 let absorber_active = spec
2106 .score_influence_jacobian
2107 .as_ref()
2108 .is_some_and(|j| j.ncols() > 0);
2109 let conditioning_dense = if absorber_active {
2110 None
2111 } else {
2112 Some(
2113 marginal_design
2114 .design
2115 .try_to_dense_arc("bernoulli marginal-slope conditional latent-z gate")?,
2116 )
2117 };
2118 let (latent_measure, latent_z_calibration) = build_latent_measure_with_geometry(
2119 &spec.z,
2120 &spec.weights,
2121 &spec.latent_z_policy,
2122 conditioning_dense.as_ref().map(|d| d.view()),
2123 )?;
2124 if latent_measure.is_empirical() && sigma_learnable {
2125 return Err("empirical latent-measure marginal-slope calibration requires fixed GaussianShift sigma; learnable sigma derivatives must be fit under the standard-normal latent measure"
2126 .to_string());
2127 }
2128
2129 let y = Arc::new(spec.y.clone());
2130 let weights = Arc::new(spec.weights.clone());
2131 let z = match &latent_z_calibration {
2136 LatentMeasureCalibration::None => Arc::new(spec.z.clone()),
2137 LatentMeasureCalibration::RankInverseNormal(cal) => {
2138 Arc::new(cal.apply_to_training(&spec.z)?)
2139 }
2140 LatentMeasureCalibration::ConditionalLocationScale(cal) => {
2141 let a_block = conditioning_dense.as_ref().ok_or_else(|| {
2144 "conditional latent calibration requires the marginal conditioning block"
2145 .to_string()
2146 })?;
2147 Arc::new(cal.apply(spec.z.view(), a_block.view())?)
2148 }
2149 };
2150 let z_train = z.as_ref();
2151 let pilot_baseline = pooled_probit_baseline(&spec.y, z_train, &spec.weights)?;
2152 let baseline = (
2153 bernoulli_marginal_slope_eta_from_probability(
2154 &spec.base_link,
2155 normal_cdf(pilot_baseline.0),
2156 "bernoulli marginal-slope baseline link inversion",
2157 )?,
2158 pilot_baseline.1 / probit_scale,
2159 );
2160
2161 let rigid_pilot_eta = rigid_pooled_probit_pilot_eta(
2204 &spec.base_link,
2205 z_train,
2206 &spec.marginal_offset,
2207 &spec.logslope_offset,
2208 baseline.0,
2209 baseline.1,
2210 probit_scale,
2211 )?;
2212 let cross_block_pilot_w_score_warp =
2213 pilot_irls_hessian_row_metric_at_eta(&rigid_pilot_eta, &spec.weights);
2214
2215 let influence_columns = if let Some(jac) = spec
2227 .score_influence_jacobian
2228 .as_ref()
2229 .filter(|j| j.ncols() > 0)
2230 {
2231 let protected_design = DesignMatrix::hstack(vec![
2232 marginal_design.design.clone(),
2233 logslope_design.design.clone(),
2234 ])
2235 .map_err(|e| {
2236 format!(
2237 "bernoulli marginal-slope influence-block protected projection stack failed to concatenate marginal + logslope design: {e}"
2238 )
2239 })?;
2240 let protected_dense_for_proj = protected_design
2241 .try_to_dense_arc("bernoulli marginal-slope influence-block protected projection")?;
2242 let protected_dense = protected_dense_for_proj.as_ref();
2243 if jac.nrows() != protected_dense.nrows() {
2244 return Err(format!(
2245 "influence block: Jacobian has {} rows, protected design has {}",
2246 jac.nrows(),
2247 protected_dense.nrows()
2248 ));
2249 }
2250 let rigid_logslope_at_rows = &spec.logslope_offset + baseline.1;
2261 let residualized = crate::marginal_slope_orthogonal::residualized_influence_block(
2262 jac,
2263 z_train,
2264 &rigid_logslope_at_rows,
2265 probit_scale,
2266 protected_dense.view(),
2267 &cross_block_pilot_w_score_warp,
2268 )?;
2269 Some(residualized)
2270 } else {
2271 None
2272 };
2273 let mut cross_block_warnings: Vec<CrossBlockIdentifiabilityWarning> = Vec::new();
2274 let score_warp_prepared = if let Some(cfg) = spec.score_warp.as_ref() {
2275 use super::deviation_runtime::ParametricAnchorBlock;
2276 let mut prepared = build_score_warp_deviation_block_from_seed(z_train, cfg)?;
2277 let outcome = install_compiled_flex_block_into_runtime(
2282 &mut prepared,
2283 z_train,
2284 cfg,
2285 &[
2286 (&marginal_design.design, ParametricAnchorBlock::Marginal),
2287 (&logslope_design.design, ParametricAnchorBlock::Logslope),
2288 ],
2289 &[],
2290 &cross_block_pilot_w_score_warp,
2291 )?;
2292 match outcome {
2293 FlexCompileOutcome::Reparameterised => Some(prepared),
2294 FlexCompileOutcome::FullyAliased { reason } => {
2295 cross_block_warnings.push(CrossBlockIdentifiabilityWarning {
2301 candidate_label: "score_warp",
2302 anchor_summary: "marginal+logslope".to_string(),
2303 reason,
2304 });
2305 Some(prepared)
2306 }
2307 }
2308 } else {
2309 None
2310 };
2311 let link_dev_prepared = if let Some(cfg) = spec.link_dev.as_ref() {
2337 let eta_pilot = pilot_eta_for_link_dev_orthogonalisation(
2338 &spec.base_link,
2339 &spec.y,
2340 z_train,
2341 &spec.weights,
2342 &marginal_design.design,
2343 &spec.marginal_offset,
2344 &spec.logslope_offset,
2345 baseline.0,
2346 baseline.1,
2347 probit_scale,
2348 )?;
2349 let link_dev_seed = padded_deviation_seed(&eta_pilot, 1.0, 0.5);
2350 let mut prepared = build_link_deviation_block_from_knots_design_seed_and_weights(
2351 &link_dev_seed,
2352 &eta_pilot,
2353 cfg,
2354 )?;
2355 let score_warp_anchor_design = score_warp_prepared
2392 .as_ref()
2393 .map(|sw| sw.runtime.design_at_training_with_residual(z_train))
2394 .transpose()?;
2395 use super::deviation_runtime::ParametricAnchorBlock;
2396 let parametric_anchors: [(&DesignMatrix, ParametricAnchorBlock); 2] = [
2397 (&marginal_design.design, ParametricAnchorBlock::Marginal),
2398 (&logslope_design.design, ParametricAnchorBlock::Logslope),
2399 ];
2400 let flex_anchor_slot: Option<&Array2<f64>> = score_warp_anchor_design.as_ref();
2401 let flex_anchors: Vec<&Array2<f64>> = flex_anchor_slot.into_iter().collect();
2402 let cross_block_pilot_w_link_dev =
2407 pilot_irls_hessian_row_metric_at_eta(&eta_pilot, &spec.weights);
2408 let outcome = install_compiled_flex_block_into_runtime(
2409 &mut prepared,
2410 &eta_pilot,
2411 cfg,
2412 ¶metric_anchors,
2413 &flex_anchors,
2414 &cross_block_pilot_w_link_dev,
2415 )?;
2416 match outcome {
2417 FlexCompileOutcome::Reparameterised => Some(prepared),
2418 FlexCompileOutcome::FullyAliased { reason } => {
2419 cross_block_warnings.push(CrossBlockIdentifiabilityWarning {
2425 candidate_label: "link_deviation",
2426 anchor_summary: "marginal+logslope+score_warp".to_string(),
2427 reason,
2428 });
2429 Some(prepared)
2430 }
2431 }
2432 } else {
2433 None
2434 };
2435 let extra_rho0 = {
2436 let mut out = Vec::new();
2437 if let Some(ref prepared) = score_warp_prepared {
2438 out.extend(std::iter::repeat_n(0.0, prepared.block.penalties.len()));
2439 }
2440 if let Some(ref prepared) = link_dev_prepared {
2441 out.extend(std::iter::repeat_n(0.0, prepared.block.penalties.len()));
2442 }
2443 out
2444 };
2445 let logslope_reduced_reparam: Option<ReducedLogslopeReparam> = build_reduced_logslope_reparam(
2458 &marginal_design,
2459 &logslope_design,
2460 z.as_ref(),
2461 &cross_block_pilot_w_score_warp,
2462 &spec.marginal_offset,
2463 &spec.logslope_offset,
2464 baseline.0,
2465 baseline.1,
2466 probit_scale,
2467 )?;
2468 let reduce_logslope_design =
2474 |logslope_design: &TermCollectionDesign| -> Result<TermCollectionDesign, String> {
2475 match logslope_reduced_reparam.as_ref() {
2476 Some(reparam) => reparameterize_logslope_design_reduced(logslope_design, reparam),
2477 None => Ok(logslope_design.clone()),
2478 }
2479 };
2480
2481 let absorber_slots = usize::from(influence_columns.is_some());
2485 let absorber_rho0 = influence_columns
2486 .as_ref()
2487 .map(|_| influence_absorber_log_lambda(spec.z.len()).clamp(-12.0, 12.0));
2488 let marginal_penalty_count = marginal_design.penalties.len() + absorber_slots;
2489 let setup = joint_setup(
2490 data_view,
2491 &marginalspec_boot,
2492 &logslopespec_boot,
2493 marginal_penalty_count,
2494 logslope_design.penalties.len(),
2495 absorber_rho0,
2496 &extra_rho0,
2497 &effective_kappa_options,
2498 )
2499 .map_err(|error| error.to_string())?;
2500 let setup = if sigma_learnable {
2501 setup.with_auxiliary(
2502 Array1::from_vec(vec![initial_sigma.expect("learnable sigma seed").ln()]),
2503 Array1::from_vec(vec![0.01_f64.ln()]),
2504 Array1::from_vec(vec![5.0_f64.ln()]),
2505 )
2506 } else {
2507 setup
2508 };
2509 let final_sigma_cell = std::cell::Cell::new(initial_sigma);
2510 let exact_warm_start = RefCell::new(None::<CustomFamilyWarmStart>);
2511 let runaway_error = RefCell::new(None::<String>);
2512 let pending_beta_seed = RefCell::new(None::<Array1<f64>>);
2519 let hints = RefCell::new(ThetaHints::default());
2520 let score_warp_runtime = score_warp_prepared.as_ref().map(|p| p.runtime.clone());
2521 let link_dev_runtime = link_dev_prepared.as_ref().map(|p| p.runtime.clone());
2522
2523 let build_blocks = |rho: &Array1<f64>,
2524 marginal_design: &TermCollectionDesign,
2525 logslope_design: &TermCollectionDesign|
2526 -> Result<Vec<ParameterBlockSpec>, String> {
2527 let hints = hints.borrow();
2528 let mut cursor = 0usize;
2529 let logslope_design_reduced = reduce_logslope_design(logslope_design)?;
2536 let logslope_design = &logslope_design_reduced;
2537 let marginal_rho_len = marginal_design.penalties.len() + absorber_slots;
2542 let rho_marginal = rho.slice(s![cursor..cursor + marginal_rho_len]).to_owned();
2543 cursor += marginal_rho_len;
2544 let rho_logslope = rho
2545 .slice(s![cursor..cursor + logslope_design.penalties.len()])
2546 .to_owned();
2547 cursor += logslope_design.penalties.len();
2548 let p_m = marginal_design.design.ncols()
2549 + influence_columns.as_ref().map(|z| z.ncols()).unwrap_or(0);
2550 let mut blocks = vec![
2551 build_marginal_blockspec_bms(
2552 marginal_design,
2553 baseline.0,
2554 &spec.marginal_offset,
2555 rho_marginal,
2556 hints.marginal_beta.clone(),
2557 logslope_design,
2558 &spec.logslope_offset,
2559 baseline.1,
2560 p_m,
2561 influence_columns.as_ref(),
2562 )?,
2563 build_logslope_blockspec_bms(
2564 logslope_design,
2565 baseline.1,
2566 &spec.logslope_offset,
2567 rho_logslope,
2568 hints.logslope_beta.clone(),
2569 marginal_design,
2570 &spec.marginal_offset,
2571 baseline.0,
2572 Arc::clone(&z),
2573 p_m,
2574 influence_columns.as_ref(),
2575 )?,
2576 ];
2577 push_deviation_aux_blockspecs(
2578 &mut blocks,
2579 rho,
2580 &mut cursor,
2581 score_warp_prepared.as_ref(),
2582 link_dev_prepared.as_ref(),
2583 hints.score_warp_beta.clone(),
2584 hints.link_dev_beta.clone(),
2585 )?;
2586 Ok(blocks)
2587 };
2588
2589 let intercept_warm_starts = new_intercept_warm_start_cache(y.len());
2590 let cell_moment_lru = new_cell_moment_lru_cache(policy);
2591 let cell_moment_cache_stats = new_cell_moment_cache_stats();
2592 let make_family = |marginal_design: &TermCollectionDesign,
2593 logslope_design: &TermCollectionDesign,
2594 sigma: Option<f64>|
2595 -> BernoulliMarginalSlopeFamily {
2596 let kernel_marginal_design = match influence_columns.as_ref() {
2602 Some(z_infl) => {
2603 let raw = marginal_design
2604 .design
2605 .try_to_dense_arc("make_family::widened-marginal")
2606 .expect("dense marginal design for influence widening");
2607 let widened = widen_marginal_dense_with_influence(&raw, Some(z_infl))
2608 .expect("widen marginal design with influence columns");
2609 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
2610 (*widened).clone(),
2611 ))
2612 }
2613 None => marginal_design.design.clone(),
2614 };
2615 let kernel_logslope_design = reduce_logslope_design(logslope_design)
2621 .expect("reduce logslope design for family construction")
2622 .design;
2623 BernoulliMarginalSlopeFamily {
2624 y: Arc::clone(&y),
2625 weights: Arc::clone(&weights),
2626 z: Arc::clone(&z),
2627 latent_measure: latent_measure.clone(),
2628 gaussian_frailty_sd: sigma,
2629 base_link: spec.base_link.clone(),
2630 marginal_design: kernel_marginal_design,
2631 logslope_design: kernel_logslope_design,
2632 score_warp: score_warp_runtime.clone(),
2633 link_dev: link_dev_runtime.clone(),
2634 policy: policy.clone(),
2635 cell_moment_lru: Arc::clone(&cell_moment_lru),
2636 cell_moment_cache_stats: Arc::clone(&cell_moment_cache_stats),
2637 intercept_warm_starts: Some(Arc::clone(&intercept_warm_starts)),
2638 auto_subsample_phase_counter: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2639 auto_subsample_last_rho: Arc::new(Mutex::new(None)),
2640 }
2641 };
2642
2643 let marginal_terms = spatial_length_scale_term_indices(&marginalspec_boot);
2644 let logslope_terms = spatial_length_scale_term_indices(&logslopespec_boot);
2645 let marginal_has_spatial = !marginal_terms.is_empty();
2646 let logslope_has_spatial = !logslope_terms.is_empty();
2647 let analytic_joint_derivatives_available =
2648 marginal_has_spatial || logslope_has_spatial || setup.log_kappa_dim() == 0;
2649 if setup.log_kappa_dim() > 0 && !analytic_joint_derivatives_available {
2650 return Err("exact bernoulli marginal-slope spatial optimization requires analytic joint psi derivatives"
2651 .to_string());
2652 }
2653 let initial_rho = setup.theta0().slice(s![..setup.rho_dim()]).to_owned();
2654 let initial_blocks = build_blocks(&initial_rho, &marginal_design, &logslope_design)?;
2655 let initial_family = make_family(&marginal_design, &logslope_design, initial_sigma);
2656 let (joint_gradient, joint_hessian) =
2657 custom_family_outer_derivatives(&initial_family, &initial_blocks, options);
2658 let analytic_joint_gradient_available = analytic_joint_derivatives_available
2659 && matches!(joint_gradient, gam_problem::Derivative::Analytic);
2660 let analytic_joint_hessian_available =
2666 analytic_joint_derivatives_available && joint_hessian.is_analytic();
2667 let kappa_options_ref: &SpatialLengthScaleOptimizationOptions = &effective_kappa_options;
2668 let sigma_from_theta = |theta: &Array1<f64>| -> Option<f64> {
2669 if sigma_learnable {
2670 Some(theta[setup.rho_dim() + setup.log_kappa_dim()].exp())
2671 } else {
2672 initial_sigma
2673 }
2674 };
2675 let hyper_layout_cache = RefCell::new(
2676 None::<(
2677 Array1<f64>,
2678 crate::custom_family::SharedCustomFamilyHyperLayout,
2679 )>,
2680 );
2681 let theta_matches = |left: &Array1<f64>, right: &Array1<f64>| -> bool {
2682 left.len() == right.len()
2683 && left
2684 .iter()
2685 .zip(right.iter())
2686 .all(|(lhs, rhs)| lhs.to_bits() == rhs.to_bits())
2687 };
2688 let get_hyper_layout = |theta: &Array1<f64>,
2689 specs: &[TermCollectionSpec],
2690 designs: &[TermCollectionDesign]|
2691 -> Result<crate::custom_family::SharedCustomFamilyHyperLayout, String> {
2692 if let Some((cached_theta, cached_layout)) = hyper_layout_cache.borrow().as_ref()
2693 && theta_matches(cached_theta, theta)
2694 {
2695 return Ok(Arc::clone(cached_layout));
2696 }
2697
2698 let built = |specs: &[TermCollectionSpec],
2699 designs: &[TermCollectionDesign]|
2700 -> Result<
2701 Vec<Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>>,
2702 String,
2703 > {
2704 let marginal_psi_derivs = if marginal_has_spatial {
2705 build_block_spatial_psi_derivatives(data_view, &specs[0], &designs[0])?.ok_or_else(
2706 || {
2707 "bernoulli marginal-slope: marginal block has spatial terms \
2708 but spatial psi derivatives are unavailable"
2709 .to_string()
2710 },
2711 )?
2712 } else {
2713 Vec::new()
2714 };
2715 let logslope_psi_derivs = if logslope_has_spatial {
2716 let built = if let Some(reparam) = logslope_reduced_reparam.as_ref() {
2717 let transform =
2718 CoefficientSpatialPsiBlockTransform::new(&reparam.transform)?;
2719 build_block_spatial_psi_derivatives_with_transform(
2720 data_view,
2721 &specs[1],
2722 &designs[1],
2723 &transform,
2724 )?
2725 } else {
2726 build_block_spatial_psi_derivatives(data_view, &specs[1], &designs[1])?
2727 };
2728 built.ok_or_else(|| {
2729 "bernoulli marginal-slope: logslope block has spatial terms \
2730 but spatial psi derivatives are unavailable"
2731 .to_string()
2732 })?
2733 } else {
2734 Vec::new()
2735 };
2736 let mut derivative_blocks = vec![marginal_psi_derivs, logslope_psi_derivs];
2737 if score_warp_runtime.is_some() {
2738 derivative_blocks.push(Vec::new());
2739 }
2740 if link_dev_runtime.is_some() {
2741 derivative_blocks.push(Vec::new());
2742 }
2743 Ok(derivative_blocks)
2744 }(specs, designs)?;
2745 let family_axes = if sigma_learnable { vec![0] } else { Vec::new() };
2746 let hyper_values = theta.slice(s![setup.rho_dim()..]).to_owned();
2747 let layout = Arc::new(crate::custom_family::CustomFamilyHyperLayout::new(
2748 built,
2749 family_axes,
2750 hyper_values,
2751 )?);
2752 hyper_layout_cache.replace(Some((theta.clone(), Arc::clone(&layout))));
2753 Ok(layout)
2754 };
2755
2756 let outer_policy = {
2761 let psi_dim = setup.theta0().len() - setup.rho_dim();
2762 initial_family.outer_derivative_policy(&initial_blocks, psi_dim, options)
2763 };
2764 let exact_spatial_outer_tol = kappa_options_ref.rel_tol.max(EXACT_SPATIAL_OUTER_TOL_FLOOR);
2765 let solved = optimize_spatial_length_scale_exact_joint(
2766 data_view,
2767 &[marginalspec_boot.clone(), logslopespec_boot.clone()],
2768 &[marginal_terms.clone(), logslope_terms.clone()],
2769 kappa_options_ref,
2770 &setup,
2771 gam_solve::seeding::SeedRiskProfile::GeneralizedLinear,
2772 analytic_joint_gradient_available,
2773 analytic_joint_hessian_available,
2774 true,
2775 None,
2776 outer_policy,
2777 |theta, specs: &[TermCollectionSpec], designs: &[TermCollectionDesign], provenance| {
2778 if let Some(err) = runaway_error.borrow().as_ref().cloned() {
2779 return Err(err);
2780 }
2781 assert_eq!(
2782 specs.len(),
2783 designs.len(),
2784 "spatial joint optimizer must supply one spec per design",
2785 );
2786 let rho = theta.slice(s![..setup.rho_dim()]).to_owned();
2787 let blocks = build_blocks(&rho, &designs[0], &designs[1])?;
2788 let sigma = sigma_from_theta(theta);
2789 final_sigma_cell.set(sigma);
2790 let family = make_family(&designs[0], &designs[1], sigma);
2791 let fit = match provenance {
2792 SpatialFitProvenance::NoOuterOptimization => {
2793 inner_fit(&family, &blocks, options)?
2794 }
2795 SpatialFitProvenance::Certified { outer, mode } => {
2796 inner_fit_from_certified_outer(
2797 &family, &blocks, options, mode, theta, outer,
2798 )?
2799 }
2800 };
2801 if let Some(block) = fit.block_states.first()
2802 && let Some(err) = bernoulli_marginal_slope_runaway_error_from_beta(
2803 block.beta.view(),
2804 &designs[0],
2805 &specs[0],
2806 true,
2807 "final fit",
2808 )
2809 {
2810 runaway_error.replace(Some(err.clone()));
2811 return Err(err);
2812 }
2813 let mut hints_mut = hints.borrow_mut();
2814 let mut bidx = 0usize;
2815 if let Some(block) = fit.block_states.get(bidx) {
2816 hints_mut.marginal_beta = Some(block.beta.clone());
2817 }
2818 bidx += 1;
2819 if let Some(block) = fit.block_states.get(bidx) {
2820 hints_mut.logslope_beta = Some(block.beta.clone());
2821 }
2822 bidx += 1;
2823 if score_warp_prepared.is_some() {
2824 if let Some(block) = fit.block_states.get(bidx) {
2825 hints_mut.score_warp_beta = Some(block.beta.clone());
2826 }
2827 bidx += 1;
2828 }
2829 if link_dev_prepared.is_some()
2830 && let Some(block) = fit.block_states.get(bidx)
2831 {
2832 hints_mut.link_dev_beta = Some(block.beta.clone());
2833 }
2834 Ok(fit)
2835 },
2836 |theta,
2837 specs: &[TermCollectionSpec],
2838 designs: &[TermCollectionDesign],
2839 eval_mode,
2840 row_set: &crate::row_kernel::RowSet| {
2841 if let Some(err) = runaway_error.borrow().as_ref().cloned() {
2842 return Err(err);
2843 }
2844 use gam_problem::EvalMode;
2845 static BMS_OUTER_EVAL_ROWSET_LOGGED: std::sync::Once = std::sync::Once::new();
2852 BMS_OUTER_EVAL_ROWSET_LOGGED.call_once(|| {
2853 let row_set_rows = match row_set {
2854 crate::row_kernel::RowSet::All => spec.y.len(),
2855 crate::row_kernel::RowSet::Subsample { rows, .. } => rows.len(),
2856 };
2857 log::debug!(
2858 "[BMS exact outer eval] mode={eval_mode:?} row_set_rows={row_set_rows}"
2859 );
2860 });
2861 let rho = theta.slice(s![..setup.rho_dim()]).to_owned();
2862 let blocks = build_blocks(&rho, &designs[0], &designs[1])?;
2863 if let Some(beta_seed) = pending_beta_seed.borrow_mut().take() {
2867 let widths: Vec<usize> = blocks.iter().map(|b| b.design.ncols()).collect();
2868 match CustomFamilyWarmStart::from_cached_beta(&widths, &beta_seed) {
2869 Ok(ws) => {
2870 exact_warm_start.replace(Some(ws));
2871 }
2872 Err(e) => {
2873 log::warn!(
2874 "[BMS] outer ρ-cache β-warm-start rejected: {e}; falling back to cold β"
2875 );
2876 }
2877 }
2878 }
2879 let sigma = sigma_from_theta(theta);
2880 final_sigma_cell.set(sigma);
2881 let family = make_family(&designs[0], &designs[1], sigma);
2882 let hyper_layout = get_hyper_layout(theta, specs, designs)?;
2883 let effective_mode = match eval_mode {
2887 EvalMode::ValueGradientHessian if !analytic_joint_hessian_available => {
2888 EvalMode::ValueAndGradient
2889 }
2890 other => other,
2891 };
2892 let tolerance_options =
2893 joint_hyper_options_for_outer_tolerance(options, exact_spatial_outer_tol);
2894 let eval_options = crate::outer_subsample::exact_outer_options_for_row_set(
2895 &tolerance_options,
2896 row_set,
2897 );
2898 let owned = evaluate_custom_family_joint_hyper_owned_shared(
2899 &family,
2900 &blocks,
2901 &eval_options,
2902 &rho,
2903 hyper_layout,
2904 exact_warm_start.borrow().as_ref(),
2905 effective_mode,
2906 )?;
2907 if let Some(err) = bernoulli_marginal_slope_runaway_error(
2908 &owned.result.warm_start,
2909 &designs[0],
2910 &specs[0],
2911 owned.result.inner_converged,
2912 "exact outer evaluation",
2913 ) {
2914 runaway_error.replace(Some(err.clone()));
2915 return Err(err);
2916 }
2917 exact_warm_start.replace(Some(owned.result.warm_start.clone()));
2918 if !owned.result.inner_converged {
2919 return Err(
2920 "exact bernoulli marginal-slope inner solve did not converge".to_string(),
2921 );
2922 }
2923 if matches!(eval_mode, EvalMode::ValueGradientHessian)
2924 && analytic_joint_hessian_available
2925 && !owned.result.outer_hessian.is_analytic()
2926 {
2927 return Err("exact bernoulli marginal-slope joint [rho, psi] objective did not return an outer Hessian"
2928 .to_string());
2929 }
2930 Ok(ExactJointEvaluation {
2931 objective: owned.result.objective,
2932 gradient: owned.result.gradient,
2933 hessian: owned.result.outer_hessian,
2934 mode: owned.mode,
2935 })
2936 },
2937 |theta,
2938 specs: &[TermCollectionSpec],
2939 designs: &[TermCollectionDesign],
2940 row_set: &crate::row_kernel::RowSet| {
2941 if let Some(err) = runaway_error.borrow().as_ref().cloned() {
2942 return Err(err);
2943 }
2944 let rho = theta.slice(s![..setup.rho_dim()]).to_owned();
2945 let blocks = build_blocks(&rho, &designs[0], &designs[1])?;
2946 if let Some(beta_seed) = pending_beta_seed.borrow_mut().take() {
2947 let widths: Vec<usize> = blocks.iter().map(|b| b.design.ncols()).collect();
2948 match CustomFamilyWarmStart::from_cached_beta(&widths, &beta_seed) {
2949 Ok(ws) => {
2950 exact_warm_start.replace(Some(ws));
2951 }
2952 Err(e) => {
2953 log::warn!(
2954 "[BMS] outer ρ-cache β-warm-start rejected (efs): {e}; falling back to cold β"
2955 );
2956 }
2957 }
2958 }
2959 let sigma = sigma_from_theta(theta);
2960 final_sigma_cell.set(sigma);
2961 let family = make_family(&designs[0], &designs[1], sigma);
2962 let hyper_layout = get_hyper_layout(theta, specs, designs)?;
2963 let tolerance_options =
2964 joint_hyper_options_for_outer_tolerance(options, exact_spatial_outer_tol);
2965 let eval_options = crate::outer_subsample::exact_outer_options_for_row_set(
2966 &tolerance_options,
2967 row_set,
2968 );
2969 let owned = evaluate_custom_family_joint_hyper_efs_owned_shared(
2970 &family,
2971 &blocks,
2972 &eval_options,
2973 &rho,
2974 hyper_layout,
2975 exact_warm_start.borrow().as_ref(),
2976 )?;
2977 if let Some(err) = bernoulli_marginal_slope_runaway_error(
2978 &owned.result.warm_start,
2979 &designs[0],
2980 &specs[0],
2981 owned.result.inner_converged,
2982 "EFS outer evaluation",
2983 ) {
2984 runaway_error.replace(Some(err.clone()));
2985 return Err(err);
2986 }
2987 exact_warm_start.replace(Some(owned.result.warm_start.clone()));
2988 if !owned.result.inner_converged {
2989 return Err(
2990 "exact bernoulli marginal-slope EFS inner solve did not converge".to_string(),
2991 );
2992 }
2993 Ok(ExactJointEfsEvaluation {
2994 evaluation: owned.result.efs_eval,
2995 mode: owned.mode,
2996 })
2997 },
2998 crate::marginal_slope_shared::make_beta_seed_validator(&pending_beta_seed),
2999 )?;
3000
3001 let mut resolved_specs = solved.resolved_specs;
3002 let mut designs = solved.designs;
3003 let mut solved_fit = solved.fit;
3004 let (latent_z_rank_int_calibration, latent_z_conditional_calibration) =
3040 match latent_z_calibration {
3041 LatentMeasureCalibration::None => (None, None),
3042 LatentMeasureCalibration::RankInverseNormal(cal) => (Some(cal), None),
3043 LatentMeasureCalibration::ConditionalLocationScale(cal) => (None, Some(cal)),
3044 };
3045 if let Some(cal) = latent_z_conditional_calibration.as_ref()
3052 && let Some(vb) = solved_fit.covariance_conditional.clone()
3053 {
3054 if !matches!(latent_measure, LatentMeasureKind::StandardNormal) {
3055 return Err(
3056 "BMS Murphy-Topel generated-regressor covariance is unavailable for a conditional latent-z calibration whose second-stage latent measure is not StandardNormal"
3057 .to_string(),
3058 );
3059 }
3060 let p_beta = vb.nrows();
3061 let calibration_marginal_dense = marginal_design
3062 .design
3063 .try_to_dense_arc("bms generated-regressor marginal design")?;
3064 if p_beta != vb.ncols() {
3065 return Err(format!(
3066 "bms generated-regressor: covariance_conditional must be square, got {}×{}",
3067 vb.nrows(),
3068 vb.ncols()
3069 ));
3070 }
3071 let correction_family =
3072 make_family(&marginal_design, &logslope_design, final_sigma_cell.get());
3073 let flex_active = score_warp_runtime.is_some() || link_dev_runtime.is_some();
3074 let s = if flex_active {
3075 correction_family.flex_score_zeta_sensitivity(
3076 &solved_fit.block_states,
3077 options,
3078 p_beta,
3079 )?
3080 } else {
3081 let score_marginal_dense = correction_family
3085 .marginal_design
3086 .try_to_dense_arc("bms generated-regressor fitted marginal design")?;
3087 let score_logslope_dense = correction_family
3088 .logslope_design
3089 .try_to_dense_arc("bms generated-regressor fitted logslope design")?;
3090 let p_score = score_marginal_dense.ncols() + score_logslope_dense.ncols();
3091 if p_beta != p_score {
3092 return Err(format!(
3093 "bms generated-regressor rigid covariance/frame mismatch: covariance width {p_beta} != marginal({}) + logslope({})",
3094 score_marginal_dense.ncols(),
3095 score_logslope_dense.ncols()
3096 ));
3097 }
3098 let marginal_eta = &solved_fit.block_states[0].eta;
3099 let slope_eta = &solved_fit.block_states[1].eta;
3100 let probit_scale = probit_frailty_scale(final_sigma_cell.get());
3101 rigid_standard_normal_score_zeta_sensitivity(
3102 &spec.base_link,
3103 marginal_eta,
3104 slope_eta,
3105 z.as_ref(),
3106 y.as_ref(),
3107 weights.as_ref(),
3108 probit_scale,
3109 score_marginal_dense.view(),
3110 score_logslope_dense.view(),
3111 p_beta,
3112 )?
3113 };
3114 let correction = cal.generated_regressor_correction(
3118 s.view(),
3119 spec.z.view(),
3120 calibration_marginal_dense.view(),
3121 vb.view(),
3122 )?;
3123 if let Some(cov) = solved_fit.covariance_conditional.as_mut() {
3124 *cov = &*cov + &correction;
3125 }
3126 if let Some(cov) = solved_fit.covariance_corrected.as_mut() {
3127 *cov = &*cov + &correction;
3128 }
3129 log::info!(
3130 "[BMS latent-z] Murphy–Topel generated-regressor SE correction applied: \
3131 p_beta={p_beta} flex_active={flex_active} theta1_dim={} max_diag_inflation={:.3e}",
3132 cal.theta1_dim(),
3133 (0..p_beta)
3134 .map(|i| correction[[i, i]])
3135 .fold(0.0_f64, f64::max),
3136 );
3137 }
3138 if let Some(reparam) = logslope_reduced_reparam.as_ref() {
3142 let r = reparam.reduced_cols();
3143 if let Some(block) = solved_fit.blocks.get_mut(1)
3144 && block.beta.len() == r
3145 {
3146 block.beta = reparam.recover_original_logslope_beta(&block.beta)?;
3147 }
3148 if let Some(state) = solved_fit.block_states.get_mut(1)
3149 && state.beta.len() == r
3150 {
3151 state.beta = reparam.recover_original_logslope_beta(&state.beta)?;
3152 }
3153 }
3154 Ok(BernoulliMarginalSlopeFitResult {
3167 fit: solved_fit,
3168 marginalspec_resolved: resolved_specs.remove(0),
3169 logslopespec_resolved: resolved_specs.remove(0),
3170 marginal_design: designs.remove(0),
3171 logslope_design: designs.remove(0),
3172 baseline_marginal: baseline.0,
3173 baseline_logslope: baseline.1,
3174 z_normalization,
3175 latent_measure,
3176 score_warp_runtime,
3177 link_dev_runtime,
3178 gaussian_frailty_sd: final_sigma_cell.get(),
3179 cross_block_warnings,
3180 latent_z_rank_int_calibration,
3181 latent_z_conditional_calibration,
3182 })
3183}