pycode-kg 0.16.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 (63) hide show
  1. pycode_kg/.DS_Store +0 -0
  2. pycode_kg/__init__.py +91 -0
  3. pycode_kg/__main__.py +11 -0
  4. pycode_kg/analysis/__init__.py +15 -0
  5. pycode_kg/analysis/bridge.py +108 -0
  6. pycode_kg/analysis/centrality.py +412 -0
  7. pycode_kg/analysis/framework_detector.py +103 -0
  8. pycode_kg/analysis/hybrid_rank.py +53 -0
  9. pycode_kg/app.py +1335 -0
  10. pycode_kg/architecture.py +624 -0
  11. pycode_kg/build_pycodekg_lancedb.py +15 -0
  12. pycode_kg/build_pycodekg_sqlite.py +15 -0
  13. pycode_kg/cli/__init__.py +29 -0
  14. pycode_kg/cli/cmd_analyze.py +96 -0
  15. pycode_kg/cli/cmd_architecture.py +150 -0
  16. pycode_kg/cli/cmd_bridges.py +26 -0
  17. pycode_kg/cli/cmd_build.py +154 -0
  18. pycode_kg/cli/cmd_build_full.py +242 -0
  19. pycode_kg/cli/cmd_centrality.py +131 -0
  20. pycode_kg/cli/cmd_explain.py +180 -0
  21. pycode_kg/cli/cmd_framework_nodes.py +18 -0
  22. pycode_kg/cli/cmd_hooks.py +137 -0
  23. pycode_kg/cli/cmd_init.py +312 -0
  24. pycode_kg/cli/cmd_mcp.py +71 -0
  25. pycode_kg/cli/cmd_model.py +53 -0
  26. pycode_kg/cli/cmd_query.py +211 -0
  27. pycode_kg/cli/cmd_snapshot.py +421 -0
  28. pycode_kg/cli/cmd_viz.py +180 -0
  29. pycode_kg/cli/main.py +23 -0
  30. pycode_kg/cli/options.py +63 -0
  31. pycode_kg/config.py +78 -0
  32. pycode_kg/graph.py +125 -0
  33. pycode_kg/index.py +542 -0
  34. pycode_kg/kg.py +220 -0
  35. pycode_kg/layout3d.py +470 -0
  36. pycode_kg/mcp/bridge_tools.py +19 -0
  37. pycode_kg/mcp/framework_tools.py +18 -0
  38. pycode_kg/mcp_server.py +1965 -0
  39. pycode_kg/module/__init__.py +83 -0
  40. pycode_kg/module/base.py +720 -0
  41. pycode_kg/module/extractor.py +276 -0
  42. pycode_kg/module/types.py +532 -0
  43. pycode_kg/pycodekg.py +543 -0
  44. pycode_kg/pycodekg_query.py +1 -0
  45. pycode_kg/pycodekg_snippet_packer.py +1 -0
  46. pycode_kg/pycodekg_thorough_analysis.py +2751 -0
  47. pycode_kg/pycodekg_viz.py +1 -0
  48. pycode_kg/pycodekg_viz3d.py +1 -0
  49. pycode_kg/ranking/__init__.py +1 -0
  50. pycode_kg/ranking/cli_rank.py +92 -0
  51. pycode_kg/ranking/coderank.py +555 -0
  52. pycode_kg/snapshots.py +612 -0
  53. pycode_kg/sql/004_add_centrality_table.sql +12 -0
  54. pycode_kg/store.py +766 -0
  55. pycode_kg/utils.py +39 -0
  56. pycode_kg/visitor.py +413 -0
  57. pycode_kg/viz3d.py +1353 -0
  58. pycode_kg/viz3d_timeline.py +364 -0
  59. pycode_kg-0.16.0.dist-info/METADATA +305 -0
  60. pycode_kg-0.16.0.dist-info/RECORD +63 -0
  61. pycode_kg-0.16.0.dist-info/WHEEL +4 -0
  62. pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
  63. pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
pycode_kg/kg.py ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ kg.py
4
+
5
+ PyCodeKG — concrete KGModule implementation for Python codebases.
6
+
7
+ Owns the Python-specific extraction layer (CodeGraph / AST) and delegates
8
+ all generic infrastructure (SQLite, LanceDB, hybrid query, snippet packing)
9
+ to the KGModule base class.
10
+
11
+ Also re-exports the result types (BuildStats, QueryResult, Snippet,
12
+ SnippetPack) from module.types for backwards API compatibility.
13
+
14
+ Author: Eric G. Suchanek, PhD
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from pycode_kg.graph import CodeGraph
22
+ from pycode_kg.module.base import KGModule
23
+ from pycode_kg.module.extractor import KGExtractor, PyCodeKGExtractor
24
+ from pycode_kg.module.types import (
25
+ BuildStats,
26
+ QueryResult,
27
+ Snippet,
28
+ SnippetPack,
29
+ )
30
+ from pycode_kg.pycodekg import DEFAULT_MODEL
31
+ from pycode_kg.store import GraphStore
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Re-export result types for backwards compatibility
35
+ # (code that does ``from pycode_kg.kg import BuildStats`` keeps working)
36
+ # ---------------------------------------------------------------------------
37
+
38
+ __all__ = [
39
+ "PyCodeKG",
40
+ "BuildStats",
41
+ "QueryResult",
42
+ "Snippet",
43
+ "SnippetPack",
44
+ ]
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Python-specific kind priority
48
+ # ---------------------------------------------------------------------------
49
+
50
+ _PYCODEKG_KIND_PRIORITY: dict[str, int] = {
51
+ "function": 0,
52
+ "method": 1,
53
+ "class": 2,
54
+ "module": 3,
55
+ "symbol": 4,
56
+ }
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # PyCodeKG — concrete KGModule for Python
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ class PyCodeKG(KGModule):
65
+ """
66
+ Top-level orchestrator for the Code Knowledge Graph.
67
+
68
+ Subclasses :class:`~pycode_kg.module.base.KGModule` and provides the
69
+ Python-AST-specific extraction layer via :class:`PyCodeKGExtractor`.
70
+ All generic infrastructure — SQLite persistence, LanceDB indexing,
71
+ hybrid query, snippet packing, snapshots — is inherited from
72
+ :class:`~pycode_kg.module.base.KGModule`.
73
+
74
+ Typical usage::
75
+
76
+ kg = PyCodeKG(repo_root="/path/to/repo")
77
+ stats = kg.build(wipe=True)
78
+ print(stats)
79
+
80
+ result = kg.query("database connection setup", k=8, hop=1)
81
+ result.print_summary()
82
+
83
+ pack = kg.pack("configuration loading", k=8, hop=1)
84
+ pack.save("context.md")
85
+
86
+ :param repo_root: Repository root directory.
87
+ :param db_path: SQLite database path (defaults to
88
+ ``<repo_root>/.pycodekg/graph.sqlite``).
89
+ :param lancedb_dir: LanceDB directory (defaults to
90
+ ``<repo_root>/.pycodekg/lancedb``).
91
+ :param model: Sentence-transformer model name.
92
+ :param table: LanceDB table name.
93
+ """
94
+
95
+ _default_dir = ".pycodekg"
96
+
97
+ def __init__(
98
+ self,
99
+ repo_root: str | Path,
100
+ db_path: str | Path | None = None,
101
+ lancedb_dir: str | Path | None = None,
102
+ *,
103
+ model: str = DEFAULT_MODEL,
104
+ table: str = "pycodekg_nodes",
105
+ ) -> None:
106
+ """Initialise ``PyCodeKG`` and resolve all paths.
107
+
108
+ :param repo_root: Repository root directory; resolved to an absolute path.
109
+ :param db_path: Path to the SQLite database file.
110
+ :param lancedb_dir: Directory used by LanceDB for the vector index.
111
+ :param model: Sentence-transformer model name used for embedding.
112
+ :param table: LanceDB table name for the node index.
113
+ """
114
+ super().__init__(
115
+ repo_root,
116
+ db_path=db_path,
117
+ lancedb_dir=lancedb_dir,
118
+ model=model,
119
+ table=table,
120
+ )
121
+ # Backwards-compatible cached CodeGraph (direct access still works)
122
+ self._graph: CodeGraph | None = None
123
+
124
+ # ------------------------------------------------------------------
125
+ # KGModule abstract interface
126
+ # ------------------------------------------------------------------
127
+
128
+ def make_extractor(self) -> KGExtractor:
129
+ """Return a PyCodeKGExtractor for this repository.
130
+
131
+ :return: :class:`PyCodeKGExtractor` targeting :attr:`repo_root`.
132
+ """
133
+ return PyCodeKGExtractor(self.repo_root)
134
+
135
+ def kind(self) -> str:
136
+ """Return the KG kind string.
137
+
138
+ :return: ``"code"``
139
+ """
140
+ return "code"
141
+
142
+ def analyze(self, *, include_edge_provenance: bool = False) -> str:
143
+ """Run full nine-phase architectural analysis and return Markdown.
144
+
145
+ Delegates to :class:`~pycode_kg.pycodekg_thorough_analysis.PyCodeKGAnalyzer`.
146
+ This is the same analysis produced by ``pycodekg analyze``.
147
+
148
+ :param include_edge_provenance: Annotate edges with confidence metadata.
149
+ :return: Markdown-formatted architectural analysis report.
150
+ """
151
+ try:
152
+ from io import StringIO # pylint: disable=import-outside-toplevel
153
+
154
+ from rich.console import Console # pylint: disable=import-outside-toplevel
155
+
156
+ from pycode_kg.pycodekg_thorough_analysis import ( # pylint: disable=import-outside-toplevel
157
+ PyCodeKGAnalyzer,
158
+ )
159
+
160
+ silent = Console(file=StringIO(), highlight=False)
161
+ analyzer = PyCodeKGAnalyzer(self, console=silent)
162
+ analyzer.run_analysis()
163
+ return analyzer.to_markdown()
164
+ except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
165
+ return f"# PyCodeKG Analysis\n\nAnalysis failed: {exc}\n"
166
+
167
+ # ------------------------------------------------------------------
168
+ # Python-specific overrides
169
+ # ------------------------------------------------------------------
170
+
171
+ def _kind_priority(self, kind: str) -> int:
172
+ """Return Python-specific node kind sort priority.
173
+
174
+ :param kind: Node kind string.
175
+ :return: Integer priority (lower = ranked first).
176
+ """
177
+ return _PYCODEKG_KIND_PRIORITY.get(kind, 99)
178
+
179
+ def _post_build_hook(self, store: GraphStore) -> None:
180
+ """Resolve Python symbol stubs after writing nodes/edges.
181
+
182
+ Called by :meth:`~pycode_kg.module.base.KGModule.build_graph` after
183
+ ``store.write()``. Links ``sym:`` import stubs to their definitions
184
+ via ``RESOLVES_TO`` edges.
185
+
186
+ :param store: The :class:`~pycode_kg.store.GraphStore` just written to.
187
+ """
188
+ store.resolve_symbols()
189
+
190
+ # ------------------------------------------------------------------
191
+ # Backwards-compatible CodeGraph property
192
+ # ------------------------------------------------------------------
193
+
194
+ @property
195
+ def graph(self) -> CodeGraph:
196
+ """Python AST extraction layer (lazy, cached).
197
+
198
+ Kept for backwards compatibility with code that accesses
199
+ ``kg.graph`` directly. The build pipeline uses
200
+ :meth:`make_extractor` instead.
201
+ """
202
+ if self._graph is None:
203
+ self._graph = CodeGraph(self.repo_root)
204
+ return self._graph
205
+
206
+ # ------------------------------------------------------------------
207
+ # Repr
208
+ # ------------------------------------------------------------------
209
+
210
+ def __repr__(self) -> str:
211
+ """Return an unambiguous string representation.
212
+
213
+ :return: ``PyCodeKG(repo_root=..., db_path=..., lancedb_dir=..., model=...)``.
214
+ """
215
+ return (
216
+ f"PyCodeKG(repo_root={self.repo_root!r}, "
217
+ f"db_path={self.db_path!r}, "
218
+ f"lancedb_dir={self.lancedb_dir!r}, "
219
+ f"model={self.model_name!r})"
220
+ )
pycode_kg/layout3d.py ADDED
@@ -0,0 +1,470 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ layout3d.py — Pluggable 3-D layout engine for the PyCodeKG knowledge graph.
4
+
5
+ Provides an abstract :class:`Layout3D` base class and two concrete
6
+ implementations:
7
+
8
+ - :class:`AlliumLayout`: Each module is rendered as a Giant Allium plant
9
+ (a vertical stem with a Fibonacci-sphere "head" of classes and functions).
10
+ Modules are arranged in a Fibonacci annulus in the XY plane.
11
+
12
+ - :class:`LayerCakeLayout`: Node kind determines the Z level (modules at
13
+ the bottom, classes above, functions/methods at the top). XY positions
14
+ are spread via a golden-angle spiral within each layer.
15
+
16
+ The Fibonacci utilities (``fibonacci_sphere``, ``fibonacci_annulus``) are
17
+ adapted from *repo_vis* ``pkg_visualizer/utility.py``
18
+ (Eric G. Suchanek, PhD — https://github.com/Suchanek/repo_vis).
19
+
20
+ Author: Eric G. Suchanek, PhD
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from abc import ABC, abstractmethod
26
+ from dataclasses import dataclass
27
+
28
+ import numpy as np
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Fibonacci spatial utilities (adapted from repo_vis/pkg_visualizer/utility.py)
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def fibonacci_sphere(
36
+ samples: int,
37
+ radius: float = 1.0,
38
+ center: np.ndarray | None = None,
39
+ ) -> list[np.ndarray]:
40
+ """
41
+ Distribute *samples* points uniformly on a sphere using the Fibonacci spiral.
42
+
43
+ Adapted from ``utility.fibonacci_sphere`` in *repo_vis*.
44
+
45
+ :param samples: Number of points to generate.
46
+ :param radius: Sphere radius.
47
+ :param center: Centre of the sphere (default: origin).
48
+ :return: List of 3-D coordinate arrays.
49
+ """
50
+ if center is None:
51
+ center = np.zeros(3)
52
+ if samples <= 0:
53
+ return []
54
+ if samples == 1:
55
+ return [center + radius * np.array([0.0, 0.0, 1.0])]
56
+
57
+ phi = np.pi * (3.0 - np.sqrt(5.0)) # golden angle in radians
58
+ points: list[np.ndarray] = []
59
+ for i in range(samples):
60
+ y = 1.0 - (i / float(samples - 1)) * 2.0
61
+ r_at_y = np.sqrt(max(0.0, 1.0 - y * y))
62
+ theta = phi * i
63
+ x = np.cos(theta) * r_at_y
64
+ z = np.sin(theta) * r_at_y
65
+ points.append(center + radius * np.array([x, y, z]))
66
+ return points
67
+
68
+
69
+ def fibonacci_annulus(
70
+ samples: int,
71
+ inner_radius: float = 1.0,
72
+ outer_radius: float = 2.0,
73
+ center: np.ndarray | None = None,
74
+ z_thickness: float = 0.2,
75
+ ) -> list[np.ndarray]:
76
+ """
77
+ Distribute *samples* points in a flat annular ring in the XY plane.
78
+
79
+ A small Z jitter (``z_thickness``) adds visual depth when non-zero.
80
+ Adapted from ``utility.fibonacci_annulus`` in *repo_vis*.
81
+
82
+ :param samples: Number of points to generate.
83
+ :param inner_radius: Inner radius of the annulus.
84
+ :param outer_radius: Outer radius of the annulus.
85
+ :param center: Centre of the annulus (default: origin).
86
+ :param z_thickness: Half-range of Z jitter applied to each point.
87
+ :return: List of 3-D coordinate arrays.
88
+ """
89
+ if center is None:
90
+ center = np.zeros(3)
91
+ if samples <= 0:
92
+ return []
93
+ if samples == 1:
94
+ mid = (inner_radius + outer_radius) / 2.0
95
+ return [center + np.array([mid, 0.0, 0.0])]
96
+
97
+ phi = np.pi * (3.0 - np.sqrt(5.0))
98
+ r_range = outer_radius - inner_radius
99
+ r_step = r_range / max(samples - 1, 1)
100
+ rng = np.random.default_rng(42) # deterministic jitter seed
101
+
102
+ points: list[np.ndarray] = []
103
+ for i in range(samples):
104
+ r = inner_radius + i * r_step
105
+ theta = phi * i
106
+ x = np.cos(theta) * r
107
+ y = np.sin(theta) * r
108
+ z = (rng.random() * 2.0 - 1.0) * z_thickness
109
+ points.append(center + np.array([x, y, z]))
110
+ return points
111
+
112
+
113
+ def _golden_spiral_2d(
114
+ samples: int,
115
+ radius: float = 1.0,
116
+ center: np.ndarray | None = None,
117
+ z: float = 0.0,
118
+ ) -> list[np.ndarray]:
119
+ """
120
+ Place *samples* points in the XY plane using a golden-angle disc spiral.
121
+
122
+ :param samples: Number of points.
123
+ :param radius: Outer radius of the disc.
124
+ :param center: XY centre (Z component ignored; overridden by *z*).
125
+ :param z: Fixed Z coordinate for all output points.
126
+ :return: List of 3-D coordinate arrays.
127
+ """
128
+ if center is None:
129
+ center = np.zeros(3)
130
+ if samples <= 0:
131
+ return []
132
+
133
+ phi = np.pi * (3.0 - np.sqrt(5.0))
134
+ points: list[np.ndarray] = []
135
+ for i in range(samples):
136
+ r = radius * np.sqrt(i / max(samples - 1, 1))
137
+ theta = phi * i
138
+ x = r * np.cos(theta)
139
+ y = r * np.sin(theta)
140
+ points.append(center + np.array([x, y, z]))
141
+ return points
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Data transfer objects
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ @dataclass
150
+ class LayoutNode:
151
+ """
152
+ Thin wrapper around a node dict from :class:`~pycode_kg.store.GraphStore`.
153
+
154
+ :param id: Stable node identifier (e.g. ``mod:src/foo.py``).
155
+ :param kind: Node kind — ``module``, ``class``, ``function``, ``method``,
156
+ or ``symbol``.
157
+ :param name: Short name of the node.
158
+ :param module_path: Source module path (may be ``None`` for symbol stubs).
159
+ :param docstring: Raw docstring text (may be ``None``).
160
+ :param lineno: First source line number (may be ``None``).
161
+ :param end_lineno: Last source line number (may be ``None``).
162
+ """
163
+
164
+ id: str
165
+ kind: str
166
+ name: str
167
+ module_path: str | None = None
168
+ docstring: str | None = None
169
+ lineno: int | None = None
170
+ end_lineno: int | None = None
171
+
172
+ @classmethod
173
+ def from_dict(cls, d: dict) -> LayoutNode:
174
+ """Construct from a GraphStore node dict.
175
+
176
+ :param d: Dict with keys ``id``, ``kind``, ``name``, etc.
177
+ :return: New :class:`LayoutNode`.
178
+ """
179
+ return cls(
180
+ id=d["id"],
181
+ kind=d["kind"],
182
+ name=d["name"],
183
+ module_path=d.get("module_path"),
184
+ docstring=d.get("docstring"),
185
+ lineno=d.get("lineno"),
186
+ end_lineno=d.get("end_lineno"),
187
+ )
188
+
189
+ @property
190
+ def line_count(self) -> int:
191
+ """Approximate source size in lines (0 if unknown).
192
+
193
+ :return: ``end_lineno - lineno`` or 0.
194
+ """
195
+ if self.lineno and self.end_lineno:
196
+ return max(0, self.end_lineno - self.lineno)
197
+ return 0
198
+
199
+
200
+ @dataclass
201
+ class LayoutEdge:
202
+ """
203
+ Thin wrapper around an edge dict from :class:`~pycode_kg.store.GraphStore`.
204
+
205
+ :param src: Source node ID.
206
+ :param rel: Relation type — ``CONTAINS``, ``CALLS``, ``IMPORTS``,
207
+ ``INHERITS``.
208
+ :param dst: Destination node ID.
209
+ """
210
+
211
+ src: str
212
+ rel: str
213
+ dst: str
214
+
215
+ @classmethod
216
+ def from_dict(cls, d: dict) -> LayoutEdge:
217
+ """Construct from a GraphStore edge dict.
218
+
219
+ :param d: Dict with keys ``src``, ``rel``, ``dst``.
220
+ :return: New :class:`LayoutEdge`.
221
+ """
222
+ return cls(src=d["src"], rel=d["rel"], dst=d["dst"])
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Abstract base
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ class Layout3D(ABC):
231
+ """
232
+ Abstract base class for 3-D graph layout strategies.
233
+
234
+ Subclasses implement :meth:`compute` to assign a 3-D position to every
235
+ node, returning a ``{node_id: np.ndarray([x, y, z])}`` mapping that the
236
+ :class:`~pycode_kg.viz3d.KGViz3D` renderer consumes.
237
+ """
238
+
239
+ @abstractmethod
240
+ def compute(
241
+ self,
242
+ nodes: list[LayoutNode],
243
+ edges: list[LayoutEdge],
244
+ ) -> dict[str, np.ndarray]:
245
+ """
246
+ Compute 3-D positions for all *nodes*.
247
+
248
+ :param nodes: All nodes in the graph.
249
+ :param edges: All edges in the graph (used to derive hierarchy).
250
+ :return: Mapping from node ID to ``[x, y, z]`` position.
251
+ """
252
+ ...
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # AlliumLayout
257
+ # ---------------------------------------------------------------------------
258
+
259
+
260
+ class AlliumLayout(Layout3D):
261
+ """
262
+ Allium-plant layout: each module is visualised as a Giant Allium flower.
263
+
264
+ Spatial structure:
265
+
266
+ - **Stem base** — module node sits at XY position in a Fibonacci annulus
267
+ at ``Z = 0``.
268
+ - **Head** — classes and top-level functions are distributed on a
269
+ Fibonacci sphere centred at the stem apex (``Z = stem_height``).
270
+ Head radius scales with ``sqrt(n_children)``.
271
+ - **Florets** — methods orbit their parent class on a smaller Fibonacci
272
+ sphere, slightly above the head.
273
+ - **Orphans** — nodes with no CONTAINS parent cluster on a small sphere
274
+ at the origin.
275
+
276
+ Multiple module-alliums are arranged in a Fibonacci annulus in the XY
277
+ plane so they are evenly spaced regardless of count.
278
+
279
+ Inspired by :class:`GiantAllium` in *repo_vis/pkg_visualizer/plants3d.py*.
280
+
281
+ :param stem_height: Height of each allium stem (Z offset of the head).
282
+ :param base_head_radius: Minimum radius for the Fibonacci sphere head.
283
+ :param method_orbit_radius: Base radius for method sub-spheres.
284
+ :param annulus_inner_radius: Inner radius of the module placement ring.
285
+ :param annulus_outer_radius: Minimum outer radius (auto-scaled for large graphs).
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ stem_height: float = 8.0,
291
+ base_head_radius: float = 2.0,
292
+ method_orbit_radius: float = 0.8,
293
+ annulus_inner_radius: float = 8.0,
294
+ annulus_outer_radius: float = 20.0,
295
+ ) -> None:
296
+ """Initialise layout parameters.
297
+
298
+ :param stem_height: Vertical height of each allium stem.
299
+ :param base_head_radius: Minimum allium head sphere radius.
300
+ :param method_orbit_radius: Base orbit radius for methods.
301
+ :param annulus_inner_radius: Inner radius for module ring placement.
302
+ :param annulus_outer_radius: Minimum outer radius for module ring.
303
+ """
304
+ self.stem_height = stem_height
305
+ self.base_head_radius = base_head_radius
306
+ self.method_orbit_radius = method_orbit_radius
307
+ self.annulus_inner_radius = annulus_inner_radius
308
+ self.annulus_outer_radius = annulus_outer_radius
309
+
310
+ def compute(
311
+ self,
312
+ nodes: list[LayoutNode],
313
+ edges: list[LayoutEdge],
314
+ ) -> dict[str, np.ndarray]:
315
+ """
316
+ Compute allium-plant 3-D positions for all nodes.
317
+
318
+ :param nodes: All nodes in the graph.
319
+ :param edges: All edges (``CONTAINS`` used to derive hierarchy).
320
+ :return: Mapping from node ID to ``[x, y, z]`` position.
321
+ """
322
+ # Build CONTAINS hierarchy: child_id -> parent_id, parent_id -> [child_ids]
323
+ parent: dict[str, str] = {}
324
+ children: dict[str, list[str]] = {}
325
+ for e in edges:
326
+ if e.rel == "CONTAINS":
327
+ parent[e.dst] = e.src
328
+ children.setdefault(e.src, []).append(e.dst)
329
+
330
+ node_by_id: dict[str, LayoutNode] = {n.id: n for n in nodes}
331
+ positions: dict[str, np.ndarray] = {}
332
+
333
+ # Module nodes form the allium stems
334
+ modules = [n for n in nodes if n.kind == "module"]
335
+ if not modules:
336
+ # Fallback: treat nodes without a CONTAINS parent as pseudo-modules
337
+ modules = [n for n in nodes if n.id not in parent]
338
+
339
+ n_mods = len(modules)
340
+ inner = self.annulus_inner_radius
341
+ # Scale outer radius so stems don't crowd each other
342
+ outer = max(self.annulus_outer_radius, inner + n_mods * 2.5)
343
+
344
+ mod_positions = fibonacci_annulus(
345
+ n_mods,
346
+ inner_radius=inner,
347
+ outer_radius=outer,
348
+ center=np.zeros(3),
349
+ z_thickness=0.0, # flat ring — alliums stand vertically
350
+ )
351
+
352
+ for mod_node, mod_pos in zip(modules, mod_positions):
353
+ positions[mod_node.id] = np.array(mod_pos)
354
+ stem_apex = np.array([mod_pos[0], mod_pos[1], self.stem_height])
355
+
356
+ # Direct children (classes, top-level functions)
357
+ direct_ids = children.get(mod_node.id, [])
358
+ direct = [node_by_id[cid] for cid in direct_ids if cid in node_by_id]
359
+ n_direct = len(direct)
360
+ if not direct:
361
+ continue
362
+
363
+ # Head radius scales with child count
364
+ head_r = self.base_head_radius + np.sqrt(n_direct) * 0.4
365
+ head_positions = fibonacci_sphere(n_direct, radius=head_r, center=stem_apex)
366
+
367
+ for child, child_pos in zip(direct, head_positions):
368
+ positions[child.id] = np.array(child_pos)
369
+
370
+ # Grandchildren (methods) orbit their parent class
371
+ grand_ids = children.get(child.id, [])
372
+ grand = [node_by_id[gid] for gid in grand_ids if gid in node_by_id]
373
+ n_grand = len(grand)
374
+ if not grand:
375
+ continue
376
+
377
+ method_r = self.method_orbit_radius + np.sqrt(n_grand) * 0.15
378
+ method_positions = fibonacci_sphere(
379
+ n_grand, radius=method_r, center=np.array(child_pos)
380
+ )
381
+ for gc, gc_pos in zip(grand, method_positions):
382
+ positions[gc.id] = np.array(gc_pos)
383
+
384
+ # Orphan nodes: anything not yet placed (symbols, unrooted nodes)
385
+ orphans = [n for n in nodes if n.id not in positions]
386
+ if orphans:
387
+ orphan_positions = fibonacci_sphere(len(orphans), radius=3.0, center=np.zeros(3))
388
+ for n, pos in zip(orphans, orphan_positions):
389
+ positions[n.id] = np.array(pos)
390
+
391
+ return positions
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # LayerCakeLayout
396
+ # ---------------------------------------------------------------------------
397
+
398
+ # Z level per node kind
399
+ _KIND_ZLEVEL: dict[str, int] = {
400
+ "module": 0,
401
+ "class": 1,
402
+ "function": 2,
403
+ "method": 2,
404
+ "symbol": 3,
405
+ }
406
+
407
+
408
+ class LayerCakeLayout(Layout3D):
409
+ """
410
+ Stratified layout: node *kind* determines the Z layer; XY positions use a
411
+ golden-angle disc spiral within each layer.
412
+
413
+ Layers (bottom to top):
414
+
415
+ - **Z = 0** — modules
416
+ - **Z = layer_gap** — classes
417
+ - **Z = 2 × layer_gap** — functions and methods
418
+ - **Z = 3 × layer_gap** — symbol stubs
419
+
420
+ Cross-cutting edges (``CALLS``, ``IMPORTS``, ``INHERITS``) arc between
421
+ layers, making structural coupling immediately visible from any angle.
422
+
423
+ :param layer_gap: Vertical distance between adjacent layers.
424
+ :param disc_radius: Base outer radius of the golden-angle disc per layer.
425
+ """
426
+
427
+ def __init__(
428
+ self,
429
+ layer_gap: float = 12.0,
430
+ disc_radius: float = 28.0,
431
+ ) -> None:
432
+ """Initialise layout parameters.
433
+
434
+ :param layer_gap: Vertical separation between layers.
435
+ :param disc_radius: Base XY spread radius per layer disc.
436
+ """
437
+ self.layer_gap = layer_gap
438
+ self.disc_radius = disc_radius
439
+
440
+ def compute(
441
+ self,
442
+ nodes: list[LayoutNode],
443
+ edges: list[LayoutEdge],
444
+ ) -> dict[str, np.ndarray]:
445
+ """
446
+ Compute layer-cake 3-D positions for all nodes.
447
+
448
+ :param nodes: All nodes in the graph.
449
+ :param edges: Unused by this layout (present for API compatibility).
450
+ :return: Mapping from node ID to ``[x, y, z]`` position.
451
+ """
452
+ # Group nodes by Z layer
453
+ layers: dict[int, list[LayoutNode]] = {}
454
+ for n in nodes:
455
+ level = _KIND_ZLEVEL.get(n.kind, 3)
456
+ layers.setdefault(level, []).append(n)
457
+
458
+ positions: dict[str, np.ndarray] = {}
459
+ n_total = max(len(nodes), 1)
460
+
461
+ for level, layer_nodes in layers.items():
462
+ z = level * self.layer_gap
463
+ # Scale disc radius proportionally to the layer's node count
464
+ r = self.disc_radius * np.sqrt(len(layer_nodes) / n_total)
465
+ r = max(r, 4.0) # minimum spread
466
+ pts = _golden_spiral_2d(len(layer_nodes), radius=r, z=z)
467
+ for n, pt in zip(layer_nodes, pts):
468
+ positions[n.id] = pt
469
+
470
+ return positions
@@ -0,0 +1,19 @@
1
+ """
2
+ MCP tool hooks for bridge centrality in PyCodeKG.
3
+ """
4
+
5
+ # Placeholder for MCP integration
6
+
7
+
8
+ def top_bridges(kind="module", limit=20):
9
+ from pycode_kg.analysis.bridge import ( # pylint: disable=import-outside-toplevel
10
+ compute_bridge_centrality,
11
+ )
12
+
13
+ bridges = compute_bridge_centrality(kind=kind, top=limit)
14
+ return bridges
15
+
16
+
17
+ def why_bridge(node_id):
18
+ # TODO: Explain why a node is a bridge
19
+ pass