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

@@ -16,15 +16,16 @@ import (
// 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**,
// 因此它**不进这一簇**(留待人工),而不是让整簇作废:
//
// 1. **数值**(§5.2-2,sameNumbers):数值不同即不同事实;
// 1. **数值**(§5.2-2,judgeDedupeNumbers):**三态**判据 ——
// 同值 ⇒ 可合并(中英混写也认:`一小时` ≡ `60 分钟`)/同量纲不同值 ⇒ 冲突/
// 量纲不可比 ⇒ 保守不许合并(`一小时` vs `一杯` 不是"放行")。
// 它取代了 `sameNumbers`(后者只认 ASCII token,既合不了中英混写、也看不见中文数字);
// 裸数字 token(无单位)仍按 `sameNumbers` 的**集合相等**口径判,一字未改。
// 沿革:r2(中文数字冲突)→ r3-项 2(归一后同值必须能合)→ r3-项 3(不可比必须拒)。
// 2. **否定**(§5.2-3,sameNegations):「X」vs「非X」是覆盖不是重复;
// 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 集),内容差得
@@ -35,7 +36,7 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu
if it.item.ID == survivor.item.ID {
continue
}
if !sameNumbers(it.item.Content, survivor.item.Content) {
if verdict, _ := judgeDedupeNumbers(it.item.Content, survivor.item.Content); verdict != tidyNumberSame {
continue
}
if !sameNegations(it.item.Content, survivor.item.Content) {
@@ -44,9 +45,6 @@ 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
}