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/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")