Skip to content

Commit f895152

Browse files
authored
Merge branch 'main' into fix/test-wave-atomic-barrier-writes
2 parents 3b9052c + 7b0f553 commit f895152

4 files changed

Lines changed: 258 additions & 57 deletions

File tree

‎src/mcp/mcp.c‎

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4195,18 +4195,19 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
41954195
}
41964196

41974197
/* Extract keyword strings from a yyjson array into `keywords`. Returns the
4198-
* number of strings copied (capped at `max_out`). */
4198+
* number of strings copied (capped at `max_out`), or -1 when any element is
4199+
* not a string: a mixed-type array is a caller error and is never silently
4200+
* narrowed to its string members. */
41994201
static int extract_semantic_keywords(yyjson_val *sq_val, const char **keywords, int max_out) {
4200-
int kw_count = (int)yyjson_arr_size(sq_val);
4201-
if (kw_count > max_out) {
4202-
kw_count = max_out;
4203-
}
42044202
size_t kw_idx = 0;
42054203
size_t kw_max = 0;
42064204
yyjson_val *kw_val;
42074205
int ki = 0;
42084206
yyjson_arr_foreach(sq_val, kw_idx, kw_max, kw_val) {
4209-
if (ki < kw_count && yyjson_is_str(kw_val)) {
4207+
if (!yyjson_is_str(kw_val)) {
4208+
return -1;
4209+
}
4210+
if (ki < max_out) {
42104211
keywords[ki++] = yyjson_get_str(kw_val);
42114212
}
42124213
}
@@ -4237,13 +4238,20 @@ static void emit_semantic_results(yyjson_mut_doc *doc, yyjson_mut_val *root,
42374238
yyjson_mut_obj_add_val(doc, root, "semantic", sem);
42384239
}
42394240

4241+
typedef enum {
4242+
SQ_RUN_OK = 0,
4243+
SQ_RUN_TYPE_ERROR, /* semantic_query is not an array of strings */
4244+
SQ_RUN_STORE_ERROR, /* the vector scan itself failed */
4245+
} sq_run_status_t;
4246+
42404247
/* Run the semantic_query vector search from raw args. Sets *out_vresults /
42414248
* *out_vcount (caller frees via cbm_store_free_vector_results when vcount>0).
4242-
* Returns true if semantic_query was provided as a non-array (type error —
4243-
* caller should surface to the user). */
4244-
static bool run_semantic_query_core(const char *args, cbm_store_t *store, const char *project,
4245-
int materialize_limit, cbm_vector_result_t **out_vresults,
4246-
int *out_vcount, bool *out_present) {
4249+
* A store without a vector table (lean index) is an empty result; a failed
4250+
* scan is SQ_RUN_STORE_ERROR and must never be rendered as zero matches. */
4251+
static sq_run_status_t run_semantic_query_core(const char *args, cbm_store_t *store,
4252+
const char *project, int materialize_limit,
4253+
cbm_vector_result_t **out_vresults, int *out_vcount,
4254+
bool *out_present) {
42474255
enum { MAX_KW_SEARCH = 32 };
42484256
*out_vresults = NULL;
42494257
*out_vcount = 0;
@@ -4256,25 +4264,31 @@ static bool run_semantic_query_core(const char *args, cbm_store_t *store, const
42564264
if (out_present && sq_val) {
42574265
*out_present = true;
42584266
}
4259-
bool type_error = false;
4267+
sq_run_status_t status = SQ_RUN_OK;
42604268
if (sq_val && !yyjson_is_arr(sq_val)) {
4261-
type_error = true;
4269+
status = SQ_RUN_TYPE_ERROR;
42624270
} else if (sq_val && yyjson_arr_size(sq_val) > 0) {
42634271
const char *keywords[MAX_KW_SEARCH];
42644272
int ki = extract_semantic_keywords(sq_val, keywords, MAX_KW_SEARCH);
4265-
cbm_vector_result_t *vresults = NULL;
4266-
int vcount = 0;
4267-
if (cbm_store_vector_search(store, project, keywords, ki, materialize_limit, &vresults,
4268-
&vcount) == CBM_STORE_OK &&
4269-
vcount > 0) {
4270-
*out_vresults = vresults;
4271-
*out_vcount = vcount;
4273+
if (ki < 0) {
4274+
status = SQ_RUN_TYPE_ERROR;
4275+
} else {
4276+
cbm_vector_result_t *vresults = NULL;
4277+
int vcount = 0;
4278+
int rc = cbm_store_vector_search(store, project, keywords, ki, materialize_limit,
4279+
&vresults, &vcount);
4280+
if (rc == CBM_STORE_ERR) {
4281+
status = SQ_RUN_STORE_ERROR;
4282+
} else if (rc == CBM_STORE_OK && vcount > 0) {
4283+
*out_vresults = vresults;
4284+
*out_vcount = vcount;
4285+
}
42724286
}
42734287
}
42744288
if (args_doc) {
42754289
yyjson_doc_free(args_doc);
42764290
}
4277-
return type_error;
4291+
return status;
42784292
}
42794293

42804294
static bool search_graph_arg_present(const char *args, const char *name) {
@@ -5134,9 +5148,9 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
51345148
cbm_vector_result_t *vresults = NULL;
51355149
int vcount = 0;
51365150
bool sq_present = false;
5137-
bool sq_type_error = run_semantic_query_core(args, store, project, semantic_materialize_limit,
5138-
&vresults, &vcount, &sq_present);
5139-
if (sq_type_error) {
5151+
sq_run_status_t sq_status = run_semantic_query_core(
5152+
args, store, project, semantic_materialize_limit, &vresults, &vcount, &sq_present);
5153+
if (sq_status != SQ_RUN_OK) {
51405154
if (fields_owner) {
51415155
yyjson_doc_free(fields_owner);
51425156
}
@@ -5146,11 +5160,17 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
51465160
free(qn_pattern);
51475161
free(file_pattern);
51485162
free(relationship);
5163+
if (sq_status == SQ_RUN_STORE_ERROR) {
5164+
return cbm_mcp_text_result(
5165+
"semantic search failed: the vector index could not be scanned — see the "
5166+
"server log for the SQLite error; reindex the project if it persists.",
5167+
true);
5168+
}
51495169
return cbm_mcp_text_result(
51505170
"semantic_query must be an array of keyword strings, e.g. "
5151-
"[\"send\",\"pubsub\",\"publish\"] — not a single string. Split your query "
5152-
"into individual keywords; each is scored independently via per-keyword "
5153-
"min-cosine.",
5171+
"[\"send\",\"pubsub\",\"publish\"] — not a single string, and every element "
5172+
"must be a string. Split your query into individual keywords; each is scored "
5173+
"independently via per-keyword min-cosine.",
51545174
true);
51555175
}
51565176

‎src/store/store.c‎

Lines changed: 85 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10018,25 +10018,42 @@ static int vs_build_keyword_vectors(cbm_store_t *s, const char *project, const c
1001810018
return actual_kw;
1001910019
}
1002010020

10021+
/* L2 norm of every quantized keyword vector, computed once per search
10022+
* instead of once per (node, keyword) pair inside the scoring loop. */
10023+
static void vs_keyword_norms(const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw, double *kw_norms) {
10024+
for (int k = 0; k < actual_kw; k++) {
10025+
int32_t ma = 0;
10026+
for (int d = 0; d < VS_VEC_DIM; d++) {
10027+
ma += (int32_t)kw_vecs[k][d] * (int32_t)kw_vecs[k][d];
10028+
}
10029+
kw_norms[k] = sqrt((double)ma);
10030+
}
10031+
}
10032+
1002110033
/* Compute the per-keyword min cosine score between a node's int8 vector and
10022-
* each of the query vectors. Returns 0.0 if the node vector is unavailable
10023-
* or mis-sized. */
10034+
* each of the query vectors. `kw_norms[k]` is the precomputed L2 norm of
10035+
* kw_vecs[k]; the node norm is computed once per node. The integer sums are
10036+
* exact, so factoring the norms out of the pair loop leaves every score
10037+
* bit-identical to the fused form. Returns 0.0 if the node vector is
10038+
* unavailable or mis-sized. */
1002410039
static double vs_min_cosine_score(const int8_t *node_vec, int node_vec_len,
10025-
const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw) {
10040+
const int8_t (*kw_vecs)[VS_VEC_DIM], const double *kw_norms,
10041+
int actual_kw) {
1002610042
if (!node_vec || node_vec_len != VS_VEC_DIM) {
1002710043
return 0.0;
1002810044
}
10045+
int32_t mb = 0;
10046+
for (int d = 0; d < VS_VEC_DIM; d++) {
10047+
mb += (int32_t)node_vec[d] * (int32_t)node_vec[d];
10048+
}
10049+
double node_norm = sqrt((double)mb);
1002910050
double min_score = CBM_STORE_UNIT_POS_D;
1003010051
for (int k = 0; k < actual_kw; k++) {
1003110052
int32_t dot = 0;
10032-
int32_t ma = 0;
10033-
int32_t mb = 0;
1003410053
for (int d = 0; d < VS_VEC_DIM; d++) {
1003510054
dot += (int32_t)kw_vecs[k][d] * (int32_t)node_vec[d];
10036-
ma += (int32_t)kw_vecs[k][d] * (int32_t)kw_vecs[k][d];
10037-
mb += (int32_t)node_vec[d] * (int32_t)node_vec[d];
1003810055
}
10039-
double denom = sqrt((double)ma) * sqrt((double)mb);
10056+
double denom = kw_norms[k] * node_norm;
1004010057
double cos_k = denom > CBM_STORE_DENOM_EPS_D ? (double)dot / denom : 0.0;
1004110058
if (cos_k < min_score) {
1004210059
min_score = cos_k;
@@ -10045,35 +10062,69 @@ static double vs_min_cosine_score(const int8_t *node_vec, int node_vec_len,
1004510062
return min_score;
1004610063
}
1004710064

10048-
/* Append one candidate row read from the scan statement into the result
10049-
* vector. Grows the results array geometrically on demand. Returns the
10050-
* (possibly grown) results pointer, or NULL on allocation failure. */
10051-
static cbm_vector_result_t *vs_append_result(cbm_vector_result_t *results, int *count, int *cap,
10052-
sqlite3_stmt *stmt,
10053-
const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw) {
10065+
/* Append one candidate row read from the scan statement into `*results`,
10066+
* growing the array geometrically on demand. Returns CBM_STORE_OK, or
10067+
* CBM_STORE_ERR on allocation failure — in which case `*results` still owns
10068+
* exactly `*count` complete rows (a row is only counted once every string
10069+
* copy succeeded), so the caller's usual free path stays valid. */
10070+
static int vs_append_result(cbm_vector_result_t **results, int *count, int *cap, sqlite3_stmt *stmt,
10071+
const int8_t (*kw_vecs)[VS_VEC_DIM], const double *kw_norms,
10072+
int actual_kw) {
1005410073
if (*count >= *cap) {
1005510074
int nc = *cap < CBM_SZ_16 ? CBM_SZ_16 : *cap * ST_COL_2;
10056-
cbm_vector_result_t *grown = realloc(results, (size_t)nc * sizeof(cbm_vector_result_t));
10075+
cbm_vector_result_t *grown = realloc(*results, (size_t)nc * sizeof(cbm_vector_result_t));
1005710076
if (!grown) {
10058-
return NULL;
10077+
return CBM_STORE_ERR;
1005910078
}
10060-
results = grown;
10079+
*results = grown;
1006110080
*cap = nc;
1006210081
}
10063-
int idx = (*count)++;
10064-
results[idx].node_id = sqlite3_column_int64(stmt, 0);
10082+
cbm_vector_result_t *row = &(*results)[*count];
10083+
row->node_id = sqlite3_column_int64(stmt, 0);
1006510084
const char *name = (const char *)sqlite3_column_text(stmt, SKIP_ONE);
1006610085
const char *qn = (const char *)sqlite3_column_text(stmt, ST_COL_2);
1006710086
const char *fp = (const char *)sqlite3_column_text(stmt, ST_COL_3);
1006810087
const char *label = (const char *)sqlite3_column_text(stmt, ST_COL_4);
10069-
results[idx].name = name ? strdup(name) : strdup("");
10070-
results[idx].qualified_name = qn ? strdup(qn) : strdup("");
10071-
results[idx].file_path = fp ? strdup(fp) : strdup("");
10072-
results[idx].label = label ? strdup(label) : strdup("");
10088+
row->name = strdup(name ? name : "");
10089+
row->qualified_name = strdup(qn ? qn : "");
10090+
row->file_path = strdup(fp ? fp : "");
10091+
row->label = strdup(label ? label : "");
10092+
if (!row->name || !row->qualified_name || !row->file_path || !row->label) {
10093+
free(row->name);
10094+
free(row->qualified_name);
10095+
free(row->file_path);
10096+
free(row->label);
10097+
return CBM_STORE_ERR;
10098+
}
1007310099
const int8_t *node_vec = (const int8_t *)sqlite3_column_blob(stmt, ST_COL_6);
1007410100
int node_vec_len = sqlite3_column_bytes(stmt, ST_COL_6);
10075-
results[idx].score = vs_min_cosine_score(node_vec, node_vec_len, kw_vecs, actual_kw);
10076-
return results;
10101+
row->score = vs_min_cosine_score(node_vec, node_vec_len, kw_vecs, kw_norms, actual_kw);
10102+
(*count)++;
10103+
return CBM_STORE_OK;
10104+
}
10105+
10106+
/* A lean index carries no node_vectors table at all. That is an empty
10107+
* semantic universe, not a scan failure, and callers must be able to tell
10108+
* the two apart — so probe the schema before preparing the scan. Returns
10109+
* CBM_STORE_OK (present), CBM_STORE_NOT_FOUND (absent) or CBM_STORE_ERR. */
10110+
static int vs_probe_node_vectors_table(cbm_store_t *s) {
10111+
sqlite3_stmt *probe = NULL;
10112+
const char *sql =
10113+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_vectors' LIMIT 1;";
10114+
if (sqlite3_prepare_v2(s->db, sql, SQLITE_AUTO_LEN, &probe, NULL) != SQLITE_OK) {
10115+
(void)fprintf(stderr, "vector_search: %s\n", sqlite3_errmsg(s->db));
10116+
return CBM_STORE_ERR;
10117+
}
10118+
int step_rc = sqlite3_step(probe);
10119+
sqlite3_finalize(probe);
10120+
if (step_rc == SQLITE_ROW) {
10121+
return CBM_STORE_OK;
10122+
}
10123+
if (step_rc == SQLITE_DONE) {
10124+
return CBM_STORE_NOT_FOUND;
10125+
}
10126+
(void)fprintf(stderr, "vector_search: %s\n", sqlite3_errmsg(s->db));
10127+
return CBM_STORE_ERR;
1007710128
}
1007810129

1007910130
static int vs_ranked_result_cmp(const void *lhs, const void *rhs) {
@@ -10097,11 +10148,18 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke
1009710148
return CBM_STORE_ERR;
1009810149
}
1009910150

10151+
int probe_rc = vs_probe_node_vectors_table(s);
10152+
if (probe_rc != CBM_STORE_OK) {
10153+
return probe_rc;
10154+
}
10155+
1010010156
int8_t kw_vecs[VS_MAX_KW][VS_VEC_DIM];
1010110157
int actual_kw = vs_build_keyword_vectors(s, project, keywords, keyword_count, kw_vecs);
1010210158
if (actual_kw == 0) {
1010310159
return CBM_STORE_OK;
1010410160
}
10161+
double kw_norms[VS_MAX_KW];
10162+
vs_keyword_norms(kw_vecs, actual_kw, kw_norms);
1010510163

1010610164
/* Use the first keyword for a cheap candidate ordering, then score each
1010710165
* materialized candidate by min-cosine across every keyword. */
@@ -10141,13 +10199,11 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke
1014110199
sqlite3_bind_int(stmt, ST_COL_3, fetch_limit);
1014210200
while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
1014310201
omitted_score_ceiling = sqlite3_column_double(stmt, ST_COL_5);
10144-
cbm_vector_result_t *grown =
10145-
vs_append_result(results, &count, &cap, stmt, kw_vecs, actual_kw);
10146-
if (!grown) {
10202+
if (vs_append_result(&results, &count, &cap, stmt, kw_vecs, kw_norms, actual_kw) !=
10203+
CBM_STORE_OK) {
1014710204
step_rc = SQLITE_NOMEM;
1014810205
break;
1014910206
}
10150-
results = grown;
1015110207
}
1015210208
if (step_rc != SQLITE_DONE) {
1015310209
char rc_buf[VS_STR_BUF];

‎src/store/store.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1048,7 +1048,11 @@ typedef struct {
10481048
/* Search for nodes similar to the given query keywords using stored RI vectors.
10491049
* Builds a merged query vector from the keywords, then does cosine scan via
10501050
* the cbm_cosine_i8 SQL function joined with the nodes table.
1051-
* Returns results sorted by score DESC. Caller must free with cbm_store_free_vector_results. */
1051+
* Returns CBM_STORE_OK with results sorted by score DESC (possibly zero),
1052+
* CBM_STORE_NOT_FOUND when the store carries no node_vectors table (lean
1053+
* index — an empty universe, not a fault), or CBM_STORE_ERR when the scan
1054+
* itself failed; callers must not render CBM_STORE_ERR as zero matches.
1055+
* Caller must free with cbm_store_free_vector_results. */
10521056
int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords,
10531057
int keyword_count, int limit, cbm_vector_result_t **out,
10541058
int *out_count);

0 commit comments

Comments
 (0)