Temporal edges (mnemo-graph)
The mnemo-graph crate (introduced in v0.4.0-rc1) adds a
bitemporal graph layer over the existing storage backends. It’s
loosely inspired by Graphiti (Zep)
and the Graphiti paper: every
relation carries two clocks instead of one, so historical queries
can ask “what did we believe at time T?” without losing later
corrections.
The two clocks
Every edge in the graph stores:
valid_from valid_to (None = still true)
^ ^
| fact validity |
+-----------------------+
|
+-- recorded_at (when we wrote the row)
valid_from/valid_todescribe fact validity — when the relation is true in the world.recorded_atdescribes system time — when we wrote the row. Useful for audit replay: “show me what the graph looked like at recorded_at = 2026-04-15.”
Without the second clock, there’s no way to distinguish “we always knew Priya works at Acme since 2025” from “we found out yesterday Priya works at Acme since 2025.” Both situations are common; both need different answers from a debugging session.
The TemporalEdge model
#![allow(unused)]
fn main() {
pub struct TemporalEdge {
pub id: Uuid,
pub src: Uuid,
pub dst: Uuid,
pub relation: String,
pub valid_from: DateTime<Utc>,
pub valid_to: Option<DateTime<Utc>>, // None = still true
pub confidence: f32, // [0.0, 1.0]
pub recorded_at: DateTime<Utc>,
}
}
relation is a free-form string today ("works_at",
"located_in", "reports_to"). We considered an enum but
discarded it — codifying a relation set without real corpus data
risks pinning the wrong vocabulary. The full LLM extractor that
lands in v0.4.0 final will document the conventions it emits.
Storage
Two tables — DuckDB + Postgres equivalents:
CREATE TABLE graph_nodes (
id VARCHAR PRIMARY KEY,
label VARCHAR,
metadata JSON,
created_at VARCHAR NOT NULL
);
CREATE TABLE graph_edges (
id VARCHAR PRIMARY KEY,
src VARCHAR NOT NULL,
dst VARCHAR NOT NULL,
relation VARCHAR NOT NULL,
valid_from VARCHAR NOT NULL,
valid_to VARCHAR,
confidence FLOAT NOT NULL DEFAULT 1.0,
recorded_at VARCHAR NOT NULL
);
CREATE INDEX idx_graph_edges_src_validfrom ON graph_edges(src, valid_from);
CREATE INDEX idx_graph_edges_dst ON graph_edges(dst);
graph_expand — the bitemporal walk
#![allow(unused)]
fn main() {
use chrono::{TimeZone, Utc};
use mnemo_graph::{DuckGraphStore, GraphStore, TemporalEdge, graph_expand};
let store = DuckGraphStore::open_in_memory()?;
// Priya works at Acme starting 2026-01-01.
let priya = Uuid::now_v7();
let acme = Uuid::now_v7();
let acme_edge = TemporalEdge::new(
priya, acme, "works_at",
Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
None, 0.9,
);
store.insert_edge(&acme_edge).await?;
// Priya leaves Acme on 2026-04-01 and joins Globex.
let globex = Uuid::now_v7();
store.close_edge(acme_edge.id, Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap()).await?;
store.insert_edge(&TemporalEdge::new(
priya, globex, "works_at",
Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap(),
None, 0.95,
)).await?;
// Walk the graph at two different points in time.
let in_feb = Utc.with_ymd_and_hms(2026, 2, 15, 0, 0, 0).unwrap();
let in_june = Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
let reachable_feb = graph_expand(&store, priya, 2, in_feb).await?;
// ^^^ contains acme, NOT globex (relation hadn't started yet)
let reachable_june = graph_expand(&store, priya, 2, in_june).await?;
// ^^^ contains globex, NOT acme (relation closed at 2026-04-01)
}
This is the supersession property — without it, the graph layer would be redundant with a regular non-temporal graph.
Conflict resolution
When the LLM extractor (v0.4.0 final) emits a contradicting fact with higher confidence than an existing edge, the convention is:
- The new edge inserts with its own
valid_fromandrecorded_at. - The pre-existing edge with the lower confidence has its
valid_toset to the new edge’svalid_from— capping its validity window.
The result: a sceptical operator can reconstruct the historical view
that contained the old answer (via recorded_at) AND the corrected
view (via valid_from).
What ships in v0.4.0-rc1
| Status | |
|---|---|
TemporalEdge model | ✓ |
GraphStore async trait | ✓ |
DuckDB-backed DuckGraphStore | ✓ |
graph_expand BFS with as_of filter | ✓ |
| Postgres-backed store | v0.4.0 final |
TemporalEdge::extract LLM-driven | out of scope (#156) |
hybrid_rrf 4th-signal integration | v0.4.0 final |
MCP / REST / gRPC graph_expand tools | v0.4.0 final |
There is no LLM extractor, and there is no longer a stub pretending to be
one. mnemo-graph is a bitemporal storage + query layer: callers construct
TemporalEdges, and this crate stores, closes and walks them.
A TemporalEdge::extract stub lived here until 2026-08-15, always returning
Vec::new(). It was removed rather than left in place, because a function that
always returns empty is worse than an absent one: a caller cannot tell “found
no relations” from “not implemented”, so wiring it in produces silent no-ops
indefinitely. It also outlived its own promise by five releases — the docstring
said “lands in v0.4.0 final” while the workspace reached 0.5.23 — and its
graph-extract feature flag gated nothing, since the module was compiled
unconditionally. See #156.
If LLM-driven extraction is wanted later it should arrive as a designed feature with its own issue, not as a placeholder.