holonomy-lib 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. holonomy_explorer/__init__.py +1 -0
  2. holonomy_explorer/__main__.py +79 -0
  3. holonomy_explorer/batching.py +182 -0
  4. holonomy_explorer/common.py +187 -0
  5. holonomy_explorer/curvature.py +154 -0
  6. holonomy_explorer/datasets.py +346 -0
  7. holonomy_explorer/graphs.py +286 -0
  8. holonomy_explorer/server.py +172 -0
  9. holonomy_explorer/static/app.js +435 -0
  10. holonomy_explorer/static/charts.js +114 -0
  11. holonomy_explorer/static/index.html +45 -0
  12. holonomy_explorer/static/layout.js +85 -0
  13. holonomy_explorer/static/style.css +121 -0
  14. holonomy_explorer/static/view.js +195 -0
  15. holonomy_explorer/topology.py +118 -0
  16. holonomy_lib/__init__.py +9 -0
  17. holonomy_lib/_graph_utils.py +52 -0
  18. holonomy_lib/_quadrature.py +74 -0
  19. holonomy_lib/algebra/__init__.py +32 -0
  20. holonomy_lib/algebra/eigsh_sparse.py +251 -0
  21. holonomy_lib/algebra/lanczos.py +361 -0
  22. holonomy_lib/algebra/linear.py +180 -0
  23. holonomy_lib/algebra/solvers.py +547 -0
  24. holonomy_lib/convex/__init__.py +27 -0
  25. holonomy_lib/convex/least_squares.py +184 -0
  26. holonomy_lib/discrete_geometry/__init__.py +38 -0
  27. holonomy_lib/discrete_geometry/forman.py +242 -0
  28. holonomy_lib/discrete_geometry/ricci.py +855 -0
  29. holonomy_lib/experimental/__init__.py +13 -0
  30. holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
  31. holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
  32. holonomy_lib/hyperbolic/__init__.py +27 -0
  33. holonomy_lib/hyperbolic/frechet_mean.py +381 -0
  34. holonomy_lib/hyperbolic/heat_kernel.py +608 -0
  35. holonomy_lib/hyperbolic/manifold_inner.py +81 -0
  36. holonomy_lib/info_geometry/__init__.py +64 -0
  37. holonomy_lib/info_geometry/divergences.py +224 -0
  38. holonomy_lib/info_geometry/fisher.py +179 -0
  39. holonomy_lib/lie/__init__.py +54 -0
  40. holonomy_lib/lie/so3.py +385 -0
  41. holonomy_lib/lie/spherical_harmonics.py +189 -0
  42. holonomy_lib/manifolds/__init__.py +53 -0
  43. holonomy_lib/manifolds/comparison.py +343 -0
  44. holonomy_lib/manifolds/fixed_rank.py +453 -0
  45. holonomy_lib/manifolds/lorentz.py +699 -0
  46. holonomy_lib/manifolds/lorentzian.py +567 -0
  47. holonomy_lib/manifolds/product.py +425 -0
  48. holonomy_lib/manifolds/spd.py +504 -0
  49. holonomy_lib/manifolds/stereographic.py +680 -0
  50. holonomy_lib/optimization/__init__.py +42 -0
  51. holonomy_lib/optimization/base.py +65 -0
  52. holonomy_lib/optimization/sgd.py +112 -0
  53. holonomy_lib/py.typed +0 -0
  54. holonomy_lib/sheaf/__init__.py +74 -0
  55. holonomy_lib/sheaf/graph_sheaf.py +352 -0
  56. holonomy_lib/sheaf/laplacian.py +529 -0
  57. holonomy_lib/simplicial/__init__.py +55 -0
  58. holonomy_lib/simplicial/_face_lookup.py +99 -0
  59. holonomy_lib/simplicial/complex.py +168 -0
  60. holonomy_lib/simplicial/sparse_complex.py +145 -0
  61. holonomy_lib/simplicial/vietoris_rips.py +277 -0
  62. holonomy_lib/spectral/__init__.py +55 -0
  63. holonomy_lib/spectral/diffusion_map.py +169 -0
  64. holonomy_lib/spectral/effective_resistance.py +199 -0
  65. holonomy_lib/spectral/embedding.py +131 -0
  66. holonomy_lib/spectral/heat_kernel.py +236 -0
  67. holonomy_lib/spectral/laplacian.py +441 -0
  68. holonomy_lib/spectral/magnetic.py +351 -0
  69. holonomy_lib/spectral/spectral_dimension.py +104 -0
  70. holonomy_lib/tensor_calculus/__init__.py +23 -0
  71. holonomy_lib/tensor_calculus/decomposition.py +208 -0
  72. holonomy_lib/topology/__init__.py +28 -0
  73. holonomy_lib/topology/_filtration.py +147 -0
  74. holonomy_lib/topology/_reduction.py +227 -0
  75. holonomy_lib/topology/hodge.py +250 -0
  76. holonomy_lib/topology/persistent_homology.py +301 -0
  77. holonomy_lib-0.8.0.dist-info/METADATA +161 -0
  78. holonomy_lib-0.8.0.dist-info/RECORD +82 -0
  79. holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
  80. holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
  81. holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
  82. holonomy_lib-0.8.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,42 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """holonomy_lib.optimization: Riemannian optimizers on the library's manifolds.
5
+
6
+ Currently implemented:
7
+ RiemannianSGD(manifold, lr) : steepest descent on a manifold.
8
+
9
+ The optimizer only calls `egrad_to_rgrad` and `retraction` on its manifold,
10
+ so it works with any manifold that has both (`FixedRankManifold`,
11
+ `SPDManifold`, `ProductManifold`). The flow is:
12
+
13
+ 1. Caller computes the ambient gradient `g` of the objective.
14
+ 2. `optimizer.step(point, g)` converts `g` using the metric at
15
+ `point`, scales by `-lr`, and retracts back onto the manifold.
16
+ 3. Returns the new point. Caller drives the loop.
17
+
18
+ The functional helper `riemannian_sgd_step` exposes the same logic
19
+ without state, useful for tests and for integrating into custom
20
+ training loops.
21
+
22
+ Why no Adam (or RMSProp, AdamW, ...): adaptive step-size schemes are
23
+ user-side ergonomics, not part of the math of optimization on a
24
+ manifold. SGD uses metric gradient conversion followed by retraction.
25
+ Users who want Adam-style rates can compute their adaptive step in
26
+ ambient space and pass the rescaled gradient into `step()`.
27
+
28
+ Planned:
29
+ - `torch.optim.Optimizer` integration via a `ManifoldParameter`
30
+ wrapper so manifold params can be mixed with ambient params in a
31
+ normal PyTorch training loop. Out of scope for v1.
32
+ - Trust-region and conjugate-gradient solvers.
33
+ """
34
+
35
+ from holonomy_lib.optimization.base import RiemannianOptimizer
36
+ from holonomy_lib.optimization.sgd import RiemannianSGD, riemannian_sgd_step
37
+
38
+ __all__ = [
39
+ "RiemannianOptimizer",
40
+ "RiemannianSGD",
41
+ "riemannian_sgd_step",
42
+ ]
@@ -0,0 +1,65 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Base class for Riemannian optimizers.
5
+
6
+ Subclasses implement `step(point, ambient_grad) -> new_point`. The base
7
+ class holds only the manifold reference. Hyperparameters and any per-step
8
+ state belong to the subclass.
9
+
10
+ The "manifold" parameter must expose `egrad_to_rgrad(point, ambient)` and
11
+ `retraction(point, tangent)` methods with the same signatures as
12
+ `FixedRankManifold` and `SPDManifold`. The optimizer is otherwise
13
+ agnostic to which manifold it's operating on.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from abc import ABC, abstractmethod
19
+ from typing import Any
20
+
21
+
22
+ class RiemannianOptimizer(ABC):
23
+ """Abstract base for Riemannian optimizers.
24
+
25
+ An optimizer instance holds:
26
+ - the manifold (stored by `__init__`; provides gradient conversion and
27
+ retraction),
28
+ - hyperparameters (learning rate, momentum coefficients, etc.),
29
+ - per-instance state (moment buffers, step count) if stateful.
30
+
31
+ The subclass implements `step(point, ambient_grad)`, returning the
32
+ new point on the manifold. Caller is responsible for storing the
33
+ new point and reading its gradient on the next iteration.
34
+
35
+ Why this shape: the `FixedRankManifold` and `SPDManifold` methods
36
+ return new points rather than mutating in place, so the optimizer
37
+ follows the same convention. Wrapping a `torch.optim.Optimizer` around
38
+ this, for `loss.backward()` flows, is possible later. The explicit
39
+ point-in / point-out contract is the simpler primitive to build on.
40
+ """
41
+
42
+ def __init__(self, manifold: Any) -> None:
43
+ self.manifold = manifold
44
+
45
+ @abstractmethod
46
+ def step(self, point: Any, ambient_grad: Any) -> Any:
47
+ """Apply one optimizer step and return the new point.
48
+
49
+ Args:
50
+ point: current point on the manifold (tensor for SPD, triple
51
+ of tensors for FixedRank).
52
+ ambient_grad: gradient of the objective with respect to the
53
+ ambient (embedding-space) coordinates. Same structure as
54
+ an ambient tangent: a single tensor for both SPD and
55
+ FixedRank (FixedRank tangents are stored in ambient
56
+ `(B, m, n)` form per Vandereycken 2013).
57
+
58
+ Returns:
59
+ new_point: the point after the step.
60
+
61
+ References:
62
+ Bonnabel (2013), Stochastic Gradient Descent on Riemannian
63
+ Manifolds, §III.
64
+ """
65
+ ...
@@ -0,0 +1,112 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Riemannian steepest descent (RSGD).
5
+
6
+ For a Riemannian manifold M with `egrad_to_rgrad` (ambient gradient to
7
+ Riemannian gradient, metric included) and `retraction` (move along a
8
+ tangent vector to a new point), Riemannian SGD takes the ambient
9
+ gradient `g` at the current point, converts it with the metric, scales
10
+ by `-lr`, and retracts back onto M.
11
+
12
+ ξ = egrad_to_rgrad(point, g)
13
+ point' = retraction(point, -lr · ξ)
14
+
15
+ References:
16
+ Absil, P.-A., Mahony, R., Sepulchre, R. (2008). Optimization
17
+ Algorithms on Matrix Manifolds. Princeton University Press,
18
+ §4.1-§4.2.
19
+ Bonnabel, S. (2013). Stochastic gradient descent on Riemannian
20
+ manifolds. IEEE TAC 58(9):2217-2229.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ import torch
28
+
29
+ from holonomy_lib.optimization.base import RiemannianOptimizer
30
+
31
+
32
+ def riemannian_sgd_step(
33
+ manifold: Any,
34
+ point: Any,
35
+ ambient_grad: torch.Tensor,
36
+ lr: float,
37
+ ) -> Any:
38
+ """Functional one-step Riemannian SGD.
39
+
40
+ Args:
41
+ manifold: object exposing `egrad_to_rgrad(point, ambient)` and
42
+ `retraction(point, tangent)` methods (e.g.
43
+ `FixedRankManifold`, `SPDManifold`).
44
+ point: current point on the manifold (tensor for SPD; triple
45
+ of tensors for FixedRank).
46
+ ambient_grad: ambient-space gradient of the objective. Always
47
+ a single `torch.Tensor`; for `FixedRankManifold` this is the
48
+ `(B, m, n)` ambient gradient per Vandereycken (2013)'s
49
+ embedded-tangent representation.
50
+ lr: learning rate, a positive scalar. Not checked here;
51
+ `RiemannianSGD` rejects lr <= 0.
52
+
53
+ Returns:
54
+ new_point: the manifold-constrained next iterate.
55
+
56
+ References:
57
+ Absil, P.-A., Mahony, R., Sepulchre, R. (2008). Optimization
58
+ Algorithms on Matrix Manifolds. Princeton University Press,
59
+ §4.1; retraction-based steepest descent.
60
+ Bonnabel, S. (2013). Stochastic gradient descent on Riemannian
61
+ manifolds. IEEE TAC 58(9):2217-2229, §III.
62
+ """
63
+ # A plain tangent projection is the right conversion only when the metric
64
+ # is the embedded Euclidean one, as for FixedRankManifold. SPD's
65
+ # affine-invariant metric needs S sym(G) S, so each manifold does its own
66
+ # conversion through egrad_to_rgrad. The step then scales that tangent by
67
+ # -lr and retracts it back onto the manifold.
68
+ tangent = manifold.egrad_to_rgrad(point, ambient_grad)
69
+ return manifold.retraction(point, -lr * tangent)
70
+
71
+
72
+ class RiemannianSGD(RiemannianOptimizer):
73
+ """Optimizer that holds a manifold and a learning rate.
74
+
75
+ Each `step` call forwards to `riemannian_sgd_step`. The object keeps no
76
+ moment buffers or step count, so nothing carries over between steps.
77
+
78
+ Args:
79
+ manifold: the underlying manifold object (see `riemannian_sgd_step`).
80
+ lr: learning rate, must be > 0 (ValueError otherwise). The default
81
+ `1e-2` is a starting point, not a tuned value; tune it per problem.
82
+
83
+ Example:
84
+ >>> from holonomy_lib.manifolds import SPDManifold
85
+ >>> mfd = SPDManifold(n=4)
86
+ >>> opt = RiemannianSGD(mfd, lr=0.05)
87
+ >>> point = mfd.random_point(batch_size=1)
88
+ >>> # in a training loop:
89
+ >>> ambient_grad = compute_ambient_grad(point)
90
+ >>> point = opt.step(point, ambient_grad)
91
+ """
92
+
93
+ def __init__(self, manifold: Any, lr: float = 1e-2) -> None:
94
+ super().__init__(manifold)
95
+ if lr <= 0:
96
+ raise ValueError(f"lr must be > 0, got {lr}")
97
+ self.lr = lr
98
+
99
+ def step(self, point: Any, ambient_grad: Any) -> Any:
100
+ """Convert the ambient gradient and retract one descent step.
101
+
102
+ Args:
103
+ point: current point on the manifold.
104
+ ambient_grad: ambient-space gradient of the objective at `point`.
105
+ Returns:
106
+ new_point: the point after the step.
107
+
108
+ References:
109
+ Bonnabel (2013), Stochastic Gradient Descent on Riemannian
110
+ Manifolds, §III.
111
+ """
112
+ return riemannian_sgd_step(self.manifold, point, ambient_grad, self.lr)
holonomy_lib/py.typed ADDED
File without changes
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """holonomy_lib.sheaf; cellular sheaves and sheaf Laplacians on graphs.
5
+
6
+ A **cellular sheaf** on a graph attaches a finite-dimensional vector
7
+ space (a "stalk") `F(σ)` to each simplex σ (node or edge) and a linear
8
+ **restriction map** `F_{τ ≤ σ}: F(τ) → F(σ)` for each face relation
9
+ τ ≤ σ. The sheaf is the data of those stalks + maps; the sheaf
10
+ Laplacian generalizes the graph Laplacian by capturing the
11
+ "disagreement" of node-stalk values once they are pushed up to each
12
+ incident edge stalk:
13
+
14
+ L_F = δ^T δ, δ_e(x) = F_{u ≤ e}(x_u) − F_{v ≤ e}(x_v)
15
+ for each edge e = (u, v).
16
+
17
+ When all stalks have dimension 1 and every restriction map is the
18
+ identity (i.e., the *trivial sheaf*), `L_F` reduces to the standard
19
+ combinatorial graph Laplacian. With higher-dimensional stalks and
20
+ non-trivial restriction maps it captures structure that a scalar
21
+ Laplacian cannot; orientation sheaves, signed cellular sheaves,
22
+ connection Laplacians, and the Laplacians used by Neural Sheaf
23
+ Diffusion (Bodnar et al. 2022).
24
+
25
+ Currently exposed:
26
+ GraphSheaf ; dataclass holding edges + stalks + maps
27
+ HeterogeneousGraphSheaf ; per-node (heterogeneous) stalk dimensions
28
+ sheaf_coboundary(sheaf) ; δ as a (n_e·d_e, n_v·d_v) tensor
29
+ sheaf_laplacian(sheaf) ; L_F = δ^T δ, (n_v·d_v, n_v·d_v), PSD
30
+ sheaf_coboundary_sparse(sheaf); sparse COO coboundary
31
+ sheaf_laplacian_sparse(sheaf); sparse COO Laplacian
32
+ sheaf_bottom_spectrum(sheaf, k); bottom-k spectrum with optional kernel removal
33
+ sheaf_dirichlet_energy(sheaf, x)
34
+ ; `x^T L_F x`, batched-first
35
+
36
+ Restricted to **node-edge sheaves on graphs**. Higher-dimensional
37
+ cellular sheaves on simplicial complexes (with 2-cells / faces and
38
+ the corresponding ∂_1 ∘ ∂_2 = 0 chain identity) are not implemented.
39
+
40
+ References:
41
+ Hansen, J., Ghrist, R. (2019). Toward a spectral theory of cellular
42
+ sheaves. Journal of Applied and Computational Topology 3:315–358.
43
+ The canonical reference for sheaf Laplacians and their spectral
44
+ theory.
45
+ Bodnar, C., Di Giovanni, F., Chamberlain, B. P., Liò, P., Bronstein,
46
+ M. M. (2022). Neural sheaf diffusion: A topological perspective on
47
+ heterophily and oversmoothing in GNNs. NeurIPS 2022. Brought
48
+ sheaf Laplacians into mainstream geometric deep learning.
49
+ Curry, J. (2014). Sheaves, Cosheaves and Applications. PhD thesis,
50
+ University of Pennsylvania. The applied-topology textbook on
51
+ cellular sheaves; introduces the explicit cellular formulation
52
+ that all the computational work uses.
53
+ """
54
+
55
+ from holonomy_lib.sheaf.graph_sheaf import GraphSheaf, HeterogeneousGraphSheaf
56
+ from holonomy_lib.sheaf.laplacian import (
57
+ sheaf_bottom_spectrum,
58
+ sheaf_coboundary,
59
+ sheaf_coboundary_sparse,
60
+ sheaf_dirichlet_energy,
61
+ sheaf_laplacian,
62
+ sheaf_laplacian_sparse,
63
+ )
64
+
65
+ __all__ = [
66
+ "GraphSheaf",
67
+ "HeterogeneousGraphSheaf",
68
+ "sheaf_bottom_spectrum",
69
+ "sheaf_coboundary",
70
+ "sheaf_coboundary_sparse",
71
+ "sheaf_dirichlet_energy",
72
+ "sheaf_laplacian",
73
+ "sheaf_laplacian_sparse",
74
+ ]
@@ -0,0 +1,352 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """GraphSheaf dataclass; the data of a cellular sheaf on a graph.
5
+
6
+ The sheaf attaches a `d_v`-dim stalk to each node and a `d_e`-dim
7
+ stalk to each edge. For every edge `e = (u, v)` it carries two
8
+ restriction maps:
9
+
10
+ F_left[e] : R^{d_v} → R^{d_e} (the map from node u's stalk)
11
+ F_right[e] : R^{d_v} → R^{d_e} (the map from node v's stalk)
12
+
13
+ The coboundary on a node-stalk vector `x ∈ R^{n_v · d_v}` reads each
14
+ edge's contribution as `F_left[e] · x_u − F_right[e] · x_v`. The
15
+ sheaf Laplacian is `δ^T δ`.
16
+
17
+ Representations:
18
+ - `GraphSheaf` stores uniform stalk dimensions in dense tensors.
19
+ `HeterogeneousGraphSheaf` stores per-cell dimensions and ragged maps.
20
+ - Undirected graphs: edges are unordered, but the (u, v) order in each
21
+ tuple sets the sign convention of `δ`. Flipping an edge to (v, u)
22
+ and swapping its left and right maps gives the same sheaf, with that
23
+ edge's block of `δ` negated. The Laplacian `L_F = δ^T δ` does not
24
+ change, since it is quadratic in `δ`.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+
31
+ import torch
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class GraphSheaf:
36
+ """A cellular sheaf on a graph.
37
+
38
+ Attributes:
39
+ n_nodes: number of nodes (each carries a d_v-dim stalk).
40
+ edges: `(n_edges, 2)` int64 tensor of `(u, v)` pairs.
41
+ node_stalk_dim: `d_v`.
42
+ edge_stalk_dim: `d_e`.
43
+ F_left: `(n_edges, d_e, d_v)` restriction maps for the
44
+ `u`-side of each edge.
45
+ F_right: `(n_edges, d_e, d_v)` restriction maps for the
46
+ `v`-side of each edge.
47
+
48
+ Validation:
49
+ - `edges` must be `(n_edges, 2)` int64.
50
+ - `F_left`, `F_right` shapes match `(n_edges, d_e, d_v)`.
51
+ - All tensors share `device`; restriction maps share a real float dtype.
52
+
53
+ References:
54
+ Hansen, J., Ghrist, R. (2019). Toward a spectral theory of cellular
55
+ sheaves. Journal of Applied and Computational Topology 3:315–358, §3.
56
+ """
57
+
58
+ n_nodes: int
59
+ edges: torch.Tensor
60
+ node_stalk_dim: int
61
+ edge_stalk_dim: int
62
+ F_left: torch.Tensor
63
+ F_right: torch.Tensor
64
+
65
+ def __post_init__(self) -> None:
66
+ if not isinstance(self.n_nodes, int) or isinstance(self.n_nodes, bool):
67
+ raise ValueError("n_nodes must be an integer")
68
+ if self.n_nodes <= 0:
69
+ raise ValueError(f"n_nodes must be > 0; got {self.n_nodes}")
70
+ if (not isinstance(self.node_stalk_dim, int)
71
+ or isinstance(self.node_stalk_dim, bool)):
72
+ raise ValueError("node_stalk_dim must be an integer")
73
+ if (not isinstance(self.edge_stalk_dim, int)
74
+ or isinstance(self.edge_stalk_dim, bool)):
75
+ raise ValueError("edge_stalk_dim must be an integer")
76
+ if self.node_stalk_dim <= 0:
77
+ raise ValueError(
78
+ f"node_stalk_dim must be > 0; got {self.node_stalk_dim}"
79
+ )
80
+ if self.edge_stalk_dim <= 0:
81
+ raise ValueError(
82
+ f"edge_stalk_dim must be > 0; got {self.edge_stalk_dim}"
83
+ )
84
+ if self.edges.ndim != 2 or self.edges.shape[-1] != 2:
85
+ raise ValueError(
86
+ f"edges must be (n_edges, 2); got "
87
+ f"shape={tuple(self.edges.shape)}"
88
+ )
89
+ n_e = self.edges.shape[0]
90
+ d_e, d_v = self.edge_stalk_dim, self.node_stalk_dim
91
+ for name, t in (("F_left", self.F_left), ("F_right", self.F_right)):
92
+ if t.shape != (n_e, d_e, d_v):
93
+ raise ValueError(
94
+ f"{name} must have shape (n_edges={n_e}, "
95
+ f"edge_stalk_dim={d_e}, node_stalk_dim={d_v}); got "
96
+ f"shape={tuple(t.shape)}"
97
+ )
98
+ if self.edges.dtype != torch.int64:
99
+ raise ValueError("edges must have dtype int64")
100
+ if not self.F_left.is_floating_point() or not self.F_right.is_floating_point():
101
+ raise ValueError("restriction maps must have a real floating dtype")
102
+ if self.edges.device != self.F_left.device:
103
+ raise ValueError("edges and restriction maps must share device")
104
+ if self.F_left.device != self.F_right.device:
105
+ raise ValueError(
106
+ f"F_left and F_right must be on the same device; "
107
+ f"got {self.F_left.device} vs {self.F_right.device}"
108
+ )
109
+ if self.F_left.dtype != self.F_right.dtype:
110
+ raise ValueError(
111
+ f"F_left and F_right must share dtype; got "
112
+ f"{self.F_left.dtype} vs {self.F_right.dtype}"
113
+ )
114
+ # Negative indices would wrap around silently in torch indexing, so
115
+ # both ends of the range are checked here.
116
+ if (self.edges < 0).any() or (self.edges >= self.n_nodes).any():
117
+ raise ValueError(
118
+ f"edges entries must be in [0, n_nodes={self.n_nodes})"
119
+ )
120
+ # A self-loop adds a diagonal term (F_left - F_right)^T (F_left - F_right)
121
+ # to the sheaf Laplacian. The library drops self-loops from graphs
122
+ # (CONVENTIONS.md, via `_graph_utils.drop_self_loops`), so the sheaf
123
+ # code refuses them too. To proceed, drop them first:
124
+ # `mask = u != v; edges = edges[mask]; F_left = F_left[mask];
125
+ # F_right = F_right[mask]`.
126
+ if (self.edges[:, 0] == self.edges[:, 1]).any():
127
+ raise ValueError(
128
+ "self-loops (u == v in edges) are not supported in v1; "
129
+ "drop them before constructing the GraphSheaf"
130
+ )
131
+ # Duplicate unordered endpoint pairs would silently double the
132
+ # off-diagonal mass of the sheaf Laplacian, breaking the
133
+ # "trivial sheaf → graph Laplacian" reduction. Reject them.
134
+ unique_edges = torch.unique(self.edges.sort(dim=-1).values, dim=0)
135
+ if unique_edges.shape[0] != self.edges.shape[0]:
136
+ raise ValueError(
137
+ "duplicate edges are not supported in v1; "
138
+ "the resulting sheaf Laplacian would double-count "
139
+ "the duplicated pair's off-diagonal block"
140
+ )
141
+
142
+ @property
143
+ def n_edges(self) -> int:
144
+ return int(self.edges.shape[0])
145
+
146
+ @property
147
+ def total_node_dim(self) -> int:
148
+ """Length of the stacked node-stalk vector, `n_nodes · d_v`."""
149
+ return self.n_nodes * self.node_stalk_dim
150
+
151
+ @property
152
+ def device(self) -> torch.device:
153
+ return self.F_left.device
154
+
155
+ @property
156
+ def dtype(self) -> torch.dtype:
157
+ return self.F_left.dtype
158
+
159
+
160
+ @staticmethod
161
+ def trivial(
162
+ n_nodes: int,
163
+ edges: torch.Tensor,
164
+ stalk_dim: int = 1,
165
+ device: str | torch.device | None = None,
166
+ dtype: torch.dtype = torch.float64,
167
+ ) -> GraphSheaf:
168
+ """Build the trivial sheaf: identity restriction maps on every edge.
169
+
170
+ With `stalk_dim = 1` this is the sheaf whose Laplacian equals the
171
+ standard combinatorial graph Laplacian. With `stalk_dim = d > 1`,
172
+ node-major ordering gives `L ⊗ I_d`, which is permutation-similar to
173
+ a block diagonal matrix of `d` copies of L.
174
+
175
+ Args:
176
+ n_nodes: number of nodes.
177
+ edges: `(n_edges, 2)` int64 `(u, v)` pairs.
178
+ stalk_dim: stalk dimension `d` on every node and edge; must be > 0.
179
+ device: target device. Defaults to the device of `edges`, and an
180
+ explicit value moves both the indices and the maps there.
181
+ dtype: float dtype of the restriction maps.
182
+
183
+ Returns:
184
+ A `GraphSheaf` with `node_stalk_dim == edge_stalk_dim == stalk_dim`.
185
+
186
+ References:
187
+ Hansen-Ghrist (2019), §3.1, constant sheaves and graph Laplacians.
188
+ """
189
+ if edges.ndim != 2 or edges.shape[-1] != 2:
190
+ raise ValueError(
191
+ f"edges must be (n_edges, 2); got shape={tuple(edges.shape)}"
192
+ )
193
+ if edges.dtype != torch.int64:
194
+ raise ValueError("edges must have dtype int64")
195
+ if not isinstance(stalk_dim, int) or isinstance(stalk_dim, bool):
196
+ raise ValueError("stalk_dim must be an integer")
197
+ if stalk_dim <= 0:
198
+ raise ValueError("stalk_dim must be > 0")
199
+ if not dtype.is_floating_point:
200
+ raise ValueError("restriction maps must have a real floating dtype")
201
+ n_e = edges.shape[0]
202
+ device = edges.device if device is None else device
203
+ eye = torch.eye(stalk_dim, dtype=dtype, device=device)
204
+ F = eye.unsqueeze(dim=0).expand(n_e, stalk_dim, stalk_dim).contiguous()
205
+ edges = edges.to(device=device, dtype=torch.int64)
206
+ # F_left and F_right get separate copies, so writing into one of them
207
+ # does not change the other.
208
+ return GraphSheaf(
209
+ n_nodes=n_nodes,
210
+ edges=edges,
211
+ node_stalk_dim=stalk_dim,
212
+ edge_stalk_dim=stalk_dim,
213
+ F_left=F.clone(),
214
+ F_right=F.clone(),
215
+ )
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class HeterogeneousGraphSheaf:
220
+ """A cellular sheaf with PER-NODE (heterogeneous) stalk dimensions.
221
+
222
+ The generalization of `GraphSheaf` to per-node stalk dimensions: node `i`
223
+ carries a `d_v[i]`-dim stalk and edge `e` a `d_e[e]`-dim stalk, all free
224
+ to differ. For edge `e = (u, v)`:
225
+
226
+ F_left[e] : R^{d_v[u]} -> R^{d_e[e]} shape (d_e[e], d_v[u])
227
+ F_right[e] : R^{d_v[v]} -> R^{d_e[e]} shape (d_e[e], d_v[v])
228
+
229
+ Coboundary, Laplacian, and Dirichlet energy mean exactly what they do in the
230
+ uniform case (each edge block of `δ` is `F_left[e]·x_u − F_right[e]·x_v`,
231
+ `L_F = δ^T δ`); only the per-node/per-edge dims and the ragged restriction
232
+ maps differ. The maps are stored as a tuple of tensors (ragged), since the
233
+ dims vary, rather than one dense `(n_e, d_e, d_v)` block.
234
+
235
+ Use this when a node's stalk dimension is a quantity of its own, such as
236
+ each concept's relational rank, rather than one global K.
237
+
238
+ Attributes:
239
+ n_nodes: number of nodes.
240
+ edges: `(n_edges, 2)` int64 `(u, v)` pairs.
241
+ node_stalk_dims: `(n_nodes,)` int64, per-node stalk dim `d_v[i] > 0`.
242
+ edge_stalk_dims: `(n_edges,)` int64, per-edge stalk dim `d_e[e] > 0`.
243
+ F_left: length-`n_edges` tuple; `F_left[e]` is `(d_e[e], d_v[u])`.
244
+ F_right: length-`n_edges` tuple; `F_right[e]` is `(d_e[e], d_v[v])`.
245
+
246
+ References:
247
+ Hansen-Ghrist (2019): cellular sheaves carry per-cell stalks of arbitrary
248
+ finite dimension; the uniform `GraphSheaf` is the special case.
249
+ """
250
+
251
+ n_nodes: int
252
+ edges: torch.Tensor
253
+ node_stalk_dims: torch.Tensor
254
+ edge_stalk_dims: torch.Tensor
255
+ F_left: tuple[torch.Tensor, ...]
256
+ F_right: tuple[torch.Tensor, ...]
257
+
258
+ def __post_init__(self) -> None:
259
+ if not isinstance(self.n_nodes, int) or isinstance(self.n_nodes, bool):
260
+ raise ValueError("n_nodes must be an integer")
261
+ if self.n_nodes <= 0:
262
+ raise ValueError(f"n_nodes must be > 0; got {self.n_nodes}")
263
+ if self.edges.ndim != 2 or self.edges.shape[-1] != 2:
264
+ raise ValueError(
265
+ f"edges must be (n_edges, 2); got shape={tuple(self.edges.shape)}"
266
+ )
267
+ n_e = int(self.edges.shape[0])
268
+ if tuple(self.node_stalk_dims.shape) != (self.n_nodes,):
269
+ raise ValueError(
270
+ f"node_stalk_dims must be (n_nodes={self.n_nodes},); got "
271
+ f"shape={tuple(self.node_stalk_dims.shape)}"
272
+ )
273
+ if tuple(self.edge_stalk_dims.shape) != (n_e,):
274
+ raise ValueError(
275
+ f"edge_stalk_dims must be (n_edges={n_e},); got "
276
+ f"shape={tuple(self.edge_stalk_dims.shape)}"
277
+ )
278
+ for name, tensor in (("edges", self.edges),
279
+ ("node_stalk_dims", self.node_stalk_dims),
280
+ ("edge_stalk_dims", self.edge_stalk_dims)):
281
+ if tensor.dtype != torch.int64:
282
+ raise ValueError(f"{name} must have dtype int64")
283
+ if (self.node_stalk_dims <= 0).any() or (self.edge_stalk_dims <= 0).any():
284
+ raise ValueError("all stalk dims must be > 0")
285
+ if len(self.F_left) != n_e or len(self.F_right) != n_e:
286
+ raise ValueError(
287
+ f"F_left and F_right must each have n_edges={n_e} maps; got "
288
+ f"{len(self.F_left)} and {len(self.F_right)}"
289
+ )
290
+ if (self.edges < 0).any() or (self.edges >= self.n_nodes).any():
291
+ raise ValueError(
292
+ f"edges entries must be in [0, n_nodes={self.n_nodes})"
293
+ )
294
+ if (self.edges[:, 0] == self.edges[:, 1]).any():
295
+ raise ValueError(
296
+ "self-loops (u == v in edges) are not supported; drop them first"
297
+ )
298
+ if torch.unique(self.edges.sort(dim=-1).values, dim=0).shape[0] != n_e:
299
+ raise ValueError("duplicate edges are not supported")
300
+ # Each F_left[e] must be (d_e[e], d_v[u]) and each F_right[e] must be
301
+ # (d_e[e], d_v[v]). The shapes change from edge to edge, so this is
302
+ # checked one edge at a time. The sizes and endpoints are pulled into
303
+ # Python lists once, so the loop never reads the device tensors.
304
+ node_dims = self.node_stalk_dims.tolist()
305
+ edge_dims = self.edge_stalk_dims.tolist()
306
+ uv = self.edges.tolist()
307
+ for e in range(n_e):
308
+ u, v = uv[e]
309
+ want_left = (edge_dims[e], node_dims[u])
310
+ want_right = (edge_dims[e], node_dims[v])
311
+ if tuple(self.F_left[e].shape) != want_left:
312
+ raise ValueError(
313
+ f"F_left[{e}] must be (d_e[e], d_v[u])={want_left}; got "
314
+ f"{tuple(self.F_left[e].shape)}"
315
+ )
316
+ if tuple(self.F_right[e].shape) != want_right:
317
+ raise ValueError(
318
+ f"F_right[{e}] must be (d_e[e], d_v[v])={want_right}; got "
319
+ f"{tuple(self.F_right[e].shape)}"
320
+ )
321
+ # With no edges there are no maps to compare, so the device and dtype
322
+ # check only runs when at least one edge exists.
323
+ if n_e > 0:
324
+ dev, dt = self.F_left[0].device, self.F_left[0].dtype
325
+ for t in (*self.F_left, *self.F_right):
326
+ if not t.is_floating_point():
327
+ raise ValueError("restriction maps must have a real floating dtype")
328
+ if t.device != dev or t.dtype != dt:
329
+ raise ValueError(
330
+ "all restriction maps must share device and dtype"
331
+ )
332
+
333
+ @property
334
+ def n_edges(self) -> int:
335
+ return int(self.edges.shape[0])
336
+
337
+ @property
338
+ def total_node_dim(self) -> int:
339
+ """Length of the stacked node-stalk vector, `Σ_i d_v[i]`."""
340
+ return int(self.node_stalk_dims.sum())
341
+
342
+ # With no edges there is no map to read the device or dtype from. The
343
+ # device falls back to the stalk-dim tensor, and the dtype to float64,
344
+ # the default that `trivial` uses.
345
+ @property
346
+ def device(self) -> torch.device:
347
+ return (self.F_left[0].device if self.n_edges
348
+ else self.node_stalk_dims.device)
349
+
350
+ @property
351
+ def dtype(self) -> torch.dtype:
352
+ return self.F_left[0].dtype if self.n_edges else torch.float64