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/fts.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""FTS5 query construction and multi-signal rescoring (Phase 3, Slice 1).
|
|
2
|
+
|
|
3
|
+
Pure leaf module — no imports from query/server/cli layers, no DB access.
|
|
4
|
+
Only stdlib + typing. Must not open a database connection.
|
|
5
|
+
|
|
6
|
+
WHY this module exists:
|
|
7
|
+
Before Phase 3, engine.py passed the raw user text directly into the FTS5
|
|
8
|
+
MATCH clause. FTS5 implicitly ANDs space-separated terms, so a query like
|
|
9
|
+
"parse issues board" returned ZERO hits if 'board' was not indexed — even
|
|
10
|
+
though 'parse' matched many symbols. This module fixes that by building an
|
|
11
|
+
OR-joined prefix-quoted MATCH expression, and adds multi-signal rescoring
|
|
12
|
+
so precision is recovered without requiring all terms to match.
|
|
13
|
+
|
|
14
|
+
build_match_query(text) -> str
|
|
15
|
+
Tokenise, strip FTS5 operators/specials, wrap each surviving term as "term"*,
|
|
16
|
+
join with OR. Returns a safe sentinel (empty string) on fully-stripped input.
|
|
17
|
+
|
|
18
|
+
rescore(rows, terms) -> list
|
|
19
|
+
Multi-signal rerank over FTS result rows. Signals (per CodeGraph research §4.2):
|
|
20
|
+
1. Exact name match: +80
|
|
21
|
+
2. Prefix name match: +40 (name starts with any query term)
|
|
22
|
+
3. Path relevance: +10 per term found in file path (directory/filename)
|
|
23
|
+
4. Test-file dampening: -30 when query has no test-signal and file is a test file
|
|
24
|
+
5. Cluster boost: +20 when row shares the dominant seed's cluster_id
|
|
25
|
+
6. Signature boost: +15 per term found in signature (Phase 4; additive/small
|
|
26
|
+
to avoid displacing exact-name matches — see constant comment)
|
|
27
|
+
|
|
28
|
+
Score is an UNBOUNDED relevance heuristic — document as "relevance", never as %.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
import re
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from seam.analysis.testpaths import is_test_file
|
|
36
|
+
from seam.config import SEAM_TOKENIZE_IDENTIFIERS
|
|
37
|
+
from seam.indexer.tokenize import split_identifier
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# ── FTS5 special characters and operators to strip ────────────────────────────
|
|
42
|
+
|
|
43
|
+
# Characters that are special in FTS5 MATCH syntax.
|
|
44
|
+
# We strip these so user input cannot inject MATCH operators or crash the query.
|
|
45
|
+
_FTS5_SPECIAL_CHARS: re.Pattern = re.compile(r'["\'\(\)\*\+\-\^\:\.]')
|
|
46
|
+
|
|
47
|
+
# FTS5 boolean operators and NEAR function — must not survive as query terms.
|
|
48
|
+
# Checked case-insensitively against individual tokens after splitting.
|
|
49
|
+
_FTS5_OPERATORS: frozenset[str] = frozenset({"AND", "OR", "NOT", "NEAR"})
|
|
50
|
+
|
|
51
|
+
# Minimum token length to include in the MATCH expression.
|
|
52
|
+
# 1-char prefix queries are valid FTS5 and can match single-letter symbols (e.g. 'x').
|
|
53
|
+
# WHY 1 not 2: a search for 'x' should find symbol 'x'. The original 2 silently dropped
|
|
54
|
+
# single-char queries, making them appear to return no results.
|
|
55
|
+
# NOTE: fuzzy fallback still requires >= 3 chars (too noisy for short terms).
|
|
56
|
+
_MIN_TERM_LEN: int = 1
|
|
57
|
+
|
|
58
|
+
# Sentinel returned when the input strips to nothing.
|
|
59
|
+
# An empty string is a valid SQLite expression that yields zero FTS5 rows without
|
|
60
|
+
# raising OperationalError, so callers need no special-case branch for stripped queries.
|
|
61
|
+
_EMPTY_SENTINEL: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Rescoring constants ───────────────────────────────────────────────────────
|
|
65
|
+
# These mirror CodeGraph's query-utils.ts weights (§4.2) adapted for Seam's
|
|
66
|
+
# data model (cluster_id vs. no cluster concept in CodeGraph).
|
|
67
|
+
|
|
68
|
+
_BONUS_EXACT_NAME: float = 80.0 # name IS the query term (highest signal)
|
|
69
|
+
_BONUS_PREFIX_NAME: float = 40.0 # name STARTS WITH a query term
|
|
70
|
+
_BONUS_PATH_PER_TERM: float = 10.0 # query term appears in the file path
|
|
71
|
+
_PENALTY_TEST_FILE: float = -30.0 # test-file dampening on non-test queries
|
|
72
|
+
_BONUS_CLUSTER_PEER: float = 20.0 # shares cluster with the strongest-score row
|
|
73
|
+
# Phase 4: small additive boost when a query term appears in the signature.
|
|
74
|
+
# Intentionally smaller than name/path signals so signature-only matches rank
|
|
75
|
+
# below exact-name matches. Per PRD: MUST NOT regress existing signals.
|
|
76
|
+
_BONUS_SIGNATURE_PER_TERM: float = 15.0
|
|
77
|
+
# P2 cohesion bonus: scales a small +0.1 with the row's cluster cohesion ratio.
|
|
78
|
+
# Deliberately tiny — it only nudges ordering AMONG otherwise-equal results
|
|
79
|
+
# (e.g. two name-tied symbols), never overrides a name/path signal. Fires ONLY
|
|
80
|
+
# when a row carries a non-NULL 'cohesion' key, so rows without it are unchanged.
|
|
81
|
+
_BONUS_COHESION_MAX: float = 0.1
|
|
82
|
+
|
|
83
|
+
# A query is considered "test-aware" if any term is one of these words,
|
|
84
|
+
# in which case we suppress the test-file dampening.
|
|
85
|
+
_TEST_QUERY_SIGNALS: frozenset[str] = frozenset({"test", "tests", "spec", "fixture"})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
89
|
+
# Public API
|
|
90
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_terms(text: str) -> list[str]:
|
|
94
|
+
"""Extract plain query terms from raw user text (no FTS5 operators).
|
|
95
|
+
|
|
96
|
+
Public single source of truth for tokenisation used by both build_match_query()
|
|
97
|
+
and engine.py rescore() signal computation.
|
|
98
|
+
|
|
99
|
+
Algorithm mirrors build_match_query() step 1-4 but returns plain tokens
|
|
100
|
+
(NOT wrapped as quoted prefixes). Used by rescore() to compute name/path signals.
|
|
101
|
+
|
|
102
|
+
WHY public: engine.py previously had a private _extract_terms that duplicated
|
|
103
|
+
this exact logic (same regex, same operator set, same min-len). Centralising
|
|
104
|
+
here ensures that the tokeniser and query builder always agree.
|
|
105
|
+
"""
|
|
106
|
+
if not text or not text.strip():
|
|
107
|
+
return []
|
|
108
|
+
cleaned = _FTS5_SPECIAL_CHARS.sub(" ", text)
|
|
109
|
+
tokens = cleaned.split()
|
|
110
|
+
return [t for t in tokens if t.upper() not in _FTS5_OPERATORS and len(t) >= _MIN_TERM_LEN]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_match_query(text: str) -> str:
|
|
114
|
+
"""Build a safe FTS5 MATCH expression from raw user input.
|
|
115
|
+
|
|
116
|
+
Algorithm:
|
|
117
|
+
1. Strip FTS5 special characters (quotes, parens, *, ^, etc.).
|
|
118
|
+
2. Split on whitespace into candidate tokens.
|
|
119
|
+
3. Discard FTS5 boolean operators (AND, OR, NOT, NEAR — case-insensitive).
|
|
120
|
+
4. Discard tokens shorter than _MIN_TERM_LEN characters.
|
|
121
|
+
5. Wrap each surviving token as a quoted prefix: "token"*
|
|
122
|
+
6. Join with " OR " so one non-matching word cannot zero the result.
|
|
123
|
+
|
|
124
|
+
The OR-join is the core fix for the implicit-AND zero-hit bug:
|
|
125
|
+
"parse issues board" → "parse"* OR "issues"* OR "board"*
|
|
126
|
+
→ returns all symbols matching ANY of the three terms.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
A safe FTS5 MATCH expression string, or _EMPTY_SENTINEL ("") when all
|
|
130
|
+
tokens are stripped. The empty string triggers zero FTS5 rows (SQLite
|
|
131
|
+
handles it without raising OperationalError).
|
|
132
|
+
"""
|
|
133
|
+
if not text or not text.strip():
|
|
134
|
+
return _EMPTY_SENTINEL
|
|
135
|
+
|
|
136
|
+
# Step 1: strip FTS5 special characters
|
|
137
|
+
cleaned = _FTS5_SPECIAL_CHARS.sub(" ", text)
|
|
138
|
+
|
|
139
|
+
# Step 2: split into tokens
|
|
140
|
+
tokens = cleaned.split()
|
|
141
|
+
|
|
142
|
+
# Steps 3+4: filter operators and short tokens
|
|
143
|
+
surviving: list[str] = []
|
|
144
|
+
for token in tokens:
|
|
145
|
+
# Case-insensitive operator check
|
|
146
|
+
if token.upper() in _FTS5_OPERATORS:
|
|
147
|
+
logger.debug("fts: stripped FTS5 operator %r from query", token)
|
|
148
|
+
continue
|
|
149
|
+
if len(token) < _MIN_TERM_LEN:
|
|
150
|
+
logger.debug("fts: stripped short token %r from query", token)
|
|
151
|
+
continue
|
|
152
|
+
surviving.append(token)
|
|
153
|
+
|
|
154
|
+
if not surviving:
|
|
155
|
+
# Log at INFO (not DEBUG) so operators can distinguish "query discarded because
|
|
156
|
+
# all tokens were operators/too-short" from "FTS ran but genuinely found nothing".
|
|
157
|
+
# Both surface as an empty result set to the caller, but the cause is different.
|
|
158
|
+
logger.info(
|
|
159
|
+
"fts: all tokens stripped from query %r — returning sentinel (zero FTS5 rows)", text
|
|
160
|
+
)
|
|
161
|
+
return _EMPTY_SENTINEL
|
|
162
|
+
|
|
163
|
+
# Tier D #12: expand each surviving token into its identifier sub-words so a camelCase
|
|
164
|
+
# query term ("GlobalPushToTalk") matches the split index, and the original token is kept
|
|
165
|
+
# (so exact/prefix matching on the name column still fires). split_identifier on a plain
|
|
166
|
+
# lowercase word returns just itself, so natural-language queries are unaffected.
|
|
167
|
+
# Gated on the knob: when off, the term set is byte-identical to pre-D12.
|
|
168
|
+
expanded: list[str] = []
|
|
169
|
+
seen_terms: set[str] = set()
|
|
170
|
+
for token in surviving:
|
|
171
|
+
candidates = [token]
|
|
172
|
+
if SEAM_TOKENIZE_IDENTIFIERS == "on":
|
|
173
|
+
# Drop single-char sub-words: a "a"* / "x"* prefix term matches a huge swath of
|
|
174
|
+
# the index and only adds noise (the original camelCase token is already kept).
|
|
175
|
+
# Guard at >=2 explicitly (not _MIN_TERM_LEN, which is 1) since sub-tokens are the
|
|
176
|
+
# high-fan-out source — the OR-expansion makes short terms disproportionately costly.
|
|
177
|
+
candidates += [sub for sub in split_identifier(token) if len(sub) >= 2]
|
|
178
|
+
for cand in candidates:
|
|
179
|
+
low = cand.lower()
|
|
180
|
+
if low not in seen_terms:
|
|
181
|
+
seen_terms.add(low)
|
|
182
|
+
expanded.append(cand)
|
|
183
|
+
|
|
184
|
+
# Step 5+6: build quoted-prefix OR expression.
|
|
185
|
+
# Each term is wrapped as "term"* so FTS5 does prefix-matching (matches any
|
|
186
|
+
# indexed token starting with 'term'). Terms are OR-joined so that one
|
|
187
|
+
# non-matching word cannot zero the entire result set — the fix for the
|
|
188
|
+
# implicit-AND bug where "parse issues board" returned nothing if 'board' was
|
|
189
|
+
# not indexed, even though 'parse' matched many symbols.
|
|
190
|
+
terms = [f'"{t}"*' for t in expanded]
|
|
191
|
+
match_expr = " OR ".join(terms)
|
|
192
|
+
|
|
193
|
+
logger.debug("fts: built MATCH expression: %r", match_expr)
|
|
194
|
+
return match_expr
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def rescore(rows: list[dict[str, Any]], terms: list[str]) -> list[dict[str, Any]]:
|
|
198
|
+
"""Multi-signal rerank over FTS result rows.
|
|
199
|
+
|
|
200
|
+
Applies five signals on top of the FTS BM25 score:
|
|
201
|
+
1. Exact name match bonus (+80): symbol name equals a query term (case-insensitive)
|
|
202
|
+
2. Prefix name match bonus (+40): symbol name starts with a query term
|
|
203
|
+
3. Path relevance (+10/term): query term appears in the file path
|
|
204
|
+
4. Test-file dampening (-30): if query has no test-signal and file is a test file
|
|
205
|
+
5. Cluster boost (+20): row shares cluster_id with the row that has the highest
|
|
206
|
+
raw FTS score (the "strongest seed").
|
|
207
|
+
|
|
208
|
+
The final score is the base FTS score + all applicable bonuses/penalties.
|
|
209
|
+
It is an UNBOUNDED relevance heuristic — callers MUST NOT render it as a %.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
rows: List of result dicts. Each row must have keys: symbol (str), file (str),
|
|
213
|
+
score (float). Optional key: cluster_id (int | None).
|
|
214
|
+
terms: The query terms as plain strings (already stripped of FTS5 operators).
|
|
215
|
+
Used for name/path relevance checks.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The same rows with updated 'score' fields, sorted highest-first.
|
|
219
|
+
Returns a new list; the input is not mutated.
|
|
220
|
+
"""
|
|
221
|
+
if not rows:
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
# Normalise terms to lowercase for case-insensitive comparison
|
|
225
|
+
lower_terms = [t.lower() for t in terms if t]
|
|
226
|
+
|
|
227
|
+
# Determine whether the query itself has test-awareness
|
|
228
|
+
# (suppresses dampening so test-related queries surface test files normally)
|
|
229
|
+
is_test_query = bool(_TEST_QUERY_SIGNALS & frozenset(lower_terms))
|
|
230
|
+
|
|
231
|
+
# Signal 5 (cluster boost): identify the dominant cluster from the highest-scoring row.
|
|
232
|
+
# FTS5 returns rows ordered by BM25 (best first), so rows[0] is the strongest seed.
|
|
233
|
+
# Peers of that seed are likely related — boosting them improves recall without
|
|
234
|
+
# widening to unrelated clusters.
|
|
235
|
+
dominant_cluster_id: int | None = None
|
|
236
|
+
if rows:
|
|
237
|
+
first_cluster = rows[0].get("cluster_id")
|
|
238
|
+
if first_cluster is not None:
|
|
239
|
+
dominant_cluster_id = int(first_cluster)
|
|
240
|
+
|
|
241
|
+
# Apply signals to each row (work on copies to avoid mutating input)
|
|
242
|
+
scored: list[dict[str, Any]] = []
|
|
243
|
+
for row in rows:
|
|
244
|
+
# Shallow copy: we only replace 'score', rest stays identical
|
|
245
|
+
new_row = dict(row)
|
|
246
|
+
base_score = float(row.get("score", 0.0))
|
|
247
|
+
bonus = 0.0
|
|
248
|
+
|
|
249
|
+
name_lower = str(row.get("symbol", "")).lower()
|
|
250
|
+
file_lower = str(row.get("file", "")).lower()
|
|
251
|
+
|
|
252
|
+
if lower_terms:
|
|
253
|
+
# Signal 1: exact name match — name IS one of the query terms
|
|
254
|
+
if name_lower in lower_terms:
|
|
255
|
+
bonus += _BONUS_EXACT_NAME
|
|
256
|
+
logger.debug(
|
|
257
|
+
"fts.rescore: exact-name bonus +%.0f for %r", _BONUS_EXACT_NAME, row["symbol"]
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Signal 2: prefix name match — name starts with any query term
|
|
261
|
+
elif any(name_lower.startswith(t) for t in lower_terms):
|
|
262
|
+
bonus += _BONUS_PREFIX_NAME
|
|
263
|
+
logger.debug(
|
|
264
|
+
"fts.rescore: prefix-name bonus +%.0f for %r", _BONUS_PREFIX_NAME, row["symbol"]
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Signal 3: path relevance — query term appears in the file path
|
|
268
|
+
path_bonus = sum(_BONUS_PATH_PER_TERM for t in lower_terms if t in file_lower)
|
|
269
|
+
bonus += path_bonus
|
|
270
|
+
|
|
271
|
+
# Signal 4: test-file dampening — only on non-test queries
|
|
272
|
+
if not is_test_query and is_test_file(row.get("file")):
|
|
273
|
+
bonus += _PENALTY_TEST_FILE
|
|
274
|
+
logger.debug(
|
|
275
|
+
"fts.rescore: test-file penalty %.0f for %r", _PENALTY_TEST_FILE, row["symbol"]
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Signal 5: cluster boost — shares cluster with the strongest seed
|
|
279
|
+
row_cluster = row.get("cluster_id")
|
|
280
|
+
if (
|
|
281
|
+
dominant_cluster_id is not None
|
|
282
|
+
and row_cluster is not None
|
|
283
|
+
and int(row_cluster) == dominant_cluster_id
|
|
284
|
+
):
|
|
285
|
+
bonus += _BONUS_CLUSTER_PEER
|
|
286
|
+
logger.debug(
|
|
287
|
+
"fts.rescore: cluster boost +%.0f for %r", _BONUS_CLUSTER_PEER, row["symbol"]
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Signal 6 (Phase 4): signature boost — query term appears in the signature.
|
|
291
|
+
# Only fires when signature is non-NULL and a query term is found in it.
|
|
292
|
+
# WHY additive and small: signature matches are weaker than name matches (many
|
|
293
|
+
# functions share the same param types like 'conn' or 'str'). The 15-point cap
|
|
294
|
+
# per term keeps this below the exact-name (80) and prefix-name (40) signals so
|
|
295
|
+
# the ranking order for name-matching symbols is preserved.
|
|
296
|
+
if lower_terms:
|
|
297
|
+
raw_sig = row.get("signature")
|
|
298
|
+
if raw_sig is not None:
|
|
299
|
+
sig_lower = str(raw_sig).lower()
|
|
300
|
+
sig_bonus = sum(
|
|
301
|
+
_BONUS_SIGNATURE_PER_TERM for t in lower_terms if t in sig_lower
|
|
302
|
+
)
|
|
303
|
+
if sig_bonus > 0:
|
|
304
|
+
bonus += sig_bonus
|
|
305
|
+
logger.debug(
|
|
306
|
+
"fts.rescore: signature boost +%.0f for %r",
|
|
307
|
+
sig_bonus,
|
|
308
|
+
row["symbol"],
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Signal 7 (P2): cohesion bonus — additive, tiny, opt-in.
|
|
312
|
+
# Fires ONLY when the row carries a non-NULL 'cohesion' value (in [0,1]).
|
|
313
|
+
# Rows without the key (or with NULL) get NO bonus → byte-identical to pre-P2.
|
|
314
|
+
# WHY guard on the key: search()/fallback rows do not select cohesion today,
|
|
315
|
+
# so this is dead-weight-free unless a caller explicitly supplies cohesion.
|
|
316
|
+
cohesion = row.get("cohesion")
|
|
317
|
+
if cohesion is not None:
|
|
318
|
+
try:
|
|
319
|
+
bonus += _BONUS_COHESION_MAX * float(cohesion)
|
|
320
|
+
except (TypeError, ValueError):
|
|
321
|
+
pass # non-numeric cohesion → no bonus (defensive, never raises)
|
|
322
|
+
|
|
323
|
+
new_row["score"] = base_score + bonus
|
|
324
|
+
scored.append(new_row)
|
|
325
|
+
|
|
326
|
+
# Sort by final score descending (higher = more relevant per API contract)
|
|
327
|
+
scored.sort(key=lambda r: r["score"], reverse=True)
|
|
328
|
+
return scored
|