Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_test.go
T
AskaEth 6a3f3a329a feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)
M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:

1) 新增 `dedupe_group` 候选动作(不放大 containNone):
   - 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
     (后者附加词项余弦 ≥0.70 与同 kind 收窄);
   - 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
   - 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
     数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
     两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
   - 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
   - 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
     回滚复用 revertTidyMerge(逐条复原);
   - 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
   - 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。

2) 留存闸改组合口径(治根)+ N6/N7:
   - 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
     (数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
   - N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
     这类偏好不再因寒暄词被判临时);
   - 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
     硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
   - 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
     已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
     存量一条不补写(用例钉住)。

契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。

实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
  89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
  这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
  簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。

验收:go build ./... + go vet ./... + go test ./... 全绿。
2026-10-25 00:08:46 +08:00

311 lines
15 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)
}
}