fix(memory): dedupe_group 补中文数字门(量纲/单位归一),修 r2 复审 p2

r2 复审 p2:`sameNumbers` 只抽 ASCII 数字 token,而本语料的数字主要写成中文
(一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒「午睡一小时 vs 十五分钟」
「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**被当成同一事实合并。

修法(新文件 internal/runtime/memory/tidy_dedupe_numbers.go):
- 抽出「**中文数字 + 单位**」对(一~九/两/半/十/百/千/万,含十五、二十四、一万两千、
  半个小时),按**量纲归一**后比较:时间=秒(分钟 60 / 小时 3600 / 天 86400 /
  周 604800 / 个月 2592000 / 年 31536000)、长度=米、文字量=个、计数=个;
- 只认**后面紧跟已知单位**的数字:`一起 / 一直 / 一些 / 一样 / 第二天(序数位置)`
  这类含数字的习语一律不产生数量 —— 不做成「有中文数字就不合并」;
- 判据是**冲突**而非差异:存活行在同量纲里没有对应数量 ⇒ 不算冲突(被吸收行多出
  的细节);等值(含单位换算后等值,如「半小时」=1800 秒)⇒ 照常合并;
  值不等且单位换算不到一起 ⇒ 按冲突处理(复审口径:宁可不合并);
- 只**增加否决**,不放宽任何既有门;ASCII 路径仍由 sameNumbers 先判,一字未动;
  `sameNumbers` 本身也未改(judgePair/judgeIngest 共用它,属于另一处 blast radius,
  已在回报里登记为后续建议)。

语料实测(生产库只读 mode=ro,active 3667):999 对里中文数字门挡掉 **17 对**,
其中真冲突:午睡十五分钟 vs 一小时 ×3、咖啡一杯 vs 第二杯 ×4、
「三千次轮回」vs「三千万次轮回」×1(ASCII 路径完全看不见这条);
其余 9 对是「单位不可换算」的保守拒绝(半碗 vs 一个包/一杯、一次 vs 两圈)。
dedupe 产出 255 簇/505 行 → **250 簇/495 行(13.5%)**。

回归用例(去掉这道门即红,已实测):
- TestDedupeGroupRejectsConflictingChineseNumbers(复审给的两对真实语料,脱敏保留原意)
- TestDedupeGroupRejectsNonConvertibleUnits(两杯 vs 三瓶 ⇒ 保守不合并)
- TestDedupeGroupSameChineseNumberStillMerges(同值/换算后同值 ⇒ 照常合并,门没退化成「有数字就不合」)
- TestTidyQuantitiesChineseNumerals(解析口径:一小时=3600s、十五分钟=900s、半小时=1800s、
  一万两千行=12000、第二天/一起/一直 不产生数量)

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
This commit is contained in:
AskaEth committed 2026-10-25 00:41:16 +08:00
1 parent 54e32304f2
commit 7700a7eda0
5 files changed
+601 -86

No files matched your search

@@ -21,6 +21,10 @@ import (
// 3. **取值**(§5.2-4/§5.2-5,dedupeValueGaps):被吸收行的引号取值 / 拉丁 token
// 必须在存活行里一个不少 —— 这条是 r1 复审 P1 的落点(`「叶酱」` 不许并进
// `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。
// 4. **中文数字**(§5.2-2 的补充,dedupeNumberConflicts):`sameNumbers` 只抽
// ASCII 数字 token,而语料里的数字大多写成中文(一小时 / 十五分钟 / 第二杯)——
// r2 复审实测约 5 对真实数值冲突因此被合并。这道门按**量纲 + 单位换算**比,
// 单位换算不到一起就按冲突处理(标准是「宁可不合并」)。
//
// 相似度下限对**两种簇都生效**(`dedupeGroupMinSimilarity`):tag 集相同只是
// 「同主题」的弱证据(体检报告 §1.3 的「冰美式 vs 红牛」也同 tag 集),内容差得
@@ -40,6 +44,9 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu
if len(dedupeValueGaps(it.item.Content, survivor.item.Content)) > 0 {
continue
}
if len(dedupeNumberConflicts(it.item.Content, survivor.item.Content)) > 0 {
continue
}
if lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupMinSimilarity(rule) {
continue
}
@@ -0,0 +1,370 @@
package memory
import "fmt"
// 本文件是 `dedupe_group` 的**中文数字门**(2026-10-25,r2 复审 P2 的落点):
// 数值冲突不仅要认出 ASCII 数字,还要认出中文数字,并且按**量纲/单位**归一后比较。
//
// 动因是一次实测:`sameNumbers`(tidy_containment.go)只抽 ASCII 数字 token,
// 而本语料的数字主要写成中文(一小时 / 十五分钟 / 第二杯 / 一辈子)——
// 于是「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**
// 被当成同一事实合并掉。复审在 22 个簇里量到约 5 对真实冲突。
//
// 🔴 **两道自我约束**(复审明确要求):
// 1. **不做成「两边都有中文数字就一律不合并」**:那样等于把规则废掉。判据是
// 「**同一个量纲**里,值不相等」才算冲突;两边都写「一杯」照样合。
// 2. **只在数值上收紧,不放宽别的门**:本门只**增加**否决,不改变任何「原本会被
// 否决的对」(ASCII 路径仍由 `sameNumbers` 先判,一字未动)。
//
// 形态判据是「**中文数字 + 单位**」,而不是「中文数字」:
// - 只认后面紧跟已知单位(小时/分钟/杯/次/份…)的数字,因此「一起」「一直」
// 「一些」「一样」「一部分」这类**含数字的常见词**不会被当成数量;
// - 数字后面没有单位时**不产生数量**(哪怕它是真的数量):NL 里的裸中文数字
// 绝大多数是习语,收它会把精确度换成一堆误否决。
//
// 单位不可换算时按**保守方向**处理(复审口径「比不了单位就不许合并」):同一个
// 量纲里值不相等、单位又换算不到一起 ⇒ 一律算冲突。
// tidyQuantity 是一处「数量」:归一后的值 + 单位名 + 量纲。
type tidyQuantity struct {
// value 是按单位换算到**量纲基准单位**后的值(时间=秒、计数=个、长度=米)。
value float64
// unit 是单位名(用于把冲突原因写清楚,也是「能不能换算」的判据)。
unit string
// dim 是量纲(time / count / length / text)。同量纲才可比。
dim string
// raw 是原始写法(一~数字+单位),只用于日志/测试断言。
raw string
}
// dedupeNumberConflicts 返回被吸收行与存活行之间的**数值冲突**(空 = 无冲突)。
//
// 判据(逐条被吸收行的数量):
// 1. 存活行在**同一量纲**里没有任何数量 ⇒ 不算冲突(被吸收行多出来的细节,
// 合并不改变存活行正文,因此不算「冲突」,与取值门的单向口径一致);
// 2. 存活行在同一量纲里有**等值**的数量 ⇒ 不算冲突(单位可以不同:三份 vs 三张);
// 3. 其余 ⇒ 冲突(值不等;单位换算不到一起时同样算冲突,方向保守)。
func dedupeNumberConflicts(absorbed, survivor string) []string {
have := tidyQuantities(survivor)
if len(have) == 0 {
return nil
}
gaps := make([]string, 0, 2)
for _, q := range tidyQuantities(absorbed) {
same, comparable, sameDim := false, false, false
for _, h := range have {
if h.dim != q.dim {
continue
}
sameDim = true
if sameQuantity(q, h) {
same = true
break
}
if convertibleUnits(q, h) {
comparable = true
}
}
if same || !sameDim {
// 等值 ⇒ 不是冲突;同量纲里根本没有可比对的数量 ⇒ 也**不是**冲突
//(被吸收行多出来的细节,合并不改存活行正文,因此不算「冲突」)。
// 这一条是「不同量纲不算冲突」的落点:`四行歌词` 对 `一段歌词/心事`
// 里 `行`(文字量)与 `段`(计数)根本不是同一回事。
continue
}
if comparable {
gaps = append(gaps, fmt.Sprintf("%s≠同量纲的另一取值", q.raw))
continue
}
gaps = append(gaps, fmt.Sprintf("%s 与存活行的同量纲数量不可换算", q.raw))
}
return gaps
}
// sameQuantity 报告两处数量是否等值(同量纲已由调用方保证)。
func sameQuantity(a, b tidyQuantity) bool {
if a.dim != b.dim {
return false
}
if a.value == b.value {
return true
}
// 浮点比较留一点余量:换算会乘除(半小时 = 0.5×3600 秒),不做精确相等。
diff := a.value - b.value
if diff < 0 {
diff = -diff
}
return diff < 0.5
}
// convertibleUnits 报告两处数量的单位能不能换算到一起(同一量纲里,计数类单位
// 之间**不能**互换:两杯 ≠ 三次)。
func convertibleUnits(a, b tidyQuantity) bool {
if a.dim != b.dim {
return false
}
if a.dim == tidyDimCount {
// 计数器的量纲相同但单位不可互换(杯/次/份/张…)——除非本来就是同一个单位。
return a.unit == b.unit
}
return tidyUnitFactor(a.dim, a.unit) > 0 && tidyUnitFactor(b.dim, b.unit) > 0
}
// tidyQuantities 抽出一段正文里所有的「中文数字 + 单位」。
func tidyQuantities(text string) []tidyQuantity {
runes := []rune(dedupeKey(text))
out := make([]tidyQuantity, 0, 4)
for i := 0; i < len(runes); i++ {
value, next, ok := parseChineseNumberAt(runes, i)
if !ok {
continue
}
unit, dim, factor, unext, uok := tidyUnitAt(runes, next)
if !uok {
// 数字后面没有已知单位:不当作数量(习语占绝对多数)。
i = next - 1
continue
}
if tidyOrdinalPosition(runes, i, dim) {
// 「第二天」「第三周」是**序数位置**,不是时长。
i = unext - 1
continue
}
raw := string(runes[i:unext])
out = append(out, tidyQuantity{value: value * factor, unit: unit, dim: dim, raw: raw})
i = unext - 1
}
return out
}
// tidyOrdinalPosition 报告这个数字是不是「第X」式的**序数位置**(只对时间量纲的
// 天/周/月/年成立:「第二天」是次序,不是两天)。序数 + 计数量词(第二杯)仍是数量。
func tidyOrdinalPosition(runes []rune, idx int, dim string) bool {
if dim != tidyDimTime || idx == 0 {
return false
}
return runes[idx-1] == '第'
}
// 量纲名(同量纲才可比)。
const (
tidyDimTime = "time"
tidyDimCount = "count"
tidyDimLength = "length"
tidyDimText = "text"
)
// tidyUnitAt 在 text[at:] 处匹配一个已知单位(**最长优先**:`个月` 先于 `个`)。
// 返回单位名、量纲、换算到量纲基准的系数与匹配结束下标。
func tidyUnitAt(text []rune, at int) (string, string, float64, int, bool) {
best := ""
var bestDim string
var bestFactor float64
bestEnd := at
for _, u := range tidyUnitTable() {
if at+len([]rune(u.name)) > len(text) {
continue
}
if string(text[at:at+len([]rune(u.name))]) != u.name {
continue
}
if len([]rune(u.name)) > len([]rune(best)) {
best, bestDim, bestFactor, bestEnd = u.name, u.dim, u.factor, at+len([]rune(u.name))
}
}
if best == "" {
return "", "", 0, at, false
}
if best == "个" {
// 「个」在「一个小时 / 一个星期」里是**量词**而不是计数单位:它后面若紧跟
// 另一个单位,以那个单位为准(否则「一个小时」会被读成「一个」而丢掉时长)。
if inner, dim, factor, end, ok := tidyUnitAt(text, bestEnd); ok {
return inner, dim, factor, end, true
}
}
return best, bestDim, bestFactor, bestEnd, true
}
// tidyUnit 是单位表的一行。
type tidyUnit struct {
name string
dim string
factor float64
}
// tidyUnitTable 是已知单位表(换算到量纲基准单位:时间=秒、计数=个、长度=米)。
//
// 月份/年用**约定值**(30 天 / 365 天)换算:它们在语料里是「多久」的意思,
// 不换算就变成「比不了」,那会让「半年 vs 六个月」也被判冲突 —— 虽然方向保守,
// 但会把一条正确事实挡在簇外。约定值让它们可比。
func tidyUnitTable() []tidyUnit {
return []tidyUnit{
// 时间(基准:秒)
{"秒钟", tidyDimTime, 1},
{"秒", tidyDimTime, 1},
{"分钟", tidyDimTime, 60},
{"分", tidyDimTime, 60},
{"小时", tidyDimTime, 3600},
{"钟头", tidyDimTime, 3600},
{"天", tidyDimTime, 86400},
{"日", tidyDimTime, 86400},
{"周", tidyDimTime, 604800},
{"星期", tidyDimTime, 604800},
{"礼拜", tidyDimTime, 604800},
{"个月", tidyDimTime, 2592000},
{"年", tidyDimTime, 31536000},
// 计数(基准:个;**单位之间不互换**,见 convertibleUnits)
{"次", tidyDimCount, 1},
{"遍", tidyDimCount, 1},
{"圈", tidyDimCount, 1},
{"步", tidyDimCount, 1},
{"杯", tidyDimCount, 1},
{"碗", tidyDimCount, 1},
{"口", tidyDimCount, 1},
{"份", tidyDimCount, 1},
{"张", tidyDimCount, 1},
{"条", tidyDimCount, 1},
{"件", tidyDimCount, 1},
{"个", tidyDimCount, 1},
{"位", tidyDimCount, 1},
{"只", tidyDimCount, 1},
{"颗", tidyDimCount, 1},
{"瓶", tidyDimCount, 1},
{"盒", tidyDimCount, 1},
{"包", tidyDimCount, 1},
{"岁", tidyDimCount, 1},
{"层", tidyDimCount, 1},
{"段", tidyDimCount, 1},
{"篇", tidyDimCount, 1},
{"首", tidyDimCount, 1},
{"集", tidyDimCount, 1},
// 长度(基准:米)
{"公里", tidyDimLength, 1000},
{"千米", tidyDimLength, 1000},
{"米", tidyDimLength, 1},
{"厘米", tidyDimLength, 0.01},
// 文字量(基准:个)
{"行", tidyDimText, 1},
{"字", tidyDimText, 1},
{"页", tidyDimText, 1},
}
}
// tidyUnitFactor 返回单位在它量纲里的换算系数(0 = 不认识的单位)。
func tidyUnitFactor(dim, unit string) float64 {
for _, u := range tidyUnitTable() {
if u.dim == dim && u.name == unit {
return u.factor
}
}
return 0
}
// parseChineseNumberAt 在 text[at:] 处解析一个中文数字(含「半」),返回数值与结束下标。
//
// 覆盖语料里真实出现的形态:一/两/三…九、十、十五、二十、二十四、一百二十、两千、
// 一万两千、半。**不认**「几/十几/数/好几」这类模糊量词(它们不是可比数量)。
func parseChineseNumberAt(text []rune, at int) (float64, int, bool) {
if at >= len(text) {
return 0, at, false
}
if text[at] == '半' {
return 0.5, at + 1, true
}
total := 0.0 // 已结算的「万」以上段与整段合计
section := 0.0 // 当前小节(万以下)
digit := 0.0
seen := false
hasUnitRune := false
i := at
for ; i < len(text); i++ {
r := text[i]
if d, ok := chineseDigitValue(r); ok {
digit = d
seen = true
continue
}
switch r {
case '十':
if !seen {
digit = 1
}
section += digit * 10
digit = 0
seen = true
hasUnitRune = true
case '百':
if !seen {
digit = 1
}
section += digit * 100
digit = 0
seen = true
hasUnitRune = true
case '千':
if !seen {
digit = 1
}
section += digit * 1000
digit = 0
seen = true
hasUnitRune = true
case '万':
if !seen {
digit = 1
}
section += digit
total += section * 10000
section, digit = 0, 0
seen = true
hasUnitRune = true
default:
if !seen {
return 0, at, false
}
// 结束:把最后一个裸数字并进小节。
total += section + digit
if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 {
// 「两三」「三四」这类并列不是确定数量,按模糊处理(不认)。
return 0, at, false
}
return total, i, true
}
}
if !seen {
return 0, at, false
}
total += section + digit
if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 {
return 0, at, false
}
return total, i, true
}
// chineseDigitValue 是中文数字字符到值的映射(不含十百千万半)。
func chineseDigitValue(r rune) (float64, bool) {
switch r {
case '零', '〇':
return 0, true
case '一':
return 1, true
case '二', '两', '俩':
return 2, true
case '三':
return 3, true
case '四':
return 4, true
case '五':
return 5, true
case '六':
return 6, true
case '七':
return 7, true
case '八':
return 8, true
case '九':
return 9, true
default:
return 0, false
}
}
@@ -0,0 +1,128 @@
package memory
import (
"testing"
"time"
)
// 本文件是 `dedupe_group` **中文数字门**(r2 复审 P2)的验收用例集:
// 中位数写法下的数值冲突(一小时 vs 十五分钟、第二杯 vs 一杯)、单位不可换算的
// 保守拒绝、同值照常合并,以及中文数字解析口径本身。
// TestDedupeGroupRejectsConflictingChineseNumbers 是 r2 复审 **P2 的回归用例**:
// 语料里的数字大多写成**中文**,而 `sameNumbers` 只抽 ASCII 数字 token ⇒
// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**
// 曾被当成同一事实合并掉。
//
// 旧逻辑(只有 sameNumbers)下这些对会成簇 ⇒ 用例红;补中文数字门后不成簇 ⇒ 绿。
func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
cases := []struct {
name string
survivor string
member string
tagsA []string
tagsB []string
whyItReds string
}{
{
name: "午睡时长(一小时 vs 十五分钟)",
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
tagsA: []string{"作息", "午睡"},
tagsB: []string{"作息", "午睡"},
},
{
name: "咖啡杯数(第二杯 vs 一杯)",
survivor: "用户被约定:一天咖啡不许续第二杯,下午犯困时起身走动、喝温水。",
member: "用户有喝咖啡的习惯,助手提醒她每天不超过一杯(第二杯不许续)。",
tagsA: []string{"习惯", "咖啡"},
tagsB: []string{"习惯", "咖啡"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
a := dedupeTestItem(tc.survivor, tc.tagsA, base)
b := dedupeTestItem(tc.member, tc.tagsB, base.Add(time.Minute))
// 先钉住「这不是因为别的门挡住的」:数值门确实报出冲突。
if gaps := dedupeNumberConflicts(b.Content, a.Content); len(gaps) == 0 {
t.Fatalf("中文数字门没报出冲突:%q vs %q", b.Content, a.Content)
}
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(数值冲突=不同事实,§5.2-2)", len(cands))
}
})
}
}
// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」
// (r2 复审的明确口径):两杯 vs 三瓶是同一个计数量的不同取值,但杯/瓶不能互换。
func TestDedupeGroupRejectsNonConvertibleUnits(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"习惯", "饮水"}
a := dedupeTestItem("主人每天要喝两杯咖啡,下午不续。", tags, base)
b := dedupeTestItem("主人每天要喝三瓶水,下午也要喝。", tags, base.Add(time.Minute))
if gaps := dedupeNumberConflicts(b.Content, a.Content); len(gaps) == 0 {
t.Fatalf("单位不可换算没有报冲突:%q vs %q", b.Content, a.Content)
}
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(单位不可换算 ⇒ 保守不合并)", len(cands))
}
}
// TestDedupeGroupSameChineseNumberStillMerges 是中文数字门的**反向护栏**:
// 值相等(含不同单位换算后相等)照常成簇 —— 门收的是「冲突」,不是「出现数字」。
func TestDedupeGroupSameChineseNumberStillMerges(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"作息", "午睡"}
a := dedupeTestItem("主人午睡一小时,午后精神好。", tags, base)
b := dedupeTestItem("主人午睡一小时,下午很清醒。", tags, base.Add(time.Minute))
// 一条写「一个小时」、另一条写「一小时」:换算后同为 3600 秒,属于同一事实。
c := dedupeTestItem("主人午睡一个小时,午后精神不错。", tags, base.Add(2*time.Minute))
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b, c})
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1(同值照常合并 —— 门不许退化成「有中文数字就不合」)", len(cands))
}
}
// TestTidyQuantitiesChineseNumerals 钉住中文数字的解析口径(含单位换算与习语排除)。
func TestTidyQuantitiesChineseNumerals(t *testing.T) {
t.Parallel()
cases := []struct {
text string
value float64
unit string
dim string
ok bool
}{
{"主人午睡一小时", 3600, "小时", tidyDimTime, true},
{"主人午睡十五分钟", 900, "分钟", tidyDimTime, true},
{"主人午睡半个小时", 1800, "小时", tidyDimTime, true},
{"一万两千行的验收纸条", 12000, "行", tidyDimText, true},
{"每天要喝两杯咖啡", 2, "杯", tidyDimCount, true},
// 序数与习语:不是数量。
{"第二天再说", 0, "", "", false},
{"一起散步", 0, "", "", false},
{"一直加班", 0, "", "", false},
}
for _, tc := range cases {
got := tidyQuantities(tc.text)
if !tc.ok {
if len(got) != 0 {
t.Fatalf("%q 应不产生数量,得到 %+v", tc.text, got)
}
continue
}
if len(got) == 0 {
t.Fatalf("%q 没解析出数量", tc.text)
}
if got[0].value != tc.value || got[0].unit != tc.unit || got[0].dim != tc.dim {
t.Fatalf("%q 解析为 %+v, want value=%v unit=%s dim=%s",
tc.text, got[0], tc.value, tc.unit, tc.dim)
}
}
}
@@ -4,7 +4,6 @@ import (
"context"
"crypto/sha256"
"fmt"
"strings"
"testing"
"time"
)
@@ -310,91 +309,6 @@ func TestDedupeGroupIsNeverAutoApplied(t *testing.T) {
}
}
// TestDedupeGroupRejectsSameTagDifferentValue 是 r1 复审 **P1-1 的回归用例**:
// 同 tag 集、但**取值不同**(`「老K」` vs `「阿七」`)的两条**不许同簇**。
//
// 语料原型(脱敏):生产库里 `用户的昵称是「叶酱」。` 曾被并进
// `主人的昵称是「喵喵七号」…` 的存活行,而同库另有 4 行明确写着「旧的称呼
// 『喵喵七号』已作废,不要再使用」—— 一次 apply 就会让当前事实消失,并留下与库内
// 其它行直接矛盾的结论。因此这条用例钉的不是「少减一点」,而是「不许毁数据」。
func TestDedupeGroupRejectsSameTagDifferentValue(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"昵称", "称呼"}
// 取值不同:只剩存活行一条 ⇒ 不成簇、一条候选都不产。
survivor := dedupeTestItem("主人的昵称是「老K」,是相处中慢慢形成的亲昵称呼;具体来历不明,不可编造。", tags, base)
staleA := dedupeTestItem("用户的昵称是「阿七」。", tags, base.Add(time.Minute))
staleB := dedupeTestItem("主人被称呼为「阿七」,助手平时就这么叫她。", tags, base.Add(2*time.Minute))
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, staleA, staleB})
if len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(取值不同=不同事实,§5.2-4/§5.2-5)", len(cands))
}
for _, m := range []MemoryItem{staleA, staleB} {
if gaps := dedupeValueGaps(m.Content, survivor.Content); len(gaps) == 0 {
t.Fatalf("取值门没认出 %q 里的取值与存活行不同", m.Content)
}
}
// 对照组:同样的 tag 集 + **相同取值**,必须照常成簇(证明上面那条不是因为门太宽)。
sameValue := dedupeTestItem("主人被称呼为「老K」,助手平时就这么叫她。", tags, base.Add(3*time.Minute))
cands, _ = dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, sameValue})
if len(cands) != 1 {
t.Fatalf("对照组簇数 = %d, want 1(同 tag 同取值必须成簇)", len(cands))
}
if cands[0].TargetID != survivor.ID {
t.Fatalf("对照组存活行 = %s, want 最长的那条 %s", cands[0].TargetID, survivor.ID)
}
}
// TestDedupeGroupLatinTokenIsAlsoAValue 钉住取值门的第二类取值:**拉丁 token**
// (人名 / 型号 / 代号)。`助手名为 Cyrene` 与 `助手名为 Alice` 同 tag 集,但取值不同。
func TestDedupeGroupLatinTokenIsAlsoAValue(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"身份", "名字"}
keep := dedupeTestItem("助手名为 Cyrene,主人习惯这么叫我。", tags, base)
other := dedupeTestItem("助手名为 Alice,主人习惯这么叫我。", tags, base.Add(time.Minute))
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{keep, other}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(拉丁取值不同=不同事实)", len(cands))
}
}
// TestDedupeGroupNeverLosesAValue 是**簇级不变式**:任何产出的候选,被吸收行里的
// 每个取值(引号取值 + 拉丁 token)都必须出现在存活行正文里 —— 否则那条取值会随
// 合并从 active 集合里消失。它比单点用例强:新增的簇判据 / 门顺序变化都要过它。
func TestDedupeGroupNeverLosesAValue(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"暗号", "约定"}
items := []MemoryItem{
dedupeTestItem("用户与助手的验收暗号是「青柠」", tags, base),
dedupeTestItem("用户与助手的验收暗号是「青柠」,用于确认文档摄取正常", tags, base.Add(time.Minute)),
dedupeTestItem("验收暗号「青柠」用于确认文档摄取功能正常", tags, base.Add(2*time.Minute)),
// 同一 tag 集但取值不同(不许进簇)。
dedupeTestItem("用户与助手的验收暗号是「柠檬」", tags, base.Add(3*time.Minute)),
// 同 tag 集、取值包含了额外信息(不许进簇:那条取值会丢)。
dedupeTestItem("验收暗号「青柠」配合口令「开门」一起使用", tags, base.Add(4*time.Minute)),
}
cands, _ := dedupeCandsOf(t, newTestStore(t), items)
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1", len(cands))
}
survivor := cands[0].MergedContent
for _, member := range cands[0].Evidence.DedupeMembers {
for _, value := range dedupeValues(member) {
if !strings.Contains(dedupeKey(survivor), value) {
t.Fatalf("取值 %q 会随合并消失(member=%q survivor=%q)", value, member, survivor)
}
}
}
for _, member := range cands[0].Evidence.DedupeMembers {
if strings.Contains(member, "柠檬") || strings.Contains(member, "开门") {
t.Fatalf("不该被吸收的成员进了簇:%q", member)
}
}
}
// TestDedupeInPartitionSkipsWhenMetaUnavailable 是 r1 复审 **P2-1 前半**的回归:
// 元信息查询失败时**整个分区这一轮不分簇**(旧实现降级成 nil map,而 nil map 上
// `temporary` 恒为 false ⇒ 把「临时行绝不进簇」那条红线绕开了)。
@@ -0,0 +1,96 @@
package memory
import (
"strings"
"testing"
"time"
)
// 本文件是 `dedupe_group` **取值门**(r1 复审 P1)的验收用例集:
// 同属性不同取值(`「老K」` vs `「阿七」`)、拉丁取值、以及簇级的「取值不丢」不变式。
// 分簇/候选构造的用例在 tidy_dedupe_test.go,中文数字门在 tidy_dedupe_numbers_test.go。
// TestDedupeGroupRejectsSameTagDifferentValue 是 r1 复审 **P1-1 的回归用例**:
// 同 tag 集、但**取值不同**(`「老K」` vs `「阿七」`)的两条**不许同簇**。
//
// 语料原型(脱敏):生产库里 `用户的昵称是「叶酱」。` 曾被并进
// `主人的昵称是「喵喵七号」…` 的存活行,而同库另有 4 行明确写着「旧的称呼
// 『喵喵七号』已作废,不要再使用」—— 一次 apply 就会让当前事实消失,并留下与库内
// 其它行直接矛盾的结论。因此这条用例钉的不是「少减一点」,而是「不许毁数据」。
func TestDedupeGroupRejectsSameTagDifferentValue(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"昵称", "称呼"}
// 取值不同:只剩存活行一条 ⇒ 不成簇、一条候选都不产。
survivor := dedupeTestItem("主人的昵称是「老K」,是相处中慢慢形成的亲昵称呼;具体来历不明,不可编造。", tags, base)
staleA := dedupeTestItem("用户的昵称是「阿七」。", tags, base.Add(time.Minute))
staleB := dedupeTestItem("主人被称呼为「阿七」,助手平时就这么叫她。", tags, base.Add(2*time.Minute))
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, staleA, staleB})
if len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(取值不同=不同事实,§5.2-4/§5.2-5)", len(cands))
}
for _, m := range []MemoryItem{staleA, staleB} {
if gaps := dedupeValueGaps(m.Content, survivor.Content); len(gaps) == 0 {
t.Fatalf("取值门没认出 %q 里的取值与存活行不同", m.Content)
}
}
// 对照组:同样的 tag 集 + **相同取值**,必须照常成簇(证明上面那条不是因为门太宽)。
sameValue := dedupeTestItem("主人被称呼为「老K」,助手平时就这么叫她。", tags, base.Add(3*time.Minute))
cands, _ = dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, sameValue})
if len(cands) != 1 {
t.Fatalf("对照组簇数 = %d, want 1(同 tag 同取值必须成簇)", len(cands))
}
if cands[0].TargetID != survivor.ID {
t.Fatalf("对照组存活行 = %s, want 最长的那条 %s", cands[0].TargetID, survivor.ID)
}
}
// TestDedupeGroupLatinTokenIsAlsoAValue 钉住取值门的第二类取值:**拉丁 token**
// (人名 / 型号 / 代号)。`助手名为 Cyrene` 与 `助手名为 Alice` 同 tag 集,但取值不同。
func TestDedupeGroupLatinTokenIsAlsoAValue(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"身份", "名字"}
keep := dedupeTestItem("助手名为 Cyrene,主人习惯这么叫我。", tags, base)
other := dedupeTestItem("助手名为 Alice,主人习惯这么叫我。", tags, base.Add(time.Minute))
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{keep, other}); len(cands) != 0 {
t.Fatalf("簇数 = %d, want 0(拉丁取值不同=不同事实)", len(cands))
}
}
// TestDedupeGroupNeverLosesAValue 是**簇级不变式**:任何产出的候选,被吸收行里的
// 每个取值(引号取值 + 拉丁 token)都必须出现在存活行正文里 —— 否则那条取值会随
// 合并从 active 集合里消失。它比单点用例强:新增的簇判据 / 门顺序变化都要过它。
func TestDedupeGroupNeverLosesAValue(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
tags := []string{"暗号", "约定"}
items := []MemoryItem{
dedupeTestItem("用户与助手的验收暗号是「青柠」", tags, base),
dedupeTestItem("用户与助手的验收暗号是「青柠」,用于确认文档摄取正常", tags, base.Add(time.Minute)),
dedupeTestItem("验收暗号「青柠」用于确认文档摄取功能正常", tags, base.Add(2*time.Minute)),
// 同一 tag 集但取值不同(不许进簇)。
dedupeTestItem("用户与助手的验收暗号是「柠檬」", tags, base.Add(3*time.Minute)),
// 同 tag 集、取值包含了额外信息(不许进簇:那条取值会丢)。
dedupeTestItem("验收暗号「青柠」配合口令「开门」一起使用", tags, base.Add(4*time.Minute)),
}
cands, _ := dedupeCandsOf(t, newTestStore(t), items)
if len(cands) != 1 {
t.Fatalf("簇数 = %d, want 1", len(cands))
}
survivor := cands[0].MergedContent
for _, member := range cands[0].Evidence.DedupeMembers {
for _, value := range dedupeValues(member) {
if !strings.Contains(dedupeKey(survivor), value) {
t.Fatalf("取值 %q 会随合并消失(member=%q survivor=%q)", value, member, survivor)
}
}
}
for _, member := range cands[0].Evidence.DedupeMembers {
if strings.Contains(member, "柠檬") || strings.Contains(member, "开门") {
t.Fatalf("不该被吸收的成员进了簇:%q", member)
}
}
}