Skip to content

symfonic.capabilities.memory.ports

ports

The ports the memory system speaks, and nothing else (CON-P, CON-S).

Three of them are what a store owes: retrieval, write, lifecycle. The fourth, :class:MemoryExtractorPort, runs the other way -- it is what the capability calls when a deployment asks it to turn a finished turn into durable memory. It is optional for the same reason the other three are not: a deployment that does not extract still recalls, records and erases, while one missing a store port has no memory at all.

Three, not one. A retrieval-and-write-and-lifecycle interface would force a read-only replica adapter to implement writing, and a consolidation service that only needs to flush to depend on the retrieval surface it never calls (interface segregation, CON-P-1). The split is also what makes the kernel-side story true: the prompt/input stage holds a :class:MemoryRetrievalPort, the post-response stage a :class:MemoryWritePort, and neither can reach the other's capability.

All three are Protocols, so an adopter's HMS owes nothing to our inheritance tree, and all three are async, because every real backend is (CON-P-4). The synchronous half of the world — the prompt compiler's source protocol — is crossed once, in :mod:.contribution, by reading an already materialised result rather than by making the compiler a coroutine.

Failure vocabulary. An adapter that cannot reach its store raises :class:~.errors.MemoryUnavailable; one asked something impossible raises :class:~.errors.MemoryContractError; one that would cross a scope boundary raises :class:~.errors.ScopeViolation. Per-item refusals are not exceptions — they belong in the receipt, because a write of five memories where one hit a ceiling is not a failed write.

Write then flush. write records intent; flush makes it retrievable. Splitting them is what gives the lifecycle port a job: a post-response write that became visible immediately would surface a half-formed extraction to the next turn, and a cancelled invocation could not un-say it.

CandidateScan

Bases: Protocol

Reads candidates: uncapped by the query's limit, bounded by a budget.

A second seam beside :class:MemoryRetrievalPort, and the separation is the design rather than an inconvenience:

  • the port promises a limit and keeps it. A consumer asking for five memories receives at most five, whoever they are.
  • the scan exists because the gates that decide what survives -- scope, salience, relevance, the character caps -- all run after retrieval. A limit applied before them turns one rejection into a lost slot, so a scan is bounded by the store's own budget and the caller caps afterwards.

Required, not optional. An earlier version treated it as a courtesy and fell back to a widened public retrieve when a store did not offer it, which put the cap back before the gates for every store but one -- the same defect surviving in the escape hatch, invisible because the fallback returned plausible results. A store that cannot scan is a wiring error, raised where a human is reading it.

scan_candidates async

scan_candidates(query: MemoryQuery) -> RetrievalResult

Everything admissible for query, within the store's own budget.

query.limit is not a cap here. It may inform how eagerly the implementation reads; the caller applies the real limit once its gates have run.

Source code in src/symfonic/capabilities/memory/ports.py
async def scan_candidates(self, query: MemoryQuery) -> RetrievalResult:
    """Everything admissible for ``query``, within the store's own budget.

    ``query.limit`` is not a cap here. It may inform how eagerly the
    implementation reads; the caller applies the real limit once its gates
    have run.
    """
    ...

MemoryExtractorPort

Bases: Protocol

Turns one finished exchange into memories worth keeping.

The capability calls this after the final model round and writes whatever comes back. :class:~.extraction.MemoryExtractionService is the shipped implementation; a deployment with its own extraction policy implements this instead.

A failed extraction is not a failed turn. An implementation that cannot reach its model returns a result with degraded=True and a reason rather than raising: the exchange already happened and the user already has an answer, and losing it because a side effect failed is the worse outcome. Raising is reserved for being asked something impossible.

extract async

extract(request: ExtractionRequest) -> ExtractionResult

Read request's exchange and mint the memories it justifies.

Source code in src/symfonic/capabilities/memory/ports.py
async def extract(self, request: ExtractionRequest) -> ExtractionResult:
    """Read ``request``'s exchange and mint the memories it justifies."""
    ...

MemoryLifecyclePort

Bases: Protocol

Commits and erases. The finalize seam, and the privacy seam.

flush async

flush(scope: MemoryScope) -> LifecycleReceipt

Commit every pending memory in scope and its descendants.

Source code in src/symfonic/capabilities/memory/ports.py
async def flush(self, scope: MemoryScope) -> LifecycleReceipt:
    """Commit every pending memory in ``scope`` and its descendants."""
    ...

forget async

forget(scope: MemoryScope) -> LifecycleReceipt

Erase every memory in scope and its descendants, pending or not.

Scoped by construction, so a deletion request can never reach outside the subtree it named (SEC-PRIV). Idempotent: forgetting an already-empty scope reports an empty receipt rather than failing.

Source code in src/symfonic/capabilities/memory/ports.py
async def forget(self, scope: MemoryScope) -> LifecycleReceipt:
    """Erase every memory in ``scope`` and its descendants, pending or not.

    Scoped by construction, so a deletion request can never reach outside
    the subtree it named (SEC-PRIV). Idempotent: forgetting an already-empty
    scope reports an empty receipt rather than failing.
    """
    ...

MemoryRetrievalPort

Bases: Protocol

Reads memories visible from one scope. The prompt/input seam.

retrieve async

retrieve(query: MemoryQuery) -> RetrievalResult

Return the memories visible at query.scope, ranked and capped.

Visibility is the adapter's obligation, not a courtesy: a memory whose scope does not cover query.scope must not appear in the result. The bridge re-checks it (:class:~.errors.ScopeViolation) because the adapter is exactly the component that might be wrong.

Raises :class:~.errors.MemoryUnavailable when the store is unreachable. Returning an empty result instead would be indistinguishable from a scope that genuinely remembers nothing.

Source code in src/symfonic/capabilities/memory/ports.py
async def retrieve(self, query: MemoryQuery) -> RetrievalResult:
    """Return the memories visible at ``query.scope``, ranked and capped.

    Visibility is the adapter's obligation, not a courtesy: a memory whose
    scope does not cover ``query.scope`` must not appear in the result. The
    bridge re-checks it (:class:`~.errors.ScopeViolation`) because the
    adapter is exactly the component that might be wrong.

    Raises :class:`~.errors.MemoryUnavailable` when the store is unreachable.
    Returning an empty result instead would be indistinguishable from a
    scope that genuinely remembers nothing.
    """
    ...

MemoryWritePort

Bases: Protocol

Records what a turn produced. The post-response seam.

write async

write(request: WriteRequest) -> WriteReceipt

Record request's memories as pending, and report per memory.

Idempotent on record_id within a scope: writing the same id twice upserts rather than duplicating, so a retried post-response stage does not double a memory.

Pending memories are not retrievable until :meth:MemoryLifecyclePort.flush.

Source code in src/symfonic/capabilities/memory/ports.py
async def write(self, request: WriteRequest) -> WriteReceipt:
    """Record ``request``'s memories as pending, and report per memory.

    Idempotent on ``record_id`` within a scope: writing the same id twice
    upserts rather than duplicating, so a retried post-response stage does
    not double a memory.

    Pending memories are not retrievable until :meth:`MemoryLifecyclePort.flush`.
    """
    ...

validate_extractor

validate_extractor(extractor: Any) -> None

Refuse an extractor that cannot serve the port, at composition.

Raises:

Type Description
ConfigurationError

naming what is wrong and what to pass instead.

Asked of the declared signature and never by calling it: an extractor that raises from inside its own body must reach the caller as a failure, not be quietly reclassified as the wrong shape.

Source code in src/symfonic/capabilities/memory/ports.py
def validate_extractor(extractor: Any) -> None:
    """Refuse an extractor that cannot serve the port, at composition.

    Raises:
        ConfigurationError: naming what is wrong and what to pass instead.

    Asked of the declared signature and never by calling it: an extractor
    that raises from inside its own body must reach the caller as a failure,
    not be quietly reclassified as the wrong shape.
    """
    if extractor is None:
        return
    extract = getattr(extractor, "extract", None)
    named = type(extractor).__name__
    if not callable(extract):
        raise ConfigurationError(
            f"{named} has no callable extract(), so it cannot be composed as "
            f"the memory extractor. {_EXTRACTOR_HINT}"
        )
    if not inspect.iscoroutinefunction(extract):
        raise ConfigurationError(
            f"{named}.extract() is synchronous; the memory extractor is "
            f"awaited after the final model round. {_EXTRACTOR_HINT}"
        )
    try:
        parameters = list(inspect.signature(extract).parameters.values())
    except (TypeError, ValueError):  # pragma: no cover - exotic callables
        return
    required = [
        parameter
        for parameter in parameters
        if parameter.default is inspect.Parameter.empty
        and parameter.kind
        in (
            inspect.Parameter.POSITIONAL_ONLY,
            inspect.Parameter.POSITIONAL_OR_KEYWORD,
        )
    ]
    if len(required) != 1:
        raise ConfigurationError(
            f"{named}.extract() takes {len(required)} required arguments; the "
            f"memory extractor is called with one ExtractionRequest. "
            f"{_EXTRACTOR_HINT}"
        )