r3 复审指出我第一版中文数字门两个方向都反了(规格本身被复审自己收回重写):
- 项 2(过严):归一后**同值**、中文与 ASCII 混写的两条合不了
(`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)。根因是判定仍以 `sameNumbers`
(只抽 ASCII token)为闸:`{}` vs `{60}` 不等 ⇒ 直接否决,归一化没机会生效。
- 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)被当成"不算冲突"⇒ 会合并,
与「比不了单位就不许合并」相反。
修法:数值门从「布尔冲突」改成**三态**,并取代 `sameNumbers` 作为 dedupe 的数值判据:
| 结果 | 含义 | 合并 |
|---|---|---|
| tidyNumberSame | 归一后同值(含中英混写、单位换算:一小时 ≡ 60 分钟) | ✅ |
| tidyNumberConflict | 同量纲、值不相等(含单位不可互换:两杯 vs 三瓶) | ❌ |
| tidyNumberIncomparable | 量纲不可比 / 存活行没有同量纲数量可比(一小时 vs 一杯) | ❌ 保守拒绝 |
配套:
- 数量抽取同时支持 **ASCII 与中文数字 + 单位**(`60 分钟` / `15分钟` / `十五分钟` /
`半个小时` / `一万两千行`),并新增 `字符`/`字节` 两个文字量单位;
- **裸数字 token(无单位)**仍按 `sameNumbers` 的集合相等口径判(`0023` vs `0024`
这类靠它拦住),并有 parity 用例钉住「与 extractTidyFacts 的 nums 逐项一致」;
- 三态里 `incomparable` 只**收紧**,不放宽任何既有门;`sameNumbers` 函数本身未改
(judgePair/judgeIngest 共用,另处 blast radius,已登记建议)。
回归用例(三态各一条 + 修前红/修后绿,均实测):
- TestDedupeGroupNumberGateThreeStates 四个子用例(两条同值含混写、同量纲不同值、量纲不可比),
同时断言判定与簇结果;
· 变异 = 退回 `sameNumbers`(项 2 旧行为)⇒ 两条「同值」子用例红;
· 变异 = 不可比当放行(项 3 旧行为)⇒ 「量纲不可比」子用例红。
- TestDedupeGroupRejectsConflictingChineseNumbers / RejectsNonConvertibleUnits /
SameChineseNumberStillMerges(r2 三项保持绿)+ TestTidyQuantitiesNumberParsing +
TestScanTidyNumbersBareTokensMatchExistingFactNums(裸 token 口径不变)。
语料实测(生产库只读 mode=ro,active 3667,999 对已过否定/取值/相似度门的成员对):
三态分布 same=942 / conflict=25 / incomparable=32;dedupe 产出
`250 簇/495 行` → **248 簇/489 行(13.3%)**。incomparable 那 32 对是「四行歌词 vs
一段歌词」「一个项目进度代号 vs 项目进度代号为「w49」」这类:按「比不了就不许合并」
一律拒绝,聚合上只少减 6 行(簇成员互有重叠)。
验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
236 lines
9.6 KiB
Go
236 lines
9.6 KiB
Go
package memory
|
||
|
||
import (
|
||
"sort"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是 `dedupe_group` **数值门**的验收用例集(三态)。
|
||
//
|
||
// 沿革:r2 复审要「认出中文数字的数值冲突」→ r3 复审把规格收成**三态**,并指出我第一版
|
||
// 两个方向都反了:
|
||
// - 项 2(过严):归一后**同值**、中文与 ASCII 混写(`一小时` vs `60 分钟`)必须**能合并**;
|
||
// - 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)必须**保守不许合并**,不是放行。
|
||
//
|
||
// 因此本文件的核心用例是「三态各一条」,且每条都同时断言**判定**与**簇结果** ——
|
||
// 只断言簇结果的话,别的原因(相似度地板 / 取值门)也能让簇消失,测不出数值门。
|
||
|
||
// TestDedupeGroupNumberGateThreeStates 是三态的主用例。
|
||
//
|
||
// 旧逻辑红/新逻辑绿的证据(已实测):
|
||
// - 同值那一条:旧代码用 `sameNumbers`(只认 ASCII token)⇒ `{}` vs `{60}` 不等 ⇒ 不合 ⇒ 红;
|
||
// - 不可比那一条:r2 版把「存活行同量纲没有数量」当**放行** ⇒ 合了 ⇒ 红。
|
||
func TestDedupeGroupNumberGateThreeStates(t *testing.T) {
|
||
t.Parallel()
|
||
base := tidyTestTime(t)
|
||
cases := []struct {
|
||
name string
|
||
survivor string
|
||
member string
|
||
want tidyNumberVerdict
|
||
wantMerged bool
|
||
}{
|
||
{
|
||
name: "同值:中文与 ASCII 混写(一小时 ≡ 60 分钟)",
|
||
survivor: "主人午睡 60 分钟,下午很清醒。",
|
||
member: "主人午睡一小时,午后精神好。",
|
||
want: tidyNumberSame,
|
||
wantMerged: true,
|
||
},
|
||
{
|
||
name: "同值:十五分钟 ≡ 15 分钟",
|
||
survivor: "主人午睡 15 分钟,起来刚好。",
|
||
member: "主人午睡十五分钟,醒来正好。",
|
||
want: tidyNumberSame,
|
||
wantMerged: true,
|
||
},
|
||
{
|
||
name: "同量纲不同值:一小时 vs 十五分钟",
|
||
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
|
||
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
|
||
want: tidyNumberConflict,
|
||
wantMerged: false,
|
||
},
|
||
{
|
||
name: "量纲不可比:一小时 vs 一杯",
|
||
survivor: "主人午睡时喝了一杯温牛奶,午后精神好。",
|
||
member: "主人午睡一小时,午后精神好。",
|
||
want: tidyNumberIncomparable,
|
||
wantMerged: false,
|
||
},
|
||
}
|
||
tags := []string{"作息", "午睡"}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
a := dedupeTestItem(tc.survivor, tags, base)
|
||
b := dedupeTestItem(tc.member, tags, base.Add(time.Minute))
|
||
verdict, reasons := judgeDedupeNumbers(b.Content, a.Content)
|
||
if verdict != tc.want {
|
||
t.Fatalf("判定 = %s %v, want %s(MEMBER=%q SURV=%q)",
|
||
verdict, reasons, tc.want, b.Content, a.Content)
|
||
}
|
||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b})
|
||
if tc.wantMerged && len(cands) != 1 {
|
||
t.Fatalf("簇数 = %d, want 1(同值必须能合并)", len(cands))
|
||
}
|
||
if !tc.wantMerged && len(cands) != 0 {
|
||
t.Fatalf("簇数 = %d, want 0(%s ⇒ 不许合并)", len(cands), verdict)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestDedupeGroupRejectsConflictingChineseNumbers 是 r2 复审 **P2 的回归用例**:
|
||
// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」曾被当成同一事实。
|
||
func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) {
|
||
t.Parallel()
|
||
base := tidyTestTime(t)
|
||
cases := []struct {
|
||
name string
|
||
survivor string
|
||
member string
|
||
tags []string
|
||
}{
|
||
{
|
||
name: "午睡时长(一小时 vs 十五分钟)",
|
||
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
|
||
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
|
||
tags: []string{"作息", "午睡"},
|
||
},
|
||
{
|
||
name: "咖啡杯数(第二杯 vs 一杯)",
|
||
survivor: "用户被约定:一天咖啡不许续第二杯,下午犯困时起身走动、喝温水。",
|
||
member: "用户有喝咖啡的习惯,助手提醒她每天不超过一杯(第二杯不许续)。",
|
||
tags: []string{"习惯", "咖啡"},
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
a := dedupeTestItem(tc.survivor, tc.tags, base)
|
||
b := dedupeTestItem(tc.member, tc.tags, base.Add(time.Minute))
|
||
if verdict, _ := judgeDedupeNumbers(b.Content, a.Content); verdict != tidyNumberConflict {
|
||
t.Fatalf("判定 = %s, want conflict", verdict)
|
||
}
|
||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
|
||
t.Fatalf("簇数 = %d, want 0(数值冲突=不同事实,§5.2-2)", len(cands))
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」:
|
||
// 两杯 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 verdict, _ := judgeDedupeNumbers(b.Content, a.Content); verdict != tidyNumberConflict {
|
||
t.Fatalf("判定 = %s, want conflict(同量纲不同值)", verdict)
|
||
}
|
||
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))
|
||
c := dedupeTestItem("主人午睡 60 分钟,起来神清气爽。", 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))
|
||
}
|
||
}
|
||
|
||
// TestTidyQuantitiesNumberParsing 钉住数量解析口径:中文数字、ASCII 数字、单位换算、
|
||
// 以及「没有单位的数字走裸 token 路径」。
|
||
func TestTidyQuantitiesNumberParsing(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},
|
||
// ASCII 数字 + 单位(r3 必需:`60 分钟` 与 `一小时` 要能归一)
|
||
{"主人午睡 60 分钟", 3600, "分钟", tidyDimTime, true},
|
||
{"主人午睡15分钟", 900, "分钟", tidyDimTime, 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestScanTidyNumbersBareTokensMatchExistingFactNums 钉住「裸数字 token 口径没变」:
|
||
// 当正文里**没有任何「数字 + 单位」**时,本文件的裸 token 抽取必须与既有
|
||
// `extractTidyFacts` 的 nums 完全一致 —— 否则「数值门取代 sameNumbers」会悄悄放宽
|
||
// 一条既有判据(`0023` vs `0024` 这类无单位数字正是靠它拦住的)。
|
||
//
|
||
// 有「数字 + 单位」时**刻意**不再进裸 token(那正是 r3 项 2 要的:`60 分钟` 要能与
|
||
// `一小时` 归一),因此第二条断言把这个分歧也钉住。
|
||
func TestScanTidyNumbersBareTokensMatchExistingFactNums(t *testing.T) {
|
||
t.Parallel()
|
||
// 这些输入里**没有任何「数字 + 单位」**,因此裸 token 必须与既有抽取逐项一致。
|
||
parity := []string{
|
||
"迁移号从 0023 起递增",
|
||
"文件路径 D:/a/b 与端口 8080",
|
||
"h3R3 的歌曲循环了三遍",
|
||
"登记编号 624046 已入库",
|
||
"2026-10-03 那条记录", // 日期 token 由 hasAbsoluteTimestamp 在分簇前就排除
|
||
}
|
||
for _, text := range parity {
|
||
_, bare := scanTidyNumbers(text)
|
||
facts := extractTidyFacts(text)
|
||
want := make([]string, 0, len(facts.nums))
|
||
for n := range facts.nums {
|
||
want = append(want, n)
|
||
}
|
||
sort.Strings(want)
|
||
if !sameStringSet(bare, want) {
|
||
t.Fatalf("%q 裸 token = %v, want %v(必须与 extractTidyFacts 同口径)", text, bare, want)
|
||
}
|
||
}
|
||
|
||
// 分歧(刻意):带单位的数字进「数量」,不再充当裸 token。
|
||
qty, bare := scanTidyNumbers("主人午睡 60 分钟")
|
||
if len(bare) != 0 {
|
||
t.Fatalf("`60 分钟` 的裸 token = %v, want 空(它已归一成数量)", bare)
|
||
}
|
||
if len(qty) != 1 || qty[0].value != 3600 || qty[0].dim != tidyDimTime {
|
||
t.Fatalf("`60 分钟` 解析 = %+v, want 3600 秒(时间量纲)", qty)
|
||
}
|
||
}
|