diff --git a/Makefile.cbm b/Makefile.cbm index fcecc4977..1b73483ef 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -180,6 +180,9 @@ PIPELINE_SRCS = \ # Depindex module (dependency/reference API indexing) DEPINDEX_SRCS = src/depindex/depindex.c +# PageRank module (node + edge ranking) +PAGERANK_SRCS = src/pagerank/pagerank.c + # Traces module (new) TRACES_SRCS = src/traces/traces.c @@ -226,7 +229,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre YYJSON_SRC = vendored/yyjson/yyjson.c # All production sources -PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(DEPINDEX_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC) +PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(DEPINDEX_SRCS) $(PAGERANK_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC) EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \ $(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(SQLITE_WRITER_SRC) @@ -289,11 +292,13 @@ TEST_MEM_SRCS = tests/test_mem.c TEST_UI_SRCS = tests/test_ui.c -TEST_TOKEN_REDUCTION_SRCS = tests/test_token_reduction.c - TEST_DEPINDEX_SRCS = tests/test_depindex.c -ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_TOKEN_REDUCTION_SRCS) $(TEST_DEPINDEX_SRCS) $(TEST_INTEGRATION_SRCS) +TEST_PAGERANK_SRCS = tests/test_pagerank.c + +TEST_TOKEN_REDUCTION_SRCS = tests/test_token_reduction.c + +ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_DEPINDEX_SRCS) $(TEST_PAGERANK_SRCS) $(TEST_TOKEN_REDUCTION_SRCS) $(TEST_INTEGRATION_SRCS) # ── Build directories ──────────────────────────────────────────── diff --git a/src/depindex/depindex.c b/src/depindex/depindex.c index 8201bb4f4..06a8780a8 100644 --- a/src/depindex/depindex.c +++ b/src/depindex/depindex.c @@ -373,18 +373,12 @@ int cbm_dep_auto_index(const char *project_name, const char *project_root, int cbm_dep_link_cross_edges(cbm_store_t *store, const char *project_name) { if (!store || !project_name || !project_name[0]) return 0; - /* Build dep project LIKE pattern once (invariant across loop) */ - char dep_pattern[CBM_NAME_MAX]; - snprintf(dep_pattern, sizeof(dep_pattern), "%s" CBM_DEP_SEPARATOR "%%", - project_name); - - /* Find Variable nodes in the project — import statements are extracted as - * Variable nodes by tree-sitter extractors (extract_imports.c). */ + /* Find all IMPORTS nodes in the main project */ cbm_search_params_t params = {0}; params.project = project_name; params.project_exact = true; - params.label = "Variable"; - params.limit = CBM_DEFAULT_AUTO_DEP_LIMIT; + params.label = "Variable"; /* import statements are typically Variable nodes */ + params.limit = 500; cbm_search_output_t out = {0}; int rc = cbm_store_search(store, ¶ms, &out); @@ -395,12 +389,17 @@ int cbm_dep_link_cross_edges(cbm_store_t *store, const char *project_name) { int linked = 0; - /* For each import variable, look for a matching Module in dep projects */ + /* For each import, look for a matching Module in dep projects */ for (int i = 0; i < out.count; i++) { const char *import_name = out.results[i].node.name; if (!import_name || !import_name[0]) continue; - /* Search for Module with matching name across all dep projects */ + /* Build dep project pattern: project_name.dep.% */ + char dep_pattern[CBM_NAME_MAX]; + snprintf(dep_pattern, sizeof(dep_pattern), "%s" CBM_DEP_SEPARATOR "%%", + project_name); + + /* Search for Module with matching name in dep projects */ cbm_search_params_t dep_params = {0}; dep_params.name_pattern = import_name; dep_params.project_pattern = dep_pattern; @@ -410,6 +409,7 @@ int cbm_dep_link_cross_edges(cbm_store_t *store, const char *project_name) { cbm_search_output_t dep_out = {0}; int drc = cbm_store_search(store, &dep_params, &dep_out); if (drc == 0 && dep_out.count > 0) { + /* Create cross-boundary IMPORTS edge */ cbm_edge_t edge = { .source_id = out.results[i].node.id, .target_id = dep_out.results[0].node.id, diff --git a/src/main.c b/src/main.c index f4218e136..e01fb3bd5 100644 --- a/src/main.c +++ b/src/main.c @@ -18,6 +18,7 @@ #include "pipeline/pipeline.h" #include "store/store.h" #include "depindex/depindex.h" +#include "pagerank/pagerank.h" #include "cli/cli.h" #include "foundation/log.h" #include "foundation/compat_thread.h" @@ -94,6 +95,7 @@ static int watcher_index_fn(const char *project_name, const char *root_path, voi cbm_store_t *store = cbm_store_open(pname); if (store) { cbm_dep_auto_index(pname, root_path, store, CBM_DEFAULT_AUTO_DEP_LIMIT); + cbm_pagerank_compute_default(store, pname); cbm_store_close(store); } free(pname); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 6dee977fd..90acff41c 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -12,6 +12,7 @@ #include "cypher/cypher.h" #include "pipeline/pipeline.h" #include "depindex/depindex.h" +#include "pagerank/pagerank.h" #include "cli/cli.h" #include "watcher/watcher.h" #include "foundation/mem.h" @@ -274,6 +275,9 @@ static const tool_def_t TOOLS[] = { "Response includes has_more and pagination_hint when more pages exist." "\"},\"offset\":{\"type\":\"integer\",\"default\":0,\"description\":\"Skip N results " "for pagination. Check pagination_hint in response for next page offset.\"}," + "\"sort_by\":{\"type\":\"string\",\"enum\":[\"relevance\",\"name\",\"degree\"]," + "\"description\":\"Sort order: relevance (PageRank structural importance, default), " + "name (alphabetical), degree (most connected).\"}," "\"mode\":{\"type\":\"string\",\"enum\":[\"full\",\"summary\"],\"default\":\"full\"," "\"description\":\"full=individual results (default), summary=aggregate counts by label and " "file. Use summary first to understand scope, then full with filters to drill down." @@ -1050,6 +1054,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { char *label = cbm_mcp_get_string_arg(args, "label"); char *name_pattern = cbm_mcp_get_string_arg(args, "name_pattern"); char *file_pattern = cbm_mcp_get_string_arg(args, "file_pattern"); + char *sort_by = cbm_mcp_get_string_arg(args, "sort_by"); int cfg_search_limit = cbm_config_get_int(srv->config, CBM_CONFIG_SEARCH_LIMIT, CBM_DEFAULT_SEARCH_LIMIT); int limit = cbm_mcp_get_int_arg(args, "limit", cfg_search_limit); @@ -1068,6 +1073,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { params.label = label; params.name_pattern = name_pattern; params.file_pattern = file_pattern; + params.sort_by = sort_by; params.limit = effective_limit; params.offset = offset; params.min_degree = min_degree; @@ -1153,6 +1159,8 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { sr->node.file_path ? sr->node.file_path : ""); yyjson_mut_obj_add_int(doc, item, "in_degree", sr->in_degree); yyjson_mut_obj_add_int(doc, item, "out_degree", sr->out_degree); + if (sr->pagerank_score > 0.0) + yyjson_mut_obj_add_real(doc, item, "pagerank", sr->pagerank_score); /* Unconditional source tagging — critical for AI grounding. * Every result tagged source:"project" or source:"dependency". @@ -1190,6 +1198,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { free(name_pattern); free(file_pattern); free(search_mode); + free(sort_by); char *result = cbm_mcp_text_result(json, false); free(json); @@ -1568,6 +1577,11 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { doc, item, "qualified_name", tr_out.visited[i].node.qualified_name ? tr_out.visited[i].node.qualified_name : ""); yyjson_mut_obj_add_int(doc, item, "hop", tr_out.visited[i].hop); + { + double pr = cbm_pagerank_get(store, tr_out.visited[i].node.id); + if (pr > 0.0) + yyjson_mut_obj_add_real(doc, item, "pagerank", pr); + } /* Boundary tagging: mark if callee is in a dependency */ bool callee_dep = cbm_is_dep_project(tr_out.visited[i].node.project, srv->session_project); @@ -1610,6 +1624,11 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { doc, item, "qualified_name", tr_in.visited[i].node.qualified_name ? tr_in.visited[i].node.qualified_name : ""); yyjson_mut_obj_add_int(doc, item, "hop", tr_in.visited[i].hop); + { + double pr = cbm_pagerank_get(store, tr_in.visited[i].node.id); + if (pr > 0.0) + yyjson_mut_obj_add_real(doc, item, "pagerank", pr); + } /* Boundary tagging: mark if caller is in a dependency */ bool caller_dep = cbm_is_dep_project(tr_in.visited[i].node.project, srv->session_project); @@ -1774,6 +1793,9 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { int deps_reindexed = cbm_dep_auto_index( project_name, repo_path, store, CBM_DEFAULT_AUTO_DEP_LIMIT); + /* Compute PageRank + LinkRank on full graph (project + deps) */ + cbm_pagerank_compute_default(store, project_name); + int nodes = cbm_store_count_nodes(store, project_name); int edges = cbm_store_count_edges(store, project_name); yyjson_mut_obj_add_int(doc, root, "nodes", nodes); @@ -2761,6 +2783,9 @@ static char *handle_index_dependencies(cbm_mcp_server_t *srv, const char *args) if (srv->session_project[0]) yyjson_mut_obj_add_str(doc, root, "session_project", srv->session_project); + /* Recompute PageRank after adding dep nodes so relevance sort includes them */ + cbm_pagerank_compute_default(store, project); + char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); yyjson_doc_free(doc_args); @@ -2898,6 +2923,7 @@ static void *autoindex_thread(void *arg) { if (store) { cbm_dep_auto_index(srv->session_project, srv->session_root, store, CBM_DEFAULT_AUTO_DEP_LIMIT); + cbm_pagerank_compute_default(store, srv->session_project); } cbm_log_info("autoindex.done", "project", srv->session_project); diff --git a/src/pagerank/pagerank.c b/src/pagerank/pagerank.c new file mode 100644 index 000000000..bc2664458 --- /dev/null +++ b/src/pagerank/pagerank.c @@ -0,0 +1,381 @@ +/* + * pagerank.c — PageRank (node) + LinkRank (edge) ranking for codebase graphs. + * + * References: + * - aider repomap.py (github.com/Aider-AI/aider/blob/main/aider/repomap.py) + * - NetworkX pagerank (networkx/algorithms/link_analysis/pagerank_alg.py) + * - Kim et al. (2010) LinkRank, arXiv:0902.3728 + * - nazgob/PageRank (github.com/nazgob/PageRank/blob/master/algorithm.c) + */ + +#include "pagerank.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* ── Default edge weights (aider/RepoMapper-inspired) ──────── */ + +const cbm_edge_weights_t CBM_DEFAULT_EDGE_WEIGHTS = { + .calls = 1.0, .defines_method = 0.8, .defines = 0.5, + .imports = 0.3, .usage = 0.2, .configures = 0.1, + .http_calls = 0.5, .async_calls = 0.8, .default_weight = 0.3 +}; + +/* ── Edge weight lookup (ordered by frequency) ─────────────── */ + +static double edge_type_weight(const cbm_edge_weights_t *w, const char *type) { + if (!type) return w->default_weight; + if (strcmp(type, "CALLS") == 0) return w->calls; + if (strcmp(type, "IMPORTS") == 0) return w->imports; + if (strcmp(type, "USAGE") == 0) return w->usage; + if (strcmp(type, "DEFINES") == 0) return w->defines; + if (strcmp(type, "DEFINES_METHOD") == 0) return w->defines_method; + if (strcmp(type, "CONFIGURES") == 0) return w->configures; + if (strcmp(type, "HTTP_CALLS") == 0) return w->http_calls; + if (strcmp(type, "ASYNC_CALLS") == 0) return w->async_calls; + return w->default_weight; +} + +/* ── Internal edge struct ────────────────────────────────────── */ + +typedef struct { + int src_idx; + int dst_idx; + int64_t edge_id; + double weight; +} pr_edge_t; + +/* ── ISO timestamp helper ────────────────────────────────────── */ + +static void iso_now(char *buf, size_t sz) { + time_t t = time(NULL); + struct tm tm; +#ifdef _WIN32 + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + strftime(buf, sz, "%Y-%m-%dT%H:%M:%SZ", &tm); +} + +/* ── Hash map: node_id -> array index (linear probing) ──────── */ + +typedef struct { + int64_t *keys; + int *vals; + int cap; +} id_map_t; + +static int id_map_init(id_map_t *m, int n) { + m->cap = n * CBM_HASHMAP_LOAD_FACTOR + 1; + m->keys = calloc((size_t)m->cap, sizeof(int64_t)); + m->vals = calloc((size_t)m->cap, sizeof(int)); + if (!m->keys || !m->vals) { + free(m->keys); free(m->vals); + m->keys = NULL; m->vals = NULL; + return -1; + } + memset(m->vals, -1, (size_t)m->cap * sizeof(int)); + return 0; +} + +static void id_map_put(id_map_t *m, int64_t key, int val) { + int h = (int)((uint64_t)key % (uint64_t)m->cap); + while (m->keys[h] != 0 && m->keys[h] != key) + h = (h + 1) % m->cap; + m->keys[h] = key; + m->vals[h] = val; +} + +static int id_map_get(const id_map_t *m, int64_t key) { + int h = (int)((uint64_t)key % (uint64_t)m->cap); + while (m->keys[h] != 0) { + if (m->keys[h] == key) return m->vals[h]; + h = (h + 1) % m->cap; + } + return -1; +} + +static void id_map_free(id_map_t *m) { + free(m->keys); + free(m->vals); + m->keys = NULL; + m->vals = NULL; +} + +/* ── Scope -> SQL WHERE clause (DRY: one function) ──────────── */ + +static const char *scope_where(cbm_rank_scope_t scope) { + switch (scope) { + case CBM_RANK_SCOPE_PROJECT: return "project = ?1"; + case CBM_RANK_SCOPE_DEPS: return "project LIKE ?1 || '.dep.%'"; + case CBM_RANK_SCOPE_FULL: + default: return "(project = ?1 OR project LIKE ?1 || '.dep.%')"; + } +} + +/* ── Core PageRank + LinkRank ────────────────────────────────── */ + +int cbm_pagerank_compute(cbm_store_t *store, const char *project, + double damping, double epsilon, int max_iter, + const cbm_edge_weights_t *weights, + cbm_rank_scope_t scope) { + if (!store || !project || !project[0]) return -1; + if (!weights) weights = &CBM_DEFAULT_EDGE_WEIGHTS; + if (damping < 0.0 || damping > 1.0) damping = CBM_PAGERANK_DAMPING; + if (max_iter <= 0) max_iter = CBM_PAGERANK_MAX_ITER; + if (epsilon <= 0.0) epsilon = CBM_PAGERANK_EPSILON; + + sqlite3 *db = cbm_store_get_db(store); + if (!db) return -1; + + /* All heap pointers initialized to NULL for safe cleanup via goto */ + int64_t *node_ids = NULL; + pr_edge_t *edges = NULL; + double *out_weight = NULL, *rank = NULL, *new_rank = NULL; + id_map_t map = {0}; + int N = 0, E = 0, result = -1; + + /* ── Step 1: Load node IDs ────────────────────────────── */ + char sql_buf[512]; + snprintf(sql_buf, sizeof(sql_buf), "SELECT id FROM nodes WHERE %s", + scope_where(scope)); + + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, sql_buf, -1, &stmt, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); + + int cap = CBM_PAGERANK_INITIAL_CAP; + node_ids = malloc((size_t)cap * sizeof(int64_t)); + if (!node_ids) { sqlite3_finalize(stmt); return -1; } + + while (sqlite3_step(stmt) == SQLITE_ROW) { + if (N >= cap) { + cap *= 2; + node_ids = safe_realloc(node_ids, (size_t)cap * sizeof(int64_t)); + if (!node_ids) { sqlite3_finalize(stmt); return -1; } + } + node_ids[N++] = sqlite3_column_int64(stmt, 0); + } + sqlite3_finalize(stmt); + stmt = NULL; + + if (N == 0) { free(node_ids); return 0; } + + /* Build id->index map */ + if (id_map_init(&map, N) != 0) { free(node_ids); return -1; } + for (int i = 0; i < N; i++) id_map_put(&map, node_ids[i], i); + + /* ── Step 2: Load weighted edges ──────────────────────── */ + snprintf(sql_buf, sizeof(sql_buf), + "SELECT id, source_id, target_id, type FROM edges WHERE %s", + scope_where(scope)); + if (sqlite3_prepare_v2(db, sql_buf, -1, &stmt, NULL) != SQLITE_OK) + goto cleanup; + sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); + + int ecap = CBM_PAGERANK_INITIAL_CAP; + edges = malloc((size_t)ecap * sizeof(pr_edge_t)); + if (!edges) { sqlite3_finalize(stmt); goto cleanup; } + + while (sqlite3_step(stmt) == SQLITE_ROW) { + int64_t eid = sqlite3_column_int64(stmt, 0); + int64_t src = sqlite3_column_int64(stmt, 1); + int64_t dst = sqlite3_column_int64(stmt, 2); + const char *type = (const char *)sqlite3_column_text(stmt, 3); + + int si = id_map_get(&map, src); + int di = id_map_get(&map, dst); + if (si < 0 || di < 0) continue; + + if (E >= ecap) { + ecap *= 2; + edges = safe_realloc(edges, (size_t)ecap * sizeof(pr_edge_t)); + if (!edges) { sqlite3_finalize(stmt); goto cleanup; } + } + edges[E].src_idx = si; + edges[E].dst_idx = di; + edges[E].edge_id = eid; + edges[E].weight = edge_type_weight(weights, type); + E++; + } + sqlite3_finalize(stmt); + stmt = NULL; + + /* ── Step 3: Allocate computation buffers ─────────────── */ + out_weight = calloc((size_t)N, sizeof(double)); + rank = malloc((size_t)N * sizeof(double)); + new_rank = malloc((size_t)N * sizeof(double)); + if (!out_weight || !rank || !new_rank) goto cleanup; + + for (int e = 0; e < E; e++) + out_weight[edges[e].src_idx] += edges[e].weight; + + /* ── Step 4: Power iteration ──────────────────────────── */ + double init_rank = 1.0 / N; + for (int i = 0; i < N; i++) rank[i] = init_rank; + + double base = (1.0 - damping) / N; + int iter; + for (iter = 0; iter < max_iter; iter++) { + for (int i = 0; i < N; i++) new_rank[i] = base; + + /* Distribute rank along weighted edges */ + for (int e = 0; e < E; e++) { + int s = edges[e].src_idx; + if (out_weight[s] > 0.0) { + new_rank[edges[e].dst_idx] += + damping * rank[s] * edges[e].weight / out_weight[s]; + } + } + + /* Dangling node handling (NetworkX convention) */ + double dangling_sum = 0.0; + for (int i = 0; i < N; i++) { + if (out_weight[i] == 0.0) dangling_sum += rank[i]; + } + if (dangling_sum > 0.0) { + double add = damping * dangling_sum / N; + for (int i = 0; i < N; i++) new_rank[i] += add; + } + + /* Convergence: L2 norm of rank delta */ + double delta = 0.0; + for (int i = 0; i < N; i++) { + double d = new_rank[i] - rank[i]; + delta += d * d; + } + delta = sqrt(delta); + + /* Swap buffers */ + double *tmp = rank; rank = new_rank; new_rank = tmp; + + if (delta < epsilon) { iter++; break; } + } + + /* ── Step 5: Store PageRank in db ─────────────────────── */ + char ts[CBM_ISO_TIMESTAMP_LEN]; + iso_now(ts, sizeof(ts)); + + /* Clear old ranks for this scope */ + snprintf(sql_buf, sizeof(sql_buf), "DELETE FROM pagerank WHERE %s", + scope_where(scope)); + if (sqlite3_prepare_v2(db, sql_buf, -1, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); + sqlite3_step(stmt); + sqlite3_finalize(stmt); + stmt = NULL; + } + + /* Batch insert within transaction */ + sqlite3_exec(db, "BEGIN", NULL, NULL, NULL); + const char *ins_sql = + "INSERT OR REPLACE INTO pagerank " + "(node_id, project, rank, computed_at) " + "SELECT ?1, project, ?2, ?3 FROM nodes WHERE id = ?1"; + sqlite3_stmt *ins_stmt = NULL; + if (sqlite3_prepare_v2(db, ins_sql, -1, &ins_stmt, NULL) == SQLITE_OK) { + for (int i = 0; i < N; i++) { + sqlite3_bind_int64(ins_stmt, 1, node_ids[i]); + sqlite3_bind_double(ins_stmt, 2, rank[i]); + sqlite3_bind_text(ins_stmt, 3, ts, -1, SQLITE_TRANSIENT); + sqlite3_step(ins_stmt); + sqlite3_reset(ins_stmt); + } + sqlite3_finalize(ins_stmt); + } + sqlite3_exec(db, "COMMIT", NULL, NULL, NULL); + + /* ── Step 6: Compute LinkRank for edges ───────────────── */ + snprintf(sql_buf, sizeof(sql_buf), "DELETE FROM linkrank WHERE %s", + scope_where(scope)); + if (sqlite3_prepare_v2(db, sql_buf, -1, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); + sqlite3_step(stmt); + sqlite3_finalize(stmt); + stmt = NULL; + } + + const char *lr_sql = + "INSERT OR REPLACE INTO linkrank " + "(edge_id, project, rank, computed_at) " + "SELECT ?1, project, ?2, ?3 FROM edges WHERE id = ?1"; + sqlite3_stmt *lr_stmt = NULL; + if (sqlite3_prepare_v2(db, lr_sql, -1, &lr_stmt, NULL) == SQLITE_OK) { + sqlite3_exec(db, "BEGIN", NULL, NULL, NULL); + for (int e = 0; e < E; e++) { + int s_idx = edges[e].src_idx; + double lr = 0.0; + if (out_weight[s_idx] > 0.0) + lr = rank[s_idx] * edges[e].weight / out_weight[s_idx]; + sqlite3_bind_int64(lr_stmt, 1, edges[e].edge_id); + sqlite3_bind_double(lr_stmt, 2, lr); + sqlite3_bind_text(lr_stmt, 3, ts, -1, SQLITE_TRANSIENT); + sqlite3_step(lr_stmt); + sqlite3_reset(lr_stmt); + } + sqlite3_exec(db, "COMMIT", NULL, NULL, NULL); + sqlite3_finalize(lr_stmt); + } + + /* ── Logging ──────────────────────────────────────────── */ + char iter_s[CBM_LOG_INT_BUF], n_s[CBM_LOG_INT_BUF], e_s[CBM_LOG_INT_BUF]; + snprintf(iter_s, sizeof(iter_s), "%d", iter); + snprintf(n_s, sizeof(n_s), "%d", N); + snprintf(e_s, sizeof(e_s), "%d", E); + cbm_log_info("pagerank.done", "project", project, + "nodes", n_s, "edges", e_s, "iterations", iter_s); + + result = N; + +cleanup: + if (stmt) sqlite3_finalize(stmt); /* defensive: finalize any in-flight stmt */ + free(node_ids); + id_map_free(&map); + free(edges); + free(out_weight); + free(rank); + free(new_rank); + return result; +} + +int cbm_pagerank_compute_default(cbm_store_t *store, const char *project) { + return cbm_pagerank_compute(store, project, + CBM_PAGERANK_DAMPING, CBM_PAGERANK_EPSILON, + CBM_PAGERANK_MAX_ITER, &CBM_DEFAULT_EDGE_WEIGHTS, + CBM_DEFAULT_RANK_SCOPE); +} + +double cbm_pagerank_get(cbm_store_t *store, int64_t node_id) { + sqlite3 *db = cbm_store_get_db(store); + if (!db) return 0.0; + sqlite3_stmt *stmt = NULL; + double r = 0.0; + if (sqlite3_prepare_v2(db, "SELECT rank FROM pagerank WHERE node_id = ?1", + -1, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_int64(stmt, 1, node_id); + if (sqlite3_step(stmt) == SQLITE_ROW) r = sqlite3_column_double(stmt, 0); + sqlite3_finalize(stmt); + } + return r; +} + +double cbm_linkrank_get(cbm_store_t *store, int64_t edge_id) { + sqlite3 *db = cbm_store_get_db(store); + if (!db) return 0.0; + sqlite3_stmt *stmt = NULL; + double r = 0.0; + if (sqlite3_prepare_v2(db, "SELECT rank FROM linkrank WHERE edge_id = ?1", + -1, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_int64(stmt, 1, edge_id); + if (sqlite3_step(stmt) == SQLITE_ROW) r = sqlite3_column_double(stmt, 0); + sqlite3_finalize(stmt); + } + return r; +} diff --git a/src/pagerank/pagerank.h b/src/pagerank/pagerank.h new file mode 100644 index 000000000..de7fc84ef --- /dev/null +++ b/src/pagerank/pagerank.h @@ -0,0 +1,83 @@ +/* pagerank.h — PageRank (node) + LinkRank (edge) ranking for codebase graphs. + * + * References: + * - aider repomap (github.com/Aider-AI/aider/blob/main/aider/repomap.py) + * - NetworkX pagerank (networkx/algorithms/link_analysis/pagerank_alg.py) + * - RepoGraph (github.com/ozyyshr/RepoGraph) — peer-reviewed + * - Kim et al. (2010) LinkRank, arXiv:0902.3728 + */ + +#ifndef CBM_PAGERANK_H +#define CBM_PAGERANK_H + +#include + +/* ── Algorithm defaults (config-overridable) ──────────────── */ + +#define CBM_PAGERANK_DAMPING 0.85 /* Standard Google PageRank damping */ +#define CBM_PAGERANK_EPSILON 1e-6 /* L2 convergence threshold */ +#define CBM_PAGERANK_MAX_ITER 20 /* Max power iterations */ + +/* Config keys for runtime tuning */ +#define CBM_CONFIG_PAGERANK_MAX_ITER "pagerank_max_iter" +#define CBM_CONFIG_RANK_SCOPE "rank_scope" + +/* ── Internal tuning constants ────────────────────────────── */ + +#define CBM_PAGERANK_INITIAL_CAP 256 /* Initial array capacity for nodes/edges */ +#define CBM_ISO_TIMESTAMP_LEN 32 /* ISO-8601 timestamp buffer size */ +#define CBM_LOG_INT_BUF 16 /* int->string buffer for logging */ +#define CBM_HASHMAP_LOAD_FACTOR 2 /* Hash map capacity = N * factor + 1 */ + +/* ── Scope control ────────────────────────────────────────── */ + +typedef enum { + CBM_RANK_SCOPE_PROJECT = 0, /* project nodes only */ + CBM_RANK_SCOPE_FULL = 1, /* project + all deps (default) */ + CBM_RANK_SCOPE_DEPS = 2, /* deps only */ +} cbm_rank_scope_t; + +#define CBM_DEFAULT_RANK_SCOPE CBM_RANK_SCOPE_FULL + +/* ── Edge type weights ────────────────────────────────────── */ + +typedef struct { + double calls; /* CALLS edges — direct function calls */ + double defines_method; /* DEFINES_METHOD — class->method */ + double defines; /* DEFINES — declaration->definition */ + double imports; /* IMPORTS — module imports */ + double usage; /* USAGE — variable/type references */ + double configures; /* CONFIGURES — config file links */ + double http_calls; /* HTTP_CALLS — cross-service */ + double async_calls; /* ASYNC_CALLS — async function calls */ + double default_weight; /* Fallback for unknown edge types */ +} cbm_edge_weights_t; + +extern const cbm_edge_weights_t CBM_DEFAULT_EDGE_WEIGHTS; + +/* ── PageRank API ─────────────────────────────────────────── */ + +/* Compute PageRank + LinkRank for all nodes/edges in a project scope. + * Stores results in pagerank and linkrank tables. + * Called after index_repository dump/flush. + * + * Runtime: O(max_iter * (V + E)), typically 20 * (V + E). + * Memory: O(V) for rank arrays + O(E) for edge list. + * Returns: number of nodes ranked, or -1 on error. */ +int cbm_pagerank_compute(cbm_store_t *store, const char *project, + double damping, double epsilon, int max_iter, + const cbm_edge_weights_t *weights, + cbm_rank_scope_t scope); + +/* Convenience: compute with defaults (FULL scope, d=0.85, eps=1e-6, 20 iter) */ +int cbm_pagerank_compute_default(cbm_store_t *store, const char *project); + +/* Get PageRank score for a single node. Returns 0.0 if not computed. */ +double cbm_pagerank_get(cbm_store_t *store, int64_t node_id); + +/* ── LinkRank API ─────────────────────────────────────────── */ + +/* Get LinkRank score for a single edge. Returns 0.0 if not computed. */ +double cbm_linkrank_get(cbm_store_t *store, int64_t edge_id); + +#endif /* CBM_PAGERANK_H */ diff --git a/src/store/store.c b/src/store/store.c index 35bf05ee3..ee940ea44 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -73,6 +73,12 @@ struct cbm_store { sqlite3_stmt *stmt_delete_file_hashes; }; +/* ── Public accessor ────────────────────────────────────────────── */ + +sqlite3 *cbm_store_get_db(cbm_store_t *s) { + return s ? s->db : NULL; +} + /* ── Helpers ────────────────────────────────────────────────────── */ static void store_set_error(cbm_store_t *s, const char *msg) { @@ -195,6 +201,18 @@ static int init_schema(cbm_store_t *s) { " source_hash TEXT NOT NULL," " created_at TEXT NOT NULL," " updated_at TEXT NOT NULL" + ");" + "CREATE TABLE IF NOT EXISTS pagerank (" + " node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE," + " project TEXT NOT NULL," + " rank REAL NOT NULL DEFAULT 0.0," + " computed_at TEXT NOT NULL" + ");" + "CREATE TABLE IF NOT EXISTS linkrank (" + " edge_id INTEGER PRIMARY KEY REFERENCES edges(id) ON DELETE CASCADE," + " project TEXT NOT NULL," + " rank REAL NOT NULL DEFAULT 0.0," + " computed_at TEXT NOT NULL" ");"; return exec_sql(s, ddl); @@ -209,7 +227,10 @@ static int create_user_indexes(cbm_store_t *s) { "CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id, type);" "CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(project, type);" "CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(project, target_id, type);" - "CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(project, source_id, type);"; + "CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(project, source_id, type);" + "CREATE INDEX IF NOT EXISTS idx_pagerank_project ON pagerank(project);" + "CREATE INDEX IF NOT EXISTS idx_pagerank_rank ON pagerank(project, rank DESC);" + "CREATE INDEX IF NOT EXISTS idx_linkrank_project ON linkrank(project);"; return exec_sql(s, sql); } @@ -1734,12 +1755,25 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear char count_sql[4096]; int bind_idx = 0; - /* We build a query that selects nodes with optional degree subqueries */ - const char *select_cols = - "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " - "n.file_path, n.start_line, n.end_line, n.properties, " - "(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id AND e.type = 'CALLS') AS in_deg, " - "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND e.type = 'CALLS') AS out_deg "; + /* 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 *select_cols; + if (use_pagerank) { + select_cols = + "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " + "n.file_path, n.start_line, n.end_line, n.properties, " + "(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id AND e.type = 'CALLS') AS in_deg, " + "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND e.type = 'CALLS') AS out_deg, " + "COALESCE(pr.rank, 0.0) AS pr_rank "; + } else { + select_cols = + "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " + "n.file_path, n.start_line, n.end_line, n.properties, " + "(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id AND e.type = 'CALLS') AS in_deg, " + "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND e.type = 'CALLS') AS out_deg "; + } /* Start building WHERE */ char where[2048] = ""; @@ -1825,10 +1859,13 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear } /* Build full SQL */ + const char *from_join = use_pagerank + ? "FROM nodes n LEFT JOIN pagerank pr ON pr.node_id = n.id" + : "FROM nodes n"; if (nparams > 0) { - snprintf(sql, sizeof(sql), "%s FROM nodes n WHERE %s", select_cols, where); + snprintf(sql, sizeof(sql), "%s %s WHERE %s", select_cols, from_join, where); } else { - snprintf(sql, sizeof(sql), "%s FROM nodes n", select_cols); + snprintf(sql, sizeof(sql), "%s %s", select_cols, from_join); } /* Degree filters: -1 = no filter, 0+ = active filter. @@ -1863,19 +1900,40 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear // NOLINTNEXTLINE(readability-implicit-bool-conversion) const char *name_col = has_degree_wrap ? "name" : "n.name"; char order_limit[128]; - /* Stable pagination: ORDER BY name, id prevents duplicates across pages. - * When project_pattern includes deps, add project-first sort so project - * results appear before dependency results. */ + /* Sort dispatch: relevance (PageRank), name, degree. + * Stable pagination via secondary sort on name, id. */ const char *id_col = has_degree_wrap ? "id" : "n.id"; - if (params->project_pattern && !params->sort_by) { - const char *proj_col = has_degree_wrap ? "project" : "n.project"; + const char *pr_col = has_degree_wrap ? "pr_rank" : "pr_rank"; + if (use_pagerank) { + /* Relevance sort: PageRank DESC, then dep-last, then name for stability */ + if (params->project_pattern) { + const char *proj_col = has_degree_wrap ? "project" : "n.project"; + snprintf(order_limit, sizeof(order_limit), + " ORDER BY %s DESC, " + "CASE WHEN %s LIKE '%%.dep.%%' THEN 1 ELSE 0 END, %s, %s" + " LIMIT %d OFFSET %d", + pr_col, proj_col, name_col, id_col, limit, offset); + } else { + snprintf(order_limit, sizeof(order_limit), + " ORDER BY %s DESC, %s, %s LIMIT %d OFFSET %d", + pr_col, name_col, id_col, limit, offset); + } + } else if (params->sort_by && strcmp(params->sort_by, "degree") == 0) { snprintf(order_limit, sizeof(order_limit), - " ORDER BY CASE WHEN %s LIKE '%%.dep.%%' THEN 1 ELSE 0 END, %s, %s" - " LIMIT %d OFFSET %d", - proj_col, name_col, id_col, limit, offset); - } else { - snprintf(order_limit, sizeof(order_limit), " ORDER BY %s, %s LIMIT %d OFFSET %d", + " ORDER BY (in_deg + out_deg) DESC, %s, %s LIMIT %d OFFSET %d", name_col, id_col, limit, offset); + } else { + /* name sort (explicit or fallback) */ + if (params->project_pattern) { + const char *proj_col = has_degree_wrap ? "project" : "n.project"; + snprintf(order_limit, sizeof(order_limit), + " ORDER BY CASE WHEN %s LIKE '%%.dep.%%' THEN 1 ELSE 0 END, %s, %s" + " LIMIT %d OFFSET %d", + proj_col, name_col, id_col, limit, offset); + } else { + snprintf(order_limit, sizeof(order_limit), " ORDER BY %s, %s LIMIT %d OFFSET %d", + name_col, id_col, limit, offset); + } } strncat(sql, order_limit, sizeof(sql) - strlen(sql) - 1); @@ -1918,6 +1976,7 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear scan_node(main_stmt, &results[n].node); results[n].in_degree = sqlite3_column_int(main_stmt, 9); results[n].out_degree = sqlite3_column_int(main_stmt, 10); + results[n].pagerank_score = use_pagerank ? sqlite3_column_double(main_stmt, 11) : 0.0; n++; } @@ -2004,11 +2063,13 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const " WHERE e.type IN (%s) AND bfs.hop < %d" ")" "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 " + "n.file_path, n.start_line, n.end_line, n.properties, bfs.hop, " + "COALESCE(pr.rank, 0.0) AS pr_rank " "FROM bfs " "JOIN nodes n ON n.id = bfs.node_id " + "LEFT JOIN pagerank pr ON pr.node_id = n.id " "WHERE bfs.hop > 0 " /* exclude root */ - "ORDER BY bfs.hop " + "ORDER BY bfs.hop, pr_rank DESC " "LIMIT %d;", (long long)start_id, next_id, join_cond, types_clause, max_depth, max_results); @@ -2050,12 +2111,15 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char edge_sql[8192]; snprintf(edge_sql, sizeof(edge_sql), - "SELECT n1.name, n2.name, e.type " + "SELECT n1.name, n2.name, e.type, " + "COALESCE(lr.rank, 0.0) AS lr_rank " "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 " "WHERE e.source_id IN (%s) AND e.target_id IN (%s) " - "AND e.type IN (%s)", + "AND e.type IN (%s) " + "ORDER BY lr_rank DESC", id_set, id_set, types_clause); sqlite3_stmt *estmt = NULL; @@ -2073,7 +2137,7 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const edges[en].from_name = heap_strdup((const char *)sqlite3_column_text(estmt, 0)); edges[en].to_name = heap_strdup((const char *)sqlite3_column_text(estmt, 1)); edges[en].type = heap_strdup((const char *)sqlite3_column_text(estmt, 2)); - edges[en].confidence = 1.0; + edges[en].confidence = sqlite3_column_double(estmt, 3); en++; } sqlite3_finalize(estmt); diff --git a/src/store/store.h b/src/store/store.h index d6f6bc4b2..29a5ccb86 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -123,6 +123,7 @@ typedef struct { cbm_node_t node; int in_degree; int out_degree; + double pagerank_score; /* PageRank rank, 0.0 if not computed */ /* connected_names: allocated array of strings, count in connected_count */ const char **connected_names; int connected_count; @@ -201,6 +202,10 @@ void cbm_store_close(cbm_store_t *s); /* Get the last error message (static string, valid until next call). */ const char *cbm_store_error(cbm_store_t *s); +/* Raw SQLite handle — use for pagerank/linkrank bulk inserts. + * Do NOT use for schema modifications. Returns NULL if store is NULL. */ +struct sqlite3 *cbm_store_get_db(cbm_store_t *s); + /* ── Transaction ────────────────────────────────────────────────── */ /* Begin a transaction. Returns CBM_STORE_OK on success. */ diff --git a/tests/test_depindex.c b/tests/test_depindex.c index 5222d401d..da57a35d5 100644 --- a/tests/test_depindex.c +++ b/tests/test_depindex.c @@ -819,7 +819,6 @@ TEST(test_index_status_shows_deps) { * ══════════════════════════════════════════════════════════════════ */ TEST(test_trace_results_have_source_field) { - /* trace_call_path results for project nodes must have source:"project" */ char tmp[256]; cbm_mcp_server_t *srv = setup_dep_query_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); @@ -831,7 +830,6 @@ TEST(test_trace_results_have_source_field) { free(raw); ASSERT_NOT_NULL(resp); - /* Callees should have source field tagged as "project" */ if (strstr(resp, "callees") && strstr(resp, "source")) { ASSERT_NOT_NULL(strstr(resp, "\"source\":\"project\"")); } @@ -843,7 +841,6 @@ TEST(test_trace_results_have_source_field) { } TEST(test_snippet_has_source_field) { - /* get_code_snippet results must have source field */ char tmp[256]; cbm_mcp_server_t *srv = setup_dep_query_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); @@ -855,7 +852,6 @@ TEST(test_snippet_has_source_field) { free(raw); ASSERT_NOT_NULL(resp); - /* Project snippet must have source:"project" */ ASSERT_NOT_NULL(strstr(resp, "\"source\":\"project\"")); free(resp); @@ -865,11 +861,9 @@ TEST(test_snippet_has_source_field) { } TEST(test_cross_edges_null_safety) { - /* cbm_dep_link_cross_edges must handle NULL/empty args safely */ ASSERT_EQ(0, cbm_dep_link_cross_edges(NULL, "test")); ASSERT_EQ(0, cbm_dep_link_cross_edges(NULL, NULL)); - /* With a valid store but no deps, should return 0 (no edges linked) */ cbm_store_t *st = cbm_store_open_memory(); ASSERT_NOT_NULL(st); ASSERT_EQ(0, cbm_dep_link_cross_edges(st, "nonexistent")); diff --git a/tests/test_main.c b/tests/test_main.c index c0c138b1e..e24505375 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -49,6 +49,7 @@ extern void suite_mem(void); extern void suite_ui(void); extern void suite_token_reduction(void); extern void suite_depindex(void); +extern void suite_pagerank(void); extern void suite_integration(void); int main(void) { @@ -138,6 +139,9 @@ int main(void) { /* Dependency indexing */ RUN_SUITE(depindex); + /* PageRank (node + edge ranking) */ + RUN_SUITE(pagerank); + /* Integration (end-to-end) */ RUN_SUITE(integration); diff --git a/tests/test_pagerank.c b/tests/test_pagerank.c new file mode 100644 index 000000000..2653ddfa0 --- /dev/null +++ b/tests/test_pagerank.c @@ -0,0 +1,649 @@ +/* + * test_pagerank.c — Tests for PageRank (node) + LinkRank (edge) ranking. + * + * TDD: All tests written BEFORE implementation. They should fail (RED) + * until the corresponding feature is implemented (GREEN). + * + * References: + * - igraph test suite: pagerank, multigraph, dangling, complete graph + * - NetworkX test suite: test_pagerank, test_dangling, test_empty + * - aider repomap: edge weights, file rank distribution + * - Kim et al. (2010) LinkRank: edge ranking formula + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include +#include +#include +#include +#include +#include + +/* ── Test helpers ──────────────────────────────────────────── */ + +static int64_t add_node(cbm_store_t *s, const char *project, const char *name) { + cbm_node_t n = {0}; + n.project = project; + n.label = "Function"; + n.name = name; + n.qualified_name = name; + n.file_path = "test.c"; + return cbm_store_upsert_node(s, &n); +} + +static int64_t add_edge(cbm_store_t *s, const char *project, + int64_t src, int64_t dst, const char *type) { + cbm_edge_t e = {0}; + e.project = project; + e.source_id = src; + e.target_id = dst; + e.type = type; + return cbm_store_insert_edge(s, &e); +} + +static double get_pr(cbm_store_t *s, int64_t node_id) { + return cbm_pagerank_get(s, node_id); +} + +static int count_table_rows(cbm_store_t *s, const char *table) { + sqlite3 *db = cbm_store_get_db(s); + if (!db) return -1; + char sql[64]; + snprintf(sql, sizeof(sql), "SELECT COUNT(*) FROM %s", table); + sqlite3_stmt *stmt = NULL; + int count = 0; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) { + if (sqlite3_step(stmt) == SQLITE_ROW) count = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + } + return count; +} + +static double get_lr_by_edge_id(cbm_store_t *s, int64_t edge_id) { + return cbm_linkrank_get(s, edge_id); +} + +/* ── 1. Core PageRank tests ──────────────────────────────── */ + +TEST(pagerank_empty_graph) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "empty", "/tmp/empty"); + int rc = cbm_pagerank_compute_default(s, "empty"); + ASSERT_EQ(rc, 0); /* 0 nodes ranked */ + ASSERT_EQ(count_table_rows(s, "pagerank"), 0); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_single_node) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "single", "/tmp/single"); + int64_t a = add_node(s, "single", "main"); + int rc = cbm_pagerank_compute_default(s, "single"); + ASSERT_EQ(rc, 1); + double r = get_pr(s, a); + ASSERT_TRUE(fabs(r - 1.0) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_two_nodes_one_edge) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "two", "/tmp/two"); + int64_t a = add_node(s, "two", "caller"); + int64_t b = add_node(s, "two", "callee"); + add_edge(s, "two", a, b, "CALLS"); + cbm_pagerank_compute_default(s, "two"); + double ra = get_pr(s, a); + double rb = get_pr(s, b); + ASSERT_TRUE(rb > ra); /* callee gets more rank */ + ASSERT_TRUE(fabs(ra + rb - 1.0) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_cycle) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "cyc", "/tmp/cyc"); + int64_t a = add_node(s, "cyc", "funcA"); + int64_t b = add_node(s, "cyc", "funcB"); + add_edge(s, "cyc", a, b, "CALLS"); + add_edge(s, "cyc", b, a, "CALLS"); + cbm_pagerank_compute_default(s, "cyc"); + double ra = get_pr(s, a); + double rb = get_pr(s, b); + ASSERT_TRUE(fabs(ra - rb) < 0.01); /* symmetric */ + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_star_topology) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "star", "/tmp/star"); + int64_t hub = add_node(s, "star", "hub"); + int64_t s1 = add_node(s, "star", "spoke1"); + int64_t s2 = add_node(s, "star", "spoke2"); + int64_t s3 = add_node(s, "star", "spoke3"); + add_edge(s, "star", s1, hub, "CALLS"); + add_edge(s, "star", s2, hub, "CALLS"); + add_edge(s, "star", s3, hub, "CALLS"); + cbm_pagerank_compute_default(s, "star"); + ASSERT_TRUE(get_pr(s, hub) > get_pr(s, s1)); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_edge_weights) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "wt", "/tmp/wt"); + int64_t a = add_node(s, "wt", "source"); + int64_t b = add_node(s, "wt", "called"); + int64_t c = add_node(s, "wt", "used"); + add_edge(s, "wt", a, b, "CALLS"); /* weight 1.0 */ + add_edge(s, "wt", a, c, "USAGE"); /* weight 0.2 */ + cbm_pagerank_compute_default(s, "wt"); + ASSERT_TRUE(get_pr(s, b) > get_pr(s, c)); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_convergence) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "chain", "/tmp/chain"); + int64_t ids[5]; + for (int i = 0; i < 5; i++) { + char name[8]; snprintf(name, sizeof(name), "n%d", i); + ids[i] = add_node(s, "chain", name); + } + for (int i = 0; i < 4; i++) add_edge(s, "chain", ids[i], ids[i+1], "CALLS"); + int rc = cbm_pagerank_compute_default(s, "chain"); + ASSERT_EQ(rc, 5); + ASSERT_TRUE(get_pr(s, ids[4]) > get_pr(s, ids[0])); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_sum_to_one) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "sum", "/tmp/sum"); + int64_t a = add_node(s, "sum", "a"); + int64_t b = add_node(s, "sum", "b"); + int64_t c = add_node(s, "sum", "c"); + add_edge(s, "sum", a, b, "CALLS"); + add_edge(s, "sum", b, c, "CALLS"); + add_edge(s, "sum", c, a, "CALLS"); + cbm_pagerank_compute_default(s, "sum"); + double total = get_pr(s, a) + get_pr(s, b) + get_pr(s, c); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_stored_in_db) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "db", "/tmp/db"); + add_node(s, "db", "f1"); + add_node(s, "db", "f2"); + cbm_pagerank_compute_default(s, "db"); + ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_recompute_replaces) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "re", "/tmp/re"); + int64_t a = add_node(s, "re", "f1"); + cbm_pagerank_compute_default(s, "re"); + double r1 = get_pr(s, a); + cbm_pagerank_compute_default(s, "re"); + ASSERT_EQ(count_table_rows(s, "pagerank"), 1); + double r2 = get_pr(s, a); + ASSERT_TRUE(fabs(r1 - r2) < 0.001); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_full_scope_includes_deps) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "proj", "/tmp/proj"); + cbm_store_upsert_project(s, "proj.dep.lib", "/tmp/lib"); + int64_t a = add_node(s, "proj", "app_main"); + int64_t b = add_node(s, "proj.dep.lib", "lib_func"); + add_edge(s, "proj", a, b, "CALLS"); + int rc = cbm_pagerank_compute(s, "proj", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, CBM_PAGERANK_MAX_ITER, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_FULL); + ASSERT_EQ(rc, 2); + ASSERT_TRUE(get_pr(s, b) > 0.0); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_project_scope_excludes_deps) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "proj2", "/tmp/proj2"); + cbm_store_upsert_project(s, "proj2.dep.lib", "/tmp/lib2"); + add_node(s, "proj2", "my_func"); + int64_t dep = add_node(s, "proj2.dep.lib", "lib_func"); + int rc = cbm_pagerank_compute(s, "proj2", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, CBM_PAGERANK_MAX_ITER, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_PROJECT); + ASSERT_EQ(rc, 1); + ASSERT_TRUE(get_pr(s, dep) == 0.0); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_dangling_nodes) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "dang", "/tmp/dang"); + int64_t a = add_node(s, "dang", "caller"); + int64_t b = add_node(s, "dang", "leaf"); + add_edge(s, "dang", a, b, "CALLS"); + cbm_pagerank_compute_default(s, "dang"); + ASSERT_TRUE(get_pr(s, b) > 0.0); + double total = get_pr(s, a) + get_pr(s, b); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_null_safety) { + ASSERT_EQ(cbm_pagerank_compute_default(NULL, "x"), -1); + ASSERT_EQ(cbm_pagerank_compute_default(NULL, NULL), -1); + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_EQ(cbm_pagerank_compute_default(s, NULL), -1); + ASSERT_EQ(cbm_pagerank_compute_default(s, ""), -1); + cbm_store_close(s); + PASS(); +} + +/* ── 2. Edge cases from igraph/NetworkX ──────────────────── */ + +TEST(pagerank_self_loop) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "self", "/tmp/self"); + int64_t a = add_node(s, "self", "recursive"); + add_edge(s, "self", a, a, "CALLS"); + int rc = cbm_pagerank_compute_default(s, "self"); + ASSERT_EQ(rc, 1); + ASSERT_TRUE(fabs(get_pr(s, a) - 1.0) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_disconnected_components) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "disc", "/tmp/disc"); + int64_t a = add_node(s, "disc", "a"); + int64_t b = add_node(s, "disc", "b"); + int64_t c = add_node(s, "disc", "c"); + int64_t d = add_node(s, "disc", "d"); + add_edge(s, "disc", a, b, "CALLS"); + add_edge(s, "disc", c, d, "CALLS"); + cbm_pagerank_compute_default(s, "disc"); + double total = get_pr(s, a) + get_pr(s, b) + get_pr(s, c) + get_pr(s, d); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + double comp1 = get_pr(s, a) + get_pr(s, b); + double comp2 = get_pr(s, c) + get_pr(s, d); + ASSERT_TRUE(fabs(comp1 - comp2) < 0.15); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_all_dangling_no_edges) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "noedge", "/tmp/noedge"); + int64_t ids[5]; + for (int i = 0; i < 5; i++) { + char name[16]; snprintf(name, sizeof(name), "n%d", i); + ids[i] = add_node(s, "noedge", name); + } + int rc = cbm_pagerank_compute_default(s, "noedge"); + ASSERT_EQ(rc, 5); + double expected = 1.0 / 5.0; + for (int i = 0; i < 5; i++) + ASSERT_TRUE(fabs(get_pr(s, ids[i]) - expected) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_complete_graph) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "kn", "/tmp/kn"); + int64_t ids[4]; + for (int i = 0; i < 4; i++) { + char name[8]; snprintf(name, sizeof(name), "k%d", i); + ids[i] = add_node(s, "kn", name); + } + for (int i = 0; i < 4; i++) + for (int j = 0; j < 4; j++) + if (i != j) add_edge(s, "kn", ids[i], ids[j], "CALLS"); + cbm_pagerank_compute_default(s, "kn"); + for (int i = 0; i < 4; i++) + ASSERT_TRUE(fabs(get_pr(s, ids[i]) - 0.25) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_multigraph_edges) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "multi", "/tmp/multi"); + int64_t a = add_node(s, "multi", "caller"); + int64_t b = add_node(s, "multi", "callee"); + add_edge(s, "multi", a, b, "CALLS"); + add_edge(s, "multi", a, b, "IMPORTS"); + cbm_pagerank_compute_default(s, "multi"); + ASSERT_TRUE(get_pr(s, b) > get_pr(s, a)); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_large_graph_stability) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "big", "/tmp/big"); + int64_t ids[100]; + for (int i = 0; i < 100; i++) { + char name[16]; snprintf(name, sizeof(name), "f%d", i); + ids[i] = add_node(s, "big", name); + } + for (int i = 0; i < 99; i++) + add_edge(s, "big", ids[i], ids[i+1], "CALLS"); + int rc = cbm_pagerank_compute_default(s, "big"); + ASSERT_EQ(rc, 100); + double total = 0.0; + for (int i = 0; i < 100; i++) total += get_pr(s, ids[i]); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + ASSERT_TRUE(get_pr(s, ids[99]) > get_pr(s, ids[0])); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_zero_weight_edges) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "zw", "/tmp/zw"); + int64_t a = add_node(s, "zw", "a"); + int64_t b = add_node(s, "zw", "b"); + add_edge(s, "zw", a, b, "CONFIGURES"); + cbm_edge_weights_t zero_w = CBM_DEFAULT_EDGE_WEIGHTS; + zero_w.configures = 0.0; + cbm_pagerank_compute(s, "zw", CBM_PAGERANK_DAMPING, CBM_PAGERANK_EPSILON, + CBM_PAGERANK_MAX_ITER, &zero_w, CBM_RANK_SCOPE_FULL); + ASSERT_TRUE(fabs(get_pr(s, a) - get_pr(s, b)) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_custom_damping_high) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "hi_d", "/tmp/hi_d"); + int64_t a = add_node(s, "hi_d", "a"); + int64_t b = add_node(s, "hi_d", "b"); + add_edge(s, "hi_d", a, b, "CALLS"); + cbm_pagerank_compute(s, "hi_d", 0.99, CBM_PAGERANK_EPSILON, + 50, &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_FULL); + double total = get_pr(s, a) + get_pr(s, b); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + ASSERT_TRUE(get_pr(s, b) > get_pr(s, a)); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_custom_damping_low) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lo_d", "/tmp/lo_d"); + int64_t a = add_node(s, "lo_d", "a"); + int64_t b = add_node(s, "lo_d", "b"); + add_edge(s, "lo_d", a, b, "CALLS"); + cbm_pagerank_compute(s, "lo_d", 0.1, CBM_PAGERANK_EPSILON, + CBM_PAGERANK_MAX_ITER, &CBM_DEFAULT_EDGE_WEIGHTS, + CBM_RANK_SCOPE_FULL); + ASSERT_TRUE(fabs(get_pr(s, a) - get_pr(s, b)) < 0.1); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_max_iter_zero) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "mi0", "/tmp/mi0"); + add_node(s, "mi0", "a"); + add_node(s, "mi0", "b"); + add_edge(s, "mi0", 1, 2, "CALLS"); + /* max_iter <= 0 resets to default */ + int rc = cbm_pagerank_compute(s, "mi0", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, 0, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_FULL); + ASSERT_TRUE(rc > 0); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_known_values) { + /* 3-node cycle: all should get equal rank 1/3 */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "kv", "/tmp/kv"); + int64_t a = add_node(s, "kv", "a"); + int64_t b = add_node(s, "kv", "b"); + int64_t c = add_node(s, "kv", "c"); + add_edge(s, "kv", a, b, "CALLS"); + add_edge(s, "kv", b, c, "CALLS"); + add_edge(s, "kv", c, a, "CALLS"); + cbm_pagerank_compute_default(s, "kv"); + double expected = 1.0 / 3.0; + ASSERT_TRUE(fabs(get_pr(s, a) - expected) < 0.01); + ASSERT_TRUE(fabs(get_pr(s, b) - expected) < 0.01); + ASSERT_TRUE(fabs(get_pr(s, c) - expected) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_known_values_asymmetric) { + /* NetworkX test graph: 6 nodes, node 4 highest rank */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "nx", "/tmp/nx"); + int64_t n[7]; + for (int i = 1; i <= 6; i++) { + char name[8]; snprintf(name, sizeof(name), "n%d", i); + n[i] = add_node(s, "nx", name); + } + add_edge(s, "nx", n[1], n[2], "CALLS"); + add_edge(s, "nx", n[1], n[3], "CALLS"); + add_edge(s, "nx", n[3], n[1], "CALLS"); + add_edge(s, "nx", n[3], n[2], "CALLS"); + add_edge(s, "nx", n[3], n[5], "CALLS"); + add_edge(s, "nx", n[4], n[5], "CALLS"); + add_edge(s, "nx", n[4], n[6], "CALLS"); + add_edge(s, "nx", n[5], n[4], "CALLS"); + add_edge(s, "nx", n[5], n[6], "CALLS"); + add_edge(s, "nx", n[6], n[4], "CALLS"); + cbm_pagerank_compute_default(s, "nx"); + ASSERT_TRUE(get_pr(s, n[4]) > get_pr(s, n[1])); + ASSERT_TRUE(get_pr(s, n[2]) > 0.0); /* dangling node gets rank */ + double total = 0; + for (int i = 1; i <= 6; i++) total += get_pr(s, n[i]); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_scope_deps_only) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "sd", "/tmp/sd"); + cbm_store_upsert_project(s, "sd.dep.lib", "/tmp/sdlib"); + int64_t proj_node = add_node(s, "sd", "app"); + int64_t dep_node = add_node(s, "sd.dep.lib", "lib"); + int rc = cbm_pagerank_compute(s, "sd", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, CBM_PAGERANK_MAX_ITER, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_DEPS); + ASSERT_EQ(rc, 1); + ASSERT_TRUE(get_pr(s, dep_node) > 0.0); + ASSERT_TRUE(get_pr(s, proj_node) == 0.0); + cbm_store_close(s); + PASS(); +} + +/* ── 3. LinkRank tests ───────────────────────────────────── */ + +TEST(linkrank_computed_from_pagerank) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lr", "/tmp/lr"); + add_node(s, "lr", "f1"); + add_node(s, "lr", "f2"); + add_edge(s, "lr", 1, 2, "CALLS"); + cbm_pagerank_compute_default(s, "lr"); + ASSERT_TRUE(count_table_rows(s, "linkrank") > 0); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_formula_correct) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrf", "/tmp/lrf"); + int64_t a = add_node(s, "lrf", "src"); + int64_t b = add_node(s, "lrf", "dst"); + int64_t eid = add_edge(s, "lrf", a, b, "CALLS"); + cbm_pagerank_compute_default(s, "lrf"); + double pra = get_pr(s, a); + double lr = get_lr_by_edge_id(s, eid); + /* Single outgoing CALLS (weight 1.0): LR = PR(A) * 1.0 / 1.0 = PR(A) */ + ASSERT_TRUE(fabs(lr - pra) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_calls_higher_than_usage) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrw", "/tmp/lrw"); + int64_t a = add_node(s, "lrw", "src"); + int64_t b = add_node(s, "lrw", "called"); + int64_t c = add_node(s, "lrw", "used"); + int64_t e1 = add_edge(s, "lrw", a, b, "CALLS"); + int64_t e2 = add_edge(s, "lrw", a, c, "USAGE"); + cbm_pagerank_compute_default(s, "lrw"); + ASSERT_TRUE(get_lr_by_edge_id(s, e1) > get_lr_by_edge_id(s, e2)); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_stored_in_db) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrs", "/tmp/lrs"); + add_node(s, "lrs", "f1"); + add_node(s, "lrs", "f2"); + add_edge(s, "lrs", 1, 2, "CALLS"); + add_edge(s, "lrs", 2, 1, "IMPORTS"); + cbm_pagerank_compute_default(s, "lrs"); + ASSERT_EQ(count_table_rows(s, "linkrank"), 2); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_self_loop_edge) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrsl", "/tmp/lrsl"); + int64_t a = add_node(s, "lrsl", "recursive"); + int64_t eid = add_edge(s, "lrsl", a, a, "CALLS"); + cbm_pagerank_compute_default(s, "lrsl"); + ASSERT_EQ(count_table_rows(s, "linkrank"), 1); + ASSERT_TRUE(get_lr_by_edge_id(s, eid) > 0.0); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_no_edges) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrne", "/tmp/lrne"); + add_node(s, "lrne", "isolated"); + cbm_pagerank_compute_default(s, "lrne"); + ASSERT_EQ(count_table_rows(s, "linkrank"), 0); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_sum_equals_pagerank_sum) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrs2", "/tmp/lrs2"); + int64_t a = add_node(s, "lrs2", "a"); + int64_t b = add_node(s, "lrs2", "b"); + int64_t c = add_node(s, "lrs2", "c"); + add_edge(s, "lrs2", a, b, "CALLS"); + add_edge(s, "lrs2", b, c, "CALLS"); + add_edge(s, "lrs2", c, a, "CALLS"); + cbm_pagerank_compute_default(s, "lrs2"); + sqlite3 *db = cbm_store_get_db(s); + sqlite3_stmt *st = NULL; + double lr_sum = 0.0; + sqlite3_prepare_v2(db, "SELECT SUM(rank) FROM linkrank", -1, &st, NULL); + if (sqlite3_step(st) == SQLITE_ROW) lr_sum = sqlite3_column_double(st, 0); + sqlite3_finalize(st); + double pr_sum = get_pr(s, a) + get_pr(s, b) + get_pr(s, c); + ASSERT_TRUE(fabs(lr_sum - pr_sum) < 0.05); + cbm_store_close(s); + PASS(); +} + +/* ── 4. Integration: dep scoping ─────────────────────────── */ + +TEST(pagerank_after_dep_index) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "proj", "/tmp/proj"); + cbm_store_upsert_project(s, "proj.dep.lib", "/tmp/lib"); + int64_t a = add_node(s, "proj", "app_main"); + int64_t b = add_node(s, "proj.dep.lib", "lib_init"); + int64_t c = add_node(s, "proj.dep.lib", "lib_process"); + add_edge(s, "proj", a, b, "CALLS"); + add_edge(s, "proj.dep.lib", b, c, "CALLS"); + int rc = cbm_pagerank_compute_default(s, "proj"); + ASSERT_EQ(rc, 3); + ASSERT_TRUE(get_pr(s, c) > 0.0); + double total = get_pr(s, a) + get_pr(s, b) + get_pr(s, c); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + cbm_store_close(s); + PASS(); +} + +/* ── Suite registration ──────────────────────────────────── */ + +SUITE(pagerank) { + /* Core PageRank (14 tests) */ + RUN_TEST(pagerank_empty_graph); + RUN_TEST(pagerank_single_node); + RUN_TEST(pagerank_two_nodes_one_edge); + RUN_TEST(pagerank_cycle); + RUN_TEST(pagerank_star_topology); + RUN_TEST(pagerank_edge_weights); + RUN_TEST(pagerank_convergence); + RUN_TEST(pagerank_sum_to_one); + RUN_TEST(pagerank_stored_in_db); + RUN_TEST(pagerank_recompute_replaces); + RUN_TEST(pagerank_full_scope_includes_deps); + RUN_TEST(pagerank_project_scope_excludes_deps); + RUN_TEST(pagerank_dangling_nodes); + RUN_TEST(pagerank_null_safety); + /* Edge cases from igraph/NetworkX (13 tests) */ + RUN_TEST(pagerank_self_loop); + RUN_TEST(pagerank_disconnected_components); + RUN_TEST(pagerank_all_dangling_no_edges); + RUN_TEST(pagerank_complete_graph); + RUN_TEST(pagerank_multigraph_edges); + RUN_TEST(pagerank_large_graph_stability); + RUN_TEST(pagerank_zero_weight_edges); + RUN_TEST(pagerank_custom_damping_high); + RUN_TEST(pagerank_custom_damping_low); + RUN_TEST(pagerank_max_iter_zero); + RUN_TEST(pagerank_known_values); + RUN_TEST(pagerank_known_values_asymmetric); + RUN_TEST(pagerank_scope_deps_only); + /* LinkRank (7 tests) */ + RUN_TEST(linkrank_computed_from_pagerank); + RUN_TEST(linkrank_formula_correct); + RUN_TEST(linkrank_calls_higher_than_usage); + RUN_TEST(linkrank_stored_in_db); + RUN_TEST(linkrank_self_loop_edge); + RUN_TEST(linkrank_no_edges); + RUN_TEST(linkrank_sum_equals_pagerank_sum); + /* Integration (1 test) */ + RUN_TEST(pagerank_after_dep_index); +}