Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 138 additions & 21 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -4012,6 +4012,7 @@ enum {
BM25_BIND_EXACT = 8,
BM25_BIND_KIND = 9,
BM25_BIND_NS = 10,
BM25_BIND_LABEL_WEIGHT = 11,
BM25_SQL_AUTO_LEN = -1,
/* Inner FTS5 candidate cap. SQLite can early-terminate a plain FTS5 query
* (no JOIN/WHERE on outer table) of the form:
Expand All @@ -4020,6 +4021,11 @@ enum {
* and then joining/filtering/re-ranking those, we bound all work to O(N) where
* N = BM25_INNER_LIMIT rather than the full match set size. */
BM25_INNER_LIMIT = 2000,
/* Longest label spliced into the MATCH pre-filter; real labels are short
* identifiers, so anything longer simply skips the pre-filter. */
BM25_LABEL_MAX = 64,
/* Room for "(<tokens>) AND label : \"<label>\"" around the token list. */
BM25_MATCH_BUF = BM25_QUERY_BUF + BM25_LABEL_MAX + 32,
};

/* Column weights for nodes_fts (name, qualified_name, label, file_path, body).
Expand All @@ -4038,8 +4044,28 @@ enum {
* Safe against a legacy four-column nodes_fts: FTS5's bm25() reads a weight
* only when an instance actually lands in that column (`nVal > ic`), so the
* fifth weight is simply never consulted on a table that has no fifth
* column. */
#define BM25_WEIGHTS "bm25(nodes_fts, 1.0, 1.0, 1.0, 1.0, 0.3)"
* column.
*
* The label weight is bound (?11): 1.0 normally, 0.0 when the MATCH is scoped
* to a label (#2386). Every candidate then carries that label, so a label hit
* cannot tell them apart, and a zero weight keeps the added `label : "..."`
* phrase from shifting BM25 order by row length. */
#define BM25_WEIGHTS "bm25(nodes_fts, 1.0, 1.0, ?11, 1.0, 0.3)"
static const double BM25_LABEL_WEIGHT = 1.0;
static const double BM25_LABEL_WEIGHT_SCOPED = 0.0;

/* Labels an UNLABELLED query never ranks: path-shaped File/Folder nodes, the
* Project node and Variables match almost any token through their qualified
* name and file path, and would crowd definitions out of the result. They are
* still in nodes_fts, so an explicit `label` that names one of them is served
* from its own label-scoped window like any other label (?7 is the caller's
* label, NULL when none was given). Excluding them there too returned 0 rows
* -- often with an exact "eq" total -- although such nodes match. Section and
* Module are NOT excluded (#518/#519): they carry the prose the body column
* indexes. Shared by the ranked and the count query, so the two cannot
* desynchronise. */
#define BM25_UNLABELLED_EXCLUSION \
"(?7 IS NOT NULL OR n.label NOT IN ('File','Folder','Variable','Project'))"

/* Module-local SQLITE_TRANSIENT wrapper to dodge performance-no-int-to-ptr.
* See the matching helper in src/store/store.c for the same pattern. */
Expand Down Expand Up @@ -4090,6 +4116,85 @@ static int bm25_build_match(const char *query, char *out, size_t out_size) {
return tokens;
}

/* Scope an FTS5 MATCH expression to one node label (#2386). The label filter
* must choose the BM25_INNER_LIMIT candidate window, not trim it afterwards:
* otherwise a label that is rare among a common token's matches falls outside
* the window and the search returns 0 rows although matching nodes exist.
* FTS5 matching is case-insensitive, so this is a superset pre-filter; the
* caller keeps the exact `n.label = ?` check. Only a plain identifier is
* spliced in, as a quoted FTS5 string; any other label leaves `tokens`
* unscoped, which is the pre-#2386 behaviour. Returns the label-column weight
* to bind for the resulting MATCH. */
static double bm25_scope_match_to_label(const char *tokens, const char *label, char *out,
size_t out_size) {
snprintf(out, out_size, "%s", tokens);
if (!label || !label[0]) {
return BM25_LABEL_WEIGHT;
}
size_t len = 0;
for (; label[len]; len++) {
char ch = label[len];
bool ident = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') || ch == '_';
if (!ident || len >= BM25_LABEL_MAX) {
return BM25_LABEL_WEIGHT;
}
}
char scoped[BM25_MATCH_BUF];
int n = snprintf(scoped, sizeof(scoped), "(%s) AND label : \"%s\"", tokens, label);
if (n <= 0 || (size_t)n >= sizeof(scoped) || (size_t)n >= out_size) {
return BM25_LABEL_WEIGHT;
}
memcpy(out, scoped, (size_t)n + 1);
return BM25_LABEL_WEIGHT_SCOPED;
}

/* A BM25 result is empty although nodes match when the label is spelled in a
* case no node carries: labels are case-sensitive, so `label=class` finds
* nothing while Class nodes match the query. Returns a hint (memory core,
* CBM_MEM_CLASS_OTHER) naming the label this project actually holds, or NULL
* when the label exists exactly (a true zero). The exact probe is an index
* seek on (project, label); the case-insensitive scan runs only when it
* misses. The caller's label is never echoed back, only the stored spelling. */
static char *bm25_label_zero_hint(sqlite3 *db, const char *project, const char *label) {
enum { HINT_BIND_PROJECT = 1, HINT_BIND_LABEL = 2, HINT_COL_LABEL = 0, HINT_PROBE_EXACT = 0 };
if (!db || !label || !label[0]) {
return NULL;
}
static const char *const probes[] = {
"SELECT label FROM nodes WHERE project = ?1 AND label = ?2 LIMIT 1",
"SELECT label FROM nodes WHERE project = ?1 AND label = ?2 COLLATE NOCASE LIMIT 1",
};
char found[CBM_SZ_256] = "";
for (size_t i = 0; i < sizeof(probes) / sizeof(probes[0]) && !found[0]; i++) {
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(db, probes[i], BM25_SQL_AUTO_LEN, &st, NULL) != SQLITE_OK) {
return NULL;
}
sqlite3_bind_text(st, HINT_BIND_PROJECT, project, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
sqlite3_bind_text(st, HINT_BIND_LABEL, label, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
if (sqlite3_step(st) == SQLITE_ROW) {
const char *stored = (const char *)sqlite3_column_text(st, HINT_COL_LABEL);
snprintf(found, sizeof(found), "%s", stored ? stored : "");
}
sqlite3_finalize(st);
if (found[0] && i == HINT_PROBE_EXACT) {
return NULL;
}
}
if (!found[0]) {
return cbm_mem_strdup(CBM_MEM_CLASS_OTHER,
"no node in this project carries this label; get_graph_schema "
"lists the labels this index holds");
}
char hint[CBM_SZ_512];
snprintf(hint, sizeof(hint),
"no node carries this label as spelled; labels are case-sensitive -- retry with "
"label=\"%s\"",
found);
return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, hint);
}

static char *bm25_file_pattern_like(const char *file_pattern) {
if (!file_pattern) {
return NULL;
Expand Down Expand Up @@ -4141,7 +4246,8 @@ static void bm25_lines_str(char *out, size_t size, int start, int end) {

static void bm25_emit_metadata_tree(cbm_sb_t *sb, int total, int offset, int returned,
bool candidate_window_saturated, bool budget_hit,
bool floor_exceeded, size_t max_output_bytes) {
bool floor_exceeded, size_t max_output_bytes,
const char *zero_hint) {
bool has_more = total > offset + returned;
cbm_tree_scalar_int(sb, "total", total);
cbm_tree_scalar_str(sb, "total_relation", candidate_window_saturated ? "gte" : "eq");
Expand Down Expand Up @@ -4172,12 +4278,15 @@ static void bm25_emit_metadata_tree(cbm_sb_t *sb, int total, int offset, int ret
cbm_tree_scalar_str(sb, "hint",
"first identity row exceeds max_output_bytes; raise "
"max_output_tokens");
} else if (zero_hint) {
cbm_tree_scalar_str(sb, "hint", zero_hint);
}
}

static void bm25_emit_metadata_json(yyjson_mut_doc *doc, yyjson_mut_val *root, int total,
int offset, int returned, bool candidate_window_saturated,
bool budget_hit, bool floor_exceeded, size_t max_output_bytes) {
bool budget_hit, bool floor_exceeded, size_t max_output_bytes,
const char *zero_hint) {
bool has_more = total > offset + returned;
yyjson_mut_obj_add_int(doc, root, "total", total);
yyjson_mut_obj_add_str(doc, root, "total_relation", candidate_window_saturated ? "gte" : "eq");
Expand Down Expand Up @@ -4209,12 +4318,14 @@ static void bm25_emit_metadata_json(yyjson_mut_doc *doc, yyjson_mut_val *root, i
yyjson_mut_obj_add_str(doc, root, "hint",
"first identity row exceeds max_output_bytes; raise "
"max_output_tokens");
} else if (zero_hint) {
yyjson_mut_obj_add_strcpy(doc, root, "hint", zero_hint);
}
}

static char *bm25_render(const bm25_output_row_t *rows, int returned, int total, int offset,
bool candidate_window_saturated, bool tree_format, bool budget_hit,
bool floor_exceeded, size_t max_output_bytes) {
bool floor_exceeded, size_t max_output_bytes, const char *zero_hint) {
if (tree_format) {
cbm_sb_t sb;
cbm_sb_init(&sb);
Expand All @@ -4232,7 +4343,7 @@ static char *bm25_render(const bm25_output_row_t *rows, int returned, int total,
cbm_tree_row_end(&sb);
}
bm25_emit_metadata_tree(&sb, total, offset, returned, candidate_window_saturated,
budget_hit, floor_exceeded, max_output_bytes);
budget_hit, floor_exceeded, max_output_bytes, zero_hint);
return cbm_sb_finish(&sb);
}

Expand All @@ -4259,7 +4370,7 @@ static char *bm25_render(const bm25_output_row_t *rows, int returned, int total,
}
yyjson_mut_obj_add_val(doc, root, "rows", result_rows);
bm25_emit_metadata_json(doc, root, total, offset, returned, candidate_window_saturated,
budget_hit, floor_exceeded, max_output_bytes);
budget_hit, floor_exceeded, max_output_bytes, zero_hint);
char *json = yy_doc_to_str(doc);
yyjson_mut_doc_free(doc);
return json;
Expand All @@ -4276,11 +4387,16 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
if (!db) {
return NULL;
}
char fts_query[BM25_QUERY_BUF];
int tok_count = bm25_build_match(query, fts_query, sizeof(fts_query));
char fts_tokens[BM25_QUERY_BUF];
int tok_count = bm25_build_match(query, fts_tokens, sizeof(fts_tokens));
if (tok_count == 0) {
return NULL;
}
/* One MATCH string for the ranked query, the count and the saturation
* probe, so all three describe the same label-scoped window. */
char fts_query[BM25_MATCH_BUF];
double label_weight =
bm25_scope_match_to_label(fts_tokens, label, fts_query, sizeof(fts_query));
char *file_like = bm25_file_pattern_like(file_pattern);

/* BM25 ranked query using a two-step approach to enable FTS5 early termination.
Expand Down Expand Up @@ -4323,12 +4439,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
") fts "
"JOIN nodes n ON n.id = fts.rowid "
"WHERE n.project = ?2 "
/* Section and Module are NO LONGER excluded (#518/#519): they are the
* labels that carry prose — a Markdown section's body, a config file's
* description — so excluding them made the body column unreachable.
* This exclusion list is MIRRORED in the count query below; the two
* must be changed together or results desynchronise from counts. */
" AND n.label NOT IN ('File','Folder','Variable','Project') "
" AND " BM25_UNLABELLED_EXCLUSION " "
" AND (?6 IS NULL OR n.file_path LIKE ?6) "
/* The caller's label filter applies in query mode exactly as it does
* in the structural mode (2026-09-16 probe: `label=Class` was ignored
Expand Down Expand Up @@ -4378,6 +4489,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
} else {
sqlite3_bind_null(stmt, BM25_BIND_NS);
}
sqlite3_bind_double(stmt, BM25_BIND_LABEL_WEIGHT, label_weight);

/* Count hits within the same inner-limit window — capped at BM25_INNER_LIMIT.
* Uses the identical subquery structure so the FTS5 early-exit applies here too. */
Expand All @@ -4394,7 +4506,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
/* MIRRORS the ranked query's filter verbatim — same weights, same
* label exclusions. Changing one alone reports a total that does
* not describe the rows returned. */
" AND n.label NOT IN ('File','Folder','Variable','Project')"
" AND " BM25_UNLABELLED_EXCLUSION " "
" AND (?6 IS NULL OR n.file_path LIKE ?6)"
" AND (?7 IS NULL OR n.label = ?7)"
" AND (?9 IS NULL OR (CASE WHEN json_valid(n.properties) "
Expand All @@ -4411,6 +4523,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
sqlite3_bind_text(cs, BM25_BIND_PROJECT, project, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
sqlite3_bind_int(cs, BM25_BIND_LIMIT, BM25_INNER_LIMIT);
sqlite3_bind_double(cs, BM25_BIND_LABEL_WEIGHT, label_weight);
if (file_like) {
sqlite3_bind_text(cs, BM25_BIND_FILE, file_like, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
Expand Down Expand Up @@ -4443,9 +4556,10 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu

/* The top-candidate window is a performance ceiling, not an exact-total
* boundary. Probe one candidate beyond it so a broad query never presents
* a window-local count as the complete match count. This is global to the
* FTS table; saturation is therefore conservatively reported even when
* later project/path filters might discard the hidden candidates. */
* a window-local count as the complete match count. The probe shares the
* label-scoped MATCH but is otherwise global to the FTS table; saturation
* is therefore conservatively reported even when later project/path
* filters might discard the hidden candidates. */
bool candidate_window_saturated = true;
{
const char *probe_sql = "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1 "
Expand Down Expand Up @@ -4483,13 +4597,15 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
if (!rows) {
return heap_strdup("out of memory");
}
/* Never a silent zero for a label the index spells differently. */
char *zero_hint = total == 0 ? bm25_label_zero_hint(db, project, label) : NULL;

int returned = row_count;
bool budget_hit = false;
char *payload = NULL;
for (;;) {
payload = bm25_render(rows, returned, total, offset, candidate_window_saturated,
tree_format, budget_hit, false, max_output_bytes);
tree_format, budget_hit, false, max_output_bytes, zero_hint);
if (!payload || strlen(payload) <= max_output_bytes || returned == 0) {
break;
}
Expand All @@ -4502,8 +4618,9 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
if (!payload || strlen(payload) > max_output_bytes || (row_count > 0 && returned == 0)) {
free(payload);
payload = bm25_render(rows, 0, total, offset, candidate_window_saturated, tree_format, true,
row_count > 0, max_output_bytes);
row_count > 0, max_output_bytes, zero_hint);
}
cbm_free(CBM_MEM_CLASS_OTHER, zero_hint);
bm25_output_rows_free(rows, row_count);
return payload;
}
Expand Down
Loading
Loading