Skip to content

symfonic.capabilities.memory.phases.quick

quick

The QUICK cadence: four phases, and the factory that returns all four.

PHASE_ROSTER[QUICK] has named strengthen, soul_corrections, episodic_summary and semantic_merge since T3.3.4, and until now nothing implemented any of them. :func:quick_phases is what makes the roster composable: it returns the complete set or raises, so a runtime built from it can never be the runtime that reported a clean cycle having run nothing.

All four, always. A phase whose dependency the deployment did not supply is still constructed, and declines through applies. That is the distinction the runtime now draws: a roster name with no implementation is a composition error, while a phase with nothing to work on is skipped — legacy's if self._episodic is not None: written where the runtime can see it, so "this deployment has no episodic layer" stops reading as "consolidation ran and found nothing to summarise".

A backend is enough. graph takes either the GraphMemoryStore the phases read through or the GraphBackend underneath it, and wraps the second in the first. A deployment composing memory has a backend -- that is what GraphBackedHms is built over -- and requiring the store would have meant every composition root importing symfonic.memory.graph.store, which publishes no __all__ and is exactly the reach past the public surface a generated project's own test refuses. Pass the store when you have one with auto-embed configured; pass the backend otherwise.

One window, two readers. Phases 1 and 5 both work over the recently-updated semantic nodes, and legacy queried them once and passed the list to both. :class:RecentSemanticNodes keeps that: one query per cycle, memoised on the cycle rather than on the phase, so two scopes consolidating at once never read each other's window.

EpisodicSummaryPhase

EpisodicSummaryPhase(episodic: Any | None, *, llm_summarise: Any | None = None, max_entries: int = 100, summarize_batch: int = 50)

Phase 10. The episodic log is summarised so it does not grow without end.

Source code in src/symfonic/capabilities/memory/phases/quick.py
def __init__(
    self,
    episodic: Any | None,
    *,
    llm_summarise: Any | None = None,
    max_entries: int = 100,
    summarize_batch: int = 50,
) -> None:
    self._episodic = episodic
    self._summarise = llm_summarise
    self._max_entries = max_entries
    self._batch = summarize_batch

SemanticMergePhase

SemanticMergePhase(graph: Any, *, chat_model: Any | None = None, embedding_provider: Any | None = None, embedding_threshold: float = DEFAULT_EMBEDDING_THRESHOLD, lexical_threshold: float = DEFAULT_LEXICAL_THRESHOLD, max_pairs_per_run: int = 10)

Phase 13. Two memories that say the same thing become one.

Source code in src/symfonic/capabilities/memory/phases/quick.py
def __init__(
    self,
    graph: Any,
    *,
    chat_model: Any | None = None,
    embedding_provider: Any | None = None,
    embedding_threshold: float = DEFAULT_EMBEDDING_THRESHOLD,
    lexical_threshold: float = DEFAULT_LEXICAL_THRESHOLD,
    max_pairs_per_run: int = 10,
) -> None:
    self._graph = graph
    self._chat_model = chat_model
    self._embedding_provider = embedding_provider
    self._embedding_threshold = embedding_threshold
    self._lexical_threshold = lexical_threshold
    self._max_pairs = max_pairs_per_run

applies

applies(context: PhaseContext) -> bool

The judge is a model call, so without one there is no phase.

This is the only quick phase that can cost a model round, which is why declining is visible rather than silent: an adopter who set the merge thresholds and no judge would otherwise see a clean cycle and no merges and conclude there were no duplicates.

Source code in src/symfonic/capabilities/memory/phases/quick.py
def applies(self, context: PhaseContext) -> bool:
    """The judge is a model call, so without one there is no phase.

    This is the only quick phase that can cost a model round, which is why
    declining is visible rather than silent: an adopter who set the merge
    thresholds and no judge would otherwise see a clean cycle and no merges
    and conclude there were no duplicates.
    """
    return self._chat_model is not None

SoulCorrectionsPhase

SoulCorrectionsPhase(graph: Any, window: RecentSemanticNodes, *, profile_fields: frozenset[str] | None = None)

Phase 5. A correction the user made is promoted onto their profile.

Source code in src/symfonic/capabilities/memory/phases/quick.py
def __init__(
    self,
    graph: Any,
    window: RecentSemanticNodes,
    *,
    profile_fields: frozenset[str] | None = None,
) -> None:
    self._graph = graph
    self._window = window
    self._fields = profile_fields

applies

applies(context: PhaseContext) -> bool

Only where the deployment declared which fields are profile fields.

Legacy's guard, kept: profile_fields is READ to learn which names count as profile, and without it the phase has no way to tell a correction from any other fact.

Source code in src/symfonic/capabilities/memory/phases/quick.py
def applies(self, context: PhaseContext) -> bool:
    """Only where the deployment declared which fields are profile fields.

    Legacy's guard, kept: ``profile_fields`` is READ to learn which names
    count as profile, and without it the phase has no way to tell a
    correction from any other fact.
    """
    return bool(self._fields)

StrengthenPhase

StrengthenPhase(graph: Any, window: RecentSemanticNodes, *, spreading_weight: float = 0.5)

Phase 1. A memory that keeps coming up matters more than one that does not.

Source code in src/symfonic/capabilities/memory/phases/quick.py
def __init__(
    self,
    graph: Any,
    window: RecentSemanticNodes,
    *,
    spreading_weight: float = 0.5,
) -> None:
    self._graph = graph
    self._window = window
    self._spreading_weight = spreading_weight

phase_graph

phase_graph(graph: Any) -> Any

The store the phases write through, from a store or a bare backend.

isinstance rather than duck-typing: both objects answer to query_nodes and their signatures differ (the store takes layer=, the backend takes a filter mapping), so a check that guessed from the shape would guess wrong exactly where it mattered.

The backend is wrapped in :class:~symfonic.capabilities.memory.journal. JournalledGraph and the store in :class:~symfonic.capabilities.memory. fencing.FencedGraph, which is why every factory routes through this one function. Ten of the roster's phase modules write to the graph directly rather than through the write coordinator, so anything applied phase by phase would be a rule each of them -- and each one written later -- has to remember. Applied here it is structural: a phase gets both by being handed its graph.

Both are inert outside a cycle. The journal defers mutations only while one is running on this task and the fence checks only while a lease is held, so single-process use, an ordinary turn and every unit test behave exactly as before.

A deployment that shares one backend between the phases and its memory layers should wrap it once at the composition root instead -- see :class:~symfonic.capabilities.memory.journal.JournalledGraph. Wrapping here covers what the phases reach; it cannot cover what ProceduralLayer writes through a store this function never sees.

Source code in src/symfonic/capabilities/memory/phases/phase_graph.py
def phase_graph(graph: Any) -> Any:
    """The store the phases write through, from a store or a bare backend.

    ``isinstance`` rather than duck-typing: both objects answer to
    ``query_nodes`` and their signatures differ (the store takes ``layer=``,
    the backend takes a filter mapping), so a check that guessed from the shape
    would guess wrong exactly where it mattered.

    The backend is wrapped in :class:`~symfonic.capabilities.memory.journal.\
    JournalledGraph` and the store in :class:`~symfonic.capabilities.memory.\
    fencing.FencedGraph`, which is why every factory routes through this one
    function. Ten of the roster's phase modules write to the graph directly
    rather than through the write coordinator, so anything applied phase by
    phase would be a rule each of them -- and each one written later -- has to
    remember. Applied here it is structural: a phase gets both by being handed
    its graph.

    Both are inert outside a cycle. The journal defers mutations only while one
    is running on this task and the fence checks only while a lease is held, so
    single-process use, an ordinary turn and every unit test behave exactly as
    before.

    A deployment that shares one backend between the phases and its memory
    layers should wrap it once at the composition root instead -- see
    :class:`~symfonic.capabilities.memory.journal.JournalledGraph`. Wrapping
    here covers what the phases reach; it cannot cover what
    ``ProceduralLayer`` writes through a store this function never sees.
    """
    if isinstance(graph, FencedGraph):
        # Idempotent, because the deep and nightly factories resolve the graph
        # once and hand the result to the factories they compose.
        return graph
    if isinstance(graph, GraphMemoryStore):
        return FencedGraph(graph)
    if isinstance(graph, JournalledGraph):
        # Already journalled at the composition root, where it also covers
        # the memory layers. Wrapping again would nest one journal inside
        # another.
        return FencedGraph(GraphMemoryStore(graph))
    return FencedGraph(GraphMemoryStore(JournalledGraph(graph)))

quick_phases

quick_phases(*, graph: Any, episodic: Any | None = None, profile_fields: frozenset[str] | None = None, chat_model: Any | None = None, embedding_provider: Any | None = None, lookback_hours: float = 24.0, spreading_weight: float = 0.5, llm_summarise: Any | None = None, episodic_max_entries: int = 100, episodic_summarize_batch: int = 50, embedding_threshold: float = DEFAULT_EMBEDDING_THRESHOLD, lexical_threshold: float = DEFAULT_LEXICAL_THRESHOLD, max_pairs_per_run: int = 10) -> tuple[ConsolidationPhase, ...]

Build the complete QUICK roster, in roster order.

graph is the one hard requirement: three of the four phases read and write the semantic graph, and a roster built without one would be three phases that fail on their first call rather than a roster that was never composed. Everything else is optional, and its absence makes exactly one phase decline.

Source code in src/symfonic/capabilities/memory/phases/quick.py
def quick_phases(
    *,
    graph: Any,
    episodic: Any | None = None,
    profile_fields: frozenset[str] | None = None,
    chat_model: Any | None = None,
    embedding_provider: Any | None = None,
    lookback_hours: float = 24.0,
    spreading_weight: float = 0.5,
    llm_summarise: Any | None = None,
    episodic_max_entries: int = 100,
    episodic_summarize_batch: int = 50,
    embedding_threshold: float = DEFAULT_EMBEDDING_THRESHOLD,
    lexical_threshold: float = DEFAULT_LEXICAL_THRESHOLD,
    max_pairs_per_run: int = 10,
) -> tuple[ConsolidationPhase, ...]:
    """Build the complete QUICK roster, in roster order.

    ``graph`` is the one hard requirement: three of the four phases read and
    write the semantic graph, and a roster built without one would be three
    phases that fail on their first call rather than a roster that was never
    composed. Everything else is optional, and its absence makes exactly one
    phase decline.
    """
    if graph is None:
        raise MemoryContractError(
            "quick_phases needs a graph: strengthen, soul_corrections and "
            "semantic_merge all read and write the semantic graph, so a roster "
            "without one is three phases that would fail on their first call. "
            "Pass the ``GraphBackend`` your store is built over, or a "
            "``GraphMemoryStore`` if you have one."
        )
    graph = phase_graph(graph)
    window = RecentSemanticNodes(graph, lookback_hours=lookback_hours)
    return PhaseRoster((
        StrengthenPhase(graph, window, spreading_weight=spreading_weight),
        SoulCorrectionsPhase(graph, window, profile_fields=profile_fields),
        EpisodicSummaryPhase(
            episodic,
            llm_summarise=llm_summarise,
            max_entries=episodic_max_entries,
            summarize_batch=episodic_summarize_batch,
        ),
        SemanticMergePhase(
            graph,
            chat_model=chat_model,
            embedding_provider=embedding_provider,
            embedding_threshold=embedding_threshold,
            lexical_threshold=lexical_threshold,
            max_pairs_per_run=max_pairs_per_run,
        ),
    ), graph)