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
¶
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
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
¶
MemoryLifecyclePort ¶
Bases: Protocol
Commits and erases. The finalize seam, and the privacy seam.
flush
async
¶
forget
async
¶
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
MemoryRetrievalPort ¶
Bases: Protocol
Reads memories visible from one scope. The prompt/input seam.
retrieve
async
¶
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
MemoryWritePort ¶
Bases: Protocol
Records what a turn produced. The post-response seam.
write
async
¶
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
validate_extractor ¶
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.