Skip to main content

gam_problem/
gauge.rs

1// One Gauge object (#933).
2//
3// Every identifiability mechanism in the engine performs the same
4// mathematical act: quotient the coefficient space by directions in
5// ker(J) ∩ ker(S), pick a section, fit in the reduced coordinates θ,
6// and lift estimates / covariance back to the raw
7// coordinates β. This module owns that act once.
8//
9// A `Gauge` is the affine section itself: the lift matrix
10// `T : reduced → raw` plus an affine shift `a`
11// (`β_raw = T · θ + a`) together with the per-block partitions
12// of both coordinate systems. Block-diagonal `T`
13// (independent per-block reductions, the canonical-audit case) and
14// block-upper-triangular `T` (cross-block residualisation, the
15// survival V+M-exact compile) are the same object — the partitions
16// record where each block's rows/columns live.
17//
18// Lift conventions (the whole point — there is exactly one):
19//   - point estimate:   β_raw = T · θ + a
20//   - covariance:       Σ_raw = T · Σ_θ · Tᵀ
21//   - Hessian/penalty:  H_θ = Tᵀ · H_raw · T
22//   - η is invariant:   X_raw · (T · θ + a) = X_reduced · θ + offset_reduced
23//
24// Raw directions the active fit cannot move (zero rows of `T`) receive their
25// fixed affine-shift value, zero variance, and zero covariance with every
26// other coordinate: a coordinate the reduced fit cannot move carries no
27// posterior uncertainty in raw space.
28
29use ndarray::{Array1, Array2, ArrayBase, Data, Ix2};
30use serde::{Deserialize, Serialize};
31
32use gam_linalg::faer_ndarray::{fast_ab, fast_abt, fast_atb};
33
34/// Neutral view of a compiled identifiability reparametrisation that
35/// [`Gauge::from_compiled_map`] consumes. The concrete `CompiledMap`
36/// emitted by the identifiability compiler lives ABOVE this crate, so
37/// `Gauge` names only this trait (inverted dependency #1521); the
38/// compiler crate provides the `impl`.
39///
40/// `raw_from_compiled` IS the global triangular lift `T`; the two block
41/// range slices give the raw-width and compiled-width column partitions.
42pub trait CompiledBlockMap {
43    /// The `(p_raw × p_compiled)` raw-from-compiled reparam matrix `T`.
44    fn raw_from_compiled(&self) -> &Array2<f64>;
45    /// Per-block raw-width column ranges.
46    fn raw_block_ranges(&self) -> &[std::ops::Range<usize>];
47    /// Per-block compiled-width column ranges, parallel to
48    /// [`Self::raw_block_ranges`].
49    fn compiled_block_ranges(&self) -> &[std::ops::Range<usize>];
50}
51
52/// The lift `T : reduced → raw` plus the per-block partitions of both
53/// coordinate systems. See the module docs for the lift conventions.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Gauge {
56    /// Global lift matrix, shape `(Σ p_b_raw) × (Σ r_b_reduced)`.
57    pub t_full: Array2<f64>,
58    /// Global affine shift in raw coordinates, length `Σ p_b_raw`.
59    pub affine_shift: Array1<f64>,
60    /// Raw-coordinate block partition: `block_starts_raw[b]..block_starts_raw[b+1]`
61    /// is block `b`'s raw row range in `t_full`. Length `n_blocks + 1`, starts at 0.
62    pub block_starts_raw: Vec<usize>,
63    /// Reduced-coordinate block partition (columns of `t_full`), same layout.
64    pub block_starts_reduced: Vec<usize>,
65}
66
67fn starts_from_widths(widths: &[usize]) -> Vec<usize> {
68    let mut starts = Vec::with_capacity(widths.len() + 1);
69    starts.push(0);
70    for w in widths {
71        starts.push(starts.last().copied().unwrap() + w);
72    }
73    starts
74}
75
76/// Assemble a block-upper-triangular lift `T` from per-block diagonal
77/// `V_b` matrices and strictly-upper residualisation blocks `R_{a→b}`.
78///
79/// `r_per_term[b]` (when `Some`) packs ALL strictly-upper off-diagonal
80/// columns for block `b` stacked row-wise across all earlier-priority
81/// blocks `a < b`: `nrows = Σ_{a<b} v_per_term[a].nrows()`,
82/// `ncols = v_per_term[b].ncols()`. The assembled `T` carries `V_b` on
83/// the diagonal and `−R_{a→b}` at `(a, b)`. `r_per_term[0]` must be
84/// `None` (no earlier block to residualise against).
85pub fn assemble_block_triangular_t(
86    v_per_term: &[Array2<f64>],
87    r_per_term: &[Option<Array2<f64>>],
88) -> Array2<f64> {
89    assert_eq!(
90        v_per_term.len(),
91        r_per_term.len(),
92        "assemble_block_triangular_t: v_per_term len {} != r_per_term len {}",
93        v_per_term.len(),
94        r_per_term.len(),
95    );
96    let raw_widths: Vec<usize> = v_per_term.iter().map(|v| v.nrows()).collect();
97    let kept_widths: Vec<usize> = v_per_term.iter().map(|v| v.ncols()).collect();
98    let row_offsets = starts_from_widths(&raw_widths);
99    let col_offsets = starts_from_widths(&kept_widths);
100    let total_rows = row_offsets.last().copied().unwrap_or(0);
101    let total_cols = col_offsets.last().copied().unwrap_or(0);
102    let mut t = Array2::<f64>::zeros((total_rows, total_cols));
103    // Diagonal: place V_b at (b, b).
104    for (b, v) in v_per_term.iter().enumerate() {
105        let r = v.nrows();
106        let c = v.ncols();
107        if r > 0 && c > 0 {
108            t.slice_mut(ndarray::s![
109                row_offsets[b]..row_offsets[b] + r,
110                col_offsets[b]..col_offsets[b] + c
111            ])
112            .assign(v);
113        }
114    }
115    // Strict upper triangle: for each b ≥ 1, place −R_{a→b} at (a, b),
116    // a < b, slicing the row-stacked `r_per_term[b]` in earlier-block order.
117    for b in 1..v_per_term.len() {
118        let Some(r_stack) = r_per_term[b].as_ref() else {
119            continue;
120        };
121        let kept_b = kept_widths[b];
122        assert_eq!(
123            r_stack.ncols(),
124            kept_b,
125            "assemble_block_triangular_t: r_per_term[{b}] has {} cols, expected {}",
126            r_stack.ncols(),
127            kept_b,
128        );
129        let expected_rows: usize = raw_widths.iter().take(b).sum();
130        assert_eq!(
131            r_stack.nrows(),
132            expected_rows,
133            "assemble_block_triangular_t: r_per_term[{b}] has {} rows, expected {} \
134             (sum of raw_widths[0..{}])",
135            r_stack.nrows(),
136            expected_rows,
137            b,
138        );
139        let mut local_row = 0usize;
140        for a in 0..b {
141            let r_a = raw_widths[a];
142            if r_a == 0 || kept_b == 0 {
143                local_row += r_a;
144                continue;
145            }
146            let block = r_stack.slice(ndarray::s![local_row..local_row + r_a, ..]);
147            let mut dst = t.slice_mut(ndarray::s![
148                row_offsets[a]..row_offsets[a] + r_a,
149                col_offsets[b]..col_offsets[b] + kept_b
150            ]);
151            for i in 0..r_a {
152                for j in 0..kept_b {
153                    dst[[i, j]] = -block[[i, j]];
154                }
155            }
156            local_row += r_a;
157        }
158    }
159    t
160}
161
162impl Gauge {
163    /// Validate the serialized affine section and its block topology.
164    ///
165    /// A `Gauge` is persisted inside fitted models and its fields remain public
166    /// for matrix-oriented consumers. Consumers of decoded or manually
167    /// assembled state must therefore reject malformed dimensions, partitions,
168    /// and non-finite affine maps before coefficient or curvature transforms.
169    pub fn validate(&self) -> Result<(), String> {
170        if self.block_starts_raw.len() != self.block_starts_reduced.len() {
171            return Err(format!(
172                "raw and reduced block partitions have different lengths: {} and {}",
173                self.block_starts_raw.len(),
174                self.block_starts_reduced.len(),
175            ));
176        }
177        if self.block_starts_raw.is_empty() {
178            return Err("block partitions must contain their zero origin".to_string());
179        }
180        if self.block_starts_raw[0] != 0 || self.block_starts_reduced[0] != 0 {
181            return Err(format!(
182                "block partitions must start at zero, got raw={} and reduced={}",
183                self.block_starts_raw[0], self.block_starts_reduced[0],
184            ));
185        }
186        for (label, starts) in [
187            ("raw", &self.block_starts_raw),
188            ("reduced", &self.block_starts_reduced),
189        ] {
190            if let Some((index, pair)) = starts
191                .windows(2)
192                .enumerate()
193                .find(|(_, pair)| pair[0] > pair[1])
194            {
195                return Err(format!(
196                    "{label} block partition decreases at boundary {index}: {} > {}",
197                    pair[0], pair[1],
198                ));
199            }
200        }
201        if self.t_full.nrows() != self.raw_total() || self.t_full.ncols() != self.reduced_total() {
202            return Err(format!(
203                "lift shape {:?} does not match partition totals ({}, {})",
204                self.t_full.dim(),
205                self.raw_total(),
206                self.reduced_total(),
207            ));
208        }
209        if self.reduced_total() > self.raw_total() {
210            return Err(format!(
211                "reduced total {} exceeds raw total {}; an affine section cannot be injective",
212                self.reduced_total(),
213                self.raw_total(),
214            ));
215        }
216        if self.affine_shift.len() != self.raw_total() {
217            return Err(format!(
218                "affine shift length {} does not match raw total {}",
219                self.affine_shift.len(),
220                self.raw_total(),
221            ));
222        }
223        if self.t_full.iter().any(|value| !value.is_finite()) {
224            return Err("lift contains a non-finite value".to_string());
225        }
226        if self.affine_shift.iter().any(|value| !value.is_finite()) {
227            return Err("affine shift contains a non-finite value".to_string());
228        }
229        Ok(())
230    }
231
232    /// The trivial section: raw == reduced for every block.
233    pub fn identity(raw_widths: &[usize]) -> Self {
234        let transforms: Vec<Array2<f64>> =
235            raw_widths.iter().map(|&w| Array2::<f64>::eye(w)).collect();
236        Self::from_block_transforms(&transforms)
237    }
238
239    /// Block-diagonal section from independent per-block lifts
240    /// `T_b : reduced_b → raw_b` (selection matrices from the canonical
241    /// audit, orthogonalisation `V_b`s, or their compositions).
242    pub fn from_block_transforms(transforms: &[Array2<f64>]) -> Self {
243        let raw_total: usize = transforms.iter().map(|t| t.nrows()).sum();
244        Self::from_block_transforms_with_shift(transforms, Array1::zeros(raw_total))
245    }
246
247    /// Block-diagonal affine section from independent per-block lifts
248    /// plus one concatenated raw-coordinate shift.
249    pub fn from_block_transforms_with_shift(
250        transforms: &[Array2<f64>],
251        affine_shift: Array1<f64>,
252    ) -> Self {
253        let r_none: Vec<Option<Array2<f64>>> = transforms.iter().map(|_| None).collect();
254        let mut gauge = Self::from_v_and_r(transforms, &r_none);
255        assert_eq!(
256            affine_shift.len(),
257            gauge.raw_total(),
258            "Gauge::from_block_transforms_with_shift: affine shift len {} != raw width {}",
259            affine_shift.len(),
260            gauge.raw_total(),
261        );
262        gauge.affine_shift = affine_shift;
263        gauge
264    }
265
266    /// Single-block affine section.
267    pub fn from_block_transform_with_shift(
268        transform: Array2<f64>,
269        affine_shift: Array1<f64>,
270    ) -> Self {
271        Self::from_block_transforms_with_shift(&[transform], affine_shift)
272    }
273
274    /// Block-upper-triangular section from per-block `V_b` plus
275    /// cross-block residualisation stacks `R_{a→b}` — see
276    /// [`assemble_block_triangular_t`] for the packing convention.
277    pub fn from_v_and_r(v_per_term: &[Array2<f64>], r_per_term: &[Option<Array2<f64>>]) -> Self {
278        let raw_widths: Vec<usize> = v_per_term.iter().map(|v| v.nrows()).collect();
279        let reduced_widths: Vec<usize> = v_per_term.iter().map(|v| v.ncols()).collect();
280        Self {
281            t_full: assemble_block_triangular_t(v_per_term, r_per_term),
282            affine_shift: Array1::zeros(raw_widths.iter().sum::<usize>()),
283            block_starts_raw: starts_from_widths(&raw_widths),
284            block_starts_reduced: starts_from_widths(&reduced_widths),
285        }
286    }
287
288    /// The sum-to-zero (centering) section as a first-class single-block
289    /// gauge. `z` is the `(k × (k−1))` reparametrisation matrix returned by
290    /// `terms::basis::duchon_thinplate::apply_sum_to_zero_constraint`
291    /// (an orthonormal basis for `null(cᵀ)`, `c = Bᵀw` the weighted column
292    /// sums): the constrained design is `B_c = B · z`, so on the model
293    /// `η = B · β_raw = B_c · θ = B · z · θ` the raw coefficients lift back
294    /// from the reduced (centred) coefficients by exactly `β_raw = z · θ`.
295    ///
296    /// That is the one Gauge convention with `T = z` over a single block, so
297    /// the centring constraint stops being a special-cased outside-the-object
298    /// transform and becomes a `Gauge` section like every other reduction:
299    /// the covariance of the centred fit pushes forward to the raw basis
300    /// through the SAME `z` via [`Gauge::lift_covariance`]. A raw-coordinate
301    /// Hessian or penalty instead pulls back to the centred coordinates as
302    /// `zᵀ H z` via [`Gauge::restrict_penalty`].
303    ///
304    /// `z` is taken as the section itself (rather than recomputed from a basis)
305    /// because the constraint matrix is the only gauge-relevant artifact — the
306    /// basis the column sums were taken over is irrelevant to the lift. The
307    /// only requirement is the structural one of a centring section:
308    /// `z.ncols() < z.nrows()` (at least one direction is removed); an identity
309    /// `z` would be `Gauge::identity` and is rejected so callers do not silently
310    /// treat an unconstrained block as centred.
311    pub fn sum_to_zero(z: Array2<f64>) -> Self {
312        let (k, r) = z.dim();
313        assert!(
314            k > 0 && r < k,
315            "Gauge::sum_to_zero: z must be a tall reparametrisation ({k}×{r}); \
316             a centring section removes at least one direction (r < k)",
317        );
318        Self::from_block_transforms(&[z])
319    }
320
321    /// Wrap an already-assembled global `T` given the per-block raw and
322    /// reduced width partitions.
323    pub fn from_t(t_full: Array2<f64>, raw_widths: &[usize], reduced_widths: &[usize]) -> Self {
324        let total_raw: usize = raw_widths.iter().sum();
325        Self::from_t_with_shift(t_full, raw_widths, reduced_widths, Array1::zeros(total_raw))
326    }
327
328    /// Wrap an already-assembled global affine section `β = Tθ + a` given the
329    /// per-block raw and reduced width partitions.
330    pub fn from_t_with_shift(
331        t_full: Array2<f64>,
332        raw_widths: &[usize],
333        reduced_widths: &[usize],
334        affine_shift: Array1<f64>,
335    ) -> Self {
336        assert_eq!(
337            raw_widths.len(),
338            reduced_widths.len(),
339            "Gauge::from_t: raw_widths len {} != reduced_widths len {}",
340            raw_widths.len(),
341            reduced_widths.len(),
342        );
343        let total_raw: usize = raw_widths.iter().sum();
344        let total_reduced: usize = reduced_widths.iter().sum();
345        assert_eq!(
346            t_full.dim(),
347            (total_raw, total_reduced),
348            "Gauge::from_t: T has shape {:?}, expected ({total_raw}, {total_reduced})",
349            t_full.dim(),
350        );
351        assert_eq!(
352            affine_shift.len(),
353            total_raw,
354            "Gauge::from_t_with_shift: affine shift len {} != raw width {total_raw}",
355            affine_shift.len(),
356        );
357        Self {
358            t_full,
359            affine_shift,
360            block_starts_raw: starts_from_widths(raw_widths),
361            block_starts_reduced: starts_from_widths(reduced_widths),
362        }
363    }
364
365    /// Compose this affine section on the left with `outer`.
366    ///
367    /// `self` maps active geometry coordinates into a current raw coefficient
368    /// frame, while `outer` maps that current frame into a new raw frame:
369    ///
370    /// ```text
371    /// current = T_self · active + a_self
372    /// new     = T_outer · current + a_outer
373    /// ```
374    ///
375    /// Therefore the returned section has lift `T_outer · T_self` and
376    /// shift `T_outer · a_self + a_outer`. The complete intermediate block
377    /// partition must agree exactly; matching only the total dimension would
378    /// lose the coefficient-block lineage persisted with a fit.
379    pub fn left_compose(&self, outer: &Gauge) -> Result<Gauge, String> {
380        self.validate()
381            .map_err(|reason| format!("inner gauge is invalid: {reason}"))?;
382        outer
383            .validate()
384            .map_err(|reason| format!("outer gauge is invalid: {reason}"))?;
385        if self.block_starts_raw != outer.block_starts_reduced {
386            return Err(format!(
387                "composition frame partition mismatch: inner raw {:?} != outer reduced {:?}",
388                self.block_starts_raw, outer.block_starts_reduced,
389            ));
390        }
391
392        let composed = Gauge {
393            t_full: fast_ab(&outer.t_full, &self.t_full),
394            affine_shift: outer.t_full.dot(&self.affine_shift) + &outer.affine_shift,
395            block_starts_raw: outer.block_starts_raw.clone(),
396            block_starts_reduced: self.block_starts_reduced.clone(),
397        };
398        composed
399            .validate()
400            .map_err(|reason| format!("composed gauge is invalid: {reason}"))?;
401        Ok(composed)
402    }
403
404    /// Build from a compiled identifiability reparametrisation
405    /// (see [`CompiledBlockMap`], implemented for the `CompiledMap` emitted by
406    /// the identifiability compiler): `map.raw_from_compiled()` IS the global
407    /// triangular `T`, and the block ranges give both partitions. `ordering`
408    /// is accepted purely as a length sanity check.
409    pub fn from_compiled_map<M: CompiledBlockMap, O>(map: &M, ordering: &[O]) -> Self {
410        assert_eq!(
411            map.raw_block_ranges().len(),
412            map.compiled_block_ranges().len(),
413            "Gauge::from_compiled_map: CompiledMap raw_block_ranges len {} != \
414             compiled_block_ranges len {}",
415            map.raw_block_ranges().len(),
416            map.compiled_block_ranges().len(),
417        );
418        assert_eq!(
419            map.raw_block_ranges().len(),
420            ordering.len(),
421            "Gauge::from_compiled_map: ordering len {} != block count {}",
422            ordering.len(),
423            map.raw_block_ranges().len(),
424        );
425        let mut block_starts_raw = Vec::with_capacity(map.raw_block_ranges().len() + 1);
426        block_starts_raw.push(0);
427        for r in map.raw_block_ranges() {
428            block_starts_raw.push(r.end);
429        }
430        let mut block_starts_reduced = Vec::with_capacity(map.compiled_block_ranges().len() + 1);
431        block_starts_reduced.push(0);
432        for r in map.compiled_block_ranges() {
433            block_starts_reduced.push(r.end);
434        }
435        let total_raw = block_starts_raw.last().copied().unwrap_or(0);
436        Self {
437            t_full: map.raw_from_compiled().clone(),
438            affine_shift: Array1::zeros(total_raw),
439            block_starts_raw,
440            block_starts_reduced,
441        }
442    }
443
444    /// Number of blocks in the partition.
445    pub fn n_blocks(&self) -> usize {
446        self.block_starts_raw.len().saturating_sub(1)
447    }
448
449    /// Total raw width `Σ p_b`.
450    pub fn raw_total(&self) -> usize {
451        self.block_starts_raw.last().copied().unwrap_or(0)
452    }
453
454    /// Total reduced width `Σ r_b`.
455    pub fn reduced_total(&self) -> usize {
456        self.block_starts_reduced.last().copied().unwrap_or(0)
457    }
458
459    /// Per-block raw widths.
460    pub fn raw_widths(&self) -> Vec<usize> {
461        self.block_starts_raw
462            .windows(2)
463            .map(|w| w[1] - w[0])
464            .collect()
465    }
466
467    /// Per-block reduced widths.
468    pub fn reduced_widths(&self) -> Vec<usize> {
469        self.block_starts_reduced
470            .windows(2)
471            .map(|w| w[1] - w[0])
472            .collect()
473    }
474
475    /// The diagonal slab `T_b = T[raw_b, reduced_b]` of block `b`.
476    /// For a block-diagonal gauge this is the whole story for the
477    /// block; for a triangular gauge it omits the cross-block `−R`.
478    pub fn block_transform(&self, b: usize) -> Array2<f64> {
479        assert!(
480            b < self.n_blocks(),
481            "Gauge::block_transform: block {b} out of range {}",
482            self.n_blocks(),
483        );
484        self.t_full
485            .slice(ndarray::s![
486                self.block_starts_raw[b]..self.block_starts_raw[b + 1],
487                self.block_starts_reduced[b]..self.block_starts_reduced[b + 1]
488            ])
489            .to_owned()
490    }
491
492    /// Compose a raw design with the section: `X_reduced = X_raw · T`.
493    pub fn restrict_design<S: Data<Elem = f64>>(
494        &self,
495        raw_design: &ArrayBase<S, Ix2>,
496    ) -> Array2<f64> {
497        let raw_total = self.raw_total();
498        assert_eq!(
499            raw_design.ncols(),
500            raw_total,
501            "Gauge::restrict_design: design has {} columns, expected raw width {raw_total}",
502            raw_design.ncols(),
503        );
504        // A trivial section (`T = I`) leaves the design untouched: `X·I = X`
505        // bit-for-bit (every off-diagonal `T` entry is an exact zero, the
506        // diagonal an exact one, so the reduction is the identity map). The
507        // unconstrained Wahba sphere chart hits this on every build, and the
508        // skipped GEMM is an `(n × w)·(w × w)` product — ~0.8 s of host
509        // matrixmultiply at production shapes (n ≳ 1e5, w ~ 200). Detecting
510        // identity costs O(w²), negligible beside the O(n·w²) it elides.
511        if self.t_full_is_identity() {
512            return raw_design.to_owned();
513        }
514        fast_ab(raw_design, &self.t_full)
515    }
516
517    /// [`restrict_design`](Self::restrict_design) for a caller that owns the raw
518    /// design and does not need it afterwards.
519    ///
520    /// The borrowed form cannot express the trivial section's real cost: `X·I = X`
521    /// elides the GEMM, but it still has to hand back an `Array2`, so it copies
522    /// `n·w` doubles it did not need to touch. At production sphere shapes
523    /// (`n ≳ 1e5`, `w ~ 200`) that copy is ~320 MB of pure memory traffic —
524    /// measured at 0.167 s of an 8.2 s host Wahba build, and ~17% of the same
525    /// build once the kernel matrix moves to a device (#2420). It also doubles
526    /// peak residency, since the raw and reduced designs are live at once.
527    ///
528    /// Taking the buffer by value lets the identity case return it untouched: no
529    /// allocation, no copy, and the caller's own bytes. The result is
530    /// bit-identical to the borrowed form in both branches, and is in standard
531    /// layout either way — a non-standard input is normalised rather than passed
532    /// through, so downstream consumers see exactly what `to_owned` would have
533    /// given them.
534    pub fn restrict_design_owned(&self, raw_design: Array2<f64>) -> Array2<f64> {
535        let raw_total = self.raw_total();
536        assert_eq!(
537            raw_design.ncols(),
538            raw_total,
539            "Gauge::restrict_design_owned: design has {} columns, expected raw width {raw_total}",
540            raw_design.ncols(),
541        );
542        if self.t_full_is_identity() {
543            return if raw_design.is_standard_layout() {
544                raw_design
545            } else {
546                raw_design.as_standard_layout().into_owned()
547            };
548        }
549        fast_ab(&raw_design, &self.t_full)
550    }
551
552    /// Whether the lift `T` is the exact identity (square with unit diagonal
553    /// and zero off-diagonal). When true, `restrict_design`/`restrict_penalty`
554    /// are no-ops and skip their GEMMs. The comparison is exact equality, not
555    /// a tolerance — only a literal identity short-circuits, so the fast path
556    /// is always bit-identical to the full product.
557    fn t_full_is_identity(&self) -> bool {
558        let (r, c) = self.t_full.dim();
559        if r != c {
560            return false;
561        }
562        self.t_full
563            .indexed_iter()
564            .all(|((i, j), &v)| v == if i == j { 1.0 } else { 0.0 })
565    }
566
567    /// Whether this is the exact affine identity on every persisted block.
568    ///
569    /// This is stricter than the internal linear fast-path predicate: the lift
570    /// must be a literal identity, the affine shift must be exactly zero, and
571    /// raw/reduced block boundaries must coincide. No tolerance is used, so a
572    /// `true` result is a proof that active and saved coordinates are identical
573    /// rather than merely numerically close.
574    pub fn is_identity(&self) -> bool {
575        self.validate().is_ok()
576            && self.block_starts_raw == self.block_starts_reduced
577            && self.affine_shift.iter().all(|&value| value == 0.0)
578            && self.t_full_is_identity()
579    }
580
581    /// Compose a raw design and offset with the affine section:
582    /// `X_raw · (Tθ + a) + o_raw = (X_raw · T)θ + (o_raw + X_raw · a)`.
583    pub fn restrict_design_and_offset<S: Data<Elem = f64>>(
584        &self,
585        raw_design: &ArrayBase<S, Ix2>,
586        raw_offset: &Array1<f64>,
587    ) -> (Array2<f64>, Array1<f64>) {
588        assert_eq!(
589            raw_design.nrows(),
590            raw_offset.len(),
591            "Gauge::restrict_design_and_offset: design rows {} != offset len {}",
592            raw_design.nrows(),
593            raw_offset.len(),
594        );
595        let reduced_design = self.restrict_design(raw_design);
596        let reduced_offset = raw_offset + &raw_design.dot(&self.affine_shift);
597        (reduced_design, reduced_offset)
598    }
599
600    /// Pull a raw-coordinate quadratic form (including a penalty or Hessian)
601    /// back to reduced coordinates: `S_reduced = Tᵀ · S_raw · T`.
602    pub fn restrict_penalty<S: Data<Elem = f64>>(
603        &self,
604        raw_penalty: &ArrayBase<S, Ix2>,
605    ) -> Array2<f64> {
606        let raw_total = self.raw_total();
607        assert_eq!(
608            raw_penalty.dim(),
609            (raw_total, raw_total),
610            "Gauge::restrict_penalty: matrix has shape {:?}, expected ({raw_total}, {raw_total})",
611            raw_penalty.dim(),
612        );
613        // `Tᵀ S T = S` exactly when `T = I` (see `restrict_design`). Skip the
614        // two `(w × w)·(w × w)` products on the unconstrained chart.
615        if self.t_full_is_identity() {
616            return raw_penalty.to_owned();
617        }
618        let t_s = fast_atb(&self.t_full, raw_penalty);
619        fast_ab(&t_s, &self.t_full)
620    }
621
622    /// Pull a constructive quadratic factor into reduced coordinates.
623    ///
624    /// A positive-semidefinite quadratic represented as
625    /// `S_raw = A_rawᵀ A_raw` has energy `‖A_raw · β_raw‖²`.  Under this
626    /// gauge's section `β_raw = T · θ + a`, the quadratic part is therefore
627    /// represented *constructively* by
628    ///
629    /// ```text
630    /// A_reduced = A_raw · T,
631    /// S_reduced = A_reducedᵀ A_reduced.
632    /// ```
633    ///
634    /// Keeping the rectangular factor across the congruence is stronger than
635    /// materializing `Tᵀ S_raw T`: it preserves the proof that the restricted
636    /// form is PSD and its null space is `null(A_raw T)`, even when two dense
637    /// matrix products would leave a signed roundoff residue in an exact null
638    /// direction (#2318).
639    pub fn restrict_quadratic_factor<S: Data<Elem = f64>>(
640        &self,
641        raw_factor: &ArrayBase<S, Ix2>,
642    ) -> Array2<f64> {
643        let raw_total = self.raw_total();
644        assert_eq!(
645            raw_factor.ncols(),
646            raw_total,
647            "Gauge::restrict_quadratic_factor: factor has {} columns, expected {raw_total}",
648            raw_factor.ncols(),
649        );
650        if self.t_full_is_identity() {
651            return raw_factor.to_owned();
652        }
653        fast_ab(raw_factor, &self.t_full)
654    }
655
656    /// Append blocks that were never reduced (raw == reduced, identity
657    /// lift). Used to lift joint objects that span both gauged blocks
658    /// and untouched ones (e.g. the survival flex blocks alongside the
659    /// compiled parametric blocks).
660    pub fn extend_with_identity(&self, extra_raw_widths: &[usize]) -> Self {
661        let extra_total: usize = extra_raw_widths.iter().sum();
662        let raw_total = self.raw_total();
663        let reduced_total = self.reduced_total();
664        let mut t = Array2::<f64>::zeros((raw_total + extra_total, reduced_total + extra_total));
665        t.slice_mut(ndarray::s![0..raw_total, 0..reduced_total])
666            .assign(&self.t_full);
667        for k in 0..extra_total {
668            t[[raw_total + k, reduced_total + k]] = 1.0;
669        }
670        let mut block_starts_raw = self.block_starts_raw.clone();
671        let mut block_starts_reduced = self.block_starts_reduced.clone();
672        for &w in extra_raw_widths {
673            block_starts_raw.push(block_starts_raw.last().copied().unwrap() + w);
674            block_starts_reduced.push(block_starts_reduced.last().copied().unwrap() + w);
675        }
676        let mut affine_shift = Array1::<f64>::zeros(raw_total + extra_total);
677        affine_shift
678            .slice_mut(ndarray::s![0..raw_total])
679            .assign(&self.affine_shift);
680        Self {
681            t_full: t,
682            affine_shift,
683            block_starts_raw,
684            block_starts_reduced,
685        }
686    }
687
688    /// Lift per-block reduced coefficients to per-block raw
689    /// coefficients: concatenate into θ, apply `β = T · θ + a`, split at
690    /// the raw partition.
691    pub fn lift_block_betas(&self, reduced_block_betas: &[Array1<f64>]) -> Vec<Array1<f64>> {
692        let n_blocks = self.n_blocks();
693        assert_eq!(
694            reduced_block_betas.len(),
695            n_blocks,
696            "Gauge::lift_block_betas: got {} reduced block betas, expected {}",
697            reduced_block_betas.len(),
698            n_blocks,
699        );
700        for (b, beta) in reduced_block_betas.iter().enumerate() {
701            let expected = self.block_starts_reduced[b + 1] - self.block_starts_reduced[b];
702            assert_eq!(
703                beta.len(),
704                expected,
705                "Gauge::lift_block_betas: block {b} has β of len {}, expected reduced width {}",
706                beta.len(),
707                expected,
708            );
709        }
710        let mut theta_full = Array1::<f64>::zeros(self.reduced_total());
711        for (b, beta) in reduced_block_betas.iter().enumerate() {
712            let c0 = self.block_starts_reduced[b];
713            let c1 = self.block_starts_reduced[b + 1];
714            theta_full.slice_mut(ndarray::s![c0..c1]).assign(beta);
715        }
716        let beta_full = self.t_full.dot(&theta_full) + &self.affine_shift;
717        let mut out = Vec::with_capacity(n_blocks);
718        for b in 0..n_blocks {
719            let r0 = self.block_starts_raw[b];
720            let r1 = self.block_starts_raw[b + 1];
721            out.push(beta_full.slice(ndarray::s![r0..r1]).to_owned());
722        }
723        out
724    }
725
726    /// Push a reduced-coordinate posterior covariance forward to raw
727    /// coordinates via the exact sandwich `Σ_raw = T · Σ_θ · Tᵀ`.
728    ///
729    /// This is deliberately covariance-specific. Hessians and penalties are
730    /// covariant quadratic forms and transform in the opposite direction via
731    /// [`Gauge::restrict_penalty`]; `T H Tᵀ` is not a Hessian pushforward.
732    ///
733    /// The result is explicitly symmetrised: `T · M · Tᵀ` is symmetric
734    /// for symmetric `M`, but the two matmuls accumulate independent
735    /// rounding, so the transpose pair is averaged to land an exactly
736    /// symmetric matrix for downstream Cholesky / eigensolves.
737    pub fn lift_covariance(&self, covariance_reduced: &Array2<f64>) -> Array2<f64> {
738        let total_reduced = self.reduced_total();
739        assert_eq!(
740            covariance_reduced.dim(),
741            (total_reduced, total_reduced),
742            "Gauge::lift_covariance: matrix has shape {:?}, expected ({total_reduced}, {total_reduced})",
743            covariance_reduced.dim(),
744        );
745        let t_m = fast_ab(&self.t_full, covariance_reduced);
746        let mut raw = fast_abt(&t_m, &self.t_full);
747        let n = raw.nrows();
748        for i in 0..n {
749            for j in (i + 1)..n {
750                let avg = 0.5 * (raw[[i, j]] + raw[[j, i]]);
751                raw[[i, j]] = avg;
752                raw[[j, i]] = avg;
753            }
754        }
755        raw
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762    use ndarray::ShapeBuilder;
763
764    #[test]
765    fn identity_gauge_round_trips_betas_and_covariance() {
766        let gauge = Gauge::identity(&[2, 3]);
767        assert!(gauge.is_identity());
768        assert_eq!(gauge.n_blocks(), 2);
769        assert_eq!(gauge.raw_total(), 5);
770        assert_eq!(gauge.reduced_total(), 5);
771        let theta = vec![
772            Array1::from(vec![0.5, -0.25]),
773            Array1::from(vec![1.0, 2.0, -3.0]),
774        ];
775        let raw = gauge.lift_block_betas(&theta);
776        assert_eq!(raw[0].as_slice().unwrap(), &[0.5, -0.25]);
777        assert_eq!(raw[1].as_slice().unwrap(), &[1.0, 2.0, -3.0]);
778
779        let mut cov = Array2::<f64>::eye(5);
780        cov[[0, 3]] = 0.4;
781        cov[[3, 0]] = 0.4;
782        let lifted = gauge.lift_covariance(&cov);
783        for i in 0..5 {
784            for j in 0..5 {
785                assert!(
786                    (lifted[[i, j]] - cov[[i, j]]).abs() < 1e-14,
787                    "identity gauge must be a covariance no-op at ({i},{j})",
788                );
789            }
790        }
791    }
792
793    #[test]
794    fn identity_section_short_circuits_restrict_bit_exactly() {
795        // A trivial section must restrict design/penalty to the *exact* input,
796        // matching the full GEMM bit-for-bit while skipping it.
797        let gauge = Gauge::identity(&[4]);
798        assert!(gauge.t_full_is_identity());
799
800        // An irregular design with values that would perturb under a real GEMM
801        // if any rounding crept in.
802        let raw_design = Array2::<f64>::from_shape_fn((7, 4), |(i, j)| {
803            ((i as f64) * 0.3 - (j as f64) * 1.7).sin() * 1.000000001
804        });
805        let restricted = gauge.restrict_design(&raw_design);
806        // Bit-exact equality with the input (the identity map).
807        assert_eq!(restricted, raw_design);
808        // And bit-exact with the full product it elides.
809        let via_gemm = fast_ab(&raw_design, &gauge.t_full);
810        assert_eq!(restricted, via_gemm);
811
812        let raw_penalty = Array2::<f64>::from_shape_fn((4, 4), |(i, j)| {
813            (i as f64 + 1.0) * (j as f64 + 2.0) * 0.111
814        });
815        let restricted_pen = gauge.restrict_penalty(&raw_penalty);
816        assert_eq!(restricted_pen, raw_penalty);
817        let pen_via_gemm = fast_ab(&fast_atb(&gauge.t_full, &raw_penalty), &gauge.t_full);
818        assert_eq!(restricted_pen, pen_via_gemm);
819    }
820
821    /// The owned form must agree with the borrowed one bit-for-bit in BOTH
822    /// branches — that equivalence is the whole licence for using it — and in the
823    /// identity branch it must hand back the caller's own allocation rather than
824    /// a copy of it. Pointer identity is the only way to state "did not copy"
825    /// that a faster memcpy cannot accidentally satisfy.
826    #[test]
827    fn owned_restrict_design_moves_through_a_trivial_section() {
828        let gauge = Gauge::identity(&[4]);
829        let raw_design = Array2::<f64>::from_shape_fn((7, 4), |(i, j)| {
830            ((i as f64) * 0.3 - (j as f64) * 1.7).sin() * 1.000000001
831        });
832
833        let borrowed = gauge.restrict_design(&raw_design);
834        let raw_ptr = raw_design.as_ptr();
835        let owned = gauge.restrict_design_owned(raw_design.clone());
836        assert_eq!(owned, borrowed, "owned and borrowed forms must agree exactly");
837        assert!(
838            owned.is_standard_layout(),
839            "consumers rely on the standard layout `to_owned` would have produced"
840        );
841
842        // The move path: the returned buffer IS the one that was handed in.
843        let moved = gauge.restrict_design_owned(raw_design);
844        assert_eq!(
845            moved.as_ptr(), raw_ptr,
846            "a trivial section must return the caller's own buffer, not a copy of it"
847        );
848
849        // A real section still goes through the GEMM, from an owned input.
850        let mut t = Array2::<f64>::eye(4);
851        t[[0, 1]] = 0.5;
852        let real = Gauge::from_t(t.clone(), &[4], &[4]);
853        let raw = Array2::<f64>::from_shape_fn((7, 4), |(i, j)| i as f64 + j as f64 * 0.25);
854        assert_eq!(
855            real.restrict_design_owned(raw.clone()),
856            real.restrict_design(&raw),
857            "the non-identity branch must match the borrowed form too"
858        );
859    }
860
861    /// A column-major input would pass the identity branch's move straight
862    /// through to a consumer expecting row-major, so it is normalised instead.
863    #[test]
864    fn owned_restrict_design_normalises_a_non_standard_layout() {
865        let gauge = Gauge::identity(&[3]);
866        let column_major = Array2::<f64>::from_shape_vec(
867            (4, 3).f(),
868            (0..12).map(|v| v as f64 * 0.5).collect(),
869        )
870        .expect("column-major fixture");
871        assert!(!column_major.is_standard_layout());
872
873        let owned = gauge.restrict_design_owned(column_major.clone());
874        assert!(
875            owned.is_standard_layout(),
876            "a non-standard input must be normalised, not passed through"
877        );
878        assert_eq!(
879            owned,
880            gauge.restrict_design(&column_major),
881            "normalisation must not change any value"
882        );
883    }
884
885    #[test]
886    fn non_identity_section_is_not_short_circuited() {
887        // A real reparametrisation must NOT take the identity fast path.
888        let mut t = Array2::<f64>::eye(3);
889        t[[0, 1]] = 0.5;
890        let gauge = Gauge::from_t(t.clone(), &[3], &[3]);
891        assert!(!gauge.t_full_is_identity());
892        let raw = Array2::<f64>::from_shape_fn((5, 3), |(i, j)| i as f64 + j as f64 * 0.25);
893        let restricted = gauge.restrict_design(&raw);
894        assert_eq!(restricted, fast_ab(&raw, &t));
895    }
896
897    #[test]
898    fn rectangular_section_is_not_identity() {
899        // A tall centring section is square-free and must never be mistaken
900        // for the identity (it removes a direction).
901        let z =
902            Array2::<f64>::from_shape_vec((3, 2), vec![1.0, 0.0, 0.0, 1.0, -1.0, -1.0]).unwrap();
903        let gauge = Gauge::sum_to_zero(z);
904        assert!(!gauge.t_full_is_identity());
905    }
906
907    #[test]
908    fn affine_gauge_lifts_betas_and_restricts_offsets() {
909        let t = Array2::from_shape_vec((3, 1), vec![2.0, -1.0, 0.5]).unwrap();
910        let shift = Array1::from(vec![0.25, 1.5, -0.75]);
911        let gauge = Gauge::from_block_transform_with_shift(t.clone(), shift.clone());
912        assert!(!gauge.is_identity());
913        let theta = Array1::from(vec![4.0]);
914
915        let raw = gauge.lift_block_betas(&[theta.clone()]);
916        let expected_raw = t.dot(&theta) + &shift;
917        assert_eq!(raw[0], expected_raw);
918
919        let x = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, 2.0, -1.0, 3.0, 0.5]).unwrap();
920        let offset = Array1::from(vec![0.1, -0.2]);
921        let (x_reduced, offset_reduced) = gauge.restrict_design_and_offset(&x, &offset);
922        assert_eq!(x_reduced, x.dot(&t));
923        assert_eq!(offset_reduced, &offset + &x.dot(&shift));
924
925        let eta_raw = x.dot(&expected_raw) + &offset;
926        let eta_reduced = x_reduced.dot(&theta) + &offset_reduced;
927        for i in 0..eta_raw.len() {
928            assert!((eta_raw[i] - eta_reduced[i]).abs() < 1e-14);
929        }
930
931        let cov_reduced = Array2::from_elem((1, 1), 3.0);
932        let lifted_cov = gauge.lift_covariance(&cov_reduced);
933        let expected_cov = t.dot(&cov_reduced).dot(&t.t());
934        assert_eq!(lifted_cov, expected_cov);
935    }
936
937    /// The covariance pushforward of an affine section `β = T·θ + a` must be
938    /// EXACTLY independent of the affine shift `a` — `Cov(T·θ + a) = T·Cov(θ)·Tᵀ`
939    /// for any constant `a`, because a deterministic offset adds no variance. The
940    /// b≡1 unit-log-t pin (#892) folds the warp into `a`; this is the property
941    /// that guarantees reporting the pinned coefficients carries the same
942    /// posterior uncertainty as the unpinned linear section. We assert it two
943    /// ways: (1) the analytic lift is bit-identical across a sweep of shift
944    /// magnitudes spanning the zero-shift linear case up to 1e7; and (2) an
945    /// empirical check — the sample covariance of `T·θ_k + a` over reduced draws
946    /// `θ_k` is unchanged when `a` is replaced by a 1e6-scale offset (the offset
947    /// cancels under centering).
948    #[test]
949    fn affine_shift_leaves_lifted_covariance_invariant() {
950        // A non-trivial 4-raw × 2-reduced section (so T mixes coordinates).
951        let t =
952            Array2::from_shape_vec((4, 2), vec![1.0, 0.0, 0.5, -1.0, 2.0, 0.3, -0.4, 1.5]).unwrap();
953        let raw_widths = [4usize];
954        let reduced_widths = [2usize];
955
956        // A non-diagonal reduced covariance.
957        let cov_reduced = Array2::from_shape_vec((2, 2), vec![2.0, -0.7, -0.7, 1.3]).unwrap();
958
959        // The reference lift is the zero-shift (purely linear) section.
960        let base =
961            Gauge::from_t_with_shift(t.clone(), &raw_widths, &reduced_widths, Array1::zeros(4));
962        let reference = base.lift_covariance(&cov_reduced);
963
964        // (1) Bit-identical across a wide sweep of shift magnitudes.
965        for &mag in &[0.0, 1e-7, 1.0, 1e3, 1e7] {
966            let shift = Array1::from(vec![mag, -mag, 0.5 * mag, -2.0 * mag]);
967            let gauge = Gauge::from_t_with_shift(t.clone(), &raw_widths, &reduced_widths, shift);
968            let lifted = gauge.lift_covariance(&cov_reduced);
969            for i in 0..4 {
970                for j in 0..4 {
971                    assert_eq!(
972                        lifted[[i, j]],
973                        reference[[i, j]],
974                        "affine shift magnitude {mag} must not perturb the lifted covariance \
975                         at ({i},{j}) — covariance is offset-invariant",
976                    );
977                }
978            }
979        }
980
981        // (2) Empirical check: draw reduced samples, push them through
982        // β = T·θ + a for two very different shifts, and confirm the sample
983        // covariance is the same for both shifts. Draws use a fixed Cholesky
984        // colouring of cov_reduced so the test is deterministic (no RNG).
985        let chol = {
986            let l00 = cov_reduced[[0, 0]].sqrt();
987            let l10 = cov_reduced[[1, 0]] / l00;
988            let l11 = (cov_reduced[[1, 1]] - l10 * l10).sqrt();
989            Array2::from_shape_vec((2, 2), vec![l00, 0.0, l10, l11]).unwrap()
990        };
991        let z_raw = [
992            [1.2, -0.4],
993            [-0.8, 0.9],
994            [0.3, 1.7],
995            [-1.5, -0.6],
996            [0.6, -1.1],
997            [-0.2, 0.3],
998            [1.9, 0.2],
999            [-1.4, -0.9],
1000        ];
1001        let sample_cov_for_shift = |shift: &Array1<f64>| -> Array2<f64> {
1002            let n = z_raw.len();
1003            let betas: Vec<Array1<f64>> = z_raw
1004                .iter()
1005                .map(|z| {
1006                    let theta = chol.dot(&Array1::from(vec![z[0], z[1]]));
1007                    t.dot(&theta) + shift
1008                })
1009                .collect();
1010            let mut mean = Array1::<f64>::zeros(4);
1011            for b in &betas {
1012                mean = &mean + b;
1013            }
1014            mean /= n as f64;
1015            let mut cov = Array2::<f64>::zeros((4, 4));
1016            for b in &betas {
1017                let c = b - &mean;
1018                for i in 0..4 {
1019                    for j in 0..4 {
1020                        cov[[i, j]] += c[i] * c[j] / n as f64;
1021                    }
1022                }
1023            }
1024            cov
1025        };
1026        let cov_small = sample_cov_for_shift(&Array1::zeros(4));
1027        let cov_big = sample_cov_for_shift(&Array1::from(vec![1e6, -1e6, 5e5, -2e6]));
1028        for i in 0..4 {
1029            for j in 0..4 {
1030                assert!(
1031                    (cov_small[[i, j]] - cov_big[[i, j]]).abs() < 1e-6,
1032                    "empirical sample covariance must be offset-invariant at ({i},{j}): \
1033                     small-shift {} vs big-shift {}",
1034                    cov_small[[i, j]],
1035                    cov_big[[i, j]],
1036                );
1037            }
1038        }
1039    }
1040
1041    #[test]
1042    fn block_diagonal_gauge_matches_per_block_lift() {
1043        // Block 0: selection keeping raw cols {0, 2} of width 3.
1044        let mut t0 = Array2::<f64>::zeros((3, 2));
1045        t0[[0, 0]] = 1.0;
1046        t0[[2, 1]] = 1.0;
1047        // Block 1: full identity of width 2.
1048        let t1 = Array2::<f64>::eye(2);
1049        let gauge = Gauge::from_block_transforms(&[t0.clone(), t1.clone()]);
1050        assert_eq!(gauge.raw_widths(), vec![3, 2]);
1051        assert_eq!(gauge.reduced_widths(), vec![2, 2]);
1052
1053        let theta = vec![Array1::from(vec![1.5, -2.5]), Array1::from(vec![0.5, 4.0])];
1054        let raw = gauge.lift_block_betas(&theta);
1055        assert_eq!(raw[0].as_slice().unwrap(), &[1.5, 0.0, -2.5]);
1056        assert_eq!(raw[1].as_slice().unwrap(), &[0.5, 4.0]);
1057
1058        // block_transform recovers the diagonal slabs exactly.
1059        assert_eq!(gauge.block_transform(0), t0);
1060        assert_eq!(gauge.block_transform(1), t1);
1061    }
1062
1063    #[test]
1064    fn triangular_gauge_applies_negative_r_off_diagonal() {
1065        // Two blocks, raw widths 2 and 2; block 1 keeps 1 column and is
1066        // residualised against block 0 by R (2×1).
1067        let v_a = Array2::<f64>::eye(2);
1068        let mut v_b = Array2::<f64>::zeros((2, 1));
1069        v_b[[0, 0]] = 1.0;
1070        let mut r_ab = Array2::<f64>::zeros((2, 1));
1071        r_ab[[0, 0]] = 0.5;
1072        r_ab[[1, 0]] = -0.25;
1073        let gauge = Gauge::from_v_and_r(&[v_a, v_b], &[None, Some(r_ab)]);
1074
1075        let theta = vec![Array1::from(vec![1.0, 2.0]), Array1::from(vec![4.0])];
1076        let raw = gauge.lift_block_betas(&theta);
1077        // β_a = V_a·θ_a − R_{a→b}·θ_b = [1 − 0.5·4, 2 + 0.25·4] = [−1, 3].
1078        assert!((raw[0][0] - (-1.0)).abs() < 1e-14);
1079        assert!((raw[0][1] - 3.0).abs() < 1e-14);
1080        // β_b = V_b·θ_b = [4, 0].
1081        assert!((raw[1][0] - 4.0).abs() < 1e-14);
1082        assert!((raw[1][1] - 0.0).abs() < 1e-14);
1083    }
1084
1085    /// For a zero-shift gauge, covariance lift must be the exact pushforward of
1086    /// the SAME `T` the β lift applies: for a rank-1 `Σ_θ = θθᵀ`, the lifted
1087    /// covariance must equal `(Tθ)(Tθ)ᵀ` built from the lifted β.
1088    #[test]
1089    fn covariance_lift_is_rank1_consistent_with_beta_lift() {
1090        let v_a = Array2::<f64>::eye(2);
1091        let mut v_b = Array2::<f64>::zeros((2, 1));
1092        v_b[[0, 0]] = 1.0;
1093        let mut r_ab = Array2::<f64>::zeros((2, 1));
1094        r_ab[[0, 0]] = 0.3;
1095        r_ab[[1, 0]] = 0.7;
1096        let gauge = Gauge::from_v_and_r(&[v_a, v_b], &[None, Some(r_ab)]);
1097
1098        let theta = vec![Array1::from(vec![0.8, -1.2]), Array1::from(vec![2.0])];
1099        let raw = gauge.lift_block_betas(&theta);
1100        let beta_full: Vec<f64> = raw.iter().flat_map(|b| b.iter().copied()).collect();
1101
1102        let theta_full = Array1::from(vec![0.8, -1.2, 2.0]);
1103        let cov_rank1 = {
1104            let n = theta_full.len();
1105            Array2::from_shape_fn((n, n), |(i, j)| theta_full[i] * theta_full[j])
1106        };
1107        let lifted = gauge.lift_covariance(&cov_rank1);
1108        assert_eq!(lifted.dim(), (4, 4));
1109        for i in 0..4 {
1110            for j in 0..4 {
1111                let expected = beta_full[i] * beta_full[j];
1112                assert!(
1113                    (lifted[[i, j]] - expected).abs() < 1e-12,
1114                    "rank-1 covariance lift must equal (Tθ)(Tθ)ᵀ at ({i},{j}): \
1115                     got {} expected {expected}",
1116                    lifted[[i, j]],
1117                );
1118            }
1119        }
1120    }
1121
1122    /// `Gauge::sum_to_zero(z)` must lift exactly as `β_raw = z · θ`, and the
1123    /// lift must preserve the linear predictor: for any centred design
1124    /// `B_c = B · z` and any reduced coefficient `θ`, the raw prediction
1125    /// `B · (z · θ)` equals the reduced prediction `B_c · θ`. This is the
1126    /// invariant that makes `z` the correct section — a wrong gauge would
1127    /// preserve coefficients but break η.
1128    #[test]
1129    fn sum_to_zero_gauge_lifts_via_z_and_preserves_eta() {
1130        // A concrete orthonormal centring section: null space of c = [1,1,1]ᵀ
1131        // (the unweighted sum-to-zero constraint on a width-3 block), built as
1132        // two orthonormal columns each summing to zero.
1133        let s = 1.0 / 2.0_f64.sqrt();
1134        let s6 = 1.0 / 6.0_f64.sqrt();
1135        let mut z = Array2::<f64>::zeros((3, 2));
1136        z[[0, 0]] = s;
1137        z[[1, 0]] = -s;
1138        z[[2, 0]] = 0.0;
1139        z[[0, 1]] = s6;
1140        z[[1, 1]] = s6;
1141        z[[2, 1]] = -2.0 * s6;
1142        // The columns are orthonormal and sum to zero (cᵀz = 0).
1143        for j in 0..2 {
1144            assert!(
1145                (z.column(j).sum()).abs() < 1e-14,
1146                "column {j} must sum to 0"
1147            );
1148            assert!(
1149                (z.column(j).dot(&z.column(j)) - 1.0).abs() < 1e-14,
1150                "column {j} must be unit norm"
1151            );
1152        }
1153
1154        let gauge = Gauge::sum_to_zero(z.clone());
1155        assert_eq!(gauge.n_blocks(), 1);
1156        assert_eq!(gauge.raw_widths(), vec![3]);
1157        assert_eq!(gauge.reduced_widths(), vec![2]);
1158        assert_eq!(gauge.block_transform(0), z);
1159
1160        // Lift β_raw = z · θ exactly.
1161        let theta = Array1::from(vec![1.3, -0.7]);
1162        let raw = gauge.lift_block_betas(&[theta.clone()]);
1163        let expected_raw = z.dot(&theta);
1164        for i in 0..3 {
1165            assert!((raw[0][i] - expected_raw[i]).abs() < 1e-14);
1166        }
1167        // Centring is satisfied: the raw coefficients sum to zero.
1168        assert!(raw[0].sum().abs() < 1e-14, "lifted β must be centred");
1169
1170        // η preservation: B · (z · θ) == (B · z) · θ for an arbitrary B.
1171        let b = Array2::from_shape_vec(
1172            (4, 3),
1173            vec![
1174                1.0, 2.0, -1.0, 0.5, -0.5, 3.0, 2.0, 1.0, 1.0, -1.0, 0.0, 4.0,
1175            ],
1176        )
1177        .unwrap();
1178        let b_c = fast_ab(&b, &z); // the constrained design B_c
1179        assert_eq!(gauge.restrict_design(&b), b_c);
1180        let eta_reduced = b_c.dot(&theta);
1181        let eta_raw = b.dot(&expected_raw);
1182        for i in 0..4 {
1183            assert!(
1184                (eta_reduced[i] - eta_raw[i]).abs() < 1e-13,
1185                "η must be invariant under the centring lift at row {i}",
1186            );
1187        }
1188
1189        // Covariance pushforward through the SAME z (rank-1 consistency).
1190        let cov_rank1 = Array2::from_shape_fn((2, 2), |(i, j)| theta[i] * theta[j]);
1191        let lifted = gauge.lift_covariance(&cov_rank1);
1192        assert_eq!(lifted.dim(), (3, 3));
1193        for i in 0..3 {
1194            for j in 0..3 {
1195                let expect = expected_raw[i] * expected_raw[j];
1196                assert!(
1197                    (lifted[[i, j]] - expect).abs() < 1e-13,
1198                    "centring covariance lift must equal (zθ)(zθ)ᵀ at ({i},{j})",
1199                );
1200            }
1201        }
1202
1203        let raw_penalty = Array2::from_shape_vec(
1204            (3, 3),
1205            vec![2.0, 0.5, 0.0, 0.5, 3.0, -0.25, 0.0, -0.25, 4.0],
1206        )
1207        .unwrap();
1208        let reduced_penalty = gauge.restrict_penalty(&raw_penalty);
1209        let expected_reduced_penalty = fast_ab(&fast_atb(&z, &raw_penalty), &z);
1210        assert_eq!(reduced_penalty, expected_reduced_penalty);
1211    }
1212
1213    #[test]
1214    #[should_panic(expected = "removes at least one direction")]
1215    fn sum_to_zero_rejects_identity_section() {
1216        // A square z removes no direction — that is not a centring section.
1217        drop(Gauge::sum_to_zero(Array2::<f64>::eye(3)));
1218    }
1219
1220    #[test]
1221    fn extend_with_identity_passes_extra_blocks_through() {
1222        let mut t0 = Array2::<f64>::zeros((2, 1));
1223        t0[[0, 0]] = 1.0;
1224        let gauge = Gauge::from_block_transforms(&[t0]).extend_with_identity(&[2]);
1225        assert_eq!(gauge.n_blocks(), 2);
1226        assert_eq!(gauge.raw_total(), 4);
1227        assert_eq!(gauge.reduced_total(), 3);
1228
1229        let theta = vec![Array1::from(vec![3.0]), Array1::from(vec![1.0, -1.0])];
1230        let raw = gauge.lift_block_betas(&theta);
1231        assert_eq!(raw[0].as_slice().unwrap(), &[3.0, 0.0]);
1232        assert_eq!(raw[1].as_slice().unwrap(), &[1.0, -1.0]);
1233
1234        // Covariance: the extra (untouched) block's diagonal sub-matrix
1235        // survives the lift bit-for-bit; the reduced block zero-pads.
1236        let mut cov = Array2::<f64>::eye(3);
1237        cov[[1, 2]] = 0.25;
1238        cov[[2, 1]] = 0.25;
1239        let lifted = gauge.lift_covariance(&cov);
1240        assert_eq!(lifted.dim(), (4, 4));
1241        assert!((lifted[[0, 0]] - 1.0).abs() < 1e-14);
1242        assert!(
1243            (lifted[[1, 1]] - 0.0).abs() < 1e-14,
1244            "dropped raw row has zero variance"
1245        );
1246        assert!((lifted[[2, 2]] - 1.0).abs() < 1e-14);
1247        assert!((lifted[[3, 3]] - 1.0).abs() < 1e-14);
1248        assert!((lifted[[2, 3]] - 0.25).abs() < 1e-14);
1249    }
1250
1251    #[test]
1252    fn left_compose_preserves_affine_maps_and_block_lineage() {
1253        // active [1,1] -> current raw [2,1]
1254        let inner_t = Array2::from_shape_vec((3, 2), vec![1.0, 0.0, -0.5, 0.0, 0.0, 2.0]).unwrap();
1255        let inner_shift = Array1::from(vec![0.25, -1.0, 0.5]);
1256        let inner =
1257            Gauge::from_t_with_shift(inner_t.clone(), &[2, 1], &[1, 1], inner_shift.clone());
1258
1259        // current [2,1] -> new raw [3,1]
1260        let outer_t = Array2::from_shape_vec(
1261            (4, 3),
1262            vec![1.0, 0.0, 0.5, 0.0, 2.0, 0.0, -1.0, 0.25, 0.0, 0.0, 0.0, 3.0],
1263        )
1264        .unwrap();
1265        let outer_shift = Array1::from(vec![1.0, -0.5, 0.75, 2.0]);
1266        let outer =
1267            Gauge::from_t_with_shift(outer_t.clone(), &[3, 1], &[2, 1], outer_shift.clone());
1268
1269        let composed = inner.left_compose(&outer).expect("compatible frames");
1270        assert_eq!(composed.raw_widths(), vec![3, 1]);
1271        assert_eq!(composed.reduced_widths(), vec![1, 1]);
1272        assert_eq!(composed.t_full, fast_ab(&outer_t, &inner_t));
1273        assert_eq!(
1274            composed.affine_shift,
1275            outer_t.dot(&inner_shift) + &outer_shift
1276        );
1277
1278        let active = Array1::from(vec![1.5, -0.75]);
1279        let current = inner_t.dot(&active) + &inner_shift;
1280        let expected_new = outer_t.dot(&current) + &outer_shift;
1281        let actual_new = composed.t_full.dot(&active) + &composed.affine_shift;
1282        for index in 0..actual_new.len() {
1283            assert!((actual_new[index] - expected_new[index]).abs() < 1e-14);
1284        }
1285
1286        let encoded = serde_json::to_string(&composed).expect("serialize gauge");
1287        let decoded: Gauge = serde_json::from_str(&encoded).expect("deserialize gauge");
1288        decoded.validate().expect("round-tripped gauge");
1289        assert_eq!(decoded.t_full, composed.t_full);
1290        assert_eq!(decoded.affine_shift, composed.affine_shift);
1291        assert_eq!(decoded.block_starts_raw, composed.block_starts_raw);
1292        assert_eq!(decoded.block_starts_reduced, composed.block_starts_reduced);
1293    }
1294
1295    #[test]
1296    fn left_compose_rejects_equal_totals_with_different_block_frames() {
1297        let inner = Gauge::from_t(Array2::eye(3), &[2, 1], &[2, 1]);
1298        let outer = Gauge::from_t(Array2::eye(3), &[2, 1], &[1, 2]);
1299        let error = inner
1300            .left_compose(&outer)
1301            .expect_err("block boundaries are part of the coordinate frame");
1302        assert!(error.contains("composition frame partition mismatch"));
1303    }
1304}