seam-code 0.3.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.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Cluster labeling module — deterministic labels with optional LLM naming.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
deterministic_label(members) -> str
|
|
5
|
+
label_cluster(members, naming_mode, api_key, model) -> tuple[str, str]
|
|
6
|
+
|
|
7
|
+
Design:
|
|
8
|
+
- deterministic_label: always available, zero dependencies, no I/O.
|
|
9
|
+
Derives a label from dominant directory/file prefix + highest-degree symbol.
|
|
10
|
+
Format: "dir/subdir — symbol_name" (em-dash separator).
|
|
11
|
+
- label_cluster: dispatches to LLM or deterministic based on naming_mode.
|
|
12
|
+
LLM path is fully isolated in _call_llm_for_label (stub-able in tests).
|
|
13
|
+
Any LLM error → silent fallback to deterministic + log warning.
|
|
14
|
+
- _call_llm_for_label: uses stdlib urllib only. NEVER called by default.
|
|
15
|
+
|
|
16
|
+
Naming source values:
|
|
17
|
+
"deterministic" — label from heuristic (always the fallback)
|
|
18
|
+
"llm" — label from LLM (only when naming_mode='llm' AND key present)
|
|
19
|
+
|
|
20
|
+
Import rules:
|
|
21
|
+
This module imports ONLY stdlib (logging, urllib, json, pathlib, collections).
|
|
22
|
+
It must NOT import seam.indexer.db, seam.query, seam.server, seam.cli.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from collections import Counter
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Timeout for LLM HTTP request (seconds). Fail fast — labeling is best-effort.
|
|
36
|
+
_LLM_TIMEOUT_SECONDS = 10
|
|
37
|
+
|
|
38
|
+
# Default LLM model when not specified
|
|
39
|
+
_DEFAULT_LLM_MODEL = "gpt-4o-mini"
|
|
40
|
+
|
|
41
|
+
# Anthropic-compatible API endpoint default (can be overridden in tests)
|
|
42
|
+
_DEFAULT_LLM_ENDPOINT = "https://api.openai.com/v1/chat/completions"
|
|
43
|
+
|
|
44
|
+
# Generic / non-module directory names. These read as packaging scaffolding,
|
|
45
|
+
# NOT functional areas — labeling a cluster 'src' or 'lib' tells an agent nothing.
|
|
46
|
+
# When the immediate parent dir is one of these, _dominant_dir walks UP the path
|
|
47
|
+
# tree to the first non-generic ancestor (e.g. 'render/src/x.py' → 'render').
|
|
48
|
+
GENERIC_DIRS: frozenset[str] = frozenset({"src", "lib", "app", "pkg", "main", "core", "base"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Public types ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
# Member dict shape (consumed by label functions):
|
|
54
|
+
# {"name": str, "file": str, "degree": int}
|
|
55
|
+
# "degree" = number of edges this symbol has (used to find the "most connected")
|
|
56
|
+
MemberInfo = dict[str, Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── deterministic_label ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _module_dir_for_path(file_path: str) -> str | None:
|
|
63
|
+
"""Highest NON-GENERIC module directory on a file's path (P2 two-level labels).
|
|
64
|
+
|
|
65
|
+
WHY two-level (vs. the old immediate-parent-only rule): a file like
|
|
66
|
+
'render/src/widget.py' used to label as 'src' — a packaging dir that names no
|
|
67
|
+
functional area. We instead walk the directory parts from the LEAF upward and
|
|
68
|
+
return the FIRST non-generic dir (the immediate parent if it isn't generic,
|
|
69
|
+
otherwise its first non-generic ancestor). This yields module-level labels
|
|
70
|
+
('query', 'analysis', 'render') instead of leaf/scaffolding noise.
|
|
71
|
+
|
|
72
|
+
Returns None when the file has no parent dir, or when every directory on the
|
|
73
|
+
path is generic (e.g. 'src/lib/x.py').
|
|
74
|
+
"""
|
|
75
|
+
parts = Path(file_path).parts
|
|
76
|
+
# Drop the filename (last part); only directory components are candidates.
|
|
77
|
+
dir_parts = parts[:-1]
|
|
78
|
+
# Walk from the immediate parent (closest to the file) upward toward the root,
|
|
79
|
+
# returning the first directory name that is NOT a generic scaffolding dir.
|
|
80
|
+
for name in reversed(dir_parts):
|
|
81
|
+
if name not in GENERIC_DIRS:
|
|
82
|
+
return name
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _dominant_dir(members: list[MemberInfo]) -> str | None:
|
|
87
|
+
"""Most common module DIRECTORY NAME among member files (P2 two-level).
|
|
88
|
+
|
|
89
|
+
WHY the dir name, not 'dir/filename': the filename is the noisy part — it
|
|
90
|
+
produces labels like 'unit/test_query_engine' or 'assets/index-Bb07j4Ym'
|
|
91
|
+
(a build bundle). The directory alone ('query', 'indexer', 'cli', 'analysis')
|
|
92
|
+
reads as a functional area. Ties broken alphabetically. None when no member
|
|
93
|
+
has a non-generic module directory (e.g. a repo-root file or an all-generic path).
|
|
94
|
+
|
|
95
|
+
P2 change: uses _module_dir_for_path so generic scaffolding dirs (src/lib/app/…)
|
|
96
|
+
are skipped in favour of the enclosing module dir.
|
|
97
|
+
"""
|
|
98
|
+
dirs: list[str] = []
|
|
99
|
+
for m in members:
|
|
100
|
+
file_path = m.get("file", "")
|
|
101
|
+
if file_path:
|
|
102
|
+
module_dir = _module_dir_for_path(file_path)
|
|
103
|
+
if module_dir is not None:
|
|
104
|
+
dirs.append(module_dir)
|
|
105
|
+
if not dirs:
|
|
106
|
+
return None
|
|
107
|
+
counts = Counter(dirs)
|
|
108
|
+
return min(counts.keys(), key=lambda k: (-counts[k], k))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def deterministic_label(members: list[MemberInfo]) -> str:
|
|
112
|
+
"""Derive a human-readable cluster label from its members.
|
|
113
|
+
|
|
114
|
+
Label format: "<dominant_dir> — <highest_degree_symbol>"
|
|
115
|
+
Example: "analysis — resolve_edge" (NOT "analysis/confidence — resolve_edge")
|
|
116
|
+
|
|
117
|
+
The anchor is the highest-degree symbol — the hub that best "names" the
|
|
118
|
+
community. The directory adds locality WITHOUT the filename noise that made
|
|
119
|
+
old labels look like file paths (the user-reported confusion). When a file
|
|
120
|
+
has no parent dir, the label is just the anchor symbol.
|
|
121
|
+
|
|
122
|
+
Algorithm:
|
|
123
|
+
1. Anchor = highest-degree symbol (ties broken by name alphabetically).
|
|
124
|
+
2. Dir = most common immediate-parent directory name (ties alphabetical).
|
|
125
|
+
3. Combine as "dir — anchor" (or just "anchor" when no dir is available).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
members: List of MemberInfo dicts with 'name', 'file', 'degree' keys.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Non-empty label string. Returns "<unnamed>" on empty input (never raises).
|
|
132
|
+
"""
|
|
133
|
+
if not members:
|
|
134
|
+
return "<unnamed>"
|
|
135
|
+
|
|
136
|
+
# Anchor: highest-degree symbol (ties broken by name) — the community's hub.
|
|
137
|
+
anchor = min(
|
|
138
|
+
members,
|
|
139
|
+
key=lambda m: (-m.get("degree", 0), m.get("name", "")),
|
|
140
|
+
).get("name", "<unknown>")
|
|
141
|
+
|
|
142
|
+
dir_name = _dominant_dir(members)
|
|
143
|
+
if dir_name and dir_name != anchor:
|
|
144
|
+
return f"{dir_name} — {anchor}"
|
|
145
|
+
return anchor
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── label_cluster ─────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def label_cluster(
|
|
152
|
+
members: list[MemberInfo],
|
|
153
|
+
naming_mode: str = "deterministic",
|
|
154
|
+
api_key: str | None = None,
|
|
155
|
+
model: str | None = None,
|
|
156
|
+
endpoint: str | None = None,
|
|
157
|
+
) -> tuple[str, str]:
|
|
158
|
+
"""Produce a label for a cluster, returning (label, naming_source).
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
members: List of MemberInfo dicts (name, file, degree).
|
|
162
|
+
naming_mode: "deterministic" (default) or "llm".
|
|
163
|
+
api_key: API key for LLM; required when naming_mode="llm".
|
|
164
|
+
model: LLM model name; uses _DEFAULT_LLM_MODEL when not set.
|
|
165
|
+
endpoint: LLM API endpoint; uses _DEFAULT_LLM_ENDPOINT when not set.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
(label, naming_source) where naming_source ∈ {"deterministic", "llm"}.
|
|
169
|
+
The LLM path is only tried when naming_mode="llm" AND api_key is present.
|
|
170
|
+
Any LLM error falls back to deterministic silently.
|
|
171
|
+
|
|
172
|
+
WHY: The naming_source is stored in the clusters table so operators can see
|
|
173
|
+
which names were AI-generated vs. heuristic-derived.
|
|
174
|
+
"""
|
|
175
|
+
if naming_mode != "llm" or not api_key:
|
|
176
|
+
# Deterministic mode or no key provided — never touch network code
|
|
177
|
+
return deterministic_label(members), "deterministic"
|
|
178
|
+
|
|
179
|
+
# LLM path: try to call, fall back on ANY error
|
|
180
|
+
try:
|
|
181
|
+
llm_label = _call_llm_for_label(
|
|
182
|
+
members,
|
|
183
|
+
api_key=api_key,
|
|
184
|
+
model=model or _DEFAULT_LLM_MODEL,
|
|
185
|
+
endpoint=endpoint or _DEFAULT_LLM_ENDPOINT,
|
|
186
|
+
)
|
|
187
|
+
if llm_label and llm_label.strip():
|
|
188
|
+
return llm_label.strip(), "llm"
|
|
189
|
+
# Empty/blank response → fall through to deterministic
|
|
190
|
+
logger.warning("cluster_naming: LLM returned empty label, using deterministic fallback")
|
|
191
|
+
return deterministic_label(members), "deterministic"
|
|
192
|
+
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
# Any error (network, timeout, bad JSON, missing key) is fail-safe
|
|
195
|
+
logger.warning(
|
|
196
|
+
"cluster_naming: LLM naming failed (%s: %s) — using deterministic fallback",
|
|
197
|
+
type(exc).__name__,
|
|
198
|
+
exc,
|
|
199
|
+
)
|
|
200
|
+
return deterministic_label(members), "deterministic"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── _call_llm_for_label ────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _call_llm_for_label(
|
|
207
|
+
members: list[MemberInfo],
|
|
208
|
+
api_key: str,
|
|
209
|
+
model: str = _DEFAULT_LLM_MODEL,
|
|
210
|
+
endpoint: str = _DEFAULT_LLM_ENDPOINT,
|
|
211
|
+
) -> str:
|
|
212
|
+
"""Call an OpenAI-compatible LLM API to generate a cluster label.
|
|
213
|
+
|
|
214
|
+
Uses stdlib urllib only — no external SDK. This is intentionally isolated
|
|
215
|
+
so tests can monkeypatch this function without touching urllib.
|
|
216
|
+
|
|
217
|
+
WHY: Isolated here (not inlined in label_cluster) so tests can stub it
|
|
218
|
+
via `patch('seam.analysis.cluster_naming._call_llm_for_label')`.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
members: List of MemberInfo dicts.
|
|
222
|
+
api_key: Bearer token for the API.
|
|
223
|
+
model: Model name string.
|
|
224
|
+
endpoint: Full URL to the chat completions endpoint.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
The label string from the LLM response (may be empty).
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
Any exception from urllib or JSON parsing — caller handles these.
|
|
231
|
+
"""
|
|
232
|
+
# Build a concise prompt from member names and files
|
|
233
|
+
symbol_list = ", ".join(m.get("name", "") for m in members[:20]) # cap at 20
|
|
234
|
+
prompt = (
|
|
235
|
+
f"Name this code cluster in 3-5 words. Members: {symbol_list}. "
|
|
236
|
+
"Respond with ONLY the cluster name, no explanation."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
payload = json.dumps({
|
|
240
|
+
"model": model,
|
|
241
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
242
|
+
"max_tokens": 20,
|
|
243
|
+
"temperature": 0, # deterministic response
|
|
244
|
+
}).encode("utf-8")
|
|
245
|
+
|
|
246
|
+
req = urllib.request.Request(
|
|
247
|
+
endpoint,
|
|
248
|
+
data=payload,
|
|
249
|
+
headers={
|
|
250
|
+
"Authorization": f"Bearer {api_key}",
|
|
251
|
+
"Content-Type": "application/json",
|
|
252
|
+
},
|
|
253
|
+
method="POST",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
with urllib.request.urlopen(req, timeout=_LLM_TIMEOUT_SECONDS) as response:
|
|
257
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
258
|
+
|
|
259
|
+
# Extract content from OpenAI-compatible response shape
|
|
260
|
+
return body["choices"][0]["message"]["content"]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Pure community detection module — Louvain greedy modularity maximization.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
detect_communities(nodes, edges) -> dict[str, int]
|
|
5
|
+
|
|
6
|
+
This is a DEEP PURE module: graph in → cluster map out.
|
|
7
|
+
No SQLite, no file I/O, no config, no side effects.
|
|
8
|
+
|
|
9
|
+
Design decisions:
|
|
10
|
+
- Pure-Python Louvain (greedy modularity). No external deps.
|
|
11
|
+
- Determinism is load-bearing: nodes are sorted before processing,
|
|
12
|
+
and tie-breaking is always deterministic (sort by community label).
|
|
13
|
+
- Singleton/disconnected nodes each get their own cluster ID.
|
|
14
|
+
- Self-loops are ignored (they don't affect modularity).
|
|
15
|
+
- Edges referencing unknown nodes are safely ignored.
|
|
16
|
+
- Empty graph → {}.
|
|
17
|
+
- Never raises on any input — returns sensible degraded output.
|
|
18
|
+
|
|
19
|
+
Algorithm (standard Louvain phase 1 — modularity maximization):
|
|
20
|
+
1. Assign each node to its own singleton community.
|
|
21
|
+
2. For each node (in sorted order), try moving it to each neighbor's
|
|
22
|
+
community. Accept the move with the highest positive modularity gain.
|
|
23
|
+
3. Repeat until no node moves in a full pass (convergence) or
|
|
24
|
+
_MAX_ITERATIONS is reached.
|
|
25
|
+
4. Renumber community IDs as stable integers (sorted by min member name).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Maximum passes over all nodes. Prevents infinite loops on pathological graphs.
|
|
33
|
+
_MAX_ITERATIONS = 100
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_communities(
|
|
37
|
+
nodes: list[str],
|
|
38
|
+
edges: list[tuple[str, str]],
|
|
39
|
+
) -> dict[str, int]:
|
|
40
|
+
"""Detect communities in an undirected graph using Louvain modularity optimization.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
nodes: List of node names (symbol names). Duplicates are deduplicated.
|
|
44
|
+
edges: List of (source, target) pairs. Directed edges are treated as
|
|
45
|
+
undirected (both directions considered). Edges to unknown nodes
|
|
46
|
+
are silently ignored.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Dict mapping each node name to an integer cluster ID.
|
|
50
|
+
Empty dict when nodes is empty.
|
|
51
|
+
Cluster IDs are stable integers starting at 0, ordered by the
|
|
52
|
+
lexicographically smallest member of each community (deterministic).
|
|
53
|
+
|
|
54
|
+
Never raises. On any internal error, logs a warning and returns
|
|
55
|
+
each node in its own singleton cluster.
|
|
56
|
+
"""
|
|
57
|
+
if not nodes:
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
return _detect_communities_impl(nodes, edges)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
logger.warning(
|
|
64
|
+
"clustering: detect_communities failed with %s — falling back to singletons",
|
|
65
|
+
exc,
|
|
66
|
+
)
|
|
67
|
+
# Deterministic fallback: each node its own cluster (sorted for stability)
|
|
68
|
+
sorted_unique = sorted(set(nodes))
|
|
69
|
+
return {n: i for i, n in enumerate(sorted_unique)}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _detect_communities_impl(
|
|
73
|
+
nodes: list[str],
|
|
74
|
+
edges: list[tuple[str, str]],
|
|
75
|
+
) -> dict[str, int]:
|
|
76
|
+
"""Inner implementation — may raise; outer function catches and degrades."""
|
|
77
|
+
# Deduplicate and sort nodes for deterministic processing order
|
|
78
|
+
unique_nodes = sorted(set(nodes))
|
|
79
|
+
|
|
80
|
+
if not unique_nodes:
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
# Build undirected adjacency sets — only between known nodes
|
|
84
|
+
node_set = set(unique_nodes)
|
|
85
|
+
adj: dict[str, set[str]] = {n: set() for n in unique_nodes}
|
|
86
|
+
|
|
87
|
+
for src, tgt in edges:
|
|
88
|
+
if src == tgt:
|
|
89
|
+
# Self-loops don't affect modularity — skip
|
|
90
|
+
continue
|
|
91
|
+
if src in node_set and tgt in node_set:
|
|
92
|
+
adj[src].add(tgt)
|
|
93
|
+
adj[tgt].add(src)
|
|
94
|
+
|
|
95
|
+
# m = total number of undirected edges (used in modularity formula)
|
|
96
|
+
# Count half-sum of all degrees (each undirected edge appears twice in degree sum)
|
|
97
|
+
degree: dict[str, int] = {n: len(adj[n]) for n in unique_nodes}
|
|
98
|
+
m = sum(degree.values()) // 2 # undirected edge count
|
|
99
|
+
|
|
100
|
+
# Each node starts in its own singleton community (labeled by the node name)
|
|
101
|
+
# Using node names as community labels ensures determinism: no integer IDs
|
|
102
|
+
# that depend on insertion order.
|
|
103
|
+
community: dict[str, str] = {n: n for n in unique_nodes}
|
|
104
|
+
|
|
105
|
+
# community_degree[comm]: sum of degrees of all nodes in community comm
|
|
106
|
+
community_degree: dict[str, int] = {n: degree[n] for n in unique_nodes}
|
|
107
|
+
|
|
108
|
+
if m == 0:
|
|
109
|
+
# No edges → every node is its own singleton; assign stable int IDs
|
|
110
|
+
return _assign_stable_ids(community, unique_nodes)
|
|
111
|
+
|
|
112
|
+
# Louvain phase 1: iterate until no node moves
|
|
113
|
+
for _iteration in range(_MAX_ITERATIONS):
|
|
114
|
+
improved = False
|
|
115
|
+
|
|
116
|
+
# Process nodes in deterministic sorted order
|
|
117
|
+
for node in unique_nodes:
|
|
118
|
+
current_comm = community[node]
|
|
119
|
+
node_deg = degree[node]
|
|
120
|
+
|
|
121
|
+
# Count edges from this node into each neighboring community
|
|
122
|
+
# k_in[comm] = number of edges from node to members of comm
|
|
123
|
+
k_in: dict[str, int] = {}
|
|
124
|
+
for neighbor in adj[node]:
|
|
125
|
+
nc = community[neighbor]
|
|
126
|
+
k_in[nc] = k_in.get(nc, 0) + 1
|
|
127
|
+
|
|
128
|
+
# Remove node from current community (to evaluate re-insertion)
|
|
129
|
+
community_degree[current_comm] -= node_deg
|
|
130
|
+
k_in_current = k_in.get(current_comm, 0)
|
|
131
|
+
|
|
132
|
+
# Find best community using Louvain modularity gain formula:
|
|
133
|
+
# ΔQ ∝ k_in_C - (Σ_tot * k_i) / (2m)
|
|
134
|
+
# We compare against the "remove" baseline (k_in=0, sigma_tot=0),
|
|
135
|
+
# so the "stay" option is evaluated fairly.
|
|
136
|
+
# WHY: Using 2*m as denominator normalization for the degree term.
|
|
137
|
+
best_comm = current_comm
|
|
138
|
+
# Gain of reinserting into the current community after removal
|
|
139
|
+
best_gain = _modularity_gain(k_in_current, community_degree[current_comm], node_deg, m)
|
|
140
|
+
|
|
141
|
+
# Evaluate all neighbor communities (sorted for deterministic tie-breaking)
|
|
142
|
+
for cand_comm in sorted(k_in.keys()):
|
|
143
|
+
if cand_comm == current_comm:
|
|
144
|
+
continue
|
|
145
|
+
gain = _modularity_gain(k_in[cand_comm], community_degree[cand_comm], node_deg, m)
|
|
146
|
+
# Accept strictly better gain; on equal gain, prefer smaller community label
|
|
147
|
+
# (lexicographic tie-break ensures determinism)
|
|
148
|
+
if gain > best_gain or (gain == best_gain and cand_comm < best_comm):
|
|
149
|
+
best_gain = gain
|
|
150
|
+
best_comm = cand_comm
|
|
151
|
+
|
|
152
|
+
# Reinsert node into best community (may be the same as current)
|
|
153
|
+
community[node] = best_comm
|
|
154
|
+
community_degree[best_comm] += node_deg
|
|
155
|
+
|
|
156
|
+
if best_comm != current_comm:
|
|
157
|
+
improved = True
|
|
158
|
+
|
|
159
|
+
if not improved:
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
return _assign_stable_ids(community, unique_nodes)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _modularity_gain(k_in: int, sigma_tot: int, k_i: int, m: int) -> float:
|
|
166
|
+
"""Compute the modularity gain of inserting a node into a community.
|
|
167
|
+
|
|
168
|
+
Formula: ΔQ = k_in/m - (sigma_tot * k_i) / (2 * m^2)
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
k_in: Number of edges from the node to existing community members.
|
|
172
|
+
sigma_tot: Total degree of the community (after removing the node).
|
|
173
|
+
k_i: Degree of the node being placed.
|
|
174
|
+
m: Total number of undirected edges in the graph.
|
|
175
|
+
|
|
176
|
+
WHY: This is the standard Louvain gain formula from Blondel et al. (2008).
|
|
177
|
+
We use floating point division to handle fractional gains correctly.
|
|
178
|
+
"""
|
|
179
|
+
if m == 0:
|
|
180
|
+
return 0.0
|
|
181
|
+
return float(k_in) / m - (sigma_tot * k_i) / (2.0 * m * m)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _assign_stable_ids(
|
|
185
|
+
community: dict[str, str],
|
|
186
|
+
sorted_nodes: list[str],
|
|
187
|
+
) -> dict[str, int]:
|
|
188
|
+
"""Assign stable integer IDs to communities.
|
|
189
|
+
|
|
190
|
+
WHY: Internal community labels (node name strings) are arbitrary identifiers
|
|
191
|
+
used during the algorithm. We convert them to stable integers by sorting
|
|
192
|
+
communities by their lexicographically smallest member — so the same logical
|
|
193
|
+
partition always gets the same integer IDs.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
community: Mapping of node -> community label (internal string).
|
|
197
|
+
sorted_nodes: All nodes in sorted order.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Dict of node -> integer cluster ID starting at 0.
|
|
201
|
+
"""
|
|
202
|
+
# Collect the minimum member name per community (for sorting)
|
|
203
|
+
comm_min_member: dict[str, str] = {}
|
|
204
|
+
for node in sorted_nodes:
|
|
205
|
+
comm_label = community[node]
|
|
206
|
+
if comm_label not in comm_min_member:
|
|
207
|
+
comm_min_member[comm_label] = node
|
|
208
|
+
else:
|
|
209
|
+
if node < comm_min_member[comm_label]:
|
|
210
|
+
comm_min_member[comm_label] = node
|
|
211
|
+
|
|
212
|
+
# Sort community labels by their minimum member → deterministic integer IDs
|
|
213
|
+
sorted_comm_labels = sorted(comm_min_member.keys(), key=lambda c: comm_min_member[c])
|
|
214
|
+
comm_to_int: dict[str, int] = {label: i for i, label in enumerate(sorted_comm_labels)}
|
|
215
|
+
|
|
216
|
+
return {node: comm_to_int[community[node]] for node in sorted_nodes}
|