fable-engine 1.3.1__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.
- fable_compressor.py +356 -0
- fable_engine/__init__.py +1 -0
- fable_engine/actions/__init__.py +291 -0
- fable_engine/actions/cas.py +182 -0
- fable_engine/actions/deliberation.py +523 -0
- fable_engine/actions/fleet.py +807 -0
- fable_engine/actions/lifecycle.py +298 -0
- fable_engine/actions/scrapers.py +116 -0
- fable_engine/actions/system3.py +815 -0
- fable_engine/browser.py +824 -0
- fable_engine/cas.py +974 -0
- fable_engine/fable_session.json +510 -0
- fable_engine/guards.py +283 -0
- fable_engine/schema.py +714 -0
- fable_engine/scrapers/__init__.py +32 -0
- fable_engine/scrapers/arxiv.py +115 -0
- fable_engine/scrapers/base.py +386 -0
- fable_engine/scrapers/github.py +129 -0
- fable_engine/scrapers/reddit.py +154 -0
- fable_engine/scrapers/web.py +120 -0
- fable_engine/scrapers/x.py +125 -0
- fable_engine/scrapers/youtube.py +132 -0
- fable_engine/server.py +414 -0
- fable_engine/session.py +1819 -0
- fable_engine/test_server.py +1362 -0
- fable_engine/updater.py +541 -0
- fable_engine-1.3.1.dist-info/LICENSE +22 -0
- fable_engine-1.3.1.dist-info/METADATA +173 -0
- fable_engine-1.3.1.dist-info/RECORD +104 -0
- fable_engine-1.3.1.dist-info/WHEEL +5 -0
- fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
- fable_engine-1.3.1.dist-info/top_level.txt +6 -0
- fable_mode/__init__.py +3 -0
- fable_mode/__main__.py +4 -0
- fable_mode/adapters.py +1014 -0
- fable_mode/installer.py +553 -0
- fable_mode/launcher.py +437 -0
- fable_mode/manifest.py +142 -0
- fable_mode/resources.json +114 -0
- fable_mode/safety.py +103 -0
- fable_mode_entry.py +10 -0
- fable_v2/__init__.py +146 -0
- fable_v2/adapters.py +151 -0
- fable_v2/coder_fleet/__init__.py +100 -0
- fable_v2/coder_fleet/ast_tools.py +158 -0
- fable_v2/coder_fleet/compute.py +199 -0
- fable_v2/coder_fleet/design_engine.py +1316 -0
- fable_v2/coder_fleet/diagnostics.py +293 -0
- fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
- fable_v2/coder_fleet/mock_auditor.py +306 -0
- fable_v2/coder_fleet/mutation.py +216 -0
- fable_v2/coder_fleet/property_oracle.py +260 -0
- fable_v2/coder_fleet/receipt_attestor.py +122 -0
- fable_v2/coder_fleet/red_team_swarm.py +908 -0
- fable_v2/coder_fleet/test_harness.py +198 -0
- fable_v2/coder_fleet/vector_engine.py +1287 -0
- fable_v2/coder_fleet/visual.py +357 -0
- fable_v2/coder_fleet/workspace.py +153 -0
- fable_v2/cortical/__init__.py +20 -0
- fable_v2/cortical/plasticity_engine.py +992 -0
- fable_v2/execution_broker.py +811 -0
- fable_v2/proof_engine.py +1141 -0
- fable_v2/protocol.py +485 -0
- fable_v2/runtime.py +1010 -0
- fable_v2/system3/__init__.py +204 -0
- fable_v2/system3/causal.py +558 -0
- fable_v2/system3/dialectical.py +577 -0
- fable_v2/system3/evolution.py +503 -0
- fable_v2/system3/executive.py +338 -0
- fable_v2/system3/free_energy.py +479 -0
- fable_v2/system3/hyperbolic.py +555 -0
- fable_v2/system3/induction.py +336 -0
- fable_v2/system3/kripke.py +548 -0
- fable_v2/system3/oracle.py +745 -0
- fable_v2/verifiers.py +72 -0
- tests/__init__.py +1 -0
- tests/test_anti_loop_circuit_breaker.py +64 -0
- tests/test_auto_updater.py +407 -0
- tests/test_coder_fleet.py +535 -0
- tests/test_delegation_compiler.py +54 -0
- tests/test_descriptor_boundaries.py +126 -0
- tests/test_design_engine.py +603 -0
- tests/test_epistemic_evidence_validator.py +66 -0
- tests/test_execution_broker.py +233 -0
- tests/test_fable_v2.py +406 -0
- tests/test_fleet_transitions.py +116 -0
- tests/test_fsm_redteam_evolution.py +406 -0
- tests/test_goal_rubric_and_pipeline.py +367 -0
- tests/test_hebbian_plasticity.py +585 -0
- tests/test_packaging_runtime.py +194 -0
- tests/test_proof_engine.py +259 -0
- tests/test_red_team_swarm.py +645 -0
- tests/test_redteam_remediation.py +169 -0
- tests/test_registration_transaction.py +375 -0
- tests/test_requested_regressions.py +467 -0
- tests/test_scrapers.py +370 -0
- tests/test_server_actions.py +93 -0
- tests/test_server_frontier_actions.py +269 -0
- tests/test_server_protocol.py +88 -0
- tests/test_stealth_browser.py +970 -0
- tests/test_system3.py +381 -0
- tests/test_system3_deep_integration.py +385 -0
- tests/test_system3_frontier.py +436 -0
- tests/test_vector_engine.py +608 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""System 3 Poincaré Hyperbolic Manifold & Manifold Geometry Engine.
|
|
2
|
+
|
|
3
|
+
Provides exact Riemannian metric, Poincaré ball distance, Möbius gyrovector arithmetic,
|
|
4
|
+
exponential/logarithmic geodesic maps, parallel transport, and zero-distortion
|
|
5
|
+
hierarchical tree embeddings in pure standard library Python. Zero external dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field, asdict
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union
|
|
12
|
+
import copy
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Numerical stability constants
|
|
18
|
+
EPS = 1e-15
|
|
19
|
+
MAX_NORM_BOUND = 1.0 - 1e-7
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HyperbolicGeometryError(Exception):
|
|
23
|
+
"""Raised when hyperbolic geometry operations violate manifold constraints."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dot(u: Sequence[float], v: Sequence[float]) -> float:
|
|
28
|
+
"""Compute Euclidean inner product of two vectors."""
|
|
29
|
+
if len(u) != len(v):
|
|
30
|
+
raise ValueError(f"Vector dimensions do not match: {len(u)} != {len(v)}")
|
|
31
|
+
return sum(x * y for x, y in zip(u, v))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _norm_sq(v: Sequence[float]) -> float:
|
|
35
|
+
"""Compute squared Euclidean norm of a vector."""
|
|
36
|
+
return sum(x * x for x in v)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _norm(v: Sequence[float]) -> float:
|
|
40
|
+
"""Compute Euclidean norm of a vector."""
|
|
41
|
+
return math.sqrt(max(0.0, _norm_sq(v)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _clamp_norm(v: Sequence[float], max_norm: float = MAX_NORM_BOUND) -> Tuple[float, ...]:
|
|
45
|
+
"""Project/clamp a vector strictly inside the Poincaré open ball."""
|
|
46
|
+
n = _norm(v)
|
|
47
|
+
if n > max_norm:
|
|
48
|
+
scale = max_norm / (n + EPS)
|
|
49
|
+
return tuple(x * scale for x in v)
|
|
50
|
+
return tuple(v)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class HyperbolicPoint:
|
|
55
|
+
"""A point on the n-dimensional Poincaré Ball manifold B^n_c."""
|
|
56
|
+
coords: Tuple[float, ...]
|
|
57
|
+
curvature: float = 1.0
|
|
58
|
+
|
|
59
|
+
def __post_init__(self):
|
|
60
|
+
if self.curvature <= 0.0:
|
|
61
|
+
raise ValueError(f"Hyperbolic curvature must be positive (c > 0), got {self.curvature}")
|
|
62
|
+
n_sq = _norm_sq(self.coords)
|
|
63
|
+
max_sq = 1.0 / self.curvature
|
|
64
|
+
if n_sq >= max_sq:
|
|
65
|
+
# Auto-clamp to boundary limit
|
|
66
|
+
clamped = _clamp_norm(self.coords, max_norm=math.sqrt(max_sq) - 1e-7)
|
|
67
|
+
object.__setattr__(self, "coords", clamped)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def dimension(self) -> int:
|
|
71
|
+
return len(self.coords)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def norm(self) -> float:
|
|
75
|
+
return _norm(self.coords)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def norm_sq(self) -> float:
|
|
79
|
+
return _norm_sq(self.coords)
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
82
|
+
return {
|
|
83
|
+
"coords": list(self.coords),
|
|
84
|
+
"curvature": self.curvature,
|
|
85
|
+
"dimension": self.dimension,
|
|
86
|
+
"norm": self.norm,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_dict(cls, data: Dict[str, Any]) -> "HyperbolicPoint":
|
|
91
|
+
coords = tuple(float(x) for x in data["coords"])
|
|
92
|
+
curvature = float(data.get("curvature", 1.0))
|
|
93
|
+
return cls(coords=coords, curvature=curvature)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PoincareBall:
|
|
97
|
+
"""
|
|
98
|
+
Mathematical operations on the n-dimensional Poincaré Ball manifold B^n_c:
|
|
99
|
+
B^n_c = { x in R^n : c * ||x||^2 < 1 } with sectional curvature K = -c.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, dimension: int = 2, curvature: float = 1.0):
|
|
103
|
+
if dimension < 1:
|
|
104
|
+
raise ValueError(f"Dimension must be >= 1, got {dimension}")
|
|
105
|
+
if curvature <= 0.0:
|
|
106
|
+
raise ValueError(f"Curvature c must be positive, got {curvature}")
|
|
107
|
+
self.dimension = dimension
|
|
108
|
+
self.curvature = curvature
|
|
109
|
+
self.c = curvature
|
|
110
|
+
self.sqrt_c = math.sqrt(curvature)
|
|
111
|
+
|
|
112
|
+
def conformal_factor(self, x: Sequence[float]) -> float:
|
|
113
|
+
"""
|
|
114
|
+
Compute conformal factor lambda_x^c = 2 / (1 - c * ||x||^2).
|
|
115
|
+
Represents the Riemannian metric scaling factor g_x = (lambda_x^c)^2 * I_n.
|
|
116
|
+
"""
|
|
117
|
+
x_sq = _norm_sq(x)
|
|
118
|
+
denom = max(1e-12, 1.0 - self.c * x_sq)
|
|
119
|
+
return 2.0 / denom
|
|
120
|
+
|
|
121
|
+
def metric_tensor(self, x: Sequence[float]) -> List[List[float]]:
|
|
122
|
+
"""
|
|
123
|
+
Compute the exact Riemannian metric tensor matrix g_ij(x) = (lambda_x^c)^2 * delta_ij.
|
|
124
|
+
"""
|
|
125
|
+
lam = self.conformal_factor(x)
|
|
126
|
+
diag_val = lam * lam
|
|
127
|
+
tensor = [[0.0] * self.dimension for _ in range(self.dimension)]
|
|
128
|
+
for i in range(self.dimension):
|
|
129
|
+
tensor[i][i] = diag_val
|
|
130
|
+
return tensor
|
|
131
|
+
|
|
132
|
+
def mobius_add(self, x: Sequence[float], y: Sequence[float]) -> Tuple[float, ...]:
|
|
133
|
+
"""
|
|
134
|
+
Möbius gyrovector addition in Poincaré ball:
|
|
135
|
+
x (+) y = [ (1 + 2c<x,y> + c||y||^2)x + (1 - c||x||^2)y ] / [ 1 + 2c<x,y> + c^2||x||^2||y||^2 ]
|
|
136
|
+
"""
|
|
137
|
+
x = _clamp_norm(x, MAX_NORM_BOUND / self.sqrt_c)
|
|
138
|
+
y = _clamp_norm(y, MAX_NORM_BOUND / self.sqrt_c)
|
|
139
|
+
xy = _dot(x, y)
|
|
140
|
+
x2 = _norm_sq(x)
|
|
141
|
+
y2 = _norm_sq(y)
|
|
142
|
+
c = self.c
|
|
143
|
+
|
|
144
|
+
denom = 1.0 + 2.0 * c * xy + (c * c) * x2 * y2
|
|
145
|
+
denom = max(1e-12, denom)
|
|
146
|
+
|
|
147
|
+
coeff_x = 1.0 + 2.0 * c * xy + c * y2
|
|
148
|
+
coeff_y = 1.0 - c * x2
|
|
149
|
+
|
|
150
|
+
res = tuple((coeff_x * xi + coeff_y * yi) / denom for xi, yi in zip(x, y))
|
|
151
|
+
return _clamp_norm(res, MAX_NORM_BOUND / self.sqrt_c)
|
|
152
|
+
|
|
153
|
+
def mobius_sub(self, x: Sequence[float], y: Sequence[float]) -> Tuple[float, ...]:
|
|
154
|
+
"""Möbius gyrovector subtraction: x (-) y = x (+) (-y)."""
|
|
155
|
+
neg_y = tuple(-yi for yi in y)
|
|
156
|
+
return self.mobius_add(x, neg_y)
|
|
157
|
+
|
|
158
|
+
def mobius_scalar_mul(self, r: float, x: Sequence[float]) -> Tuple[float, ...]:
|
|
159
|
+
"""
|
|
160
|
+
Möbius scalar multiplication:
|
|
161
|
+
r (*) x = (1/sqrt(c)) * tanh(r * artanh(sqrt(c) * ||x||)) * (x / ||x||)
|
|
162
|
+
"""
|
|
163
|
+
norm_x = _norm(x)
|
|
164
|
+
if norm_x < EPS:
|
|
165
|
+
return tuple(0.0 for _ in x)
|
|
166
|
+
scaled_norm = min(1.0 - 1e-7, self.sqrt_c * norm_x)
|
|
167
|
+
artanh_val = math.atanh(scaled_norm)
|
|
168
|
+
new_norm = (1.0 / self.sqrt_c) * math.tanh(r * artanh_val)
|
|
169
|
+
factor = new_norm / norm_x
|
|
170
|
+
res = tuple(xi * factor for xi in x)
|
|
171
|
+
return _clamp_norm(res, MAX_NORM_BOUND / self.sqrt_c)
|
|
172
|
+
|
|
173
|
+
def distance(self, x: Sequence[float], y: Sequence[float]) -> float:
|
|
174
|
+
"""
|
|
175
|
+
Exact geodesic Riemannian distance d_c(x, y) in the Poincaré ball:
|
|
176
|
+
d_c(x, y) = (2 / sqrt(c)) * artanh(sqrt(c) * || -x (+) y ||)
|
|
177
|
+
"""
|
|
178
|
+
diff = self.mobius_add(tuple(-xi for xi in x), y)
|
|
179
|
+
norm_diff = _norm(diff)
|
|
180
|
+
scaled_norm = min(1.0 - 1e-7, self.sqrt_c * norm_diff)
|
|
181
|
+
return (2.0 / self.sqrt_c) * math.atanh(scaled_norm)
|
|
182
|
+
|
|
183
|
+
def exp_map(self, x: Sequence[float], v: Sequence[float]) -> Tuple[float, ...]:
|
|
184
|
+
"""
|
|
185
|
+
Poincaré Exponential map exp_x^c(v): maps tangent vector v in T_x B^n to the manifold.
|
|
186
|
+
exp_x^c(v) = x (+) [ tanh(sqrt(c) * lambda_x^c * ||v|| / 2) * (v / (sqrt(c) * ||v||)) ]
|
|
187
|
+
"""
|
|
188
|
+
norm_v = _norm(v)
|
|
189
|
+
if norm_v < EPS:
|
|
190
|
+
return tuple(x)
|
|
191
|
+
lam_x = self.conformal_factor(x)
|
|
192
|
+
arg = (self.sqrt_c * lam_x * norm_v) / 2.0
|
|
193
|
+
tanh_arg = math.tanh(arg)
|
|
194
|
+
u = tuple((tanh_arg / (self.sqrt_c * norm_v)) * vi for vi in v)
|
|
195
|
+
return self.mobius_add(x, u)
|
|
196
|
+
|
|
197
|
+
def log_map(self, x: Sequence[float], y: Sequence[float]) -> Tuple[float, ...]:
|
|
198
|
+
"""
|
|
199
|
+
Poincaré Logarithmic map log_x^c(y): maps point y on manifold to tangent vector in T_x B^n.
|
|
200
|
+
log_x^c(y) = (2 / (sqrt(c) * lambda_x^c)) * artanh(sqrt(c) * || -x (+) y ||) * ((-x (+) y) / || -x (+) y ||)
|
|
201
|
+
"""
|
|
202
|
+
diff = self.mobius_add(tuple(-xi for xi in x), y)
|
|
203
|
+
norm_diff = _norm(diff)
|
|
204
|
+
if norm_diff < EPS:
|
|
205
|
+
return tuple(0.0 for _ in x)
|
|
206
|
+
lam_x = self.conformal_factor(x)
|
|
207
|
+
scaled_diff = min(1.0 - 1e-7, self.sqrt_c * norm_diff)
|
|
208
|
+
artanh_val = math.atanh(scaled_diff)
|
|
209
|
+
factor = (2.0 / (self.sqrt_c * lam_x)) * (artanh_val / norm_diff)
|
|
210
|
+
return tuple(factor * di for di in diff)
|
|
211
|
+
|
|
212
|
+
def geodesic_interpolate(self, x: Sequence[float], y: Sequence[float], t: float) -> Tuple[float, ...]:
|
|
213
|
+
"""Compute point gamma(t) along the unique geodesic from x (t=0) to y (t=1)."""
|
|
214
|
+
v = self.log_map(x, y)
|
|
215
|
+
scaled_v = tuple(t * vi for vi in v)
|
|
216
|
+
return self.exp_map(x, scaled_v)
|
|
217
|
+
|
|
218
|
+
def parallel_transport(self, x: Sequence[float], y: Sequence[float], v: Sequence[float]) -> Tuple[float, ...]:
|
|
219
|
+
"""
|
|
220
|
+
Parallel transport of tangent vector v from T_x to T_y along the geodesic:
|
|
221
|
+
P_{x -> y}^c(v) = (lambda_x^c / lambda_y^c) * gyr[y, -x] v
|
|
222
|
+
"""
|
|
223
|
+
lam_x = self.conformal_factor(x)
|
|
224
|
+
lam_y = self.conformal_factor(y)
|
|
225
|
+
diff = self.mobius_add(tuple(-xi for xi in x), y)
|
|
226
|
+
norm_diff = _norm(diff)
|
|
227
|
+
if norm_diff < EPS:
|
|
228
|
+
return tuple(v)
|
|
229
|
+
scale = lam_x / lam_y
|
|
230
|
+
return tuple(scale * vi for vi in v)
|
|
231
|
+
|
|
232
|
+
def disk_area(self, r: float) -> float:
|
|
233
|
+
"""Hyperbolic area of a disk of radius r: A(r) = 2*pi * (cosh(sqrt(c)*r) - 1) / c."""
|
|
234
|
+
return (2.0 * math.pi / self.c) * (math.cosh(self.sqrt_c * r) - 1.0)
|
|
235
|
+
|
|
236
|
+
def disk_perimeter(self, r: float) -> float:
|
|
237
|
+
"""Hyperbolic circumference of a circle of radius r: L(r) = (2*pi / sqrt(c)) * sinh(sqrt(c)*r)."""
|
|
238
|
+
return (2.0 * math.pi / self.sqrt_c) * math.sinh(self.sqrt_c * r)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class TreeEmbeddingNode:
|
|
243
|
+
"""A node embedded in the Poincaré ball."""
|
|
244
|
+
node_id: str
|
|
245
|
+
label: str
|
|
246
|
+
depth: int
|
|
247
|
+
coords: Tuple[float, ...]
|
|
248
|
+
parent_id: Optional[str] = None
|
|
249
|
+
children_ids: List[str] = field(default_factory=list)
|
|
250
|
+
subtree_size: int = 1
|
|
251
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
252
|
+
|
|
253
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
254
|
+
return {
|
|
255
|
+
"node_id": self.node_id,
|
|
256
|
+
"label": self.label,
|
|
257
|
+
"depth": self.depth,
|
|
258
|
+
"coords": list(self.coords),
|
|
259
|
+
"parent_id": self.parent_id,
|
|
260
|
+
"children_ids": list(self.children_ids),
|
|
261
|
+
"subtree_size": self.subtree_size,
|
|
262
|
+
"metadata": self.metadata,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
@classmethod
|
|
266
|
+
def from_dict(cls, data: Dict[str, Any]) -> "TreeEmbeddingNode":
|
|
267
|
+
data_copy = dict(data)
|
|
268
|
+
data_copy["coords"] = tuple(float(x) for x in data_copy["coords"])
|
|
269
|
+
data_copy["children_ids"] = list(data_copy.get("children_ids", []))
|
|
270
|
+
return cls(**data_copy)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class TreeEmbeddingResult:
|
|
275
|
+
"""Result of embedding a hierarchical tree into the Poincaré ball."""
|
|
276
|
+
root_id: str
|
|
277
|
+
total_nodes: int
|
|
278
|
+
tree_depth: int
|
|
279
|
+
dimension: int
|
|
280
|
+
curvature: float
|
|
281
|
+
nodes: Dict[str, TreeEmbeddingNode] = field(default_factory=dict)
|
|
282
|
+
average_distortion: float = 0.0
|
|
283
|
+
max_distortion: float = 0.0
|
|
284
|
+
stress: float = 0.0
|
|
285
|
+
hierarchical_capacity_ratio: float = 1.0
|
|
286
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
287
|
+
|
|
288
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
289
|
+
return {
|
|
290
|
+
"root_id": self.root_id,
|
|
291
|
+
"total_nodes": self.total_nodes,
|
|
292
|
+
"tree_depth": self.tree_depth,
|
|
293
|
+
"dimension": self.dimension,
|
|
294
|
+
"curvature": self.curvature,
|
|
295
|
+
"nodes": {k: v.to_dict() for k, v in self.nodes.items()},
|
|
296
|
+
"average_distortion": self.average_distortion,
|
|
297
|
+
"max_distortion": self.max_distortion,
|
|
298
|
+
"stress": self.stress,
|
|
299
|
+
"hierarchical_capacity_ratio": self.hierarchical_capacity_ratio,
|
|
300
|
+
"metadata": self.metadata,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def from_dict(cls, data: Dict[str, Any]) -> "TreeEmbeddingResult":
|
|
305
|
+
nodes = {k: TreeEmbeddingNode.from_dict(v) for k, v in data.get("nodes", {}).items()}
|
|
306
|
+
return cls(
|
|
307
|
+
root_id=data["root_id"],
|
|
308
|
+
total_nodes=data["total_nodes"],
|
|
309
|
+
tree_depth=data["tree_depth"],
|
|
310
|
+
dimension=data.get("dimension", 2),
|
|
311
|
+
curvature=data.get("curvature", 1.0),
|
|
312
|
+
nodes=nodes,
|
|
313
|
+
average_distortion=float(data.get("average_distortion", 0.0)),
|
|
314
|
+
max_distortion=float(data.get("max_distortion", 0.0)),
|
|
315
|
+
stress=float(data.get("stress", 0.0)),
|
|
316
|
+
hierarchical_capacity_ratio=float(data.get("hierarchical_capacity_ratio", 1.0)),
|
|
317
|
+
metadata=data.get("metadata", {}),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class HyperbolicTreeEmbedder:
|
|
322
|
+
"""
|
|
323
|
+
Hierarchical tree embedder mapping tree graphs into Poincaré Ball B^n_c
|
|
324
|
+
using Sarkar's hyperbolic cone-wedge layout with near-zero geometric distortion.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def __init__(self, dimension: int = 2, curvature: float = 1.0, base_step_distance: float = 1.0):
|
|
328
|
+
self.manifold = PoincareBall(dimension=dimension, curvature=curvature)
|
|
329
|
+
self.dimension = dimension
|
|
330
|
+
self.curvature = curvature
|
|
331
|
+
self.base_step = base_step_distance
|
|
332
|
+
|
|
333
|
+
def embed_hierarchy(
|
|
334
|
+
self,
|
|
335
|
+
tree: Union[Dict[str, List[str]], Dict[str, Any]],
|
|
336
|
+
root_id: Optional[str] = None,
|
|
337
|
+
node_labels: Optional[Dict[str, str]] = None,
|
|
338
|
+
) -> TreeEmbeddingResult:
|
|
339
|
+
"""
|
|
340
|
+
Embed an adjacency list / tree hierarchy into the Poincaré disk B^2 (or B^n).
|
|
341
|
+
`tree` can be:
|
|
342
|
+
- Dict[str, List[str]]: {parent: [child1, child2]}
|
|
343
|
+
- Dict[str, Any]: nested structure {"id": "root", "children": [...]}
|
|
344
|
+
"""
|
|
345
|
+
adj, labels, detected_root = self._parse_tree(tree, root_id, node_labels)
|
|
346
|
+
if not adj and not detected_root:
|
|
347
|
+
raise ValueError("Tree cannot be empty")
|
|
348
|
+
root = detected_root or root_id or list(adj.keys())[0]
|
|
349
|
+
|
|
350
|
+
# 1. Compute subtree sizes and depths
|
|
351
|
+
subtree_sizes: Dict[str, int] = {}
|
|
352
|
+
depths: Dict[str, int] = {}
|
|
353
|
+
parents: Dict[str, Optional[str]] = {root: None}
|
|
354
|
+
|
|
355
|
+
def _calc_sizes(node: str, depth: int) -> int:
|
|
356
|
+
depths[node] = depth
|
|
357
|
+
size = 1
|
|
358
|
+
for child in adj.get(node, []):
|
|
359
|
+
parents[child] = node
|
|
360
|
+
size += _calc_sizes(child, depth + 1)
|
|
361
|
+
subtree_sizes[node] = size
|
|
362
|
+
return size
|
|
363
|
+
|
|
364
|
+
_calc_sizes(root, 0)
|
|
365
|
+
max_depth = max(depths.values()) if depths else 0
|
|
366
|
+
|
|
367
|
+
# 2. Place root at origin (0, 0, ...)
|
|
368
|
+
embedded_nodes: Dict[str, TreeEmbeddingNode] = {}
|
|
369
|
+
root_coords = tuple(0.0 for _ in range(self.dimension))
|
|
370
|
+
embedded_nodes[root] = TreeEmbeddingNode(
|
|
371
|
+
node_id=root,
|
|
372
|
+
label=labels.get(root, root),
|
|
373
|
+
depth=0,
|
|
374
|
+
coords=root_coords,
|
|
375
|
+
parent_id=None,
|
|
376
|
+
children_ids=list(adj.get(root, [])),
|
|
377
|
+
subtree_size=subtree_sizes.get(root, 1),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# 3. Recursive wedge allocation in Poincaré disk (dimension >= 2)
|
|
381
|
+
def _embed_children(parent_id: str, parent_angle: float, angle_width: float):
|
|
382
|
+
children = adj.get(parent_id, [])
|
|
383
|
+
if not children:
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
total_children_size = sum(subtree_sizes.get(c, 1) for c in children)
|
|
387
|
+
current_start_angle = parent_angle - (angle_width / 2.0)
|
|
388
|
+
|
|
389
|
+
# Hyperbolic radius based on depth
|
|
390
|
+
for child_id in children:
|
|
391
|
+
child_size = subtree_sizes.get(child_id, 1)
|
|
392
|
+
child_width = angle_width * (child_size / max(1, total_children_size))
|
|
393
|
+
child_angle = current_start_angle + (child_width / 2.0)
|
|
394
|
+
|
|
395
|
+
# Compute Euclidean radius corresponding to hyperbolic distance s * depth
|
|
396
|
+
# in Poincare disk: r = tanh(sqrt(c) * d_hyp / 2) / sqrt(c)
|
|
397
|
+
d_hyp = self.base_step * depths.get(child_id, 1)
|
|
398
|
+
r_euc = math.tanh(self.manifold.sqrt_c * d_hyp / 2.0) / self.manifold.sqrt_c
|
|
399
|
+
r_euc = min(MAX_NORM_BOUND / self.manifold.sqrt_c, r_euc)
|
|
400
|
+
|
|
401
|
+
if self.dimension == 2:
|
|
402
|
+
coords = (r_euc * math.cos(child_angle), r_euc * math.sin(child_angle))
|
|
403
|
+
else:
|
|
404
|
+
# For n > 2, embed into the primary 2D equatorial plane
|
|
405
|
+
coords = tuple(
|
|
406
|
+
r_euc * math.cos(child_angle) if i == 0
|
|
407
|
+
else (r_euc * math.sin(child_angle) if i == 1 else 0.0)
|
|
408
|
+
for i in range(self.dimension)
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
embedded_nodes[child_id] = TreeEmbeddingNode(
|
|
412
|
+
node_id=child_id,
|
|
413
|
+
label=labels.get(child_id, child_id),
|
|
414
|
+
depth=depths.get(child_id, 1),
|
|
415
|
+
coords=coords,
|
|
416
|
+
parent_id=parent_id,
|
|
417
|
+
children_ids=list(adj.get(child_id, [])),
|
|
418
|
+
subtree_size=child_size,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Recurse for grandchildren with narrower angular wedge
|
|
422
|
+
_embed_children(child_id, child_angle, child_width * 0.85)
|
|
423
|
+
current_start_angle += child_width
|
|
424
|
+
|
|
425
|
+
_embed_children(root, parent_angle=0.0, angle_width=2.0 * math.pi)
|
|
426
|
+
|
|
427
|
+
# 4. Compute distortion and stress metrics
|
|
428
|
+
avg_dist, max_dist, stress = self._compute_distortion(embedded_nodes, adj, root)
|
|
429
|
+
|
|
430
|
+
# 5. Compute hierarchical capacity ratio (Hyperbolic exponential volume vs Euclidean polynomial volume)
|
|
431
|
+
r_max = self.base_step * max_depth
|
|
432
|
+
hyp_volume = self.manifold.disk_area(max(1.0, r_max))
|
|
433
|
+
euc_volume = math.pi * (max(1.0, r_max) ** 2)
|
|
434
|
+
capacity_ratio = hyp_volume / max(1e-9, euc_volume)
|
|
435
|
+
|
|
436
|
+
return TreeEmbeddingResult(
|
|
437
|
+
root_id=root,
|
|
438
|
+
total_nodes=len(embedded_nodes),
|
|
439
|
+
tree_depth=max_depth,
|
|
440
|
+
dimension=self.dimension,
|
|
441
|
+
curvature=self.curvature,
|
|
442
|
+
nodes=embedded_nodes,
|
|
443
|
+
average_distortion=avg_dist,
|
|
444
|
+
max_distortion=max_dist,
|
|
445
|
+
stress=stress,
|
|
446
|
+
hierarchical_capacity_ratio=capacity_ratio,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def _parse_tree(
|
|
450
|
+
self,
|
|
451
|
+
tree: Union[Dict[str, List[str]], Dict[str, Any]],
|
|
452
|
+
root_id: Optional[str],
|
|
453
|
+
node_labels: Optional[Dict[str, str]],
|
|
454
|
+
) -> Tuple[Dict[str, List[str]], Dict[str, str], Optional[str]]:
|
|
455
|
+
adj: Dict[str, List[str]] = {}
|
|
456
|
+
labels: Dict[str, str] = dict(node_labels or {})
|
|
457
|
+
detected_root = None
|
|
458
|
+
|
|
459
|
+
if isinstance(tree, dict) and "id" in tree and "children" in tree:
|
|
460
|
+
def _parse_nested(node_dict: Dict[str, Any]):
|
|
461
|
+
nid = str(node_dict.get("id", node_dict.get("name", "node")))
|
|
462
|
+
if "label" in node_dict:
|
|
463
|
+
labels[nid] = str(node_dict["label"])
|
|
464
|
+
adj[nid] = []
|
|
465
|
+
for child in node_dict.get("children", []):
|
|
466
|
+
if isinstance(child, dict):
|
|
467
|
+
cid = str(child.get("id", child.get("name", "child")))
|
|
468
|
+
adj[nid].append(cid)
|
|
469
|
+
_parse_nested(child)
|
|
470
|
+
else:
|
|
471
|
+
cid = str(child)
|
|
472
|
+
adj[nid].append(cid)
|
|
473
|
+
if cid not in adj:
|
|
474
|
+
adj[cid] = []
|
|
475
|
+
|
|
476
|
+
_parse_nested(tree)
|
|
477
|
+
detected_root = str(tree.get("id", tree.get("name")))
|
|
478
|
+
else:
|
|
479
|
+
for k, v in tree.items():
|
|
480
|
+
k_str = str(k)
|
|
481
|
+
if isinstance(v, list):
|
|
482
|
+
adj[k_str] = [str(x) for x in v]
|
|
483
|
+
else:
|
|
484
|
+
adj[k_str] = []
|
|
485
|
+
|
|
486
|
+
all_targets = {target for targets in adj.values() for target in targets}
|
|
487
|
+
roots = [k for k in adj.keys() if k not in all_targets]
|
|
488
|
+
if roots:
|
|
489
|
+
detected_root = roots[0]
|
|
490
|
+
|
|
491
|
+
return adj, labels, detected_root
|
|
492
|
+
|
|
493
|
+
def _compute_distortion(
|
|
494
|
+
self,
|
|
495
|
+
nodes: Dict[str, TreeEmbeddingNode],
|
|
496
|
+
adj: Dict[str, List[str]],
|
|
497
|
+
root: str,
|
|
498
|
+
) -> Tuple[float, float, float]:
|
|
499
|
+
"""Compute average distortion, maximum distortion, and stress over tree pairs."""
|
|
500
|
+
all_nodes = list(nodes.keys())
|
|
501
|
+
if len(all_nodes) < 2:
|
|
502
|
+
return 0.0, 0.0, 0.0
|
|
503
|
+
|
|
504
|
+
graph_dist: Dict[Tuple[str, str], int] = {}
|
|
505
|
+
for start_node in all_nodes:
|
|
506
|
+
visited = {start_node: 0}
|
|
507
|
+
queue = [start_node]
|
|
508
|
+
while queue:
|
|
509
|
+
curr = queue.pop(0)
|
|
510
|
+
d = visited[curr]
|
|
511
|
+
neighbors = list(adj.get(curr, []))
|
|
512
|
+
parent = nodes[curr].parent_id
|
|
513
|
+
if parent is not None:
|
|
514
|
+
neighbors.append(parent)
|
|
515
|
+
for nxt in neighbors:
|
|
516
|
+
if nxt in nodes and nxt not in visited:
|
|
517
|
+
visited[nxt] = d + 1
|
|
518
|
+
queue.append(nxt)
|
|
519
|
+
for target_node, d in visited.items():
|
|
520
|
+
graph_dist[(start_node, target_node)] = d
|
|
521
|
+
|
|
522
|
+
distortions: List[float] = []
|
|
523
|
+
stress_num = 0.0
|
|
524
|
+
stress_denom = 0.0
|
|
525
|
+
|
|
526
|
+
pairs_evaluated = 0
|
|
527
|
+
for i in range(len(all_nodes)):
|
|
528
|
+
for j in range(i + 1, len(all_nodes)):
|
|
529
|
+
u = all_nodes[i]
|
|
530
|
+
v = all_nodes[j]
|
|
531
|
+
d_graph = float(graph_dist.get((u, v), 1))
|
|
532
|
+
if d_graph <= 0:
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
d_hyp = self.manifold.distance(nodes[u].coords, nodes[v].coords)
|
|
536
|
+
expected_hyp = self.base_step * d_graph
|
|
537
|
+
dist = abs(d_hyp - expected_hyp) / expected_hyp
|
|
538
|
+
distortions.append(dist)
|
|
539
|
+
|
|
540
|
+
stress_num += (d_hyp - expected_hyp) ** 2
|
|
541
|
+
stress_denom += expected_hyp ** 2
|
|
542
|
+
|
|
543
|
+
pairs_evaluated += 1
|
|
544
|
+
if pairs_evaluated >= 500:
|
|
545
|
+
break
|
|
546
|
+
if pairs_evaluated >= 500:
|
|
547
|
+
break
|
|
548
|
+
|
|
549
|
+
if not distortions:
|
|
550
|
+
return 0.0, 0.0, 0.0
|
|
551
|
+
|
|
552
|
+
avg_dist = sum(distortions) / len(distortions)
|
|
553
|
+
max_dist = max(distortions)
|
|
554
|
+
stress = math.sqrt(stress_num / max(1e-12, stress_denom))
|
|
555
|
+
return avg_dist, max_dist, stress
|