Skip to content

symfonic.capabilities.memory.legacy_port

legacy_port

The legacy retrieval engine, seen as a MemoryRetrievalPort.

The last adapter #14 needs: it is what lets an engine that already has a memory orchestrator hand the migrated path a port, instead of the migrated path requiring an adopter to wire one by hand.

Everything hard about it was decided in the design and built in :mod:.admission. This is the assembly:

  • legacy RetrievalEngine.retrieve answers with MemoryEntry rows;
  • :func:~.admission.admit_legacy_entries resolves each row's provenance and excludes the ones that have none — explicit scope_path, else the record's own tenant_id, else refused;
  • what survives becomes the RetrievalResult the port promises.

The excluded rows are reported in dropped rather than logged and forgotten. A silently shorter recall is indistinguishable from a scope that genuinely remembers less, and that is the failure mode this whole task has been avoiding.

LegacyRetrievalPort

LegacyRetrievalPort(engine: Any, scope_of: Any, embedding_provider: Any = None)

Reads the legacy retrieval engine through the capability's port.

Parameters:

Name Type Description Default
engine Any

the legacy RetrievalEngine.

required
scope_of Any

turns a capability :class:MemoryScope into the TenantScope legacy queries with. Injected rather than imported so this module never depends on the transport that happens to own the translation today.

required
embedding_provider Any

what embeds the cue for the vector half of the search. TA8.73: this was missing, and its absence was silent. RetrievalEngine.retrieve takes it per call and returns zero rows without it, because it cannot embed the cue -- measured, 0 with it absent against 1 with it present, on a store holding the answer. So every hydration on the migrated route lost the vector store, which is where episodic and semantic memories live, and the prompt carried only whatever the graph half produced. None keeps the old behaviour for callers that genuinely have no embedder, and it is a real degradation rather than a default worth defending.

None
Source code in src/symfonic/capabilities/memory/legacy_port.py
def __init__(
    self, engine: Any, scope_of: Any, embedding_provider: Any = None
) -> None:
    """
    Args:
        engine: the legacy ``RetrievalEngine``.
        scope_of: turns a capability :class:`MemoryScope` into the
            ``TenantScope`` legacy queries with. Injected rather than
            imported so this module never depends on the transport that
            happens to own the translation today.
        embedding_provider: what embeds the cue for the vector half of the
            search. TA8.73: this was missing, and its absence was silent.
            ``RetrievalEngine.retrieve`` takes it per call and returns
            **zero** rows without it, because it cannot embed the cue --
            measured, 0 with it absent against 1 with it present, on a
            store holding the answer. So every hydration on the migrated
            route lost the vector store, which is where episodic and
            semantic memories live, and the prompt carried only whatever
            the graph half produced. ``None`` keeps the old behaviour for
            callers that genuinely have no embedder, and it is a real
            degradation rather than a default worth defending.
    """
    self._engine = engine
    self._scope_of = scope_of
    self._embedding_provider = embedding_provider

retrieve async

retrieve(query: MemoryQuery) -> RetrievalResult

The public port: capped to query.limit, and scope-checked.

Two obligations this class owes anyone holding it directly, and neither can be delegated to a downstream component that happens to exist in production:

  • the cap. A query asking for limit memories receives at most limit, and the rows admission accepted beyond it are reported in dropped rather than deleted -- a memory removed with no reason is the failure mode this whole adapter keeps being asked to avoid.
  • visibility. A backend enforces isolation and this verifies it, because the backend is the component that might be wrong. A row from another tenant is a ScopeViolation, not a filtered result: a silent filter would leave the leak in place for the next consumer.

Overscanning is deliberately not here. Fetching wide is a decision about gates that run after this port, so it belongs to :meth:scan_candidates, which the coordinator's source uses.

Source code in src/symfonic/capabilities/memory/legacy_port.py
async def retrieve(self, query: MemoryQuery) -> RetrievalResult:
    """The public port: capped to ``query.limit``, and scope-checked.

    Two obligations this class owes anyone holding it directly, and neither
    can be delegated to a downstream component that happens to exist in
    production:

    * **the cap.** A query asking for ``limit`` memories receives at most
      ``limit``, and the rows admission accepted beyond it are reported in
      ``dropped`` rather than deleted -- a memory removed with no reason is
      the failure mode this whole adapter keeps being asked to avoid.
    * **visibility.** A backend enforces isolation and this verifies it,
      because the backend is the component that might be wrong. A row from
      another tenant is a ``ScopeViolation``, not a filtered result: a
      silent filter would leave the leak in place for the next consumer.

    Overscanning is deliberately *not* here. Fetching wide is a decision
    about gates that run after this port, so it belongs to
    :meth:`scan_candidates`, which the coordinator's source uses.
    """
    admitted, excluded = await self._scan(query, wanted=query.limit)
    self._require_visible(admitted, query.scope)
    served = admitted[: query.limit]
    for record, _entry in admitted[query.limit :]:
        excluded.append(
            (
                record.record_id,
                f"beyond the {query.limit}-memory limit this query asked for",
            )
        )
    return _result(served, excluded)

scan_candidates async

scan_candidates(query: MemoryQuery) -> RetrievalResult

The internal seam: everything admissible within the scan budget.

Uncapped by query.limit on purpose, and separate from :meth:retrieve rather than a mode of it. The gates that decide what survives -- scope, salience, relevance, the character caps -- all run after this call, so a cap here makes one rejection a lost slot. Three successive attempts to express that as a widened limit on the public port failed the same way, because a cap is a cap whatever number it holds.

Visibility is checked here too: the coordinator re-checks scope, but a foreign row should not travel that far, and the check is cheap next to the round trip that produced the row.

Source code in src/symfonic/capabilities/memory/legacy_port.py
async def scan_candidates(self, query: MemoryQuery) -> RetrievalResult:
    """The internal seam: everything admissible within the scan budget.

    Uncapped by ``query.limit`` on purpose, and separate from
    :meth:`retrieve` rather than a mode of it. The gates that decide what
    survives -- scope, salience, relevance, the character caps -- all run
    after this call, so a cap here makes one rejection a lost slot. Three
    successive attempts to express that as a widened limit on the public
    port failed the same way, because a cap is a cap whatever number it
    holds.

    Visibility is checked here too: the coordinator re-checks scope, but a
    foreign row should not travel that far, and the check is cheap next to
    the round trip that produced the row.
    """
    admitted, excluded = await self._scan(query, wanted=SCAN_BUDGET)
    self._require_visible(admitted, query.scope)
    return _result(admitted, excluded)

first_page

first_page(limit: int) -> int

The size of the first read, before any paging.

Source code in src/symfonic/capabilities/memory/legacy_port.py
def first_page(limit: int) -> int:
    """The size of the first read, before any paging."""
    if limit <= 0:
        return SCAN_BUDGET
    return min(SCAN_BUDGET, max(PAGE_MINIMUM, limit * PAGE_FACTOR))