r2 复审 p2:`sameNumbers` 只抽 ASCII 数字 token,而本语料的数字主要写成中文 (一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒「午睡一小时 vs 十五分钟」 「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**被当成同一事实合并。 修法(新文件 internal/runtime/memory/tidy_dedupe_numbers.go): - 抽出「**中文数字 + 单位**」对(一~九/两/半/十/百/千/万,含十五、二十四、一万两千、 半个小时),按**量纲归一**后比较:时间=秒(分钟 60 / 小时 3600 / 天 86400 / 周 604800 / 个月 2592000 / 年 31536000)、长度=米、文字量=个、计数=个; - 只认**后面紧跟已知单位**的数字:`一起 / 一直 / 一些 / 一样 / 第二天(序数位置)` 这类含数字的习语一律不产生数量 —— 不做成「有中文数字就不合并」; - 判据是**冲突**而非差异:存活行在同量纲里没有对应数量 ⇒ 不算冲突(被吸收行多出 的细节);等值(含单位换算后等值,如「半小时」=1800 秒)⇒ 照常合并; 值不等且单位换算不到一起 ⇒ 按冲突处理(复审口径:宁可不合并); - 只**增加否决**,不放宽任何既有门;ASCII 路径仍由 sameNumbers 先判,一字未动; `sameNumbers` 本身也未改(judgePair/judgeIngest 共用它,属于另一处 blast radius, 已在回报里登记为后续建议)。 语料实测(生产库只读 mode=ro,active 3667):999 对里中文数字门挡掉 **17 对**, 其中真冲突:午睡十五分钟 vs 一小时 ×3、咖啡一杯 vs 第二杯 ×4、 「三千次轮回」vs「三千万次轮回」×1(ASCII 路径完全看不见这条); 其余 9 对是「单位不可换算」的保守拒绝(半碗 vs 一个包/一杯、一次 vs 两圈)。 dedupe 产出 255 簇/505 行 → **250 簇/495 行(13.5%)**。 回归用例(去掉这道门即红,已实测): - TestDedupeGroupRejectsConflictingChineseNumbers(复审给的两对真实语料,脱敏保留原意) - TestDedupeGroupRejectsNonConvertibleUnits(两杯 vs 三瓶 ⇒ 保守不合并) - TestDedupeGroupSameChineseNumberStillMerges(同值/换算后同值 ⇒ 照常合并,门没退化成「有数字就不合」) - TestTidyQuantitiesChineseNumerals(解析口径:一小时=3600s、十五分钟=900s、半小时=1800s、 一万两千行=12000、第二天/一起/一直 不产生数量) 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package memory
|
||
|
||
import "fmt"
|
||
|
||
// 本文件是 `dedupe_group` 的**中文数字门**(2026-10-25,r2 复审 P2 的落点):
|
||
// 数值冲突不仅要认出 ASCII 数字,还要认出中文数字,并且按**量纲/单位**归一后比较。
|
||
//
|
||
// 动因是一次实测:`sameNumbers`(tidy_containment.go)只抽 ASCII 数字 token,
|
||
// 而本语料的数字主要写成中文(一小时 / 十五分钟 / 第二杯 / 一辈子)——
|
||
// 于是「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**
|
||
// 被当成同一事实合并掉。复审在 22 个簇里量到约 5 对真实冲突。
|
||
//
|
||
// 🔴 **两道自我约束**(复审明确要求):
|
||
// 1. **不做成「两边都有中文数字就一律不合并」**:那样等于把规则废掉。判据是
|
||
// 「**同一个量纲**里,值不相等」才算冲突;两边都写「一杯」照样合。
|
||
// 2. **只在数值上收紧,不放宽别的门**:本门只**增加**否决,不改变任何「原本会被
|
||
// 否决的对」(ASCII 路径仍由 `sameNumbers` 先判,一字未动)。
|
||
//
|
||
// 形态判据是「**中文数字 + 单位**」,而不是「中文数字」:
|
||
// - 只认后面紧跟已知单位(小时/分钟/杯/次/份…)的数字,因此「一起」「一直」
|
||
// 「一些」「一样」「一部分」这类**含数字的常见词**不会被当成数量;
|
||
// - 数字后面没有单位时**不产生数量**(哪怕它是真的数量):NL 里的裸中文数字
|
||
// 绝大多数是习语,收它会把精确度换成一堆误否决。
|
||
//
|
||
// 单位不可换算时按**保守方向**处理(复审口径「比不了单位就不许合并」):同一个
|
||
// 量纲里值不相等、单位又换算不到一起 ⇒ 一律算冲突。
|
||
|
||
// tidyQuantity 是一处「数量」:归一后的值 + 单位名 + 量纲。
|
||
type tidyQuantity struct {
|
||
// value 是按单位换算到**量纲基准单位**后的值(时间=秒、计数=个、长度=米)。
|
||
value float64
|
||
// unit 是单位名(用于把冲突原因写清楚,也是「能不能换算」的判据)。
|
||
unit string
|
||
// dim 是量纲(time / count / length / text)。同量纲才可比。
|
||
dim string
|
||
// raw 是原始写法(一~数字+单位),只用于日志/测试断言。
|
||
raw string
|
||
}
|
||
|
||
// dedupeNumberConflicts 返回被吸收行与存活行之间的**数值冲突**(空 = 无冲突)。
|
||
//
|
||
// 判据(逐条被吸收行的数量):
|
||
// 1. 存活行在**同一量纲**里没有任何数量 ⇒ 不算冲突(被吸收行多出来的细节,
|
||
// 合并不改变存活行正文,因此不算「冲突」,与取值门的单向口径一致);
|
||
// 2. 存活行在同一量纲里有**等值**的数量 ⇒ 不算冲突(单位可以不同:三份 vs 三张);
|
||
// 3. 其余 ⇒ 冲突(值不等;单位换算不到一起时同样算冲突,方向保守)。
|
||
func dedupeNumberConflicts(absorbed, survivor string) []string {
|
||
have := tidyQuantities(survivor)
|
||
if len(have) == 0 {
|
||
return nil
|
||
}
|
||
gaps := make([]string, 0, 2)
|
||
for _, q := range tidyQuantities(absorbed) {
|
||
same, comparable, sameDim := false, false, false
|
||
for _, h := range have {
|
||
if h.dim != q.dim {
|
||
continue
|
||
}
|
||
sameDim = true
|
||
if sameQuantity(q, h) {
|
||
same = true
|
||
break
|
||
}
|
||
if convertibleUnits(q, h) {
|
||
comparable = true
|
||
}
|
||
}
|
||
if same || !sameDim {
|
||
// 等值 ⇒ 不是冲突;同量纲里根本没有可比对的数量 ⇒ 也**不是**冲突
|
||
//(被吸收行多出来的细节,合并不改存活行正文,因此不算「冲突」)。
|
||
// 这一条是「不同量纲不算冲突」的落点:`四行歌词` 对 `一段歌词/心事`
|
||
// 里 `行`(文字量)与 `段`(计数)根本不是同一回事。
|
||
continue
|
||
}
|
||
if comparable {
|
||
gaps = append(gaps, fmt.Sprintf("%s≠同量纲的另一取值", q.raw))
|
||
continue
|
||
}
|
||
gaps = append(gaps, fmt.Sprintf("%s 与存活行的同量纲数量不可换算", q.raw))
|
||
}
|
||
return gaps
|
||
}
|
||
|
||
// sameQuantity 报告两处数量是否等值(同量纲已由调用方保证)。
|
||
func sameQuantity(a, b tidyQuantity) bool {
|
||
if a.dim != b.dim {
|
||
return false
|
||
}
|
||
if a.value == b.value {
|
||
return true
|
||
}
|
||
// 浮点比较留一点余量:换算会乘除(半小时 = 0.5×3600 秒),不做精确相等。
|
||
diff := a.value - b.value
|
||
if diff < 0 {
|
||
diff = -diff
|
||
}
|
||
return diff < 0.5
|
||
}
|
||
|
||
// convertibleUnits 报告两处数量的单位能不能换算到一起(同一量纲里,计数类单位
|
||
// 之间**不能**互换:两杯 ≠ 三次)。
|
||
func convertibleUnits(a, b tidyQuantity) bool {
|
||
if a.dim != b.dim {
|
||
return false
|
||
}
|
||
if a.dim == tidyDimCount {
|
||
// 计数器的量纲相同但单位不可互换(杯/次/份/张…)——除非本来就是同一个单位。
|
||
return a.unit == b.unit
|
||
}
|
||
return tidyUnitFactor(a.dim, a.unit) > 0 && tidyUnitFactor(b.dim, b.unit) > 0
|
||
}
|
||
|
||
// tidyQuantities 抽出一段正文里所有的「中文数字 + 单位」。
|
||
func tidyQuantities(text string) []tidyQuantity {
|
||
runes := []rune(dedupeKey(text))
|
||
out := make([]tidyQuantity, 0, 4)
|
||
for i := 0; i < len(runes); i++ {
|
||
value, next, ok := parseChineseNumberAt(runes, i)
|
||
if !ok {
|
||
continue
|
||
}
|
||
unit, dim, factor, unext, uok := tidyUnitAt(runes, next)
|
||
if !uok {
|
||
// 数字后面没有已知单位:不当作数量(习语占绝对多数)。
|
||
i = next - 1
|
||
continue
|
||
}
|
||
if tidyOrdinalPosition(runes, i, dim) {
|
||
// 「第二天」「第三周」是**序数位置**,不是时长。
|
||
i = unext - 1
|
||
continue
|
||
}
|
||
raw := string(runes[i:unext])
|
||
out = append(out, tidyQuantity{value: value * factor, unit: unit, dim: dim, raw: raw})
|
||
i = unext - 1
|
||
}
|
||
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() {
|
||
if at+len([]rune(u.name)) > len(text) {
|
||
continue
|
||
}
|
||
if string(text[at:at+len([]rune(u.name))]) != u.name {
|
||
continue
|
||
}
|
||
if len([]rune(u.name)) > len([]rune(best)) {
|
||
best, bestDim, bestFactor, bestEnd = u.name, u.dim, u.factor, at+len([]rune(u.name))
|
||
}
|
||
}
|
||
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},
|
||
// 计数(基准:个;**单位之间不互换**,见 convertibleUnits)
|
||
{"次", 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},
|
||
}
|
||
}
|
||
|
||
// tidyUnitFactor 返回单位在它量纲里的换算系数(0 = 不认识的单位)。
|
||
func tidyUnitFactor(dim, unit string) float64 {
|
||
for _, u := range tidyUnitTable() {
|
||
if u.dim == dim && u.name == unit {
|
||
return u.factor
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|