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,351 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Magnetic Laplacian for directed and signed-directed graphs.
5
+
6
+ The standard real-valued graph Laplacians (`spectral.laplacian.*`)
7
+ assume a symmetric adjacency. For a directed graph the asymmetry
8
+ encodes information that any symmetrization throws away. The
9
+ **magnetic Laplacian** (Lieb-Loss 1993; Fanuel et al. 2017; Furutani
10
+ et al. 2020) recovers that information by attaching a complex phase
11
+ factor `e^{i · 2π · q · (A_{ij} - A_{ji})}` to each edge, while keeping
12
+ the magnitude structure symmetric. The result is a Hermitian operator
13
+ whose eigenvalues are real and whose eigenvectors are complex; the
14
+ phase of those eigenvectors carries the directional information.
15
+
16
+ Definition (combinatorial form):
17
+
18
+ A_s = (A + A^T) / 2 (symmetrized)
19
+ D_s = diag(Σ_j A_s[i, j]) (symmetric degree)
20
+ H = exp(i · 2π · q · (A - A^T)) (Hermitian phase factor)
21
+ L^(q) = D_s - H ⊙ A_s
22
+
23
+ For symmetric A this reduces to L = D - A regardless of q. For purely
24
+ directed A (entries in {0, 1}, A ≠ A^T) the phase encodes "flow
25
+ direction": q = 1/4 separates directed eigenmodes most clearly
26
+ (Furutani 2020, §5).
27
+
28
+ Four forms exposed:
29
+ combinatorial(A, q) : L^(q) = D_s − H ⊙ A_s
30
+ symmetric_normalized(A, q) : L_sym^(q) = I − D_s^{−1/2} (H ⊙ A_s) D_s^{−1/2}
31
+ sign_magnetic_combinatorial(A, q) : signed-directed analog,
32
+ unifies Kunegis signed + magnetic
33
+ sign_magnetic_symmetric_normalized(A, q) : normalized signed-directed
34
+
35
+ The signed forms implement MSGNN (He, Perlmutter, Reinert, Cucuringu
36
+ 2022, equations 1 and 2). They use the signed symmetric adjacency
37
+ A_s = (A + A^T)/2, absolute degree D_abs[i,i] = sum_j
38
+ (|A[i,j]| + |A[j,i]|)/2, and phase exp(i 2π q (A - A^T)).
39
+ Both operators are positive semidefinite; the normalized spectrum
40
+ lies in [0, 2]. Nonnegative input gives the ordinary magnetic
41
+ Laplacian. Symmetric signed input gives the signed Laplacian for
42
+ any charge. At q = 0 the phase disappears, leaving D_abs - A_s.
43
+ For opposite-sign reciprocal edges, D_abs differs from sum_j |A_s|;
44
+ this distinction is part of MSGNN's definition.
45
+
46
+ All four functions return a complex Hermitian tensor of shape
47
+ (B, n, n). Use `torch.linalg.eigh` on the result : its real-eigenvalue
48
+ output is the spectrum and its complex eigenvectors are the
49
+ directed-graph modes.
50
+
51
+ References:
52
+ Lieb, E. H., Loss, M. (1993). Fluxes, Laplacians, and Kasteleyn's
53
+ theorem. Duke Mathematical Journal 71(2):337–363. The original
54
+ magnetic / flux-discrete Laplacian on graphs.
55
+ Fanuel, M., Alaiz, C. M., Suykens, J. A. K. (2017). Magnetic
56
+ eigenmaps for the visualization of directed networks. Applied and
57
+ Computational Harmonic Analysis 44(1):189–199. The modern spectral-
58
+ embedding application.
59
+ Furutani, S., Shibahara, T., Akiyama, M., Hato, K., Aida, M. (2020).
60
+ Graph Signal Processing for Directed Graphs based on the Hermitian
61
+ Laplacian. Journal of Machine Learning Research 21(122):1–37.
62
+ Gives the q = 1/4 default and the normalization conventions used
63
+ here. Section 5 covers the spectral interpretation.
64
+ Kunegis, J., Schmidt, S., Lommatzsch, A., Lerner, J., De Luca, E. W.,
65
+ Albayrak, S. (2010). Spectral analysis of signed graphs for
66
+ clustering, prediction and visualization. SDM 2010. The signed
67
+ Laplacian that the q = 0 signed-magnetic case reduces to.
68
+ He, Y., Perlmutter, M., Reinert, G., Cucuringu, M. (2022). MSGNN:
69
+ A Spectral Graph Neural Network Based on a Novel Magnetic Signed
70
+ Laplacian. LoG 2022, PMLR 198, §3.2, equations 1 and 2.
71
+ """
72
+
73
+ from __future__ import annotations
74
+
75
+ import math
76
+
77
+ import torch
78
+
79
+ from holonomy_lib._graph_utils import drop_self_loops
80
+
81
+ # Default magnetic charge q ∈ [0, 1) per Furutani et al. (2020), §5.
82
+ # `q = 1/4` is the value that maximally separates the directed
83
+ # eigenmodes for a cycle and is therefore the standard literature
84
+ # default. **Scale of validity**: dimensionless ratio of magnetic flux
85
+ # units; does not depend on n. Cataloged as `MAGNETIC_CHARGE_DEFAULT`.
86
+ MAGNETIC_CHARGE_DEFAULT: float = 0.25
87
+
88
+
89
+ def combinatorial(
90
+ A: torch.Tensor, q: float = MAGNETIC_CHARGE_DEFAULT,
91
+ ) -> torch.Tensor:
92
+ """Combinatorial magnetic Laplacian L^(q) = D_s − H ⊙ A_s.
93
+
94
+ Args:
95
+ A: (B, n, n) weighted adjacency. May be non-symmetric. Entries
96
+ may be negative (signed-directed graphs); the symmetrized
97
+ adjacency `A_s = (A + A^T)/2` keeps the "edge presence",
98
+ and the antisymmetric part `A − A^T` enters via the phase.
99
+ q: magnetic charge in [0, 1). Default 1/4 per Furutani 2020 §5.
100
+
101
+ Returns:
102
+ L: (B, n, n) complex Hermitian Laplacian. Use `torch.linalg.eigh`
103
+ to get real eigenvalues + complex eigenvectors.
104
+
105
+ References:
106
+ Furutani et al. (2020), eq. 8.
107
+ Lieb-Loss (1993).
108
+ """
109
+ _validate_real_adjacency(A, q)
110
+ A = drop_self_loops(A)
111
+ A_s = 0.5 * (A + A.mT) # symmetrized magnitude
112
+ D_s = A_s.sum(dim=-1) # (B, n) symmetric degree
113
+
114
+ H_times_As = _magnetic_phase_times_adj(A, A_s, q)
115
+
116
+ # D_s on the diagonal, embedded in the complex dtype.
117
+ D_diag = torch.diag_embed(D_s).to(H_times_As.dtype)
118
+ return D_diag - H_times_As
119
+
120
+
121
+ def symmetric_normalized(
122
+ A: torch.Tensor, q: float = MAGNETIC_CHARGE_DEFAULT,
123
+ ) -> torch.Tensor:
124
+ """Symmetric-normalized magnetic Laplacian.
125
+
126
+ L_sym^(q) = I − D_s^{−1/2} (H ⊙ A_s) D_s^{−1/2}
127
+
128
+ For nonnegative A, spectrum ⊂ [0, 2] regardless of q (Furutani 2020, Prop. 1). Use
129
+ this form for spectral embeddings of directed graphs; the
130
+ eigenvectors at the bottom-k eigenvalues are the directed analog
131
+ of Laplacian eigenmaps.
132
+
133
+ Args:
134
+ A: (B, n, n) nonnegative weighted adjacency. May be non-symmetric.
135
+ q: magnetic charge in [0, 1).
136
+
137
+ Returns:
138
+ L_sym^(q): (B, n, n) complex Hermitian, spectrum in [0, 2].
139
+
140
+ References:
141
+ Furutani et al. (2020), Definition 4 and Prop. 1.
142
+ """
143
+ _validate_real_adjacency(A, q)
144
+ A = drop_self_loops(A)
145
+ A_s = 0.5 * (A + A.mT)
146
+ D_s = A_s.sum(dim=-1) # (B, n)
147
+
148
+ # Moore-Penrose pseudoinverse handling for isolated nodes, the same
149
+ # convention as `holonomy_lib.spectral.laplacian.symmetric_normalized`.
150
+ # The clamp keeps rsqrt finite on the branch `where` throws away, so
151
+ # an isolated node never puts an inf into the autograd graph
152
+ floor = torch.finfo(A.dtype).tiny
153
+ safe_D = D_s.clamp(min=floor)
154
+ d_inv_sqrt = torch.where(
155
+ D_s > 0, torch.rsqrt(safe_D), torch.zeros_like(D_s),
156
+ ) # (B, n)
157
+
158
+ H_times_As = _magnetic_phase_times_adj(A, A_s, q)
159
+
160
+ # D^{-1/2} (H ⊙ A_s) D^{-1/2} as an outer product of the two scalings.
161
+ # The real scale has to be cast to complex before it multiplies the
162
+ # complex phase matrix
163
+ scale = (
164
+ d_inv_sqrt.unsqueeze(dim=-1) * d_inv_sqrt.unsqueeze(dim=-2)
165
+ ).to(H_times_As.dtype)
166
+ normalized = H_times_As * scale
167
+
168
+ n = A.shape[-1]
169
+ eye = torch.eye(n, device=A.device, dtype=H_times_As.dtype).expand_as(
170
+ normalized,
171
+ )
172
+ return eye - normalized
173
+
174
+
175
+ # ============================================================
176
+ # Sign-magnetic Laplacian (signed-directed graphs)
177
+ # ============================================================
178
+
179
+
180
+ def sign_magnetic_combinatorial(
181
+ A: torch.Tensor, q: float = MAGNETIC_CHARGE_DEFAULT,
182
+ ) -> torch.Tensor:
183
+ """Sign-magnetic combinatorial Laplacian for signed-directed graphs.
184
+
185
+ L^(q,σ) = D_|.| − exp(i · 2π · q · (A − A^T)) ⊙ A_s^signed
186
+
187
+ where:
188
+ A_s^signed = (A + A^T) / 2 (signed symmetric part)
189
+ A_s^|.| = (|A| + |A|^T) / 2 (magnitude symmetric part)
190
+ D_|.| = diag(Σ_j A_s^|.|[i, j]) (signless degree, à la
191
+ Kunegis 2010)
192
+
193
+ Phase uses signed A, as in MSGNN equation 1. At q = 0 the phase
194
+ disappears. The absolute degree is computed before symmetrization,
195
+ so opposite-sign reciprocal edges still contribute to the degree.
196
+
197
+ Reduces to:
198
+ magnetic.combinatorial(A, q) if A ≥ 0 (signs irrelevant)
199
+ laplacian.signed(A) if A = A^T (Θ ≡ 0, signs visible)
200
+ laplacian.combinatorial(A) if A ≥ 0 ∧ A = A^T
201
+
202
+ Args:
203
+ A: (B, n, n) real-valued adjacency. May be non-symmetric and may
204
+ have negative entries.
205
+ q: magnetic charge in [0, 1). Default 1/4.
206
+
207
+ Returns:
208
+ L: (B, n, n) complex Hermitian positive-semidefinite Laplacian.
209
+
210
+ References:
211
+ He, Perlmutter, Reinert, Cucuringu (2022). MSGNN. LoG 2022,
212
+ PMLR 198, §3.2, equation 1 and Theorem 1.
213
+ Kunegis et al. (2010) for the q = 0 (signed-undirected) reduction.
214
+ """
215
+ _validate_real_adjacency(A, q)
216
+ A = drop_self_loops(A)
217
+ abs_A = A.abs()
218
+ A_s_signed = 0.5 * (A + A.mT)
219
+ A_s_abs = 0.5 * (abs_A + abs_A.mT)
220
+ D_abs = A_s_abs.sum(dim=-1) # (B, n)
221
+
222
+ H_times_As = _magnetic_phase_times_adj(A, A_s_signed, q)
223
+ D_diag = torch.diag_embed(D_abs).to(H_times_As.dtype)
224
+ return D_diag - H_times_As
225
+
226
+
227
+ def sign_magnetic_symmetric_normalized(
228
+ A: torch.Tensor, q: float = MAGNETIC_CHARGE_DEFAULT,
229
+ ) -> torch.Tensor:
230
+ """Sign-magnetic symmetric-normalized Laplacian.
231
+
232
+ L_sym^(q,σ) = I − D_|.|^{−1/2} (exp(iΘ) ⊙ A_s^signed) D_|.|^{−1/2}
233
+
234
+ where Θ_{ij} = 2π · q · (A_{ij} − A_{ji}) and
235
+ D_|.|[i,i] = sum_j (|A[i,j]| + |A[j,i]|)/2. Use this form for
236
+ spectral embeddings of signed-directed graphs; the eigenvectors at
237
+ the bottom-k eigenvalues are the signed-directed analog of
238
+ Laplacian eigenmaps.
239
+
240
+ Args:
241
+ A: (B, n, n) real-valued adjacency, may be non-symmetric and signed.
242
+ q: magnetic charge in [0, 1).
243
+
244
+ Returns:
245
+ L_sym^(q,σ): (B, n, n) complex Hermitian, spectrum in [0, 2].
246
+ Isolated nodes have diagonal 1 under the pseudoinverse convention.
247
+
248
+ References:
249
+ He, Perlmutter, Reinert, Cucuringu (2022). MSGNN. LoG 2022,
250
+ PMLR 198, §3.2, equation 2 and Theorems 1 and 2.
251
+ """
252
+ _validate_real_adjacency(A, q)
253
+ A = drop_self_loops(A)
254
+ abs_A = A.abs()
255
+ A_s_signed = 0.5 * (A + A.mT)
256
+ A_s_abs = 0.5 * (abs_A + abs_A.mT)
257
+ D_abs = A_s_abs.sum(dim=-1) # (B, n)
258
+
259
+ # Same pseudoinverse handling as `laplacian.symmetric_normalized` and
260
+ # `magnetic.symmetric_normalized`: isolated nodes get d^{-1/2} = 0.
261
+ floor = torch.finfo(A.dtype).tiny
262
+ safe_D = D_abs.clamp(min=floor)
263
+ d_inv_sqrt = torch.where(
264
+ D_abs > 0, torch.rsqrt(safe_D), torch.zeros_like(D_abs),
265
+ ) # (B, n)
266
+
267
+ H_times_As = _magnetic_phase_times_adj(A, A_s_signed, q)
268
+
269
+ scale = (
270
+ d_inv_sqrt.unsqueeze(dim=-1) * d_inv_sqrt.unsqueeze(dim=-2)
271
+ ).to(H_times_As.dtype)
272
+ normalized = H_times_As * scale
273
+
274
+ n = A.shape[-1]
275
+ eye = torch.eye(n, device=A.device, dtype=H_times_As.dtype).expand_as(
276
+ normalized,
277
+ )
278
+ return eye - normalized
279
+
280
+
281
+ # ============================================================
282
+ # Internal helpers
283
+ # ============================================================
284
+
285
+
286
+ def _validate_real_adjacency(A: torch.Tensor, q: float) -> None:
287
+ """Shared input validation for the magnetic Laplacian primitives.
288
+
289
+ Takes the adjacency and charge, returns nothing, raises `ValueError`
290
+ if `A` is not square in its last two dims, is not a real floating
291
+ dtype (complex and integer tensors both fail), or `q` is outside
292
+ [0, 1). Complex `A` is rejected outright: the phase factor
293
+ `H = exp(i · 2π · q · (A − A^T))` is only defined for real
294
+ antisymmetric arguments. Passing a complex `A` would let
295
+ `torch.cos`/`torch.sin` produce complex outputs and
296
+ `torch.complex(cos, sin)` would raise an opaque "expected real
297
+ tensors" error deep inside the helper.
298
+ """
299
+ if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
300
+ raise ValueError(
301
+ f"A must be (..., n, n); got A.shape={tuple(A.shape)}"
302
+ )
303
+ if not A.is_floating_point():
304
+ raise ValueError(
305
+ f"A must be a real floating-point tensor; got dtype={A.dtype}. "
306
+ f"The magnetic Laplacian's phase factor is defined for real "
307
+ f"adjacencies only."
308
+ )
309
+ if not (0.0 <= q < 1.0):
310
+ raise ValueError(f"magnetic charge q must be in [0, 1), got {q}")
311
+
312
+
313
+ def _magnetic_phase_times_adj(
314
+ A: torch.Tensor, A_s: torch.Tensor, q: float,
315
+ ) -> torch.Tensor:
316
+ """Return H ⊙ A_s where H_{ij} = exp(i · 2π · q · (A_{ij} - A_{ji})).
317
+
318
+ Takes the real adjacency `A` (for the phase) and the already
319
+ symmetrized `A_s` (signed or unsigned, the caller picks), both
320
+ `(..., n, n)`, and returns one complex tensor of the same shape. For
321
+ symmetric A the phase is identically 1 and the result reduces to
322
+ A_s + 0j, but we still pay for building the phase. For q = 0 the
323
+ phase is also identically 1, and that case skips the trig entirely.
324
+ """
325
+ if q == 0.0:
326
+ # no phase at all, so the answer is just A_s cast to complex
327
+ complex_dtype = _matching_complex_dtype(A.dtype)
328
+ return A_s.to(complex_dtype)
329
+
330
+ # the antisymmetric part A - A^T carries the direction of each edge.
331
+ # For a symmetric input it is all zeros, the phase is 1 everywhere,
332
+ # and q has no effect, which is the reduction the module promises
333
+ asym = A - A.mT # (B, n, n)
334
+ angle = (2.0 * math.pi * q) * asym # (B, n, n) real
335
+ phase = torch.complex(torch.cos(angle), torch.sin(angle))
336
+ complex_dtype = phase.dtype
337
+ return phase * A_s.to(complex_dtype)
338
+
339
+
340
+ def _matching_complex_dtype(real_dtype: torch.dtype) -> torch.dtype:
341
+ """Pick a complex dtype matching the precision of a real dtype.
342
+
343
+ float64 maps to complex128 and float32 to complex64. Anything else
344
+ (half, bfloat16) goes up to complex64, since half-precision complex
345
+ support in torch is thin.
346
+ """
347
+ if real_dtype == torch.float64:
348
+ return torch.complex128
349
+ if real_dtype == torch.float32:
350
+ return torch.complex64
351
+ return torch.complex64
@@ -0,0 +1,104 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Spectral dimension from a graph / operator Laplacian spectrum.
5
+
6
+ The spectral dimension `d_s` is the exponent governing the small-time decay of
7
+ the heat-kernel return probability:
8
+
9
+ p(t) = (1/n) Σ_i exp(-t·λ_i) ~ t^(-d_s/2) as t → 0+,
10
+
11
+ equivalently `d_s = -2 · d log p / d log t`. It need not be an integer (fractals,
12
+ multifractal media) and on a finite graph it is read off as the slope of
13
+ `log p` against `log t` over a scaling window: outside that window finite-size
14
+ effects flatten it (`p → (#zero modes)/n` as `t → ∞`, biasing `d_s` toward 0).
15
+
16
+ This estimates `d_s` as the least-squares slope of `log p(t)` against `log t`
17
+ over a caller-supplied set of times `t`; choose them inside the power-law
18
+ window. The input is the Laplacian spectrum (e.g. from
19
+ `holonomy_lib.spectral.laplacian` + an eigensolver), so the same routine serves
20
+ graphs, fractal approximants, and discretized continua.
21
+
22
+ References:
23
+ Rammal, R., Toulouse, G. (1983). Random walks on fractal structures and
24
+ percolation clusters. J. Physique Lett. 44(1):L13-L22 (spectral dimension).
25
+ Durhuus, B., Jonsson, T., Wheater, J. F. (2007). The spectral dimension of
26
+ generic trees. J. Stat. Phys. 128:1237-1260 (return-probability form).
27
+ Hambly, B. M., Kigami, J., Kumagai, T. (2002). Multifractal formalisms for
28
+ the local spectral and walk dimensions. Math. Proc. Camb. Phil. Soc.
29
+ 132:555-571 (local / pointwise version).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import math
35
+
36
+ import torch
37
+
38
+
39
+ def spectral_dimension(
40
+ eigenvalues: torch.Tensor, t: torch.Tensor,
41
+ ) -> torch.Tensor:
42
+ """Spectral dimension `d_s = -2 · slope of log p(t) vs log t`, where
43
+ `p(t) = mean_i exp(-t·λ_i)` is the heat-kernel return probability.
44
+
45
+ Args:
46
+ eigenvalues: `(B, n)` Laplacian eigenvalues, `>= 0` (float-noise negatives
47
+ are clamped to 0). A `(n,)` input is treated as `B = 1`.
48
+ t: `(T,)` strictly positive sample times, `T >= 2`. Choose them inside the
49
+ power-law window: small enough that many modes contribute, large enough
50
+ that the spectral gap has not yet flattened `p`.
51
+ Returns:
52
+ `(B,)` estimated spectral dimension (a `(n,)` input returns shape `()`).
53
+
54
+ Notes:
55
+ `d_s` is a fitted asymptotic exponent, not exact on a finite spectrum: as
56
+ `t → ∞`, `p → (#zero modes)/n`, so an over-large `t` biases `d_s` toward 0.
57
+ See the module docstring for the window caveat.
58
+
59
+ References:
60
+ Rammal-Toulouse (1983); Durhuus-Jonsson-Wheater (2007).
61
+ """
62
+ if t.ndim != 1 or t.shape[0] < 2:
63
+ raise ValueError(
64
+ f"t must be a 1-D tensor of at least 2 sample times, got shape "
65
+ f"{tuple(t.shape)}"
66
+ )
67
+ if eigenvalues.ndim < 1:
68
+ raise ValueError(
69
+ f"eigenvalues must have a mode axis (ndim >= 1), got shape "
70
+ f"{tuple(eigenvalues.shape)}"
71
+ )
72
+ if eigenvalues.shape[-1] == 0:
73
+ raise ValueError("eigenvalues must contain at least one mode")
74
+ if not eigenvalues.is_floating_point() or not t.is_floating_point():
75
+ raise ValueError("eigenvalues and t must have real floating-point dtypes")
76
+ if not torch.isfinite(t).all() or (t <= 0).any():
77
+ raise ValueError("t must be finite and strictly positive")
78
+ if not torch.isfinite(eigenvalues).all():
79
+ raise ValueError("eigenvalues must be finite")
80
+
81
+ # a lone (n,) spectrum gets a batch axis of 1 here and loses it at the
82
+ # end. The clamp removes tiny negative eigenvalues from the eigensolver,
83
+ # which would otherwise make exp(-t·λ) grow with t
84
+ squeeze = eigenvalues.ndim == 1
85
+ lam = (eigenvalues.unsqueeze(0) if squeeze else eigenvalues).clamp(min=0.0)
86
+
87
+ # least-squares slope of log p against log t, per batch element. The
88
+ # slope is Σ (x - x̄)(y - ȳ) / Σ (x - x̄)², with x = log t shared by every
89
+ # batch element, so x is centered and its variance computed once
90
+ log_t = torch.log(t) # (T,)
91
+ lt_centered = log_t - log_t.mean() # (T,)
92
+ variance = lt_centered.square().sum()
93
+ # all t equal (repeated values) means there is no slope to fit
94
+ if variance == 0:
95
+ raise ValueError("t must have nonzero log-time variance")
96
+ # Keep the sum in log space, since valid late times can underflow
97
+ # every exp(-t * lambda) even though the fitted slope is finite.
98
+ log_p = torch.logsumexp(-lam.unsqueeze(-2) * t.unsqueeze(-1), dim=-1)
99
+ log_p = log_p - math.log(lam.shape[-1]) # mean, not sum
100
+ lp_centered = log_p - log_p.mean(dim=-1, keepdim=True) # (B, T)
101
+ slope = (lp_centered * lt_centered).sum(dim=-1) / variance
102
+ d_s = -2.0 * slope
103
+
104
+ return d_s.squeeze(0) if squeeze else d_s
@@ -0,0 +1,23 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """holonomy_lib.tensor_calculus: multilinear algebra primitives.
5
+
6
+ Currently implemented:
7
+ mode_product(T, A, axis): n-mode product T ×_axis A.
8
+ mode_unfolding(T, axis): matricize T by moving one axis to position 1,
9
+ after the batch axis.
10
+ hosvd(T, ranks, mode): truncated Higher-Order SVD.
11
+
12
+ Planned:
13
+ CP / Tucker decompositions with ALS, Tensor-Train, manifold-valued
14
+ tensor fields, Einstein notation helpers.
15
+ """
16
+
17
+ from holonomy_lib.tensor_calculus.decomposition import (
18
+ hosvd,
19
+ mode_product,
20
+ mode_unfolding,
21
+ )
22
+
23
+ __all__ = ["hosvd", "mode_product", "mode_unfolding"]
@@ -0,0 +1,208 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Tensor decompositions: HOSVD and the n-mode product machinery.
5
+
6
+ This module assumes the leading dimension of every tensor is a batch
7
+ dimension. So a tensor of order d is stored as a tensor of shape
8
+ `(B, n_1, n_2, ..., n_d)` where B is the batch size. The "tensor modes"
9
+ are axes 1 through d (axis 0 is batch).
10
+
11
+ Operations implemented:
12
+
13
+ mode_product(T, A, axis)
14
+ n-mode product T ×_axis A. Contracts `T`'s `axis`-th dimension with
15
+ the last dimension of `A` (Kolda-Bader 2009 convention).
16
+
17
+ mode_unfolding(T, axis)
18
+ Matricize T by moving `axis` to position 1 and flattening the rest.
19
+ Output shape `(B, n_axis, prod_of_other_modes)`.
20
+
21
+ hosvd(T, ranks, mode)
22
+ Truncated Higher-Order SVD (De Lathauwer-De Moor-Vandewalle 2000).
23
+ Returns (core, factors). Each factor is the top-r_k left singular
24
+ vectors of T's mode-k unfolding.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ from collections.abc import Sequence
31
+ from typing import Literal
32
+
33
+ import torch
34
+
35
+ from holonomy_lib.algebra.linear import truncated_svd
36
+
37
+
38
+ def mode_product(
39
+ T: torch.Tensor, A: torch.Tensor, axis: int
40
+ ) -> torch.Tensor:
41
+ """n-mode product T ×_axis A.
42
+
43
+ Contracts axis `axis` of T (size n_axis) with the **last** axis of A
44
+ (also size n_axis), producing a tensor where axis `axis` has size
45
+ A.shape[-2] instead. Kolda-Bader (2009) §2.5 convention.
46
+
47
+ Args:
48
+ T: (B, n_1, ..., n_d).
49
+ A: (B, j, n_axis); last axis must match T's `axis`-th axis.
50
+ axis: which axis of T to contract, in [1, T.ndim − 1] (axis 0 is batch).
51
+
52
+ Returns:
53
+ Tensor of shape (B, ..., j, ...), where size j appears at position `axis`.
54
+
55
+ References:
56
+ Kolda, T. G., Bader, B. W. (2009). Tensor decompositions and
57
+ applications. SIAM Review 51(3):455–500, §2.5.
58
+ """
59
+ if axis <= 0 or axis >= T.ndim:
60
+ raise ValueError(
61
+ f"axis must be in [1, {T.ndim - 1}] (axis 0 is batch), got {axis}"
62
+ )
63
+ if A.ndim != 3:
64
+ raise ValueError(
65
+ f"A must be 3-D (B, j, n_axis); got A.shape={tuple(A.shape)}"
66
+ )
67
+ if A.shape[0] != T.shape[0]:
68
+ raise ValueError(
69
+ f"batch sizes disagree: T has B={T.shape[0]}, A has B={A.shape[0]}"
70
+ )
71
+ if A.shape[-1] != T.shape[axis]:
72
+ raise ValueError(
73
+ f"size mismatch: A.shape[-1]={A.shape[-1]} must equal "
74
+ f"T.shape[{axis}]={T.shape[axis]}"
75
+ )
76
+
77
+ B = T.shape[0]
78
+ n_axis = T.shape[axis]
79
+ j = A.shape[-2]
80
+
81
+ # Move the contracted axis of T to the last position, then reshape
82
+ # so that the contracted axis is the inner dim of a 2-D matmul.
83
+ T_perm = T.movedim(axis, -1) # (B, ..., n_axis)
84
+ # Collapse the untouched modes into one K-sized axis.
85
+ other_modes = math.prod(T_perm.shape[1:-1])
86
+ T_flat = T_perm.reshape(B, other_modes, n_axis)
87
+ # Multiply: (B, K, n_axis) @ (B, n_axis, j) → (B, K, j)
88
+ out_flat = torch.bmm(T_flat, A.mT) # uses A.mT = (B, n_axis, j)
89
+ # Undo the collapse. The untouched modes are back in their original order.
90
+ middle = list(T_perm.shape[1:-1])
91
+ out_perm = out_flat.reshape(B, *middle, j)
92
+ # Move the new (size-j) axis from last back to position `axis`:
93
+ return out_perm.movedim(-1, axis)
94
+
95
+
96
+ def mode_unfolding(T: torch.Tensor, axis: int) -> torch.Tensor:
97
+ """Matricize T along `axis`: move `axis` to position 1, flatten the rest.
98
+
99
+ Other modes retain their original order and the last varies fastest
100
+ across columns (row-major ordering).
101
+
102
+ Args:
103
+ T: (B, n_1, ..., n_d).
104
+ axis: in [1, T.ndim − 1].
105
+
106
+ Returns:
107
+ (B, n_axis, prod of other tensor modes).
108
+
109
+ References:
110
+ Kolda, T. G., Bader, B. W. (2009), §2.4; mode-n matricization.
111
+ """
112
+ if axis <= 0 or axis >= T.ndim:
113
+ raise ValueError(
114
+ f"axis must be in [1, {T.ndim - 1}] (axis 0 is batch), got {axis}"
115
+ )
116
+ B = T.shape[0]
117
+ n_axis = T.shape[axis]
118
+ T_perm = T.movedim(axis, 1) # (B, n_axis, ...)
119
+ return T_perm.reshape(B, n_axis, math.prod(T_perm.shape[2:]))
120
+
121
+
122
+ def hosvd(
123
+ T: torch.Tensor,
124
+ ranks: Sequence[int],
125
+ mode: Literal["exact", "randomized"] = "exact",
126
+ generator: torch.Generator | None = None,
127
+ ) -> tuple[torch.Tensor, list[torch.Tensor]]:
128
+ """Truncated Higher-Order SVD (HOSVD).
129
+
130
+ For a tensor T ∈ R^{B × n_1 × ... × n_d}, returns factors
131
+ {U_k}_{k=1..d} and a core G such that
132
+
133
+ T ≈ G ×_1 U_1 ×_2 U_2 ×_3 ... ×_d U_d
134
+
135
+ where U_k has shape (B, n_k, r_k) with orthonormal columns and G
136
+ has shape (B, r_1, ..., r_d). The factors are taken as the top-r_k
137
+ left singular vectors of T's mode-k unfolding; the core is then
138
+
139
+ G = T ×_1 U_1ᵀ ×_2 U_2ᵀ ×_3 ... ×_d U_dᵀ.
140
+
141
+ Truncated HOSVD is in general **not** the best rank-(r_1,...,r_d)
142
+ Tucker approximation (that requires HOOI / ALS), but it is a
143
+ quasi-optimal initializer and is itself a useful approximation in
144
+ practice; see Vannieuwenhoven et al. (2012) for analysis.
145
+
146
+ Args:
147
+ T: (B, n_1, ..., n_d). Must have ndim ≥ 2 (one batch dim + ≥1 mode).
148
+ ranks: tuple of length d giving the truncation rank per tensor mode.
149
+ Each rank must be in [1, min(n_k, product of the other mode sizes)],
150
+ the available rank of that mode's reduced SVD.
151
+ mode: "exact" or "randomized"; passed through to `truncated_svd`.
152
+ generator: PyTorch RNG, forwarded to randomized SVDs.
153
+
154
+ Returns:
155
+ core: (B, r_1, ..., r_d).
156
+ factors: list of d tensors, factors[k-1] of shape (B, n_k, r_k).
157
+
158
+ References:
159
+ De Lathauwer, L., De Moor, B., Vandewalle, J. (2000). A multilinear
160
+ singular value decomposition. SIAM J. Matrix Anal. Appl.
161
+ 21(4):1253–1278.
162
+ Vannieuwenhoven, N., Vandebril, R., Meerbergen, K. (2012). A new
163
+ truncation strategy for the higher-order singular value
164
+ decomposition. SIAM J. Sci. Comput. 34(2):A1027–A1052.
165
+ Kolda, T. G., Bader, B. W. (2009), §4.2; Tucker decomposition.
166
+ """
167
+ if T.ndim < 2:
168
+ raise ValueError(
169
+ f"T must have ≥1 batch dim and ≥1 tensor mode (ndim ≥ 2); "
170
+ f"got T.shape={tuple(T.shape)}"
171
+ )
172
+ d = T.ndim - 1 # number of tensor modes
173
+ if len(ranks) != d:
174
+ raise ValueError(
175
+ f"len(ranks)={len(ranks)} must equal tensor order d={d}"
176
+ )
177
+ for k, r_k in enumerate(ranks):
178
+ n_k = T.shape[k + 1]
179
+ other_modes = math.prod(T.shape[1:k + 1] + T.shape[k + 2:])
180
+ # The mode-k unfolding of each member is (n_k, other_modes), so its
181
+ # rank cannot exceed the smaller of the two.
182
+ max_rank = min(n_k, other_modes)
183
+ if r_k <= 0 or r_k > max_rank:
184
+ raise ValueError(
185
+ f"ranks[{k}]={r_k} must be in [1, {max_rank}]; "
186
+ f"mode {k + 1} unfolding has shape ({n_k}, {other_modes})"
187
+ )
188
+
189
+ # Step 1: factors. For each mode k, take top-r_k left singular vectors
190
+ # of the mode-k unfolding.
191
+ factors: list[torch.Tensor] = []
192
+ for k in range(1, d + 1):
193
+ T_k = mode_unfolding(T, axis=k) # (B, n_k, prod_others)
194
+ U_k, _, _ = truncated_svd(
195
+ T_k, r=ranks[k - 1], mode=mode, generator=generator,
196
+ )
197
+ factors.append(U_k)
198
+
199
+ # Step 2: core G = T ×_1 U_1ᵀ ×_2 U_2ᵀ × ... ×_d U_dᵀ.
200
+ # Our `mode_product(T, A, axis)` contracts T's axis with A's last dim.
201
+ # We want to project axis k from size n_k down to r_k. Pass A =
202
+ # factors[k-1].mT so A has shape (B, r_k, n_k) and the result has r_k at
203
+ # axis k.
204
+ core = T
205
+ for k in range(1, d + 1):
206
+ core = mode_product(core, factors[k - 1].mT, axis=k)
207
+
208
+ return core, factors