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。
168 lines
6.2 KiB
Go
168 lines
6.2 KiB
Go
package memory
|
||
|
||
import (
|
||
"sort"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// 本文件是 `dedupe_group` 的**取值门**(2026-10-25,r1 复审 P1 的落点):
|
||
// 合并前必须确认「被吸收行里的**取值**」在存活行里一个不少。
|
||
//
|
||
// 动因是一次**会毁数据**的实测:生产语料里 `tag_set` 簇(同 tag 集)把
|
||
// `用户的昵称是「叶酱」。` 并进了 `主人的昵称是「喵喵七号」…` 的存活行 —— 两条
|
||
// **同属性、不同取值**(design/23 §5.2-4/§5.2-5 的绝不合并清单),而同库另有 4 行
|
||
// 明确写着「喵喵七号已作废、应称叶酱」。一次 apply 就会让当前事实消失,并留下与
|
||
// 库内其它行直接矛盾的结论。
|
||
//
|
||
// 根因不是某条词表写漏,而是**判据缺一件**:design/23 §5.1-3 要求「专有名词、
|
||
// 数值、否定词」逐一比对,而实现只有后两件(`sameNumbers` / `sameNegations`)。
|
||
// 本文件的取值门补的就是「专有名词」那一件,因此它对**两种簇判据都生效**
|
||
//(不是只对 prefix12 生效的相似度下限那种补丁)。
|
||
//
|
||
// 方向是**单向**的(被吸收 ⊆ 存活),与软合并的语义同向:合并后存活行的正文
|
||
// 原样保留,所以**存活行多出来的取值不会丢**;会丢的只有「被吸收行有、存活行没有」
|
||
// 的那些取值。因此判据是「被吸收行的每个取值都出现在存活行里」,而不是「两侧取值
|
||
// 集合相等」—— 后者会把「超集吸收子集」这个正常方向也挡掉。
|
||
|
||
// dedupeValueGaps 返回被吸收行里**在存活行正文中缺失**的取值(空 = 可以合并)。
|
||
//
|
||
// 取值有两类,都是 design/23 §5.1-3 说的「专有名词」的可机械抓手:
|
||
//
|
||
// 1. **引号内取值**(`「」` / `『』` / `“”` / `‘’` / ASCII `"`):昵称、暗号、口令、
|
||
// 代号、台词这类「同一属性的一组取值」在语料里几乎总被引号括起来 ——
|
||
// `「喵喵七号」` vs `「叶酱」` 就是这一类的正例;
|
||
// 2. **拉丁 token**(≥2 个字母的 ASCII 词,含 `h3R3` / `AAA` 这种混排):
|
||
// 人名 / 型号 / 代号 / 英文标题。中文正文里它们不会被分词歧义吃掉。
|
||
//
|
||
// **刻意不收**的:ASCII 单引号 `'`(英文所有格 `assistant's` 会被误配成引号对,
|
||
// 于是整句剩余部分都要在存活行里出现 —— 方向虽安全,但会把大量英文条目挡在簇外);
|
||
// 中文数字(`十点` vs `十一点`):既有 `sameNumbers` 只认 ASCII 数字 token,
|
||
// 扩它属于改既有口径,不在本切片。
|
||
//
|
||
// 比较用**归一化正文**(小写 + 空白折叠,同 dedupeKey):取值比对不该被大小写或
|
||
// 多余空格左右。
|
||
func dedupeValueGaps(absorbed, survivor string) []string {
|
||
haystack := dedupeKey(survivor)
|
||
if haystack == "" {
|
||
return []string{"(empty survivor)"}
|
||
}
|
||
gaps := make([]string, 0, 2)
|
||
seen := make(map[string]struct{}, 4)
|
||
for _, value := range dedupeValues(absorbed) {
|
||
if _, dup := seen[value]; dup {
|
||
continue
|
||
}
|
||
seen[value] = struct{}{}
|
||
if !strings.Contains(haystack, value) {
|
||
gaps = append(gaps, value)
|
||
}
|
||
}
|
||
sort.Strings(gaps)
|
||
return gaps
|
||
}
|
||
|
||
// 取值门的两个阈值。
|
||
const (
|
||
// dedupeQuoteMaxRunes 是「当作取值」的引号内容长度上限(rune 计):取值门的靶子
|
||
// 是短标识(昵称/暗号/代号/型号),一整段引文作为取值只会把「同一件事的不同引述」
|
||
// 全挡掉。超过它的引号内容**不产生取值**(即不用它否决合并)。
|
||
dedupeQuoteMaxRunes = 24
|
||
|
||
// dedupeLatinMinRunes 是「当作取值」的拉丁 token 最小长度:单字母(`a`、`x`)
|
||
// 在正文里到处都是,收它等于给每个簇加一道随机否决。
|
||
dedupeLatinMinRunes = 2
|
||
)
|
||
|
||
// dedupeValues 抽出一段正文里的**取值**(引号内取值 + 拉丁 token),已归一化为小写。
|
||
//
|
||
// 引号配对按「开 → 就近的闭」:中文引号有明确的开关字符,配不上对的那一段
|
||
// 整个丢弃(解析失败时**不产生取值**,因此不会凭它否决合并 —— 少一条取值门
|
||
// 比凭空造一条安全门更不容易误伤)。
|
||
func dedupeValues(content string) []string {
|
||
text := dedupeKey(content)
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, 4)
|
||
out = append(out, dedupeQuotedValues(text)...)
|
||
out = append(out, dedupeLatinTokens(text)...)
|
||
return out
|
||
}
|
||
|
||
// dedupeQuotePairs 是引号的开关对(同一字符既是开也是闭的只有 ASCII `"`)。
|
||
func dedupeQuotePairs() [][2]rune {
|
||
return [][2]rune{
|
||
{'「', '」'},
|
||
{'『', '』'},
|
||
{'“', '”'},
|
||
{'‘', '’'},
|
||
{'"', '"'},
|
||
}
|
||
}
|
||
|
||
// dedupeQuotedValues 抽出所有**成对**引号里的内容(去首尾空白、丢弃空串与超长串)。
|
||
//
|
||
// 超长串(> dedupeQuoteMaxRunes)丢弃是刻意的:一整段引文(台词、段落)作为取值门
|
||
// 会把「同一件事的不同引述」全挡掉,而取值门的靶子是**短标识**(昵称/暗号/代号)。
|
||
func dedupeQuotedValues(text string) []string {
|
||
runes := []rune(text)
|
||
out := make([]string, 0, 2)
|
||
for i := 0; i < len(runes); i++ {
|
||
open := runes[i]
|
||
var closer rune
|
||
switch open {
|
||
case '「':
|
||
closer = '」'
|
||
case '『':
|
||
closer = '』'
|
||
case '“':
|
||
closer = '”'
|
||
case '‘':
|
||
closer = '’'
|
||
case '"':
|
||
closer = '"'
|
||
default:
|
||
continue
|
||
}
|
||
for j := i + 1; j < len(runes); j++ {
|
||
if runes[j] != closer {
|
||
continue
|
||
}
|
||
value := strings.TrimSpace(string(runes[i+1 : j]))
|
||
if value != "" && len([]rune(value)) <= dedupeQuoteMaxRunes {
|
||
out = append(out, value)
|
||
}
|
||
i = j
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// dedupeLatinTokens 抽出 ≥2 个字母的 ASCII 词(含 `h3R3` 这类字母+数字混排)。
|
||
func dedupeLatinTokens(text string) []string {
|
||
out := make([]string, 0, 2)
|
||
var cur []rune
|
||
hasLetter := false
|
||
flush := func() {
|
||
if hasLetter && len(cur) >= dedupeLatinMinRunes {
|
||
out = append(out, string(cur))
|
||
}
|
||
cur = nil
|
||
hasLetter = false
|
||
}
|
||
for _, r := range text {
|
||
if r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
|
||
cur = append(cur, r)
|
||
if unicode.IsLetter(r) {
|
||
hasLetter = true
|
||
}
|
||
continue
|
||
}
|
||
flush()
|
||
}
|
||
flush()
|
||
return out
|
||
}
|