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。
477 lines
15 KiB
Go
477 lines
15 KiB
Go
package memory
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// 本文件是 `dedupe_group` 的**数值门**(2026-10-25):数值冲突要同时认出 ASCII 数字
|
||
// 与中文数字,按**量纲/单位归一**后比较,并给出**三态**结论。
|
||
//
|
||
// 沿革(三轮复审各修一处,别把其中任何一条当"可简化"):
|
||
// - r2:`sameNumbers` 只抽 ASCII 数字 token,而语料里的数字主要写成中文
|
||
// (一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒ 真冲突被当同一事实合并(实测 ~8 对);
|
||
// - r3-项 2(**过严**):归一后**同值**、中文与 ASCII 混写的两条必须能合并
|
||
// (`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)—— 归一化就是为此存在;
|
||
// - r3-项 3(**过松**):**量纲不可比**(`一小时` vs `一杯`)不许"当作不算冲突"放行,
|
||
// 必须按「比不了就不许合并」保守拒绝。
|
||
//
|
||
// ⇒ 判据是**三态**,不是布尔「有没有冲突」:
|
||
//
|
||
// 同值 ⇒ 可合并(tidyNumberSame)
|
||
// 同量纲不同值 ⇒ 冲突,不许合并(tidyNumberConflict)
|
||
// 量纲不可比/无可比 ⇒ 保守不许合并(tidyNumberIncomparable)
|
||
//
|
||
// 三态都建立在同一个前提上:**被吸收行的每个数量都要在存活行里找到等值对应**
|
||
// (方向与被吸收 ⊆ 存活 的软合并语义一致)。找不到时,看**为什么找不到**来分态:
|
||
// 存活行在同一量纲里有数量但值不同 ⇒ 冲突;存活行在该量纲里根本没有数量
|
||
//(含"存活行一个数字都没有")⇒ 量纲不可比 ⇒ 保守拒绝。
|
||
//
|
||
// 形态判据是「**数字 + 单位**」而不是「数字」:
|
||
// - 只认后面紧跟已知单位(小时/分钟/杯/次/份…)的数字,因此「一起」「一直」
|
||
// 「一些」「一样」「一部分」这类含数字的常见词不会被当成数量;
|
||
// - 没有单位的数字走**裸 token** 路径(与既有 `extractTidyFacts` 的 num 抽取同口径):
|
||
// 两侧 token 集合必须**完全相等**(既有 `sameNumbers` 的语义,一字不改)。
|
||
|
||
// tidyNumberVerdict 是一对记忆的数值关系(三态)。
|
||
type tidyNumberVerdict int
|
||
|
||
const (
|
||
// tidyNumberSame:归一后数值一致(或无数量可比且裸 token 相同)⇒ 可合并。
|
||
tidyNumberSame tidyNumberVerdict = iota
|
||
// tidyNumberConflict:同一量纲里值不相等 ⇒ 冲突,不许合并。
|
||
tidyNumberConflict
|
||
// tidyNumberIncomparable:量纲不可比 / 存活行没有可比数量 ⇒ 保守不许合并。
|
||
tidyNumberIncomparable
|
||
)
|
||
|
||
// String 让证据与测试断言可读。
|
||
func (v tidyNumberVerdict) String() string {
|
||
switch v {
|
||
case tidyNumberSame:
|
||
return "same"
|
||
case tidyNumberConflict:
|
||
return "conflict"
|
||
case tidyNumberIncomparable:
|
||
return "incomparable"
|
||
default:
|
||
return "unknown"
|
||
}
|
||
}
|
||
|
||
// tidyQuantity 是一处「数量」:归一后的值 + 单位名 + 量纲。
|
||
type tidyQuantity struct {
|
||
// value 是按单位换算到**量纲基准单位**后的值(时间=秒、计数=个、长度=米)。
|
||
value float64
|
||
// unit 是单位名(写清冲突原因,也是「能不能换算」的判据)。
|
||
unit string
|
||
// dim 是量纲(time / count / length / text)。同量纲才可比。
|
||
dim string
|
||
// raw 是原始写法(数字 + 单位),只用于日志/测试断言。
|
||
raw string
|
||
}
|
||
|
||
// judgeDedupeNumbers 判一对记忆的数值关系(三态),并给出理由(证据/日志用)。
|
||
//
|
||
// 顺序:先比**裸数字 token**(与 `sameNumbers` 同语义:集合必须完全相等),
|
||
// 再逐条比**数量**(同值 / 同量纲不同值 / 不可比)。
|
||
func judgeDedupeNumbers(absorbed, survivor string) (tidyNumberVerdict, []string) {
|
||
aQty, aBare := scanTidyNumbers(absorbed)
|
||
sQty, sBare := scanTidyNumbers(survivor)
|
||
|
||
if !sameStringSet(aBare, sBare) {
|
||
// 裸数字(无单位)不一致:与既有 sameNumbers 同一条口径 —— 数值不同即不同事实。
|
||
return tidyNumberConflict, []string{fmt.Sprintf("裸数字 token 不一致:%v vs %v", aBare, sBare)}
|
||
}
|
||
verdict := tidyNumberSame
|
||
reasons := make([]string, 0, 2)
|
||
for _, q := range aQty {
|
||
same, sameDim := false, false
|
||
for _, h := range sQty {
|
||
if h.dim != q.dim {
|
||
continue
|
||
}
|
||
sameDim = true
|
||
if sameQuantity(q, h) {
|
||
same = true
|
||
break
|
||
}
|
||
}
|
||
if same {
|
||
continue
|
||
}
|
||
if sameDim {
|
||
// 同量纲、值不相等(含单位换算不到一起)⇒ 真冲突。
|
||
verdict = tidyNumberConflict
|
||
reasons = append(reasons, fmt.Sprintf("%s≠同量纲的其它取值", q.raw))
|
||
continue
|
||
}
|
||
// 存活行在该量纲里没有数量 ⇒ 比不了 ⇒ 保守拒绝(r3-项 3)。
|
||
// 注意:这条**不是**「放行」,因此 `一小时` vs `一杯` 不会被合并。
|
||
if verdict != tidyNumberConflict {
|
||
verdict = tidyNumberIncomparable
|
||
}
|
||
reasons = append(reasons, fmt.Sprintf("%s 在存活行里没有同量纲数量可比", q.raw))
|
||
}
|
||
return verdict, reasons
|
||
}
|
||
|
||
// sameQuantity 报告两处数量是否等值(归一化后比较)。
|
||
func sameQuantity(a, b tidyQuantity) bool {
|
||
if a.dim != b.dim {
|
||
return false
|
||
}
|
||
diff := a.value - b.value
|
||
if diff < 0 {
|
||
diff = -diff
|
||
}
|
||
// 浮点留一点余量:换算会乘除(半小时 = 0.5×3600 秒)。
|
||
return diff < 0.5
|
||
}
|
||
|
||
// sameStringSet 报告两个字符串切片(已排序去重)是否完全相等。
|
||
func sameStringSet(a, b []string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// tidyQuantities 抽出一段正文里所有的「数字 + 单位」(ASCII 与中文数字都算)。
|
||
func tidyQuantities(text string) []tidyQuantity {
|
||
qty, _ := scanTidyNumbers(text)
|
||
return qty
|
||
}
|
||
|
||
// scanTidyNumbers 一次扫描产出两类结果:
|
||
// - quantities:「数字 + 已知单位」的数量(ASCII 数字与中文数字都算);
|
||
// - bare:**没有单位**的 ASCII 数字 token(口径与 extractTidyFacts 的 nums 一致:
|
||
// 数字之间的 : : / . - 并入同一个 token),升序去重。
|
||
//
|
||
// 中文数字没有单位时**不产出任何东西**(「一起」「一直」「第二天」这类习语占绝对多数)。
|
||
func scanTidyNumbers(text string) ([]tidyQuantity, []string) {
|
||
normalized := dedupeKey(text)
|
||
runes := []rune(normalized)
|
||
qty := make([]tidyQuantity, 0, 4)
|
||
bare := make([]string, 0, 2)
|
||
for i := 0; i < len(runes); i++ {
|
||
// ① 中文数字 + 单位
|
||
if value, next, ok := parseChineseNumberAt(runes, i); ok {
|
||
unit, dim, factor, unext, uok := tidyUnitAt(runes, next)
|
||
if !uok || tidyOrdinalPosition(runes, i, dim) {
|
||
i = next - 1
|
||
continue
|
||
}
|
||
qty = append(qty, tidyQuantity{
|
||
value: value * factor, unit: unit, dim: dim, raw: string(runes[i:unext]),
|
||
})
|
||
i = unext - 1
|
||
continue
|
||
}
|
||
// ② ASCII 数字 + 单位(`60 分钟` / `15分钟` / `3 份`)
|
||
if runes[i] < '0' || runes[i] > '9' {
|
||
continue
|
||
}
|
||
token, tend := scanASCIINumberToken(runes, i)
|
||
value, isFloat := parseASCIINumberValue(token)
|
||
unitAt := skipASCIISpaces(runes, tend)
|
||
if unit, dim, factor, unext, uok := tidyUnitAt(runes, unitAt); uok && isFloat {
|
||
qty = append(qty, tidyQuantity{
|
||
value: value * factor, unit: unit, dim: dim, raw: string(runes[i:unext]),
|
||
})
|
||
i = unext - 1
|
||
continue
|
||
}
|
||
bare = append(bare, token)
|
||
i = tend - 1
|
||
}
|
||
sort.Strings(bare)
|
||
bare = dedupeSortedStrings(bare)
|
||
return qty, bare
|
||
}
|
||
|
||
// scanASCIINumberToken 读一个 ASCII 数字 token(数字之间允许 : : / . - 分隔,
|
||
// 与 extractTidyFacts 的 num 抽取同一规则)。返回去掉首尾分隔符的 token 与结束下标。
|
||
func scanASCIINumberToken(runes []rune, at int) (string, int) {
|
||
var b strings.Builder
|
||
i := at
|
||
for ; i < len(runes); i++ {
|
||
r := runes[i]
|
||
if r >= '0' && r <= '9' {
|
||
b.WriteRune(r)
|
||
continue
|
||
}
|
||
if (r == ':' || r == ':' || r == '/' || r == '.' || r == '-') && b.Len() > 0 {
|
||
b.WriteRune(r)
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
return strings.Trim(b.String(), "::/.-"), i
|
||
}
|
||
|
||
// parseASCIINumberValue 把 ASCII 数字 token 解析成数值。带分隔符的复合 token
|
||
// (`22:38`、`10/3`、`3.5` 之外的多段写法)返回 false —— 它们不是「数量」,
|
||
// 只作为裸 token 参与集合比较。
|
||
func parseASCIINumberValue(token string) (float64, bool) {
|
||
if token == "" {
|
||
return 0, false
|
||
}
|
||
if v, err := strconv.ParseFloat(token, 64); err == nil {
|
||
return v, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// skipASCIISpaces 跳过一个空白(`60 分钟` 这种写法)。
|
||
func skipASCIISpaces(runes []rune, at int) int {
|
||
if at < len(runes) && runes[at] == ' ' {
|
||
return at + 1
|
||
}
|
||
return at
|
||
}
|
||
|
||
// dedupeSortedStrings 去掉已排序切片里的重复项。
|
||
func dedupeSortedStrings(in []string) []string {
|
||
if len(in) < 2 {
|
||
return in
|
||
}
|
||
out := in[:1]
|
||
for _, s := range in[1:] {
|
||
if s != out[len(out)-1] {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// tidyOrdinalPosition 报告这个数字是不是「第X」式的**序数位置**(只对时间量纲的
|
||
// 天/周/月/年成立:「第二天」是次序,不是两天)。序数 + 计数量词(第二杯)仍是数量。
|
||
func tidyOrdinalPosition(runes []rune, idx int, dim string) bool {
|
||
if dim != tidyDimTime || idx == 0 {
|
||
return false
|
||
}
|
||
return runes[idx-1] == '第'
|
||
}
|
||
|
||
// 量纲名(同量纲才可比)。
|
||
const (
|
||
tidyDimTime = "time"
|
||
tidyDimCount = "count"
|
||
tidyDimLength = "length"
|
||
tidyDimText = "text"
|
||
)
|
||
|
||
// tidyUnitAt 在 text[at:] 处匹配一个已知单位(**最长优先**:`个月` 先于 `个`)。
|
||
// 返回单位名、量纲、换算到量纲基准的系数与匹配结束下标。
|
||
func tidyUnitAt(text []rune, at int) (string, string, float64, int, bool) {
|
||
best := ""
|
||
var bestDim string
|
||
var bestFactor float64
|
||
bestEnd := at
|
||
for _, u := range tidyUnitTable() {
|
||
n := len([]rune(u.name))
|
||
if at+n > len(text) {
|
||
continue
|
||
}
|
||
if string(text[at:at+n]) != u.name {
|
||
continue
|
||
}
|
||
if n > len([]rune(best)) {
|
||
best, bestDim, bestFactor, bestEnd = u.name, u.dim, u.factor, at+n
|
||
}
|
||
}
|
||
if best == "" {
|
||
return "", "", 0, at, false
|
||
}
|
||
if best == "个" {
|
||
// 「个」在「一个小时 / 一个星期」里是**量词**而不是计数单位:它后面若紧跟
|
||
// 另一个单位,以那个单位为准(否则「一个小时」会被读成「一个」而丢掉时长)。
|
||
if inner, dim, factor, end, ok := tidyUnitAt(text, bestEnd); ok {
|
||
return inner, dim, factor, end, true
|
||
}
|
||
}
|
||
return best, bestDim, bestFactor, bestEnd, true
|
||
}
|
||
|
||
// tidyUnit 是单位表的一行。
|
||
type tidyUnit struct {
|
||
name string
|
||
dim string
|
||
factor float64
|
||
}
|
||
|
||
// tidyUnitTable 是已知单位表(换算到量纲基准单位:时间=秒、计数=个、长度=米)。
|
||
//
|
||
// 月份/年用**约定值**(30 天 / 365 天)换算:它们在语料里是「多久」的意思,
|
||
// 不换算就变成「比不了」,那会让「半年 vs 六个月」也被判不可比。
|
||
func tidyUnitTable() []tidyUnit {
|
||
return []tidyUnit{
|
||
// 时间(基准:秒)
|
||
{"秒钟", tidyDimTime, 1},
|
||
{"秒", tidyDimTime, 1},
|
||
{"分钟", tidyDimTime, 60},
|
||
{"分", tidyDimTime, 60},
|
||
{"小时", tidyDimTime, 3600},
|
||
{"钟头", tidyDimTime, 3600},
|
||
{"天", tidyDimTime, 86400},
|
||
{"日", tidyDimTime, 86400},
|
||
{"周", tidyDimTime, 604800},
|
||
{"星期", tidyDimTime, 604800},
|
||
{"礼拜", tidyDimTime, 604800},
|
||
{"个月", tidyDimTime, 2592000},
|
||
{"年", tidyDimTime, 31536000},
|
||
// 计数(基准:个;不同计数单位之间**不可互换**)
|
||
{"次", tidyDimCount, 1},
|
||
{"遍", tidyDimCount, 1},
|
||
{"圈", tidyDimCount, 1},
|
||
{"步", tidyDimCount, 1},
|
||
{"杯", tidyDimCount, 1},
|
||
{"碗", tidyDimCount, 1},
|
||
{"口", tidyDimCount, 1},
|
||
{"份", tidyDimCount, 1},
|
||
{"张", tidyDimCount, 1},
|
||
{"条", tidyDimCount, 1},
|
||
{"件", tidyDimCount, 1},
|
||
{"个", tidyDimCount, 1},
|
||
{"位", tidyDimCount, 1},
|
||
{"只", tidyDimCount, 1},
|
||
{"颗", tidyDimCount, 1},
|
||
{"瓶", tidyDimCount, 1},
|
||
{"盒", tidyDimCount, 1},
|
||
{"包", tidyDimCount, 1},
|
||
{"岁", tidyDimCount, 1},
|
||
{"层", tidyDimCount, 1},
|
||
{"段", tidyDimCount, 1},
|
||
{"篇", tidyDimCount, 1},
|
||
{"首", tidyDimCount, 1},
|
||
{"集", tidyDimCount, 1},
|
||
// 长度(基准:米)
|
||
{"公里", tidyDimLength, 1000},
|
||
{"千米", tidyDimLength, 1000},
|
||
{"米", tidyDimLength, 1},
|
||
{"厘米", tidyDimLength, 0.01},
|
||
// 文字量(基准:个)
|
||
{"字符", tidyDimText, 1},
|
||
{"字节", tidyDimText, 1},
|
||
{"行", tidyDimText, 1},
|
||
{"字", tidyDimText, 1},
|
||
{"页", tidyDimText, 1},
|
||
}
|
||
}
|
||
|
||
// parseChineseNumberAt 在 text[at:] 处解析一个中文数字(含「半」),返回数值与结束下标。
|
||
//
|
||
// 覆盖语料里真实出现的形态:一/两/三…九、十、十五、二十、二十四、一百二十、两千、
|
||
// 一万两千、半。**不认**「几/十几/数/好几」这类模糊量词(它们不是可比数量)。
|
||
func parseChineseNumberAt(text []rune, at int) (float64, int, bool) {
|
||
if at >= len(text) {
|
||
return 0, at, false
|
||
}
|
||
if text[at] == '半' {
|
||
return 0.5, at + 1, true
|
||
}
|
||
total := 0.0 // 已结算的「万」以上段与整段合计
|
||
section := 0.0 // 当前小节(万以下)
|
||
digit := 0.0
|
||
seen := false
|
||
hasUnitRune := false
|
||
i := at
|
||
for ; i < len(text); i++ {
|
||
r := text[i]
|
||
if d, ok := chineseDigitValue(r); ok {
|
||
digit = d
|
||
seen = true
|
||
continue
|
||
}
|
||
switch r {
|
||
case '十':
|
||
if !seen {
|
||
digit = 1
|
||
}
|
||
section += digit * 10
|
||
digit = 0
|
||
seen = true
|
||
hasUnitRune = true
|
||
case '百':
|
||
if !seen {
|
||
digit = 1
|
||
}
|
||
section += digit * 100
|
||
digit = 0
|
||
seen = true
|
||
hasUnitRune = true
|
||
case '千':
|
||
if !seen {
|
||
digit = 1
|
||
}
|
||
section += digit * 1000
|
||
digit = 0
|
||
seen = true
|
||
hasUnitRune = true
|
||
case '万':
|
||
if !seen {
|
||
digit = 1
|
||
}
|
||
section += digit
|
||
total += section * 10000
|
||
section, digit = 0, 0
|
||
seen = true
|
||
hasUnitRune = true
|
||
default:
|
||
if !seen {
|
||
return 0, at, false
|
||
}
|
||
total += section + digit
|
||
if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 {
|
||
// 「两三」「三四」这类并列不是确定数量,按模糊处理(不认)。
|
||
return 0, at, false
|
||
}
|
||
return total, i, true
|
||
}
|
||
}
|
||
if !seen {
|
||
return 0, at, false
|
||
}
|
||
total += section + digit
|
||
if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 {
|
||
return 0, at, false
|
||
}
|
||
return total, i, true
|
||
}
|
||
|
||
// chineseDigitValue 是中文数字字符到值的映射(不含十百千万半)。
|
||
func chineseDigitValue(r rune) (float64, bool) {
|
||
switch r {
|
||
case '零', '〇':
|
||
return 0, true
|
||
case '一':
|
||
return 1, true
|
||
case '二', '两', '俩':
|
||
return 2, true
|
||
case '三':
|
||
return 3, true
|
||
case '四':
|
||
return 4, true
|
||
case '五':
|
||
return 5, true
|
||
case '六':
|
||
return 6, true
|
||
case '七':
|
||
return 7, true
|
||
case '八':
|
||
return 8, true
|
||
case '九':
|
||
return 9, true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|