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.
- holonomy_explorer/__init__.py +1 -0
- holonomy_explorer/__main__.py +79 -0
- holonomy_explorer/batching.py +182 -0
- holonomy_explorer/common.py +187 -0
- holonomy_explorer/curvature.py +154 -0
- holonomy_explorer/datasets.py +346 -0
- holonomy_explorer/graphs.py +286 -0
- holonomy_explorer/server.py +172 -0
- holonomy_explorer/static/app.js +435 -0
- holonomy_explorer/static/charts.js +114 -0
- holonomy_explorer/static/index.html +45 -0
- holonomy_explorer/static/layout.js +85 -0
- holonomy_explorer/static/style.css +121 -0
- holonomy_explorer/static/view.js +195 -0
- holonomy_explorer/topology.py +118 -0
- holonomy_lib/__init__.py +9 -0
- holonomy_lib/_graph_utils.py +52 -0
- holonomy_lib/_quadrature.py +74 -0
- holonomy_lib/algebra/__init__.py +32 -0
- holonomy_lib/algebra/eigsh_sparse.py +251 -0
- holonomy_lib/algebra/lanczos.py +361 -0
- holonomy_lib/algebra/linear.py +180 -0
- holonomy_lib/algebra/solvers.py +547 -0
- holonomy_lib/convex/__init__.py +27 -0
- holonomy_lib/convex/least_squares.py +184 -0
- holonomy_lib/discrete_geometry/__init__.py +38 -0
- holonomy_lib/discrete_geometry/forman.py +242 -0
- holonomy_lib/discrete_geometry/ricci.py +855 -0
- holonomy_lib/experimental/__init__.py +13 -0
- holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
- holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
- holonomy_lib/hyperbolic/__init__.py +27 -0
- holonomy_lib/hyperbolic/frechet_mean.py +381 -0
- holonomy_lib/hyperbolic/heat_kernel.py +608 -0
- holonomy_lib/hyperbolic/manifold_inner.py +81 -0
- holonomy_lib/info_geometry/__init__.py +64 -0
- holonomy_lib/info_geometry/divergences.py +224 -0
- holonomy_lib/info_geometry/fisher.py +179 -0
- holonomy_lib/lie/__init__.py +54 -0
- holonomy_lib/lie/so3.py +385 -0
- holonomy_lib/lie/spherical_harmonics.py +189 -0
- holonomy_lib/manifolds/__init__.py +53 -0
- holonomy_lib/manifolds/comparison.py +343 -0
- holonomy_lib/manifolds/fixed_rank.py +453 -0
- holonomy_lib/manifolds/lorentz.py +699 -0
- holonomy_lib/manifolds/lorentzian.py +567 -0
- holonomy_lib/manifolds/product.py +425 -0
- holonomy_lib/manifolds/spd.py +504 -0
- holonomy_lib/manifolds/stereographic.py +680 -0
- holonomy_lib/optimization/__init__.py +42 -0
- holonomy_lib/optimization/base.py +65 -0
- holonomy_lib/optimization/sgd.py +112 -0
- holonomy_lib/py.typed +0 -0
- holonomy_lib/sheaf/__init__.py +74 -0
- holonomy_lib/sheaf/graph_sheaf.py +352 -0
- holonomy_lib/sheaf/laplacian.py +529 -0
- holonomy_lib/simplicial/__init__.py +55 -0
- holonomy_lib/simplicial/_face_lookup.py +99 -0
- holonomy_lib/simplicial/complex.py +168 -0
- holonomy_lib/simplicial/sparse_complex.py +145 -0
- holonomy_lib/simplicial/vietoris_rips.py +277 -0
- holonomy_lib/spectral/__init__.py +55 -0
- holonomy_lib/spectral/diffusion_map.py +169 -0
- holonomy_lib/spectral/effective_resistance.py +199 -0
- holonomy_lib/spectral/embedding.py +131 -0
- holonomy_lib/spectral/heat_kernel.py +236 -0
- holonomy_lib/spectral/laplacian.py +441 -0
- holonomy_lib/spectral/magnetic.py +351 -0
- holonomy_lib/spectral/spectral_dimension.py +104 -0
- holonomy_lib/tensor_calculus/__init__.py +23 -0
- holonomy_lib/tensor_calculus/decomposition.py +208 -0
- holonomy_lib/topology/__init__.py +28 -0
- holonomy_lib/topology/_filtration.py +147 -0
- holonomy_lib/topology/_reduction.py +227 -0
- holonomy_lib/topology/hodge.py +250 -0
- holonomy_lib/topology/persistent_homology.py +301 -0
- holonomy_lib-0.8.0.dist-info/METADATA +161 -0
- holonomy_lib-0.8.0.dist-info/RECORD +82 -0
- holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
- holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
- holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
- 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
|