Files
Cyrene_v2/internal/runtime/memory/extract_retention_test.go
T
AskaEth cc6904b1b4 feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增
retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤
一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。

结构(迁移 0026 + 自举前滚):
- memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型,
  历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过
  (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。
- NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。

判定(retention.go,全仓唯一入口):
- RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。
- resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源
  不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。
- tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔
  INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。

合并(defer ⇔ temp):
- judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此
  全仓唯一(源码级断言钉住)。
- 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。
- 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。
- IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。

读侧:
- 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。
- 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位
  过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。
  原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。
- store.go 的读路径拆到 store_list.go,守 500 行上限。

回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
2026-10-26 13:09:30 +08:00

340 lines
17 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 (
"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)。
// 它们是「**带宾语的情绪句**」——有情绪词、但情绪只是**状语**,真正要记的是
// 后面那个稳定行为/对象。判成 defer 即 7 天后从所有读路径消失、再被后台
// 清扫**物理删除**(不可逆),因此它们必须**全部**放行。
//
// 前 5 条是第一轮举证的形态(宾语是名词/动宾);后 5 条是第二轮举证的
// **对抗形态**——宾语本身由「能当实体的词」构成(`我`/`我们`/`他`/`和好`),
// 正是黑名单剥离会吞掉、而白名单形态匹配结构上吞不掉的那一类。
{"情绪+实体的习惯(听音乐)", "主人心情不好时会听音乐"},
{"情绪+实体的习惯(咬指甲)", "主人焦虑时会咬指甲"},
{"情绪+实体的习惯(养猫)", "主人心情低落时会养猫"},
{"情绪+实体的偏好", "主人压力大的时候喜欢吃甜的"},
{"情绪+实体的关系事实", "主人难过时会去找朋友聊天"},
{"情绪+代词宾语(想我)", "主人不开心时会想我"},
{"情绪+代词宾语(想我们)", "主人难过时会想我们"},
{"情绪+代词宾语(想他)", "主人心情不好时会想他"},
{"情绪+动词短语宾语(想和好)", "主人不开心时会想和好"},
{"情绪+名词宾语(想妈妈)", "主人难过时会想妈妈"},
}
}
// TestExtractionRetention_EmotionWithObjectIsNotDeferred 是**正例的机器守卫**,也是
// 「带宾语 ⇒ 余部非空」这条**不变量**的钉子。
//
// 判据的**方向**比精度重要:N2 判 defer 的记忆 7 天后会被读侧时效闸隐藏、随后被
// 后台清扫物理删除(design/23 §19),**不可逆**。因此 N2 的判据必须是
// design/03 §10.5 的「**词项 + 无实体**」,而实现走**白名单形态匹配**:
//
// [主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]* 余部须只剩标点
//
// `rest` 就是**没能被这个形态消费掉的余部** —— 它同时是判定依据与**可断言的证据**:
// 只要句子里有一个承载内容的字(`想` / `会` / `时` / `和` / `听音乐` / `妈妈` …),
// 那个字一定留在余部里 ⇒ 余部非空 ⇒ 放行。
//
// 上一版(黑名单剥离)正是死在这里:它把「本身能当实体的词」(`我`/`他`/`我们`/`和`)
// 也列进了剥离表,于是 `主人不开心时会想我` 被剥成空、判成 defer —— 而那是一条
// **持久习惯**。黑名单修不完(实体是开放集合),白名单的失败模式是「认不出 ⇒ 放行」,
// 方向才安全。
func TestExtractionRetention_EmotionWithObjectIsNotDeferred(t *testing.T) {
t.Parallel()
cases := []struct {
content string
rest string // 未被「纯情绪形态」消费掉的余部
wantDefer bool
}{
// 纯情绪形态 ⇒ 消费干净 ⇒ defer。
{content: "今天好累", rest: "", wantDefer: true},
{content: "心情不错!", rest: "", wantDefer: true},
{content: "主人今天真的特别累", rest: "", wantDefer: true},
{content: "主人很生气", rest: "", wantDefer: true},
{content: "今天有点好累", rest: "", wantDefer: true},
// 带宾语 ⇒ 余部非空 ⇒ 放行。前两组是 review 两轮各自举证的对抗输入。
{content: "主人心情不好时会听音乐", rest: "时会听音乐", wantDefer: false},
{content: "主人焦虑时会咬指甲", rest: "时会咬指甲", wantDefer: false},
{content: "主人不开心时会想我", rest: "时会想我", wantDefer: false},
{content: "主人难过时会想我们", rest: "时会想我们", wantDefer: false},
{content: "主人心情不好时会想他", rest: "时会想他", wantDefer: false},
{content: "主人不开心时会想和好", rest: "时会想和好", wantDefer: false},
{content: "主人难过时会想妈妈", rest: "时会想妈妈", wantDefer: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.content, func(t *testing.T) {
t.Parallel()
rest, sawEmotion := retentionEmotionFormRest(normalizeRetentionText(tc.content))
if !sawEmotion {
t.Fatalf("%q 没被认出情绪核心 —— 这几条都含情绪词,应当认出", tc.content)
}
if rest != tc.rest {
t.Fatalf("余部 = %q, want %q(余部就是「未被纯情绪形态消费掉的部分」)", rest, tc.rest)
}
if (rest == "") != tc.wantDefer {
t.Fatalf("%q:余部为空 = %t 与 wantDefer = %t 不一致",
tc.content, rest == "", tc.wantDefer)
}
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 是**接线**断言:档位判定真的接在判定链上,
// 被判成 temp 的新条目拿到第五态 `defer` + 留痕原因码 `t0_low_value`,且**不进灰区**
// (design/03 §10.6:规则闸拦下的不进 T1)。
//
// 🔴 切片 M37 起,`judgeIngest` **不再自己跑留存规则** —— 档位由 `resolveRetention`
// 一次判完(commit 前的写法是这里再调一次 `judgeExtractionRetention`,那正是
// 「两套规则各判一遍」)。因此本用例先把档位判出来放进条目(与 `Store.Add` 的
// 做法逐字一致),再断言判定链**只读**这一位。变异(把规则调用搬回 judgeIngest)
// 会让 retention_test.go 的唯一性断言先红。
func TestJudgeIngestDefersLowValueItem(t *testing.T) {
t.Parallel()
item := habitItem("谢谢啦", tidyTestTime(t), 0.9)
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier != RetentionTemp {
t.Fatalf("低价值条目档位 = %q, want %q", verdict.Tier, RetentionTemp)
}
item.RetentionTier = verdict.Tier
// 故意给一条「相似候选」:它本该进灰区,但 temp 档先命中 ⇒ 灰区必须为空,
// 于是 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)
}
}
// persistentFactCorpus 是**持久事实 / 习惯**语料:它们**逐条都不许**被判成 N2 的
// defer。这份语料来自 review round3 的对抗构造(塔给的 6 条 + reviewer 扩展的 19 条,
// 共 25 条),每一条都是「带情绪色彩、但真正要记的是情绪之后的那个持续行为/对象」。
//
// 判 defer 的后果是**不可逆**的:7 天后从所有读路径消失(读侧时效闸),随后被后台
// 清扫物理删除(design/23 §19)。因此这份语料是**不可回退的 tripwire**。
//
// 它是**可增长的**:以后遇到新的持久事实被判 defer,往这里加一条即可 —— 加进去就
// 从「一次线上事故」变成「一条回归断言」。
func persistentFactCorpus() []string {
return []string{
"主人不开心时会好起来",
"主人难过时会想不开",
"主人不开心时会好好想",
"主人心情不好时会不好",
"主人难过时会想我妈妈",
"主人开心时话就多",
"主人难过会想我",
"主人心情不好就没胃口",
"主人开心就爱说话",
"主人难过的时候想一个人待着",
"主人焦虑时睡不着",
"主人紧张时会结巴",
"主人孤单时会找人聊天",
"主人兴奋时会话多",
"主人害怕时会躲起来",
"主人心情差时会听歌",
"主人不开心了会想和好",
"主人压力大的时候会失眠",
"主人最近很容易焦虑",
"主人一个人时容易难过",
"主人心情不好就会想家",
"主人难过会想妈妈",
"主人不开心就沉默",
"主人心情不好时会找我",
"主人焦虑时会咬指甲",
}
}
// TestExtractionRetention_PersistentFactsAreNeverDeferred 逐条断言上面那份语料
// **一条都不判 defer**(规则闸返回 retentionNone ⇒ 走既有的 create 路径)。
//
// 它钉的是**语义后果**:只要有哪条持久事实被判进临时层,本用例立刻红。
func TestExtractionRetention_PersistentFactsAreNeverDeferred(t *testing.T) {
t.Parallel()
corpus := persistentFactCorpus()
if len(corpus) < 25 {
t.Fatalf("语料只有 %d 条,不应少于 review 举证的 25 条(别把语料删小)", len(corpus))
}
for _, content := range corpus {
content := content
t.Run(content, func(t *testing.T) {
t.Parallel()
if rule := judgeExtractionRetention(content); rule != retentionNone {
t.Fatalf("持久事实 %q 被判成 %q —— 它会进临时层、7 天后被物理删除(不可逆)",
content, rule)
}
})
}
}
// TestExtractionRetention_SuffixTableIsPinned 是**表的内容性质** tripwire:把「情绪
// 核心之后允许出现的修饰 token」这张表的**内容**逐 token 钉死,任何增删都立刻红。
//
// 为什么它比「语料回归」更可靠:语料只能覆盖**已经想到的**输入,而这张表是判据的
// 全部自由度 —— 往后缀表里加一个名词(比如把 `想` / `妈妈` 也当成"语气词"),表变了
// 而语料可能一条都不碰。**golden 钉住「表变了」这个事实本身**,于是改表必须显式改这
// 份 golden ⇒ 变成一次可 review 的意图声明,而不是一次静默放宽。
//
// review round3 的 p2#1 正是这个风险:注释里一句「表里没有承载内容的词」若被当真,
// 后来人就会照着它往表里加词。现在那句话已被改成真实论证(位置锁定 + 四类修饰),
// 这张 golden 则是它的**机器化形态**。
func TestExtractionRetention_SuffixTableIsPinned(t *testing.T) {
t.Parallel()
golden := []string{
"不开心", "压力大", "非常", "特别", "稍微", "真的", "真是", "十分", "比较", "有点",
"有些", "没有", "好累", "累了", "开心", "高兴", "难过", "焦虑", "心情", "疲惫",
"难受", "无聊", "郁闷", "沮丧", "生气", "委屈", "烦死", "好烦", "烦躁", "兴奋",
"紧张", "害怕", "孤单", "寂寞", "不错", "很", "挺", "太", "好", "超",
"蛮", "更", "最", "不", "没", "累", "啊", "呀", "啦", "哦",
"喔", "嘛", "呢", "吧", "了", "的", "哎", "唉", "嗯", "哈",
}
got := retentionEmotionSuffixTokens()
if len(got) != len(golden) {
t.Fatalf("后缀表 token 数 = %d, want %d —— 表被改过。**改这张表会放宽/收紧 N2 的判据**:"+
"往后缀里加名词/动词会让「情绪 + 该名词作宾语」的持久事实被静默判成 defer。"+
"确认是**有意**改动后,同步更新这份 golden 与 persistentFactCorpus()。\ngot = %#v",
len(got), len(golden), got)
}
for i := range golden {
if got[i] != golden[i] {
t.Fatalf("后缀表第 %d 个 token = %q, want %q(表的内容变了,见上一条的理由)",
i, got[i], golden[i])
}
}
}