Skip to main content

gam_problem/
block_spec.rs

1//! Data model for the blockwise carrier (subset moved down to `gam-problem`):
2//! parameter-block specs, the effective-Jacobian and channel-Hessian
3//! abstractions, per-block working sets and states, and the block geometry
4//! directional derivative.
5//!
6//! The coefficient-group/label/prior types, `custom_family_block_role`, and the
7//! blockspec validators remain in the family crate because they depend on
8//! `CoefficientGroupPrior`/`RhoPrior`/`BlockRole`/`CustomFamilyError`.
9
10use std::ops::Range;
11use std::sync::Arc;
12
13use ndarray::{Array1, Array2};
14
15use crate::PenaltyMatrix;
16use gam_linalg::matrix::{DesignMatrix, SymmetricMatrix};
17
18/// Per-subject channel Hessian provider for multi-output families.
19///
20/// The Fisher information decomposition for multi-output families is
21///
22/// ```text
23/// I(β) = Σ_i  J_iᵀ W_i J_i
24/// ```
25///
26/// where `J_i` is the channel-stacked Jacobian (shape `n_outputs × p` for
27/// subject `i`) and `W_i` is the `n_outputs × n_outputs` per-subject channel
28/// Hessian of the row negative log-likelihood (the second-derivative block of
29/// `−log L_i(u_i)` at a pilot β, PSD-clamped).
30///
31/// For single-output families this is the scalar IRLS weight; for multi-output
32/// families (survival marginal-slope: `n_outputs = 4`; location-scale:
33/// `n_outputs = 2`) it carries full cross-channel curvature.
34///
35/// The identifiability canonicalisation step uses the `n_outputs`-channel
36/// weighted joint design `W_joint = Σ_i sqrt(W_i) ⊗ J_i` to detect
37/// block-against-block aliasing.  When this trait is present on
38/// `ParameterBlockSpec::channel_hessian`, `canonicalize_for_identifiability`
39/// routes through `audit_identifiability_channel_aware`; when absent it falls
40/// back to the scalar-weight flat audit.
41///
42/// # W-metric rank theorem
43///
44/// The canonicalisation computes `rank(J^T W J)` where `W_blkdiag =
45/// block-diagonal of per-subject W_i`.  This rank equals
46///
47/// ```text
48/// rank(J) − dim(range(J) ∩ ker(W_blkdiag))
49/// ```
50///
51/// i.e. columns of `J` that lie in the kernel of `W_blkdiag` (flat directions
52/// in the curvature landscape at the pilot β) are correctly identified as
53/// curvature-redundant and may be dropped.
54pub trait FamilyChannelHessian: Send + Sync {
55    /// Number of output channels `n_outputs` (= K in the row Jacobian).
56    fn n_outputs(&self) -> usize;
57
58    /// Number of subjects (rows).
59    fn n_subjects(&self) -> usize;
60
61    /// Fill the `n_outputs × n_outputs` per-subject channel Hessian `W_i`
62    /// into `out` (row-major, length `n_outputs * n_outputs`) for subject `i`.
63    /// Negative eigenvalues must be clamped to zero (PSD projection) before
64    /// or inside this call.
65    fn fill_subject(&self, i: usize, out: &mut [f64]);
66
67    /// Materialise the full `(n_subjects × n_outputs × n_outputs)` tensor.
68    /// Default implementation calls `fill_subject` for each row.
69    fn evaluate_full(&self) -> ndarray::Array3<f64> {
70        let n = self.n_subjects();
71        let k = self.n_outputs();
72        let mut out = ndarray::Array3::<f64>::zeros((n, k, k));
73        let mut buf = vec![0.0_f64; k * k];
74        for i in 0..n {
75            self.fill_subject(i, &mut buf);
76            for a in 0..k {
77                for b in 0..k {
78                    out[[i, a, b]] = buf[a * k + b];
79                }
80            }
81        }
82        out
83    }
84}
85
86/// β-linearization state passed to [`BlockEffectiveJacobian::effective_jacobian_at`].
87///
88/// At pre-fit initialization, pass `beta = &[]` / zeros and `family_scalars = None`.
89/// Families that need β-dependent scalars (e.g. survival marginal-slope's q0, q1,
90/// g, c, z) store them in `family_scalars` as a concrete type behind
91/// `Arc<dyn Any + Send + Sync>` and downcast inside their impl.
92pub struct FamilyLinearizationState<'a> {
93    pub beta: &'a [f64],
94    /// Optional family-shared scalars at this β linearization.
95    /// Downcast via `state.family_scalars.as_ref().and_then(|a| a.downcast_ref::<T>())`.
96    pub family_scalars: Option<Arc<dyn std::any::Any + Send + Sync>>,
97    /// Optional per-subject channel Hessian for multi-output families.
98    /// When `Some`, the identifiability canonicalisation step and the Gram
99    /// builder use the channel-stacked Fisher information instead of the
100    /// scalar-weight approximation.  Single-output families leave this `None`.
101    pub channel_hessian: Option<Arc<dyn FamilyChannelHessian>>,
102    /// Probit frailty scale factor `s_f = 1/√(1+σ²)`.
103    ///
104    /// For survival marginal-slope families the logslope η contribution is
105    /// `s_f · g · z`, so any Jacobian callback that depends on g or z must
106    /// read `s_f` from here rather than from a captured-at-construction value.
107    /// When σ = 0 (no frailty) or for non-frailty families, set this to 1.0.
108    ///
109    /// Since σ is always **fixed** (not jointly optimised with β) in the
110    /// survival family, `s_f` is a static scalar for the entire inner fit;
111    /// `∂s_f/∂σ` never appears in the β-Jacobian.  The field is nonetheless
112    /// carried through state so that Jacobian callbacks are not required to
113    /// capture `s_f` at spec-construction time — they can read it at
114    /// evaluation time and thus stay correct across outer-loop σ updates.
115    pub probit_frailty_scale: f64,
116}
117
118/// β-dependent Jacobian callback for a parameter block.
119///
120/// Principled long-term contract for expressing how a block contributes to
121/// the stacked linear predictor at a given β:
122///
123/// ```text
124/// J(β) ∈ ℝ^{n_rows · n_outputs × p_block}
125/// ```
126///
127/// - Single-output linear block: returns `design.clone()`.
128/// - Row-scaled block (`RowScaledJacobian`): returns `diag(eta_scaling) · design` (still linear in β).
129/// - Multi-output block (e.g. survival marginal-slope with η0, η1, ad1):
130///   stacks `∂eta_r/∂β_k` for `r ∈ 0..n_outputs`, row-major ordering.
131///
132/// The default impl on [`ParameterBlockSpec::effective_jacobian_at`] is:
133/// - `jacobian_callback = None` → `design.clone()`.
134/// - `jacobian_callback = Some(cb)` → delegates to `cb.effective_jacobian_at`.
135pub trait BlockEffectiveJacobian: Send + Sync {
136    /// Stacked multi-output Jacobian for a contiguous observation row range.
137    ///
138    /// Shape: `(rows.len() * n_outputs, p_block)`, with the same channel-major
139    /// layout as [`Self::effective_jacobian_at`]: row
140    /// `channel * rows.len() + local_row` is `rows.start + local_row` in that
141    /// output channel. Implementations should keep this as the single source of
142    /// row math so large construction-time audits can stream chunks instead of
143    /// materialising all `n * p * K` entries at once.
144    fn effective_jacobian_rows(
145        &self,
146        state: &FamilyLinearizationState<'_>,
147        rows: Range<usize>,
148    ) -> Result<Array2<f64>, String>;
149
150    /// Stacked multi-output Jacobian at the current β.
151    ///
152    /// Shape: `(n_rows * n_outputs, p_block)`, **channel-major**: rows
153    /// `r * n_rows .. (r + 1) * n_rows` carry output channel `r`'s row
154    /// Jacobian, so `stacked[r * n_rows + i, j]` is observation `i`'s row at
155    /// output `r` and coefficient column `j`.  Every consumer that destacks
156    /// this matrix (audit, canonicaliser, fit) relies on this layout — see
157    /// `BlockJacobianAsRowOp::from_callback` for the destacking transpose.
158    /// For `n_outputs = 1` this is identical to the `(n_rows, p_block)` effective
159    /// design used by the flat identifiability audit.
160    fn effective_jacobian_at(
161        &self,
162        state: &FamilyLinearizationState<'_>,
163    ) -> Result<Array2<f64>, String> {
164        let full = self.effective_jacobian_rows(state, 0..usize::MAX)?;
165        Ok(full)
166    }
167
168    /// Number of stacked output channels. 1 for most blocks.
169    fn n_outputs(&self) -> usize {
170        1
171    }
172
173    /// Returns the per-row scaling vector when this callback is a simple
174    /// diagonal-scaling block (`RowScaledJacobian`).  Used by the
175    /// identifiability audit's skewness-aware bias correction (T25).
176    ///
177    /// Returns `None` for all blocks except `RowScaledJacobian`.
178    fn eta_row_scaling_for_skewness(&self) -> Option<Arc<[f64]>> {
179        None
180    }
181
182    /// Whether the identifiability canonicaliser must keep this block at its
183    /// full raw column width instead of column-reducing it.
184    ///
185    /// The `#933` reduction path wraps a `jacobian_callback` block in a
186    /// gauge-composed Jacobian so the family fits in a reduced section — sound
187    /// only when the family's effective geometry is DERIVED from the callback
188    /// (marginal-slope logslope). It is NOT sound for a callback whose
189    /// effective Jacobian is a **fixed nonlinear functional basis** recomputed
190    /// at the raw coefficient width on every evaluation (the survival
191    /// marginal-slope monotone time-wiggle time block), nor for one that merely
192    /// DECLARES an output channel for a family that materialises its own
193    /// raw-width design (the multinomial softmax, whose every assembly reads the
194    /// `X` it captured at construction — #2744): such a family's downstream
195    /// likelihood reads raw-width internal designs and asserts
196    /// `beta.len() == p_raw`, so a reduced β desynchronises that layout — the
197    /// same failure mode the competing-risks dead-column veto already guards.
198    /// Such a block returns `true` so the canonicaliser keeps it at raw width
199    /// (its own penalty nullspace regularises the weak directions instead).
200    ///
201    /// Defaults to `false`: every existing callback reduces safely.
202    fn locks_raw_width_reduction(&self) -> bool {
203        false
204    }
205}
206
207/// A [`BlockEffectiveJacobian`] for any block that contributes linearly to
208/// exactly one output of a multi-output family.
209///
210/// `own_output` is the zero-based output index that this block drives.
211/// `n_family_outputs` is the total number of outputs (e.g. 2 for location-scale).
212/// `design` is the block's effective design matrix (n × p_block).
213///
214/// The returned Jacobian has shape `(n_family_outputs * n, p_block)`:
215/// rows `own_output * n .. (own_output + 1) * n` contain `design`,
216/// all other rows are zero.
217pub struct AdditiveBlockJacobian {
218    pub design: Array2<f64>,
219    pub own_output: usize,
220    pub n_family_outputs: usize,
221}
222
223impl BlockEffectiveJacobian for AdditiveBlockJacobian {
224    fn effective_jacobian_rows(
225        &self,
226        state: &FamilyLinearizationState<'_>,
227        rows: Range<usize>,
228    ) -> Result<Array2<f64>, String> {
229        let n = self.design.nrows();
230        let p = self.design.ncols();
231        let rows = clamp_jacobian_rows(rows, n);
232        // Additive (linear) block: Jacobian is β-independent — design does
233        // not depend on state.beta. Verify beta contains no NaN when provided.
234        if !state.beta.is_empty() && state.beta.iter().any(|v| v.is_nan()) {
235            return Err(
236                "AdditiveBlockJacobian::effective_jacobian_at: beta contains NaN".to_string(),
237            );
238        }
239        let chunk = rows.end - rows.start;
240        let total_rows = self.n_family_outputs * chunk;
241        let mut jac = Array2::<f64>::zeros((total_rows, p));
242        let row_start = self.own_output * chunk;
243        jac.slice_mut(ndarray::s![row_start..row_start + chunk, ..])
244            .assign(&self.design.slice(ndarray::s![rows.start..rows.end, ..]));
245        Ok(jac)
246    }
247
248    fn n_outputs(&self) -> usize {
249        self.n_family_outputs
250    }
251}
252
253/// A [`BlockEffectiveJacobian`] for a single-output block whose contribution
254/// to the linear predictor is `diag(eta_scaling) · design` (row-wise scaling).
255///
256/// This is the canonical replacement for the former `eta_row_scaling` field on
257/// [`ParameterBlockSpec`].  The identifiability audit's skewness-aware bias
258/// correction can recover the scaling vector via
259/// [`BlockEffectiveJacobian::eta_row_scaling_for_skewness`].
260pub struct RowScaledJacobian {
261    pub design: Arc<Array2<f64>>,
262    pub eta_scaling: Arc<[f64]>,
263}
264
265impl BlockEffectiveJacobian for RowScaledJacobian {
266    fn effective_jacobian_rows(
267        &self,
268        state: &FamilyLinearizationState<'_>,
269        rows: Range<usize>,
270    ) -> Result<Array2<f64>, String> {
271        let n = self.design.nrows();
272        let rows = clamp_jacobian_rows(rows, n);
273        if self.eta_scaling.len() != n {
274            return Err(format!(
275                "RowScaledJacobian: eta_scaling length {} != design nrows {}",
276                self.eta_scaling.len(),
277                n,
278            ));
279        }
280        // Row-scaled blocks are β-linear; verify the linearization point
281        // contains no NaN when β is provided (sanity check on caller state).
282        if !state.beta.is_empty() && state.beta.iter().any(|v| v.is_nan()) {
283            return Err(
284                "RowScaledJacobian::effective_jacobian_at: state.beta contains NaN".to_string(),
285            );
286        }
287        let mut scaled = self
288            .design
289            .slice(ndarray::s![rows.start..rows.end, ..])
290            .to_owned();
291        for local_i in 0..scaled.nrows() {
292            let s = self.eta_scaling[rows.start + local_i];
293            for j in 0..scaled.ncols() {
294                scaled[[local_i, j]] *= s;
295            }
296        }
297        Ok(scaled)
298    }
299
300    fn eta_row_scaling_for_skewness(&self) -> Option<Arc<[f64]>> {
301        Some(Arc::clone(&self.eta_scaling))
302    }
303}
304
305pub(crate) fn clamp_jacobian_rows(rows: Range<usize>, n: usize) -> Range<usize> {
306    let start = rows.start.min(n);
307    let end = rows.end.min(n);
308    start..end.max(start)
309}
310
311/// A [`BlockEffectiveJacobian`] that composes an inner callback's raw-width
312/// effective Jacobian with a fixed reduced→raw block transform `T_b`
313/// (`p_raw × r_reduced`), so the family sees the **reduced** coordinates by
314/// construction (#933).
315///
316/// The inner callback emits its row Jacobian in the raw coordinate system
317/// (`(rows · k) × p_raw`), the layout every `BlockEffectiveJacobian` impl
318/// produces — channel-major rows, raw columns. Post-multiplying each row by
319/// `T_b` rotates those raw columns into the reduced section: the effective
320/// reduced Jacobian is `J_raw · T_b`, with `r_reduced` columns. On the model
321/// `η = J_raw · β_raw = (J_raw · T_b) · θ` this is the exact reduced operator
322/// for the reduced coefficient θ, and the family lifts θ back to β_raw through
323/// the SAME `T_b` via the one [`Gauge`](crate::Gauge).
324///
325/// This is the inversion #933 calls for: instead of forwarding a raw-width
326/// callback alongside a column-selection `T_i` (which leaves the family
327/// asserting raw column counts on a reduced spec and panicking), the callback
328/// is wrapped so its output already has the reduced width — the family captures
329/// the reduced design and its row-Hessian column-count assertions hold by
330/// construction. A column-selection `T_b` (zero/one entries) makes this exactly
331/// the audit's drop; a general orthonormal `T_b` makes it any gauge section.
332pub struct GaugeComposedJacobian {
333    inner: Arc<dyn BlockEffectiveJacobian>,
334    /// Reduced→raw block transform `T_b`, shape `(p_raw × r_reduced)`.
335    t_block: Arc<Array2<f64>>,
336}
337
338impl GaugeComposedJacobian {
339    /// Wrap `inner` so its effective Jacobian is post-multiplied by `t_block`
340    /// (`p_raw × r_reduced`). `t_block.nrows()` must equal the inner callback's
341    /// raw column count.
342    pub fn new(inner: Arc<dyn BlockEffectiveJacobian>, t_block: Arc<Array2<f64>>) -> Self {
343        Self { inner, t_block }
344    }
345}
346
347impl BlockEffectiveJacobian for GaugeComposedJacobian {
348    fn effective_jacobian_rows(
349        &self,
350        state: &FamilyLinearizationState<'_>,
351        rows: Range<usize>,
352    ) -> Result<Array2<f64>, String> {
353        let raw_width = self.t_block.nrows();
354        let reduced_width = self.t_block.ncols();
355        let lifted_beta;
356        let lifted_state;
357        let zero_raw_beta;
358        let delegate_state = if state.beta.len() == raw_width {
359            state
360        } else if state.beta.len() == reduced_width {
361            lifted_beta = self.t_block.dot(&ndarray::ArrayView1::from(state.beta));
362            lifted_state = FamilyLinearizationState {
363                beta: lifted_beta
364                    .as_slice()
365                    .expect("GaugeComposedJacobian lifted beta is contiguous"),
366                family_scalars: state.family_scalars.clone(),
367                channel_hessian: state.channel_hessian.clone(),
368                probit_frailty_scale: state.probit_frailty_scale,
369            };
370            &lifted_state
371        } else if state.beta.is_empty() {
372            zero_raw_beta = ndarray::Array1::<f64>::zeros(raw_width);
373            lifted_state = FamilyLinearizationState {
374                beta: zero_raw_beta
375                    .as_slice()
376                    .expect("GaugeComposedJacobian zero raw beta is contiguous"),
377                family_scalars: state.family_scalars.clone(),
378                channel_hessian: state.channel_hessian.clone(),
379                probit_frailty_scale: state.probit_frailty_scale,
380            };
381            &lifted_state
382        } else {
383            return Err(format!(
384                "GaugeComposedJacobian: beta has length {}, expected raw width {} \
385                 or reduced width {}; this wrapper cannot infer a block slice from a joint \
386                 coefficient vector",
387                state.beta.len(),
388                raw_width,
389                reduced_width,
390            ));
391        };
392        let j_raw = self.inner.effective_jacobian_rows(delegate_state, rows)?;
393        if j_raw.ncols() != self.t_block.nrows() {
394            return Err(format!(
395                "GaugeComposedJacobian: inner Jacobian has {} columns but T_b has {} rows",
396                j_raw.ncols(),
397                self.t_block.nrows(),
398            ));
399        }
400        // (rows·k × p_raw) · (p_raw × r_reduced) = (rows·k × r_reduced).
401        Ok(j_raw.dot(self.t_block.as_ref()))
402    }
403
404    fn n_outputs(&self) -> usize {
405        self.inner.n_outputs()
406    }
407
408    // Skewness scaling is a raw-row property; reducing the column space does not
409    // change the per-row scaling, so it is forwarded unchanged when present.
410    fn eta_row_scaling_for_skewness(&self) -> Option<Arc<[f64]>> {
411        self.inner.eta_row_scaling_for_skewness()
412    }
413}
414
415#[cfg(test)]
416mod gauge_composed_jacobian_tests {
417    use super::*;
418    use ndarray::array;
419
420    struct BetaScaledJacobian {
421        design: Array2<f64>,
422    }
423
424    impl BlockEffectiveJacobian for BetaScaledJacobian {
425        fn effective_jacobian_rows(
426            &self,
427            state: &FamilyLinearizationState<'_>,
428            rows: Range<usize>,
429        ) -> Result<Array2<f64>, String> {
430            let n = self.design.nrows();
431            let rows = rows.start.min(n)..rows.end.min(n);
432            let mut out = self.design.slice(ndarray::s![rows, ..]).to_owned();
433            for col in 0..out.ncols() {
434                let scale = 1.0 + state.beta.get(col).copied().unwrap_or(0.0);
435                out.column_mut(col).mapv_inplace(|v| v * scale);
436            }
437            Ok(out)
438        }
439
440        fn n_outputs(&self) -> usize {
441            1
442        }
443    }
444
445    #[test]
446    fn gauge_composed_jacobian_lifts_reduced_block_beta_before_delegating() {
447        let inner: Arc<dyn BlockEffectiveJacobian> = Arc::new(BetaScaledJacobian {
448            design: array![[2.0, 3.0], [5.0, 7.0]],
449        });
450        let t_block = Arc::new(array![[0.0], [1.0]]);
451        let wrapped = GaugeComposedJacobian::new(inner, Arc::clone(&t_block));
452
453        let theta = [4.0];
454        let reduced_state = FamilyLinearizationState {
455            beta: &theta,
456            family_scalars: None,
457            channel_hessian: None,
458            probit_frailty_scale: 1.0,
459        };
460        let reduced = wrapped
461            .effective_jacobian_rows(&reduced_state, 0..2)
462            .expect("reduced beta should be lifted through T before inner callback");
463
464        let raw_beta = [0.0, 4.0];
465        let raw_state = FamilyLinearizationState {
466            beta: &raw_beta,
467            family_scalars: None,
468            channel_hessian: None,
469            probit_frailty_scale: 1.0,
470        };
471        let raw = wrapped
472            .effective_jacobian_rows(&raw_state, 0..2)
473            .expect("raw beta state remains valid");
474
475        assert_eq!(reduced, raw);
476        assert_eq!(reduced, array![[15.0], [35.0]]);
477    }
478
479    struct StrictRawWidthJacobian {
480        design: Array2<f64>,
481    }
482
483    impl BlockEffectiveJacobian for StrictRawWidthJacobian {
484        fn effective_jacobian_rows(
485            &self,
486            state: &FamilyLinearizationState<'_>,
487            rows: Range<usize>,
488        ) -> Result<Array2<f64>, String> {
489            if state.beta.len() != self.design.ncols() {
490                return Err(format!(
491                    "StrictRawWidthJacobian expected raw beta len {}, got {}",
492                    self.design.ncols(),
493                    state.beta.len(),
494                ));
495            }
496            Ok(self.design.slice(ndarray::s![rows, ..]).to_owned())
497        }
498    }
499
500    #[test]
501    fn gauge_composed_jacobian_lifts_zero_reduced_beta_before_delegating() {
502        let inner: Arc<dyn BlockEffectiveJacobian> = Arc::new(StrictRawWidthJacobian {
503            design: array![[2.0, 3.0], [5.0, 7.0]],
504        });
505        let wrapped = GaugeComposedJacobian::new(inner, Arc::new(array![[0.0], [1.0]]));
506
507        let theta = [0.0];
508        let reduced_state = FamilyLinearizationState {
509            beta: &theta,
510            family_scalars: None,
511            channel_hessian: None,
512            probit_frailty_scale: 1.0,
513        };
514
515        let reduced = wrapped
516            .effective_jacobian_rows(&reduced_state, 0..2)
517            .expect("zero reduced beta must still be lifted to raw width");
518
519        assert_eq!(reduced, array![[3.0], [7.0]]);
520    }
521
522    #[test]
523    fn gauge_composed_jacobian_rejects_nonzero_unknown_beta_layout() {
524        let inner: Arc<dyn BlockEffectiveJacobian> = Arc::new(BetaScaledJacobian {
525            design: array![[2.0, 3.0]],
526        });
527        let wrapped = GaugeComposedJacobian::new(inner, Arc::new(array![[0.0], [1.0]]));
528        let joint_like_beta = [1.0, 0.0, 0.0];
529        let state = FamilyLinearizationState {
530            beta: &joint_like_beta,
531            family_scalars: None,
532            channel_hessian: None,
533            probit_frailty_scale: 1.0,
534        };
535
536        let err = wrapped
537            .effective_jacobian_rows(&state, 0..1)
538            .expect_err("nonzero joint-layout beta cannot be inferred from one block T");
539        assert!(
540            err.contains("cannot infer a block slice"),
541            "unexpected error: {err}"
542        );
543    }
544}
545
546/// Static specification for one parameter block in a custom family.
547///
548/// `design` and `stacked_design` are two structurally distinct operators:
549///
550/// * `design` is the **canonical, single-channel, n-observation operator**.
551///   `design.nrows()` ALWAYS equals `n_obs` (one row per training
552///   observation).  This is the matrix the identifiability audit, the
553///   shape policy, and every "what shape is this block?" reader inspect.
554///   For most blocks `design` is also the eta-producing operator used by
555///   the solver — see [`Self::solver_design`].
556/// * `stacked_design`, when `Some`, is the **multi-channel eta-producing
557///   operator** used by the solver.  Survival time-varying blocks stack
558///   `[exit; entry; deriv]` into a `(3·n × p)` operator here so the
559///   solver can produce a `3·n`-long `eta` in one mat-vec; the audit
560///   never sees this matrix.  When `None`, the solver uses `design` (the
561///   single-channel default).
562///
563/// The single contract that downstream code can rely on:
564/// `design.nrows() == n_obs`.  No more dual semantics on `design`.
565///
566/// Read access:
567/// * Audit / canonicalize / "n_obs is the row count" code → `&spec.design`.
568/// * Eta-producing solver code → [`Self::solver_design`].
569#[derive(Clone)]
570pub struct ParameterBlockSpec {
571    pub name: String,
572    pub design: DesignMatrix,
573    pub offset: Array1<f64>,
574    /// Block-local penalty matrices (all p_block x p_block).
575    pub penalties: Vec<PenaltyMatrix>,
576    /// Structural nullspace dimension of each penalty matrix (same length as `penalties`).
577    /// Used by the penalty pseudo-logdet to determine rank without numerical thresholds.
578    /// If empty, falls back to eigenvalue-based rank detection.
579    pub nullspace_dims: Vec<usize>,
580    /// Initial log-smoothing parameters for this block (same length as `penalties`).
581    pub initial_log_lambdas: Array1<f64>,
582    /// Optional initial coefficients (defaults to zeros if omitted).
583    pub initial_beta: Option<Array1<f64>>,
584    /// Gauge ownership priority. Higher = more likely to retain a
585    /// redundant direction during canonical-gauge reparameterisation.
586    /// Defaults to 100. Set higher for blocks that should "own" shared
587    /// affine/null-space directions (e.g. baseline time in survival).
588    pub gauge_priority: u8,
589    /// Full β-dependent Jacobian callback.  When `Some`, this is the
590    /// authoritative source for `effective_jacobian_at`.  For simple
591    /// single-output row-scaled blocks use [`RowScaledJacobian`].
592    pub jacobian_callback: Option<Arc<dyn BlockEffectiveJacobian>>,
593    /// Optional multi-channel eta-producing operator used by the solver.
594    ///
595    /// When `Some`, the solver consumes this matrix (typically
596    /// `(k·n × p)` for `k` stacked channels — e.g. survival
597    /// `[exit; entry; deriv]` with `k = 3`) to evaluate `eta = stacked · β + stacked_offset`.
598    /// The audit and shape policy NEVER read this field; they only ever
599    /// inspect `design` (which always has `n_obs` rows).
600    ///
601    /// When `None`, the solver falls back to `design` — the correct
602    /// behavior for every single-channel block (i.e. all non-survival
603    /// time-varying blocks).
604    ///
605    /// Read this field via [`Self::solver_design`], never directly.
606    ///
607    /// Invariant: when `stacked_design = Some(_)`, `stacked_offset` MUST
608    /// also be `Some(_)` and its length MUST equal `stacked_design.nrows()`.
609    pub stacked_design: Option<DesignMatrix>,
610    /// Optional offset paired with [`Self::stacked_design`]. Same Option
611    /// state as `stacked_design` (both `Some` or both `None`).
612    /// Read via [`Self::solver_offset`].
613    pub stacked_offset: Option<Array1<f64>>,
614}
615
616impl std::fmt::Debug for ParameterBlockSpec {
617    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618        f.debug_struct("ParameterBlockSpec")
619            .field("name", &self.name)
620            .field("design", &self.design)
621            .field("offset", &self.offset)
622            .field("penalties", &self.penalties)
623            .field("nullspace_dims", &self.nullspace_dims)
624            .field("initial_log_lambdas", &self.initial_log_lambdas)
625            .field("initial_beta", &self.initial_beta)
626            .field("gauge_priority", &self.gauge_priority)
627            .field(
628                "jacobian_callback",
629                &self
630                    .jacobian_callback
631                    .as_ref()
632                    .map(|_| "<BlockEffectiveJacobian>"),
633            )
634            .finish()
635    }
636}
637
638impl ParameterBlockSpec {
639    /// Returns a ParameterBlockSpec with sensible defaults for all optional
640    /// fields. Callers using struct literal syntax can use
641    /// `..ParameterBlockSpec::defaults()` to fill in any fields added after
642    /// the literal was written.
643    pub fn defaults() -> Self {
644        Self {
645            name: String::new(),
646            design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
647                ndarray::Array2::<f64>::zeros((0, 0)),
648            )),
649            offset: ndarray::Array1::<f64>::zeros(0),
650            penalties: Vec::new(),
651            nullspace_dims: Vec::new(),
652            initial_log_lambdas: ndarray::Array1::<f64>::zeros(0),
653            initial_beta: None,
654            gauge_priority: 100,
655            jacobian_callback: None,
656            stacked_design: None,
657            stacked_offset: None,
658        }
659    }
660
661    /// Returns the eta-producing operator used by the solver.
662    ///
663    /// Resolution order:
664    ///   1. `stacked_design = Some(d)` → return `d` (multi-channel
665    ///      operator, e.g. `(3n × p)` for survival time-varying blocks).
666    ///   2. otherwise → return `&self.design` (the single-channel default).
667    ///
668    /// Solver code that needs `eta = D · β` MUST call this accessor;
669    /// reading `&self.design` directly silently breaks multi-channel
670    /// (survival LS time-varying) blocks because `self.design.nrows()`
671    /// always equals `n_obs`, never `3·n_obs`.
672    pub fn solver_design(&self) -> &DesignMatrix {
673        self.stacked_design.as_ref().unwrap_or(&self.design)
674    }
675
676    /// Returns the offset paired with [`Self::solver_design`]. When
677    /// `stacked_offset = Some(o)` this returns `&o`; otherwise it falls
678    /// back to `&self.offset`.
679    pub fn solver_offset(&self) -> &Array1<f64> {
680        self.stacked_offset.as_ref().unwrap_or(&self.offset)
681    }
682
683    /// Returns the effective design `D_eff` for this block at β = 0 with no
684    /// family scalars — a convenience wrapper around [`Self::effective_jacobian_at`]
685    /// for the single-output (n_outputs = 1) case.
686    ///
687    /// Callers that need multi-output Jacobians or β-dependent scalars should
688    /// call `effective_jacobian_at` directly with the appropriate state.
689    ///
690    /// Returns `Err` if the design cannot be densified.
691    pub fn effective_design(&self, caller: &str) -> Result<ndarray::Array2<f64>, String> {
692        let p = self.design.ncols();
693        let zeros = vec![0.0f64; p];
694        let state = FamilyLinearizationState {
695            beta: &zeros,
696            family_scalars: None,
697            channel_hessian: None,
698            probit_frailty_scale: 1.0,
699        };
700        self.effective_jacobian_at(caller, &state)
701    }
702
703    /// Returns the β-dependent stacked Jacobian `J(β)` for this block.
704    ///
705    /// Shape: `(n_rows * n_outputs, p_block)`.  For most blocks `n_outputs = 1`
706    /// and the result is the familiar `(n_rows, p_block)` effective design.
707    ///
708    /// Dispatch order:
709    ///   1. `jacobian_callback = Some(cb)` → `cb.effective_jacobian_at(state)`.
710    ///   2. `jacobian_callback = None` → `design.clone()` (ignores `beta` and `family_scalars`).
711    ///
712    /// Returns `Err` if the design cannot be densified.
713    pub fn effective_jacobian_at(
714        &self,
715        caller: &str,
716        state: &FamilyLinearizationState<'_>,
717    ) -> Result<ndarray::Array2<f64>, String> {
718        if let Some(cb) = self.jacobian_callback.as_ref() {
719            return cb.effective_jacobian_at(state);
720        }
721        self.design
722            .try_to_dense_arc(&format!(
723                "{caller}::effective_jacobian_at block '{}'",
724                self.name
725            ))
726            .map(|arc| arc.as_ref().clone())
727    }
728}
729
730/// Current state for a parameter block.
731#[derive(Clone, Debug)]
732pub struct ParameterBlockState {
733    pub beta: Array1<f64>,
734    pub eta: Array1<f64>,
735}
736
737#[derive(Clone)]
738pub struct BlockGeometryDirectionalDerivative {
739    /// Directional derivative of the block design matrix along a coefficient-space direction.
740    pub d_design: Option<Array2<f64>>,
741    /// Directional derivative of the block offset along the same direction.
742    pub d_offset: Array1<f64>,
743}
744
745/// Working quantities supplied by a custom family for one block.
746///
747/// # Observed vs expected information (see response.md Section 3)
748///
749/// For the outer REML/LAML criterion, the Hessian used in log|H| and trace terms
750/// must be the **observed** (actual) Hessian at the mode, not the expected Fisher.
751///
752/// - `ExactNewton`: provides -nabla^2 log L directly, which is the observed Hessian
753///   by construction. This is always correct.
754///
755/// - `Diagonal`: provides IRLS working weights W such that the per-block Hessian
756///   is X'WX. For canonical links (logit-Binomial, log-Poisson), W_obs = W_Fisher.
757///   For supported non-canonical diagonal links, W must be the observed weight
758///   W_obs = W_Fisher - (y-mu)*B so the outer REML uses the exact Laplace
759///   Hessian. The matching `CustomFamily::diagonalworking_weights_directional_derivative`
760///   callback must differentiate the same observed W surface; silently using Fisher
761///   weights or zero `dW` would change the criterion into a PQL-type surrogate.
762#[derive(Clone, Debug)]
763pub enum BlockWorkingSet {
764    /// Standard IRLS/GLM-style diagonal working set for eta-space updates.
765    Diagonal {
766        /// IRLS pseudo-response for this block's linear predictor.
767        working_response: Array1<f64>,
768        /// IRLS working curvature for this block (finite signed values, length n).
769        ///
770        /// For the inner solver, Fisher or observed weights both find the same mode.
771        /// For the outer REML/LAML log|H| term, observed weights are the correct
772        /// Laplace choice (see response.md Section 3). Canonical-link families need
773        /// no correction since observed = Fisher.
774        working_weights: Array1<f64>,
775    },
776    /// Exact Newton block update in coefficient space.
777    ///
778    /// `gradient` is nabla log L wrt block coefficients.
779    /// `hessian` is -nabla^2 log L wrt block coefficients (positive semidefinite near optimum).
780    ///
781    /// This is the observed Hessian by construction (actual second derivative of the
782    /// log-likelihood), which is the correct quantity for the outer REML Laplace
783    /// approximation.
784    ExactNewton {
785        gradient: Array1<f64>,
786        hessian: SymmetricMatrix,
787    },
788}
789
790impl BlockWorkingSet {
791    /// Construct a `Diagonal` working set with its length and finite-value
792    /// invariants enforced at the type boundary. Signed observed curvature is
793    /// preserved exactly; stabilization belongs to the assembled matrix.
794    #[inline]
795    pub fn diagonal_checked(
796        working_response: Array1<f64>,
797        working_weights: Array1<f64>,
798    ) -> Result<Self, String> {
799        if working_response.len() != working_weights.len() {
800            return Err(format!(
801                "BlockWorkingSet::Diagonal length mismatch: working_response={}, working_weights={}",
802                working_response.len(),
803                working_weights.len(),
804            ));
805        }
806        if let Some((row, value)) = working_response
807            .iter()
808            .chain(working_weights.iter())
809            .enumerate()
810            .find(|(_, value)| !value.is_finite())
811        {
812            return Err(format!(
813                "BlockWorkingSet::Diagonal contains a non-finite value at flattened index {row}: {value}"
814            ));
815        }
816        Ok(Self::Diagonal {
817            working_response,
818            working_weights,
819        })
820    }
821}
822
823/// What a parameter block's COEFFICIENT COORDINATE is, as opposed to what its
824/// column space is (#2748).
825///
826/// # The distinction, and why one bit is needed to state it
827///
828/// For most blocks the coefficients are an arbitrary basis: any `V` with
829/// `X V` spanning `range(X)` gives the same model, so the identifiability
830/// canonicaliser is free to reparameterise `β ↦ Vᵀβ`, pull the penalties back
831/// as `VᵀSV`, and let [`crate::Gauge`] lift the answer home. That freedom is
832/// what lets it remove a cross-block structural confound EXACTLY instead of
833/// ridging it away.
834///
835/// Some blocks are not like that. The monotone link-wiggle warp is the
836/// canonical case: its family imposes `β_w ≥ 0` **componentwise on those very
837/// coefficients**, because an I-spline with non-negative coefficients is what
838/// makes the learned link monotone. `β ↦ Vᵀβ` maps that cone to
839/// `{A V β̃ ≥ 0}`, and the hook that produces it
840/// (`CustomFamily::block_linear_constraints`) is a function of the block's
841/// WIDTH — it cannot express `A V`, so after a reparameterisation it would
842/// return a cone in rotated coordinates that means nothing, and the
843/// coordinatewise projection beside it (`post_update_block_beta`) would enforce
844/// that nothing. The model would silently stop being monotone.
845///
846/// So "may this block be reparameterised?" is a property of the block's
847/// coordinate, and it is NOT the question [`ParameterBlockSpec::gauge_priority`]
848/// answers. A priority answers "if a shared direction must be given up, whose
849/// is it?" — an ordering AMONG blocks. Reading an ordering as a licence to
850/// rotate is how gam#2748 broke: giving the warp a strictly lower priority
851/// (correctly, so a cross-block alias stops being *unfittable*) also lifted the
852/// canonicaliser's equal-priority guard and authorised rotating the one block
853/// that cannot be rotated.
854///
855/// # Fail-closed
856///
857/// [`Spanning`](Self::Spanning) is the default because it is the common case,
858/// but every DERIVATION of this value must resolve doubt toward
859/// [`Structural`](Self::Structural): declining a reparameterisation always
860/// preserves the model (the canonicaliser falls through to the audit gate,
861/// which is where it lived before the orthogonalisation pass existed), while
862/// performing one on a structural coordinate silently changes it.
863#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
864pub enum CoefficientCoordinate {
865    /// Only the block's column SPACE is model content. Any basis of it is the
866    /// same model, so a reparameterisation `β ↦ Vᵀβ` with the penalties and the
867    /// warm start pulled back is exact.
868    #[default]
869    Spanning,
870    /// The coordinate ITSELF is model content — a componentwise sign cone, a
871    /// monotonicity ordering, a box the family projects onto, or a geometry the
872    /// family rebuilds at this exact width. No change of basis preserves the
873    /// model, and no change of width preserves the family's own rebuild.
874    Structural,
875}
876
877impl CoefficientCoordinate {
878    /// Is this coordinate free to be reparameterised?
879    pub fn is_spanning(self) -> bool {
880        matches!(self, Self::Spanning)
881    }
882
883    /// Does this coordinate carry model structure a reparameterisation would
884    /// destroy?
885    pub fn is_structural(self) -> bool {
886        matches!(self, Self::Structural)
887    }
888}