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) } }