Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_to describe fact validity — when the relation is true in the world.
  • recorded_at describes 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:

  1. The new edge inserts with its own valid_from and recorded_at.
  2. The pre-existing edge with the lower confidence has its valid_to set to the new edge’s valid_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 storev0.4.0 final
TemporalEdge::extract LLM-drivenout of scope (#156)
hybrid_rrf 4th-signal integrationv0.4.0 final
MCP / REST / gRPC graph_expand toolsv0.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.

Sources