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,184 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Regularized (Tikhonov / ridge) least squares with solver dispatch.
5
+
6
+ synoros-substrate hand-rolls this one convex problem four inconsistent
7
+ ways (normal-equation `solve`, `lstsq`, sparse LSQR, dense pseudoinverse),
8
+ so it lives here once:
9
+
10
+ min_x ‖A x − b‖² + λ ‖x‖²
11
+
12
+ Three solution paths, one interface:
13
+
14
+ - `"cholesky"`: normal equations `(AᵀA + λI) x = Aᵀb` via Cholesky
15
+ factorization + triangular solve (CONVENTIONS §3.3). Exact, one
16
+ factorization, the default for dense problems. It squares
17
+ the condition number, so for ill-conditioned `A` with tiny `λ`
18
+ prefer `"lsqr"`.
19
+ - `"cg"`: conjugate gradient on the normal operator `v ↦ Aᵀ(Av) + λv`.
20
+ `AᵀA` is never materialized. For very large dense problems where
21
+ the `(n, n)` Gram matrix itself is the memory bottleneck.
22
+ - `"lsqr"`: `algebra.solvers.lsqr` with `damp = √λ` (the damped
23
+ least-squares equivalence, Paige-Saunders 1982 §1). Works directly
24
+ on `A` (condition number not squared) and on sparse `A`.
25
+
26
+ `method="auto"` dispatch rule (benchmark-derived, see
27
+ `notes/benchmark_2026-07-08_solvers.md`): sparse `A` → `"lsqr"`
28
+ (Cholesky needs a dense Gram matrix); dense `A` → `"cholesky"`. In the
29
+ sweep, Cholesky dominated the iterative methods by 3-24x up to n=256
30
+ and stayed within noise of them (±25%) at n=1024-2048; its runtime is
31
+ also condition-number-independent (fixed flops), while CG/LSQR
32
+ iteration counts grow with `√cond`. No crossing justifies a size
33
+ threshold, so there is none to tune.
34
+
35
+ References:
36
+ Tikhonov, A. N. (1963). Solution of incorrectly formulated problems
37
+ and the regularization method. Soviet Math. Dokl. 4:1035-1038.
38
+ Björck, Å. (1996). Numerical Methods for Least Squares Problems.
39
+ SIAM, §2.7 (regularized LS, normal-equations vs damped-LSQR
40
+ trade-offs).
41
+ Paige-Saunders (1982). ACM TOMS 8(1):43-71, §1 (LSQR's damp
42
+ parameter solves the ridge problem).
43
+ Boyd, S., Vandenberghe, L. (2004). Convex Optimization. Cambridge
44
+ UP, §6.3.2 (ridge/Tikhonov as a convex regularized approximation
45
+ problem).
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import math
51
+ from typing import Literal
52
+
53
+ import torch
54
+
55
+ from holonomy_lib.algebra.solvers import cg, lsqr
56
+
57
+ _SPARSE_LAYOUTS = (
58
+ torch.sparse_coo, torch.sparse_csr, torch.sparse_csc,
59
+ )
60
+
61
+ Method = Literal["auto", "cholesky", "cg", "lsqr"]
62
+
63
+
64
+ def regularized_lstsq(
65
+ A: torch.Tensor,
66
+ b: torch.Tensor,
67
+ lam: float,
68
+ method: Method = "auto",
69
+ rtol: float | None = None,
70
+ maxiter: int | None = None,
71
+ ) -> torch.Tensor:
72
+ """Solve the ridge problem `min_x ‖A x − b‖² + λ ‖x‖²`.
73
+
74
+ Strictly convex for `λ > 0` (unique solution regardless of `A`'s
75
+ rank); for `λ = 0` reduces to ordinary least squares and requires
76
+ full column rank on the `"cholesky"` path (`"lsqr"` handles the
77
+ rank-deficient case, converging to the minimum-norm solution).
78
+
79
+ Args:
80
+ A: dense `(m, n)` / `(B, m, n)`, or sparse 2-D `(m, n)`
81
+ (COO/CSR/CSC; dispatched to `"lsqr"`/`"cg"`; `"cholesky"`
82
+ rejects sparse input).
83
+ b: `(B, m)` or `(m,)` observations.
84
+ lam: ridge weight `λ ≥ 0`.
85
+ method: `"auto"` (see module docstring for the dispatch rule),
86
+ `"cholesky"`, `"cg"`, or `"lsqr"`.
87
+ rtol: convergence tolerance for the iterative paths (forwarded to
88
+ `cg` as `rtol`, to `lsqr` as `atol`/`btol`); ignored by
89
+ `"cholesky"`. Default `sqrt(eps(dtype))` (derived).
90
+ maxiter: iteration cap for the iterative paths; ignored by
91
+ `"cholesky"`.
92
+
93
+ Returns:
94
+ `x`: `(B, n)` (or `(n,)` for unbatched `b`), minimizing the ridge
95
+ objective.
96
+
97
+ References:
98
+ Tikhonov (1963); Björck (1996) §2.7; Paige-Saunders (1982) §1.
99
+ See the module docstring.
100
+ """
101
+ if method not in ("auto", "cholesky", "cg", "lsqr"):
102
+ raise ValueError(
103
+ f"method must be one of 'auto', 'cholesky', 'cg', 'lsqr'; "
104
+ f"got method={method!r}"
105
+ )
106
+ if not isinstance(A, torch.Tensor):
107
+ raise ValueError(f"A must be a torch.Tensor; got {type(A)}")
108
+ if lam < 0:
109
+ raise ValueError(f"lam must be >= 0; got {lam}")
110
+
111
+ sparse = A.layout in _SPARSE_LAYOUTS
112
+ # Cholesky forms a dense Gram matrix, so under "auto" sparse A never
113
+ # takes that route.
114
+ if method == "auto":
115
+ method = "lsqr" if sparse else "cholesky"
116
+ if sparse and method == "cholesky":
117
+ raise ValueError(
118
+ "method='cholesky' requires dense A (it factorizes the "
119
+ "dense Gram matrix); use 'lsqr' or 'cg' for sparse input"
120
+ )
121
+
122
+ if method == "cholesky":
123
+ if A.ndim < 2:
124
+ raise ValueError(
125
+ f"dense A must be (..., m, n); got A.shape={tuple(A.shape)}"
126
+ )
127
+ n = A.shape[-1]
128
+ # A lone right-hand side (m,) is lifted to (1, m) so the batched code
129
+ # below handles it. The batch axis is dropped again at the return, but
130
+ # only when A is unbatched too: a batched A of size 1 has to keep its
131
+ # batch axis, or the output shape would depend on the batch size.
132
+ squeeze = b.ndim == 1 and A.ndim == 2
133
+ b2 = b.unsqueeze(0) if squeeze else b
134
+ gram = A.mT @ A # (..., n, n)
135
+ eye = torch.eye(n, dtype=A.dtype, device=A.device)
136
+ # λI makes S positive definite even when A is rank deficient. Cholesky
137
+ # can therefore only fail for λ = 0 with a singular AᵀA, which is the
138
+ # full-column-rank requirement in the docstring.
139
+ S = gram + lam * eye
140
+ Atb = (A.mT @ b2.unsqueeze(-1)).squeeze(-1) # (..., n) or (B, n)
141
+ L = torch.linalg.cholesky(S)
142
+ x = torch.cholesky_solve(Atb.unsqueeze(-1), L).squeeze(-1)
143
+ return x.squeeze(0) if squeeze and x.ndim > 1 else x
144
+
145
+ if method == "lsqr":
146
+ # Damped LSQR minimizes ‖Ax − b‖² + damp²‖x‖², so damp = √λ gives the
147
+ # ridge objective exactly.
148
+ return lsqr(
149
+ A, b, damp=math.sqrt(lam),
150
+ atol=rtol, btol=rtol, maxiter=maxiter,
151
+ )
152
+
153
+ # method == "cg": CG on the normal operator v ↦ Aᵀ(Av) + λv. The
154
+ # Gram matrix is never materialized; `A` may be dense or sparse.
155
+ if sparse:
156
+ # One COO copy of A and one of Aᵀ, built once here, so every matvec
157
+ # inside cg is a sparse mm rather than a fresh conversion.
158
+ A_coo = (A if A.layout == torch.sparse_coo
159
+ else A.to_sparse_coo()).coalesce()
160
+ At = A_coo.t().coalesce()
161
+
162
+ def normal_op(v: torch.Tensor) -> torch.Tensor:
163
+ Av = torch.sparse.mm(A_coo, v.mT).mT
164
+ return torch.sparse.mm(At, Av.mT).mT + lam * v
165
+
166
+ def rproject(u: torch.Tensor) -> torch.Tensor:
167
+ return torch.sparse.mm(At, u.mT).mT
168
+ else:
169
+
170
+ def normal_op(v: torch.Tensor) -> torch.Tensor:
171
+ Av = (A @ v.unsqueeze(-1)).squeeze(-1)
172
+ return (A.mT @ Av.unsqueeze(-1)).squeeze(-1) + lam * v
173
+
174
+ def rproject(u: torch.Tensor) -> torch.Tensor:
175
+ return (A.mT @ u.unsqueeze(-1)).squeeze(-1)
176
+
177
+ # A lone b is lifted to (1, m) and squeezed back at the end, as in the
178
+ # cholesky path, again only when A has no batch axis of its own. Sparse A
179
+ # is always a single 2-D matrix. The right-hand side of the normal
180
+ # equations is Aᵀb.
181
+ squeeze = b.ndim == 1 and A.ndim == 2
182
+ b2 = b.unsqueeze(0) if squeeze else b
183
+ x = cg(normal_op, rproject(b2), rtol=rtol, maxiter=maxiter)
184
+ return x.squeeze(0) if squeeze else x
@@ -0,0 +1,38 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Discrete graph curvature and edge-weight Ricci flow.
5
+
6
+ Available primitives:
7
+ ollivier_ricci_curvature: Ollivier-Ricci curvature on every node pair,
8
+ from entropic (Sinkhorn) transport.
9
+ forman_ricci_simple: weighted graph Forman curvature with unit vertices.
10
+ forman_ricci_augmented: unit-weight Forman curvature with triangle terms.
11
+ discrete_ricci_flow: curvature-driven edge-weight updates.
12
+ ricci_flow_with_surgery: flow with threshold-based edge removal.
13
+
14
+ References:
15
+ Ollivier (2009), Ricci curvature of Markov chains on metric spaces.
16
+ Sreejith et al. (2016), Forman curvature for complex networks.
17
+ Samal et al. (2018), Comparative analysis of two discretizations of
18
+ Ricci curvature for complex networks.
19
+ Ni et al. (2019), Community detection on networks with Ricci flow.
20
+ """
21
+
22
+ from holonomy_lib.discrete_geometry.forman import (
23
+ forman_ricci_augmented,
24
+ forman_ricci_simple,
25
+ )
26
+ from holonomy_lib.discrete_geometry.ricci import (
27
+ discrete_ricci_flow,
28
+ ollivier_ricci_curvature,
29
+ ricci_flow_with_surgery,
30
+ )
31
+
32
+ __all__ = [
33
+ "discrete_ricci_flow",
34
+ "forman_ricci_augmented",
35
+ "forman_ricci_simple",
36
+ "ollivier_ricci_curvature",
37
+ "ricci_flow_with_surgery",
38
+ ]
@@ -0,0 +1,242 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Forman-Ricci curvature on batched graphs.
5
+
6
+ Forman (2003) defined a combinatorial Ricci curvature for general CW
7
+ complexes that specializes to a very simple per-edge formula on graphs.
8
+ Unlike Ollivier-Ricci (`ricci.ollivier_ricci_curvature`), no optimal-
9
+ transport solve is needed: each edge's curvature is a closed-form
10
+ function of the local degrees and, for the augmented form, the
11
+ triangles on that edge. Both forms are cheaper than a Sinkhorn solve
12
+ per edge.
13
+
14
+ Forman-Ricci is less geometrically faithful than Ollivier on weighted
15
+ metric measure spaces, but it tracks the same qualitative bottleneck
16
+ structure: edges bridging two clusters get negative curvature, edges
17
+ embedded in dense sub-communities get positive curvature. For networks
18
+ too large for Ollivier, Forman-Ricci is a common substitute (Sreejith
19
+ et al. 2016).
20
+
21
+ Cost note: the implementations here are dense. The simple form is
22
+ O(B · n²) and the augmented form is O(B · n³), the cost of the
23
+ triangle-count matmul A @ A. A sparse edge-list path would scale with
24
+ |E| instead of n², but it is not implemented. Memory is O(B · n²) for
25
+ the dense tensors.
26
+
27
+ Two forms are exposed:
28
+
29
+ forman_ricci_simple(A)
30
+ κ_F(u, v) = w_e · ( w_u/w_e + w_v/w_e
31
+ - Σ_{e_u ≠ e} w_u/√(w_e · w_{e_u})
32
+ - Σ_{e_v ≠ e} w_v/√(w_e · w_{e_v}) )
33
+ The original CW-complex specialization without 2-faces (Sreejith
34
+ et al. 2016, eq. 1). On unweighted simple graphs with node
35
+ weights = 1 this collapses to `4 - deg(u) - deg(v)`.
36
+
37
+ forman_ricci_augmented(A)
38
+ For binary adjacency only:
39
+ κ_F^aug(u, v) = κ_F_simple(u, v) + 3 · #triangles_through(u, v)
40
+ Adds the contribution from 2-faces (triangles incident to the
41
+ edge), restoring the "augmented" form that more closely tracks
42
+ Ollivier-Ricci on dense substructures (Samal et al. 2018,
43
+ §"Augmented Forman curvature").
44
+
45
+ References:
46
+ Forman, R. (2003). Bochner's method for cell complexes and
47
+ combinatorial Ricci curvature. Discrete & Computational Geometry
48
+ 29:323–374.
49
+ Sreejith, R. P., Mohanraj, K., Jost, J., Saucan, E., Samal, A.
50
+ (2016). Forman curvature for complex networks. Journal of
51
+ Statistical Mechanics: Theory and Experiment 2016:063206. Eq. 1
52
+ (no 2-face contribution).
53
+ Samal, A., Sreejith, R. P., Gu, J., Liu, S., Saucan, E., Jost, J.
54
+ (2018). Comparative analysis of two discretizations of Ricci
55
+ curvature for complex networks. Scientific Reports 8:8650.
56
+ Equations 9–10 give the weighted and unit-weight specializations.
57
+ Sandhu, R., Georgiou, T., Tannenbaum, A. (2015). Ricci curvature: An
58
+ economic indicator for market fragility and systemic risk.
59
+ Science Advances 2(5):e1501495. Application demonstrating Forman's
60
+ qualitative agreement with Ollivier at lower compute cost.
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ import warnings
66
+
67
+ import torch
68
+
69
+ from holonomy_lib._graph_utils import drop_self_loops
70
+
71
+ # Unit vertex weights in the graph specialization of Sreejith et al.
72
+ # (2016), equation 1. Edge weights alone do not encode general vertex
73
+ # weights, so the public functions fix every vertex weight to one.
74
+ FORMAN_NODE_WEIGHT_DEFAULT: float = 1.0
75
+
76
+ # Samal et al. (2018), equations 9–10: with unit vertex, edge, and face
77
+ # weights, an incident triangular face contributes one face term and
78
+ # removes two negative parallel-edge contributions, giving 1 + 2 = 3.
79
+ FORMAN_TRIANGLE_CONTRIBUTION: float = 3.0
80
+
81
+
82
+ def forman_ricci_simple(A: torch.Tensor) -> torch.Tensor:
83
+ """Forman-Ricci curvature on every edge of a batched weighted graph.
84
+
85
+ Implements the Sreejith et al. (2016) form without 2-face
86
+ contributions:
87
+
88
+ κ_F(u, v) = w_e · ( w_u/w_e + w_v/w_e
89
+ - Σ_{e' ∋ u, e' ≠ e} w_u / √(w_e · w_{e'})
90
+ - Σ_{e' ∋ v, e' ≠ e} w_v / √(w_e · w_{e'}) )
91
+
92
+ For unweighted simple graphs (w_u = w_v = 1, w_e ∈ {0, 1}) this
93
+ collapses to `4 - deg(u) - deg(v)`: positive on edges in sparse
94
+ contexts (low degree), negative on edges anchored in dense hubs.
95
+
96
+ Args:
97
+ A: (B, n, n) symmetric weighted adjacency. Non-negative weights.
98
+ Zero off-diagonal means no edge. Diagonal is ignored.
99
+
100
+ Returns:
101
+ κ_F: (B, n, n) curvature tensor. κ_F[..., u, v] is the Forman-
102
+ Ricci curvature of edge (u, v) when A[..., u, v] > 0, and 0
103
+ otherwise. The diagonal is set to 0 by convention, since Forman
104
+ curvature is defined per edge and a self-loop is not an edge here.
105
+
106
+ Notes:
107
+ Cost is O(B · n²). An edge-list variant would cost O(B · |E|) on
108
+ sparse graphs, but it is not implemented.
109
+
110
+ References:
111
+ Sreejith et al. (2016), eq. 1.
112
+ Forman (2003), §"Combinatorial Ricci curvature for graphs".
113
+ """
114
+ _validate_adjacency(A)
115
+ A = drop_self_loops(A)
116
+ *batch, n, _ = A.shape
117
+
118
+ # Edge positions as a 0/1 tensor. Staying in tensor form keeps Python
119
+ # from branching on tensor values, which would force a device-to-host
120
+ # sync. Non-edges contribute zero to every sum below.
121
+ edge_mask = (A != 0).to(A.dtype) # (B, n, n)
122
+
123
+ # Node weights are fixed at 1 (Sreejith et al. 2016). Weighted nodes
124
+ # would need these passed in; for now they are constants.
125
+ w_u = FORMAN_NODE_WEIGHT_DEFAULT
126
+ w_v = FORMAN_NODE_WEIGHT_DEFAULT
127
+
128
+ # Replace non-edge zeros with ones before any division. rsqrt(0) is inf,
129
+ # and inf times the zero mask is NaN. The mask zeroes the non-edge
130
+ # entries of kappa afterward.
131
+ safe_we = torch.where(edge_mask > 0, A, torch.ones_like(A))
132
+
133
+ # The two diagonal terms of the formula, w_u / w_e + w_v / w_e.
134
+ diagonal_term = (w_u + w_v) / safe_we # (B, n, n)
135
+
136
+ # Neighbor sums. The u-side term for edge (u, v) is
137
+ # w_u / √w_e · Σ_{u' ≠ v} 1/√A[u, u'], so one row sum of inv_sqrt_A
138
+ # gives every neighbor sum. Non-edges are zero in inv_sqrt_A, and the
139
+ # (u, v) entry itself is subtracted out below.
140
+ inv_sqrt_A = torch.where(
141
+ edge_mask > 0, torch.rsqrt(safe_we), torch.zeros_like(A),
142
+ ) # (B, n, n)
143
+
144
+ # row_sum is (B, n, 1) and holds each node's neighbor sum; col_sum is
145
+ # (B, 1, n) and holds the same sums laid out by column. Broadcast, they
146
+ # give position (u, v) both u's sum and v's sum. Subtracting
147
+ # inv_sqrt_A[u, v] removes the edge from its own sums.
148
+ row_sum = inv_sqrt_A.sum(dim=-1, keepdim=True) # (B, n, 1)
149
+ col_sum = inv_sqrt_A.sum(dim=-2, keepdim=True) # (B, 1, n)
150
+ u_neighbor_sum = row_sum - inv_sqrt_A # (B, n, n)
151
+ v_neighbor_sum = col_sum - inv_sqrt_A # (B, n, n)
152
+ u_side = w_u * inv_sqrt_A * u_neighbor_sum # (B, n, n)
153
+ v_side = w_v * inv_sqrt_A * v_neighbor_sum # (B, n, n)
154
+
155
+ # Non-edges come out as zero through the edge_mask factor.
156
+ kappa = safe_we * (diagonal_term - u_side - v_side) * edge_mask
157
+
158
+ # The diagonal is already zero, since edge_mask is zero there after
159
+ # drop_self_loops. The explicit where keeps that true without relying
160
+ # on the mask.
161
+ eye = torch.eye(n, device=A.device, dtype=A.dtype).expand_as(A)
162
+ kappa = torch.where(eye > 0, torch.zeros_like(kappa), kappa)
163
+ return kappa
164
+
165
+
166
+ def forman_ricci_augmented(A: torch.Tensor) -> torch.Tensor:
167
+ """Augmented Forman-Ricci curvature on an unweighted graph.
168
+
169
+ With unit vertex, edge, and triangular-face weights, Samal et al.
170
+ (2018), equations 9–10, give
171
+
172
+ kappa_aug(u, v) = kappa_simple(u, v) + 3 * #triangles(u, v).
173
+
174
+ Each face contributes one positive term and removes two negative
175
+ parallel-edge terms. Weighted edges require a face-weight convention
176
+ and the full weighted expression, which this function does not expose.
177
+
178
+ Args:
179
+ A: (B, n, n) symmetric real floating binary adjacency. Zero means
180
+ no edge and one means an edge. The diagonal is ignored.
181
+
182
+ Returns:
183
+ (B, n, n) curvature, zero on the diagonal and nonedges.
184
+
185
+ Raises:
186
+ ValueError: if any off-diagonal weight is neither zero nor one.
187
+ The shape, dtype, and sign checks in `_validate_adjacency` also raise.
188
+
189
+ References:
190
+ Samal, A., et al. (2018). Comparative analysis of two discretizations
191
+ of Ricci curvature for complex networks. Scientific Reports
192
+ 8:8650, equations 9–10.
193
+ """
194
+ _validate_adjacency(A)
195
+ A = drop_self_loops(A)
196
+ if ((A != 0) & (A != 1)).any():
197
+ raise ValueError(
198
+ "forman_ricci_augmented requires binary (unweighted) adjacency; "
199
+ "weighted curvature needs an explicit face-weight convention "
200
+ "(Samal et al. 2018, equations 9–10)."
201
+ )
202
+ # After drop_self_loops, (A @ A)[u, v] counts the nodes adjacent to both
203
+ # u and v, which is the number of triangles on edge (u, v) when A is
204
+ # binary. Multiplying by A keeps only real edges, since non-edges get no
205
+ # curvature.
206
+ triangle_count = (A @ A) * A
207
+ return forman_ricci_simple(A) + FORMAN_TRIANGLE_CONTRIBUTION * triangle_count
208
+
209
+
210
+ # ============================================================
211
+ # Internal helpers
212
+ # ============================================================
213
+
214
+
215
+ def _validate_adjacency(A: torch.Tensor) -> None:
216
+ """Check that A is a real (..., n, n) tensor of non-negative edge weights.
217
+
218
+ Input: A, with its diagonal ignored.
219
+ Output: None. Raises ValueError on a bad shape, a non-floating dtype,
220
+ or a nonfinite or negative off-diagonal entry. Warns, without raising,
221
+ when A is not symmetric within a small absolute tolerance.
222
+ """
223
+ if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
224
+ raise ValueError(
225
+ f"A must be (..., n, n); got A.shape={tuple(A.shape)}"
226
+ )
227
+ if not A.is_floating_point():
228
+ raise ValueError("A must have a real floating dtype")
229
+ A = drop_self_loops(A)
230
+ if not torch.isfinite(A).all() or (A < 0).any():
231
+ raise ValueError(
232
+ "Forman-Ricci is defined for non-negative edge weights; "
233
+ "nonfinite or negative entries detected."
234
+ )
235
+ # Symmetry check matches the convention in `ollivier_ricci_curvature`.
236
+ if not torch.allclose(A, A.mT, atol=1e-9, rtol=0):
237
+ warnings.warn(
238
+ "forman_ricci received an asymmetric adjacency; Forman-Ricci is "
239
+ "only well-defined for symmetric (undirected) graphs. Symmetrize "
240
+ "A first (e.g. `0.5 * (A + A.mT)`) for meaningful results.",
241
+ stacklevel=2,
242
+ )