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,346 @@
1
+ """Small reproducible inputs; the explorer has no dataset download step."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import torch
8
+
9
+ from .common import (
10
+ DEFAULT_NODES,
11
+ DEFAULT_NOISE,
12
+ DEFAULT_POINTS,
13
+ DEFAULT_SEED,
14
+ MAX_NODES,
15
+ MAX_POINTS,
16
+ Context,
17
+ Request,
18
+ choice,
19
+ integer,
20
+ number,
21
+ points_tensor,
22
+ )
23
+
24
+ # Zachary (1977), Journal of Anthropological Research 33, 452-473, Table 3.
25
+ # Zero-based, unweighted transcription of the 34-member, 78-edge network.
26
+ KARATE_NODES = 34
27
+ KARATE_EDGE_COUNT = 78
28
+ KARATE_DEGREES = (16, 17) # Vertices 0 and 33, checked against the transcription.
29
+ KARATE_EDGES = (
30
+ (0, 1),
31
+ (0, 2),
32
+ (0, 3),
33
+ (0, 4),
34
+ (0, 5),
35
+ (0, 6),
36
+ (0, 7),
37
+ (0, 8),
38
+ (0, 10),
39
+ (0, 11),
40
+ (0, 12),
41
+ (0, 13),
42
+ (0, 17),
43
+ (0, 19),
44
+ (0, 21),
45
+ (0, 31),
46
+ (1, 2),
47
+ (1, 3),
48
+ (1, 7),
49
+ (1, 13),
50
+ (1, 17),
51
+ (1, 19),
52
+ (1, 21),
53
+ (1, 30),
54
+ (2, 3),
55
+ (2, 7),
56
+ (2, 8),
57
+ (2, 9),
58
+ (2, 13),
59
+ (2, 27),
60
+ (2, 28),
61
+ (2, 32),
62
+ (3, 7),
63
+ (3, 12),
64
+ (3, 13),
65
+ (4, 6),
66
+ (4, 10),
67
+ (5, 6),
68
+ (5, 10),
69
+ (5, 16),
70
+ (6, 16),
71
+ (8, 30),
72
+ (8, 32),
73
+ (8, 33),
74
+ (9, 33),
75
+ (13, 33),
76
+ (14, 32),
77
+ (14, 33),
78
+ (15, 32),
79
+ (15, 33),
80
+ (18, 32),
81
+ (18, 33),
82
+ (19, 33),
83
+ (20, 32),
84
+ (20, 33),
85
+ (22, 32),
86
+ (22, 33),
87
+ (23, 25),
88
+ (23, 27),
89
+ (23, 29),
90
+ (23, 32),
91
+ (23, 33),
92
+ (24, 25),
93
+ (24, 27),
94
+ (24, 31),
95
+ (25, 31),
96
+ (26, 29),
97
+ (26, 33),
98
+ (27, 33),
99
+ (28, 31),
100
+ (28, 33),
101
+ (29, 32),
102
+ (29, 33),
103
+ (30, 32),
104
+ (30, 33),
105
+ (31, 32),
106
+ (31, 33),
107
+ (32, 33),
108
+ )
109
+ # Observed club after fission, not the faction predicted by a graph algorithm.
110
+ KARATE_FACTIONS = (
111
+ 0,
112
+ 0,
113
+ 0,
114
+ 0,
115
+ 0,
116
+ 0,
117
+ 0,
118
+ 0,
119
+ 0,
120
+ 1,
121
+ 0,
122
+ 0,
123
+ 0,
124
+ 0,
125
+ 1,
126
+ 1,
127
+ 0,
128
+ 0,
129
+ 1,
130
+ 0,
131
+ 1,
132
+ 0,
133
+ 1,
134
+ 1,
135
+ 1,
136
+ 1,
137
+ 1,
138
+ 1,
139
+ 1,
140
+ 1,
141
+ 1,
142
+ 1,
143
+ 1,
144
+ 1,
145
+ )
146
+ # Initial controls are examples, not fitted model parameters.
147
+ DEFAULT_BLOCKS = 3
148
+ DEFAULT_P_IN = 0.5
149
+ DEFAULT_P_OUT = 0.05
150
+ DEFAULT_RADIUS = 0.3
151
+ DEFAULT_CLIQUE_SIZE = 5
152
+ DEFAULT_CLIQUES = 4
153
+ MAX_SEED = 2**32 - 1 # Portable unsigned 32-bit seed range.
154
+ CIRCLE_SEPARATION = 3.0 # Unit circles remain disjoint before adding noise.
155
+ ANNULUS_INNER_RADIUS = 0.5
156
+ CLUSTER_SPREAD = 0.15 # Example Gaussian standard deviation, coordinate units.
157
+
158
+
159
+ def graph(request: Request, context: Context) -> tuple[torch.Tensor, Request]:
160
+ """Generate an undirected simple adjacency of shape (1, N, N).
161
+
162
+ References:
163
+ Zachary (1977), An Information Flow Model for Conflict and Fission
164
+ in Small Groups, J. Anthropological Research 33:452-473.
165
+ Holland, Laskey and Leinhardt (1983), Stochastic blockmodels.
166
+ """
167
+ kind = choice(
168
+ request,
169
+ "graph",
170
+ "karate",
171
+ (
172
+ "karate",
173
+ "sbm",
174
+ "barbell",
175
+ "cliques",
176
+ "geometric",
177
+ "custom",
178
+ ),
179
+ )
180
+ seed = integer(request, "seed", DEFAULT_SEED, 0, MAX_SEED)
181
+ generator = context.generator(seed)
182
+ metadata: Request = {"graph": kind, "seed": seed, "weighted": False}
183
+ if kind == "karate":
184
+ adjacency = torch.zeros(
185
+ (KARATE_NODES, KARATE_NODES),
186
+ device=context.device,
187
+ dtype=context.dtype,
188
+ )
189
+ edges = torch.tensor(KARATE_EDGES, device=context.device)
190
+ adjacency[edges[:, 0], edges[:, 1]] = 1
191
+ metadata["citation"] = "Zachary (1977), J. Anthropol. Res. 33:452–473"
192
+ metadata["factions"] = KARATE_FACTIONS
193
+ elif kind == "custom":
194
+ adjacency, weighted = _edge_list(request, context)
195
+ metadata["weighted"] = weighted
196
+ elif kind in ("barbell", "cliques"):
197
+ size = integer(request, "clique_size", DEFAULT_CLIQUE_SIZE, 2, MAX_NODES // 2)
198
+ count = (
199
+ 2
200
+ if kind == "barbell"
201
+ else integer(request, "cliques", DEFAULT_CLIQUES, 2, MAX_NODES // 2)
202
+ )
203
+ if size * count > MAX_NODES:
204
+ raise ValueError(f"clique_size × cliques must be at most {MAX_NODES}")
205
+ labels = torch.arange(size * count, device=context.device) // size
206
+ adjacency = (labels[:, None] == labels[None, :]).to(context.dtype)
207
+ bridges = torch.arange(count - (kind == "barbell"), device=context.device)
208
+ adjacency[bridges * size + size - 1, ((bridges + 1) % count) * size] = 1
209
+ metadata.update(clique_size=size, cliques=count)
210
+ else:
211
+ nodes = integer(request, "nodes", DEFAULT_NODES, 3, MAX_NODES)
212
+ if kind == "sbm":
213
+ blocks = integer(request, "blocks", DEFAULT_BLOCKS, 1, nodes)
214
+ p_in = number(request, "p_in", DEFAULT_P_IN, 0, 1)
215
+ p_out = number(request, "p_out", DEFAULT_P_OUT, 0, 1)
216
+ labels = torch.arange(nodes, device=context.device) * blocks // nodes
217
+ probability = torch.where(
218
+ labels[:, None] == labels[None, :],
219
+ p_in,
220
+ p_out,
221
+ )
222
+ draws = torch.rand(
223
+ (nodes, nodes),
224
+ generator=generator,
225
+ device=context.device,
226
+ dtype=context.dtype,
227
+ )
228
+ adjacency = (draws < probability).to(context.dtype).triu(1)
229
+ metadata.update(nodes=nodes, blocks=blocks, p_in=p_in, p_out=p_out)
230
+ else:
231
+ radius = number(request, "radius", DEFAULT_RADIUS, 0, math.sqrt(2))
232
+ points = torch.rand(
233
+ (nodes, 2),
234
+ generator=generator,
235
+ device=context.device,
236
+ dtype=context.dtype,
237
+ )
238
+ adjacency = (torch.cdist(points, points) <= radius).to(context.dtype)
239
+ metadata.update(nodes=nodes, radius=radius)
240
+ adjacency = torch.maximum(adjacency, adjacency.mT)
241
+ adjacency.fill_diagonal_(0)
242
+ return adjacency.unsqueeze(0), metadata
243
+
244
+
245
+ def _edge_list(request: Request, context: Context) -> tuple[torch.Tensor, bool]:
246
+ text = request.get("edges", "")
247
+ if not isinstance(text, str):
248
+ raise ValueError("edges must be text: i j [weight] per line")
249
+ parsed: dict[tuple[int, int], float] = {}
250
+ largest = -1
251
+ # Text parsing is outside the tensor compute path. Reject conflicting duplicate
252
+ # edges so that input order cannot silently change the graph's meaning.
253
+ for line_number, line in enumerate(text.splitlines(), start=1):
254
+ parts = line.split("#", 1)[0].split()
255
+ if not parts:
256
+ continue
257
+ if len(parts) not in (2, 3):
258
+ raise ValueError(f"line {line_number}: expected i j [weight]")
259
+ left, right = int(parts[0]), int(parts[1])
260
+ weight = float(parts[2]) if len(parts) == 3 else 1.0
261
+ if not 0 <= min(left, right) <= max(left, right) < MAX_NODES:
262
+ raise ValueError(f"node IDs must be between 0 and {MAX_NODES - 1}")
263
+ if not math.isfinite(weight) or weight <= 0:
264
+ raise ValueError("edge weights must be finite and positive")
265
+ largest = max(largest, left, right)
266
+ if left == right:
267
+ continue
268
+ key = (min(left, right), max(left, right))
269
+ if key in parsed and parsed[key] != weight:
270
+ raise ValueError(f"conflicting weights for edge {key}")
271
+ parsed[key] = weight
272
+ nodes = integer(request, "nodes", max(1, largest + 1), 1, MAX_NODES)
273
+ if largest >= nodes:
274
+ raise ValueError("nodes must include the largest edge endpoint")
275
+ adjacency = torch.zeros((nodes, nodes), device=context.device, dtype=context.dtype)
276
+ if parsed:
277
+ endpoints = torch.tensor(list(parsed), device=context.device)
278
+ weights = context.tensor(list(parsed.values()))
279
+ if not bool((torch.isfinite(weights) & (weights > 0)).all()):
280
+ raise ValueError(
281
+ "edge weights must stay finite and positive in the chosen dtype"
282
+ )
283
+ adjacency[endpoints[:, 0], endpoints[:, 1]] = weights
284
+ return adjacency, any(weight != 1 for weight in parsed.values())
285
+
286
+
287
+ def cloud(request: Request, context: Context) -> torch.Tensor:
288
+ """Generate (N, 2) Euclidean points or use the user's clicks.
289
+
290
+ References:
291
+ The polar area element r dr dθ gives uniform annulus area sampling.
292
+ """
293
+ kind = choice(
294
+ request,
295
+ "cloud",
296
+ "circle",
297
+ (
298
+ "circle",
299
+ "two_circles",
300
+ "figure_eight",
301
+ "annulus",
302
+ "clusters",
303
+ "custom",
304
+ ),
305
+ )
306
+ if kind == "custom":
307
+ return points_tensor(request, context, MAX_POINTS)
308
+ count = integer(request, "points_count", DEFAULT_POINTS, 1, MAX_POINTS)
309
+ seed = integer(request, "seed", DEFAULT_SEED, 0, MAX_SEED)
310
+ noise = number(request, "noise", DEFAULT_NOISE, 0, 1)
311
+ generator = context.generator(seed)
312
+ random = torch.rand(
313
+ (count, 2),
314
+ generator=generator,
315
+ device=context.device,
316
+ dtype=context.dtype,
317
+ )
318
+ angle = random[:, 0] * math.tau
319
+ points = torch.stack((angle.cos(), angle.sin()), dim=-1)
320
+ if kind == "two_circles":
321
+ points[:, 0] += (
322
+ torch.arange(count, device=context.device) % 2
323
+ ) * CIRCLE_SEPARATION
324
+ points[:, 0] -= CIRCLE_SEPARATION / 2
325
+ elif kind == "figure_eight":
326
+ points = torch.stack((angle.sin(), (2 * angle).sin() / 2), dim=-1)
327
+ elif kind == "annulus":
328
+ radius = (
329
+ ANNULUS_INNER_RADIUS**2 + random[:, 1] * (1 - ANNULUS_INNER_RADIUS**2)
330
+ ).sqrt()
331
+ points *= radius[:, None]
332
+ elif kind == "clusters":
333
+ centers = context.tensor(((-1, -1), (1, -1), (0, 1)))
334
+ points = centers[torch.arange(count, device=context.device) % len(centers)]
335
+ points = points + CLUSTER_SPREAD * torch.randn(
336
+ (count, 2),
337
+ generator=generator,
338
+ device=context.device,
339
+ dtype=context.dtype,
340
+ )
341
+ return points + noise * torch.randn(
342
+ (count, 2),
343
+ generator=generator,
344
+ device=context.device,
345
+ dtype=context.dtype,
346
+ )
@@ -0,0 +1,286 @@
1
+ """Graph scene: adapters for spectral and discrete-geometric primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings as python_warnings
6
+
7
+ import torch
8
+
9
+ from holonomy_lib import discrete_geometry, spectral
10
+ from holonomy_lib.spectral.laplacian import symmetric_normalized
11
+
12
+ from . import datasets
13
+ from .common import (
14
+ MAX_FLOW_FRAMES,
15
+ MAX_FLOW_STEPS,
16
+ Context,
17
+ Request,
18
+ choice,
19
+ integer,
20
+ number,
21
+ )
22
+
23
+ # Measured 2026-10-25 with the converged Ollivier curvature (CPU, float64, each
24
+ # generator at its defaults): ring of cliques ends as [5, 5, 5, 5], barbell as
25
+ # [4, 4, 1, 1], the geometric graph as [20, 4], while karate and the block model
26
+ # stay in one piece. An earlier 21/13 karate split came from unconverged
27
+ # curvature. A sweep over alpha, dt, steps and cut thresholds found no setting
28
+ # that recovers Zachary's two factions; the best modularity cut gave four parts.
29
+ # So these are a demonstration on clear community structure, not a detector.
30
+ DEFAULT_FLOW_STEPS = 20
31
+ DEFAULT_SURGERY_PERIOD = 10
32
+ DEFAULT_SURGERY_THRESHOLD = 1.5
33
+ DEFAULT_FLOW_DT = 1.0
34
+ MAX_SURGERY_THRESHOLD = 10.0
35
+ # With the library's default K=30, keep the heat slider in a modest t range.
36
+ MIN_HEAT_TIME = 0.01
37
+ MAX_HEAT_TIME = 10.0
38
+ REFERENCE_EPS_MULTIPLIER = 64 # Roundoff allowance for a six-node eigensolve.
39
+
40
+
41
+ def components(adjacency: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
42
+ """Return component labels and sizes for one (N,N) graph.
43
+
44
+ Repeated squaring doubles the reachable path length each round, so this
45
+ loop is over log2(N) path lengths, never over individual nodes or edges.
46
+
47
+ References:
48
+ Warshall (1962), A Theorem on Boolean Matrices.
49
+ """
50
+ nodes = adjacency.shape[-1]
51
+ reachable = (adjacency > 0) | torch.eye(
52
+ nodes,
53
+ device=adjacency.device,
54
+ dtype=torch.bool,
55
+ )
56
+ for _ in range((nodes - 1).bit_length()):
57
+ reachable = (reachable.to(adjacency.dtype) @ reachable.to(adjacency.dtype)) > 0
58
+ indices = torch.arange(nodes, device=adjacency.device)
59
+ first = torch.where(reachable, indices[None, :], nodes).amin(-1)
60
+ _, labels, sizes = torch.unique(first, return_inverse=True, return_counts=True)
61
+ return labels, sizes
62
+
63
+
64
+ def _resistance_reference(context: Context) -> Request:
65
+ adjacency = context.tensor(
66
+ [
67
+ [
68
+ [0, 1, 1, 0, 0, 0],
69
+ [1, 0, 1, 0, 0, 0],
70
+ [1, 1, 0, 1, 0, 0],
71
+ [0, 0, 1, 0, 1, 1],
72
+ [0, 0, 0, 1, 0, 1],
73
+ [0, 0, 0, 1, 1, 0],
74
+ ]
75
+ ]
76
+ )
77
+ resistance = context.call(spectral.effective_resistance, adjacency)
78
+ observed = torch.stack((resistance[0, 0, 1], resistance[0, 2, 3]))
79
+ expected = context.tensor([2 / 3, 1]) # Triangle edge and unit bridge.
80
+ error = (observed - expected).abs().max()
81
+ return {
82
+ "observed": observed,
83
+ "expected": expected,
84
+ "max_abs_error": error,
85
+ "passed": bool(
86
+ error < torch.finfo(context.dtype).eps * REFERENCE_EPS_MULTIPLIER
87
+ ),
88
+ }
89
+
90
+
91
+ @torch.no_grad()
92
+ def compute(request: Request) -> Request:
93
+ """Return the graph, layout and a single library-computed overlay.
94
+
95
+ Flow frames are sequential in step count. Each frame starts at the original
96
+ adjacency: chunked calls reset surgery's cutoff and normalization reference.
97
+
98
+ References:
99
+ Belkin and Niyogi (2003), Laplacian Eigenmaps.
100
+ Ollivier (2009), Ricci curvature of Markov chains on metric spaces.
101
+ Forman (2003), Bochner's method for cell complexes.
102
+ Ni et al. (2019), Community detection on networks with Ricci flow.
103
+ """
104
+ context = Context.from_request(request)
105
+ adjacency, parameters = datasets.graph(request, context)
106
+ nodes = adjacency.shape[-1]
107
+ source = integer(request, "source", 0, 0, nodes - 1)
108
+ overlay = choice(
109
+ request,
110
+ "overlay",
111
+ "forman",
112
+ (
113
+ "none",
114
+ "ollivier",
115
+ "forman",
116
+ "flow",
117
+ "heat",
118
+ "resistance",
119
+ "spectrum",
120
+ ),
121
+ )
122
+ eigenvalues, vectors = context.call(
123
+ spectral.laplacian_eigenmaps,
124
+ adjacency,
125
+ k=min(3, nodes),
126
+ )
127
+ coordinates = torch.zeros((nodes, 2), device=context.device, dtype=context.dtype)
128
+ coordinates[:, : vectors.shape[-1] - 1] = vectors[0, :, 1:3]
129
+ edges = torch.triu_indices(nodes, nodes, 1, device=context.device)
130
+ present = adjacency[0, edges[0], edges[1]] > 0
131
+ edges = edges[:, present].mT
132
+ labels, sizes = components(adjacency[0])
133
+ warnings: list[str] = []
134
+ if len(sizes) > 1:
135
+ warnings.append(
136
+ "Disconnected graph: the spectral layout can overlap vertices in "
137
+ "degenerate component modes. Cross-component resistance is infinite."
138
+ )
139
+ result: Request = {
140
+ "nodes": nodes,
141
+ "edges": edges,
142
+ "weights": adjacency[0, edges[:, 0], edges[:, 1]],
143
+ "coordinates": coordinates,
144
+ "overlay": overlay,
145
+ "source": source,
146
+ "parameters": parameters,
147
+ "component_labels": labels,
148
+ "component_sizes": sizes,
149
+ "warnings": warnings,
150
+ }
151
+ parameters["overlay"] = overlay
152
+ if overlay in ("ollivier", "forman"):
153
+ function = (
154
+ discrete_geometry.ollivier_ricci_curvature
155
+ if overlay == "ollivier"
156
+ else discrete_geometry.forman_ricci_simple
157
+ )
158
+ curvature = context.call(function, adjacency)
159
+ values = curvature[0, edges[:, 0], edges[:, 1]]
160
+ result["edge_values"] = values
161
+ result["range"] = [values.min(), values.max()] if values.numel() else [0, 0]
162
+ if overlay == "ollivier":
163
+ warnings.append(
164
+ "Ollivier values use the library's regularized Sinkhorn approximation."
165
+ )
166
+ elif overlay == "heat":
167
+ time = number(request, "time", 1, MIN_HEAT_TIME, MAX_HEAT_TIME)
168
+ laplacian = context.call(symmetric_normalized, adjacency)
169
+ signal = torch.zeros((1, nodes, 1), device=context.device, dtype=context.dtype)
170
+ signal[:, source, :] = 1
171
+ heat = context.call(
172
+ spectral.heat_kernel_chebyshev, laplacian, time, signal=signal
173
+ )
174
+ result["node_values"] = heat[0, :, 0]
175
+ result["value_label"] = "Heat amplitude (normalized Laplacian)"
176
+ parameters.update(time=time, source=source)
177
+ elif overlay == "resistance":
178
+ resistance = context.call(spectral.effective_resistance, adjacency)[0, source]
179
+ result["node_values"] = torch.where(
180
+ labels == labels[source],
181
+ resistance,
182
+ float("inf"),
183
+ )
184
+ result["value_label"] = "Effective resistance (unit-edge resistance units)"
185
+ reference = _resistance_reference(context)
186
+ result["reference_check"] = reference
187
+ if not reference["passed"]:
188
+ warnings.append(
189
+ "Library resistance failed a triangle-and-bridge reference check "
190
+ "in this dtype. Values below are the unmodified library output."
191
+ )
192
+ parameters["source"] = source
193
+ elif overlay == "flow":
194
+ steps = integer(request, "steps", DEFAULT_FLOW_STEPS, 1, MAX_FLOW_STEPS)
195
+ period = integer(request, "surgery_period", DEFAULT_SURGERY_PERIOD, 1, steps)
196
+ threshold = number(
197
+ request,
198
+ "surgery_threshold",
199
+ DEFAULT_SURGERY_THRESHOLD,
200
+ torch.finfo(context.dtype).eps,
201
+ MAX_SURGERY_THRESHOLD,
202
+ )
203
+ dt = number(request, "dt", DEFAULT_FLOW_DT, torch.finfo(context.dtype).eps, 1)
204
+ frame_steps = list(range(period, steps + 1, period))
205
+ if not frame_steps or frame_steps[-1] != steps:
206
+ frame_steps.append(steps)
207
+ if len(frame_steps) > MAX_FLOW_FRAMES:
208
+ raise ValueError(
209
+ f"choose a longer surgery period: at most {MAX_FLOW_FRAMES} frames"
210
+ )
211
+ flow_plan = [0, *frame_steps]
212
+ # A browser can request each cumulative frame separately and show progress
213
+ # between requests. Ordinary direct callers still receive the whole run.
214
+ if "frame_step" in request:
215
+ frame_step = integer(request, "frame_step", 0, 0, steps)
216
+ if frame_step not in flow_plan:
217
+ raise ValueError("frame_step must be a planned cumulative flow step")
218
+ frame_steps = [frame_step] if frame_step else []
219
+ complete = bool(frame_steps and frame_steps[-1] == steps)
220
+ frames = [adjacency[0]]
221
+ flow_notices: list[str] = []
222
+ for step in frame_steps:
223
+ with python_warnings.catch_warnings(record=True) as notices:
224
+ python_warnings.simplefilter("always", UserWarning)
225
+ frames.append(
226
+ context.call(
227
+ discrete_geometry.ricci_flow_with_surgery,
228
+ adjacency,
229
+ n_steps=step,
230
+ surgery_period=period,
231
+ surgery_threshold=threshold,
232
+ dt=dt,
233
+ )[0]
234
+ )
235
+ # Keep solver notices visible in the response rather than leaving
236
+ # them only in the terminal while the browser reports a partition.
237
+ flow_notices.extend(str(notice.message) for notice in notices)
238
+ final_labels, final_sizes = components(frames[-1])
239
+ # Say so plainly when nothing split, otherwise an unchanged graph reads
240
+ # like the animation failed.
241
+ if complete and len(final_sizes) == 1:
242
+ warnings.append(
243
+ "The flow did not split this graph at these settings. Ricci flow "
244
+ "separates clear community structure (try the ring of cliques); "
245
+ "on the karate club it does not recover the two factions."
246
+ )
247
+ result.update(
248
+ frames=torch.stack(frames),
249
+ frame_steps=[0, *frame_steps],
250
+ flow_plan=flow_plan,
251
+ flow_complete=complete,
252
+ flow_warning_count=len(flow_notices),
253
+ flow_last_warning=flow_notices[-1] if flow_notices else None,
254
+ final_labels=final_labels,
255
+ final_sizes=final_sizes,
256
+ flow_method=(
257
+ "Each frame reruns from the original graph "
258
+ "to its cumulative step count."
259
+ ),
260
+ )
261
+ parameters.update(
262
+ steps=steps, surgery_period=period, surgery_threshold=threshold, dt=dt
263
+ )
264
+ if parameters["graph"] == "karate":
265
+ factions = torch.tensor(datasets.KARATE_FACTIONS, device=context.device)
266
+ mismatch = (final_labels != factions).sum()
267
+ result["faction_wrong_side"] = (
268
+ torch.minimum(mismatch, nodes - mismatch)
269
+ if len(final_sizes) == 2
270
+ else None
271
+ )
272
+ result["faction_note"] = (
273
+ "Faction labels are exchanged to minimize the mismatch count."
274
+ if len(final_sizes) == 2
275
+ else "Wrong-side count requires exactly two final components; "
276
+ "this flow did not produce two."
277
+ )
278
+ if overlay in ("ollivier", "flow") and parameters["weighted"]:
279
+ warnings.append(
280
+ "Weighted Ollivier/flow currently use adjacency values both as path "
281
+ "lengths and as random-walk mass. Interpret weighted results with care."
282
+ )
283
+ laplacian = context.call(symmetric_normalized, adjacency)
284
+ spectrum = context.call(torch.linalg.eigvalsh, laplacian)[0]
285
+ result.update(spectrum=spectrum, spectral_gap=spectrum[1] if nodes > 1 else None)
286
+ return context.finish(**result)