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,425 @@
|
|
|
1
|
+
"""Clustering orchestration — bridge between pure analysis and persistence.
|
|
2
|
+
|
|
3
|
+
Reads the symbol graph from the DB, runs community detection, labels each
|
|
4
|
+
cluster, and writes the results in a single transaction.
|
|
5
|
+
|
|
6
|
+
This module sits in the indexer layer and is called by cli/main.py after the
|
|
7
|
+
full file-indexing loop completes (never per-file, always whole-graph).
|
|
8
|
+
|
|
9
|
+
Import hierarchy (enforced):
|
|
10
|
+
indexer/cluster_index → analysis.clustering + analysis.cluster_naming + config
|
|
11
|
+
analysis modules → pure, no DB writes
|
|
12
|
+
cli → this module (not the other way)
|
|
13
|
+
|
|
14
|
+
Design decisions:
|
|
15
|
+
- Reads all symbols + edges from the connection in one pass.
|
|
16
|
+
- Calls detect_communities (pure) then label_cluster per community.
|
|
17
|
+
- Writes in ONE transaction: DELETE clusters, INSERT clusters, UPDATE symbols.
|
|
18
|
+
- NEVER raises: returns -1 on error (signals failure to CLI); any error logs
|
|
19
|
+
a warning and leaves cluster_id NULL. Returns ≥0 on success.
|
|
20
|
+
- Watcher does NOT call this — only seam init does.
|
|
21
|
+
- Deterministic cluster DB IDs: communities are sorted by their smallest
|
|
22
|
+
member name, then assigned IDs 1..N in that order. Same graph → same IDs.
|
|
23
|
+
- SEAM_CLUSTER_MIN_SIZE is enforced: communities smaller than min_size are
|
|
24
|
+
dropped; their symbols get cluster_id=NULL (unclustered).
|
|
25
|
+
- clusters.size is set from the actual DB row count after write-back, not
|
|
26
|
+
from the community node count (handles multi-row homonyms correctly).
|
|
27
|
+
- Write-back is batched: one executemany per cluster (not one UPDATE per node).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import sqlite3
|
|
32
|
+
|
|
33
|
+
import seam.config as config
|
|
34
|
+
from seam.analysis.cluster_naming import label_cluster
|
|
35
|
+
from seam.analysis.clustering import detect_communities
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _should_filter_edges(symbol_count: int) -> bool:
|
|
41
|
+
"""Decide whether to apply the P2 confidence filter for this graph size.
|
|
42
|
+
|
|
43
|
+
The filter trims noisy AMBIGUOUS/inferred-call edges before community
|
|
44
|
+
detection. It is OPT-IN by graph size to preserve small-repo behaviour:
|
|
45
|
+
- "off" → never filter (byte-identical to pre-P2).
|
|
46
|
+
- numeric threshold → filter only when symbol_count > threshold.
|
|
47
|
+
A threshold of "0" forces the filter on for any non-empty graph
|
|
48
|
+
(used by tests and aggressive setups).
|
|
49
|
+
|
|
50
|
+
WHY size-gated: on a small/sparse graph the AMBIGUOUS edges are often the
|
|
51
|
+
only connective tissue — dropping them would shatter clusters. On a large
|
|
52
|
+
graph they are mostly homonym noise that wrongly merges unrelated modules.
|
|
53
|
+
"""
|
|
54
|
+
raw = config.SEAM_CLUSTER_CONFIDENCE_FILTER
|
|
55
|
+
if raw == "off":
|
|
56
|
+
return False
|
|
57
|
+
try:
|
|
58
|
+
threshold = int(raw)
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
# Misconfigured value → safest is to leave behaviour unchanged (no filter).
|
|
61
|
+
logger.warning(
|
|
62
|
+
"cluster_index: invalid SEAM_CLUSTER_CONFIDENCE_FILTER=%r — disabling filter",
|
|
63
|
+
raw,
|
|
64
|
+
)
|
|
65
|
+
return False
|
|
66
|
+
return symbol_count > threshold
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Max nodes sampled per cluster when computing cohesion (perf bound on hub clusters).
|
|
70
|
+
_COHESION_SAMPLE_CAP = 50
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _compute_cohesion(
|
|
74
|
+
member_names: list[str],
|
|
75
|
+
adjacency: dict[str, set[str]],
|
|
76
|
+
name_to_cluster: dict[str, int],
|
|
77
|
+
cluster_id: int,
|
|
78
|
+
) -> float:
|
|
79
|
+
"""Internal-edge ratio for one cluster (P2 cohesion score).
|
|
80
|
+
|
|
81
|
+
cohesion = (edges whose BOTH endpoints are in this cluster)
|
|
82
|
+
/ (all edges touching a sampled member)
|
|
83
|
+
|
|
84
|
+
Range [0, 1]. 1.0 = perfectly self-contained (every edge stays inside the
|
|
85
|
+
cluster); near 0 = the cluster's members mostly link OUT to other clusters
|
|
86
|
+
(a weak/noisy community). Used as a small additive search-ranking bonus.
|
|
87
|
+
|
|
88
|
+
Perf: samples at most _COHESION_SAMPLE_CAP members so a hot hub cluster does
|
|
89
|
+
not make this O(huge). Sampling is deterministic (sorted, first N) so repeated
|
|
90
|
+
runs over the same graph produce the same value.
|
|
91
|
+
|
|
92
|
+
Returns 0.0 when the sampled members touch no edges (avoids div-by-zero).
|
|
93
|
+
"""
|
|
94
|
+
sample = sorted(member_names)[:_COHESION_SAMPLE_CAP]
|
|
95
|
+
internal = 0
|
|
96
|
+
total = 0
|
|
97
|
+
for name in sample:
|
|
98
|
+
for neighbor in adjacency.get(name, ()): # undirected neighbours
|
|
99
|
+
total += 1
|
|
100
|
+
if name_to_cluster.get(neighbor) == cluster_id:
|
|
101
|
+
internal += 1
|
|
102
|
+
if total == 0:
|
|
103
|
+
return 0.0
|
|
104
|
+
return internal / total
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def index_clusters(
|
|
108
|
+
conn: sqlite3.Connection,
|
|
109
|
+
naming_mode: str = "deterministic",
|
|
110
|
+
llm_api_key: str | None = None,
|
|
111
|
+
llm_model: str | None = None,
|
|
112
|
+
min_size: int = 1,
|
|
113
|
+
) -> int:
|
|
114
|
+
"""Detect and persist graph communities for all indexed symbols.
|
|
115
|
+
|
|
116
|
+
Reads the full symbol+edge graph, partitions into communities, assigns
|
|
117
|
+
deterministic labels, and stores everything in the clusters table and
|
|
118
|
+
symbols.cluster_id column.
|
|
119
|
+
|
|
120
|
+
Called by `seam init` after the indexing loop, NEVER by the watcher.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
conn: Open SQLite connection (must have write access).
|
|
124
|
+
naming_mode: "deterministic" (default) or "llm".
|
|
125
|
+
llm_api_key: API key for LLM naming (required if naming_mode="llm").
|
|
126
|
+
llm_model: LLM model name (optional; uses default if None).
|
|
127
|
+
min_size: Minimum community size to persist as a cluster.
|
|
128
|
+
Communities smaller than this get cluster_id=NULL.
|
|
129
|
+
Default 1 (all communities; effectively no filter).
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Number of clusters created (≥0). Returns -1 on error (never raises).
|
|
133
|
+
|
|
134
|
+
WHY: Returning -1 (not 0) on error lets the CLI distinguish "zero clusters
|
|
135
|
+
because the graph has no connected edges" from "clustering failed."
|
|
136
|
+
Never raises so `seam init` can't be aborted by a clustering bug.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
return _index_clusters_impl(conn, naming_mode, llm_api_key, llm_model, min_size)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logger.warning(
|
|
142
|
+
"cluster_index: failed to compute clusters (%s: %s) — "
|
|
143
|
+
"all symbols will have cluster_id=NULL; run 'seam init' again to retry",
|
|
144
|
+
type(exc).__name__,
|
|
145
|
+
exc,
|
|
146
|
+
)
|
|
147
|
+
return -1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _index_clusters_impl(
|
|
151
|
+
conn: sqlite3.Connection,
|
|
152
|
+
naming_mode: str,
|
|
153
|
+
llm_api_key: str | None,
|
|
154
|
+
llm_model: str | None,
|
|
155
|
+
min_size: int,
|
|
156
|
+
) -> int:
|
|
157
|
+
"""Inner implementation. May raise — outer function is the guard.
|
|
158
|
+
|
|
159
|
+
WHY separate function: the outer wrapper catches ALL exceptions and converts
|
|
160
|
+
them to -1. Having a clean inner function makes the logic easier to reason
|
|
161
|
+
about and test (tests can call the inner function directly if needed).
|
|
162
|
+
"""
|
|
163
|
+
# ── Step 0: Always clear stale cluster state FIRST ────────────────────────
|
|
164
|
+
# This must happen before ANY early-return so a previous successful run
|
|
165
|
+
# is never left as a phantom when the current run finds nothing to cluster.
|
|
166
|
+
# WHY: If we returned early without clearing, old cluster rows would persist
|
|
167
|
+
# even though the symbol table is now empty — "ghost" clusters in list output.
|
|
168
|
+
with conn:
|
|
169
|
+
conn.execute("DELETE FROM clusters")
|
|
170
|
+
conn.execute("UPDATE symbols SET cluster_id = NULL")
|
|
171
|
+
|
|
172
|
+
# ── Step 1: Read all symbols ──────────────────────────────────────────────
|
|
173
|
+
# We need: name (for the graph), file (for labeling), degree (for label heuristic)
|
|
174
|
+
symbol_rows = conn.execute(
|
|
175
|
+
"""
|
|
176
|
+
SELECT s.name, f.path AS file
|
|
177
|
+
FROM symbols s
|
|
178
|
+
JOIN files f ON f.id = s.file_id
|
|
179
|
+
ORDER BY s.name
|
|
180
|
+
"""
|
|
181
|
+
).fetchall()
|
|
182
|
+
|
|
183
|
+
if not symbol_rows:
|
|
184
|
+
logger.debug("cluster_index: no symbols in index, skipping clustering")
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
# Map: symbol name → file path (for labeling)
|
|
188
|
+
# WHY: Multiple symbols can share a name (ambiguous); use first file path seen.
|
|
189
|
+
name_to_file: dict[str, str] = {}
|
|
190
|
+
for row in symbol_rows:
|
|
191
|
+
name = row["name"]
|
|
192
|
+
if name not in name_to_file:
|
|
193
|
+
name_to_file[name] = row["file"]
|
|
194
|
+
|
|
195
|
+
all_nodes = list(name_to_file.keys())
|
|
196
|
+
|
|
197
|
+
# ── Step 2: Read all edges as undirected pairs ────────────────────────────
|
|
198
|
+
# Read confidence + kind too so the P2 confidence filter (Step 2b) can drop
|
|
199
|
+
# noisy edges before community detection on large graphs. The unfiltered set
|
|
200
|
+
# is always kept for the cohesion computation (Step 8b) so cohesion reflects
|
|
201
|
+
# the REAL connectivity, not the filtered view.
|
|
202
|
+
#
|
|
203
|
+
# Exclude SYNTHESIZED edges (synthesized_by IS NOT NULL): they are a heuristic
|
|
204
|
+
# over-approximation produced by the post-pass that runs AFTER clustering, and
|
|
205
|
+
# they survive across runs (stored under the ':synthesis:' file row). Feeding
|
|
206
|
+
# them into Louvain would re-cluster the codebase against last run's guessed
|
|
207
|
+
# dispatch edges — merging unrelated modules on every re-cluster. The synthesis
|
|
208
|
+
# engine already excludes them from its own input for the same anti-feedback
|
|
209
|
+
# reason; clustering must too. Guarded for pre-v12 indexes lacking the column.
|
|
210
|
+
_edge_cols = {r["name"] for r in conn.execute("PRAGMA table_info(edges)").fetchall()}
|
|
211
|
+
_synth_filter = "WHERE synthesized_by IS NULL" if "synthesized_by" in _edge_cols else ""
|
|
212
|
+
edge_rows = conn.execute(
|
|
213
|
+
f"SELECT DISTINCT source_name, target_name, kind, confidence FROM edges {_synth_filter}"
|
|
214
|
+
).fetchall()
|
|
215
|
+
all_edges = [(row["source_name"], row["target_name"]) for row in edge_rows]
|
|
216
|
+
|
|
217
|
+
# ── Step 2b: P2 confidence filter — large graphs only ─────────────────────
|
|
218
|
+
# Pass only high-trust edges to Louvain when the graph is big enough that
|
|
219
|
+
# AMBIGUOUS homonym noise would wrongly merge unrelated modules. High-trust =
|
|
220
|
+
# EXTRACTED (resolved to one symbol) OR import-kind INFERRED (real dependency,
|
|
221
|
+
# just not name-unique). Everything else (AMBIGUOUS, inferred CALL edges) is
|
|
222
|
+
# dropped. Small repos pass the full set (see _should_filter_edges).
|
|
223
|
+
if _should_filter_edges(len(all_nodes)):
|
|
224
|
+
clustering_edges = [
|
|
225
|
+
(row["source_name"], row["target_name"])
|
|
226
|
+
for row in edge_rows
|
|
227
|
+
if row["confidence"] == "EXTRACTED"
|
|
228
|
+
or (row["confidence"] == "INFERRED" and row["kind"] == "import")
|
|
229
|
+
]
|
|
230
|
+
logger.debug(
|
|
231
|
+
"cluster_index: confidence filter active — %d/%d edges passed to Louvain",
|
|
232
|
+
len(clustering_edges),
|
|
233
|
+
len(all_edges),
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
clustering_edges = all_edges
|
|
237
|
+
|
|
238
|
+
# ── Step 3: Detect communities (pure function) ────────────────────────────
|
|
239
|
+
community_map: dict[str, int] = detect_communities(all_nodes, clustering_edges)
|
|
240
|
+
|
|
241
|
+
if not community_map:
|
|
242
|
+
logger.debug("cluster_index: detect_communities returned empty map")
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
# ── Step 4: Group nodes by cluster ID and apply min_size filter ───────────
|
|
246
|
+
# cluster_id (int from detect_communities) → list of node names
|
|
247
|
+
raw_cluster_members: dict[int, list[str]] = {}
|
|
248
|
+
for node, cid in community_map.items():
|
|
249
|
+
raw_cluster_members.setdefault(cid, []).append(node)
|
|
250
|
+
|
|
251
|
+
# Keep only communities with at least min_size distinct graph nodes.
|
|
252
|
+
# WHY: min_size=2 (the default in config) kills pure singletons so `seam clusters`
|
|
253
|
+
# shows functional areas, not every unconnected symbol in its own row.
|
|
254
|
+
# Nodes in dropped communities get cluster_id=NULL (already cleared in Step 0).
|
|
255
|
+
cluster_members: dict[int, list[str]] = {
|
|
256
|
+
cid: members
|
|
257
|
+
for cid, members in raw_cluster_members.items()
|
|
258
|
+
if len(members) >= min_size
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if not cluster_members:
|
|
262
|
+
logger.debug(
|
|
263
|
+
"cluster_index: all communities are below min_size=%d — no clusters persisted",
|
|
264
|
+
min_size,
|
|
265
|
+
)
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
# ── Step 5: Compute degree per node (for labeling heuristic) ─────────────
|
|
269
|
+
# Degree = number of edges touching this symbol (undirected)
|
|
270
|
+
node_degree: dict[str, int] = {n: 0 for n in all_nodes}
|
|
271
|
+
for src, tgt in all_edges:
|
|
272
|
+
if src in node_degree:
|
|
273
|
+
node_degree[src] += 1
|
|
274
|
+
if tgt in node_degree:
|
|
275
|
+
node_degree[tgt] += 1
|
|
276
|
+
|
|
277
|
+
# ── Step 6: Order communities deterministically for stable DB IDs ─────────
|
|
278
|
+
# WHY (issue #1): AUTOINCREMENT IDs climb on every DELETE+INSERT, so IDs
|
|
279
|
+
# drift across re-runs even when the graph is unchanged. Instead we INSERT
|
|
280
|
+
# with explicit IDs 1..N. The ordering key is the lexicographically smallest
|
|
281
|
+
# member name of each community — the same key used internally by detect_communities
|
|
282
|
+
# to assign stable algorithm IDs, so this ordering is doubly stable.
|
|
283
|
+
ordered_algo_ids: list[int] = sorted(
|
|
284
|
+
cluster_members.keys(),
|
|
285
|
+
key=lambda cid: min(cluster_members[cid]), # min member name → deterministic
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# ── Step 7: Compute labels for each persisted community ───────────────────
|
|
289
|
+
cluster_labels: dict[int, tuple[str, str]] = {}
|
|
290
|
+
for algo_cid in ordered_algo_ids:
|
|
291
|
+
members_info = [
|
|
292
|
+
{
|
|
293
|
+
"name": name,
|
|
294
|
+
"file": name_to_file.get(name, ""),
|
|
295
|
+
"degree": node_degree.get(name, 0),
|
|
296
|
+
}
|
|
297
|
+
for name in sorted(cluster_members[algo_cid]) # sorted for determinism
|
|
298
|
+
]
|
|
299
|
+
label, naming_source = label_cluster(
|
|
300
|
+
members_info,
|
|
301
|
+
naming_mode=naming_mode,
|
|
302
|
+
api_key=llm_api_key,
|
|
303
|
+
model=llm_model,
|
|
304
|
+
)
|
|
305
|
+
cluster_labels[algo_cid] = (label, naming_source)
|
|
306
|
+
|
|
307
|
+
# ── Step 7b: Compute cohesion per cluster (P2 internal-edge ratio) ─────────
|
|
308
|
+
# cohesion(cluster) = internal edges / all edges touching sampled members.
|
|
309
|
+
# Built from the FULL (unfiltered) edge graph so the score reflects real
|
|
310
|
+
# connectivity, not the filtered Louvain view. Stable DB id = position in
|
|
311
|
+
# ordered_algo_ids (1-based), matching the INSERT loop below.
|
|
312
|
+
adjacency: dict[str, set[str]] = {n: set() for n in all_nodes}
|
|
313
|
+
for src, tgt in all_edges:
|
|
314
|
+
if src in adjacency and tgt in adjacency and src != tgt:
|
|
315
|
+
adjacency[src].add(tgt)
|
|
316
|
+
adjacency[tgt].add(src)
|
|
317
|
+
# name → stable DB cluster id (only for persisted members).
|
|
318
|
+
name_to_cluster: dict[str, int] = {}
|
|
319
|
+
for db_id, algo_cid in enumerate(ordered_algo_ids, start=1):
|
|
320
|
+
for name in cluster_members[algo_cid]:
|
|
321
|
+
name_to_cluster[name] = db_id
|
|
322
|
+
cohesion_by_db_id: dict[int, float] = {}
|
|
323
|
+
for db_id, algo_cid in enumerate(ordered_algo_ids, start=1):
|
|
324
|
+
cohesion_by_db_id[db_id] = _compute_cohesion(
|
|
325
|
+
cluster_members[algo_cid], adjacency, name_to_cluster, db_id
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# ── Step 8: Persist in one transaction with explicit stable IDs ───────────
|
|
329
|
+
# WHY single transaction: if any INSERT/UPDATE fails the rollback leaves
|
|
330
|
+
# the DB in a consistent state (all cluster_id=NULL from Step 0 clear).
|
|
331
|
+
with conn:
|
|
332
|
+
# Map: algorithm cluster ID → stable DB cluster ID (1-based, ordered by min member)
|
|
333
|
+
algo_to_db_id: dict[int, int] = {}
|
|
334
|
+
for db_id, algo_cid in enumerate(ordered_algo_ids, start=1):
|
|
335
|
+
label, naming_source = cluster_labels[algo_cid]
|
|
336
|
+
# Insert with explicit ID so IDs are identical across re-runs.
|
|
337
|
+
# Size is a placeholder (0) — updated to actual count after write-back (issue #3).
|
|
338
|
+
# cohesion (P2) is stored at insert time from the precomputed map.
|
|
339
|
+
conn.execute(
|
|
340
|
+
"INSERT INTO clusters (id, label, size, naming_source, cohesion)"
|
|
341
|
+
" VALUES (?, ?, 0, ?, ?)",
|
|
342
|
+
(db_id, label, naming_source, cohesion_by_db_id[db_id]),
|
|
343
|
+
)
|
|
344
|
+
algo_to_db_id[algo_cid] = db_id
|
|
345
|
+
|
|
346
|
+
# Batch write-back: one executemany per cluster instead of one UPDATE per node.
|
|
347
|
+
# WHY (issue #9): a 100k-symbol repo would run 100k individual UPDATEs without this.
|
|
348
|
+
# Each cluster's members share the same db_id, so we group and write at once.
|
|
349
|
+
for algo_cid, db_id in algo_to_db_id.items():
|
|
350
|
+
member_names = cluster_members[algo_cid]
|
|
351
|
+
# executemany issues one call per row but SQLite batches them in one round-trip
|
|
352
|
+
conn.executemany(
|
|
353
|
+
"UPDATE symbols SET cluster_id = ? WHERE name = ?",
|
|
354
|
+
[(db_id, name) for name in member_names],
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Fix cluster.size to actual DB member count (issue #3).
|
|
358
|
+
# WHY: UPDATE WHERE name=? stamps EVERY row with that name, so if the same
|
|
359
|
+
# symbol name appears in two files, both rows get the cluster_id. The node
|
|
360
|
+
# count from community detection counts unique NAMES, not rows. We must
|
|
361
|
+
# re-count from the DB to get the true member row count per cluster.
|
|
362
|
+
for db_id in algo_to_db_id.values():
|
|
363
|
+
actual_size = conn.execute(
|
|
364
|
+
"SELECT COUNT(*) FROM symbols WHERE cluster_id = ?", (db_id,)
|
|
365
|
+
).fetchone()[0]
|
|
366
|
+
conn.execute(
|
|
367
|
+
"UPDATE clusters SET size = ? WHERE id = ?", (actual_size, db_id)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
n_clusters = len(cluster_members)
|
|
371
|
+
|
|
372
|
+
# ── Step 9: Surface LLM-naming fallback stats (issue #8) ─────────────────
|
|
373
|
+
# When LLM naming was requested, report how many clusters fell back to deterministic.
|
|
374
|
+
# This is a read-only post-write query; any error is silently ignored.
|
|
375
|
+
llm_summary: str | None = None
|
|
376
|
+
if naming_mode == "llm":
|
|
377
|
+
try:
|
|
378
|
+
source_rows = conn.execute(
|
|
379
|
+
"SELECT naming_source, COUNT(*) AS n FROM clusters GROUP BY naming_source"
|
|
380
|
+
).fetchall()
|
|
381
|
+
source_counts = {r["naming_source"]: r["n"] for r in source_rows}
|
|
382
|
+
llm_count = source_counts.get("llm", 0)
|
|
383
|
+
det_count = source_counts.get("deterministic", 0)
|
|
384
|
+
fallback = det_count
|
|
385
|
+
if fallback > 0:
|
|
386
|
+
llm_summary = f"{llm_count}/{n_clusters} llm-named, {fallback} fell back to deterministic"
|
|
387
|
+
else:
|
|
388
|
+
llm_summary = f"{llm_count}/{n_clusters} llm-named"
|
|
389
|
+
except Exception:
|
|
390
|
+
pass # non-critical summary; don't let this break the return value
|
|
391
|
+
|
|
392
|
+
logger.info(
|
|
393
|
+
"cluster_index: %d cluster(s) computed (%s naming)%s",
|
|
394
|
+
n_clusters,
|
|
395
|
+
naming_mode,
|
|
396
|
+
f" [{llm_summary}]" if llm_summary else "",
|
|
397
|
+
)
|
|
398
|
+
return n_clusters
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def get_llm_naming_summary(conn: sqlite3.Connection) -> str | None:
|
|
402
|
+
"""Return a human-readable LLM naming summary string, or None if not applicable.
|
|
403
|
+
|
|
404
|
+
Reads the clusters table and returns a string like
|
|
405
|
+
"naming: llm requested, 3/10 fell back to deterministic" for use by the CLI.
|
|
406
|
+
|
|
407
|
+
Returns None when the clusters table is empty or inaccessible.
|
|
408
|
+
WHY: Exposed as a separate callable so cli/main.py can print the summary after
|
|
409
|
+
index_clusters() without duplicating the query logic.
|
|
410
|
+
"""
|
|
411
|
+
try:
|
|
412
|
+
rows = conn.execute(
|
|
413
|
+
"SELECT naming_source, COUNT(*) AS n FROM clusters GROUP BY naming_source"
|
|
414
|
+
).fetchall()
|
|
415
|
+
if not rows:
|
|
416
|
+
return None
|
|
417
|
+
source_counts = {r["naming_source"]: r["n"] for r in rows}
|
|
418
|
+
total = sum(source_counts.values())
|
|
419
|
+
llm_n = source_counts.get("llm", 0)
|
|
420
|
+
det_n = source_counts.get("deterministic", 0)
|
|
421
|
+
if det_n > 0:
|
|
422
|
+
return f"naming: llm requested, {det_n}/{total} fell back to deterministic"
|
|
423
|
+
return f"naming: llm ({llm_n}/{total} named by LLM)"
|
|
424
|
+
except Exception:
|
|
425
|
+
return None
|