Skip to content

symfonic.capabilities.memory.admission

admission

Which legacy entries the migrated adapter may serve, and on whose authority.

Split from :mod:.compat when that module passed its budget, and the seam is the honest one: compat translates shapes between legacy and this capability, in both directions and without judgement. This decides admission — whether a stored row can be attributed to a scope at all, and what happens to the ones that cannot. Translation that always succeeds and a gate that sometimes refuses are different jobs.

admit_legacy_entries

admit_legacy_entries(entries: Iterable[Any]) -> tuple[tuple[MemoryRecord, ...], tuple[tuple[str, str], ...]]

Convert what legacy retrieval returned, excluding what cannot be attributed.

Returns the admitted records in the order retrieval chose and the entries that were dropped, each with the reason. Order is preserved because ranking belongs to the retrieval engine; re-ordering here would silently re-rank the prompt, and both orders render as plausible text.

Excludes rather than raises. One unattributable row must not end the turn: that would turn a data-quality problem into an outage and make the migrated path less available than the legacy one it replaces. The drop is returned rather than logged and forgotten, because a silently shorter recall is indistinguishable from a model that simply remembered less.

Source code in src/symfonic/capabilities/memory/admission.py
def admit_legacy_entries(
    entries: Iterable[Any],
) -> tuple[tuple[MemoryRecord, ...], tuple[tuple[str, str], ...]]:
    """Convert what legacy retrieval returned, excluding what cannot be attributed.

    Returns the admitted records **in the order retrieval chose** and the
    entries that were dropped, each with the reason. Order is preserved because
    ranking belongs to the retrieval engine; re-ordering here would silently
    re-rank the prompt, and both orders render as plausible text.

    Excludes rather than raises. One unattributable row must not end the turn:
    that would turn a data-quality problem into an outage and make the migrated
    path less available than the legacy one it replaces. The drop is returned
    rather than logged and forgotten, because a silently shorter recall is
    indistinguishable from a model that simply remembered less.
    """
    pairs, excluded = admit_legacy_pairs(entries)
    return tuple(record for record, _entry in pairs), excluded

admit_legacy_pairs

admit_legacy_pairs(entries: Iterable[Any]) -> tuple[tuple[tuple[MemoryRecord, Any], ...], tuple[tuple[str, str], ...]]

Like :func:admit_legacy_entries, keeping each record beside its entry.

The record deliberately does not carry a relevance score — it is what a memory is, not how it rated for one query — so a caller that needs legacy's ranking needs the entry too. Returning the pair is what stops that caller from reaching for the nearest number on the record instead: review found the port scoring on salience, which is derived from legacy importance, so the most important memory outranked the most relevant one and the recall block came out inverted.

Source code in src/symfonic/capabilities/memory/admission.py
def admit_legacy_pairs(
    entries: Iterable[Any],
) -> tuple[tuple[tuple[MemoryRecord, Any], ...], tuple[tuple[str, str], ...]]:
    """Like :func:`admit_legacy_entries`, keeping each record beside its entry.

    The record deliberately does not carry a relevance score — it is what a
    memory *is*, not how it rated for one query — so a caller that needs
    legacy's ranking needs the entry too. Returning the pair is what stops that
    caller from reaching for the nearest number on the record instead: review
    found the port scoring on ``salience``, which is derived from legacy
    ``importance``, so the most *important* memory outranked the most
    *relevant* one and the recall block came out inverted.
    """
    admitted: list[tuple[MemoryRecord, Any]] = []
    excluded: list[tuple[str, str]] = []
    for entry in entries:
        try:
            admitted.append((record_from_legacy_entry(entry), entry))
        except (MemoryContractError, ValueError, TypeError) as exc:
            entry_id = str(getattr(entry, "id", "") or "<unidentified>")
            excluded.append((entry_id, f"entry {entry_id!r} has no usable provenance: {exc}"))
    return tuple(admitted), tuple(excluded)

record_from_legacy_entry

record_from_legacy_entry(entry: Any) -> MemoryRecord

Read a legacy MemoryEntry into a record, resolving its provenance.

The mirror of :func:legacy_entry_payload, and the adapter's single decision about where a record's isolation key comes from. Three branches, settled on 2026-08-17 (#14):

  1. an explicit metadata['scope_path'] -> used, read through the legacy wire form;
  2. absent or blank, with a valid tenant_id -> the tenant root, exactly as legacy's own dual-read resolves it;
  3. neither -> :class:MemoryContractError.

Branch 2 is the one that needed deciding, and the distinction it rests on is narrow but load-bearing. What the #16 design rejected was deriving a record's path from the query's scope: that makes the bridge's revalidation pass by construction, so an isolation test written over it would be green and prove nothing. This derives from the record's own stored tenant_id -- per-record, deterministic, and narrower than a deep path, since a tenant root is visible only within its own tenant. It is also what legacy already does, which is what keeps the migrated path at parity instead of quietly recalling less.

Source code in src/symfonic/capabilities/memory/admission.py
def record_from_legacy_entry(entry: Any) -> MemoryRecord:
    """Read a legacy ``MemoryEntry`` into a record, resolving its provenance.

    The mirror of :func:`legacy_entry_payload`, and the adapter's single
    decision about where a record's isolation key comes from. Three branches,
    settled on 2026-08-17 (#14):

    1. an explicit ``metadata['scope_path']`` -> used, read through the legacy
       wire form;
    2. absent or blank, with a valid ``tenant_id`` -> the tenant root, exactly
       as legacy's own dual-read resolves it;
    3. neither -> :class:`MemoryContractError`.

    Branch 2 is the one that needed deciding, and the distinction it rests on
    is narrow but load-bearing. What the #16 design rejected was deriving a
    record's path from *the query's* scope: that makes the bridge's
    revalidation pass by construction, so an isolation test written over it
    would be green and prove nothing. This derives from the record's own stored
    ``tenant_id`` -- per-record, deterministic, and *narrower* than a deep path,
    since a tenant root is visible only within its own tenant. It is also what
    legacy already does, which is what keeps the migrated path at parity
    instead of quietly recalling less.
    """
    metadata = dict(getattr(entry, "metadata", None) or {})
    stored_path = metadata.get(SCOPE_PATH_KEY)
    if isinstance(stored_path, str) and stored_path:
        scope = scope_from_legacy_path(stored_path)
    else:
        # ``MemoryScope`` refuses a blank or malformed tenant, which is branch
        # 3: an entry with neither an explicit path nor an attributable tenant
        # has no provenance to serve it under.
        scope = MemoryScope(str(getattr(entry, "tenant_id", "") or ""))
    layer = getattr(entry, "layer", MemoryLayer.SEMANTIC)
    return MemoryRecord(
        record_id=str(metadata.get(RECORD_ID_KEY) or getattr(entry, "id", "") or ""),
        layer=resolve_layer(str(getattr(layer, "value", layer))),
        text=str(getattr(entry, "content", "")),
        scope_path=scope.path,
        salience=importance_to_salience(getattr(entry, "importance", 5.0)),
        origin=str(metadata.get(ORIGIN_KEY, "")),
    )