package memory import ( "context" "crypto/sha256" "fmt" "testing" "time" ) // 本文件是 `dedupe_group`(2026-10-24,M34)的验收用例集。 // // 它钉住五件事,每件都能被一次**变异**打红: // 1. **簇判据只有两条**(同 scope+kind+完全相同 tag 集 / 前 12 字相同)—— // 拿掉任一条、或让它跨 scope/跨 kind 成簇,用例立刻红; // 2. **保留规则**是「最长 > merge_count 大 > created_at 早」——改动顺序即红; // 3. **安全门与既有合并路径同源**:数值/否定/时效/低置信/受限来源一律不入簇; // 4. **幂等**:同一批数据两次分簇产出同一个幂等键(重复跑不重复产候选); // 5. **可应用 / 可回滚**:应用是软合并(行不删、正文不改),回滚把每一行都复原, // 批量应用按**行**计上限、超限如实 truncated。 // dedupeTestItem 造一条「可进簇」的记忆:同分区(persona.a / persona / 无项目)、 // 置信 0.9、owner 来源(web 主会话)。tags / 内容 / 创建时刻逐项可控。 func dedupeTestItem(content string, tags []string, at time.Time) MemoryItem { return MemoryItem{ // 确定性 id:内容 + 时刻,因此「同内容的孪生」也各有自己的 id // (id 为空的两行会被 dedupeGroupEligible 挡在簇外 —— 那是纵深防御, // 但夹具不该靠它)。 ID: fmt.Sprintf("item-%x-%d", sha1OfForTest(content), at.UnixNano()), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: content, Tags: tags, Confidence: 0.9, CreatedAt: at, SourceConversation: FormatSourceConversation("web", "msg-1"), } } // sha1OfForTest 给夹具造确定性 id 用(与生产 id 无关,只要在用例内唯一)。 func sha1OfForTest(content string) []byte { sum := sha256.Sum256([]byte(content)) return sum[:6] } // seedDedupeItem 落一条记忆并返回它的落库形状。 func seedDedupeItem(t *testing.T, store *Store, item MemoryItem) MemoryItem { t.Helper() return seedTidyItem(t, store, item) } // dedupeCandsOf 跑一次分簇并只保留 dedupe_group 候选。 func dedupeCandsOf(t *testing.T, store *Store, items []MemoryItem) ([]TidyCandidate, tidyDedupeStats) { t.Helper() tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store, Now: func() time.Time { return tidyTestTime(t) }}) if err != nil { t.Fatalf("NewTidier: %v", err) } meta, err := store.dedupeItemMeta(context.Background(), tidyPartition{personaID: "persona.a", scope: ScopePersona}) if err != nil { t.Fatalf("dedupeItemMeta: %v", err) } return tidier.dedupeCandidates(items, meta, nil, tidyTestTime(t)) } // TestDedupeGroupClustersByIdenticalTagSet 钉住判据一:**同 scope + 同 kind + // 完全相同 tag 集**的条目成簇,每簇只产**一条**候选,存活行是「内容最长」那条。 func TestDedupeGroupClustersByIdenticalTagSet(t *testing.T) { t.Parallel() store := newTestStore(t) base := tidyTestTime(t) tags := []string{"身份", "名字"} items := []MemoryItem{ seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)), seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))), seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))), } cands, stats := dedupeCandsOf(t, store, items) if len(cands) != 1 || stats.Groups != 1 { t.Fatalf("簇数 = %d (stats %+v), want 1 —— 同 tag 集的三条必须合成一簇", len(cands), stats) } cand := cands[0] if cand.Action != TidyDedupeGroup { t.Fatalf("动作 = %q, want %q", cand.Action, TidyDedupeGroup) } if cand.TargetID != items[2].ID { t.Fatalf("存活行 = %s, want 最长的那条 %s", cand.TargetID, items[2].ID) } if len(cand.SourceIDs) != 2 || stats.Rows != 2 { t.Fatalf("被吸收行 = %v (rows=%d), want 2 条", cand.SourceIDs, stats.Rows) } if cand.Evidence.DedupeRule != tidyDedupeRuleTagSet { t.Fatalf("簇判据 = %q, want %q", cand.Evidence.DedupeRule, tidyDedupeRuleTagSet) } if cand.Evidence.ClusterSize != 3 || cand.Evidence.KeptReason != tidyDedupeKeepLongest { t.Fatalf("证据 = %+v, want cluster_size=3 kept_reason=%q", cand.Evidence, tidyDedupeKeepLongest) } if len(cand.Evidence.DedupeMembers) != 2 { t.Fatalf("证据里的被吸收正文 = %d 条, want 2(面板要能一次看清吞了哪几条)", len(cand.Evidence.DedupeMembers)) } } // TestDedupeGroupClustersByPrefix12 钉住判据二:**前 12 个字相同**(换个说法的同一句) // 也会成簇 —— 这正是「整理层的包含判定覆盖不了改写」的那个缺口。 func TestDedupeGroupClustersByPrefix12(t *testing.T) { t.Parallel() store := newTestStore(t) base := tidyTestTime(t) // 两条 tag 集**不同**(否则会先被 tag 集判据吃掉),但前 12 字逐字相同。 items := []MemoryItem{ seedDedupeItem(t, store, dedupeTestItem("用户与助手约定每天晚饭后一起散步", []string{"约定", "散步"}, base)), seedDedupeItem(t, store, dedupeTestItem("用户与助手约定每天晚饭后出门散步十分钟", []string{"约定", "运动"}, base.Add(time.Minute))), } cands, _ := dedupeCandsOf(t, store, items) if len(cands) != 1 { t.Fatalf("簇数 = %d, want 1(前 12 字相同 ⇒ 同一簇)", len(cands)) } if cands[0].Evidence.DedupeRule != tidyDedupeRulePrefix { t.Fatalf("簇判据 = %q, want %q", cands[0].Evidence.DedupeRule, tidyDedupeRulePrefix) } } // TestDedupeGroupPrefixRequiresSimilarityFloor 钉住前缀簇的**收窄条件**:同开头 // 但后半句讲了别的事(相似度低于下限)⇒ 不成簇。它是「同构不同事实」的护栏。 func TestDedupeGroupPrefixRequiresSimilarityFloor(t *testing.T) { t.Parallel() a := "助手(persona)的说话风格:亲昵、爱撒娇" b := "助手(persona)的象征花为蓝玫瑰和月桂叶" if got := lexicalSimilarity(a, b); got >= dedupeGroupPrefixSimilarity { t.Fatalf("夹具失效:两条相似度 %.3f 高于下限 %.2f,测不出护栏", got, dedupeGroupPrefixSimilarity) } items := []MemoryItem{ dedupeTestItem(a, []string{"日常", "电影"}, tidyTestTime(t)), dedupeTestItem(b, []string{"日常", "回家"}, tidyTestTime(t)), } cands, _ := dedupeCandsOf(t, newTestStore(t), items) if len(cands) != 0 { t.Fatalf("簇数 = %d, want 0(相似度低于 %.2f 的同开头对不许成簇)", len(cands), dedupeGroupPrefixSimilarity) } } // TestDedupeGroupNeverCrossesPartition 钉住硬门:跨 scope / 跨 kind 的同一事实 // **绝不成簇**(design/23 §5.2-7/8、§6;跨 scope 收敛是塔已明确「不做」的那一批)。 func TestDedupeGroupNeverCrossesPartition(t *testing.T) { t.Parallel() base := tidyTestTime(t) tags := []string{"称呼", "亲密"} content := "主人喜欢被称呼为「叶酱」" user := dedupeTestItem(content, tags, base) user.Scope = ScopeUser persona := dedupeTestItem(content, tags, base.Add(time.Minute)) worldEvent := dedupeTestItem(content, tags, base.Add(2*time.Minute)) worldEvent.Kind = KindWorldEvent cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{user, persona, worldEvent}) if len(cands) != 0 { t.Fatalf("簇数 = %d, want 0(跨 scope / 跨 kind 是硬门,一条都不许成簇)", len(cands)) } } // TestDedupeGroupSkipsUnsafeMembers 钉住安全门:时效 / 低置信 / 受限来源的条目 // 连簇都不进;数值或否定与存活行不一致的成员被逐条剔出(留待人工)。 func TestDedupeGroupSkipsUnsafeMembers(t *testing.T) { t.Parallel() base := tidyTestTime(t) tags := []string{"作息", "睡眠"} // 一簇 4 条:一条正常、一条数值不同、一条否定不同、一条时效污染。 // 数值用**ASCII 数字**:既有 sameNumbers 只认 ASCII 数字 token(中文数字 // 「十点 vs 十一点」不在它的判据里 —— 那是既有口径的已知限制,本切片不改)。 normal := dedupeTestItem("主人每晚 22 点后才有空聊天", tags, base) numeric := dedupeTestItem("主人每晚 23 点后才有空聊天", tags, base.Add(time.Minute)) negated := dedupeTestItem("主人不再每晚 22 点后聊天", tags, base.Add(2*time.Minute)) temporal := dedupeTestItem("主人每晚 22 点后才有空聊天,假期期间更晚", tags, base.Add(3*time.Minute)) cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{normal, numeric, negated, temporal}) if len(cands) != 0 { t.Fatalf("簇数 = %d, want 0:数值/否定不一致与时效污染的成员不许与存活行同簇(剩一条不成簇)", len(cands)) } // 低置信:两条**都**低置信的孪生也不许成簇(对照组用同样的内容 + 0.9 置信)。 twin := "主人每晚 22 点后才有空聊天" lowA := dedupeTestItem(twin, tags, base) lowA.Confidence = 0.6 lowB := dedupeTestItem(twin, tags, base.Add(time.Minute)) lowB.Confidence = 0.6 if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{lowA, lowB}); len(cands) != 0 { t.Fatalf("簇数 = %d, want 0(低置信是硬否决门 §5.2-6)", len(cands)) } okA := dedupeTestItem(twin, tags, base) okB := dedupeTestItem(twin, tags, base.Add(time.Minute)) if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{okA, okB}); len(cands) != 1 { t.Fatalf("对照组簇数 = %d, want 1(0.9 置信的两条必须成簇,否则测不出上面的否决)", len(cands)) } // 受限来源(群聊):连簇都不进 —— 与 owner 来源的孪生也不许成簇。 owner := dedupeTestItem(twin, tags, base) restricted := dedupeTestItem(twin, tags, base.Add(time.Minute)) restricted.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1") if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{owner, restricted}); len(cands) != 0 { t.Fatalf("簇数 = %d, want 0(群聊来源只读不写 §5.2-9)", len(cands)) } } // TestDedupeGroupKeepsSupersetByLengthThenMergeCountThenAge 钉住保留规则的三级优先序。 func TestDedupeGroupKeepsSupersetByLengthThenMergeCountThenAge(t *testing.T) { t.Parallel() ctx := context.Background() tags := []string{"宠物", "猫"} t.Run("等长时取 merge_count 大的", func(t *testing.T) { t.Parallel() store := newTestStore(t) base := tidyTestTime(t) old := seedDedupeItem(t, store, dedupeTestItem("小茂密是白毛蝴蝶结的小猫", tags, base)) hot := seedDedupeItem(t, store, dedupeTestItem("小茂密是白毛蝴蝶结的小猫", tags, base.Add(time.Minute))) if _, err := store.db.ExecContext(ctx, `UPDATE memory_items SET merge_count = 4 WHERE id = ?`, hot.ID); err != nil { t.Fatalf("bump merge_count: %v", err) } cands, _ := dedupeCandsOf(t, store, []MemoryItem{old, hot}) if len(cands) != 1 || cands[0].TargetID != hot.ID { t.Fatalf("存活行 = %v, want merge_count 大的 %s", cands, hot.ID) } if cands[0].Evidence.KeptReason != tidyDedupeKeepMergeCount { t.Fatalf("kept_reason = %q, want %q", cands[0].Evidence.KeptReason, tidyDedupeKeepMergeCount) } }) t.Run("等长同等时取更早创建的", func(t *testing.T) { t.Parallel() store := newTestStore(t) base := tidyTestTime(t) early := seedDedupeItem(t, store, dedupeTestItem("小茂密是深色蝴蝶结的小猫", tags, base)) late := seedDedupeItem(t, store, dedupeTestItem("小茂密是深色蝴蝶结的小猫", tags, base.Add(time.Minute))) cands, _ := dedupeCandsOf(t, store, []MemoryItem{late, early}) if len(cands) != 1 || cands[0].TargetID != early.ID { t.Fatalf("存活行 = %v, want 更早创建的 %s", cands, early.ID) } if cands[0].Evidence.KeptReason != tidyDedupeKeepOldest { t.Fatalf("kept_reason = %q, want %q", cands[0].Evidence.KeptReason, tidyDedupeKeepOldest) } }) } // TestDedupeGroupIsIdempotentAndDeterministic 钉住幂等:同一批数据两次分簇,候选 // 的幂等键、存活行、被吸收行集合**逐项相同**(重复跑不重复产候选)。 func TestDedupeGroupIsIdempotentAndDeterministic(t *testing.T) { t.Parallel() store := newTestStore(t) base := tidyTestTime(t) tags := []string{"约定", "散步"} items := []MemoryItem{ seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分钟", tags, base)), seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分钟晒太阳", tags, base.Add(time.Minute))), seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分", tags, base.Add(2*time.Minute))), } first, _ := dedupeCandsOf(t, store, items) second, _ := dedupeCandsOf(t, store, items) if len(first) != 1 || len(second) != 1 { t.Fatalf("两次分簇 = %d / %d, want 1 / 1", len(first), len(second)) } if tidyIdempotencyKey(first[0]) != tidyIdempotencyKey(second[0]) { t.Fatalf("幂等键不同:%q vs %q", tidyIdempotencyKey(first[0]), tidyIdempotencyKey(second[0])) } if first[0].TargetID != second[0].TargetID || len(first[0].SourceIDs) != len(second[0].SourceIDs) { t.Fatalf("两次分簇结果不一致:%+v vs %+v", first[0], second[0]) } } // TestDedupeGroupSuppressesPairsWithExistingCandidate 钉住「不制造第二条待办」: // 已被 archive/merge 候选覆盖的条目不再进簇。 func TestDedupeGroupSuppressesPairsWithExistingCandidate(t *testing.T) { t.Parallel() store := newTestStore(t) base := tidyTestTime(t) tags := []string{"身份", "名字"} items := []MemoryItem{ seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)), seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))), } tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store}) if err != nil { t.Fatalf("NewTidier: %v", err) } covered := map[string]struct{}{items[0].ID: {}, items[1].ID: {}} if cands, _ := tidier.dedupeCandidates(items, nil, covered, tidyTestTime(t)); len(cands) != 0 { t.Fatalf("簇数 = %d, want 0(两条都已被既有候选覆盖)", len(cands)) } // 只覆盖一条:另一条单独一条也不成簇(簇至少两条)。 if cands, _ := tidier.dedupeCandidates(items, nil, map[string]struct{}{items[0].ID: {}}, tidyTestTime(t)); len(cands) != 0 { t.Fatalf("簇数 = %d, want 0(剩一条不成簇)", len(cands)) } } // TestDedupeGroupIsNeverAutoApplied 钉住红线:dedupe_group **不进自动档**。 // 一次点击改多行记忆,必须在环核对(体检报告 §7.2);变异(把它的 case 加进 // tidyAutoApplyEligible)会让这条用例立刻红。 func TestDedupeGroupIsNeverAutoApplied(t *testing.T) { t.Parallel() cand := TidyCandidate{ Action: TidyDedupeGroup, SourceIDs: []string{"a", "b"}, TargetID: "c", Reason: tidyReasonDedupeGroup, } if ok, reason := tidyAutoApplyEligible(cand); ok { t.Fatalf("dedupe_group 被判成可自动落地(reason=%q)—— 它必须在环核对", reason) } } // TestDedupeInPartitionSkipsWhenMetaUnavailable 是 r1 复审 **P2-1 前半**的回归: // 元信息查询失败时**整个分区这一轮不分簇**(旧实现降级成 nil map,而 nil map 上 // `temporary` 恒为 false ⇒ 把「临时行绝不进簇」那条红线绕开了)。 func TestDedupeInPartitionSkipsWhenMetaUnavailable(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) tags := []string{"身份", "名字"} items := []MemoryItem{ seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)), seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))), } tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store, Log: testLogger()}) if err != nil { t.Fatalf("NewTidier: %v", err) } // 让元信息查询失败:关掉底层库(查询随之报错)。 if err := store.db.Close(); err != nil { t.Fatalf("close db: %v", err) } cands, stats := tidier.dedupeInPartition(ctx, tidyPartition{personaID: "persona.a", scope: ScopePersona}, items, nil) if len(cands) != 0 || stats.Groups != 0 { t.Fatalf("簇数 = %d (stats %+v), want 0(元信息不可得 ⇒ 本轮不做,而不是猜)", len(cands), stats) } } // TestDedupeGroupTagSetAlsoNeedsSimilarityFloor 钉住「tag 集簇也要过相似度下限」 // (r1 复审 P1 修法第 2 条):同 tag 集但**正文几乎没有任何共同词项**的两条不许成簇。 // // 这条下限取值刻意很低(0.10,见 dedupeGroupTagSetSimilarity 的实测依据):语料里 // 同一事实的多种写法的相似度本来就在 0.05~0.4 之间,抬高它只会删掉合法去重。 // 因此本用例用**零重叠**的极端对钉住「门确实在」,而不是钉某个档位。 func TestDedupeGroupTagSetAlsoNeedsSimilarityFloor(t *testing.T) { t.Parallel() base := tidyTestTime(t) tags := []string{"日常", "杂项"} a := dedupeTestItem("主人喜欢冰美式,夏天要加冰", tags, base) b := dedupeTestItem("仓库里的迁移脚本必须先过静态检查", tags, base.Add(time.Minute)) if got := lexicalSimilarity(a.Content, b.Content); got >= dedupeGroupTagSetSimilarity { t.Fatalf("夹具失效:两条相似度 %.3f 已高于下限 %.2f", got, dedupeGroupTagSetSimilarity) } if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 { t.Fatalf("簇数 = %d, want 0(同 tag 集但正文零重叠 ⇒ 不成簇)", len(cands)) } }