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
@@ -84,6 +84,27 @@ const (
|
||||
// 「要不要自动判同一簇」,两者风险不同 —— 但**两者都不是判定依据**(簇的同一性
|
||||
// 由「前 12 字」给出),这条下限只是把明显不同的对排除掉。
|
||||
dedupeGroupPrefixSimilarity = 0.70
|
||||
|
||||
// dedupeGroupTagSetSimilarity 是 **tag 集簇**成员的相似度下限(r1 复审 P1 要求
|
||||
// 「tag_set 簇也要过相似度下限,不能只有 prefix12 走这道门」)。
|
||||
//
|
||||
// ⚠️ 它取 **0.10**,比前缀簇那档(0.70)低一个量级,而且**刻意如此** ——
|
||||
// 语料实测(active 3667、已过取值门/数值门/否定门的 503 对 tag 集成员对):
|
||||
//
|
||||
// floor 0.10 → 保留 98% floor 0.20 → 86% floor 0.30 → 63%
|
||||
// floor 0.40 → 40% floor 0.50 → 20%
|
||||
//
|
||||
// 而**挡住的那 60% 全是同一事实的改写**,不是「不同事实」——例如
|
||||
// `用户可能需要提醒吃早饭,容易空着肚子出门。`(0.047)对
|
||||
// `早餐习惯:吃半碗炒面,需搭配牛奶或鸡蛋…`,或 `叶酱有工作,白天要上班…`(0.109)
|
||||
// 对 `主人是上班族,工作日上班、单位有食堂…`。原因是语料里同一事实的多种写法
|
||||
// 共享的是**语义**而不是二元组,词项余弦天然偏低。
|
||||
//
|
||||
// ⇒ 这道门的定位被数据改了:它只挡「正文几乎没有任何共同词项」的退化对
|
||||
// (同 tag 却各说各话),**不是**同一性判据。同一性由三件套决定:数值门、
|
||||
// 否定门、(P1 新增的)**取值门**。抬高这道门只会删掉合法去重、并不会多挡住
|
||||
// 危险对 —— 需要更准的判据时该走 T1 模型层(不在本切片)。
|
||||
dedupeGroupTagSetSimilarity = 0.10
|
||||
)
|
||||
|
||||
// dedupe_group 的证据字段(进 TidyEvidence,供面板 diff 预览与运维判读)。
|
||||
@@ -139,13 +160,19 @@ func (t *Tidier) dedupeInPartition(ctx context.Context, p tidyPartition, items [
|
||||
covered[c.TargetID] = struct{}{}
|
||||
}
|
||||
}
|
||||
// merge_count 是存活行的**次要**判据(「内容最长」并列时才用它)。缺它时降级到
|
||||
// created_at 仍能给出确定性的存活行,因此这里降级继续、但如实记 WARN。
|
||||
// 元信息(merge_count + `expires_at` 是否非空)**不是可降级的旁支**:它承载的是
|
||||
// 「临时层行绝不进簇」这条数据安全红线(临时行 7 天后会被后台物理删除,
|
||||
// design/23 §19;让长期事实并进一个将被删除的存活行 = 信息不可达)。
|
||||
//
|
||||
// 因此查询失败时**这个分区这一轮不分簇**(fail-safe 的方向是「少做」而不是
|
||||
// 「猜」):成本只是下一轮再来,而猜错的代价是不可逆的。
|
||||
// 这是 r1 复审 P2-1 的落点 —— 旧实现降级成 nil map 后 `temporary` 恒为 false,
|
||||
// 恰好把那道红线绕开了。
|
||||
meta, err := t.deps.Store.dedupeItemMeta(ctx, p)
|
||||
if err != nil {
|
||||
t.log.Warn("memory tidy dedupe item meta unavailable, clustering without merge_count/temporary flags",
|
||||
t.log.Warn("memory tidy dedupe skipped: item meta unavailable",
|
||||
"partition", p.label(), "error", err)
|
||||
meta = nil
|
||||
return nil, tidyDedupeStats{}
|
||||
}
|
||||
return t.dedupeCandidates(items, meta, covered, t.now().UTC())
|
||||
}
|
||||
@@ -307,84 +334,6 @@ func dedupeGroupEligible(it MemoryItem) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// dedupeClusterKeys 返回一条记忆的簇键(tag 集 / 前 12 字)。两条判据都可能不成立
|
||||
// (tag 集不合格 / 内容短于 12 字),那时只返回另一条。
|
||||
func dedupeClusterKeys(it MemoryItem) []tidyDedupeKey {
|
||||
out := make([]tidyDedupeKey, 0, 2)
|
||||
if key := dedupeTagKey(it); key != "" {
|
||||
out = append(out, tidyDedupeKey{rule: tidyDedupeRuleTagSet, key: key})
|
||||
}
|
||||
if key := dedupePrefixKey(it); key != "" {
|
||||
out = append(out, tidyDedupeKey{rule: tidyDedupeRulePrefix, key: key})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeTagKey 是「同分区 + 同 kind + 完全相同 tag 集」的键。tag 集为空/太通用时返回空串。
|
||||
//
|
||||
// 键里带**分区**(persona/scope/project)与 kind,两个都是**纵深防御**:
|
||||
// - 调用方本来就按分区逐块调用(design/23 §6「分区之间永不比较」),但候选键
|
||||
// 自带分区后,任何跨分区的输入(手工调用、将来别的入口)也**无法成簇** ——
|
||||
// 跨 scope 收敛是塔已明确不做的那一批,它不许只靠调用纪律保证;
|
||||
// - kind 是**内容类别**(relationship / world_event):跨 kind 的簇合并会顺手改掉
|
||||
// 被吸收行的分类语义,而分类改动只该走 reclassify(§5.1 T0-6)。
|
||||
func dedupeTagKey(it MemoryItem) string {
|
||||
tags := normalizedTidyTags(it.Tags)
|
||||
if len(tags) < dedupeGroupMinTagCount {
|
||||
return ""
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if len([]rune(tag)) < dedupeGroupMinTagRunes {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + strings.Join(tags, "\x1e")
|
||||
}
|
||||
|
||||
// dedupePartitionPrefix 是簇键里的分区段(persona + scope + project)。
|
||||
func dedupePartitionPrefix(it MemoryItem) string {
|
||||
return it.PersonaID + "\x1e" + string(it.Scope) + "\x1e" + it.ProjectID + "\x1e"
|
||||
}
|
||||
|
||||
// normalizedTidyTags 归一化 tag 集:去空白、去空项、去重、排序。排序是幂等键稳定
|
||||
// 的前提(同一 tag 集的不同书写顺序必须落到同一个键)。
|
||||
func normalizedTidyTags(tags []string) []string {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
out := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
t := strings.TrimSpace(tag)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[t]; dup {
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupePrefixKey 是「**同分区同 kind** + 前 12 个字相同」的键(按**归一化正文**取前 12 个 rune)。
|
||||
//
|
||||
// 归一化复用 dedupeKey(小写 + 空白折叠):`助手名为 Cyrene。` 与 `助手名为 Cyrene`
|
||||
// 不该因为多一个空格就落到两个簇。
|
||||
//
|
||||
// 短于 12 字的内容返回空串**而不受理**:那时「前 12 字」等于全文,簇会退化成
|
||||
// 「逐字重复」——那是 §15 D3-2 T0-1 的 `skip`(入库闸)与 `merge`(字面重复自动档)
|
||||
// 已经在管的事,不需要第二个动作重复它。
|
||||
func dedupePrefixKey(it MemoryItem) string {
|
||||
runes := []rune(dedupeKey(it.Content))
|
||||
if len(runes) < dedupeGroupPrefixRunes {
|
||||
return ""
|
||||
}
|
||||
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + string(runes[:dedupeGroupPrefixRunes])
|
||||
}
|
||||
|
||||
// tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。
|
||||
//
|
||||
// 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store.go 的 13 列)**不含**
|
||||
|
||||
Reference in new issue
Block a user