Files
Cyrene_v2/internal/runtime/memory/extract_retention_transient_test.go
T
AskaEth 54e32304f2 fix(memory): dedupe_group 补取值门 + 临时层双击复核;闸补 3 类长期事实安全阀(M34 r1 复审)
r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**,
生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行,
而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。
修法(结构性,不是补丁):
- 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与
  **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少;
  方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。
  补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。
- tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高:
  语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109)
  的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。
  实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。
- 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、
  TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、
  TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度
  归零 ⇒ 第四条红。

r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map,
`temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核
(候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例:
TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。

r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、
「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内;
「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。

实测(生产库只读 mode=ro,active 3667):
- dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15;
  取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2
  (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。
- 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条
  逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。
- 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74%
  且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」
  10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:29:02 +08:00

225 lines
12 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 (
"context"
"testing"
"time"
)
// 本文件是留存闸 **N6(指示性时点)/ N7(进行中状态)** 与**组合护栏②**
// (2026-10-24,M34)的验收用例集。
//
// 两条不对称的事实决定了这个文件的写法:
// - 漏判只是「库里多一条噪声」(T1 与整理会兜底);
// - 误判会把一条真记忆丢进临时层 —— 7 天后从所有读路径消失,再被后台清扫
// **物理删除**(不可逆)。
//
// 因此这里的**正例(必须放行)比负例(必须拦下)更硬**:`retentionLongTermCases`
// 里的每一条都是真实语料里「带时点词但仍然长期成立」的句子,它们逐条钉住
// 「误杀真实长期记忆比漏放新记忆更严重」这条口径。
// retentionTimepointCases 是 N6 的正例:**锚点是一个正在过去/即将到来的时刻**,
// 且没有习惯词、没有硬性长期事实词、没有数值/约束 —— 它们该进临时层。
func retentionTimepointCases() []string {
return []string{
"用户昨天发送了一份长达一万两千行的验收纸条",
"用户近期在测试一条通道,今天来回测了三轮",
"用户最近在看一部年代/审讯题材的影视作品,台词「我肯定能拿下口供」",
"计划下周为主人留一个安静的午后",
"用户今晚在公司加班到很晚",
// 约定类:锚定到某个时刻的安排(约定本身不是长期事实,配上时点才是临时记忆)。
"用户与助理约定明天中午见",
}
}
// retentionLongTermCases 是**回归护栏**:真实语料里「带时点词但长期成立」的句子,
// 一条都不许判 defer。每一行都对应一次实测误杀(谁把它们放进来,就是谁在丢记忆)。
func retentionLongTermCases() []struct {
name string
content string
} {
return []struct {
name string
content string
}{
// 时点词埋在**引语/从句**里,不是这句话的锚点(位置安全阀)。
{"上班族习惯(引语里的明天)", "用户是上班族,需要上班(曾说明天要上班并感到不开心)"},
{"睡前习惯(从句里的明天见)", "用户睡前会道「晚安、明天见」,人家会替她掀被角、调暗灯光,用数呼吸的方式陪她入睡"},
{"单位排班事实(引语里的今天)", "主人所在单位存在周末/节假日被安排加班的情况,实际排班与公开调休日历并不一致(她自己说「我们今天加班啦」)"},
// 习惯词/硬性长期事实词(地点、职业、住)⇒ 一律长期,标题里带「今天」也不许判临时。
{"地点事实", "用户目前生活在花溪(贵阳花溪区),不在上海上班"},
{"住宿舍(近期)", "用户近期住在宿舍(不在家里),晚间会关心她洗漱水温、睡觉时间等宿舍生活细节"},
{"作息习惯", "主人常在深夜与助手聊天,有熬夜习惯"},
{"长期偏好", "主人喜欢喝冰美式来提神"},
{"称呼约定", "主人希望被称呼为「叶酱」;旧的称呼「喵喵七号」已作废,不要再使用"},
{"关系里程", "用户曾向 Cyrene 发送内容为「i love you」的文件,Cyrene 完整接收并回复「me too」"},
{"人格设定", "助手设定:曾经历三千万次轮回等待用户,并在「记忆之海」中捞回用户的记忆"},
{"海报即本人", "主人曾发来一张海报,画面是粉发紫眼、配蓝玫瑰与月桂叶,并明确说「那张海报上就是你哦」"},
// 情绪倾向 + 寒暄:N6 不接管 N1/N2 的语料(安全阀 5)。
{"情绪倾向", "主人最近很容易焦虑"},
// 2026-10-25 r1 复审 P2-2 的实测误杀(同一条语料、同一套代码被漏报过的那些)。
{"宠物稳定外观(换成)", "茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了一个小铃铛"},
{"居住事实(宿舍过夜)", "主人近期在宿舍过夜,需要提醒她宿舍灯早点关、洗漱水别太凉"},
{"习惯陈述里的从句「正在」", "用户会主动维护助手与自己之间的连接通道,并告诉助手她正在维护,表明她重视保持与助手的稳定联系"},
{"习惯陈述里的从句「正在」(二)", "用户会维护自己与助手之间的连接通道,并曾表示正在为此进行维护"},
// 「以后/从此/往后」= 自此刻起永久成立(复审构造即可复现)。
{"以后都叫我小叶", "昨天主人说以后都叫我小叶"},
{"以后周末不加班", "上周主人说以后周末不加班了"},
{"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
}
}
// TestExtractionRetention_TimepointIsDeferred 钉住 N6 的正例。
func TestExtractionRetention_TimepointIsDeferred(t *testing.T) {
t.Parallel()
for _, content := range retentionTimepointCases() {
if got := judgeExtractionRetention(content); got != retentionTimepoint && got != retentionInProgress {
t.Fatalf("%q 判成 %q, want %q(锚点是一个时刻 ⇒ 临时层)",
content, got, retentionTimepoint)
}
}
}
// TestRetentionTimepointHitsBySpecificRule 钉住「命中哪一条」:时点归 N6、进行中归 N7。
// 它让两条规则的词表**各自**可被单测,不会一条盖住另一条。
func TestRetentionTimepointHitsBySpecificRule(t *testing.T) {
t.Parallel()
if got := judgeExtractionRetention("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint {
t.Fatalf("时点句 = %q, want %q", got, retentionTimepoint)
}
if got := judgeExtractionRetention("用户正在验证分享确认面板的展开功能"); got != retentionInProgress {
t.Fatalf("进行中句 = %q, want %q", got, retentionInProgress)
}
}
// TestExtractionRetention_RealLongTermFactsSurviveTimepoints 是**误杀回归**:
// 真实长期事实即使带着时点词也必须放行。任何一条变红都意味着「7 天后它会消失」。
func TestExtractionRetention_RealLongTermFactsSurviveTimepoints(t *testing.T) {
t.Parallel()
for _, tc := range retentionLongTermCases() {
if got := judgeExtractionRetention(tc.content); got != retentionNone {
t.Fatalf("长期事实 %q(%s)被判成 %q —— 它会在 7 天后被物理删除(不可逆)",
tc.content, tc.name, got)
}
}
}
// TestExtractionRetention_TimepointMustBeFronted 钉住**位置安全阀**:同一个句子,
// 时点在句首 → defer;把同一时点挪到从句里 → 放行。位置是这条规则精度的来源。
func TestExtractionRetention_TimepointMustBeFronted(t *testing.T) {
t.Parallel()
fronted := "用户昨天发来一卷验收纸条"
buried := "用户与助手讨论过验收纸条的格式(他自己说昨天发来一卷)"
if got := judgeExtractionRetention(fronted); got != retentionTimepoint {
t.Fatalf("时点在句首 = %q, want %q", got, retentionTimepoint)
}
if got := judgeExtractionRetention(buried); got != retentionNone {
t.Fatalf("时点埋在从句里 = %q, want %q(位置安全阀失效会误杀长内容)", got, retentionNone)
}
}
// TestExtractionRetention_ConjunctionIsNotANoOp 钉住护栏②改的是**组合口径**:
// 「约定」这种锚点单独出现时不再一票放行(于是它会被 N6 判成临时安排);
// 但一旦配上具体约束(频次),立刻回到长期层。改回「任一锚点就放行」时,
// 前一半会变红;把组合条件删成「任一命中即放行」时,整条用例会红。
func TestExtractionRetention_ConjunctionIsNotANoOp(t *testing.T) {
t.Parallel()
if got := judgeExtractionRetention("约定下周给用户留一个安静的午后"); got != retentionTimepoint {
t.Fatalf("「约定 + 时点、无约束」= %q, want %q(锚点单独出现不再一票放行)",
got, retentionTimepoint)
}
if got := judgeExtractionRetention("约定每天给主人留一个安静的午后"); got != retentionNone {
t.Fatalf("「约定 + 每天(具体约束)」= %q, want 放行(组合条件命中即长期)", got)
}
if got := judgeExtractionRetention("主人每天要喝 2 杯咖啡"); got != retentionNone {
t.Fatalf("「偏好 + 数值」= %q, want 放行", got)
}
if got := judgeExtractionRetention("主人说过不要在群里提她的工作"); got != retentionNone {
t.Fatalf("「禁忌」= %q, want 放行", got)
}
}
// TestExtractionRetention_TimepointSafetyValves 逐条钉住 N6/N7 的四个安全阀:
// 习惯词 / 硬性长期事实词 / 数值与约束 / 寒暄与情绪词都把它们按回去。
//
// 断言写成「**不是 N6/N7**」而不是「必须放行」是刻意的:安全阀的职责只有一个 ——
// 不让这条句子被 N6/N7 判成临时层。它可能仍被更早的既有规则(N1/N2)判成临时,
// 那是**既有口径**(本条用例不重定义它),也不是回归。
func TestExtractionRetention_TimepointSafetyValves(t *testing.T) {
t.Parallel()
cases := []struct {
content string
why string
}{
{"用户今天又被加班拖住了,她通常十点后才有空", "习惯词(通常)⇒ 反复发生"},
{"用户今天要去医院复查过敏", "硬性长期事实词(过敏)"},
{"用户昨天对花生过敏犯了,吃了药", "硬性长期事实词(过敏 / 吃药)"},
{"用户昨天发了 3 份文件", "数值是事实的载体"},
{"用户最近每天要喝两杯水", "约束词(每天)"},
{"用户昨夜睡得还行,今天心情不错", "情绪词 ⇒ 归 N2 判"},
}
for _, tc := range cases {
got := judgeExtractionRetention(tc.content)
if got == retentionTimepoint || got == retentionInProgress {
t.Fatalf("%q 判成 %q, want 不被 N6/N7 判临时(%s)", tc.content, got, tc.why)
}
}
}
// TestIngestGate_TimepointDefersWithTempTTL 钉住 TTL 的落点:N6 命中 ⇒ 第五态
// `defer`,行照常 active 落库(正文一字不动),`expires_at` = 判定时刻 + 临时 TTL
// (默认 7 天,落 `$DATA_DIR/config/memory-tidy.json` 的 `tempTtlSec`,零新 env 键)。
func TestIngestGate_TimepointDefersWithTempTTL(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
ttl := 7 * 24 * time.Hour
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base))
if exp, want := readIngestExpiry(t, store, got.ID), expiryText(base.Add(ttl)); exp != want {
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 7 天)", exp, want)
}
row := loadTidyItemRow(t, store, got.ID)
if row.status != tidyStatusActive || row.content != "用户昨天发来一卷一万两千行的验收纸条" {
t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row)
}
rev := gateRecordFor(t, store, got.ID)
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
}
}
// TestIngestGate_DeferNeverBackfillsExistingRows 钉住「**TTL 只开新增侧**」:
// 存量行的 `expires_at` 一条都不补写。变异(在 defer 时顺手 UPDATE 整个分区)会红。
func TestIngestGate_DeferNeverBackfillsExistingRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
// 存量行在**接入闸之前**落库:这条用例要看的是「闸跑一轮之后,存量行的
// expires_at 仍然为空」,而不是「存量行当初怎么落的库」。
existing := seedTidyItem(t, store, gateFixtureItem("主人有不吃早饭的习惯,需要提醒每天吃热乎的早饭", base))
r := newGateReviewer(t, store, ReviewConfig{TempTTL: 7 * 24 * time.Hour}, nil)
store.AttachIngestReviewer(r)
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
t.Fatalf("存量行 expires_at = %q, want 空串", exp)
}
fresh := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base.Add(time.Minute)))
if exp := readIngestExpiry(t, store, fresh.ID); exp == "" {
t.Fatalf("新行没被写上 expires_at —— N6 没有生效")
}
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
t.Fatalf("存量行被补写了 expires_at = %q —— TTL 只许开新增侧", exp)
}
var withExpiry int
if err := store.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_items WHERE expires_at <> ''`).Scan(&withExpiry); err != nil {
t.Fatalf("count expires_at: %v", err)
}
if withExpiry != 1 {
t.Fatalf("带 expires_at 的行 = %d, want 恰为 1(只有那条新记忆)", withExpiry)
}
}