1"""Consolidation strategies — algorithms for pruning and merging memory entries."""
2
3from __future__ import annotations
4
5from datetime import UTC, datetime
6import math
7
8from lexigram.contracts.ai.memory import MemoryEntry
9from lexigram.logging import (
10 get_logger,
11)
12
13logger = get_logger(__name__)
14
15
16class RecencyDecayStrategy:
17 """Prunes entries whose recency score falls below a threshold.
18
19 Uses an exponential decay model with a configurable half-life.
20 """
21
22 def __init__(self, half_life_hours: float = 24.0, threshold: float = 0.05) -> None:
23 """Initialise the recency decay strategy.
24
25 Args:
26 half_life_hours: Time (h) for importance to halve.
27 threshold: Entries with recency below this are pruned.
28 """
29 self._half_life_s = half_life_hours * 3600.0
30 self._threshold = threshold
31
32 def should_prune(self, entry: MemoryEntry) -> bool:
33 """Return True if *entry* should be pruned.
34
35 Args:
36 entry: Entry to evaluate.
37
38 Returns:
39 True if the entry's recency score is below threshold.
40 """
41 age_s = (datetime.now(UTC) - entry.timestamp).total_seconds()
42 recency = math.exp(-math.log(2) * age_s / self._half_life_s)
43 return recency < self._threshold
44
45 def filter(
46 self, entries: list[MemoryEntry]
47 ) -> tuple[list[MemoryEntry], list[MemoryEntry]]:
48 """Split entries into (kept, pruned).
49
50 Args:
51 entries: Entries to evaluate.
52
53 Returns:
54 Tuple of (kept, pruned) entry lists.
55 """
56 kept: list[MemoryEntry] = []
57 pruned: list[MemoryEntry] = []
58 for e in entries:
59 (pruned if self.should_prune(e) else kept).append(e)
60 return kept, pruned
61
62
63class AccessFrequencyStrategy:
64 """Preserves high-importance entries; prunes low-importance stale ones."""
65
66 def __init__(self, importance_threshold: float = 0.1) -> None:
67 """Initialise the importance threshold strategy.
68
69 Args:
70 importance_threshold: Entries below this value are candidates for pruning.
71 """
72 self._threshold = importance_threshold
73
74 def should_prune(self, entry: MemoryEntry) -> bool:
75 """Return True if *entry* importance is below threshold.
76
77 Args:
78 entry: Entry to evaluate.
79
80 Returns:
81 True if importance is below threshold.
82 """
83 return entry.importance < self._threshold
84
85 def filter(
86 self, entries: list[MemoryEntry]
87 ) -> tuple[list[MemoryEntry], list[MemoryEntry]]:
88 """Split entries into (kept, pruned).
89
90 Args:
91 entries: Entries to evaluate.
92
93 Returns:
94 Tuple of (kept, pruned) entry lists.
95 """
96 kept: list[MemoryEntry] = []
97 pruned: list[MemoryEntry] = []
98 for e in entries:
99 (pruned if self.should_prune(e) else kept).append(e)
100 return kept, pruned
101
102
103class DeduplicationStrategy:
104 """Removes near-duplicate entries based on content similarity.
105
106 Two entries are duplicates if their lowercased content shares more than
107 *similarity_threshold* characters of the shorter one (Jaccard-like).
108 """
109
110 def __init__(self, similarity_threshold: float = 0.85) -> None:
111 """Initialise the deduplication strategy.
112
113 Args:
114 similarity_threshold: Jaccard-like overlap above which entries
115 are considered duplicates.
116 """
117 self._threshold = similarity_threshold
118
119 def _tokens(self, text: str) -> set[str]:
120 return set(text.lower().split())
121
122 def deduplicate(
123 self, entries: list[MemoryEntry]
124 ) -> tuple[list[MemoryEntry], list[MemoryEntry]]:
125 """Return (unique, duplicates).
126
127 Args:
128 entries: Entries to deduplicate.
129
130 Returns:
131 Tuple of (unique, duplicate) entry lists.
132 """
133 unique: list[MemoryEntry] = []
134 dupes: list[MemoryEntry] = []
135 seen_tokens: list[set[str]] = []
136
137 for entry in entries:
138 tokens = self._tokens(entry.content)
139 is_dup = False
140 for seen in seen_tokens:
141 if not tokens or not seen:
142 continue
143 intersection = len(tokens & seen)
144 union = len(tokens | seen)
145 if union > 0 and intersection / union >= self._threshold:
146 is_dup = True
147 break
148 if is_dup:
149 dupes.append(entry)
150 else:
151 unique.append(entry)
152 seen_tokens.append(tokens)
153
154 return unique, dupes
155
156
157class TimeDecayStrategy:
158 """Exponential decay of memory importance based on entry age.
159
160 The *effective importance* of an entry degrades over time using the formula::
161
162 effective = entry.importance * exp(-0.693 * age_hours / half_life_hours)
163
164 This mirrors radio-isotope half-life: after ``half_life_hours`` the importance
165 is exactly half the original value. After two half-lives it is one quarter,
166 and so on.
167
168 Unlike :class:`RecencyDecayStrategy` (which *prunes* entries), this strategy
169 *reweights* them — entries are not removed but their importance is updated so
170 that downstream retrieval ranks them lower.
171
172 Args:
173 half_life_hours: Time after which importance halves. Default is one week
174 (168 h).
175 min_importance: Floor value — entries will never be weighted below this.
176
177 Example::
178
179 strategy = TimeDecayStrategy(half_life_hours=72.0)
180 updated_entries = strategy.apply(all_entries)
181 """
182
183 def __init__(
184 self,
185 half_life_hours: float = 168.0,
186 *,
187 min_importance: float = 0.01,
188 ) -> None:
189 """Initialise the time-decay strategy.
190
191 Args:
192 half_life_hours: Hours until importance halves (default 168 = 1 week).
193 min_importance: Minimum importance floor after decay.
194 """
195 if half_life_hours <= 0:
196 raise ValueError("half_life_hours must be positive")
197 self._half_life_hours = half_life_hours
198 self._min_importance = min_importance
199
200 def compute_importance(self, entry: MemoryEntry) -> float:
201 """Return the time-decayed importance for *entry*.
202
203 Args:
204 entry: Memory entry to evaluate.
205
206 Returns:
207 Decayed importance value in the range ``[min_importance, entry.importance]``.
208 """
209 age_hours = (datetime.now(UTC) - entry.timestamp).total_seconds() / 3600.0
210 decayed = entry.importance * math.exp(
211 -math.log(2) * age_hours / self._half_life_hours
212 )
213 return max(decayed, self._min_importance)
214
215 def apply(self, entries: list[MemoryEntry]) -> list[MemoryEntry]:
216 """Return entries with importance reweighted by age.
217
218 The original entries are not mutated — new :class:`MemoryEntry` objects
219 are returned with updated ``importance`` values.
220
221 Args:
222 entries: Memory entries to reweight.
223
224 Returns:
225 New list of reweighted entries sorted by descending effective importance.
226 """
227 reweighted: list[MemoryEntry] = []
228 for entry in entries:
229 new_importance = self.compute_importance(entry)
230 reweighted.append(
231 MemoryEntry(
232 id=entry.id,
233 owner_id=entry.owner_id,
234 content=entry.content,
235 role=entry.role,
236 importance=new_importance,
237 timestamp=entry.timestamp,
238 metadata=entry.metadata,
239 embedding=entry.embedding,
240 )
241 )
242 reweighted.sort(key=lambda e: e.importance, reverse=True)
243 return reweighted
244
245
246__all__ = [
247 "AccessFrequencyStrategy",
248 "DeduplicationStrategy",
249 "RecencyDecayStrategy",
250 "TimeDecayStrategy",
251]