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//! **Edge extraction is out of scope for this crate.** It is a bitemporal
29//! STORAGE + QUERY layer: callers construct `TemporalEdge`s and this crate
30//! stores, closes and walks them. There is deliberately no LLM in it.
31//!
32//! An `extract()` stub used to live here, always returning an empty `Vec`.
33//! That is worse than absent: a caller cannot distinguish "found no
34//! relations" from "not implemented", so wiring it in yields silent no-ops
35//! forever. It was removed in favour of saying so (see #156).
36
37pub mod model;
38pub mod store;
39
40pub use crate::model::TemporalEdge;
41pub use crate::store::{GraphStore, duckdb::DuckGraphStore};
42
43use chrono::{DateTime, Utc};
44use std::collections::{HashSet, VecDeque};
45use uuid::Uuid;
46
47use crate::store::Result;
48
49/// Bounded BFS from `seed` that respects bitemporal validity at
50/// `as_of` and a max walk depth.
51///
52/// Returns every UUID reachable through edges whose
53/// `valid_from <= as_of < valid_to.unwrap_or(MAX)`. Self-loops are
54/// dropped. The seed is included in the returned set unless the
55/// caller filters it out themselves.
56pub async fn graph_expand(
57 store: &dyn GraphStore,
58 seed: Uuid,
59 depth: u8,
60 as_of: DateTime<Utc>,
61) -> Result<Vec<Uuid>> {
62 let mut visited: HashSet<Uuid> = HashSet::new();
63 let mut frontier: VecDeque<(Uuid, u8)> = VecDeque::new();
64 frontier.push_back((seed, 0));
65 visited.insert(seed);
66
67 while let Some((node, d)) = frontier.pop_front() {
68 if d == depth {
69 continue;
70 }
71 for edge in store.outgoing_at(node, as_of).await? {
72 if edge.dst == node {
73 continue;
74 }
75 if visited.insert(edge.dst) {
76 frontier.push_back((edge.dst, d + 1));
77 }
78 }
79 }
80 Ok(visited.into_iter().collect())
81}