use core_api::{
wal_commit_count_at, Direction, GraphDb, GraphError, MutationEvent, Predicate,
PredicateSummary, RuleDef, Value,
};
use core_rules::with_ivf_drift_rebuild;
use core_storage::fs::{FileId, Fs, RealFs};
use core_storage::wal::{decode_all, WalRecord};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("graphdb-{}-{}", name, std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn fk_rule() -> RuleDef {
RuleDef {
name: "works_at".into(),
src_label: "Person".into(),
dst_label: "Org".into(),
predicate: Predicate::KeyMatch {
field: "org_id".into(),
},
edge_type: "WORKS_AT".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
}
}
#[test]
fn rules_fire_on_insert_and_survive_reopen() {
let dir = tmp("rules");
{
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Org", "o1", vec![]).unwrap();
db.create_rule(fk_rule()).unwrap();
db.insert_node(
"Person",
"p1",
vec![("org_id".into(), Value::Str("o1".into()))],
)
.unwrap();
assert_eq!(
db.neighbors("p1", "WORKS_AT", Direction::Out).unwrap(),
vec!["o1"]
);
assert!(matches!(
db.insert_edge("WORKS_AT", "p1", "o1"),
Err(GraphError::RuleOwned { .. })
));
assert_eq!(db.rules().len(), 1);
}
let db = GraphDb::open(&dir).unwrap();
assert_eq!(
db.neighbors("p1", "WORKS_AT", Direction::Out).unwrap(),
vec!["o1"]
);
assert_eq!(db.rules().len(), 1);
}
#[test]
fn prop_update_retracts_and_relinks() {
let dir = tmp("rules-update");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Org", "o1", vec![]).unwrap();
db.insert_node("Org", "o2", vec![]).unwrap();
db.create_rule(fk_rule()).unwrap();
db.insert_node(
"Person",
"p1",
vec![("org_id".into(), Value::Str("o1".into()))],
)
.unwrap();
db.set_prop("p1", "org_id", Value::Str("o2".into()))
.unwrap();
assert_eq!(
db.neighbors("p1", "WORKS_AT", Direction::Out).unwrap(),
vec!["o2"]
);
assert_eq!(db.edge_count(), 1); }
#[test]
fn delete_rule_removes_only_derived_edges_and_bad_rules_rejected() {
let dir = tmp("rules-delete");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Org", "o1", vec![]).unwrap();
db.insert_node(
"Person",
"p1",
vec![("org_id".into(), Value::Str("o1".into()))],
)
.unwrap();
db.insert_edge("FRIEND", "p1", "o1").unwrap(); db.create_rule(fk_rule()).unwrap();
assert_eq!(db.edge_count(), 2);
db.delete_rule("works_at").unwrap();
assert_eq!(db.edge_count(), 1);
assert!(matches!(
db.delete_rule("works_at"),
Err(GraphError::RuleNotFound { .. })
));
let mut bad = fk_rule();
bad.edge_type = String::new();
assert!(matches!(
db.create_rule(bad),
Err(GraphError::RuleInvalid { .. })
));
assert!(matches!(db.create_rule(fk_rule()), Ok(())));
assert!(matches!(
db.create_rule(fk_rule()),
Err(GraphError::RuleInvalid { .. })
)); }
#[test]
fn derived_edge_state_records_not_wal_logged_markers_are() {
let dir = tmp("rules-walsize");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Org", "o1", vec![]).unwrap();
db.create_rule(fk_rule()).unwrap();
let before = std::fs::metadata(dir.join("wal.bin")).unwrap().len();
db.insert_node(
"Person",
"p1",
vec![("org_id".into(), Value::Str("o1".into()))],
)
.unwrap();
let wal = std::fs::read(dir.join("wal.bin")).unwrap();
assert!(wal.len() as u64 > before);
let (recs, _) = core_storage::wal::decode_all(&wal[before as usize..]);
let all_inner: Vec<&WalRecord> = recs
.iter()
.flat_map(|r| match r {
WalRecord::Batch(inner) => inner.iter().collect::<Vec<_>>(),
other => vec![other],
})
.collect();
let has_state_edge = all_inner.iter().any(|r| {
matches!(
r,
WalRecord::InsertEdge { .. } | WalRecord::InsertEdgeId { .. }
)
});
assert!(
!has_state_edge,
"derived edge state records must not be WAL-logged"
);
let has_marker = all_inner
.iter()
.any(|r| matches!(r, WalRecord::DerivedEdgeAdded { .. }));
assert!(has_marker, "DerivedEdgeAdded marker must be WAL-logged");
assert_eq!(db.edge_count(), 1); }
#[test]
fn explain_reports_rule_provenance_and_weights() {
let dir = tmp("explain");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node(
"Org",
"o1",
vec![("tags".into(), Value::List(vec![Value::Str("x".into())]))],
)
.unwrap();
db.create_rule(fk_rule()).unwrap();
db.create_rule(RuleDef {
name: "shared".into(),
src_label: "Person".into(),
dst_label: "Org".into(),
predicate: Predicate::Overlap {
field: "tags".into(),
min: 0.5,
},
edge_type: "SIMILAR".into(),
weight_prop: Some("score".into()),
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})
.unwrap();
db.insert_node(
"Person",
"p1",
vec![
("org_id".into(), Value::Str("o1".into())),
("tags".into(), Value::List(vec![Value::Str("x".into())])),
],
)
.unwrap();
let ex = db.explain("p1", "o1").unwrap();
assert_eq!(ex.len(), 2);
assert_eq!(ex[0].rule, "shared");
assert_eq!(ex[0].weight, Some(1.0));
assert_eq!(ex[1].rule, "works_at");
assert_eq!(ex[1].weight, Some(1.0));
db.insert_node("Org", "o2", vec![]).unwrap();
assert!(db.explain("p1", "o2").unwrap().is_empty());
assert!(matches!(
db.explain("p1", "ghost"),
Err(GraphError::KeyNotFound { .. })
));
}
#[test]
fn explain_high_degree_hub_returns_only_the_pair() {
use core_api::{AutoFk, IngestOptions};
use std::collections::BTreeMap;
let dir = tmp("explain-hub");
let mut db = GraphDb::open(&dir).unwrap();
let opts = IngestOptions {
key_field: "id".into(),
auto_fk: AutoFk::Off,
};
let mut org = BTreeMap::new();
org.insert("id".into(), Value::Str("hub".into()));
db.ingest("Org", vec![org], &opts).unwrap();
let people: Vec<_> = (0..1000)
.map(|i| {
let mut row = BTreeMap::new();
row.insert("id".into(), Value::Str(format!("p{i}")));
row.insert("org_id".into(), Value::Str("hub".into()));
row
})
.collect();
db.ingest("Person", people, &opts).unwrap();
db.create_rule(fk_rule()).unwrap();
let ex = db.explain("hub", "p0").unwrap();
assert_eq!(ex.len(), 1);
assert_eq!(ex[0].rule, "works_at");
assert_eq!(ex[0].src_key, "p0");
assert_eq!(ex[0].dst_key, "hub");
assert!(db.explain("p0", "p1").unwrap().is_empty());
}
#[test]
fn explain_predicate_summary_key_match_and_all() {
let dir = tmp("explain-pred");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Org", "o1", vec![("ind".into(), Value::Str("arch".into()))])
.unwrap();
db.create_rule(fk_rule()).unwrap();
db.create_rule(RuleDef {
name: "both".into(),
src_label: "Person".into(),
dst_label: "Org".into(),
predicate: Predicate::All(vec![
Predicate::FieldEqual {
field: "ind".into(),
},
Predicate::Overlap {
field: "tags".into(),
min: 0.5,
},
]),
edge_type: "BOTH".into(),
weight_prop: Some("score".into()),
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})
.unwrap();
db.insert_node(
"Person",
"p1",
vec![
("org_id".into(), Value::Str("o1".into())),
("ind".into(), Value::Str("arch".into())),
("tags".into(), Value::List(vec![Value::Str("x".into())])),
],
)
.unwrap();
db.set_prop("o1", "tags", Value::List(vec![Value::Str("x".into())]))
.unwrap();
let ex = db.explain("p1", "o1").unwrap();
let km = ex.iter().find(|e| e.rule == "works_at").unwrap();
assert_eq!(km.predicate.kind, "key_match");
assert_eq!(km.predicate.fields, vec!["org_id".to_string()]);
assert!(km.predicate.parts.is_none());
let all = ex.iter().find(|e| e.rule == "both").unwrap();
assert_eq!(all.predicate.kind, "all");
assert_eq!(
all.predicate.fields,
vec!["ind".to_string(), "tags".to_string()]
);
let parts = all.predicate.parts.as_ref().expect("all has parts");
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].kind, "field_equal");
assert_eq!(parts[0].fields, vec!["ind".to_string()]);
assert_eq!(parts[1].kind, "overlap");
assert_eq!(parts[1].fields, vec!["tags".to_string()]);
assert_eq!(parts[1].min, Some(0.5));
}
#[test]
fn predicate_summary_kind_table() {
struct Row {
pred: Predicate,
kind: &'static str,
fields: &'static [&'static str],
min: Option<f64>,
tolerance: Option<f64>,
km: Option<f64>,
n_parts: Option<usize>,
}
let cases = [
Row {
pred: Predicate::KeyMatch { field: "fk".into() },
kind: "key_match",
fields: &["fk"],
min: None,
tolerance: None,
km: None,
n_parts: None,
},
Row {
pred: Predicate::FieldEqual {
field: "ind".into(),
},
kind: "field_equal",
fields: &["ind"],
min: None,
tolerance: None,
km: None,
n_parts: None,
},
Row {
pred: Predicate::Overlap {
field: "tags".into(),
min: 0.5,
},
kind: "overlap",
fields: &["tags"],
min: Some(0.5),
tolerance: None,
km: None,
n_parts: None,
},
Row {
pred: Predicate::All(vec![
Predicate::FieldEqual {
field: "ind".into(),
},
Predicate::Overlap {
field: "tags".into(),
min: 0.4,
},
]),
kind: "all",
fields: &["ind", "tags"],
min: None,
tolerance: None,
km: None,
n_parts: Some(2),
},
Row {
pred: Predicate::NumericWithin {
field: "year".into(),
tolerance: 2.0,
},
kind: "numeric_within",
fields: &["year"],
min: None,
tolerance: Some(2.0),
km: None,
n_parts: None,
},
Row {
pred: Predicate::GeoRadius {
field: "loc".into(),
km: 400.0,
},
kind: "geo_radius",
fields: &["loc"],
min: None,
tolerance: None,
km: Some(400.0),
n_parts: None,
},
Row {
pred: Predicate::VectorSimilar {
field: "emb".into(),
min: 0.9,
},
kind: "vector_similar",
fields: &["emb"],
min: Some(0.9),
tolerance: None,
km: None,
n_parts: None,
},
];
for row in &cases {
let s = PredicateSummary::from(&row.pred);
assert_eq!(s.kind, row.kind, "{}", row.kind);
assert_eq!(
s.fields,
row.fields
.iter()
.map(|f| (*f).to_string())
.collect::<Vec<_>>(),
"{} fields",
row.kind
);
assert_eq!(s.min, row.min, "{} min", row.kind);
assert_eq!(s.tolerance, row.tolerance, "{} tolerance", row.kind);
assert_eq!(s.km, row.km, "{} km", row.kind);
match row.n_parts {
None => assert!(s.parts.is_none(), "{} parts", row.kind),
Some(n) => {
assert_eq!(
s.parts.as_ref().map(Vec::len),
Some(n),
"{} parts",
row.kind
)
}
}
}
}
fn emb(xs: &[f64]) -> Value {
Value::List(xs.iter().copied().map(Value::Float).collect())
}
#[test]
fn all_vector_then_field_equal_does_not_scan_all() {
let dir = tmp("all-vec-fe");
let mut db = GraphDb::open(&dir).unwrap();
db.create_rule(RuleDef {
name: "fit".into(),
src_label: "Person".into(),
dst_label: "Org".into(),
predicate: Predicate::All(vec![
Predicate::VectorSimilar {
field: "e".into(),
min: 0.8,
},
Predicate::FieldEqual {
field: "industry".into(),
},
]),
edge_type: "FIT".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})
.unwrap();
db.insert_node(
"Person",
"p",
vec![
("e".into(), emb(&[1.0, 0.0])),
("industry".into(), Value::Str("tech".into())),
],
)
.unwrap();
db.insert_node(
"Org",
"low_cos",
vec![
("e".into(), emb(&[0.0, 1.0])),
("industry".into(), Value::Str("tech".into())),
],
)
.unwrap();
db.insert_node(
"Org",
"wrong_ind",
vec![
("e".into(), emb(&[1.0, 0.0])),
("industry".into(), Value::Str("law".into())),
],
)
.unwrap();
db.insert_node(
"Org",
"both",
vec![
("e".into(), emb(&[1.0, 0.0])),
("industry".into(), Value::Str("tech".into())),
],
)
.unwrap();
let out = db.neighbors("p", "FIT", Direction::Out).unwrap();
assert_eq!(out, vec!["both".to_string()]);
}
fn approx_vec_rule() -> RuleDef {
RuleDef {
name: "sim".into(),
src_label: "V".into(),
dst_label: "V".into(),
predicate: Predicate::VectorSimilar {
field: "emb".into(),
min: 0.5,
},
edge_type: "SIM".into(),
weight_prop: None,
max_edges: None,
approximate: true,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
}
}
#[test]
fn approximate_rule_rebuilds_after_drift_threshold() {
let dir = tmp("approx-drift-rebuild");
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..6 {
let x = i as f64 * 0.2;
db.insert_node(
"V",
&format!("v{i}"),
vec![("emb".into(), emb(&[x, 1.0 - x]))],
)
.unwrap();
}
db.create_rule(approx_vec_rule()).unwrap();
assert_eq!(db.ivf_dst_drift("sim"), Some(0));
let evs = Arc::new(Mutex::new(Vec::new()));
let sink = evs.clone();
db.set_event_sink(Box::new(move |e| sink.lock().unwrap().push(e)));
with_ivf_drift_rebuild(1, || {
let before = wal_commit_count_at(&dir).unwrap();
db.delete_node("v0").unwrap();
assert_eq!(
wal_commit_count_at(&dir).unwrap(),
before + 2,
"first delete is under threshold; DeleteNode + DerivedEdgeRetracted marker"
);
assert_eq!(db.ivf_dst_drift("sim"), Some(1));
db.delete_node("v1").unwrap();
assert_eq!(
wal_commit_count_at(&dir).unwrap(),
before + 5,
"second delete trips drift > 1; DeleteNode + marker + RebuildRule (no rebuild marker)"
);
assert_eq!(
db.ivf_dst_drift("sim"),
Some(0),
"rebuild_rule resets dst drift"
);
});
let got = evs.lock().unwrap().clone();
let rebuilt = got
.iter()
.filter(|e| matches!(e, MutationEvent::RuleRebuilt { name } if name == "sim"))
.count();
assert_eq!(
rebuilt, 1,
"exactly one auto RebuildRule, not a retrigger loop; got {got:?}"
);
let before = wal_commit_count_at(&dir).unwrap();
db.rebuild_rule("sim").unwrap();
assert_eq!(
wal_commit_count_at(&dir).unwrap(),
before + 1,
"RebuildRule must not retrigger another RebuildRule; exactly 1 commit"
);
assert_eq!(db.ivf_dst_drift("sim"), Some(0));
}
struct FailRebuildWal {
inner: RealFs,
fail_rebuild: Arc<AtomicBool>,
}
impl Fs for FailRebuildWal {
fn append(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()> {
if file == FileId::Wal
&& self.fail_rebuild.load(Ordering::SeqCst)
&& decode_all(data)
.0
.iter()
.any(|r| matches!(r, WalRecord::RebuildRule { .. }))
{
return Err(std::io::Error::other("forced RebuildRule wal failure"));
}
self.inner.append(file, data)
}
fn sync(&mut self, file: FileId) -> std::io::Result<()> {
self.inner.sync(file)
}
fn read(&self, file: FileId) -> std::io::Result<Vec<u8>> {
self.inner.read(file)
}
fn write_atomic(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()> {
self.inner.write_atomic(file, data)
}
}
#[test]
fn auto_rebuild_wal_failure_does_not_fail_user_write() {
let dir = tmp("approx-rebuild-wal-fail");
let fail_rebuild = Arc::new(AtomicBool::new(false));
let fs = FailRebuildWal {
inner: RealFs::new(&dir).unwrap(),
fail_rebuild: fail_rebuild.clone(),
};
let mut db = GraphDb::open_with(fs).unwrap();
for i in 0..6 {
let x = i as f64 * 0.2;
db.insert_node(
"V",
&format!("v{i}"),
vec![("emb".into(), emb(&[x, 1.0 - x]))],
)
.unwrap();
}
db.create_rule(approx_vec_rule()).unwrap();
fail_rebuild.store(true, Ordering::SeqCst);
with_ivf_drift_rebuild(1, || {
db.delete_node("v0").unwrap();
let before = wal_commit_count_at(&dir).unwrap();
db.delete_node("v1")
.expect("user delete must succeed even if auto-rebuild WAL fails");
assert!(
!db.has_node("v1"),
"user delete is durable when rebuild WAL fails"
);
assert_eq!(
wal_commit_count_at(&dir).unwrap(),
before + 2,
"RebuildRule must not be committed when its WAL append fails; \
only DeleteNode + DerivedEdgeRetracted marker"
);
assert_eq!(
db.ivf_dst_drift("sim"),
Some(2),
"failed auto-rebuild must leave dst drift in place"
);
});
fail_rebuild.store(false, Ordering::SeqCst);
db.insert_node("V", "v9", vec![("emb".into(), emb(&[0.1, 0.9]))])
.unwrap();
assert_eq!(
db.ivf_dst_drift("sim"),
Some(0),
"re-queued rebuild must run on a later write"
);
}
#[test]
fn find_similar_vector_without_edges() {
let dir = tmp("find-similar-vector-no-edges");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Item", "a", vec![("emb".into(), emb(&[1.0, 0.0]))])
.unwrap();
db.insert_node("Item", "b", vec![("emb".into(), emb(&[1.0, 1.0]))])
.unwrap();
db.insert_node("Item", "c", vec![("emb".into(), emb(&[0.0, 1.0]))])
.unwrap();
let query = vec![1.0_f64, 0.0];
let hits = db.find_similar_vector("emb", Some("Item"), &query, 2, 0.5);
let keys: Vec<&str> = hits.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, vec!["a", "b"], "top-2 with min=0.5");
assert!(hits[0].1 > hits[1].1, "sorted descending");
assert!((hits[0].1 - 1.0).abs() < 1e-9, "a is perfectly aligned");
let all = db.find_similar_vector("emb", Some("Item"), &query, 3, 0.0);
assert_eq!(all.len(), 3);
assert_eq!(all[2].0, "c");
assert!(all[2].1.abs() < 1e-9, "c is orthogonal");
let filtered = db.find_similar_vector("emb", Some("Other"), &query, 10, 0.0);
assert!(filtered.is_empty(), "no Other-label nodes exist");
}
#[test]
fn via_hop_rule_fires_only_matching_industry() {
let dir = tmp("via-hop-basic");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node(
"Org",
"techcorp",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_node("Person", "alice", vec![]).unwrap();
db.insert_node("Person", "bob", vec![]).unwrap();
db.insert_node(
"Project",
"proj_a",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_node(
"Project",
"proj_b",
vec![("industry".into(), Value::Str("law".into()))],
)
.unwrap();
db.insert_edge("WORKS_AT", "alice", "techcorp").unwrap();
db.insert_edge("WORKS_AT", "bob", "techcorp").unwrap();
let rule = RuleDef {
name: "fit".into(),
src_label: "Person".into(),
dst_label: "Project".into(),
predicate: Predicate::FieldEqual {
field: "industry".into(),
},
edge_type: "FIT".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: Some("Org".into()),
via_edge: Some("WORKS_AT".into()),
via_dir: None, namespace: None,
};
db.create_rule(rule).unwrap();
let alice_fit = db.neighbors("alice", "FIT", Direction::Out).unwrap();
assert_eq!(alice_fit, vec!["proj_a"], "alice fits proj_a (tech)");
let bob_fit = db.neighbors("bob", "FIT", Direction::Out).unwrap();
assert_eq!(bob_fit, vec!["proj_a"], "bob fits proj_a (tech)");
let proj_b_fit = db.neighbors("proj_b", "FIT", Direction::In).unwrap();
assert!(proj_b_fit.is_empty(), "proj_b (law) gets no FIT edges");
}
#[test]
fn via_hop_validate_rejects_half_set() {
let dir = tmp("via-hop-validate");
let mut db = GraphDb::open(&dir).unwrap();
let bad_label_only = RuleDef {
name: "r1".into(),
src_label: "A".into(),
dst_label: "B".into(),
predicate: Predicate::FieldEqual { field: "f".into() },
edge_type: "E".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: Some("V".into()),
via_edge: None,
via_dir: None,
namespace: None,
};
assert!(
db.create_rule(bad_label_only).is_err(),
"via_label without via_edge must be rejected"
);
let bad_edge_only = RuleDef {
name: "r2".into(),
src_label: "A".into(),
dst_label: "B".into(),
predicate: Predicate::FieldEqual { field: "f".into() },
edge_type: "E".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: None,
via_edge: Some("VE".into()),
via_dir: None,
namespace: None,
};
assert!(
db.create_rule(bad_edge_only).is_err(),
"via_edge without via_label must be rejected"
);
}
#[test]
fn via_hop_incremental_edge_insert() {
let dir = tmp("via-hop-edge-insert");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node(
"Org",
"techcorp",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_node("Person", "alice", vec![]).unwrap();
db.insert_node(
"Project",
"proj_a",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
let rule = RuleDef {
name: "fit".into(),
src_label: "Person".into(),
dst_label: "Project".into(),
predicate: Predicate::FieldEqual {
field: "industry".into(),
},
edge_type: "FIT".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: Some("Org".into()),
via_edge: Some("WORKS_AT".into()),
via_dir: None,
namespace: None,
};
db.create_rule(rule).unwrap();
assert!(
db.neighbors("alice", "FIT", Direction::Out)
.unwrap()
.is_empty(),
"no FIT before WORKS_AT inserted"
);
db.insert_edge("WORKS_AT", "alice", "techcorp").unwrap();
let fit = db.neighbors("alice", "FIT", Direction::Out).unwrap();
assert_eq!(fit, vec!["proj_a"], "FIT fires after WORKS_AT inserted");
}
#[test]
fn via_hop_incremental_via_prop_change() {
let dir = tmp("via-hop-via-prop");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node(
"Org",
"techcorp",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_node("Person", "alice", vec![]).unwrap();
db.insert_node(
"Project",
"proj_a",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_node(
"Project",
"proj_b",
vec![("industry".into(), Value::Str("law".into()))],
)
.unwrap();
db.insert_edge("WORKS_AT", "alice", "techcorp").unwrap();
let rule = RuleDef {
name: "fit".into(),
src_label: "Person".into(),
dst_label: "Project".into(),
predicate: Predicate::FieldEqual {
field: "industry".into(),
},
edge_type: "FIT".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: Some("Org".into()),
via_edge: Some("WORKS_AT".into()),
via_dir: None,
namespace: None,
};
db.create_rule(rule).unwrap();
assert_eq!(
db.neighbors("alice", "FIT", Direction::Out).unwrap(),
vec!["proj_a"]
);
db.set_prop("techcorp", "industry", Value::Str("law".into()))
.unwrap();
let fit = db.neighbors("alice", "FIT", Direction::Out).unwrap();
assert_eq!(
fit,
vec!["proj_b"],
"FIT updated after via-node prop change"
);
let proj_a_fit = db.neighbors("proj_a", "FIT", Direction::In).unwrap();
assert!(
proj_a_fit.is_empty(),
"proj_a FIT retracted after org industry changed"
);
}
#[test]
fn via_hop_via_dir_in() {
let dir = tmp("via-hop-dir-in");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node(
"Project",
"proj_x",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_node(
"Org",
"org_x",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_node(
"Person",
"dev_alice",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_node(
"Person",
"dev_bob",
vec![("industry".into(), Value::Str("law".into()))],
)
.unwrap();
db.insert_edge("MEMBER_OF", "org_x", "proj_x").unwrap();
let rule = RuleDef {
name: "project_person_fit".into(),
src_label: "Project".into(),
dst_label: "Person".into(),
predicate: Predicate::FieldEqual {
field: "industry".into(),
},
edge_type: "FIT".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: Some("Org".into()),
via_edge: Some("MEMBER_OF".into()),
via_dir: Some(core_storage::Direction::In),
namespace: None,
};
db.create_rule(rule).unwrap();
let fit = db.neighbors("proj_x", "FIT", Direction::Out).unwrap();
assert_eq!(
fit,
vec!["dev_alice"],
"via_dir=In rule fires for matching industry"
);
let bob_fit = db.neighbors("dev_bob", "FIT", Direction::In).unwrap();
assert!(bob_fit.is_empty(), "dev_bob (law) gets no FIT via proj_x");
}
#[test]
fn via_hop_survives_snapshot_and_wal_replay() {
let dir = tmp("via-hop-snapshot");
{
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node(
"Org",
"org1",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_node("Person", "alice", vec![]).unwrap();
db.insert_node(
"Project",
"proj_a",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
db.insert_edge("WORKS_AT", "alice", "org1").unwrap();
let rule = RuleDef {
name: "fit".into(),
src_label: "Person".into(),
dst_label: "Project".into(),
predicate: Predicate::FieldEqual {
field: "industry".into(),
},
edge_type: "FIT".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: Some("Org".into()),
via_edge: Some("WORKS_AT".into()),
via_dir: None,
namespace: None,
};
db.create_rule(rule).unwrap();
assert_eq!(
db.neighbors("alice", "FIT", Direction::Out).unwrap(),
vec!["proj_a"],
"FIT edge present before snapshot"
);
db.snapshot().unwrap();
}
let db = GraphDb::open(&dir).unwrap();
assert_eq!(
db.neighbors("alice", "FIT", Direction::Out).unwrap(),
vec!["proj_a"],
"FIT edge survives snapshot+reopen"
);
assert_eq!(db.rules().len(), 1, "rule survives snapshot+reopen");
drop(db);
let db2 = GraphDb::open(&dir).unwrap();
assert_eq!(
db2.neighbors("alice", "FIT", Direction::Out).unwrap(),
vec!["proj_a"],
"FIT edge survives WAL replay"
);
}
#[test]
fn via_hop_new_via_node_insert_after_rule_creation() {
let dir = tmp("via-hop-new-via");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Person", "alice", vec![]).unwrap();
db.insert_node(
"Project",
"proj_a",
vec![("industry".into(), Value::Str("bio".into()))],
)
.unwrap();
db.insert_node(
"Project",
"proj_b",
vec![("industry".into(), Value::Str("tech".into()))],
)
.unwrap();
let rule = RuleDef {
name: "fit".into(),
src_label: "Person".into(),
dst_label: "Project".into(),
predicate: Predicate::FieldEqual {
field: "industry".into(),
},
edge_type: "FIT".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: Some("Org".into()),
via_edge: Some("WORKS_AT".into()),
via_dir: None,
namespace: None,
};
db.create_rule(rule).unwrap();
assert!(
db.neighbors("alice", "FIT", Direction::Out)
.unwrap()
.is_empty(),
"no FIT before any Org inserted"
);
db.insert_node(
"Org",
"biotech_inc",
vec![("industry".into(), Value::Str("bio".into()))],
)
.unwrap();
assert!(
db.neighbors("alice", "FIT", Direction::Out)
.unwrap()
.is_empty(),
"no FIT after Org insert without WORKS_AT edge"
);
db.insert_edge("WORKS_AT", "alice", "biotech_inc").unwrap();
let fit = db.neighbors("alice", "FIT", Direction::Out).unwrap();
assert_eq!(
fit,
vec!["proj_a"],
"FIT fires after WORKS_AT to newly inserted Org"
);
assert!(
db.neighbors("proj_b", "FIT", Direction::In)
.unwrap()
.is_empty(),
"proj_b (tech) gets no FIT"
);
}
#[test]
fn ingest_json_list_fk_plus_keymatch_rule_yields_edges() {
use core_api::{AutoFk, EdgeEvent, IngestOptions};
let dir = tmp("rules-list-fk");
let mut db = GraphDb::open(&dir).unwrap();
let opts = IngestOptions {
key_field: "id".into(),
auto_fk: AutoFk::Off,
};
db.ingest_json(
"Mod",
r#"[{"id": "a.rs"}, {"id": "b.rs"}, {"id": "c.rs"}]"#,
&opts,
)
.unwrap();
db.create_rule(RuleDef {
name: "imports".into(),
src_label: "File".into(),
dst_label: "Mod".into(),
predicate: Predicate::KeyMatch {
field: "imports".into(),
},
edge_type: "IMPORTS".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})
.unwrap();
db.ingest_json(
"File",
r#"[{"id": "main.rs", "imports": ["a.rs", "ghost.rs", "b.rs"]}]"#,
&opts,
)
.unwrap();
assert_eq!(
db.neighbors("main.rs", "IMPORTS", Direction::Out).unwrap(),
vec!["a.rs", "b.rs"],
"one edge per list element that names a live Mod"
);
db.set_prop(
"main.rs",
"imports",
Value::List(vec![Value::Str("a.rs".into()), Value::Str("c.rs".into())]),
)
.unwrap();
assert_eq!(
db.neighbors("main.rs", "IMPORTS", Direction::Out).unwrap(),
vec!["a.rs", "c.rs"]
);
let hist = db.edge_history("main.rs", "b.rs").unwrap();
assert_eq!(hist.items.len(), 2, "b.rs history: {:?}", hist.items);
assert_eq!(hist.items[0].event, EdgeEvent::Added);
assert_eq!(hist.items[1].event, EdgeEvent::Retracted);
assert_eq!(
hist.items[1].rule,
Some("imports".to_string()),
"per-element retraction carries rule attribution"
);
let hist_c = db.edge_history("main.rs", "c.rs").unwrap();
assert_eq!(hist_c.items.len(), 1, "c.rs history: {:?}", hist_c.items);
assert_eq!(hist_c.items[0].event, EdgeEvent::Added);
assert_eq!(
hist.items[1].commit, hist_c.items[0].commit,
"retracting b.rs and firing c.rs share one commit"
);
let hist_a = db.edge_history("main.rs", "a.rs").unwrap();
assert_eq!(hist_a.items.len(), 1, "a.rs history: {:?}", hist_a.items);
assert_eq!(hist_a.items[0].event, EdgeEvent::Added);
drop(db);
let mut db = GraphDb::open(&dir).unwrap();
assert_eq!(
db.neighbors("main.rs", "IMPORTS", Direction::Out).unwrap(),
vec!["a.rs", "c.rs"],
"list-derived edges survive replay"
);
db.snapshot().unwrap();
drop(db);
let mut db = GraphDb::open(&dir).unwrap();
assert_eq!(
db.neighbors("main.rs", "IMPORTS", Direction::Out).unwrap(),
vec!["a.rs", "c.rs"],
"list-derived edges survive a snapshot"
);
db.set_prop(
"main.rs",
"imports",
Value::List(vec![Value::Str("a.rs".into()), Value::Str("b.rs".into())]),
)
.unwrap();
assert_eq!(
db.neighbors("main.rs", "IMPORTS", Direction::Out).unwrap(),
vec!["a.rs", "b.rs"],
"element swap after a snapshot re-links through the rebuilt index"
);
}
#[test]
fn explain_reports_keymatch_for_list_edge() {
let dir = tmp("rules-list-explain");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Mod", "a.rs", vec![]).unwrap();
db.create_rule(RuleDef {
name: "imports".into(),
src_label: "File".into(),
dst_label: "Mod".into(),
predicate: Predicate::KeyMatch {
field: "imports".into(),
},
edge_type: "IMPORTS".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})
.unwrap();
db.insert_node(
"File",
"main.rs",
vec![(
"imports".into(),
Value::List(vec![Value::Str("a.rs".into())]),
)],
)
.unwrap();
let ex = db.explain("main.rs", "a.rs").unwrap();
assert_eq!(ex.len(), 1);
assert_eq!(ex[0].rule, "imports");
assert_eq!(ex[0].edge_type, "IMPORTS");
assert_eq!(
ex[0].predicate,
PredicateSummary::from(&Predicate::KeyMatch {
field: "imports".into()
}),
"explain reports KeyMatch for a list-derived edge"
);
assert_eq!(ex[0].weight, Some(1.0));
}
#[test]
fn list_fk_fires_per_element_at_the_default_cap() {
use core_api::{EdgeEvent, DEFAULT_KEYMATCH_TOP_K};
let dir = tmp("rules-list-default-cap");
let mut db = GraphDb::open(&dir).unwrap();
for k in ["a.rs", "b.rs", "c.rs"] {
db.insert_node("Mod", k, vec![]).unwrap();
}
db.create_rule(RuleDef {
name: "imports".into(),
src_label: "File".into(),
dst_label: "Mod".into(),
predicate: Predicate::KeyMatch {
field: "imports".into(),
},
edge_type: "IMPORTS".into(),
weight_prop: None,
max_edges: Some(DEFAULT_KEYMATCH_TOP_K),
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})
.unwrap();
db.insert_node(
"File",
"main.rs",
vec![(
"imports".into(),
Value::List(vec![
Value::Str("a.rs".into()),
Value::Str("b.rs".into()),
Value::Str("c.rs".into()),
]),
)],
)
.unwrap();
assert_eq!(
db.neighbors("main.rs", "IMPORTS", Direction::Out).unwrap(),
vec!["a.rs", "b.rs", "c.rs"],
"the stored default cap fires on every element, not just the first"
);
db.set_prop(
"main.rs",
"imports",
Value::List(vec![Value::Str("a.rs".into()), Value::Str("c.rs".into())]),
)
.unwrap();
assert_eq!(
db.neighbors("main.rs", "IMPORTS", Direction::Out).unwrap(),
vec!["a.rs", "c.rs"]
);
let hist = db.edge_history("main.rs", "b.rs").unwrap();
assert_eq!(hist.items.len(), 2, "b.rs history: {:?}", hist.items);
assert_eq!(hist.items[1].event, EdgeEvent::Retracted);
let hist_a = db.edge_history("main.rs", "a.rs").unwrap();
assert_eq!(hist_a.items.len(), 1, "surviving element does not churn");
}
#[test]
fn list_fk_under_a_small_cap_keeps_the_lowest_destination_keys() {
let dir = tmp("rules-list-small-cap");
let mut db = GraphDb::open(&dir).unwrap();
for k in ["a.rs", "b.rs", "c.rs"] {
db.insert_node("Mod", k, vec![]).unwrap();
}
db.create_rule(RuleDef {
name: "imports".into(),
src_label: "File".into(),
dst_label: "Mod".into(),
predicate: Predicate::KeyMatch {
field: "imports".into(),
},
edge_type: "IMPORTS".into(),
weight_prop: None,
max_edges: Some(2),
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})
.unwrap();
db.insert_node(
"File",
"main.rs",
vec![(
"imports".into(),
Value::List(vec![
Value::Str("c.rs".into()),
Value::Str("b.rs".into()),
Value::Str("a.rs".into()),
]),
)],
)
.unwrap();
assert_eq!(
db.neighbors("main.rs", "IMPORTS", Direction::Out).unwrap(),
vec!["a.rs", "b.rs"],
"cap of 2 over 3 targets keeps the two lowest destination keys, \
not the two first-listed elements"
);
}
fn seed_any_keymatch(db: &mut GraphDb<RealFs>) {
db.insert_node("Person", "alice", vec![]).unwrap();
db.insert_node("Person", "bob", vec![]).unwrap();
db.insert_node("Person", "carol", vec![]).unwrap();
db.create_rule(RuleDef {
name: "linked".into(),
src_label: "Person".into(),
dst_label: "Person".into(),
predicate: Predicate::Any(vec![
Predicate::KeyMatch {
field: "friend_id".into(),
},
Predicate::FieldEqual {
field: "city".into(),
},
]),
edge_type: "LINKED".into(),
weight_prop: None,
max_edges: Some(10),
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})
.unwrap();
db.set_prop("carol", "city", Value::Str("berlin".into()))
.unwrap();
db.set_prop("bob", "city", Value::Str("lisbon".into()))
.unwrap();
}
fn linked(db: &GraphDb<RealFs>, key: &str) -> Vec<String> {
let mut v = db.neighbors(key, "LINKED", Direction::Out).unwrap();
v.sort();
v
}
#[test]
fn any_of_keymatch_and_field_equal_derives_both_branches() {
let dir = tmp("any-keymatch-src");
let mut db = GraphDb::open(&dir).unwrap();
seed_any_keymatch(&mut db);
db.set_prop("alice", "friend_id", Value::Str("bob".into()))
.unwrap();
db.set_prop("alice", "city", Value::Str("berlin".into()))
.unwrap();
assert_eq!(
linked(&db, "alice"),
vec!["bob", "carol"],
"bob is reachable only through the KeyMatch branch and must be derived"
);
db.rebuild_rule("linked").unwrap();
assert_eq!(linked(&db, "alice"), vec!["bob", "carol"], "rebuild");
db.set_prop("alice", "friend_id", Value::Str("nobody".into()))
.unwrap();
assert_eq!(linked(&db, "alice"), vec!["carol"]);
}
#[test]
fn any_of_keymatch_derives_when_the_destination_is_the_one_written() {
let dir = tmp("any-keymatch-dst");
let mut db = GraphDb::open(&dir).unwrap();
seed_any_keymatch(&mut db);
db.set_prop("alice", "friend_id", Value::Str("dave".into()))
.unwrap();
assert_eq!(linked(&db, "alice"), Vec::<String>::new());
db.insert_node(
"Person",
"dave",
vec![("city".into(), Value::Str("oslo".into()))],
)
.unwrap();
assert_eq!(
linked(&db, "alice"),
vec!["dave"],
"inserting the named destination must derive the KeyMatch branch"
);
}
fn unit_vec_8_raw(i: u32) -> Vec<f64> {
let mut s = 0x9E37_79B9_7F4A_7C15u64 ^ (i as u64).wrapping_mul(0xD6E8_FEB8_6659_FD93);
let mut out = Vec::with_capacity(8);
for _ in 0..8 {
s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut x = s;
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
out.push((x as i64 as f64) / (i64::MAX as f64));
}
let n = out.iter().map(|x| x * x).sum::<f64>().sqrt();
out.iter().map(|x| x / n).collect()
}
fn unit_vec_8(i: u32) -> Value {
emb(&unit_vec_8_raw(i))
}
fn tight_vec_rule() -> RuleDef {
RuleDef {
name: "sim".into(),
src_label: "V".into(),
dst_label: "V".into(),
predicate: Predicate::VectorSimilar {
field: "emb".into(),
min: 0.9,
},
edge_type: "SIM".into(),
weight_prop: None,
max_edges: None,
approximate: true,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
}
}
#[test]
fn reopening_does_not_rebuild_the_vector_index() {
const N: u32 = 400;
let dir = tmp("no-rebuild-on-open");
{
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..N {
db.insert_node("V", &format!("v{i}"), vec![("emb".into(), unit_vec_8(i))])
.unwrap();
}
db.create_rule(tight_vec_rule()).unwrap();
db.snapshot().unwrap();
}
core_rules::hnsw_insert_count_reset();
let mut db = GraphDb::open(&dir).unwrap();
assert!(
!db.find_similar_vector("emb", Some("V"), &unit_vec_8_raw(7), 3, 0.0)
.is_empty(),
"force the index path so the count is meaningful"
);
db.insert_node("Other", "o", vec![("v".into(), Value::Int(1))])
.unwrap();
assert_eq!(
core_rules::hnsw_insert_count(),
0,
"opening a store must not insert a single vector into the HNSW graph"
);
drop(db);
{
let mut w = GraphDb::open(&dir).unwrap();
w.insert_node("V", "extra", vec![("emb".into(), unit_vec_8(9_999))])
.unwrap();
}
core_rules::hnsw_insert_count_reset();
let db = GraphDb::open(&dir).unwrap();
assert!(db.has_node("extra"));
assert_eq!(
core_rules::hnsw_insert_count(),
2,
"only the post-snapshot node is inserted (once per rule side); the other \
{N} are adopted"
);
}
const SLICE_DIM: usize = 32;
fn slice_vec_raw(i: usize) -> Vec<f64> {
let axis = (i / 10) % SLICE_DIM;
let mut xs = vec![0.0f64; SLICE_DIM];
xs[axis] = 1.0;
xs[(axis + 1) % SLICE_DIM] = (i % 10) as f64 * 0.001;
xs
}
fn slice_vec(i: usize) -> Value {
emb(&slice_vec_raw(i))
}
fn store_with_vectors(name: &str, n: usize) -> (std::path::PathBuf, GraphDb<RealFs>) {
let dir = tmp(name);
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..n {
db.insert_node("V", &format!("v{i}"), vec![("emb".into(), slice_vec(i))])
.unwrap();
}
(dir, db)
}
fn slice_rule() -> RuleDef {
RuleDef {
name: "sim".into(),
src_label: "V".into(),
dst_label: "V".into(),
predicate: Predicate::VectorSimilar {
field: "emb".into(),
min: 0.9,
},
edge_type: "SIM".into(),
weight_prop: None,
max_edges: None,
approximate: true,
via_label: None,
via_dir: None,
namespace: None,
via_edge: None,
}
}
fn edge_set(db: &GraphDb<RealFs>, et: &str, n: usize) -> Vec<(String, String)> {
let mut out = Vec::new();
for i in 0..n {
let k = format!("v{i}");
for d in db.neighbors(&k, et, Direction::Out).unwrap_or_default() {
out.push((k.clone(), d));
}
}
out.sort();
out
}
fn building_of(db: &GraphDb<RealFs>, rule: &str) -> Option<core_api::BuildProgress> {
db.stats()
.rules
.iter()
.find(|r| r.name == rule)
.and_then(|r| r.building.clone())
}
fn edges_of(db: &GraphDb<RealFs>, rule: &str) -> u64 {
db.stats()
.rules
.iter()
.find(|r| r.name == rule)
.map(|r| r.edges)
.unwrap_or(0)
}
#[test]
fn create_rule_under_the_slice_is_one_commit() {
let (_dir, mut db) = store_with_vectors("slice-small", 100);
db.create_rule(slice_rule()).unwrap();
assert!(
db.stats().rules.iter().all(|r| r.building.is_none()),
"a 100-vector corpus must not defer anything"
);
assert!(
edges_of(&db, "sim") > 0,
"edges exist the moment create_rule returns"
);
assert!(
db.pump_index_build().unwrap().is_empty(),
"pumping a store with nothing pending must be a no-op"
);
}
#[test]
fn create_rule_over_the_slice_defers_then_matches() {
let want = {
let (_d, mut db) = store_with_vectors("slice-want", 300);
db.create_rule(slice_rule()).unwrap();
assert!(db.stats().rules[0].building.is_none());
edge_set(&db, "SIM", 300)
};
assert!(!want.is_empty(), "the fixture must derive some edges");
let (_d, mut db) = store_with_vectors("slice-defer", 300);
core_rules::with_hnsw_build_batch(64, || {
db.create_rule(slice_rule()).unwrap();
let p = building_of(&db, "sim").expect("must report a build in progress");
assert_eq!(p.indexed, 64, "create_rule does exactly one slice inline");
assert_eq!(p.total, 300);
assert_eq!(edges_of(&db, "sim"), 0, "no partial edge set, ever");
assert_eq!(
db.neighbors("v0", "SIM", Direction::Out).unwrap(),
Vec::<String>::new()
);
while !db.pump_index_build().unwrap().is_empty() {}
});
assert!(building_of(&db, "sim").is_none());
assert_eq!(
edge_set(&db, "SIM", 300),
want,
"the deferred build derives the same edges"
);
}
#[test]
fn a_write_pumps_the_build() {
let want = {
let (_d, mut db) = store_with_vectors("slice-write-want", 300);
db.create_rule(slice_rule()).unwrap();
edge_set(&db, "SIM", 300)
};
let (_d, mut db) = store_with_vectors("slice-write", 300);
core_rules::with_hnsw_build_batch(64, || {
db.create_rule(slice_rule()).unwrap();
assert!(building_of(&db, "sim").is_some());
let mut writes = 0;
while building_of(&db, "sim").is_some() {
db.insert_node(
"Other",
&format!("o{writes}"),
vec![("v".into(), Value::Int(1))],
)
.unwrap();
writes += 1;
assert!(writes < 100, "the build never finished under plain writes");
}
assert!(writes >= 3, "a 64-vector slice should need several writes");
});
assert_eq!(
edge_set(&db, "SIM", 300),
want,
"the write-driven build derives the same edges"
);
core_rules::with_hnsw_build_batch(64, || {
db.insert_node("V", "late", vec![("emb".into(), slice_vec(3))])
.unwrap();
while !db.pump_index_build().unwrap().is_empty() {}
});
let late: Vec<String> = db.neighbors("late", "SIM", Direction::Out).unwrap();
assert!(
late.contains(&"v0".to_string()),
"a node written during/after the build must link to its cluster; got {late:?}"
);
}
#[test]
fn an_interrupted_build_resumes_on_reopen() {
let want = {
let (_d, mut db) = store_with_vectors("slice-resume-want", 300);
db.create_rule(slice_rule()).unwrap();
edge_set(&db, "SIM", 300)
};
let (dir, mut db) = store_with_vectors("slice-resume", 300);
core_rules::with_hnsw_build_batch(64, || {
db.create_rule(slice_rule()).unwrap();
db.pump_index_build().unwrap();
let p = building_of(&db, "sim").expect("still building");
assert_eq!(p.indexed, 128, "two slices in");
db.snapshot().unwrap();
});
drop(db);
let mut db = GraphDb::open(&dir).unwrap();
assert_eq!(edges_of(&db, "sim"), 0, "the partial build derived nothing");
core_rules::hnsw_insert_count_reset();
core_rules::with_hnsw_build_batch(64, || while !db.pump_index_build().unwrap().is_empty() {});
let inserts = core_rules::hnsw_insert_count();
assert!(
inserts > 64,
"a reopen is documented to finish the index inline, not to resume slicing; \
{inserts} inserts would mean it now slices and the docs need changing"
);
assert!(building_of(&db, "sim").is_none());
assert_eq!(
edge_set(&db, "SIM", 300),
want,
"the resumed build derives the same edges"
);
}
#[test]
fn a_search_during_a_build_is_exact_not_partial() {
let (_d, mut db) = store_with_vectors("slice-search-live", 300);
let q = slice_vec_raw(200);
core_rules::with_hnsw_build_batch(64, || {
db.create_rule(slice_rule()).unwrap();
let p = building_of(&db, "sim").expect("the fixture must defer its build");
assert_eq!(p.indexed, 64, "only the first slice is indexed");
let hits = db.find_similar_vector("emb", Some("V"), &q, 1, 0.99);
assert_eq!(
hits.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(),
vec!["v200"],
"a query mid-build must be answered exhaustively, not from the \
{} vectors the index has reached; got {hits:?}",
p.indexed
);
let any = db.find_similar_vector("emb", None, &q, 1, 0.99);
assert_eq!(
any.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(),
vec!["v200"],
"the label-less query answered from the partial graph; got {any:?}"
);
while !db.pump_index_build().unwrap().is_empty() {}
});
assert!(building_of(&db, "sim").is_none(), "the build must finish");
let after = db.find_similar_vector("emb", Some("V"), &q, 1, 0.99);
assert_eq!(
after.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(),
vec!["v200"],
"the finished index must give the same answer; got {after:?}"
);
}
#[test]
fn a_reader_over_a_mid_build_snapshot_answers_exactly() {
let (dir, mut db) = store_with_vectors("slice-search-lazy", 300);
let q = slice_vec_raw(200);
core_rules::with_hnsw_build_batch(64, || {
db.create_rule(slice_rule()).unwrap();
assert!(building_of(&db, "sim").is_some(), "must be mid-build");
db.snapshot().unwrap();
});
drop(db);
let db = GraphDb::open(&dir).unwrap();
let hits = db.find_similar_vector("emb", Some("V"), &q, 1, 0.99);
assert_eq!(
hits.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(),
vec!["v200"],
"a reader over a mid-build snapshot answered from the partial graph; got {hits:?}"
);
assert_eq!(
core_rules::hnsw_search_count(),
0,
"the partial graph must not have been walked at all"
);
drop(db);
let mut db = GraphDb::open(&dir).unwrap();
core_rules::with_hnsw_build_batch(64, || while !db.pump_index_build().unwrap().is_empty() {});
assert!(building_of(&db, "sim").is_none());
core_rules::hnsw_search_count_reset();
let after = db.find_similar_vector("emb", Some("V"), &q, 1, 0.99);
assert_eq!(
after.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(),
vec!["v200"]
);
assert!(
core_rules::hnsw_search_count() > 0,
"once the build is done the index must serve the query again"
);
}
#[test]
fn no_write_after_a_clean_reopen_looks_like_an_interrupted_build() {
for (case, dir_name) in [
("b-new", "slice-reopen-b"),
("c-update", "slice-reopen-c"),
("d-gains", "slice-reopen-d"),
] {
let dir = tmp(dir_name);
{
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..300 {
db.insert_node("V", &format!("v{i}"), vec![("emb".into(), slice_vec(i))])
.unwrap();
}
db.insert_node(
"V",
"bare",
vec![("note".into(), Value::Str("no emb".into()))],
)
.unwrap();
db.create_rule(slice_rule()).unwrap();
assert!(
db.stats().rules[0].building.is_none(),
"{case}: 300 vectors is one commit at the production slice"
);
db.snapshot().unwrap();
}
let mut db = GraphDb::open(&dir).unwrap();
let fires_before = db.stats().rules[0].fires;
let wal_before = std::fs::metadata(dir.join("wal.bin")).map_or(0, |m| m.len());
let subject = match case {
"b-new" => {
db.insert_node("V", "late", vec![("emb".into(), slice_vec(3))])
.unwrap();
"late"
}
"c-update" => {
db.set_prop("v0", "emb", slice_vec(3)).unwrap();
"v0"
}
_ => {
db.set_prop("bare", "emb", slice_vec(3)).unwrap();
"bare"
}
};
let wal = std::fs::read(dir.join("wal.bin")).unwrap();
let (tail, _) = decode_all(&wal[wal_before as usize..]);
assert!(
!tail
.iter()
.any(|r| matches!(r, WalRecord::RebuildRule { .. })),
"{case}: the first write after a reopen must not trigger a rebuild; \
WAL tail: {tail:?}"
);
assert!(
db.stats().rules[0].building.is_none(),
"{case}: a completed rule must not be reported as building"
);
assert_eq!(
db.stats().rules[0].fires - fires_before,
1,
"{case}: one write must evaluate the rule once, not once per node"
);
assert!(
db.neighbors(subject, "SIM", Direction::Out)
.unwrap()
.contains(&"v0".to_string())
|| subject == "v0",
"{case}: the write still derives its own edges"
);
}
}
#[test]
fn a_write_during_a_pending_build_derives_no_edges() {
let want = {
let (_d, mut db) = store_with_vectors("slice-noedge-want", 300);
db.create_rule(slice_rule()).unwrap();
db.insert_node("V", "late", vec![("emb".into(), slice_vec(3))])
.unwrap();
edge_set(&db, "SIM", 300)
};
let (_d, mut db) = store_with_vectors("slice-noedge", 300);
db.set_hnsw_build_batch(Some(64));
db.create_rule(slice_rule()).unwrap();
db.insert_node("V", "late", vec![("emb".into(), slice_vec(3))])
.unwrap();
assert!(
building_of(&db, "sim").is_some(),
"one write does not finish a 300/64 build"
);
assert_eq!(
edges_of(&db, "sim"),
0,
"a rule that is still building must derive nothing, not a partial set"
);
assert_eq!(
db.neighbors("late", "SIM", Direction::Out).unwrap(),
Vec::<String>::new()
);
while !db.pump_index_build().unwrap().is_empty() {}
assert!(building_of(&db, "sim").is_none());
assert_eq!(
edge_set(&db, "SIM", 300),
want,
"the backfill derives the whole set, the write included"
);
assert!(db
.neighbors("late", "SIM", Direction::Out)
.unwrap()
.contains(&"v0".to_string()));
}
#[test]
fn a_truncated_wal_replay_leaves_the_build_resumable() {
let want = {
let (_d, mut db) = store_with_vectors("slice-truncwal-want", 300);
db.create_rule(slice_rule()).unwrap();
edge_set(&db, "SIM", 300)
};
let dir = tmp("slice-truncwal");
let after_create;
{
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..300 {
db.insert_node("V", &format!("v{i}"), vec![("emb".into(), slice_vec(i))])
.unwrap();
}
db.set_hnsw_build_batch(Some(64));
db.create_rule(slice_rule()).unwrap();
assert_eq!(edges_of(&db, "sim"), 0);
after_create = std::fs::metadata(dir.join("wal.bin")).unwrap().len();
while !db.pump_index_build().unwrap().is_empty() {}
assert!(
edges_of(&db, "sim") > 0,
"the build finished before the kill"
);
}
let full = std::fs::read(dir.join("wal.bin")).unwrap();
let (discarded, _) = decode_all(&full[after_create as usize..]);
assert!(
discarded
.iter()
.any(|r| matches!(r, WalRecord::RebuildRule { .. })),
"the bytes being truncated must include the finishing RebuildRule, \
else this test proves nothing; got {discarded:?}"
);
std::fs::write(dir.join("wal.bin"), &full[..after_create as usize]).unwrap();
let mut db = core_rules::with_hnsw_build_batch(64, || GraphDb::open(&dir).unwrap());
let p = building_of(&db, "sim").expect("the replayed build is reported as outstanding");
assert_eq!(p.total, 300);
assert_eq!(
edges_of(&db, "sim"),
0,
"a rule still building owns no edges"
);
core_rules::with_hnsw_build_batch(64, || while !db.pump_index_build().unwrap().is_empty() {});
assert!(building_of(&db, "sim").is_none());
assert_eq!(
edge_set(&db, "SIM", 300),
want,
"a replayed unfinished build lands on the one-shot edge set"
);
}
#[test]
fn pump_index_build_is_refused_read_only() {
let dir = tmp("slice-readonly");
{
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("V", "v0", vec![("emb".into(), slice_vec(0))])
.unwrap();
db.snapshot().unwrap();
}
let opts = core_api::OpenOptions {
read_only: true,
..Default::default()
};
let mut db = GraphDb::open_with_options(&dir, opts).unwrap();
assert!(matches!(db.pump_index_build(), Err(GraphError::ReadOnly)));
}
fn clustered_vec_8_raw(i: u32, per: u32) -> Vec<f64> {
let dir = unit_vec_8_raw(0xC0FF_EE00 + i / per);
let noise = unit_vec_8_raw(0x5EED_0000 + i);
let mut out: Vec<f64> = dir
.iter()
.zip(noise.iter())
.map(|(d, n)| d + 0.08 * n)
.collect();
let norm = out.iter().map(|x| x * x).sum::<f64>().sqrt();
out.iter_mut().for_each(|x| *x /= norm);
out
}
fn exact_vec_rule(min: f64) -> RuleDef {
RuleDef {
name: "sim".into(),
src_label: "V".into(),
dst_label: "V".into(),
predicate: Predicate::VectorSimilar {
field: "emb".into(),
min,
},
edge_type: "SIM".into(),
weight_prop: Some("w".into()),
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
}
}
fn weighted_edges(
db: &GraphDb<RealFs>,
n: u32,
) -> std::collections::BTreeMap<(String, String), f64> {
let mut out = std::collections::BTreeMap::new();
for i in 0..n {
let src = format!("v{i}");
for dst in db
.neighbors(&src, "SIM", Direction::Out)
.unwrap_or_default()
{
let w = db
.explain(&src, &dst)
.unwrap()
.into_iter()
.find(|e| e.edge_type == "SIM" && e.src_key == src && e.dst_key == dst)
.and_then(|e| e.weight)
.expect("a derived SIM edge carries its weight");
out.insert((src.clone(), dst), w);
}
}
out
}
fn derive_exact_sim(
name: &str,
n: u32,
per: u32,
min: f64,
) -> std::collections::BTreeMap<(String, String), f64> {
let dir = tmp(name);
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..n {
db.insert_node(
"V",
&format!("v{i}"),
vec![("emb".into(), emb(&clustered_vec_8_raw(i, per)))],
)
.unwrap();
}
db.create_rule(exact_vec_rule(min)).unwrap();
while !db.pump_index_build().unwrap().is_empty() {}
weighted_edges(&db, n)
}
fn brute_force_sim(
n: u32,
per: u32,
min: f64,
) -> std::collections::BTreeMap<(String, String), f64> {
let vs: Vec<Vec<f64>> = (0..n).map(|i| clustered_vec_8_raw(i, per)).collect();
let mut out = std::collections::BTreeMap::new();
for i in 0..n {
for j in 0..n {
if i == j {
continue;
}
let dot: f64 = vs[i as usize]
.iter()
.zip(vs[j as usize].iter())
.map(|(a, b)| a * b)
.sum();
if dot >= min {
out.insert((format!("v{i}"), format!("v{j}")), dot);
}
}
}
out
}
#[test]
fn index_backed_vector_rule_equals_brute_force_on_a_fixed_set() {
const N: u32 = 200;
const PER: u32 = 5;
const MIN: f64 = 0.90;
let truth = brute_force_sim(N, PER, MIN);
assert!(
truth.len() > 100,
"a vacuous fixture proves nothing; got {} pairs above {MIN}",
truth.len()
);
let index = derive_exact_sim("t4-equiv-index", N, PER, MIN);
let scan =
core_rules::with_vector_scan(true, || derive_exact_sim("t4-equiv-scan", N, PER, MIN));
let keys = |m: &std::collections::BTreeMap<(String, String), f64>| {
m.keys().cloned().collect::<std::collections::BTreeSet<_>>()
};
assert_eq!(
keys(&index),
keys(&truth),
"index-backed rule must find every true pair"
);
assert_eq!(
keys(&scan),
keys(&truth),
"the escape hatch must still be exact"
);
assert_eq!(index, scan, "scores are computed exactly on both paths");
for ((s, d), w) in &truth {
let got = index[&(s.clone(), d.clone())];
assert!(
(got - w).abs() < 1e-12,
"weight {s}->{d}: index {got} brute force {w}"
);
}
}
#[test]
fn index_backed_vector_rule_answers_from_one_full_beam() {
const N: u32 = 600;
const PER: u32 = 6;
const MIN: f64 = 0.90;
let truth = brute_force_sim(N, PER, MIN);
assert!(
truth.len() > 500,
"a vacuous fixture proves nothing; got {} pairs",
truth.len()
);
core_rules::hnsw_search_count_reset();
let index = derive_exact_sim("t4-beam", N, PER, MIN);
let searches = core_rules::hnsw_search_count();
assert!(
searches > 0,
"this fixture is past the default beam width, so the graph — not the \
whole-index fallback — must be what answered"
);
assert!(
searches < 2 * u64::from(N),
"with 100 clusters of 6 the first beam already passes the floor, so no \
source should need a second search; got {searches} for {N} sources"
);
assert_eq!(
index
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
truth
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
"the beam must not drop a qualifying pair"
);
}
fn beam_width() -> usize {
core_rules::hnsw::hnsw_params().ef_search
}
fn cone_vec_2(i: u32, n: u32, span: f64) -> Vec<f64> {
let t = span * f64::from(i) / f64::from(n - 1);
vec![t.cos(), t.sin()]
}
fn sim_pairs(db: &GraphDb<RealFs>, n: u32) -> std::collections::BTreeSet<(String, String)> {
let mut out = std::collections::BTreeSet::new();
for i in 0..n {
let src = format!("v{i}");
for dst in db
.neighbors(&src, "SIM", Direction::Out)
.unwrap_or_default()
{
out.insert((src.clone(), dst));
}
}
out
}
fn derive_pairs_over(
name: &str,
n: u32,
rule: RuleDef,
vec_of: &dyn Fn(u32) -> Vec<f64>,
) -> std::collections::BTreeSet<(String, String)> {
let dir = tmp(name);
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..n {
db.insert_node("V", &format!("v{i}"), vec![("emb".into(), emb(&vec_of(i)))])
.unwrap();
}
db.create_rule(rule).unwrap();
while !db.pump_index_build().unwrap().is_empty() {}
sim_pairs(&db, n)
}
#[test]
fn a_beam_short_of_its_width_falls_back_to_every_vector() {
const N: u32 = 420;
assert!(
N as usize > beam_width(),
"this fixture must be wider than the configured beam ({}); resize N or \
unset MUSHROOMDB_HNSW_PARAMS",
beam_width()
);
let same = vec![1.0, 0.0];
core_rules::hnsw_search_count_reset();
let got = derive_pairs_over("t4-duplicates", N, exact_vec_rule(0.90), &|_| same.clone());
assert!(
core_rules::hnsw_search_count() > 0,
"the graph must have been consulted, else this proves nothing"
);
assert_eq!(
got.len(),
(N as usize) * (N as usize - 1),
"every pair of identical vectors is above any floor, so every ordered \
pair must be derived — a short beam must fall back to the whole side"
);
}
#[test]
fn the_beam_ceiling_falls_back_to_every_vector() {
const N: u32 = 420;
const MIN: f64 = 0.90;
assert!(
N as usize > beam_width(),
"this fixture must be wider than the configured beam ({}); resize N or \
unset MUSHROOMDB_HNSW_PARAMS",
beam_width()
);
let got = core_rules::with_ef_max(64, || {
derive_pairs_over("t4-ceiling", N, exact_vec_rule(MIN), &|i| {
cone_vec_2(i, N, 0.40)
})
});
assert_eq!(
got.len(),
(N as usize) * (N as usize - 1),
"every vector in the cone is above {MIN} of every other, so capping the \
beam must cost time and not pairs"
);
}
#[test]
fn the_widening_loop_runs_when_one_cluster_is_wider_than_the_beam() {
const DSTS: u32 = 810;
const SRCS: u32 = 20;
const MIN: f64 = 0.90;
const SPAN: f64 = 0.40;
assert!(
DSTS as usize > 2 * beam_width(),
"the loop only doubles if the index is past two beam widths ({}); resize \
DSTS or unset MUSHROOMDB_HNSW_PARAMS",
2 * beam_width()
);
let dir = tmp("t4-widen");
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..DSTS {
db.insert_node(
"V",
&format!("v{i}"),
vec![("emb".into(), emb(&cone_vec_2(i, DSTS, SPAN)))],
)
.unwrap();
}
for q in 0..SRCS {
let at = q * (DSTS / SRCS);
db.insert_node(
"Q",
&format!("q{q}"),
vec![("emb".into(), emb(&cone_vec_2(at, DSTS, SPAN)))],
)
.unwrap();
}
let mut def = exact_vec_rule(MIN);
def.src_label = "Q".into();
def.max_edges = Some(8);
core_rules::hnsw_search_count_reset();
db.create_rule(def).unwrap();
while !db.pump_index_build().unwrap().is_empty() {}
let searches = core_rules::hnsw_search_count();
assert!(
searches >= 2 * u64::from(SRCS),
"every source's first beam comes back full above the floor, so every \
source must search at least twice; got {searches} for {SRCS} sources"
);
let mut derived: Vec<(String, String)> = Vec::new();
for q in 0..SRCS {
let src = format!("q{q}");
for dst in db
.neighbors(&src, "SIM", Direction::Out)
.unwrap_or_default()
{
derived.push((src.clone(), dst));
}
}
assert_eq!(
derived.len(),
(SRCS as usize) * 8,
"top-8 per source over a cone where every destination qualifies"
);
let dvs: Vec<Vec<f64>> = (0..DSTS).map(|i| cone_vec_2(i, DSTS, SPAN)).collect();
let cos = |a: &[f64], b: &[f64]| a.iter().zip(b.iter()).map(|(x, y)| x * y).sum::<f64>();
for q in 0..SRCS {
let qv = cone_vec_2(q * (DSTS / SRCS), DSTS, SPAN);
let mut sims: Vec<f64> = dvs.iter().map(|v| cos(&qv, v)).collect();
sims.sort_by(|a, b| b.partial_cmp(a).unwrap());
let eighth = sims[7];
for (_, dst) in derived.iter().filter(|(s, _)| *s == format!("q{q}")) {
let j: usize = dst[1..].parse().unwrap();
let got = cos(&qv, &dvs[j]);
assert!(
got >= eighth - 1e-12,
"q{q}->{dst} scores {got}, below the 8th-best true {eighth}: \
the beam dropped a nearer neighbour"
);
}
}
}
#[test]
fn the_scan_escape_hatch_keeps_the_persisted_graph() {
const N: u32 = 60;
const PER: u32 = 5;
const MIN: f64 = 0.90;
let dir = tmp("t4-scan-keeps-graph");
{
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..N {
db.insert_node(
"V",
&format!("v{i}"),
vec![("emb".into(), emb(&clustered_vec_8_raw(i, PER)))],
)
.unwrap();
}
db.create_rule(exact_vec_rule(MIN)).unwrap();
db.snapshot().unwrap();
}
core_rules::with_vector_scan(true, || {
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node(
"V",
&format!("v{N}"),
vec![("emb".into(), emb(&clustered_vec_8_raw(N, PER)))],
)
.unwrap();
db.snapshot().unwrap();
});
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Other", "o", vec![("v".into(), Value::Int(1))])
.unwrap();
assert_eq!(
db.hnsw_build_count(),
0,
"a snapshot written under MUSHROOMDB_VECTOR_SCAN dropped the graph"
);
while !db.pump_index_build().unwrap().is_empty() {}
let truth = brute_force_sim(N + 1, PER, MIN);
assert_eq!(
sim_pairs(&db, N + 1),
truth
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
"the node written under the escape hatch must be in the edge set too"
);
}
#[test]
fn rebuilding_an_exact_vector_rule_keeps_its_graph() {
const N: u32 = 420;
const PER: u32 = 6;
const MIN: f64 = 0.90;
assert!(
N as usize > beam_width(),
"this fixture must be wider than the configured beam ({}); resize N or \
unset MUSHROOMDB_HNSW_PARAMS",
beam_width()
);
let dir = tmp("t4-rebuild-graph");
let mut db = GraphDb::open(&dir).unwrap();
for i in 0..N {
db.insert_node(
"V",
&format!("v{i}"),
vec![("emb".into(), emb(&clustered_vec_8_raw(i, PER)))],
)
.unwrap();
}
db.set_hnsw_build_batch(Some(64));
db.create_rule(exact_vec_rule(MIN)).unwrap();
assert!(
!db.builds_in_progress().is_empty(),
"{N} vectors past a 64-vector slice must defer"
);
while !db.pump_index_build().unwrap().is_empty() {}
let after_build = sim_pairs(&db, N);
assert_eq!(
after_build,
brute_force_sim(N, PER, MIN)
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
"the backfill a finished slice-build triggers derives the whole set"
);
core_rules::hnsw_search_count_reset();
db.rebuild_rule("sim").unwrap();
assert!(
core_rules::hnsw_search_count() > 0,
"a rebuilt exact vector rule must still probe its graph"
);
assert_eq!(sim_pairs(&db, N), after_build, "and land on the same edges");
}
#[test]
fn changing_an_embedding_retracts_and_rederives() {
let dir = tmp("t4-retract");
let mut db = GraphDb::open(&dir).unwrap();
let near = emb(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
let near2 = emb(&[0.99, 0.141, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
let far = emb(&[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
db.insert_node("V", "a", vec![("emb".into(), near.clone())])
.unwrap();
db.insert_node("V", "b", vec![("emb".into(), near2.clone())])
.unwrap();
db.insert_node("V", "c", vec![("emb".into(), far.clone())])
.unwrap();
db.create_rule(exact_vec_rule(0.9)).unwrap();
let pairs = |db: &GraphDb<RealFs>| {
let mut out = std::collections::BTreeSet::new();
for k in ["a", "b", "c"] {
for d in db.neighbors(k, "SIM", Direction::Out).unwrap_or_default() {
out.insert((k.to_string(), d));
}
}
out
};
let ab: std::collections::BTreeSet<(String, String)> = [("a", "b"), ("b", "a")]
.iter()
.map(|(s, d)| (s.to_string(), d.to_string()))
.collect();
let all: std::collections::BTreeSet<(String, String)> = [
("a", "b"),
("b", "a"),
("a", "c"),
("c", "a"),
("b", "c"),
("c", "b"),
]
.iter()
.map(|(s, d)| (s.to_string(), d.to_string()))
.collect();
assert_eq!(pairs(&db), ab, "c starts outside the cluster");
db.set_prop("c", "emb", near.clone()).unwrap();
assert_eq!(pairs(&db), all, "c in the cluster derives both its pairs");
db.set_prop("c", "emb", far.clone()).unwrap();
assert_eq!(pairs(&db), ab, "c leaving retracts exactly its own edges");
db.set_prop("c", "emb", near.clone()).unwrap();
assert_eq!(pairs(&db), all, "the edges come back");
}