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。
This commit is contained in:
1 parent
6a3f3a329a
commit
54e32304f2
12 files changed
+704
-147
No files matched your search
@@ -181,8 +181,8 @@ func TestTidyDryRunReportsDedupeGroupsWithoutWriting(t *testing.T) {
|
||||
tags := []string{"宠物", "小茂密"}
|
||||
for i, content := range []string{
|
||||
"小茂密是白毛的小猫,喜欢撒娇",
|
||||
"茂密的蝴蝶结最近换成了深色",
|
||||
"茂密爱坐在窗边看外面的风景",
|
||||
"白毛小茂密爱撒娇,是只小猫",
|
||||
"爱撒娇的小猫名叫小茂密,通体白毛",
|
||||
} {
|
||||
seedDedupeItem(t, store, dedupeTestItem(content, tags, base.Add(time.Duration(i)*time.Minute)))
|
||||
}
|
||||
@@ -263,9 +263,9 @@ func TestTidyRunReportsDedupeTruncation(t *testing.T) {
|
||||
tags []string
|
||||
}{
|
||||
{"小茂密是白毛的小猫,喜欢撒娇", []string{"宠物", "小茂密"}},
|
||||
{"茂密的蝴蝶结最近换成了深色", []string{"宠物", "小茂密"}},
|
||||
{"主人对花溪的住处很满意", []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)))
|
||||
@@ -287,3 +287,67 @@ func TestTidyRunReportsDedupeTruncation(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user