Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_numbers.go
T
AskaEth 01aff71bc1 fix(memory): dedupe_group 数值门改三态(归一后同值可合 / 同量纲不同值冲突 / 量纲不可比保守拒),修 r3 复审两项
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。
2026-10-25 00:49:44 +08:00

477 lines
15 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"
"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
}
}