Files
Cyrene_v2/internal/runtime/memory/extract_retention_transient_test.go
T
AskaEth 3b9ae3f918 refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 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 全绿。
2026-10-26 19:15:08 +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 := judgeRetentionNoTemporal(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 := judgeRetentionNoTemporal("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint {
t.Fatalf("时点句 = %q, want %q", got, retentionTimepoint)
}
if got := judgeRetentionNoTemporal("用户正在验证分享确认面板的展开功能"); 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 := judgeRetentionNoTemporal(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 := judgeRetentionNoTemporal(fronted); got != retentionTimepoint {
t.Fatalf("时点在句首 = %q, want %q", got, retentionTimepoint)
}
if got := judgeRetentionNoTemporal(buried); got != retentionNone {
t.Fatalf("时点埋在从句里 = %q, want %q(位置安全阀失效会误杀长内容)", got, retentionNone)
}
}
// TestExtractionRetention_ConjunctionIsNotANoOp 钉住护栏②改的是**组合口径**:
// 「约定」这种锚点单独出现时不再一票放行(于是它会被 N6 判成临时安排);
// 但一旦配上具体约束(频次),立刻回到长期层。改回「任一锚点就放行」时,
// 前一半会变红;把组合条件删成「任一命中即放行」时,整条用例会红。
func TestExtractionRetention_ConjunctionIsNotANoOp(t *testing.T) {
t.Parallel()
if got := judgeRetentionNoTemporal("约定下周给用户留一个安静的午后"); got != retentionTimepoint {
t.Fatalf("「约定 + 时点、无约束」= %q, want %q(锚点单独出现不再一票放行)",
got, retentionTimepoint)
}
if got := judgeRetentionNoTemporal("约定每天给主人留一个安静的午后"); got != retentionNone {
t.Fatalf("「约定 + 每天(具体约束)」= %q, want 放行(组合条件命中即长期)", got)
}
if got := judgeRetentionNoTemporal("主人每天要喝 2 杯咖啡"); got != retentionNone {
t.Fatalf("「偏好 + 数值」= %q, want 放行", got)
}
if got := judgeRetentionNoTemporal("主人说过不要在群里提她的工作"); 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 := judgeRetentionNoTemporal(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)
}
}