diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 1b88b62e0..53b8ea350 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -93,6 +93,30 @@ static void add_pagerank_val(yyjson_mut_doc *doc, yyjson_mut_val *obj, double v) yyjson_mut_obj_add_val(doc, obj, "pagerank", yyjson_mut_rawcpy(doc, buf)); } +static void add_derived_freshness_warnings(yyjson_mut_doc *doc, yyjson_mut_val *root, + bool pagerank_stale, bool linkrank_stale, + bool node_degree_stale) { + yyjson_mut_val *warnings = yyjson_mut_arr(doc); + if (pagerank_stale) { + yyjson_mut_arr_add_str( + doc, warnings, + "pagerank derived view is stale; stale PageRank values were omitted."); + } + if (linkrank_stale) { + yyjson_mut_arr_add_str( + doc, warnings, + "linkrank derived view is stale; stale LinkRank ordering was not used."); + } + if (node_degree_stale) { + yyjson_mut_arr_add_str( + doc, warnings, + "node_degree derived view is stale; precomputed degree data was not used."); + } + if (yyjson_mut_arr_size(warnings) > 0) { + yyjson_mut_obj_add_val(doc, root, "warnings", warnings); + } +} + /* Default snippet fallback line count (when end_line unknown) */ #define SNIPPET_DEFAULT_LINES 50 @@ -3235,6 +3259,8 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { /* Auto-context: first response gets full architecture/schema/_context header. * Subsequent responses just get session_project. */ inject_context_once(doc, root, srv, store); + add_derived_freshness_warnings(doc, root, out.pagerank_stale, out.linkrank_stale, + out.node_degree_stale); if (is_summary) { /* Summary mode: aggregate counts by label and file (top 20) */ @@ -4542,6 +4568,13 @@ static char *handle_trace_path(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_int(doc, root, "callers_total", tr_in.visited_count); } + add_derived_freshness_warnings(doc, root, + (do_outbound && tr_out.pagerank_stale) || + (do_inbound && tr_in.pagerank_stale), + (do_outbound && tr_out.linkrank_stale) || + (do_inbound && tr_in.linkrank_stale), + false); + if (srv->session_project[0]) yyjson_mut_obj_add_str(doc, root, "session_project", srv->session_project); diff --git a/src/pagerank/pagerank.c b/src/pagerank/pagerank.c index 2260c0aa1..febcafd68 100644 --- a/src/pagerank/pagerank.c +++ b/src/pagerank/pagerank.c @@ -198,6 +198,9 @@ int cbm_pagerank_compute(cbm_store_t *store, const char *project, double *w_in = NULL; id_map_t map = {0}; int N = 0, E = 0, result = -1; + bool pagerank_written = false; + bool linkrank_written = false; + bool node_degree_written = false; char **node_labels = NULL; /* label per node, parallel to node_ids */ char **node_projects = NULL; /* owning project per node, parallel to node_ids */ @@ -413,12 +416,15 @@ int cbm_pagerank_compute(cbm_store_t *store, const char *project, "VALUES (?1, ?2, ?3, ?4)"; sqlite3_stmt *ins_stmt = NULL; if (sqlite3_prepare_v2(db, ins_sql, -1, &ins_stmt, NULL) == SQLITE_OK) { + pagerank_written = true; for (int i = 0; i < N; i++) { sqlite3_bind_int64(ins_stmt, 1, node_ids[i]); sqlite3_bind_text(ins_stmt, 2, node_projects[i], -1, SQLITE_TRANSIENT); sqlite3_bind_double(ins_stmt, 3, rank[i]); sqlite3_bind_text(ins_stmt, 4, ts, -1, SQLITE_TRANSIENT); - sqlite3_step(ins_stmt); + if (sqlite3_step(ins_stmt) != SQLITE_DONE) { + pagerank_written = false; + } sqlite3_reset(ins_stmt); } sqlite3_finalize(ins_stmt); @@ -441,6 +447,7 @@ int cbm_pagerank_compute(cbm_store_t *store, const char *project, "VALUES (?1, ?2, ?3, ?4)"; sqlite3_stmt *lr_stmt = NULL; if (sqlite3_prepare_v2(db, lr_sql, -1, &lr_stmt, NULL) == SQLITE_OK) { + linkrank_written = true; sqlite3_exec(db, "BEGIN", NULL, NULL, NULL); for (int e = 0; e < E; e++) { int s_idx = edges[e].src_idx; @@ -451,7 +458,9 @@ int cbm_pagerank_compute(cbm_store_t *store, const char *project, sqlite3_bind_text(lr_stmt, 2, edges[e].project, -1, SQLITE_TRANSIENT); sqlite3_bind_double(lr_stmt, 3, lr); sqlite3_bind_text(lr_stmt, 4, ts, -1, SQLITE_TRANSIENT); - sqlite3_step(lr_stmt); + if (sqlite3_step(lr_stmt) != SQLITE_DONE) { + linkrank_written = false; + } sqlite3_reset(lr_stmt); } sqlite3_exec(db, "COMMIT", NULL, NULL, NULL); @@ -489,6 +498,7 @@ int cbm_pagerank_compute(cbm_store_t *store, const char *project, "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"; sqlite3_stmt *deg_stmt = NULL; if (sqlite3_prepare_v2(db, deg_sql, -1, °_stmt, NULL) == SQLITE_OK) { + node_degree_written = true; for (int i = 0; i < N; i++) { sqlite3_bind_int64(deg_stmt, 1, node_ids[i]); sqlite3_bind_text(deg_stmt, 2, node_projects[i], -1, SQLITE_TRANSIENT); @@ -500,7 +510,9 @@ int cbm_pagerank_compute(cbm_store_t *store, const char *project, sqlite3_bind_double(deg_stmt, 8, out_weight[i]); sqlite3_bind_double(deg_stmt, 9, lr_in ? lr_in[i] : 0.0); sqlite3_bind_text(deg_stmt, 10, ts, -1, SQLITE_TRANSIENT); - sqlite3_step(deg_stmt); + if (sqlite3_step(deg_stmt) != SQLITE_DONE) { + node_degree_written = false; + } sqlite3_reset(deg_stmt); } sqlite3_finalize(deg_stmt); @@ -517,6 +529,22 @@ int cbm_pagerank_compute(cbm_store_t *store, const char *project, cbm_log_info("pagerank.done", "project", project, "nodes", n_s, "edges", e_s, "iterations", iter_s); + if (pagerank_written) { + (void)cbm_store_set_derived_view_state(store, project, CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_COMPLETE); + } + if (linkrank_written) { + (void)cbm_store_set_derived_view_state(store, project, CBM_STORE_DERIVED_VIEW_LINKRANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_COMPLETE); + } + if (node_degree_written) { + (void)cbm_store_set_derived_view_state(store, project, CBM_STORE_DERIVED_VIEW_NODE_DEGREE, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_COMPLETE); + } + result = N; cleanup: diff --git a/src/store/store.c b/src/store/store.c index 6109a3f88..46888d4b7 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -2528,7 +2528,8 @@ int cbm_store_set_derived_view_state(cbm_store_t *s, const char *project, const char *view_name, int64_t generation, const char *status) { if (!s || !s->db || !project || !project[0] || !view_name || !view_name[0] || - generation <= 0 || !store_derived_status_valid(status)) { + generation < CBM_STORE_DERIVED_GENERATION_UNKNOWN || + !store_derived_status_valid(status)) { if (s) { store_set_error(s, "set_derived_view_state: invalid argument"); } @@ -2555,7 +2556,8 @@ int cbm_store_set_derived_view_state(cbm_store_t *s, const char *project, int cbm_store_mark_derived_views_stale(cbm_store_t *s, const char *project, int64_t generation, const char *const *view_names, int view_count) { - if (!s || !s->db || !project || !project[0] || generation <= 0 || view_count < 0 || + if (!s || !s->db || !project || !project[0] || + generation < CBM_STORE_DERIVED_GENERATION_UNKNOWN || view_count < 0 || (view_count > 0 && !view_names)) { if (s) { store_set_error(s, "mark_derived_views_stale: invalid argument"); @@ -2631,6 +2633,18 @@ int cbm_store_get_derived_view_state(cbm_store_t *s, const char *project, return CBM_STORE_OK; } +bool cbm_store_derived_view_is_stale(cbm_store_t *s, const char *project, + const char *view_name) { + cbm_derived_view_state_t state = {0}; + int rc = cbm_store_get_derived_view_state(s, project, view_name, &state); + if (rc != CBM_STORE_OK) { + return false; + } + bool stale = state.status && strcmp(state.status, CBM_STORE_DERIVED_STATUS_STALE) == 0; + cbm_store_derived_view_state_free_fields(&state); + return stale; +} + int cbm_store_reserve_index_generation(cbm_store_t *s, const char *project, const char *repo_fingerprint, const char *config_fingerprint, @@ -3981,14 +3995,27 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear char count_sql[CBM_SZ_4K]; int bind_idx = 0; - /* Conditionally join pagerank table only when sort_by is relevance. - * Avoids JOIN overhead for name/degree sorts. */ - bool use_pagerank = (!params->sort_by || - strcmp(params->sort_by, "relevance") == 0); + const char *freshness_project = + (params->project && params->project[0] && !params->project_pattern) ? params->project : NULL; + out->pagerank_stale = + freshness_project && + cbm_store_derived_view_is_stale(s, freshness_project, CBM_STORE_DERIVED_VIEW_PAGERANK); + out->linkrank_stale = + freshness_project && + cbm_store_derived_view_is_stale(s, freshness_project, CBM_STORE_DERIVED_VIEW_LINKRANK); + out->node_degree_stale = + freshness_project && + cbm_store_derived_view_is_stale(s, freshness_project, CBM_STORE_DERIVED_VIEW_NODE_DEGREE); + + /* Conditionally join pagerank table only when sort_by is relevance and the + * freshness ledger has not explicitly marked PageRank stale. Missing ledger + * rows are treated as legacy-compatible, so older indexes keep their rank data. */ + bool use_pagerank = + (!params->sort_by || strcmp(params->sort_by, "relevance") == 0) && !out->pagerank_stale; /* DF-1: Use precomputed node_degree table when available (O(1) JOIN vs O(|E|) subquery). * HC-6: Falls back to edge COUNT when node_degree is empty. */ bool has_degree_table = false; - { + if (!out->node_degree_stale) { sqlite3_stmt *check = NULL; if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM node_degree LIMIT 1", -1, &check, NULL) == SQLITE_OK) { @@ -4137,7 +4164,7 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear dep_last, name_col, id_col, limit, offset); } } else if (params->sort_by && strcmp(params->sort_by, "linkrank") == 0) { - if (has_degree_table && !has_degree_filter) { + if (has_degree_table && !out->linkrank_stale && !has_degree_filter) { snprintf(order_limit, sizeof(order_limit), " ORDER BY COALESCE(nd.linkrank_in, 0) DESC, %s%s, %s LIMIT %d OFFSET %d", dep_last, name_col, id_col, limit, offset); @@ -4247,6 +4274,15 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const return rc; } out->root = root; + const char *freshness_project = root.project && root.project[0] ? root.project : NULL; + out->pagerank_stale = + freshness_project && + cbm_store_derived_view_is_stale(s, freshness_project, CBM_STORE_DERIVED_VIEW_PAGERANK); + out->linkrank_stale = + freshness_project && + cbm_store_derived_view_is_stale(s, freshness_project, CBM_STORE_DERIVED_VIEW_LINKRANK); + bool use_pagerank = !out->pagerank_stale; + bool use_linkrank = !out->linkrank_stale; /* MERGE: fork delta — build edge type IN clause with ?N parameterized * placeholders and cap at 16 (bfs_et_count) so the bind loop and clause @@ -4286,6 +4322,10 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const next_id = "e.target_id"; } + const char *pagerank_select = + use_pagerank ? "COALESCE(pr.rank, 0.0) AS pr_rank " : "0.0 AS pr_rank "; + const char *pagerank_join = use_pagerank ? "LEFT JOIN pagerank pr ON pr.node_id = n.id " : ""; + const char *pagerank_order = use_pagerank ? "bfs.hop, pr_rank DESC" : "bfs.hop, n.name, n.id"; snprintf(sql, sizeof(sql), "WITH RECURSIVE bfs(node_id, hop) AS (" " SELECT %lld, 0" @@ -4297,14 +4337,15 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const ")" "SELECT DISTINCT n.id, n.project, n.label, n.name, n.qualified_name, " "n.file_path, n.start_line, n.end_line, n.properties, bfs.hop, " - "COALESCE(pr.rank, 0.0) AS pr_rank " + "%s" "FROM bfs " "JOIN nodes n ON n.id = bfs.node_id " - "LEFT JOIN pagerank pr ON pr.node_id = n.id " + "%s" "WHERE bfs.hop > 0 " /* exclude root */ - "ORDER BY bfs.hop, pr_rank DESC " + "ORDER BY %s " "LIMIT %d;", - (long long)start_id, next_id, join_cond, types_clause, max_depth, max_results); + (long long)start_id, next_id, join_cond, types_clause, max_depth, pagerank_select, + pagerank_join, pagerank_order, max_results); sqlite3_stmt *stmt = NULL; rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL); @@ -4368,17 +4409,21 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const /* Build edge query using the same ?N placeholders for edge types. * types_clause already contains "?1,?2,..." — safe placeholder string. */ char edge_sql[ST_SQL_BUF]; + const char *linkrank_select = + use_linkrank ? "COALESCE(lr.rank, 0.0) AS lr_rank " : "0.0 AS lr_rank "; + const char *linkrank_join = use_linkrank ? "LEFT JOIN linkrank lr ON lr.edge_id = e.id " : ""; + const char *linkrank_order = use_linkrank ? "lr_rank DESC" : "n1.name, n2.name, e.type"; snprintf(edge_sql, sizeof(edge_sql), "SELECT n1.name, n2.name, e.type, " - "COALESCE(lr.rank, 0.0) AS lr_rank " + "%s" "FROM edges e " "JOIN nodes n1 ON n1.id = e.source_id " "JOIN nodes n2 ON n2.id = e.target_id " - "LEFT JOIN linkrank lr ON lr.edge_id = e.id " + "%s" "WHERE e.source_id IN (%s) AND e.target_id IN (%s) " "AND e.type IN (%s) " - "ORDER BY lr_rank DESC", - id_set, id_set, types_clause); + "ORDER BY %s", + linkrank_select, linkrank_join, id_set, id_set, types_clause, linkrank_order); sqlite3_stmt *estmt = NULL; rc = sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &estmt, NULL); diff --git a/src/store/store.h b/src/store/store.h index b557f99dc..8ba52769f 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -31,6 +31,7 @@ typedef struct cbm_store cbm_store_t; #define CBM_STORE_INDEX_STATUS_FAILED "failed" #define CBM_STORE_DERIVED_STATUS_STALE "stale" #define CBM_STORE_DERIVED_STATUS_COMPLETE "complete" +#define CBM_STORE_DERIVED_GENERATION_UNKNOWN 0 #define CBM_STORE_DERIVED_KIND_DIRECT "direct" #define CBM_STORE_DERIVED_VIEW_NODES_FTS "nodes_fts" #define CBM_STORE_DERIVED_VIEW_PAGERANK "pagerank" @@ -221,6 +222,9 @@ typedef struct { cbm_search_result_t *results; int count; int total; /* total before pagination */ + bool pagerank_stale; + bool linkrank_stale; + bool node_degree_stale; } cbm_search_output_t; /* ── Traversal ──────────────────────────────────────────────────── */ @@ -244,6 +248,8 @@ typedef struct { int visited_count; cbm_edge_info_t *edges; int edge_count; + bool pagerank_stale; + bool linkrank_stale; } cbm_traverse_result_t; /* ── Schema introspection ───────────────────────────────────────── */ @@ -580,6 +586,8 @@ int cbm_store_mark_derived_views_stale(cbm_store_t *s, const char *project, int cbm_store_get_derived_view_state(cbm_store_t *s, const char *project, const char *view_name, cbm_derived_view_state_t *out); +bool cbm_store_derived_view_is_stale(cbm_store_t *s, const char *project, + const char *view_name); int cbm_store_publish_file_delta(cbm_store_t *s, const cbm_store_file_delta_t *delta); int cbm_store_publish_file_delta_batch(cbm_store_t *s, diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 5ed159368..48f192f7f 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -696,6 +696,49 @@ TEST(tool_search_graph_includes_node_properties) { PASS(); } +TEST(tool_search_graph_warns_on_stale_pagerank_view) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + ASSERT_EQ(cbm_store_upsert_project(st, "test", "/tmp/test"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, "test"); + + cbm_node_t node = {.project = "test", + .label = "Function", + .name = "Handle", + .qualified_name = "test.Handle", + .file_path = "handle.c"}; + int64_t id = cbm_store_upsert_node(st, &node); + ASSERT_TRUE(id > 0); + char rank_sql[256]; + snprintf(rank_sql, sizeof(rank_sql), + "INSERT INTO pagerank(project,node_id,rank,computed_at) " + "VALUES('test',%lld,0.9,'2026-06-30T00:00:00Z')", + (long long)id); + ASSERT_EQ(cbm_store_exec(st, rank_sql), CBM_STORE_OK); + ASSERT_EQ(cbm_store_set_derived_view_state(st, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":43,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"test\",\"label\":\"Function\",\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "pagerank derived view is stale")); + ASSERT_NULL(strstr(inner, "\"pagerank\":")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + static bool mcp_test_upsert_fts_node(cbm_store_t *st, const char *project, const char *label, const char *name, const char *qualified_name, const char *file_path) { @@ -3036,6 +3079,7 @@ SUITE(mcp) { RUN_TEST(tool_unknown_tool); RUN_TEST(tool_search_graph_basic); RUN_TEST(tool_search_graph_includes_node_properties); + RUN_TEST(tool_search_graph_warns_on_stale_pagerank_view); RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552); RUN_TEST(tool_search_graph_query_uses_search_limit_config); RUN_TEST(tool_search_graph_query_rejects_bad_semantic_query); diff --git a/tests/test_pagerank.c b/tests/test_pagerank.c index 4989d158e..1ec79718e 100644 --- a/tests/test_pagerank.c +++ b/tests/test_pagerank.c @@ -213,6 +213,27 @@ TEST(pagerank_stored_in_db) { add_node(s, "db", "f2"); cbm_pagerank_compute_default(s, "db"); ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + + cbm_derived_view_state_t state = {0}; + ASSERT_EQ(cbm_store_get_derived_view_state(s, "db", CBM_STORE_DERIVED_VIEW_PAGERANK, + &state), + CBM_STORE_OK); + ASSERT_STR_EQ(state.status, CBM_STORE_DERIVED_STATUS_COMPLETE); + ASSERT_EQ(state.source_generation, CBM_STORE_DERIVED_GENERATION_UNKNOWN); + cbm_store_derived_view_state_free_fields(&state); + + ASSERT_EQ(cbm_store_get_derived_view_state(s, "db", CBM_STORE_DERIVED_VIEW_LINKRANK, + &state), + CBM_STORE_OK); + ASSERT_STR_EQ(state.status, CBM_STORE_DERIVED_STATUS_COMPLETE); + cbm_store_derived_view_state_free_fields(&state); + + ASSERT_EQ(cbm_store_get_derived_view_state(s, "db", CBM_STORE_DERIVED_VIEW_NODE_DEGREE, + &state), + CBM_STORE_OK); + ASSERT_STR_EQ(state.status, CBM_STORE_DERIVED_STATUS_COMPLETE); + cbm_store_derived_view_state_free_fields(&state); + cbm_store_close(s); PASS(); } diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index 30e6eb7a8..8f488061c 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -1936,6 +1936,8 @@ TEST(store_derived_view_state_public_api) { ASSERT_EQ(cbm_store_get_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_NODE_DEGREE, &got), CBM_STORE_OK); + ASSERT_TRUE(cbm_store_derived_view_is_stale(s, "test", + CBM_STORE_DERIVED_VIEW_NODE_DEGREE)); ASSERT_STR_EQ(got.project, "test"); ASSERT_STR_EQ(got.view_name, CBM_STORE_DERIVED_VIEW_NODE_DEGREE); ASSERT_EQ(got.source_generation, STALE_GENERATION); @@ -1960,6 +1962,19 @@ TEST(store_derived_view_state_public_api) { ASSERT_EQ(got.source_generation, COMPLETE_GENERATION); ASSERT_STR_EQ(got.status, CBM_STORE_DERIVED_STATUS_COMPLETE); cbm_store_derived_view_state_free_fields(&got); + ASSERT_FALSE(cbm_store_derived_view_is_stale(s, "test", + CBM_STORE_DERIVED_VIEW_PAGERANK)); + + ASSERT_EQ(cbm_store_set_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_ARCHITECTURE, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_ARCHITECTURE, + &got), + CBM_STORE_OK); + ASSERT_EQ(got.source_generation, CBM_STORE_DERIVED_GENERATION_UNKNOWN); + ASSERT_STR_EQ(got.status, CBM_STORE_DERIVED_STATUS_COMPLETE); + cbm_store_derived_view_state_free_fields(&got); ASSERT_EQ(cbm_store_set_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_LINKRANK, COMPLETE_GENERATION, diff --git a/tests/test_store_search.c b/tests/test_store_search.c index ad7065a3f..2c1321c46 100644 --- a/tests/test_store_search.c +++ b/tests/test_store_search.c @@ -1020,6 +1020,66 @@ TEST(store_bfs_carries_joined_pagerank_score) { ASSERT_FLOAT_EQ(result.visited[0].pagerank_score, 0.75, CBM_PAGERANK_EPSILON); cbm_store_traverse_free(&result); + + ASSERT_EQ(cbm_store_set_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + cbm_traverse_result_t stale_result = {0}; + ASSERT_EQ(cbm_store_bfs(s, idA, "outbound", types, 1, 1, 10, &stale_result), + CBM_STORE_OK); + ASSERT_TRUE(stale_result.pagerank_stale); + ASSERT_EQ(stale_result.visited_count, 1); + ASSERT_FLOAT_EQ(stale_result.visited[0].pagerank_score, 0.0, CBM_PAGERANK_EPSILON); + + cbm_store_traverse_free(&stale_result); + cbm_store_close(s); + PASS(); +} + +TEST(store_search_uses_legacy_but_not_stale_pagerank) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t na = { + .project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"}; + cbm_node_t nb = { + .project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"}; + int64_t idA = cbm_store_upsert_node(s, &na); + int64_t idB = cbm_store_upsert_node(s, &nb); + + char rank_sql[512]; + snprintf(rank_sql, sizeof(rank_sql), + "INSERT INTO pagerank(project,node_id,rank,computed_at) " + "VALUES('test',%lld,0.1,'2026-06-30T00:00:00Z')," + "('test',%lld,0.9,'2026-06-30T00:00:00Z')", + (long long)idA, (long long)idB); + ASSERT_EQ(cbm_store_exec(s, rank_sql), CBM_STORE_OK); + + cbm_search_params_t params = {0}; + params.project = "test"; + params.limit = 2; + params.min_degree = -1; + params.max_degree = -1; + cbm_search_output_t out = {0}; + ASSERT_EQ(cbm_store_search(s, ¶ms, &out), CBM_STORE_OK); + ASSERT_FALSE(out.pagerank_stale); + ASSERT_EQ(out.count, 2); + ASSERT_STR_EQ(out.results[0].node.name, "B"); + ASSERT_FLOAT_EQ(out.results[0].pagerank_score, 0.9, CBM_PAGERANK_EPSILON); + cbm_store_search_free(&out); + + ASSERT_EQ(cbm_store_set_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_search(s, ¶ms, &out), CBM_STORE_OK); + ASSERT_TRUE(out.pagerank_stale); + ASSERT_EQ(out.count, 2); + ASSERT_STR_EQ(out.results[0].node.name, "A"); + ASSERT_FLOAT_EQ(out.results[0].pagerank_score, 0.0, CBM_PAGERANK_EPSILON); + + cbm_store_search_free(&out); cbm_store_close(s); PASS(); } @@ -1628,6 +1688,7 @@ SUITE(store_search) { RUN_TEST(store_deduplicate_hops); RUN_TEST(store_bfs_with_risk_labels); RUN_TEST(store_bfs_carries_joined_pagerank_score); + RUN_TEST(store_search_uses_legacy_but_not_stale_pagerank); RUN_TEST(store_bfs_cross_service_summary); RUN_TEST(store_glob_to_like); RUN_TEST(store_extract_like_hints);