按 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 全绿。
348 lines
17 KiB
Go
348 lines
17 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 且灰区非空)。
|
||
|
||
// 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])
|
||
}
|
||
}
|
||
}
|