1- from typing import List , Dict , Optional , Callable
1+ import re
2+ from typing import List , Dict , Optional , Callable , Tuple
23import numpy as np
34from .base import BaseMemory
45from .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+
2267class 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