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 pub fn restrict_design_owned(&self, raw_design: Array2<f64>) -> Array2<f64> {
535 let raw_total = self.raw_total();
536 assert_eq!(
537 raw_design.ncols(),
538 raw_total,
539 "Gauge::restrict_design_owned: design has {} columns, expected raw width {raw_total}",
540 raw_design.ncols(),
541 );
542 if self.t_full_is_identity() {
543 return if raw_design.is_standard_layout() {
544 raw_design
545 } else {
546 raw_design.as_standard_layout().into_owned()
547 };
548 }
549 fast_ab(&raw_design, &self.t_full)
550 }
551
552 fn t_full_is_identity(&self) -> bool {
558 let (r, c) = self.t_full.dim();
559 if r != c {
560 return false;
561 }
562 self.t_full
563 .indexed_iter()
564 .all(|((i, j), &v)| v == if i == j { 1.0 } else { 0.0 })
565 }
566
567 pub fn is_identity(&self) -> bool {
575 self.validate().is_ok()
576 && self.block_starts_raw == self.block_starts_reduced
577 && self.affine_shift.iter().all(|&value| value == 0.0)
578 && self.t_full_is_identity()
579 }
580
581 pub fn restrict_design_and_offset<S: Data<Elem = f64>>(
584 &self,
585 raw_design: &ArrayBase<S, Ix2>,
586 raw_offset: &Array1<f64>,
587 ) -> (Array2<f64>, Array1<f64>) {
588 assert_eq!(
589 raw_design.nrows(),
590 raw_offset.len(),
591 "Gauge::restrict_design_and_offset: design rows {} != offset len {}",
592 raw_design.nrows(),
593 raw_offset.len(),
594 );
595 let reduced_design = self.restrict_design(raw_design);
596 let reduced_offset = raw_offset + &raw_design.dot(&self.affine_shift);
597 (reduced_design, reduced_offset)
598 }
599
600 pub fn restrict_penalty<S: Data<Elem = f64>>(
603 &self,
604 raw_penalty: &ArrayBase<S, Ix2>,
605 ) -> Array2<f64> {
606 let raw_total = self.raw_total();
607 assert_eq!(
608 raw_penalty.dim(),
609 (raw_total, raw_total),
610 "Gauge::restrict_penalty: matrix has shape {:?}, expected ({raw_total}, {raw_total})",
611 raw_penalty.dim(),
612 );
613 if self.t_full_is_identity() {
616 return raw_penalty.to_owned();
617 }
618 let t_s = fast_atb(&self.t_full, raw_penalty);
619 fast_ab(&t_s, &self.t_full)
620 }
621
622 pub fn restrict_quadratic_factor<S: Data<Elem = f64>>(
640 &self,
641 raw_factor: &ArrayBase<S, Ix2>,
642 ) -> Array2<f64> {
643 let raw_total = self.raw_total();
644 assert_eq!(
645 raw_factor.ncols(),
646 raw_total,
647 "Gauge::restrict_quadratic_factor: factor has {} columns, expected {raw_total}",
648 raw_factor.ncols(),
649 );
650 if self.t_full_is_identity() {
651 return raw_factor.to_owned();
652 }
653 fast_ab(raw_factor, &self.t_full)
654 }
655
656 pub fn extend_with_identity(&self, extra_raw_widths: &[usize]) -> Self {
661 let extra_total: usize = extra_raw_widths.iter().sum();
662 let raw_total = self.raw_total();
663 let reduced_total = self.reduced_total();
664 let mut t = Array2::<f64>::zeros((raw_total + extra_total, reduced_total + extra_total));
665 t.slice_mut(ndarray::s![0..raw_total, 0..reduced_total])
666 .assign(&self.t_full);
667 for k in 0..extra_total {
668 t[[raw_total + k, reduced_total + k]] = 1.0;
669 }
670 let mut block_starts_raw = self.block_starts_raw.clone();
671 let mut block_starts_reduced = self.block_starts_reduced.clone();
672 for &w in extra_raw_widths {
673 block_starts_raw.push(block_starts_raw.last().copied().unwrap() + w);
674 block_starts_reduced.push(block_starts_reduced.last().copied().unwrap() + w);
675 }
676 let mut affine_shift = Array1::<f64>::zeros(raw_total + extra_total);
677 affine_shift
678 .slice_mut(ndarray::s![0..raw_total])
679 .assign(&self.affine_shift);
680 Self {
681 t_full: t,
682 affine_shift,
683 block_starts_raw,
684 block_starts_reduced,
685 }
686 }
687
688 pub fn lift_block_betas(&self, reduced_block_betas: &[Array1<f64>]) -> Vec<Array1<f64>> {
692 let n_blocks = self.n_blocks();
693 assert_eq!(
694 reduced_block_betas.len(),
695 n_blocks,
696 "Gauge::lift_block_betas: got {} reduced block betas, expected {}",
697 reduced_block_betas.len(),
698 n_blocks,
699 );
700 for (b, beta) in reduced_block_betas.iter().enumerate() {
701 let expected = self.block_starts_reduced[b + 1] - self.block_starts_reduced[b];
702 assert_eq!(
703 beta.len(),
704 expected,
705 "Gauge::lift_block_betas: block {b} has β of len {}, expected reduced width {}",
706 beta.len(),
707 expected,
708 );
709 }
710 let mut theta_full = Array1::<f64>::zeros(self.reduced_total());
711 for (b, beta) in reduced_block_betas.iter().enumerate() {
712 let c0 = self.block_starts_reduced[b];
713 let c1 = self.block_starts_reduced[b + 1];
714 theta_full.slice_mut(ndarray::s![c0..c1]).assign(beta);
715 }
716 let beta_full = self.t_full.dot(&theta_full) + &self.affine_shift;
717 let mut out = Vec::with_capacity(n_blocks);
718 for b in 0..n_blocks {
719 let r0 = self.block_starts_raw[b];
720 let r1 = self.block_starts_raw[b + 1];
721 out.push(beta_full.slice(ndarray::s![r0..r1]).to_owned());
722 }
723 out
724 }
725
726 pub fn lift_covariance(&self, covariance_reduced: &Array2<f64>) -> Array2<f64> {
738 let total_reduced = self.reduced_total();
739 assert_eq!(
740 covariance_reduced.dim(),
741 (total_reduced, total_reduced),
742 "Gauge::lift_covariance: matrix has shape {:?}, expected ({total_reduced}, {total_reduced})",
743 covariance_reduced.dim(),
744 );
745 let t_m = fast_ab(&self.t_full, covariance_reduced);
746 let mut raw = fast_abt(&t_m, &self.t_full);
747 let n = raw.nrows();
748 for i in 0..n {
749 for j in (i + 1)..n {
750 let avg = 0.5 * (raw[[i, j]] + raw[[j, i]]);
751 raw[[i, j]] = avg;
752 raw[[j, i]] = avg;
753 }
754 }
755 raw
756 }
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762 use ndarray::ShapeBuilder;
763
764 #[test]
765 fn identity_gauge_round_trips_betas_and_covariance() {
766 let gauge = Gauge::identity(&[2, 3]);
767 assert!(gauge.is_identity());
768 assert_eq!(gauge.n_blocks(), 2);
769 assert_eq!(gauge.raw_total(), 5);
770 assert_eq!(gauge.reduced_total(), 5);
771 let theta = vec![
772 Array1::from(vec![0.5, -0.25]),
773 Array1::from(vec![1.0, 2.0, -3.0]),
774 ];
775 let raw = gauge.lift_block_betas(&theta);
776 assert_eq!(raw[0].as_slice().unwrap(), &[0.5, -0.25]);
777 assert_eq!(raw[1].as_slice().unwrap(), &[1.0, 2.0, -3.0]);
778
779 let mut cov = Array2::<f64>::eye(5);
780 cov[[0, 3]] = 0.4;
781 cov[[3, 0]] = 0.4;
782 let lifted = gauge.lift_covariance(&cov);
783 for i in 0..5 {
784 for j in 0..5 {
785 assert!(
786 (lifted[[i, j]] - cov[[i, j]]).abs() < 1e-14,
787 "identity gauge must be a covariance no-op at ({i},{j})",
788 );
789 }
790 }
791 }
792
793 #[test]
794 fn identity_section_short_circuits_restrict_bit_exactly() {
795 let gauge = Gauge::identity(&[4]);
798 assert!(gauge.t_full_is_identity());
799
800 let raw_design = Array2::<f64>::from_shape_fn((7, 4), |(i, j)| {
803 ((i as f64) * 0.3 - (j as f64) * 1.7).sin() * 1.000000001
804 });
805 let restricted = gauge.restrict_design(&raw_design);
806 assert_eq!(restricted, raw_design);
808 let via_gemm = fast_ab(&raw_design, &gauge.t_full);
810 assert_eq!(restricted, via_gemm);
811
812 let raw_penalty = Array2::<f64>::from_shape_fn((4, 4), |(i, j)| {
813 (i as f64 + 1.0) * (j as f64 + 2.0) * 0.111
814 });
815 let restricted_pen = gauge.restrict_penalty(&raw_penalty);
816 assert_eq!(restricted_pen, raw_penalty);
817 let pen_via_gemm = fast_ab(&fast_atb(&gauge.t_full, &raw_penalty), &gauge.t_full);
818 assert_eq!(restricted_pen, pen_via_gemm);
819 }
820
821 #[test]
827 fn owned_restrict_design_moves_through_a_trivial_section() {
828 let gauge = Gauge::identity(&[4]);
829 let raw_design = Array2::<f64>::from_shape_fn((7, 4), |(i, j)| {
830 ((i as f64) * 0.3 - (j as f64) * 1.7).sin() * 1.000000001
831 });
832
833 let borrowed = gauge.restrict_design(&raw_design);
834 let raw_ptr = raw_design.as_ptr();
835 let owned = gauge.restrict_design_owned(raw_design.clone());
836 assert_eq!(owned, borrowed, "owned and borrowed forms must agree exactly");
837 assert!(
838 owned.is_standard_layout(),
839 "consumers rely on the standard layout `to_owned` would have produced"
840 );
841
842 let moved = gauge.restrict_design_owned(raw_design);
844 assert_eq!(
845 moved.as_ptr(), raw_ptr,
846 "a trivial section must return the caller's own buffer, not a copy of it"
847 );
848
849 let mut t = Array2::<f64>::eye(4);
851 t[[0, 1]] = 0.5;
852 let real = Gauge::from_t(t.clone(), &[4], &[4]);
853 let raw = Array2::<f64>::from_shape_fn((7, 4), |(i, j)| i as f64 + j as f64 * 0.25);
854 assert_eq!(
855 real.restrict_design_owned(raw.clone()),
856 real.restrict_design(&raw),
857 "the non-identity branch must match the borrowed form too"
858 );
859 }
860
861 #[test]
864 fn owned_restrict_design_normalises_a_non_standard_layout() {
865 let gauge = Gauge::identity(&[3]);
866 let column_major = Array2::<f64>::from_shape_vec(
867 (4, 3).f(),
868 (0..12).map(|v| v as f64 * 0.5).collect(),
869 )
870 .expect("column-major fixture");
871 assert!(!column_major.is_standard_layout());
872
873 let owned = gauge.restrict_design_owned(column_major.clone());
874 assert!(
875 owned.is_standard_layout(),
876 "a non-standard input must be normalised, not passed through"
877 );
878 assert_eq!(
879 owned,
880 gauge.restrict_design(&column_major),
881 "normalisation must not change any value"
882 );
883 }
884
885 #[test]
886 fn non_identity_section_is_not_short_circuited() {
887 let mut t = Array2::<f64>::eye(3);
889 t[[0, 1]] = 0.5;
890 let gauge = Gauge::from_t(t.clone(), &[3], &[3]);
891 assert!(!gauge.t_full_is_identity());
892 let raw = Array2::<f64>::from_shape_fn((5, 3), |(i, j)| i as f64 + j as f64 * 0.25);
893 let restricted = gauge.restrict_design(&raw);
894 assert_eq!(restricted, fast_ab(&raw, &t));
895 }
896
897 #[test]
898 fn rectangular_section_is_not_identity() {
899 let z =
902 Array2::<f64>::from_shape_vec((3, 2), vec![1.0, 0.0, 0.0, 1.0, -1.0, -1.0]).unwrap();
903 let gauge = Gauge::sum_to_zero(z);
904 assert!(!gauge.t_full_is_identity());
905 }
906
907 #[test]
908 fn affine_gauge_lifts_betas_and_restricts_offsets() {
909 let t = Array2::from_shape_vec((3, 1), vec![2.0, -1.0, 0.5]).unwrap();
910 let shift = Array1::from(vec![0.25, 1.5, -0.75]);
911 let gauge = Gauge::from_block_transform_with_shift(t.clone(), shift.clone());
912 assert!(!gauge.is_identity());
913 let theta = Array1::from(vec![4.0]);
914
915 let raw = gauge.lift_block_betas(&[theta.clone()]);
916 let expected_raw = t.dot(&theta) + &shift;
917 assert_eq!(raw[0], expected_raw);
918
919 let x = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, 2.0, -1.0, 3.0, 0.5]).unwrap();
920 let offset = Array1::from(vec![0.1, -0.2]);
921 let (x_reduced, offset_reduced) = gauge.restrict_design_and_offset(&x, &offset);
922 assert_eq!(x_reduced, x.dot(&t));
923 assert_eq!(offset_reduced, &offset + &x.dot(&shift));
924
925 let eta_raw = x.dot(&expected_raw) + &offset;
926 let eta_reduced = x_reduced.dot(&theta) + &offset_reduced;
927 for i in 0..eta_raw.len() {
928 assert!((eta_raw[i] - eta_reduced[i]).abs() < 1e-14);
929 }
930
931 let cov_reduced = Array2::from_elem((1, 1), 3.0);
932 let lifted_cov = gauge.lift_covariance(&cov_reduced);
933 let expected_cov = t.dot(&cov_reduced).dot(&t.t());
934 assert_eq!(lifted_cov, expected_cov);
935 }
936
937 #[test]
949 fn affine_shift_leaves_lifted_covariance_invariant() {
950 let t =
952 Array2::from_shape_vec((4, 2), vec![1.0, 0.0, 0.5, -1.0, 2.0, 0.3, -0.4, 1.5]).unwrap();
953 let raw_widths = [4usize];
954 let reduced_widths = [2usize];
955
956 let cov_reduced = Array2::from_shape_vec((2, 2), vec![2.0, -0.7, -0.7, 1.3]).unwrap();
958
959 let base =
961 Gauge::from_t_with_shift(t.clone(), &raw_widths, &reduced_widths, Array1::zeros(4));
962 let reference = base.lift_covariance(&cov_reduced);
963
964 for &mag in &[0.0, 1e-7, 1.0, 1e3, 1e7] {
966 let shift = Array1::from(vec![mag, -mag, 0.5 * mag, -2.0 * mag]);
967 let gauge = Gauge::from_t_with_shift(t.clone(), &raw_widths, &reduced_widths, shift);
968 let lifted = gauge.lift_covariance(&cov_reduced);
969 for i in 0..4 {
970 for j in 0..4 {
971 assert_eq!(
972 lifted[[i, j]],
973 reference[[i, j]],
974 "affine shift magnitude {mag} must not perturb the lifted covariance \
975 at ({i},{j}) — covariance is offset-invariant",
976 );
977 }
978 }
979 }
980
981 let chol = {
986 let l00 = cov_reduced[[0, 0]].sqrt();
987 let l10 = cov_reduced[[1, 0]] / l00;
988 let l11 = (cov_reduced[[1, 1]] - l10 * l10).sqrt();
989 Array2::from_shape_vec((2, 2), vec![l00, 0.0, l10, l11]).unwrap()
990 };
991 let z_raw = [
992 [1.2, -0.4],
993 [-0.8, 0.9],
994 [0.3, 1.7],
995 [-1.5, -0.6],
996 [0.6, -1.1],
997 [-0.2, 0.3],
998 [1.9, 0.2],
999 [-1.4, -0.9],
1000 ];
1001 let sample_cov_for_shift = |shift: &Array1<f64>| -> Array2<f64> {
1002 let n = z_raw.len();
1003 let betas: Vec<Array1<f64>> = z_raw
1004 .iter()
1005 .map(|z| {
1006 let theta = chol.dot(&Array1::from(vec![z[0], z[1]]));
1007 t.dot(&theta) + shift
1008 })
1009 .collect();
1010 let mut mean = Array1::<f64>::zeros(4);
1011 for b in &betas {
1012 mean = &mean + b;
1013 }
1014 mean /= n as f64;
1015 let mut cov = Array2::<f64>::zeros((4, 4));
1016 for b in &betas {
1017 let c = b - &mean;
1018 for i in 0..4 {
1019 for j in 0..4 {
1020 cov[[i, j]] += c[i] * c[j] / n as f64;
1021 }
1022 }
1023 }
1024 cov
1025 };
1026 let cov_small = sample_cov_for_shift(&Array1::zeros(4));
1027 let cov_big = sample_cov_for_shift(&Array1::from(vec![1e6, -1e6, 5e5, -2e6]));
1028 for i in 0..4 {
1029 for j in 0..4 {
1030 assert!(
1031 (cov_small[[i, j]] - cov_big[[i, j]]).abs() < 1e-6,
1032 "empirical sample covariance must be offset-invariant at ({i},{j}): \
1033 small-shift {} vs big-shift {}",
1034 cov_small[[i, j]],
1035 cov_big[[i, j]],
1036 );
1037 }
1038 }
1039 }
1040
1041 #[test]
1042 fn block_diagonal_gauge_matches_per_block_lift() {
1043 let mut t0 = Array2::<f64>::zeros((3, 2));
1045 t0[[0, 0]] = 1.0;
1046 t0[[2, 1]] = 1.0;
1047 let t1 = Array2::<f64>::eye(2);
1049 let gauge = Gauge::from_block_transforms(&[t0.clone(), t1.clone()]);
1050 assert_eq!(gauge.raw_widths(), vec![3, 2]);
1051 assert_eq!(gauge.reduced_widths(), vec![2, 2]);
1052
1053 let theta = vec![Array1::from(vec![1.5, -2.5]), Array1::from(vec![0.5, 4.0])];
1054 let raw = gauge.lift_block_betas(&theta);
1055 assert_eq!(raw[0].as_slice().unwrap(), &[1.5, 0.0, -2.5]);
1056 assert_eq!(raw[1].as_slice().unwrap(), &[0.5, 4.0]);
1057
1058 assert_eq!(gauge.block_transform(0), t0);
1060 assert_eq!(gauge.block_transform(1), t1);
1061 }
1062
1063 #[test]
1064 fn triangular_gauge_applies_negative_r_off_diagonal() {
1065 let v_a = Array2::<f64>::eye(2);
1068 let mut v_b = Array2::<f64>::zeros((2, 1));
1069 v_b[[0, 0]] = 1.0;
1070 let mut r_ab = Array2::<f64>::zeros((2, 1));
1071 r_ab[[0, 0]] = 0.5;
1072 r_ab[[1, 0]] = -0.25;
1073 let gauge = Gauge::from_v_and_r(&[v_a, v_b], &[None, Some(r_ab)]);
1074
1075 let theta = vec![Array1::from(vec![1.0, 2.0]), Array1::from(vec![4.0])];
1076 let raw = gauge.lift_block_betas(&theta);
1077 assert!((raw[0][0] - (-1.0)).abs() < 1e-14);
1079 assert!((raw[0][1] - 3.0).abs() < 1e-14);
1080 assert!((raw[1][0] - 4.0).abs() < 1e-14);
1082 assert!((raw[1][1] - 0.0).abs() < 1e-14);
1083 }
1084
1085 #[test]
1089 fn covariance_lift_is_rank1_consistent_with_beta_lift() {
1090 let v_a = Array2::<f64>::eye(2);
1091 let mut v_b = Array2::<f64>::zeros((2, 1));
1092 v_b[[0, 0]] = 1.0;
1093 let mut r_ab = Array2::<f64>::zeros((2, 1));
1094 r_ab[[0, 0]] = 0.3;
1095 r_ab[[1, 0]] = 0.7;
1096 let gauge = Gauge::from_v_and_r(&[v_a, v_b], &[None, Some(r_ab)]);
1097
1098 let theta = vec![Array1::from(vec![0.8, -1.2]), Array1::from(vec![2.0])];
1099 let raw = gauge.lift_block_betas(&theta);
1100 let beta_full: Vec<f64> = raw.iter().flat_map(|b| b.iter().copied()).collect();
1101
1102 let theta_full = Array1::from(vec![0.8, -1.2, 2.0]);
1103 let cov_rank1 = {
1104 let n = theta_full.len();
1105 Array2::from_shape_fn((n, n), |(i, j)| theta_full[i] * theta_full[j])
1106 };
1107 let lifted = gauge.lift_covariance(&cov_rank1);
1108 assert_eq!(lifted.dim(), (4, 4));
1109 for i in 0..4 {
1110 for j in 0..4 {
1111 let expected = beta_full[i] * beta_full[j];
1112 assert!(
1113 (lifted[[i, j]] - expected).abs() < 1e-12,
1114 "rank-1 covariance lift must equal (Tθ)(Tθ)ᵀ at ({i},{j}): \
1115 got {} expected {expected}",
1116 lifted[[i, j]],
1117 );
1118 }
1119 }
1120 }
1121
1122 #[test]
1129 fn sum_to_zero_gauge_lifts_via_z_and_preserves_eta() {
1130 let s = 1.0 / 2.0_f64.sqrt();
1134 let s6 = 1.0 / 6.0_f64.sqrt();
1135 let mut z = Array2::<f64>::zeros((3, 2));
1136 z[[0, 0]] = s;
1137 z[[1, 0]] = -s;
1138 z[[2, 0]] = 0.0;
1139 z[[0, 1]] = s6;
1140 z[[1, 1]] = s6;
1141 z[[2, 1]] = -2.0 * s6;
1142 for j in 0..2 {
1144 assert!(
1145 (z.column(j).sum()).abs() < 1e-14,
1146 "column {j} must sum to 0"
1147 );
1148 assert!(
1149 (z.column(j).dot(&z.column(j)) - 1.0).abs() < 1e-14,
1150 "column {j} must be unit norm"
1151 );
1152 }
1153
1154 let gauge = Gauge::sum_to_zero(z.clone());
1155 assert_eq!(gauge.n_blocks(), 1);
1156 assert_eq!(gauge.raw_widths(), vec![3]);
1157 assert_eq!(gauge.reduced_widths(), vec![2]);
1158 assert_eq!(gauge.block_transform(0), z);
1159
1160 let theta = Array1::from(vec![1.3, -0.7]);
1162 let raw = gauge.lift_block_betas(&[theta.clone()]);
1163 let expected_raw = z.dot(&theta);
1164 for i in 0..3 {
1165 assert!((raw[0][i] - expected_raw[i]).abs() < 1e-14);
1166 }
1167 assert!(raw[0].sum().abs() < 1e-14, "lifted β must be centred");
1169
1170 let b = Array2::from_shape_vec(
1172 (4, 3),
1173 vec![
1174 1.0, 2.0, -1.0, 0.5, -0.5, 3.0, 2.0, 1.0, 1.0, -1.0, 0.0, 4.0,
1175 ],
1176 )
1177 .unwrap();
1178 let b_c = fast_ab(&b, &z); assert_eq!(gauge.restrict_design(&b), b_c);
1180 let eta_reduced = b_c.dot(&theta);
1181 let eta_raw = b.dot(&expected_raw);
1182 for i in 0..4 {
1183 assert!(
1184 (eta_reduced[i] - eta_raw[i]).abs() < 1e-13,
1185 "η must be invariant under the centring lift at row {i}",
1186 );
1187 }
1188
1189 let cov_rank1 = Array2::from_shape_fn((2, 2), |(i, j)| theta[i] * theta[j]);
1191 let lifted = gauge.lift_covariance(&cov_rank1);
1192 assert_eq!(lifted.dim(), (3, 3));
1193 for i in 0..3 {
1194 for j in 0..3 {
1195 let expect = expected_raw[i] * expected_raw[j];
1196 assert!(
1197 (lifted[[i, j]] - expect).abs() < 1e-13,
1198 "centring covariance lift must equal (zθ)(zθ)ᵀ at ({i},{j})",
1199 );
1200 }
1201 }
1202
1203 let raw_penalty = Array2::from_shape_vec(
1204 (3, 3),
1205 vec![2.0, 0.5, 0.0, 0.5, 3.0, -0.25, 0.0, -0.25, 4.0],
1206 )
1207 .unwrap();
1208 let reduced_penalty = gauge.restrict_penalty(&raw_penalty);
1209 let expected_reduced_penalty = fast_ab(&fast_atb(&z, &raw_penalty), &z);
1210 assert_eq!(reduced_penalty, expected_reduced_penalty);
1211 }
1212
1213 #[test]
1214 #[should_panic(expected = "removes at least one direction")]
1215 fn sum_to_zero_rejects_identity_section() {
1216 drop(Gauge::sum_to_zero(Array2::<f64>::eye(3)));
1218 }
1219
1220 #[test]
1221 fn extend_with_identity_passes_extra_blocks_through() {
1222 let mut t0 = Array2::<f64>::zeros((2, 1));
1223 t0[[0, 0]] = 1.0;
1224 let gauge = Gauge::from_block_transforms(&[t0]).extend_with_identity(&[2]);
1225 assert_eq!(gauge.n_blocks(), 2);
1226 assert_eq!(gauge.raw_total(), 4);
1227 assert_eq!(gauge.reduced_total(), 3);
1228
1229 let theta = vec![Array1::from(vec![3.0]), Array1::from(vec![1.0, -1.0])];
1230 let raw = gauge.lift_block_betas(&theta);
1231 assert_eq!(raw[0].as_slice().unwrap(), &[3.0, 0.0]);
1232 assert_eq!(raw[1].as_slice().unwrap(), &[1.0, -1.0]);
1233
1234 let mut cov = Array2::<f64>::eye(3);
1237 cov[[1, 2]] = 0.25;
1238 cov[[2, 1]] = 0.25;
1239 let lifted = gauge.lift_covariance(&cov);
1240 assert_eq!(lifted.dim(), (4, 4));
1241 assert!((lifted[[0, 0]] - 1.0).abs() < 1e-14);
1242 assert!(
1243 (lifted[[1, 1]] - 0.0).abs() < 1e-14,
1244 "dropped raw row has zero variance"
1245 );
1246 assert!((lifted[[2, 2]] - 1.0).abs() < 1e-14);
1247 assert!((lifted[[3, 3]] - 1.0).abs() < 1e-14);
1248 assert!((lifted[[2, 3]] - 0.25).abs() < 1e-14);
1249 }
1250
1251 #[test]
1252 fn left_compose_preserves_affine_maps_and_block_lineage() {
1253 let inner_t = Array2::from_shape_vec((3, 2), vec![1.0, 0.0, -0.5, 0.0, 0.0, 2.0]).unwrap();
1255 let inner_shift = Array1::from(vec![0.25, -1.0, 0.5]);
1256 let inner =
1257 Gauge::from_t_with_shift(inner_t.clone(), &[2, 1], &[1, 1], inner_shift.clone());
1258
1259 let outer_t = Array2::from_shape_vec(
1261 (4, 3),
1262 vec![1.0, 0.0, 0.5, 0.0, 2.0, 0.0, -1.0, 0.25, 0.0, 0.0, 0.0, 3.0],
1263 )
1264 .unwrap();
1265 let outer_shift = Array1::from(vec![1.0, -0.5, 0.75, 2.0]);
1266 let outer =
1267 Gauge::from_t_with_shift(outer_t.clone(), &[3, 1], &[2, 1], outer_shift.clone());
1268
1269 let composed = inner.left_compose(&outer).expect("compatible frames");
1270 assert_eq!(composed.raw_widths(), vec![3, 1]);
1271 assert_eq!(composed.reduced_widths(), vec![1, 1]);
1272 assert_eq!(composed.t_full, fast_ab(&outer_t, &inner_t));
1273 assert_eq!(
1274 composed.affine_shift,
1275 outer_t.dot(&inner_shift) + &outer_shift
1276 );
1277
1278 let active = Array1::from(vec![1.5, -0.75]);
1279 let current = inner_t.dot(&active) + &inner_shift;
1280 let expected_new = outer_t.dot(¤t) + &outer_shift;
1281 let actual_new = composed.t_full.dot(&active) + &composed.affine_shift;
1282 for index in 0..actual_new.len() {
1283 assert!((actual_new[index] - expected_new[index]).abs() < 1e-14);
1284 }
1285
1286 let encoded = serde_json::to_string(&composed).expect("serialize gauge");
1287 let decoded: Gauge = serde_json::from_str(&encoded).expect("deserialize gauge");
1288 decoded.validate().expect("round-tripped gauge");
1289 assert_eq!(decoded.t_full, composed.t_full);
1290 assert_eq!(decoded.affine_shift, composed.affine_shift);
1291 assert_eq!(decoded.block_starts_raw, composed.block_starts_raw);
1292 assert_eq!(decoded.block_starts_reduced, composed.block_starts_reduced);
1293 }
1294
1295 #[test]
1296 fn left_compose_rejects_equal_totals_with_different_block_frames() {
1297 let inner = Gauge::from_t(Array2::eye(3), &[2, 1], &[2, 1]);
1298 let outer = Gauge::from_t(Array2::eye(3), &[2, 1], &[1, 2]);
1299 let error = inner
1300 .left_compose(&outer)
1301 .expect_err("block boundaries are part of the coordinate frame");
1302 assert!(error.contains("composition frame partition mismatch"));
1303 }
1304}