Files
Cyrene_v2/internal/runtime/memory/extract_retention_transient_test.go
AskaEth c7c0294a16 refactor(memory): M45 裁决 D —— temporal 位撤出判定,只保留观测
按 r1 复审裁决 D:这一位先只做观测,`resolveRetention` 不许读它。理由(实测):
无条件 one_off ⇒ temp 会当天删 21 条长期约定;只放开 N6 位置判据能翻的 13 条
逐条是约定、而复审点名的三条目标行 0/3 救得了。在「模型真实标签分布」这份证据
缺位时不该做 7 天后物理删的不可逆决定。

撤接线(结论集逐字回到 M45 之前):
- retention.go:resolveRetention 不再读 item.Temporal;与之对比 dev base
  (af61b8c)本文件**只有注释差异**,规则与判定入口逐字未变。
- extract_retention.go:judgeExtractionRetention 签名回退单参;删除
  judgeRetentionTimepointWide 与 n6_timepoint_wide 规则码。与 dev base 对比
  **同样只有注释差异** ⇒ 「结论集 = 不含 temporal 的那个集合」是结构性的,
  不是靠调用点记得别传。
- 连带取消初版的 durable / recurring ⇒ long(零影响必须是双向的)。

保留观测面(一律未动):迁移 0027 的列、自举前滚、提取器的 temporal 产出、
prompt 枚举、闭集与 normalizeTemporal、store_temporal.go 的校验/编码。
另外把 prompt 里「标成 one_off 会让它进临时层、到期消失」这句**撤接线后已是假话**
改成中性标注说明 —— 否则会诱导模型避开 one_off,观测数据直接被污染。

测试:
- TestTemporalBitDoesNotAffectRetention —— 4 类语料 × 5 个取值,(档位,规则码)
  与未分型逐字相同(核心正断言);
- TestTemporalOneOffDoesNotMakeAnythingTemporary / ...DoesNotRescueRuleHitRows
  —— 零影响的双向点名;
- TestTemporalIsStillRecorded —— one_off 的约定行仍落 temporal='one_off' 而档位
  long、expires 空(观测在、判定不动);
- TestTemporalDoesNotTouchExplicitTierOrRestrictedSource —— 人工入口与受限来源不变。
变异实测:把 `if item.Temporal == TemporalOneOff { return temp }` 插回
resolveRetention ⇒ 上述四条用例全红(FAIL 输出已贴给塔),恢复后全绿。

IngestRuleVersion 保持 d3.5,注释改准为「记录 temporal 信号,但不参与判定」
(留痕面变了:库里多了「模型当时标了什么」这一维,回退会让新旧留痕混在一起)。

P2 三条保留:store.go 500 行(拆 store_temporal.go)、temporal.go 头注('' 的口径
+ 两次被否的接线方案 + 接线前必须看的那两条统计 SQL)、报告语境段。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race
38.2s 全绿。
2026-10-26 19:21:24 +08:00

225 lines
12 KiB
Go
Raw Permalink 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)
}
}