Skip to Content
MemoryFact graph

Fact graph

recall_graph() answers multi-hop questions that flat fact recall misses, by following chains of facts from one entity to the next. Use it when the answer depends on two or more facts that only connect through a shared entity. 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, so 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 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, with 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, because 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.

Scale bounds. The graph is built in process, per call from facts fetched through the FactStore protocol, with no graph database and 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