## 改了什么(行为变更)
`judgeRetentionEmotion` 原判据是「含情绪词 ∧ 无阿拉伯数字 ∧ ≤24 rune」—— 它把
design/03 §10.5 对 N2 的定义「**词项 + 无实体**」里的「无实体」这一维**顶替成了
「无数字」**,方向恰好落在 fail-safe 的反面。
反例(review 走 `judgeIngest` 真链路复现):
主人心情不好时会听音乐 → 旧判据 defer / t0_low_value
主人焦虑时会咬指甲 → 旧判据 defer / t0_low_value
这两条是**稳定习惯**(正向清单),判 defer 即 7 天后从所有读路径消失(读侧时效闸,
design/03 §9.6)、随后被后台清扫**物理删除**(design/23 §19)—— **不可逆**,与
mission 文本写死的 fail-safe「宁可多留,不可误弃」相反。
新判据(设计与 mission 允许,不改设计):**剥掉情绪词 / 时间词 / 主语 / 程度词 /
虚词 / 标点之后,余下为空才判 defer**;只要还剩一个实体(听音乐 / 咬指甲 / 养猫
/ 找朋友聊天)就放行。实现是 `retentionResidual` + 一张**长词优先**的剥离表
(长词优先是必须的:先剥「好」会把「好累」拆成光杆「累」而永远剥不掉)。
配套:
- 剥离表新增「不错」(情绪评价,整词)与光杆「累」—— 只靠虚词表里的「不」会把
「心情不错」剥成「错」、把「主人今天真的特别累」剥成「累」而漏判;
- 去掉 N2 专属的 24-rune 上限(判据恒是「无实体」,长度由全局 40-rune 上限兜住);
- 数字不再单独设防:它不在剥离表里 ⇒ 必然留在残差里 ⇒ 一律放行(与旧
hasRetentionDigit 等价,但由同一条判据统一给出)。
## 为什么这是 p1 而不是精度问题
判错的方向不对称:漏判只是噪声留进长期层(有 T1 与整理兜底),误弃是**不可逆的
数据丢失**。因此 N2 宁可漏判,绝不误弃。
## 配套测试(此前这条缝**完全没被覆盖**)
- 正例清单新增 5 条「带宾语的情绪句」:`主人心情不好时会听音乐` /
`主人焦虑时会咬指甲` / `主人心情低落时会养猫` / `主人压力大的时候喜欢吃甜的` /
`主人难过时会去找朋友聊天`(旧正例里没有这一类样本,所以旧缺陷一路全绿);
- 新增 `TestExtractionRetention_EmotionWithObjectIsNotDeferred`:逐条断言**残差**
与最终结论 —— 带宾语 ⇒ 不 defer、纯情绪 ⇒ defer,两者必须给出相反结论;
- 新增反例 `主人今天真的特别累`(纯情绪、无实体)钉住「不是靠长度躲过」。
修前必红 / 修后绿(把 N2 退回旧判据后原样输出):
正例 "主人心情不好时会听音乐" 被拦成 "n2_emotion" —— 那是把真记忆丢进临时层
正例 "主人焦虑时会咬指甲" 被拦成 "n2_emotion" —— …
judgeExtractionRetention("主人心情不好时会听音乐") = "n2_emotion", wantEmotionDefer=false
--- FAIL: TestExtractionRetention_PositivesAreNeverDeferred
--- FAIL: TestExtractionRetention_EmotionWithObjectIsNotDeferred
还原后:TestExtractionRetention_{UpperCaseASCII,EmotionWithObjectIsNotDeferred,
NegativesAreDeferred,PositivesAreNeverDeferred} 全 PASS。
验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿。
196 lines
9.1 KiB
Go
196 lines
9.1 KiB
Go
package memory
|
||
|
||
import (
|
||
"testing"
|
||
)
|
||
|
||
// 本文件是**提取留存规则闸**(extract_retention.go,design/03 §10)的验收用例集:
|
||
// 正例(值得长期记 ⇒ 一条都不许被拦)+ 反例(显然不值得记 ⇒ 必须被拦成第五态)
|
||
// 各远超 3 条,并逐条钉住**命中哪一条反例规则**(N1~N5)。
|
||
//
|
||
// 为什么正例与反例**一样重要**:本闸判错的方向不对称 —— 漏判只是「库里多一条
|
||
// 噪声」(有 T1 与整理兜底),误判却会把一条真记忆丢进临时层(到期删除)。
|
||
// design/23 §18.3 的 fail-safe 方向是「**宁可多留,不可误弃**」,所以「正例必须
|
||
// 放行」是比「反例必须拦下」更硬的要求。
|
||
//
|
||
// 「闸真在起作用」而不是「恰好如此」的证据是 TestJudgeIngestDefersLowValueItem:
|
||
// 它在**同一个判定链**上既断言低价值条目拿到 `defer`+`t0_low_value`,又断言它
|
||
// **不进灰区**(于是 T1 一个 token 都花不出去);把闸摘掉时那条断言会立刻变红
|
||
// (判成 create 且灰区非空)。
|
||
|
||
// retentionPositiveCases 是正向清单(design/03 §10.4)的脱敏样本与**两道护栏**
|
||
// 的样本:它们必须**全部**放行(retentionNone)。
|
||
func retentionPositiveCases() []struct {
|
||
name string
|
||
content string
|
||
} {
|
||
return []struct {
|
||
name string
|
||
content string
|
||
}{
|
||
{"长期事实", "主人住在杭州"},
|
||
{"稳定偏好", "主人喜欢喝冰美式来提神"},
|
||
{"习惯", "主人常在深夜与助手聊天,有熬夜习惯"},
|
||
{"关系里程碑", "主人与助手约定验收暗号喵喵七号"},
|
||
{"项目约定", "迁移号约定从 0023 起"},
|
||
{"能力边界", "主人说过不要在群里提她的工作"},
|
||
{"健康约束", "主人对花生过敏"},
|
||
{"带数值的偏好", "主人每天要喝 2 杯咖啡"},
|
||
// 护栏①:显式「记住」是主人的直接指令,短句也不许被拦。
|
||
{"显式记住(短)", "记住主人周日去看牙医"},
|
||
// 护栏②:短寒暄词出现,但整句带持久信号 ⇒ 放行。
|
||
{"寒暄词+持久信号", "主人喜欢用喵打招呼"},
|
||
// 长度安全阀:寒暄词出现,但句子长到装得下事实 ⇒ 放行。
|
||
{"长句里的致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
|
||
|
||
// 🔴 N2 的「**无实体**」那一半(review 走真链路复现的误弃,p1)。
|
||
// 它们是「**带宾语的情绪句**」——有情绪词、但情绪只是**状语**,真正要记的是
|
||
// 后面那个稳定行为。旧判据(够短 + 无数字)会把它们扔进临时层:7 天后从
|
||
// 所有读路径消失、再被后台清扫**物理删除**,不可逆。
|
||
{"情绪+实体的习惯(听音乐)", "主人心情不好时会听音乐"},
|
||
{"情绪+实体的习惯(咬指甲)", "主人焦虑时会咬指甲"},
|
||
{"情绪+实体的习惯(养猫)", "主人心情低落时会养猫"},
|
||
{"情绪+实体的偏好", "主人压力大的时候喜欢吃甜的"},
|
||
{"情绪+实体的关系事实", "主人难过时会去找朋友聊天"},
|
||
}
|
||
}
|
||
|
||
// TestExtractionRetention_EmotionWithObjectIsNotDeferred 是**上一条正例的机器守卫**
|
||
// (拆出来是为了让「为什么这条不许被拦」的理由留在断言旁边)。
|
||
//
|
||
// 判据的**方向**比精度重要:N2 判 defer 的记忆 7 天后会被读侧时效闸隐藏、随后被
|
||
// 后台清扫物理删除(design/23 §19),**不可逆**。因此 N2 的判据必须是
|
||
// design/03 §10.5 的原文「**词项 + 无实体**」—— 剥掉情绪/时间/主语/程度/虚词之后
|
||
// **余下为空**才 defer;只要还剩一个实体(听音乐 / 咬指甲 / 养猫)就必须放行。
|
||
//
|
||
// 变异自证:把 judgeRetentionEmotion 退回「够短 + 无数字」的旧判据 ⇒ 本用例立刻红。
|
||
func TestExtractionRetention_EmotionWithObjectIsNotDeferred(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
content string
|
||
residual string // 剥完剩下的实体
|
||
wantDefer bool
|
||
}{
|
||
{content: "主人心情不好时会听音乐", residual: "听音乐", wantDefer: false},
|
||
{content: "主人焦虑时会咬指甲", residual: "咬指甲", wantDefer: false},
|
||
{content: "今天好累", residual: "", wantDefer: true},
|
||
{content: "主人今天真的特别累", residual: "", wantDefer: true},
|
||
{content: "心情不错!", residual: "", wantDefer: true},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.content, func(t *testing.T) {
|
||
t.Parallel()
|
||
text := normalizeRetentionText(tc.content)
|
||
if got := retentionResidual(text); got != tc.residual {
|
||
t.Fatalf("残差 = %q, want %q(残差就是 N2「无实体」那一半的判据)", got, tc.residual)
|
||
}
|
||
if rule := judgeExtractionRetention(tc.content); (rule == retentionEmotion) != tc.wantDefer {
|
||
t.Fatalf("judgeExtractionRetention(%q) = %q, wantEmotionDefer=%t —— 带宾语的与纯情绪的必须给出相反结论",
|
||
tc.content, rule, tc.wantDefer)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// retentionNegativeCases 是反例清单(design/03 §10.5)的脱敏样本,逐条钉住
|
||
// 命中的规则码。
|
||
func retentionNegativeCases() []struct {
|
||
name string
|
||
content string
|
||
rule retentionRule
|
||
} {
|
||
return []struct {
|
||
name string
|
||
content string
|
||
rule retentionRule
|
||
}{
|
||
{"N1 致谢", "谢谢", retentionChatter},
|
||
{"N1 寒暄", "早上好呀", retentionChatter},
|
||
{"N1 晚安", "晚安~", retentionChatter},
|
||
{"N2 情绪", "今天好累", retentionEmotion},
|
||
{"N2 情绪(带标点)", "心情不错!", retentionEmotion},
|
||
{"N4 时效查询", "现在几点了", retentionTimelyData},
|
||
{"N4 天气", "明天天气怎么样", retentionTimelyData},
|
||
{"N3 一次性琐事", "帮我订个 7 点的闹钟", retentionOneOffAsk},
|
||
{"N3 一次性提醒", "提醒我明天把快递取回来", retentionOneOffAsk},
|
||
{"N5 一次性任务", "把这个文件转成 pdf", retentionOneOffTask},
|
||
{"N5 一次性查询", "查一下明天的航班", retentionOneOffTask},
|
||
}
|
||
}
|
||
|
||
// TestExtractionRetention_PositivesAreNeverDeferred 钉住正向清单一票放行:
|
||
// 任何一条正例被判成 defer(= 进临时层、到期删除)都是**记忆丢失**,
|
||
// 因此这里逐条断言 retentionNone。
|
||
func TestExtractionRetention_PositivesAreNeverDeferred(t *testing.T) {
|
||
t.Parallel()
|
||
for _, tc := range retentionPositiveCases() {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if rule := judgeExtractionRetention(tc.content); rule != retentionNone {
|
||
t.Fatalf("正例 %q 被拦成 %q —— 那是把真记忆丢进临时层(fail-safe 方向被违反)",
|
||
tc.content, rule)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestExtractionRetention_NegativesAreDeferred 钉住反例清单:每条都必须命中
|
||
// 它该命中的规则码(而不是「反正被拦了」——规则码要能解释**为什么**)。
|
||
func TestExtractionRetention_NegativesAreDeferred(t *testing.T) {
|
||
t.Parallel()
|
||
for _, tc := range retentionNegativeCases() {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if rule := judgeExtractionRetention(tc.content); rule != tc.rule {
|
||
t.Fatalf("反例 %q 命中 %q, want %q", tc.content, rule, tc.rule)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestExtractionRetention_UpperCaseASCII 钉住 ASCII 寒暄词(中英混排):
|
||
// 规范化会把 `HI` / `Thanks` 折成小写再匹配。
|
||
func TestExtractionRetention_UpperCaseASCII(t *testing.T) {
|
||
t.Parallel()
|
||
for _, content := range []string{"HI", "Thanks", "hello~"} {
|
||
if rule := judgeExtractionRetention(content); rule != retentionChatter {
|
||
t.Fatalf("%q 命中 %q, want %q(ASCII 词应先折小写)", content, rule, retentionChatter)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestJudgeIngestDefersLowValueItem 是**接线**断言:留存规则闸真的接在判定链上,
|
||
// 命中的新条目拿到第五态 `defer` + 留痕原因码 `t0_low_value`,且**不进灰区**
|
||
// (design/03 §10.6:规则闸拦下的不进 T1)。
|
||
func TestJudgeIngestDefersLowValueItem(t *testing.T) {
|
||
t.Parallel()
|
||
item := habitItem("谢谢啦", tidyTestTime(t), 0.9)
|
||
// 故意给一条「相似候选」:它本该进灰区,但留存规则先命中 ⇒ 灰区必须为空,
|
||
// 于是 T1 一个 token 都花不出去。
|
||
cands := []ingestCandidate{{item: habitItem("谢谢", tidyTestTime(t), 0.9), score: 0.9, mode: tidyRecallLexical}}
|
||
v := judgeIngest(item, cands)
|
||
if v.Decision != IngestDefer {
|
||
t.Fatalf("低价值条目 decision = %q, want %q", v.Decision, IngestDefer)
|
||
}
|
||
if v.Reason != retentionLowValueReason {
|
||
t.Fatalf("低价值条目 reason = %q, want %q", v.Reason, retentionLowValueReason)
|
||
}
|
||
if len(v.Gray) != 0 {
|
||
t.Fatalf("低价值条目仍收集了 %d 条灰区候选 —— T1 会被白调一次(§10.6 不许)", len(v.Gray))
|
||
}
|
||
}
|
||
|
||
// TestJudgeIngestKeepsValuableItemAsCreate 是**变异用例**的对偶:同一条判定链上,
|
||
// 值得长期留的条目仍然走既有四态(这里是 create),证明闸只拦「显然不值得记」。
|
||
func TestJudgeIngestKeepsValuableItemAsCreate(t *testing.T) {
|
||
t.Parallel()
|
||
item := habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)
|
||
v := judgeIngest(item, nil)
|
||
if v.Decision != IngestCreate {
|
||
t.Fatalf("正常条目 decision = %q, want %q(闸不许误伤)", v.Decision, IngestCreate)
|
||
}
|
||
}
|