Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_numbers.go
T
AskaEth 7700a7eda0 fix(memory): dedupe_group 补中文数字门(量纲/单位归一),修 r2 复审 p2
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。
2026-10-25 00:41:16 +08:00

371 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}