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:
AskaEth committed 2026-10-25 00:49:44 +08:00
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
}
+202 -96
View File
@@ -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)
}
}