1use crate::reml_contract_panic;
6use gam_linalg::dense;
7use ndarray::{Array1, Array2, ArrayView1, ArrayViewMut1};
8
9#[derive(Clone, Debug)]
19pub enum PenaltyCoordinate {
20 DenseRoot(Array2<f64>),
21 DenseRootCentered {
22 root: Array2<f64>,
23 prior_mean: Array1<f64>,
24 },
25 BlockRoot {
26 root: Array2<f64>,
27 start: usize,
28 end: usize,
29 total_dim: usize,
30 },
31 BlockRootCentered {
32 root: Array2<f64>,
33 start: usize,
34 end: usize,
35 total_dim: usize,
36 prior_mean: Array1<f64>,
37 },
38 KroneckerMarginal {
45 eigenvalues: Vec<Array1<f64>>,
47 dim_index: usize,
49 marginal_dims: Vec<usize>,
51 total_dim: usize,
53 },
54}
55
56impl PenaltyCoordinate {
57 pub fn from_dense_root(root: Array2<f64>) -> Self {
58 Self::DenseRoot(root)
59 }
60
61 pub fn from_dense_root_with_mean(root: Array2<f64>, prior_mean: Array1<f64>) -> Self {
62 assert_eq!(root.ncols(), prior_mean.len());
63 if prior_mean.iter().all(|&value| value == 0.0) {
64 Self::DenseRoot(root)
65 } else {
66 Self::DenseRootCentered { root, prior_mean }
67 }
68 }
69
70 pub fn from_block_root(root: Array2<f64>, start: usize, end: usize, total_dim: usize) -> Self {
71 assert_eq!(
72 root.ncols(),
73 end.saturating_sub(start),
74 "block prior root column count must match block width"
75 );
76 assert!(
77 end <= total_dim,
78 "block prior root end exceeds total dimension: start={start}, end={end}, total_dim={total_dim}, root_dim={:?}",
79 root.dim()
80 );
81 Self::BlockRoot {
82 root,
83 start,
84 end,
85 total_dim,
86 }
87 }
88
89 pub fn from_block_root_with_mean(
90 root: Array2<f64>,
91 start: usize,
92 end: usize,
93 total_dim: usize,
94 prior_mean: Array1<f64>,
95 ) -> Self {
96 assert_eq!(
97 root.ncols(),
98 end.saturating_sub(start),
99 "centered block prior root column count must match block width"
100 );
101 assert_eq!(
102 prior_mean.len(),
103 end.saturating_sub(start),
104 "centered block prior mean length must match block width"
105 );
106 assert!(
107 end <= total_dim,
108 "centered block prior root end exceeds total dimension: start={start}, end={end}, total_dim={total_dim}, root_dim={:?}, prior_mean_len={}",
109 root.dim(),
110 prior_mean.len()
111 );
112 if prior_mean.iter().all(|&value| value == 0.0) {
113 Self::from_block_root(root, start, end, total_dim)
114 } else {
115 Self::BlockRootCentered {
116 root,
117 start,
118 end,
119 total_dim,
120 prior_mean,
121 }
122 }
123 }
124
125 pub fn rank(&self) -> usize {
126 match self {
127 Self::DenseRoot(root)
128 | Self::DenseRootCentered { root, .. }
129 | Self::BlockRoot { root, .. }
130 | Self::BlockRootCentered { root, .. } => root.nrows(),
131 Self::KroneckerMarginal {
132 eigenvalues,
133 dim_index,
134 ..
135 } => {
136 let nz = eigenvalues[*dim_index]
139 .iter()
140 .filter(|&&v| v.abs() > 1e-12)
141 .count();
142 let other: usize = eigenvalues
143 .iter()
144 .enumerate()
145 .filter(|&(j, _)| j != *dim_index)
146 .map(|(_, e)| e.len())
147 .product::<usize>()
148 .max(1);
149 nz * other
150 }
151 }
152 }
153
154 pub fn dim(&self) -> usize {
155 match self {
156 Self::DenseRoot(root) | Self::DenseRootCentered { root, .. } => root.ncols(),
157 Self::BlockRoot { total_dim, .. }
158 | Self::BlockRootCentered { total_dim, .. }
159 | Self::KroneckerMarginal { total_dim, .. } => *total_dim,
160 }
161 }
162
163 pub fn uses_operator_fast_path(&self) -> bool {
164 matches!(
165 self,
166 Self::BlockRoot { .. }
167 | Self::BlockRootCentered { .. }
168 | Self::KroneckerMarginal { .. }
169 )
170 }
171
172 pub fn block_local_root(&self) -> Option<(&Array2<f64>, usize, usize)> {
178 match self {
179 Self::DenseRoot(root) | Self::DenseRootCentered { root, .. } => {
180 Some((root, 0, root.ncols()))
181 }
182 Self::BlockRoot {
183 root, start, end, ..
184 }
185 | Self::BlockRootCentered {
186 root, start, end, ..
187 } => Some((root, *start, *end)),
188 Self::KroneckerMarginal { .. } => None,
189 }
190 }
191
192 pub fn project_into_subspace(&self, z: &Array2<f64>) -> Self {
209 assert_eq!(
210 z.nrows(),
211 self.dim(),
212 "PenaltyCoordinate::project_into_subspace: free-basis row count {} does not match coordinate dimension {}",
213 z.nrows(),
214 self.dim()
215 );
216 match self {
217 Self::DenseRoot(root) => Self::DenseRoot(root.dot(z)),
218 Self::DenseRootCentered { root, prior_mean } => {
219 Self::from_dense_root_with_mean(root.dot(z), z.t().dot(prior_mean))
220 }
221 Self::BlockRoot {
222 root, start, end, ..
223 } => {
224 let z_block = z.slice(ndarray::s![*start..*end, ..]);
225 Self::DenseRoot(root.dot(&z_block))
226 }
227 Self::BlockRootCentered {
228 root,
229 start,
230 end,
231 prior_mean,
232 ..
233 } => {
234 let z_block = z.slice(ndarray::s![*start..*end, ..]);
235 let z_block_owned = z_block.to_owned();
239 Self::from_dense_root_with_mean(
240 root.dot(&z_block_owned),
241 z_block_owned.t().dot(prior_mean),
242 )
243 }
244 Self::KroneckerMarginal { .. } => reml_contract_panic(
245 "PenaltyCoordinate::project_into_subspace: Kronecker-factored \
246 coordinates do not co-occur with linear-inequality active sets \
247 (box/monotone constraints lower to dense/block roots)",
248 ),
249 }
250 }
251
252 pub(crate) fn apply_root(&self, beta: &Array1<f64>) -> Array1<f64> {
253 assert_eq!(beta.len(), self.dim());
254 match self {
255 Self::DenseRoot(root) | Self::DenseRootCentered { root, .. } => root.dot(beta),
256 Self::BlockRoot {
257 root, start, end, ..
258 }
259 | Self::BlockRootCentered {
260 root, start, end, ..
261 } => root.dot(&beta.slice(ndarray::s![*start..*end])),
262 Self::KroneckerMarginal { .. } => {
263 reml_contract_panic(
271 "apply_root not supported for KroneckerMarginal; use apply_penalty directly",
272 );
273 }
274 }
275 }
276
277 pub fn apply_penalty(&self, beta: &Array1<f64>, scale: f64) -> Array1<f64> {
278 assert_eq!(beta.len(), self.dim());
279 let mut out = Array1::<f64>::zeros(self.dim());
280 self.apply_penalty_view_into(beta.view(), scale, out.view_mut());
281 out
282 }
283
284 pub fn apply_penalty_view_into(
285 &self,
286 beta: ArrayView1<'_, f64>,
287 scale: f64,
288 mut out: ArrayViewMut1<'_, f64>,
289 ) {
290 assert_eq!(beta.len(), self.dim());
291 assert_eq!(out.len(), self.dim());
292 out.fill(0.0);
293 self.scaled_add_penalty_view(beta, scale, out);
294 }
295
296 pub fn scaled_add_penalty_view(
297 &self,
298 beta: ArrayView1<'_, f64>,
299 scale: f64,
300 mut out: ArrayViewMut1<'_, f64>,
301 ) {
302 assert_eq!(beta.len(), self.dim());
303 assert_eq!(out.len(), self.dim());
304 if scale == 0.0 {
305 return;
306 }
307 match self {
308 Self::DenseRoot(_)
309 | Self::DenseRootCentered { .. }
310 | Self::BlockRoot { .. }
311 | Self::BlockRootCentered { .. } => match self {
312 Self::DenseRoot(root) | Self::DenseRootCentered { root, .. } => {
313 let mut root_beta = Array1::<f64>::zeros(root.nrows());
314 dense::matvec_into(root, beta, root_beta.view_mut());
315 dense::transpose_matvec_scaled_add_into(
316 root,
317 root_beta.view(),
318 scale,
319 out.view_mut(),
320 );
321 }
322 Self::BlockRoot {
323 root,
324 start,
325 end,
326 total_dim: _,
327 }
328 | Self::BlockRootCentered {
329 root,
330 start,
331 end,
332 total_dim: _,
333 ..
334 } => {
335 let beta_block = beta.slice(ndarray::s![*start..*end]);
336 let mut root_beta = Array1::<f64>::zeros(root.nrows());
337 dense::matvec_into(root, beta_block, root_beta.view_mut());
338 let out_block = out.slice_mut(ndarray::s![*start..*end]);
339 dense::transpose_matvec_scaled_add_into(
340 root,
341 root_beta.view(),
342 scale,
343 out_block,
344 );
345 }
346 Self::KroneckerMarginal { .. } => {}
348 },
349 Self::KroneckerMarginal {
350 eigenvalues,
351 dim_index,
352 marginal_dims,
353 total_dim,
354 } => {
355 let k = *dim_index;
358 let q_k = marginal_dims[k];
359 let stride_k: usize = marginal_dims[k + 1..]
360 .iter()
361 .copied()
362 .product::<usize>()
363 .max(1);
364 let outer_size: usize =
365 marginal_dims[..k].iter().copied().product::<usize>().max(1);
366 let inner_size = stride_k;
367 let eigs = &eigenvalues[k];
368 assert_eq!(
369 outer_size * q_k * stride_k,
370 *total_dim,
371 "KroneckerMarginal dimension mismatch in apply"
372 );
373
374 for outer in 0..outer_size {
375 for j in 0..q_k {
376 let mu = eigs[j] * scale;
377 if mu == 0.0 {
378 continue;
379 }
380 let base = outer * q_k * stride_k + j * stride_k;
381 for inner in 0..inner_size {
382 let idx = base + inner;
383 out[idx] += mu * beta[idx];
384 }
385 }
386 }
387 }
388 }
389 }
390
391 pub fn quadratic(&self, beta: &Array1<f64>, scale: f64) -> f64 {
392 match self {
393 Self::DenseRoot(_)
394 | Self::DenseRootCentered { .. }
395 | Self::BlockRoot { .. }
396 | Self::BlockRootCentered { .. } => {
397 let root_beta = self.apply_root(beta);
398 scale * root_beta.dot(&root_beta)
399 }
400 Self::KroneckerMarginal {
401 eigenvalues,
402 dim_index,
403 marginal_dims,
404 ..
405 } => {
406 let k = *dim_index;
408 let q_k = marginal_dims[k];
409 let stride_k: usize = marginal_dims[k + 1..]
410 .iter()
411 .copied()
412 .product::<usize>()
413 .max(1);
414 let outer_size: usize =
415 marginal_dims[..k].iter().copied().product::<usize>().max(1);
416 let inner_size = stride_k;
417 let eigs = &eigenvalues[k];
418
419 let mut sum = 0.0;
420 for outer in 0..outer_size {
421 for j in 0..q_k {
422 let mu = eigs[j];
423 if mu == 0.0 {
424 continue;
425 }
426 let base = outer * q_k * stride_k + j * stride_k;
427 for inner in 0..inner_size {
428 let v = beta[base + inner];
429 sum += mu * v * v;
430 }
431 }
432 }
433 sum * scale
434 }
435 }
436 }
437
438 pub fn apply_shifted_penalty(&self, beta: &Array1<f64>, scale: f64) -> Array1<f64> {
439 match self {
440 Self::DenseRootCentered { root, prior_mean } => {
441 let centered = beta - prior_mean;
442 let root_beta = root.dot(¢ered);
443 let mut out = root.t().dot(&root_beta);
444 out *= scale;
445 out
446 }
447 Self::BlockRootCentered {
448 root,
449 start,
450 end,
451 total_dim,
452 prior_mean,
453 } => {
454 let mut out = Array1::<f64>::zeros(*total_dim);
455 let beta_block = beta.slice(ndarray::s![*start..*end]);
456 let centered = beta_block.to_owned() - prior_mean;
457 let root_beta = root.dot(¢ered);
458 let mut block = root.t().dot(&root_beta);
459 block *= scale;
460 out.slice_mut(ndarray::s![*start..*end]).assign(&block);
461 out
462 }
463 _ => self.apply_penalty(beta, scale),
464 }
465 }
466
467 pub fn shifted_quadratic(&self, beta: &Array1<f64>, scale: f64) -> f64 {
468 match self {
469 Self::DenseRootCentered { root, prior_mean } => {
470 let centered = beta - prior_mean;
471 let root_beta = root.dot(¢ered);
472 scale * root_beta.dot(&root_beta)
473 }
474 Self::BlockRootCentered {
475 root,
476 start,
477 end,
478 prior_mean,
479 ..
480 } => {
481 let beta_block = beta.slice(ndarray::s![*start..*end]);
482 let centered = beta_block.to_owned() - prior_mean;
483 let root_beta = root.dot(¢ered);
484 scale * root_beta.dot(&root_beta)
485 }
486 _ => self.quadratic(beta, scale),
487 }
488 }
489
490 pub fn scaled_dense_matrix(&self, scale: f64) -> Array2<f64> {
491 match self {
492 Self::DenseRoot(root) | Self::DenseRootCentered { root, .. } => {
493 let mut out = root.t().dot(root);
494 out *= scale;
495 out
496 }
497 Self::BlockRoot {
498 root,
499 start,
500 end,
501 total_dim,
502 }
503 | Self::BlockRootCentered {
504 root,
505 start,
506 end,
507 total_dim,
508 ..
509 } => {
510 let mut out = Array2::<f64>::zeros((*total_dim, *total_dim));
511 let mut block = root.t().dot(root);
512 block *= scale;
513 out.slice_mut(ndarray::s![*start..*end, *start..*end])
514 .assign(&block);
515 out
516 }
517 Self::KroneckerMarginal {
518 eigenvalues,
519 dim_index,
520 marginal_dims,
521 total_dim,
522 } => {
523 let k = *dim_index;
525 let q_k = marginal_dims[k];
526 let stride_k: usize = marginal_dims[k + 1..]
527 .iter()
528 .copied()
529 .product::<usize>()
530 .max(1);
531 let outer_size: usize =
532 marginal_dims[..k].iter().copied().product::<usize>().max(1);
533 let eigs = &eigenvalues[k];
534 assert_eq!(
535 outer_size * q_k * stride_k,
536 *total_dim,
537 "KroneckerMarginal dimension mismatch in to_dense"
538 );
539
540 let mut out = Array2::<f64>::zeros((*total_dim, *total_dim));
541 for outer in 0..outer_size {
542 for j in 0..q_k {
543 let mu = eigs[j] * scale;
544 let base = outer * q_k * stride_k + j * stride_k;
545 for inner in 0..stride_k {
546 let idx = base + inner;
547 out[[idx, idx]] = mu;
548 }
549 }
550 }
551 out
552 }
553 }
554 }
555
556 pub fn scaled_block_local(&self, scale: f64) -> (Array2<f64>, usize, usize) {
560 match self {
561 Self::DenseRoot(root) | Self::DenseRootCentered { root, .. } => {
562 let mut out = root.t().dot(root);
563 out *= scale;
564 let p = out.nrows();
565 (out, 0, p)
566 }
567 Self::BlockRoot {
568 root, start, end, ..
569 }
570 | Self::BlockRootCentered {
571 root, start, end, ..
572 } => {
573 let mut block = root.t().dot(root);
574 block *= scale;
575 (block, *start, *end)
576 }
577 Self::KroneckerMarginal { total_dim, .. } => {
578 let mat = self.scaled_dense_matrix(scale);
580 (mat, 0, *total_dim)
581 }
582 }
583 }
584
585 pub fn is_block_local(&self) -> bool {
587 matches!(
588 self,
589 Self::BlockRoot { .. }
590 | Self::BlockRootCentered { .. }
591 | Self::KroneckerMarginal { .. }
592 )
593 }
594
595 pub fn scaled_matvec(&self, v: &Array1<f64>, scale: f64) -> Array1<f64> {
598 match self {
599 Self::DenseRoot(root) | Self::DenseRootCentered { root, .. } => {
600 let root_v = root.dot(v);
601 let mut out = root.t().dot(&root_v);
602 out *= scale;
603 out
604 }
605 Self::BlockRoot {
606 root, start, end, ..
607 }
608 | Self::BlockRootCentered {
609 root, start, end, ..
610 } => {
611 let mut out = Array1::zeros(v.len());
612 let v_block = v.slice(ndarray::s![*start..*end]);
613 let root_v = root.dot(&v_block);
614 let mut block_result = root.t().dot(&root_v);
615 block_result *= scale;
616 out.slice_mut(ndarray::s![*start..*end])
617 .assign(&block_result);
618 out
619 }
620 Self::KroneckerMarginal { .. } => {
621 self.apply_penalty(v, scale)
623 }
624 }
625 }
626
627 pub fn canonical_structural_key(&self) -> u64 {
649 use std::hash::{Hash, Hasher};
650 let mut hasher = std::collections::hash_map::DefaultHasher::new();
651
652 let quant = |v: f64| -> i64 {
656 if !v.is_finite() || v.abs() <= 1e-300 {
657 return 0;
658 }
659 let q = (v.abs().ln() * 1.0e6).round() as i64;
661 if v < 0.0 { -q } else { q }
662 };
663
664 match self {
665 Self::DenseRoot(root)
666 | Self::DenseRootCentered { root, .. }
667 | Self::BlockRoot { root, .. }
668 | Self::BlockRootCentered { root, .. } => {
669 0u8.hash(&mut hasher);
674 root.nrows().hash(&mut hasher); root.ncols().hash(&mut hasher); let sk = root.t().dot(root);
677 let n = sk.nrows().min(sk.ncols());
686 let trace1 = (0..n).map(|i| sk[[i, i]]).sum::<f64>();
687 let frob_sq = sk.iter().map(|&x| x * x).sum::<f64>(); let sk2 = sk.dot(&sk);
689 let trace3 = {
690 let sk3diag = sk2.dot(&sk);
691 (0..n).map(|i| sk3diag[[i, i]]).sum::<f64>()
692 };
693 let mut invariants = [quant(trace1), quant(frob_sq), quant(trace3)];
694 invariants.sort_unstable();
697 invariants.hash(&mut hasher);
698 }
699 Self::KroneckerMarginal {
700 eigenvalues,
701 dim_index,
702 marginal_dims,
703 ..
704 } => {
705 1u8.hash(&mut hasher);
710 let mut margin_spectrum: Vec<i64> =
711 eigenvalues[*dim_index].iter().map(|&e| quant(e)).collect();
712 margin_spectrum.sort_unstable();
713 margin_spectrum.hash(&mut hasher);
714 let mut dims_sorted = marginal_dims.clone();
715 dims_sorted.sort_unstable();
716 dims_sorted.hash(&mut hasher);
717 }
718 }
719
720 hasher.finish()
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use ndarray::{Array1, Array2, array};
728
729 fn identity_root(n: usize) -> Array2<f64> {
730 Array2::<f64>::eye(n)
731 }
732
733 #[test]
736 fn from_dense_root_creates_dense_root_variant() {
737 let root = identity_root(3);
738 let pc = PenaltyCoordinate::from_dense_root(root);
739 assert!(matches!(pc, PenaltyCoordinate::DenseRoot(_)));
740 }
741
742 #[test]
743 fn from_dense_root_with_zero_mean_degrades_to_dense_root() {
744 let root = identity_root(2);
745 let mean = Array1::<f64>::zeros(2);
746 let pc = PenaltyCoordinate::from_dense_root_with_mean(root, mean);
747 assert!(matches!(pc, PenaltyCoordinate::DenseRoot(_)));
748 }
749
750 #[test]
751 fn from_dense_root_with_nonzero_mean_creates_centered_variant() {
752 let root = identity_root(2);
753 let mean = array![1.0_f64, 0.0];
754 let pc = PenaltyCoordinate::from_dense_root_with_mean(root, mean);
755 assert!(matches!(pc, PenaltyCoordinate::DenseRootCentered { .. }));
756 }
757
758 #[test]
759 fn from_block_root_creates_block_root_variant() {
760 let root = Array2::<f64>::zeros((2, 2));
761 let pc = PenaltyCoordinate::from_block_root(root, 0, 2, 5);
762 assert!(matches!(pc, PenaltyCoordinate::BlockRoot { .. }));
763 }
764
765 #[test]
768 fn dense_root_rank_is_nrows_dim_is_ncols() {
769 let root = Array2::<f64>::zeros((4, 3));
771 let pc = PenaltyCoordinate::from_dense_root(root);
772 assert_eq!(pc.rank(), 4);
773 assert_eq!(pc.dim(), 3);
774 }
775
776 #[test]
777 fn block_root_dim_is_total_dim() {
778 let root = Array2::<f64>::zeros((2, 2));
779 let pc = PenaltyCoordinate::from_block_root(root, 1, 3, 7);
780 assert_eq!(pc.dim(), 7);
781 }
782
783 #[test]
786 fn dense_root_does_not_use_fast_path() {
787 let pc = PenaltyCoordinate::from_dense_root(identity_root(2));
788 assert!(!pc.uses_operator_fast_path());
789 }
790
791 #[test]
792 fn block_root_uses_fast_path() {
793 let root = Array2::<f64>::zeros((1, 2));
794 let pc = PenaltyCoordinate::from_block_root(root, 0, 2, 4);
795 assert!(pc.uses_operator_fast_path());
796 }
797
798 #[test]
801 fn dense_identity_root_penalty_is_beta() {
802 let pc = PenaltyCoordinate::from_dense_root(identity_root(3));
804 let beta = array![1.0_f64, 2.0, 3.0];
805 let out = pc.apply_penalty(&beta, 1.0);
806 for i in 0..3 {
807 assert!((out[i] - beta[i]).abs() < 1e-12, "index {i}: {}", out[i]);
808 }
809 }
810
811 #[test]
812 fn apply_penalty_zero_scale_returns_zeros() {
813 let pc = PenaltyCoordinate::from_dense_root(identity_root(2));
814 let beta = array![5.0_f64, 7.0];
815 let out = pc.apply_penalty(&beta, 0.0);
816 assert_eq!(out[0], 0.0);
817 assert_eq!(out[1], 0.0);
818 }
819
820 #[test]
821 fn apply_penalty_scale_two_doubles_beta_for_identity_root() {
822 let pc = PenaltyCoordinate::from_dense_root(identity_root(2));
823 let beta = array![3.0_f64, 4.0];
824 let out = pc.apply_penalty(&beta, 2.0);
825 assert!((out[0] - 6.0).abs() < 1e-12);
826 assert!((out[1] - 8.0).abs() < 1e-12);
827 }
828}