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→ tokyoNo 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]: ...| Parameter | Default | Description |
|---|---|---|
fact_store | required | Any FactStore — the graph is built via the protocol query() surface, so every backend works. |
query | required | Seed extraction: entities whose name/alias shares a casefolded token (length ≥ 3) with the query become traversal starting points. |
user_id | None | Multi-tenant partition. Only this tenant’s facts enter the graph — cross-tenant paths are impossible by construction. |
hops | 2 | Max edges per path. BFS expands in both directions from each seed. |
valid_at | None | None = traverse only currently-valid facts. A concrete instant replays the graph as of that world time. |
limit | 10 | Max paths returned. |
fetch_limit | 2000 | Max 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→ tokyovalid_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| Member | Description |
|---|---|
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.nodes | Node names in traversal order (len(edges) + 1 entries). |
path.facts | The backing Fact rows — full provenance for every hop. |
edge.render() | One hop: alice —works_at→ acme. |
from loomflow.memory import Edge, FactGraph, Path, recall_graphEntity 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:
- Shorter paths first — a direct claim beats a two-hop inference.
- 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.