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
@@ -0,0 +1 @@
1
+ # This module has been removed. Use `pycodekg viz` instead.
@@ -0,0 +1 @@
1
+ # This module has been removed. Use `pycodekg viz3d` instead.
@@ -0,0 +1 @@
1
+ """Ranking utilities for PyCodeKG."""
@@ -0,0 +1,92 @@
1
+ """CLI wrapper for CodeRank and hybrid query ranking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from .coderank import (
11
+ DEFAULT_GLOBAL_RELS,
12
+ build_code_graph,
13
+ compute_coderank,
14
+ persist_metric_scores,
15
+ )
16
+
17
+
18
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
19
+ parser = argparse.ArgumentParser(description="Compute CodeRank metrics for PyCodeKG graphs.")
20
+ parser.add_argument("--sqlite", required=True, help="Path to PyCodeKG SQLite graph.")
21
+ parser.add_argument("--top", type=int, default=25, help="Number of top results to print.")
22
+ parser.add_argument(
23
+ "--rels",
24
+ default=",".join(DEFAULT_GLOBAL_RELS),
25
+ help="Comma-separated relations to include.",
26
+ )
27
+ parser.add_argument(
28
+ "--kinds",
29
+ default="function,method,class,module",
30
+ help="Comma-separated node kinds to include.",
31
+ )
32
+ parser.add_argument(
33
+ "--persist-metric",
34
+ default=None,
35
+ help="Optional metric name to persist into node_metrics.",
36
+ )
37
+ parser.add_argument(
38
+ "--json",
39
+ action="store_true",
40
+ help="Emit results as JSON rather than plain text.",
41
+ )
42
+ return parser.parse_args(argv)
43
+
44
+
45
+ def main(argv: Sequence[str] | None = None) -> int:
46
+ args = parse_args(argv)
47
+ sqlite_path = Path(args.sqlite)
48
+ if not sqlite_path.exists():
49
+ raise SystemExit(f"SQLite file does not exist: {sqlite_path}")
50
+
51
+ rels = tuple(rel.strip() for rel in args.rels.split(",") if rel.strip())
52
+ kinds = tuple(kind.strip() for kind in args.kinds.split(",") if kind.strip())
53
+
54
+ graph = build_code_graph(
55
+ str(sqlite_path),
56
+ include_relations=rels,
57
+ include_kinds=kinds,
58
+ exclude_test_paths=True,
59
+ )
60
+ scores = compute_coderank(graph)
61
+
62
+ if args.persist_metric:
63
+ persist_metric_scores(str(sqlite_path), args.persist_metric, scores)
64
+
65
+ ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True)[: args.top]
66
+ rows = []
67
+ for rank, (node_id, score) in enumerate(ranked, start=1):
68
+ attrs = graph.nodes[node_id]
69
+ rows.append(
70
+ {
71
+ "rank": rank,
72
+ "node_id": node_id,
73
+ "score": score,
74
+ "kind": attrs.get("kind"),
75
+ "qualname": attrs.get("qualname"),
76
+ "module_path": attrs.get("module_path"),
77
+ }
78
+ )
79
+
80
+ if args.json:
81
+ print(json.dumps(rows, indent=2))
82
+ else:
83
+ for row in rows:
84
+ print(
85
+ f"{row['rank']:>3} {row['score']:.6f} "
86
+ f"{row['kind']:<8} {row['qualname']} [{row['module_path']}]"
87
+ )
88
+ return 0
89
+
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())
@@ -0,0 +1,555 @@
1
+ """CodeRank and hybrid ranking utilities for PyCodeKG.
2
+
3
+ This module implements:
4
+ - weighted global PageRank ("CodeRank")
5
+ - query-induced personalized PageRank
6
+ - hybrid score combination with semantic relevance and graph proximity
7
+ - explainable per-node score components
8
+
9
+ The implementation is designed to be repo-agnostic and easy to adapt to the
10
+ existing PyCodeKG SQLite schema.
11
+
12
+ Assumptions
13
+ -----------
14
+ - Nodes live in a ``nodes`` table with at least:
15
+ id, kind, name, qualname, module_path
16
+ - Edges live in an ``edges`` table with at least:
17
+ source_id, target_id, relation
18
+
19
+ Notes
20
+ -----
21
+ - Edge weights in this module represent *strength*.
22
+ - If you later compute shortest-path or betweenness centrality, convert these
23
+ strengths to distances, e.g. distance = 1 / (weight + eps).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import sqlite3
30
+ from collections.abc import Iterable, Mapping, Sequence
31
+ from dataclasses import dataclass
32
+ from datetime import UTC, datetime
33
+
34
+ import networkx as nx
35
+
36
+ DEFAULT_EDGE_WEIGHTS: dict[str, float] = {
37
+ "CALLS": 1.00,
38
+ "IMPORTS": 0.90,
39
+ "INHERITS": 0.75,
40
+ "RESOLVES_TO": 0.30,
41
+ "CONTAINS": 0.15,
42
+ "ATTR_ACCESS": 0.10,
43
+ "READS": 0.05,
44
+ "WRITES": 0.05,
45
+ }
46
+
47
+ DEFAULT_KIND_PRIORS: dict[str, float] = {
48
+ "function": 1.00,
49
+ "method": 1.00,
50
+ "class": 0.92,
51
+ "module": 0.80,
52
+ "symbol": 0.50,
53
+ "attribute": 0.50,
54
+ }
55
+
56
+ DEFAULT_GLOBAL_RELS: tuple[str, ...] = (
57
+ "CALLS",
58
+ "IMPORTS",
59
+ "INHERITS",
60
+ "RESOLVES_TO",
61
+ )
62
+
63
+ DEFAULT_HYBRID_WEIGHTS: dict[str, float] = {
64
+ "semantic": 0.60,
65
+ "centrality": 0.25,
66
+ "proximity": 0.15,
67
+ }
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class RankResult:
72
+ """Final hybrid ranking result for one node."""
73
+
74
+ node_id: str
75
+ final_score: float
76
+ semantic_score: float
77
+ centrality_score: float
78
+ proximity_score: float
79
+ adjusted_score: float
80
+ kind: str | None
81
+ qualname: str | None
82
+ module_path: str | None
83
+ why: tuple[str, ...]
84
+
85
+
86
+ def _normalize_scores(scores: Mapping[str, float]) -> dict[str, float]:
87
+ """Min-max normalize a score mapping into [0, 1].
88
+
89
+ If all scores are equal, return 1.0 for positive entries and 0 otherwise.
90
+ """
91
+ if not scores:
92
+ return {}
93
+
94
+ values = list(scores.values())
95
+ min_v = min(values)
96
+ max_v = max(values)
97
+ if math.isclose(min_v, max_v):
98
+ return {k: (1.0 if v > 0 else 0.0) for k, v in scores.items()}
99
+
100
+ scale = max_v - min_v
101
+ return {k: (v - min_v) / scale for k, v in scores.items()}
102
+
103
+
104
+ def _safe_norm_sum(scores: Mapping[str, float]) -> dict[str, float]:
105
+ """Normalize nonnegative scores to sum to 1.0."""
106
+ total = sum(max(v, 0.0) for v in scores.values())
107
+ if total <= 0:
108
+ return {k: 0.0 for k in scores}
109
+ return {k: max(v, 0.0) / total for k, v in scores.items()}
110
+
111
+
112
+ def build_code_graph(
113
+ sqlite_path: str,
114
+ *,
115
+ include_relations: Iterable[str] | None = None,
116
+ include_kinds: Iterable[str] | None = None,
117
+ edge_weights: Mapping[str, float] | None = None,
118
+ kind_priors: Mapping[str, float] | None = None,
119
+ exclude_test_paths: bool = True,
120
+ ) -> nx.DiGraph:
121
+ """Build a weighted directed graph from the PyCodeKG SQLite store.
122
+
123
+ :param sqlite_path: Path to the PyCodeKG SQLite database.
124
+ :param include_relations: Optional subset of relations to include.
125
+ :param include_kinds: Optional subset of node kinds to include.
126
+ :param edge_weights: Edge strength per relation type.
127
+ :param kind_priors: Multiplicative weight prior based on target node kind.
128
+ :param exclude_test_paths: Exclude nodes whose module_path appears to be test code.
129
+ :returns: A NetworkX DiGraph with edge attribute ``weight``.
130
+ """
131
+ relations = set(include_relations) if include_relations else None
132
+ kinds = set(include_kinds) if include_kinds else None
133
+ rel_weights = dict(DEFAULT_EDGE_WEIGHTS)
134
+ if edge_weights:
135
+ rel_weights.update(edge_weights)
136
+ priors = dict(DEFAULT_KIND_PRIORS)
137
+ if kind_priors:
138
+ priors.update(kind_priors)
139
+
140
+ conn = sqlite3.connect(sqlite_path)
141
+ conn.row_factory = sqlite3.Row
142
+ cur = conn.cursor()
143
+
144
+ graph = nx.DiGraph()
145
+
146
+ cur.execute(
147
+ """
148
+ SELECT id, kind, name, qualname, module_path
149
+ FROM nodes
150
+ """
151
+ )
152
+ for row in cur.fetchall():
153
+ kind = row["kind"]
154
+ module_path = row["module_path"]
155
+
156
+ if kinds and kind not in kinds:
157
+ continue
158
+ if exclude_test_paths and module_path:
159
+ lowered = module_path.lower()
160
+ if (
161
+ "/tests/" in lowered
162
+ or lowered.startswith("tests/")
163
+ or lowered.endswith("_test.py")
164
+ or lowered.endswith("test_.py")
165
+ ):
166
+ continue
167
+
168
+ graph.add_node(
169
+ row["id"],
170
+ kind=kind,
171
+ name=row["name"],
172
+ qualname=row["qualname"],
173
+ module_path=module_path,
174
+ )
175
+
176
+ cur.execute(
177
+ """
178
+ SELECT src, dst, rel
179
+ FROM edges
180
+ """
181
+ )
182
+ for row in cur.fetchall():
183
+ src = row["src"]
184
+ dst = row["dst"]
185
+ relation = row["rel"]
186
+
187
+ if src not in graph or dst not in graph:
188
+ continue
189
+ if relations and relation not in relations:
190
+ continue
191
+
192
+ base_weight = rel_weights.get(relation, 0.0)
193
+ if base_weight <= 0:
194
+ continue
195
+
196
+ target_kind = graph.nodes[dst].get("kind")
197
+ weight = base_weight * priors.get(target_kind, 1.0)
198
+ if weight <= 0:
199
+ continue
200
+
201
+ if graph.has_edge(src, dst):
202
+ graph[src][dst]["weight"] += weight
203
+ graph[src][dst]["relations"].add(relation)
204
+ else:
205
+ graph.add_edge(src, dst, weight=weight, relations={relation})
206
+
207
+ conn.close()
208
+ return graph
209
+
210
+
211
+ def compute_coderank(
212
+ graph: nx.DiGraph,
213
+ *,
214
+ alpha: float = 0.85,
215
+ max_iter: int = 200,
216
+ tol: float = 1.0e-8,
217
+ ) -> dict[str, float]:
218
+ """Compute global weighted PageRank on the graph."""
219
+ if graph.number_of_nodes() == 0:
220
+ return {}
221
+ return nx.pagerank(
222
+ graph,
223
+ alpha=alpha,
224
+ weight="weight",
225
+ max_iter=max_iter,
226
+ tol=tol,
227
+ )
228
+
229
+
230
+ def compute_personalized_coderank(
231
+ graph: nx.DiGraph,
232
+ seed_scores: Mapping[str, float],
233
+ *,
234
+ alpha: float = 0.85,
235
+ max_iter: int = 200,
236
+ tol: float = 1.0e-8,
237
+ ) -> dict[str, float]:
238
+ """Compute weighted personalized PageRank from seed nodes."""
239
+ if graph.number_of_nodes() == 0:
240
+ return {}
241
+
242
+ personalization = {node_id: 0.0 for node_id in graph.nodes}
243
+ for node_id, score in seed_scores.items():
244
+ if node_id in personalization and score > 0:
245
+ personalization[node_id] = float(score)
246
+
247
+ personalization = _safe_norm_sum(personalization)
248
+ if sum(personalization.values()) <= 0:
249
+ return compute_coderank(graph, alpha=alpha, max_iter=max_iter, tol=tol)
250
+
251
+ return nx.pagerank(
252
+ graph,
253
+ alpha=alpha,
254
+ weight="weight",
255
+ personalization=personalization,
256
+ dangling=personalization,
257
+ max_iter=max_iter,
258
+ tol=tol,
259
+ )
260
+
261
+
262
+ def induce_query_subgraph(
263
+ graph: nx.DiGraph,
264
+ seeds: Sequence[str],
265
+ *,
266
+ radius: int = 2,
267
+ include_reverse: bool = True,
268
+ ) -> nx.DiGraph:
269
+ """Induce a local query subgraph around seed nodes.
270
+
271
+ This collects nodes reachable within ``radius`` hops from the seed set.
272
+ When ``include_reverse`` is true, both successors and predecessors are
273
+ traversed so caller/importer context is included.
274
+ """
275
+ if not seeds:
276
+ return graph.copy()
277
+
278
+ frontier = {node for node in seeds if node in graph}
279
+ visited = set(frontier)
280
+
281
+ for _ in range(max(radius, 0)):
282
+ next_frontier: set[str] = set()
283
+ for node in frontier:
284
+ next_frontier.update(graph.successors(node))
285
+ if include_reverse:
286
+ next_frontier.update(graph.predecessors(node))
287
+ next_frontier.difference_update(visited)
288
+ visited.update(next_frontier)
289
+ frontier = next_frontier
290
+ if not frontier:
291
+ break
292
+
293
+ return graph.subgraph(visited).copy()
294
+
295
+
296
+ def compute_seed_proximity(
297
+ graph: nx.DiGraph,
298
+ seeds: Sequence[str],
299
+ ) -> dict[str, float]:
300
+ """Compute simple inverse-distance proximity to the nearest seed."""
301
+ if graph.number_of_nodes() == 0:
302
+ return {}
303
+ valid_seeds = [seed for seed in seeds if seed in graph]
304
+ if not valid_seeds:
305
+ return {node_id: 0.0 for node_id in graph.nodes}
306
+
307
+ undirected = graph.to_undirected()
308
+ best_distance: dict[str, int] = {}
309
+ for seed in valid_seeds:
310
+ lengths = nx.single_source_shortest_path_length(undirected, seed)
311
+ for node_id, dist in lengths.items():
312
+ current = best_distance.get(node_id)
313
+ if current is None or dist < current:
314
+ best_distance[node_id] = dist
315
+
316
+ return {
317
+ node_id: 1.0 / (1.0 + best_distance[node_id]) if node_id in best_distance else 0.0
318
+ for node_id in graph.nodes
319
+ }
320
+
321
+
322
+ def combine_hybrid_scores(
323
+ graph: nx.DiGraph,
324
+ semantic_scores: Mapping[str, float],
325
+ centrality_scores: Mapping[str, float],
326
+ proximity_scores: Mapping[str, float],
327
+ *,
328
+ weights: Mapping[str, float] | None = None,
329
+ kind_priors: Mapping[str, float] | None = None,
330
+ top_k: int | None = None,
331
+ ) -> list[RankResult]:
332
+ """Combine semantic, centrality, and proximity scores into a final ranking."""
333
+ score_weights = dict(DEFAULT_HYBRID_WEIGHTS)
334
+ if weights:
335
+ score_weights.update(weights)
336
+
337
+ semantic_norm = _normalize_scores(
338
+ {node: semantic_scores.get(node, 0.0) for node in graph.nodes}
339
+ )
340
+ centrality_norm = _normalize_scores(
341
+ {node: centrality_scores.get(node, 0.0) for node in graph.nodes}
342
+ )
343
+ proximity_norm = _normalize_scores(
344
+ {node: proximity_scores.get(node, 0.0) for node in graph.nodes}
345
+ )
346
+
347
+ priors = dict(DEFAULT_KIND_PRIORS)
348
+ if kind_priors:
349
+ priors.update(kind_priors)
350
+
351
+ results: list[RankResult] = []
352
+ for node_id, attrs in graph.nodes(data=True):
353
+ semantic = semantic_norm.get(node_id, 0.0)
354
+ centrality = centrality_norm.get(node_id, 0.0)
355
+ proximity = proximity_norm.get(node_id, 0.0)
356
+ final = (
357
+ score_weights["semantic"] * semantic
358
+ + score_weights["centrality"] * centrality
359
+ + score_weights["proximity"] * proximity
360
+ )
361
+ kind = attrs.get("kind")
362
+ adjusted = final * priors.get(kind, 1.0)
363
+
364
+ reasons = _build_why(
365
+ graph=graph,
366
+ node_id=node_id,
367
+ semantic=semantic,
368
+ centrality=centrality,
369
+ proximity=proximity,
370
+ )
371
+
372
+ results.append(
373
+ RankResult(
374
+ node_id=node_id,
375
+ final_score=final,
376
+ semantic_score=semantic,
377
+ centrality_score=centrality,
378
+ proximity_score=proximity,
379
+ adjusted_score=adjusted,
380
+ kind=kind,
381
+ qualname=attrs.get("qualname"),
382
+ module_path=attrs.get("module_path"),
383
+ why=reasons,
384
+ )
385
+ )
386
+
387
+ results.sort(key=lambda item: item.adjusted_score, reverse=True)
388
+ if top_k is not None:
389
+ return results[:top_k]
390
+ return results
391
+
392
+
393
+ def rank_query_hybrid(
394
+ graph: nx.DiGraph,
395
+ semantic_scores: Mapping[str, float],
396
+ *,
397
+ global_coderank: Mapping[str, float] | None = None,
398
+ radius: int = 2,
399
+ top_k: int = 25,
400
+ weights: Mapping[str, float] | None = None,
401
+ ) -> list[RankResult]:
402
+ """Hybrid rank for a query using semantic scores + global centrality + proximity."""
403
+ seeds = [node_id for node_id, score in semantic_scores.items() if score > 0]
404
+ local_graph = induce_query_subgraph(graph, seeds, radius=radius, include_reverse=True)
405
+ centrality = global_coderank or compute_coderank(local_graph)
406
+ proximity = compute_seed_proximity(local_graph, seeds)
407
+ local_semantic = {node_id: semantic_scores.get(node_id, 0.0) for node_id in local_graph.nodes}
408
+ local_centrality = {node_id: centrality.get(node_id, 0.0) for node_id in local_graph.nodes}
409
+ return combine_hybrid_scores(
410
+ local_graph,
411
+ local_semantic,
412
+ local_centrality,
413
+ proximity,
414
+ weights=weights,
415
+ top_k=top_k,
416
+ )
417
+
418
+
419
+ def rank_query_ppr(
420
+ graph: nx.DiGraph,
421
+ semantic_scores: Mapping[str, float],
422
+ *,
423
+ radius: int = 2,
424
+ top_k: int = 25,
425
+ ppr_weight: float = 0.70,
426
+ semantic_weight: float = 0.30,
427
+ ) -> list[RankResult]:
428
+ """Rank query results using personalized PageRank on a query-induced subgraph."""
429
+ seeds = [node_id for node_id, score in semantic_scores.items() if score > 0]
430
+ local_graph = induce_query_subgraph(graph, seeds, radius=radius, include_reverse=True)
431
+
432
+ local_semantic = {node_id: semantic_scores.get(node_id, 0.0) for node_id in local_graph.nodes}
433
+ ppr = compute_personalized_coderank(local_graph, local_semantic)
434
+ ppr_norm = _normalize_scores(ppr)
435
+ semantic_norm = _normalize_scores(local_semantic)
436
+ proximity = compute_seed_proximity(local_graph, seeds)
437
+
438
+ priors = DEFAULT_KIND_PRIORS
439
+ results: list[RankResult] = []
440
+ for node_id, attrs in local_graph.nodes(data=True):
441
+ ppr_score = ppr_norm.get(node_id, 0.0)
442
+ semantic_score = semantic_norm.get(node_id, 0.0)
443
+ final = ppr_weight * ppr_score + semantic_weight * semantic_score
444
+ adjusted = final * priors.get(attrs.get("kind"), 1.0)
445
+ reasons = _build_why(
446
+ graph=local_graph,
447
+ node_id=node_id,
448
+ semantic=semantic_score,
449
+ centrality=ppr_score,
450
+ proximity=proximity.get(node_id, 0.0),
451
+ centrality_label="ppr",
452
+ )
453
+ results.append(
454
+ RankResult(
455
+ node_id=node_id,
456
+ final_score=final,
457
+ semantic_score=semantic_score,
458
+ centrality_score=ppr_score,
459
+ proximity_score=proximity.get(node_id, 0.0),
460
+ adjusted_score=adjusted,
461
+ kind=attrs.get("kind"),
462
+ qualname=attrs.get("qualname"),
463
+ module_path=attrs.get("module_path"),
464
+ why=reasons,
465
+ )
466
+ )
467
+
468
+ results.sort(key=lambda item: item.adjusted_score, reverse=True)
469
+ return results[:top_k]
470
+
471
+
472
+ def persist_metric_scores(
473
+ sqlite_path: str,
474
+ metric: str,
475
+ scores: Mapping[str, float],
476
+ ) -> None:
477
+ """Persist node-level metric scores into a ``node_metrics`` table."""
478
+ now = datetime.now(UTC).isoformat()
479
+ conn = sqlite3.connect(sqlite_path)
480
+ cur = conn.cursor()
481
+ cur.execute(
482
+ """
483
+ CREATE TABLE IF NOT EXISTS node_metrics (
484
+ node_id TEXT NOT NULL,
485
+ metric TEXT NOT NULL,
486
+ score REAL NOT NULL,
487
+ computed_at TEXT NOT NULL,
488
+ PRIMARY KEY (node_id, metric)
489
+ )
490
+ """
491
+ )
492
+ cur.executemany(
493
+ """
494
+ INSERT INTO node_metrics (node_id, metric, score, computed_at)
495
+ VALUES (?, ?, ?, ?)
496
+ ON CONFLICT(node_id, metric) DO UPDATE SET
497
+ score = excluded.score,
498
+ computed_at = excluded.computed_at
499
+ """,
500
+ [(node_id, metric, float(score), now) for node_id, score in scores.items()],
501
+ )
502
+ conn.commit()
503
+ conn.close()
504
+
505
+
506
+ def _build_why(
507
+ *,
508
+ graph: nx.DiGraph,
509
+ node_id: str,
510
+ semantic: float,
511
+ centrality: float,
512
+ proximity: float,
513
+ centrality_label: str = "centrality",
514
+ ) -> tuple[str, ...]:
515
+ """Generate an explainable summary for a node score."""
516
+ messages: list[str] = []
517
+
518
+ incoming = list(graph.in_edges(node_id, data=True))
519
+ if incoming:
520
+ callers = 0
521
+ importers = 0
522
+ inheritors = 0
523
+ for _, _, data in incoming:
524
+ relations = data.get("relations", set())
525
+ callers += int("CALLS" in relations)
526
+ importers += int("IMPORTS" in relations)
527
+ inheritors += int("INHERITS" in relations)
528
+ if callers:
529
+ messages.append(f"called by {callers} upstream node(s)")
530
+ if importers:
531
+ messages.append(f"imported by {importers} upstream node(s)")
532
+ if inheritors:
533
+ messages.append(f"inherited by {inheritors} subclass node(s)")
534
+
535
+ if semantic > 0.75:
536
+ messages.append("strong semantic match to the query")
537
+ elif semantic > 0.40:
538
+ messages.append("moderate semantic match to the query")
539
+
540
+ if centrality > 0.75:
541
+ messages.append(f"high {centrality_label} within the ranked subgraph")
542
+ elif centrality > 0.40:
543
+ messages.append(f"moderate {centrality_label} within the ranked subgraph")
544
+
545
+ if proximity >= 1.0:
546
+ messages.append("direct semantic seed")
547
+ elif proximity >= 0.5:
548
+ messages.append("one hop from a semantic seed")
549
+ elif proximity > 0:
550
+ messages.append("within local query neighborhood")
551
+
552
+ if not messages:
553
+ messages.append("ranked by combined structural and semantic signals")
554
+
555
+ return tuple(messages)