Skip to content

symfonic.capabilities.memory.records

records

What a memory is, and what the write and lifecycle ports say back.

A record carries its own scope path rather than inheriting one from the call that stored it. That looks redundant next to :class:WriteRequest.scope — the request validates that they agree — but it is what lets a retrieval answer "where did this come from?" without a side table, and what lets the bridge check a returned memory against the query's scope at the port boundary.

Receipts report per item. A write of five memories where one exceeded capacity is not a failure and not a success; it is three-plus-one-plus-one, and a boolean would force the caller to guess which.

LifecycleReceipt dataclass

LifecycleReceipt(scope_path: str, committed: tuple[str, ...] = (), discarded: tuple[str, ...] = (), degraded: bool = False)

What a flush or a forget did, and to which scope.

MemoryRecord dataclass

MemoryRecord(record_id: str, layer: MemoryLayer, text: str, scope_path: str, salience: float = 0.5, origin: str = '', revision: str = '', metadata: Mapping[str, Any] = dict(), edited_by: str = '')

One memory, as every port in this capability moves it.

scope property

scope: MemoryScope

The scope this memory was written at.

validate

validate() -> None

Refuse a record no store should be asked to hold.

Source code in src/symfonic/capabilities/memory/records.py
def validate(self) -> None:
    """Refuse a record no store should be asked to hold."""
    if not self.record_id:
        raise MemoryContractError("a memory record must declare a non-empty record_id.")
    if not RECORD_ID_CHARSET.match(self.record_id):
        raise MemoryContractError(
            f"record id {self.record_id!r} is outside the permitted charset "
            "[A-Za-z0-9_.:-]; ids appear in receipts and drop reasons, which are rendered."
        )
    if not self.text.strip():
        raise MemoryContractError(
            f"memory {self.record_id!r} carries no text. An empty memory costs a retrieval "
            "slot and renders as nothing, which is indistinguishable from a lost one."
        )
    if not 0.0 <= self.salience <= 1.0:
        raise MemoryContractError(
            f"memory {self.record_id!r} declares salience {self.salience}; salience is a "
            "weight in [0, 1], and a value outside it silently dominates every ranking."
        )
    # Raises MemoryContractError on a malformed path — the record's scope is
    # the isolation key, so an unparseable one is refused at construction.
    scope_from_path(self.scope_path)
    validate_edit_authority(self.edited_by)

WriteReceipt dataclass

WriteReceipt(accepted: tuple[str, ...] = (), rejected: tuple[tuple[str, str], ...] = (), degraded: bool = False)

What the write port did, per memory.

ok property

ok: bool

Whether every memory in the request was stored.

WriteRequest dataclass

WriteRequest(scope: MemoryScope, records: tuple[MemoryRecord, ...] = (), turn: int = 0)

One post-response write: a scope, the memories it produced, the turn.

validate

validate() -> None

Refuse a request no adapter should have to interpret.

Both checks are about identity, which is why they raise rather than landing in the receipt's rejected list: a record filed under another scope and two records sharing an id are ambiguities, and an adapter that resolved either one silently would resolve it differently from the next adapter.

Source code in src/symfonic/capabilities/memory/records.py
def validate(self) -> None:
    """Refuse a request no adapter should have to interpret.

    Both checks are about *identity*, which is why they raise rather than
    landing in the receipt's ``rejected`` list: a record filed under another
    scope and two records sharing an id are ambiguities, and an adapter that
    resolved either one silently would resolve it differently from the next
    adapter.
    """
    seen: set[str] = set()
    for record in self.records:
        record.validate()
        if record.scope_path != self.scope.path:
            raise MemoryContractError(
                f"memory {record.record_id!r} declares scope {record.scope_path!r} in a "
                f"write to {self.scope.path!r}. A write states one scope; a record filed "
                "under another is a cross-scope write wearing a single-scope call."
            )
        if record.record_id in seen:
            raise MemoryContractError(
                f"memory {record.record_id!r} appears twice in one write. Ids are the "
                "upsert key, so the request does not say which of the two survives."
            )
        seen.add(record.record_id)

retrievable_text

retrievable_text(label: str, metadata: Mapping[str, Any]) -> str

Keep producer facts in the text the prompt can actually retrieve.

Extractors commonly return a compact symbolic label plus structured facts, for example person:Amiel and occupation=software engineer. Memory metadata survives for administration, but prompt recall renders only :attr:MemoryRecord.text. Append values not already represented by the label so old and new rows do not silently lose half the fact.

Source code in src/symfonic/capabilities/memory/records.py
def retrievable_text(label: str, metadata: Mapping[str, Any]) -> str:
    """Keep producer facts in the text the prompt can actually retrieve.

    Extractors commonly return a compact symbolic label plus structured facts,
    for example ``person:Amiel`` and ``occupation=software engineer``.  Memory
    metadata survives for administration, but prompt recall renders only
    :attr:`MemoryRecord.text`.  Append values not already represented by the
    label so old and new rows do not silently lose half the fact.
    """
    symbolic = bool(label) and all(char.isalnum() or char in "_.:-" for char in label)
    if not symbolic:
        return label
    missing = [
        f"{key}: {value}"
        for key, value in metadata.items()
        if str(value).casefold() not in label.casefold()
    ]
    return f"{label}{', '.join(missing)}" if missing else label