1use std::sync::Arc;
30
31use ndarray::{Array1, Array2, Array3};
32
33use faer::Side;
34use gam_identifiability::families::compiler::{
35 BlockOrder, RowHessian, RowJacobianOperator, scale_jacobian_by_sqrt_h_with,
36};
37use gam_linalg::faer_ndarray::FaerEigh;
38use gam_linalg::matrix::{CoefficientTransformOperator, DenseDesignMatrix, DesignMatrix};
39use gam_problem::gauge::assemble_block_triangular_t;
40use gam_problem::{FamilyChannelHessian, PenaltyMatrix};
41
42const K_SURVIVAL: usize = 4;
43
44pub struct SurvivalRowHessian {
51 h: Array3<f64>,
54 weights: Array1<f64>,
59 event: Array1<f64>,
60 derivative_guard: f64,
61}
62
63impl SurvivalRowHessian {
64 pub fn from_pilot_primary_state(
68 q0: &Array1<f64>,
69 q1: &Array1<f64>,
70 qd1: &Array1<f64>,
71 g: &Array1<f64>,
72 z: &Array1<f64>,
73 weights: &Array1<f64>,
74 event: &Array1<f64>,
75 derivative_guard: f64,
76 probit_scale: f64,
77 ) -> Result<Self, String> {
78 let n = q0.len();
79 if [
80 q1.len(),
81 qd1.len(),
82 g.len(),
83 z.len(),
84 weights.len(),
85 event.len(),
86 ]
87 .iter()
88 .any(|&l| l != n)
89 {
90 return Err(format!(
91 "SurvivalRowHessian: length mismatch \
92 q0={n}, q1={}, qd1={}, g={}, z={}, weights={}, event={}",
93 q1.len(),
94 qd1.len(),
95 g.len(),
96 z.len(),
97 weights.len(),
98 event.len()
99 ));
100 }
101 let mut h_full = Array3::<f64>::zeros((n, K_SURVIVAL, K_SURVIVAL));
102 for i in 0..n {
103 let clamped = evaluated_psd_row_hessian(
104 q0[i],
105 q1[i],
106 qd1[i],
107 g[i],
108 z[i],
109 weights[i],
110 event[i],
111 derivative_guard,
112 probit_scale,
113 )
114 .map_err(|reason| format!("SurvivalRowHessian: row {i}: {reason}"))?;
115 for a in 0..K_SURVIVAL {
116 for b in 0..K_SURVIVAL {
117 h_full[[i, a, b]] = clamped[[a, b]];
118 }
119 }
120 }
121 Ok(Self {
122 h: h_full,
123 weights: weights.clone(),
124 event: event.clone(),
125 derivative_guard,
126 })
127 }
128}
129
130impl RowHessian for SurvivalRowHessian {
131 fn k(&self) -> usize {
132 K_SURVIVAL
133 }
134 fn nrows(&self) -> usize {
135 self.h.shape()[0]
136 }
137 fn fill_row(&self, row: usize, out: &mut [f64]) {
138 assert_eq!(out.len(), K_SURVIVAL * K_SURVIVAL);
139 for a in 0..K_SURVIVAL {
140 for b in 0..K_SURVIVAL {
141 out[a * K_SURVIVAL + b] = self.h[[row, a, b]];
142 }
143 }
144 }
145 fn evaluate_full(&self) -> Array3<f64> {
146 self.h.clone()
147 }
148}
149
150impl FamilyChannelHessian for SurvivalRowHessian {
186 fn n_outputs(&self) -> usize {
187 K_SURVIVAL
188 }
189
190 fn n_subjects(&self) -> usize {
191 self.h.shape()[0]
192 }
193
194 fn fill_subject(&self, i: usize, out: &mut [f64]) {
195 assert_eq!(out.len(), K_SURVIVAL * K_SURVIVAL);
196 for a in 0..K_SURVIVAL {
197 for b in 0..K_SURVIVAL {
198 out[a * K_SURVIVAL + b] = self.h[[i, a, b]];
199 }
200 }
201 }
202
203 fn evaluate_full(&self) -> ndarray::Array3<f64> {
204 self.h.clone()
205 }
206
207 fn channel_hessian_at(
208 &self,
209 beta: &[f64],
210 family_scalars: Option<&Arc<dyn std::any::Any + Send + Sync>>,
211 ) -> Result<Arc<dyn FamilyChannelHessian>, String> {
212 use crate::survival::marginal_slope::SurvivalMarginalSlopeFamilyScalars;
213
214 if beta.iter().any(|b| !b.is_finite()) {
215 return Err(
216 "SurvivalRowHessian::channel_hessian_at: beta contains a non-finite value"
217 .to_string(),
218 );
219 }
220 if beta.is_empty() && family_scalars.is_some() {
221 return Err(
222 "SurvivalRowHessian::channel_hessian_at: family_scalars supplied but beta is empty"
223 .to_string(),
224 );
225 }
226 let scalars_opt = match family_scalars {
227 None => None,
228 Some(scalars) => Some(
229 scalars
230 .downcast_ref::<SurvivalMarginalSlopeFamilyScalars>()
231 .ok_or_else(|| {
232 "SurvivalRowHessian::channel_hessian_at: family_scalars has the wrong type; expected SurvivalMarginalSlopeFamilyScalars"
233 .to_string()
234 })?,
235 ),
236 };
237
238 let beta_nontrivial = beta.iter().any(|&b| b != 0.0);
239
240 match scalars_opt {
241 None if beta_nontrivial => {
242 Err(
245 "SurvivalRowHessian::channel_hessian_at: beta is non-trivial but \
246 family_scalars is None; supply SurvivalMarginalSlopeFamilyScalars \
247 via FamilyLinearizationState::family_scalars to evaluate W(β) \
248 correctly (same contract as T26 Jacobian callbacks)."
249 .to_string(),
250 )
251 }
252 None => {
253 Ok(Arc::new(gam_problem::TensorChannelHessian {
255 h: self.h.clone(),
256 }))
257 }
258 Some(sc) => {
259 let n = self.h.shape()[0];
260 if sc.q0_i.len() != n
261 || sc.q1_i.len() != n
262 || sc.qd1_i.len() != n
263 || sc.g_i.len() != n
264 || sc.z_i.len() != n
265 {
266 return Err(format!(
267 "SurvivalRowHessian::channel_hessian_at: scalars length mismatch \
268 (expected n={n}, got q0={} q1={} qd1={} g={} z={})",
269 sc.q0_i.len(),
270 sc.q1_i.len(),
271 sc.qd1_i.len(),
272 sc.g_i.len(),
273 sc.z_i.len(),
274 ));
275 }
276 let mut h_full = Array3::<f64>::zeros((n, K_SURVIVAL, K_SURVIVAL));
277 for i in 0..n {
278 let clamped = evaluated_psd_row_hessian(
279 sc.q0_i[i],
280 sc.q1_i[i],
281 sc.qd1_i[i],
282 sc.g_i[i],
283 sc.z_i[i],
284 self.weights[i],
285 self.event[i],
286 self.derivative_guard,
287 sc.s,
288 )
289 .map_err(|reason| {
290 format!("SurvivalRowHessian::channel_hessian_at: row {i}: {reason}")
291 })?;
292 for a in 0..K_SURVIVAL {
293 for b in 0..K_SURVIVAL {
294 h_full[[i, a, b]] = clamped[[a, b]];
295 }
296 }
297 }
298 Ok(Arc::new(gam_problem::TensorChannelHessian { h: h_full }))
299 }
300 }
301 }
302}
303
304fn evaluated_psd_row_hessian(
305 q0: f64,
306 q1: f64,
307 qd1: f64,
308 g: f64,
309 z: f64,
310 weight: f64,
311 event: f64,
312 derivative_guard: f64,
313 probit_scale: f64,
314) -> Result<Array2<f64>, String> {
315 let (_, _grad, hess) = crate::survival::marginal_slope::row_primary_for_compiler(
316 q0,
317 q1,
318 qd1,
319 g,
320 z,
321 weight,
322 event,
323 derivative_guard,
324 probit_scale,
325 )?;
326 let mut h_i = Array2::<f64>::zeros((K_SURVIVAL, K_SURVIVAL));
327 for a in 0..K_SURVIVAL {
328 for b in 0..K_SURVIVAL {
329 h_i[[a, b]] = hess[a][b];
330 }
331 }
332 psd_clamp_4x4(&h_i)
333}
334
335fn psd_clamp_4x4(m: &Array2<f64>) -> Result<Array2<f64>, String> {
339 let k = m.nrows();
340 if m.dim() != (K_SURVIVAL, K_SURVIVAL) {
341 return Err(format!(
342 "survival row Hessian must be {K_SURVIVAL}x{K_SURVIVAL}, got {}x{}",
343 m.nrows(),
344 m.ncols(),
345 ));
346 }
347 if m.iter().any(|v| !v.is_finite()) {
348 return Err("survival row Hessian contains a non-finite entry".to_string());
349 }
350 let (evals, evecs) = m
351 .eigh(Side::Lower)
352 .map_err(|_| "survival row Hessian symmetric eigendecomposition failed".to_string())?;
353 let mut out = Array2::<f64>::zeros((k, k));
354 for i in 0..k {
355 for j in 0..k {
356 let mut acc = 0.0;
357 for l in 0..k {
358 acc += evecs[[i, l]] * evals[l].max(0.0) * evecs[[j, l]];
359 }
360 out[[i, j]] = acc;
361 }
362 }
363 Ok(out)
364}
365
366pub struct TimeBlockOperator {
369 dq0: Array2<f64>,
370 dq1: Array2<f64>,
371 dqd1: Array2<f64>,
372}
373
374impl TimeBlockOperator {
375 pub fn new(dq0: Array2<f64>, dq1: Array2<f64>, dqd1: Array2<f64>) -> Self {
376 assert_eq!(dq0.dim(), dq1.dim());
377 assert_eq!(dq0.dim(), dqd1.dim());
378 Self { dq0, dq1, dqd1 }
379 }
380}
381
382impl RowJacobianOperator for TimeBlockOperator {
383 fn k(&self) -> usize {
384 K_SURVIVAL
385 }
386 fn ncols(&self) -> usize {
387 self.dq0.ncols()
388 }
389 fn nrows(&self) -> usize {
390 self.dq0.nrows()
391 }
392 fn apply_row(&self, row: usize, delta_beta: &[f64], out: &mut [f64]) {
393 assert_eq!(out.len(), K_SURVIVAL);
394 assert_eq!(delta_beta.len(), self.dq0.ncols());
395 let mut acc = [0.0_f64; K_SURVIVAL];
396 for (j, &b) in delta_beta.iter().enumerate() {
397 acc[0] += self.dq0[[row, j]] * b;
398 acc[1] += self.dq1[[row, j]] * b;
399 acc[2] += self.dqd1[[row, j]] * b;
400 }
401 out.copy_from_slice(&acc);
402 }
403 fn evaluate_full(&self) -> Array3<f64> {
404 let n = self.dq0.nrows();
405 let p = self.dq0.ncols();
406 let mut out = Array3::<f64>::zeros((n, p, K_SURVIVAL));
407 for i in 0..n {
408 for j in 0..p {
409 out[[i, j, 0]] = self.dq0[[i, j]];
410 out[[i, j, 1]] = self.dq1[[i, j]];
411 out[[i, j, 2]] = self.dqd1[[i, j]];
412 }
413 }
414 out
415 }
416 fn scaled_design_by_sqrt_h(&self, h_full: &Array3<f64>) -> Array2<f64> {
417 let n = self.dq0.nrows();
423 let p = self.dq0.ncols();
424 scale_jacobian_by_sqrt_h_with(n, p, K_SURVIVAL, h_full, |i, a, c| match c {
425 0 => self.dq0[[i, a]],
426 1 => self.dq1[[i, a]],
427 2 => self.dqd1[[i, a]],
428 _ => 0.0,
429 })
430 }
431}
432
433pub struct QChannelBlockOperator {
438 dq: Array2<f64>,
439 dqd1: Array2<f64>,
440}
441
442impl QChannelBlockOperator {
443 pub fn new(dq: Array2<f64>, dqd1: Array2<f64>) -> Self {
444 assert_eq!(dq.dim(), dqd1.dim());
445 Self { dq, dqd1 }
446 }
447}
448
449impl RowJacobianOperator for QChannelBlockOperator {
450 fn k(&self) -> usize {
451 K_SURVIVAL
452 }
453 fn ncols(&self) -> usize {
454 self.dq.ncols()
455 }
456 fn nrows(&self) -> usize {
457 self.dq.nrows()
458 }
459 fn apply_row(&self, row: usize, delta_beta: &[f64], out: &mut [f64]) {
460 assert_eq!(out.len(), K_SURVIVAL);
461 assert_eq!(delta_beta.len(), self.dq.ncols());
462 let mut dq_acc = 0.0;
463 let mut dqd_acc = 0.0;
464 for (j, &b) in delta_beta.iter().enumerate() {
465 dq_acc += self.dq[[row, j]] * b;
466 dqd_acc += self.dqd1[[row, j]] * b;
467 }
468 out[0] = dq_acc;
469 out[1] = dq_acc;
470 out[2] = dqd_acc;
471 out[3] = 0.0;
472 }
473 fn evaluate_full(&self) -> Array3<f64> {
474 let n = self.dq.nrows();
475 let p = self.dq.ncols();
476 let mut out = Array3::<f64>::zeros((n, p, K_SURVIVAL));
477 for i in 0..n {
478 for j in 0..p {
479 let v = self.dq[[i, j]];
480 out[[i, j, 0]] = v;
481 out[[i, j, 1]] = v;
482 out[[i, j, 2]] = self.dqd1[[i, j]];
483 }
484 }
485 out
486 }
487 fn scaled_design_by_sqrt_h(&self, h_full: &Array3<f64>) -> Array2<f64> {
488 let n = self.dq.nrows();
492 let p = self.dq.ncols();
493 scale_jacobian_by_sqrt_h_with(n, p, K_SURVIVAL, h_full, |i, a, c| match c {
494 0 | 1 => self.dq[[i, a]],
495 2 => self.dqd1[[i, a]],
496 _ => 0.0,
497 })
498 }
499}
500
501pub struct LogslopeBlockOperator {
504 dg: Array2<f64>,
505}
506
507impl LogslopeBlockOperator {
508 pub fn new(dg: Array2<f64>) -> Self {
509 Self { dg }
510 }
511}
512
513impl RowJacobianOperator for LogslopeBlockOperator {
514 fn k(&self) -> usize {
515 K_SURVIVAL
516 }
517 fn ncols(&self) -> usize {
518 self.dg.ncols()
519 }
520 fn nrows(&self) -> usize {
521 self.dg.nrows()
522 }
523 fn apply_row(&self, row: usize, delta_beta: &[f64], out: &mut [f64]) {
524 assert_eq!(out.len(), K_SURVIVAL);
525 assert_eq!(delta_beta.len(), self.dg.ncols());
526 let mut acc = 0.0;
527 for (j, &b) in delta_beta.iter().enumerate() {
528 acc += self.dg[[row, j]] * b;
529 }
530 out[0] = 0.0;
531 out[1] = 0.0;
532 out[2] = 0.0;
533 out[3] = acc;
534 }
535 fn evaluate_full(&self) -> Array3<f64> {
536 let n = self.dg.nrows();
537 let p = self.dg.ncols();
538 let mut out = Array3::<f64>::zeros((n, p, K_SURVIVAL));
539 for i in 0..n {
540 for j in 0..p {
541 out[[i, j, 3]] = self.dg[[i, j]];
542 }
543 }
544 out
545 }
546 fn scaled_design_by_sqrt_h(&self, h_full: &Array3<f64>) -> Array2<f64> {
547 let n = self.dg.nrows();
552 let p = self.dg.ncols();
553 scale_jacobian_by_sqrt_h_with(n, p, K_SURVIVAL, h_full, |i, a, c| {
554 if c == 3 { self.dg[[i, a]] } else { 0.0 }
555 })
556 }
557}
558
559pub struct SurvivalCompilerInputs {
563 pub operators: Vec<Arc<dyn RowJacobianOperator>>,
564 pub ordering: Vec<BlockOrder>,
565}
566
567pub struct SurvivalParametricCompiled {
583 pub v_time: Array2<f64>,
584 pub v_marginal: Array2<f64>,
585 pub v_logslope: Array2<f64>,
586 pub drops_by_block: (usize, usize, usize),
591}
592
593fn wrap_design_with_transform(
594 raw: DesignMatrix,
595 v: &Array2<f64>,
596 context: &str,
597) -> Result<DesignMatrix, String> {
598 if raw.ncols() != v.nrows() {
599 return Err(format!(
600 "{context}: raw design has {} cols but V has {} rows (V is {}×{})",
601 raw.ncols(),
602 v.nrows(),
603 v.nrows(),
604 v.ncols(),
605 ));
606 }
607 let inner_dense = match raw {
608 DesignMatrix::Dense(d) => d,
609 DesignMatrix::Sparse(_) => {
610 let dense = raw
611 .try_to_dense_by_chunks(&format!("{context} sparse→dense for V apply"))
612 .map_err(|reason| format!("{context}: densify failed: {reason}"))?;
613 DenseDesignMatrix::from(dense)
614 }
615 };
616 let op = CoefficientTransformOperator::new(inner_dense, v.clone())
617 .map_err(|reason| format!("{context}: CoefficientTransformOperator::new: {reason}"))?;
618 Ok(DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(op))))
619}
620
621pub struct SurvivalParametricCompiledPerTerm {
629 pub v_time_per_term: Vec<Array2<f64>>,
630 pub v_marginal_per_term: Vec<Array2<f64>>,
631 pub v_logslope_per_term: Vec<Array2<f64>>,
632 pub r_lw_per_term: Vec<Option<Array2<f64>>>,
639 pub drops_by_block: (usize, usize, usize),
641}
642
643pub fn compile_survival_parametric_designs_per_term(
662 time_dq0: Array2<f64>,
663 time_dq1: Array2<f64>,
664 time_dqd1: Array2<f64>,
665 time_partition: &[std::ops::Range<usize>],
666 marginal_dq: Array2<f64>,
667 marginal_dqd1: Array2<f64>,
668 marginal_partition: &[std::ops::Range<usize>],
669 logslope_dg: Array2<f64>,
670 logslope_partition: &[std::ops::Range<usize>],
671 row_hess: &dyn RowHessian,
672 protect_time: bool,
673) -> Result<SurvivalParametricCompiledPerTerm, String> {
674 use gam_identifiability::families::compiler::compile_protected;
675
676 let p_time = time_dq0.ncols();
677 let p_marg = marginal_dq.ncols();
678 let p_log = logslope_dg.ncols();
679 validate_partition(time_partition, p_time, "time")?;
680 validate_partition(marginal_partition, p_marg, "marginal")?;
681 validate_partition(logslope_partition, p_log, "logslope")?;
682
683 let mut operators: Vec<Arc<dyn RowJacobianOperator>> = Vec::new();
687 let mut ordering: Vec<BlockOrder> = Vec::new();
688 for range in time_partition {
689 let dq0 = time_dq0.slice(ndarray::s![.., range.clone()]).to_owned();
690 let dq1 = time_dq1.slice(ndarray::s![.., range.clone()]).to_owned();
691 let dqd1 = time_dqd1.slice(ndarray::s![.., range.clone()]).to_owned();
692 operators.push(Arc::new(TimeBlockOperator::new(dq0, dq1, dqd1)));
693 ordering.push(BlockOrder::Time);
694 }
695 for range in marginal_partition {
696 let dq = marginal_dq.slice(ndarray::s![.., range.clone()]).to_owned();
697 let dqd1 = marginal_dqd1
698 .slice(ndarray::s![.., range.clone()])
699 .to_owned();
700 operators.push(Arc::new(QChannelBlockOperator::new(dq, dqd1)));
701 ordering.push(BlockOrder::Marginal);
702 }
703 for range in logslope_partition {
704 let dg = logslope_dg.slice(ndarray::s![.., range.clone()]).to_owned();
705 operators.push(Arc::new(LogslopeBlockOperator::new(dg)));
706 ordering.push(BlockOrder::Logslope);
707 }
708
709 let n_time = time_partition.len();
718 let protected: Vec<bool> = if protect_time {
719 (0..operators.len()).map(|i| i < n_time).collect()
720 } else {
721 Vec::new()
722 };
723 let compiled = compile_protected(&operators, row_hess, &ordering, &protected).map_err(|e| {
724 format!("identifiability::families::compiler::compile (per-term) failed: {e}")
725 })?;
726 let blocks = compiled.blocks;
727 let n_marg = marginal_partition.len();
728 let n_log = logslope_partition.len();
729 if blocks.len() != n_time + n_marg + n_log {
730 return Err(format!(
731 "per-term compile: expected {} compiled blocks (time={}, marg={}, log={}), got {}",
732 n_time + n_marg + n_log,
733 n_time,
734 n_marg,
735 n_log,
736 blocks.len(),
737 ));
738 }
739 let mut iter = blocks.into_iter();
740 let mut v_time_per_term: Vec<Array2<f64>> = Vec::with_capacity(n_time);
741 let mut r_time_per_term: Vec<Option<Array2<f64>>> = Vec::with_capacity(n_time);
742 for _ in 0..n_time {
743 let blk = iter.next().unwrap();
744 v_time_per_term.push(blk.t_lw);
745 r_time_per_term.push(blk.r_lw);
746 }
747 let mut v_marginal_per_term: Vec<Array2<f64>> = Vec::with_capacity(n_marg);
748 let mut r_marginal_per_term: Vec<Option<Array2<f64>>> = Vec::with_capacity(n_marg);
749 for _ in 0..n_marg {
750 let blk = iter.next().unwrap();
751 v_marginal_per_term.push(blk.t_lw);
752 r_marginal_per_term.push(blk.r_lw);
753 }
754 let mut v_logslope_per_term: Vec<Array2<f64>> = Vec::with_capacity(n_log);
755 let mut r_logslope_per_term: Vec<Option<Array2<f64>>> = Vec::with_capacity(n_log);
756 for _ in 0..n_log {
757 let blk = iter.next().unwrap();
758 v_logslope_per_term.push(blk.t_lw);
759 r_logslope_per_term.push(blk.r_lw);
760 }
761 let mut r_lw_per_term: Vec<Option<Array2<f64>>> = Vec::with_capacity(n_time + n_marg + n_log);
762 r_lw_per_term.extend(r_time_per_term);
763 r_lw_per_term.extend(r_marginal_per_term);
764 r_lw_per_term.extend(r_logslope_per_term);
765 let drops_time: usize = time_partition
766 .iter()
767 .zip(v_time_per_term.iter())
768 .map(|(r, v)| r.len().saturating_sub(v.ncols()))
769 .sum();
770 let drops_marg: usize = marginal_partition
771 .iter()
772 .zip(v_marginal_per_term.iter())
773 .map(|(r, v)| r.len().saturating_sub(v.ncols()))
774 .sum();
775 let drops_log: usize = logslope_partition
776 .iter()
777 .zip(v_logslope_per_term.iter())
778 .map(|(r, v)| r.len().saturating_sub(v.ncols()))
779 .sum();
780 Ok(SurvivalParametricCompiledPerTerm {
781 v_time_per_term,
782 v_marginal_per_term,
783 v_logslope_per_term,
784 r_lw_per_term,
785 drops_by_block: (drops_time, drops_marg, drops_log),
786 })
787}
788
789fn validate_partition(
790 partition: &[std::ops::Range<usize>],
791 p_block: usize,
792 label: &str,
793) -> Result<(), String> {
794 if partition.is_empty() {
795 if p_block == 0 {
796 return Ok(());
797 }
798 return Err(format!(
799 "{label} partition empty but block has p={p_block} columns"
800 ));
801 }
802 if partition[0].start != 0 {
803 return Err(format!(
804 "{label} partition must start at 0, got start={}",
805 partition[0].start
806 ));
807 }
808 if partition.last().unwrap().end != p_block {
809 return Err(format!(
810 "{label} partition must cover [0, {p_block}); last range ends at {}",
811 partition.last().unwrap().end
812 ));
813 }
814 for w in partition.windows(2) {
815 if w[0].end != w[1].start {
816 return Err(format!(
817 "{label} partition has gap/overlap between [{}..{}) and [{}..{})",
818 w[0].start, w[0].end, w[1].start, w[1].end
819 ));
820 }
821 if w[0].is_empty() {
822 return Err(format!(
823 "{label} partition has empty range [{}..{})",
824 w[0].start, w[0].end
825 ));
826 }
827 }
828 if partition.last().unwrap().is_empty() {
829 return Err(format!("{label} partition's final range is empty",));
830 }
831 Ok(())
832}
833
834pub fn extract_term_partition_from_penalty_ranges(
840 p_block: usize,
841 penalty_ranges: &[std::ops::Range<usize>],
842) -> Vec<std::ops::Range<usize>> {
843 use std::collections::BTreeSet;
844 let mut starts: BTreeSet<usize> = BTreeSet::new();
845 starts.insert(0);
846 starts.insert(p_block);
847 for r in penalty_ranges {
848 starts.insert(r.start.min(p_block));
849 starts.insert(r.end.min(p_block));
850 }
851 let v: Vec<usize> = starts.into_iter().collect();
852 v.windows(2)
853 .filter_map(|w| if w[0] < w[1] { Some(w[0]..w[1]) } else { None })
854 .collect()
855}
856
857pub fn pull_back_blockwise_penalty_through_block_v(
880 pen: &gam_terms::smooth::BlockwisePenalty,
881 v_block: &Array2<f64>,
882) -> Result<PenaltyMatrix, String> {
883 let raw_p = v_block.nrows();
884 let compiled_p = v_block.ncols();
885 let block_p = pen.col_range.len();
886 let embed_start = pen.col_range.start;
887 let embed_end = pen.col_range.end;
888 if embed_end > raw_p {
889 return Err(format!(
890 "pull_back_blockwise_penalty_through_block_v: penalty col_range {embed_start}..{embed_end} \
891 exceeds block raw width {raw_p}"
892 ));
893 }
894 if pen.local.nrows() != block_p || pen.local.ncols() != block_p {
895 return Err(format!(
896 "pull_back_blockwise_penalty_through_block_v: penalty local is {}x{} but col_range \
897 width is {block_p}",
898 pen.local.nrows(),
899 pen.local.ncols(),
900 ));
901 }
902 let mut embedded = Array2::<f64>::zeros((raw_p, raw_p));
903 if block_p > 0 {
904 let mut dst =
905 embedded.slice_mut(ndarray::s![embed_start..embed_end, embed_start..embed_end]);
906 for i in 0..block_p {
907 for j in 0..block_p {
908 dst[[i, j]] = pen.local[[i, j]];
909 }
910 }
911 }
912 let temp = embedded.dot(v_block);
914 let pulled = v_block.t().dot(&temp);
915 let mut sym = Array2::<f64>::zeros((compiled_p, compiled_p));
916 for i in 0..compiled_p {
917 for j in 0..compiled_p {
918 sym[[i, j]] = 0.5 * (pulled[[i, j]] + pulled[[j, i]]);
919 }
920 }
921 Ok(PenaltyMatrix::Dense(sym))
922}
923
924pub fn compiled_map_from_per_term(
946 compiled: &SurvivalParametricCompiledPerTerm,
947) -> gam_identifiability::families::compiler::CompiledMap {
948 let mut v_all: Vec<Array2<f64>> = Vec::new();
951 v_all.extend(compiled.v_time_per_term.iter().cloned());
952 v_all.extend(compiled.v_marginal_per_term.iter().cloned());
953 v_all.extend(compiled.v_logslope_per_term.iter().cloned());
954
955 let t_full = assemble_block_triangular_t(&v_all, &compiled.r_lw_per_term);
956
957 let raw_w = |terms: &[Array2<f64>]| -> usize { terms.iter().map(|v| v.nrows()).sum() };
959 let kept_w = |terms: &[Array2<f64>]| -> usize { terms.iter().map(|v| v.ncols()).sum() };
960 let raw_time = raw_w(&compiled.v_time_per_term);
961 let raw_marg = raw_w(&compiled.v_marginal_per_term);
962 let raw_log = raw_w(&compiled.v_logslope_per_term);
963 let kept_time = kept_w(&compiled.v_time_per_term);
964 let kept_marg = kept_w(&compiled.v_marginal_per_term);
965 let kept_log = kept_w(&compiled.v_logslope_per_term);
966
967 let raw_block_ranges = vec![
968 0..raw_time,
969 raw_time..(raw_time + raw_marg),
970 (raw_time + raw_marg)..(raw_time + raw_marg + raw_log),
971 ];
972 let compiled_block_ranges = vec![
973 0..kept_time,
974 kept_time..(kept_time + kept_marg),
975 (kept_time + kept_marg)..(kept_time + kept_marg + kept_log),
976 ];
977
978 gam_identifiability::families::compiler::CompiledMap {
979 raw_from_compiled: t_full,
980 compiled_block_ranges,
981 raw_block_ranges,
982 }
983}
984
985pub(crate) fn survival_reduced_logslope_transform_effective(
1040 marginal_dq: ndarray::ArrayView2<'_, f64>,
1041 logslope_dg: ndarray::ArrayView2<'_, f64>,
1042 row_hess: &SurvivalRowHessian,
1043) -> Result<crate::bms::block_specs::ReducedLogslopeOutcome, String> {
1044 use crate::bms::block_specs::{LOGSLOPE_REDUCED_BASIS_RELATIVE_TOL, ReducedLogslopeOutcome};
1045 use gam_linalg::faer_ndarray::{
1046 FaerArrayView, factorize_symmetricwith_fallback, fast_atb, fast_xt_diag_x, fast_xt_diag_y,
1047 };
1048
1049 let n = marginal_dq.nrows();
1050 let p_m = marginal_dq.ncols();
1051 let p_log = logslope_dg.ncols();
1052 if p_m == 0 || p_log == 0 {
1053 return Ok(ReducedLogslopeOutcome::FullRank);
1054 }
1055 if logslope_dg.nrows() != n || row_hess.h.shape()[0] != n {
1056 return Err(format!(
1057 "survival reduced logslope: row mismatch marginal={n}, logslope={}, row_hess={}",
1058 logslope_dg.nrows(),
1059 row_hess.h.shape()[0],
1060 ));
1061 }
1062
1063 let mut w_mm = Array1::<f64>::zeros(n);
1066 let mut w_mg = Array1::<f64>::zeros(n);
1067 let mut w_gg = Array1::<f64>::zeros(n);
1068 for i in 0..n {
1069 w_mm[i] = row_hess.h[[i, 0, 0]] + row_hess.h[[i, 1, 1]];
1070 w_mg[i] = row_hess.h[[i, 0, 3]] + row_hess.h[[i, 1, 3]];
1071 w_gg[i] = row_hess.h[[i, 3, 3]];
1072 if !(w_mm[i].is_finite() && w_mg[i].is_finite() && w_gg[i].is_finite()) {
1073 return Err("survival reduced logslope: non-finite row Hessian weight".to_string());
1074 }
1075 }
1076
1077 let marg = marginal_dq.to_owned();
1078 let log = logslope_dg.to_owned();
1079
1080 let c_gram = fast_xt_diag_x(&log, &w_gg);
1083 let energy_scale = (0..p_log).map(|i| c_gram[[i, i]]).fold(0.0_f64, f64::max);
1084 if !energy_scale.is_finite() {
1085 return Err(
1086 "survival reduced logslope: non-finite effective logslope energy scale".to_string(),
1087 );
1088 }
1089 if energy_scale <= 0.0 {
1090 return Ok(ReducedLogslopeOutcome::FullyConfounded);
1093 }
1094
1095 let mut a_gram = fast_xt_diag_x(&marg, &w_mm);
1099 let a_scale = (0..p_m).map(|i| a_gram[[i, i]]).fold(0.0_f64, f64::max);
1100 let a_ridge = (a_scale * LOGSLOPE_REDUCED_BASIS_RELATIVE_TOL).max(f64::EPSILON);
1101 for i in 0..p_m {
1102 a_gram[[i, i]] += a_ridge;
1103 }
1104
1105 let b_cross = fast_xt_diag_y(&marg, &w_mg, &log);
1107 let a_view = FaerArrayView::new(&a_gram);
1108 let a_factor = factorize_symmetricwith_fallback(a_view.as_ref(), Side::Lower).map_err(|e| {
1109 format!("survival reduced logslope: marginal effective Gram factorization failed: {e}")
1110 })?;
1111 let b_view = FaerArrayView::new(&b_cross);
1112 let solved = a_factor.solve(b_view.as_ref()); let a_inv_b = Array2::from_shape_fn((p_m, p_log), |(i, j)| solved[(i, j)]);
1114 let schur = fast_atb(&b_cross, &a_inv_b); let mut stt = &c_gram - &schur;
1116 stt = (&stt + &stt.t()) * 0.5;
1117 if stt.iter().any(|v| !v.is_finite()) {
1118 return Err(
1119 "survival reduced logslope: effective Schur Gram produced non-finite entries"
1120 .to_string(),
1121 );
1122 }
1123
1124 let (evals, evecs) = stt
1125 .eigh(Side::Lower)
1126 .map_err(|e| format!("survival reduced logslope: eigendecomposition failed: {e:?}"))?;
1127 let tol = energy_scale * LOGSLOPE_REDUCED_BASIS_RELATIVE_TOL;
1131 let mut kept: Vec<usize> = (0..evals.len()).filter(|&i| evals[i] > tol).collect();
1132 kept.sort_by(|&a, &b| {
1133 evals[b]
1134 .partial_cmp(&evals[a])
1135 .unwrap_or(std::cmp::Ordering::Equal)
1136 });
1137 let r = kept.len();
1138 if r == p_log {
1145 return Ok(ReducedLogslopeOutcome::FullRank);
1146 }
1147 if r == 0 {
1148 return Ok(ReducedLogslopeOutcome::FullyConfounded);
1149 }
1150 let mut transform = Array2::<f64>::zeros((p_log, r));
1151 for (out_col, &src) in kept.iter().enumerate() {
1152 transform.column_mut(out_col).assign(&evecs.column(src));
1153 }
1154 if transform.iter().any(|v| !v.is_finite()) {
1155 return Err(
1156 "survival reduced logslope: reduced transform produced non-finite entries".to_string(),
1157 );
1158 }
1159 Ok(ReducedLogslopeOutcome::Reduced(transform))
1160}
1161
1162pub fn survival_block_diagonal_logslope_map(
1179 p_time: usize,
1180 p_marg: usize,
1181 t_log: &Array2<f64>,
1182) -> gam_identifiability::families::compiler::CompiledMap {
1183 let p_log = t_log.nrows();
1184 let r = t_log.ncols();
1185 let raw_total = p_time + p_marg + p_log;
1186 let compiled_total = p_time + p_marg + r;
1187 let mut t_full = Array2::<f64>::zeros((raw_total, compiled_total));
1188 for i in 0..p_time {
1189 t_full[[i, i]] = 1.0;
1190 }
1191 for i in 0..p_marg {
1192 t_full[[p_time + i, p_time + i]] = 1.0;
1193 }
1194 for ri in 0..p_log {
1195 for cj in 0..r {
1196 t_full[[p_time + p_marg + ri, p_time + p_marg + cj]] = t_log[[ri, cj]];
1197 }
1198 }
1199 gam_identifiability::families::compiler::CompiledMap {
1200 raw_from_compiled: t_full,
1201 compiled_block_ranges: vec![
1202 0..p_time,
1203 p_time..(p_time + p_marg),
1204 (p_time + p_marg)..compiled_total,
1205 ],
1206 raw_block_ranges: vec![
1207 0..p_time,
1208 p_time..(p_time + p_marg),
1209 (p_time + p_marg)..raw_total,
1210 ],
1211 }
1212}
1213
1214pub fn apply_compiled_map_to_designs(
1242 map: &gam_identifiability::families::compiler::CompiledMap,
1243 time_design_entry: DesignMatrix,
1244 time_design_exit: DesignMatrix,
1245 time_design_derivative_exit: DesignMatrix,
1246 marginal_design: DesignMatrix,
1247 logslope_design: DesignMatrix,
1248 time_penalties: &[gam_terms::smooth::BlockwisePenalty],
1249 marginal_penalties: &[gam_terms::smooth::BlockwisePenalty],
1250 logslope_penalties: &[gam_terms::smooth::BlockwisePenalty],
1251) -> Result<CompiledSurvivalDesignsVMExact, String> {
1252 if map.raw_block_ranges.len() != 3 || map.compiled_block_ranges.len() != 3 {
1253 return Err(format!(
1254 "apply_compiled_map_to_designs: expected exactly 3 blocks (time, marginal, logslope), \
1255 got {} raw / {} compiled",
1256 map.raw_block_ranges.len(),
1257 map.compiled_block_ranges.len(),
1258 ));
1259 }
1260 let time_raw = map.raw_block_ranges[0].clone();
1261 let marg_raw = map.raw_block_ranges[1].clone();
1262 let log_raw = map.raw_block_ranges[2].clone();
1263 let time_compiled = map.compiled_block_ranges[0].clone();
1264 let marg_compiled = map.compiled_block_ranges[1].clone();
1265 let log_compiled = map.compiled_block_ranges[2].clone();
1266
1267 let t = &map.raw_from_compiled;
1268 let raw_total = t.nrows();
1269 let compiled_total = t.ncols();
1270 let expected_raw_total = log_raw.end;
1271 if raw_total != expected_raw_total {
1272 return Err(format!(
1273 "apply_compiled_map_to_designs: T has {raw_total} raw rows but block ranges sum to \
1274 {expected_raw_total}"
1275 ));
1276 }
1277 let expected_compiled_total = log_compiled.end;
1278 if compiled_total != expected_compiled_total {
1279 return Err(format!(
1280 "apply_compiled_map_to_designs: T has {compiled_total} compiled cols but block ranges \
1281 sum to {expected_compiled_total}"
1282 ));
1283 }
1284
1285 let v_time = t
1286 .slice(ndarray::s![time_raw.clone(), time_compiled.clone()])
1287 .to_owned();
1288 let v_marg = t
1289 .slice(ndarray::s![marg_raw.clone(), marg_compiled.clone()])
1290 .to_owned();
1291 let v_log = t
1292 .slice(ndarray::s![log_raw.clone(), log_compiled.clone()])
1293 .to_owned();
1294
1295 let time_entry_out =
1296 wrap_design_with_transform(time_design_entry, &v_time, "compiled-map: time entry")?;
1297 let time_exit_out =
1298 wrap_design_with_transform(time_design_exit, &v_time, "compiled-map: time exit")?;
1299 let time_deriv_out = wrap_design_with_transform(
1300 time_design_derivative_exit,
1301 &v_time,
1302 "compiled-map: time derivative_exit",
1303 )?;
1304 let marg_out = wrap_design_with_transform(marginal_design, &v_marg, "compiled-map: marginal")?;
1305 let log_out = wrap_design_with_transform(logslope_design, &v_log, "compiled-map: logslope")?;
1306
1307 let pull_set = |pens: &[gam_terms::smooth::BlockwisePenalty],
1328 v_block: &Array2<f64>,
1329 channel: &str|
1330 -> Result<Vec<PenaltyMatrix>, String> {
1331 pens.iter()
1332 .map(|p| {
1333 pull_back_blockwise_penalty_through_block_v(p, v_block).map_err(|e| {
1334 format!("apply_compiled_map_to_designs: {channel} penalty pullback: {e}")
1335 })
1336 })
1337 .collect()
1338 };
1339
1340 let time_penalties = pull_set(time_penalties, &v_time, "time")?;
1341 let marginal_penalties = pull_set(marginal_penalties, &v_marg, "marginal")?;
1342 let logslope_penalties = pull_set(logslope_penalties, &v_log, "logslope")?;
1343 validate_block_penalty_shapes("time", time_exit_out.ncols(), &time_penalties)?;
1344 validate_block_penalty_shapes("marginal", marg_out.ncols(), &marginal_penalties)?;
1345 validate_block_penalty_shapes("logslope", log_out.ncols(), &logslope_penalties)?;
1346
1347 Ok(CompiledSurvivalDesignsVMExact {
1348 time_design_entry: time_entry_out,
1349 time_design_exit: time_exit_out,
1350 time_design_derivative_exit: time_deriv_out,
1351 marginal_design: marg_out,
1352 logslope_design: log_out,
1353 time_penalties,
1354 marginal_penalties,
1355 logslope_penalties,
1356 })
1357}
1358
1359fn validate_block_penalty_shapes(
1360 block: &str,
1361 width: usize,
1362 penalties: &[PenaltyMatrix],
1363) -> Result<(), String> {
1364 for (idx, penalty) in penalties.iter().enumerate() {
1365 let shape = penalty.shape();
1366 if shape != (width, width) {
1367 return Err(format!(
1368 "apply_compiled_map_to_designs: {block} penalty {idx} must be {width}x{width}, got {}x{}",
1369 shape.0, shape.1
1370 ));
1371 }
1372 }
1373 Ok(())
1374}
1375
1376pub fn compile_survival_parametric_designs(
1404 time_dq0: Array2<f64>,
1405 time_dq1: Array2<f64>,
1406 time_dqd1: Array2<f64>,
1407 marginal_dq: Array2<f64>,
1408 marginal_dqd1: Array2<f64>,
1409 logslope_dg: Array2<f64>,
1410 row_hess: &dyn RowHessian,
1411) -> Result<SurvivalParametricCompiled, String> {
1412 use gam_identifiability::families::compiler::compile;
1413
1414 let p_time_raw = time_dq0.ncols();
1415 let p_marg_raw = marginal_dq.ncols();
1416 let p_log_raw = logslope_dg.ncols();
1417
1418 let inputs = build_survival_compiler_inputs(
1419 time_dq0,
1420 time_dq1,
1421 time_dqd1,
1422 marginal_dq,
1423 marginal_dqd1,
1424 logslope_dg,
1425 None,
1426 None,
1427 );
1428 if inputs.operators.len() != 3 {
1429 return Err(format!(
1430 "compile_survival_parametric_designs: expected exactly 3 parametric operators \
1431 (time, marginal, logslope); got {}",
1432 inputs.operators.len(),
1433 ));
1434 }
1435 let compiled = compile(&inputs.operators, row_hess, &inputs.ordering)
1436 .map_err(|e| format!("identifiability::families::compiler::compile failed: {e}"))?;
1437 if compiled.blocks.len() != 3 {
1438 return Err(format!(
1439 "compile_survival_parametric_designs: compiler emitted {} blocks; expected 3",
1440 compiled.blocks.len(),
1441 ));
1442 }
1443 let v_time = compiled.blocks[0].t_lw.clone();
1444 let v_marginal = compiled.blocks[1].t_lw.clone();
1445 let v_logslope = compiled.blocks[2].t_lw.clone();
1446 let drops_by_block = (
1447 p_time_raw.saturating_sub(v_time.ncols()),
1448 p_marg_raw.saturating_sub(v_marginal.ncols()),
1449 p_log_raw.saturating_sub(v_logslope.ncols()),
1450 );
1451 Ok(SurvivalParametricCompiled {
1452 v_time,
1453 v_marginal,
1454 v_logslope,
1455 drops_by_block,
1456 })
1457}
1458
1459pub fn build_survival_compiler_inputs(
1471 time_dq0: Array2<f64>,
1472 time_dq1: Array2<f64>,
1473 time_dqd1: Array2<f64>,
1474 marginal_dq: Array2<f64>,
1475 marginal_dqd1: Array2<f64>,
1476 logslope_dg: Array2<f64>,
1477 score_warp_dq_dqd1: Option<(Array2<f64>, Array2<f64>)>,
1478 link_dev_dq_dqd1: Option<(Array2<f64>, Array2<f64>)>,
1479) -> SurvivalCompilerInputs {
1480 let mut operators: Vec<Arc<dyn RowJacobianOperator>> = Vec::with_capacity(5);
1481 let mut ordering: Vec<BlockOrder> = Vec::with_capacity(5);
1482
1483 operators.push(Arc::new(TimeBlockOperator::new(
1484 time_dq0, time_dq1, time_dqd1,
1485 )));
1486 ordering.push(BlockOrder::Time);
1487
1488 operators.push(Arc::new(QChannelBlockOperator::new(
1489 marginal_dq,
1490 marginal_dqd1,
1491 )));
1492 ordering.push(BlockOrder::Marginal);
1493
1494 operators.push(Arc::new(LogslopeBlockOperator::new(logslope_dg)));
1495 ordering.push(BlockOrder::Logslope);
1496
1497 if let Some((dq, dqd1)) = score_warp_dq_dqd1 {
1498 operators.push(Arc::new(QChannelBlockOperator::new(dq, dqd1)));
1499 ordering.push(BlockOrder::ScoreWarp);
1500 }
1501 if let Some((dq, dqd1)) = link_dev_dq_dqd1 {
1502 operators.push(Arc::new(QChannelBlockOperator::new(dq, dqd1)));
1503 ordering.push(BlockOrder::LinkDev);
1504 }
1505
1506 SurvivalCompilerInputs {
1507 operators,
1508 ordering,
1509 }
1510}
1511
1512pub struct CompiledSurvivalDesignsVMExact {
1531 pub time_design_entry: DesignMatrix,
1532 pub time_design_exit: DesignMatrix,
1533 pub time_design_derivative_exit: DesignMatrix,
1534 pub marginal_design: DesignMatrix,
1535 pub logslope_design: DesignMatrix,
1536 pub time_penalties: Vec<PenaltyMatrix>,
1544 pub marginal_penalties: Vec<PenaltyMatrix>,
1545 pub logslope_penalties: Vec<PenaltyMatrix>,
1546}
1547
1548#[cfg(test)]
1549mod tests {
1550
1551 fn survival_row_hessian_from_full(h: Array3<f64>) -> SurvivalRowHessian {
1556 assert_eq!(h.shape()[1], K_SURVIVAL);
1557 assert_eq!(h.shape()[2], K_SURVIVAL);
1558 let n = h.shape()[0];
1559 SurvivalRowHessian {
1560 h,
1561 weights: Array1::ones(n),
1562 event: Array1::ones(n),
1563 derivative_guard:
1564 crate::survival::marginal_slope::DEFAULT_SURVIVAL_MARGINAL_SLOPE_DERIVATIVE_GUARD,
1565 }
1566 }
1567 use super::*;
1568 use gam_problem::Gauge;
1569
1570 #[test]
1571 fn psd_clamp_zeros_negative_eigenvalues() {
1572 let mut m = Array2::<f64>::zeros((4, 4));
1576 m[[0, 0]] = 2.0;
1579 m[[1, 1]] = -1.0;
1580 m[[2, 2]] = 0.5;
1581 m[[3, 3]] = -0.25;
1582 let clamped = psd_clamp_4x4(&m).expect("finite 4x4 eigendecomposition must succeed");
1583 assert!((clamped[[0, 0]] - 2.0).abs() < 1e-12);
1584 assert!(clamped[[1, 1]].abs() < 1e-12);
1585 assert!((clamped[[2, 2]] - 0.5).abs() < 1e-12);
1586 assert!(clamped[[3, 3]].abs() < 1e-12);
1587 }
1588
1589 #[test]
1590 fn time_block_operator_evaluate_full_shape() {
1591 let n = 6;
1592 let p = 3;
1593 let dq0 = Array2::from_shape_fn((n, p), |(i, j)| (i + j) as f64);
1594 let dq1 = Array2::from_shape_fn((n, p), |(i, j)| (i as f64) * 2.0 + j as f64);
1595 let dqd1 = Array2::from_shape_fn((n, p), |(i, j)| 0.5 * ((i * j) as f64));
1596 let op = TimeBlockOperator::new(dq0.clone(), dq1.clone(), dqd1.clone());
1597 let full = op.evaluate_full();
1598 assert_eq!(full.shape(), &[n, p, K_SURVIVAL]);
1599 for i in 0..n {
1600 for j in 0..p {
1601 assert_eq!(full[[i, j, 0]], dq0[[i, j]]);
1602 assert_eq!(full[[i, j, 1]], dq1[[i, j]]);
1603 assert_eq!(full[[i, j, 2]], dqd1[[i, j]]);
1604 assert_eq!(full[[i, j, 3]], 0.0);
1605 }
1606 }
1607 }
1608
1609 #[test]
1610 fn q_channel_block_apply_row_shares_q0_q1() {
1611 let n = 5;
1612 let p = 2;
1613 let dq = Array2::from_shape_fn((n, p), |(i, j)| (i as f64) * (j as f64 + 1.0));
1614 let dqd1 = Array2::from_shape_fn((n, p), |(i, j)| (j as f64) - (i as f64));
1615 let op = QChannelBlockOperator::new(dq.clone(), dqd1.clone());
1616 let mut out = [0.0_f64; K_SURVIVAL];
1617 let delta = [1.0_f64, -0.5];
1618 op.apply_row(3, &delta, &mut out);
1619 let want_q = dq[[3, 0]] * 1.0 + dq[[3, 1]] * (-0.5);
1620 let want_qd = dqd1[[3, 0]] * 1.0 + dqd1[[3, 1]] * (-0.5);
1621 assert!((out[0] - want_q).abs() < 1e-12);
1622 assert!((out[1] - want_q).abs() < 1e-12);
1623 assert!((out[2] - want_qd).abs() < 1e-12);
1624 assert_eq!(out[3], 0.0);
1625 }
1626
1627 #[test]
1628 fn logslope_block_writes_only_g_channel() {
1629 let n = 4;
1630 let p = 2;
1631 let dg = Array2::from_shape_fn((n, p), |(i, j)| (i as f64) + 0.1 * (j as f64));
1632 let op = LogslopeBlockOperator::new(dg.clone());
1633 let mut out = [0.0_f64; K_SURVIVAL];
1634 let delta = [2.0_f64, -1.0];
1635 op.apply_row(1, &delta, &mut out);
1636 assert_eq!(out[0], 0.0);
1637 assert_eq!(out[1], 0.0);
1638 assert_eq!(out[2], 0.0);
1639 let want = dg[[1, 0]] * 2.0 + dg[[1, 1]] * (-1.0);
1640 assert!((out[3] - want).abs() < 1e-12);
1641 }
1642
1643 #[test]
1644 fn extract_term_partition_simple_cases() {
1645 let full = 0..5usize;
1646 let part = extract_term_partition_from_penalty_ranges(5, &[]);
1648 assert_eq!(part.as_slice(), std::slice::from_ref(&full));
1649 let part = extract_term_partition_from_penalty_ranges(5, std::slice::from_ref(&full));
1651 assert_eq!(part.as_slice(), std::slice::from_ref(&full));
1652 let part = extract_term_partition_from_penalty_ranges(10, &[0..3, 6..10]);
1654 assert_eq!(part, vec![0..3, 3..6, 6..10]);
1655 let part = extract_term_partition_from_penalty_ranges(6, &[0..3, 0..3, 3..6]);
1657 assert_eq!(part, vec![0..3, 3..6]);
1658 let part = extract_term_partition_from_penalty_ranges(0, &[]);
1660 assert!(part.is_empty());
1661 }
1662
1663 #[test]
1664 fn assemble_block_triangular_t_identity_when_v_eye_and_r_none() {
1665 let v_a = Array2::<f64>::eye(2);
1666 let v_b = Array2::<f64>::eye(2);
1667 let t = assemble_block_triangular_t(&[v_a, v_b], &[None, None]);
1668 assert_eq!(t.dim(), (4, 4));
1669 let eye4 = Array2::<f64>::eye(4);
1670 for i in 0..4 {
1671 for j in 0..4 {
1672 assert!((t[[i, j]] - eye4[[i, j]]).abs() < 1e-14);
1673 }
1674 }
1675 }
1676
1677 #[test]
1678 fn assemble_block_triangular_t_with_drops_and_nonzero_r() {
1679 let mut v_a = Array2::<f64>::zeros((3, 2));
1680 v_a[[0, 0]] = 1.0;
1681 v_a[[1, 0]] = 0.5;
1682 v_a[[2, 1]] = 1.0;
1683 let v_b = Array2::<f64>::eye(2);
1684 let r_ab =
1685 Array2::<f64>::from_shape_fn((3, 2), |(i, j)| 1.0 + (i as f64) + 0.25 * (j as f64));
1686 let t =
1687 assemble_block_triangular_t(&[v_a.clone(), v_b.clone()], &[None, Some(r_ab.clone())]);
1688 assert_eq!(t.dim(), (5, 4));
1689 for i in 0..3 {
1690 for j in 0..2 {
1691 assert!((t[[i, j]] - v_a[[i, j]]).abs() < 1e-14);
1692 }
1693 }
1694 for i in 0..2 {
1695 for j in 0..2 {
1696 assert!((t[[3 + i, 2 + j]] - v_b[[i, j]]).abs() < 1e-14);
1697 }
1698 }
1699 for i in 0..3 {
1700 for j in 0..2 {
1701 assert!((t[[i, 2 + j]] + r_ab[[i, j]]).abs() < 1e-14);
1702 }
1703 }
1704 for i in 0..2 {
1705 for j in 0..2 {
1706 assert_eq!(t[[3 + i, j]], 0.0);
1707 }
1708 }
1709 }
1710
1711 #[test]
1712 fn validate_partition_rejects_bad_partitions() {
1713 let bad_start = 1..5usize;
1714 let short_cover = 0..3usize;
1715 let full_cover = 0..5usize;
1716 assert!(validate_partition(std::slice::from_ref(&bad_start), 5, "test").is_err());
1718 assert!(validate_partition(std::slice::from_ref(&short_cover), 5, "test").is_err());
1720 assert!(validate_partition(&[0..2, 3..5], 5, "test").is_err());
1722 assert!(validate_partition(&[0..3, 2..5], 5, "test").is_err());
1724 assert!(validate_partition(&[0..0, 0..5], 5, "test").is_err());
1726 assert!(validate_partition(&[], 0, "test").is_ok());
1728 assert!(validate_partition(&[0..2, 2..5], 5, "test").is_ok());
1730 assert!(validate_partition(std::slice::from_ref(&full_cover), 5, "test").is_ok());
1731 }
1732
1733 #[test]
1744 fn compiled_map_penalty_pullback_is_per_block_width_with_nonzero_residual() {
1745 use gam_identifiability::families::compiler::CompiledMap;
1746 use gam_terms::smooth::BlockwisePenalty;
1747
1748 let n = 10;
1749 let v_time =
1753 Array2::<f64>::from_shape_fn(
1754 (3, 3),
1755 |(i, j)| {
1756 if i == j { 1.0 } else { 0.1 * ((i + j) as f64) }
1757 },
1758 );
1759 let v_marg = Array2::<f64>::from_shape_fn((3, 2), |(i, j)| {
1760 0.5 + 0.3 * (i as f64) - 0.2 * (j as f64)
1761 });
1762 let v_log = Array2::<f64>::from_shape_fn((2, 2), |(i, j)| if i == j { 1.2 } else { 0.4 });
1763 let r_marg = Array2::<f64>::from_shape_fn((3, 2), |(i, j)| 0.7 - 0.1 * ((i + j) as f64));
1765 let r_log =
1770 Array2::<f64>::from_shape_fn((6, 2), |(i, j)| 0.3 + 0.05 * ((i * 2 + j) as f64));
1771
1772 let t = assemble_block_triangular_t(
1773 &[v_time.clone(), v_marg.clone(), v_log.clone()],
1774 &[None, Some(r_marg.clone()), Some(r_log.clone())],
1775 );
1776 assert_eq!(t.dim(), (8, 7), "joint raw 8 × joint compiled 7");
1777
1778 let map = CompiledMap {
1779 raw_from_compiled: t.clone(),
1780 compiled_block_ranges: vec![0..3, 3..5, 5..7],
1781 raw_block_ranges: vec![0..3, 3..6, 6..8],
1782 };
1783
1784 let raw_time_entry = DesignMatrix::Dense(DenseDesignMatrix::from(
1786 Array2::<f64>::from_shape_fn((n, 3), |(i, j)| 1.0 + (i as f64) * 0.1 + (j as f64)),
1787 ));
1788 let raw_time_exit = raw_time_entry.clone();
1789 let raw_time_deriv = raw_time_entry.clone();
1790 let raw_marg = DesignMatrix::Dense(DenseDesignMatrix::from(Array2::<f64>::from_shape_fn(
1791 (n, 3),
1792 |(i, j)| 0.2 * (i as f64) - 0.3 * (j as f64),
1793 )));
1794 let raw_log = DesignMatrix::Dense(DenseDesignMatrix::from(Array2::<f64>::from_shape_fn(
1795 (n, 2),
1796 |(i, j)| 0.5 + (i as f64) * (j as f64 + 1.0),
1797 )));
1798
1799 let s_time =
1801 Array2::<f64>::from_shape_fn(
1802 (3, 3),
1803 |(i, j)| if i == j { (i + 2) as f64 } else { 0.3 },
1804 );
1805 let s_marg =
1806 Array2::<f64>::from_shape_fn(
1807 (3, 3),
1808 |(i, j)| if i == j { 1.5 + i as f64 } else { 0.2 },
1809 );
1810 let s_log = Array2::<f64>::from_shape_fn((2, 2), |(i, j)| if i == j { 2.0 } else { 0.5 });
1811 let time_pens = vec![BlockwisePenalty::new(0..3, s_time.clone())];
1812 let marg_pens = vec![BlockwisePenalty::new(0..3, s_marg.clone())];
1813 let log_pens = vec![BlockwisePenalty::new(0..2, s_log.clone())];
1814
1815 let out = apply_compiled_map_to_designs(
1816 &map,
1817 raw_time_entry,
1818 raw_time_exit,
1819 raw_time_deriv,
1820 raw_marg,
1821 raw_log,
1822 &time_pens,
1823 &marg_pens,
1824 &log_pens,
1825 )
1826 .expect("apply_compiled_map_to_designs must succeed");
1827
1828 assert_eq!(out.time_design_entry.ncols(), 3);
1830 assert_eq!(out.marginal_design.ncols(), 2);
1831 assert_eq!(out.logslope_design.ncols(), 2);
1832
1833 for s in &out.time_penalties {
1836 assert_eq!(
1837 s.as_dense_cow().dim(),
1838 (3, 3),
1839 "time penalty must be per-block 3×3, not joint-width"
1840 );
1841 }
1842 for s in &out.marginal_penalties {
1843 assert_eq!(
1844 s.as_dense_cow().dim(),
1845 (2, 2),
1846 "marginal penalty must match reduced compiled width 2, not joint 7"
1847 );
1848 }
1849 for s in &out.logslope_penalties {
1850 assert_eq!(s.as_dense_cow().dim(), (2, 2));
1851 }
1852
1853 let p_time_dense = out.time_penalties[0].as_dense_cow().into_owned();
1857 let theta_time = Array1::<f64>::from_shape_fn(3, |k| 0.4 + 0.7 * (k as f64));
1858 let gamma_time = v_time.dot(&theta_time);
1859 let lhs = theta_time.dot(&p_time_dense.dot(&theta_time));
1860 let rhs = gamma_time.dot(&s_time.dot(&gamma_time));
1861 assert!(
1862 (lhs - rhs).abs() < 1e-10,
1863 "time-block per-block pullback must be exact: lhs={lhs}, rhs={rhs}"
1864 );
1865
1866 let p_marg_dense = out.marginal_penalties[0].as_dense_cow().into_owned();
1869 let want_marg = v_marg.t().dot(&s_marg.dot(&v_marg));
1870 for i in 0..2 {
1871 for j in 0..2 {
1872 assert!(
1873 (p_marg_dense[[i, j]] - want_marg[[i, j]]).abs() < 1e-12,
1874 "marginal penalty must be V_margᵀ S_marg V_marg at ({i},{j})"
1875 );
1876 }
1877 }
1878 }
1879
1880 #[test]
1887 fn compile_survival_parametric_designs_helper_attributes_drop_to_marginal() {
1888 let n = 24;
1889 let p_time = 3;
1890 let p_marginal = 3;
1891 let p_logslope = 2;
1892 let x: Vec<f64> = (0..n)
1893 .map(|i| -1.0 + 2.0 * (i as f64) / (n as f64 - 1.0))
1894 .collect();
1895 let mut time_dq0 = Array2::<f64>::zeros((n, p_time));
1896 let mut time_dq1 = Array2::<f64>::zeros((n, p_time));
1897 let mut time_dqd1 = Array2::<f64>::zeros((n, p_time));
1898 let mut marg_dq = Array2::<f64>::zeros((n, p_marginal));
1899 let marg_dqd1 = Array2::<f64>::zeros((n, p_marginal));
1900 let mut log_dg = Array2::<f64>::zeros((n, p_logslope));
1901 for i in 0..n {
1902 time_dq0[[i, 0]] = 1.0;
1903 time_dq0[[i, 1]] = x[i];
1904 time_dq0[[i, 2]] = x[i] * x[i];
1905 time_dq1[[i, 0]] = 1.0;
1906 time_dq1[[i, 1]] = x[i];
1907 time_dq1[[i, 2]] = x[i] * x[i];
1908 time_dqd1[[i, 0]] = 0.0;
1909 time_dqd1[[i, 1]] = 1.0;
1910 time_dqd1[[i, 2]] = 2.0 * x[i];
1911 marg_dq[[i, 0]] = 1.0; marg_dq[[i, 1]] = x[i] * x[i] * x[i];
1913 marg_dq[[i, 2]] = x[i].sin();
1914 log_dg[[i, 0]] = (2.0 * x[i]).cos();
1915 log_dg[[i, 1]] = x[i].tanh();
1916 }
1917 let mut h_full = Array3::<f64>::zeros((n, K_SURVIVAL, K_SURVIVAL));
1918 for i in 0..n {
1919 for k in 0..K_SURVIVAL {
1920 h_full[[i, k, k]] = 1.0;
1921 }
1922 }
1923 let row_hess = survival_row_hessian_from_full(h_full);
1924 let out = compile_survival_parametric_designs(
1925 time_dq0, time_dq1, time_dqd1, marg_dq, marg_dqd1, log_dg, &row_hess,
1926 )
1927 .expect("Phase-4b parametric compile must succeed on single-direction alias");
1928 assert_eq!(out.v_time.ncols(), p_time, "time keeps all columns");
1929 assert_eq!(
1930 out.v_marginal.ncols(),
1931 p_marginal - 1,
1932 "marginal loses exactly the shared-constant direction"
1933 );
1934 assert_eq!(out.v_logslope.ncols(), p_logslope, "logslope is clean");
1935 assert_eq!(
1936 out.drops_by_block,
1937 (0, 1, 0),
1938 "attribution: zero from time/logslope, one from marginal",
1939 );
1940 }
1941
1942 #[test]
1963 fn compile_survival_three_block_with_shared_constant_drops_one_direction() {
1964 use gam_identifiability::families::compiler::compile;
1965
1966 let n = 32;
1967 let p_time = 3;
1968 let p_marginal = 3;
1969 let p_logslope = 2;
1970
1971 let x: Vec<f64> = (0..n)
1982 .map(|i| -1.0 + 2.0 * (i as f64) / (n as f64 - 1.0))
1983 .collect();
1984 let mut time_dq0 = Array2::<f64>::zeros((n, p_time));
1985 let mut time_dq1 = Array2::<f64>::zeros((n, p_time));
1986 let mut time_dqd1 = Array2::<f64>::zeros((n, p_time));
1987 for i in 0..n {
1988 time_dq0[[i, 0]] = 1.0;
1989 time_dq0[[i, 1]] = x[i];
1990 time_dq0[[i, 2]] = x[i] * x[i];
1991 time_dq1[[i, 0]] = 1.0;
1992 time_dq1[[i, 1]] = x[i];
1993 time_dq1[[i, 2]] = x[i] * x[i];
1994 time_dqd1[[i, 0]] = 0.0;
1996 time_dqd1[[i, 1]] = 1.0;
1997 time_dqd1[[i, 2]] = 2.0 * x[i];
1998 }
1999
2000 let mut marg_dq = Array2::<f64>::zeros((n, p_marginal));
2006 let marg_dqd1 = Array2::<f64>::zeros((n, p_marginal));
2007 for i in 0..n {
2008 marg_dq[[i, 0]] = 1.0;
2009 marg_dq[[i, 1]] = x[i] * x[i] * x[i];
2010 marg_dq[[i, 2]] = x[i].sin();
2011 }
2012
2013 let mut log_dg = Array2::<f64>::zeros((n, p_logslope));
2017 for i in 0..n {
2018 log_dg[[i, 0]] = (2.0 * x[i]).cos();
2019 log_dg[[i, 1]] = x[i].tanh();
2020 }
2021
2022 let inputs = build_survival_compiler_inputs(
2023 time_dq0, time_dq1, time_dqd1, marg_dq, marg_dqd1, log_dg, None, None,
2024 );
2025
2026 let mut h_full = Array3::<f64>::zeros((n, K_SURVIVAL, K_SURVIVAL));
2032 for i in 0..n {
2033 for k in 0..K_SURVIVAL {
2034 h_full[[i, k, k]] = 1.0;
2035 }
2036 }
2037 let row_hess = survival_row_hessian_from_full(h_full);
2038
2039 let compiled = compile(&inputs.operators, &row_hess, &inputs.ordering)
2040 .expect("survival 3-block compile must succeed; aliasing is single-direction");
2041
2042 assert_eq!(compiled.blocks.len(), 3, "expected 3 CompiledBlocks");
2044
2045 let v_time = &compiled.blocks[0].t_lw;
2050 assert_eq!(
2051 v_time.ncols(),
2052 p_time,
2053 "time block (first in ordering) must retain all {p_time} of its columns; V_time={:?}",
2054 v_time.dim(),
2055 );
2056
2057 let v_marg = &compiled.blocks[1].t_lw;
2064 assert_eq!(
2065 v_marg.ncols(),
2066 p_marginal - 1,
2067 "marginal block must lose exactly the shared-constant direction; \
2068 V_marginal cols = {}, expected {}",
2069 v_marg.ncols(),
2070 p_marginal - 1,
2071 );
2072
2073 let v_log = &compiled.blocks[2].t_lw;
2076 assert_eq!(
2077 v_log.ncols(),
2078 p_logslope,
2079 "logslope block (no shared direction) must retain all {p_logslope} columns",
2080 );
2081
2082 let raw_total = p_time + p_marginal + p_logslope;
2085 let kept_total: usize = compiled.blocks.iter().map(|b| b.t_lw.ncols()).sum();
2086 assert_eq!(
2087 kept_total,
2088 raw_total - 1,
2089 "joint kept = raw_total − aliased; got {kept_total}, expected {}",
2090 raw_total - 1,
2091 );
2092 assert_eq!(
2093 compiled.joint_rank, kept_total,
2094 "CompiledBlocks::joint_rank must match the sum of per-block t_lw widths",
2095 );
2096
2097 let v_per_term: Vec<Array2<f64>> = compiled.blocks.iter().map(|b| b.t_lw.clone()).collect();
2107 let r_per_term: Vec<Option<Array2<f64>>> = vec![None; v_per_term.len()];
2108 let gauge = Gauge::from_v_and_r(&v_per_term, &r_per_term);
2109
2110 let mut expected_reduced = vec![0usize];
2111 let mut expected_raw = vec![0usize];
2112 for b in &compiled.blocks {
2113 let prev_reduced = *expected_reduced.last().unwrap();
2114 expected_reduced.push(prev_reduced + b.t_lw.ncols());
2115 let prev_raw = *expected_raw.last().unwrap();
2116 expected_raw.push(prev_raw + b.t_lw.nrows());
2117 }
2118 assert_eq!(
2119 *gauge.block_starts_reduced.last().unwrap(),
2120 compiled.joint_rank,
2121 "SMGS lift reduced dimension must equal the compiled joint_rank",
2122 );
2123 assert_eq!(
2124 gauge.block_starts_reduced, expected_reduced,
2125 "SMGS lift reduced block boundaries must match the compiled kept widths",
2126 );
2127 assert_eq!(
2128 gauge.block_starts_raw, expected_raw,
2129 "SMGS lift raw block boundaries must match the compiled per-block raw widths",
2130 );
2131
2132 for (bi, block) in compiled.blocks.iter().enumerate() {
2137 for j in 0..block.t_lw.ncols() {
2138 let col = block.t_lw.column(j);
2139 assert!(
2140 col.iter().all(|v| v.is_finite()),
2141 "block {bi} kept direction {j} has a non-finite entry",
2142 );
2143 let norm = col.dot(&col).sqrt();
2144 assert!(
2145 norm > 1e-10,
2146 "block {bi} kept direction {j} is degenerate (norm {norm:.3e})",
2147 );
2148 }
2149 }
2150 }
2151
2152 #[test]
2155 fn smgs_lift_via_t_identity_passes_through() {
2156 let v0 = Array2::<f64>::eye(3);
2157 let v1 = Array2::<f64>::eye(2);
2158 let v_per_term = vec![v0, v1];
2159 let r_per_term: Vec<Option<Array2<f64>>> = vec![None, None];
2160 let lift = Gauge::from_v_and_r(&v_per_term, &r_per_term);
2161 assert_eq!(lift.t_full.dim(), (5, 5));
2162 assert_eq!(lift.block_starts_reduced, vec![0, 3, 5]);
2163 assert_eq!(lift.block_starts_raw, vec![0, 3, 5]);
2164 for i in 0..5 {
2165 for j in 0..5 {
2166 let want = if i == j { 1.0 } else { 0.0 };
2167 assert!((lift.t_full[[i, j]] - want).abs() < 1e-14);
2168 }
2169 }
2170 let theta_0 = Array1::from(vec![1.0_f64, -2.0, 3.5]);
2171 let theta_1 = Array1::from(vec![-0.5_f64, 7.0]);
2172 let lifted = lift.lift_block_betas(&[theta_0.clone(), theta_1.clone()]);
2173 assert_eq!(lifted.len(), 2);
2174 for (a, b) in theta_0.iter().zip(lifted[0].iter()) {
2175 assert!((a - b).abs() < 1e-14);
2176 }
2177 for (a, b) in theta_1.iter().zip(lifted[1].iter()) {
2178 assert!((a - b).abs() < 1e-14);
2179 }
2180 }
2181
2182 #[test]
2186 fn smgs_lift_via_t_two_block_with_residualisation() {
2187 let v_a = Array2::<f64>::eye(3);
2188 let mut v_b = Array2::<f64>::zeros((3, 2));
2189 v_b[[0, 0]] = 1.0;
2190 v_b[[2, 1]] = 1.0;
2191 let mut r_b = Array2::<f64>::zeros((3, 2));
2192 r_b[[0, 0]] = 0.4;
2193 r_b[[0, 1]] = -0.1;
2194 r_b[[1, 0]] = 0.7;
2195 r_b[[1, 1]] = 1.3;
2196 r_b[[2, 0]] = -0.2;
2197 r_b[[2, 1]] = 0.5;
2198 let lift = Gauge::from_v_and_r(&[v_a.clone(), v_b.clone()], &[None, Some(r_b.clone())]);
2199 assert_eq!(lift.t_full.dim(), (6, 5));
2200 assert_eq!(lift.block_starts_reduced, vec![0, 3, 5]);
2201 assert_eq!(lift.block_starts_raw, vec![0, 3, 6]);
2202
2203 let theta_a = Array1::from(vec![1.0_f64, 2.0, -1.5]);
2204 let theta_b = Array1::from(vec![0.5_f64, -0.25]);
2205 let lifted = lift.lift_block_betas(&[theta_a.clone(), theta_b.clone()]);
2206 let r_theta_b = r_b.dot(&theta_b);
2207 let expected_a = &theta_a - &r_theta_b;
2208 assert_eq!(lifted[0].len(), 3);
2209 for (got, want) in lifted[0].iter().zip(expected_a.iter()) {
2210 assert!((got - want).abs() < 1e-12, "got {got}, want {want}");
2211 }
2212 assert_eq!(lifted[1].len(), 3);
2213 assert!((lifted[1][0] - theta_b[0]).abs() < 1e-12);
2214 assert!(lifted[1][1].abs() < 1e-12);
2215 assert!((lifted[1][2] - theta_b[1]).abs() < 1e-12);
2216 }
2217
2218 #[test]
2230 fn smgs_lift_covariance_identity_and_rank1_consistency() {
2231 let lift_id = Gauge::from_v_and_r(
2233 &[Array2::<f64>::eye(2), Array2::<f64>::eye(2)],
2234 &[None, None],
2235 );
2236 let mut cov = Array2::<f64>::zeros((4, 4));
2237 for i in 0..4 {
2239 for j in 0..4 {
2240 cov[[i, j]] = 1.0 / (1.0 + (i as f64 - j as f64).abs());
2241 }
2242 }
2243 let lifted_id = lift_id.lift_covariance(&cov);
2244 assert_eq!(lifted_id.dim(), (4, 4));
2245 for i in 0..4 {
2246 for j in 0..4 {
2247 assert!(
2248 (lifted_id[[i, j]] - cov[[i, j]]).abs() < 1e-12,
2249 "identity-T covariance lift must be a no-op at [{i},{j}]",
2250 );
2251 }
2252 }
2253
2254 let v_a = Array2::<f64>::eye(3);
2259 let mut v_b = Array2::<f64>::zeros((3, 2));
2260 v_b[[0, 0]] = 1.0;
2261 v_b[[2, 1]] = 1.0;
2262 let mut r_b = Array2::<f64>::zeros((3, 2));
2263 r_b[[0, 0]] = 0.4;
2264 r_b[[0, 1]] = -0.1;
2265 r_b[[1, 0]] = 0.7;
2266 r_b[[1, 1]] = 1.3;
2267 r_b[[2, 0]] = -0.2;
2268 r_b[[2, 1]] = 0.5;
2269 let lift = Gauge::from_v_and_r(&[v_a, v_b], &[None, Some(r_b)]);
2270
2271 let theta_a = Array1::from(vec![1.0_f64, 2.0, -1.5]);
2272 let theta_b = Array1::from(vec![0.5_f64, -0.25]);
2273 let theta_full = Array1::from(vec![
2275 theta_a[0], theta_a[1], theta_a[2], theta_b[0], theta_b[1],
2276 ]);
2277 let mut cov_rank1 = Array2::<f64>::zeros((5, 5));
2279 for i in 0..5 {
2280 for j in 0..5 {
2281 cov_rank1[[i, j]] = theta_full[i] * theta_full[j];
2282 }
2283 }
2284 let lifted_cov = lift.lift_covariance(&cov_rank1);
2285 let lifted_blocks = lift.lift_block_betas(&[theta_a, theta_b]);
2287 let beta_raw = Array1::from(
2288 lifted_blocks
2289 .iter()
2290 .flat_map(|b| b.iter().copied())
2291 .collect::<Vec<f64>>(),
2292 );
2293 assert_eq!(lifted_cov.dim(), (6, 6));
2294 assert_eq!(beta_raw.len(), 6);
2295 for i in 0..6 {
2296 for j in 0..6 {
2297 let want = beta_raw[i] * beta_raw[j];
2298 assert!(
2299 (lifted_cov[[i, j]] - want).abs() < 1e-10,
2300 "rank-1 covariance pushforward must equal (Tθ)(Tθ)ᵀ at [{i},{j}]: got {}, want {want}",
2301 lifted_cov[[i, j]],
2302 );
2303 }
2304 }
2305 for i in 0..6 {
2307 for j in 0..6 {
2308 assert!((lifted_cov[[i, j]] - lifted_cov[[j, i]]).abs() < 1e-14);
2309 }
2310 }
2311 }
2312
2313 #[test]
2316 fn smgs_lift_via_t_zero_r_matches_per_block_v_lift() {
2317 let mut v_a = Array2::<f64>::zeros((3, 2));
2318 v_a[[0, 0]] = 0.6;
2319 v_a[[1, 0]] = -0.8;
2320 v_a[[1, 1]] = 0.3;
2321 v_a[[2, 1]] = 0.9;
2322 let mut v_b = Array2::<f64>::zeros((4, 3));
2323 v_b[[0, 0]] = 1.0;
2324 v_b[[1, 1]] = -0.4;
2325 v_b[[2, 0]] = 0.2;
2326 v_b[[2, 2]] = 0.7;
2327 v_b[[3, 2]] = -1.1;
2328 let v_per_term = vec![v_a.clone(), v_b.clone()];
2329 let lift = Gauge::from_v_and_r(&v_per_term, &[None, None]);
2330 let theta_a = Array1::from(vec![0.3_f64, -1.4]);
2331 let theta_b = Array1::from(vec![2.1_f64, 0.0, -0.7]);
2332 let via_t = lift.lift_block_betas(&[theta_a.clone(), theta_b.clone()]);
2333 let ref_a = v_a.dot(&theta_a);
2334 let ref_b = v_b.dot(&theta_b);
2335 assert_eq!(via_t[0].len(), ref_a.len());
2336 for (g, w) in via_t[0].iter().zip(ref_a.iter()) {
2337 assert!((g - w).abs() < 1e-12);
2338 }
2339 assert_eq!(via_t[1].len(), ref_b.len());
2340 for (g, w) in via_t[1].iter().zip(ref_b.iter()) {
2341 assert!((g - w).abs() < 1e-12);
2342 }
2343 }
2344
2345 #[test]
2355 fn recompile_after_accept_diff_detection_pilot_curvature_trap() {
2356 let n = 6usize;
2357 let time_dq0 = Array2::<f64>::from_elem((n, 1), 1.0);
2361 let time_dq1 = Array2::<f64>::zeros((n, 1));
2362 let time_dqd1 = Array2::<f64>::zeros((n, 1));
2363 let marg_dq = Array2::<f64>::from_elem((n, 1), 1.0);
2368 let marg_dqd1 = Array2::<f64>::zeros((n, 1));
2369 let log_dg = Array2::<f64>::zeros((n, 0));
2371 let mut time_partition: Vec<std::ops::Range<usize>> = Vec::with_capacity(1);
2372 time_partition.push(0..1);
2373 let mut marg_partition: Vec<std::ops::Range<usize>> = Vec::with_capacity(1);
2374 marg_partition.push(0..1);
2375 let log_partition: Vec<std::ops::Range<usize>> = Vec::new();
2376
2377 let mut h_ident = Array3::<f64>::zeros((n, K_SURVIVAL, K_SURVIVAL));
2381 for i in 0..n {
2382 for k in 0..K_SURVIVAL {
2383 h_ident[[i, k, k]] = 1.0;
2384 }
2385 }
2386 let row_hess_ident = survival_row_hessian_from_full(h_ident);
2387 let compiled_ident = compile_survival_parametric_designs_per_term(
2388 time_dq0.clone(),
2389 time_dq1.clone(),
2390 time_dqd1.clone(),
2391 &time_partition,
2392 marg_dq.clone(),
2393 marg_dqd1.clone(),
2394 &marg_partition,
2395 log_dg.clone(),
2396 &log_partition,
2397 &row_hess_ident,
2398 false,
2399 )
2400 .expect("identity-H compile must succeed");
2401
2402 let mut h_q0_only = Array3::<f64>::zeros((n, K_SURVIVAL, K_SURVIVAL));
2406 for i in 0..n {
2407 h_q0_only[[i, 0, 0]] = 1.0;
2408 }
2409 let row_hess_q0 = survival_row_hessian_from_full(h_q0_only);
2410 let compiled_q0 = compile_survival_parametric_designs_per_term(
2411 time_dq0,
2412 time_dq1,
2413 time_dqd1,
2414 &time_partition,
2415 marg_dq,
2416 marg_dqd1,
2417 &marg_partition,
2418 log_dg,
2419 &log_partition,
2420 &row_hess_q0,
2421 false,
2422 )
2423 .expect("q0-only-H compile must succeed");
2424
2425 assert_ne!(
2429 compiled_ident.drops_by_block, compiled_q0.drops_by_block,
2430 "structural-H and data-adaptive-H compiles must produce different \
2431 drops_by_block on the constructed pilot-curvature-trap design; \
2432 identity={:?} q0-only={:?}",
2433 compiled_ident.drops_by_block, compiled_q0.drops_by_block,
2434 );
2435 assert_eq!(
2437 compiled_ident.drops_by_block.1, 0,
2438 "identity-H marg drops expected 0, got {:?}",
2439 compiled_ident.drops_by_block,
2440 );
2441 assert_eq!(
2443 compiled_q0.drops_by_block.1, 1,
2444 "q0-only-H marg drops expected 1, got {:?}",
2445 compiled_q0.drops_by_block,
2446 );
2447 }
2448
2449 #[test]
2450 fn compiled_map_from_per_term_partitions_and_lift_round_trip() {
2451 let v_time = Array2::<f64>::eye(2);
2455 let mut v_marg = Array2::<f64>::zeros((2, 1));
2456 v_marg[[0, 0]] = 1.0;
2457 v_marg[[1, 0]] = 0.5;
2458 let v_log = Array2::<f64>::eye(1);
2459 let r_marg = Array2::<f64>::from_shape_fn((2, 1), |(i, _)| 0.25 + i as f64);
2462 let r_log = Array2::<f64>::from_shape_fn((4, 1), |(i, _)| 0.1 * (i as f64 + 1.0));
2463 let per_term = SurvivalParametricCompiledPerTerm {
2464 v_time_per_term: vec![v_time.clone()],
2465 v_marginal_per_term: vec![v_marg.clone()],
2466 v_logslope_per_term: vec![v_log.clone()],
2467 r_lw_per_term: vec![None, Some(r_marg.clone()), Some(r_log.clone())],
2468 drops_by_block: (0, 1, 0),
2469 };
2470
2471 let map = compiled_map_from_per_term(&per_term);
2472
2473 assert_eq!(map.raw_block_ranges, vec![0..2, 2..4, 4..5]);
2475 assert_eq!(map.compiled_block_ranges, vec![0..2, 2..3, 3..4]);
2477 assert_eq!(map.raw_from_compiled.dim(), (5, 4));
2478
2479 let v_time_slice = map
2482 .raw_from_compiled
2483 .slice(ndarray::s![0..2, 0..2])
2484 .to_owned();
2485 let v_marg_slice = map
2486 .raw_from_compiled
2487 .slice(ndarray::s![2..4, 2..3])
2488 .to_owned();
2489 let v_log_slice = map
2490 .raw_from_compiled
2491 .slice(ndarray::s![4..5, 3..4])
2492 .to_owned();
2493 for i in 0..2 {
2494 for j in 0..2 {
2495 assert!((v_time_slice[[i, j]] - v_time[[i, j]]).abs() < 1e-14);
2496 }
2497 assert!((v_marg_slice[[i, 0]] - v_marg[[i, 0]]).abs() < 1e-14);
2498 }
2499 assert!((v_log_slice[[0, 0]] - v_log[[0, 0]]).abs() < 1e-14);
2500
2501 let ordering = [
2504 gam_identifiability::families::compiler::BlockOrder::Time,
2505 gam_identifiability::families::compiler::BlockOrder::Marginal,
2506 gam_identifiability::families::compiler::BlockOrder::Logslope,
2507 ];
2508 let lift_from_map = Gauge::from_compiled_map(&map, &ordering);
2509 let v_all = vec![v_time, v_marg, v_log];
2510 let lift_direct = Gauge::from_v_and_r(&v_all, &[None, Some(r_marg), Some(r_log)]);
2511 assert_eq!(lift_from_map.t_full.dim(), lift_direct.t_full.dim());
2512 for i in 0..lift_from_map.t_full.nrows() {
2513 for j in 0..lift_from_map.t_full.ncols() {
2514 assert!(
2515 (lift_from_map.t_full[[i, j]] - lift_direct.t_full[[i, j]]).abs() < 1e-14,
2516 "T mismatch at ({i},{j}): map={} direct={}",
2517 lift_from_map.t_full[[i, j]],
2518 lift_direct.t_full[[i, j]],
2519 );
2520 }
2521 }
2522 }
2523
2524 fn const_row_hess_q0g(n: usize, h00: f64, h03: f64, h33: f64) -> SurvivalRowHessian {
2540 let mut h = Array3::<f64>::zeros((n, K_SURVIVAL, K_SURVIVAL));
2541 for i in 0..n {
2542 h[[i, 0, 0]] = h00;
2543 h[[i, 0, 3]] = h03;
2544 h[[i, 3, 0]] = h03;
2545 h[[i, 3, 3]] = h33;
2546 }
2547 survival_row_hessian_from_full(h)
2548 }
2549
2550 #[test]
2551 fn survival_reduced_logslope_drops_confounded_keeps_free_979() {
2552 let n = 4;
2558 let row_hess = const_row_hess_q0g(n, 2.0, 2.0, 2.0); let marg = Array2::from_shape_vec((n, 1), vec![1.0, 1.0, 1.0, 1.0]).unwrap();
2560 let log =
2563 Array2::from_shape_vec((n, 2), vec![1.0, 10.0, 1.0, -10.0, 1.0, 10.0, 1.0, -10.0])
2564 .unwrap();
2565 let t =
2566 match survival_reduced_logslope_transform_effective(marg.view(), log.view(), &row_hess)
2567 .expect("contraction must succeed")
2568 {
2569 crate::bms::block_specs::ReducedLogslopeOutcome::Reduced(t) => t,
2570 other => panic!("a partial confound must yield a reduced transform, got {other:?}"),
2571 };
2572 assert_eq!(t.dim(), (2, 1), "exactly one logslope direction survives");
2573 assert!(
2576 t[[0, 0]].abs() < 1e-6,
2577 "confounded (e1) direction must be dropped, got {}",
2578 t[[0, 0]]
2579 );
2580 assert!(
2581 (t[[1, 0]].abs() - 1.0).abs() < 1e-6,
2582 "free (e2) direction must be kept as a unit vector, got {}",
2583 t[[1, 0]]
2584 );
2585 }
2586
2587 #[test]
2588 fn survival_reduced_logslope_fully_confounded_is_distinct_signal_979() {
2589 let n = 4;
2596 let row_hess = const_row_hess_q0g(n, 2.0, 2.0, 2.0);
2597 let marg = Array2::from_shape_vec((n, 1), vec![1.0, 1.0, 1.0, 1.0]).unwrap();
2598 let log = marg.clone();
2599 let out = survival_reduced_logslope_transform_effective(marg.view(), log.view(), &row_hess)
2600 .expect("contraction must succeed");
2601 assert!(
2602 matches!(
2603 out,
2604 crate::bms::block_specs::ReducedLogslopeOutcome::FullyConfounded
2605 ),
2606 "a fully marginal-explained logslope block must report FullyConfounded"
2607 );
2608 }
2609
2610 #[test]
2611 fn survival_reduced_logslope_no_confound_is_full_rank_979() {
2612 let n = 4;
2616 let row_hess = const_row_hess_q0g(n, 2.0, 0.0, 2.0);
2617 let marg = Array2::from_shape_vec((n, 1), vec![1.0, 1.0, 1.0, 1.0]).unwrap();
2618 let log =
2619 Array2::from_shape_vec((n, 2), vec![1.0, 10.0, 1.0, -10.0, 1.0, 10.0, 1.0, -10.0])
2620 .unwrap();
2621 let out = survival_reduced_logslope_transform_effective(marg.view(), log.view(), &row_hess)
2622 .expect("contraction must succeed");
2623 assert!(
2624 matches!(
2625 out,
2626 crate::bms::block_specs::ReducedLogslopeOutcome::FullRank
2627 ),
2628 "W-orthogonal channels need no reduction → keep raw"
2629 );
2630 }
2631
2632 #[test]
2633 fn survival_block_diagonal_logslope_map_is_identity_on_time_and_marginal_979() {
2634 let p_time = 2;
2637 let p_marg = 3;
2638 let t_log = Array2::from_shape_fn((4, 2), |(i, j)| 1.0 + (i * 2 + j) as f64);
2639 let map = survival_block_diagonal_logslope_map(p_time, p_marg, &t_log);
2640
2641 assert_eq!(map.raw_block_ranges, vec![0..2, 2..5, 5..9]);
2642 assert_eq!(map.compiled_block_ranges, vec![0..2, 2..5, 5..7]);
2643 assert_eq!(map.raw_from_compiled.dim(), (9, 7));
2644
2645 let t = &map.raw_from_compiled;
2646 for i in 0..p_time {
2648 for j in 0..p_time {
2649 let want = if i == j { 1.0 } else { 0.0 };
2650 assert!((t[[i, j]] - want).abs() < 1e-14, "V_time[{i},{j}]");
2651 }
2652 }
2653 for i in 0..p_marg {
2655 for j in 0..p_marg {
2656 let want = if i == j { 1.0 } else { 0.0 };
2657 assert!(
2658 (t[[p_time + i, p_time + j]] - want).abs() < 1e-14,
2659 "V_marg[{i},{j}]"
2660 );
2661 }
2662 }
2663 for i in 0..4 {
2665 for j in 0..2 {
2666 assert!(
2667 (t[[p_time + p_marg + i, p_time + p_marg + j]] - t_log[[i, j]]).abs() < 1e-14,
2668 "V_log[{i},{j}]"
2669 );
2670 }
2671 }
2672 let nnz = t.iter().filter(|&&v| v != 0.0).count();
2675 assert_eq!(
2676 nnz,
2677 p_time + p_marg + t_log.iter().filter(|&&v| v != 0.0).count()
2678 );
2679 }
2680}