Skip to content

symfonic.core.learning.phases_semantic_expiry

phases_semantic_expiry

Phase 9.2: durability/validity-driven SEMANTIC expiry (J1 + J2).

Closes the gap documented in the prompt-part-blocks architecture note (an adopter's stale-memory incident): SEMANTIC nodes have no expiry path at all. cleanup_working_ttl (phases_maintenance.py) only looks at MemoryLayer.WORKING; decay_importance lowers importance but never removes a node; prune_retracted needs a retraction marker that nothing stamps for a semantic fact that simply stopped being true.

This phase gives the SEMANTIC layer that missing expiry path, entirely time-based -- no model is ever asked whether a memory is stale:

  • J1 -- honour durability. A node marked durability="expired" is retracted unconditionally, mirroring cleanup_working_ttl's unconditional expired branch (:96-107). A node marked durability="transient" is retracted once ttl_hours (explicit, or :data:TRANSIENT_TTL_HOURS_DEFAULT) has elapsed since updated_at -- the SEMANTIC-layer analogue of the WORKING-layer transient TTL floor, except the terminal action here is soft-retract, never delete.
  • J2 -- valid_until + INCIDENT:. Any node carrying a valid_until ISO timestamp is retracted once that instant passes, independent of its durability value -- this is the generic validity axis the design doc calls for. Nodes labelled INCIDENT: / INCIDENT are treated as at-least-transient even when durability is absent or explicitly "durable": this is deliberately the first prefix convention that makes a node age out FASTER rather than slower (the opposite of SOUL: / AGENT_IDENTITY:), so writing an incident as a plain fact can never accidentally pin it forever. A future valid_until on an INCIDENT node extends its life past the default TTL; there is no way to make an INCIDENT node permanently exempt -- that is what the SOUL: / AGENT_IDENTITY: vocabulary is for.

Design decision -- placement (sibling phase, not inside decay_importance): decay_importance's documented contract is "lower importance"; this phase's terminal action is retraction, a different and stronger effect. Keeping them separate also keeps each file under the ~300-line budget and lets each phase fail independently in SleepConsolidator.run without one phase's exception aborting the other.

Design decision -- the identity exemption applies, the importance exemption does not: This phase reuses :func:phases_maintenance.is_identity_labelled (SOUL: / AGENT_IDENTITY:) as an unconditional skip -- an identity fact must never age out, full stop, regardless of what a caller writes into durability or valid_until on it. It deliberately does NOT reuse CRITICAL_IMPORTANCE_THRESHOLD (importance >= 8.0): that exemption protects high-signal knowledge from decay, but a high-importance INCIDENT fact ("production is down") is exactly the case J2 exists to expire (§2.5a). Applying the importance exemption here would silently defeat the whole point of the INCIDENT: prefix.

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 src/symfonic/core/learning/phases_semantic_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 {})
        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