MemorySelector
On this page
Purpose
Scores every candidate MemoryEntry against the current query on three axes — relevance (cosine similarity), recency (exponential decay), and importance (the entry's own effective_importance) — combines them into a weighted composite score, and returns the top entries that fit within a token budget. This is the ranking step between raw candidate gathering (STM + LTM + semantic search results) and WorkingMemory.load().
SelectionConfig (dataclass)
@dataclass
class SelectionConfig:
relevance_weight: float = 0.50
recency_weight: float = 0.20
importance_weight: float = 0.30
recency_half_life_hours: float = 24.0
min_composite_score: float = 0.10
top_k: int = 10
token_budget: int = 3000
__post_init__ validates that the three weights sum to 1.0 (within rel_tol=1e-3 via math.isclose), raising ValueError(f"Weights must sum to 1.0, got {total:.3f}") otherwise. No other field is validated — recency_half_life_hours <= 0, min_composite_score outside [0, 1], top_k <= 0, or token_budget <= 0 are all accepted silently and produce degenerate-but-non-crashing behavior (e.g. top_k=0 simply selects nothing via _apply_token_budget's len(selected) >= 0 immediately breaking).
ScoredMemory (dataclass)
@dataclass
class ScoredMemory:
entry: MemoryEntry
relevance_score: float = 0.0
recency_score: float = 0.0
importance_score: float = 0.0
composite_score: float = 0.0
def to_dict(self) -> Dict[str, Any]: ...
to_dict() truncates content to 120 characters (via str(entry.content)[:120]) and rounds all scores to 4 decimal places — intended for logging/debugging (see explain() below), not for reconstructing a full entry.
MemorySelector constructor
def __init__(self, config: Optional[SelectionConfig] = None) -> None
Defaults to SelectionConfig() if none given.
select()
def select(
self, query: str, candidates: List[MemoryEntry],
similarity_scores: Optional[Dict[str, float]] = None,
) -> List[ScoredMemory]
- Returns
[]immediately ifcandidatesis empty. - For each candidate:
rel = similarity_scores.get(entry.id, 0.5)(defaults to0.5— a neutral middle score — for any entry with no precomputed similarity, not0.0),rec = _recency_score(entry, now),imp = entry.effective_importance. - Computes
composite = relevance_weight*rel + recency_weight*rec + importance_weight*imp. - Filters out any candidate whose composite score is below
config.min_composite_score— these are dropped entirely, not merely deprioritized. - Sorts survivors by
composite_scoredescending. - Passes to
_apply_token_budget()(see below) for final trimming.
Note that query itself is not actually used inside select() when similarity_scores is provided — it's accepted as a parameter (and documented as being used "when similarity_scores not provided") but there is no code path in this method that computes relevance from query directly; if you call select() without a similarity_scores dict, every candidate simply gets the same flat rel = 0.5, regardless of query's content. Actual query-based relevance only happens upstream, wherever you compute similarity_scores (e.g. via SemanticMemory.search()).
_recency_score(entry, now) -> float
hours_old = (now - entry.timestamp) / 3600.0
lam = math.log(2) / half_life
return math.exp(-lam * hours_old)
Exponential decay based on entry.timestamp (creation time), not entry.last_access — an entry accessed recently but created long ago still scores low on recency here. This differs from ForgettingMechanism, which bases its decay on last_access.
_apply_token_budget(scored) -> List[ScoredMemory] (internal)
def _apply_token_budget(self, scored):
selected, used = [], 0
for sm in scored:
if len(selected) >= self.config.top_k:
break
tokens = self._token_estimate(sm.entry)
if used + tokens > self.config.token_budget:
continue
selected.append(sm)
used += tokens
return selected
[!DANGER] Docstring says 'fit the budget' but the implementation skips, not stops The
select()docstring states it returns "the top_k that fit the token budget," implying a straightforward top-k-by-score cutoff limited by budget. The actual loop usescontinuewhen an entry doesn't fit — meaning it keeps checking smaller, lower-ranked entries further down the sorted list rather than stopping. Concretely: if the #1-ranked entry is too large to fit the remaining budget, but the #2-ranked entry is smaller and fits,_apply_token_budgetwill select #2 (skipping #1) rather than stopping there. This is a reasonable greedy packing strategy in isolation, but it means the final selection isn't strictly "top_k by composite score" when budget constraints bind — it's "the highest-scoring subset that happens to fit, evaluated greedily in score order." Contrast withWorkingMemory.load(), which usesbreakinstead, stopping entirely at the first entry that doesn't fit. See Known Issues.
select_entries()
def select_entries(self, query, candidates, similarity_scores=None) -> List[MemoryEntry]
[sm.entry for sm in self.select(...)] — the entry-only convenience form used by AIMemoryManager.build_context().
explain()
def explain(self, query, candidates, similarity_scores=None) -> List[Dict[str, Any]]
[sm.to_dict() for sm in self.select(...)] — a diagnostic, human-readable breakdown of the same selection, useful for debugging why a particular memory was or wasn't chosen.
Raises
Thread safety
Read-only with respect to instance state (self.config is set once and never mutated) — select/select_entries/explain can be called concurrently from multiple threads on the same MemorySelector instance.
Complexity
O(n log n) for the sort in select(), where n = len(candidates). _apply_token_budget is O(min(n, top_k)) in the best case but can scan all n candidates in the worst case (if many are skipped for exceeding remaining budget).
Examples
from zerda.memory import MemorySelector, SelectionConfig, MemoryEntry, MemoryType
import time
selector = MemorySelector(SelectionConfig(top_k=5, token_budget=1000))
candidates = [
MemoryEntry(content={"input": f"fact {i}", "output": "ok"},
timestamp=time.time() - i * 3600, memory_type=MemoryType.SEMANTIC,
importance=0.5)
for i in range(20)
]
# 1. Basic — no precomputed similarity scores (all get rel=0.5, query has no effect here)
selected = selector.select_entries("irrelevant query text", candidates)
# 2. Intermediate — with real similarity scores from SemanticMemory.search()
sim_scores = {candidates[0].id: 0.9, candidates[5].id: 0.7}
selected2 = selector.select_entries("What facts do we know?", candidates, sim_scores)
# 3. Advanced — diagnostic breakdown
breakdown = selector.explain("query", candidates, sim_scores)
for row in breakdown[:3]:
print(row)
# 4. Error handling — invalid weights
try:
SelectionConfig(relevance_weight=0.5, recency_weight=0.5, importance_weight=0.5)
except ValueError as e:
print(e) # "Weights must sum to 1.0, got 1.500"
# 5. Production — tune weights toward recency for a live-chat use case
live_chat_config = SelectionConfig(
relevance_weight=0.4, recency_weight=0.4, importance_weight=0.2,
recency_half_life_hours=2.0, # much shorter half-life
)
live_selector = MemorySelector(live_chat_config)
Best practices
- Always compute
similarity_scoresupstream (e.g. viaSemanticMemory.search()) before callingselect()— passingqueryalone with nosimilarity_scoresproduces a flat, query-independent relevance score for every candidate. - If you need strict top-k-by-score behavior when budget-constrained, don't rely on
_apply_token_budget's currentcontinue-based logic — pre-filter or post-verify against your own top-k requirement, or subclass/reimplement if this greedy substitution behavior isn't acceptable for your use case.
Common mistakes
- Assuming
select(query, candidates)(withoutsimilarity_scores) actually ranks by semantic relevance toquery— it doesn't; it flat-scores every candidate at0.5relevance. - Assuming the returned list is strictly "top_k by composite score" when the token budget binds — a lower-ranked, smaller entry can appear ahead of a higher-ranked, larger one that was skipped.
Related APIs
WorkingMemory.load()— consumesselect_entries()'s output, with a different (stricter) budgeting strategy.SemanticMemory.search()— the typical source ofsimilarity_scores.AIMemoryManager.build_context()— orchestrates the full pipeline.