GraphMemoryStore(
backend: GraphBackend,
embedding_provider: EmbeddingProvider | None = None,
embedding_cache: EmbeddingCache | None = None,
)
Graph-aware memory store wrapping a GraphBackend.
All operations enforce tenant isolation via TenantScope.
Access counts are automatically incremented on reads to support
frequency-based retrieval scoring.
v7.3 Item 13.1: optional auto-embed at the store level.
The graph-backed memory LAYERS (semantic / procedural /
prospective) wrap their own writes through
:func:~symfonic.memory.embeddings.auto_embed.maybe_embed so
layer-API callers benefit even when constructing layers without
the orchestrator. Consolidation phases that bypass the layers
(Item 12 EntityLinker calls GraphMemoryStore.add_node
directly) need the same auto-embed pathway -- that's what the
constructor kwargs here are for.
The double wrap (layer -> store) is idempotent: maybe_embed
short-circuits when node.embedding is already populated, so
a node that went through SemanticLayer.store_fact only
triggers one provider call regardless of how many layers of
wrapping it traverses.
Both kwargs default to None so legacy callers that build
GraphMemoryStore directly are byte-identical to v7.2.
Source code in src/symfonic/memory/graph/store.py
| def __init__(
self,
backend: GraphBackend,
embedding_provider: EmbeddingProvider | None = None,
embedding_cache: EmbeddingCache | None = None,
) -> None:
"""v7.3 Item 13.1: optional auto-embed at the store level.
The graph-backed memory LAYERS (semantic / procedural /
prospective) wrap their own writes through
:func:`~symfonic.memory.embeddings.auto_embed.maybe_embed` so
layer-API callers benefit even when constructing layers without
the orchestrator. Consolidation phases that bypass the layers
(Item 12 EntityLinker calls ``GraphMemoryStore.add_node``
directly) need the same auto-embed pathway -- that's what the
constructor kwargs here are for.
The double wrap (layer -> store) is idempotent: ``maybe_embed``
short-circuits when ``node.embedding`` is already populated, so
a node that went through ``SemanticLayer.store_fact`` only
triggers one provider call regardless of how many layers of
wrapping it traverses.
Both kwargs default to ``None`` so legacy callers that build
``GraphMemoryStore`` directly are byte-identical to v7.2.
"""
self._backend = backend
self._embedding_provider = embedding_provider
self._embedding_cache = embedding_cache
|
add_edge
async
add_edge(
scope: TenantScope, edge: MemoryEdge
) -> MemoryEdge
Add an edge to the graph. Returns the persisted edge.
Source code in src/symfonic/memory/graph/store.py
| async def add_edge(self, scope: TenantScope, edge: MemoryEdge) -> MemoryEdge:
"""Add an edge to the graph. Returns the persisted edge."""
edge.tenant_id = scope.tenant_id
return await self._backend.add_edge(scope, edge)
|
add_node
async
add_node(
scope: TenantScope, node: MemoryNode
) -> MemoryNode
Add a node to the graph. Returns the persisted node.
v7.3 Item 13.1: when an embedding_provider is wired into
this store, nodes the caller left with embedding=None
auto-embed before the backend write. Caller-provided embeddings
always win (see :func:maybe_embed).
Source code in src/symfonic/memory/graph/store.py
| async def add_node(self, scope: TenantScope, node: MemoryNode) -> MemoryNode:
"""Add a node to the graph. Returns the persisted node.
v7.3 Item 13.1: when an ``embedding_provider`` is wired into
this store, nodes the caller left with ``embedding=None``
auto-embed before the backend write. Caller-provided embeddings
always win (see :func:`maybe_embed`).
"""
node.tenant_id = scope.tenant_id
if self._embedding_provider is not None:
# Late import keeps the symfonic.memory.embeddings package an
# optional surface for callers that never construct one.
from symfonic.memory.embeddings.auto_embed import maybe_embed
node = await maybe_embed(
node, self._embedding_provider, self._embedding_cache,
)
return await self._backend.add_node(scope, node)
|
bump_spreading
async
bump_spreading(
scope: TenantScope,
node_id: NodeId,
*,
include_retracted: bool = False,
) -> MemoryNode | None
Fetch a node and increment its spreading_access_count.
v6.2 T02: BFS-induced reads funnel through this helper instead
of :meth:get_node, so the Phase 1 recurrence signal can
distinguish direct fetches from one-shot spreading-activation
visits. Both counters carry independent monotonic semantics:
- access_count grows on get_node
- spreading_access_count grows on bump_spreading
The method is intentionally named bump_spreading (rather than
get_node_via_spreading or a private _bump_spreading) so
alternative traversal strategies in future sprints can call it
directly without reaching into private API.
Returns None if the node does not exist or belongs to another
tenant; otherwise returns the updated node.
Soft-retracted nodes return None by default (and are not bumped) so
a retracted false memory cannot re-enter context via spreading
activation -- notably as a live node's neighbor. Pass
include_retracted=True only for audit/maintenance reads.
Source code in src/symfonic/memory/graph/store.py
| async def bump_spreading(
self, scope: TenantScope, node_id: NodeId,
*, include_retracted: bool = False,
) -> MemoryNode | None:
"""Fetch a node and increment its spreading_access_count.
v6.2 T02: BFS-induced reads funnel through this helper instead
of :meth:`get_node`, so the Phase 1 recurrence signal can
distinguish direct fetches from one-shot spreading-activation
visits. Both counters carry independent monotonic semantics:
- access_count grows on ``get_node``
- spreading_access_count grows on ``bump_spreading``
The method is intentionally named ``bump_spreading`` (rather than
``get_node_via_spreading`` or a private ``_bump_spreading``) so
alternative traversal strategies in future sprints can call it
directly without reaching into private API.
Returns None if the node does not exist or belongs to another
tenant; otherwise returns the updated node.
Soft-retracted nodes return ``None`` by default (and are not bumped) so
a retracted false memory cannot re-enter context via spreading
activation -- notably as a live node's neighbor. Pass
``include_retracted=True`` only for audit/maintenance reads.
"""
node = await self._backend.get_node(scope, node_id)
if node is None:
return None
if not include_retracted and is_retracted(node.properties):
return None
updated = await self._backend.update_node(
scope,
node_id,
{
"spreading_access_count": node.spreading_access_count + 1,
"updated_at": datetime.now(UTC).isoformat(),
},
)
return updated
|
delete_edge
async
delete_edge(scope: TenantScope, edge_id: EdgeId) -> None
Delete a single edge by its ID.
Source code in src/symfonic/memory/graph/store.py
| async def delete_edge(self, scope: TenantScope, edge_id: EdgeId) -> None:
"""Delete a single edge by its ID."""
await self._backend.delete_edge(scope, edge_id)
|
delete_node
async
delete_node(scope: TenantScope, node_id: NodeId) -> None
Delete a node and cascade-delete all connected edges.
Source code in src/symfonic/memory/graph/store.py
| async def delete_node(self, scope: TenantScope, node_id: NodeId) -> None:
"""Delete a node and cascade-delete all connected edges."""
await self._backend.delete_node(scope, node_id, cascade=True)
|
distinct_labels
async
distinct_labels(
scope: TenantScope, *, include_retracted: bool = False
) -> list[dict[str, Any]]
Get distinct labels with counts and last_updated.
Aggregates all nodes in the tenant scope by their label field.
Soft-retracted nodes are excluded by default so a label whose only
node has been retracted is NOT reported as present -- otherwise a
required-memory discovery check (retrieval.discovery) would mark a
block satisfied when no live node backs it. Pass
include_retracted=True for audit aggregation.
Returns:
| Type |
Description |
list[dict[str, Any]]
|
List of dicts with keys label, count, last_updated.
|
Source code in src/symfonic/memory/graph/store.py
| async def distinct_labels(
self, scope: TenantScope, *, include_retracted: bool = False,
) -> list[dict[str, Any]]:
"""Get distinct labels with counts and last_updated.
Aggregates all nodes in the tenant scope by their ``label`` field.
Soft-retracted nodes are excluded by default so a label whose only
node has been retracted is NOT reported as present -- otherwise a
required-memory discovery check (``retrieval.discovery``) would mark a
block satisfied when no live node backs it. Pass
``include_retracted=True`` for audit aggregation.
Returns:
List of dicts with keys ``label``, ``count``, ``last_updated``.
"""
# Full aggregation, no cap: route through the store's own
# ``query_nodes`` (the single retraction choke point). A flat
# backend cap was unsafe here -- with retractions excluded AFTER a
# truncated fetch, N>=cap retracted rows ahead of a live node made
# its label vanish (retrieval.discovery would then mark a satisfied
# block UNsatisfied). ``limit=None`` scans every node and refills
# past retracted rows via the same doubling over-fetch as siblings.
nodes = await self.query_nodes(
scope, limit=None, include_retracted=include_retracted,
)
groups: dict[str, dict[str, Any]] = defaultdict(
lambda: {"count": 0, "last_updated": None},
)
for node in nodes:
g = groups[node.label]
g["count"] += 1
if node.updated_at and (
g["last_updated"] is None or node.updated_at > g["last_updated"]
):
g["last_updated"] = node.updated_at
return [
{"label": label, "count": g["count"], "last_updated": g["last_updated"]}
for label, g in groups.items()
]
|
get_neighbors
async
get_neighbors(
scope: TenantScope,
node_id: NodeId,
relationship: str | None = None,
*,
include_retracted: bool = False,
) -> list[MemoryNode]
Get neighboring nodes, optionally filtered by relationship type.
Soft-retracted neighbours are excluded by default so a retracted node
cannot re-enter context as a graph neighbour of a live node. Audit /
maintenance callers pass include_retracted=True.
Source code in src/symfonic/memory/graph/store.py
| async def get_neighbors(
self,
scope: TenantScope,
node_id: NodeId,
relationship: str | None = None,
*,
include_retracted: bool = False,
) -> list[MemoryNode]:
"""Get neighboring nodes, optionally filtered by relationship type.
Soft-retracted neighbours are excluded by default so a retracted node
cannot re-enter context as a graph neighbour of a live node. Audit /
maintenance callers pass ``include_retracted=True``.
"""
neighbors = await self._backend.get_neighbors(
scope, node_id, relationship,
)
if include_retracted:
return neighbors
return [n for n in neighbors if not is_retracted(n.properties)]
|
get_node
async
get_node(
scope: TenantScope,
node_id: NodeId,
*,
include_retracted: bool = False,
) -> MemoryNode | None
Get a node by ID, incrementing its access count.
Returns None if the node does not exist or belongs to another tenant.
Soft-retracted nodes (see symfonic.memory.retraction for the
namespaced marker contract) return None by default so a
corrected/false-positive memory never re-enters context through a
direct fetch; the access count is NOT bumped for them. Audit /
erasure / prune callers pass include_retracted=True to reach them.
Source code in src/symfonic/memory/graph/store.py
| async def get_node(
self, scope: TenantScope, node_id: NodeId,
*, include_retracted: bool = False,
) -> MemoryNode | None:
"""Get a node by ID, incrementing its access count.
Returns None if the node does not exist or belongs to another tenant.
Soft-retracted nodes (see ``symfonic.memory.retraction`` for the
namespaced marker contract) return ``None`` by default so a
corrected/false-positive memory never re-enters context through a
direct fetch; the access count is NOT bumped for them. Audit /
erasure / prune callers pass ``include_retracted=True`` to reach them.
"""
node = await self._backend.get_node(scope, node_id)
if node is None:
return None
if not include_retracted and is_retracted(node.properties):
return None
# Increment access_count for frequency scoring
updated = await self._backend.update_node(
scope,
node_id,
{
"access_count": node.access_count + 1,
"updated_at": datetime.now(UTC).isoformat(),
},
)
return updated
|
list_edges
async
list_edges(
scope: TenantScope,
*,
limit: int = 50,
offset: int = 0,
relationship: str | None = None,
) -> list[MemoryEdge]
List edges for the tenant with optional filtering and pagination.
Delegates directly to GraphBackend.query_edges — no node iteration.
Parameters:
| Name |
Type |
Description |
Default |
scope
|
TenantScope
|
|
required
|
limit
|
int
|
Maximum number of edges to return (default 50).
|
50
|
offset
|
int
|
Number of edges to skip for pagination (default 0).
|
0
|
relationship
|
str | None
|
If provided, only edges of this type are returned.
|
None
|
Source code in src/symfonic/memory/graph/store.py
| async def list_edges(
self,
scope: TenantScope,
*,
limit: int = 50,
offset: int = 0,
relationship: str | None = None,
) -> list[MemoryEdge]:
"""List edges for the tenant with optional filtering and pagination.
Delegates directly to ``GraphBackend.query_edges`` — no node iteration.
Args:
scope: Tenant isolation scope.
limit: Maximum number of edges to return (default 50).
offset: Number of edges to skip for pagination (default 0).
relationship: If provided, only edges of this type are returned.
"""
filters: dict[str, Any] | None = None
if relationship is not None:
filters = {"relationship": relationship}
return await self._backend.query_edges(scope, filters, limit, offset)
|
query_nodes
async
query_nodes(
scope: TenantScope,
layer: MemoryLayer | None = None,
label: str | None = None,
label_prefix: str | None = None,
filters: dict[str, object] | None = None,
limit: int | None = None,
include_retracted: bool = False,
) -> list[MemoryNode]
Query nodes by layer, label, label_prefix, and/or arbitrary filters.
Parameters:
| Name |
Type |
Description |
Default |
scope
|
TenantScope
|
|
required
|
layer
|
MemoryLayer | None
|
Exact match on the memory layer.
|
None
|
label
|
str | None
|
Exact match on the node label.
|
None
|
label_prefix
|
str | None
|
Prefix match on the node label (e.g. "SOUL"
matches "SOUL: Joy" and "SOUL: name: Amiel").
label and label_prefix can coexist (AND logic).
|
None
|
filters
|
dict[str, object] | None
|
Arbitrary key/value filters passed through to the backend.
|
None
|
limit
|
int | None
|
Maximum rows to return. None (default) returns all
matching nodes. Prior to v8.7.2 this wrapper silently dropped
the caller's request and the backend truncated every read to
its ~50-row default — starving retrieval scans, dedupe, and
(worst) inverting working_graph_retention >= 50 pruning.
|
None
|
include_retracted
|
bool
|
When False (default) nodes soft-retracted
via a retract_node extraction op (see
symfonic.memory.retraction for the namespaced marker) are
filtered out. This is the single read-side choke point that
keeps a corrected/false-positive memory out of hydration,
routing, and dedup. Consolidation's prune phase passes
True so it can find and delete them. See
docs/guides/09-consolidation-and-deep-sleep.md.
|
False
|
A positive limit combined with include_retracted=False no
longer under-returns when a retracted row happens to fall inside the
backend's page: the wrapper over-fetches (doubling the requested
window) and refills with live nodes until limit is satisfied or
the backend is exhausted, then trims to exactly limit. This keeps
limit semantics honest for every backend (InMemory/Postgres/
Mongo) without any backend-side change.
Source code in src/symfonic/memory/graph/store.py
| async def query_nodes(
self,
scope: TenantScope,
layer: MemoryLayer | None = None,
label: str | None = None,
label_prefix: str | None = None,
filters: dict[str, object] | None = None,
limit: int | None = None,
include_retracted: bool = False,
) -> list[MemoryNode]:
"""Query nodes by layer, label, label_prefix, and/or arbitrary filters.
Args:
scope: Tenant isolation scope.
layer: Exact match on the memory layer.
label: Exact match on the node label.
label_prefix: Prefix match on the node label (e.g. ``"SOUL"``
matches ``"SOUL: Joy"`` and ``"SOUL: name: Amiel"``).
``label`` and ``label_prefix`` can coexist (AND logic).
filters: Arbitrary key/value filters passed through to the backend.
limit: Maximum rows to return. ``None`` (default) returns *all*
matching nodes. Prior to v8.7.2 this wrapper silently dropped
the caller's request and the backend truncated every read to
its ~50-row default — starving retrieval scans, dedupe, and
(worst) inverting ``working_graph_retention >= 50`` pruning.
include_retracted: When ``False`` (default) nodes soft-retracted
via a ``retract_node`` extraction op (see
``symfonic.memory.retraction`` for the namespaced marker) are
filtered out. This is the single read-side choke point that
keeps a corrected/false-positive memory out of hydration,
routing, and dedup. Consolidation's prune phase passes
``True`` so it can find and delete them. See
``docs/guides/09-consolidation-and-deep-sleep.md``.
A positive ``limit`` combined with ``include_retracted=False`` no
longer under-returns when a retracted row happens to fall inside the
backend's page: the wrapper over-fetches (doubling the requested
window) and refills with live nodes until ``limit`` is satisfied or
the backend is exhausted, then trims to exactly ``limit``. This keeps
``limit`` semantics honest for every backend (InMemory/Postgres/
Mongo) without any backend-side change.
"""
filter_dict: dict[str, object] = {}
if layer is not None:
filter_dict["layer"] = layer.value
if label is not None:
filter_dict["label"] = label
if label_prefix is not None:
filter_dict["label_prefix"] = label_prefix
if filters:
filter_dict.update(filters)
# v8.7.1: unify limit==0 semantics across backends. ``None`` = all,
# a positive int caps the result, 0 means "no rows". Mongo's native
# ``.limit(0)`` otherwise means *unlimited* (the opposite) and InMemory
# returned 1 row — so short-circuit here before dispatch.
if limit == 0:
return []
if include_retracted:
return await self._backend.query_nodes(scope, filter_dict, limit=limit)
if limit is None:
nodes = await self._backend.query_nodes(scope, filter_dict, limit=None)
return [n for n in nodes if not is_retracted(n.properties)]
# Retraction exclusion: a soft-retracted node stays in the store (for
# audit + reversibility) but must never surface to any reader. Applied
# here so every consumer of query_nodes -- semantic/procedural
# retrieve, dedup, spreading activation -- inherits the exclusion for
# free. Over-fetch with a doubling window so a retracted row inside
# the backend's page doesn't silently starve the caller's ``limit``.
fetch = limit
while True:
batch = await self._backend.query_nodes(scope, filter_dict, limit=fetch)
live = [n for n in batch if not is_retracted(n.properties)]
if len(live) >= limit or len(batch) < fetch:
break
fetch *= 2
return live[:limit]
|
update_node
async
update_node(
scope: TenantScope,
node_id: NodeId,
properties: dict[str, object],
) -> MemoryNode
Partial update of node properties.
Source code in src/symfonic/memory/graph/store.py
| async def update_node(
self, scope: TenantScope, node_id: NodeId, properties: dict[str, object]
) -> MemoryNode:
"""Partial update of node properties."""
return await self._backend.update_node(scope, node_id, properties)
|
upsert_edge
async
upsert_edge(
scope: TenantScope, edge: MemoryEdge
) -> MemoryEdge
Insert edge or increment weight if it already exists.
Uniqueness key: (tenant_id, source, target, relationship).
Each repeated call on the same pair increments weight by 1.
Source code in src/symfonic/memory/graph/store.py
| async def upsert_edge(self, scope: TenantScope, edge: MemoryEdge) -> MemoryEdge:
"""Insert edge or increment weight if it already exists.
Uniqueness key: (tenant_id, source, target, relationship).
Each repeated call on the same pair increments weight by 1.
"""
edge.tenant_id = scope.tenant_id
return await self._backend.upsert_edge(scope, edge)
|
upsert_edge_props
async
upsert_edge_props(
scope: TenantScope,
edge_id: EdgeId,
props: dict[str, Any],
) -> MemoryEdge | None
Update edge properties in-place.
Attempts backend.update_edge when available; otherwise falls back
to delete+re-add. Returns the updated edge, or None on failure.
Source code in src/symfonic/memory/graph/store.py
| async def upsert_edge_props(
self,
scope: TenantScope,
edge_id: EdgeId,
props: dict[str, Any],
) -> MemoryEdge | None:
"""Update edge properties in-place.
Attempts backend.update_edge when available; otherwise falls back
to delete+re-add. Returns the updated edge, or None on failure.
"""
if hasattr(self._backend, "update_edge"):
return await self._backend.update_edge(scope, edge_id, props)
# Fallback: find edge, apply props, delete, re-add
edges = await self._backend.query_edges(scope, None, 10_000, 0)
target_edge = None
for e in edges:
if str(e.id) == str(edge_id):
target_edge = e
break
if target_edge is None:
return None
valid_props = {k: v for k, v in props.items() if hasattr(target_edge, k)}
updated_edge = target_edge.model_copy(update=valid_props)
# Delete-then-add (v8.7.2): the previous add-then-delete order re-added
# the edge under the SAME id and then deleted that id, permanently
# destroying the edge — on InMemory the delete popped the just-written
# key, and on Postgres ``add_edge`` is ``ON CONFLICT DO NOTHING`` so the
# write was a no-op before the delete removed the original. Deleting
# first, then re-adding the mutated copy, is correct on every backend.
await self._backend.delete_edge(scope, edge_id)
await self._backend.add_edge(scope, updated_edge)
return updated_edge
|