Skip to content

symfonic.capabilities.memory.phases.strengthen

strengthen

Phase 1: a memory that keeps coming up matters more than one that does not.

Moved here from symfonic.core.learning.phases rather than reimplemented. The rule the move follows is the one the roster tables already state: a phase that is transcribed twice is a phase with two behaviours, and the second one is discovered by an adopter. core.learning.phases.strengthen is now this function, imported, so the legacy consolidator and the capability runtime cannot drift apart while both are shipped.

The body is unchanged. What follows is why it looks the way it does.

strengthen async

strengthen(graph: GraphMemoryStore, scope: TenantScope, recent_nodes: list[MemoryNode], *, spreading_weight: float = 0.5) -> int

Increment importance on recently accessed (recurring) nodes.

A node qualifies as "recurring" when EITHER:

  1. It appears >= 2 times in recent_nodes (duplicate-in-list signal). This covers access-log replay / neighbourhood traversal callers that intentionally feed per-hit records into the consolidator.

  2. Its combined recurrence score access_count + spreading_weight * spreading_access_count >= 2.

  3. access_count is bumped by GraphMemoryStore.get_node on every direct fetch (one-shot reads).

  4. spreading_access_count is bumped by GraphMemoryStore.bump_spreading on every BFS-induced visit (spreading activation through GraphTraversal.bfs).

spreading_weight (default 0.5) comes from FrameworkConfig.phase1_spreading_weight. At 0.5 the combined score matches v6.1.x observable behaviour: two BFS visits count like one direct fetch. Setting the weight to 0.0 yields the direct-only semantic from the user task description.

Before the v6.0.x fix, Phase 1 silently never fired in production: GraphMemoryStore.query_nodes returns each node exactly once, so the duplicate-in-list count was always 1 and the < 2 guard always skipped. Falling back to the per-node access_count surfaces the access-recurrence signal already captured at retrieval time without requiring a new DB schema or access log.

The count semantics of the list-duplicate path are preserved: a node that appears N>=2 times in the list still gets N successful update attempts (each subsequent read loads the freshly-boosted value).

ADR (v6.1 T02/T03, revised v6.2 T02): the canonical recurrence signal is the combined access_count + weighted spreading_access_count. v6.1 aligned spreading-activation reads with the single access_count counter by routing GraphTraversal.bfs through store.get_node. v6.2 decomposed the counter so callers can distinguish direct fetches from one-shot spreading visits. BFS now bumps spreading_access_count via GraphMemoryStore.bump_spreading. Intentionally excluded from bumping: query_nodes (returns candidate sets for scoring -- one query bumping N nodes would distort the frequency signal that scoring.py also reads), and shortest_path / graph_proximity (run inside the scoring loop itself -- same contamination risk).

Source code in src/symfonic/capabilities/memory/phases/strengthen.py
async def strengthen(
    graph: GraphMemoryStore,
    scope: TenantScope,
    recent_nodes: list[MemoryNode],
    *,
    spreading_weight: float = 0.5,
) -> int:
    """Increment importance on recently accessed (recurring) nodes.

    A node qualifies as "recurring" when EITHER:

    1. It appears >= 2 times in ``recent_nodes`` (duplicate-in-list signal).
       This covers access-log replay / neighbourhood traversal callers that
       intentionally feed per-hit records into the consolidator.

    2. Its combined recurrence score
       ``access_count + spreading_weight * spreading_access_count >= 2``.

       - ``access_count`` is bumped by ``GraphMemoryStore.get_node`` on
         every direct fetch (one-shot reads).
       - ``spreading_access_count`` is bumped by
         ``GraphMemoryStore.bump_spreading`` on every BFS-induced visit
         (spreading activation through ``GraphTraversal.bfs``).

       ``spreading_weight`` (default 0.5) comes from
       ``FrameworkConfig.phase1_spreading_weight``. At 0.5 the combined
       score matches v6.1.x observable behaviour: two BFS visits count
       like one direct fetch. Setting the weight to 0.0 yields the
       direct-only semantic from the user task description.

    Before the v6.0.x fix, Phase 1 silently never fired in production:
    ``GraphMemoryStore.query_nodes`` returns each node exactly once, so
    the duplicate-in-list count was always 1 and the ``< 2`` guard always
    skipped. Falling back to the per-node ``access_count`` surfaces the
    access-recurrence signal already captured at retrieval time without
    requiring a new DB schema or access log.

    The count semantics of the list-duplicate path are preserved: a node
    that appears N>=2 times in the list still gets N successful update
    attempts (each subsequent read loads the freshly-boosted value).

    ADR (v6.1 T02/T03, revised v6.2 T02): the canonical recurrence signal
    is the combined ``access_count`` + weighted ``spreading_access_count``.
    v6.1 aligned spreading-activation reads with the single ``access_count``
    counter by routing ``GraphTraversal.bfs`` through ``store.get_node``.
    v6.2 decomposed the counter so callers can distinguish direct fetches
    from one-shot spreading visits. BFS now bumps
    ``spreading_access_count`` via ``GraphMemoryStore.bump_spreading``.
    Intentionally excluded from bumping: ``query_nodes`` (returns
    candidate sets for scoring -- one query bumping N nodes would distort
    the frequency signal that scoring.py also reads), and
    ``shortest_path`` / ``graph_proximity`` (run inside the scoring
    loop itself -- same contamination risk).
    """
    list_counts: Counter[str] = Counter()
    for node in recent_nodes:
        list_counts[str(node.id)] += 1

    strengthened = 0
    # Deduplicate while preserving iteration count for list-duplicate nodes:
    # nodes passed multiple times in the list are strengthened per-iteration
    # (legacy behaviour). Nodes passed ONCE are strengthened when their
    # combined access score signals recurrence.
    for node in recent_nodes:
        nid = str(node.id)
        recurs_in_list = list_counts[nid] >= 2
        spread = getattr(node, "spreading_access_count", 0) or 0
        combined = node.access_count + spreading_weight * spread
        recurs_via_access = combined >= 2
        if not (recurs_in_list or recurs_via_access):
            continue
        new_importance = min(node.importance + IMPORTANCE_BOOST, MAX_IMPORTANCE)
        if new_importance == node.importance:
            continue
        try:
            await graph.update_node(scope, node.id, {"importance": new_importance})
            strengthened += 1
        except Exception:
            logger.debug("Failed to strengthen node %s", nid, exc_info=True)
    return strengthened