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.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. 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}