`filter_unrelated_and_redundant_memories` returned early on
`len(memories) <= 1`, skipping both of the filtering steps it is responsible
for. That guard is correct for `filter_redundant_memories` — one memory cannot
be redundant with itself — but this method also removes memories that are
unrelated to the query history, and that check is per-memory: it applies just as
well to a list of one.
`filter_unrelated_memories`, which does only that step, has no such guard. So
the two paths disagreed on the same input: given one off-topic memory, the
unrelated-only filter dropped it while the combined filter kept it and never
consulted the LLM at all.
The log lines record the copy: the redundancy filter says "no redundancy to
filter" (accurate), while the combined one says "no filtering needed"
(not accurate — unrelated filtering was still needed). The guard was carried
over without widening its condition.
The prompt confirms the intent. MEMORY_COMBINED_FILTERING_PROMPT asks for two
steps, the first being "Unrelated Memory Removal ... Has no semantic connection
to any query in the history", which needs no second memory to be meaningful.
This runs on the working-memory replacement path
(`optimized_scheduler.replace_working_memory`), so a single unrelated memory
survived into working memory.
Drop the guard. The `not memories` and `not query_history` early returns are
untouched, and the LLM-failure path still conservatively keeps everything.
What's wrong
MemoryFilter.filter_unrelated_and_redundant_memoriesstarts with three earlyreturns. The third one is wrong:
This method is responsible for two filtering steps, and they have different
arity requirements:
The guard skips both. With a single memory, unrelated filtering never runs and
the LLM is never called.
The two paths disagree on the same input
filter_unrelated_memories(line 18), which performs only the unrelated step,has no
len(memories) <= 1guard — it filters a single memory correctly.filter_redundant_memories(line 105) has the guard, and there it is right.So for one off-topic memory the two code paths return opposite results.
Reproduced against the real
MemoryFilterwith a mocked LLM:Why this looks like a copy that was not adjusted
The log messages preserve the history:
memory_filter.py:139(redundancy filter) —"Only one memory - no redundancy to filter"— accurate.memory_filter.py:238(combined filter) —"Only one memory - no filtering needed"— not accurate; unrelated filtering was still needed.The guard was carried over from the redundancy-only method without widening its
condition, and the log text was softened rather than re-derived.
MEMORY_COMBINED_FILTERING_PROMPT(
src/memos/templates/mem_scheduler_prompts.py:279) states the intentexplicitly — it asks for two steps, the first being:
Nothing there requires a second memory to be present.
Impact
The combined filter runs on the working-memory replacement path,
OptimizedScheduler.replace_working_memory(
src/memos/mem_scheduler/optimized_scheduler.py:318). When reranking leaves asingle candidate, an unrelated memory survives into working memory and is then
carried into subsequent prompts.
The fix
Delete the guard. Behaviour that is deliberately preserved:
not memories→([], True)unchanged.not query_history→ keep everything unchanged (no relevance signal available).success_flag=False.Tests
Two cases added to
tests/mem_scheduler/test_retriever.py, following theexisting convention in that file (
self.retriever,MagicMock(spec=BaseLLM),json.dumps(...)for the mocked response):test_combined_filtering_still_filters_a_single_unrelated_memory— oneoff-topic memory must be dropped, and asserts
llm.generate.calledso theguard cannot come back as a silent short-circuit.
test_combined_filtering_keeps_a_single_relevant_memory— the counterpart, sothe fix cannot pass by simply discarding lone memories.
Note the mocked response uses
kept_memories, which is the key this methodactually reads (
memory_filter.py:264) — notrelevant_memories, which is theunrelated-only filter's schema.
Verification
Environment note: the repo uses poetry; poetry was unavailable locally, so the
venv was built with
uv(uv pip install -e .).pikahad to be installedseparately — without it
tests/mem_scheduler/fails to import viasrc/memos/dependency.py:47.uv.lockwas left untouched.The one failure,
test_scheduler.py::TestGeneralScheduler::test_dynamic_cache_layers_access,is pre-existing: it fails identically on an unmodified
dev-v2.0.34.Wider suite, excluding two files that need
torch(
tests/llms/test_hf.py,tests/memories/activation/test_kv.py) — identicalbefore and after this change, so no regression:
Those remaining failures/errors are missing optional dependencies in my local
environment, not related to this change.
Reverse-verified: with the source change stashed and the new tests kept,
test_combined_filtering_still_filters_a_single_unrelated_memoryfails and theother 17 pass — so the test pins this specific behaviour rather than passing
incidentally.
AI disclosure
This change was prepared with AI assistance. The defect was found by auditing
the three filter methods against each other, then confirmed by executing the
real
MemoryFilterclass; every claim above (log line numbers, prompt text,call site, test counts, baseline comparison) was verified by running the code
rather than inferred.