Skip to main content

gam_identifiability/families/
compiler.rs

1//! Family-agnostic identifiability compiler.
2//!
3//! Single source of truth for cross-block W-metric residualisation across
4//! every blockwise family (BMS, SMGS, …). Row-Jacobian compiler that
5//! orthogonalises parameter blocks in the *row primary-state* metric `H_i`. Each block
6//! exposes a [`RowJacobianOperator`] that maps a coefficient perturbation
7//! `δβ ∈ R^p` to its contribution to the per-row primary state
8//! `u_i ∈ R^K`. The compiler walks the supplied ordering left-to-right,
9//! solves the weighted Gram system against the cumulative anchor, and
10//! emits a [`CompiledBlock`] per stage. A post-walk column-pivoted QR
11//! audit on the joint primary-state design deterministically drops
12//! trailing pivots from the latest block when joint rank is lost.
13
14use std::ops::Range;
15use std::sync::Arc;
16
17use ndarray::{Array1, Array2, Array3, Axis, s};
18
19use faer::Side;
20use gam_linalg::decision::{RankDecision, certified_rank, equilibrate_gram};
21use gam_linalg::faer_ndarray::{
22    FaerEigh, default_rrqr_rank_alpha, fast_ab, fast_ata, fast_atb, fast_xt_diag_y,
23    rrqr_with_permutation,
24};
25
26/// Slack factor (multiples of machine ε) for the rank-revealing eigenvalue
27/// threshold used when pseudo-inverting a Gram matrix or selecting the
28/// positive eigenspace of a residual Gram. The retain threshold is
29/// `scale · RANK_REVEAL_EPS_SLACK · size · ε`, where `scale` is the dominant
30/// eigenvalue (and matrix size accounts for the worst-case roundoff
31/// accumulation in the `O(size)` inner products forming each Gram entry). 64×
32/// keeps numerically-zero directions out of the kept subspace while preserving
33/// every genuinely identified direction at large-scale conditioning.
34const RANK_REVEAL_EPS_SLACK: f64 = 64.0;
35
36/// Two-sided multiplicative half-gap for the certified-rank guard band (issue
37/// #2337 §9-step-6). A rank decision is host-stable only when no equilibrated
38/// eigenvalue lands inside `(τ/(1+gap), τ·(1+gap))`. `gap = 1.0` (a factor-of-2
39/// band on each side) is used purely to *observe* Ambiguous frequency in this
40/// stage-1, observe-only rollout; the actual retained rank still comes from the
41/// unchanged threshold count.
42pub(crate) const RANK_DECISION_GAP: f64 = 1.0;
43
44/// Maps a coefficient perturbation `δβ ∈ R^p` for one parameter block into
45/// its contribution to the per-row primary state `u_i ∈ R^K`.
46///
47/// For affine blocks (everything in this compiler), `J_i = ∂u_i/∂β_block` is
48/// independent of `β` and equals the transposed row of the block's effective
49/// design matrix lifted into `R^K`.
50pub trait RowJacobianOperator: Send + Sync {
51    /// Dimension of the row primary state (survival marginal-slope: `3 + K`
52    /// for `K` score coordinates; Bernoulli: 1).
53    fn k(&self) -> usize;
54
55    /// Number of coefficients in this block (= width of `J_i`).
56    fn ncols(&self) -> usize;
57
58    /// Number of training rows.
59    fn nrows(&self) -> usize;
60
61    /// Apply the row Jacobian: writes `J_i · δβ ∈ R^K` for `row` into `out`.
62    fn apply_row(&self, row: usize, delta_beta: &[f64], out: &mut [f64]);
63
64    /// Materialise the full operator as an `(n_rows × ncols × K)` tensor.
65    fn evaluate_full(&self) -> Array3<f64>;
66
67    /// Build the sqrt(H)-scaled design `W = stack_i sqrt(H_i) · J_i`, flattened
68    /// channel-major to `(n_rows·K × ncols)`.
69    ///
70    /// This is the representation the identifiability *compiler*
71    /// ([`compile_with_dual_metric`]) actually consumes — it residualises and
72    /// eigendecomposes Grams of `W`, and never indexes the per-row `(n, p, K)`
73    /// tensor element-wise. Requesting the scaled design directly lets an
74    /// operator with a structured / streaming form supply it without
75    /// materialising and cloning the whole `O(n·p·K)` tensor; the default
76    /// implementation routes through [`evaluate_full`] so existing operators
77    /// remain correct unchanged. (#738: a capability is not a representation —
78    /// the compiler asks for the scaled design it needs, not the dense tensor.)
79    ///
80    /// [`evaluate_full`]: RowJacobianOperator::evaluate_full
81    /// [`compile_with_dual_metric`]: crate::families::compiler::compile_with_dual_metric
82    fn scaled_design_by_sqrt_h(&self, h_full: &Array3<f64>) -> Array2<f64> {
83        scale_block_by_sqrt_h(&self.evaluate_full(), h_full)
84    }
85
86    /// Write the channel-flattened column `col` — the `(n_rows · K)` vector
87    /// whose entry `i·K + ch` is `J[i, col, ch]` — into `out`.
88    ///
89    /// This is the representation the identifiability *audit* actually consumes
90    /// (per-column leverage statistics and pairwise overlaps), as opposed to the
91    /// dense `(n, p, K)` tensor. Requesting a column directly lets an operator
92    /// that has a structured / streaming form supply it without materialising
93    /// and cloning the whole `O(n·p·K)` tensor on every audit pass; the default
94    /// implementation routes through [`evaluate_full`] so existing operators
95    /// remain correct unchanged. (#738: a capability is not a representation —
96    /// the audit asks for the column view it needs, not the tensor.)
97    ///
98    /// [`evaluate_full`]: RowJacobianOperator::evaluate_full
99    fn channel_flattened_column(&self, col: usize, out: &mut [f64]) {
100        let k = self.k();
101        let n = self.nrows();
102        assert!(
103            col < self.ncols(),
104            "channel_flattened_column col {col} out of range {}",
105            self.ncols()
106        );
107        assert_eq!(
108            out.len(),
109            n * k,
110            "channel_flattened_column out length {} != n*k = {}*{}",
111            out.len(),
112            n,
113            k
114        );
115        let full = self.evaluate_full();
116        for i in 0..n {
117            for ch in 0..k {
118                out[i * k + ch] = full[[i, col, ch]];
119            }
120        }
121    }
122
123    /// Write channel-flattened rows for `rows` into `out`.
124    ///
125    /// `out` has shape `(rows.len() * K, ncols)`, with row
126    /// `local_row * K + channel` holding `J[row, :, channel]`. The default
127    /// implementation materialises the full tensor for legacy operators; large
128    /// construction-time adapters override this to stream row chunks.
129    fn channel_flattened_rows(&self, rows: Range<usize>, out: &mut Array2<f64>) {
130        let n = self.nrows();
131        let start = rows.start.min(n);
132        let end = rows.end.min(n);
133        let chunk = end - start;
134        let k = self.k();
135        let p = self.ncols();
136        assert_eq!(out.shape(), &[chunk * k, p]);
137        let full = self.evaluate_full();
138        for local_i in 0..chunk {
139            let row = start + local_i;
140            for ch in 0..k {
141                for col in 0..p {
142                    out[[local_i * k + ch, col]] = full[[row, col, ch]];
143                }
144            }
145        }
146    }
147}
148
149/// Per-row `K × K` PSD Hessian of `−log L_i(u_i)` evaluated at a pilot β.
150pub trait RowHessian: Send + Sync {
151    fn k(&self) -> usize;
152    fn nrows(&self) -> usize;
153    /// Fill the `K × K` block at `row` into `out` (row-major).
154    fn fill_row(&self, row: usize, out: &mut [f64]);
155    /// Materialise full `(n_rows × K × K)` tensor.
156    fn evaluate_full(&self) -> Array3<f64>;
157}
158
159/// Identity row metric: `K^S_i = I_K` for every row. Default structural
160/// metric for [`compile_with_dual_metric`]. Decoupling the
161/// "which directions are real structural columns" decision from a
162/// possibly rank-deficient pilot curvature `H` prevents the compiler from
163/// wrongly dropping columns whose curvature happens to be zero at the
164/// pilot β but which would be kept at the optimum.
165pub struct IdentityRowHessian {
166    n: usize,
167    k: usize,
168}
169
170impl IdentityRowHessian {
171    /// Construct an identity row metric with `n` rows and `K`-channel
172    /// row primary state.
173    pub fn new(n: usize, k: usize) -> Self {
174        Self { n, k }
175    }
176}
177
178impl RowHessian for IdentityRowHessian {
179    fn k(&self) -> usize {
180        self.k
181    }
182    fn nrows(&self) -> usize {
183        self.n
184    }
185    fn fill_row(&self, row: usize, out: &mut [f64]) {
186        assert!(
187            row < self.n,
188            "IdentityRowHessian::fill_row row {row} out of range {n}",
189            n = self.n
190        );
191        assert_eq!(out.len(), self.k * self.k);
192        for i in 0..self.k {
193            for j in 0..self.k {
194                out[i * self.k + j] = if i == j { 1.0 } else { 0.0 };
195            }
196        }
197    }
198    fn evaluate_full(&self) -> Array3<f64> {
199        let mut out = Array3::<f64>::zeros((self.n, self.k, self.k));
200        for i in 0..self.n {
201            for c in 0..self.k {
202                out[[i, c, c]] = 1.0;
203            }
204        }
205        out
206    }
207}
208
209/// One compiled block: reparam matrix `V` (`t_lw`) and the optional anchor
210/// correction matrix `M` that downstream blocks consume as a first-class
211/// anchor.
212pub struct CompiledBlock {
213    /// Orthogonal-complement reparam matrix `V ∈ R^{p × p'}` (right-selector).
214    pub t_lw: Array2<f64>,
215    /// Residualised anchor correction `M ∈ R^{d_raw × p'}` at the compiled
216    /// width, expressed in *raw* cumulative-anchor-column coordinates: `d_raw`
217    /// is the sum of the raw column counts of every prior block, NOT the
218    /// (possibly smaller) count of kept anchor directions. The predict-time
219    /// row contribution is `(C(x)·V − A_raw(x)·M)·β`, where `A_raw(x)` is the
220    /// raw anchor evaluation. `None` for the first block in the ordering.
221    /// Synonymous with `r_lw`.
222    pub anchor_correction: Option<Array2<f64>>,
223    /// Residualised reparam `R_b = M_b · V_b` — what the residualised row
224    /// evaluator uses to subtract the anchor portion. `None` for the first
225    /// block in the ordering (no anchor). Equal to `anchor_correction`.
226    pub r_lw: Option<Array2<f64>>,
227}
228
229/// Output of [`compile`]: one [`CompiledBlock`] per input block plus the
230/// joint pre-fit audit verdict.
231pub struct CompiledBlocks {
232    pub blocks: Vec<CompiledBlock>,
233    /// Joint rank reported by the post-walk column-pivoted QR audit.
234    pub joint_rank: usize,
235    /// Columns deterministically dropped by the audit, as
236    /// `(block_idx, local_col)`. The audit drops only from the latest block.
237    pub dropped: Vec<(usize, usize)>,
238}
239
240/// Structural relationship between one raw penalized block and the higher-priority
241/// anchor already accepted by the identifiability compiler.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum PenalizedDirectionAnnotationKind {
244    /// The block kept its full realized-design span; none of its penalized
245    /// directions were already represented by a higher-priority block.
246    Independent,
247    /// Some, but not all, raw directions were absorbed by the higher-priority
248    /// anchor. The kept width is the independent residual span.
249    PartiallyAbsorbedByHigherPriority,
250    /// The entire block was the same realized-design direction/span as the
251    /// higher-priority anchor and therefore contributes no independent
252    /// coefficients or smoothing parameter directions.
253    FullyAbsorbedByHigherPriority,
254}
255
256/// Per-block structural annotation emitted by [`orthogonalize_design_blocks`].
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct PenalizedDirectionAnnotation {
259    pub block_idx: usize,
260    pub raw_width: usize,
261    pub kept_width: usize,
262    pub absorbed_width: usize,
263    pub kind: PenalizedDirectionAnnotationKind,
264}
265
266/// Errors raised by [`compile`].
267#[derive(Debug)]
268pub enum CompilerError {
269    /// Operator/Hessian/ordering dimensions are inconsistent.
270    DimensionMismatch(String),
271    /// A supplied row metric is not a finite positive-semidefinite weight.
272    InvalidMetric(String),
273    /// A block degenerated to zero residual span — fully aliased by the
274    /// cumulative anchor in the row metric.
275    FullyAliased { block_idx: usize, reason: String },
276    /// A linear-algebra step failed (Gram solve, eigendecomposition, QR).
277    LinalgFailure(String),
278    /// CUDA was configured for this compile, but probing the runtime failed.
279    GpuFailure(String),
280}
281
282impl std::fmt::Display for CompilerError {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            CompilerError::DimensionMismatch(msg) => write!(f, "dimension mismatch: {msg}"),
286            CompilerError::InvalidMetric(msg) => write!(f, "invalid row metric: {msg}"),
287            CompilerError::FullyAliased { block_idx, reason } => {
288                write!(f, "block {block_idx} fully aliased: {reason}")
289            }
290            CompilerError::LinalgFailure(msg) => write!(f, "linalg failure: {msg}"),
291            CompilerError::GpuFailure(msg) => write!(f, "GPU failure: {msg}"),
292        }
293    }
294}
295
296impl std::error::Error for CompilerError {}
297
298/// Semantic block label. The compiler does not need to know what the block
299/// *is*, only its relative order — but downstream consumers (per-family
300/// install paths) tag the input operators with these labels so that the
301/// compiled output can be routed back to the right runtime slot.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum BlockOrder {
304    Time,
305    Marginal,
306    Logslope,
307    ScoreWarp,
308    LinkDev,
309}
310
311/// Compile a sequence of row-Jacobian operators against a shared row
312/// Hessian. Walks `ordering` left-to-right, residualising each block
313/// against the cumulative anchor in the `H_i`-weighted row metric, then
314/// performs a joint-design audit and emits one [`CompiledBlock`] per
315/// input (in the same order as `operators`).
316///
317/// `ordering` parallels `operators` and supplies the semantic label for
318/// each block. The compiler treats `ordering[i]` purely as metadata —
319/// the *position* `i` is the residualisation order.
320pub fn compile(
321    operators: &[Arc<dyn RowJacobianOperator>],
322    row_hess: &dyn RowHessian,
323    ordering: &[BlockOrder],
324) -> Result<CompiledBlocks, CompilerError> {
325    compile_protected(operators, row_hess, ordering, &[])
326}
327
328/// Variant of [`compile`] that keeps designated blocks at full raw width.
329///
330/// `protected[b] == true` forces block `b` to retain every raw column,
331/// suppressing both the structural and curvature eigenspace drops for that
332/// block while still using it as a full-width anchor for later blocks. See
333/// [`compile_from_raw_grams_protected`] for the motivation: a block whose
334/// effective Jacobian is a fixed nonlinear functional basis (e.g. the survival
335/// marginal-slope time-wiggle block) cannot be expressed on a linearly reduced
336/// design, so it must not be reparameterised/dropped. `protected` may be
337/// shorter than `ordering`; an empty slice reproduces [`compile`] exactly.
338pub fn compile_protected(
339    operators: &[Arc<dyn RowJacobianOperator>],
340    row_hess: &dyn RowHessian,
341    ordering: &[BlockOrder],
342    protected: &[bool],
343) -> Result<CompiledBlocks, CompilerError> {
344    // Default structural metric is the per-row identity `K^S_i = I_K`.
345    // A pilot-curvature `H` can collapse a direction (zero eigenvalue) at
346    // a bad β even though the optimum keeps that direction; routing the
347    // rank decision through the structural metric and reserving `H` for
348    // *within-kept-subspace* curvature handling prevents that mis-drop.
349    let n = row_hess.nrows();
350    let k = row_hess.k();
351    let id_struct = IdentityRowHessian::new(n, k);
352    compile_with_dual_metric_protected(operators, row_hess, &id_struct, ordering, protected)
353}
354
355/// Compile a sequence of row-Jacobian operators using *separate* metrics
356/// for structural rank decisions and curvature-aware orthogonalisation.
357///
358/// - `row_hess` is the curvature row metric `K^H_i` (a PSD-clamped Hessian
359///   of `−log L_i(u_i)` at a pilot β).
360/// - `row_structural` is the structural row metric `K^S_i` — typically an
361///   [`IdentityRowHessian`] — used only to decide which columns survive
362///   block-against-block residualisation. A direction that the curvature
363///   `K^H` happens to see as zero at a bad pilot β is *not* dropped here
364///   as long as it is structurally non-degenerate.
365///
366/// Per-block algorithm (left-to-right walk over `ordering`):
367///
368/// 1. Residualise the block in the structural metric against the
369///    cumulative structural anchor; eigendecompose the structural residual
370///    Gram and drop only structural-zero eigenvalues → kept basis `D`
371///    (raw-block selector).
372/// 2. Residualise `W^H_b · D` in the curvature metric against the
373///    cumulative curvature anchor → curvature anchor correction
374///    `M^H_inner` and residual `R^H`.
375/// 3. Eigendecompose the curvature Gram of `R^H` and drop curvature-zero
376///    directions (a *within*-structurally-kept curvature alias is a true
377///    redundancy) → rotation/selector `T_inner`.
378/// 4. Compose: `V = D · T_inner`; compiled anchor correction is
379///    `M^H_inner · T_inner` so the predict-time row contribution stays
380///    `(C(x) · V − A(x) · anchor_correction) · β`.
381///
382/// When `row_structural` and `row_hess` represent the same metric (e.g.
383/// `compile()` with an identity row Hessian on both sides), the two
384/// passes collapse to the single-metric loop.
385pub fn compile_with_dual_metric(
386    operators: &[Arc<dyn RowJacobianOperator>],
387    row_hess: &dyn RowHessian,
388    row_structural: &dyn RowHessian,
389    ordering: &[BlockOrder],
390) -> Result<CompiledBlocks, CompilerError> {
391    compile_with_dual_metric_protected(operators, row_hess, row_structural, ordering, &[])
392}
393
394/// Variant of [`compile_with_dual_metric`] that keeps designated blocks at full
395/// raw width (see [`compile_protected`] / [`compile_from_raw_grams_protected`]
396/// for the motivation). `protected[b] == true` replaces block `b`'s structural
397/// and curvature eigenspace drops with identity, so the block emerges at full
398/// raw width while still anchoring later blocks. `protected` may be shorter
399/// than `ordering`; an empty slice reproduces [`compile_with_dual_metric`].
400pub fn compile_with_dual_metric_protected(
401    operators: &[Arc<dyn RowJacobianOperator>],
402    row_hess: &dyn RowHessian,
403    row_structural: &dyn RowHessian,
404    ordering: &[BlockOrder],
405    protected: &[bool],
406) -> Result<CompiledBlocks, CompilerError> {
407    if operators.len() != ordering.len() {
408        return Err(CompilerError::DimensionMismatch(format!(
409            "operators ({}) and ordering ({}) length mismatch",
410            operators.len(),
411            ordering.len()
412        )));
413    }
414    if operators.is_empty() {
415        return Ok(CompiledBlocks {
416            blocks: Vec::new(),
417            joint_rank: 0,
418            dropped: Vec::new(),
419        });
420    }
421
422    let k = row_hess.k();
423    let n = row_hess.nrows();
424    if row_structural.k() != k {
425        return Err(CompilerError::DimensionMismatch(format!(
426            "structural row metric has K={} but curvature row Hessian has K={k}",
427            row_structural.k()
428        )));
429    }
430    if row_structural.nrows() != n {
431        return Err(CompilerError::DimensionMismatch(format!(
432            "structural row metric has nrows={} but curvature row Hessian has nrows={n}",
433            row_structural.nrows()
434        )));
435    }
436    for (idx, op) in operators.iter().enumerate() {
437        if op.k() != k {
438            return Err(CompilerError::DimensionMismatch(format!(
439                "operator {idx} has K={} but row Hessian has K={k}",
440                op.k()
441            )));
442        }
443        if op.nrows() != n {
444            return Err(CompilerError::DimensionMismatch(format!(
445                "operator {idx} has nrows={} but row Hessian has nrows={n}",
446                op.nrows()
447            )));
448        }
449    }
450
451    // Materialise once per metric. K is tiny (1 or 4) so the K×K
452    // symmetric-sqrt cost is dominated by the joint-design audit below.
453    let h_full = row_hess.evaluate_full();
454    let s_full = row_structural.evaluate_full();
455
456    // Request each block's sqrt(H)-scaled design directly through the intent
457    // accessor — the `(n·K, p)` representation the compiler actually consumes —
458    // instead of first materialising the dense `(n, p, K)` per-row tensor and
459    // scaling it. The default `scaled_design_by_sqrt_h` impl still routes
460    // through `evaluate_full()`, so operators without a structured form stay
461    // correct unchanged; a streaming operator (e.g. `BlockJacobianAsRowOp`)
462    // overrides it to scale straight out of its stored layout, dropping the
463    // `O(n·p·K)` tensor clone that `evaluate_full()` performs per block at
464    // large-scale `n`. (#738: a capability is not a representation — the compiler
465    // asks for the scaled design it needs, never the dense tensor.)
466    let scaled_h: Vec<Array2<f64>> = operators
467        .iter()
468        .map(|op| op.scaled_design_by_sqrt_h(&h_full))
469        .collect();
470    let scaled_s: Vec<Array2<f64>> = operators
471        .iter()
472        .map(|op| op.scaled_design_by_sqrt_h(&s_full))
473        .collect();
474
475    let mut compiled: Vec<CompiledBlock> = Vec::with_capacity(operators.len());
476    // Demotions that happen *inside* the per-block walk (a structurally-kept
477    // block losing all its directions to a higher-priority anchor in the
478    // structural or curvature pass) are recorded here, one entry per demoted
479    // raw column, in the same `(block_idx, local_col)` convention that
480    // `audit_and_drop_trailing_pivots` emits at the joint-audit step. Without
481    // this, a zero-width demotion vanished from `dropped`, breaking the
482    // `kept_width + dropped_count == structural_pre_audit_width` accounting.
483    let mut walk_demotions: Vec<(usize, usize)> = Vec::new();
484    let mut anchor_h: Array2<f64> = Array2::zeros((n * k, 0));
485    let mut anchor_s: Array2<f64> = Array2::zeros((n * k, 0));
486    // Cumulative *raw* (un-residualised) curvature-scaled anchor: the
487    // horizontal stack of `sqrt(H)·J_b` for every block already walked,
488    // keeping one column per raw block column. Where `anchor_h` carries the
489    // residualised, kept-direction anchor (its width shrinks whenever a block
490    // sheds an aliased column), this matrix keeps the full raw column count so
491    // the emitted `anchor_correction` can be expressed in raw-anchor-column
492    // coordinates — exactly the basis the predict-time subtraction
493    // `A_raw(x)·M` evaluates against. See the `M_raw` derivation below.
494    let mut raw_anchor_h: Array2<f64> = Array2::zeros((n * k, 0));
495
496    for idx in 0..operators.len() {
497        let w_h = &scaled_h[idx];
498        let w_s = &scaled_s[idx];
499        let p_b = w_h.ncols();
500        let block_protected = protected.get(idx).copied().unwrap_or(false);
501
502        // A zero-width block owns no raw columns, so it cannot alias against any
503        // anchor and is trivially identifiable. Emit an empty compiled block and
504        // skip the structural/curvature passes: their residual Grams are 0×0 and
505        // yield no positive eigenspace, which the `anchor_h.ncols() == 0`
506        // first-block guards below would otherwise mis-report as `FullyAliased`
507        // even though there is nothing to alias. This mirrors the empty block a
508        // fully-absorbed later block compiles to, with no demotions to record
509        // (there are no columns) and no change to the running anchors.
510        if p_b == 0 {
511            compiled.push(CompiledBlock {
512                t_lw: Array2::<f64>::zeros((0, 0)),
513                anchor_correction: Some(Array2::<f64>::zeros((raw_anchor_h.ncols(), 0))),
514                r_lw: Some(Array2::<f64>::zeros((raw_anchor_h.ncols(), 0))),
515            });
516            continue;
517        }
518
519        // Pass 1 (structural): residualise W^S_b against cumulative
520        // structural anchor; eigendecompose the structural residual Gram
521        // and keep only directions with non-zero structural mass → D
522        // (raw-block selector).
523        // Only the structural residual is consumed downstream; the
524        // structural-metric correction M^S is intentionally discarded —
525        // predict-time subtraction uses the curvature metric correction
526        // (`M^H_inner` below), not the structural one.
527        let (residual_s, _) = residualise_in_metric(&anchor_s, w_s)?;
528        let g_s = fast_atb(&residual_s, &residual_s);
529        // Scale reference for the kept-eigenspace tolerance: the *original*
530        // (pre-residualisation) structural block Gram trace. A fully-absorbed
531        // block's residual collapses to ~ε² noise; anchoring tau to that would
532        // keep the noise directions and wrongly treat the block as
533        // structurally independent. The original-block trace is invariant to
534        // absorption, so a near-zero residual is rejected as fully absorbed.
535        let g_s_bb = fast_atb(w_s, w_s);
536        let g_s_trace: f64 = (0..p_b).map(|i| g_s_bb[[i, i]].max(0.0)).sum();
537        // A protected block keeps every raw column: the structural residual
538        // eigenfilter is replaced by identity so no within-block direction is
539        // dropped. It still anchors later blocks at full raw width.
540        let d = if block_protected {
541            Array2::<f64>::eye(p_b)
542        } else {
543            keep_positive_eigenspace(&g_s, n, k, g_s_trace)?
544        };
545        if d.ncols() == 0 {
546            if anchor_h.ncols() == 0 {
547                return Err(CompilerError::FullyAliased {
548                    block_idx: idx,
549                    reason: format!(
550                        "structural residual Gram has no positive eigenspace (block of width {p_b} has zero structural span before any anchor exists)"
551                    ),
552                });
553            }
554            compiled.push(CompiledBlock {
555                t_lw: Array2::<f64>::zeros((p_b, 0)),
556                anchor_correction: Some(Array2::<f64>::zeros((raw_anchor_h.ncols(), 0))),
557                r_lw: Some(Array2::<f64>::zeros((raw_anchor_h.ncols(), 0))),
558            });
559            // The structural pass fully absorbed all `p_b` raw columns into the
560            // higher-priority anchor: record each as a drop so the per-block
561            // width accounting (kept + dropped == raw width) stays exact.
562            for c in 0..p_b {
563                walk_demotions.push((idx, c));
564            }
565            raw_anchor_h = concat_cols(&raw_anchor_h, w_h);
566            continue;
567        }
568
569        // Pass 2 (curvature): form W^H_b · D and residualise against the
570        // cumulative curvature anchor. Eigendecompose the curvature
571        // residual Gram and drop curvature-zero directions inside D →
572        // T_inner. A direction kept by the structural pass but degenerate
573        // here is genuinely curvature-redundant *within* the
574        // structurally-kept basis, so dropping it is correct.
575        let w_h_d = fast_ab(w_h, &d);
576        let (residual_h, m_h_inner_opt) = residualise_in_metric(&anchor_h, &w_h_d)?;
577        let g_h = fast_atb(&residual_h, &residual_h);
578        let p_d = d.ncols();
579        // Scale reference: the *unresidualised* curvature block Gram trace of
580        // `W^H_b · D` (the same convention the closed-form `compile_from_raw_grams`
581        // path uses with `d_t_kh_d`). Anchoring to the residual trace would
582        // collapse to ~ε² when the block is fully curvature-absorbed and keep
583        // its noise directions.
584        let g_h_dd = fast_atb(&w_h_d, &w_h_d);
585        let g_h_trace: f64 = (0..p_d).map(|i| g_h_dd[[i, i]].max(0.0)).sum();
586        // Protected block: retain every structurally-kept direction (identity
587        // curvature span) instead of dropping curvature-degenerate ones; its own
588        // penalty nullspace regularises the conditioning downstream.
589        let t_inner = if block_protected {
590            Array2::<f64>::eye(p_d)
591        } else {
592            keep_positive_eigenspace(&g_h, n, k, g_h_trace)?
593        };
594        if t_inner.ncols() == 0 {
595            if anchor_h.ncols() == 0 {
596                return Err(CompilerError::FullyAliased {
597                    block_idx: idx,
598                    reason: format!(
599                        "curvature residual Gram has no positive eigenspace within structurally-kept basis (block of width {p_b}, structural-kept {p_d}) before any anchor exists"
600                    ),
601                });
602            }
603            compiled.push(CompiledBlock {
604                t_lw: Array2::<f64>::zeros((p_b, 0)),
605                anchor_correction: Some(Array2::<f64>::zeros((raw_anchor_h.ncols(), 0))),
606                r_lw: Some(Array2::<f64>::zeros((raw_anchor_h.ncols(), 0))),
607            });
608            // The structural pass kept `p_d` directions, but the curvature pass
609            // absorbed all of them into the higher-priority anchor. Record each
610            // structurally-kept-but-curvature-demoted direction as a drop so the
611            // pre-audit structural width is fully accounted for.
612            for c in 0..p_d {
613                walk_demotions.push((idx, c));
614            }
615            raw_anchor_h = concat_cols(&raw_anchor_h, w_h);
616            continue;
617        }
618
619        // Compose V = D · T_inner (raw-block → kept).
620        let v = fast_ab(&d, &t_inner);
621
622        // `m_h_inner_opt` was residualised against `anchor_h` as it stands
623        // *here*, i.e. the cumulative kept-direction anchor of all PRIOR
624        // blocks. Snapshot that pre-append anchor and its raw counterpart
625        // before this block's residual columns are appended below; the
626        // change-of-basis for this block's correction must be expressed
627        // against the prior-block anchor that `m` is indexed against, not the
628        // post-append anchor that already carries this block's own columns.
629        let prior_anchor_h = anchor_h.clone();
630        let prior_raw_anchor_h = raw_anchor_h.clone();
631
632        // Append residual-V columns to both cumulative anchors so future
633        // blocks see the structurally-orthogonal and curvature-orthogonal
634        // residual designs of this block, never the raw scaled block.
635        let residual_h_t = fast_ab(&residual_h, &t_inner);
636        anchor_h = concat_cols(&anchor_h, &residual_h_t);
637        // The structural anchor needs the structural-residual restricted
638        // to the kept directions: residual_s · v gives (W^S_b − A^S · M^S)·V.
639        let residual_s_v = fast_ab(&residual_s, &v);
640        anchor_s = concat_cols(&anchor_s, &residual_s_v);
641
642        // Compiled anchor correction lives in the curvature metric — the
643        // predict-time row contribution is `(C(x) · V − A(x) · M)·β`, where
644        // the subtraction makes residuals H-orthogonal at training and `A(x)`
645        // is the *raw* anchor evaluation (one column per raw anchor column).
646        //
647        // `m_h_inner_opt · t_inner` (call it `M_kept`) lives in the
648        // *kept-direction* anchor coordinates of the PRIOR-block anchor
649        // `prior_anchor_h` (the value `anchor_h` held when `m` was produced at
650        // `residualise_in_metric` above, before this block's residual columns
651        // were appended). Its row count is `prior_anchor_h.ncols()`, which
652        // equals the prior-block raw anchor width only when no upstream block
653        // shed an aliased column. The predict path multiplies by the raw
654        // anchor matrix `A_raw` (one column per raw anchor column of the prior
655        // blocks), so we must re-express `M_kept` in raw-anchor-column
656        // coordinates.
657        //
658        // `prior_anchor_h` and `prior_raw_anchor_h` span the same column space
659        // in the curvature metric (the residualisation/rotation only drops
660        // directions that lie inside that span), so there is an exact `Z` with
661        // `prior_raw_anchor_h · Z = prior_anchor_h`. Then
662        //   `prior_anchor_h · M_kept = prior_raw_anchor_h · (Z · M_kept)`,
663        // and the raw-coordinate correction is `M_raw = Z · M_kept`, with row
664        // count `prior_raw_anchor_h.ncols()` = the sum of prior raw anchor
665        // block widths. `Z = (Aᵀ A)⁺ Aᵀ prior_anchor_h` (with
666        // `A = prior_raw_anchor_h`) is the metric-exact least-squares change of
667        // basis (`solve_psd_system`).
668        let m_compiled = match m_h_inner_opt.as_ref() {
669            Some(m) => {
670                let m_kept = fast_ab(m, &t_inner);
671                if m_kept.nrows() != prior_anchor_h.ncols() {
672                    return Err(CompilerError::DimensionMismatch(format!(
673                        "anchor correction must be indexed by prior-block kept anchor directions: \
674                         m_kept has {} rows but prior_anchor_h has {} columns",
675                        m_kept.nrows(),
676                        prior_anchor_h.ncols()
677                    )));
678                }
679                let g_raw = fast_atb(&prior_raw_anchor_h, &prior_raw_anchor_h);
680                let z_rhs = fast_atb(&prior_raw_anchor_h, &prior_anchor_h);
681                let z = solve_psd_system(&g_raw, &z_rhs)?;
682                Some(fast_ab(&z, &m_kept))
683            }
684            None => None,
685        };
686        compiled.push(CompiledBlock {
687            t_lw: v,
688            anchor_correction: m_compiled.clone(),
689            r_lw: m_compiled,
690        });
691
692        // Append this block's raw curvature-scaled columns to the raw anchor
693        // accumulator so the *next* block's `M_raw` is expressed against the
694        // full raw column set of all blocks walked so far.
695        raw_anchor_h = concat_cols(&raw_anchor_h, w_h);
696    }
697
698    // Joint-design audit on the curvature-scaled cumulative anchor: the
699    // identifiability question the fit cares about is curvature-rank.
700    let audit_dropped = audit_and_drop_trailing_pivots(&anchor_h, &mut compiled)?;
701    // Combine in-walk demotions (structural / curvature full absorption of a
702    // block) with the joint-audit trailing-pivot drops so `dropped` accounts
703    // for *every* column the compiler removed, not just the joint-audit ones.
704    let mut dropped = walk_demotions;
705    dropped.extend(audit_dropped);
706    let joint_rank: usize = compiled.iter().map(|b| b.t_lw.ncols()).sum();
707
708    Ok(CompiledBlocks {
709        blocks: compiled,
710        joint_rank,
711        dropped,
712    })
713}
714
715/// Build `W_b = stack_i sqrt(H_i) · J_b,i` flattened to `(n*K, ncols)` from a
716/// materialised `(n, p, K)` tensor. Thin wrapper over
717/// [`scale_jacobian_by_sqrt_h_with`] that reads the tensor element-wise.
718fn scale_block_by_sqrt_h(jb: &Array3<f64>, h_full: &Array3<f64>) -> Array2<f64> {
719    let n = jb.shape()[0];
720    let p = jb.shape()[1];
721    let k = jb.shape()[2];
722    scale_jacobian_by_sqrt_h_with(n, p, k, h_full, |i, a, c| jb[[i, a, c]])
723}
724
725/// Build `W_b = stack_i sqrt(H_i) · J_b,i` flattened to `(n*K, ncols)` without
726/// ever requiring a materialised `(n, p, K)` tensor.
727///
728/// The Jacobian entries are pulled through the `jac` closure
729/// (`jac(i, a, c) = J_b,i[a, c]`), so a structured operator that stores its
730/// Jacobian in a compact / streaming form can supply the sqrt(H)-scaled design
731/// directly — the representation the compiler actually consumes — rather than
732/// being forced to clone a dense `(n, p, K)` tensor first. (#738: a capability
733/// is not a representation — the compiler asks for the scaled `(n·K, p)` design
734/// it needs, not the dense per-row tensor.)
735///
736/// `K` is tiny (1 or 4), so the per-row symmetric sqrt is negligible relative
737/// to the overall compile.
738pub fn scale_jacobian_by_sqrt_h_with(
739    n: usize,
740    p: usize,
741    k: usize,
742    h_full: &Array3<f64>,
743    jac: impl Fn(usize, usize, usize) -> f64,
744) -> Array2<f64> {
745    assert_eq!(h_full.shape(), &[n, k, k]);
746    let mut out = Array2::<f64>::zeros((n * k, p));
747    let mut sqrt_h = Array2::<f64>::zeros((k, k));
748    let mut scratch_jrow = Array2::<f64>::zeros((p, k));
749    for i in 0..n {
750        // Symmetric square root of H_i via eigendecomposition.
751        let h_i = h_full.index_axis(Axis(0), i).to_owned();
752        sqrt_h.fill(0.0);
753        symmetric_sqrt_into(&h_i, &mut sqrt_h);
754        // scratch_jrow[a, c] = J_b,i[a, c] (transpose-friendly layout for
755        // the GEMV below: we want (p × k) · (k,) = (p,) for each column of
756        // sqrt_h, but we batch by writing out[(i*k+c), a] = (sqrt_h · J_b,iᵀ)[c, a].
757        for a in 0..p {
758            for c in 0..k {
759                scratch_jrow[[a, c]] = jac(i, a, c);
760            }
761        }
762        for c in 0..k {
763            for a in 0..p {
764                let mut acc = 0.0;
765                for cp in 0..k {
766                    acc += sqrt_h[[c, cp]] * scratch_jrow[[a, cp]];
767                }
768                out[[i * k + c, a]] = acc;
769            }
770        }
771    }
772    out
773}
774
775/// Symmetric matrix square root via eigendecomposition with negative
776/// eigenvalues clamped to zero (PSD projection guard).
777pub(crate) fn symmetric_sqrt_into(m: &Array2<f64>, out: &mut Array2<f64>) {
778    let k = m.nrows();
779    assert_eq!(m.ncols(), k);
780    assert_eq!(out.shape(), &[k, k]);
781    if k == 1 {
782        out[[0, 0]] = m[[0, 0]].max(0.0).sqrt();
783        return;
784    }
785    let (evals, evecs) = match m.eigh(Side::Lower) {
786        Ok(pair) => pair,
787        Err(_) => {
788            // Fall back to clipped diagonal — extremely defensive for the
789            // K=4 row Hessian which is already PSD-clamped by the caller.
790            out.fill(0.0);
791            for i in 0..k {
792                out[[i, i]] = m[[i, i]].max(0.0).sqrt();
793            }
794            return;
795        }
796    };
797    // out = U · diag(sqrt(max(0, λ))) · Uᵀ
798    let mut scaled = evecs.clone();
799    for j in 0..k {
800        let s = evals[j].max(0.0).sqrt();
801        for i in 0..k {
802            scaled[[i, j]] *= s;
803        }
804    }
805    out.assign(&fast_atb(&evecs.t().to_owned(), &scaled.t().to_owned()));
806    // The above fast_atb computed (Uᵀ)ᵀ · (Uᵀ·diag(s)) = U · diag(s) · Uᵀ
807    // when the inputs are owned. To be safe and avoid layout surprises,
808    // re-do the small multiplication explicitly for K ≤ 4.
809    out.fill(0.0);
810    for i in 0..k {
811        for j in 0..k {
812            let mut acc = 0.0;
813            for l in 0..k {
814                acc += evecs[[i, l]] * evals[l].max(0.0).sqrt() * evecs[[j, l]];
815            }
816            out[[i, j]] = acc;
817        }
818    }
819}
820
821/// Solve `Aᵀ A · M = Aᵀ B` and return `(B − A·M, Some(M))`. With `A`
822/// having zero columns, returns `(B, None)` — the first block needs no
823/// anchor correction.
824fn residualise_in_metric(
825    a_scaled: &Array2<f64>,
826    b_scaled: &Array2<f64>,
827) -> Result<(Array2<f64>, Option<Array2<f64>>), CompilerError> {
828    let d = a_scaled.ncols();
829    if d == 0 {
830        return Ok((b_scaled.clone(), None));
831    }
832    let g_aa = fast_atb(a_scaled, a_scaled);
833    let g_ab = fast_atb(a_scaled, b_scaled);
834    let m = solve_psd_system(&g_aa, &g_ab)?;
835    let a_m = fast_ab(a_scaled, &m);
836    let residual = b_scaled - &a_m;
837    Ok((residual, Some(m)))
838}
839
840/// Solve a PSD linear system `G · M = R` for `M`. Tries the eigen-based
841/// pseudoinverse with a relative threshold and falls back to a damped
842/// solve if the spectrum is ill-conditioned beyond what the threshold
843/// can clean.
844fn solve_psd_system(g: &Array2<f64>, r: &Array2<f64>) -> Result<Array2<f64>, CompilerError> {
845    let n = g.nrows();
846    if n == 0 {
847        return Ok(Array2::zeros((0, r.ncols())));
848    }
849    let (evals, evecs) = g
850        .eigh(Side::Lower)
851        .map_err(|err| CompilerError::LinalgFailure(format!("Gram eigh failed: {err:?}")))?;
852    let lambda_max = evals.iter().cloned().fold(0.0_f64, f64::max).max(0.0);
853    let tol = lambda_max * RANK_REVEAL_EPS_SLACK * (n.max(1) as f64) * f64::EPSILON;
854    // M = U · diag(1/λ_kept) · Uᵀ · R
855    let u_t_r = fast_atb(&evecs, r);
856    let mut scaled = u_t_r.clone();
857    for i in 0..n {
858        let lam = evals[i];
859        let inv = if lam > tol { 1.0 / lam } else { 0.0 };
860        for j in 0..scaled.ncols() {
861            scaled[[i, j]] *= inv;
862        }
863    }
864    let m = fast_ab(&evecs, &scaled);
865    Ok(m)
866}
867
868/// Eigendecompose the residual Gram `G̃` and return `V` made of the
869/// eigenvectors whose eigenvalues exceed
870/// `τ = max(λ_max(G̃), tr(G_BB)) · RANK_REVEAL_EPS_SLACK · n · K · ε`.
871fn keep_positive_eigenspace(
872    g_tilde: &Array2<f64>,
873    n: usize,
874    k: usize,
875    g_bb_trace: f64,
876) -> Result<Array2<f64>, CompilerError> {
877    let p = g_tilde.nrows();
878    if p == 0 {
879        return Ok(Array2::zeros((0, 0)));
880    }
881    // A block whose UNRESIDUALISED diagonal trace is zero owns no positive
882    // eigenspace (an all-zero residual Gram): rank 0.
883    if g_bb_trace <= 0.0 {
884        return Ok(Array2::zeros((p, 0)));
885    }
886    let (evals, evecs) = g_tilde.eigh(Side::Lower).map_err(|err| {
887        CompilerError::LinalgFailure(format!("residual Gram eigh failed: {err:?}"))
888    })?;
889
890    // WEIGHT-INVARIANT RANK COUNT. The rank tolerance is relative to the dominant
891    // eigenvalue, so a single stiff residual direction inflates it until
892    // well-conditioned independent directions are dropped. The marginal-slope
893    // effective Jacobian carries the per-row chain weight c_i = sqrt(1+(s·g_i)²);
894    // it produced a residual Gram spectrum σ² of [7.5e15, 1.3e3, …, 2.3e-3] — one
895    // stiff direction and eleven absolutely well-conditioned ones — and the
896    // lambda_max-relative cutoff dropped all eleven, reporting range_rank 1/12 on
897    // a fully identified time surface. Identifiability is invariant to a positive
898    // per-column scaling (a diagonal congruence D^{-1/2}·G·D^{-1/2} preserves rank
899    // and inertia), so take the rank COUNT from the diagonally-equilibrated
900    // residual Gram, whose cutoff sees true residual correlation rather than
901    // scale. Return the RAW eigenvectors (top-`rank` by descending raw
902    // eigenvalue), so blocks already ranked correctly are byte-identical — only
903    // stiff-direction-mislabeled blocks gain their true rank.
904    // Problem-size factor shared by the equilibrated rank cutoff and the raw
905    // absorption floor below, so the two stay in the same currency.
906    let nk = (n.saturating_mul(k)).max(p).max(1) as f64;
907
908    // BLOCK-LEVEL FULL ABSORPTION, decided in the RAW gauge before any
909    // equilibration. This is a different question from "which directions
910    // survive", and it is the only one a first-order tolerance can answer safely.
911    //
912    // Callers hand this function residual Grams built by different arithmetic.
913    // `orthogonalize_design_blocks` residualises the weighted DESIGN and then
914    // squares it, so an absorbed block's residual Gram is `O(ε²·tr(G_BB))`.
915    // `compile_from_raw_grams` instead forms a Schur complement of Grams,
916    // `G_bb − G_abᵀ·G_aa⁺·G_ab`, which is ONE cancellation between computed
917    // `O(tr)` quantities through a pseudo-inverse: an absorbed block leaves
918    // `O(κ(G_AA)·ε·tr(G_BB))`, first order in ε. A per-direction floor at that
919    // first-order level is NOT safe — the marginal-slope effective Jacobian's
920    // smallest genuine direction sits at `2.3e-3` of `7.5e15 ≈ 3e-19`, five
921    // orders below it, and `b58bd1909` records that this is exactly the route
922    // (`compile_from_raw_grams → keep_positive_eigenspace`) that regression
923    // travelled.
924    //
925    // But FULL absorption is a statement about the whole residual, not about one
926    // direction: for an exact alias `B = A·L` the Schur complement is
927    // identically zero in exact arithmetic, so EVERY eigenvalue is noise,
928    // `λ_max` included. Testing `λ_max` therefore separates the two regimes with
929    // no ambiguity — the stiff case keeps its block alive on `λ_max = 7.5e15`
930    // however small its other directions are, and cannot be touched by this
931    // branch. Partial absorption is left entirely to the equilibrated count
932    // below, which is where it belongs.
933    let lambda_max_raw = evals.iter().cloned().fold(0.0_f64, f64::max).max(0.0);
934    if lambda_max_raw <= g_bb_trace * RANK_REVEAL_EPS_SLACK * nk * f64::EPSILON {
935        return Ok(Array2::zeros((p, 0)));
936    }
937
938    let rank = {
939        // Diagonally equilibrate into the column-scale gauge (Sylvester's law of
940        // inertia: the congruence preserves rank), then take the count from the
941        // equilibrated spectrum. See `gam_linalg::decision::equilibrate_gram`.
942        let (g_eq, _) = equilibrate_gram(g_tilde);
943        let (evals_eq, _) = g_eq.eigh(Side::Lower).map_err(|err| {
944            CompilerError::LinalgFailure(format!("equilibrated residual Gram eigh failed: {err:?}"))
945        })?;
946        let lambda_max_eq = evals_eq.iter().cloned().fold(0.0_f64, f64::max).max(0.0);
947        let tau_eq = lambda_max_eq * RANK_REVEAL_EPS_SLACK * nk * f64::EPSILON;
948        // Threshold count the pipeline has always acted on: the decision we must
949        // preserve exactly.
950        let threshold_count = evals_eq.iter().filter(|&&e| e > tau_eq).count();
951        // Two-stage rollout (#2337 §9-step-6). STAGE 1 — OBSERVE ONLY: classify
952        // the same decision against a two-sided guard band. When the band is
953        // clean the certified rank equals `threshold_count` by construction (no
954        // eigenvalue lies in `(τ/(1+gap), τ·(1+gap))`, so `#{e ≥ high}` =
955        // `#{e > τ}`). When a value sits inside the band the decision is
956        // host-unstable; we do NOT refuse here — we log the payload so we can
957        // measure Ambiguous frequency before enforcing a refusal path in stage 2
958        // — and fall back to the preserved threshold count.
959        match certified_rank(evals_eq.as_slice().unwrap_or(&[]), tau_eq, RANK_DECISION_GAP) {
960            RankDecision::Certified { rank, .. } => rank,
961            RankDecision::Ambiguous {
962                rank_floor,
963                rank_ceil,
964                sigma_in_band,
965                tol,
966                gap,
967            } => {
968                log::warn!(
969                    "keep_positive_eigenspace: ambiguous equilibrated rank (observe-only, \
970                     #2337 stage 1): rank_floor={rank_floor}, rank_ceil={rank_ceil}, \
971                     sigma_in_band={sigma_in_band:.3e}, tol={tol:.3e}, gap={gap}, \
972                     falling back to threshold_count={threshold_count}"
973                );
974                threshold_count
975            }
976        }
977    };
978
979    // ABSORPTION FLOOR, in the RAW gauge. Equilibration is scale-invariant by
980    // construction — that is exactly why it fixes the stiff-direction case — and
981    // that same invariance makes it blind to a block that carries no residual at
982    // all. When block `b` is fully absorbed by a higher-priority anchor its
983    // residual Gram is pure roundoff, `O(ε²·tr(G_BB))` in EVERY direction;
984    // dividing each column by its own `√diag` turns that noise into a
985    // correlation matrix with unit diagonal and `O(1)` eigenvalues, so a cutoff
986    // relative to `λ_max(G_eq)` admits all of it and the block is reported
987    // `Independent`. This function's own contract already says the tolerance is
988    // relative to `max(λ_max(G̃), tr(G_BB))`; the `tr(G_BB)` half was what the
989    // equilibrated count dropped.
990    //
991    // Restore it as an absolute admission test rather than by inflating `τ_eq`:
992    // a direction is genuine only if its residual NORM clears the roundoff of
993    // the ORIGINAL block norm, i.e. `√(λ_raw / tr(G_BB)) > SLACK·n·K·ε`. The
994    // floor is therefore the SQUARE of the usual rank tolerance, because a Gram
995    // is the square of the residual it is built from. That separates the two
996    // regimes by orders of magnitude in both directions and does not re-open the
997    // stiff case: a fully absorbed block sits at `λ_raw/tr ≈ ε² ≈ 5e-32`, while
998    // the marginal-slope effective Jacobian's smallest GENUINE direction sat at
999    // `2.3e-3 / 7.5e15 ≈ 3e-19` — seven orders above this floor and seven below
1000    // where a raw λ_max-relative cutoff would have killed it.
1001    //
1002    // Blocks where the floor does not bind are untouched, so every already-ranked
1003    // block keeps its exact previous basis.
1004    let raw_absorption_floor = {
1005        let rel = RANK_REVEAL_EPS_SLACK * nk * f64::EPSILON;
1006        g_bb_trace * rel * rel
1007    };
1008
1009    // Top-`rank` RAW eigenvectors by descending raw eigenvalue (stable order),
1010    // restricted to those clearing the absorption floor.
1011    let mut kept: Vec<usize> = (0..p).collect();
1012    kept.sort_by(|&a, &b| {
1013        evals[b]
1014            .partial_cmp(&evals[a])
1015            .unwrap_or(std::cmp::Ordering::Equal)
1016    });
1017    kept.retain(|&i| evals[i] > raw_absorption_floor);
1018    kept.truncate(rank);
1019    let mut v = Array2::<f64>::zeros((p, kept.len()));
1020    for (out_col, &src_col) in kept.iter().enumerate() {
1021        for row in 0..p {
1022            v[[row, out_col]] = evecs[[row, src_col]];
1023        }
1024    }
1025    Ok(v)
1026}
1027
1028/// Concatenate two matrices column-wise. Both must have the same row count.
1029fn concat_cols(left: &Array2<f64>, right: &Array2<f64>) -> Array2<f64> {
1030    let nrows = left.nrows().max(right.nrows());
1031    let lc = left.ncols();
1032    let rc = right.ncols();
1033    let mut out = Array2::<f64>::zeros((nrows, lc + rc));
1034    if lc > 0 {
1035        out.slice_mut(s![.., ..lc]).assign(left);
1036    }
1037    if rc > 0 {
1038        out.slice_mut(s![.., lc..]).assign(right);
1039    }
1040    out
1041}
1042
1043/// Post-walk audit: column-pivoted QR on the cumulative scaled design.
1044/// If rank < p_total, deterministically drop trailing pivots from the
1045/// latest block's `V`. Earlier blocks are never modified.
1046fn audit_and_drop_trailing_pivots(
1047    w_joint: &Array2<f64>,
1048    compiled: &mut [CompiledBlock],
1049) -> Result<Vec<(usize, usize)>, CompilerError> {
1050    let p_total: usize = compiled.iter().map(|b| b.t_lw.ncols()).sum();
1051    if p_total == 0 || w_joint.nrows() == 0 {
1052        return Ok(Vec::new());
1053    }
1054
1055    // RRQR rank with the codebase's default α.
1056    let rrqr = rrqr_with_permutation(w_joint, default_rrqr_rank_alpha())
1057        .map_err(|err| CompilerError::LinalgFailure(format!("audit RRQR failed: {err:?}")))?;
1058    let rank = rrqr.rank;
1059    if rank >= p_total {
1060        return Ok(Vec::new());
1061    }
1062
1063    // Trailing pivots are the redundant columns. Attribute every demoted
1064    // global column to the *latest* block by truncating its V; earlier
1065    // blocks keep their full V. The demoted suffix is sorted only by
1066    // pivot order, but we drop deterministically: take the count of
1067    // demoted columns and truncate that many trailing columns of the
1068    // latest block.
1069    let drop_count = p_total - rank;
1070    let latest_idx = compiled.len() - 1;
1071    let latest = &mut compiled[latest_idx];
1072    let kept_local = latest.t_lw.ncols().saturating_sub(drop_count);
1073    let dropped_locals: Vec<(usize, usize)> = (kept_local..latest.t_lw.ncols())
1074        .map(|c| (latest_idx, c))
1075        .collect();
1076    // Truncate ALL kept-direction-indexed matrices in lockstep so the
1077    // shape contract (`anchor_correction: d_total × k_kept`, `r_lw:
1078    // d_total × k_kept`, `t_lw: p_raw × k_kept`) holds after the audit
1079    // drops trailing pivots. Forgetting these two left
1080    // `anchor_correction.ncols() == pre_truncation_k_kept` while
1081    // `t_lw.ncols() == post_truncation_k_kept`, surfaced downstream as
1082    // `cross-block identifiability: anchor_correction shape D×P does
1083    // not match expected d_total=D × k_kept=K`.
1084    latest.t_lw = latest.t_lw.slice(s![.., ..kept_local]).to_owned();
1085    if let Some(m) = latest.anchor_correction.as_ref() {
1086        latest.anchor_correction = Some(m.slice(s![.., ..kept_local]).to_owned());
1087    }
1088    if let Some(r) = latest.r_lw.as_ref() {
1089        latest.r_lw = Some(r.slice(s![.., ..kept_local]).to_owned());
1090    }
1091    Ok(dropped_locals)
1092}
1093
1094/// Channel-pair decomposition of every parameter block's row Jacobian.
1095///
1096/// For families with `K` primary-state channels (survival: K=4), each block
1097/// `b` contributes a (n × p_b) channel matrix `X_b^(c)` per channel `c` that
1098/// it touches. Blocks that do not contribute to a channel store `None` in
1099/// that slot. The closed-form Gram compiler consumes this view directly to
1100/// build the joint Gram `K^H` without ever materialising the full
1101/// `(n·K) × p_total` weighted design `W = sqrt(H) · J`.
1102pub struct PrimaryChannelBlocks {
1103    /// Outer index: block. Inner index: channel `c ∈ 0..K`. `None` means the
1104    /// block does not contribute to that channel.
1105    pub blocks: Vec<Vec<Option<Array2<f64>>>>,
1106}
1107
1108/// Closed-form Gram builder: `K^H[a, b] = Σ_{c,d} (X_a^(c))ᵀ · diag(h_{cd}) · X_b^(d)`.
1109///
1110/// Inputs:
1111/// - `channel_blocks`: per-block channel decomposition of the row Jacobian.
1112/// - `row_hess`: `(n × K × K)` per-row PSD Hessian (typically clamped to PSD
1113///   by the family upstream).
1114/// - `raw_block_ranges`: `[start, end)` column ranges of each block inside
1115///   the full `p_total`-wide coefficient vector. Must be contiguous and
1116///   non-overlapping; their union spans `0..p_total`.
1117///
1118/// Returns the symmetric `(p_total × p_total)` Gram matrix.
1119pub fn build_raw_grams_from_channel_blocks(
1120    channel_blocks: &PrimaryChannelBlocks,
1121    row_hess: &dyn RowHessian,
1122    raw_block_ranges: &[std::ops::Range<usize>],
1123) -> Result<Array2<f64>, CompilerError> {
1124    let num_blocks = channel_blocks.blocks.len();
1125    if num_blocks != raw_block_ranges.len() {
1126        return Err(CompilerError::DimensionMismatch(format!(
1127            "channel_blocks ({num_blocks}) and raw_block_ranges ({}) length mismatch",
1128            raw_block_ranges.len()
1129        )));
1130    }
1131    if num_blocks == 0 {
1132        return Ok(Array2::<f64>::zeros((0, 0)));
1133    }
1134    let k = row_hess.k();
1135    let n = row_hess.nrows();
1136    let p_total: usize = raw_block_ranges.iter().map(|r| r.end - r.start).sum();
1137    let expected_total = raw_block_ranges.last().map(|r| r.end).unwrap_or(0);
1138    if expected_total != p_total {
1139        return Err(CompilerError::DimensionMismatch(format!(
1140            "raw_block_ranges must be contiguous from 0; got p_total={p_total} but last end={expected_total}"
1141        )));
1142    }
1143    // Per-block channel-slot shape sanity.
1144    for (b, slots) in channel_blocks.blocks.iter().enumerate() {
1145        if slots.len() != k {
1146            return Err(CompilerError::DimensionMismatch(format!(
1147                "block {b}: expected {k} channel slots, got {}",
1148                slots.len()
1149            )));
1150        }
1151        let p_b = raw_block_ranges[b].end - raw_block_ranges[b].start;
1152        for (c, mat) in slots.iter().enumerate() {
1153            if let Some(x) = mat.as_ref() {
1154                if x.nrows() != n {
1155                    return Err(CompilerError::DimensionMismatch(format!(
1156                        "block {b} channel {c}: nrows={} but row Hessian nrows={n}",
1157                        x.nrows()
1158                    )));
1159                }
1160                if x.ncols() != p_b {
1161                    return Err(CompilerError::DimensionMismatch(format!(
1162                        "block {b} channel {c}: ncols={} but block width={p_b}",
1163                        x.ncols()
1164                    )));
1165                }
1166            }
1167        }
1168    }
1169
1170    // Materialise H once and slice it into K·K length-n vectors h_{cd}.
1171    let h_full = row_hess.evaluate_full();
1172    if h_full.shape() != &[n, k, k] {
1173        return Err(CompilerError::DimensionMismatch(format!(
1174            "row Hessian evaluate_full shape {:?} != [n={n}, k={k}, k={k}]",
1175            h_full.shape()
1176        )));
1177    }
1178    // h_pairs[c * k + d] = length-n vector of H_i[c, d].
1179    let mut h_pairs: Vec<Array1<f64>> = Vec::with_capacity(k * k);
1180    for c in 0..k {
1181        for d in 0..k {
1182            let mut v = Array1::<f64>::zeros(n);
1183            for i in 0..n {
1184                v[i] = h_full[[i, c, d]];
1185            }
1186            h_pairs.push(v);
1187        }
1188    }
1189
1190    let mut gram = Array2::<f64>::zeros((p_total, p_total));
1191    // Accumulate upper triangle (a ≤ b) then symmetrise.
1192    for a in 0..num_blocks {
1193        let range_a = raw_block_ranges[a].clone();
1194        for b in a..num_blocks {
1195            let range_b = raw_block_ranges[b].clone();
1196            let mut block_acc =
1197                Array2::<f64>::zeros((range_a.end - range_a.start, range_b.end - range_b.start));
1198            for c in 0..k {
1199                let Some(x_a_c) = channel_blocks.blocks[a][c].as_ref() else {
1200                    continue;
1201                };
1202                for d in 0..k {
1203                    let Some(x_b_d) = channel_blocks.blocks[b][d].as_ref() else {
1204                        continue;
1205                    };
1206                    let h_cd = &h_pairs[c * k + d];
1207                    // (X_a^(c))ᵀ · diag(h_cd) · X_b^(d)  →  (p_a × p_b).
1208                    let contrib = fast_xt_diag_y(x_a_c, h_cd, x_b_d);
1209                    block_acc += &contrib;
1210                }
1211            }
1212            // Write into upper triangle (and the diagonal block itself).
1213            gram.slice_mut(s![range_a.start..range_a.end, range_b.start..range_b.end])
1214                .assign(&block_acc);
1215        }
1216    }
1217    // Symmetrise: copy upper triangle to lower. Diagonal blocks are
1218    // themselves p_a × p_a — symmetrise within them too.
1219    for i in 0..p_total {
1220        for j in 0..i {
1221            let v = gram[[j, i]];
1222            gram[[i, j]] = v;
1223        }
1224    }
1225    Ok(gram)
1226}
1227
1228/// Structural Gram `K^S`: same shape as [`build_raw_grams_from_channel_blocks`]
1229/// but with the per-row Hessian replaced by the K×K identity. Used by the
1230/// dual-metric compiler as the un-weighted reference geometry.
1231///
1232/// `K^S[a, b] = Σ_c (X_a^(c))ᵀ · X_b^(c)` (cross-channel terms vanish under
1233/// `H_i = I_K`).
1234pub fn build_raw_grams_structural(
1235    channel_blocks: &PrimaryChannelBlocks,
1236    raw_block_ranges: &[std::ops::Range<usize>],
1237) -> Array2<f64> {
1238    let num_blocks = channel_blocks.blocks.len();
1239    assert_eq!(
1240        num_blocks,
1241        raw_block_ranges.len(),
1242        "channel_blocks ({num_blocks}) and raw_block_ranges ({}) length mismatch",
1243        raw_block_ranges.len()
1244    );
1245    if num_blocks == 0 {
1246        return Array2::<f64>::zeros((0, 0));
1247    }
1248    let p_total = raw_block_ranges.last().map(|r| r.end).unwrap_or(0);
1249    let mut gram = Array2::<f64>::zeros((p_total, p_total));
1250    for a in 0..num_blocks {
1251        let range_a = raw_block_ranges[a].clone();
1252        for b in a..num_blocks {
1253            let range_b = raw_block_ranges[b].clone();
1254            let p_a = range_a.end - range_a.start;
1255            let p_b = range_b.end - range_b.start;
1256            let k_a = channel_blocks.blocks[a].len();
1257            let k_b = channel_blocks.blocks[b].len();
1258            assert_eq!(
1259                k_a, k_b,
1260                "structural Gram: block {a} has {k_a} channels but block {b} has {k_b}",
1261            );
1262            let mut block_acc = Array2::<f64>::zeros((p_a, p_b));
1263            for c in 0..k_a {
1264                let (Some(x_a_c), Some(x_b_c)) = (
1265                    channel_blocks.blocks[a][c].as_ref(),
1266                    channel_blocks.blocks[b][c].as_ref(),
1267                ) else {
1268                    continue;
1269                };
1270                let contrib = if a == b {
1271                    // Diagonal block, same channel — symmetric XᵀX.
1272                    fast_ata(x_a_c)
1273                } else {
1274                    fast_atb(x_a_c, x_b_c)
1275                };
1276                block_acc += &contrib;
1277            }
1278            gram.slice_mut(s![range_a.start..range_a.end, range_b.start..range_b.end])
1279                .assign(&block_acc);
1280        }
1281    }
1282    for i in 0..p_total {
1283        for j in 0..i {
1284            let v = gram[[j, i]];
1285            gram[[i, j]] = v;
1286        }
1287    }
1288    gram
1289}
1290
1291/// Build the primary-state curvature Gram `K^H` and structural Gram `K^S`
1292/// for a block decomposition, preferring the device (GPU) path when
1293/// available and falling back to the CPU closed-form builders otherwise.
1294///
1295/// The GPU path is only attempted for survival-family geometry
1296/// (`K = CHANNELS = 4`) — that is the case the GPU kernel
1297/// ([`crate::families::gpu::try_primary_state_gram_cuda`])
1298/// is specialised for via the packed-symmetric `n × 10` weight layout.
1299/// For any other `K` the CPU builders are used unconditionally.
1300///
1301/// Returns `(gram_h, gram_struct)` with the same shape and semantics as
1302/// [`build_raw_grams_from_channel_blocks`] + [`build_raw_grams_structural`].
1303pub fn build_primary_grams_gpu_or_cpu(
1304    channel_blocks: &PrimaryChannelBlocks,
1305    row_hess: &dyn RowHessian,
1306    raw_block_ranges: &[std::ops::Range<usize>],
1307) -> Result<(Array2<f64>, Array2<f64>), CompilerError> {
1308    let k = row_hess.k();
1309    if k == crate::families::gpu::CHANNELS {
1310        let gpu_blocks: Vec<Vec<Option<Array2<f64>>>> = channel_blocks
1311            .blocks
1312            .iter()
1313            .map(|slots| slots.iter().cloned().collect())
1314            .collect();
1315        if let Some(h_packed) = pack_row_hessian_symmetric(row_hess) {
1316            if let Some(bundle) = crate::families::gpu::try_primary_state_gram_cuda(
1317                &gpu_blocks,
1318                &h_packed,
1319                raw_block_ranges,
1320            )
1321            .map_err(|error| CompilerError::GpuFailure(error.to_string()))?
1322            {
1323                log::info!("[identifiability_compile] gram path = gpu");
1324                return Ok((bundle.gram_h, bundle.gram_struct));
1325            }
1326        }
1327    }
1328    log::info!("[identifiability_compile] gram path = cpu");
1329    let gram_h = build_raw_grams_from_channel_blocks(channel_blocks, row_hess, raw_block_ranges)?;
1330    let gram_struct = build_raw_grams_structural(channel_blocks, raw_block_ranges);
1331    Ok((gram_h, gram_struct))
1332}
1333
1334/// Pack a per-row symmetric `K = 4` Hessian into the `n × 10`
1335/// upper-triangular row-major layout consumed by the GPU kernel
1336/// (`packed_index(c, d)` for `c ≤ d`). Returns `None` when `K != 4`.
1337fn pack_row_hessian_symmetric(row_hess: &dyn RowHessian) -> Option<Array2<f64>> {
1338    use crate::families::gpu::{CHANNELS, PACKED_LEN, packed_index};
1339    if row_hess.k() != CHANNELS {
1340        return None;
1341    }
1342    let n = row_hess.nrows();
1343    let h_full = row_hess.evaluate_full();
1344    if h_full.shape() != [n, CHANNELS, CHANNELS] {
1345        return None;
1346    }
1347    let mut packed = Array2::<f64>::zeros((n, PACKED_LEN));
1348    for i in 0..n {
1349        for c in 0..CHANNELS {
1350            for d in c..CHANNELS {
1351                packed[[i, packed_index(c, d)]] = h_full[[i, c, d]];
1352            }
1353        }
1354    }
1355    Some(packed)
1356}
1357
1358/// Closed-form Gram-based compile output: a single `p_raw × p_compiled`
1359/// reparam matrix `T` mapping compiled coordinates back to raw width.
1360/// `T · θ` lifts a fitted compiled-width β back to raw width; predict-time
1361/// row contribution is `X_raw · T · θ` where `X_raw` is the full raw design.
1362///
1363/// `compiled_block_ranges[b]` gives the column range inside `T` (and inside
1364/// the compiled-width coefficient vector) attributable to raw block `b`.
1365/// `raw_block_ranges[b]` gives the corresponding raw-width column range.
1366#[derive(Debug)]
1367pub struct CompiledMap {
1368    /// `(p_raw × p_compiled)` raw-from-compiled reparam matrix.
1369    pub raw_from_compiled: Array2<f64>,
1370    /// Per-block compiled-width column ranges, parallel to
1371    /// `raw_block_ranges`. Same length as the input `ordering`.
1372    pub compiled_block_ranges: Vec<std::ops::Range<usize>>,
1373    /// Per-block raw-width column ranges (copied through from input).
1374    pub raw_block_ranges: Vec<std::ops::Range<usize>>,
1375}
1376
1377/// Neutral view of this compiled reparametrisation for the gauge layer
1378/// (#1521): `Gauge::from_compiled_map` lives DOWN in `gam-problem` and
1379/// names only the `CompiledBlockMap` trait, never the concrete
1380/// `CompiledMap` (which lives ABOVE `gam-problem`). This `impl` supplies
1381/// the inverted dependency edge.
1382impl gam_problem::gauge::CompiledBlockMap for CompiledMap {
1383    fn raw_from_compiled(&self) -> &Array2<f64> {
1384        &self.raw_from_compiled
1385    }
1386    fn raw_block_ranges(&self) -> &[std::ops::Range<usize>] {
1387        &self.raw_block_ranges
1388    }
1389    fn compiled_block_ranges(&self) -> &[std::ops::Range<usize>] {
1390        &self.compiled_block_ranges
1391    }
1392}
1393
1394/// Closed-form Gram-based identifiability compile.
1395///
1396/// Sequential algorithm operating purely on the raw-width Grams
1397/// `K^H = Σ_i J_iᵀ H_i J_i` (curvature) and `K^S = Σ_i J_iᵀ J_i`
1398/// (structural). Walks `ordering` left-to-right; for each block `b` with
1399/// raw-width selector `P_b` (columns of the identity selecting that
1400/// block) and cumulative compiled map `T = [T_0, …, T_{b-1}]`:
1401///
1402/// 1. Structural rank step (drop true gauges):
1403///    `G^S_AA = Tᵀ K^S T`, `G^S_Ab = Tᵀ K^S P_b`, `G^S_bb = P_bᵀ K^S P_b`,
1404///    `R_S = (G^S_AA)^+ G^S_Ab`, `G^S_res = G^S_bb − G^S_Abᵀ R_S`.
1405///    Eigendecompose `G^S_res`; keep positive eigvecs `Q+`. Then
1406///    `D = (P_b − T R_S) · Q+` (raw-space cols, structurally independent
1407///    of `T`).
1408/// 2. Curvature step (within-block conditioning):
1409///    `G^H_AA = Tᵀ K^H T`, `G^H_AD = Tᵀ K^H D`,
1410///    `R_H = (G^H_AA)^+ G^H_AD`, `E = D − T R_H` (raw-space).
1411///    Curvature Gram `G^H_res = Dᵀ K^H D − G^H_ADᵀ R_H`. Eigendecompose
1412///    and keep positive eigvecs `U`. Then `T_b = E · U`.
1413/// 3. Append: `T ← [T, T_b]`.
1414///
1415/// Returns [`CompilerError::FullyAliased`] only when the first block has no
1416/// usable structural/curvature span. Later fully absorbed blocks compile to a
1417/// zero-width block range, which is the reduced-coordinate representation of
1418/// the lower-priority block owning no degrees of freedom.
1419pub fn compile_from_raw_grams(
1420    gram_h: &Array2<f64>,
1421    gram_struct: &Array2<f64>,
1422    raw_block_ranges: &[std::ops::Range<usize>],
1423    ordering: &[BlockOrder],
1424) -> Result<CompiledMap, CompilerError> {
1425    compile_from_raw_grams_protected(gram_h, gram_struct, raw_block_ranges, ordering, &[])
1426}
1427
1428/// Variant of [`compile_from_raw_grams`] that keeps designated blocks at full
1429/// raw width instead of dropping their near-null structural/curvature
1430/// directions.
1431///
1432/// `protected[b] == true` forces block `b` to retain **all** of its raw
1433/// columns: the structural and curvature eigenspace filters that would drop
1434/// weak directions are replaced by identity, so `T_b` embeds the full raw
1435/// block (orthogonalised against earlier anchors) rather than a reduced
1436/// section. The block still serves as a full-width anchor for every later
1437/// (unprotected) block, so cross-block aliasing against it is removed exactly
1438/// as before — only the protected block's own within-block reparameterisation
1439/// is suppressed.
1440///
1441/// This exists for blocks whose effective Jacobian is a **fixed nonlinear
1442/// functional basis** rather than a plain linear design (e.g. the survival
1443/// marginal-slope monotone time-wiggle block). Such a block's chain-rule
1444/// Jacobian recomputes its basis at the raw coefficient width on every
1445/// evaluation and therefore cannot be expressed on a linearly recombined /
1446/// reduced design; reparameterising it silently corrupts — and can index out
1447/// of bounds in — that basis evaluation. Keeping it at raw width lets its own
1448/// penalty nullspace regularise its conditioning, which is the correct
1449/// treatment for a within-block (as opposed to cross-block) rank deficiency.
1450///
1451/// `protected` may be shorter than `ordering` (missing entries default to
1452/// `false`); an empty slice reproduces [`compile_from_raw_grams`] exactly.
1453pub fn compile_from_raw_grams_protected(
1454    gram_h: &Array2<f64>,
1455    gram_struct: &Array2<f64>,
1456    raw_block_ranges: &[std::ops::Range<usize>],
1457    ordering: &[BlockOrder],
1458    protected: &[bool],
1459) -> Result<CompiledMap, CompilerError> {
1460    if raw_block_ranges.len() != ordering.len() {
1461        return Err(CompilerError::DimensionMismatch(format!(
1462            "raw_block_ranges ({}) and ordering ({}) length mismatch",
1463            raw_block_ranges.len(),
1464            ordering.len()
1465        )));
1466    }
1467    let p_raw = raw_block_ranges.last().map(|r| r.end).unwrap_or(0);
1468    if gram_h.shape() != [p_raw, p_raw] {
1469        return Err(CompilerError::DimensionMismatch(format!(
1470            "gram_h shape {:?} != [p_raw={p_raw}, p_raw={p_raw}]",
1471            gram_h.shape()
1472        )));
1473    }
1474    if gram_struct.shape() != [p_raw, p_raw] {
1475        return Err(CompilerError::DimensionMismatch(format!(
1476            "gram_struct shape {:?} != [p_raw={p_raw}, p_raw={p_raw}]",
1477            gram_struct.shape()
1478        )));
1479    }
1480    if raw_block_ranges.is_empty() {
1481        return Ok(CompiledMap {
1482            raw_from_compiled: Array2::<f64>::zeros((0, 0)),
1483            compiled_block_ranges: Vec::new(),
1484            raw_block_ranges: Vec::new(),
1485        });
1486    }
1487    // Validate contiguous ranges from 0.
1488    let mut expected_start = 0usize;
1489    for (b, r) in raw_block_ranges.iter().enumerate() {
1490        if r.start != expected_start {
1491            return Err(CompilerError::DimensionMismatch(format!(
1492                "raw_block_ranges must be contiguous from 0; block {b} starts at {} expected {expected_start}",
1493                r.start
1494            )));
1495        }
1496        expected_start = r.end;
1497    }
1498
1499    // Cumulative raw-from-compiled map. Starts empty (zero compiled cols).
1500    let mut t_cum: Array2<f64> = Array2::<f64>::zeros((p_raw, 0));
1501    let mut compiled_block_ranges: Vec<std::ops::Range<usize>> =
1502        Vec::with_capacity(raw_block_ranges.len());
1503
1504    for (idx, range_b) in raw_block_ranges.iter().enumerate() {
1505        let p_b = range_b.end - range_b.start;
1506        let block_protected = protected.get(idx).copied().unwrap_or(false);
1507        // A zero-width block owns no raw columns. It contributes no compiled
1508        // degrees of freedom and — having no columns — cannot alias against any
1509        // anchor, so it is trivially identifiable. Emit an empty compiled range
1510        // and skip the structural/curvature analysis: a 0×0 residual Gram has no
1511        // positive eigenspace, which the first-block guard below would otherwise
1512        // mis-report as `FullyAliased` even though there is literally nothing to
1513        // alias. This mirrors the empty range a fully-absorbed later block
1514        // already compiles to (see the `q_plus.ncols() == 0` / `u_mat.ncols() == 0`
1515        // branches), keeping `kept_width + dropped_count == raw_width` exact.
1516        if p_b == 0 {
1517            let at = t_cum.ncols();
1518            compiled_block_ranges.push(at..at);
1519            continue;
1520        }
1521        // Slice gram columns/rows by raw block range. P_bᵀ K X = rows
1522        // range_b of K X. K^S T and K^H T are full-rows products.
1523        // 1) Structural rank step.
1524        // K^S · T (p_raw × p_compiled)
1525        let ks_t = fast_ab(gram_struct, &t_cum);
1526        // G^S_AA = Tᵀ K^S T (p_compiled × p_compiled)
1527        let g_s_aa = fast_atb(&t_cum, &ks_t);
1528        // G^S_Ab = Tᵀ K^S P_b = Tᵀ · K^S[:, range_b] (p_compiled × p_b)
1529        let ks_pb = gram_struct
1530            .slice(s![.., range_b.start..range_b.end])
1531            .to_owned();
1532        let g_s_ab = fast_atb(&t_cum, &ks_pb);
1533        // G^S_bb = P_bᵀ K^S P_b = K^S[range_b, range_b] (p_b × p_b)
1534        let g_s_bb = gram_struct
1535            .slice(s![range_b.start..range_b.end, range_b.start..range_b.end])
1536            .to_owned();
1537        // R_S = (G^S_AA)^+ G^S_Ab (p_compiled × p_b)
1538        let r_s = solve_psd_system(&g_s_aa, &g_s_ab)?;
1539        // G^S_res = G^S_bb − G^S_Abᵀ R_S (p_b × p_b), symmetrise.
1540        let g_s_res_raw = &g_s_bb - &fast_atb(&g_s_ab, &r_s);
1541        let g_s_res = symmetrise(&g_s_res_raw);
1542        // Trace of the unresidualised diagonal block (scale ref).
1543        let g_s_bb_trace: f64 = (0..p_b).map(|i| g_s_bb[[i, i]].max(0.0)).sum();
1544        // p_raw stands in as the "n*K" scale for the closed-form tolerance.
1545        // A protected block keeps every raw column (identity structural span);
1546        // the residual-Gram eigenfilter that would drop weak directions is
1547        // suppressed so the block emerges at full raw width.
1548        let q_plus = if block_protected {
1549            Array2::<f64>::eye(p_b)
1550        } else {
1551            keep_positive_eigenspace(&g_s_res, p_raw, 1, g_s_bb_trace)?
1552        };
1553        if q_plus.ncols() == 0 {
1554            if t_cum.ncols() == 0 {
1555                return Err(CompilerError::FullyAliased {
1556                    block_idx: idx,
1557                    reason: format!(
1558                        "structural residual Gram has no positive eigenspace (block of width {p_b} has zero structural span before any anchor exists)"
1559                    ),
1560                });
1561            }
1562            let at = t_cum.ncols();
1563            compiled_block_ranges.push(at..at);
1564            continue;
1565        }
1566        // D = (P_b − T R_S) · Q+ (p_raw × k_kept). Build (P_b − T R_S)
1567        // explicitly as a p_raw × p_b matrix: columns of P_b are columns
1568        // range_b of I_p_raw, so (P_b − T R_S) places −T R_S in all rows
1569        // and adds the identity on rows range_b.
1570        let mut diff = Array2::<f64>::zeros((p_raw, p_b));
1571        if t_cum.ncols() > 0 {
1572            // diff = −T · R_S
1573            let t_rs = fast_ab(&t_cum, &r_s);
1574            for i in 0..p_raw {
1575                for j in 0..p_b {
1576                    diff[[i, j]] = -t_rs[[i, j]];
1577                }
1578            }
1579        }
1580        for j in 0..p_b {
1581            diff[[range_b.start + j, j]] += 1.0;
1582        }
1583        let d_mat = fast_ab(&diff, &q_plus);
1584
1585        // 2) Curvature step.
1586        // K^H · T (p_raw × p_compiled), K^H · D (p_raw × k_kept)
1587        let kh_t = fast_ab(gram_h, &t_cum);
1588        let g_h_aa = fast_atb(&t_cum, &kh_t);
1589        let kh_d = fast_ab(gram_h, &d_mat);
1590        let g_h_ad = fast_atb(&t_cum, &kh_d);
1591        let r_h = solve_psd_system(&g_h_aa, &g_h_ad)?;
1592        // G^H_res = Dᵀ K^H D − G^H_ADᵀ R_H (k_kept × k_kept)
1593        let d_t_kh_d = fast_atb(&d_mat, &kh_d);
1594        let g_h_res_raw = &d_t_kh_d - &fast_atb(&g_h_ad, &r_h);
1595        let g_h_res = symmetrise(&g_h_res_raw);
1596        let k_kept = q_plus.ncols();
1597        let g_h_dd_trace: f64 = (0..k_kept).map(|i| d_t_kh_d[[i, i]].max(0.0)).sum();
1598        // A protected block also retains every structurally-kept curvature
1599        // direction (identity curvature span), so no within-block conditioning
1600        // drop occurs; its own penalty nullspace regularises the fit instead.
1601        let u_mat = if block_protected {
1602            Array2::<f64>::eye(k_kept)
1603        } else {
1604            keep_positive_eigenspace(&g_h_res, p_raw, 1, g_h_dd_trace)?
1605        };
1606        if u_mat.ncols() == 0 {
1607            if t_cum.ncols() == 0 {
1608                return Err(CompilerError::FullyAliased {
1609                    block_idx: idx,
1610                    reason: format!(
1611                        "curvature residual Gram has no positive eigenspace within structurally-kept basis (block of width {p_b}, structural-kept {k_kept}) before any anchor exists"
1612                    ),
1613                });
1614            }
1615            let at = t_cum.ncols();
1616            compiled_block_ranges.push(at..at);
1617            continue;
1618        }
1619        // E = D − T · R_H (p_raw × k_kept); T_b = E · U.
1620        let mut e_mat = d_mat.clone();
1621        if t_cum.ncols() > 0 {
1622            let t_rh = fast_ab(&t_cum, &r_h);
1623            e_mat = &e_mat - &t_rh;
1624        }
1625        let t_b = fast_ab(&e_mat, &u_mat);
1626
1627        let start = t_cum.ncols();
1628        let end = start + t_b.ncols();
1629        compiled_block_ranges.push(start..end);
1630        t_cum = concat_cols(&t_cum, &t_b);
1631    }
1632
1633    // Finite check.
1634    for v in t_cum.iter() {
1635        if !v.is_finite() {
1636            return Err(CompilerError::LinalgFailure(
1637                "compile_from_raw_grams produced non-finite entry in raw_from_compiled".to_string(),
1638            ));
1639        }
1640    }
1641
1642    Ok(CompiledMap {
1643        raw_from_compiled: t_cum,
1644        compiled_block_ranges,
1645        raw_block_ranges: raw_block_ranges.to_vec(),
1646    })
1647}
1648
1649impl CompiledMap {
1650    /// Raw coefficient width (`p_raw`).
1651    pub fn p_raw(&self) -> usize {
1652        self.raw_from_compiled.nrows()
1653    }
1654
1655    /// Compiled (reduced) coefficient width (`p_compiled`).
1656    pub fn p_compiled(&self) -> usize {
1657        self.raw_from_compiled.ncols()
1658    }
1659
1660    /// Reparameterise a raw design into compiled coordinates:
1661    /// `X_compiled = X_raw · T` (`n × p_compiled`). Because the lift is
1662    /// `β_raw = T β_compiled`, the compiled design predicts identically to the
1663    /// raw design on every compiled coefficient: `X_compiled · θ = X_raw · (T θ)`.
1664    /// Families that build directly in reduced coordinates feed this compiled
1665    /// design (and the [`reduce_penalties_with_map`] penalties) to the solver;
1666    /// the rank-deficient raw basis never reaches Newton.
1667    pub fn reduce_design(&self, raw_design: &Array2<f64>) -> Result<Array2<f64>, String> {
1668        if raw_design.ncols() != self.p_raw() {
1669            return Err(format!(
1670                "CompiledMap::reduce_design: raw_design has {} columns, expected p_raw {}",
1671                raw_design.ncols(),
1672                self.p_raw()
1673            ));
1674        }
1675        Ok(fast_ab(raw_design, &self.raw_from_compiled))
1676    }
1677
1678    /// Lift a fitted compiled-width coefficient vector back to raw width:
1679    /// `β_raw = T · β_compiled`. This is the exact inverse direction of the
1680    /// quotient reduction — the reduced coordinates are what Newton/REML
1681    /// operate in, and this map carries the final estimate (and any linear
1682    /// functional of it) back to the original parameterisation so reported
1683    /// coefficients and predictions match the raw design.
1684    pub fn lift_coefficients(&self, beta_compiled: &Array1<f64>) -> Result<Array1<f64>, String> {
1685        if beta_compiled.len() != self.p_compiled() {
1686            return Err(format!(
1687                "CompiledMap::lift_coefficients: beta_compiled len {} != p_compiled {}",
1688                beta_compiled.len(),
1689                self.p_compiled()
1690            ));
1691        }
1692        Ok(self.raw_from_compiled.dot(beta_compiled))
1693    }
1694
1695    /// The rows of `T` belonging to raw block `b` (`T[raw_block_ranges[b], :]`,
1696    /// shape `p_b_raw × p_compiled`). A raw-block penalty `S_b` acts only on
1697    /// these raw columns, so the penalty's reduced-coordinate form depends on
1698    /// `T` only through this slice.
1699    fn raw_block_rows(&self, block_idx: usize) -> Result<Array2<f64>, String> {
1700        let range = self.raw_block_ranges.get(block_idx).ok_or_else(|| {
1701            format!(
1702                "CompiledMap::raw_block_rows: block {block_idx} out of range {}",
1703                self.raw_block_ranges.len()
1704            )
1705        })?;
1706        Ok(self
1707            .raw_from_compiled
1708            .slice(s![range.start..range.end, ..])
1709            .to_owned())
1710    }
1711}
1712
1713/// Transform a per-block raw-width penalty into the compiled (reduced)
1714/// coordinate frame defined by `map`.
1715///
1716/// `raw_penalties[b]` is the penalty matrix `S_b` acting on raw block `b`
1717/// (shape `p_b_raw × p_b_raw`), or `None` for an unpenalised block. The
1718/// returned `reduced[b]` is the **full** `(p_compiled × p_compiled)` penalty
1719/// `Tᵀ Ŝ_b T`, where `Ŝ_b` embeds `S_b` into the `p_raw × p_raw` zero matrix
1720/// at block `b`'s position. Because `Ŝ_b` is zero outside block `b`'s rows and
1721/// columns, this equals `T_bᵀ S_b T_b` with `T_b = T[raw_block_ranges[b], :]`,
1722/// so the reduced penalty is computed from the block's lift rows alone — no
1723/// dense `p_raw × p_raw` embedding is materialised.
1724///
1725/// Exactness: for any compiled coefficient `θ` with raw lift `β = T θ`, the raw
1726/// penalty energy `βᵀ Ŝ_b β = (T θ)ᵀ Ŝ_b (T θ) = θᵀ (Tᵀ Ŝ_b T) θ`, so the
1727/// reduced penalty reproduces the raw penalty energy on every lifted point.
1728/// A compiled block that absorbed to zero width simply contributes a zero
1729/// column range; its raw penalty (if any) projects onto the surviving
1730/// compiled directions through `T_b`, never lost.
1731pub fn reduce_penalties_with_map(
1732    map: &CompiledMap,
1733    raw_penalties: &[Option<Array2<f64>>],
1734) -> Result<Vec<Option<Array2<f64>>>, String> {
1735    if raw_penalties.len() != map.raw_block_ranges.len() {
1736        return Err(format!(
1737            "reduce_penalties_with_map: raw_penalties ({}) != blocks ({})",
1738            raw_penalties.len(),
1739            map.raw_block_ranges.len()
1740        ));
1741    }
1742    let p_compiled = map.p_compiled();
1743    let mut reduced: Vec<Option<Array2<f64>>> = Vec::with_capacity(raw_penalties.len());
1744    for (block_idx, raw_penalty) in raw_penalties.iter().enumerate() {
1745        let Some(s_b) = raw_penalty.as_ref() else {
1746            reduced.push(None);
1747            continue;
1748        };
1749        let p_b_raw = map.raw_block_ranges[block_idx].len();
1750        if s_b.shape() != [p_b_raw, p_b_raw] {
1751            return Err(format!(
1752                "reduce_penalties_with_map: block {block_idx} penalty shape {:?} != [{p_b_raw}, {p_b_raw}]",
1753                s_b.shape()
1754            ));
1755        }
1756        // T_b = T[raw rows of block b, :]  (p_b_raw × p_compiled)
1757        let t_b = map.raw_block_rows(block_idx)?;
1758        // S_compiled = T_bᵀ S_b T_b  (p_compiled × p_compiled)
1759        let s_t_b = fast_ab(s_b, &t_b); // (p_b_raw × p_compiled)
1760        let s_compiled_raw = fast_atb(&t_b, &s_t_b); // (p_compiled × p_compiled)
1761        let mut s_compiled = symmetrise(&s_compiled_raw);
1762        if s_compiled.shape() != [p_compiled, p_compiled] {
1763            return Err(format!(
1764                "reduce_penalties_with_map: block {block_idx} reduced penalty shape {:?} != [{p_compiled}, {p_compiled}]",
1765                s_compiled.shape()
1766            ));
1767        }
1768        for v in s_compiled.iter_mut() {
1769            if !v.is_finite() {
1770                return Err(format!(
1771                    "reduce_penalties_with_map: block {block_idx} reduced penalty has non-finite entry"
1772                ));
1773            }
1774        }
1775        reduced.push(Some(s_compiled));
1776    }
1777    Ok(reduced)
1778}
1779
1780/// Per-block exact orthogonal reparameterisation of structural confounds.
1781///
1782/// `block_transforms[b]` is a dense `(p_b × r_b)` reparam `V_b` mapping raw
1783/// block-`b` coefficients to reduced coordinates: the orthogonalised block
1784/// design is `X_b · V_b`, and a fitted reduced coefficient lifts back to raw
1785/// space exactly via `β_b_raw = V_b · θ_b`. `r_b ≤ p_b`; `r_b < p_b` exactly
1786/// when block `b` carries `p_b − r_b` directions already spanned (in the
1787/// pilot W-metric) by the cumulative anchor of all higher-priority blocks —
1788/// those directions are removed (not penalised), so the joint design
1789/// `[X_0 V_0 | X_1 V_1 | …]` has the overlap excised exactly.
1790pub struct BlockOrthogonalization {
1791    /// `block_transforms[b]`: the `(p_b × r_b)` reparam `V_b` for raw block `b`,
1792    /// in the **original block order** (parallel to the `block_designs` input).
1793    pub block_transforms: Vec<Array2<f64>>,
1794    /// `(block_idx, local_raw_col_count_dropped)` for every block whose
1795    /// reduced width is strictly smaller than its raw width — i.e. the blocks
1796    /// that shed overlap directions against the anchor. Empty when no block
1797    /// overlapped (every `V_b` is then a `p_b × p_b` rotation/identity).
1798    pub dropped: Vec<(usize, usize)>,
1799    /// One structural annotation per input block, in original block order.
1800    ///
1801    /// This is the explicit "same direction vs independent direction" verdict:
1802    /// `Independent` means the block kept its full realized-design rank, while
1803    /// `PartiallyAbsorbed...` / `FullyAbsorbed...` mean the lower-priority block
1804    /// shared realized-design directions with the cumulative anchor and those
1805    /// directions were removed rather than assigned a separate penalty.
1806    pub direction_annotations: Vec<PenalizedDirectionAnnotation>,
1807}
1808
1809/// Build per-block exact W-metric orthogonalising reparameterisations.
1810///
1811/// `block_designs[b]` is the raw `(n × p_b)` design of block `b`.
1812/// `priority[b]` is the block's gauge priority — blocks are residualised in
1813/// **descending** priority order, so the highest-priority block keeps its full
1814/// column span and lower-priority blocks shed only the directions already
1815/// explained by the cumulative higher-priority anchor. `weight` is the pilot
1816/// W-metric row weight `w_i ≥ 0` (the diagonal of the working GLM/GAM Hessian
1817/// at the pilot β); pass an all-ones vector for the plain Euclidean metric.
1818///
1819/// The returned `block_transforms` are in the **original** block order. For a
1820/// block whose columns are all W-orthogonal to the anchor, `V_b` is a square
1821/// `p_b × p_b` orthonormal rotation (rank preserved, round-trip exact). For a
1822/// block with an overlap of dimension `d`, `V_b` is `p_b × (p_b − d)` and the
1823/// `d` overlap directions are removed exactly.
1824///
1825/// Exactness / round-trip: `X_b · V_b` is the reduced design and
1826/// `β_b_raw = V_b · θ_b` lifts a reduced fit back to raw coordinates. `V_b` has
1827/// orthonormal columns (eigenvectors of the residual Gram), so the lift is the
1828/// minimum-norm raw representative of the reduced fit.
1829pub fn orthogonalize_design_blocks(
1830    block_designs: &[Array2<f64>],
1831    priority: &[u32],
1832    weight: &[f64],
1833) -> Result<BlockOrthogonalization, CompilerError> {
1834    if block_designs.len() != priority.len() {
1835        return Err(CompilerError::DimensionMismatch(format!(
1836            "block_designs ({}) and priority ({}) length mismatch",
1837            block_designs.len(),
1838            priority.len()
1839        )));
1840    }
1841    if block_designs.is_empty() {
1842        return Ok(BlockOrthogonalization {
1843            block_transforms: Vec::new(),
1844            dropped: Vec::new(),
1845            direction_annotations: Vec::new(),
1846        });
1847    }
1848    let n = block_designs[0].nrows();
1849    for (b, x) in block_designs.iter().enumerate() {
1850        if x.nrows() != n {
1851            return Err(CompilerError::DimensionMismatch(format!(
1852                "block {b} design has {} rows but block 0 has {n}",
1853                x.nrows()
1854            )));
1855        }
1856    }
1857    if weight.len() != n {
1858        return Err(CompilerError::DimensionMismatch(format!(
1859            "weight length {} != n {n}",
1860            weight.len()
1861        )));
1862    }
1863    // sqrt(W) row scale. The pilot Hessian is PSD-clamped upstream; accepting
1864    // a negative or non-finite value here would silently change the requested
1865    // metric and can turn an aliased direction into an apparently independent
1866    // one. Reject the invalid mathematical object at the boundary.
1867    let mut sqrt_w = Array1::<f64>::zeros(n);
1868    for i in 0..n {
1869        let wi = weight[i];
1870        if !wi.is_finite() || wi < 0.0 {
1871            return Err(CompilerError::InvalidMetric(format!(
1872                "weight[{i}] must be finite and non-negative; got {wi}"
1873            )));
1874        }
1875        sqrt_w[i] = wi.sqrt();
1876    }
1877
1878    // Descending-priority visitation order over the original block indices.
1879    // Stable on ties (preserves input order) so the anchor build is
1880    // deterministic.
1881    let mut order: Vec<usize> = (0..block_designs.len()).collect();
1882    order.sort_by(|&a, &b| priority[b].cmp(&priority[a]));
1883
1884    // Cumulative weighted anchor `A = sqrt(W) · [kept block designs]`.
1885    let mut anchor: Array2<f64> = Array2::<f64>::zeros((n, 0));
1886
1887    // Output transforms indexed by ORIGINAL block index (filled out of order).
1888    let mut block_transforms: Vec<Option<Array2<f64>>> = vec![None; block_designs.len()];
1889    let mut direction_annotations: Vec<Option<PenalizedDirectionAnnotation>> =
1890        vec![None; block_designs.len()];
1891    let mut dropped: Vec<(usize, usize)> = Vec::new();
1892
1893    for &b in order.iter() {
1894        let x_b = &block_designs[b];
1895        let p_b = x_b.ncols();
1896        // Weighted block design `W_b = sqrt(W) · X_b`.
1897        let mut w_b = x_b.clone();
1898        for i in 0..n {
1899            let s = sqrt_w[i];
1900            for j in 0..p_b {
1901                w_b[[i, j]] *= s;
1902            }
1903        }
1904        // Residualise `W_b` against the cumulative anchor in the W-metric and
1905        // eigendecompose the residual Gram. Eigenvectors with positive
1906        // eigenvalues span block `b`'s W-orthogonal-to-anchor column space;
1907        // the zero-eigenvalue directions are exactly the overlap with the
1908        // anchor and are removed.
1909        let (residual, _correction) = residualise_in_metric(&anchor, &w_b)?;
1910        let g_res = symmetrise(&fast_atb(&residual, &residual));
1911        // Scale reference for `keep_positive_eigenspace` must be the
1912        // *original* (pre-residualisation) weighted block Gram trace, NOT the
1913        // residual's. When `b` is fully absorbed by a higher-priority anchor
1914        // the residual collapses to floating-point noise (~ε² of the original
1915        // O(1) data); anchoring tau to that noise floor would keep the noise
1916        // eigenvalues and misreport a fully-absorbed block as `Independent`.
1917        // The original-block trace is invariant to absorption, so a near-zero
1918        // residual is correctly rejected as fully absorbed.
1919        let g_bb = fast_atb(&w_b, &w_b);
1920        let g_bb_trace: f64 = (0..p_b).map(|i| g_bb[[i, i]].max(0.0)).sum();
1921        let v_b = keep_positive_eigenspace(&g_res, n, 1, g_bb_trace)?;
1922        let r_b = v_b.ncols();
1923        let absorbed_width = p_b - r_b;
1924        let kind = if absorbed_width == 0 {
1925            PenalizedDirectionAnnotationKind::Independent
1926        } else if r_b == 0 {
1927            PenalizedDirectionAnnotationKind::FullyAbsorbedByHigherPriority
1928        } else {
1929            PenalizedDirectionAnnotationKind::PartiallyAbsorbedByHigherPriority
1930        };
1931        direction_annotations[b] = Some(PenalizedDirectionAnnotation {
1932            block_idx: b,
1933            raw_width: p_b,
1934            kept_width: r_b,
1935            absorbed_width,
1936            kind,
1937        });
1938        if absorbed_width > 0 {
1939            dropped.push((b, absorbed_width));
1940        }
1941        // Append this block's kept, W-orthogonalised weighted columns to the
1942        // anchor so lower-priority blocks residualise against them too. The
1943        // residual (already anchor-orthogonal) projected onto the kept basis
1944        // is `residual · V_b` — these are mutually orthogonal in the W-metric
1945        // by construction of `keep_positive_eigenspace`.
1946        let kept_weighted = fast_ab(&residual, &v_b);
1947        anchor = concat_cols(&anchor, &kept_weighted);
1948        block_transforms[b] = Some(v_b);
1949    }
1950
1951    let block_transforms: Vec<Array2<f64>> = block_transforms
1952        .into_iter()
1953        .enumerate()
1954        .map(|(b, t)| {
1955            t.ok_or_else(|| {
1956                CompilerError::LinalgFailure(format!(
1957                    "orthogonalize_design_blocks: block {b} transform was never assigned"
1958                ))
1959            })
1960        })
1961        .collect::<Result<Vec<_>, _>>()?;
1962    let direction_annotations: Vec<PenalizedDirectionAnnotation> = direction_annotations
1963        .into_iter()
1964        .enumerate()
1965        .map(|(b, annotation)| {
1966            annotation.ok_or_else(|| {
1967                CompilerError::LinalgFailure(format!(
1968                    "orthogonalize_design_blocks: block {b} direction annotation was never assigned"
1969                ))
1970            })
1971        })
1972        .collect::<Result<Vec<_>, _>>()?;
1973
1974    // Finite check on every transform.
1975    for (b, v) in block_transforms.iter().enumerate() {
1976        for value in v.iter() {
1977            if !value.is_finite() {
1978                return Err(CompilerError::LinalgFailure(format!(
1979                    "orthogonalize_design_blocks: block {b} transform has a non-finite entry"
1980                )));
1981            }
1982        }
1983    }
1984
1985    Ok(BlockOrthogonalization {
1986        block_transforms,
1987        dropped,
1988        direction_annotations,
1989    })
1990}
1991
1992/// Symmetrise a (nearly-symmetric) matrix by averaging with its transpose.
1993fn symmetrise(m: &Array2<f64>) -> Array2<f64> {
1994    let (r, c) = m.dim();
1995    assert_eq!(r, c, "symmetrise expects square matrix");
1996    let mut out = Array2::<f64>::zeros((r, c));
1997    for i in 0..r {
1998        for j in 0..c {
1999            out[[i, j]] = 0.5 * (m[[i, j]] + m[[j, i]]);
2000        }
2001    }
2002    out
2003}
2004
2005#[cfg(test)]
2006mod tests {
2007    use super::*;
2008    use ndarray::{Array1, Array2};
2009
2010    /// Convenience: wrap a dense `(n × p)` block design as a `K=1`
2011    /// row-Jacobian operator. Used by tests; production families ship their
2012    /// own concrete operators.
2013    struct DenseScalarOperator {
2014        design: Array2<f64>,
2015    }
2016
2017    impl DenseScalarOperator {
2018        fn new(design: Array2<f64>) -> Self {
2019            Self { design }
2020        }
2021    }
2022
2023    impl RowJacobianOperator for DenseScalarOperator {
2024        fn k(&self) -> usize {
2025            1
2026        }
2027        fn ncols(&self) -> usize {
2028            self.design.ncols()
2029        }
2030        fn nrows(&self) -> usize {
2031            self.design.nrows()
2032        }
2033        fn apply_row(&self, row: usize, delta_beta: &[f64], out: &mut [f64]) {
2034            assert_eq!(out.len(), 1);
2035            let mut acc = 0.0;
2036            for (j, &b) in delta_beta.iter().enumerate() {
2037                acc += self.design[[row, j]] * b;
2038            }
2039            out[0] = acc;
2040        }
2041        fn evaluate_full(&self) -> Array3<f64> {
2042            let n = self.design.nrows();
2043            let p = self.design.ncols();
2044            let mut out = Array3::<f64>::zeros((n, p, 1));
2045            for i in 0..n {
2046                for j in 0..p {
2047                    out[[i, j, 0]] = self.design[[i, j]];
2048                }
2049            }
2050            out
2051        }
2052    }
2053
2054    // `IdentityRowHessian` is re-exported from the parent module's `use
2055    // super::*;` above (now a public struct so the dual-metric API can
2056    // share the default structural metric with callers).
2057
2058    /// Diagonal row Hessian with per-row scalar weights (K=1 case).
2059    struct DiagonalScalarRowHessian {
2060        w: Array1<f64>,
2061    }
2062
2063    impl DiagonalScalarRowHessian {
2064        fn new(w: Array1<f64>) -> Self {
2065            Self { w }
2066        }
2067    }
2068
2069    impl RowHessian for DiagonalScalarRowHessian {
2070        fn k(&self) -> usize {
2071            1
2072        }
2073        fn nrows(&self) -> usize {
2074            self.w.len()
2075        }
2076        fn fill_row(&self, row: usize, out: &mut [f64]) {
2077            assert_eq!(out.len(), 1);
2078            out[0] = self.w[row];
2079        }
2080        fn evaluate_full(&self) -> Array3<f64> {
2081            let n = self.w.len();
2082            let mut out = Array3::<f64>::zeros((n, 1, 1));
2083            for i in 0..n {
2084                out[[i, 0, 0]] = self.w[i];
2085            }
2086            out
2087        }
2088    }
2089
2090    fn op(design: Array2<f64>) -> Arc<dyn RowJacobianOperator> {
2091        Arc::new(DenseScalarOperator::new(design))
2092    }
2093
2094    /// §10 test #1: two affine blocks, identity row Hessian. The compiled
2095    /// second-block design must be orthogonal to the first block under the
2096    /// (identity) row metric to machine epsilon.
2097    #[test]
2098    fn compile_two_block_orthogonalises_under_metric() {
2099        let n = 50;
2100        let a = Array2::from_shape_fn((n, 3), |(i, j)| ((i + 1) as f64).sin().powi((j + 1) as i32));
2101        // B partly aliases A's first column.
2102        let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2103            0.5 * a[[i, 0]] + ((i as f64) * 0.13 + j as f64).cos()
2104        });
2105        let hess = IdentityRowHessian::new(n, 1);
2106        let ops = vec![op(a.clone()), op(b.clone())];
2107        let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2108            .expect("compile should succeed");
2109        // Build A's design (no rotation) and B's compiled design B·V − A·M.
2110        let v_b = &compiled.blocks[1].t_lw;
2111        let m_b = compiled.blocks[1]
2112            .anchor_correction
2113            .as_ref()
2114            .expect("second block must carry an anchor correction");
2115        let b_v = b.dot(v_b);
2116        let a_m = a.dot(m_b);
2117        let b_compiled = &b_v - &a_m;
2118        // <A, B_compiled>_I = Aᵀ · B_compiled should be ≈ 0.
2119        let cross = a.t().dot(&b_compiled);
2120        let max_err = cross.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2121        assert!(
2122            max_err < 1e-10,
2123            "orthogonality residual too large: {max_err:e}"
2124        );
2125    }
2126
2127    /// §10 test #2: three-block chain with sequential aliases.
2128    #[test]
2129    fn compile_three_block_chain() {
2130        let n = 80;
2131        let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.1 + j as f64).sin());
2132        let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2133            0.3 * a[[i, 0]] + (j as f64) * (i as f64).cos()
2134        });
2135        let c = Array2::from_shape_fn((n, 2), |(i, j)| {
2136            0.2 * a[[i, 1]] + 0.4 * b[[i, 0]] + ((i + j) as f64).tan().min(5.0).max(-5.0)
2137        });
2138        let hess = IdentityRowHessian::new(n, 1);
2139        let ops = vec![op(a), op(b), op(c)];
2140        let compiled = compile(
2141            &ops,
2142            &hess,
2143            &[
2144                BlockOrder::Marginal,
2145                BlockOrder::Logslope,
2146                BlockOrder::LinkDev,
2147            ],
2148        )
2149        .expect("compile should succeed");
2150        let total: usize = compiled.blocks.iter().map(|b| b.t_lw.ncols()).sum();
2151        assert_eq!(
2152            compiled.joint_rank, total,
2153            "audit must report full rank on synthetic full-rank design"
2154        );
2155    }
2156
2157    /// `compile_protected` keeps a rank-deficient protected first block at full
2158    /// raw width (identity V) while the unprotected path drops its null
2159    /// direction, and later blocks still orthogonalise against the full anchor.
2160    /// Mirrors the `compile_from_raw_grams_protected` guard for the operator
2161    /// (per-term) reduction path used by the survival time-wiggle time block.
2162    #[test]
2163    fn compile_protected_keeps_rank_deficient_first_block_full_width() {
2164        let n = 40;
2165        // Block A: two identical columns → structural rank 1 (one within-block
2166        // null the unprotected filter drops).
2167        let a = Array2::from_shape_fn((n, 2), |(i, _)| ((i + 1) as f64 * 0.31).sin());
2168        let b = Array2::from_shape_fn((n, 2), |(i, j)| ((i as f64) * 0.17 + j as f64).cos());
2169        let hess = IdentityRowHessian::new(n, 1);
2170        let ordering = [BlockOrder::Time, BlockOrder::Marginal];
2171
2172        let unprotected = compile(&[op(a.clone()), op(b.clone())], &hess, &ordering)
2173            .expect("unprotected compile");
2174        assert_eq!(
2175            unprotected.blocks[0].t_lw.ncols(),
2176            1,
2177            "unprotected first block drops its duplicate column"
2178        );
2179
2180        let protected = compile_protected(
2181            &[op(a.clone()), op(b.clone())],
2182            &hess,
2183            &ordering,
2184            &[true, false],
2185        )
2186        .expect("protected compile");
2187        let v_a = &protected.blocks[0].t_lw;
2188        assert_eq!(
2189            v_a.ncols(),
2190            2,
2191            "protected first block retains its full raw width"
2192        );
2193        // V_a is the 2×2 identity: raw coords == compiled coords for the
2194        // protected first block.
2195        for i in 0..2 {
2196            for j in 0..2 {
2197                let expect = if i == j { 1.0 } else { 0.0 };
2198                assert!(
2199                    (v_a[[i, j]] - expect).abs() <= 1e-12,
2200                    "protected first block V must be identity, got [{i},{j}]={}",
2201                    v_a[[i, j]]
2202                );
2203            }
2204        }
2205    }
2206
2207    /// §10 test #3: non-identity row Hessian. With K=1 and weights `w`,
2208    /// the projection of a 1-col block `b` onto a 1-col block `a` is
2209    /// `Σ w·a·b / Σ w·a²`. Verify the Gram solve recovers this scalar.
2210    #[test]
2211    fn compile_weighted_metric_nontrivial() {
2212        let n = 32;
2213        let a: Array2<f64> = Array2::from_shape_fn((n, 1), |(i, _)| (i as f64 + 1.0).sqrt());
2214        let b: Array2<f64> =
2215            Array2::from_shape_fn((n, 1), |(i, _)| 0.7 * a[[i, 0]] + (i as f64 * 0.05).cos());
2216        let w = Array1::from_shape_fn(n, |i| 0.5 + (i as f64 * 0.2).sin().abs());
2217        let hess = DiagonalScalarRowHessian::new(w.clone());
2218        let ops = vec![op(a.clone()), op(b.clone())];
2219        let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2220            .expect("compile should succeed");
2221        let m = compiled.blocks[1]
2222            .anchor_correction
2223            .as_ref()
2224            .expect("anchor correction present");
2225        let analytic_num: f64 = (0..n).map(|i| w[i] * a[[i, 0]] * b[[i, 0]]).sum();
2226        let analytic_den: f64 = (0..n).map(|i| w[i] * a[[i, 0]] * a[[i, 0]]).sum();
2227        let analytic = analytic_num / analytic_den;
2228        assert!(m.dim() == (1, 1));
2229        assert!(
2230            (m[[0, 0]] - analytic).abs() < 1e-10,
2231            "weighted projection mismatch: got {got}, analytic {analytic}",
2232            got = m[[0, 0]]
2233        );
2234    }
2235
2236    /// Regression for #372: an anchor block that internally sheds an aliased
2237    /// column makes the residualised kept-anchor width (`anchor_h.ncols()`)
2238    /// strictly smaller than the raw anchor width (`d_total`). The emitted
2239    /// `anchor_correction` must be expressed in *raw* anchor-column
2240    /// coordinates so the predict-time / install-time subtraction
2241    /// `A_raw(x)·M` is dimensionally and metrically correct. Previously the
2242    /// correction was indexed by kept directions, producing a (d_total−1)×k
2243    /// matrix and the failure
2244    /// `anchor_correction shape 36x6 does not match d_total=37`.
2245    #[test]
2246    fn compile_emits_anchor_correction_in_raw_column_coordinates() {
2247        let n = 64;
2248        // Anchor block A has 3 raw columns but only rank 2: col 2 is an exact
2249        // linear combination of cols 0 and 1, so the compiler keeps just two
2250        // anchor directions (kept width 2 < raw width 3).
2251        let a: Array2<f64> = Array2::from_shape_fn((n, 3), |(i, j)| {
2252            let c0 = (i as f64 * 0.07 + 1.0).ln();
2253            let c1 = (i as f64 * 0.13).sin();
2254            match j {
2255                0 => c0,
2256                1 => c1,
2257                _ => 2.0 * c0 - 0.5 * c1,
2258            }
2259        });
2260        // Candidate block C: partly aliases A's span plus genuine signal.
2261        let c: Array2<f64> = Array2::from_shape_fn((n, 2), |(i, j)| {
2262            0.4 * a[[i, 0]] + (j as f64) * (i as f64 * 0.05).cos() + (i as f64 * 0.011).tanh()
2263        });
2264        let w = Array1::from_shape_fn(n, |i| 0.3 + (i as f64 * 0.17).sin().abs());
2265        let hess = DiagonalScalarRowHessian::new(w.clone());
2266        let ops = vec![op(a.clone()), op(c.clone())];
2267        let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::LinkDev])
2268            .expect("compile should succeed");
2269
2270        let v = &compiled.blocks[1].t_lw;
2271        let m = compiled.blocks[1]
2272            .anchor_correction
2273            .as_ref()
2274            .expect("candidate block must carry an anchor correction");
2275        let k_kept = v.ncols();
2276        assert!(k_kept >= 1, "candidate must keep at least one direction");
2277
2278        // The off-by-one the issue tripped on: M must have one row per *raw*
2279        // anchor column (3), not per kept anchor direction (2).
2280        assert_eq!(
2281            m.nrows(),
2282            a.ncols(),
2283            "anchor_correction must be indexed by raw anchor columns (d_total), \
2284             got {} rows for {} raw anchor columns",
2285            m.nrows(),
2286            a.ncols(),
2287        );
2288        assert_eq!(m.ncols(), k_kept, "anchor_correction width must match V");
2289
2290        // Metric correctness: the raw-coordinate subtraction A_raw·M must make
2291        // the compiled candidate design W-orthogonal to the full raw anchor
2292        // span. C̃ = C·V − A·M; require Aᵀ W C̃ ≈ 0 column-wise.
2293        let c_v = c.dot(v);
2294        let a_m = a.dot(m);
2295        let c_tilde = &c_v - &a_m;
2296        let mut max_cross = 0.0_f64;
2297        for ac in 0..a.ncols() {
2298            for cc in 0..c_tilde.ncols() {
2299                let mut acc = 0.0;
2300                for i in 0..n {
2301                    acc += w[i] * a[[i, ac]] * c_tilde[[i, cc]];
2302                }
2303                max_cross = max_cross.max(acc.abs());
2304            }
2305        }
2306        assert!(
2307            max_cross < 1e-9,
2308            "raw-coordinate anchor correction must W-orthogonalise the candidate \
2309             against the raw anchor span; max |Aᵀ W C̃| = {max_cross:e}"
2310        );
2311    }
2312
2313    /// §10 test #4: deliberately rank-deficient joint design. The trailing
2314    /// pivot drop must come from the *latest* block in the ordering.
2315    #[test]
2316    fn compile_drops_trailing_pivots_from_latest_block() {
2317        let n = 40;
2318        let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 + 1.0).ln() * (j as f64 + 1.0));
2319        // c is exactly a's first column → after residualising c against a,
2320        // the residual span is zero in that direction, but a non-zero
2321        // independent column also exists. Add an extra exact-alias column
2322        // to force trailing-pivot drop at the audit stage.
2323        let c = Array2::from_shape_fn((n, 2), |(i, j)| {
2324            if j == 0 {
2325                a[[i, 0]]
2326            } else {
2327                (i as f64 * 0.1).cos()
2328            }
2329        });
2330        let hess = IdentityRowHessian::new(n, 1);
2331        let ops = vec![op(a), op(c)];
2332        // Manually inject a known alias: pass a second block whose
2333        // residualised columns will themselves be linearly dependent on
2334        // the first block after metric projection — already covered by the
2335        // eigenvalue threshold inside `compile`. Verify either drop path
2336        // (eigen-threshold or audit) attributes loss to block index 1.
2337        let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2338            .expect("compile should succeed");
2339        // Either the eigen-threshold dropped a column from block 1, or
2340        // the audit did. In both cases block 1's V must have fewer than
2341        // its 2 input columns.
2342        let v1_cols = compiled.blocks[1].t_lw.ncols();
2343        assert!(
2344            v1_cols < 2 || !compiled.dropped.is_empty(),
2345            "expected rank loss attributed to block 1, got v1_cols={v1_cols}, dropped={dropped:?}",
2346            dropped = compiled.dropped
2347        );
2348        for (block_idx, _) in &compiled.dropped {
2349            assert_eq!(
2350                *block_idx, 1,
2351                "audit drops must come from the latest block only"
2352            );
2353        }
2354    }
2355
2356    /// Regression: when `audit_and_drop_trailing_pivots` truncates the
2357    /// latest block's `t_lw`, the sibling `anchor_correction` and `r_lw`
2358    /// matrices must be truncated to the same `k_kept` so the trailing-
2359    /// block install path sees a coherent
2360    /// `t_lw.ncols() == anchor_correction.ncols() == r_lw.ncols()` shape.
2361    ///
2362    /// Pre-fix bug: only `t_lw` got truncated. Downstream callers
2363    /// asserting `anchor_correction.ncols() == k_kept` then failed with
2364    /// `cross-block identifiability: anchor_correction shape D×P does
2365    /// not match expected d_total=D × k_kept=K` — surfaced via the
2366    /// large-scale V+M repro test.
2367    #[test]
2368    fn audit_truncation_keeps_t_lw_and_anchor_correction_in_lockstep() {
2369        let n = 40;
2370        let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 + 1.0).ln() * (j as f64 + 1.0));
2371        let c = Array2::from_shape_fn((n, 2), |(i, j)| {
2372            if j == 0 {
2373                a[[i, 0]]
2374            } else {
2375                (i as f64 * 0.1).cos()
2376            }
2377        });
2378        let hess = IdentityRowHessian::new(n, 1);
2379        let ops = vec![op(a), op(c)];
2380        let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2381            .expect("compile should succeed");
2382        for (idx, block) in compiled.blocks.iter().enumerate() {
2383            let k_kept = block.t_lw.ncols();
2384            if let Some(m) = block.anchor_correction.as_ref() {
2385                assert_eq!(
2386                    m.ncols(),
2387                    k_kept,
2388                    "block {idx}: anchor_correction.ncols()={ac} must equal t_lw.ncols()={k_kept} \
2389                     after audit truncation",
2390                    ac = m.ncols(),
2391                );
2392            }
2393            if let Some(r) = block.r_lw.as_ref() {
2394                assert_eq!(
2395                    r.ncols(),
2396                    k_kept,
2397                    "block {idx}: r_lw.ncols()={r_cols} must equal t_lw.ncols()={k_kept} \
2398                     after audit truncation",
2399                    r_cols = r.ncols(),
2400                );
2401            }
2402        }
2403    }
2404
2405    /// §10 test #5: regression test for the deleted FlexEvaluation skip
2406    /// bug. A flex anchor (represented by a dense scalar operator with the
2407    /// same column span as the parametric reference) must receive the same
2408    /// residualisation as the parametric anchor.
2409    #[test]
2410    fn compile_flex_anchor_is_first_class() {
2411        let n = 60;
2412        // Two parametric blocks A, B; a third "flex" block C whose
2413        // operator is dense (modelling a compiled flex anchor's column
2414        // span). All-parametric reference vs. mixed parametric+flex must
2415        // produce identical compiled blocks B (residualised against A)
2416        // because the compiler treats every input as a `RowJacobianOperator`.
2417        let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.07 + j as f64).sin());
2418        let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2419            0.4 * a[[i, 0]] + (j as f64) * (i as f64 + 1.0).ln()
2420        });
2421        let hess = IdentityRowHessian::new(n, 1);
2422
2423        let ops_param = vec![op(a.clone()), op(b.clone())];
2424        let compiled_param = compile(
2425            &ops_param,
2426            &hess,
2427            &[BlockOrder::Marginal, BlockOrder::Logslope],
2428        )
2429        .expect("compile should succeed");
2430
2431        // Now wrap A's design behind a mock anchor evaluator and feed it
2432        // to the compiler as a `DenseScalarOperator` with the same span.
2433        // The B-block result must match the parametric reference.
2434        let ops_flex = vec![op(a.clone()), op(b.clone())];
2435        let compiled_flex = compile(
2436            &ops_flex,
2437            &hess,
2438            &[BlockOrder::ScoreWarp, BlockOrder::LinkDev],
2439        )
2440        .expect("compile should succeed");
2441
2442        let m_param = compiled_param.blocks[1].anchor_correction.as_ref().unwrap();
2443        let m_flex = compiled_flex.blocks[1].anchor_correction.as_ref().unwrap();
2444        assert_eq!(m_param.dim(), m_flex.dim());
2445        let max_diff = (m_param - m_flex)
2446            .iter()
2447            .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2448        assert!(
2449            max_diff < 1e-12,
2450            "flex vs parametric anchor correction mismatch: {max_diff:e}"
2451        );
2452    }
2453
2454    /// §10 test #7: Bernoulli row Hessian = IRLS weight. Verified at the
2455    /// trait level — a `DiagonalScalarRowHessian` round-trips through
2456    /// `evaluate_full` to the same per-row scalar.
2457    #[test]
2458    fn bernoulli_row_hessian_matches_irls_weight() {
2459        let w = Array1::from(vec![0.1, 0.5, 0.9, 0.25, 0.75]);
2460        let hess = DiagonalScalarRowHessian::new(w.clone());
2461        let full = hess.evaluate_full();
2462        assert_eq!(full.shape(), &[5, 1, 1]);
2463        for i in 0..5 {
2464            assert_eq!(full[[i, 0, 0]], w[i]);
2465            let mut buf = [0.0_f64; 1];
2466            hess.fill_row(i, &mut buf);
2467            assert_eq!(buf[0], w[i]);
2468        }
2469    }
2470
2471    /// §10 test #8: predict-path roundtrip. With the parametric setting,
2472    /// the row-application of `(C(x)·V − A(x)·M)` at training rows must
2473    /// equal the in-metric residual computed during `compile`.
2474    #[test]
2475    fn compiler_predict_path_roundtrip() {
2476        let n = 24;
2477        let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.21).cos() + j as f64);
2478        let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2479            0.3 * a[[i, 0]] + (i as f64 + j as f64).sqrt()
2480        });
2481        let hess = IdentityRowHessian::new(n, 1);
2482        let ops = vec![op(a.clone()), op(b.clone())];
2483        let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2484            .expect("compile should succeed");
2485        let v_b = &compiled.blocks[1].t_lw;
2486        let m_b = compiled.blocks[1].anchor_correction.as_ref().unwrap();
2487        // Training-time residual: B · V − A · M.
2488        let predict_design = b.dot(v_b) - a.dot(m_b);
2489        // Compare to the algebraic in-metric residual: same expression
2490        // (identity row Hessian collapses sqrt(H) = I), so this is a
2491        // self-consistency / shape check ensuring V and M compose to the
2492        // promised predict-time operator.
2493        assert_eq!(predict_design.nrows(), n);
2494        assert_eq!(predict_design.ncols(), v_b.ncols());
2495        // Finite-value gate.
2496        for &val in predict_design.iter() {
2497            assert!(val.is_finite(), "predict design produced non-finite entry");
2498        }
2499    }
2500
2501    /// `r_lw` and `anchor_correction` are populated on every non-first
2502    /// block as `M_b · V_b` at compiled width. The first block carries
2503    /// `None`. Also verifies the H-orthogonality invariant that the
2504    /// cumulative anchor for the next iteration is orthogonal (in the row
2505    /// metric) to the prior block's design.
2506    #[test]
2507    fn compile_exposes_r_lw_equal_to_m_dot_v() {
2508        let n = 40;
2509        let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.17 + j as f64).sin());
2510        // B partially aliases A's first column, so anchor correction is non-trivial.
2511        let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2512            0.6 * a[[i, 0]] + ((i as f64) * 0.11 + j as f64).cos()
2513        });
2514        let hess = IdentityRowHessian::new(n, 1);
2515        let ops = vec![op(a.clone()), op(b.clone())];
2516        let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2517            .expect("compile should succeed");
2518
2519        // First block: no anchor → both fields None.
2520        assert!(compiled.blocks[0].r_lw.is_none());
2521        assert!(compiled.blocks[0].anchor_correction.is_none());
2522
2523        // Second block: r_lw and anchor_correction must both equal M·V at
2524        // compiled width (p_a_kept × p_b_kept).
2525        let v_a = &compiled.blocks[0].t_lw;
2526        let v_b = &compiled.blocks[1].t_lw;
2527        let m_compiled = compiled.blocks[1]
2528            .anchor_correction
2529            .as_ref()
2530            .expect("second block must carry an anchor correction");
2531        let r_lw = compiled.blocks[1]
2532            .r_lw
2533            .as_ref()
2534            .expect("second block must expose r_lw");
2535        let p_a_kept = v_a.ncols();
2536        let p_b_kept = v_b.ncols();
2537        assert_eq!(
2538            m_compiled.dim(),
2539            (p_a_kept, p_b_kept),
2540            "anchor_correction must be at compiled width"
2541        );
2542        assert_eq!(r_lw.dim(), (p_a_kept, p_b_kept));
2543        // r_lw and anchor_correction are synonymous.
2544        let diff = r_lw - m_compiled;
2545        let max_diff = diff.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
2546        assert!(
2547            max_diff == 0.0,
2548            "r_lw and anchor_correction must be identical"
2549        );
2550
2551        // H-orthogonality (identity row metric): the residualised
2552        // compiled B-design `B·V − A·(M·V)` must be orthogonal to A in
2553        // the column-inner-product sense. This validates that the
2554        // cumulative anchor build uses `(W_b − A·M)·V` rather than `W_b·V`.
2555        let b_compiled = b.dot(v_b) - a.dot(m_compiled);
2556        let cross = a.t().dot(&b_compiled);
2557        let max_cross = cross.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
2558        assert!(
2559            max_cross < 1e-10,
2560            "compiled B-design must be H-orthogonal to A: max cross = {max_cross:e}"
2561        );
2562    }
2563
2564    /// `K=4` dense row Hessian: per-row PSD matrix supplied directly.
2565    struct DenseRowHessian {
2566        h: Array3<f64>,
2567    }
2568
2569    impl RowHessian for DenseRowHessian {
2570        fn k(&self) -> usize {
2571            self.h.shape()[1]
2572        }
2573        fn nrows(&self) -> usize {
2574            self.h.shape()[0]
2575        }
2576        fn fill_row(&self, row: usize, out: &mut [f64]) {
2577            let k = self.k();
2578            assert_eq!(out.len(), k * k);
2579            for c in 0..k {
2580                for d in 0..k {
2581                    out[c * k + d] = self.h[[row, c, d]];
2582                }
2583            }
2584        }
2585        fn evaluate_full(&self) -> Array3<f64> {
2586            self.h.clone()
2587        }
2588    }
2589
2590    /// Reference W-based Gram for verification: build `W = sqrt(H) · J` then
2591    /// return `Wᵀ W`. Mirrors the in-walk path in [`compile`].
2592    fn reference_gram_from_w(j_full: &Array3<f64>, h_full: &Array3<f64>) -> Array2<f64> {
2593        let w = scale_block_by_sqrt_h(j_full, h_full);
2594        fast_ata(&w)
2595    }
2596
2597    /// Two-block toy at K=4: build per-channel (n × p_b) blocks and verify
2598    /// the closed-form Gram matches the reference W-based Gram.
2599    #[test]
2600    fn closed_form_gram_matches_reference_two_block_k4() {
2601        let n = 17;
2602        let k = 4;
2603        let p_a = 3;
2604        let p_b = 2;
2605
2606        // Random-ish per-channel design matrices for each block.
2607        let make_block = |seed: f64, n: usize, p: usize| -> Vec<Option<Array2<f64>>> {
2608            (0..4)
2609                .map(|c| {
2610                    let m = Array2::from_shape_fn((n, p), |(i, j)| {
2611                        ((i as f64 + 1.0) * (j as f64 + 1.0) * (c as f64 + 1.0) + seed).sin()
2612                    });
2613                    Some(m)
2614                })
2615                .collect()
2616        };
2617        let block_a = make_block(0.3, n, p_a);
2618        let block_b = make_block(1.1, n, p_b);
2619
2620        // Per-row PSD H: random symmetric PSD via Mᵀ M.
2621        let h = Array3::from_shape_fn((n, k, k), |(i, c, d)| {
2622            let mut acc = 0.0;
2623            for r in 0..k {
2624                let mc = ((i + 1) as f64 * (c + 1) as f64 * (r + 1) as f64 * 0.13).cos();
2625                let md = ((i + 1) as f64 * (d + 1) as f64 * (r + 1) as f64 * 0.13).cos();
2626                acc += mc * md;
2627            }
2628            acc + if c == d { 0.5 } else { 0.0 }
2629        });
2630        let row_hess = DenseRowHessian { h: h.clone() };
2631
2632        let channel_blocks = PrimaryChannelBlocks {
2633            blocks: vec![block_a.clone(), block_b.clone()],
2634        };
2635        let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
2636
2637        let gram = build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges)
2638            .expect("closed-form Gram should succeed");
2639
2640        // Reference: assemble full row Jacobian J as (n × p_total × K) by
2641        // placing per-block, per-channel slices at the right columns.
2642        let p_total = p_a + p_b;
2643        let mut j_full = Array3::<f64>::zeros((n, p_total, k));
2644        for c in 0..k {
2645            if let Some(xa) = block_a[c].as_ref() {
2646                for i in 0..n {
2647                    for j in 0..p_a {
2648                        j_full[[i, j, c]] = xa[[i, j]];
2649                    }
2650                }
2651            }
2652            if let Some(xb) = block_b[c].as_ref() {
2653                for i in 0..n {
2654                    for j in 0..p_b {
2655                        j_full[[i, p_a + j, c]] = xb[[i, j]];
2656                    }
2657                }
2658            }
2659        }
2660        let ref_gram = reference_gram_from_w(&j_full, &h);
2661
2662        let diff = &gram - &ref_gram;
2663        let max_err = diff.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2664        let scale = ref_gram.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2665        assert!(
2666            max_err < 1e-9 * scale.max(1.0),
2667            "closed-form Gram mismatches reference: max_err={max_err:e}, scale={scale:e}"
2668        );
2669
2670        // Symmetry of the result.
2671        for i in 0..p_total {
2672            for j in 0..p_total {
2673                assert!(
2674                    (gram[[i, j]] - gram[[j, i]]).abs() < 1e-12,
2675                    "closed-form Gram not symmetric at ({i},{j})"
2676                );
2677            }
2678        }
2679    }
2680
2681    /// Channel sparsity test: block A contributes only to channel 0, block B
2682    /// only to channel 3. Cross-block contribution must be exactly
2683    /// `(X_A^(0))ᵀ · diag(h_{03}) · X_B^(3)` — zero when `h_03 ≡ 0`,
2684    /// non-zero otherwise.
2685    #[test]
2686    fn closed_form_gram_channel_sparsity() {
2687        let n = 13;
2688        let k = 4;
2689        let p_a = 2;
2690        let p_b = 2;
2691
2692        let xa = Array2::from_shape_fn((n, p_a), |(i, j)| ((i + 1) as f64 * 0.21 + j as f64).cos());
2693        let xb = Array2::from_shape_fn((n, p_b), |(i, j)| {
2694            ((i + 1) as f64 * 0.17 + j as f64).sin() + 0.5
2695        });
2696
2697        let block_a: Vec<Option<Array2<f64>>> = vec![Some(xa.clone()), None, None, None];
2698        let block_b: Vec<Option<Array2<f64>>> = vec![None, None, None, Some(xb.clone())];
2699
2700        // Case 1: H with non-zero h_{03} (and h_{30}). The cross-block
2701        // (A, B) entries must equal `Xaᵀ · diag(h_03) · Xb`.
2702        let h_03_vec = Array1::from_shape_fn(n, |i| 0.7 + 0.3 * ((i as f64) * 0.4).sin());
2703        let h = Array3::from_shape_fn((n, k, k), |(i, c, d)| {
2704            // Symmetric: only the (0,3)/(3,0) off-diagonal carries weight,
2705            // plus a strong PSD diagonal so per-row H is PSD.
2706            if (c, d) == (0, 3) || (c, d) == (3, 0) {
2707                h_03_vec[i]
2708            } else if c == d {
2709                2.0
2710            } else {
2711                0.0
2712            }
2713        });
2714        let row_hess = DenseRowHessian { h: h.clone() };
2715
2716        let channel_blocks = PrimaryChannelBlocks {
2717            blocks: vec![block_a.clone(), block_b.clone()],
2718        };
2719        let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
2720        let gram = build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges)
2721            .expect("closed-form Gram should succeed");
2722
2723        // Cross-block submatrix.
2724        let cross = gram.slice(s![0..p_a, p_a..(p_a + p_b)]).to_owned();
2725        // Expected: only the (c=0, d=3) channel-pair survives.
2726        let expected = fast_xt_diag_y(&xa, &h_03_vec, &xb);
2727        let diff = &cross - &expected;
2728        let max_err = diff.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2729        assert!(
2730            max_err < 1e-12,
2731            "cross-block Gram must equal Xaᵀ·diag(h_03)·Xb: max_err={max_err:e}"
2732        );
2733
2734        // Case 2: zero out h_{03} → cross-block must be zero.
2735        let h_zero = Array3::from_shape_fn((n, k, k), |(_, c, d)| if c == d { 2.0 } else { 0.0 });
2736        let row_hess_zero = DenseRowHessian { h: h_zero };
2737        let gram_zero =
2738            build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess_zero, &raw_ranges)
2739                .expect("closed-form Gram should succeed");
2740        let cross_zero = gram_zero.slice(s![0..p_a, p_a..(p_a + p_b)]);
2741        let max_zero = cross_zero.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2742        assert!(
2743            max_zero < 1e-12,
2744            "cross-block Gram must vanish when coupling channel pair is zero: got {max_zero:e}"
2745        );
2746    }
2747
2748    /// Structural Gram: identity per-row Hessian collapses the channel-pair
2749    /// sum to within-channel `XᵀX`. Validates [`build_raw_grams_structural`].
2750    #[test]
2751    fn structural_gram_matches_within_channel_sum() {
2752        let n = 11;
2753        let p_a = 2;
2754        let p_b = 3;
2755        let make_block = |seed: f64, n: usize, p: usize| -> Vec<Option<Array2<f64>>> {
2756            (0..4)
2757                .map(|c| {
2758                    if c == 1 {
2759                        // Sparse channel for variety.
2760                        return None;
2761                    }
2762                    Some(Array2::from_shape_fn((n, p), |(i, j)| {
2763                        ((i as f64 + 1.0) * (j as f64 + 1.0) + seed * (c as f64 + 1.0)).sin()
2764                    }))
2765                })
2766                .collect()
2767        };
2768        let block_a = make_block(0.1, n, p_a);
2769        let block_b = make_block(0.7, n, p_b);
2770        let channel_blocks = PrimaryChannelBlocks {
2771            blocks: vec![block_a.clone(), block_b.clone()],
2772        };
2773        let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
2774        let gram = build_raw_grams_structural(&channel_blocks, &raw_ranges);
2775
2776        // Hand-compute cross block: Σ_c Xaᵀ Xb over channels where both
2777        // sides are present (skipping channel 1 entirely).
2778        let mut expected_cross = Array2::<f64>::zeros((p_a, p_b));
2779        for c in 0..4 {
2780            if let (Some(xa), Some(xb)) = (block_a[c].as_ref(), block_b[c].as_ref()) {
2781                expected_cross += &fast_atb(xa, xb);
2782            }
2783        }
2784        let cross = gram.slice(s![0..p_a, p_a..(p_a + p_b)]).to_owned();
2785        let diff = &cross - &expected_cross;
2786        let max_err = diff.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2787        assert!(
2788            max_err < 1e-12,
2789            "structural cross-block must equal Σ_c Xaᵀ·Xb: max_err={max_err:e}"
2790        );
2791
2792        // Symmetry.
2793        for i in 0..(p_a + p_b) {
2794            for j in 0..(p_a + p_b) {
2795                assert!(
2796                    (gram[[i, j]] - gram[[j, i]]).abs() < 1e-12,
2797                    "structural Gram not symmetric at ({i},{j})"
2798                );
2799            }
2800        }
2801    }
2802
2803    // Per-row Hessian (K=1) sourced from an arbitrary positive vector —
2804    // used by the dual-metric sanity test to drive both structural and
2805    // curvature passes with the *same* non-identity weights.
2806    fn diag_hess(w: Array1<f64>) -> DiagonalScalarRowHessian {
2807        DiagonalScalarRowHessian::new(w)
2808    }
2809
2810    /// L#1: dual-metric with structural = curvature reproduces single-metric
2811    /// `compile()` exactly. The two passes degenerate to one because the
2812    /// structural-anchor and curvature-anchor are the same matrix.
2813    #[test]
2814    fn dual_metric_with_equal_metrics_matches_single_metric() {
2815        let n = 36;
2816        let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.13 + j as f64).sin());
2817        // B partially aliases A's first column.
2818        let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2819            0.4 * a[[i, 0]] + (i as f64 * 0.07 + j as f64).cos()
2820        });
2821        let w = Array1::from_shape_fn(n, |i| 0.5 + (i as f64 * 0.17).sin().abs());
2822        let curvature = diag_hess(w.clone());
2823        let ordering = [BlockOrder::Marginal, BlockOrder::Logslope];
2824
2825        let ops_single = vec![op(a.clone()), op(b.clone())];
2826        let single = compile(&ops_single, &curvature, &ordering)
2827            .expect("single-metric compile should succeed");
2828
2829        // Dual-metric with structural = curvature (same `RowHessian` on both
2830        // sides). The structural pass collapses to the curvature pass.
2831        let structural_same = diag_hess(w.clone());
2832        let ops_dual = vec![op(a.clone()), op(b.clone())];
2833        let dual = compile_with_dual_metric(&ops_dual, &curvature, &structural_same, &ordering)
2834            .expect("dual-metric compile should succeed");
2835
2836        assert_eq!(single.blocks.len(), dual.blocks.len());
2837        for (idx, (sb, db)) in single.blocks.iter().zip(dual.blocks.iter()).enumerate() {
2838            assert_eq!(sb.t_lw.dim(), db.t_lw.dim(), "block {idx}: V dims differ");
2839            let max_v = (&sb.t_lw - &db.t_lw)
2840                .iter()
2841                .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2842            assert!(max_v < 1e-10, "block {idx}: V mismatch {max_v:e}");
2843            match (sb.anchor_correction.as_ref(), db.anchor_correction.as_ref()) {
2844                (None, None) => {}
2845                (Some(s), Some(d)) => {
2846                    assert_eq!(s.dim(), d.dim());
2847                    let max_m = (s - d).iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2848                    assert!(max_m < 1e-10, "block {idx}: M mismatch {max_m:e}");
2849                }
2850                _ => panic!("block {idx}: one side has anchor correction, the other does not"),
2851            }
2852        }
2853        assert_eq!(single.joint_rank, dual.joint_rank);
2854    }
2855
2856    /// L#2: the pilot-curvature trap. A 2-block toy where the pilot
2857    /// curvature `H` has a zero direction that is NOT a real gauge — the
2858    /// dual-metric path keeps it (identity-structural sees it as a full-
2859    /// rank structural direction), while a single-metric path through the
2860    /// same H would drop it.
2861    ///
2862    /// Construction: two K=1 blocks `A` (n × 1) and `B` (n × 1). Choose H
2863    /// (diagonal row weights) so that `H · B` happens to be a scalar
2864    /// multiple of `H · A` (curvature alias) but `B` is *not* a scalar
2865    /// multiple of `A` in the unweighted metric. Specifically, pick rows
2866    /// where `w_i` is non-zero only on a handful of rows where A and B
2867    /// happen to be proportional, and zero on the rows where they differ.
2868    /// Under identity-structural this is structurally-independent; under H
2869    /// it is a (spurious) curvature alias.
2870    #[test]
2871    fn dual_metric_resists_pilot_curvature_alias() {
2872        let n = 12;
2873        // A: x_i = i+1 (no zeros). B: equals 2·A on rows 0..6 only; the
2874        // remaining rows are uncorrelated (linear vs trigonometric).
2875        let a = Array2::from_shape_fn((n, 1), |(i, _)| (i as f64) + 1.0);
2876        let b = Array2::from_shape_fn((n, 1), |(i, _)| {
2877            if i < 6 {
2878                2.0 * a[[i, 0]]
2879            } else {
2880                ((i as f64) * 0.3).cos() + 0.5
2881            }
2882        });
2883
2884        // Curvature weights are non-zero ONLY on the rows where B == 2A.
2885        // Under curvature metric, B is exactly 2·A → curvature-rank drops
2886        // B fully. Under identity-structural, B is independent of A across
2887        // all rows → structural-rank is 1 (kept).
2888        let mut w_vec = vec![0.0_f64; n];
2889        for w in &mut w_vec[..6] {
2890            *w = 1.0;
2891        }
2892        let w = Array1::from(w_vec);
2893        let curvature = diag_hess(w.clone());
2894
2895        // Reference single-metric compile (uses identity by `compile()` —
2896        // which now routes through identity-structural). For this test we
2897        // explicitly invoke the dual-metric API both ways.
2898        let id_struct = IdentityRowHessian::new(n, 1);
2899        let ordering = [BlockOrder::Marginal, BlockOrder::Logslope];
2900
2901        // Path 1: dual-metric with identity-structural (the new default).
2902        // Structural pass: B is independent of A across all rows → keep
2903        // B's single column.
2904        let ops_dual = vec![op(a.clone()), op(b.clone())];
2905        let dual = compile_with_dual_metric(&ops_dual, &curvature, &id_struct, &ordering);
2906
2907        // Path 2: dual-metric with structural = curvature (the "H decides
2908        // everything" trap). On the curvature-only rows, B ≡ 2A, so
2909        // structural pass sees zero residual span and rejects the block.
2910        let ops_h_only = vec![op(a.clone()), op(b.clone())];
2911        let h_only = compile_with_dual_metric(&ops_h_only, &curvature, &curvature, &ordering);
2912
2913        // The H-only path must fail (FullyAliased) or strip B's column.
2914        // The dual (identity-structural) path must keep B.
2915        match h_only {
2916            Err(CompilerError::FullyAliased { block_idx, .. }) => {
2917                assert_eq!(block_idx, 1, "H-only path must alias block 1");
2918            }
2919            Ok(out) => {
2920                // If the H-only path somehow compiled, it must have
2921                // either dropped B's column to zero width or audited it
2922                // out. Either way B's V must be empty after the audit
2923                // attributes the drop.
2924                let v1_cols = out.blocks[1].t_lw.ncols();
2925                assert!(
2926                    v1_cols == 0 || !out.dropped.is_empty(),
2927                    "H-only path should reject B's curvature-aliased column; v1_cols={v1_cols}, dropped={dropped:?}",
2928                    dropped = out.dropped,
2929                );
2930            }
2931            Err(other) => panic!("unexpected H-only error: {other:?}"),
2932        }
2933
2934        let dual =
2935            dual.expect("dual-metric must succeed: identity-structural sees B as independent");
2936        // The dual path may still drop B's column at the joint audit step
2937        // because the joint H-scaled design is rank-1 (only the first
2938        // block contributes non-zero rows under the curvature weights).
2939        // What matters is that the *structural* decision did NOT drop B
2940        // — verified by the structural pass not raising FullyAliased and
2941        // by B's `t_lw` having the full structural width before the audit
2942        // demotes it. After audit, B's V may shrink because the curvature
2943        // joint design is rank-deficient, and that is expected.
2944        assert_eq!(dual.blocks.len(), 2);
2945        assert_eq!(dual.blocks[0].t_lw.ncols(), 1, "A must keep its column");
2946        // Block 1 either keeps its structural rank-1 column or is audited
2947        // away by the joint H-rank check, but in either case the per-block
2948        // pre-audit width must reflect that the structural pass kept the
2949        // column (i.e. the function did not return FullyAliased).
2950        let v1_post_audit = dual.blocks[1].t_lw.ncols();
2951        let dropped_count = dual.dropped.len();
2952        assert_eq!(
2953            v1_post_audit + dropped_count,
2954            1,
2955            "structural pass kept B's column; audit may demote it but the pre-audit width was 1"
2956        );
2957    }
2958
2959    /// L#3: identity-structural lets the compiler keep a direction even
2960    /// when the pilot curvature has reduced rank. This is the same
2961    /// scenario as L#2 but with a curvature `H` whose row weights are all
2962    /// strictly positive — so the *only* aliasing source is the structural
2963    /// pass deciding to keep or drop. The dual-metric path with non-trivial
2964    /// `H` and identity-structural must agree with the dual-metric path
2965    /// with identity on both sides whenever the blocks are structurally
2966    /// non-aliased.
2967    #[test]
2968    fn dual_metric_identity_structural_preserves_full_rank() {
2969        let n = 24;
2970        let a = Array2::from_shape_fn((n, 2), |(i, j)| ((i + 1) as f64 + j as f64).sqrt());
2971        let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2972            ((i + 1) as f64).ln() + (i as f64 * 0.1 + j as f64).cos()
2973        });
2974        let w = Array1::from_shape_fn(n, |i| 0.4 + (i as f64 * 0.05).sin().powi(2));
2975        let curvature = diag_hess(w.clone());
2976        let id_struct = IdentityRowHessian::new(n, 1);
2977        let ordering = [BlockOrder::Marginal, BlockOrder::Logslope];
2978
2979        let ops = vec![op(a.clone()), op(b.clone())];
2980        let out =
2981            compile_with_dual_metric(&ops, &curvature, &id_struct, &ordering).expect("compile");
2982        // Both blocks structurally independent → both keep full width.
2983        assert_eq!(out.blocks[0].t_lw.ncols(), 2);
2984        assert_eq!(out.blocks[1].t_lw.ncols(), 2);
2985        assert_eq!(out.dropped.len(), 0);
2986        assert_eq!(out.joint_rank, 4);
2987    }
2988
2989    /// Smoke test for the GPU-or-CPU dispatch helper. On non-CUDA hosts
2990    /// (or when the runtime is unavailable) the helper falls back to the
2991    /// CPU closed-form builders; the result must match the CPU builders
2992    /// called directly. When a CUDA runtime is live, parity vs. CPU is
2993    /// verified to tight tolerance.
2994    #[test]
2995    fn build_primary_grams_gpu_or_cpu_two_block_k4_matches_cpu() {
2996        let n = 11;
2997        let k = 4;
2998        let p_a = 2;
2999        let p_b = 3;
3000
3001        let make_block = |seed: f64, n: usize, p: usize| -> Vec<Option<Array2<f64>>> {
3002            (0..4)
3003                .map(|c| {
3004                    let m = Array2::from_shape_fn((n, p), |(i, j)| {
3005                        ((i as f64 + 1.0) * (j as f64 + 1.0) * (c as f64 + 1.0) + seed).sin()
3006                    });
3007                    Some(m)
3008                })
3009                .collect()
3010        };
3011        let block_a = make_block(0.7, n, p_a);
3012        let block_b = make_block(-0.4, n, p_b);
3013
3014        let h = Array3::from_shape_fn((n, k, k), |(i, c, d)| {
3015            let mut acc = 0.0;
3016            for r in 0..k {
3017                let mc = ((i + 1) as f64 * (c + 1) as f64 * (r + 1) as f64 * 0.11).cos();
3018                let md = ((i + 1) as f64 * (d + 1) as f64 * (r + 1) as f64 * 0.11).cos();
3019                acc += mc * md;
3020            }
3021            acc + if c == d { 0.25 } else { 0.0 }
3022        });
3023        let row_hess = DenseRowHessian { h: h.clone() };
3024
3025        let channel_blocks = PrimaryChannelBlocks {
3026            blocks: vec![block_a, block_b],
3027        };
3028        let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
3029
3030        let (gram_h, gram_struct) =
3031            build_primary_grams_gpu_or_cpu(&channel_blocks, &row_hess, &raw_ranges)
3032                .expect("dispatch helper should succeed");
3033
3034        let cpu_h = build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges)
3035            .expect("CPU curvature Gram should succeed");
3036        let cpu_s = build_raw_grams_structural(&channel_blocks, &raw_ranges);
3037
3038        let tol = 1e-9_f64;
3039        for idx in cpu_h.indexed_iter().map(|(i, _)| i) {
3040            let diff = (gram_h[idx] - cpu_h[idx]).abs();
3041            let scale = cpu_h[idx].abs().max(1.0);
3042            assert!(
3043                diff <= tol * scale,
3044                "gram_h mismatch at {idx:?}: helper={} cpu={}",
3045                gram_h[idx],
3046                cpu_h[idx]
3047            );
3048        }
3049        for idx in cpu_s.indexed_iter().map(|(i, _)| i) {
3050            let diff = (gram_struct[idx] - cpu_s[idx]).abs();
3051            let scale = cpu_s[idx].abs().max(1.0);
3052            assert!(
3053                diff <= tol * scale,
3054                "gram_struct mismatch at {idx:?}: helper={} cpu={}",
3055                gram_struct[idx],
3056                cpu_s[idx]
3057            );
3058        }
3059    }
3060
3061    // ---- compile_from_raw_grams tests ----
3062
3063    /// Build (gram_h, gram_struct) for a K=1 scalar two-block toy via the
3064    /// per-block channel-block builders. Used by the closed-form tests
3065    /// below.
3066    fn scalar_grams_two_block(
3067        a: &Array2<f64>,
3068        b: &Array2<f64>,
3069        w: &Array1<f64>,
3070    ) -> (Array2<f64>, Array2<f64>, Vec<std::ops::Range<usize>>) {
3071        let p_a = a.ncols();
3072        let p_b = b.ncols();
3073        let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
3074        let channel_blocks = PrimaryChannelBlocks {
3075            blocks: vec![vec![Some(a.clone())], vec![Some(b.clone())]],
3076        };
3077        let row_hess = DiagonalScalarRowHessian::new(w.clone());
3078        let gram_h =
3079            build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges).unwrap();
3080        let gram_struct = build_raw_grams_structural(&channel_blocks, &raw_ranges);
3081        (gram_h, gram_struct, raw_ranges)
3082    }
3083
3084    /// Block B is a column-duplicate of block A in the structural metric
3085    /// → the lower-priority block compiles to zero width instead of making
3086    /// callers skip reduced-coordinate construction.
3087    #[test]
3088    fn compile_from_raw_grams_full_structural_alias() {
3089        let n = 10;
3090        let a = Array2::from_shape_fn((n, 2), |(i, j)| ((i + 1) as f64 * (j + 1) as f64).sin());
3091        // Block B = A · L for some 2×2 invertible L → same column span.
3092        let l = Array2::from_shape_vec((2, 2), vec![1.0, 0.5, -0.25, 1.0]).unwrap();
3093        let b = a.dot(&l);
3094        let w = Array1::ones(n);
3095        let (gram_h, gram_struct, raw_ranges) = scalar_grams_two_block(&a, &b, &w);
3096        let res = compile_from_raw_grams(
3097            &gram_h,
3098            &gram_struct,
3099            &raw_ranges,
3100            &[BlockOrder::Marginal, BlockOrder::Logslope],
3101        )
3102        .expect("lower-priority full alias should compile to zero width");
3103        assert_eq!(res.compiled_block_ranges[0].len(), 2);
3104        assert_eq!(res.compiled_block_ranges[1].len(), 0);
3105        assert_eq!(res.raw_from_compiled.dim(), (4, 2));
3106        assert!(
3107            res.raw_from_compiled
3108                .slice(s![raw_ranges[1].clone(), ..])
3109                .iter()
3110                .all(|v| v.abs() <= 1.0e-12),
3111            "zero-width block must not retain raw coefficient directions in T"
3112        );
3113    }
3114
3115    /// A zero-width *first* block has no columns to alias and must compile to
3116    /// an empty range with the remaining blocks intact — not abort with
3117    /// `FullyAliased`. Regression for the survival location-scale lognormal AFT
3118    /// pre-fit channel-aware audit, whose `time_transform` block collapses to
3119    /// zero free coefficients under the parametric AFT reduction and previously
3120    /// crashed the fit ("block of width 0 has zero structural span").
3121    #[test]
3122    fn compile_from_raw_grams_zero_width_first_block_is_identifiable() {
3123        let n = 12;
3124        let empty = Array2::<f64>::zeros((n, 0));
3125        let b = Array2::from_shape_fn((n, 2), |(i, j)| {
3126            ((i + 1) as f64 * (j + 1) as f64 * 0.23).cos()
3127        });
3128        let w = Array1::ones(n);
3129        let (gram_h, gram_struct, raw_ranges) = scalar_grams_two_block(&empty, &b, &w);
3130        let map = compile_from_raw_grams(
3131            &gram_h,
3132            &gram_struct,
3133            &raw_ranges,
3134            &[BlockOrder::Marginal, BlockOrder::Logslope],
3135        )
3136        .expect("zero-width first block must be trivially identifiable, not FullyAliased");
3137        assert_eq!(
3138            map.compiled_block_ranges[0].len(),
3139            0,
3140            "empty first block keeps zero columns"
3141        );
3142        assert_eq!(
3143            map.compiled_block_ranges[1].len(),
3144            2,
3145            "the second block keeps its full structural rank"
3146        );
3147        assert_eq!(map.raw_from_compiled.dim(), (2, 2));
3148    }
3149
3150    /// A `protected` first block keeps every raw column even when it is
3151    /// internally rank-deficient (a duplicate-column structural null that the
3152    /// unprotected path drops), and later blocks still reduce against the full
3153    /// raw anchor. Regression for the survival marginal-slope monotone
3154    /// time-wiggle time block: its chain-rule Jacobian recomputes a fixed
3155    /// `p_tw`-column wiggle basis on every evaluation, so a reduced (`p_time <
3156    /// p_tw`) time design made that Jacobian write past its buffer — an
3157    /// out-of-bounds panic in the phase-4b compiled-map path.
3158    #[test]
3159    fn compile_from_raw_grams_protected_keeps_full_rank_deficient_first_block() {
3160        let n = 14;
3161        // Block A (first, highest priority): two IDENTICAL columns → structural
3162        // rank 1, i.e. one within-block null direction the unprotected filter
3163        // drops. Stands in for the wiggle time block whose raw width must be
3164        // preserved.
3165        // Column value depends only on the row → both columns are identical.
3166        let a = Array2::from_shape_fn((n, 2), |(i, _)| ((i + 1) as f64 * 0.37).sin());
3167        // Block B: genuinely independent, so it survives at full width.
3168        let b = Array2::from_shape_fn((n, 2), |(i, j)| {
3169            ((i + 1) as f64 * (0.29 + j as f64 * 0.11)).cos()
3170        });
3171        let w = Array1::ones(n);
3172        let (gram_h, gram_struct, raw_ranges) = scalar_grams_two_block(&a, &b, &w);
3173        let ordering = [BlockOrder::Time, BlockOrder::Marginal];
3174
3175        // Unprotected: the duplicate column is dropped → block 0 reduces to 1.
3176        let unprotected = compile_from_raw_grams(&gram_h, &gram_struct, &raw_ranges, &ordering)
3177            .expect("unprotected compile");
3178        assert_eq!(
3179            unprotected.compiled_block_ranges[0].len(),
3180            1,
3181            "unprotected first block drops its structural-null direction"
3182        );
3183
3184        // Protected: block 0 keeps both raw columns; T's block-0 diagonal is the
3185        // 2×2 identity (raw coords == compiled coords for the protected block).
3186        let protected = compile_from_raw_grams_protected(
3187            &gram_h,
3188            &gram_struct,
3189            &raw_ranges,
3190            &ordering,
3191            &[true, false],
3192        )
3193        .expect("protected compile");
3194        assert_eq!(
3195            protected.compiled_block_ranges[0].len(),
3196            2,
3197            "protected first block retains its full raw width"
3198        );
3199        let t_block0 = protected
3200            .raw_from_compiled
3201            .slice(s![0..2, protected.compiled_block_ranges[0].clone()])
3202            .to_owned();
3203        for i in 0..2 {
3204            for j in 0..2 {
3205                let expect = if i == j { 1.0 } else { 0.0 };
3206                assert!(
3207                    (t_block0[[i, j]] - expect).abs() <= 1e-12,
3208                    "protected first block map must be identity, got [{i},{j}]={}",
3209                    t_block0[[i, j]]
3210                );
3211            }
3212        }
3213    }
3214
3215    #[test]
3216    fn orthogonalization_annotates_independent_and_fully_absorbed_blocks() {
3217        let n = 18;
3218        let anchor = Array2::from_shape_fn((n, 2), |(i, j)| {
3219            ((i + 1) as f64 * (0.19 + j as f64 * 0.07)).sin()
3220        });
3221        let duplicate = anchor.clone();
3222        let independent = Array2::from_shape_fn((n, 1), |(i, _)| ((i + 1) as f64 * 0.43).cos());
3223        let weight = vec![1.0; n];
3224        let ortho = orthogonalize_design_blocks(
3225            &[anchor, duplicate, independent],
3226            &[200, 100, 50],
3227            &weight,
3228        )
3229        .expect("structural annotation compile");
3230
3231        assert_eq!(
3232            ortho.direction_annotations[0].kind,
3233            PenalizedDirectionAnnotationKind::Independent
3234        );
3235        assert_eq!(ortho.direction_annotations[0].absorbed_width, 0);
3236        assert_eq!(
3237            ortho.direction_annotations[1].kind,
3238            PenalizedDirectionAnnotationKind::FullyAbsorbedByHigherPriority,
3239            "a duplicated lower-priority block is the same realized-design direction"
3240        );
3241        assert_eq!(ortho.direction_annotations[1].raw_width, 2);
3242        assert_eq!(ortho.direction_annotations[1].kept_width, 0);
3243        assert_eq!(ortho.direction_annotations[1].absorbed_width, 2);
3244        assert_eq!(
3245            ortho.direction_annotations[2].kind,
3246            PenalizedDirectionAnnotationKind::Independent,
3247            "a genuinely new realized-design direction keeps its own penalty block"
3248        );
3249        assert_eq!(ortho.direction_annotations[2].raw_width, 1);
3250        assert_eq!(ortho.direction_annotations[2].kept_width, 1);
3251        assert_eq!(ortho.dropped, vec![(1, 2)]);
3252    }
3253
3254    #[test]
3255    fn orthogonalization_rejects_invalid_row_metric_weights() {
3256        let design = Array2::from_shape_fn((4, 1), |(row, _)| row as f64 + 1.0);
3257        for invalid in [-1.0, f64::NAN, f64::INFINITY] {
3258            let result = orthogonalize_design_blocks(
3259                std::slice::from_ref(&design),
3260                &[1],
3261                &[1.0, invalid, 1.0, 1.0],
3262            );
3263            let error = match result {
3264                Err(error) => error,
3265                Ok(_) => panic!("an invalid W-metric must be rejected, not silently clamped"),
3266            };
3267            assert!(
3268                matches!(error, CompilerError::InvalidMetric(_)),
3269                "unexpected error for weight {invalid}: {error}"
3270            );
3271        }
3272    }
3273
3274    #[test]
3275    fn compile_from_raw_grams_three_block_full_logslope_alias_keeps_fast_path() {
3276        let n = 24;
3277        let time = Array2::from_shape_fn((n, 2), |(i, j)| {
3278            ((i + 1) as f64 * (j + 2) as f64 * 0.17).sin()
3279        });
3280        let marginal = Array2::from_shape_fn((n, 1), |(i, _)| ((i + 3) as f64 * 0.11).cos());
3281        let logslope = marginal.clone();
3282        let p_time = time.ncols();
3283        let p_marg = marginal.ncols();
3284        let p_log = logslope.ncols();
3285        let raw_ranges = vec![
3286            0..p_time,
3287            p_time..(p_time + p_marg),
3288            (p_time + p_marg)..(p_time + p_marg + p_log),
3289        ];
3290        let channel_blocks = PrimaryChannelBlocks {
3291            blocks: vec![
3292                vec![Some(time.clone())],
3293                vec![Some(marginal.clone())],
3294                vec![Some(logslope.clone())],
3295            ],
3296        };
3297        let row_hess = DiagonalScalarRowHessian::new(Array1::ones(n));
3298        let gram_h =
3299            build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges).unwrap();
3300        let gram_struct = build_raw_grams_structural(&channel_blocks, &raw_ranges);
3301
3302        let map = compile_from_raw_grams(
3303            &gram_h,
3304            &gram_struct,
3305            &raw_ranges,
3306            &[BlockOrder::Time, BlockOrder::Marginal, BlockOrder::Logslope],
3307        )
3308        .expect("fully aliased logslope block should not skip the compiled-map path");
3309
3310        assert_eq!(map.compiled_block_ranges[0].len(), p_time);
3311        assert_eq!(map.compiled_block_ranges[1].len(), p_marg);
3312        assert_eq!(map.compiled_block_ranges[2].len(), 0);
3313        assert_eq!(
3314            map.raw_from_compiled.dim(),
3315            (p_time + p_marg + p_log, p_time + p_marg)
3316        );
3317        let x_raw = {
3318            let mut out = Array2::<f64>::zeros((n, p_time + p_marg + p_log));
3319            out.slice_mut(s![.., raw_ranges[0].clone()]).assign(&time);
3320            out.slice_mut(s![.., raw_ranges[1].clone()])
3321                .assign(&marginal);
3322            out.slice_mut(s![.., raw_ranges[2].clone()])
3323                .assign(&logslope);
3324            out
3325        };
3326        let x_compiled = fast_ab(&x_raw, &map.raw_from_compiled);
3327        let rrqr = rrqr_with_permutation(&x_compiled, default_rrqr_rank_alpha()).unwrap();
3328        assert_eq!(rrqr.rank, x_compiled.ncols());
3329    }
3330
3331    /// Partial alias: block B's first column duplicates A; second column is
3332    /// independent. Closed-form `T` must have shape `(p_raw × (p_a + 1))`
3333    /// — block 1's compiled width is exactly the independent direction —
3334    /// and the joint design `X_raw · T` must span the same column space as
3335    /// the W-based reference compile result.
3336    #[test]
3337    fn compile_from_raw_grams_partial_alias_matches_w_reference() {
3338        let n = 25;
3339        let a = Array2::from_shape_fn((n, 2), |(i, j)| {
3340            ((i + 1) as f64 * (j + 1) as f64 * 0.3).sin()
3341        });
3342        // B = [a_0  +  independent]
3343        let mut b = Array2::<f64>::zeros((n, 2));
3344        for i in 0..n {
3345            b[[i, 0]] = a[[i, 0]];
3346            b[[i, 1]] = ((i + 1) as f64 * 0.7).cos();
3347        }
3348        let w = Array1::from_shape_fn(n, |i| 1.0 + 0.1 * (i as f64));
3349        let (gram_h, gram_struct, raw_ranges) = scalar_grams_two_block(&a, &b, &w);
3350        let compiled = compile_from_raw_grams(
3351            &gram_h,
3352            &gram_struct,
3353            &raw_ranges,
3354            &[BlockOrder::Marginal, BlockOrder::Logslope],
3355        )
3356        .expect("closed-form compile must succeed");
3357        let p_a = a.ncols();
3358        let p_b = b.ncols();
3359        assert_eq!(compiled.raw_from_compiled.shape()[0], p_a + p_b);
3360        assert_eq!(
3361            compiled.raw_from_compiled.shape()[1],
3362            p_a + 1,
3363            "partial alias should leave compiled width = p_a + 1 (one column dropped from B)"
3364        );
3365        // Block ranges sum to compiled width.
3366        assert_eq!(compiled.compiled_block_ranges[0], 0..p_a);
3367        assert_eq!(
3368            compiled.compiled_block_ranges[1].end - compiled.compiled_block_ranges[1].start,
3369            1
3370        );
3371
3372        // Column-span equality vs. W-reference: stack the raw design
3373        // X_raw = [A | B] and check that range(X_raw · T) ⊆ range(X_raw)
3374        // and has the same rank as the W-based compile.
3375        let mut x_raw = Array2::<f64>::zeros((n, p_a + p_b));
3376        for i in 0..n {
3377            for j in 0..p_a {
3378                x_raw[[i, j]] = a[[i, j]];
3379            }
3380            for j in 0..p_b {
3381                x_raw[[i, p_a + j]] = b[[i, j]];
3382            }
3383        }
3384        let x_compiled = fast_ab(&x_raw, &compiled.raw_from_compiled);
3385        // Rank of compiled design via Gram eigvals.
3386        let g_compiled = fast_ata(&x_compiled);
3387        let (evals, _) = g_compiled.eigh(Side::Lower).unwrap();
3388        let lam_max = evals.iter().cloned().fold(0.0_f64, f64::max);
3389        let tol = lam_max * 64.0 * (g_compiled.nrows() as f64) * f64::EPSILON;
3390        let rank_compiled = evals.iter().filter(|&&l| l > tol).count();
3391        assert_eq!(
3392            rank_compiled,
3393            p_a + 1,
3394            "compiled design column rank must equal p_a + 1 after dropping the alias"
3395        );
3396
3397        // Reference compile via the W-based dual-metric path on the same
3398        // scalar blocks; compiled total width should also be p_a + 1.
3399        let ops_dual: Vec<Arc<dyn RowJacobianOperator>> = vec![op(a.clone()), op(b.clone())];
3400        let curvature = DiagonalScalarRowHessian::new(w.clone());
3401        let id_struct = IdentityRowHessian::new(n, 1);
3402        let dual = compile_with_dual_metric(
3403            &ops_dual,
3404            &curvature,
3405            &id_struct,
3406            &[BlockOrder::Marginal, BlockOrder::Logslope],
3407        )
3408        .expect("dual metric compile should succeed");
3409        let dual_total: usize = dual.blocks.iter().map(|b| b.t_lw.ncols()).sum();
3410        assert_eq!(dual_total, p_a + 1, "W-reference total width should match");
3411    }
3412
3413    /// Three-block toy: changing the ordering changes the per-block
3414    /// compiled widths (later blocks absorb the alias instead of earlier).
3415    #[test]
3416    fn compile_from_raw_grams_three_block_ordering_matters() {
3417        let n = 30;
3418        let a = Array2::from_shape_fn((n, 2), |(i, j)| {
3419            ((i + 1) as f64 * (j + 2) as f64 * 0.2).sin()
3420        });
3421        // B has 2 cols: col 0 independent, col 1 = a[:, 0]
3422        let mut b = Array2::<f64>::zeros((n, 2));
3423        for i in 0..n {
3424            b[[i, 0]] = ((i + 1) as f64 * 0.4).cos();
3425            b[[i, 1]] = a[[i, 0]];
3426        }
3427        // C has 2 cols: col 0 independent, col 1 = a[:, 1]
3428        let mut c = Array2::<f64>::zeros((n, 2));
3429        for i in 0..n {
3430            c[[i, 0]] = ((i + 1) as f64 * 0.55).sin();
3431            c[[i, 1]] = a[[i, 1]];
3432        }
3433        let w = Array1::ones(n);
3434
3435        let build = |b0: &Array2<f64>, b1: &Array2<f64>, b2: &Array2<f64>| {
3436            let raw_ranges = vec![
3437                0..b0.ncols(),
3438                b0.ncols()..(b0.ncols() + b1.ncols()),
3439                (b0.ncols() + b1.ncols())..(b0.ncols() + b1.ncols() + b2.ncols()),
3440            ];
3441            let channel_blocks = PrimaryChannelBlocks {
3442                blocks: vec![
3443                    vec![Some(b0.clone())],
3444                    vec![Some(b1.clone())],
3445                    vec![Some(b2.clone())],
3446                ],
3447            };
3448            let row_hess = DiagonalScalarRowHessian::new(w.clone());
3449            let gram_h =
3450                build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges)
3451                    .unwrap();
3452            let gram_struct = build_raw_grams_structural(&channel_blocks, &raw_ranges);
3453            (gram_h, gram_struct, raw_ranges)
3454        };
3455
3456        // Order 1: A, B, C — B drops 1 (col 1 aliased to A), C drops 1.
3457        let (gh, gs, rr) = build(&a, &b, &c);
3458        let order_abc = compile_from_raw_grams(
3459            &gh,
3460            &gs,
3461            &rr,
3462            &[
3463                BlockOrder::Marginal,
3464                BlockOrder::Logslope,
3465                BlockOrder::LinkDev,
3466            ],
3467        )
3468        .expect("ABC compile");
3469        assert_eq!(order_abc.compiled_block_ranges[0].len(), 2);
3470        assert_eq!(order_abc.compiled_block_ranges[1].len(), 1);
3471        assert_eq!(order_abc.compiled_block_ranges[2].len(), 1);
3472
3473        // Order 2: B, A, C — A's col 0 is aliased by B's col 1 now; A's
3474        // col 1 is independent. So A drops 1; C still drops 1.
3475        let (gh2, gs2, rr2) = build(&b, &a, &c);
3476        let order_bac = compile_from_raw_grams(
3477            &gh2,
3478            &gs2,
3479            &rr2,
3480            &[
3481                BlockOrder::Marginal,
3482                BlockOrder::Logslope,
3483                BlockOrder::LinkDev,
3484            ],
3485        )
3486        .expect("BAC compile");
3487        assert_eq!(order_bac.compiled_block_ranges[0].len(), 2);
3488        assert_eq!(order_bac.compiled_block_ranges[1].len(), 1);
3489        // Total rank invariant under permutation: 4.
3490        let total_abc: usize = order_abc
3491            .compiled_block_ranges
3492            .iter()
3493            .map(|r| r.len())
3494            .sum();
3495        let total_bac: usize = order_bac
3496            .compiled_block_ranges
3497            .iter()
3498            .map(|r| r.len())
3499            .sum();
3500        assert_eq!(total_abc, total_bac);
3501        assert_eq!(total_abc, 4);
3502    }
3503
3504    /// Build a K=1 raw `(gram_h, gram_struct)` pair for a single stacked design
3505    /// `X` with per-row curvature weights `w`: `gram_struct = Xᵀ X`,
3506    /// `gram_h = Xᵀ diag(w) X`. Mirrors the closed-form definitions the
3507    /// production Gram builders implement for the scalar-channel case.
3508    fn k1_grams(x: &Array2<f64>, w: &Array1<f64>) -> (Array2<f64>, Array2<f64>) {
3509        let gram_struct = fast_atb(x, x);
3510        let xw = fast_xt_diag_y(x, w, x);
3511        (xw, gram_struct)
3512    }
3513
3514    /// Full-rank reduction: when the two blocks are jointly independent the
3515    /// compiled width equals the raw width and the lift `T` reproduces a raw
3516    /// coefficient exactly from its compiled image `θ = T⁺ β` (here, with no
3517    /// aliasing, `lift_coefficients(θ)` of any compiled `θ` lands in the raw
3518    /// design's column interpretation: applying `T` then comparing the induced
3519    /// raw predictor `X·Tθ` to `X·β_raw` for the `θ` solving `Tθ=β_raw`).
3520    #[test]
3521    fn compiled_map_lift_coefficients_roundtrips_full_rank() {
3522        let n = 21;
3523        let p_a = 2;
3524        let p_b = 2;
3525        // Distinct per-column frequencies make the four sinusoidal columns
3526        // genuinely linearly independent over the sample grid. (A shared phase
3527        // offset varying only by column would collapse every column into
3528        // span{sin θ, cos θ, 1}, i.e. rank 3, and the compiler would correctly
3529        // absorb a column — defeating the full-rank premise of this test.)
3530        let x = Array2::from_shape_fn((n, p_a + p_b), |(i, j)| {
3531            ((i as f64 + 1.0) * (0.21 + 0.17 * j as f64)).sin() + 0.11 * (j as f64)
3532        });
3533        let w = Array1::from_shape_fn(n, |i| 0.5 + 0.5 * ((i as f64) * 0.3).cos().abs());
3534        let (gh, gs) = k1_grams(&x, &w);
3535        let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
3536        let map = compile_from_raw_grams(
3537            &gh,
3538            &gs,
3539            &raw_ranges,
3540            &[BlockOrder::Marginal, BlockOrder::Logslope],
3541        )
3542        .expect("full-rank compile");
3543        // Jointly independent ⇒ no columns absorbed.
3544        assert_eq!(map.p_compiled(), p_a + p_b);
3545        assert_eq!(map.p_raw(), p_a + p_b);
3546        // For a target raw coefficient, solve T θ = β_raw (T square invertible
3547        // here) and confirm lift_coefficients(θ) == β_raw.
3548        let beta_raw = Array1::from_shape_fn(p_a + p_b, |j| 0.4 * (j as f64) - 0.7);
3549        // T is (p × p); recover θ by a least-squares solve via the normal
3550        // equations TᵀT θ = Tᵀ β.
3551        let tt = fast_atb(&map.raw_from_compiled, &map.raw_from_compiled);
3552        let tb = map.raw_from_compiled.t().dot(&beta_raw);
3553        let theta = solve_psd_system(&tt, &tb.insert_axis(Axis(1)))
3554            .expect("normal-equation solve")
3555            .column(0)
3556            .to_owned();
3557        let lifted = map.lift_coefficients(&theta).expect("lift");
3558        let max_err = (&lifted - &beta_raw)
3559            .iter()
3560            .fold(0.0_f64, |a, &v| a.max(v.abs()));
3561        assert!(
3562            max_err < 1e-8,
3563            "lift round-trip error {max_err:e} (full-rank reduction must be exactly invertible)"
3564        );
3565    }
3566
3567    /// Design reparameterisation exactness: the compiled design predicts
3568    /// identically to the raw design on every lifted coefficient, i.e.
3569    /// `X_compiled · θ == X_raw · (T θ)`. This is the contract that lets a
3570    /// family fit in reduced coordinates and still produce raw-design
3571    /// predictions.
3572    #[test]
3573    fn compiled_map_reduce_design_matches_lifted_raw_predictor() {
3574        let n = 23;
3575        let p_a = 3;
3576        let p_b = 3;
3577        let mut x = Array2::from_shape_fn((n, p_a + p_b), |(i, j)| {
3578            ((i as f64 + 1.0) * 0.41 + (j as f64 + 1.0) * 0.7).sin() + 0.05 * (i % 3) as f64
3579        });
3580        // Alias one B column onto an A column so the reduction is non-trivial.
3581        for i in 0..n {
3582            x[[i, p_a + 1]] = x[[i, 1]];
3583        }
3584        let w = Array1::from_shape_fn(n, |i| 0.6 + 0.4 * ((i as f64) * 0.25).cos().abs());
3585        let (gh, gs) = k1_grams(&x, &w);
3586        let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
3587        let map = compile_from_raw_grams(
3588            &gh,
3589            &gs,
3590            &raw_ranges,
3591            &[BlockOrder::Marginal, BlockOrder::Logslope],
3592        )
3593        .expect("compile");
3594        let x_compiled = map.reduce_design(&x).expect("reduce_design");
3595        assert_eq!(x_compiled.ncols(), map.p_compiled());
3596        let theta = Array1::from_shape_fn(map.p_compiled(), |j| 0.3 * (j as f64) - 0.5);
3597        let pred_compiled = x_compiled.dot(&theta);
3598        let beta_raw = map.lift_coefficients(&theta).expect("lift");
3599        let pred_raw = x.dot(&beta_raw);
3600        let max_err = (&pred_compiled - &pred_raw)
3601            .iter()
3602            .fold(0.0_f64, |a, &v| a.max(v.abs()));
3603        assert!(
3604            max_err < 1e-9,
3605            "compiled-design predictor diverges from lifted raw predictor: {max_err:e}"
3606        );
3607    }
3608
3609    /// Penalty-energy preservation: the reduced penalty `Tᵀ Ŝ_b T` reproduces
3610    /// the raw penalty energy `βᵀ Ŝ_b β` on every lifted point `β = T θ`. This
3611    /// is the exactness contract the lift map must satisfy for REML/inference
3612    /// to be invariant to the quotient reparameterisation.
3613    #[test]
3614    fn reduce_penalties_with_map_preserves_energy_on_lift() {
3615        let n = 19;
3616        let p_a = 3;
3617        let p_b = 2;
3618        // Make block B partly aliased with A so the reduction actually drops a
3619        // column — the penalty reduction must still preserve energy on the
3620        // surviving compiled directions.
3621        let mut x = Array2::from_shape_fn((n, p_a + p_b), |(i, j)| {
3622            ((i as f64 + 1.0) * 0.29 + (j as f64 + 1.0) * 0.9).cos()
3623        });
3624        // Column (p_a+0) := column 0 (exact alias) ⇒ B loses one direction.
3625        for i in 0..n {
3626            x[[i, p_a]] = x[[i, 0]];
3627        }
3628        let w = Array1::from_shape_fn(n, |i| 0.7 + 0.3 * ((i as f64) * 0.2).sin().abs());
3629        let (gh, gs) = k1_grams(&x, &w);
3630        let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
3631        let map = compile_from_raw_grams(
3632            &gh,
3633            &gs,
3634            &raw_ranges,
3635            &[BlockOrder::Marginal, BlockOrder::Logslope],
3636        )
3637        .expect("compile with alias");
3638        assert!(
3639            map.p_compiled() < p_a + p_b,
3640            "expected at least one absorbed column, got p_compiled={}",
3641            map.p_compiled()
3642        );
3643        // A simple per-block raw penalty: ridge on each block.
3644        let s_a = Array2::<f64>::eye(p_a);
3645        let s_b = Array2::<f64>::eye(p_b);
3646        let reduced = reduce_penalties_with_map(&map, &[Some(s_a.clone()), Some(s_b.clone())])
3647            .expect("reduce penalties");
3648        // For random compiled θ, raw β = T θ. Raw energy for block b is
3649        // β[range_b]ᵀ S_b β[range_b]; reduced energy is θᵀ S_reduced_b θ.
3650        let theta = Array1::from_shape_fn(map.p_compiled(), |j| {
3651            0.6 * (j as f64) - 0.3 + 0.05 * (j % 2) as f64
3652        });
3653        let beta = map.lift_coefficients(&theta).expect("lift");
3654        for (block_idx, s_raw) in [(0usize, &s_a), (1usize, &s_b)] {
3655            let range = &map.raw_block_ranges[block_idx];
3656            let beta_b = beta.slice(s![range.start..range.end]).to_owned();
3657            let raw_energy = beta_b.dot(&s_raw.dot(&beta_b));
3658            let s_reduced = reduced[block_idx]
3659                .as_ref()
3660                .expect("reduced penalty present");
3661            let reduced_energy = theta.dot(&s_reduced.dot(&theta));
3662            assert!(
3663                (raw_energy - reduced_energy).abs() < 1e-8 * raw_energy.abs().max(1.0),
3664                "block {block_idx} energy mismatch: raw={raw_energy:e} reduced={reduced_energy:e}"
3665            );
3666        }
3667    }
3668}