Files
Cyrene_v2/internal/runtime/memory/extract_retention_test.go
T
AskaEth b1d51d8de2 fix(memory): N2 判据改为白名单形态匹配(结构上不再吞实体)
## 上一版为什么没修完(review round2 举证)

上一版把 N2 实现成**黑名单剥离**:`strings.ReplaceAll` 扫整句、把「情绪词 + 时间词 +
主语 + 程度 + 虚词 + 标点」统统删掉,**残差为空才 defer**。它的要害是剥离表里混进了
**本身就能当实体的词** —— 人称词(`我`/`我们`/`他`/`咱`/`自己`)与可作内容的单字
(`好`/`会`/`要`/`想`/`对`/`在`/`有`/`是`/`时`/`得`/`和`)。宾语恰好由这些词构成时,
实体被吞、残差为空 ⇒ **仍判 defer**:

  主人不开心时会想我    → residual=""      → defer   ✗(持久习惯进临时层,7 天后物理删除)
  主人难过时会想我们    → residual=""      → defer   ✗
  主人心情不好时会想他  → residual=""      → defer   ✗
  主人不开心时会想和好  → residual=""      → defer   ✗(`和`+`好` 两个词项各吃掉一半)
  主人难过时会想妈妈    → residual="妈妈"  → create  ← 对照:宾语没被表吃掉

⇒ 这证伪了上一版代码里声称的不变量(「还剩一个实体就必须放行」),也说明**黑名单这
条路走不到头**:「实体」是开放集合(想我/想我们/想他/想和好/想妈妈/听音乐/咬指甲/
养猫/…),永远列不全。

## 新判据:白名单**形态**匹配

「纯情绪句」的**形态是封闭的**,据此改成「整句恰好落在形态里才 defer,其余一律放行」:

	[主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]*   余部须只剩标点/空白

实现在 `retentionEmotionFormRest`(唯一判据点):
- 主语与时间词**只允许出现在句首**、各至多一次;
- 情绪核心**必须**消费到一个情绪词,否则 sawEmotion=false ⇒ 放行;
- 核心之后**只**允许「程度 / 否定 / 情绪 / 语气」四类修饰(`retentionEmotionSuffixTokens`);
- 最后要求**整句消费干净**(余部只剩标点/空白);
- 各段一律**最长词优先**(否则 `不` 会抢在 `不错` 前、`好` 会拆掉 `好累`)。

## 为什么它在**结构上**不可能再把带实体的句子判成 defer

1. 判 defer 的门槛是**整句被封闭表消费干净**。表里**没有承载内容的词** —— 没有动词、
   名词、量词。任何一个形态外的字(`想`/`会`/`时`/`和`/`我`/`妈妈`/`听音乐`)都无法被
   消费,必然留在余部 ⇒ 余部非空 ⇒ **放行**。
2. 主语/时间词**限死句首**:`我`/`他`/`我们` 出现在核心之后时**没有**任何词项能吃掉它
   (后缀表里没有它们)—— 这正是上一版翻车的地方,现在从结构上排除了。
3. 失败模式从「**未知实体被吞**(危险、不可逆)」翻成「**未知情绪句没被认出**(安全:
   多留,有 T1 与整理兜底)」。方向与 design/23 §18.3 的 fail-safe「宁可多留,不可
   误弃」一致。

## 配套测试

- 正例清单补 5 条**对抗形态**(宾语由「能当实体的词」构成):`主人不开心时会想我` /
  `主人难过时会想我们` / `主人心情不好时会想他` / `主人不开心时会想和好` /
  `主人难过时会想妈妈` —— 与上一轮的 5 条(听音乐/咬指甲/养猫/喜欢吃甜的/找朋友聊天)
  合计 10 条「带宾语的情绪句」正例;
- `TestExtractionRetention_EmotionWithObjectIsNotDeferred` 改为断言**余部**(未被形态
  消费掉的部分)+最终结论:纯情绪 ⇒ 余部为空 ⇒ defer;带宾语 ⇒ 余部非空(
  `时会想我` / `时会听音乐` / …)⇒ 放行。**「带宾语 ⇒ 余部非空」这条不变量现在是被
  判据本身保证的**,不再是靠一张表的完备性。

## 修前必红 / 修后绿(原样输出)

修前(把 `judgeRetentionEmotion` 换成 commit 979d1cf 的黑名单实现,逐字):

```
--- FAIL: TestExtractionRetention_EmotionWithObjectIsNotDeferred (0.00s)
    --- FAIL: …/主人难过时会想我们  extract_retention_test.go:122: judgeExtractionRetention("主人难过时会想我们") = "n2_emotion", wantEmotionDefer=false
    --- FAIL: …/主人心情不好时会想他  … = "n2_emotion", wantEmotionDefer=false
    --- FAIL: …/主人不开心时会想和好  … = "n2_emotion", wantEmotionDefer=false
    --- FAIL: …/主人不开心时会想我    … = "n2_emotion", wantEmotionDefer=false
--- FAIL: TestExtractionRetention_PositivesAreNeverDeferred (0.00s)
    --- FAIL: …/情绪+代词宾语(想我)   正例 "主人不开心时会想我" 被拦成 "n2_emotion" —— 把真记忆丢进临时层
    --- FAIL: …/情绪+代词宾语(想他)   …   --- FAIL: …/(想我们) … --- FAIL: …/(想和好) …
FAIL
```

修后:`TestExtractionRetention_{UpperCaseASCII,NegativesAreDeferred,
EmotionWithObjectIsNotDeferred,PositivesAreNeverDeferred}` 全 PASS(共 44 个子例)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿。
2026-10-13 20:47:59 +08:00

229 lines
11 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 是**接线**断言:留存规则闸真的接在判定链上,
// 命中的新条目拿到第五态 `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)
}
}