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
seam/query/engine.py
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
"""Query engine — read path for all MCP tool queries.
|
|
2
|
+
|
|
3
|
+
All functions take an open sqlite3.Connection. No connection management here.
|
|
4
|
+
Returns typed dicts matching the MCP tool output spec in docs/api-contracts/mcp-tools.yaml.
|
|
5
|
+
|
|
6
|
+
context(): Tier A Slice 2 — resolves bare names to all qualified definitions and merges
|
|
7
|
+
callers/callees. See seam/query/names.py for the qualified<->bare bridging details.
|
|
8
|
+
search() / query(): FTS5 BM25 + OR-join + rescore + LIKE/fuzzy fallback (Phase 3).
|
|
9
|
+
Phase 3 (Slice 1): OR-join expression via fts.build_match_query() prevents one non-matching
|
|
10
|
+
word from zeroing the result; fts.rescore() applies name/path/test/cluster signals.
|
|
11
|
+
Phase 3 (Slice 2): LIKE→fuzzy two-tier fallback (FTS zero → LIKE %term% → DL fuzzy).
|
|
12
|
+
Hybrid semantic (T6): opt-in RRF merge when SEAM_SEMANTIC=on and embeddings present.
|
|
13
|
+
RRF (Reciprocal Rank Fusion) merges BM25 FTS id-list with cosine semantic id-list.
|
|
14
|
+
FTS snippets are preserved for FTS hits; semantic-only hits get snippet="".
|
|
15
|
+
The hybrid path is skipped transparently if SEAM_SEMANTIC=off or embeddings missing.
|
|
16
|
+
|
|
17
|
+
Context helpers (_collect_edges_for_names, _build_merged_context_result, _build_context_result)
|
|
18
|
+
live in seam/query/context.py to keep engine.py under the 1000-line limit.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import sqlite3
|
|
24
|
+
from typing import TypedDict, cast
|
|
25
|
+
|
|
26
|
+
import seam.config as config
|
|
27
|
+
from seam.config import SEAM_FUZZY_MAX_CANDIDATES, SEAM_FUZZY_MAX_DIST
|
|
28
|
+
from seam.query import fts
|
|
29
|
+
from seam.query.context import build_context_result as _build_context_result_fn
|
|
30
|
+
from seam.query.context import build_merged_context_result as _build_merged_context_result_fn
|
|
31
|
+
from seam.query.context import collect_edges_for_names as _collect_edges_for_names
|
|
32
|
+
from seam.query.fts import extract_terms as _extract_terms
|
|
33
|
+
from seam.query.names import edge_match_names as _edge_match_names
|
|
34
|
+
from seam.query.names import resolve_query_to_defs as _resolve_query_to_defs
|
|
35
|
+
from seam.query.semantic import rrf_merge, semantic_candidates
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Module-level flag: emit the "SEAM_SEMANTIC=on but no embeddings" warning at most once
|
|
40
|
+
# per process. Without this, every search/query call would spam the same warning.
|
|
41
|
+
_hybrid_warned: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class QueryResult(TypedDict):
|
|
45
|
+
symbol: str
|
|
46
|
+
file: str
|
|
47
|
+
line: int
|
|
48
|
+
score: float
|
|
49
|
+
callers_count: int
|
|
50
|
+
callees_count: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ContextResult(TypedDict):
|
|
54
|
+
symbol: str
|
|
55
|
+
file: str
|
|
56
|
+
line: int
|
|
57
|
+
end_line: int
|
|
58
|
+
kind: str
|
|
59
|
+
docstring: str | None
|
|
60
|
+
callers: list[str]
|
|
61
|
+
callees: list[str]
|
|
62
|
+
ambiguous: bool # True when multiple symbols share this name in the index (Phase 1)
|
|
63
|
+
cluster_id: int | None # Phase 2: cluster this symbol belongs to (None if not clustered)
|
|
64
|
+
cluster_label: str | None # Phase 2: human-readable cluster label
|
|
65
|
+
cluster_peers: list[str] # Phase 2: other symbols in the same cluster (may be empty)
|
|
66
|
+
# Phase 4 enrichment fields. All None for pre-v5 rows (no migration yet) or
|
|
67
|
+
# unsupported languages — callers should treat None as "unknown", not as absent.
|
|
68
|
+
signature: str | None
|
|
69
|
+
decorators: list[str] # [] when none extracted or for pre-v5 rows
|
|
70
|
+
is_exported: bool | None
|
|
71
|
+
visibility: str | None
|
|
72
|
+
qualified_name: str | None
|
|
73
|
+
# A3: field-access split. For kind='field' seeds, these list the functions that
|
|
74
|
+
# read/write this field via reads/writes edges. For class seeds, aggregated across
|
|
75
|
+
# all fields. For function/method seeds, both are [] (no reads/writes edges target
|
|
76
|
+
# a function; the field only has access sites from methods).
|
|
77
|
+
# Always present ([] not absent) so agents can check without key-existence guards.
|
|
78
|
+
field_readers: list[str]
|
|
79
|
+
field_writers: list[str]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SearchResult(TypedDict):
|
|
83
|
+
symbol: str
|
|
84
|
+
file: str
|
|
85
|
+
line: int
|
|
86
|
+
snippet: str
|
|
87
|
+
score: float
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Shared Phase 4 enrichment decoder ────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def decode_enrichment_fields(row: sqlite3.Row) -> tuple[list[str], bool | None]:
|
|
94
|
+
"""Decode the Phase 4 SQLite enrichment columns from a DB row.
|
|
95
|
+
|
|
96
|
+
Returns (decorators, is_exported) ready for use in ContextResult or NeighborRef.
|
|
97
|
+
|
|
98
|
+
WHY extracted: context() and pack._enrich_neighbors both need to decode the
|
|
99
|
+
same two SQLite-encoded fields. Keeping the logic in one place ensures they
|
|
100
|
+
always agree on null-contract semantics and avoids drift over time.
|
|
101
|
+
|
|
102
|
+
Rules:
|
|
103
|
+
decorators: NULL → [] (pre-v5 row or nothing extracted)
|
|
104
|
+
JSON TEXT → decoded list (corrupted JSON → [] gracefully)
|
|
105
|
+
is_exported: NULL → None (pre-v5 row or extraction unavailable)
|
|
106
|
+
0 → False
|
|
107
|
+
1 → True
|
|
108
|
+
"""
|
|
109
|
+
raw_dec = row["decorators"]
|
|
110
|
+
raw_exp = row["is_exported"]
|
|
111
|
+
|
|
112
|
+
# Decode decorators: stored as JSON TEXT; pre-v5 rows have NULL.
|
|
113
|
+
if raw_dec is None:
|
|
114
|
+
decorators: list[str] = []
|
|
115
|
+
else:
|
|
116
|
+
try:
|
|
117
|
+
decorators = json.loads(raw_dec)
|
|
118
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
119
|
+
# Corrupted JSON — degrade gracefully, never crash the read path.
|
|
120
|
+
decorators = []
|
|
121
|
+
|
|
122
|
+
# Decode is_exported: SQLite has no native bool; stored as 0/1/NULL.
|
|
123
|
+
if raw_exp is None:
|
|
124
|
+
is_exported: bool | None = None
|
|
125
|
+
else:
|
|
126
|
+
is_exported = bool(raw_exp)
|
|
127
|
+
|
|
128
|
+
return decorators, is_exported
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── Damerau-Levenshtein edit distance (pure-Python, bounded) ─────────────────
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _bounded_edit_distance(a: str, b: str, max_dist: int) -> int:
|
|
135
|
+
"""Compute the Damerau-Levenshtein edit distance between a and b.
|
|
136
|
+
|
|
137
|
+
Returns the true distance, or max_dist+1 if it exceeds max_dist.
|
|
138
|
+
The early-exit bound keeps the inner loop cost manageable when scanning
|
|
139
|
+
many candidates.
|
|
140
|
+
|
|
141
|
+
WHY Damerau-Levenshtein (not plain Levenshtein):
|
|
142
|
+
Transpositions (e.g. 'authenitcate' → 'authenticate') are the most
|
|
143
|
+
common real-world typo pattern. DL handles them as cost=1 rather than
|
|
144
|
+
cost=2, giving better recall for common typos.
|
|
145
|
+
"""
|
|
146
|
+
la, lb = len(a), len(b)
|
|
147
|
+
# Early bound: if lengths differ by more than max_dist, impossible to match
|
|
148
|
+
if abs(la - lb) > max_dist:
|
|
149
|
+
return max_dist + 1
|
|
150
|
+
|
|
151
|
+
# DP matrix: (la+1) x (lb+1)
|
|
152
|
+
# Row represents current character of a; column represents current char of b.
|
|
153
|
+
prev_prev = list(range(lb + 1))
|
|
154
|
+
prev = [0] * (lb + 1)
|
|
155
|
+
curr = [0] * (lb + 1)
|
|
156
|
+
|
|
157
|
+
for i in range(1, la + 1):
|
|
158
|
+
curr[0] = i
|
|
159
|
+
row_min = i
|
|
160
|
+
for j in range(1, lb + 1):
|
|
161
|
+
cost = 0 if a[i - 1] == b[j - 1] else 1
|
|
162
|
+
curr[j] = min(
|
|
163
|
+
prev[j] + 1, # deletion
|
|
164
|
+
curr[j - 1] + 1, # insertion
|
|
165
|
+
prev[j - 1] + cost, # substitution
|
|
166
|
+
)
|
|
167
|
+
# Transposition: swap adjacent chars
|
|
168
|
+
if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
|
|
169
|
+
curr[j] = min(curr[j], prev_prev[j - 2] + cost)
|
|
170
|
+
row_min = min(row_min, curr[j])
|
|
171
|
+
|
|
172
|
+
# Early-exit: if the entire row is above max_dist, no path can succeed
|
|
173
|
+
if row_min > max_dist:
|
|
174
|
+
return max_dist + 1
|
|
175
|
+
|
|
176
|
+
prev_prev, prev, curr = prev, curr, [0] * (lb + 1)
|
|
177
|
+
|
|
178
|
+
return prev[lb]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── LIKE→fuzzy fallback ───────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _escape_like(term: str) -> str:
|
|
185
|
+
"""Escape LIKE metacharacters in a term so they are treated as literals.
|
|
186
|
+
|
|
187
|
+
SQLite LIKE has three special characters: % (any sequence), _ (any single char),
|
|
188
|
+
and the escape char itself (here: backslash). Escaping prevents a query for
|
|
189
|
+
'get_user' from matching 'getXuser' due to the _ wildcard.
|
|
190
|
+
|
|
191
|
+
Must be paired with ESCAPE '\\' in the SQL clause.
|
|
192
|
+
"""
|
|
193
|
+
# Order matters: escape backslash first so we do not double-escape
|
|
194
|
+
term = term.replace("\\", "\\\\")
|
|
195
|
+
term = term.replace("%", "\\%")
|
|
196
|
+
term = term.replace("_", "\\_")
|
|
197
|
+
return term
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _like_fallback(
|
|
201
|
+
conn: sqlite3.Connection,
|
|
202
|
+
term: str,
|
|
203
|
+
limit: int,
|
|
204
|
+
) -> list[dict]:
|
|
205
|
+
"""LIKE %term% substring query — second tier fallback after FTS returns zero rows.
|
|
206
|
+
|
|
207
|
+
Returns rows in the same dict shape as the FTS query (symbol, file, line,
|
|
208
|
+
snippet="", score=0.0, cluster_id) so they can pass through rescore().
|
|
209
|
+
|
|
210
|
+
WHY escape: SQLite LIKE treats '_' as a single-char wildcard and '%' as a
|
|
211
|
+
multi-char wildcard. Without escaping, a search for 'get_user' matches
|
|
212
|
+
'getXuser', 'get1user', etc. — over-broadening the fallback results.
|
|
213
|
+
"""
|
|
214
|
+
escaped = _escape_like(term)
|
|
215
|
+
# Phase 4: SELECT s.signature so rescore() Signal-6 can fire for LIKE fallback rows.
|
|
216
|
+
sql = """
|
|
217
|
+
SELECT
|
|
218
|
+
s.name AS symbol,
|
|
219
|
+
f.path AS file,
|
|
220
|
+
s.start_line AS line,
|
|
221
|
+
s.cluster_id AS cluster_id,
|
|
222
|
+
s.signature AS signature
|
|
223
|
+
FROM symbols s
|
|
224
|
+
JOIN files f ON f.id = s.file_id
|
|
225
|
+
WHERE s.name LIKE ? ESCAPE '\\'
|
|
226
|
+
LIMIT ?
|
|
227
|
+
"""
|
|
228
|
+
rows = conn.execute(sql, (f"%{escaped}%", limit)).fetchall()
|
|
229
|
+
return [
|
|
230
|
+
{
|
|
231
|
+
"symbol": row["symbol"],
|
|
232
|
+
"file": row["file"],
|
|
233
|
+
"line": row["line"],
|
|
234
|
+
"snippet": "",
|
|
235
|
+
"score": 0.0,
|
|
236
|
+
"cluster_id": row["cluster_id"],
|
|
237
|
+
# Phase 4: include signature for rescore Signal-6 (nullable — pre-v5 rows have NULL).
|
|
238
|
+
"signature": row["signature"],
|
|
239
|
+
}
|
|
240
|
+
for row in rows
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _fuzzy_fallback(
|
|
245
|
+
conn: sqlite3.Connection,
|
|
246
|
+
term: str,
|
|
247
|
+
max_dist: int,
|
|
248
|
+
candidate_cap: int,
|
|
249
|
+
limit: int,
|
|
250
|
+
) -> list[dict]:
|
|
251
|
+
"""Bounded Damerau-Levenshtein fuzzy match over distinct symbol names.
|
|
252
|
+
|
|
253
|
+
Third-tier fallback. Reads at most candidate_cap distinct symbol names from
|
|
254
|
+
the DB, computes edit distance against `term`, and returns those within max_dist.
|
|
255
|
+
|
|
256
|
+
WHY bounded candidate cap: this is O(n * |term|) per call. On large indexes
|
|
257
|
+
(thousands of symbols), scanning all names would be slow. The cap keeps the
|
|
258
|
+
tail-case bounded; SEAM_FUZZY_MAX_CANDIDATES controls it via env var.
|
|
259
|
+
|
|
260
|
+
WHY length-window pre-filter: only names whose length is within max_dist of the
|
|
261
|
+
term length can possibly achieve edit-distance <= max_dist. Filtering in SQL
|
|
262
|
+
before the Python DL loop avoids scanning irrelevant names (e.g. a 20-char name
|
|
263
|
+
can never match a 3-char term at dist=1). This also makes the cap more meaningful:
|
|
264
|
+
all names that CAN match are eligible, not just the first N in rowid order.
|
|
265
|
+
|
|
266
|
+
WHY ORDER BY name: deterministic ordering means repeated calls with the same
|
|
267
|
+
inputs return the same result set (reproducible for agents/tests).
|
|
268
|
+
"""
|
|
269
|
+
term_lower = term.lower()
|
|
270
|
+
term_len = len(term_lower)
|
|
271
|
+
|
|
272
|
+
# Pre-filter by length window: |len(name) - len(term)| <= max_dist is a necessary
|
|
273
|
+
# (not sufficient) condition for edit-distance <= max_dist. Filter in SQL so we
|
|
274
|
+
# only pull names that can possibly match, then order deterministically.
|
|
275
|
+
name_rows = conn.execute(
|
|
276
|
+
"""
|
|
277
|
+
SELECT DISTINCT name FROM symbols
|
|
278
|
+
WHERE length(name) BETWEEN ? AND ?
|
|
279
|
+
ORDER BY name
|
|
280
|
+
LIMIT ?
|
|
281
|
+
""",
|
|
282
|
+
(max(1, term_len - max_dist), term_len + max_dist, candidate_cap),
|
|
283
|
+
).fetchall()
|
|
284
|
+
|
|
285
|
+
matches: list[str] = []
|
|
286
|
+
for row in name_rows:
|
|
287
|
+
name = row["name"]
|
|
288
|
+
dist = _bounded_edit_distance(term_lower, name.lower(), max_dist)
|
|
289
|
+
if dist <= max_dist:
|
|
290
|
+
matches.append(name)
|
|
291
|
+
|
|
292
|
+
if not matches:
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
# Fetch full rows for all matched symbol names.
|
|
296
|
+
# Phase 4: SELECT s.signature so rescore() Signal-6 can fire for fuzzy fallback rows.
|
|
297
|
+
placeholders = ",".join("?" * len(matches))
|
|
298
|
+
sql = f"""
|
|
299
|
+
SELECT
|
|
300
|
+
s.name AS symbol,
|
|
301
|
+
f.path AS file,
|
|
302
|
+
s.start_line AS line,
|
|
303
|
+
s.cluster_id AS cluster_id,
|
|
304
|
+
s.signature AS signature
|
|
305
|
+
FROM symbols s
|
|
306
|
+
JOIN files f ON f.id = s.file_id
|
|
307
|
+
WHERE s.name IN ({placeholders})
|
|
308
|
+
LIMIT ?
|
|
309
|
+
"""
|
|
310
|
+
rows = conn.execute(sql, matches + [limit]).fetchall()
|
|
311
|
+
return [
|
|
312
|
+
{
|
|
313
|
+
"symbol": row["symbol"],
|
|
314
|
+
"file": row["file"],
|
|
315
|
+
"line": row["line"],
|
|
316
|
+
"snippet": "",
|
|
317
|
+
"score": 0.0,
|
|
318
|
+
"cluster_id": row["cluster_id"],
|
|
319
|
+
# Phase 4: include signature for rescore Signal-6 (nullable — pre-v5 rows have NULL).
|
|
320
|
+
"signature": row["signature"],
|
|
321
|
+
}
|
|
322
|
+
for row in rows
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# _extract_terms is imported from seam.query.fts rather than duplicated here because
|
|
327
|
+
# both the MATCH expression builder (build_match_query) and the rescore signal
|
|
328
|
+
# computation must tokenise identically. A local copy would silently drift.
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ── Hybrid search helpers ─────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _hydrate_symbol_rows(
|
|
335
|
+
conn: sqlite3.Connection, symbol_ids: list[int]
|
|
336
|
+
) -> dict[int, dict]:
|
|
337
|
+
"""Fetch symbol + file data for a list of symbol IDs.
|
|
338
|
+
|
|
339
|
+
Returns a dict mapping symbol_id → row dict with keys:
|
|
340
|
+
symbol, file, line, cluster_id, signature.
|
|
341
|
+
|
|
342
|
+
Used by the hybrid path to hydrate RRF-merged id lists into full rows.
|
|
343
|
+
WHY: After rrf_merge we have a ranked list of symbol_ids. To produce
|
|
344
|
+
SearchResult objects we need name, file, line etc. One query fetches all
|
|
345
|
+
needed columns at once rather than N per-id lookups.
|
|
346
|
+
"""
|
|
347
|
+
if not symbol_ids:
|
|
348
|
+
return {}
|
|
349
|
+
placeholders = ",".join("?" * len(symbol_ids))
|
|
350
|
+
sql = f"""
|
|
351
|
+
SELECT
|
|
352
|
+
s.id AS id,
|
|
353
|
+
s.name AS symbol,
|
|
354
|
+
f.path AS file,
|
|
355
|
+
s.start_line AS line,
|
|
356
|
+
s.cluster_id AS cluster_id,
|
|
357
|
+
s.signature AS signature
|
|
358
|
+
FROM symbols s
|
|
359
|
+
JOIN files f ON f.id = s.file_id
|
|
360
|
+
WHERE s.id IN ({placeholders})
|
|
361
|
+
"""
|
|
362
|
+
rows = conn.execute(sql, symbol_ids).fetchall()
|
|
363
|
+
return {row["id"]: dict(row) for row in rows}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _is_hybrid_enabled(conn: sqlite3.Connection) -> bool:
|
|
367
|
+
"""Return True when the hybrid path should be used for this query.
|
|
368
|
+
|
|
369
|
+
Conditions (all must hold):
|
|
370
|
+
1. SEAM_SEMANTIC config is 'on'.
|
|
371
|
+
2. The embeddings table has at least one row for the configured model.
|
|
372
|
+
|
|
373
|
+
WHY check at query time vs. at startup: the embeddings table can be
|
|
374
|
+
populated between process starts without restarting the server. Checking
|
|
375
|
+
per-query ensures newly indexed embeddings are immediately available without
|
|
376
|
+
requiring a server restart. The check is a single COUNT(*) — negligible cost.
|
|
377
|
+
"""
|
|
378
|
+
if config.SEAM_SEMANTIC != "on":
|
|
379
|
+
return False
|
|
380
|
+
count = conn.execute(
|
|
381
|
+
"SELECT COUNT(*) FROM embeddings WHERE model = ?",
|
|
382
|
+
(config.SEAM_EMBED_MODEL,),
|
|
383
|
+
).fetchone()[0]
|
|
384
|
+
if count == 0:
|
|
385
|
+
# SEAM_SEMANTIC=on but no embeddings — warn once per process.
|
|
386
|
+
global _hybrid_warned # noqa: PLW0603
|
|
387
|
+
if not _hybrid_warned:
|
|
388
|
+
_hybrid_warned = True
|
|
389
|
+
logger.warning(
|
|
390
|
+
"_is_hybrid_enabled: SEAM_SEMANTIC=on but no embeddings found for "
|
|
391
|
+
"model=%r. Run 'seam init --semantic' to build the embedding index.",
|
|
392
|
+
config.SEAM_EMBED_MODEL,
|
|
393
|
+
)
|
|
394
|
+
return False
|
|
395
|
+
return True
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ── Hybrid search results helper ─────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _hybrid_search_results(
|
|
402
|
+
conn: sqlite3.Connection,
|
|
403
|
+
text: str,
|
|
404
|
+
fts_rows: list[dict],
|
|
405
|
+
fts_symbol_ids: list[int],
|
|
406
|
+
limit: int,
|
|
407
|
+
) -> list[SearchResult] | None:
|
|
408
|
+
"""Compute hybrid (FTS + semantic) search results via RRF.
|
|
409
|
+
|
|
410
|
+
Returns a list of SearchResult when semantic adds new candidates beyond FTS,
|
|
411
|
+
or None to signal "fall through to the FTS-only path".
|
|
412
|
+
|
|
413
|
+
WHY extracted from search(): to keep search() under 200 lines (DRIFT-2).
|
|
414
|
+
WHY returns None: a None return lets search() stay in the normal FTS path
|
|
415
|
+
when semantic adds nothing new — semantically clear vs. returning [].
|
|
416
|
+
|
|
417
|
+
Snippet contract (STOP-2):
|
|
418
|
+
FTS rows carry real BM25 snippets. When a merged result was found by FTS,
|
|
419
|
+
we preserve its snippet. Semantic-only results get snippet="" (no FTS row).
|
|
420
|
+
"""
|
|
421
|
+
sem_candidates = semantic_candidates(
|
|
422
|
+
conn,
|
|
423
|
+
text,
|
|
424
|
+
model=config.SEAM_EMBED_MODEL,
|
|
425
|
+
limit=config.SEAM_SEMANTIC_LIMIT,
|
|
426
|
+
)
|
|
427
|
+
sem_ids = [sid for sid, _score in sem_candidates]
|
|
428
|
+
|
|
429
|
+
# Check if semantic added any new candidates beyond FTS.
|
|
430
|
+
fts_id_set = set(fts_symbol_ids)
|
|
431
|
+
new_sem_ids = [sid for sid in sem_ids if sid not in fts_id_set]
|
|
432
|
+
|
|
433
|
+
if not new_sem_ids:
|
|
434
|
+
return None # No new recall — fall through to FTS rescore path.
|
|
435
|
+
|
|
436
|
+
# Build snippet + score lookup for FTS rows (preserve real BM25 snippets).
|
|
437
|
+
fts_id_to_snippet: dict[int, str] = {r["id"]: r.get("snippet", "") for r in fts_rows}
|
|
438
|
+
|
|
439
|
+
# RRF merge: combine FTS id list (BM25-ordered) and semantic id list (cosine-ordered).
|
|
440
|
+
merged_ids = rrf_merge(fts_symbol_ids, sem_ids)
|
|
441
|
+
|
|
442
|
+
# Hydrate the merged id list into full rows (batch fetch — one SQL query).
|
|
443
|
+
id_to_row = _hydrate_symbol_rows(conn, merged_ids)
|
|
444
|
+
|
|
445
|
+
# Build SearchResult list in merged-rank order. RRF score = 1/(k+rank).
|
|
446
|
+
k = config.SEAM_RRF_K
|
|
447
|
+
results: list[SearchResult] = []
|
|
448
|
+
for rank, sym_id in enumerate(merged_ids[:limit], start=1):
|
|
449
|
+
if sym_id not in id_to_row:
|
|
450
|
+
continue
|
|
451
|
+
row = id_to_row[sym_id]
|
|
452
|
+
rrf_score = 1.0 / (k + rank)
|
|
453
|
+
# Use the real FTS snippet for ids that came from FTS; "" for semantic-only.
|
|
454
|
+
snippet = fts_id_to_snippet.get(sym_id, "")
|
|
455
|
+
results.append(
|
|
456
|
+
SearchResult(
|
|
457
|
+
symbol=row["symbol"],
|
|
458
|
+
file=row["file"],
|
|
459
|
+
line=row["line"],
|
|
460
|
+
snippet=snippet,
|
|
461
|
+
score=rrf_score,
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
if results:
|
|
466
|
+
logger.debug(
|
|
467
|
+
"search: hybrid path returned %d results (%d FTS + %d new semantic) for %r",
|
|
468
|
+
len(results),
|
|
469
|
+
len(fts_symbol_ids),
|
|
470
|
+
len(new_sem_ids),
|
|
471
|
+
text,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
return results if results else None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ── search ───────────────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def search(
|
|
481
|
+
conn: sqlite3.Connection,
|
|
482
|
+
text: str,
|
|
483
|
+
limit: int = 20,
|
|
484
|
+
*,
|
|
485
|
+
semantic: bool = True,
|
|
486
|
+
) -> list[SearchResult]:
|
|
487
|
+
"""Full-text search across symbol names and docstrings.
|
|
488
|
+
|
|
489
|
+
Phase 3 (Slice 1) changes vs. the original:
|
|
490
|
+
- Builds the MATCH expression via fts.build_match_query() (OR-join of prefix
|
|
491
|
+
terms) instead of passing raw text. This prevents one non-matching word from
|
|
492
|
+
zeroing the entire result set (the implicit-AND bug).
|
|
493
|
+
- Passes FTS rows through fts.rescore() for multi-signal ranking.
|
|
494
|
+
- LIKE→fuzzy fallback: when FTS returns zero rows:
|
|
495
|
+
1. Falls back to LIKE %term% per term.
|
|
496
|
+
2. If still empty and term ≥3 chars, tries bounded Damerau-Levenshtein
|
|
497
|
+
fuzzy match over distinct symbol names (capped at SEAM_FUZZY_MAX_CANDIDATES).
|
|
498
|
+
|
|
499
|
+
Semantic search (T6):
|
|
500
|
+
- When SEAM_SEMANTIC=on AND embeddings exist for the configured model:
|
|
501
|
+
1. FTS5 candidates (id list, ranked by BM25) are combined with semantic
|
|
502
|
+
candidates (id list, ranked by cosine) via rrf_merge.
|
|
503
|
+
2. The merged id list is hydrated into SearchResult rows.
|
|
504
|
+
3. Score in SearchResult is the RRF rank (1/(k+rank), higher=better).
|
|
505
|
+
- When semantic is off or unavailable: existing pure-FTS5 path is used.
|
|
506
|
+
Behavior is byte-identical to pre-T6 in this case.
|
|
507
|
+
|
|
508
|
+
Result ordering: highest score first. The returned `score` is the NEGATED bm25
|
|
509
|
+
value + rescore bonuses, so higher = more relevant. FTS rows carry meaningful
|
|
510
|
+
BM25 scores; fallback rows start at 0.0 before rescore bonuses are applied.
|
|
511
|
+
|
|
512
|
+
Raises sqlite3.OperationalError on genuinely malformed FTS5 syntax.
|
|
513
|
+
NOTE: After OR-join, most user input won't be malformed. But the propagation
|
|
514
|
+
path is preserved so callers (MCP handlers) can still map it to INVALID_QUERY.
|
|
515
|
+
"""
|
|
516
|
+
terms = _extract_terms(text)
|
|
517
|
+
|
|
518
|
+
# Step 1: try FTS5 with the safe OR-join expression
|
|
519
|
+
match_expr = fts.build_match_query(text)
|
|
520
|
+
fts_rows: list[dict] = []
|
|
521
|
+
|
|
522
|
+
# Also collect FTS symbol IDs for the hybrid path (if needed)
|
|
523
|
+
fts_symbol_ids: list[int] = []
|
|
524
|
+
|
|
525
|
+
if match_expr:
|
|
526
|
+
# Let OperationalError (genuinely malformed FTS5) propagate to caller.
|
|
527
|
+
# With OR-join, this only fires on actual syntax errors, not multi-word queries.
|
|
528
|
+
# Phase 4: SELECT s.signature so that fts.rescore() Signal-6 (signature boost)
|
|
529
|
+
# can fire. Without s.signature in the row, rescore() always sees None and the
|
|
530
|
+
# boost is permanently dead code. This column is nullable — pre-v5 rows have NULL.
|
|
531
|
+
sql = """
|
|
532
|
+
SELECT
|
|
533
|
+
s.id AS id,
|
|
534
|
+
s.name AS symbol,
|
|
535
|
+
f.path AS file,
|
|
536
|
+
s.start_line AS line,
|
|
537
|
+
snippet(symbols_fts, 0, '<b>', '</b>', '...', 8) AS snippet,
|
|
538
|
+
-- Column weights (name, docstring, signature, search_text): the Tier D #12
|
|
539
|
+
-- split-token column is weighted LOWEST so it adds camelCase RECALL without
|
|
540
|
+
-- outranking or evicting real name/doc/signature hits from the LIMIT window.
|
|
541
|
+
bm25(symbols_fts, 10.0, 2.0, 2.0, 1.0) AS score,
|
|
542
|
+
s.cluster_id AS cluster_id,
|
|
543
|
+
s.signature AS signature
|
|
544
|
+
FROM symbols_fts
|
|
545
|
+
JOIN symbols s ON s.id = symbols_fts.rowid
|
|
546
|
+
JOIN files f ON f.id = s.file_id
|
|
547
|
+
WHERE symbols_fts MATCH ?
|
|
548
|
+
ORDER BY score -- raw bm25 ascending = most relevant first
|
|
549
|
+
LIMIT ?
|
|
550
|
+
"""
|
|
551
|
+
raw_rows = conn.execute(sql, (match_expr, limit)).fetchall()
|
|
552
|
+
fts_rows = [
|
|
553
|
+
{
|
|
554
|
+
"id": row["id"],
|
|
555
|
+
"symbol": row["symbol"],
|
|
556
|
+
"file": row["file"],
|
|
557
|
+
"line": row["line"],
|
|
558
|
+
"snippet": row["snippet"] or "",
|
|
559
|
+
# Flip bm25 sign: contract wants higher = better; raw bm25 is lower = better
|
|
560
|
+
"score": -float(row["score"]),
|
|
561
|
+
"cluster_id": row["cluster_id"],
|
|
562
|
+
# Phase 4: include signature so rescore() Signal-6 can fire.
|
|
563
|
+
"signature": row["signature"],
|
|
564
|
+
}
|
|
565
|
+
for row in raw_rows
|
|
566
|
+
]
|
|
567
|
+
# Collect FTS symbol IDs (BM25-order = best first) for RRF merge
|
|
568
|
+
fts_symbol_ids = [r["id"] for r in fts_rows]
|
|
569
|
+
|
|
570
|
+
# ── Hybrid path: merge FTS and semantic candidates ────────────────────────
|
|
571
|
+
# When SEAM_SEMANTIC=on AND embeddings exist AND semantic=True (caller opt-in):
|
|
572
|
+
# combine both recall sources. Semantic ONLY ADDS recall — FTS hits never dropped.
|
|
573
|
+
# `semantic=False` lets callers (e.g. CLI --no-semantic) bypass hybrid without
|
|
574
|
+
# mutating global config (DRIFT-1 fix).
|
|
575
|
+
if semantic and _is_hybrid_enabled(conn):
|
|
576
|
+
hybrid_results = _hybrid_search_results(
|
|
577
|
+
conn, text, fts_rows, fts_symbol_ids, limit
|
|
578
|
+
)
|
|
579
|
+
if hybrid_results is not None:
|
|
580
|
+
return hybrid_results
|
|
581
|
+
|
|
582
|
+
if fts_rows:
|
|
583
|
+
# Rescore and return FTS results (the common happy path)
|
|
584
|
+
rescored = fts.rescore(fts_rows, terms)
|
|
585
|
+
return [
|
|
586
|
+
SearchResult(
|
|
587
|
+
symbol=r["symbol"],
|
|
588
|
+
file=r["file"],
|
|
589
|
+
line=r["line"],
|
|
590
|
+
snippet=r.get("snippet", ""),
|
|
591
|
+
score=r["score"],
|
|
592
|
+
)
|
|
593
|
+
for r in rescored
|
|
594
|
+
]
|
|
595
|
+
|
|
596
|
+
# ── Step 2: LIKE fallback — FTS returned zero rows ────────────────────────
|
|
597
|
+
# WHY: FTS5 requires tokens to be indexed. A query term that was never seen
|
|
598
|
+
# (a typo like 'autenticate') gets zero FTS hits. LIKE %term% catches substrings.
|
|
599
|
+
if not match_expr:
|
|
600
|
+
# match_expr was empty sentinel — all tokens were stripped (operators/short).
|
|
601
|
+
# Log at INFO to distinguish "query discarded" from "genuine no-match".
|
|
602
|
+
logger.info(
|
|
603
|
+
"search: match_expr empty (query discarded) — all tokens stripped from %r; "
|
|
604
|
+
"returning []",
|
|
605
|
+
text,
|
|
606
|
+
)
|
|
607
|
+
return []
|
|
608
|
+
logger.debug("search: FTS returned zero rows for %r — trying LIKE fallback", text)
|
|
609
|
+
|
|
610
|
+
like_rows: list[dict] = []
|
|
611
|
+
for term in terms:
|
|
612
|
+
found = _like_fallback(conn, term, limit)
|
|
613
|
+
# Deduplicate by symbol name across multiple term searches
|
|
614
|
+
seen = {r["symbol"] for r in like_rows}
|
|
615
|
+
like_rows.extend(r for r in found if r["symbol"] not in seen)
|
|
616
|
+
if len(like_rows) >= limit:
|
|
617
|
+
break
|
|
618
|
+
|
|
619
|
+
if like_rows:
|
|
620
|
+
logger.debug("search: LIKE fallback found %d rows", len(like_rows))
|
|
621
|
+
rescored = fts.rescore(like_rows, terms)
|
|
622
|
+
return [
|
|
623
|
+
SearchResult(
|
|
624
|
+
symbol=r["symbol"],
|
|
625
|
+
file=r["file"],
|
|
626
|
+
line=r["line"],
|
|
627
|
+
snippet=r.get("snippet", ""),
|
|
628
|
+
score=r["score"],
|
|
629
|
+
)
|
|
630
|
+
for r in rescored[:limit]
|
|
631
|
+
]
|
|
632
|
+
|
|
633
|
+
# ── Step 3: fuzzy fallback — LIKE also returned nothing ───────────────────
|
|
634
|
+
# Only attempt for terms that are long enough for edit-distance to be meaningful.
|
|
635
|
+
logger.debug("search: LIKE returned zero rows for %r — trying fuzzy fallback", text)
|
|
636
|
+
|
|
637
|
+
fuzzy_rows: list[dict] = []
|
|
638
|
+
for term in terms:
|
|
639
|
+
if len(term) < 3:
|
|
640
|
+
continue
|
|
641
|
+
found = _fuzzy_fallback(
|
|
642
|
+
conn,
|
|
643
|
+
term,
|
|
644
|
+
max_dist=SEAM_FUZZY_MAX_DIST,
|
|
645
|
+
candidate_cap=SEAM_FUZZY_MAX_CANDIDATES,
|
|
646
|
+
limit=limit,
|
|
647
|
+
)
|
|
648
|
+
seen = {r["symbol"] for r in fuzzy_rows}
|
|
649
|
+
fuzzy_rows.extend(r for r in found if r["symbol"] not in seen)
|
|
650
|
+
|
|
651
|
+
if fuzzy_rows:
|
|
652
|
+
logger.debug("search: fuzzy fallback found %d rows", len(fuzzy_rows))
|
|
653
|
+
rescored = fts.rescore(fuzzy_rows, terms)
|
|
654
|
+
return [
|
|
655
|
+
SearchResult(
|
|
656
|
+
symbol=r["symbol"],
|
|
657
|
+
file=r["file"],
|
|
658
|
+
line=r["line"],
|
|
659
|
+
snippet=r.get("snippet", ""),
|
|
660
|
+
score=r["score"],
|
|
661
|
+
)
|
|
662
|
+
for r in rescored[:limit]
|
|
663
|
+
]
|
|
664
|
+
|
|
665
|
+
# All three tiers exhausted — genuine miss (not a query-discard).
|
|
666
|
+
logger.info(
|
|
667
|
+
"search: all tiers ran (FTS + LIKE + fuzzy), genuine miss for %r — returning []",
|
|
668
|
+
text,
|
|
669
|
+
)
|
|
670
|
+
return []
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ── query ────────────────────────────────────────────────────────────────────
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def query(
|
|
677
|
+
conn: sqlite3.Connection,
|
|
678
|
+
concept: str,
|
|
679
|
+
limit: int = 10,
|
|
680
|
+
*,
|
|
681
|
+
semantic: bool = True,
|
|
682
|
+
) -> list[QueryResult]:
|
|
683
|
+
"""Find symbols related to a concept (FTS5 seed + 1-hop graph expansion).
|
|
684
|
+
|
|
685
|
+
Algorithm:
|
|
686
|
+
1. Build MATCH expression via fts.build_match_query() (OR-join fix).
|
|
687
|
+
2. FTS5 MATCH to get seed symbols (with BM25 score).
|
|
688
|
+
3. Rescore seeds via fts.rescore().
|
|
689
|
+
3b. [Semantic] When SEAM_SEMANTIC=on and embeddings exist: semantic candidates are
|
|
690
|
+
injected into seed_map with score=0.5 (below FTS rescored seeds, above neighbors).
|
|
691
|
+
WHY score=0.5: FTS rescored seeds have scores in roughly [0.5, 5.0]; graph
|
|
692
|
+
neighbors have score=0.0. Placing semantic seeds at 0.5 makes them rank below
|
|
693
|
+
confident FTS matches but above pure-graph neighbors — semantically relevant but
|
|
694
|
+
not as strong as keyword-matched seeds.
|
|
695
|
+
4. For each seed symbol, collect 1-hop neighbors via edges
|
|
696
|
+
(both direct callees and callers — anything connected).
|
|
697
|
+
5. Deduplicate; seed symbols keep their (rescored) score, neighbors get 0.
|
|
698
|
+
6. For each symbol in the result set, compute callers_count + callees_count.
|
|
699
|
+
7. Sort seeds-first, then by score descending, apply limit.
|
|
700
|
+
|
|
701
|
+
Raises sqlite3.OperationalError on malformed FTS5 syntax (the caller maps
|
|
702
|
+
this to INVALID_QUERY, mirroring search() — do NOT swallow it here, or a
|
|
703
|
+
malformed concept looks identical to "no matches").
|
|
704
|
+
"""
|
|
705
|
+
terms = _extract_terms(concept)
|
|
706
|
+
|
|
707
|
+
# Step 1+2: FTS5 seed query with OR-join
|
|
708
|
+
match_expr = fts.build_match_query(concept)
|
|
709
|
+
|
|
710
|
+
seed_map: dict[str, tuple[str, int, float]] = {}
|
|
711
|
+
|
|
712
|
+
if match_expr:
|
|
713
|
+
# Phase 4: include s.signature so rescore() Signal-6 (signature boost) fires.
|
|
714
|
+
seed_sql = """
|
|
715
|
+
SELECT
|
|
716
|
+
s.name AS name,
|
|
717
|
+
f.path AS file,
|
|
718
|
+
s.start_line AS line,
|
|
719
|
+
-- Column weights (name, docstring, signature, search_text): the Tier D #12
|
|
720
|
+
-- split-token column is weighted LOWEST so it adds camelCase RECALL without
|
|
721
|
+
-- outranking or evicting real name/doc/signature hits from the LIMIT window.
|
|
722
|
+
bm25(symbols_fts, 10.0, 2.0, 2.0, 1.0) AS score,
|
|
723
|
+
s.cluster_id AS cluster_id,
|
|
724
|
+
s.signature AS signature
|
|
725
|
+
FROM symbols_fts
|
|
726
|
+
JOIN symbols s ON s.id = symbols_fts.rowid
|
|
727
|
+
JOIN files f ON f.id = s.file_id
|
|
728
|
+
WHERE symbols_fts MATCH ?
|
|
729
|
+
ORDER BY score
|
|
730
|
+
LIMIT ?
|
|
731
|
+
"""
|
|
732
|
+
# Let OperationalError (malformed FTS5) propagate — same contract as search():
|
|
733
|
+
# caller maps it to INVALID_QUERY so it is distinct from "no matches found".
|
|
734
|
+
seed_rows_raw = conn.execute(seed_sql, (match_expr, limit)).fetchall()
|
|
735
|
+
|
|
736
|
+
if seed_rows_raw:
|
|
737
|
+
# Rescore to apply name/path/cluster/signature signals
|
|
738
|
+
seed_dicts = [
|
|
739
|
+
{
|
|
740
|
+
"symbol": row["name"],
|
|
741
|
+
"file": row["file"],
|
|
742
|
+
"line": row["line"],
|
|
743
|
+
"snippet": "",
|
|
744
|
+
"score": -float(
|
|
745
|
+
row["score"]
|
|
746
|
+
), # negate: contract wants higher=better; raw bm25 is negative (lower=better)
|
|
747
|
+
"cluster_id": row["cluster_id"],
|
|
748
|
+
# Phase 4: include signature for rescore Signal-6.
|
|
749
|
+
"signature": row["signature"],
|
|
750
|
+
}
|
|
751
|
+
for row in seed_rows_raw
|
|
752
|
+
]
|
|
753
|
+
rescored_seeds = fts.rescore(seed_dicts, terms)
|
|
754
|
+
|
|
755
|
+
# Build seed map: name -> (file, line, score)
|
|
756
|
+
for row in rescored_seeds:
|
|
757
|
+
seed_map[row["symbol"]] = (row["file"], row["line"], row["score"])
|
|
758
|
+
|
|
759
|
+
# ── Hybrid augmentation for query(): inject semantic seeds ───────────────
|
|
760
|
+
# When SEAM_SEMANTIC=on AND embeddings exist, semantic candidates are fetched
|
|
761
|
+
# and their symbol names are added to seed_map as additional seeds (score=0.5
|
|
762
|
+
# — below FTS rescored seeds but above 1-hop graph neighbors at score=0.0).
|
|
763
|
+
# This lets semantic-only symbols appear as peers of FTS seeds, going through
|
|
764
|
+
# the same 1-hop expansion and callers/callees enrichment path.
|
|
765
|
+
if semantic and _is_hybrid_enabled(conn):
|
|
766
|
+
sem_candidates = semantic_candidates(
|
|
767
|
+
conn,
|
|
768
|
+
concept,
|
|
769
|
+
model=config.SEAM_EMBED_MODEL,
|
|
770
|
+
limit=config.SEAM_SEMANTIC_LIMIT,
|
|
771
|
+
)
|
|
772
|
+
if sem_candidates:
|
|
773
|
+
sem_ids = [sid for sid, _s in sem_candidates]
|
|
774
|
+
id_to_row = _hydrate_symbol_rows(conn, sem_ids)
|
|
775
|
+
for sym_id, _sem_score in sem_candidates:
|
|
776
|
+
if sym_id not in id_to_row:
|
|
777
|
+
continue
|
|
778
|
+
row = id_to_row[sym_id]
|
|
779
|
+
name = row["symbol"]
|
|
780
|
+
# Add to seed_map only if not already present (FTS seeds take priority)
|
|
781
|
+
if name not in seed_map:
|
|
782
|
+
# Score 0.5: above graph neighbors (0.0), below FTS rescored seeds.
|
|
783
|
+
seed_map[name] = (row["file"], row["line"], 0.5)
|
|
784
|
+
|
|
785
|
+
if not seed_map:
|
|
786
|
+
if not match_expr:
|
|
787
|
+
# Empty sentinel: query was discarded (all tokens stripped).
|
|
788
|
+
logger.info(
|
|
789
|
+
"query: match_expr empty (query discarded) — all tokens stripped from %r; "
|
|
790
|
+
"returning []",
|
|
791
|
+
concept,
|
|
792
|
+
)
|
|
793
|
+
else:
|
|
794
|
+
# FTS ran but found nothing — genuine miss.
|
|
795
|
+
logger.info(
|
|
796
|
+
"query: FTS5 ran, genuine miss for %r — returning []",
|
|
797
|
+
concept,
|
|
798
|
+
)
|
|
799
|
+
return []
|
|
800
|
+
|
|
801
|
+
# Step 3: 1-hop expansion — collect neighbors of seed symbols.
|
|
802
|
+
# WHY expand seed names: a class seed like "CompanionManager" has NO call edges
|
|
803
|
+
# targeting the class name itself — callers invoke bare member names ("start", "stop").
|
|
804
|
+
# edge_match_names expands container seeds to include those bare member names so
|
|
805
|
+
# the neighbor SQL finds callers-of-members as neighbors of the class.
|
|
806
|
+
neighbor_map: dict[str, tuple[str, int, float]] = {}
|
|
807
|
+
seed_names = list(seed_map.keys())
|
|
808
|
+
|
|
809
|
+
# Build the full set of edge-lookup names: for each seed, expand via edge_match_names
|
|
810
|
+
# (which includes bare/qualified bridging AND member fan-out for containers).
|
|
811
|
+
edge_lookup_names: list[str] = []
|
|
812
|
+
seen_edge_names: set[str] = set()
|
|
813
|
+
for seed_name in seed_names:
|
|
814
|
+
for match_name in _edge_match_names(conn, seed_name):
|
|
815
|
+
if match_name not in seen_edge_names:
|
|
816
|
+
edge_lookup_names.append(match_name)
|
|
817
|
+
seen_edge_names.add(match_name)
|
|
818
|
+
|
|
819
|
+
# Batch the edge lookup to avoid hitting SQLite's SQLITE_MAX_VARIABLE_NUMBER=999 limit.
|
|
820
|
+
# Each batch of N names uses 2*N params (source_IN + target_IN), so cap at 450.
|
|
821
|
+
# WHY needed: Slice 3 container fan-out can produce up to 51 names per seed × 20 seeds
|
|
822
|
+
# = 1020 names → 2040 params, exceeding the 999-param limit on Linux/CI.
|
|
823
|
+
# traversal.py uses the same 900-item guard pattern (_SQL_VAR_BATCH).
|
|
824
|
+
neighbor_batch_size = 450 # 450 names × 2 params each = 900 total per query
|
|
825
|
+
neighbor_rows_raw: list[dict] = []
|
|
826
|
+
for batch_start in range(0, max(len(edge_lookup_names), 1), neighbor_batch_size):
|
|
827
|
+
batch = edge_lookup_names[batch_start : batch_start + neighbor_batch_size]
|
|
828
|
+
if not batch:
|
|
829
|
+
break
|
|
830
|
+
ph = ",".join("?" * len(batch))
|
|
831
|
+
batch_sql = f"""
|
|
832
|
+
SELECT DISTINCT
|
|
833
|
+
s.name AS name,
|
|
834
|
+
f.path AS file,
|
|
835
|
+
s.start_line AS line
|
|
836
|
+
FROM edges e
|
|
837
|
+
JOIN symbols s ON (
|
|
838
|
+
s.name = e.target_name OR s.name = e.source_name
|
|
839
|
+
)
|
|
840
|
+
JOIN files f ON f.id = s.file_id
|
|
841
|
+
WHERE e.source_name IN ({ph})
|
|
842
|
+
OR e.target_name IN ({ph})
|
|
843
|
+
"""
|
|
844
|
+
neighbor_rows_raw.extend(conn.execute(batch_sql, batch + batch).fetchall())
|
|
845
|
+
for row in neighbor_rows_raw:
|
|
846
|
+
name = row["name"]
|
|
847
|
+
if name not in seed_map and name not in neighbor_map:
|
|
848
|
+
neighbor_map[name] = (row["file"], row["line"], 0.0)
|
|
849
|
+
|
|
850
|
+
# Step 4: Combine and deduplicate
|
|
851
|
+
combined: dict[str, tuple[str, int, float]] = {**neighbor_map, **seed_map}
|
|
852
|
+
|
|
853
|
+
# Step 5: Compute callers/callees counts for each symbol in the result set.
|
|
854
|
+
# WHY _collect_edges_for_names instead of exact COUNT(*): a qualified symbol like
|
|
855
|
+
# "Parser.parse" has edges stored with bare "parse" as target_name, so an exact
|
|
856
|
+
# COUNT WHERE target_name="Parser.parse" returns 0. Using _edge_match_names expands
|
|
857
|
+
# "Parser.parse" → ["Parser.parse", "parse"] before counting, matching the same
|
|
858
|
+
# bridging logic used by context() — consistent, never shows 0 for qualified methods.
|
|
859
|
+
result: list[QueryResult] = []
|
|
860
|
+
for name, (file, line, score) in combined.items():
|
|
861
|
+
match_names = _edge_match_names(conn, name)
|
|
862
|
+
callers_set, callees_set = _collect_edges_for_names(conn, match_names)
|
|
863
|
+
result.append(
|
|
864
|
+
QueryResult(
|
|
865
|
+
symbol=name,
|
|
866
|
+
file=file,
|
|
867
|
+
line=line,
|
|
868
|
+
score=score,
|
|
869
|
+
callers_count=len(callers_set),
|
|
870
|
+
callees_count=len(callees_set),
|
|
871
|
+
)
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
# Step 6: seeds rank above neighbors; within each group, higher score first.
|
|
875
|
+
seed_name_set = set(seed_map)
|
|
876
|
+
result.sort(key=lambda r: (r["symbol"] in seed_name_set, r["score"]), reverse=True)
|
|
877
|
+
return result[:limit]
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# ── context ──────────────────────────────────────────────────────────────────
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def context(conn: sqlite3.Connection, symbol_name: str) -> ContextResult | None:
|
|
884
|
+
"""Get 360-degree view of a symbol: location, kind, docstring, callers, callees.
|
|
885
|
+
|
|
886
|
+
Tier A Slice 2: resolves bare names to all qualified definitions and merges
|
|
887
|
+
callers/callees across them. Sets ambiguous=True when resolution spans >1 definition.
|
|
888
|
+
Exact single-def match stays byte-stable. Returns None when nothing is found.
|
|
889
|
+
Per-edge confidence is preserved (union never invents confidence).
|
|
890
|
+
"""
|
|
891
|
+
# resolve_query_to_defs handles: exact match, bare-name suffix scan, qualified-not-found.
|
|
892
|
+
def_rows = _resolve_query_to_defs(conn, symbol_name)
|
|
893
|
+
if not def_rows:
|
|
894
|
+
return None
|
|
895
|
+
|
|
896
|
+
# Distinguish bare-name resolution from exact-name match: resolve_query_to_defs may
|
|
897
|
+
# return a "Parser.parse" row for a query of "parse" (bare suffix scan). The returned
|
|
898
|
+
# def's name differs from symbol_name in that case, so we cannot use dup_count for
|
|
899
|
+
# the caller's "Parser.parse" — it would misrepresent collision count for "parse".
|
|
900
|
+
is_exact_match = def_rows[0]["name"] == symbol_name
|
|
901
|
+
|
|
902
|
+
# Fast path: single exact-match def → byte-stable, dup_count drives ambiguous.
|
|
903
|
+
if is_exact_match and len(def_rows) == 1:
|
|
904
|
+
dup_count = conn.execute(
|
|
905
|
+
"SELECT COUNT(*) FROM symbols WHERE name = ?", (symbol_name,)
|
|
906
|
+
).fetchone()[0]
|
|
907
|
+
return cast(ContextResult, _build_context_result_fn(conn, def_rows[0], dup_count=dup_count, decode_enrichment_fields_fn=decode_enrichment_fields))
|
|
908
|
+
|
|
909
|
+
# Multi-def path: bare-name homonym or exact-name collision → merge and mark ambiguous.
|
|
910
|
+
return cast(ContextResult, _build_merged_context_result_fn(conn, def_rows, decode_enrichment_fields))
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def context_at(
|
|
914
|
+
conn: sqlite3.Connection, file_path: str, start_line: int
|
|
915
|
+
) -> ContextResult | None:
|
|
916
|
+
"""P6c: resolve the EXACT symbol at (file_path, start_line) — bypasses name lookup.
|
|
917
|
+
|
|
918
|
+
Returns None when no symbol is at that exact location (unknown/stale UID).
|
|
919
|
+
"""
|
|
920
|
+
row = conn.execute(
|
|
921
|
+
"""
|
|
922
|
+
SELECT s.name, f.path AS file, s.start_line, s.end_line, s.kind, s.docstring,
|
|
923
|
+
s.signature, s.decorators, s.is_exported, s.visibility, s.qualified_name
|
|
924
|
+
FROM symbols s
|
|
925
|
+
JOIN files f ON s.file_id = f.id
|
|
926
|
+
WHERE f.path = ? AND s.start_line = ?
|
|
927
|
+
ORDER BY s.id
|
|
928
|
+
LIMIT 1
|
|
929
|
+
""",
|
|
930
|
+
(file_path, start_line),
|
|
931
|
+
).fetchone()
|
|
932
|
+
|
|
933
|
+
if row is None:
|
|
934
|
+
return None
|
|
935
|
+
|
|
936
|
+
dup_count = conn.execute(
|
|
937
|
+
"SELECT COUNT(*) FROM symbols WHERE name = ?", (row["name"],)
|
|
938
|
+
).fetchone()[0]
|
|
939
|
+
|
|
940
|
+
return cast(ContextResult, _build_context_result_fn(conn, row, dup_count=dup_count, decode_enrichment_fields_fn=decode_enrichment_fields))
|