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

168 lines
6.2 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"
"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
}