merge pagerank-ranking: add PageRank node + LinkRank edge ranking

Merge pagerank-ranking branch into combined token-reduction +
reference-api-indexing branch. Resolves conflicts in mcp.c
(sort_by coexists with compact/summary/token-reduction params),
Makefile.cbm (restore TEST_TOKEN_REDUCTION_SRCS lost in merge).

All 2119 tests pass (35 PageRank + 22 token-reduction + 2062 existing).
This commit is contained in:
Andrew Hundt
2026-03-21 23:29:31 -04:00
11 changed files with 1258 additions and 45 deletions
+9 -4
View File
@@ -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 ────────────────────────────────────────────
+11 -11
View File
@@ -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, &params, &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,
+2
View File
@@ -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);
+26
View File
@@ -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);
+381
View File
@@ -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 <foundation/log.h>
#include <foundation/platform.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sqlite3.h>
/* ── 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;
}
+83
View File
@@ -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 <store/store.h>
/* ── 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 */
+88 -24
View File
@@ -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);
+5
View File
@@ -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. */
-6
View File
@@ -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"));
+4
View File
@@ -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);
+649
View File
@@ -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 <store/store.h>
#include <pagerank/pagerank.h>
#include <string.h>
#include <stdlib.h>
#include <math.h>
#include <sqlite3.h>
/* ── 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);
}