Skip to content

symfonic.core.learning.phases_maintenance

phases_maintenance

Maintenance consolidation phase implementations.

Phase 8 (Working TTL cleanup) and Phase 9 (Semantic importance decay) live here. These are housekeeping phases that clean up stale data rather than building new structure.

cleanup_working_ttl async

cleanup_working_ttl(
    graph: GraphMemoryStore,
    scope: TenantScope,
    all_nodes: list[MemoryNode] | None = None,
) -> int

Prune WORKING-layer graph nodes with expired TTL.

Queries all WORKING-layer nodes and deletes those whose ttl_hours property has elapsed relative to updated_at.

v7.26.2 (Shape C1) adds two durability-aware branches:

  • durability == "expired" -- pruned unconditionally (the explicit "this transient row served its purpose" signal from upgrade_durability), bypassing all TTL math.
  • durability == "transient" -- subject to a 1h implicit TTL floor. A transient row with no ttl_hours ages out after 1h; a transient row with a longer ttl_hours is capped at 1h (Contract F). This protects against the "still syncing" footgun where a transient row with ttl_hours=720 would otherwise survive 30 days.

Nodes with no durability marker keep their pre-v7.26.2 behaviour exactly: pruned only when they carry a ttl_hours that has elapsed.

Returns:

Type Description
int

Number of pruned nodes.

Source code in src/symfonic/core/learning/phases_maintenance.py
async def cleanup_working_ttl(
    graph: GraphMemoryStore,
    scope: TenantScope,
    all_nodes: list[MemoryNode] | None = None,
) -> int:
    """Prune WORKING-layer graph nodes with expired TTL.

    Queries all WORKING-layer nodes and deletes those whose
    ``ttl_hours`` property has elapsed relative to ``updated_at``.

    v7.26.2 (Shape C1) adds two durability-aware branches:

    * ``durability == "expired"`` -- pruned unconditionally (the explicit
      "this transient row served its purpose" signal from
      ``upgrade_durability``), bypassing all TTL math.
    * ``durability == "transient"`` -- subject to a 1h implicit TTL floor.
      A transient row with no ``ttl_hours`` ages out after 1h; a transient
      row with a longer ``ttl_hours`` is capped at 1h (Contract F).  This
      protects against the "still syncing" footgun where a transient row
      with ``ttl_hours=720`` would otherwise survive 30 days.

    Nodes with no durability marker keep their pre-v7.26.2 behaviour
    exactly: pruned only when they carry a ``ttl_hours`` that has elapsed.

    Returns:
        Number of pruned nodes.
    """
    if all_nodes is None:
        working_nodes = await graph.query_nodes(scope, layer=MemoryLayer.WORKING)
    else:
        working_nodes = [
            n for n in all_nodes
            if n.layer == MemoryLayer.WORKING
        ]

    now = datetime.now(UTC)
    pruned = 0
    for node in working_nodes:
        props = node.properties or {}
        durability = props.get("durability", "durable")

        # v7.26.2: explicit "expired" marker prunes regardless of TTL.
        if durability == "expired":
            try:
                await graph.delete_node(scope, node.id)
                pruned += 1
                logger.info(
                    "Durability=expired, pruned working node: %s", node.id,
                )
            except Exception:
                logger.debug(
                    "Failed to prune expired node %s", node.id, exc_info=True,
                )
            continue

        ttl_raw = props.get("ttl_hours")
        if ttl_raw is None:
            # v7.26.2: transient rows without an explicit TTL get a 1h floor.
            if durability == "transient":
                ttl_hours = 1.0
            else:
                continue
        else:
            try:
                ttl_hours = float(str(ttl_raw))
            except (ValueError, TypeError):
                continue
            # v7.26.2: transient rows are capped at the 1h floor (Contract F).
            if durability == "transient":
                ttl_hours = min(ttl_hours, 1.0)

        if node.updated_at is None:
            continue
        expiry = node.updated_at + timedelta(hours=ttl_hours)
        if now >= expiry:
            try:
                await graph.delete_node(scope, node.id)
                pruned += 1
                logger.info("Working TTL expired, pruned node: %s", node.id)
            except Exception:
                logger.debug(
                    "Failed to prune working TTL node %s", node.id, exc_info=True,
                )
    return pruned

decay_importance async

decay_importance(
    graph: GraphMemoryStore,
    scope: TenantScope,
    all_nodes: list[MemoryNode],
    stale_days: int = STALE_DAYS_DEFAULT,
) -> int

Reduce importance of semantic nodes not accessed in stale_days.

Nodes whose updated_at is older than the threshold have their importance reduced by 0.5, floored at 1.0.

High-signal / identity nodes are EXEMPT from decay so that the user profile and agent persona are never corrupted by age-based attrition. See :func:_is_exempt_from_decay for the exemption criteria.

Returns:

Type Description
int

Number of decayed nodes.

Source code in src/symfonic/core/learning/phases_maintenance.py
async def decay_importance(
    graph: GraphMemoryStore,
    scope: TenantScope,
    all_nodes: list[MemoryNode],
    stale_days: int = STALE_DAYS_DEFAULT,
) -> int:
    """Reduce importance of semantic nodes not accessed in ``stale_days``.

    Nodes whose ``updated_at`` is older than the threshold have their
    importance reduced by 0.5, floored at 1.0.

    High-signal / identity nodes are EXEMPT from decay so that the user
    profile and agent persona are never corrupted by age-based attrition.
    See :func:`_is_exempt_from_decay` for the exemption criteria.

    Returns:
        Number of decayed nodes.
    """
    cutoff = datetime.now(UTC) - timedelta(days=stale_days)
    decayed = 0
    for node in all_nodes:
        if node.updated_at is None or node.updated_at >= cutoff:
            continue
        if _is_exempt_from_decay(node):
            logger.debug(
                "decay_importance: exempted %s (importance=%.1f, label=%s)",
                node.id, node.importance, node.label,
            )
            continue
        new_importance = max(node.importance - DECAY_AMOUNT, MIN_IMPORTANCE)
        if new_importance == node.importance:
            continue
        try:
            await graph.update_node(scope, node.id, {"importance": new_importance})
            decayed += 1
            logger.info(
                "Importance decayed: node=%s %.1f -> %.1f",
                node.id, node.importance, new_importance,
            )
        except Exception:
            logger.debug("Failed to decay node %s", node.id, exc_info=True)
    return decayed

prune_retracted async

prune_retracted(
    graph: GraphMemoryStore,
    scope: TenantScope,
    grace_days: int = RETRACTED_GRACE_DAYS_DEFAULT,
) -> int

Hard-delete nodes soft-retracted more than grace_days ago.

A retract_node extraction op flags a false-positive/corrected memory with the namespaced retraction marker + timestamp (see symfonic.memory.retraction and SymfonicAgent._retract_memory). The flag alone removes the node from every read path immediately (GraphMemoryStore.query_nodes excludes it), but the row lingers for reversibility/audit. This phase reclaims it once it has been retracted past the grace window.

Must query with include_retracted=True -- retracted nodes are invisible to the default read path by design, so a plain scan would never see them.

query_nodes is ancestor-visible (a child scope sees a parent scope's rows), so an enumerated retracted node may belong to an ANCESTOR scope, not this one. Only a node materialized at exactly scope is deleted; ancestor-owned rows are left for the ancestor's own consolidation to reclaim, otherwise a child's consolidation could hard-delete a parent's memory.

Returns:

Type Description
int

Number of pruned nodes.

Source code in src/symfonic/core/learning/phases_maintenance.py
async def prune_retracted(
    graph: GraphMemoryStore,
    scope: TenantScope,
    grace_days: int = RETRACTED_GRACE_DAYS_DEFAULT,
) -> int:
    """Hard-delete nodes soft-retracted more than ``grace_days`` ago.

    A ``retract_node`` extraction op flags a false-positive/corrected memory
    with the namespaced retraction marker + timestamp (see
    ``symfonic.memory.retraction`` and ``SymfonicAgent._retract_memory``). The
    flag alone removes the node from every read path immediately
    (``GraphMemoryStore.query_nodes`` excludes it), but the row lingers for
    reversibility/audit. This phase reclaims it once it has been retracted
    past the grace window.

    Must query with ``include_retracted=True`` -- retracted nodes are invisible
    to the default read path by design, so a plain scan would never see them.

    ``query_nodes`` is ancestor-visible (a child scope sees a parent scope's
    rows), so an enumerated retracted node may belong to an ANCESTOR scope,
    not this one. Only a node materialized at exactly ``scope`` is deleted;
    ancestor-owned rows are left for the ancestor's own consolidation to
    reclaim, otherwise a child's consolidation could hard-delete a parent's
    memory.

    Returns:
        Number of pruned nodes.
    """
    try:
        nodes = await graph.query_nodes(scope, include_retracted=True)
    except TypeError:
        # Store without the include_retracted kwarg (older/custom backend):
        # nothing to reclaim through this path.
        return 0

    cutoff = datetime.now(UTC) - timedelta(days=grace_days)
    write_scope = materialise_scope_path(scope)
    pruned = 0
    for node in nodes:
        props = node.properties or {}
        if not is_retracted(props):
            continue
        if stored_scope_path(props, node.tenant_id) != write_scope:
            continue
        # Within the grace window -> keep (reversible). Missing/unparseable
        # timestamp -> KEEP (never reclaim): a marker without a trustworthy
        # timestamp cannot be proven to be past the grace window, and treating
        # it as long-retracted would let arbitrary domain data that happens to
        # carry the marker (or a malformed stamp) be silently hard-deleted.
        retracted_at = props.get(RETRACTED_AT_KEY)
        if retracted_at is None:
            continue
        try:
            ts = datetime.fromisoformat(str(retracted_at))
        except (ValueError, TypeError):
            continue
        if ts.tzinfo is None:
            ts = ts.replace(tzinfo=UTC)
        if ts >= cutoff:
            continue
        try:
            await graph.delete_node(scope, node.id)
            pruned += 1
            logger.info(
                "Pruned retracted node: %s (reason=%r)",
                node.id, props.get(RETRACTION_REASON_KEY),
            )
        except Exception:
            logger.debug(
                "Failed to prune retracted node %s", node.id, exc_info=True,
            )
    return pruned