fix(memory): exact whole-entry match beats substring match in replace/remove/batch
A short entry whose full text is contained inside a longer sibling entry
was unaddressable: remove('test') against entries ['test', '...tests
pass...'] hit _find_unique_match's substring scan, reported 'Multiple
entries matched', and refused — the entry could never be targeted.
apply_batch hit the same matcher and aborted the whole operation.
_find_unique_match() now prefers whole-entry EXACT matches
(old_text == entry) and only falls back to substring matches when no
entry equals old_text. Both call sites (_edit for single replace/remove,
_apply_batch_op for batch) share the matcher, so the whole bug class is
fixed at one seam. Substring partial matching is unchanged.
Reproduced red on current main (2 invariant tests), 53/53 green after
the fix; 8 sibling memory suites green via scripts/run_tests.sh.
This commit is contained in:
@@ -225,6 +225,28 @@ class TestMemoryStoreRemove:
|
||||
assert store.remove("memory", " ")["success"] is False
|
||||
|
||||
|
||||
class TestExactWholeEntryMatchPriority:
|
||||
"""A short entry whose full text is contained inside a longer sibling entry
|
||||
must stay addressable: old_text that equals an entry wins outright, and
|
||||
substring matches only apply when no entry equals old_text. Without this,
|
||||
remove('test') against entries ['test', '...tests pass...'] reported
|
||||
ambiguity and the entry could never be addressed."""
|
||||
|
||||
def test_remove_exact_entry_beats_substring_collision(self, store):
|
||||
store.add("memory", "test")
|
||||
store.add("memory", "echo-reply tests pass via local twins and are false positives")
|
||||
result = store.remove("memory", "test")
|
||||
assert result["success"] is True
|
||||
assert store.memory_entries == ["echo-reply tests pass via local twins and are false positives"]
|
||||
|
||||
def test_batch_remove_exact_entry_beats_substring_collision(self, store):
|
||||
store.add("memory", "test")
|
||||
store.add("memory", "echo-reply tests pass via local twins and are false positives")
|
||||
result = store.apply_batch("memory", [{"action": "remove", "old_text": "test"}])
|
||||
assert result["success"] is True
|
||||
assert store.memory_entries == ["echo-reply tests pass via local twins and are false positives"]
|
||||
|
||||
|
||||
class TestMemoryConsolidationGracefulDegrade:
|
||||
"""Fix #3 for #42405: a failed at-capacity consolidation must never loop the
|
||||
turn to budget exhaustion — after a per-turn cap of failures, memory ops
|
||||
|
||||
@@ -56,9 +56,14 @@ def _read_failed_error(path: Path) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _find_unique_match(entries: List[str], old_text: str) -> Tuple[Optional[int], bool]:
|
||||
"""``(index, ambiguous)`` for entries containing *old_text*. Exact-duplicate
|
||||
"""``(index, ambiguous)`` for entries matching *old_text*. A whole-entry
|
||||
EXACT match (``old_text == entry``) takes absolute priority — substring
|
||||
matches are only considered when no entry equals *old_text*, so a short
|
||||
entry stays addressable even when its full text is contained inside a
|
||||
longer sibling entry (remove('test') vs '...tests pass...'). Exact-duplicate
|
||||
matches are safe (first wins); distinct matches → ``(None, True)``."""
|
||||
matches = [i for i, e in enumerate(entries) if old_text in e]
|
||||
exact = [i for i, e in enumerate(entries) if e == old_text]
|
||||
matches = exact if exact else [i for i, e in enumerate(entries) if old_text in e]
|
||||
if len({entries[i] for i in matches}) > 1:
|
||||
return None, True
|
||||
return (matches[0] if matches else None), False
|
||||
|
||||
Reference in New Issue
Block a user