1use super::*;
2
3#[derive(Debug, Clone)]
37pub struct BlockOrthogonalityPenalty {
38 pub target: PsiSlice,
39 pub groups: Vec<Vec<usize>>,
40 pub weight: f64,
43 pub n_eff: usize,
45 pub learnable_weight: bool,
46 pub rho_index: usize,
47 pub weight_schedule: Option<ScalarWeightSchedule>,
48}
49
50impl BlockOrthogonalityPenalty {
51 #[must_use = "build error must be handled"]
52 pub fn new(
53 target: PsiSlice,
54 groups: Vec<Vec<usize>>,
55 weight: f64,
56 n_eff: usize,
57 learnable_weight: bool,
58 ) -> Result<Self, String> {
59 if target.is_empty() {
60 return Err("BlockOrthogonalityPenalty::new requires a non-empty target".to_string());
61 }
62 if !(weight.is_finite() && weight > 0.0) {
63 return Err(format!(
64 "BlockOrthogonalityPenalty::new requires finite weight > 0, got {weight}"
65 ));
66 }
67 if n_eff == 0 {
68 return Err("BlockOrthogonalityPenalty::new requires n_eff > 0".to_string());
69 }
70 if !target.len().is_multiple_of(n_eff) {
71 return Err(format!(
72 "BlockOrthogonalityPenalty::new target length {} is not divisible by n_eff {}",
73 target.len(),
74 n_eff
75 ));
76 }
77 let latent_dim = target.len() / n_eff;
78 if let Some(expected_dim) = target.latent_dim {
79 let expected = n_eff.checked_mul(expected_dim).ok_or_else(|| {
80 "BlockOrthogonalityPenalty::new target shape overflows usize".to_string()
81 })?;
82 if expected != target.len() {
83 return Err(format!(
84 "BlockOrthogonalityPenalty::new target length {} does not match n_eff {} × latent_dim {}",
85 target.len(),
86 n_eff,
87 expected_dim
88 ));
89 }
90 }
91 if groups.len() < 2 {
92 return Err("BlockOrthogonalityPenalty::new requires at least two groups".to_string());
93 }
94 let mut seen = vec![false; latent_dim];
95 for (group_idx, group) in groups.iter().enumerate() {
96 if group.is_empty() {
97 return Err(format!(
98 "BlockOrthogonalityPenalty::new groups[{group_idx}] must not be empty"
99 ));
100 }
101 for &axis in group {
102 if axis >= latent_dim {
103 return Err(format!(
104 "BlockOrthogonalityPenalty::new groups[{group_idx}] axis {axis} exceeds latent_dim {latent_dim}"
105 ));
106 }
107 if seen[axis] {
108 return Err(format!(
109 "BlockOrthogonalityPenalty::new axis {axis} appears in more than one group"
110 ));
111 }
112 seen[axis] = true;
113 }
114 }
115 for (axis, present) in seen.iter().copied().enumerate() {
116 if !present {
117 return Err(format!(
118 "BlockOrthogonalityPenalty::new groups must partition latent axes; missing axis {axis}"
119 ));
120 }
121 }
122 Ok(Self {
123 target,
124 groups,
125 weight,
126 n_eff,
127 learnable_weight,
128 rho_index: 0,
129 weight_schedule: None,
130 })
131 }
132
133 impl_with_weight_schedule!(weight);
134
135 fn resolved_weight(&self, rho: ArrayView1<'_, f64>) -> f64 {
136 if self.learnable_weight {
137 validated_learnable_weight(self.weight, rho[self.rho_index])
138 } else {
139 self.weight
140 }
141 }
142
143 fn latent_dim(&self, target_len: usize) -> Option<usize> {
144 if self.n_eff == 0 || !target_len.is_multiple_of(self.n_eff) {
145 assert_eq!(
146 target_len % self.n_eff.max(1),
147 0,
148 "target length must be divisible by n_eff"
149 );
150 return None;
151 }
152 Some(target_len / self.n_eff)
153 }
154
155 fn target_matrix<'a>(&self, target: ArrayView1<'a, f64>) -> Option<ArrayView2<'a, f64>> {
156 let d = self.latent_dim(target.len())?;
157 target.into_shape_with_order((self.n_eff, d)).ok()
158 }
159
160 fn flatten_matrix(m: &Array2<f64>) -> Array1<f64> {
161 let n_obs = m.nrows();
162 let d = m.ncols();
163 let mut out = Array1::<f64>::zeros(n_obs * d);
164 for n in 0..n_obs {
165 for a in 0..d {
166 out[n * d + a] = m[[n, a]];
167 }
168 }
169 out
170 }
171
172 fn cross_gram(t: ArrayView2<'_, f64>, left: &[usize], right: &[usize]) -> Array2<f64> {
173 let mut out = Array2::<f64>::zeros((left.len(), right.len()));
174 for (li, &a) in left.iter().enumerate() {
175 for (ri, &b) in right.iter().enumerate() {
176 let mut s = 0.0;
177 for n in 0..t.nrows() {
178 s += t[[n, a]] * t[[n, b]];
179 }
180 out[[li, ri]] = s;
181 }
182 }
183 out
184 }
185
186 fn mixed_cross_gram(
193 a: ArrayView2<'_, f64>,
194 b: ArrayView2<'_, f64>,
195 left: &[usize],
196 right: &[usize],
197 ) -> Array2<f64> {
198 assert_eq!(a.nrows(), b.nrows(), "mixed_cross_gram row mismatch");
199 let mut out = Array2::<f64>::zeros((left.len(), right.len()));
200 for (li, &al) in left.iter().enumerate() {
201 for (ri, &br) in right.iter().enumerate() {
202 let mut s = 0.0;
203 for n in 0..a.nrows() {
204 s += a[[n, al]] * b[[n, br]];
205 }
206 out[[li, ri]] = s;
207 }
208 }
209 out
210 }
211
212 fn add_right_times_cross(
213 out: &mut Array2<f64>,
214 right: ArrayView2<'_, f64>,
215 left_axes: &[usize],
216 right_axes: &[usize],
217 cross_right_left: ArrayView2<'_, f64>,
218 factor: f64,
219 ) {
220 assert_eq!(cross_right_left.dim(), (right_axes.len(), left_axes.len()));
221 for n in 0..out.nrows() {
222 for (li, &left_axis) in left_axes.iter().enumerate() {
223 let mut s = 0.0;
224 for (ri, &right_axis) in right_axes.iter().enumerate() {
225 s += right[[n, right_axis]] * cross_right_left[[ri, li]];
226 }
227 out[[n, left_axis]] += factor * s;
228 }
229 }
230 }
231
232 fn hvp_with_precomputed_cross(
233 &self,
234 t: ArrayView2<'_, f64>,
235 cross: &[Vec<Option<Array2<f64>>>],
236 v: ArrayView2<'_, f64>,
237 weight: f64,
238 ) -> Array2<f64> {
239 assert_eq!(v.dim(), t.dim(), "hvp matrix dimension mismatch");
240 if v.dim() != t.dim() {
241 return Array2::<f64>::zeros(t.dim());
242 }
243 let mut out = Array2::<f64>::zeros(t.dim());
244 for g in 0..self.groups.len() {
245 let group_g = &self.groups[g];
246 for h in 0..self.groups.len() {
247 if g == h {
248 continue;
249 }
250 let group_h = &self.groups[h];
251 let c_hg = cross[h][g]
252 .as_ref()
253 .expect("between-block cross Gram must be precomputed");
254 Self::add_right_times_cross(&mut out, v, group_g, group_h, c_hg.view(), weight);
257
258 let dv_h_g = Self::mixed_cross_gram(v, t, group_h, group_g);
271 let tv_h_g = Self::mixed_cross_gram(t, v, group_h, group_g);
272 let mut d_c_hg = dv_h_g;
273 d_c_hg += &tv_h_g;
274 Self::add_right_times_cross(&mut out, t, group_g, group_h, d_c_hg.view(), weight);
275 }
276 }
277 out
278 }
279
280 fn precompute_cross(&self, t: ArrayView2<'_, f64>) -> Vec<Vec<Option<Array2<f64>>>> {
281 let mut cross = vec![vec![None; self.groups.len()]; self.groups.len()];
282 for g in 0..self.groups.len() {
283 for h in 0..self.groups.len() {
284 if g != h {
285 cross[g][h] = Some(Self::cross_gram(t, &self.groups[g], &self.groups[h]));
286 }
287 }
288 }
289 cross
290 }
291
292 pub fn as_dense(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array2<f64> {
295 let n = target.len();
296 let Some(t) = self.target_matrix(target) else {
297 return Array2::<f64>::zeros((n, n));
298 };
299 let cross = self.precompute_cross(t.view());
300 let weight = self.resolved_weight(rho);
301 let mut dense = Array2::<f64>::zeros((n, n));
302 let mut e = Array1::<f64>::zeros(n);
303 for j in 0..n {
304 e[j] = 1.0;
305 let Some(e_mat) = self.target_matrix(e.view()) else {
306 return Array2::<f64>::zeros((n, n));
307 };
308 let col = self.hvp_with_precomputed_cross(t.view(), &cross, e_mat, weight);
309 for i in 0..n {
310 dense[[i, j]] = col[[i / t.ncols(), i % t.ncols()]];
311 }
312 e[j] = 0.0;
313 }
314 dense
315 }
316}
317
318impl AnalyticPenalty for BlockOrthogonalityPenalty {
319 fn tier(&self) -> PenaltyTier {
320 PenaltyTier::Psi
321 }
322
323 fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
324 let Some(t) = self.target_matrix(target) else {
325 return 0.0;
326 };
327 let mut acc = 0.0;
328 for g in 0..self.groups.len() {
329 for h in (g + 1)..self.groups.len() {
330 let c = Self::cross_gram(t.view(), &self.groups[g], &self.groups[h]);
331 for &v in c.iter() {
332 acc += v * v;
333 }
334 }
335 }
336 0.5 * self.resolved_weight(rho) * acc
341 }
342
343 fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
344 let Some(t) = self.target_matrix(target) else {
345 return Array1::<f64>::zeros(target.len());
346 };
347 let cross = self.precompute_cross(t.view());
348 let weight = self.resolved_weight(rho);
349 let mut grad = Array2::<f64>::zeros(t.dim());
350 for g in 0..self.groups.len() {
354 for h in 0..self.groups.len() {
355 if g == h {
356 continue;
357 }
358 let c_hg = cross[h][g]
359 .as_ref()
360 .expect("between-block cross Gram must be precomputed");
361 Self::add_right_times_cross(
362 &mut grad,
363 t.view(),
364 &self.groups[g],
365 &self.groups[h],
366 c_hg.view(),
367 weight,
368 );
369 }
370 }
371 Self::flatten_matrix(&grad)
372 }
373
374 fn hvp(
375 &self,
376 target: ArrayView1<'_, f64>,
377 rho: ArrayView1<'_, f64>,
378 v: ArrayView1<'_, f64>,
379 ) -> Array1<f64> {
380 assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
381 if target.len() != v.len() {
382 return Array1::<f64>::zeros(target.len());
383 }
384 let Some(t) = self.target_matrix(target) else {
385 return Array1::<f64>::zeros(target.len());
386 };
387 let Some(v_mat) = self.target_matrix(v) else {
388 return Array1::<f64>::zeros(target.len());
389 };
390 let cross = self.precompute_cross(t.view());
391 let hv = self.hvp_with_precomputed_cross(
392 t.view(),
393 &cross,
394 v_mat.view(),
395 self.resolved_weight(rho),
396 );
397 Self::flatten_matrix(&hv)
398 }
399
400 fn hessian_diag(
401 &self,
402 target: ArrayView1<'_, f64>,
403 rho: ArrayView1<'_, f64>,
404 ) -> Option<Array1<f64>> {
405 let t = self.target_matrix(target)?;
406 let n_obs = t.nrows();
407 let d = t.ncols();
408 let weight = self.resolved_weight(rho);
409 let mut group_of = vec![usize::MAX; d];
410 for (gi, group) in self.groups.iter().enumerate() {
411 for &axis in group {
412 group_of[axis] = gi;
413 }
414 }
415 let mut out = Array1::<f64>::zeros(n_obs * d);
416 for n in 0..n_obs {
417 let mut row_sq = 0.0_f64;
418 let mut group_sq = vec![0.0_f64; self.groups.len()];
419 for b in 0..d {
420 let v = t[[n, b]];
421 let v2 = v * v;
422 row_sq += v2;
423 group_sq[group_of[b]] += v2;
424 }
425 for a in 0..d {
426 let g = group_of[a];
427 out[n * d + a] = weight * (row_sq - group_sq[g]);
428 }
429 }
430 Some(out)
431 }
432
433 impl_learnable_weight_grad_rho!();
434
435 impl_learnable_weight_rho_count!();
436 impl_learnable_weight_domain!(weight);
437
438 fn name(&self) -> &str {
439 "block_orthogonality"
440 }
441
442 impl_scalar_apply_schedule!(weight);
443}
444
445#[derive(Debug, Clone)]
490pub struct DecoderIncoherencePenalty {
491 pub target: PsiSlice,
492 pub block_sizes: Vec<usize>,
495 pub p_out: usize,
498 pub k_atoms: usize,
505 pub pairs: Vec<(usize, usize, f64)>,
510 pub weight: f64,
513 pub learnable_weight: bool,
514 pub rho_index: usize,
515 pub weight_schedule: Option<ScalarWeightSchedule>,
516}
517
518impl DecoderIncoherencePenalty {
519 #[must_use = "build error must be handled"]
520 pub fn new(
521 target: PsiSlice,
522 block_sizes: Vec<usize>,
523 p_out: usize,
524 coactivation: Array2<f64>,
525 weight: f64,
526 learnable_weight: bool,
527 ) -> Result<Self, String> {
528 if target.is_empty() {
529 return Err("DecoderIncoherencePenalty::new requires a non-empty target".to_string());
530 }
531 if !(weight.is_finite() && weight > 0.0) {
532 return Err(format!(
533 "DecoderIncoherencePenalty::new requires finite weight > 0, got {weight}"
534 ));
535 }
536 if p_out == 0 {
537 return Err("DecoderIncoherencePenalty::new requires p_out > 0".to_string());
538 }
539 if block_sizes.len() < 2 {
540 return Err(
541 "DecoderIncoherencePenalty::new requires at least two atom blocks".to_string(),
542 );
543 }
544 let k = block_sizes.len();
545 if coactivation.dim() != (k, k) {
546 return Err(format!(
547 "DecoderIncoherencePenalty::new requires (K, K)=({k}, {k}) coactivation; got {:?}",
548 coactivation.dim()
549 ));
550 }
551 if !coactivation
552 .iter()
553 .all(|value| value.is_finite() && *value >= 0.0)
554 {
555 return Err(
556 "DecoderIncoherencePenalty::new requires finite non-negative coactivation entries"
557 .to_string(),
558 );
559 }
560 let mut total = 0usize;
561 for (atom_idx, &m) in block_sizes.iter().enumerate() {
562 if m == 0 {
563 return Err(format!(
564 "DecoderIncoherencePenalty::new block_sizes[{atom_idx}] must be > 0"
565 ));
566 }
567 let span = m.checked_mul(p_out).ok_or_else(|| {
568 "DecoderIncoherencePenalty::new block span overflows usize".to_string()
569 })?;
570 total = total.checked_add(span).ok_or_else(|| {
571 "DecoderIncoherencePenalty::new total span overflows usize".to_string()
572 })?;
573 }
574 if total != target.len() {
575 return Err(format!(
576 "DecoderIncoherencePenalty::new Σ_k M_k·p_out = {total} does not match target length {}",
577 target.len()
578 ));
579 }
580 let mut pairs = Vec::new();
585 for j in 0..k {
586 for kk in (j + 1)..k {
587 let w = 0.5 * (coactivation[[j, kk]] + coactivation[[kk, j]]);
588 if w != 0.0 {
589 pairs.push((j, kk, w));
590 }
591 }
592 }
593 Ok(Self {
594 target,
595 block_sizes,
596 p_out,
597 k_atoms: k,
598 pairs,
599 weight,
600 learnable_weight,
601 rho_index: 0,
602 weight_schedule: None,
603 })
604 }
605
606 #[must_use = "build error must be handled"]
614 pub fn new_sparse(
615 target: PsiSlice,
616 block_sizes: Vec<usize>,
617 p_out: usize,
618 pairs: Vec<(usize, usize, f64)>,
619 weight: f64,
620 learnable_weight: bool,
621 ) -> Result<Self, String> {
622 if target.is_empty() {
623 return Err(
624 "DecoderIncoherencePenalty::new_sparse requires a non-empty target".to_string(),
625 );
626 }
627 if !(weight.is_finite() && weight > 0.0) {
628 return Err(format!(
629 "DecoderIncoherencePenalty::new_sparse requires finite weight > 0, got {weight}"
630 ));
631 }
632 if p_out == 0 {
633 return Err("DecoderIncoherencePenalty::new_sparse requires p_out > 0".to_string());
634 }
635 if block_sizes.len() < 2 {
636 return Err(
637 "DecoderIncoherencePenalty::new_sparse requires at least two atom blocks"
638 .to_string(),
639 );
640 }
641 let k = block_sizes.len();
642 let mut total = 0usize;
643 for (atom_idx, &m) in block_sizes.iter().enumerate() {
644 if m == 0 {
645 return Err(format!(
646 "DecoderIncoherencePenalty::new_sparse block_sizes[{atom_idx}] must be > 0"
647 ));
648 }
649 let span = m.checked_mul(p_out).ok_or_else(|| {
650 "DecoderIncoherencePenalty::new_sparse block span overflows usize".to_string()
651 })?;
652 total = total.checked_add(span).ok_or_else(|| {
653 "DecoderIncoherencePenalty::new_sparse total span overflows usize".to_string()
654 })?;
655 }
656 if total != target.len() {
657 return Err(format!(
658 "DecoderIncoherencePenalty::new_sparse Σ_k M_k·p_out = {total} does not match target length {}",
659 target.len()
660 ));
661 }
662 let mut clean = Vec::with_capacity(pairs.len());
663 for (j, kk, w) in pairs {
664 if j >= k || kk >= k {
665 return Err(format!(
666 "DecoderIncoherencePenalty::new_sparse pair ({j}, {kk}) out of range K={k}"
667 ));
668 }
669 if j >= kk {
670 return Err(format!(
671 "DecoderIncoherencePenalty::new_sparse requires j < k for each pair, got ({j}, {kk})"
672 ));
673 }
674 if !(w.is_finite() && w >= 0.0) {
675 return Err(format!(
676 "DecoderIncoherencePenalty::new_sparse requires finite non-negative pair weight, got {w}"
677 ));
678 }
679 if w != 0.0 {
680 clean.push((j, kk, w));
681 }
682 }
683 Ok(Self {
684 target,
685 block_sizes,
686 p_out,
687 k_atoms: k,
688 pairs: clean,
689 weight,
690 learnable_weight,
691 rho_index: 0,
692 weight_schedule: None,
693 })
694 }
695
696 impl_with_weight_schedule!(weight);
697
698 fn resolved_weight(&self, rho: ArrayView1<'_, f64>) -> f64 {
699 if self.learnable_weight {
700 validated_learnable_weight(self.weight, rho[self.rho_index])
701 } else {
702 self.weight
703 }
704 }
705
706 fn block_offsets(&self) -> Vec<usize> {
710 let mut out = Vec::with_capacity(self.block_sizes.len());
711 let mut cursor = self.target.range.start;
712 for &m in &self.block_sizes {
713 out.push(cursor);
714 cursor += m * self.p_out;
715 }
716 out
717 }
718
719 fn cross_gram(
721 target: ArrayView1<'_, f64>,
722 off_j: usize,
723 m_j: usize,
724 off_k: usize,
725 m_k: usize,
726 p_out: usize,
727 ) -> Array2<f64> {
728 let mut out = Array2::<f64>::zeros((m_j, m_k));
729 for a in 0..m_j {
730 for b in 0..m_k {
731 let mut s = 0.0;
732 for o in 0..p_out {
733 s += target[off_j + a * p_out + o] * target[off_k + b * p_out + o];
734 }
735 out[[a, b]] = s;
736 }
737 }
738 out
739 }
740
741 fn block_norm_sq(target: ArrayView1<'_, f64>, off: usize, m: usize, p_out: usize) -> f64 {
747 let mut s = 0.0;
748 for i in 0..(m * p_out) {
749 let v = target[off + i];
750 s += v * v;
751 }
752 s
753 }
754
755 fn hvp_impl(
763 &self,
764 target: ArrayView1<'_, f64>,
765 rho: ArrayView1<'_, f64>,
766 v: ArrayView1<'_, f64>,
767 include_residual: bool,
768 ) -> Array1<f64> {
769 let mut out = Array1::<f64>::zeros(target.len());
770 if target.len() != self.target.len() {
771 return out;
772 }
773 let offsets = self.block_offsets();
774 let weight = self.resolved_weight(rho);
775 let p_out = self.p_out;
776 for &(j, k, w_sym) in &self.pairs {
777 {
778 let w_pair = w_sym * weight;
779 if w_pair == 0.0 {
780 continue;
781 }
782 let off_j = offsets[j];
783 let off_k = offsets[k];
784 let m_j = self.block_sizes[j];
785 let m_k = self.block_sizes[k];
786 let nj = Self::block_norm_sq(target, off_j, m_j, p_out);
790 let nk = Self::block_norm_sq(target, off_k, m_k, p_out);
791 if !(nj > 0.0 && nk > 0.0) {
792 continue;
793 }
794 let kappa = w_pair / (nj * nk);
795 let mut d_c = Array2::<f64>::zeros((m_j, m_k));
797 for a in 0..m_j {
798 for b in 0..m_k {
799 let mut s = 0.0;
800 for o in 0..p_out {
801 s += v[off_j + a * p_out + o] * target[off_k + b * p_out + o]
802 + target[off_j + a * p_out + o] * v[off_k + b * p_out + o];
803 }
804 d_c[[a, b]] = s;
805 }
806 }
807 if !include_residual {
808 for a in 0..m_j {
814 for o in 0..p_out {
815 let mut s = 0.0;
816 for b in 0..m_k {
817 s += d_c[[a, b]] * target[off_k + b * p_out + o];
818 }
819 out[off_j + a * p_out + o] += kappa * s;
820 }
821 }
822 for b in 0..m_k {
823 for o in 0..p_out {
824 let mut s = 0.0;
825 for a in 0..m_j {
826 s += d_c[[a, b]] * target[off_j + a * p_out + o];
827 }
828 out[off_k + b * p_out + o] += kappa * s;
829 }
830 }
831 continue;
832 }
833 let c = Self::cross_gram(target, off_j, m_j, off_k, m_k, p_out);
839 let mut e = 0.0;
840 let mut d_e = 0.0;
841 for a in 0..m_j {
842 for b in 0..m_k {
843 e += c[[a, b]] * c[[a, b]];
844 d_e += 2.0 * c[[a, b]] * d_c[[a, b]];
845 }
846 }
847 let mut bjvj = 0.0;
848 for i in 0..(m_j * p_out) {
849 bjvj += target[off_j + i] * v[off_j + i];
850 }
851 let mut bkvk = 0.0;
852 for i in 0..(m_k * p_out) {
853 bkvk += target[off_k + i] * v[off_k + i];
854 }
855 let alpha = 2.0 * bjvj / nj;
856 let beta = 2.0 * bkvk / nk;
857 let e_o_nj = e / nj;
858 let e_o_nk = e / nk;
859 let de_o_nj = d_e / nj;
860 let de_o_nk = d_e / nk;
861 for a in 0..m_j {
862 for o in 0..p_out {
863 let mut g_j = 0.0;
864 let mut dg_j = 0.0;
865 for b in 0..m_k {
866 g_j += c[[a, b]] * target[off_k + b * p_out + o];
867 dg_j += d_c[[a, b]] * target[off_k + b * p_out + o]
868 + c[[a, b]] * v[off_k + b * p_out + o];
869 }
870 let bj = target[off_j + a * p_out + o];
871 let vj = v[off_j + a * p_out + o];
872 let hv = dg_j - (alpha + beta) * g_j - de_o_nj * bj
873 + e_o_nj * (2.0 * alpha + beta) * bj
874 - e_o_nj * vj;
875 out[off_j + a * p_out + o] += kappa * hv;
876 }
877 }
878 for b in 0..m_k {
879 for o in 0..p_out {
880 let mut g_k = 0.0;
881 let mut dg_k = 0.0;
882 for a in 0..m_j {
883 g_k += c[[a, b]] * target[off_j + a * p_out + o];
884 dg_k += d_c[[a, b]] * target[off_j + a * p_out + o]
885 + c[[a, b]] * v[off_j + a * p_out + o];
886 }
887 let bk = target[off_k + b * p_out + o];
888 let vk = v[off_k + b * p_out + o];
889 let hv = dg_k - (alpha + beta) * g_k - de_o_nk * bk
890 + e_o_nk * (2.0 * beta + alpha) * bk
891 - e_o_nk * vk;
892 out[off_k + b * p_out + o] += kappa * hv;
893 }
894 }
895 }
896 }
897 out
898 }
899
900 pub fn accumulate_psd_majorizer_dense(
922 &self,
923 target: ArrayView1<'_, f64>,
924 rho: ArrayView1<'_, f64>,
925 scale: f64,
926 hbb: &mut Array2<f64>,
927 ) {
928 if target.len() != self.target.len() {
929 return;
930 }
931 let offsets = self.block_offsets();
932 let weight = self.resolved_weight(rho);
933 let p = self.p_out;
934 for &(j, k, w_sym) in &self.pairs {
935 let off_j = offsets[j];
936 let off_k = offsets[k];
937 let m_j = self.block_sizes[j];
938 let m_k = self.block_sizes[k];
939 let nj = Self::block_norm_sq(target, off_j, m_j, self.p_out);
947 let nk = Self::block_norm_sq(target, off_k, m_k, self.p_out);
948 if !(nj > 0.0 && nk > 0.0) {
949 continue;
950 }
951 let w = w_sym * weight * scale / (nj * nk);
952 if w == 0.0 {
953 continue;
954 }
955 let mut g_j = vec![0.0_f64; p * p];
958 let mut g_k = vec![0.0_f64; p * p];
959 for o in 0..p {
960 for o2 in 0..p {
961 let mut sj = 0.0;
962 for a in 0..m_j {
963 sj += target[off_j + a * p + o] * target[off_j + a * p + o2];
964 }
965 g_j[o * p + o2] = sj;
966 let mut sk = 0.0;
967 for b in 0..m_k {
968 sk += target[off_k + b * p + o] * target[off_k + b * p + o2];
969 }
970 g_k[o * p + o2] = sk;
971 }
972 }
973 for a in 0..m_j {
975 let base = off_j + a * p;
976 for o in 0..p {
977 for o2 in 0..p {
978 hbb[[base + o, base + o2]] += w * g_k[o * p + o2];
979 }
980 }
981 }
982 for b in 0..m_k {
984 let base = off_k + b * p;
985 for o in 0..p {
986 for o2 in 0..p {
987 hbb[[base + o, base + o2]] += w * g_j[o * p + o2];
988 }
989 }
990 }
991 for a in 0..m_j {
994 for b in 0..m_k {
995 for o1 in 0..p {
996 let row_j = off_j + a * p + o1;
997 let bk_b_o1 = target[off_k + b * p + o1];
998 for o2 in 0..p {
999 let col_k = off_k + b * p + o2;
1000 let contrib = w * target[off_j + a * p + o2] * bk_b_o1;
1001 hbb[[row_j, col_k]] += contrib;
1002 hbb[[col_k, row_j]] += contrib;
1003 }
1004 }
1005 }
1006 }
1007 }
1008 }
1009}
1010
1011impl AnalyticPenalty for DecoderIncoherencePenalty {
1012 fn tier(&self) -> PenaltyTier {
1013 PenaltyTier::Beta
1014 }
1015
1016 fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
1017 if target.len() != self.target.len() {
1018 return 0.0;
1019 }
1020 let offsets = self.block_offsets();
1021 let mut acc = 0.0;
1022 for &(j, k, w_pair) in &self.pairs {
1023 {
1024 if w_pair == 0.0 {
1025 continue;
1026 }
1027 let nj = Self::block_norm_sq(target, offsets[j], self.block_sizes[j], self.p_out);
1033 let nk = Self::block_norm_sq(target, offsets[k], self.block_sizes[k], self.p_out);
1034 if !(nj > 0.0 && nk > 0.0) {
1035 continue;
1036 }
1037 let c = Self::cross_gram(
1038 target,
1039 offsets[j],
1040 self.block_sizes[j],
1041 offsets[k],
1042 self.block_sizes[k],
1043 self.p_out,
1044 );
1045 let mut frob_sq = 0.0;
1046 for &value in c.iter() {
1047 frob_sq += value * value;
1048 }
1049 acc += w_pair * frob_sq / (nj * nk);
1050 }
1051 }
1052 0.5 * self.resolved_weight(rho) * acc
1053 }
1054
1055 fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1056 let mut grad = Array1::<f64>::zeros(target.len());
1057 if target.len() != self.target.len() {
1058 return grad;
1059 }
1060 let offsets = self.block_offsets();
1061 let weight = self.resolved_weight(rho);
1062 for &(j, k, w_sym) in &self.pairs {
1063 {
1064 let w_pair = w_sym * weight;
1065 if w_pair == 0.0 {
1066 continue;
1067 }
1068 let off_j = offsets[j];
1069 let off_k = offsets[k];
1070 let m_j = self.block_sizes[j];
1071 let m_k = self.block_sizes[k];
1072 let nj = Self::block_norm_sq(target, off_j, m_j, self.p_out);
1073 let nk = Self::block_norm_sq(target, off_k, m_k, self.p_out);
1074 if !(nj > 0.0 && nk > 0.0) {
1075 continue;
1076 }
1077 let c = Self::cross_gram(target, off_j, m_j, off_k, m_k, self.p_out);
1078 let mut e = 0.0;
1079 for &value in c.iter() {
1080 e += value * value;
1081 }
1082 let inv_j = w_pair / (nj * nk);
1090 for a in 0..m_j {
1091 for o in 0..self.p_out {
1092 let mut s = 0.0;
1093 for b in 0..m_k {
1094 s += c[[a, b]] * target[off_k + b * self.p_out + o];
1095 }
1096 let radial = (e / nj) * target[off_j + a * self.p_out + o];
1097 grad[off_j + a * self.p_out + o] += inv_j * (s - radial);
1098 }
1099 }
1100 for b in 0..m_k {
1101 for o in 0..self.p_out {
1102 let mut s = 0.0;
1103 for a in 0..m_j {
1104 s += c[[a, b]] * target[off_j + a * self.p_out + o];
1105 }
1106 let radial = (e / nk) * target[off_k + b * self.p_out + o];
1107 grad[off_k + b * self.p_out + o] += inv_j * (s - radial);
1108 }
1109 }
1110 }
1111 }
1112 grad
1113 }
1114
1115 fn hvp(
1131 &self,
1132 target: ArrayView1<'_, f64>,
1133 rho: ArrayView1<'_, f64>,
1134 v: ArrayView1<'_, f64>,
1135 ) -> Array1<f64> {
1136 assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
1137 self.hvp_impl(target, rho, v, true)
1138 }
1139
1140 fn psd_majorizer_hvp(
1153 &self,
1154 target: ArrayView1<'_, f64>,
1155 rho: ArrayView1<'_, f64>,
1156 v: ArrayView1<'_, f64>,
1157 ) -> Array1<f64> {
1158 assert_eq!(
1159 target.len(),
1160 v.len(),
1161 "psd_majorizer_hvp dimension mismatch"
1162 );
1163 self.hvp_impl(target, rho, v, false)
1164 }
1165
1166 impl_learnable_weight_grad_rho!();
1172
1173 impl_learnable_weight_rho_count!();
1174 impl_learnable_weight_domain!(weight);
1175
1176 fn name(&self) -> &str {
1177 "decoder_incoherence"
1178 }
1179
1180 impl_scalar_apply_schedule!(weight);
1181}
1182
1183#[derive(Debug, Clone)]
1193pub struct OrthogonalityPenalty {
1194 pub target: PsiSlice,
1195 pub latent_dim: usize,
1196 pub weight: f64,
1199 pub n_eff: usize,
1202 pub learnable_weight: bool,
1203 pub rho_index: usize,
1204 pub weight_schedule: Option<ScalarWeightSchedule>,
1205}
1206
1207impl OrthogonalityPenalty {
1208 #[must_use = "build error must be handled"]
1209 pub fn new(
1210 target: PsiSlice,
1211 latent_dim: usize,
1212 weight: f64,
1213 n_eff: usize,
1214 learnable_weight: bool,
1215 ) -> Result<Self, String> {
1216 if latent_dim == 0 {
1217 return Err("OrthogonalityPenalty::new requires latent_dim > 0".to_string());
1218 }
1219 if !target.len().is_multiple_of(latent_dim) {
1220 return Err(format!(
1221 "OrthogonalityPenalty::new target length {} is not divisible by latent_dim {}",
1222 target.len(),
1223 latent_dim
1224 ));
1225 }
1226 let n_obs = target.len() / latent_dim;
1227 if n_obs < latent_dim {
1228 return Err(format!(
1229 "OrthogonalityPenalty::new requires n_obs >= latent_dim for a feasible \
1230 Stiefel target, got n_obs {n_obs} and latent_dim {latent_dim}"
1231 ));
1232 }
1233 if !(weight.is_finite() && weight > 0.0) {
1234 return Err(format!(
1235 "OrthogonalityPenalty::new requires finite weight > 0, got {weight}"
1236 ));
1237 }
1238 if n_eff == 0 {
1239 return Err("OrthogonalityPenalty::new requires n_eff > 0".to_string());
1240 }
1241 if n_eff != n_obs {
1242 return Err(format!(
1243 "OrthogonalityPenalty::new requires n_eff to match target rows, got \
1244 n_eff {n_eff} and target rows {n_obs}"
1245 ));
1246 }
1247 Ok(Self {
1248 target,
1249 latent_dim,
1250 weight,
1251 n_eff,
1252 learnable_weight,
1253 rho_index: 0,
1254 weight_schedule: None,
1255 })
1256 }
1257
1258 impl_with_weight_schedule!(weight);
1259
1260 fn resolved_weight(&self, rho: ArrayView1<'_, f64>) -> f64 {
1261 if self.learnable_weight {
1262 validated_learnable_weight(self.weight, rho[self.rho_index])
1263 } else {
1264 self.weight
1265 }
1266 }
1267
1268 pub(crate) fn scale(&self, rho: ArrayView1<'_, f64>) -> f64 {
1269 self.resolved_weight(rho) / self.n_eff as f64
1270 }
1271
1272 pub(crate) fn target_matrix<'a>(
1273 &self,
1274 target: ArrayView1<'a, f64>,
1275 ) -> Option<ArrayView2<'a, f64>> {
1276 let d = self.latent_dim;
1277 if !target.len().is_multiple_of(d) {
1278 assert_eq!(
1279 target.len() % d,
1280 0,
1281 "target length must be divisible by latent_dim"
1282 );
1283 return None;
1284 }
1285 let n_obs = target.len() / d;
1286 target.into_shape_with_order((n_obs, d)).ok()
1287 }
1288
1289 pub(crate) fn gram_minus_identity(t: ArrayView2<'_, f64>) -> Array2<f64> {
1290 let n_obs = t.nrows();
1291 let d = t.ncols();
1292 let mut gram = Array2::<f64>::zeros((d, d));
1293 for a in 0..d {
1294 for b in 0..d {
1295 let mut s = 0.0;
1296 for n in 0..n_obs {
1297 s += t[[n, a]] * t[[n, b]];
1298 }
1299 gram[[a, b]] = s;
1300 }
1301 gram[[a, a]] -= 1.0;
1302 }
1303 gram
1304 }
1305
1306 fn flatten_matrix(m: &Array2<f64>) -> Array1<f64> {
1307 let n_obs = m.nrows();
1308 let d = m.ncols();
1309 let mut out = Array1::<f64>::zeros(n_obs * d);
1310 for n in 0..n_obs {
1311 for a in 0..d {
1312 out[n * d + a] = m[[n, a]];
1313 }
1314 }
1315 out
1316 }
1317
1318 pub(crate) fn hvp_with_precomputed_m(
1319 &self,
1320 t: ArrayView2<'_, f64>,
1321 m: ArrayView2<'_, f64>,
1322 v: ArrayView2<'_, f64>,
1323 scale: f64,
1324 ) -> Array2<f64> {
1325 let n_obs = t.nrows();
1326 let d = t.ncols();
1327 assert_eq!(v.dim(), t.dim(), "hvp matrix dimension mismatch");
1328 assert_eq!(m.dim(), (d, d), "precomputed gram dimension mismatch");
1329 if v.dim() != t.dim() {
1330 return Array2::<f64>::zeros((n_obs, d));
1331 }
1332
1333 let mut vt_t_plus_tt_v = Array2::<f64>::zeros((d, d));
1334 for c in 0..d {
1335 for b in 0..d {
1336 let mut s = 0.0;
1337 for n in 0..n_obs {
1338 s += v[[n, c]] * t[[n, b]] + t[[n, c]] * v[[n, b]];
1339 }
1340 vt_t_plus_tt_v[[c, b]] = s;
1341 }
1342 }
1343
1344 let mut out = Array2::<f64>::zeros((n_obs, d));
1345 for n in 0..n_obs {
1346 for b in 0..d {
1347 let mut va = 0.0;
1348 let mut tb = 0.0;
1349 for c in 0..d {
1350 va += v[[n, c]] * m[[c, b]];
1351 tb += t[[n, c]] * vt_t_plus_tt_v[[c, b]];
1352 }
1353 out[[n, b]] = 2.0 * scale * (va + tb);
1354 }
1355 }
1356 out
1357 }
1358
1359 pub(crate) fn as_dense_with_precomputed_m(
1360 &self,
1361 t: ArrayView2<'_, f64>,
1362 m: ArrayView2<'_, f64>,
1363 scale: f64,
1364 ) -> Array2<f64> {
1365 let n_obs = t.nrows();
1366 let d = t.ncols();
1367 assert_eq!(m.dim(), (d, d), "precomputed gram dimension mismatch");
1368 if m.dim() != (d, d) {
1369 return Array2::<f64>::zeros((n_obs * d, n_obs * d));
1370 }
1371
1372 let mut dense = Array2::<f64>::zeros((n_obs * d, n_obs * d));
1373 let factor = 2.0 * scale;
1374 for row1 in 0..n_obs {
1375 for row2 in 0..n_obs {
1376 let mut row_dot = 0.0;
1377 for axis in 0..d {
1378 row_dot += t[[row1, axis]] * t[[row2, axis]];
1379 }
1380 for col1 in 0..d {
1381 let i = row1 * d + col1;
1382 for col2 in 0..d {
1383 let j = row2 * d + col2;
1384 let mut entry = t[[row1, col2]] * t[[row2, col1]];
1385 if row1 == row2 {
1386 entry += m[[col2, col1]];
1387 }
1388 if col1 == col2 {
1389 entry += row_dot;
1390 }
1391 dense[[i, j]] = factor * entry;
1392 }
1393 }
1394 }
1395 }
1396 dense
1397 }
1398}
1399
1400impl AnalyticPenalty for OrthogonalityPenalty {
1401 fn tier(&self) -> PenaltyTier {
1402 PenaltyTier::Psi
1403 }
1404
1405 fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
1406 let Some(t) = self.target_matrix(target) else {
1407 return 0.0;
1408 };
1409 let gram = Self::gram_minus_identity(t.view());
1410 let mut acc = 0.0;
1411 for &v in gram.iter() {
1412 acc += v * v;
1413 }
1414 0.5 * self.scale(rho) * acc
1415 }
1416
1417 fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1418 let Some(t) = self.target_matrix(target) else {
1422 return Array1::<f64>::zeros(target.len());
1423 };
1424 let gram = Self::gram_minus_identity(t.view());
1425 let n_obs = t.nrows();
1426 let d = t.ncols();
1427 let factor = 2.0 * self.scale(rho);
1428 let mut grad = Array2::<f64>::zeros((n_obs, d));
1429 for n in 0..n_obs {
1430 for a in 0..d {
1431 let mut s = 0.0;
1432 for b in 0..d {
1433 s += t[[n, b]] * gram[[b, a]];
1434 }
1435 grad[[n, a]] = factor * s;
1436 }
1437 }
1438 Self::flatten_matrix(&grad)
1439 }
1440
1441 fn hvp(
1442 &self,
1443 target: ArrayView1<'_, f64>,
1444 rho: ArrayView1<'_, f64>,
1445 v: ArrayView1<'_, f64>,
1446 ) -> Array1<f64> {
1447 assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
1448 if target.len() != v.len() {
1449 return Array1::<f64>::zeros(target.len());
1450 }
1451 let Some(t) = self.target_matrix(target) else {
1452 return Array1::<f64>::zeros(target.len());
1453 };
1454 let Some(v_mat) = self.target_matrix(v) else {
1455 return Array1::<f64>::zeros(target.len());
1456 };
1457 let m = Self::gram_minus_identity(t.view());
1458 let hv = self.hvp_with_precomputed_m(t.view(), m.view(), v_mat.view(), self.scale(rho));
1459 Self::flatten_matrix(&hv)
1460 }
1461
1462 impl_learnable_weight_grad_rho!();
1463
1464 impl_learnable_weight_rho_count!();
1465 impl_learnable_weight_domain!(weight);
1466
1467 fn name(&self) -> &str {
1468 "orthogonality"
1469 }
1470
1471 impl_scalar_apply_schedule!(weight);
1472}