Reading the memory graph: edges, neighbourhoods, and a portable export.
MemoryAdminService answers what a scope remembers -- the published
records. This answers how those records relate, which is a different question
with a different backend call, and the one a graph view and a GDPR export both
need.
Split rather than added because the two have different blast radii. Records are
what a turn recalls; edges are what a maintenance job rewrites and a privacy
request must include. An adopter mounting a read-only graph view should not
have to hold the object that can publish and forget.
Why it takes a backend and not a store. Relationships live on the
GraphBackend protocol -- query_edges, get_neighbors, traverse
-- and the capability-native store deliberately exposes only the three memory
ports. Reaching through the store for an edge would make every store owe a
graph, when most deployments want one.
GraphAdminService
GraphAdminService(graph: Any, records: Any = None)
Relationship reads for one deployment's graph backend.
Parameters:
| Name |
Type |
Description |
Default |
graph
|
Any
|
the GraphBackend the memory store persists through.
|
required
|
records
|
Any
|
a MemoryAdminService, needed only by :meth:export.
Absent, an export would contain relationships and no memories,
which is a worse answer to a portability request than an error.
|
None
|
Source code in src/symfonic/capabilities/memory/graph_admin.py
| def __init__(self, graph: Any, records: Any = None) -> None:
"""
Args:
graph: the ``GraphBackend`` the memory store persists through.
records: a ``MemoryAdminService``, needed only by :meth:`export`.
Absent, an export would contain relationships and no memories,
which is a worse answer to a portability request than an error.
"""
self._graph = graph
self._records = records
|
edges
async
edges(scope: MemoryScope, *, limit: int = 500, offset: int = 0) -> list[dict[str, Any]]
One bounded page of relationships, in newest backend order.
Source code in src/symfonic/capabilities/memory/graph_admin.py
| async def edges(
self, scope: MemoryScope, *, limit: int = 500, offset: int = 0
) -> list[dict[str, Any]]:
"""One bounded page of relationships, in newest backend order."""
found = await self._graph.query_edges(
tenant_scope(scope), {}, limit=limit, offset=offset
)
return [edge_body(edge) for edge in found]
|
export
async
export(scope: MemoryScope) -> dict[str, Any]
Everything this scope owns, as a portable document (GDPR Art. 20).
complete is part of the payload rather than an exception, because a
partial export is still owed to the subject -- and a request that
silently returned nine layers of ten would be a portability failure
nobody could see. What could not be read is named.
Source code in src/symfonic/capabilities/memory/graph_admin.py
| async def export(self, scope: MemoryScope) -> dict[str, Any]:
"""Everything this scope owns, as a portable document (GDPR Art. 20).
``complete`` is part of the payload rather than an exception, because a
partial export is still owed to the subject -- and a request that
silently returned nine layers of ten would be a portability failure
nobody could see. What could not be read is named.
"""
if self._records is None:
raise ValueError(
"this GraphAdminService was built without a records service, so "
"an export would carry relationships and no memories. Pass the "
"MemoryAdminService that owns the same store."
)
failed: dict[str, str] = {}
try:
page = await self._records.record_page(scope, limit=MAX_CANDIDATE_LIMIT)
memories = [_record_body(item.record) for item in page.memories]
incomplete = page.dropped or page.degraded or page.unavailable
if incomplete or len(memories) >= MAX_CANDIDATE_LIMIT:
failed["memories"] = "bounded export is incomplete; use a paginated inventory"
except Exception as unreadable: # noqa: BLE001 - named, not swallowed
memories, failed["memories"] = [], str(unreadable)[:200]
try:
relationships = await self.edges(scope, limit=100_000)
except Exception as unreadable: # noqa: BLE001
relationships, failed["edges"] = [], str(unreadable)[:200]
return {
"scope_path": scope.path,
"schema_version": EXPORT_SCHEMA_VERSION,
"memories": memories,
"edges": relationships,
"complete": not failed,
"failed": failed,
}
|
neighborhood
async
neighborhood(scope: MemoryScope, node_id: str, *, depth: int = 1) -> dict[str, Any]
One node's neighbours, out to depth hops.
Depth 1 is the immediate neighbours, which is what a graph view expands
on a click. Deeper traversals are the backend's job -- doing it here
with repeated neighbour calls would issue a query per node and call it
a traversal.
Source code in src/symfonic/capabilities/memory/graph_admin.py
| async def neighborhood(
self, scope: MemoryScope, node_id: str, *, depth: int = 1
) -> dict[str, Any]:
"""One node's neighbours, out to ``depth`` hops.
Depth 1 is the immediate neighbours, which is what a graph view expands
on a click. Deeper traversals are the backend's job -- doing it here
with repeated neighbour calls would issue a query per node and call it
a traversal.
"""
if depth <= 1:
found = await self._graph.get_neighbors(tenant_scope(scope), node_id)
else:
found = await self._graph.traverse(tenant_scope(scope), node_id, depth)
return {
"node_id": node_id,
"depth": depth,
"neighbors": [_node_body(node) for node in found],
}
|
nodes
async
nodes(scope: MemoryScope, *, limit: int = 500, offset: int = 0) -> list[dict[str, Any]]
Drawable nodes owned by scope, including its descendants.
A graph browser and a recall answer are different views. Recall is
ranked and ancestor-facing; a graph needs the endpoints of the edges
it was given, including conversation descendants, or it silently drops
almost every relationship as dangling.
Source code in src/symfonic/capabilities/memory/graph_admin.py
| async def nodes(
self, scope: MemoryScope, *, limit: int = 500, offset: int = 0
) -> list[dict[str, Any]]:
"""Drawable nodes owned by ``scope``, including its descendants.
A graph browser and a recall answer are different views. Recall is
ranked and ancestor-facing; a graph needs the endpoints of the edges
it was given, including conversation descendants, or it silently drops
almost every relationship as dangling.
"""
legacy = tenant_scope(scope)
query = getattr(self._graph, "query_subtree", None)
if limit <= 0:
return []
native_page = getattr(self._graph, "query_subtree_page", None)
if callable(native_page):
found = await native_page(legacy, {}, limit=limit, offset=offset)
return [_node_body(node) for node in found]
# GraphBackend's historical node contract has no offset. Fetch only
# through the requested page and slice here; this bounds process memory
# while keeping custom backends compatible. Native cursor pagination
# can replace this without changing the public admin/API contract.
through = offset + limit
found = await (
query(legacy, {}, limit=through)
if callable(query)
else self._graph.query_nodes(legacy, {}, limit=through)
)
return [_node_body(node) for node in found[offset:through]]
|
edge_body
edge_body(edge: Any) -> dict[str, Any]
One relationship as JSON. Named fields, so a model gaining an internal
one does not silently widen a privacy export.
Source code in src/symfonic/capabilities/memory/graph_admin.py
| def edge_body(edge: Any) -> dict[str, Any]:
"""One relationship as JSON. Named fields, so a model gaining an internal
one does not silently widen a privacy export."""
return {
"id": str(getattr(edge, "id", "")),
"source": str(getattr(edge, "source", "")),
"target": str(getattr(edge, "target", "")),
"relationship": str(getattr(edge, "relationship", "")),
"weight": getattr(edge, "weight", None),
"uses": getattr(edge, "uses", None),
}
|