Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_test.go
T
AskaEth 54e32304f2 fix(memory): dedupe_group 补取值门 + 临时层双击复核;闸补 3 类长期事实安全阀(M34 r1 复审)
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。
2026-10-25 00:29:02 +08:00

446 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"crypto/sha256"
"fmt"
"strings"
"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)
}
}
// 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))
}
}