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。
This commit is contained in:
AskaEth committed 2026-10-25 00:49:44 +08:00
1 parent 7700a7eda0
commit 01aff71bc1
3 files changed
+351 -140

No files matched your search

@@ -1,54 +1,118 @@
package memory
import (
"sort"
"testing"
"time"
)
// 本文件是 `dedupe_group` **中文数字门**(r2 复审 P2)的验收用例集:
// 中位数写法下的数值冲突(一小时 vs 十五分钟、第二杯 vs 一杯)、单位不可换算的
// 保守拒绝、同值照常合并,以及中文数字解析口径本身。
// 本文件是 `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 的回归用例**:
// 语料里的数字大多写成**中文**,而 `sameNumbers` 只抽 ASCII 数字 token ⇒
// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**
// 曾被当成同一事实合并掉。
//
// 旧逻辑(只有 sameNumbers)下这些对会成簇 ⇒ 用例红;补中文数字门后不成簇 ⇒ 绿。
// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」曾被当成同一事实。
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 string
survivor string
member string
tags []string
}{
{
name: "午睡时长(一小时 vs 十五分钟)",
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
tagsA: []string{"作息", "午睡"},
tagsB: []string{"作息", "午睡"},
tags: []string{"作息", "午睡"},
},
{
name: "咖啡杯数(第二杯 vs 一杯)",
survivor: "用户被约定:一天咖啡不许续第二杯,下午犯困时起身走动、喝温水。",
member: "用户有喝咖啡的习惯,助手提醒她每天不超过一杯(第二杯不许续)。",
tagsA: []string{"习惯", "咖啡"},
tagsB: []string{"习惯", "咖啡"},
tags: []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)
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))
@@ -57,40 +121,40 @@ func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) {
}
}
// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」
// (r2 复审的明确口径):两杯 vs 三瓶是同一个计数量的不同取值,但杯/瓶不能互换。
// 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 gaps := dedupeNumberConflicts(b.Content, a.Content); len(gaps) == 0 {
t.Fatalf("单位不可换算没有报冲突:%q vs %q", b.Content, a.Content)
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 是中文数字门的**反向护栏**:
// 值相等(含不同单位换算后相等)照常成簇 —— 门收的是「冲突」,不是「出现数字」。
// 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))
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))
t.Fatalf("簇数 = %d, want 1(同值照常合并 —— 门不许退化成「有数字就不合」)", len(cands))
}
}
// TestTidyQuantitiesChineseNumerals 钉住中文数字的解析口径(含单位换算与习语排除)。
func TestTidyQuantitiesChineseNumerals(t *testing.T) {
// TestTidyQuantitiesNumberParsing 钉住数量解析口径:中文数字、ASCII 数字、单位换算、
// 以及「没有单位的数字走裸 token 路径」。
func TestTidyQuantitiesNumberParsing(t *testing.T) {
t.Parallel()
cases := []struct {
text string
@@ -104,6 +168,9 @@ func TestTidyQuantitiesChineseNumerals(t *testing.T) {
{"主人午睡半个小时", 1800, "小时", tidyDimTime, true},
{"一万两千行的验收纸条", 12000, "行", tidyDimText, true},
{"每天要喝两杯咖啡", 2, "杯", tidyDimCount, true},
// ASCII 数字 + 单位(r3 必需:`60 分钟` 与 `一小时` 要能归一)
{"主人午睡 60 分钟", 3600, "分钟", tidyDimTime, true},
{"主人午睡15分钟", 900, "分钟", tidyDimTime, true},
// 序数与习语:不是数量。
{"第二天再说", 0, "", "", false},
{"一起散步", 0, "", "", false},
@@ -126,3 +193,43 @@ func TestTidyQuantitiesChineseNumerals(t *testing.T) {
}
}
}
// 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)
}
}