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,28 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """holonomy_lib.topology: Hodge Laplacians + persistent homology.
5
+
6
+ Built on `holonomy_lib.simplicial`. Two flavors of output:
7
+
8
+ Hodge / Betti
9
+ `hodge_laplacian(complex, k)`; the Hodge Laplacian on k-chains,
10
+ `L_k = ∂_{k+1} ∂_{k+1}^T + ∂_k^T ∂_k`. Its kernel has dimension
11
+ equal to the k-th Betti number.
12
+ `betti_numbers(complex, max_dim)`; computes
13
+ `β_k = n_k - rank(∂_k) - rank(∂_{k+1})` from float64 boundary ranks,
14
+ giving `(β_0, β_1, …, β_max_dim)`. It does not form `L_k`.
15
+
16
+ Persistent homology
17
+ `persistence_diagrams(points_or_distances, max_dim)` returns the
18
+ per-dim birth/death barcodes for a Vietoris-Rips filtration.
19
+ """
20
+
21
+ from holonomy_lib.topology.hodge import betti_numbers, hodge_laplacian
22
+ from holonomy_lib.topology.persistent_homology import persistence_diagrams
23
+
24
+ __all__ = [
25
+ "betti_numbers",
26
+ "hodge_laplacian",
27
+ "persistence_diagrams",
28
+ ]
@@ -0,0 +1,147 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Vietoris-Rips filtration construction for persistent homology.
5
+
6
+ A filtration is a totally-ordered sequence of simplices, each with
7
+ a birth time at which it joins the complex. For the Vietoris-Rips
8
+ filtration, the birth time of a simplex `σ` is the largest pairwise
9
+ distance among its vertices:
10
+
11
+ birth(σ) = max_{i, j ∈ σ} d(i, j).
12
+
13
+ Simplices with the same birth time are tie-broken by `(dim, input row
14
+ index)` so the ordering is total and deterministic. This module
15
+ returns the filtration in the order needed by the Z/2 boundary-matrix
16
+ reduction (`_reduction.py`): simplices listed in birth-time order,
17
+ with a per-simplex `(dim, original-index-within-dim)` tag so
18
+ boundary columns can be reconstructed.
19
+
20
+ References:
21
+ Edelsbrunner, H., Harer, J. (2010). Computational Topology: An
22
+ Introduction. American Mathematical Society. §VI.2; filtrations.
23
+ Bauer, U. (2021). Ripser: efficient computation of Vietoris-Rips
24
+ persistence barcodes. J. Appl. Comput. Topology 5:391-423.
25
+ Filtration ordering conventions used here.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+
32
+ import torch
33
+
34
+ from holonomy_lib.simplicial import SparseSimplicialComplex
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Filtration:
39
+ """A Vietoris-Rips filtration with simplices in birth-time order.
40
+
41
+ Attributes:
42
+ complex: the underlying simplicial complex (sparse, single-instance).
43
+ birth_times: `(n_total_simplices,)` float tensor, in filtration order.
44
+ sorted_dims: `(n_total_simplices,)` int tensor; dim of each simplex.
45
+ sorted_indices: `(n_total_simplices,)` int tensor; for the j-th
46
+ simplex in filtration order, its row index within
47
+ `complex.simplices_by_dim[sorted_dims[j]]`. Used to fetch the
48
+ simplex's vertex tuple when building the boundary matrix.
49
+ """
50
+
51
+ complex: SparseSimplicialComplex
52
+ birth_times: torch.Tensor
53
+ sorted_dims: torch.Tensor
54
+ sorted_indices: torch.Tensor
55
+
56
+
57
+ def build_filtration(
58
+ distances: torch.Tensor,
59
+ complex: SparseSimplicialComplex,
60
+ ) -> Filtration:
61
+ """Compute the birth time and sort order of every simplex in a VR complex.
62
+
63
+ Args:
64
+ distances: `(n, n)` pairwise distance matrix for the same vertex
65
+ set as `complex`.
66
+ complex: pre-built `SparseSimplicialComplex` (typically from
67
+ `vietoris_rips_sparse`).
68
+
69
+ Returns:
70
+ A `Filtration` with simplices ordered by (birth_time, dim,
71
+ input_row_index).
72
+
73
+ References:
74
+ Edelsbrunner, Harer (2010). Computational Topology: An Introduction.
75
+ AMS, Chapter VI.2, filtrations.
76
+ """
77
+ # Gather one record per simplex across all dimensions into flat arrays,
78
+ # so the sort below can order the whole complex at once.
79
+ all_birth: list[torch.Tensor] = []
80
+ all_dim: list[torch.Tensor] = []
81
+ all_idx: list[torch.Tensor] = []
82
+
83
+ for k in sorted(complex.simplices_by_dim.keys()):
84
+ simplices = complex.simplices_by_dim[k] # (n_k, k+1)
85
+ n_k = simplices.shape[0]
86
+ if n_k == 0:
87
+ continue
88
+ if k == 0:
89
+ # A vertex's birth would be d(i, i) from the general formula below.
90
+ # That is 0 for any distance matrix, so the gather is skipped.
91
+ births = torch.zeros(n_k, dtype=distances.dtype,
92
+ device=distances.device)
93
+ else:
94
+ # Birth is the largest distance among the simplex's vertices, which
95
+ # is the max over its (k+1) x (k+1) submatrix of `distances`. Advanced
96
+ # indexing builds all those submatrices at once:
97
+ # pairs[s, i, j] = distances[ simplices[s, i], simplices[s, j] ]
98
+ v_i = simplices.unsqueeze(-1) # (n_k, k+1, 1)
99
+ v_j = simplices.unsqueeze(-2) # (n_k, 1, k+1)
100
+ # Broadcast to (n_k, k+1, k+1) of vertex pairs.
101
+ sub = distances[v_i, v_j] # (n_k, k+1, k+1)
102
+ births = sub.flatten(start_dim=1).max(dim=-1).values
103
+ all_birth.append(births)
104
+ all_dim.append(torch.full((n_k,), k, dtype=torch.int64,
105
+ device=distances.device))
106
+ all_idx.append(torch.arange(n_k, dtype=torch.int64,
107
+ device=distances.device))
108
+
109
+ # No simplices at all. Return empty tensors in the same dtypes as the
110
+ # non-empty case, so callers never see a different type here.
111
+ if not all_birth:
112
+ empty = torch.zeros(0, dtype=distances.dtype, device=distances.device)
113
+ empty_i = torch.zeros(0, dtype=torch.int64, device=distances.device)
114
+ return Filtration(
115
+ complex=complex, birth_times=empty,
116
+ sorted_dims=empty_i, sorted_indices=empty_i,
117
+ )
118
+
119
+ birth_times = torch.cat(all_birth)
120
+ dims = torch.cat(all_dim)
121
+ indices = torch.cat(all_idx)
122
+
123
+ # Sort by (birth_time, dim, row). A stable sort keeps the existing order
124
+ # among ties, so we sort by the least significant key first. The last pass
125
+ # sorts by birth time, and simplices with equal birth keep the (dim, row)
126
+ # order that the passes before it left.
127
+ order = torch.argsort(indices, stable=True)
128
+ dims_sorted = dims[order]
129
+ times_sorted = birth_times[order]
130
+ indices_sorted = indices[order]
131
+
132
+ order2 = torch.argsort(dims_sorted, stable=True)
133
+ times_sorted = times_sorted[order2]
134
+ indices_sorted = indices_sorted[order2]
135
+ dims_sorted = dims_sorted[order2]
136
+
137
+ order3 = torch.argsort(times_sorted, stable=True)
138
+ times_sorted = times_sorted[order3]
139
+ indices_sorted = indices_sorted[order3]
140
+ dims_sorted = dims_sorted[order3]
141
+
142
+ return Filtration(
143
+ complex=complex,
144
+ birth_times=times_sorted,
145
+ sorted_dims=dims_sorted,
146
+ sorted_indices=indices_sorted,
147
+ )
@@ -0,0 +1,227 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Z/2 left-to-right boundary-matrix reduction for persistent homology.
5
+
6
+ Boundary columns are prepared by tensor face enumeration and row joins.
7
+ The Python backend transfers their indices and filtration metadata to the
8
+ host together, once per filtration. The torch backend keeps columns on the
9
+ input device and transfers only metadata in that bulk operation.
10
+
11
+ Reduction is sequential: a column's pivot depends on earlier reduced
12
+ columns. Python uses set symmetric differences; torch uses odd occurrence
13
+ counts in concatenated index tensors. The torch backend synchronizes once
14
+ per pivot attempt, including every attempt after an XOR. It is not a fully
15
+ asynchronous GPU reduction. Neither backend implements clearing.
16
+
17
+ A nonzero reduced column pairs its lowest face with a death. A zero column
18
+ is essential only when no later column has paired that birth with a death.
19
+
20
+ References:
21
+ Edelsbrunner, H., Letscher, D., Zomorodian, A. (2002). Topological
22
+ persistence and simplification. Discrete & Computational Geometry
23
+ 28:511-533. Left-to-right boundary reduction.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Literal
29
+
30
+ import torch
31
+
32
+ from holonomy_lib.simplicial._face_lookup import enumerate_faces, lookup_rows
33
+ from holonomy_lib.topology._filtration import Filtration
34
+
35
+
36
+ def _boundary_columns(filtration: Filtration) -> torch.Tensor:
37
+ """Build the padded boundary matrix of a filtration, with -1 for unused slots.
38
+
39
+ Takes a filtration whose complex is closed under taking faces. Returns an
40
+ `(n_total, max_dim + 1)` int64 tensor. Row j holds the filtration ids of the
41
+ faces of the simplex at filtration position j, and vertex rows are all -1.
42
+ """
43
+ complex = filtration.complex
44
+ n_total = filtration.birth_times.numel()
45
+ device = filtration.birth_times.device
46
+ columns = torch.full(
47
+ (n_total, complex.max_dim + 1), -1, dtype=torch.int64, device=device,
48
+ )
49
+ filtration_ids = torch.arange(n_total, device=device)
50
+ inverse_by_dim: dict[int, torch.Tensor] = {}
51
+ # Map each simplex's row within its dimension to its position in the
52
+ # filtration. Faces are found by row, so this inverse turns them into
53
+ # filtration ids.
54
+ for dim, simplices in complex.simplices_by_dim.items():
55
+ selected = filtration.sorted_dims == dim
56
+ inverse = torch.empty(simplices.shape[0], dtype=torch.int64, device=device)
57
+ inverse[filtration.sorted_indices[selected]] = filtration_ids[selected]
58
+ inverse_by_dim[dim] = inverse
59
+ for dim, simplices in complex.simplices_by_dim.items():
60
+ if dim == 0 or simplices.shape[0] == 0:
61
+ continue
62
+ # Each simplex has dim+1 faces. enumerate_faces lists them as vertex
63
+ # tuples, and lookup_rows finds each one's row in the dimension below.
64
+ # The reshape gives every simplex its own row of face rows.
65
+ faces, _ = enumerate_faces(simplices)
66
+ face_ids = lookup_rows(
67
+ complex.simplices_by_dim[dim - 1], faces.reshape(-1, dim),
68
+ ).reshape(simplices.shape[0], dim + 1)
69
+ columns[inverse_by_dim[dim], :dim + 1] = inverse_by_dim[dim - 1][face_ids]
70
+ return columns
71
+
72
+
73
+ def _host_records(
74
+ filtration: Filtration, columns: torch.Tensor | None,
75
+ ) -> tuple[list[int], list[float], list[list[int]]]:
76
+ """Copy dims, birth times, and optional boundary columns to host lists.
77
+
78
+ Takes a filtration and the padded boundary matrix or None. Returns three
79
+ lists: the dim of each simplex, its birth time, and the columns as nested
80
+ lists of filtration ids (empty when `columns` is None). Everything goes
81
+ over in one device-to-host copy as raw bytes, so int64 indices never pass
82
+ through a float dtype.
83
+ """
84
+ dims = filtration.sorted_dims.contiguous()
85
+ births = filtration.birth_times.contiguous()
86
+ chunks = [dims.view(torch.uint8), births.view(torch.uint8)]
87
+ if columns is not None:
88
+ chunks.append(columns.contiguous().view(torch.uint8).flatten())
89
+ host = torch.cat(chunks).cpu()
90
+ dims_end = dims.numel() * dims.element_size()
91
+ births_end = dims_end + births.numel() * births.element_size()
92
+ host_dims = host[:dims_end].view(dims.dtype).tolist()
93
+ host_births = host[dims_end:births_end].view(births.dtype).tolist()
94
+ # Float births can leave the columns at a byte offset that is not a
95
+ # multiple of 8, and an int64 view needs that alignment. Cloning the slice
96
+ # gives it a fresh host buffer that starts at offset 0. This is a host
97
+ # copy, not another device transfer.
98
+ host_columns = (
99
+ host[births_end:].clone().view(columns.dtype).reshape(columns.shape).tolist()
100
+ if columns is not None else []
101
+ )
102
+ return host_dims, host_births, host_columns
103
+
104
+
105
+ def reduce_filtration(
106
+ filtration: Filtration,
107
+ backend: Literal["python", "torch"] = "python",
108
+ ) -> dict[int, list[tuple[float, float]]]:
109
+ """Run sequential Z/2 reduction and return persistence pairs by dimension.
110
+
111
+ Args:
112
+ filtration: the simplex ordering produced by `build_filtration`.
113
+ backend: `"python"` for host set columns, or `"torch"` for index
114
+ tensors on the input device. Torch synchronizes for each pivot
115
+ attempt because the next column operation depends on that pivot.
116
+
117
+ Returns:
118
+ A dictionary keyed by homology dimension, holding the finite and
119
+ essential `(birth, death)` pairs. Essential bars have infinite death;
120
+ zero-length bars are omitted.
121
+
122
+ References:
123
+ Edelsbrunner, Letscher, Zomorodian (2002). Topological persistence
124
+ and simplification. Discrete & Computational Geometry 28:511-533.
125
+ """
126
+ if backend not in ("python", "torch"):
127
+ raise ValueError(f"backend must be 'python' or 'torch'; got {backend!r}")
128
+ boundary = _boundary_columns(filtration)
129
+ # The python backend needs the boundary on the host as sets. The torch
130
+ # backend keeps it on the device, so only dims and births are copied.
131
+ dims, births, host_columns = _host_records(
132
+ filtration, boundary if backend == "python" else None,
133
+ )
134
+ if backend == "torch":
135
+ pairs, pivots, empty = _reduce_torch(boundary, dims)
136
+ else:
137
+ # One set of filtration ids per column. A dim-0 simplex has no
138
+ # boundary, so its column starts out empty.
139
+ columns = [set(row[:dim + 1]) if dim else set()
140
+ for row, dim in zip(host_columns, dims, strict=True)]
141
+ pairs, pivots, empty = _reduce_python(columns)
142
+
143
+ # Each pair joins a birth simplex to the simplex that kills it. A bar whose
144
+ # death is not after its birth has zero length and is dropped.
145
+ pairs_by_dim: dict[int, list[tuple[float, float]]] = {}
146
+ for birth_id, death_id in pairs:
147
+ birth, death = births[birth_id], births[death_id]
148
+ if birth < death:
149
+ pairs_by_dim.setdefault(dims[birth_id], []).append((birth, death))
150
+
151
+ # A zero column creates a class at its own index. If some column later has
152
+ # that index as its low, the class died there. The rest never die.
153
+ essential = torch.tensor(empty, dtype=torch.bool)
154
+ essential[list(pivots)] = False
155
+ for index in essential.nonzero().flatten().tolist():
156
+ pairs_by_dim.setdefault(dims[index], []).append((births[index], float("inf")))
157
+ return pairs_by_dim
158
+
159
+
160
+ def _reduce_python(
161
+ columns: list[set[int]],
162
+ ) -> tuple[list[tuple[int, int]], dict[int, int], list[bool]]:
163
+ """Reduce the boundary columns left to right over Z/2.
164
+
165
+ Takes one set of filtration ids per simplex, in filtration order. Returns
166
+ the (low, index) pairs, a map from each low to the column that owns it,
167
+ and a flag per column that is True when the reduced column is zero.
168
+ Assumes the columns are in filtration order, since each column's pivot
169
+ depends only on the columns before it.
170
+ """
171
+ pivot_map: dict[int, int] = {}
172
+ pairs: list[tuple[int, int]] = []
173
+ for index, column in enumerate(columns):
174
+ while column:
175
+ # The low is the largest filtration id left in the column, which is
176
+ # the latest-born face. If an earlier column already owns that low,
177
+ # add that column in over Z/2 to cancel the face, then look again.
178
+ # Otherwise this column claims the low and is done.
179
+ low = max(column)
180
+ if low in pivot_map:
181
+ column ^= columns[pivot_map[low]]
182
+ else:
183
+ pivot_map[low] = index
184
+ pairs.append((low, index))
185
+ break
186
+ return pairs, pivot_map, [not column for column in columns]
187
+
188
+
189
+ def _reduce_torch(
190
+ boundary: torch.Tensor, dims: list[int],
191
+ ) -> tuple[list[tuple[int, int]], dict[int, int], list[bool]]:
192
+ """Reduce the boundary columns on the device; same output as the python path.
193
+
194
+ Takes the padded boundary matrix and the dim of each simplex. Returns the
195
+ same triple as `_reduce_python`. Each pass through the loop reads the pivot
196
+ back with `.item()`, so every pivot attempt costs one device sync.
197
+ """
198
+ # Keep only the real faces of each row. Vertex rows have none, and the -1
199
+ # padding is cut off.
200
+ columns = [row[:dim + 1] if dim else row[:0]
201
+ for row, dim in zip(boundary.unbind(), dims, strict=True)]
202
+ pivot_map: dict[int, int] = {}
203
+ pairs: list[tuple[int, int]] = []
204
+ for index, column in enumerate(columns):
205
+ while column.numel():
206
+ # .item() copies the pivot to the host because the branch below
207
+ # depends on its value. This is the one sync per attempt.
208
+ low = int(column.max().item())
209
+ if low in pivot_map:
210
+ column = _xor_columns(column, columns[pivot_map[low]])
211
+ columns[index] = column
212
+ else:
213
+ pivot_map[low] = index
214
+ pairs.append((low, index))
215
+ break
216
+ return pairs, pivot_map, [column.numel() == 0 for column in columns]
217
+
218
+
219
+ def _xor_columns(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
220
+ """Return the Z/2 sum of two boundary columns as a sorted index tensor.
221
+
222
+ Takes two 1-D index tensors. Returns the entries that appear in exactly one
223
+ input, sorted ascending. Assumes no repeats within either input, since a
224
+ repeat would be counted as a second occurrence and cancel wrongly.
225
+ """
226
+ unique, counts = torch.unique(torch.cat((a, b)), return_counts=True)
227
+ return unique[counts % 2 == 1]
@@ -0,0 +1,250 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Hodge Laplacians + Betti numbers on simplicial complexes.
5
+
6
+ The k-th Hodge Laplacian on a simplicial complex `K` is
7
+
8
+ L_k = ∂_{k+1} ∂_{k+1}^T + ∂_k^T ∂_k,
9
+
10
+ acting on the space of k-chains `C_k(K) ≅ ℝ^{n_k}`. It generalizes
11
+ the graph Laplacian (which is `L_0` for a 1-complex) to higher
12
+ dimensions and connects spectral analysis to topology via the Hodge
13
+ decomposition:
14
+
15
+ ker(L_k) ≅ H_k(K), so β_k = dim(ker(L_k)) = n_k − rank(L_k).
16
+
17
+ For a topological invariant of `K`, `β_k` is the k-th Betti number:
18
+ `β_0` counts connected components, `β_1` counts independent 1-cycles
19
+ (holes), `β_2` counts independent voids, and so on.
20
+
21
+ Two paths share the same public function `hodge_laplacian(complex, k)`:
22
+
23
+ Dense complex (`DenseSimplicialComplex`)
24
+ Boundary operators returned as dense `(B, n_{k-1}_max, n_k_max)`
25
+ tensors. The Hodge Laplacian is built via batched dense matmuls
26
+ and returned as `(B, n_k_max, n_k_max)`. Boundary and Laplacian
27
+ storage both scale with the padded batch dimensions.
28
+
29
+ Sparse complex (`SparseSimplicialComplex`)
30
+ Boundary operators returned as `torch.sparse_csc_tensor
31
+ (n_{k-1}, n_k)`. The Hodge Laplacian is built via sparse matmul
32
+ and returned as a dense `(n_k, n_k)` tensor. This avoids dense
33
+ boundary storage but still requires space for the final dense L_k.
34
+
35
+ `betti_numbers(complex, max_dim)` returns the Betti numbers for dims
36
+ 0..max_dim from float64 boundary ranks. Rank tolerances use each
37
+ member's real simplex counts, independently of batch padding.
38
+
39
+ References:
40
+ Eckmann, B. (1944). Harmonische Funktionen und Randwertaufgaben in
41
+ einem Komplex. Commentarii Mathematici Helvetici 17:240–255.
42
+ The original Hodge-theoretic interpretation on simplicial
43
+ complexes.
44
+ Lim, L.-H. (2020). Hodge Laplacians on graphs. SIAM Review
45
+ 62(3):685–715. Modern survey.
46
+ Schaub, M. T., Benson, A. R., Horn, P., Lippner, G., Jadbabaie, A.
47
+ (2020). Random walks on simplicial complexes and the normalized
48
+ Hodge 1-Laplacian. SIAM Review 62(2):353–391.
49
+ Ribando-Gros, E., Wang, R., Chen, J., Tong, Y., Wei, G.-W. (2024).
50
+ Combinatorial and Hodge Laplacians: Similarities and differences.
51
+ SIAM Review 66(3):575–601.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import torch
57
+
58
+ from holonomy_lib.simplicial import (
59
+ DenseSimplicialComplex,
60
+ SparseSimplicialComplex,
61
+ )
62
+
63
+
64
+ def hodge_laplacian(
65
+ complex: DenseSimplicialComplex | SparseSimplicialComplex,
66
+ k: int,
67
+ ) -> torch.Tensor:
68
+ """Hodge Laplacian `L_k = ∂_{k+1} ∂_{k+1}^T + ∂_k^T ∂_k`.
69
+
70
+ Args:
71
+ complex: a `DenseSimplicialComplex` (batched) or
72
+ `SparseSimplicialComplex` (single-instance).
73
+ k: simplex dimension. Must be in `[0, complex.max_dim]`.
74
+
75
+ Returns:
76
+ Dense complex → `(B, n_k_max, n_k_max)` Tensor.
77
+ Sparse complex → `(n_k, n_k)` dense Tensor. Boundary products
78
+ are computed sparsely, then materialized for this return contract.
79
+
80
+ References:
81
+ Eckmann (1944). Harmonische Funktionen und Randwertaufgaben in
82
+ einem Komplex. Comment. Math. Helv. 17:240–255.
83
+ Lim (2020). Hodge Laplacians on graphs. SIAM Review 62:685–715.
84
+ """
85
+ if k < 0:
86
+ raise ValueError(f"k must be >= 0, got {k}")
87
+ if isinstance(complex, DenseSimplicialComplex):
88
+ return _hodge_dense(complex, k)
89
+ if isinstance(complex, SparseSimplicialComplex):
90
+ return _hodge_sparse(complex, k)
91
+ raise TypeError(
92
+ f"complex must be a Dense or Sparse SimplicialComplex; "
93
+ f"got {type(complex).__name__}"
94
+ )
95
+
96
+
97
+ def betti_numbers(
98
+ complex: DenseSimplicialComplex | SparseSimplicialComplex,
99
+ max_dim: int,
100
+ threshold: float | None = None,
101
+ ) -> torch.Tensor:
102
+ """Betti numbers `(β_0, β_1, …, β_max_dim)` for a simplicial complex.
103
+
104
+ `β_k = n_k - rank(∂_k) - rank(∂_{k+1})`. Boundary entries are
105
+ exactly 0 or ±1, so ranks are computed in float64 regardless of
106
+ the complex's storage dtype. This avoids squaring the conditioning
107
+ by forming a Hodge Laplacian.
108
+
109
+ Args:
110
+ complex: a `DenseSimplicialComplex` or `SparseSimplicialComplex`.
111
+ max_dim: highest dim to return Betti numbers for. Must satisfy
112
+ `0 ≤ max_dim ≤ complex.max_dim`.
113
+ threshold: optional positive relative rank tolerance. With `None`,
114
+ each boundary uses `max(n_{k-1}, n_k) * eps(float64)`, with
115
+ real simplex counts for that batch member. An explicit value
116
+ applies to boundary singular values, with zero absolute tolerance.
117
+
118
+ Returns:
119
+ Dense complex → `(B, max_dim + 1)` Tensor of int64 Betti numbers.
120
+ Sparse complex → `(max_dim + 1,)` Tensor.
121
+
122
+ Notes:
123
+ Dense boundaries retain zero padding for batched SVD; it adds only
124
+ zero singular values and does not enter the per-member tolerance.
125
+ Sparse boundaries are materialized for the rank computation.
126
+
127
+ References:
128
+ Eckmann (1944); kernel of L_k equals harmonic k-forms.
129
+ Lim (2020), §4.
130
+ """
131
+ if max_dim < 0:
132
+ raise ValueError(f"max_dim must be >= 0, got {max_dim}")
133
+ if max_dim > complex.max_dim:
134
+ raise ValueError(
135
+ f"max_dim={max_dim} exceeds complex.max_dim={complex.max_dim}"
136
+ )
137
+ if threshold is not None and threshold <= 0:
138
+ raise ValueError(f"threshold must be > 0, got {threshold}")
139
+
140
+ if isinstance(complex, DenseSimplicialComplex):
141
+ B = complex.batch_size
142
+ ranks = torch.zeros(B, max_dim + 2, dtype=torch.int64,
143
+ device=complex.device)
144
+ for k in range(1, min(max_dim + 1, complex.max_dim) + 1):
145
+ boundary = complex.boundary(k).to(torch.float64)
146
+ relative_tol: float | torch.Tensor | None = threshold
147
+ if relative_tol is None:
148
+ real_size = torch.maximum(
149
+ complex.n_simplices(k - 1), complex.n_simplices(k),
150
+ )
151
+ relative_tol = real_size.to(torch.float64) * torch.finfo(
152
+ torch.float64,
153
+ ).eps
154
+ ranks[:, k] = torch.linalg.matrix_rank(boundary, rtol=relative_tol)
155
+ counts = torch.stack([
156
+ complex.n_simplices(k) for k in range(max_dim + 1)
157
+ ], dim=-1)
158
+ return counts - ranks[:, :-1] - ranks[:, 1:]
159
+
160
+ if isinstance(complex, SparseSimplicialComplex):
161
+ ranks = torch.zeros(max_dim + 2, dtype=torch.int64, device=complex.device)
162
+ for k in range(1, min(max_dim + 1, complex.max_dim) + 1):
163
+ boundary = complex.boundary(k, dtype=torch.float64).to_dense()
164
+ ranks[k] = torch.linalg.matrix_rank(boundary, rtol=threshold)
165
+ counts = torch.tensor([
166
+ complex.n_simplices(k) for k in range(max_dim + 1)
167
+ ], dtype=torch.int64, device=complex.device)
168
+ return counts - ranks[:-1] - ranks[1:]
169
+
170
+ raise TypeError(
171
+ f"complex must be a Dense or Sparse SimplicialComplex; "
172
+ f"got {type(complex).__name__}"
173
+ )
174
+
175
+
176
+ # ============================================================
177
+ # Internal: dispatched paths
178
+ # ============================================================
179
+
180
+
181
+ def _hodge_dense(
182
+ complex: DenseSimplicialComplex, k: int,
183
+ ) -> torch.Tensor:
184
+ """Hodge Laplacian L_k on a dense complex, batched over members.
185
+
186
+ Takes a `DenseSimplicialComplex` and k with k <= complex.max_dim. Returns a
187
+ `(B, n_k_max, n_k_max)` tensor in the complex dtype. Padded simplex rows
188
+ and columns are zero.
189
+ """
190
+ if k > complex.max_dim:
191
+ raise ValueError(
192
+ f"k={k} exceeds complex.max_dim={complex.max_dim}"
193
+ )
194
+
195
+ B = complex.batch_size
196
+ n_k_max = (
197
+ complex.simplices_by_dim[k].shape[1]
198
+ if k in complex.simplices_by_dim else 0
199
+ )
200
+ L = torch.zeros(B, n_k_max, n_k_max,
201
+ device=complex.device, dtype=complex.dtype)
202
+
203
+ # The ∂_k^T ∂_k term. It is skipped for k = 0, since there are no
204
+ # (-1)-simplices.
205
+ if k > 0:
206
+ d_k = complex.boundary(k) # (B, n_{k-1}, n_k)
207
+ L = L + torch.matmul(d_k.mT, d_k)
208
+
209
+ # The ∂_{k+1} ∂_{k+1}^T term. It is skipped when the complex has no
210
+ # (k+1)-simplex table.
211
+ if (k + 1) in complex.simplices_by_dim:
212
+ d_kp1 = complex.boundary(k + 1) # (B, n_k, n_{k+1})
213
+ L = L + torch.matmul(d_kp1, d_kp1.mT)
214
+
215
+ return L
216
+
217
+
218
+ def _hodge_sparse(
219
+ complex: SparseSimplicialComplex, k: int,
220
+ dtype: torch.dtype = torch.float64,
221
+ ) -> torch.Tensor:
222
+ """Hodge Laplacian L_k on a sparse complex, returned as a dense (n_k, n_k).
223
+
224
+ Takes a `SparseSimplicialComplex` and k with k <= complex.max_dim. Each
225
+ boundary product is computed sparsely and densified once, then added into
226
+ the dense L_k, because the public return contract is dense.
227
+
228
+ `SparseSimplicialComplex` has no float dtype attribute, so the dtype is
229
+ passed in (default `torch.float64`). Both boundary operators are built at
230
+ this dtype so the matmul sees matching types.
231
+ """
232
+ if k > complex.max_dim:
233
+ raise ValueError(
234
+ f"k={k} exceeds complex.max_dim={complex.max_dim}"
235
+ )
236
+
237
+ n_k = complex.n_simplices(k)
238
+ device = complex.device
239
+ L = torch.zeros(n_k, n_k, device=device, dtype=dtype)
240
+
241
+ if k > 0:
242
+ d_k = complex.boundary(k, dtype=dtype) # sparse (n_{k-1}, n_k)
243
+ d_k_coo = d_k.to_sparse_coo().coalesce()
244
+ L = L + torch.sparse.mm(d_k_coo.mT, d_k_coo).to_dense()
245
+
246
+ if (k + 1) in complex.simplices_by_dim:
247
+ d_kp1 = complex.boundary(k + 1, dtype=dtype).to_sparse_coo().coalesce()
248
+ L = L + torch.sparse.mm(d_kp1, d_kp1.mT).to_dense()
249
+
250
+ return L