diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 804222436..dce0bdaa6 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -376,7 +376,8 @@ static const tool_def_t TOOLS[] = { "fields (e.g. [\"complexity\",\"signature\",\"docstring\"]); pass format=\"json\" for " "legacy verbose objects (include_connected always uses JSON). " "PAGINATION: results are capped at limit (default 50). The response always includes " - "'total' (full match count before limit) and 'has_more' (true when total > " + "'total' (full structural/BM25 match count, or threshold-qualified semantic matches " + "from a bounded candidate window) and 'has_more' (true when total > " "offset+returned). Detect truncation with has_more, then page by re-calling with " "offset=offset+limit until has_more is false. Narrow first via label/file_pattern/" "min_degree before paginating large result sets.", @@ -395,8 +396,9 @@ static const tool_def_t TOOLS[] = { "\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"MUST be an ARRAY of " "keyword strings (e.g. [\\\"send\\\",\\\"pubsub\\\",\\\"publish\\\"]) — NOT a single string. " "Each keyword is scored independently via per-keyword min-cosine; results reflect functions " - "that score well on ALL keywords. Requires moderate/full index mode. Results appear in the " - "'semantic_results' field (separate from 'results').\"},\"limit\":{\"type\":" + "that score well on ALL keywords. Requires moderate/full index mode. Semantic-only calls " + "return the primary semantic table (or JSON 'results'); combined calls add a semantic " + "sidecar table (or JSON 'semantic_results').\"},\"limit\":{\"type\":" "\"integer\",\"description\":\"Max results per call. Default 50. Response carries " "'total' (full match count) and 'has_more' (true if truncated) so callers can " "detect the limit and paginate.\"},\"offset\":{\"type\":\"integer\",\"default\":0," @@ -2471,20 +2473,19 @@ static int extract_semantic_keywords(yyjson_val *sq_val, const char **keywords, return ki; } -/* Emit cbm_vector_result_t entries as a "semantic_results" array on the doc. */ -static void emit_semantic_results(yyjson_mut_doc *doc, yyjson_mut_val *root, - cbm_vector_result_t *vresults, int vcount) { - yyjson_mut_val *sem_results = yyjson_mut_arr(doc); - for (int v = 0; v < vcount; v++) { +static void emit_vector_results(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *field, + cbm_vector_result_t *vresults, int start, int vcount) { + yyjson_mut_val *results = yyjson_mut_arr(doc); + for (int v = start; v < start + vcount; v++) { yyjson_mut_val *vitem = yyjson_mut_obj(doc); yyjson_mut_obj_add_strcpy(doc, vitem, "name", vresults[v].name); yyjson_mut_obj_add_strcpy(doc, vitem, "qualified_name", vresults[v].qualified_name); yyjson_mut_obj_add_strcpy(doc, vitem, "label", vresults[v].label); yyjson_mut_obj_add_strcpy(doc, vitem, "file_path", vresults[v].file_path); yyjson_mut_obj_add_real(doc, vitem, "score", vresults[v].score); - yyjson_mut_arr_add_val(sem_results, vitem); + yyjson_mut_arr_add_val(results, vitem); } - yyjson_mut_obj_add_val(doc, root, "semantic_results", sem_results); + yyjson_mut_obj_add_val(doc, root, field, results); } /* Run the semantic_query vector search from raw args. Sets *out_vresults / @@ -2492,11 +2493,15 @@ static void emit_semantic_results(yyjson_mut_doc *doc, yyjson_mut_val *root, * Returns true if semantic_query was provided as a non-array (type error — * caller should surface to the user). */ static bool run_semantic_query_core(const char *args, cbm_store_t *store, const char *project, - int limit, cbm_vector_result_t **out_vresults, int *out_vcount, - bool *out_present) { + int limit, cbm_vector_search_policy_t policy, + cbm_vector_result_t **out_vresults, int *out_vcount, + int *out_total, bool *out_present) { enum { MAX_KW_SEARCH = 32 }; *out_vresults = NULL; *out_vcount = 0; + if (out_total) { + *out_total = 0; + } if (out_present) { *out_present = false; } @@ -2514,13 +2519,17 @@ static bool run_semantic_query_core(const char *args, cbm_store_t *store, const int ki = extract_semantic_keywords(sq_val, keywords, MAX_KW_SEARCH); cbm_vector_result_t *vresults = NULL; int vcount = 0; + int total = 0; int sem_limit = limit > 0 ? limit : CBM_SZ_16; - if (cbm_store_vector_search(store, project, keywords, ki, sem_limit, &vresults, &vcount) == - CBM_STORE_OK && + if (cbm_store_vector_search(store, project, keywords, ki, sem_limit, policy, &vresults, + &vcount, out_total ? &total : NULL) == CBM_STORE_OK && vcount > 0) { *out_vresults = vresults; *out_vcount = vcount; } + if (out_total) { + *out_total = total; + } } if (args_doc) { yyjson_doc_free(args_doc); @@ -2535,10 +2544,10 @@ static bool run_semantic_query(yyjson_mut_doc *doc, yyjson_mut_val *root, const cbm_store_t *store, const char *project, int limit) { cbm_vector_result_t *vresults = NULL; int vcount = 0; - bool type_error = - run_semantic_query_core(args, store, project, limit, &vresults, &vcount, NULL); + bool type_error = run_semantic_query_core( + args, store, project, limit, CBM_VECTOR_SEARCH_SIDECAR, &vresults, &vcount, NULL, NULL); if (vcount > 0) { - emit_semantic_results(doc, root, vresults, vcount); + emit_vector_results(doc, root, "semantic_results", vresults, 0, vcount); cbm_store_free_vector_results(vresults, vcount); } return type_error; @@ -2653,11 +2662,11 @@ static void emit_search_results_toon(cbm_sb_t *sb, const cbm_search_output_t *ou } /* Emit semantic vector-search results as a TOON table. */ -static void emit_semantic_results_toon(cbm_sb_t *sb, const cbm_vector_result_t *vresults, +static void emit_semantic_results_toon(cbm_sb_t *sb, const cbm_vector_result_t *vresults, int start, int vcount) { static const char *const cols[] = {"qn", "label", "file", "score"}; cbm_toon_table_header(sb, "semantic", vcount, cols, 4); - for (int v = 0; v < vcount; v++) { + for (int v = start; v < start + vcount; v++) { cbm_toon_row_begin(sb); cbm_toon_cell_str(sb, vresults[v].qualified_name, true); cbm_toon_cell_str(sb, vresults[v].label, false); @@ -2667,6 +2676,11 @@ static void emit_semantic_results_toon(cbm_sb_t *sb, const cbm_vector_result_t * } } +static bool semantic_page_has_more(int total, int offset, int page_count) { + long long page_end = (long long)(offset > 0 ? offset : 0) + (long long)page_count; + return (long long)total > page_end; +} + static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); cbm_store_t *store = resolve_store(srv, project); @@ -2747,6 +2761,67 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { .max_degree = max_degree, }; + bool has_filters = (label && label[0]) || name_pattern || qn_pattern || file_pattern || + relationship || exclude_entry_points || include_connected || + min_degree != CBM_NOT_FOUND || max_degree != CBM_NOT_FOUND; + + if (legacy_json && !has_filters) { + cbm_vector_result_t *vresults = NULL; + int vcount = 0; + int vtotal = 0; + bool sq_present = false; + bool sq_type_error = run_semantic_query_core( + args, store, project, CBM_VECTOR_SEARCH_CANDIDATE_CAP, CBM_VECTOR_SEARCH_PRIMARY, + &vresults, &vcount, &vtotal, &sq_present); + if (sq_type_error) { + free(project); + free(label); + free(name_pattern); + free(qn_pattern); + free(file_pattern); + free(relationship); + return cbm_mcp_text_result( + "semantic_query must be an array of keyword strings, e.g. " + "[\"send\",\"pubsub\",\"publish\"] — not a single string. Split your query " + "into individual keywords; each is scored independently via per-keyword " + "min-cosine.", + true); + } + if (sq_present) { + int start = offset > 0 ? offset : 0; + if (start > vcount) { + start = vcount; + } + int page_count = vcount - start; + int page_limit = limit > 0 ? limit : CBM_DEFAULT_SEARCH_LIMIT; + if (page_count > page_limit) { + page_count = page_limit; + } + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_int(doc, root, "total", vtotal); + yyjson_mut_obj_add_str(doc, root, "search_mode", "semantic"); + emit_vector_results(doc, root, "results", vresults, start, page_count); + yyjson_mut_obj_add_bool(doc, root, "has_more", + semantic_page_has_more(vtotal, offset, page_count)); + char *json = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + if (vresults) { + cbm_store_free_vector_results(vresults, vcount); + } + free(project); + free(label); + free(name_pattern); + free(qn_pattern); + free(file_pattern); + free(relationship); + char *result = cbm_mcp_text_result(json, false); + free(json); + return result; + } + } + if (!legacy_json) { const char *fields[SG_MAX_EXTRA_FIELDS]; yyjson_doc *fields_owner = NULL; @@ -2754,16 +2829,17 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { cbm_vector_result_t *vresults = NULL; int vcount = 0; + int vtotal = 0; bool sq_present = false; - bool sq_type_error = - run_semantic_query_core(args, store, project, limit, &vresults, &vcount, &sq_present); + int semantic_fetch_limit = has_filters ? limit : CBM_VECTOR_SEARCH_CANDIDATE_CAP; + bool sq_type_error = run_semantic_query_core( + args, store, project, semantic_fetch_limit, + has_filters ? CBM_VECTOR_SEARCH_SIDECAR : CBM_VECTOR_SEARCH_PRIMARY, &vresults, &vcount, + has_filters ? NULL : &vtotal, &sq_present); if (!sq_type_error) { /* Semantic-only calls get semantic results only: the legacy * behavior also ran the UNFILTERED regex search and prepended * up to `limit` unrelated enriched nodes to the response. */ - bool has_filters = label || name_pattern || qn_pattern || file_pattern || - relationship || exclude_entry_points || - min_degree != CBM_NOT_FOUND || max_degree != CBM_NOT_FOUND; bool semantic_only = sq_present && !has_filters; cbm_sb_t sb; @@ -2789,14 +2865,28 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { } } } - if (vcount > 0) { - emit_semantic_results_toon(&sb, vresults, vcount); - } else if (semantic_only) { - static const char *const sem_cols[] = {"qn", "label", "file", "score"}; - cbm_toon_table_header(&sb, "semantic", 0, sem_cols, 4); - cbm_toon_scalar_str(&sb, "hint", - "No semantic matches. semantic_query needs a moderate/full " - "index; try broader or fewer keywords."); + if (semantic_only) { + int start = offset > 0 ? offset : 0; + if (start > vcount) { + start = vcount; + } + int page_count = vcount - start; + int page_limit = limit > 0 ? limit : CBM_DEFAULT_SEARCH_LIMIT; + if (page_count > page_limit) { + page_count = page_limit; + } + cbm_toon_scalar_str(&sb, "search_mode", "semantic"); + cbm_toon_scalar_int(&sb, "total", vtotal); + emit_semantic_results_toon(&sb, vresults, start, page_count); + cbm_toon_scalar_bool(&sb, "has_more", + semantic_page_has_more(vtotal, offset, page_count)); + if (vcount == 0) { + cbm_toon_scalar_str(&sb, "hint", + "No semantic matches. semantic_query needs a moderate/full " + "index; try broader or fewer keywords."); + } + } else if (vcount > 0) { + emit_semantic_results_toon(&sb, vresults, 0, vcount); } if (vcount > 0) { cbm_store_free_vector_results(vresults, vcount); diff --git a/src/store/store.c b/src/store/store.c index 112d5ea71..0e75ea8cd 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -11,6 +11,7 @@ #include #include "foundation/constants.h" +#include #include enum { @@ -7114,8 +7115,13 @@ enum { VS_RI_SEED = 0x52494E44, VS_MAX_KW = 32, VS_STR_BUF = 16, + VS_PREFILTER_MULTIPLIER = 5, }; +/* Query retrieval needs a much lower bar than SEMANTICALLY_RELATED edge + * creation, but zero/near-zero cosine candidates are not meaningful matches. */ +#define VS_MIN_MATCH_SCORE 0.10 + /* Try to look up an enriched int8 vector for `token` in the token_vectors * table. On success, writes the de-quantized float representation to * `out` and returns true. */ @@ -7265,11 +7271,29 @@ static cbm_vector_result_t *vs_append_result(cbm_vector_result_t *results, int * return results; } +static int vs_result_score_desc(const void *lhs, const void *rhs) { + const cbm_vector_result_t *a = lhs; + const cbm_vector_result_t *b = rhs; + if (a->score < b->score) { + return 1; + } + if (a->score > b->score) { + return -1; + } + if (a->node_id < b->node_id) { + return -1; + } + return a->node_id > b->node_id; +} + int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords, - int keyword_count, int limit, cbm_vector_result_t **out, - int *out_count) { + int keyword_count, int limit, cbm_vector_search_policy_t policy, + cbm_vector_result_t **out, int *out_count, int *total_count) { *out = NULL; *out_count = 0; + if (total_count) { + *total_count = 0; + } if (!s || !project || !keywords || keyword_count <= 0) { return CBM_STORE_ERR; } @@ -7279,17 +7303,15 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke if (actual_kw == 0) { return CBM_STORE_OK; } - - /* Scan all node vectors, compute per-keyword cosine, take min. - * We use the FIRST keyword as the SQL sort (for top-K pre-filter), - * then re-score with min across all keywords in the append helper. */ + /* Fetch a first-keyword candidate window, then re-score each candidate + * with the minimum cosine across all query keywords. */ const char *sql = "SELECT n.id, n.name, n.qualified_name, n.file_path, n.label," " cbm_cosine_i8(v.vector, ?1) as score, v.vector" " FROM node_vectors v" " INNER JOIN nodes n ON n.id = v.node_id" " WHERE v.project = ?2" " AND n.label IN ('Function','Method','Class')" - " ORDER BY score DESC" + " ORDER BY score DESC, n.id ASC" " LIMIT ?3"; sqlite3_stmt *stmt = NULL; @@ -7299,8 +7321,19 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke return CBM_STORE_ERR; } - /* Use first keyword for SQL pre-filter, fetch more candidates for re-ranking */ - int fetch_limit = (limit > 0 ? limit : CBM_SZ_16) * ST_COL_5; + /* Use the first keyword for a bounded SQL prefilter, then re-rank that + * window with the minimum score across every keyword. */ + int requested_limit = limit > 0 ? limit : CBM_SZ_16; + bool primary_results = policy == CBM_VECTOR_SEARCH_PRIMARY; + if (primary_results && requested_limit > CBM_VECTOR_SEARCH_CANDIDATE_CAP) { + requested_limit = CBM_VECTOR_SEARCH_CANDIDATE_CAP; + } + int fetch_limit = CBM_VECTOR_SEARCH_CANDIDATE_CAP; + if (!primary_results) { + fetch_limit = requested_limit > INT_MAX / VS_PREFILTER_MULTIPLIER + ? INT_MAX + : requested_limit * VS_PREFILTER_MULTIPLIER; + } sqlite3_bind_blob(stmt, SKIP_ONE, kw_vecs[0], VS_VEC_DIM, SQLITE_STATIC); sqlite3_bind_text(stmt, ST_COL_2, project, SQLITE_AUTO_LEN, SQLITE_STATIC); sqlite3_bind_int(stmt, ST_COL_3, fetch_limit); @@ -7325,6 +7358,13 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke break; } results = grown; + if (primary_results && results[count - 1].score < VS_MIN_MATCH_SCORE) { + free(results[count - 1].name); + free(results[count - 1].qualified_name); + free(results[count - 1].file_path); + free(results[count - 1].label); + count--; + } } if (step_rc != SQLITE_DONE) { @@ -7339,19 +7379,18 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke } sqlite3_finalize(stmt); - /* Re-sort by min-score (SQL sorted by first keyword only) */ - for (int i = 0; i < count - SKIP_ONE; i++) { - for (int j = i + SKIP_ONE; j < count; j++) { - if (results[j].score > results[i].score) { - cbm_vector_result_t tmp = results[i]; - results[i] = results[j]; - results[j] = tmp; - } - } + /* Re-sort by min-score (SQL sorted by first keyword only). Node ID is a + * deterministic tie-breaker so repeated pages keep the same ordering. */ + if (count > 1) { + qsort(results, (size_t)count, sizeof(*results), vs_result_score_desc); + } + + if (total_count) { + *total_count = count; } /* Trim to requested limit */ - int final_limit = limit > 0 ? limit : CBM_SZ_16; + int final_limit = requested_limit; if (count > final_limit) { for (int i = final_limit; i < count; i++) { free(results[i].name); @@ -7362,6 +7401,11 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke count = final_limit; } + if (count == 0) { + free(results); + results = NULL; + } + *out = results; *out_count = count; return CBM_STORE_OK; diff --git a/src/store/store.h b/src/store/store.h index 963837f88..4c8f9cb75 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -758,13 +758,26 @@ typedef struct { double score; } cbm_vector_result_t; +/* Maximum first-keyword candidate window scored and re-ranked for paged + * semantic-primary results. */ +enum { CBM_VECTOR_SEARCH_CANDIDATE_CAP = 250 }; + +typedef enum { + CBM_VECTOR_SEARCH_SIDECAR = 0, + CBM_VECTOR_SEARCH_PRIMARY = 1, +} cbm_vector_search_policy_t; + /* Search for nodes similar to the given query keywords using stored RI vectors. - * Builds a merged query vector from the keywords, then does cosine scan via - * the cbm_cosine_i8 SQL function joined with the nodes table. - * Returns results sorted by score DESC. Caller must free with cbm_store_free_vector_results. */ + * Each keyword is scored independently and candidates are ranked by their + * minimum cosine. The first keyword drives a bounded SQL prefilter; the full + * keyword set is applied in memory. Primary-result searches use a hard cap and + * minimum-match threshold; sidecar searches retain the legacy limit*5 window + * without thresholding. total_count reports threshold-qualified primary + * matches in the bounded window. Returns results sorted by score DESC. Caller + * must free with cbm_store_free_vector_results. */ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords, - int keyword_count, int limit, cbm_vector_result_t **out, - int *out_count); + int keyword_count, int limit, cbm_vector_search_policy_t policy, + cbm_vector_result_t **out, int *out_count, int *total_count); /* Free vector search results. */ void cbm_store_free_vector_results(cbm_vector_result_t *results, int count); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 4e1d5eeb3..4cecb6827 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -14,6 +14,7 @@ #include /* spawn-count hook — #845 in-process guard */ #include #include +#include #include #include #include @@ -74,6 +75,18 @@ static void mcp_capture_log(const char *line) { } } +static int semantic915_fetch_limit = -1; + +static void semantic915_capture_vector_log(const char *line) { + if (!line || !strstr(line, "msg=vector_search.exec")) { + return; + } + const char *value = strstr(line, "fetch_limit="); + if (value) { + semantic915_fetch_limit = atoi(value + strlen("fetch_limit=")); + } +} + static bool response_contains_json_fragment(const char *response, const char *fragment) { if (!response || !fragment) { return false; @@ -1298,6 +1311,298 @@ TEST(tool_search_graph_query_honors_file_pattern_issue552) { PASS(); } +static int semantic915_insert_node_vector(sqlite3 *db, int64_t node_id, const char *project, + const int8_t vector[CBM_SEM_DIM]) { + const char *sql = "INSERT INTO node_vectors(node_id, project, vector) VALUES (?1, ?2, ?3)"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + return SQLITE_ERROR; + } + sqlite3_bind_int64(stmt, 1, node_id); + sqlite3_bind_text(stmt, 2, project, -1, SQLITE_STATIC); + sqlite3_bind_blob(stmt, 3, vector, CBM_SEM_DIM, SQLITE_STATIC); + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? SQLITE_OK : rc; +} + +static int semantic915_insert_token_vector(sqlite3 *db, const char *project, const char *token, + const int8_t vector[CBM_SEM_DIM]) { + const char *sql = + "INSERT INTO token_vectors(project, token, vector, idf) VALUES (?1, ?2, ?3, ?4)"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + return SQLITE_ERROR; + } + sqlite3_bind_text(stmt, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, token, -1, SQLITE_STATIC); + sqlite3_bind_blob(stmt, 3, vector, CBM_SEM_DIM, SQLITE_STATIC); + sqlite3_bind_int(stmt, 4, 1); + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? SQLITE_OK : rc; +} + +TEST(tool_search_graph_semantic_only_uses_vector_results_issue915) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-sem915-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + FAIL("mkdtemp cache failed"); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "issue-915-semantic"; + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + + cbm_store_t *st = cbm_store_open_path(db_path); + bool setup_ok = st != NULL; + if (setup_ok && + cbm_store_upsert_project(st, project, "/tmp/issue-915-semantic") != CBM_STORE_OK) { + setup_ok = false; + } + + cbm_node_t registry = {.project = project, + .label = "Function", + .name = "AAARegistryKey", + .qualified_name = "issue915.registry.AAARegistryKey", + .file_path = "registry.c", + .start_line = 1, + .end_line = 3}; + if (setup_ok && cbm_store_upsert_node(st, ®istry) <= 0) { + setup_ok = false; + } + + cbm_node_t debt = {.project = project, + .label = "Function", + .name = "DebtCalculator", + .qualified_name = "issue915.finance.DebtCalculator", + .file_path = "debt.c", + .start_line = 10, + .end_line = 20}; + cbm_node_t resolver = {.project = project, + .label = "Function", + .name = "DebtResolver", + .qualified_name = "issue915.finance.DebtResolver", + .file_path = "resolver.c", + .start_line = 30, + .end_line = 40}; + int64_t debt_id = setup_ok ? cbm_store_upsert_node(st, &debt) : 0; + int64_t resolver_id = setup_ok ? cbm_store_upsert_node(st, &resolver) : 0; + setup_ok = setup_ok && debt_id > 0 && resolver_id > debt_id; + + if (setup_ok) { + setup_ok = + cbm_store_exec(st, "CREATE TABLE node_vectors (node_id INTEGER PRIMARY KEY, " + "project TEXT NOT NULL, vector BLOB NOT NULL)") == CBM_STORE_OK; + } + if (setup_ok) { + setup_ok = + cbm_store_exec(st, "CREATE TABLE token_vectors (id INTEGER PRIMARY KEY, " + "project TEXT NOT NULL, token TEXT NOT NULL, " + "vector BLOB NOT NULL, idf INTEGER NOT NULL)") == CBM_STORE_OK; + } + + int8_t debt_vec[CBM_SEM_DIM] = {0}; + debt_vec[0] = 127; + if (setup_ok) { + setup_ok = semantic915_insert_token_vector(cbm_store_get_db(st), project, "debt", + debt_vec) == SQLITE_OK; + } + if (setup_ok) { + setup_ok = semantic915_insert_node_vector(cbm_store_get_db(st), debt_id, project, + debt_vec) == SQLITE_OK; + } + if (setup_ok) { + setup_ok = semantic915_insert_node_vector(cbm_store_get_db(st), resolver_id, project, + debt_vec) == SQLITE_OK; + } + + /* More candidates than the hard window, all below the query threshold. */ + int8_t tail_vec[CBM_SEM_DIM] = {0}; + tail_vec[0] = 1; + tail_vec[1] = 127; + for (int i = 0; setup_ok && i < 260; i++) { + char name[32]; + char qn[64]; + char file[32]; + snprintf(name, sizeof(name), "NearZeroTail%03d", i); + snprintf(qn, sizeof(qn), "issue915.tail.%s", name); + snprintf(file, sizeof(file), "tail_%03d.c", i); + cbm_node_t tail = {.project = project, + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = file, + .start_line = i + 1, + .end_line = i + 1}; + int64_t tail_id = cbm_store_upsert_node(st, &tail); + setup_ok = tail_id > 0 && semantic915_insert_node_vector(cbm_store_get_db(st), tail_id, + project, tail_vec) == SQLITE_OK; + } + if (st) { + (void)cbm_store_checkpoint(st); + cbm_store_close(st); + } + if (!setup_ok) { + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + FAIL("semantic fixture setup failed"); + } + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + CBMLogLevel previous_log_level = cbm_log_get_level(); + semantic915_fetch_limit = -1; + cbm_log_set_level(CBM_LOG_INFO); + cbm_log_set_sink_ex(semantic915_capture_vector_log, CBM_LOG_SINK_REPLACE); + char *resp = + cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\"]," + "\"format\":\"json\",\"limit\":1,\"offset\":0}"); + cbm_log_set_sink(NULL); + cbm_log_set_level(previous_log_level); + int primary_fetch_limit = semantic915_fetch_limit; + + char *inner = extract_text_content(resp); + bool first_page_ok = + inner && response_contains_json_fragment(inner, "\"search_mode\":\"semantic\"") && + response_contains_json_fragment(inner, "\"total\":2") && + response_contains_json_fragment(inner, "\"has_more\":true") && + response_contains_json_fragment(inner, "\"name\":\"DebtCalculator\"") && + strstr(inner, "DebtResolver") == NULL && strstr(inner, "NearZeroTail") == NULL && + strstr(inner, "AAARegistryKey") == NULL && strstr(inner, "semantic_results") == NULL; + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\"]," + "\"format\":\"json\",\"limit\":1,\"offset\":1}"); + inner = extract_text_content(resp); + bool second_page_ok = inner && response_contains_json_fragment(inner, "\"total\":2") && + response_contains_json_fragment(inner, "\"has_more\":false") && + response_contains_json_fragment(inner, "\"name\":\"DebtResolver\"") && + strstr(inner, "DebtCalculator") == NULL; + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\"]," + "\"format\":\"json\",\"limit\":1,\"offset\":2}"); + inner = extract_text_content(resp); + bool exhausted_page_ok = inner && response_contains_json_fragment(inner, "\"total\":2") && + response_contains_json_fragment(inner, "\"results\":[]") && + response_contains_json_fragment(inner, "\"has_more\":false"); + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\"]," + "\"limit\":1,\"offset\":1}"); + inner = extract_text_content(resp); + bool toon_page_ok = inner && strstr(inner, "search_mode: semantic") && + strstr(inner, "total: 2") && strstr(inner, "semantic[1]") && + strstr(inner, "DebtResolver") && strstr(inner, "has_more: false") && + strstr(inner, "results[") == NULL && strstr(inner, "NearZeroTail") == NULL; + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"label\":\"\"," + "\"semantic_query\":[\"debt\"],\"format\":\"json\",\"limit\":1}"); + inner = extract_text_content(resp); + bool empty_label_omitted_ok = + inner && response_contains_json_fragment(inner, "\"search_mode\":\"semantic\"") && + response_contains_json_fragment(inner, "\"total\":2") && + response_contains_json_fragment(inner, "\"name\":\"DebtCalculator\"") && + strstr(inner, "semantic_results") == NULL && strstr(inner, "AAARegistryKey") == NULL; + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"label\":\"\"," + "\"semantic_query\":[\"debt\"],\"limit\":1}"); + inner = extract_text_content(resp); + bool empty_label_toon_ok = inner && strstr(inner, "search_mode: semantic") && + strstr(inner, "total: 2") && strstr(inner, "semantic[1]") && + strstr(inner, "DebtCalculator") && strstr(inner, "results[") == NULL; + free(inner); + free(resp); + + semantic915_fetch_limit = -1; + cbm_log_set_level(CBM_LOG_INFO); + cbm_log_set_sink_ex(semantic915_capture_vector_log, CBM_LOG_SINK_REPLACE); + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"label\":\"Function\"," + "\"semantic_query\":[\"debt\"],\"format\":\"json\",\"limit\":60}"); + cbm_log_set_sink(NULL); + cbm_log_set_level(previous_log_level); + int combined_fetch_limit = semantic915_fetch_limit; + inner = extract_text_content(resp); + yyjson_doc *combined_doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL; + yyjson_val *combined_root = combined_doc ? yyjson_doc_get_root(combined_doc) : NULL; + yyjson_val *semantic_results = + combined_root ? yyjson_obj_get(combined_root, "semantic_results") : NULL; + bool combined_has_tail = false; + size_t combined_idx = 0; + size_t combined_max = 0; + yyjson_val *combined_item; + yyjson_arr_foreach(semantic_results, combined_idx, combined_max, combined_item) { + const char *name = yyjson_get_str(yyjson_obj_get(combined_item, "name")); + if (name && strncmp(name, "NearZeroTail", strlen("NearZeroTail")) == 0) { + combined_has_tail = true; + } + } + bool combined_ok = semantic_results && yyjson_is_arr(semantic_results) && + yyjson_arr_size(semantic_results) == 60 && combined_has_tail; + if (combined_doc) { + yyjson_doc_free(combined_doc); + } + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":[\"debt\"]," + "\"format\":\"json\",\"limit\":1,\"offset\":2147483647}"); + inner = extract_text_content(resp); + bool large_offset_ok = inner && response_contains_json_fragment(inner, "\"results\":[]") && + response_contains_json_fragment(inner, "\"has_more\":false"); + free(inner); + free(resp); + + resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"issue-915-semantic\",\"semantic_query\":\"debt\",\"limit\":6}"); + bool type_error_unchanged = + resp && strstr(resp, "semantic_query must be an array of keyword strings") != NULL; + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + + ASSERT_EQ(primary_fetch_limit, CBM_VECTOR_SEARCH_CANDIDATE_CAP); + ASSERT_EQ(combined_fetch_limit, 300); + ASSERT_TRUE(first_page_ok); + ASSERT_TRUE(second_page_ok); + ASSERT_TRUE(exhausted_page_ok); + ASSERT_TRUE(toon_page_ok); + ASSERT_TRUE(empty_label_omitted_ok); + ASSERT_TRUE(empty_label_toon_ok); + ASSERT_TRUE(combined_ok); + ASSERT_TRUE(large_offset_ok); + ASSERT_TRUE(type_error_unchanged); + PASS(); +} + /* Resource discovery methods this server doesn't populate must return EMPTY * lists, not -32601 Method-not-found: clients like Cline probe them on connect * and surface the errors as a failed connection (#958). */ @@ -6406,6 +6711,7 @@ SUITE(mcp) { RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields); RUN_TEST(tool_output_byte_budgets); RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552); + RUN_TEST(tool_search_graph_semantic_only_uses_vector_results_issue915); RUN_TEST(mcp_resource_discovery_methods_return_empty_lists); RUN_TEST(tool_query_graph_basic); RUN_TEST(tool_index_status_no_project);