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,529 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Sheaf coboundary, sheaf Laplacian, and Dirichlet energy on graphs.
5
+
6
+ The coboundary operator `δ: C^0 → C^1` sends a node-stalk vector
7
+ `x ∈ R^{n_v · d_v}` to an edge-stalk vector whose value at each edge
8
+ `e = (u, v)` is `F_left[e] · x_u − F_right[e] · x_v`. Stacked across
9
+ all edges, `δ` is an `(n_e · d_e) × (n_v · d_v)` matrix.
10
+
11
+ The sheaf Laplacian is `L_F = δ^T δ`, which is symmetric PSD by
12
+ construction. Its kernel is the space of **global sections**: tuples
13
+ of node-stalk values that all incident edges agree on under the
14
+ restriction maps. For the trivial sheaf this kernel is the constant
15
+ vectors in each stalk channel on each connected component, as for
16
+ the scalar graph Laplacian.
17
+
18
+ Dense and sparse COO assembly are available through `sheaf_coboundary`
19
+ and `sheaf_coboundary_sparse`; the Laplacian has matching dense and sparse
20
+ entry points. `sheaf_bottom_spectrum` solves for its lowest eigenmodes.
21
+
22
+ References:
23
+ Hansen-Ghrist (2019), §3-4; sheaf Laplacian + spectral theory.
24
+ Bodnar et al. (2022), eq. 6; the cellular-sheaf Laplacian as a
25
+ block matrix.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Literal
31
+
32
+ import torch
33
+
34
+ from holonomy_lib.sheaf.graph_sheaf import GraphSheaf, HeterogeneousGraphSheaf
35
+
36
+ # Dense-coboundary allocation guard: refuse to allocate beyond this
37
+ # many bytes for the staging tensor + δ. 2 GiB is a chosen threshold for
38
+ # machines with 24 to 32 GB of host RAM; the sparse path
39
+ # (`sheaf_coboundary_sparse`) covers larger graphs.
40
+ # **Scale of validity**: byte budget for the dense implementation.
41
+ # Cataloged as `SHEAF_DENSE_BYTES_CAP`.
42
+ SHEAF_DENSE_BYTES_CAP: int = 2 * 2**30
43
+
44
+ # Extra bottom modes requested beyond `k` in `sheaf_bottom_spectrum` so
45
+ # the kernel (global sections, λ≈0) can be dropped and still leave `k`
46
+ # nonzero modes. The sheaf kernel of a (near-)connected corpus is small;
47
+ # 16 covers it, and the solve raises with instructions when a sheaf has
48
+ # more global sections than the headroom. Value from synoros-substrate
49
+ # `sheaf_scale.py` (validated 2026-06-06). **Scale of validity**:
50
+ # corpora whose global-section space is ≤ 16-dimensional; raise the
51
+ # parameter for heavily disconnected sheaves. Catalog:
52
+ # `SHEAF_KERNEL_HEADROOM` in `notes/magic_numbers.md`.
53
+ SHEAF_KERNEL_HEADROOM: int = 16
54
+
55
+ # LOBPCG searches over three blocks (Knyazev 2001): the current Ritz block,
56
+ # the residuals, and the previous search directions. The 1 is the Ritz
57
+ # block and the 2 is the residual and previous-direction pair. torch.lobpcg
58
+ # needs room for all three.
59
+ _LOBPCG_SUBSPACE_BLOCKS: int = 1 + 2
60
+
61
+
62
+ def _coboundary_coo(
63
+ sheaf: GraphSheaf | HeterogeneousGraphSheaf,
64
+ ) -> tuple[torch.Tensor, torch.Tensor, tuple[int, int]]:
65
+ """COO triplets of the coboundary `δ`: `(indices (2, nnz), values (nnz,),
66
+ shape (Σd_e, Σd_v))`.
67
+
68
+ Vectorized ragged assembly, no Python loop over edges (one flattening
69
+ pass over the ragged restriction-map tuples aside): edge `e = (u, v)`
70
+ contributes the block `+F_left[e]` at rows `[eoff[e], eoff[e]+d_e)` ×
71
+ cols `[voff[u], voff[u]+d_v[u])` and `−F_right[e]` at the same rows ×
72
+ `v`'s columns. Row/col indices come from `repeat_interleave` + exclusive
73
+ prefix-sum arithmetic over the per-edge block sizes. Handles both the
74
+ uniform `GraphSheaf` (constant dims) and the heterogeneous sheaf with
75
+ the same code path.
76
+
77
+ The index sets of distinct blocks are disjoint: different edges occupy
78
+ different row blocks, and within an edge `u != v` (self-loops rejected
79
+ at construction) separates the two column blocks.
80
+ """
81
+ device, dtype = sheaf.device, sheaf.dtype
82
+ if isinstance(sheaf, HeterogeneousGraphSheaf):
83
+ dv = sheaf.node_stalk_dims.to(device=device, dtype=torch.long)
84
+ de = sheaf.edge_stalk_dims.to(device=device, dtype=torch.long)
85
+ else:
86
+ dv = torch.full(
87
+ (sheaf.n_nodes,), sheaf.node_stalk_dim,
88
+ device=device, dtype=torch.long,
89
+ )
90
+ de = torch.full(
91
+ (sheaf.n_edges,), sheaf.edge_stalk_dim,
92
+ device=device, dtype=torch.long,
93
+ )
94
+ if isinstance(sheaf, HeterogeneousGraphSheaf):
95
+ shape = (sum(t.shape[0] for t in sheaf.F_left), sheaf.total_node_dim)
96
+ else:
97
+ shape = (sheaf.n_edges * sheaf.edge_stalk_dim, sheaf.total_node_dim)
98
+
99
+ n_e = sheaf.n_edges
100
+ if n_e == 0:
101
+ return (
102
+ torch.zeros(2, 0, device=device, dtype=torch.long),
103
+ torch.zeros(0, device=device, dtype=dtype),
104
+ shape,
105
+ )
106
+
107
+ if isinstance(sheaf, HeterogeneousGraphSheaf):
108
+ left_vals = torch.cat([t.reshape(-1) for t in sheaf.F_left])
109
+ right_vals = torch.cat([t.reshape(-1) for t in sheaf.F_right])
110
+ else:
111
+ left_vals = sheaf.F_left.reshape(-1)
112
+ right_vals = sheaf.F_right.reshape(-1)
113
+
114
+ edges = sheaf.edges.to(device=device, dtype=torch.long)
115
+ eoff = torch.cumsum(de, dim=0) - de # exclusive row offsets
116
+ voff = torch.cumsum(dv, dim=0) - dv # exclusive col offsets
117
+
118
+ def _side(node_idx: torch.Tensor, size: int) -> tuple[torch.Tensor, torch.Tensor]:
119
+ """Row and column COO indices for one side (left or right) of all edges.
120
+
121
+ Input: `node_idx` is the node on this side of each edge, `size` is the
122
+ total number of values on this side. Output: `(rows, cols)`, one entry
123
+ per value. Assumes the values are laid out edge by edge, row-major.
124
+ """
125
+ # Block e is (d_e[e], d_v[node]) laid out row-major in the flattened
126
+ # values; recover (row-in-block, col-in-block) from the flat position.
127
+ w = dv[node_idx] # (n_e,) block widths
128
+ m = de * w # (n_e,) block numels
129
+ starts = torch.cumsum(m, dim=0) - m # (n_e,) exclusive
130
+ eid = torch.repeat_interleave(
131
+ torch.arange(n_e, device=device), m,
132
+ ) # (nnz_side,)
133
+ # p is the position inside edge eid's block. Blocks are row-major with
134
+ # width w, so p // w is the row in the block and p % w is the column.
135
+ p = torch.arange(size, device=device) - starts[eid]
136
+ w_el = w[eid]
137
+ rows = eoff[eid] + p // w_el
138
+ cols = voff[node_idx][eid] + p % w_el
139
+ return rows, cols
140
+
141
+ rows_l, cols_l = _side(edges[:, 0], left_vals.numel())
142
+ rows_r, cols_r = _side(edges[:, 1], right_vals.numel())
143
+ indices = torch.stack(
144
+ (torch.cat((rows_l, rows_r)), torch.cat((cols_l, cols_r))),
145
+ )
146
+ values = torch.cat((left_vals, -right_vals))
147
+ return indices, values, shape
148
+
149
+
150
+ def _heterogeneous_coboundary(sheaf: HeterogeneousGraphSheaf) -> torch.Tensor:
151
+ """Dense coboundary `δ` for a heterogeneous (per-node-stalk) sheaf.
152
+
153
+ Built from the vectorized COO triplets of `_coboundary_coo`: block
154
+ positions are pairwise disjoint (no self-loops, no duplicate edges,
155
+ enforced at construction), so a single advanced-indexing assignment
156
+ places every entry exactly once; same semantics as a per-edge slice
157
+ write, without the Python loop over edges.
158
+ """
159
+ device, dtype = sheaf.device, sheaf.dtype
160
+ total_v = sheaf.total_node_dim
161
+ total_e = sum(t.shape[0] for t in sheaf.F_left)
162
+
163
+ bytes_per_elem = torch.empty((), dtype=dtype).element_size()
164
+ dense_bytes = 2 * total_e * total_v * bytes_per_elem
165
+ if dense_bytes > SHEAF_DENSE_BYTES_CAP:
166
+ raise RuntimeError(
167
+ f"sheaf_coboundary would allocate {dense_bytes:,} bytes for the dense "
168
+ f"δ tensor (Σd_e={total_e}, Σd_v={total_v}); the v1 dense path is "
169
+ f"capped at {SHEAF_DENSE_BYTES_CAP:,} bytes. Restrict to smaller "
170
+ f"sheaves or use sheaf_coboundary_sparse."
171
+ )
172
+
173
+ delta = torch.zeros(total_e, total_v, device=device, dtype=dtype)
174
+ if sheaf.n_edges == 0:
175
+ return delta
176
+
177
+ indices, values, _ = _coboundary_coo(sheaf)
178
+ delta[indices[0], indices[1]] = values
179
+ return delta
180
+
181
+
182
+ def sheaf_coboundary(sheaf: GraphSheaf | HeterogeneousGraphSheaf) -> torch.Tensor:
183
+ """Build the sheaf coboundary `δ` as a dense `(n_e·d_e, n_v·d_v)`
184
+ tensor.
185
+
186
+ For edge index `e = (u, v)`, the corresponding `d_e`-row block of
187
+ `δ` has `+F_left[e]` at columns `u·d_v : (u+1)·d_v` and
188
+ `−F_right[e]` at columns `v·d_v : (v+1)·d_v`.
189
+
190
+ Args:
191
+ sheaf: a `GraphSheaf` or `HeterogeneousGraphSheaf`.
192
+
193
+ Returns:
194
+ `(n_e · d_e, n_v · d_v)` coboundary on the sheaf's device/dtype.
195
+
196
+ References:
197
+ Hansen-Ghrist (2019), eq. 2.
198
+ """
199
+ if isinstance(sheaf, HeterogeneousGraphSheaf):
200
+ return _heterogeneous_coboundary(sheaf)
201
+ n_e = sheaf.n_edges
202
+ n_v = sheaf.n_nodes
203
+ d_e = sheaf.edge_stalk_dim
204
+ d_v = sheaf.node_stalk_dim
205
+ device, dtype = sheaf.device, sheaf.dtype
206
+
207
+ # Dense δ size guard. The staging tensor `block` is `(n_e, d_e, n_v · d_v)`
208
+ # and `delta` is `(n_e · d_e, n_v · d_v)`, the same size, so the factor 2
209
+ # counts both. Graphs with many nodes and edges, or large stalk dims, reach
210
+ # many GB without warning, so the size is checked against
211
+ # SHEAF_DENSE_BYTES_CAP before anything is allocated.
212
+ bytes_per_elem = torch.empty((), dtype=dtype).element_size()
213
+ dense_bytes = 2 * n_e * d_e * n_v * d_v * bytes_per_elem
214
+ if dense_bytes > SHEAF_DENSE_BYTES_CAP:
215
+ raise RuntimeError(
216
+ f"sheaf_coboundary would allocate {dense_bytes:,} bytes "
217
+ f"for the dense δ tensor (n_e={n_e}, d_e={d_e}, n_v={n_v}, "
218
+ f"d_v={d_v}); the v1 dense path is capped at "
219
+ f"{SHEAF_DENSE_BYTES_CAP:,} bytes. Use "
220
+ f"sheaf_coboundary_sparse for larger sheaves."
221
+ )
222
+
223
+ if n_e == 0:
224
+ return torch.zeros(0, n_v * d_v, device=device, dtype=dtype)
225
+
226
+ # Vectorized assembly: build a (n_e, d_e, n_v * d_v) staging tensor
227
+ # then reshape to (n_e * d_e, n_v * d_v). Allocating one big
228
+ # intermediate beats a Python loop over edges for any non-trivial n_e.
229
+ block = torch.zeros(n_e, d_e, n_v * d_v, device=device, dtype=dtype)
230
+ u_idx = sheaf.edges[:, 0] # (n_e,)
231
+ v_idx = sheaf.edges[:, 1] # (n_e,)
232
+ # For each edge e, F_left[e] goes into the columns of node u, which are
233
+ # [u·d_v : (u+1)·d_v]. u_cols and v_cols hold the column index of each
234
+ # node-stalk coordinate for every edge.
235
+ col_off = torch.arange(d_v, device=device)
236
+ u_cols = u_idx.unsqueeze(dim=-1) * d_v + col_off # (n_e, d_v)
237
+ v_cols = v_idx.unsqueeze(dim=-1) * d_v + col_off # (n_e, d_v)
238
+ # scatter_ writes block[e, :, u_cols[e, k]] = F_left[e, :, k]. The index is
239
+ # expanded over the d_e rows so it matches the (n_e, d_e, d_v) source.
240
+ block.scatter_(
241
+ dim=-1,
242
+ index=u_cols.unsqueeze(dim=-2).expand(n_e, d_e, d_v),
243
+ src=sheaf.F_left,
244
+ )
245
+ # scatter_ only wrote the u-columns. The v-columns are still zero for this
246
+ # edge because self-loops are rejected (u != v), so adding -F_right here
247
+ # is the same as writing it. The add also gives F_left - F_right if u == v,
248
+ # where both blocks land in the same columns.
249
+ block.scatter_add_(
250
+ dim=-1,
251
+ index=v_cols.unsqueeze(dim=-2).expand(n_e, d_e, d_v),
252
+ src=-sheaf.F_right,
253
+ )
254
+
255
+ return block.reshape(n_e * d_e, n_v * d_v)
256
+
257
+
258
+ def sheaf_laplacian(sheaf: GraphSheaf | HeterogeneousGraphSheaf) -> torch.Tensor:
259
+ """Sheaf Laplacian `L_F = δ^T δ`, a symmetric PSD operator of
260
+ shape `(n_v · d_v, n_v · d_v)`.
261
+
262
+ For the *trivial* sheaf (every restriction map is identity,
263
+ uniform stalk dim `d`):
264
+ d = 1 → L_F equals the combinatorial graph Laplacian.
265
+ d > 1 → L_F = L ⊗ I_d in node-major order, permutation-similar
266
+ to a block diagonal matrix of d scalar Laplacians.
267
+
268
+ Args:
269
+ sheaf: a `GraphSheaf` or `HeterogeneousGraphSheaf`.
270
+
271
+ Returns:
272
+ `(n_v · d_v, n_v · d_v)` symmetric PSD Laplacian.
273
+
274
+ References:
275
+ Hansen-Ghrist (2019), §3.1.
276
+ Bodnar et al. (2022), eq. 6.
277
+ """
278
+ delta = sheaf_coboundary(sheaf)
279
+ # δ^T δ is the Gram matrix of the columns of δ, so it is
280
+ # (n_v·d_v, n_v·d_v). Rounding in the matmul can leave a tiny asymmetry
281
+ # even though the exact product is symmetric. Symmetrizing lets callers
282
+ # pass the result straight to `torch.linalg.eigh`.
283
+ L = delta.mT @ delta
284
+ return 0.5 * (L + L.mT)
285
+
286
+
287
+ def sheaf_coboundary_sparse(
288
+ sheaf: GraphSheaf | HeterogeneousGraphSheaf,
289
+ ) -> torch.Tensor:
290
+ """Sheaf coboundary `δ` as a coalesced sparse-COO `(Σd_e, Σd_v)` tensor.
291
+
292
+ Same operator as `sheaf_coboundary`, without materializing the dense
293
+ matrix: `δ` has at most `Σ_e d_e[e]·(d_v[u_e] + d_v[v_e])` nonzeros
294
+ (two blocks per edge), so its sparse form scales with the edge count
295
+ rather than with `Σd_e · Σd_v`. Use for large sheaves past the dense
296
+ byte cap; convert with `.to_sparse_csr()` for repeated matvecs.
297
+
298
+ Args:
299
+ sheaf: a `GraphSheaf` or `HeterogeneousGraphSheaf`.
300
+
301
+ Returns:
302
+ Coalesced sparse-COO `(Σ d_e, Σ d_v)` coboundary on the sheaf's
303
+ device/dtype.
304
+
305
+ References:
306
+ Hansen-Ghrist (2019), eq. 2.
307
+ """
308
+ indices, values, shape = _coboundary_coo(sheaf)
309
+ return torch.sparse_coo_tensor(
310
+ indices, values, shape,
311
+ device=sheaf.device, dtype=sheaf.dtype,
312
+ ).coalesce()
313
+
314
+
315
+ def sheaf_laplacian_sparse(
316
+ sheaf: GraphSheaf | HeterogeneousGraphSheaf,
317
+ ) -> torch.Tensor:
318
+ """Sheaf Laplacian `L_F = δ^T δ` as a coalesced sparse-COO tensor.
319
+
320
+ Same operator as `sheaf_laplacian`, assembled sparsely: `L_F`'s
321
+ block-sparsity pattern is the graph adjacency's (one `(d_v[u],
322
+ d_v[v])` off-diagonal block per edge plus the node-diagonal blocks),
323
+ so nnz scales with edges, not with `(Σd_v)²`. Symmetrized as in the
324
+ dense path so callers can chain into symmetric eigensolvers.
325
+
326
+ Args:
327
+ sheaf: a `GraphSheaf` or `HeterogeneousGraphSheaf`.
328
+
329
+ Returns:
330
+ Coalesced sparse-COO `(Σ d_v, Σ d_v)` symmetric PSD Laplacian.
331
+
332
+ References:
333
+ Hansen-Ghrist (2019), §3.1.
334
+ Bodnar et al. (2022), eq. 6.
335
+ """
336
+ delta = sheaf_coboundary_sparse(sheaf)
337
+ L = torch.sparse.mm(delta.t(), delta)
338
+ # Same symmetrization rationale as the dense `sheaf_laplacian`.
339
+ return (0.5 * (L + L.t())).coalesce()
340
+
341
+
342
+ def _node_offsets(sheaf: GraphSheaf | HeterogeneousGraphSheaf) -> torch.Tensor:
343
+ """`(n_nodes + 1,)` int64 exclusive prefix sums of the node-stalk dims:
344
+ node `i`'s block of the stacked node-stalk vector is
345
+ `[offsets[i], offsets[i+1])`."""
346
+ if isinstance(sheaf, HeterogeneousGraphSheaf):
347
+ dims = sheaf.node_stalk_dims.to(device=sheaf.device, dtype=torch.long)
348
+ return torch.cat(
349
+ (torch.zeros(1, dtype=torch.long, device=dims.device),
350
+ torch.cumsum(dims, dim=0)),
351
+ )
352
+ return torch.arange(
353
+ sheaf.n_nodes + 1, dtype=torch.long, device=sheaf.device,
354
+ ) * sheaf.node_stalk_dim
355
+
356
+
357
+ def sheaf_bottom_spectrum(
358
+ sheaf: GraphSheaf | HeterogeneousGraphSheaf,
359
+ k: int,
360
+ drop_kernel: bool = True,
361
+ kernel_headroom: int = SHEAF_KERNEL_HEADROOM,
362
+ solver: Literal["auto", "eigsh", "lobpcg", "torch_lobpcg"] = "auto",
363
+ generator: torch.Generator | None = None,
364
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
365
+ """Bottom-k spectral form of the sheaf Laplacian via a sparse solve.
366
+
367
+ Returns the `k` smallest **nonzero** eigenpairs of `L_F = δ^T δ`
368
+ (with `drop_kernel=True`, the default): the kernel of `L_F` is the
369
+ space of global sections. For a trivial sheaf these are constant
370
+ in each stalk channel on each connected component. Dropping them
371
+ starts the returned spectrum at the first resolved positive mode. A mode
372
+ is positive only above both its residual bound `||Lv - λv|| / ||v||`
373
+ and a matrix-scale tolerance. That tolerance is at least
374
+ `n · eps(dtype) · scale`, and at least `LOBPCG_TOL · scale` for
375
+ LOBPCG, where `scale` is the full spectral norm on the dense path
376
+ and its upper bound `max_i Σ_j |L[i,j]|` on sparse paths.
377
+
378
+ The eigensolve runs on the SPARSE Laplacian via
379
+ `algebra.lanczos_eigsh(which="SA")`; see `algebra.eigsh_sparse` for
380
+ the backend dispatch and its scale-of-validity notes. Full-spectrum
381
+ requests and requests too wide for the LOBPCG search subspace use
382
+ dense `torch.linalg.eigh`. This spectral entry point is deliberately
383
+ nondifferentiable: maps requiring gradients are rejected on every
384
+ backend. Use `sheaf_laplacian` and a dense torch eigensolver for autograd.
385
+
386
+ Args:
387
+ sheaf: a `GraphSheaf` or `HeterogeneousGraphSheaf`.
388
+ k: number of nonzero eigenpairs to return.
389
+ drop_kernel: drop λ≈0 modes (default). With `False`, returns the
390
+ raw bottom-k modes including global sections.
391
+ kernel_headroom: extra modes requested beyond `k` so the kernel
392
+ can be dropped and still leave `k` nonzero modes (cataloged
393
+ default `SHEAF_KERNEL_HEADROOM`). Raise it for sheaves with
394
+ many global sections (e.g. many connected components).
395
+ solver: sparse eigensolver backend, forwarded to `lanczos_eigsh`.
396
+ generator: torch generator for the LOBPCG starting block.
397
+
398
+ Returns:
399
+ `(eigvals (k,), eigvecs (Σd_v, k), node_offsets (n_nodes + 1,))`.
400
+ Eigenvalues ascend. Node `i`'s rows of the eigenvector block are
401
+ `eigvecs[node_offsets[i]:node_offsets[i+1]]`.
402
+
403
+ References:
404
+ Hansen-Ghrist (2019), §3.1 (kernel = global sections), §4
405
+ (harmonic/spectral reads of the sheaf Laplacian).
406
+ Ericsson-Ruhe (1980); Knyazev (2001), for the sparse bottom-k
407
+ backends, via `algebra.eigsh_sparse`.
408
+ """
409
+ from holonomy_lib.algebra.eigsh_sparse import LOBPCG_TOL, SPARSE_SA_ITER_SOLVER_N
410
+ from holonomy_lib.algebra.lanczos import lanczos_eigsh
411
+
412
+ if not isinstance(k, int) or isinstance(k, bool) or k < 1:
413
+ raise ValueError(f"k must be >= 1; got k={k}")
414
+ if (not isinstance(kernel_headroom, int)
415
+ or isinstance(kernel_headroom, bool) or kernel_headroom < 0):
416
+ raise ValueError(
417
+ f"kernel_headroom must be >= 0; got {kernel_headroom}"
418
+ )
419
+
420
+ if solver not in ("auto", "eigsh", "lobpcg", "torch_lobpcg"):
421
+ raise ValueError(f"unknown sparse eigensolver {solver!r}")
422
+ n = sheaf.total_node_dim
423
+ if k > n:
424
+ raise ValueError(f"k must not exceed total node dim {n}; got k={k}")
425
+ L = sheaf_laplacian_sparse(sheaf)
426
+ if L.requires_grad:
427
+ raise ValueError(
428
+ "sheaf_bottom_spectrum is nondifferentiable; detach restriction "
429
+ "maps or use torch.linalg.eigh(sheaf_laplacian(sheaf))"
430
+ )
431
+ k_req = min(k + kernel_headroom, n) if drop_kernel else k
432
+ # Machine-epsilon floor for the matrix-scale tolerance, scaled by n. A
433
+ # LOBPCG run raises it below.
434
+ relative_tol = n * torch.finfo(sheaf.dtype).eps
435
+ # A zero Laplacian has no positive modes, so the solver is skipped: it
436
+ # would have to factor a singular matrix. The identity columns only give
437
+ # the residual check something of the right shape. Every mode is zero,
438
+ # so the cutoff rejects all of them.
439
+ if L._nnz() == 0 or not torch.any(L.values()):
440
+ vals = L.values().new_zeros(k_req)
441
+ kernel_scale = vals.new_zeros(())
442
+ vecs = torch.eye(n, k_req, dtype=sheaf.dtype, device=sheaf.device)
443
+ elif k_req == n or (
444
+ solver != "eigsh" and n < _LOBPCG_SUBSPACE_BLOCKS * k_req
445
+ ):
446
+ vals, vecs = torch.linalg.eigh(L.to_dense())
447
+ kernel_scale = vals.abs().max()
448
+ vals, vecs = vals[:k_req], vecs[:, :k_req]
449
+ else:
450
+ vals, vecs = lanczos_eigsh(
451
+ L, k_req, which="SA", solver=solver, generator=generator,
452
+ )
453
+ row_magnitudes = L.values().new_zeros(n).scatter_add_(
454
+ 0, L.indices()[0], L.values().abs(),
455
+ )
456
+ kernel_scale = row_magnitudes.max()
457
+ # This repeats the "auto" choice in eigsh_sparse: off the CPU, or past
458
+ # SPARSE_SA_ITER_SOLVER_N nodes, the solver is LOBPCG and the tolerance
459
+ # gets the LOBPCG floor.
460
+ uses_lobpcg = solver in ("lobpcg", "torch_lobpcg") or (
461
+ solver == "auto" and (
462
+ L.device.type != "cpu" or n > SPARSE_SA_ITER_SOLVER_N
463
+ )
464
+ )
465
+ if uses_lobpcg:
466
+ relative_tol = max(relative_tol, LOBPCG_TOL)
467
+
468
+ if not drop_kernel:
469
+ return vals[:k], vecs[:, :k], _node_offsets(sheaf)
470
+
471
+ # On the sparse path the computed values are only the bottom modes, so
472
+ # their maximum says nothing about the top of the spectrum. For symmetric
473
+ # L the largest absolute row sum bounds the spectral norm, so that bound
474
+ # sets the scale without another eigensolve. The dense path has the full
475
+ # spectrum and used its own maximum above.
476
+ residual_bounds = torch.linalg.vector_norm(
477
+ torch.sparse.mm(L, vecs) - vecs * vals, dim=0,
478
+ ) / torch.linalg.vector_norm(vecs, dim=0)
479
+ cutoff = torch.maximum(relative_tol * kernel_scale, residual_bounds)
480
+ nonzero = vals > cutoff
481
+ n_nonzero = int(nonzero.sum())
482
+ if n_nonzero < k:
483
+ reason = (
484
+ "the full spectrum contains too few resolved positive modes"
485
+ if k_req == n else
486
+ f"increase kernel_headroom={kernel_headroom} to inspect more modes"
487
+ )
488
+ raise ValueError(
489
+ f"only {n_nonzero} resolved positive modes among the {k_req} computed "
490
+ f"for k={k}; {reason} (remaining modes are in the sheaf kernel "
491
+ f"or unresolved at the solver accuracy; scale cutoff="
492
+ f"{float(relative_tol * kernel_scale):.6g}, "
493
+ f"max residual={float(residual_bounds.max()):.6g})"
494
+ )
495
+ vals = vals[nonzero][:k]
496
+ vecs = vecs[:, nonzero][:, :k]
497
+ return vals, vecs, _node_offsets(sheaf)
498
+
499
+
500
+ def sheaf_dirichlet_energy(
501
+ sheaf: GraphSheaf | HeterogeneousGraphSheaf, x: torch.Tensor,
502
+ ) -> torch.Tensor:
503
+ """Per-batch Dirichlet energy `E(x) = x^T L_F x` for sheaf-valued
504
+ signals.
505
+
506
+ The Dirichlet energy is the squared coboundary norm; it measures
507
+ how much each edge's two restriction-mapped values disagree.
508
+ Minimized at the global sections (kernel of `L_F`).
509
+
510
+ Args:
511
+ sheaf: a `GraphSheaf` or `HeterogeneousGraphSheaf`.
512
+ x: `(B, n_v · d_v)` or `(n_v · d_v,)` node-stalk signals.
513
+
514
+ Returns:
515
+ Scalar `()` for unbatched input or `(B,)` for batched input.
516
+
517
+ References:
518
+ Hansen-Ghrist (2019), §3.2.
519
+ """
520
+ total = sheaf.total_node_dim
521
+ if x.ndim not in (1, 2) or x.shape[-1] != total:
522
+ raise ValueError(
523
+ f"x last dim must equal the total node-stalk dim (sum of d_v over "
524
+ f"n_nodes) = {total}; got x.shape={tuple(x.shape)}"
525
+ )
526
+ delta = sheaf_coboundary(sheaf) # (n_e·d_e, n_v·d_v)
527
+ # x: (..., n_v·d_v); δ x: (..., n_e·d_e); energy: (..., )
528
+ delta_x = torch.einsum("ij,...j->...i", delta, x)
529
+ return (delta_x * delta_x).sum(dim=-1)
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """holonomy_lib.simplicial: simplicial complexes + boundary operators
5
+ + Vietoris-Rips construction.
6
+
7
+ Used as the foundation for `holonomy_lib.topology` (Hodge Laplacians
8
+ and persistent homology). Two representations:
9
+
10
+ DenseSimplicialComplex(simplices_by_dim, valid_mask, n_vertices, ...)
11
+ Batched, with padded simplex tables and a validity mask. Boundary
12
+ operators return dense `(B, n_{k-1}_max, n_k_max)` tensors.
13
+
14
+ SparseSimplicialComplex(simplices_by_dim, n_vertices, ...)
15
+ Single-instance (no batch dim). Boundary operators return
16
+ `torch.sparse_csc_tensor` of shape `(n_{k-1}, n_k)`. Used by
17
+ persistent homology, where the matrix-reduction kernel walks the
18
+ sparse boundary column by column.
19
+
20
+ Construction:
21
+
22
+ vietoris_rips_sparse(distances, max_radius, max_dim)
23
+ Single `(n, n)` distance matrix → SparseSimplicialComplex.
24
+
25
+ vietoris_rips_dense(distances, max_radius, max_dim, dtype=...)
26
+ Batched `(B, n, n)` distance matrices → DenseSimplicialComplex
27
+ with per-batch padded tables.
28
+
29
+ pairwise_distances(points)
30
+ Helper: Euclidean distance matrix from a `(n, d)` or `(B, n, d)`
31
+ point cloud.
32
+
33
+ References:
34
+ Munkres, J. R. (1984). Elements of Algebraic Topology. Westview
35
+ Press. §1 : chain complex + boundary operator.
36
+ Hausmann, J.-C. (1995). On the Vietoris-Rips complexes and a
37
+ cohomology theory for metric spaces. In Prospects in Topology,
38
+ Annals of Math Studies 138.
39
+ """
40
+
41
+ from holonomy_lib.simplicial.complex import DenseSimplicialComplex
42
+ from holonomy_lib.simplicial.sparse_complex import SparseSimplicialComplex
43
+ from holonomy_lib.simplicial.vietoris_rips import (
44
+ pairwise_distances,
45
+ vietoris_rips_dense,
46
+ vietoris_rips_sparse,
47
+ )
48
+
49
+ __all__ = [
50
+ "DenseSimplicialComplex",
51
+ "SparseSimplicialComplex",
52
+ "pairwise_distances",
53
+ "vietoris_rips_dense",
54
+ "vietoris_rips_sparse",
55
+ ]
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Shared face-enumeration + lookup helpers for the boundary operator.
5
+
6
+ For a simplex σ = [v_0, v_1, ..., v_k] (vertices sorted ascending),
7
+ the boundary is
8
+
9
+ ∂σ = Σ_{i=0..k} (-1)^i · [v_0, ..., v̂_i, ..., v_k].
10
+
11
+ Building ∂_k as a matrix requires, for each k-simplex column j, locating
12
+ the (k-1)-simplex row index of each of its (k+1) faces. The two
13
+ representations (dense batched, sparse single-instance) share this
14
+ enumeration logic; only the matrix assembly differs.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import torch
20
+
21
+
22
+ def enumerate_faces(simplex: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
23
+ """Enumerate oriented faces along the last simplex-table axis.
24
+
25
+ Args:
26
+ simplex: (..., k+1) int tensor of sorted vertex indices.
27
+
28
+ Returns:
29
+ faces: (..., k+1, k) int tensor; row i is the face obtained by
30
+ dropping the i-th vertex.
31
+ signs: (k+1,) tensor of `(-1)**i` values.
32
+
33
+ References:
34
+ Munkres, J. R. (1984). Elements of Algebraic Topology, §1.
35
+
36
+ Notes:
37
+ Assumes the last axis is sorted ascending. Dropping a vertex keeps
38
+ the rest sorted, so each face has the same sorted form that
39
+ lookup_rows matches against the stored tables.
40
+ """
41
+ width = simplex.shape[-1]
42
+ positions = torch.arange(width, device=simplex.device)
43
+ # Row i of `keep` lists every position except i, so indexing with it
44
+ # drops vertex i from the simplex.
45
+ keep = positions.expand(width, width)[
46
+ ~torch.eye(width, dtype=torch.bool, device=simplex.device)
47
+ ].reshape(width, width - 1)
48
+ faces = simplex[..., keep]
49
+ # Dropping vertex i carries the sign (-1)**i: +1 for even i, -1 for odd.
50
+ signs = 1 - 2 * (positions % 2)
51
+ return faces, signs
52
+
53
+
54
+ def lookup_rows(
55
+ reference_rows: torch.Tensor, query_rows: torch.Tensor,
56
+ ) -> torch.Tensor:
57
+ """Return the reference index of each query row by an exact tensor join.
58
+
59
+ Args:
60
+ reference_rows: (n, d) unique integer keys.
61
+ query_rows: (m, d) integer keys. Include batch ids in both tables
62
+ when identical simplices in different batches must stay distinct.
63
+
64
+ Returns:
65
+ (m,) integer reference indices. A missing query raises ValueError.
66
+
67
+ References:
68
+ Munkres, J. R. (1984). Elements of Algebraic Topology, §1,
69
+ for face incidence; the row join is an implementation detail.
70
+
71
+ Notes:
72
+ Duplicate reference rows are not supported. If two reference rows
73
+ share a key, which of their indices comes back is unspecified.
74
+ """
75
+ n_reference = reference_rows.shape[0]
76
+ # unique gives every distinct row one id, so a query row and its matching
77
+ # reference row end up with the same id. The join uses whole rows instead
78
+ # of a packed integer hash, because packing a vertex tuple into one integer
79
+ # can overflow int64 when the vertex count is large. The batch id is one
80
+ # of the columns, so the same simplex in two batch members stays distinct.
81
+ keys, inverse = torch.unique(
82
+ torch.cat((reference_rows, query_rows)), dim=0, return_inverse=True,
83
+ )
84
+ # Ids that no reference row claims stay -1.
85
+ rows = torch.full(
86
+ (keys.shape[0],), -1, dtype=torch.int64, device=reference_rows.device,
87
+ )
88
+ rows[inverse[:n_reference]] = torch.arange(
89
+ n_reference, device=reference_rows.device,
90
+ )
91
+ found = rows[inverse[n_reference:]]
92
+ # A -1 here means a query row never showed up among the reference rows.
93
+ # For the boundary operator that means the complex is not closed under faces.
94
+ if bool((found < 0).any()):
95
+ raise ValueError(
96
+ "Malformed complex: every face of a simplex must appear "
97
+ "in the previous dimension's simplex table."
98
+ )
99
+ return found