Skip to main content

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}