Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_values_test.go
T
AskaEth 7700a7eda0 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。
2026-10-25 00:41:16 +08:00

97 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}