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,385 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """SO(3) rotation-group primitives: batched, GPU-native.
5
+
6
+ The 3D rotation group SO(3) has a 3-dim Lie algebra so(3) ≅ R³ via the
7
+ hat map ω ↦ [ω]_×, where [ω]_× is the 3×3 skew-symmetric matrix
8
+
9
+ [[ 0, −ω_z, ω_y],
10
+ [ ω_z, 0, −ω_x],
11
+ [−ω_y, ω_x, 0 ]].
12
+
13
+ The Lie-exp map `so(3) → SO(3)` is the matrix exponential, which has
14
+ the closed-form Rodrigues formula:
15
+
16
+ exp([ω]_×) = I + (sin θ / θ) [ω]_× + ((1 − cos θ) / θ²) [ω]_ײ,
17
+ θ = ‖ω‖.
18
+
19
+ Its inverse, the Lie log, is smooth on rotations with angle below π.
20
+ At π the axis has a sign ambiguity; see `so3_log` for the
21
+ representative it returns.
22
+
23
+ References:
24
+ Hall (2015), §3.1: SO(3) and the Rodrigues formula.
25
+ Shoemake (1992): uniform-random rotations via quaternions.
26
+ Murray, Li, Sastry (1994). A Mathematical Introduction to Robotic
27
+ Manipulation, §2.3: the hat/vee maps and the SO(3) exp/log
28
+ closed forms used here.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import math
34
+
35
+ import torch
36
+
37
+ # Mathematical dimension of SO(3): the group lives in R^{3×3} and its
38
+ # Lie algebra is R^3. **Scale of validity**: hard-coded by the
39
+ # definition of "3D rotation"; not tunable. Cataloged as `SO3_DIM`.
40
+ SO3_DIM: int = 3
41
+
42
+ # Quaternion dimension. **Scale of validity**: hard-coded by the
43
+ # definition of "unit quaternion"; not tunable. Cataloged as
44
+ # `QUATERNION_DIM`.
45
+ QUATERNION_DIM: int = 4
46
+
47
+ # The near-pi crossover was calibrated in float64 against Rodrigues round
48
+ # trips. so3_log uses the larger of this and sqrt(eps) for the input dtype,
49
+ # so float32 (sqrt(eps) about 3.5e-4) gets a wider near-pi band.
50
+ # Cataloged as SO3_LOG_NEAR_PI_RAD; this is an angle in radians.
51
+ SO3_LOG_NEAR_PI_RAD: float = 1e-7
52
+
53
+ # For cos(theta) > 0, theta = arcsin(s) with s = sin(theta), so
54
+ # theta / sin(theta) = arcsin(s) / s = 1 + s² / 3! + O(s⁴).
55
+ # The small-angle neighborhood comes from dtype epsilon: with s² below
56
+ # sqrt(eps), the dropped s⁴ term is of order eps, which is roundoff. No
57
+ # separate angle tolerance is needed.
58
+ SO3_LOG_SERIES_SECOND_ORDER: float = 1.0 / math.factorial(SO3_DIM)
59
+
60
+ # Taylor coefficients of sin(theta)/theta and (1-cos(theta))/theta².
61
+ # They are reciprocal factorials from Rodrigues' formula, not fitted values.
62
+ SO3_EXP_SINC_LINEAR: float = 1.0 / 6.0
63
+ SO3_EXP_SINC_QUADRATIC: float = 1.0 / 120.0
64
+ SO3_EXP_COSC_LINEAR: float = 1.0 / 24.0
65
+ SO3_EXP_COSC_QUADRATIC: float = 1.0 / 720.0
66
+
67
+
68
+ def axis_angle_to_matrix(
69
+ axis: torch.Tensor, angle: torch.Tensor,
70
+ ) -> torch.Tensor:
71
+ """Rodrigues formula: convert (unit axis, angle) → rotation matrix.
72
+
73
+ R = I + sin(θ)·K + (1 − cos(θ))·K², K = [axis]_×.
74
+
75
+ The axis is normalized to unit length internally so callers do
76
+ not need to pre-normalize.
77
+
78
+ Args:
79
+ axis: `(..., 3)` axis vectors; will be normalized.
80
+ angle: `(...,)` rotation angle in radians.
81
+
82
+ Returns:
83
+ `(..., 3, 3)` rotation matrices in SO(3).
84
+
85
+ References:
86
+ Hall (2015), Theorem 3.3.
87
+ """
88
+ if axis.ndim < 1 or axis.shape[-1] != SO3_DIM:
89
+ raise ValueError(
90
+ f"axis must end with dim {SO3_DIM}; got axis.shape={tuple(axis.shape)}"
91
+ )
92
+ if axis.shape[:-1] != angle.shape:
93
+ raise ValueError(
94
+ f"axis batch shape {tuple(axis.shape[:-1])} must match "
95
+ f"angle shape {tuple(angle.shape)}"
96
+ )
97
+ # A zero axis would divide by zero. The clamp keeps the division finite,
98
+ # and a zero axis then gives the identity.
99
+ floor = torch.finfo(axis.dtype).tiny
100
+ norm = torch.linalg.norm(axis, dim=-1, keepdim=True).clamp(min=floor)
101
+ axis_unit = axis / norm # (..., 3)
102
+
103
+ K = _hat(axis_unit) # (..., 3, 3)
104
+ s = torch.sin(angle).unsqueeze(dim=-1).unsqueeze(dim=-1) # (..., 1, 1)
105
+ c = torch.cos(angle).unsqueeze(dim=-1).unsqueeze(dim=-1)
106
+ eye = torch.eye(SO3_DIM, dtype=axis.dtype, device=axis.device)
107
+ eye = eye.expand(*axis.shape[:-1], SO3_DIM, SO3_DIM)
108
+ return eye + s * K + (1.0 - c) * (K @ K)
109
+
110
+
111
+ def matrix_to_axis_angle(R: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
112
+ """Inverse Rodrigues: convert SO(3) matrix to (unit axis, angle).
113
+
114
+ Uses the Lie logarithm, then normalizes its rotation vector. At the
115
+ identity the angle is zero and the axis is chosen as (1, 0, 0).
116
+ The axis-angle representation is not differentiable at identity.
117
+
118
+ Args:
119
+ R: `(..., 3, 3)` rotation matrix.
120
+
121
+ Returns:
122
+ `(axis, angle)`:
123
+ axis `(..., 3)` unit vector.
124
+ angle `(...,)` in `[0, π]`.
125
+
126
+ References:
127
+ Murray-Li-Sastry (1994), §2.3.3: the dual-branch logarithm.
128
+ """
129
+ if R.shape[-2:] != (SO3_DIM, SO3_DIM):
130
+ raise ValueError(
131
+ f"R must end with ({SO3_DIM}, {SO3_DIM}); got R.shape={tuple(R.shape)}"
132
+ )
133
+ omega = so3_log(R)
134
+ angle = torch.linalg.vector_norm(omega, dim=-1)
135
+ # The axis is undefined at the identity, where the rotation vector is
136
+ # zero. Only this axis-angle API has to pick one, so it uses (1, 0, 0).
137
+ nonzero = angle > 0
138
+ divisor = torch.where(nonzero, angle, torch.ones_like(angle))
139
+ axis = omega / divisor.unsqueeze(-1)
140
+ canonical = torch.zeros_like(axis)
141
+ canonical[..., 0] = 1.0
142
+ return torch.where(nonzero.unsqueeze(-1), axis, canonical), angle
143
+
144
+
145
+ def so3_exp(omega: torch.Tensor) -> torch.Tensor:
146
+ """Matrix exponential on so(3): ω ∈ R³ ↦ R ∈ SO(3).
147
+
148
+ Evaluates Rodrigues' formula. Near zero it uses Taylor polynomials for
149
+ the two coefficients, so the result and its gradient stay finite at
150
+ omega = 0.
151
+
152
+ Args:
153
+ omega: `(..., 3)` Lie algebra elements.
154
+
155
+ Returns:
156
+ `(..., 3, 3)` rotation matrices.
157
+
158
+ References:
159
+ Hall (2015). Lie Groups, Lie Algebras, and Representations, §3.1.
160
+ Murray, Li, Sastry (1994). A Mathematical Introduction to Robotic
161
+ Manipulation, equation 2.14.
162
+ """
163
+ if omega.ndim < 1 or omega.shape[-1] != SO3_DIM:
164
+ raise ValueError(
165
+ f"omega must end with dim {SO3_DIM}; got omega.shape={tuple(omega.shape)}"
166
+ )
167
+ theta_squared = omega.square().sum(dim=-1)
168
+ small = theta_squared <= math.sqrt(torch.finfo(omega.dtype).eps)
169
+ # torch.where computes both branches everywhere, and a NaN or inf in the
170
+ # unused one would still turn into a NaN gradient. So the sqrt and the
171
+ # division get 1 wherever the small branch is active, and the polynomial
172
+ # sees 0 wherever it is not.
173
+ safe_squared = torch.where(small, torch.ones_like(theta_squared), theta_squared)
174
+ theta = torch.sqrt(safe_squared)
175
+ series_squared = torch.where(small, theta_squared, torch.zeros_like(theta_squared))
176
+ sinc = torch.where(
177
+ small,
178
+ 1.0 - SO3_EXP_SINC_LINEAR * series_squared
179
+ + SO3_EXP_SINC_QUADRATIC * series_squared.square(),
180
+ torch.sin(theta) / theta,
181
+ )
182
+ cosc = torch.where(
183
+ small,
184
+ 0.5 - SO3_EXP_COSC_LINEAR * series_squared
185
+ + SO3_EXP_COSC_QUADRATIC * series_squared.square(),
186
+ (1.0 - torch.cos(theta)) / safe_squared,
187
+ )
188
+ generator = _hat(omega)
189
+ eye = torch.eye(SO3_DIM, dtype=omega.dtype, device=omega.device)
190
+ return (
191
+ eye + sinc[..., None, None] * generator
192
+ + cosc[..., None, None] * (generator @ generator)
193
+ )
194
+
195
+
196
+ def so3_log(R: torch.Tensor) -> torch.Tensor:
197
+ """Principal rotation vector of an SO(3) matrix, with norm in [0, π].
198
+
199
+ Away from π, the skew part is sin(theta) times the rotation axis, so
200
+ the rotation vector is that part scaled by theta / sin(theta). Near
201
+ zero that quotient is replaced by its series 1 + sin²(theta) / 6.
202
+ Near π the sine is too small to carry the axis, so the symmetric part
203
+ determines it instead. The sign at exactly π is intrinsically
204
+ ambiguous, so the logarithm is not smooth there.
205
+
206
+ Args:
207
+ R: `(..., 3, 3)` rotation matrices.
208
+
209
+ Returns:
210
+ `(..., 3)` rotation vectors.
211
+
212
+ References:
213
+ Murray, Li, Sastry (1994). A Mathematical Introduction to Robotic
214
+ Manipulation, §2.3.3, the logarithm of a rotation matrix.
215
+ """
216
+ if R.shape[-2:] != (SO3_DIM, SO3_DIM):
217
+ raise ValueError(
218
+ f"R must end with ({SO3_DIM}, {SO3_DIM}); got R.shape={tuple(R.shape)}"
219
+ )
220
+ skew_vector = _vee(0.5 * (R - R.mT))
221
+ sin_angle = torch.linalg.vector_norm(skew_vector, dim=-1)
222
+ sin_squared = (skew_vector * skew_vector).sum(dim=-1)
223
+ cos_angle = ((R.diagonal(dim1=-2, dim2=-1).sum(dim=-1) - 1.0) * 0.5)
224
+ angle = torch.atan2(sin_angle, cos_angle)
225
+ small = (cos_angle > 0) & (sin_squared < math.sqrt(torch.finfo(R.dtype).eps))
226
+ # The skew part only carries the axis when sin(theta) is not tiny, so
227
+ # this threshold switches to the symmetric part close to π.
228
+ near_pi = angle > math.pi - max(
229
+ SO3_LOG_NEAR_PI_RAD, math.sqrt(torch.finfo(R.dtype).eps),
230
+ )
231
+ # The angle / sine quotient is computed everywhere, so its sine is swapped
232
+ # for 1 in the small and near-π branches, where that quotient is not
233
+ # used. This matters in backward too: torch.where sends zero gradient
234
+ # into the unused branch, and zero times an infinite derivative is NaN.
235
+ safe_sine = torch.where(small | near_pi, torch.ones_like(sin_angle), sin_angle)
236
+ scale = torch.where(
237
+ small, 1.0 + SO3_LOG_SERIES_SECOND_ORDER * sin_squared, angle / safe_sine,
238
+ )
239
+ omega = scale.unsqueeze(-1) * skew_vector
240
+
241
+ # Sym(R) - cos(theta) I = (1 - cos(theta)) axis axis^T. Its largest
242
+ # diagonal selects a row with a nonzero axis component even at π.
243
+ eye = torch.eye(SO3_DIM, dtype=R.dtype, device=R.device)
244
+ symmetric = 0.5 * (R + R.mT) - cos_angle[..., None, None] * eye
245
+ # Away from π this part's axis is not used. At the identity, though,
246
+ # Sym(R) - cos(theta) I is exactly zero, so its row would be a 0 / 0
247
+ # normalization. Swap in the identity wherever this is not used. A NaN
248
+ # in an unused branch would still poison the gradient.
249
+ symmetric = torch.where(near_pi[..., None, None], symmetric, eye)
250
+ pivot = symmetric.diagonal(dim1=-2, dim2=-1).argmax(dim=-1)
251
+ row = symmetric.gather(
252
+ -2, pivot[..., None, None].expand(*R.shape[:-2], 1, SO3_DIM),
253
+ ).squeeze(-2)
254
+ axis = row / torch.linalg.vector_norm(row, dim=-1, keepdim=True)
255
+ alignment = (axis * skew_vector).sum(dim=-1, keepdim=True)
256
+ axis = torch.where(alignment < 0, -axis, axis)
257
+ return torch.where(near_pi.unsqueeze(-1), angle.unsqueeze(-1) * axis, omega)
258
+
259
+
260
+ def compose(R1: torch.Tensor, R2: torch.Tensor) -> torch.Tensor:
261
+ """SO(3) group product `R1 · R2`, a plain matmul.
262
+
263
+ It lives in this module so the group operations are together.
264
+
265
+ Args:
266
+ R1, R2: broadcastable `(..., 3, 3)` rotation matrices.
267
+
268
+ Returns:
269
+ `(..., 3, 3)` rotation matrices, applying R2 before R1.
270
+
271
+ References:
272
+ Hall (2015). Lie Groups, Lie Algebras, and Representations, §1.2.
273
+ """
274
+ if R1.shape[-2:] != (SO3_DIM, SO3_DIM) or R2.shape[-2:] != (SO3_DIM, SO3_DIM):
275
+ raise ValueError(
276
+ f"both inputs must end with ({SO3_DIM}, {SO3_DIM}); got "
277
+ f"R1.shape={tuple(R1.shape)}, R2.shape={tuple(R2.shape)}"
278
+ )
279
+ return R1 @ R2
280
+
281
+
282
+ def random_so3(
283
+ batch_size: int,
284
+ generator: torch.Generator | None = None,
285
+ device: str | torch.device = "cpu",
286
+ dtype: torch.dtype = torch.float64,
287
+ ) -> torch.Tensor:
288
+ """Haar-uniform random rotation matrices.
289
+
290
+ Construction: sample a unit quaternion uniformly on S³ (the
291
+ Shoemake 1992 method), then convert to a rotation matrix. The
292
+ quaternion-Haar measure pushes through to the SO(3)-Haar measure
293
+ under the standard 2-to-1 cover.
294
+
295
+ Args:
296
+ batch_size: number of independent rotations.
297
+ generator: torch.Generator for reproducibility.
298
+ device, dtype: tensor placement.
299
+
300
+ Returns:
301
+ `(batch_size, 3, 3)` SO(3) matrices.
302
+
303
+ References:
304
+ Shoemake (1992).
305
+ """
306
+ if batch_size < 0:
307
+ raise ValueError(f"batch_size must be >= 0, got {batch_size}")
308
+ # Shoemake: three uniform [0, 1] samples u1, u2, u3 → unit
309
+ # quaternion (w, x, y, z) with q on S³ uniformly. The sampling
310
+ # and trig run on `device` so a `device="cuda"` call doesn't
311
+ # silently fall back to CPU for the per-sample scalar work. The
312
+ # generator must be on the matching device: if the caller wants
313
+ # GPU sampling, they must pass a CUDA generator.
314
+ target_device = torch.device(device)
315
+ # A CUDA generator reports `cuda` with no index, so compare the device
316
+ # type, and the index only when both sides name one.
317
+ generator_device = None if generator is None else generator.device
318
+ if generator_device is not None and (
319
+ generator_device.type != target_device.type
320
+ or (generator_device.index is not None
321
+ and target_device.index is not None
322
+ and generator_device.index != target_device.index)
323
+ ):
324
+ raise ValueError(
325
+ f"generator is on {generator_device} but device={target_device} "
326
+ f"was requested; pass a generator on the target device or omit "
327
+ f"`generator` to use the default"
328
+ )
329
+ u = torch.rand(
330
+ batch_size, SO3_DIM,
331
+ generator=generator, dtype=dtype, device=target_device,
332
+ )
333
+ u1, u2, u3 = u[..., 0], u[..., 1], u[..., 2]
334
+ sqrt_u1 = torch.sqrt(u1)
335
+ sqrt_1_minus_u1 = torch.sqrt(1.0 - u1)
336
+ two_pi_u2 = 2.0 * math.pi * u2
337
+ two_pi_u3 = 2.0 * math.pi * u3
338
+ w = sqrt_1_minus_u1 * torch.sin(two_pi_u2)
339
+ x = sqrt_1_minus_u1 * torch.cos(two_pi_u2)
340
+ y = sqrt_u1 * torch.sin(two_pi_u3)
341
+ z = sqrt_u1 * torch.cos(two_pi_u3)
342
+ q = torch.stack([w, x, y, z], dim=-1) # (B, 4)
343
+ return _quaternion_to_matrix(q)
344
+
345
+
346
+ # ============================================================
347
+ # Internal helpers
348
+ # ============================================================
349
+
350
+
351
+ def _hat(v: torch.Tensor) -> torch.Tensor:
352
+ """Hat map: R³ → so(3). v ↦ [v]_× as a (..., 3, 3) skew-symmetric."""
353
+ zero = torch.zeros_like(v[..., 0])
354
+ x, y, z = v[..., 0], v[..., 1], v[..., 2]
355
+ row0 = torch.stack([zero, -z, y], dim=-1)
356
+ row1 = torch.stack([z, zero, -x], dim=-1)
357
+ row2 = torch.stack([-y, x, zero], dim=-1)
358
+ return torch.stack([row0, row1, row2], dim=-2)
359
+
360
+
361
+ def _vee(K: torch.Tensor) -> torch.Tensor:
362
+ """Vee map: skew(3) → R³. The inverse of `_hat`."""
363
+ return torch.stack(
364
+ [K[..., 2, 1], K[..., 0, 2], K[..., 1, 0]], dim=-1,
365
+ )
366
+
367
+
368
+ def _quaternion_to_matrix(q: torch.Tensor) -> torch.Tensor:
369
+ """Convert unit quaternions `(w, x, y, z)` to rotation matrices.
370
+
371
+ Standard formula; works for arbitrary batch shape ending in 4.
372
+ """
373
+ # Components are read by index, in the (w, x, y, z) layout. The last
374
+ # index is written as QUATERNION_DIM - 1 so it is tied to the constant.
375
+ # The others are plain 0, 1, 2.
376
+ last = QUATERNION_DIM - 1
377
+ w, x, y, z = q[..., 0], q[..., 1], q[..., 2], q[..., last]
378
+ # Each product shows up in more than one entry, so compute it once.
379
+ wx, wy, wz = w * x, w * y, w * z
380
+ xx, yy, zz = x * x, y * y, z * z
381
+ xy, xz, yz = x * y, x * z, y * z
382
+ row0 = torch.stack([1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy)], dim=-1)
383
+ row1 = torch.stack([2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx)], dim=-1)
384
+ row2 = torch.stack([2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy)], dim=-1)
385
+ return torch.stack([row0, row1, row2], dim=-2)
@@ -0,0 +1,189 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Real spherical harmonics Y_lm on the unit 2-sphere, closed form
5
+ for l ≤ 4.
6
+
7
+ Real spherical harmonics are the orthonormal basis for square-
8
+ integrable functions on `S² ⊂ R³` that diagonalizes the Laplace-
9
+ Beltrami operator on the sphere. On the sphere, each `Y_lm` agrees with
10
+ a homogeneous polynomial of degree l in `(x, y, z)`. There are `2l + 1`
11
+ of them at each `l`, indexed by `m ∈ {−l, …, l}`. The total at
12
+ truncation `l_max` is `(l_max + 1)²` components.
13
+
14
+ In equivariant deep learning these serve as the spherical features that
15
+ rotate in a known way under SO(3). e3nn and SE(3)-Transformer use them
16
+ as steerable filters, and Clebsch-Gordan coefficients are what combine
17
+ different degrees into equivariant products.
18
+
19
+ The basis is orthonormal over the sphere and uses the Condon-Shortley-free
20
+ convention: every prefactor is positive, with no `(−1)^m` phase. Inputs
21
+ are direction vectors `(..., 3)`; the function normalizes to unit norm
22
+ internally, so callers can pass unnormalized vectors.
23
+
24
+ References:
25
+ Wikipedia, "Real spherical harmonics": the explicit formulas
26
+ transcribed here are the standard tesseral/sectoral basis.
27
+ Edmonds (1957), §2.5.
28
+ Geiger, M., Smidt, T. (2022). e3nn: Euclidean neural networks.
29
+ arXiv:2207.09453. The modern implementation in geometric
30
+ deep learning. To compare with e3nn.o3.spherical_harmonics, pass
31
+ directions[..., [1, 2, 0]], normalize=True, normalization="integral".
32
+ e3nn uses Cartesian order (x, y, z) at degree one; this basis uses
33
+ (y, z, x). See https://docs.e3nn.org/en/stable/api/o3/o3_sh.html.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import math
39
+
40
+ import torch
41
+
42
+ _SUPPORTED_L_MAX: int = 4
43
+
44
+
45
+ def real_spherical_harmonics(
46
+ directions: torch.Tensor, l_max: int = 2,
47
+ ) -> torch.Tensor:
48
+ """Real spherical harmonics `Y_lm` at the given direction vectors.
49
+
50
+ For each input direction `(x, y, z)` (normalized internally),
51
+ returns the `(l_max + 1)²` real spherical harmonics in the
52
+ standard ordering:
53
+
54
+ [Y_{0,0},
55
+ Y_{1,-1}, Y_{1,0}, Y_{1,1},
56
+ Y_{2,-2}, Y_{2,-1}, Y_{2,0}, Y_{2,1}, Y_{2,2},
57
+ …,
58
+ Y_{l_max, -l_max}, …, Y_{l_max, l_max}]
59
+
60
+ Args:
61
+ directions: `(..., 3)` direction vectors. Need not be unit; the
62
+ function normalizes internally.
63
+ l_max: highest degree to compute. Must be in `[0, 4]`. Higher
64
+ degrees need the associated-Legendre recurrence, which is not
65
+ implemented.
66
+
67
+ Returns:
68
+ `(..., (l_max + 1)²)` real spherical harmonic values.
69
+
70
+ References:
71
+ Wikipedia, "Table of spherical harmonics": real form.
72
+ Edmonds (1957), §2.5.
73
+ """
74
+ if directions.ndim < 1 or directions.shape[-1] != 3:
75
+ raise ValueError(
76
+ f"directions must end with dim 3; got shape="
77
+ f"{tuple(directions.shape)}"
78
+ )
79
+ if not (0 <= l_max <= _SUPPORTED_L_MAX):
80
+ raise ValueError(
81
+ f"l_max must be in [0, {_SUPPORTED_L_MAX}]; got l_max={l_max}. "
82
+ f"Higher degrees need the associated-Legendre recurrence "
83
+ f"(not implemented)."
84
+ )
85
+
86
+ # The clamp keeps a zero direction from dividing by zero. It then stays
87
+ # the zero vector, and the polynomials evaluated there are not meaningful
88
+ # SH values.
89
+ floor = torch.finfo(directions.dtype).tiny
90
+ norm = torch.linalg.norm(directions, dim=-1, keepdim=True).clamp(min=floor)
91
+ unit = directions / norm
92
+ x, y, z = unit[..., 0], unit[..., 1], unit[..., 2]
93
+
94
+ components: list[torch.Tensor] = []
95
+
96
+ # ----- l = 0 -----
97
+ # Y_{0,0} = 0.5 · sqrt(1/π). full_like keeps the batch shape, dtype and
98
+ # device of x, so the constant stacks with the other components.
99
+ y00 = torch.full_like(x, 0.5 * math.sqrt(1.0 / math.pi))
100
+ components.append(y00)
101
+
102
+ # Each block below adds one degree. Returning early for a smaller l_max
103
+ # means the higher blocks never run.
104
+ if l_max < 1:
105
+ return torch.stack(components, dim=-1)
106
+
107
+ # ----- l = 1 -----
108
+ # Y_{1,m} = sqrt(3/(4π)) · {y, z, x} for m = -1, 0, 1.
109
+ c1 = math.sqrt(3.0 / (4.0 * math.pi))
110
+ components.extend([c1 * y, c1 * z, c1 * x])
111
+
112
+ if l_max < 2:
113
+ return torch.stack(components, dim=-1)
114
+
115
+ # ----- l = 2 -----
116
+ # Y_{2,-2} = 0.5 · sqrt(15/π) · xy
117
+ # Y_{2,-1} = 0.5 · sqrt(15/π) · yz
118
+ # Y_{2,0} = 0.25 · sqrt(5/π) · (3z² − 1)
119
+ # Y_{2,1} = 0.5 · sqrt(15/π) · xz
120
+ # Y_{2,2} = 0.25 · sqrt(15/π) · (x² − y²)
121
+ c2a = 0.5 * math.sqrt(15.0 / math.pi)
122
+ c20 = 0.25 * math.sqrt(5.0 / math.pi)
123
+ c22 = 0.25 * math.sqrt(15.0 / math.pi)
124
+ components.extend([
125
+ c2a * x * y,
126
+ c2a * y * z,
127
+ c20 * (3.0 * z * z - 1.0),
128
+ c2a * x * z,
129
+ c22 * (x * x - y * y),
130
+ ])
131
+
132
+ if l_max < 3:
133
+ return torch.stack(components, dim=-1)
134
+
135
+ # ----- l = 3 -----
136
+ # Y_{3,-3} = 0.25 · sqrt(35/(2π)) · y(3x² − y²)
137
+ # Y_{3,-2} = 0.5 · sqrt(105/π) · xyz
138
+ # Y_{3,-1} = 0.25 · sqrt(21/(2π)) · y(5z² − 1)
139
+ # Y_{3,0} = 0.25 · sqrt(7/π) · (5z³ − 3z)
140
+ # Y_{3,1} = 0.25 · sqrt(21/(2π)) · x(5z² − 1)
141
+ # Y_{3,2} = 0.25 · sqrt(105/π) · z(x² − y²)
142
+ # Y_{3,3} = 0.25 · sqrt(35/(2π)) · x(x² − 3y²)
143
+ c3_a = 0.25 * math.sqrt(35.0 / (2.0 * math.pi))
144
+ c3_b = 0.5 * math.sqrt(105.0 / math.pi)
145
+ c3_c = 0.25 * math.sqrt(21.0 / (2.0 * math.pi))
146
+ c3_0 = 0.25 * math.sqrt(7.0 / math.pi)
147
+ c3_d = 0.25 * math.sqrt(105.0 / math.pi)
148
+ components.extend([
149
+ c3_a * y * (3.0 * x * x - y * y),
150
+ c3_b * x * y * z,
151
+ c3_c * y * (5.0 * z * z - 1.0),
152
+ c3_0 * (5.0 * z ** 3 - 3.0 * z),
153
+ c3_c * x * (5.0 * z * z - 1.0),
154
+ c3_d * z * (x * x - y * y),
155
+ c3_a * x * (x * x - 3.0 * y * y),
156
+ ])
157
+
158
+ if l_max < 4:
159
+ return torch.stack(components, dim=-1)
160
+
161
+ # ----- l = 4 -----
162
+ # Y_{4,-4} = 0.75 · sqrt(35/π) · xy(x² − y²)
163
+ # Y_{4,-3} = 0.75 · sqrt(35/(2π)) · yz(3x² − y²)
164
+ # Y_{4,-2} = 0.75 · sqrt(5/π) · xy(7z² − 1)
165
+ # Y_{4,-1} = 0.75 · sqrt(5/(2π)) · yz(7z² − 3)
166
+ # Y_{4,0} = 3/16 · sqrt(1/π) · (35z⁴ − 30z² + 3)
167
+ # Y_{4,1} = 0.75 · sqrt(5/(2π)) · xz(7z² − 3)
168
+ # Y_{4,2} = 0.375 · sqrt(5/π) · (x² − y²)(7z² − 1)
169
+ # Y_{4,3} = 0.75 · sqrt(35/(2π)) · xz(x² − 3y²)
170
+ # Y_{4,4} = 3/16 · sqrt(35/π) · (x⁴ − 6x²y² + y⁴)
171
+ c4_a = 0.75 * math.sqrt(35.0 / math.pi)
172
+ c4_b = 0.75 * math.sqrt(35.0 / (2.0 * math.pi))
173
+ c4_c = 0.75 * math.sqrt(5.0 / math.pi)
174
+ c4_d = 0.75 * math.sqrt(5.0 / (2.0 * math.pi))
175
+ c4_0 = (3.0 / 16.0) * math.sqrt(1.0 / math.pi)
176
+ c4_e = 0.375 * math.sqrt(5.0 / math.pi)
177
+ c4_f = (3.0 / 16.0) * math.sqrt(35.0 / math.pi)
178
+ components.extend([
179
+ c4_a * x * y * (x * x - y * y),
180
+ c4_b * y * z * (3.0 * x * x - y * y),
181
+ c4_c * x * y * (7.0 * z * z - 1.0),
182
+ c4_d * y * z * (7.0 * z * z - 3.0),
183
+ c4_0 * (35.0 * z ** 4 - 30.0 * z * z + 3.0),
184
+ c4_d * x * z * (7.0 * z * z - 3.0),
185
+ c4_e * (x * x - y * y) * (7.0 * z * z - 1.0),
186
+ c4_b * x * z * (x * x - 3.0 * y * y),
187
+ c4_f * (x ** 4 - 6.0 * x * x * y * y + y ** 4),
188
+ ])
189
+ return torch.stack(components, dim=-1)
@@ -0,0 +1,53 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """holonomy_lib.manifolds; Riemannian manifold primitives, GPU-batched.
5
+
6
+ Currently implemented:
7
+ fixed_rank.FixedRankManifold; M_r(m, n), the rank-r matrices in R^{m×n}
8
+ spd.SPDManifold ; P(n), symmetric positive definite,
9
+ affine-invariant metric
10
+ lorentz.LorentzManifold ; H^n_k, hyperboloid model of hyperbolic
11
+ space of curvature k < 0 (Nickel-Kiela 2018)
12
+ stereographic.KappaStereographicManifold; parametric κ ∈ R, interpolates
13
+ spherical (κ>0), Euclidean (κ=0),
14
+ hyperbolic (κ<0) via gyro-algebra
15
+ (Bachmann-Bécigneul-Ganea 2020)
16
+ lorentzian.LorentzianManifold; flat Minkowski spacetime R^{1, n-1}
17
+ with pseudo-Riemannian (1, n-1)
18
+ signature; causal structure (timelike/
19
+ null/spacelike) + proper time/distance
20
+ (MTW 1973; O'Neill 1983)
21
+ product.ProductManifold ; Riemannian product M_1 × M_2 × ...
22
+ with the Pythagorean direct-sum
23
+ metric (Gu-Sala et al. 2019;
24
+ Skopek et al. 2019)
25
+ Planned (port/wrap from geoopt, geomstats, pymanopt):
26
+ sphere, stiefel, grassmann
27
+ """
28
+
29
+ from holonomy_lib.manifolds.comparison import (
30
+ model_anisotropic_flux,
31
+ model_ball_volume,
32
+ model_sphere_area,
33
+ sn_kappa,
34
+ )
35
+ from holonomy_lib.manifolds.fixed_rank import FixedRankManifold
36
+ from holonomy_lib.manifolds.lorentz import LorentzManifold
37
+ from holonomy_lib.manifolds.lorentzian import LorentzianManifold
38
+ from holonomy_lib.manifolds.product import ProductManifold
39
+ from holonomy_lib.manifolds.spd import SPDManifold
40
+ from holonomy_lib.manifolds.stereographic import KappaStereographicManifold
41
+
42
+ __all__ = [
43
+ "FixedRankManifold",
44
+ "KappaStereographicManifold",
45
+ "LorentzManifold",
46
+ "LorentzianManifold",
47
+ "ProductManifold",
48
+ "SPDManifold",
49
+ "model_anisotropic_flux",
50
+ "model_ball_volume",
51
+ "model_sphere_area",
52
+ "sn_kappa",
53
+ ]