Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_numbers_test.go
T
AskaEth 01aff71bc1 fix(memory): dedupe_group 数值门改三态(归一后同值可合 / 同量纲不同值冲突 / 量纲不可比保守拒),修 r3 复审两项
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。
2026-10-25 00:49:44 +08:00

236 lines
9.6 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"
"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)
}
}