Skip to main content

gam_problem/
penalty_coordinate.rs

1//! Neutral penalty-coordinate contract (moved from solver::reml::reml_outer_engine
2//! under #1521). The enum is pure data; its operators use only gam-problem's own
3//! dense linalg helpers, so hosting it here lets the criterion/solver layers share
4//! one definition without an upward edge into the engine.
5use crate::reml_contract_panic;
6use gam_linalg::dense;
7use ndarray::{Array1, Array2, ArrayView1, ArrayViewMut1};
8
9/// A rho-coordinate always contributes
10///
11///   A_k = λ_k S_k,
12///   S_k = R_k^T R_k.
13///
14/// For single-block/small problems it is fine to store the full-root `R_k`
15/// in the joint basis. For exact-joint multi-block paths that scaling is
16/// wasteful: the root is naturally block-local. This enum lets the unified
17/// evaluator consume both forms through one interface.
18#[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    /// Kronecker-factored penalty coordinate for tensor-product smooths.
39    ///
40    /// In the reparameterized (eigenbasis) representation, the penalty
41    /// `I ⊗ ... ⊗ S_k ⊗ ... ⊗ I` becomes `I ⊗ ... ⊗ Λ_k ⊗ ... ⊗ I`
42    /// where `Λ_k = diag(μ_{k,0}, ..., μ_{k,q_k-1})`.  This is diagonal
43    /// in each mode, so apply/quadratic/trace operations avoid O(p²).
44    KroneckerMarginal {
45        /// Marginal eigenvalues for ALL dimensions: `eigenvalues[j]` has length `q_j`.
46        eigenvalues: Vec<Array1<f64>>,
47        /// Which marginal dimension this penalty coordinate corresponds to.
48        dim_index: usize,
49        /// Marginal basis dimensions: `[q_0, ..., q_{d-1}]`.
50        marginal_dims: Vec<usize>,
51        /// Total joint dimension: `∏ q_j`.
52        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                // Rank = number of nonzero marginal eigenvalues for this dim,
137                // times the product of all other dims.
138                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    /// Borrow the canonical penalty root in its native block chart.
173    ///
174    /// The root rows are the authoritative structural range coordinates: their
175    /// count is `rank()` and must not be rediscovered by eigendecomposing the
176    /// squared Gram `RᵀR`, which can promote roundoff in a structural zero.
177    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    /// Restrict this penalty coordinate onto the free subspace spanned by the
193    /// orthonormal columns of `z` (shape `p × m`, `m ≤ p`, `zᵀz = I`).
194    ///
195    /// When a linear-inequality active set is non-empty, the inner solve and the
196    /// penalized Hessian are reduced to the free subspace `β = z β_f` of
197    /// dimension `m = p − active_set_size`. The penalty must move in lockstep:
198    /// the quadratic `βᵀ S_k β = β_fᵀ (zᵀ S_k z) β_f`, and since `S_k = R_kᵀ R_k`
199    /// the reduced root is `R_k z` (shape `rank_k × m`). For a block-local root
200    /// `R_k` acting on `β[start..end]` the same identity gives reduced dense root
201    /// `R_k · z[start..end, :]`, so the reduced coordinate is always a
202    /// (dimension-`m`) `DenseRoot` / `DenseRootCentered` — the block structure
203    /// does not survive an arbitrary subspace rotation. A centered mean `μ_k`
204    /// maps to `zᵀ μ_k`, the representation of `μ_k` in the free subspace.
205    ///
206    /// This keeps `dim()` equal to the reduced `beta.len()`, which
207    /// `InnerSolutionBuilder::build` asserts.
208    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                // Reduced mean: the block-local prior `μ_k` sits at
236                // `β[start..end]`; lift it into the full coordinate before
237                // projecting so the free-space mean is `zᵀ (E_block μ_k)`.
238                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                // No single root for Kronecker — use apply_penalty instead.
264                // SAFETY: `has_root()` returns `false` for the
265                // KroneckerMarginal variant (see the `matches!` block
266                // above); callers of `apply_root` are required to gate on
267                // `has_root()`, so reaching this arm means a caller
268                // invoked the rooted-only API on a rootless variant.
269                // SAFETY: KroneckerMarginal has no root; callers must gate on has_root() before apply_root.
270                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                // Outer arm guarantees only the four root-bearing variants reach here.
347                Self::KroneckerMarginal { .. } => {}
348            },
349            Self::KroneckerMarginal {
350                eigenvalues,
351                dim_index,
352                marginal_dims,
353                total_dim,
354            } => {
355                // Apply (I ⊗ ... ⊗ Λ_k ⊗ ... ⊗ I) β via mode-k scaling.
356                // In the eigenbasis, Λ_k is diagonal, so this is element-wise.
357                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                // β' (I ⊗ ... ⊗ Λ_k ⊗ ... ⊗ I) β = Σ μ_{k,j} β[...]²
407                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(&centered);
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(&centered);
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(&centered);
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(&centered);
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                // Materialize diagonal penalty in eigenbasis.
524                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    /// Returns the block-local scaled penalty matrix (p_block × p_block) along
557    /// with the embedding range, WITHOUT materializing into total_dim × total_dim.
558    /// For DenseRoot (full-rank, no block structure), returns (matrix, 0, p).
559    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                // Fallback: materialize full matrix.
579                let mat = self.scaled_dense_matrix(scale);
580                (mat, 0, *total_dim)
581            }
582        }
583    }
584
585    /// Whether this coordinate has block structure (not full-rank dense).
586    pub fn is_block_local(&self) -> bool {
587        matches!(
588            self,
589            Self::BlockRoot { .. }
590                | Self::BlockRootCentered { .. }
591                | Self::KroneckerMarginal { .. }
592        )
593    }
594
595    /// Apply λ_k S_k to a vector v without materializing the full matrix.
596    /// For BlockRoot: extracts v[start..end], multiplies by local S_k, embeds result.
597    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                // Reuse apply_penalty which handles mode-k contraction.
622                self.apply_penalty(v, scale)
623            }
624        }
625    }
626
627    /// A stable, formula-order-independent signature of this penalty
628    /// coordinate's STRUCTURAL CONTENT.
629    ///
630    /// Two penalty coordinates that represent the same smoothing structure —
631    /// the same wiggliness root, the same null-space ridge, the same tensor
632    /// margin — produce the same key regardless of which block of the joint
633    /// coefficient vector they happen to occupy or which order the user typed
634    /// the terms in. It is derived ENTIRELY from rotation/placement-invariant
635    /// content (rank, block width, the spectrum of the block-local penalty
636    /// `Sₖ = RₖᵀRₖ`, or the marginal eigenvalue spectrum for a Kronecker
637    /// margin), and NEVER from a coordinate's position (`start`/`dim_index`)
638    /// in the joint layout. Swapping `s(x)+s(z)` ↔ `s(z)+s(x)` or
639    /// `te(x,z)` ↔ `te(z,x)` permutes the coordinates but leaves each
640    /// coordinate's key fixed.
641    ///
642    /// This is the key the outer REML driver sorts on to present an identical
643    /// canonical coordinate layout to the smoothing-parameter optimizer
644    /// regardless of term/margin order, so the flat double-penalty REML valley
645    /// is resolved order-invariantly (#1538/#1539). Values are quantized to a
646    /// coarse relative grid so that floating-point round-off in the roots does
647    /// not split an otherwise-identical key.
648    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        // Quantize a magnitude to a coarse log-relative grid so tiny numeric
653        // differences in equivalent roots collapse to the same bucket, while
654        // genuinely different roughness scales stay distinct.
655        let quant = |v: f64| -> i64 {
656            if !v.is_finite() || v.abs() <= 1e-300 {
657                return 0;
658            }
659            // ~1e-6 relative resolution: round log|v| to 6 decimals and keep sign.
660            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                // Tag the rooted family uniformly: placement (start/end/total)
670                // is deliberately excluded so a block that moves between term
671                // orders keeps its key. The spectrum of Sₖ = RₖᵀRₖ is the
672                // rotation-invariant fingerprint of the penalty.
673                0u8.hash(&mut hasher);
674                root.nrows().hash(&mut hasher); // rank
675                root.ncols().hash(&mut hasher); // block width
676                let sk = root.t().dot(root);
677                // Orthogonal-invariants of the symmetric Sₖ = RₖᵀRₖ: the power
678                // sums Σλ (trace), Σλ² (= ‖Sₖ‖²_F), Σλ³ (tr(Sₖ³)). Each is a
679                // symmetric function of Sₖ's eigenvalues, so they are unchanged
680                // by any orthonormal change of basis of the block coordinates
681                // (hence by which joint block the penalty occupies) and by the
682                // order of the terms. Together with rank and width they form a
683                // strong placement-independent fingerprint without an
684                // eigendecomposition.
685                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>(); // = Σλ²
688                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                // Power sums are already order-agnostic; sorting is a harmless
695                // guard against any future addition of non-symmetric summaries.
696                invariants.sort_unstable();
697                invariants.hash(&mut hasher);
698            }
699            Self::KroneckerMarginal {
700                eigenvalues,
701                dim_index,
702                marginal_dims,
703                ..
704            } => {
705                // A tensor margin's identity is its OWN marginal penalty
706                // spectrum plus the (sorted) set of marginal dimensions — both
707                // independent of which slot `dim_index` the margin occupies, so
708                // `te(x,z)` and `te(z,x)` give each margin the same key.
709                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    // ── constructors ──────────────────────────────────────────────────────────
734
735    #[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    // ── rank() and dim() ──────────────────────────────────────────────────────
766
767    #[test]
768    fn dense_root_rank_is_nrows_dim_is_ncols() {
769        // root is 4 × 3
770        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    // ── uses_operator_fast_path ───────────────────────────────────────────────
784
785    #[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    // ── apply_penalty ─────────────────────────────────────────────────────────
799
800    #[test]
801    fn dense_identity_root_penalty_is_beta() {
802        // S = I^T I = I, so S β = β
803        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}