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

105 lines
4.6 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 (
"sort"
"time"
)
// 本文件是 `dedupe_group` 的**候选构造**侧:从一簇成员里挑出可并入的行、
// 并把簇转成候选(落库形状与 merge 候选同构,因此面板/应用/回滚三条路都不用改)。
// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
// 判据与注释**逐字未动**;簇形成在 tidy_dedupe.go,元信息查询在 tidy_dedupe_meta.go。
// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。
//
// 三道门都是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」,
// 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**,
// 因此它**不进这一簇**(留待人工),而不是让整簇作废:
//
// 1. **数值**(§5.2-2,sameNumbers):数值不同即不同事实;
// 2. **否定**(§5.2-3,sameNegations):「X」vs「非X」是覆盖不是重复;
// 3. **取值**(§5.2-4/§5.2-5,dedupeValueGaps):被吸收行的引号取值 / 拉丁 token
// 必须在存活行里一个不少 —— 这条是 r1 复审 P1 的落点(`「叶酱」` 不许并进
// `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。
//
// 相似度下限对**两种簇都生效**(`dedupeGroupMinSimilarity`):tag 集相同只是
// 「同主题」的弱证据(体检报告 §1.3 的「冰美式 vs 红牛」也同 tag 集),内容差得
// 太远的一律不进簇。「同开头」比「同 tag 集」更弱,因此它用的是更严的那一档。
func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedupeMember) []tidyDedupeMember {
out := make([]tidyDedupeMember, 0, len(members))
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if !sameNumbers(it.item.Content, survivor.item.Content) {
continue
}
if !sameNegations(it.item.Content, survivor.item.Content) {
continue
}
if len(dedupeValueGaps(it.item.Content, survivor.item.Content)) > 0 {
continue
}
if lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupMinSimilarity(rule) {
continue
}
out = append(out, it)
}
// 被吸收行的顺序也必须是确定的(幂等键按 sorted(source_ids) 算,但证据里的
// dedupe_members 会原样落库,顺序抖动会让同一个簇每次产出不同的证据文本)。
sort.Slice(out, func(i, j int) bool { return out[i].item.ID < out[j].item.ID })
return out
}
// dedupeGroupMinSimilarity 返回一种簇判据的相似度下限(词项余弦)。
//
// 两档刻意不同:「完全相同 tag 集」是结构化证据(同主题的强信号),容忍改写得远一点;
// 「前 12 字相同」只是「开头一样」,必须要求正文整体也相近,否则同开头不同事实
// (`助手(persona)的说话风格…` vs `助手(persona)的象征花…`)会被合掉。
func dedupeGroupMinSimilarity(rule string) float64 {
if rule == tidyDedupeRulePrefix {
return dedupeGroupPrefixSimilarity
}
return dedupeGroupTagSetSimilarity
}
// dedupeGroupCandidate 把一个簇转成候选。
//
// 落库字段与 merge 候选同构,因此既有面板/应用/回滚三条路都不用改形状:
// `source_ids` = 被吸收行(**可多条**,`applyTidyDedupeGroup` 逐条软合并),
// `target_id` = 存活行,`merged_content` = 存活行正文(**绝不改正文**)。
func dedupeGroupCandidate(cl tidyDedupeCluster, now time.Time) TidyCandidate {
absorbedIDs := make([]string, 0, len(cl.Absorbed))
members := make([]string, 0, len(cl.Absorbed))
for _, it := range cl.Absorbed {
absorbedIDs = append(absorbedIDs, it.ID)
members = append(members, it.Content)
}
return TidyCandidate{
ID: mustNewTidyID(),
PersonaID: cl.Survivor.PersonaID,
Scope: cl.Survivor.Scope,
ProjectID: cl.Survivor.ProjectID,
Kind: cl.Survivor.Kind,
Action: TidyDedupeGroup,
SourceIDs: absorbedIDs,
TargetID: cl.Survivor.ID,
ProposedContent: cl.Survivor.Content,
MergedContent: cl.Survivor.Content,
Reason: tidyReasonDedupeGroup,
Evidence: TidyEvidence{
Containment: "none",
ContentA: cl.Survivor.Content,
DedupeRule: cl.Rule,
DedupeKey: cl.Key,
ClusterSize: len(cl.Absorbed) + 1,
KeptReason: cl.KeptReason,
DedupeMembers: members,
KindA: string(cl.Survivor.Kind),
},
RuleVersion: TidyRuleVersion,
Status: tidyCandidatePending,
CreatedAt: now,
}
}