Skip to main content

gam_models/survival/marginal_slope/
identifiability.rs

1//! Survival marginal-slope concrete impls for the family-agnostic
2//! identifiability compiler (`gam_identifiability::families::compiler`).
3//!
4//! Survival's row primary state is the 4-vector `u_i = (q0, q1, qd1, g)`,
5//! so `K = 4`. The row Hessian is the 4×4 second-derivative block of the
6//! per-row neg-log-likelihood kernel `row_primary_closed_form` at a pilot
7//! `β`, PSD-clamped via eigendecomposition (negative eigenvalues projected
8//! to zero) to handle pilot points far from the optimum.
9//!
10//! Each block exposes its row Jacobian as the contribution of `δβ_block`
11//! to the row primary-state vector:
12//!
13//! - **TimeBlockOperator**: `(δq0, δq1, δqd1, 0)` from `design_entry`,
14//!   `design_exit`, `design_derivative_exit` rows.
15//! - **MarginalBlockOperator**: `(δq, δq, δqd_marginal, 0)` from the
16//!   marginal design row (shared by q0 and q1; qd contribution zero unless
17//!   timewiggle is active — captured by an explicit derivative row matrix).
18//! - **LogslopeBlockOperator**: `(0, 0, 0, δg)` from the logslope design.
19//! - **ScoreWarpBlockOperator**: `(δq, δq, δqd_warp, 0)` from the warp
20//!   basis (shifts q at entry/exit; chain rule via dq0_seed/dt for qd1).
21//! - **LinkDevBlockOperator**: `(δq, δq, δqd_link, 0)` from the link-dev
22//!   basis on the rigid/pilot q-seed.
23//!
24//! Phase 4a delivery: trait impls + an input-builder helper. Phase 4b
25//! threads these through SMGS's construction site and the migrated pilot
26//! β; Phase 4c deletes the legacy
27//! `install_compiled_flex_block_into_runtime` path.
28
29use 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
44/// Per-row 4×4 row Hessian for the survival marginal-slope likelihood at a
45/// pilot `β`. The pilot supplies the primary-state vector
46/// `(q0_i, q1_i, qd1_i, g_i)` and the per-row sample weight + event
47/// indicator + z + probit scale. The 4×4 block is evaluated via the
48/// existing `row_primary_closed_form` kernel (which already returns the
49/// full Hessian in `(q0, q1, qd1, g)` order) and PSD-clamped per row.
50pub struct SurvivalRowHessian {
51    /// PSD-projected per-row 4×4 Hessian, stored row-major as
52    /// `(n × 4 × 4)`.
53    h: Array3<f64>,
54    /// Immutable row data needed to refresh `h` exactly at a new primary
55    /// state. These must not be replaced by unit weights/events: censoring and
56    /// observation weights change the channel curvature and therefore the
57    /// identifiability certificate.
58    weights: Array1<f64>,
59    event: Array1<f64>,
60    derivative_guard: f64,
61}
62
63impl SurvivalRowHessian {
64    /// Construct from explicit per-row pilot primary-state and the row
65    /// data needed by `row_primary_closed_form`. Negative eigenvalues are
66    /// projected to zero before storage so the matrix is PSD.
67    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
150/// `FamilyChannelHessian` for survival marginal-slope.
151///
152/// The 4×4 per-subject W_i is the Hessian of the row negative log-likelihood
153/// `ρ_i(q0, q1, qd1, g) = −δ_i log f(η_1, ad1) − (1−δ_i) log S(η_1) + log S(η_0)`
154/// with respect to the 4-vector primary state `(q0, q1, qd1, g)`.
155///
156/// Derivation of W_i entries (all from `row_primary_closed_form`):
157///
158/// - W[0,0] = u2_η0 · c²  (q0–q0; only η0 depends on q0)
159/// - W[1,1] = (u2_η1 + w·δ) · c²  (q1–q1; η1 and log-φ both depend on q1)
160/// - W[2,2] = w·δ · (∂ad1/∂qd1)² · (−1/ad1²)  (qd1–qd1 via neglog(ad1))
161/// - W[3,3] = u2_η0·(∂η0/∂g)² + u1_η0·(∂²η0/∂g²) + u2_η1·(∂η1/∂g)² + ...
162/// - W[0,3] = W[3,0] = u2_η0·c·(q0·c1 + s_f·z) + u1_η0·c1  (cross q0–g)
163/// - W[1,3] = W[3,1] = u2_η1·c·(q1·c1 + s_f·z) + u1_η1·c1  (cross q1–g)
164/// - W[2,3] = W[3,2] = u2_ad1·c·(qd1·c1) + u1_ad1·c1  (cross qd1–g)
165/// - All other off-diagonals are zero (η0, η1, ad1 depend on non-overlapping
166///   subsets of (q0,q1,qd1,g), and only g is shared across all three).
167///
168/// This is already computed by `row_primary_closed_form` and stored in
169/// `SurvivalRowHessian::h` after PSD-clamping.
170///
171/// # β-dependent W via `channel_hessian_at`
172///
173/// `channel_hessian_at` overrides the default β-independent path.  When
174/// `family_scalars` carries `SurvivalMarginalSlopeFamilyScalars`, the
175/// current per-row primary state `(q0_i, q1_i, qd1_i, g_i)` is read from
176/// those scalars and the 4×4 W_i is recomputed via `row_primary_for_compiler`.
177/// This makes `I(β) = J(β)^T W(β) J(β)` accurate at the current β instead of
178/// at the frozen pilot β=0 state.
179///
180/// When `family_scalars` is `None` and `beta` is exactly zero, the frozen pilot
181/// W is returned unchanged. When `family_scalars` is `None` and any `beta`
182/// entry is non-zero, `Err` is returned — the caller must
183/// supply scalars for a correct W at non-pilot β (same contract as T26's
184/// Jacobian callbacks: scalars required when β affects the primary state).
185impl 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                // β is non-zero in a way that would change W via the primary-state
243                // coupling (g ≠ 0 → c ≠ 1 → W changes).  Scalars are required.
244                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                // β = 0: return the exact stored pilot W unchanged.
254                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
335/// Project a 4×4 symmetric matrix onto the PSD cone by zeroing negative
336/// eigenvalues. A failed decomposition is an invalid curvature certificate,
337/// so it is reported instead of being replaced by unrelated diagonal data.
338fn 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
366/// Row Jacobian operator for the survival time block. Channels (q0, q1,
367/// qd1) come from the three time designs; the g channel is zero.
368pub 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        // Scale straight out of the three compact `(n, p)` channel designs —
418        // the compiler consumes the `(n·K, p)` sqrt(H)-scaled design, so the
419        // dense `(n, p, K)` tensor (3 of its 4 channels held explicitly, the
420        // 4th identically zero) that `evaluate_full()` builds is never needed.
421        // (#738: a capability is not a representation.)
422        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
433/// Row Jacobian operator for a block whose contribution flows into the
434/// q-channels (q0 and q1 identically) and optionally the qd1 channel.
435/// Covers the survival marginal, score-warp, and link-dev blocks (all
436/// three share the structural property `δq0 = δq1 = basis·δβ`, `δg = 0`).
437pub 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        // q0 and q1 share `dq`; qd1 is `dqd1`; the g channel is identically
489        // zero. Scale directly from the compact `(n, p)` designs, skipping the
490        // dense `(n, p, K)` tensor `evaluate_full()` would build. (#738.)
491        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
501/// Row Jacobian operator for the survival logslope block: contribution
502/// lives entirely on the g channel.
503pub 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        // The logslope contribution lives entirely on the g channel (3); the
548        // other three channels are identically zero. Scale directly from the
549        // compact `(n, p)` design, skipping the mostly-zero dense `(n, p, K)`
550        // tensor `evaluate_full()` would build. (#738.)
551        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
559/// Inputs assembled for the survival fit driver to feed `compile()`. The
560/// ordering follows `gauge_priority` descending (time=200 → marginal=150 →
561/// logslope=120 → score_warp=80 → link_dev=60).
562pub struct SurvivalCompilerInputs {
563    pub operators: Vec<Arc<dyn RowJacobianOperator>>,
564    pub ordering: Vec<BlockOrder>,
565}
566
567/// Per-block V reparameterisation matrices for the three parametric
568/// survival blocks emitted by [`compile_survival_parametric_designs`].
569/// Each `v_*` is a `(p_block_raw × p_block_kept)` selection-or-rotation
570/// matrix that maps a `β_kept` coefficient vector to its `β_raw`
571/// equivalent: `β_raw = V · β_kept`. The construction site applies these
572/// to the raw block designs (`design_raw · V → design_compiled`) and
573/// to the penalties (`Vᵀ S V`) before building `ParameterBlockSpec`s
574/// and passing the compiled designs into `make_family`.
575///
576/// Phase-4b architecture: this is the seam where the family-agnostic
577/// row-Jacobian compiler hands control back to the family-specific
578/// construction site. Each `v_*` width equals the corresponding
579/// `CompiledBlocks::blocks[i].t_lw.ncols()` — i.e., the kept-direction
580/// count after sqrt-H-metric residualisation and post-walk RRQR
581/// trailing-pivot drop.
582pub struct SurvivalParametricCompiled {
583    pub v_time: Array2<f64>,
584    pub v_marginal: Array2<f64>,
585    pub v_logslope: Array2<f64>,
586    /// Per-block dropped raw-column count, indexed
587    /// `(time_dropped, marginal_dropped, logslope_dropped)`. Equal to
588    /// `(p_raw − v.ncols())` for each block. Useful for logging the
589    /// gauge-attribution summary at the construction site.
590    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
621/// Per-term V reparameterisation matrices for the three parametric
622/// survival blocks. Each block's full V is the block-diagonal assembly
623/// of its per-term V's (one entry per element of the input
624/// `*_partition`). Preserves per-term penalty structure: applying
625/// `V_b = block_diag(V_term1, ..., V_termM)` to a per-term BlockwisePenalty
626/// pulls each penalty back only via its OWN term's V, so what was a
627/// per-term λ tunable in REML stays per-term tunable.
628pub 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    /// Per-term residualised reparam `R_b = M_b · V_b` from the
633    /// identifiability compiler, in the same global compile order
634    /// (time terms, then marginal terms, then logslope terms). `None`
635    /// for the very first compiled block (no anchor). Used by the
636    /// V+M-exact apply path to emit residualised rows
637    /// `C_b·V_b − A_{<b}·R_b` and to assemble the full triangular T.
638    pub r_lw_per_term: Vec<Option<Array2<f64>>>,
639    /// Per-block drops (raw_cols − sum(kept_cols across terms)).
640    pub drops_by_block: (usize, usize, usize),
641}
642
643/// Per-term-aware compile: residualise each block's TERMS individually
644/// in priority order so the emitted V is block-diagonal on term
645/// boundaries. This preserves the per-term penalty structure that
646/// REML's per-λ accounting depends on.
647///
648/// Each `*_partition` is a list of disjoint contiguous column ranges
649/// covering `[0..p_block)`. For the marginal/logslope blocks the
650/// natural source is the union of `BlockwisePenalty::col_range` values
651/// (one per smoothness penalty / term) plus the complement
652/// (unpenalised parametric columns).
653///
654/// Order of residualisation: time terms first (in their partition
655/// order), then marginal terms, then logslope terms. Within each
656/// block, terms are residualised against ALL prior anchor columns
657/// (terms from earlier blocks + earlier terms within this block).
658/// Aliased directions land in the lowest-priority block that contains
659/// them, in the natural term order within that block — matching the
660/// gauge-priority ownership contract.
661pub 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    // Build per-term operators. Each term gets its own RowJacobianOperator
684    // restricted to its column slice; the operator type matches the
685    // block's K-channel signature (Time, QChannel, Logslope).
686    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    // The time block carries the monotone time-wiggle basis, whose effective
710    // Jacobian is a fixed nonlinear functional basis rather than a linear
711    // design. When `protect_time` is set it must be kept at full raw width: a
712    // linear reparameterisation of it would desynchronise the raw-width
713    // wiggle-basis chain rule (`SmsTimewiggleTimeJacobian`), which recomputes
714    // the basis on every evaluation. Marginal/logslope still reduce against the
715    // full time anchor. The time block spans operators `0..n_time` (pushed
716    // first above); mark exactly those protected.
717    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
834/// Derive a disjoint contiguous partition of `[0..p_block)` from a
835/// list of BlockwisePenalty col_ranges. Distinct penalty ranges define
836/// term boundaries; gaps between them (unpenalised columns) become
837/// their own single-column partitions. Multiple penalties with the
838/// SAME col_range (e.g. tensor anisotropy axes) coalesce to one term.
839pub 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
857/// Pull a single raw block-local [`BlockwisePenalty`] back through the
858/// block's own diagonal reparameterisation `V_b` (the `(b, b)` block of
859/// the triangular T), producing a per-block-width compiled penalty.
860///
861/// The penalty's `local` is `pen.col_range.len()` square and covers a
862/// sub-region of the raw block at offset `pen.col_range.start` (which is
863/// block-local, i.e. relative to the block's first raw column). It is
864/// embedded into the full raw block width `v_block.nrows()` at that
865/// offset, then pulled back as `V_bᵀ · embed(S) · V_b`, giving a
866/// `(w_b_compiled × w_b_compiled)` symmetric `PenaltyMatrix::Dense`
867/// where `w_b_compiled == v_block.ncols()`.
868///
869/// This is the penalty contract a per-block `ParameterBlockSpec`
870/// requires: each block's penalty acts on that block's own compiled
871/// coordinate `θ_b`. The cross-block residualisation `R_{a→b}` carried
872/// in T's strict-upper triangle is absorbed into the *design* columns
873/// (the residualised emitted design `C_b V_b − A_{<b} R_b`), not into
874/// the penalty — exactly as the VM-exact compile-map path
875/// [`apply_compiled_map_to_designs`] does. Pulling the penalty back
876/// through the full joint T instead would yield a `(p_compiled × p_compiled)` dense
877/// matrix that cannot live in a single block's `penalties` slot and
878/// would violate the `p_b × p_b` block-spec validation.
879pub 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    // V_bᵀ · embed(S) · V_b → (compiled_p × compiled_p).
913    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
924/// Assemble a 3-block [`CompiledMap`] (time, marginal, logslope) from a
925/// [`SurvivalParametricCompiledPerTerm`] produced by the full 4×4 row-Hessian
926/// driver [`compile_survival_parametric_designs_per_term`].
927///
928/// The full global triangular `T` is built from the per-term `V`/`R` blocks
929/// (diagonal `V_b`, strict-upper `−R_{a→b}` — identical to the matrix the
930/// result-time lift [`Gauge::from_v_and_r`] uses), then partitioned
931/// into the three *block* ranges (raw = summed per-term raw widths, compiled =
932/// summed per-term kept widths). The resulting `CompiledMap` is interchangeable
933/// with one from
934/// [`gam_identifiability::families::compiler::compile_from_raw_grams`], so the
935/// existing [`apply_compiled_map_to_designs`] +
936/// [`Gauge::from_compiled_map`] machinery consumes it unchanged.
937///
938/// This is the seam that lets the survival closed-form fast path engage on the
939/// *correct* identifiable quotient: the cheap η₁-only rawstack metric can
940/// falsely collapse a whole channel (marginal/logslope share a PC surface in
941/// the η₁ row curvature), but the full survival row Hessian is 4×4 in
942/// `(q0, q1, qd1, g)` and chains differently into each block, so it keeps the
943/// channels distinct when no *true* alias exists. The reduced basis it emits
944/// goes to Newton in place of the rank-deficient raw basis.
945pub fn compiled_map_from_per_term(
946    compiled: &SurvivalParametricCompiledPerTerm,
947) -> gam_identifiability::families::compiler::CompiledMap {
948    // Per-term V's and R's in global compile order: time terms, then marginal,
949    // then logslope — exactly the order `r_lw_per_term` is stored in.
950    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    // Per-block raw / compiled widths = summed per-term widths within the block.
958    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
985/// Build a W-orthogonal **partial** reduced-logslope reparameterisation `T`
986/// (`p_log × r`, `0 < r < p_log`) for the survival marginal↔logslope confound,
987/// mirroring the proven-correct BMS effective-Schur-Gram construction
988/// [`crate::bms::block_specs::reduced_logslope_transform_effective`] but in
989/// survival's per-row 4×4 primary-state Hessian metric.
990///
991/// # Why this exists (#979)
992///
993/// The survival marginal and logslope channels share the SAME spatial basis
994/// (e.g. `matern(PC1,PC2,PC3)`), so on clustered-PC data the full 4×4
995/// row-Hessian identifiability compiler can attribute the *entire* shared
996/// surface to the lowest-priority logslope block and collapse it to zero width
997/// — which the `#741` required-channel guard rejects, forcing a fallback to the
998/// UNREDUCED design + Jeffreys conditioning. That fallback leaves a
999/// quadratically-flat near-null direction in the joint penalised Hessian
1000/// `M = JᵀHJ + S`, so the inner joint-Newton cannot certify stationarity and
1001/// runs to its bounded iteration cap without converging.
1002///
1003/// The BMS path never hits this because it does a *partial* reduction: it
1004/// removes from the logslope block ONLY the directions whose effective image is
1005/// W-explained by the marginal span (the confounded null space of the effective
1006/// Schur Gram), keeping every surviving logslope direction. The result is
1007/// full-rank `M` BY CONSTRUCTION — no runtime projection needed.
1008///
1009/// # The metric collapse to scalar weights
1010///
1011/// At the pilot the marginal design feeds the primary channels `(q0, q1)`
1012/// identically (`∂q0/∂β_m = ∂q1/∂β_m = m`, and `∂qd1/∂β_m = 0` because the
1013/// `#808` fallback always builds a zero marginal-derivative design) and the
1014/// logslope design feeds only `g` (`∂g/∂β_s = g_dg`). With the per-row PSD 4×4
1015/// Hessian `H` in channel order `(q0, q1, qd1, g)` and `H[0,1] = 0` (q0 and q1
1016/// enter the disjoint outputs η0, η1), the combined effective Gram
1017/// `[[A, Bᵀ], [B, C]] = J_combinedᵀ H J_combined` (PSD per row) collapses to
1018/// scalar-weighted Grams of the raw block designs:
1019///
1020/// ```text
1021///     w_mm = H00 + H11           (marginal self weight, ≥ 0)
1022///     w_mg = H03 + H13           (marginal↔logslope cross weight)
1023///     w_gg = H33                 (logslope self weight, ≥ 0)
1024///     A = m_dqᵀ diag(w_mm) m_dq + εI     (p_m × p_m)
1025///     B = m_dqᵀ diag(w_mg) g_dg          (p_m × p_log)
1026///     C = g_dgᵀ diag(w_gg) g_dg          (p_log × p_log)
1027///     Gtt = C − Bᵀ A⁻¹ B                 (p_log × p_log, PSD Schur complement)
1028/// ```
1029///
1030/// `T` is the orthonormal eigenbasis of `Gtt` for eigenvalues above a tolerance
1031/// relative to the effective logslope energy scale (single-sourced from the BMS
1032/// reference cut). Returns `Ok(None)` when there is nothing to reduce
1033/// (`r == p_log`) or the entire effective logslope image collapses into the
1034/// marginal span (`r == 0`); in both cases the caller keeps its existing path.
1035///
1036/// Precondition: `marginal_dq`'s derivative-into-qd1 contribution is zero (the
1037/// `#808` fallback constructs `m_dqd1` as an all-zero matrix), so marginal
1038/// touches only `(q0, q1)` and the scalar collapse above is exact.
1039pub(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    // Scalar effective weights from the per-row 4×4 PSD Hessian, channel order
1064    // (q0, q1, qd1, g). Marginal → {q0, q1} (identical column m), logslope → {g}.
1065    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    // C = G_effᵀ W G_eff (raw-coordinate effective logslope Gram); its diagonal
1081    // sets the energy scale for the relative kept-direction tolerance.
1082    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        // Zero effective logslope energy: the block carries no curvature at
1091        // all — every direction is unidentified (#2245 finding 45 sibling).
1092        return Ok(ReducedLogslopeOutcome::FullyConfounded);
1093    }
1094
1095    // A = M_effᵀ W M_eff + εI (ridge relative to the marginal effective energy
1096    // so the Schur solve is well-posed even when the marginal pilot Gram is
1097    // rank-soft; the ridge only under-removes, i.e. is conservative).
1098    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    // B = M_effᵀ W G_eff (p_m × p_log);  Gtt = C − Bᵀ A⁻¹ B (p_log × p_log, PSD).
1106    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()); // A⁻¹ B  (p_m × p_log)
1113    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); // Bᵀ A⁻¹ B  (p_log × p_log)
1115    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    // A `Gtt` eigenvalue far below the effective logslope energy scale means that
1128    // direction's effective logslope column is W-explained by the effective
1129    // marginal span — exactly the joint-Hessian rank-soft confounded direction.
1130    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    // r == p_log: no confounded direction to remove — keep the raw design.
1139    // r == 0: the whole effective logslope image is W-explained by the
1140    // marginal span — the block is unidentified. These are OPPOSITE cases and
1141    // must not share a signal (#2245 finding 45 sibling): keeping the raw
1142    // columns for a fully confounded block lets the penalty pick an arbitrary
1143    // decomposition and report it as an estimate.
1144    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
1162/// Assemble a block-diagonal 3-block [`CompiledMap`] that passes the time and
1163/// marginal blocks through unchanged (identity) and reparameterises ONLY the
1164/// logslope block via `t_log` (`p_log × r`). Used by the survival `#979`
1165/// partial reduced-logslope confound removal
1166/// ([`survival_reduced_logslope_transform_effective`]): the marginal/time
1167/// channels are untouched, the logslope block drops only its confounded
1168/// directions, and the joint penalised Hessian is full-rank by construction.
1169///
1170/// The resulting `CompiledMap` is interchangeable with one from
1171/// [`compiled_map_from_per_term`] /
1172/// [`gam_identifiability::families::compiler::compile_from_raw_grams`], so the
1173/// existing [`apply_compiled_map_to_designs`] + [`Gauge::from_compiled_map`]
1174/// machinery consumes it unchanged. Because the map is block-diagonal there is
1175/// no strict-upper cross-block residual `R`, and `apply_compiled_map_to_designs`
1176/// reads only the per-block diagonal `V_b = T[raw_b, compiled_b]` — `V_time` and
1177/// `V_marg` are identities, `V_log = t_log`.
1178pub 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
1214/// Apply a global [`CompiledMap`] T directly to the three survival
1215/// parametric block designs (time/marginal/logslope). Slices the
1216/// per-block diagonal of T into `V_b = T[raw_range_b, compiled_range_b]`
1217/// (shape `p_b_raw × w_b_compiled`), wraps each channel's raw design via
1218/// [`wrap_design_with_transform`], and pulls each block's penalties back
1219/// through that block's OWN `V_b` via
1220/// [`pull_back_blockwise_penalty_through_block_v`], producing
1221/// per-block-width `(w_b_compiled × w_b_compiled)` penalties — the shape
1222/// a per-block `ParameterBlockSpec.penalties` slot requires.
1223///
1224/// `map.raw_block_ranges` must equal three contiguous ranges in the
1225/// order Time → Marginal → Logslope (matching the input designs).
1226/// `map.compiled_block_ranges` runs in the same order.
1227///
1228/// Penalties supplied to this function:
1229/// - `time_penalties` are `BlockwisePenalty`s whose `col_range` is in
1230///   the time block's local raw coords (e.g. `0..p_time`).
1231/// - `marginal_penalties` / `logslope_penalties` likewise — local to
1232///   their own channel's raw width.
1233///
1234/// Each penalty's block-local `col_range` is embedded into the block's
1235/// raw width and pulled back as `V_bᵀ S_b V_b`. The cross-block
1236/// residualisation `R_{a→b}` carried in T's strict-upper triangle is
1237/// absorbed into the residualised *design* columns, not the penalty, so
1238/// the per-block penalty model stays exact for the highest-priority
1239/// block (time, no anchor → `R = []`) and matches the sibling per-block
1240/// compile path for the rest.
1241pub 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    // Pull each block's penalties back through that block's OWN diagonal
1308    // reparameterisation V_b (= the (b, b) block of T). This produces a
1309    // per-block-width `(w_b_compiled × w_b_compiled)` penalty — the only
1310    // shape a per-block `ParameterBlockSpec.penalties` slot accepts.
1311    //
1312    // The block-local penalty `V_bᵀ S_b V_b` is the correct per-block
1313    // penalty: in raw coords the model penalises `γ_bᵀ S_b γ_b` on block
1314    // b's own coefficients, and under the residualised reparameterisation
1315    // the cross-block carry `R_{a→b}` lives entirely in the *design*
1316    // columns (`C_b V_b − A_{<b} R_b`), not in the penalty.
1317    //
1318    // Pulling penalties back through the full joint triangular T instead
1319    // (`Tᵀ blkdiag(S_b) T`) yields a `(p_compiled × p_compiled)` dense
1320    // matrix whose off-diagonal couples θ_b to earlier blocks' θ_a;
1321    // jamming that joint-width matrix into a single block's `penalties`
1322    // produced the `block 0 penalty 0 must be 12x12, got 17x17` mismatch
1323    // that surfaced as the `assert_valid_blockspecs` FFI panic. The two
1324    // agree whenever the residualisation `R_{a→b}` lands in the null space
1325    // of S_a (the shared low-order / parametric directions the identifiable
1326    // quotient strips), which is the case the compiler targets.
1327    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
1376/// Run the identifiability compiler on the three survival parametric
1377/// blocks (time, marginal, logslope) at a pilot β and return the per-
1378/// block V reparameterisation matrices.
1379///
1380/// `row_hess` must be a PSD per-row 4×4 Hessian of `−log L_i(u_i)` at
1381/// the pilot β (see [`SurvivalRowHessian::from_pilot_primary_state`]).
1382/// The compiler residualises blocks left-to-right in priority order
1383/// (time → marginal → logslope) in the sqrt-H-metric so any aliased
1384/// direction lands in the lower-priority block, then runs a post-walk
1385/// column-pivoted QR on the cumulative anchor and drops trailing
1386/// pivots from the latest block. The returned V matrices are ready to
1387/// be applied to each block's raw design and penalty before the
1388/// `ParameterBlockSpec` list is assembled.
1389///
1390/// On `FullyAliased` from `compile()` (a block fully absorbed by its
1391/// cumulative anchor) this returns `Err`. The construction site should
1392/// surface that as a structured user-facing diagnostic — the model is
1393/// asking the compiler to assign zero degrees of freedom to a named
1394/// parametric block, which is a model-spec bug not a numerical one.
1395///
1396/// Sibling Phase-4b wiring (`bernoulli_marginal_slope::install_compiled_flex_block_into_runtime`)
1397/// already calls `compile()` for the flex blocks. This helper extends
1398/// that contract to the parametric blocks by giving the SMGS
1399/// construction site a one-line entry point — it does NOT yet apply
1400/// the V transforms to the family's captured designs (the captured-
1401/// design update is the remaining integration step that touches the
1402/// family's row-Hessian assembly assertions).
1403pub 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
1459/// Build the operator stack from already-materialised dense designs.
1460///
1461/// `time_dq0/dq1/dqd1` are the time block's three primary-state Jacobians
1462/// at training rows. `marginal_dq` and `marginal_dqd1` are the marginal
1463/// block's contributions to q (shared between q0 and q1) and to qd1
1464/// (typically zero unless timewiggle interacts). `logslope_dg` is the
1465/// logslope block's contribution to g.
1466///
1467/// `score_warp_(dq, dqd1)` / `link_dev_(dq, dqd1)` are present only when
1468/// the corresponding flex block is active. The returned `ordering` parallels
1469/// `operators` so the caller can route compiled outputs back to runtime slots.
1470pub 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
1512/// V+M-exact compiled designs + per-block penalties for the survival
1513/// time/marginal/logslope blocks, produced by
1514/// [`apply_compiled_map_to_designs`] from a `CompiledMap`. The
1515/// construction site swaps raw designs/penalties for these compiled
1516/// versions before building `ParameterBlockSpec`s.
1517///
1518/// The emitted designs carry the exact residualised `C_b·V_b − A_{<b}·R_b`
1519/// row form (via [`wrap_design_with_transform`] on `V_b = T[raw_b, comp_b]`):
1520/// the cross-block residualisation `R_{a→b}` lives in those design columns,
1521/// while each block's penalty is pulled back through that block's own
1522/// diagonal `V_b` as `V_bᵀ S_b V_b` (the `*_penalties` fields).
1523///
1524/// At fit result the joint compiled β is lifted back to raw via the
1525/// `gam_solve::gauge::Gauge` built from the *same* `CompiledMap`
1526/// (`β_raw = T · θ`, T block-upper-triangular with `V_b` on the diagonal
1527/// and `-R_{a→b}` off-diagonal). The full T therefore lives on that
1528/// `Gauge`, not on this struct — the caller holds the `CompiledMap` and
1529/// constructs both from it, so duplicating T here would be dead state.
1530pub 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    /// Per-block penalties, each pulled back through that block's OWN
1537    /// diagonal reparameterisation `V_b` as `V_bᵀ S_b V_b`. The result
1538    /// is a per-block-width `PenaltyMatrix::Dense`
1539    /// (`w_b_compiled × w_b_compiled`) — the shape a per-block
1540    /// `ParameterBlockSpec.penalties` slot requires. Cross-block
1541    /// residualisation `R_{a→b}` is carried by the residualised design
1542    /// columns, not the penalty.
1543    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    /// Construct a synthetic tensor-backed `SurvivalRowHessian` for the unit
1552    /// tests below. Production instances must retain their real row data so a
1553    /// later `channel_hessian_at` refresh is exact — hence a test-module
1554    /// helper, not a production constructor.
1555    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        // Construct M = U diag(2, -1, 0.5, -0.25) Uᵀ for a fixed U from
1573        // a small rotation, verify the clamped matrix has eigenvalues
1574        // (2, 0, 0.5, 0).
1575        let mut m = Array2::<f64>::zeros((4, 4));
1576        // Diagonal with mixed signs is sufficient for the test: the
1577        // eigenvalues equal the diagonal and the eigenvectors are e_i.
1578        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        // No penalties: whole block is one term.
1647        let part = extract_term_partition_from_penalty_ranges(5, &[]);
1648        assert_eq!(part.as_slice(), std::slice::from_ref(&full));
1649        // One penalty covering the whole block.
1650        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        // Two penalties with a gap: produces three terms (pen1, gap, pen2).
1653        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        // Duplicate penalty ranges coalesce.
1656        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        // Empty block.
1659        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        // Doesn't start at 0.
1717        assert!(validate_partition(std::slice::from_ref(&bad_start), 5, "test").is_err());
1718        // Doesn't cover the block.
1719        assert!(validate_partition(std::slice::from_ref(&short_cover), 5, "test").is_err());
1720        // Has a gap.
1721        assert!(validate_partition(&[0..2, 3..5], 5, "test").is_err());
1722        // Has overlap.
1723        assert!(validate_partition(&[0..3, 2..5], 5, "test").is_err());
1724        // Has empty range.
1725        assert!(validate_partition(&[0..0, 0..5], 5, "test").is_err());
1726        // Empty block + empty partition OK.
1727        assert!(validate_partition(&[], 0, "test").is_ok());
1728        // Valid partition.
1729        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    /// Regression for #368: the phase-4b compiled-map penalty pullback must
1734    /// emit a PER-BLOCK-WIDTH penalty for every block (sized to that block's
1735    /// COMPILED design width), even when a block drops columns and the
1736    /// triangular T carries nonzero off-diagonal cross-block residualisation
1737    /// `R_{a→b}`. The original bug pulled penalties back through the full
1738    /// joint T (`Tᵀ S T`), producing joint-compiled-width penalties (e.g.
1739    /// 7×7) that did not fit a single per-block `ParameterBlockSpec.penalties`
1740    /// slot (e.g. time block compiled width 3), making `validate_blockspecs`
1741    /// fail and `assert_valid_blockspecs` panic across the FFI boundary on
1742    /// ordinary survival data.
1743    #[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        // Time raw 3 → compiled 3 (block 0: no anchor, V pure, R=None).
1750        // Marginal raw 3 → compiled 2 (a real drop, with nonzero R against time).
1751        // Logslope raw 2 → compiled 2 (nonzero R against time+marginal).
1752        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        // R_marg: rows = time raw width 3, cols = marginal compiled width 2.
1764        let r_marg = Array2::<f64>::from_shape_fn((3, 2), |(i, j)| 0.7 - 0.1 * ((i + j) as f64));
1765        // R_log: rows = time+marg RAW width 6 (3 + 3), cols = logslope compiled
1766        // width 2. `assemble_block_triangular_t` stacks R_{a→b} over a<b, so the row
1767        // count is the sum of the RAW widths of the prior blocks (not their
1768        // compiled widths — marginal's compiled width is 2 but its raw width is 3).
1769        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        // Raw designs (dense, n rows).
1785        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        // Block-local penalties (col_range relative to each block's first col).
1800        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        // Designs carry per-block compiled widths.
1829        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        // Core invariant the bug violated: every penalty is sized to ITS
1834        // OWN block's compiled width, NOT the joint compiled width (7).
1835        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        // For the time block (block 0, no anchor ⇒ R=None), the per-block
1854        // pullback is EXACT: θ_timeᵀ P_time θ_time == γ_timeᵀ S_time γ_time
1855        // with γ_time = V_time · θ_time. Verify the quadratic-form identity.
1856        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        // The marginal pullback must equal V_margᵀ S_marg V_marg exactly
1867        // (block-local; the cross-block R_marg lives in the design, not here).
1868        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    /// Top-level Phase-4b API test for the SMGS parametric path:
1881    /// call `compile_survival_parametric_designs` on a shared-constant
1882    /// alias between time and marginal, with an identity row Hessian.
1883    /// Verify the returned `v_*` matrices have the expected widths
1884    /// (time keeps all 3, marginal loses 1, logslope keeps both) and
1885    /// `drops_by_block` reports `(0, 1, 0)`.
1886    #[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; // alias with time col 0
1912            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    /// End-to-end Phase-4b smoke test: build the full 3-block survival
1943    /// parametric operator stack (time + marginal + logslope) with a
1944    /// shared-constant alias seeded between the time and marginal
1945    /// blocks, feed it into `compile()` with an identity 4×4 row
1946    /// Hessian on every row, and verify the compiler:
1947    ///
1948    ///   (1) returns a [`CompiledBlocks`] with one block per input;
1949    ///   (2) preserves all 3 columns of the highest-priority `Time`
1950    ///       block in `t_lw` (the time block enters first in the
1951    ///       ordering, so its full column span survives);
1952    ///   (3) drops exactly one direction from `Marginal` (the
1953    ///       constant aliased with the time intercept), leaving its
1954    ///       remaining columns in `t_lw`;
1955    ///   (4) reports `joint_rank` = (raw_total - 1).
1956    ///
1957    /// This validates the Phase-4b construction-time orthogonalisation
1958    /// path on the survival K=4 row primary state and then feeds the
1959    /// compiled per-block reduced bases through the SMGS lift [`Gauge`]
1960    /// (step 6), asserting the lift's reduced/raw block structure agrees
1961    /// with the compiled rank-drop — the construction contract end to end.
1962    #[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        // Time block:
1972        //   col 0 = ones (the shared constant — aliases marginal col 0);
1973        //   col 1 = linear x;
1974        //   col 2 = quadratic x².
1975        // q0/q1 share the same design (so the alias surfaces in both
1976        // the entry and exit primary channels); qd1 is the derivative
1977        // of the design w.r.t. time at the exit point, which for the
1978        // constant column is exactly zero (the gauge identity that
1979        // makes the constant a true null direction under (q0, q1, qd1)
1980        // joint).
1981        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            // d/dt of a constant = 0; d/dt of x ≡ 1; d/dt of x² ≡ 2x.
1995            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        // Marginal block (q-channel only; qd1 contribution zero — no
2001        // timewiggle in this scenario):
2002        //   col 0 = ones (the shared constant);
2003        //   col 1 = x³;
2004        //   col 2 = sin(x).
2005        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        // Logslope block (g-channel only):
2014        //   col 0 = cos(2x);
2015        //   col 1 = tanh(x).  (no shared constant — logslope is clean)
2016        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        // Identity 4×4 row Hessian on every row. With H_i = I the
2027        // sqrt-H metric collapses to the standard Frobenius metric,
2028        // so the compiler's residualisation is ordinary least-squares
2029        // projection — exactly what we want for verifying the
2030        // structural rank-deficiency attribution.
2031        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        // (1) One CompiledBlock per input.
2043        assert_eq!(compiled.blocks.len(), 3, "expected 3 CompiledBlocks");
2044
2045        // (2) Time enters first; under sqrt-I metric every column of
2046        // the time block is residual-vs-empty-anchor and therefore
2047        // survives the eigendecomposition with positive eigenvalue.
2048        // V_time has p_time columns.
2049        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        // (3) Marginal enters second. Its constant column is aliased
2058        // with time's constant column in (q0, q1) and contributes zero
2059        // to qd1. After residualising against the time anchor in the
2060        // K=4 stacked metric, the residual Gram has rank
2061        // p_marginal − 1 (one direction collapsed by the alias). So
2062        // V_marginal has exactly (p_marginal − 1) columns.
2063        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        // (4) Logslope enters third and carries no shared direction
2074        // with time or marginal in the g-channel. Both columns survive.
2075        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        // (5) Joint rank consistency: sum of compiled column counts
2083        // equals raw_total minus the one aliased direction.
2084        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        // (6) SMGS construction contract. Feed the compiled per-block reduced
2098        // bases (V_k = t_lw, shaped raw_k × kept_k) into the SMGS lift `Gauge`
2099        // and verify the lift's coordinate bookkeeping matches the compiler's
2100        // rank attribution: the reduced dimension equals `joint_rank`, the
2101        // reduced block boundaries advance by each block's kept width, and —
2102        // with R = None (no residualised cross-block reparam in this V-only
2103        // construction) — the raw block boundaries advance by each block's raw
2104        // width. This exercises the SMGS construction hook directly on the
2105        // compiled output rather than asserting against a hypothetical shape.
2106        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        // (7) Every kept direction is finite and non-degenerate. A retained
2133        // column with a zero or non-finite norm would be a spurious rank
2134        // contribution that the count-only checks above cannot catch, so verify
2135        // each compiled block's surviving directions directly.
2136        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    /// `T = I` case: per-block V = identity, R = None. The triangular
2153    /// lift must be the identity on each block.
2154    #[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    /// Two-block toy: V_a = I_3, V_b drops the middle column, R is a
2183    /// non-trivial residualised reparam. Verify β_a_raw = θ_a − R · θ_b
2184    /// and β_b_raw = V_b · θ_b.
2185    #[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    /// Covariance pushforward `Σ_raw = T · Σ_θ · Tᵀ` must be the exact
2219    /// inference companion of the point-estimate lift. Two invariants:
2220    ///
2221    /// 1. Identity T (V = I, R = None): the lifted covariance equals the
2222    ///    input covariance — a true no-op for a rank-clean fit.
2223    /// 2. Rank-1 consistency with the β lift: for a degenerate posterior
2224    ///    `Σ_θ = θ θᵀ`, the pushforward must equal `(T θ)(T θ)ᵀ`, i.e.
2225    ///    lifting the covariance of a point mass agrees with lifting the
2226    ///    point itself. This couples `lift_covariance` to
2227    ///    `lift_block_betas` exactly, so the mean and its
2228    ///    uncertainty can never drift into inconsistent coordinates.
2229    #[test]
2230    fn smgs_lift_covariance_identity_and_rank1_consistency() {
2231        // ── Invariant 1: identity T leaves the covariance unchanged. ──
2232        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        // An arbitrary symmetric PSD-ish covariance.
2238        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        // ── Invariant 2: rank-1 Σ_θ = θθᵀ pushes to (Tθ)(Tθ)ᵀ. ──
2255        // Reuse the two-block-with-residualisation geometry: V_a = I_3,
2256        // V_b drops the middle raw column, R_b non-trivial → raw width 6,
2257        // compiled width 5.
2258        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        // Concatenated compiled θ (width 5).
2274        let theta_full = Array1::from(vec![
2275            theta_a[0], theta_a[1], theta_a[2], theta_b[0], theta_b[1],
2276        ]);
2277        // Σ_θ = θ θᵀ (rank-1).
2278        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        // Reference: (T θ)(T θ)ᵀ via the point-estimate lift.
2286        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        // Symmetry sanity.
2306        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    /// When all R's are None, the triangular gauge lift must equal the
2314    /// strictly per-block `V_b · θ_b` lift.
2315    #[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    /// Recompile-after-first-PIRLS-accept refinement: under a structural
2346    /// (identity) row Hessian, a direction that is *only* identifiable
2347    /// through the q1 channel survives the per-term compile; under a
2348    /// data-adaptive row Hessian that happens to zero out the q1/qd1/g
2349    /// metric weight (everything except q0), the same direction collapses.
2350    /// This pins the diagnostic the production hook in
2351    /// `fit_survival_marginal_slope_terms` watches for: the two row
2352    /// Hessians produce different `drops_by_block` on identical raw
2353    /// designs.
2354    #[test]
2355    fn recompile_after_accept_diff_detection_pilot_curvature_trap() {
2356        let n = 6usize;
2357        // Time block: a single column that only contributes through q0
2358        // (entry-time channel). Both row Hessians see it identically on
2359        // the q0 axis.
2360        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        // Marginal block: a single column whose q0 part is colinear with
2364        // the time block's q0 (both are ones-vectors). Its q-channel maps
2365        // into BOTH q0 and q1 under QChannelBlockOperator, so under a
2366        // metric that weighs q1 it carries a non-colinear component.
2367        let marg_dq = Array2::<f64>::from_elem((n, 1), 1.0);
2368        let marg_dqd1 = Array2::<f64>::zeros((n, 1));
2369        // No logslope columns.
2370        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        // Pass 1: structural identity row Hessian. q0/q1/qd1/g all weighted
2378        // equally → marg's q1 component is visible, so marg is identifiable
2379        // after residualising against the time block (drops_marg = 0).
2380        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        // Pass 2: data-adaptive row Hessian that only weighs q0 (all
2403        // other channel diagonals zero). Marg's q1 contribution is now
2404        // invisible → marg fully aliases with time on q0 → drops_marg = 1.
2405        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        // The two drops_by_block tuples disagree on the marginal block —
2426        // this is exactly the "pilot-curvature trap" the recompile-after-
2427        // accept hook is designed to surface.
2428        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        // Under identity H, marg survives (no drop).
2436        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        // Under q0-only H, marg fully aliases with time on q0.
2442        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        // Build a per-term compile by hand: time has one term (raw 2, kept 2),
2452        // marginal one term (raw 2, kept 1 — a drop), logslope one term
2453        // (raw 1, kept 1). No required channel is fully collapsed.
2454        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        // R for the marginal block (anchor = time, raw width 2) and logslope
2460        // block (anchors = time + marginal, raw width 2 + 2 = 4).
2461        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        // Raw block ranges: time 0..2, marginal 2..4, logslope 4..5.
2474        assert_eq!(map.raw_block_ranges, vec![0..2, 2..4, 4..5]);
2475        // Compiled block ranges: time 0..2, marginal 2..3, logslope 3..4.
2476        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        // The block-diagonal slices recovered by apply_compiled_map_to_designs
2480        // must equal the per-term V's exactly.
2481        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        // The cross-block carry (-R) must sit in the strict upper triangle, so
2502        // the map agrees with the lift assembled directly from V and R.
2503        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    // ----- #979 effective reduced-logslope confound removal -----------------
2525    //
2526    // Direct unit coverage of the two numerical routines added for #979,
2527    // mirroring the BMS reference cuts
2528    // (`bms::block_specs` `effective_reduction_*`): the scalar weight
2529    // contraction off the per-row 4×4 Hessian, and the block-diagonal map
2530    // assembly. The 900s end-to-end `survival_marginal_slope_converges_*`
2531    // guard exercises the same path but is slow and data-dependent; these pin
2532    // the distinguishing logic deterministically.
2533
2534    /// Constant per-row 4×4 PSD Hessian carrying ONLY the (q0, g) coupling,
2535    /// channel order (q0, q1, qd1, g): `H[0,0]=h00`, `H[0,3]=H[3,0]=h03`,
2536    /// `H[3,3]=h33`, all else zero. The effective scalar weights the
2537    /// contraction reads are then `w_mm=h00`, `w_mg=h03`, `w_gg=h33`. The
2538    /// 2×2 (q0,g) block `[[h00,h03],[h03,h33]]` is PSD when `h00·h33 ≥ h03²`.
2539    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        // p_m=1 marginal column m; p_log=2 logslope columns [l1, l2] with
2553        // l1 == m (an exact rank-1 (q0,g) confound: h00·h33 = h03²) so l1 is
2554        // fully marginal-explained, and l2 ⊥ m with 100× the energy so it is
2555        // unambiguously free. The effective Schur Gram must drop ONLY the
2556        // confounded direction: 0 < r == 1 < p_log == 2.
2557        let n = 4;
2558        let row_hess = const_row_hess_q0g(n, 2.0, 2.0, 2.0); // (q0,g) = [[2,2],[2,2]], rank-1
2559        let marg = Array2::from_shape_vec((n, 1), vec![1.0, 1.0, 1.0, 1.0]).unwrap();
2560        // l1 = m (confounded); l2 = [10,-10,10,-10] (Euclidean-orthogonal to m,
2561        // ‖l2‖² = 400 ≫ ‖m‖² = 4, so Gtt's free eigenvalue ≫ tol).
2562        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        // The kept eigenvector is the free column ≈ e2 (up to sign); the
2574        // confounded e1 component is dropped.
2575        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        // A single logslope column equal to the marginal column under the exact
2590        // rank-1 (q0,g) confound: the whole effective logslope image lies in the
2591        // marginal span. The conservative ridge floors the residual eigenvalue at
2592        // energy_scale·TOL/(1+TOL) < tol, so r == 0 → FullyConfounded — a
2593        // DISTINCT signal from the full-rank case, so the caller can delete the
2594        // unidentified channel deliberately (#2245 finding 45 sibling).
2595        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        // No marginal↔logslope cross weight (h03 = 0): the channels are
2613        // W-orthogonal, so every logslope direction is free (r == p_log) and
2614        // there is nothing to remove → FullRank (keep the raw design).
2615        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        // Time (p=2) and marginal (p=3) blocks pass through as identities; only
2635        // the logslope block (raw p_log=4) is reparameterised by t_log (4×2).
2636        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        // V_time = I2.
2647        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        // V_marg = I3.
2654        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        // V_log = t_log.
2664        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        // No cross-block bleed: the only nonzeros are the two identities and the
2673        // t_log block (every t_log entry here is nonzero).
2674        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}