Skip to main content

gam_models/
marginal_slope_orthogonal.rs

1//! Neyman-orthogonal, cross-fitted marginal-slope calibration (closes #461):
2//! the Stage-1 score-influence Jacobian and the absorbed influence block.
3//!
4//! Stage 1 (a conditional transformation-normal, "CTN", model) fits a monotone
5//! `h(y | x; θ₁)` and emits a latent score `z_i = Φ⁻¹(u_i)` from the
6//! finite-support PIT
7//!
8//!     u_i = [Φ(h_i) − Φ(L_i)] / [Φ(U_i) − Φ(L_i)],
9//!
10//! with (SCOP form, see `transformation_normal.rs`)
11//!
12//!     h_i = b(x_i) + ε·(y_i − median) + Σ_{k≥1} I_k(y_i)·γ_k(x_i)²
13//!     L_i = h(y_min | x_i),  U_i = h(y_max | x_i)
14//!     b(x_i)   = Xᶜᵒᵛ_i · θ_b           (location column, response basis col 0)
15//!     γ_k(x_i) = Xᶜᵒᵛ_i · θ_{γk}         (squared SCOP shape coeffs, cols k≥1).
16//!
17//! Stage 2 (marginal-slope) treats `z_i` as a **generated regressor**:
18//! `z_i` depends on the Stage-1 estimate θ̂₁, so the β estimating equation is
19//! not orthogonal to θ₁. This module exposes the score-influence Jacobian
20//! `J = ∂z/∂θ₁` (design §2) and the absorbed influence block
21//! `Z_infl = diag(s_f·β̂₀)·J` (design §3); Stage 2 appends `Z_infl` as a
22//! null-penalized absorbed block, making the β estimating equation orthogonal
23//! to `span(Z_infl)` — the discrete realization of `ψ − Π_η[ψ]`.
24//!
25//! ## Column ordering of `J`
26//!
27//! `θ₁` is the Stage-1 coefficient vector of length `p₁ = p_resp · p_cov`,
28//! reshaped row-major to the `(p_resp, p_cov)` matrix `Γ` (`beta_mat`):
29//! response component `k` (row) crossed with covariate column `j` (col). The
30//! flat index of `Γ[k, j]` is `k·p_cov + j`. **`J`'s columns follow exactly
31//! this order**: column `k·p_cov + j` holds `∂z_i/∂Γ[k, j]`. Response row
32//! `k = 0` is the unconstrained location block `b(x)`; rows `k ≥ 1` are the
33//! squared SCOP shape blocks for `γ_k(x)`.
34
35use gam_linalg::faer_ndarray::{
36    FaerArrayView, factorize_symmetricwith_fallback, fast_ab, fast_abt, fast_xt_diag_x,
37    fast_xt_diag_y,
38};
39use crate::transformation_normal::{
40    TRANSFORMATION_MONOTONICITY_EPS, TransformationNormalFitResult, transformation_normal_pit_score,
41};
42use crate::inference::model::TRANSFORMATION_SCORE_PIT_CLIP_EPS;
43use gam_linalg::matrix::FactorizedSystem;
44use crate::probability::{
45    log1mexp_positive, normal_cdf, normal_logcdf, normal_pdf, standard_normal_quantile,
46};
47use gam_terms::smooth::build_term_collection_design;
48use faer::Side;
49use ndarray::{Array1, Array2, ArrayView2};
50
51/// Fixed (NOT REML-learned) log-λ for the influence-absorber block's ridge.
52///
53/// The §3 absorbed block `+Z_infl·γ` carries a small fixed ridge `½·ρ·‖γ‖²`
54/// (`ρ = exp(log_λ)`) so the joint solve soaks the `span(Z_infl)` component of
55/// the η-residual into `γ` without that block being treated as a smooth/REML
56/// surface. `log_λ = 0` ⇒ `ρ = 1`: enough to keep `γ` finite and bounded while
57/// the much larger marginal/logslope likelihood curvature dominates the fit.
58pub(crate) const INFLUENCE_ABSORBER_FIXED_LOG_LAMBDA: f64 = 0.0;
59
60/// Per-row, per-θ₁ score-influence Jacobian `∂z/∂θ₁` for a fitted CTN, plus the
61/// latent score `z` itself on the same rows.
62///
63/// `columns` is `n × p₁` with `p₁ = p_resp · p_cov`; see the module-level
64/// "Column ordering of `J`" note for the layout of the second axis. Computing
65/// `J` already evaluates `h/L/U` and the finite-support PIT, so `z = Φ⁻¹(PIT)`
66/// comes for free — exposing it here is the single source of truth for the
67/// cross-fit fold loop, which needs the out-of-fold `z` alongside `J` and must
68/// not re-run the PIT path to get it.
69pub struct ScoreInfluenceJacobian {
70    /// `n × p₁` matrix of `∂z_i/∂θ₁`.
71    pub columns: Array2<f64>,
72    /// `n` latent scores `z_i = Φ⁻¹(PIT_i)` at the same rows `J` was evaluated.
73    pub z: Array1<f64>,
74}
75
76/// Compute `J = ∂z/∂θ₁` from a fitted CTN at the given `(x, y)` rows.
77///
78/// `response` (length `n`) and `covariate_data` (`n × d`) are the rows at which
79/// to evaluate the Jacobian; they need not be the Stage-1 training rows (for
80/// cross-fitting they are the held-out fold). The fitted CTN supplies the
81/// coefficient vector θ̂₁, the response basis (re-evaluated at these `y` via the
82/// fitted knots), and the resolved covariate spec (re-built at these `x`).
83///
84/// `offset` (length `n`) is the per-row additive transformation linear-predictor
85/// offset on these rows. The CTN row build adds this offset identically to
86/// `h`, `L`, and `U` (`row_quantities`: `h_acc = … + offset_i`, and likewise for
87/// the lower/upper endpoints), so the finite-support PIT — and hence the latent
88/// score `z` reported here — depends on it. The Jacobian itself (`∂z/∂θ₁`) does
89/// NOT depend on the offset (it is θ₁-independent), but the *operating point*
90/// at which the φ/Φ ratios are evaluated does; omitting a non-zero offset would
91/// place `h/L/U` (and the emitted `z`) at the wrong point. For an offset-free
92/// Stage-1 (`offset ≡ 0`) this is a no-op. Pass the held-out fold's offset rows.
93///
94/// Implements design §2:
95///
96/// ```text
97/// ∂z_i/∂θ₁ = (1/φ(z_i)) · ∂u_i/∂θ₁
98/// ∂u_i/∂θ₁ = [ φ(h_i)·∂h_i/∂θ₁
99///              − u_i·(φ(U_i)·∂U_i/∂θ₁ − φ(L_i)·∂L_i/∂θ₁)
100///              − φ(L_i)·∂L_i/∂θ₁ ] / (Φ(U_i) − Φ(L_i))
101/// ∂h_i/∂Γ[0,j]   = Xᶜᵒᵛ_{i,j}
102/// ∂h_i/∂Γ[k,j]   = 2·I_k(y_i)·γ_k(x_i)·Xᶜᵒᵛ_{i,j}   (k ≥ 1)
103/// ```
104///
105/// with `∂L_i`, `∂U_i` analogous (the response basis `I_k` evaluated at the
106/// lower/upper support endpoints). Non-finite rows, support-order violations,
107/// and an under-resolvable endpoint mass return `Err` with row context.
108pub fn score_influence_jacobian(
109    fit: &TransformationNormalFitResult,
110    response: &Array1<f64>,
111    covariate_data: ArrayView2<f64>,
112    offset: &Array1<f64>,
113) -> Result<ScoreInfluenceJacobian, String> {
114    let family = &fit.family;
115    let n = response.len();
116    if covariate_data.nrows() != n {
117        return Err(format!(
118            "score_influence_jacobian: covariate rows ({}) != response rows ({n})",
119            covariate_data.nrows()
120        ));
121    }
122    if offset.len() != n {
123        return Err(format!(
124            "score_influence_jacobian: offset rows ({}) != response rows ({n})",
125            offset.len()
126        ));
127    }
128    if n == 0 {
129        return Err("score_influence_jacobian: empty input rows".to_string());
130    }
131
132    let p_resp = family.p_resp();
133    let p_cov = family.p_cov();
134    let p1 = p_resp.checked_mul(p_cov).ok_or_else(|| {
135        format!("score_influence_jacobian: p_resp({p_resp}) * p_cov({p_cov}) overflowed")
136    })?;
137
138    let beta = &fit
139        .fit
140        .block_states
141        .first()
142        .ok_or_else(|| "score_influence_jacobian: fitted CTN has no block states".to_string())?
143        .beta;
144    if beta.len() != p1 {
145        return Err(format!(
146            "score_influence_jacobian: beta length {} != p_resp({p_resp}) * p_cov({p_cov})",
147            beta.len()
148        ));
149    }
150    // θ₁ reshaped row-major to Γ (p_resp × p_cov): row k = response component k,
151    // col j = covariate column j. Matches the CTN fit reshape exactly.
152    let beta_mat = beta
153        .view()
154        .into_shape_with_order((p_resp, p_cov))
155        .map_err(|e| format!("score_influence_jacobian: beta reshape failed: {e}"))?;
156
157    // Response value basis [1, I_1(y), …, I_K(y)] at the fitted knots.
158    let resp_val = family.evaluate_response_value_basis(response.view())?;
159    if resp_val.nrows() != n || resp_val.ncols() != p_resp {
160        return Err(format!(
161            "score_influence_jacobian: response basis shape {}x{} != {n}x{p_resp}",
162            resp_val.nrows(),
163            resp_val.ncols()
164        ));
165    }
166
167    // Covariate design at these rows, rebuilt from the fitted resolved spec so
168    // the column geometry matches Stage-1. Materialize dense once.
169    let cov_design = build_term_collection_design(covariate_data, &fit.covariate_spec_resolved)
170        .map_err(|e| format!("score_influence_jacobian: covariate design build failed: {e}"))?;
171    if cov_design.design.ncols() != p_cov {
172        return Err(format!(
173            "score_influence_jacobian: rebuilt covariate design has {} columns, fitted p_cov is {p_cov}",
174            cov_design.design.ncols()
175        ));
176    }
177    let x_cov = cov_design.design.try_row_chunk(0..n).map_err(|e| {
178        format!("score_influence_jacobian: covariate design materialization failed: {e}")
179    })?;
180
181    // γ_k(x_i) = Σ_j Xᶜᵒᵛ_{i,j}·Γ[k,j]  ⇒  gamma = Xᶜᵒᵛ · Γᵀ  (n × p_resp).
182    let gamma = fast_abt(&x_cov, &beta_mat);
183    if gamma.nrows() != n || gamma.ncols() != p_resp {
184        return Err(format!(
185            "score_influence_jacobian: gamma shape {}x{} != {n}x{p_resp}",
186            gamma.nrows(),
187            gamma.ncols()
188        ));
189    }
190
191    // Row-independent endpoint response bases and floor offsets (fitted).
192    let lower_basis = family.response_lower_basis();
193    let upper_basis = family.response_upper_basis();
194    let lower_floor = family.response_lower_floor_offset();
195    let upper_floor = family.response_upper_floor_offset();
196    let median = family.response_median();
197
198    // Floor on φ(z): at the PIT clip the score saturates at a fixed extreme
199    // quantile, so φ(z) is bounded below by φ(Φ⁻¹(clip_eps)). Clamping the
200    // ∂z = ∂u/φ(z) denominator there keeps a saturated row's influence bounded
201    // rather than infinite — the same bound the PIT clip already imposes on z.
202    let pdf_z_floor = normal_pdf(
203        standard_normal_quantile(TRANSFORMATION_SCORE_PIT_CLIP_EPS)
204            .map_err(|e| format!("score_influence_jacobian: clip quantile failed: {e}"))?,
205    );
206
207    let mut columns = Array2::<f64>::zeros((n, p1));
208    let mut z_scores = Array1::<f64>::zeros(n);
209
210    for i in 0..n {
211        let gamma_row = gamma.row(i);
212        let val_row = resp_val.row(i);
213        let x_row = x_cov.row(i);
214        let g0 = gamma_row[0];
215
216        // h, L, U exactly as the CTN row-quantity build assembles them
217        // (`row_quantities`): the additive linear-predictor offset enters h, L,
218        // and U identically, so it is added here at all three to place the PIT
219        // operating point (and the emitted z) where the fitted model evaluates
220        // it. The per-row monotonicity floor ε·(y − median) and the endpoint
221        // floors are recomputed from the fitted median.
222        let offset_i = offset[i];
223        let value_floor = TRANSFORMATION_MONOTONICITY_EPS * (response[i] - median);
224        let mut h = val_row[0] * g0 + offset_i + value_floor;
225        let mut l = lower_basis[0] * g0 + offset_i + lower_floor;
226        let mut u = upper_basis[0] * g0 + offset_i + upper_floor;
227        for k in 1..p_resp {
228            let gk = gamma_row[k];
229            let gk_sq = gk * gk;
230            h += val_row[k] * gk_sq;
231            l += lower_basis[k] * gk_sq;
232            u += upper_basis[k] * gk_sq;
233        }
234
235        if !(h.is_finite() && l.is_finite() && u.is_finite()) {
236            return Err(format!(
237                "score_influence_jacobian: non-finite transform geometry at row {i}: h={h}, L={l}, U={u}"
238            ));
239        }
240        if u <= l {
241            return Err(format!(
242                "score_influence_jacobian: support order violated at row {i}: L={l:.6e} >= U={u:.6e}"
243            ));
244        }
245
246        // z is the finite-support PIT score, computed by the SAME canonical
247        // kernel the normal Stage-2 path consumes (`calibrate_transformation_scores`
248        // → `transformation_normal_pit_score`). That kernel forms the PIT ratio in
249        // LOG space (`normal_logcdf` + `log1mexp_positive`), which stays accurate
250        // when h/L/U all sit deep in a tail where the direct CDF subtraction
251        // `(Φ(h)−Φ(L))/(Φ(U)−Φ(L))` would cancel catastrophically. Reusing it makes
252        // z bit-identical to Stage-2's z — single source of truth.
253        let z = transformation_normal_pit_score(h, l, u, TRANSFORMATION_SCORE_PIT_CLIP_EPS)
254            .map_err(|e| format!("score_influence_jacobian: PIT score failed at row {i}: {e}"))?;
255        z_scores[i] = z;
256
257        // The ∂u/∂θ chain is evaluated at the SAME (clipped) operating point z
258        // represents: u_pit = Φ(z) exactly inverts z = Φ⁻¹(u_clamped), so the
259        // derivative coefficient and the reported score stay self-consistent
260        // without recomputing the (less stable) direct ratio. The endpoint
261        // φ/D ratios below are the analytic derivatives of that ratio.
262        //
263        // Compute log(D) = log(Φ(U)−Φ(L)) in log-space to avoid catastrophic
264        // cancellation when L and U both sit deep in the same tail (e.g. L=5,
265        // U=6 where normal_cdf returns 1.0 for both in direct form). This
266        // mirrors the `log_normal_cdf_diff` approach in `transformation_normal.rs`:
267        //
268        //   If L > 0 : Φ(U)−Φ(L) = Φ(−L)−Φ(−U)  (reflection, both < 0.5)
269        //              log_denom = normal_logcdf(−L) + log1mexp(normal_logcdf(−L) − normal_logcdf(−U))
270        //   Otherwise: log_denom = normal_logcdf(U) + log1mexp(normal_logcdf(U) − normal_logcdf(L))
271        let log_denom = if l > 0.0 {
272            let log_neg_l = normal_logcdf(-l);
273            let log_neg_u = normal_logcdf(-u);
274            let gap = log_neg_l - log_neg_u;
275            if !(gap.is_finite() && gap > 0.0) {
276                return Err(format!(
277                    "score_influence_jacobian: endpoint mass not resolvable at row {i}: l={l:.6e}, u={u:.6e}"
278                ));
279            }
280            log_neg_l + log1mexp_positive(gap)
281        } else {
282            let log_cu = normal_logcdf(u);
283            let log_cl = normal_logcdf(l);
284            let gap = log_cu - log_cl;
285            if !(gap.is_finite() && gap > 0.0) {
286                return Err(format!(
287                    "score_influence_jacobian: endpoint mass not resolvable at row {i}: l={l:.6e}, u={u:.6e}"
288                ));
289            }
290            log_cu + log1mexp_positive(gap)
291        };
292        if !log_denom.is_finite() {
293            return Err(format!(
294                "score_influence_jacobian: log endpoint mass not finite at row {i}: l={l:.6e}, u={u:.6e}"
295            ));
296        }
297
298        let u_pit = normal_cdf(z);
299
300        // φ(x)/D = exp(log φ(x) − log D).  Using log-space for these ratios
301        // keeps them accurate when D is tiny (deep-tail support intervals).
302        // log φ(x) = −½x² − log(√(2π)).
303        const LOG_SQRT_2PI: f64 = 0.918_938_533_204_672_7;
304        let log_phi = |x: f64| -0.5 * x * x - LOG_SQRT_2PI;
305
306        // φ at h/L/U. The chain ∂u/∂θ uses φ at the *unclamped* h when
307        // h is inside [L,U]; at the boundary (h clamped) φ(h)·∂h is the limiting
308        // contribution and the clamp keeps it finite.
309        let h_clamped = h.clamp(l, u);
310        let c_h = (log_phi(h_clamped) - log_denom).exp();
311        let c_l = (log_phi(l) - log_denom).exp();
312        let c_u = (log_phi(u) - log_denom).exp();
313
314        // ∂z = ∂u / φ(z). Where the score saturates (|z| large), φ(z) underflows;
315        // the `pdf_z_floor` clamp keeps a saturated row's influence bounded.
316        let pdf_z = normal_pdf(z).max(pdf_z_floor);
317
318        let mut row = columns.row_mut(i);
319        for k in 0..p_resp {
320            // ∂h/∂Γ[k,j], ∂L/∂Γ[k,j], ∂U/∂Γ[k,j] share the factor Xᶜᵒᵛ_{i,j};
321            // the response-side scalar differs between the location block
322            // (k = 0, basis value, unsquared) and the shape blocks
323            // (k ≥ 1, 2·basis·γ_k).
324            let (dh_scalar, dl_scalar, du_scalar) = if k == 0 {
325                (val_row[0], lower_basis[0], upper_basis[0])
326            } else {
327                let two_gk = 2.0 * gamma_row[k];
328                (
329                    val_row[k] * two_gk,
330                    lower_basis[k] * two_gk,
331                    upper_basis[k] * two_gk,
332                )
333            };
334            let base = k * p_cov;
335            for j in 0..p_cov {
336                let xij = x_row[j];
337                let dh = dh_scalar * xij;
338                let dl = dl_scalar * xij;
339                let du = du_scalar * xij;
340                // ∂u = [φ(h)∂h − u·(φ(U)∂U − φ(L)∂L) − φ(L)∂L] / (Φ(U)−Φ(L))
341                //     = c_h·∂h − u_pit·(c_u·∂U − c_l·∂L) − c_l·∂L
342                let du_pit = c_h * dh - u_pit * (c_u * du - c_l * dl) - c_l * dl;
343                row[base + j] = du_pit / pdf_z;
344            }
345        }
346    }
347
348    if columns.iter().any(|v| !v.is_finite()) {
349        return Err("score_influence_jacobian: produced non-finite Jacobian entries".to_string());
350    }
351    if z_scores.iter().any(|v| !v.is_finite()) {
352        return Err("score_influence_jacobian: produced non-finite z scores".to_string());
353    }
354
355    Ok(ScoreInfluenceJacobian {
356        columns,
357        z: z_scores,
358    })
359}
360
361/// Build the absorbed influence block `Z_infl = diag(s_f·β̂₀)·J` for Stage 2
362/// (design §3): row-scale each Jacobian row `i` by `s_f · pilot_beta0[i]`,
363/// where `pilot_beta0` is the rigid-pilot logslope `β̂₀(x_i)` (length `n`).
364///
365/// The returned `n × p₁` matrix spans the realized η-space leakage directions
366/// at the rigid pilot. Stage 2 appends it as a **plain additive** absorbed
367/// parameter block `+Z_infl·γ` carrying a fixed small ridge `½·ρ·‖γ‖²` (γ is a
368/// training-time leakage absorber, not a smooth/REML-learned block). This is
369/// NOT routed through the multiplicative `score_warp` / `DeviationRuntime`
370/// path — that path evaluates a scalar 1-D cubic in η and cannot carry the
371/// arbitrary x-dependent `n × p₁` matrix. The absorber is orthogonalized
372/// against the marginal block but deliberately overlaps logslope, with gauge
373/// priority above logslope, and is dropped at predict time.
374pub fn influence_block_design(
375    jac: &ScoreInfluenceJacobian,
376    pilot_beta0: &Array1<f64>,
377    s_f: f64,
378) -> Array2<f64> {
379    let n = jac.columns.nrows();
380    assert_eq!(
381        pilot_beta0.len(),
382        n,
383        "influence_block_design: pilot_beta0 length must equal Jacobian rows"
384    );
385    let mut out = jac.columns.clone();
386    for (i, mut row) in out.axis_iter_mut(ndarray::Axis(0)).enumerate() {
387        let scale = s_f * pilot_beta0[i];
388        row.mapv_inplace(|v| v * scale);
389    }
390    out
391}
392
393/// Residualize the influence columns `Z_infl` against the **marginal** design
394/// span in the rigid-pilot row metric `W`, retaining the logslope overlap
395/// (#461, design §3 — single source of truth for the BMS and survival absorbed
396/// blocks):
397///
398///   Z̃ = Z − M·(MᵀWM + εI)⁻¹·MᵀW·Z.
399///
400/// Residualizing against **marginal only** deliberately keeps the
401/// logslope-aligned component, so the absorber soaks the leakage direction that
402/// would otherwise manufacture spurious `β(x)` heterogeneity. `W` is the PIRLS
403/// row inner product at the rigid pilot, so the resulting orthogonality
404/// `MᵀW Z̃ ≈ 0` holds in the same metric the penalized joint solve sees, not
405/// merely in the Euclidean sense. `eps` is the (caller-scaled) ridge added to
406/// the weighted marginal Gram diagonal so the projection solve stays stable when
407/// the marginal design is rank-deficient at the pilot.
408///
409/// `z_infl` must already be the `influence_block_design` output (`n × p₁`).
410/// When the marginal design has zero columns the raw `z_infl` is returned (no
411/// span to project out).
412pub(crate) fn residualize_influence_columns(
413    z_infl: &Array2<f64>,
414    marginal_design: ArrayView2<f64>,
415    w_metric: &Array1<f64>,
416    eps: f64,
417) -> Array2<f64> {
418    let n = marginal_design.nrows();
419    assert_eq!(
420        z_infl.nrows(),
421        n,
422        "residualize_influence_columns: Z_infl rows must equal marginal design rows"
423    );
424    assert_eq!(
425        w_metric.len(),
426        n,
427        "residualize_influence_columns: row metric length must equal marginal design rows"
428    );
429    let p_m = marginal_design.ncols();
430    if p_m == 0 {
431        // No marginal span to residualize against; the raw directions are the
432        // absorbed columns.
433        return z_infl.clone();
434    }
435    // Weighted Gram MᵀWM and cross term MᵀW Z in the pilot row metric.
436    let mut gram = fast_xt_diag_x(&marginal_design, w_metric);
437    for i in 0..p_m {
438        gram[[i, i]] += eps;
439    }
440    let cross = fast_xt_diag_y(&marginal_design, w_metric, z_infl);
441    let gram_view = FaerArrayView::new(&gram);
442    let factor = factorize_symmetricwith_fallback(gram_view.as_ref(), Side::Lower)
443        .expect("residualize_influence_columns: weighted marginal Gram factorization failed");
444    // coeffs = (MᵀWM + εI)⁻¹ MᵀW Z   (p_m × p₁)
445    let coeffs = factor
446        .solvemulti(&cross)
447        .expect("residualize_influence_columns: marginal projection solve failed");
448    // Z̃ = Z − M·coeffs.
449    let projection = fast_ab(&marginal_design, &coeffs);
450    z_infl - &projection
451}
452
453/// Relative magnitude (vs. the largest weighted marginal-Gram diagonal) of the
454/// ridge added to `MᵀWM` in the §3 projection solve. Tiny — it only regularizes
455/// a rank-deficient marginal design at the pilot (a dropped/aliased spatial
456/// column leaves a zero pivot) and never perturbs a well-conditioned projection.
457pub(crate) const INFLUENCE_PROJECTION_RELATIVE_RIDGE: f64 = 1.0e-10;
458/// Absolute floor on the §3 projection ridge, so a degenerate (all-zero)
459/// weighted marginal Gram still yields an invertible system.
460pub(crate) const INFLUENCE_PROJECTION_RIDGE_FLOOR: f64 = 1.0e-12;
461
462/// The full §3 absorbed-block projection from the raw score-influence Jacobian —
463/// the single shared entry point for both families.
464///
465/// Performs the entire sequence, so neither BMS (widened marginal design) nor
466/// survival (dedicated η₁ channel) inlines any of it and both get byte-identical
467/// numerics:
468///
469///  1. build the realized leakage directions `Z_infl = diag(s_f·β̂₀)·J`
470///     ([`influence_block_design`]),
471///  2. derive the projection ridge from the weighted marginal Gram's own
472///     magnitude — `eps = max(diag(MᵀWM))·INFLUENCE_PROJECTION_RELATIVE_RIDGE`
473///     floored at `INFLUENCE_PROJECTION_RIDGE_FLOOR` — so it scales with the
474///     design rather than being a fixed absolute the caller must guess,
475///  3. residualize against the marginal/primary span in the rigid-pilot
476///     `W`-metric ([`residualize_influence_columns`]):
477///     `Z̃ = Z_infl − M·(MᵀWM + εI)⁻¹·MᵀW·Z_infl`.
478///
479/// Returns `Err` if the residualized columns are not all finite (e.g. a
480/// non-finite pilot logslope or row metric propagated through) — the finite
481/// guard is baked in so neither call site repeats it. The two families differ
482/// ONLY in how they install the returned `Z̃` (BMS widens `[M | Z̃]`; survival
483/// adds a dedicated additive η₁ channel), never in this math.
484///
485/// `raw_jac` is the bare `n × p₁` score-influence Jacobian (`∂z/∂θ₁`) — i.e.
486/// the value carried by the spec's `score_influence_jacobian` field — and
487/// `oof_z` is the matching out-of-fold latent score; callers hold these two
488/// arrays directly, so this entry point pairs them into a `ScoreInfluenceJacobian`
489/// internally rather than asking every site to construct one.
490pub(crate) fn residualized_influence_block(
491    raw_jac: &Array2<f64>,
492    oof_z: &Array1<f64>,
493    pilot_beta0: &Array1<f64>,
494    s_f: f64,
495    marginal_design: ArrayView2<f64>,
496    w_metric: &Array1<f64>,
497) -> Result<Array2<f64>, String> {
498    let jac = ScoreInfluenceJacobian {
499        columns: raw_jac.clone(),
500        z: oof_z.clone(),
501    };
502    let z_infl = influence_block_design(&jac, pilot_beta0, s_f);
503
504    // Ridge scaled to the weighted marginal Gram's own magnitude. Only the
505    // diagonal is needed to size it, so reuse the same MᵀWM the residualizer
506    // forms internally (the cost is one extra weighted Gram; kept here so the
507    // ε logic lives with the projection it regularizes).
508    let p_m = marginal_design.ncols();
509    let gram = fast_xt_diag_x(&marginal_design, w_metric);
510    let gram_scale = (0..p_m).map(|i| gram[[i, i]]).fold(0.0_f64, f64::max);
511    let eps =
512        (gram_scale * INFLUENCE_PROJECTION_RELATIVE_RIDGE).max(INFLUENCE_PROJECTION_RIDGE_FLOOR);
513
514    let residualized = residualize_influence_columns(&z_infl, marginal_design, w_metric, eps);
515    if residualized.iter().any(|v| !v.is_finite()) {
516        return Err(
517            "residualized_influence_block: residualized influence columns contain non-finite entries"
518                .to_string(),
519        );
520    }
521    Ok(residualized)
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use ndarray::array;
528
529    fn jac_from(columns: Array2<f64>) -> ScoreInfluenceJacobian {
530        let n = columns.nrows();
531        ScoreInfluenceJacobian {
532            columns,
533            z: Array1::zeros(n),
534        }
535    }
536
537    // ---- influence_block_design ----
538
539    #[test]
540    fn influence_block_design_row_scales_by_sf_times_pilot() {
541        // Z_infl[i, :] = (s_f * pilot_beta0[i]) * J[i, :].
542        let cols = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
543        let jac = jac_from(cols.clone());
544        let pilot = array![2.0, -1.0, 0.5];
545        let s_f = 3.0;
546        let out = influence_block_design(&jac, &pilot, s_f);
547        for i in 0..3 {
548            let scale = s_f * pilot[i];
549            for j in 0..2 {
550                assert_eq!(out[[i, j]], cols[[i, j]] * scale);
551            }
552        }
553    }
554
555    #[test]
556    fn influence_block_design_preserves_shape_and_does_not_mutate_input() {
557        let cols = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
558        let jac = jac_from(cols.clone());
559        let out = influence_block_design(&jac, &array![1.0, 1.0], 1.0);
560        assert_eq!(out.dim(), (2, 3));
561        // s_f = 1, pilot = ones => Z_infl == J exactly.
562        assert_eq!(out, cols);
563        // Source columns untouched (function clones internally).
564        assert_eq!(jac.columns, cols);
565    }
566
567    #[test]
568    #[should_panic(expected = "pilot_beta0 length must equal Jacobian rows")]
569    fn influence_block_design_panics_on_pilot_length_mismatch() {
570        let jac = jac_from(array![[1.0], [2.0]]);
571        influence_block_design(&jac, &array![1.0], 1.0);
572    }
573
574    // ---- residualize_influence_columns ----
575
576    #[test]
577    fn residualize_returns_input_when_no_marginal_columns() {
578        // p_m == 0 => nothing to project out, raw columns returned verbatim.
579        let z = array![[1.0, 2.0], [3.0, 4.0]];
580        let m = Array2::<f64>::zeros((2, 0));
581        let w = array![1.0, 1.0];
582        let out = residualize_influence_columns(&z, m.view(), &w, 1e-12);
583        assert_eq!(out, z);
584    }
585
586    #[test]
587    fn residualize_kills_columns_in_marginal_span() {
588        // If Z lies entirely in the column span of M, the residual is ~0
589        // (with a tiny ridge). Build Z = M * C.
590        let m = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0]];
591        let c = array![[2.0, -1.0], [0.5, 4.0]];
592        let z = fast_ab(&m, &c);
593        let w = Array1::<f64>::ones(4);
594        let out = residualize_influence_columns(&z, m.view(), &w, 1e-12);
595        let max_abs = out.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
596        assert!(max_abs < 1e-6, "residual of in-span Z too large: {max_abs}");
597    }
598
599    #[test]
600    fn residualize_yields_w_orthogonal_residual() {
601        // The defining property: MᵀW Z̃ ≈ 0 in the row metric W. Use a Z with a
602        // component outside span(M) so the residual is nonzero but W-orthogonal.
603        let m = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 4.0]];
604        let z = array![[0.3, 1.0], [-2.0, 0.5], [4.0, -1.0], [0.7, 2.0]];
605        let w = array![1.0, 2.0, 0.5, 1.5];
606        let out = residualize_influence_columns(&z, m.view(), &w, 1e-12);
607        // MᵀW Z̃ should be ~0.
608        let mtwz = fast_xt_diag_y(&m, &w, &out);
609        let max_abs = mtwz.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
610        assert!(max_abs < 1e-6, "MᵀW Z̃ not ~0: {max_abs}");
611        // The residual is genuinely nonzero (Z had an out-of-span part).
612        let resid_mag = out.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
613        assert!(resid_mag > 1e-3, "residual unexpectedly zero: {resid_mag}");
614        // Shape preserved.
615        assert_eq!(out.dim(), z.dim());
616    }
617
618    // ---- residualized_influence_block (end-to-end pure path) ----
619
620    #[test]
621    fn residualized_block_matches_manual_scale_then_residualize() {
622        // The block builds Z_infl = diag(s_f·β̂₀)·J then residualizes against M in
623        // the W-metric with a Gram-scaled ridge. Reconstruct that path manually.
624        let raw_jac = array![[1.0, 0.5], [2.0, -1.0], [0.0, 3.0], [1.5, 1.0]];
625        let oof_z = array![0.1, 0.2, 0.3, 0.4];
626        let pilot = array![1.0, 2.0, -0.5, 0.5];
627        let s_f = 1.5;
628        let m = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0]];
629        let w = array![1.0, 1.0, 2.0, 0.5];
630
631        let out =
632            residualized_influence_block(&raw_jac, &oof_z, &pilot, s_f, m.view(), &w).unwrap();
633
634        // Manual: scale rows, derive eps from the weighted Gram diagonal, residualize.
635        let jac = ScoreInfluenceJacobian {
636            columns: raw_jac.clone(),
637            z: oof_z.clone(),
638        };
639        let z_infl = influence_block_design(&jac, &pilot, s_f);
640        let gram = fast_xt_diag_x(&m, &w);
641        let gram_scale = (0..m.ncols()).map(|i| gram[[i, i]]).fold(0.0_f64, f64::max);
642        let eps = (gram_scale * INFLUENCE_PROJECTION_RELATIVE_RIDGE)
643            .max(INFLUENCE_PROJECTION_RIDGE_FLOOR);
644        let expected = residualize_influence_columns(&z_infl, m.view(), &w, eps);
645
646        assert_eq!(out.dim(), expected.dim());
647        for (a, b) in out.iter().zip(expected.iter()) {
648            assert!((a - b).abs() < 1e-12, "mismatch: {a} vs {b}");
649        }
650        // And the result is W-orthogonal to the marginal span.
651        let mtwz = fast_xt_diag_y(&m, &w, &out);
652        let max_abs = mtwz.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
653        assert!(max_abs < 1e-6, "MᵀW Z̃ not ~0: {max_abs}");
654    }
655}