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_node
async
¶
delete_edge
async
¶
delete_node
async
¶
Delete a node. When cascade=True, also removes all connected edges.
delete_subtree
async
¶
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
get_neighbors
async
¶
Get neighboring nodes, optionally filtered by relationship type.
get_node
async
¶
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: |
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 |
Source code in src/symfonic/memory/protocols_graph.py
query_nodes
async
¶
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
query_subtree
async
¶
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
traverse
async
¶
update_node
async
¶
Partial update of node properties. Returns the updated node.
upsert_edge
async
¶
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.