From 7700a7eda0310b6f5ea468280514f2e86767cc6d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 25 Oct 2026 00:41:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20dedupe=5Fgroup=20=E8=A1=A5?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=95=B0=E5=AD=97=E9=97=A8=EF=BC=88=E9=87=8F?= =?UTF-8?q?=E7=BA=B2/=E5=8D=95=E4=BD=8D=E5=BD=92=E4=B8=80=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=20r2=20=E5=A4=8D=E5=AE=A1=20p2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- .../runtime/memory/tidy_dedupe_candidate.go | 7 + .../runtime/memory/tidy_dedupe_numbers.go | 370 ++++++++++++++++++ .../memory/tidy_dedupe_numbers_test.go | 128 ++++++ internal/runtime/memory/tidy_dedupe_test.go | 86 ---- .../runtime/memory/tidy_dedupe_values_test.go | 96 +++++ 5 files changed, 601 insertions(+), 86 deletions(-) create mode 100644 internal/runtime/memory/tidy_dedupe_numbers.go create mode 100644 internal/runtime/memory/tidy_dedupe_numbers_test.go create mode 100644 internal/runtime/memory/tidy_dedupe_values_test.go diff --git a/internal/runtime/memory/tidy_dedupe_candidate.go b/internal/runtime/memory/tidy_dedupe_candidate.go index d380eef..6bd2b86 100644 --- a/internal/runtime/memory/tidy_dedupe_candidate.go +++ b/internal/runtime/memory/tidy_dedupe_candidate.go @@ -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 } diff --git a/internal/runtime/memory/tidy_dedupe_numbers.go b/internal/runtime/memory/tidy_dedupe_numbers.go new file mode 100644 index 0000000..5981f24 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_numbers.go @@ -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 + } +} diff --git a/internal/runtime/memory/tidy_dedupe_numbers_test.go b/internal/runtime/memory/tidy_dedupe_numbers_test.go new file mode 100644 index 0000000..3316c06 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_numbers_test.go @@ -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) + } + } +} diff --git a/internal/runtime/memory/tidy_dedupe_test.go b/internal/runtime/memory/tidy_dedupe_test.go index 07986d8..99273e7 100644 --- a/internal/runtime/memory/tidy_dedupe_test.go +++ b/internal/runtime/memory/tidy_dedupe_test.go @@ -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 ⇒ 把「临时行绝不进簇」那条红线绕开了)。 diff --git a/internal/runtime/memory/tidy_dedupe_values_test.go b/internal/runtime/memory/tidy_dedupe_values_test.go new file mode 100644 index 0000000..1179e01 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_values_test.go @@ -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) + } + } +}