use proptest::prelude::*;
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use topodb::AdjEntry;
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![
(any::<usize>(), any::<usize>()).prop_map(|(f, t)| Intent::Edge {
from_ix: f,
to_ix: t
}),
any::<usize>().prop_map(|i| Intent::Close { edge_ix: i }),
(any::<usize>(), any::<i64>()).prop_map(|(i, v)| Intent::SetProp { node_ix: i, val: v }),
(any::<usize>(), any::<usize>()).prop_map(|(i, w)| Intent::SetText {
node_ix: i,
word_ix: w
}),
any::<usize>().prop_map(|i| Intent::Embed { node_ix: i }),
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 nodes_map(snap: &Snapshot) -> BTreeMap<NodeId, NodeRecord> {
snap.debug_nodes()
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect()
}
fn edges_map(snap: &Snapshot) -> BTreeMap<EdgeId, EdgeRecord> {
snap.debug_edges()
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect()
}
fn adj_map(m: &im::HashMap<NodeId, im::Vector<AdjEntry>>) -> BTreeMap<NodeId, Vec<AdjEntry>> {
m.iter()
.map(|(k, v)| {
let mut entries: Vec<AdjEntry> = v.iter().cloned().collect();
entries.sort_by_key(|e| e.edge);
(*k, entries)
})
.collect()
}
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 db = Db::open_with(dir.path().join("t.redb"), spec()).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 snap_before = db.debug_snapshot();
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 snap_after = db.debug_snapshot();
prop_assert!(
!Arc::ptr_eq(&snap_before, &snap_after),
"rebuild_state_from_ops must store a fresh Snapshot Arc, not leave the old one in place"
);
prop_assert_eq!(nodes_map(&snap_before), nodes_map(&snap_after));
prop_assert_eq!(edges_map(&snap_before), edges_map(&snap_after));
prop_assert_eq!(adj_map(snap_before.debug_out()), adj_map(snap_after.debug_out()));
prop_assert_eq!(adj_map(snap_before.debug_inn()), adj_map(snap_after.debug_inn()));
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);
}
}