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。
This commit is contained in:
1 parent
7700a7eda0
commit
01aff71bc1
3 files changed
+351
-140
No files matched your search
@@ -16,15 +16,16 @@ import (
|
||||
// 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**,
|
||||
// 因此它**不进这一簇**(留待人工),而不是让整簇作废:
|
||||
//
|
||||
// 1. **数值**(§5.2-2,sameNumbers):数值不同即不同事实;
|
||||
// 1. **数值**(§5.2-2,judgeDedupeNumbers):**三态**判据 ——
|
||||
// 同值 ⇒ 可合并(中英混写也认:`一小时` ≡ `60 分钟`)/同量纲不同值 ⇒ 冲突/
|
||||
// 量纲不可比 ⇒ 保守不许合并(`一小时` vs `一杯` 不是"放行")。
|
||||
// 它取代了 `sameNumbers`(后者只认 ASCII token,既合不了中英混写、也看不见中文数字);
|
||||
// 裸数字 token(无单位)仍按 `sameNumbers` 的**集合相等**口径判,一字未改。
|
||||
// 沿革:r2(中文数字冲突)→ r3-项 2(归一后同值必须能合)→ r3-项 3(不可比必须拒)。
|
||||
// 2. **否定**(§5.2-3,sameNegations):「X」vs「非X」是覆盖不是重复;
|
||||
// 3. **取值**(§5.2-4/§5.2-5,dedupeValueGaps):被吸收行的引号取值 / 拉丁 token
|
||||
// 必须在存活行里一个不少 —— 这条是 r1 复审 P1 的落点(`「叶酱」` 不许并进
|
||||
// `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。
|
||||
// 4. **中文数字**(§5.2-2 的补充,dedupeNumberConflicts):`sameNumbers` 只抽
|
||||
// ASCII 数字 token,而语料里的数字大多写成中文(一小时 / 十五分钟 / 第二杯)——
|
||||
// r2 复审实测约 5 对真实数值冲突因此被合并。这道门按**量纲 + 单位换算**比,
|
||||
// 单位换算不到一起就按冲突处理(标准是「宁可不合并」)。
|
||||
//
|
||||
// 相似度下限对**两种簇都生效**(`dedupeGroupMinSimilarity`):tag 集相同只是
|
||||
// 「同主题」的弱证据(体检报告 §1.3 的「冰美式 vs 红牛」也同 tag 集),内容差得
|
||||
@@ -35,7 +36,7 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu
|
||||
if it.item.ID == survivor.item.ID {
|
||||
continue
|
||||
}
|
||||
if !sameNumbers(it.item.Content, survivor.item.Content) {
|
||||
if verdict, _ := judgeDedupeNumbers(it.item.Content, survivor.item.Content); verdict != tidyNumberSame {
|
||||
continue
|
||||
}
|
||||
if !sameNegations(it.item.Content, survivor.item.Content) {
|
||||
@@ -44,9 +45,6 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu
|
||||
if len(dedupeValueGaps(it.item.Content, survivor.item.Content)) > 0 {
|
||||
continue
|
||||
}
|
||||
if len(dedupeNumberConflicts(it.item.Content, survivor.item.Content)) > 0 {
|
||||
continue
|
||||
}
|
||||
if lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupMinSimilarity(rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,58 +1,95 @@
|
||||
package memory
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` 的**中文数字门**(2026-10-25,r2 复审 P2 的落点):
|
||||
// 数值冲突不仅要认出 ASCII 数字,还要认出中文数字,并且按**量纲/单位**归一后比较。
|
||||
// 本文件是 `dedupe_group` 的**数值门**(2026-10-25):数值冲突要同时认出 ASCII 数字
|
||||
// 与中文数字,按**量纲/单位归一**后比较,并给出**三态**结论。
|
||||
//
|
||||
// 动因是一次实测:`sameNumbers`(tidy_containment.go)只抽 ASCII 数字 token,
|
||||
// 而本语料的数字主要写成中文(一小时 / 十五分钟 / 第二杯 / 一辈子)——
|
||||
// 于是「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**
|
||||
// 被当成同一事实合并掉。复审在 22 个簇里量到约 5 对真实冲突。
|
||||
// 沿革(三轮复审各修一处,别把其中任何一条当"可简化"):
|
||||
// - r2:`sameNumbers` 只抽 ASCII 数字 token,而语料里的数字主要写成中文
|
||||
// (一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒ 真冲突被当同一事实合并(实测 ~8 对);
|
||||
// - r3-项 2(**过严**):归一后**同值**、中文与 ASCII 混写的两条必须能合并
|
||||
// (`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)—— 归一化就是为此存在;
|
||||
// - r3-项 3(**过松**):**量纲不可比**(`一小时` vs `一杯`)不许"当作不算冲突"放行,
|
||||
// 必须按「比不了就不许合并」保守拒绝。
|
||||
//
|
||||
// 🔴 **两道自我约束**(复审明确要求):
|
||||
// 1. **不做成「两边都有中文数字就一律不合并」**:那样等于把规则废掉。判据是
|
||||
// 「**同一个量纲**里,值不相等」才算冲突;两边都写「一杯」照样合。
|
||||
// 2. **只在数值上收紧,不放宽别的门**:本门只**增加**否决,不改变任何「原本会被
|
||||
// 否决的对」(ASCII 路径仍由 `sameNumbers` 先判,一字未动)。
|
||||
// ⇒ 判据是**三态**,不是布尔「有没有冲突」:
|
||||
//
|
||||
// 形态判据是「**中文数字 + 单位**」,而不是「中文数字」:
|
||||
// 同值 ⇒ 可合并(tidyNumberSame)
|
||||
// 同量纲不同值 ⇒ 冲突,不许合并(tidyNumberConflict)
|
||||
// 量纲不可比/无可比 ⇒ 保守不许合并(tidyNumberIncomparable)
|
||||
//
|
||||
// 三态都建立在同一个前提上:**被吸收行的每个数量都要在存活行里找到等值对应**
|
||||
// (方向与被吸收 ⊆ 存活 的软合并语义一致)。找不到时,看**为什么找不到**来分态:
|
||||
// 存活行在同一量纲里有数量但值不同 ⇒ 冲突;存活行在该量纲里根本没有数量
|
||||
//(含"存活行一个数字都没有")⇒ 量纲不可比 ⇒ 保守拒绝。
|
||||
//
|
||||
// 形态判据是「**数字 + 单位**」而不是「数字」:
|
||||
// - 只认后面紧跟已知单位(小时/分钟/杯/次/份…)的数字,因此「一起」「一直」
|
||||
// 「一些」「一样」「一部分」这类**含数字的常见词**不会被当成数量;
|
||||
// - 数字后面没有单位时**不产生数量**(哪怕它是真的数量):NL 里的裸中文数字
|
||||
// 绝大多数是习语,收它会把精确度换成一堆误否决。
|
||||
//
|
||||
// 单位不可换算时按**保守方向**处理(复审口径「比不了单位就不许合并」):同一个
|
||||
// 量纲里值不相等、单位又换算不到一起 ⇒ 一律算冲突。
|
||||
// 「一些」「一样」「一部分」这类含数字的常见词不会被当成数量;
|
||||
// - 没有单位的数字走**裸 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 是单位名(写清冲突原因,也是「能不能换算」的判据)。
|
||||
unit string
|
||||
// dim 是量纲(time / count / length / text)。同量纲才可比。
|
||||
dim string
|
||||
// raw 是原始写法(一~数字+单位),只用于日志/测试断言。
|
||||
// raw 是原始写法(数字 + 单位),只用于日志/测试断言。
|
||||
raw string
|
||||
}
|
||||
|
||||
// dedupeNumberConflicts 返回被吸收行与存活行之间的**数值冲突**(空 = 无冲突)。
|
||||
// judgeDedupeNumbers 判一对记忆的数值关系(三态),并给出理由(证据/日志用)。
|
||||
//
|
||||
// 判据(逐条被吸收行的数量):
|
||||
// 1. 存活行在**同一量纲**里没有任何数量 ⇒ 不算冲突(被吸收行多出来的细节,
|
||||
// 合并不改变存活行正文,因此不算「冲突」,与取值门的单向口径一致);
|
||||
// 2. 存活行在同一量纲里有**等值**的数量 ⇒ 不算冲突(单位可以不同:三份 vs 三张);
|
||||
// 3. 其余 ⇒ 冲突(值不等;单位换算不到一起时同样算冲突,方向保守)。
|
||||
func dedupeNumberConflicts(absorbed, survivor string) []string {
|
||||
have := tidyQuantities(survivor)
|
||||
if len(have) == 0 {
|
||||
return nil
|
||||
// 顺序:先比**裸数字 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)}
|
||||
}
|
||||
gaps := make([]string, 0, 2)
|
||||
for _, q := range tidyQuantities(absorbed) {
|
||||
same, comparable, sameDim := false, false, false
|
||||
for _, h := range have {
|
||||
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
|
||||
}
|
||||
@@ -61,78 +98,156 @@ func dedupeNumberConflicts(absorbed, survivor string) []string {
|
||||
same = true
|
||||
break
|
||||
}
|
||||
if convertibleUnits(q, h) {
|
||||
comparable = true
|
||||
}
|
||||
}
|
||||
if same || !sameDim {
|
||||
// 等值 ⇒ 不是冲突;同量纲里根本没有可比对的数量 ⇒ 也**不是**冲突
|
||||
//(被吸收行多出来的细节,合并不改存活行正文,因此不算「冲突」)。
|
||||
// 这一条是「不同量纲不算冲突」的落点:`四行歌词` 对 `一段歌词/心事`
|
||||
// 里 `行`(文字量)与 `段`(计数)根本不是同一回事。
|
||||
if same {
|
||||
continue
|
||||
}
|
||||
if comparable {
|
||||
gaps = append(gaps, fmt.Sprintf("%s≠同量纲的另一取值", q.raw))
|
||||
if sameDim {
|
||||
// 同量纲、值不相等(含单位换算不到一起)⇒ 真冲突。
|
||||
verdict = tidyNumberConflict
|
||||
reasons = append(reasons, fmt.Sprintf("%s≠同量纲的其它取值", q.raw))
|
||||
continue
|
||||
}
|
||||
gaps = append(gaps, fmt.Sprintf("%s 与存活行的同量纲数量不可换算", q.raw))
|
||||
// 存活行在该量纲里没有数量 ⇒ 比不了 ⇒ 保守拒绝(r3-项 3)。
|
||||
// 注意:这条**不是**「放行」,因此 `一小时` vs `一杯` 不会被合并。
|
||||
if verdict != tidyNumberConflict {
|
||||
verdict = tidyNumberIncomparable
|
||||
}
|
||||
reasons = append(reasons, fmt.Sprintf("%s 在存活行里没有同量纲数量可比", q.raw))
|
||||
}
|
||||
return gaps
|
||||
return verdict, reasons
|
||||
}
|
||||
|
||||
// sameQuantity 报告两处数量是否等值(同量纲已由调用方保证)。
|
||||
// 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
|
||||
}
|
||||
// 浮点留一点余量:换算会乘除(半小时 = 0.5×3600 秒)。
|
||||
return diff < 0.5
|
||||
}
|
||||
|
||||
// convertibleUnits 报告两处数量的单位能不能换算到一起(同一量纲里,计数类单位
|
||||
// 之间**不能**互换:两杯 ≠ 三次)。
|
||||
func convertibleUnits(a, b tidyQuantity) bool {
|
||||
if a.dim != b.dim {
|
||||
// sameStringSet 报告两个字符串切片(已排序去重)是否完全相等。
|
||||
func sameStringSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if a.dim == tidyDimCount {
|
||||
// 计数器的量纲相同但单位不可互换(杯/次/份/张…)——除非本来就是同一个单位。
|
||||
return a.unit == b.unit
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return tidyUnitFactor(a.dim, a.unit) > 0 && tidyUnitFactor(b.dim, b.unit) > 0
|
||||
return true
|
||||
}
|
||||
|
||||
// tidyQuantities 抽出一段正文里所有的「中文数字 + 单位」。
|
||||
// tidyQuantities 抽出一段正文里所有的「数字 + 单位」(ASCII 与中文数字都算)。
|
||||
func tidyQuantities(text string) []tidyQuantity {
|
||||
runes := []rune(dedupeKey(text))
|
||||
out := make([]tidyQuantity, 0, 4)
|
||||
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++ {
|
||||
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) {
|
||||
// 「第二天」「第三周」是**序数位置**,不是时长。
|
||||
// ① 中文数字 + 单位
|
||||
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
|
||||
}
|
||||
raw := string(runes[i:unext])
|
||||
out = append(out, tidyQuantity{value: value * factor, unit: unit, dim: dim, raw: raw})
|
||||
i = unext - 1
|
||||
// ② 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
|
||||
}
|
||||
@@ -162,14 +277,15 @@ func tidyUnitAt(text []rune, at int) (string, string, float64, int, bool) {
|
||||
var bestFactor float64
|
||||
bestEnd := at
|
||||
for _, u := range tidyUnitTable() {
|
||||
if at+len([]rune(u.name)) > len(text) {
|
||||
n := len([]rune(u.name))
|
||||
if at+n > len(text) {
|
||||
continue
|
||||
}
|
||||
if string(text[at:at+len([]rune(u.name))]) != u.name {
|
||||
if string(text[at:at+n]) != 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 n > len([]rune(best)) {
|
||||
best, bestDim, bestFactor, bestEnd = u.name, u.dim, u.factor, at+n
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
@@ -195,8 +311,7 @@ type tidyUnit struct {
|
||||
// tidyUnitTable 是已知单位表(换算到量纲基准单位:时间=秒、计数=个、长度=米)。
|
||||
//
|
||||
// 月份/年用**约定值**(30 天 / 365 天)换算:它们在语料里是「多久」的意思,
|
||||
// 不换算就变成「比不了」,那会让「半年 vs 六个月」也被判冲突 —— 虽然方向保守,
|
||||
// 但会把一条正确事实挡在簇外。约定值让它们可比。
|
||||
// 不换算就变成「比不了」,那会让「半年 vs 六个月」也被判不可比。
|
||||
func tidyUnitTable() []tidyUnit {
|
||||
return []tidyUnit{
|
||||
// 时间(基准:秒)
|
||||
@@ -213,7 +328,7 @@ func tidyUnitTable() []tidyUnit {
|
||||
{"礼拜", tidyDimTime, 604800},
|
||||
{"个月", tidyDimTime, 2592000},
|
||||
{"年", tidyDimTime, 31536000},
|
||||
// 计数(基准:个;**单位之间不互换**,见 convertibleUnits)
|
||||
// 计数(基准:个;不同计数单位之间**不可互换**)
|
||||
{"次", tidyDimCount, 1},
|
||||
{"遍", tidyDimCount, 1},
|
||||
{"圈", tidyDimCount, 1},
|
||||
@@ -244,22 +359,14 @@ func tidyUnitTable() []tidyUnit {
|
||||
{"米", tidyDimLength, 1},
|
||||
{"厘米", tidyDimLength, 0.01},
|
||||
// 文字量(基准:个)
|
||||
{"字符", tidyDimText, 1},
|
||||
{"字节", tidyDimText, 1},
|
||||
{"行", 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:] 处解析一个中文数字(含「半」),返回数值与结束下标。
|
||||
//
|
||||
// 覆盖语料里真实出现的形态:一/两/三…九、十、十五、二十、二十四、一百二十、两千、
|
||||
@@ -322,7 +429,6 @@ func parseChineseNumberAt(text []rune, at int) (float64, int, bool) {
|
||||
if !seen {
|
||||
return 0, at, false
|
||||
}
|
||||
// 结束:把最后一个裸数字并进小节。
|
||||
total += section + digit
|
||||
if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 {
|
||||
// 「两三」「三四」这类并列不是确定数量,按模糊处理(不认)。
|
||||
|
||||
@@ -1,54 +1,118 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` **中文数字门**(r2 复审 P2)的验收用例集:
|
||||
// 中位数写法下的数值冲突(一小时 vs 十五分钟、第二杯 vs 一杯)、单位不可换算的
|
||||
// 保守拒绝、同值照常合并,以及中文数字解析口径本身。
|
||||
// 本文件是 `dedupe_group` **数值门**的验收用例集(三态)。
|
||||
//
|
||||
// 沿革:r2 复审要「认出中文数字的数值冲突」→ r3 复审把规格收成**三态**,并指出我第一版
|
||||
// 两个方向都反了:
|
||||
// - 项 2(过严):归一后**同值**、中文与 ASCII 混写(`一小时` vs `60 分钟`)必须**能合并**;
|
||||
// - 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)必须**保守不许合并**,不是放行。
|
||||
//
|
||||
// 因此本文件的核心用例是「三态各一条」,且每条都同时断言**判定**与**簇结果** ——
|
||||
// 只断言簇结果的话,别的原因(相似度地板 / 取值门)也能让簇消失,测不出数值门。
|
||||
|
||||
// TestDedupeGroupNumberGateThreeStates 是三态的主用例。
|
||||
//
|
||||
// 旧逻辑红/新逻辑绿的证据(已实测):
|
||||
// - 同值那一条:旧代码用 `sameNumbers`(只认 ASCII token)⇒ `{}` vs `{60}` 不等 ⇒ 不合 ⇒ 红;
|
||||
// - 不可比那一条:r2 版把「存活行同量纲没有数量」当**放行** ⇒ 合了 ⇒ 红。
|
||||
func TestDedupeGroupNumberGateThreeStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
survivor string
|
||||
member string
|
||||
want tidyNumberVerdict
|
||||
wantMerged bool
|
||||
}{
|
||||
{
|
||||
name: "同值:中文与 ASCII 混写(一小时 ≡ 60 分钟)",
|
||||
survivor: "主人午睡 60 分钟,下午很清醒。",
|
||||
member: "主人午睡一小时,午后精神好。",
|
||||
want: tidyNumberSame,
|
||||
wantMerged: true,
|
||||
},
|
||||
{
|
||||
name: "同值:十五分钟 ≡ 15 分钟",
|
||||
survivor: "主人午睡 15 分钟,起来刚好。",
|
||||
member: "主人午睡十五分钟,醒来正好。",
|
||||
want: tidyNumberSame,
|
||||
wantMerged: true,
|
||||
},
|
||||
{
|
||||
name: "同量纲不同值:一小时 vs 十五分钟",
|
||||
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
|
||||
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
|
||||
want: tidyNumberConflict,
|
||||
wantMerged: false,
|
||||
},
|
||||
{
|
||||
name: "量纲不可比:一小时 vs 一杯",
|
||||
survivor: "主人午睡时喝了一杯温牛奶,午后精神好。",
|
||||
member: "主人午睡一小时,午后精神好。",
|
||||
want: tidyNumberIncomparable,
|
||||
wantMerged: false,
|
||||
},
|
||||
}
|
||||
tags := []string{"作息", "午睡"}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := dedupeTestItem(tc.survivor, tags, base)
|
||||
b := dedupeTestItem(tc.member, tags, base.Add(time.Minute))
|
||||
verdict, reasons := judgeDedupeNumbers(b.Content, a.Content)
|
||||
if verdict != tc.want {
|
||||
t.Fatalf("判定 = %s %v, want %s(MEMBER=%q SURV=%q)",
|
||||
verdict, reasons, tc.want, b.Content, a.Content)
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b})
|
||||
if tc.wantMerged && len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1(同值必须能合并)", len(cands))
|
||||
}
|
||||
if !tc.wantMerged && len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(%s ⇒ 不许合并)", len(cands), verdict)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupRejectsConflictingChineseNumbers 是 r2 复审 **P2 的回归用例**:
|
||||
// 语料里的数字大多写成**中文**,而 `sameNumbers` 只抽 ASCII 数字 token ⇒
|
||||
// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**
|
||||
// 曾被当成同一事实合并掉。
|
||||
//
|
||||
// 旧逻辑(只有 sameNumbers)下这些对会成簇 ⇒ 用例红;补中文数字门后不成簇 ⇒ 绿。
|
||||
// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」曾被当成同一事实。
|
||||
func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
survivor string
|
||||
member string
|
||||
tagsA []string
|
||||
tagsB []string
|
||||
whyItReds string
|
||||
name string
|
||||
survivor string
|
||||
member string
|
||||
tags []string
|
||||
}{
|
||||
{
|
||||
name: "午睡时长(一小时 vs 十五分钟)",
|
||||
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
|
||||
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
|
||||
tagsA: []string{"作息", "午睡"},
|
||||
tagsB: []string{"作息", "午睡"},
|
||||
tags: []string{"作息", "午睡"},
|
||||
},
|
||||
{
|
||||
name: "咖啡杯数(第二杯 vs 一杯)",
|
||||
survivor: "用户被约定:一天咖啡不许续第二杯,下午犯困时起身走动、喝温水。",
|
||||
member: "用户有喝咖啡的习惯,助手提醒她每天不超过一杯(第二杯不许续)。",
|
||||
tagsA: []string{"习惯", "咖啡"},
|
||||
tagsB: []string{"习惯", "咖啡"},
|
||||
tags: []string{"习惯", "咖啡"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := dedupeTestItem(tc.survivor, tc.tagsA, base)
|
||||
b := dedupeTestItem(tc.member, tc.tagsB, base.Add(time.Minute))
|
||||
// 先钉住「这不是因为别的门挡住的」:数值门确实报出冲突。
|
||||
if gaps := dedupeNumberConflicts(b.Content, a.Content); len(gaps) == 0 {
|
||||
t.Fatalf("中文数字门没报出冲突:%q vs %q", b.Content, a.Content)
|
||||
a := dedupeTestItem(tc.survivor, tc.tags, base)
|
||||
b := dedupeTestItem(tc.member, tc.tags, base.Add(time.Minute))
|
||||
if verdict, _ := judgeDedupeNumbers(b.Content, a.Content); verdict != tidyNumberConflict {
|
||||
t.Fatalf("判定 = %s, want conflict", verdict)
|
||||
}
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(数值冲突=不同事实,§5.2-2)", len(cands))
|
||||
@@ -57,40 +121,40 @@ func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」
|
||||
// (r2 复审的明确口径):两杯 vs 三瓶是同一个计数量的不同取值,但杯/瓶不能互换。
|
||||
// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」:
|
||||
// 两杯 vs 三瓶是同一量纲的不同取值,但杯/瓶不能互换。
|
||||
func TestDedupeGroupRejectsNonConvertibleUnits(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"习惯", "饮水"}
|
||||
a := dedupeTestItem("主人每天要喝两杯咖啡,下午不续。", tags, base)
|
||||
b := dedupeTestItem("主人每天要喝三瓶水,下午也要喝。", tags, base.Add(time.Minute))
|
||||
if gaps := dedupeNumberConflicts(b.Content, a.Content); len(gaps) == 0 {
|
||||
t.Fatalf("单位不可换算没有报冲突:%q vs %q", b.Content, a.Content)
|
||||
if verdict, _ := judgeDedupeNumbers(b.Content, a.Content); verdict != tidyNumberConflict {
|
||||
t.Fatalf("判定 = %s, want conflict(同量纲不同值)", verdict)
|
||||
}
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(单位不可换算 ⇒ 保守不合并)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupSameChineseNumberStillMerges 是中文数字门的**反向护栏**:
|
||||
// 值相等(含不同单位换算后相等)照常成簇 —— 门收的是「冲突」,不是「出现数字」。
|
||||
// TestDedupeGroupSameChineseNumberStillMerges 是数值门的**反向护栏**:
|
||||
// 值相等(含不同单位换算后相等、中英混写)照常成簇 —— 门收的是「冲突」,不是「出现数字」。
|
||||
func TestDedupeGroupSameChineseNumberStillMerges(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"作息", "午睡"}
|
||||
a := dedupeTestItem("主人午睡一小时,午后精神好。", tags, base)
|
||||
b := dedupeTestItem("主人午睡一小时,下午很清醒。", tags, base.Add(time.Minute))
|
||||
// 一条写「一个小时」、另一条写「一小时」:换算后同为 3600 秒,属于同一事实。
|
||||
c := dedupeTestItem("主人午睡一个小时,午后精神不错。", tags, base.Add(2*time.Minute))
|
||||
b := dedupeTestItem("主人午睡一个小时,下午很清醒。", tags, base.Add(time.Minute))
|
||||
c := dedupeTestItem("主人午睡 60 分钟,起来神清气爽。", tags, base.Add(2*time.Minute))
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b, c})
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1(同值照常合并 —— 门不许退化成「有中文数字就不合」)", len(cands))
|
||||
t.Fatalf("簇数 = %d, want 1(同值照常合并 —— 门不许退化成「有数字就不合」)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyQuantitiesChineseNumerals 钉住中文数字的解析口径(含单位换算与习语排除)。
|
||||
func TestTidyQuantitiesChineseNumerals(t *testing.T) {
|
||||
// TestTidyQuantitiesNumberParsing 钉住数量解析口径:中文数字、ASCII 数字、单位换算、
|
||||
// 以及「没有单位的数字走裸 token 路径」。
|
||||
func TestTidyQuantitiesNumberParsing(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
text string
|
||||
@@ -104,6 +168,9 @@ func TestTidyQuantitiesChineseNumerals(t *testing.T) {
|
||||
{"主人午睡半个小时", 1800, "小时", tidyDimTime, true},
|
||||
{"一万两千行的验收纸条", 12000, "行", tidyDimText, true},
|
||||
{"每天要喝两杯咖啡", 2, "杯", tidyDimCount, true},
|
||||
// ASCII 数字 + 单位(r3 必需:`60 分钟` 与 `一小时` 要能归一)
|
||||
{"主人午睡 60 分钟", 3600, "分钟", tidyDimTime, true},
|
||||
{"主人午睡15分钟", 900, "分钟", tidyDimTime, true},
|
||||
// 序数与习语:不是数量。
|
||||
{"第二天再说", 0, "", "", false},
|
||||
{"一起散步", 0, "", "", false},
|
||||
@@ -126,3 +193,43 @@ func TestTidyQuantitiesChineseNumerals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanTidyNumbersBareTokensMatchExistingFactNums 钉住「裸数字 token 口径没变」:
|
||||
// 当正文里**没有任何「数字 + 单位」**时,本文件的裸 token 抽取必须与既有
|
||||
// `extractTidyFacts` 的 nums 完全一致 —— 否则「数值门取代 sameNumbers」会悄悄放宽
|
||||
// 一条既有判据(`0023` vs `0024` 这类无单位数字正是靠它拦住的)。
|
||||
//
|
||||
// 有「数字 + 单位」时**刻意**不再进裸 token(那正是 r3 项 2 要的:`60 分钟` 要能与
|
||||
// `一小时` 归一),因此第二条断言把这个分歧也钉住。
|
||||
func TestScanTidyNumbersBareTokensMatchExistingFactNums(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 这些输入里**没有任何「数字 + 单位」**,因此裸 token 必须与既有抽取逐项一致。
|
||||
parity := []string{
|
||||
"迁移号从 0023 起递增",
|
||||
"文件路径 D:/a/b 与端口 8080",
|
||||
"h3R3 的歌曲循环了三遍",
|
||||
"登记编号 624046 已入库",
|
||||
"2026-10-03 那条记录", // 日期 token 由 hasAbsoluteTimestamp 在分簇前就排除
|
||||
}
|
||||
for _, text := range parity {
|
||||
_, bare := scanTidyNumbers(text)
|
||||
facts := extractTidyFacts(text)
|
||||
want := make([]string, 0, len(facts.nums))
|
||||
for n := range facts.nums {
|
||||
want = append(want, n)
|
||||
}
|
||||
sort.Strings(want)
|
||||
if !sameStringSet(bare, want) {
|
||||
t.Fatalf("%q 裸 token = %v, want %v(必须与 extractTidyFacts 同口径)", text, bare, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 分歧(刻意):带单位的数字进「数量」,不再充当裸 token。
|
||||
qty, bare := scanTidyNumbers("主人午睡 60 分钟")
|
||||
if len(bare) != 0 {
|
||||
t.Fatalf("`60 分钟` 的裸 token = %v, want 空(它已归一成数量)", bare)
|
||||
}
|
||||
if len(qty) != 1 || qty[0].value != 3600 || qty[0].dim != tidyDimTime {
|
||||
t.Fatalf("`60 分钟` 解析 = %+v, want 3600 秒(时间量纲)", qty)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user