Skip to content

symfonic.capabilities.memory.phases.evidence

evidence

What a cycle reads before it changes anything.

Three readers, because the nightly roster asks three different questions of the store and legacy answered each by passing a list down from run():

  • :class:RecentSemanticNodes (in :mod:.window) -- the lookback window phases 1 and 5 work over.
  • :class:AllNodes -- every node in the scope, which the structural and maintenance phases need because a node they must prune is by definition one nothing recently touched.
  • :class:EpisodicEvidence -- the turns a scope has had, for the two phases that learn from them.

The third is the one worth reading about. Legacy hands those phases an EpisodicLayer, which reads the vector store and labels every row it finds EPISODIC regardless of what layer the row actually belongs to. Pointed at a capability-composed deployment that would be wrong twice over: a kernel-native turn writes its episodic rows through the memory ports, and the same vector index also holds that scope's semantic and working rows -- so phase 12 would have been shown a scope's facts as evidence of the things it repeatedly does.

This reads through the capability's own retrieval port, asks for one layer, and converts. Same phase, correct evidence.

AllNodes

AllNodes(graph: Any)

Every node in a scope, read once per cycle.

The counterpart to :class:~.window.RecentSemanticNodes, and deliberately not the same object: pruning, decay and TTL are about what a scope stopped touching, so a phase handed only the recent window would be handed exactly the rows it must not act on.

Source code in src/symfonic/capabilities/memory/phases/evidence.py
def __init__(self, graph: Any) -> None:
    self._graph = graph
    self._key: tuple[str, datetime] | None = None
    self._nodes: list[Any] = []

EpisodicEvidence

EpisodicEvidence(store: Any)

A scope's episodic turns, as the entries phases 11 and 12 read.

Reads through :class:~..ports.MemoryRetrievalPort's candidate scan rather than retrieve: a scan is uncapped by limit and by the character ceilings, and both of those are decisions about what reaches a prompt. Applying them here would let a cycle's evidence depend on how much room a turn had.

Source code in src/symfonic/capabilities/memory/phases/evidence.py
def __init__(self, store: Any) -> None:
    self._store = store
    self._key: tuple[str, datetime] | None = None
    self._entries: list[MemoryEntry] = []

list_events async

list_events(scope: Any, limit: int = EPISODIC_HORIZON) -> list[MemoryEntry]

The signature legacy's EpisodicLayer offers, over the port.

scope is the legacy value the phase was given; the capability scope is the one this reader was built for, and they name the same place.

Source code in src/symfonic/capabilities/memory/phases/evidence.py
async def list_events(self, scope: Any, limit: int = EPISODIC_HORIZON) -> list[MemoryEntry]:
    """The signature legacy's ``EpisodicLayer`` offers, over the port.

    ``scope`` is the legacy value the phase was given; the capability scope
    is the one this reader was built for, and they name the same place.
    """
    return list(self._entries[:limit])

load async

load(context: Any) -> None

Read this cycle's evidence, once.

Source code in src/symfonic/capabilities/memory/phases/evidence.py
async def load(self, context: Any) -> None:
    """Read this cycle's evidence, once."""
    key = (context.scope.path, context.started_at)
    if self._key == key:
        return
    result = await self._store.scan_candidates(
        MemoryQuery(
            scope=context.scope,
            cue="",
            layers=frozenset({MemoryLayer.EPISODIC}),
            limit=EPISODIC_HORIZON,
        )
    )
    self._key = key
    self._entries = [_entry(memory.record) for memory in result.memories]