mnemo_graph/lib.rs
1//! Bitemporal graph layer for Mnemo.
2//!
3//! Inspired by Graphiti ([repo](https://github.com/getzep/graphiti),
4//! [paper](https://arxiv.org/abs/2501.13956)). The model is the same:
5//! every edge carries `valid_from` / `valid_to` (when the *fact* is
6//! true in the world) plus `recorded_at` (when the system saw it),
7//! so historical queries can ask "what did we believe at time T?"
8//! without losing later corrections.
9//!
10//! ```text
11//! valid_from valid_to (None = still true)
12//! ^ ^
13//! | fact validity |
14//! +-----------------------+
15//! |
16//! +-- recorded_at (when we wrote the row)
17//! ```
18//!
19//! Today this crate ships:
20//!
21//! 1. The [`TemporalEdge`] type and a [`GraphStore`] async trait.
22//! 2. A DuckDB-backed [`DuckGraphStore`] that creates `graph_nodes`
23//! and `graph_edges` tables on first use and supports the round-trip
24//! + bitemporal `as_of` walk needed by retrieval.
25//! 3. [`graph_expand`] — bounded BFS that respects `as_of` filtering
26//! and a maximum depth.
27//!
28//! The LLM-driven [`TemporalEdge::extract`] path is feature-gated under
29//! `graph-extract` and currently returns an empty `Vec`. A real
30//! extractor lands in v0.4.0 final once the prompt + ICL examples are
31//! tuned.
32
33pub mod extract;
34pub mod model;
35pub mod store;
36
37pub use crate::model::TemporalEdge;
38pub use crate::store::{GraphStore, duckdb::DuckGraphStore};
39
40use chrono::{DateTime, Utc};
41use std::collections::{HashSet, VecDeque};
42use uuid::Uuid;
43
44use crate::store::Result;
45
46/// Bounded BFS from `seed` that respects bitemporal validity at
47/// `as_of` and a max walk depth.
48///
49/// Returns every UUID reachable through edges whose
50/// `valid_from <= as_of < valid_to.unwrap_or(MAX)`. Self-loops are
51/// dropped. The seed is included in the returned set unless the
52/// caller filters it out themselves.
53pub async fn graph_expand(
54 store: &dyn GraphStore,
55 seed: Uuid,
56 depth: u8,
57 as_of: DateTime<Utc>,
58) -> Result<Vec<Uuid>> {
59 let mut visited: HashSet<Uuid> = HashSet::new();
60 let mut frontier: VecDeque<(Uuid, u8)> = VecDeque::new();
61 frontier.push_back((seed, 0));
62 visited.insert(seed);
63
64 while let Some((node, d)) = frontier.pop_front() {
65 if d == depth {
66 continue;
67 }
68 for edge in store.outgoing_at(node, as_of).await? {
69 if edge.dst == node {
70 continue;
71 }
72 if visited.insert(edge.dst) {
73 frontier.push_back((edge.dst, d + 1));
74 }
75 }
76 }
77 Ok(visited.into_iter().collect())
78}