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 } }