Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_apply_test.go
T
AskaEth 54e32304f2 fix(memory): dedupe_group 补取值门 + 临时层双击复核;闸补 3 类长期事实安全阀(M34 r1 复审)
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。
2026-10-25 00:29:02 +08:00

354 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"
"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 为空 —— 被上限切掉的部分必须如实登记分区标签")
}
}
// TestTidyApplyDedupeGroupSkipsTemporaryRows 是 r1 复审 **P2-1 后半**的回归:
// 候选生成之后某一行才变成临时层(`expires_at` 非空)时,apply **必须整簇 skip**。
//
// 为什么必须有这道复核:候选从生成到「人点 apply」之间隔着任意长的墙钟。若存活行
// 在那之后进了临时层,7 天后它被物理删除(design/23 §19),被吸收行就会永远停在
// `status='merged'` 且 `merged_into` 指向一个不存在的 id。
func TestTidyApplyDedupeGroupSkipsTemporaryRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := tidyTestTime(t)
cases := []struct {
name string
markTarget bool
}{
{"存活行变成临时层", true},
{"被吸收行变成临时层", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
tags := []string{"身份", "名字"}
items := []MemoryItem{
seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)),
seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(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)
}
// 候选落库之后、apply 之前:把目标/被吸收行变成临时层。
victim := cand.SourceIDs[0]
if tc.markTarget {
victim = cand.TargetID
}
if _, err := store.db.ExecContext(ctx,
`UPDATE memory_items SET expires_at = ? WHERE id = ?`,
expiryText(base.Add(7*24*time.Hour)), victim); err != nil {
t.Fatalf("mark temporary: %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 != tidySkipTemporaryRow {
t.Fatalf("结果 = %+v, want skipped/%s", res, tidySkipTemporaryRow)
}
for _, id := range cand.SourceIDs {
if got := loadTidyItemRow(t, store, id).status; got != tidyStatusActive {
t.Fatalf("被吸收行 %s 状态 = %q, want active(整簇 skip,一行都不写)", id, got)
}
}
if got, _ := store.GetTidyCandidate(ctx, cand.ID); got.Status != tidyCandidatePending {
t.Fatalf("候选状态 = %q, want pending(skip 不该消费掉候选)", got.Status)
}
})
}
}