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 ./... 全绿。
This commit is contained in:
AskaEth committed 2026-10-25 00:08:46 +08:00
1 parent 210c8b999f
commit 6a3f3a329a
20 files changed
+2353 -403

No files matched your search

@@ -0,0 +1,289 @@
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 为空 —— 被上限切掉的部分必须如实登记分区标签")
}
}