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.
- pycode_kg/.DS_Store +0 -0
- pycode_kg/__init__.py +91 -0
- pycode_kg/__main__.py +11 -0
- pycode_kg/analysis/__init__.py +15 -0
- pycode_kg/analysis/bridge.py +108 -0
- pycode_kg/analysis/centrality.py +412 -0
- pycode_kg/analysis/framework_detector.py +103 -0
- pycode_kg/analysis/hybrid_rank.py +53 -0
- pycode_kg/app.py +1335 -0
- pycode_kg/architecture.py +624 -0
- pycode_kg/build_pycodekg_lancedb.py +15 -0
- pycode_kg/build_pycodekg_sqlite.py +15 -0
- pycode_kg/cli/__init__.py +29 -0
- pycode_kg/cli/cmd_analyze.py +96 -0
- pycode_kg/cli/cmd_architecture.py +150 -0
- pycode_kg/cli/cmd_bridges.py +26 -0
- pycode_kg/cli/cmd_build.py +154 -0
- pycode_kg/cli/cmd_build_full.py +242 -0
- pycode_kg/cli/cmd_centrality.py +131 -0
- pycode_kg/cli/cmd_explain.py +180 -0
- pycode_kg/cli/cmd_framework_nodes.py +18 -0
- pycode_kg/cli/cmd_hooks.py +137 -0
- pycode_kg/cli/cmd_init.py +312 -0
- pycode_kg/cli/cmd_mcp.py +71 -0
- pycode_kg/cli/cmd_model.py +53 -0
- pycode_kg/cli/cmd_query.py +211 -0
- pycode_kg/cli/cmd_snapshot.py +421 -0
- pycode_kg/cli/cmd_viz.py +180 -0
- pycode_kg/cli/main.py +23 -0
- pycode_kg/cli/options.py +63 -0
- pycode_kg/config.py +78 -0
- pycode_kg/graph.py +125 -0
- pycode_kg/index.py +542 -0
- pycode_kg/kg.py +220 -0
- pycode_kg/layout3d.py +470 -0
- pycode_kg/mcp/bridge_tools.py +19 -0
- pycode_kg/mcp/framework_tools.py +18 -0
- pycode_kg/mcp_server.py +1965 -0
- pycode_kg/module/__init__.py +83 -0
- pycode_kg/module/base.py +720 -0
- pycode_kg/module/extractor.py +276 -0
- pycode_kg/module/types.py +532 -0
- pycode_kg/pycodekg.py +543 -0
- pycode_kg/pycodekg_query.py +1 -0
- pycode_kg/pycodekg_snippet_packer.py +1 -0
- pycode_kg/pycodekg_thorough_analysis.py +2751 -0
- pycode_kg/pycodekg_viz.py +1 -0
- pycode_kg/pycodekg_viz3d.py +1 -0
- pycode_kg/ranking/__init__.py +1 -0
- pycode_kg/ranking/cli_rank.py +92 -0
- pycode_kg/ranking/coderank.py +555 -0
- pycode_kg/snapshots.py +612 -0
- pycode_kg/sql/004_add_centrality_table.sql +12 -0
- pycode_kg/store.py +766 -0
- pycode_kg/utils.py +39 -0
- pycode_kg/visitor.py +413 -0
- pycode_kg/viz3d.py +1353 -0
- pycode_kg/viz3d_timeline.py +364 -0
- pycode_kg-0.16.0.dist-info/METADATA +305 -0
- pycode_kg-0.16.0.dist-info/RECORD +63 -0
- pycode_kg-0.16.0.dist-info/WHEEL +4 -0
- pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
- 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)
|