Skip to main content

gam_sae/
frames.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2
3use crate::manifold::SaeManifoldTerm;
4use gam_linalg::faer_ndarray::{FaerSvd, fast_ab, fast_abt, fast_atb};
5
6/// Canonical orthonormal row frame spanning a requested ambient-output
7/// subspace (#2260).
8///
9/// `raw` is `(r, p)`. Its rows may be scaled or non-orthogonal, but must have
10/// numerical row rank `r`. The returned `(r, p)` rows are orthonormal and use
11/// [`GrassmannFrame`]'s deterministic sign gauge. Rank is certified against the
12/// standard SVD backward-error scale `eps * max(r, p) * sigma_max`; a deficient
13/// request is rejected rather than completed with arbitrary orthogonal vectors.
14pub fn canonical_output_subspace_rows(raw: ArrayView2<'_, f64>) -> Result<Array2<f64>, String> {
15    let (r, p) = raw.dim();
16    if r == 0 || p == 0 {
17        return Err("canonical_output_subspace_rows: frame must be non-empty".to_string());
18    }
19    if r > p {
20        return Err(format!(
21            "canonical_output_subspace_rows: rank r={r} cannot exceed output dimension p={p}"
22        ));
23    }
24    if raw.iter().any(|value| !value.is_finite()) {
25        return Err("canonical_output_subspace_rows: frame must be finite".to_string());
26    }
27
28    let frame = GrassmannFrame::polar_update(raw.t())?;
29    let singular_values = frame.gauge_singular_values();
30    let rank_resolution = f64::EPSILON * r.max(p) as f64 * singular_values[0];
31    if singular_values[r - 1] <= rank_resolution {
32        return Err(format!(
33            "canonical_output_subspace_rows: requested {r}-row frame is rank deficient \
34             (sigma_min={}, numerical resolution={rank_resolution})",
35            singular_values[r - 1]
36        ));
37    }
38    Ok(frame.frame().t().to_owned())
39}
40
41#[derive(Clone, Debug)]
42pub(crate) struct FrameProjection {
43    pub(crate) p: usize,
44    pub(crate) beta_offsets: Vec<usize>,
45    pub(crate) border_offsets: Vec<usize>,
46    pub(crate) basis_sizes: Vec<usize>,
47    pub(crate) ranks: Vec<usize>,
48    frames: Vec<Option<Array2<f64>>>,
49}
50
51impl FrameProjection {
52    pub(crate) fn new(term: &SaeManifoldTerm) -> Self {
53        Self {
54            p: term.output_dim(),
55            beta_offsets: term.beta_offsets(),
56            border_offsets: term.factored_border_offsets(),
57            basis_sizes: term.atoms.iter().map(|atom| atom.basis_size()).collect(),
58            ranks: term
59                .atoms
60                .iter()
61                .map(|atom| atom.border_frame_rank())
62                .collect(),
63            frames: term
64                .atoms
65                .iter()
66                .map(|atom| {
67                    atom.decoder_frame
68                        .as_ref()
69                        .map(|frame| frame.frame().to_owned())
70                })
71                .collect(),
72        }
73    }
74
75    pub(crate) fn beta_dim(&self) -> usize {
76        self.basis_sizes.iter().sum::<usize>() * self.p
77    }
78
79    /// Owned per-atom output frames (`U_k`, `p × r_k`), `None` for an unframed
80    /// atom (`U_k = I_p`). Handed to the #974 whitened factored β-Hessian
81    /// operator so it can expand the factored coordinates through each frame.
82    pub(crate) fn frames_owned(&self) -> Vec<Option<Array2<f64>>> {
83        self.frames.clone()
84    }
85
86    pub(crate) fn border_dim(&self) -> usize {
87        self.basis_sizes
88            .iter()
89            .zip(&self.ranks)
90            .map(|(m, r)| m * r)
91            .sum()
92    }
93
94    pub(crate) fn lift_border_vec(&self, border: ArrayView1<'_, f64>) -> Array1<f64> {
95        let mut out = Array1::<f64>::zeros(self.beta_dim());
96        for atom in 0..self.basis_sizes.len() {
97            self.lift_atom_vec_into(atom, border, out.view_mut());
98        }
99        out
100    }
101
102    pub(crate) fn project_border_vec(&self, beta: ArrayView1<'_, f64>) -> Array1<f64> {
103        let mut out = Array1::<f64>::zeros(self.border_dim());
104        for atom in 0..self.basis_sizes.len() {
105            self.project_atom_vec_into(atom, beta, out.view_mut(), 1.0);
106        }
107        out
108    }
109
110    pub(crate) fn lift_block(&self, atom: usize, block: ArrayView2<'_, f64>) -> Array2<f64> {
111        let m = self.basis_sizes[atom];
112        let r = self.ranks[atom];
113        if self.frames[atom].is_none() {
114            return block.to_owned();
115        }
116        let uk = self.frames[atom].as_ref().expect("framed atom has a frame");
117        let mut out = Array2::<f64>::zeros((m * self.p, m * self.p));
118        for b1 in 0..m {
119            for b2 in 0..m {
120                for c1 in 0..self.p {
121                    for c2 in 0..self.p {
122                        let mut acc = 0.0;
123                        for j1 in 0..r {
124                            for j2 in 0..r {
125                                acc +=
126                                    uk[[c1, j1]] * block[[b1 * r + j1, b2 * r + j2]] * uk[[c2, j2]];
127                            }
128                        }
129                        out[[b1 * self.p + c1, b2 * self.p + c2]] = acc;
130                    }
131                }
132            }
133        }
134        out
135    }
136
137    pub(crate) fn project_block(&self, hbb: ArrayView2<'_, f64>) -> Array2<f64> {
138        let t = self.project_rows(hbb);
139        let mut out = Array2::<f64>::zeros((self.border_dim(), self.border_dim()));
140        for atom in 0..self.basis_sizes.len() {
141            self.project_block_left_atom(atom, t.view(), out.view_mut());
142        }
143        out
144    }
145
146    pub(crate) fn project_rows(&self, block: ArrayView2<'_, f64>) -> Array2<f64> {
147        let mut out = Array2::<f64>::zeros((block.nrows(), self.border_dim()));
148        for row in 0..block.nrows() {
149            let projected = self.project_border_vec(block.row(row));
150            out.row_mut(row).assign(&projected);
151        }
152        out
153    }
154
155    pub(crate) fn atom_border_range(&self, atom: usize) -> std::ops::Range<usize> {
156        let start = self.border_offsets[atom];
157        start..start + self.basis_sizes[atom] * self.ranks[atom]
158    }
159
160    pub(crate) fn lift_axis_into(
161        &self,
162        out: &mut Array1<f64>,
163        atom: usize,
164        basis_col: usize,
165        frame_col: usize,
166    ) {
167        let base = self.beta_offsets[atom] + basis_col * self.p;
168        match &self.frames[atom] {
169            None => out[base + frame_col] = 1.0,
170            Some(uk) => {
171                for out_col in 0..self.p {
172                    out[base + out_col] = uk[[out_col, frame_col]];
173                }
174            }
175        }
176    }
177
178    pub(crate) fn lift_local_axis_into(
179        &self,
180        out: &mut Array1<f64>,
181        atom: usize,
182        basis_col: usize,
183        frame_col: usize,
184    ) {
185        let base = basis_col * self.p;
186        match &self.frames[atom] {
187            None => out[base + frame_col] = 1.0,
188            Some(uk) => {
189                for out_col in 0..self.p {
190                    out[base + out_col] = uk[[out_col, frame_col]];
191                }
192            }
193        }
194    }
195
196    pub(crate) fn project_atom_vec_into(
197        &self,
198        atom: usize,
199        beta: ArrayView1<'_, f64>,
200        mut out: ndarray::ArrayViewMut1<'_, f64>,
201        scale: f64,
202    ) {
203        let m = self.basis_sizes[atom];
204        let r = self.ranks[atom];
205        let ob = self.beta_offsets[atom];
206        let oc = self.border_offsets[atom];
207        for basis_col in 0..m {
208            let base_b = ob + basis_col * self.p;
209            let base_c = oc + basis_col * r;
210            match &self.frames[atom] {
211                None => {
212                    for j in 0..r {
213                        out[base_c + j] += scale * beta[base_b + j];
214                    }
215                }
216                Some(uk) => {
217                    for j in 0..r {
218                        let mut acc = 0.0;
219                        for i in 0..self.p {
220                            acc += uk[[i, j]] * beta[base_b + i];
221                        }
222                        out[base_c + j] += scale * acc;
223                    }
224                }
225            }
226        }
227    }
228
229    pub(crate) fn project_local_atom_vec_into(
230        &self,
231        atom: usize,
232        beta: ArrayView1<'_, f64>,
233        out: ndarray::ArrayViewMut1<'_, f64>,
234        scale: f64,
235    ) {
236        self.project_atom_vec_into_with_base(atom, beta, out, scale, 0);
237    }
238
239    pub(crate) fn project_atom_vec_into_with_base(
240        &self,
241        atom: usize,
242        beta: ArrayView1<'_, f64>,
243        mut out: ndarray::ArrayViewMut1<'_, f64>,
244        scale: f64,
245        beta_base_offset: usize,
246    ) {
247        let m = self.basis_sizes[atom];
248        let r = self.ranks[atom];
249        let oc = self.border_offsets[atom];
250        for basis_col in 0..m {
251            let base_b = beta_base_offset + basis_col * self.p;
252            let base_c = oc + basis_col * r;
253            match &self.frames[atom] {
254                None => {
255                    for j in 0..r {
256                        out[base_c + j] += scale * beta[base_b + j];
257                    }
258                }
259                Some(uk) => {
260                    for j in 0..r {
261                        let mut acc = 0.0;
262                        for i in 0..self.p {
263                            acc += uk[[i, j]] * beta[base_b + i];
264                        }
265                        out[base_c + j] += scale * acc;
266                    }
267                }
268            }
269        }
270    }
271
272    pub(crate) fn lift_atom_vec_into(
273        &self,
274        atom: usize,
275        border: ArrayView1<'_, f64>,
276        mut out: ndarray::ArrayViewMut1<'_, f64>,
277    ) {
278        let m = self.basis_sizes[atom];
279        let r = self.ranks[atom];
280        let ob = self.beta_offsets[atom];
281        let oc = self.border_offsets[atom];
282        for basis_col in 0..m {
283            let base_b = ob + basis_col * self.p;
284            let base_c = oc + basis_col * r;
285            match &self.frames[atom] {
286                None => {
287                    for i in 0..self.p {
288                        out[base_b + i] = border[base_c + i];
289                    }
290                }
291                Some(uk) => {
292                    for i in 0..self.p {
293                        let mut acc = 0.0;
294                        for j in 0..r {
295                            acc += uk[[i, j]] * border[base_c + j];
296                        }
297                        out[base_b + i] = acc;
298                    }
299                }
300            }
301        }
302    }
303
304    pub(crate) fn accumulate_output_project(
305        &self,
306        atom: usize,
307        c_base: usize,
308        output: usize,
309        value: f64,
310        out: &mut [f64],
311    ) {
312        match &self.frames[atom] {
313            None => out[c_base + output] += value,
314            Some(uk) => {
315                let rank = self.ranks[atom];
316                let frame_row = uk.row(output);
317                let frame_slice = frame_row.as_slice().expect("frame rows are contiguous");
318                let out_slice = &mut out[c_base..c_base + rank];
319                for (slot, &u) in out_slice.iter_mut().zip(frame_slice.iter()) {
320                    *slot += value * u;
321                }
322            }
323        }
324    }
325
326    /// Project a `(q × p)` block of t-row Jacobian rows through atom `atom`'s
327    /// frame in ONE GEMM (`J·U_k`, `q × rank`), or `None` for an unframed atom
328    /// (identity frame — callers use the raw rows directly). The projection is
329    /// basis-column independent, so hot assembly loops hoist this out of their
330    /// per-basis-column scans instead of re-deriving it one scalar
331    /// [`Self::accumulate_output_project`] call at a time.
332    pub(crate) fn project_jacobian_rows(
333        &self,
334        atom: usize,
335        jac: ArrayView2<'_, f64>,
336    ) -> Option<Array2<f64>> {
337        self.frames[atom].as_ref().map(|uk| fast_ab(&jac, uk))
338    }
339
340    pub(crate) fn output_variance(
341        &self,
342        atom: usize,
343        cov_c: ArrayView2<'_, f64>,
344        basis: ArrayView1<'_, f64>,
345        output: usize,
346    ) -> f64 {
347        let Some(uk) = &self.frames[atom] else {
348            return self.full_output_variance(atom, cov_c, basis, output);
349        };
350        let m = self.basis_sizes[atom];
351        let r = self.ranks[atom];
352        let mut var = 0.0;
353        for b1 in 0..m {
354            let phi1 = basis[b1];
355            if phi1 == 0.0 {
356                continue;
357            }
358            for b2 in 0..m {
359                let phi2 = basis[b2];
360                if phi2 == 0.0 {
361                    continue;
362                }
363                for j1 in 0..r {
364                    for j2 in 0..r {
365                        var += phi1
366                            * phi2
367                            * uk[[output, j1]]
368                            * cov_c[[b1 * r + j1, b2 * r + j2]]
369                            * uk[[output, j2]];
370                    }
371                }
372            }
373        }
374        var
375    }
376
377    pub(crate) fn full_output_variance(
378        &self,
379        atom: usize,
380        cov: ArrayView2<'_, f64>,
381        basis: ArrayView1<'_, f64>,
382        output: usize,
383    ) -> f64 {
384        let m = self.basis_sizes[atom];
385        let mut var = 0.0;
386        for b1 in 0..m {
387            let phi1 = basis[b1];
388            if phi1 == 0.0 {
389                continue;
390            }
391            for b2 in 0..m {
392                var += phi1 * basis[b2] * cov[[b1 * self.p + output, b2 * self.p + output]];
393            }
394        }
395        var
396    }
397
398    pub(crate) fn project_block_left_atom(
399        &self,
400        atom: usize,
401        t: ArrayView2<'_, f64>,
402        mut out: ndarray::ArrayViewMut2<'_, f64>,
403    ) {
404        let m = self.basis_sizes[atom];
405        let r = self.ranks[atom];
406        let ob = self.beta_offsets[atom];
407        let oc = self.border_offsets[atom];
408        for basis_col in 0..m {
409            let base_b = ob + basis_col * self.p;
410            let base_c = oc + basis_col * r;
411            match &self.frames[atom] {
412                None => {
413                    for j in 0..r {
414                        for c in 0..out.ncols() {
415                            out[[base_c + j, c]] += t[[base_b + j, c]];
416                        }
417                    }
418                }
419                Some(uk) => {
420                    for j in 0..r {
421                        for c in 0..out.ncols() {
422                            let mut acc = 0.0;
423                            for i in 0..self.p {
424                                acc += uk[[i, j]] * t[[base_b + i, c]];
425                            }
426                            out[[base_c + j, c]] += acc;
427                        }
428                    }
429                }
430            }
431        }
432    }
433}
434
435/// Build the frames-engaged device SAE PCG data (issue #1017/#1026): the
436/// factored-border analogue of the full-`B` `DeviceSaePcgData`. The penalty side
437/// carries the smooth `λ S_k ⊗ I_{r_k}` blocks (right-width `r_k`, at `off_c[k]`)
438/// and the data-fit `G_{ij} ⊗ W_{ij}` blocks; the reduced-Schur side carries the
439/// per-row DENSE cross-block `H_tβ^(i)` as a row-major `q_i × border_dim` slab.
440///
441/// `args.frame_blocks` are the same `(g, w)` blocks fed to
442/// `FactoredFrameKroneckerOp`, snapshotted before that op consumed them.
443/// `args.smooth_scaled_s[k]` is `λ S_k` (`M_k × M_k`). A row whose `htbeta` is
444/// not at the factored width contributes an empty slab (reduced-Schur term zero).
445pub(crate) struct FramedDeviceArgs<'a> {
446    pub p: usize,
447    pub border_dim: usize,
448    pub border_offsets: &'a [usize],
449    pub ranks: &'a [usize],
450    pub basis_sizes: &'a [usize],
451    pub smooth_scaled_s: &'a [Array2<f64>],
452    pub frame_blocks: Vec<gam_solve::arrow_schur::FactoredFrameGBlock>,
453    pub rows: &'a [gam_solve::arrow_schur::ArrowRowBlock],
454}
455
456pub(crate) fn build_framed_device_sae_data(
457    args: FramedDeviceArgs<'_>,
458) -> gam_solve::arrow_schur::DeviceSaePcgData {
459    use gam_solve::arrow_schur::{DeviceSaeFrameData, DeviceSaePcgData, DeviceSaeSmoothBlock};
460    let FramedDeviceArgs {
461        p,
462        border_dim,
463        border_offsets,
464        ranks,
465        basis_sizes,
466        smooth_scaled_s,
467        frame_blocks,
468        rows,
469    } = args;
470    let n_atoms = ranks.len();
471    let mut smooth_blocks = Vec::with_capacity(n_atoms);
472    let mut smooth_ranks = Vec::with_capacity(n_atoms);
473    for k in 0..n_atoms {
474        smooth_blocks.push(DeviceSaeSmoothBlock {
475            global_offset: border_offsets[k],
476            factor_a: smooth_scaled_s[k].clone(),
477        });
478        smooth_ranks.push(ranks[k]);
479    }
480    let row_htbeta: Vec<Vec<f64>> = rows
481        .iter()
482        .map(|row| {
483            let (qi, w) = row.htbeta.dim();
484            if w != border_dim {
485                return Vec::new();
486            }
487            let mut flat = vec![0.0_f64; qi * w];
488            for c in 0..qi {
489                for a in 0..w {
490                    flat[c * w + a] = row.htbeta[[c, a]];
491                }
492            }
493            flat
494        })
495        .collect();
496    DeviceSaePcgData {
497        p,
498        beta_dim: border_dim,
499        // #1033: empty shared slices — the frames path carries its cross-block
500        // through `frame.frame_blocks`, not the full-`B` `a_phi`/`local_jac`.
501        a_phi: std::sync::Arc::from(Vec::new().into_boxed_slice()),
502        local_jac: std::sync::Arc::from(Vec::new().into_boxed_slice()),
503        smooth_blocks,
504        sparse_g_blocks: Vec::new(),
505        frame: Some(DeviceSaeFrameData {
506            ranks: ranks.to_vec(),
507            basis_sizes: basis_sizes.to_vec(),
508            border_offsets: border_offsets.to_vec(),
509            frame_blocks,
510            smooth_ranks,
511            row_htbeta,
512        }),
513    }
514}
515
516/// Build current framed device operands while refilling a uniquely owned prior
517/// descriptor in place when its framed layout remains usable. In particular,
518/// each row's dense `H_tβ` vector is resized and overwritten directly from the
519/// production [`gam_solve::arrow_schur::ArrowRowBlock`] instead of allocating a
520/// second full row-slab tree and copying it into the retained descriptor
521/// afterward.
522pub(crate) fn build_framed_device_sae_data_reusing(
523    args: FramedDeviceArgs<'_>,
524    recycled: Option<std::sync::Arc<gam_solve::arrow_schur::DeviceSaePcgData>>,
525) -> std::sync::Arc<gam_solve::arrow_schur::DeviceSaePcgData> {
526    if let Some(mut allocation) = recycled {
527        if let Some(data) = std::sync::Arc::get_mut(&mut allocation) {
528            if data.frame.is_some() {
529                refresh_framed_device_sae_data(data, args);
530                return allocation;
531            }
532        }
533        return std::sync::Arc::new(build_framed_device_sae_data(args));
534    }
535    std::sync::Arc::new(build_framed_device_sae_data(args))
536}
537
538fn refresh_framed_device_sae_data(
539    data: &mut gam_solve::arrow_schur::DeviceSaePcgData,
540    args: FramedDeviceArgs<'_>,
541) {
542    let FramedDeviceArgs {
543        p,
544        border_dim,
545        border_offsets,
546        ranks,
547        basis_sizes,
548        smooth_scaled_s,
549        frame_blocks,
550        rows,
551    } = args;
552    data.p = p;
553    data.beta_dim = border_dim;
554    if !data.a_phi.is_empty() {
555        data.a_phi = std::sync::Arc::from(Vec::new().into_boxed_slice());
556    }
557    if !data.local_jac.is_empty() {
558        data.local_jac = std::sync::Arc::from(Vec::new().into_boxed_slice());
559    }
560    if data.smooth_blocks.len() == smooth_scaled_s.len() {
561        for (atom_idx, (block, source)) in data
562            .smooth_blocks
563            .iter_mut()
564            .zip(smooth_scaled_s)
565            .enumerate()
566        {
567            block.global_offset = border_offsets[atom_idx];
568            block.factor_a.clone_from(source);
569        }
570    } else {
571        data.smooth_blocks = smooth_scaled_s
572            .iter()
573            .enumerate()
574            .map(
575                |(atom_idx, source)| gam_solve::arrow_schur::DeviceSaeSmoothBlock {
576                    global_offset: border_offsets[atom_idx],
577                    factor_a: source.clone(),
578                },
579            )
580            .collect();
581    }
582    data.sparse_g_blocks.clear();
583
584    let frame = data
585        .frame
586        .as_mut()
587        .expect("framed descriptor checked before refresh");
588    frame.ranks.clear();
589    frame.ranks.extend_from_slice(ranks);
590    frame.basis_sizes.clear();
591    frame.basis_sizes.extend_from_slice(basis_sizes);
592    frame.border_offsets.clear();
593    frame.border_offsets.extend_from_slice(border_offsets);
594    let stable_frame_block_layout = frame.frame_blocks.len() == frame_blocks.len()
595        && frame
596            .frame_blocks
597            .iter()
598            .zip(&frame_blocks)
599            .all(|(current, source)| {
600                current.g.dim() == source.g.dim() && current.w.dim() == source.w.dim()
601            });
602    if stable_frame_block_layout {
603        for (current, source) in frame.frame_blocks.iter_mut().zip(frame_blocks) {
604            current.atom_i = source.atom_i;
605            current.atom_j = source.atom_j;
606            current.g.assign(&source.g);
607            current.w.assign(&source.w);
608        }
609    } else {
610        frame.frame_blocks = frame_blocks;
611    }
612    frame.smooth_ranks.clear();
613    frame.smooth_ranks.extend_from_slice(ranks);
614    frame.row_htbeta.resize_with(rows.len(), Vec::new);
615    for (target, row) in frame.row_htbeta.iter_mut().zip(rows) {
616        let (qi, width) = row.htbeta.dim();
617        if width != border_dim {
618            target.clear();
619            continue;
620        }
621        target.resize(qi * width, 0.0);
622        if let Some(source) = row.htbeta.as_slice() {
623            target.copy_from_slice(source);
624        } else {
625            for c in 0..qi {
626                for a in 0..width {
627                    target[c * width + a] = row.htbeta[[c, a]];
628                }
629            }
630        }
631    }
632}
633
634/// Relative spectral cutoff used when the Grassmann-frame factorization decides
635/// the effective column rank `r` of an atom's decoder `B_k` (issue #972). A
636/// singular value of `B_k` below `cutoff · σ_max` carries `< (σ/σ_max)²` of the
637/// decoder energy and is dropped from the profiled frame.
638pub(crate) const SAE_FRAME_RANK_CUTOFF: f64 = 1.0e-7;
639
640/// Small ambient decoders stay on the full-`B` path. Below this width the dense
641/// decoder border is cheap, while auto-profiling a cold low-rank decoder changes
642/// the β coordinates before the inner solve has learned the output span.
643pub(crate) const SAE_FRAME_MIN_AUTO_OUTPUT_DIM: usize = 12;
644
645/// Border-saving threshold for auto-activating the low-rank Grassmann
646/// factorization (issue #972). The factored border holds `Σ_k M_k · r` instead
647/// of `Σ_k M_k · p`, so factorization is beneficial only when the chosen frame
648/// rank `r` is materially smaller than the ambient output dimension `p`. We
649/// require `r ≤ p · (1 − margin)` (frame must shrink the per-atom border by at
650/// least this fraction) AND a positive absolute gap `p − r ≥ 1`, so a full-rank
651/// atom (`r == p`) never pays the polar-step / frame-storage cost for zero
652/// border saving and stays bit-for-bit on the historical full-`B` path.
653pub(crate) const SAE_FRAME_ACTIVATION_MARGIN: f64 = 0.25;
654
655/// A Grassmann point: a `p × r` column-orthonormal FRAME `U` spanning an atom's
656/// decoder column space (issue #972).
657///
658/// The decoder coefficient matrix `B_k` (`M_k × p`) factors as `B_k = C_k · Uᵀ`
659/// where `C_k` (`M_k × r`) is the coordinate matrix that lives IN the
660/// arrow-Schur border and `U` (`p × r`) is this frame, profiled OUT of the
661/// border by closed-form streaming polar steps. The border then carries only
662/// `Σ_k M_k · r` coefficients rather than `Σ_k M_k · p` — the reduction that
663/// keeps the border Cholesky / evidence log-det tractable at frontier `p`.
664///
665/// **Canonical inner gauge.** `U` is only defined up to a right `r × r`
666/// orthogonal rotation `U → U R` (with the matching `C_k → C_k R`); the column
667/// span (the Grassmann point) is invariant. For deterministic serialization we
668/// pin a canonical representative: the frame is the left-singular subspace of
669/// the cross-moment, ordered by descending singular value, with each column's
670/// sign fixed so its largest-magnitude entry is non-negative. The ordering is
671/// recorded by the `gauge_singular_values` field so the same span always
672/// serializes to the same bytes (no run-to-run rotation drift).
673#[derive(Debug, Clone)]
674pub struct GrassmannFrame {
675    /// Column-orthonormal frame `U`, shape `(p, r)` with `Uᵀ U = I_r`.
676    frame: Array2<f64>,
677    /// Singular values of the most recent cross-moment used to build `U`,
678    /// descending, length `r`. The canonical ordering gauge (issue #972).
679    gauge_singular_values: Array1<f64>,
680}
681
682impl GrassmannFrame {
683    /// Ambient output dimension `p`.
684    pub fn output_dim(&self) -> usize {
685        self.frame.nrows()
686    }
687
688    /// Frame rank `r` (number of profiled column directions).
689    pub fn rank(&self) -> usize {
690        self.frame.ncols()
691    }
692
693    /// Canonical descending singular values of the cross-moment that fixed this
694    /// frame's column ordering (issue #972). Exposed so the serialization /
695    /// canonicalization path can read the recorded gauge and reproduce the same
696    /// span byte-for-byte (no run-to-run rotation drift).
697    pub fn gauge_singular_values(&self) -> &Array1<f64> {
698        &self.gauge_singular_values
699    }
700
701    /// Read-only view of the orthonormal frame `U` (`p × r`).
702    pub fn frame(&self) -> ArrayView2<'_, f64> {
703        self.frame.view()
704    }
705
706    /// Build a canonical-gauge frame from an already column-orthonormal matrix.
707    ///
708    /// This is for callers that obtained the left image frame through an
709    /// equivalent covariance/eigendecomposition path rather than a direct thin
710    /// SVD. The supplied gauge values must be finite, non-negative, descending,
711    /// and have one value per frame column.
712    pub fn from_orthonormal(
713        frame: Array2<f64>,
714        gauge_singular_values: Array1<f64>,
715    ) -> Result<Self, String> {
716        let (p, r) = frame.dim();
717        if p == 0 || r == 0 {
718            return Err("GrassmannFrame::from_orthonormal: frame must be non-empty".to_string());
719        }
720        if r > p {
721            return Err(format!(
722                "GrassmannFrame::from_orthonormal: frame rank r={r} cannot exceed output dim p={p}"
723            ));
724        }
725        if gauge_singular_values.len() != r {
726            return Err(format!(
727                "GrassmannFrame::from_orthonormal: gauge length {} must equal rank {r}",
728                gauge_singular_values.len()
729            ));
730        }
731        for i in 0..r {
732            let value = gauge_singular_values[i];
733            if !(value.is_finite() && value >= 0.0) {
734                return Err(format!(
735                    "GrassmannFrame::from_orthonormal: gauge value {i} must be finite and non-negative, got {value}"
736                ));
737            }
738            if i > 0 && gauge_singular_values[i - 1] < value {
739                return Err(
740                    "GrassmannFrame::from_orthonormal: gauge values must be descending".to_string(),
741                );
742            }
743        }
744        let tol = 1.0e-8_f64;
745        for a in 0..r {
746            for b in a..r {
747                let mut dot = 0.0_f64;
748                for row in 0..p {
749                    dot += frame[[row, a]] * frame[[row, b]];
750                }
751                let target = if a == b { 1.0 } else { 0.0 };
752                if (dot - target).abs() > tol {
753                    return Err(format!(
754                        "GrassmannFrame::from_orthonormal: frame columns are not orthonormal at ({a}, {b}); dot={dot}"
755                    ));
756                }
757            }
758        }
759        Ok(Self::from_oriented(frame, gauge_singular_values))
760    }
761
762    /// Grassmann manifold dimension `r·(p − r)` of this frame — the count of
763    /// profiled-out degrees of freedom that must enter the Laplace evidence
764    /// dimension accounting (issue #972, evidence honesty). A point on the
765    /// Grassmannian `Gr(r, p)` has exactly this many intrinsic coordinates.
766    pub fn manifold_dimension(&self) -> usize {
767        let r = self.rank();
768        let p = self.output_dim();
769        r * (p - r)
770    }
771
772    /// Build the canonical-gauge frame for a `p × r` orthonormal `U` paired with
773    /// its `gauge_singular_values`. Enforces the column-sign convention
774    /// (largest-magnitude entry per column non-negative) so the span serializes
775    /// deterministically. The caller guarantees `U` is already column-orthonormal
776    /// and its columns are ordered by descending singular value.
777    pub(crate) fn from_oriented(
778        mut frame: Array2<f64>,
779        gauge_singular_values: Array1<f64>,
780    ) -> Self {
781        let (p, r) = frame.dim();
782        for col in 0..r {
783            // Sign-fix: make the largest-magnitude entry of each column
784            // non-negative so `U` and `−U` (same span) serialize identically.
785            let mut pivot_abs = 0.0_f64;
786            let mut pivot_val = 0.0_f64;
787            for row in 0..p {
788                let v = frame[[row, col]];
789                if v.abs() > pivot_abs {
790                    pivot_abs = v.abs();
791                    pivot_val = v;
792                }
793            }
794            if pivot_val < 0.0 {
795                for row in 0..p {
796                    frame[[row, col]] = -frame[[row, col]];
797                }
798            }
799        }
800        Self {
801            frame,
802            gauge_singular_values,
803        }
804    }
805
806    /// Closed-form streaming POLAR step (issue #972): given an accumulated
807    /// `p × r` cross-moment `Mcm` (a sum of decoder-target outer products that
808    /// pulls the frame toward the current column-span evidence), return the
809    /// orthogonal polar factor `U_new = polar(Mcm)`.
810    ///
811    /// `polar(M) = W Vᵀ` from the thin SVD `M = W Σ Vᵀ`: the nearest
812    /// column-orthonormal matrix to `M` in Frobenius norm, and the closed-form
813    /// MAP frame update on the Grassmannian. Runs OUTSIDE the border (an
814    /// `O(p r² )` thin SVD), so the border never carries the `p` factor.
815    /// `gauge_singular_values = Σ` records the canonical descending-σ ordering.
816    pub fn polar_update(cross_moment: ArrayView2<'_, f64>) -> Result<Self, String> {
817        let (p, r) = cross_moment.dim();
818        if p == 0 || r == 0 {
819            return Err("GrassmannFrame::polar_update: cross-moment must be non-empty".into());
820        }
821        if r > p {
822            return Err(format!(
823                "GrassmannFrame::polar_update: frame rank r={r} cannot exceed output dim p={p}"
824            ));
825        }
826        let owned = cross_moment.to_owned();
827        let (u_opt, sv, vt_opt) = owned
828            .svd(true, true)
829            .map_err(|e| format!("GrassmannFrame::polar_update: SVD failed: {e}"))?;
830        let w = u_opt.ok_or_else(|| {
831            "GrassmannFrame::polar_update: thin SVD returned no left factor".to_string()
832        })?;
833        let vt = vt_opt.ok_or_else(|| {
834            "GrassmannFrame::polar_update: thin SVD returned no right factor".to_string()
835        })?;
836        // `W` is `p × r`, `Vᵀ` is `r × r`. polar(M) = W·Vᵀ is `p × r`,
837        // column-orthonormal because both factors have orthonormal columns/rows.
838        let polar = fast_ab(&w, &vt);
839        Ok(Self::from_oriented(polar, sv))
840    }
841
842    /// Project a coordinate matrix `C_k` (`M_k × r`) back to the full decoder
843    /// `B_k = C_k · Uᵀ` (`M_k × p`) — the reconstruction used wherever the
844    /// full-`B` consumers (assembly, decode, smoothness pullback) read the
845    /// decoder. `fast_abt` computes `C_k · Uᵀ` without materializing `Uᵀ`.
846    pub fn reconstruct_decoder(&self, coords: ArrayView2<'_, f64>) -> Result<Array2<f64>, String> {
847        if coords.ncols() != self.rank() {
848            return Err(format!(
849                "GrassmannFrame::reconstruct_decoder: coord cols {} must equal frame rank {}",
850                coords.ncols(),
851                self.rank()
852            ));
853        }
854        Ok(fast_abt(&coords.to_owned(), &self.frame))
855    }
856
857    /// Project a full decoder `B_k` (`M_k × p`) onto this frame, returning the
858    /// coordinate matrix `C_k = B_k · U` (`M_k × r`) that the border stores.
859    /// The frame is orthonormal so `U` is its own pseudo-inverse-from-the-right:
860    /// `C_k = B_k U` recovers the in-span coordinates exactly and discards the
861    /// component of `B_k` orthogonal to the frame (zero when `B_k`'s span lies in
862    /// `range(U)`, i.e. when the frame rank matched the decoder rank).
863    pub fn project_decoder(&self, decoder: ArrayView2<'_, f64>) -> Result<Array2<f64>, String> {
864        if decoder.ncols() != self.output_dim() {
865            return Err(format!(
866                "GrassmannFrame::project_decoder: decoder cols {} must equal output dim {}",
867                decoder.ncols(),
868                self.output_dim()
869            ));
870        }
871        Ok(fast_ab(&decoder.to_owned(), &self.frame))
872    }
873
874    /// Largest principal angle (radians) between this frame's column span and
875    /// another `p × r'` orthonormal frame's span — the Grassmann geodesic
876    /// distance component used by the planted-atom recovery verifier (issue
877    /// #972).
878    ///
879    /// The naive formula `arccos(min σ_i(UᵀV))` loses half the available
880    /// precision for near-parallel spans: when `cos θ = 1 − ε` (the
881    /// `ε ~ fp64.eps` regime hit by a polar update of an already-orthonormal
882    /// frame), `arccos(1 − ε) ≈ √(2ε)` ≈ `1.49e-8`, so a planted span the
883    /// solver actually recovered to machine precision was being reported as
884    /// `O(√fp64.eps)` off. The stable form uses BOTH the cosines from
885    /// `M = UᵀV` (small-angle limit: `cos θ ≈ 1 − θ²/2`, sensitive to noise)
886    /// AND the sines from the orthogonal complement
887    /// `V_⊥ = (I − UUᵀ) V` (small-angle limit: `sin θ ≈ θ`, sensitive to the
888    /// quantity we actually want), then combines them with `atan2(sin, cos)`.
889    /// `atan2` returns a precise angle across the whole `[0, π/2]` interval
890    /// regardless of which leg is small — so an exactly-equal-frame test now
891    /// reports the genuine ~fp64.eps residual instead of an inflated
892    /// `√fp64.eps`. The pairing is exact because the singular values of
893    /// `M` and `V_⊥` are matched component-wise to the same principal
894    /// angle: `σ_r(M) = cos θ_max` and `σ_1(V_⊥) = sin θ_max`.
895    pub fn max_principal_angle(&self, other: ArrayView2<'_, f64>) -> Result<f64, String> {
896        if other.nrows() != self.output_dim() {
897            return Err(format!(
898                "GrassmannFrame::max_principal_angle: other rows {} must equal output dim {}",
899                other.nrows(),
900                self.output_dim()
901            ));
902        }
903        if other.ncols() != self.rank() {
904            // Principal angles pair only the common number of directions. Any
905            // unmatched direction in unequal-rank subspaces is orthogonal to
906            // the absent direction and therefore contributes pi/2. Returning
907            // only the paired angles made nested spaces read as distance zero
908            // in one argument order and non-zero in the other.
909            return Ok(std::f64::consts::FRAC_PI_2);
910        }
911        let other_owned = other.to_owned();
912        let overlap = fast_atb(&self.frame, &other_owned);
913        let (_u, sv_cos, _vt) = overlap
914            .svd(false, false)
915            .map_err(|e| format!("GrassmannFrame::max_principal_angle: cos-SVD failed: {e}"))?;
916        // V_⊥ = V − U·(UᵀV); its largest singular value is sin(θ_max).
917        let u_overlap = fast_ab(&self.frame, &overlap);
918        let v_perp = &other_owned - &u_overlap;
919        let (_u, sv_sin, _vt) = v_perp
920            .svd(false, false)
921            .map_err(|e| format!("GrassmannFrame::max_principal_angle: sin-SVD failed: {e}"))?;
922        // Smallest cosine and largest sine both correspond to θ_max; combine
923        // via atan2 for full precision across [0, π/2]. Clamp the SVD outputs
924        // into [0, 1] before pairing — both arise from singular values of
925        // matrices whose true norms are ≤ 1, so any drift above 1 or below
926        // 0 is pure floating-point noise.
927        let min_cos = sv_cos
928            .iter()
929            .copied()
930            .fold(1.0_f64, f64::min)
931            .clamp(0.0, 1.0);
932        let max_sin = sv_sin
933            .iter()
934            .copied()
935            .fold(0.0_f64, f64::max)
936            .clamp(0.0, 1.0);
937        Ok(max_sin.atan2(min_cos))
938    }
939
940    /// Build the column-orthonormal ambient frame `V` (`p × r`) spanning the
941    /// subspace of `ℝ^p` an atom's decoded curve lives in, from its decoder
942    /// coefficients `B_k` (`M_k × p`).
943    ///
944    /// The decoded point for latent coordinate `t` is `x(t) = Φ_k(t) · B_k`, so
945    /// as `t` sweeps the atom's manifold `x(t)` ranges over the ROW SPACE of
946    /// `B_k` — the span of `B_k`'s right singular vectors. That ambient span is
947    /// exactly the object the chart-gluing pre-screen (#1890) compares between
948    /// two atoms: two arcs of ONE circle span the same 2-plane (principal angles
949    /// ≈ 0 via [`Self::max_principal_angle`]), while two genuinely distinct
950    /// circles generically span different planes. The effective rank `r` is the
951    /// number of singular values at or above [`SAE_FRAME_RANK_CUTOFF`] · σ_max,
952    /// so a rank-deficient decoder contributes only its live directions.
953    ///
954    /// Returns `None` for an empty decoder or one whose largest singular value is
955    /// not finite-positive (a dead atom carries no ambient span to compare).
956    pub fn from_decoder_row_space(decoder: ArrayView2<'_, f64>) -> Option<Self> {
957        let (m, p) = decoder.dim();
958        if m == 0 || p == 0 {
959            return None;
960        }
961        // Right singular vectors of `B_k` are the rows of `Vᵀ`; each is a unit
962        // vector in `ℝ^p`. Request the right factor only (the left factor is the
963        // basis-space image we do not need here).
964        let (_u, sv, vt_opt) = decoder.to_owned().svd(false, true).ok()?;
965        let vt = vt_opt?;
966        let sigma_max = sv.iter().copied().fold(0.0_f64, f64::max);
967        if !(sigma_max.is_finite() && sigma_max > 0.0) {
968            return None;
969        }
970        let cutoff = SAE_FRAME_RANK_CUTOFF * sigma_max;
971        // `Vᵀ` is `k × p` with `k = min(m, p)`; keep the leading rows whose
972        // singular value clears the cutoff (they are already ordered descending).
973        let r = sv.iter().take(vt.nrows()).filter(|&&s| s >= cutoff).count();
974        if r == 0 {
975            return None;
976        }
977        // Frame column `j` is right singular vector `j` (row `j` of `Vᵀ`); the
978        // rows of `Vᵀ` are orthonormal, so the assembled `p × r` matrix is
979        // column-orthonormal as `from_oriented` requires.
980        let mut frame = Array2::<f64>::zeros((p, r));
981        for j in 0..r {
982            for row in 0..p {
983                frame[[row, j]] = vt[[j, row]];
984            }
985        }
986        let gauge = Array1::from_iter(sv.iter().take(r).copied());
987        Some(Self::from_oriented(frame, gauge))
988    }
989}
990
991/// Streaming `p × r` cross-moment accumulator for the closed-form polar frame
992/// update (issue #972). Sums decoder-target outer products `Σ_i t_i c_iᵀ`
993/// (ambient target `t_i ∈ ℝ^p` against in-span coordinate `c_i ∈ ℝ^r`) so the
994/// frame can be re-polared from accumulated evidence WITHOUT re-touching the
995/// border. Accumulation is `O(p r)` per update and never forms a `p × p` matrix.
996#[derive(Debug, Clone)]
997pub struct GrassmannCrossMoment {
998    moment: Array2<f64>,
999}
1000
1001impl GrassmannCrossMoment {
1002    /// Empty `p × r` accumulator.
1003    pub fn new(output_dim: usize, rank: usize) -> Self {
1004        Self {
1005            moment: Array2::<f64>::zeros((output_dim, rank)),
1006        }
1007    }
1008
1009    /// Accumulate the full-batch cross-moment `Targetᵀ · Coords` where
1010    /// `targets` is `(N × p)` ambient decoder targets and `coords` is `(N × r)`
1011    /// in-span coordinates. `fast_atb` forms `Targetᵀ Coords` (`p × r`) directly.
1012    pub fn accumulate(
1013        &mut self,
1014        targets: ArrayView2<'_, f64>,
1015        coords: ArrayView2<'_, f64>,
1016    ) -> Result<(), String> {
1017        if targets.ncols() != self.moment.nrows() || coords.ncols() != self.moment.ncols() {
1018            return Err(format!(
1019                "GrassmannCrossMoment::accumulate: expected targets (·,{}) and coords (·,{}); \
1020                 got (·,{}) and (·,{})",
1021                self.moment.nrows(),
1022                self.moment.ncols(),
1023                targets.ncols(),
1024                coords.ncols()
1025            ));
1026        }
1027        if targets.nrows() != coords.nrows() {
1028            return Err(format!(
1029                "GrassmannCrossMoment::accumulate: targets rows {} must equal coords rows {}",
1030                targets.nrows(),
1031                coords.nrows()
1032            ));
1033        }
1034        let block = fast_atb(&targets.to_owned(), &coords.to_owned());
1035        self.moment += &block;
1036        Ok(())
1037    }
1038
1039    /// Read the accumulated `p × r` cross-moment.
1040    pub fn moment(&self) -> ArrayView2<'_, f64> {
1041        self.moment.view()
1042    }
1043
1044    /// Re-polar the frame from the accumulated cross-moment (the streaming
1045    /// closed-form step): `U_new = polar(Mcm)`.
1046    pub fn polar_frame(&self) -> Result<GrassmannFrame, String> {
1047        GrassmannFrame::polar_update(self.moment.view())
1048    }
1049}
1050
1051/// Verification helper (issue #972): recover the planted low-rank column span of
1052/// an atom by polaring the decoder-target cross-moment and report the largest
1053/// principal angle (radians) between the recovered frame and a planted
1054/// orthonormal frame `planted` (`p × r`).
1055///
1056/// `targets` (`N × p`) are the ambient decoder targets and `coords` (`N × r`)
1057/// the latent coordinates that generated them (`targets ≈ coords · plantedᵀ`).
1058/// The closed-form polar of `Σ targetsᵀ coords` recovers `range(planted)`; a
1059/// successful low-rank fit drives the returned angle to `0`. Used by the
1060/// `planted_low_rank_frame_recovered_by_polar` test, and available to callers
1061/// that want a runtime span-recovery diagnostic.
1062pub fn grassmann_recover_planted_span_angle(
1063    targets: ArrayView2<'_, f64>,
1064    coords: ArrayView2<'_, f64>,
1065    planted: ArrayView2<'_, f64>,
1066) -> Result<f64, String> {
1067    let p = targets.ncols();
1068    let r = coords.ncols();
1069    if planted.dim() != (p, r) {
1070        return Err(format!(
1071            "grassmann_recover_planted_span_angle: planted frame must be ({p}, {r}); got {:?}",
1072            planted.dim()
1073        ));
1074    }
1075    let mut cross = GrassmannCrossMoment::new(p, r);
1076    cross.accumulate(targets, coords)?;
1077    let frame = cross.polar_frame()?;
1078    frame.max_principal_angle(planted)
1079}
1080
1081/// Verification helper (issue #972): the factored arrow-Schur border dimension
1082/// equals `Σ_k M_k · r_k` exactly. Returns `Ok(())` iff the invariant holds for
1083/// `term`, else an explanatory error. Compiled-in so the border-size contract is
1084/// checkable at runtime, not only in tests.
1085pub fn grassmann_assert_border_dim_invariant(term: &SaeManifoldTerm) -> Result<(), String> {
1086    let expected: usize = term
1087        .atoms
1088        .iter()
1089        .map(|a| a.basis_size() * a.border_frame_rank())
1090        .sum();
1091    let got = term.factored_border_dim();
1092    if got != expected {
1093        return Err(format!(
1094            "grassmann border-dim invariant violated: factored_border_dim() = {got}, \
1095             expected Σ M_k·r_k = {expected}"
1096        ));
1097    }
1098    Ok(())
1099}