Skip to content

Commit 4bc70b2

Browse files
committed
fix(graph): bound non-square CSR OOB read, honor max_depth, loud-error path-tracking, fix HNSW recall + deterministic sort (review 2.9)
- MEMORY SAFETY: 8 traverse algorithms (pagerank/connected/louvain/degree/ topsort/cluster/betweenness/closeness) read OOB when a relation is non-square (fwd.n_nodes != rev.n_nodes) -> graph_require_square guard, loud 'domain' error (ASAN-confirmed real SEGV without it). - Dijkstra/A* max_depth was accepted then ignored (search ran unbounded) -> now honored (both track a hop counter); default 255 keeps full search. - var-expand path_tracking ('track-path') was accepted then ignored (only endpoints returned) -> loud 'nyi'; real path return is a large feature. - HNSW: at neighbor saturation the back-link was silently dropped (prune was dead because M_max_l == M_keep), degrading recall -> proper 'select M closest' replacement; removed the dead prune_neighbors. - CSR edge sort had no .row tie-break -> unstable rowmap for parallel edges; added .row tie-break for deterministic output. - review claim STALE: HNSW xorshift RNG (fixed seed) is intended determinism, not a bug -- left unchanged. Tests: non-square OOB bounded (ASAN), max_depth honored, CSR tie-break determinism, ANN recall@1 under forced saturation; re-baselined the var-expand track + HNSW tied-top-1 assertions. Clean-rebuild suite 3487/3489 0-failed, ASAN/UBSan + -Werror clean.
1 parent ce697f9 commit 4bc70b2

11 files changed

Lines changed: 416 additions & 40 deletions

src/ops/graph_builtin.c

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,12 @@ ray_t* ray_graph_var_expand_fn(ray_t** args, int64_t n) {
742742
return ray_error("type", "graph.var-expand: track-path must be a boolean or integer, got %s", ray_type_name(args[5]->type));
743743
track = (args[5]->type == -RAY_BOOL) ? (args[5]->b8 != 0)
744744
: (atom_to_i64(args[5]) != 0);
745+
/* Path tracking (returning the full intermediate node sequence per
746+
* reached endpoint) is not implemented: the BFS only records
747+
* (start, end, depth) endpoints, not parent chains. Rather than
748+
* silently ignore the flag (a lying API), reject it loudly. */
749+
if (track)
750+
return ray_error("nyi", "graph.var-expand: track-path is not supported (only start/end/depth endpoints are returned); pass track-path=false");
745751
}
746752

747753
ray_graph_t* g = ray_graph_new(NULL);

src/ops/traverse.c

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@
2727
* Graph execution functions
2828
* ============================================================================ */
2929

30+
/* Precondition for algorithms that iterate node ids in [0, fwd.n_nodes) while
31+
* dereferencing the reverse CSR at the same index (e.g. rev_off[i+1]). A
32+
* relation built with distinct src/dst node counts (ray_rel_from_edges with
33+
* n_src_nodes != n_dst_nodes) has fwd.n_nodes != rev.n_nodes; indexing the
34+
* smaller CSR's offset array with the larger bound reads out of bounds.
35+
*
36+
* Returns NULL when the relation is square (safe), otherwise a loud error
37+
* naming the op. Callers must propagate the returned error. */
38+
static ray_t* graph_require_square(const ray_rel_t* rel, const char* op_name) {
39+
if (rel->fwd.n_nodes != rel->rev.n_nodes)
40+
return ray_error("domain",
41+
"%s: requires a square relation (forward and reverse node counts "
42+
"must match), got fwd=%lld rev=%lld",
43+
op_name, (long long)rel->fwd.n_nodes, (long long)rel->rev.n_nodes);
44+
return NULL;
45+
}
46+
3047
/* exec_expand_factorized: emit factorized output for expand+group fusion.
3148
* Returns a table with _src (unique sources) and _count (degree per source).
3249
* This avoids materializing the full (src, dst) cross-product. */
@@ -293,6 +310,11 @@ ray_t* exec_var_expand(ray_graph_t* g, ray_op_t* op, ray_t* start_vec) {
293310
uint8_t direction = ext->graph.direction;
294311
uint8_t min_depth = ext->graph.min_depth;
295312
uint8_t max_depth = ext->graph.max_depth;
313+
/* path_tracking is accepted by the op constructor but unimplemented: the
314+
* BFS records (start, end, depth) endpoints only, not parent chains.
315+
* Surface a loud error instead of silently dropping the request. */
316+
if (ext->graph.path_tracking)
317+
return ray_error("nyi", "graph.var-expand: path tracking is not supported (only start/end/depth endpoints are returned)");
296318
ray_csr_t* csr_fwd = &rel->fwd;
297319
ray_csr_t* csr_rev = &rel->rev;
298320
/* For direction==2 (both), use fwd for n_nodes bound but expand both */
@@ -651,6 +673,7 @@ ray_t* exec_pagerank(ray_graph_t* g, ray_op_t* op) {
651673
double damping = ext->graph.damping;
652674

653675
if (n <= 0) return ray_error("length", "graph.pagerank: graph must have at least one node, got %lld", (long long)n);
676+
{ ray_t* sq = graph_require_square(rel, "graph.pagerank"); if (sq) return sq; }
654677

655678
/* Arena for all scratch memory — freed in one shot */
656679
ray_scratch_arena_t arena;
@@ -752,6 +775,7 @@ ray_t* exec_connected_comp(ray_graph_t* g, ray_op_t* op) {
752775

753776
int64_t n = rel->fwd.n_nodes;
754777
if (n <= 0) return ray_error("length", "graph.connected: graph must have at least one node, got %lld", (long long)n);
778+
{ ray_t* sq = graph_require_square(rel, "graph.connected"); if (sq) return sq; }
755779

756780
/* Arena for all scratch memory — freed in one shot */
757781
ray_scratch_arena_t arena;
@@ -939,6 +963,7 @@ ray_t* exec_dijkstra(ray_graph_t* g, ray_op_t* op,
939963

940964
int64_t n = rel->fwd.n_nodes;
941965
int64_t m = rel->fwd.n_edges;
966+
uint8_t max_depth = ext->graph.max_depth;
942967
int64_t src_id = ray_is_atom(src_val) ? src_val->i64 : ((int64_t*)ray_data(src_val))[0];
943968
int64_t dst_id = !dst_val ? -1 : ray_is_atom(dst_val) ? dst_val->i64 : ((int64_t*)ray_data(dst_val))[0];
944969

@@ -1000,6 +1025,11 @@ ray_t* exec_dijkstra(ray_graph_t* g, ray_op_t* op,
10001025

10011026
if (u == dst_id) break; /* early exit if destination reached */
10021027

1028+
/* Honor max-depth (hop bound): do not relax beyond max_depth hops
1029+
* from the source. depth[] is the natural hop counter. A path
1030+
* needing more than max_depth hops is simply not discovered. */
1031+
if (depth[u] >= max_depth) continue;
1032+
10031033
for (int64_t j = fwd_off[u]; j < fwd_off[u + 1]; j++) {
10041034
int64_t v = fwd_tgt[j];
10051035
int64_t edge_row = fwd_row[j];
@@ -1179,6 +1209,7 @@ ray_t* exec_louvain(ray_graph_t* g, ray_op_t* op) {
11791209
uint16_t max_iter = ext->graph.max_iter;
11801210

11811211
if (n <= 0) return ray_error("length", "graph.louvain: graph must have at least one node, got %lld", (long long)n);
1212+
{ ray_t* sq = graph_require_square(rel, "graph.louvain"); if (sq) return sq; }
11821213

11831214
/* Arena for all scratch memory — freed in one shot */
11841215
ray_scratch_arena_t arena;
@@ -1331,6 +1362,7 @@ ray_t* exec_degree_cent(ray_graph_t* g, ray_op_t* op) {
13311362

13321363
int64_t n = rel->fwd.n_nodes;
13331364
if (n <= 0) return ray_error("length", "graph.degree: graph must have at least one node, got %lld", (long long)n);
1365+
{ ray_t* sq = graph_require_square(rel, "graph.degree"); if (sq) return sq; }
13341366

13351367
int64_t* fwd_off = (int64_t*)ray_data(rel->fwd.offsets);
13361368
int64_t* rev_off = (int64_t*)ray_data(rel->rev.offsets);
@@ -1397,6 +1429,7 @@ ray_t* exec_topsort(ray_graph_t* g, ray_op_t* op) {
13971429

13981430
int64_t n = rel->fwd.n_nodes;
13991431
if (n <= 0) return ray_error("length", "graph.topsort: graph must have at least one node, got %lld", (long long)n);
1432+
{ ray_t* sq = graph_require_square(rel, "graph.topsort"); if (sq) return sq; }
14001433

14011434
int64_t* fwd_off = (int64_t*)ray_data(rel->fwd.offsets);
14021435
int64_t* fwd_tgt = (int64_t*)ray_data(rel->fwd.targets);
@@ -1490,6 +1523,7 @@ ray_t* exec_cluster_coeff(ray_graph_t* g, ray_op_t* op) {
14901523

14911524
int64_t n = rel->fwd.n_nodes;
14921525
if (n <= 0) return ray_error("length", "graph.cluster: graph must have at least one node, got %lld", (long long)n);
1526+
{ ray_t* sq = graph_require_square(rel, "graph.cluster"); if (sq) return sq; }
14931527

14941528
ray_scratch_arena_t arena;
14951529
ray_scratch_arena_init(&arena);
@@ -1592,6 +1626,7 @@ ray_t* exec_betweenness(ray_graph_t* g, ray_op_t* op) {
15921626

15931627
int64_t n = rel->fwd.n_nodes;
15941628
if (n <= 0) return ray_error("length", "graph.betweenness: graph must have at least one node, got %lld", (long long)n);
1629+
{ ray_t* sq = graph_require_square(rel, "graph.betweenness"); if (sq) return sq; }
15951630
uint16_t sample = ext->graph.max_iter;
15961631
int64_t n_sources = (sample > 0 && (int64_t)sample < n) ? (int64_t)sample : n;
15971632

@@ -1778,6 +1813,7 @@ ray_t* exec_closeness(ray_graph_t* g, ray_op_t* op) {
17781813

17791814
int64_t n = rel->fwd.n_nodes;
17801815
if (n <= 0) return ray_error("length", "graph.closeness: graph must have at least one node, got %lld", (long long)n);
1816+
{ ray_t* sq = graph_require_square(rel, "graph.closeness"); if (sq) return sq; }
17811817
uint16_t sample = ext->graph.max_iter;
17821818
int64_t n_sources = (sample > 0 && (int64_t)sample < n) ? (int64_t)sample : n;
17831819

@@ -2224,6 +2260,7 @@ ray_t* exec_astar(ray_graph_t* g, ray_op_t* op,
22242260

22252261
int64_t n = rel->fwd.n_nodes;
22262262
int64_t m = rel->fwd.n_edges;
2263+
uint8_t max_depth = ext->graph.max_depth;
22272264
int64_t src_id = src_val->i64;
22282265
int64_t dst_id = dst_val->i64;
22292266

@@ -2282,6 +2319,9 @@ ray_t* exec_astar(ray_graph_t* g, ray_op_t* op,
22822319

22832320
if (u == dst_id) break;
22842321

2322+
/* Honor max-depth (hop bound): stop expanding past max_depth hops. */
2323+
if (depth_a[u] >= max_depth) continue;
2324+
22852325
for (int64_t j = fwd_off[u]; j < fwd_off[u + 1]; j++) {
22862326
int64_t v = fwd_tgt[j];
22872327
int64_t edge_row = fwd_row[j];

src/store/csr.c

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,25 +44,35 @@ typedef struct {
4444
int64_t row; /* original row index for rowmap */
4545
} edge_pair_t;
4646

47-
/* Comparison by src then dst */
47+
/* Comparison by src then dst, with .row as a final tie-break.
48+
*
49+
* qsort is not a stable sort: for parallel edges (equal src AND equal dst)
50+
* the relative order of their original rows would otherwise be platform- and
51+
* input-dependent, making rowmap (and any golden test or MST that consumes it)
52+
* non-deterministic. The .row tie-break pins a total order. */
4853
static int cmp_edge_by_src(const void* a, const void* b) {
4954
const edge_pair_t* ea = (const edge_pair_t*)a;
5055
const edge_pair_t* eb = (const edge_pair_t*)b;
5156
if (ea->src < eb->src) return -1;
5257
if (ea->src > eb->src) return 1;
5358
if (ea->dst < eb->dst) return -1;
5459
if (ea->dst > eb->dst) return 1;
60+
if (ea->row < eb->row) return -1;
61+
if (ea->row > eb->row) return 1;
5562
return 0;
5663
}
5764

58-
/* Comparison by dst then src (for reverse CSR) */
65+
/* Comparison by dst then src (for reverse CSR), with .row as a final tie-break
66+
* for determinism (see cmp_edge_by_src). */
5967
static int cmp_edge_by_dst(const void* a, const void* b) {
6068
const edge_pair_t* ea = (const edge_pair_t*)a;
6169
const edge_pair_t* eb = (const edge_pair_t*)b;
6270
if (ea->dst < eb->dst) return -1;
6371
if (ea->dst > eb->dst) return 1;
6472
if (ea->src < eb->src) return -1;
6573
if (ea->src > eb->src) return 1;
74+
if (ea->row < eb->row) return -1;
75+
if (ea->row > eb->row) return 1;
6676
return 0;
6777
}
6878

src/store/hnsw.c

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -393,27 +393,45 @@ static int64_t hnsw_greedy_closest(const ray_hnsw_t* idx, const float* query,
393393
}
394394

395395
/* --------------------------------------------------------------------------
396-
* Neighbor pruning: keep M closest neighbors (simple selection)
397-
* -------------------------------------------------------------------------- */
398-
399-
static void prune_neighbors(const ray_hnsw_t* idx, int64_t node_id,
400-
int64_t* nb, int64_t M_max, int64_t M_keep) {
401-
/* Count current neighbors */
396+
* Neighbor selection: keep M closest neighbors (simple selection)
397+
* --------------------------------------------------------------------------
398+
*
399+
* Connect new_id into node_id's neighbor list, applying "keep M closest"
400+
* selection when the list is already full.
401+
*
402+
* Background: M_keep == M_max in this build (layer M_max equals the connect
403+
* budget M / M_max0), so the old prune_neighbors() guard `count <= M_keep`
404+
* was always true and prune was a no-op — meaning a saturated list silently
405+
* dropped every back-link, degrading recall. Here we instead consider the
406+
* combined set {existing neighbors} ∪ {new_id}, rank by distance to node_id,
407+
* and keep the M_keep closest. If new_id is farther than all M current
408+
* neighbors it is correctly not added (standard HNSW heuristic); otherwise it
409+
* displaces the current farthest.
410+
*
411+
* Returns true if new_id ended up in the list, false if it was farther than
412+
* all kept neighbors (a legitimate, non-silent drop). */
413+
static bool hnsw_connect_neighbor(const ray_hnsw_t* idx, int64_t node_id,
414+
int64_t* nb, int64_t M_max, int64_t M_keep,
415+
int64_t new_id) {
416+
/* Fast path: room available (or already present). */
417+
if (add_neighbor(nb, M_max, new_id)) return true;
418+
419+
/* List is full. Build combined candidate set and keep M_keep closest. */
402420
int64_t count = count_neighbors(nb, M_max);
403-
if (count <= M_keep) return;
421+
int64_t total = count + 1;
422+
hnsw_cand_t* ranked = (hnsw_cand_t*)ray_sys_alloc((size_t)total * sizeof(hnsw_cand_t));
423+
if (!ranked) return false; /* OOM: leave list unchanged (no corruption) */
404424

405-
/* Compute distances from node to each neighbor */
406425
const float* vec = idx->vectors + node_id * idx->dim;
407-
hnsw_cand_t* ranked = (hnsw_cand_t*)ray_sys_alloc((size_t)count * sizeof(hnsw_cand_t));
408-
if (!ranked) return;
409-
410426
for (int64_t i = 0; i < count; i++) {
411427
ranked[i].id = nb[i];
412428
ranked[i].dist = hnsw_dist(idx, vec, idx->vectors + nb[i] * idx->dim);
413429
}
430+
ranked[count].id = new_id;
431+
ranked[count].dist = hnsw_dist(idx, vec, idx->vectors + new_id * idx->dim);
414432

415-
/* Sort by distance ascending */
416-
for (int64_t i = 1; i < count; i++) {
433+
/* Insertion sort by distance ascending (lists are small, <= M_max+1). */
434+
for (int64_t i = 1; i < total; i++) {
417435
hnsw_cand_t key = ranked[i];
418436
int64_t j = i - 1;
419437
while (j >= 0 && ranked[j].dist > key.dist) {
@@ -423,12 +441,19 @@ static void prune_neighbors(const ray_hnsw_t* idx, int64_t node_id,
423441
ranked[j + 1] = key;
424442
}
425443

426-
/* Keep M_keep closest */
444+
bool kept = false;
445+
int64_t keep = (M_keep < M_max) ? M_keep : M_max;
427446
for (int64_t i = 0; i < M_max; i++) {
428-
nb[i] = (i < M_keep) ? ranked[i].id : -1;
447+
if (i < keep && i < total) {
448+
nb[i] = ranked[i].id;
449+
if (ranked[i].id == new_id) kept = true;
450+
} else {
451+
nb[i] = -1;
452+
}
429453
}
430454

431455
ray_sys_free(ranked);
456+
return kept;
432457
}
433458

434459
/* --------------------------------------------------------------------------
@@ -560,11 +585,10 @@ ray_hnsw_t* ray_hnsw_build(const float* vectors, int64_t n_nodes, int32_t dim,
560585
if (nb_local < 0) continue;
561586

562587
int64_t* their_nb = &layer->neighbors[nb_local * M_max_l];
563-
if (!add_neighbor(their_nb, M_max_l, i)) {
564-
/* Neighbor list full — prune to make room, then add i */
565-
prune_neighbors(idx, nb_id, their_nb, M_max_l, M_keep);
566-
add_neighbor(their_nb, M_max_l, i);
567-
}
588+
/* Insert i into nb_id's list, displacing the farthest neighbor
589+
* when full (keep the M_keep closest). Previously a full list
590+
* silently dropped the back-link, degrading recall. */
591+
hnsw_connect_neighbor(idx, nb_id, their_nb, M_max_l, M_keep, i);
568592
}
569593

570594
/* Update ep for next lower layer */

test/rfl/graph/graph_advanced.rfl

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,12 @@
182182
;; The reverse-CSR predecessors of sink node 5 are exactly {3,4}.
183183
(at VeRev '_end) -- [3 4]
184184

185-
;; track-path = true accepts the boolean form without changing schema
186-
;; (the underlying op records paths internally; the result table is the
187-
;; same {_start,_end,_depth} shape per the wrapper).
188-
(>= (count (.graph.var-expand G 0 1 2 0 true)) 4) -- true
185+
;; track-path = true is rejected loudly: path tracking is unimplemented
186+
;; (only {_start,_end,_depth} endpoints are returned), so rather than
187+
;; silently ignore the flag the wrapper returns a 'nyi error.
188+
(.graph.var-expand G 0 1 2 0 true) !- nyi
189+
;; track-path = false still succeeds.
190+
(>= (count (.graph.var-expand G 0 1 2 0 false)) 4) -- true
189191

190192
;; ====================================================================
191193
;; (.graph.betweenness h [sample])

test/rfl/graph/graph_builtin_branches.rfl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,10 +474,10 @@
474474
;; non-bool/non-int track -> type error (line 741-742)
475475
(.graph.var-expand Uw 0 1 2 0 "bad") !- type
476476

477-
;; track as integer (1) -> exercises atom_to_i64 path (line 744)
478-
(>= (count (.graph.var-expand Uw 0 1 2 0 1)) 1) -- true
477+
;; track as integer (1) -> truthy -> path tracking unsupported -> nyi error
478+
(.graph.var-expand Uw 0 1 2 0 1) !- nyi
479479

480-
;; track as integer (0) -> exercises atom_to_i64 path (line 744)
480+
;; track as integer (0) -> falsy -> exercises atom_to_i64 path, succeeds
481481
(>= (count (.graph.var-expand Uw 0 1 2 0 0)) 1) -- true
482482

483483
;; ====================================================================

test/rfl/graph/traverse_branch_cov.rfl

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,10 @@
228228
(>= (sum (at PrFC '_rank)) 0.99) -- true
229229
(<= (sum (at PrFC '_rank)) 1.01) -- true
230230

231-
;; (5) var-expand track-path integer-true 6-arg form (schema unchanged).
232-
(>= (count (.graph.var-expand FC 0 1 2 2 1)) 1) -- true
233-
;; track-path integer-false also accepted.
231+
;; (5) var-expand track-path integer-true 6-arg form: path tracking is
232+
;; unimplemented, so a truthy track-path flag is rejected loudly (nyi).
233+
(.graph.var-expand FC 0 1 2 2 1) !- nyi
234+
;; track-path integer-false is accepted (no tracking requested).
234235
(>= (count (.graph.var-expand FC 0 1 2 2 0)) 1) -- true
235236

236237
;; =====================================================================

0 commit comments

Comments
 (0)