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。
97 lines
5.0 KiB
Go
97 lines
5.0 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|