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,168 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """DenseSimplicialComplex : batched simplicial complex with padded
5
+ dense simplex tables.
6
+
7
+ Used by Hodge Laplacians on small complexes (where batched dense
8
+ eigh is fast) and by tutorials / illustration code. For large
9
+ single-instance complexes (persistent homology), use
10
+ `SparseSimplicialComplex`.
11
+
12
+ Layout:
13
+ `simplices_by_dim[k]` is an `(B, n_k_max, k+1)` int64 tensor.
14
+ Each row holds a sorted tuple of vertex indices. Padding rows
15
+ (beyond the valid count for that batch element) are filled with
16
+ `-1` and masked off via `valid_mask[k]: (B, n_k_max) bool`.
17
+
18
+ This shape makes boundary operators naturally batched: one
19
+ `(B, n_{k-1}_max, n_k_max)` matrix per dim. Padding rows and columns
20
+ stay zero because only valid simplices write entries into it.
21
+
22
+ References:
23
+ Munkres, J. R. (1984). Elements of Algebraic Topology. Westview
24
+ Press. §1 : simplicial complexes and chain complexes.
25
+ Kolda, T. G., Bader, B. W. (2009). Tensor decompositions and
26
+ applications. SIAM Review 51(3):455-500. (Tensor-layout
27
+ conventions reused here.)
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass, field
33
+ from typing import TYPE_CHECKING
34
+
35
+ import torch
36
+
37
+ from holonomy_lib.simplicial._face_lookup import enumerate_faces, lookup_rows
38
+
39
+ if TYPE_CHECKING:
40
+ from holonomy_lib.simplicial.sparse_complex import SparseSimplicialComplex
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class DenseSimplicialComplex:
45
+ """Batched, padded simplicial complex.
46
+
47
+ Attributes:
48
+ simplices_by_dim: `dict[k, Tensor (B, n_k_max, k+1)]` of sorted
49
+ vertex tuples. Padding rows are -1.
50
+ valid_mask: `dict[k, Tensor (B, n_k_max) bool]` : True for real
51
+ simplices, False for padding.
52
+ n_vertices: vertex count (identical across batch elements).
53
+ device, dtype: tensor placement + float dtype used for boundary
54
+ operator returns.
55
+ """
56
+
57
+ simplices_by_dim: dict[int, torch.Tensor]
58
+ valid_mask: dict[int, torch.Tensor]
59
+ n_vertices: int
60
+ device: torch.device = field(default=torch.device("cpu"))
61
+ dtype: torch.dtype = field(default=torch.float64)
62
+
63
+ @property
64
+ def batch_size(self) -> int:
65
+ """Leading batch dim. All dims must agree on this."""
66
+ for tensor in self.simplices_by_dim.values():
67
+ return tensor.shape[0]
68
+ return 0
69
+
70
+ @property
71
+ def max_dim(self) -> int:
72
+ """Highest dimension with a table, including an empty table."""
73
+ return max(self.simplices_by_dim) if self.simplices_by_dim else -1
74
+
75
+ def n_simplices(self, k: int) -> torch.Tensor:
76
+ """Per-batch count of valid k-simplices: shape `(B,)`."""
77
+ if k not in self.simplices_by_dim:
78
+ return torch.zeros(self.batch_size, dtype=torch.int64,
79
+ device=self.device)
80
+ return self.valid_mask[k].sum(dim=-1)
81
+
82
+ def boundary(self, k: int) -> torch.Tensor:
83
+ """Boundary operator `∂_k: C_k → C_{k-1}`.
84
+
85
+ Returns a dense `(B, n_{k-1}_max, n_k_max)` matrix; entries
86
+ `± 1` mark face → simplex incidences with the Koszul sign
87
+ `(-1)^i` (i = dropped-vertex position). Padding rows/columns
88
+ are zero.
89
+
90
+ Args:
91
+ k: simplex dimension. For k=0 the result is a `(B, 0, n_0_max)`
92
+ zero tensor, since vertices have an empty boundary in the chain
93
+ complex.
94
+
95
+ References:
96
+ Munkres, J. R. (1984). Elements of Algebraic Topology, §1.
97
+
98
+ Notes:
99
+ Assumes the complex is closed under faces. A face missing from
100
+ the (k-1) table raises ValueError from the lookup.
101
+ """
102
+ if k == 0:
103
+ # ∂_0 maps to the (-1)-chain group; conventionally zero.
104
+ # Return a (B, 0, n_0_max) zero tensor so downstream
105
+ # arithmetic (Hodge L_0 = ∂_0^T ∂_0 + ∂_1 ∂_1^T) reduces
106
+ # cleanly to ∂_1 ∂_1^T.
107
+ B = self.batch_size
108
+ n_0_max = (
109
+ self.simplices_by_dim[0].shape[1]
110
+ if 0 in self.simplices_by_dim else 0
111
+ )
112
+ return torch.zeros(B, 0, n_0_max,
113
+ device=self.device, dtype=self.dtype)
114
+ if k not in self.simplices_by_dim or k - 1 not in self.simplices_by_dim:
115
+ raise ValueError(
116
+ f"boundary(k={k}): complex lacks simplices at dim {k} "
117
+ f"or {k - 1}"
118
+ )
119
+
120
+ B = self.batch_size
121
+ k_simplices = self.simplices_by_dim[k] # (B, n_k_max, k+1)
122
+ prev_simplices = self.simplices_by_dim[k - 1] # (B, n_{k-1}_max, k)
123
+ n_k_max = k_simplices.shape[1]
124
+ n_prev_max = prev_simplices.shape[1]
125
+ k_valid = self.valid_mask[k] # (B, n_k_max)
126
+ prev_valid = self.valid_mask[k - 1]
127
+
128
+ D = torch.zeros(B, n_prev_max, n_k_max,
129
+ device=self.device, dtype=self.dtype)
130
+
131
+ # Keys are (batch id, vertices), so a face in another batch member
132
+ # cannot stand in for a missing face here. lookup_rows indexes only
133
+ # the valid rows of the previous dimension, so prev_row maps that
134
+ # index back to the padded row position.
135
+ simplex_batch, simplex_col = torch.where(k_valid) # padded columns
136
+ prev_batch, prev_row = torch.where(prev_valid)
137
+ faces, signs = enumerate_faces(k_simplices[k_valid])
138
+ face_keys = torch.cat((
139
+ simplex_batch[:, None, None].expand(-1, k + 1, 1), faces,
140
+ ), dim=-1).reshape(-1, k + 1)
141
+ prev_keys = torch.cat((prev_batch[:, None], prev_simplices[prev_valid]),
142
+ dim=-1)
143
+ face_rows = prev_row[lookup_rows(prev_keys, face_keys)]
144
+ # One entry per (simplex, face) pair, flattened simplex-major and
145
+ # face-minor. repeat_interleave builds the index lists in that order,
146
+ # and repeat builds the signs in the same order.
147
+ D[simplex_batch.repeat_interleave(k + 1), face_rows,
148
+ simplex_col.repeat_interleave(k + 1)] = signs.to(self.dtype).repeat(
149
+ simplex_col.shape[0],
150
+ )
151
+ return D
152
+
153
+ def to_sparse(self) -> SparseSimplicialComplex:
154
+ """Convert to a single-instance sparse complex. Requires `B = 1`."""
155
+ from holonomy_lib.simplicial.sparse_complex import SparseSimplicialComplex
156
+ if self.batch_size != 1:
157
+ raise ValueError(
158
+ f"to_sparse() requires batch_size == 1, got {self.batch_size}"
159
+ )
160
+ sparse_simplices: dict[int, torch.Tensor] = {}
161
+ for k, table in self.simplices_by_dim.items():
162
+ valid = self.valid_mask[k][0]
163
+ sparse_simplices[k] = table[0][valid]
164
+ return SparseSimplicialComplex(
165
+ simplices_by_dim=sparse_simplices,
166
+ n_vertices=self.n_vertices,
167
+ device=self.device,
168
+ )
@@ -0,0 +1,145 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """SparseSimplicialComplex : single-instance simplicial complex with
5
+ sparse-CSC boundary operators.
6
+
7
+ Used by persistent homology (where the matrix-reduction kernel walks
8
+ the sparse boundary matrix column by column) and by Hodge Laplacians
9
+ on large complexes where dense `(n, n)` doesn't fit.
10
+
11
+ No batch dimension: persistent homology operates on one complex at a
12
+ time (the batching for PH happens at a higher level, calling this
13
+ class once per point cloud). For batched small complexes, use
14
+ `DenseSimplicialComplex` and the dense Hodge path.
15
+
16
+ References:
17
+ Saad, Y. (2003). Iterative Methods for Sparse Linear Systems, 2nd
18
+ ed. SIAM. §3.4 : CSR and CSC formats.
19
+ Edelsbrunner, H., Letscher, D., Zomorodian, A. (2002). Topological
20
+ persistence and simplification. Discrete & Computational Geometry
21
+ 28:511-533. Original sparse-boundary reduction algorithm.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from typing import TYPE_CHECKING
28
+
29
+ import torch
30
+
31
+ from holonomy_lib.simplicial._face_lookup import enumerate_faces, lookup_rows
32
+
33
+ if TYPE_CHECKING:
34
+ from holonomy_lib.simplicial.complex import DenseSimplicialComplex
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class SparseSimplicialComplex:
39
+ """Single-instance simplicial complex.
40
+
41
+ Attributes:
42
+ simplices_by_dim: `dict[k, Tensor (n_k, k+1)]` of sorted vertex
43
+ tuples. No batch dim, no padding.
44
+ n_vertices: vertex count.
45
+ device: tensor placement.
46
+ """
47
+
48
+ simplices_by_dim: dict[int, torch.Tensor]
49
+ n_vertices: int
50
+ device: torch.device = field(default=torch.device("cpu"))
51
+
52
+ @property
53
+ def max_dim(self) -> int:
54
+ """Highest dimension with a table, including an empty table."""
55
+ return max(self.simplices_by_dim) if self.simplices_by_dim else -1
56
+
57
+ def n_simplices(self, k: int) -> int:
58
+ """Number of k-simplices. Returns 0 when there is no table for dimension k."""
59
+ if k not in self.simplices_by_dim:
60
+ return 0
61
+ return self.simplices_by_dim[k].shape[0]
62
+
63
+ def boundary(
64
+ self, k: int, dtype: torch.dtype = torch.float64,
65
+ ) -> torch.Tensor:
66
+ """Sparse-CSC boundary operator `∂_k: C_k → C_{k-1}`.
67
+
68
+ Returns a sparse-CSC `(n_{k-1}, n_k)` tensor; entries are
69
+ `± 1` with the Koszul sign for each face → simplex incidence.
70
+
71
+ Args:
72
+ k: simplex dimension. `k = 0` returns an empty `(0, n_0)`
73
+ sparse tensor (vertices have empty boundary).
74
+ dtype: float dtype for the matrix values.
75
+
76
+ References:
77
+ Munkres, J. R. (1984). Elements of Algebraic Topology, §1.
78
+
79
+ Notes:
80
+ Assumes the complex is closed under faces. A face missing from
81
+ the (k-1) table raises ValueError from the lookup.
82
+ """
83
+ if k == 0:
84
+ n_0 = self.n_simplices(0)
85
+ # No row indices and every column pointer is 0, so each of the
86
+ # n_0 columns is empty.
87
+ return torch.sparse_csc_tensor(
88
+ ccol_indices=torch.zeros(n_0 + 1, dtype=torch.int64,
89
+ device=self.device),
90
+ row_indices=torch.zeros(0, dtype=torch.int64,
91
+ device=self.device),
92
+ values=torch.zeros(0, dtype=dtype, device=self.device),
93
+ size=(0, n_0),
94
+ )
95
+ if k not in self.simplices_by_dim or k - 1 not in self.simplices_by_dim:
96
+ raise ValueError(
97
+ f"boundary(k={k}): complex lacks simplices at dim {k} "
98
+ f"or {k - 1}"
99
+ )
100
+
101
+ k_simplices = self.simplices_by_dim[k] # (n_k, k+1)
102
+ prev_simplices = self.simplices_by_dim[k - 1] # (n_{k-1}, k)
103
+ n_k = k_simplices.shape[0]
104
+ n_prev = prev_simplices.shape[0]
105
+
106
+ faces, signs = enumerate_faces(k_simplices)
107
+ rows = lookup_rows(prev_simplices, faces.reshape(-1, k))
108
+ cols = torch.arange(n_k, device=self.device).repeat_interleave(k + 1)
109
+ vals = signs.to(dtype).repeat(n_k)
110
+
111
+ # Build the entries as COO, which takes (row, column) pairs directly.
112
+ # coalesce sorts them by row then column, and to_sparse_csc then
113
+ # compresses the column indices into the CSC layout.
114
+ coo = torch.sparse_coo_tensor(
115
+ indices=torch.stack([rows, cols]),
116
+ values=vals,
117
+ size=(n_prev, n_k),
118
+ ).coalesce()
119
+ return coo.to_sparse_csc()
120
+
121
+ def to_dense(
122
+ self, dtype: torch.dtype = torch.float64,
123
+ ) -> DenseSimplicialComplex:
124
+ """Convert to a batched-with-B=1 dense complex.
125
+
126
+ Args:
127
+ dtype: float dtype for boundary matrix returns in the dense
128
+ complex.
129
+ """
130
+ from holonomy_lib.simplicial.complex import DenseSimplicialComplex
131
+ dense_simplices: dict[int, torch.Tensor] = {}
132
+ valid_masks: dict[int, torch.Tensor] = {}
133
+ for k, table in self.simplices_by_dim.items():
134
+ dense_simplices[k] = table.unsqueeze(0) # (1, n_k, k+1)
135
+ # A single complex has no padding, so every row is a real simplex.
136
+ valid_masks[k] = torch.ones(
137
+ 1, table.shape[0], dtype=torch.bool, device=self.device,
138
+ )
139
+ return DenseSimplicialComplex(
140
+ simplices_by_dim=dense_simplices,
141
+ valid_mask=valid_masks,
142
+ n_vertices=self.n_vertices,
143
+ device=self.device,
144
+ dtype=dtype,
145
+ )
@@ -0,0 +1,277 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Vietoris-Rips complex from a point cloud or distance matrix.
5
+
6
+ The Vietoris-Rips complex `VR(X, r)` of a metric space `X` at scale
7
+ `r` is the simplicial complex whose k-simplex `[v_0, ..., v_k]`
8
+ exists iff `d(v_i, v_j) ≤ r` for every pair `(i, j)`. It's the
9
+ standard finite-scale approximation to the (typically infinite)
10
+ Čech complex and the input to most persistent-homology pipelines.
11
+
12
+ Construction extends each existing simplex by larger vertex indices.
13
+ Candidates are checked in configurable tiles; no batch, vertex, or simplex
14
+ is traversed individually in Python. Dimension progression is sequential
15
+ because a k-simplex is extended from a (k-1)-simplex. The worst case still
16
+ has combinatorial output size. Tiles bound candidate working memory, not
17
+ the size of the returned complex.
18
+
19
+ References:
20
+ Hausmann, J.-C. (1995). On the Vietoris-Rips complexes and a
21
+ cohomology theory for metric spaces. In Prospects in Topology,
22
+ Annals of Math Studies 138.
23
+ Bauer, U. (2021). Ripser: efficient computation of Vietoris-Rips
24
+ persistence barcodes. J. Appl. Comput. Topology 5:391-423.
25
+ Implementation references for incremental k-simplex construction.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import torch
31
+
32
+ from holonomy_lib.simplicial.complex import DenseSimplicialComplex
33
+ from holonomy_lib.simplicial.sparse_complex import SparseSimplicialComplex
34
+
35
+ # Each tile evaluates at most this many extension candidates, so the temporary
36
+ # index and bool tables stay at O(tile * simplex_width) entries. It is a memory
37
+ # budget with no effect on results. Raise it on large-memory devices for fewer
38
+ # kernel launches. Cataloged in notes/magic_numbers.md.
39
+ VR_CANDIDATE_TILE_DEFAULT = 1024
40
+
41
+
42
+ def _rips_tables(
43
+ distances: torch.Tensor, max_radius: float, max_dim: int,
44
+ candidate_tile_size: int,
45
+ ) -> dict[int, tuple[torch.Tensor, torch.Tensor]]:
46
+ """Build the simplex table of every dimension up to `max_dim`.
47
+
48
+ Takes `(B, n, n)` distances, the radius cap, the top dimension, and the
49
+ candidate tile size. Returns a dict from dimension to `(batch_ids, simplices)`,
50
+ where `batch_ids` is `(m,)` and `simplices` is `(m, dim + 1)` vertex indices.
51
+ Rows are grouped by batch member and sorted lexicographically within each
52
+ member. Only pairs with i < j are read, so the distances are assumed symmetric.
53
+ """
54
+ if distances.ndim != 2 + 1 or distances.shape[-1] != distances.shape[-2]:
55
+ raise ValueError(
56
+ f"distances must be (B, n, n); got {tuple(distances.shape)}"
57
+ )
58
+ if not distances.is_floating_point():
59
+ raise ValueError("distances must have a real floating dtype")
60
+ if not isinstance(max_dim, int) or max_dim < 0:
61
+ raise ValueError(f"max_dim must be a nonnegative integer, got {max_dim}")
62
+ if not isinstance(candidate_tile_size, int) or candidate_tile_size <= 0:
63
+ raise ValueError("candidate_tile_size must be a positive integer")
64
+ if max_radius < 0:
65
+ raise ValueError("max_radius must be nonnegative")
66
+
67
+ batch_size, n_vertices, _ = distances.shape
68
+ device = distances.device
69
+ vertex_ids = torch.arange(n_vertices, device=device)
70
+ batch_ids = torch.arange(batch_size, device=device)
71
+ tables = {0: (
72
+ batch_ids.repeat_interleave(n_vertices),
73
+ vertex_ids.expand(batch_size, -1).reshape(-1, 1),
74
+ )}
75
+ if max_dim == 0:
76
+ return tables
77
+
78
+ # Pairs within the cap. The diagonal passes too, but the i < j mask below
79
+ # removes it.
80
+ adjacency = distances <= max_radius
81
+ # torch.where returns indices in row-major order, so edges come out grouped
82
+ # by batch member and sorted by (i, j).
83
+ edge_batch, edge_left, edge_right = torch.where(
84
+ adjacency & (vertex_ids[:, None] < vertex_ids[None, :]),
85
+ )
86
+ tables[1] = (edge_batch, torch.stack((edge_left, edge_right), dim=-1))
87
+ for dim in range(2, max_dim + 1):
88
+ previous_batch, previous = tables[dim - 1]
89
+ chunks: list[torch.Tensor] = []
90
+ batch_chunks: list[torch.Tensor] = []
91
+ # A candidate is a parent simplex plus one extra vertex. Requiring the
92
+ # extra vertex to exceed the parent's largest vertex gives every simplex
93
+ # exactly one parent: the simplex with its largest vertex removed.
94
+ n_candidates = previous.shape[0] * n_vertices
95
+ for start in range(0, n_candidates, candidate_tile_size):
96
+ candidate_ids = torch.arange(
97
+ start, min(start + candidate_tile_size, n_candidates),
98
+ device=device,
99
+ )
100
+ # Each candidate id encodes (parent row, extra vertex) as
101
+ # parent * n_vertices + extra.
102
+ parent_ids = candidate_ids // n_vertices
103
+ extra_vertices = candidate_ids % n_vertices
104
+ # Only extra vertices above the parent's largest vertex are canonical.
105
+ ordered = extra_vertices > previous[parent_ids, -1]
106
+ parent_ids = parent_ids[ordered]
107
+ extra_vertices = extra_vertices[ordered]
108
+ parents = previous[parent_ids]
109
+ batches = previous_batch[parent_ids]
110
+ # The parent's own edges already passed, so only the new vertex's
111
+ # edges to the parent's vertices need checking.
112
+ cofaceable = adjacency[
113
+ batches[:, None], parents, extra_vertices[:, None],
114
+ ].all(dim=-1)
115
+ chunks.append(torch.cat((
116
+ parents[cofaceable], extra_vertices[cofaceable, None],
117
+ ), dim=-1))
118
+ batch_chunks.append(batches[cofaceable])
119
+ tables[dim] = (
120
+ torch.cat(batch_chunks) if batch_chunks else batch_ids[:0],
121
+ torch.cat(chunks) if chunks else torch.empty(
122
+ (0, dim + 1), device=device, dtype=torch.int64,
123
+ ),
124
+ )
125
+ return tables
126
+
127
+
128
+ def vietoris_rips_sparse(
129
+ distances: torch.Tensor,
130
+ max_radius: float,
131
+ max_dim: int,
132
+ *,
133
+ candidate_tile_size: int = VR_CANDIDATE_TILE_DEFAULT,
134
+ ) -> SparseSimplicialComplex:
135
+ """Vietoris-Rips complex from a single distance matrix.
136
+
137
+ Args:
138
+ distances: `(n, n)` symmetric real distance matrix.
139
+ max_radius: include only pairs with `d <= max_radius`.
140
+ max_dim: build simplex tables through this dimension, including
141
+ empty tables where no simplex exists.
142
+ candidate_tile_size: maximum number of simplex-extension candidates
143
+ evaluated at once. It bounds temporary storage, not output size.
144
+
145
+ Returns:
146
+ Single-instance complex on the input device.
147
+
148
+ Notes:
149
+ Dimensions are constructed sequentially because each extension uses
150
+ the preceding dimension. Candidates within a tile are tensorized.
151
+
152
+ References:
153
+ Hausmann, J.-C. (1995). On the Vietoris-Rips complexes and a
154
+ cohomology theory for metric spaces. In Prospects in Topology,
155
+ Annals of Math Studies 138.
156
+ Bauer, U. (2021). Ripser: efficient computation of Vietoris-Rips
157
+ persistence barcodes. J. Appl. Comput. Topology 5:391-423.
158
+ """
159
+ if distances.ndim != 2 or distances.shape[0] != distances.shape[1]:
160
+ raise ValueError(
161
+ f"distances must be (n, n); got {tuple(distances.shape)}"
162
+ )
163
+ tables = _rips_tables(
164
+ distances.unsqueeze(0), max_radius, max_dim, candidate_tile_size,
165
+ )
166
+ return SparseSimplicialComplex(
167
+ simplices_by_dim={dim: table for dim, (_, table) in tables.items()},
168
+ n_vertices=distances.shape[-1], device=distances.device,
169
+ )
170
+
171
+
172
+ def vietoris_rips_dense(
173
+ distances: torch.Tensor,
174
+ max_radius: float,
175
+ max_dim: int,
176
+ dtype: torch.dtype | None = None,
177
+ *,
178
+ candidate_tile_size: int = VR_CANDIDATE_TILE_DEFAULT,
179
+ ) -> DenseSimplicialComplex:
180
+ """Batched Vietoris-Rips construction with padded simplex tables.
181
+
182
+ Args:
183
+ distances: `(B, n, n)` symmetric real distance matrices.
184
+ max_radius: include pairs with `d <= max_radius`.
185
+ max_dim: build tables through this dimension, including empty ones.
186
+ dtype: boundary-operator dtype; defaults to the distances' dtype.
187
+ candidate_tile_size: maximum number of simplex-extension candidates
188
+ evaluated at once. It bounds temporary storage, not output size.
189
+
190
+ Returns:
191
+ A complex with per-batch padded simplex tables on the input device.
192
+ Empty batches retain all requested dimensions.
193
+
194
+ Notes:
195
+ Batch members share tensor candidate evaluation and packing. Only
196
+ dimensions and memory tiles are traversed in Python. Dimension
197
+ progression is sequential because extensions require their parents.
198
+
199
+ References:
200
+ Hausmann, J.-C. (1995). On the Vietoris-Rips complexes and a
201
+ cohomology theory for metric spaces.
202
+ Bauer, U. (2021). Ripser: efficient computation of Vietoris-Rips
203
+ persistence barcodes. J. Appl. Comput. Topology 5:391-423.
204
+ """
205
+ tables = _rips_tables(distances, max_radius, max_dim, candidate_tile_size)
206
+ batch_size = distances.shape[0]
207
+ device = distances.device
208
+ simplices_by_dim: dict[int, torch.Tensor] = {}
209
+ valid_mask: dict[int, torch.Tensor] = {}
210
+ for dim, (batches, table) in tables.items():
211
+ counts = torch.bincount(batches, minlength=batch_size)
212
+ # The padded width is the largest count over batch members, which only
213
+ # the data can tell us. int() syncs the device once per dimension
214
+ # before the output table is allocated.
215
+ width = int(counts.max()) if batch_size else 0
216
+ # Every member has exactly n vertices, so dim 0 uses n directly. That
217
+ # also keeps the width right when the batch is empty.
218
+ if dim == 0:
219
+ width = distances.shape[-1]
220
+ padded = torch.full(
221
+ (batch_size, width, dim + 1), -1,
222
+ dtype=torch.int64, device=device,
223
+ )
224
+ mask = torch.zeros(batch_size, width, dtype=torch.bool, device=device)
225
+ # Tables are sorted by batch id, so member b's rows are contiguous and
226
+ # start at offsets[b]. columns is each row's slot inside its member's
227
+ # padded block.
228
+ offsets = counts.cumsum(0) - counts
229
+ columns = torch.arange(table.shape[0], device=device) - offsets[batches]
230
+ padded[batches, columns] = table
231
+ mask[batches, columns] = True
232
+ simplices_by_dim[dim] = padded
233
+ valid_mask[dim] = mask
234
+ return DenseSimplicialComplex(
235
+ simplices_by_dim=simplices_by_dim, valid_mask=valid_mask,
236
+ n_vertices=distances.shape[-1], device=device,
237
+ dtype=distances.dtype if dtype is None else dtype,
238
+ )
239
+
240
+
241
+ def pairwise_distances(points: torch.Tensor) -> torch.Tensor:
242
+ """Pairwise Euclidean distances.
243
+
244
+ Args:
245
+ points: `(n, d)` or `(B, n, d)`.
246
+
247
+ Returns:
248
+ `(n, n)` or `(B, n, n)` distance matrix on the input device and dtype.
249
+
250
+ Notes:
251
+ Half and bfloat16 inputs use float32 intermediates because cdist's
252
+ direct CPU kernel does not support these dtypes.
253
+
254
+ References:
255
+ Standard Euclidean distance. See e.g. Hartle, J. B. (2003),
256
+ Gravity: An Introduction to Einstein's General Relativity,
257
+ §2.1 for the abstract metric formulation; computationally
258
+ we use `||p - q||_2` directly.
259
+ """
260
+ if points.ndim != 2 and points.ndim != 2 + 1:
261
+ raise ValueError(
262
+ f"points must be (n, d) or (B, n, d); got {tuple(points.shape)}"
263
+ )
264
+ if not points.is_floating_point():
265
+ raise ValueError("points must have a real floating dtype")
266
+ # cdist's CPU kernel only accepts float32/float64. Evaluate low-precision
267
+ # inputs in float32 and return their original dtype on the same device.
268
+ working = (
269
+ points.float() if points.dtype in (torch.float16, torch.bfloat16) else points
270
+ )
271
+ # The direct kernel avoids the cancellation in the matrix-multiplication
272
+ # identity ||p||^2 + ||q||^2 - 2 p.q, which loses digits for nearly
273
+ # coincident points. Those small distances are the first edges the
274
+ # filtration adds.
275
+ return torch.cdist(
276
+ working, working, compute_mode="donot_use_mm_for_euclid_dist",
277
+ ).to(points.dtype)
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """holonomy_lib.spectral: graph Laplacians and spectral utilities.
5
+
6
+ Public names:
7
+ laplacian.combinatorial(A) : L = D - A
8
+ laplacian.symmetric_normalized(A) : L_sym = I - D^{-1/2} A D^{-1/2}
9
+ laplacian.random_walk(A) : L_rw = I - D^{-1} A
10
+ laplacian.signed(A) : L^sigma = D^{|sigma|} - A
11
+ (Kunegis 2010)
12
+ laplacian.degree(A) : degree vector (signed-aware)
13
+ magnetic.combinatorial(A, q) : Hermitian Laplacian on directed
14
+ graphs (Furutani 2020, Fanuel 2017)
15
+ magnetic.symmetric_normalized(A, q) : normalized magnetic Laplacian,
16
+ spectrum in [0, 2]
17
+ magnetic.sign_magnetic_combinatorial(A, q)
18
+ : signed-directed Hermitian
19
+ Laplacian; unifies Kunegis
20
+ signed and magnetic
21
+ (He et al. 2022, MSGNN)
22
+ magnetic.sign_magnetic_symmetric_normalized(A, q)
23
+ : normalized signed-directed form
24
+ laplacian_eigenmaps(A, k, ...) : bottom-k spectral embedding
25
+ spectral_dimension(eigenvalues, t) : d_s = -2 d log p / d log t from the
26
+ heat-kernel return decay
27
+ (Rammal-Toulouse 1983)
28
+ diffusion_map(A, k, t) : diffusion-map embedding at time t
29
+ (Coifman-Lafon 2006)
30
+ effective_resistance(A) : pairwise effective resistance
31
+ commute_time(A) : pairwise commute time, vol(component) R
32
+ heat_kernel_chebyshev(L, t, signal=None) : exp(-t L), or exp(-t L) @ signal,
33
+ by a Chebyshev polynomial expansion
34
+ """
35
+
36
+ from holonomy_lib.spectral import laplacian, magnetic
37
+ from holonomy_lib.spectral.diffusion_map import diffusion_map
38
+ from holonomy_lib.spectral.effective_resistance import (
39
+ commute_time,
40
+ effective_resistance,
41
+ )
42
+ from holonomy_lib.spectral.embedding import laplacian_eigenmaps
43
+ from holonomy_lib.spectral.heat_kernel import heat_kernel_chebyshev
44
+ from holonomy_lib.spectral.spectral_dimension import spectral_dimension
45
+
46
+ __all__ = [
47
+ "commute_time",
48
+ "diffusion_map",
49
+ "effective_resistance",
50
+ "heat_kernel_chebyshev",
51
+ "laplacian",
52
+ "laplacian_eigenmaps",
53
+ "magnetic",
54
+ "spectral_dimension",
55
+ ]