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,118 @@
1
+ """One persistence computation supplies the complete interactive filtration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from holonomy_lib.simplicial import pairwise_distances
8
+ from holonomy_lib.topology import persistence_diagrams
9
+
10
+ from .common import Context, Request
11
+ from .datasets import cloud
12
+
13
+
14
+ def reference_check(context: Context) -> Request:
15
+ """Check the square's sole H1 interval against [1, sqrt(2)).
16
+
17
+ References:
18
+ Edelsbrunner and Harer (2010), Computational Topology, VII.
19
+ """
20
+ square = context.tensor([[[0, 0], [1, 0], [1, 1], [0, 1]]])
21
+ diagrams, masks = context.call(persistence_diagrams, square, max_dim=1)
22
+ bars = diagrams[1][masks[1]]
23
+ # Zero-length intervals do not change persistence and may be retained by a
24
+ # future reduction backend. The check concerns positive-lifetime H1 only.
25
+ bars = bars[bars[:, 1] > bars[:, 0]]
26
+ expected = context.tensor([[1, 2**0.5]])
27
+ passed = bars.shape == expected.shape and torch.allclose(bars, expected)
28
+ return {"passed": bool(passed), "h1": bars, "expected_h1": expected}
29
+
30
+
31
+ @torch.no_grad()
32
+ def compute(request: Request) -> Request:
33
+ """Return bars and all radius events so slider movement needs no recompute.
34
+
35
+ Reduction in the dependency is sequential per batch member and uses CPU
36
+ bookkeeping even with GPU tensors. Counts below use [birth, death), including
37
+ all simplices with diameter <= ε at a tied filtration event.
38
+
39
+ References:
40
+ Edelsbrunner, Letscher and Zomorodian (2002), Topological persistence
41
+ and simplification. Discrete & Computational Geometry 28:511-533.
42
+ """
43
+ context = Context.from_request(request)
44
+ points = cloud(request, context)
45
+ distances = context.call(pairwise_distances, points[None])[0]
46
+ if not bool(torch.isfinite(distances).all()):
47
+ raise ValueError(
48
+ "pairwise distances overflowed; use smaller coordinates or float64"
49
+ )
50
+ diagrams, masks = context.call(persistence_diagrams, points[None], max_dim=1)
51
+ bars = [diagram[0, mask[0]] for diagram, mask in zip(diagrams, masks, strict=True)]
52
+ indices = torch.arange(len(points), device=context.device)
53
+ edges = torch.combinations(indices, r=2)
54
+ triangles = torch.combinations(indices, r=3)
55
+ edge_radii = distances[edges[:, 0], edges[:, 1]]
56
+ triangle_radii = torch.stack(
57
+ (
58
+ distances[triangles[:, 0], triangles[:, 1]],
59
+ distances[triangles[:, 0], triangles[:, 2]],
60
+ distances[triangles[:, 1], triangles[:, 2]],
61
+ ),
62
+ -1,
63
+ ).amax(-1)
64
+ events = torch.unique(torch.cat((context.tensor([0]), edge_radii)), sorted=True)
65
+ betti = torch.stack(
66
+ [
67
+ (
68
+ (bar[None, :, 0] <= events[:, None])
69
+ & (events[:, None] < bar[None, :, 1])
70
+ ).sum(-1)
71
+ for bar in bars
72
+ ],
73
+ -1,
74
+ )
75
+ finite_h1 = bars[1][torch.isfinite(bars[1][:, 1])]
76
+ lifetimes = finite_h1[:, 1] - finite_h1[:, 0]
77
+ finite_h0_deaths = bars[0][torch.isfinite(bars[0][:, 1]), 1]
78
+ if lifetimes.numel() and bool((lifetimes > 0).any()):
79
+ # Start inside the longest-lived finite loop, not at an empty complex.
80
+ longest = finite_h1[lifetimes.argmax()]
81
+ initial_epsilon = longest[0] + (longest[1] - longest[0]) / 2
82
+ initial_reason = "Midpoint of the most persistent finite H1 interval"
83
+ elif finite_h0_deaths.numel():
84
+ initial_epsilon = finite_h0_deaths.quantile(0.5)
85
+ initial_reason = "Median finite H0 death"
86
+ else:
87
+ initial_epsilon = context.tensor(0)
88
+ initial_reason = "No finite intervals; starting at zero"
89
+ initial_event = torch.searchsorted(events, initial_epsilon, right=True) - 1
90
+ reference = reference_check(context)
91
+ warnings = (
92
+ []
93
+ if reference["passed"]
94
+ else [
95
+ "This library checkout fails the square H1 reference: it emits extra "
96
+ "infinite bars. The bars and Betti counts shown here are provisional "
97
+ "library results, not corrected by the explorer."
98
+ ]
99
+ )
100
+ return context.finish(
101
+ points=points,
102
+ distances=distances,
103
+ bars=bars,
104
+ edges=edges,
105
+ edge_radii=edge_radii,
106
+ triangles=triangles,
107
+ triangle_radii=triangle_radii,
108
+ events=events,
109
+ betti=betti,
110
+ initial_epsilon=initial_epsilon,
111
+ initial_event=initial_event,
112
+ initial_reason=initial_reason,
113
+ edge_counts=(edge_radii[None] <= events[:, None]).sum(-1),
114
+ triangle_counts=(triangle_radii[None] <= events[:, None]).sum(-1),
115
+ reference_check=reference,
116
+ warnings=warnings,
117
+ parameters={key: value for key, value in request.items() if key != "points"},
118
+ )
@@ -0,0 +1,9 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """PyTorch primitives for geometry, graph spectra, and tensor computations.
5
+
6
+ Subpackages: algebra, convex, discrete_geometry, experimental, hyperbolic,
7
+ info_geometry, lie, manifolds, optimization, sheaf, simplicial, spectral,
8
+ tensor_calculus, and topology.
9
+ """
@@ -0,0 +1,52 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Internal graph-primitive utilities shared across modules.
5
+
6
+ Conventions enforced here (see `CONVENTIONS.md`):
7
+
8
+ Self-loop policy
9
+ ----------------
10
+ Graph primitives in this library treat the input adjacency `A` as a
11
+ simple graph: the diagonal `A[i, i]` is **dropped at entry**, not
12
+ treated as an edge from node `i` to itself.
13
+
14
+ Rationale: most of the math (Forman-Ricci, Ollivier-Ricci, the
15
+ random-walk and symmetric-normalized Laplacians, magnetic Laplacian)
16
+ is defined on simple graphs in the source literature. Including a
17
+ self-loop changes `d_i = Σ_j A[i, j]` and every quantity built from
18
+ it (normalizers, walk distributions, phase factors). If each
19
+ primitive left the diagonal to the caller, the same input could give
20
+ different answers depending on whether that primitive happened to
21
+ cancel it, so the policy is applied once, at entry.
22
+
23
+ The combinatorial Laplacian `L = D − A` happens to be invariant to
24
+ self-loops (the diagonal subtraction zeroes them), but we still drop
25
+ them at entry so callers see consistent behavior across the library.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import torch
31
+
32
+
33
+ def drop_self_loops(A: torch.Tensor) -> torch.Tensor:
34
+ """Return a tensor with the diagonal of `A` zeroed.
35
+
36
+ Args:
37
+ A: (..., n, n) adjacency-like tensor.
38
+
39
+ Returns:
40
+ Tensor with the same shape as `A`, identical to `A` everywhere
41
+ except `result[..., i, i] = 0` for all i.
42
+
43
+ Notes:
44
+ Uses `torch.where`, so the zeroing is one vectorized op over the
45
+ batch. The boolean eye mask leaves the value dtype of `A` unchanged.
46
+
47
+ References:
48
+ Chung (1997). Spectral Graph Theory. AMS, Chapter 1, simple graphs.
49
+ """
50
+ n = A.shape[-1]
51
+ eye = torch.eye(n, device=A.device, dtype=torch.bool).expand_as(A)
52
+ return torch.where(eye, torch.zeros_like(A), A)
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Cached Gauss-Legendre quadrature nodes (internal).
5
+
6
+ `scipy.special.roots_legendre` recomputes the rule on every call (the
7
+ Golub-Welsch tridiagonal eigensolve plus Newton refinement); it does NOT
8
+ cache (only `scipy.integrate.fixed_quad`'s private wrapper does). The
9
+ library's quadrature call sites (`manifolds.comparison.model_ball_volume`,
10
+ `hyperbolic.heat_kernel`) sit inside batched paths where the same `n_quad`
11
+ recurs on every invocation, so the recompute plus the numpy-to-torch
12
+ round-trip is pure per-call overhead. This module keeps one float64 CPU
13
+ master copy per `n_quad` and hands out dtype/device casts.
14
+
15
+ Callers must treat the returned tensors as read-only: when the requested
16
+ dtype/device match the master copy, `Tensor.to` returns the cached tensor
17
+ itself, not a copy.
18
+
19
+ References:
20
+ Golub, G. H., Welsch, J. H. (1969). Calculation of Gauss quadrature
21
+ rules. Mathematics of Computation 23(106):221-230. (The algorithm
22
+ underlying `scipy.special.roots_legendre`.)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import functools
28
+
29
+ import torch
30
+ from scipy.special import roots_legendre
31
+
32
+
33
+ @functools.cache
34
+ def _gauss_legendre_float64_cpu(n_quad: int) -> tuple[torch.Tensor, torch.Tensor]:
35
+ """Master copy: float64 CPU `(nodes, weights)` of the `n_quad`-point rule.
36
+
37
+ Input is `n_quad >= 1`. Output is two float64 CPU tensors of shape
38
+ `(n_quad,)`. The cache key is `n_quad` alone, so every caller gets the
39
+ same tensors and none of them may write into them.
40
+ """
41
+ nodes_np, weights_np = roots_legendre(n_quad)
42
+ return (
43
+ torch.as_tensor(nodes_np, dtype=torch.float64),
44
+ torch.as_tensor(weights_np, dtype=torch.float64),
45
+ )
46
+
47
+
48
+ def gauss_legendre_nodes(
49
+ n_quad: int, *, dtype: torch.dtype, device: torch.device,
50
+ ) -> tuple[torch.Tensor, torch.Tensor]:
51
+ """`(nodes, weights)` of the `n_quad`-point Gauss-Legendre rule on (-1, 1).
52
+
53
+ The float64 CPU master is computed once per `n_quad` (`functools.cache`); the
54
+ per-call `.to(dtype, device)` is a cheap cast/copy (no-op when they match).
55
+
56
+ Args:
57
+ n_quad: node count of the rule.
58
+ dtype: floating dtype of the returned tensors.
59
+ device: device of the returned tensors.
60
+
61
+ Returns:
62
+ `(nodes, weights)`, each `(n_quad,)`, read-only by convention.
63
+
64
+ References:
65
+ Golub-Welsch (1969); see module docstring.
66
+ """
67
+ nodes, weights = _gauss_legendre_float64_cpu(n_quad)
68
+ # The cast comes last so the cache always holds float64 on CPU, whatever
69
+ # the caller asked for. When the request matches the master, `.to` hands
70
+ # back the cached tensor itself, which is why callers must not mutate it.
71
+ return (
72
+ nodes.to(dtype=dtype, device=device),
73
+ weights.to(dtype=dtype, device=device),
74
+ )
@@ -0,0 +1,32 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """holonomy_lib.algebra: audit-clean wrappers around torch.linalg + extras.
5
+
6
+ Currently implemented:
7
+ linear.truncated_svd: batched truncated SVD (exact + randomized).
8
+ lanczos_eigsh: largest/smallest algebraic eigenpairs of dense or
9
+ sparse A; an explicit sigma (with which="SA")
10
+ targets nearby modes.
11
+ Full subspaces use torch.linalg.eigh.
12
+ Sparse SA uses the backends in eigsh_sparse
13
+ (bounded-pole SciPy eigsh / preconditioned
14
+ SciPy LOBPCG / torch.lobpcg).
15
+ eigsh_sparse.eigsh_sparse_sa: the sparse smallest-k backend, callable
16
+ directly for backend control.
17
+ solvers.cg: conjugate gradient for SPD systems: batched
18
+ dense, sparse, or callable-operator form.
19
+ solvers.lsqr: iterative (damped) least squares via
20
+ Golub-Kahan bidiagonalization.
21
+
22
+ Planned:
23
+ Einstein notation helpers, group representations (cyclic, dihedral),
24
+ Lie algebras (su(n), so(n)), exterior algebra (wedge products).
25
+ """
26
+
27
+ from holonomy_lib.algebra.eigsh_sparse import eigsh_sparse_sa
28
+ from holonomy_lib.algebra.lanczos import lanczos_eigsh
29
+ from holonomy_lib.algebra.linear import truncated_svd
30
+ from holonomy_lib.algebra.solvers import cg, lsqr
31
+
32
+ __all__ = ["cg", "eigsh_sparse_sa", "lanczos_eigsh", "lsqr", "truncated_svd"]
@@ -0,0 +1,251 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Smallest-algebraic eigenpairs of sparse real symmetric matrices.
5
+
6
+ By default, CPU inputs use SciPy shift-invert eigsh or preconditioned LOBPCG,
7
+ and other devices use torch.lobpcg. The SciPy boundary computes in CPU float64
8
+ and casts the result back. All backends are nondifferentiable. A Gershgorin
9
+ lower bound places the default shift-invert pole below the spectrum, including
10
+ for indefinite inputs. Full requests, and LOBPCG requests with n < 3k, use
11
+ dense eigh.
12
+
13
+ References:
14
+ Ericsson, T., Ruhe, A. (1980). The spectral transformation Lanczos
15
+ method for the numerical solution of large sparse generalized
16
+ symmetric eigenvalue problems. Math. Comp. 35(152):1251-1268.
17
+ Knyazev, A. V. (2001). Toward the optimal preconditioned eigensolver:
18
+ locally optimal block preconditioned conjugate gradient method.
19
+ SIAM J. Sci. Comput. 23(2):517-541.
20
+ Saad, Y. (2011). Numerical Methods for Large Eigenvalue Problems,
21
+ 2nd ed. SIAM, §3.2 (spectral transformation).
22
+ Golub, G. H., Van Loan, C. F. (2013). Matrix Computations, 4th ed.
23
+ Johns Hopkins University Press, §7.2 (Gershgorin bounds).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ from typing import Literal
30
+
31
+ import torch
32
+
33
+ # Existing throughput threshold from substrate's sheaf_scale.py (2026-06).
34
+ # Its measurements concern Laplacian-like CPU sparsity, not every operator.
35
+ SPARSE_SA_ITER_SOLVER_N: int = 8000
36
+
37
+ # Existing cataloged convergence/preconditioner knobs from sheaf_scale.py.
38
+ SPILU_EPS_FRAC: float = 1e-3
39
+ SPILU_DROP_TOL: float = 1e-3
40
+ SPILU_FILL_FACTOR: int = 10
41
+ LOBPCG_TOL: float = 1e-6
42
+ LOBPCG_MAXITER: int = 100
43
+
44
+ # LOBPCG works in a Ritz subspace built from three blocks: the current
45
+ # vectors, their residuals, and the previous search directions. That subspace
46
+ # has up to 3k columns, so it needs n >= 3k. Smaller problems go to dense eigh.
47
+ LOBPCG_SUBSPACE_BLOCKS: int = len(("iterate", "residual", "search_direction"))
48
+
49
+ _SPARSE_LAYOUTS = (torch.sparse_coo, torch.sparse_csr, torch.sparse_csc)
50
+ Solver = Literal["auto", "eigsh", "lobpcg", "torch_lobpcg"]
51
+
52
+
53
+ def eigsh_sparse_sa(
54
+ A: torch.Tensor,
55
+ k: int,
56
+ sigma: float | None = None,
57
+ solver: Solver = "auto",
58
+ generator: torch.Generator | None = None,
59
+ ) -> tuple[torch.Tensor, torch.Tensor]:
60
+ """Return the smallest algebraic eigenpairs of sparse symmetric ``A``.
61
+
62
+ Args:
63
+ A: real sparse ``(n, n)`` COO/CSR/CSC. Asymmetry emits a warning.
64
+ Grad-requiring inputs raise an error; detach explicitly to use
65
+ this nondifferentiable solver.
66
+ k: number of eigenpairs, ``1 <= k <= n``. Full requests, and LOBPCG
67
+ requests with n < 3k, use dense eigh because the iterative subspace
68
+ does not fit.
69
+ sigma: optional eigsh pole, strictly below the Gershgorin lower
70
+ bound. The default is that bound minus a dtype-scaled margin.
71
+ LOBPCG does not use a pole; explicit sigma is rejected there.
72
+ solver: ``auto`` selects torch.lobpcg off CPU, SciPy eigsh on CPU
73
+ up to ``SPARSE_SA_ITER_SOLVER_N``, then SciPy LOBPCG with shifted
74
+ incomplete-LU preconditioning. The three explicit names are
75
+ ``eigsh``, ``lobpcg`` and ``torch_lobpcg``.
76
+ generator: random-start generator compatible with ``A.device``.
77
+ Used by every iterative backend, including native LOBPCG.
78
+
79
+ Returns:
80
+ Ascending eigenvalues ``(k,)`` and orthonormal eigenvector columns
81
+ ``(n, k)``, on ``A.device`` with ``A.dtype``. SciPy computes in
82
+ float64 on CPU; its transfer is an explicit backend boundary.
83
+
84
+ Notes:
85
+ Native LOBPCG is unpreconditioned; clustered eigenvalues can need
86
+ more iterations than the fixed cataloged limit. SciPy's shifted
87
+ ILU preconditioner is intended for Laplacian-like sparse problems.
88
+
89
+ References:
90
+ Ericsson and Ruhe (1980); Knyazev (2001); Saad (2011), §3.2;
91
+ Golub and Van Loan (2013), §7.2. Full citations above.
92
+ """
93
+ if A.ndim != 2 or A.shape[0] != A.shape[1]:
94
+ raise ValueError(f"sparse A must be (n, n); got {A.shape}")
95
+ if A.layout not in _SPARSE_LAYOUTS:
96
+ raise ValueError("A must be a sparse tensor (COO/CSR/CSC)")
97
+ if not A.is_floating_point():
98
+ raise ValueError("A must have a real floating dtype")
99
+ n = A.shape[0]
100
+ if not isinstance(k, int) or isinstance(k, bool) or not 1 <= k <= n:
101
+ raise ValueError(f"k must satisfy 1 <= k <= n={n}; got k={k}")
102
+ if solver not in ("auto", "eigsh", "lobpcg", "torch_lobpcg"):
103
+ raise ValueError("solver must be auto, eigsh, lobpcg or torch_lobpcg")
104
+ if A.requires_grad:
105
+ raise ValueError(
106
+ "sparse eigensolvers do not support autograd; detach A explicitly"
107
+ )
108
+ if sigma is not None and not math.isfinite(sigma):
109
+ raise ValueError("sigma must be finite")
110
+ from holonomy_lib.algebra.lanczos import _warn_asymmetric
111
+
112
+ _warn_asymmetric(A)
113
+ # Coalesce first so duplicate entries are summed before the Gershgorin sums
114
+ # below read the values. A nonfinite entry would poison the bounds and the
115
+ # SciPy conversion, so it is rejected here.
116
+ coo = A.to_sparse_coo().coalesce()
117
+ if not torch.isfinite(coo.values()).all():
118
+ raise ValueError("A must have finite entries")
119
+ # Auto: non-CPU devices take torch.lobpcg. On CPU, eigsh's sparse LU is used
120
+ # up to SPARSE_SA_ITER_SOLVER_N, past which its fill-in grows too fast.
121
+ if solver == "auto":
122
+ if A.device.type != "cpu":
123
+ solver = "torch_lobpcg"
124
+ elif n <= SPARSE_SA_ITER_SOLVER_N:
125
+ solver = "eigsh"
126
+ else:
127
+ solver = "lobpcg"
128
+ if sigma is not None and solver != "eigsh":
129
+ raise ValueError("sigma is only used by the eigsh backend")
130
+
131
+ # Gershgorin discs: row i is centered on its diagonal entry, with radius
132
+ # equal to the sum of its off-diagonal magnitudes. Every eigenvalue lies in
133
+ # some disc, so the leftmost disc edge, `lower`, bounds the spectrum from
134
+ # below. `scale` is the largest |center| + radius and sizes the margins.
135
+ row, column = coo.indices()
136
+ entries = coo.values()
137
+ diagonal = entries.new_zeros(n).scatter_add(0, row, entries * (row == column))
138
+ radius = entries.new_zeros(n).scatter_add(0, row, entries.abs() * (row != column))
139
+ lower = (diagonal - radius).amin()
140
+ scale = (diagonal.abs() + radius).amax()
141
+ if sigma is not None and not bool(sigma < lower):
142
+ raise ValueError("sigma must be strictly below the Gershgorin lower bound")
143
+ # With scale zero the default pole lands exactly on the zero spectrum, so
144
+ # shift-invert would factor a singular matrix. Every vector is an
145
+ # eigenvector of the zero operator, so the first k identity columns work.
146
+ if not bool(scale):
147
+ return entries.new_zeros(k), torch.eye(n, k, dtype=A.dtype, device=A.device)
148
+ # Dense eigh when there is no room for an iterative subspace: k == n, or
149
+ # LOBPCG with n below its 3k requirement.
150
+ if k == n or (solver != "eigsh" and n < LOBPCG_SUBSPACE_BLOCKS * k):
151
+ values, vectors = torch.linalg.eigh(A.to_dense())
152
+ return values[:k], vectors[:, :k]
153
+
154
+ if solver == "torch_lobpcg":
155
+ initial = torch.randn(n, k, dtype=A.dtype, device=A.device, generator=generator)
156
+ values, vectors = torch.lobpcg(
157
+ coo,
158
+ k=k,
159
+ X=initial,
160
+ largest=False,
161
+ tol=LOBPCG_TOL,
162
+ niter=LOBPCG_MAXITER,
163
+ )
164
+ # torch.lobpcg does not promise an order, so sort to return ascending
165
+ # values like the other backends.
166
+ order = values.argsort()
167
+ return values[order], vectors[:, order]
168
+
169
+ import numpy as np
170
+ import scipy.sparse as sp
171
+ import scipy.sparse.linalg as spla
172
+
173
+ # SciPy works in host float64. The COO data is copied out here, and the
174
+ # results are cast back to A's device and dtype on return.
175
+ indices_np = coo.indices().cpu().numpy()
176
+ A_sp = sp.coo_matrix(
177
+ (entries.to(torch.float64).cpu().numpy(), (indices_np[0], indices_np[1])),
178
+ shape=(n, n),
179
+ ).tocsr()
180
+ if solver == "eigsh":
181
+ if sigma is None:
182
+ # The nextafter step keeps the pole strictly below the bound
183
+ # even if subtraction rounds back to the bound at this scale.
184
+ pole_tensor = lower - torch.finfo(A.dtype).eps ** 0.5 * scale
185
+ pole = float(
186
+ torch.nextafter(pole_tensor, torch.full_like(lower, -torch.inf))
187
+ )
188
+ else:
189
+ pole = sigma
190
+ # ARPACK's random start, drawn on A's device and moved to host float64.
191
+ initial_vector = (
192
+ torch.randn(
193
+ n,
194
+ device=A.device,
195
+ dtype=A.dtype,
196
+ generator=generator,
197
+ )
198
+ .to(torch.float64)
199
+ .cpu()
200
+ .numpy()
201
+ )
202
+ # Shift-invert with the pole below the spectrum. ARPACK's largest inverse
203
+ # eigenvalues are the eigenvalues of A nearest the pole. Every eigenvalue
204
+ # is above the pole, so those are the k smallest.
205
+ values_np, vectors_np = spla.eigsh(
206
+ A_sp,
207
+ k=k,
208
+ sigma=pole,
209
+ which="LM",
210
+ v0=initial_vector,
211
+ )
212
+ else:
213
+ # Shift below the Gershgorin bound. The matrix being factored, A - shift I,
214
+ # is then positive definite even when A is indefinite, and LOBPCG's
215
+ # preconditioner is its incomplete LU.
216
+ shift = float(lower - SPILU_EPS_FRAC * scale)
217
+ ilu = spla.spilu(
218
+ (A_sp - shift * sp.identity(n, format="csc")).tocsc(),
219
+ drop_tol=SPILU_DROP_TOL,
220
+ fill_factor=SPILU_FILL_FACTOR,
221
+ )
222
+ # The preconditioner applies the ILU solve, which approximates (A - shift I)^-1.
223
+ preconditioner = spla.LinearOperator((n, n), matvec=ilu.solve)
224
+ initial_block = (
225
+ torch.randn(
226
+ n,
227
+ k,
228
+ device=A.device,
229
+ dtype=A.dtype,
230
+ generator=generator,
231
+ )
232
+ .to(torch.float64)
233
+ .cpu()
234
+ .numpy()
235
+ )
236
+ values_np, vectors_np = spla.lobpcg(
237
+ A_sp,
238
+ initial_block,
239
+ M=preconditioner,
240
+ largest=False,
241
+ tol=LOBPCG_TOL,
242
+ maxiter=LOBPCG_MAXITER,
243
+ )
244
+ # The backends don't promise an order, so sort ascending before returning.
245
+ order_np = np.argsort(values_np)
246
+ return (
247
+ torch.from_numpy(values_np[order_np].copy()).to(device=A.device, dtype=A.dtype),
248
+ torch.from_numpy(vectors_np[:, order_np].copy()).to(
249
+ device=A.device, dtype=A.dtype
250
+ ),
251
+ )