1use ndarray::{Array1, Array2, ArrayBase, Data, Ix2};
30use serde::{Deserialize, Serialize};
31
32use gam_linalg::faer_ndarray::{fast_ab, fast_abt, fast_atb};
33
34pub trait CompiledBlockMap {
43 fn raw_from_compiled(&self) -> &Array2<f64>;
45 fn raw_block_ranges(&self) -> &[std::ops::Range<usize>];
47 fn compiled_block_ranges(&self) -> &[std::ops::Range<usize>];
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Gauge {
56 pub t_full: Array2<f64>,
58 pub affine_shift: Array1<f64>,
60 pub block_starts_raw: Vec<usize>,
63 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
76pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn n_blocks(&self) -> usize {
446 self.block_starts_raw.len().saturating_sub(1)
447 }
448
449 pub fn raw_total(&self) -> usize {
451 self.block_starts_raw.last().copied().unwrap_or(0)
452 }
453
454 pub fn reduced_total(&self) -> usize {
456 self.block_starts_reduced.last().copied().unwrap_or(0)
457 }
458
459 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 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 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 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 if self.t_full_is_identity() {
512 return raw_design.to_owned();
513 }
514 fast_ab(raw_design, &self.t_full)
515 }
516
517 fn t_full_is_identity(&self) -> bool {
523 let (r, c) = self.t_full.dim();
524 if r != c {
525 return false;
526 }
527 self.t_full
528 .indexed_iter()
529 .all(|((i, j), &v)| v == if i == j { 1.0 } else { 0.0 })
530 }
531
532 pub fn is_identity(&self) -> bool {
540 self.validate().is_ok()
541 && self.block_starts_raw == self.block_starts_reduced
542 && self.affine_shift.iter().all(|&value| value == 0.0)
543 && self.t_full_is_identity()
544 }
545
546 pub fn restrict_design_and_offset<S: Data<Elem = f64>>(
549 &self,
550 raw_design: &ArrayBase<S, Ix2>,
551 raw_offset: &Array1<f64>,
552 ) -> (Array2<f64>, Array1<f64>) {
553 assert_eq!(
554 raw_design.nrows(),
555 raw_offset.len(),
556 "Gauge::restrict_design_and_offset: design rows {} != offset len {}",
557 raw_design.nrows(),
558 raw_offset.len(),
559 );
560 let reduced_design = self.restrict_design(raw_design);
561 let reduced_offset = raw_offset + &raw_design.dot(&self.affine_shift);
562 (reduced_design, reduced_offset)
563 }
564
565 pub fn restrict_penalty<S: Data<Elem = f64>>(
568 &self,
569 raw_penalty: &ArrayBase<S, Ix2>,
570 ) -> Array2<f64> {
571 let raw_total = self.raw_total();
572 assert_eq!(
573 raw_penalty.dim(),
574 (raw_total, raw_total),
575 "Gauge::restrict_penalty: matrix has shape {:?}, expected ({raw_total}, {raw_total})",
576 raw_penalty.dim(),
577 );
578 if self.t_full_is_identity() {
581 return raw_penalty.to_owned();
582 }
583 let t_s = fast_atb(&self.t_full, raw_penalty);
584 fast_ab(&t_s, &self.t_full)
585 }
586
587 pub fn restrict_quadratic_factor<S: Data<Elem = f64>>(
605 &self,
606 raw_factor: &ArrayBase<S, Ix2>,
607 ) -> Array2<f64> {
608 let raw_total = self.raw_total();
609 assert_eq!(
610 raw_factor.ncols(),
611 raw_total,
612 "Gauge::restrict_quadratic_factor: factor has {} columns, expected {raw_total}",
613 raw_factor.ncols(),
614 );
615 if self.t_full_is_identity() {
616 return raw_factor.to_owned();
617 }
618 fast_ab(raw_factor, &self.t_full)
619 }
620
621 pub fn extend_with_identity(&self, extra_raw_widths: &[usize]) -> Self {
626 let extra_total: usize = extra_raw_widths.iter().sum();
627 let raw_total = self.raw_total();
628 let reduced_total = self.reduced_total();
629 let mut t = Array2::<f64>::zeros((raw_total + extra_total, reduced_total + extra_total));
630 t.slice_mut(ndarray::s![0..raw_total, 0..reduced_total])
631 .assign(&self.t_full);
632 for k in 0..extra_total {
633 t[[raw_total + k, reduced_total + k]] = 1.0;
634 }
635 let mut block_starts_raw = self.block_starts_raw.clone();
636 let mut block_starts_reduced = self.block_starts_reduced.clone();
637 for &w in extra_raw_widths {
638 block_starts_raw.push(block_starts_raw.last().copied().unwrap() + w);
639 block_starts_reduced.push(block_starts_reduced.last().copied().unwrap() + w);
640 }
641 let mut affine_shift = Array1::<f64>::zeros(raw_total + extra_total);
642 affine_shift
643 .slice_mut(ndarray::s![0..raw_total])
644 .assign(&self.affine_shift);
645 Self {
646 t_full: t,
647 affine_shift,
648 block_starts_raw,
649 block_starts_reduced,
650 }
651 }
652
653 pub fn lift_block_betas(&self, reduced_block_betas: &[Array1<f64>]) -> Vec<Array1<f64>> {
657 let n_blocks = self.n_blocks();
658 assert_eq!(
659 reduced_block_betas.len(),
660 n_blocks,
661 "Gauge::lift_block_betas: got {} reduced block betas, expected {}",
662 reduced_block_betas.len(),
663 n_blocks,
664 );
665 for (b, beta) in reduced_block_betas.iter().enumerate() {
666 let expected = self.block_starts_reduced[b + 1] - self.block_starts_reduced[b];
667 assert_eq!(
668 beta.len(),
669 expected,
670 "Gauge::lift_block_betas: block {b} has β of len {}, expected reduced width {}",
671 beta.len(),
672 expected,
673 );
674 }
675 let mut theta_full = Array1::<f64>::zeros(self.reduced_total());
676 for (b, beta) in reduced_block_betas.iter().enumerate() {
677 let c0 = self.block_starts_reduced[b];
678 let c1 = self.block_starts_reduced[b + 1];
679 theta_full.slice_mut(ndarray::s![c0..c1]).assign(beta);
680 }
681 let beta_full = self.t_full.dot(&theta_full) + &self.affine_shift;
682 let mut out = Vec::with_capacity(n_blocks);
683 for b in 0..n_blocks {
684 let r0 = self.block_starts_raw[b];
685 let r1 = self.block_starts_raw[b + 1];
686 out.push(beta_full.slice(ndarray::s![r0..r1]).to_owned());
687 }
688 out
689 }
690
691 pub fn lift_covariance(&self, covariance_reduced: &Array2<f64>) -> Array2<f64> {
703 let total_reduced = self.reduced_total();
704 assert_eq!(
705 covariance_reduced.dim(),
706 (total_reduced, total_reduced),
707 "Gauge::lift_covariance: matrix has shape {:?}, expected ({total_reduced}, {total_reduced})",
708 covariance_reduced.dim(),
709 );
710 let t_m = fast_ab(&self.t_full, covariance_reduced);
711 let mut raw = fast_abt(&t_m, &self.t_full);
712 let n = raw.nrows();
713 for i in 0..n {
714 for j in (i + 1)..n {
715 let avg = 0.5 * (raw[[i, j]] + raw[[j, i]]);
716 raw[[i, j]] = avg;
717 raw[[j, i]] = avg;
718 }
719 }
720 raw
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn identity_gauge_round_trips_betas_and_covariance() {
730 let gauge = Gauge::identity(&[2, 3]);
731 assert!(gauge.is_identity());
732 assert_eq!(gauge.n_blocks(), 2);
733 assert_eq!(gauge.raw_total(), 5);
734 assert_eq!(gauge.reduced_total(), 5);
735 let theta = vec![
736 Array1::from(vec![0.5, -0.25]),
737 Array1::from(vec![1.0, 2.0, -3.0]),
738 ];
739 let raw = gauge.lift_block_betas(&theta);
740 assert_eq!(raw[0].as_slice().unwrap(), &[0.5, -0.25]);
741 assert_eq!(raw[1].as_slice().unwrap(), &[1.0, 2.0, -3.0]);
742
743 let mut cov = Array2::<f64>::eye(5);
744 cov[[0, 3]] = 0.4;
745 cov[[3, 0]] = 0.4;
746 let lifted = gauge.lift_covariance(&cov);
747 for i in 0..5 {
748 for j in 0..5 {
749 assert!(
750 (lifted[[i, j]] - cov[[i, j]]).abs() < 1e-14,
751 "identity gauge must be a covariance no-op at ({i},{j})",
752 );
753 }
754 }
755 }
756
757 #[test]
758 fn identity_section_short_circuits_restrict_bit_exactly() {
759 let gauge = Gauge::identity(&[4]);
762 assert!(gauge.t_full_is_identity());
763
764 let raw_design = Array2::<f64>::from_shape_fn((7, 4), |(i, j)| {
767 ((i as f64) * 0.3 - (j as f64) * 1.7).sin() * 1.000000001
768 });
769 let restricted = gauge.restrict_design(&raw_design);
770 assert_eq!(restricted, raw_design);
772 let via_gemm = fast_ab(&raw_design, &gauge.t_full);
774 assert_eq!(restricted, via_gemm);
775
776 let raw_penalty = Array2::<f64>::from_shape_fn((4, 4), |(i, j)| {
777 (i as f64 + 1.0) * (j as f64 + 2.0) * 0.111
778 });
779 let restricted_pen = gauge.restrict_penalty(&raw_penalty);
780 assert_eq!(restricted_pen, raw_penalty);
781 let pen_via_gemm = fast_ab(&fast_atb(&gauge.t_full, &raw_penalty), &gauge.t_full);
782 assert_eq!(restricted_pen, pen_via_gemm);
783 }
784
785 #[test]
786 fn non_identity_section_is_not_short_circuited() {
787 let mut t = Array2::<f64>::eye(3);
789 t[[0, 1]] = 0.5;
790 let gauge = Gauge::from_t(t.clone(), &[3], &[3]);
791 assert!(!gauge.t_full_is_identity());
792 let raw = Array2::<f64>::from_shape_fn((5, 3), |(i, j)| i as f64 + j as f64 * 0.25);
793 let restricted = gauge.restrict_design(&raw);
794 assert_eq!(restricted, fast_ab(&raw, &t));
795 }
796
797 #[test]
798 fn rectangular_section_is_not_identity() {
799 let z =
802 Array2::<f64>::from_shape_vec((3, 2), vec![1.0, 0.0, 0.0, 1.0, -1.0, -1.0]).unwrap();
803 let gauge = Gauge::sum_to_zero(z);
804 assert!(!gauge.t_full_is_identity());
805 }
806
807 #[test]
808 fn affine_gauge_lifts_betas_and_restricts_offsets() {
809 let t = Array2::from_shape_vec((3, 1), vec![2.0, -1.0, 0.5]).unwrap();
810 let shift = Array1::from(vec![0.25, 1.5, -0.75]);
811 let gauge = Gauge::from_block_transform_with_shift(t.clone(), shift.clone());
812 assert!(!gauge.is_identity());
813 let theta = Array1::from(vec![4.0]);
814
815 let raw = gauge.lift_block_betas(&[theta.clone()]);
816 let expected_raw = t.dot(&theta) + &shift;
817 assert_eq!(raw[0], expected_raw);
818
819 let x = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, 2.0, -1.0, 3.0, 0.5]).unwrap();
820 let offset = Array1::from(vec![0.1, -0.2]);
821 let (x_reduced, offset_reduced) = gauge.restrict_design_and_offset(&x, &offset);
822 assert_eq!(x_reduced, x.dot(&t));
823 assert_eq!(offset_reduced, &offset + &x.dot(&shift));
824
825 let eta_raw = x.dot(&expected_raw) + &offset;
826 let eta_reduced = x_reduced.dot(&theta) + &offset_reduced;
827 for i in 0..eta_raw.len() {
828 assert!((eta_raw[i] - eta_reduced[i]).abs() < 1e-14);
829 }
830
831 let cov_reduced = Array2::from_elem((1, 1), 3.0);
832 let lifted_cov = gauge.lift_covariance(&cov_reduced);
833 let expected_cov = t.dot(&cov_reduced).dot(&t.t());
834 assert_eq!(lifted_cov, expected_cov);
835 }
836
837 #[test]
849 fn affine_shift_leaves_lifted_covariance_invariant() {
850 let t =
852 Array2::from_shape_vec((4, 2), vec![1.0, 0.0, 0.5, -1.0, 2.0, 0.3, -0.4, 1.5]).unwrap();
853 let raw_widths = [4usize];
854 let reduced_widths = [2usize];
855
856 let cov_reduced = Array2::from_shape_vec((2, 2), vec![2.0, -0.7, -0.7, 1.3]).unwrap();
858
859 let base =
861 Gauge::from_t_with_shift(t.clone(), &raw_widths, &reduced_widths, Array1::zeros(4));
862 let reference = base.lift_covariance(&cov_reduced);
863
864 for &mag in &[0.0, 1e-7, 1.0, 1e3, 1e7] {
866 let shift = Array1::from(vec![mag, -mag, 0.5 * mag, -2.0 * mag]);
867 let gauge = Gauge::from_t_with_shift(t.clone(), &raw_widths, &reduced_widths, shift);
868 let lifted = gauge.lift_covariance(&cov_reduced);
869 for i in 0..4 {
870 for j in 0..4 {
871 assert_eq!(
872 lifted[[i, j]],
873 reference[[i, j]],
874 "affine shift magnitude {mag} must not perturb the lifted covariance \
875 at ({i},{j}) — covariance is offset-invariant",
876 );
877 }
878 }
879 }
880
881 let chol = {
886 let l00 = cov_reduced[[0, 0]].sqrt();
887 let l10 = cov_reduced[[1, 0]] / l00;
888 let l11 = (cov_reduced[[1, 1]] - l10 * l10).sqrt();
889 Array2::from_shape_vec((2, 2), vec![l00, 0.0, l10, l11]).unwrap()
890 };
891 let z_raw = [
892 [1.2, -0.4],
893 [-0.8, 0.9],
894 [0.3, 1.7],
895 [-1.5, -0.6],
896 [0.6, -1.1],
897 [-0.2, 0.3],
898 [1.9, 0.2],
899 [-1.4, -0.9],
900 ];
901 let sample_cov_for_shift = |shift: &Array1<f64>| -> Array2<f64> {
902 let n = z_raw.len();
903 let betas: Vec<Array1<f64>> = z_raw
904 .iter()
905 .map(|z| {
906 let theta = chol.dot(&Array1::from(vec![z[0], z[1]]));
907 t.dot(&theta) + shift
908 })
909 .collect();
910 let mut mean = Array1::<f64>::zeros(4);
911 for b in &betas {
912 mean = &mean + b;
913 }
914 mean /= n as f64;
915 let mut cov = Array2::<f64>::zeros((4, 4));
916 for b in &betas {
917 let c = b - &mean;
918 for i in 0..4 {
919 for j in 0..4 {
920 cov[[i, j]] += c[i] * c[j] / n as f64;
921 }
922 }
923 }
924 cov
925 };
926 let cov_small = sample_cov_for_shift(&Array1::zeros(4));
927 let cov_big = sample_cov_for_shift(&Array1::from(vec![1e6, -1e6, 5e5, -2e6]));
928 for i in 0..4 {
929 for j in 0..4 {
930 assert!(
931 (cov_small[[i, j]] - cov_big[[i, j]]).abs() < 1e-6,
932 "empirical sample covariance must be offset-invariant at ({i},{j}): \
933 small-shift {} vs big-shift {}",
934 cov_small[[i, j]],
935 cov_big[[i, j]],
936 );
937 }
938 }
939 }
940
941 #[test]
942 fn block_diagonal_gauge_matches_per_block_lift() {
943 let mut t0 = Array2::<f64>::zeros((3, 2));
945 t0[[0, 0]] = 1.0;
946 t0[[2, 1]] = 1.0;
947 let t1 = Array2::<f64>::eye(2);
949 let gauge = Gauge::from_block_transforms(&[t0.clone(), t1.clone()]);
950 assert_eq!(gauge.raw_widths(), vec![3, 2]);
951 assert_eq!(gauge.reduced_widths(), vec![2, 2]);
952
953 let theta = vec![Array1::from(vec![1.5, -2.5]), Array1::from(vec![0.5, 4.0])];
954 let raw = gauge.lift_block_betas(&theta);
955 assert_eq!(raw[0].as_slice().unwrap(), &[1.5, 0.0, -2.5]);
956 assert_eq!(raw[1].as_slice().unwrap(), &[0.5, 4.0]);
957
958 assert_eq!(gauge.block_transform(0), t0);
960 assert_eq!(gauge.block_transform(1), t1);
961 }
962
963 #[test]
964 fn triangular_gauge_applies_negative_r_off_diagonal() {
965 let v_a = Array2::<f64>::eye(2);
968 let mut v_b = Array2::<f64>::zeros((2, 1));
969 v_b[[0, 0]] = 1.0;
970 let mut r_ab = Array2::<f64>::zeros((2, 1));
971 r_ab[[0, 0]] = 0.5;
972 r_ab[[1, 0]] = -0.25;
973 let gauge = Gauge::from_v_and_r(&[v_a, v_b], &[None, Some(r_ab)]);
974
975 let theta = vec![Array1::from(vec![1.0, 2.0]), Array1::from(vec![4.0])];
976 let raw = gauge.lift_block_betas(&theta);
977 assert!((raw[0][0] - (-1.0)).abs() < 1e-14);
979 assert!((raw[0][1] - 3.0).abs() < 1e-14);
980 assert!((raw[1][0] - 4.0).abs() < 1e-14);
982 assert!((raw[1][1] - 0.0).abs() < 1e-14);
983 }
984
985 #[test]
989 fn covariance_lift_is_rank1_consistent_with_beta_lift() {
990 let v_a = Array2::<f64>::eye(2);
991 let mut v_b = Array2::<f64>::zeros((2, 1));
992 v_b[[0, 0]] = 1.0;
993 let mut r_ab = Array2::<f64>::zeros((2, 1));
994 r_ab[[0, 0]] = 0.3;
995 r_ab[[1, 0]] = 0.7;
996 let gauge = Gauge::from_v_and_r(&[v_a, v_b], &[None, Some(r_ab)]);
997
998 let theta = vec![Array1::from(vec![0.8, -1.2]), Array1::from(vec![2.0])];
999 let raw = gauge.lift_block_betas(&theta);
1000 let beta_full: Vec<f64> = raw.iter().flat_map(|b| b.iter().copied()).collect();
1001
1002 let theta_full = Array1::from(vec![0.8, -1.2, 2.0]);
1003 let cov_rank1 = {
1004 let n = theta_full.len();
1005 Array2::from_shape_fn((n, n), |(i, j)| theta_full[i] * theta_full[j])
1006 };
1007 let lifted = gauge.lift_covariance(&cov_rank1);
1008 assert_eq!(lifted.dim(), (4, 4));
1009 for i in 0..4 {
1010 for j in 0..4 {
1011 let expected = beta_full[i] * beta_full[j];
1012 assert!(
1013 (lifted[[i, j]] - expected).abs() < 1e-12,
1014 "rank-1 covariance lift must equal (Tθ)(Tθ)ᵀ at ({i},{j}): \
1015 got {} expected {expected}",
1016 lifted[[i, j]],
1017 );
1018 }
1019 }
1020 }
1021
1022 #[test]
1029 fn sum_to_zero_gauge_lifts_via_z_and_preserves_eta() {
1030 let s = 1.0 / 2.0_f64.sqrt();
1034 let s6 = 1.0 / 6.0_f64.sqrt();
1035 let mut z = Array2::<f64>::zeros((3, 2));
1036 z[[0, 0]] = s;
1037 z[[1, 0]] = -s;
1038 z[[2, 0]] = 0.0;
1039 z[[0, 1]] = s6;
1040 z[[1, 1]] = s6;
1041 z[[2, 1]] = -2.0 * s6;
1042 for j in 0..2 {
1044 assert!(
1045 (z.column(j).sum()).abs() < 1e-14,
1046 "column {j} must sum to 0"
1047 );
1048 assert!(
1049 (z.column(j).dot(&z.column(j)) - 1.0).abs() < 1e-14,
1050 "column {j} must be unit norm"
1051 );
1052 }
1053
1054 let gauge = Gauge::sum_to_zero(z.clone());
1055 assert_eq!(gauge.n_blocks(), 1);
1056 assert_eq!(gauge.raw_widths(), vec![3]);
1057 assert_eq!(gauge.reduced_widths(), vec![2]);
1058 assert_eq!(gauge.block_transform(0), z);
1059
1060 let theta = Array1::from(vec![1.3, -0.7]);
1062 let raw = gauge.lift_block_betas(&[theta.clone()]);
1063 let expected_raw = z.dot(&theta);
1064 for i in 0..3 {
1065 assert!((raw[0][i] - expected_raw[i]).abs() < 1e-14);
1066 }
1067 assert!(raw[0].sum().abs() < 1e-14, "lifted β must be centred");
1069
1070 let b = Array2::from_shape_vec(
1072 (4, 3),
1073 vec![
1074 1.0, 2.0, -1.0, 0.5, -0.5, 3.0, 2.0, 1.0, 1.0, -1.0, 0.0, 4.0,
1075 ],
1076 )
1077 .unwrap();
1078 let b_c = fast_ab(&b, &z); assert_eq!(gauge.restrict_design(&b), b_c);
1080 let eta_reduced = b_c.dot(&theta);
1081 let eta_raw = b.dot(&expected_raw);
1082 for i in 0..4 {
1083 assert!(
1084 (eta_reduced[i] - eta_raw[i]).abs() < 1e-13,
1085 "η must be invariant under the centring lift at row {i}",
1086 );
1087 }
1088
1089 let cov_rank1 = Array2::from_shape_fn((2, 2), |(i, j)| theta[i] * theta[j]);
1091 let lifted = gauge.lift_covariance(&cov_rank1);
1092 assert_eq!(lifted.dim(), (3, 3));
1093 for i in 0..3 {
1094 for j in 0..3 {
1095 let expect = expected_raw[i] * expected_raw[j];
1096 assert!(
1097 (lifted[[i, j]] - expect).abs() < 1e-13,
1098 "centring covariance lift must equal (zθ)(zθ)ᵀ at ({i},{j})",
1099 );
1100 }
1101 }
1102
1103 let raw_penalty = Array2::from_shape_vec(
1104 (3, 3),
1105 vec![2.0, 0.5, 0.0, 0.5, 3.0, -0.25, 0.0, -0.25, 4.0],
1106 )
1107 .unwrap();
1108 let reduced_penalty = gauge.restrict_penalty(&raw_penalty);
1109 let expected_reduced_penalty = fast_ab(&fast_atb(&z, &raw_penalty), &z);
1110 assert_eq!(reduced_penalty, expected_reduced_penalty);
1111 }
1112
1113 #[test]
1114 #[should_panic(expected = "removes at least one direction")]
1115 fn sum_to_zero_rejects_identity_section() {
1116 drop(Gauge::sum_to_zero(Array2::<f64>::eye(3)));
1118 }
1119
1120 #[test]
1121 fn extend_with_identity_passes_extra_blocks_through() {
1122 let mut t0 = Array2::<f64>::zeros((2, 1));
1123 t0[[0, 0]] = 1.0;
1124 let gauge = Gauge::from_block_transforms(&[t0]).extend_with_identity(&[2]);
1125 assert_eq!(gauge.n_blocks(), 2);
1126 assert_eq!(gauge.raw_total(), 4);
1127 assert_eq!(gauge.reduced_total(), 3);
1128
1129 let theta = vec![Array1::from(vec![3.0]), Array1::from(vec![1.0, -1.0])];
1130 let raw = gauge.lift_block_betas(&theta);
1131 assert_eq!(raw[0].as_slice().unwrap(), &[3.0, 0.0]);
1132 assert_eq!(raw[1].as_slice().unwrap(), &[1.0, -1.0]);
1133
1134 let mut cov = Array2::<f64>::eye(3);
1137 cov[[1, 2]] = 0.25;
1138 cov[[2, 1]] = 0.25;
1139 let lifted = gauge.lift_covariance(&cov);
1140 assert_eq!(lifted.dim(), (4, 4));
1141 assert!((lifted[[0, 0]] - 1.0).abs() < 1e-14);
1142 assert!(
1143 (lifted[[1, 1]] - 0.0).abs() < 1e-14,
1144 "dropped raw row has zero variance"
1145 );
1146 assert!((lifted[[2, 2]] - 1.0).abs() < 1e-14);
1147 assert!((lifted[[3, 3]] - 1.0).abs() < 1e-14);
1148 assert!((lifted[[2, 3]] - 0.25).abs() < 1e-14);
1149 }
1150
1151 #[test]
1152 fn left_compose_preserves_affine_maps_and_block_lineage() {
1153 let inner_t = Array2::from_shape_vec((3, 2), vec![1.0, 0.0, -0.5, 0.0, 0.0, 2.0]).unwrap();
1155 let inner_shift = Array1::from(vec![0.25, -1.0, 0.5]);
1156 let inner =
1157 Gauge::from_t_with_shift(inner_t.clone(), &[2, 1], &[1, 1], inner_shift.clone());
1158
1159 let outer_t = Array2::from_shape_vec(
1161 (4, 3),
1162 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],
1163 )
1164 .unwrap();
1165 let outer_shift = Array1::from(vec![1.0, -0.5, 0.75, 2.0]);
1166 let outer =
1167 Gauge::from_t_with_shift(outer_t.clone(), &[3, 1], &[2, 1], outer_shift.clone());
1168
1169 let composed = inner.left_compose(&outer).expect("compatible frames");
1170 assert_eq!(composed.raw_widths(), vec![3, 1]);
1171 assert_eq!(composed.reduced_widths(), vec![1, 1]);
1172 assert_eq!(composed.t_full, fast_ab(&outer_t, &inner_t));
1173 assert_eq!(
1174 composed.affine_shift,
1175 outer_t.dot(&inner_shift) + &outer_shift
1176 );
1177
1178 let active = Array1::from(vec![1.5, -0.75]);
1179 let current = inner_t.dot(&active) + &inner_shift;
1180 let expected_new = outer_t.dot(¤t) + &outer_shift;
1181 let actual_new = composed.t_full.dot(&active) + &composed.affine_shift;
1182 for index in 0..actual_new.len() {
1183 assert!((actual_new[index] - expected_new[index]).abs() < 1e-14);
1184 }
1185
1186 let encoded = serde_json::to_string(&composed).expect("serialize gauge");
1187 let decoded: Gauge = serde_json::from_str(&encoded).expect("deserialize gauge");
1188 decoded.validate().expect("round-tripped gauge");
1189 assert_eq!(decoded.t_full, composed.t_full);
1190 assert_eq!(decoded.affine_shift, composed.affine_shift);
1191 assert_eq!(decoded.block_starts_raw, composed.block_starts_raw);
1192 assert_eq!(decoded.block_starts_reduced, composed.block_starts_reduced);
1193 }
1194
1195 #[test]
1196 fn left_compose_rejects_equal_totals_with_different_block_frames() {
1197 let inner = Gauge::from_t(Array2::eye(3), &[2, 1], &[2, 1]);
1198 let outer = Gauge::from_t(Array2::eye(3), &[2, 1], &[1, 2]);
1199 let error = inner
1200 .left_compose(&outer)
1201 .expect_err("block boundaries are part of the coordinate frame");
1202 assert!(error.contains("composition frame partition mismatch"));
1203 }
1204}