deer-flow/backend/tests/test_memory_fact_dedup.py
wd_pan 5d86ce345c
feat(memory): add deterministic near-duplicate fact gate (#5254)
* feat(memory): add deterministic near-duplicate fact gate

Add an opt-in write-side gate for DeerMem (issue #5252): a proposed NEW
fact whose bounded token-Jaccard similarity to an existing fact in the
same user/agent scope AND category reaches
fact_dedup_similarity_threshold merges into that fact instead of being
appended — the existing id/content/createdAt are kept, confidence is
raised to the maximum, and the source is refreshed, with one
facts_merged_dedup metric increment recording the merge.

Defaults preserve the legacy behavior exactly; targeted updates by fact
id and the exact-content key check are untouched. Companion write-side
step to the read-side relevance/diversity work in #5251.

Refs #5252
Signed-off-by: pwd11 <fvdsrc@163.com>

* fix(memory): preserve corrections during fact deduplication

Signed-off-by: pwd11 <fvdsrc@163.com>

---------

Signed-off-by: pwd11 <fvdsrc@163.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-14 07:22:38 +08:00

318 lines
13 KiB
Python

"""Tests for the deterministic near-duplicate fact gate (issue #5252).
The gate is opt-in via DeerMem-private config (``fact_dedup_enabled``) and
must never change the default behavior. A proposed NEW fact whose bounded
token-Jaccard similarity to an existing fact in the same user/agent scope
AND category reaches ``fact_dedup_similarity_threshold`` merges into that
fact (existing id/content/createdAt kept, confidence raised to the maximum,
source refreshed only on a confidence increase) instead of being appended.
Test construction mirrors ``tests/test_memory_scope_gate.py``: a real
``MemoryUpdater`` with an in-memory storage and ``_apply_updates`` driven
directly, no LLM, no network.
"""
from __future__ import annotations
import copy
import logging
import pytest
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
from deerflow.agents.memory.backends.deermem.deermem.core.storage import MemoryStorage
from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater, _fact_content_similarity, _fact_content_tokens
def _memory(facts: list[dict[str, object]] | None = None) -> dict[str, object]:
return {
"user": {
"workContext": {"summary": "", "updatedAt": ""},
"personalContext": {"summary": "", "updatedAt": ""},
"topOfMind": {"summary": "", "updatedAt": ""},
},
"history": {
"recentMonths": {"summary": "", "updatedAt": ""},
"earlierContext": {"summary": "", "updatedAt": ""},
"longTermBackground": {"summary": "", "updatedAt": ""},
},
"facts": copy.deepcopy(facts or []),
}
class _Storage(MemoryStorage):
def load(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, object]:
return _memory()
def reload(self, agent_name: str | None = None, *, user_id: str | None = None) -> dict[str, object]:
return self.load(agent_name, user_id=user_id)
def save(
self,
memory_data: dict[str, object],
agent_name: str | None = None,
*,
user_id: str | None = None,
expected_revision: int | None = None,
) -> bool:
return True
def _updater(**config_overrides: object) -> MemoryUpdater:
config = DeerMemConfig()
for key, value in config_overrides.items():
setattr(config, key, value)
return MemoryUpdater(config, _Storage(), llm=None)
def _fact(content: str, **overrides: object) -> dict[str, object]:
fact: dict[str, object] = {
"content": content,
"category": "preference",
"confidence": 0.9,
"scope": "user",
"durability": "durable",
"authority": "descriptive",
}
fact.update(overrides)
return fact
def _stored_fact(fact_id: str, content: str, **overrides: object) -> dict[str, object]:
fact: dict[str, object] = {
"id": fact_id,
"content": content,
"category": "preference",
"confidence": 0.9,
"createdAt": "2026-01-01T00:00:00Z",
"source": "thread-old",
}
fact.update(overrides)
return fact
def _update(new_facts: list[dict[str, object]]) -> dict[str, object]:
return {"user": {}, "history": {}, "newFacts": new_facts}
class TestFactDedupGate:
def test_default_config_keeps_near_duplicate_facts(self):
updater = _updater()
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
result = updater._apply_updates(current, _update([_fact("User prefers concise answers in chat, short form")]))
assert len(result["facts"]) == 2
def test_enabled_merges_paraphrased_fact(self):
updater = _updater(fact_dedup_enabled=True)
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
metrics: dict[str, object] = {}
result = updater._apply_updates(
current,
_update([_fact("User prefers concise answers in chat, short form", confidence=0.95)]),
metrics=metrics,
)
assert len(result["facts"]) == 1
merged = result["facts"][0]
assert merged["id"] == "fact_old"
assert merged["content"] == "User prefers concise answers in chat"
assert merged["createdAt"] == "2026-01-01T00:00:00Z"
assert merged["confidence"] == 0.95
assert merged["source"] == "unknown" # refreshed from the proposed fact's thread
assert metrics.get("facts_merged_dedup") == 1
def test_merge_keeps_higher_existing_confidence(self):
updater = _updater(fact_dedup_enabled=True)
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat", confidence=0.98)])
result = updater._apply_updates(
current,
_update([_fact("User prefers concise answers in chat, short form", confidence=0.8)]),
)
assert len(result["facts"]) == 1
assert result["facts"][0]["confidence"] == 0.98
def test_different_category_does_not_merge(self):
updater = _updater(fact_dedup_enabled=True)
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
result = updater._apply_updates(
current,
_update([_fact("User prefers concise answers in chat, short form", category="project")]),
)
assert len(result["facts"]) == 2
def test_unrelated_content_below_threshold_does_not_merge(self):
updater = _updater(fact_dedup_enabled=True)
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
result = updater._apply_updates(
current,
_update([_fact("User works on database migrations")]),
)
assert len(result["facts"]) == 2
def test_threshold_is_respected(self):
updater = _updater(fact_dedup_enabled=True, fact_dedup_similarity_threshold=0.9)
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
result = updater._apply_updates(
current,
_update([_fact("User prefers concise answers in chat, short form")]),
)
# 6/8 = 0.75 token-Jaccard is below the configured 0.9 threshold.
assert len(result["facts"]) == 2
def test_exact_duplicate_still_skipped_without_merge_metrics(self):
updater = _updater(fact_dedup_enabled=True)
current = _memory([_stored_fact("fact_old", "User prefers concise answers in chat")])
metrics: dict[str, object] = {}
result = updater._apply_updates(
current,
_update([_fact("User prefers concise answers in chat")]),
metrics=metrics,
)
assert len(result["facts"]) == 1
assert "facts_merged_dedup" not in metrics
class TestFactDedupConfig:
def test_defaults_keep_legacy_behavior(self):
config = DeerMemConfig()
assert config.fact_dedup_enabled is False
assert config.fact_dedup_similarity_threshold == 0.7
def test_backend_config_accepts_new_knobs(self):
config = DeerMemConfig.from_backend_config(
{
"fact_dedup_enabled": True,
"fact_dedup_similarity_threshold": 0.8,
}
)
assert config.fact_dedup_enabled is True
assert config.fact_dedup_similarity_threshold == 0.8
@pytest.mark.parametrize("paired", [True, False])
def test_correction_is_not_merged_into_removal_target(paired):
old = "User prefers email notifications over sms for weekly reports"
new = "User prefers push notifications over sms for weekly reports"
current = _memory([_stored_fact("fact_B", old, confidence=0.5)])
update = _update([_fact(new, confidence=0.95)])
removal = {"id": "fact_B", "scope": "user", "reason": "Preference changed"}
if paired:
removal["replacementFactIndex"] = 0
update["factsToRemove"] = [removal]
metrics = {}
result = _updater(fact_dedup_enabled=True)._apply_updates(current, update, thread_id="correction", metrics=metrics)
assert len(result["facts"]) == 1
assert result["facts"][0]["content"] == new
assert result["facts"][0]["id"] != "fact_B"
assert result["facts"][0]["source"] == "correction"
assert metrics.get("facts_merged_dedup", 0) == 0
def test_paired_replacement_is_not_merged_into_another_similar_fact():
other = "User prefers email notifications over sms for weekly reports"
current = _memory(
[
_stored_fact("fact_B", "User previously disabled all notifications"),
_stored_fact("fact_other", other),
]
)
new = "User prefers push notifications over sms for weekly reports"
update = _update([_fact(new, confidence=0.95)])
update["factsToRemove"] = [{"id": "fact_B", "scope": "user", "reason": "Changed", "replacementFactIndex": 0}]
result = _updater(fact_dedup_enabled=True)._apply_updates(current, update)
assert {fact["content"] for fact in result["facts"]} == {other, new}
assert all(fact["id"] != "fact_B" for fact in result["facts"])
def test_stale_removal_proposal_is_not_strengthened_when_guard_keeps_it():
old = _stored_fact("fact_old", "User prefers concise answers in chat")
update = _update([_fact("User prefers concise answers in chat, short form", confidence=0.95)])
# Configure preferences as protected from staleness removal. A proposal
# still must not become evidence strengthening the proposed target.
update["staleFactsToRemove"] = [{"id": "fact_old", "reason": "Outdated"}]
result = _updater(fact_dedup_enabled=True, staleness_protected_categories=["preference"])._apply_updates(_memory([old]), update)
assert len(result["facts"]) == 2
assert next(fact for fact in result["facts"] if fact["id"] == "fact_old") == old
@pytest.mark.parametrize("confidence", [0.8, 0.98])
def test_nonincreasing_confidence_preserves_source_and_confirmation(confidence):
old = _stored_fact("fact_old", "User prefers concise answers in chat", confidence=0.98, lastConfirmedAt="2026-01-02T00:00:00Z", confirmationCount=2)
result = _updater(fact_dedup_enabled=True)._apply_updates(
_memory([old]),
_update([_fact("User prefers concise answers in chat, short form", confidence=confidence)]),
thread_id="restatement",
)
assert result["facts"] == [old]
def test_merge_logs_metadata_at_info_without_fact_text(caplog):
old = "User prefers concise answers in chat"
new = old + ", short form"
with caplog.at_level(logging.INFO):
_updater(fact_dedup_enabled=True)._apply_updates(_memory([_stored_fact("fact_old", old)]), _update([_fact(new, confidence=0.95)]))
assert "Near-duplicate fact merge proposed" in caplog.text
assert "fact_old" in caplog.text
assert "proposal_index=0" in caplog.text
assert old not in caplog.text
assert new not in caplog.text
def test_mixed_script_facts_retain_different_cities():
old = "Prefers Python, 用户在北京"
new = "Prefers Python, 用户在上海"
result = _updater(fact_dedup_enabled=True)._apply_updates(_memory([_stored_fact("fact_old", old)]), _update([_fact(new)]))
assert len(result["facts"]) == 2
assert _fact_content_similarity(old, new) < 0.7
def test_tokenizer_combines_words_and_cjk_bigrams():
assert _fact_content_tokens("Prefers Python, 用户在北京") == ["prefers", "python", "用户", "户在", "在北", "北京"]
assert _fact_content_tokens("用户 北京") == ["用户", "户北", "北京"]
assert _fact_content_similarity("用户 北京", "北京 用户") < 0.7
assert _fact_content_tokens("") == [""]
@pytest.mark.parametrize("override", [{"scope": "thread"}, {"confidence": 0.1}])
def test_rejected_replacement_does_not_delete_or_strengthen_original(override):
old = _stored_fact("fact_B", "User prefers email notifications over sms for weekly reports", confidence=0.8)
update = _update([_fact("User prefers push notifications over sms for weekly reports", **override)])
update["factsToRemove"] = [{"id": "fact_B", "scope": "user", "reason": "Changed", "replacementFactIndex": 0}]
result = _updater(fact_dedup_enabled=True)._apply_updates(_memory([old]), update)
assert result["facts"] == [old]
@pytest.mark.parametrize("confirmed", [False, True])
def test_merge_preserves_deterministic_confirmation_gate(confirmed):
old = _stored_fact("fact_old", "User prefers concise answers in chat", lastConfirmedAt="2026-01-02T00:00:00Z", confirmationCount=2)
update = _update([_fact("User prefers concise answers in chat, short form", confidence=0.95)])
update["factsToReinforce"] = [{"id": "fact_old", "scope": "user", "reason": "Restated"}]
result = _updater(fact_dedup_enabled=True, fact_eviction_shadow_enabled=True)._apply_updates(
_memory([old]),
update,
signals=frozenset({"reinforcement"}) if confirmed else frozenset(),
)
merged = result["facts"][0]
assert merged["confidence"] == 0.95
assert merged["confirmationCount"] == (3 if confirmed else 2)
assert (merged["lastConfirmedAt"] != old["lastConfirmedAt"]) is confirmed