Files
Cyrene_v2/internal/runtime/memory/extract_retention_test.go
T
AskaEth c7c0294a16 refactor(memory): M45 裁决 D —— temporal 位撤出判定,只保留观测
按 r1 复审裁决 D:这一位先只做观测,`resolveRetention` 不许读它。理由(实测):
无条件 one_off ⇒ temp 会当天删 21 条长期约定;只放开 N6 位置判据能翻的 13 条
逐条是约定、而复审点名的三条目标行 0/3 救得了。在「模型真实标签分布」这份证据
缺位时不该做 7 天后物理删的不可逆决定。

撤接线(结论集逐字回到 M45 之前):
- retention.go:resolveRetention 不再读 item.Temporal;与之对比 dev base
  (af61b8c)本文件**只有注释差异**,规则与判定入口逐字未变。
- extract_retention.go:judgeExtractionRetention 签名回退单参;删除
  judgeRetentionTimepointWide 与 n6_timepoint_wide 规则码。与 dev base 对比
  **同样只有注释差异** ⇒ 「结论集 = 不含 temporal 的那个集合」是结构性的,
  不是靠调用点记得别传。
- 连带取消初版的 durable / recurring ⇒ long(零影响必须是双向的)。

保留观测面(一律未动):迁移 0027 的列、自举前滚、提取器的 temporal 产出、
prompt 枚举、闭集与 normalizeTemporal、store_temporal.go 的校验/编码。
另外把 prompt 里「标成 one_off 会让它进临时层、到期消失」这句**撤接线后已是假话**
改成中性标注说明 —— 否则会诱导模型避开 one_off,观测数据直接被污染。

测试:
- TestTemporalBitDoesNotAffectRetention —— 4 类语料 × 5 个取值,(档位,规则码)
  与未分型逐字相同(核心正断言);
- TestTemporalOneOffDoesNotMakeAnythingTemporary / ...DoesNotRescueRuleHitRows
  —— 零影响的双向点名;
- TestTemporalIsStillRecorded —— one_off 的约定行仍落 temporal='one_off' 而档位
  long、expires 空(观测在、判定不动);
- TestTemporalDoesNotTouchExplicitTierOrRestrictedSource —— 人工入口与受限来源不变。
变异实测:把 `if item.Temporal == TemporalOneOff { return temp }` 插回
resolveRetention ⇒ 上述四条用例全红(FAIL 输出已贴给塔),恢复后全绿。

IngestRuleVersion 保持 d3.5,注释改准为「记录 temporal 信号,但不参与判定」
(留痕面变了:库里多了「模型当时标了什么」这一维,回退会让新旧留痕混在一起)。

P2 三条保留:store.go 500 行(拆 store_temporal.go)、temporal.go 头注('' 的口径
+ 两次被否的接线方案 + 接线前必须看的那两条统计 SQL)、报告语境段。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race
38.2s 全绿。
2026-10-26 19:21:24 +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])
}
}
}