From 2b884fce571339f42cc1340109a5f58ea57901e9 Mon Sep 17 00:00:00 2001 From: ruvnet Date: Sun, 28 Jun 2026 12:02:45 -0400 Subject: [PATCH 1/2] fix(graph-node): batchInsert nodes missing from label index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit batchInsert only populated the hypergraph adjacency/vector index (used by kHopNeighbors and stats) but never inserted nodes into the property graph + label index that the Cypher `MATCH (n:Label) RETURN n` scan reads. As a result, the fastest ingest path produced query-invisible nodes: they were counted in stats() and traversable by kHopNeighbors, but a label-scoped MATCH returned 0. createNode did both; batchInsert did not. Extract the shared index-registration logic into a single `register_node` helper (single source of truth) and call it from both createNode and batchInsert so the hypergraph index, property graph + label index, and optional storage all stay consistent. batchInsert now also honors per-node labels/properties (previously ignored). Adds a Rust regression test asserting that nodes registered via the shared path are consistently visible through all three read surfaces: label-scoped scan (get_nodes_by_label), kHop adjacency (k_hop_neighbors), and stats() entity counts. Discovered via agent-harness-generator ruvector benchmarking (GRAPH-ANALYTICS-PROOF §5). Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_019rVRYrRDKyxYK18kuVrDSf --- crates/ruvector-graph-node/src/lib.rs | 181 +++++++++++++++++++++----- 1 file changed, 147 insertions(+), 34 deletions(-) diff --git a/crates/ruvector-graph-node/src/lib.rs b/crates/ruvector-graph-node/src/lib.rs index 5cfdc6e6e9..028686653e 100644 --- a/crates/ruvector-graph-node/src/lib.rs +++ b/crates/ruvector-graph-node/src/lib.rs @@ -17,6 +17,7 @@ use ruvector_graph::cypher::{parse_cypher, Statement}; use ruvector_graph::node::NodeBuilder; use ruvector_graph::storage::GraphStorage; use ruvector_graph::GraphDB; +use std::collections::HashMap; use std::sync::{Arc, RwLock}; mod streaming; @@ -46,6 +47,56 @@ pub struct GraphDatabase { storage_path: Option, } +/// Register a node into BOTH backing indexes so they stay consistent: +/// 1. the hypergraph adjacency / vector index (used by `kHopNeighbors`, +/// `stats`, and `searchHyperedges`), and +/// 2. the property graph + label index (used by the Cypher +/// `MATCH (n:Label) RETURN n` label scan). +/// +/// This is the single source of truth shared by `create_node` and +/// `batch_insert`. Previously `batch_insert` only populated the hypergraph, +/// so bulk-loaded nodes were counted in `stats()` and traversable by +/// `kHopNeighbors` but invisible to the label-scoped Cypher scan. +fn register_node( + hg: &mut CoreHypergraphIndex, + gdb: &mut GraphDB, + storage: Option<&Arc>>, + id: String, + embedding: Vec, + labels: Option>, + properties: Option>, +) -> Result<()> { + // 1. Adjacency / vector index (kHop, stats, hyperedge search). + hg.add_entity(id.clone(), embedding); + + // 2. Property graph + label index (Cypher label scan). + let mut builder = NodeBuilder::new().id(&id); + if let Some(node_labels) = labels { + for label in node_labels { + builder = builder.label(&label); + } + } + if let Some(props) = properties { + for (key, value) in props { + builder = builder.property(&key, value); + } + } + let graph_node = builder.build(); + + // Persist to storage if enabled (mirrors create_node behaviour). + if let Some(storage_arc) = storage { + let storage_guard = storage_arc.write().expect("Storage RwLock poisoned"); + storage_guard + .insert_node(&graph_node) + .map_err(|e| Error::from_reason(format!("Failed to persist node: {}", e)))?; + } + + gdb.create_node(graph_node) + .map_err(|e| Error::from_reason(format!("Failed to create node: {}", e)))?; + + Ok(()) +} + #[napi] impl GraphDatabase { /// Create a new graph database @@ -145,40 +196,18 @@ impl GraphDatabase { let labels = node.labels.clone(); tokio::task::spawn_blocking(move || { - // Add to hypergraph index let mut hg = hypergraph.write().expect("RwLock poisoned"); - hg.add_entity(id.clone(), embedding); - - // Add to property graph let mut gdb = graph_db.write().expect("RwLock poisoned"); - let mut builder = NodeBuilder::new().id(&id); - - // Add labels if provided - if let Some(node_labels) = labels { - for label in node_labels { - builder = builder.label(&label); - } - } - - // Add properties if provided - if let Some(props) = properties { - for (key, value) in props { - builder = builder.property(&key, value); - } - } - let graph_node = builder.build(); - - // Persist to storage if enabled - if let Some(ref storage_arc) = storage { - let storage_guard = storage_arc.write().expect("Storage RwLock poisoned"); - storage_guard - .insert_node(&graph_node) - .map_err(|e| Error::from_reason(format!("Failed to persist node: {}", e)))?; - } - - gdb.create_node(graph_node) - .map_err(|e| Error::from_reason(format!("Failed to create node: {}", e)))?; + register_node( + &mut hg, + &mut gdb, + storage.as_ref(), + id.clone(), + embedding, + labels, + properties, + )?; Ok::(id) }) @@ -482,18 +511,32 @@ impl GraphDatabase { #[napi] pub async fn batch_insert(&self, batch: JsBatchInsert) -> Result { let hypergraph = self.hypergraph.clone(); + let graph_db = self.graph_db.clone(); + let storage = self.storage.clone(); let nodes = batch.nodes; let edges = batch.edges; tokio::task::spawn_blocking(move || { let mut hg = hypergraph.write().expect("RwLock poisoned"); + let mut gdb = graph_db.write().expect("RwLock poisoned"); let mut node_ids = Vec::new(); let mut edge_ids = Vec::new(); - // Insert nodes + // Insert nodes into BOTH the hypergraph index and the property + // graph + label index (so bulk-loaded nodes are also visible to + // the Cypher `MATCH (n:Label)` scan, not just kHop/stats). for node in nodes { - hg.add_entity(node.id.clone(), node.embedding.to_vec()); - node_ids.push(node.id); + let id = node.id.clone(); + register_node( + &mut hg, + &mut gdb, + storage.as_ref(), + id.clone(), + node.embedding.to_vec(), + node.labels, + node.properties, + )?; + node_ids.push(id); } // Insert edges @@ -685,3 +728,73 @@ pub fn version() -> String { pub fn hello() -> String { "Hello from RuVector Graph Node.js bindings!".to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression test for the `batchInsert` ↔ label-index consistency bug. + /// + /// Both `create_node` and `batch_insert` funnel through `register_node`. + /// This test drives `register_node` directly (the same code path the batch + /// insert uses) and asserts that the resulting node is consistently visible + /// through all three read surfaces: + /// 1. `get_nodes_by_label` — the Cypher `MATCH (n:Label)` label scan, + /// 2. `k_hop_neighbors` — undirected adjacency traversal, + /// 3. `stats` — entity counts. + /// + /// Before the fix the batch path skipped (1), so this would report 0 nodes + /// for the label scan while (2) and (3) saw the nodes. + #[test] + fn batch_inserted_nodes_are_visible_to_label_scan_khop_and_stats() { + let mut hg = CoreHypergraphIndex::new(DistanceMetric::Cosine); + let mut gdb = GraphDB::new(); + + let n = 5usize; + // Insert N labeled nodes via the shared registration path. + for i in 0..n { + register_node( + &mut hg, + &mut gdb, + None, + format!("p{i}"), + vec![i as f32, i as f32, i as f32, i as f32], + Some(vec!["Person".to_string()]), + Some(HashMap::from([("name".to_string(), format!("name{i}"))])), + ) + .expect("register_node should succeed"); + } + + // Chain edges p0->p1->...->p{n-1} so kHop has something to traverse. + for i in 0..(n - 1) { + let edge = CoreHyperedge::new( + vec![format!("p{i}"), format!("p{}", i + 1)], + "knows".to_string(), + vec![0.0, 0.0, 0.0, 1.0], + 1.0, + ); + hg.add_hyperedge(edge).expect("add_hyperedge should succeed"); + } + + // 1. Label scan (the bug): every batch-inserted node must appear. + let by_label = gdb.get_nodes_by_label("Person"); + assert_eq!( + by_label.len(), + n, + "label-scoped MATCH must see all {n} batch-inserted nodes (regression: was 0)" + ); + + // 2. kHop adjacency: p0's 2-hop ball includes itself, p1 and p2. + let neighbors = hg.k_hop_neighbors("p0".to_string(), 2); + assert!(neighbors.contains("p0"), "kHop ball includes the start node"); + assert!(neighbors.contains("p1"), "kHop ball includes 1-hop neighbor"); + assert!(neighbors.contains("p2"), "kHop ball includes 2-hop neighbor"); + + // 3. stats entity count stays consistent with the above. + let stats = hg.stats(); + assert_eq!( + stats.total_entities, n, + "stats() entity count must match the inserted node count" + ); + } +} From 3ed48bcbfea5a0c783cc1886b5d70dc11db1db8a Mon Sep 17 00:00:00 2001 From: ruvnet Date: Sun, 28 Jun 2026 13:45:27 -0400 Subject: [PATCH 2/2] fix(ci): rustfmt graph-node test + sync Cargo.lock to ruvector-sona 0.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cargo fmt on crates/ruvector-graph-node/src/lib.rs (Rustfmt CI) - regenerate Cargo.lock so the local ruvector-sona workspace member resolves at 0.2.1 (offline, no external version bumps) — fixes `cargo metadata --locked` lockfile-integrity check Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_019rVRYrRDKyxYK18kuVrDSf --- Cargo.lock | 40 +++++++++++++-------------- crates/ruvector-graph-node/src/lib.rs | 18 +++++++++--- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d1a578541..3a2c896a17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5202,7 +5202,7 @@ dependencies = [ "hex", "regex-lite", "reqwest 0.12.28", - "ruvector-sona 0.2.0", + "ruvector-sona 0.2.1", "serde", "serde_json", "sha3", @@ -5237,7 +5237,7 @@ dependencies = [ "ruvector-mincut 2.2.3", "ruvector-nervous-system", "ruvector-solver", - "ruvector-sona 0.2.0", + "ruvector-sona 0.2.1", "ruvector-sparsifier", "ruvllm 2.2.3", "rvf-crypto", @@ -7247,7 +7247,7 @@ dependencies = [ "ruvector-mincut 2.2.3", "ruvector-nervous-system", "ruvector-raft", - "ruvector-sona 0.2.0", + "ruvector-sona 0.2.1", "ruvllm 2.3.0", "serde", "serde_json", @@ -10444,36 +10444,36 @@ dependencies = [ [[package]] name = "ruvector-sona" version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a9465310393f43fc9ba2409cbf819c543ef16e0eb1791090351bfc4833c158" dependencies = [ - "console_error_panic_hook", - "criterion 0.5.1", "crossbeam", "getrandom 0.2.17", - "js-sys", - "napi", - "napi-derive", - "once_cell", "parking_lot 0.12.5", "rand 0.8.6", "serde", "serde_json", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", ] [[package]] name = "ruvector-sona" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02a9465310393f43fc9ba2409cbf819c543ef16e0eb1791090351bfc4833c158" +version = "0.2.1" dependencies = [ + "console_error_panic_hook", + "criterion 0.5.1", "crossbeam", "getrandom 0.2.17", + "js-sys", + "napi", + "napi-derive", + "once_cell", "parking_lot 0.12.5", "rand 0.8.6", "serde", "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -10576,7 +10576,7 @@ version = "2.2.3" [[package]] name = "ruvector-timesfm" -version = "2.2.3" +version = "2.2.4" dependencies = [ "anyhow", "candle-core 0.9.2", @@ -10926,7 +10926,7 @@ dependencies = [ "ruvector-core 2.2.3", "ruvector-gnn", "ruvector-graph", - "ruvector-sona 0.2.0", + "ruvector-sona 0.2.1", "serde", "serde_json", "sha2 0.10.9", @@ -10962,7 +10962,7 @@ dependencies = [ "rand 0.8.6", "regex", "ruvector-core 2.2.3 (registry+https://github.com/rust-lang/crates.io-index)", - "ruvector-sona 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "ruvector-sona 0.2.0", "serde", "serde_json", "sha2 0.10.9", @@ -11245,7 +11245,7 @@ dependencies = [ "dashmap 6.2.1", "mockall", "parking_lot 0.12.5", - "ruvector-sona 0.2.0", + "ruvector-sona 0.2.1", "rvagent-backends", "rvagent-core", "serde", @@ -12760,7 +12760,7 @@ dependencies = [ [[package]] name = "timesfm" -version = "2.2.3" +version = "2.2.4" dependencies = [ "anyhow", "candle-core 0.9.2", diff --git a/crates/ruvector-graph-node/src/lib.rs b/crates/ruvector-graph-node/src/lib.rs index 028686653e..7f0d268149 100644 --- a/crates/ruvector-graph-node/src/lib.rs +++ b/crates/ruvector-graph-node/src/lib.rs @@ -773,7 +773,8 @@ mod tests { vec![0.0, 0.0, 0.0, 1.0], 1.0, ); - hg.add_hyperedge(edge).expect("add_hyperedge should succeed"); + hg.add_hyperedge(edge) + .expect("add_hyperedge should succeed"); } // 1. Label scan (the bug): every batch-inserted node must appear. @@ -786,9 +787,18 @@ mod tests { // 2. kHop adjacency: p0's 2-hop ball includes itself, p1 and p2. let neighbors = hg.k_hop_neighbors("p0".to_string(), 2); - assert!(neighbors.contains("p0"), "kHop ball includes the start node"); - assert!(neighbors.contains("p1"), "kHop ball includes 1-hop neighbor"); - assert!(neighbors.contains("p2"), "kHop ball includes 2-hop neighbor"); + assert!( + neighbors.contains("p0"), + "kHop ball includes the start node" + ); + assert!( + neighbors.contains("p1"), + "kHop ball includes 1-hop neighbor" + ); + assert!( + neighbors.contains("p2"), + "kHop ball includes 2-hop neighbor" + ); // 3. stats entity count stays consistent with the above. let stats = hg.stats();