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.
42const 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 let rank = {
905 // Diagonally equilibrate into the column-scale gauge (Sylvester's law of
906 // inertia: the congruence preserves rank), then take the count from the
907 // equilibrated spectrum. See `gam_linalg::decision::equilibrate_gram`.
908 let (g_eq, _) = equilibrate_gram(g_tilde);
909 let (evals_eq, _) = g_eq.eigh(Side::Lower).map_err(|err| {
910 CompilerError::LinalgFailure(format!("equilibrated residual Gram eigh failed: {err:?}"))
911 })?;
912 let lambda_max_eq = evals_eq.iter().cloned().fold(0.0_f64, f64::max).max(0.0);
913 let nk = (n.saturating_mul(k)).max(p).max(1) as f64;
914 let tau_eq = lambda_max_eq * RANK_REVEAL_EPS_SLACK * nk * f64::EPSILON;
915 // Threshold count the pipeline has always acted on: the decision we must
916 // preserve exactly.
917 let threshold_count = evals_eq.iter().filter(|&&e| e > tau_eq).count();
918 // Two-stage rollout (#2337 §9-step-6). STAGE 1 — OBSERVE ONLY: classify
919 // the same decision against a two-sided guard band. When the band is
920 // clean the certified rank equals `threshold_count` by construction (no
921 // eigenvalue lies in `(τ/(1+gap), τ·(1+gap))`, so `#{e ≥ high}` =
922 // `#{e > τ}`). When a value sits inside the band the decision is
923 // host-unstable; we do NOT refuse here — we log the payload so we can
924 // measure Ambiguous frequency before enforcing a refusal path in stage 2
925 // — and fall back to the preserved threshold count.
926 match certified_rank(evals_eq.as_slice().unwrap_or(&[]), tau_eq, RANK_DECISION_GAP) {
927 RankDecision::Certified { rank, .. } => rank,
928 RankDecision::Ambiguous {
929 rank_floor,
930 rank_ceil,
931 sigma_in_band,
932 tol,
933 gap,
934 } => {
935 log::warn!(
936 "keep_positive_eigenspace: ambiguous equilibrated rank (observe-only, \
937 #2337 stage 1): rank_floor={rank_floor}, rank_ceil={rank_ceil}, \
938 sigma_in_band={sigma_in_band:.3e}, tol={tol:.3e}, gap={gap}, \
939 falling back to threshold_count={threshold_count}"
940 );
941 threshold_count
942 }
943 }
944 };
945
946 // Top-`rank` RAW eigenvectors by descending raw eigenvalue (stable order).
947 let mut kept: Vec<usize> = (0..p).collect();
948 kept.sort_by(|&a, &b| {
949 evals[b]
950 .partial_cmp(&evals[a])
951 .unwrap_or(std::cmp::Ordering::Equal)
952 });
953 kept.truncate(rank);
954 let mut v = Array2::<f64>::zeros((p, kept.len()));
955 for (out_col, &src_col) in kept.iter().enumerate() {
956 for row in 0..p {
957 v[[row, out_col]] = evecs[[row, src_col]];
958 }
959 }
960 Ok(v)
961}
962
963/// Concatenate two matrices column-wise. Both must have the same row count.
964fn concat_cols(left: &Array2<f64>, right: &Array2<f64>) -> Array2<f64> {
965 let nrows = left.nrows().max(right.nrows());
966 let lc = left.ncols();
967 let rc = right.ncols();
968 let mut out = Array2::<f64>::zeros((nrows, lc + rc));
969 if lc > 0 {
970 out.slice_mut(s![.., ..lc]).assign(left);
971 }
972 if rc > 0 {
973 out.slice_mut(s![.., lc..]).assign(right);
974 }
975 out
976}
977
978/// Post-walk audit: column-pivoted QR on the cumulative scaled design.
979/// If rank < p_total, deterministically drop trailing pivots from the
980/// latest block's `V`. Earlier blocks are never modified.
981fn audit_and_drop_trailing_pivots(
982 w_joint: &Array2<f64>,
983 compiled: &mut [CompiledBlock],
984) -> Result<Vec<(usize, usize)>, CompilerError> {
985 let p_total: usize = compiled.iter().map(|b| b.t_lw.ncols()).sum();
986 if p_total == 0 || w_joint.nrows() == 0 {
987 return Ok(Vec::new());
988 }
989
990 // RRQR rank with the codebase's default α.
991 let rrqr = rrqr_with_permutation(w_joint, default_rrqr_rank_alpha())
992 .map_err(|err| CompilerError::LinalgFailure(format!("audit RRQR failed: {err:?}")))?;
993 let rank = rrqr.rank;
994 if rank >= p_total {
995 return Ok(Vec::new());
996 }
997
998 // Trailing pivots are the redundant columns. Attribute every demoted
999 // global column to the *latest* block by truncating its V; earlier
1000 // blocks keep their full V. The demoted suffix is sorted only by
1001 // pivot order, but we drop deterministically: take the count of
1002 // demoted columns and truncate that many trailing columns of the
1003 // latest block.
1004 let drop_count = p_total - rank;
1005 let latest_idx = compiled.len() - 1;
1006 let latest = &mut compiled[latest_idx];
1007 let kept_local = latest.t_lw.ncols().saturating_sub(drop_count);
1008 let dropped_locals: Vec<(usize, usize)> = (kept_local..latest.t_lw.ncols())
1009 .map(|c| (latest_idx, c))
1010 .collect();
1011 // Truncate ALL kept-direction-indexed matrices in lockstep so the
1012 // shape contract (`anchor_correction: d_total × k_kept`, `r_lw:
1013 // d_total × k_kept`, `t_lw: p_raw × k_kept`) holds after the audit
1014 // drops trailing pivots. Forgetting these two left
1015 // `anchor_correction.ncols() == pre_truncation_k_kept` while
1016 // `t_lw.ncols() == post_truncation_k_kept`, surfaced downstream as
1017 // `cross-block identifiability: anchor_correction shape D×P does
1018 // not match expected d_total=D × k_kept=K`.
1019 latest.t_lw = latest.t_lw.slice(s![.., ..kept_local]).to_owned();
1020 if let Some(m) = latest.anchor_correction.as_ref() {
1021 latest.anchor_correction = Some(m.slice(s![.., ..kept_local]).to_owned());
1022 }
1023 if let Some(r) = latest.r_lw.as_ref() {
1024 latest.r_lw = Some(r.slice(s![.., ..kept_local]).to_owned());
1025 }
1026 Ok(dropped_locals)
1027}
1028
1029/// Channel-pair decomposition of every parameter block's row Jacobian.
1030///
1031/// For families with `K` primary-state channels (survival: K=4), each block
1032/// `b` contributes a (n × p_b) channel matrix `X_b^(c)` per channel `c` that
1033/// it touches. Blocks that do not contribute to a channel store `None` in
1034/// that slot. The closed-form Gram compiler consumes this view directly to
1035/// build the joint Gram `K^H` without ever materialising the full
1036/// `(n·K) × p_total` weighted design `W = sqrt(H) · J`.
1037pub struct PrimaryChannelBlocks {
1038 /// Outer index: block. Inner index: channel `c ∈ 0..K`. `None` means the
1039 /// block does not contribute to that channel.
1040 pub blocks: Vec<Vec<Option<Array2<f64>>>>,
1041}
1042
1043/// Closed-form Gram builder: `K^H[a, b] = Σ_{c,d} (X_a^(c))ᵀ · diag(h_{cd}) · X_b^(d)`.
1044///
1045/// Inputs:
1046/// - `channel_blocks`: per-block channel decomposition of the row Jacobian.
1047/// - `row_hess`: `(n × K × K)` per-row PSD Hessian (typically clamped to PSD
1048/// by the family upstream).
1049/// - `raw_block_ranges`: `[start, end)` column ranges of each block inside
1050/// the full `p_total`-wide coefficient vector. Must be contiguous and
1051/// non-overlapping; their union spans `0..p_total`.
1052///
1053/// Returns the symmetric `(p_total × p_total)` Gram matrix.
1054pub fn build_raw_grams_from_channel_blocks(
1055 channel_blocks: &PrimaryChannelBlocks,
1056 row_hess: &dyn RowHessian,
1057 raw_block_ranges: &[std::ops::Range<usize>],
1058) -> Result<Array2<f64>, CompilerError> {
1059 let num_blocks = channel_blocks.blocks.len();
1060 if num_blocks != raw_block_ranges.len() {
1061 return Err(CompilerError::DimensionMismatch(format!(
1062 "channel_blocks ({num_blocks}) and raw_block_ranges ({}) length mismatch",
1063 raw_block_ranges.len()
1064 )));
1065 }
1066 if num_blocks == 0 {
1067 return Ok(Array2::<f64>::zeros((0, 0)));
1068 }
1069 let k = row_hess.k();
1070 let n = row_hess.nrows();
1071 let p_total: usize = raw_block_ranges.iter().map(|r| r.end - r.start).sum();
1072 let expected_total = raw_block_ranges.last().map(|r| r.end).unwrap_or(0);
1073 if expected_total != p_total {
1074 return Err(CompilerError::DimensionMismatch(format!(
1075 "raw_block_ranges must be contiguous from 0; got p_total={p_total} but last end={expected_total}"
1076 )));
1077 }
1078 // Per-block channel-slot shape sanity.
1079 for (b, slots) in channel_blocks.blocks.iter().enumerate() {
1080 if slots.len() != k {
1081 return Err(CompilerError::DimensionMismatch(format!(
1082 "block {b}: expected {k} channel slots, got {}",
1083 slots.len()
1084 )));
1085 }
1086 let p_b = raw_block_ranges[b].end - raw_block_ranges[b].start;
1087 for (c, mat) in slots.iter().enumerate() {
1088 if let Some(x) = mat.as_ref() {
1089 if x.nrows() != n {
1090 return Err(CompilerError::DimensionMismatch(format!(
1091 "block {b} channel {c}: nrows={} but row Hessian nrows={n}",
1092 x.nrows()
1093 )));
1094 }
1095 if x.ncols() != p_b {
1096 return Err(CompilerError::DimensionMismatch(format!(
1097 "block {b} channel {c}: ncols={} but block width={p_b}",
1098 x.ncols()
1099 )));
1100 }
1101 }
1102 }
1103 }
1104
1105 // Materialise H once and slice it into K·K length-n vectors h_{cd}.
1106 let h_full = row_hess.evaluate_full();
1107 if h_full.shape() != &[n, k, k] {
1108 return Err(CompilerError::DimensionMismatch(format!(
1109 "row Hessian evaluate_full shape {:?} != [n={n}, k={k}, k={k}]",
1110 h_full.shape()
1111 )));
1112 }
1113 // h_pairs[c * k + d] = length-n vector of H_i[c, d].
1114 let mut h_pairs: Vec<Array1<f64>> = Vec::with_capacity(k * k);
1115 for c in 0..k {
1116 for d in 0..k {
1117 let mut v = Array1::<f64>::zeros(n);
1118 for i in 0..n {
1119 v[i] = h_full[[i, c, d]];
1120 }
1121 h_pairs.push(v);
1122 }
1123 }
1124
1125 let mut gram = Array2::<f64>::zeros((p_total, p_total));
1126 // Accumulate upper triangle (a ≤ b) then symmetrise.
1127 for a in 0..num_blocks {
1128 let range_a = raw_block_ranges[a].clone();
1129 for b in a..num_blocks {
1130 let range_b = raw_block_ranges[b].clone();
1131 let mut block_acc =
1132 Array2::<f64>::zeros((range_a.end - range_a.start, range_b.end - range_b.start));
1133 for c in 0..k {
1134 let Some(x_a_c) = channel_blocks.blocks[a][c].as_ref() else {
1135 continue;
1136 };
1137 for d in 0..k {
1138 let Some(x_b_d) = channel_blocks.blocks[b][d].as_ref() else {
1139 continue;
1140 };
1141 let h_cd = &h_pairs[c * k + d];
1142 // (X_a^(c))ᵀ · diag(h_cd) · X_b^(d) → (p_a × p_b).
1143 let contrib = fast_xt_diag_y(x_a_c, h_cd, x_b_d);
1144 block_acc += &contrib;
1145 }
1146 }
1147 // Write into upper triangle (and the diagonal block itself).
1148 gram.slice_mut(s![range_a.start..range_a.end, range_b.start..range_b.end])
1149 .assign(&block_acc);
1150 }
1151 }
1152 // Symmetrise: copy upper triangle to lower. Diagonal blocks are
1153 // themselves p_a × p_a — symmetrise within them too.
1154 for i in 0..p_total {
1155 for j in 0..i {
1156 let v = gram[[j, i]];
1157 gram[[i, j]] = v;
1158 }
1159 }
1160 Ok(gram)
1161}
1162
1163/// Structural Gram `K^S`: same shape as [`build_raw_grams_from_channel_blocks`]
1164/// but with the per-row Hessian replaced by the K×K identity. Used by the
1165/// dual-metric compiler as the un-weighted reference geometry.
1166///
1167/// `K^S[a, b] = Σ_c (X_a^(c))ᵀ · X_b^(c)` (cross-channel terms vanish under
1168/// `H_i = I_K`).
1169pub fn build_raw_grams_structural(
1170 channel_blocks: &PrimaryChannelBlocks,
1171 raw_block_ranges: &[std::ops::Range<usize>],
1172) -> Array2<f64> {
1173 let num_blocks = channel_blocks.blocks.len();
1174 assert_eq!(
1175 num_blocks,
1176 raw_block_ranges.len(),
1177 "channel_blocks ({num_blocks}) and raw_block_ranges ({}) length mismatch",
1178 raw_block_ranges.len()
1179 );
1180 if num_blocks == 0 {
1181 return Array2::<f64>::zeros((0, 0));
1182 }
1183 let p_total = raw_block_ranges.last().map(|r| r.end).unwrap_or(0);
1184 let mut gram = Array2::<f64>::zeros((p_total, p_total));
1185 for a in 0..num_blocks {
1186 let range_a = raw_block_ranges[a].clone();
1187 for b in a..num_blocks {
1188 let range_b = raw_block_ranges[b].clone();
1189 let p_a = range_a.end - range_a.start;
1190 let p_b = range_b.end - range_b.start;
1191 let k_a = channel_blocks.blocks[a].len();
1192 let k_b = channel_blocks.blocks[b].len();
1193 assert_eq!(
1194 k_a, k_b,
1195 "structural Gram: block {a} has {k_a} channels but block {b} has {k_b}",
1196 );
1197 let mut block_acc = Array2::<f64>::zeros((p_a, p_b));
1198 for c in 0..k_a {
1199 let (Some(x_a_c), Some(x_b_c)) = (
1200 channel_blocks.blocks[a][c].as_ref(),
1201 channel_blocks.blocks[b][c].as_ref(),
1202 ) else {
1203 continue;
1204 };
1205 let contrib = if a == b {
1206 // Diagonal block, same channel — symmetric XᵀX.
1207 fast_ata(x_a_c)
1208 } else {
1209 fast_atb(x_a_c, x_b_c)
1210 };
1211 block_acc += &contrib;
1212 }
1213 gram.slice_mut(s![range_a.start..range_a.end, range_b.start..range_b.end])
1214 .assign(&block_acc);
1215 }
1216 }
1217 for i in 0..p_total {
1218 for j in 0..i {
1219 let v = gram[[j, i]];
1220 gram[[i, j]] = v;
1221 }
1222 }
1223 gram
1224}
1225
1226/// Build the primary-state curvature Gram `K^H` and structural Gram `K^S`
1227/// for a block decomposition, preferring the device (GPU) path when
1228/// available and falling back to the CPU closed-form builders otherwise.
1229///
1230/// The GPU path is only attempted for survival-family geometry
1231/// (`K = CHANNELS = 4`) — that is the case the GPU kernel
1232/// ([`crate::families::gpu::try_primary_state_gram_cuda`])
1233/// is specialised for via the packed-symmetric `n × 10` weight layout.
1234/// For any other `K` the CPU builders are used unconditionally.
1235///
1236/// Returns `(gram_h, gram_struct)` with the same shape and semantics as
1237/// [`build_raw_grams_from_channel_blocks`] + [`build_raw_grams_structural`].
1238pub fn build_primary_grams_gpu_or_cpu(
1239 channel_blocks: &PrimaryChannelBlocks,
1240 row_hess: &dyn RowHessian,
1241 raw_block_ranges: &[std::ops::Range<usize>],
1242) -> Result<(Array2<f64>, Array2<f64>), CompilerError> {
1243 let k = row_hess.k();
1244 if k == crate::families::gpu::CHANNELS {
1245 let gpu_blocks: Vec<Vec<Option<Array2<f64>>>> = channel_blocks
1246 .blocks
1247 .iter()
1248 .map(|slots| slots.iter().cloned().collect())
1249 .collect();
1250 if let Some(h_packed) = pack_row_hessian_symmetric(row_hess) {
1251 if let Some(bundle) = crate::families::gpu::try_primary_state_gram_cuda(
1252 &gpu_blocks,
1253 &h_packed,
1254 raw_block_ranges,
1255 )
1256 .map_err(|error| CompilerError::GpuFailure(error.to_string()))?
1257 {
1258 log::info!("[identifiability_compile] gram path = gpu");
1259 return Ok((bundle.gram_h, bundle.gram_struct));
1260 }
1261 }
1262 }
1263 log::info!("[identifiability_compile] gram path = cpu");
1264 let gram_h = build_raw_grams_from_channel_blocks(channel_blocks, row_hess, raw_block_ranges)?;
1265 let gram_struct = build_raw_grams_structural(channel_blocks, raw_block_ranges);
1266 Ok((gram_h, gram_struct))
1267}
1268
1269/// Pack a per-row symmetric `K = 4` Hessian into the `n × 10`
1270/// upper-triangular row-major layout consumed by the GPU kernel
1271/// (`packed_index(c, d)` for `c ≤ d`). Returns `None` when `K != 4`.
1272fn pack_row_hessian_symmetric(row_hess: &dyn RowHessian) -> Option<Array2<f64>> {
1273 use crate::families::gpu::{CHANNELS, PACKED_LEN, packed_index};
1274 if row_hess.k() != CHANNELS {
1275 return None;
1276 }
1277 let n = row_hess.nrows();
1278 let h_full = row_hess.evaluate_full();
1279 if h_full.shape() != [n, CHANNELS, CHANNELS] {
1280 return None;
1281 }
1282 let mut packed = Array2::<f64>::zeros((n, PACKED_LEN));
1283 for i in 0..n {
1284 for c in 0..CHANNELS {
1285 for d in c..CHANNELS {
1286 packed[[i, packed_index(c, d)]] = h_full[[i, c, d]];
1287 }
1288 }
1289 }
1290 Some(packed)
1291}
1292
1293/// Closed-form Gram-based compile output: a single `p_raw × p_compiled`
1294/// reparam matrix `T` mapping compiled coordinates back to raw width.
1295/// `T · θ` lifts a fitted compiled-width β back to raw width; predict-time
1296/// row contribution is `X_raw · T · θ` where `X_raw` is the full raw design.
1297///
1298/// `compiled_block_ranges[b]` gives the column range inside `T` (and inside
1299/// the compiled-width coefficient vector) attributable to raw block `b`.
1300/// `raw_block_ranges[b]` gives the corresponding raw-width column range.
1301#[derive(Debug)]
1302pub struct CompiledMap {
1303 /// `(p_raw × p_compiled)` raw-from-compiled reparam matrix.
1304 pub raw_from_compiled: Array2<f64>,
1305 /// Per-block compiled-width column ranges, parallel to
1306 /// `raw_block_ranges`. Same length as the input `ordering`.
1307 pub compiled_block_ranges: Vec<std::ops::Range<usize>>,
1308 /// Per-block raw-width column ranges (copied through from input).
1309 pub raw_block_ranges: Vec<std::ops::Range<usize>>,
1310}
1311
1312/// Neutral view of this compiled reparametrisation for the gauge layer
1313/// (#1521): `Gauge::from_compiled_map` lives DOWN in `gam-problem` and
1314/// names only the `CompiledBlockMap` trait, never the concrete
1315/// `CompiledMap` (which lives ABOVE `gam-problem`). This `impl` supplies
1316/// the inverted dependency edge.
1317impl gam_problem::gauge::CompiledBlockMap for CompiledMap {
1318 fn raw_from_compiled(&self) -> &Array2<f64> {
1319 &self.raw_from_compiled
1320 }
1321 fn raw_block_ranges(&self) -> &[std::ops::Range<usize>] {
1322 &self.raw_block_ranges
1323 }
1324 fn compiled_block_ranges(&self) -> &[std::ops::Range<usize>] {
1325 &self.compiled_block_ranges
1326 }
1327}
1328
1329/// Closed-form Gram-based identifiability compile.
1330///
1331/// Sequential algorithm operating purely on the raw-width Grams
1332/// `K^H = Σ_i J_iᵀ H_i J_i` (curvature) and `K^S = Σ_i J_iᵀ J_i`
1333/// (structural). Walks `ordering` left-to-right; for each block `b` with
1334/// raw-width selector `P_b` (columns of the identity selecting that
1335/// block) and cumulative compiled map `T = [T_0, …, T_{b-1}]`:
1336///
1337/// 1. Structural rank step (drop true gauges):
1338/// `G^S_AA = Tᵀ K^S T`, `G^S_Ab = Tᵀ K^S P_b`, `G^S_bb = P_bᵀ K^S P_b`,
1339/// `R_S = (G^S_AA)^+ G^S_Ab`, `G^S_res = G^S_bb − G^S_Abᵀ R_S`.
1340/// Eigendecompose `G^S_res`; keep positive eigvecs `Q+`. Then
1341/// `D = (P_b − T R_S) · Q+` (raw-space cols, structurally independent
1342/// of `T`).
1343/// 2. Curvature step (within-block conditioning):
1344/// `G^H_AA = Tᵀ K^H T`, `G^H_AD = Tᵀ K^H D`,
1345/// `R_H = (G^H_AA)^+ G^H_AD`, `E = D − T R_H` (raw-space).
1346/// Curvature Gram `G^H_res = Dᵀ K^H D − G^H_ADᵀ R_H`. Eigendecompose
1347/// and keep positive eigvecs `U`. Then `T_b = E · U`.
1348/// 3. Append: `T ← [T, T_b]`.
1349///
1350/// Returns [`CompilerError::FullyAliased`] only when the first block has no
1351/// usable structural/curvature span. Later fully absorbed blocks compile to a
1352/// zero-width block range, which is the reduced-coordinate representation of
1353/// the lower-priority block owning no degrees of freedom.
1354pub fn compile_from_raw_grams(
1355 gram_h: &Array2<f64>,
1356 gram_struct: &Array2<f64>,
1357 raw_block_ranges: &[std::ops::Range<usize>],
1358 ordering: &[BlockOrder],
1359) -> Result<CompiledMap, CompilerError> {
1360 compile_from_raw_grams_protected(gram_h, gram_struct, raw_block_ranges, ordering, &[])
1361}
1362
1363/// Variant of [`compile_from_raw_grams`] that keeps designated blocks at full
1364/// raw width instead of dropping their near-null structural/curvature
1365/// directions.
1366///
1367/// `protected[b] == true` forces block `b` to retain **all** of its raw
1368/// columns: the structural and curvature eigenspace filters that would drop
1369/// weak directions are replaced by identity, so `T_b` embeds the full raw
1370/// block (orthogonalised against earlier anchors) rather than a reduced
1371/// section. The block still serves as a full-width anchor for every later
1372/// (unprotected) block, so cross-block aliasing against it is removed exactly
1373/// as before — only the protected block's own within-block reparameterisation
1374/// is suppressed.
1375///
1376/// This exists for blocks whose effective Jacobian is a **fixed nonlinear
1377/// functional basis** rather than a plain linear design (e.g. the survival
1378/// marginal-slope monotone time-wiggle block). Such a block's chain-rule
1379/// Jacobian recomputes its basis at the raw coefficient width on every
1380/// evaluation and therefore cannot be expressed on a linearly recombined /
1381/// reduced design; reparameterising it silently corrupts — and can index out
1382/// of bounds in — that basis evaluation. Keeping it at raw width lets its own
1383/// penalty nullspace regularise its conditioning, which is the correct
1384/// treatment for a within-block (as opposed to cross-block) rank deficiency.
1385///
1386/// `protected` may be shorter than `ordering` (missing entries default to
1387/// `false`); an empty slice reproduces [`compile_from_raw_grams`] exactly.
1388pub fn compile_from_raw_grams_protected(
1389 gram_h: &Array2<f64>,
1390 gram_struct: &Array2<f64>,
1391 raw_block_ranges: &[std::ops::Range<usize>],
1392 ordering: &[BlockOrder],
1393 protected: &[bool],
1394) -> Result<CompiledMap, CompilerError> {
1395 if raw_block_ranges.len() != ordering.len() {
1396 return Err(CompilerError::DimensionMismatch(format!(
1397 "raw_block_ranges ({}) and ordering ({}) length mismatch",
1398 raw_block_ranges.len(),
1399 ordering.len()
1400 )));
1401 }
1402 let p_raw = raw_block_ranges.last().map(|r| r.end).unwrap_or(0);
1403 if gram_h.shape() != [p_raw, p_raw] {
1404 return Err(CompilerError::DimensionMismatch(format!(
1405 "gram_h shape {:?} != [p_raw={p_raw}, p_raw={p_raw}]",
1406 gram_h.shape()
1407 )));
1408 }
1409 if gram_struct.shape() != [p_raw, p_raw] {
1410 return Err(CompilerError::DimensionMismatch(format!(
1411 "gram_struct shape {:?} != [p_raw={p_raw}, p_raw={p_raw}]",
1412 gram_struct.shape()
1413 )));
1414 }
1415 if raw_block_ranges.is_empty() {
1416 return Ok(CompiledMap {
1417 raw_from_compiled: Array2::<f64>::zeros((0, 0)),
1418 compiled_block_ranges: Vec::new(),
1419 raw_block_ranges: Vec::new(),
1420 });
1421 }
1422 // Validate contiguous ranges from 0.
1423 let mut expected_start = 0usize;
1424 for (b, r) in raw_block_ranges.iter().enumerate() {
1425 if r.start != expected_start {
1426 return Err(CompilerError::DimensionMismatch(format!(
1427 "raw_block_ranges must be contiguous from 0; block {b} starts at {} expected {expected_start}",
1428 r.start
1429 )));
1430 }
1431 expected_start = r.end;
1432 }
1433
1434 // Cumulative raw-from-compiled map. Starts empty (zero compiled cols).
1435 let mut t_cum: Array2<f64> = Array2::<f64>::zeros((p_raw, 0));
1436 let mut compiled_block_ranges: Vec<std::ops::Range<usize>> =
1437 Vec::with_capacity(raw_block_ranges.len());
1438
1439 for (idx, range_b) in raw_block_ranges.iter().enumerate() {
1440 let p_b = range_b.end - range_b.start;
1441 let block_protected = protected.get(idx).copied().unwrap_or(false);
1442 // A zero-width block owns no raw columns. It contributes no compiled
1443 // degrees of freedom and — having no columns — cannot alias against any
1444 // anchor, so it is trivially identifiable. Emit an empty compiled range
1445 // and skip the structural/curvature analysis: a 0×0 residual Gram has no
1446 // positive eigenspace, which the first-block guard below would otherwise
1447 // mis-report as `FullyAliased` even though there is literally nothing to
1448 // alias. This mirrors the empty range a fully-absorbed later block
1449 // already compiles to (see the `q_plus.ncols() == 0` / `u_mat.ncols() == 0`
1450 // branches), keeping `kept_width + dropped_count == raw_width` exact.
1451 if p_b == 0 {
1452 let at = t_cum.ncols();
1453 compiled_block_ranges.push(at..at);
1454 continue;
1455 }
1456 // Slice gram columns/rows by raw block range. P_bᵀ K X = rows
1457 // range_b of K X. K^S T and K^H T are full-rows products.
1458 // 1) Structural rank step.
1459 // K^S · T (p_raw × p_compiled)
1460 let ks_t = fast_ab(gram_struct, &t_cum);
1461 // G^S_AA = Tᵀ K^S T (p_compiled × p_compiled)
1462 let g_s_aa = fast_atb(&t_cum, &ks_t);
1463 // G^S_Ab = Tᵀ K^S P_b = Tᵀ · K^S[:, range_b] (p_compiled × p_b)
1464 let ks_pb = gram_struct
1465 .slice(s![.., range_b.start..range_b.end])
1466 .to_owned();
1467 let g_s_ab = fast_atb(&t_cum, &ks_pb);
1468 // G^S_bb = P_bᵀ K^S P_b = K^S[range_b, range_b] (p_b × p_b)
1469 let g_s_bb = gram_struct
1470 .slice(s![range_b.start..range_b.end, range_b.start..range_b.end])
1471 .to_owned();
1472 // R_S = (G^S_AA)^+ G^S_Ab (p_compiled × p_b)
1473 let r_s = solve_psd_system(&g_s_aa, &g_s_ab)?;
1474 // G^S_res = G^S_bb − G^S_Abᵀ R_S (p_b × p_b), symmetrise.
1475 let g_s_res_raw = &g_s_bb - &fast_atb(&g_s_ab, &r_s);
1476 let g_s_res = symmetrise(&g_s_res_raw);
1477 // Trace of the unresidualised diagonal block (scale ref).
1478 let g_s_bb_trace: f64 = (0..p_b).map(|i| g_s_bb[[i, i]].max(0.0)).sum();
1479 // p_raw stands in as the "n*K" scale for the closed-form tolerance.
1480 // A protected block keeps every raw column (identity structural span);
1481 // the residual-Gram eigenfilter that would drop weak directions is
1482 // suppressed so the block emerges at full raw width.
1483 let q_plus = if block_protected {
1484 Array2::<f64>::eye(p_b)
1485 } else {
1486 keep_positive_eigenspace(&g_s_res, p_raw, 1, g_s_bb_trace)?
1487 };
1488 if q_plus.ncols() == 0 {
1489 if t_cum.ncols() == 0 {
1490 return Err(CompilerError::FullyAliased {
1491 block_idx: idx,
1492 reason: format!(
1493 "structural residual Gram has no positive eigenspace (block of width {p_b} has zero structural span before any anchor exists)"
1494 ),
1495 });
1496 }
1497 let at = t_cum.ncols();
1498 compiled_block_ranges.push(at..at);
1499 continue;
1500 }
1501 // D = (P_b − T R_S) · Q+ (p_raw × k_kept). Build (P_b − T R_S)
1502 // explicitly as a p_raw × p_b matrix: columns of P_b are columns
1503 // range_b of I_p_raw, so (P_b − T R_S) places −T R_S in all rows
1504 // and adds the identity on rows range_b.
1505 let mut diff = Array2::<f64>::zeros((p_raw, p_b));
1506 if t_cum.ncols() > 0 {
1507 // diff = −T · R_S
1508 let t_rs = fast_ab(&t_cum, &r_s);
1509 for i in 0..p_raw {
1510 for j in 0..p_b {
1511 diff[[i, j]] = -t_rs[[i, j]];
1512 }
1513 }
1514 }
1515 for j in 0..p_b {
1516 diff[[range_b.start + j, j]] += 1.0;
1517 }
1518 let d_mat = fast_ab(&diff, &q_plus);
1519
1520 // 2) Curvature step.
1521 // K^H · T (p_raw × p_compiled), K^H · D (p_raw × k_kept)
1522 let kh_t = fast_ab(gram_h, &t_cum);
1523 let g_h_aa = fast_atb(&t_cum, &kh_t);
1524 let kh_d = fast_ab(gram_h, &d_mat);
1525 let g_h_ad = fast_atb(&t_cum, &kh_d);
1526 let r_h = solve_psd_system(&g_h_aa, &g_h_ad)?;
1527 // G^H_res = Dᵀ K^H D − G^H_ADᵀ R_H (k_kept × k_kept)
1528 let d_t_kh_d = fast_atb(&d_mat, &kh_d);
1529 let g_h_res_raw = &d_t_kh_d - &fast_atb(&g_h_ad, &r_h);
1530 let g_h_res = symmetrise(&g_h_res_raw);
1531 let k_kept = q_plus.ncols();
1532 let g_h_dd_trace: f64 = (0..k_kept).map(|i| d_t_kh_d[[i, i]].max(0.0)).sum();
1533 // A protected block also retains every structurally-kept curvature
1534 // direction (identity curvature span), so no within-block conditioning
1535 // drop occurs; its own penalty nullspace regularises the fit instead.
1536 let u_mat = if block_protected {
1537 Array2::<f64>::eye(k_kept)
1538 } else {
1539 keep_positive_eigenspace(&g_h_res, p_raw, 1, g_h_dd_trace)?
1540 };
1541 if u_mat.ncols() == 0 {
1542 if t_cum.ncols() == 0 {
1543 return Err(CompilerError::FullyAliased {
1544 block_idx: idx,
1545 reason: format!(
1546 "curvature residual Gram has no positive eigenspace within structurally-kept basis (block of width {p_b}, structural-kept {k_kept}) before any anchor exists"
1547 ),
1548 });
1549 }
1550 let at = t_cum.ncols();
1551 compiled_block_ranges.push(at..at);
1552 continue;
1553 }
1554 // E = D − T · R_H (p_raw × k_kept); T_b = E · U.
1555 let mut e_mat = d_mat.clone();
1556 if t_cum.ncols() > 0 {
1557 let t_rh = fast_ab(&t_cum, &r_h);
1558 e_mat = &e_mat - &t_rh;
1559 }
1560 let t_b = fast_ab(&e_mat, &u_mat);
1561
1562 let start = t_cum.ncols();
1563 let end = start + t_b.ncols();
1564 compiled_block_ranges.push(start..end);
1565 t_cum = concat_cols(&t_cum, &t_b);
1566 }
1567
1568 // Finite check.
1569 for v in t_cum.iter() {
1570 if !v.is_finite() {
1571 return Err(CompilerError::LinalgFailure(
1572 "compile_from_raw_grams produced non-finite entry in raw_from_compiled".to_string(),
1573 ));
1574 }
1575 }
1576
1577 Ok(CompiledMap {
1578 raw_from_compiled: t_cum,
1579 compiled_block_ranges,
1580 raw_block_ranges: raw_block_ranges.to_vec(),
1581 })
1582}
1583
1584impl CompiledMap {
1585 /// Raw coefficient width (`p_raw`).
1586 pub fn p_raw(&self) -> usize {
1587 self.raw_from_compiled.nrows()
1588 }
1589
1590 /// Compiled (reduced) coefficient width (`p_compiled`).
1591 pub fn p_compiled(&self) -> usize {
1592 self.raw_from_compiled.ncols()
1593 }
1594
1595 /// Reparameterise a raw design into compiled coordinates:
1596 /// `X_compiled = X_raw · T` (`n × p_compiled`). Because the lift is
1597 /// `β_raw = T β_compiled`, the compiled design predicts identically to the
1598 /// raw design on every compiled coefficient: `X_compiled · θ = X_raw · (T θ)`.
1599 /// Families that build directly in reduced coordinates feed this compiled
1600 /// design (and the [`reduce_penalties_with_map`] penalties) to the solver;
1601 /// the rank-deficient raw basis never reaches Newton.
1602 pub fn reduce_design(&self, raw_design: &Array2<f64>) -> Result<Array2<f64>, String> {
1603 if raw_design.ncols() != self.p_raw() {
1604 return Err(format!(
1605 "CompiledMap::reduce_design: raw_design has {} columns, expected p_raw {}",
1606 raw_design.ncols(),
1607 self.p_raw()
1608 ));
1609 }
1610 Ok(fast_ab(raw_design, &self.raw_from_compiled))
1611 }
1612
1613 /// Lift a fitted compiled-width coefficient vector back to raw width:
1614 /// `β_raw = T · β_compiled`. This is the exact inverse direction of the
1615 /// quotient reduction — the reduced coordinates are what Newton/REML
1616 /// operate in, and this map carries the final estimate (and any linear
1617 /// functional of it) back to the original parameterisation so reported
1618 /// coefficients and predictions match the raw design.
1619 pub fn lift_coefficients(&self, beta_compiled: &Array1<f64>) -> Result<Array1<f64>, String> {
1620 if beta_compiled.len() != self.p_compiled() {
1621 return Err(format!(
1622 "CompiledMap::lift_coefficients: beta_compiled len {} != p_compiled {}",
1623 beta_compiled.len(),
1624 self.p_compiled()
1625 ));
1626 }
1627 Ok(self.raw_from_compiled.dot(beta_compiled))
1628 }
1629
1630 /// The rows of `T` belonging to raw block `b` (`T[raw_block_ranges[b], :]`,
1631 /// shape `p_b_raw × p_compiled`). A raw-block penalty `S_b` acts only on
1632 /// these raw columns, so the penalty's reduced-coordinate form depends on
1633 /// `T` only through this slice.
1634 fn raw_block_rows(&self, block_idx: usize) -> Result<Array2<f64>, String> {
1635 let range = self.raw_block_ranges.get(block_idx).ok_or_else(|| {
1636 format!(
1637 "CompiledMap::raw_block_rows: block {block_idx} out of range {}",
1638 self.raw_block_ranges.len()
1639 )
1640 })?;
1641 Ok(self
1642 .raw_from_compiled
1643 .slice(s![range.start..range.end, ..])
1644 .to_owned())
1645 }
1646}
1647
1648/// Transform a per-block raw-width penalty into the compiled (reduced)
1649/// coordinate frame defined by `map`.
1650///
1651/// `raw_penalties[b]` is the penalty matrix `S_b` acting on raw block `b`
1652/// (shape `p_b_raw × p_b_raw`), or `None` for an unpenalised block. The
1653/// returned `reduced[b]` is the **full** `(p_compiled × p_compiled)` penalty
1654/// `Tᵀ Ŝ_b T`, where `Ŝ_b` embeds `S_b` into the `p_raw × p_raw` zero matrix
1655/// at block `b`'s position. Because `Ŝ_b` is zero outside block `b`'s rows and
1656/// columns, this equals `T_bᵀ S_b T_b` with `T_b = T[raw_block_ranges[b], :]`,
1657/// so the reduced penalty is computed from the block's lift rows alone — no
1658/// dense `p_raw × p_raw` embedding is materialised.
1659///
1660/// Exactness: for any compiled coefficient `θ` with raw lift `β = T θ`, the raw
1661/// penalty energy `βᵀ Ŝ_b β = (T θ)ᵀ Ŝ_b (T θ) = θᵀ (Tᵀ Ŝ_b T) θ`, so the
1662/// reduced penalty reproduces the raw penalty energy on every lifted point.
1663/// A compiled block that absorbed to zero width simply contributes a zero
1664/// column range; its raw penalty (if any) projects onto the surviving
1665/// compiled directions through `T_b`, never lost.
1666pub fn reduce_penalties_with_map(
1667 map: &CompiledMap,
1668 raw_penalties: &[Option<Array2<f64>>],
1669) -> Result<Vec<Option<Array2<f64>>>, String> {
1670 if raw_penalties.len() != map.raw_block_ranges.len() {
1671 return Err(format!(
1672 "reduce_penalties_with_map: raw_penalties ({}) != blocks ({})",
1673 raw_penalties.len(),
1674 map.raw_block_ranges.len()
1675 ));
1676 }
1677 let p_compiled = map.p_compiled();
1678 let mut reduced: Vec<Option<Array2<f64>>> = Vec::with_capacity(raw_penalties.len());
1679 for (block_idx, raw_penalty) in raw_penalties.iter().enumerate() {
1680 let Some(s_b) = raw_penalty.as_ref() else {
1681 reduced.push(None);
1682 continue;
1683 };
1684 let p_b_raw = map.raw_block_ranges[block_idx].len();
1685 if s_b.shape() != [p_b_raw, p_b_raw] {
1686 return Err(format!(
1687 "reduce_penalties_with_map: block {block_idx} penalty shape {:?} != [{p_b_raw}, {p_b_raw}]",
1688 s_b.shape()
1689 ));
1690 }
1691 // T_b = T[raw rows of block b, :] (p_b_raw × p_compiled)
1692 let t_b = map.raw_block_rows(block_idx)?;
1693 // S_compiled = T_bᵀ S_b T_b (p_compiled × p_compiled)
1694 let s_t_b = fast_ab(s_b, &t_b); // (p_b_raw × p_compiled)
1695 let s_compiled_raw = fast_atb(&t_b, &s_t_b); // (p_compiled × p_compiled)
1696 let mut s_compiled = symmetrise(&s_compiled_raw);
1697 if s_compiled.shape() != [p_compiled, p_compiled] {
1698 return Err(format!(
1699 "reduce_penalties_with_map: block {block_idx} reduced penalty shape {:?} != [{p_compiled}, {p_compiled}]",
1700 s_compiled.shape()
1701 ));
1702 }
1703 for v in s_compiled.iter_mut() {
1704 if !v.is_finite() {
1705 return Err(format!(
1706 "reduce_penalties_with_map: block {block_idx} reduced penalty has non-finite entry"
1707 ));
1708 }
1709 }
1710 reduced.push(Some(s_compiled));
1711 }
1712 Ok(reduced)
1713}
1714
1715/// Per-block exact orthogonal reparameterisation of structural confounds.
1716///
1717/// `block_transforms[b]` is a dense `(p_b × r_b)` reparam `V_b` mapping raw
1718/// block-`b` coefficients to reduced coordinates: the orthogonalised block
1719/// design is `X_b · V_b`, and a fitted reduced coefficient lifts back to raw
1720/// space exactly via `β_b_raw = V_b · θ_b`. `r_b ≤ p_b`; `r_b < p_b` exactly
1721/// when block `b` carries `p_b − r_b` directions already spanned (in the
1722/// pilot W-metric) by the cumulative anchor of all higher-priority blocks —
1723/// those directions are removed (not penalised), so the joint design
1724/// `[X_0 V_0 | X_1 V_1 | …]` has the overlap excised exactly.
1725pub struct BlockOrthogonalization {
1726 /// `block_transforms[b]`: the `(p_b × r_b)` reparam `V_b` for raw block `b`,
1727 /// in the **original block order** (parallel to the `block_designs` input).
1728 pub block_transforms: Vec<Array2<f64>>,
1729 /// `(block_idx, local_raw_col_count_dropped)` for every block whose
1730 /// reduced width is strictly smaller than its raw width — i.e. the blocks
1731 /// that shed overlap directions against the anchor. Empty when no block
1732 /// overlapped (every `V_b` is then a `p_b × p_b` rotation/identity).
1733 pub dropped: Vec<(usize, usize)>,
1734 /// One structural annotation per input block, in original block order.
1735 ///
1736 /// This is the explicit "same direction vs independent direction" verdict:
1737 /// `Independent` means the block kept its full realized-design rank, while
1738 /// `PartiallyAbsorbed...` / `FullyAbsorbed...` mean the lower-priority block
1739 /// shared realized-design directions with the cumulative anchor and those
1740 /// directions were removed rather than assigned a separate penalty.
1741 pub direction_annotations: Vec<PenalizedDirectionAnnotation>,
1742}
1743
1744/// Build per-block exact W-metric orthogonalising reparameterisations.
1745///
1746/// `block_designs[b]` is the raw `(n × p_b)` design of block `b`.
1747/// `priority[b]` is the block's gauge priority — blocks are residualised in
1748/// **descending** priority order, so the highest-priority block keeps its full
1749/// column span and lower-priority blocks shed only the directions already
1750/// explained by the cumulative higher-priority anchor. `weight` is the pilot
1751/// W-metric row weight `w_i ≥ 0` (the diagonal of the working GLM/GAM Hessian
1752/// at the pilot β); pass an all-ones vector for the plain Euclidean metric.
1753///
1754/// The returned `block_transforms` are in the **original** block order. For a
1755/// block whose columns are all W-orthogonal to the anchor, `V_b` is a square
1756/// `p_b × p_b` orthonormal rotation (rank preserved, round-trip exact). For a
1757/// block with an overlap of dimension `d`, `V_b` is `p_b × (p_b − d)` and the
1758/// `d` overlap directions are removed exactly.
1759///
1760/// Exactness / round-trip: `X_b · V_b` is the reduced design and
1761/// `β_b_raw = V_b · θ_b` lifts a reduced fit back to raw coordinates. `V_b` has
1762/// orthonormal columns (eigenvectors of the residual Gram), so the lift is the
1763/// minimum-norm raw representative of the reduced fit.
1764pub fn orthogonalize_design_blocks(
1765 block_designs: &[Array2<f64>],
1766 priority: &[u32],
1767 weight: &[f64],
1768) -> Result<BlockOrthogonalization, CompilerError> {
1769 if block_designs.len() != priority.len() {
1770 return Err(CompilerError::DimensionMismatch(format!(
1771 "block_designs ({}) and priority ({}) length mismatch",
1772 block_designs.len(),
1773 priority.len()
1774 )));
1775 }
1776 if block_designs.is_empty() {
1777 return Ok(BlockOrthogonalization {
1778 block_transforms: Vec::new(),
1779 dropped: Vec::new(),
1780 direction_annotations: Vec::new(),
1781 });
1782 }
1783 let n = block_designs[0].nrows();
1784 for (b, x) in block_designs.iter().enumerate() {
1785 if x.nrows() != n {
1786 return Err(CompilerError::DimensionMismatch(format!(
1787 "block {b} design has {} rows but block 0 has {n}",
1788 x.nrows()
1789 )));
1790 }
1791 }
1792 if weight.len() != n {
1793 return Err(CompilerError::DimensionMismatch(format!(
1794 "weight length {} != n {n}",
1795 weight.len()
1796 )));
1797 }
1798 // sqrt(W) row scale. The pilot Hessian is PSD-clamped upstream; accepting
1799 // a negative or non-finite value here would silently change the requested
1800 // metric and can turn an aliased direction into an apparently independent
1801 // one. Reject the invalid mathematical object at the boundary.
1802 let mut sqrt_w = Array1::<f64>::zeros(n);
1803 for i in 0..n {
1804 let wi = weight[i];
1805 if !wi.is_finite() || wi < 0.0 {
1806 return Err(CompilerError::InvalidMetric(format!(
1807 "weight[{i}] must be finite and non-negative; got {wi}"
1808 )));
1809 }
1810 sqrt_w[i] = wi.sqrt();
1811 }
1812
1813 // Descending-priority visitation order over the original block indices.
1814 // Stable on ties (preserves input order) so the anchor build is
1815 // deterministic.
1816 let mut order: Vec<usize> = (0..block_designs.len()).collect();
1817 order.sort_by(|&a, &b| priority[b].cmp(&priority[a]));
1818
1819 // Cumulative weighted anchor `A = sqrt(W) · [kept block designs]`.
1820 let mut anchor: Array2<f64> = Array2::<f64>::zeros((n, 0));
1821
1822 // Output transforms indexed by ORIGINAL block index (filled out of order).
1823 let mut block_transforms: Vec<Option<Array2<f64>>> = vec![None; block_designs.len()];
1824 let mut direction_annotations: Vec<Option<PenalizedDirectionAnnotation>> =
1825 vec![None; block_designs.len()];
1826 let mut dropped: Vec<(usize, usize)> = Vec::new();
1827
1828 for &b in order.iter() {
1829 let x_b = &block_designs[b];
1830 let p_b = x_b.ncols();
1831 // Weighted block design `W_b = sqrt(W) · X_b`.
1832 let mut w_b = x_b.clone();
1833 for i in 0..n {
1834 let s = sqrt_w[i];
1835 for j in 0..p_b {
1836 w_b[[i, j]] *= s;
1837 }
1838 }
1839 // Residualise `W_b` against the cumulative anchor in the W-metric and
1840 // eigendecompose the residual Gram. Eigenvectors with positive
1841 // eigenvalues span block `b`'s W-orthogonal-to-anchor column space;
1842 // the zero-eigenvalue directions are exactly the overlap with the
1843 // anchor and are removed.
1844 let (residual, _correction) = residualise_in_metric(&anchor, &w_b)?;
1845 let g_res = symmetrise(&fast_atb(&residual, &residual));
1846 // Scale reference for `keep_positive_eigenspace` must be the
1847 // *original* (pre-residualisation) weighted block Gram trace, NOT the
1848 // residual's. When `b` is fully absorbed by a higher-priority anchor
1849 // the residual collapses to floating-point noise (~ε² of the original
1850 // O(1) data); anchoring tau to that noise floor would keep the noise
1851 // eigenvalues and misreport a fully-absorbed block as `Independent`.
1852 // The original-block trace is invariant to absorption, so a near-zero
1853 // residual is correctly rejected as fully absorbed.
1854 let g_bb = fast_atb(&w_b, &w_b);
1855 let g_bb_trace: f64 = (0..p_b).map(|i| g_bb[[i, i]].max(0.0)).sum();
1856 let v_b = keep_positive_eigenspace(&g_res, n, 1, g_bb_trace)?;
1857 let r_b = v_b.ncols();
1858 let absorbed_width = p_b - r_b;
1859 let kind = if absorbed_width == 0 {
1860 PenalizedDirectionAnnotationKind::Independent
1861 } else if r_b == 0 {
1862 PenalizedDirectionAnnotationKind::FullyAbsorbedByHigherPriority
1863 } else {
1864 PenalizedDirectionAnnotationKind::PartiallyAbsorbedByHigherPriority
1865 };
1866 direction_annotations[b] = Some(PenalizedDirectionAnnotation {
1867 block_idx: b,
1868 raw_width: p_b,
1869 kept_width: r_b,
1870 absorbed_width,
1871 kind,
1872 });
1873 if absorbed_width > 0 {
1874 dropped.push((b, absorbed_width));
1875 }
1876 // Append this block's kept, W-orthogonalised weighted columns to the
1877 // anchor so lower-priority blocks residualise against them too. The
1878 // residual (already anchor-orthogonal) projected onto the kept basis
1879 // is `residual · V_b` — these are mutually orthogonal in the W-metric
1880 // by construction of `keep_positive_eigenspace`.
1881 let kept_weighted = fast_ab(&residual, &v_b);
1882 anchor = concat_cols(&anchor, &kept_weighted);
1883 block_transforms[b] = Some(v_b);
1884 }
1885
1886 let block_transforms: Vec<Array2<f64>> = block_transforms
1887 .into_iter()
1888 .enumerate()
1889 .map(|(b, t)| {
1890 t.ok_or_else(|| {
1891 CompilerError::LinalgFailure(format!(
1892 "orthogonalize_design_blocks: block {b} transform was never assigned"
1893 ))
1894 })
1895 })
1896 .collect::<Result<Vec<_>, _>>()?;
1897 let direction_annotations: Vec<PenalizedDirectionAnnotation> = direction_annotations
1898 .into_iter()
1899 .enumerate()
1900 .map(|(b, annotation)| {
1901 annotation.ok_or_else(|| {
1902 CompilerError::LinalgFailure(format!(
1903 "orthogonalize_design_blocks: block {b} direction annotation was never assigned"
1904 ))
1905 })
1906 })
1907 .collect::<Result<Vec<_>, _>>()?;
1908
1909 // Finite check on every transform.
1910 for (b, v) in block_transforms.iter().enumerate() {
1911 for value in v.iter() {
1912 if !value.is_finite() {
1913 return Err(CompilerError::LinalgFailure(format!(
1914 "orthogonalize_design_blocks: block {b} transform has a non-finite entry"
1915 )));
1916 }
1917 }
1918 }
1919
1920 Ok(BlockOrthogonalization {
1921 block_transforms,
1922 dropped,
1923 direction_annotations,
1924 })
1925}
1926
1927/// Symmetrise a (nearly-symmetric) matrix by averaging with its transpose.
1928fn symmetrise(m: &Array2<f64>) -> Array2<f64> {
1929 let (r, c) = m.dim();
1930 assert_eq!(r, c, "symmetrise expects square matrix");
1931 let mut out = Array2::<f64>::zeros((r, c));
1932 for i in 0..r {
1933 for j in 0..c {
1934 out[[i, j]] = 0.5 * (m[[i, j]] + m[[j, i]]);
1935 }
1936 }
1937 out
1938}
1939
1940#[cfg(test)]
1941mod tests {
1942 use super::*;
1943 use ndarray::{Array1, Array2};
1944
1945 /// Convenience: wrap a dense `(n × p)` block design as a `K=1`
1946 /// row-Jacobian operator. Used by tests; production families ship their
1947 /// own concrete operators.
1948 struct DenseScalarOperator {
1949 design: Array2<f64>,
1950 }
1951
1952 impl DenseScalarOperator {
1953 fn new(design: Array2<f64>) -> Self {
1954 Self { design }
1955 }
1956 }
1957
1958 impl RowJacobianOperator for DenseScalarOperator {
1959 fn k(&self) -> usize {
1960 1
1961 }
1962 fn ncols(&self) -> usize {
1963 self.design.ncols()
1964 }
1965 fn nrows(&self) -> usize {
1966 self.design.nrows()
1967 }
1968 fn apply_row(&self, row: usize, delta_beta: &[f64], out: &mut [f64]) {
1969 assert_eq!(out.len(), 1);
1970 let mut acc = 0.0;
1971 for (j, &b) in delta_beta.iter().enumerate() {
1972 acc += self.design[[row, j]] * b;
1973 }
1974 out[0] = acc;
1975 }
1976 fn evaluate_full(&self) -> Array3<f64> {
1977 let n = self.design.nrows();
1978 let p = self.design.ncols();
1979 let mut out = Array3::<f64>::zeros((n, p, 1));
1980 for i in 0..n {
1981 for j in 0..p {
1982 out[[i, j, 0]] = self.design[[i, j]];
1983 }
1984 }
1985 out
1986 }
1987 }
1988
1989 // `IdentityRowHessian` is re-exported from the parent module's `use
1990 // super::*;` above (now a public struct so the dual-metric API can
1991 // share the default structural metric with callers).
1992
1993 /// Diagonal row Hessian with per-row scalar weights (K=1 case).
1994 struct DiagonalScalarRowHessian {
1995 w: Array1<f64>,
1996 }
1997
1998 impl DiagonalScalarRowHessian {
1999 fn new(w: Array1<f64>) -> Self {
2000 Self { w }
2001 }
2002 }
2003
2004 impl RowHessian for DiagonalScalarRowHessian {
2005 fn k(&self) -> usize {
2006 1
2007 }
2008 fn nrows(&self) -> usize {
2009 self.w.len()
2010 }
2011 fn fill_row(&self, row: usize, out: &mut [f64]) {
2012 assert_eq!(out.len(), 1);
2013 out[0] = self.w[row];
2014 }
2015 fn evaluate_full(&self) -> Array3<f64> {
2016 let n = self.w.len();
2017 let mut out = Array3::<f64>::zeros((n, 1, 1));
2018 for i in 0..n {
2019 out[[i, 0, 0]] = self.w[i];
2020 }
2021 out
2022 }
2023 }
2024
2025 fn op(design: Array2<f64>) -> Arc<dyn RowJacobianOperator> {
2026 Arc::new(DenseScalarOperator::new(design))
2027 }
2028
2029 /// §10 test #1: two affine blocks, identity row Hessian. The compiled
2030 /// second-block design must be orthogonal to the first block under the
2031 /// (identity) row metric to machine epsilon.
2032 #[test]
2033 fn compile_two_block_orthogonalises_under_metric() {
2034 let n = 50;
2035 let a = Array2::from_shape_fn((n, 3), |(i, j)| ((i + 1) as f64).sin().powi((j + 1) as i32));
2036 // B partly aliases A's first column.
2037 let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2038 0.5 * a[[i, 0]] + ((i as f64) * 0.13 + j as f64).cos()
2039 });
2040 let hess = IdentityRowHessian::new(n, 1);
2041 let ops = vec![op(a.clone()), op(b.clone())];
2042 let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2043 .expect("compile should succeed");
2044 // Build A's design (no rotation) and B's compiled design B·V − A·M.
2045 let v_b = &compiled.blocks[1].t_lw;
2046 let m_b = compiled.blocks[1]
2047 .anchor_correction
2048 .as_ref()
2049 .expect("second block must carry an anchor correction");
2050 let b_v = b.dot(v_b);
2051 let a_m = a.dot(m_b);
2052 let b_compiled = &b_v - &a_m;
2053 // <A, B_compiled>_I = Aᵀ · B_compiled should be ≈ 0.
2054 let cross = a.t().dot(&b_compiled);
2055 let max_err = cross.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2056 assert!(
2057 max_err < 1e-10,
2058 "orthogonality residual too large: {max_err:e}"
2059 );
2060 }
2061
2062 /// §10 test #2: three-block chain with sequential aliases.
2063 #[test]
2064 fn compile_three_block_chain() {
2065 let n = 80;
2066 let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.1 + j as f64).sin());
2067 let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2068 0.3 * a[[i, 0]] + (j as f64) * (i as f64).cos()
2069 });
2070 let c = Array2::from_shape_fn((n, 2), |(i, j)| {
2071 0.2 * a[[i, 1]] + 0.4 * b[[i, 0]] + ((i + j) as f64).tan().min(5.0).max(-5.0)
2072 });
2073 let hess = IdentityRowHessian::new(n, 1);
2074 let ops = vec![op(a), op(b), op(c)];
2075 let compiled = compile(
2076 &ops,
2077 &hess,
2078 &[
2079 BlockOrder::Marginal,
2080 BlockOrder::Logslope,
2081 BlockOrder::LinkDev,
2082 ],
2083 )
2084 .expect("compile should succeed");
2085 let total: usize = compiled.blocks.iter().map(|b| b.t_lw.ncols()).sum();
2086 assert_eq!(
2087 compiled.joint_rank, total,
2088 "audit must report full rank on synthetic full-rank design"
2089 );
2090 }
2091
2092 /// `compile_protected` keeps a rank-deficient protected first block at full
2093 /// raw width (identity V) while the unprotected path drops its null
2094 /// direction, and later blocks still orthogonalise against the full anchor.
2095 /// Mirrors the `compile_from_raw_grams_protected` guard for the operator
2096 /// (per-term) reduction path used by the survival time-wiggle time block.
2097 #[test]
2098 fn compile_protected_keeps_rank_deficient_first_block_full_width() {
2099 let n = 40;
2100 // Block A: two identical columns → structural rank 1 (one within-block
2101 // null the unprotected filter drops).
2102 let a = Array2::from_shape_fn((n, 2), |(i, _)| ((i + 1) as f64 * 0.31).sin());
2103 let b = Array2::from_shape_fn((n, 2), |(i, j)| ((i as f64) * 0.17 + j as f64).cos());
2104 let hess = IdentityRowHessian::new(n, 1);
2105 let ordering = [BlockOrder::Time, BlockOrder::Marginal];
2106
2107 let unprotected = compile(&[op(a.clone()), op(b.clone())], &hess, &ordering)
2108 .expect("unprotected compile");
2109 assert_eq!(
2110 unprotected.blocks[0].t_lw.ncols(),
2111 1,
2112 "unprotected first block drops its duplicate column"
2113 );
2114
2115 let protected = compile_protected(
2116 &[op(a.clone()), op(b.clone())],
2117 &hess,
2118 &ordering,
2119 &[true, false],
2120 )
2121 .expect("protected compile");
2122 let v_a = &protected.blocks[0].t_lw;
2123 assert_eq!(
2124 v_a.ncols(),
2125 2,
2126 "protected first block retains its full raw width"
2127 );
2128 // V_a is the 2×2 identity: raw coords == compiled coords for the
2129 // protected first block.
2130 for i in 0..2 {
2131 for j in 0..2 {
2132 let expect = if i == j { 1.0 } else { 0.0 };
2133 assert!(
2134 (v_a[[i, j]] - expect).abs() <= 1e-12,
2135 "protected first block V must be identity, got [{i},{j}]={}",
2136 v_a[[i, j]]
2137 );
2138 }
2139 }
2140 }
2141
2142 /// §10 test #3: non-identity row Hessian. With K=1 and weights `w`,
2143 /// the projection of a 1-col block `b` onto a 1-col block `a` is
2144 /// `Σ w·a·b / Σ w·a²`. Verify the Gram solve recovers this scalar.
2145 #[test]
2146 fn compile_weighted_metric_nontrivial() {
2147 let n = 32;
2148 let a: Array2<f64> = Array2::from_shape_fn((n, 1), |(i, _)| (i as f64 + 1.0).sqrt());
2149 let b: Array2<f64> =
2150 Array2::from_shape_fn((n, 1), |(i, _)| 0.7 * a[[i, 0]] + (i as f64 * 0.05).cos());
2151 let w = Array1::from_shape_fn(n, |i| 0.5 + (i as f64 * 0.2).sin().abs());
2152 let hess = DiagonalScalarRowHessian::new(w.clone());
2153 let ops = vec![op(a.clone()), op(b.clone())];
2154 let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2155 .expect("compile should succeed");
2156 let m = compiled.blocks[1]
2157 .anchor_correction
2158 .as_ref()
2159 .expect("anchor correction present");
2160 let analytic_num: f64 = (0..n).map(|i| w[i] * a[[i, 0]] * b[[i, 0]]).sum();
2161 let analytic_den: f64 = (0..n).map(|i| w[i] * a[[i, 0]] * a[[i, 0]]).sum();
2162 let analytic = analytic_num / analytic_den;
2163 assert!(m.dim() == (1, 1));
2164 assert!(
2165 (m[[0, 0]] - analytic).abs() < 1e-10,
2166 "weighted projection mismatch: got {got}, analytic {analytic}",
2167 got = m[[0, 0]]
2168 );
2169 }
2170
2171 /// Regression for #372: an anchor block that internally sheds an aliased
2172 /// column makes the residualised kept-anchor width (`anchor_h.ncols()`)
2173 /// strictly smaller than the raw anchor width (`d_total`). The emitted
2174 /// `anchor_correction` must be expressed in *raw* anchor-column
2175 /// coordinates so the predict-time / install-time subtraction
2176 /// `A_raw(x)·M` is dimensionally and metrically correct. Previously the
2177 /// correction was indexed by kept directions, producing a (d_total−1)×k
2178 /// matrix and the failure
2179 /// `anchor_correction shape 36x6 does not match d_total=37`.
2180 #[test]
2181 fn compile_emits_anchor_correction_in_raw_column_coordinates() {
2182 let n = 64;
2183 // Anchor block A has 3 raw columns but only rank 2: col 2 is an exact
2184 // linear combination of cols 0 and 1, so the compiler keeps just two
2185 // anchor directions (kept width 2 < raw width 3).
2186 let a: Array2<f64> = Array2::from_shape_fn((n, 3), |(i, j)| {
2187 let c0 = (i as f64 * 0.07 + 1.0).ln();
2188 let c1 = (i as f64 * 0.13).sin();
2189 match j {
2190 0 => c0,
2191 1 => c1,
2192 _ => 2.0 * c0 - 0.5 * c1,
2193 }
2194 });
2195 // Candidate block C: partly aliases A's span plus genuine signal.
2196 let c: Array2<f64> = Array2::from_shape_fn((n, 2), |(i, j)| {
2197 0.4 * a[[i, 0]] + (j as f64) * (i as f64 * 0.05).cos() + (i as f64 * 0.011).tanh()
2198 });
2199 let w = Array1::from_shape_fn(n, |i| 0.3 + (i as f64 * 0.17).sin().abs());
2200 let hess = DiagonalScalarRowHessian::new(w.clone());
2201 let ops = vec![op(a.clone()), op(c.clone())];
2202 let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::LinkDev])
2203 .expect("compile should succeed");
2204
2205 let v = &compiled.blocks[1].t_lw;
2206 let m = compiled.blocks[1]
2207 .anchor_correction
2208 .as_ref()
2209 .expect("candidate block must carry an anchor correction");
2210 let k_kept = v.ncols();
2211 assert!(k_kept >= 1, "candidate must keep at least one direction");
2212
2213 // The off-by-one the issue tripped on: M must have one row per *raw*
2214 // anchor column (3), not per kept anchor direction (2).
2215 assert_eq!(
2216 m.nrows(),
2217 a.ncols(),
2218 "anchor_correction must be indexed by raw anchor columns (d_total), \
2219 got {} rows for {} raw anchor columns",
2220 m.nrows(),
2221 a.ncols(),
2222 );
2223 assert_eq!(m.ncols(), k_kept, "anchor_correction width must match V");
2224
2225 // Metric correctness: the raw-coordinate subtraction A_raw·M must make
2226 // the compiled candidate design W-orthogonal to the full raw anchor
2227 // span. C̃ = C·V − A·M; require Aᵀ W C̃ ≈ 0 column-wise.
2228 let c_v = c.dot(v);
2229 let a_m = a.dot(m);
2230 let c_tilde = &c_v - &a_m;
2231 let mut max_cross = 0.0_f64;
2232 for ac in 0..a.ncols() {
2233 for cc in 0..c_tilde.ncols() {
2234 let mut acc = 0.0;
2235 for i in 0..n {
2236 acc += w[i] * a[[i, ac]] * c_tilde[[i, cc]];
2237 }
2238 max_cross = max_cross.max(acc.abs());
2239 }
2240 }
2241 assert!(
2242 max_cross < 1e-9,
2243 "raw-coordinate anchor correction must W-orthogonalise the candidate \
2244 against the raw anchor span; max |Aᵀ W C̃| = {max_cross:e}"
2245 );
2246 }
2247
2248 /// §10 test #4: deliberately rank-deficient joint design. The trailing
2249 /// pivot drop must come from the *latest* block in the ordering.
2250 #[test]
2251 fn compile_drops_trailing_pivots_from_latest_block() {
2252 let n = 40;
2253 let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 + 1.0).ln() * (j as f64 + 1.0));
2254 // c is exactly a's first column → after residualising c against a,
2255 // the residual span is zero in that direction, but a non-zero
2256 // independent column also exists. Add an extra exact-alias column
2257 // to force trailing-pivot drop at the audit stage.
2258 let c = Array2::from_shape_fn((n, 2), |(i, j)| {
2259 if j == 0 {
2260 a[[i, 0]]
2261 } else {
2262 (i as f64 * 0.1).cos()
2263 }
2264 });
2265 let hess = IdentityRowHessian::new(n, 1);
2266 let ops = vec![op(a), op(c)];
2267 // Manually inject a known alias: pass a second block whose
2268 // residualised columns will themselves be linearly dependent on
2269 // the first block after metric projection — already covered by the
2270 // eigenvalue threshold inside `compile`. Verify either drop path
2271 // (eigen-threshold or audit) attributes loss to block index 1.
2272 let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2273 .expect("compile should succeed");
2274 // Either the eigen-threshold dropped a column from block 1, or
2275 // the audit did. In both cases block 1's V must have fewer than
2276 // its 2 input columns.
2277 let v1_cols = compiled.blocks[1].t_lw.ncols();
2278 assert!(
2279 v1_cols < 2 || !compiled.dropped.is_empty(),
2280 "expected rank loss attributed to block 1, got v1_cols={v1_cols}, dropped={dropped:?}",
2281 dropped = compiled.dropped
2282 );
2283 for (block_idx, _) in &compiled.dropped {
2284 assert_eq!(
2285 *block_idx, 1,
2286 "audit drops must come from the latest block only"
2287 );
2288 }
2289 }
2290
2291 /// Regression: when `audit_and_drop_trailing_pivots` truncates the
2292 /// latest block's `t_lw`, the sibling `anchor_correction` and `r_lw`
2293 /// matrices must be truncated to the same `k_kept` so the trailing-
2294 /// block install path sees a coherent
2295 /// `t_lw.ncols() == anchor_correction.ncols() == r_lw.ncols()` shape.
2296 ///
2297 /// Pre-fix bug: only `t_lw` got truncated. Downstream callers
2298 /// asserting `anchor_correction.ncols() == k_kept` then failed with
2299 /// `cross-block identifiability: anchor_correction shape D×P does
2300 /// not match expected d_total=D × k_kept=K` — surfaced via the
2301 /// large-scale V+M repro test.
2302 #[test]
2303 fn audit_truncation_keeps_t_lw_and_anchor_correction_in_lockstep() {
2304 let n = 40;
2305 let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 + 1.0).ln() * (j as f64 + 1.0));
2306 let c = Array2::from_shape_fn((n, 2), |(i, j)| {
2307 if j == 0 {
2308 a[[i, 0]]
2309 } else {
2310 (i as f64 * 0.1).cos()
2311 }
2312 });
2313 let hess = IdentityRowHessian::new(n, 1);
2314 let ops = vec![op(a), op(c)];
2315 let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2316 .expect("compile should succeed");
2317 for (idx, block) in compiled.blocks.iter().enumerate() {
2318 let k_kept = block.t_lw.ncols();
2319 if let Some(m) = block.anchor_correction.as_ref() {
2320 assert_eq!(
2321 m.ncols(),
2322 k_kept,
2323 "block {idx}: anchor_correction.ncols()={ac} must equal t_lw.ncols()={k_kept} \
2324 after audit truncation",
2325 ac = m.ncols(),
2326 );
2327 }
2328 if let Some(r) = block.r_lw.as_ref() {
2329 assert_eq!(
2330 r.ncols(),
2331 k_kept,
2332 "block {idx}: r_lw.ncols()={r_cols} must equal t_lw.ncols()={k_kept} \
2333 after audit truncation",
2334 r_cols = r.ncols(),
2335 );
2336 }
2337 }
2338 }
2339
2340 /// §10 test #5: regression test for the deleted FlexEvaluation skip
2341 /// bug. A flex anchor (represented by a dense scalar operator with the
2342 /// same column span as the parametric reference) must receive the same
2343 /// residualisation as the parametric anchor.
2344 #[test]
2345 fn compile_flex_anchor_is_first_class() {
2346 let n = 60;
2347 // Two parametric blocks A, B; a third "flex" block C whose
2348 // operator is dense (modelling a compiled flex anchor's column
2349 // span). All-parametric reference vs. mixed parametric+flex must
2350 // produce identical compiled blocks B (residualised against A)
2351 // because the compiler treats every input as a `RowJacobianOperator`.
2352 let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.07 + j as f64).sin());
2353 let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2354 0.4 * a[[i, 0]] + (j as f64) * (i as f64 + 1.0).ln()
2355 });
2356 let hess = IdentityRowHessian::new(n, 1);
2357
2358 let ops_param = vec![op(a.clone()), op(b.clone())];
2359 let compiled_param = compile(
2360 &ops_param,
2361 &hess,
2362 &[BlockOrder::Marginal, BlockOrder::Logslope],
2363 )
2364 .expect("compile should succeed");
2365
2366 // Now wrap A's design behind a mock anchor evaluator and feed it
2367 // to the compiler as a `DenseScalarOperator` with the same span.
2368 // The B-block result must match the parametric reference.
2369 let ops_flex = vec![op(a.clone()), op(b.clone())];
2370 let compiled_flex = compile(
2371 &ops_flex,
2372 &hess,
2373 &[BlockOrder::ScoreWarp, BlockOrder::LinkDev],
2374 )
2375 .expect("compile should succeed");
2376
2377 let m_param = compiled_param.blocks[1].anchor_correction.as_ref().unwrap();
2378 let m_flex = compiled_flex.blocks[1].anchor_correction.as_ref().unwrap();
2379 assert_eq!(m_param.dim(), m_flex.dim());
2380 let max_diff = (m_param - m_flex)
2381 .iter()
2382 .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2383 assert!(
2384 max_diff < 1e-12,
2385 "flex vs parametric anchor correction mismatch: {max_diff:e}"
2386 );
2387 }
2388
2389 /// §10 test #7: Bernoulli row Hessian = IRLS weight. Verified at the
2390 /// trait level — a `DiagonalScalarRowHessian` round-trips through
2391 /// `evaluate_full` to the same per-row scalar.
2392 #[test]
2393 fn bernoulli_row_hessian_matches_irls_weight() {
2394 let w = Array1::from(vec![0.1, 0.5, 0.9, 0.25, 0.75]);
2395 let hess = DiagonalScalarRowHessian::new(w.clone());
2396 let full = hess.evaluate_full();
2397 assert_eq!(full.shape(), &[5, 1, 1]);
2398 for i in 0..5 {
2399 assert_eq!(full[[i, 0, 0]], w[i]);
2400 let mut buf = [0.0_f64; 1];
2401 hess.fill_row(i, &mut buf);
2402 assert_eq!(buf[0], w[i]);
2403 }
2404 }
2405
2406 /// §10 test #8: predict-path roundtrip. With the parametric setting,
2407 /// the row-application of `(C(x)·V − A(x)·M)` at training rows must
2408 /// equal the in-metric residual computed during `compile`.
2409 #[test]
2410 fn compiler_predict_path_roundtrip() {
2411 let n = 24;
2412 let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.21).cos() + j as f64);
2413 let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2414 0.3 * a[[i, 0]] + (i as f64 + j as f64).sqrt()
2415 });
2416 let hess = IdentityRowHessian::new(n, 1);
2417 let ops = vec![op(a.clone()), op(b.clone())];
2418 let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2419 .expect("compile should succeed");
2420 let v_b = &compiled.blocks[1].t_lw;
2421 let m_b = compiled.blocks[1].anchor_correction.as_ref().unwrap();
2422 // Training-time residual: B · V − A · M.
2423 let predict_design = b.dot(v_b) - a.dot(m_b);
2424 // Compare to the algebraic in-metric residual: same expression
2425 // (identity row Hessian collapses sqrt(H) = I), so this is a
2426 // self-consistency / shape check ensuring V and M compose to the
2427 // promised predict-time operator.
2428 assert_eq!(predict_design.nrows(), n);
2429 assert_eq!(predict_design.ncols(), v_b.ncols());
2430 // Finite-value gate.
2431 for &val in predict_design.iter() {
2432 assert!(val.is_finite(), "predict design produced non-finite entry");
2433 }
2434 }
2435
2436 /// `r_lw` and `anchor_correction` are populated on every non-first
2437 /// block as `M_b · V_b` at compiled width. The first block carries
2438 /// `None`. Also verifies the H-orthogonality invariant that the
2439 /// cumulative anchor for the next iteration is orthogonal (in the row
2440 /// metric) to the prior block's design.
2441 #[test]
2442 fn compile_exposes_r_lw_equal_to_m_dot_v() {
2443 let n = 40;
2444 let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.17 + j as f64).sin());
2445 // B partially aliases A's first column, so anchor correction is non-trivial.
2446 let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2447 0.6 * a[[i, 0]] + ((i as f64) * 0.11 + j as f64).cos()
2448 });
2449 let hess = IdentityRowHessian::new(n, 1);
2450 let ops = vec![op(a.clone()), op(b.clone())];
2451 let compiled = compile(&ops, &hess, &[BlockOrder::Marginal, BlockOrder::Logslope])
2452 .expect("compile should succeed");
2453
2454 // First block: no anchor → both fields None.
2455 assert!(compiled.blocks[0].r_lw.is_none());
2456 assert!(compiled.blocks[0].anchor_correction.is_none());
2457
2458 // Second block: r_lw and anchor_correction must both equal M·V at
2459 // compiled width (p_a_kept × p_b_kept).
2460 let v_a = &compiled.blocks[0].t_lw;
2461 let v_b = &compiled.blocks[1].t_lw;
2462 let m_compiled = compiled.blocks[1]
2463 .anchor_correction
2464 .as_ref()
2465 .expect("second block must carry an anchor correction");
2466 let r_lw = compiled.blocks[1]
2467 .r_lw
2468 .as_ref()
2469 .expect("second block must expose r_lw");
2470 let p_a_kept = v_a.ncols();
2471 let p_b_kept = v_b.ncols();
2472 assert_eq!(
2473 m_compiled.dim(),
2474 (p_a_kept, p_b_kept),
2475 "anchor_correction must be at compiled width"
2476 );
2477 assert_eq!(r_lw.dim(), (p_a_kept, p_b_kept));
2478 // r_lw and anchor_correction are synonymous.
2479 let diff = r_lw - m_compiled;
2480 let max_diff = diff.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
2481 assert!(
2482 max_diff == 0.0,
2483 "r_lw and anchor_correction must be identical"
2484 );
2485
2486 // H-orthogonality (identity row metric): the residualised
2487 // compiled B-design `B·V − A·(M·V)` must be orthogonal to A in
2488 // the column-inner-product sense. This validates that the
2489 // cumulative anchor build uses `(W_b − A·M)·V` rather than `W_b·V`.
2490 let b_compiled = b.dot(v_b) - a.dot(m_compiled);
2491 let cross = a.t().dot(&b_compiled);
2492 let max_cross = cross.iter().fold(0.0_f64, |acc, &x| acc.max(x.abs()));
2493 assert!(
2494 max_cross < 1e-10,
2495 "compiled B-design must be H-orthogonal to A: max cross = {max_cross:e}"
2496 );
2497 }
2498
2499 /// `K=4` dense row Hessian: per-row PSD matrix supplied directly.
2500 struct DenseRowHessian {
2501 h: Array3<f64>,
2502 }
2503
2504 impl RowHessian for DenseRowHessian {
2505 fn k(&self) -> usize {
2506 self.h.shape()[1]
2507 }
2508 fn nrows(&self) -> usize {
2509 self.h.shape()[0]
2510 }
2511 fn fill_row(&self, row: usize, out: &mut [f64]) {
2512 let k = self.k();
2513 assert_eq!(out.len(), k * k);
2514 for c in 0..k {
2515 for d in 0..k {
2516 out[c * k + d] = self.h[[row, c, d]];
2517 }
2518 }
2519 }
2520 fn evaluate_full(&self) -> Array3<f64> {
2521 self.h.clone()
2522 }
2523 }
2524
2525 /// Reference W-based Gram for verification: build `W = sqrt(H) · J` then
2526 /// return `Wᵀ W`. Mirrors the in-walk path in [`compile`].
2527 fn reference_gram_from_w(j_full: &Array3<f64>, h_full: &Array3<f64>) -> Array2<f64> {
2528 let w = scale_block_by_sqrt_h(j_full, h_full);
2529 fast_ata(&w)
2530 }
2531
2532 /// Two-block toy at K=4: build per-channel (n × p_b) blocks and verify
2533 /// the closed-form Gram matches the reference W-based Gram.
2534 #[test]
2535 fn closed_form_gram_matches_reference_two_block_k4() {
2536 let n = 17;
2537 let k = 4;
2538 let p_a = 3;
2539 let p_b = 2;
2540
2541 // Random-ish per-channel design matrices for each block.
2542 let make_block = |seed: f64, n: usize, p: usize| -> Vec<Option<Array2<f64>>> {
2543 (0..4)
2544 .map(|c| {
2545 let m = Array2::from_shape_fn((n, p), |(i, j)| {
2546 ((i as f64 + 1.0) * (j as f64 + 1.0) * (c as f64 + 1.0) + seed).sin()
2547 });
2548 Some(m)
2549 })
2550 .collect()
2551 };
2552 let block_a = make_block(0.3, n, p_a);
2553 let block_b = make_block(1.1, n, p_b);
2554
2555 // Per-row PSD H: random symmetric PSD via Mᵀ M.
2556 let h = Array3::from_shape_fn((n, k, k), |(i, c, d)| {
2557 let mut acc = 0.0;
2558 for r in 0..k {
2559 let mc = ((i + 1) as f64 * (c + 1) as f64 * (r + 1) as f64 * 0.13).cos();
2560 let md = ((i + 1) as f64 * (d + 1) as f64 * (r + 1) as f64 * 0.13).cos();
2561 acc += mc * md;
2562 }
2563 acc + if c == d { 0.5 } else { 0.0 }
2564 });
2565 let row_hess = DenseRowHessian { h: h.clone() };
2566
2567 let channel_blocks = PrimaryChannelBlocks {
2568 blocks: vec![block_a.clone(), block_b.clone()],
2569 };
2570 let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
2571
2572 let gram = build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges)
2573 .expect("closed-form Gram should succeed");
2574
2575 // Reference: assemble full row Jacobian J as (n × p_total × K) by
2576 // placing per-block, per-channel slices at the right columns.
2577 let p_total = p_a + p_b;
2578 let mut j_full = Array3::<f64>::zeros((n, p_total, k));
2579 for c in 0..k {
2580 if let Some(xa) = block_a[c].as_ref() {
2581 for i in 0..n {
2582 for j in 0..p_a {
2583 j_full[[i, j, c]] = xa[[i, j]];
2584 }
2585 }
2586 }
2587 if let Some(xb) = block_b[c].as_ref() {
2588 for i in 0..n {
2589 for j in 0..p_b {
2590 j_full[[i, p_a + j, c]] = xb[[i, j]];
2591 }
2592 }
2593 }
2594 }
2595 let ref_gram = reference_gram_from_w(&j_full, &h);
2596
2597 let diff = &gram - &ref_gram;
2598 let max_err = diff.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2599 let scale = ref_gram.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2600 assert!(
2601 max_err < 1e-9 * scale.max(1.0),
2602 "closed-form Gram mismatches reference: max_err={max_err:e}, scale={scale:e}"
2603 );
2604
2605 // Symmetry of the result.
2606 for i in 0..p_total {
2607 for j in 0..p_total {
2608 assert!(
2609 (gram[[i, j]] - gram[[j, i]]).abs() < 1e-12,
2610 "closed-form Gram not symmetric at ({i},{j})"
2611 );
2612 }
2613 }
2614 }
2615
2616 /// Channel sparsity test: block A contributes only to channel 0, block B
2617 /// only to channel 3. Cross-block contribution must be exactly
2618 /// `(X_A^(0))ᵀ · diag(h_{03}) · X_B^(3)` — zero when `h_03 ≡ 0`,
2619 /// non-zero otherwise.
2620 #[test]
2621 fn closed_form_gram_channel_sparsity() {
2622 let n = 13;
2623 let k = 4;
2624 let p_a = 2;
2625 let p_b = 2;
2626
2627 let xa = Array2::from_shape_fn((n, p_a), |(i, j)| ((i + 1) as f64 * 0.21 + j as f64).cos());
2628 let xb = Array2::from_shape_fn((n, p_b), |(i, j)| {
2629 ((i + 1) as f64 * 0.17 + j as f64).sin() + 0.5
2630 });
2631
2632 let block_a: Vec<Option<Array2<f64>>> = vec![Some(xa.clone()), None, None, None];
2633 let block_b: Vec<Option<Array2<f64>>> = vec![None, None, None, Some(xb.clone())];
2634
2635 // Case 1: H with non-zero h_{03} (and h_{30}). The cross-block
2636 // (A, B) entries must equal `Xaᵀ · diag(h_03) · Xb`.
2637 let h_03_vec = Array1::from_shape_fn(n, |i| 0.7 + 0.3 * ((i as f64) * 0.4).sin());
2638 let h = Array3::from_shape_fn((n, k, k), |(i, c, d)| {
2639 // Symmetric: only the (0,3)/(3,0) off-diagonal carries weight,
2640 // plus a strong PSD diagonal so per-row H is PSD.
2641 if (c, d) == (0, 3) || (c, d) == (3, 0) {
2642 h_03_vec[i]
2643 } else if c == d {
2644 2.0
2645 } else {
2646 0.0
2647 }
2648 });
2649 let row_hess = DenseRowHessian { h: h.clone() };
2650
2651 let channel_blocks = PrimaryChannelBlocks {
2652 blocks: vec![block_a.clone(), block_b.clone()],
2653 };
2654 let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
2655 let gram = build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges)
2656 .expect("closed-form Gram should succeed");
2657
2658 // Cross-block submatrix.
2659 let cross = gram.slice(s![0..p_a, p_a..(p_a + p_b)]).to_owned();
2660 // Expected: only the (c=0, d=3) channel-pair survives.
2661 let expected = fast_xt_diag_y(&xa, &h_03_vec, &xb);
2662 let diff = &cross - &expected;
2663 let max_err = diff.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2664 assert!(
2665 max_err < 1e-12,
2666 "cross-block Gram must equal Xaᵀ·diag(h_03)·Xb: max_err={max_err:e}"
2667 );
2668
2669 // Case 2: zero out h_{03} → cross-block must be zero.
2670 let h_zero = Array3::from_shape_fn((n, k, k), |(_, c, d)| if c == d { 2.0 } else { 0.0 });
2671 let row_hess_zero = DenseRowHessian { h: h_zero };
2672 let gram_zero =
2673 build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess_zero, &raw_ranges)
2674 .expect("closed-form Gram should succeed");
2675 let cross_zero = gram_zero.slice(s![0..p_a, p_a..(p_a + p_b)]);
2676 let max_zero = cross_zero.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2677 assert!(
2678 max_zero < 1e-12,
2679 "cross-block Gram must vanish when coupling channel pair is zero: got {max_zero:e}"
2680 );
2681 }
2682
2683 /// Structural Gram: identity per-row Hessian collapses the channel-pair
2684 /// sum to within-channel `XᵀX`. Validates [`build_raw_grams_structural`].
2685 #[test]
2686 fn structural_gram_matches_within_channel_sum() {
2687 let n = 11;
2688 let p_a = 2;
2689 let p_b = 3;
2690 let make_block = |seed: f64, n: usize, p: usize| -> Vec<Option<Array2<f64>>> {
2691 (0..4)
2692 .map(|c| {
2693 if c == 1 {
2694 // Sparse channel for variety.
2695 return None;
2696 }
2697 Some(Array2::from_shape_fn((n, p), |(i, j)| {
2698 ((i as f64 + 1.0) * (j as f64 + 1.0) + seed * (c as f64 + 1.0)).sin()
2699 }))
2700 })
2701 .collect()
2702 };
2703 let block_a = make_block(0.1, n, p_a);
2704 let block_b = make_block(0.7, n, p_b);
2705 let channel_blocks = PrimaryChannelBlocks {
2706 blocks: vec![block_a.clone(), block_b.clone()],
2707 };
2708 let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
2709 let gram = build_raw_grams_structural(&channel_blocks, &raw_ranges);
2710
2711 // Hand-compute cross block: Σ_c Xaᵀ Xb over channels where both
2712 // sides are present (skipping channel 1 entirely).
2713 let mut expected_cross = Array2::<f64>::zeros((p_a, p_b));
2714 for c in 0..4 {
2715 if let (Some(xa), Some(xb)) = (block_a[c].as_ref(), block_b[c].as_ref()) {
2716 expected_cross += &fast_atb(xa, xb);
2717 }
2718 }
2719 let cross = gram.slice(s![0..p_a, p_a..(p_a + p_b)]).to_owned();
2720 let diff = &cross - &expected_cross;
2721 let max_err = diff.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2722 assert!(
2723 max_err < 1e-12,
2724 "structural cross-block must equal Σ_c Xaᵀ·Xb: max_err={max_err:e}"
2725 );
2726
2727 // Symmetry.
2728 for i in 0..(p_a + p_b) {
2729 for j in 0..(p_a + p_b) {
2730 assert!(
2731 (gram[[i, j]] - gram[[j, i]]).abs() < 1e-12,
2732 "structural Gram not symmetric at ({i},{j})"
2733 );
2734 }
2735 }
2736 }
2737
2738 // Per-row Hessian (K=1) sourced from an arbitrary positive vector —
2739 // used by the dual-metric sanity test to drive both structural and
2740 // curvature passes with the *same* non-identity weights.
2741 fn diag_hess(w: Array1<f64>) -> DiagonalScalarRowHessian {
2742 DiagonalScalarRowHessian::new(w)
2743 }
2744
2745 /// L#1: dual-metric with structural = curvature reproduces single-metric
2746 /// `compile()` exactly. The two passes degenerate to one because the
2747 /// structural-anchor and curvature-anchor are the same matrix.
2748 #[test]
2749 fn dual_metric_with_equal_metrics_matches_single_metric() {
2750 let n = 36;
2751 let a = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * 0.13 + j as f64).sin());
2752 // B partially aliases A's first column.
2753 let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2754 0.4 * a[[i, 0]] + (i as f64 * 0.07 + j as f64).cos()
2755 });
2756 let w = Array1::from_shape_fn(n, |i| 0.5 + (i as f64 * 0.17).sin().abs());
2757 let curvature = diag_hess(w.clone());
2758 let ordering = [BlockOrder::Marginal, BlockOrder::Logslope];
2759
2760 let ops_single = vec![op(a.clone()), op(b.clone())];
2761 let single = compile(&ops_single, &curvature, &ordering)
2762 .expect("single-metric compile should succeed");
2763
2764 // Dual-metric with structural = curvature (same `RowHessian` on both
2765 // sides). The structural pass collapses to the curvature pass.
2766 let structural_same = diag_hess(w.clone());
2767 let ops_dual = vec![op(a.clone()), op(b.clone())];
2768 let dual = compile_with_dual_metric(&ops_dual, &curvature, &structural_same, &ordering)
2769 .expect("dual-metric compile should succeed");
2770
2771 assert_eq!(single.blocks.len(), dual.blocks.len());
2772 for (idx, (sb, db)) in single.blocks.iter().zip(dual.blocks.iter()).enumerate() {
2773 assert_eq!(sb.t_lw.dim(), db.t_lw.dim(), "block {idx}: V dims differ");
2774 let max_v = (&sb.t_lw - &db.t_lw)
2775 .iter()
2776 .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2777 assert!(max_v < 1e-10, "block {idx}: V mismatch {max_v:e}");
2778 match (sb.anchor_correction.as_ref(), db.anchor_correction.as_ref()) {
2779 (None, None) => {}
2780 (Some(s), Some(d)) => {
2781 assert_eq!(s.dim(), d.dim());
2782 let max_m = (s - d).iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2783 assert!(max_m < 1e-10, "block {idx}: M mismatch {max_m:e}");
2784 }
2785 _ => panic!("block {idx}: one side has anchor correction, the other does not"),
2786 }
2787 }
2788 assert_eq!(single.joint_rank, dual.joint_rank);
2789 }
2790
2791 /// L#2: the pilot-curvature trap. A 2-block toy where the pilot
2792 /// curvature `H` has a zero direction that is NOT a real gauge — the
2793 /// dual-metric path keeps it (identity-structural sees it as a full-
2794 /// rank structural direction), while a single-metric path through the
2795 /// same H would drop it.
2796 ///
2797 /// Construction: two K=1 blocks `A` (n × 1) and `B` (n × 1). Choose H
2798 /// (diagonal row weights) so that `H · B` happens to be a scalar
2799 /// multiple of `H · A` (curvature alias) but `B` is *not* a scalar
2800 /// multiple of `A` in the unweighted metric. Specifically, pick rows
2801 /// where `w_i` is non-zero only on a handful of rows where A and B
2802 /// happen to be proportional, and zero on the rows where they differ.
2803 /// Under identity-structural this is structurally-independent; under H
2804 /// it is a (spurious) curvature alias.
2805 #[test]
2806 fn dual_metric_resists_pilot_curvature_alias() {
2807 let n = 12;
2808 // A: x_i = i+1 (no zeros). B: equals 2·A on rows 0..6 only; the
2809 // remaining rows are uncorrelated (linear vs trigonometric).
2810 let a = Array2::from_shape_fn((n, 1), |(i, _)| (i as f64) + 1.0);
2811 let b = Array2::from_shape_fn((n, 1), |(i, _)| {
2812 if i < 6 {
2813 2.0 * a[[i, 0]]
2814 } else {
2815 ((i as f64) * 0.3).cos() + 0.5
2816 }
2817 });
2818
2819 // Curvature weights are non-zero ONLY on the rows where B == 2A.
2820 // Under curvature metric, B is exactly 2·A → curvature-rank drops
2821 // B fully. Under identity-structural, B is independent of A across
2822 // all rows → structural-rank is 1 (kept).
2823 let mut w_vec = vec![0.0_f64; n];
2824 for w in &mut w_vec[..6] {
2825 *w = 1.0;
2826 }
2827 let w = Array1::from(w_vec);
2828 let curvature = diag_hess(w.clone());
2829
2830 // Reference single-metric compile (uses identity by `compile()` —
2831 // which now routes through identity-structural). For this test we
2832 // explicitly invoke the dual-metric API both ways.
2833 let id_struct = IdentityRowHessian::new(n, 1);
2834 let ordering = [BlockOrder::Marginal, BlockOrder::Logslope];
2835
2836 // Path 1: dual-metric with identity-structural (the new default).
2837 // Structural pass: B is independent of A across all rows → keep
2838 // B's single column.
2839 let ops_dual = vec![op(a.clone()), op(b.clone())];
2840 let dual = compile_with_dual_metric(&ops_dual, &curvature, &id_struct, &ordering);
2841
2842 // Path 2: dual-metric with structural = curvature (the "H decides
2843 // everything" trap). On the curvature-only rows, B ≡ 2A, so
2844 // structural pass sees zero residual span and rejects the block.
2845 let ops_h_only = vec![op(a.clone()), op(b.clone())];
2846 let h_only = compile_with_dual_metric(&ops_h_only, &curvature, &curvature, &ordering);
2847
2848 // The H-only path must fail (FullyAliased) or strip B's column.
2849 // The dual (identity-structural) path must keep B.
2850 match h_only {
2851 Err(CompilerError::FullyAliased { block_idx, .. }) => {
2852 assert_eq!(block_idx, 1, "H-only path must alias block 1");
2853 }
2854 Ok(out) => {
2855 // If the H-only path somehow compiled, it must have
2856 // either dropped B's column to zero width or audited it
2857 // out. Either way B's V must be empty after the audit
2858 // attributes the drop.
2859 let v1_cols = out.blocks[1].t_lw.ncols();
2860 assert!(
2861 v1_cols == 0 || !out.dropped.is_empty(),
2862 "H-only path should reject B's curvature-aliased column; v1_cols={v1_cols}, dropped={dropped:?}",
2863 dropped = out.dropped,
2864 );
2865 }
2866 Err(other) => panic!("unexpected H-only error: {other:?}"),
2867 }
2868
2869 let dual =
2870 dual.expect("dual-metric must succeed: identity-structural sees B as independent");
2871 // The dual path may still drop B's column at the joint audit step
2872 // because the joint H-scaled design is rank-1 (only the first
2873 // block contributes non-zero rows under the curvature weights).
2874 // What matters is that the *structural* decision did NOT drop B
2875 // — verified by the structural pass not raising FullyAliased and
2876 // by B's `t_lw` having the full structural width before the audit
2877 // demotes it. After audit, B's V may shrink because the curvature
2878 // joint design is rank-deficient, and that is expected.
2879 assert_eq!(dual.blocks.len(), 2);
2880 assert_eq!(dual.blocks[0].t_lw.ncols(), 1, "A must keep its column");
2881 // Block 1 either keeps its structural rank-1 column or is audited
2882 // away by the joint H-rank check, but in either case the per-block
2883 // pre-audit width must reflect that the structural pass kept the
2884 // column (i.e. the function did not return FullyAliased).
2885 let v1_post_audit = dual.blocks[1].t_lw.ncols();
2886 let dropped_count = dual.dropped.len();
2887 assert_eq!(
2888 v1_post_audit + dropped_count,
2889 1,
2890 "structural pass kept B's column; audit may demote it but the pre-audit width was 1"
2891 );
2892 }
2893
2894 /// L#3: identity-structural lets the compiler keep a direction even
2895 /// when the pilot curvature has reduced rank. This is the same
2896 /// scenario as L#2 but with a curvature `H` whose row weights are all
2897 /// strictly positive — so the *only* aliasing source is the structural
2898 /// pass deciding to keep or drop. The dual-metric path with non-trivial
2899 /// `H` and identity-structural must agree with the dual-metric path
2900 /// with identity on both sides whenever the blocks are structurally
2901 /// non-aliased.
2902 #[test]
2903 fn dual_metric_identity_structural_preserves_full_rank() {
2904 let n = 24;
2905 let a = Array2::from_shape_fn((n, 2), |(i, j)| ((i + 1) as f64 + j as f64).sqrt());
2906 let b = Array2::from_shape_fn((n, 2), |(i, j)| {
2907 ((i + 1) as f64).ln() + (i as f64 * 0.1 + j as f64).cos()
2908 });
2909 let w = Array1::from_shape_fn(n, |i| 0.4 + (i as f64 * 0.05).sin().powi(2));
2910 let curvature = diag_hess(w.clone());
2911 let id_struct = IdentityRowHessian::new(n, 1);
2912 let ordering = [BlockOrder::Marginal, BlockOrder::Logslope];
2913
2914 let ops = vec![op(a.clone()), op(b.clone())];
2915 let out =
2916 compile_with_dual_metric(&ops, &curvature, &id_struct, &ordering).expect("compile");
2917 // Both blocks structurally independent → both keep full width.
2918 assert_eq!(out.blocks[0].t_lw.ncols(), 2);
2919 assert_eq!(out.blocks[1].t_lw.ncols(), 2);
2920 assert_eq!(out.dropped.len(), 0);
2921 assert_eq!(out.joint_rank, 4);
2922 }
2923
2924 /// Smoke test for the GPU-or-CPU dispatch helper. On non-CUDA hosts
2925 /// (or when the runtime is unavailable) the helper falls back to the
2926 /// CPU closed-form builders; the result must match the CPU builders
2927 /// called directly. When a CUDA runtime is live, parity vs. CPU is
2928 /// verified to tight tolerance.
2929 #[test]
2930 fn build_primary_grams_gpu_or_cpu_two_block_k4_matches_cpu() {
2931 let n = 11;
2932 let k = 4;
2933 let p_a = 2;
2934 let p_b = 3;
2935
2936 let make_block = |seed: f64, n: usize, p: usize| -> Vec<Option<Array2<f64>>> {
2937 (0..4)
2938 .map(|c| {
2939 let m = Array2::from_shape_fn((n, p), |(i, j)| {
2940 ((i as f64 + 1.0) * (j as f64 + 1.0) * (c as f64 + 1.0) + seed).sin()
2941 });
2942 Some(m)
2943 })
2944 .collect()
2945 };
2946 let block_a = make_block(0.7, n, p_a);
2947 let block_b = make_block(-0.4, n, p_b);
2948
2949 let h = Array3::from_shape_fn((n, k, k), |(i, c, d)| {
2950 let mut acc = 0.0;
2951 for r in 0..k {
2952 let mc = ((i + 1) as f64 * (c + 1) as f64 * (r + 1) as f64 * 0.11).cos();
2953 let md = ((i + 1) as f64 * (d + 1) as f64 * (r + 1) as f64 * 0.11).cos();
2954 acc += mc * md;
2955 }
2956 acc + if c == d { 0.25 } else { 0.0 }
2957 });
2958 let row_hess = DenseRowHessian { h: h.clone() };
2959
2960 let channel_blocks = PrimaryChannelBlocks {
2961 blocks: vec![block_a, block_b],
2962 };
2963 let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
2964
2965 let (gram_h, gram_struct) =
2966 build_primary_grams_gpu_or_cpu(&channel_blocks, &row_hess, &raw_ranges)
2967 .expect("dispatch helper should succeed");
2968
2969 let cpu_h = build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges)
2970 .expect("CPU curvature Gram should succeed");
2971 let cpu_s = build_raw_grams_structural(&channel_blocks, &raw_ranges);
2972
2973 let tol = 1e-9_f64;
2974 for idx in cpu_h.indexed_iter().map(|(i, _)| i) {
2975 let diff = (gram_h[idx] - cpu_h[idx]).abs();
2976 let scale = cpu_h[idx].abs().max(1.0);
2977 assert!(
2978 diff <= tol * scale,
2979 "gram_h mismatch at {idx:?}: helper={} cpu={}",
2980 gram_h[idx],
2981 cpu_h[idx]
2982 );
2983 }
2984 for idx in cpu_s.indexed_iter().map(|(i, _)| i) {
2985 let diff = (gram_struct[idx] - cpu_s[idx]).abs();
2986 let scale = cpu_s[idx].abs().max(1.0);
2987 assert!(
2988 diff <= tol * scale,
2989 "gram_struct mismatch at {idx:?}: helper={} cpu={}",
2990 gram_struct[idx],
2991 cpu_s[idx]
2992 );
2993 }
2994 }
2995
2996 // ---- compile_from_raw_grams tests ----
2997
2998 /// Build (gram_h, gram_struct) for a K=1 scalar two-block toy via the
2999 /// per-block channel-block builders. Used by the closed-form tests
3000 /// below.
3001 fn scalar_grams_two_block(
3002 a: &Array2<f64>,
3003 b: &Array2<f64>,
3004 w: &Array1<f64>,
3005 ) -> (Array2<f64>, Array2<f64>, Vec<std::ops::Range<usize>>) {
3006 let p_a = a.ncols();
3007 let p_b = b.ncols();
3008 let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
3009 let channel_blocks = PrimaryChannelBlocks {
3010 blocks: vec![vec![Some(a.clone())], vec![Some(b.clone())]],
3011 };
3012 let row_hess = DiagonalScalarRowHessian::new(w.clone());
3013 let gram_h =
3014 build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges).unwrap();
3015 let gram_struct = build_raw_grams_structural(&channel_blocks, &raw_ranges);
3016 (gram_h, gram_struct, raw_ranges)
3017 }
3018
3019 /// Block B is a column-duplicate of block A in the structural metric
3020 /// → the lower-priority block compiles to zero width instead of making
3021 /// callers skip reduced-coordinate construction.
3022 #[test]
3023 fn compile_from_raw_grams_full_structural_alias() {
3024 let n = 10;
3025 let a = Array2::from_shape_fn((n, 2), |(i, j)| ((i + 1) as f64 * (j + 1) as f64).sin());
3026 // Block B = A · L for some 2×2 invertible L → same column span.
3027 let l = Array2::from_shape_vec((2, 2), vec![1.0, 0.5, -0.25, 1.0]).unwrap();
3028 let b = a.dot(&l);
3029 let w = Array1::ones(n);
3030 let (gram_h, gram_struct, raw_ranges) = scalar_grams_two_block(&a, &b, &w);
3031 let res = compile_from_raw_grams(
3032 &gram_h,
3033 &gram_struct,
3034 &raw_ranges,
3035 &[BlockOrder::Marginal, BlockOrder::Logslope],
3036 )
3037 .expect("lower-priority full alias should compile to zero width");
3038 assert_eq!(res.compiled_block_ranges[0].len(), 2);
3039 assert_eq!(res.compiled_block_ranges[1].len(), 0);
3040 assert_eq!(res.raw_from_compiled.dim(), (4, 2));
3041 assert!(
3042 res.raw_from_compiled
3043 .slice(s![raw_ranges[1].clone(), ..])
3044 .iter()
3045 .all(|v| v.abs() <= 1.0e-12),
3046 "zero-width block must not retain raw coefficient directions in T"
3047 );
3048 }
3049
3050 /// A zero-width *first* block has no columns to alias and must compile to
3051 /// an empty range with the remaining blocks intact — not abort with
3052 /// `FullyAliased`. Regression for the survival location-scale lognormal AFT
3053 /// pre-fit channel-aware audit, whose `time_transform` block collapses to
3054 /// zero free coefficients under the parametric AFT reduction and previously
3055 /// crashed the fit ("block of width 0 has zero structural span").
3056 #[test]
3057 fn compile_from_raw_grams_zero_width_first_block_is_identifiable() {
3058 let n = 12;
3059 let empty = Array2::<f64>::zeros((n, 0));
3060 let b = Array2::from_shape_fn((n, 2), |(i, j)| {
3061 ((i + 1) as f64 * (j + 1) as f64 * 0.23).cos()
3062 });
3063 let w = Array1::ones(n);
3064 let (gram_h, gram_struct, raw_ranges) = scalar_grams_two_block(&empty, &b, &w);
3065 let map = compile_from_raw_grams(
3066 &gram_h,
3067 &gram_struct,
3068 &raw_ranges,
3069 &[BlockOrder::Marginal, BlockOrder::Logslope],
3070 )
3071 .expect("zero-width first block must be trivially identifiable, not FullyAliased");
3072 assert_eq!(
3073 map.compiled_block_ranges[0].len(),
3074 0,
3075 "empty first block keeps zero columns"
3076 );
3077 assert_eq!(
3078 map.compiled_block_ranges[1].len(),
3079 2,
3080 "the second block keeps its full structural rank"
3081 );
3082 assert_eq!(map.raw_from_compiled.dim(), (2, 2));
3083 }
3084
3085 /// A `protected` first block keeps every raw column even when it is
3086 /// internally rank-deficient (a duplicate-column structural null that the
3087 /// unprotected path drops), and later blocks still reduce against the full
3088 /// raw anchor. Regression for the survival marginal-slope monotone
3089 /// time-wiggle time block: its chain-rule Jacobian recomputes a fixed
3090 /// `p_tw`-column wiggle basis on every evaluation, so a reduced (`p_time <
3091 /// p_tw`) time design made that Jacobian write past its buffer — an
3092 /// out-of-bounds panic in the phase-4b compiled-map path.
3093 #[test]
3094 fn compile_from_raw_grams_protected_keeps_full_rank_deficient_first_block() {
3095 let n = 14;
3096 // Block A (first, highest priority): two IDENTICAL columns → structural
3097 // rank 1, i.e. one within-block null direction the unprotected filter
3098 // drops. Stands in for the wiggle time block whose raw width must be
3099 // preserved.
3100 // Column value depends only on the row → both columns are identical.
3101 let a = Array2::from_shape_fn((n, 2), |(i, _)| ((i + 1) as f64 * 0.37).sin());
3102 // Block B: genuinely independent, so it survives at full width.
3103 let b = Array2::from_shape_fn((n, 2), |(i, j)| {
3104 ((i + 1) as f64 * (0.29 + j as f64 * 0.11)).cos()
3105 });
3106 let w = Array1::ones(n);
3107 let (gram_h, gram_struct, raw_ranges) = scalar_grams_two_block(&a, &b, &w);
3108 let ordering = [BlockOrder::Time, BlockOrder::Marginal];
3109
3110 // Unprotected: the duplicate column is dropped → block 0 reduces to 1.
3111 let unprotected = compile_from_raw_grams(&gram_h, &gram_struct, &raw_ranges, &ordering)
3112 .expect("unprotected compile");
3113 assert_eq!(
3114 unprotected.compiled_block_ranges[0].len(),
3115 1,
3116 "unprotected first block drops its structural-null direction"
3117 );
3118
3119 // Protected: block 0 keeps both raw columns; T's block-0 diagonal is the
3120 // 2×2 identity (raw coords == compiled coords for the protected block).
3121 let protected = compile_from_raw_grams_protected(
3122 &gram_h,
3123 &gram_struct,
3124 &raw_ranges,
3125 &ordering,
3126 &[true, false],
3127 )
3128 .expect("protected compile");
3129 assert_eq!(
3130 protected.compiled_block_ranges[0].len(),
3131 2,
3132 "protected first block retains its full raw width"
3133 );
3134 let t_block0 = protected
3135 .raw_from_compiled
3136 .slice(s![0..2, protected.compiled_block_ranges[0].clone()])
3137 .to_owned();
3138 for i in 0..2 {
3139 for j in 0..2 {
3140 let expect = if i == j { 1.0 } else { 0.0 };
3141 assert!(
3142 (t_block0[[i, j]] - expect).abs() <= 1e-12,
3143 "protected first block map must be identity, got [{i},{j}]={}",
3144 t_block0[[i, j]]
3145 );
3146 }
3147 }
3148 }
3149
3150 #[test]
3151 fn orthogonalization_annotates_independent_and_fully_absorbed_blocks() {
3152 let n = 18;
3153 let anchor = Array2::from_shape_fn((n, 2), |(i, j)| {
3154 ((i + 1) as f64 * (0.19 + j as f64 * 0.07)).sin()
3155 });
3156 let duplicate = anchor.clone();
3157 let independent = Array2::from_shape_fn((n, 1), |(i, _)| ((i + 1) as f64 * 0.43).cos());
3158 let weight = vec![1.0; n];
3159 let ortho = orthogonalize_design_blocks(
3160 &[anchor, duplicate, independent],
3161 &[200, 100, 50],
3162 &weight,
3163 )
3164 .expect("structural annotation compile");
3165
3166 assert_eq!(
3167 ortho.direction_annotations[0].kind,
3168 PenalizedDirectionAnnotationKind::Independent
3169 );
3170 assert_eq!(ortho.direction_annotations[0].absorbed_width, 0);
3171 assert_eq!(
3172 ortho.direction_annotations[1].kind,
3173 PenalizedDirectionAnnotationKind::FullyAbsorbedByHigherPriority,
3174 "a duplicated lower-priority block is the same realized-design direction"
3175 );
3176 assert_eq!(ortho.direction_annotations[1].raw_width, 2);
3177 assert_eq!(ortho.direction_annotations[1].kept_width, 0);
3178 assert_eq!(ortho.direction_annotations[1].absorbed_width, 2);
3179 assert_eq!(
3180 ortho.direction_annotations[2].kind,
3181 PenalizedDirectionAnnotationKind::Independent,
3182 "a genuinely new realized-design direction keeps its own penalty block"
3183 );
3184 assert_eq!(ortho.direction_annotations[2].raw_width, 1);
3185 assert_eq!(ortho.direction_annotations[2].kept_width, 1);
3186 assert_eq!(ortho.dropped, vec![(1, 2)]);
3187 }
3188
3189 #[test]
3190 fn orthogonalization_rejects_invalid_row_metric_weights() {
3191 let design = Array2::from_shape_fn((4, 1), |(row, _)| row as f64 + 1.0);
3192 for invalid in [-1.0, f64::NAN, f64::INFINITY] {
3193 let result = orthogonalize_design_blocks(
3194 std::slice::from_ref(&design),
3195 &[1],
3196 &[1.0, invalid, 1.0, 1.0],
3197 );
3198 let error = match result {
3199 Err(error) => error,
3200 Ok(_) => panic!("an invalid W-metric must be rejected, not silently clamped"),
3201 };
3202 assert!(
3203 matches!(error, CompilerError::InvalidMetric(_)),
3204 "unexpected error for weight {invalid}: {error}"
3205 );
3206 }
3207 }
3208
3209 #[test]
3210 fn compile_from_raw_grams_three_block_full_logslope_alias_keeps_fast_path() {
3211 let n = 24;
3212 let time = Array2::from_shape_fn((n, 2), |(i, j)| {
3213 ((i + 1) as f64 * (j + 2) as f64 * 0.17).sin()
3214 });
3215 let marginal = Array2::from_shape_fn((n, 1), |(i, _)| ((i + 3) as f64 * 0.11).cos());
3216 let logslope = marginal.clone();
3217 let p_time = time.ncols();
3218 let p_marg = marginal.ncols();
3219 let p_log = logslope.ncols();
3220 let raw_ranges = vec![
3221 0..p_time,
3222 p_time..(p_time + p_marg),
3223 (p_time + p_marg)..(p_time + p_marg + p_log),
3224 ];
3225 let channel_blocks = PrimaryChannelBlocks {
3226 blocks: vec![
3227 vec![Some(time.clone())],
3228 vec![Some(marginal.clone())],
3229 vec![Some(logslope.clone())],
3230 ],
3231 };
3232 let row_hess = DiagonalScalarRowHessian::new(Array1::ones(n));
3233 let gram_h =
3234 build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges).unwrap();
3235 let gram_struct = build_raw_grams_structural(&channel_blocks, &raw_ranges);
3236
3237 let map = compile_from_raw_grams(
3238 &gram_h,
3239 &gram_struct,
3240 &raw_ranges,
3241 &[BlockOrder::Time, BlockOrder::Marginal, BlockOrder::Logslope],
3242 )
3243 .expect("fully aliased logslope block should not skip the compiled-map path");
3244
3245 assert_eq!(map.compiled_block_ranges[0].len(), p_time);
3246 assert_eq!(map.compiled_block_ranges[1].len(), p_marg);
3247 assert_eq!(map.compiled_block_ranges[2].len(), 0);
3248 assert_eq!(
3249 map.raw_from_compiled.dim(),
3250 (p_time + p_marg + p_log, p_time + p_marg)
3251 );
3252 let x_raw = {
3253 let mut out = Array2::<f64>::zeros((n, p_time + p_marg + p_log));
3254 out.slice_mut(s![.., raw_ranges[0].clone()]).assign(&time);
3255 out.slice_mut(s![.., raw_ranges[1].clone()])
3256 .assign(&marginal);
3257 out.slice_mut(s![.., raw_ranges[2].clone()])
3258 .assign(&logslope);
3259 out
3260 };
3261 let x_compiled = fast_ab(&x_raw, &map.raw_from_compiled);
3262 let rrqr = rrqr_with_permutation(&x_compiled, default_rrqr_rank_alpha()).unwrap();
3263 assert_eq!(rrqr.rank, x_compiled.ncols());
3264 }
3265
3266 /// Partial alias: block B's first column duplicates A; second column is
3267 /// independent. Closed-form `T` must have shape `(p_raw × (p_a + 1))`
3268 /// — block 1's compiled width is exactly the independent direction —
3269 /// and the joint design `X_raw · T` must span the same column space as
3270 /// the W-based reference compile result.
3271 #[test]
3272 fn compile_from_raw_grams_partial_alias_matches_w_reference() {
3273 let n = 25;
3274 let a = Array2::from_shape_fn((n, 2), |(i, j)| {
3275 ((i + 1) as f64 * (j + 1) as f64 * 0.3).sin()
3276 });
3277 // B = [a_0 + independent]
3278 let mut b = Array2::<f64>::zeros((n, 2));
3279 for i in 0..n {
3280 b[[i, 0]] = a[[i, 0]];
3281 b[[i, 1]] = ((i + 1) as f64 * 0.7).cos();
3282 }
3283 let w = Array1::from_shape_fn(n, |i| 1.0 + 0.1 * (i as f64));
3284 let (gram_h, gram_struct, raw_ranges) = scalar_grams_two_block(&a, &b, &w);
3285 let compiled = compile_from_raw_grams(
3286 &gram_h,
3287 &gram_struct,
3288 &raw_ranges,
3289 &[BlockOrder::Marginal, BlockOrder::Logslope],
3290 )
3291 .expect("closed-form compile must succeed");
3292 let p_a = a.ncols();
3293 let p_b = b.ncols();
3294 assert_eq!(compiled.raw_from_compiled.shape()[0], p_a + p_b);
3295 assert_eq!(
3296 compiled.raw_from_compiled.shape()[1],
3297 p_a + 1,
3298 "partial alias should leave compiled width = p_a + 1 (one column dropped from B)"
3299 );
3300 // Block ranges sum to compiled width.
3301 assert_eq!(compiled.compiled_block_ranges[0], 0..p_a);
3302 assert_eq!(
3303 compiled.compiled_block_ranges[1].end - compiled.compiled_block_ranges[1].start,
3304 1
3305 );
3306
3307 // Column-span equality vs. W-reference: stack the raw design
3308 // X_raw = [A | B] and check that range(X_raw · T) ⊆ range(X_raw)
3309 // and has the same rank as the W-based compile.
3310 let mut x_raw = Array2::<f64>::zeros((n, p_a + p_b));
3311 for i in 0..n {
3312 for j in 0..p_a {
3313 x_raw[[i, j]] = a[[i, j]];
3314 }
3315 for j in 0..p_b {
3316 x_raw[[i, p_a + j]] = b[[i, j]];
3317 }
3318 }
3319 let x_compiled = fast_ab(&x_raw, &compiled.raw_from_compiled);
3320 // Rank of compiled design via Gram eigvals.
3321 let g_compiled = fast_ata(&x_compiled);
3322 let (evals, _) = g_compiled.eigh(Side::Lower).unwrap();
3323 let lam_max = evals.iter().cloned().fold(0.0_f64, f64::max);
3324 let tol = lam_max * 64.0 * (g_compiled.nrows() as f64) * f64::EPSILON;
3325 let rank_compiled = evals.iter().filter(|&&l| l > tol).count();
3326 assert_eq!(
3327 rank_compiled,
3328 p_a + 1,
3329 "compiled design column rank must equal p_a + 1 after dropping the alias"
3330 );
3331
3332 // Reference compile via the W-based dual-metric path on the same
3333 // scalar blocks; compiled total width should also be p_a + 1.
3334 let ops_dual: Vec<Arc<dyn RowJacobianOperator>> = vec![op(a.clone()), op(b.clone())];
3335 let curvature = DiagonalScalarRowHessian::new(w.clone());
3336 let id_struct = IdentityRowHessian::new(n, 1);
3337 let dual = compile_with_dual_metric(
3338 &ops_dual,
3339 &curvature,
3340 &id_struct,
3341 &[BlockOrder::Marginal, BlockOrder::Logslope],
3342 )
3343 .expect("dual metric compile should succeed");
3344 let dual_total: usize = dual.blocks.iter().map(|b| b.t_lw.ncols()).sum();
3345 assert_eq!(dual_total, p_a + 1, "W-reference total width should match");
3346 }
3347
3348 /// Three-block toy: changing the ordering changes the per-block
3349 /// compiled widths (later blocks absorb the alias instead of earlier).
3350 #[test]
3351 fn compile_from_raw_grams_three_block_ordering_matters() {
3352 let n = 30;
3353 let a = Array2::from_shape_fn((n, 2), |(i, j)| {
3354 ((i + 1) as f64 * (j + 2) as f64 * 0.2).sin()
3355 });
3356 // B has 2 cols: col 0 independent, col 1 = a[:, 0]
3357 let mut b = Array2::<f64>::zeros((n, 2));
3358 for i in 0..n {
3359 b[[i, 0]] = ((i + 1) as f64 * 0.4).cos();
3360 b[[i, 1]] = a[[i, 0]];
3361 }
3362 // C has 2 cols: col 0 independent, col 1 = a[:, 1]
3363 let mut c = Array2::<f64>::zeros((n, 2));
3364 for i in 0..n {
3365 c[[i, 0]] = ((i + 1) as f64 * 0.55).sin();
3366 c[[i, 1]] = a[[i, 1]];
3367 }
3368 let w = Array1::ones(n);
3369
3370 let build = |b0: &Array2<f64>, b1: &Array2<f64>, b2: &Array2<f64>| {
3371 let raw_ranges = vec![
3372 0..b0.ncols(),
3373 b0.ncols()..(b0.ncols() + b1.ncols()),
3374 (b0.ncols() + b1.ncols())..(b0.ncols() + b1.ncols() + b2.ncols()),
3375 ];
3376 let channel_blocks = PrimaryChannelBlocks {
3377 blocks: vec![
3378 vec![Some(b0.clone())],
3379 vec![Some(b1.clone())],
3380 vec![Some(b2.clone())],
3381 ],
3382 };
3383 let row_hess = DiagonalScalarRowHessian::new(w.clone());
3384 let gram_h =
3385 build_raw_grams_from_channel_blocks(&channel_blocks, &row_hess, &raw_ranges)
3386 .unwrap();
3387 let gram_struct = build_raw_grams_structural(&channel_blocks, &raw_ranges);
3388 (gram_h, gram_struct, raw_ranges)
3389 };
3390
3391 // Order 1: A, B, C — B drops 1 (col 1 aliased to A), C drops 1.
3392 let (gh, gs, rr) = build(&a, &b, &c);
3393 let order_abc = compile_from_raw_grams(
3394 &gh,
3395 &gs,
3396 &rr,
3397 &[
3398 BlockOrder::Marginal,
3399 BlockOrder::Logslope,
3400 BlockOrder::LinkDev,
3401 ],
3402 )
3403 .expect("ABC compile");
3404 assert_eq!(order_abc.compiled_block_ranges[0].len(), 2);
3405 assert_eq!(order_abc.compiled_block_ranges[1].len(), 1);
3406 assert_eq!(order_abc.compiled_block_ranges[2].len(), 1);
3407
3408 // Order 2: B, A, C — A's col 0 is aliased by B's col 1 now; A's
3409 // col 1 is independent. So A drops 1; C still drops 1.
3410 let (gh2, gs2, rr2) = build(&b, &a, &c);
3411 let order_bac = compile_from_raw_grams(
3412 &gh2,
3413 &gs2,
3414 &rr2,
3415 &[
3416 BlockOrder::Marginal,
3417 BlockOrder::Logslope,
3418 BlockOrder::LinkDev,
3419 ],
3420 )
3421 .expect("BAC compile");
3422 assert_eq!(order_bac.compiled_block_ranges[0].len(), 2);
3423 assert_eq!(order_bac.compiled_block_ranges[1].len(), 1);
3424 // Total rank invariant under permutation: 4.
3425 let total_abc: usize = order_abc
3426 .compiled_block_ranges
3427 .iter()
3428 .map(|r| r.len())
3429 .sum();
3430 let total_bac: usize = order_bac
3431 .compiled_block_ranges
3432 .iter()
3433 .map(|r| r.len())
3434 .sum();
3435 assert_eq!(total_abc, total_bac);
3436 assert_eq!(total_abc, 4);
3437 }
3438
3439 /// Build a K=1 raw `(gram_h, gram_struct)` pair for a single stacked design
3440 /// `X` with per-row curvature weights `w`: `gram_struct = Xᵀ X`,
3441 /// `gram_h = Xᵀ diag(w) X`. Mirrors the closed-form definitions the
3442 /// production Gram builders implement for the scalar-channel case.
3443 fn k1_grams(x: &Array2<f64>, w: &Array1<f64>) -> (Array2<f64>, Array2<f64>) {
3444 let gram_struct = fast_atb(x, x);
3445 let xw = fast_xt_diag_y(x, w, x);
3446 (xw, gram_struct)
3447 }
3448
3449 /// Full-rank reduction: when the two blocks are jointly independent the
3450 /// compiled width equals the raw width and the lift `T` reproduces a raw
3451 /// coefficient exactly from its compiled image `θ = T⁺ β` (here, with no
3452 /// aliasing, `lift_coefficients(θ)` of any compiled `θ` lands in the raw
3453 /// design's column interpretation: applying `T` then comparing the induced
3454 /// raw predictor `X·Tθ` to `X·β_raw` for the `θ` solving `Tθ=β_raw`).
3455 #[test]
3456 fn compiled_map_lift_coefficients_roundtrips_full_rank() {
3457 let n = 21;
3458 let p_a = 2;
3459 let p_b = 2;
3460 // Distinct per-column frequencies make the four sinusoidal columns
3461 // genuinely linearly independent over the sample grid. (A shared phase
3462 // offset varying only by column would collapse every column into
3463 // span{sin θ, cos θ, 1}, i.e. rank 3, and the compiler would correctly
3464 // absorb a column — defeating the full-rank premise of this test.)
3465 let x = Array2::from_shape_fn((n, p_a + p_b), |(i, j)| {
3466 ((i as f64 + 1.0) * (0.21 + 0.17 * j as f64)).sin() + 0.11 * (j as f64)
3467 });
3468 let w = Array1::from_shape_fn(n, |i| 0.5 + 0.5 * ((i as f64) * 0.3).cos().abs());
3469 let (gh, gs) = k1_grams(&x, &w);
3470 let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
3471 let map = compile_from_raw_grams(
3472 &gh,
3473 &gs,
3474 &raw_ranges,
3475 &[BlockOrder::Marginal, BlockOrder::Logslope],
3476 )
3477 .expect("full-rank compile");
3478 // Jointly independent ⇒ no columns absorbed.
3479 assert_eq!(map.p_compiled(), p_a + p_b);
3480 assert_eq!(map.p_raw(), p_a + p_b);
3481 // For a target raw coefficient, solve T θ = β_raw (T square invertible
3482 // here) and confirm lift_coefficients(θ) == β_raw.
3483 let beta_raw = Array1::from_shape_fn(p_a + p_b, |j| 0.4 * (j as f64) - 0.7);
3484 // T is (p × p); recover θ by a least-squares solve via the normal
3485 // equations TᵀT θ = Tᵀ β.
3486 let tt = fast_atb(&map.raw_from_compiled, &map.raw_from_compiled);
3487 let tb = map.raw_from_compiled.t().dot(&beta_raw);
3488 let theta = solve_psd_system(&tt, &tb.insert_axis(Axis(1)))
3489 .expect("normal-equation solve")
3490 .column(0)
3491 .to_owned();
3492 let lifted = map.lift_coefficients(&theta).expect("lift");
3493 let max_err = (&lifted - &beta_raw)
3494 .iter()
3495 .fold(0.0_f64, |a, &v| a.max(v.abs()));
3496 assert!(
3497 max_err < 1e-8,
3498 "lift round-trip error {max_err:e} (full-rank reduction must be exactly invertible)"
3499 );
3500 }
3501
3502 /// Design reparameterisation exactness: the compiled design predicts
3503 /// identically to the raw design on every lifted coefficient, i.e.
3504 /// `X_compiled · θ == X_raw · (T θ)`. This is the contract that lets a
3505 /// family fit in reduced coordinates and still produce raw-design
3506 /// predictions.
3507 #[test]
3508 fn compiled_map_reduce_design_matches_lifted_raw_predictor() {
3509 let n = 23;
3510 let p_a = 3;
3511 let p_b = 3;
3512 let mut x = Array2::from_shape_fn((n, p_a + p_b), |(i, j)| {
3513 ((i as f64 + 1.0) * 0.41 + (j as f64 + 1.0) * 0.7).sin() + 0.05 * (i % 3) as f64
3514 });
3515 // Alias one B column onto an A column so the reduction is non-trivial.
3516 for i in 0..n {
3517 x[[i, p_a + 1]] = x[[i, 1]];
3518 }
3519 let w = Array1::from_shape_fn(n, |i| 0.6 + 0.4 * ((i as f64) * 0.25).cos().abs());
3520 let (gh, gs) = k1_grams(&x, &w);
3521 let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
3522 let map = compile_from_raw_grams(
3523 &gh,
3524 &gs,
3525 &raw_ranges,
3526 &[BlockOrder::Marginal, BlockOrder::Logslope],
3527 )
3528 .expect("compile");
3529 let x_compiled = map.reduce_design(&x).expect("reduce_design");
3530 assert_eq!(x_compiled.ncols(), map.p_compiled());
3531 let theta = Array1::from_shape_fn(map.p_compiled(), |j| 0.3 * (j as f64) - 0.5);
3532 let pred_compiled = x_compiled.dot(&theta);
3533 let beta_raw = map.lift_coefficients(&theta).expect("lift");
3534 let pred_raw = x.dot(&beta_raw);
3535 let max_err = (&pred_compiled - &pred_raw)
3536 .iter()
3537 .fold(0.0_f64, |a, &v| a.max(v.abs()));
3538 assert!(
3539 max_err < 1e-9,
3540 "compiled-design predictor diverges from lifted raw predictor: {max_err:e}"
3541 );
3542 }
3543
3544 /// Penalty-energy preservation: the reduced penalty `Tᵀ Ŝ_b T` reproduces
3545 /// the raw penalty energy `βᵀ Ŝ_b β` on every lifted point `β = T θ`. This
3546 /// is the exactness contract the lift map must satisfy for REML/inference
3547 /// to be invariant to the quotient reparameterisation.
3548 #[test]
3549 fn reduce_penalties_with_map_preserves_energy_on_lift() {
3550 let n = 19;
3551 let p_a = 3;
3552 let p_b = 2;
3553 // Make block B partly aliased with A so the reduction actually drops a
3554 // column — the penalty reduction must still preserve energy on the
3555 // surviving compiled directions.
3556 let mut x = Array2::from_shape_fn((n, p_a + p_b), |(i, j)| {
3557 ((i as f64 + 1.0) * 0.29 + (j as f64 + 1.0) * 0.9).cos()
3558 });
3559 // Column (p_a+0) := column 0 (exact alias) ⇒ B loses one direction.
3560 for i in 0..n {
3561 x[[i, p_a]] = x[[i, 0]];
3562 }
3563 let w = Array1::from_shape_fn(n, |i| 0.7 + 0.3 * ((i as f64) * 0.2).sin().abs());
3564 let (gh, gs) = k1_grams(&x, &w);
3565 let raw_ranges = vec![0..p_a, p_a..(p_a + p_b)];
3566 let map = compile_from_raw_grams(
3567 &gh,
3568 &gs,
3569 &raw_ranges,
3570 &[BlockOrder::Marginal, BlockOrder::Logslope],
3571 )
3572 .expect("compile with alias");
3573 assert!(
3574 map.p_compiled() < p_a + p_b,
3575 "expected at least one absorbed column, got p_compiled={}",
3576 map.p_compiled()
3577 );
3578 // A simple per-block raw penalty: ridge on each block.
3579 let s_a = Array2::<f64>::eye(p_a);
3580 let s_b = Array2::<f64>::eye(p_b);
3581 let reduced = reduce_penalties_with_map(&map, &[Some(s_a.clone()), Some(s_b.clone())])
3582 .expect("reduce penalties");
3583 // For random compiled θ, raw β = T θ. Raw energy for block b is
3584 // β[range_b]ᵀ S_b β[range_b]; reduced energy is θᵀ S_reduced_b θ.
3585 let theta = Array1::from_shape_fn(map.p_compiled(), |j| {
3586 0.6 * (j as f64) - 0.3 + 0.05 * (j % 2) as f64
3587 });
3588 let beta = map.lift_coefficients(&theta).expect("lift");
3589 for (block_idx, s_raw) in [(0usize, &s_a), (1usize, &s_b)] {
3590 let range = &map.raw_block_ranges[block_idx];
3591 let beta_b = beta.slice(s![range.start..range.end]).to_owned();
3592 let raw_energy = beta_b.dot(&s_raw.dot(&beta_b));
3593 let s_reduced = reduced[block_idx]
3594 .as_ref()
3595 .expect("reduced penalty present");
3596 let reduced_energy = theta.dot(&s_reduced.dot(&theta));
3597 assert!(
3598 (raw_energy - reduced_energy).abs() < 1e-8 * raw_energy.abs().max(1.0),
3599 "block {block_idx} energy mismatch: raw={raw_energy:e} reduced={reduced_energy:e}"
3600 );
3601 }
3602 }
3603}