//! `describe()` goldens for relationship embedding stores.
use crate::graph::dir_graph::DirGraph;
use crate::graph::introspection::describe::{compute_description, DescribeRequest};
use crate::graph::introspection::{ConnectionDetail, CypherDetail, DescribeSurface};
use crate::graph::session::execute::{execute_mut, ExecuteOptions};
fn run(graph: &mut DirGraph, query: &str) {
let params = std::collections::HashMap::new();
execute_mut(graph, query, &ExecuteOptions::eager(¶ms))
.unwrap_or_else(|e| panic!("setup query failed: {query}: {e}"));
}
/// Node type and relationship type both named `SUPPORTS`; `node_store` and
/// `edge_store` choose which carries a `body_emb` store (dim 3 on the node
/// side, dim 2 on the relationship side, so the two are distinguishable).
fn graph(node_store: bool, edge_store: bool) -> DirGraph {
let mut graph = DirGraph::new();
run(
&mut graph,
"CREATE (:SUPPORTS {id: 1, title: 'a', body: 'node one'}), \
(:SUPPORTS {id: 2, title: 'b', body: 'node two'})",
);
run(
&mut graph,
"MATCH (a:SUPPORTS {id: 1}), (b:SUPPORTS {id: 2}) \
CREATE (a)-[:SUPPORTS {body: 'edge text'}]->(b), (a)-[:SUPPORTS {body: 'more'}]->(b)",
);
if node_store {
crate::graph::embeddings::set_embeddings(
&mut graph,
"SUPPORTS",
"body",
None,
[
(
crate::datatypes::values::Value::Int64(1),
vec![1.0, 0.0, 0.0],
),
(
crate::datatypes::values::Value::Int64(2),
vec![0.0, 1.0, 0.0],
),
],
)
.unwrap();
}
if edge_store {
run(
&mut graph,
"MATCH ()-[r:SUPPORTS {body: 'edge text'}]->() \
CALL db.relationship_embeddings.set({type:'SUPPORTS', text_column:'body', \
entries:[{relationship:r, vector:[0.6, 0.8]}]}) YIELD stored RETURN stored",
);
}
graph
}
fn describe_with(graph: &DirGraph, connections: &ConnectionDetail) -> String {
let mut request = DescribeRequest::new(DescribeSurface::Python);
request.connections = connections;
compute_description(graph, &request).unwrap()
}
fn inventory(graph: &DirGraph) -> String {
describe_with(graph, &ConnectionDetail::Off)
}
fn line_with<'a>(xml: &'a str, needle: &str) -> &'a str {
xml.lines()
.find(|line| line.contains(needle))
.unwrap_or_else(|| panic!("no line containing {needle:?} in:\n{xml}"))
}
const CONN_LINE: &str = "<conn type=\"SUPPORTS\" count=\"2\" from=\"SUPPORTS\" to=\"SUPPORTS\" \
properties=\"body:String\" embeddings=\"body(dim=2,count=1)\"/>";
/// The node-only hint, pinned byte-for-byte (`embedding_norm` takes the store
/// name, `'col_emb'` — the raw column spelling was a false claim).
const NODE_SEMANTIC_LINE: &str = " <semantic hint=\"text_score(n, 'col', 'query'|[0.1,0.2,...], metric) — similarity; a list query is scored as your query vector, a string query is embedded via set_embedder() (metric: 'cosine'|'poincare'|'dot_product'|'euclidean'); vector_score(n, 'col_emb', $v) scores against a vector (ORDER BY … DESC LIMIT k is served from the store, through HNSW once indexed); embedding_norm(n, 'col_emb') — L2 norm (hierarchy depth in Poincaré space); CALL db.node_embeddings.query({type:'T' | types:['A','B'], text_column:'col', vector:$v | text:'query', top_k:10}) YIELD node, score, search_method, type ranks whole stores, and db.node_embeddings.set / .embed / .build_index / .list manage them in a query (db.embeddings.* routes by entity); describe(cypher=['node_semantic']) has the details\"/>";
#[test]
fn the_inventory_map_names_the_relationship_store_on_its_conn_line() {
let xml = inventory(&graph(true, true));
assert_eq!(line_with(&xml, "<conn type=\"SUPPORTS\"").trim(), CONN_LINE);
// The node store keeps its own element and dimension: the same-name pair
// is never merged.
assert!(xml.contains("<embeddings text_col=\"body\" dim=\"3\" count=\"2\"/>"));
}
#[test]
fn the_connections_overview_carries_the_same_attribute() {
let xml = describe_with(&graph(false, true), &ConnectionDetail::Overview);
assert_eq!(line_with(&xml, "<conn type=\"SUPPORTS\"").trim(), CONN_LINE);
}
#[test]
fn the_connection_detail_view_lists_each_store_as_a_child() {
let detail = ConnectionDetail::Topics(vec!["SUPPORTS".to_string()]);
let xml = describe_with(&graph(false, true), &detail);
assert_eq!(
line_with(&xml, "<embeddings "),
" <embeddings text_col=\"body\" dim=\"2\" count=\"1\"/>"
);
let without = describe_with(&graph(true, false), &detail);
assert!(
!without.contains("<embeddings "),
"a node store is not a relationship store:\n{without}"
);
}
#[test]
fn a_graph_with_only_relationship_stores_gets_the_semantic_hint() {
let xml = inventory(&graph(false, true));
let semantic = line_with(&xml, "<semantic ");
assert!(semantic.contains("vector_score(r, 'col_emb'"), "{semantic}");
assert!(
semantic.contains("db.relationship_embeddings.query"),
"{semantic}"
);
assert!(
semantic.contains("deleting an embedded relationship or an endpoint drops that index"),
"the delete contract: {semantic}"
);
assert!(
!semantic.contains("text_score(n,"),
"no node store, so no node spelling: {semantic}"
);
}
#[test]
fn a_graph_with_both_entities_names_both_spellings() {
let xml = inventory(&graph(true, true));
let semantic = line_with(&xml, "<semantic ");
assert!(semantic.contains("text_score(n, 'col'"), "{semantic}");
assert!(
semantic.contains("db.relationship_embeddings.query"),
"{semantic}"
);
}
#[test]
fn graphs_without_relationship_stores_render_only_the_node_hints() {
let node_only = inventory(&graph(true, false));
assert_eq!(line_with(&node_only, "<semantic "), NODE_SEMANTIC_LINE);
assert!(!node_only.contains("embeddings=\""));
let neither = inventory(&graph(false, false));
assert!(!neither.contains("<semantic "));
assert!(!neither.contains("embeddings=\""));
let conn = line_with(&neither, "<conn type=\"SUPPORTS\"").trim();
assert_eq!(
conn,
"<conn type=\"SUPPORTS\" count=\"2\" from=\"SUPPORTS\" to=\"SUPPORTS\" properties=\"body:String\"/>"
);
}
#[test]
fn the_cypher_reference_names_the_relationship_embedding_procedures() {
let graph = DirGraph::new();
let mut request = DescribeRequest::new(DescribeSurface::Python);
request.cypher = &CypherDetail::Overview;
let overview = compute_description(&graph, &request).unwrap();
let proc_line = line_with(&overview, "<proc name=\"db.relationship_embeddings.*\"");
for name in [
"set",
"embed",
"list",
"remove",
"drop",
"query",
"build_index",
"refresh_index",
"drop_index",
] {
assert!(
proc_line.contains(&format!("db.relationship_embeddings.{name}(")),
"{name} missing: {proc_line}"
);
}
assert!(proc_line.contains("text:"), "P5's text option: {proc_line}");
assert!(
proc_line.contains("refresh_index refuses when no index is built"),
"{proc_line}"
);
assert!(
proc_line.contains("DETACH DELETE of either endpoint"),
"the delete contract: {proc_line}"
);
assert!(
!proc_line.contains("text_bm25"),
"the lexical lane is not documented before it ships"
);
let topics = CypherDetail::Topics(vec!["functions".to_string()]);
request.cypher = &topics;
let functions = compute_description(&graph, &request).unwrap();
let group = line_with(&functions, "<group name=\"relationship_semantic\"");
assert!(group.contains("vector_score(r, 'col_emb'"), "{group}");
assert!(
group.contains("db.relationship_embeddings.query"),
"{group}"
);
}
/// The three contracts a blank-slate user test found stated too narrowly or
/// not at all: unembedded rows lead a DESC top-k (openCypher sorts null
/// first) unless filtered, a compacting `vacuum()` drops every vector index,
/// and `delta` is the vectors the index does not hold — all of them when
/// there is no index.
#[test]
fn the_semantic_topics_state_null_ordering_vacuum_and_delta() {
let graph = DirGraph::new();
let mut request = DescribeRequest::new(DescribeSurface::Python);
let topics = CypherDetail::Topics(vec!["functions".to_string()]);
request.cypher = &topics;
let functions = compute_description(&graph, &request).unwrap();
let node = line_with(&functions, "<group name=\"semantic\"");
assert!(
node.contains("WHERE vector_score(n, 'col_emb', $v) IS NOT NULL"),
"{node}"
);
let relationship = line_with(&functions, "<group name=\"relationship_semantic\"");
assert!(
relationship.contains("WHERE vector_score(r, 'col_emb', $v) IS NOT NULL"),
"{relationship}"
);
assert!(relationship.contains("sorts null first"), "{relationship}");
// The filter is served from the store; a node type with unembedded
// members is answered from its store, a relationship type by row scan.
assert!(
node.contains("is served from the store at the store procedure's cost"),
"{node}"
);
assert!(
node.contains(
"the null-scored nodes first in type order, then the store's ranking; \
row_coverage is reported only when all k rows are null or the store's order \
differs from the type's"
),
"{node}"
);
assert!(
relationship.contains("is served from the store at the store procedure's cost"),
"{relationship}"
);
assert!(
relationship.contains(
"a type whose relationships are not all embedded is answered by row scan \
(row_coverage)"
),
"{relationship}"
);
assert!(
relationship.contains(
"WITH r, vector_score(r, …) AS s ORDER BY s DESC LIMIT k RETURN startNode(r)… \
is served the same way, and an undirected (a)-[r:T]-(b) uses the index, \
returning each relationship once per orientation"
),
"{relationship}"
);
let topics = CypherDetail::Topics(vec!["relationship_semantic".to_string()]);
request.cypher = &topics;
let topic = compute_description(&graph, &request).unwrap();
let caveat = line_with(&topic, "<caveat>");
assert!(
caveat.contains("a vacuum() that compacts drops every vector index"),
"{caveat}"
);
request.cypher = &CypherDetail::Overview;
let overview = compute_description(&graph, &request).unwrap();
let proc_line = line_with(&overview, "<proc name=\"db.relationship_embeddings.*\"");
assert!(
proc_line.contains("a vacuum() that compacts drops every vector index"),
"{proc_line}"
);
assert!(
proc_line.contains("delta equals count when no index is built"),
"{proc_line}"
);
}
// ── relationship lexical lane ─────────────────────────────────────────
/// Byte-identical to the lines every node-only graph has always carried.
const NODE_LEXICAL_LINE: &str = " <lexical hint=\"text_bm25(n, 'prop', 'query text') — BM25 relevance of the node's indexed text; 0.0 = indexed but shares no word with the query, null = no document for that row. Build with build_text_index(node_type, property).\"/>";
const NODE_HYBRID_LINE: &str = " <hybrid hint=\"score_fuse(text_bm25(n, 'prop', $q), vector_score(n, 'col_emb', $qv)) — one score from both lanes (weights: a trailing list, e.g. [0.7, 0.3]). A lane that cannot see a row scores null and drops out of the average rather than zeroing it; all lanes absent = null. Rank with ORDER BY … DESC LIMIT k.\"/>";
fn with_text_indexes(mut graph: DirGraph, node: bool, edge: bool) -> DirGraph {
if node {
crate::graph::text_indexes::build_text_index(&mut graph, "SUPPORTS", "body", None).unwrap();
}
if edge {
run(
&mut graph,
"CALL db.relationship_text_index.build({type:'SUPPORTS', text_column:'body'}) \
YIELD indexed RETURN indexed",
);
}
graph
}
#[test]
fn a_relationship_text_index_alone_gets_the_lexical_hint() {
let xml = inventory(&with_text_indexes(graph(false, false), false, true));
let lexical = line_with(&xml, "<lexical ");
assert!(lexical.contains("text_bm25(r, 'prop'"), "{lexical}");
assert!(
lexical.contains("db.relationship_text_index.build"),
"{lexical}"
);
assert!(!lexical.contains("text_bm25(n,"), "{lexical}");
assert!(!xml.contains("<hybrid "), "one lane is not hybrid:\n{xml}");
}
#[test]
fn both_relationship_lanes_get_the_hybrid_hint() {
let xml = inventory(&with_text_indexes(graph(false, true), false, true));
let hybrid = line_with(&xml, "<hybrid ");
assert!(hybrid.contains("text_bm25(r, 'prop', $q)"), "{hybrid}");
assert!(!hybrid.contains("text_bm25(n,"), "{hybrid}");
}
#[test]
fn node_only_retrieval_hints_render_exactly_as_before() {
let xml = inventory(&with_text_indexes(graph(true, false), true, false));
assert_eq!(line_with(&xml, "<lexical "), NODE_LEXICAL_LINE);
assert_eq!(line_with(&xml, "<hybrid "), NODE_HYBRID_LINE);
}
#[test]
fn the_cypher_reference_names_the_relationship_text_index_procedures() {
let graph = DirGraph::new();
let mut request = DescribeRequest::new(DescribeSurface::Python);
request.cypher = &CypherDetail::Overview;
let overview = compute_description(&graph, &request).unwrap();
let proc_line = line_with(&overview, "<proc name=\"db.relationship_text_index.*\"");
for name in ["build", "refresh", "drop", "list"] {
assert!(
proc_line.contains(&format!("db.relationship_text_index.{name}(")),
"{name} missing: {proc_line}"
);
}
assert!(proc_line.contains("text_bm25(r, 'property'"), "{proc_line}");
let drop_clause = line_with(&overview, "<clause name=\"DROP INDEX\"");
assert!(drop_clause.contains("BM25 text index"), "{drop_clause}");
let topics = CypherDetail::Topics(vec!["functions".to_string()]);
request.cypher = &topics;
let functions = compute_description(&graph, &request).unwrap();
let lexical = line_with(&functions, "<group name=\"lexical\"");
assert!(lexical.contains("text_bm25(r, 'prop'"), "{lexical}");
assert!(
lexical.contains("db.relationship_text_index.build"),
"{lexical}"
);
}
// ── index presence, both entities ─────────────────────────────────────
fn with_vector_indexes(mut graph: DirGraph, node: bool, edge: bool) -> DirGraph {
if node {
crate::graph::embeddings::build_vector_index(
&mut graph, "SUPPORTS", "body", None, None, None, None, None,
)
.unwrap();
}
if edge {
run(
&mut graph,
"CALL db.relationship_embeddings.build_index({type:'SUPPORTS', text_column:'body'}) \
YIELD indexed RETURN indexed",
);
}
graph
}
fn node_detail(graph: &DirGraph) -> String {
let types = ["SUPPORTS".to_string()];
let mut request = DescribeRequest::new(DescribeSurface::Python);
request.types = Some(&types);
compute_description(graph, &request).unwrap()
}
#[test]
fn an_hnsw_index_is_shown_on_both_entities_stores() {
let graph = with_vector_indexes(graph(true, true), true, true);
let xml = inventory(&graph);
assert_eq!(
line_with(&xml, "<conn type=\"SUPPORTS\"").trim(),
"<conn type=\"SUPPORTS\" count=\"2\" from=\"SUPPORTS\" to=\"SUPPORTS\" \
properties=\"body:String\" embeddings=\"body(dim=2,count=1,hnsw)\"/>"
);
let detail = describe_with(&graph, &ConnectionDetail::Topics(vec!["SUPPORTS".into()]));
assert_eq!(
line_with(&detail, "<embeddings "),
" <embeddings text_col=\"body\" dim=\"2\" count=\"1\" index=\"hnsw\"/>"
);
assert!(
node_detail(&graph)
.contains("<embeddings text_col=\"body\" dim=\"3\" count=\"2\" index=\"hnsw\"/>"),
"{}",
node_detail(&graph)
);
// Only the relationship store indexed: the node store says nothing.
let edge_only = with_vector_indexes(self::graph(true, true), false, true);
assert!(
node_detail(&edge_only).contains("<embeddings text_col=\"body\" dim=\"3\" count=\"2\"/>")
);
assert!(inventory(&edge_only).contains("embeddings=\"body(dim=2,count=1,hnsw)\""));
}
#[test]
fn a_bm25_index_is_shown_on_both_entities() {
let graph = with_text_indexes(self::graph(false, false), true, true);
let xml = inventory(&graph);
assert_eq!(
line_with(&xml, "<conn type=\"SUPPORTS\"").trim(),
"<conn type=\"SUPPORTS\" count=\"2\" from=\"SUPPORTS\" to=\"SUPPORTS\" \
properties=\"body:String\" text_index=\"body\"/>"
);
let detail = describe_with(&graph, &ConnectionDetail::Topics(vec!["SUPPORTS".into()]));
assert_eq!(
line_with(&detail, "<text_index "),
" <text_index text_col=\"body\"/>"
);
assert!(
node_detail(&graph).contains("<text_index text_col=\"body\"/>"),
"{}",
node_detail(&graph)
);
// A relationship index alone leaves the node type's view untouched.
let edge_only = with_text_indexes(self::graph(false, false), false, true);
assert!(!node_detail(&edge_only).contains("<text_index "));
}
#[test]
fn without_any_index_nothing_new_is_rendered() {
let graph = graph(true, true);
for xml in [
inventory(&graph),
describe_with(&graph, &ConnectionDetail::Topics(vec!["SUPPORTS".into()])),
node_detail(&graph),
] {
assert!(!xml.contains("hnsw\""), "{xml}");
assert!(!xml.contains(",hnsw"), "{xml}");
assert!(!xml.contains("text_index"), "{xml}");
}
assert_eq!(
line_with(&inventory(&graph), "<conn type=\"SUPPORTS\"").trim(),
CONN_LINE
);
}
#[test]
fn relationship_semantic_is_a_direct_topic_matching_the_functions_group() {
let graph = DirGraph::new();
let mut request = DescribeRequest::new(DescribeSurface::Python);
let topic = CypherDetail::Topics(vec!["relationship_semantic".to_string()]);
request.cypher = &topic;
let xml = compute_description(&graph, &request).unwrap();
assert!(
xml.contains("<topic name=\"relationship_semantic\">"),
"{xml}"
);
let summary = line_with(&xml, "<summary>");
assert!(
summary.contains("Stores are per (relationship type, text column)"),
"{summary}"
);
assert!(xml.contains("types:['A','B']"), "{xml}");
assert!(xml.contains("MATCH ()-[r:A|B]->()"), "{xml}");
let caveat = line_with(&xml, "<caveat>");
assert!(
caveat.contains(
"vector_score(): no embedding 'col_emb' found for relationship type 'X', or \
text_score(): no embedding for property 'col' on relationship type 'X'"
),
"{caveat}"
);
let functions = CypherDetail::Topics(vec!["functions".to_string()]);
request.cypher = &functions;
let listing = compute_description(&graph, &request).unwrap();
let group = line_with(&listing, "<group name=\"relationship_semantic\"");
let group_body = group
.trim()
.trim_start_matches("<group name=\"relationship_semantic\">")
.trim_end_matches("</group>");
let summary_body = summary
.trim()
.trim_start_matches("<summary>")
.trim_end_matches("</summary>");
assert_eq!(group_body, summary_body);
// The per-graph hint says once how stores are keyed and ranked across types.
let semantic = line_with(&inventory(&self::graph(false, true)), "<semantic ").to_string();
assert!(
semantic.contains("stores are per relationship type and text column"),
"{semantic}"
);
assert!(
semantic.contains("describe(cypher=['relationship_semantic'])"),
"{semantic}"
);
}
#[test]
fn the_embedding_readout_is_named_beside_vector_score() {
let semantic = line_with(&inventory(&graph(false, true)), "<semantic ").to_string();
assert!(
semantic.contains("embedding(r, 'col_emb') returns its stored vector"),
"{semantic}"
);
let graph = DirGraph::new();
let mut request = DescribeRequest::new(DescribeSurface::Python);
let functions = CypherDetail::Topics(vec!["functions".to_string()]);
request.cypher = &functions;
let listing = compute_description(&graph, &request).unwrap();
let relationship = line_with(&listing, "<group name=\"relationship_semantic\"");
assert!(
relationship.contains("vector_score(r2, 'col_emb', embedding(r1, 'col_emb'))"),
"{relationship}"
);
let node = line_with(&listing, "<group name=\"semantic\"");
assert!(node.contains("embedding(n, 'col_emb')"), "{node}");
}
#[test]
fn node_semantic_is_a_direct_topic_matching_the_functions_group() {
let graph = DirGraph::new();
let mut request = DescribeRequest::new(DescribeSurface::Python);
let topic = CypherDetail::Topics(vec!["node_semantic".to_string()]);
request.cypher = &topic;
let xml = compute_description(&graph, &request).unwrap();
assert!(xml.contains("<topic name=\"node_semantic\">"), "{xml}");
let usage = line_with(&xml, "<usage>");
assert!(usage.contains("CALL db.node_embeddings.query("), "{usage}");
assert!(usage.contains("db.embeddings.*"), "{usage}");
let summary = line_with(&xml, "<summary>");
let functions = CypherDetail::Topics(vec!["functions".to_string()]);
request.cypher = &functions;
let listing = compute_description(&graph, &request).unwrap();
let group = line_with(&listing, "<group name=\"semantic\"");
let group_body = group
.trim()
.trim_start_matches("<group name=\"semantic\">")
.trim_end_matches("</group>");
let summary_body = summary
.trim()
.trim_start_matches("<summary>")
.trim_end_matches("</summary>");
assert_eq!(group_body, summary_body);
}
#[test]
fn the_node_semantic_hint_names_the_node_procedures_and_topic() {
let semantic = line_with(&inventory(&graph(true, false)), "<semantic ").to_string();
assert!(semantic.contains("db.node_embeddings.query"), "{semantic}");
assert!(
semantic.contains("describe(cypher=['node_semantic'])"),
"{semantic}"
);
}
#[test]
fn cross_type_ranking_states_its_per_type_cost() {
let graph = DirGraph::new();
let mut request = DescribeRequest::new(DescribeSurface::Python);
let topic = CypherDetail::Topics(vec!["relationship_semantic".to_string()]);
request.cypher = &topic;
let xml = compute_description(&graph, &request).unwrap();
assert!(
line_with(&xml, "<usage>").contains("one search per relationship type"),
"{xml}"
);
}