作者复盘自己开发的记忆系统中四个设计缺陷:静默删除过期规则、opt-in删除机制、缺乏归档vs删除区分,以及最终采用显式状态机+反记忆注入方案的真实心路。
我为 AI 编码智能体构建了一个工作记忆系统:笔记通过一次工具调用写入,通过另一次工具调用读出,整个产品的核心卖点是——一条笔记能够在对话无法存活的情况下存活下来,包括上下文压缩、全新会话、模型更换。所以,比我更感到尴尬的应该是承认:我的工具曾经有一个可选设置,会在常驻规则足够老的时候悄悄将其删除。
不是归档,不是标记,是 DELETE FROM notes WHERE workspace = ? AND created_at < ?。如果你设置了 VECTR_NOTES_TTL_DAYS 并让它一直运行,一条指令型笔记——也就是那种专门用来记录用户明确告知智能体的规则的笔记——比如"总是在 venv 中运行测试""不要直接碰配置文件"——会和几个月前完成的任务里随手记下的发现一样,按照完全相同的时间表被清除。那行数据没了。记录它的操作日志也随之消失了。
这个 Bug 是整篇文章的主题,因为修复它实际上与 TTL 调优无关。它涉及的是一个几乎我读过的所有记忆系统都会犯的类别错误,包括一段时间内的我自己的系统:把笔记的年龄当作判断这条笔记是否仍然有效的依据来使用。
但年龄不是依据。一年前的一条笔记告诉你某个服务的部署目标,或者某个迁移为什么那样写,可能恰恰是今天你拥有的最相关的东西。一小时前的一条笔记可能已经错了。唯一能告诉你某条记忆已经过时的,是实际发生的事:有人说它错了且说明了原因、有东西替换了它、它所锚定的产物在它底下发生了变化。流逝的时间本身什么也告诉不了你。它只是告诉你时间流逝了。
TTL(time-to-live:一段数据被允许存活多久,之后系统自动将其视为过期)是极大量系统类别正确的默认值,这恰恰也是为什么在这里也容易伸手去用它。对缓存来说它是正确的,因为缓存的整个契约是:这个值是一个真实来源的便利副本,真实来源仍然存在于别处。如果副本变旧了,你重新获取就是了,世界不会受到伤害。TTL 是在赌上游真实来源移动的速度有多快,如果你赌错了,降级方案只是一次往返就能得到真实答案。
记忆不是任何东西的便利副本。它就是记录本身。当一个智能体写下"这个表的 staging DB 迁移需要用 --allow-dangerous 运行,是踩坑学到的",如果笔记过期了,不会有某个上游真实来源准备好被重新获取。笔记就是产物。按时间钟删除它,你没有使一个缓存失效,你销毁了某人付出过时间或线上事故才学到的东西的唯一副本。
洞察。 缓存失效和记忆过期看起来是同一个问题,因为两者都涉及时间戳和截止值,但它们解决的是相反的故障模式。缓存过时是指它相对于一个可重新推导的真实来源太旧了。记忆过时是指它被反驳了、被取代了、或者偏离了它所锚定的东西——而年龄不能直接衡量其中任何一项。用 TTL 处理缓存。处理记忆要用别的东西。
我在修复这个 Bug 时并没有发明那个区别,我在几个月前就在一次关于这类笔记(构建怪癖、环境事实、没有文件可哈希的决策)陈旧度处理的研究中写过它。那份研究列出了五种确定性优先的选项,按每个选项携带了多少真实证据排序:
那份文档对每个选项的排名判断很直白:"A 是既没有锚点也没有撤销历史笔记的残余降级方案,限制最坏情况下的暴露程度,模仿的是缓存失效实践中的 TTL 基准/事件驱动混合方案。" E 是被标为核心的那个选项,"相对于每个被调研系统的最大差异化能力",因为被调研的生产系统(mem0、Zep/Graphiti、Letta、LangMem、ChatGPT 记忆、Anthropic 的记忆工具)没有一个把过去的撤销重新注入为智能体必须面对的内容。它们最多把它记入日志,然后继续前进。
E 最先交付,作为一个 append-only(只追加)的事件日志依附于每条笔记:创建,然后可选地更替、撤销(原因、执行者、时间戳)、标记为陈旧、或者恢复。任何状态更迭都不会被变更或删除,所以撤销一个撤销只是一条更多的事件,不是特殊情况。
A,列表上最便宜也最弱的选项,那个被明确标为没有锚点也没有撤销历史的笔记的降级方案,正是那个交付出了问题的。而且交付的东西不只是实现得很糟糕,它公然违反了 E 的核心不变量——通过删除行来解决问题。
衰减逻辑位于 agent/working_context_store/_store.py 中,作为一个名为 decay_old_notes() 的函数。以下是修复前 grep 其调用者得到的结果:三个调用点,全部在同一个测试文件里,此外什么都没有。没有生产代码路径调用它。它计算了一个真实的数字,写入了一个真实的列,但下游没有任何东西查那个列来做决策。拥有自己通过的单元测试的死代码,比明显未使用的死代码更糟糕,因为它读起来像是活的。你会看到测试全绿,然后假设这个功能在起作用。
这是第一个缺陷。另外三个在函数体本身:
它没有按类别区分作用域。函数签名接收一个单一的、平坦的 half_life_days=14.0,然后统一应用它:
# 从修复前的行为重构而来
UPDATE notes SET decay_score = decay_score * ... WHERE workspace = ?
WHERE 子句里没有 kind 列,没有按笔记类型分支。一条常驻指令——正是原始研究文档明确点名的情况,"指令型事实默认不衰减"——和一条用完即弃的发现按照完全相同的时钟衰减。那种设计明确说绝对不能因年龄而失去排名的类型,从来没有被任何东西豁免。
它删除了行。一旦笔记的 decay_score 越过阈值,函数就会执行:
DELETE FROM notes WHERE workspace = ? AND decay_score < 0.1
这和 VECTR_NOTES_TTL_DAYS 清理是同一类 Bug,只是通过另一扇门达到了:排名机制伸手进去销毁数据。它还使 note_events 中的每一行都变成了孤兒——那个 append-only 表正是那条笔记的审计日志。删除笔记,你失去的不只是内容,你失去了它曾经被创建、被撤销、被恢复的记录。整个系统其余部分所围绕的事件溯源不变量——当前状态是对不可变日志的折叠——无法在一个 DELETE 触及它本应折叠的东西的情况下存活。
数学上不是幂等的。实际的衰减计算是:
decay_score = decay_score * pow(0.5, (now - created_at) / half_life_s)
仔细看这个 Bug 在第一个 token。它用一个新的衰减因子乘以现有的 decay_score,而不是从 created_at 重新计算一个全新的衰减分数。在同一墙上时钟时刻调用它两次,你会得到两个不同的答案,因为第二次调用的输入已经包含了第一次调用的输出。一个把这个问题固定下来的测试,播入一个先前的 decay_score 0.3,让函数跑过半个半衰期的时间,得到 0.14999896382860867,而正确答案——一次干净的应用半个半衰期——是 0.5。那个数字在测试内部真实时间流逝的微秒级别内是 0.3 × 0.5:旧值与新的衰减因子复合了,而不是被替换。一条恰好被两次衰减过程紧密触碰的笔记,或者在启动时被同一过程运行两次的笔记,会比其配置的半衰期所暗示的更快地悄悄腐烂,原因与其实际年龄毫无关系。
总结起来:死代码、类别盲目、破坏性、非幂等(一个操作是幂等的,如果用相同的输入调用它两次,两次都产生相同的结果)。其中任何一个单独都是 Bug。四个 Bug 出现在同一个十二行函数里,就是当一个降级机制被焊接上去而没有人重新阅读它应该遵守的不变量时会发生的事。
那条活的路径更糟,因为它是真的被连接起来的
decay_old_notes() 坏掉了但处于惰性状态,没有任何东西调用它。而它的兄弟函数 purge_expired_notes() 有相反的问题:它的代码确实做了它看起来做的事,而它做的事是错的。它运行在一个环境变量可选配置 VECTR_NOTES_TTL_DAYS 后面,从 app/service.py 的启动路径接线上电,函数体是一句平坦的语句:
DELETE FROM notes WHERE workspace = ? AND created_at < ?
完全没有类型检查。开启这个设置后,所有种类的笔记都会在同一把时钟下老化过期,包括 directive 和已撤销笔记的威慑记录也不例外。
警告。一条已撤销的笔记并非死重,它的全部职责就是持续渲染为一条警告:"此前认为 X,在不核查的情况下不要重新推导此结论",这样做正是为了防止智能体重新陷入一个已经犯过的错误。一种无法区分已撤销威慑记录与普通过时笔记的 TTL 清除机制,会欣然删除那种唯一存在目的就是持续存在的条目。
调用点本身也有一个更小的异味,我在自己的代码中已经遇到不止一次:它在 except (ValueError, Exception) 内部将环境变量解析为浮点数。ValueError 已经是 Exception 的子类,所以这个子句是冗余的,更糟糕的是,它以同样的方式吞掉了除坏浮点数以外的所有其他故障。修复将其拆分为两部分:一个 ValueError 守卫围绕解析逻辑,这样格式错误的值会记录一条清晰的警告并什么也不做;另一个独立的 except Exception 围绕实际的清除逻辑,这样无关的故障会有自己的追踪记录,而不是被折叠进"不是有效浮点数"这个模糊信息里。
这个修复在 agent/config.yaml 中添加了一个 memory_decay 分段,包含两张按种类划分的数据表,一张用于排序信号,一张用于可见性信号:
half_life_days_by_kind:
directive: null
operational: 14
task: 21
gotcha: 30
finding: 30
reference: 45
decision: 60
ttl_days_by_kind:
directive: null
operational: 60
task: 90
gotcha: 120
finding: 120
reference: 180
decision: 240
null 表示在该机制下免于老化,无论年龄多大都适用。今天恰好有一种种类在两张表中都被标记为 null:directive。而这种豁免是写在代码中的,会在任何显式操作员覆盖之上取得胜利。如果你设置 VECTR_NOTES_TTL_DAYS=1 来积极修剪一个嘈杂的工作区,directive 仍然不会过期,因为该种类的 null 基线在覆盖应用之前就被检查了,而不是之后。操作员可以从外部收紧每种种类的保留窗口,但他们无法意外收紧那种其全部目的就是永不悄然消失的种类。
其余六个数字的顺序不是任意的;它编码了一个真实的声明,即每种笔记的用途是什么:
operational 衰减最快(14 天半衰期,60 天 TTL):关于环境的一个事实,"构建需要 JDK 21",恰恰是那些最可能已经从其下方移动出去的东西。在所有种类中,最接近缓存 TTL 真正擅长的场景。
task 排第二(21 / 90):一个正在执行工作中的检查点。工作完成或被放弃,无论哪种情况,检查点都会停止成为当前的画面。
gotcha 和 finding 并肩(30 / 120):比检查点更长的保质期,但仍然是操作级的,而非刻意做出的决定。
reference(45 / 180)比排序类种类的寿命更长:一个指针要么仍然有效,要么无效,更接近选项 B 的领地而非选项 A。
decision 拥有最长的排序跑道(60 / 240),最接近架构记录,意图是在很长时间后仍然可读。
directive 免于两者:用户陈述过一次的一条规则不会因为变老而变得不那么正确。
类比。把这两张表想象成图书馆设定借阅期限的方式,而不是逾期罚款。图书馆不会把每本书都放在同一把十四天的时钟上:一本畅销书通常获得更短的借阅期,因为其他人在等着它;一本参考书获得整整一个学期,因为它本来就该放在桌上供数月查阅;而图书馆自己的编目规则根本没有任何到期日。operational 是那本畅销书,借出和归还都快,因为它所描述的东西变化快。decision 是那本参考书,意在它被归档很久之后仍然有用。directive 从来就没有按某个日期到期借出过,它是图书馆运行的长期政策。
在这两张表如何被加载的方式中,有一个第二层更安静的设计,我喜爱它甚至超过那些数字本身:VALID_KINDS 中的每种种类都必须在这两张表中各有条目,而查询是直接的字典下标访问 MEMORY_DECAY_TTL_DAYS_BY_KIND[kind],而不是 .get(kind, some_default)。向系统中添加一种新的笔记种类,却没有在 config.yaml 的两张表中添加对应的条目,导入就会在进程启动时抛出 KeyError 而失败,而不是静默回退到某个没人刻意选择的默认半衰期。一种新记忆种类的保留策略不是你可以忘记去做的可选作业。它是系统拒绝启动之前就必须做出的、承载性的决策。
修复之所以作为两个函数而不是一个函数交付,原因是 decay-for-ranking 和 decay-for-visibility 并不是穿着不同外衣的同一操作。它们有不同的爆炸半径,修复也因此区别对待它们。
decay_old_notes() 无条件运行,每次服务启动时都运行,不需要任何环境标志。它能够无条件运行是因为它是非破坏性的:它只重新计算一个 decay_score 列,该列在排序中作为决胜平手的依据。最坏的情况下,一条笔记在同等信任度的同伴中排序稍晚一些。它永远不会让一条笔记消失。
purge_expired_notes() 保持在 VECTR_NOTES_TTL_DAYS 这一可选择加入的开关之后,默认不设置。它是对默认 recall() 或 fire() 调用实际返回的内容有可观察影响的函数,所以它保持为操作员必须主动开启的东西,而不是被烘焙进每个安装中的行为。开箱即用,没有任何东西会因为年龄增长而改变可见性。你必须主动请求那个行为。
干净的函数体,注释为节省篇幅已删除,修复的形状直接呈现如下:
def purge_expired_notes(self, workspace, ttl_days=None, now=None):
from agent.config import MEMORY_DECAY_TTL_DAYS_BY_KIND
now = now or time.time()
candidate_ids = []
for r in rows: # SELECT note_id, kind, created_at FROM notes WHERE workspace = ?
baseline = MEMORY_DECAY_TTL_DAYS_BY_KIND[r["kind"] or DEFAULT_KIND]
if baseline is None:
continue # exempt at any age, e.g. directive
effective_ttl = ttl_days if ttl_days is not None else baseline
if r["created_at"] < now - effective_ttl * 86400:
candidate_ids.append(r["note_id"])
states = self._note_event_states_by_ids(workspace, candidate_ids)
expired_count = 0
for note_id in candidate_ids:
if states.get(note_id, {}).get("state", "active") in ("expired", "revoked"):
continue # already expired or revoked: no-op, not a duplicate event
_append_event(conn, workspace, note_id, "expired", actor="system", reason="ttl exceeded", ts=now)
expired_count += 1
return expired_count # a transition count, never a delete count
其中没有任何 DELETE 语句。这个函数的职责是决定哪些笔记跨过那条线,并为每条笔记各追加一条 expired 事件,跳过任何已经过期或已撤销的条目,这样重复调用是真正的空操作而不是重复的日志条目。
decay_old_notes() 同样以从头计算而不是乘法累计的方式修复了复合 bug:
def decay_old_notes(self, workspace, half_life_days=None, now=None):
from agent.config import MEMORY_DECAY_HALF_LIFE_DAYS_BY_KIND
now = now or time.time()
for r in rows:
baseline = MEMORY_DECAY_HALF_LIFE_DAYS_BY_KIND[r["kind"] or DEFAULT_KIND]
if baseline is None:
continue # exempt at any age, e.g. directive
effective_half_life = half_life_days if half_life_days is not None else baseline
half_life_s = effective_half_life * 86400
elapsed_s = now - r["created_at"]
score = pow(0.5, elapsed_s / half_life_s) if elapsed_s > 0 else 1.0
conn.execute("UPDATE notes SET decay_score = ? WHERE workspace = ? AND note_id = ?",
(score, workspace, r["note_id"]))
数学原理。这与通过重复乘以当前读数余额来计算复利,而不是从本金和经过的期数重新计算的 bug 是一样的。balance = balance * 1.05 在循环中跑三次与 balance = principal * pow(1.05, 3) 一次计算出来的结果不一样,因为循环中第二次调用时其输入已经包含了第一次调用的舍入和时间因素。从固定起点重新计算,在利息案例中是本金,在这里是 created_at,才能使答案只取决于实际流逝的时间,而不是取决于函数在这期间恰好运行了多少次。
The score depends only on now and the note's unchanging created_at. Call this twice at the same clock reading and you get the same number twice, the idempotence property the original version lacked. One extra guard is worth noticing, elapsed_s > 0 else 1.0: without it, a note whose created_at is at or after now (clock skew, a note created mid-call) would compute pow(0.5, negative number), which is greater than 1.0, and rank a skewed note above a genuinely fresh one.
评分只取决于当前时刻和笔记不变的 created_at。在同一时刻读数调用两次会得到完全相同的数字两次,这是原版本缺少的幂等性。还有一个值得注意的额外保护:elapsed_s > 0 else 1.0——没有它,一个 created_at 处于当前时刻或之后(时钟偏移,或者在调用过程中创建的笔记)会计算出 pow(0.5, 负数),结果大于 1.0,从而将一个偏移的笔记排在一个真正新鲜的笔记之上。
The sort key, and the trap of multiplying instead of ranking
排序键,以及用乘法代替排序的陷阱
Here's the design point in this fix I think is easiest to get wrong even after you've internalized everything above: how decay_score actually enters ranking. It would be natural to multiply it against a note's semantic similarity score, treating decay as a discount factor on relevance. That's the trap. What shipped does something structurally different:
以下是此修复中最容易在完全理解上述内容之后仍然出错的设计要点:decay_score 如何实际参与排序。把它乘以笔记的语义相似度分数是很自然的做法,把衰减视为对相关性的折扣因子。但这就是陷阱。实际发布的代码做了结构上不同的事情:
def _sort_key(note: WorkingNote) -> tuple:
trust = 1.0 if note.kind == "task" else note.author_trust_score
decay = 1.0 if note.kind == "task" else note.decay_score
return (trust, decay, note.created_at, note.note_id)
The first element of that tuple, author_trust_score, is a separate axis this post doesn't get into, provenance rather than age, covered in full in the companion post linked at the end. What matters here is the position it occupies: decay_score is element two of a tuple, not a coefficient on similarity. That's a lexicographic sort key (comparing two sequences position by position, the way dictionary words are alphabetized), meaning it only breaks ties within a pool that's already been assembled by relevance. A note has to clear semantic retrieval first. Only once two or more notes are roughly tied on relevance does decay_score get consulted at all, to decide which one sorts first among equals.
该元组的第一个元素 author_trust_score 是本文不展开的另一个维度——来源而非年龄,在末尾链接的姊妹篇中有完整讨论。这里重要的是它所处的位置:decay_score 是元组的第二个元素,而不是相似度的系数。这是一个字典序排序键(按位置逐个比较两个序列,就像字典中单词的字母顺序排列一样),这意味着它只在已经由相关性筛选出的池子内部打破平局。一条笔记必须先通过语义检索。只有当两条或多条笔记在相关性上大致相同时,才会咨询 decay_score 来决定它们之间的排序先后。
Warning. Multiply similarity by a decay factor instead, and a sufficiently old note becomes mathematically unreachable no matter how well it matches the query. A decision note from eight months ago that is a perfect, dead-on match for what the agent needs right now would get its score dragged toward zero by pure elapsed time, and something worse but fresher would outrank it. That's precisely the failure mode this design refuses to allow. Decay can reorder an already-retrieved pool. It can never suppress a relevant note out of that pool, and it can never pull an irrelevant note above a relevant one.
警告。用衰减因子乘以相似度,而不是上述方式,那么一条足够老的笔记无论与查询匹配得多么好,在数学上都变得无法被检索到。八个月前的一条决策笔记恰好完全匹配智能体此刻所需,会因为纯粹的时间流逝而使其分数被拖向零,而更差但更新的东西会排在它前面。这正是此设计拒绝允许的失败模式。衰减可以重排已检索池中的顺序。但它永远不能把一条相关笔记压制出池子,也永远不能把一条无关笔记拉到相关笔记之上。
Notice too that task notes are pinned to a flat 1.0 on both axes regardless of their actual decay_score or trust value. That's deliberate and consistent with the retention table above: a task note is current-work state, and current-work state should sort first among task notes by recency, not get quietly buried by a decay computation that was never meant to apply to it in the first place. It's the same "kind changes the rule" principle as the two tables, just expressed in the ranking tie-break instead of the retention window.
还要注意,task 类型的笔记在两个轴上都固定为 1.0,无论其实际的 decay_score 或 trust 值是多少。这是故意的,与上面的保留表一致:task 笔记是当前工作状态,而当前工作状态应该按时间戳在 task 笔记中排在最前面,而不是被一个本来就不适用于它的衰减计算悄悄埋没。这与两个表中的"类型改变规则"原则相同,只是表达在排序平局决胜中,而不是保留窗口中。
Expiry as a state, not a hole where a note used to be
过期是一种状态,而不是笔记曾经存在的空洞
The word expired was added as a new entry in the note event vocabulary, alongside the six already there: created, superseded, revoked, stale_flagged, reinstated, and promoted. That's the real shape of the fix: expiry became one more transition an event-sourced log can record, not an operation that reaches outside the log and deletes something.
expired 一词作为新的笔记事件被添加到了事件词汇表中,与已有的六个并列:created、superseded、revoked、stale_flagged、reinstated 和 promoted。这才是修复的真正形态:过期成为了事件溯源日志可以记录的又一种转换,而不是一个伸到日志之外去删除某些东西的操作。
The practical consequence is that an expired note's row survives, its full note_events history survives, and it keeps rendering as a deterrent instead of silently vanishing:
实际结果是,一条过期笔记的行得以保留,其完整的 note_events 历史得以保留,并且它继续被渲染为威慑物而不是悄然消失:
[142] [EXPIRED] [deploy, staging]
Expired by age (recorded 2026-02-11, expired 2026-06-11, reason: ttl exceeded):
"old operational fact". Row and event history retained — use vectr_reinstate
to bring it back into active recall.
Filtering the note out of default results happens at read time, through one small helper, _exclude_expired(), called from exactly four places: recall(), the semantic recall path, path-scoped recall, and the trigger-firing path that powers automatic injection. Two other paths deliberately skip it: get_note() and format_notes_for_llm(), because those are the explicit-expand paths, and an expired note's deterrent has to stay reachable there or the whole mechanism is pointless.
将笔记从默认结果中过滤发生在读取时,通过一个小型辅助函数 _exclude_expired(),它恰好在四个地方被调用:recall()、语义召回路径、路径作用域召回,以及驱动自动注入的触发器 firing 路径。另外两个路径故意跳过它:get_note() 和 format_notes_for_llm(),因为那些是显式展开路径,而过期笔记的威慑必须保持可触达,否则整个机制就毫无意义。
Insight. I think this is the generalizable lesson of the whole fix, more than any specific number in the config tables: filter at read time, don't destroy at write time. The four call sites are a short, fixed list, easy to audit, easy to reason about. Every one of them applies the same rule, "drop anything whose folded state is expired," and every one of them is trivially wrong-able if a fifth call site gets added later and someone forgets to wire it in, which is exactly the kind of mistake a code review or a test suite can catch. Compare that to a DELETE buried in a decay pass: there's no reviewing your way out of a row that's already gone.
洞见。我认为这是整个修复中可推广的教训,超越了配置表中的任何具体数字:在读取时过滤,而不是在写入时销毁。四个调用点是一份简短的固定列表,易于审查,易于推理。每一个都应用相同的规则"丢弃折叠状态为 expired 的任何内容",而如果后续有人添加了第五个调用点却忘记接入过滤逻辑,每一个都很容易出错——这正是代码审查或测试套件能够catch的错误类型。对比一下埋在衰减过程中的 DELETE:对于已经消失的行,没有任何审查方式可以补救。
And because nothing is ever mutated or deleted, reversing any of this is free. vectr_reinstate on an expired note appends a reinstated event, and the fold over the log just produces a different current state on the next read. A revert of a revert costs one row in an append-only table and nothing else. That property, an undo that never has to special-case "well, what if the thing I'm undoing was itself an undo," is the actual payoff of building this on an event log in the first place rather than a mutable status column.
而且由于没有任何东西被修改或删除,撤销任何操作都是零成本的。在过期笔记上调用 vectr_reinstate 会追加一条 reinstated 事件,而对日志的折叠操作在下次读取时会产生不同的当前状态。撤销一次撤销只会在只追加表中增加一行,别无其他。这种"撤销永远不需要特别处理'我要撤销的东西本身是否也是一次撤销'"的特性,才是最初在事件日志而非可变状态列上构建此机制的真正回报。
(The published post includes two interactive demos here: one that lets you append events to a note's log by hand and watch the folded state change while switching between the four filtered read paths and the two explicit-expand paths, and one that runs the buggy compounding formula against the fixed recompute-from-scratch formula side by side across repeated passes.)
(已发布的文章在这里包含两个交互式演示:一个让你手动向笔记日志追加事件,并在四个过滤读取路径和两个显式展开路径之间切换时观察折叠状态的变化;另一个在重复运行中将 buggy 的复合公式与修复后的从头重算公式并排对比。)
What this fix had to prove, and what it doesn't claim to solve
此修复必须证明什么,以及它没有声称解决什么
The acceptance bar for the fix, stated as three concrete checks, doubles as a decent closing checklist for anyone building the same kind of mechanism:
修复的验收标准以三个具体检查的形式陈述,对任何构建类似机制的人来说也是一个不错的收尾清单:
A directive note survives both the decay pass and the TTL purge at any age, including under an aggressive operator override.
指令笔记在任意年龄下都能同时经受住衰减过程和 TTL 清除,包括在激进的 operator override 下。
An expired note stops appearing in default recall()/fire() output, but get_note() still resolves its row, its event log still shows the expired transition, and format_notes_for_llm() still renders it as a deterrent.
过期笔记停止出现在默认的 recall()/fire() 输出中,但 get_note() 仍然能解析其行、其事件日志仍然显示 expired 转换、format_notes_for_llm() 仍然将其渲染为威慑物。
Calling decay_old_notes() twice at the same clock reading produces the identical decay scores on the second call as on the first, idempotency, and compounding is not occurring.
在同一时刻读数调用 decay_old_notes() 两次,第二次产生的衰减分数与第一次完全相同——幂等性,且复合没有发生。