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,608 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Differentiable radial heat kernels on hyperbolic space.
5
+
6
+ For unit curvature, Grigor'yan-Noguchi (1998), equations (1.6)-(1.8), give
7
+
8
+ k_3(t,r) = (4 pi t)^(-3/2) exp(-t-r^2/(4t)) r/sinh(r),
9
+ k_2(t,r) = sqrt(2) exp(-t/4) (4 pi t)^(-3/2)
10
+ integral_r^infinity s exp(-s^2/(4t))/sqrt(cosh(s)-cosh(r)) ds,
11
+ k_(n+2)(t,r) = -exp(-nt)/(2 pi sinh(r)) d_r k_n(t,r).
12
+
13
+ H^1 is the Euclidean Gaussian. H^3, H^5 and H^7 use closed forms;
14
+ higher odd dimensions and even dimensions above two use the recurrence.
15
+ Odd kernels have a dtype-aware small-radius series, and even kernels
16
+ use a smooth dimension-descent integral in that neighborhood. Recursion
17
+ is sequential in dimension because each step differentiates the preceding
18
+ kernel. All tensor elements and quadrature nodes are evaluated together.
19
+
20
+ For curvature -K, k_(K,n)(t,r) = K^(n/2) k_n(Kt,sqrt(K)r).
21
+ Quadrature is approximate: the tail budget controls Gaussian decay at
22
+ the endpoint, not a uniform bound on the full integral's relative error.
23
+
24
+ References:
25
+ Grigor'yan, A., Noguchi, M. (1998). The heat kernel on hyperbolic
26
+ space. Bull. London Math. Soc. 30(6):643-650, equations (1.4)-(1.8).
27
+ Davies, E. B., Mandouvalos, N. (1988). Heat kernel bounds on
28
+ hyperbolic space and Kleinian groups. Proc. London Math. Soc.
29
+ 57(1):182-208.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import math
35
+ from collections.abc import Callable
36
+ from fractions import Fraction
37
+ from functools import cache
38
+ from typing import Protocol
39
+
40
+ import torch
41
+
42
+ from holonomy_lib._quadrature import gauss_legendre_nodes
43
+
44
+ # Dimensions with dedicated closed-form or quadrature routines. Above
45
+ # these we fall back to the Grigor'yan–Noguchi recursion. The values are
46
+ # fixed by the geometry, not tuned: 3 is the lowest dimension with a
47
+ # Davies–Mandouvalos closed form, and 2 is the lowest that needs the
48
+ # integral representation. `notes/magic_numbers.md` records them.
49
+ _N_GRIGORYAN_INTEGRAL: int = 2
50
+ _N_DAVIES_MANDOUVALOS: int = 3
51
+
52
+ # n = 5 has a polynomial closed form, obtained by applying the operator
53
+ # chain (1/sinh r · ∂_r)² analytically. Using it directly skips the
54
+ # autograd recursion, which adds float noise through two
55
+ # `torch.autograd.grad` calls.
56
+ _N_DAVIES_MANDOUVALOS_N5_CLOSED: int = 5
57
+
58
+ # n = 7 has a polynomial closed form, one operator-chain step beyond n = 5
59
+ # (derived and checked symbolically in notes/verification/heat_kernel_n7_sympy.py).
60
+ # It is the same argument as n = 5, and it seeds the odd-n recursion at n >= 9.
61
+ _N_OPERATOR_CHAIN_N7_CLOSED: int = 7
62
+
63
+ # Default quadrature resolution, checked by node refinement and independent
64
+ # normalized mpmath integrals. Increase n_quad for unusually narrow or broad
65
+ # time/radius regimes. Cataloged in notes/magic_numbers.md.
66
+ HEAT_KERNEL_QUADRATURE_NODES: int = 32
67
+
68
+ # This is an exponent budget: S=sqrt(4*budget*t) gives S^2/(4t)=budget.
69
+ # The endpoint of the H^2 substitution s=r+u^2 is r+S. The smooth
70
+ # descent substitution uses sqrt(r^2+S^2). Neither endpoint promises
71
+ # a uniform relative tail bound after the geometric factors are included.
72
+ HEAT_KERNEL_QUADRATURE_TAIL_BUDGET: float = 20.0
73
+
74
+
75
+ # The 4 in the Gaussian normalization (4 pi t)^(-n/2) and in the exponent
76
+ # d^2 / (4t). It is a geometric constant, not a fitted scale.
77
+ _GAUSSIAN_TIME_SCALE = 2 * 2
78
+
79
+ # Sixteen powers of r^2 retain terms through r^30. The switching radius
80
+ # balances the omitted r^32 term against eps / r^(n-3) cancellation in
81
+ # the odd-dimensional quotient. Checked against 60-digit operator chains.
82
+ _RADIAL_SERIES_TERMS = 16
83
+
84
+
85
+ class _HyperbolicManifold(Protocol):
86
+ n: int
87
+ k: float
88
+
89
+
90
+ @cache
91
+ def _inverse_sinhc_coefficients(order: int) -> tuple[Fraction, ...]:
92
+ """Taylor coefficients of sqrt(q)/sinh(sqrt(q)), through q^order."""
93
+ # Invert sum(q^j / (2j+1)!) as a formal power series. Fractions keep
94
+ # these geometry-derived coefficients exact until tensor evaluation.
95
+ coefficients = [Fraction(1)]
96
+ for degree in range(1, order + 1):
97
+ coefficients.append(
98
+ -sum(
99
+ (
100
+ coefficients[degree - j] / math.factorial(2 * j + 1)
101
+ for j in range(1, degree + 1)
102
+ ),
103
+ start=Fraction(0),
104
+ )
105
+ )
106
+ return tuple(coefficients)
107
+
108
+
109
+ def _odd_radial_series(n: int, t: torch.Tensor, q: torch.Tensor) -> torch.Tensor:
110
+ """Polynomial factor in k_n=(4 pi t)^(-n/2) exp(-rho^2 t-q/4t) P_n.
111
+
112
+ The Grigor'yan-Noguchi (1998), equation (1.8), recurrence gives
113
+ P_(n+2) = (r/sinh r) (P_n - 4t dP_n/dq), q=r^2, P_1=1.
114
+ Each derivative consumes a coefficient, so begin with enough terms
115
+ for every dimension-raising step. Loops are over series coefficients
116
+ and dimensions, never over tensor elements.
117
+ """
118
+ steps = (n - 1) // 2
119
+ order = _RADIAL_SERIES_TERMS + steps
120
+ inverse_sinhc = _inverse_sinhc_coefficients(order)
121
+ coefficients = [torch.ones_like(t)] + [torch.zeros_like(t)] * order
122
+ for _ in range(steps):
123
+ differentiated = [
124
+ coefficients[j] - _GAUSSIAN_TIME_SCALE * t * (j + 1) * coefficients[j + 1]
125
+ for j in range(len(coefficients) - 1)
126
+ ]
127
+ coefficients = [
128
+ sum(
129
+ (float(inverse_sinhc[j - k]) * differentiated[k] for k in range(j + 1)),
130
+ start=torch.zeros_like(t),
131
+ )
132
+ for j in range(len(differentiated))
133
+ ]
134
+ result = torch.zeros_like(t + q)
135
+ for coefficient in reversed(coefficients[:_RADIAL_SERIES_TERMS]):
136
+ result = result * q + coefficient
137
+ return result
138
+
139
+
140
+ def _small_radius(n: int, d: torch.Tensor) -> torch.Tensor:
141
+ """Balance series truncation r^(2L) with quotient cancellation eps/r^(n-3)."""
142
+ radius = torch.finfo(d.dtype).eps ** (
143
+ 1 / (2 * _RADIAL_SERIES_TERMS + n - _N_DAVIES_MANDOUVALOS)
144
+ )
145
+ return d.abs() <= radius
146
+
147
+
148
+ def _heat_kernel_unit_n1(t: torch.Tensor, d: torch.Tensor) -> torch.Tensor:
149
+ """Heat kernel on H^1, which is R: the standard Gaussian.
150
+
151
+ k^1_t(d) = (4πt)^{-1/2} · exp(-d²/4t)
152
+
153
+ Boundary case for the recursion (H^1 is Euclidean; curvature 0 is
154
+ a degenerate limit of sectional curvature < 0). Included so the
155
+ recursion has a clean odd-n seed.
156
+ """
157
+ return (_GAUSSIAN_TIME_SCALE * math.pi * t) ** -0.5 * torch.exp(
158
+ -d * d / (2.0 * 2.0 * t)
159
+ )
160
+
161
+
162
+ def _csch_and_coth(r: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
163
+ """Hyperbolic reciprocals for positive r without sinh/cosh overflow."""
164
+ # Everything is written in e^(-r), so large r does not overflow. expm1
165
+ # keeps 1 - e^(-2r) accurate for small r.
166
+ exp_minus_r = torch.exp(-r)
167
+ denominator = -torch.expm1(-2 * r)
168
+ return 2 * exp_minus_r / denominator, (1 + exp_minus_r.square()) / denominator
169
+
170
+
171
+ def _odd_closed_form(
172
+ n: int,
173
+ t: torch.Tensor,
174
+ d: torch.Tensor,
175
+ spectral_decay: float | None,
176
+ ) -> torch.Tensor:
177
+ """Evaluate the H^3/H^5/H^7 operator-chain polynomials.
178
+
179
+ Grigor'yan-Noguchi (1998), equation (1.4), gives the coefficients.
180
+ Factoring out powers of csch(r) avoids overflowing hyperbolic powers;
181
+ the small-radius series avoids cancellation in the remaining bracket.
182
+ A spectral decay override goes into the same exponential as the Gaussian
183
+ term, so the two are evaluated together.
184
+ """
185
+ small = _small_radius(n, d)
186
+ # The closed form is 0/0 near r = 0, and torch.where still evaluates it.
187
+ # Those lanes get r = 1 so the discarded branch stays finite. The series
188
+ # below replaces them.
189
+ r = torch.where(small, torch.ones_like(d), d)
190
+ csch, coth = _csch_and_coth(r)
191
+ if n == _N_DAVIES_MANDOUVALOS:
192
+ ratio = r * csch
193
+ elif n == _N_DAVIES_MANDOUVALOS_N5_CLOSED:
194
+ ratio = csch.square() * (r.square() + 2 * t * (r * coth - 1))
195
+ else:
196
+ # n = 7 bracket: the operator-chain step after n = 5, derived from (1.4).
197
+ three = _N_DAVIES_MANDOUVALOS
198
+ six = 2 * three
199
+ eight = 2 * _GAUSSIAN_TIME_SCALE
200
+ twelve = _GAUSSIAN_TIME_SCALE * three
201
+ ratio = csch**three * (
202
+ r**three
203
+ + six * r.square() * t * coth
204
+ + (eight * t.square() - six * t) * r
205
+ + twelve * t.square() * (r * csch.square() - coth)
206
+ )
207
+ # The series is in q = r^2. Outside the small band q is zero, and the
208
+ # series output there is discarded.
209
+ q = torch.where(small, d.square(), torch.zeros_like(d))
210
+ ratio = torch.where(small, _odd_radial_series(n, t, q), ratio)
211
+ # Default decay rho^2 with rho = (n-1)/2 is the bottom of the L^2 spectrum of H^n.
212
+ decay = ((n - 1) / 2) ** 2 if spectral_decay is None else spectral_decay
213
+ return (
214
+ (_GAUSSIAN_TIME_SCALE * math.pi * t) ** (-n / 2)
215
+ * torch.exp(
216
+ -decay * t - d.square() / (_GAUSSIAN_TIME_SCALE * t),
217
+ )
218
+ * ratio
219
+ )
220
+
221
+
222
+ def _heat_kernel_unit_n3(
223
+ t: torch.Tensor,
224
+ d: torch.Tensor,
225
+ spectral_decay: float | None = None,
226
+ ) -> torch.Tensor:
227
+ """H^3: (4 pi t)^(-3/2) exp(-t-r^2/4t) r/sinh(r)."""
228
+ return _odd_closed_form(_N_DAVIES_MANDOUVALOS, t, d, spectral_decay)
229
+
230
+
231
+ def _heat_kernel_unit_n5(
232
+ t: torch.Tensor,
233
+ d: torch.Tensor,
234
+ spectral_decay: float | None = None,
235
+ ) -> torch.Tensor:
236
+ """H^5 closed form; its radial polynomial tends to 1+2t/3 at zero."""
237
+ return _odd_closed_form(_N_DAVIES_MANDOUVALOS_N5_CLOSED, t, d, spectral_decay)
238
+
239
+
240
+ def _heat_kernel_unit_n7(
241
+ t: torch.Tensor,
242
+ d: torch.Tensor,
243
+ spectral_decay: float | None = None,
244
+ ) -> torch.Tensor:
245
+ """H^7 closed form; its radial polynomial tends to 1+2t+16t^2/15."""
246
+ return _odd_closed_form(_N_OPERATOR_CHAIN_N7_CLOSED, t, d, spectral_decay)
247
+
248
+
249
+ def _heat_kernel_unit_n2(
250
+ t: torch.Tensor,
251
+ d: torch.Tensor,
252
+ n_quad: int = HEAT_KERNEL_QUADRATURE_NODES,
253
+ tail_budget: float = HEAT_KERNEL_QUADRATURE_TAIL_BUDGET,
254
+ ) -> torch.Tensor:
255
+ """H^2 via Gauss-Legendre quadrature of equation (1.7).
256
+
257
+ In s=r+u^2 coordinates, the upper u endpoint is (4*budget*t)^(1/4).
258
+ Factor cosh(r+u^2)-cosh(r) as 2 sinh(r+u^2/2) sinh(u^2/2) to
259
+ avoid cancellation. Close to zero, use the smooth descent integral
260
+ so differentiating the quadrature does not create a spurious cusp.
261
+ """
262
+ original_d = d
263
+ small = _small_radius(_N_DAVIES_MANDOUVALOS, d)
264
+ # The square-root endpoint substitution has a nonuniform radial
265
+ # derivative at zero. Use the smooth descent integral there instead.
266
+ d = torch.where(small, torch.ones_like(d), d)
267
+ # Gauss–Legendre nodes and weights come from the library-wide cache
268
+ # (`_quadrature.gauss_legendre_nodes`), computed once per n_quad and cast
269
+ # here. Nodes lie in (-1, 1). Since t may be batched, each element is
270
+ # rescaled to u in (0, sqrt_S), and s = r + u^2 then runs up to
271
+ # r + sqrt(4 * tail_budget * t).
272
+ nodes, weights = gauss_legendre_nodes(
273
+ n_quad,
274
+ dtype=d.dtype,
275
+ device=d.device,
276
+ )
277
+
278
+ # Broadcast t and d to a common shape and add a quadrature-node axis.
279
+ t_b, d_b = torch.broadcast_tensors(t, d)
280
+ sqrt_S = (_GAUSSIAN_TIME_SCALE * tail_budget * t_b).sqrt().sqrt() # (...,)
281
+ # Affine map from nodes in (-1, 1) onto u in (0, sqrt_S):
282
+ # u = (sqrt_S / 2) · (node + 1)
283
+ # du = (sqrt_S / 2) · d(node)
284
+ u = 0.5 * sqrt_S.unsqueeze(-1) * (nodes + 1.0) # (..., n_quad)
285
+ jacobian = 0.5 * sqrt_S.unsqueeze(-1) # (..., n_quad)
286
+
287
+ # s = d + u²
288
+ s = d_b.unsqueeze(-1) + u * u
289
+ # f(s) = s · exp(-s²/4t)
290
+ f_s = s * torch.exp(-s * s / (2.0 * 2.0 * t_b.unsqueeze(-1))) # 4t
291
+ # Denominator: √(cosh s − cosh d). At u = 0 (i.e. s = d) this is
292
+ # zero; the change of variable absorbs the singularity in the
293
+ # `2u` from the Jacobian. After the substitution the *transformed*
294
+ # integrand `f(d+u²) · 2u / √(cosh(d+u²) − cosh d)` is bounded.
295
+ half_u_squared = u.square() / 2
296
+ sqrt_diff = (
297
+ math.sqrt(2)
298
+ * torch.sinh(d_b.unsqueeze(-1) + half_u_squared).sqrt()
299
+ * torch.sinh(half_u_squared).sqrt()
300
+ )
301
+ # Transformed integrand: f(d+u²) · 2u / √(cosh(d+u²) − cosh d)
302
+ # (the `2u du` from `ds = 2u du`)
303
+ integrand = f_s * 2.0 * u / sqrt_diff
304
+ # Numerical integration: Σ weights · jacobian · integrand
305
+ integral = (weights * jacobian * integrand).sum(dim=-1)
306
+
307
+ # Prefactor of equation (1.7): sqrt(2) exp(-t/4) (4 pi t)^(-3/2).
308
+ prefactor = math.sqrt(2.0) * torch.exp(-t_b / (2.0 * 2.0))
309
+ prefactor = prefactor / (_GAUSSIAN_TIME_SCALE * math.pi * t_b) ** (
310
+ _N_DAVIES_MANDOUVALOS / 2
311
+ )
312
+ regular = prefactor * integral
313
+ # The descent branch needs real radii only where small is true. Elsewhere it
314
+ # gets zero, which keeps it finite. The where below discards its output there.
315
+ small_d = torch.where(small, original_d, torch.zeros_like(original_d))
316
+ smooth = _even_descent(_N_GRIGORYAN_INTEGRAL, t, small_d, n_quad, tail_budget)
317
+ return torch.where(small, smooth, regular)
318
+
319
+
320
+ def _even_descent(
321
+ n: int,
322
+ t: torch.Tensor,
323
+ d: torch.Tensor,
324
+ n_quad: int,
325
+ tail_budget: float,
326
+ ) -> torch.Tensor:
327
+ """Smooth radial evaluation from the next odd-dimensional kernel.
328
+
329
+ Grigor'yan-Noguchi (1998), equations (1.6)-(1.8), imply
330
+ k_n(r) = sqrt(2) exp((2n-1)t/4) integral_r^infinity
331
+ k_(n+1)(s) sinh(s) / sqrt(cosh(s)-cosh(r)) ds.
332
+ The Abel integral in x=cosh(r) commutes with d/dx, so its H^2/H^3
333
+ identity extends to each even/odd pair by dimension raising.
334
+
335
+ Substitute s=sqrt(r^2+u^2). Factoring the cosh difference gives
336
+ the weight below, which is smooth in r^2 even at r=0. The odd
337
+ kernel already has a small-radius series. Quadrature nodes avoid
338
+ u=0, so all three sinhc ratios have strictly positive arguments.
339
+ """
340
+ nodes, weights = gauss_legendre_nodes(n_quad, dtype=d.dtype, device=d.device)
341
+ t_b, d_b = torch.broadcast_tensors(t, d)
342
+ upper = (_GAUSSIAN_TIME_SCALE * tail_budget * t_b).sqrt().unsqueeze(-1)
343
+ u = upper * (nodes + 1) / 2
344
+ r = d_b.unsqueeze(-1)
345
+ s = (r.square() + u.square()).sqrt()
346
+ plus = (s + r) / 2
347
+ # s-r = u^2/(s+r) avoids subtraction when r dominates u.
348
+ minus = u.square() / (2 * (s + r))
349
+
350
+ # log(sinh(x)/x) = x + log(1 - e^(-2x)) - log(2x). expm1 keeps the middle
351
+ # term accurate for small x, and nothing overflows for large x.
352
+ def log_sinhc(x: torch.Tensor) -> torch.Tensor:
353
+ return x + torch.log(-torch.expm1(-2 * x)) - torch.log(2 * x)
354
+
355
+ # This equals sinh(s)/s · u / (2 sqrt(sinh(plus) sinh(minus))), because
356
+ # plus · minus = u^2 / 4.
357
+ weight = torch.exp(log_sinhc(s) - (log_sinhc(plus) + log_sinhc(minus)) / 2)
358
+ # The Abel prefactor exp((2n-1)t/4) and the odd kernel's exp(-n^2 t/4)
359
+ # combine into one exponential with decay (n-1)^2/4. Two separate ones
360
+ # could underflow where the even kernel is still representable.
361
+ odd = _heat_kernel_unit(
362
+ n + 1,
363
+ t_b.unsqueeze(-1),
364
+ s,
365
+ n_quad,
366
+ tail_budget,
367
+ spectral_decay=((n - 1) / 2) ** 2,
368
+ )
369
+ return (weights * upper * odd * weight).sum(dim=-1)
370
+
371
+
372
+ def _apply_one_recursion(
373
+ prev_kernel_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
374
+ n_prev: int,
375
+ t: torch.Tensor,
376
+ d: torch.Tensor,
377
+ *,
378
+ apply_spectral_shift: bool = True,
379
+ ) -> torch.Tensor:
380
+ """Apply one step of the Grigor'yan recursion.
381
+
382
+ The recursion, derived from the operator chain on the
383
+ spectrally-shifted Gaussian (checked symbolically in
384
+ `notes/verification/heat_kernel_recursion_sympy.py`), is
385
+
386
+ k^{n_prev + 2}(t, r) = -exp(-n_prev · t) / (2π · sinh r)
387
+ · ∂_r k^{n_prev}(t, r).
388
+
389
+ The `exp(-n_prev · t)` factor is required. Heat kernels on
390
+ `H^{n}` carry a spectral-bottom factor `exp(-((n-1)/2)²·t)`; going
391
+ from `n_prev` to `n_prev+2` shifts the spectral bottom by
392
+ `((n_prev+1)/2)² − ((n_prev-1)/2)² = n_prev`, so we multiply by
393
+ `exp(-n_prev · t)`. The same sympy script checks both cases: with
394
+ the factor the result satisfies the H^5 radial heat equation, and
395
+ without it, it does not.
396
+
397
+ Args:
398
+ prev_kernel_fn: function `(t, d) -> k^{n_prev}(t, d)`.
399
+ n_prev: the dimension `n_prev` of `prev_kernel_fn`'s output.
400
+ t, d: time and radial distance tensors.
401
+ apply_spectral_shift: false when the seed already includes the
402
+ final dimension's spectral exponential, so only radial raising remains.
403
+
404
+ Returns:
405
+ `k^{n_prev + 2}(t, d)`, heat kernel two dimensions higher.
406
+ """
407
+ # Read the caller's grad mode before enable_grad() turns it on.
408
+ keep_graph = torch.is_grad_enabled() and (t.requires_grad or d.requires_grad)
409
+ with torch.enable_grad():
410
+ # Differentiate each broadcast pair independently. Differentiating the
411
+ # original scalar d instead would sum derivatives across the time batch.
412
+ t_b, d_b = torch.broadcast_tensors(t, d)
413
+ # A fresh leaf when the caller gave no grad-tracked d. A d already in a
414
+ # graph is kept, so the outer graph survives.
415
+ if not d_b.requires_grad:
416
+ d_b = d_b.detach().requires_grad_(True)
417
+ kn = prev_kernel_fn(t_b, d_b)
418
+ # kn.sum() is safe because each element depends only on its own d, so
419
+ # the gradient is elementwise.
420
+ (dk_dd,) = torch.autograd.grad(kn.sum(), d_b, create_graph=True)
421
+ # At d = 0 the csch factor is singular. Those lanes take a finite
422
+ # placeholder and then get the limit below.
423
+ nonzero = d_b != 0
424
+ safe_d = torch.where(nonzero, d_b, torch.ones_like(d_b))
425
+ csch, _ = _csch_and_coth(safe_d)
426
+ radial_derivative = dk_dd * csch
427
+ # At d = 0, k'(d)/sinh(d) tends to k''(0) by L'Hopital's rule.
428
+ (d2k_dd2,) = torch.autograd.grad(dk_dd.sum(), d_b, create_graph=True)
429
+ radial_derivative = torch.where(nonzero, radial_derivative, d2k_dd2)
430
+ shift = torch.exp(-n_prev * t_b) if apply_spectral_shift else 1
431
+ result = -shift * radial_derivative / (2 * math.pi)
432
+ # Detach unless the inputs needed a graph, so the recursion does not keep
433
+ # graphs nobody asked for.
434
+ return result if keep_graph else result.detach()
435
+
436
+
437
+ def _heat_kernel_unit(
438
+ n: int,
439
+ t: torch.Tensor,
440
+ d: torch.Tensor,
441
+ n_quad: int,
442
+ tail_budget: float,
443
+ spectral_decay: float | None = None,
444
+ ) -> torch.Tensor:
445
+ """Dispatch closed forms, quadrature, and dimension-raising recurrence.
446
+
447
+ H^5/H^7 and all higher odd dimensions use the even-in-r series
448
+ near zero. Even dimensions use dimension descent there because
449
+ repeated radial differentiation amplifies the quadrature error.
450
+ """
451
+ decay = ((n - 1) / 2) ** 2 if spectral_decay is None else spectral_decay
452
+ if n == 1:
453
+ return _heat_kernel_unit_n1(t, d)
454
+ if n == _N_GRIGORYAN_INTEGRAL:
455
+ return _heat_kernel_unit_n2(t, d, n_quad, tail_budget)
456
+ if n == _N_DAVIES_MANDOUVALOS:
457
+ return _heat_kernel_unit_n3(t, d, decay)
458
+ if n == _N_DAVIES_MANDOUVALOS_N5_CLOSED:
459
+ return _heat_kernel_unit_n5(t, d, decay)
460
+ if n == _N_OPERATOR_CHAIN_N7_CLOSED:
461
+ return _heat_kernel_unit_n7(t, d, decay)
462
+
463
+ # Equation (1.8) applies to both parities. Closure construction is
464
+ # sequential in dimension; each evaluation stays fully batched.
465
+ # The recursion starts from the n = 7 closed form (odd n) or the n = 2
466
+ # integral (even n), then raises the dimension by two until it reaches n.
467
+ current_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor]
468
+ if n % 2 == 1:
469
+ current_n = _N_OPERATOR_CHAIN_N7_CLOSED
470
+
471
+ def odd_seed(_t: torch.Tensor, _d: torch.Tensor) -> torch.Tensor:
472
+ return _heat_kernel_unit_n7(_t, _d, decay)
473
+
474
+ current_fn = odd_seed
475
+ else:
476
+ current_n = _N_GRIGORYAN_INTEGRAL
477
+
478
+ def integral_seed(_t: torch.Tensor, _d: torch.Tensor) -> torch.Tensor:
479
+ return _heat_kernel_unit_n2(_t, _d, n_quad, tail_budget)
480
+
481
+ current_fn = integral_seed
482
+
483
+ def raise_dimension(
484
+ previous: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
485
+ dimension: int,
486
+ ) -> Callable[[torch.Tensor, torch.Tensor], torch.Tensor]:
487
+ def raised(_t: torch.Tensor, _d: torch.Tensor) -> torch.Tensor:
488
+ return _apply_one_recursion(
489
+ previous,
490
+ dimension,
491
+ _t,
492
+ _d,
493
+ # Odd targets: the seed already carries the final decay, so each
494
+ # step is radial only. Even targets: each step adds exp(-n_prev t).
495
+ apply_spectral_shift=(n % 2 == 0),
496
+ )
497
+
498
+ return raised
499
+
500
+ while current_n < n:
501
+ current_fn = raise_dimension(current_fn, current_n)
502
+ current_n += 2
503
+
504
+ # Even n: the recursion divides by sinh near zero, so the small band uses
505
+ # the smooth descent integral and the rest uses the recursion.
506
+ if n % 2 == 0:
507
+ small = _small_radius(n, d)
508
+ safe_d = torch.where(small, torch.ones_like(d), d)
509
+ regular = current_fn(t, safe_d)
510
+ small_d = torch.where(small, d, torch.zeros_like(d))
511
+ smooth = _even_descent(n, t, small_d, n_quad, tail_budget)
512
+ return torch.where(small, smooth, regular)
513
+ small = _small_radius(n, d)
514
+ safe_d = torch.where(small, torch.ones_like(d), d)
515
+ regular = current_fn(t, safe_d)
516
+ # Odd n: the small band uses the even-in-r series, which is smooth at zero.
517
+ q = torch.where(small, d.square(), torch.zeros_like(d))
518
+ series = (
519
+ (_GAUSSIAN_TIME_SCALE * math.pi * t) ** (-n / 2)
520
+ * torch.exp(
521
+ -decay * t - q / (_GAUSSIAN_TIME_SCALE * t),
522
+ )
523
+ * _odd_radial_series(n, t, q)
524
+ )
525
+ return torch.where(small, series, regular)
526
+
527
+
528
+ def hyperbolic_heat_kernel(
529
+ t: torch.Tensor,
530
+ distances: torch.Tensor,
531
+ manifold: _HyperbolicManifold,
532
+ n_quad: int = HEAT_KERNEL_QUADRATURE_NODES,
533
+ tail_budget: float = HEAT_KERNEL_QUADRATURE_TAIL_BUDGET,
534
+ ) -> torch.Tensor:
535
+ """Heat kernel `k^n_t(d)` on the hyperbolic manifold `manifold`.
536
+
537
+ Computes the probability density of a Brownian-motion particle at
538
+ geodesic distance `d` after time `t`, starting from a delta source
539
+ at the manifold origin.
540
+
541
+ The kernel depends only on `t`, the geodesic distance `d`, and the
542
+ intrinsic dimension `n` of the manifold (rotational symmetry
543
+ around the source). Curvature scales out: for curvature `k = -|k|`,
544
+
545
+ k^n_{−|k|, t}(d) = |k|^{n/2} · k^n_{−1, |k|·t}(√|k| · d).
546
+
547
+ Args:
548
+ t: positive time(s). Broadcastable with `distances`. Typically
549
+ a scalar, but `(B,)` or matching `distances.shape` are also
550
+ supported.
551
+ distances: non-negative geodesic distance(s). Any shape, must
552
+ be broadcastable with `t`.
553
+ manifold: a manifold object exposing `.n` (intrinsic dim) and
554
+ `.k` (sectional curvature, must be `k < 0`; ValueError otherwise).
555
+ n_quad: number of Gauss–Legendre nodes for the quadrature: the n=2
556
+ integral and the even-dimension descent. Default 32; cataloged as
557
+ `HEAT_KERNEL_QUADRATURE_NODES`.
558
+ tail_budget: integration-upper-bound budget factor (truncate at
559
+ `d + sqrt(4 * tail_budget * t)`). Default 20. Cataloged.
560
+ The small-radius descent integral ends at
561
+ `sqrt(d**2 + 4 * tail_budget * t)` instead.
562
+
563
+ Returns:
564
+ Tensor of heat-kernel values, same shape as
565
+ `broadcast(t, distances)`.
566
+
567
+ Dimension support:
568
+ n=1 is Gaussian; n=2 uses quadrature; n=3,5,7 have closed forms.
569
+ Remaining dimensions use the spectral-shifted recurrence. Near
570
+ zero, odd dimensions use a series and even dimensions use a smooth
571
+ descent integral. Gradients flow through `t` and `distances`. Under
572
+ `torch.no_grad()` the same values come back with no graph kept.
573
+ Quadrature accuracy depends on n_quad and tail_budget; very high
574
+ dimensions, extreme times and large radii can still exceed
575
+ floating-point range.
576
+
577
+ References:
578
+ Davies–Mandouvalos (1988); Grigor'yan (2009), Theorem 8.21;
579
+ Grigor'yan–Noguchi (1998); Anker–Ostellari (2003).
580
+ """
581
+ n = manifold.n
582
+ # The closed forms are for hyperbolic space only. Taking abs(k) would
583
+ # quietly turn a sphere (k > 0) into a hyperbolic answer, and k = 0 gives
584
+ # t_unit = 0, so both are rejected up front.
585
+ if not manifold.k < 0:
586
+ raise ValueError(
587
+ f"hyperbolic_heat_kernel needs curvature k < 0; got k={manifold.k}"
588
+ )
589
+ abs_k = abs(manifold.k)
590
+
591
+ # Curvature scaling: convert to unit-curvature arguments, evaluate,
592
+ # then rescale the output by |k|^{n/2}. k < 0 was checked above, so abs(k)
593
+ # is just -k.
594
+ t_unit = abs_k * t
595
+ sqrt_abs_k = math.sqrt(abs_k)
596
+ d_unit = sqrt_abs_k * distances
597
+
598
+ # A Python float t makes t_unit a float. Turn it into a tensor with the
599
+ # distances' dtype and device before the kernel runs.
600
+ if not isinstance(t_unit, torch.Tensor):
601
+ t_unit = torch.as_tensor(
602
+ t_unit,
603
+ dtype=distances.dtype,
604
+ device=distances.device,
605
+ )
606
+ k_unit = _heat_kernel_unit(n, t_unit, d_unit, n_quad, tail_budget)
607
+ scale = abs_k ** (n * 0.5)
608
+ return scale * k_unit
@@ -0,0 +1,81 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Manifold-aware inner product, cosine-analog on a Riemannian manifold.
5
+
6
+ For points x, y on a Riemannian manifold M, the natural "inner
7
+ product" is not the ambient dot product (which ignores the geometry)
8
+ but the Riemannian inner product of their tangent representations at
9
+ a common base point. This module always uses the origin as that base
10
+ point and pulls each point back to the tangent space there with `log`:
11
+
12
+ ⟨x, y⟩_M := ⟨log_o(x), log_o(y)⟩_o,
13
+
14
+ where `log_o(·)` is the inverse exponential at the origin and the
15
+ right-hand inner product is the Riemannian metric `g_o` on `T_o M`.
16
+ The quantity is symmetric, bilinear in its tangent representatives,
17
+ and reduces to the standard Euclidean inner when M is Euclidean.
18
+
19
+ Manifold-agnostic: this works on any manifold that exposes `log(x, y)`,
20
+ `inner(x, u, v)`, and `origin(batch_size)`. Two cases worth checking by hand.
21
+ On `LorentzManifold` the origin is the north pole, whose tangent vectors
22
+ have zero time coordinate, so the Lorentz inner there is the Euclidean
23
+ inner of the spatial coordinates. On `KappaStereographicManifold` the
24
+ conformal factor at the origin is `λ_κ(o) = 2`, so the metric there is
25
+ 4 times the Euclidean one. In both cases `manifold_aware_inner(x, x)`
26
+ equals `d(o, x)²`, the same value in either model.
27
+
28
+ References:
29
+ Pennec, X. (2006). Intrinsic statistics on Riemannian manifolds.
30
+ J. Math. Imaging Vision 25(1):127–154 (§3 tangent statistics).
31
+ Said, S., Bombrun, L., Berthoumieu, Y. (2015). Riemannian inner
32
+ products in covariance estimation on the SPD manifold. SIIMS.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import Any
38
+
39
+ import torch
40
+
41
+
42
+ def manifold_aware_inner(
43
+ x: torch.Tensor,
44
+ y: torch.Tensor,
45
+ manifold: Any,
46
+ ) -> torch.Tensor:
47
+ """Riemannian inner product `⟨log_o(x), log_o(y)⟩_o` at the
48
+ manifold origin.
49
+
50
+ Works on any manifold exposing `log(x, y)`, `inner(x, u, v)`, and
51
+ `origin(batch_size)`. With `x = y` the result is `d_M(o, x)²`, as long
52
+ as `inner` is the Riemannian metric and `log` inverts `exp`.
53
+ Assumes `x` and `y` have the same batch size B, since the origin is
54
+ built with `x`'s batch size.
55
+
56
+ Args:
57
+ x, y: points on the manifold, shape `(B, ambient_dim)`.
58
+ manifold: a manifold object exposing `log`, `inner`, `origin`.
59
+
60
+ Returns:
61
+ `(B,)` tensor of Riemannian inner products.
62
+
63
+ Example:
64
+ >>> from holonomy_lib.manifolds import LorentzManifold
65
+ >>> mfd = LorentzManifold(n=3)
66
+ >>> x = mfd.random_point(batch_size=4)
67
+ >>> sim = manifold_aware_inner(x, x, mfd) # = d(o, x)² per batch
68
+ >>> sim.shape
69
+ torch.Size([4])
70
+
71
+ References:
72
+ Pennec (2006), §3, tangent-space statistics on Riemannian manifolds.
73
+ """
74
+ B = x.shape[0]
75
+ # The manifold builds its origin on its own device and dtype. Move it onto
76
+ # x's, or a GPU or float64 input would meet a CPU or float32 base point.
77
+ origin = manifold.origin(batch_size=B).to(device=x.device, dtype=x.dtype)
78
+ log_x = manifold.log(origin, x) # (B, ambient_dim) tangent vectors at o
79
+ log_y = manifold.log(origin, y)
80
+ # The metric at the origin, applied to the two tangent vectors.
81
+ return manifold.inner(origin, log_x, log_y)