1use super::*;
2use ndarray::s;
3
4#[derive(Debug, Clone)]
38pub struct BlockOrthogonalityPenalty {
39 pub target: PsiSlice,
40 pub groups: Vec<Vec<usize>>,
41 pub weight: f64,
44 pub n_eff: usize,
46 pub learnable_weight: bool,
47 pub rho_index: usize,
48 pub weight_schedule: Option<ScalarWeightSchedule>,
49}
50
51impl BlockOrthogonalityPenalty {
52 #[must_use = "build error must be handled"]
53 pub fn new(
54 target: PsiSlice,
55 groups: Vec<Vec<usize>>,
56 weight: f64,
57 n_eff: usize,
58 learnable_weight: bool,
59 ) -> Result<Self, String> {
60 if target.is_empty() {
61 return Err("BlockOrthogonalityPenalty::new requires a non-empty target".to_string());
62 }
63 if !(weight.is_finite() && weight > 0.0) {
64 return Err(format!(
65 "BlockOrthogonalityPenalty::new requires finite weight > 0, got {weight}"
66 ));
67 }
68 if n_eff == 0 {
69 return Err("BlockOrthogonalityPenalty::new requires n_eff > 0".to_string());
70 }
71 if !target.len().is_multiple_of(n_eff) {
72 return Err(format!(
73 "BlockOrthogonalityPenalty::new target length {} is not divisible by n_eff {}",
74 target.len(),
75 n_eff
76 ));
77 }
78 let latent_dim = target.len() / n_eff;
79 if let Some(expected_dim) = target.latent_dim {
80 let expected = n_eff.checked_mul(expected_dim).ok_or_else(|| {
81 "BlockOrthogonalityPenalty::new target shape overflows usize".to_string()
82 })?;
83 if expected != target.len() {
84 return Err(format!(
85 "BlockOrthogonalityPenalty::new target length {} does not match n_eff {} × latent_dim {}",
86 target.len(),
87 n_eff,
88 expected_dim
89 ));
90 }
91 }
92 if groups.len() < 2 {
93 return Err("BlockOrthogonalityPenalty::new requires at least two groups".to_string());
94 }
95 let mut seen = vec![false; latent_dim];
96 for (group_idx, group) in groups.iter().enumerate() {
97 if group.is_empty() {
98 return Err(format!(
99 "BlockOrthogonalityPenalty::new groups[{group_idx}] must not be empty"
100 ));
101 }
102 for &axis in group {
103 if axis >= latent_dim {
104 return Err(format!(
105 "BlockOrthogonalityPenalty::new groups[{group_idx}] axis {axis} exceeds latent_dim {latent_dim}"
106 ));
107 }
108 if seen[axis] {
109 return Err(format!(
110 "BlockOrthogonalityPenalty::new axis {axis} appears in more than one group"
111 ));
112 }
113 seen[axis] = true;
114 }
115 }
116 for (axis, present) in seen.iter().copied().enumerate() {
117 if !present {
118 return Err(format!(
119 "BlockOrthogonalityPenalty::new groups must partition latent axes; missing axis {axis}"
120 ));
121 }
122 }
123 Ok(Self {
124 target,
125 groups,
126 weight,
127 n_eff,
128 learnable_weight,
129 rho_index: 0,
130 weight_schedule: None,
131 })
132 }
133
134 impl_with_weight_schedule!(weight);
135
136 fn resolved_weight(&self, rho: ArrayView1<'_, f64>) -> f64 {
137 if self.learnable_weight {
138 validated_learnable_weight(self.weight, rho[self.rho_index])
139 } else {
140 self.weight
141 }
142 }
143
144 fn latent_dim(&self, target_len: usize) -> Option<usize> {
145 if self.n_eff == 0 || !target_len.is_multiple_of(self.n_eff) {
146 assert_eq!(
147 target_len % self.n_eff.max(1),
148 0,
149 "target length must be divisible by n_eff"
150 );
151 return None;
152 }
153 Some(target_len / self.n_eff)
154 }
155
156 fn target_matrix<'a>(&self, target: ArrayView1<'a, f64>) -> Option<ArrayView2<'a, f64>> {
157 let d = self.latent_dim(target.len())?;
158 target.into_shape_with_order((self.n_eff, d)).ok()
159 }
160
161 fn flatten_matrix(m: &Array2<f64>) -> Array1<f64> {
162 let n_obs = m.nrows();
163 let d = m.ncols();
164 let mut out = Array1::<f64>::zeros(n_obs * d);
165 for n in 0..n_obs {
166 for a in 0..d {
167 out[n * d + a] = m[[n, a]];
168 }
169 }
170 out
171 }
172
173 fn cross_gram(t: ArrayView2<'_, f64>, left: &[usize], right: &[usize]) -> Array2<f64> {
174 let mut out = Array2::<f64>::zeros((left.len(), right.len()));
175 for (li, &a) in left.iter().enumerate() {
176 for (ri, &b) in right.iter().enumerate() {
177 let mut s = 0.0;
178 for n in 0..t.nrows() {
179 s += t[[n, a]] * t[[n, b]];
180 }
181 out[[li, ri]] = s;
182 }
183 }
184 out
185 }
186
187 fn mixed_cross_gram(
194 a: ArrayView2<'_, f64>,
195 b: ArrayView2<'_, f64>,
196 left: &[usize],
197 right: &[usize],
198 ) -> Array2<f64> {
199 assert_eq!(a.nrows(), b.nrows(), "mixed_cross_gram row mismatch");
200 let mut out = Array2::<f64>::zeros((left.len(), right.len()));
201 for (li, &al) in left.iter().enumerate() {
202 for (ri, &br) in right.iter().enumerate() {
203 let mut s = 0.0;
204 for n in 0..a.nrows() {
205 s += a[[n, al]] * b[[n, br]];
206 }
207 out[[li, ri]] = s;
208 }
209 }
210 out
211 }
212
213 fn add_right_times_cross(
214 out: &mut Array2<f64>,
215 right: ArrayView2<'_, f64>,
216 left_axes: &[usize],
217 right_axes: &[usize],
218 cross_right_left: ArrayView2<'_, f64>,
219 factor: f64,
220 ) {
221 assert_eq!(cross_right_left.dim(), (right_axes.len(), left_axes.len()));
222 for n in 0..out.nrows() {
223 for (li, &left_axis) in left_axes.iter().enumerate() {
224 let mut s = 0.0;
225 for (ri, &right_axis) in right_axes.iter().enumerate() {
226 s += right[[n, right_axis]] * cross_right_left[[ri, li]];
227 }
228 out[[n, left_axis]] += factor * s;
229 }
230 }
231 }
232
233 fn hvp_with_precomputed_cross(
234 &self,
235 t: ArrayView2<'_, f64>,
236 cross: &[Vec<Option<Array2<f64>>>],
237 v: ArrayView2<'_, f64>,
238 weight: f64,
239 ) -> Array2<f64> {
240 assert_eq!(v.dim(), t.dim(), "hvp matrix dimension mismatch");
241 if v.dim() != t.dim() {
242 return Array2::<f64>::zeros(t.dim());
243 }
244 let mut out = Array2::<f64>::zeros(t.dim());
245 for g in 0..self.groups.len() {
246 let group_g = &self.groups[g];
247 for h in 0..self.groups.len() {
248 if g == h {
249 continue;
250 }
251 let group_h = &self.groups[h];
252 let c_hg = cross[h][g]
253 .as_ref()
254 .expect("between-block cross Gram must be precomputed");
255 Self::add_right_times_cross(&mut out, v, group_g, group_h, c_hg.view(), weight);
258
259 let dv_h_g = Self::mixed_cross_gram(v, t, group_h, group_g);
272 let tv_h_g = Self::mixed_cross_gram(t, v, group_h, group_g);
273 let mut d_c_hg = dv_h_g;
274 d_c_hg += &tv_h_g;
275 Self::add_right_times_cross(&mut out, t, group_g, group_h, d_c_hg.view(), weight);
276 }
277 }
278 out
279 }
280
281 fn precompute_cross(&self, t: ArrayView2<'_, f64>) -> Vec<Vec<Option<Array2<f64>>>> {
282 let mut cross = vec![vec![None; self.groups.len()]; self.groups.len()];
283 for g in 0..self.groups.len() {
284 for h in 0..self.groups.len() {
285 if g != h {
286 cross[g][h] = Some(Self::cross_gram(t, &self.groups[g], &self.groups[h]));
287 }
288 }
289 }
290 cross
291 }
292
293 pub fn as_dense(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array2<f64> {
296 let n = target.len();
297 let Some(t) = self.target_matrix(target) else {
298 return Array2::<f64>::zeros((n, n));
299 };
300 let cross = self.precompute_cross(t.view());
301 let weight = self.resolved_weight(rho);
302 let mut dense = Array2::<f64>::zeros((n, n));
303 let mut e = Array1::<f64>::zeros(n);
304 for j in 0..n {
305 e[j] = 1.0;
306 let Some(e_mat) = self.target_matrix(e.view()) else {
307 return Array2::<f64>::zeros((n, n));
308 };
309 let col = self.hvp_with_precomputed_cross(t.view(), &cross, e_mat, weight);
310 for i in 0..n {
311 dense[[i, j]] = col[[i / t.ncols(), i % t.ncols()]];
312 }
313 e[j] = 0.0;
314 }
315 dense
316 }
317}
318
319impl AnalyticPenalty for BlockOrthogonalityPenalty {
320 fn tier(&self) -> PenaltyTier {
321 PenaltyTier::Psi
322 }
323
324 fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
325 let Some(t) = self.target_matrix(target) else {
326 return 0.0;
327 };
328 let mut acc = 0.0;
329 for g in 0..self.groups.len() {
330 for h in (g + 1)..self.groups.len() {
331 let c = Self::cross_gram(t.view(), &self.groups[g], &self.groups[h]);
332 for &v in c.iter() {
333 acc += v * v;
334 }
335 }
336 }
337 0.5 * self.resolved_weight(rho) * acc
342 }
343
344 fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
345 let Some(t) = self.target_matrix(target) else {
346 return Array1::<f64>::zeros(target.len());
347 };
348 let cross = self.precompute_cross(t.view());
349 let weight = self.resolved_weight(rho);
350 let mut grad = Array2::<f64>::zeros(t.dim());
351 for g in 0..self.groups.len() {
355 for h in 0..self.groups.len() {
356 if g == h {
357 continue;
358 }
359 let c_hg = cross[h][g]
360 .as_ref()
361 .expect("between-block cross Gram must be precomputed");
362 Self::add_right_times_cross(
363 &mut grad,
364 t.view(),
365 &self.groups[g],
366 &self.groups[h],
367 c_hg.view(),
368 weight,
369 );
370 }
371 }
372 Self::flatten_matrix(&grad)
373 }
374
375 fn hvp(
376 &self,
377 target: ArrayView1<'_, f64>,
378 rho: ArrayView1<'_, f64>,
379 v: ArrayView1<'_, f64>,
380 ) -> Array1<f64> {
381 assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
382 if target.len() != v.len() {
383 return Array1::<f64>::zeros(target.len());
384 }
385 let Some(t) = self.target_matrix(target) else {
386 return Array1::<f64>::zeros(target.len());
387 };
388 let Some(v_mat) = self.target_matrix(v) else {
389 return Array1::<f64>::zeros(target.len());
390 };
391 let cross = self.precompute_cross(t.view());
392 let hv = self.hvp_with_precomputed_cross(
393 t.view(),
394 &cross,
395 v_mat.view(),
396 self.resolved_weight(rho),
397 );
398 Self::flatten_matrix(&hv)
399 }
400
401 fn hessian_diag(
402 &self,
403 target: ArrayView1<'_, f64>,
404 rho: ArrayView1<'_, f64>,
405 ) -> Option<Array1<f64>> {
406 let t = self.target_matrix(target)?;
407 let n_obs = t.nrows();
408 let d = t.ncols();
409 let weight = self.resolved_weight(rho);
410 let mut group_of = vec![usize::MAX; d];
411 for (gi, group) in self.groups.iter().enumerate() {
412 for &axis in group {
413 group_of[axis] = gi;
414 }
415 }
416 let mut out = Array1::<f64>::zeros(n_obs * d);
417 for n in 0..n_obs {
418 let mut row_sq = 0.0_f64;
419 let mut group_sq = vec![0.0_f64; self.groups.len()];
420 for b in 0..d {
421 let v = t[[n, b]];
422 let v2 = v * v;
423 row_sq += v2;
424 group_sq[group_of[b]] += v2;
425 }
426 for a in 0..d {
427 let g = group_of[a];
428 out[n * d + a] = weight * (row_sq - group_sq[g]);
429 }
430 }
431 Some(out)
432 }
433
434 impl_learnable_weight_grad_rho!();
435
436 impl_learnable_weight_rho_count!();
437 impl_learnable_weight_domain!(weight);
438
439 fn name(&self) -> &str {
440 "block_orthogonality"
441 }
442
443 impl_scalar_apply_schedule!(weight);
444}
445
446#[derive(Debug, Clone)]
491pub struct DecoderIncoherencePenalty {
492 pub target: PsiSlice,
493 pub block_sizes: Vec<usize>,
496 pub p_out: usize,
499 pub k_atoms: usize,
506 pub pairs: Vec<(usize, usize, f64)>,
511 pub weight: f64,
514 pub learnable_weight: bool,
515 pub rho_index: usize,
516 pub weight_schedule: Option<ScalarWeightSchedule>,
517}
518
519struct PreparedCoherencePair {
520 left: std::ops::Range<usize>,
521 right: std::ops::Range<usize>,
522 coefficient: f64,
523 geometry: normalized_gram::NormalizedCrossGram,
524}
525
526pub struct PreparedDecoderIncoherence {
530 dimension: usize,
531 pairs: Vec<PreparedCoherencePair>,
532}
533
534impl PreparedDecoderIncoherence {
535 fn pair_direction(pair: &PreparedCoherencePair, direction: &[f64]) -> Array1<f64> {
536 Array1::from_iter(
537 direction[pair.left.clone()]
538 .iter()
539 .chain(direction[pair.right.clone()].iter())
540 .copied(),
541 )
542 }
543
544 fn scatter(pair: &PreparedCoherencePair, local: ArrayView1<'_, f64>, out: &mut [f64]) {
545 for (i, destination) in pair.left.clone().chain(pair.right.clone()).enumerate() {
546 out[destination] += pair.coefficient * local[i];
547 }
548 }
549
550 pub fn remainder_action_add(&self, direction: &[f64], out: &mut [f64]) {
551 assert_eq!(direction.len(), self.dimension);
552 assert_eq!(out.len(), self.dimension);
553 for pair in &self.pairs {
554 if pair
557 .left
558 .clone()
559 .chain(pair.right.clone())
560 .all(|i| direction[i] == 0.0)
561 {
562 continue;
563 }
564 let local = Self::pair_direction(pair, direction);
565 let delta = pair.geometry.hessian_action(local.view())
566 - pair.geometry.gauss_newton_action(local.view());
567 Self::scatter(pair, delta.view(), out);
568 }
569 }
570
571 pub fn remainder_diagonal_add(&self, out: &mut [f64]) {
572 assert_eq!(out.len(), self.dimension);
573 for pair in &self.pairs {
574 let delta = pair.geometry.diagonal() - pair.geometry.gauss_newton_diagonal();
575 Self::scatter(pair, delta.view(), out);
576 }
577 }
578
579 pub fn theta_bilinear_add(&self, exact: bool, left: &[f64], right: &[f64], out: &mut [f64]) {
580 assert_eq!(left.len(), self.dimension);
581 assert_eq!(right.len(), self.dimension);
582 assert_eq!(out.len(), self.dimension);
583 for pair in &self.pairs {
584 let l = Self::pair_direction(pair, left);
585 let r = Self::pair_direction(pair, right);
586 let local = if exact {
587 pair.geometry.third_bilinear(l.view(), r.view())
588 } else {
589 pair.geometry
590 .gauss_newton_bilinear_gradient(l.view(), r.view())
591 };
592 Self::scatter(pair, local.view(), out);
593 }
594 }
595}
596
597impl DecoderIncoherencePenalty {
598 pub fn prepare_curvature(
599 &self,
600 target: ArrayView1<'_, f64>,
601 rho: ArrayView1<'_, f64>,
602 ) -> PreparedDecoderIncoherence {
603 assert_eq!(target.len(), self.target.len());
604 let offsets = self.block_offsets();
605 let weight = self.resolved_weight(rho);
606 let pairs = self
607 .pairs
608 .iter()
609 .filter_map(|&(j, k, pair_weight)| {
610 if pair_weight == 0.0 || weight == 0.0 {
611 return None;
612 }
613 let left = offsets[j]..offsets[j] + self.block_sizes[j] * self.p_out;
614 let right = offsets[k]..offsets[k] + self.block_sizes[k] * self.p_out;
615 let left_matrix = target
616 .slice(s![left.clone()])
617 .into_shape_with_order((self.block_sizes[j], self.p_out))
618 .expect("validated left block span matches its decoder shape");
619 let right_matrix = target
620 .slice(s![right.clone()])
621 .into_shape_with_order((self.block_sizes[k], self.p_out))
622 .expect("validated right block span matches its decoder shape");
623 let geometry = normalized_gram::NormalizedCrossGram::new(
624 left_matrix,
625 right_matrix,
626 normalized_gram::GramNormalization::DecoderNorm,
627 )?;
628 Some(PreparedCoherencePair {
629 left,
630 right,
631 coefficient: 0.5 * weight * pair_weight,
632 geometry,
633 })
634 })
635 .collect();
636 PreparedDecoderIncoherence {
637 dimension: target.len(),
638 pairs,
639 }
640 }
641
642 #[must_use = "build error must be handled"]
643 pub fn new(
644 target: PsiSlice,
645 block_sizes: Vec<usize>,
646 p_out: usize,
647 coactivation: Array2<f64>,
648 weight: f64,
649 learnable_weight: bool,
650 ) -> Result<Self, String> {
651 if target.is_empty() {
652 return Err("DecoderIncoherencePenalty::new requires a non-empty target".to_string());
653 }
654 if !(weight.is_finite() && weight > 0.0) {
655 return Err(format!(
656 "DecoderIncoherencePenalty::new requires finite weight > 0, got {weight}"
657 ));
658 }
659 if p_out == 0 {
660 return Err("DecoderIncoherencePenalty::new requires p_out > 0".to_string());
661 }
662 if block_sizes.len() < 2 {
663 return Err(
664 "DecoderIncoherencePenalty::new requires at least two atom blocks".to_string(),
665 );
666 }
667 let k = block_sizes.len();
668 if coactivation.dim() != (k, k) {
669 return Err(format!(
670 "DecoderIncoherencePenalty::new requires (K, K)=({k}, {k}) coactivation; got {:?}",
671 coactivation.dim()
672 ));
673 }
674 if !coactivation
675 .iter()
676 .all(|value| value.is_finite() && *value >= 0.0)
677 {
678 return Err(
679 "DecoderIncoherencePenalty::new requires finite non-negative coactivation entries"
680 .to_string(),
681 );
682 }
683 let mut total = 0usize;
684 for (atom_idx, &m) in block_sizes.iter().enumerate() {
685 if m == 0 {
686 return Err(format!(
687 "DecoderIncoherencePenalty::new block_sizes[{atom_idx}] must be > 0"
688 ));
689 }
690 let span = m.checked_mul(p_out).ok_or_else(|| {
691 "DecoderIncoherencePenalty::new block span overflows usize".to_string()
692 })?;
693 total = total.checked_add(span).ok_or_else(|| {
694 "DecoderIncoherencePenalty::new total span overflows usize".to_string()
695 })?;
696 }
697 if total != target.len() {
698 return Err(format!(
699 "DecoderIncoherencePenalty::new Σ_k M_k·p_out = {total} does not match target length {}",
700 target.len()
701 ));
702 }
703 let mut pairs = Vec::new();
708 for j in 0..k {
709 for kk in (j + 1)..k {
710 let w = 0.5 * (coactivation[[j, kk]] + coactivation[[kk, j]]);
711 if w != 0.0 {
712 pairs.push((j, kk, w));
713 }
714 }
715 }
716 Ok(Self {
717 target,
718 block_sizes,
719 p_out,
720 k_atoms: k,
721 pairs,
722 weight,
723 learnable_weight,
724 rho_index: 0,
725 weight_schedule: None,
726 })
727 }
728
729 #[must_use = "build error must be handled"]
737 pub fn new_sparse(
738 target: PsiSlice,
739 block_sizes: Vec<usize>,
740 p_out: usize,
741 pairs: Vec<(usize, usize, f64)>,
742 weight: f64,
743 learnable_weight: bool,
744 ) -> Result<Self, String> {
745 if target.is_empty() {
746 return Err(
747 "DecoderIncoherencePenalty::new_sparse requires a non-empty target".to_string(),
748 );
749 }
750 if !(weight.is_finite() && weight > 0.0) {
751 return Err(format!(
752 "DecoderIncoherencePenalty::new_sparse requires finite weight > 0, got {weight}"
753 ));
754 }
755 if p_out == 0 {
756 return Err("DecoderIncoherencePenalty::new_sparse requires p_out > 0".to_string());
757 }
758 if block_sizes.len() < 2 {
759 return Err(
760 "DecoderIncoherencePenalty::new_sparse requires at least two atom blocks"
761 .to_string(),
762 );
763 }
764 let k = block_sizes.len();
765 let mut total = 0usize;
766 for (atom_idx, &m) in block_sizes.iter().enumerate() {
767 if m == 0 {
768 return Err(format!(
769 "DecoderIncoherencePenalty::new_sparse block_sizes[{atom_idx}] must be > 0"
770 ));
771 }
772 let span = m.checked_mul(p_out).ok_or_else(|| {
773 "DecoderIncoherencePenalty::new_sparse block span overflows usize".to_string()
774 })?;
775 total = total.checked_add(span).ok_or_else(|| {
776 "DecoderIncoherencePenalty::new_sparse total span overflows usize".to_string()
777 })?;
778 }
779 if total != target.len() {
780 return Err(format!(
781 "DecoderIncoherencePenalty::new_sparse Σ_k M_k·p_out = {total} does not match target length {}",
782 target.len()
783 ));
784 }
785 let mut clean = Vec::with_capacity(pairs.len());
786 for (j, kk, w) in pairs {
787 if j >= k || kk >= k {
788 return Err(format!(
789 "DecoderIncoherencePenalty::new_sparse pair ({j}, {kk}) out of range K={k}"
790 ));
791 }
792 if j >= kk {
793 return Err(format!(
794 "DecoderIncoherencePenalty::new_sparse requires j < k for each pair, got ({j}, {kk})"
795 ));
796 }
797 if !(w.is_finite() && w >= 0.0) {
798 return Err(format!(
799 "DecoderIncoherencePenalty::new_sparse requires finite non-negative pair weight, got {w}"
800 ));
801 }
802 if w != 0.0 {
803 clean.push((j, kk, w));
804 }
805 }
806 Ok(Self {
807 target,
808 block_sizes,
809 p_out,
810 k_atoms: k,
811 pairs: clean,
812 weight,
813 learnable_weight,
814 rho_index: 0,
815 weight_schedule: None,
816 })
817 }
818
819 impl_with_weight_schedule!(weight);
820
821 fn resolved_weight(&self, rho: ArrayView1<'_, f64>) -> f64 {
822 if self.learnable_weight {
823 validated_learnable_weight(self.weight, rho[self.rho_index])
824 } else {
825 self.weight
826 }
827 }
828
829 fn block_offsets(&self) -> Vec<usize> {
833 let mut out = Vec::with_capacity(self.block_sizes.len());
834 let mut cursor = self.target.range.start;
835 for &m in &self.block_sizes {
836 out.push(cursor);
837 cursor += m * self.p_out;
838 }
839 out
840 }
841
842 fn cross_gram(
844 target: ArrayView1<'_, f64>,
845 off_j: usize,
846 m_j: usize,
847 off_k: usize,
848 m_k: usize,
849 p_out: usize,
850 ) -> Array2<f64> {
851 let mut out = Array2::<f64>::zeros((m_j, m_k));
852 for a in 0..m_j {
853 for b in 0..m_k {
854 let mut s = 0.0;
855 for o in 0..p_out {
856 s += target[off_j + a * p_out + o] * target[off_k + b * p_out + o];
857 }
858 out[[a, b]] = s;
859 }
860 }
861 out
862 }
863
864 fn block_norm_sq(target: ArrayView1<'_, f64>, off: usize, m: usize, p_out: usize) -> f64 {
870 let mut s = 0.0;
871 for i in 0..(m * p_out) {
872 let v = target[off + i];
873 s += v * v;
874 }
875 s
876 }
877
878 fn hvp_impl(
886 &self,
887 target: ArrayView1<'_, f64>,
888 rho: ArrayView1<'_, f64>,
889 v: ArrayView1<'_, f64>,
890 include_residual: bool,
891 ) -> Array1<f64> {
892 let mut out = Array1::<f64>::zeros(target.len());
893 if target.len() != self.target.len() {
894 return out;
895 }
896 let offsets = self.block_offsets();
897 let weight = self.resolved_weight(rho);
898 let p_out = self.p_out;
899 for &(j, k, w_sym) in &self.pairs {
900 {
901 let w_pair = w_sym * weight;
902 if w_pair == 0.0 {
903 continue;
904 }
905 let off_j = offsets[j];
906 let off_k = offsets[k];
907 let m_j = self.block_sizes[j];
908 let m_k = self.block_sizes[k];
909 let nj = Self::block_norm_sq(target, off_j, m_j, p_out);
913 let nk = Self::block_norm_sq(target, off_k, m_k, p_out);
914 if !(nj > 0.0 && nk > 0.0) {
915 continue;
916 }
917 let kappa = w_pair / (nj * nk);
918 let mut d_c = Array2::<f64>::zeros((m_j, m_k));
920 for a in 0..m_j {
921 for b in 0..m_k {
922 let mut s = 0.0;
923 for o in 0..p_out {
924 s += v[off_j + a * p_out + o] * target[off_k + b * p_out + o]
925 + target[off_j + a * p_out + o] * v[off_k + b * p_out + o];
926 }
927 d_c[[a, b]] = s;
928 }
929 }
930 if !include_residual {
931 for a in 0..m_j {
937 for o in 0..p_out {
938 let mut s = 0.0;
939 for b in 0..m_k {
940 s += d_c[[a, b]] * target[off_k + b * p_out + o];
941 }
942 out[off_j + a * p_out + o] += kappa * s;
943 }
944 }
945 for b in 0..m_k {
946 for o in 0..p_out {
947 let mut s = 0.0;
948 for a in 0..m_j {
949 s += d_c[[a, b]] * target[off_j + a * p_out + o];
950 }
951 out[off_k + b * p_out + o] += kappa * s;
952 }
953 }
954 continue;
955 }
956 let c = Self::cross_gram(target, off_j, m_j, off_k, m_k, p_out);
962 let mut e = 0.0;
963 let mut d_e = 0.0;
964 for a in 0..m_j {
965 for b in 0..m_k {
966 e += c[[a, b]] * c[[a, b]];
967 d_e += 2.0 * c[[a, b]] * d_c[[a, b]];
968 }
969 }
970 let mut bjvj = 0.0;
971 for i in 0..(m_j * p_out) {
972 bjvj += target[off_j + i] * v[off_j + i];
973 }
974 let mut bkvk = 0.0;
975 for i in 0..(m_k * p_out) {
976 bkvk += target[off_k + i] * v[off_k + i];
977 }
978 let alpha = 2.0 * bjvj / nj;
979 let beta = 2.0 * bkvk / nk;
980 let e_o_nj = e / nj;
981 let e_o_nk = e / nk;
982 let de_o_nj = d_e / nj;
983 let de_o_nk = d_e / nk;
984 for a in 0..m_j {
985 for o in 0..p_out {
986 let mut g_j = 0.0;
987 let mut dg_j = 0.0;
988 for b in 0..m_k {
989 g_j += c[[a, b]] * target[off_k + b * p_out + o];
990 dg_j += d_c[[a, b]] * target[off_k + b * p_out + o]
991 + c[[a, b]] * v[off_k + b * p_out + o];
992 }
993 let bj = target[off_j + a * p_out + o];
994 let vj = v[off_j + a * p_out + o];
995 let hv = dg_j - (alpha + beta) * g_j - de_o_nj * bj
996 + e_o_nj * (2.0 * alpha + beta) * bj
997 - e_o_nj * vj;
998 out[off_j + a * p_out + o] += kappa * hv;
999 }
1000 }
1001 for b in 0..m_k {
1002 for o in 0..p_out {
1003 let mut g_k = 0.0;
1004 let mut dg_k = 0.0;
1005 for a in 0..m_j {
1006 g_k += c[[a, b]] * target[off_j + a * p_out + o];
1007 dg_k += d_c[[a, b]] * target[off_j + a * p_out + o]
1008 + c[[a, b]] * v[off_j + a * p_out + o];
1009 }
1010 let bk = target[off_k + b * p_out + o];
1011 let vk = v[off_k + b * p_out + o];
1012 let hv = dg_k - (alpha + beta) * g_k - de_o_nk * bk
1013 + e_o_nk * (2.0 * beta + alpha) * bk
1014 - e_o_nk * vk;
1015 out[off_k + b * p_out + o] += kappa * hv;
1016 }
1017 }
1018 }
1019 }
1020 out
1021 }
1022
1023 #[must_use]
1060 pub fn psd_majorizer_carriers(
1061 &self,
1062 target: ArrayView1<'_, f64>,
1063 rho: ArrayView1<'_, f64>,
1064 scale: f64,
1065 ) -> Vec<(f64, (usize, Vec<f64>), (usize, Vec<f64>))> {
1066 let mut out = Vec::new();
1067 if target.len() != self.target.len() {
1068 return out;
1069 }
1070 let offsets = self.block_offsets();
1071 let weight = self.resolved_weight(rho);
1072 let p = self.p_out;
1073 for &(j, k, w_sym) in &self.pairs {
1074 if j == k {
1075 continue;
1076 }
1077 let off_j = offsets[j];
1078 let off_k = offsets[k];
1079 let m_j = self.block_sizes[j];
1080 let m_k = self.block_sizes[k];
1081 if m_j == 0 || m_k == 0 {
1082 continue;
1083 }
1084 let nj = Self::block_norm_sq(target, off_j, m_j, p);
1085 let nk = Self::block_norm_sq(target, off_k, m_k, p);
1086 if !(nj > 0.0 && nk > 0.0) {
1087 continue;
1088 }
1089 let kappa = w_sym * weight * scale / (nj * nk);
1090 if kappa == 0.0 {
1091 continue;
1092 }
1093 for a in 0..m_j {
1094 for b in 0..m_k {
1095 let run_j: Vec<f64> = (0..p).map(|o| target[off_k + b * p + o]).collect();
1096 let run_k: Vec<f64> = (0..p).map(|o| target[off_j + a * p + o]).collect();
1097 out.push((kappa, (off_j + a * p, run_j), (off_k + b * p, run_k)));
1098 }
1099 }
1100 }
1101 out
1102 }
1103
1104 pub fn accumulate_psd_majorizer_dense(
1105 &self,
1106 target: ArrayView1<'_, f64>,
1107 rho: ArrayView1<'_, f64>,
1108 scale: f64,
1109 hbb: &mut Array2<f64>,
1110 ) {
1111 if target.len() != self.target.len() {
1112 return;
1113 }
1114 let offsets = self.block_offsets();
1115 let weight = self.resolved_weight(rho);
1116 let p = self.p_out;
1117 for &(j, k, w_sym) in &self.pairs {
1118 let off_j = offsets[j];
1119 let off_k = offsets[k];
1120 let m_j = self.block_sizes[j];
1121 let m_k = self.block_sizes[k];
1122 let nj = Self::block_norm_sq(target, off_j, m_j, self.p_out);
1130 let nk = Self::block_norm_sq(target, off_k, m_k, self.p_out);
1131 if !(nj > 0.0 && nk > 0.0) {
1132 continue;
1133 }
1134 let w = w_sym * weight * scale / (nj * nk);
1135 if w == 0.0 {
1136 continue;
1137 }
1138 let mut g_j = vec![0.0_f64; p * p];
1141 let mut g_k = vec![0.0_f64; p * p];
1142 for o in 0..p {
1143 for o2 in 0..p {
1144 let mut sj = 0.0;
1145 for a in 0..m_j {
1146 sj += target[off_j + a * p + o] * target[off_j + a * p + o2];
1147 }
1148 g_j[o * p + o2] = sj;
1149 let mut sk = 0.0;
1150 for b in 0..m_k {
1151 sk += target[off_k + b * p + o] * target[off_k + b * p + o2];
1152 }
1153 g_k[o * p + o2] = sk;
1154 }
1155 }
1156 for a in 0..m_j {
1158 let base = off_j + a * p;
1159 for o in 0..p {
1160 for o2 in 0..p {
1161 hbb[[base + o, base + o2]] += w * g_k[o * p + o2];
1162 }
1163 }
1164 }
1165 for b in 0..m_k {
1167 let base = off_k + b * p;
1168 for o in 0..p {
1169 for o2 in 0..p {
1170 hbb[[base + o, base + o2]] += w * g_j[o * p + o2];
1171 }
1172 }
1173 }
1174 for a in 0..m_j {
1177 for b in 0..m_k {
1178 for o1 in 0..p {
1179 let row_j = off_j + a * p + o1;
1180 let bk_b_o1 = target[off_k + b * p + o1];
1181 for o2 in 0..p {
1182 let col_k = off_k + b * p + o2;
1183 let contrib = w * target[off_j + a * p + o2] * bk_b_o1;
1184 hbb[[row_j, col_k]] += contrib;
1185 hbb[[col_k, row_j]] += contrib;
1186 }
1187 }
1188 }
1189 }
1190 }
1191 }
1192}
1193
1194impl AnalyticPenalty for DecoderIncoherencePenalty {
1195 fn tier(&self) -> PenaltyTier {
1196 PenaltyTier::Beta
1197 }
1198
1199 fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
1200 if target.len() != self.target.len() {
1201 return 0.0;
1202 }
1203 let offsets = self.block_offsets();
1204 let mut acc = 0.0;
1205 for &(j, k, w_pair) in &self.pairs {
1206 {
1207 if w_pair == 0.0 {
1208 continue;
1209 }
1210 let nj = Self::block_norm_sq(target, offsets[j], self.block_sizes[j], self.p_out);
1216 let nk = Self::block_norm_sq(target, offsets[k], self.block_sizes[k], self.p_out);
1217 if !(nj > 0.0 && nk > 0.0) {
1218 continue;
1219 }
1220 let c = Self::cross_gram(
1221 target,
1222 offsets[j],
1223 self.block_sizes[j],
1224 offsets[k],
1225 self.block_sizes[k],
1226 self.p_out,
1227 );
1228 let mut frob_sq = 0.0;
1229 for &value in c.iter() {
1230 frob_sq += value * value;
1231 }
1232 acc += w_pair * frob_sq / (nj * nk);
1233 }
1234 }
1235 0.5 * self.resolved_weight(rho) * acc
1236 }
1237
1238 fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1239 let mut grad = Array1::<f64>::zeros(target.len());
1240 if target.len() != self.target.len() {
1241 return grad;
1242 }
1243 let offsets = self.block_offsets();
1244 let weight = self.resolved_weight(rho);
1245 for &(j, k, w_sym) in &self.pairs {
1246 {
1247 let w_pair = w_sym * weight;
1248 if w_pair == 0.0 {
1249 continue;
1250 }
1251 let off_j = offsets[j];
1252 let off_k = offsets[k];
1253 let m_j = self.block_sizes[j];
1254 let m_k = self.block_sizes[k];
1255 let nj = Self::block_norm_sq(target, off_j, m_j, self.p_out);
1256 let nk = Self::block_norm_sq(target, off_k, m_k, self.p_out);
1257 if !(nj > 0.0 && nk > 0.0) {
1258 continue;
1259 }
1260 let c = Self::cross_gram(target, off_j, m_j, off_k, m_k, self.p_out);
1261 let mut e = 0.0;
1262 for &value in c.iter() {
1263 e += value * value;
1264 }
1265 let inv_j = w_pair / (nj * nk);
1273 for a in 0..m_j {
1274 for o in 0..self.p_out {
1275 let mut s = 0.0;
1276 for b in 0..m_k {
1277 s += c[[a, b]] * target[off_k + b * self.p_out + o];
1278 }
1279 let radial = (e / nj) * target[off_j + a * self.p_out + o];
1280 grad[off_j + a * self.p_out + o] += inv_j * (s - radial);
1281 }
1282 }
1283 for b in 0..m_k {
1284 for o in 0..self.p_out {
1285 let mut s = 0.0;
1286 for a in 0..m_j {
1287 s += c[[a, b]] * target[off_j + a * self.p_out + o];
1288 }
1289 let radial = (e / nk) * target[off_k + b * self.p_out + o];
1290 grad[off_k + b * self.p_out + o] += inv_j * (s - radial);
1291 }
1292 }
1293 }
1294 }
1295 grad
1296 }
1297
1298 fn hvp(
1314 &self,
1315 target: ArrayView1<'_, f64>,
1316 rho: ArrayView1<'_, f64>,
1317 v: ArrayView1<'_, f64>,
1318 ) -> Array1<f64> {
1319 assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
1320 self.hvp_impl(target, rho, v, true)
1321 }
1322
1323 fn psd_majorizer_hvp(
1336 &self,
1337 target: ArrayView1<'_, f64>,
1338 rho: ArrayView1<'_, f64>,
1339 v: ArrayView1<'_, f64>,
1340 ) -> Array1<f64> {
1341 assert_eq!(
1342 target.len(),
1343 v.len(),
1344 "psd_majorizer_hvp dimension mismatch"
1345 );
1346 self.hvp_impl(target, rho, v, false)
1347 }
1348
1349 impl_learnable_weight_grad_rho!();
1355
1356 impl_learnable_weight_rho_count!();
1357 impl_learnable_weight_domain!(weight);
1358
1359 fn name(&self) -> &str {
1360 "decoder_incoherence"
1361 }
1362
1363 impl_scalar_apply_schedule!(weight);
1364}
1365
1366#[derive(Debug, Clone)]
1376pub struct OrthogonalityPenalty {
1377 pub target: PsiSlice,
1378 pub latent_dim: usize,
1379 pub weight: f64,
1382 pub n_eff: usize,
1385 pub learnable_weight: bool,
1386 pub rho_index: usize,
1387 pub weight_schedule: Option<ScalarWeightSchedule>,
1388}
1389
1390impl OrthogonalityPenalty {
1391 #[must_use = "build error must be handled"]
1392 pub fn new(
1393 target: PsiSlice,
1394 latent_dim: usize,
1395 weight: f64,
1396 n_eff: usize,
1397 learnable_weight: bool,
1398 ) -> Result<Self, String> {
1399 if latent_dim == 0 {
1400 return Err("OrthogonalityPenalty::new requires latent_dim > 0".to_string());
1401 }
1402 if !target.len().is_multiple_of(latent_dim) {
1403 return Err(format!(
1404 "OrthogonalityPenalty::new target length {} is not divisible by latent_dim {}",
1405 target.len(),
1406 latent_dim
1407 ));
1408 }
1409 let n_obs = target.len() / latent_dim;
1410 if n_obs < latent_dim {
1411 return Err(format!(
1412 "OrthogonalityPenalty::new requires n_obs >= latent_dim for a feasible \
1413 Stiefel target, got n_obs {n_obs} and latent_dim {latent_dim}"
1414 ));
1415 }
1416 if !(weight.is_finite() && weight > 0.0) {
1417 return Err(format!(
1418 "OrthogonalityPenalty::new requires finite weight > 0, got {weight}"
1419 ));
1420 }
1421 if n_eff == 0 {
1422 return Err("OrthogonalityPenalty::new requires n_eff > 0".to_string());
1423 }
1424 if n_eff != n_obs {
1425 return Err(format!(
1426 "OrthogonalityPenalty::new requires n_eff to match target rows, got \
1427 n_eff {n_eff} and target rows {n_obs}"
1428 ));
1429 }
1430 Ok(Self {
1431 target,
1432 latent_dim,
1433 weight,
1434 n_eff,
1435 learnable_weight,
1436 rho_index: 0,
1437 weight_schedule: None,
1438 })
1439 }
1440
1441 impl_with_weight_schedule!(weight);
1442
1443 fn resolved_weight(&self, rho: ArrayView1<'_, f64>) -> f64 {
1444 if self.learnable_weight {
1445 validated_learnable_weight(self.weight, rho[self.rho_index])
1446 } else {
1447 self.weight
1448 }
1449 }
1450
1451 pub(crate) fn scale(&self, rho: ArrayView1<'_, f64>) -> f64 {
1452 self.resolved_weight(rho) / self.n_eff as f64
1453 }
1454
1455 pub(crate) fn target_matrix<'a>(
1456 &self,
1457 target: ArrayView1<'a, f64>,
1458 ) -> Option<ArrayView2<'a, f64>> {
1459 let d = self.latent_dim;
1460 if !target.len().is_multiple_of(d) {
1461 assert_eq!(
1462 target.len() % d,
1463 0,
1464 "target length must be divisible by latent_dim"
1465 );
1466 return None;
1467 }
1468 let n_obs = target.len() / d;
1469 target.into_shape_with_order((n_obs, d)).ok()
1470 }
1471
1472 pub(crate) fn gram_minus_identity(t: ArrayView2<'_, f64>) -> Array2<f64> {
1473 let n_obs = t.nrows();
1474 let d = t.ncols();
1475 let mut gram = Array2::<f64>::zeros((d, d));
1476 for a in 0..d {
1477 for b in 0..d {
1478 let mut s = 0.0;
1479 for n in 0..n_obs {
1480 s += t[[n, a]] * t[[n, b]];
1481 }
1482 gram[[a, b]] = s;
1483 }
1484 gram[[a, a]] -= 1.0;
1485 }
1486 gram
1487 }
1488
1489 fn flatten_matrix(m: &Array2<f64>) -> Array1<f64> {
1490 let n_obs = m.nrows();
1491 let d = m.ncols();
1492 let mut out = Array1::<f64>::zeros(n_obs * d);
1493 for n in 0..n_obs {
1494 for a in 0..d {
1495 out[n * d + a] = m[[n, a]];
1496 }
1497 }
1498 out
1499 }
1500
1501 pub(crate) fn hvp_with_precomputed_m(
1502 &self,
1503 t: ArrayView2<'_, f64>,
1504 m: ArrayView2<'_, f64>,
1505 v: ArrayView2<'_, f64>,
1506 scale: f64,
1507 ) -> Array2<f64> {
1508 let n_obs = t.nrows();
1509 let d = t.ncols();
1510 assert_eq!(v.dim(), t.dim(), "hvp matrix dimension mismatch");
1511 assert_eq!(m.dim(), (d, d), "precomputed gram dimension mismatch");
1512 if v.dim() != t.dim() {
1513 return Array2::<f64>::zeros((n_obs, d));
1514 }
1515
1516 let mut vt_t_plus_tt_v = Array2::<f64>::zeros((d, d));
1517 for c in 0..d {
1518 for b in 0..d {
1519 let mut s = 0.0;
1520 for n in 0..n_obs {
1521 s += v[[n, c]] * t[[n, b]] + t[[n, c]] * v[[n, b]];
1522 }
1523 vt_t_plus_tt_v[[c, b]] = s;
1524 }
1525 }
1526
1527 let mut out = Array2::<f64>::zeros((n_obs, d));
1528 for n in 0..n_obs {
1529 for b in 0..d {
1530 let mut va = 0.0;
1531 let mut tb = 0.0;
1532 for c in 0..d {
1533 va += v[[n, c]] * m[[c, b]];
1534 tb += t[[n, c]] * vt_t_plus_tt_v[[c, b]];
1535 }
1536 out[[n, b]] = 2.0 * scale * (va + tb);
1537 }
1538 }
1539 out
1540 }
1541
1542 pub(crate) fn as_dense_with_precomputed_m(
1543 &self,
1544 t: ArrayView2<'_, f64>,
1545 m: ArrayView2<'_, f64>,
1546 scale: f64,
1547 ) -> Array2<f64> {
1548 let n_obs = t.nrows();
1549 let d = t.ncols();
1550 assert_eq!(m.dim(), (d, d), "precomputed gram dimension mismatch");
1551 if m.dim() != (d, d) {
1552 return Array2::<f64>::zeros((n_obs * d, n_obs * d));
1553 }
1554
1555 let mut dense = Array2::<f64>::zeros((n_obs * d, n_obs * d));
1556 let factor = 2.0 * scale;
1557 for row1 in 0..n_obs {
1558 for row2 in 0..n_obs {
1559 let mut row_dot = 0.0;
1560 for axis in 0..d {
1561 row_dot += t[[row1, axis]] * t[[row2, axis]];
1562 }
1563 for col1 in 0..d {
1564 let i = row1 * d + col1;
1565 for col2 in 0..d {
1566 let j = row2 * d + col2;
1567 let mut entry = t[[row1, col2]] * t[[row2, col1]];
1568 if row1 == row2 {
1569 entry += m[[col2, col1]];
1570 }
1571 if col1 == col2 {
1572 entry += row_dot;
1573 }
1574 dense[[i, j]] = factor * entry;
1575 }
1576 }
1577 }
1578 }
1579 dense
1580 }
1581}
1582
1583impl AnalyticPenalty for OrthogonalityPenalty {
1584 fn tier(&self) -> PenaltyTier {
1585 PenaltyTier::Psi
1586 }
1587
1588 fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
1589 let Some(t) = self.target_matrix(target) else {
1590 return 0.0;
1591 };
1592 let gram = Self::gram_minus_identity(t.view());
1593 let mut acc = 0.0;
1594 for &v in gram.iter() {
1595 acc += v * v;
1596 }
1597 0.5 * self.scale(rho) * acc
1598 }
1599
1600 fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1601 let Some(t) = self.target_matrix(target) else {
1605 return Array1::<f64>::zeros(target.len());
1606 };
1607 let gram = Self::gram_minus_identity(t.view());
1608 let n_obs = t.nrows();
1609 let d = t.ncols();
1610 let factor = 2.0 * self.scale(rho);
1611 let mut grad = Array2::<f64>::zeros((n_obs, d));
1612 for n in 0..n_obs {
1613 for a in 0..d {
1614 let mut s = 0.0;
1615 for b in 0..d {
1616 s += t[[n, b]] * gram[[b, a]];
1617 }
1618 grad[[n, a]] = factor * s;
1619 }
1620 }
1621 Self::flatten_matrix(&grad)
1622 }
1623
1624 fn hvp(
1625 &self,
1626 target: ArrayView1<'_, f64>,
1627 rho: ArrayView1<'_, f64>,
1628 v: ArrayView1<'_, f64>,
1629 ) -> Array1<f64> {
1630 assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
1631 if target.len() != v.len() {
1632 return Array1::<f64>::zeros(target.len());
1633 }
1634 let Some(t) = self.target_matrix(target) else {
1635 return Array1::<f64>::zeros(target.len());
1636 };
1637 let Some(v_mat) = self.target_matrix(v) else {
1638 return Array1::<f64>::zeros(target.len());
1639 };
1640 let m = Self::gram_minus_identity(t.view());
1641 let hv = self.hvp_with_precomputed_m(t.view(), m.view(), v_mat.view(), self.scale(rho));
1642 Self::flatten_matrix(&hv)
1643 }
1644
1645 impl_learnable_weight_grad_rho!();
1646
1647 impl_learnable_weight_rho_count!();
1648 impl_learnable_weight_domain!(weight);
1649
1650 fn name(&self) -> &str {
1651 "orthogonality"
1652 }
1653
1654 impl_scalar_apply_schedule!(weight);
1655}