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,547 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Iterative Krylov solvers: conjugate gradient (CG) and LSQR.
5
+
6
+ The two workhorse iterative solvers for the library's linear systems:
7
+
8
+ - `cg`: symmetric positive-definite `A x = b` (Hestenes-Stiefel 1952).
9
+ Dense operators may be batched, `(B, n, n)`. A sparse operator is one
10
+ `(n, n)` matrix shared by the whole right-hand-side batch. A callable
11
+ matvec covers operators that are never materialized, such as normal
12
+ equations `AᵀA + λI` applied as two matvecs, or a sheaf Laplacian applied
13
+ as `δᵀ(δx)`.
14
+ - `lsqr`: least squares `min ‖Ax − b‖` for rectangular `A`, with
15
+ optional Tikhonov damping `min ‖Ax − b‖² + damp²·‖x‖²`
16
+ (Paige-Saunders 1982, via Golub-Kahan bidiagonalization). More
17
+ numerically reliable than solving the normal equations when `A` is
18
+ ill-conditioned: it works with `A` directly, never squaring the
19
+ condition number.
20
+
21
+ Defaults are derived, not tuned: convergence tolerances default to
22
+ `sqrt(eps(dtype))` (the point past which normal-equation-quality error
23
+ stops improving; ≈1.5e-8 for float64, ≈3.5e-4 for float32), CG's
24
+ iteration cap defaults to `n` (finite-termination bound of the Krylov
25
+ space), LSQR's to `2·n` (SciPy's convention).
26
+
27
+ Convergence checks force a device sync (`.item()`), so they run every
28
+ `sync_every` iterations (CONVENTIONS §4.1; same cadence pattern as
29
+ Sinkhorn's `SINKHORN_SYNC_EVERY_DEFAULT`).
30
+
31
+ References:
32
+ Hestenes, M. R., Stiefel, E. (1952). Methods of conjugate gradients
33
+ for solving linear systems. J. Res. Natl. Bur. Stand. 49(6):409-436.
34
+ Paige, C. C., Saunders, M. A. (1982). LSQR: an algorithm for sparse
35
+ linear equations and sparse least squares. ACM TOMS 8(1):43-71.
36
+ Saad, Y. (2003). Iterative Methods for Sparse Linear Systems, 2nd ed.
37
+ SIAM, §6.7 (CG convergence theory).
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import warnings
43
+ from collections.abc import Callable
44
+
45
+ import torch
46
+
47
+ # Cadence of the host-syncing convergence check inside the solver
48
+ # loops. Same value and rationale as the established
49
+ # `SINKHORN_SYNC_EVERY_DEFAULT` (CONVENTIONS §4.1): checking every
50
+ # iteration serializes GPU work on a `.item()` sync; every 8 amortizes
51
+ # the sync to ~12% of iterations while wasting at most 7 cheap
52
+ # iterations past convergence. Catalog: `SOLVER_SYNC_EVERY_DEFAULT`
53
+ # in `notes/magic_numbers.md`.
54
+ SOLVER_SYNC_EVERY_DEFAULT: int = 8
55
+
56
+ _SPARSE_LAYOUTS = (
57
+ torch.sparse_coo, torch.sparse_csr, torch.sparse_csc,
58
+ )
59
+
60
+ Operator = torch.Tensor | Callable[[torch.Tensor], torch.Tensor]
61
+
62
+
63
+ def _default_tol(dtype: torch.dtype) -> float:
64
+ """`sqrt(eps(dtype))`; derived default tolerance (module docstring)."""
65
+ return float(torch.finfo(dtype).eps) ** 0.5
66
+
67
+
68
+ def _sym_matvec(A: Operator) -> Callable[[torch.Tensor], torch.Tensor]:
69
+ """Normalize a symmetric operator to a `(B, n) → (B, n)` matvec.
70
+
71
+ Accepts a callable (used as-is), a sparse 2-D `(n, n)` tensor
72
+ (applied to the batch via one sparse×dense matmul), or a dense
73
+ `(n, n)` / `(B, n, n)` tensor (batched matmul, broadcasting an
74
+ unbatched A over the batch of right-hand sides).
75
+ """
76
+ if callable(A):
77
+ return A
78
+ if not isinstance(A, torch.Tensor):
79
+ raise ValueError(
80
+ f"A must be a torch.Tensor or a callable matvec; got {type(A)}"
81
+ )
82
+ if A.layout in _SPARSE_LAYOUTS:
83
+ if A.ndim != 2 or A.shape[0] != A.shape[1]:
84
+ raise ValueError(
85
+ f"sparse A must be (n, n); got A.shape={tuple(A.shape)}"
86
+ )
87
+ # CSC is converted to CSR once, up front, so the matvec below does not
88
+ # repeat the conversion on every call.
89
+ A_coo = (
90
+ A if A.layout != torch.sparse_csc else A.to_sparse_csr()
91
+ )
92
+ return lambda v: torch.sparse.mm(A_coo, v.mT).mT
93
+ if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
94
+ raise ValueError(
95
+ f"dense A must be (..., n, n); got A.shape={tuple(A.shape)}"
96
+ )
97
+ return lambda v: (A @ v.unsqueeze(-1)).squeeze(-1)
98
+
99
+
100
+ def _operator_is_batched(A: object) -> bool:
101
+ """True when A is a dense tensor with its own leading batch axis.
102
+
103
+ Input: the operator handed to `cg` or `lsqr` (dense tensor, sparse
104
+ matrix, or callable). Output: whether it carries a batch axis. Sparse
105
+ matrices are always one 2-D matrix and callables don't say, so both
106
+ count as unbatched. This decides whether a lone `b` may be squeezed
107
+ back to `(n,)`: with a batched A of size 1 it must not be, or the output
108
+ shape would depend on the batch size.
109
+ """
110
+ return isinstance(A, torch.Tensor) and A.layout == torch.strided and A.ndim > 2
111
+
112
+
113
+ def _normalize_rhs(b: torch.Tensor) -> tuple[torch.Tensor, bool]:
114
+ """`(n,) → (1, n)` with a flag to squeeze the output back."""
115
+ if b.ndim == 1:
116
+ return b.unsqueeze(0), True
117
+ if b.ndim != 2:
118
+ raise ValueError(
119
+ f"b must be (n,) or (B, n); got b.shape={tuple(b.shape)}"
120
+ )
121
+ return b, False
122
+
123
+
124
+ def _safe_norm(v: torch.Tensor) -> torch.Tensor:
125
+ """Euclidean norm without unscaled squares; empty vectors have norm zero."""
126
+ # amax has no value on an empty dimension, so the empty case is handled first.
127
+ if v.shape[-1] == 0:
128
+ return v.new_zeros(v.shape[:-1])
129
+ # Divide by the largest magnitude before squaring, so the squares stay in
130
+ # range and a vector with huge entries does not overflow to inf. A zero
131
+ # vector divides by 1 instead of 0, and its norm is 0 either way.
132
+ scale = v.abs().amax(dim=-1)
133
+ denominator = torch.where(scale > 0, scale, torch.ones_like(scale))
134
+ return scale * torch.linalg.vector_norm(v / denominator.unsqueeze(-1), dim=-1)
135
+
136
+
137
+ def _rhs_scale(b: torch.Tensor) -> torch.Tensor:
138
+ """Per-member maximum magnitude, with unit scale for a zero right-hand side."""
139
+ scale = b.abs().amax(dim=-1)
140
+ return torch.where(scale > 0, scale, torch.ones_like(scale))
141
+
142
+
143
+ def _scale_matrix(
144
+ A: torch.Tensor, damp: float = 0.0,
145
+ ) -> tuple[torch.Tensor, torch.Tensor]:
146
+ """Divide `A` by its largest magnitude, floored at `damp`. Sparse stays sparse.
147
+
148
+ Input: dense `(..., n, n)` or sparse 2-D `A`; `damp >= 0`.
149
+ Output: `(scaled A, scale)`. The caller divides its own damping by `scale`.
150
+ The floor makes `damp / scale` at most 1 after scaling.
151
+ """
152
+ if A.layout in _SPARSE_LAYOUTS:
153
+ coo = A.to_sparse_coo().coalesce()
154
+ values = coo.values()
155
+ # amax fails on an empty value tensor, so an all-zero matrix gets scale 0.
156
+ scale = values.abs().amax() if values.numel() else values.new_zeros(())
157
+ scale = scale.clamp(min=damp)
158
+ # A zero scale (zero matrix, damp 0) becomes 1, so the division is a no-op.
159
+ scale = torch.where(scale > 0, scale, torch.ones_like(scale))
160
+ # Only the stored values are scaled. The indices come from a coalesced
161
+ # tensor, so the invariant check can be skipped.
162
+ scaled = torch.sparse_coo_tensor(
163
+ coo.indices(), values / scale, coo.shape, device=A.device, dtype=A.dtype,
164
+ check_invariants=False,
165
+ ).coalesce()
166
+ return scaled, scale
167
+ scale = A.abs().amax(dim=(-2, -1)).clamp(min=damp)
168
+ scale = torch.where(scale > 0, scale, torch.ones_like(scale))
169
+ return A / scale[..., None, None], scale
170
+
171
+
172
+ def cg(
173
+ A: Operator,
174
+ b: torch.Tensor,
175
+ M: Callable[[torch.Tensor], torch.Tensor] | None = None,
176
+ x0: torch.Tensor | None = None,
177
+ rtol: float | None = None,
178
+ atol: float = 0.0,
179
+ maxiter: int | None = None,
180
+ sync_every: int = SOLVER_SYNC_EVERY_DEFAULT,
181
+ ) -> torch.Tensor:
182
+ """Conjugate gradient solve of symmetric positive-definite `A x = b`.
183
+
184
+ Math: minimizes the quadratic `½ xᵀAx − bᵀx` over expanding Krylov
185
+ subspaces. In exact arithmetic it terminates in at most `n` steps. In
186
+ floating point the A-norm of the error falls off like
187
+ `((√κ − 1)/(√κ + 1))^k` up to a constant factor, with `κ = cond(A)`
188
+ (Saad 2003, §6.11).
189
+
190
+ Args:
191
+ A: the operator. One of: dense `(n, n)` or `(B, n, n)` tensor;
192
+ sparse `(n, n)` (COO/CSR/CSC; applied to the whole batch in one
193
+ sparse matmul); or a callable `(B, n) → (B, n)` matvec (for
194
+ never-materialized operators). Symmetric PD assumed,
195
+ not checked.
196
+ b: `(B, n)` or `(n,)` right-hand sides.
197
+ M: optional preconditioner as a callable `(B, n) → (B, n)`
198
+ applying `M⁻¹` (e.g. a Jacobi inverse-diagonal multiply).
199
+ x0: optional `(B, n)` / `(n,)` initial guess (default zeros).
200
+ rtol: relative tolerance on `‖r‖ ≤ max(rtol·‖b‖, atol)`; default
201
+ `sqrt(eps(dtype))` (derived; see module docstring).
202
+ atol: absolute tolerance floor (default 0).
203
+ maxiter: iteration cap; default `n` (finite-termination bound).
204
+ sync_every: convergence-check cadence (CONVENTIONS §4.1).
205
+
206
+ Iterations are sequential Krylov recurrences; all batch members advance
207
+ together, with converged members frozen. Tensor operators and right-hand
208
+ sides are scaled before iteration to avoid overflow in inner products.
209
+
210
+ Returns:
211
+ `x` with `b`'s shape, except that a lone `b` with a batched dense A
212
+ returns `(B, n)`. Warns (`UserWarning`) if any batch element
213
+ has not met the tolerance at `maxiter`, or a nonfinite iterate occurs.
214
+
215
+ References:
216
+ Hestenes-Stiefel (1952); Saad (2003) §6.7, §6.11.
217
+ """
218
+ b2, squeeze = _normalize_rhs(b)
219
+ squeeze = squeeze and not _operator_is_batched(A)
220
+ B, n = b2.shape
221
+ matvec = _sym_matvec(A)
222
+ if isinstance(A, torch.Tensor) and A.shape[-1] != n:
223
+ raise ValueError(f"A last dimension must match b: {A.shape}, {b.shape}")
224
+ if not b2.is_floating_point():
225
+ raise ValueError(f"b must be floating point; got dtype={b2.dtype}")
226
+ if sync_every < 1:
227
+ raise ValueError(f"sync_every must be >= 1; got {sync_every}")
228
+ if rtol is None:
229
+ rtol = _default_tol(b2.dtype)
230
+ if maxiter is None:
231
+ maxiter = n
232
+ if rtol < 0 or atol < 0 or maxiter < 0:
233
+ raise ValueError("rtol, atol and maxiter must be nonnegative")
234
+ # An empty batch or an empty system has nothing to solve. A copy of b
235
+ # already has the right shape.
236
+ if B == 0 or n == 0:
237
+ return b.clone()
238
+
239
+ # Solve in relative units so large finite right-hand sides do not overflow
240
+ # in the CG recurrence. A dense or sparse matrix is scaled to max magnitude 1
241
+ # (Ã = A / a) and the rhs to unit scale (b̃ = b / s). Then x̃ = (a/s) x, so
242
+ # x = x̃ * solution_scale with solution_scale = s / a. A callable is not
243
+ # scaled, which is the same as a = 1.
244
+ bscale = _rhs_scale(b2)
245
+ solution_scale = bscale
246
+ if isinstance(A, torch.Tensor):
247
+ scaled_A, ascale = _scale_matrix(A)
248
+ matvec = _sym_matvec(scaled_A)
249
+ solution_scale = bscale / ascale
250
+ rhs = b2 / bscale.unsqueeze(-1)
251
+
252
+ def _precondition(residual: torch.Tensor) -> torch.Tensor:
253
+ z = M(residual) if M is not None else residual
254
+ # A scalar rescaling of z also rescales r·z and the next search
255
+ # direction, so the CG step is unchanged. Keeping z in unit range
256
+ # avoids overflow for either a large or a small preconditioner.
257
+ return z / _rhs_scale(z).unsqueeze(-1)
258
+
259
+ x = (torch.zeros_like(rhs) if x0 is None
260
+ else x0.reshape(B, n) / solution_scale.unsqueeze(-1))
261
+ r = rhs - matvec(x)
262
+ z = _precondition(r)
263
+ p = z.clone()
264
+ rz = (r * z).sum(dim=-1)
265
+ # Stopping target in scaled units. The test ||r|| <= max(rtol ||b||, atol)
266
+ # becomes the same test on r / s, with ||b̃|| = ||b|| / s.
267
+ target = torch.maximum(_safe_norm(rhs) * rtol, atol / bscale)
268
+ finite = torch.isfinite(x).all(dim=-1) & torch.isfinite(r).all(dim=-1)
269
+ done = finite & (_safe_norm(r) <= target)
270
+ failed = ~finite
271
+ n_done = 0
272
+ for it in range(1, maxiter + 1):
273
+ # Members that are done or failed are frozen. Their alpha is zeroed below,
274
+ # and their search direction is set to zero at the end of the step.
275
+ active = ~(done | failed)
276
+ Ap = matvec(p)
277
+ pAp = (p * Ap).sum(dim=-1)
278
+ # Keep the division safe for frozen members and for pAp == 0. A zero pAp
279
+ # is a breakdown, and it is marked failed below.
280
+ denominator = torch.where(active & (pAp != 0), pAp, torch.ones_like(pAp))
281
+ alpha = torch.where(active, rz / denominator, torch.zeros_like(rz))
282
+ candidate = x + alpha.unsqueeze(-1) * p
283
+ r_new = r - alpha.unsqueeze(-1) * Ap
284
+ finite = (torch.isfinite(candidate).all(dim=-1)
285
+ & torch.isfinite(r_new).all(dim=-1) & torch.isfinite(pAp))
286
+ # pAp <= 0 means A is not positive definite along p, or roundoff has
287
+ # gotten there first, so CG cannot continue for this member. Its step is
288
+ # thrown away, so a failed member keeps its last good iterate instead of
289
+ # one moved along a direction CG cannot trust. The warning still fires.
290
+ failed_now = active & (~finite | (pAp <= 0))
291
+ failed = failed | failed_now
292
+ step_ok = active & ~failed_now
293
+ x = torch.where(step_ok.unsqueeze(-1), candidate, x)
294
+ r = torch.where(step_ok.unsqueeze(-1), r_new, r)
295
+ done = done | (step_ok & (_safe_norm(r) <= target))
296
+ z = _precondition(r)
297
+ rz_new = (r * z).sum(dim=-1)
298
+ denominator = torch.where(rz != 0, rz, torch.ones_like(rz))
299
+ beta = rz_new / denominator
300
+ p = torch.where((~(done | failed)).unsqueeze(-1),
301
+ z + beta.unsqueeze(-1) * p, torch.zeros_like(p))
302
+ rz = rz_new
303
+ n_done = it
304
+ # The all-done check forces a host sync, so it runs every sync_every steps
305
+ # and on the last step.
306
+ if it % sync_every == 0 or it == maxiter:
307
+ if bool((done | failed).all()):
308
+ break
309
+
310
+ # Undo the scaling. A result that is nonfinite after unscaling counts as
311
+ # failed, even when the scaled iterate was finite.
312
+ result = x * solution_scale.unsqueeze(-1)
313
+ finite_result = torch.isfinite(result).all(dim=-1)
314
+ failed = failed | ~finite_result
315
+ unsuccessful = ~done | failed
316
+ if bool(unsuccessful.any()):
317
+ warnings.warn(
318
+ f"cg: {int(unsuccessful.sum())}/{B} batch elements not converged "
319
+ f"after {n_done} iterations (nonfinite or breakdown failures: "
320
+ f"{int(failed.sum())}). Result returned as-is; raise maxiter, "
321
+ "loosen rtol, or precondition.",
322
+ stacklevel=2,
323
+ )
324
+ return result.squeeze(0) if squeeze else result
325
+
326
+
327
+ def _lsqr_matvecs(
328
+ A: torch.Tensor,
329
+ ) -> tuple[
330
+ Callable[[torch.Tensor], torch.Tensor],
331
+ Callable[[torch.Tensor], torch.Tensor],
332
+ int,
333
+ int,
334
+ ]:
335
+ """`(matvec, rmatvec, m, n)` for dense batched or sparse 2-D `A`."""
336
+ if not isinstance(A, torch.Tensor):
337
+ raise ValueError(
338
+ f"A must be a torch.Tensor for lsqr; got {type(A)}"
339
+ )
340
+ if A.layout in _SPARSE_LAYOUTS:
341
+ if A.ndim != 2:
342
+ raise ValueError(
343
+ f"sparse A must be 2-D (m, n); got A.shape={tuple(A.shape)}"
344
+ )
345
+ m, n = A.shape
346
+ A_coo = (A if A.layout == torch.sparse_coo
347
+ else A.to_sparse_coo()).coalesce()
348
+ At = A_coo.t().coalesce()
349
+ return (
350
+ lambda v: torch.sparse.mm(A_coo, v.mT).mT,
351
+ lambda u: torch.sparse.mm(At, u.mT).mT,
352
+ m, n,
353
+ )
354
+ if A.ndim < 2:
355
+ raise ValueError(
356
+ f"dense A must be (..., m, n); got A.shape={tuple(A.shape)}"
357
+ )
358
+ m, n = A.shape[-2], A.shape[-1]
359
+ return (
360
+ lambda v: (A @ v.unsqueeze(-1)).squeeze(-1),
361
+ lambda u: (A.mT @ u.unsqueeze(-1)).squeeze(-1),
362
+ m, n,
363
+ )
364
+
365
+
366
+ def lsqr(
367
+ A: torch.Tensor,
368
+ b: torch.Tensor,
369
+ damp: float = 0.0,
370
+ atol: float | None = None,
371
+ btol: float | None = None,
372
+ maxiter: int | None = None,
373
+ sync_every: int = SOLVER_SYNC_EVERY_DEFAULT,
374
+ ) -> torch.Tensor:
375
+ """LSQR: iterative least squares `min ‖Ax − b‖² (+ damp²·‖x‖²)`.
376
+
377
+ Golub-Kahan bidiagonalization of `A` with QR-by-Givens updates
378
+ (Paige-Saunders 1982, Alg. LSQR). Analytically equivalent to CG on
379
+ the normal equations `(AᵀA + damp²·I) x = Aᵀb` but numerically
380
+ preferable: it never forms `AᵀA`, so the effective condition number
381
+ is `cond(A)`, not `cond(A)²`. Handles rank-deficient and
382
+ inconsistent systems (converges to the minimum-norm least-squares
383
+ solution).
384
+
385
+ Stopping follows Paige-Saunders §6, tests 1-2 (without SciPy's
386
+ condition-limit test):
387
+ 1. compatible systems: `‖r‖ ≤ btol·‖b‖ + atol·‖A‖·‖x‖`
388
+ 2. least-squares: `‖Aᵀr‖ ≤ atol·‖A‖·‖r‖`
389
+ with `‖A‖` the running Frobenius estimate from the bidiagonalization,
390
+ damping term included.
391
+
392
+ Args:
393
+ A: dense `(m, n)` / `(B, m, n)`, or sparse 2-D `(m, n)` (applied
394
+ to the whole batch via sparse matmuls). Callables are not
395
+ accepted here (LSQR needs the adjoint too; pass a tensor).
396
+ b: `(B, m)` or `(m,)`.
397
+ damp: Tikhonov damping `≥ 0`; `damp = √λ` solves the ridge
398
+ problem `min ‖Ax − b‖² + λ‖x‖²`.
399
+ atol, btol: stopping tolerances; default `sqrt(eps(dtype))`
400
+ (derived; see module docstring).
401
+ maxiter: iteration cap; default `2·n` (SciPy convention).
402
+ sync_every: convergence-check cadence (CONVENTIONS §4.1).
403
+
404
+ Iterations are sequential Golub-Kahan recurrences. Operators and right-hand
405
+ sides are scaled before iteration; rotations use overflow-safe hypotenuses.
406
+ Batch members that converge stop updating their solution.
407
+
408
+ Returns:
409
+ `x`: `(B, n)`, or `(n,)` when both `b` and A are unbatched. Warns
410
+ (`UserWarning`) if any batch element met neither stopping test
411
+ at `maxiter`, or a nonfinite iterate occurs.
412
+
413
+ References:
414
+ Paige-Saunders (1982), ACM TOMS 8(1):43-71, Alg. LSQR + §6
415
+ (stopping criteria). Reference implementation compared against:
416
+ `scipy.sparse.linalg.lsqr` (tests).
417
+ """
418
+ b2, squeeze = _normalize_rhs(b)
419
+ squeeze = squeeze and not _operator_is_batched(A)
420
+ B = b2.shape[0]
421
+ _, _, m, n = _lsqr_matvecs(A)
422
+ if b2.shape[-1] != m:
423
+ raise ValueError(
424
+ f"b last dim must equal A's row count m={m}; got "
425
+ f"b.shape={tuple(b.shape)}"
426
+ )
427
+ if not b2.is_floating_point() or not A.is_floating_point():
428
+ raise ValueError("A and b must be floating point")
429
+ if damp < 0:
430
+ raise ValueError(f"damp must be >= 0; got {damp}")
431
+ if sync_every < 1:
432
+ raise ValueError(f"sync_every must be >= 1; got {sync_every}")
433
+ if atol is None:
434
+ atol = _default_tol(b2.dtype)
435
+ if btol is None:
436
+ btol = _default_tol(b2.dtype)
437
+ if maxiter is None:
438
+ maxiter = 2 * n
439
+ if atol < 0 or btol < 0 or maxiter < 0:
440
+ raise ValueError("atol, btol and maxiter must be nonnegative")
441
+ if B == 0 or m == 0 or n == 0:
442
+ out = b2.new_zeros(B, n)
443
+ return out.squeeze(0) if squeeze else out
444
+
445
+ scaled_A, ascale = _scale_matrix(A, damp=damp)
446
+ matvec, rmatvec, _, _ = _lsqr_matvecs(scaled_A)
447
+ bscale = _rhs_scale(b2)
448
+ rhs = b2 / bscale.unsqueeze(-1)
449
+ # The damping is in A's units, so it is divided by the same scale as A. The
450
+ # solution is multiplied back by s / a at the end, which leaves the ridge
451
+ # problem unchanged.
452
+ damping = damp / ascale
453
+
454
+ def _safe_normalize(t: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
455
+ nrm = _safe_norm(t)
456
+ denominator = torch.where(nrm > 0, nrm, torch.ones_like(nrm))
457
+ return t / denominator.unsqueeze(-1), nrm
458
+
459
+ def _denominator(t: torch.Tensor) -> torch.Tensor:
460
+ return torch.where(t != 0, t, torch.ones_like(t))
461
+
462
+ def _hypot(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
463
+ # At (0, 0), torch.hypot's backward is 0/0, and a torch.where mask does
464
+ # not stop that NaN from reaching the gradient. Feed hypot a safe pair
465
+ # there, then mask the output to zero.
466
+ nonzero = (a != 0) | (b != 0)
467
+ safe_a = torch.where(nonzero, a, torch.ones_like(a))
468
+ safe_b = torch.where(nonzero, b, torch.zeros_like(b))
469
+ return torch.where(nonzero, torch.hypot(safe_a, safe_b), 0.0)
470
+
471
+ # Start of Golub-Kahan: u1 = b / beta1 and v1 = Aᵀu1 / alpha1. Both
472
+ # normalizations are guarded, so a zero b or a zero Aᵀb leaves the vector at zero.
473
+ u, beta = _safe_normalize(rhs)
474
+ bnorm = beta.clone()
475
+ v, alfa = _safe_normalize(rmatvec(u))
476
+ w = v.clone()
477
+ x = b2.new_zeros(B, n)
478
+ phibar = beta.clone()
479
+ rhobar = alfa.clone()
480
+ anorm = torch.zeros_like(beta)
481
+ damped_residual = torch.zeros_like(beta)
482
+ finite = torch.isfinite(beta) & torch.isfinite(alfa)
483
+ # beta = 0 means b = 0, and alfa = 0 means Aᵀb = 0. In either case x = 0 is
484
+ # already the least-squares answer, so those members are done before the loop.
485
+ done = finite & ((beta == 0) | (alfa == 0))
486
+ failed = ~finite
487
+ n_done = 0
488
+ for itn in range(1, maxiter + 1):
489
+ active = ~(done | failed)
490
+ u, beta = _safe_normalize(matvec(v) - alfa.unsqueeze(-1) * u)
491
+ anorm = _hypot(_hypot(anorm, alfa), _hypot(beta, damping))
492
+ v, alfa = _safe_normalize(rmatvec(u) - beta.unsqueeze(-1) * v)
493
+
494
+ # Finished members still participate until the next host sync.
495
+ # Give them an identity rotation before any hypot or division.
496
+ rhobar = torch.where(active, rhobar, torch.ones_like(rhobar))
497
+ beta = torch.where(active, beta, 0.0)
498
+ alfa = torch.where(active, alfa, 0.0)
499
+ active_damping = torch.where(active, damping, 0.0)
500
+ phibar = torch.where(active, phibar, 0.0)
501
+ # Both rotations use hypot: squaring a finite coefficient need not
502
+ # produce a finite intermediate, even when the rotation is well-defined.
503
+ rhobar1 = _hypot(rhobar, active_damping)
504
+ cs1 = rhobar / _denominator(rhobar1)
505
+ psi = (active_damping / _denominator(rhobar1)) * phibar
506
+ phibar = cs1 * phibar
507
+ rho = _hypot(rhobar1, beta)
508
+ cs = rhobar1 / _denominator(rho)
509
+ sn = beta / _denominator(rho)
510
+ theta = sn * alfa
511
+ rhobar = -cs * alfa
512
+ phi = cs * phibar
513
+ phibar = sn * phibar
514
+
515
+ candidate = x + (phi / _denominator(rho)).unsqueeze(-1) * w
516
+ x = torch.where(active.unsqueeze(-1), candidate, x)
517
+ w = v - (theta / _denominator(rho)).unsqueeze(-1) * w
518
+ damped_residual = _hypot(damped_residual, psi)
519
+ rnorm = _hypot(phibar, damped_residual)
520
+ arnorm = alfa * (sn * phi).abs()
521
+ finite = (torch.isfinite(x).all(dim=-1) & torch.isfinite(rnorm)
522
+ & torch.isfinite(arnorm) & torch.isfinite(anorm)
523
+ & torch.isfinite(u).all(dim=-1) & torch.isfinite(v).all(dim=-1))
524
+ failed = failed | (active & ~finite)
525
+ # Paige-Saunders tests 1 and 2 (see the docstring). Test 1 asks whether the
526
+ # residual is small for a compatible system. Test 2 asks whether Aᵀr is
527
+ # small next to the matrix and the residual. Passing either one is done.
528
+ compatible = rnorm <= btol * bnorm + atol * anorm * _safe_norm(x)
529
+ least_squares = arnorm <= atol * anorm * rnorm
530
+ done = done | (active & finite & (compatible | least_squares))
531
+ n_done = itn
532
+ if itn % sync_every == 0 or itn == maxiter:
533
+ if bool((done | failed).all()):
534
+ break
535
+
536
+ result = x * (bscale / ascale).unsqueeze(-1)
537
+ failed = failed | ~torch.isfinite(result).all(dim=-1)
538
+ unsuccessful = ~done | failed
539
+ if bool(unsuccessful.any()):
540
+ warnings.warn(
541
+ f"lsqr: {int(unsuccessful.sum())}/{B} batch elements met neither "
542
+ f"stopping test after {n_done} iterations (nonfinite failures: "
543
+ f"{int(failed.sum())}). Result returned as-is; raise maxiter "
544
+ "or loosen atol/btol.",
545
+ stacklevel=2,
546
+ )
547
+ return result.squeeze(0) if squeeze else result
@@ -0,0 +1,27 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """holonomy_lib.convex: problem-level convex optimization primitives.
5
+
6
+ This package holds CONVEX PROBLEM interfaces ("minimize this objective")
7
+ as opposed to the raw iterative solvers in `holonomy_lib.algebra.solvers`
8
+ ("run this Krylov method"). A primitive enters this package only with a
9
+ named downstream consumer (repo rule: no speculative features); the
10
+ docstring of each function records who consumes it.
11
+
12
+ Currently implemented:
13
+ least_squares.regularized_lstsq: Tikhonov/ridge least squares
14
+ `min ‖Ax − b‖² + λ‖x‖²` with solver dispatch (Cholesky normal
15
+ equations / CG on the normal operator / LSQR with damping).
16
+ Consumers: synoros-substrate's ridge sites (expectation_unit,
17
+ shape_prediction, arbitrary_pairs, hierarchy_potential; all
18
+ hand-rolled variants of this one problem as of 2026-07).
19
+
20
+ Planned (each gated on a consumer materializing):
21
+ proximal operators, projections onto convex sets, accelerated
22
+ first-order methods.
23
+ """
24
+
25
+ from holonomy_lib.convex.least_squares import regularized_lstsq
26
+
27
+ __all__ = ["regularized_lstsq"]