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→ 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, so 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 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→ 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, 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:
- 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.
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.