diff --git a/internal/runtime/memory/tidy_dedupe_candidate.go b/internal/runtime/memory/tidy_dedupe_candidate.go index 6bd2b86..d8d629a 100644 --- a/internal/runtime/memory/tidy_dedupe_candidate.go +++ b/internal/runtime/memory/tidy_dedupe_candidate.go @@ -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 } diff --git a/internal/runtime/memory/tidy_dedupe_numbers.go b/internal/runtime/memory/tidy_dedupe_numbers.go index 5981f24..ad18fbb 100644 --- a/internal/runtime/memory/tidy_dedupe_numbers.go +++ b/internal/runtime/memory/tidy_dedupe_numbers.go @@ -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 { // 「两三」「三四」这类并列不是确定数量,按模糊处理(不认)。 diff --git a/internal/runtime/memory/tidy_dedupe_numbers_test.go b/internal/runtime/memory/tidy_dedupe_numbers_test.go index 3316c06..a3fd355 100644 --- a/internal/runtime/memory/tidy_dedupe_numbers_test.go +++ b/internal/runtime/memory/tidy_dedupe_numbers_test.go @@ -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) + } +}