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
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
applies ¶
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
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
applies ¶
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
StrengthenPhase ¶
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
phase_graph ¶
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
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.