From 6a3f3a329a332fe9178c1c3a4e5b77a15c299122 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 25 Oct 2026 00:08:46 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(memory):=20dedupe=5Fgroup=20=E5=80=99?= =?UTF-8?q?=E9=80=89=E5=8A=A8=E4=BD=9C=20+=20=E7=95=99=E5=AD=98=E9=97=B8?= =?UTF-8?q?=E7=BB=84=E5=90=88=E5=8F=A3=E5=BE=84=E4=B8=8E=20N6/N7=20?= =?UTF-8?q?=E6=97=B6=E7=82=B9=E8=A7=84=E5=88=99=EF=BC=88M34=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项: 1) 新增 `dedupe_group` 候选动作(不放大 containNone): - 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」 (后者附加词项余弦 ≥0.70 与同 kind 收窄); - 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id; - 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇, 数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空) 两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into); - 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制); - 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并, 回滚复用 revertTidyMerge(逐条复原); - 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated; - 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。 2) 留存闸改组合口径(治根)+ N6/N7: - 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束 (数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判; - N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安` 这类偏好不再因寒暄词被判临时); - 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、 硬性长期事实词、数值/约束、寒暄/情绪词一律按回去; - 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天, 已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**, 存量一条不补写(用例钉住)。 契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups / dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。 实测(生产库只读 mode=ro,active 3667): - 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2; 89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」 这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。 - dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16; 簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。 验收:go build ./... + go vet ./... + go test ./... 全绿。 --- internal/runtime/memory/extract_retention.go | 507 +++++++----------- .../memory/extract_retention_emotion.go | 148 +++++ .../extract_retention_transient_test.go | 216 ++++++++ .../runtime/memory/extract_retention_words.go | 212 ++++++++ internal/runtime/memory/tidy.go | 72 ++- internal/runtime/memory/tidy_apply.go | 87 +-- internal/runtime/memory/tidy_apply_result.go | 70 +++ internal/runtime/memory/tidy_apply_store.go | 4 +- internal/runtime/memory/tidy_candidate.go | 11 + internal/runtime/memory/tidy_dedupe.go | 492 +++++++++++++++++ internal/runtime/memory/tidy_dedupe_apply.go | 110 ++++ .../runtime/memory/tidy_dedupe_apply_test.go | 289 ++++++++++ .../runtime/memory/tidy_dedupe_candidate.go | 83 +++ internal/runtime/memory/tidy_dedupe_meta.go | 65 +++ internal/runtime/memory/tidy_dedupe_test.go | 310 +++++++++++ internal/runtime/memory/tidy_types.go | 8 + .../services/gateway/admin_memory_tidy.go | 34 +- .../gateway/admin_memory_tidy_apply.go | 2 + .../gateway/admin_memory_tidy_apply_dto.go | 33 +- .../services/gateway/admin_memory_tidy_run.go | 3 + 20 files changed, 2353 insertions(+), 403 deletions(-) create mode 100644 internal/runtime/memory/extract_retention_emotion.go create mode 100644 internal/runtime/memory/extract_retention_transient_test.go create mode 100644 internal/runtime/memory/extract_retention_words.go create mode 100644 internal/runtime/memory/tidy_apply_result.go create mode 100644 internal/runtime/memory/tidy_dedupe.go create mode 100644 internal/runtime/memory/tidy_dedupe_apply.go create mode 100644 internal/runtime/memory/tidy_dedupe_apply_test.go create mode 100644 internal/runtime/memory/tidy_dedupe_candidate.go create mode 100644 internal/runtime/memory/tidy_dedupe_meta.go create mode 100644 internal/runtime/memory/tidy_dedupe_test.go diff --git a/internal/runtime/memory/extract_retention.go b/internal/runtime/memory/extract_retention.go index 23b751b..ec00ade 100644 --- a/internal/runtime/memory/extract_retention.go +++ b/internal/runtime/memory/extract_retention.go @@ -1,7 +1,6 @@ package memory import ( - "sort" "strings" ) @@ -29,18 +28,22 @@ import ( // 写得**保守**:宁可有噪声漏过闸(那条走 T1 / 长期层),也不把一条真记忆判成临时 // 层(到期会被删)。落实为两道**护栏**: // -// 1. **正向清单一票否决**:内容里出现任何「长期事实 / 稳定偏好 / 关系里程碑 / -// 项目约定」的信号(复用 tidyHabitMarkers 的「持久」词族 + 本文件的正向词), -// 直接放行 —— 「主人喜欢用喵打招呼」虽然短,但它有「喜欢」,不是寒暄; +// 1. **锚点 + 具体约束的组合放行**(2026-10-24 起;此前是「任一锚点就一票放行」): +// 内容里出现的「长期事实 / 稳定偏好 / 关系里程碑 / 项目约定」信号(复用 +// tidyHabitMarkers 的「持久」词族 + 本文件的正向词)必须**配上一条可执行约束** +// (数字 / 禁忌 / 频次规律)才算长期放行 —— 光有「喜欢」只是句泛泛的偏好, +// 光有「约定」也可能只是一次下周的安排。改组合口径的动因是 M27 体检实测: +// 一票放行让 64.6% 的条目必然过闸、`n1`~`n5` 在生产**一次都没命中过**; // 2. **显式「记住」一票否决**:内容里出现「记住 / 记下 / 别忘了」时放行 —— // 主人明确要求记的东西,闸不许替他改成临时记忆。 // -// 一处**刻意不实现**(如实登记,不是遗漏):design/03 §10.5 的 **N6(助手自己的 -// 输出 / 发言人归属)**。它是**来源属性**(这句话是谁说的),不是内容属性;本闸 -// 只拿到落库条目(结构化溯源里没有「谁说的」这一位),无法机器判定。归属由 B 级 -// prompt 的负例承担(「不要记助手自己说过的话」),上游提取侧若要硬判,需另立 -// 结构化字段 —— 那超出本切片授权。**N7(不确定推断)**同理交给 T1(§10.5 明写 -// 「拿不准交第五态」),不进本闸。 +// 反例规则(design/03 §10.5 的 N1~N5 + 2026-10-24 新增的 N6/N7)见各自函数的注释。 +// 注意 **N6/N7 是文件内的新编号**,与 design/03 §10.5 的 N6(助手自己的输出)、 +// N7(不确定推断)**不是一回事**:那两条的落点仍是「不实现 / 交 T1」—— +// - **N6 发言人归属**是**来源属性**(这句话是谁说的),不是内容属性;本闸只拿到 +// 落库条目(结构化溯源里没有「谁说的」这一位),无法机器判定。归属由 B 级 +// prompt 的负例承担(「不要记助手自己说过的话」); +// - **N7 不确定推断**交给 T1(§10.5 明写「拿不准交第五态」),不进本闸。 // retentionRule 是一条提取留存反例规则的匹配结果(留痕/日志用;`retentionNone` // 表示「不是显然不值得记的」)。 @@ -59,6 +62,15 @@ const ( retentionTimelyData retentionRule = "n4_timely_data" // retentionOneOffTask 对应 **N5 一次性任务指令**。 retentionOneOffTask retentionRule = "n5_one_off_task" + // retentionTimepoint 对应 **N6 指示性时点**(2026-10-24 新增):正文的锚点是 + // 一个**正在过去或即将到来**的时刻(昨天/今天/明天/下周/最近…),且没有习惯词、 + // 没有数值、没有约束、没有硬性长期事实词。它是「一次性事件」的形态判据 + // (M27 体检报告 §4 的 R4),因此落 `defer`(临时层,7 天后到期)。 + retentionTimepoint retentionRule = "n6_timepoint" + // retentionInProgress 对应 **N7 进行中状态**(2026-10-24 新增):正文说的是 + // 「正在/暂时 做某件事」(正在测通道、暂时搁置、正在慢慢想),同样是时效轴上的 + // 状态而不是长期事实。安全阀与 N6 完全一致(见 judgeRetentionInProgress)。 + retentionInProgress retentionRule = "n7_in_progress" ) // retentionLowValueReason 是第五态在**留痕 `reason` 列**上的编码(而不是上面那些 @@ -75,10 +87,19 @@ const ( // retentionMaxImperativeRunes 是一次性指令的长度上限。它比前两者宽: // 「帮我把 D 盘那个文档转成 pdf」这类真·一次性任务本来就长一些。 retentionMaxImperativeRunes = 40 + // retentionTimepointLeadRunes 是 N6 的**位置阈值**:指示性时点词必须出现在 + // 正文前这么多个字之内(rune 计)才算「这句话的锚点」。 + // + // 12 不是拍的:M27 语料实测下,放宽到 20 字只多 4 条命中、但开始把 + // 「用户是上班族,需要上班(曾说明天要上班…)」这类**引语里的时点**算成锚点; + // 收紧到 8 字则把「用户昨天发来…」之外的合法形态(「助手计划下周…」)挡掉。 + // 12 字是「主谓 + 时点」的典型长度,也是 R2 用同一个 N 的口径。 + retentionTimepointLeadRunes = 12 ) // judgeExtractionRetention 对一条新记忆的正文施加提取留存规则(design/03 §10.5 -// 的 N1~N5)。命中返回该规则码(调用方据此判 `defer`),否则返回 retentionNone。 +// 的 N1~N5 与 M34 新增的 N6/N7)。命中返回该规则码(调用方据此判 `defer`), +// 否则返回 retentionNone。 // // 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码, // 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。 @@ -90,31 +111,80 @@ func judgeExtractionRetention(content string) retentionRule { // 判断该不该存在」—— 判成 defer 才是替它做决定)。 return retentionNone } - if len([]rune(text)) > retentionMaxImperativeRunes { - // 长内容一律放行:它已经装得下可检索的事实,闸只拦「显然不值得记」。 - return retentionNone - } - // 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。 + // 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。它排在最前,因此 + // 组合口径(护栏②)不会把主人的直接指令改成临时记忆。 if containsAny(text, retentionRememberDirectives()) { return retentionNone } - // 护栏②:任何「持久」信号(习惯 / 稳定偏好 / 约定 / 长期事实)一票放行。 - if containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionDurableMarkers()) { + // 护栏②(2026-10-24 改**组合口径**,M27 体检报告 §7 第 5 批-1):以前是 + // 「含习惯词 **或** 含长期事实词 ⇒ 一票放行」,实测 3657 条里 2363 条 + // (64.6%)必然过闸、`n1`~`n5` 生产命中 0 次 —— 闸因此形同虚设。 + // + // 现在只有「**锚点词 且 含具体约束**」才一票放行:光有「喜欢/约定」这种 + // 泛泛的锚点、却没有任何可执行约束(数字 / 禁忌 / 频次规律)的句子,仍然 + // 交给下面更窄的规则判 —— 它可能是「喜欢(一条泛泛的偏好)」,也可能只是 + // 一次「约定(下周留个午后)」的临时安排。 + // + // 🔴 方向仍是 fail-safe:下面所有规则都不命中时**放行**,因此「有锚点但没 + // 约束」的句子绝大多数照旧进长期层(实测 1841 条里只有 88 条最终被判临时)。 + anchor := retentionHasAnchor(text) + if anchor && retentionHasConcreteConstraint(text) { return retentionNone } + // N1~N5 的**长度安全阀**:它们都建立在「短到装不下一个事实」上。长内容一律 + // 不进这四条规则(但可以进 N6/N7 —— 一次性事件与长度无关)。 + short := len([]rune(text)) <= retentionMaxImperativeRunes // **情绪先于寒暄**是刻意的:「心情不错」既是情绪陈述、又含致谢词表里的 // 「不错」——先判情绪才能给出更具体的规则码(两条规则都判 defer,差别只在 // 「为什么」,而那正是留痕/日志要回答的)。 - if rule := judgeRetentionEmotion(text); rule != retentionNone { + // + // N2 是**形态匹配**(整句必须恰好是纯情绪),因此它在有锚点时同样安全: + // 锚点词(喜欢/习惯/约定)不在它的白名单里,必然消费不干净 ⇒ 放行。 + if short { + if rule := judgeRetentionEmotion(text); rule != retentionNone { + return rule + } + } + // N1/N3/N4/N5 是**词项匹配**(寒暄词 / 祈使开头 / 一次性动作词),一个词就 + // 开火。因此它们只在**没有任何锚点**时生效:「主人喜欢说晚安」含寒暄词,但 + // `喜欢` 说明它在陈述偏好不好寒暄;「提醒我每天喝水」含祈使开头,但 `每天` + // 说明主人要的是一条长期规矩。有锚点就交给护栏②与 N6/N7 判,这几条不许开火。 + if short && !anchor { + if rule := judgeRetentionChatter(text); rule != retentionNone { + return rule + } + if rule := judgeRetentionTimelyData(text); rule != retentionNone { + return rule + } + if rule := judgeRetentionOneOff(text); rule != retentionNone { + return rule + } + } + // N6/N7:**指示性时点 / 进行中状态**的窄规则(2026-10-24 新增,M27 §4 R4)。 + // 它们与 N1~N5 同性质(纯词项 + 形态),但判的是「这份记忆的锚点是一个正在 + // 过去/即将到来的时刻」,而不是「这句话没有信息」。判据与安全阀见各自函数。 + if rule := judgeRetentionTransient(text); rule != retentionNone { return rule } - if rule := judgeRetentionChatter(text); rule != retentionNone { - return rule - } - if rule := judgeRetentionTimelyData(text); rule != retentionNone { - return rule - } - return judgeRetentionOneOff(text) + return retentionNone +} + +// retentionHasAnchor 报告正文里有没有「这条记忆可能长期成立」的锚点词 +// (习惯族 tidyHabitMarkers + 长期事实族 retentionDurableMarkers)。 +// +// 它**只是锚点,不是放行证**:锚点必须配上具体约束才算长期(护栏②), +// 而 N1/N3/N4/N5 这几条词项规则反过来不许在锚点存在时开火(见上)。 +func retentionHasAnchor(text string) bool { + return containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionDurableMarkers()) +} + +// retentionHasConcreteConstraint 报告正文里有没有**可执行的约束**:数值、禁忌 +// (不要/禁止/不许/必须…)、频次与时间规律(每天/每周/按时/定期…)。 +// +// 它守住护栏②的「具体」二字:只有约束才是「这份记忆将来会被用上」的证据 —— +// 「主人喜欢猫」是偏好但没有可执行约束,「主人每天只喂小茂密一次」才有。 +func retentionHasConcreteConstraint(text string) bool { + return hasRetentionDigit(text) || containsAny(text, retentionConstraintMarkers()) } // judgeRetentionChatter 施加 **N1**:短且只由寒暄/致谢词构成的内容。 @@ -136,141 +206,6 @@ func judgeRetentionChatter(text string) retentionRule { return retentionChatter } -// judgeRetentionEmotion 施加 **N2**:**纯**情绪陈述(「今天好累」「心情不错」)。 -// -// 🔴 判据是**白名单形态匹配**,不是黑名单剥离。这是本规则唯一正确的形态,理由: -// -// - 「实体」(宾语)的词表是**开放集合**—— 想我 / 想我们 / 想他 / 想和好 / 想妈妈 -// / 听音乐 / 咬指甲 / 养猫 / …,永远列不全; -// - 而「纯情绪句」的**形态是封闭的**:`[主语]? [时间]? [程度]* 情绪词 [程度|否定|情绪|语气]* [标点]*`, -// 且**没有其它成分**。 -// -// ⇒ 判据 = 「**整句恰好落在上面这个形态里**才 defer,其余一律放行」。 -// -// **为什么它在结构上不可能把带实体的句子判成 defer**(不是"概率很低",是结构性): -// -// 1. 匹配要求**整句被消费干净**(`retentionEmotionFormRest` 返回空余部)。任何 -// 一个**非标点 / 非空白**的字符只要不在词表里,就会留在余部 ⇒ 判不出空 ⇒ -// **放行**。(**标点与空白是被容忍的**:它们由 `onlyRetentionPunctuation` 在收尾 -// 时清掉,所以 `心情不错!` 实测 `rest=""` ⇒ 仍判 defer。) -// -// 2. **位置锁定 + 核心之后只允许四类修饰** —— 这才是真正保安全的一条。 -// (早先这里写的是「封闭表里没有承载内容的词」,**那句被 reviewer 证伪** -// —— 表里确实有:`心情` 是名词,`生气`/`害怕`/`紧张`/`没有`/`比较` 可作动词, -// `压力大` 是动宾,而主语表里的 `主人`/`我`/`他` 本身就是指人实体。) -// -// 真正的机制是**位置**而不是"表里没有实词": -// - **位置锁定**(按实现写):主语只在**位置 0** 被消费一次;时间词只在**紧随其后** -// 被消费一次(无主语时它就是首词)。两者都**没有**「后面还能再出现」的缝 —— -// `主人今天好累` 匹配(`rest=""`),而 `今天主人好累` 连核心都认不出 -// (`rest="今天主人好累"`, `saw=false` ⇒ 放行)。⇒ `主人`/`我`/`你`/`他`/`今天` -// 这些**指人 / 指时的实体在文法里永远不可能落到宾语位置**(上一版正是死在 -// 这里:`会想我` 的 `我` 被当成主语剥掉了); -// - 核心之后只允许「程度 / 否定 / 情绪 / 语气」四类 token,而它们**只允许紧跟 -// 在情绪核心之后** —— 核心本身必须先被消费掉,因此那一段在语义上只能是 -// **情绪的修饰**(「心情**不错**」「**不**开心」「心情**很好**」,三条均实测 -// `rest=""`;反例「生气**得很**」的 `得` 不在表里 ⇒ `rest="得很"` ⇒ 放行), -// 装不下一个宾语短语。核心之后一旦出现四类之外的东西(`想`/`会`/`时`/`和`/ -// `听音乐`/`妈妈`),**必然消费不掉** ⇒ 余部非空 ⇒ 放行。 -// -// 3. 失败方向是**安全**的:认不出的形态一律**放行**(留进长期层,有 T1 与整理兜底), -// 而**绝不**误弃。上一版(黑名单剥离)恰好相反 —— 它把「能当实体的词」也列进 -// 剥离表,于是 `主人不开心时会想我` 被剥成空、判成 defer,而那条是**持久习惯**, -// 判 defer 即 7 天后从所有读路径消失、随后被后台清扫**物理删除**(不可逆)。 -// 黑名单那种形态**修不完**(review 举证 `会想和好` 被 `和`+`好` 吃掉), -// 白名单把失败模式从「未知实体被吞」翻成「未知情绪句没认出」,方向才正确。 -// -// 🔴 **后缀表不得加入承载内容的词**(名词 / 动词 / 量词 / 新的指人词)。上面第 2 条 -// 的整条安全性都建立在「四类之外一律消费不掉」上:往后缀表里塞一个名词,就会**静默** -// 把「情绪 + 该名词作宾语」的持久事实判成 defer,而且**不会有任何行为测试变红** -// (除非那条输入恰好在语料里)—— 这正是 review 点出的风险。两条 tripwire 钉住它: -// -// - `TestExtractionRetention_SuffixTableIsPinned`:把后缀表的**内容**逐 token 钉死, -// **任何增删都立刻红**(改表必须显式改这份 golden ⇒ 变成一次可 review 的意图声明); -// - `TestExtractionRetention_PersistentFactsAreNeverDeferred`:25 条持久事实/习惯 -// 语料逐条断言「绝不 defer」,钉住**语义后果**。 -// -// 两条一起才够:golden 钉「表变了」,语料钉「变了以后语义还对不对」。 -// -// 长度不单独设限:全局 40 rune 的上限(judgeExtractionRetention)已经兜住。 -func judgeRetentionEmotion(text string) retentionRule { - rest, sawEmotion := retentionEmotionFormRest(text) - if !sawEmotion || rest != "" { - // 没认出情绪核心(与 N2 无关),或**没被消费干净**(含形态外的成分, - // 也就是"不只是情绪")⇒ 放行。两个分支都是安全方向。 - return retentionNone - } - return retentionEmotion -} - -// retentionEmotionFormRest 尝试把 text 按「纯情绪形态」消费掉,返回 -// (未能消费的余部, 是否消费到情绪核心)。**只有余部为空且消费到情绪核心**才意味着 -// 「整句恰好是纯情绪陈述」。 -// -// 形态(顺序固定,各段均按**最长词优先**匹配): -// -// [主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]* 余部须只剩标点/空白 -// -// 余部同时是**可断言的证据**:带宾语的句子在这里必定留下非空余部(测试钉住它)。 -func retentionEmotionFormRest(text string) (string, bool) { - rest := text - rest, _ = consumeRetentionPrefix(rest, retentionEmotionSubjects()) - rest, _ = consumeRetentionPrefix(rest, retentionEmotionTimeWords()) - rest = consumeRetentionPrefixes(rest, retentionEmotionDegreeWords()) - - core, ok := consumeRetentionPrefix(rest, retentionEmotionWords()) - if !ok { - // 认不出情绪核心 ⇒ sawEmotion=false,调用方放行。 - return text, false - } - rest = consumeRetentionPrefixes(core, retentionEmotionSuffixTokens()) - if onlyRetentionPunctuation(rest) { - rest = "" - } - return rest, true -} - -// consumeRetentionPrefix 在 text 的开头按**最长词优先**消费 tokens 里的一个词, -// 返回余部与「是否消费到」。最长优先是必须的:`不` 与 `不错`、`好` 与 `好累` 同时 -// 命中时,短词先消费会把长词拆开,于是纯情绪句反而消费不干净、漏判。 -func consumeRetentionPrefix(text string, tokens []string) (string, bool) { - best := "" - for _, tok := range tokens { - if tok == "" || !strings.HasPrefix(text, tok) { - continue - } - if len(tok) > len(best) { - best = tok - } - } - if best == "" { - return text, false - } - return text[len(best):], true -} - -// consumeRetentionPrefixes 反复在句首消费,直到再也匹配不上(用于「可重复修饰」段)。 -func consumeRetentionPrefixes(text string, tokens []string) string { - for { - next, ok := consumeRetentionPrefix(text, tokens) - if !ok { - return text - } - text = next - } -} - -// onlyRetentionPunctuation 报告 text 是否只由标点与空白构成(消费干净的最后一步: -// 「今天好累!」剥到只剩一个标点,仍是「无实体」)。 -func onlyRetentionPunctuation(text string) bool { - for _, r := range text { - if !strings.ContainsRune(retentionPunctuationRunes, r) { - return false - } - } - return true -} - // judgeRetentionTimelyData 施加 **N4**:时效性查询(当前时间 / 天气)。 // // 它的词项是**整句形态**而不是单词(「几点」「天气」),因为「天气」单独出现可能 @@ -299,6 +234,112 @@ func judgeRetentionOneOff(text string) retentionRule { return retentionNone } +// judgeRetentionTransient 施加 **N6 / N7**:时效轴上的两种窄形态(2026-10-24)。 +// +// 动因是 M27 只读体检的两条实测(报告 §4 R4 / §5.3):留存闸的 `defer` 在全历史 +// 只命中过 1 次,`expires_at` 非空只有 1 行 ⇒「临时记忆」这条时效轴在生产中 +// **一次都没跑起来**,而 active 里「指示性时点词 ⇒ 一次性事件」的条目约占 6%。 +// +// 判据与**安全阀**(每一条都是「宁可漏判,不可误弃」,design/23 §18.3): +// +// 1. **有习惯词 ⇒ 不判临时**。习惯词(通常/经常/每天/喜欢…)说的是「反复发生」, +// 哪怕句子里同时出现「今天」(「今天又被加班拖住了,她**通常**十点后才有空」); +// 2. **有硬性长期事实词 ⇒ 不判临时**(住/职业/工作/生日/过敏/称呼/项目/端口…); +// 这条防的是「用户目前在花溪工作」被「目前」两个字误判 —— 那是**地点事实**, +// 不是一次性事件。`约定/承诺/答应/说好` **不在**这条硬名单里:它们是 +// 「锚定到某个时刻的安排」,配上时点正是典型的临时记忆(「约定下周留个午后」), +// 而它们要算长期仍走护栏②(约定 + 具体约束); +// 3. **有数值 ⇒ 不判临时**(「一两千行的验收纸条」这类数值是事实的载体, +// 与 N1 的数值安全阀同口径); +// 4. **有具体约束 ⇒ 不判临时**(数字/禁忌/频次,与护栏②同一个判据); +// 5. **时点词必须在句首前 12 个字内**(N6 独有)。实测这一条把误杀从 ~10% 降到 +// ~0:`用户是上班族,需要上班(曾说明天要上班并感到不开心)`、`睡前会道晚安… +// 并约好「明天中午见」` 这类「时点词只出现在引语/从句里」的长期事实,锚点 +// 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。 +// +// **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终 +// (正在测通道、暂时搁置、正在慢慢想),安全阀 1~4 与 N6 完全一致。 +// +// 🔴 **刻意不做的事**(M27 §4 的实测反例,别再重新发明): +// - 不做**长度阈值**筛(R3:≤15 字的条目抽 8 条 8/8 是可复用事实); +// - 不做**寒暄/情绪词表**筛(R5:10 条里 9 条是关系事实); +// - 不做**一次性事件动词表**(曾/发来/分享…):实测会命中「助手曾经历三千万次 +// 轮回」「用户曾发来『i love you』文件」「海报就是助手本人」这类**长期身份与 +// 关系里程碑**(抽检误杀 ≈ 50%),方向错误。 +func judgeRetentionTransient(text string) retentionRule { + // 安全阀 1~4:与 N1~N5 不同,它们在这里是**硬门**(命中即放行),因为 N6/N7 + // 判的是「这条记忆的锚点是一个时刻」,而锚点词/数值/约束都在说「它不止是一个时刻」。 + if containsAny(text, tidyHabitMarkers()) { + return retentionNone + } + if containsAny(text, retentionHardDurableMarkers()) { + return retentionNone + } + if retentionHasConcreteConstraint(text) { + return retentionNone + } + // 安全阀 5:N6 **不接管 N1/N2 的语料**。含寒暄/致谢词或情绪词的句子归那两条 + // 规则判(N1 有它的长度安全阀,N2 有形态匹配),N6 不越过它们改判: + // - 「谢谢你今天特地提醒我这件事,真是帮了大忙了」是社交致谢,不是事件记录; + // - 「主人最近很容易焦虑」是**情绪倾向**(长期事实),不是「最近发生了一件事」。 + // 实测这两条是单纯加时点判据时唯一的两个既有正例回归。 + if containsAny(text, retentionChatterWords()) || containsAny(text, retentionEmotionWords()) { + return retentionNone + } + if judgeRetentionTimepoint(text) != retentionNone { + return retentionTimepoint + } + if containsAny(text, retentionInProgressMarkers()) { + return retentionInProgress + } + return retentionNone +} + +// judgeRetentionTimepoint 是 N6 的形态判定:正文里**句首前 N 个字内**出现一个 +// 指示性时点词。位置判据是这条规则精度的来源(见 judgeRetentionTransient 安全阀 5)。 +func judgeRetentionTimepoint(text string) retentionRule { + if idx := retentionFrontedTimepoint(text); idx >= 0 && idx <= retentionTimepointLeadRunes { + return retentionTimepoint + } + return retentionNone +} + +// retentionFrontedTimepoint 返回第一个指示性时点词在 text 里的**rune 下标**; +// 没有时点词时返回 -1。调用方再用 retentionTimepointLeadRunes 判它是否「句首」。 +// +// 用 rune 下标而不是字节下标是必须的:中文一个字 3 字节,按字节算会把「前 12 字」 +// 变成「前 4 字」,位置判据当场失效。 +func retentionFrontedTimepoint(text string) int { + runes := []rune(text) + best := -1 + for _, marker := range retentionTimepointMarkers() { + idx := runeIndex(text, marker) + if idx < 0 { + continue + } + if best < 0 || idx < best { + best = idx + } + } + if best < 0 || best >= len(runes) { + return -1 + } + return best +} + +// runeIndex 返回子串 sub 在 text 里的**rune 下标**(找不到返回 -1)。它只在很短的 +// 时点词表上调用(每次至多几十次 strings.Index),因此不必预编译。 +func runeIndex(text, sub string) int { + if sub == "" { + return -1 + } + byteIdx := strings.Index(text, sub) + if byteIdx < 0 { + return -1 + } + return len([]rune(text[:byteIdx])) +} + // normalizeRetentionText 规范化正文供词项匹配:转小写(对 ASCII 词如 `hi` 有用, // 中文不受影响)、把连续空白折叠成**一个空格**、去掉结尾的标点。 // @@ -340,147 +381,3 @@ func hasRetentionDigit(text string) bool { } return false } - -// 下列词表一律是**函数**而不是包级变量(AGENTS §10 禁包级全局变量; -// 同 tidySystemSessions / tidyHabitMarkers 的手法)。 - -// retentionRememberDirectives 是「主人明确要求记住」的信号(护栏①)。 -func retentionRememberDirectives() []string { - return []string{"记住", "记下", "别忘了", "不要忘", "别忘记"} -} - -// retentionDurableMarkers 是正向清单(design/03 §10.4)的词族里、**不被 -// tidyHabitMarkers 覆盖**的那些:长期事实 / 关系里程碑 / 项目约定 / 能力边界。 -func retentionDurableMarkers() []string { - return []string{ - // 长期事实 - "生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母", - "过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄", - // 关系里程碑 / 约定 - "约定", "承诺", "答应", "说好", "暗号", "口令", "称呼", - // 项目约定 - "项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成", - // 能力边界(主人明确的「不要做 X」) - "不要做", "别做", "禁止", "不许", - // 稳定偏好(补 tidyHabitMarkers 之外的) - "口味", "爱好", "讨厌", "不喜欢", "偏爱", "嗜好", - } -} - -// retentionChatterWords 是 N1 的寒暄 / 致谢词表。 -func retentionChatterWords() []string { - return []string{ - "谢谢", "多谢", "感谢", "辛苦", "麻烦你了", "早上好", "早安", "中午好", - "下午好", "晚上好", "晚安", "你好", "您好", "在吗", "收到", "好的", - "好呀", "好嘞", "嗯嗯", "哈哈", "嘿嘿", "呵呵", "么么", "拜拜", "再见", - "加油", "不错", "真棒", "太棒了", "hi", "hello", "hey", "thx", "thanks", - } -} - -// retentionEmotionWords 是 N2 的情绪词表。 -func retentionEmotionWords() []string { - return []string{ - "好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大", - "心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈", - "烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞", - // 「不错」是**情绪评价**(「心情不错」),因此它必须在**核心词表**里按整词 - // 出现:只靠否定词表里的「不」,`不错` 会被拆成 `不` + 光杆「错」,于是 - // 「心情不错」既认不出核心、也消费不干净 —— 一条纯情绪句反而漏判。 - "不错", - // 光杆「累」同理:「主人今天真的特别累」只有一个 `累` 而**不含** `好累` / - // `累了`,漏掉它就会留下一个消费不掉的「累」、把一条纯情绪句放行。 - // 收它**不会**带来误弃 —— 判 defer 的门槛恒是「整句被形态消费干净」, - // 多收一个情绪词只会让**本该 defer 的**句子认得出来。 - "累", - } -} - -// retentionEmotionSuffixTokens 是「情绪核心之后**允许**出现的修饰」的封闭集合: -// 程度 / 否定 / 情绪 / 语气。**它不含任何承载内容的词** —— 这正是白名单判据在 -// 结构上不会吞掉实体的原因(核心之后一旦出现 `想`/`会`/`时`/`和`/`我` 这类字, -// 就必然消费不掉、留下非空余部 ⇒ 放行)。 -// -// 它是函数而不是包级变量(AGENTS §10 禁包级全局变量;同 tidyHabitMarkers 的手法); -// 长度先按**最长词优先**排序,`不` 不会抢在 `不错` 前面被消费。 -func retentionEmotionSuffixTokens() []string { - tokens := make([]string, 0, 64) - tokens = append(tokens, retentionEmotionDegreeWords()...) - tokens = append(tokens, retentionEmotionNegations()...) - tokens = append(tokens, retentionEmotionWords()...) - tokens = append(tokens, retentionEmotionToneWords()...) - sort.SliceStable(tokens, func(i, j int) bool { - return len(tokens[i]) > len(tokens[j]) - }) - return tokens -} - -// retentionEmotionTimeWords 是**只允许在「主语之后」出现一次**的时间词:无主语时 -// 它就是首词(「今天好累」),有主语时紧跟主语(「主人今天好累」)—— 之外的位置一律 -// 不匹配(`今天主人好累` 实测 `rest="今天主人好累"`, `saw=false` ⇒ 放行)。 -// 它们让一句情绪陈述显形为「今天怎么样」,本身不含实体。 -func retentionEmotionTimeWords() []string { - return []string{ - "今天", "明天", "昨天", "今晚", "昨晚", "今早", "此刻", "现在", "刚才", "刚刚", - "最近", "这两天", "这几天", "这阵子", "这段时间", "目前", "当下", "一整天", - } -} - -// retentionEmotionSubjects 是**只允许出现在句首**的主语 / 称呼。把它限死在句首是 -// 白名单判据的要害之一:`我`/`他`/`我们` 这些词**本身可以当实体**(「会想我」的 -// 宾语就是它),若允许它们在任意位置被消费,`主人不开心时会想我` 就会被吞成空。 -func retentionEmotionSubjects() []string { - return []string{ - "主人", "咱们", "我们", "你们", "他们", "她们", "自己", - "我", "你", "他", "她", "它", "咱", - } -} - -// retentionEmotionDegreeWords 是程度词(只修饰强度,不引入实体)。 -func retentionEmotionDegreeWords() []string { - return []string{ - "非常", "特别", "稍微", "真的", "真是", "十分", "比较", "有点", "有些", - "很", "挺", "太", "好", "超", "蛮", "更", "最", - } -} - -// retentionEmotionNegations 是情绪核心之后的否定词(「心情**不**好」)。它只能出现在 -// 后缀位;`不` 与 `没` 都不承载实体。 -func retentionEmotionNegations() []string { - return []string{"没有", "不", "没"} -} - -// retentionEmotionToneWords 是句尾的语气词(「好累**啊**」「不错**了**」)。它们只出现 -// 在句尾,不引入实体。 -func retentionEmotionToneWords() []string { - return []string{"啊", "呀", "啦", "哦", "喔", "嘛", "呢", "吧", "了", "的", "哎", "唉", "嗯", "哈"} -} - -// retentionPunctuationRunes 是「消费干净」判定允许剩下的标点与空白。 -const retentionPunctuationRunes = ",。!?、;:…~~·--,.!?;:()() \t\n" - -// retentionTimelyQueries 是 N4 的时效性**查询整句**词表(不是单词,理由见 -// judgeRetentionTimelyData)。 -func retentionTimelyQueries() []string { - return []string{ - "现在几点", "几点了", "现在时间", "当前时间", - "今天天气", "明天天气", "天气怎么样", "天气如何", "天气怎样", - "温度多少", "多少度", "气温多少", - } -} - -// retentionAskPrefixes 是 N3 的祈使开头词表。 -func retentionAskPrefixes() []string { - return []string{ - "帮我", "请帮", "麻烦帮", "帮个忙", "提醒我", "叫我", "替我", - "给我订", "给我买", "帮我查", "帮我搜", "帮我订", "帮我买", - } -} - -// retentionOneOffActions 是 N5 的一次性动作词表(位置不限 —— 它们描述的是 -// 「转一次文件」这类有始有终的动作,而不是可以长期成立的偏好)。 -func retentionOneOffActions() []string { - return []string{ - "转成pdf", "转成 pdf", "转换成", "截图", "打开这个", "关掉这个", - "搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟", - } -} diff --git a/internal/runtime/memory/extract_retention_emotion.go b/internal/runtime/memory/extract_retention_emotion.go new file mode 100644 index 0000000..8a94fce --- /dev/null +++ b/internal/runtime/memory/extract_retention_emotion.go @@ -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 +} diff --git a/internal/runtime/memory/extract_retention_transient_test.go b/internal/runtime/memory/extract_retention_transient_test.go new file mode 100644 index 0000000..4e38ca8 --- /dev/null +++ b/internal/runtime/memory/extract_retention_transient_test.go @@ -0,0 +1,216 @@ +package memory + +import ( + "context" + "testing" + "time" +) + +// 本文件是留存闸 **N6(指示性时点)/ N7(进行中状态)** 与**组合护栏②** +// (2026-10-24,M34)的验收用例集。 +// +// 两条不对称的事实决定了这个文件的写法: +// - 漏判只是「库里多一条噪声」(T1 与整理会兜底); +// - 误判会把一条真记忆丢进临时层 —— 7 天后从所有读路径消失,再被后台清扫 +// **物理删除**(不可逆)。 +// +// 因此这里的**正例(必须放行)比负例(必须拦下)更硬**:`retentionLongTermCases` +// 里的每一条都是真实语料里「带时点词但仍然长期成立」的句子,它们逐条钉住 +// 「误杀真实长期记忆比漏放新记忆更严重」这条口径。 + +// retentionTimepointCases 是 N6 的正例:**锚点是一个正在过去/即将到来的时刻**, +// 且没有习惯词、没有硬性长期事实词、没有数值/约束 —— 它们该进临时层。 +func retentionTimepointCases() []string { + return []string{ + "用户昨天发送了一份长达一万两千行的验收纸条", + "用户近期在测试一条通道,今天来回测了三轮", + "用户最近在看一部年代/审讯题材的影视作品,台词「我肯定能拿下口供」", + "计划下周为主人留一个安静的午后", + "用户今晚睡宿舍,不在家", + // 约定类:锚定到某个时刻的安排(约定本身不是长期事实,配上时点才是临时记忆)。 + "用户与助理约定明天中午见", + } +} + +// retentionLongTermCases 是**回归护栏**:真实语料里「带时点词但长期成立」的句子, +// 一条都不许判 defer。每一行都对应一次实测误杀(谁把它们放进来,就是谁在丢记忆)。 +func retentionLongTermCases() []struct { + name string + content string +} { + return []struct { + name string + content string + }{ + // 时点词埋在**引语/从句**里,不是这句话的锚点(位置安全阀)。 + {"上班族习惯(引语里的明天)", "用户是上班族,需要上班(曾说明天要上班并感到不开心)"}, + {"睡前习惯(从句里的明天见)", "用户睡前会道「晚安、明天见」,人家会替她掀被角、调暗灯光,用数呼吸的方式陪她入睡"}, + {"单位排班事实(引语里的今天)", "主人所在单位存在周末/节假日被安排加班的情况,实际排班与公开调休日历并不一致(她自己说「我们今天加班啦」)"}, + // 习惯词/硬性长期事实词(地点、职业、住)⇒ 一律长期,标题里带「今天」也不许判临时。 + {"地点事实", "用户目前生活在花溪(贵阳花溪区),不在上海上班"}, + {"住宿舍(近期)", "用户近期住在宿舍(不在家里),晚间会关心她洗漱水温、睡觉时间等宿舍生活细节"}, + {"作息习惯", "主人常在深夜与助手聊天,有熬夜习惯"}, + {"长期偏好", "主人喜欢喝冰美式来提神"}, + {"称呼约定", "主人希望被称呼为「叶酱」;旧的称呼「喵喵七号」已作废,不要再使用"}, + {"关系里程", "用户曾向 Cyrene 发送内容为「i love you」的文件,Cyrene 完整接收并回复「me too」"}, + {"人格设定", "助手设定:曾经历三千万次轮回等待用户,并在「记忆之海」中捞回用户的记忆"}, + {"海报即本人", "主人曾发来一张海报,画面是粉发紫眼、配蓝玫瑰与月桂叶,并明确说「那张海报上就是你哦」"}, + // 情绪倾向 + 寒暄:N6 不接管 N1/N2 的语料(安全阀 5)。 + {"情绪倾向", "主人最近很容易焦虑"}, + {"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"}, + } +} + +// TestExtractionRetention_TimepointIsDeferred 钉住 N6 的正例。 +func TestExtractionRetention_TimepointIsDeferred(t *testing.T) { + t.Parallel() + for _, content := range retentionTimepointCases() { + if got := judgeExtractionRetention(content); got != retentionTimepoint && got != retentionInProgress { + t.Fatalf("%q 判成 %q, want %q(锚点是一个时刻 ⇒ 临时层)", + content, got, retentionTimepoint) + } + } +} + +// TestRetentionTimepointHitsBySpecificRule 钉住「命中哪一条」:时点归 N6、进行中归 N7。 +// 它让两条规则的词表**各自**可被单测,不会一条盖住另一条。 +func TestRetentionTimepointHitsBySpecificRule(t *testing.T) { + t.Parallel() + if got := judgeExtractionRetention("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint { + t.Fatalf("时点句 = %q, want %q", got, retentionTimepoint) + } + if got := judgeExtractionRetention("用户正在验证分享确认面板的展开功能"); got != retentionInProgress { + t.Fatalf("进行中句 = %q, want %q", got, retentionInProgress) + } +} + +// TestExtractionRetention_RealLongTermFactsSurviveTimepoints 是**误杀回归**: +// 真实长期事实即使带着时点词也必须放行。任何一条变红都意味着「7 天后它会消失」。 +func TestExtractionRetention_RealLongTermFactsSurviveTimepoints(t *testing.T) { + t.Parallel() + for _, tc := range retentionLongTermCases() { + if got := judgeExtractionRetention(tc.content); got != retentionNone { + t.Fatalf("长期事实 %q(%s)被判成 %q —— 它会在 7 天后被物理删除(不可逆)", + tc.content, tc.name, got) + } + } +} + +// TestExtractionRetention_TimepointMustBeFronted 钉住**位置安全阀**:同一个句子, +// 时点在句首 → defer;把同一时点挪到从句里 → 放行。位置是这条规则精度的来源。 +func TestExtractionRetention_TimepointMustBeFronted(t *testing.T) { + t.Parallel() + fronted := "用户昨天发来一卷验收纸条" + buried := "用户与助手讨论过验收纸条的格式(他自己说昨天发来一卷)" + if got := judgeExtractionRetention(fronted); got != retentionTimepoint { + t.Fatalf("时点在句首 = %q, want %q", got, retentionTimepoint) + } + if got := judgeExtractionRetention(buried); got != retentionNone { + t.Fatalf("时点埋在从句里 = %q, want %q(位置安全阀失效会误杀长内容)", got, retentionNone) + } +} + +// TestExtractionRetention_ConjunctionIsNotANoOp 钉住护栏②改的是**组合口径**: +// 「约定」这种锚点单独出现时不再一票放行(于是它会被 N6 判成临时安排); +// 但一旦配上具体约束(频次),立刻回到长期层。改回「任一锚点就放行」时, +// 前一半会变红;把组合条件删成「任一命中即放行」时,整条用例会红。 +func TestExtractionRetention_ConjunctionIsNotANoOp(t *testing.T) { + t.Parallel() + if got := judgeExtractionRetention("约定下周给用户留一个安静的午后"); got != retentionTimepoint { + t.Fatalf("「约定 + 时点、无约束」= %q, want %q(锚点单独出现不再一票放行)", + got, retentionTimepoint) + } + if got := judgeExtractionRetention("约定每天给主人留一个安静的午后"); got != retentionNone { + t.Fatalf("「约定 + 每天(具体约束)」= %q, want 放行(组合条件命中即长期)", got) + } + if got := judgeExtractionRetention("主人每天要喝 2 杯咖啡"); got != retentionNone { + t.Fatalf("「偏好 + 数值」= %q, want 放行", got) + } + if got := judgeExtractionRetention("主人说过不要在群里提她的工作"); got != retentionNone { + t.Fatalf("「禁忌」= %q, want 放行", got) + } +} + +// TestExtractionRetention_TimepointSafetyValves 逐条钉住 N6/N7 的四个安全阀: +// 习惯词 / 硬性长期事实词 / 数值与约束 / 寒暄与情绪词都把它们按回去。 +// +// 断言写成「**不是 N6/N7**」而不是「必须放行」是刻意的:安全阀的职责只有一个 —— +// 不让这条句子被 N6/N7 判成临时层。它可能仍被更早的既有规则(N1/N2)判成临时, +// 那是**既有口径**(本条用例不重定义它),也不是回归。 +func TestExtractionRetention_TimepointSafetyValves(t *testing.T) { + t.Parallel() + cases := []struct { + content string + why string + }{ + {"用户今天又被加班拖住了,她通常十点后才有空", "习惯词(通常)⇒ 反复发生"}, + {"用户今天要去医院复查过敏", "硬性长期事实词(过敏)"}, + {"用户昨天对花生过敏犯了,吃了药", "硬性长期事实词(过敏 / 吃药)"}, + {"用户昨天发了 3 份文件", "数值是事实的载体"}, + {"用户最近每天要喝两杯水", "约束词(每天)"}, + {"用户昨夜睡得还行,今天心情不错", "情绪词 ⇒ 归 N2 判"}, + } + for _, tc := range cases { + got := judgeExtractionRetention(tc.content) + if got == retentionTimepoint || got == retentionInProgress { + t.Fatalf("%q 判成 %q, want 不被 N6/N7 判临时(%s)", tc.content, got, tc.why) + } + } +} + +// TestIngestGate_TimepointDefersWithTempTTL 钉住 TTL 的落点:N6 命中 ⇒ 第五态 +// `defer`,行照常 active 落库(正文一字不动),`expires_at` = 判定时刻 + 临时 TTL +// (默认 7 天,落 `$DATA_DIR/config/memory-tidy.json` 的 `tempTtlSec`,零新 env 键)。 +func TestIngestGate_TimepointDefersWithTempTTL(t *testing.T) { + t.Parallel() + store := newTestStore(t) + base := tidyTestTime(t) + ttl := 7 * 24 * time.Hour + r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil) + store.AttachIngestReviewer(r) + + got := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base)) + if exp, want := readIngestExpiry(t, store, got.ID), expiryText(base.Add(ttl)); exp != want { + t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 7 天)", exp, want) + } + row := loadTidyItemRow(t, store, got.ID) + if row.status != tidyStatusActive || row.content != "用户昨天发来一卷一万两千行的验收纸条" { + t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row) + } + rev := gateRecordFor(t, store, got.ID) + if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason { + t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason) + } +} + +// TestIngestGate_DeferNeverBackfillsExistingRows 钉住「**TTL 只开新增侧**」: +// 存量行的 `expires_at` 一条都不补写。变异(在 defer 时顺手 UPDATE 整个分区)会红。 +func TestIngestGate_DeferNeverBackfillsExistingRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + // 存量行在**接入闸之前**落库:这条用例要看的是「闸跑一轮之后,存量行的 + // expires_at 仍然为空」,而不是「存量行当初怎么落的库」。 + existing := seedTidyItem(t, store, gateFixtureItem("主人有不吃早饭的习惯,需要提醒每天吃热乎的早饭", base)) + r := newGateReviewer(t, store, ReviewConfig{TempTTL: 7 * 24 * time.Hour}, nil) + store.AttachIngestReviewer(r) + if exp := readIngestExpiry(t, store, existing.ID); exp != "" { + t.Fatalf("存量行 expires_at = %q, want 空串", exp) + } + fresh := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base.Add(time.Minute))) + if exp := readIngestExpiry(t, store, fresh.ID); exp == "" { + t.Fatalf("新行没被写上 expires_at —— N6 没有生效") + } + if exp := readIngestExpiry(t, store, existing.ID); exp != "" { + t.Fatalf("存量行被补写了 expires_at = %q —— TTL 只许开新增侧", exp) + } + var withExpiry int + if err := store.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM memory_items WHERE expires_at <> ''`).Scan(&withExpiry); err != nil { + t.Fatalf("count expires_at: %v", err) + } + if withExpiry != 1 { + t.Fatalf("带 expires_at 的行 = %d, want 恰为 1(只有那条新记忆)", withExpiry) + } +} diff --git a/internal/runtime/memory/extract_retention_words.go b/internal/runtime/memory/extract_retention_words.go new file mode 100644 index 0000000..d1aca67 --- /dev/null +++ b/internal/runtime/memory/extract_retention_words.go @@ -0,0 +1,212 @@ +package memory + +import "sort" + +// 本文件是提取留存规则闸(design/03 §10)的**词表**:每一项都是**函数**而不是 +// 包级变量(AGENTS §10 禁包级全局变量;同 tidySystemSessions / tidyHabitMarkers 的手法)。 +// +// 它从 extract_retention.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)—— +// 词表内容与逐条理由**逐字未动**;判定逻辑在 extract_retention.go。 + +// 下列词表一律是**函数**而不是包级变量(AGENTS §10 禁包级全局变量; +// 同 tidySystemSessions / tidyHabitMarkers 的手法)。 + +// retentionRememberDirectives 是「主人明确要求记住」的信号(护栏①)。 +func retentionRememberDirectives() []string { + return []string{"记住", "记下", "别忘了", "不要忘", "别忘记"} +} + +// retentionDurableMarkers 是正向清单(design/03 §10.4)的词族里、**不被 +// tidyHabitMarkers 覆盖**的那些:长期事实 / 关系里程碑 / 项目约定 / 能力边界。 +func retentionDurableMarkers() []string { + return []string{ + // 长期事实 + "生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母", + "过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄", + // 关系里程碑 / 约定 + "约定", "承诺", "答应", "说好", "暗号", "口令", "称呼", + // 项目约定 + "项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成", + // 能力边界(主人明确的「不要做 X」) + "不要做", "别做", "禁止", "不许", + // 稳定偏好(补 tidyHabitMarkers 之外的) + "口味", "爱好", "讨厌", "不喜欢", "偏爱", "嗜好", + } +} + +// retentionChatterWords 是 N1 的寒暄 / 致谢词表。 +func retentionChatterWords() []string { + return []string{ + "谢谢", "多谢", "感谢", "辛苦", "麻烦你了", "早上好", "早安", "中午好", + "下午好", "晚上好", "晚安", "你好", "您好", "在吗", "收到", "好的", + "好呀", "好嘞", "嗯嗯", "哈哈", "嘿嘿", "呵呵", "么么", "拜拜", "再见", + "加油", "不错", "真棒", "太棒了", "hi", "hello", "hey", "thx", "thanks", + } +} + +// retentionEmotionWords 是 N2 的情绪词表。 +func retentionEmotionWords() []string { + return []string{ + "好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大", + "心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈", + "烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞", + // 「不错」是**情绪评价**(「心情不错」),因此它必须在**核心词表**里按整词 + // 出现:只靠否定词表里的「不」,`不错` 会被拆成 `不` + 光杆「错」,于是 + // 「心情不错」既认不出核心、也消费不干净 —— 一条纯情绪句反而漏判。 + "不错", + // 光杆「累」同理:「主人今天真的特别累」只有一个 `累` 而**不含** `好累` / + // `累了`,漏掉它就会留下一个消费不掉的「累」、把一条纯情绪句放行。 + // 收它**不会**带来误弃 —— 判 defer 的门槛恒是「整句被形态消费干净」, + // 多收一个情绪词只会让**本该 defer 的**句子认得出来。 + "累", + } +} + +// retentionEmotionSuffixTokens 是「情绪核心之后**允许**出现的修饰」的封闭集合: +// 程度 / 否定 / 情绪 / 语气。**它不含任何承载内容的词** —— 这正是白名单判据在 +// 结构上不会吞掉实体的原因(核心之后一旦出现 `想`/`会`/`时`/`和`/`我` 这类字, +// 就必然消费不掉、留下非空余部 ⇒ 放行)。 +// +// 它是函数而不是包级变量(AGENTS §10 禁包级全局变量;同 tidyHabitMarkers 的手法); +// 长度先按**最长词优先**排序,`不` 不会抢在 `不错` 前面被消费。 +func retentionEmotionSuffixTokens() []string { + tokens := make([]string, 0, 64) + tokens = append(tokens, retentionEmotionDegreeWords()...) + tokens = append(tokens, retentionEmotionNegations()...) + tokens = append(tokens, retentionEmotionWords()...) + tokens = append(tokens, retentionEmotionToneWords()...) + sort.SliceStable(tokens, func(i, j int) bool { + return len(tokens[i]) > len(tokens[j]) + }) + return tokens +} + +// retentionEmotionTimeWords 是**只允许在「主语之后」出现一次**的时间词:无主语时 +// 它就是首词(「今天好累」),有主语时紧跟主语(「主人今天好累」)—— 之外的位置一律 +// 不匹配(`今天主人好累` 实测 `rest="今天主人好累"`, `saw=false` ⇒ 放行)。 +// 它们让一句情绪陈述显形为「今天怎么样」,本身不含实体。 +func retentionEmotionTimeWords() []string { + return []string{ + "今天", "明天", "昨天", "今晚", "昨晚", "今早", "此刻", "现在", "刚才", "刚刚", + "最近", "这两天", "这几天", "这阵子", "这段时间", "目前", "当下", "一整天", + } +} + +// retentionEmotionSubjects 是**只允许出现在句首**的主语 / 称呼。把它限死在句首是 +// 白名单判据的要害之一:`我`/`他`/`我们` 这些词**本身可以当实体**(「会想我」的 +// 宾语就是它),若允许它们在任意位置被消费,`主人不开心时会想我` 就会被吞成空。 +func retentionEmotionSubjects() []string { + return []string{ + "主人", "咱们", "我们", "你们", "他们", "她们", "自己", + "我", "你", "他", "她", "它", "咱", + } +} + +// retentionEmotionDegreeWords 是程度词(只修饰强度,不引入实体)。 +func retentionEmotionDegreeWords() []string { + return []string{ + "非常", "特别", "稍微", "真的", "真是", "十分", "比较", "有点", "有些", + "很", "挺", "太", "好", "超", "蛮", "更", "最", + } +} + +// retentionEmotionNegations 是情绪核心之后的否定词(「心情**不**好」)。它只能出现在 +// 后缀位;`不` 与 `没` 都不承载实体。 +func retentionEmotionNegations() []string { + return []string{"没有", "不", "没"} +} + +// retentionEmotionToneWords 是句尾的语气词(「好累**啊**」「不错**了**」)。它们只出现 +// 在句尾,不引入实体。 +func retentionEmotionToneWords() []string { + return []string{"啊", "呀", "啦", "哦", "喔", "嘛", "呢", "吧", "了", "的", "哎", "唉", "嗯", "哈"} +} + +// retentionPunctuationRunes 是「消费干净」判定允许剩下的标点与空白。 +const retentionPunctuationRunes = ",。!?、;:…~~·--,.!?;:()() \t\n" + +// retentionTimelyQueries 是 N4 的时效性**查询整句**词表(不是单词,理由见 +// judgeRetentionTimelyData)。 +func retentionTimelyQueries() []string { + return []string{ + "现在几点", "几点了", "现在时间", "当前时间", + "今天天气", "明天天气", "天气怎么样", "天气如何", "天气怎样", + "温度多少", "多少度", "气温多少", + } +} + +// retentionAskPrefixes 是 N3 的祈使开头词表。 +func retentionAskPrefixes() []string { + return []string{ + "帮我", "请帮", "麻烦帮", "帮个忙", "提醒我", "叫我", "替我", + "给我订", "给我买", "帮我查", "帮我搜", "帮我订", "帮我买", + } +} + +// retentionOneOffActions 是 N5 的一次性动作词表(位置不限 —— 它们描述的是 +// 「转一次文件」这类有始有终的动作,而不是可以长期成立的偏好)。 +func retentionOneOffActions() []string { + return []string{ + "转成pdf", "转成 pdf", "转换成", "截图", "打开这个", "关掉这个", + "搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟", + } +} + +// retentionConstraintMarkers 是「**具体约束**」的词项(护栏②与 N6/N7 共用): +// 禁忌 / 硬性要求 / 频次与时间规律。它是「这条记忆将来会被用上」的可执行证据。 +// +// ⚠️ 只收**能落到行动**的词:把「喜欢/讨厌」这类偏好词收进来就等于取消护栏② +// 的意义(它们已经在 tidyHabitMarkers 里当锚点,锚点 + 锚点不构成约束)。 +func retentionConstraintMarkers() []string { + return []string{ + // 禁忌 / 硬性要求(主人明确的「不要做 X」) + "不要", "不准", "不许", "不能", "禁止", "必须", "一定要", "记得", "别忘", + // 频次 / 时间规律 + "每天", "每日", "每晚", "每早", "每周", "每月", "每年", "每个", + "按时", "定期", "定量", "固定", + // 量化边界 + "不超过", "至少", "最多", "以内", "不少于", + } +} + +// retentionTimepointMarkers 是 N6 的**指示性时点词**(一次性事件的信号)。 +// +// 🔴 两张表都**不收**「周期性时段词」:晚上/早上/中午/周末/工作日 —— 它们说的是 +// 规律而不是某个时刻,收进来会让人把作息习惯判成一次性事件(M27 §4 R4 实测: +// 把这类词当锚点,误杀从 ~8% 升到 ~35%)。也**不收**「目前/现在/当下/这段时间」 +// 这类**当前状态**词:实测它们命中的是「用户目前生活在花溪」「用户现在有工作」 +// 这类**地点 / 职业事实**,正是最不该过期的一类。 +func retentionTimepointMarkers() []string { + return []string{ + "今天", "明天", "后天", "昨天", "前天", + "今晚", "明晚", "昨晚", "今早", "明早", + "刚才", "刚刚", + "这两天", "这几天", "这阵子", + "本周", "这周", "下周", "上周", "下个月", "本月", + "最近", "近期", + } +} + +// retentionInProgressMarkers 是 N7 的「进行中状态」词(有始有终的状态, +// 不是长期事实)。它们比时点词更弱,因此 N7 与 N6 共用同一组安全阀。 +func retentionInProgressMarkers() []string { + return []string{"正在", "暂时", "眼下", "这会儿"} +} + +// retentionHardDurableMarkers 是 N6/N7 的**硬性长期事实词**:命中即放行。 +// +// 它是 retentionDurableMarkers **减去「约定/承诺/答应/说好」** 的差集,理由: +// 那四个词是「锚定到某个时刻的安排」,配上时点恰恰是临时记忆(「约定下周留个 +// 午后」→ 下周过去就没意义了);而「住/职业/工作/生日/过敏/称呼/项目/端口…」 +// 说的是**不会因为一个时刻而过期**的事实,标题里带「今天」也不许判临时。 +func retentionHardDurableMarkers() []string { + appointments := map[string]struct{}{"约定": {}, "承诺": {}, "答应": {}, "说好": {}} + markers := make([]string, 0, len(retentionDurableMarkers())) + for _, m := range retentionDurableMarkers() { + if _, ok := appointments[m]; ok { + continue + } + markers = append(markers, m) + } + return markers +} diff --git a/internal/runtime/memory/tidy.go b/internal/runtime/memory/tidy.go index a7e954f..6e2b7d4 100644 --- a/internal/runtime/memory/tidy.go +++ b/internal/runtime/memory/tidy.go @@ -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 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出): diff --git a/internal/runtime/memory/tidy_apply.go b/internal/runtime/memory/tidy_apply.go index b63dc3d..cb4f446 100644 --- a/internal/runtime/memory/tidy_apply.go +++ b/internal/runtime/memory/tidy_apply.go @@ -141,6 +141,13 @@ type TidyApplyBatch struct { Merged int `json:"merged"` Archived int `json:"archived"` Reclassified int `json:"reclassified"` + // Deduped 是成功落地的 `dedupe_group` 簇数(2026-10-24)。它与 Merged 分开: + // 「一对一并进」与「一簇 N 条并进」在面板上是两件事(后者一次动多行)。 + Deduped int `json:"deduped"` + // RowsAffected 是本次真的被改动的**记忆行数**(merge/archive/reclassify 各 1 行, + // dedupe_group 是簇里的被吸收行数)。行预算 `maxTidyApplyRowsPerBatch` 按它计, + // 因此这个数字是「这一次点击改了多少行记忆」的直接回答。 + RowsAffected int `json:"rowsAffected"` } // ApplyTidyCandidate 应用**一条**候选(design/23 §11 的 apply 粒度之一)。 @@ -175,6 +182,8 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid return s.applyTidyArchive(ctx, cand, opts, now) case TidyReclassify: return s.applyTidyReclassify(ctx, cand, opts, now) + case TidyDedupeGroup: + return s.applyTidyDedupeGroup(ctx, cand, opts, now) default: return skipTidyApply(base, tidySkipActionNotApplyable), nil } @@ -185,7 +194,7 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid // 库里既有的历史行与「不许假装做过」的诚实口径。 func (a TidyAction) applyable() bool { switch a { - case TidyMerge, TidyArchive, TidyReclassify: + case TidyMerge, TidyArchive, TidyReclassify, TidyDedupeGroup: return true default: return false @@ -399,11 +408,21 @@ func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts Tidy Truncated: total > len(cands), Results: make([]TidyApplyResult, 0, len(cands)), } + rows := 0 for _, cand := range cands { if err := ctx.Err(); err != nil { // 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。 break } + // 按行计的批量上限(2026-10-24):一条 dedupe_group 候选会改 N 行,只按候选 + // 条数限批会让「一次点击改上千行记忆」。超限时**如实标 truncated** 并停下, + // 剩下的留给下一次点击(与扫描/候选上限同一口径:绝不静默少做)。 + if cost := tidyApplyRowCost(cand); rows+cost > maxTidyApplyRowsPerBatch { + batch.Truncated = true + break + } else { + rows += cost + } res, err := s.applyCandidate(ctx, cand, opts) if err != nil { res = tidyFailed(TidyApplyResult{ @@ -412,8 +431,13 @@ func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts Tidy }, err) } batch.Results = append(batch.Results, res) + batch.RowsAffected += len(res.AffectedIDs) tallyTidyApply(&batch, res) } + if len(batch.Results) < len(cands) { + // 循环被行预算或 ctx 打断:`len(cands)` 之内还有未处理项 ⇒ 不完整。 + batch.Truncated = true + } if batch.RunID != "" && batch.Applied > 0 { // 该轮的 applied 计数累加(design/23 §10 的 memory_tidy_runs.applied): // 运行时那一轮写 0(当轮绝不自动应用),事后应用在这里补记。 @@ -423,64 +447,3 @@ func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts Tidy } return batch, nil } - -// tallyTidyApply 把一条结果计入批量回执的计数器。 -func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) { - switch res.Status { - case TidyApplyApplied: - batch.Applied++ - switch res.Action { - case TidyMerge: - batch.Merged++ - case TidyArchive: - batch.Archived++ - case TidyReclassify: - batch.Reclassified++ - } - case TidyApplyFailed: - batch.Failed++ - default: - batch.Skipped++ - } -} - -// skipTidyApply 造一条 skipped 结果。 -func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult { - base.Status = TidyApplySkipped - base.Reason = reason - if base.AffectedIDs == nil { - base.AffectedIDs = []string{} - } - return base -} - -// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。 -func tidyFailed(base TidyApplyResult, err error) TidyApplyResult { - base.Status = TidyApplyFailed - base.Reason = "apply_failed" - base.Error = err.Error() - if base.AffectedIDs == nil { - base.AffectedIDs = []string{} - } - return base -} - -// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped; -// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。 -// -// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行 -// (那是把「没做」说成「做了」)。 -func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) { - switch { - case err == nil: - base.Status = TidyApplyApplied - base.AffectedIDs = affected - return base, nil - case errors.Is(err, errTidyCandidateDecided): - return skipTidyApply(base, tidySkipAlreadyDecided), nil - case errors.Is(err, errTidyItemNotActive): - return skipTidyApply(base, notActiveReason), nil - default: - return tidyFailed(base, err), nil - } -} diff --git a/internal/runtime/memory/tidy_apply_result.go b/internal/runtime/memory/tidy_apply_result.go new file mode 100644 index 0000000..32ba301 --- /dev/null +++ b/internal/runtime/memory/tidy_apply_result.go @@ -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 + } +} diff --git a/internal/runtime/memory/tidy_apply_store.go b/internal/runtime/memory/tidy_apply_store.go index de8d71c..2b60879 100644 --- a/internal/runtime/memory/tidy_apply_store.go +++ b/internal/runtime/memory/tidy_apply_store.go @@ -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) diff --git a/internal/runtime/memory/tidy_candidate.go b/internal/runtime/memory/tidy_candidate.go index 9c0008c..167fa0a 100644 --- a/internal/runtime/memory/tidy_candidate.go +++ b/internal/runtime/memory/tidy_candidate.go @@ -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)。 diff --git a/internal/runtime/memory/tidy_dedupe.go b/internal/runtime/memory/tidy_dedupe.go new file mode 100644 index 0000000..cf05ba3 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe.go @@ -0,0 +1,492 @@ +package memory + +import ( + "context" + "sort" + "strings" + "time" +) + +// 本文件是 `dedupe_group` 候选动作(2026-10-24,M34/M27)的**簇形成**与候选构造。 +// +// 动因是 M27 只读体检的定量结论(`DevDocs/audit/记忆库价值体检-2026-10-24.md`): +// active 3657 条里可复用 ≈32%,而「近似冗余」≈50%;但整理层 10-14 起**一条候选都不产** +// —— 因为它的合并判据是**包含关系**(`judgeContainment`),而问题的形态是 +// 「同一件事换了个说法」:`助手名为 Cyrene。` 与 `助手/人格名为 Cyrene。` 互不包含。 +// +// 🔴 **为什么不放大 `containNone`**(体检报告 §9-1 的两条路里选了后一条): +// `containNone` 是一条**护栏**(防「冰美式 vs 红牛」这类同主题不同事实被合并), +// 放宽它等于把护栏拆掉。因此这里新增一个**更窄**的动作:它不改变任何一对的 +// 包含判定,只在「簇」这个粒度上认两种**机械可判**的同一性: +// +// 1. **同 scope + 同 kind + 完全相同 tag 集**(tag 集非空,且每个 tag ≥2 字、 +// 至少 2 个 tag —— 体检报告 §7 第 2 批的建议:排除 `('作息','工作')` 这类 +// 过于通用的 tag 集,宁可少减也不误杀); +// 2. **前 12 个字相同**(`dedupeGroupPrefixRunes`,与体检报告 R2 同一口径), +// 并附加**相似度下限** `dedupeGroupPrefixSimilarity` 与**同 kind** 两条收窄: +// 前者挡住「同一开头、后半句讲了别的实体」的对(`主人喜欢用「喵」…` vs +// `主人喜欢用「汪」…` 这类同构不同事实),后者避免「合并顺手改了分类」—— +// kind 的改动仍只走 reclassify 那条路(design/23 §5.1 T0-6)。 +// +// 分区(persona_id + scope + project_id)是**硬门**:候选只在一个分区内形成, +// 跨 scope 的同事实(体检报告 §7 第 3 批,83 簇/421 行)**明确不做**(会动 persona +// 写权语义,塔已拍不做)。 +// +// 每簇的**安全门**与既有合并路径**逐条同源**(不新造一套判据): +// - 群聊/陌生人来源 ⇒ 排除(design/23 §5.2-9、§6,纵向防御同 tidy_rules); +// - 含绝对日期/时刻 ⇒ 排除(那些走 archive,design/23 §5.1 T0-5); +// - 含有时限状态词(假期/期间/本周…)⇒ 排除(§5.2-1); +// - 置信 < tidyLowConfidenceThreshold ⇒ 排除(§5.2-6); +// - 数值 token 与存活行不一致 ⇒ 该成员不入簇(§5.2-2); +// - 否定标记与存活行不一致 ⇒ 该成员不入簇(§5.2-3「X vs 非X 是覆盖不是重复」)。 +// +// 保留规则(体检报告 §7 第 2 批的同一条):**内容最长(超集) > merge_count 大 > +// created_at 早**,仍并列时按 id 字典序(确定性)。「最长」优先是因为簇里的冗余 +// 大多是「把同一件事越写越全」,最长那条通常信息量最大;merge_count 大说明它已经 +// 吸收过别的表述;两者都并列时留最早的那条(它是 first_seen,时间跨度落在它身上)。 + +const ( + // TidyDedupeGroup 是「机械簇去重」的动作名(design/23 §10 的 action 枚举新增值)。 + // + // 它与 `merge` 的差别只在**判据**(簇的同一性 vs 事实集合的包含),落库形态完全 + // 相同:一簇一条候选,`source_ids` = 被吸收的行,`target_id` = 存活行,应用是 + // **软合并**(被吸收行 → status='merged' + merged_into),因此**可逐条 revert**。 + // + // 🔴 **不在自动档里**:一次点击会改多行,体检报告 §7.2 要求在环核对(先做 100 簇、 + // 人读 20 簇再放开),因此它**只走人工/批量点**,不进 tidy_auto 的白名单。 + TidyDedupeGroup TidyAction = "dedupe_group" + + // tidyReasonDedupeGroup 是 dedupe_group 候选的理由标签(落 reason 列)。 + tidyReasonDedupeGroup = "dedupe_group" + + // dedupeGroupPrefixRunes 是「前 N 字相同」的 N(体检报告 R2 的实测口径)。 + dedupeGroupPrefixRunes = 12 + + // dedupeGroupMinTagRunes / dedupeGroupMinTagCount 是 tag 集簇的门槛: + // 每个 tag 至少 2 个字、至少 2 个 tag。单字 tag(「喵」「住」)与单个 tag + // 太宽,簇会退化成「同主题就合并」——那正是体检报告 §7 第 2 批警告的形态。 + dedupeGroupMinTagRunes = 2 + dedupeGroupMinTagCount = 2 + + // dedupeGroupPrefixSimilarity 是前缀簇成员的**附加**相似度下限(词项余弦, + // 与召回的 lexicalSimilarity 同一个函数、同一个量纲)。 + // + // 这条收窄是**必须的**:语料实测 871 对「前 12 字相同」的候选对里,相似度分布从 + // 0.11 到 0.99 —— 最低那批是「同开头、不同事实」(`助手(persona)的说话风格…` + // vs `助手(persona)的象征花为蓝玫瑰…`,0.111),合并它们是**丢事实**。 + // 按阈值看语料(同一批 871 对): + // + // ≥0.60 → 40% ≥0.70 → 14% ≥0.75 → 6% ≥0.80 → 2% ≥0.85 → 1% + // + // 0.70 取在这里:≥0.70 的 120 对里抽 25 对人读**全是同一句话的改写**,而 0.11~0.35 + // 段全是不同事实 ⇒ 0.70 是「既能捞回改写、又不碰同开头不同事实」的那一档。 + // 它比 tidy 的召回门(0.80)低是刻意的:召回门只是「值不值得看一眼」,而这里是 + // 「要不要自动判同一簇」,两者风险不同 —— 但**两者都不是判定依据**(簇的同一性 + // 由「前 12 字」给出),这条下限只是把明显不同的对排除掉。 + dedupeGroupPrefixSimilarity = 0.70 +) + +// dedupe_group 的证据字段(进 TidyEvidence,供面板 diff 预览与运维判读)。 +const ( + // tidyDedupeRuleTagSet / tidyDedupeRulePrefix 是簇的判据名(可审计)。 + tidyDedupeRuleTagSet = "tag_set" + tidyDedupeRulePrefix = "prefix12" + + // 保留规则名(evidence.kept_reason):存活行为什么是它。 + tidyDedupeKeepLongest = "longest_content" + tidyDedupeKeepMergeCount = "merge_count" + tidyDedupeKeepOldest = "oldest_created_at" + tidyDedupeKeepID = "id_order" +) + +// tidyDedupeKey 是簇键:判据 + 键值。两者一起进 evidence,便于复核「为什么它们同簇」。 +type tidyDedupeKey struct { + rule string + key string +} + +// tidyDedupeCluster 是一个待落候选的簇:存活行 + 被吸收行。 +type tidyDedupeCluster struct { + Rule string + Key string + Survivor MemoryItem + Absorbed []MemoryItem + // KeptReason 是存活行胜出的那条规则(evidence.kept_reason 的来源)。 + KeptReason string +} + +// tidyDedupeStats 是分区内 dedupe 分簇的观测口径(进 TidyStats)。 +type tidyDedupeStats struct { + // Groups 是**真正产出候选**的簇数。 + Groups int + // Rows 是这些簇**可减少的 active 行数**(被吸收行合计)——「预计减少行数」。 + Rows int +} + +// dedupeInPartition 是 runPartition 调用点:读 merge_count、把**已被既有候选覆盖**的 +// 条目屏蔽掉,再分簇产候选。 +// +// 屏蔽(covered)是「不产生第二条待办」的落点:一对已经拿到 archive/merge 候选的 +// 条目不再进簇。它是纯粹的减法(覆盖集合由前一步的**产物**给出),因此不是第二套判据。 +func (t *Tidier) dedupeInPartition(ctx context.Context, p tidyPartition, items []MemoryItem, + existing []TidyCandidate) ([]TidyCandidate, tidyDedupeStats) { + covered := make(map[string]struct{}, len(existing)*2) + for _, c := range existing { + for _, id := range c.SourceIDs { + covered[id] = struct{}{} + } + if c.TargetID != "" { + covered[c.TargetID] = struct{}{} + } + } + // merge_count 是存活行的**次要**判据(「内容最长」并列时才用它)。缺它时降级到 + // created_at 仍能给出确定性的存活行,因此这里降级继续、但如实记 WARN。 + meta, err := t.deps.Store.dedupeItemMeta(ctx, p) + if err != nil { + t.log.Warn("memory tidy dedupe item meta unavailable, clustering without merge_count/temporary flags", + "partition", p.label(), "error", err) + meta = nil + } + return t.dedupeCandidates(items, meta, covered, t.now().UTC()) +} + +// dedupeCandidates 在**一个分区内**把 items 分成机械簇并产候选。它是纯函数式的 +// (只读入参、无 IO、无模型),因此可完全单测,也在试算轮与真跑轮**共用** +// (两条路径都从 runPartition 进来,见 tidy.go)。 +func (t *Tidier) dedupeCandidates(items []MemoryItem, meta map[string]tidyDedupeMeta, + covered map[string]struct{}, now time.Time) ([]TidyCandidate, tidyDedupeStats) { + clusters := dedupeClusters(items, meta, covered) + if len(clusters) == 0 { + return nil, tidyDedupeStats{} + } + out := make([]TidyCandidate, 0, len(clusters)) + stats := tidyDedupeStats{} + for _, cl := range clusters { + out = append(out, dedupeGroupCandidate(cl, now)) + stats.Groups++ + stats.Rows += len(cl.Absorbed) + } + return out, stats +} + +// dedupeClusters 把条目分组、按确定性顺序认领成员、并为每簇选出存活行。 +// +// 「每一条只进一个簇」是靠**全局认领集合**保证的(大簇优先、键字典序次之): +// 一条记忆同时出现在两个簇里会让两次应用互相打脸(第二次的 `AND status='active'` +// 条件更新如实跳过,但面板会看到一条永远 skip 的候选)。 +func dedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) []tidyDedupeCluster { + groups := dedupeGroupItems(items, meta, covered) + ordered := dedupeOrderedGroups(groups) + return claimDedupeClusters(items, meta, ordered) +} + +// dedupeGroupItems 把可进簇的条目按簇键归组(键 → 成员下标)。 +func dedupeGroupItems(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) map[string]*tidyDedupeGroup { + groups := make(map[string]*tidyDedupeGroup) + for idx := range items { + if _, taken := covered[items[idx].ID]; taken { + continue + } + if meta[items[idx].ID].temporary { + // 临时层行不许进簇(见 tidyDedupeMeta.temporary 的说明)。 + continue + } + if !dedupeGroupEligible(items[idx]) { + continue + } + for _, key := range dedupeClusterKeys(items[idx]) { + g := groups[key.key] + if g == nil { + g = &tidyDedupeGroup{rule: key.rule, key: key.key} + groups[key.key] = g + } + g.members = append(g.members, idx) + } + } + return groups +} + +// dedupeOrderedGroups 只留下真正的簇(≥2 条)并按**确定性顺序**排好:大簇优先 +// (可减行数最多的先落),同大小按 (key, rule) 字典序 —— 与 map 的随机遍历顺序无关, +// 因此同一批数据反复运行产出同一批候选(幂等键随之稳定)。 +func dedupeOrderedGroups(groups map[string]*tidyDedupeGroup) []*tidyDedupeGroup { + ordered := make([]*tidyDedupeGroup, 0, len(groups)) + for _, g := range groups { + if len(g.members) < 2 { + continue + } + ordered = append(ordered, g) + } + sort.Slice(ordered, func(i, j int) bool { + if len(ordered[i].members) != len(ordered[j].members) { + return len(ordered[i].members) > len(ordered[j].members) + } + if ordered[i].key != ordered[j].key { + return ordered[i].key < ordered[j].key + } + return ordered[i].rule < ordered[j].rule + }) + return ordered +} + +// claimDedupeClusters 逐簇认领成员并产簇:**每一条只进一个簇**(靠全局认领集合), +// 存活行与全部被吸收行一起认领 —— 存活行绝不能再当别簇的被吸收行。 +func claimDedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta, + ordered []*tidyDedupeGroup) []tidyDedupeCluster { + claimed := make(map[int]struct{}, len(items)) + out := make([]tidyDedupeCluster, 0, len(ordered)) + for _, g := range ordered { + free := make([]tidyDedupeMember, 0, len(g.members)) + for _, idx := range g.members { + if _, taken := claimed[idx]; taken { + continue + } + free = append(free, tidyDedupeMember{item: items[idx], mergeCount: meta[items[idx].ID].mergeCount}) + } + if len(free) < 2 { + continue + } + survivor, keptReason := pickDedupeSurvivor(free) + absorbed := dedupeAbsorbable(g.rule, survivor, free) + if len(absorbed) == 0 { + continue + } + keep := make(map[string]struct{}, len(absorbed)+1) + keep[survivor.item.ID] = struct{}{} + for _, it := range absorbed { + keep[it.item.ID] = struct{}{} + } + for _, idx := range g.members { + if _, ok := keep[items[idx].ID]; ok { + claimed[idx] = struct{}{} + } + } + out = append(out, tidyDedupeCluster{ + Rule: g.rule, Key: g.key, Survivor: survivor.item, Absorbed: dedupeItems(absorbed), + KeptReason: keptReason, + }) + } + return out +} + +// tidyDedupeGroup 是内部用的分组累加器(键 → 成员下标)。 +type tidyDedupeGroup struct { + rule string + key string + members []int +} + +// dedupeGroupEligible 报告一条记忆能不能进机械簇。它是既有硬门/否决规则的**并集**, +// 因此 dedupe_group 的判定面严格窄于 `judgePair`(后者还要求包含关系)。 +func dedupeGroupEligible(it MemoryItem) bool { + if strings.TrimSpace(it.ID) == "" { + // 没有 id 的行不可能被候选引用(候选靠 source_ids / target_id 指向真实行), + // 把它放进簇只会产出一条永远 skip 的候选。纵深防御:这条在落库数据上不会出现。 + return false + } + if nonOwnerSource(it.SourceConversation) { + // 群聊/陌生人来源只读不写(design/23 §5.2-9、§6):连簇都不进。 + return false + } + if hasAbsoluteTimestamp(it.Content) { + // 一次性事件走 archive(§5.1 T0-5),不进合并。 + return false + } + if hasTemporalState(it.Content) { + // 有期限状态(假期/期间/本周…)不是长期事实(§5.2-1)。 + return false + } + if it.Confidence < tidyLowConfidenceThreshold { + // 低置信是**否决门**(§5.2-6):低置信条目的孪生被它挡住了 2479 对, + // 那正是体检报告 §6.2 的最大否决项。dedupe_group 不越过这道门。 + return false + } + if len([]rune(it.Content)) < minTidyContentRunes { + return false + } + return true +} + +// dedupeClusterKeys 返回一条记忆的簇键(tag 集 / 前 12 字)。两条判据都可能不成立 +// (tag 集不合格 / 内容短于 12 字),那时只返回另一条。 +func dedupeClusterKeys(it MemoryItem) []tidyDedupeKey { + out := make([]tidyDedupeKey, 0, 2) + if key := dedupeTagKey(it); key != "" { + out = append(out, tidyDedupeKey{rule: tidyDedupeRuleTagSet, key: key}) + } + if key := dedupePrefixKey(it); key != "" { + out = append(out, tidyDedupeKey{rule: tidyDedupeRulePrefix, key: key}) + } + return out +} + +// dedupeTagKey 是「同分区 + 同 kind + 完全相同 tag 集」的键。tag 集为空/太通用时返回空串。 +// +// 键里带**分区**(persona/scope/project)与 kind,两个都是**纵深防御**: +// - 调用方本来就按分区逐块调用(design/23 §6「分区之间永不比较」),但候选键 +// 自带分区后,任何跨分区的输入(手工调用、将来别的入口)也**无法成簇** —— +// 跨 scope 收敛是塔已明确不做的那一批,它不许只靠调用纪律保证; +// - kind 是**内容类别**(relationship / world_event):跨 kind 的簇合并会顺手改掉 +// 被吸收行的分类语义,而分类改动只该走 reclassify(§5.1 T0-6)。 +func dedupeTagKey(it MemoryItem) string { + tags := normalizedTidyTags(it.Tags) + if len(tags) < dedupeGroupMinTagCount { + return "" + } + for _, tag := range tags { + if len([]rune(tag)) < dedupeGroupMinTagRunes { + return "" + } + } + return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + strings.Join(tags, "\x1e") +} + +// dedupePartitionPrefix 是簇键里的分区段(persona + scope + project)。 +func dedupePartitionPrefix(it MemoryItem) string { + return it.PersonaID + "\x1e" + string(it.Scope) + "\x1e" + it.ProjectID + "\x1e" +} + +// normalizedTidyTags 归一化 tag 集:去空白、去空项、去重、排序。排序是幂等键稳定 +// 的前提(同一 tag 集的不同书写顺序必须落到同一个键)。 +func normalizedTidyTags(tags []string) []string { + if len(tags) == 0 { + return nil + } + seen := make(map[string]struct{}, len(tags)) + out := make([]string, 0, len(tags)) + for _, tag := range tags { + t := strings.TrimSpace(tag) + if t == "" { + continue + } + if _, dup := seen[t]; dup { + continue + } + seen[t] = struct{}{} + out = append(out, t) + } + sort.Strings(out) + return out +} + +// dedupePrefixKey 是「**同分区同 kind** + 前 12 个字相同」的键(按**归一化正文**取前 12 个 rune)。 +// +// 归一化复用 dedupeKey(小写 + 空白折叠):`助手名为 Cyrene。` 与 `助手名为 Cyrene` +// 不该因为多一个空格就落到两个簇。 +// +// 短于 12 字的内容返回空串**而不受理**:那时「前 12 字」等于全文,簇会退化成 +// 「逐字重复」——那是 §15 D3-2 T0-1 的 `skip`(入库闸)与 `merge`(字面重复自动档) +// 已经在管的事,不需要第二个动作重复它。 +func dedupePrefixKey(it MemoryItem) string { + runes := []rune(dedupeKey(it.Content)) + if len(runes) < dedupeGroupPrefixRunes { + return "" + } + return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + string(runes[:dedupeGroupPrefixRunes]) +} + +// tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。 +// +// 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store.go 的 13 列)**不含** +// `merge_count`(它只写不读),而「存活行选谁」的次要判据正是它(合并次数多的那条 +// 已经吸收过别的表述,留它信息量最大)。为一个次要判据去改 13 列读面会牵动 +// Get/List*/preinsert/tidy 四处 SQL,风险远大于收益;改为按分区一次轻量查询把 +// id → merge_count 取出来(见 Store.dedupeMergeCounts)。 +type tidyDedupeMember struct { + item MemoryItem + mergeCount int +} + +// dedupeItems 把成员视图降回 MemoryItem(簇只对外暴露记忆行)。 +func dedupeItems(members []tidyDedupeMember) []MemoryItem { + out := make([]MemoryItem, 0, len(members)) + for _, m := range members { + out = append(out, m.item) + } + return out +} + +// pickDedupeSurvivor 按「内容最长 > merge_count 大 > created_at 早 > id 字典序」选存活行, +// 并返回命中的那条规则名(进 evidence.kept_reason,回答「为什么留下的是它」)。 +func pickDedupeSurvivor(members []tidyDedupeMember) (tidyDedupeMember, string) { + best := members[0] + for _, it := range members[1:] { + switch compareDedupePriority(it, best) { + case -1: + best = it + case 0: + if it.item.ID < best.item.ID { + best = it + } + } + } + return best, dedupeKeepReason(members, best) +} + +// compareDedupePriority 比较两条记忆谁更该存活:-1 表示 a 优先,1 表示 b 优先, +// 0 表示按 id 决胜(调用方处理)。 +func compareDedupePriority(a, b tidyDedupeMember) int { + if la, lb := len([]rune(a.item.Content)), len([]rune(b.item.Content)); la != lb { + if la > lb { + return -1 + } + return 1 + } + if a.mergeCount != b.mergeCount { + if a.mergeCount > b.mergeCount { + return -1 + } + return 1 + } + if !a.item.CreatedAt.Equal(b.item.CreatedAt) { + if a.item.CreatedAt.Before(b.item.CreatedAt) { + return -1 + } + return 1 + } + return 0 +} + +// dedupeKeepReason 报告存活行是靠哪一条胜出的(证据面)。三条判据依次判「是否唯一 +// 胜出」,都并列时落到最早创建(created_at)那条 —— 与 compareDedupePriority 同序。 +func dedupeKeepReason(members []tidyDedupeMember, survivor tidyDedupeMember) string { + longest := len([]rune(survivor.item.Content)) + uniqueLongest := true + uniqueMergeCount := true + for _, it := range members { + if it.item.ID == survivor.item.ID { + continue + } + if len([]rune(it.item.Content)) == longest { + uniqueLongest = false + } + if it.mergeCount == survivor.mergeCount { + uniqueMergeCount = false + } + } + switch { + case uniqueLongest: + return tidyDedupeKeepLongest + case uniqueMergeCount: + return tidyDedupeKeepMergeCount + case survivor.item.CreatedAt.Before(earliestOtherCreatedAt(members, survivor)): + return tidyDedupeKeepOldest + default: + return tidyDedupeKeepID + } +} + +// earliestOtherCreatedAt 返回除存活行以外最早创建的时刻(用于区分「最早胜出」与 +// 「id 决胜」这两种证据)。空簇成员只在单条簇里出现,那时调用方不会问证据。 +func earliestOtherCreatedAt(members []tidyDedupeMember, survivor tidyDedupeMember) time.Time { + var earliest time.Time + for _, it := range members { + if it.item.ID == survivor.item.ID { + continue + } + if earliest.IsZero() || it.item.CreatedAt.Before(earliest) { + earliest = it.item.CreatedAt + } + } + return earliest +} diff --git a/internal/runtime/memory/tidy_dedupe_apply.go b/internal/runtime/memory/tidy_dedupe_apply.go new file mode 100644 index 0000000..bd7eb0c --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_apply.go @@ -0,0 +1,110 @@ +package memory + +import ( + "context" + "database/sql" + "errors" + "time" +) + +// 本文件是 `dedupe_group`(2026-10-24,M34)的**应用与回滚**执行面。 +// +// 它与 merge 共用同一套语义(design/23 §8 的软合并):被吸收行 +// `status='merged' + merged_into=<存活 id>`,存活行 `merged_from` 追加、`merge_count` +// 累加;**绝不改正文、绝不删行**。因此回滚直接复用 `revertTidyMerge`(它本来就按 +// `SourceIDs` 逐条复原,不是为「一对一」写的)。 +// +// 与单条 merge 的两点差别、以及它们的理由: +// +// 1. **一条候选吸收多行**(一簇 N 条)。整簇在**一个事务**里落,任何一条不在册 +// ⇒ 整簇回滚(`errTidyItemNotActive`),不会出现「吞了一半」的中间态 —— +// 半吞的簇最坏:面板显示 applied,但被吞行里有一半还在 active 集合里, +// 回滚也无从判断该复原哪几条。 +// 2. **按行计的分批上限**(`maxTidyApplyRowsPerBatch`)。一条候选动 N 行,若只按 +// 「候选条数」限批,一次批量应用可能在一条命令里改上千行记忆 —— 这正是体检 +// 报告 §7.2 要求分批的理由(「第 2 批必须分批 + 每批留 run_id」)。行预算在 +// `ApplyTidyCandidates` 里统一执行,超限如实报 `truncated`。 +// +// 🔴 **不进自动档**:`tidyAutoApplyEligible` 的 switch 只认 merge/archive/reclassify, +// 默认分支返回 false,因此 dedupe_group 永远只走人工/批量点(测试钉着这一条)。 + +// maxTidyApplyRowsPerBatch 是一次批量应用**最多改动的记忆行数**(dedupe_group 按 +// 被吸收行计)。200 的来源是体检报告 §7 第 2 批的建议值:一簇平均 3~4 行, +// 200 行 ≈ 50 簇,一屏可核对;它比候选条数上限(100)**更严**的场合是「簇很大」, +// 这正是需要护栏的那种数据。 +const maxTidyApplyRowsPerBatch = 200 + +// tidyApplyRowCost 返回一条候选**会改动的记忆行数**,供批量应用的按行预算使用。 +// 只有 dedupe_group 是多行动作;其余三种动作恒为 1(merge/archive/reclassify 各碰一行)。 +func tidyApplyRowCost(c TidyCandidate) int { + if c.Action == TidyDedupeGroup && len(c.SourceIDs) > 0 { + return len(c.SourceIDs) + } + return 1 +} + +// applyTidyDedupeGroup 把一簇被吸收行软合并进存活行。 +// +// 前置校验与 merge 逐条同源:分区硬门、只读来源硬门、目标行必须存在且与候选同分区。 +// 任何一条不通过都如实 skip(不假装做过),且**一行都不写**。 +func (s *Store) applyTidyDedupeGroup(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) { + base := TidyApplyResult{ + CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{}, + } + if len(cand.SourceIDs) == 0 || cand.TargetID == "" { + return skipTidyApply(base, tidySkipInvalidCandidate), nil + } + survivor, err := s.Get(ctx, cand.TargetID) + if errors.Is(err, ErrNotFound) { + return skipTidyApply(base, tidySkipSourceMissing), nil + } + if err != nil { + return base, err + } + if skip := tidyPartitionMismatch(cand, survivor); skip != "" { + return skipTidyApply(base, skip), nil + } + if nonOwnerSource(survivor.SourceConversation) { + // 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御, + // 规则层本就把这类条目排除在分簇之外。 + return skipTidyApply(base, tidySkipSourceRestricted), nil + } + absorbed := make([]MemoryItem, 0, len(cand.SourceIDs)) + for _, id := range cand.SourceIDs { + if id == cand.TargetID { + return skipTidyApply(base, tidySkipInvalidCandidate), nil + } + it, gerr := s.Get(ctx, id) + if errors.Is(gerr, ErrNotFound) { + return skipTidyApply(base, tidySkipSourceMissing), nil + } + if gerr != nil { + return base, gerr + } + if skip := tidyPartitionMismatch(cand, it); skip != "" { + return skipTidyApply(base, skip), nil + } + if nonOwnerSource(it.SourceConversation) { + return skipTidyApply(base, tidySkipSourceRestricted), nil + } + absorbed = append(absorbed, it) + } + err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error { + for _, it := range absorbed { + if err := markTidyAbsorbed(ctx, tx, it.ID, survivor.ID, now); err != nil { + return err + } + // 逐条 bump:bumpSurvivor 在事务内**重读**存活行的 merge_count, + // 因此连续调用会把 N 条都记进 merged_from、把计数加 N 次。 + if err := bumpSurvivor(ctx, tx, survivor, it, now); err != nil { + return err + } + } + return nil + }) + affected := make([]string, 0, len(absorbed)) + for _, it := range absorbed { + affected = append(affected, it.ID) + } + return finishTidyApply(base, affected, err, tidySkipSourceNotActive) +} diff --git a/internal/runtime/memory/tidy_dedupe_apply_test.go b/internal/runtime/memory/tidy_dedupe_apply_test.go new file mode 100644 index 0000000..110008a --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_apply_test.go @@ -0,0 +1,289 @@ +package memory + +import ( + "context" + "fmt" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" +) + +// 本文件是 `dedupe_group` 的**应用 / 回滚**验收用例集(软合并、整簇原子、 +// 按行计的分批上限、临时层数据安全)。分簇与候选构造的用例在 +// tidy_dedupe_test.go —— 分开只为守 AGENTS §10 的文件行数上限(≤500 行)。 + +// TestTidyApplyDedupeGroupIsSoftAndRevertible 钉住应用/回滚:整簇软合并(行不删、 +// 正文不改、merge_count 累加),revert 把**每一行**都复原。 +func TestTidyApplyDedupeGroupIsSoftAndRevertible(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + tags := []string{"身份", "名字"} + items := []MemoryItem{ + seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)), + seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))), + seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))), + } + cands, _ := dedupeCandsOf(t, store, items) + if len(cands) != 1 { + t.Fatalf("簇数 = %d, want 1", len(cands)) + } + cand := cands[0] + if created, err := store.insertTidyCandidate(ctx, cand); err != nil || !created { + t.Fatalf("insert candidate: created=%v err=%v", created, err) + } + + res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"}) + if err != nil { + t.Fatalf("ApplyTidyCandidate: %v", err) + } + if res.Status != TidyApplyApplied || len(res.AffectedIDs) != 2 { + t.Fatalf("应用结果 = %+v, want applied 且影响 2 行", res) + } + for _, id := range cand.SourceIDs { + row := loadTidyItemRow(t, store, id) + if row.status != ingestStatusMerged || row.mergedInto != cand.TargetID { + t.Fatalf("被吸收行 %s = %+v, want merged → %s", id, row, cand.TargetID) + } + } + survivor := loadTidyItemRow(t, store, cand.TargetID) + if survivor.status != tidyStatusActive { + t.Fatalf("存活行状态 = %q, want active", survivor.status) + } + if got := countMemoryItems(t, store); got != 3 { + t.Fatalf("应用后行数 = %d, want 3(软合并绝不删行)", got) + } + if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 3 { + t.Fatalf("存活行 merge_count = %d, want 3(1 + 吸收 2)", got) + } + if got := loadTidyItemRow(t, store, cand.TargetID).content; got != items[2].Content { + t.Fatalf("存活行正文被改了:%q", got) + } + + reverted, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"}) + if err != nil { + t.Fatalf("RevertTidyCandidate: %v", err) + } + if reverted.Status != TidyApplyApplied || len(reverted.AffectedIDs) != 2 { + t.Fatalf("回滚结果 = %+v, want applied 且复原 2 行", reverted) + } + for _, id := range cand.SourceIDs { + row := loadTidyItemRow(t, store, id) + if row.status != tidyStatusActive || row.mergedInto != "" { + t.Fatalf("回滚后 %s = %+v, want active 且 merged_into 清空", id, row) + } + } + if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 1 { + t.Fatalf("回滚后 merge_count = %d, want 1", got) + } + if got, _ := store.GetTidyCandidate(ctx, cand.ID); got.Status != tidyCandidatePending { + t.Fatalf("回滚后候选状态 = %q, want pending(可重新决定)", got.Status) + } +} + +// TestTidyApplyDedupeGroupRollsBackWholeClusterOnStaleMember 钉住原子的**安全方向**: +// 簇里有一行已不在册 ⇒ 整簇不吞(一行都不改),候选如实 skip。 +func TestTidyApplyDedupeGroupRollsBackWholeClusterOnStaleMember(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + tags := []string{"身份", "名字"} + items := []MemoryItem{ + seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)), + seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))), + seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))), + } + cands, _ := dedupeCandsOf(t, store, items) + cand := cands[0] + if created, err := store.insertTidyCandidate(ctx, cand); err != nil || !created { + t.Fatalf("insert candidate: created=%v err=%v", created, err) + } + // 让簇里一个成员先被别的动作合并掉(模拟过时候选)。 + if _, err := store.db.ExecContext(ctx, + `UPDATE memory_items SET status = ? WHERE id = ?`, + ingestStatusMerged, cand.SourceIDs[0]); err != nil { + t.Fatalf("stale member update: %v", err) + } + res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"}) + if err != nil { + t.Fatalf("ApplyTidyCandidate: %v", err) + } + if res.Status != TidyApplySkipped || res.Reason != tidySkipSourceNotActive { + t.Fatalf("结果 = %+v, want skipped/%s(整簇原子,不许吞一半)", res, tidySkipSourceNotActive) + } + if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 1 { + t.Fatalf("存活行 merge_count = %d, want 1(整簇回滚,一行都没改)", got) + } + if got := loadTidyItemRow(t, store, cand.SourceIDs[1]).status; got != tidyStatusActive { + t.Fatalf("第二个被吸收行状态 = %q, want active(半吞禁止)", got) + } +} + +// TestTidyApplyDedupeGroupHonoursRowBudget 钉住「批量按行计、超限如实 truncated」: +// 每簇 2 个被吸收行(行预算按被吸收行计),预算 200 ⇒ 最多 100 簇(= 200 行), +// 第 101 簇起留给下一次点击,回执必须如实说 truncated。 +func TestTidyApplyDedupeGroupHonoursRowBudget(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + total := maxTidyApplyRowsPerBatch/2 + 5 // 簇数多到必然撞上 200 行预算 + wantApplied := maxTidyApplyRowsPerBatch / 2 // 每簇 2 个被吸收行 + + for i := 0; i < total; i++ { + tags := []string{"身份", "代号"} + content := fmt.Sprintf("小茂密的第 %d 号说法:蝴蝶结是小铃铛", i) + items := []MemoryItem{ + seedDedupeItem(t, store, dedupeTestItem(content+"甲", tags, base)), + seedDedupeItem(t, store, dedupeTestItem(content+"乙", tags, base.Add(time.Minute))), + seedDedupeItem(t, store, dedupeTestItem(content+"丙", tags, base.Add(2*time.Minute))), + } + cands, _ := dedupeCandsOf(t, store, items) + if len(cands) != 1 { + t.Fatalf("第 %d 组簇数 = %d, want 1", i, len(cands)) + } + if created, err := store.insertTidyCandidate(ctx, cands[0]); err != nil || !created { + t.Fatalf("insert candidate %d: created=%v err=%v", i, created, err) + } + } + batch, err := store.ApplyTidyCandidates(ctx, "", TidyApplyOptions{Actor: "owner:test", Limit: 500}) + if err != nil { + t.Fatalf("ApplyTidyCandidates: %v", err) + } + if !batch.Truncated { + t.Fatalf("truncated = false, want true(行预算 200 被撞上时必须如实说)") + } + if batch.Applied != wantApplied { + t.Fatalf("应用簇数 = %d, want %d(200 行 ÷ 3 行/簇)", batch.Applied, wantApplied) + } + if batch.RowsAffected != wantApplied*2 { + t.Fatalf("改动行数 = %d, want %d", batch.RowsAffected, wantApplied*2) + } + if batch.Deduped != wantApplied { + t.Fatalf("deduped 计数 = %d, want %d", batch.Deduped, wantApplied) + } +} + +// TestTidyDryRunReportsDedupeGroupsWithoutWriting 钉住「**先试算、不写库**」这条要求: +// 试算轮必须给出 dedupe_group 的**簇数与可减少行数**(面板据此决定要不要落库), +// 并且三张表逐行不变(零副作用)—— 试算与真跑共用同一条规则路径,因此这份报告 +// 就是「真跑会产出的东西」。 +func TestTidyDryRunReportsDedupeGroupsWithoutWriting(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + // 三条**互不包含**、但 tag 集完全相同的说法(否则它们会先被包含判定吃掉, + // 那时 dedupe 会被「已覆盖」抑制掉 —— 那是对的行为,但测不到试算口径)。 + tags := []string{"宠物", "小茂密"} + for i, content := range []string{ + "小茂密是白毛的小猫,喜欢撒娇", + "茂密的蝴蝶结最近换成了深色", + "茂密爱坐在窗边看外面的风景", + } { + seedDedupeItem(t, store, dedupeTestItem(content, tags, base.Add(time.Duration(i)*time.Minute))) + } + before := snapshotTidyTables(t, store) + emb := stubEmbedder{vecs: map[string]embedding.Vector{}} + + dr, err := newTestTidier(t, store, tidyTestCfg(0.80), emb).DryRun(ctx) + if err != nil { + t.Fatalf("DryRun: %v", err) + } + if dr.Stats.DedupeGroups != 1 || dr.Stats.DedupeRows != 2 { + t.Fatalf("试算口径 = groups:%d rows:%d, want 1 / 2(三行一簇,可减两行)", + dr.Stats.DedupeGroups, dr.Stats.DedupeRows) + } + dedupeCands := candidatesByAction(dr.Candidates, TidyDedupeGroup) + if len(dedupeCands) != 1 { + t.Fatalf("试算候选里的 dedupe_group = %d 条, want 1", len(dedupeCands)) + } + assertRowsUnchanged(t, "试算后三张表", before.items, snapshotTidyTables(t, store).items) + after := snapshotTidyTables(t, store) + assertRowsUnchanged(t, "试算后候选表", before.candidates, after.candidates) + assertRowsUnchanged(t, "试算后运行表", before.runs, after.runs) +} + +// TestDedupeGroupNeverTouchesTemporaryRows 钉住临时层的**数据安全**不变式: +// `expires_at` 非空的记忆(临时层)绝不进簇 —— 无论当存活行还是被吸收行。 +// +// 为什么是红线:临时行 7 天后由后台清扫**物理删除**(design/23 §19)。若它当存活行, +// 被吸收的长期事实会永远停在 `status='merged'` 且 `merged_into` 指向一个已不存在的 +// id(信息不可达);若它当被吸收行,一次合并就把一条正在生效的临时记忆藏了起来。 +func TestDedupeGroupNeverTouchesTemporaryRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + tags := []string{"身份", "名字"} + durable := seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)) + temporary := seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))) + if _, err := store.db.ExecContext(ctx, + `UPDATE memory_items SET expires_at = ? WHERE id = ?`, + expiryText(base.Add(7*24*time.Hour)), temporary.ID); err != nil { + t.Fatalf("mark temporary: %v", err) + } + cands, stats := dedupeCandsOf(t, store, []MemoryItem{durable, temporary}) + if len(cands) != 0 || stats.Groups != 0 { + t.Fatalf("簇数 = %d, want 0(临时层行不许进簇:存活行会让被吸收行指向一个将被删除的 id)", + len(cands)) + } + + // 三条时:两条长期 + 一条临时 ⇒ 正是两条长期的那一簇,临时行不在候选里。 + durable2 := seedDedupeItem(t, store, dedupeTestItem("助理/助手名为 Cyrene。", tags, base.Add(2*time.Minute))) + cands, _ = dedupeCandsOf(t, store, []MemoryItem{durable, temporary, durable2}) + if len(cands) != 1 { + t.Fatalf("簇数 = %d, want 1", len(cands)) + } + for _, id := range cands[0].SourceIDs { + if id == temporary.ID { + t.Fatalf("临时行出现在被吸收行里:%v", cands[0].SourceIDs) + } + } + if cands[0].TargetID == temporary.ID { + t.Fatalf("临时行当上了存活行 —— 它 7 天后会被物理删除") + } +} + +// TestTidyRunReportsDedupeTruncation 钉住「truncated 如实」:每分区候选上限切掉了 +// 多少 dedupe 簇必须**单独**报出来。变异(不给 DedupeTruncated 计数、或按错误的 +// 段计算)会让这条用例红 —— 被截断的簇在面板上会彻底消失,那正是「静默少做」。 +func TestTidyRunReportsDedupeTruncation(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + + // 两个互不相干的簇(tag 集不同 ⇒ 两个簇),一共 2 条 dedupe 候选。 + seed := []struct { + content string + tags []string + }{ + {"小茂密是白毛的小猫,喜欢撒娇", []string{"宠物", "小茂密"}}, + {"茂密的蝴蝶结最近换成了深色", []string{"宠物", "小茂密"}}, + {"主人对花溪的住处很满意", []string{"地点", "住处"}}, + {"主人说花溪的住处很安静", []string{"地点", "住处"}}, + } + for i, s := range seed { + seedDedupeItem(t, store, dedupeTestItem(s.content, s.tags, base.Add(time.Duration(i)*time.Minute))) + } + cfg := tidyTestCfg(0.80) + cfg.CandidateLimit = 1 + emb := stubEmbedder{vecs: map[string]embedding.Vector{}} + stats, err := newTestTidier(t, store, cfg, emb).RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.DedupeGroups != 2 { + t.Fatalf("分簇数 = %d, want 2(分簇口径不受候选上限影响)", stats.DedupeGroups) + } + if stats.DedupeTruncated != 1 { + t.Fatalf("DedupeTruncated = %d, want 1(上限 1 只容得下一簇)", stats.DedupeTruncated) + } + if len(stats.Truncated) == 0 { + t.Fatalf("Truncated 为空 —— 被上限切掉的部分必须如实登记分区标签") + } +} diff --git a/internal/runtime/memory/tidy_dedupe_candidate.go b/internal/runtime/memory/tidy_dedupe_candidate.go new file mode 100644 index 0000000..7c31881 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_candidate.go @@ -0,0 +1,83 @@ +package memory + +import ( + "sort" + "time" +) + +// 本文件是 `dedupe_group` 的**候选构造**侧:从一簇成员里挑出可并入的行、 +// 并把簇转成候选(落库形状与 merge 候选同构,因此面板/应用/回滚三条路都不用改)。 +// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行), +// 判据与注释**逐字未动**;簇形成在 tidy_dedupe.go,元信息查询在 tidy_dedupe_meta.go。 + +// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。 +// +// 数值与否定是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」, +// 而同一性由存活行代表;任何一条与存活行数值/否定不一致的成员,说明它讲的是 +// **另一个事实**(§5.2-2/§5.2-3),因此它**不进这一簇**(留待人工),而不是让整簇作废。 +// +// 前缀簇还要过相似度下限(见 dedupeGroupPrefixSimilarity);tag 集簇不需要 —— +// 完全相同的 tag 集本身就是强证据,而前缀簇的「同开头」弱得多。 +func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedupeMember) []tidyDedupeMember { + out := make([]tidyDedupeMember, 0, len(members)) + for _, it := range members { + if it.item.ID == survivor.item.ID { + continue + } + if !sameNumbers(it.item.Content, survivor.item.Content) { + continue + } + if !sameNegations(it.item.Content, survivor.item.Content) { + continue + } + if rule == tidyDedupeRulePrefix && + lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupPrefixSimilarity { + continue + } + out = append(out, it) + } + // 被吸收行的顺序也必须是确定的(幂等键按 sorted(source_ids) 算,但证据里的 + // dedupe_members 会原样落库,顺序抖动会让同一个簇每次产出不同的证据文本)。 + sort.Slice(out, func(i, j int) bool { return out[i].item.ID < out[j].item.ID }) + return out +} + +// dedupeGroupCandidate 把一个簇转成候选。 +// +// 落库字段与 merge 候选同构,因此既有面板/应用/回滚三条路都不用改形状: +// `source_ids` = 被吸收行(**可多条**,`applyTidyDedupeGroup` 逐条软合并), +// `target_id` = 存活行,`merged_content` = 存活行正文(**绝不改正文**)。 +func dedupeGroupCandidate(cl tidyDedupeCluster, now time.Time) TidyCandidate { + absorbedIDs := make([]string, 0, len(cl.Absorbed)) + members := make([]string, 0, len(cl.Absorbed)) + for _, it := range cl.Absorbed { + absorbedIDs = append(absorbedIDs, it.ID) + members = append(members, it.Content) + } + return TidyCandidate{ + ID: mustNewTidyID(), + PersonaID: cl.Survivor.PersonaID, + Scope: cl.Survivor.Scope, + ProjectID: cl.Survivor.ProjectID, + Kind: cl.Survivor.Kind, + Action: TidyDedupeGroup, + SourceIDs: absorbedIDs, + TargetID: cl.Survivor.ID, + ProposedContent: cl.Survivor.Content, + MergedContent: cl.Survivor.Content, + Reason: tidyReasonDedupeGroup, + Evidence: TidyEvidence{ + Containment: "none", + ContentA: cl.Survivor.Content, + DedupeRule: cl.Rule, + DedupeKey: cl.Key, + ClusterSize: len(cl.Absorbed) + 1, + KeptReason: cl.KeptReason, + DedupeMembers: members, + KindA: string(cl.Survivor.Kind), + }, + RuleVersion: TidyRuleVersion, + Status: tidyCandidatePending, + CreatedAt: now, + } +} diff --git a/internal/runtime/memory/tidy_dedupe_meta.go b/internal/runtime/memory/tidy_dedupe_meta.go new file mode 100644 index 0000000..ee99c11 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_meta.go @@ -0,0 +1,65 @@ +package memory + +import ( + "context" + "fmt" + "strings" +) + +// 本文件是 `dedupe_group` 的**元信息查询**:按分区取出「合并次数」与「是否临时层」 +// 两列 —— 它们是存活规则的次要判据与一条数据安全护栏(临时行绝不进簇)。 +// 从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行), +// 注释与判据**逐字未动**。 + +// dedupeItemMetaSQL 取一个分区内每条记忆的**分簇所需元信息**:合并次数 +// (存活规则的次要判据)+ 是否是临时层(`expires_at` 非空)。 +// +// 两列都只读、只走分区条件,与 tidy 分区扫描同一组谓词(不含 archived/merged)。 +const dedupeItemMetaSQL = `SELECT id, merge_count, expires_at FROM memory_items + WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ?` + +// tidyDedupeMeta 是一条记忆的额外元信息(`MemoryItem` 的读侧 SELECT 不含这两列)。 +type tidyDedupeMeta struct { + // mergeCount 是存活规则的次要判据(合并次数多的留)。 + mergeCount int + // temporary 报告这条记忆在**临时层**(`expires_at` 非空,design/03 §9.6)。 + // + // 🔴 临时层行**绝不进簇**,两个方向都不许:让一条长期事实并进一个 7 天后会被 + // 后台**物理删除**(design/23 §19)的存活行,等于把那条事实一起删掉 —— + // 被吸收行会永远停在 `status='merged'` 且 `merged_into` 指向一个不存在的 id。 + // 临时行本身也不值得合并:它的寿命本来就只剩几天。 + temporary bool +} + +// dedupeItemMeta 读出一个分区的 id → 元信息映射。 +// +// 失败方向上它是**可降级**的(调用方在失败时用空映射继续分簇):存活行的第一判据 +// 是「内容最长」,merge_count 只在前者并列时决定胜负。但**降级会让「临时行」这个 +// 判断失效** —— 因此调用方在降级时必须记 WARN,并且降级路径下 `temporary` 恒为 +// false 意味着「可能把临时行当长期行」;这是**宁可少做也不误删**的反面, +// 所以这里把降级方向定成「继续分簇 + WARN」,而不是「整个分区不分簇」: +// 判错的方向是「多留一条冗余」(可 revert),不是「丢一条记忆」。 +func (s *Store) dedupeItemMeta(ctx context.Context, p tidyPartition) (map[string]tidyDedupeMeta, error) { + rows, err := s.db.QueryContext(ctx, dedupeItemMetaSQL, + p.personaID, string(p.scope), p.projectID, string(tidyStatusActive)) + if err != nil { + return nil, fmt.Errorf("memory/tidy: dedupe item meta: %w", err) + } + defer func() { _ = rows.Close() }() + out := make(map[string]tidyDedupeMeta, 64) + for rows.Next() { + var ( + id string + mergeCnt int + expiresAt string + ) + if err := rows.Scan(&id, &mergeCnt, &expiresAt); err != nil { + return nil, fmt.Errorf("memory/tidy: scan dedupe item meta: %w", err) + } + out[id] = tidyDedupeMeta{mergeCount: mergeCnt, temporary: strings.TrimSpace(expiresAt) != ""} + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("memory/tidy: dedupe item meta rows: %w", err) + } + return out, nil +} diff --git a/internal/runtime/memory/tidy_dedupe_test.go b/internal/runtime/memory/tidy_dedupe_test.go new file mode 100644 index 0000000..7ebdb39 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_test.go @@ -0,0 +1,310 @@ +package memory + +import ( + "context" + "crypto/sha256" + "fmt" + "testing" + "time" +) + +// 本文件是 `dedupe_group`(2026-10-24,M34)的验收用例集。 +// +// 它钉住五件事,每件都能被一次**变异**打红: +// 1. **簇判据只有两条**(同 scope+kind+完全相同 tag 集 / 前 12 字相同)—— +// 拿掉任一条、或让它跨 scope/跨 kind 成簇,用例立刻红; +// 2. **保留规则**是「最长 > merge_count 大 > created_at 早」——改动顺序即红; +// 3. **安全门与既有合并路径同源**:数值/否定/时效/低置信/受限来源一律不入簇; +// 4. **幂等**:同一批数据两次分簇产出同一个幂等键(重复跑不重复产候选); +// 5. **可应用 / 可回滚**:应用是软合并(行不删、正文不改),回滚把每一行都复原, +// 批量应用按**行**计上限、超限如实 truncated。 + +// dedupeTestItem 造一条「可进簇」的记忆:同分区(persona.a / persona / 无项目)、 +// 置信 0.9、owner 来源(web 主会话)。tags / 内容 / 创建时刻逐项可控。 +func dedupeTestItem(content string, tags []string, at time.Time) MemoryItem { + return MemoryItem{ + // 确定性 id:内容 + 时刻,因此「同内容的孪生」也各有自己的 id + // (id 为空的两行会被 dedupeGroupEligible 挡在簇外 —— 那是纵深防御, + // 但夹具不该靠它)。 + ID: fmt.Sprintf("item-%x-%d", sha1OfForTest(content), at.UnixNano()), + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, + Content: content, Tags: tags, Confidence: 0.9, CreatedAt: at, + SourceConversation: FormatSourceConversation("web", "msg-1"), + } +} + +// sha1OfForTest 给夹具造确定性 id 用(与生产 id 无关,只要在用例内唯一)。 +func sha1OfForTest(content string) []byte { + sum := sha256.Sum256([]byte(content)) + return sum[:6] +} + +// seedDedupeItem 落一条记忆并返回它的落库形状。 +func seedDedupeItem(t *testing.T, store *Store, item MemoryItem) MemoryItem { + t.Helper() + return seedTidyItem(t, store, item) +} + +// dedupeCandsOf 跑一次分簇并只保留 dedupe_group 候选。 +func dedupeCandsOf(t *testing.T, store *Store, items []MemoryItem) ([]TidyCandidate, tidyDedupeStats) { + t.Helper() + tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store, Now: func() time.Time { return tidyTestTime(t) }}) + if err != nil { + t.Fatalf("NewTidier: %v", err) + } + meta, err := store.dedupeItemMeta(context.Background(), + tidyPartition{personaID: "persona.a", scope: ScopePersona}) + if err != nil { + t.Fatalf("dedupeItemMeta: %v", err) + } + return tidier.dedupeCandidates(items, meta, nil, tidyTestTime(t)) +} + +// TestDedupeGroupClustersByIdenticalTagSet 钉住判据一:**同 scope + 同 kind + +// 完全相同 tag 集**的条目成簇,每簇只产**一条**候选,存活行是「内容最长」那条。 +func TestDedupeGroupClustersByIdenticalTagSet(t *testing.T) { + t.Parallel() + store := newTestStore(t) + base := tidyTestTime(t) + tags := []string{"身份", "名字"} + items := []MemoryItem{ + seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)), + seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))), + seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))), + } + cands, stats := dedupeCandsOf(t, store, items) + if len(cands) != 1 || stats.Groups != 1 { + t.Fatalf("簇数 = %d (stats %+v), want 1 —— 同 tag 集的三条必须合成一簇", len(cands), stats) + } + cand := cands[0] + if cand.Action != TidyDedupeGroup { + t.Fatalf("动作 = %q, want %q", cand.Action, TidyDedupeGroup) + } + if cand.TargetID != items[2].ID { + t.Fatalf("存活行 = %s, want 最长的那条 %s", cand.TargetID, items[2].ID) + } + if len(cand.SourceIDs) != 2 || stats.Rows != 2 { + t.Fatalf("被吸收行 = %v (rows=%d), want 2 条", cand.SourceIDs, stats.Rows) + } + if cand.Evidence.DedupeRule != tidyDedupeRuleTagSet { + t.Fatalf("簇判据 = %q, want %q", cand.Evidence.DedupeRule, tidyDedupeRuleTagSet) + } + if cand.Evidence.ClusterSize != 3 || cand.Evidence.KeptReason != tidyDedupeKeepLongest { + t.Fatalf("证据 = %+v, want cluster_size=3 kept_reason=%q", cand.Evidence, tidyDedupeKeepLongest) + } + if len(cand.Evidence.DedupeMembers) != 2 { + t.Fatalf("证据里的被吸收正文 = %d 条, want 2(面板要能一次看清吞了哪几条)", + len(cand.Evidence.DedupeMembers)) + } +} + +// TestDedupeGroupClustersByPrefix12 钉住判据二:**前 12 个字相同**(换个说法的同一句) +// 也会成簇 —— 这正是「整理层的包含判定覆盖不了改写」的那个缺口。 +func TestDedupeGroupClustersByPrefix12(t *testing.T) { + t.Parallel() + store := newTestStore(t) + base := tidyTestTime(t) + // 两条 tag 集**不同**(否则会先被 tag 集判据吃掉),但前 12 字逐字相同。 + items := []MemoryItem{ + seedDedupeItem(t, store, dedupeTestItem("用户与助手约定每天晚饭后一起散步", []string{"约定", "散步"}, base)), + seedDedupeItem(t, store, dedupeTestItem("用户与助手约定每天晚饭后出门散步十分钟", []string{"约定", "运动"}, base.Add(time.Minute))), + } + cands, _ := dedupeCandsOf(t, store, items) + if len(cands) != 1 { + t.Fatalf("簇数 = %d, want 1(前 12 字相同 ⇒ 同一簇)", len(cands)) + } + if cands[0].Evidence.DedupeRule != tidyDedupeRulePrefix { + t.Fatalf("簇判据 = %q, want %q", cands[0].Evidence.DedupeRule, tidyDedupeRulePrefix) + } +} + +// TestDedupeGroupPrefixRequiresSimilarityFloor 钉住前缀簇的**收窄条件**:同开头 +// 但后半句讲了别的事(相似度低于下限)⇒ 不成簇。它是「同构不同事实」的护栏。 +func TestDedupeGroupPrefixRequiresSimilarityFloor(t *testing.T) { + t.Parallel() + a := "助手(persona)的说话风格:亲昵、爱撒娇" + b := "助手(persona)的象征花为蓝玫瑰和月桂叶" + if got := lexicalSimilarity(a, b); got >= dedupeGroupPrefixSimilarity { + t.Fatalf("夹具失效:两条相似度 %.3f 高于下限 %.2f,测不出护栏", + got, dedupeGroupPrefixSimilarity) + } + items := []MemoryItem{ + dedupeTestItem(a, []string{"日常", "电影"}, tidyTestTime(t)), + dedupeTestItem(b, []string{"日常", "回家"}, tidyTestTime(t)), + } + cands, _ := dedupeCandsOf(t, newTestStore(t), items) + if len(cands) != 0 { + t.Fatalf("簇数 = %d, want 0(相似度低于 %.2f 的同开头对不许成簇)", + len(cands), dedupeGroupPrefixSimilarity) + } +} + +// TestDedupeGroupNeverCrossesPartition 钉住硬门:跨 scope / 跨 kind 的同一事实 +// **绝不成簇**(design/23 §5.2-7/8、§6;跨 scope 收敛是塔已明确「不做」的那一批)。 +func TestDedupeGroupNeverCrossesPartition(t *testing.T) { + t.Parallel() + base := tidyTestTime(t) + tags := []string{"称呼", "亲密"} + content := "主人喜欢被称呼为「叶酱」" + user := dedupeTestItem(content, tags, base) + user.Scope = ScopeUser + persona := dedupeTestItem(content, tags, base.Add(time.Minute)) + worldEvent := dedupeTestItem(content, tags, base.Add(2*time.Minute)) + worldEvent.Kind = KindWorldEvent + + cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{user, persona, worldEvent}) + if len(cands) != 0 { + t.Fatalf("簇数 = %d, want 0(跨 scope / 跨 kind 是硬门,一条都不许成簇)", len(cands)) + } +} + +// TestDedupeGroupSkipsUnsafeMembers 钉住安全门:时效 / 低置信 / 受限来源的条目 +// 连簇都不进;数值或否定与存活行不一致的成员被逐条剔出(留待人工)。 +func TestDedupeGroupSkipsUnsafeMembers(t *testing.T) { + t.Parallel() + base := tidyTestTime(t) + tags := []string{"作息", "睡眠"} + // 一簇 4 条:一条正常、一条数值不同、一条否定不同、一条时效污染。 + // 数值用**ASCII 数字**:既有 sameNumbers 只认 ASCII 数字 token(中文数字 + // 「十点 vs 十一点」不在它的判据里 —— 那是既有口径的已知限制,本切片不改)。 + normal := dedupeTestItem("主人每晚 22 点后才有空聊天", tags, base) + numeric := dedupeTestItem("主人每晚 23 点后才有空聊天", tags, base.Add(time.Minute)) + negated := dedupeTestItem("主人不再每晚 22 点后聊天", tags, base.Add(2*time.Minute)) + temporal := dedupeTestItem("主人每晚 22 点后才有空聊天,假期期间更晚", tags, base.Add(3*time.Minute)) + cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{normal, numeric, negated, temporal}) + if len(cands) != 0 { + t.Fatalf("簇数 = %d, want 0:数值/否定不一致与时效污染的成员不许与存活行同簇(剩一条不成簇)", + len(cands)) + } + + // 低置信:两条**都**低置信的孪生也不许成簇(对照组用同样的内容 + 0.9 置信)。 + twin := "主人每晚 22 点后才有空聊天" + lowA := dedupeTestItem(twin, tags, base) + lowA.Confidence = 0.6 + lowB := dedupeTestItem(twin, tags, base.Add(time.Minute)) + lowB.Confidence = 0.6 + if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{lowA, lowB}); len(cands) != 0 { + t.Fatalf("簇数 = %d, want 0(低置信是硬否决门 §5.2-6)", len(cands)) + } + okA := dedupeTestItem(twin, tags, base) + okB := dedupeTestItem(twin, tags, base.Add(time.Minute)) + if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{okA, okB}); len(cands) != 1 { + t.Fatalf("对照组簇数 = %d, want 1(0.9 置信的两条必须成簇,否则测不出上面的否决)", len(cands)) + } + + // 受限来源(群聊):连簇都不进 —— 与 owner 来源的孪生也不许成簇。 + owner := dedupeTestItem(twin, tags, base) + restricted := dedupeTestItem(twin, tags, base.Add(time.Minute)) + restricted.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1") + if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{owner, restricted}); len(cands) != 0 { + t.Fatalf("簇数 = %d, want 0(群聊来源只读不写 §5.2-9)", len(cands)) + } +} + +// TestDedupeGroupKeepsSupersetByLengthThenMergeCountThenAge 钉住保留规则的三级优先序。 +func TestDedupeGroupKeepsSupersetByLengthThenMergeCountThenAge(t *testing.T) { + t.Parallel() + ctx := context.Background() + tags := []string{"宠物", "猫"} + + t.Run("等长时取 merge_count 大的", func(t *testing.T) { + t.Parallel() + store := newTestStore(t) + base := tidyTestTime(t) + old := seedDedupeItem(t, store, dedupeTestItem("小茂密是白毛蝴蝶结的小猫", tags, base)) + hot := seedDedupeItem(t, store, dedupeTestItem("小茂密是白毛蝴蝶结的小猫", tags, base.Add(time.Minute))) + if _, err := store.db.ExecContext(ctx, + `UPDATE memory_items SET merge_count = 4 WHERE id = ?`, hot.ID); err != nil { + t.Fatalf("bump merge_count: %v", err) + } + cands, _ := dedupeCandsOf(t, store, []MemoryItem{old, hot}) + if len(cands) != 1 || cands[0].TargetID != hot.ID { + t.Fatalf("存活行 = %v, want merge_count 大的 %s", cands, hot.ID) + } + if cands[0].Evidence.KeptReason != tidyDedupeKeepMergeCount { + t.Fatalf("kept_reason = %q, want %q", cands[0].Evidence.KeptReason, tidyDedupeKeepMergeCount) + } + }) + + t.Run("等长同等时取更早创建的", func(t *testing.T) { + t.Parallel() + store := newTestStore(t) + base := tidyTestTime(t) + early := seedDedupeItem(t, store, dedupeTestItem("小茂密是深色蝴蝶结的小猫", tags, base)) + late := seedDedupeItem(t, store, dedupeTestItem("小茂密是深色蝴蝶结的小猫", tags, base.Add(time.Minute))) + cands, _ := dedupeCandsOf(t, store, []MemoryItem{late, early}) + if len(cands) != 1 || cands[0].TargetID != early.ID { + t.Fatalf("存活行 = %v, want 更早创建的 %s", cands, early.ID) + } + if cands[0].Evidence.KeptReason != tidyDedupeKeepOldest { + t.Fatalf("kept_reason = %q, want %q", cands[0].Evidence.KeptReason, tidyDedupeKeepOldest) + } + }) +} + +// TestDedupeGroupIsIdempotentAndDeterministic 钉住幂等:同一批数据两次分簇,候选 +// 的幂等键、存活行、被吸收行集合**逐项相同**(重复跑不重复产候选)。 +func TestDedupeGroupIsIdempotentAndDeterministic(t *testing.T) { + t.Parallel() + store := newTestStore(t) + base := tidyTestTime(t) + tags := []string{"约定", "散步"} + items := []MemoryItem{ + seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分钟", tags, base)), + seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分钟晒太阳", tags, base.Add(time.Minute))), + seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分", tags, base.Add(2*time.Minute))), + } + first, _ := dedupeCandsOf(t, store, items) + second, _ := dedupeCandsOf(t, store, items) + if len(first) != 1 || len(second) != 1 { + t.Fatalf("两次分簇 = %d / %d, want 1 / 1", len(first), len(second)) + } + if tidyIdempotencyKey(first[0]) != tidyIdempotencyKey(second[0]) { + t.Fatalf("幂等键不同:%q vs %q", tidyIdempotencyKey(first[0]), tidyIdempotencyKey(second[0])) + } + if first[0].TargetID != second[0].TargetID || + len(first[0].SourceIDs) != len(second[0].SourceIDs) { + t.Fatalf("两次分簇结果不一致:%+v vs %+v", first[0], second[0]) + } +} + +// TestDedupeGroupSuppressesPairsWithExistingCandidate 钉住「不制造第二条待办」: +// 已被 archive/merge 候选覆盖的条目不再进簇。 +func TestDedupeGroupSuppressesPairsWithExistingCandidate(t *testing.T) { + t.Parallel() + store := newTestStore(t) + base := tidyTestTime(t) + tags := []string{"身份", "名字"} + items := []MemoryItem{ + seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)), + seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))), + } + tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store}) + if err != nil { + t.Fatalf("NewTidier: %v", err) + } + covered := map[string]struct{}{items[0].ID: {}, items[1].ID: {}} + if cands, _ := tidier.dedupeCandidates(items, nil, covered, tidyTestTime(t)); len(cands) != 0 { + t.Fatalf("簇数 = %d, want 0(两条都已被既有候选覆盖)", len(cands)) + } + // 只覆盖一条:另一条单独一条也不成簇(簇至少两条)。 + if cands, _ := tidier.dedupeCandidates(items, nil, map[string]struct{}{items[0].ID: {}}, tidyTestTime(t)); len(cands) != 0 { + t.Fatalf("簇数 = %d, want 0(剩一条不成簇)", len(cands)) + } +} + +// TestDedupeGroupIsNeverAutoApplied 钉住红线:dedupe_group **不进自动档**。 +// 一次点击改多行记忆,必须在环核对(体检报告 §7.2);变异(把它的 case 加进 +// tidyAutoApplyEligible)会让这条用例立刻红。 +func TestDedupeGroupIsNeverAutoApplied(t *testing.T) { + t.Parallel() + cand := TidyCandidate{ + Action: TidyDedupeGroup, + SourceIDs: []string{"a", "b"}, + TargetID: "c", + Reason: tidyReasonDedupeGroup, + } + if ok, reason := tidyAutoApplyEligible(cand); ok { + t.Fatalf("dedupe_group 被判成可自动落地(reason=%q)—— 它必须在环核对", reason) + } +} diff --git a/internal/runtime/memory/tidy_types.go b/internal/runtime/memory/tidy_types.go index 742793c..9324353 100644 --- a/internal/runtime/memory/tidy_types.go +++ b/internal/runtime/memory/tidy_types.go @@ -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 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。 diff --git a/internal/services/gateway/admin_memory_tidy.go b/internal/services/gateway/admin_memory_tidy.go index f504cba..f73b36b 100644 --- a/internal/services/gateway/admin_memory_tidy.go +++ b/internal/services/gateway/admin_memory_tidy.go @@ -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)) diff --git a/internal/services/gateway/admin_memory_tidy_apply.go b/internal/services/gateway/admin_memory_tidy_apply.go index 054dd76..6a1757d 100644 --- a/internal/services/gateway/admin_memory_tidy_apply.go +++ b/internal/services/gateway/admin_memory_tidy_apply.go @@ -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 { diff --git a/internal/services/gateway/admin_memory_tidy_apply_dto.go b/internal/services/gateway/admin_memory_tidy_apply_dto.go index a5cd8e3..46e1676 100644 --- a/internal/services/gateway/admin_memory_tidy_apply_dto.go +++ b/internal/services/gateway/admin_memory_tidy_apply_dto.go @@ -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)。 diff --git a/internal/services/gateway/admin_memory_tidy_run.go b/internal/services/gateway/admin_memory_tidy_run.go index eb3b6f9..87c3aa4 100644 --- a/internal/services/gateway/admin_memory_tidy_run.go +++ b/internal/services/gateway/admin_memory_tidy_run.go @@ -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, } } From 54e32304f268c540d6814c761bf998710c7709ea Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 25 Oct 2026 00:29:02 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(memory):=20dedupe=5Fgroup=20=E8=A1=A5?= =?UTF-8?q?=E5=8F=96=E5=80=BC=E9=97=A8=20+=20=E4=B8=B4=E6=97=B6=E5=B1=82?= =?UTF-8?q?=E5=8F=8C=E5=87=BB=E5=A4=8D=E6=A0=B8=EF=BC=9B=E9=97=B8=E8=A1=A5?= =?UTF-8?q?=203=20=E7=B1=BB=E9=95=BF=E6=9C=9F=E4=BA=8B=E5=AE=9E=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=98=80=EF=BC=88M34=20r1=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**, 生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行, 而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。 修法(结构性,不是补丁): - 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与 **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少; 方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。 补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。 - tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高: 语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109) 的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。 实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。 - 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、 TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、 TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度 归零 ⇒ 第四条红。 r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map, `temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核 (候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例: TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。 r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、 「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内; 「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。 实测(生产库只读 mode=ro,active 3667): - dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15; 取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2 (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。 - 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条 逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。 - 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74% 且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」 10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。 --- internal/runtime/memory/extract_retention.go | 59 +++++-- .../extract_retention_transient_test.go | 10 +- .../runtime/memory/extract_retention_words.go | 32 +++- internal/runtime/memory/tidy_apply.go | 4 + internal/runtime/memory/tidy_dedupe.go | 113 ++++-------- internal/runtime/memory/tidy_dedupe_apply.go | 124 +++++++++---- .../runtime/memory/tidy_dedupe_apply_test.go | 74 +++++++- .../runtime/memory/tidy_dedupe_candidate.go | 35 +++- internal/runtime/memory/tidy_dedupe_keys.go | 88 +++++++++ internal/runtime/memory/tidy_dedupe_meta.go | 10 +- internal/runtime/memory/tidy_dedupe_test.go | 135 ++++++++++++++ internal/runtime/memory/tidy_dedupe_values.go | 167 ++++++++++++++++++ 12 files changed, 704 insertions(+), 147 deletions(-) create mode 100644 internal/runtime/memory/tidy_dedupe_keys.go create mode 100644 internal/runtime/memory/tidy_dedupe_values.go diff --git a/internal/runtime/memory/extract_retention.go b/internal/runtime/memory/extract_retention.go index ec00ade..af506f4 100644 --- a/internal/runtime/memory/extract_retention.go +++ b/internal/runtime/memory/extract_retention.go @@ -258,7 +258,9 @@ func judgeRetentionOneOff(text string) retentionRule { // 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。 // // **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终 -// (正在测通道、暂时搁置、正在慢慢想),安全阀 1~4 与 N6 完全一致。 +// (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致** +// (r1 复审 P2-2:`用户会主动维护…并告诉助手她正在维护…` 里的「正在」只出现在从句, +// 是「会…」的习惯陈述,不许开火)。 // // 🔴 **刻意不做的事**(M27 §4 的实测反例,别再重新发明): // - 不做**长度阈值**筛(R3:≤15 字的条目抽 8 条 8/8 是可复用事实); @@ -278,7 +280,18 @@ func judgeRetentionTransient(text string) retentionRule { if retentionHasConcreteConstraint(text) { return retentionNone } - // 安全阀 5:N6 **不接管 N1/N2 的语料**。含寒暄/致谢词或情绪词的句子归那两条 + // 安全阀 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 不越过它们改判: // - 「谢谢你今天特地提醒我这件事,真是帮了大忙了」是社交致谢,不是事件记录; // - 「主人最近很容易焦虑」是**情绪倾向**(长期事实),不是「最近发生了一件事」。 @@ -289,7 +302,7 @@ func judgeRetentionTransient(text string) retentionRule { if judgeRetentionTimepoint(text) != retentionNone { return retentionTimepoint } - if containsAny(text, retentionInProgressMarkers()) { + if judgeRetentionInProgress(text) != retentionNone { return retentionInProgress } return retentionNone @@ -297,22 +310,32 @@ func judgeRetentionTransient(text string) retentionRule { // 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 { + if idx := retentionFrontedTimepoint(text); idx >= 0 && idx < retentionTimepointLeadRunes { return retentionTimepoint } return retentionNone } -// retentionFrontedTimepoint 返回第一个指示性时点词在 text 里的**rune 下标**; -// 没有时点词时返回 -1。调用方再用 retentionTimepointLeadRunes 判它是否「句首」。 -// -// 用 rune 下标而不是字节下标是必须的:中文一个字 3 字节,按字节算会把「前 12 字」 -// 变成「前 4 字」,位置判据当场失效。 -func retentionFrontedTimepoint(text string) int { - runes := []rune(text) +// 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 retentionTimepointMarkers() { + for _, marker := range markers { idx := runeIndex(text, marker) if idx < 0 { continue @@ -321,7 +344,17 @@ func retentionFrontedTimepoint(text string) int { best = idx } } - if best < 0 || best >= len(runes) { + 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 diff --git a/internal/runtime/memory/extract_retention_transient_test.go b/internal/runtime/memory/extract_retention_transient_test.go index 4e38ca8..bbc3930 100644 --- a/internal/runtime/memory/extract_retention_transient_test.go +++ b/internal/runtime/memory/extract_retention_transient_test.go @@ -26,7 +26,7 @@ func retentionTimepointCases() []string { "用户近期在测试一条通道,今天来回测了三轮", "用户最近在看一部年代/审讯题材的影视作品,台词「我肯定能拿下口供」", "计划下周为主人留一个安静的午后", - "用户今晚睡宿舍,不在家", + "用户今晚在公司加班到很晚", // 约定类:锚定到某个时刻的安排(约定本身不是长期事实,配上时点才是临时记忆)。 "用户与助理约定明天中午见", } @@ -57,6 +57,14 @@ func retentionLongTermCases() []struct { {"海报即本人", "主人曾发来一张海报,画面是粉发紫眼、配蓝玫瑰与月桂叶,并明确说「那张海报上就是你哦」"}, // 情绪倾向 + 寒暄:N6 不接管 N1/N2 的语料(安全阀 5)。 {"情绪倾向", "主人最近很容易焦虑"}, + // 2026-10-25 r1 复审 P2-2 的实测误杀(同一条语料、同一套代码被漏报过的那些)。 + {"宠物稳定外观(换成)", "茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了一个小铃铛"}, + {"居住事实(宿舍过夜)", "主人近期在宿舍过夜,需要提醒她宿舍灯早点关、洗漱水别太凉"}, + {"习惯陈述里的从句「正在」", "用户会主动维护助手与自己之间的连接通道,并告诉助手她正在维护,表明她重视保持与助手的稳定联系"}, + {"习惯陈述里的从句「正在」(二)", "用户会维护自己与助手之间的连接通道,并曾表示正在为此进行维护"}, + // 「以后/从此/往后」= 自此刻起永久成立(复审构造即可复现)。 + {"以后都叫我小叶", "昨天主人说以后都叫我小叶"}, + {"以后周末不加班", "上周主人说以后周末不加班了"}, {"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"}, } } diff --git a/internal/runtime/memory/extract_retention_words.go b/internal/runtime/memory/extract_retention_words.go index d1aca67..3252360 100644 --- a/internal/runtime/memory/extract_retention_words.go +++ b/internal/runtime/memory/extract_retention_words.go @@ -21,7 +21,11 @@ func retentionRememberDirectives() []string { func retentionDurableMarkers() []string { return []string{ // 长期事实 - "生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母", + // + // 「宿舍」(2026-10-25 r1 复审 P2-2 补):语料里「近期在宿舍过夜 / 睡在宿舍」 + // 是**居住事实 + 稳定照料约定**,不含「住」字却被 N6 判成临时;收它之后 + // 这类句子与「住在宿舍」同档(都要过护栏②才放行)。 + "生日", "纪念日", "住", "宿舍", "职业", "工作", "家庭", "家人", "孩子", "父母", "过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄", // 关系里程碑 / 约定 "约定", "承诺", "答应", "说好", "暗号", "口令", "称呼", @@ -210,3 +214,29 @@ func retentionHardDurableMarkers() []string { } 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{"换成", "变成", "改成", "改为", "换装", "搬进", "搬到"} +} diff --git a/internal/runtime/memory/tidy_apply.go b/internal/runtime/memory/tidy_apply.go index cb4f446..98a2d6b 100644 --- a/internal/runtime/memory/tidy_apply.go +++ b/internal/runtime/memory/tidy_apply.go @@ -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 包裹, diff --git a/internal/runtime/memory/tidy_dedupe.go b/internal/runtime/memory/tidy_dedupe.go index cf05ba3..138d7bc 100644 --- a/internal/runtime/memory/tidy_dedupe.go +++ b/internal/runtime/memory/tidy_dedupe.go @@ -84,6 +84,27 @@ const ( // 「要不要自动判同一簇」,两者风险不同 —— 但**两者都不是判定依据**(簇的同一性 // 由「前 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 预览与运维判读)。 @@ -139,13 +160,19 @@ func (t *Tidier) dedupeInPartition(ctx context.Context, p tidyPartition, items [ covered[c.TargetID] = struct{}{} } } - // merge_count 是存活行的**次要**判据(「内容最长」并列时才用它)。缺它时降级到 - // created_at 仍能给出确定性的存活行,因此这里降级继续、但如实记 WARN。 + // 元信息(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 item meta unavailable, clustering without merge_count/temporary flags", + t.log.Warn("memory tidy dedupe skipped: item meta unavailable", "partition", p.label(), "error", err) - meta = nil + return nil, tidyDedupeStats{} } return t.dedupeCandidates(items, meta, covered, t.now().UTC()) } @@ -307,84 +334,6 @@ func dedupeGroupEligible(it MemoryItem) bool { return true } -// dedupeClusterKeys 返回一条记忆的簇键(tag 集 / 前 12 字)。两条判据都可能不成立 -// (tag 集不合格 / 内容短于 12 字),那时只返回另一条。 -func dedupeClusterKeys(it MemoryItem) []tidyDedupeKey { - out := make([]tidyDedupeKey, 0, 2) - if key := dedupeTagKey(it); key != "" { - out = append(out, tidyDedupeKey{rule: tidyDedupeRuleTagSet, key: key}) - } - if key := dedupePrefixKey(it); key != "" { - out = append(out, tidyDedupeKey{rule: tidyDedupeRulePrefix, key: key}) - } - return out -} - -// dedupeTagKey 是「同分区 + 同 kind + 完全相同 tag 集」的键。tag 集为空/太通用时返回空串。 -// -// 键里带**分区**(persona/scope/project)与 kind,两个都是**纵深防御**: -// - 调用方本来就按分区逐块调用(design/23 §6「分区之间永不比较」),但候选键 -// 自带分区后,任何跨分区的输入(手工调用、将来别的入口)也**无法成簇** —— -// 跨 scope 收敛是塔已明确不做的那一批,它不许只靠调用纪律保证; -// - kind 是**内容类别**(relationship / world_event):跨 kind 的簇合并会顺手改掉 -// 被吸收行的分类语义,而分类改动只该走 reclassify(§5.1 T0-6)。 -func dedupeTagKey(it MemoryItem) string { - tags := normalizedTidyTags(it.Tags) - if len(tags) < dedupeGroupMinTagCount { - return "" - } - for _, tag := range tags { - if len([]rune(tag)) < dedupeGroupMinTagRunes { - return "" - } - } - return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + strings.Join(tags, "\x1e") -} - -// dedupePartitionPrefix 是簇键里的分区段(persona + scope + project)。 -func dedupePartitionPrefix(it MemoryItem) string { - return it.PersonaID + "\x1e" + string(it.Scope) + "\x1e" + it.ProjectID + "\x1e" -} - -// normalizedTidyTags 归一化 tag 集:去空白、去空项、去重、排序。排序是幂等键稳定 -// 的前提(同一 tag 集的不同书写顺序必须落到同一个键)。 -func normalizedTidyTags(tags []string) []string { - if len(tags) == 0 { - return nil - } - seen := make(map[string]struct{}, len(tags)) - out := make([]string, 0, len(tags)) - for _, tag := range tags { - t := strings.TrimSpace(tag) - if t == "" { - continue - } - if _, dup := seen[t]; dup { - continue - } - seen[t] = struct{}{} - out = append(out, t) - } - sort.Strings(out) - return out -} - -// dedupePrefixKey 是「**同分区同 kind** + 前 12 个字相同」的键(按**归一化正文**取前 12 个 rune)。 -// -// 归一化复用 dedupeKey(小写 + 空白折叠):`助手名为 Cyrene。` 与 `助手名为 Cyrene` -// 不该因为多一个空格就落到两个簇。 -// -// 短于 12 字的内容返回空串**而不受理**:那时「前 12 字」等于全文,簇会退化成 -// 「逐字重复」——那是 §15 D3-2 T0-1 的 `skip`(入库闸)与 `merge`(字面重复自动档) -// 已经在管的事,不需要第二个动作重复它。 -func dedupePrefixKey(it MemoryItem) string { - runes := []rune(dedupeKey(it.Content)) - if len(runes) < dedupeGroupPrefixRunes { - return "" - } - return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + string(runes[:dedupeGroupPrefixRunes]) -} - // tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。 // // 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store.go 的 13 列)**不含** diff --git a/internal/runtime/memory/tidy_dedupe_apply.go b/internal/runtime/memory/tidy_dedupe_apply.go index bd7eb0c..8d6bd68 100644 --- a/internal/runtime/memory/tidy_dedupe_apply.go +++ b/internal/runtime/memory/tidy_dedupe_apply.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "errors" + "fmt" + "strings" "time" ) @@ -45,8 +47,9 @@ func tidyApplyRowCost(c TidyCandidate) int { // applyTidyDedupeGroup 把一簇被吸收行软合并进存活行。 // -// 前置校验与 merge 逐条同源:分区硬门、只读来源硬门、目标行必须存在且与候选同分区。 -// 任何一条不通过都如实 skip(不假装做过),且**一行都不写**。 +// 前置校验与 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{}, @@ -54,41 +57,13 @@ func (s *Store) applyTidyDedupeGroup(ctx context.Context, cand TidyCandidate, op if len(cand.SourceIDs) == 0 || cand.TargetID == "" { return skipTidyApply(base, tidySkipInvalidCandidate), nil } - survivor, err := s.Get(ctx, cand.TargetID) - if errors.Is(err, ErrNotFound) { - return skipTidyApply(base, tidySkipSourceMissing), nil - } + survivor, absorbed, skip, err := s.loadDedupeGroupRows(ctx, cand) if err != nil { return base, err } - if skip := tidyPartitionMismatch(cand, survivor); skip != "" { + if skip != "" { return skipTidyApply(base, skip), nil } - if nonOwnerSource(survivor.SourceConversation) { - // 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御, - // 规则层本就把这类条目排除在分簇之外。 - return skipTidyApply(base, tidySkipSourceRestricted), nil - } - absorbed := make([]MemoryItem, 0, len(cand.SourceIDs)) - for _, id := range cand.SourceIDs { - if id == cand.TargetID { - return skipTidyApply(base, tidySkipInvalidCandidate), nil - } - it, gerr := s.Get(ctx, id) - if errors.Is(gerr, ErrNotFound) { - return skipTidyApply(base, tidySkipSourceMissing), nil - } - if gerr != nil { - return base, gerr - } - if skip := tidyPartitionMismatch(cand, it); skip != "" { - return skipTidyApply(base, skip), nil - } - if nonOwnerSource(it.SourceConversation) { - return skipTidyApply(base, tidySkipSourceRestricted), nil - } - absorbed = append(absorbed, it) - } err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error { for _, it := range absorbed { if err := markTidyAbsorbed(ctx, tx, it.ID, survivor.ID, now); err != nil { @@ -108,3 +83,88 @@ func (s *Store) applyTidyDedupeGroup(ctx context.Context, cand TidyCandidate, op } 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 +} diff --git a/internal/runtime/memory/tidy_dedupe_apply_test.go b/internal/runtime/memory/tidy_dedupe_apply_test.go index 110008a..22238e6 100644 --- a/internal/runtime/memory/tidy_dedupe_apply_test.go +++ b/internal/runtime/memory/tidy_dedupe_apply_test.go @@ -181,8 +181,8 @@ func TestTidyDryRunReportsDedupeGroupsWithoutWriting(t *testing.T) { tags := []string{"宠物", "小茂密"} for i, content := range []string{ "小茂密是白毛的小猫,喜欢撒娇", - "茂密的蝴蝶结最近换成了深色", - "茂密爱坐在窗边看外面的风景", + "白毛小茂密爱撒娇,是只小猫", + "爱撒娇的小猫名叫小茂密,通体白毛", } { seedDedupeItem(t, store, dedupeTestItem(content, tags, base.Add(time.Duration(i)*time.Minute))) } @@ -263,9 +263,9 @@ func TestTidyRunReportsDedupeTruncation(t *testing.T) { tags []string }{ {"小茂密是白毛的小猫,喜欢撒娇", []string{"宠物", "小茂密"}}, - {"茂密的蝴蝶结最近换成了深色", []string{"宠物", "小茂密"}}, - {"主人对花溪的住处很满意", []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))) @@ -287,3 +287,67 @@ func TestTidyRunReportsDedupeTruncation(t *testing.T) { 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) + } + }) + } +} diff --git a/internal/runtime/memory/tidy_dedupe_candidate.go b/internal/runtime/memory/tidy_dedupe_candidate.go index 7c31881..d380eef 100644 --- a/internal/runtime/memory/tidy_dedupe_candidate.go +++ b/internal/runtime/memory/tidy_dedupe_candidate.go @@ -12,12 +12,19 @@ import ( // dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。 // -// 数值与否定是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」, -// 而同一性由存活行代表;任何一条与存活行数值/否定不一致的成员,说明它讲的是 -// **另一个事实**(§5.2-2/§5.2-3),因此它**不进这一簇**(留待人工),而不是让整簇作废。 +// 三道门都是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」, +// 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**, +// 因此它**不进这一簇**(留待人工),而不是让整簇作废: // -// 前缀簇还要过相似度下限(见 dedupeGroupPrefixSimilarity);tag 集簇不需要 —— -// 完全相同的 tag 集本身就是强证据,而前缀簇的「同开头」弱得多。 +// 1. **数值**(§5.2-2,sameNumbers):数值不同即不同事实; +// 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 { @@ -30,8 +37,10 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu if !sameNegations(it.item.Content, survivor.item.Content) { continue } - if rule == tidyDedupeRulePrefix && - lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupPrefixSimilarity { + 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) @@ -42,6 +51,18 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu return out } +// dedupeGroupMinSimilarity 返回一种簇判据的相似度下限(词项余弦)。 +// +// 两档刻意不同:「完全相同 tag 集」是结构化证据(同主题的强信号),容忍改写得远一点; +// 「前 12 字相同」只是「开头一样」,必须要求正文整体也相近,否则同开头不同事实 +// (`助手(persona)的说话风格…` vs `助手(persona)的象征花…`)会被合掉。 +func dedupeGroupMinSimilarity(rule string) float64 { + if rule == tidyDedupeRulePrefix { + return dedupeGroupPrefixSimilarity + } + return dedupeGroupTagSetSimilarity +} + // dedupeGroupCandidate 把一个簇转成候选。 // // 落库字段与 merge 候选同构,因此既有面板/应用/回滚三条路都不用改形状: diff --git a/internal/runtime/memory/tidy_dedupe_keys.go b/internal/runtime/memory/tidy_dedupe_keys.go new file mode 100644 index 0000000..0be2b92 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_keys.go @@ -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]) +} diff --git a/internal/runtime/memory/tidy_dedupe_meta.go b/internal/runtime/memory/tidy_dedupe_meta.go index ee99c11..ca59220 100644 --- a/internal/runtime/memory/tidy_dedupe_meta.go +++ b/internal/runtime/memory/tidy_dedupe_meta.go @@ -33,12 +33,10 @@ type tidyDedupeMeta struct { // dedupeItemMeta 读出一个分区的 id → 元信息映射。 // -// 失败方向上它是**可降级**的(调用方在失败时用空映射继续分簇):存活行的第一判据 -// 是「内容最长」,merge_count 只在前者并列时决定胜负。但**降级会让「临时行」这个 -// 判断失效** —— 因此调用方在降级时必须记 WARN,并且降级路径下 `temporary` 恒为 -// false 意味着「可能把临时行当长期行」;这是**宁可少做也不误删**的反面, -// 所以这里把降级方向定成「继续分簇 + WARN」,而不是「整个分区不分簇」: -// 判错的方向是「多留一条冗余」(可 revert),不是「丢一条记忆」。 +// 🔴 **失败方向是「这个分区这轮不分簇」**(不是「降级继续」):`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)) diff --git a/internal/runtime/memory/tidy_dedupe_test.go b/internal/runtime/memory/tidy_dedupe_test.go index 7ebdb39..07986d8 100644 --- a/internal/runtime/memory/tidy_dedupe_test.go +++ b/internal/runtime/memory/tidy_dedupe_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "fmt" + "strings" "testing" "time" ) @@ -308,3 +309,137 @@ func TestDedupeGroupIsNeverAutoApplied(t *testing.T) { t.Fatalf("dedupe_group 被判成可自动落地(reason=%q)—— 它必须在环核对", reason) } } + +// 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) + } + } +} + +// 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)) + } +} diff --git a/internal/runtime/memory/tidy_dedupe_values.go b/internal/runtime/memory/tidy_dedupe_values.go new file mode 100644 index 0000000..4f9f380 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_values.go @@ -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 +} From 7700a7eda0310b6f5ea468280514f2e86767cc6d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 25 Oct 2026 00:41:16 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(memory):=20dedupe=5Fgroup=20=E8=A1=A5?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=95=B0=E5=AD=97=E9=97=A8=EF=BC=88=E9=87=8F?= =?UTF-8?q?=E7=BA=B2/=E5=8D=95=E4=BD=8D=E5=BD=92=E4=B8=80=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=20r2=20=E5=A4=8D=E5=AE=A1=20p2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 复审 p2:`sameNumbers` 只抽 ASCII 数字 token,而本语料的数字主要写成中文 (一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒「午睡一小时 vs 十五分钟」 「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**被当成同一事实合并。 修法(新文件 internal/runtime/memory/tidy_dedupe_numbers.go): - 抽出「**中文数字 + 单位**」对(一~九/两/半/十/百/千/万,含十五、二十四、一万两千、 半个小时),按**量纲归一**后比较:时间=秒(分钟 60 / 小时 3600 / 天 86400 / 周 604800 / 个月 2592000 / 年 31536000)、长度=米、文字量=个、计数=个; - 只认**后面紧跟已知单位**的数字:`一起 / 一直 / 一些 / 一样 / 第二天(序数位置)` 这类含数字的习语一律不产生数量 —— 不做成「有中文数字就不合并」; - 判据是**冲突**而非差异:存活行在同量纲里没有对应数量 ⇒ 不算冲突(被吸收行多出 的细节);等值(含单位换算后等值,如「半小时」=1800 秒)⇒ 照常合并; 值不等且单位换算不到一起 ⇒ 按冲突处理(复审口径:宁可不合并); - 只**增加否决**,不放宽任何既有门;ASCII 路径仍由 sameNumbers 先判,一字未动; `sameNumbers` 本身也未改(judgePair/judgeIngest 共用它,属于另一处 blast radius, 已在回报里登记为后续建议)。 语料实测(生产库只读 mode=ro,active 3667):999 对里中文数字门挡掉 **17 对**, 其中真冲突:午睡十五分钟 vs 一小时 ×3、咖啡一杯 vs 第二杯 ×4、 「三千次轮回」vs「三千万次轮回」×1(ASCII 路径完全看不见这条); 其余 9 对是「单位不可换算」的保守拒绝(半碗 vs 一个包/一杯、一次 vs 两圈)。 dedupe 产出 255 簇/505 行 → **250 簇/495 行(13.5%)**。 回归用例(去掉这道门即红,已实测): - TestDedupeGroupRejectsConflictingChineseNumbers(复审给的两对真实语料,脱敏保留原意) - TestDedupeGroupRejectsNonConvertibleUnits(两杯 vs 三瓶 ⇒ 保守不合并) - TestDedupeGroupSameChineseNumberStillMerges(同值/换算后同值 ⇒ 照常合并,门没退化成「有数字就不合」) - TestTidyQuantitiesChineseNumerals(解析口径:一小时=3600s、十五分钟=900s、半小时=1800s、 一万两千行=12000、第二天/一起/一直 不产生数量) 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。 --- .../runtime/memory/tidy_dedupe_candidate.go | 7 + .../runtime/memory/tidy_dedupe_numbers.go | 370 ++++++++++++++++++ .../memory/tidy_dedupe_numbers_test.go | 128 ++++++ internal/runtime/memory/tidy_dedupe_test.go | 86 ---- .../runtime/memory/tidy_dedupe_values_test.go | 96 +++++ 5 files changed, 601 insertions(+), 86 deletions(-) create mode 100644 internal/runtime/memory/tidy_dedupe_numbers.go create mode 100644 internal/runtime/memory/tidy_dedupe_numbers_test.go create mode 100644 internal/runtime/memory/tidy_dedupe_values_test.go diff --git a/internal/runtime/memory/tidy_dedupe_candidate.go b/internal/runtime/memory/tidy_dedupe_candidate.go index d380eef..6bd2b86 100644 --- a/internal/runtime/memory/tidy_dedupe_candidate.go +++ b/internal/runtime/memory/tidy_dedupe_candidate.go @@ -21,6 +21,10 @@ import ( // 3. **取值**(§5.2-4/§5.2-5,dedupeValueGaps):被吸收行的引号取值 / 拉丁 token // 必须在存活行里一个不少 —— 这条是 r1 复审 P1 的落点(`「叶酱」` 不许并进 // `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。 +// 4. **中文数字**(§5.2-2 的补充,dedupeNumberConflicts):`sameNumbers` 只抽 +// ASCII 数字 token,而语料里的数字大多写成中文(一小时 / 十五分钟 / 第二杯)—— +// r2 复审实测约 5 对真实数值冲突因此被合并。这道门按**量纲 + 单位换算**比, +// 单位换算不到一起就按冲突处理(标准是「宁可不合并」)。 // // 相似度下限对**两种簇都生效**(`dedupeGroupMinSimilarity`):tag 集相同只是 // 「同主题」的弱证据(体检报告 §1.3 的「冰美式 vs 红牛」也同 tag 集),内容差得 @@ -40,6 +44,9 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu if len(dedupeValueGaps(it.item.Content, survivor.item.Content)) > 0 { continue } + if len(dedupeNumberConflicts(it.item.Content, survivor.item.Content)) > 0 { + continue + } if lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupMinSimilarity(rule) { continue } diff --git a/internal/runtime/memory/tidy_dedupe_numbers.go b/internal/runtime/memory/tidy_dedupe_numbers.go new file mode 100644 index 0000000..5981f24 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_numbers.go @@ -0,0 +1,370 @@ +package memory + +import "fmt" + +// 本文件是 `dedupe_group` 的**中文数字门**(2026-10-25,r2 复审 P2 的落点): +// 数值冲突不仅要认出 ASCII 数字,还要认出中文数字,并且按**量纲/单位**归一后比较。 +// +// 动因是一次实测:`sameNumbers`(tidy_containment.go)只抽 ASCII 数字 token, +// 而本语料的数字主要写成中文(一小时 / 十五分钟 / 第二杯 / 一辈子)—— +// 于是「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突** +// 被当成同一事实合并掉。复审在 22 个簇里量到约 5 对真实冲突。 +// +// 🔴 **两道自我约束**(复审明确要求): +// 1. **不做成「两边都有中文数字就一律不合并」**:那样等于把规则废掉。判据是 +// 「**同一个量纲**里,值不相等」才算冲突;两边都写「一杯」照样合。 +// 2. **只在数值上收紧,不放宽别的门**:本门只**增加**否决,不改变任何「原本会被 +// 否决的对」(ASCII 路径仍由 `sameNumbers` 先判,一字未动)。 +// +// 形态判据是「**中文数字 + 单位**」,而不是「中文数字」: +// - 只认后面紧跟已知单位(小时/分钟/杯/次/份…)的数字,因此「一起」「一直」 +// 「一些」「一样」「一部分」这类**含数字的常见词**不会被当成数量; +// - 数字后面没有单位时**不产生数量**(哪怕它是真的数量):NL 里的裸中文数字 +// 绝大多数是习语,收它会把精确度换成一堆误否决。 +// +// 单位不可换算时按**保守方向**处理(复审口径「比不了单位就不许合并」):同一个 +// 量纲里值不相等、单位又换算不到一起 ⇒ 一律算冲突。 + +// tidyQuantity 是一处「数量」:归一后的值 + 单位名 + 量纲。 +type tidyQuantity struct { + // value 是按单位换算到**量纲基准单位**后的值(时间=秒、计数=个、长度=米)。 + value float64 + // unit 是单位名(用于把冲突原因写清楚,也是「能不能换算」的判据)。 + unit string + // dim 是量纲(time / count / length / text)。同量纲才可比。 + dim string + // raw 是原始写法(一~数字+单位),只用于日志/测试断言。 + raw string +} + +// dedupeNumberConflicts 返回被吸收行与存活行之间的**数值冲突**(空 = 无冲突)。 +// +// 判据(逐条被吸收行的数量): +// 1. 存活行在**同一量纲**里没有任何数量 ⇒ 不算冲突(被吸收行多出来的细节, +// 合并不改变存活行正文,因此不算「冲突」,与取值门的单向口径一致); +// 2. 存活行在同一量纲里有**等值**的数量 ⇒ 不算冲突(单位可以不同:三份 vs 三张); +// 3. 其余 ⇒ 冲突(值不等;单位换算不到一起时同样算冲突,方向保守)。 +func dedupeNumberConflicts(absorbed, survivor string) []string { + have := tidyQuantities(survivor) + if len(have) == 0 { + return nil + } + gaps := make([]string, 0, 2) + for _, q := range tidyQuantities(absorbed) { + same, comparable, sameDim := false, false, false + for _, h := range have { + if h.dim != q.dim { + continue + } + sameDim = true + if sameQuantity(q, h) { + same = true + break + } + if convertibleUnits(q, h) { + comparable = true + } + } + if same || !sameDim { + // 等值 ⇒ 不是冲突;同量纲里根本没有可比对的数量 ⇒ 也**不是**冲突 + //(被吸收行多出来的细节,合并不改存活行正文,因此不算「冲突」)。 + // 这一条是「不同量纲不算冲突」的落点:`四行歌词` 对 `一段歌词/心事` + // 里 `行`(文字量)与 `段`(计数)根本不是同一回事。 + continue + } + if comparable { + gaps = append(gaps, fmt.Sprintf("%s≠同量纲的另一取值", q.raw)) + continue + } + gaps = append(gaps, fmt.Sprintf("%s 与存活行的同量纲数量不可换算", q.raw)) + } + return gaps +} + +// sameQuantity 报告两处数量是否等值(同量纲已由调用方保证)。 +func sameQuantity(a, b tidyQuantity) bool { + if a.dim != b.dim { + return false + } + if a.value == b.value { + return true + } + // 浮点比较留一点余量:换算会乘除(半小时 = 0.5×3600 秒),不做精确相等。 + diff := a.value - b.value + if diff < 0 { + diff = -diff + } + return diff < 0.5 +} + +// convertibleUnits 报告两处数量的单位能不能换算到一起(同一量纲里,计数类单位 +// 之间**不能**互换:两杯 ≠ 三次)。 +func convertibleUnits(a, b tidyQuantity) bool { + if a.dim != b.dim { + return false + } + if a.dim == tidyDimCount { + // 计数器的量纲相同但单位不可互换(杯/次/份/张…)——除非本来就是同一个单位。 + return a.unit == b.unit + } + return tidyUnitFactor(a.dim, a.unit) > 0 && tidyUnitFactor(b.dim, b.unit) > 0 +} + +// tidyQuantities 抽出一段正文里所有的「中文数字 + 单位」。 +func tidyQuantities(text string) []tidyQuantity { + runes := []rune(dedupeKey(text)) + out := make([]tidyQuantity, 0, 4) + for i := 0; i < len(runes); i++ { + value, next, ok := parseChineseNumberAt(runes, i) + if !ok { + continue + } + unit, dim, factor, unext, uok := tidyUnitAt(runes, next) + if !uok { + // 数字后面没有已知单位:不当作数量(习语占绝对多数)。 + i = next - 1 + continue + } + if tidyOrdinalPosition(runes, i, dim) { + // 「第二天」「第三周」是**序数位置**,不是时长。 + i = unext - 1 + continue + } + raw := string(runes[i:unext]) + out = append(out, tidyQuantity{value: value * factor, unit: unit, dim: dim, raw: raw}) + i = unext - 1 + } + 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() { + if at+len([]rune(u.name)) > len(text) { + continue + } + if string(text[at:at+len([]rune(u.name))]) != u.name { + continue + } + if len([]rune(u.name)) > len([]rune(best)) { + best, bestDim, bestFactor, bestEnd = u.name, u.dim, u.factor, at+len([]rune(u.name)) + } + } + 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}, + // 计数(基准:个;**单位之间不互换**,见 convertibleUnits) + {"次", 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}, + } +} + +// tidyUnitFactor 返回单位在它量纲里的换算系数(0 = 不认识的单位)。 +func tidyUnitFactor(dim, unit string) float64 { + for _, u := range tidyUnitTable() { + if u.dim == dim && u.name == unit { + return u.factor + } + } + return 0 +} + +// 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 + } +} diff --git a/internal/runtime/memory/tidy_dedupe_numbers_test.go b/internal/runtime/memory/tidy_dedupe_numbers_test.go new file mode 100644 index 0000000..3316c06 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_numbers_test.go @@ -0,0 +1,128 @@ +package memory + +import ( + "testing" + "time" +) + +// 本文件是 `dedupe_group` **中文数字门**(r2 复审 P2)的验收用例集: +// 中位数写法下的数值冲突(一小时 vs 十五分钟、第二杯 vs 一杯)、单位不可换算的 +// 保守拒绝、同值照常合并,以及中文数字解析口径本身。 + +// TestDedupeGroupRejectsConflictingChineseNumbers 是 r2 复审 **P2 的回归用例**: +// 语料里的数字大多写成**中文**,而 `sameNumbers` 只抽 ASCII 数字 token ⇒ +// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突** +// 曾被当成同一事实合并掉。 +// +// 旧逻辑(只有 sameNumbers)下这些对会成簇 ⇒ 用例红;补中文数字门后不成簇 ⇒ 绿。 +func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) { + t.Parallel() + base := tidyTestTime(t) + cases := []struct { + name string + survivor string + member string + tagsA []string + tagsB []string + whyItReds string + }{ + { + name: "午睡时长(一小时 vs 十五分钟)", + survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。", + member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。", + tagsA: []string{"作息", "午睡"}, + tagsB: []string{"作息", "午睡"}, + }, + { + name: "咖啡杯数(第二杯 vs 一杯)", + survivor: "用户被约定:一天咖啡不许续第二杯,下午犯困时起身走动、喝温水。", + member: "用户有喝咖啡的习惯,助手提醒她每天不超过一杯(第二杯不许续)。", + tagsA: []string{"习惯", "咖啡"}, + tagsB: []string{"习惯", "咖啡"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + a := dedupeTestItem(tc.survivor, tc.tagsA, base) + b := dedupeTestItem(tc.member, tc.tagsB, base.Add(time.Minute)) + // 先钉住「这不是因为别的门挡住的」:数值门确实报出冲突。 + if gaps := dedupeNumberConflicts(b.Content, a.Content); len(gaps) == 0 { + t.Fatalf("中文数字门没报出冲突:%q vs %q", b.Content, a.Content) + } + if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 { + t.Fatalf("簇数 = %d, want 0(数值冲突=不同事实,§5.2-2)", len(cands)) + } + }) + } +} + +// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」 +// (r2 复审的明确口径):两杯 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 gaps := dedupeNumberConflicts(b.Content, a.Content); len(gaps) == 0 { + t.Fatalf("单位不可换算没有报冲突:%q vs %q", b.Content, a.Content) + } + 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)) + // 一条写「一个小时」、另一条写「一小时」:换算后同为 3600 秒,属于同一事实。 + c := dedupeTestItem("主人午睡一个小时,午后精神不错。", 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)) + } +} + +// TestTidyQuantitiesChineseNumerals 钉住中文数字的解析口径(含单位换算与习语排除)。 +func TestTidyQuantitiesChineseNumerals(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}, + // 序数与习语:不是数量。 + {"第二天再说", 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) + } + } +} diff --git a/internal/runtime/memory/tidy_dedupe_test.go b/internal/runtime/memory/tidy_dedupe_test.go index 07986d8..99273e7 100644 --- a/internal/runtime/memory/tidy_dedupe_test.go +++ b/internal/runtime/memory/tidy_dedupe_test.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "fmt" - "strings" "testing" "time" ) @@ -310,91 +309,6 @@ func TestDedupeGroupIsNeverAutoApplied(t *testing.T) { } } -// 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) - } - } -} - // TestDedupeInPartitionSkipsWhenMetaUnavailable 是 r1 复审 **P2-1 前半**的回归: // 元信息查询失败时**整个分区这一轮不分簇**(旧实现降级成 nil map,而 nil map 上 // `temporary` 恒为 false ⇒ 把「临时行绝不进簇」那条红线绕开了)。 diff --git a/internal/runtime/memory/tidy_dedupe_values_test.go b/internal/runtime/memory/tidy_dedupe_values_test.go new file mode 100644 index 0000000..1179e01 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_values_test.go @@ -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) + } + } +} From 01aff71bc1fac2d627035f359b077441a27fb4c2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 25 Oct 2026 00:49:44 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(memory):=20dedupe=5Fgroup=20=E6=95=B0?= =?UTF-8?q?=E5=80=BC=E9=97=A8=E6=94=B9=E4=B8=89=E6=80=81=EF=BC=88=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=90=8E=E5=90=8C=E5=80=BC=E5=8F=AF=E5=90=88=20/=20?= =?UTF-8?q?=E5=90=8C=E9=87=8F=E7=BA=B2=E4=B8=8D=E5=90=8C=E5=80=BC=E5=86=B2?= =?UTF-8?q?=E7=AA=81=20/=20=E9=87=8F=E7=BA=B2=E4=B8=8D=E5=8F=AF=E6=AF=94?= =?UTF-8?q?=E4=BF=9D=E5=AE=88=E6=8B=92=EF=BC=89=EF=BC=8C=E4=BF=AE=20r3=20?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=E4=B8=A4=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r3 复审指出我第一版中文数字门两个方向都反了(规格本身被复审自己收回重写): - 项 2(过严):归一后**同值**、中文与 ASCII 混写的两条合不了 (`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)。根因是判定仍以 `sameNumbers` (只抽 ASCII token)为闸:`{}` vs `{60}` 不等 ⇒ 直接否决,归一化没机会生效。 - 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)被当成"不算冲突"⇒ 会合并, 与「比不了单位就不许合并」相反。 修法:数值门从「布尔冲突」改成**三态**,并取代 `sameNumbers` 作为 dedupe 的数值判据: | 结果 | 含义 | 合并 | |---|---|---| | tidyNumberSame | 归一后同值(含中英混写、单位换算:一小时 ≡ 60 分钟) | ✅ | | tidyNumberConflict | 同量纲、值不相等(含单位不可互换:两杯 vs 三瓶) | ❌ | | tidyNumberIncomparable | 量纲不可比 / 存活行没有同量纲数量可比(一小时 vs 一杯) | ❌ 保守拒绝 | 配套: - 数量抽取同时支持 **ASCII 与中文数字 + 单位**(`60 分钟` / `15分钟` / `十五分钟` / `半个小时` / `一万两千行`),并新增 `字符`/`字节` 两个文字量单位; - **裸数字 token(无单位)**仍按 `sameNumbers` 的集合相等口径判(`0023` vs `0024` 这类靠它拦住),并有 parity 用例钉住「与 extractTidyFacts 的 nums 逐项一致」; - 三态里 `incomparable` 只**收紧**,不放宽任何既有门;`sameNumbers` 函数本身未改 (judgePair/judgeIngest 共用,另处 blast radius,已登记建议)。 回归用例(三态各一条 + 修前红/修后绿,均实测): - TestDedupeGroupNumberGateThreeStates 四个子用例(两条同值含混写、同量纲不同值、量纲不可比), 同时断言判定与簇结果; · 变异 = 退回 `sameNumbers`(项 2 旧行为)⇒ 两条「同值」子用例红; · 变异 = 不可比当放行(项 3 旧行为)⇒ 「量纲不可比」子用例红。 - TestDedupeGroupRejectsConflictingChineseNumbers / RejectsNonConvertibleUnits / SameChineseNumberStillMerges(r2 三项保持绿)+ TestTidyQuantitiesNumberParsing + TestScanTidyNumbersBareTokensMatchExistingFactNums(裸 token 口径不变)。 语料实测(生产库只读 mode=ro,active 3667,999 对已过否定/取值/相似度门的成员对): 三态分布 same=942 / conflict=25 / incomparable=32;dedupe 产出 `250 簇/495 行` → **248 簇/489 行(13.3%)**。incomparable 那 32 对是「四行歌词 vs 一段歌词」「一个项目进度代号 vs 项目进度代号为「w49」」这类:按「比不了就不许合并」 一律拒绝,聚合上只少减 6 行(簇成员互有重叠)。 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。 --- .../runtime/memory/tidy_dedupe_candidate.go | 16 +- .../runtime/memory/tidy_dedupe_numbers.go | 298 ++++++++++++------ .../memory/tidy_dedupe_numbers_test.go | 177 +++++++++-- 3 files changed, 351 insertions(+), 140 deletions(-) diff --git a/internal/runtime/memory/tidy_dedupe_candidate.go b/internal/runtime/memory/tidy_dedupe_candidate.go index 6bd2b86..d8d629a 100644 --- a/internal/runtime/memory/tidy_dedupe_candidate.go +++ b/internal/runtime/memory/tidy_dedupe_candidate.go @@ -16,15 +16,16 @@ import ( // 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**, // 因此它**不进这一簇**(留待人工),而不是让整簇作废: // -// 1. **数值**(§5.2-2,sameNumbers):数值不同即不同事实; +// 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 的落点(`「叶酱」` 不许并进 // `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。 -// 4. **中文数字**(§5.2-2 的补充,dedupeNumberConflicts):`sameNumbers` 只抽 -// ASCII 数字 token,而语料里的数字大多写成中文(一小时 / 十五分钟 / 第二杯)—— -// r2 复审实测约 5 对真实数值冲突因此被合并。这道门按**量纲 + 单位换算**比, -// 单位换算不到一起就按冲突处理(标准是「宁可不合并」)。 // // 相似度下限对**两种簇都生效**(`dedupeGroupMinSimilarity`):tag 集相同只是 // 「同主题」的弱证据(体检报告 §1.3 的「冰美式 vs 红牛」也同 tag 集),内容差得 @@ -35,7 +36,7 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu if it.item.ID == survivor.item.ID { continue } - if !sameNumbers(it.item.Content, survivor.item.Content) { + if verdict, _ := judgeDedupeNumbers(it.item.Content, survivor.item.Content); verdict != tidyNumberSame { continue } if !sameNegations(it.item.Content, survivor.item.Content) { @@ -44,9 +45,6 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu if len(dedupeValueGaps(it.item.Content, survivor.item.Content)) > 0 { continue } - if len(dedupeNumberConflicts(it.item.Content, survivor.item.Content)) > 0 { - continue - } if lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupMinSimilarity(rule) { continue } diff --git a/internal/runtime/memory/tidy_dedupe_numbers.go b/internal/runtime/memory/tidy_dedupe_numbers.go index 5981f24..ad18fbb 100644 --- a/internal/runtime/memory/tidy_dedupe_numbers.go +++ b/internal/runtime/memory/tidy_dedupe_numbers.go @@ -1,58 +1,95 @@ package memory -import "fmt" +import ( + "fmt" + "sort" + "strconv" + "strings" +) -// 本文件是 `dedupe_group` 的**中文数字门**(2026-10-25,r2 复审 P2 的落点): -// 数值冲突不仅要认出 ASCII 数字,还要认出中文数字,并且按**量纲/单位**归一后比较。 +// 本文件是 `dedupe_group` 的**数值门**(2026-10-25):数值冲突要同时认出 ASCII 数字 +// 与中文数字,按**量纲/单位归一**后比较,并给出**三态**结论。 // -// 动因是一次实测:`sameNumbers`(tidy_containment.go)只抽 ASCII 数字 token, -// 而本语料的数字主要写成中文(一小时 / 十五分钟 / 第二杯 / 一辈子)—— -// 于是「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突** -// 被当成同一事实合并掉。复审在 22 个簇里量到约 5 对真实冲突。 +// 沿革(三轮复审各修一处,别把其中任何一条当"可简化"): +// - r2:`sameNumbers` 只抽 ASCII 数字 token,而语料里的数字主要写成中文 +// (一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒ 真冲突被当同一事实合并(实测 ~8 对); +// - r3-项 2(**过严**):归一后**同值**、中文与 ASCII 混写的两条必须能合并 +// (`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)—— 归一化就是为此存在; +// - r3-项 3(**过松**):**量纲不可比**(`一小时` vs `一杯`)不许"当作不算冲突"放行, +// 必须按「比不了就不许合并」保守拒绝。 // -// 🔴 **两道自我约束**(复审明确要求): -// 1. **不做成「两边都有中文数字就一律不合并」**:那样等于把规则废掉。判据是 -// 「**同一个量纲**里,值不相等」才算冲突;两边都写「一杯」照样合。 -// 2. **只在数值上收紧,不放宽别的门**:本门只**增加**否决,不改变任何「原本会被 -// 否决的对」(ASCII 路径仍由 `sameNumbers` 先判,一字未动)。 +// ⇒ 判据是**三态**,不是布尔「有没有冲突」: // -// 形态判据是「**中文数字 + 单位**」,而不是「中文数字」: +// 同值 ⇒ 可合并(tidyNumberSame) +// 同量纲不同值 ⇒ 冲突,不许合并(tidyNumberConflict) +// 量纲不可比/无可比 ⇒ 保守不许合并(tidyNumberIncomparable) +// +// 三态都建立在同一个前提上:**被吸收行的每个数量都要在存活行里找到等值对应** +// (方向与被吸收 ⊆ 存活 的软合并语义一致)。找不到时,看**为什么找不到**来分态: +// 存活行在同一量纲里有数量但值不同 ⇒ 冲突;存活行在该量纲里根本没有数量 +//(含"存活行一个数字都没有")⇒ 量纲不可比 ⇒ 保守拒绝。 +// +// 形态判据是「**数字 + 单位**」而不是「数字」: // - 只认后面紧跟已知单位(小时/分钟/杯/次/份…)的数字,因此「一起」「一直」 -// 「一些」「一样」「一部分」这类**含数字的常见词**不会被当成数量; -// - 数字后面没有单位时**不产生数量**(哪怕它是真的数量):NL 里的裸中文数字 -// 绝大多数是习语,收它会把精确度换成一堆误否决。 -// -// 单位不可换算时按**保守方向**处理(复审口径「比不了单位就不许合并」):同一个 -// 量纲里值不相等、单位又换算不到一起 ⇒ 一律算冲突。 +// 「一些」「一样」「一部分」这类含数字的常见词不会被当成数量; +// - 没有单位的数字走**裸 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 是单位名(写清冲突原因,也是「能不能换算」的判据)。 unit string // dim 是量纲(time / count / length / text)。同量纲才可比。 dim string - // raw 是原始写法(一~数字+单位),只用于日志/测试断言。 + // raw 是原始写法(数字 + 单位),只用于日志/测试断言。 raw string } -// dedupeNumberConflicts 返回被吸收行与存活行之间的**数值冲突**(空 = 无冲突)。 +// judgeDedupeNumbers 判一对记忆的数值关系(三态),并给出理由(证据/日志用)。 // -// 判据(逐条被吸收行的数量): -// 1. 存活行在**同一量纲**里没有任何数量 ⇒ 不算冲突(被吸收行多出来的细节, -// 合并不改变存活行正文,因此不算「冲突」,与取值门的单向口径一致); -// 2. 存活行在同一量纲里有**等值**的数量 ⇒ 不算冲突(单位可以不同:三份 vs 三张); -// 3. 其余 ⇒ 冲突(值不等;单位换算不到一起时同样算冲突,方向保守)。 -func dedupeNumberConflicts(absorbed, survivor string) []string { - have := tidyQuantities(survivor) - if len(have) == 0 { - return nil +// 顺序:先比**裸数字 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)} } - gaps := make([]string, 0, 2) - for _, q := range tidyQuantities(absorbed) { - same, comparable, sameDim := false, false, false - for _, h := range have { + 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 } @@ -61,78 +98,156 @@ func dedupeNumberConflicts(absorbed, survivor string) []string { same = true break } - if convertibleUnits(q, h) { - comparable = true - } } - if same || !sameDim { - // 等值 ⇒ 不是冲突;同量纲里根本没有可比对的数量 ⇒ 也**不是**冲突 - //(被吸收行多出来的细节,合并不改存活行正文,因此不算「冲突」)。 - // 这一条是「不同量纲不算冲突」的落点:`四行歌词` 对 `一段歌词/心事` - // 里 `行`(文字量)与 `段`(计数)根本不是同一回事。 + if same { continue } - if comparable { - gaps = append(gaps, fmt.Sprintf("%s≠同量纲的另一取值", q.raw)) + if sameDim { + // 同量纲、值不相等(含单位换算不到一起)⇒ 真冲突。 + verdict = tidyNumberConflict + reasons = append(reasons, fmt.Sprintf("%s≠同量纲的其它取值", q.raw)) continue } - gaps = append(gaps, fmt.Sprintf("%s 与存活行的同量纲数量不可换算", q.raw)) + // 存活行在该量纲里没有数量 ⇒ 比不了 ⇒ 保守拒绝(r3-项 3)。 + // 注意:这条**不是**「放行」,因此 `一小时` vs `一杯` 不会被合并。 + if verdict != tidyNumberConflict { + verdict = tidyNumberIncomparable + } + reasons = append(reasons, fmt.Sprintf("%s 在存活行里没有同量纲数量可比", q.raw)) } - return gaps + return verdict, reasons } -// sameQuantity 报告两处数量是否等值(同量纲已由调用方保证)。 +// sameQuantity 报告两处数量是否等值(归一化后比较)。 func sameQuantity(a, b tidyQuantity) bool { if a.dim != b.dim { return false } - if a.value == b.value { - return true - } - // 浮点比较留一点余量:换算会乘除(半小时 = 0.5×3600 秒),不做精确相等。 diff := a.value - b.value if diff < 0 { diff = -diff } + // 浮点留一点余量:换算会乘除(半小时 = 0.5×3600 秒)。 return diff < 0.5 } -// convertibleUnits 报告两处数量的单位能不能换算到一起(同一量纲里,计数类单位 -// 之间**不能**互换:两杯 ≠ 三次)。 -func convertibleUnits(a, b tidyQuantity) bool { - if a.dim != b.dim { +// sameStringSet 报告两个字符串切片(已排序去重)是否完全相等。 +func sameStringSet(a, b []string) bool { + if len(a) != len(b) { return false } - if a.dim == tidyDimCount { - // 计数器的量纲相同但单位不可互换(杯/次/份/张…)——除非本来就是同一个单位。 - return a.unit == b.unit + for i := range a { + if a[i] != b[i] { + return false + } } - return tidyUnitFactor(a.dim, a.unit) > 0 && tidyUnitFactor(b.dim, b.unit) > 0 + return true } -// tidyQuantities 抽出一段正文里所有的「中文数字 + 单位」。 +// tidyQuantities 抽出一段正文里所有的「数字 + 单位」(ASCII 与中文数字都算)。 func tidyQuantities(text string) []tidyQuantity { - runes := []rune(dedupeKey(text)) - out := make([]tidyQuantity, 0, 4) + 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++ { - value, next, ok := parseChineseNumberAt(runes, i) - if !ok { - continue - } - unit, dim, factor, unext, uok := tidyUnitAt(runes, next) - if !uok { - // 数字后面没有已知单位:不当作数量(习语占绝对多数)。 - i = next - 1 - continue - } - if tidyOrdinalPosition(runes, i, dim) { - // 「第二天」「第三周」是**序数位置**,不是时长。 + // ① 中文数字 + 单位 + 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 } - raw := string(runes[i:unext]) - out = append(out, tidyQuantity{value: value * factor, unit: unit, dim: dim, raw: raw}) - i = unext - 1 + // ② 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 } @@ -162,14 +277,15 @@ func tidyUnitAt(text []rune, at int) (string, string, float64, int, bool) { var bestFactor float64 bestEnd := at for _, u := range tidyUnitTable() { - if at+len([]rune(u.name)) > len(text) { + n := len([]rune(u.name)) + if at+n > len(text) { continue } - if string(text[at:at+len([]rune(u.name))]) != u.name { + if string(text[at:at+n]) != u.name { continue } - if len([]rune(u.name)) > len([]rune(best)) { - best, bestDim, bestFactor, bestEnd = u.name, u.dim, u.factor, at+len([]rune(u.name)) + if n > len([]rune(best)) { + best, bestDim, bestFactor, bestEnd = u.name, u.dim, u.factor, at+n } } if best == "" { @@ -195,8 +311,7 @@ type tidyUnit struct { // tidyUnitTable 是已知单位表(换算到量纲基准单位:时间=秒、计数=个、长度=米)。 // // 月份/年用**约定值**(30 天 / 365 天)换算:它们在语料里是「多久」的意思, -// 不换算就变成「比不了」,那会让「半年 vs 六个月」也被判冲突 —— 虽然方向保守, -// 但会把一条正确事实挡在簇外。约定值让它们可比。 +// 不换算就变成「比不了」,那会让「半年 vs 六个月」也被判不可比。 func tidyUnitTable() []tidyUnit { return []tidyUnit{ // 时间(基准:秒) @@ -213,7 +328,7 @@ func tidyUnitTable() []tidyUnit { {"礼拜", tidyDimTime, 604800}, {"个月", tidyDimTime, 2592000}, {"年", tidyDimTime, 31536000}, - // 计数(基准:个;**单位之间不互换**,见 convertibleUnits) + // 计数(基准:个;不同计数单位之间**不可互换**) {"次", tidyDimCount, 1}, {"遍", tidyDimCount, 1}, {"圈", tidyDimCount, 1}, @@ -244,22 +359,14 @@ func tidyUnitTable() []tidyUnit { {"米", tidyDimLength, 1}, {"厘米", tidyDimLength, 0.01}, // 文字量(基准:个) + {"字符", tidyDimText, 1}, + {"字节", tidyDimText, 1}, {"行", tidyDimText, 1}, {"字", tidyDimText, 1}, {"页", tidyDimText, 1}, } } -// tidyUnitFactor 返回单位在它量纲里的换算系数(0 = 不认识的单位)。 -func tidyUnitFactor(dim, unit string) float64 { - for _, u := range tidyUnitTable() { - if u.dim == dim && u.name == unit { - return u.factor - } - } - return 0 -} - // parseChineseNumberAt 在 text[at:] 处解析一个中文数字(含「半」),返回数值与结束下标。 // // 覆盖语料里真实出现的形态:一/两/三…九、十、十五、二十、二十四、一百二十、两千、 @@ -322,7 +429,6 @@ func parseChineseNumberAt(text []rune, at int) (float64, int, bool) { if !seen { return 0, at, false } - // 结束:把最后一个裸数字并进小节。 total += section + digit if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 { // 「两三」「三四」这类并列不是确定数量,按模糊处理(不认)。 diff --git a/internal/runtime/memory/tidy_dedupe_numbers_test.go b/internal/runtime/memory/tidy_dedupe_numbers_test.go index 3316c06..a3fd355 100644 --- a/internal/runtime/memory/tidy_dedupe_numbers_test.go +++ b/internal/runtime/memory/tidy_dedupe_numbers_test.go @@ -1,54 +1,118 @@ package memory import ( + "sort" "testing" "time" ) -// 本文件是 `dedupe_group` **中文数字门**(r2 复审 P2)的验收用例集: -// 中位数写法下的数值冲突(一小时 vs 十五分钟、第二杯 vs 一杯)、单位不可换算的 -// 保守拒绝、同值照常合并,以及中文数字解析口径本身。 +// 本文件是 `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 的回归用例**: -// 语料里的数字大多写成**中文**,而 `sameNumbers` 只抽 ASCII 数字 token ⇒ -// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突** -// 曾被当成同一事实合并掉。 -// -// 旧逻辑(只有 sameNumbers)下这些对会成簇 ⇒ 用例红;补中文数字门后不成簇 ⇒ 绿。 +// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」曾被当成同一事实。 func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) { t.Parallel() base := tidyTestTime(t) cases := []struct { - name string - survivor string - member string - tagsA []string - tagsB []string - whyItReds string + name string + survivor string + member string + tags []string }{ { name: "午睡时长(一小时 vs 十五分钟)", survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。", member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。", - tagsA: []string{"作息", "午睡"}, - tagsB: []string{"作息", "午睡"}, + tags: []string{"作息", "午睡"}, }, { name: "咖啡杯数(第二杯 vs 一杯)", survivor: "用户被约定:一天咖啡不许续第二杯,下午犯困时起身走动、喝温水。", member: "用户有喝咖啡的习惯,助手提醒她每天不超过一杯(第二杯不许续)。", - tagsA: []string{"习惯", "咖啡"}, - tagsB: []string{"习惯", "咖啡"}, + tags: []string{"习惯", "咖啡"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - a := dedupeTestItem(tc.survivor, tc.tagsA, base) - b := dedupeTestItem(tc.member, tc.tagsB, base.Add(time.Minute)) - // 先钉住「这不是因为别的门挡住的」:数值门确实报出冲突。 - if gaps := dedupeNumberConflicts(b.Content, a.Content); len(gaps) == 0 { - t.Fatalf("中文数字门没报出冲突:%q vs %q", b.Content, a.Content) + 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)) @@ -57,40 +121,40 @@ func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) { } } -// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」 -// (r2 复审的明确口径):两杯 vs 三瓶是同一个计数量的不同取值,但杯/瓶不能互换。 +// 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 gaps := dedupeNumberConflicts(b.Content, a.Content); len(gaps) == 0 { - t.Fatalf("单位不可换算没有报冲突:%q vs %q", b.Content, a.Content) + 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 是中文数字门的**反向护栏**: -// 值相等(含不同单位换算后相等)照常成簇 —— 门收的是「冲突」,不是「出现数字」。 +// TestDedupeGroupSameChineseNumberStillMerges 是数值门的**反向护栏**: +// 值相等(含不同单位换算后相等、中英混写)照常成簇 —— 门收的是「冲突」,不是「出现数字」。 func TestDedupeGroupSameChineseNumberStillMerges(t *testing.T) { t.Parallel() base := tidyTestTime(t) tags := []string{"作息", "午睡"} a := dedupeTestItem("主人午睡一小时,午后精神好。", tags, base) - b := dedupeTestItem("主人午睡一小时,下午很清醒。", tags, base.Add(time.Minute)) - // 一条写「一个小时」、另一条写「一小时」:换算后同为 3600 秒,属于同一事实。 - c := dedupeTestItem("主人午睡一个小时,午后精神不错。", tags, base.Add(2*time.Minute)) + 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)) + t.Fatalf("簇数 = %d, want 1(同值照常合并 —— 门不许退化成「有数字就不合」)", len(cands)) } } -// TestTidyQuantitiesChineseNumerals 钉住中文数字的解析口径(含单位换算与习语排除)。 -func TestTidyQuantitiesChineseNumerals(t *testing.T) { +// TestTidyQuantitiesNumberParsing 钉住数量解析口径:中文数字、ASCII 数字、单位换算、 +// 以及「没有单位的数字走裸 token 路径」。 +func TestTidyQuantitiesNumberParsing(t *testing.T) { t.Parallel() cases := []struct { text string @@ -104,6 +168,9 @@ func TestTidyQuantitiesChineseNumerals(t *testing.T) { {"主人午睡半个小时", 1800, "小时", tidyDimTime, true}, {"一万两千行的验收纸条", 12000, "行", tidyDimText, true}, {"每天要喝两杯咖啡", 2, "杯", tidyDimCount, true}, + // ASCII 数字 + 单位(r3 必需:`60 分钟` 与 `一小时` 要能归一) + {"主人午睡 60 分钟", 3600, "分钟", tidyDimTime, true}, + {"主人午睡15分钟", 900, "分钟", tidyDimTime, true}, // 序数与习语:不是数量。 {"第二天再说", 0, "", "", false}, {"一起散步", 0, "", "", false}, @@ -126,3 +193,43 @@ func TestTidyQuantitiesChineseNumerals(t *testing.T) { } } } + +// 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) + } +}