Skip to Content
DocsMemoryFact graph

Fact graph

recall_graph() answers multi-hop questions that flat fact recall misses. Facts are (subject, predicate, object) triples; treat each one as a directed edge between entity nodes and “where does Alice’s employer operate?” becomes a two-hop traversal:

from loomflow.memory import recall_graph paths = await recall_graph( agent.memory.facts, # any FactStore "where does alice's employer operate?", user_id="alice", ) for path in paths: print(path.render()) # alice —works_at→ acme —operates_in→ tokyo

No single fact mentions both alice and tokyo, so keyword or vector recall over individual facts can’t connect them. The graph can. For the underlying fact model (supersession, valid_at) see Bi-temporal facts; for episodes vs facts see Episode vs Fact.


recall_graph(...)

async def recall_graph( fact_store: FactStore, query: str, *, user_id: str | None = None, hops: int = 2, valid_at: datetime | None = None, limit: int = 10, fetch_limit: int = 2000, ) -> list[Path]: ...
ParameterDefaultDescription
fact_storerequiredAny FactStore — the graph is built via the protocol query() surface, so every backend works.
queryrequiredSeed extraction: entities whose name/alias shares a casefolded token (length ≥ 3) with the query become traversal starting points.
user_idNoneMulti-tenant partition. Only this tenant’s facts enter the graph — cross-tenant paths are impossible by construction.
hops2Max edges per path. BFS expands in both directions from each seed.
valid_atNoneNone = traverse only currently-valid facts. A concrete instant replays the graph as of that world time.
limit10Max paths returned.
fetch_limit2000Max facts fetched from the store per call — the documented v1 scale bound.

Returns list[Path], ranked shortest-first; ties broken by the recency of the newest fact on the path.


Temporal correctness

Every edge carries its fact’s bi-temporal window, and supersession is edge invalidation, never edge deletion — the same semantics as FactStore.query(valid_at=):

# alice worked at acme, then moved to initech (supersession closed # the acme fact's valid_until). acme operates in tokyo; initech in austin. # Current traversal — the superseded edge is excluded: paths = await recall_graph(store, "alice employer", user_id="alice") # alice —works_at→ initech —operates_in→ austin # Point-in-time replay — the old edge is back, the new one doesn't exist yet: from datetime import UTC, datetime paths = await recall_graph( store, "alice employer", user_id="alice", valid_at=datetime(2024, 1, 5, tzinfo=UTC), ) # alice —works_at→ acme —operates_in→ tokyo

valid_at=None means currently valid: valid_until unset, or set to a future instant. A concrete valid_at uses the same half-open [valid_from, valid_until) window as the fact store.


Path and Edge

@dataclass(frozen=True) class Edge: subject: str # normalized node name predicate: str object: str # normalized node name fact: Fact # full provenance — raw spellings, timestamps @dataclass(frozen=True) class Path: edges: tuple[Edge, ...] directions: tuple[bool, ...] # True = forward hop, False = backward
MemberDescription
path.render()Human-readable chain: alice —works_at→ acme —operates_in→ tokyo. Backward hops render with a reversed arrow (tokyo ←operates_in— acme) so the direction of the underlying claim is never misstated.
path.nodesNode names in traversal order (len(edges) + 1 entries).
path.factsThe backing Fact rows — full provenance for every hop.
edge.render()One hop: alice —works_at→ acme.
from loomflow.memory import Edge, FactGraph, Path, recall_graph

Entity linking: exact-normalized + explicit merge()

Nodes are keyed by casefold + strip + collapse-whitespace of the raw subject/object text, so " Alice Smith " and "alice smith" are one node. Every raw spelling is kept in graph.aliases.

Fuzzy matching (prefix rules, embeddings) is deliberately not attempted — a silent wrong merge is worse than a missed one. When you know two spellings are the same entity, say so explicitly:

from loomflow.memory import FactGraph graph = FactGraph.from_facts(facts) # Before: bob's traversal never reaches acme (facts name "Robert"). graph.merge("bob", "Robert") # returns canonical name: "bob" # After: bob —works_at→ acme —operates_in→ tokyo paths = graph.traverse(["bob"], hops=2)

merge() affects traversal identity, not provenance — merged edges keep their original node names, and neither argument needs to exist in the graph yet.


Building the graph directly

recall_graph is the one-call wrapper. For repeated queries over the same fact set, build once and traverse many times:

from loomflow.memory import FactGraph facts = await agent.memory.facts.query(user_id="alice", limit=2000) graph = FactGraph.from_facts(facts) graph.entities # all node names (merge-resolved) graph.aliases # normalized name -> raw spellings graph.neighbors("acme", direction="in") # who points at acme? ("out"/"in"/"both") graph.seed_entities("alice employer") # query -> seed nodes graph.traverse(["alice"], hops=2, limit=50)

neighbors() and traverse() both accept valid_at= with the same temporal semantics as recall_graph.


Ranking

traverse() returns simple paths (no node revisited within a path), ranked:

  1. Shorter paths first — a direct claim beats a two-hop inference.
  2. Ties broken by recency — the path whose newest fact was recorded most recently wins.

v1 bounds. The graph is built in process, per call from facts fetched through the FactStore protocol — no graph database, no new tables. fetch_limit=2000 facts is the documented scale bound; path enumeration stops at a 10,000-path safety valve on dense graphs. Entity linking is exact-normalized-match only — distinct spellings stay distinct until you merge() them. For corpora beyond that scale you want a dedicated graph store, not this.

Last updated on