Skip to content

Recency on the hot path: the scorer_on_hot_path knob

Status: Shipped in v7.26.1. Closes: a production benchmark finding (2026-06-12) -- the 20% ScoringWeights.recency weight was dead on the MEMORY_CONTEXT hydration path. Related: FrameworkConfig.scorer_on_hot_path, RetrievalEngine.retrieve, RetrievalScorer._recency_score, src/symfonic/agent/engine.py:_hydrate_impl.

The bug v7.26.1 closes

Pre-v7.26.1, the per-turn MEMORY_CONTEXT hydration loop dispatched each enabled layer's retrieve() directly:

for layer_type, layer in self._orchestrator.all_layers().items():
    entries = await layer.retrieve(scope, query, top_k=...)
    all_entries.extend(entries)

That loop never called RetrievalEngine.retrieve -- the place where RetrievalScorer.score_batch runs the four-signal composition (semantic_similarity * w1 + recency * w2 + frequency * w3 + graph_proximity * w4). The ScoringWeights knob the adopter configured on OrchestratorConfig only mattered for ad-hoc agent._orchestrator.retrieval_engine.retrieve(...) calls; it had zero effect on the per-turn prompt.

A production benchmark on 2026-06-12 surfaced the operational tell: two-week-old episodic events with high cosine similarity ranked above twelve-minute-old events with similar cosine, because no recency term modulated the ranking. The 20% recency=0.2 weight was earning nothing on the hot path.

The fix: an opt-in route to the composed engine

v7.26.1 adds FrameworkConfig.scorer_on_hot_path: bool = False. When True, _hydrate_impl routes the long-term layers through RetrievalEngine.retrieve:

from symfonic.agent.config import FrameworkConfig

cfg = FrameworkConfig(scorer_on_hot_path=True)

With the knob set, every per-turn MEMORY_CONTEXT hydration:

  1. Embeds the query once (via embedding_provider).
  2. Pulls vector candidates from the vector backend (2x top_k for re-rank headroom).
  3. Pulls graph candidates from each enabled long-term layer (semantic, episodic, procedural, prospective).
  4. Runs RetrievalScorer.score_batch over the merged candidate set -- the four-signal composition fires (recency / frequency / graph_proximity / semantic).
  5. Returns the top_k results across all enabled layers in one ranked list.

Working memory still rides on top via the recency-prefix block (see Contract B below) -- the FIFO ordering of working_memory_recent_turns is unchanged in both modes.

Default is False on purpose

Default-on would change MEMORY_CONTEXT byte-ordering for every adopter at the v7.26.0 to v7.26.1 boundary. Even though the new ordering is "more correct" by spec, prompt-cache parity is the constraint -- every Anthropic + OpenAI cached prefix would rebill on the upgrade. v7.26.1 ships behind a single opt-in flag; the default-flip decision is deferred to v8.0 with a documented migration cycle.

The seven contracts

Contract Guarantee
A When scorer_on_hot_path=False, _hydrate_impl runs the v7.26.0 per-layer dispatch loop UNCHANGED. The engine is never called.
B The working-memory recency-prefix block (most-recent N working entries injected verbatim at the head of MEMORY_CONTEXT) runs UNCHANGED in both modes.
C When the engine path raises (transient vector outage, embedding-provider failure), the hydrate loop falls back to per-layer dispatch FOR THAT TURN, logs a scorer_hotpath_fallback warning, and increments agent._scorer_hotpath_fallback_count. Next turn re-attempts the engine path.
D Orchestrators built via HMSFactory.build / build_in_memory are automatically wired with a RetrievalEngine. Direct dataclass-constructed orchestrators without an engine fall back to per-layer dispatch with the same telemetry signal.
E ScoringWeights defaults are unchanged (semantic=0.4, recency=0.2, frequency=0.2, graph=0.2). Adopters can re-tune via OrchestratorConfig.scoring_weights.
F The intent_filter_mode=enforce skip (action turns omit long-term layers) is preserved -- the engine call passes layers=effective_layers derived from the intent verdict.
G The engine path returns FEWER total entries than the per-layer path (single composed top_k vs per-layer top_k * len(enabled_layers)). Adopters who relied on the higher per-layer quota should bump default_top_k when opting in.

Recency math

The recency signal is the exponential decay at src/symfonic/memory/graph/scoring.py:34-43:

recency = exp(-ln(2) / half_life_days * days_since_updated_at)

Default half-life is 7 days (_DEFAULT_HALF_LIFE_DAYS). A twelve-minute-old node scores ~1.0; a 14-day-old node scores ~0.25. With default ScoringWeights(semantic=0.4, recency=0.2), a fresh node at cosine 0.92 beats a stale node at cosine 0.95 because the recency premium covers the cosine deficit.

Surfacing the half-life as a FrameworkConfig.recency_half_life_days knob is deferred to a follow-up release (architect note in plan §10).

Telemetry

When the engine path takes the fallback to per-layer dispatch, two signals fire:

  1. Log: WARNING symfonic.agent.engine: scorer_hotpath_fallback for tenant=<id> -- ... with exc_info when an exception caused the fallback.
  2. Counter: agent._scorer_hotpath_fallback_count increments by 1 per fallback.

Adopters can wire the counter into their observability stack via a periodic snapshot from a custom callback or health-check endpoint. Persistent non-zero counts indicate either a backend health issue or a misconfigured orchestrator without a wired engine.

Migration recipe

  1. Build the orchestrator via HMSFactory.build or HMSFactory.build_in_memory (these wire the engine automatically).
  2. Set FrameworkConfig(scorer_on_hot_path=True).
  3. Verify agent._scorer_hotpath_fallback_count == 0 after several turns -- non-zero means the engine path is failing and you should diagnose before relying on the new ordering.
  4. Optional: tune OrchestratorConfig(scoring_weights=ScoringWeights(recency=0.3, ...)) if you want more aggressive recency bias.
  5. Optional: bump OrchestratorConfig.default_top_k if you noticed fewer entries in MEMORY_CONTEXT post-flip (Contract G).

Why this is patch (v7.26.1), not minor (v7.27.0)

  • No new public class.
  • New field on FrameworkConfig is additive + default-false.
  • No new dependency, no wire schema change, no migration.
  • The only observable change is on adopters who explicitly opt in.

The minor designation is reserved for the future default-flip release (v8.0 candidate).

Reference plan

.agent/team/plans/2026-06-12-v7.26-B-recency-on-hot-path.md (Shape B1 decision, contracts A-G, sequencing rationale).