feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)

M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:

1) 新增 `dedupe_group` 候选动作(不放大 containNone):
   - 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
     (后者附加词项余弦 ≥0.70 与同 kind 收窄);
   - 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
   - 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
     数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
     两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
   - 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
   - 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
     回滚复用 revertTidyMerge(逐条复原);
   - 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
   - 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。

2) 留存闸改组合口径(治根)+ N6/N7:
   - 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
     (数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
   - N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
     这类偏好不再因寒暄词被判临时);
   - 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
     硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
   - 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
     已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
     存量一条不补写(用例钉住)。

契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。

实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
  89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
  这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
  簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。

验收:go build ./... + go vet ./... + go test ./... 全绿。
This commit is contained in:
AskaEth committed 2026-10-25 00:08:46 +08:00
1 parent 210c8b999f
commit 6a3f3a329a
20 files changed
+2353 -403

No files matched your search

+202 -305
View File
@@ -1,7 +1,6 @@
package memory
import (
"sort"
"strings"
)
@@ -29,18 +28,22 @@ import (
// 写得**保守**:宁可有噪声漏过闸(那条走 T1 / 长期层),也不把一条真记忆判成临时
// 层(到期会被删)。落实为两道**护栏**:
//
// 1. **正向清单一票否决**:内容里出现任何「长期事实 / 稳定偏好 / 关系里程碑 /
// 项目约定」的信号(复用 tidyHabitMarkers 的「持久」词族 + 本文件的正向词),
// 直接放行 —— 「主人喜欢用喵打招呼」虽然短,但它有「喜欢」,不是寒暄;
// 1. **锚点 + 具体约束的组合放行**(2026-10-24 起;此前是「任一锚点就一票放行」):
// 内容里出现的「长期事实 / 稳定偏好 / 关系里程碑 / 项目约定」信号(复用
// tidyHabitMarkers 的「持久」词族 + 本文件的正向词)必须**配上一条可执行约束**
// (数字 / 禁忌 / 频次规律)才算长期放行 —— 光有「喜欢」只是句泛泛的偏好,
// 光有「约定」也可能只是一次下周的安排。改组合口径的动因是 M27 体检实测:
// 一票放行让 64.6% 的条目必然过闸、`n1`~`n5` 在生产**一次都没命中过**;
// 2. **显式「记住」一票否决**:内容里出现「记住 / 记下 / 别忘了」时放行 ——
// 主人明确要求记的东西,闸不许替他改成临时记忆。
//
// 一处**刻意不实现**(如实登记,不是遗漏):design/03 §10.5 的 **N6(助手自己的
// 输出 / 发言人归属)**。它是**来源属性**(这句话是谁说的),不是内容属性;本闸
// 只拿到落库条目(结构化溯源里没有「谁说的」这一位),无法机器判定。归属由 B 级
// prompt 的负例承担(「不要记助手自己说过的话」),上游提取侧若要硬判,需另立
// 结构化字段 —— 那超出本切片授权。**N7(不确定推断)**同理交给 T1(§10.5 明写
// 「拿不准交第五态」),不进本闸。
// 反例规则(design/03 §10.5 的 N1~N5 + 2026-10-24 新增的 N6/N7)见各自函数的注释。
// 注意 **N6/N7 是文件内的新编号**,与 design/03 §10.5 的 N6(助手自己的输出)、
// N7(不确定推断)**不是一回事**:那两条的落点仍是「不实现 / 交 T1」——
// - **N6 发言人归属**是**来源属性**(这句话是谁说的),不是内容属性;本闸只拿到
// 落库条目(结构化溯源里没有「谁说的」这一位),无法机器判定。归属由 B 级
// prompt 的负例承担(「不要记助手自己说过的话」);
// - **N7 不确定推断**交给 T1(§10.5 明写「拿不准交第五态」),不进本闸。
// retentionRule 是一条提取留存反例规则的匹配结果(留痕/日志用;`retentionNone`
// 表示「不是显然不值得记的」)。
@@ -59,6 +62,15 @@ const (
retentionTimelyData retentionRule = "n4_timely_data"
// retentionOneOffTask 对应 **N5 一次性任务指令**。
retentionOneOffTask retentionRule = "n5_one_off_task"
// retentionTimepoint 对应 **N6 指示性时点**(2026-10-24 新增):正文的锚点是
// 一个**正在过去或即将到来**的时刻(昨天/今天/明天/下周/最近…),且没有习惯词、
// 没有数值、没有约束、没有硬性长期事实词。它是「一次性事件」的形态判据
// (M27 体检报告 §4 的 R4),因此落 `defer`(临时层,7 天后到期)。
retentionTimepoint retentionRule = "n6_timepoint"
// retentionInProgress 对应 **N7 进行中状态**(2026-10-24 新增):正文说的是
// 「正在/暂时 做某件事」(正在测通道、暂时搁置、正在慢慢想),同样是时效轴上的
// 状态而不是长期事实。安全阀与 N6 完全一致(见 judgeRetentionInProgress)。
retentionInProgress retentionRule = "n7_in_progress"
)
// retentionLowValueReason 是第五态在**留痕 `reason` 列**上的编码(而不是上面那些
@@ -75,10 +87,19 @@ const (
// retentionMaxImperativeRunes 是一次性指令的长度上限。它比前两者宽:
// 「帮我把 D 盘那个文档转成 pdf」这类真·一次性任务本来就长一些。
retentionMaxImperativeRunes = 40
// retentionTimepointLeadRunes 是 N6 的**位置阈值**:指示性时点词必须出现在
// 正文前这么多个字之内(rune 计)才算「这句话的锚点」。
//
// 12 不是拍的:M27 语料实测下,放宽到 20 字只多 4 条命中、但开始把
// 「用户是上班族,需要上班(曾说明天要上班…)」这类**引语里的时点**算成锚点;
// 收紧到 8 字则把「用户昨天发来…」之外的合法形态(「助手计划下周…」)挡掉。
// 12 字是「主谓 + 时点」的典型长度,也是 R2 用同一个 N 的口径。
retentionTimepointLeadRunes = 12
)
// judgeExtractionRetention 对一条新记忆的正文施加提取留存规则(design/03 §10.5
// 的 N1~N5)。命中返回该规则码(调用方据此判 `defer`),否则返回 retentionNone。
// 的 N1~N5 与 M34 新增的 N6/N7)。命中返回该规则码(调用方据此判 `defer`),
// 否则返回 retentionNone。
//
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码,
// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。
@@ -90,31 +111,80 @@ func judgeExtractionRetention(content string) retentionRule {
// 判断该不该存在」—— 判成 defer 才是替它做决定)。
return retentionNone
}
if len([]rune(text)) > retentionMaxImperativeRunes {
// 长内容一律放行:它已经装得下可检索的事实,闸只拦「显然不值得记」。
return retentionNone
}
// 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。
// 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。它排在最前,因此
// 组合口径(护栏②)不会把主人的直接指令改成临时记忆。
if containsAny(text, retentionRememberDirectives()) {
return retentionNone
}
// 护栏②:任何「持久」信号(习惯 / 稳定偏好 / 约定 / 长期事实)一票放行。
if containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionDurableMarkers()) {
// 护栏②(2026-10-24 改**组合口径**,M27 体检报告 §7 第 5 批-1):以前是
// 「含习惯词 **或** 含长期事实词 ⇒ 一票放行」,实测 3657 条里 2363 条
// (64.6%)必然过闸、`n1`~`n5` 生产命中 0 次 —— 闸因此形同虚设。
//
// 现在只有「**锚点词 且 含具体约束**」才一票放行:光有「喜欢/约定」这种
// 泛泛的锚点、却没有任何可执行约束(数字 / 禁忌 / 频次规律)的句子,仍然
// 交给下面更窄的规则判 —— 它可能是「喜欢(一条泛泛的偏好)」,也可能只是
// 一次「约定(下周留个午后)」的临时安排。
//
// 🔴 方向仍是 fail-safe:下面所有规则都不命中时**放行**,因此「有锚点但没
// 约束」的句子绝大多数照旧进长期层(实测 1841 条里只有 88 条最终被判临时)。
anchor := retentionHasAnchor(text)
if anchor && retentionHasConcreteConstraint(text) {
return retentionNone
}
// N1~N5 的**长度安全阀**:它们都建立在「短到装不下一个事实」上。长内容一律
// 不进这四条规则(但可以进 N6/N7 —— 一次性事件与长度无关)。
short := len([]rune(text)) <= retentionMaxImperativeRunes
// **情绪先于寒暄**是刻意的:「心情不错」既是情绪陈述、又含致谢词表里的
// 「不错」——先判情绪才能给出更具体的规则码(两条规则都判 defer,差别只在
// 「为什么」,而那正是留痕/日志要回答的)。
if rule := judgeRetentionEmotion(text); rule != retentionNone {
//
// N2 是**形态匹配**(整句必须恰好是纯情绪),因此它在有锚点时同样安全:
// 锚点词(喜欢/习惯/约定)不在它的白名单里,必然消费不干净 ⇒ 放行。
if short {
if rule := judgeRetentionEmotion(text); rule != retentionNone {
return rule
}
}
// N1/N3/N4/N5 是**词项匹配**(寒暄词 / 祈使开头 / 一次性动作词),一个词就
// 开火。因此它们只在**没有任何锚点**时生效:「主人喜欢说晚安」含寒暄词,但
// `喜欢` 说明它在陈述偏好不好寒暄;「提醒我每天喝水」含祈使开头,但 `每天`
// 说明主人要的是一条长期规矩。有锚点就交给护栏②与 N6/N7 判,这几条不许开火。
if short && !anchor {
if rule := judgeRetentionChatter(text); rule != retentionNone {
return rule
}
if rule := judgeRetentionTimelyData(text); rule != retentionNone {
return rule
}
if rule := judgeRetentionOneOff(text); rule != retentionNone {
return rule
}
}
// N6/N7:**指示性时点 / 进行中状态**的窄规则(2026-10-24 新增,M27 §4 R4)。
// 它们与 N1~N5 同性质(纯词项 + 形态),但判的是「这份记忆的锚点是一个正在
// 过去/即将到来的时刻」,而不是「这句话没有信息」。判据与安全阀见各自函数。
if rule := judgeRetentionTransient(text); rule != retentionNone {
return rule
}
if rule := judgeRetentionChatter(text); rule != retentionNone {
return rule
}
if rule := judgeRetentionTimelyData(text); rule != retentionNone {
return rule
}
return judgeRetentionOneOff(text)
return retentionNone
}
// retentionHasAnchor 报告正文里有没有「这条记忆可能长期成立」的锚点词
// (习惯族 tidyHabitMarkers + 长期事实族 retentionDurableMarkers)。
//
// 它**只是锚点,不是放行证**:锚点必须配上具体约束才算长期(护栏②),
// 而 N1/N3/N4/N5 这几条词项规则反过来不许在锚点存在时开火(见上)。
func retentionHasAnchor(text string) bool {
return containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionDurableMarkers())
}
// retentionHasConcreteConstraint 报告正文里有没有**可执行的约束**:数值、禁忌
// (不要/禁止/不许/必须…)、频次与时间规律(每天/每周/按时/定期…)。
//
// 它守住护栏②的「具体」二字:只有约束才是「这份记忆将来会被用上」的证据 ——
// 「主人喜欢猫」是偏好但没有可执行约束,「主人每天只喂小茂密一次」才有。
func retentionHasConcreteConstraint(text string) bool {
return hasRetentionDigit(text) || containsAny(text, retentionConstraintMarkers())
}
// judgeRetentionChatter 施加 **N1**:短且只由寒暄/致谢词构成的内容。
@@ -136,141 +206,6 @@ func judgeRetentionChatter(text string) retentionRule {
return retentionChatter
}
// judgeRetentionEmotion 施加 **N2**:**纯**情绪陈述(「今天好累」「心情不错」)。
//
// 🔴 判据是**白名单形态匹配**,不是黑名单剥离。这是本规则唯一正确的形态,理由:
//
// - 「实体」(宾语)的词表是**开放集合**—— 想我 / 想我们 / 想他 / 想和好 / 想妈妈
// / 听音乐 / 咬指甲 / 养猫 / …,永远列不全;
// - 而「纯情绪句」的**形态是封闭的**:`[主语]? [时间]? [程度]* 情绪词 [程度|否定|情绪|语气]* [标点]*`,
// 且**没有其它成分**。
//
// ⇒ 判据 = 「**整句恰好落在上面这个形态里**才 defer,其余一律放行」。
//
// **为什么它在结构上不可能把带实体的句子判成 defer**(不是"概率很低",是结构性):
//
// 1. 匹配要求**整句被消费干净**(`retentionEmotionFormRest` 返回空余部)。任何
// 一个**非标点 / 非空白**的字符只要不在词表里,就会留在余部 ⇒ 判不出空 ⇒
// **放行**。(**标点与空白是被容忍的**:它们由 `onlyRetentionPunctuation` 在收尾
// 时清掉,所以 `心情不错!` 实测 `rest=""` ⇒ 仍判 defer。)
//
// 2. **位置锁定 + 核心之后只允许四类修饰** —— 这才是真正保安全的一条。
// (早先这里写的是「封闭表里没有承载内容的词」,**那句被 reviewer 证伪**
// —— 表里确实有:`心情` 是名词,`生气`/`害怕`/`紧张`/`没有`/`比较` 可作动词,
// `压力大` 是动宾,而主语表里的 `主人`/`我`/`他` 本身就是指人实体。)
//
// 真正的机制是**位置**而不是"表里没有实词":
// - **位置锁定**(按实现写):主语只在**位置 0** 被消费一次;时间词只在**紧随其后**
// 被消费一次(无主语时它就是首词)。两者都**没有**「后面还能再出现」的缝 ——
// `主人今天好累` 匹配(`rest=""`),而 `今天主人好累` 连核心都认不出
// (`rest="今天主人好累"`, `saw=false` ⇒ 放行)。⇒ `主人`/`我`/`你`/`他`/`今天`
// 这些**指人 / 指时的实体在文法里永远不可能落到宾语位置**(上一版正是死在
// 这里:`会想我` 的 `我` 被当成主语剥掉了);
// - 核心之后只允许「程度 / 否定 / 情绪 / 语气」四类 token,而它们**只允许紧跟
// 在情绪核心之后** —— 核心本身必须先被消费掉,因此那一段在语义上只能是
// **情绪的修饰**(「心情**不错**」「**不**开心」「心情**很好**」,三条均实测
// `rest=""`;反例「生气**得很**」的 `得` 不在表里 ⇒ `rest="得很"` ⇒ 放行),
// 装不下一个宾语短语。核心之后一旦出现四类之外的东西(`想`/`会`/`时`/`和`/
// `听音乐`/`妈妈`),**必然消费不掉** ⇒ 余部非空 ⇒ 放行。
//
// 3. 失败方向是**安全**的:认不出的形态一律**放行**(留进长期层,有 T1 与整理兜底),
// 而**绝不**误弃。上一版(黑名单剥离)恰好相反 —— 它把「能当实体的词」也列进
// 剥离表,于是 `主人不开心时会想我` 被剥成空、判成 defer,而那条是**持久习惯**,
// 判 defer 即 7 天后从所有读路径消失、随后被后台清扫**物理删除**(不可逆)。
// 黑名单那种形态**修不完**(review 举证 `会想和好` 被 `和`+`好` 吃掉),
// 白名单把失败模式从「未知实体被吞」翻成「未知情绪句没认出」,方向才正确。
//
// 🔴 **后缀表不得加入承载内容的词**(名词 / 动词 / 量词 / 新的指人词)。上面第 2 条
// 的整条安全性都建立在「四类之外一律消费不掉」上:往后缀表里塞一个名词,就会**静默**
// 把「情绪 + 该名词作宾语」的持久事实判成 defer,而且**不会有任何行为测试变红**
// (除非那条输入恰好在语料里)—— 这正是 review 点出的风险。两条 tripwire 钉住它:
//
// - `TestExtractionRetention_SuffixTableIsPinned`:把后缀表的**内容**逐 token 钉死,
// **任何增删都立刻红**(改表必须显式改这份 golden ⇒ 变成一次可 review 的意图声明);
// - `TestExtractionRetention_PersistentFactsAreNeverDeferred`:25 条持久事实/习惯
// 语料逐条断言「绝不 defer」,钉住**语义后果**。
//
// 两条一起才够:golden 钉「表变了」,语料钉「变了以后语义还对不对」。
//
// 长度不单独设限:全局 40 rune 的上限(judgeExtractionRetention)已经兜住。
func judgeRetentionEmotion(text string) retentionRule {
rest, sawEmotion := retentionEmotionFormRest(text)
if !sawEmotion || rest != "" {
// 没认出情绪核心(与 N2 无关),或**没被消费干净**(含形态外的成分,
// 也就是"不只是情绪")⇒ 放行。两个分支都是安全方向。
return retentionNone
}
return retentionEmotion
}
// retentionEmotionFormRest 尝试把 text 按「纯情绪形态」消费掉,返回
// (未能消费的余部, 是否消费到情绪核心)。**只有余部为空且消费到情绪核心**才意味着
// 「整句恰好是纯情绪陈述」。
//
// 形态(顺序固定,各段均按**最长词优先**匹配):
//
// [主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]* 余部须只剩标点/空白
//
// 余部同时是**可断言的证据**:带宾语的句子在这里必定留下非空余部(测试钉住它)。
func retentionEmotionFormRest(text string) (string, bool) {
rest := text
rest, _ = consumeRetentionPrefix(rest, retentionEmotionSubjects())
rest, _ = consumeRetentionPrefix(rest, retentionEmotionTimeWords())
rest = consumeRetentionPrefixes(rest, retentionEmotionDegreeWords())
core, ok := consumeRetentionPrefix(rest, retentionEmotionWords())
if !ok {
// 认不出情绪核心 ⇒ sawEmotion=false,调用方放行。
return text, false
}
rest = consumeRetentionPrefixes(core, retentionEmotionSuffixTokens())
if onlyRetentionPunctuation(rest) {
rest = ""
}
return rest, true
}
// consumeRetentionPrefix 在 text 的开头按**最长词优先**消费 tokens 里的一个词,
// 返回余部与「是否消费到」。最长优先是必须的:`不` 与 `不错`、`好` 与 `好累` 同时
// 命中时,短词先消费会把长词拆开,于是纯情绪句反而消费不干净、漏判。
func consumeRetentionPrefix(text string, tokens []string) (string, bool) {
best := ""
for _, tok := range tokens {
if tok == "" || !strings.HasPrefix(text, tok) {
continue
}
if len(tok) > len(best) {
best = tok
}
}
if best == "" {
return text, false
}
return text[len(best):], true
}
// consumeRetentionPrefixes 反复在句首消费,直到再也匹配不上(用于「可重复修饰」段)。
func consumeRetentionPrefixes(text string, tokens []string) string {
for {
next, ok := consumeRetentionPrefix(text, tokens)
if !ok {
return text
}
text = next
}
}
// onlyRetentionPunctuation 报告 text 是否只由标点与空白构成(消费干净的最后一步:
// 「今天好累!」剥到只剩一个标点,仍是「无实体」)。
func onlyRetentionPunctuation(text string) bool {
for _, r := range text {
if !strings.ContainsRune(retentionPunctuationRunes, r) {
return false
}
}
return true
}
// judgeRetentionTimelyData 施加 **N4**:时效性查询(当前时间 / 天气)。
//
// 它的词项是**整句形态**而不是单词(「几点」「天气」),因为「天气」单独出现可能
@@ -299,6 +234,112 @@ func judgeRetentionOneOff(text string) retentionRule {
return retentionNone
}
// judgeRetentionTransient 施加 **N6 / N7**:时效轴上的两种窄形态(2026-10-24)。
//
// 动因是 M27 只读体检的两条实测(报告 §4 R4 / §5.3):留存闸的 `defer` 在全历史
// 只命中过 1 次,`expires_at` 非空只有 1 行 ⇒「临时记忆」这条时效轴在生产中
// **一次都没跑起来**,而 active 里「指示性时点词 ⇒ 一次性事件」的条目约占 6%。
//
// 判据与**安全阀**(每一条都是「宁可漏判,不可误弃」,design/23 §18.3):
//
// 1. **有习惯词 ⇒ 不判临时**。习惯词(通常/经常/每天/喜欢…)说的是「反复发生」,
// 哪怕句子里同时出现「今天」(「今天又被加班拖住了,她**通常**十点后才有空」);
// 2. **有硬性长期事实词 ⇒ 不判临时**(住/职业/工作/生日/过敏/称呼/项目/端口…);
// 这条防的是「用户目前在花溪工作」被「目前」两个字误判 —— 那是**地点事实**,
// 不是一次性事件。`约定/承诺/答应/说好` **不在**这条硬名单里:它们是
// 「锚定到某个时刻的安排」,配上时点正是典型的临时记忆(「约定下周留个午后」),
// 而它们要算长期仍走护栏②(约定 + 具体约束);
// 3. **有数值 ⇒ 不判临时**(「一两千行的验收纸条」这类数值是事实的载体,
// 与 N1 的数值安全阀同口径);
// 4. **有具体约束 ⇒ 不判临时**(数字/禁忌/频次,与护栏②同一个判据);
// 5. **时点词必须在句首前 12 个字内**(N6 独有)。实测这一条把误杀从 ~10% 降到
// ~0:`用户是上班族,需要上班(曾说明天要上班并感到不开心)`、`睡前会道晚安…
// 并约好「明天中午见」` 这类「时点词只出现在引语/从句里」的长期事实,锚点
// 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。
//
// **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终
// (正在测通道、暂时搁置、正在慢慢想),安全阀 1~4 与 N6 完全一致。
//
// 🔴 **刻意不做的事**(M27 §4 的实测反例,别再重新发明):
// - 不做**长度阈值**筛(R3:≤15 字的条目抽 8 条 8/8 是可复用事实);
// - 不做**寒暄/情绪词表**筛(R5:10 条里 9 条是关系事实);
// - 不做**一次性事件动词表**(曾/发来/分享…):实测会命中「助手曾经历三千万次
// 轮回」「用户曾发来『i love you』文件」「海报就是助手本人」这类**长期身份与
// 关系里程碑**(抽检误杀 ≈ 50%),方向错误。
func judgeRetentionTransient(text string) retentionRule {
// 安全阀 1~4:与 N1~N5 不同,它们在这里是**硬门**(命中即放行),因为 N6/N7
// 判的是「这条记忆的锚点是一个时刻」,而锚点词/数值/约束都在说「它不止是一个时刻」。
if containsAny(text, tidyHabitMarkers()) {
return retentionNone
}
if containsAny(text, retentionHardDurableMarkers()) {
return retentionNone
}
if retentionHasConcreteConstraint(text) {
return retentionNone
}
// 安全阀 5:N6 **不接管 N1/N2 的语料**。含寒暄/致谢词或情绪词的句子归那两条
// 规则判(N1 有它的长度安全阀,N2 有形态匹配),N6 不越过它们改判:
// - 「谢谢你今天特地提醒我这件事,真是帮了大忙了」是社交致谢,不是事件记录;
// - 「主人最近很容易焦虑」是**情绪倾向**(长期事实),不是「最近发生了一件事」。
// 实测这两条是单纯加时点判据时唯一的两个既有正例回归。
if containsAny(text, retentionChatterWords()) || containsAny(text, retentionEmotionWords()) {
return retentionNone
}
if judgeRetentionTimepoint(text) != retentionNone {
return retentionTimepoint
}
if containsAny(text, retentionInProgressMarkers()) {
return retentionInProgress
}
return retentionNone
}
// judgeRetentionTimepoint 是 N6 的形态判定:正文里**句首前 N 个字内**出现一个
// 指示性时点词。位置判据是这条规则精度的来源(见 judgeRetentionTransient 安全阀 5)。
func judgeRetentionTimepoint(text string) retentionRule {
if idx := retentionFrontedTimepoint(text); idx >= 0 && idx <= retentionTimepointLeadRunes {
return retentionTimepoint
}
return retentionNone
}
// retentionFrontedTimepoint 返回第一个指示性时点词在 text 里的**rune 下标**;
// 没有时点词时返回 -1。调用方再用 retentionTimepointLeadRunes 判它是否「句首」。
//
// 用 rune 下标而不是字节下标是必须的:中文一个字 3 字节,按字节算会把「前 12 字」
// 变成「前 4 字」,位置判据当场失效。
func retentionFrontedTimepoint(text string) int {
runes := []rune(text)
best := -1
for _, marker := range retentionTimepointMarkers() {
idx := runeIndex(text, marker)
if idx < 0 {
continue
}
if best < 0 || idx < best {
best = idx
}
}
if best < 0 || best >= len(runes) {
return -1
}
return best
}
// runeIndex 返回子串 sub 在 text 里的**rune 下标**(找不到返回 -1)。它只在很短的
// 时点词表上调用(每次至多几十次 strings.Index),因此不必预编译。
func runeIndex(text, sub string) int {
if sub == "" {
return -1
}
byteIdx := strings.Index(text, sub)
if byteIdx < 0 {
return -1
}
return len([]rune(text[:byteIdx]))
}
// normalizeRetentionText 规范化正文供词项匹配:转小写(对 ASCII 词如 `hi` 有用,
// 中文不受影响)、把连续空白折叠成**一个空格**、去掉结尾的标点。
//
@@ -340,147 +381,3 @@ func hasRetentionDigit(text string) bool {
}
return false
}
// 下列词表一律是**函数**而不是包级变量(AGENTS §10 禁包级全局变量;
// 同 tidySystemSessions / tidyHabitMarkers 的手法)。
// retentionRememberDirectives 是「主人明确要求记住」的信号(护栏①)。
func retentionRememberDirectives() []string {
return []string{"记住", "记下", "别忘了", "不要忘", "别忘记"}
}
// retentionDurableMarkers 是正向清单(design/03 §10.4)的词族里、**不被
// tidyHabitMarkers 覆盖**的那些:长期事实 / 关系里程碑 / 项目约定 / 能力边界。
func retentionDurableMarkers() []string {
return []string{
// 长期事实
"生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母",
"过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄",
// 关系里程碑 / 约定
"约定", "承诺", "答应", "说好", "暗号", "口令", "称呼",
// 项目约定
"项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成",
// 能力边界(主人明确的「不要做 X」)
"不要做", "别做", "禁止", "不许",
// 稳定偏好(补 tidyHabitMarkers 之外的)
"口味", "爱好", "讨厌", "不喜欢", "偏爱", "嗜好",
}
}
// retentionChatterWords 是 N1 的寒暄 / 致谢词表。
func retentionChatterWords() []string {
return []string{
"谢谢", "多谢", "感谢", "辛苦", "麻烦你了", "早上好", "早安", "中午好",
"下午好", "晚上好", "晚安", "你好", "您好", "在吗", "收到", "好的",
"好呀", "好嘞", "嗯嗯", "哈哈", "嘿嘿", "呵呵", "么么", "拜拜", "再见",
"加油", "不错", "真棒", "太棒了", "hi", "hello", "hey", "thx", "thanks",
}
}
// retentionEmotionWords 是 N2 的情绪词表。
func retentionEmotionWords() []string {
return []string{
"好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大",
"心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈",
"烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞",
// 「不错」是**情绪评价**(「心情不错」),因此它必须在**核心词表**里按整词
// 出现:只靠否定词表里的「不」,`不错` 会被拆成 `不` + 光杆「错」,于是
// 「心情不错」既认不出核心、也消费不干净 —— 一条纯情绪句反而漏判。
"不错",
// 光杆「累」同理:「主人今天真的特别累」只有一个 `累` 而**不含** `好累` /
// `累了`,漏掉它就会留下一个消费不掉的「累」、把一条纯情绪句放行。
// 收它**不会**带来误弃 —— 判 defer 的门槛恒是「整句被形态消费干净」,
// 多收一个情绪词只会让**本该 defer 的**句子认得出来。
"累",
}
}
// retentionEmotionSuffixTokens 是「情绪核心之后**允许**出现的修饰」的封闭集合:
// 程度 / 否定 / 情绪 / 语气。**它不含任何承载内容的词** —— 这正是白名单判据在
// 结构上不会吞掉实体的原因(核心之后一旦出现 `想`/`会`/`时`/`和`/`我` 这类字,
// 就必然消费不掉、留下非空余部 ⇒ 放行)。
//
// 它是函数而不是包级变量(AGENTS §10 禁包级全局变量;同 tidyHabitMarkers 的手法);
// 长度先按**最长词优先**排序,`不` 不会抢在 `不错` 前面被消费。
func retentionEmotionSuffixTokens() []string {
tokens := make([]string, 0, 64)
tokens = append(tokens, retentionEmotionDegreeWords()...)
tokens = append(tokens, retentionEmotionNegations()...)
tokens = append(tokens, retentionEmotionWords()...)
tokens = append(tokens, retentionEmotionToneWords()...)
sort.SliceStable(tokens, func(i, j int) bool {
return len(tokens[i]) > len(tokens[j])
})
return tokens
}
// retentionEmotionTimeWords 是**只允许在「主语之后」出现一次**的时间词:无主语时
// 它就是首词(「今天好累」),有主语时紧跟主语(「主人今天好累」)—— 之外的位置一律
// 不匹配(`今天主人好累` 实测 `rest="今天主人好累"`, `saw=false` ⇒ 放行)。
// 它们让一句情绪陈述显形为「今天怎么样」,本身不含实体。
func retentionEmotionTimeWords() []string {
return []string{
"今天", "明天", "昨天", "今晚", "昨晚", "今早", "此刻", "现在", "刚才", "刚刚",
"最近", "这两天", "这几天", "这阵子", "这段时间", "目前", "当下", "一整天",
}
}
// retentionEmotionSubjects 是**只允许出现在句首**的主语 / 称呼。把它限死在句首是
// 白名单判据的要害之一:`我`/`他`/`我们` 这些词**本身可以当实体**(「会想我」的
// 宾语就是它),若允许它们在任意位置被消费,`主人不开心时会想我` 就会被吞成空。
func retentionEmotionSubjects() []string {
return []string{
"主人", "咱们", "我们", "你们", "他们", "她们", "自己",
"我", "你", "他", "她", "它", "咱",
}
}
// retentionEmotionDegreeWords 是程度词(只修饰强度,不引入实体)。
func retentionEmotionDegreeWords() []string {
return []string{
"非常", "特别", "稍微", "真的", "真是", "十分", "比较", "有点", "有些",
"很", "挺", "太", "好", "超", "蛮", "更", "最",
}
}
// retentionEmotionNegations 是情绪核心之后的否定词(「心情**不**好」)。它只能出现在
// 后缀位;`不` 与 `没` 都不承载实体。
func retentionEmotionNegations() []string {
return []string{"没有", "不", "没"}
}
// retentionEmotionToneWords 是句尾的语气词(「好累**啊**」「不错**了**」)。它们只出现
// 在句尾,不引入实体。
func retentionEmotionToneWords() []string {
return []string{"啊", "呀", "啦", "哦", "喔", "嘛", "呢", "吧", "了", "的", "哎", "唉", "嗯", "哈"}
}
// retentionPunctuationRunes 是「消费干净」判定允许剩下的标点与空白。
const retentionPunctuationRunes = ",。!?、;:…~~·--,.!?;:()() \t\n"
// retentionTimelyQueries 是 N4 的时效性**查询整句**词表(不是单词,理由见
// judgeRetentionTimelyData)。
func retentionTimelyQueries() []string {
return []string{
"现在几点", "几点了", "现在时间", "当前时间",
"今天天气", "明天天气", "天气怎么样", "天气如何", "天气怎样",
"温度多少", "多少度", "气温多少",
}
}
// retentionAskPrefixes 是 N3 的祈使开头词表。
func retentionAskPrefixes() []string {
return []string{
"帮我", "请帮", "麻烦帮", "帮个忙", "提醒我", "叫我", "替我",
"给我订", "给我买", "帮我查", "帮我搜", "帮我订", "帮我买",
}
}
// retentionOneOffActions 是 N5 的一次性动作词表(位置不限 —— 它们描述的是
// 「转一次文件」这类有始有终的动作,而不是可以长期成立的偏好)。
func retentionOneOffActions() []string {
return []string{
"转成pdf", "转成 pdf", "转换成", "截图", "打开这个", "关掉这个",
"搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟",
}
}
@@ -0,0 +1,148 @@
package memory
import (
"strings"
)
// 本文件是**N2 纯情绪形态**的解析器(design/03 §10.5 N2):把一句正文按
// 「[主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]*」消费掉,只有**消费干净**
// 才算「整句恰好是纯情绪陈述」。
//
// 它从 extract_retention.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)——
// 判据、词表与注释**逐字未动**;词表在 extract_retention_words.go,判定入口在
// extract_retention.go 的 judgeRetentionEmotion。
// judgeRetentionEmotion 施加 **N2**:**纯**情绪陈述(「今天好累」「心情不错」)。
//
// 🔴 判据是**白名单形态匹配**,不是黑名单剥离。这是本规则唯一正确的形态,理由:
//
// - 「实体」(宾语)的词表是**开放集合**—— 想我 / 想我们 / 想他 / 想和好 / 想妈妈
// / 听音乐 / 咬指甲 / 养猫 / …,永远列不全;
// - 而「纯情绪句」的**形态是封闭的**:`[主语]? [时间]? [程度]* 情绪词 [程度|否定|情绪|语气]* [标点]*`,
// 且**没有其它成分**。
//
// ⇒ 判据 = 「**整句恰好落在上面这个形态里**才 defer,其余一律放行」。
//
// **为什么它在结构上不可能把带实体的句子判成 defer**(不是"概率很低",是结构性):
//
// 1. 匹配要求**整句被消费干净**(`retentionEmotionFormRest` 返回空余部)。任何
// 一个**非标点 / 非空白**的字符只要不在词表里,就会留在余部 ⇒ 判不出空 ⇒
// **放行**。(**标点与空白是被容忍的**:它们由 `onlyRetentionPunctuation` 在收尾
// 时清掉,所以 `心情不错!` 实测 `rest=""` ⇒ 仍判 defer。)
//
// 2. **位置锁定 + 核心之后只允许四类修饰** —— 这才是真正保安全的一条。
// (早先这里写的是「封闭表里没有承载内容的词」,**那句被 reviewer 证伪**
// —— 表里确实有:`心情` 是名词,`生气`/`害怕`/`紧张`/`没有`/`比较` 可作动词,
// `压力大` 是动宾,而主语表里的 `主人`/`我`/`他` 本身就是指人实体。)
//
// 真正的机制是**位置**而不是"表里没有实词":
// - **位置锁定**(按实现写):主语只在**位置 0** 被消费一次;时间词只在**紧随其后**
// 被消费一次(无主语时它就是首词)。两者都**没有**「后面还能再出现」的缝 ——
// `主人今天好累` 匹配(`rest=""`),而 `今天主人好累` 连核心都认不出
// (`rest="今天主人好累"`, `saw=false` ⇒ 放行)。⇒ `主人`/`我`/`你`/`他`/`今天`
// 这些**指人 / 指时的实体在文法里永远不可能落到宾语位置**(上一版正是死在
// 这里:`会想我` 的 `我` 被当成主语剥掉了);
// - 核心之后只允许「程度 / 否定 / 情绪 / 语气」四类 token,而它们**只允许紧跟
// 在情绪核心之后** —— 核心本身必须先被消费掉,因此那一段在语义上只能是
// **情绪的修饰**(「心情**不错**」「**不**开心」「心情**很好**」,三条均实测
// `rest=""`;反例「生气**得很**」的 `得` 不在表里 ⇒ `rest="得很"` ⇒ 放行),
// 装不下一个宾语短语。核心之后一旦出现四类之外的东西(`想`/`会`/`时`/`和`/
// `听音乐`/`妈妈`),**必然消费不掉** ⇒ 余部非空 ⇒ 放行。
//
// 3. 失败方向是**安全**的:认不出的形态一律**放行**(留进长期层,有 T1 与整理兜底),
// 而**绝不**误弃。上一版(黑名单剥离)恰好相反 —— 它把「能当实体的词」也列进
// 剥离表,于是 `主人不开心时会想我` 被剥成空、判成 defer,而那条是**持久习惯**,
// 判 defer 即 7 天后从所有读路径消失、随后被后台清扫**物理删除**(不可逆)。
// 黑名单那种形态**修不完**(review 举证 `会想和好` 被 `和`+`好` 吃掉),
// 白名单把失败模式从「未知实体被吞」翻成「未知情绪句没认出」,方向才正确。
//
// 🔴 **后缀表不得加入承载内容的词**(名词 / 动词 / 量词 / 新的指人词)。上面第 2 条
// 的整条安全性都建立在「四类之外一律消费不掉」上:往后缀表里塞一个名词,就会**静默**
// 把「情绪 + 该名词作宾语」的持久事实判成 defer,而且**不会有任何行为测试变红**
// (除非那条输入恰好在语料里)—— 这正是 review 点出的风险。两条 tripwire 钉住它:
//
// - `TestExtractionRetention_SuffixTableIsPinned`:把后缀表的**内容**逐 token 钉死,
// **任何增删都立刻红**(改表必须显式改这份 golden ⇒ 变成一次可 review 的意图声明);
// - `TestExtractionRetention_PersistentFactsAreNeverDeferred`:25 条持久事实/习惯
// 语料逐条断言「绝不 defer」,钉住**语义后果**。
//
// 两条一起才够:golden 钉「表变了」,语料钉「变了以后语义还对不对」。
//
// 长度不单独设限:全局 40 rune 的上限(judgeExtractionRetention)已经兜住。
func judgeRetentionEmotion(text string) retentionRule {
rest, sawEmotion := retentionEmotionFormRest(text)
if !sawEmotion || rest != "" {
// 没认出情绪核心(与 N2 无关),或**没被消费干净**(含形态外的成分,
// 也就是"不只是情绪")⇒ 放行。两个分支都是安全方向。
return retentionNone
}
return retentionEmotion
}
// retentionEmotionFormRest 尝试把 text 按「纯情绪形态」消费掉,返回
// (未能消费的余部, 是否消费到情绪核心)。**只有余部为空且消费到情绪核心**才意味着
// 「整句恰好是纯情绪陈述」。
//
// 形态(顺序固定,各段均按**最长词优先**匹配):
//
// [主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]* 余部须只剩标点/空白
//
// 余部同时是**可断言的证据**:带宾语的句子在这里必定留下非空余部(测试钉住它)。
func retentionEmotionFormRest(text string) (string, bool) {
rest := text
rest, _ = consumeRetentionPrefix(rest, retentionEmotionSubjects())
rest, _ = consumeRetentionPrefix(rest, retentionEmotionTimeWords())
rest = consumeRetentionPrefixes(rest, retentionEmotionDegreeWords())
core, ok := consumeRetentionPrefix(rest, retentionEmotionWords())
if !ok {
// 认不出情绪核心 ⇒ sawEmotion=false,调用方放行。
return text, false
}
rest = consumeRetentionPrefixes(core, retentionEmotionSuffixTokens())
if onlyRetentionPunctuation(rest) {
rest = ""
}
return rest, true
}
// consumeRetentionPrefix 在 text 的开头按**最长词优先**消费 tokens 里的一个词,
// 返回余部与「是否消费到」。最长优先是必须的:`不` 与 `不错`、`好` 与 `好累` 同时
// 命中时,短词先消费会把长词拆开,于是纯情绪句反而消费不干净、漏判。
func consumeRetentionPrefix(text string, tokens []string) (string, bool) {
best := ""
for _, tok := range tokens {
if tok == "" || !strings.HasPrefix(text, tok) {
continue
}
if len(tok) > len(best) {
best = tok
}
}
if best == "" {
return text, false
}
return text[len(best):], true
}
// consumeRetentionPrefixes 反复在句首消费,直到再也匹配不上(用于「可重复修饰」段)。
func consumeRetentionPrefixes(text string, tokens []string) string {
for {
next, ok := consumeRetentionPrefix(text, tokens)
if !ok {
return text
}
text = next
}
}
// onlyRetentionPunctuation 报告 text 是否只由标点与空白构成(消费干净的最后一步:
// 「今天好累!」剥到只剩一个标点,仍是「无实体」)。
func onlyRetentionPunctuation(text string) bool {
for _, r := range text {
if !strings.ContainsRune(retentionPunctuationRunes, r) {
return false
}
}
return true
}
@@ -0,0 +1,216 @@
package memory
import (
"context"
"testing"
"time"
)
// 本文件是留存闸 **N6(指示性时点)/ N7(进行中状态)** 与**组合护栏②**
// (2026-10-24,M34)的验收用例集。
//
// 两条不对称的事实决定了这个文件的写法:
// - 漏判只是「库里多一条噪声」(T1 与整理会兜底);
// - 误判会把一条真记忆丢进临时层 —— 7 天后从所有读路径消失,再被后台清扫
// **物理删除**(不可逆)。
//
// 因此这里的**正例(必须放行)比负例(必须拦下)更硬**:`retentionLongTermCases`
// 里的每一条都是真实语料里「带时点词但仍然长期成立」的句子,它们逐条钉住
// 「误杀真实长期记忆比漏放新记忆更严重」这条口径。
// retentionTimepointCases 是 N6 的正例:**锚点是一个正在过去/即将到来的时刻**,
// 且没有习惯词、没有硬性长期事实词、没有数值/约束 —— 它们该进临时层。
func retentionTimepointCases() []string {
return []string{
"用户昨天发送了一份长达一万两千行的验收纸条",
"用户近期在测试一条通道,今天来回测了三轮",
"用户最近在看一部年代/审讯题材的影视作品,台词「我肯定能拿下口供」",
"计划下周为主人留一个安静的午后",
"用户今晚睡宿舍,不在家",
// 约定类:锚定到某个时刻的安排(约定本身不是长期事实,配上时点才是临时记忆)。
"用户与助理约定明天中午见",
}
}
// retentionLongTermCases 是**回归护栏**:真实语料里「带时点词但长期成立」的句子,
// 一条都不许判 defer。每一行都对应一次实测误杀(谁把它们放进来,就是谁在丢记忆)。
func retentionLongTermCases() []struct {
name string
content string
} {
return []struct {
name string
content string
}{
// 时点词埋在**引语/从句**里,不是这句话的锚点(位置安全阀)。
{"上班族习惯(引语里的明天)", "用户是上班族,需要上班(曾说明天要上班并感到不开心)"},
{"睡前习惯(从句里的明天见)", "用户睡前会道「晚安、明天见」,人家会替她掀被角、调暗灯光,用数呼吸的方式陪她入睡"},
{"单位排班事实(引语里的今天)", "主人所在单位存在周末/节假日被安排加班的情况,实际排班与公开调休日历并不一致(她自己说「我们今天加班啦」)"},
// 习惯词/硬性长期事实词(地点、职业、住)⇒ 一律长期,标题里带「今天」也不许判临时。
{"地点事实", "用户目前生活在花溪(贵阳花溪区),不在上海上班"},
{"住宿舍(近期)", "用户近期住在宿舍(不在家里),晚间会关心她洗漱水温、睡觉时间等宿舍生活细节"},
{"作息习惯", "主人常在深夜与助手聊天,有熬夜习惯"},
{"长期偏好", "主人喜欢喝冰美式来提神"},
{"称呼约定", "主人希望被称呼为「叶酱」;旧的称呼「喵喵七号」已作废,不要再使用"},
{"关系里程", "用户曾向 Cyrene 发送内容为「i love you」的文件,Cyrene 完整接收并回复「me too」"},
{"人格设定", "助手设定:曾经历三千万次轮回等待用户,并在「记忆之海」中捞回用户的记忆"},
{"海报即本人", "主人曾发来一张海报,画面是粉发紫眼、配蓝玫瑰与月桂叶,并明确说「那张海报上就是你哦」"},
// 情绪倾向 + 寒暄:N6 不接管 N1/N2 的语料(安全阀 5)。
{"情绪倾向", "主人最近很容易焦虑"},
{"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
}
}
// TestExtractionRetention_TimepointIsDeferred 钉住 N6 的正例。
func TestExtractionRetention_TimepointIsDeferred(t *testing.T) {
t.Parallel()
for _, content := range retentionTimepointCases() {
if got := judgeExtractionRetention(content); got != retentionTimepoint && got != retentionInProgress {
t.Fatalf("%q 判成 %q, want %q(锚点是一个时刻 ⇒ 临时层)",
content, got, retentionTimepoint)
}
}
}
// TestRetentionTimepointHitsBySpecificRule 钉住「命中哪一条」:时点归 N6、进行中归 N7。
// 它让两条规则的词表**各自**可被单测,不会一条盖住另一条。
func TestRetentionTimepointHitsBySpecificRule(t *testing.T) {
t.Parallel()
if got := judgeExtractionRetention("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint {
t.Fatalf("时点句 = %q, want %q", got, retentionTimepoint)
}
if got := judgeExtractionRetention("用户正在验证分享确认面板的展开功能"); got != retentionInProgress {
t.Fatalf("进行中句 = %q, want %q", got, retentionInProgress)
}
}
// TestExtractionRetention_RealLongTermFactsSurviveTimepoints 是**误杀回归**:
// 真实长期事实即使带着时点词也必须放行。任何一条变红都意味着「7 天后它会消失」。
func TestExtractionRetention_RealLongTermFactsSurviveTimepoints(t *testing.T) {
t.Parallel()
for _, tc := range retentionLongTermCases() {
if got := judgeExtractionRetention(tc.content); got != retentionNone {
t.Fatalf("长期事实 %q(%s)被判成 %q —— 它会在 7 天后被物理删除(不可逆)",
tc.content, tc.name, got)
}
}
}
// TestExtractionRetention_TimepointMustBeFronted 钉住**位置安全阀**:同一个句子,
// 时点在句首 → defer;把同一时点挪到从句里 → 放行。位置是这条规则精度的来源。
func TestExtractionRetention_TimepointMustBeFronted(t *testing.T) {
t.Parallel()
fronted := "用户昨天发来一卷验收纸条"
buried := "用户与助手讨论过验收纸条的格式(他自己说昨天发来一卷)"
if got := judgeExtractionRetention(fronted); got != retentionTimepoint {
t.Fatalf("时点在句首 = %q, want %q", got, retentionTimepoint)
}
if got := judgeExtractionRetention(buried); got != retentionNone {
t.Fatalf("时点埋在从句里 = %q, want %q(位置安全阀失效会误杀长内容)", got, retentionNone)
}
}
// TestExtractionRetention_ConjunctionIsNotANoOp 钉住护栏②改的是**组合口径**:
// 「约定」这种锚点单独出现时不再一票放行(于是它会被 N6 判成临时安排);
// 但一旦配上具体约束(频次),立刻回到长期层。改回「任一锚点就放行」时,
// 前一半会变红;把组合条件删成「任一命中即放行」时,整条用例会红。
func TestExtractionRetention_ConjunctionIsNotANoOp(t *testing.T) {
t.Parallel()
if got := judgeExtractionRetention("约定下周给用户留一个安静的午后"); got != retentionTimepoint {
t.Fatalf("「约定 + 时点、无约束」= %q, want %q(锚点单独出现不再一票放行)",
got, retentionTimepoint)
}
if got := judgeExtractionRetention("约定每天给主人留一个安静的午后"); got != retentionNone {
t.Fatalf("「约定 + 每天(具体约束)」= %q, want 放行(组合条件命中即长期)", got)
}
if got := judgeExtractionRetention("主人每天要喝 2 杯咖啡"); got != retentionNone {
t.Fatalf("「偏好 + 数值」= %q, want 放行", got)
}
if got := judgeExtractionRetention("主人说过不要在群里提她的工作"); got != retentionNone {
t.Fatalf("「禁忌」= %q, want 放行", got)
}
}
// TestExtractionRetention_TimepointSafetyValves 逐条钉住 N6/N7 的四个安全阀:
// 习惯词 / 硬性长期事实词 / 数值与约束 / 寒暄与情绪词都把它们按回去。
//
// 断言写成「**不是 N6/N7**」而不是「必须放行」是刻意的:安全阀的职责只有一个 ——
// 不让这条句子被 N6/N7 判成临时层。它可能仍被更早的既有规则(N1/N2)判成临时,
// 那是**既有口径**(本条用例不重定义它),也不是回归。
func TestExtractionRetention_TimepointSafetyValves(t *testing.T) {
t.Parallel()
cases := []struct {
content string
why string
}{
{"用户今天又被加班拖住了,她通常十点后才有空", "习惯词(通常)⇒ 反复发生"},
{"用户今天要去医院复查过敏", "硬性长期事实词(过敏)"},
{"用户昨天对花生过敏犯了,吃了药", "硬性长期事实词(过敏 / 吃药)"},
{"用户昨天发了 3 份文件", "数值是事实的载体"},
{"用户最近每天要喝两杯水", "约束词(每天)"},
{"用户昨夜睡得还行,今天心情不错", "情绪词 ⇒ 归 N2 判"},
}
for _, tc := range cases {
got := judgeExtractionRetention(tc.content)
if got == retentionTimepoint || got == retentionInProgress {
t.Fatalf("%q 判成 %q, want 不被 N6/N7 判临时(%s)", tc.content, got, tc.why)
}
}
}
// TestIngestGate_TimepointDefersWithTempTTL 钉住 TTL 的落点:N6 命中 ⇒ 第五态
// `defer`,行照常 active 落库(正文一字不动),`expires_at` = 判定时刻 + 临时 TTL
// (默认 7 天,落 `$DATA_DIR/config/memory-tidy.json` 的 `tempTtlSec`,零新 env 键)。
func TestIngestGate_TimepointDefersWithTempTTL(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
ttl := 7 * 24 * time.Hour
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base))
if exp, want := readIngestExpiry(t, store, got.ID), expiryText(base.Add(ttl)); exp != want {
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 7 天)", exp, want)
}
row := loadTidyItemRow(t, store, got.ID)
if row.status != tidyStatusActive || row.content != "用户昨天发来一卷一万两千行的验收纸条" {
t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row)
}
rev := gateRecordFor(t, store, got.ID)
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
}
}
// TestIngestGate_DeferNeverBackfillsExistingRows 钉住「**TTL 只开新增侧**」:
// 存量行的 `expires_at` 一条都不补写。变异(在 defer 时顺手 UPDATE 整个分区)会红。
func TestIngestGate_DeferNeverBackfillsExistingRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
// 存量行在**接入闸之前**落库:这条用例要看的是「闸跑一轮之后,存量行的
// expires_at 仍然为空」,而不是「存量行当初怎么落的库」。
existing := seedTidyItem(t, store, gateFixtureItem("主人有不吃早饭的习惯,需要提醒每天吃热乎的早饭", base))
r := newGateReviewer(t, store, ReviewConfig{TempTTL: 7 * 24 * time.Hour}, nil)
store.AttachIngestReviewer(r)
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
t.Fatalf("存量行 expires_at = %q, want 空串", exp)
}
fresh := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base.Add(time.Minute)))
if exp := readIngestExpiry(t, store, fresh.ID); exp == "" {
t.Fatalf("新行没被写上 expires_at —— N6 没有生效")
}
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
t.Fatalf("存量行被补写了 expires_at = %q —— TTL 只许开新增侧", exp)
}
var withExpiry int
if err := store.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_items WHERE expires_at <> ''`).Scan(&withExpiry); err != nil {
t.Fatalf("count expires_at: %v", err)
}
if withExpiry != 1 {
t.Fatalf("带 expires_at 的行 = %d, want 恰为 1(只有那条新记忆)", withExpiry)
}
}
@@ -0,0 +1,212 @@
package memory
import "sort"
// 本文件是提取留存规则闸(design/03 §10)的**词表**:每一项都是**函数**而不是
// 包级变量(AGENTS §10 禁包级全局变量;同 tidySystemSessions / tidyHabitMarkers 的手法)。
//
// 它从 extract_retention.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)——
// 词表内容与逐条理由**逐字未动**;判定逻辑在 extract_retention.go。
// 下列词表一律是**函数**而不是包级变量(AGENTS §10 禁包级全局变量;
// 同 tidySystemSessions / tidyHabitMarkers 的手法)。
// retentionRememberDirectives 是「主人明确要求记住」的信号(护栏①)。
func retentionRememberDirectives() []string {
return []string{"记住", "记下", "别忘了", "不要忘", "别忘记"}
}
// retentionDurableMarkers 是正向清单(design/03 §10.4)的词族里、**不被
// tidyHabitMarkers 覆盖**的那些:长期事实 / 关系里程碑 / 项目约定 / 能力边界。
func retentionDurableMarkers() []string {
return []string{
// 长期事实
"生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母",
"过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄",
// 关系里程碑 / 约定
"约定", "承诺", "答应", "说好", "暗号", "口令", "称呼",
// 项目约定
"项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成",
// 能力边界(主人明确的「不要做 X」)
"不要做", "别做", "禁止", "不许",
// 稳定偏好(补 tidyHabitMarkers 之外的)
"口味", "爱好", "讨厌", "不喜欢", "偏爱", "嗜好",
}
}
// retentionChatterWords 是 N1 的寒暄 / 致谢词表。
func retentionChatterWords() []string {
return []string{
"谢谢", "多谢", "感谢", "辛苦", "麻烦你了", "早上好", "早安", "中午好",
"下午好", "晚上好", "晚安", "你好", "您好", "在吗", "收到", "好的",
"好呀", "好嘞", "嗯嗯", "哈哈", "嘿嘿", "呵呵", "么么", "拜拜", "再见",
"加油", "不错", "真棒", "太棒了", "hi", "hello", "hey", "thx", "thanks",
}
}
// retentionEmotionWords 是 N2 的情绪词表。
func retentionEmotionWords() []string {
return []string{
"好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大",
"心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈",
"烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞",
// 「不错」是**情绪评价**(「心情不错」),因此它必须在**核心词表**里按整词
// 出现:只靠否定词表里的「不」,`不错` 会被拆成 `不` + 光杆「错」,于是
// 「心情不错」既认不出核心、也消费不干净 —— 一条纯情绪句反而漏判。
"不错",
// 光杆「累」同理:「主人今天真的特别累」只有一个 `累` 而**不含** `好累` /
// `累了`,漏掉它就会留下一个消费不掉的「累」、把一条纯情绪句放行。
// 收它**不会**带来误弃 —— 判 defer 的门槛恒是「整句被形态消费干净」,
// 多收一个情绪词只会让**本该 defer 的**句子认得出来。
"累",
}
}
// retentionEmotionSuffixTokens 是「情绪核心之后**允许**出现的修饰」的封闭集合:
// 程度 / 否定 / 情绪 / 语气。**它不含任何承载内容的词** —— 这正是白名单判据在
// 结构上不会吞掉实体的原因(核心之后一旦出现 `想`/`会`/`时`/`和`/`我` 这类字,
// 就必然消费不掉、留下非空余部 ⇒ 放行)。
//
// 它是函数而不是包级变量(AGENTS §10 禁包级全局变量;同 tidyHabitMarkers 的手法);
// 长度先按**最长词优先**排序,`不` 不会抢在 `不错` 前面被消费。
func retentionEmotionSuffixTokens() []string {
tokens := make([]string, 0, 64)
tokens = append(tokens, retentionEmotionDegreeWords()...)
tokens = append(tokens, retentionEmotionNegations()...)
tokens = append(tokens, retentionEmotionWords()...)
tokens = append(tokens, retentionEmotionToneWords()...)
sort.SliceStable(tokens, func(i, j int) bool {
return len(tokens[i]) > len(tokens[j])
})
return tokens
}
// retentionEmotionTimeWords 是**只允许在「主语之后」出现一次**的时间词:无主语时
// 它就是首词(「今天好累」),有主语时紧跟主语(「主人今天好累」)—— 之外的位置一律
// 不匹配(`今天主人好累` 实测 `rest="今天主人好累"`, `saw=false` ⇒ 放行)。
// 它们让一句情绪陈述显形为「今天怎么样」,本身不含实体。
func retentionEmotionTimeWords() []string {
return []string{
"今天", "明天", "昨天", "今晚", "昨晚", "今早", "此刻", "现在", "刚才", "刚刚",
"最近", "这两天", "这几天", "这阵子", "这段时间", "目前", "当下", "一整天",
}
}
// retentionEmotionSubjects 是**只允许出现在句首**的主语 / 称呼。把它限死在句首是
// 白名单判据的要害之一:`我`/`他`/`我们` 这些词**本身可以当实体**(「会想我」的
// 宾语就是它),若允许它们在任意位置被消费,`主人不开心时会想我` 就会被吞成空。
func retentionEmotionSubjects() []string {
return []string{
"主人", "咱们", "我们", "你们", "他们", "她们", "自己",
"我", "你", "他", "她", "它", "咱",
}
}
// retentionEmotionDegreeWords 是程度词(只修饰强度,不引入实体)。
func retentionEmotionDegreeWords() []string {
return []string{
"非常", "特别", "稍微", "真的", "真是", "十分", "比较", "有点", "有些",
"很", "挺", "太", "好", "超", "蛮", "更", "最",
}
}
// retentionEmotionNegations 是情绪核心之后的否定词(「心情**不**好」)。它只能出现在
// 后缀位;`不` 与 `没` 都不承载实体。
func retentionEmotionNegations() []string {
return []string{"没有", "不", "没"}
}
// retentionEmotionToneWords 是句尾的语气词(「好累**啊**」「不错**了**」)。它们只出现
// 在句尾,不引入实体。
func retentionEmotionToneWords() []string {
return []string{"啊", "呀", "啦", "哦", "喔", "嘛", "呢", "吧", "了", "的", "哎", "唉", "嗯", "哈"}
}
// retentionPunctuationRunes 是「消费干净」判定允许剩下的标点与空白。
const retentionPunctuationRunes = ",。!?、;:…~~·--,.!?;:()() \t\n"
// retentionTimelyQueries 是 N4 的时效性**查询整句**词表(不是单词,理由见
// judgeRetentionTimelyData)。
func retentionTimelyQueries() []string {
return []string{
"现在几点", "几点了", "现在时间", "当前时间",
"今天天气", "明天天气", "天气怎么样", "天气如何", "天气怎样",
"温度多少", "多少度", "气温多少",
}
}
// retentionAskPrefixes 是 N3 的祈使开头词表。
func retentionAskPrefixes() []string {
return []string{
"帮我", "请帮", "麻烦帮", "帮个忙", "提醒我", "叫我", "替我",
"给我订", "给我买", "帮我查", "帮我搜", "帮我订", "帮我买",
}
}
// retentionOneOffActions 是 N5 的一次性动作词表(位置不限 —— 它们描述的是
// 「转一次文件」这类有始有终的动作,而不是可以长期成立的偏好)。
func retentionOneOffActions() []string {
return []string{
"转成pdf", "转成 pdf", "转换成", "截图", "打开这个", "关掉这个",
"搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟",
}
}
// retentionConstraintMarkers 是「**具体约束**」的词项(护栏②与 N6/N7 共用):
// 禁忌 / 硬性要求 / 频次与时间规律。它是「这条记忆将来会被用上」的可执行证据。
//
// ⚠️ 只收**能落到行动**的词:把「喜欢/讨厌」这类偏好词收进来就等于取消护栏②
// 的意义(它们已经在 tidyHabitMarkers 里当锚点,锚点 + 锚点不构成约束)。
func retentionConstraintMarkers() []string {
return []string{
// 禁忌 / 硬性要求(主人明确的「不要做 X」)
"不要", "不准", "不许", "不能", "禁止", "必须", "一定要", "记得", "别忘",
// 频次 / 时间规律
"每天", "每日", "每晚", "每早", "每周", "每月", "每年", "每个",
"按时", "定期", "定量", "固定",
// 量化边界
"不超过", "至少", "最多", "以内", "不少于",
}
}
// retentionTimepointMarkers 是 N6 的**指示性时点词**(一次性事件的信号)。
//
// 🔴 两张表都**不收**「周期性时段词」:晚上/早上/中午/周末/工作日 —— 它们说的是
// 规律而不是某个时刻,收进来会让人把作息习惯判成一次性事件(M27 §4 R4 实测:
// 把这类词当锚点,误杀从 ~8% 升到 ~35%)。也**不收**「目前/现在/当下/这段时间」
// 这类**当前状态**词:实测它们命中的是「用户目前生活在花溪」「用户现在有工作」
// 这类**地点 / 职业事实**,正是最不该过期的一类。
func retentionTimepointMarkers() []string {
return []string{
"今天", "明天", "后天", "昨天", "前天",
"今晚", "明晚", "昨晚", "今早", "明早",
"刚才", "刚刚",
"这两天", "这几天", "这阵子",
"本周", "这周", "下周", "上周", "下个月", "本月",
"最近", "近期",
}
}
// retentionInProgressMarkers 是 N7 的「进行中状态」词(有始有终的状态,
// 不是长期事实)。它们比时点词更弱,因此 N7 与 N6 共用同一组安全阀。
func retentionInProgressMarkers() []string {
return []string{"正在", "暂时", "眼下", "这会儿"}
}
// retentionHardDurableMarkers 是 N6/N7 的**硬性长期事实词**:命中即放行。
//
// 它是 retentionDurableMarkers **减去「约定/承诺/答应/说好」** 的差集,理由:
// 那四个词是「锚定到某个时刻的安排」,配上时点恰恰是临时记忆(「约定下周留个
// 午后」→ 下周过去就没意义了);而「住/职业/工作/生日/过敏/称呼/项目/端口…」
// 说的是**不会因为一个时刻而过期**的事实,标题里带「今天」也不许判临时。
func retentionHardDurableMarkers() []string {
appointments := map[string]struct{}{"约定": {}, "承诺": {}, "答应": {}, "说好": {}}
markers := make([]string, 0, len(retentionDurableMarkers()))
for _, m := range retentionDurableMarkers() {
if _, ok := appointments[m]; ok {
continue
}
markers = append(markers, m)
}
return markers
}
+60 -12
View File
@@ -202,6 +202,9 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
"auto_reclassify", stats.AutoReclassified,
"auto_eligible", stats.AutoEligible,
"auto_truncated", stats.AutoTruncated,
"dedupe_groups", stats.DedupeGroups,
"dedupe_rows", stats.DedupeRows,
"dedupe_truncated", stats.DedupeTruncated,
}
if len(stats.Truncated) > 0 {
fields = append(fields, "truncated", strings.Join(stats.Truncated, ","))
@@ -305,6 +308,31 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
}
candidates := t.mergeCandidates(archives, pairs, stats)
// dedupe_group(2026-10-24):机械簇(同 tag 集 / 前 12 字相同)**不依赖相似度
// 召回**,因此它跑在可比条目全集上(`comparable`)、**不受**比对截断影响 ——
// 截断是给 O(n²) 的两两余弦用的护栏,不该让机械分簇跟着少看一半条目。
//
// 它排在包含判定**之后**是为了**抑制重复待办**:一对已经拿到 archive/merge
// 候选的条目不再进簇 —— 否则同一对会同时出现「并入超集」与「并入簇存活行」
// 两条候选,第二条应用时必然 skip(source_not_active),面板上看像 bug。
// 覆盖面由前一步的**产物**决定(不是又一套判据),因此抑制是纯粹的减法。
dedupe, dedupeStats := t.dedupeInPartition(ctx, p, comparable, candidates)
stats.DedupeGroups += dedupeStats.Groups
stats.DedupeRows += dedupeStats.Rows
// dedupe 候选追加在既有两类之后:**不改变**归档/合并在候选上限里的优先序
// (那两类是既有的、被验证过的口径;新动作不许把它们挤出上限)。
candidates = append(candidates, dedupe...)
candidates = t.trimPartitionCandidates(p, candidates, len(dedupe), stats)
policy.sink(ctx, candidates, stats)
return nil
}
// mergeCandidates 把包含判定的产物收敛成候选:归档建议在前,merge 在后;
// 被否决的对进否决分布,互不包含的对进 Kept(**不产候选**,2026-10-07 口径)。
func (t *Tidier) mergeCandidates(archives []TidyCandidate, pairs []tidyPair, stats *TidyStats) []TidyCandidate {
candidates := archives
for _, pr := range pairs {
verdict := judgePair(pr.a, pr.b, pr.score)
@@ -321,19 +349,39 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
}
candidates = append(candidates, buildCandidates(pr, verdict, t.now().UTC())...)
}
// 每分区候选上限(design §9)对**全部候选**生效(含归档建议),
// 超出的只登记不入队,并记进 stats.Truncated(绝不静默)。
if len(candidates) > t.cfg.CandidateLimit {
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则
// 判出的,必须能从这里读出被丢掉了多少。
t.log.Warn("memory tidy candidates truncated by per-partition limit",
"partition", p.label(), "candidates", len(candidates), "limit", t.cfg.CandidateLimit)
stats.Skipped += len(candidates) - t.cfg.CandidateLimit
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
candidates = candidates[:t.cfg.CandidateLimit]
return candidates
}
// trimPartitionCandidates 施加每分区候选上限(design §9),并**如实登记**被丢掉的
// 部分:总量进 stats.Skipped 与 Truncated 标签,其中属于 dedupe 段的那部分单独进
// stats.DedupeTruncated(新动作被截断时面板必须看得到,不许「静默少做」)。
//
// dedupeCount 是本批候选**尾部**属于 dedupe 的条数(调用方把 dedupe 追加在最后)。
func (t *Tidier) trimPartitionCandidates(p tidyPartition, candidates []TidyCandidate,
dedupeCount int, stats *TidyStats) []TidyCandidate {
limit := t.cfg.CandidateLimit
if len(candidates) <= limit {
return candidates
}
policy.sink(ctx, candidates, stats)
return nil
dedupeStart := len(candidates) - dedupeCount
// 上限切在 dedupe 段之前 ⇒ 丢掉的**全部**是 dedupe 候选;切在段内 ⇒ 丢掉尾部。
keptDedupe := 0
if limit > dedupeStart {
keptDedupe = limit - dedupeStart
}
if keptDedupe > dedupeCount {
keptDedupe = dedupeCount
}
droppedDedupe := dedupeCount - keptDedupe
stats.DedupeTruncated += droppedDedupe
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则判出的,
// 必须能从这里读出被丢掉了多少。
t.log.Warn("memory tidy candidates truncated by per-partition limit",
"partition", p.label(), "candidates", len(candidates), "limit", limit,
"dedupe_truncated", droppedDedupe)
stats.Skipped += len(candidates) - limit
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
return candidates[:limit]
}
// classifyItems 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出):
+25 -62
View File
@@ -141,6 +141,13 @@ type TidyApplyBatch struct {
Merged int `json:"merged"`
Archived int `json:"archived"`
Reclassified int `json:"reclassified"`
// Deduped 是成功落地的 `dedupe_group` 簇数(2026-10-24)。它与 Merged 分开:
// 「一对一并进」与「一簇 N 条并进」在面板上是两件事(后者一次动多行)。
Deduped int `json:"deduped"`
// RowsAffected 是本次真的被改动的**记忆行数**(merge/archive/reclassify 各 1 行,
// dedupe_group 是簇里的被吸收行数)。行预算 `maxTidyApplyRowsPerBatch` 按它计,
// 因此这个数字是「这一次点击改了多少行记忆」的直接回答。
RowsAffected int `json:"rowsAffected"`
}
// ApplyTidyCandidate 应用**一条**候选(design/23 §11 的 apply 粒度之一)。
@@ -175,6 +182,8 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid
return s.applyTidyArchive(ctx, cand, opts, now)
case TidyReclassify:
return s.applyTidyReclassify(ctx, cand, opts, now)
case TidyDedupeGroup:
return s.applyTidyDedupeGroup(ctx, cand, opts, now)
default:
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
@@ -185,7 +194,7 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid
// 库里既有的历史行与「不许假装做过」的诚实口径。
func (a TidyAction) applyable() bool {
switch a {
case TidyMerge, TidyArchive, TidyReclassify:
case TidyMerge, TidyArchive, TidyReclassify, TidyDedupeGroup:
return true
default:
return false
@@ -399,11 +408,21 @@ func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts Tidy
Truncated: total > len(cands),
Results: make([]TidyApplyResult, 0, len(cands)),
}
rows := 0
for _, cand := range cands {
if err := ctx.Err(); err != nil {
// 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。
break
}
// 按行计的批量上限(2026-10-24):一条 dedupe_group 候选会改 N 行,只按候选
// 条数限批会让「一次点击改上千行记忆」。超限时**如实标 truncated** 并停下,
// 剩下的留给下一次点击(与扫描/候选上限同一口径:绝不静默少做)。
if cost := tidyApplyRowCost(cand); rows+cost > maxTidyApplyRowsPerBatch {
batch.Truncated = true
break
} else {
rows += cost
}
res, err := s.applyCandidate(ctx, cand, opts)
if err != nil {
res = tidyFailed(TidyApplyResult{
@@ -412,8 +431,13 @@ func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts Tidy
}, err)
}
batch.Results = append(batch.Results, res)
batch.RowsAffected += len(res.AffectedIDs)
tallyTidyApply(&batch, res)
}
if len(batch.Results) < len(cands) {
// 循环被行预算或 ctx 打断:`len(cands)` 之内还有未处理项 ⇒ 不完整。
batch.Truncated = true
}
if batch.RunID != "" && batch.Applied > 0 {
// 该轮的 applied 计数累加(design/23 §10 的 memory_tidy_runs.applied):
// 运行时那一轮写 0(当轮绝不自动应用),事后应用在这里补记。
@@ -423,64 +447,3 @@ func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts Tidy
}
return batch, nil
}
// tallyTidyApply 把一条结果计入批量回执的计数器。
func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) {
switch res.Status {
case TidyApplyApplied:
batch.Applied++
switch res.Action {
case TidyMerge:
batch.Merged++
case TidyArchive:
batch.Archived++
case TidyReclassify:
batch.Reclassified++
}
case TidyApplyFailed:
batch.Failed++
default:
batch.Skipped++
}
}
// skipTidyApply 造一条 skipped 结果。
func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult {
base.Status = TidyApplySkipped
base.Reason = reason
if base.AffectedIDs == nil {
base.AffectedIDs = []string{}
}
return base
}
// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。
func tidyFailed(base TidyApplyResult, err error) TidyApplyResult {
base.Status = TidyApplyFailed
base.Reason = "apply_failed"
base.Error = err.Error()
if base.AffectedIDs == nil {
base.AffectedIDs = []string{}
}
return base
}
// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped;
// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。
//
// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行
// (那是把「没做」说成「做了」)。
func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) {
switch {
case err == nil:
base.Status = TidyApplyApplied
base.AffectedIDs = affected
return base, nil
case errors.Is(err, errTidyCandidateDecided):
return skipTidyApply(base, tidySkipAlreadyDecided), nil
case errors.Is(err, errTidyItemNotActive):
return skipTidyApply(base, notActiveReason), nil
default:
return tidyFailed(base, err), nil
}
}
@@ -0,0 +1,70 @@
package memory
import "errors"
// 本文件是记忆整理应用路径的**结果收敛**:把一条结果计入回执、把事务结果收敛成
// 一条跳过/失败/已应用的结果。它从 tidy_apply.go 拆出来只为守 AGENTS §10 的
// 文件行数上限(≤500 行),判据与注释**逐字未动**。
// tallyTidyApply 把一条结果计入批量回执的计数器。
func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) {
switch res.Status {
case TidyApplyApplied:
batch.Applied++
switch res.Action {
case TidyMerge:
batch.Merged++
case TidyArchive:
batch.Archived++
case TidyReclassify:
batch.Reclassified++
case TidyDedupeGroup:
batch.Deduped++
}
case TidyApplyFailed:
batch.Failed++
default:
batch.Skipped++
}
}
// skipTidyApply 造一条 skipped 结果。
func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult {
base.Status = TidyApplySkipped
base.Reason = reason
if base.AffectedIDs == nil {
base.AffectedIDs = []string{}
}
return base
}
// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。
func tidyFailed(base TidyApplyResult, err error) TidyApplyResult {
base.Status = TidyApplyFailed
base.Reason = "apply_failed"
base.Error = err.Error()
if base.AffectedIDs == nil {
base.AffectedIDs = []string{}
}
return base
}
// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped;
// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。
//
// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行
// (那是把「没做」说成「做了」)。
func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) {
switch {
case err == nil:
base.Status = TidyApplyApplied
base.AffectedIDs = affected
return base, nil
case errors.Is(err, errTidyCandidateDecided):
return skipTidyApply(base, tidySkipAlreadyDecided), nil
case errors.Is(err, errTidyItemNotActive):
return skipTidyApply(base, notActiveReason), nil
default:
return tidyFailed(base, err), nil
}
}
+3 -1
View File
@@ -198,7 +198,9 @@ func (s *Store) addTidyRunApplied(ctx context.Context, runID string, applied int
// 手工撤过了,此时只把候选退回 pending,不假装改过东西)。
func revertTidyAction(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
switch cand.Action {
case TidyMerge:
case TidyMerge, TidyDedupeGroup:
// dedupe_group 与 merge 是**同一套软合并**(被吸收行 → merged + merged_into),
// 因此回滚逐字复用 revertTidyMerge:它本来就按 SourceIDs 逐条复原。
return revertTidyMerge(ctx, tx, cand, now)
case TidyArchive:
return revertTidyArchive(ctx, tx, cand, now)
+11
View File
@@ -35,6 +35,17 @@ type TidyEvidence struct {
// ContentA/ContentB 是两条原文,供面板 diff 预览。
ContentA string `json:"content_a,omitempty"`
ContentB string `json:"content_b,omitempty"`
// 以下是 dedupe_group(2026-10-24)的**簇信息**:判据、簇键、簇大小、存活规则,
// 以及被吸收行的正文列表(面板要能一次看清「这一簇吞了哪几条」——merge 候选
// 只有一对,ContentA/ContentB 装得下;dedupe_group 一簇最多十来条,装不下)。
//
// 🔴 回滚**不需要**这些字段:回滚靠 SourceIDs + TargetID(被吸收行的
// merged_into 必须等于 target),与 merge 候选完全同一套。簇信息只服务审计。
DedupeRule string `json:"dedupe_rule,omitempty"`
DedupeKey string `json:"dedupe_key,omitempty"`
ClusterSize int `json:"cluster_size,omitempty"`
KeptReason string `json:"kept_reason,omitempty"`
DedupeMembers []string `json:"dedupe_members,omitempty"`
}
// TidyCandidate 是一条整理建议(落库形状,design/23 §10)。
+492
View File
@@ -0,0 +1,492 @@
package memory
import (
"context"
"sort"
"strings"
"time"
)
// 本文件是 `dedupe_group` 候选动作(2026-10-24,M34/M27)的**簇形成**与候选构造。
//
// 动因是 M27 只读体检的定量结论(`DevDocs/audit/记忆库价值体检-2026-10-24.md`):
// active 3657 条里可复用 ≈32%,而「近似冗余」≈50%;但整理层 10-14 起**一条候选都不产**
// —— 因为它的合并判据是**包含关系**(`judgeContainment`),而问题的形态是
// 「同一件事换了个说法」:`助手名为 Cyrene。` 与 `助手/人格名为 Cyrene。` 互不包含。
//
// 🔴 **为什么不放大 `containNone`**(体检报告 §9-1 的两条路里选了后一条):
// `containNone` 是一条**护栏**(防「冰美式 vs 红牛」这类同主题不同事实被合并),
// 放宽它等于把护栏拆掉。因此这里新增一个**更窄**的动作:它不改变任何一对的
// 包含判定,只在「簇」这个粒度上认两种**机械可判**的同一性:
//
// 1. **同 scope + 同 kind + 完全相同 tag 集**(tag 集非空,且每个 tag ≥2 字、
// 至少 2 个 tag —— 体检报告 §7 第 2 批的建议:排除 `('作息','工作')` 这类
// 过于通用的 tag 集,宁可少减也不误杀);
// 2. **前 12 个字相同**(`dedupeGroupPrefixRunes`,与体检报告 R2 同一口径),
// 并附加**相似度下限** `dedupeGroupPrefixSimilarity` 与**同 kind** 两条收窄:
// 前者挡住「同一开头、后半句讲了别的实体」的对(`主人喜欢用「喵」…` vs
// `主人喜欢用「汪」…` 这类同构不同事实),后者避免「合并顺手改了分类」——
// kind 的改动仍只走 reclassify 那条路(design/23 §5.1 T0-6)。
//
// 分区(persona_id + scope + project_id)是**硬门**:候选只在一个分区内形成,
// 跨 scope 的同事实(体检报告 §7 第 3 批,83 簇/421 行)**明确不做**(会动 persona
// 写权语义,塔已拍不做)。
//
// 每簇的**安全门**与既有合并路径**逐条同源**(不新造一套判据):
// - 群聊/陌生人来源 ⇒ 排除(design/23 §5.2-9、§6,纵向防御同 tidy_rules);
// - 含绝对日期/时刻 ⇒ 排除(那些走 archive,design/23 §5.1 T0-5);
// - 含有时限状态词(假期/期间/本周…)⇒ 排除(§5.2-1);
// - 置信 < tidyLowConfidenceThreshold ⇒ 排除(§5.2-6);
// - 数值 token 与存活行不一致 ⇒ 该成员不入簇(§5.2-2);
// - 否定标记与存活行不一致 ⇒ 该成员不入簇(§5.2-3「X vs 非X 是覆盖不是重复」)。
//
// 保留规则(体检报告 §7 第 2 批的同一条):**内容最长(超集) > merge_count 大 >
// created_at 早**,仍并列时按 id 字典序(确定性)。「最长」优先是因为簇里的冗余
// 大多是「把同一件事越写越全」,最长那条通常信息量最大;merge_count 大说明它已经
// 吸收过别的表述;两者都并列时留最早的那条(它是 first_seen,时间跨度落在它身上)。
const (
// TidyDedupeGroup 是「机械簇去重」的动作名(design/23 §10 的 action 枚举新增值)。
//
// 它与 `merge` 的差别只在**判据**(簇的同一性 vs 事实集合的包含),落库形态完全
// 相同:一簇一条候选,`source_ids` = 被吸收的行,`target_id` = 存活行,应用是
// **软合并**(被吸收行 → status='merged' + merged_into),因此**可逐条 revert**。
//
// 🔴 **不在自动档里**:一次点击会改多行,体检报告 §7.2 要求在环核对(先做 100 簇、
// 人读 20 簇再放开),因此它**只走人工/批量点**,不进 tidy_auto 的白名单。
TidyDedupeGroup TidyAction = "dedupe_group"
// tidyReasonDedupeGroup 是 dedupe_group 候选的理由标签(落 reason 列)。
tidyReasonDedupeGroup = "dedupe_group"
// dedupeGroupPrefixRunes 是「前 N 字相同」的 N(体检报告 R2 的实测口径)。
dedupeGroupPrefixRunes = 12
// dedupeGroupMinTagRunes / dedupeGroupMinTagCount 是 tag 集簇的门槛:
// 每个 tag 至少 2 个字、至少 2 个 tag。单字 tag(「喵」「住」)与单个 tag
// 太宽,簇会退化成「同主题就合并」——那正是体检报告 §7 第 2 批警告的形态。
dedupeGroupMinTagRunes = 2
dedupeGroupMinTagCount = 2
// dedupeGroupPrefixSimilarity 是前缀簇成员的**附加**相似度下限(词项余弦,
// 与召回的 lexicalSimilarity 同一个函数、同一个量纲)。
//
// 这条收窄是**必须的**:语料实测 871 对「前 12 字相同」的候选对里,相似度分布从
// 0.11 到 0.99 —— 最低那批是「同开头、不同事实」(`助手(persona)的说话风格…`
// vs `助手(persona)的象征花为蓝玫瑰…`,0.111),合并它们是**丢事实**。
// 按阈值看语料(同一批 871 对):
//
// ≥0.60 → 40% ≥0.70 → 14% ≥0.75 → 6% ≥0.80 → 2% ≥0.85 → 1%
//
// 0.70 取在这里:≥0.70 的 120 对里抽 25 对人读**全是同一句话的改写**,而 0.11~0.35
// 段全是不同事实 ⇒ 0.70 是「既能捞回改写、又不碰同开头不同事实」的那一档。
// 它比 tidy 的召回门(0.80)低是刻意的:召回门只是「值不值得看一眼」,而这里是
// 「要不要自动判同一簇」,两者风险不同 —— 但**两者都不是判定依据**(簇的同一性
// 由「前 12 字」给出),这条下限只是把明显不同的对排除掉。
dedupeGroupPrefixSimilarity = 0.70
)
// dedupe_group 的证据字段(进 TidyEvidence,供面板 diff 预览与运维判读)。
const (
// tidyDedupeRuleTagSet / tidyDedupeRulePrefix 是簇的判据名(可审计)。
tidyDedupeRuleTagSet = "tag_set"
tidyDedupeRulePrefix = "prefix12"
// 保留规则名(evidence.kept_reason):存活行为什么是它。
tidyDedupeKeepLongest = "longest_content"
tidyDedupeKeepMergeCount = "merge_count"
tidyDedupeKeepOldest = "oldest_created_at"
tidyDedupeKeepID = "id_order"
)
// tidyDedupeKey 是簇键:判据 + 键值。两者一起进 evidence,便于复核「为什么它们同簇」。
type tidyDedupeKey struct {
rule string
key string
}
// tidyDedupeCluster 是一个待落候选的簇:存活行 + 被吸收行。
type tidyDedupeCluster struct {
Rule string
Key string
Survivor MemoryItem
Absorbed []MemoryItem
// KeptReason 是存活行胜出的那条规则(evidence.kept_reason 的来源)。
KeptReason string
}
// tidyDedupeStats 是分区内 dedupe 分簇的观测口径(进 TidyStats)。
type tidyDedupeStats struct {
// Groups 是**真正产出候选**的簇数。
Groups int
// Rows 是这些簇**可减少的 active 行数**(被吸收行合计)——「预计减少行数」。
Rows int
}
// dedupeInPartition 是 runPartition 调用点:读 merge_count、把**已被既有候选覆盖**的
// 条目屏蔽掉,再分簇产候选。
//
// 屏蔽(covered)是「不产生第二条待办」的落点:一对已经拿到 archive/merge 候选的
// 条目不再进簇。它是纯粹的减法(覆盖集合由前一步的**产物**给出),因此不是第二套判据。
func (t *Tidier) dedupeInPartition(ctx context.Context, p tidyPartition, items []MemoryItem,
existing []TidyCandidate) ([]TidyCandidate, tidyDedupeStats) {
covered := make(map[string]struct{}, len(existing)*2)
for _, c := range existing {
for _, id := range c.SourceIDs {
covered[id] = struct{}{}
}
if c.TargetID != "" {
covered[c.TargetID] = struct{}{}
}
}
// merge_count 是存活行的**次要**判据(「内容最长」并列时才用它)。缺它时降级到
// created_at 仍能给出确定性的存活行,因此这里降级继续、但如实记 WARN。
meta, err := t.deps.Store.dedupeItemMeta(ctx, p)
if err != nil {
t.log.Warn("memory tidy dedupe item meta unavailable, clustering without merge_count/temporary flags",
"partition", p.label(), "error", err)
meta = nil
}
return t.dedupeCandidates(items, meta, covered, t.now().UTC())
}
// dedupeCandidates 在**一个分区内**把 items 分成机械簇并产候选。它是纯函数式的
// (只读入参、无 IO、无模型),因此可完全单测,也在试算轮与真跑轮**共用**
// (两条路径都从 runPartition 进来,见 tidy.go)。
func (t *Tidier) dedupeCandidates(items []MemoryItem, meta map[string]tidyDedupeMeta,
covered map[string]struct{}, now time.Time) ([]TidyCandidate, tidyDedupeStats) {
clusters := dedupeClusters(items, meta, covered)
if len(clusters) == 0 {
return nil, tidyDedupeStats{}
}
out := make([]TidyCandidate, 0, len(clusters))
stats := tidyDedupeStats{}
for _, cl := range clusters {
out = append(out, dedupeGroupCandidate(cl, now))
stats.Groups++
stats.Rows += len(cl.Absorbed)
}
return out, stats
}
// dedupeClusters 把条目分组、按确定性顺序认领成员、并为每簇选出存活行。
//
// 「每一条只进一个簇」是靠**全局认领集合**保证的(大簇优先、键字典序次之):
// 一条记忆同时出现在两个簇里会让两次应用互相打脸(第二次的 `AND status='active'`
// 条件更新如实跳过,但面板会看到一条永远 skip 的候选)。
func dedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) []tidyDedupeCluster {
groups := dedupeGroupItems(items, meta, covered)
ordered := dedupeOrderedGroups(groups)
return claimDedupeClusters(items, meta, ordered)
}
// dedupeGroupItems 把可进簇的条目按簇键归组(键 → 成员下标)。
func dedupeGroupItems(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) map[string]*tidyDedupeGroup {
groups := make(map[string]*tidyDedupeGroup)
for idx := range items {
if _, taken := covered[items[idx].ID]; taken {
continue
}
if meta[items[idx].ID].temporary {
// 临时层行不许进簇(见 tidyDedupeMeta.temporary 的说明)。
continue
}
if !dedupeGroupEligible(items[idx]) {
continue
}
for _, key := range dedupeClusterKeys(items[idx]) {
g := groups[key.key]
if g == nil {
g = &tidyDedupeGroup{rule: key.rule, key: key.key}
groups[key.key] = g
}
g.members = append(g.members, idx)
}
}
return groups
}
// dedupeOrderedGroups 只留下真正的簇(≥2 条)并按**确定性顺序**排好:大簇优先
// (可减行数最多的先落),同大小按 (key, rule) 字典序 —— 与 map 的随机遍历顺序无关,
// 因此同一批数据反复运行产出同一批候选(幂等键随之稳定)。
func dedupeOrderedGroups(groups map[string]*tidyDedupeGroup) []*tidyDedupeGroup {
ordered := make([]*tidyDedupeGroup, 0, len(groups))
for _, g := range groups {
if len(g.members) < 2 {
continue
}
ordered = append(ordered, g)
}
sort.Slice(ordered, func(i, j int) bool {
if len(ordered[i].members) != len(ordered[j].members) {
return len(ordered[i].members) > len(ordered[j].members)
}
if ordered[i].key != ordered[j].key {
return ordered[i].key < ordered[j].key
}
return ordered[i].rule < ordered[j].rule
})
return ordered
}
// claimDedupeClusters 逐簇认领成员并产簇:**每一条只进一个簇**(靠全局认领集合),
// 存活行与全部被吸收行一起认领 —— 存活行绝不能再当别簇的被吸收行。
func claimDedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta,
ordered []*tidyDedupeGroup) []tidyDedupeCluster {
claimed := make(map[int]struct{}, len(items))
out := make([]tidyDedupeCluster, 0, len(ordered))
for _, g := range ordered {
free := make([]tidyDedupeMember, 0, len(g.members))
for _, idx := range g.members {
if _, taken := claimed[idx]; taken {
continue
}
free = append(free, tidyDedupeMember{item: items[idx], mergeCount: meta[items[idx].ID].mergeCount})
}
if len(free) < 2 {
continue
}
survivor, keptReason := pickDedupeSurvivor(free)
absorbed := dedupeAbsorbable(g.rule, survivor, free)
if len(absorbed) == 0 {
continue
}
keep := make(map[string]struct{}, len(absorbed)+1)
keep[survivor.item.ID] = struct{}{}
for _, it := range absorbed {
keep[it.item.ID] = struct{}{}
}
for _, idx := range g.members {
if _, ok := keep[items[idx].ID]; ok {
claimed[idx] = struct{}{}
}
}
out = append(out, tidyDedupeCluster{
Rule: g.rule, Key: g.key, Survivor: survivor.item, Absorbed: dedupeItems(absorbed),
KeptReason: keptReason,
})
}
return out
}
// tidyDedupeGroup 是内部用的分组累加器(键 → 成员下标)。
type tidyDedupeGroup struct {
rule string
key string
members []int
}
// dedupeGroupEligible 报告一条记忆能不能进机械簇。它是既有硬门/否决规则的**并集**,
// 因此 dedupe_group 的判定面严格窄于 `judgePair`(后者还要求包含关系)。
func dedupeGroupEligible(it MemoryItem) bool {
if strings.TrimSpace(it.ID) == "" {
// 没有 id 的行不可能被候选引用(候选靠 source_ids / target_id 指向真实行),
// 把它放进簇只会产出一条永远 skip 的候选。纵深防御:这条在落库数据上不会出现。
return false
}
if nonOwnerSource(it.SourceConversation) {
// 群聊/陌生人来源只读不写(design/23 §5.2-9、§6):连簇都不进。
return false
}
if hasAbsoluteTimestamp(it.Content) {
// 一次性事件走 archive(§5.1 T0-5),不进合并。
return false
}
if hasTemporalState(it.Content) {
// 有期限状态(假期/期间/本周…)不是长期事实(§5.2-1)。
return false
}
if it.Confidence < tidyLowConfidenceThreshold {
// 低置信是**否决门**(§5.2-6):低置信条目的孪生被它挡住了 2479 对,
// 那正是体检报告 §6.2 的最大否决项。dedupe_group 不越过这道门。
return false
}
if len([]rune(it.Content)) < minTidyContentRunes {
return false
}
return true
}
// dedupeClusterKeys 返回一条记忆的簇键(tag 集 / 前 12 字)。两条判据都可能不成立
// (tag 集不合格 / 内容短于 12 字),那时只返回另一条。
func dedupeClusterKeys(it MemoryItem) []tidyDedupeKey {
out := make([]tidyDedupeKey, 0, 2)
if key := dedupeTagKey(it); key != "" {
out = append(out, tidyDedupeKey{rule: tidyDedupeRuleTagSet, key: key})
}
if key := dedupePrefixKey(it); key != "" {
out = append(out, tidyDedupeKey{rule: tidyDedupeRulePrefix, key: key})
}
return out
}
// dedupeTagKey 是「同分区 + 同 kind + 完全相同 tag 集」的键。tag 集为空/太通用时返回空串。
//
// 键里带**分区**(persona/scope/project)与 kind,两个都是**纵深防御**:
// - 调用方本来就按分区逐块调用(design/23 §6「分区之间永不比较」),但候选键
// 自带分区后,任何跨分区的输入(手工调用、将来别的入口)也**无法成簇** ——
// 跨 scope 收敛是塔已明确不做的那一批,它不许只靠调用纪律保证;
// - kind 是**内容类别**(relationship / world_event):跨 kind 的簇合并会顺手改掉
// 被吸收行的分类语义,而分类改动只该走 reclassify(§5.1 T0-6)。
func dedupeTagKey(it MemoryItem) string {
tags := normalizedTidyTags(it.Tags)
if len(tags) < dedupeGroupMinTagCount {
return ""
}
for _, tag := range tags {
if len([]rune(tag)) < dedupeGroupMinTagRunes {
return ""
}
}
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + strings.Join(tags, "\x1e")
}
// dedupePartitionPrefix 是簇键里的分区段(persona + scope + project)。
func dedupePartitionPrefix(it MemoryItem) string {
return it.PersonaID + "\x1e" + string(it.Scope) + "\x1e" + it.ProjectID + "\x1e"
}
// normalizedTidyTags 归一化 tag 集:去空白、去空项、去重、排序。排序是幂等键稳定
// 的前提(同一 tag 集的不同书写顺序必须落到同一个键)。
func normalizedTidyTags(tags []string) []string {
if len(tags) == 0 {
return nil
}
seen := make(map[string]struct{}, len(tags))
out := make([]string, 0, len(tags))
for _, tag := range tags {
t := strings.TrimSpace(tag)
if t == "" {
continue
}
if _, dup := seen[t]; dup {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
sort.Strings(out)
return out
}
// dedupePrefixKey 是「**同分区同 kind** + 前 12 个字相同」的键(按**归一化正文**取前 12 个 rune)。
//
// 归一化复用 dedupeKey(小写 + 空白折叠):`助手名为 Cyrene。` 与 `助手名为 Cyrene`
// 不该因为多一个空格就落到两个簇。
//
// 短于 12 字的内容返回空串**而不受理**:那时「前 12 字」等于全文,簇会退化成
// 「逐字重复」——那是 §15 D3-2 T0-1 的 `skip`(入库闸)与 `merge`(字面重复自动档)
// 已经在管的事,不需要第二个动作重复它。
func dedupePrefixKey(it MemoryItem) string {
runes := []rune(dedupeKey(it.Content))
if len(runes) < dedupeGroupPrefixRunes {
return ""
}
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + string(runes[:dedupeGroupPrefixRunes])
}
// tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。
//
// 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store.go 的 13 列)**不含**
// `merge_count`(它只写不读),而「存活行选谁」的次要判据正是它(合并次数多的那条
// 已经吸收过别的表述,留它信息量最大)。为一个次要判据去改 13 列读面会牵动
// Get/List*/preinsert/tidy 四处 SQL,风险远大于收益;改为按分区一次轻量查询把
// id → merge_count 取出来(见 Store.dedupeMergeCounts)。
type tidyDedupeMember struct {
item MemoryItem
mergeCount int
}
// dedupeItems 把成员视图降回 MemoryItem(簇只对外暴露记忆行)。
func dedupeItems(members []tidyDedupeMember) []MemoryItem {
out := make([]MemoryItem, 0, len(members))
for _, m := range members {
out = append(out, m.item)
}
return out
}
// pickDedupeSurvivor 按「内容最长 > merge_count 大 > created_at 早 > id 字典序」选存活行,
// 并返回命中的那条规则名(进 evidence.kept_reason,回答「为什么留下的是它」)。
func pickDedupeSurvivor(members []tidyDedupeMember) (tidyDedupeMember, string) {
best := members[0]
for _, it := range members[1:] {
switch compareDedupePriority(it, best) {
case -1:
best = it
case 0:
if it.item.ID < best.item.ID {
best = it
}
}
}
return best, dedupeKeepReason(members, best)
}
// compareDedupePriority 比较两条记忆谁更该存活:-1 表示 a 优先,1 表示 b 优先,
// 0 表示按 id 决胜(调用方处理)。
func compareDedupePriority(a, b tidyDedupeMember) int {
if la, lb := len([]rune(a.item.Content)), len([]rune(b.item.Content)); la != lb {
if la > lb {
return -1
}
return 1
}
if a.mergeCount != b.mergeCount {
if a.mergeCount > b.mergeCount {
return -1
}
return 1
}
if !a.item.CreatedAt.Equal(b.item.CreatedAt) {
if a.item.CreatedAt.Before(b.item.CreatedAt) {
return -1
}
return 1
}
return 0
}
// dedupeKeepReason 报告存活行是靠哪一条胜出的(证据面)。三条判据依次判「是否唯一
// 胜出」,都并列时落到最早创建(created_at)那条 —— 与 compareDedupePriority 同序。
func dedupeKeepReason(members []tidyDedupeMember, survivor tidyDedupeMember) string {
longest := len([]rune(survivor.item.Content))
uniqueLongest := true
uniqueMergeCount := true
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if len([]rune(it.item.Content)) == longest {
uniqueLongest = false
}
if it.mergeCount == survivor.mergeCount {
uniqueMergeCount = false
}
}
switch {
case uniqueLongest:
return tidyDedupeKeepLongest
case uniqueMergeCount:
return tidyDedupeKeepMergeCount
case survivor.item.CreatedAt.Before(earliestOtherCreatedAt(members, survivor)):
return tidyDedupeKeepOldest
default:
return tidyDedupeKeepID
}
}
// earliestOtherCreatedAt 返回除存活行以外最早创建的时刻(用于区分「最早胜出」与
// 「id 决胜」这两种证据)。空簇成员只在单条簇里出现,那时调用方不会问证据。
func earliestOtherCreatedAt(members []tidyDedupeMember, survivor tidyDedupeMember) time.Time {
var earliest time.Time
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if earliest.IsZero() || it.item.CreatedAt.Before(earliest) {
earliest = it.item.CreatedAt
}
}
return earliest
}
@@ -0,0 +1,110 @@
package memory
import (
"context"
"database/sql"
"errors"
"time"
)
// 本文件是 `dedupe_group`(2026-10-24,M34)的**应用与回滚**执行面。
//
// 它与 merge 共用同一套语义(design/23 §8 的软合并):被吸收行
// `status='merged' + merged_into=<存活 id>`,存活行 `merged_from` 追加、`merge_count`
// 累加;**绝不改正文、绝不删行**。因此回滚直接复用 `revertTidyMerge`(它本来就按
// `SourceIDs` 逐条复原,不是为「一对一」写的)。
//
// 与单条 merge 的两点差别、以及它们的理由:
//
// 1. **一条候选吸收多行**(一簇 N 条)。整簇在**一个事务**里落,任何一条不在册
// ⇒ 整簇回滚(`errTidyItemNotActive`),不会出现「吞了一半」的中间态 ——
// 半吞的簇最坏:面板显示 applied,但被吞行里有一半还在 active 集合里,
// 回滚也无从判断该复原哪几条。
// 2. **按行计的分批上限**(`maxTidyApplyRowsPerBatch`)。一条候选动 N 行,若只按
// 「候选条数」限批,一次批量应用可能在一条命令里改上千行记忆 —— 这正是体检
// 报告 §7.2 要求分批的理由(「第 2 批必须分批 + 每批留 run_id」)。行预算在
// `ApplyTidyCandidates` 里统一执行,超限如实报 `truncated`。
//
// 🔴 **不进自动档**:`tidyAutoApplyEligible` 的 switch 只认 merge/archive/reclassify,
// 默认分支返回 false,因此 dedupe_group 永远只走人工/批量点(测试钉着这一条)。
// maxTidyApplyRowsPerBatch 是一次批量应用**最多改动的记忆行数**(dedupe_group 按
// 被吸收行计)。200 的来源是体检报告 §7 第 2 批的建议值:一簇平均 3~4 行,
// 200 行 ≈ 50 簇,一屏可核对;它比候选条数上限(100)**更严**的场合是「簇很大」,
// 这正是需要护栏的那种数据。
const maxTidyApplyRowsPerBatch = 200
// tidyApplyRowCost 返回一条候选**会改动的记忆行数**,供批量应用的按行预算使用。
// 只有 dedupe_group 是多行动作;其余三种动作恒为 1(merge/archive/reclassify 各碰一行)。
func tidyApplyRowCost(c TidyCandidate) int {
if c.Action == TidyDedupeGroup && len(c.SourceIDs) > 0 {
return len(c.SourceIDs)
}
return 1
}
// applyTidyDedupeGroup 把一簇被吸收行软合并进存活行。
//
// 前置校验与 merge 逐条同源:分区硬门、只读来源硬门、目标行必须存在且与候选同分区。
// 任何一条不通过都如实 skip(不假装做过),且**一行都不写**。
func (s *Store) applyTidyDedupeGroup(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
}
if len(cand.SourceIDs) == 0 || cand.TargetID == "" {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
survivor, err := s.Get(ctx, cand.TargetID)
if errors.Is(err, ErrNotFound) {
return skipTidyApply(base, tidySkipSourceMissing), nil
}
if err != nil {
return base, err
}
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
return skipTidyApply(base, skip), nil
}
if nonOwnerSource(survivor.SourceConversation) {
// 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御,
// 规则层本就把这类条目排除在分簇之外。
return skipTidyApply(base, tidySkipSourceRestricted), nil
}
absorbed := make([]MemoryItem, 0, len(cand.SourceIDs))
for _, id := range cand.SourceIDs {
if id == cand.TargetID {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
it, gerr := s.Get(ctx, id)
if errors.Is(gerr, ErrNotFound) {
return skipTidyApply(base, tidySkipSourceMissing), nil
}
if gerr != nil {
return base, gerr
}
if skip := tidyPartitionMismatch(cand, it); skip != "" {
return skipTidyApply(base, skip), nil
}
if nonOwnerSource(it.SourceConversation) {
return skipTidyApply(base, tidySkipSourceRestricted), nil
}
absorbed = append(absorbed, it)
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
for _, it := range absorbed {
if err := markTidyAbsorbed(ctx, tx, it.ID, survivor.ID, now); err != nil {
return err
}
// 逐条 bump:bumpSurvivor 在事务内**重读**存活行的 merge_count,
// 因此连续调用会把 N 条都记进 merged_from、把计数加 N 次。
if err := bumpSurvivor(ctx, tx, survivor, it, now); err != nil {
return err
}
}
return nil
})
affected := make([]string, 0, len(absorbed))
for _, it := range absorbed {
affected = append(affected, it.ID)
}
return finishTidyApply(base, affected, err, tidySkipSourceNotActive)
}
@@ -0,0 +1,289 @@
package memory
import (
"context"
"fmt"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 `dedupe_group` 的**应用 / 回滚**验收用例集(软合并、整簇原子、
// 按行计的分批上限、临时层数据安全)。分簇与候选构造的用例在
// tidy_dedupe_test.go —— 分开只为守 AGENTS §10 的文件行数上限(≤500 行)。
// TestTidyApplyDedupeGroupIsSoftAndRevertible 钉住应用/回滚:整簇软合并(行不删、
// 正文不改、merge_count 累加),revert 把**每一行**都复原。
func TestTidyApplyDedupeGroupIsSoftAndRevertible(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
tags := []string{"身份", "名字"}
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)),
seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))),
seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))),
}
cands, _ := dedupeCandsOf(t, store, items)
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1", len(cands))
}
cand := cands[0]
if created, err := store.insertTidyCandidate(ctx, cand); err != nil || !created {
t.Fatalf("insert candidate: created=%v err=%v", created, err)
}
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
}
if res.Status != TidyApplyApplied || len(res.AffectedIDs) != 2 {
t.Fatalf("应用结果 = %+v, want applied 且影响 2 行", res)
}
for _, id := range cand.SourceIDs {
row := loadTidyItemRow(t, store, id)
if row.status != ingestStatusMerged || row.mergedInto != cand.TargetID {
t.Fatalf("被吸收行 %s = %+v, want merged → %s", id, row, cand.TargetID)
}
}
survivor := loadTidyItemRow(t, store, cand.TargetID)
if survivor.status != tidyStatusActive {
t.Fatalf("存活行状态 = %q, want active", survivor.status)
}
if got := countMemoryItems(t, store); got != 3 {
t.Fatalf("应用后行数 = %d, want 3(软合并绝不删行)", got)
}
if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 3 {
t.Fatalf("存活行 merge_count = %d, want 3(1 + 吸收 2)", got)
}
if got := loadTidyItemRow(t, store, cand.TargetID).content; got != items[2].Content {
t.Fatalf("存活行正文被改了:%q", got)
}
reverted, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("RevertTidyCandidate: %v", err)
}
if reverted.Status != TidyApplyApplied || len(reverted.AffectedIDs) != 2 {
t.Fatalf("回滚结果 = %+v, want applied 且复原 2 行", reverted)
}
for _, id := range cand.SourceIDs {
row := loadTidyItemRow(t, store, id)
if row.status != tidyStatusActive || row.mergedInto != "" {
t.Fatalf("回滚后 %s = %+v, want active 且 merged_into 清空", id, row)
}
}
if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 1 {
t.Fatalf("回滚后 merge_count = %d, want 1", got)
}
if got, _ := store.GetTidyCandidate(ctx, cand.ID); got.Status != tidyCandidatePending {
t.Fatalf("回滚后候选状态 = %q, want pending(可重新决定)", got.Status)
}
}
// TestTidyApplyDedupeGroupRollsBackWholeClusterOnStaleMember 钉住原子的**安全方向**:
// 簇里有一行已不在册 ⇒ 整簇不吞(一行都不改),候选如实 skip。
func TestTidyApplyDedupeGroupRollsBackWholeClusterOnStaleMember(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
tags := []string{"身份", "名字"}
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)),
seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))),
seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))),
}
cands, _ := dedupeCandsOf(t, store, items)
cand := cands[0]
if created, err := store.insertTidyCandidate(ctx, cand); err != nil || !created {
t.Fatalf("insert candidate: created=%v err=%v", created, err)
}
// 让簇里一个成员先被别的动作合并掉(模拟过时候选)。
if _, err := store.db.ExecContext(ctx,
`UPDATE memory_items SET status = ? WHERE id = ?`,
ingestStatusMerged, cand.SourceIDs[0]); err != nil {
t.Fatalf("stale member update: %v", err)
}
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
}
if res.Status != TidyApplySkipped || res.Reason != tidySkipSourceNotActive {
t.Fatalf("结果 = %+v, want skipped/%s(整簇原子,不许吞一半)", res, tidySkipSourceNotActive)
}
if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 1 {
t.Fatalf("存活行 merge_count = %d, want 1(整簇回滚,一行都没改)", got)
}
if got := loadTidyItemRow(t, store, cand.SourceIDs[1]).status; got != tidyStatusActive {
t.Fatalf("第二个被吸收行状态 = %q, want active(半吞禁止)", got)
}
}
// TestTidyApplyDedupeGroupHonoursRowBudget 钉住「批量按行计、超限如实 truncated」:
// 每簇 2 个被吸收行(行预算按被吸收行计),预算 200 ⇒ 最多 100 簇(= 200 行),
// 第 101 簇起留给下一次点击,回执必须如实说 truncated。
func TestTidyApplyDedupeGroupHonoursRowBudget(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
total := maxTidyApplyRowsPerBatch/2 + 5 // 簇数多到必然撞上 200 行预算
wantApplied := maxTidyApplyRowsPerBatch / 2 // 每簇 2 个被吸收行
for i := 0; i < total; i++ {
tags := []string{"身份", "代号"}
content := fmt.Sprintf("小茂密的第 %d 号说法:蝴蝶结是小铃铛", i)
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem(content+"甲", tags, base)),
seedDedupeItem(t, store, dedupeTestItem(content+"乙", tags, base.Add(time.Minute))),
seedDedupeItem(t, store, dedupeTestItem(content+"丙", tags, base.Add(2*time.Minute))),
}
cands, _ := dedupeCandsOf(t, store, items)
if len(cands) != 1 {
t.Fatalf("第 %d 组簇数 = %d, want 1", i, len(cands))
}
if created, err := store.insertTidyCandidate(ctx, cands[0]); err != nil || !created {
t.Fatalf("insert candidate %d: created=%v err=%v", i, created, err)
}
}
batch, err := store.ApplyTidyCandidates(ctx, "", TidyApplyOptions{Actor: "owner:test", Limit: 500})
if err != nil {
t.Fatalf("ApplyTidyCandidates: %v", err)
}
if !batch.Truncated {
t.Fatalf("truncated = false, want true(行预算 200 被撞上时必须如实说)")
}
if batch.Applied != wantApplied {
t.Fatalf("应用簇数 = %d, want %d(200 行 ÷ 3 行/簇)", batch.Applied, wantApplied)
}
if batch.RowsAffected != wantApplied*2 {
t.Fatalf("改动行数 = %d, want %d", batch.RowsAffected, wantApplied*2)
}
if batch.Deduped != wantApplied {
t.Fatalf("deduped 计数 = %d, want %d", batch.Deduped, wantApplied)
}
}
// TestTidyDryRunReportsDedupeGroupsWithoutWriting 钉住「**先试算、不写库**」这条要求:
// 试算轮必须给出 dedupe_group 的**簇数与可减少行数**(面板据此决定要不要落库),
// 并且三张表逐行不变(零副作用)—— 试算与真跑共用同一条规则路径,因此这份报告
// 就是「真跑会产出的东西」。
func TestTidyDryRunReportsDedupeGroupsWithoutWriting(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
// 三条**互不包含**、但 tag 集完全相同的说法(否则它们会先被包含判定吃掉,
// 那时 dedupe 会被「已覆盖」抑制掉 —— 那是对的行为,但测不到试算口径)。
tags := []string{"宠物", "小茂密"}
for i, content := range []string{
"小茂密是白毛的小猫,喜欢撒娇",
"茂密的蝴蝶结最近换成了深色",
"茂密爱坐在窗边看外面的风景",
} {
seedDedupeItem(t, store, dedupeTestItem(content, tags, base.Add(time.Duration(i)*time.Minute)))
}
before := snapshotTidyTables(t, store)
emb := stubEmbedder{vecs: map[string]embedding.Vector{}}
dr, err := newTestTidier(t, store, tidyTestCfg(0.80), emb).DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if dr.Stats.DedupeGroups != 1 || dr.Stats.DedupeRows != 2 {
t.Fatalf("试算口径 = groups:%d rows:%d, want 1 / 2(三行一簇,可减两行)",
dr.Stats.DedupeGroups, dr.Stats.DedupeRows)
}
dedupeCands := candidatesByAction(dr.Candidates, TidyDedupeGroup)
if len(dedupeCands) != 1 {
t.Fatalf("试算候选里的 dedupe_group = %d 条, want 1", len(dedupeCands))
}
assertRowsUnchanged(t, "试算后三张表", before.items, snapshotTidyTables(t, store).items)
after := snapshotTidyTables(t, store)
assertRowsUnchanged(t, "试算后候选表", before.candidates, after.candidates)
assertRowsUnchanged(t, "试算后运行表", before.runs, after.runs)
}
// TestDedupeGroupNeverTouchesTemporaryRows 钉住临时层的**数据安全**不变式:
// `expires_at` 非空的记忆(临时层)绝不进簇 —— 无论当存活行还是被吸收行。
//
// 为什么是红线:临时行 7 天后由后台清扫**物理删除**(design/23 §19)。若它当存活行,
// 被吸收的长期事实会永远停在 `status='merged'` 且 `merged_into` 指向一个已不存在的
// id(信息不可达);若它当被吸收行,一次合并就把一条正在生效的临时记忆藏了起来。
func TestDedupeGroupNeverTouchesTemporaryRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
tags := []string{"身份", "名字"}
durable := seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base))
temporary := seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute)))
if _, err := store.db.ExecContext(ctx,
`UPDATE memory_items SET expires_at = ? WHERE id = ?`,
expiryText(base.Add(7*24*time.Hour)), temporary.ID); err != nil {
t.Fatalf("mark temporary: %v", err)
}
cands, stats := dedupeCandsOf(t, store, []MemoryItem{durable, temporary})
if len(cands) != 0 || stats.Groups != 0 {
t.Fatalf("簇数 = %d, want 0(临时层行不许进簇:存活行会让被吸收行指向一个将被删除的 id)",
len(cands))
}
// 三条时:两条长期 + 一条临时 ⇒ 正是两条长期的那一簇,临时行不在候选里。
durable2 := seedDedupeItem(t, store, dedupeTestItem("助理/助手名为 Cyrene。", tags, base.Add(2*time.Minute)))
cands, _ = dedupeCandsOf(t, store, []MemoryItem{durable, temporary, durable2})
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1", len(cands))
}
for _, id := range cands[0].SourceIDs {
if id == temporary.ID {
t.Fatalf("临时行出现在被吸收行里:%v", cands[0].SourceIDs)
}
}
if cands[0].TargetID == temporary.ID {
t.Fatalf("临时行当上了存活行 —— 它 7 天后会被物理删除")
}
}
// TestTidyRunReportsDedupeTruncation 钉住「truncated 如实」:每分区候选上限切掉了
// 多少 dedupe 簇必须**单独**报出来。变异(不给 DedupeTruncated 计数、或按错误的
// 段计算)会让这条用例红 —— 被截断的簇在面板上会彻底消失,那正是「静默少做」。
func TestTidyRunReportsDedupeTruncation(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
// 两个互不相干的簇(tag 集不同 ⇒ 两个簇),一共 2 条 dedupe 候选。
seed := []struct {
content string
tags []string
}{
{"小茂密是白毛的小猫,喜欢撒娇", []string{"宠物", "小茂密"}},
{"茂密的蝴蝶结最近换成了深色", []string{"宠物", "小茂密"}},
{"主人对花溪的住处很满意", []string{"地点", "住处"}},
{"主人说花溪的住处很安静", []string{"地点", "住处"}},
}
for i, s := range seed {
seedDedupeItem(t, store, dedupeTestItem(s.content, s.tags, base.Add(time.Duration(i)*time.Minute)))
}
cfg := tidyTestCfg(0.80)
cfg.CandidateLimit = 1
emb := stubEmbedder{vecs: map[string]embedding.Vector{}}
stats, err := newTestTidier(t, store, cfg, emb).RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.DedupeGroups != 2 {
t.Fatalf("分簇数 = %d, want 2(分簇口径不受候选上限影响)", stats.DedupeGroups)
}
if stats.DedupeTruncated != 1 {
t.Fatalf("DedupeTruncated = %d, want 1(上限 1 只容得下一簇)", stats.DedupeTruncated)
}
if len(stats.Truncated) == 0 {
t.Fatalf("Truncated 为空 —— 被上限切掉的部分必须如实登记分区标签")
}
}
@@ -0,0 +1,83 @@
package memory
import (
"sort"
"time"
)
// 本文件是 `dedupe_group` 的**候选构造**侧:从一簇成员里挑出可并入的行、
// 并把簇转成候选(落库形状与 merge 候选同构,因此面板/应用/回滚三条路都不用改)。
// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
// 判据与注释**逐字未动**;簇形成在 tidy_dedupe.go,元信息查询在 tidy_dedupe_meta.go。
// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。
//
// 数值与否定是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」,
// 而同一性由存活行代表;任何一条与存活行数值/否定不一致的成员,说明它讲的是
// **另一个事实**(§5.2-2/§5.2-3),因此它**不进这一簇**(留待人工),而不是让整簇作废。
//
// 前缀簇还要过相似度下限(见 dedupeGroupPrefixSimilarity);tag 集簇不需要 ——
// 完全相同的 tag 集本身就是强证据,而前缀簇的「同开头」弱得多。
func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedupeMember) []tidyDedupeMember {
out := make([]tidyDedupeMember, 0, len(members))
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if !sameNumbers(it.item.Content, survivor.item.Content) {
continue
}
if !sameNegations(it.item.Content, survivor.item.Content) {
continue
}
if rule == tidyDedupeRulePrefix &&
lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupPrefixSimilarity {
continue
}
out = append(out, it)
}
// 被吸收行的顺序也必须是确定的(幂等键按 sorted(source_ids) 算,但证据里的
// dedupe_members 会原样落库,顺序抖动会让同一个簇每次产出不同的证据文本)。
sort.Slice(out, func(i, j int) bool { return out[i].item.ID < out[j].item.ID })
return out
}
// dedupeGroupCandidate 把一个簇转成候选。
//
// 落库字段与 merge 候选同构,因此既有面板/应用/回滚三条路都不用改形状:
// `source_ids` = 被吸收行(**可多条**,`applyTidyDedupeGroup` 逐条软合并),
// `target_id` = 存活行,`merged_content` = 存活行正文(**绝不改正文**)。
func dedupeGroupCandidate(cl tidyDedupeCluster, now time.Time) TidyCandidate {
absorbedIDs := make([]string, 0, len(cl.Absorbed))
members := make([]string, 0, len(cl.Absorbed))
for _, it := range cl.Absorbed {
absorbedIDs = append(absorbedIDs, it.ID)
members = append(members, it.Content)
}
return TidyCandidate{
ID: mustNewTidyID(),
PersonaID: cl.Survivor.PersonaID,
Scope: cl.Survivor.Scope,
ProjectID: cl.Survivor.ProjectID,
Kind: cl.Survivor.Kind,
Action: TidyDedupeGroup,
SourceIDs: absorbedIDs,
TargetID: cl.Survivor.ID,
ProposedContent: cl.Survivor.Content,
MergedContent: cl.Survivor.Content,
Reason: tidyReasonDedupeGroup,
Evidence: TidyEvidence{
Containment: "none",
ContentA: cl.Survivor.Content,
DedupeRule: cl.Rule,
DedupeKey: cl.Key,
ClusterSize: len(cl.Absorbed) + 1,
KeptReason: cl.KeptReason,
DedupeMembers: members,
KindA: string(cl.Survivor.Kind),
},
RuleVersion: TidyRuleVersion,
Status: tidyCandidatePending,
CreatedAt: now,
}
}
@@ -0,0 +1,65 @@
package memory
import (
"context"
"fmt"
"strings"
)
// 本文件是 `dedupe_group` 的**元信息查询**:按分区取出「合并次数」与「是否临时层」
// 两列 —— 它们是存活规则的次要判据与一条数据安全护栏(临时行绝不进簇)。
// 从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
// 注释与判据**逐字未动**。
// dedupeItemMetaSQL 取一个分区内每条记忆的**分簇所需元信息**:合并次数
// (存活规则的次要判据)+ 是否是临时层(`expires_at` 非空)。
//
// 两列都只读、只走分区条件,与 tidy 分区扫描同一组谓词(不含 archived/merged)。
const dedupeItemMetaSQL = `SELECT id, merge_count, expires_at FROM memory_items
WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ?`
// tidyDedupeMeta 是一条记忆的额外元信息(`MemoryItem` 的读侧 SELECT 不含这两列)。
type tidyDedupeMeta struct {
// mergeCount 是存活规则的次要判据(合并次数多的留)。
mergeCount int
// temporary 报告这条记忆在**临时层**(`expires_at` 非空,design/03 §9.6)。
//
// 🔴 临时层行**绝不进簇**,两个方向都不许:让一条长期事实并进一个 7 天后会被
// 后台**物理删除**(design/23 §19)的存活行,等于把那条事实一起删掉 ——
// 被吸收行会永远停在 `status='merged'` 且 `merged_into` 指向一个不存在的 id。
// 临时行本身也不值得合并:它的寿命本来就只剩几天。
temporary bool
}
// dedupeItemMeta 读出一个分区的 id → 元信息映射。
//
// 失败方向上它是**可降级**的(调用方在失败时用空映射继续分簇):存活行的第一判据
// 是「内容最长」,merge_count 只在前者并列时决定胜负。但**降级会让「临时行」这个
// 判断失效** —— 因此调用方在降级时必须记 WARN,并且降级路径下 `temporary` 恒为
// false 意味着「可能把临时行当长期行」;这是**宁可少做也不误删**的反面,
// 所以这里把降级方向定成「继续分簇 + WARN」,而不是「整个分区不分簇」:
// 判错的方向是「多留一条冗余」(可 revert),不是「丢一条记忆」。
func (s *Store) dedupeItemMeta(ctx context.Context, p tidyPartition) (map[string]tidyDedupeMeta, error) {
rows, err := s.db.QueryContext(ctx, dedupeItemMetaSQL,
p.personaID, string(p.scope), p.projectID, string(tidyStatusActive))
if err != nil {
return nil, fmt.Errorf("memory/tidy: dedupe item meta: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]tidyDedupeMeta, 64)
for rows.Next() {
var (
id string
mergeCnt int
expiresAt string
)
if err := rows.Scan(&id, &mergeCnt, &expiresAt); err != nil {
return nil, fmt.Errorf("memory/tidy: scan dedupe item meta: %w", err)
}
out[id] = tidyDedupeMeta{mergeCount: mergeCnt, temporary: strings.TrimSpace(expiresAt) != ""}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory/tidy: dedupe item meta rows: %w", err)
}
return out, nil
}
+310
View File
@@ -0,0 +1,310 @@
package memory
import (
"context"
"crypto/sha256"
"fmt"
"testing"
"time"
)
// 本文件是 `dedupe_group`(2026-10-24,M34)的验收用例集。
//
// 它钉住五件事,每件都能被一次**变异**打红:
// 1. **簇判据只有两条**(同 scope+kind+完全相同 tag 集 / 前 12 字相同)——
// 拿掉任一条、或让它跨 scope/跨 kind 成簇,用例立刻红;
// 2. **保留规则**是「最长 > merge_count 大 > created_at 早」——改动顺序即红;
// 3. **安全门与既有合并路径同源**:数值/否定/时效/低置信/受限来源一律不入簇;
// 4. **幂等**:同一批数据两次分簇产出同一个幂等键(重复跑不重复产候选);
// 5. **可应用 / 可回滚**:应用是软合并(行不删、正文不改),回滚把每一行都复原,
// 批量应用按**行**计上限、超限如实 truncated。
// dedupeTestItem 造一条「可进簇」的记忆:同分区(persona.a / persona / 无项目)、
// 置信 0.9、owner 来源(web 主会话)。tags / 内容 / 创建时刻逐项可控。
func dedupeTestItem(content string, tags []string, at time.Time) MemoryItem {
return MemoryItem{
// 确定性 id:内容 + 时刻,因此「同内容的孪生」也各有自己的 id
// (id 为空的两行会被 dedupeGroupEligible 挡在簇外 —— 那是纵深防御,
// 但夹具不该靠它)。
ID: fmt.Sprintf("item-%x-%d", sha1OfForTest(content), at.UnixNano()),
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: content, Tags: tags, Confidence: 0.9, CreatedAt: at,
SourceConversation: FormatSourceConversation("web", "msg-1"),
}
}
// sha1OfForTest 给夹具造确定性 id 用(与生产 id 无关,只要在用例内唯一)。
func sha1OfForTest(content string) []byte {
sum := sha256.Sum256([]byte(content))
return sum[:6]
}
// seedDedupeItem 落一条记忆并返回它的落库形状。
func seedDedupeItem(t *testing.T, store *Store, item MemoryItem) MemoryItem {
t.Helper()
return seedTidyItem(t, store, item)
}
// dedupeCandsOf 跑一次分簇并只保留 dedupe_group 候选。
func dedupeCandsOf(t *testing.T, store *Store, items []MemoryItem) ([]TidyCandidate, tidyDedupeStats) {
t.Helper()
tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store, Now: func() time.Time { return tidyTestTime(t) }})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
meta, err := store.dedupeItemMeta(context.Background(),
tidyPartition{personaID: "persona.a", scope: ScopePersona})
if err != nil {
t.Fatalf("dedupeItemMeta: %v", err)
}
return tidier.dedupeCandidates(items, meta, nil, tidyTestTime(t))
}
// TestDedupeGroupClustersByIdenticalTagSet 钉住判据一:**同 scope + 同 kind +
// 完全相同 tag 集**的条目成簇,每簇只产**一条**候选,存活行是「内容最长」那条。
func TestDedupeGroupClustersByIdenticalTagSet(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
tags := []string{"身份", "名字"}
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)),
seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))),
seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))),
}
cands, stats := dedupeCandsOf(t, store, items)
if len(cands) != 1 || stats.Groups != 1 {
t.Fatalf("簇数 = %d (stats %+v), want 1 —— 同 tag 集的三条必须合成一簇", len(cands), stats)
}
cand := cands[0]
if cand.Action != TidyDedupeGroup {
t.Fatalf("动作 = %q, want %q", cand.Action, TidyDedupeGroup)
}
if cand.TargetID != items[2].ID {
t.Fatalf("存活行 = %s, want 最长的那条 %s", cand.TargetID, items[2].ID)
}
if len(cand.SourceIDs) != 2 || stats.Rows != 2 {
t.Fatalf("被吸收行 = %v (rows=%d), want 2 条", cand.SourceIDs, stats.Rows)
}
if cand.Evidence.DedupeRule != tidyDedupeRuleTagSet {
t.Fatalf("簇判据 = %q, want %q", cand.Evidence.DedupeRule, tidyDedupeRuleTagSet)
}
if cand.Evidence.ClusterSize != 3 || cand.Evidence.KeptReason != tidyDedupeKeepLongest {
t.Fatalf("证据 = %+v, want cluster_size=3 kept_reason=%q", cand.Evidence, tidyDedupeKeepLongest)
}
if len(cand.Evidence.DedupeMembers) != 2 {
t.Fatalf("证据里的被吸收正文 = %d 条, want 2(面板要能一次看清吞了哪几条)",
len(cand.Evidence.DedupeMembers))
}
}
// TestDedupeGroupClustersByPrefix12 钉住判据二:**前 12 个字相同**(换个说法的同一句)
// 也会成簇 —— 这正是「整理层的包含判定覆盖不了改写」的那个缺口。
func TestDedupeGroupClustersByPrefix12(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
// 两条 tag 集**不同**(否则会先被 tag 集判据吃掉),但前 12 字逐字相同。
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem("用户与助手约定每天晚饭后一起散步", []string{"约定", "散步"}, base)),
seedDedupeItem(t, store, dedupeTestItem("用户与助手约定每天晚饭后出门散步十分钟", []string{"约定", "运动"}, base.Add(time.Minute))),
}
cands, _ := dedupeCandsOf(t, store, items)
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1(前 12 字相同 ⇒ 同一簇)", len(cands))
}
if cands[0].Evidence.DedupeRule != tidyDedupeRulePrefix {
t.Fatalf("簇判据 = %q, want %q", cands[0].Evidence.DedupeRule, tidyDedupeRulePrefix)
}
}
// TestDedupeGroupPrefixRequiresSimilarityFloor 钉住前缀簇的**收窄条件**:同开头
// 但后半句讲了别的事(相似度低于下限)⇒ 不成簇。它是「同构不同事实」的护栏。
func TestDedupeGroupPrefixRequiresSimilarityFloor(t *testing.T) {
t.Parallel()
a := "助手(persona)的说话风格:亲昵、爱撒娇"
b := "助手(persona)的象征花为蓝玫瑰和月桂叶"
if got := lexicalSimilarity(a, b); got >= dedupeGroupPrefixSimilarity {
t.Fatalf("夹具失效:两条相似度 %.3f 高于下限 %.2f,测不出护栏",
got, dedupeGroupPrefixSimilarity)
}
items := []MemoryItem{
dedupeTestItem(a, []string{"日常", "电影"}, tidyTestTime(t)),
dedupeTestItem(b, []string{"日常", "回家"}, tidyTestTime(t)),
}
cands, _ := dedupeCandsOf(t, newTestStore(t), items)
if len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(相似度低于 %.2f 的同开头对不许成簇)",
len(cands), dedupeGroupPrefixSimilarity)
}
}
// TestDedupeGroupNeverCrossesPartition 钉住硬门:跨 scope / 跨 kind 的同一事实
// **绝不成簇**(design/23 §5.2-7/8、§6;跨 scope 收敛是塔已明确「不做」的那一批)。
func TestDedupeGroupNeverCrossesPartition(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"称呼", "亲密"}
content := "主人喜欢被称呼为「叶酱」"
user := dedupeTestItem(content, tags, base)
user.Scope = ScopeUser
persona := dedupeTestItem(content, tags, base.Add(time.Minute))
worldEvent := dedupeTestItem(content, tags, base.Add(2*time.Minute))
worldEvent.Kind = KindWorldEvent
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{user, persona, worldEvent})
if len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(跨 scope / 跨 kind 是硬门,一条都不许成簇)", len(cands))
}
}
// TestDedupeGroupSkipsUnsafeMembers 钉住安全门:时效 / 低置信 / 受限来源的条目
// 连簇都不进;数值或否定与存活行不一致的成员被逐条剔出(留待人工)。
func TestDedupeGroupSkipsUnsafeMembers(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"作息", "睡眠"}
// 一簇 4 条:一条正常、一条数值不同、一条否定不同、一条时效污染。
// 数值用**ASCII 数字**:既有 sameNumbers 只认 ASCII 数字 token(中文数字
// 「十点 vs 十一点」不在它的判据里 —— 那是既有口径的已知限制,本切片不改)。
normal := dedupeTestItem("主人每晚 22 点后才有空聊天", tags, base)
numeric := dedupeTestItem("主人每晚 23 点后才有空聊天", tags, base.Add(time.Minute))
negated := dedupeTestItem("主人不再每晚 22 点后聊天", tags, base.Add(2*time.Minute))
temporal := dedupeTestItem("主人每晚 22 点后才有空聊天,假期期间更晚", tags, base.Add(3*time.Minute))
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{normal, numeric, negated, temporal})
if len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0:数值/否定不一致与时效污染的成员不许与存活行同簇(剩一条不成簇)",
len(cands))
}
// 低置信:两条**都**低置信的孪生也不许成簇(对照组用同样的内容 + 0.9 置信)。
twin := "主人每晚 22 点后才有空聊天"
lowA := dedupeTestItem(twin, tags, base)
lowA.Confidence = 0.6
lowB := dedupeTestItem(twin, tags, base.Add(time.Minute))
lowB.Confidence = 0.6
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{lowA, lowB}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(低置信是硬否决门 §5.2-6)", len(cands))
}
okA := dedupeTestItem(twin, tags, base)
okB := dedupeTestItem(twin, tags, base.Add(time.Minute))
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{okA, okB}); len(cands) != 1 {
t.Fatalf("对照组簇数 = %d, want 1(0.9 置信的两条必须成簇,否则测不出上面的否决)", len(cands))
}
// 受限来源(群聊):连簇都不进 —— 与 owner 来源的孪生也不许成簇。
owner := dedupeTestItem(twin, tags, base)
restricted := dedupeTestItem(twin, tags, base.Add(time.Minute))
restricted.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{owner, restricted}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(群聊来源只读不写 §5.2-9)", len(cands))
}
}
// TestDedupeGroupKeepsSupersetByLengthThenMergeCountThenAge 钉住保留规则的三级优先序。
func TestDedupeGroupKeepsSupersetByLengthThenMergeCountThenAge(t *testing.T) {
t.Parallel()
ctx := context.Background()
tags := []string{"宠物", "猫"}
t.Run("等长时取 merge_count 大的", func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
old := seedDedupeItem(t, store, dedupeTestItem("小茂密是白毛蝴蝶结的小猫", tags, base))
hot := seedDedupeItem(t, store, dedupeTestItem("小茂密是白毛蝴蝶结的小猫", tags, base.Add(time.Minute)))
if _, err := store.db.ExecContext(ctx,
`UPDATE memory_items SET merge_count = 4 WHERE id = ?`, hot.ID); err != nil {
t.Fatalf("bump merge_count: %v", err)
}
cands, _ := dedupeCandsOf(t, store, []MemoryItem{old, hot})
if len(cands) != 1 || cands[0].TargetID != hot.ID {
t.Fatalf("存活行 = %v, want merge_count 大的 %s", cands, hot.ID)
}
if cands[0].Evidence.KeptReason != tidyDedupeKeepMergeCount {
t.Fatalf("kept_reason = %q, want %q", cands[0].Evidence.KeptReason, tidyDedupeKeepMergeCount)
}
})
t.Run("等长同等时取更早创建的", func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
early := seedDedupeItem(t, store, dedupeTestItem("小茂密是深色蝴蝶结的小猫", tags, base))
late := seedDedupeItem(t, store, dedupeTestItem("小茂密是深色蝴蝶结的小猫", tags, base.Add(time.Minute)))
cands, _ := dedupeCandsOf(t, store, []MemoryItem{late, early})
if len(cands) != 1 || cands[0].TargetID != early.ID {
t.Fatalf("存活行 = %v, want 更早创建的 %s", cands, early.ID)
}
if cands[0].Evidence.KeptReason != tidyDedupeKeepOldest {
t.Fatalf("kept_reason = %q, want %q", cands[0].Evidence.KeptReason, tidyDedupeKeepOldest)
}
})
}
// TestDedupeGroupIsIdempotentAndDeterministic 钉住幂等:同一批数据两次分簇,候选
// 的幂等键、存活行、被吸收行集合**逐项相同**(重复跑不重复产候选)。
func TestDedupeGroupIsIdempotentAndDeterministic(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
tags := []string{"约定", "散步"}
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分钟", tags, base)),
seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分钟晒太阳", tags, base.Add(time.Minute))),
seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分", tags, base.Add(2*time.Minute))),
}
first, _ := dedupeCandsOf(t, store, items)
second, _ := dedupeCandsOf(t, store, items)
if len(first) != 1 || len(second) != 1 {
t.Fatalf("两次分簇 = %d / %d, want 1 / 1", len(first), len(second))
}
if tidyIdempotencyKey(first[0]) != tidyIdempotencyKey(second[0]) {
t.Fatalf("幂等键不同:%q vs %q", tidyIdempotencyKey(first[0]), tidyIdempotencyKey(second[0]))
}
if first[0].TargetID != second[0].TargetID ||
len(first[0].SourceIDs) != len(second[0].SourceIDs) {
t.Fatalf("两次分簇结果不一致:%+v vs %+v", first[0], second[0])
}
}
// TestDedupeGroupSuppressesPairsWithExistingCandidate 钉住「不制造第二条待办」:
// 已被 archive/merge 候选覆盖的条目不再进簇。
func TestDedupeGroupSuppressesPairsWithExistingCandidate(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
tags := []string{"身份", "名字"}
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)),
seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))),
}
tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
covered := map[string]struct{}{items[0].ID: {}, items[1].ID: {}}
if cands, _ := tidier.dedupeCandidates(items, nil, covered, tidyTestTime(t)); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(两条都已被既有候选覆盖)", len(cands))
}
// 只覆盖一条:另一条单独一条也不成簇(簇至少两条)。
if cands, _ := tidier.dedupeCandidates(items, nil, map[string]struct{}{items[0].ID: {}}, tidyTestTime(t)); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(剩一条不成簇)", len(cands))
}
}
// TestDedupeGroupIsNeverAutoApplied 钉住红线:dedupe_group **不进自动档**。
// 一次点击改多行记忆,必须在环核对(体检报告 §7.2);变异(把它的 case 加进
// tidyAutoApplyEligible)会让这条用例立刻红。
func TestDedupeGroupIsNeverAutoApplied(t *testing.T) {
t.Parallel()
cand := TidyCandidate{
Action: TidyDedupeGroup,
SourceIDs: []string{"a", "b"},
TargetID: "c",
Reason: tidyReasonDedupeGroup,
}
if ok, reason := tidyAutoApplyEligible(cand); ok {
t.Fatalf("dedupe_group 被判成可自动落地(reason=%q)—— 它必须在环核对", reason)
}
}
+8
View File
@@ -61,6 +61,14 @@ type TidyStats struct {
// ByAction 是本轮**新建**候选按动作的分布(幂等跳过的 duplicates 不计,
// 它们不是这一轮的产出)。它是「新增/合并/软删各多少」这条回执的领域来源。
ByAction map[string]int
// DedupeGroups 是本轮产出的 `dedupe_group` 簇数;DedupeRows 是这些簇**可减少**
// 的 active 行数(被吸收行合计)。两者是「机械可判重复能减多少行」的唯一口径
// —— 试算轮(DryRun)也填它,因此「先看数、后落库」不必跑两次不同代码。
DedupeGroups int
DedupeRows int
// DedupeTruncated 是本轮被每分区候选上限丢掉的 `dedupe_group` 条数(超限绝不
// 静默:它同时进 Truncated 标签与 WARN)。
DedupeTruncated int
// Errors 是本轮的失败计数(分区失败 + 候选写入失败),进运行表。
Errors int
// Truncated 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。
+22 -12
View File
@@ -98,6 +98,13 @@ type AdminTidyDryRunResponse struct {
// Truncated 列出被护栏截断的分区标签(扫描/比对/候选上限),供运维放大配额。
// 它**不是**候选;缺席表示本轮没有被截断。
Truncated []string `json:"truncated,omitempty"`
// DedupeGroups / DedupeRowsReducible 是 `dedupe_group`(2026-10-24)的**试算口径**:
// 机械可判重复分成了多少簇、这些簇一共能减少多少 active 行。
// 它们是「先看数、后落库」这条要求的落点 —— 试算零写库,数字就是真跑会产出的。
DedupeGroups int `json:"dedupeGroups"`
DedupeRowsReducible int `json:"dedupeRowsReducible"`
// DedupeTruncated 是本轮被每分区候选上限丢掉的 dedupe 簇数(超限绝不静默)。
DedupeTruncated int `json:"dedupeTruncated"`
// Candidates 恒为非 null 数组:空数组 = 「算过了,这一轮没有建议」,与
// 「没算出来」(那种情况是错误响应)**必须能区分**。
Candidates []AdminTidyDryRunCandidate `json:"candidates"`
@@ -145,18 +152,21 @@ func (s *Server) writeTidyDryRunError(w http.ResponseWriter, err error) {
// 条数与截断标签都来自领域侧已经算好的结果(Candidates 已被每分区上限截断)。
func adminTidyDryRunResponse(result memory.TidyDryRun) AdminTidyDryRunResponse {
out := AdminTidyDryRunResponse{
DryRun: true,
RecallMode: result.RecallMode,
DurationMs: result.DurationMS,
RuleVersion: memory.TidyRuleVersion,
Partitions: result.Stats.Partitions,
Scanned: result.Stats.Scanned,
Compared: result.Stats.Compared,
Pairs: result.Stats.Pairs,
Vetoed: result.Stats.Vetoed,
VetoReasons: result.Stats.VetoReasons,
Truncated: result.Stats.Truncated,
Candidates: make([]AdminTidyDryRunCandidate, 0, len(result.Candidates)),
DryRun: true,
RecallMode: result.RecallMode,
DurationMs: result.DurationMS,
RuleVersion: memory.TidyRuleVersion,
Partitions: result.Stats.Partitions,
Scanned: result.Stats.Scanned,
Compared: result.Stats.Compared,
Pairs: result.Stats.Pairs,
Vetoed: result.Stats.Vetoed,
VetoReasons: result.Stats.VetoReasons,
Truncated: result.Stats.Truncated,
DedupeGroups: result.Stats.DedupeGroups,
DedupeRowsReducible: result.Stats.DedupeRows,
DedupeTruncated: result.Stats.DedupeTruncated,
Candidates: make([]AdminTidyDryRunCandidate, 0, len(result.Candidates)),
}
for _, c := range result.Candidates {
out.Candidates = append(out.Candidates, adminTidyDryRunCandidate(c))
@@ -309,6 +309,8 @@ func adminTidyApplyBatch(batch memory.TidyApplyBatch) AdminTidyApplyBatchRespons
Merged: batch.Merged,
Archived: batch.Archived,
Reclassified: batch.Reclassified,
Deduped: batch.Deduped,
RowsAffected: batch.RowsAffected,
Results: make([]AdminTidyApplyResult, 0, len(batch.Results)),
}
for _, res := range batch.Results {
@@ -53,16 +53,21 @@ type AdminTidyApplyResult struct {
// `applied+skipped+failed` 只覆盖 `limit` 之内处理过的那些 —— 两者不相等时
// `truncated=true`(超限绝不静默)。
type AdminTidyApplyBatchResponse struct {
RunID string `json:"runId,omitempty"`
Limit int `json:"limit"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Applied int `json:"applied"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Merged int `json:"merged"`
Archived int `json:"archived"`
Reclassified int `json:"reclassified"`
RunID string `json:"runId,omitempty"`
Limit int `json:"limit"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Applied int `json:"applied"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Merged int `json:"merged"`
Archived int `json:"archived"`
Reclassified int `json:"reclassified"`
// Deduped 是成功落地的 `dedupe_group` 簇数(2026-10-24);RowsAffected 是这次
// 真的改动的**记忆行数**。两者分开是因为一簇动多行:「应用了 3 条候选」与
// 「改动了 11 行记忆」在面板上是两个不同的信息(行预算按后者计)。
Deduped int `json:"deduped"`
RowsAffected int `json:"rowsAffected"`
Results []AdminTidyApplyResult `json:"results"`
}
@@ -95,8 +100,14 @@ type AdminTidyRunResponse struct {
// 挡下(同一批数据重复跑)的数量。
CandidatesCreated int `json:"candidatesCreated"`
CandidatesDuplicate int `json:"candidatesDuplicate"`
// CandidatesByAction 是新建候选按动作的分布(merge / archive / reclassify)。
// CandidatesByAction 是新建候选按动作的分布(merge / archive / reclassify /
// dedupe_group,2026-10-24 起新增最后一档)。
CandidatesByAction map[string]int `json:"candidatesByAction"`
// DedupeGroups / DedupeRowsReducible 是 `dedupe_group` 的簇数与可减少行数;
// DedupeTruncated 是被候选上限丢掉的那部分(超限绝不静默)。
DedupeGroups int `json:"dedupeGroups"`
DedupeRowsReducible int `json:"dedupeRowsReducible"`
DedupeTruncated int `json:"dedupeTruncated"`
}
// AdminTidyRejectResponse 是「按动作批量拒绝候选」的回执(契约新增,2026-10-07)。
@@ -96,5 +96,8 @@ func adminTidyRunResponse(stats memory.TidyStats) AdminTidyRunResponse {
CandidatesCreated: stats.Candidates,
CandidatesDuplicate: stats.Duplicates,
CandidatesByAction: byAction,
DedupeGroups: stats.DedupeGroups,
DedupeRowsReducible: stats.DedupeRows,
DedupeTruncated: stats.DedupeTruncated,
}
}