pycode-kg 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. pycode_kg/.DS_Store +0 -0
  2. pycode_kg/__init__.py +91 -0
  3. pycode_kg/__main__.py +11 -0
  4. pycode_kg/analysis/__init__.py +15 -0
  5. pycode_kg/analysis/bridge.py +108 -0
  6. pycode_kg/analysis/centrality.py +412 -0
  7. pycode_kg/analysis/framework_detector.py +103 -0
  8. pycode_kg/analysis/hybrid_rank.py +53 -0
  9. pycode_kg/app.py +1335 -0
  10. pycode_kg/architecture.py +624 -0
  11. pycode_kg/build_pycodekg_lancedb.py +15 -0
  12. pycode_kg/build_pycodekg_sqlite.py +15 -0
  13. pycode_kg/cli/__init__.py +29 -0
  14. pycode_kg/cli/cmd_analyze.py +96 -0
  15. pycode_kg/cli/cmd_architecture.py +150 -0
  16. pycode_kg/cli/cmd_bridges.py +26 -0
  17. pycode_kg/cli/cmd_build.py +154 -0
  18. pycode_kg/cli/cmd_build_full.py +242 -0
  19. pycode_kg/cli/cmd_centrality.py +131 -0
  20. pycode_kg/cli/cmd_explain.py +180 -0
  21. pycode_kg/cli/cmd_framework_nodes.py +18 -0
  22. pycode_kg/cli/cmd_hooks.py +137 -0
  23. pycode_kg/cli/cmd_init.py +312 -0
  24. pycode_kg/cli/cmd_mcp.py +71 -0
  25. pycode_kg/cli/cmd_model.py +53 -0
  26. pycode_kg/cli/cmd_query.py +211 -0
  27. pycode_kg/cli/cmd_snapshot.py +421 -0
  28. pycode_kg/cli/cmd_viz.py +180 -0
  29. pycode_kg/cli/main.py +23 -0
  30. pycode_kg/cli/options.py +63 -0
  31. pycode_kg/config.py +78 -0
  32. pycode_kg/graph.py +125 -0
  33. pycode_kg/index.py +542 -0
  34. pycode_kg/kg.py +220 -0
  35. pycode_kg/layout3d.py +470 -0
  36. pycode_kg/mcp/bridge_tools.py +19 -0
  37. pycode_kg/mcp/framework_tools.py +18 -0
  38. pycode_kg/mcp_server.py +1965 -0
  39. pycode_kg/module/__init__.py +83 -0
  40. pycode_kg/module/base.py +720 -0
  41. pycode_kg/module/extractor.py +276 -0
  42. pycode_kg/module/types.py +532 -0
  43. pycode_kg/pycodekg.py +543 -0
  44. pycode_kg/pycodekg_query.py +1 -0
  45. pycode_kg/pycodekg_snippet_packer.py +1 -0
  46. pycode_kg/pycodekg_thorough_analysis.py +2751 -0
  47. pycode_kg/pycodekg_viz.py +1 -0
  48. pycode_kg/pycodekg_viz3d.py +1 -0
  49. pycode_kg/ranking/__init__.py +1 -0
  50. pycode_kg/ranking/cli_rank.py +92 -0
  51. pycode_kg/ranking/coderank.py +555 -0
  52. pycode_kg/snapshots.py +612 -0
  53. pycode_kg/sql/004_add_centrality_table.sql +12 -0
  54. pycode_kg/store.py +766 -0
  55. pycode_kg/utils.py +39 -0
  56. pycode_kg/visitor.py +413 -0
  57. pycode_kg/viz3d.py +1353 -0
  58. pycode_kg/viz3d_timeline.py +364 -0
  59. pycode_kg-0.16.0.dist-info/METADATA +305 -0
  60. pycode_kg-0.16.0.dist-info/RECORD +63 -0
  61. pycode_kg-0.16.0.dist-info/WHEEL +4 -0
  62. pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
  63. pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
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,11 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Dispatcher for python -m pycode_kg <command> [args...]
4
+
5
+ Routes to Click group-based CLI.
6
+ """
7
+
8
+ from pycode_kg.cli.main import cli
9
+
10
+ if __name__ == "__main__":
11
+ cli()
@@ -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
+ ]