fix(memory): dedupe_group 补中文数字门(量纲/单位归一),修 r2 复审 p2
r2 复审 p2:`sameNumbers` 只抽 ASCII 数字 token,而本语料的数字主要写成中文 (一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒「午睡一小时 vs 十五分钟」 「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**被当成同一事实合并。 修法(新文件 internal/runtime/memory/tidy_dedupe_numbers.go): - 抽出「**中文数字 + 单位**」对(一~九/两/半/十/百/千/万,含十五、二十四、一万两千、 半个小时),按**量纲归一**后比较:时间=秒(分钟 60 / 小时 3600 / 天 86400 / 周 604800 / 个月 2592000 / 年 31536000)、长度=米、文字量=个、计数=个; - 只认**后面紧跟已知单位**的数字:`一起 / 一直 / 一些 / 一样 / 第二天(序数位置)` 这类含数字的习语一律不产生数量 —— 不做成「有中文数字就不合并」; - 判据是**冲突**而非差异:存活行在同量纲里没有对应数量 ⇒ 不算冲突(被吸收行多出 的细节);等值(含单位换算后等值,如「半小时」=1800 秒)⇒ 照常合并; 值不等且单位换算不到一起 ⇒ 按冲突处理(复审口径:宁可不合并); - 只**增加否决**,不放宽任何既有门;ASCII 路径仍由 sameNumbers 先判,一字未动; `sameNumbers` 本身也未改(judgePair/judgeIngest 共用它,属于另一处 blast radius, 已在回报里登记为后续建议)。 语料实测(生产库只读 mode=ro,active 3667):999 对里中文数字门挡掉 **17 对**, 其中真冲突:午睡十五分钟 vs 一小时 ×3、咖啡一杯 vs 第二杯 ×4、 「三千次轮回」vs「三千万次轮回」×1(ASCII 路径完全看不见这条); 其余 9 对是「单位不可换算」的保守拒绝(半碗 vs 一个包/一杯、一次 vs 两圈)。 dedupe 产出 255 簇/505 行 → **250 簇/495 行(13.5%)**。 回归用例(去掉这道门即红,已实测): - TestDedupeGroupRejectsConflictingChineseNumbers(复审给的两对真实语料,脱敏保留原意) - TestDedupeGroupRejectsNonConvertibleUnits(两杯 vs 三瓶 ⇒ 保守不合并) - TestDedupeGroupSameChineseNumberStillMerges(同值/换算后同值 ⇒ 照常合并,门没退化成「有数字就不合」) - TestTidyQuantitiesChineseNumerals(解析口径:一小时=3600s、十五分钟=900s、半小时=1800s、 一万两千行=12000、第二天/一起/一直 不产生数量) 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
This commit is contained in:
1 parent
54e32304f2
commit
7700a7eda0
5 files changed
+601
-86
No files matched your search
@@ -21,6 +21,10 @@ import (
|
||||
// 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 集),内容差得
|
||||
@@ -40,6 +44,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
package memory
|
||||
|
||||
import "fmt"
|
||||
|
||||
// 本文件是 `dedupe_group` 的**中文数字门**(2026-10-25,r2 复审 P2 的落点):
|
||||
// 数值冲突不仅要认出 ASCII 数字,还要认出中文数字,并且按**量纲/单位**归一后比较。
|
||||
//
|
||||
// 动因是一次实测:`sameNumbers`(tidy_containment.go)只抽 ASCII 数字 token,
|
||||
// 而本语料的数字主要写成中文(一小时 / 十五分钟 / 第二杯 / 一辈子)——
|
||||
// 于是「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**
|
||||
// 被当成同一事实合并掉。复审在 22 个簇里量到约 5 对真实冲突。
|
||||
//
|
||||
// 🔴 **两道自我约束**(复审明确要求):
|
||||
// 1. **不做成「两边都有中文数字就一律不合并」**:那样等于把规则废掉。判据是
|
||||
// 「**同一个量纲**里,值不相等」才算冲突;两边都写「一杯」照样合。
|
||||
// 2. **只在数值上收紧,不放宽别的门**:本门只**增加**否决,不改变任何「原本会被
|
||||
// 否决的对」(ASCII 路径仍由 `sameNumbers` 先判,一字未动)。
|
||||
//
|
||||
// 形态判据是「**中文数字 + 单位**」,而不是「中文数字」:
|
||||
// - 只认后面紧跟已知单位(小时/分钟/杯/次/份…)的数字,因此「一起」「一直」
|
||||
// 「一些」「一样」「一部分」这类**含数字的常见词**不会被当成数量;
|
||||
// - 数字后面没有单位时**不产生数量**(哪怕它是真的数量):NL 里的裸中文数字
|
||||
// 绝大多数是习语,收它会把精确度换成一堆误否决。
|
||||
//
|
||||
// 单位不可换算时按**保守方向**处理(复审口径「比不了单位就不许合并」):同一个
|
||||
// 量纲里值不相等、单位又换算不到一起 ⇒ 一律算冲突。
|
||||
|
||||
// tidyQuantity 是一处「数量」:归一后的值 + 单位名 + 量纲。
|
||||
type tidyQuantity struct {
|
||||
// value 是按单位换算到**量纲基准单位**后的值(时间=秒、计数=个、长度=米)。
|
||||
value float64
|
||||
// unit 是单位名(用于把冲突原因写清楚,也是「能不能换算」的判据)。
|
||||
unit string
|
||||
// dim 是量纲(time / count / length / text)。同量纲才可比。
|
||||
dim string
|
||||
// raw 是原始写法(一~数字+单位),只用于日志/测试断言。
|
||||
raw string
|
||||
}
|
||||
|
||||
// dedupeNumberConflicts 返回被吸收行与存活行之间的**数值冲突**(空 = 无冲突)。
|
||||
//
|
||||
// 判据(逐条被吸收行的数量):
|
||||
// 1. 存活行在**同一量纲**里没有任何数量 ⇒ 不算冲突(被吸收行多出来的细节,
|
||||
// 合并不改变存活行正文,因此不算「冲突」,与取值门的单向口径一致);
|
||||
// 2. 存活行在同一量纲里有**等值**的数量 ⇒ 不算冲突(单位可以不同:三份 vs 三张);
|
||||
// 3. 其余 ⇒ 冲突(值不等;单位换算不到一起时同样算冲突,方向保守)。
|
||||
func dedupeNumberConflicts(absorbed, survivor string) []string {
|
||||
have := tidyQuantities(survivor)
|
||||
if len(have) == 0 {
|
||||
return nil
|
||||
}
|
||||
gaps := make([]string, 0, 2)
|
||||
for _, q := range tidyQuantities(absorbed) {
|
||||
same, comparable, sameDim := false, false, false
|
||||
for _, h := range have {
|
||||
if h.dim != q.dim {
|
||||
continue
|
||||
}
|
||||
sameDim = true
|
||||
if sameQuantity(q, h) {
|
||||
same = true
|
||||
break
|
||||
}
|
||||
if convertibleUnits(q, h) {
|
||||
comparable = true
|
||||
}
|
||||
}
|
||||
if same || !sameDim {
|
||||
// 等值 ⇒ 不是冲突;同量纲里根本没有可比对的数量 ⇒ 也**不是**冲突
|
||||
//(被吸收行多出来的细节,合并不改存活行正文,因此不算「冲突」)。
|
||||
// 这一条是「不同量纲不算冲突」的落点:`四行歌词` 对 `一段歌词/心事`
|
||||
// 里 `行`(文字量)与 `段`(计数)根本不是同一回事。
|
||||
continue
|
||||
}
|
||||
if comparable {
|
||||
gaps = append(gaps, fmt.Sprintf("%s≠同量纲的另一取值", q.raw))
|
||||
continue
|
||||
}
|
||||
gaps = append(gaps, fmt.Sprintf("%s 与存活行的同量纲数量不可换算", q.raw))
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
return diff < 0.5
|
||||
}
|
||||
|
||||
// convertibleUnits 报告两处数量的单位能不能换算到一起(同一量纲里,计数类单位
|
||||
// 之间**不能**互换:两杯 ≠ 三次)。
|
||||
func convertibleUnits(a, b tidyQuantity) bool {
|
||||
if a.dim != b.dim {
|
||||
return false
|
||||
}
|
||||
if a.dim == tidyDimCount {
|
||||
// 计数器的量纲相同但单位不可互换(杯/次/份/张…)——除非本来就是同一个单位。
|
||||
return a.unit == b.unit
|
||||
}
|
||||
return tidyUnitFactor(a.dim, a.unit) > 0 && tidyUnitFactor(b.dim, b.unit) > 0
|
||||
}
|
||||
|
||||
// tidyQuantities 抽出一段正文里所有的「中文数字 + 单位」。
|
||||
func tidyQuantities(text string) []tidyQuantity {
|
||||
runes := []rune(dedupeKey(text))
|
||||
out := make([]tidyQuantity, 0, 4)
|
||||
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) {
|
||||
// 「第二天」「第三周」是**序数位置**,不是时长。
|
||||
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
|
||||
}
|
||||
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() {
|
||||
if at+len([]rune(u.name)) > len(text) {
|
||||
continue
|
||||
}
|
||||
if string(text[at:at+len([]rune(u.name))]) != 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 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},
|
||||
// 计数(基准:个;**单位之间不互换**,见 convertibleUnits)
|
||||
{"次", 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},
|
||||
}
|
||||
}
|
||||
|
||||
// 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:] 处解析一个中文数字(含「半」),返回数值与结束下标。
|
||||
//
|
||||
// 覆盖语料里真实出现的形态:一/两/三…九、十、十五、二十、二十四、一百二十、两千、
|
||||
// 一万两千、半。**不认**「几/十几/数/好几」这类模糊量词(它们不是可比数量)。
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` **中文数字门**(r2 复审 P2)的验收用例集:
|
||||
// 中位数写法下的数值冲突(一小时 vs 十五分钟、第二杯 vs 一杯)、单位不可换算的
|
||||
// 保守拒绝、同值照常合并,以及中文数字解析口径本身。
|
||||
|
||||
// TestDedupeGroupRejectsConflictingChineseNumbers 是 r2 复审 **P2 的回归用例**:
|
||||
// 语料里的数字大多写成**中文**,而 `sameNumbers` 只抽 ASCII 数字 token ⇒
|
||||
// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**
|
||||
// 曾被当成同一事实合并掉。
|
||||
//
|
||||
// 旧逻辑(只有 sameNumbers)下这些对会成簇 ⇒ 用例红;补中文数字门后不成簇 ⇒ 绿。
|
||||
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: "午睡时长(一小时 vs 十五分钟)",
|
||||
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
|
||||
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
|
||||
tagsA: []string{"作息", "午睡"},
|
||||
tagsB: []string{"作息", "午睡"},
|
||||
},
|
||||
{
|
||||
name: "咖啡杯数(第二杯 vs 一杯)",
|
||||
survivor: "用户被约定:一天咖啡不许续第二杯,下午犯困时起身走动、喝温水。",
|
||||
member: "用户有喝咖啡的习惯,助手提醒她每天不超过一杯(第二杯不许续)。",
|
||||
tagsA: []string{"习惯", "咖啡"},
|
||||
tagsB: []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)
|
||||
}
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(数值冲突=不同事实,§5.2-2)", len(cands))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」
|
||||
// (r2 复审的明确口径):两杯 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 cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(单位不可换算 ⇒ 保守不合并)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b, c})
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1(同值照常合并 —— 门不许退化成「有中文数字就不合」)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyQuantitiesChineseNumerals 钉住中文数字的解析口径(含单位换算与习语排除)。
|
||||
func TestTidyQuantitiesChineseNumerals(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
text string
|
||||
value float64
|
||||
unit string
|
||||
dim string
|
||||
ok bool
|
||||
}{
|
||||
{"主人午睡一小时", 3600, "小时", tidyDimTime, true},
|
||||
{"主人午睡十五分钟", 900, "分钟", tidyDimTime, true},
|
||||
{"主人午睡半个小时", 1800, "小时", tidyDimTime, true},
|
||||
{"一万两千行的验收纸条", 12000, "行", tidyDimText, true},
|
||||
{"每天要喝两杯咖啡", 2, "杯", tidyDimCount, true},
|
||||
// 序数与习语:不是数量。
|
||||
{"第二天再说", 0, "", "", false},
|
||||
{"一起散步", 0, "", "", false},
|
||||
{"一直加班", 0, "", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := tidyQuantities(tc.text)
|
||||
if !tc.ok {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("%q 应不产生数量,得到 %+v", tc.text, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("%q 没解析出数量", tc.text)
|
||||
}
|
||||
if got[0].value != tc.value || got[0].unit != tc.unit || got[0].dim != tc.dim {
|
||||
t.Fatalf("%q 解析为 %+v, want value=%v unit=%s dim=%s",
|
||||
tc.text, got[0], tc.value, tc.unit, tc.dim)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -310,91 +309,6 @@ func TestDedupeGroupIsNeverAutoApplied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupRejectsSameTagDifferentValue 是 r1 复审 **P1-1 的回归用例**:
|
||||
// 同 tag 集、但**取值不同**(`「老K」` vs `「阿七」`)的两条**不许同簇**。
|
||||
//
|
||||
// 语料原型(脱敏):生产库里 `用户的昵称是「叶酱」。` 曾被并进
|
||||
// `主人的昵称是「喵喵七号」…` 的存活行,而同库另有 4 行明确写着「旧的称呼
|
||||
// 『喵喵七号』已作废,不要再使用」—— 一次 apply 就会让当前事实消失,并留下与库内
|
||||
// 其它行直接矛盾的结论。因此这条用例钉的不是「少减一点」,而是「不许毁数据」。
|
||||
func TestDedupeGroupRejectsSameTagDifferentValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"昵称", "称呼"}
|
||||
|
||||
// 取值不同:只剩存活行一条 ⇒ 不成簇、一条候选都不产。
|
||||
survivor := dedupeTestItem("主人的昵称是「老K」,是相处中慢慢形成的亲昵称呼;具体来历不明,不可编造。", tags, base)
|
||||
staleA := dedupeTestItem("用户的昵称是「阿七」。", tags, base.Add(time.Minute))
|
||||
staleB := dedupeTestItem("主人被称呼为「阿七」,助手平时就这么叫她。", tags, base.Add(2*time.Minute))
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, staleA, staleB})
|
||||
if len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(取值不同=不同事实,§5.2-4/§5.2-5)", len(cands))
|
||||
}
|
||||
for _, m := range []MemoryItem{staleA, staleB} {
|
||||
if gaps := dedupeValueGaps(m.Content, survivor.Content); len(gaps) == 0 {
|
||||
t.Fatalf("取值门没认出 %q 里的取值与存活行不同", m.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// 对照组:同样的 tag 集 + **相同取值**,必须照常成簇(证明上面那条不是因为门太宽)。
|
||||
sameValue := dedupeTestItem("主人被称呼为「老K」,助手平时就这么叫她。", tags, base.Add(3*time.Minute))
|
||||
cands, _ = dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, sameValue})
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("对照组簇数 = %d, want 1(同 tag 同取值必须成簇)", len(cands))
|
||||
}
|
||||
if cands[0].TargetID != survivor.ID {
|
||||
t.Fatalf("对照组存活行 = %s, want 最长的那条 %s", cands[0].TargetID, survivor.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupLatinTokenIsAlsoAValue 钉住取值门的第二类取值:**拉丁 token**
|
||||
// (人名 / 型号 / 代号)。`助手名为 Cyrene` 与 `助手名为 Alice` 同 tag 集,但取值不同。
|
||||
func TestDedupeGroupLatinTokenIsAlsoAValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"身份", "名字"}
|
||||
keep := dedupeTestItem("助手名为 Cyrene,主人习惯这么叫我。", tags, base)
|
||||
other := dedupeTestItem("助手名为 Alice,主人习惯这么叫我。", tags, base.Add(time.Minute))
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{keep, other}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(拉丁取值不同=不同事实)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupNeverLosesAValue 是**簇级不变式**:任何产出的候选,被吸收行里的
|
||||
// 每个取值(引号取值 + 拉丁 token)都必须出现在存活行正文里 —— 否则那条取值会随
|
||||
// 合并从 active 集合里消失。它比单点用例强:新增的簇判据 / 门顺序变化都要过它。
|
||||
func TestDedupeGroupNeverLosesAValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"暗号", "约定"}
|
||||
items := []MemoryItem{
|
||||
dedupeTestItem("用户与助手的验收暗号是「青柠」", tags, base),
|
||||
dedupeTestItem("用户与助手的验收暗号是「青柠」,用于确认文档摄取正常", tags, base.Add(time.Minute)),
|
||||
dedupeTestItem("验收暗号「青柠」用于确认文档摄取功能正常", tags, base.Add(2*time.Minute)),
|
||||
// 同一 tag 集但取值不同(不许进簇)。
|
||||
dedupeTestItem("用户与助手的验收暗号是「柠檬」", tags, base.Add(3*time.Minute)),
|
||||
// 同 tag 集、取值包含了额外信息(不许进簇:那条取值会丢)。
|
||||
dedupeTestItem("验收暗号「青柠」配合口令「开门」一起使用", tags, base.Add(4*time.Minute)),
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), items)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1", len(cands))
|
||||
}
|
||||
survivor := cands[0].MergedContent
|
||||
for _, member := range cands[0].Evidence.DedupeMembers {
|
||||
for _, value := range dedupeValues(member) {
|
||||
if !strings.Contains(dedupeKey(survivor), value) {
|
||||
t.Fatalf("取值 %q 会随合并消失(member=%q survivor=%q)", value, member, survivor)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, member := range cands[0].Evidence.DedupeMembers {
|
||||
if strings.Contains(member, "柠檬") || strings.Contains(member, "开门") {
|
||||
t.Fatalf("不该被吸收的成员进了簇:%q", member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeInPartitionSkipsWhenMetaUnavailable 是 r1 复审 **P2-1 前半**的回归:
|
||||
// 元信息查询失败时**整个分区这一轮不分簇**(旧实现降级成 nil map,而 nil map 上
|
||||
// `temporary` 恒为 false ⇒ 把「临时行绝不进簇」那条红线绕开了)。
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` **取值门**(r1 复审 P1)的验收用例集:
|
||||
// 同属性不同取值(`「老K」` vs `「阿七」`)、拉丁取值、以及簇级的「取值不丢」不变式。
|
||||
// 分簇/候选构造的用例在 tidy_dedupe_test.go,中文数字门在 tidy_dedupe_numbers_test.go。
|
||||
|
||||
// TestDedupeGroupRejectsSameTagDifferentValue 是 r1 复审 **P1-1 的回归用例**:
|
||||
// 同 tag 集、但**取值不同**(`「老K」` vs `「阿七」`)的两条**不许同簇**。
|
||||
//
|
||||
// 语料原型(脱敏):生产库里 `用户的昵称是「叶酱」。` 曾被并进
|
||||
// `主人的昵称是「喵喵七号」…` 的存活行,而同库另有 4 行明确写着「旧的称呼
|
||||
// 『喵喵七号』已作废,不要再使用」—— 一次 apply 就会让当前事实消失,并留下与库内
|
||||
// 其它行直接矛盾的结论。因此这条用例钉的不是「少减一点」,而是「不许毁数据」。
|
||||
func TestDedupeGroupRejectsSameTagDifferentValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"昵称", "称呼"}
|
||||
|
||||
// 取值不同:只剩存活行一条 ⇒ 不成簇、一条候选都不产。
|
||||
survivor := dedupeTestItem("主人的昵称是「老K」,是相处中慢慢形成的亲昵称呼;具体来历不明,不可编造。", tags, base)
|
||||
staleA := dedupeTestItem("用户的昵称是「阿七」。", tags, base.Add(time.Minute))
|
||||
staleB := dedupeTestItem("主人被称呼为「阿七」,助手平时就这么叫她。", tags, base.Add(2*time.Minute))
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, staleA, staleB})
|
||||
if len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(取值不同=不同事实,§5.2-4/§5.2-5)", len(cands))
|
||||
}
|
||||
for _, m := range []MemoryItem{staleA, staleB} {
|
||||
if gaps := dedupeValueGaps(m.Content, survivor.Content); len(gaps) == 0 {
|
||||
t.Fatalf("取值门没认出 %q 里的取值与存活行不同", m.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// 对照组:同样的 tag 集 + **相同取值**,必须照常成簇(证明上面那条不是因为门太宽)。
|
||||
sameValue := dedupeTestItem("主人被称呼为「老K」,助手平时就这么叫她。", tags, base.Add(3*time.Minute))
|
||||
cands, _ = dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, sameValue})
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("对照组簇数 = %d, want 1(同 tag 同取值必须成簇)", len(cands))
|
||||
}
|
||||
if cands[0].TargetID != survivor.ID {
|
||||
t.Fatalf("对照组存活行 = %s, want 最长的那条 %s", cands[0].TargetID, survivor.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupLatinTokenIsAlsoAValue 钉住取值门的第二类取值:**拉丁 token**
|
||||
// (人名 / 型号 / 代号)。`助手名为 Cyrene` 与 `助手名为 Alice` 同 tag 集,但取值不同。
|
||||
func TestDedupeGroupLatinTokenIsAlsoAValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"身份", "名字"}
|
||||
keep := dedupeTestItem("助手名为 Cyrene,主人习惯这么叫我。", tags, base)
|
||||
other := dedupeTestItem("助手名为 Alice,主人习惯这么叫我。", tags, base.Add(time.Minute))
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{keep, other}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(拉丁取值不同=不同事实)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupNeverLosesAValue 是**簇级不变式**:任何产出的候选,被吸收行里的
|
||||
// 每个取值(引号取值 + 拉丁 token)都必须出现在存活行正文里 —— 否则那条取值会随
|
||||
// 合并从 active 集合里消失。它比单点用例强:新增的簇判据 / 门顺序变化都要过它。
|
||||
func TestDedupeGroupNeverLosesAValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"暗号", "约定"}
|
||||
items := []MemoryItem{
|
||||
dedupeTestItem("用户与助手的验收暗号是「青柠」", tags, base),
|
||||
dedupeTestItem("用户与助手的验收暗号是「青柠」,用于确认文档摄取正常", tags, base.Add(time.Minute)),
|
||||
dedupeTestItem("验收暗号「青柠」用于确认文档摄取功能正常", tags, base.Add(2*time.Minute)),
|
||||
// 同一 tag 集但取值不同(不许进簇)。
|
||||
dedupeTestItem("用户与助手的验收暗号是「柠檬」", tags, base.Add(3*time.Minute)),
|
||||
// 同 tag 集、取值包含了额外信息(不许进簇:那条取值会丢)。
|
||||
dedupeTestItem("验收暗号「青柠」配合口令「开门」一起使用", tags, base.Add(4*time.Minute)),
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), items)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1", len(cands))
|
||||
}
|
||||
survivor := cands[0].MergedContent
|
||||
for _, member := range cands[0].Evidence.DedupeMembers {
|
||||
for _, value := range dedupeValues(member) {
|
||||
if !strings.Contains(dedupeKey(survivor), value) {
|
||||
t.Fatalf("取值 %q 会随合并消失(member=%q survivor=%q)", value, member, survivor)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, member := range cands[0].Evidence.DedupeMembers {
|
||||
if strings.Contains(member, "柠檬") || strings.Contains(member, "开门") {
|
||||
t.Fatalf("不该被吸收的成员进了簇:%q", member)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user