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 @@
1
+ """Local, offline views of computations performed by holonomy_lib."""
@@ -0,0 +1,79 @@
1
+ """Launch with holonomy-explorer or python -m holonomy_explorer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import webbrowser
8
+
9
+ import torch
10
+
11
+ from .common import Context
12
+ from .server import create_server
13
+
14
+ # Local-only defaults requested by the explorer brief; every deployment can override.
15
+ DEFAULT_HOST = "127.0.0.1"
16
+ WILDCARD_HOST = "0.0.0.0" # IPv4 bind-all address; browse via loopback instead.
17
+ DEFAULT_PORT = 8765
18
+ DEFAULT_DTYPE = "float64"
19
+ MAX_PORT = 65535 # TCP port field is an unsigned 16-bit integer.
20
+
21
+
22
+ def main() -> None:
23
+ """Parse deployment settings, print the bound URL, and serve until Ctrl-C."""
24
+ parser = argparse.ArgumentParser(
25
+ description="Local holonomy_lib explorer. Offline UI; CPU/CUDA tensors.",
26
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
27
+ )
28
+ parser.add_argument(
29
+ "--host",
30
+ default=os.getenv("HOLONOMY_EXPLORER_HOST", DEFAULT_HOST),
31
+ help="bind address; env HOLONOMY_EXPLORER_HOST",
32
+ )
33
+ parser.add_argument(
34
+ "--port",
35
+ type=int,
36
+ default=os.getenv("HOLONOMY_EXPLORER_PORT", str(DEFAULT_PORT)),
37
+ help="TCP port, 0 chooses a free port; env HOLONOMY_EXPLORER_PORT",
38
+ )
39
+ parser.add_argument(
40
+ "--device",
41
+ default=os.getenv(
42
+ "HOLONOMY_EXPLORER_DEVICE",
43
+ "cuda" if torch.cuda.is_available() else "cpu",
44
+ ),
45
+ help="torch device; env HOLONOMY_EXPLORER_DEVICE; ROCm also uses cuda",
46
+ )
47
+ parser.add_argument(
48
+ "--dtype",
49
+ choices=("float32", "float64"),
50
+ default=os.getenv("HOLONOMY_EXPLORER_DTYPE", DEFAULT_DTYPE),
51
+ help="tensor precision; env HOLONOMY_EXPLORER_DTYPE",
52
+ )
53
+ parser.add_argument(
54
+ "--no-browser", action="store_true", help="print URL without opening a browser"
55
+ )
56
+ args = parser.parse_args()
57
+ if not 0 <= args.port <= MAX_PORT:
58
+ parser.error(f"port must be between 0 and {MAX_PORT}")
59
+ try:
60
+ context = Context.from_request({"device": args.device, "dtype": args.dtype})
61
+ server = create_server(args.host, args.port, str(context.device), args.dtype)
62
+ except (OSError, ValueError, RuntimeError) as error:
63
+ parser.error(str(error))
64
+ # Wildcard bindings are not navigable destinations; loopback reaches this server.
65
+ browser_host = DEFAULT_HOST if args.host == WILDCARD_HOST else args.host
66
+ url = f"http://{browser_host}:{server.server_address[1]}"
67
+ print(f"Holonomy explorer: {url} ({context.device}, {args.dtype})", flush=True)
68
+ if not args.no_browser:
69
+ webbrowser.open(url)
70
+ try:
71
+ server.serve_forever()
72
+ except KeyboardInterrupt:
73
+ pass
74
+ finally:
75
+ server.server_close()
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
@@ -0,0 +1,182 @@
1
+ """Synchronized warm-up and repeated timings of the actual library paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ import torch
9
+
10
+ from holonomy_lib.manifolds import KappaStereographicManifold
11
+ from holonomy_lib.spectral import heat_kernel_chebyshev
12
+ from holonomy_lib.spectral.laplacian import symmetric_normalized
13
+ from holonomy_lib.topology import persistence_diagrams
14
+
15
+ from .common import (
16
+ DEFAULT_REPEATS,
17
+ DEFAULT_SEED,
18
+ DEFAULT_SWEEP,
19
+ MAX_BATCH_SIZE,
20
+ MAX_NODES,
21
+ MAX_PERSISTENCE_BATCH,
22
+ MAX_PERSISTENCE_SIZE,
23
+ MAX_REPEATS,
24
+ Context,
25
+ Request,
26
+ choice,
27
+ integer,
28
+ )
29
+ from .datasets import MAX_SEED
30
+ from .topology import reference_check
31
+
32
+ # Keep the initial persistence sweep small enough for interactive CPU reduction.
33
+ DEFAULT_PROBLEM_SIZE = 8
34
+ BENCHMARK_GRAPH_DENSITY = 0.25 # Example Erdős-Rényi connection probability.
35
+ BENCHMARK_POINT_SCALE = 0.2 # Uniform square sits inside the κ=-1 unit ball.
36
+
37
+
38
+ def _operation(
39
+ primitive: str,
40
+ batch: int,
41
+ size: int,
42
+ context: Context,
43
+ seed: int,
44
+ ) -> Callable[[], Any]:
45
+ generator = context.generator(seed)
46
+ options: dict[str, Any] = {"device": context.device, "dtype": context.dtype}
47
+ if primitive == "heat":
48
+ upper = (
49
+ (
50
+ torch.rand((batch, size, size), generator=generator, **options)
51
+ < BENCHMARK_GRAPH_DENSITY
52
+ )
53
+ .to(context.dtype)
54
+ .triu(1)
55
+ )
56
+ adjacency = upper + upper.mT
57
+ # Matrix construction is outside the measured primitive call.
58
+ laplacian = symmetric_normalized(adjacency)
59
+ signal = torch.ones((batch, size, 1), **options)
60
+ return lambda: heat_kernel_chebyshev(laplacian, 1.0, signal=signal)
61
+ points = torch.rand((batch, size, 2), generator=generator, **options)
62
+ if primitive == "distance":
63
+ points = (points - 0.5) * (2 * BENCHMARK_POINT_SCALE)
64
+ manifold = KappaStereographicManifold(n=2, kappa=-1, **options)
65
+ return lambda: manifold.distance(points[:, :, None, :], points[:, None, :, :])
66
+ return lambda: persistence_diagrams(points, max_dim=1)
67
+
68
+
69
+ def _probe(output: Any) -> Request:
70
+ if isinstance(output, torch.Tensor):
71
+ return {
72
+ "shape": list(output.shape),
73
+ "all_finite": bool(torch.isfinite(output).all()),
74
+ }
75
+ diagrams, masks = output
76
+ return {
77
+ "diagram_shapes": [list(item.shape) for item in diagrams],
78
+ "valid_bars": [int(mask.sum()) for mask in masks],
79
+ }
80
+
81
+
82
+ @torch.no_grad()
83
+ def compute(request: Request) -> Request:
84
+ """Sweep batch sizes, warming each shape before synchronized measurements.
85
+
86
+ Repeats and device/shape sweeps are deliberately sequential to avoid timing
87
+ competing work. The tensor operation itself receives the whole batch.
88
+
89
+ References:
90
+ Hammond, Vandergheynst and Gribonval (2011), Wavelets on graphs.
91
+ Bachmann et al. (2020), Constant Curvature Graph Convolutional Networks.
92
+ Edelsbrunner, Letscher and Zomorodian (2002), Topological persistence.
93
+ """
94
+ selected = Context.from_request(request)
95
+ primitive = choice(
96
+ request, "primitive", "heat", ("heat", "distance", "persistence")
97
+ )
98
+ size_cap = MAX_PERSISTENCE_SIZE if primitive == "persistence" else MAX_NODES
99
+ batch_cap = MAX_PERSISTENCE_BATCH if primitive == "persistence" else MAX_BATCH_SIZE
100
+ size = integer(request, "size", DEFAULT_PROBLEM_SIZE, 1, size_cap)
101
+ repeats = integer(request, "repeats", DEFAULT_REPEATS, 1, MAX_REPEATS)
102
+ seed = integer(request, "seed", DEFAULT_SEED, 0, MAX_SEED)
103
+ batches = request.get(
104
+ "batches", [batch for batch in DEFAULT_SWEEP if batch <= batch_cap]
105
+ )
106
+ if (
107
+ not isinstance(batches, list)
108
+ or not batches
109
+ or len(batches) > len(DEFAULT_SWEEP)
110
+ ):
111
+ raise ValueError(f"batches must contain 1 to {len(DEFAULT_SWEEP)} batch sizes")
112
+ if any(type(batch) is not int or not 1 <= batch <= batch_cap for batch in batches):
113
+ raise ValueError(
114
+ f"each batch size must be an integer between 1 and {batch_cap}"
115
+ )
116
+ devices = [torch.device("cpu")]
117
+ if torch.cuda.is_available():
118
+ devices.append(
119
+ selected.device
120
+ if selected.device.type == "cuda"
121
+ else torch.device("cuda", 0)
122
+ )
123
+ rows: list[Request] = []
124
+ warnings: list[str] = []
125
+ references: list[Request] = []
126
+ for device in devices:
127
+ context = Context(device, selected.dtype)
128
+ if primitive == "persistence":
129
+ reference = reference_check(context)
130
+ references.append({"device": str(device), **reference})
131
+ if not reference["passed"]:
132
+ warnings.append(
133
+ f"{device}: square H1 reference failed; "
134
+ "timing the current provisional reduction."
135
+ )
136
+ for batch in sorted(set(batches)):
137
+ operation = _operation(primitive, batch, size, context, seed)
138
+ probe = _probe(context.call(operation))
139
+ times = []
140
+ for _ in range(repeats):
141
+ start = context.elapsed_ms
142
+ context.call(operation)
143
+ times.append(context.elapsed_ms - start)
144
+ timings = torch.tensor(times, dtype=torch.float64)
145
+ median = timings.quantile(0.5)
146
+ rows.append(
147
+ {
148
+ "device": str(device),
149
+ "batch": batch,
150
+ "min_ms": timings.min(),
151
+ "median_ms": median,
152
+ "max_ms": timings.max(),
153
+ "samples_ms": times,
154
+ "per_item_ms": median / batch,
155
+ "per_item_min_ms": timings.min() / batch,
156
+ "per_item_max_ms": timings.max() / batch,
157
+ "probe": probe,
158
+ }
159
+ )
160
+ selected.elapsed_ms += context.elapsed_ms
161
+ selected.calls.extend(context.calls)
162
+ return selected.finish(
163
+ rows=rows,
164
+ warnings=warnings,
165
+ reference_checks=references,
166
+ parameters={
167
+ "primitive": primitive,
168
+ "size": size,
169
+ "repeats": repeats,
170
+ "seed": seed,
171
+ "batches": sorted(set(batches)),
172
+ "warmups": 1,
173
+ },
174
+ timing_note=(
175
+ "Wall time, synchronized before and after each call; "
176
+ "input generation excluded."
177
+ ),
178
+ persistence_note=(
179
+ "Persistence reduction is sequential per batch member, with CPU "
180
+ "bookkeeping even for GPU inputs. It is expected to scale differently."
181
+ ),
182
+ )
@@ -0,0 +1,187 @@
1
+ """Request validation, compute timing, and the JSON boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from inspect import signature
9
+ from time import perf_counter
10
+ from typing import Any, TypeVar
11
+
12
+ import torch
13
+
14
+ # These are UI resource budgets, not numerical accuracy claims. They bound dense
15
+ # graph work, cubic VR simplex counts, and repeated sweeps on a laptop CPU.
16
+ MAX_NODES = 64
17
+ MAX_POINTS = 40
18
+ MAX_CURVATURE_POINTS = 12
19
+ MAX_BATCH_SIZE = 256
20
+ MAX_PERSISTENCE_BATCH = 16
21
+ MAX_PERSISTENCE_SIZE = 16
22
+ MAX_REPEATS = 7
23
+ MAX_FLOW_STEPS = 24
24
+ MAX_FLOW_FRAMES = 8
25
+ MAX_REQUEST_BYTES = 256 * 1024
26
+ REQUEST_TIMEOUT_SECONDS = 20
27
+
28
+ # Presentation and sampling defaults are choices for a small first view.
29
+ DEFAULT_SEED = 0
30
+ DEFAULT_NODES = 24
31
+ DEFAULT_POINTS = 24
32
+ DEFAULT_NOISE = 0.05
33
+ DEFAULT_REPEATS = 3
34
+ DEFAULT_SWEEP = (1, 4, 16, 64, 256)
35
+ CURVE_SAMPLES = 65
36
+ GRID_RINGS = 5
37
+ GRID_RAYS = 12
38
+ # Stay away from the positive-curvature chart pole when sampling the grid.
39
+ CHART_POLE_FRACTION = 0.8
40
+ DEFAULT_GRID_EXTENT = 1.0
41
+ SECONDS_TO_MS = 1000
42
+ DTYPES = {"float32": torch.float32, "float64": torch.float64}
43
+ Request = dict[str, Any]
44
+ T = TypeVar("T")
45
+
46
+
47
+ def integer(request: Request, key: str, default: int, low: int, high: int) -> int:
48
+ """Read an integer without silently truncating fractional input."""
49
+ value = request.get(key, default)
50
+ if isinstance(value, bool) or not isinstance(value, int):
51
+ raise ValueError(f"{key} must be an integer")
52
+ if not low <= value <= high:
53
+ raise ValueError(f"{key} must be between {low} and {high}")
54
+ return value
55
+
56
+
57
+ def number(
58
+ request: Request, key: str, default: float, low: float, high: float
59
+ ) -> float:
60
+ """Read a finite scalar within the control's documented domain."""
61
+ value = request.get(key, default)
62
+ if isinstance(value, bool) or not isinstance(value, (float, int)):
63
+ raise ValueError(f"{key} must be a number")
64
+ value = float(value)
65
+ if not math.isfinite(value) or not low <= value <= high:
66
+ raise ValueError(f"{key} must be finite and between {low} and {high}")
67
+ return value
68
+
69
+
70
+ def choice(request: Request, key: str, default: str, options: tuple[str, ...]) -> str:
71
+ """Read a named option, rejecting unexpected routes through the adapter."""
72
+ value = request.get(key, default)
73
+ if value not in options:
74
+ raise ValueError(f"{key} must be one of {', '.join(options)}")
75
+ return str(value)
76
+
77
+
78
+ def json_safe(value: Any) -> Any:
79
+ """Detach tensors once at the boundary; JSON has no NaN or infinity."""
80
+ if isinstance(value, torch.Tensor):
81
+ return json_safe(value.detach().cpu().tolist())
82
+ if isinstance(value, float):
83
+ return value if math.isfinite(value) else None
84
+ if isinstance(value, dict):
85
+ return {str(key): json_safe(item) for key, item in value.items()}
86
+ if isinstance(value, (list, tuple)):
87
+ return [json_safe(item) for item in value]
88
+ return value
89
+
90
+
91
+ @dataclass
92
+ class Context:
93
+ """Keep chosen tensor placement and synchronized library-call time together."""
94
+
95
+ device: torch.device
96
+ dtype: torch.dtype
97
+ elapsed_ms: float = 0.0
98
+ calls: list[Request] = field(default_factory=list)
99
+
100
+ @classmethod
101
+ def from_request(cls, request: Request) -> Context:
102
+ dtype_name = choice(request, "dtype", "float64", tuple(DTYPES))
103
+ device = torch.device(str(request.get("device", "cpu")))
104
+ if device.type not in ("cpu", "cuda"):
105
+ raise ValueError("device must be cpu or cuda (CUDA also names ROCm)")
106
+ if device.type == "cpu":
107
+ device = torch.device("cpu")
108
+ if device.type == "cuda":
109
+ if not torch.cuda.is_available():
110
+ raise ValueError(
111
+ "CUDA/ROCm is not available in this Python environment"
112
+ )
113
+ device = torch.device("cuda", device.index or 0)
114
+ # Allocate now so an invalid GPU index fails before any computation.
115
+ torch.empty(0, device=device, dtype=DTYPES[dtype_name])
116
+ return cls(device, DTYPES[dtype_name])
117
+
118
+ def sync(self) -> None:
119
+ if self.device.type == "cuda":
120
+ torch.cuda.synchronize(self.device)
121
+
122
+ def call(self, function: Callable[..., T], *args: Any, **kwargs: Any) -> T:
123
+ """Time only the requested library operation, including device completion."""
124
+ self.sync()
125
+ start = perf_counter()
126
+ try:
127
+ return function(*args, **kwargs)
128
+ finally:
129
+ self.sync()
130
+ duration = (perf_counter() - start) * SECONDS_TO_MS
131
+ self.elapsed_ms += duration
132
+ self.calls.append(
133
+ {
134
+ "function": f"{function.__module__}.{function.__qualname__}",
135
+ "elapsed_ms": duration,
136
+ "device": str(self.device),
137
+ "parameters": _scalar_arguments(function, args, kwargs),
138
+ }
139
+ )
140
+
141
+ def tensor(self, data: Any) -> torch.Tensor:
142
+ return torch.as_tensor(data, device=self.device, dtype=self.dtype)
143
+
144
+ def generator(self, seed: int) -> torch.Generator:
145
+ return torch.Generator(device=self.device).manual_seed(seed)
146
+
147
+ def finish(self, **values: Any) -> Request:
148
+ return dict(
149
+ json_safe(values),
150
+ device=str(self.device),
151
+ dtype=str(self.dtype).removeprefix("torch."),
152
+ elapsed_ms=self.elapsed_ms,
153
+ calls=json_safe(self.calls),
154
+ )
155
+
156
+
157
+ def _scalar_arguments(
158
+ function: Callable[..., Any],
159
+ args: tuple[Any, ...],
160
+ kwargs: Request,
161
+ ) -> Request:
162
+ # Read defaults from the dependency instead of copying them into the UI.
163
+ # Some torch C builtins have no Python signature; their inputs are already
164
+ # described by the scene's parameters and data arrays.
165
+ try:
166
+ bound = signature(function).bind(*args, **kwargs)
167
+ except (ValueError, TypeError):
168
+ return {}
169
+ bound.apply_defaults()
170
+ return {
171
+ key: value
172
+ for key, value in bound.arguments.items()
173
+ if isinstance(value, (str, int, float, bool)) or value is None
174
+ }
175
+
176
+
177
+ def points_tensor(request: Request, context: Context, cap: int) -> torch.Tensor:
178
+ """Read a finite (N, 2) point array; empty clicks are a valid cloud."""
179
+ raw = request.get("points", [])
180
+ if not isinstance(raw, list) or len(raw) > cap:
181
+ raise ValueError(f"points must be a list containing at most {cap} points")
182
+ if not raw:
183
+ return context.tensor([]).reshape(0, 2)
184
+ points = context.tensor(raw)
185
+ if points.shape != (len(raw), 2) or not bool(torch.isfinite(points).all()):
186
+ raise ValueError("points must contain finite [x, y] pairs")
187
+ return points
@@ -0,0 +1,154 @@
1
+ """Stereographic distances, geodesics, and a checked mean estimate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import torch
8
+
9
+ from holonomy_lib.hyperbolic import frechet_mean
10
+ from holonomy_lib.manifolds import KappaStereographicManifold
11
+
12
+ from .common import (
13
+ CHART_POLE_FRACTION,
14
+ CURVE_SAMPLES,
15
+ DEFAULT_GRID_EXTENT,
16
+ GRID_RAYS,
17
+ GRID_RINGS,
18
+ MAX_CURVATURE_POINTS,
19
+ Context,
20
+ Request,
21
+ number,
22
+ points_tensor,
23
+ )
24
+
25
+ # Residual tolerance is a display diagnostic, scaled to the dtype's resolution.
26
+ MEAN_RESIDUAL_EPS_POWER = 0.5
27
+ # An initial asymmetric triangle inside every displayed chart at |κ| <= 2.
28
+ DEFAULT_CURVATURE_POINTS = ((-0.35, -0.15), (0.35, -0.15), (0.0, 0.4))
29
+
30
+
31
+ @torch.no_grad()
32
+ def compute(request: Request) -> Request:
33
+ """Evaluate one chart with a leading singleton scene batch.
34
+
35
+ Points outside the chart remain in the response but are excluded from all
36
+ geometry. The library mean uses sequential Karcher iterations; its residual
37
+ is reported because an iteration cap alone does not establish convergence.
38
+
39
+ References:
40
+ Bachmann et al. (2020), Constant Curvature Graph Convolutional Networks.
41
+ Karcher (1977), Riemannian center of mass and mollifier smoothing.
42
+ """
43
+ context = Context.from_request(request)
44
+ kappa = number(request, "kappa", -1, -2, 2)
45
+ points = points_tensor(request, context, MAX_CURVATURE_POINTS)
46
+ manifold = KappaStereographicManifold(
47
+ n=2,
48
+ kappa=kappa,
49
+ device=context.device,
50
+ dtype=context.dtype,
51
+ )
52
+ # The negative-curvature ball is open. Use the documented hemispherical
53
+ # chart for positive κ as well, without relying on a membership sign bug.
54
+ valid = (
55
+ points.square().sum(-1) * abs(kappa) < 1
56
+ if kappa != 0
57
+ else torch.ones(len(points), device=context.device, dtype=torch.bool)
58
+ )
59
+ indices = valid.nonzero().flatten()
60
+ included = points[valid]
61
+ distance = context.call(
62
+ manifold.distance,
63
+ included[None, :, None, :],
64
+ included[None, None, :, :],
65
+ )[0]
66
+ raw_pairs = request.get("pairs")
67
+ if raw_pairs is None:
68
+ pairs = torch.combinations(indices, r=2)
69
+ else:
70
+ if not isinstance(raw_pairs, list) or len(raw_pairs) > MAX_CURVATURE_POINTS**2:
71
+ raise ValueError("pairs must be a list of point-index pairs")
72
+ if raw_pairs:
73
+ if any(
74
+ not isinstance(pair, list)
75
+ or len(pair) != 2
76
+ or any(type(index) is not int for index in pair)
77
+ for pair in raw_pairs
78
+ ):
79
+ raise ValueError("each pair must contain two integer indices")
80
+ pairs = torch.tensor(raw_pairs, device=context.device, dtype=torch.long)
81
+ if bool(((pairs < 0) | (pairs >= len(points))).any()):
82
+ raise ValueError("pair index is outside the point list")
83
+ pairs = pairs[valid[pairs].all(-1)]
84
+ else:
85
+ pairs = torch.empty((0, 2), device=context.device, dtype=torch.long)
86
+ steps = torch.linspace(
87
+ 0, 1, CURVE_SAMPLES, device=context.device, dtype=context.dtype
88
+ )
89
+ start, end = points[pairs[:, 0]], points[pairs[:, 1]]
90
+ tangent = context.call(manifold.log, start, end)
91
+ curves = context.call(
92
+ manifold.exp, start[:, None, :], tangent[:, None, :] * steps[None, :, None]
93
+ )
94
+ extent = DEFAULT_GRID_EXTENT
95
+ if kappa > 0:
96
+ # exp_0 reaches the hemisphere boundary at tangent radius π/(4√κ).
97
+ extent = min(extent, CHART_POLE_FRACTION * math.pi / (4 * math.sqrt(kappa)))
98
+ angles = torch.linspace(
99
+ 0, math.tau, CURVE_SAMPLES, device=context.device, dtype=context.dtype
100
+ )
101
+ radii = torch.arange(1, GRID_RINGS + 1, device=context.device, dtype=context.dtype)
102
+ radii *= extent / GRID_RINGS
103
+ rings = radii[:, None, None] * torch.stack((angles.cos(), angles.sin()), -1)[None]
104
+ ray_angles = (
105
+ torch.arange(GRID_RAYS, device=context.device, dtype=context.dtype)
106
+ * math.tau
107
+ / GRID_RAYS
108
+ )
109
+ rays = (
110
+ extent
111
+ * steps[None, :, None]
112
+ * torch.stack((ray_angles.cos(), ray_angles.sin()), -1)[:, None]
113
+ )
114
+ grid = context.call(manifold.exp_0, torch.cat((rings, rays)))
115
+ mean: Request | None = None
116
+ warnings: list[str] = []
117
+ if kappa <= 0 and len(included):
118
+ try:
119
+ estimate = context.call(frechet_mean, included[None], manifold)
120
+ average_log = context.call(
121
+ manifold.log, estimate[:, None, :], included[None]
122
+ ).mean(1)
123
+ residual = context.call(manifold.norm, estimate, average_log)[0]
124
+ converged = bool(
125
+ residual <= torch.finfo(context.dtype).eps ** MEAN_RESIDUAL_EPS_POWER
126
+ )
127
+ mean = {"point": estimate[0], "residual": residual, "converged": converged}
128
+ if not converged:
129
+ warnings.append(
130
+ "Fréchet iteration has not converged; "
131
+ "the marked point is only an estimate."
132
+ )
133
+ except (ValueError, RuntimeError) as error:
134
+ warnings.append(f"Fréchet mean unavailable: {error}")
135
+ return context.finish(
136
+ points=points,
137
+ valid=valid,
138
+ included_indices=indices,
139
+ distances=distance,
140
+ pairs=pairs,
141
+ curves=curves,
142
+ grid=grid,
143
+ mean=mean,
144
+ warnings=warnings,
145
+ boundary_radius=1 / math.sqrt(abs(kappa)) if kappa else None,
146
+ parameters={"kappa": kappa, "curve_samples": CURVE_SAMPLES},
147
+ chart_note=(
148
+ "Open hyperbolic ball"
149
+ if kappa < 0
150
+ else "Conservative hemispherical chart"
151
+ if kappa > 0
152
+ else "Flat chart: distance is 2 × Euclidean coordinate distance"
153
+ ),
154
+ )