forked from Yinglianchun/Ombre-Brain
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdecay_engine.py
More file actions
324 lines (284 loc) · 13.1 KB
/
Copy pathdecay_engine.py
File metadata and controls
324 lines (284 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# ============================================================
# Module: Memory Decay Engine (decay_engine.py)
# 模块:记忆衰减引擎
#
# Simulates human forgetting curve; auto-decays inactive memories and archives them.
# 模拟人类遗忘曲线,自动衰减不活跃记忆并归档。
#
# Core formula (improved Ebbinghaus + emotion coordinates):
# 核心公式(改进版艾宾浩斯遗忘曲线 + 情感坐标):
# Score = Importance × (activation_count^0.3) × e^(-λ×days) × emotion_weight
#
# Emotion weight (continuous coordinate, not discrete labels):
# 情感权重(基于连续坐标而非离散列举):
# emotion_weight = base + (arousal × arousal_boost)
# Higher arousal → higher emotion weight → slower decay
# 唤醒度越高 → 情感权重越大 → 记忆衰减越慢
#
# Depended on by: server.py
# 被谁依赖:server.py
# ============================================================
import math
import asyncio
import logging
from datetime import datetime, timezone
logger = logging.getLogger("ombre_brain.decay")
class DecayEngine:
"""
Memory decay engine — periodically scans all dynamic buckets,
calculates decay scores, auto-archives low-activity buckets
to simulate natural forgetting.
记忆衰减引擎 —— 定期扫描所有动态桶,
计算衰减得分,将低活跃桶自动归档,模拟自然遗忘。
"""
def __init__(self, config: dict, bucket_mgr):
# --- Load decay parameters / 加载衰减参数 ---
decay_cfg = config.get("decay", {})
self.decay_lambda = decay_cfg.get("lambda", 0.05)
self.threshold = decay_cfg.get("threshold", 0.3)
self.check_interval = decay_cfg.get("check_interval_hours", 24)
# --- Emotion weight params (continuous arousal coordinate) ---
# --- 情感权重参数(基于连续 arousal 坐标)---
emotion_cfg = decay_cfg.get("emotion_weights", {})
self.emotion_base = emotion_cfg.get("base", 1.0)
self.arousal_boost = emotion_cfg.get("arousal_boost", 0.8)
self.bucket_mgr = bucket_mgr
# --- Background task control / 后台任务控制 ---
self._task: asyncio.Task | None = None
self._running = False
@property
def is_running(self) -> bool:
"""Whether the decay engine is running in the background.
衰减引擎是否正在后台运行。"""
return self._running
# ---------------------------------------------------------
# Core: calculate decay score for a single bucket
# 核心:计算单个桶的衰减得分
#
# Higher score = more vivid memory; below threshold → archive
# 得分越高 = 记忆越鲜活,低于阈值则归档
# Permanent buckets never decay / 固化桶永远不衰减
# ---------------------------------------------------------
# ---------------------------------------------------------
# Freshness bonus: continuous exponential decay
# 新鲜度加成:连续指数衰减
# bonus = 1.0 + 1.0 × e^(-t/36), t in hours
# t=0 → 2.0×, t≈25h(半衰) → 1.5×, t≈72h → ≈1.14×, t→∞ → 1.0×
# ---------------------------------------------------------
@staticmethod
def _calc_time_weight(days_since: float) -> float:
"""
Freshness bonus multiplier: 1.0 + e^(-t/36), t in hours.
新鲜度加成乘数:刚存入×2.0,~36小时半衰,72小时后趋近×1.0。
"""
hours = days_since * 24.0
return 1.0 + 1.0 * math.exp(-hours / 36.0)
@staticmethod
def _parse_datetime(value) -> datetime:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if parsed.tzinfo is None:
return parsed
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
@staticmethod
def _now_naive_utc() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def calculate_score(self, metadata: dict) -> float:
"""
Calculate current activity score for a memory bucket.
计算一个记忆桶的当前活跃度得分。
New model: short-term vs long-term weight separation.
新模型:短期/长期权重分离。
- Short-term (≤3 days): time_weight dominates, emotion amplifies
- Long-term (>3 days): emotion_weight dominates, time decays to floor
短期(≤3天):时间权重主导,情感放大
长期(>3天):情感权重主导,时间衰减到底线
"""
if not isinstance(metadata, dict):
return 0.0
# --- Pinned/protected buckets: never decay, importance locked to 10 ---
if metadata.get("pinned") or metadata.get("protected"):
return 999.0
# --- Permanent buckets never decay ---
if metadata.get("type") == "permanent":
return 999.0
# --- Feel buckets: never decay, fixed moderate score ---
if metadata.get("type") == "feel":
return 50.0
importance = max(1, min(10, int(metadata.get("importance", 5))))
activation_count = max(1, int(metadata.get("activation_count", 1)))
# --- Days since last activation ---
last_active_str = metadata.get("last_active", metadata.get("created", ""))
try:
last_active = self._parse_datetime(last_active_str)
days_since = max(0.0, (self._now_naive_utc() - last_active).total_seconds() / 86400)
except (ValueError, TypeError):
days_since = 30
# --- Emotion weight ---
try:
arousal = max(0.0, min(1.0, float(metadata.get("arousal", 0.3))))
except (ValueError, TypeError):
arousal = 0.3
emotion_weight = self.emotion_base + arousal * self.arousal_boost
# --- Time weight ---
time_weight = self._calc_time_weight(days_since)
# --- Short-term vs Long-term weight separation ---
# 短期(≤3天):time_weight 占 70%,emotion 占 30%
# 长期(>3天):emotion 占 70%,time_weight 占 30%
if days_since <= 3.0:
# Short-term: time dominates, emotion amplifies
combined_weight = time_weight * 0.7 + emotion_weight * 0.3
else:
# Long-term: emotion dominates, time provides baseline
combined_weight = emotion_weight * 0.7 + time_weight * 0.3
# --- Base score ---
base_score = (
importance
* (activation_count ** 0.3)
* math.exp(-self.decay_lambda * days_since)
* combined_weight
)
# --- Weight pool modifiers ---
# resolved + digested (has feel) → accelerated fade: ×0.02
# resolved only → ×0.05
# 已处理+已消化(写过feel)→ 加速淡化:×0.02
# 仅已处理 → ×0.05
resolved = metadata.get("resolved", False)
digested = metadata.get("digested", False) # set when feel is written for this memory
if resolved and digested:
resolved_factor = 0.02
elif resolved:
resolved_factor = 0.05
else:
resolved_factor = 1.0
urgency_boost = 1.5 if (arousal > 0.7 and not resolved) else 1.0
return round(base_score * resolved_factor * urgency_boost, 4)
# ---------------------------------------------------------
# Execute one decay cycle
# 执行一轮衰减周期
# Scan all dynamic buckets → score → archive those below threshold
# 扫描所有动态桶 → 算分 → 低于阈值的归档
# ---------------------------------------------------------
async def run_decay_cycle(self) -> dict:
"""
Execute one decay cycle: iterate dynamic buckets, archive those
scoring below threshold.
执行一轮衰减:遍历动态桶,归档得分低于阈值的桶。
Returns stats: {"checked": N, "archived": N, "lowest_score": X}
"""
try:
buckets = await self.bucket_mgr.list_all(include_archive=False)
except Exception as e:
logger.error(f"Failed to list buckets for decay / 衰减周期列桶失败: {e}")
return {"checked": 0, "archived": 0, "lowest_score": 0, "error": str(e)}
checked = 0
archived = 0
auto_resolved = 0
lowest_score = float("inf")
for bucket in buckets:
meta = bucket.get("metadata", {})
# Skip permanent / pinned / protected / feel buckets
# 跳过固化桶、钉选/保护桶和 feel 桶
if meta.get("type") in ("permanent", "feel") or meta.get("pinned") or meta.get("protected"):
continue
checked += 1
# --- Auto-resolve: imp≤4 + >30 days old + not resolved → auto resolve ---
# --- 自动结案:重要度≤4 + 超过30天 + 未解决 → 自动 resolve ---
if not meta.get("resolved", False):
imp = int(meta.get("importance", 5))
last_active_str = meta.get("last_active", meta.get("created", ""))
try:
last_active = self._parse_datetime(last_active_str)
days_since = (self._now_naive_utc() - last_active).total_seconds() / 86400
except (ValueError, TypeError):
days_since = 999
if imp <= 4 and days_since > 30:
try:
await self.bucket_mgr.update(bucket["id"], resolved=True)
auto_resolved += 1
logger.info(
f"Auto-resolved / 自动结案: "
f"{meta.get('name', bucket['id'])} "
f"(imp={imp}, days={days_since:.0f})"
)
except Exception as e:
logger.warning(f"Auto-resolve failed / 自动结案失败: {e}")
try:
score = self.calculate_score(meta)
except Exception as e:
logger.warning(
f"Score calculation failed for {bucket.get('id', '?')} / "
f"计算得分失败: {e}"
)
continue
lowest_score = min(lowest_score, score)
# --- Below threshold → archive (simulate forgetting) ---
# --- 低于阈值 → 归档(模拟遗忘)---
if score < self.threshold:
try:
success = await self.bucket_mgr.archive(bucket["id"])
if success:
archived += 1
logger.info(
f"Decay archived / 衰减归档: "
f"{meta.get('name', bucket['id'])} "
f"(score={score:.4f}, threshold={self.threshold})"
)
except Exception as e:
logger.warning(
f"Archive failed for {bucket.get('id', '?')} / "
f"归档失败: {e}"
)
result = {
"checked": checked,
"archived": archived,
"auto_resolved": auto_resolved,
"lowest_score": lowest_score if checked > 0 else 0,
}
logger.info(f"Decay cycle complete / 衰减周期完成: {result}")
return result
# ---------------------------------------------------------
# Background decay task management
# 后台衰减任务管理
# ---------------------------------------------------------
async def ensure_started(self) -> None:
"""
Ensure the decay engine is started (lazy init on first call).
确保衰减引擎已启动(懒加载,首次调用时启动)。
"""
if not self._running:
await self.start()
async def start(self) -> None:
"""Start the background decay loop.
启动后台衰减循环。"""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._background_loop())
logger.info(
f"Decay engine started, interval: {self.check_interval}h / "
f"衰减引擎已启动,检查间隔: {self.check_interval} 小时"
)
async def stop(self) -> None:
"""Stop the background decay loop.
停止后台衰减循环。"""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Decay engine stopped / 衰减引擎已停止")
async def _background_loop(self) -> None:
"""Background loop: run decay → sleep → repeat.
后台循环体:执行衰减 → 睡眠 → 重复。"""
while self._running:
try:
await self.run_decay_cycle()
except Exception as e:
logger.error(f"Decay cycle error / 衰减周期出错: {e}")
# --- Wait for next cycle / 等待下一个周期 ---
try:
await asyncio.sleep(self.check_interval * 3600)
except asyncio.CancelledError:
break