Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_test.go
T
AskaEth 7700a7eda0 fix(memory): dedupe_group 补中文数字门(量纲/单位归一),修 r2 复审 p2
r2 复审 p2:`sameNumbers` 只抽 ASCII 数字 token,而本语料的数字主要写成中文
(一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒「午睡一小时 vs 十五分钟」
「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**被当成同一事实合并。

修法(新文件 internal/runtime/memory/tidy_dedupe_numbers.go):
- 抽出「**中文数字 + 单位**」对(一~九/两/半/十/百/千/万,含十五、二十四、一万两千、
  半个小时),按**量纲归一**后比较:时间=秒(分钟 60 / 小时 3600 / 天 86400 /
  周 604800 / 个月 2592000 / 年 31536000)、长度=米、文字量=个、计数=个;
- 只认**后面紧跟已知单位**的数字:`一起 / 一直 / 一些 / 一样 / 第二天(序数位置)`
  这类含数字的习语一律不产生数量 —— 不做成「有中文数字就不合并」;
- 判据是**冲突**而非差异:存活行在同量纲里没有对应数量 ⇒ 不算冲突(被吸收行多出
  的细节);等值(含单位换算后等值,如「半小时」=1800 秒)⇒ 照常合并;
  值不等且单位换算不到一起 ⇒ 按冲突处理(复审口径:宁可不合并);
- 只**增加否决**,不放宽任何既有门;ASCII 路径仍由 sameNumbers 先判,一字未动;
  `sameNumbers` 本身也未改(judgePair/judgeIngest 共用它,属于另一处 blast radius,
  已在回报里登记为后续建议)。

语料实测(生产库只读 mode=ro,active 3667):999 对里中文数字门挡掉 **17 对**,
其中真冲突:午睡十五分钟 vs 一小时 ×3、咖啡一杯 vs 第二杯 ×4、
「三千次轮回」vs「三千万次轮回」×1(ASCII 路径完全看不见这条);
其余 9 对是「单位不可换算」的保守拒绝(半碗 vs 一个包/一杯、一次 vs 两圈)。
dedupe 产出 255 簇/505 行 → **250 簇/495 行(13.5%)**。

回归用例(去掉这道门即红,已实测):
- TestDedupeGroupRejectsConflictingChineseNumbers(复审给的两对真实语料,脱敏保留原意)
- TestDedupeGroupRejectsNonConvertibleUnits(两杯 vs 三瓶 ⇒ 保守不合并)
- TestDedupeGroupSameChineseNumberStillMerges(同值/换算后同值 ⇒ 照常合并,门没退化成「有数字就不合」)
- TestTidyQuantitiesChineseNumerals(解析口径:一小时=3600s、十五分钟=900s、半小时=1800s、
  一万两千行=12000、第二天/一起/一直 不产生数量)

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:41:16 +08:00

360 lines
17 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"
"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))
}
}