Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/workflows/build-graph-node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,31 @@ jobs:
working-directory: npm/packages/graph-node
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public || echo "Package may already exist"
run: |
# Same class as #1007: never swallow a failed publish.
VERSION=$(node -p "require('./package.json').version")
if npm view "@ruvector/graph-node@${VERSION}" version >/dev/null 2>&1; then
echo "@ruvector/graph-node@${VERSION} already published"
else
npm publish --access public
fi

- name: Verify all packages are on the registry
working-directory: npm/packages/graph-node
run: |
VERSION=$(node -p "require('./package.json').version")
for pkg in @ruvector/graph-node @ruvector/graph-node-linux-x64-gnu \
@ruvector/graph-node-linux-arm64-gnu @ruvector/graph-node-darwin-x64 \
@ruvector/graph-node-darwin-arm64 @ruvector/graph-node-win32-x64-msvc; do
ok=0
for i in $(seq 1 18); do
if npm view "${pkg}@${VERSION}" version >/dev/null 2>&1; then ok=1; break; fi
echo "waiting for ${pkg}@${VERSION} to propagate (attempt ${i}/18)..."
sleep 10
done
if [ "$ok" != 1 ]; then
echo "::error::${pkg}@${VERSION} is not resolvable on npm"
exit 1
fi
echo "verified ${pkg}@${VERSION}"
done
15 changes: 15 additions & 0 deletions .github/workflows/ruvector-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,18 @@ jobs:
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Verify on the registry
if: ${{ github.event.inputs.dry_run != 'true' }}
run: |
# #1007: the job's conclusion must mean "it is on the registry".
VERSION=$(node -p "require('./package.json').version")
for i in $(seq 1 18); do
if npm view "ruvector@${VERSION}" version >/dev/null 2>&1; then
echo "verified ruvector@${VERSION}"; exit 0
fi
echo "waiting for ruvector@${VERSION} to propagate (attempt ${i}/18)..."
sleep 10
done
echo "::error::ruvector@${VERSION} is not resolvable on npm"
exit 1
167 changes: 143 additions & 24 deletions crates/ruvector-graph-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,30 @@ fn run_query(
})
}

/// Build the stored property map for an edge: the caller's public `metadata`
/// (#984 — it was previously dropped) plus this binding's internal `__` keys.
/// Internal keys are inserted last so caller metadata can never spoof them.
fn edge_properties(
metadata: Option<HashMap<String, String>>,
confidence: f32,
embedding: Vec<f32>,
) -> HashMap<String, PropertyValue> {
let mut properties: HashMap<String, PropertyValue> = metadata
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, PropertyValue::String(v)))
.collect();
properties.insert(
"__confidence".to_string(),
PropertyValue::FloatArray(vec![confidence]),
);
properties.insert(
"__embedding".to_string(),
PropertyValue::FloatArray(embedding),
);
properties
}

/// Properties this binding stores for its own use rather than the caller's.
///
/// Embeddings and edge confidences are persisted as ordinary properties under a
Expand Down Expand Up @@ -487,6 +511,7 @@ impl GraphDatabase {
let description = edge.description.clone();
let embedding = edge.embedding.to_vec();
let confidence = edge.confidence.unwrap_or(1.0) as f32;
let metadata = edge.metadata;

let hydrated = self.hydrated.clone();
tokio::task::spawn_blocking(move || {
Expand All @@ -499,16 +524,7 @@ impl GraphDatabase {
.map_err(|e| Error::from_reason(format!("Failed to create edge: {}", e)))?;
drop(hg);

let properties = HashMap::from([
(
"__confidence".to_string(),
PropertyValue::FloatArray(vec![confidence]),
),
(
"__embedding".to_string(),
PropertyValue::FloatArray(embedding),
),
]);
let properties = edge_properties(metadata, confidence, embedding);
let graph_edge = GraphEdge::new(edge_id.clone(), from, to, description, properties);
if let Some(storage_arc) = storage {
storage_arc
Expand Down Expand Up @@ -548,6 +564,7 @@ impl GraphDatabase {
let description = hyperedge.description.clone();
let embedding = hyperedge.embedding.to_vec();
let confidence = hyperedge.confidence.unwrap_or(1.0) as f32;
let metadata = hyperedge.metadata;

let graph_db = self.graph_db.clone();
let hydrated = self.hydrated.clone();
Expand All @@ -571,10 +588,18 @@ impl GraphDatabase {
nodes,
edge_type: "HYPEREDGE".to_string(),
description: Some(description),
properties: HashMap::from([(
"__embedding".to_string(),
PropertyValue::FloatArray(embedding),
)]),
properties: {
let mut props: HashMap<String, PropertyValue> = metadata
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, PropertyValue::String(v)))
.collect();
props.insert(
"__embedding".to_string(),
PropertyValue::FloatArray(embedding),
);
props
},
confidence,
};
storage_arc
Expand Down Expand Up @@ -814,16 +839,7 @@ impl GraphDatabase {
edge.from,
edge.to,
edge.description,
HashMap::from([
(
"__confidence".to_string(),
PropertyValue::FloatArray(vec![confidence]),
),
(
"__embedding".to_string(),
PropertyValue::FloatArray(embedding),
),
]),
edge_properties(edge.metadata, confidence, embedding),
);
if let Some(storage_arc) = storage.as_ref() {
storage_arc
Expand Down Expand Up @@ -1258,6 +1274,109 @@ mod tests {
std::fs::remove_file(path).expect("remove test database");
}

/// #984: public edge metadata from createEdge and batchInsert must be
/// stored, returned by query(), survive a reopen, and never override the
/// binding's internal `__` properties.
#[tokio::test]
async fn edge_metadata_round_trips_through_query_and_reopen() {
let path = temp_storage_path("edge-metadata");
let db = GraphDatabase::new(Some(JsGraphOptions {
distance_metric: Some(JsDistanceMetric::Cosine),
dimensions: Some(2),
storage_path: Some(path.clone()),
}))
.expect("create persistent database");
for id in ["a", "b", "c"] {
db.create_node(JsNode {
id: id.to_string(),
embedding: Float32Array::new(vec![1.0, 0.0]),
labels: None,
properties: None,
})
.await
.expect("create node");
}
let metadata = |edge: &str| {
HashMap::from([
("sourceEdgeId".to_string(), edge.to_string()),
("weight".to_string(), "0.8".to_string()),
("note".to_string(), "témoin ✓".to_string()),
("__confidence".to_string(), "spoofed".to_string()),
])
};
db.create_edge(JsEdge {
from: "a".to_string(),
to: "b".to_string(),
description: "supports".to_string(),
embedding: Float32Array::new(vec![1.0, 0.0]),
confidence: Some(0.75),
metadata: Some(metadata("single")),
})
.await
.expect("create edge");
db.batch_insert(JsBatchInsert {
nodes: vec![],
edges: vec![JsEdge {
from: "a".to_string(),
to: "c".to_string(),
description: "supports".to_string(),
embedding: Float32Array::new(vec![1.0, 0.0]),
confidence: Some(0.75),
metadata: Some(metadata("batch")),
}],
})
.await
.expect("batch insert");

let check = |result: JsQueryResult| {
assert_eq!(result.edges.len(), 2, "both edges returned");
let mut seen: Vec<String> = result
.edges
.iter()
.map(|e| {
assert_eq!(e.properties.get("weight").map(String::as_str), Some("0.8"));
assert_eq!(
e.properties.get("note").map(String::as_str),
Some("témoin ✓")
);
assert!(!e.properties.contains_key("__confidence"));
e.properties["sourceEdgeId"].clone()
})
.collect();
seen.sort();
assert_eq!(seen, vec!["batch".to_string(), "single".to_string()]);
};
check(
db.query("MATCH (a)-[r]->(b) RETURN a,r,b".to_string())
.await
.expect("query"),
);
drop(db);

let reopened = GraphDatabase::open(path.clone()).expect("reopen");
check(
reopened
.query("MATCH (a)-[r]->(b) RETURN a,r,b".to_string())
.await
.expect("query after reopen"),
);
// The spoofed key must not have replaced the real confidence.
let stored = reopened
.graph_db
.read()
.expect("graph lock")
.get_edges_by_type("supports");
assert_eq!(stored.len(), 2);
for edge in stored {
assert_eq!(
prop_to_f32_vec(edge.properties.get("__confidence")),
vec![0.75]
);
}
drop(reopened);
std::fs::remove_file(path).expect("remove test database");
}

#[tokio::test]
async fn non_cascade_retains_durable_relationships_but_cascade_removes_them() {
let non_cascade_path = temp_storage_path("non-cascade");
Expand Down
52 changes: 21 additions & 31 deletions crates/ruvector-kge/src/ann.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,23 @@
//! entity's [`Scorer::index_vector`], retrieve candidates for a query, then
//! exact-rerank with the true score.
//!
//! ## Why not `DistanceMetric::DotProduct` directly
//! ## Why the MIPS→L2 route rather than `DistanceMetric::DotProduct`
//!
//! ADR-001 names a `DotProduct` HNSW, but router-core's `DotProduct` is a
//! *similarity*, not a distance, and the index treats it as one incorrectly for
//! MIPS. Two facts from `crates/ruvector-router-core/src/`:
//! ADR-001 names a `DotProduct` HNSW. router-core's `DotProduct` is already a
//! proper distance for that: `distance::dot_product` returns `-(a·b)`, so the
//! index's smallest-distance-first search is a maximum-inner-product search.
//! Do **not** negate the query on top of that — doing so searches for the
//! *minimum* inner product (recall@10 = 0.000, as reported in #1009). With the
//! query passed through unchanged the same table measures recall@10 = 1.000; see
//! `dotproduct_recall_matches_mips`.
//!
//! - `distance.rs:18` returns the raw dot product for `DotProduct`, and
//! `index.rs`'s search/build keep the **smallest** "distance" (min-heap).
//! So a `DotProduct` index retrieves the *minimum* inner product, the
//! opposite of MIPS. Negating the query flips the search target but not the
//! graph: `insert` (`index.rs:135`) wires each node to its lowest-dot
//! neighbours, and that entity-to-entity orientation is symmetric under any
//! global sign flip — no vector transform fixes it.
//!
//! - The robust, exact fix is the standard MIPS→L2 reduction (Bachrach et al.
//! 2014): append one coordinate so Euclidean nearest-neighbour equals maximum
//! inner product. With `φ² = max_e ‖x_e‖²`,
//! `x_e' = [x_e ; √(φ² − ‖x_e‖²)]` and `q' = [q ; 0]`, then
//! `‖q' − x_e'‖² = ‖q‖² + φ² − 2 q·x_e`, so minimising L2 maximises `q·x_e`.
//! Euclidean **is** a proper distance, so the HNSW graph is built and searched
//! consistently. This is the "handle it and document" path the task allows.
//!
//! For the record, a raw-`DotProduct`-with-negated-query index measured well
//! below the L2 route on the same table — see the `#[ignore]`d
//! `raw_dotproduct_recall_for_the_record` test.
//! This module keeps the L2 reduction because Euclidean is a metric (non-negative,
//! zero on identity), which the neighbour-selection heuristic's pruning
//! comparisons are written against. The standard MIPS→L2 reduction (Bachrach
//! et al. 2014) appends one coordinate so Euclidean nearest-neighbour equals
//! maximum inner product. With `φ² = max_e ‖x_e‖²`,
//! `x_e' = [x_e ; √(φ² − ‖x_e‖²)]` and `q' = [q ; 0]`, then
//! `‖q' − x_e'‖² = ‖q‖² + φ² − 2 q·x_e`, so minimising L2 maximises `q·x_e`.

use crate::scorer::Scorer;
use crate::{Candidate, EntityId, KgeError, Result, Tables};
Expand Down Expand Up @@ -193,11 +185,10 @@ mod tests {
assert!(recall >= 0.9, "recall@{k} = {recall:.4} < 0.9");
}

/// Not a gate — records how the raw `DotProduct` + negated-query route does
/// on the same table, so the deviation to L2 is backed by a number.
/// Gate for #1009: router-core's `DotProduct` HNSW, queried with the
/// *unmodified* query vector, is a maximum-inner-product search.
#[test]
#[ignore = "diagnostic: documents the rejected DotProduct route"]
fn raw_dotproduct_recall_for_the_record() {
fn dotproduct_recall_matches_mips() {
use ruvector_router_core::index::{HnswConfig, HnswIndex};
use ruvector_router_core::types::{DistanceMetric, SearchQuery};

Expand Down Expand Up @@ -238,11 +229,9 @@ mod tests {
Side::Tail,
);
let truth: Vec<EntityId> = batch.top_k(&q, k).into_iter().map(|c| c.entity).collect();
// Negate the query to turn the min-heap into a max-inner-product search.
let neg: Vec<f32> = q.iter().map(|x| -x).collect();
let results = hnsw
.search(&SearchQuery {
vector: neg,
vector: q.clone(),
k: ef,
filters: None,
threshold: None,
Expand All @@ -266,6 +255,7 @@ mod tests {
hits += truth.iter().filter(|e| got.contains(e)).count();
}
let recall = hits as f32 / (n_queries * k) as f32;
println!("RAW DotProduct+negated-query recall@{k} = {recall:.4} (rejected route)");
println!("DotProduct recall@{k} = {recall:.4}");
assert!(recall >= 0.9, "DotProduct recall@{k} = {recall:.4} < 0.9");
}
}
Loading
Loading