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,855 @@
1
+ # SPDX-License-Identifier: FSL-1.1-ALv2
2
+ # Copyright 2026 Synoros
3
+
4
+ """Batched Ollivier-Ricci curvature and discrete Ricci flow on graphs.
5
+
6
+ The metric is shortest-path distance with adjacency weights interpreted as
7
+ edge lengths. Those same weights determine the neighbor probabilities of
8
+ mu_x = (1-alpha) A[x, :] / sum(A[x, :]) + alpha delta_x. Curvature is
9
+ kappa(x, y) = 1 - W_1(mu_x, mu_y) / d(x, y).
10
+
11
+ Stabilized Sinkhorn on neighborhood supports approximates transport with
12
+ entropic regularization and warm-started epsilon scaling. A small
13
+ regularization does not guarantee convergence:
14
+ callers control the iteration budget and marginal tolerance, and receive
15
+ a warning when the returned transport plan fails that tolerance.
16
+
17
+ Flow updates edge lengths by w <- (1 - dt*kappa) w. The surgery variant
18
+ removes edges above a fixed multiple of their initial mean weight.
19
+ Floyd-Warshall, Sinkhorn, and flow steps are sequential recurrences;
20
+ the arithmetic within each step is batched on PyTorch.
21
+
22
+ References:
23
+ Ollivier, Y. (2009). Ricci curvature of Markov chains on metric
24
+ spaces. Journal of Functional Analysis 256(3):810–864.
25
+ Lin, Y., Lu, L., Yau, S.-T. (2011). Ricci curvature of graphs.
26
+ Tohoku Mathematical Journal 63(4):605–627. The rescaled alpha -> 1
27
+ limit is the Lin-Lu-Yau curvature, not computed by this module.
28
+ Cuturi, M. (2013). Sinkhorn distances: lightspeed computation of
29
+ optimal transport. Advances in Neural Information Processing
30
+ Systems 26:2292–2300.
31
+ Schmitzer, B. (2019). Stabilized sparse scaling algorithms for entropy
32
+ regularized transport problems. SIAM Journal on Scientific Computing
33
+ 41(3):A1443–A1481, Algorithms 2 and 3.
34
+ Sia, J., Jonckheere, E., Bogdan, P. (2019). Ollivier-Ricci
35
+ curvature-based method to community detection in complex networks.
36
+ Scientific Reports 9:9800.
37
+ Ni, C.-C., Lin, Y.-Y., Luo, F., Gao, J. (2019). Community detection
38
+ on networks with Ricci flow. Scientific Reports 9:9984.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import math
44
+ import warnings
45
+
46
+ import torch
47
+
48
+ from holonomy_lib._graph_utils import drop_self_loops
49
+
50
+ # Chosen with the iteration budget and marginal tolerance below against exact
51
+ # LP transport on karate, paths, cycles, cliques, a clique ring, a weighted
52
+ # random graph and a stochastic block graph (2026-10-25). Maximum edge-curvature
53
+ # error was below 1e-2. This is in edge-length units: the validation used unit
54
+ # lengths and weights drawn in (0, 1) or [0.5, 1.5], not arbitrary rescalings.
55
+ SINKHORN_REG_DEFAULT: float = 0.003
56
+
57
+ # Total scaling sweeps, including epsilon warmup. The validation graphs above
58
+ # converged within this budget; tiles stop as soon as all their pairs finish.
59
+ # This is an empirical budget, not a convergence guarantee for other inputs.
60
+ SINKHORN_N_ITER_DEFAULT: int = 65536
61
+
62
+ # Maximum absolute error in either marginal, chosen alongside reg and n_iter
63
+ # in the exact-W1 validation above. It permits float32 roundoff while keeping
64
+ # the observed curvature error below 1e-2; it does not bound entropic bias.
65
+ SINKHORN_TOL_DEFAULT: float = 1e-4
66
+
67
+ # Schmitzer (2019), Algorithm 3, allows a geometric epsilon schedule. We halve
68
+ # epsilon only once a stage meets the stage tolerance (the caller's tol, or
69
+ # SINKHORN_TOL_DEFAULT if the caller's is tighter). That way a small mass
70
+ # deficit is not carried into a colder problem. A stricter caller tolerance is
71
+ # held back for the target epsilon, since oversolving the warmup stages can use
72
+ # up the budget before we reach the problem the caller asked for.
73
+ SINKHORN_EPSILON_DECAY: float = 0.5
74
+
75
+ # Schmitzer (2019), section 5, uses tau=100 for absorption. We compare the
76
+ # accumulated log scaling against log(tau) to decide when to rebuild a
77
+ # normalized kernel from the dual potentials.
78
+ SINKHORN_ABSORPTION_THRESHOLD: float = 100.0
79
+
80
+ # Sync cadence for Sinkhorn convergence checks. The `.item()` call on the
81
+ # marginal-error tensor forces a host sync on GPU, and doing it every iteration
82
+ # adds tens of µs per iteration waiting on the kernel queue. Checking every 8
83
+ # iterations overshoots true convergence by at most 8 sweeps while cutting the
84
+ # host sync count by 8×.
85
+ # **Scale of validity**: a dimensionless cadence, so it does not need
86
+ # re-derivation across input sizes or precisions. Cataloged as
87
+ # `sinkhorn_sync_every_default`.
88
+ SINKHORN_SYNC_EVERY_DEFAULT: int = 8
89
+
90
+ # Default tile size (in pairs) for the batched Sinkhorn computation.
91
+ # Tiling caps simultaneous support-cost matrices at B*tile_size*s*s, where
92
+ # s <= max_degree+1. At s=256 one float64 temporary is 128 MiB per batch
93
+ # member; sparse graphs use less. Cataloged as `sinkhorn_tile_default`.
94
+ SINKHORN_TILE_DEFAULT: int = 256
95
+
96
+ # Default surgery period for `ricci_flow_with_surgery`: surgery runs once
97
+ # every N Ricci-flow steps. Ni-Lin-Luo-Gao (2019) §3.2 use values in
98
+ # [10, 15] for community detection; we default to 10 as the more
99
+ # aggressive choice. Catalog: `ricci_flow_surgery_period_default`.
100
+ RICCI_FLOW_SURGERY_PERIOD_DEFAULT: int = 10
101
+
102
+ # Default surgery threshold, as a multiple of the initial mean edge weight.
103
+ # Edges whose weight grows past this multiple are removed as forming
104
+ # neckpinches. Sia-Jonckheere-Bogdan (2019) and Ni et al. (2019) both use
105
+ # thresholds around 2-3× initial weight. The conservative end, 3.0, lets
106
+ # only clearly stretched edges go.
107
+ # Catalog: `ricci_flow_surgery_threshold_default`.
108
+ RICCI_FLOW_SURGERY_THRESHOLD_DEFAULT: float = 3.0
109
+
110
+
111
+ def ollivier_ricci_curvature(
112
+ A: torch.Tensor,
113
+ alpha: float = 0.0,
114
+ reg: float = SINKHORN_REG_DEFAULT,
115
+ n_iter: int = SINKHORN_N_ITER_DEFAULT,
116
+ tol: float = SINKHORN_TOL_DEFAULT,
117
+ tile_size: int = SINKHORN_TILE_DEFAULT,
118
+ sync_every: int = SINKHORN_SYNC_EVERY_DEFAULT,
119
+ ) -> torch.Tensor:
120
+ """Pairwise Ollivier-Ricci curvature on a batched weighted graph.
121
+
122
+ Args:
123
+ A: (B, n, n) real floating, symmetric non-negative adjacency.
124
+ Positive entries are edge lengths, used directly in shortest
125
+ paths and also as weights in the neighbor walk probabilities.
126
+ Zero off-diagonal entries mean no edge; self-loops are ignored.
127
+ alpha: laziness in [0, 1].
128
+ reg: positive entropic regularization, in the units of edge length.
129
+ n_iter: positive maximum scaling sweeps, including epsilon warmup.
130
+ tol: positive absolute tolerance for both transport marginals.
131
+ This checks feasibility, not the entropic approximation bias.
132
+ tile_size: positive number of node pairs per Sinkhorn tile.
133
+ sync_every: positive interval between marginal checks that sync
134
+ with the host. The final iteration is always checked.
135
+
136
+ Returns:
137
+ (B, n, n) curvature tensor, with diagonal 1 by convention and
138
+ cross-component entries 0. Cross-component curvature is undefined;
139
+ zero is an output convention, not a transport result.
140
+
141
+ Notes:
142
+ A warning reports the achieved marginal error if the iteration
143
+ budget is exhausted without meeting tol. The returned approximation
144
+ can then be infeasible. Zero-probability entries (self when alpha is 0)
145
+ carry no transport mass.
146
+ Padded supports contain at most s=max_degree+1 entries. Symmetry
147
+ permits solving each unordered pair once. Transport costs
148
+ O(B*n**2*s**2*n_iter), with O(B*tile_size*s**2) temporary storage;
149
+ Floyd-Warshall costs O(B*n**3). Scaling sweeps and shortest-path
150
+ recurrence steps are sequential; batches and pairs are vectorized.
151
+ Epsilon decreases from each pair's cost scale to reg, preserving
152
+ dimensional dual potentials. Convergence is checked only at reg.
153
+ The defaults were checked on unit-length and order-one weighted
154
+ graphs; rescale reg with edge lengths to retain comparable bias.
155
+ Very small reg on weighted graphs can still stall despite epsilon
156
+ scaling. Large iteration budgets alone do not ensure practical
157
+ convergence; heed the marginal-error warning in that regime.
158
+
159
+ References:
160
+ Ollivier (2009), Definition 1.
161
+ Cuturi (2013), Algorithm 1, for entropic Sinkhorn transport.
162
+ Schmitzer (2019), Algorithms 2 and 3, for stabilized scaling and
163
+ warm starts across decreasing regularization parameters.
164
+ """
165
+ return _curvature(A, alpha, reg, n_iter, tol, tile_size, sync_every)
166
+
167
+
168
+ # ============================================================
169
+ # Discrete Ricci flow (Perelman-on-networks)
170
+ # ============================================================
171
+
172
+
173
+ def discrete_ricci_flow(
174
+ A: torch.Tensor,
175
+ n_steps: int,
176
+ dt: float = 1.0,
177
+ alpha: float = 0.0,
178
+ normalize: bool = True,
179
+ reg: float = SINKHORN_REG_DEFAULT,
180
+ n_sinkhorn_iters: int = SINKHORN_N_ITER_DEFAULT,
181
+ sinkhorn_tol: float = SINKHORN_TOL_DEFAULT,
182
+ sinkhorn_tile_size: int = SINKHORN_TILE_DEFAULT,
183
+ sinkhorn_sync_every: int = SINKHORN_SYNC_EVERY_DEFAULT,
184
+ ) -> torch.Tensor:
185
+ """Discrete Ricci flow on edge weights (Sia 2019, Ni 2019).
186
+
187
+ Iterates the edge-weight update
188
+
189
+ w_ij(t+1) = (1 − dt · κ_ij(t)) · w_ij(t)
190
+
191
+ where κ_ij is the Ollivier-Ricci curvature at edge (i, j). Edges
192
+ with negative curvature (κ < 0) get stretched (multiplier > 1);
193
+ edges with positive curvature (κ > 0) get shortened. Over many
194
+ steps this is the discrete analog of Perelman's smooth Ricci flow
195
+ on networks: bottleneck edges between communities elongate while
196
+ intra-community edges contract. Steps are sequential because each
197
+ curvature computation uses the preceding step's edge lengths.
198
+
199
+ Args:
200
+ A: (B, n, n) symmetric weighted adjacency. Non-negative weights.
201
+ n_steps: number of flow iterations.
202
+ dt: step size. Each step multiplies an edge by (1 - dt * kappa).
203
+ alpha: laziness parameter, as in `ollivier_ricci_curvature`.
204
+ normalize: if True, rescale edge weights after each step so their
205
+ Frobenius norm stays at its initial value. Without this, global
206
+ scaling can drift, and normalizing isolates the structural effect
207
+ of the flow.
208
+ reg, n_sinkhorn_iters, sinkhorn_tol, sinkhorn_tile_size,
209
+ sinkhorn_sync_every: forwarded to the curvature computation.
210
+ The sync cadence controls how often marginal checks reach the host.
211
+
212
+ Returns:
213
+ (B, n, n) adjacency after `n_steps` of flow.
214
+
215
+ Notes:
216
+ Only existing edge pairs need transport solves. As lengths evolve,
217
+ even the default budget can be exhausted; a convergence warning means
218
+ that step used an infeasible transport approximation. Increase the
219
+ budget or reconsider reg in the evolved graph's edge-length units.
220
+
221
+ References:
222
+ Sia, J., Jonckheere, E., Bogdan, P. (2019). Ollivier-Ricci
223
+ curvature-based method to community detection in complex
224
+ networks. Scientific Reports 9:9800, eq. 3.
225
+ Ni, C.-C., Lin, Y.-Y., Luo, F., Gao, J. (2019). Community
226
+ detection on networks with Ricci flow. Scientific Reports
227
+ 9:9984.
228
+ """
229
+ W = _validate_flow_inputs(
230
+ A, n_steps, dt, alpha, reg, n_sinkhorn_iters, sinkhorn_tol,
231
+ sinkhorn_tile_size, sinkhorn_sync_every,
232
+ )
233
+ if normalize:
234
+ initial_norm = torch.linalg.matrix_norm(W, dim=(-2, -1), keepdim=True)
235
+
236
+ for _ in range(n_steps):
237
+ # Only the existing edges get updated, so only their transports are
238
+ # solved. Non-edge entries of kappa come back as placeholders that
239
+ # the edge mask below throws away.
240
+ kappa = _curvature(
241
+ W, alpha, reg, n_sinkhorn_iters, sinkhorn_tol,
242
+ sinkhorn_tile_size, sinkhorn_sync_every, pair_mask=W > 0,
243
+ )
244
+ # w *= (1 - dt * kappa) on existing edges only. Non-edges are exactly
245
+ # zero (weights are non-negative), so they stay zero and the flow never
246
+ # creates an edge. An edge whose weight reaches zero stays gone.
247
+ edge_mask = (W > 0).to(W.dtype)
248
+ update = (1.0 - dt * kappa) * edge_mask
249
+ W = W * update
250
+ # kappa <= 1 always (W_1 is non-negative), so the multiplier
251
+ # 1 - dt * kappa can only go negative when dt > 1. A negative length has
252
+ # no meaning, so the clamp sets that edge to zero.
253
+ W = W.clamp(min=0)
254
+ # Curvature is symmetric in exact arithmetic; averaging with the
255
+ # transpose stops float drift from making the graph directed.
256
+ W = 0.5 * (W + W.mT)
257
+ if normalize:
258
+ current_norm = torch.linalg.matrix_norm(
259
+ W, dim=(-2, -1), keepdim=True,
260
+ )
261
+ # If the step removed every edge, the norm is zero and the division
262
+ # would give NaN. Dividing by one keeps the all-zero graph at zero.
263
+ # Any positive norm, however small, is used as it is.
264
+ current_norm = torch.where(
265
+ current_norm == 0, torch.ones_like(current_norm), current_norm,
266
+ )
267
+ W = W * (initial_norm / current_norm)
268
+ return W
269
+
270
+
271
+ def ricci_flow_with_surgery(
272
+ A: torch.Tensor,
273
+ n_steps: int,
274
+ surgery_period: int = RICCI_FLOW_SURGERY_PERIOD_DEFAULT,
275
+ surgery_threshold: float = RICCI_FLOW_SURGERY_THRESHOLD_DEFAULT,
276
+ dt: float = 1.0,
277
+ alpha: float = 0.0,
278
+ normalize: bool = True,
279
+ reg: float = SINKHORN_REG_DEFAULT,
280
+ n_sinkhorn_iters: int = SINKHORN_N_ITER_DEFAULT,
281
+ sinkhorn_tol: float = SINKHORN_TOL_DEFAULT,
282
+ sinkhorn_tile_size: int = SINKHORN_TILE_DEFAULT,
283
+ sinkhorn_sync_every: int = SINKHORN_SYNC_EVERY_DEFAULT,
284
+ ) -> torch.Tensor:
285
+ """Discrete Ricci flow with threshold-based edge surgery.
286
+
287
+ Each step updates w <- (1 - dt*kappa) w, clamps negative weights to
288
+ zero, and optionally restores the initial Frobenius norm. Every
289
+ surgery_period steps, edges above surgery_threshold times the initial
290
+ mean edge weight are removed. The reference mean excludes self-loops.
291
+ Steps are sequential because curvature depends on the current graph.
292
+ Only existing edge pairs need transport solves. Evolving edge lengths
293
+ can exhaust the transport budget even at defaults; a convergence warning
294
+ means that step used an infeasible approximation, as in discrete_ricci_flow.
295
+
296
+ Args:
297
+ A: (B, n, n) symmetric weighted adjacency.
298
+ n_steps: total flow iterations.
299
+ surgery_period: perform surgery every N steps. Catalog default.
300
+ surgery_threshold: edge-removal threshold, as a multiplier of
301
+ initial mean edge weight. Catalog default. Note that when
302
+ `normalize=True`, the cutoff uses the **pre-normalization**
303
+ initial mean as a fixed reference; the per-step normalization
304
+ rescales the weights but the threshold does not move with it.
305
+ Its effect therefore depends on `dt`, `normalize`, and the
306
+ curvature magnitudes; choose the threshold for the input scale.
307
+ Other args: as in `discrete_ricci_flow`.
308
+
309
+ Returns:
310
+ (B, n, n) adjacency after flow and surgery. Components that surgery
311
+ disconnects are the candidate communities; nothing here checks that
312
+ they are.
313
+
314
+ References:
315
+ Sia, J., Jonckheere, E., Bogdan, P. (2019). Ollivier-Ricci
316
+ curvature-based method to community detection in complex networks.
317
+ Scientific Reports 9:9800.
318
+ Ni, C.-C., Lin, Y.-Y., Luo, F., Gao, J. (2019). Community detection
319
+ on networks with Ricci flow. Scientific Reports 9:9984, §3.2.
320
+ """
321
+ W = _validate_flow_inputs(
322
+ A, n_steps, dt, alpha, reg, n_sinkhorn_iters, sinkhorn_tol,
323
+ sinkhorn_tile_size, sinkhorn_sync_every,
324
+ )
325
+ if not isinstance(surgery_period, int) or surgery_period <= 0:
326
+ raise ValueError(
327
+ f"surgery_period must be a positive integer, got {surgery_period}"
328
+ )
329
+ if not math.isfinite(surgery_threshold) or surgery_threshold <= 0:
330
+ raise ValueError(
331
+ f"surgery_threshold must be finite and > 0, got {surgery_threshold}"
332
+ )
333
+
334
+ # Initial mean edge weight is the fixed scale we threshold against. Both
335
+ # (i, j) and (j, i) are counted, which gives the same mean on a symmetric
336
+ # graph. The clamp keeps an edgeless graph from dividing by zero.
337
+ edge_mask_init = (W > 0).to(W.dtype)
338
+ n_edges = edge_mask_init.sum(dim=(-2, -1), keepdim=True).clamp(min=1.0)
339
+ initial_mean = (W * edge_mask_init).sum(
340
+ dim=(-2, -1), keepdim=True,
341
+ ) / n_edges
342
+ cutoff = surgery_threshold * initial_mean
343
+
344
+ if normalize:
345
+ initial_norm = torch.linalg.matrix_norm(
346
+ W, dim=(-2, -1), keepdim=True,
347
+ )
348
+
349
+ for step in range(n_steps):
350
+ # Same flow step as discrete_ricci_flow; see the comments there.
351
+ kappa = _curvature(
352
+ W, alpha, reg, n_sinkhorn_iters, sinkhorn_tol,
353
+ sinkhorn_tile_size, sinkhorn_sync_every, pair_mask=W > 0,
354
+ )
355
+ edge_mask = (W > 0).to(W.dtype)
356
+ update = (1.0 - dt * kappa) * edge_mask
357
+ W = W * update
358
+ W = W.clamp(min=0)
359
+ W = 0.5 * (W + W.mT)
360
+ if normalize:
361
+ current_norm = torch.linalg.matrix_norm(
362
+ W, dim=(-2, -1), keepdim=True,
363
+ )
364
+ current_norm = torch.where(
365
+ current_norm == 0, torch.ones_like(current_norm), current_norm,
366
+ )
367
+ W = W * (initial_norm / current_norm)
368
+
369
+ # Surgery: remove edges that have stretched past the cutoff. `step + 1`
370
+ # counts flow steps from one, so surgery runs after step number
371
+ # surgery_period, 2 * surgery_period, and so on. With surgery_period = 1
372
+ # it runs after every step, the first one included.
373
+ if (step + 1) % surgery_period == 0:
374
+ keep = W <= cutoff
375
+ W = W * keep.to(W.dtype)
376
+ W = 0.5 * (W + W.mT)
377
+
378
+ return W
379
+
380
+
381
+ # ============================================================
382
+ # Internal helpers
383
+ # ============================================================
384
+
385
+
386
+ def _curvature(
387
+ A: torch.Tensor,
388
+ alpha: float,
389
+ reg: float,
390
+ n_iter: int,
391
+ tol: float,
392
+ tile_size: int,
393
+ sync_every: int,
394
+ pair_mask: torch.Tensor | None = None,
395
+ ) -> torch.Tensor:
396
+ """Compute Ollivier-Ricci curvature, solving transport only where asked.
397
+
398
+ Takes the public arguments plus an optional (..., n, n) boolean pair_mask
399
+ and returns the (..., n, n) curvature. Public curvature needs every pair,
400
+ so it passes no mask. Flow only uses existing edges, so it passes W > 0
401
+ and skips transports that cannot affect its update. A reachable pair
402
+ outside the mask gets W1 = 0 and so kappa = 1, a placeholder the caller
403
+ must mask out.
404
+ """
405
+ A = _validate_curvature_inputs(
406
+ A, alpha, reg, n_iter, tol, tile_size, sync_every,
407
+ )
408
+ # An empty batch or a zero-node graph has nothing to solve.
409
+ if A.numel() == 0:
410
+ return A.clone()
411
+
412
+ *batch, n, _ = A.shape
413
+
414
+ # 1. Shortest-path distances d_G(i, j). Floyd-Warshall on the
415
+ # weighted graph, batched. Treat A[i,j]=0 (off-diagonal) as no edge.
416
+ d_G, reachable = _shortest_path_distances(A) # (B, n, n)
417
+
418
+ # 2. Lazy-random-walk distributions μ_x for each node x.
419
+ # μ_x(y) = (1−α) · (A[x,y] / Σ_z A[x,z]) for y in N(x)
420
+ # μ_x(x) = α, or 1 if x is isolated (then μ_x = δ_x)
421
+ mu = _lazy_walk_distributions(A, alpha) # (B, n, n), μ_x is row x
422
+
423
+ # 3. Sinkhorn-based W_1 between μ_x and μ_y for each pair (x, y) that
424
+ # `reachable` (and `pair_mask`, if given) selects. The solver works on
425
+ # each node's walk support and reads the costs between supports from d_G
426
+ # (see _batched_sinkhorn_w1).
427
+ W1 = _batched_sinkhorn_w1(
428
+ mu, d_G, reg=reg, n_iter=n_iter, tol=tol, tile_size=tile_size,
429
+ sync_every=sync_every,
430
+ reachable=reachable if pair_mask is None else reachable & pair_mask,
431
+ ) # (B, n, n)
432
+
433
+ # 4. Curvature κ(x, y) = 1 - W_1 / d_G.
434
+ # d_G is 0 on the diagonal and holds a 0 placeholder between components.
435
+ # Dividing by 1 there keeps the arithmetic finite; both cases are
436
+ # overwritten below, the diagonal with 1 by convention and cross-component
437
+ # pairs with 0.
438
+ safe_dG = torch.where(d_G > 0, d_G, torch.ones_like(d_G))
439
+ kappa = 1.0 - W1 / safe_dG
440
+ eye = torch.eye(n, device=A.device, dtype=A.dtype).expand(*batch, n, n)
441
+ kappa = torch.where(eye > 0, torch.ones_like(kappa), kappa)
442
+ return torch.where(reachable, kappa, torch.zeros_like(kappa))
443
+
444
+
445
+
446
+ def _validate_curvature_inputs(
447
+ A: torch.Tensor,
448
+ alpha: float,
449
+ reg: float,
450
+ n_iter: int,
451
+ tol: float,
452
+ tile_size: int,
453
+ sync_every: int,
454
+ ) -> torch.Tensor:
455
+ """Check the curvature arguments and return A with its self-loops removed.
456
+
457
+ Raises ValueError on a bad shape, dtype, negative or non-finite length,
458
+ or out-of-range parameter. An asymmetric A only warns, since the caller
459
+ may have a near-symmetric input; curvature here assumes undirected graphs.
460
+ """
461
+ if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
462
+ raise ValueError(f"A must be (..., n, n); got A.shape={tuple(A.shape)}")
463
+ if not A.is_floating_point():
464
+ raise ValueError("A must have a real floating dtype")
465
+ A = drop_self_loops(A)
466
+ if not torch.isfinite(A).all() or (A < 0).any():
467
+ raise ValueError("A must have finite non-negative edge lengths")
468
+ if not 0 <= alpha <= 1:
469
+ raise ValueError(f"alpha must be in [0, 1], got {alpha}")
470
+ for name, value in (("reg", reg), ("tol", tol)):
471
+ if not math.isfinite(value) or value <= 0:
472
+ raise ValueError(f"{name} must be finite and > 0, got {value}")
473
+ for name, count in (
474
+ ("n_iter", n_iter), ("tile_size", tile_size), ("sync_every", sync_every),
475
+ ):
476
+ if not isinstance(count, int) or count <= 0:
477
+ raise ValueError(f"{name} must be a positive integer, got {count}")
478
+ if not torch.allclose(A, A.mT, atol=1e-9, rtol=0):
479
+ warnings.warn(
480
+ "ollivier_ricci_curvature received an asymmetric adjacency; "
481
+ "curvature is only defined here for undirected graphs. "
482
+ "Symmetrize A first for meaningful results.",
483
+ stacklevel=2,
484
+ )
485
+ return A
486
+
487
+
488
+ def _validate_flow_inputs(
489
+ A: torch.Tensor,
490
+ n_steps: int,
491
+ dt: float,
492
+ alpha: float,
493
+ reg: float,
494
+ n_iter: int,
495
+ tol: float,
496
+ tile_size: int,
497
+ sync_every: int,
498
+ ) -> torch.Tensor:
499
+ """Check the flow arguments and return A with its self-loops removed.
500
+
501
+ Runs the curvature checks too, so a zero-step call still rejects bad
502
+ input instead of passing it straight back.
503
+ """
504
+ A = _validate_curvature_inputs(A, alpha, reg, n_iter, tol, tile_size, sync_every)
505
+ if not isinstance(n_steps, int) or n_steps < 0:
506
+ raise ValueError(f"n_steps must be a non-negative integer, got {n_steps}")
507
+ if not math.isfinite(dt) or dt <= 0:
508
+ raise ValueError(f"dt must be finite and > 0, got {dt}")
509
+ return A
510
+
511
+
512
+ def _shortest_path_distances(A: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
513
+ """Return Floyd-Warshall distances and a reachability mask.
514
+
515
+ Input: A, (..., n, n) non-negative adjacency; its diagonal is ignored.
516
+ Output: (distances, reachable). Unreachable distances are set to zero, so
517
+ the mask is the only record of which pairs are unreachable. The vertex
518
+ recurrence is sequential, with batches and endpoint pairs handled by
519
+ tensor operations. Assumes an undirected graph, so reachability is
520
+ symmetric.
521
+ """
522
+ *batch, n, _ = A.shape
523
+ # Starting distances: the edge length A[i,j] where there is an edge, inf
524
+ # where there is none, and 0 on the diagonal.
525
+ eye = torch.eye(n, device=A.device, dtype=A.dtype).expand(*batch, n, n)
526
+ off_diag = (eye == 0)
527
+ no_edge = off_diag & (A == 0)
528
+ # Infinity distinguishes missing paths while the recurrence runs.
529
+ inf_sentinel = torch.full_like(A, float("inf"))
530
+ D = torch.where(no_edge, inf_sentinel, A.clone())
531
+ D = torch.where(eye > 0, torch.zeros_like(D), D)
532
+
533
+ # Floyd-Warshall: for k in 0..n-1: D[i,j] = min(D[i,j], D[i,k] + D[k,j]).
534
+ # Batched: vectorize over (i, j); k is the loop axis.
535
+ for k in range(n):
536
+ # via_k[..., i, j] = D[..., i, k] + D[..., k, j]
537
+ via_k = D[..., :, k : k + 1] + D[..., k : k + 1, :]
538
+ D = torch.minimum(D, via_k)
539
+
540
+ # Unreachable entries become zero, so no inf is left for the transport
541
+ # arithmetic to multiply by a zero plan entry. Only supported entries of
542
+ # reachable pairs carry real costs; padded slots are masked in
543
+ # _sinkhorn_cost.
544
+ finite_mask = torch.isfinite(D)
545
+ return torch.where(finite_mask, D, torch.zeros_like(D)), finite_mask
546
+
547
+
548
+ def _lazy_walk_distributions(A: torch.Tensor, alpha: float) -> torch.Tensor:
549
+ """Lazy-random-walk distributions for every node.
550
+
551
+ Takes a (..., n, n) non-negative adjacency with its diagonal already
552
+ zeroed. Row x of the output is μ_x, where
553
+ μ_x(x) = α (or 1 if x is isolated, so μ_x = δ_x)
554
+ μ_x(y) = (1−α) · A[x,y] / Σ_z A[x,z] for y ≠ x with A[x,y]>0.
555
+ The same weights are edge lengths and walk weights, so a longer edge also
556
+ gets more walk probability.
557
+
558
+ Returns:
559
+ (..., n, n) tensor; row x is μ_x as a probability vector.
560
+ """
561
+ n = A.shape[-1]
562
+ row_sums = A.sum(dim=-1, keepdim=True) # (..., n, 1)
563
+ is_isolated = row_sums.squeeze(dim=-1) <= 0 # (..., n)
564
+
565
+ # Isolated rows have a zero sum. Dividing them by 1 leaves a zero row,
566
+ # which the isolated-node branch below replaces anyway.
567
+ safe_row_sums = torch.where(
568
+ row_sums > 0, row_sums, torch.ones_like(row_sums),
569
+ )
570
+ walk = A / safe_row_sums # (..., n, n), rows are normalized
571
+
572
+ eye = torch.eye(n, device=A.device, dtype=A.dtype).expand_as(A)
573
+ mu = (1.0 - alpha) * walk + alpha * eye
574
+
575
+ # An isolated node has nowhere to walk, so all its mass stays on itself.
576
+ # Without this its row would only carry alpha and not sum to one.
577
+ delta_rows = eye
578
+ isolated_mask = is_isolated.unsqueeze(dim=-1).expand_as(mu)
579
+ return torch.where(isolated_mask, delta_rows, mu)
580
+
581
+
582
+ def _batched_sinkhorn_w1(
583
+ mu: torch.Tensor,
584
+ cost: torch.Tensor,
585
+ reg: float,
586
+ n_iter: int,
587
+ tol: float,
588
+ tile_size: int,
589
+ sync_every: int,
590
+ reachable: torch.Tensor,
591
+ ) -> torch.Tensor:
592
+ """Gather support costs and solve unordered pairs in memory-bounded tiles.
593
+
594
+ Takes the (..., n, n) walk distributions (row x is μ_x), the (..., n, n)
595
+ shortest-path cost, and the boolean pairs to solve. Returns the
596
+ (..., n, n) transport costs. Assumes the measures are normalized rows on
597
+ an undirected graph, so W1 is symmetric and each unordered pair is solved
598
+ once. The diagonal, unselected pairs, and cross-component pairs hold zero
599
+ placeholders. Padding carries exactly zero mass, including in log space.
600
+ Warns once if any solved pair missed the marginal tolerance.
601
+ """
602
+ *batch, n, _ = mu.shape
603
+ support = (mu > 0) | torch.eye(n, device=mu.device, dtype=torch.bool)
604
+ # Pad every node's support to the widest neighborhood. That width comes
605
+ # from one shape sync, and it bounds storage by the max degree rather than
606
+ # by n. Self stays in the support even when alpha=0, with zero probability.
607
+ # topk sorts by value, so the 1s (support) come first and the 0s (padding,
608
+ # where mu is zero) come last. A tile can then slice the first tile_width
609
+ # columns.
610
+ node_width = support.sum(dim=-1)
611
+ width = int(node_width.amax().item())
612
+ indices = support.to(torch.int).topk(width, dim=-1).indices
613
+ mu = mu.gather(-1, indices)
614
+ sink_w1 = cost.new_zeros(*batch, n, n)
615
+ # Strict upper triangle: each unordered pair once, diagonal excluded.
616
+ src_indices, tgt_indices = torch.triu_indices(n, n, offset=1, device=mu.device)
617
+ if src_indices.numel() == 0:
618
+ return sink_w1
619
+ # Keep a pair if any batch member needs it; members that do not are
620
+ # masked again inside the solve by checked_pairs.
621
+ selected = reachable[..., src_indices, tgt_indices].reshape(
622
+ -1, src_indices.numel(),
623
+ ).any(dim=0)
624
+ src_indices, tgt_indices = src_indices[selected], tgt_indices[selected]
625
+ # Sort pairs by their widest support, so each tile pads only to its own
626
+ # widest pair rather than to the graph's largest degree. A pair's width is
627
+ # the max over the batch, so every batch member uses the same pair order.
628
+ pair_width = torch.maximum(
629
+ node_width[..., src_indices], node_width[..., tgt_indices],
630
+ ).reshape(math.prod(batch), -1).amax(dim=0)
631
+ order = pair_width.argsort(descending=True)
632
+ src_indices, tgt_indices = src_indices[order], tgt_indices[order]
633
+ pair_width = pair_width[order]
634
+ worst_error = 0.0
635
+
636
+ for start in range(0, src_indices.numel(), tile_size):
637
+ src_idx = src_indices[start:start + tile_size]
638
+ tgt_idx = tgt_indices[start:start + tile_size]
639
+ # Pairs are sorted widest first, so the first pair's width covers the
640
+ # whole tile.
641
+ tile_width = int(pair_width[start].item())
642
+ # Flat index i * n + j into the (n, n) cost matrix, for every pair of
643
+ # support entries: i from the source node's support, j from the target
644
+ # node's support.
645
+ cost_indices = (
646
+ indices[..., src_idx, :tile_width, None] * n
647
+ + indices[..., tgt_idx, None, :tile_width]
648
+ )
649
+ support_cost = cost.flatten(-2).gather(
650
+ -1, cost_indices.flatten(len(batch)),
651
+ ).reshape(*batch, src_idx.numel(), tile_width, tile_width)
652
+ value, error = _sinkhorn_cost(
653
+ mu[..., src_idx, :tile_width], mu[..., tgt_idx, :tile_width], support_cost,
654
+ reachable[..., src_idx, tgt_idx], reg, n_iter, tol, sync_every,
655
+ )
656
+ sink_w1[..., src_idx, tgt_idx] = value
657
+ sink_w1[..., tgt_idx, src_idx] = value
658
+ worst_error = max(worst_error, error)
659
+
660
+ if worst_error > tol:
661
+ warnings.warn(
662
+ f"Sinkhorn did not converge after {n_iter} iterations: achieved "
663
+ f"maximum marginal error {worst_error:.6g} exceeds tol={tol:.6g}. "
664
+ "Increase n_iter or reg, or choose an appropriate tolerance.",
665
+ stacklevel=2,
666
+ )
667
+ return sink_w1
668
+
669
+
670
+ def _sinkhorn_cost(
671
+ src: torch.Tensor,
672
+ tgt: torch.Tensor,
673
+ cost: torch.Tensor,
674
+ checked_pairs: torch.Tensor,
675
+ reg: float,
676
+ n_iter: int,
677
+ tol: float,
678
+ sync_every: int,
679
+ ) -> tuple[torch.Tensor, float]:
680
+ """Solve one tile of entropic transports and return their costs.
681
+
682
+ Takes (..., P, s) source and target masses on padded supports, the
683
+ (..., P, s, s) support costs, and a (..., P) mask of pairs to solve.
684
+ Returns the (..., P) transport costs (zero for unchecked pairs) and the
685
+ largest marginal error among pairs that did not meet tol, or 0.0 if all
686
+ of them did.
687
+
688
+ Schmitzer (2019), Algorithms 2 and 3: alternate matrix scaling with
689
+ log-domain absorption, and carry dimensional potentials between epsilon
690
+ stages. Each rebuild starts with a log-domain sweep using the original
691
+ costs, so an underflowed kernel entry can become positive again.
692
+ """
693
+ output_shape = src.shape[:-1]
694
+ width = src.shape[-1]
695
+ # Flatten the batch into one list of problems. `active` holds the flat
696
+ # indices of the pairs to solve; the rest keep a zero cost.
697
+ values = src.new_zeros(checked_pairs.numel())
698
+ active = torch.arange(values.numel(), device=src.device)[checked_pairs.flatten()]
699
+ src = src.reshape(-1, width)[active]
700
+ tgt = tgt.reshape(-1, width)[active]
701
+ cost = cost.reshape(-1, width, width)[active]
702
+ # Nothing to solve in this tile.
703
+ if active.numel() == 0:
704
+ return values.reshape(output_shape), 0.0
705
+ # Support is where the mass is positive. Padding and zero-probability
706
+ # entries fall outside it.
707
+ src_support, tgt_support = src > 0, tgt > 0
708
+ # Log masses, with -inf on zero-mass entries. The inner where swaps those
709
+ # zeros for 1 before the log, so log(0) is never evaluated.
710
+ log_src = torch.where(src_support, torch.where(src_support, src, 1.0).log(),
711
+ -torch.inf)
712
+ log_tgt = torch.where(tgt_support, torch.where(tgt_support, tgt, 1.0).log(),
713
+ -torch.inf)
714
+ # Padded indices can name unrelated vertices. Their costs must not affect
715
+ # the schedule, or batching graphs of different degrees changes the solve.
716
+ # Each pair's warmup starts at its own largest support cost, floored at reg.
717
+ active_cost = torch.where(
718
+ src_support.unsqueeze(-1) & tgt_support.unsqueeze(-2), cost, 0.0,
719
+ )
720
+ epsilon = active_cost.amax(dim=(-2, -1)).clamp_min(reg).unsqueeze(-1)
721
+ # A log sweep recomputes the source potential from the target potential,
722
+ # so only the latter needs to be carried between reconstructions.
723
+ g = torch.zeros_like(tgt)
724
+ kernel = torch.zeros_like(cost)
725
+ rebuild = torch.ones_like(active, dtype=torch.bool)
726
+ # Summed block norms bound the cumulative log scaling since the last
727
+ # reconstruction. This triggers absorption before either factor can
728
+ # drift by more than the threshold, even if later updates cancel.
729
+ absorbed_change = torch.zeros_like(active, dtype=src.dtype)
730
+ # Smallest positive normal float. A row or column sum at or below it counts
731
+ # as underflow. Dividing by it would blow up, so the division is clamped and
732
+ # the pair is marked unstable.
733
+ tiny = torch.finfo(src.dtype).tiny
734
+ # Tolerance for moving a pair to the next epsilon. It is the caller's tol,
735
+ # but never tighter than SINKHORN_TOL_DEFAULT.
736
+ stage_tol = max(tol, SINKHORN_TOL_DEFAULT)
737
+ marginal_error = float("inf")
738
+
739
+ for it in range(0, n_iter, sync_every):
740
+ steps = min(sync_every, n_iter - it)
741
+ # On the last block every pair moves to the requested reg, so the plan
742
+ # returned is at reg even if warmup used up the budget.
743
+ if it + steps == n_iter:
744
+ rebuild = rebuild | (epsilon.squeeze(-1) != reg)
745
+ epsilon = torch.full_like(epsilon, reg)
746
+ # Rebuild the kernel from the carried potential g at the
747
+ # current epsilon. One log-domain row sweep then column sweep, and the
748
+ # kernel becomes that sweep's plan, so its columns match tgt exactly.
749
+ rebuild_idx = torch.nonzero(rebuild).flatten()
750
+ if rebuild_idx.numel() > 0:
751
+ eps = epsilon[rebuild_idx]
752
+ log_kernel = -cost[rebuild_idx] / eps.unsqueeze(-1)
753
+ log_v = torch.where(tgt_support[rebuild_idx],
754
+ g[rebuild_idx] / eps, -torch.inf)
755
+ log_u = log_src[rebuild_idx] - torch.logsumexp(
756
+ log_kernel + log_v.unsqueeze(-2), dim=-1,
757
+ )
758
+ log_v = log_tgt[rebuild_idx] - torch.logsumexp(
759
+ log_kernel + log_u.unsqueeze(-1), dim=-2,
760
+ )
761
+ g = g.index_copy(
762
+ 0, rebuild_idx,
763
+ torch.where(tgt_support[rebuild_idx], log_v, 0.0) * eps,
764
+ )
765
+ rebuilt = (
766
+ log_u.unsqueeze(-1) + log_kernel + log_v.unsqueeze(-2)
767
+ ).exp()
768
+ kernel = kernel.index_copy(0, rebuild_idx, rebuilt)
769
+ # A rebuilt kernel carries its own scaling, so the count starts over.
770
+ absorbed_change = torch.where(rebuild, 0.0, absorbed_change)
771
+
772
+ # Between rebuilds the kernel is only rescaled. Each block multiplies it
773
+ # by new row and column factors u and v, and the scaled plan becomes the
774
+ # next kernel. Scaling cannot bring back an entry that underflowed to
775
+ # zero, so a row or column sum at or below tiny marks the pair unstable,
776
+ # and the next log-domain rebuild recomputes the kernel from the costs.
777
+ # Divisions by such sums are clamped to stay finite.
778
+ u, v = torch.ones_like(src), torch.ones_like(tgt)
779
+ stable = torch.ones_like(active, dtype=torch.bool)
780
+ for step in range(steps):
781
+ row_sum = (kernel @ v.unsqueeze(-1)).squeeze(-1)
782
+ stable = stable & ((row_sum > tiny) | ~src_support).all(-1)
783
+ u = src / row_sum.clamp_min(tiny)
784
+ # A rebuilt kernel already had this block's first sweep done in
785
+ # log space, so its first u and v stay at one.
786
+ if step == 0:
787
+ u = torch.where(rebuild.unsqueeze(-1), 1.0, u)
788
+ col_sum = (kernel.mT @ u.unsqueeze(-1)).squeeze(-1)
789
+ stable = stable & ((col_sum > tiny) | ~tgt_support).all(-1)
790
+ v = tgt / col_sum.clamp_min(tiny)
791
+ if step == 0:
792
+ v = torch.where(rebuild.unsqueeze(-1), 1.0, v)
793
+ # Absorb the block's scaling. v goes into g in potential units; u is
794
+ # not stored, since the next rebuild recomputes it from g. A pair
795
+ # whose scaling went unstable keeps its old kernel and g, and is
796
+ # rebuilt in log space at the next block.
797
+ stable = stable & ((u > 0) | ~src_support).all(-1)
798
+ stable = stable & ((v > 0) | ~tgt_support).all(-1)
799
+ # Zero-mass entries have u or v equal to 0. Their log is taken as 0, so
800
+ # they add no scaling to the absorbed total.
801
+ log_u = torch.where(u > 0, u, 1.0).log()
802
+ log_v = torch.where(v > 0, v, 1.0).log()
803
+ g = g + epsilon * torch.where(stable.unsqueeze(-1), log_v, 0.0)
804
+ # An unstable pair falls back to its kernel, not to a plan built from
805
+ # clamped divisions.
806
+ plan = kernel * u.unsqueeze(-1) * v.unsqueeze(-2)
807
+ plan = torch.where(stable.unsqueeze(-1).unsqueeze(-1), plan, kernel)
808
+ absorbed_change = absorbed_change + torch.maximum(
809
+ log_u.abs().amax(-1), log_v.abs().amax(-1),
810
+ )
811
+
812
+ # Check each pair's plan against both marginals. The cost written below
813
+ # comes from that same plan. A pair finishes only at target reg, and
814
+ # finished pairs are dropped at the end of the block, so a slower pair
815
+ # in the tile cannot change their answer.
816
+ row_error = (plan.sum(-1) - src).abs().amax(-1)
817
+ col_error = (plan.sum(-2) - tgt).abs().amax(-1)
818
+ error = torch.maximum(row_error, col_error)
819
+ error = torch.where(torch.isfinite(error), error, torch.inf)
820
+ values = values.index_copy(0, active, (plan * cost).sum((-2, -1)))
821
+ # A pair is finished only at target epsilon. One still in warmup
822
+ # counts as an infinite error, so the loop cannot stop early on it.
823
+ finished = (error <= tol) & (epsilon.squeeze(-1) == reg)
824
+ done_error = torch.where(finished, 0.0, error)
825
+ done_error = torch.where(
826
+ (epsilon.squeeze(-1) > reg) & ~finished, torch.inf, done_error,
827
+ )
828
+ marginal_error = done_error.amax().item()
829
+ if marginal_error <= tol:
830
+ break
831
+ # Pairs that met the stage tolerance move to the next, colder
832
+ # epsilon. A changed epsilon, an unstable block, or too much
833
+ # accumulated scaling all force a log-domain rebuild next block.
834
+ ready = (error <= stage_tol).unsqueeze(-1)
835
+ next_epsilon = torch.where(
836
+ ready, (epsilon * SINKHORN_EPSILON_DECAY).clamp_min(reg), epsilon,
837
+ )
838
+ rebuild = (
839
+ (next_epsilon.squeeze(-1) != epsilon.squeeze(-1)) | ~stable
840
+ | (absorbed_change > math.log(SINKHORN_ABSORPTION_THRESHOLD))
841
+ )
842
+ epsilon = next_epsilon
843
+ kernel = plan
844
+ # Drop finished pairs at the same sync boundary as the convergence
845
+ # check, so the hard pairs keep iterating without re-solving the
846
+ # accepted ones.
847
+ keep = ~finished
848
+ active = active[keep]
849
+ src, tgt, cost = src[keep], tgt[keep], cost[keep]
850
+ src_support, tgt_support = src_support[keep], tgt_support[keep]
851
+ log_src, log_tgt = log_src[keep], log_tgt[keep]
852
+ g, epsilon = g[keep], epsilon[keep]
853
+ kernel, rebuild = kernel[keep], rebuild[keep]
854
+ absorbed_change = absorbed_change[keep]
855
+ return values.reshape(output_shape), marginal_error