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。
446 lines
22 KiB
Go
446 lines
22 KiB
Go
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))
|
||
}
|
||
}
|