ADR-0005: Memory consolidation strategy axis split¶
Status¶
Accepted, implemented in WP-4 (issue #1919).
Context¶
memory/consolidation/ ships one ConsolidationStrategy Protocol with
a single consolidate(entries, *, agent_id) -> ConsolidationResult
method and three implementations:
SimpleConsolidationStrategy: group by category, keep the highest-relevance entry per group (recency tiebreak), summarise the rest by truncated concatenation.DualModeConsolidationStrategy: group by category, classify each group sparse/dense by majority vote, route dense groups to an extractive key-fact preserver and sparse groups to an abstractive LLM summariser.LLMConsolidationStrategy: group by category, keep the highest-relevance entry, feed the rest to an LLM for synthesis with concatenation fallback.
Every strategy entangles two orthogonal decisions in one class: which entries to consolidate (grouping + keep/remove partitioning) and how to consolidate the removed ones (concatenate / extract / abstract / LLM-synthesise). The "group by category, keep top relevance" selection is copy-pasted across Simple and LLM; the concatenation fallback is duplicated. Adding a fourth selection rule or a fifth operation today means a new monolithic class re-deriving the other axis. There is no config discriminator and no factory: the service takes a hand-injected strategy.
SynthOrg is pre-alpha. This is the correct window to split the axes with no compatibility layer.
Decision¶
Decompose into two orthogonal protocols plus a composite, all under
memory/consolidation/:
All three current strategies share an identical selection step
(group by category, drop groups below group_threshold, keep the
entry with the highest relevance_score -- None treated as 0.0 --
with most-recent created_at as the tiebreak). They differ only in
how the to-remove set becomes a stored summary. So the split is one
selector, a family of ops, not a selector-per-strategy:
@runtime_checkable
class EntrySelector(Protocol):
def select(
self, entries: tuple[MemoryEntry, ...]
) -> tuple[SelectionGroup, ...]: ...
# SelectionGroup: (category, kept: MemoryEntry,
# to_remove: tuple[MemoryEntry, ...])
@runtime_checkable
class ConsolidationOp(Protocol):
async def consolidate(
self,
to_remove: tuple[MemoryEntry, ...],
*,
category: MemoryCategory,
context: ConsolidationContext,
) -> OpResult: ...
The op owns the backend (injected at construction, mirroring the
pre-split monolith __init__) and performs store + delete
internally with that strategy's exact failure semantics. This is
required because the three strategies' delete handling is mutually
incompatible and cannot be reproduced by a uniform composite-driven
delete:
- Simple: no
try/except, no return-value check -- a delete failure aborts the whole consolidation. - LLM: per-delete
try/except; swallows non-system exceptions, propagatesMemoryError/RecursionError. - DualMode: checks the bool return (
if not deleted: continue), notry/except.
So OpResult is the minimal cross-boundary contract -- not summary
text/tags (op-internal), just the outcome:
@dataclass(frozen=True, slots=True)
class OpResult:
summary_id: NotBlankStr
removed_ids: tuple[NotBlankStr, ...] # only successfully deleted
mode_assignments: tuple[ArchivalModeAssignment, ...] = () # DualMode only
The truncation-survivor contract (LLM) is preserved inside
LLMSynthesisOp: it tracks which entries the prompt cap admitted
and deletes only those, so dropped entries stay for the next pass --
exactly as the monolith did. removed_ids reports only the
successfully deleted subset.
CompositeConsolidationStrategy(selector, op, *, parallel=False)
satisfies the existing ConsolidationStrategy Protocol, so
MemoryConsolidationService is unchanged at the callsite. It runs the
selector then aggregates one OpResult per group into a
ConsolidationResult. parallel defaults to False (sequential
group iteration -- Simple/DualMode); the factory wires it True for
LLM, where the composite owns the asyncio.TaskGroup fan-out across
groups plus the except* unwrap, byte-identical with the LLM
monolith's _run_groups.
Implementations¶
Selector (one):
HighestRelevanceSelector(group_threshold): the shared category-group + relevance/recency selection, written once. Density classification is not selection -- in DualMode it decides which op processes a group, so it lives in the op, not the selector.
Operations:
ConcatenationOp: truncated bullet concatenation (Simple's operation; also the shared fallback insideLLMSynthesisOp).AbstractiveSummarizationOp: wraps the existingAbstractiveSummarizer(per-entry LLM summary,TaskGroupfan-out).ExtractivePreservationOp: wraps the existingExtractivePreserver(key-fact extraction).LLMSynthesisOp: LLM synthesis with trajectory context + the truncation accounting; concatenation fallback on LLM failure or empty result. Reports the truncation-survivor subset viaOpResult.represented.DensityRoutingOp(classifier, extractive_op, abstractive_op): majority-vote density classification over the group, then delegates to the extractive or abstractive op and stamps themode:<...>tag +mode_assignments. Routing is intrinsic to this op; it composes the other two ops rather than duplicating them.
The three existing strategies become composites¶
- Simple =
Composite(HighestRelevanceSelector, ConcatenationOp) - LLM =
Composite(HighestRelevanceSelector, LLMSynthesisOp) - DualMode =
Composite(HighestRelevanceSelector, DensityRoutingOp(classifier, ExtractivePreservationOp, AbstractiveSummarizationOp))
No monolithic class is kept; no adapter wraps an old class. The three public strategy names resolve to composite instances via the factory.
Config + factory¶
consolidation/config.py gains a ConsolidationStrategyType StrEnum
discriminator (SIMPLE, DUAL_MODE, LLM) plus optional explicit
selector / op sub-discriminators for custom compositions.
consolidation/factory.py::build_consolidation_strategy(config) uses
the StrEnum-keyed StrategyRegistry from ADR-0002. The factory is
wired into MemoryConsolidationService construction; the strategy is
no longer hand-injected in app bootstrap.
Migration mechanics¶
Ordered to make behaviour regressions impossible to land silently -- this is the highest-risk refactor in WP-4:
- Byte-identical golden regression tests against the current
monolithic Simple / DualMode / LLM (fixed inputs -> exact
ConsolidationResult, including an oversized-batch LLM input that triggersmax_total_user_content_charstruncation so the deletion-subset contract is pinned). These pass against today's code and must stay green through every later step. - Define
EntrySelector,ConsolidationOp,SelectionGroup,ConsolidationContext,OpResult; addHighestRelevanceSelector, the four ops,DensityRoutingOp, andCompositeConsolidationStrategyas new modules without touching the three existing strategy classes. - Convert Simple to the composite; delete the monolith; golden green.
- Convert DualMode (
DensityRoutingOp); delete monolith; golden green (assertsmode_assignmentsbyte-identical). - Convert LLM (
LLMSynthesisOpwith therepresentedsubset contract); delete monolith; golden green (the truncation case guards this step). - Add
ConsolidationStrategyTypediscriminator + theStrEnum-keyedStrategyRegistryfactory (ADR-0002); wire it intoMemoryConsolidationServiceconstruction; remove the hand-injection. - Reshape
tests/unit/memory/consolidation/test_*_strategy.pyinto per-selector + per-op + composite tests; the step-1 golden tests remain as the byte-identical guard. Substantial; part of the deliverable, not a follow-up. - Update
docs/design/memory-consistency.mdwith the axis-split section anddocs/reference/pluggable-subsystems.mdcatalogue.
Compat scope¶
None. The monolithic strategy classes are deleted in the same commit that introduces the composite + selectors + ops. The public strategy names survive only as factory outputs, not as classes.
Alternatives considered¶
- Protocols + factory now, keep the three classes monolithic internally behind the new interface. Rejected (user decision): that is a deferred shim; the entanglement the ADR exists to remove would remain, just hidden behind an adapter.
- Three axes (selector / grouper / op). Rejected: grouping is part of selection in every existing strategy (group-then-keep); a separate grouper axis adds a degree of freedom no current or proposed strategy needs.
- Keep the monolith. Rejected: copy-pasted selection logic and duplicated fallback are the motivation; pre-alpha is the cheap window.
Consequences¶
memory/consolidation/gains selector and op modules; the three strategy files are removed.MemoryConsolidationServicecallsite is unchanged (CompositeConsolidationStrategysatisfies the old Protocol).- Strategy selection becomes config-driven through the ADR-0002 registry instead of bootstrap hand-injection.
- Significant one-time test reshape in
tests/unit/memory/consolidation/. - Out of scope: the memory backends, retrieval/injection strategies, archival policy.