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