Skip to content

symfonic.core.prompt.blocks.memory_lane

memory_lane

The memory lane: a label-prefix scan turned into a block revision.

One half of :mod:~symfonic.core.prompt.blocks.resolver, kept separate because it answers a different question. The resolver decides which blocks to read and what to do when a read fails; this module decides what a set of graph nodes is once they have been read -- which of them carry a statement, in what order, with whose provenance, and under what revision.

Why a scan and not a retrieval

:meth:MemoryLaneScanner.query_nodes is a filter, not a ranker. It takes no query text, computes no similarity, and -- called with limit=None -- truncates nothing. The Protocol is deliberately narrower than the graph store it is satisfied by: a caller holding only this type cannot reach retrieve() even by mistake, because the method that would rank is not on the object it was handed.

Facts, not a paragraph

Each matching node becomes one :class:~symfonic.core.prompt.blocks.types.BlockFact carrying that node's own source and recorded_at. Flattening the scan into one string would force the renderer to either omit provenance or invent a single timestamp for statements recorded years apart, and an invented timestamp on a user-facing profile is worse than an absent one. Provenance a node did not record stays None.

EMPTY_MEMORY_REVISION module-attribute

EMPTY_MEMORY_REVISION = 'mem:0:empty'

Revision of a memory-backed block whose scan matched no node.

A concrete, comparable value rather than "": an empty lane is a real state that must be cacheable, and :class:BlockRevision rejects a blank revision precisely so "nothing was found" cannot be confused with "nothing was recorded".

MemoryLaneScanner

Bases: Protocol

The one memory operation the pinned lane is allowed to perform.

Satisfied structurally by the graph store, whose query_nodes accepts these arguments among others. Nothing here can rank, score or embed.

query_nodes async

query_nodes(
    scope: TenantScope,
    *,
    label_prefix: str | None = None,
    limit: int | None = None,
) -> Sequence[Any]

Return every node in scope whose label starts with the prefix.

Source code in src/symfonic/core/prompt/blocks/memory_lane.py
async def query_nodes(
    self,
    scope: TenantScope,
    *,
    label_prefix: str | None = None,
    limit: int | None = None,
) -> Sequence[Any]:
    """Return every node in ``scope`` whose label starts with the prefix."""
    ...

memory_revision

memory_revision(
    nodes: Sequence[Any], facts: Sequence[BlockFact]
) -> str

A durable revision for the lane: mem:<count>:<max updated_at>:<digest>.

Durable because every component comes from the store, not from this process: a write by the onboarding router or the consolidation worker advances max(updated_at) and the next scan here sees it. An in-process counter would not, and this agent would go on serving a block the user already corrected elsewhere.

max(updated_at) is computed over parsed instants, not over ISO text: string order only agrees with instant order when every node shares one UTC offset, and a naive stamp is always a string-prefix of (hence lexicographically less than) an aware one at the same wall clock. Comparing text let a corrected fact keep the revision of the fact it replaced whenever the writer's offset happened to sort "smaller".

The count is in the key because a deletion moves neither the remaining nodes' stamps nor the maximum, and a digest of the ordered fact values is in the key because an edit that preserves both count and max(updated_at) -- a migration pass that re-labels a node under its original timestamp -- is otherwise invisible to this revision.

Source code in src/symfonic/core/prompt/blocks/memory_lane.py
def memory_revision(nodes: Sequence[Any], facts: Sequence[BlockFact]) -> str:
    """A durable revision for the lane: ``mem:<count>:<max updated_at>:<digest>``.

    Durable because every component comes from the store, not from this
    process: a write by the onboarding router or the consolidation
    worker advances ``max(updated_at)`` and the next scan here sees it.
    An in-process counter would not, and this agent would go on serving
    a block the user already corrected elsewhere.

    ``max(updated_at)`` is computed over parsed *instants*, not over ISO
    text: string order only agrees with instant order when every node
    shares one UTC offset, and a naive stamp is always a string-prefix of
    (hence lexicographically less than) an aware one at the same wall
    clock. Comparing text let a corrected fact keep the revision of the
    fact it replaced whenever the writer's offset happened to sort
    "smaller".

    The count is in the key because a *deletion* moves neither the
    remaining nodes' stamps nor the maximum, and a digest of the ordered
    fact values is in the key because an edit that preserves both count
    and max(updated_at) -- a migration pass that re-labels a node under
    its original timestamp -- is otherwise invisible to this revision.
    """
    if not nodes:
        return EMPTY_MEMORY_REVISION
    newest = _newest_instant(getattr(node, "updated_at", None) for node in nodes)
    digest = hashlib.sha256(
        "\n".join(fact.value for fact in facts).encode()
    ).hexdigest()[:16]
    return f"mem:{len(nodes)}:{newest or _UNSTAMPED}:{digest}"

scan_memory_lane async

scan_memory_lane(
    memory: MemoryLaneScanner,
    scope: TenantScope,
    label_prefix: str,
    profile_fields: frozenset[str] = frozenset(),
) -> BlockRevision

Scan one block's label space and assemble its revision.

limit=None is passed explicitly rather than left to default: the store's own page size would truncate the scan, which is the top-K failure this lane exists to avoid, one layer down.

The returned revision always carries a facts tuple -- empty when nothing is recorded yet. A learned block with facts=None means "the source violated its contract", and an empty lane must not masquerade as one.

profile_fields names the node properties that render as facts in their own right, alongside the label. Without it this lane reads labels only, and a correction that :func:~symfonic.core.learning.phases_profile.promote_profile_corrections wrote to a property -- which is where it writes all of them -- could never reach the prompt: the two halves wrote and read different parts of the same node. The set is declared, never inferred from what a node happens to carry, so a property an extractor invented (or an attacker talked one into writing) is not a way to add a line to the model's standing context.

Source code in src/symfonic/core/prompt/blocks/memory_lane.py
async def scan_memory_lane(
    memory: MemoryLaneScanner,
    scope: TenantScope,
    label_prefix: str,
    profile_fields: frozenset[str] = frozenset(),
) -> BlockRevision:
    """Scan one block's label space and assemble its revision.

    ``limit=None`` is passed explicitly rather than left to default:
    the store's own page size would truncate the scan, which is the
    top-K failure this lane exists to avoid, one layer down.

    The returned revision always carries a ``facts`` tuple -- empty when
    nothing is recorded yet. A learned block with ``facts=None`` means
    "the source violated its contract", and an empty lane must not
    masquerade as one.

    ``profile_fields`` names the node *properties* that render as facts
    in their own right, alongside the label. Without it this lane reads
    labels only, and a correction that
    :func:`~symfonic.core.learning.phases_profile.promote_profile_corrections`
    wrote to a property -- which is where it writes all of them -- could
    never reach the prompt: the two halves wrote and read different
    parts of the same node. The set is declared, never inferred from
    what a node happens to carry, so a property an extractor invented
    (or an attacker talked one into writing) is not a way to add a line
    to the model's standing context.
    """
    nodes = list(await memory.query_nodes(scope, label_prefix=label_prefix, limit=None))
    nodes.sort(key=_scan_order)
    facts = tuple(
        fact
        for node in nodes
        for fact in _node_to_facts(node, label_prefix, profile_fields)
    )
    return BlockRevision(
        content="\n".join(fact.value for fact in facts),
        revision=memory_revision(nodes, facts),
        facts=facts,
    )