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.
Files changed (104) hide show
  1. fable_compressor.py +356 -0
  2. fable_engine/__init__.py +1 -0
  3. fable_engine/actions/__init__.py +291 -0
  4. fable_engine/actions/cas.py +182 -0
  5. fable_engine/actions/deliberation.py +523 -0
  6. fable_engine/actions/fleet.py +807 -0
  7. fable_engine/actions/lifecycle.py +298 -0
  8. fable_engine/actions/scrapers.py +116 -0
  9. fable_engine/actions/system3.py +815 -0
  10. fable_engine/browser.py +824 -0
  11. fable_engine/cas.py +974 -0
  12. fable_engine/fable_session.json +510 -0
  13. fable_engine/guards.py +283 -0
  14. fable_engine/schema.py +714 -0
  15. fable_engine/scrapers/__init__.py +32 -0
  16. fable_engine/scrapers/arxiv.py +115 -0
  17. fable_engine/scrapers/base.py +386 -0
  18. fable_engine/scrapers/github.py +129 -0
  19. fable_engine/scrapers/reddit.py +154 -0
  20. fable_engine/scrapers/web.py +120 -0
  21. fable_engine/scrapers/x.py +125 -0
  22. fable_engine/scrapers/youtube.py +132 -0
  23. fable_engine/server.py +414 -0
  24. fable_engine/session.py +1819 -0
  25. fable_engine/test_server.py +1362 -0
  26. fable_engine/updater.py +541 -0
  27. fable_engine-1.3.1.dist-info/LICENSE +22 -0
  28. fable_engine-1.3.1.dist-info/METADATA +173 -0
  29. fable_engine-1.3.1.dist-info/RECORD +104 -0
  30. fable_engine-1.3.1.dist-info/WHEEL +5 -0
  31. fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
  32. fable_engine-1.3.1.dist-info/top_level.txt +6 -0
  33. fable_mode/__init__.py +3 -0
  34. fable_mode/__main__.py +4 -0
  35. fable_mode/adapters.py +1014 -0
  36. fable_mode/installer.py +553 -0
  37. fable_mode/launcher.py +437 -0
  38. fable_mode/manifest.py +142 -0
  39. fable_mode/resources.json +114 -0
  40. fable_mode/safety.py +103 -0
  41. fable_mode_entry.py +10 -0
  42. fable_v2/__init__.py +146 -0
  43. fable_v2/adapters.py +151 -0
  44. fable_v2/coder_fleet/__init__.py +100 -0
  45. fable_v2/coder_fleet/ast_tools.py +158 -0
  46. fable_v2/coder_fleet/compute.py +199 -0
  47. fable_v2/coder_fleet/design_engine.py +1316 -0
  48. fable_v2/coder_fleet/diagnostics.py +293 -0
  49. fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
  50. fable_v2/coder_fleet/mock_auditor.py +306 -0
  51. fable_v2/coder_fleet/mutation.py +216 -0
  52. fable_v2/coder_fleet/property_oracle.py +260 -0
  53. fable_v2/coder_fleet/receipt_attestor.py +122 -0
  54. fable_v2/coder_fleet/red_team_swarm.py +908 -0
  55. fable_v2/coder_fleet/test_harness.py +198 -0
  56. fable_v2/coder_fleet/vector_engine.py +1287 -0
  57. fable_v2/coder_fleet/visual.py +357 -0
  58. fable_v2/coder_fleet/workspace.py +153 -0
  59. fable_v2/cortical/__init__.py +20 -0
  60. fable_v2/cortical/plasticity_engine.py +992 -0
  61. fable_v2/execution_broker.py +811 -0
  62. fable_v2/proof_engine.py +1141 -0
  63. fable_v2/protocol.py +485 -0
  64. fable_v2/runtime.py +1010 -0
  65. fable_v2/system3/__init__.py +204 -0
  66. fable_v2/system3/causal.py +558 -0
  67. fable_v2/system3/dialectical.py +577 -0
  68. fable_v2/system3/evolution.py +503 -0
  69. fable_v2/system3/executive.py +338 -0
  70. fable_v2/system3/free_energy.py +479 -0
  71. fable_v2/system3/hyperbolic.py +555 -0
  72. fable_v2/system3/induction.py +336 -0
  73. fable_v2/system3/kripke.py +548 -0
  74. fable_v2/system3/oracle.py +745 -0
  75. fable_v2/verifiers.py +72 -0
  76. tests/__init__.py +1 -0
  77. tests/test_anti_loop_circuit_breaker.py +64 -0
  78. tests/test_auto_updater.py +407 -0
  79. tests/test_coder_fleet.py +535 -0
  80. tests/test_delegation_compiler.py +54 -0
  81. tests/test_descriptor_boundaries.py +126 -0
  82. tests/test_design_engine.py +603 -0
  83. tests/test_epistemic_evidence_validator.py +66 -0
  84. tests/test_execution_broker.py +233 -0
  85. tests/test_fable_v2.py +406 -0
  86. tests/test_fleet_transitions.py +116 -0
  87. tests/test_fsm_redteam_evolution.py +406 -0
  88. tests/test_goal_rubric_and_pipeline.py +367 -0
  89. tests/test_hebbian_plasticity.py +585 -0
  90. tests/test_packaging_runtime.py +194 -0
  91. tests/test_proof_engine.py +259 -0
  92. tests/test_red_team_swarm.py +645 -0
  93. tests/test_redteam_remediation.py +169 -0
  94. tests/test_registration_transaction.py +375 -0
  95. tests/test_requested_regressions.py +467 -0
  96. tests/test_scrapers.py +370 -0
  97. tests/test_server_actions.py +93 -0
  98. tests/test_server_frontier_actions.py +269 -0
  99. tests/test_server_protocol.py +88 -0
  100. tests/test_stealth_browser.py +970 -0
  101. tests/test_system3.py +381 -0
  102. tests/test_system3_deep_integration.py +385 -0
  103. tests/test_system3_frontier.py +436 -0
  104. 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