From 54e32304f268c540d6814c761bf998710c7709ea Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 25 Oct 2026 00:29:02 +0800 Subject: [PATCH] =?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 +}