grit-core 0.1.0

Embedded, bi-temporal property graph for agent memory: one SQLite file, in-process, deterministic
Documentation
//! End-to-end coverage of the public API: writes, search, traversal, merge
//! candidates, purge, embeddings, export/import.

use std::sync::Arc;

use grit_core::{
    Budget, GraphOp, Grit, ManualClock, Options, Query, 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,
    })
    .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(),
        content: "We discussed the Yoneda lemma and exactness today".into(),
        occurred_at: 999_000,
        group_id: "algebra".into(),
        mentions: vec![yoneda, e1],
    })
    .unwrap();

    // Search: text hit + provenance + group filter.
    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]);

    // Group filtering: cooking content is invisible from algebra queries.
    let hits = g.search(Query::text("sourdough").group("algebra")).unwrap();
    assert!(hits.is_empty());
    assert_eq!(g.node(offtopic).unwrap().unwrap().group_id, "cooking");

    // Traversal: depth 1 reaches functor but not exact; depth 2 reaches both.
    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);

    // History: every incident edge, with provenance intact.
    let history = g.node_history(yoneda).unwrap();
    assert_eq!(history.edges.len(), 1);
    assert_eq!(g.mentions_of(e1).unwrap(), vec![ep]);

    // Budget is honored.
    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",
    );

    // Scoring only — grit never merges by itself.
    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();

    // b is expired with an audit pointer; its edge now hangs off a.
    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);

    // Traversal from the stale handle resolves to the canonical node.
    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));

    // Self-merge is rejected.
    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",
    );

    // Layer 2 lists the node AND its incident edges (their facts carry the
    // content being forgotten); node_history enumerates them.
    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());

    // Tombstones are permanent: re-adding the purged id is a no-op.
    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());

    // ...but the audit trail survives in the oplog.
    let ops = g.oplog_since(0).unwrap();
    assert!(
        ops.iter()
            .any(|(_, e)| matches!(&e.op, GraphOp::Purge { ids } if ids.contains(&secret)))
    );

    // Empty purges are rejected.
    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");

    // Embedding before registering a model fails loudly.
    assert!(g.set_node_embedding(cat, vec![1.0, 0.0, 0.0, 0.0]).is_err());

    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();

    // Dimension mismatches fail loudly.
    assert!(g.set_node_embedding(cat, vec![1.0]).is_err());
    assert!(g.register_embedding_model("other", 8, "1").is_err());

    // Vector leg: query near `cat` should rank cat/set above bread even
    // though the text matches nothing.
    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));

    // Vector similarity feeds merge candidates.
    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)
    );
}

#[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(),
        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::Purge { ids: vec![n2] }).unwrap();
    g.register_embedding_model("m", 4, "1").unwrap();

    let mut first = Vec::new();
    g.export_jsonl(&mut first).unwrap();

    let stats = import_jsonl(dir.path().join("b.db"), first.as_slice()).unwrap();
    assert_eq!(stats.nodes, 1); // n2 was purged
    assert_eq!(stats.edges, 1);
    assert_eq!(stats.oplog, 6);

    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"
    );

    // Import refuses to merge into non-empty databases.
    let mut third = Vec::new();
    g2.export_jsonl(&mut third).unwrap();
    assert!(import_jsonl(dir.path().join("b.db"), third.as_slice()).is_err());
}