use proptest::prelude::*;
use std::collections::{BTreeMap, HashSet};
use topodb::*;
const WORDS: [&str; 8] = [
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
];
#[derive(Debug, Clone)]
enum Intent {
Edge {
from_ix: usize,
to_ix: usize,
},
Close {
edge_ix: usize,
},
SetProp {
node_ix: usize,
val: i64,
},
SetText {
node_ix: usize,
word_ix: usize,
},
Embed {
node_ix: usize,
},
Remove {
node_ix: usize,
},
}
fn scripts() -> impl Strategy<Value = (usize, usize, Vec<Intent>)> {
let intent = prop_oneof![
1 => (any::<usize>(), any::<usize>()).prop_map(|(f, t)| Intent::Edge {
from_ix: f,
to_ix: t
}),
1 => any::<usize>().prop_map(|i| Intent::Close { edge_ix: i }),
1 => (any::<usize>(), any::<i64>()).prop_map(|(i, v)| Intent::SetProp { node_ix: i, val: v }),
1 => (any::<usize>(), any::<usize>()).prop_map(|(i, w)| Intent::SetText {
node_ix: i,
word_ix: w
}),
4 => any::<usize>().prop_map(|i| Intent::Embed { node_ix: i }),
1 => any::<usize>().prop_map(|i| Intent::Remove { node_ix: i }),
];
(
3usize..10,
0usize..4,
proptest::collection::vec(intent, 0..20),
)
}
fn run_script(db: &Db, n_scoped: usize, n_shared: usize, intents: &[Intent]) -> ScopeId {
let scope_id = ScopeId::new();
let scope = Scope::Id(scope_id);
let mut nodes: Vec<(NodeId, Scope)> = Vec::new();
let mut create_ops = Vec::new();
for _ in 0..n_scoped {
let id = NodeId::new();
nodes.push((id, scope));
create_ops.push(Op::CreateNode {
id,
scope,
label: "M".into(),
props: Default::default(),
});
}
for _ in 0..n_shared {
let id = NodeId::new();
nodes.push((id, Scope::Shared));
create_ops.push(Op::CreateNode {
id,
scope: Scope::Shared,
label: "M".into(),
props: Default::default(),
});
}
db.submit_at(create_ops, 0).unwrap();
let mut edges: Vec<EdgeId> = Vec::new();
for (i, intent) in intents.iter().enumerate() {
let t = 1 + i as i64;
match *intent {
Intent::Edge { from_ix, to_ix } => {
if nodes.is_empty() {
continue;
}
let (from_id, from_scope) = nodes[from_ix % nodes.len()];
let (to_id, to_scope) = nodes[to_ix % nodes.len()];
if from_id == to_id {
continue; }
let cross_scope_violation = from_scope != to_scope
&& from_scope != Scope::Shared
&& to_scope != Scope::Shared;
if cross_scope_violation {
continue;
}
let id = EdgeId::new();
db.submit_at(
vec![Op::CreateEdge {
id,
scope: from_scope,
ty: "REL".into(),
from: from_id,
to: to_id,
props: Default::default(),
valid_from: None,
}],
t,
)
.unwrap();
edges.push(id);
}
Intent::Close { edge_ix } => {
if edges.is_empty() {
continue;
}
let id = edges[edge_ix % edges.len()];
let _ = db.submit_at(vec![Op::CloseEdge { id, valid_to: None }], t);
}
Intent::SetProp { node_ix, val } => {
if nodes.is_empty() {
continue;
}
let (id, _) = nodes[node_ix % nodes.len()];
let mut props: BTreeMap<String, Option<PropValue>> = BTreeMap::new();
props.insert("v".to_string(), Some(PropValue::Int(val)));
db.submit_at(vec![Op::SetNodeProps { id, props }], t)
.unwrap();
}
Intent::SetText { node_ix, word_ix } => {
if nodes.is_empty() {
continue;
}
let (id, _) = nodes[node_ix % nodes.len()];
let mut props: BTreeMap<String, Option<PropValue>> = BTreeMap::new();
props.insert(
"text".to_string(),
Some(PropValue::Str(WORDS[word_ix % WORDS.len()].into())),
);
db.submit_at(vec![Op::SetNodeProps { id, props }], t)
.unwrap();
}
Intent::Embed { node_ix } => {
if nodes.is_empty() {
continue;
}
let (id, _) = nodes[node_ix % nodes.len()];
db.submit_at(
vec![Op::SetEmbedding {
id,
model: "m".into(),
vector: vec![node_ix as f32],
}],
t,
)
.unwrap();
}
Intent::Remove { node_ix } => {
if nodes.len() <= 1 {
continue; }
let ix = node_ix % nodes.len();
let (id, _) = nodes[ix];
db.submit_at(vec![Op::RemoveNode { id }], t).unwrap();
nodes.remove(ix);
}
}
}
scope_id
}
fn adjacency_fingerprint(
db: &Db,
scopes: &ScopeSet,
seeds: &[NodeId],
) -> BTreeMap<NodeId, (Vec<NodeId>, Vec<EdgeId>)> {
let mut out = BTreeMap::new();
for &seed in seeds {
let sub = db
.traverse(&TraversalQuery {
scopes: scopes.clone(),
seeds: vec![seed],
max_hops: 1,
edge_types: None,
direction: Direction::Both,
as_of: Some(i64::MAX),
})
.unwrap();
let mut node_ids: Vec<NodeId> = sub.nodes.iter().map(|n| n.id).collect();
node_ids.sort();
let mut edge_ids: Vec<EdgeId> = sub.edges.iter().map(|e| e.id).collect();
edge_ids.sort();
out.insert(seed, (node_ids, edge_ids));
}
out
}
fn spec() -> IndexSpec {
IndexSpec {
equality: vec![PropIndex {
label: "M".into(),
prop: "v".into(),
}],
text: vec![PropIndex {
label: "M".into(),
prop: "text".into(),
}],
}
}
fn assert_equality_and_vector_parity(db: &Db, scopes: &ScopeSet) {
let dump = db.debug_dump_nodes();
let mut present_v: HashSet<i64> = HashSet::new();
for n in &dump {
if let Some(PropValue::Int(v)) = n.props.get("v") {
present_v.insert(*v);
let hits = db
.nodes_by_prop(scopes, "M", "v", &PropValue::Int(*v))
.unwrap();
assert!(
hits.iter().any(|h| h.id == n.id),
"nodes_by_prop(\"M\",\"v\",{v}) must find node {:?}",
n.id
);
}
}
let mut absent = 0i64;
while present_v.contains(&absent) {
absent = absent.wrapping_add(1);
}
let empty_hits = db
.nodes_by_prop(scopes, "M", "v", &PropValue::Int(absent))
.unwrap();
assert!(
empty_hits.is_empty(),
"nodes_by_prop must find nothing for unused value {absent}"
);
let k = dump.len().max(1);
for n in &dump {
if let Some((model, vector)) = &n.embedding {
if vector.iter().all(|x| *x == 0.0) {
continue;
}
let hits = db
.search_vector(&VectorQuery {
scopes: scopes.clone(),
model: model.clone(),
vector: vector.clone(),
k,
candidates: None,
})
.unwrap();
assert!(
hits.iter().any(|(rec, _)| rec.id == n.id),
"search_vector must find node {:?} via its own embedding under model {model:?}",
n.id
);
}
}
}
fn fts_hit_ids(db: &Db, scopes: &ScopeSet, word: &str, k: usize) -> Vec<NodeId> {
let mut ids: Vec<NodeId> = db
.search_text(scopes, word, k)
.unwrap()
.into_iter()
.map(|(n, _)| n.id)
.collect();
ids.sort();
ids
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn state_from_replay_equals_state_from_execution(script in scripts()) {
let (n_scoped, n_shared, intents) = script;
let dir = tempfile::tempdir().unwrap();
let options = DbOptions {
hnsw_params: Some(HnswParams {
build_threshold: 4,
..Default::default()
}),
..Default::default()
};
let db = Db::open_with_options(dir.path().join("t.redb"), spec(), options).unwrap();
let scope_id = run_script(&db, n_scoped, n_shared, &intents);
let scopes = ScopeSet::of(&[scope_id]).with_shared();
let live_nodes = db.debug_dump_nodes();
let live_edges = db.debug_dump_edges();
let seeds: Vec<NodeId> = live_nodes.iter().map(|n| n.id).collect();
let adj_before = adjacency_fingerprint(&db, &scopes, &seeds);
let adj_raw_before = db.debug_dump_adjacency().unwrap();
let postings_raw_before = db.debug_dump_postings().unwrap();
let vectors_raw_before = db.debug_dump_vectors().unwrap();
let embedding_ref_raw_before = db.debug_dump_embedding_ref().unwrap();
let vector_dims_raw_before = db.debug_dump_vector_dims().unwrap();
let label_index_raw_before = db.debug_dump_label_index().unwrap();
let hnsw_meta_raw_before = db.debug_dump_hnsw_meta().unwrap();
let hnsw_links_raw_before = db.debug_dump_hnsw_links().unwrap();
let text_k = live_nodes.len().max(1);
assert_equality_and_vector_parity(&db, &scopes);
let fts_before: Vec<Vec<NodeId>> =
WORDS.iter().map(|w| fts_hit_ids(&db, &scopes, w, text_k)).collect();
db.rebuild_state_from_ops().unwrap();
prop_assert_eq!(live_nodes, db.debug_dump_nodes());
prop_assert_eq!(live_edges, db.debug_dump_edges());
let adj_raw_after = db.debug_dump_adjacency().unwrap();
prop_assert_eq!(adj_raw_before, adj_raw_after);
let adj_after = adjacency_fingerprint(&db, &scopes, &seeds);
prop_assert_eq!(adj_before, adj_after);
let postings_raw_after = db.debug_dump_postings().unwrap();
prop_assert_eq!(postings_raw_before, postings_raw_after);
let vectors_raw_after = db.debug_dump_vectors().unwrap();
prop_assert_eq!(vectors_raw_before, vectors_raw_after);
let embedding_ref_raw_after = db.debug_dump_embedding_ref().unwrap();
prop_assert_eq!(embedding_ref_raw_before, embedding_ref_raw_after);
let vector_dims_raw_after = db.debug_dump_vector_dims().unwrap();
prop_assert_eq!(vector_dims_raw_before, vector_dims_raw_after);
let label_index_raw_after = db.debug_dump_label_index().unwrap();
prop_assert_eq!(label_index_raw_before, label_index_raw_after);
let hnsw_meta_raw_after = db.debug_dump_hnsw_meta().unwrap();
prop_assert_eq!(hnsw_meta_raw_before, hnsw_meta_raw_after);
let hnsw_links_raw_after = db.debug_dump_hnsw_links().unwrap();
prop_assert_eq!(hnsw_links_raw_before, hnsw_links_raw_after);
assert_equality_and_vector_parity(&db, &scopes);
let fts_after: Vec<Vec<NodeId>> =
WORDS.iter().map(|w| fts_hit_ids(&db, &scopes, w, text_k)).collect();
prop_assert_eq!(fts_before, fts_after);
}
}
#[test]
fn label_reads_are_identical_before_and_after_rebuild() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_with(dir.path().join("t.redb"), spec()).unwrap();
let scope_a = ScopeId::new();
let scope_b = ScopeId::new();
let (e1, e2, e3, m1, doomed) = (
NodeId::new(),
NodeId::new(),
NodeId::new(),
NodeId::new(),
NodeId::new(),
);
db.submit(vec![
Op::CreateNode {
id: e1,
scope: Scope::Id(scope_a),
label: "Entity".into(),
props: Default::default(),
},
Op::CreateNode {
id: e2,
scope: Scope::Id(scope_a),
label: "Entity".into(),
props: Default::default(),
},
Op::CreateNode {
id: e3,
scope: Scope::Id(scope_b),
label: "Entity".into(),
props: Default::default(),
},
Op::CreateNode {
id: m1,
scope: Scope::Id(scope_a),
label: "M".into(),
props: Default::default(),
},
Op::CreateNode {
id: doomed,
scope: Scope::Id(scope_a),
label: "Entity".into(),
props: Default::default(),
},
])
.unwrap();
db.submit(vec![Op::RemoveNode { id: doomed }]).unwrap();
let scopes = ScopeSet::of(&[scope_a, scope_b]);
let mut before_entity: Vec<NodeId> = db
.nodes_by_label(&scopes, "Entity")
.iter()
.map(|n| n.id)
.collect();
let mut before_m: Vec<NodeId> = db
.nodes_by_label(&scopes, "M")
.iter()
.map(|n| n.id)
.collect();
before_entity.sort();
before_m.sort();
assert_eq!(before_entity, {
let mut v = vec![e1, e2, e3];
v.sort();
v
});
assert_eq!(before_m, vec![m1]);
db.rebuild_state_from_ops().unwrap();
let mut after_entity: Vec<NodeId> = db
.nodes_by_label(&scopes, "Entity")
.iter()
.map(|n| n.id)
.collect();
let mut after_m: Vec<NodeId> = db
.nodes_by_label(&scopes, "M")
.iter()
.map(|n| n.id)
.collect();
after_entity.sort();
after_m.sort();
assert_eq!(
before_entity, after_entity,
"Entity hits must survive rebuild unchanged"
);
assert_eq!(before_m, after_m, "M hits must survive rebuild unchanged");
}
#[test]
fn search_vector_is_identical_before_and_after_rebuild() {
let dir = tempfile::tempdir().unwrap();
let options = DbOptions {
hnsw_params: Some(HnswParams {
build_threshold: 4,
..Default::default()
}),
..Default::default()
};
let db = Db::open_with_options(dir.path().join("t.redb"), spec(), options).unwrap();
let scope_id = ScopeId::new();
let scope = Scope::Id(scope_id);
let ids: Vec<NodeId> = (0..12).map(|_| NodeId::new()).collect();
let create_ops: Vec<Op> = ids
.iter()
.map(|&id| Op::CreateNode {
id,
scope,
label: "M".into(),
props: Default::default(),
})
.collect();
db.submit(create_ops).unwrap();
for (i, &id) in ids.iter().enumerate() {
let vector = vec![(i as f32) + 1.0, ((i * 3) % 7) as f32, (i % 5) as f32];
db.submit(vec![Op::SetEmbedding {
id,
model: "m".into(),
vector,
}])
.unwrap();
}
let scopes = ScopeSet::of(&[scope_id]).with_shared();
let queries: Vec<Vec<f32>> = vec![
vec![1.0, 0.0, 0.0],
vec![5.0, 2.0, 4.0],
vec![10.0, 1.0, 3.0],
];
let k = ids.len();
db.submit(vec![Op::RemoveNode { id: ids[2] }]).unwrap();
db.submit(vec![Op::RemoveNode { id: ids[7] }]).unwrap();
let search_before_removal_rebuild: Vec<Vec<(NodeId, f32)>> = queries
.iter()
.map(|q| {
db.search_vector(&VectorQuery {
scopes: scopes.clone(),
model: "m".into(),
vector: q.clone(),
k,
candidates: None,
})
.unwrap()
.into_iter()
.map(|(rec, score)| (rec.id, score))
.collect()
})
.collect();
let hnsw_meta_before = db.debug_dump_hnsw_meta().unwrap();
let hnsw_links_before = db.debug_dump_hnsw_links().unwrap();
db.rebuild_state_from_ops().unwrap();
let search_after: Vec<Vec<(NodeId, f32)>> = queries
.iter()
.map(|q| {
db.search_vector(&VectorQuery {
scopes: scopes.clone(),
model: "m".into(),
vector: q.clone(),
k,
candidates: None,
})
.unwrap()
.into_iter()
.map(|(rec, score)| (rec.id, score))
.collect()
})
.collect();
let hnsw_meta_after = db.debug_dump_hnsw_meta().unwrap();
let hnsw_links_after = db.debug_dump_hnsw_links().unwrap();
assert_eq!(
search_before_removal_rebuild, search_after,
"search_vector results must survive rebuild_state_from_ops unchanged"
);
assert_eq!(
hnsw_meta_before, hnsw_meta_after,
"HNSW_META must be byte-identical before/after rebuild_state_from_ops"
);
assert_eq!(
hnsw_links_before, hnsw_links_after,
"HNSW_LINKS must be byte-identical before/after rebuild_state_from_ops"
);
}