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