use std::sync::Arc;
use grit_core::{
Budget, GraphOp, Grit, ManualClock, Options, Query, SearchKind, SearchTarget, Traversal,
import_jsonl,
};
use serde_json::json;
use uuid::Uuid;
fn open(dir: &tempfile::TempDir, name: &str, clock: Arc<ManualClock>) -> Grit {
Grit::open(
dir.path().join(name),
Options::new("test-device").clock(clock),
)
.unwrap()
}
fn add_node(g: &Grit, name: &str, summary: &str, group: &str) -> Uuid {
let id = g.new_id();
g.apply(GraphOp::AddNode {
id,
kind: "concept".into(),
name: name.into(),
summary: summary.into(),
attrs: json!({}),
group_id: group.into(),
})
.unwrap();
id
}
fn add_edge(g: &Grit, src: Uuid, dst: Uuid, rel: &str, fact: &str, group: &str) -> Uuid {
let id = g.new_id();
g.apply(GraphOp::AddEdge {
id,
src,
dst,
rel: rel.into(),
fact: fact.into(),
attrs: json!({}),
group_id: group.into(),
valid_at: None,
invalid_at: None,
})
.unwrap();
id
}
#[test]
fn write_search_traverse_history() {
let dir = tempfile::tempdir().unwrap();
let clock = Arc::new(ManualClock::new(1_000_000));
let g = open(&dir, "m.db", clock.clone());
let yoneda = add_node(
&g,
"Yoneda lemma",
"objects are their relationships",
"algebra",
);
let functor = add_node(&g, "Functor", "structure-preserving map", "algebra");
let exact = add_node(&g, "Exact sequence", "kernels meet images", "algebra");
let offtopic = add_node(&g, "Sourdough starter", "flour and water", "cooking");
let e1 = add_edge(
&g,
yoneda,
functor,
"USES",
"The Yoneda lemma is about hom-functors",
"algebra",
);
add_edge(
&g,
functor,
exact,
"PRESERVES",
"Exact functors preserve exactness",
"algebra",
);
let ep = g.new_id();
g.apply(GraphOp::AddEpisode {
id: ep,
source: "chat".into(),
kind: String::new(),
content: "We discussed the Yoneda lemma and exactness today".into(),
occurred_at: 999_000,
group_id: "algebra".into(),
mentions: vec![yoneda, e1],
})
.unwrap();
let hits = g
.search(
Query::text("yoneda")
.group("algebra")
.budget(Budget::items(10)),
)
.unwrap();
assert!(!hits.is_empty());
let node_hit = hits
.iter()
.find(|h| matches!(&h.target, SearchTarget::Node(n) if n.id == yoneda))
.expect("yoneda node should be a hit");
assert_eq!(node_hit.episodes, vec![ep]);
let hits = g.search(Query::text("sourdough").group("algebra")).unwrap();
assert!(hits.is_empty());
assert_eq!(g.node(offtopic).unwrap().unwrap().group_id, "cooking");
let one_hop = g
.traverse(&[yoneda], &Traversal::default().depth(1))
.unwrap();
let ids: Vec<Uuid> = one_hop.nodes.iter().map(|n| n.id).collect();
assert!(ids.contains(&yoneda) && ids.contains(&functor) && !ids.contains(&exact));
let two_hop = g
.traverse(&[yoneda], &Traversal::default().depth(2))
.unwrap();
assert_eq!(two_hop.nodes.len(), 3);
assert_eq!(two_hop.edges.len(), 2);
let history = g.node_history(yoneda).unwrap();
assert_eq!(history.edges.len(), 1);
assert_eq!(g.mentions_of(e1).unwrap(), vec![ep]);
let hits = g
.search(Query::text("yoneda").budget(Budget::items(1)))
.unwrap();
assert_eq!(hits.len(), 1);
}
#[test]
fn merge_candidates_and_merge() {
let dir = tempfile::tempdir().unwrap();
let clock = Arc::new(ManualClock::new(1_000_000));
let g = open(&dir, "m.db", clock.clone());
let a = add_node(&g, "Yoneda lemma", "", "algebra");
let b = add_node(&g, "Yoneda Lemma", "duplicate from a later chat", "algebra");
let c = add_node(&g, "Snake lemma", "", "algebra");
let edge_on_b = add_edge(
&g,
b,
c,
"RELATED",
"the Yoneda lemma relates to diagram chasing",
"algebra",
);
let candidates = g.find_merge_candidates(a, 0.9).unwrap();
assert_eq!(candidates[0].node.id, b);
assert!(candidates[0].name_score > 0.99);
assert!(!candidates.iter().any(|c2| c2.node.id == c));
clock.advance_ms(10);
g.apply(GraphOp::MergeNodes { from: b, into: a }).unwrap();
let b_node = g.node(b).unwrap().unwrap();
assert_eq!(b_node.merged_into, Some(a));
assert!(b_node.expired_at.is_some());
assert_eq!(g.edge(edge_on_b).unwrap().unwrap().src, a);
let sub = g.traverse(&[b], &Traversal::default().depth(1)).unwrap();
let ids: Vec<Uuid> = sub.nodes.iter().map(|n| n.id).collect();
assert!(ids.contains(&a) && ids.contains(&c) && !ids.contains(&b));
assert!(g.apply(GraphOp::MergeNodes { from: a, into: a }).is_err());
}
#[test]
fn purge_is_exact_audited_and_permanent() {
let dir = tempfile::tempdir().unwrap();
let clock = Arc::new(ManualClock::new(1_000_000));
let g = open(&dir, "m.db", clock.clone());
let secret = add_node(&g, "Secret project", "codename butterfly", "work");
let other = add_node(&g, "Coworker", "", "work");
let edge = add_edge(
&g,
secret,
other,
"KNOWS",
"Coworker knows about codename butterfly",
"work",
);
let to_purge: Vec<Uuid> = std::iter::once(secret)
.chain(g.node_history(secret).unwrap().edges.iter().map(|e| e.id))
.collect();
g.apply(GraphOp::Purge { ids: to_purge }).unwrap();
assert!(g.node(secret).unwrap().is_none());
assert!(g.edge(edge).unwrap().is_none());
assert!(g.search(Query::text("butterfly")).unwrap().is_empty());
g.apply(GraphOp::AddNode {
id: secret,
kind: "concept".into(),
name: "Secret project".into(),
summary: String::new(),
attrs: json!({}),
group_id: "work".into(),
})
.unwrap();
assert!(g.node(secret).unwrap().is_none());
let ops = g.oplog_since(0).unwrap();
assert!(
ops.iter()
.any(|(_, e)| matches!(&e.op, GraphOp::Purge { ids } if ids.contains(&secret)))
);
assert!(g.apply(GraphOp::Purge { ids: vec![] }).is_err());
}
#[test]
fn embeddings_and_vector_search() {
let dir = tempfile::tempdir().unwrap();
let clock = Arc::new(ManualClock::new(1_000_000));
let g = open(&dir, "m.db", clock.clone());
let cat = add_node(&g, "Category", "", "math");
let set = add_node(&g, "Set", "", "math");
let bread = add_node(&g, "Bread", "", "food");
assert!(g.set_node_embedding(cat, vec![1.0, 0.0, 0.0, 0.0]).is_err());
assert_eq!(g.get_node_embedding(cat).unwrap(), None);
g.register_embedding_model("test-model", 4, "1").unwrap();
g.set_node_embedding(cat, vec![1.0, 0.0, 0.0, 0.0]).unwrap();
g.set_node_embedding(set, vec![0.9, 0.1, 0.0, 0.0]).unwrap();
g.set_node_embedding(bread, vec![0.0, 0.0, 1.0, 0.0])
.unwrap();
assert!(g.set_node_embedding(cat, vec![1.0]).is_err());
assert!(g.register_embedding_model("other", 8, "1").is_err());
let hits = g
.search(
Query::text("")
.vector(vec![1.0, 0.05, 0.0, 0.0])
.budget(Budget::items(2)),
)
.unwrap();
let ids: Vec<Uuid> = hits
.iter()
.filter_map(|h| match &h.target {
SearchTarget::Node(n) => Some(n.id),
_ => None,
})
.collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&cat) && ids.contains(&set));
let candidates = g.find_merge_candidates(cat, 0.95).unwrap();
assert!(
candidates
.iter()
.any(|c| c.node.id == set && c.vector_score.unwrap() > 0.95)
);
assert_eq!(
g.get_node_embedding(cat).unwrap(),
Some(vec![1.0, 0.0, 0.0, 0.0])
);
assert_eq!(g.get_node_embedding(g.new_id()).unwrap(), None);
g.set_node_embedding(cat, vec![0.5, 0.5, 0.0, 0.0]).unwrap();
assert_eq!(
g.get_node_embedding(cat).unwrap(),
Some(vec![0.5, 0.5, 0.0, 0.0])
);
let e = add_edge(&g, cat, set, "REL", "categories generalize sets", "math");
assert_eq!(g.get_edge_embedding(e).unwrap(), None);
g.set_edge_embedding(e, vec![0.0, 1.0, 0.0, 0.0]).unwrap();
assert_eq!(
g.get_edge_embedding(e).unwrap(),
Some(vec![0.0, 1.0, 0.0, 0.0])
);
g.apply(GraphOp::Purge { ids: vec![bread] }).unwrap();
assert_eq!(g.get_node_embedding(bread).unwrap(), None);
}
#[test]
fn group_scans_match_export_view() {
let dir = tempfile::tempdir().unwrap();
let clock = Arc::new(ManualClock::new(1_000_000));
let g = open(&dir, "scan.db", clock.clone());
let a = add_node(&g, "Alpha", "", "g1");
let b = add_node(&g, "Beta", "", "g1");
let _other = add_node(&g, "Gamma", "", "g2");
let e = add_edge(&g, a, b, "REL", "alpha relates to beta", "g1");
g.apply(GraphOp::AddEpisode {
id: g.new_id(),
source: "chat".into(),
kind: String::new(),
content: "later episode".into(),
occurred_at: 2_000,
group_id: "g1".into(),
mentions: vec![a],
})
.unwrap();
g.apply(GraphOp::AddEpisode {
id: g.new_id(),
source: "chat".into(),
kind: String::new(),
content: "earlier episode".into(),
occurred_at: 1_000,
group_id: "g1".into(),
mentions: vec![b],
})
.unwrap();
g.apply(GraphOp::MergeNodes { from: b, into: a }).unwrap();
let nodes = g.nodes_in_group("g1").unwrap();
assert_eq!(nodes.len(), 2, "expired rows included");
assert!(nodes.iter().any(|n| n.id == b && n.expired_at.is_some()));
assert!(g.nodes_in_group("g2").unwrap().len() == 1);
assert!(g.nodes_in_group("nope").unwrap().is_empty());
let edges = g.edges_in_group("g1").unwrap();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].id, e);
assert_eq!(edges[0].src, a, "merge re-pointing visible in the scan");
let eps = g.episodes_in_group("g1").unwrap();
assert_eq!(eps.len(), 2);
assert!(eps[0].occurred_at < eps[1].occurred_at);
let mut export = Vec::new();
g.export_jsonl(&mut export).unwrap();
let export = String::from_utf8(export).unwrap();
let count = |t: &str, g_id: &str| {
export
.lines()
.filter(|l| l.contains(&format!("\"t\":\"{t}\"")) && l.contains(g_id))
.count()
};
assert_eq!(g.nodes_in_group("g1").unwrap().len(), count("node", "g1"));
assert_eq!(g.edges_in_group("g1").unwrap().len(), count("edge", "g1"));
assert_eq!(
g.episodes_in_group("g1").unwrap().len(),
count("episode", "g1")
);
}
#[test]
fn update_node_revises_metadata() {
let dir = tempfile::tempdir().unwrap();
let clock = Arc::new(ManualClock::new(1_000_000));
let g = open(&dir, "u.db", clock.clone());
let n = add_node(&g, "Priya", "junior engineer", "g1");
clock.advance_ms(10);
g.apply(GraphOp::UpdateNode {
id: n,
name: Some("Priya Raman".into()),
summary: Some("senior data engineer at Meridian Health".into()),
kind: None,
attrs: Some(serde_json::json!({"labels": ["Entity", "Person"]})),
})
.unwrap();
let node = g.node(n).unwrap().unwrap();
assert_eq!(node.name, "Priya Raman");
assert_eq!(node.summary, "senior data engineer at Meridian Health");
assert_eq!(node.kind, "concept", "untouched field survives");
assert_eq!(node.attrs["labels"][1], "Person");
let found = |query: &str| {
g.search(Query::text(query).group("g1").budget(Budget::items(5)))
.unwrap()
.iter()
.any(|h| matches!(&h.target, SearchTarget::Node(node) if node.id == n))
};
assert!(found("Meridian"), "revised summary in FTS");
assert!(!found("junior"), "old summary out of FTS");
let err = g
.apply(GraphOp::UpdateNode {
id: n,
name: None,
summary: None,
kind: None,
attrs: None,
})
.unwrap_err();
assert!(err.to_string().contains("no fields"));
g.apply(GraphOp::Purge { ids: vec![n] }).unwrap();
g.apply(GraphOp::UpdateNode {
id: n,
name: None,
summary: Some("revenant".into()),
kind: None,
attrs: None,
})
.unwrap();
assert!(g.node(n).unwrap().is_none());
}
#[test]
fn targets_filter_spends_budget_on_requested_kinds_only() {
let dir = tempfile::tempdir().unwrap();
let clock = Arc::new(ManualClock::new(1_000_000));
let g = open(&dir, "t.db", clock.clone());
let a = add_node(&g, "Northwind Labs", "", "g1");
let b = add_node(&g, "Northwind Warehouse", "", "g1");
let _c = add_node(&g, "Northwind Cafe", "", "g1");
let e = add_edge(
&g,
a,
b,
"REL",
"Northwind Labs supplies Northwind Warehouse.",
"g1",
);
let hits = g
.search(
Query::text("Northwind")
.group("g1")
.targets(&[SearchKind::Edge])
.budget(Budget::items(1)),
)
.unwrap();
assert_eq!(hits.len(), 1);
assert!(matches!(&hits[0].target, SearchTarget::Edge(edge) if edge.id == e));
let mixed = g
.search(
Query::text("Northwind")
.group("g1")
.budget(Budget::items(10)),
)
.unwrap();
assert!(mixed.len() > 1);
}
#[test]
fn export_import_roundtrip_is_lossless() {
let dir = tempfile::tempdir().unwrap();
let clock = Arc::new(ManualClock::new(1_000_000));
let g = open(&dir, "a.db", clock.clone());
let n1 = add_node(&g, "Alpha", "first", "g1");
let n2 = add_node(&g, "Beta", "second", "g1");
let e = add_edge(&g, n1, n2, "REL", "alpha relates to beta", "g1");
g.apply(GraphOp::AddEpisode {
id: g.new_id(),
source: "doc".into(),
kind: String::new(),
content: "alpha and beta".into(),
occurred_at: 5,
group_id: "g1".into(),
mentions: vec![n1, e],
})
.unwrap();
clock.advance_ms(10);
g.apply(GraphOp::InvalidateEdge {
edge_id: e,
invalid_at: 1_000_500,
})
.unwrap();
g.apply(GraphOp::UpdateNode {
id: n1,
name: None,
summary: Some("first, revised".into()),
kind: None,
attrs: None,
})
.unwrap();
g.apply(GraphOp::Purge { ids: vec![n2] }).unwrap();
g.register_embedding_model("m", 4, "1").unwrap();
let mut first = Vec::new();
g.export_jsonl(&mut first).unwrap();
assert!(
String::from_utf8(first.clone())
.unwrap()
.contains("\"t\":\"node_update\""),
"the per-field update record must round-trip"
);
let stats = import_jsonl(dir.path().join("b.db"), first.as_slice()).unwrap();
assert_eq!(stats.nodes, 1); assert_eq!(stats.edges, 1);
assert_eq!(stats.oplog, 7);
let g2 = open(&dir, "b.db", clock.clone());
let mut second = Vec::new();
g2.export_jsonl(&mut second).unwrap();
assert_eq!(
String::from_utf8(first).unwrap(),
String::from_utf8(second).unwrap(),
"export → import → export must be byte-identical"
);
let mut third = Vec::new();
g2.export_jsonl(&mut third).unwrap();
assert!(import_jsonl(dir.path().join("b.db"), third.as_slice()).is_err());
}