diff --git a/internal/runtime/memory/extract_retention.go b/internal/runtime/memory/extract_retention.go index 8381f8c..ac4aaa5 100644 --- a/internal/runtime/memory/extract_retention.go +++ b/internal/runtime/memory/extract_retention.go @@ -151,10 +151,22 @@ func judgeRetentionChatter(text string) retentionRule { // // 1. 匹配要求**整句被消费干净**(`retentionEmotionFormRest` 返回空余部)。任何 // 一个字符只要不在封闭表里,就会留在余部 ⇒ 判不出空 ⇒ **放行**。 -// 2. 封闭表里**没有承载内容的词**:没有动词、名词、量词。主语/时间词**只允许在句首** -// 出现一次,情绪核心之后**只**允许「程度 / 否定 / 情绪 / 语气」四类修饰。 -// 因此核心之后的部分若含 `想`、`会`、`时`、`和`、`我`、`听音乐`、`妈妈` 这类 -// 承载内容的字,**必然消费不掉** ⇒ 余部非空 ⇒ 放行。 +// +// 2. **位置锁定 + 核心之后只允许四类修饰** —— 这才是真正保安全的一条。 +// (早先这里写的是「封闭表里没有承载内容的词」,**那句被 reviewer 证伪** +// —— 表里确实有:`心情` 是名词,`生气`/`害怕`/`紧张`/`没有`/`比较` 可作动词, +// `压力大` 是动宾,而主语表里的 `主人`/`我`/`他` 本身就是指人实体。) +// +// 真正的机制是**位置**而不是"表里没有实词": +// - 主语 / 时间词**只允许出现在句首**、各至多一次 ⇒ `主人`/`我`/`你`/`他`/`今天` +// 这些**指人 / 指时的实体在文法里永远不可能落到宾语位置**(上一版正是死在 +// 这里:`会想我` 的 `我` 被当成主语剥掉了); +// - 核心之后只允许「程度 / 否定 / 情绪 / 语气」四类 token,而它们**只允许紧跟 +// 在情绪核心之后** —— 核心本身必须先被消费掉,因此那一段在语义上只能是 +// **情绪的修饰**(「心情**不错**」「**不**开心」「生气**得很**」), +// 装不下一个宾语短语。核心之后一旦出现四类之外的东西(`想`/`会`/`时`/`和`/ +// `听音乐`/`妈妈`),**必然消费不掉** ⇒ 余部非空 ⇒ 放行。 +// // 3. 失败方向是**安全**的:认不出的形态一律**放行**(留进长期层,有 T1 与整理兜底), // 而**绝不**误弃。上一版(黑名单剥离)恰好相反 —— 它把「能当实体的词」也列进 // 剥离表,于是 `主人不开心时会想我` 被剥成空、判成 defer,而那条是**持久习惯**, @@ -162,6 +174,18 @@ func judgeRetentionChatter(text string) retentionRule { // 黑名单那种形态**修不完**(review 举证 `会想和好` 被 `和`+`好` 吃掉), // 白名单把失败模式从「未知实体被吞」翻成「未知情绪句没认出」,方向才正确。 // +// 🔴 **后缀表不得加入承载内容的词**(名词 / 动词 / 量词 / 新的指人词)。上面第 2 条 +// 的整条安全性都建立在「四类之外一律消费不掉」上:往后缀表里塞一个名词,就会**静默** +// 把「情绪 + 该名词作宾语」的持久事实判成 defer,而且**不会有任何行为测试变红** +// (除非那条输入恰好在语料里)—— 这正是 review 点出的风险。两条 tripwire 钉住它: +// +// - `TestExtractionRetention_SuffixTableIsPinned`:把后缀表的**内容**逐 token 钉死, +// **任何增删都立刻红**(改表必须显式改这份 golden ⇒ 变成一次可 review 的意图声明); +// - `TestExtractionRetention_PersistentFactsAreNeverDeferred`:25 条持久事实/习惯 +// 语料逐条断言「绝不 defer」,钉住**语义后果**。 +// +// 两条一起才够:golden 钉「表变了」,语料钉「变了以后语义还对不对」。 +// // 长度不单独设限:全局 40 rune 的上限(judgeExtractionRetention)已经兜住。 func judgeRetentionEmotion(text string) retentionRule { rest, sawEmotion := retentionEmotionFormRest(text) diff --git a/internal/runtime/memory/extract_retention_test.go b/internal/runtime/memory/extract_retention_test.go index 0699b49..7a3444e 100644 --- a/internal/runtime/memory/extract_retention_test.go +++ b/internal/runtime/memory/extract_retention_test.go @@ -226,3 +226,100 @@ func TestJudgeIngestKeepsValuableItemAsCreate(t *testing.T) { 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]) + } + } +}