Files
Cyrene_v2/internal/runtime/memory/extract_retention_test.go
T
AskaEth 3b9ae3f918 refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 r1 复审裁定(p1-3items / fix-then-merge)重做三处:

P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表
(retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行
杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住
同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**:
judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient →
judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中
才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条
语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。

P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何
标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active
能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条
约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。

已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements,
不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标
one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置
无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。

P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 +
列值编码)。
P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的
写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。
P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不
存在 ⇒ 重放结论对新写入没有预测力)。

测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置
不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」
五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
2026-10-26 19:15:08 +08:00

348 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 且灰区非空)。
// judgeRetentionNoTemporal 是**本文件与 transient 测试的便捷封装**:不带时间性位
// (= 0027 之前的旧规则)。切片 M45 把 `temporal` 作为入参加进了规则函数(它只在
// N6 的位置判据上起作用),因此「旧规则」的每一处断言都显式传 TemporalUnset,
// 而不是靠一个隐式默认值 —— 「不带这一位时行为逐字不变」这件事必须在调用点看得见。
func judgeRetentionNoTemporal(content string) retentionRule {
return judgeExtractionRetention(content, TemporalUnset)
}
// 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 := judgeRetentionNoTemporal(tc.content); (rule == retentionEmotion) != tc.wantDefer {
t.Fatalf("judgeRetentionNoTemporal(%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 := judgeRetentionNoTemporal(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 := judgeRetentionNoTemporal(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 := judgeRetentionNoTemporal(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 := judgeRetentionNoTemporal(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])
}
}
}