Skip to main content

gam_terms/analytic_penalties/
sheaf.rs

1//! Cellular-sheaf consistency penalty.
2//!
3//! Given a directed graph `G = (V, E)` with per-vertex stalk vectors
4//! `s_v ∈ R^{d_v}` and per-edge linear restriction maps
5//! `R_e^{(u→e)}: R^{d_u} → R^{d_e}`, the **coboundary**
6//!
7//! ```text
8//! δs[e] = R_e^{(u→e)}(s_{u_e}) − R_e^{(v→e)}(s_{v_e})
9//! ```
10//!
11//! lifts each edge into a per-edge "discrepancy" vector. The
12//! **sheaf Laplacian** `L = δᵀ δ` is sparse PSD on the stacked stalk
13//! space `R^{Σ d_v}`. Globally consistent sections live in `ker L`;
14//! `dim ker L` ("number of harmonic modes") generalises the
15//! connected-component count of a graph Laplacian to sheaves.
16//!
17//! The penalty value is
18//!
19//! ```text
20//! P(s) = ½ · weight · sᵀ L s = ½ · weight · ∑_e ‖δs[e]‖².
21//! ```
22//!
23//! References:
24//!   * Hansen & Ghrist, "Toward a Spectral Theory of Cellular Sheaves",
25//!     J. Appl. Comput. Topol. 3 (2019).
26//!   * Bodnar, Di Giovanni, Chamberlain, Lió, Bronstein,
27//!     "Neural Sheaf Diffusion" (NeurIPS 2022).
28//!
29//! Design choices in this module:
30//!   * The Laplacian is **never materialised**. All operations route through
31//!     two matvecs (`δ` and `δᵀ`).
32//!   * Restriction maps are `(R_uv, Option<R_vu>)` pairs. If the second is
33//!     `None` it defaults to the identity (`δs[e] = R_uv·s_u − s_v`), which
34//!     is the "single-restriction edge" convention common in sheaf-diffusion
35//!     networks.
36//!   * `harmonic_modes(tol)` auto-routes through faer's self-adjoint
37//!     eigendecomposition (`gam_linalg::faer_ndarray::FaerEigh`). For
38//!     `Σ d_v > 4096`, the dense Gram of `δ` exceeds 128 MB; we use a Lanczos
39//!     trace-style probe (HKS-bounded null-space count) in that regime so we
40//!     stay matrix-free.
41
42use faer::Side;
43use ndarray::{Array1, Array2, ArrayView1};
44
45use crate::analytic_penalties::{AnalyticPenalty, PenaltyTier};
46use gam_linalg::faer_ndarray::FaerEigh;
47use gam_linalg::lanczos::{SymmetricLanczosOptions, symmetric_lanczos_eigenpairs};
48
49/// Threshold above which `harmonic_modes` switches from a dense faer eigen
50/// solve to a matrix-free Lanczos null-space count. The dense path
51/// materialises `L` (one `n×n` symmetric matrix); at `n = 4096` that's
52/// `n² · 8 B ≈ 128 MB`, our hard ceiling for the dense route.
53const DENSE_EIGH_DIM_THRESHOLD: usize = 4096;
54
55/// A single edge's pair of restriction operators.
56///
57/// * `r_uv` maps the tail stalk into the edge stalk.
58/// * `r_vu` maps the head stalk into the edge stalk; `None` means "identity"
59///   (which forces `d_e == d_v`).
60#[derive(Debug, Clone)]
61pub struct EdgeRestriction {
62    pub r_uv: Array2<f64>,
63    pub r_vu: Option<Array2<f64>>,
64}
65
66impl EdgeRestriction {
67    /// Both endpoints have an explicit restriction map.
68    #[must_use]
69    pub fn paired(r_uv: Array2<f64>, r_vu: Array2<f64>) -> Self {
70        Self {
71            r_uv,
72            r_vu: Some(r_vu),
73        }
74    }
75
76    /// Tail-only restriction; the head side is implicitly identity.
77    #[must_use]
78    pub fn single(r_uv: Array2<f64>) -> Self {
79        Self { r_uv, r_vu: None }
80    }
81
82    /// Output (edge-stalk) dimension `d_e` for this edge.
83    pub fn edge_dim(&self) -> usize {
84        self.r_uv.nrows()
85    }
86}
87
88/// Cellular-sheaf consistency penalty over a fixed directed graph + restriction
89/// maps. The stacked stalk space layout is row-major over vertices:
90/// vertex `v` occupies `stalk_offsets[v] .. stalk_offsets[v] + stalk_dims[v]`.
91#[derive(Debug, Clone)]
92pub struct SheafConsistencyPenalty {
93    edges: Vec<(usize, usize)>,
94    restrictions: Vec<EdgeRestriction>,
95    weight: f64,
96    stalk_offsets: Vec<usize>,
97    stalk_dims: Vec<usize>,
98}
99
100impl SheafConsistencyPenalty {
101    /// Construct a sheaf-consistency penalty.
102    ///
103    /// * `edges` — directed edges as `(u, v)` pairs, vertex indices `0..K`.
104    /// * `restrictions` — same length as `edges`; per-edge `EdgeRestriction`.
105    /// * `weight` — finite, positive scalar penalty weight.
106    /// * `stalk_dims` — per-vertex stalk dimensions `d_v`.
107    ///
108    /// Validates: dim agreement (`r_uv.ncols == d_u`, `r_vu.ncols == d_v`,
109    /// `r_uv.nrows == r_vu.nrows`), vertex indices in range, finite entries.
110    #[must_use = "build error must be handled"]
111    pub fn new(
112        edges: Vec<(usize, usize)>,
113        restrictions: Vec<EdgeRestriction>,
114        weight: f64,
115        stalk_dims: Vec<usize>,
116    ) -> Result<Self, String> {
117        if !(weight.is_finite() && weight > 0.0) {
118            return Err(format!(
119                "SheafConsistencyPenalty::new requires finite weight > 0, got {weight}"
120            ));
121        }
122        if edges.len() != restrictions.len() {
123            return Err(format!(
124                "SheafConsistencyPenalty::new edge count {} != restriction count {}",
125                edges.len(),
126                restrictions.len()
127            ));
128        }
129        if stalk_dims.is_empty() {
130            return Err("SheafConsistencyPenalty::new requires at least one vertex".into());
131        }
132        for (v, &d) in stalk_dims.iter().enumerate() {
133            if d == 0 {
134                return Err(format!(
135                    "SheafConsistencyPenalty::new stalk dim at vertex {v} is zero"
136                ));
137            }
138        }
139        for (e, ((u, v), restriction)) in edges.iter().zip(restrictions.iter()).enumerate() {
140            if *u >= stalk_dims.len() || *v >= stalk_dims.len() {
141                return Err(format!(
142                    "SheafConsistencyPenalty::new edge {e} = ({u}, {v}) references vertex \
143                     out of range (K = {})",
144                    stalk_dims.len()
145                ));
146            }
147            let d_u = stalk_dims[*u];
148            let d_v = stalk_dims[*v];
149            let d_e = restriction.r_uv.nrows();
150            if restriction.r_uv.ncols() != d_u {
151                return Err(format!(
152                    "SheafConsistencyPenalty::new edge {e}: r_uv has {} cols, expected d_u = {d_u}",
153                    restriction.r_uv.ncols()
154                ));
155            }
156            match &restriction.r_vu {
157                Some(r_vu) => {
158                    if r_vu.ncols() != d_v {
159                        return Err(format!(
160                            "SheafConsistencyPenalty::new edge {e}: r_vu has {} cols, \
161                             expected d_v = {d_v}",
162                            r_vu.ncols()
163                        ));
164                    }
165                    if r_vu.nrows() != d_e {
166                        return Err(format!(
167                            "SheafConsistencyPenalty::new edge {e}: r_vu has {} rows, \
168                             expected d_e = {d_e}",
169                            r_vu.nrows()
170                        ));
171                    }
172                }
173                None => {
174                    if d_e != d_v {
175                        return Err(format!(
176                            "SheafConsistencyPenalty::new edge {e}: r_vu is identity but \
177                             d_e ({d_e}) != d_v ({d_v})"
178                        ));
179                    }
180                }
181            }
182            if !restriction.r_uv.iter().all(|x| x.is_finite()) {
183                return Err(format!(
184                    "SheafConsistencyPenalty::new edge {e}: r_uv contains non-finite entries"
185                ));
186            }
187            if let Some(r_vu) = &restriction.r_vu
188                && !r_vu.iter().all(|x| x.is_finite())
189            {
190                return Err(format!(
191                    "SheafConsistencyPenalty::new edge {e}: r_vu contains non-finite entries"
192                ));
193            }
194        }
195        let mut stalk_offsets = Vec::with_capacity(stalk_dims.len() + 1);
196        let mut acc = 0usize;
197        for &d in &stalk_dims {
198            stalk_offsets.push(acc);
199            acc = acc.checked_add(d).ok_or_else(|| {
200                "SheafConsistencyPenalty::new stalk offsets overflow usize".to_string()
201            })?;
202        }
203        stalk_offsets.push(acc);
204        Ok(Self {
205            edges,
206            restrictions,
207            weight,
208            stalk_offsets,
209            stalk_dims,
210        })
211    }
212
213    /// Total dimension of the stacked stalk space `Σ d_v`.
214    pub fn total_dim(&self) -> usize {
215        *self.stalk_offsets.last().expect("offsets non-empty")
216    }
217
218    /// Number of edges.
219    pub fn num_edges(&self) -> usize {
220        self.edges.len()
221    }
222
223    /// Number of vertices `K`.
224    pub fn num_vertices(&self) -> usize {
225        self.stalk_dims.len()
226    }
227
228    /// Per-vertex stalk dimensions (clone of internal vector).
229    pub fn stalk_dims(&self) -> &[usize] {
230        &self.stalk_dims
231    }
232
233    /// Penalty weight.
234    pub fn weight(&self) -> f64 {
235        self.weight
236    }
237
238    fn vertex_slice<'a>(&self, s: ArrayView1<'a, f64>, v: usize) -> ArrayView1<'a, f64> {
239        let start = self.stalk_offsets[v];
240        let end = self.stalk_offsets[v + 1];
241        s.slice_move(ndarray::s![start..end])
242    }
243
244    /// Apply `δ` to a stacked-stalk vector `s`. Returns a `Vec<Array1<f64>>`
245    /// with one entry per edge containing `δs[e] ∈ R^{d_e}`.
246    fn delta(&self, s: ArrayView1<'_, f64>) -> Vec<Array1<f64>> {
247        assert_eq!(
248            s.len(),
249            self.total_dim(),
250            "stacked stalk vector has wrong length",
251        );
252        let mut out = Vec::with_capacity(self.edges.len());
253        for (e, &(u, v)) in self.edges.iter().enumerate() {
254            let s_u = self.vertex_slice(s, u);
255            let s_v = self.vertex_slice(s, v);
256            let restriction = &self.restrictions[e];
257            // R_uv · s_u
258            let mut delta_e = restriction.r_uv.dot(&s_u);
259            // − R_vu · s_v   (identity if r_vu is None)
260            match &restriction.r_vu {
261                Some(r_vu) => {
262                    let r_vu_s_v = r_vu.dot(&s_v);
263                    delta_e.scaled_add(-1.0, &r_vu_s_v);
264                }
265                None => {
266                    delta_e.scaled_add(-1.0, &s_v);
267                }
268            }
269            out.push(delta_e);
270        }
271        out
272    }
273
274    /// Apply `δᵀ` to per-edge discrepancies `y`. Returns the stacked-stalk
275    /// vector `δᵀ y ∈ R^{Σ d_v}`.
276    fn delta_transpose(&self, y: &[Array1<f64>]) -> Array1<f64> {
277        assert_eq!(
278            y.len(),
279            self.edges.len(),
280            "delta_transpose edge count mismatch"
281        );
282        let mut out = Array1::<f64>::zeros(self.total_dim());
283        for (e, &(u, v)) in self.edges.iter().enumerate() {
284            let restriction = &self.restrictions[e];
285            let y_e = &y[e];
286            assert_eq!(y_e.len(), restriction.edge_dim(), "edge dim mismatch");
287            // R_uvᵀ · y_e → vertex u
288            let contrib_u = restriction.r_uv.t().dot(y_e);
289            let u_start = self.stalk_offsets[u];
290            let u_end = self.stalk_offsets[u + 1];
291            {
292                let mut out_u = out.slice_mut(ndarray::s![u_start..u_end]);
293                out_u.scaled_add(1.0, &contrib_u);
294            }
295            // −R_vuᵀ · y_e → vertex v   (identity if r_vu is None)
296            let v_start = self.stalk_offsets[v];
297            let v_end = self.stalk_offsets[v + 1];
298            match &restriction.r_vu {
299                Some(r_vu) => {
300                    let contrib_v = r_vu.t().dot(y_e);
301                    let mut out_v = out.slice_mut(ndarray::s![v_start..v_end]);
302                    out_v.scaled_add(-1.0, &contrib_v);
303                }
304                None => {
305                    let mut out_v = out.slice_mut(ndarray::s![v_start..v_end]);
306                    out_v.scaled_add(-1.0, y_e);
307                }
308            }
309        }
310        out
311    }
312
313    /// Apply the sheaf Laplacian `L = δᵀ δ` to a stacked-stalk vector `s`.
314    /// Cost: two matvecs per edge; never materialises `L`.
315    pub fn laplacian_apply(&self, s: ArrayView1<'_, f64>) -> Array1<f64> {
316        let ds = self.delta(s);
317        self.delta_transpose(&ds)
318    }
319
320    /// Penalty value `½ · weight · ‖δs‖²`. Quadratic in `s`.
321    pub fn value(&self, s: ArrayView1<'_, f64>) -> f64 {
322        let ds = self.delta(s);
323        let mut sq = 0.0;
324        for de in &ds {
325            for &x in de.iter() {
326                sq += x * x;
327            }
328        }
329        0.5 * self.weight * sq
330    }
331
332    /// Gradient `∂P/∂s = weight · L s`. Length `Σ d_v`.
333    pub fn gradient(&self, s: ArrayView1<'_, f64>) -> Array1<f64> {
334        let mut g = self.laplacian_apply(s);
335        g *= self.weight;
336        g
337    }
338
339    /// Hessian diagonal `diag(weight · L)`. Independent of `s` because `L` is
340    /// constant. For a **distinct-vertex** edge `(u, v)` (`u ≠ v`) the coboundary
341    /// `C = [R_uv | −R_vu]` acts on disjoint stalk blocks, so
342    ///   * tail (u-side): `Σ_j R_uv[j, i_local]²`
343    ///   * head (v-side, single-restriction): `1.0` per incident edge
344    ///   * head (v-side, paired): `Σ_j R_vu[j, i_local]²`
345    /// For a **self-loop** edge `(u, u)` both sides share one block and the
346    /// coboundary collapses to `(R_uv − R_vu)·s_u`, so the correct contribution
347    /// is `colnorm²(R_uv − R_vu)` — NOT the sum of the two separate norms.
348    pub fn hessian_diag(&self, s: ArrayView1<'_, f64>) -> Array1<f64> {
349        assert_eq!(
350            s.len(),
351            self.total_dim(),
352            "stacked stalk vector has wrong length",
353        );
354        // L is constant in s; the argument is retained only for trait-style symmetry
355        // (other penalties take target as the first arg). The shape assertion above
356        // exercises that input.
357        let mut diag = Array1::<f64>::zeros(self.total_dim());
358        for (e, &(u, v)) in self.edges.iter().enumerate() {
359            let restriction = &self.restrictions[e];
360            let u_start = self.stalk_offsets[u];
361            let v_start = self.stalk_offsets[v];
362            let r_uv = &restriction.r_uv;
363
364            if u == v {
365                // Self-loop: δ(s)[e] = (R_uv − R_vu)·s_u, so the edge's
366                // contribution to diag(L) is colnorm²(R_uv − R_vu), NOT the
367                // sum of the two separate squared column norms (which would
368                // double-count on the shared stalk block). The distinct-vertex
369                // path below is not reached for self-loops.
370                match &restriction.r_vu {
371                    Some(r_vu) => {
372                        for col in 0..r_uv.ncols() {
373                            let mut s2 = 0.0;
374                            for row in 0..r_uv.nrows() {
375                                let diff = r_uv[[row, col]] - r_vu[[row, col]];
376                                s2 += diff * diff;
377                            }
378                            diag[u_start + col] += s2;
379                        }
380                    }
381                    None => {
382                        // r_vu = I; contribution is colnorm²(R_uv − I).
383                        let d = self.stalk_dims[u];
384                        for col in 0..d {
385                            let mut s2 = 0.0;
386                            for row in 0..r_uv.nrows() {
387                                let identity_entry = if row == col { 1.0 } else { 0.0 };
388                                let diff = r_uv[[row, col]] - identity_entry;
389                                s2 += diff * diff;
390                            }
391                            diag[u_start + col] += s2;
392                        }
393                    }
394                }
395            } else {
396                // Distinct-vertex path: u_start ≠ v_start, so u-side and v-side
397                // accumulations land on disjoint index ranges. Diagonal of Cᵀ C
398                // with C = [R_uv | −R_vu] decomposes cleanly into the two blocks.
399                for col in 0..r_uv.ncols() {
400                    let mut s2 = 0.0;
401                    for row in 0..r_uv.nrows() {
402                        let a = r_uv[[row, col]];
403                        s2 += a * a;
404                    }
405                    diag[u_start + col] += s2;
406                }
407                match &restriction.r_vu {
408                    Some(r_vu) => {
409                        for col in 0..r_vu.ncols() {
410                            let mut s2 = 0.0;
411                            for row in 0..r_vu.nrows() {
412                                let a = r_vu[[row, col]];
413                                s2 += a * a;
414                            }
415                            diag[v_start + col] += s2;
416                        }
417                    }
418                    None => {
419                        let d_v = self.stalk_dims[v];
420                        for col in 0..d_v {
421                            diag[v_start + col] += 1.0;
422                        }
423                    }
424                }
425            }
426        }
427        diag *= self.weight;
428        diag
429    }
430
431    /// Hessian-vector product `H v = weight · L v`. Two matvecs, no
432    /// materialisation. The `_s` argument is unused (L is constant); it
433    /// matches the trait-style `(target, v)` signature other penalties use.
434    pub fn hvp(&self, s: ArrayView1<'_, f64>, v: ArrayView1<'_, f64>) -> Array1<f64> {
435        assert_eq!(
436            s.len(),
437            self.total_dim(),
438            "stacked stalk vector has wrong length",
439        );
440        assert_eq!(v.len(), self.total_dim(), "hvp direction has wrong length");
441        let mut hv = self.laplacian_apply(v);
442        hv *= self.weight;
443        hv
444    }
445
446    /// Materialise the dense Laplacian `L` (no weight applied).
447    ///
448    /// Used by [`Self::harmonic_modes`] when `total_dim() ≤
449    /// DENSE_EIGH_DIM_THRESHOLD`. Cost is `O(n²)` memory and `O(n · |E| · max d_e)`
450    /// flops via `n` independent matvecs against the standard basis.
451    /// **Not** called on the inner-loop hot path.
452    fn dense_laplacian(&self) -> Array2<f64> {
453        let n = self.total_dim();
454        let mut l = Array2::<f64>::zeros((n, n));
455        let mut e = Array1::<f64>::zeros(n);
456        for j in 0..n {
457            e[j] = 1.0;
458            let col = self.laplacian_apply(e.view());
459            for i in 0..n {
460                l[[i, j]] = col[i];
461            }
462            e[j] = 0.0;
463        }
464        l
465    }
466
467    /// Count eigenvalues of the unweighted Laplacian `L` strictly below
468    /// `tol`. Equals the number of harmonic modes (global sections, mod the
469    /// `tol`-tolerance). The penalty weight is **not** folded in: harmonic
470    /// modes are an intrinsic property of `δ`.
471    ///
472    /// Auto-routing: dense faer eigh when `total_dim ≤ DENSE_EIGH_DIM_THRESHOLD`;
473    /// matrix-free Lanczos null-space count otherwise.
474    pub fn harmonic_modes(&self, tol: f64) -> usize {
475        assert!(
476            tol.is_finite() && tol >= 0.0,
477            "harmonic_modes requires finite non-negative tol, got {tol}",
478        );
479        let n = self.total_dim();
480        if n == 0 {
481            return 0;
482        }
483        if n <= DENSE_EIGH_DIM_THRESHOLD {
484            let l = self.dense_laplacian();
485            match l.eigh(Side::Lower) {
486                Ok((evals, _)) => evals.iter().filter(|&&e| e < tol).count(),
487                // SAFETY: dense Laplacian above is symmetric positive semidefinite by construction
488                // (graph Laplacian of an undirected weighted graph), so eigh on the lower triangle
489                // must succeed; any err indicates a corrupted matrix and bailing here is correct.
490                Err(err) => {
491                    panic!("SheafConsistencyPenalty::harmonic_modes faer eigh failed: {err:?}")
492                }
493            }
494        } else {
495            self.harmonic_modes_lanczos(tol)
496        }
497    }
498
499    /// Matrix-free null-space-dim estimate via Lanczos tridiagonalisation +
500    /// Sturm-style sign count. We build a `k`-step Lanczos tridiagonal `T`
501    /// for `L` against a random start vector, eigendecompose `T` densely
502    /// (`k ≪ n`), and count Ritz values below `tol`. This **lower-bounds**
503    /// the harmonic-mode count for generic starts; for sheaf Laplacians the
504    /// kernel direction is reached within `k = min(n, 64)` iterations in
505    /// practice, but we expose the result as a tight bound rather than an
506    /// exact count.
507    fn harmonic_modes_lanczos(&self, tol: f64) -> usize {
508        let n = self.total_dim();
509        let k = n.min(64).max(1);
510        // Deterministic pseudo-random start to keep the bound reproducible.
511        let mut q0 = vec![0.0_f64; n];
512        for i in 0..n {
513            // Splitmix-style scrambling of i: deterministic, dependency-free.
514            // The canonical stateful step adds G internally, so seed it with
515            // `i·G − G` to finalize the same `i·G` input and stay bit-identical.
516            let mut state = (i as u64)
517                .wrapping_mul(0x9E37_79B9_7F4A_7C15)
518                .wrapping_sub(0x9E37_79B9_7F4A_7C15);
519            let z = gam_linalg::utils::splitmix64(&mut state);
520            q0[i] = (z as f64 / u64::MAX as f64) - 0.5;
521        }
522        match symmetric_lanczos_eigenpairs(
523            n,
524            &q0,
525            SymmetricLanczosOptions {
526                max_steps: k,
527                residual_tol: 1e-12,
528                local_reorthogonalize: true,
529                full_reorthogonalize: false,
530            },
531            |q, out| {
532                let w = self.laplacian_apply(ArrayView1::from(q));
533                out.copy_from_slice(w.as_slice().ok_or_else(|| {
534                    "SheafConsistencyPenalty::harmonic_modes Lanczos matvec produced non-contiguous output"
535                        .to_string()
536                })?);
537                Ok(())
538            },
539        ) {
540            Ok(eigen) => eigen.eigenvalues.iter().filter(|&&e| e < tol).count(),
541            Err(err) => {
542                // SAFETY: A Lanczos breakdown here is a non-recoverable numerical
543                // failure of the harmonic-mode decomposition (e.g. a malformed or
544                // non-symmetric operator); there is no meaningful count to return,
545                // so the error must surface rather than be silently swallowed.
546                panic!("SheafConsistencyPenalty::harmonic_modes Lanczos failed: {err}")
547            }
548        }
549    }
550}
551
552// ---------------------------------------------------------------------------
553// AnalyticPenalty trait bridge.
554// ---------------------------------------------------------------------------
555//
556// Wires `SheafConsistencyPenalty` into the analytic-penalty registry so it is
557// reachable from REML / PIRLS / CLI callers exactly like ARDPenalty,
558// BlockOrthogonalityPenalty, etc. `target` is the stacked-stalk vector
559// (treated as a ψ-tier flat block); `rho` is unused — this penalty is
560// quadratic with a fixed scalar weight set at construction. The
561// `harmonic_modes` query and the per-vertex layout helpers remain available
562// as inherent methods for callers that want the cellular-sheaf-specific
563// diagnostics.
564
565impl AnalyticPenalty for SheafConsistencyPenalty {
566    fn tier(&self) -> PenaltyTier {
567        PenaltyTier::Psi
568    }
569
570    fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
571        assert!(
572            rho.iter().all(|x| x.is_finite()),
573            "SheafConsistencyPenalty: rho must be finite (got {rho:?})",
574        );
575        SheafConsistencyPenalty::value(self, target)
576    }
577
578    fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
579        assert!(
580            rho.iter().all(|x| x.is_finite()),
581            "SheafConsistencyPenalty: rho must be finite (got {rho:?})",
582        );
583        SheafConsistencyPenalty::gradient(self, target)
584    }
585
586    fn hessian_diag(
587        &self,
588        target: ArrayView1<'_, f64>,
589        rho: ArrayView1<'_, f64>,
590    ) -> Option<Array1<f64>> {
591        assert!(
592            rho.iter().all(|x| x.is_finite()),
593            "SheafConsistencyPenalty: rho must be finite (got {rho:?})",
594        );
595        Some(SheafConsistencyPenalty::hessian_diag(self, target))
596    }
597
598    fn hvp(
599        &self,
600        target: ArrayView1<'_, f64>,
601        rho: ArrayView1<'_, f64>,
602        v: ArrayView1<'_, f64>,
603    ) -> Array1<f64> {
604        assert!(
605            rho.iter().all(|x| x.is_finite()),
606            "SheafConsistencyPenalty: rho must be finite (got {rho:?})",
607        );
608        SheafConsistencyPenalty::hvp(self, target, v)
609    }
610
611    fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
612        // No learnable hyperparameter axes: rho_count == 0.
613        assert_eq!(
614            rho.len(),
615            0,
616            "SheafConsistencyPenalty: rho_count is 0 but rho has length {}",
617            rho.len(),
618        );
619        assert_eq!(
620            target.len(),
621            self.total_dim(),
622            "SheafConsistencyPenalty: target length {} != total stalk dim {}",
623            target.len(),
624            self.total_dim(),
625        );
626        Array1::<f64>::zeros(0)
627    }
628
629    fn rho_count(&self) -> usize {
630        0
631    }
632
633    fn name(&self) -> &str {
634        "SheafConsistencyPenalty"
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use approx::assert_abs_diff_eq;
642    use ndarray::array;
643
644    fn identity(d: usize) -> Array2<f64> {
645        let mut m = Array2::<f64>::zeros((d, d));
646        for i in 0..d {
647            m[[i, i]] = 1.0;
648        }
649        m
650    }
651
652    #[test]
653    fn single_edge_identity_restriction_value() {
654        // K=2, d_0 = d_1 = 3, R_uv = R_vu = I.
655        // s_0 = (1,0,0), s_1 = (0,1,0). δs = (1,-1,0). ‖·‖² = 2. Value = ½·1·2 = 1.
656        let edges = vec![(0usize, 1usize)];
657        let restrictions = vec![EdgeRestriction::paired(identity(3), identity(3))];
658        let pen =
659            SheafConsistencyPenalty::new(edges, restrictions, 1.0, vec![3, 3]).expect("build");
660        let s = array![1.0_f64, 0.0, 0.0, 0.0, 1.0, 0.0];
661        let v = pen.value(s.view());
662        assert_abs_diff_eq!(v, 1.0, epsilon = 1e-12);
663    }
664
665    #[test]
666    fn gradient_matches_finite_difference_k2_random() {
667        // K=2 with arbitrary restrictions; FD-check the gradient.
668        let r_uv = array![[0.7_f64, -0.1, 0.3], [0.2, 0.9, -0.4]];
669        let r_vu = array![[1.0_f64, 0.5], [-0.3, 0.8]];
670        let edges = vec![(0usize, 1usize)];
671        let restrictions = vec![EdgeRestriction::paired(r_uv, r_vu)];
672        let pen =
673            SheafConsistencyPenalty::new(edges, restrictions, 0.3, vec![3, 2]).expect("build");
674        let s = array![0.4_f64, -1.1, 0.2, 0.6, -0.7];
675        let g = pen.gradient(s.view());
676        let eps = 1e-7;
677        for i in 0..s.len() {
678            let mut sp = s.clone();
679            let mut sm = s.clone();
680            sp[i] += eps;
681            sm[i] -= eps;
682            let fd = (pen.value(sp.view()) - pen.value(sm.view())) / (2.0 * eps);
683            assert_abs_diff_eq!(g[i], fd, epsilon = 1e-6);
684        }
685    }
686
687    #[test]
688    fn hvp_matches_reconstructed_laplacian_chain_k3() {
689        // K=3 chain: edges (0,1) and (1,2), each with explicit 2x2 restrictions.
690        // d_0 = d_1 = d_2 = 2.
691        let r01_uv = array![[0.9_f64, 0.1], [-0.2, 0.7]];
692        let r01_vu = array![[1.0_f64, 0.0], [0.0, 1.0]];
693        let r12_uv = array![[0.5_f64, -0.3], [0.4, 0.8]];
694        let r12_vu = array![[0.6_f64, 0.0], [0.1, 1.1]];
695        let edges = vec![(0usize, 1usize), (1usize, 2usize)];
696        let restrictions = vec![
697            EdgeRestriction::paired(r01_uv, r01_vu),
698            EdgeRestriction::paired(r12_uv, r12_vu),
699        ];
700        let pen =
701            SheafConsistencyPenalty::new(edges, restrictions, 1.0, vec![2, 2, 2]).expect("build");
702        // Reconstruct L densely via 6 matvecs.
703        let l_dense = pen.dense_laplacian();
704        let n = pen.total_dim();
705        let s = array![0.1_f64, -0.2, 0.3, 0.4, -0.5, 0.6];
706        let v = array![0.7_f64, 0.2, -0.1, 0.5, 0.3, -0.4];
707        let hv = pen.hvp(s.view(), v.view());
708        // Reference: L · v (weight = 1)
709        let mut lv = Array1::<f64>::zeros(n);
710        for i in 0..n {
711            let mut acc = 0.0;
712            for j in 0..n {
713                acc += l_dense[[i, j]] * v[j];
714            }
715            lv[i] = acc;
716        }
717        for i in 0..n {
718            assert_abs_diff_eq!(hv[i], lv[i], epsilon = 1e-10);
719        }
720    }
721
722    #[test]
723    fn harmonic_modes_two_components_identity_restrictions() {
724        // Two disconnected vertices (no edges), d = 3 each → ker L = R^{6}, all 6 modes.
725        let pen = SheafConsistencyPenalty::new(vec![], vec![], 1.0, vec![3, 3]).expect("build");
726        let h = pen.harmonic_modes(1e-10);
727        assert_eq!(h, 6);
728
729        // K=4, two connected components: (0-1) and (2-3) with identity restrictions, d=2 each.
730        // Each component's sheaf-Laplacian kernel has dim d (the "constant sections").
731        // Total ker dim = 2·d = 4.
732        let edges = vec![(0usize, 1usize), (2usize, 3usize)];
733        let restrictions = vec![
734            EdgeRestriction::paired(identity(2), identity(2)),
735            EdgeRestriction::paired(identity(2), identity(2)),
736        ];
737        let pen2 = SheafConsistencyPenalty::new(edges, restrictions, 1.0, vec![2, 2, 2, 2])
738            .expect("build");
739        let h2 = pen2.harmonic_modes(1e-10);
740        assert_eq!(h2, 4);
741    }
742
743    #[test]
744    fn value_psd_and_zero_iff_kernel() {
745        // Random s on a non-trivial sheaf: value ≥ 0.
746        let r01_uv = array![[0.9_f64, 0.1], [-0.2, 0.7]];
747        let r01_vu = array![[1.0_f64, 0.0], [0.0, 1.0]];
748        let edges = vec![(0usize, 1usize)];
749        let restrictions = vec![EdgeRestriction::paired(r01_uv.clone(), r01_vu.clone())];
750        let pen =
751            SheafConsistencyPenalty::new(edges, restrictions, 0.5, vec![2, 2]).expect("build");
752
753        // Several random-ish stalks: non-negative value.
754        let samples = [
755            array![0.0_f64, 0.0, 0.0, 0.0],
756            array![1.0_f64, 2.0, -0.5, 0.3],
757            array![-1.3_f64, 0.7, 0.2, -0.9],
758        ];
759        for s in &samples {
760            let v = pen.value(s.view());
761            assert!(v >= 0.0, "value must be non-negative, got {v}");
762        }
763        // Zero stalk → zero value.
764        let z = Array1::<f64>::zeros(4);
765        assert_abs_diff_eq!(pen.value(z.view()), 0.0, epsilon = 1e-15);
766        // A kernel direction: pick s_0 arbitrary then set s_1 = r_vu⁻¹ · r_uv · s_0.
767        // r_vu = I, so s_1 = r_uv · s_0.
768        let s0 = array![0.3_f64, -1.1];
769        let s1 = r01_uv.dot(&s0);
770        let mut s = Array1::<f64>::zeros(4);
771        s[0] = s0[0];
772        s[1] = s0[1];
773        s[2] = s1[0];
774        s[3] = s1[1];
775        assert_abs_diff_eq!(pen.value(s.view()), 0.0, epsilon = 1e-12);
776    }
777
778    #[test]
779    fn hessian_diag_matches_diag_of_dense_laplacian() {
780        let r_uv = array![[0.7_f64, -0.1, 0.3], [0.2, 0.9, -0.4]];
781        let r_vu = array![[1.0_f64, 0.5], [-0.3, 0.8]];
782        let edges = vec![(0usize, 1usize)];
783        let restrictions = vec![EdgeRestriction::paired(r_uv, r_vu)];
784        let pen =
785            SheafConsistencyPenalty::new(edges, restrictions, 0.3, vec![3, 2]).expect("build");
786        let n = pen.total_dim();
787        let s = Array1::<f64>::zeros(n);
788        let diag = pen.hessian_diag(s.view());
789        let l = pen.dense_laplacian();
790        for i in 0..n {
791            assert_abs_diff_eq!(diag[i], 0.3 * l[[i, i]], epsilon = 1e-12);
792        }
793    }
794
795    #[test]
796    fn hessian_diag_matches_dense_laplacian_on_self_loop_paired() {
797        // Self-loop (0,0) with two distinct paired restrictions: the diagonal
798        // must equal diag(weight·L) built from the matrix-free operator, i.e.
799        // colnorm²(R_uv − R_vu), NOT colnorm²(R_uv) + colnorm²(R_vu).
800        let r_uv = array![[0.9_f64, 0.1], [-0.2, 0.7]];
801        let r_vu = array![[1.0_f64, 0.5], [-0.3, 0.8]];
802        let edges = vec![(0usize, 0usize)];
803        let restrictions = vec![EdgeRestriction::paired(r_uv, r_vu)];
804        let pen = SheafConsistencyPenalty::new(edges, restrictions, 0.7, vec![2]).expect("build");
805        let n = pen.total_dim();
806        let s = Array1::<f64>::zeros(n);
807        let diag = pen.hessian_diag(s.view());
808        let l = pen.dense_laplacian();
809        for i in 0..n {
810            assert_abs_diff_eq!(diag[i], 0.7 * l[[i, i]], epsilon = 1e-12);
811        }
812    }
813
814    #[test]
815    fn hessian_diag_matches_dense_laplacian_on_self_loop_single() {
816        // Self-loop (0,0) with a single-restriction edge: R_vu is the identity,
817        // so the coboundary is (R_uv − I)·s_0. The drop-cross-term bug would
818        // report colnorm²(R_uv) + 1 per column; the correct diagonal is
819        // colnorm²(R_uv − I). d_e == d_v == d_u = 2 (single-edge requirement).
820        // This single-restriction self-loop path is exercised by neither the
821        // committed repro (paired only) nor the landing fix's tests.
822        let r_uv = array![[1.0_f64, 2.0], [3.0, 4.0]];
823        let edges = vec![(0usize, 0usize)];
824        let restrictions = vec![EdgeRestriction::single(r_uv)];
825        let pen = SheafConsistencyPenalty::new(edges, restrictions, 1.3, vec![2]).expect("build");
826        let n = pen.total_dim();
827        let s = Array1::<f64>::zeros(n);
828        let diag = pen.hessian_diag(s.view());
829        let l = pen.dense_laplacian();
830        for i in 0..n {
831            assert_abs_diff_eq!(diag[i], 1.3 * l[[i, i]], epsilon = 1e-12);
832        }
833        // Spot the closed form: C = R_uv − I = [[0,2],[3,3]].
834        // col 0: 0² + 3² = 9; col 1: 2² + 3² = 13. ×weight 1.3 → [11.7, 16.9].
835        assert_abs_diff_eq!(diag[0], 1.3 * 9.0, epsilon = 1e-12);
836        assert_abs_diff_eq!(diag[1], 1.3 * 13.0, epsilon = 1e-12);
837    }
838
839    #[test]
840    fn hessian_diag_matches_dense_laplacian_mixed_self_loop_and_cross_edge() {
841        // A self-loop on vertex 0 AND a distinct edge (0,1) both touch vertex 0.
842        // The two edges' diagonal contributions must accumulate independently:
843        // the self-loop contributes colnorm²(R0 − R0b) on block 0, while the
844        // cross edge contributes colnorm²(R1_uv) on block 0 and colnorm²(R1_vu)
845        // on block 1. Checked against the operator-built dense Laplacian.
846        let r0_uv = array![[0.5_f64, -0.4], [0.3, 0.9]];
847        let r0_vu = array![[0.2_f64, 0.1], [-0.6, 0.7]];
848        let r1_uv = array![[1.1_f64, 0.2], [0.0, -0.5]];
849        let r1_vu = array![[0.8_f64, -0.1], [0.4, 1.0]];
850        let edges = vec![(0usize, 0usize), (0usize, 1usize)];
851        let restrictions = vec![
852            EdgeRestriction::paired(r0_uv, r0_vu),
853            EdgeRestriction::paired(r1_uv, r1_vu),
854        ];
855        let pen =
856            SheafConsistencyPenalty::new(edges, restrictions, 0.5, vec![2, 2]).expect("build");
857        let n = pen.total_dim();
858        let s = Array1::<f64>::zeros(n);
859        let diag = pen.hessian_diag(s.view());
860        let l = pen.dense_laplacian();
861        for i in 0..n {
862            assert_abs_diff_eq!(diag[i], 0.5 * l[[i, i]], epsilon = 1e-12);
863        }
864    }
865
866    #[test]
867    fn single_restriction_edge_form() {
868        // δs = R·s_0 − s_1 (single-restriction form). d_0 = 2, d_e = d_1 = 2.
869        let r = array![[1.0_f64, 2.0], [3.0, 4.0]];
870        let edges = vec![(0usize, 1usize)];
871        let restrictions = vec![EdgeRestriction::single(r.clone())];
872        let pen =
873            SheafConsistencyPenalty::new(edges, restrictions, 2.0, vec![2, 2]).expect("build");
874        // s_0 = (1, 0) → R·s_0 = (1, 3). s_1 = (1, 3) → δs = 0. Value = 0.
875        let s = array![1.0_f64, 0.0, 1.0, 3.0];
876        assert_abs_diff_eq!(pen.value(s.view()), 0.0, epsilon = 1e-12);
877        // Now break consistency: s_1 = (0, 0). δs = (1, 3). Value = ½·2·(1+9) = 10.
878        let s2 = array![1.0_f64, 0.0, 0.0, 0.0];
879        assert_abs_diff_eq!(pen.value(s2.view()), 10.0, epsilon = 1e-12);
880    }
881}