Merge branch 'feat/m34-memdedup-dedupe-group-action-plus-re' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-25 00:52:28 +08:00
commit a895754d55
25 files changed
+3636 -403

No files matched your search

+235 -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,145 @@ 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(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终
// (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致**
// (r1 复审 P2-2:`用户会主动维护…并告诉助手她正在维护…` 里的「正在」只出现在从句,
// 是「会…」的习惯陈述,不许开火)。
//
// 🔴 **刻意不做的事**(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/6(2026-10-25 r1 复审 P2-2 补):
// - **「以后/从此/往后…」= 自此刻起永久成立** ⇒ 不是一次性事件。
// 反例(复审构造):`昨天主人说以后都叫我小叶` —— 句首时点只是说话的时刻。
// - **「换成/变成/改成/搬进…」= 状态已改变且新状态持续** ⇒ 不是一次性事件。
// 反例(语料实测):`茂密最近换了装:蝴蝶结从棕色变成深色…`(宠物稳定外观)。
if containsAny(text, retentionPermanenceMarkers()) {
return retentionNone
}
if containsAny(text, retentionStateChangeMarkers()) {
return retentionNone
}
// 安全阀 7:N6 **不接管 N1/N2 的语料**。含寒暄/致谢词或情绪词的句子归那两条
// 规则判(N1 有它的长度安全阀,N2 有形态匹配),N6 不越过它们改判:
// - 「谢谢你今天特地提醒我这件事,真是帮了大忙了」是社交致谢,不是事件记录;
// - 「主人最近很容易焦虑」是**情绪倾向**(长期事实),不是「最近发生了一件事」。
// 实测这两条是单纯加时点判据时唯一的两个既有正例回归。
if containsAny(text, retentionChatterWords()) || containsAny(text, retentionEmotionWords()) {
return retentionNone
}
if judgeRetentionTimepoint(text) != retentionNone {
return retentionTimepoint
}
if judgeRetentionInProgress(text) != retentionNone {
return retentionInProgress
}
return retentionNone
}
// judgeRetentionTimepoint 是 N6 的形态判定:正文里**句首前 N 个字内**出现一个
// 指示性时点词。位置判据是这条规则精度的来源(见 judgeRetentionTransient 安全阀 5)。
//
// 位置口径是「下标 < retentionTimepointLeadRunes」(0..11 共 12 个字);r1 复审
// 指出旧写法 `<= 12` 让「起于第 13 个字」也命中,与注释差 1 字,这里改严。
func judgeRetentionTimepoint(text string) retentionRule {
if idx := retentionFrontedTimepoint(text); idx >= 0 && idx < retentionTimepointLeadRunes {
return retentionTimepoint
}
return retentionNone
}
// judgeRetentionInProgress 是 N7 的形态判定:**句首前 N 个字内**出现进行中标记
// (正在 / 暂时 / 眼下 / 这会儿)。位置判据与 N6 同口径 —— 它挡的是「正在」只出现在
// 从句里、而主句其实是习惯陈述的那些句子(r1 复审 P2-2 的实测反例)。
func judgeRetentionInProgress(text string) retentionRule {
idx := retentionFirstMarkerIndex(text, retentionInProgressMarkers())
if idx >= 0 && idx < retentionTimepointLeadRunes {
return retentionInProgress
}
return retentionNone
}
// retentionFirstMarkerIndex 返回 markers 里最早出现的那个在 text 中的 rune 下标
// (-1 = 一个都没有)。
func retentionFirstMarkerIndex(text string, markers []string) int {
best := -1
for _, marker := range markers {
idx := runeIndex(text, marker)
if idx < 0 {
continue
}
if best < 0 || idx < best {
best = idx
}
}
return best
}
// retentionFrontedTimepoint 返回第一个指示性时点词在 text 里的**rune 下标**;
// 没有时点词时返回 -1。调用方再用 retentionTimepointLeadRunes 判它是否「句首」。
//
// 用 rune 下标而不是字节下标是必须的:中文一个字 3 字节,按字节算会把「前 12 字」
// 变成「前 4 字」,位置判据当场失效。
func retentionFrontedTimepoint(text string) int {
best := retentionFirstMarkerIndex(text, retentionTimepointMarkers())
if best < 0 || best >= len([]rune(text)) {
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 +414,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,224 @@
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)。
{"情绪倾向", "主人最近很容易焦虑"},
// 2026-10-25 r1 复审 P2-2 的实测误杀(同一条语料、同一套代码被漏报过的那些)。
{"宠物稳定外观(换成)", "茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了一个小铃铛"},
{"居住事实(宿舍过夜)", "主人近期在宿舍过夜,需要提醒她宿舍灯早点关、洗漱水别太凉"},
{"习惯陈述里的从句「正在」", "用户会主动维护助手与自己之间的连接通道,并告诉助手她正在维护,表明她重视保持与助手的稳定联系"},
{"习惯陈述里的从句「正在」(二)", "用户会维护自己与助手之间的连接通道,并曾表示正在为此进行维护"},
// 「以后/从此/往后」= 自此刻起永久成立(复审构造即可复现)。
{"以后都叫我小叶", "昨天主人说以后都叫我小叶"},
{"以后周末不加班", "上周主人说以后周末不加班了"},
{"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
}
}
// 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,242 @@
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{
// 长期事实
//
// 「宿舍」(2026-10-25 r1 复审 P2-2 补):语料里「近期在宿舍过夜 / 睡在宿舍」
// 是**居住事实 + 稳定照料约定**,不含「住」字却被 N6 判成临时;收它之后
// 这类句子与「住在宿舍」同档(都要过护栏②才放行)。
"生日", "纪念日", "住", "宿舍", "职业", "工作", "家庭", "家人", "孩子", "父母",
"过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄",
// 关系里程碑 / 约定
"约定", "承诺", "答应", "说好", "暗号", "口令", "称呼",
// 项目约定
"项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成",
// 能力边界(主人明确的「不要做 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
}
// retentionPermanenceMarkers 是「**自此刻起永久成立**」的显式标记:出现它就绝不是
// 一次性事件(2026-10-25 r1 复审 P2-2 补)。
//
// 反例(复审构造即可复现,旧实现会判成临时 ⇒ 7 天后物理删除):
//
// "昨天主人说以后都叫我小叶"
// "上周主人说以后周末不加班了"
//
// 句首的「昨天/上周」是**说话的时刻**,而「以后…」说的是从那一刻起一直成立的东西 ——
// N6 只看时点词就把它们判成临时,方向正好反了。
func retentionPermanenceMarkers() []string {
return []string{"以后", "往后", "今后", "从此", "自此", "从今以后"}
}
// retentionStateChangeMarkers 是「**状态已经改变**」的标记:新状态会持续下去,
// 因此不是一次性事件(2026-10-25 r1 复审 P2-2 补)。
//
// 反例(语料实测被误判临时):`茂密最近换了装:蝴蝶结从棕色变成深色…` ——
// 宠物/物品的稳定外观与配饰是长期事实,只是「最近」换过一次。
//
// 与 design/23 §5.2-3 的关系:那里说「改成了Y」是**覆盖**不是重复(合并语义),
// 与这里「覆盖后的新状态是长期的」并不矛盾 —— 两条都在说「变化之后有稳定的新事实」。
func retentionStateChangeMarkers() []string {
return []string{"换成", "变成", "改成", "改为", "换装", "搬进", "搬到"}
}
+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 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出):
+29 -62
View File
@@ -69,6 +69,10 @@ const (
tidySkipPartitionMismatch = "partition_mismatch"
// tidySkipInvalidCandidate 表示候选形状不完整(缺 target / 缺 source / kind 非法)。
tidySkipInvalidCandidate = "invalid_candidate"
// tidySkipTemporaryRow 表示簇里有行在**临时层**(`expires_at` 非空):
// 临时行 7 天后会被后台物理删除(design/23 §19),既不许当存活行(会让被吸收行
// 指向一个不存在的 id),也不值得当被吸收行(它本来就要消失)。整簇原子的 skip。
tidySkipTemporaryRow = "temporary_row"
)
// 整理应用侧的哨兵错误。与 memory 包其余错误同一口径:调用点一律 %w 包裹,
@@ -141,6 +145,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 +186,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 +198,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 +412,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 +435,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 +451,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)。
+441
View File
@@ -0,0 +1,441 @@
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
// dedupeGroupTagSetSimilarity 是 **tag 集簇**成员的相似度下限(r1 复审 P1 要求
// 「tag_set 簇也要过相似度下限,不能只有 prefix12 走这道门」)。
//
// ⚠️ 它取 **0.10**,比前缀簇那档(0.70)低一个量级,而且**刻意如此** ——
// 语料实测(active 3667、已过取值门/数值门/否定门的 503 对 tag 集成员对):
//
// floor 0.10 → 保留 98% floor 0.20 → 86% floor 0.30 → 63%
// floor 0.40 → 40% floor 0.50 → 20%
//
// 而**挡住的那 60% 全是同一事实的改写**,不是「不同事实」——例如
// `用户可能需要提醒吃早饭,容易空着肚子出门。`(0.047)对
// `早餐习惯:吃半碗炒面,需搭配牛奶或鸡蛋…`,或 `叶酱有工作,白天要上班…`(0.109)
// 对 `主人是上班族,工作日上班、单位有食堂…`。原因是语料里同一事实的多种写法
// 共享的是**语义**而不是二元组,词项余弦天然偏低。
//
// ⇒ 这道门的定位被数据改了:它只挡「正文几乎没有任何共同词项」的退化对
// (同 tag 却各说各话),**不是**同一性判据。同一性由三件套决定:数值门、
// 否定门、(P1 新增的)**取值门**。抬高这道门只会删掉合法去重、并不会多挡住
// 危险对 —— 需要更准的判据时该走 T1 模型层(不在本切片)。
dedupeGroupTagSetSimilarity = 0.10
)
// 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 + `expires_at` 是否非空)**不是可降级的旁支**:它承载的是
// 「临时层行绝不进簇」这条数据安全红线(临时行 7 天后会被后台物理删除,
// design/23 §19;让长期事实并进一个将被删除的存活行 = 信息不可达)。
//
// 因此查询失败时**这个分区这一轮不分簇**(fail-safe 的方向是「少做」而不是
// 「猜」):成本只是下一轮再来,而猜错的代价是不可逆的。
// 这是 r1 复审 P2-1 的落点 —— 旧实现降级成 nil map 后 `temporary` 恒为 false,
// 恰好把那道红线绕开了。
meta, err := t.deps.Store.dedupeItemMeta(ctx, p)
if err != nil {
t.log.Warn("memory tidy dedupe skipped: item meta unavailable",
"partition", p.label(), "error", err)
return nil, tidyDedupeStats{}
}
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
}
// 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,170 @@
package memory
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"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 逐条同源(分区硬门、只读来源硬门、行必须存在、**临时层不复核**),
// 全部在 loadDedupeGroupRows 里;任何一条不通过都如实 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, absorbed, skip, err := s.loadDedupeGroupRows(ctx, cand)
if err != nil {
return base, err
}
if skip != "" {
return skipTidyApply(base, skip), nil
}
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)
}
// loadDedupeGroupRows 读出存活行与被吸收行并做**全部前置校验**;skip 非空表示应如实
// 跳过(原因由它给出),此时两行都不可用。
//
// 四类校验,缺一不可:
// 1. **分区硬门**(§5.2-7/8):候选与行必须同 persona/scope/project;
// 2. **只读来源硬门**(§5.2-9、§6):群聊/陌生人来源绝不改写;
// 3. **临时层复核**(r1 复审 P2-1):`expires_at` 非空的行既不许当存活行(会被
// 物理删除,design/23 §19 ⇒ 被吸收行悬空)也不许当被吸收行(本来就要消失)——
// 候选从生成到被人点是过时候选,必须在落库前按主键再判一次;
// 4. 形状:source 不得等于 target。
func (s *Store) loadDedupeGroupRows(ctx context.Context, cand TidyCandidate) (MemoryItem, []MemoryItem, string, error) {
empty := MemoryItem{}
survivor, err := s.Get(ctx, cand.TargetID)
if errors.Is(err, ErrNotFound) {
return empty, nil, tidySkipSourceMissing, nil
}
if err != nil {
return empty, nil, "", err
}
if skip := dedupeRowGuard(cand, survivor); skip != "" {
return empty, nil, skip, nil
}
if temporary, terr := s.tidyItemTemporary(ctx, survivor.ID); terr != nil {
return empty, nil, "", terr
} else if temporary {
return empty, nil, tidySkipTemporaryRow, nil
}
absorbed := make([]MemoryItem, 0, len(cand.SourceIDs))
for _, id := range cand.SourceIDs {
if id == cand.TargetID {
return empty, nil, tidySkipInvalidCandidate, nil
}
it, gerr := s.Get(ctx, id)
if errors.Is(gerr, ErrNotFound) {
return empty, nil, tidySkipSourceMissing, nil
}
if gerr != nil {
return empty, nil, "", gerr
}
if skip := dedupeRowGuard(cand, it); skip != "" {
return empty, nil, skip, nil
}
if temporary, terr := s.tidyItemTemporary(ctx, it.ID); terr != nil {
return empty, nil, "", terr
} else if temporary {
return empty, nil, tidySkipTemporaryRow, nil
}
absorbed = append(absorbed, it)
}
return survivor, absorbed, "", nil
}
// dedupeRowGuard 是「这一行能不能被这条候选改动」的静态判据(分区 + 只读来源)。
func dedupeRowGuard(cand TidyCandidate, it MemoryItem) string {
if skip := tidyPartitionMismatch(cand, it); skip != "" {
return skip
}
if nonOwnerSource(it.SourceConversation) {
// 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御,
// 规则层本就把这类条目排除在分簇之外。
return tidySkipSourceRestricted
}
return ""
}
// tidyItemTemporarySQL 只读一行的 `expires_at`(临时层判据,design/03 §9.6)。
const tidyItemTemporarySQL = `SELECT expires_at FROM memory_items WHERE id = ?`
// tidyItemTemporary 报告一条记忆是否在**临时层**(`expires_at` 非空)。
//
// 它服务应用侧的**过时复核**:`MemoryItem` 的读侧 SELECT 不含这一列(该列只写不读),
// 因此候选生成时用分区级元信息判一次、真正落库前再按主键判一次 —— 两次都要,
// 因为「候选生成」与「人点 apply」之间隔着任意长的墙钟(r1 复审 P2-1)。
func (s *Store) tidyItemTemporary(ctx context.Context, id string) (bool, error) {
var expiresAt string
err := s.db.QueryRowContext(ctx, tidyItemTemporarySQL, id).Scan(&expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("memory/tidy: read item expiry %s: %w", id, err)
}
return strings.TrimSpace(expiresAt) != "", nil
}
@@ -0,0 +1,353 @@
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 为空 —— 被上限切掉的部分必须如实登记分区标签")
}
}
// TestTidyApplyDedupeGroupSkipsTemporaryRows 是 r1 复审 **P2-1 后半**的回归:
// 候选生成之后某一行才变成临时层(`expires_at` 非空)时,apply **必须整簇 skip**。
//
// 为什么必须有这道复核:候选从生成到「人点 apply」之间隔着任意长的墙钟。若存活行
// 在那之后进了临时层,7 天后它被物理删除(design/23 §19),被吸收行就会永远停在
// `status='merged'` 且 `merged_into` 指向一个不存在的 id。
func TestTidyApplyDedupeGroupSkipsTemporaryRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := tidyTestTime(t)
cases := []struct {
name string
markTarget bool
}{
{"存活行变成临时层", true},
{"被吸收行变成临时层", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
tags := []string{"身份", "名字"}
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)),
seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(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)
}
// 候选落库之后、apply 之前:把目标/被吸收行变成临时层。
victim := cand.SourceIDs[0]
if tc.markTarget {
victim = cand.TargetID
}
if _, err := store.db.ExecContext(ctx,
`UPDATE memory_items SET expires_at = ? WHERE id = ?`,
expiryText(base.Add(7*24*time.Hour)), victim); err != nil {
t.Fatalf("mark temporary: %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 != tidySkipTemporaryRow {
t.Fatalf("结果 = %+v, want skipped/%s", res, tidySkipTemporaryRow)
}
for _, id := range cand.SourceIDs {
if got := loadTidyItemRow(t, store, id).status; got != tidyStatusActive {
t.Fatalf("被吸收行 %s 状态 = %q, want active(整簇 skip,一行都不写)", id, got)
}
}
if got, _ := store.GetTidyCandidate(ctx, cand.ID); got.Status != tidyCandidatePending {
t.Fatalf("候选状态 = %q, want pending(skip 不该消费掉候选)", got.Status)
}
})
}
}
@@ -0,0 +1,109 @@
package memory
import (
"sort"
"time"
)
// 本文件是 `dedupe_group` 的**候选构造**侧:从一簇成员里挑出可并入的行、
// 并把簇转成候选(落库形状与 merge 候选同构,因此面板/应用/回滚三条路都不用改)。
// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
// 判据与注释**逐字未动**;簇形成在 tidy_dedupe.go,元信息查询在 tidy_dedupe_meta.go。
// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。
//
// 三道门都是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」,
// 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**,
// 因此它**不进这一簇**(留待人工),而不是让整簇作废:
//
// 1. **数值**(§5.2-2,judgeDedupeNumbers):**三态**判据 ——
// 同值 ⇒ 可合并(中英混写也认:`一小时` ≡ `60 分钟`)/同量纲不同值 ⇒ 冲突/
// 量纲不可比 ⇒ 保守不许合并(`一小时` vs `一杯` 不是"放行")。
// 它取代了 `sameNumbers`(后者只认 ASCII token,既合不了中英混写、也看不见中文数字);
// 裸数字 token(无单位)仍按 `sameNumbers` 的**集合相等**口径判,一字未改。
// 沿革:r2(中文数字冲突)→ r3-项 2(归一后同值必须能合)→ r3-项 3(不可比必须拒)。
// 2. **否定**(§5.2-3,sameNegations):「X」vs「非X」是覆盖不是重复;
// 3. **取值**(§5.2-4/§5.2-5,dedupeValueGaps):被吸收行的引号取值 / 拉丁 token
// 必须在存活行里一个不少 —— 这条是 r1 复审 P1 的落点(`「叶酱」` 不许并进
// `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。
//
// 相似度下限对**两种簇都生效**(`dedupeGroupMinSimilarity`):tag 集相同只是
// 「同主题」的弱证据(体检报告 §1.3 的「冰美式 vs 红牛」也同 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 verdict, _ := judgeDedupeNumbers(it.item.Content, survivor.item.Content); verdict != tidyNumberSame {
continue
}
if !sameNegations(it.item.Content, survivor.item.Content) {
continue
}
if len(dedupeValueGaps(it.item.Content, survivor.item.Content)) > 0 {
continue
}
if lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupMinSimilarity(rule) {
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
}
// dedupeGroupMinSimilarity 返回一种簇判据的相似度下限(词项余弦)。
//
// 两档刻意不同:「完全相同 tag 集」是结构化证据(同主题的强信号),容忍改写得远一点;
// 「前 12 字相同」只是「开头一样」,必须要求正文整体也相近,否则同开头不同事实
// (`助手(persona)的说话风格…` vs `助手(persona)的象征花…`)会被合掉。
func dedupeGroupMinSimilarity(rule string) float64 {
if rule == tidyDedupeRulePrefix {
return dedupeGroupPrefixSimilarity
}
return dedupeGroupTagSetSimilarity
}
// 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,88 @@
package memory
import (
"sort"
"strings"
)
// 本文件是 `dedupe_group` 的**簇键**:一条记忆凭什么与另一条同簇。
// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
// 判据与注释**逐字未动**。
// 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])
}
@@ -0,0 +1,63 @@
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 → 元信息映射。
//
// 🔴 **失败方向是「这个分区这轮不分簇」**(不是「降级继续」):`temporary` 承载的是
// 「临时行绝不进簇」这条数据安全红线,而 nil map 会让 `temporary` 恒为 false ——
// 那等于把红线绕开(r1 复审 P2-1 实测的正是这个口子)。merge_count 缺了确实只影响
// 「等长时留哪条」,但两列同源同一次查询,为一条次要判据去承担一条红线风险不划算。
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
}
@@ -0,0 +1,476 @@
package memory
import (
"fmt"
"sort"
"strconv"
"strings"
)
// 本文件是 `dedupe_group` 的**数值门**(2026-10-25):数值冲突要同时认出 ASCII 数字
// 与中文数字,按**量纲/单位归一**后比较,并给出**三态**结论。
//
// 沿革(三轮复审各修一处,别把其中任何一条当"可简化"):
// - r2:`sameNumbers` 只抽 ASCII 数字 token,而语料里的数字主要写成中文
// (一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒ 真冲突被当同一事实合并(实测 ~8 对);
// - r3-项 2(**过严**):归一后**同值**、中文与 ASCII 混写的两条必须能合并
// (`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)—— 归一化就是为此存在;
// - r3-项 3(**过松**):**量纲不可比**(`一小时` vs `一杯`)不许"当作不算冲突"放行,
// 必须按「比不了就不许合并」保守拒绝。
//
// ⇒ 判据是**三态**,不是布尔「有没有冲突」:
//
// 同值 ⇒ 可合并(tidyNumberSame)
// 同量纲不同值 ⇒ 冲突,不许合并(tidyNumberConflict)
// 量纲不可比/无可比 ⇒ 保守不许合并(tidyNumberIncomparable)
//
// 三态都建立在同一个前提上:**被吸收行的每个数量都要在存活行里找到等值对应**
// (方向与被吸收 ⊆ 存活 的软合并语义一致)。找不到时,看**为什么找不到**来分态:
// 存活行在同一量纲里有数量但值不同 ⇒ 冲突;存活行在该量纲里根本没有数量
//(含"存活行一个数字都没有")⇒ 量纲不可比 ⇒ 保守拒绝。
//
// 形态判据是「**数字 + 单位**」而不是「数字」:
// - 只认后面紧跟已知单位(小时/分钟/杯/次/份…)的数字,因此「一起」「一直」
// 「一些」「一样」「一部分」这类含数字的常见词不会被当成数量;
// - 没有单位的数字走**裸 token** 路径(与既有 `extractTidyFacts` 的 num 抽取同口径):
// 两侧 token 集合必须**完全相等**(既有 `sameNumbers` 的语义,一字不改)。
// tidyNumberVerdict 是一对记忆的数值关系(三态)。
type tidyNumberVerdict int
const (
// tidyNumberSame:归一后数值一致(或无数量可比且裸 token 相同)⇒ 可合并。
tidyNumberSame tidyNumberVerdict = iota
// tidyNumberConflict:同一量纲里值不相等 ⇒ 冲突,不许合并。
tidyNumberConflict
// tidyNumberIncomparable:量纲不可比 / 存活行没有可比数量 ⇒ 保守不许合并。
tidyNumberIncomparable
)
// String 让证据与测试断言可读。
func (v tidyNumberVerdict) String() string {
switch v {
case tidyNumberSame:
return "same"
case tidyNumberConflict:
return "conflict"
case tidyNumberIncomparable:
return "incomparable"
default:
return "unknown"
}
}
// tidyQuantity 是一处「数量」:归一后的值 + 单位名 + 量纲。
type tidyQuantity struct {
// value 是按单位换算到**量纲基准单位**后的值(时间=秒、计数=个、长度=米)。
value float64
// unit 是单位名(写清冲突原因,也是「能不能换算」的判据)。
unit string
// dim 是量纲(time / count / length / text)。同量纲才可比。
dim string
// raw 是原始写法(数字 + 单位),只用于日志/测试断言。
raw string
}
// judgeDedupeNumbers 判一对记忆的数值关系(三态),并给出理由(证据/日志用)。
//
// 顺序:先比**裸数字 token**(与 `sameNumbers` 同语义:集合必须完全相等),
// 再逐条比**数量**(同值 / 同量纲不同值 / 不可比)。
func judgeDedupeNumbers(absorbed, survivor string) (tidyNumberVerdict, []string) {
aQty, aBare := scanTidyNumbers(absorbed)
sQty, sBare := scanTidyNumbers(survivor)
if !sameStringSet(aBare, sBare) {
// 裸数字(无单位)不一致:与既有 sameNumbers 同一条口径 —— 数值不同即不同事实。
return tidyNumberConflict, []string{fmt.Sprintf("裸数字 token 不一致:%v vs %v", aBare, sBare)}
}
verdict := tidyNumberSame
reasons := make([]string, 0, 2)
for _, q := range aQty {
same, sameDim := false, false
for _, h := range sQty {
if h.dim != q.dim {
continue
}
sameDim = true
if sameQuantity(q, h) {
same = true
break
}
}
if same {
continue
}
if sameDim {
// 同量纲、值不相等(含单位换算不到一起)⇒ 真冲突。
verdict = tidyNumberConflict
reasons = append(reasons, fmt.Sprintf("%s≠同量纲的其它取值", q.raw))
continue
}
// 存活行在该量纲里没有数量 ⇒ 比不了 ⇒ 保守拒绝(r3-项 3)。
// 注意:这条**不是**「放行」,因此 `一小时` vs `一杯` 不会被合并。
if verdict != tidyNumberConflict {
verdict = tidyNumberIncomparable
}
reasons = append(reasons, fmt.Sprintf("%s 在存活行里没有同量纲数量可比", q.raw))
}
return verdict, reasons
}
// sameQuantity 报告两处数量是否等值(归一化后比较)。
func sameQuantity(a, b tidyQuantity) bool {
if a.dim != b.dim {
return false
}
diff := a.value - b.value
if diff < 0 {
diff = -diff
}
// 浮点留一点余量:换算会乘除(半小时 = 0.5×3600 秒)。
return diff < 0.5
}
// sameStringSet 报告两个字符串切片(已排序去重)是否完全相等。
func sameStringSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// tidyQuantities 抽出一段正文里所有的「数字 + 单位」(ASCII 与中文数字都算)。
func tidyQuantities(text string) []tidyQuantity {
qty, _ := scanTidyNumbers(text)
return qty
}
// scanTidyNumbers 一次扫描产出两类结果:
// - quantities:「数字 + 已知单位」的数量(ASCII 数字与中文数字都算);
// - bare:**没有单位**的 ASCII 数字 token(口径与 extractTidyFacts 的 nums 一致:
// 数字之间的 : : / . - 并入同一个 token),升序去重。
//
// 中文数字没有单位时**不产出任何东西**(「一起」「一直」「第二天」这类习语占绝对多数)。
func scanTidyNumbers(text string) ([]tidyQuantity, []string) {
normalized := dedupeKey(text)
runes := []rune(normalized)
qty := make([]tidyQuantity, 0, 4)
bare := make([]string, 0, 2)
for i := 0; i < len(runes); i++ {
// ① 中文数字 + 单位
if value, next, ok := parseChineseNumberAt(runes, i); ok {
unit, dim, factor, unext, uok := tidyUnitAt(runes, next)
if !uok || tidyOrdinalPosition(runes, i, dim) {
i = next - 1
continue
}
qty = append(qty, tidyQuantity{
value: value * factor, unit: unit, dim: dim, raw: string(runes[i:unext]),
})
i = unext - 1
continue
}
// ② ASCII 数字 + 单位(`60 分钟` / `15分钟` / `3 份`)
if runes[i] < '0' || runes[i] > '9' {
continue
}
token, tend := scanASCIINumberToken(runes, i)
value, isFloat := parseASCIINumberValue(token)
unitAt := skipASCIISpaces(runes, tend)
if unit, dim, factor, unext, uok := tidyUnitAt(runes, unitAt); uok && isFloat {
qty = append(qty, tidyQuantity{
value: value * factor, unit: unit, dim: dim, raw: string(runes[i:unext]),
})
i = unext - 1
continue
}
bare = append(bare, token)
i = tend - 1
}
sort.Strings(bare)
bare = dedupeSortedStrings(bare)
return qty, bare
}
// scanASCIINumberToken 读一个 ASCII 数字 token(数字之间允许 : : / . - 分隔,
// 与 extractTidyFacts 的 num 抽取同一规则)。返回去掉首尾分隔符的 token 与结束下标。
func scanASCIINumberToken(runes []rune, at int) (string, int) {
var b strings.Builder
i := at
for ; i < len(runes); i++ {
r := runes[i]
if r >= '0' && r <= '9' {
b.WriteRune(r)
continue
}
if (r == ':' || r == ':' || r == '/' || r == '.' || r == '-') && b.Len() > 0 {
b.WriteRune(r)
continue
}
break
}
return strings.Trim(b.String(), "::/.-"), i
}
// parseASCIINumberValue 把 ASCII 数字 token 解析成数值。带分隔符的复合 token
// (`22:38`、`10/3`、`3.5` 之外的多段写法)返回 false —— 它们不是「数量」,
// 只作为裸 token 参与集合比较。
func parseASCIINumberValue(token string) (float64, bool) {
if token == "" {
return 0, false
}
if v, err := strconv.ParseFloat(token, 64); err == nil {
return v, true
}
return 0, false
}
// skipASCIISpaces 跳过一个空白(`60 分钟` 这种写法)。
func skipASCIISpaces(runes []rune, at int) int {
if at < len(runes) && runes[at] == ' ' {
return at + 1
}
return at
}
// dedupeSortedStrings 去掉已排序切片里的重复项。
func dedupeSortedStrings(in []string) []string {
if len(in) < 2 {
return in
}
out := in[:1]
for _, s := range in[1:] {
if s != out[len(out)-1] {
out = append(out, s)
}
}
return out
}
// tidyOrdinalPosition 报告这个数字是不是「第X」式的**序数位置**(只对时间量纲的
// 天/周/月/年成立:「第二天」是次序,不是两天)。序数 + 计数量词(第二杯)仍是数量。
func tidyOrdinalPosition(runes []rune, idx int, dim string) bool {
if dim != tidyDimTime || idx == 0 {
return false
}
return runes[idx-1] == '第'
}
// 量纲名(同量纲才可比)。
const (
tidyDimTime = "time"
tidyDimCount = "count"
tidyDimLength = "length"
tidyDimText = "text"
)
// tidyUnitAt 在 text[at:] 处匹配一个已知单位(**最长优先**:`个月` 先于 `个`)。
// 返回单位名、量纲、换算到量纲基准的系数与匹配结束下标。
func tidyUnitAt(text []rune, at int) (string, string, float64, int, bool) {
best := ""
var bestDim string
var bestFactor float64
bestEnd := at
for _, u := range tidyUnitTable() {
n := len([]rune(u.name))
if at+n > len(text) {
continue
}
if string(text[at:at+n]) != u.name {
continue
}
if n > len([]rune(best)) {
best, bestDim, bestFactor, bestEnd = u.name, u.dim, u.factor, at+n
}
}
if best == "" {
return "", "", 0, at, false
}
if best == "个" {
// 「个」在「一个小时 / 一个星期」里是**量词**而不是计数单位:它后面若紧跟
// 另一个单位,以那个单位为准(否则「一个小时」会被读成「一个」而丢掉时长)。
if inner, dim, factor, end, ok := tidyUnitAt(text, bestEnd); ok {
return inner, dim, factor, end, true
}
}
return best, bestDim, bestFactor, bestEnd, true
}
// tidyUnit 是单位表的一行。
type tidyUnit struct {
name string
dim string
factor float64
}
// tidyUnitTable 是已知单位表(换算到量纲基准单位:时间=秒、计数=个、长度=米)。
//
// 月份/年用**约定值**(30 天 / 365 天)换算:它们在语料里是「多久」的意思,
// 不换算就变成「比不了」,那会让「半年 vs 六个月」也被判不可比。
func tidyUnitTable() []tidyUnit {
return []tidyUnit{
// 时间(基准:秒)
{"秒钟", tidyDimTime, 1},
{"秒", tidyDimTime, 1},
{"分钟", tidyDimTime, 60},
{"分", tidyDimTime, 60},
{"小时", tidyDimTime, 3600},
{"钟头", tidyDimTime, 3600},
{"天", tidyDimTime, 86400},
{"日", tidyDimTime, 86400},
{"周", tidyDimTime, 604800},
{"星期", tidyDimTime, 604800},
{"礼拜", tidyDimTime, 604800},
{"个月", tidyDimTime, 2592000},
{"年", tidyDimTime, 31536000},
// 计数(基准:个;不同计数单位之间**不可互换**)
{"次", tidyDimCount, 1},
{"遍", tidyDimCount, 1},
{"圈", tidyDimCount, 1},
{"步", tidyDimCount, 1},
{"杯", tidyDimCount, 1},
{"碗", tidyDimCount, 1},
{"口", tidyDimCount, 1},
{"份", tidyDimCount, 1},
{"张", tidyDimCount, 1},
{"条", tidyDimCount, 1},
{"件", tidyDimCount, 1},
{"个", tidyDimCount, 1},
{"位", tidyDimCount, 1},
{"只", tidyDimCount, 1},
{"颗", tidyDimCount, 1},
{"瓶", tidyDimCount, 1},
{"盒", tidyDimCount, 1},
{"包", tidyDimCount, 1},
{"岁", tidyDimCount, 1},
{"层", tidyDimCount, 1},
{"段", tidyDimCount, 1},
{"篇", tidyDimCount, 1},
{"首", tidyDimCount, 1},
{"集", tidyDimCount, 1},
// 长度(基准:米)
{"公里", tidyDimLength, 1000},
{"千米", tidyDimLength, 1000},
{"米", tidyDimLength, 1},
{"厘米", tidyDimLength, 0.01},
// 文字量(基准:个)
{"字符", tidyDimText, 1},
{"字节", tidyDimText, 1},
{"行", tidyDimText, 1},
{"字", tidyDimText, 1},
{"页", tidyDimText, 1},
}
}
// parseChineseNumberAt 在 text[at:] 处解析一个中文数字(含「半」),返回数值与结束下标。
//
// 覆盖语料里真实出现的形态:一/两/三…九、十、十五、二十、二十四、一百二十、两千、
// 一万两千、半。**不认**「几/十几/数/好几」这类模糊量词(它们不是可比数量)。
func parseChineseNumberAt(text []rune, at int) (float64, int, bool) {
if at >= len(text) {
return 0, at, false
}
if text[at] == '半' {
return 0.5, at + 1, true
}
total := 0.0 // 已结算的「万」以上段与整段合计
section := 0.0 // 当前小节(万以下)
digit := 0.0
seen := false
hasUnitRune := false
i := at
for ; i < len(text); i++ {
r := text[i]
if d, ok := chineseDigitValue(r); ok {
digit = d
seen = true
continue
}
switch r {
case '十':
if !seen {
digit = 1
}
section += digit * 10
digit = 0
seen = true
hasUnitRune = true
case '百':
if !seen {
digit = 1
}
section += digit * 100
digit = 0
seen = true
hasUnitRune = true
case '千':
if !seen {
digit = 1
}
section += digit * 1000
digit = 0
seen = true
hasUnitRune = true
case '万':
if !seen {
digit = 1
}
section += digit
total += section * 10000
section, digit = 0, 0
seen = true
hasUnitRune = true
default:
if !seen {
return 0, at, false
}
total += section + digit
if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 {
// 「两三」「三四」这类并列不是确定数量,按模糊处理(不认)。
return 0, at, false
}
return total, i, true
}
}
if !seen {
return 0, at, false
}
total += section + digit
if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 {
return 0, at, false
}
return total, i, true
}
// chineseDigitValue 是中文数字字符到值的映射(不含十百千万半)。
func chineseDigitValue(r rune) (float64, bool) {
switch r {
case '零', '〇':
return 0, true
case '一':
return 1, true
case '二', '两', '俩':
return 2, true
case '三':
return 3, true
case '四':
return 4, true
case '五':
return 5, true
case '六':
return 6, true
case '七':
return 7, true
case '八':
return 8, true
case '九':
return 9, true
default:
return 0, false
}
}
@@ -0,0 +1,235 @@
package memory
import (
"sort"
"testing"
"time"
)
// 本文件是 `dedupe_group` **数值门**的验收用例集(三态)。
//
// 沿革:r2 复审要「认出中文数字的数值冲突」→ r3 复审把规格收成**三态**,并指出我第一版
// 两个方向都反了:
// - 项 2(过严):归一后**同值**、中文与 ASCII 混写(`一小时` vs `60 分钟`)必须**能合并**;
// - 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)必须**保守不许合并**,不是放行。
//
// 因此本文件的核心用例是「三态各一条」,且每条都同时断言**判定**与**簇结果** ——
// 只断言簇结果的话,别的原因(相似度地板 / 取值门)也能让簇消失,测不出数值门。
// TestDedupeGroupNumberGateThreeStates 是三态的主用例。
//
// 旧逻辑红/新逻辑绿的证据(已实测):
// - 同值那一条:旧代码用 `sameNumbers`(只认 ASCII token)⇒ `{}` vs `{60}` 不等 ⇒ 不合 ⇒ 红;
// - 不可比那一条:r2 版把「存活行同量纲没有数量」当**放行** ⇒ 合了 ⇒ 红。
func TestDedupeGroupNumberGateThreeStates(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
cases := []struct {
name string
survivor string
member string
want tidyNumberVerdict
wantMerged bool
}{
{
name: "同值:中文与 ASCII 混写(一小时 ≡ 60 分钟)",
survivor: "主人午睡 60 分钟,下午很清醒。",
member: "主人午睡一小时,午后精神好。",
want: tidyNumberSame,
wantMerged: true,
},
{
name: "同值:十五分钟 ≡ 15 分钟",
survivor: "主人午睡 15 分钟,起来刚好。",
member: "主人午睡十五分钟,醒来正好。",
want: tidyNumberSame,
wantMerged: true,
},
{
name: "同量纲不同值:一小时 vs 十五分钟",
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
want: tidyNumberConflict,
wantMerged: false,
},
{
name: "量纲不可比:一小时 vs 一杯",
survivor: "主人午睡时喝了一杯温牛奶,午后精神好。",
member: "主人午睡一小时,午后精神好。",
want: tidyNumberIncomparable,
wantMerged: false,
},
}
tags := []string{"作息", "午睡"}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
a := dedupeTestItem(tc.survivor, tags, base)
b := dedupeTestItem(tc.member, tags, base.Add(time.Minute))
verdict, reasons := judgeDedupeNumbers(b.Content, a.Content)
if verdict != tc.want {
t.Fatalf("判定 = %s %v, want %s(MEMBER=%q SURV=%q)",
verdict, reasons, tc.want, b.Content, a.Content)
}
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b})
if tc.wantMerged && len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1(同值必须能合并)", len(cands))
}
if !tc.wantMerged && len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(%s ⇒ 不许合并)", len(cands), verdict)
}
})
}
}
// TestDedupeGroupRejectsConflictingChineseNumbers 是 r2 复审 **P2 的回归用例**:
// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」曾被当成同一事实。
func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
cases := []struct {
name string
survivor string
member string
tags []string
}{
{
name: "午睡时长(一小时 vs 十五分钟)",
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
tags: []string{"作息", "午睡"},
},
{
name: "咖啡杯数(第二杯 vs 一杯)",
survivor: "用户被约定:一天咖啡不许续第二杯,下午犯困时起身走动、喝温水。",
member: "用户有喝咖啡的习惯,助手提醒她每天不超过一杯(第二杯不许续)。",
tags: []string{"习惯", "咖啡"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
a := dedupeTestItem(tc.survivor, tc.tags, base)
b := dedupeTestItem(tc.member, tc.tags, base.Add(time.Minute))
if verdict, _ := judgeDedupeNumbers(b.Content, a.Content); verdict != tidyNumberConflict {
t.Fatalf("判定 = %s, want conflict", verdict)
}
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(数值冲突=不同事实,§5.2-2)", len(cands))
}
})
}
}
// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」:
// 两杯 vs 三瓶是同一量纲的不同取值,但杯/瓶不能互换。
func TestDedupeGroupRejectsNonConvertibleUnits(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"习惯", "饮水"}
a := dedupeTestItem("主人每天要喝两杯咖啡,下午不续。", tags, base)
b := dedupeTestItem("主人每天要喝三瓶水,下午也要喝。", tags, base.Add(time.Minute))
if verdict, _ := judgeDedupeNumbers(b.Content, a.Content); verdict != tidyNumberConflict {
t.Fatalf("判定 = %s, want conflict(同量纲不同值)", verdict)
}
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(单位不可换算 ⇒ 保守不合并)", len(cands))
}
}
// TestDedupeGroupSameChineseNumberStillMerges 是数值门的**反向护栏**:
// 值相等(含不同单位换算后相等、中英混写)照常成簇 —— 门收的是「冲突」,不是「出现数字」。
func TestDedupeGroupSameChineseNumberStillMerges(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"作息", "午睡"}
a := dedupeTestItem("主人午睡一小时,午后精神好。", tags, base)
b := dedupeTestItem("主人午睡一个小时,下午很清醒。", tags, base.Add(time.Minute))
c := dedupeTestItem("主人午睡 60 分钟,起来神清气爽。", tags, base.Add(2*time.Minute))
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b, c})
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1(同值照常合并 —— 门不许退化成「有数字就不合」)", len(cands))
}
}
// TestTidyQuantitiesNumberParsing 钉住数量解析口径:中文数字、ASCII 数字、单位换算、
// 以及「没有单位的数字走裸 token 路径」。
func TestTidyQuantitiesNumberParsing(t *testing.T) {
t.Parallel()
cases := []struct {
text string
value float64
unit string
dim string
ok bool
}{
{"主人午睡一小时", 3600, "小时", tidyDimTime, true},
{"主人午睡十五分钟", 900, "分钟", tidyDimTime, true},
{"主人午睡半个小时", 1800, "小时", tidyDimTime, true},
{"一万两千行的验收纸条", 12000, "行", tidyDimText, true},
{"每天要喝两杯咖啡", 2, "杯", tidyDimCount, true},
// ASCII 数字 + 单位(r3 必需:`60 分钟` 与 `一小时` 要能归一)
{"主人午睡 60 分钟", 3600, "分钟", tidyDimTime, true},
{"主人午睡15分钟", 900, "分钟", tidyDimTime, true},
// 序数与习语:不是数量。
{"第二天再说", 0, "", "", false},
{"一起散步", 0, "", "", false},
{"一直加班", 0, "", "", false},
}
for _, tc := range cases {
got := tidyQuantities(tc.text)
if !tc.ok {
if len(got) != 0 {
t.Fatalf("%q 应不产生数量,得到 %+v", tc.text, got)
}
continue
}
if len(got) == 0 {
t.Fatalf("%q 没解析出数量", tc.text)
}
if got[0].value != tc.value || got[0].unit != tc.unit || got[0].dim != tc.dim {
t.Fatalf("%q 解析为 %+v, want value=%v unit=%s dim=%s",
tc.text, got[0], tc.value, tc.unit, tc.dim)
}
}
}
// TestScanTidyNumbersBareTokensMatchExistingFactNums 钉住「裸数字 token 口径没变」:
// 当正文里**没有任何「数字 + 单位」**时,本文件的裸 token 抽取必须与既有
// `extractTidyFacts` 的 nums 完全一致 —— 否则「数值门取代 sameNumbers」会悄悄放宽
// 一条既有判据(`0023` vs `0024` 这类无单位数字正是靠它拦住的)。
//
// 有「数字 + 单位」时**刻意**不再进裸 token(那正是 r3 项 2 要的:`60 分钟` 要能与
// `一小时` 归一),因此第二条断言把这个分歧也钉住。
func TestScanTidyNumbersBareTokensMatchExistingFactNums(t *testing.T) {
t.Parallel()
// 这些输入里**没有任何「数字 + 单位」**,因此裸 token 必须与既有抽取逐项一致。
parity := []string{
"迁移号从 0023 起递增",
"文件路径 D:/a/b 与端口 8080",
"h3R3 的歌曲循环了三遍",
"登记编号 624046 已入库",
"2026-10-03 那条记录", // 日期 token 由 hasAbsoluteTimestamp 在分簇前就排除
}
for _, text := range parity {
_, bare := scanTidyNumbers(text)
facts := extractTidyFacts(text)
want := make([]string, 0, len(facts.nums))
for n := range facts.nums {
want = append(want, n)
}
sort.Strings(want)
if !sameStringSet(bare, want) {
t.Fatalf("%q 裸 token = %v, want %v(必须与 extractTidyFacts 同口径)", text, bare, want)
}
}
// 分歧(刻意):带单位的数字进「数量」,不再充当裸 token。
qty, bare := scanTidyNumbers("主人午睡 60 分钟")
if len(bare) != 0 {
t.Fatalf("`60 分钟` 的裸 token = %v, want 空(它已归一成数量)", bare)
}
if len(qty) != 1 || qty[0].value != 3600 || qty[0].dim != tidyDimTime {
t.Fatalf("`60 分钟` 解析 = %+v, want 3600 秒(时间量纲)", qty)
}
}
+359
View File
@@ -0,0 +1,359 @@
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)
}
}
// TestDedupeInPartitionSkipsWhenMetaUnavailable 是 r1 复审 **P2-1 前半**的回归:
// 元信息查询失败时**整个分区这一轮不分簇**(旧实现降级成 nil map,而 nil map 上
// `temporary` 恒为 false ⇒ 把「临时行绝不进簇」那条红线绕开了)。
func TestDedupeInPartitionSkipsWhenMetaUnavailable(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))),
}
tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
// 让元信息查询失败:关掉底层库(查询随之报错)。
if err := store.db.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
cands, stats := tidier.dedupeInPartition(ctx,
tidyPartition{personaID: "persona.a", scope: ScopePersona}, items, nil)
if len(cands) != 0 || stats.Groups != 0 {
t.Fatalf("簇数 = %d (stats %+v), want 0(元信息不可得 ⇒ 本轮不做,而不是猜)",
len(cands), stats)
}
}
// TestDedupeGroupTagSetAlsoNeedsSimilarityFloor 钉住「tag 集簇也要过相似度下限」
// (r1 复审 P1 修法第 2 条):同 tag 集但**正文几乎没有任何共同词项**的两条不许成簇。
//
// 这条下限取值刻意很低(0.10,见 dedupeGroupTagSetSimilarity 的实测依据):语料里
// 同一事实的多种写法的相似度本来就在 0.05~0.4 之间,抬高它只会删掉合法去重。
// 因此本用例用**零重叠**的极端对钉住「门确实在」,而不是钉某个档位。
func TestDedupeGroupTagSetAlsoNeedsSimilarityFloor(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"日常", "杂项"}
a := dedupeTestItem("主人喜欢冰美式,夏天要加冰", tags, base)
b := dedupeTestItem("仓库里的迁移脚本必须先过静态检查", tags, base.Add(time.Minute))
if got := lexicalSimilarity(a.Content, b.Content); got >= dedupeGroupTagSetSimilarity {
t.Fatalf("夹具失效:两条相似度 %.3f 已高于下限 %.2f", got, dedupeGroupTagSetSimilarity)
}
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(同 tag 集但正文零重叠 ⇒ 不成簇)", len(cands))
}
}
@@ -0,0 +1,167 @@
package memory
import (
"sort"
"strings"
"unicode"
)
// 本文件是 `dedupe_group` 的**取值门**(2026-10-25,r1 复审 P1 的落点):
// 合并前必须确认「被吸收行里的**取值**」在存活行里一个不少。
//
// 动因是一次**会毁数据**的实测:生产语料里 `tag_set` 簇(同 tag 集)把
// `用户的昵称是「叶酱」。` 并进了 `主人的昵称是「喵喵七号」…` 的存活行 —— 两条
// **同属性、不同取值**(design/23 §5.2-4/§5.2-5 的绝不合并清单),而同库另有 4 行
// 明确写着「喵喵七号已作废、应称叶酱」。一次 apply 就会让当前事实消失,并留下与
// 库内其它行直接矛盾的结论。
//
// 根因不是某条词表写漏,而是**判据缺一件**:design/23 §5.1-3 要求「专有名词、
// 数值、否定词」逐一比对,而实现只有后两件(`sameNumbers` / `sameNegations`)。
// 本文件的取值门补的就是「专有名词」那一件,因此它对**两种簇判据都生效**
//(不是只对 prefix12 生效的相似度下限那种补丁)。
//
// 方向是**单向**的(被吸收 ⊆ 存活),与软合并的语义同向:合并后存活行的正文
// 原样保留,所以**存活行多出来的取值不会丢**;会丢的只有「被吸收行有、存活行没有」
// 的那些取值。因此判据是「被吸收行的每个取值都出现在存活行里」,而不是「两侧取值
// 集合相等」—— 后者会把「超集吸收子集」这个正常方向也挡掉。
// dedupeValueGaps 返回被吸收行里**在存活行正文中缺失**的取值(空 = 可以合并)。
//
// 取值有两类,都是 design/23 §5.1-3 说的「专有名词」的可机械抓手:
//
// 1. **引号内取值**(`「」` / `『』` / `“”` / `‘’` / ASCII `"`):昵称、暗号、口令、
// 代号、台词这类「同一属性的一组取值」在语料里几乎总被引号括起来 ——
// `「喵喵七号」` vs `「叶酱」` 就是这一类的正例;
// 2. **拉丁 token**(≥2 个字母的 ASCII 词,含 `h3R3` / `AAA` 这种混排):
// 人名 / 型号 / 代号 / 英文标题。中文正文里它们不会被分词歧义吃掉。
//
// **刻意不收**的:ASCII 单引号 `'`(英文所有格 `assistant's` 会被误配成引号对,
// 于是整句剩余部分都要在存活行里出现 —— 方向虽安全,但会把大量英文条目挡在簇外);
// 中文数字(`十点` vs `十一点`):既有 `sameNumbers` 只认 ASCII 数字 token,
// 扩它属于改既有口径,不在本切片。
//
// 比较用**归一化正文**(小写 + 空白折叠,同 dedupeKey):取值比对不该被大小写或
// 多余空格左右。
func dedupeValueGaps(absorbed, survivor string) []string {
haystack := dedupeKey(survivor)
if haystack == "" {
return []string{"(empty survivor)"}
}
gaps := make([]string, 0, 2)
seen := make(map[string]struct{}, 4)
for _, value := range dedupeValues(absorbed) {
if _, dup := seen[value]; dup {
continue
}
seen[value] = struct{}{}
if !strings.Contains(haystack, value) {
gaps = append(gaps, value)
}
}
sort.Strings(gaps)
return gaps
}
// 取值门的两个阈值。
const (
// dedupeQuoteMaxRunes 是「当作取值」的引号内容长度上限(rune 计):取值门的靶子
// 是短标识(昵称/暗号/代号/型号),一整段引文作为取值只会把「同一件事的不同引述」
// 全挡掉。超过它的引号内容**不产生取值**(即不用它否决合并)。
dedupeQuoteMaxRunes = 24
// dedupeLatinMinRunes 是「当作取值」的拉丁 token 最小长度:单字母(`a`、`x`)
// 在正文里到处都是,收它等于给每个簇加一道随机否决。
dedupeLatinMinRunes = 2
)
// dedupeValues 抽出一段正文里的**取值**(引号内取值 + 拉丁 token),已归一化为小写。
//
// 引号配对按「开 → 就近的闭」:中文引号有明确的开关字符,配不上对的那一段
// 整个丢弃(解析失败时**不产生取值**,因此不会凭它否决合并 —— 少一条取值门
// 比凭空造一条安全门更不容易误伤)。
func dedupeValues(content string) []string {
text := dedupeKey(content)
if text == "" {
return nil
}
out := make([]string, 0, 4)
out = append(out, dedupeQuotedValues(text)...)
out = append(out, dedupeLatinTokens(text)...)
return out
}
// dedupeQuotePairs 是引号的开关对(同一字符既是开也是闭的只有 ASCII `"`)。
func dedupeQuotePairs() [][2]rune {
return [][2]rune{
{'「', '」'},
{'『', '』'},
{'“', '”'},
{'‘', '’'},
{'"', '"'},
}
}
// dedupeQuotedValues 抽出所有**成对**引号里的内容(去首尾空白、丢弃空串与超长串)。
//
// 超长串(> dedupeQuoteMaxRunes)丢弃是刻意的:一整段引文(台词、段落)作为取值门
// 会把「同一件事的不同引述」全挡掉,而取值门的靶子是**短标识**(昵称/暗号/代号)。
func dedupeQuotedValues(text string) []string {
runes := []rune(text)
out := make([]string, 0, 2)
for i := 0; i < len(runes); i++ {
open := runes[i]
var closer rune
switch open {
case '「':
closer = '」'
case '『':
closer = '』'
case '“':
closer = '”'
case '‘':
closer = '’'
case '"':
closer = '"'
default:
continue
}
for j := i + 1; j < len(runes); j++ {
if runes[j] != closer {
continue
}
value := strings.TrimSpace(string(runes[i+1 : j]))
if value != "" && len([]rune(value)) <= dedupeQuoteMaxRunes {
out = append(out, value)
}
i = j
break
}
}
return out
}
// dedupeLatinTokens 抽出 ≥2 个字母的 ASCII 词(含 `h3R3` 这类字母+数字混排)。
func dedupeLatinTokens(text string) []string {
out := make([]string, 0, 2)
var cur []rune
hasLetter := false
flush := func() {
if hasLetter && len(cur) >= dedupeLatinMinRunes {
out = append(out, string(cur))
}
cur = nil
hasLetter = false
}
for _, r := range text {
if r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
cur = append(cur, r)
if unicode.IsLetter(r) {
hasLetter = true
}
continue
}
flush()
}
flush()
return out
}
@@ -0,0 +1,96 @@
package memory
import (
"strings"
"testing"
"time"
)
// 本文件是 `dedupe_group` **取值门**(r1 复审 P1)的验收用例集:
// 同属性不同取值(`「老K」` vs `「阿七」`)、拉丁取值、以及簇级的「取值不丢」不变式。
// 分簇/候选构造的用例在 tidy_dedupe_test.go,中文数字门在 tidy_dedupe_numbers_test.go。
// TestDedupeGroupRejectsSameTagDifferentValue 是 r1 复审 **P1-1 的回归用例**:
// 同 tag 集、但**取值不同**(`「老K」` vs `「阿七」`)的两条**不许同簇**。
//
// 语料原型(脱敏):生产库里 `用户的昵称是「叶酱」。` 曾被并进
// `主人的昵称是「喵喵七号」…` 的存活行,而同库另有 4 行明确写着「旧的称呼
// 『喵喵七号』已作废,不要再使用」—— 一次 apply 就会让当前事实消失,并留下与库内
// 其它行直接矛盾的结论。因此这条用例钉的不是「少减一点」,而是「不许毁数据」。
func TestDedupeGroupRejectsSameTagDifferentValue(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"昵称", "称呼"}
// 取值不同:只剩存活行一条 ⇒ 不成簇、一条候选都不产。
survivor := dedupeTestItem("主人的昵称是「老K」,是相处中慢慢形成的亲昵称呼;具体来历不明,不可编造。", tags, base)
staleA := dedupeTestItem("用户的昵称是「阿七」。", tags, base.Add(time.Minute))
staleB := dedupeTestItem("主人被称呼为「阿七」,助手平时就这么叫她。", tags, base.Add(2*time.Minute))
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, staleA, staleB})
if len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(取值不同=不同事实,§5.2-4/§5.2-5)", len(cands))
}
for _, m := range []MemoryItem{staleA, staleB} {
if gaps := dedupeValueGaps(m.Content, survivor.Content); len(gaps) == 0 {
t.Fatalf("取值门没认出 %q 里的取值与存活行不同", m.Content)
}
}
// 对照组:同样的 tag 集 + **相同取值**,必须照常成簇(证明上面那条不是因为门太宽)。
sameValue := dedupeTestItem("主人被称呼为「老K」,助手平时就这么叫她。", tags, base.Add(3*time.Minute))
cands, _ = dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, sameValue})
if len(cands) != 1 {
t.Fatalf("对照组簇数 = %d, want 1(同 tag 同取值必须成簇)", len(cands))
}
if cands[0].TargetID != survivor.ID {
t.Fatalf("对照组存活行 = %s, want 最长的那条 %s", cands[0].TargetID, survivor.ID)
}
}
// TestDedupeGroupLatinTokenIsAlsoAValue 钉住取值门的第二类取值:**拉丁 token**
// (人名 / 型号 / 代号)。`助手名为 Cyrene` 与 `助手名为 Alice` 同 tag 集,但取值不同。
func TestDedupeGroupLatinTokenIsAlsoAValue(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"身份", "名字"}
keep := dedupeTestItem("助手名为 Cyrene,主人习惯这么叫我。", tags, base)
other := dedupeTestItem("助手名为 Alice,主人习惯这么叫我。", tags, base.Add(time.Minute))
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{keep, other}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(拉丁取值不同=不同事实)", len(cands))
}
}
// TestDedupeGroupNeverLosesAValue 是**簇级不变式**:任何产出的候选,被吸收行里的
// 每个取值(引号取值 + 拉丁 token)都必须出现在存活行正文里 —— 否则那条取值会随
// 合并从 active 集合里消失。它比单点用例强:新增的簇判据 / 门顺序变化都要过它。
func TestDedupeGroupNeverLosesAValue(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"暗号", "约定"}
items := []MemoryItem{
dedupeTestItem("用户与助手的验收暗号是「青柠」", tags, base),
dedupeTestItem("用户与助手的验收暗号是「青柠」,用于确认文档摄取正常", tags, base.Add(time.Minute)),
dedupeTestItem("验收暗号「青柠」用于确认文档摄取功能正常", tags, base.Add(2*time.Minute)),
// 同一 tag 集但取值不同(不许进簇)。
dedupeTestItem("用户与助手的验收暗号是「柠檬」", tags, base.Add(3*time.Minute)),
// 同 tag 集、取值包含了额外信息(不许进簇:那条取值会丢)。
dedupeTestItem("验收暗号「青柠」配合口令「开门」一起使用", tags, base.Add(4*time.Minute)),
}
cands, _ := dedupeCandsOf(t, newTestStore(t), items)
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1", len(cands))
}
survivor := cands[0].MergedContent
for _, member := range cands[0].Evidence.DedupeMembers {
for _, value := range dedupeValues(member) {
if !strings.Contains(dedupeKey(survivor), value) {
t.Fatalf("取值 %q 会随合并消失(member=%q survivor=%q)", value, member, survivor)
}
}
}
for _, member := range cands[0].Evidence.DedupeMembers {
if strings.Contains(member, "柠檬") || strings.Contains(member, "开门") {
t.Fatalf("不该被吸收的成员进了簇:%q", member)
}
}
}
+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,
}
}