fix(memory): 留存规则 N2 改按实体判定(持久习惯不再被误弃)
## 改了什么(行为变更)
`judgeRetentionEmotion` 原判据是「含情绪词 ∧ 无阿拉伯数字 ∧ ≤24 rune」—— 它把
design/03 §10.5 对 N2 的定义「**词项 + 无实体**」里的「无实体」这一维**顶替成了
「无数字」**,方向恰好落在 fail-safe 的反面。
反例(review 走 `judgeIngest` 真链路复现):
主人心情不好时会听音乐 → 旧判据 defer / t0_low_value
主人焦虑时会咬指甲 → 旧判据 defer / t0_low_value
这两条是**稳定习惯**(正向清单),判 defer 即 7 天后从所有读路径消失(读侧时效闸,
design/03 §9.6)、随后被后台清扫**物理删除**(design/23 §19)—— **不可逆**,与
mission 文本写死的 fail-safe「宁可多留,不可误弃」相反。
新判据(设计与 mission 允许,不改设计):**剥掉情绪词 / 时间词 / 主语 / 程度词 /
虚词 / 标点之后,余下为空才判 defer**;只要还剩一个实体(听音乐 / 咬指甲 / 养猫
/ 找朋友聊天)就放行。实现是 `retentionResidual` + 一张**长词优先**的剥离表
(长词优先是必须的:先剥「好」会把「好累」拆成光杆「累」而永远剥不掉)。
配套:
- 剥离表新增「不错」(情绪评价,整词)与光杆「累」—— 只靠虚词表里的「不」会把
「心情不错」剥成「错」、把「主人今天真的特别累」剥成「累」而漏判;
- 去掉 N2 专属的 24-rune 上限(判据恒是「无实体」,长度由全局 40-rune 上限兜住);
- 数字不再单独设防:它不在剥离表里 ⇒ 必然留在残差里 ⇒ 一律放行(与旧
hasRetentionDigit 等价,但由同一条判据统一给出)。
## 为什么这是 p1 而不是精度问题
判错的方向不对称:漏判只是噪声留进长期层(有 T1 与整理兜底),误弃是**不可逆的
数据丢失**。因此 N2 宁可漏判,绝不误弃。
## 配套测试(此前这条缝**完全没被覆盖**)
- 正例清单新增 5 条「带宾语的情绪句」:`主人心情不好时会听音乐` /
`主人焦虑时会咬指甲` / `主人心情低落时会养猫` / `主人压力大的时候喜欢吃甜的` /
`主人难过时会去找朋友聊天`(旧正例里没有这一类样本,所以旧缺陷一路全绿);
- 新增 `TestExtractionRetention_EmotionWithObjectIsNotDeferred`:逐条断言**残差**
与最终结论 —— 带宾语 ⇒ 不 defer、纯情绪 ⇒ defer,两者必须给出相反结论;
- 新增反例 `主人今天真的特别累`(纯情绪、无实体)钉住「不是靠长度躲过」。
修前必红 / 修后绿(把 N2 退回旧判据后原样输出):
正例 "主人心情不好时会听音乐" 被拦成 "n2_emotion" —— 那是把真记忆丢进临时层
正例 "主人焦虑时会咬指甲" 被拦成 "n2_emotion" —— …
judgeExtractionRetention("主人心情不好时会听音乐") = "n2_emotion", wantEmotionDefer=false
--- FAIL: TestExtractionRetention_PositivesAreNeverDeferred
--- FAIL: TestExtractionRetention_EmotionWithObjectIsNotDeferred
还原后:TestExtractionRetention_{UpperCaseASCII,EmotionWithObjectIsNotDeferred,
NegativesAreDeferred,PositivesAreNeverDeferred} 全 PASS。
验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿。
This commit is contained in:
1 parent
d4ed043d5c
commit
979d1cfbb2
2 files changed
+164
-7
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -71,8 +72,6 @@ const (
|
||||
// retentionMaxChatterRunes 是「只可能是寒暄」的长度上限(rune 计,不是字节:
|
||||
// 中文 1 rune = 3 字节,按字节算会把阈值放大三倍)。
|
||||
retentionMaxChatterRunes = 16
|
||||
// retentionMaxEmotionRunes 是纯情绪陈述的长度上限。
|
||||
retentionMaxEmotionRunes = 24
|
||||
// retentionMaxImperativeRunes 是一次性指令的长度上限。它比前两者宽:
|
||||
// 「帮我把 D 盘那个文档转成 pdf」这类真·一次性任务本来就长一些。
|
||||
retentionMaxImperativeRunes = 40
|
||||
@@ -137,20 +136,51 @@ func judgeRetentionChatter(text string) retentionRule {
|
||||
return retentionChatter
|
||||
}
|
||||
|
||||
// judgeRetentionEmotion 施加 **N2**:短的情绪陈述(「今天好累」「心情不错」)。
|
||||
// judgeRetentionEmotion 施加 **N2**:**纯**情绪陈述(「今天好累」「心情不错」)。
|
||||
//
|
||||
// 🔴 判据是 design/03 §10.5 N2 的原文口径「**词项 + 无实体**」,不是「短 + 无数字」。
|
||||
// 实现 = 把**情绪词 / 时间词 / 主语 / 程度词 / 虚词 / 标点**统统剥掉之后,**余下为空**
|
||||
// 才判 defer;只要还剩一个实体,它就不是「情绪波动」而是**带情绪色彩的习惯或事实**
|
||||
// ⇒ 放行。
|
||||
//
|
||||
// 为什么这条必须写成「无实体」而不是「够短」——这是**方向性**问题,不是精度问题:
|
||||
// 判成 defer 的那条记忆 7 天后从所有读路径消失(读侧时效闸,design/03 §9.6),
|
||||
// 随后被后台清扫**物理删除**(design/23 §19),**不可逆**。因此宁可漏判(噪声留进
|
||||
// 长期层,有 T1 与整理兜底),**绝不误弃**(design/23 §18.3 的 fail-safe)。
|
||||
// 反例(review 走真链路复现的误弃):`主人心情不好时会听音乐`、`主人焦虑时会咬指甲`
|
||||
// 在「够短 + 无数字就 defer」的旧判据下会被扔进临时层 —— 而那两条是**持久习惯**。
|
||||
//
|
||||
// 数字不再单独设防:它不在剥离表里,因此**必然留在残差里** ⇒ 带数值的句子一律放行
|
||||
// (等价于旧的 hasRetentionDigit 守卫,但由同一条「无实体」判据统一给出)。
|
||||
// 长度也不再单独设限:全局 40 rune 的上限(judgeExtractionRetention)已经兜住。
|
||||
func judgeRetentionEmotion(text string) retentionRule {
|
||||
if len([]rune(text)) > retentionMaxEmotionRunes {
|
||||
return retentionNone
|
||||
}
|
||||
if !containsAny(text, retentionEmotionWords()) {
|
||||
// 没有情绪词 ⇒ 与 N2 无关(本条只拦「情绪波动」这一类)。
|
||||
return retentionNone
|
||||
}
|
||||
if hasRetentionDigit(text) {
|
||||
if retentionResidual(text) != "" {
|
||||
// 剥掉情绪/时间/主语/程度/虚词后**还有实体**(听音乐 / 咬指甲 / 养猫 / …)
|
||||
// ⇒ 这是「在某种心情下会做的**稳定行为**」,属于长期记忆,放行。
|
||||
return retentionNone
|
||||
}
|
||||
return retentionEmotion
|
||||
}
|
||||
|
||||
// retentionResidual 剥掉剥离表里的全部词项,返回剩下的**实体片段**
|
||||
// (空串 = 整句只是一个情绪陈述,没有任何可长期留存的实体)。
|
||||
//
|
||||
// 它是 N2「无实体」那一半的唯一实现点,也是这条规则**方向正确**的关键:只有
|
||||
// 「整句由情绪词 + 时间词 + 主语 + 虚词构成」时才可能得到空残差 ⇒ 才可能 defer。
|
||||
func retentionResidual(text string) string {
|
||||
rest := text
|
||||
for _, tok := range retentionStripTokens() {
|
||||
if tok != "" {
|
||||
rest = strings.ReplaceAll(rest, tok, "")
|
||||
}
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// judgeRetentionTimelyData 施加 **N4**:时效性查询(当前时间 / 天气)。
|
||||
//
|
||||
// 它的词项是**整句形态**而不是单词(「几点」「天气」),因为「天气」单独出现可能
|
||||
@@ -263,6 +293,85 @@ func retentionEmotionWords() []string {
|
||||
"好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大",
|
||||
"心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈",
|
||||
"烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞",
|
||||
// 「不错」是**情绪评价**(「心情不错」),因此它在剥离表里必须按**整词**
|
||||
// 出现:只靠虚词表里的「不」会把它剥成光杆「错」而留下残差(漏判)。
|
||||
"不错",
|
||||
// 光杆「累」同样要收:「主人今天真的特别累」只有一个 `累` 而不含 `好累` /
|
||||
// `累了`,漏掉它就会留下一字残差、把一条纯情绪句放行。收它**不会**带来误弃
|
||||
// —— 判 defer 的门槛恒是「剥完什么都不剩」,多收一个词只会让残差更准确。
|
||||
"累",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionStripTokens 是 N2 判定「**无实体**」用的**剥离表**:把情绪词、时间词、
|
||||
// 主语/称呼、程度词、虚词与标点全部拼在一起,**长词在前**。
|
||||
//
|
||||
// 长词在前是**必须**的,不是风格:`好` 在程度词里、`好累` 在情绪词里,若先剥 `好`,
|
||||
// `好累` 就被拆成光杆 `累` 而永远剥不掉,残差非空 ⇒ 一条纯情绪句反而漏判。
|
||||
// (最长优先剥 ⇒ 每条词项都能按它自己的完整性被认出来。)
|
||||
//
|
||||
// 它是函数而不是包级变量(AGENTS §10 禁包级全局变量;同 tidyHabitMarkers 的手法)。
|
||||
func retentionStripTokens() []string {
|
||||
tokens := make([]string, 0, 192)
|
||||
tokens = append(tokens, retentionEmotionWords()...)
|
||||
tokens = append(tokens, retentionEmotionTimeWords()...)
|
||||
tokens = append(tokens, retentionEmotionSubjects()...)
|
||||
tokens = append(tokens, retentionEmotionDegreeWords()...)
|
||||
tokens = append(tokens, retentionEmotionFunctionWords()...)
|
||||
tokens = append(tokens, retentionEmotionPunctuation()...)
|
||||
sort.SliceStable(tokens, func(i, j int) bool {
|
||||
return len([]rune(tokens[i])) > len([]rune(tokens[j]))
|
||||
})
|
||||
return tokens
|
||||
}
|
||||
|
||||
// retentionEmotionTimeWords 是 N2 剥离表里的**时间词**:它们能让一句情绪陈述显形
|
||||
// 为「今天怎么样」,但本身不含实体(「明天下午三点开会」那类带实体的句子不会因为
|
||||
// 剥掉时间词而变空)。
|
||||
func retentionEmotionTimeWords() []string {
|
||||
return []string{
|
||||
"今天", "明天", "昨天", "今晚", "昨晚", "今早", "此刻", "现在", "刚才", "刚刚",
|
||||
"最近", "这两天", "这几天", "这阵子", "这段时间", "目前", "当下", "一整天",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionSubjects 是 N2 剥离表里的**主语 / 称呼**。剥它们是为了让
|
||||
// 「主人今天好累」与「今天好累」走同一条判据 —— 主语是谁不影响「这句话有没有实体」。
|
||||
func retentionEmotionSubjects() []string {
|
||||
return []string{
|
||||
"主人", "咱们", "我们", "你们", "他们", "她们", "自己",
|
||||
"我", "你", "他", "她", "它", "咱",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionDegreeWords 是 N2 剥离表里的**程度词**(它们只修饰情绪强度,
|
||||
// 不引入任何实体)。
|
||||
func retentionEmotionDegreeWords() []string {
|
||||
return []string{
|
||||
"非常", "特别", "稍微", "真的", "真是", "十分", "比较", "有点", "有些",
|
||||
"很", "挺", "太", "好", "超", "蛮", "更", "最",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionFunctionWords 是 N2 剥离表里的**虚词**(助词 / 介词 / 连词 /
|
||||
// 副词 / 情态词)。它们不承载实体,剥掉它们才能让「主人心情不好时会听音乐」剩下
|
||||
// 「听音乐」这个**实体**、从而被放行。
|
||||
func retentionEmotionFunctionWords() []string {
|
||||
return []string{
|
||||
"的时候", "的时候", "时候", "因为", "所以", "但是", "可是", "已经", "正在",
|
||||
"觉得", "感觉", "感到", "变得", "没有", "可以",
|
||||
"的", "地", "得", "了", "着", "过", "在", "会", "被", "是", "有", "和", "跟",
|
||||
"与", "都", "也", "还", "就", "而", "及", "把", "让", "给", "对", "向", "从",
|
||||
"到", "时", "之", "却", "又", "再", "要", "想", "不", "没", "能",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionPunctuation 是 N2 剥离表里的**标点与空白**:一句「今天好累」被
|
||||
// 剥到只剩一个逗号时,那仍然是「无实体」。
|
||||
func retentionEmotionPunctuation() []string {
|
||||
return []string{
|
||||
",", "。", "!", "?", "、", ";", ":", "…", "~", "~", "·", "-", "-",
|
||||
",", ".", "!", "?", ";", ":", "(", ")", "(", ")", " ", "\t",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,54 @@ func retentionPositiveCases() []struct {
|
||||
{"寒暄词+持久信号", "主人喜欢用喵打招呼"},
|
||||
// 长度安全阀:寒暄词出现,但句子长到装得下事实 ⇒ 放行。
|
||||
{"长句里的致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
|
||||
|
||||
// 🔴 N2 的「**无实体**」那一半(review 走真链路复现的误弃,p1)。
|
||||
// 它们是「**带宾语的情绪句**」——有情绪词、但情绪只是**状语**,真正要记的是
|
||||
// 后面那个稳定行为。旧判据(够短 + 无数字)会把它们扔进临时层:7 天后从
|
||||
// 所有读路径消失、再被后台清扫**物理删除**,不可逆。
|
||||
{"情绪+实体的习惯(听音乐)", "主人心情不好时会听音乐"},
|
||||
{"情绪+实体的习惯(咬指甲)", "主人焦虑时会咬指甲"},
|
||||
{"情绪+实体的习惯(养猫)", "主人心情低落时会养猫"},
|
||||
{"情绪+实体的偏好", "主人压力大的时候喜欢吃甜的"},
|
||||
{"情绪+实体的关系事实", "主人难过时会去找朋友聊天"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionRetention_EmotionWithObjectIsNotDeferred 是**上一条正例的机器守卫**
|
||||
// (拆出来是为了让「为什么这条不许被拦」的理由留在断言旁边)。
|
||||
//
|
||||
// 判据的**方向**比精度重要:N2 判 defer 的记忆 7 天后会被读侧时效闸隐藏、随后被
|
||||
// 后台清扫物理删除(design/23 §19),**不可逆**。因此 N2 的判据必须是
|
||||
// design/03 §10.5 的原文「**词项 + 无实体**」—— 剥掉情绪/时间/主语/程度/虚词之后
|
||||
// **余下为空**才 defer;只要还剩一个实体(听音乐 / 咬指甲 / 养猫)就必须放行。
|
||||
//
|
||||
// 变异自证:把 judgeRetentionEmotion 退回「够短 + 无数字」的旧判据 ⇒ 本用例立刻红。
|
||||
func TestExtractionRetention_EmotionWithObjectIsNotDeferred(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
content string
|
||||
residual string // 剥完剩下的实体
|
||||
wantDefer bool
|
||||
}{
|
||||
{content: "主人心情不好时会听音乐", residual: "听音乐", wantDefer: false},
|
||||
{content: "主人焦虑时会咬指甲", residual: "咬指甲", wantDefer: false},
|
||||
{content: "今天好累", residual: "", wantDefer: true},
|
||||
{content: "主人今天真的特别累", residual: "", wantDefer: true},
|
||||
{content: "心情不错!", residual: "", wantDefer: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.content, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := normalizeRetentionText(tc.content)
|
||||
if got := retentionResidual(text); got != tc.residual {
|
||||
t.Fatalf("残差 = %q, want %q(残差就是 N2「无实体」那一半的判据)", got, tc.residual)
|
||||
}
|
||||
if rule := judgeExtractionRetention(tc.content); (rule == retentionEmotion) != tc.wantDefer {
|
||||
t.Fatalf("judgeExtractionRetention(%q) = %q, wantEmotionDefer=%t —— 带宾语的与纯情绪的必须给出相反结论",
|
||||
tc.content, rule, tc.wantDefer)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user