Skip to content

symfonic.memory.protocols_graph

protocols_graph

The graph-persistence backend protocol.

Split out of :mod:symfonic.memory.protocols (308 lines against the 300-line budget). GraphBackend is 143 of those on its own -- nine node/edge operations, each documented -- and it is the one backend protocol with a vocabulary of its own (NodeId, EdgeId, traversal) rather than the uniform store shape the rest of that module describes.

protocols re-exports it, so from symfonic.memory.protocols import GraphBackend is unchanged.

GraphBackend

Bases: Protocol

Protocol for graph node and edge persistence.

All methods enforce tenant isolation via TenantScope. Provides 9 core operations for full graph lifecycle management.

add_edge async

add_edge(scope: TenantScope, edge: Any) -> Any

Add an edge to the graph. Returns the persisted edge.

Source code in src/symfonic/memory/protocols_graph.py
async def add_edge(self, scope: TenantScope, edge: Any) -> Any:
    """Add an edge to the graph. Returns the persisted edge."""
    ...

add_node async

add_node(scope: TenantScope, node: Any) -> Any

Add a node to the graph. Returns the persisted node.

Source code in src/symfonic/memory/protocols_graph.py
async def add_node(self, scope: TenantScope, node: Any) -> Any:
    """Add a node to the graph. Returns the persisted node."""
    ...

delete_edge async

delete_edge(scope: TenantScope, edge_id: EdgeId) -> None

Delete a single edge by its ID.

Source code in src/symfonic/memory/protocols_graph.py
async def delete_edge(self, scope: TenantScope, edge_id: EdgeId) -> None:
    """Delete a single edge by its ID."""
    ...

delete_node async

delete_node(scope: TenantScope, node_id: NodeId, cascade: bool = False) -> None

Delete a node. When cascade=True, also removes all connected edges.

Source code in src/symfonic/memory/protocols_graph.py
async def delete_node(
    self,
    scope: TenantScope,
    node_id: NodeId,
    cascade: bool = False,
) -> None:
    """Delete a node. When cascade=True, also removes all connected edges."""
    ...

delete_subtree async

delete_subtree(scope: TenantScope) -> int

Erase every node at scope and below it. Returns the node count.

The descendant direction, and the only method here that runs it. Every read above is prefix-visibility-scoped: it answers "what can a query at P see?", which returns P's ancestors. Nothing enumerates what lies under a scope, so MemoryLifecyclePort.forget — which must erase a scope and its descendants — had no call to make.

One backend-native operation, never enumerate-then-delete. Two calls leave a window in which a concurrent write lands in a scope that was discovered but not yet swept, and that window is open during precisely the operation that must not miss a row: a tenant deletion while other work is still draining. Backends express it as a single statement (see :mod:symfonic.memory.subtree for the shared predicate); the in-memory backend does it inside one synchronous span.

Connected edges go with the nodes, as delete_node(cascade=True) does — an erased memory that left its edges behind would keep a traversable stub of what was supposed to be gone.

Source code in src/symfonic/memory/protocols_graph.py
async def delete_subtree(self, scope: TenantScope) -> int:
    """Erase every node at ``scope`` and below it. Returns the node count.

    The *descendant* direction, and the only method here that runs it.
    Every read above is prefix-visibility-scoped: it answers "what can a
    query at P see?", which returns P's **ancestors**. Nothing enumerates
    what lies under a scope, so ``MemoryLifecyclePort.forget`` — which must
    erase a scope and its descendants — had no call to make.

    **One backend-native operation, never enumerate-then-delete.** Two
    calls leave a window in which a concurrent write lands in a scope that
    was discovered but not yet swept, and that window is open during
    precisely the operation that must not miss a row: a tenant deletion
    while other work is still draining. Backends express it as a single
    statement (see :mod:`symfonic.memory.subtree` for the shared
    predicate); the in-memory backend does it inside one synchronous span.

    Connected edges go with the nodes, as ``delete_node(cascade=True)``
    does — an erased memory that left its edges behind would keep a
    traversable stub of what was supposed to be gone.
    """
    ...

get_neighbors async

get_neighbors(scope: TenantScope, node_id: NodeId, relationship: str | None = None) -> list[Any]

Get neighboring nodes, optionally filtered by relationship type.

Source code in src/symfonic/memory/protocols_graph.py
async def get_neighbors(
    self,
    scope: TenantScope,
    node_id: NodeId,
    relationship: str | None = None,
) -> list[Any]:
    """Get neighboring nodes, optionally filtered by relationship type."""
    ...

get_node async

get_node(scope: TenantScope, node_id: NodeId) -> Any | None

Get a node by ID. Returns None if not found.

Source code in src/symfonic/memory/protocols_graph.py
async def get_node(self, scope: TenantScope, node_id: NodeId) -> Any | None:
    """Get a node by ID. Returns None if not found."""
    ...

query_edges async

query_edges(scope: TenantScope, filters: dict[str, Any] | None = None, limit: int = 50, offset: int = 0) -> list[Any]

Query edges with optional filters and pagination.

Parameters:

Name Type Description Default
scope TenantScope

Tenant isolation scope.

required
filters dict[str, Any] | None

Optional filter dict. Supported key: relationship (exact match).

None
limit int

Maximum number of edges to return.

50
offset int

Number of edges to skip (for pagination).

0

Returns:

Type Description
list[Any]

List of MemoryEdge objects ordered by created_at descending.

Source code in src/symfonic/memory/protocols_graph.py
async def query_edges(
    self,
    scope: TenantScope,
    filters: dict[str, Any] | None = None,
    limit: int = 50,
    offset: int = 0,
) -> list[Any]:
    """Query edges with optional filters and pagination.

    Args:
        scope: Tenant isolation scope.
        filters: Optional filter dict. Supported key: ``relationship`` (exact match).
        limit: Maximum number of edges to return.
        offset: Number of edges to skip (for pagination).

    Returns:
        List of ``MemoryEdge`` objects ordered by ``created_at`` descending.
    """
    ...

query_nodes async

query_nodes(scope: TenantScope, filters: dict[str, Any], limit: int | None = 50) -> list[Any]

Query nodes by filters (layer, label, properties, etc.).

limit=None returns all matching nodes (no truncation).

Source code in src/symfonic/memory/protocols_graph.py
async def query_nodes(
    self,
    scope: TenantScope,
    filters: dict[str, Any],
    limit: int | None = 50,
) -> list[Any]:
    """Query nodes by filters (layer, label, properties, etc.).

    ``limit=None`` returns all matching nodes (no truncation).
    """
    ...

query_subtree async

query_subtree(scope: TenantScope, filters: dict[str, Any], limit: int | None = None) -> list[Any]

Query nodes at scope and below it. The descendant read.

The mirror of :meth:query_nodes, which is prefix-visibility-scoped and so returns a query's ancestors. Both directions are needed and neither substitutes for the other: retrieval asks "what may this scope see?", while the lifecycle verbs ask "what did this scope and its children produce?" — flush(scope) must publish a pending write made in a descendant session, and no ancestor-scoped read can find one.

Same filter vocabulary as :meth:query_nodes; limit=None returns every match.

Source code in src/symfonic/memory/protocols_graph.py
async def query_subtree(
    self,
    scope: TenantScope,
    filters: dict[str, Any],
    limit: int | None = None,
) -> list[Any]:
    """Query nodes at ``scope`` and **below** it. The descendant read.

    The mirror of :meth:`query_nodes`, which is prefix-visibility-scoped
    and so returns a query's *ancestors*. Both directions are needed and
    neither substitutes for the other: retrieval asks "what may this scope
    see?", while the lifecycle verbs ask "what did this scope and its
    children produce?" — ``flush(scope)`` must publish a pending write made
    in a descendant session, and no ancestor-scoped read can find one.

    Same filter vocabulary as :meth:`query_nodes`; ``limit=None`` returns
    every match.
    """
    ...

traverse async

traverse(scope: TenantScope, start: NodeId, max_depth: int) -> list[Any]

Traverse the graph from a start node up to max_depth.

Source code in src/symfonic/memory/protocols_graph.py
async def traverse(
    self,
    scope: TenantScope,
    start: NodeId,
    max_depth: int,
) -> list[Any]:
    """Traverse the graph from a start node up to max_depth."""
    ...

update_node async

update_node(scope: TenantScope, node_id: NodeId, updates: dict[str, Any]) -> Any

Partial update of node properties. Returns the updated node.

Source code in src/symfonic/memory/protocols_graph.py
async def update_node(
    self,
    scope: TenantScope,
    node_id: NodeId,
    updates: dict[str, Any],
) -> Any:
    """Partial update of node properties. Returns the updated node."""
    ...

upsert_edge async

upsert_edge(scope: TenantScope, edge: Any) -> Any

Insert edge or increment weight if it already exists.

An edge is considered a duplicate when (tenant_id, source, target, relationship) all match. On conflict the weight is incremented by 1; no duplicate row is created and the edge id is preserved from the first insert.

Source code in src/symfonic/memory/protocols_graph.py
async def upsert_edge(self, scope: TenantScope, edge: Any) -> Any:
    """Insert edge or increment weight if it already exists.

    An edge is considered a duplicate when (tenant_id, source, target, relationship)
    all match. On conflict the weight is incremented by 1; no duplicate row is
    created and the edge id is preserved from the first insert.
    """
    ...