Skip to content

Commit 30abfb7

Browse files
fix: patch cold-tier summaries when facts update to eliminate drift regression (#16)
Reviewed and approved: eliminates 100% temporal drift regression in cold tier by patching stale values on fact-update events.
1 parent 013673e commit 30abfb7

1 file changed

Lines changed: 79 additions & 10 deletions

File tree

memory/cascading.py

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import List, Dict, Optional, Callable
1+
import re
2+
from typing import List, Dict, Optional, Callable, Tuple
23
import numpy as np
34
from .base import BaseMemory
45
from .decay import get_decay_fn, decay_ebbinghaus
@@ -9,16 +10,60 @@ def _extractive_summary(messages: List[Dict], max_chars: int = 400) -> str:
910
"""
1011
Lightweight extractive summary: keep sentences that contain key=value patterns.
1112
No LLM call needed — fast and cost-free.
13+
14+
Update messages ("changed to") are placed first so they survive truncation
15+
and take precedence over initial injection lines for the same fact.
1216
"""
13-
lines = []
17+
update_lines = []
18+
injection_lines = []
1419
for m in messages:
1520
content = m.get("content", "")
16-
if any(kw in content.lower() for kw in ["my ", "is ", "are ", "changed to", "name", "city", "age"]):
17-
lines.append(f"{m['role']}: {content}")
21+
if "changed to" in content.lower():
22+
update_lines.append(f"{m['role']}: {content}")
23+
elif any(kw in content.lower() for kw in ["my ", "is ", "are ", "name", "city", "age"]):
24+
injection_lines.append(f"{m['role']}: {content}")
25+
26+
lines = update_lines + injection_lines
1827
summary = " | ".join(lines)
1928
return summary[:max_chars] if summary else "No key facts."
2029

2130

31+
def _parse_update(content: str) -> Optional[Tuple[str, str]]:
32+
"""
33+
Parse "Actually, my <key_name> has changed to <new_value>."
34+
Returns (key_name, new_value) or None.
35+
"""
36+
m = re.search(
37+
r"my\s+(.+?)\s+has\s+changed\s+to\s+(.+?)[\.\!]?\s*$",
38+
content,
39+
re.IGNORECASE,
40+
)
41+
if m:
42+
return m.group(1).strip(), m.group(2).strip()
43+
return None
44+
45+
46+
def _patch_cold_with_update(cold: List[str], key_name: str, new_value: str) -> List[str]:
47+
"""
48+
Replace any value associated with *key_name* in cold summary strings so that
49+
stale original values are overwritten by the current value.
50+
51+
Targets the pattern produced by Fact.injection_text():
52+
"my <key> is <old_value>" → "my <key> is <new_value>"
53+
and also direct "changed to <old_new>" occurrences from earlier updates.
54+
"""
55+
# Match "my <key_name> is <anything up to a pipe/period/end>"
56+
pattern = re.compile(
57+
r"(my\s+" + re.escape(key_name) + r"\s+(?:is|are|was|has been)\s+)([^|.\n]+)",
58+
re.IGNORECASE,
59+
)
60+
result = []
61+
for entry in cold:
62+
patched = pattern.sub(lambda m: m.group(1) + new_value, entry)
63+
result.append(patched)
64+
return result
65+
66+
2267
class CascadingTemporalMemory(BaseMemory):
2368
"""
2469
Three-tier cascading memory with pluggable temporal decay.
@@ -30,6 +75,11 @@ class CascadingTemporalMemory(BaseMemory):
3075
3176
Decay options: 'ebbinghaus' (default) | 'exponential' | 'linear' | 'default'
3277
Reference: Ebbinghaus, H. (1885). Über das Gedächtnis.
78+
79+
Fix (issue #2): when a fact-update message cascades from the warm tier into
80+
cold, existing cold summaries are patched so the stale original value is
81+
replaced by the new one. This eliminates the 100% temporal-drift regression
82+
observed at T=100 where the old value was frozen inside compressed cold text.
3383
"""
3484

3585
name = "cascading"
@@ -53,8 +103,20 @@ def __init__(
53103
self.cold: List[str] = []
54104
self.turn_count = 0
55105

106+
# fact_key → new_value for every update seen so far
107+
self._fact_updates: Dict[str, str] = {}
108+
56109
def add_message(self, role: str, content: str, turn: int) -> None:
57110
msg = {"role": role, "content": content, "turn": turn}
111+
112+
parsed = _parse_update(content)
113+
if parsed:
114+
key_name, new_val = parsed
115+
self._fact_updates[key_name] = new_val
116+
# Immediately patch warm messages that are already compressible
117+
# (they haven't hit cold yet; cold is patched in _cascade_warm)
118+
self.cold = _patch_cold_with_update(self.cold, key_name, new_val)
119+
58120
self.hot.append(msg)
59121
self.turn_count += 1
60122

@@ -80,8 +142,14 @@ def _cascade_warm(self) -> None:
80142
summary = _extractive_summary(overflow)
81143
self.cold.append(summary)
82144

145+
# Patch all cold entries with every known fact update so no stale
146+
# values survive compression into the cold tier.
147+
for key_name, new_val in self._fact_updates.items():
148+
self.cold = _patch_cold_with_update(self.cold, key_name, new_val)
149+
83150
if len(self.cold) > self.cold_max:
84-
merged = self.cold[0] + " | " + self.cold[1]
151+
# Merge oldest two; newer content first so it survives truncation
152+
merged = self.cold[1] + " | " + self.cold[0]
85153
self.cold = [merged[:600]] + self.cold[2:]
86154

87155
def get_context(self, query: str, current_turn: int) -> List[Dict]:
@@ -116,8 +184,9 @@ def get_context(self, query: str, current_turn: int) -> List[Dict]:
116184
return context
117185

118186
def reset(self) -> None:
119-
self.hot = []
120-
self.warm = []
121-
self.warm_embs = []
122-
self.cold = []
123-
self.turn_count = 0
187+
self.hot = []
188+
self.warm = []
189+
self.warm_embs = []
190+
self.cold = []
191+
self.turn_count = 0
192+
self._fact_updates = {}

0 commit comments

Comments
 (0)