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
pycode_kg/.DS_Store
ADDED
|
Binary file
|
pycode_kg/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pycode_kg: A tool to build a searchable knowledge graph from Python repositories.
|
|
3
|
+
|
|
4
|
+
Pure AST extraction → SQLite (authoritative) → LanceDB (semantic index).
|
|
5
|
+
|
|
6
|
+
Public API
|
|
7
|
+
----------
|
|
8
|
+
Primary entry point::
|
|
9
|
+
|
|
10
|
+
from pycode_kg import PyCodeKG
|
|
11
|
+
|
|
12
|
+
kg = PyCodeKG(repo_root, db_path, lancedb_dir)
|
|
13
|
+
stats = kg.build(wipe=True)
|
|
14
|
+
result = kg.query("database connection setup")
|
|
15
|
+
pack = kg.pack("configuration loading")
|
|
16
|
+
pack.save("context.md")
|
|
17
|
+
|
|
18
|
+
Individual layers::
|
|
19
|
+
|
|
20
|
+
from pycode_kg import CodeGraph, GraphStore, SemanticIndex
|
|
21
|
+
|
|
22
|
+
Result types::
|
|
23
|
+
|
|
24
|
+
from pycode_kg import BuildStats, QueryResult, SnippetPack, Snippet
|
|
25
|
+
|
|
26
|
+
Low-level primitives (v0 contract, locked)::
|
|
27
|
+
|
|
28
|
+
from pycode_kg import Node, Edge
|
|
29
|
+
|
|
30
|
+
KGModule SDK (build new domain KGs)::
|
|
31
|
+
|
|
32
|
+
from pycode_kg import KGModule, KGExtractor, PyCodeKGExtractor, NodeSpec, EdgeSpec
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
__version__ = "0.16.0"
|
|
36
|
+
__author__ = "Eric G. Suchanek, PhD"
|
|
37
|
+
|
|
38
|
+
# Low-level primitives (locked v0 contract)
|
|
39
|
+
# Layered classes
|
|
40
|
+
from pycode_kg.graph import CodeGraph
|
|
41
|
+
from pycode_kg.index import Embedder, SeedHit, SemanticIndex, SentenceTransformerEmbedder
|
|
42
|
+
|
|
43
|
+
# Orchestrator + result types
|
|
44
|
+
from pycode_kg.kg import BuildStats, PyCodeKG, QueryResult, Snippet, SnippetPack
|
|
45
|
+
|
|
46
|
+
# KGModule SDK
|
|
47
|
+
from pycode_kg.module import EdgeSpec, KGExtractor, KGModule, NodeSpec, PyCodeKGExtractor
|
|
48
|
+
from pycode_kg.pycodekg import DEFAULT_MODEL, Edge, Node
|
|
49
|
+
from pycode_kg.snapshots import (
|
|
50
|
+
Snapshot,
|
|
51
|
+
SnapshotDelta,
|
|
52
|
+
SnapshotManager,
|
|
53
|
+
SnapshotManifest,
|
|
54
|
+
SnapshotMetrics,
|
|
55
|
+
)
|
|
56
|
+
from pycode_kg.store import DEFAULT_RELS, GraphStore, ProvMeta
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
# primitives
|
|
60
|
+
"Node",
|
|
61
|
+
"Edge",
|
|
62
|
+
"DEFAULT_MODEL",
|
|
63
|
+
# layers
|
|
64
|
+
"CodeGraph",
|
|
65
|
+
"GraphStore",
|
|
66
|
+
"ProvMeta",
|
|
67
|
+
"DEFAULT_RELS",
|
|
68
|
+
"Embedder",
|
|
69
|
+
"SentenceTransformerEmbedder",
|
|
70
|
+
"SemanticIndex",
|
|
71
|
+
"SeedHit",
|
|
72
|
+
# KGModule SDK
|
|
73
|
+
"KGModule",
|
|
74
|
+
"KGExtractor",
|
|
75
|
+
"PyCodeKGExtractor",
|
|
76
|
+
"NodeSpec",
|
|
77
|
+
"EdgeSpec",
|
|
78
|
+
# orchestrator
|
|
79
|
+
"PyCodeKG",
|
|
80
|
+
# result types
|
|
81
|
+
"BuildStats",
|
|
82
|
+
"QueryResult",
|
|
83
|
+
"Snippet",
|
|
84
|
+
"SnippetPack",
|
|
85
|
+
# snapshots
|
|
86
|
+
"Snapshot",
|
|
87
|
+
"SnapshotDelta",
|
|
88
|
+
"SnapshotManifest",
|
|
89
|
+
"SnapshotManager",
|
|
90
|
+
"SnapshotMetrics",
|
|
91
|
+
]
|
pycode_kg/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Analysis primitives for PyCodeKG."""
|
|
2
|
+
|
|
3
|
+
from .centrality import (
|
|
4
|
+
CentralityConfig,
|
|
5
|
+
CentralityRecord,
|
|
6
|
+
StructuralImportanceRanker,
|
|
7
|
+
aggregate_module_scores,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CentralityConfig",
|
|
12
|
+
"CentralityRecord",
|
|
13
|
+
"StructuralImportanceRanker",
|
|
14
|
+
"aggregate_module_scores",
|
|
15
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module Connectivity Centrality for PyCodeKG.
|
|
3
|
+
Measures module interaction complexity: how many unique modules each module calls/imports.
|
|
4
|
+
For well-modularized codebases, identifies orchestrator and hub modules.
|
|
5
|
+
|
|
6
|
+
Author: Eric G. Suchanek, PhD
|
|
7
|
+
Last Revision: 2026-03-12 17:30:35
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
|
|
13
|
+
from pycode_kg.analysis.centrality import CentralityRecord, StructuralImportanceRanker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compute_bridge_centrality(
|
|
17
|
+
kind="module", include_imports=True, top=25, db_path="pycodekg.sqlite"
|
|
18
|
+
):
|
|
19
|
+
"""
|
|
20
|
+
Compute module connectivity: unique module interactions per module.
|
|
21
|
+
|
|
22
|
+
For well-modularized codebases with strong module boundaries, connectivity
|
|
23
|
+
identifies which modules are hubs (calling many others) or widely depended upon
|
|
24
|
+
(called by many modules).
|
|
25
|
+
|
|
26
|
+
Replaces betweenness centrality which is meaningless when inter-module edges are zero.
|
|
27
|
+
|
|
28
|
+
:param kind: Node kind (default 'module', currently unused but kept for compatibility)
|
|
29
|
+
:param include_imports: Whether to include IMPORTS in connectivity (default True)
|
|
30
|
+
:param top: Number of top modules to return (default 25)
|
|
31
|
+
:param db_path: Path to SQLite database
|
|
32
|
+
:return: List of (module_path, connectivity_score) tuples
|
|
33
|
+
"""
|
|
34
|
+
with sqlite3.connect(db_path) as con:
|
|
35
|
+
rows = con.execute(
|
|
36
|
+
"""
|
|
37
|
+
SELECT src.module_path, dst.module_path, rel
|
|
38
|
+
FROM edges
|
|
39
|
+
JOIN nodes AS src ON edges.src = src.id
|
|
40
|
+
JOIN nodes AS dst ON edges.dst = dst.id
|
|
41
|
+
WHERE rel IN ('CALLS', 'IMPORTS')
|
|
42
|
+
AND src.module_path IS NOT NULL
|
|
43
|
+
AND dst.module_path IS NOT NULL
|
|
44
|
+
"""
|
|
45
|
+
).fetchall()
|
|
46
|
+
|
|
47
|
+
# Compute unique modules called + unique modules calling this module
|
|
48
|
+
outbound: dict[str, set[str]] = defaultdict(set) # modules this module calls
|
|
49
|
+
inbound: dict[str, set[str]] = defaultdict(set) # modules that call this module
|
|
50
|
+
call_counts: dict[str, int] = defaultdict(int) # total call frequency
|
|
51
|
+
|
|
52
|
+
for src_mod, dst_mod, rel in rows:
|
|
53
|
+
if not src_mod or not dst_mod:
|
|
54
|
+
continue
|
|
55
|
+
if rel == "IMPORTS" and not include_imports:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Record outbound: src_mod calls/imports dst_mod
|
|
59
|
+
outbound[src_mod].add(dst_mod)
|
|
60
|
+
# Record inbound: dst_mod is called/imported by src_mod
|
|
61
|
+
inbound[dst_mod].add(src_mod)
|
|
62
|
+
call_counts[src_mod] += 1
|
|
63
|
+
|
|
64
|
+
# Collect all modules
|
|
65
|
+
all_modules = set(outbound.keys()) | set(inbound.keys())
|
|
66
|
+
|
|
67
|
+
# Compute connectivity score: unique modules touched (fan-out + fan-in)
|
|
68
|
+
# Higher score = more coupled with other modules
|
|
69
|
+
scores: dict[str, float] = {}
|
|
70
|
+
for mod in all_modules:
|
|
71
|
+
unique_outbound = len(outbound[mod])
|
|
72
|
+
unique_inbound = len(inbound[mod])
|
|
73
|
+
total_calls = call_counts[mod]
|
|
74
|
+
|
|
75
|
+
# Normalize: average of outbound and inbound diversity + call frequency
|
|
76
|
+
# Scale to [0, 1]: assume typical module touches ~15 others
|
|
77
|
+
connectivity_score = (
|
|
78
|
+
(unique_outbound + unique_inbound) / 30.0 # diversity (60%)
|
|
79
|
+
+ min(total_calls / 50.0, 1.0) * 0.4 # frequency (40%)
|
|
80
|
+
) / 1.4 # normalize to roughly [0, 1]
|
|
81
|
+
scores[mod] = min(connectivity_score, 1.0)
|
|
82
|
+
|
|
83
|
+
# Persist scores
|
|
84
|
+
records = [
|
|
85
|
+
CentralityRecord(
|
|
86
|
+
node_id=mod,
|
|
87
|
+
kind="module",
|
|
88
|
+
name=mod.split(".")[-1],
|
|
89
|
+
module_path=mod,
|
|
90
|
+
score=score,
|
|
91
|
+
rank=idx + 1,
|
|
92
|
+
inbound_count=len(inbound.get(mod, set())),
|
|
93
|
+
cross_module_inbound=len(inbound.get(mod, set())), # all are cross-module
|
|
94
|
+
rel_breakdown={
|
|
95
|
+
"calls_to_modules": len(outbound.get(mod, set())),
|
|
96
|
+
"called_by_modules": len(inbound.get(mod, set())),
|
|
97
|
+
},
|
|
98
|
+
top_contributors=[],
|
|
99
|
+
)
|
|
100
|
+
for idx, (mod, score) in enumerate(sorted(scores.items(), key=lambda x: x[1], reverse=True))
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
if records:
|
|
104
|
+
StructuralImportanceRanker(db_path).write_scores(records, metric="module_connectivity")
|
|
105
|
+
|
|
106
|
+
# Return top modules by connectivity
|
|
107
|
+
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
|
108
|
+
return ranked[:top]
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Structural centrality analysis for PyCodeKG.
|
|
3
|
+
|
|
4
|
+
Implements Structural Importance Ranking (SIR): a deterministic weighted
|
|
5
|
+
PageRank over the sym-stub-resolved PyCodeKG graph. Edge weights are tuned
|
|
6
|
+
per relation type (CALLS > INHERITS > IMPORTS > CONTAINS) and amplified for
|
|
7
|
+
cross-module links, giving a stable, interpretable importance score for every
|
|
8
|
+
module, class, function, and method in the indexed codebase.
|
|
9
|
+
|
|
10
|
+
Public API:
|
|
11
|
+
- :class:`StructuralImportanceRanker` — compute and persist SIR scores.
|
|
12
|
+
- :func:`aggregate_module_scores` — roll node scores up to module level.
|
|
13
|
+
|
|
14
|
+
Author: Eric G. Suchanek, PhD
|
|
15
|
+
Last Revision: 2026-03-11 12:45:30
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sqlite3
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
_ALLOWED_KINDS = {"module", "class", "function", "method"}
|
|
29
|
+
_STRUCTURAL_RELS = ("CALLS", "INHERITS", "IMPORTS", "CONTAINS")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class CentralityConfig:
|
|
34
|
+
"""Configuration for Structural Importance Ranking.
|
|
35
|
+
|
|
36
|
+
:param damping: PageRank damping factor.
|
|
37
|
+
:param max_iter: Maximum PageRank iterations.
|
|
38
|
+
:param tol: Convergence tolerance.
|
|
39
|
+
:param rel_weights: Per-relation weights.
|
|
40
|
+
:param cross_module_boost: Weight multiplier for cross-module edges.
|
|
41
|
+
:param private_penalty: Multiplier for private symbols.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
damping: float = 0.85
|
|
45
|
+
max_iter: int = 100
|
|
46
|
+
tol: float = 1e-10
|
|
47
|
+
rel_weights: dict[str, float] = field(
|
|
48
|
+
default_factory=lambda: {
|
|
49
|
+
"CALLS": 1.0,
|
|
50
|
+
"INHERITS": 0.8,
|
|
51
|
+
"IMPORTS": 0.45,
|
|
52
|
+
"CONTAINS": 0.15,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
cross_module_boost: float = 1.5
|
|
56
|
+
private_penalty: float = 0.85
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class CentralityRecord:
|
|
61
|
+
"""Centrality result for a single node.
|
|
62
|
+
|
|
63
|
+
:param node_id: Stable node identifier.
|
|
64
|
+
:param kind: PyCodeKG node kind.
|
|
65
|
+
:param name: Node name.
|
|
66
|
+
:param module_path: Module path from the store.
|
|
67
|
+
:param score: Final importance score.
|
|
68
|
+
:param rank: Rank among all returned nodes.
|
|
69
|
+
:param inbound_count: Number of inbound effective edges.
|
|
70
|
+
:param cross_module_inbound: Number of inbound cross-module edges.
|
|
71
|
+
:param rel_breakdown: Inbound counts by relation type.
|
|
72
|
+
:param top_contributors: Top inbound contributor summaries.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
node_id: str
|
|
76
|
+
kind: str
|
|
77
|
+
name: str
|
|
78
|
+
module_path: str | None
|
|
79
|
+
score: float
|
|
80
|
+
rank: int
|
|
81
|
+
inbound_count: int
|
|
82
|
+
cross_module_inbound: int
|
|
83
|
+
rel_breakdown: dict[str, int]
|
|
84
|
+
top_contributors: list[dict[str, Any]]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class _NodeInfo:
|
|
89
|
+
node_id: str
|
|
90
|
+
kind: str
|
|
91
|
+
name: str
|
|
92
|
+
module_path: str | None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(slots=True)
|
|
96
|
+
class _EffectiveEdge:
|
|
97
|
+
src: str
|
|
98
|
+
dst: str
|
|
99
|
+
rel: str
|
|
100
|
+
weight: float
|
|
101
|
+
same_module: bool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class StructuralImportanceRanker:
|
|
105
|
+
"""Compute Structural Importance Ranking (SIR) for a PyCodeKG SQLite database.
|
|
106
|
+
|
|
107
|
+
Loads all real nodes (excluding sym-stub intermediates) and structural
|
|
108
|
+
edges, resolves cross-module symbol stubs, then runs a weighted PageRank
|
|
109
|
+
where each relation type contributes a distinct edge weight and
|
|
110
|
+
cross-module edges receive an additional boost. Private symbols are
|
|
111
|
+
penalized post-convergence. Scores are normalized to sum to 1.0.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, db_path: str | Path, config: CentralityConfig | None = None) -> None:
|
|
115
|
+
self.db_path = Path(db_path)
|
|
116
|
+
self.config = config or CentralityConfig()
|
|
117
|
+
|
|
118
|
+
def compute(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
kinds: set[str] | None = None,
|
|
122
|
+
top: int | None = None,
|
|
123
|
+
) -> list[CentralityRecord]:
|
|
124
|
+
"""Compute SIR scores for all nodes in the graph.
|
|
125
|
+
|
|
126
|
+
:param kinds: Restrict results to a subset of node kinds
|
|
127
|
+
(``'module'``, ``'class'``, ``'function'``, ``'method'``).
|
|
128
|
+
When ``None``, all kinds are returned.
|
|
129
|
+
:param top: Cap the number of returned records after filtering.
|
|
130
|
+
:return: Records sorted descending by normalized importance score,
|
|
131
|
+
each annotated with rank, inbound-edge counts, and top
|
|
132
|
+
contributing callers.
|
|
133
|
+
"""
|
|
134
|
+
node_map = self._load_nodes()
|
|
135
|
+
effective_edges = self._load_effective_edges(node_map)
|
|
136
|
+
scores = self._pagerank(node_map, effective_edges)
|
|
137
|
+
records = self._assemble_records(node_map, effective_edges, scores)
|
|
138
|
+
|
|
139
|
+
if kinds:
|
|
140
|
+
records = [r for r in records if r.kind in kinds]
|
|
141
|
+
if top is not None:
|
|
142
|
+
records = records[:top]
|
|
143
|
+
return records
|
|
144
|
+
|
|
145
|
+
def write_scores(
|
|
146
|
+
self,
|
|
147
|
+
records: list[CentralityRecord],
|
|
148
|
+
*,
|
|
149
|
+
metric: str = "sir_pagerank",
|
|
150
|
+
) -> int:
|
|
151
|
+
"""Persist SIR scores into the ``centrality_scores`` table.
|
|
152
|
+
|
|
153
|
+
Upserts on ``(node_id, metric)`` so repeated runs overwrite stale
|
|
154
|
+
scores without accumulating duplicate rows.
|
|
155
|
+
|
|
156
|
+
:param records: Ranked records from :meth:`compute`.
|
|
157
|
+
:param metric: Label for the metric column (default ``'sir_pagerank'``).
|
|
158
|
+
:return: Number of rows written.
|
|
159
|
+
"""
|
|
160
|
+
rows = []
|
|
161
|
+
computed_at = datetime.now(UTC).isoformat()
|
|
162
|
+
params = json.dumps(
|
|
163
|
+
{
|
|
164
|
+
"damping": self.config.damping,
|
|
165
|
+
"max_iter": self.config.max_iter,
|
|
166
|
+
"tol": self.config.tol,
|
|
167
|
+
"rel_weights": self.config.rel_weights,
|
|
168
|
+
"cross_module_boost": self.config.cross_module_boost,
|
|
169
|
+
"private_penalty": self.config.private_penalty,
|
|
170
|
+
},
|
|
171
|
+
sort_keys=True,
|
|
172
|
+
)
|
|
173
|
+
for rec in records:
|
|
174
|
+
rows.append((rec.node_id, metric, rec.score, rec.rank, computed_at, params))
|
|
175
|
+
|
|
176
|
+
with sqlite3.connect(self.db_path) as con:
|
|
177
|
+
con.execute(
|
|
178
|
+
"""
|
|
179
|
+
CREATE TABLE IF NOT EXISTS centrality_scores (
|
|
180
|
+
node_id TEXT NOT NULL,
|
|
181
|
+
metric TEXT NOT NULL,
|
|
182
|
+
score REAL NOT NULL,
|
|
183
|
+
rank INTEGER,
|
|
184
|
+
computed_at TEXT NOT NULL,
|
|
185
|
+
params_json TEXT NOT NULL,
|
|
186
|
+
PRIMARY KEY (node_id, metric)
|
|
187
|
+
)
|
|
188
|
+
"""
|
|
189
|
+
)
|
|
190
|
+
con.executemany(
|
|
191
|
+
"""
|
|
192
|
+
INSERT INTO centrality_scores
|
|
193
|
+
(node_id, metric, score, rank, computed_at, params_json)
|
|
194
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
195
|
+
ON CONFLICT(node_id, metric) DO UPDATE SET
|
|
196
|
+
score = excluded.score,
|
|
197
|
+
rank = excluded.rank,
|
|
198
|
+
computed_at = excluded.computed_at,
|
|
199
|
+
params_json = excluded.params_json
|
|
200
|
+
""",
|
|
201
|
+
rows,
|
|
202
|
+
)
|
|
203
|
+
con.commit()
|
|
204
|
+
return len(rows)
|
|
205
|
+
|
|
206
|
+
def _load_nodes(self) -> dict[str, _NodeInfo]:
|
|
207
|
+
with sqlite3.connect(self.db_path) as con:
|
|
208
|
+
rows = con.execute(
|
|
209
|
+
"""
|
|
210
|
+
SELECT id, kind, name, module_path
|
|
211
|
+
FROM nodes
|
|
212
|
+
WHERE kind IN ('module', 'class', 'function', 'method')
|
|
213
|
+
"""
|
|
214
|
+
).fetchall()
|
|
215
|
+
return {
|
|
216
|
+
row[0]: _NodeInfo(node_id=row[0], kind=row[1], name=row[2], module_path=row[3])
|
|
217
|
+
for row in rows
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
def _load_effective_edges(
|
|
221
|
+
self,
|
|
222
|
+
node_map: dict[str, _NodeInfo],
|
|
223
|
+
) -> list[_EffectiveEdge]:
|
|
224
|
+
with sqlite3.connect(self.db_path) as con:
|
|
225
|
+
structural = con.execute(
|
|
226
|
+
"""
|
|
227
|
+
SELECT src, rel, dst
|
|
228
|
+
FROM edges
|
|
229
|
+
WHERE rel IN ('CALLS', 'INHERITS', 'IMPORTS', 'CONTAINS')
|
|
230
|
+
"""
|
|
231
|
+
).fetchall()
|
|
232
|
+
resolves = con.execute(
|
|
233
|
+
"""
|
|
234
|
+
SELECT src, dst
|
|
235
|
+
FROM edges
|
|
236
|
+
WHERE rel = 'RESOLVES_TO'
|
|
237
|
+
"""
|
|
238
|
+
).fetchall()
|
|
239
|
+
|
|
240
|
+
resolve_map: dict[str, list[str]] = defaultdict(list)
|
|
241
|
+
for sym_id, dst in resolves:
|
|
242
|
+
if dst in node_map:
|
|
243
|
+
resolve_map[sym_id].append(dst)
|
|
244
|
+
|
|
245
|
+
dedup: set[tuple[str, str, str]] = set()
|
|
246
|
+
effective: list[_EffectiveEdge] = []
|
|
247
|
+
|
|
248
|
+
for src, rel, dst in structural:
|
|
249
|
+
if src not in node_map:
|
|
250
|
+
continue
|
|
251
|
+
targets: list[str]
|
|
252
|
+
if dst in node_map:
|
|
253
|
+
targets = [dst]
|
|
254
|
+
else:
|
|
255
|
+
targets = resolve_map.get(dst, [])
|
|
256
|
+
for target in targets:
|
|
257
|
+
if target not in node_map:
|
|
258
|
+
continue
|
|
259
|
+
key = (src, rel, target)
|
|
260
|
+
if key in dedup:
|
|
261
|
+
continue
|
|
262
|
+
dedup.add(key)
|
|
263
|
+
weight = float(self.config.rel_weights[rel])
|
|
264
|
+
same_module = node_map[src].module_path == node_map[target].module_path
|
|
265
|
+
if not same_module:
|
|
266
|
+
weight *= self.config.cross_module_boost
|
|
267
|
+
effective.append(
|
|
268
|
+
_EffectiveEdge(
|
|
269
|
+
src=src,
|
|
270
|
+
dst=target,
|
|
271
|
+
rel=rel,
|
|
272
|
+
weight=weight,
|
|
273
|
+
same_module=same_module,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return effective
|
|
277
|
+
|
|
278
|
+
def _pagerank(
|
|
279
|
+
self,
|
|
280
|
+
node_map: dict[str, _NodeInfo],
|
|
281
|
+
effective_edges: list[_EffectiveEdge],
|
|
282
|
+
) -> dict[str, float]:
|
|
283
|
+
nodes = list(node_map)
|
|
284
|
+
n = len(nodes)
|
|
285
|
+
if n == 0:
|
|
286
|
+
return {}
|
|
287
|
+
|
|
288
|
+
out_weight: dict[str, float] = defaultdict(float)
|
|
289
|
+
incoming: dict[str, list[tuple[str, float]]] = defaultdict(list)
|
|
290
|
+
for edge in effective_edges:
|
|
291
|
+
out_weight[edge.src] += edge.weight
|
|
292
|
+
incoming[edge.dst].append((edge.src, edge.weight))
|
|
293
|
+
|
|
294
|
+
pr = {node_id: 1.0 / n for node_id in nodes}
|
|
295
|
+
damping = self.config.damping
|
|
296
|
+
|
|
297
|
+
for _ in range(self.config.max_iter):
|
|
298
|
+
base = (1.0 - damping) / n
|
|
299
|
+
dangling_mass = sum(pr[node_id] for node_id in nodes if out_weight[node_id] == 0.0)
|
|
300
|
+
dangling_share = damping * dangling_mass / n
|
|
301
|
+
new_pr: dict[str, float] = {}
|
|
302
|
+
delta = 0.0
|
|
303
|
+
|
|
304
|
+
for node_id in nodes:
|
|
305
|
+
score = base + dangling_share
|
|
306
|
+
for src, weight in incoming.get(node_id, []):
|
|
307
|
+
denom = out_weight[src]
|
|
308
|
+
if denom > 0.0:
|
|
309
|
+
score += damping * pr[src] * (weight / denom)
|
|
310
|
+
new_pr[node_id] = score
|
|
311
|
+
delta += abs(score - pr[node_id])
|
|
312
|
+
|
|
313
|
+
pr = new_pr
|
|
314
|
+
if delta < self.config.tol:
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
for node_id, info in node_map.items():
|
|
318
|
+
if info.name.startswith("_"):
|
|
319
|
+
pr[node_id] *= self.config.private_penalty
|
|
320
|
+
|
|
321
|
+
total = sum(pr.values())
|
|
322
|
+
if total > 0.0:
|
|
323
|
+
pr = {node_id: score / total for node_id, score in pr.items()}
|
|
324
|
+
return pr
|
|
325
|
+
|
|
326
|
+
def _assemble_records(
|
|
327
|
+
self,
|
|
328
|
+
node_map: dict[str, _NodeInfo],
|
|
329
|
+
effective_edges: list[_EffectiveEdge],
|
|
330
|
+
scores: dict[str, float],
|
|
331
|
+
) -> list[CentralityRecord]:
|
|
332
|
+
inbound_counts: dict[str, int] = defaultdict(int)
|
|
333
|
+
cross_counts: dict[str, int] = defaultdict(int)
|
|
334
|
+
rel_breakdown: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
335
|
+
out_weight: dict[str, float] = defaultdict(float)
|
|
336
|
+
incoming_edges: dict[str, list[_EffectiveEdge]] = defaultdict(list)
|
|
337
|
+
|
|
338
|
+
for edge in effective_edges:
|
|
339
|
+
out_weight[edge.src] += edge.weight
|
|
340
|
+
inbound_counts[edge.dst] += 1
|
|
341
|
+
rel_breakdown[edge.dst][edge.rel] += 1
|
|
342
|
+
if not edge.same_module:
|
|
343
|
+
cross_counts[edge.dst] += 1
|
|
344
|
+
incoming_edges[edge.dst].append(edge)
|
|
345
|
+
|
|
346
|
+
ranked_ids = sorted(scores, key=lambda node_id: scores[node_id], reverse=True)
|
|
347
|
+
records: list[CentralityRecord] = []
|
|
348
|
+
for rank, node_id in enumerate(ranked_ids, start=1):
|
|
349
|
+
info = node_map[node_id]
|
|
350
|
+
contributors: list[dict[str, Any]] = []
|
|
351
|
+
for edge in incoming_edges.get(node_id, []):
|
|
352
|
+
denom = out_weight[edge.src]
|
|
353
|
+
contrib = 0.0 if denom == 0.0 else scores[edge.src] * (edge.weight / denom)
|
|
354
|
+
src_info = node_map.get(edge.src)
|
|
355
|
+
contributors.append(
|
|
356
|
+
{
|
|
357
|
+
"src": edge.src,
|
|
358
|
+
"src_name": src_info.name if src_info else edge.src,
|
|
359
|
+
"rel": edge.rel,
|
|
360
|
+
"same_module": edge.same_module,
|
|
361
|
+
"contribution": contrib,
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
contributors.sort(key=lambda item: item["contribution"], reverse=True)
|
|
365
|
+
records.append(
|
|
366
|
+
CentralityRecord(
|
|
367
|
+
node_id=node_id,
|
|
368
|
+
kind=info.kind,
|
|
369
|
+
name=info.name,
|
|
370
|
+
module_path=info.module_path,
|
|
371
|
+
score=scores[node_id],
|
|
372
|
+
rank=rank,
|
|
373
|
+
inbound_count=inbound_counts[node_id],
|
|
374
|
+
cross_module_inbound=cross_counts[node_id],
|
|
375
|
+
rel_breakdown=dict(sorted(rel_breakdown[node_id].items())),
|
|
376
|
+
top_contributors=contributors[:5],
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
return records
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def aggregate_module_scores(records: list[CentralityRecord]) -> list[dict[str, Any]]:
|
|
383
|
+
"""Roll up node-level SIR scores into per-module totals.
|
|
384
|
+
|
|
385
|
+
Each node's score is weighted by kind (class × 1.2, all others × 1.0)
|
|
386
|
+
before accumulation, so modules that export important classes rank
|
|
387
|
+
higher than those containing only utility functions of similar raw score.
|
|
388
|
+
|
|
389
|
+
:param records: Node-level centrality records from
|
|
390
|
+
:meth:`StructuralImportanceRanker.compute`.
|
|
391
|
+
:return: List of ``{module_path, score, rank, member_count}`` dicts,
|
|
392
|
+
sorted descending by aggregated score.
|
|
393
|
+
"""
|
|
394
|
+
kind_weight = {"function": 1.0, "method": 1.0, "class": 1.2, "module": 1.0}
|
|
395
|
+
totals: dict[str, float] = defaultdict(float)
|
|
396
|
+
counts: dict[str, int] = defaultdict(int)
|
|
397
|
+
|
|
398
|
+
for rec in records:
|
|
399
|
+
module_key = rec.module_path or "<unknown>"
|
|
400
|
+
totals[module_key] += rec.score * kind_weight.get(rec.kind, 1.0)
|
|
401
|
+
counts[module_key] += 1
|
|
402
|
+
|
|
403
|
+
ranked = sorted(totals.items(), key=lambda item: item[1], reverse=True)
|
|
404
|
+
return [
|
|
405
|
+
{
|
|
406
|
+
"module_path": module_path,
|
|
407
|
+
"score": score,
|
|
408
|
+
"rank": rank,
|
|
409
|
+
"member_count": counts[module_path],
|
|
410
|
+
}
|
|
411
|
+
for rank, (module_path, score) in enumerate(ranked, start=1)
|
|
412
|
+
]
|