Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_apply_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

290 lines
13 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"
"fmt"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 `dedupe_group` 的**应用 / 回滚**验收用例集(软合并、整簇原子、
// 按行计的分批上限、临时层数据安全)。分簇与候选构造的用例在
// tidy_dedupe_test.go —— 分开只为守 AGENTS §10 的文件行数上限(≤500 行)。
// TestTidyApplyDedupeGroupIsSoftAndRevertible 钉住应用/回滚:整簇软合并(行不删、
// 正文不改、merge_count 累加),revert 把**每一行**都复原。
func TestTidyApplyDedupeGroupIsSoftAndRevertible(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))),
seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))),
}
cands, _ := dedupeCandsOf(t, store, items)
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1", len(cands))
}
cand := cands[0]
if created, err := store.insertTidyCandidate(ctx, cand); err != nil || !created {
t.Fatalf("insert candidate: created=%v err=%v", created, err)
}
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
}
if res.Status != TidyApplyApplied || len(res.AffectedIDs) != 2 {
t.Fatalf("应用结果 = %+v, want applied 且影响 2 行", res)
}
for _, id := range cand.SourceIDs {
row := loadTidyItemRow(t, store, id)
if row.status != ingestStatusMerged || row.mergedInto != cand.TargetID {
t.Fatalf("被吸收行 %s = %+v, want merged → %s", id, row, cand.TargetID)
}
}
survivor := loadTidyItemRow(t, store, cand.TargetID)
if survivor.status != tidyStatusActive {
t.Fatalf("存活行状态 = %q, want active", survivor.status)
}
if got := countMemoryItems(t, store); got != 3 {
t.Fatalf("应用后行数 = %d, want 3(软合并绝不删行)", got)
}
if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 3 {
t.Fatalf("存活行 merge_count = %d, want 3(1 + 吸收 2)", got)
}
if got := loadTidyItemRow(t, store, cand.TargetID).content; got != items[2].Content {
t.Fatalf("存活行正文被改了:%q", got)
}
reverted, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("RevertTidyCandidate: %v", err)
}
if reverted.Status != TidyApplyApplied || len(reverted.AffectedIDs) != 2 {
t.Fatalf("回滚结果 = %+v, want applied 且复原 2 行", reverted)
}
for _, id := range cand.SourceIDs {
row := loadTidyItemRow(t, store, id)
if row.status != tidyStatusActive || row.mergedInto != "" {
t.Fatalf("回滚后 %s = %+v, want active 且 merged_into 清空", id, row)
}
}
if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 1 {
t.Fatalf("回滚后 merge_count = %d, want 1", got)
}
if got, _ := store.GetTidyCandidate(ctx, cand.ID); got.Status != tidyCandidatePending {
t.Fatalf("回滚后候选状态 = %q, want pending(可重新决定)", got.Status)
}
}
// TestTidyApplyDedupeGroupRollsBackWholeClusterOnStaleMember 钉住原子的**安全方向**:
// 簇里有一行已不在册 ⇒ 整簇不吞(一行都不改),候选如实 skip。
func TestTidyApplyDedupeGroupRollsBackWholeClusterOnStaleMember(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))),
seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))),
}
cands, _ := dedupeCandsOf(t, store, items)
cand := cands[0]
if created, err := store.insertTidyCandidate(ctx, cand); err != nil || !created {
t.Fatalf("insert candidate: created=%v err=%v", created, err)
}
// 让簇里一个成员先被别的动作合并掉(模拟过时候选)。
if _, err := store.db.ExecContext(ctx,
`UPDATE memory_items SET status = ? WHERE id = ?`,
ingestStatusMerged, cand.SourceIDs[0]); err != nil {
t.Fatalf("stale member update: %v", err)
}
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
}
if res.Status != TidyApplySkipped || res.Reason != tidySkipSourceNotActive {
t.Fatalf("结果 = %+v, want skipped/%s(整簇原子,不许吞一半)", res, tidySkipSourceNotActive)
}
if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 1 {
t.Fatalf("存活行 merge_count = %d, want 1(整簇回滚,一行都没改)", got)
}
if got := loadTidyItemRow(t, store, cand.SourceIDs[1]).status; got != tidyStatusActive {
t.Fatalf("第二个被吸收行状态 = %q, want active(半吞禁止)", got)
}
}
// TestTidyApplyDedupeGroupHonoursRowBudget 钉住「批量按行计、超限如实 truncated」:
// 每簇 2 个被吸收行(行预算按被吸收行计),预算 200 ⇒ 最多 100 簇(= 200 行),
// 第 101 簇起留给下一次点击,回执必须如实说 truncated。
func TestTidyApplyDedupeGroupHonoursRowBudget(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
total := maxTidyApplyRowsPerBatch/2 + 5 // 簇数多到必然撞上 200 行预算
wantApplied := maxTidyApplyRowsPerBatch / 2 // 每簇 2 个被吸收行
for i := 0; i < total; i++ {
tags := []string{"身份", "代号"}
content := fmt.Sprintf("小茂密的第 %d 号说法:蝴蝶结是小铃铛", i)
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem(content+"甲", tags, base)),
seedDedupeItem(t, store, dedupeTestItem(content+"乙", tags, base.Add(time.Minute))),
seedDedupeItem(t, store, dedupeTestItem(content+"丙", tags, base.Add(2*time.Minute))),
}
cands, _ := dedupeCandsOf(t, store, items)
if len(cands) != 1 {
t.Fatalf("第 %d 组簇数 = %d, want 1", i, len(cands))
}
if created, err := store.insertTidyCandidate(ctx, cands[0]); err != nil || !created {
t.Fatalf("insert candidate %d: created=%v err=%v", i, created, err)
}
}
batch, err := store.ApplyTidyCandidates(ctx, "", TidyApplyOptions{Actor: "owner:test", Limit: 500})
if err != nil {
t.Fatalf("ApplyTidyCandidates: %v", err)
}
if !batch.Truncated {
t.Fatalf("truncated = false, want true(行预算 200 被撞上时必须如实说)")
}
if batch.Applied != wantApplied {
t.Fatalf("应用簇数 = %d, want %d(200 行 ÷ 3 行/簇)", batch.Applied, wantApplied)
}
if batch.RowsAffected != wantApplied*2 {
t.Fatalf("改动行数 = %d, want %d", batch.RowsAffected, wantApplied*2)
}
if batch.Deduped != wantApplied {
t.Fatalf("deduped 计数 = %d, want %d", batch.Deduped, wantApplied)
}
}
// TestTidyDryRunReportsDedupeGroupsWithoutWriting 钉住「**先试算、不写库**」这条要求:
// 试算轮必须给出 dedupe_group 的**簇数与可减少行数**(面板据此决定要不要落库),
// 并且三张表逐行不变(零副作用)—— 试算与真跑共用同一条规则路径,因此这份报告
// 就是「真跑会产出的东西」。
func TestTidyDryRunReportsDedupeGroupsWithoutWriting(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
// 三条**互不包含**、但 tag 集完全相同的说法(否则它们会先被包含判定吃掉,
// 那时 dedupe 会被「已覆盖」抑制掉 —— 那是对的行为,但测不到试算口径)。
tags := []string{"宠物", "小茂密"}
for i, content := range []string{
"小茂密是白毛的小猫,喜欢撒娇",
"茂密的蝴蝶结最近换成了深色",
"茂密爱坐在窗边看外面的风景",
} {
seedDedupeItem(t, store, dedupeTestItem(content, tags, base.Add(time.Duration(i)*time.Minute)))
}
before := snapshotTidyTables(t, store)
emb := stubEmbedder{vecs: map[string]embedding.Vector{}}
dr, err := newTestTidier(t, store, tidyTestCfg(0.80), emb).DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if dr.Stats.DedupeGroups != 1 || dr.Stats.DedupeRows != 2 {
t.Fatalf("试算口径 = groups:%d rows:%d, want 1 / 2(三行一簇,可减两行)",
dr.Stats.DedupeGroups, dr.Stats.DedupeRows)
}
dedupeCands := candidatesByAction(dr.Candidates, TidyDedupeGroup)
if len(dedupeCands) != 1 {
t.Fatalf("试算候选里的 dedupe_group = %d 条, want 1", len(dedupeCands))
}
assertRowsUnchanged(t, "试算后三张表", before.items, snapshotTidyTables(t, store).items)
after := snapshotTidyTables(t, store)
assertRowsUnchanged(t, "试算后候选表", before.candidates, after.candidates)
assertRowsUnchanged(t, "试算后运行表", before.runs, after.runs)
}
// TestDedupeGroupNeverTouchesTemporaryRows 钉住临时层的**数据安全**不变式:
// `expires_at` 非空的记忆(临时层)绝不进簇 —— 无论当存活行还是被吸收行。
//
// 为什么是红线:临时行 7 天后由后台清扫**物理删除**(design/23 §19)。若它当存活行,
// 被吸收的长期事实会永远停在 `status='merged'` 且 `merged_into` 指向一个已不存在的
// id(信息不可达);若它当被吸收行,一次合并就把一条正在生效的临时记忆藏了起来。
func TestDedupeGroupNeverTouchesTemporaryRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
tags := []string{"身份", "名字"}
durable := seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base))
temporary := seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute)))
if _, err := store.db.ExecContext(ctx,
`UPDATE memory_items SET expires_at = ? WHERE id = ?`,
expiryText(base.Add(7*24*time.Hour)), temporary.ID); err != nil {
t.Fatalf("mark temporary: %v", err)
}
cands, stats := dedupeCandsOf(t, store, []MemoryItem{durable, temporary})
if len(cands) != 0 || stats.Groups != 0 {
t.Fatalf("簇数 = %d, want 0(临时层行不许进簇:存活行会让被吸收行指向一个将被删除的 id)",
len(cands))
}
// 三条时:两条长期 + 一条临时 ⇒ 正是两条长期的那一簇,临时行不在候选里。
durable2 := seedDedupeItem(t, store, dedupeTestItem("助理/助手名为 Cyrene。", tags, base.Add(2*time.Minute)))
cands, _ = dedupeCandsOf(t, store, []MemoryItem{durable, temporary, durable2})
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1", len(cands))
}
for _, id := range cands[0].SourceIDs {
if id == temporary.ID {
t.Fatalf("临时行出现在被吸收行里:%v", cands[0].SourceIDs)
}
}
if cands[0].TargetID == temporary.ID {
t.Fatalf("临时行当上了存活行 —— 它 7 天后会被物理删除")
}
}
// TestTidyRunReportsDedupeTruncation 钉住「truncated 如实」:每分区候选上限切掉了
// 多少 dedupe 簇必须**单独**报出来。变异(不给 DedupeTruncated 计数、或按错误的
// 段计算)会让这条用例红 —— 被截断的簇在面板上会彻底消失,那正是「静默少做」。
func TestTidyRunReportsDedupeTruncation(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
// 两个互不相干的簇(tag 集不同 ⇒ 两个簇),一共 2 条 dedupe 候选。
seed := []struct {
content string
tags []string
}{
{"小茂密是白毛的小猫,喜欢撒娇", []string{"宠物", "小茂密"}},
{"茂密的蝴蝶结最近换成了深色", []string{"宠物", "小茂密"}},
{"主人对花溪的住处很满意", []string{"地点", "住处"}},
{"主人说花溪的住处很安静", []string{"地点", "住处"}},
}
for i, s := range seed {
seedDedupeItem(t, store, dedupeTestItem(s.content, s.tags, base.Add(time.Duration(i)*time.Minute)))
}
cfg := tidyTestCfg(0.80)
cfg.CandidateLimit = 1
emb := stubEmbedder{vecs: map[string]embedding.Vector{}}
stats, err := newTestTidier(t, store, cfg, emb).RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.DedupeGroups != 2 {
t.Fatalf("分簇数 = %d, want 2(分簇口径不受候选上限影响)", stats.DedupeGroups)
}
if stats.DedupeTruncated != 1 {
t.Fatalf("DedupeTruncated = %d, want 1(上限 1 只容得下一簇)", stats.DedupeTruncated)
}
if len(stats.Truncated) == 0 {
t.Fatalf("Truncated 为空 —— 被上限切掉的部分必须如实登记分区标签")
}
}