Skip to content

symfonic.core.learning.phases_semantic_expiry

phases_semantic_expiry

Moved to :mod:symfonic.capabilities.memory.phases.expiry.

These phases are on a roster the kernel composes, so they live in the capability now and are imported back here for the legacy consolidator to call. One phase, one implementation, for as long as both routes ship.

The private helpers travel with them: the shipped per-phase tests import them by name, and a shim that re-exported only the public surface would have moved the code away from the tests that watch it.

expire_semantic_durability async

expire_semantic_durability(graph: GraphMemoryStore, scope: TenantScope, all_nodes: list[MemoryNode]) -> int

Soft-retract SEMANTIC nodes whose durability/validity has elapsed.

Terminal action is always soft-retraction (the namespaced marker from symfonic.memory.retraction), never delete: this flows through the same reversible, audited path as retract_node, and the existing prune_retracted phase reclaims the row after the grace window.

Returns:

Type Description
int

Number of nodes retracted.

Source code in symfonic/capabilities/memory/phases/expiry.py
async def expire_semantic_durability(
    graph: GraphMemoryStore,
    scope: TenantScope,
    all_nodes: list[MemoryNode],
) -> int:
    """Soft-retract SEMANTIC nodes whose durability/validity has elapsed.

    Terminal action is always soft-retraction (the namespaced marker from
    ``symfonic.memory.retraction``), never delete: this flows through the
    same reversible, audited path as ``retract_node``, and the existing
    ``prune_retracted`` phase reclaims the row after the grace window.

    Returns:
        Number of nodes retracted.
    """
    now = datetime.now(UTC)
    retracted = 0
    for node in all_nodes:
        if is_identity_labelled(node):
            continue  # SOUL:/AGENT_IDENTITY: never age out here -- see module docstring.

        props = dict(node.properties or {})
        if is_pending(props):
            # An unpublished write borrows durability="transient" so the legacy
            # reader hides it (symfonic.memory.pending), not so a clock decides
            # its fate. Retracting one here would leave the flush that follows
            # committing an already-retracted row -- a memory that is both
            # published and gone.
            continue
        durability = props.get("durability", "durable")
        is_incident = _is_incident_labelled(node)
        valid_until = _parse_valid_until(props)

        reason: str | None = None
        if durability == DURABILITY_EXPIRED:
            reason = "durability=expired"
        elif valid_until is not None:
            if now >= valid_until:
                reason = f"valid_until={valid_until.isoformat()} elapsed"
            # else: an explicit future valid_until is authoritative --
            # skip the transient/incident TTL checks below entirely.
        elif (durability == DURABILITY_TRANSIENT or is_incident) and node.updated_at is not None:
            ttl_hours = _ttl_hours(props)
            expiry = node.updated_at + timedelta(hours=ttl_hours)
            if now >= expiry:
                marker = (
                    "durability=transient"
                    if durability == DURABILITY_TRANSIENT
                    else "INCIDENT default"
                )
                reason = f"{marker} ttl={ttl_hours}h elapsed"

        if reason is None:
            continue

        try:
            await graph.update_node(
                scope,
                node.id,
                {
                    "properties": {
                        **props,
                        RETRACTED_KEY: True,
                        RETRACTED_AT_KEY: now.isoformat(),
                        RETRACTION_REASON_KEY: reason[:200],
                    },
                },
            )
            retracted += 1
            logger.info(
                "semantic_expiry: retracted %s (%s)", node.id, reason,
            )
        except Exception:
            logger.debug(
                "Failed to expire semantic node %s", node.id, exc_info=True,
            )
    return retracted