Expand description
§topodb
The memory terrain for AI agents — embedded, temporal, graph-native.
TopoDB is an embedded, local-first memory engine for AI agents, written in pure Rust: a scoped temporal property graph on redb, with an op-log write path, disk-resident MVCC reads, and k-hop temporal traversal — running in-process, no server.
Status: 0.1 — breaking changes are 0.2.0. On-disk format still migrates
in place. The engine core and the recall layer are implemented: op log,
single-applier concurrency, scoped temporal traversal (point-in-time as_of
and Allen-style valid-time interval predicates), BM25 full-text search,
graph-scoped vector search, hybrid RRF recall (recency-, access-, and
corroboration-weighted), access stats, change feed, and replay-determinism
property tests.
use topodb::{
Db, Direction, IndexSpec, NodeId, Op, PropIndex, PropValue, Scope, ScopeId,
ScopeSet, TimeAxis, TraversalQuery, VectorQuery,
};
fn main() -> Result<(), topodb::TopoError> {
// Declare what gets indexed up front (Db::open defaults to NO indexes;
// the CLI/MCP layers declare this same spec for you automatically).
let spec = IndexSpec {
equality: vec![PropIndex { label: "Entity".into(), prop: "name".into() }],
text: vec![PropIndex { label: "Memory".into(), prop: "content".into() }],
};
let db = Db::open_with("memory.topodb", spec)?;
let scope = ScopeId::new();
let (a, b) = (NodeId::new(), NodeId::new());
// Every mutation is a batch of ops, applied atomically.
db.submit(vec![
Op::CreateNode {
id: a,
scope: Scope::Id(scope),
label: "Memory".into(),
props: [(
"content".to_string(),
PropValue::Str("ada wrote the first program".into()),
)]
.into(),
},
Op::CreateNode {
id: b,
scope: Scope::Shared,
label: "Entity".into(),
props: Default::default(),
},
Op::CreateEdge {
id: Default::default(),
scope: Scope::Id(scope),
ty: "ABOUT".into(),
from: a,
to: b,
props: Default::default(),
valid_from: None,
recorded_at: None,
},
// Embeddings are host-computed and submitted as ops (engine, not policy).
Op::SetEmbedding { id: a, model: "my-embedder".into(), vector: vec![0.1, 0.2, 0.3] },
])?;
// Every read is scoped — there is no unscoped read path.
let scopes = ScopeSet::of(&[scope]).with_shared();
// BM25 full-text recall.
let _hits = db.search_text(&scopes, "first program", 10)?;
// Vector recall, scoped, under the same model namespace.
let _near = db.search_vector(&VectorQuery {
scopes: scopes.clone(),
model: "my-embedder".into(),
vector: vec![0.1, 0.2, 0.3],
k: 10,
candidates: None,
})?;
// Scoped k-hop temporal traversal.
let _sub = db.traverse(&TraversalQuery {
scopes,
seeds: vec![a],
max_hops: 2,
edge_types: None,
direction: Direction::Out,
as_of: None,
time_axis: TimeAxis::Valid,
})?;
Ok(())
}§Design principles
- Narrow and deep — an agent-memory engine, not a general graph database
- Format stability is a feature — versioned on-disk format, migrations always
- Honest benchmarks from day one
- Engine, not policy — no LLM calls inside the database, ever
- Embedded-first — servers and sync are future layers, never prerequisites
§Core properties
- Temporal edges — facts supersede, never overwrite;
as_ofreads see history, and Allen-style interval predicates (ValidInterval:During/Overlaps/Before/After) ask interval-vs-interval questions over edge valid time (edges_from_interval/edges_to_interval/traverse_interval) - Structural scoping — every read takes a
ScopeSet; cross-scope edges require aSharedendpoint - Deterministic replay — the op log stores fully-resolved ops; replaying it reproduces state exactly (property-tested)
- Single-applier concurrency — writers from any thread serialize through one applier; reads run in redb MVCC read transactions, never block each other, and never block redb’s storage commits, though a long-running read can briefly delay the applier’s next batch while it holds the dicts/scope-registry read guards
License: MIT OR Apache-2.0.
Structs§
- Access
Stats - Per-node access statistics: how many times a node has been returned by a
scoped read, and the wall-clock millisecond timestamp of the most recent
such read.
Default(both zero) means “exists but never counted”. - Applied
Batch - The committed result of a successful
crate::Db::submit/crate::Db::submit_atcall: the inclusive[first_seq, last_seq]range the batch’s ops were assigned in the durable op log, and the batch’s ops in their fully-resolved form (timestamps filled in) as actually written. - Change
Event - A single committed op paired with its op-log sequence number.
- Created
Range - Creation-time range filter for search results: a candidate survives iff
its node id’s ULID timestamp (
crate::NodeId::timestamp_ms) is at or afterafter_ms(when set) and strictly beforebefore_ms(when set). Applied whereverPropRetainapplies — BEFORE top-k truncation (a filtered hit never consumes the result window) and never access-bumped on a dropped node. Both bounds set withafter_ms >= before_msis rejected as a caller bug: an empty range is not an empty result. - Db
- A handle to an open database. Cloning shares the same underlying storage
and applier thread —
DbisSend + Sync + Clone. All writes funnel through a single applier thread (viasubmit/submit_at), so batches serialize deterministically even under concurrent callers. - DbOptions
- Tuning knobs for
Db::open_with_options. Additive: every field defaults toNone, under which redb’s own default is used, so a freshDbOptions::default()behaves identically toDb::open/Db::open_with. - EdgeId
- Edge
Record - Hnsw
Params - Tuning knobs for a database’s HNSW graph maintenance (F8). Threaded from
crate::db::DbOptions::hnsw_params(Noneresolves todefault()at open — seeStorage::open_with_options) and re-exported astopodb::HnswParams(mirroring howIndexSpecis exposed) so a host can tune it, and so tests can open with a tinybuild_thresholdto exercise graph activation on small corpora without seeding thousands of rows. - Index
Spec - Declares which
(label, prop)pairs get indexed, and how. - Link
Suggestion - One suggested (but non-existent) edge endpoint, with evidence. Never a typed edge — creating one, and typing it, is host/agent policy.
- NodeId
- Node
Record - Prop
Half Life - Per-node recency half-life keyed by a string prop (e.g. a memory’s
kind): a node whosepropvalue matches aper_valueentry decays on that entry’s half-life; absent, non-string, or unmatched values fall todefault_ms.NoneonSearchOptionskeeps the flatrecency_half_life_ms— the engine stays agnostic about what the prop means; callers supply the map (seetopodb-json’s kind constants). - Prop
Index - A single declared
(label, prop)pair to index. - Prop
Retain - Mechanism-only string-prop allowlist for search results: a candidate
survives iff the value of
prop— aStr; a missing or non-Strvalue reads asabsent_aswhen that is set, otherwise never matches — equals one ofany_of. Applied BEFORE top-k truncation (a filtered hit never consumes the result window) and never access-bumped on a dropped node. The engine names no prop and no vocabulary — the host owns both (e.g. a host-defined category prop, where the host passes the category’s default asabsent_asso an unstamped node matches it). - Recall
Query - One production hybrid-recall request. The engine fuses; the HOST
resolves —
vectoris a pre-computed query embedding,expansionsare pre-resolved synonym terms. The engine neither knows nor cares where either came from (spec: engine mechanics, host policy). - ScopeId
- Scope
Set - The mandatory scope filter for every read. There is no unscoped read.
- Search
Options - Tuning for
Db::search_text_with.Defaultdisables every option, sosearch_text_with(scopes, query, k, &SearchOptions::default())behaves identically toDb::search_text. - SmolStr
- A
SmolStris a string type that has the following properties: - Subgraph
- Result of a traversal: every in-scope seed plus everything reached, deduped, with the full edge records (fetched from the EDGES table by slot) for every traversed edge.
- Suggest
Links Query - A
suggest_linksrequest.modelnames the vector namespace for the semantic leg;None(or no stored embedding under it) = structure-only.as_ofpins the adjacency view,None= now (traverse’s convention). - Traversal
Query - A bounded, scoped, temporal breadth-first traversal request.
- Vector
Query - A vector-search request: cosine-rank the embeddings under
modelwithinscopesagainstvector, returning the topk.
Enums§
- Direction
- Which adjacency to walk from each frontier node.
- Op
- A fully-resolved mutation. INVARIANT: ops are appended to the log only after the applier resolves every default (timestamps especially) — a stored op never contains “now”. Replay must be deterministic.
- Prop
Value - Scope
- Time
Axis - Which time axis a temporal read gates on.
Valid(the default) is world/valid-time —valid_from/valid_to, identical to every predicate this engine had before bi-temporal edges.Recordedis belief time —recorded_at/superseded_at— what we had WRITTEN byt, regardless of what the world was doing; a late-recorded fact is invisible on this axis until the write actually happened, even if itsvalid_frompredatest. - Topo
Error - Valid
Interval - Pragmatic Allen-relation subset over the half-open edge valid interval
[valid_from, valid_to)— an open edge (valid_to = None) is unbounded on the right. Query intervals are half-open[from, until)too, matching the temporal rewriter’sbetweenconvention. Valid axis only: recorded- axis intervals are out of scope (seeDb::traverse_interval’s composition rules). No disk-format change — this is query-time gating over the interval fields the v9 adjacency entries already carry.
Functions§
- analyze
- The v1 analyzer as a standalone utility for HOSTS that must agree with
the engine’s tokenization (e.g. storing synonym terms in stemmed form
so query-time lookup can’t miss a morphological variant). Same
pipeline
search_text/indexing use.