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,680 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """κ-stereographic projection model; constant curvature manifold
5
+ that interpolates spherical (κ > 0), Euclidean (κ = 0), and
6
+ hyperbolic (κ < 0) geometry via a single parameter.
7
+
8
+ The κ-stereographic model represents:
9
+
10
+ - **κ > 0** (spherical): the stereographic projection of the
11
+ `n`-sphere of radius `1/√κ`, excluding one pole, onto all of `R^n`.
12
+ - **κ = 0** (Euclidean): the standard `R^n`.
13
+ - **κ < 0** (hyperbolic): the Poincaré ball of radius `1/√|κ|`,
14
+ `{x ∈ R^n : ‖x‖² < 1/|κ|}`.
15
+
16
+ The models share curvature-dependent conformal metrics and exp/log maps.
17
+ The spherical formulas use a stereographic chart, so operations that hit
18
+ its omitted pole are undefined even though distance itself remains defined.
19
+
20
+ Coordinate convention: points live in `R^n` (no extra ambient
21
+ dimension, unlike the Lorentz model). The intrinsic and ambient
22
+ dimensions both equal `n`. Tangent vectors at any base point are
23
+ also `R^n`.
24
+
25
+ Core formulas (Bachmann–Bécigneul–Ganea 2020, §3):
26
+
27
+ λ_κ(x) = 2 / (1 + κ‖x‖²) (conformal factor)
28
+ ⟨u, v⟩_x = λ_κ(x)² · ⟨u, v⟩_Eucl (Riemannian metric)
29
+ x ⊕_κ y = ((1 - 2κ⟨x,y⟩ - κ‖y‖²) x + (1 + κ‖x‖²) y)
30
+ / (1 - 2κ⟨x,y⟩ + κ²‖x‖² ‖y‖²) (Möbius addition)
31
+ d_κ(x, y) = (2/√|κ|) · tan_κ⁻¹(√|κ| · ‖(-x) ⊕_κ y‖)
32
+ exp_x(v) = x ⊕_κ (tan_κ(λ_κ(x)·√|κ|·‖v‖/2)
33
+ · v / (√|κ|·‖v‖))
34
+ log_x(y) = (2/(λ_κ(x)·√|κ|)) · tan_κ⁻¹(√|κ|·‖(-x) ⊕_κ y‖)
35
+ · ((-x) ⊕_κ y) / ‖(-x) ⊕_κ y‖
36
+
37
+ where `tan_κ` is `tan` for κ > 0, `tanh` for κ < 0, identity for
38
+ κ = 0; correspondingly `tan_κ⁻¹` is `arctan` / `arctanh` / identity.
39
+
40
+ κ may be a Python float or a learnable scalar tensor. Tensor curvature
41
+ is evaluated on every call, including at and across zero.
42
+
43
+ References:
44
+ Bachmann, G., Bécigneul, G., Ganea, O.-E. (2020). Constant Curvature
45
+ Graph Convolutional Networks. ICML. (the κ-stereographic formulation)
46
+ Skopek, O., Ganea, O.-E., Bécigneul, G. (2019). Mixed-curvature
47
+ Variational Autoencoders. ICLR.
48
+ Ungar, A. A. (2008). A Gyrovector Space Approach to Hyperbolic
49
+ Geometry. (gyro-addition / κ-trig algebra)
50
+ Ganea, O.-E., Bécigneul, G., Hofmann, T. (2018). Hyperbolic Neural
51
+ Networks. NeurIPS. (Poincaré ball special case)
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import math
57
+
58
+ import torch
59
+
60
+ # Taylor coefficients of tan(sqrt(q))/sqrt(q) and atan(sqrt(q))/sqrt(q),
61
+ # used in place of the exact ratio when |q| is small. q = kappa * alpha² is
62
+ # signed, so one series covers both curvature signs.
63
+ KAPPA_SCALE_LINEAR = 1.0 / 3.0
64
+ KAPPA_TAN_QUADRATIC = 2.0 / 15.0
65
+ KAPPA_ATAN_QUADRATIC = 1.0 / 5.0
66
+ # Sampling cap for random_point, as a fraction of pi / sqrt(kappa). That is
67
+ # one quarter of the tan pole radius pi / (2 sqrt(kappa)), which keeps draws
68
+ # away from the chart's pole.
69
+ SPHERICAL_SAMPLE_RADIUS_FRACTION = 1.0 / 8.0
70
+ # asin(sqrt(q))/sqrt(q) = 1 + q/6 + 3q²/40 + O(q³). Used by
71
+ # _stereographic_distance when |q| is small, with q = kappa * chord² signed.
72
+ DISTANCE_SCALE_LINEAR = 1.0 / 6.0
73
+ DISTANCE_SCALE_QUADRATIC = 3.0 / 40.0
74
+
75
+
76
+ def _kappa_scale(
77
+ alpha: torch.Tensor, kappa: float | torch.Tensor, *, inverse: bool = False,
78
+ ) -> torch.Tensor:
79
+ """Return tan_κ(√|κ|·α) / (√|κ|·α), or the artan version when `inverse`.
80
+
81
+ Input: `alpha` (non-negative norms, any shape) and `kappa` (float or
82
+ scalar tensor). Output: same shape as `alpha`, equal to 1 at α = 0.
83
+ Both branches are computed and selected per element, so the sign of κ
84
+ is not assumed.
85
+
86
+ Near q = κα² = 0 the quadratic series replaces the exact ratio. Its
87
+ remainder is O(q³), so at |q| <= sqrt(eps) the value error is below
88
+ roundoff and the derivative error is O(eps).
89
+ """
90
+ q = kappa * alpha.square()
91
+ small = q.abs() <= torch.finfo(alpha.dtype).eps**0.5
92
+ series_q = torch.where(small, q, torch.zeros_like(q))
93
+ positive = (q > 0) & ~small
94
+ negative = (q < 0) & ~small
95
+ positive_arg = torch.sqrt(torch.where(positive, q, torch.ones_like(q)))
96
+ # Both branches run on every element, so each needs a finite stand-in in
97
+ # the lanes it does not use. atanh(1) is infinite, so its stand-in is 0.5;
98
+ # tanh and tan are finite at 1, so they use 1.
99
+ substitute = torch.full_like(q, 0.5) if inverse else torch.ones_like(q)
100
+ negative_arg = torch.sqrt(torch.where(negative, -q, substitute.square()))
101
+ if inverse:
102
+ series = 1.0 - KAPPA_SCALE_LINEAR * series_q
103
+ series = series + KAPPA_ATAN_QUADRATIC * series_q.square()
104
+ spherical = torch.atan(positive_arg) / positive_arg
105
+ hyperbolic = torch.atanh(negative_arg) / negative_arg
106
+ else:
107
+ series = 1.0 + KAPPA_SCALE_LINEAR * series_q
108
+ series = series + KAPPA_TAN_QUADRATIC * series_q.square()
109
+ spherical = torch.tan(positive_arg) / positive_arg
110
+ hyperbolic = torch.tanh(negative_arg) / negative_arg
111
+ return torch.where(small, series, torch.where(positive, spherical, hyperbolic))
112
+
113
+
114
+ def _project_ball(x: torch.Tensor, kappa: float | torch.Tensor) -> torch.Tensor:
115
+ """Pull hyperbolic points back inside their open ball.
116
+
117
+ Input: `x` of shape `(..., n)` and `kappa`. Output: `x` unchanged where
118
+ κ >= 0 or the point is already inside the ball, otherwise rescaled onto
119
+ the boundary radius. That radius is 1/√|κ| shrunk by the factor
120
+ (1 - n·eps), which covers rounding in the norm. A point pushed to this
121
+ radius is a precision limit: exp saturates there and log cannot recover
122
+ a larger tangent.
123
+ """
124
+ k = torch.as_tensor(kappa, dtype=x.dtype, device=x.device)
125
+ sqrt_negative = torch.sqrt(torch.where(k < 0, -k, torch.ones_like(k)))
126
+ norm = torch.linalg.vector_norm(x, dim=-1)
127
+ radius = (1.0 - x.shape[-1] * torch.finfo(x.dtype).eps) / sqrt_negative
128
+ # A zero vector never leaves the ball, but its rescale factor would divide
129
+ # by zero. Swap in 1 for the divisor; torch.where evaluates both branches.
130
+ safe_norm = torch.where(norm > 0, norm, torch.ones_like(norm))
131
+ scale = torch.where((k < 0) & (norm > radius), radius / safe_norm,
132
+ torch.ones_like(norm))
133
+ return x * scale.unsqueeze(-1)
134
+
135
+
136
+ def _stereographic_distance(
137
+ x: torch.Tensor, y: torch.Tensor, kappa: float | torch.Tensor,
138
+ ) -> torch.Tensor:
139
+ """Geodesic distance between x and y, with three branches.
140
+
141
+ The chord `‖x - y‖ / sqrt((1 + κ‖x‖²)(1 + κ‖y‖²))` feeds the series and
142
+ hyperbolic branches. Near κ·chord² = 0, including κ = 0, a signed series
143
+ is used, which keeps the result smooth in κ through zero. For κ > 0 the
144
+ points are lifted onto the sphere and the angle is taken with atan2, which
145
+ also works at antipodes where Mobius subtraction is undefined. For κ < 0
146
+ the asinh form is used, since an atanh argument can round to one near the
147
+ ball boundary.
148
+
149
+ References:
150
+ Bachmann et al. (2020), §2, stereographic metric; Cannon et al.
151
+ (1997), Hyperbolic Geometry, §3, hyperbolic distance models.
152
+ """
153
+ k = torch.as_tensor(kappa, dtype=x.dtype, device=x.device)
154
+ x_sq, y_sq = x.square().sum(-1), y.square().sum(-1)
155
+ separation = torch.linalg.vector_norm(x - y, dim=-1)
156
+ product = (1.0 + k * x_sq) * (1.0 + k * y_sq)
157
+ chord = separation / torch.sqrt(product)
158
+ q = k * chord.square()
159
+ small = q.abs() <= torch.finfo(x.dtype).eps**0.5
160
+ series_q = torch.where(small, q, torch.zeros_like(q))
161
+ scale = 1.0 + DISTANCE_SCALE_LINEAR * series_q
162
+ scale = scale + DISTANCE_SCALE_QUADRATIC * series_q.square()
163
+ series = 2.0 * chord * scale
164
+
165
+ spherical = (k > 0) & ~small
166
+ root = torch.sqrt(torch.where(spherical, k, torch.ones_like(k)))
167
+ # Mask inputs before lifting to the sphere so inactive denominators and
168
+ # transcendental branches cannot inject NaNs into backward.
169
+ sx = torch.where(spherical.unsqueeze(-1), root.unsqueeze(-1) * x, 0.0)
170
+ sy = torch.where(spherical.unsqueeze(-1), root.unsqueeze(-1) * y, 0.0)
171
+ sx_sq, sy_sq = sx.square().sum(-1, keepdim=True), sy.square().sum(-1, keepdim=True)
172
+ lifted_x = torch.cat((2.0 * sx, 1.0 - sx_sq), dim=-1) / (1.0 + sx_sq)
173
+ lifted_y = torch.cat((2.0 * sy, 1.0 - sy_sq), dim=-1) / (1.0 + sy_sq)
174
+ sphere_chord = torch.linalg.vector_norm(lifted_x - lifted_y, dim=-1)
175
+ sphere_sum = torch.linalg.vector_norm(lifted_x + lifted_y, dim=-1)
176
+ # The angle between two unit vectors is 2·atan2(|a - b|, |a + b|). This form
177
+ # stays accurate for tiny angles and near antipodes, where acos of a dot
178
+ # product would lose precision.
179
+ sphere_distance = 2.0 * torch.atan2(sphere_chord, sphere_sum) / root
180
+
181
+ hyperbolic = (k < 0) & ~small
182
+ # Same stand-in idea as on the sphere: inactive lanes get root 1 and zero
183
+ # chord, so the asinh term is finite there and the final where() discards it.
184
+ negative_root = torch.sqrt(torch.where(hyperbolic, -k, torch.ones_like(k)))
185
+ hyperbolic_chord = torch.where(hyperbolic, chord, torch.zeros_like(chord))
186
+ hyperbolic_distance = (
187
+ 2.0 * torch.asinh(negative_root * hyperbolic_chord) / negative_root
188
+ )
189
+ return torch.where(small, series, torch.where(spherical, sphere_distance,
190
+ hyperbolic_distance))
191
+
192
+
193
+ class KappaStereographicManifold:
194
+ """Constant-curvature κ-stereographic manifold.
195
+
196
+ Args:
197
+ n: intrinsic / ambient dimension. Points are stored as `(B, n)`.
198
+ kappa: sectional curvature, a Python float or learnable scalar tensor.
199
+ Tensor curvature is evaluated on every call, including across zero.
200
+ device, dtype: tensor placement and precision.
201
+
202
+ Example:
203
+ >>> mfd = KappaStereographicManifold(n=3, kappa=-1.0)
204
+ >>> x = mfd.random_point(batch_size=4)
205
+ >>> x.shape, mfd.is_on_manifold(x).all().item()
206
+ (torch.Size([4, 3]), True)
207
+ """
208
+
209
+ def __init__(self, n: int, kappa: float | torch.Tensor = -1.0,
210
+ device: str | torch.device = "cpu",
211
+ dtype: torch.dtype = torch.float64):
212
+ if n <= 0:
213
+ raise ValueError(f"n must be > 0, got n={n}")
214
+ # A tensor kappa is stored as is, not copied or detached, so a learnable
215
+ # curvature keeps its autograd graph.
216
+ if isinstance(kappa, torch.Tensor):
217
+ if kappa.ndim != 0 or not kappa.is_floating_point():
218
+ raise TypeError("kappa must be a floating-point scalar tensor")
219
+ self.kappa: float | torch.Tensor = kappa
220
+ elif isinstance(kappa, (int, float)):
221
+ self.kappa = float(kappa)
222
+ else:
223
+ raise TypeError("kappa must be a float or scalar tensor")
224
+ self.n = n
225
+ self.device = torch.device(device)
226
+ self.dtype = dtype
227
+
228
+ def _get_kappa(self) -> float | torch.Tensor:
229
+ """Return the current curvature without detaching its gradient."""
230
+ return self.kappa
231
+
232
+ @property
233
+ def dim(self) -> int:
234
+ """Intrinsic manifold dimension `n`. Same as ambient for this
235
+ model; no extra dimension (unlike `LorentzManifold(n)` which
236
+ embeds in `R^{n+1}`).
237
+ """
238
+ return self.n
239
+
240
+ @property
241
+ def ambient_dim(self) -> int:
242
+ """Ambient dimension `n`. Matches `.dim` for stereographic;
243
+ `LorentzManifold` overrides this to `n + 1`. Used by
244
+ manifold-generic primitives in `holonomy_lib.hyperbolic`.
245
+ """
246
+ return self.n
247
+
248
+
249
+ # ----------------------------------------------------------------
250
+ # Helpers: κ-trig functions, branch-dispatched
251
+ # ----------------------------------------------------------------
252
+
253
+ def _tan_kappa_c(self, alpha: torch.Tensor) -> torch.Tensor:
254
+ """Ratio tan_κ(√|κ|·α) / (√|κ|·α) at the current κ. Not tan_κ itself.
255
+
256
+ Input: `alpha` (non-negative norms, any shape). Output: same shape.
257
+ """
258
+ return _kappa_scale(alpha, self.kappa)
259
+
260
+ def _atan_kappa_c(self, alpha: torch.Tensor) -> torch.Tensor:
261
+ """Ratio tan_κ⁻¹(√|κ|·α) / (√|κ|·α) at the current κ. Not the artan itself.
262
+
263
+ Input: `alpha` (non-negative norms, any shape). Output: same shape.
264
+ """
265
+ return _kappa_scale(alpha, self.kappa, inverse=True)
266
+
267
+ def _conformal_factor(self, x: torch.Tensor) -> torch.Tensor:
268
+ """λ_κ(x) = 2 / (1 + κ‖x‖²). At origin = 2.
269
+
270
+ Uses `_get_kappa()` so the conformal factor's derivative w.r.t.
271
+ a learnable-κ tensor is captured; affects `inner`, `norm`,
272
+ `exp`, `log`, `parallel_transport`.
273
+ """
274
+ norm_sq = (x * x).sum(dim=-1)
275
+ return 2.0 / (1.0 + self._get_kappa() * norm_sq)
276
+
277
+ # ----------------------------------------------------------------
278
+ # Construction
279
+ # ----------------------------------------------------------------
280
+
281
+ def origin(self, batch_size: int = 1) -> torch.Tensor:
282
+ """The origin `(0, …, 0) ∈ R^n`; common base point for
283
+ tangent-space embeddings. Same shape as a regular point.
284
+
285
+ References:
286
+ Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
287
+ Convolutional Networks, §2, stereographic metric and maps.
288
+ """
289
+ if batch_size < 0:
290
+ raise ValueError(f"batch_size must be >= 0, got {batch_size}")
291
+ return torch.zeros(batch_size, self.n,
292
+ device=self.device, dtype=self.dtype)
293
+
294
+ def random_point(
295
+ self,
296
+ batch_size: int = 1,
297
+ generator: torch.Generator | None = None,
298
+ ) -> torch.Tensor:
299
+ """Draw Gaussian origin tangents and map them with the current κ.
300
+
301
+ Positive-curvature tangents are capped at π/(8√κ), away from
302
+ the stereographic chart's pole. Hyperbolic outputs saturate at
303
+ a dtype-dependent interior radius. Draws use the generator's
304
+ device before transfer to the manifold's device.
305
+
306
+ References:
307
+ Bachmann et al. (2020), §2, origin exponential.
308
+ """
309
+ if batch_size < 0:
310
+ raise ValueError(f"batch_size must be >= 0, got {batch_size}")
311
+ draw_device = generator.device if generator is not None else self.device
312
+ # Gaussian origin tangents with std 0.5. The scale is a sampling choice.
313
+ v = torch.randn(batch_size, self.n, generator=generator,
314
+ device=draw_device, dtype=self.dtype).to(self.device) * 0.5
315
+ k = torch.as_tensor(self.kappa, device=v.device, dtype=v.dtype)
316
+ # Only kappa > 0 is capped. Elsewhere sqrt_positive is 1 and the cap is
317
+ # masked off by the (k > 0) test below.
318
+ sqrt_positive = torch.sqrt(torch.where(k > 0, k, torch.ones_like(k)))
319
+ cap = SPHERICAL_SAMPLE_RADIUS_FRACTION * math.pi / sqrt_positive
320
+ norm = torch.linalg.vector_norm(v, dim=-1, keepdim=True)
321
+ safe_norm = torch.where(norm > 0, norm, torch.ones_like(norm))
322
+ v = v * torch.where((k > 0) & (norm > cap), cap / safe_norm,
323
+ torch.ones_like(norm))
324
+ return self.exp_0(v)
325
+
326
+ def is_on_manifold(
327
+ self, x: torch.Tensor, atol: float = 1e-9,
328
+ ) -> torch.Tensor:
329
+ """Check finite coordinates and the hyperbolic open-ball boundary.
330
+
331
+ For κ < 0, -κ||x||² < 1 - atol; for κ >= 0 every finite chart
332
+ coordinate is valid. The sphere's omitted pole is at infinity.
333
+
334
+ References:
335
+ Bachmann et al. (2020), §2, stereographic domain.
336
+ """
337
+ if x.ndim < 2 or x.shape[-1] != self.n:
338
+ raise ValueError(f"x must have shape (..., {self.n}); got {tuple(x.shape)}")
339
+ k = torch.as_tensor(self.kappa, device=x.device, dtype=x.dtype)
340
+ domain = (k >= 0) | (-k * x.square().sum(dim=-1) < 1.0 - atol)
341
+ return torch.isfinite(x).all(dim=-1) & torch.isfinite(k) & domain
342
+
343
+ # ----------------------------------------------------------------
344
+ # Möbius addition (gyro-addition)
345
+ # ----------------------------------------------------------------
346
+
347
+ def mobius_add(
348
+ self, x: torch.Tensor, y: torch.Tensor,
349
+ ) -> torch.Tensor:
350
+ """Möbius (gyro-) addition `x ⊕_κ y` on the κ-stereographic
351
+ manifold.
352
+
353
+ Formula (Bachmann et al. 2020, eq. 4):
354
+
355
+ x ⊕_κ y = ((1 - 2κ⟨x,y⟩ - κ‖y‖²)·x + (1 + κ‖x‖²)·y)
356
+ / (1 - 2κ⟨x,y⟩ + κ²‖x‖²‖y‖²)
357
+
358
+ Reduces to ordinary vector addition at κ = 0.
359
+
360
+ Args:
361
+ x, y: `(..., n)` points on the manifold.
362
+ Returns:
363
+ `(..., n)` Möbius sum.
364
+
365
+ References:
366
+ Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
367
+ Convolutional Networks, §2, stereographic metric and maps.
368
+ """
369
+ xx = (x * x).sum(dim=-1, keepdim=True)
370
+ yy = (y * y).sum(dim=-1, keepdim=True)
371
+ k = self._get_kappa()
372
+ total = x + y
373
+ total_sq = total.square().sum(dim=-1, keepdim=True)
374
+ x_factor = 1.0 + k * xx
375
+ # Forming x + y first makes y = -x give an exact zero sum, so the
376
+ # numerator of x ⊕ (-x) is exactly zero and not a rounding residue.
377
+ num = x_factor * total - k * total_sq * x
378
+ # For κ < 0 with both points inside the ball, the denominator is
379
+ # (1 + κ‖x‖²)(1 + κ‖y‖²) - κ‖x + y‖², a positive product plus a
380
+ # nonnegative term, so it stays positive. For κ > 0 it can reach zero at
381
+ # the omitted pole, where the sum is undefined.
382
+ denom = x_factor * (1.0 + k * yy) - k * total_sq
383
+ return num / denom
384
+
385
+ # ----------------------------------------------------------------
386
+ # Tangent operations
387
+ # ----------------------------------------------------------------
388
+
389
+ def projection(
390
+ self, x: torch.Tensor, w: torch.Tensor,
391
+ ) -> torch.Tensor:
392
+ """Tangent-space projection.
393
+
394
+ The κ-stereographic manifold is an open subset of `R^n`, so
395
+ the tangent space at every point is all of `R^n`. Projection
396
+ is the identity (returns `w` unchanged); included for API
397
+ parity with `LorentzManifold`, where projection enforces
398
+ `⟨x, v⟩_M = 0`.
399
+
400
+ Args:
401
+ x: base point (unused; included for API parity).
402
+ w: ambient direction.
403
+ Returns:
404
+ `w` (no-op).
405
+
406
+ References:
407
+ Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
408
+ Convolutional Networks, §2, stereographic metric and maps.
409
+ """
410
+ del x
411
+ return w
412
+
413
+ def egrad_to_rgrad(self, point: torch.Tensor, egrad: torch.Tensor) -> torch.Tensor:
414
+ """Divide an ambient gradient by the squared conformal factor.
415
+
416
+ Args:
417
+ point: (B, ambient_dim) base point.
418
+ egrad: (B, ambient_dim) Euclidean gradient.
419
+ Returns:
420
+ (B, ambient_dim) metric gradient in tangent coordinates.
421
+
422
+ References:
423
+ Absil, Mahony, Sepulchre (2008), §3.6; Bachmann et al. (2020), §2.
424
+ """
425
+ return egrad / self._conformal_factor(point).square().unsqueeze(-1)
426
+
427
+ def inner(
428
+ self, x: torch.Tensor, u: torch.Tensor, v: torch.Tensor,
429
+ ) -> torch.Tensor:
430
+ """Riemannian inner product `λ_κ(x)² · ⟨u, v⟩_Eucl`.
431
+
432
+ The metric is conformal to Euclidean with conformal factor
433
+ `λ_κ(x) = 2/(1 + κ‖x‖²)`. Point-dependent.
434
+
435
+ References:
436
+ Bachmann et al. (2020), §3.
437
+ """
438
+ lam = self._conformal_factor(x)
439
+ return (lam * lam) * (u * v).sum(dim=-1)
440
+
441
+ def norm(
442
+ self, x: torch.Tensor, v: torch.Tensor,
443
+ ) -> torch.Tensor:
444
+ """Riemannian norm `λ_κ(x) · ‖v‖_Eucl`.
445
+
446
+ Uses PyTorch's finite zero subgradient at v = 0.
447
+
448
+ References:
449
+ Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
450
+ Convolutional Networks, §2, stereographic metric and maps.
451
+ """
452
+ lam = self._conformal_factor(x)
453
+ return lam * torch.linalg.vector_norm(v, dim=-1)
454
+
455
+ # ----------------------------------------------------------------
456
+ # Exponential / logarithmic maps, geodesic distance
457
+ # ----------------------------------------------------------------
458
+
459
+ def exp_0(self, v: torch.Tensor) -> torch.Tensor:
460
+ """Exponential map at the origin: `exp_0(v) = tan_κ(√|κ|·‖v‖)
461
+ · v / (√|κ|·‖v‖)`.
462
+
463
+ At κ = 0: `exp_0(v) = v` (Euclidean identity).
464
+ At κ < 0: maps Euclidean coords to the Poincaré ball.
465
+ At κ > 0: tan has a pole at ‖v‖ = π/(2√κ), so tangents beyond that
466
+ radius have no meaningful image; see `log_0`.
467
+ At κ < 0: large tangents saturate at the ball margin (see
468
+ `_project_ball`); log_0 cannot invert that saturation.
469
+
470
+ Args:
471
+ v: `(..., n)` Euclidean tangent vector at the origin.
472
+ Returns:
473
+ `(..., n)` point on the manifold.
474
+
475
+ References:
476
+ Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
477
+ Convolutional Networks, §2, stereographic metric and maps.
478
+ """
479
+ if v.ndim < 2 or v.shape[-1] != self.n:
480
+ raise ValueError(
481
+ f"v last dim must be n={self.n}, got {tuple(v.shape)}"
482
+ )
483
+ v_norm = torch.linalg.vector_norm(v, dim=-1)
484
+ # Result is tan_κ(s) · v / s with s = √|κ|·‖v‖. The scalar factor is all
485
+ # _tan_kappa_c computes: it returns tan_κ(s)/s, and multiplying by v
486
+ # gives the full formula.
487
+ scaling = self._tan_kappa_c(v_norm)
488
+ return _project_ball(scaling.unsqueeze(-1) * v, self.kappa)
489
+
490
+ def log_0(self, y: torch.Tensor) -> torch.Tensor:
491
+ """Logarithmic map at the origin: `log_0(y) =
492
+ (1/√|κ|) · tan_κ⁻¹(√|κ|·‖y‖) · y / ‖y‖`.
493
+
494
+ At κ = 0: `log_0(y) = y` (Euclidean identity).
495
+ For κ > 0 this selects the principal logarithm; exp_0 is invertible
496
+ only for ||v|| < π/(2√κ). Hyperbolic saturation is not invertible.
497
+
498
+ Args:
499
+ y: `(..., n)` point on the manifold.
500
+ Returns:
501
+ `(..., n)` Euclidean tangent at the origin.
502
+
503
+ References:
504
+ Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
505
+ Convolutional Networks, §2, stereographic metric and maps.
506
+ """
507
+ if y.ndim < 2 or y.shape[-1] != self.n:
508
+ raise ValueError(
509
+ f"y last dim must be n={self.n}, got {tuple(y.shape)}"
510
+ )
511
+ y_norm = torch.linalg.vector_norm(y, dim=-1)
512
+ # Result is artan_κ(s) · y / s with s = √|κ|·‖y‖, the inverse of the
513
+ # exp_0 scalar. _atan_kappa_c returns artan_κ(s)/s, so multiplying by y
514
+ # matches the docstring's (1/√|κ|) · artan_κ(s) · y / ‖y‖.
515
+ scaling = self._atan_kappa_c(y_norm)
516
+ return scaling.unsqueeze(-1) * y
517
+
518
+ def distance(
519
+ self, x: torch.Tensor, y: torch.Tensor,
520
+ ) -> torch.Tensor:
521
+ """Geodesic distance `d_κ(x, y) = (2/√|κ|) · tan_κ⁻¹(√|κ|
522
+ · ‖(-x) ⊕_κ y‖)`.
523
+
524
+ Evaluated through sphere chords or a hyperbolic asinh chord formula
525
+ to cover spherical antipodes and near-boundary hyperbolic pairs.
526
+ The distance is nonsmooth at coincident points and spherical antipodes;
527
+ the implementation uses finite zero-subgradient conventions there.
528
+
529
+ At κ = 0: `d(x, y) = 2 · ‖y - x‖_Eucl` (Bachmann normalization;
530
+ the conformal factor at the origin is 2).
531
+
532
+ Args:
533
+ x, y: `(..., n)` points on the manifold.
534
+ Returns:
535
+ `(...,)` non-negative distances.
536
+
537
+ References:
538
+ Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
539
+ Convolutional Networks, §2, stereographic metric and maps.
540
+ """
541
+ return _stereographic_distance(x, y, self.kappa)
542
+
543
+ def exp(
544
+ self, x: torch.Tensor, v: torch.Tensor,
545
+ ) -> torch.Tensor:
546
+ """Exponential map at a general base point:
547
+
548
+ exp_x(v) = x ⊕_κ (tan_κ(λ_κ(x) · √|κ| · ‖v‖/2)
549
+ · v / (√|κ|·‖v‖))
550
+
551
+ References:
552
+ Bachmann et al. (2020), eq. 5.
553
+ """
554
+ v_norm = torch.linalg.vector_norm(v, dim=-1)
555
+ lam = self._conformal_factor(x)
556
+ # The map is tan_κ(angle) · v / (√|κ|·‖v‖) with angle = √|κ|·λ·‖v‖/2.
557
+ # Writing it as (λ/2) · (tan_κ(angle)/angle) · v removes the division by
558
+ # ‖v‖, which is 0/0 at v = 0. `arg` is λ·‖v‖/2, i.e. angle without the
559
+ # √|κ| factor; _tan_kappa_c applies that factor itself. `angle` only
560
+ # matters for κ < 0, since `root` is 1 otherwise.
561
+ arg = 0.5 * lam * v_norm
562
+ k = torch.as_tensor(self.kappa, dtype=x.dtype, device=x.device)
563
+ root = torch.sqrt(torch.where(k < 0, -k, torch.ones_like(k)))
564
+ angle = root * arg
565
+ # Hyperbolic lanes with a large angle take the explicit branch below.
566
+ # Small angles, and all spherical and Euclidean lanes, use the ordinary
567
+ # formula, which also gives the right derivative at v = 0.
568
+ scaled = (k < 0) & (angle.square() > torch.finfo(x.dtype).eps**0.5)
569
+ # Lanes on the explicit branch get zeros here, so the ordinary formula
570
+ # stays finite for them. Its output is discarded by the final where().
571
+ safe_arg = torch.where(scaled, torch.zeros_like(arg), arg)
572
+ scaling = 0.5 * lam * self._tan_kappa_c(safe_arg)
573
+ rhs = scaling.unsqueeze(-1) * torch.where(scaled.unsqueeze(-1), 0.0, v)
574
+ ordinary = self.mobius_add(x, rhs)
575
+
576
+ # For a large angle, tanh(angle) rounds to 1 and the ordinary formula
577
+ # loses the far end of the geodesic. The explicit branch keeps
578
+ # h = exp(-2·angle) instead. It splits root·x into a part along the
579
+ # tangent direction and a perpendicular part, evaluates the Mobius sum
580
+ # in those terms, and divides by root at the end.
581
+ safe_norm = torch.where(scaled, v_norm, torch.ones_like(v_norm))
582
+ unit = torch.where(scaled.unsqueeze(-1), v / safe_norm.unsqueeze(-1), 0.0)
583
+ base = torch.where(scaled.unsqueeze(-1), root * x, 0.0)
584
+ along = (base * unit).sum(dim=-1, keepdim=True)
585
+ perpendicular = base - along * unit
586
+ perpendicular_sq = perpendicular.square().sum(dim=-1, keepdim=True)
587
+ h = torch.exp(-2.0 * torch.where(scaled, angle, 0.0)).unsqueeze(-1)
588
+ plus = (1.0 + along) + (1.0 - along) * h
589
+ minus = (1.0 + along) - (1.0 - along) * h
590
+ denom = plus.square() + (1.0 - h).square() * perpendicular_sq
591
+ parallel_num = minus * plus - (1.0 - h.square()) * perpendicular_sq
592
+ perpendicular_scale = 2.0 * ((1.0 + along) + (1.0 - along) * h.square())
593
+ hyperbolic = (parallel_num * unit + perpendicular_scale * perpendicular) / denom
594
+ result = torch.where(scaled.unsqueeze(-1), hyperbolic / root, ordinary)
595
+ return _project_ball(result, self.kappa)
596
+
597
+ def log(
598
+ self, x: torch.Tensor, y: torch.Tensor,
599
+ ) -> torch.Tensor:
600
+ """Logarithmic map at a general base point:
601
+
602
+ log_x(y) = (2/(λ_κ(x)·√|κ|)) · tan_κ⁻¹(√|κ| · ‖(-x) ⊕_κ y‖)
603
+ · ((-x) ⊕_κ y) / ‖(-x) ⊕_κ y‖
604
+
605
+ Its magnitude comes from the stable geodesic distance, avoiding an
606
+ atanh argument that rounds to one near the hyperbolic boundary.
607
+ The coincident-point limit has derivative y-x. Spherical antipodes
608
+ have no unique logarithm.
609
+
610
+ References:
611
+ Bachmann et al. (2020), eq. 5.
612
+ """
613
+ delta = y - x
614
+ delta_sq = delta.square().sum(dim=-1, keepdim=True)
615
+ k = self._get_kappa()
616
+ x_factor = 1.0 + k * x.square().sum(dim=-1, keepdim=True)
617
+ # The Mobius denominator is positive away from spherical antipodes, so
618
+ # the numerator alone gives the direction. Writing it in terms of
619
+ # delta = y - x avoids subtracting nearly equal quadratic terms when y
620
+ # is close to x.
621
+ direction = x_factor * delta + k * delta_sq * x
622
+ direction_norm = torch.linalg.vector_norm(direction, dim=-1, keepdim=True)
623
+ coincident = delta_sq == 0
624
+ safe_norm = torch.where(
625
+ coincident, torch.ones_like(direction_norm), direction_norm,
626
+ )
627
+ magnitude = 0.5 * x_factor * self.distance(x, y).unsqueeze(-1)
628
+ # At coincident points direction is exactly zero, so the value is zero
629
+ # whatever the scaling. The scaling is 1 / x_factor there, which makes the
630
+ # derivative with respect to y the identity and matches log_x(y) ≈ y - x.
631
+ scaling = torch.where(coincident, 1.0 / x_factor, magnitude / safe_norm)
632
+ return scaling * direction
633
+
634
+ def retraction(
635
+ self, x: torch.Tensor, v: torch.Tensor,
636
+ ) -> torch.Tensor:
637
+ """Retraction = exponential map (second-order, the geodesic
638
+ itself satisfies the retraction axioms on constant-curvature
639
+ spaces).
640
+
641
+ References:
642
+ Bachmann, Bécigneul, Ganea (2020), Constant Curvature Graph
643
+ Convolutional Networks, §2, stereographic metric and maps.
644
+ """
645
+ return self.exp(x, v)
646
+
647
+ def parallel_transport(
648
+ self, x: torch.Tensor, y: torch.Tensor, v: torch.Tensor,
649
+ ) -> torch.Tensor:
650
+ """Parallel transport on the κ-stereographic manifold:
651
+
652
+ P_{x→y}(v) = (λ_κ(x) / λ_κ(y)) · gyr[y, -x] · v
653
+
654
+ where `gyr[a, b]·v = -(a ⊕_κ b) ⊕_κ (a ⊕_κ (b ⊕_κ v))` is the
655
+ Möbius gyrator. For κ = 0 this collapses to the identity
656
+ (Euclidean parallel transport is trivial).
657
+
658
+ References:
659
+ Bachmann et al. (2020), §2, κ-stereographic geometry;
660
+ Ungar (2008), A Gyrovector Space Approach to Hyperbolic Geometry,
661
+ gyration. The linear formula agrees with Geoopt stereographic.math.
662
+ """
663
+ # v is a tangent vector, not a point in the ball. Use the closed-form
664
+ # gyration, which is linear in v. Nesting Mobius additions would treat v
665
+ # as a point and impose the ball's constraints on it.
666
+ a, b = y, -x
667
+ aa = a.square().sum(dim=-1, keepdim=True)
668
+ bb = b.square().sum(dim=-1, keepdim=True)
669
+ ab = (a * b).sum(dim=-1, keepdim=True)
670
+ av = (a * v).sum(dim=-1, keepdim=True)
671
+ bv = (b * v).sum(dim=-1, keepdim=True)
672
+ # Closed form of gyr[a, b] v (Ungar 2008) with a = y and b = -x, written
673
+ # as v + 2 (coef_a · a + coef_b · b) / denominator.
674
+ k = self.kappa
675
+ coef_a = -k * k * av * bb - k * bv + 2.0 * k * k * ab * bv
676
+ coef_b = -k * k * bv * aa + k * av
677
+ denominator = 1.0 - 2.0 * k * ab + k * k * aa * bb
678
+ gyrated = v + 2.0 * (coef_a * a + coef_b * b) / denominator
679
+ ratio = self._conformal_factor(x) / self._conformal_factor(y)
680
+ return ratio.unsqueeze(-1) * gyrated