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 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
78pub 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn n_blocks(&self) -> usize {
408 self.block_starts_raw.len().saturating_sub(1)
409 }
410
411 pub fn raw_total(&self) -> usize {
413 self.block_starts_raw.last().copied().unwrap_or(0)
414 }
415
416 pub fn reduced_total(&self) -> usize {
418 self.block_starts_reduced.last().copied().unwrap_or(0)
419 }
420
421 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 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 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 if self.t_full_is_identity() {
466 return raw_design.to_owned();
467 }
468 fast_ab(raw_design, &self.t_full)
469 }
470
471 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 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 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 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 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 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 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 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 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 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 let gauge = Gauge::identity(&[4]);
791 assert!(gauge.t_full_is_identity());
792
793 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 assert_eq!(restricted, raw_design);
801 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 #[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 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 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 #[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 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 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 #[test]
944 fn affine_shift_leaves_lifted_covariance_invariant() {
945 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 let cov_reduced = Array2::from_shape_vec((2, 2), vec![2.0, -0.7, -0.7, 1.3]).unwrap();
953
954 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 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 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 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 assert!((raw[0][0] - (-1.0)).abs() < 1e-14);
1052 assert!((raw[0][1] - 3.0).abs() < 1e-14);
1053 assert!((raw[1][0] - 4.0).abs() < 1e-14);
1055 assert!((raw[1][1] - 0.0).abs() < 1e-14);
1056 }
1057
1058 #[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 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}