Skip to content

symfonic.memory.retrieval.spreading

spreading

SpreadingActivation -- expands semantic hits with graph neighbor nodes.

When a query matches a semantic node (e.g. "Marinos"), this component fetches connected nodes (e.g. "Pescado", "Ribeye") via the graph store. Results are scored by importance and deduplicated against already-retrieved entries.

Two traversal modes:

  • 1-hop concurrent gather (default, max_hops == 0): preserves v6.1.x behaviour byte-for-byte -- get_neighbors is called once per hit node via asyncio.gather.
  • N-hop BFS (v7.0 T08, max_hops >= 1): traverses the graph up to max_hops away via GraphTraversal.bfs which routes through GraphMemoryStore.bump_spreading (bumping spreading_access_count on every traversal hit -- see v6.2 T02). A per-hop decay (base_decay ** hop) dampens activation score with distance, and a max_total_nodes ceiling guards hydration latency on densely-connected graphs.

Neighbor fetches in the 1-hop path run concurrently so hydration latency stays O(1) round-trips rather than O(n).

LinkedNeighbor dataclass

LinkedNeighbor(
    node: MemoryNode,
    source_node: MemoryNode,
    relationship: str = "",
    hop_distance: int = 1,
    activation_score: float = 1.0,
)

A neighbor node discovered via spreading activation.

hop_distance and activation_score are v7.0 T08 additions; they default to 1 / 1.0 for 1-hop results so existing consumers reading only node / source_node / relationship continue to work byte-for-byte.

SpreadingActivation

SpreadingActivation(
    graph_store: GraphMemoryStore,
    max_neighbors_per_node: int = 5,
    min_importance: float = 0.0,
    min_hit_importance: float = 0.0,
    *,
    max_hops: int = 0,
    base_decay: float = 0.5,
    max_total_nodes: int = 50,
)

Expand semantic hits with graph-neighbor context.

Parameters:

Name Type Description Default
graph_store GraphMemoryStore

The graph store to query for neighbors.

required
max_neighbors_per_node int

Cap on neighbors fetched per hit node in the 1-hop path. Ignored on the N-hop BFS path (the max_total_nodes ceiling dominates there).

5
min_importance float

Skip neighbors below this importance threshold.

0.0
min_hit_importance float

v6.1.8 hydration threshold -- skip initial hit nodes whose importance is below this value. Default 0.0 preserves pre-v6.1.8 behaviour (every semantic hit is injected). Caller typically wires this from FrameworkConfig.hydration_threshold.

0.0
max_hops int

v7.0 T08 BFS depth. 0 (default) preserves v6.1.x byte-for-byte -- use the 1-hop concurrent gather. >= 1 enables N-hop BFS via GraphTraversal.bfs with per-hop decay.

0
base_decay float

Per-hop multiplicative decay applied to the activation score when max_hops >= 1. score = base_decay ** hop.

0.5
max_total_nodes int

Hard ceiling on total neighbours returned by the N-hop path. Guards hydration latency on densely- connected graphs.

50
Source code in src/symfonic/memory/retrieval/spreading.py
def __init__(
    self,
    graph_store: GraphMemoryStore,
    max_neighbors_per_node: int = 5,
    min_importance: float = 0.0,
    min_hit_importance: float = 0.0,
    *,
    max_hops: int = 0,
    base_decay: float = 0.5,
    max_total_nodes: int = 50,
) -> None:
    if max_hops < 0:
        raise ValueError("max_hops must be >= 0")
    if not 0.0 < base_decay <= 1.0:
        raise ValueError("base_decay must be in (0, 1]")
    if max_total_nodes < 1:
        raise ValueError("max_total_nodes must be >= 1")
    self._graph = graph_store
    self._max_per_node = max_neighbors_per_node
    self._min_importance = min_importance
    self._min_hit_importance = min_hit_importance
    self._max_hops = max_hops
    self._base_decay = base_decay
    self._max_total_nodes = max_total_nodes

expand async

expand(
    scope: TenantScope,
    hit_nodes: list[MemoryNode],
    already_seen_ids: set[str] | None = None,
) -> list[LinkedNeighbor]

Fetch neighbor nodes for each hit node.

Returns:

Type Description
list[LinkedNeighbor]

List of LinkedNeighbor objects, deduplicated and sorted by

list[LinkedNeighbor]

importance descending. On the N-hop path, per-hop decay is

list[LinkedNeighbor]

already folded into activation_score so callers can

list[LinkedNeighbor]

rank by (importance * activation_score) when desired.

Source code in src/symfonic/memory/retrieval/spreading.py
async def expand(
    self,
    scope: TenantScope,
    hit_nodes: list[MemoryNode],
    already_seen_ids: set[str] | None = None,
) -> list[LinkedNeighbor]:
    """Fetch neighbor nodes for each hit node.

    Returns:
        List of LinkedNeighbor objects, deduplicated and sorted by
        importance descending. On the N-hop path, per-hop decay is
        already folded into ``activation_score`` so callers can
        rank by ``(importance * activation_score)`` when desired.
    """
    hit_nodes = self._filter_hits(hit_nodes)
    if not hit_nodes:
        return []

    seen = set(already_seen_ids or set())
    for node in hit_nodes:
        seen.add(str(node.id))

    if self._max_hops >= 1:
        neighbors = await self._expand_bfs(scope, hit_nodes, seen)
    else:
        neighbors = await self._expand_one_hop(scope, hit_nodes, seen)

    neighbors.sort(key=lambda n: n.node.importance, reverse=True)
    return neighbors