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