Files
Cyrene_v2/internal/runtime/memory/temporal_test.go
T
AskaEth 91101dedfb feat(memory): M45 提取器时间性元数据位 temporal 驱动留存判定
背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成
30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的
就是那一位,不动 N1~N7 的任何阈值。

产出侧
- temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown,
  空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜
  同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。
- llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累
  同批其他候选。

落库
- 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
  down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚
  (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与
  留痕不分叉。

判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改)
- one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀:
  显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 ——
  命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时
  21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。
- durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7
  (不许因为「正文看着像今天的事」就判 temp)。
- IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。

验证
- go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/...
  ./internal/db/... 全绿;memory -race 全绿。
- 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与
  temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测:
  摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。
- 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore),
  新加一条迁移不再打红 0025/0026 的用例。
2026-10-26 18:57:38 +08:00

454 lines
20 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"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集,逐条对应
// 任务书的验收点:
//
// ① `one_off` 的夹具语料 ⇒ 全链落库 `(temp, 非空 expires_at)`;
// ② `durable` / `recurring` ⇒ `(long, 空)`;
// ③ `unknown` 且内容像一次性 ⇒ **仍按旧规则**(本条不含新的猜测:命中的规则码
// 必须是 N* 词项规则的码,绝不是时间性位的码);
// ④ 模型返回非法值 / 空值 ⇒ 归 `unknown`,且**不报错**、不拖累同批候选;
// ⑤ 变异:把 temporalVerdict 的 one_off 支路摘掉 ⇒ ① 必红(下面三条 ① 的断言
// 都以「档位是 temp」为判据,摘掉支路后它们全部退化成 long)。
//
// 🔴 这份语料的一半(`one_off` 那三条)**刻意挑成旧规则的非命中形态**:它们在
// `Temporal` 为空时判 long、带上 `one_off` 时判 temp —— 于是「这一位真的改了什么」
// 可以被单独观察,不会与 N1~N7 的效果混在一起(M37 的实测缺口正是「分不清是谁判的」)。
// temporalOneOffCorpus 是一次性事件的夹具语料:长句陈述形态、无句首时点词、
// **不含约定/承诺词**(含约定词的句子由 retentionStatesAgreement 挡回旧规则,
// 见 TestTemporalOneOffDoesNotOverrideStatedAgreement)—— 正是 M37 复盘点名的
// 「一次性事件被写成 30~80 字第三人称陈述」那种形态。
func temporalOneOffCorpus() []string {
return []string{
"桌面上的文件整理到一半被别的事打断了",
"傍晚出门走十分钟的散步这一回没走成",
"那条链路断线是主人自己排查到根因的",
}
}
// readTemporalColumn 直读一行的 `temporal` 列(该列与 expires_at 一样是**只写不读**
// 的列:四条列表读路径的 SELECT 不带它,见 store.go 的 Temporal 注释)。
func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind {
t.Helper()
var raw string
if err := store.db.QueryRowContext(context.Background(),
`SELECT temporal FROM memory_items WHERE id = ?`, id).Scan(&raw); err != nil {
t.Fatalf("read temporal column %s: %v", id, err)
}
return TemporalKind(raw)
}
// TestTemporalOneOffLandsTempWithExpiry 钉住验收点①(也是变异点⑤的靶子):
// 时间性位 `one_off` ⇒ 全链落库 `(temp, 非空 expires_at)`,且这一位真的写进了
// `temporal` 列(判定与留痕不分叉)。
//
// 「全链」= 与生产同一条写路径:`Store.Add`(唯一 INSERT 路径)→ resolveRetention
// → planIngest → 一笔 INSERT。测试**不绕开** Add,也不用任何测试专用的旁路。
func TestTemporalOneOffLandsTempWithExpiry(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
for _, content := range temporalOneOffCorpus() {
content := content
t.Run(content, func(t *testing.T) {
// 先证明这段正文**不含** N1~N7 的命中:否则本用例就分不清是规则还是
// 时间性位判的 temp(M37 的缺口就是分不清)。
bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
if err != nil {
t.Fatalf("resolveRetention(bare): %v", err)
}
if bare.Tier != RetentionLong {
t.Fatalf("夹具语料 %q 在旧规则下就判成 %q(want long)—— 这条夹具证明不了时间性位的作用,换一条",
content, bare.Tier)
}
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
got, err := store.Add(ctx, item)
if err != nil {
t.Fatalf("Add(one_off): %v", err)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTemp {
t.Fatalf("%q with temporal=one_off 落库档位 = %q, want %q", content, tier, RetentionTemp)
}
if expires == "" {
t.Fatalf("%q 判成 temp 却没写 expires_at(两列分叉)", content)
}
if got.ExpiresAt.IsZero() {
t.Fatalf("%q 的 Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)", content)
}
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
t.Fatalf("%q 的 temporal 列 = %q, want %q(判定与留痕不许分叉)", content, nat, TemporalOneOff)
}
})
}
}
// TestTemporalOneOffDefersThroughGate 钉住「判定接线」:闸开着时,`one_off` 判出的
// temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),而不是另开一条路径。
//
// 本用例同时是「不要造第二个判定入口」的观测点:`judgeIngest` 只读档位,因此这里
// 除了档位列以外不额外断言任何规则重判的痕迹 —— 重判会让
// TestRetentionJudgementHasSingleRuleCallSite 先红。
func TestTemporalOneOffDefersThroughGate(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
ttl := time.Hour
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
item := habitItem(temporalOneOffCorpus()[0], base, 0.9)
item.Temporal = TemporalOneOff
got, err := store.Add(context.Background(), item)
if err != nil {
t.Fatalf("Add: %v", err)
}
rev := gateRecordFor(t, store, got.ID)
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("one_off 条目的留痕 = %+v, want defer + %q", rev, retentionLowValueReason)
}
if rev.RuleVer != IngestRuleVersion {
t.Fatalf("留痕规则版本 = %q, want %q", rev.RuleVer, IngestRuleVersion)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTemp {
t.Fatalf("one_off 条目档位 = %q, want %q", tier, RetentionTemp)
}
// 到期时刻来自同一个判定与同一个时钟(闸注入的 now + 面板 TTL)。
if want := expiryText(base.Add(ttl)); expires != want {
t.Fatalf("expires_at = %q, want %q", expires, want)
}
}
// TestTemporalDurableAndRecurringStayLong 钉住验收点②:`durable` / `recurring` ⇒
// `(long, 空)`,**并且覆盖规则闸的 temp 结论**。
//
// 语料故意取一条旧规则会判 temp 的正文(N6 指示性时点):「模型已经答了这不是
// 一次性事件」时,规则不许把它改判成临时层 —— 方向是 fail-safe(多留一条),
// 与「规则闸拦下的不许被 T1 推翻」并不矛盾:那一条防的是**放宽**(模型把规则判成
// 临时的改成长期),方向相反。
func TestTemporalDurableAndRecurringStayLong(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
const content = "用户昨天发来一卷验收纸条"
// 前提:这条正文在旧规则下确实判 temp(否则本用例证明不了「覆盖」)。
bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
if err != nil {
t.Fatalf("resolveRetention(bare): %v", err)
}
if bare.Tier != RetentionTemp || bare.Rule != retentionTimepoint {
t.Fatalf("前提不成立:%q 旧规则判 %q/%q,want temp/%q", content, bare.Tier, bare.Rule, retentionTimepoint)
}
for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} {
temporal := temporal
t.Run(string(temporal), func(t *testing.T) {
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = temporal
got, err := store.Add(ctx, item)
if err != nil {
t.Fatalf("Add(%q): %v", temporal, err)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionLong {
t.Fatalf("temporal=%q 落库档位 = %q, want %q", temporal, tier, RetentionLong)
}
if expires != "" {
t.Fatalf("temporal=%q 写了 expires_at = %q(非 temp 档必须为空)", temporal, expires)
}
if nat := readTemporalColumn(t, store, got.ID); nat != temporal {
t.Fatalf("temporal 列 = %q, want %q", nat, temporal)
}
})
}
}
// TestTemporalUnknownFallsThroughToRules 钉住验收点③,也是最容易做偏的一条:
// `unknown`(以及未分型的空串)**不许自己判 temp** —— 它必须原样交给 N1~N7,
// 判据只有旧规则那一份。
//
// 三条断言方向不同,缺一条都测不出「方向反了」:
//
// 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:命中的
// 规则码必须是 `n6_timepoint`,**绝不能**是时间性位的 `temporal_one_off` ——
// 后者出现就说明有人把「看起来像今日事」变成了新的判据;
// 2. `unknown` + 旧规则非命中的正文 ⇒ long(不许因为看着像一次性就判 temp);
// 3. 未分型(空串)与 `unknown` 走同一条路(历史行的行为逐字不变)。
func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
t.Parallel()
ctx := context.Background()
t.Run("unknown_旧规则命中", func(t *testing.T) {
item := habitItem("今天下雨了", tidyTestTime(t), 0.9)
item.Temporal = TemporalUnknown
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier != RetentionTemp {
t.Fatalf("unknown + 「今天下雨了」= %q, want %q(旧规则 N6 的结论)", verdict.Tier, RetentionTemp)
}
if verdict.Rule != retentionTimepoint {
t.Fatalf("命中的规则 = %q, want %q —— 若出现 %q 说明 unknown 被当成了「一次性」的新判据",
verdict.Rule, retentionTimepoint, retentionTemporalTempRule)
}
})
t.Run("unknown_旧规则非命中", func(t *testing.T) {
store := newTestStore(t)
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
item.Temporal = TemporalUnknown
got, err := store.Add(ctx, item)
if err != nil {
t.Fatalf("Add(unknown): %v", err)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionLong || expires != "" {
t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 「看着像今日事」不许判 temp",
tier, expires, RetentionLong)
}
})
t.Run("未分型_与unknown同路", func(t *testing.T) {
for _, temporal := range []TemporalKind{TemporalUnset, TemporalUnknown} {
item := habitItem("谢谢啦", tidyTestTime(t), 0.9)
item.Temporal = temporal
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention(%q): %v", temporal, err)
}
if verdict.Tier != RetentionTemp || verdict.Rule != retentionChatter {
t.Fatalf("temporal=%q 时 %q 判成 %q/%q, want temp/%q(历史行的行为必须逐字不变)",
temporal, item.Content, verdict.Tier, verdict.Rule, retentionChatter)
}
}
})
}
// TestTemporalOneOffDoesNotOverrideExplicitRemember 钉住交叉规则:显式「记住」的
// 一票否决(extract_retention.go 护栏①)对时间性位**同样有效**。
//
// 没有这条,模型把「记住我明天三点开会」标成 one_off 就会让主人在 7 天后丢掉自己
// 明确要求记的东西 —— 那是 fail-safe 方向的反面,且不可逆。
func TestTemporalOneOffDoesNotOverrideExplicitRemember(t *testing.T) {
t.Parallel()
item := habitItem("记住:明天下午三点开会,别迟到", tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier != RetentionLong {
t.Fatalf("显式「记住」+ one_off = %q, want %q(护栏① 一票否决对元数据位同样有效)",
verdict.Tier, RetentionLong)
}
}
// TestTemporalOneOffDoesNotTouchRestrictedSource 钉住与受限来源的优先级:群聊 /
// 陌生人的行**不分型**(空串),因此时间性位不许把它们判成 temp —— 那会造出
// 「对读面隐藏、却永远不会被后台清扫删除」的行(expire.go 跳过非 owner 来源)。
func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) {
t.Parallel()
store := newTestStore(t)
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
item.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
got, err := store.Add(context.Background(), item)
if err != nil {
t.Fatalf("Add: %v", err)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTierUnset || expires != "" {
t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires)
}
}
// TestTemporalOneOffDoesNotOverrideStatedAgreement 钉住 `one_off` 支路的第二条
// 例外,也是本片最要紧的一条 fail-safe:**正文在陈述一份约定时,`one_off` 不许把它
// 推进临时层**。
//
// 用例内容逐字取自 M37 复审的量化证据(把 N6 时点窗口放宽到 24 字会多命中 11 条,
// 逐条都是「同一条长期约定的重复陈述」):
//
// 「一起晒太阳」暂时搁置 ×5 → 今天是「约定 + 暂缓」
// 「傍晚出门走十分钟…今天没走成明天补上」×6 → 今天是「约定 + 某一次没走成」
//
// 这两种句子**确实在描述某一次**,因此模型很容易标成 one_off;若代码照单全收,
// 7 天后被删掉的就是约定本身 —— M37 的原话:「放宽阈值会在 7 天后把约定本身删掉,
// 违背 fail-safe」。
func TestTemporalOneOffDoesNotOverrideStatedAgreement(t *testing.T) {
t.Parallel()
agreements := []string{
"两人之间有一个「晒太阳」的约定,目前暂缓,等主人想起来再提",
"和主人有「晚饭后出门走十分钟」的约定;今天没走成,算欠着,明天补上",
"和叶酱约好每天出门走十分钟;今天太阳先落山没走成,这十分钟欠着",
}
for _, content := range agreements {
content := content
t.Run(content, func(t *testing.T) {
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier == RetentionTemp {
t.Fatalf("%q 因 temporal=one_off 被判成 temp —— 这是长期约定,7 天后会被删掉(fail-safe 反了)", content)
}
if !retentionStatesAgreement(content) {
t.Fatalf("retentionStatesAgreement(%q) = false —— 例外没认出这条约定", content)
}
})
}
}
// TestTemporalOneOffDoesNotOverrideDurableShape 钉住 `one_off` 支路的语义安全阀
// (temporalSanityVeto 的后四条):正文自己在说「习惯 / 长期事实 / 自此刻起永久 /
// 状态已改变」时,模型标的一次性**不给结论**,落回旧规则。
//
// 语料全部取自 2026-10-27 对生产库 active 集合的离线重放里**真实出现过**的形态
// (它们是「上界候选」的主要来源:被时点词捎带的状态事实)。判成 temp 的后果是
// 7 天后被删,因此这些形态必须留住。
func TestTemporalOneOffDoesNotOverrideDurableShape(t *testing.T) {
t.Parallel()
cases := []struct{ content, why string }{
{"主人有每天吃早餐的习惯,几乎不会省略早餐。", "习惯词(反复发生,与一次性互斥)"},
{"用户近期住宿舍(昨天住宿舍,可能今晚还在),需要关心住宿情况。", "硬性长期事实词(居住)"},
{"茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了个小铃铛。", "状态改变(新状态会持续)"},
{"主人昨天说以后都叫我小叶", "永久化标记(自此刻起一直成立)"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.content, func(t *testing.T) {
item := habitItem(tc.content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
if !temporalSanityVeto(tc.content) {
t.Fatalf("temporalSanityVeto(%q) = false(%s)", tc.content, tc.why)
}
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier == RetentionTemp {
t.Fatalf("%q 因 temporal=one_off 被判成 temp —— %s,删掉是误弃", tc.content, tc.why)
}
})
}
}
// TestNormalizeTemporal 钉住验收点④的归一化口径:**只认四个字面值**,其余
// (空 / 中文 / 自造 / 拼写变体)一律 unknown。
//
// 不做同义词映射是刻意的:猜同义词的代价是单向的(猜成 one_off 就删记忆),
// 而「拿不准一律 long」才是 fail-safe。
func TestNormalizeTemporal(t *testing.T) {
t.Parallel()
cases := []struct {
raw string
want TemporalKind
}{
{"one_off", TemporalOneOff},
{"ONE_OFF", TemporalOneOff},
{" durable \n", TemporalDurable},
{"recurring", TemporalRecurring},
{"unknown", TemporalUnknown},
{"", TemporalUnknown},
{" ", TemporalUnknown},
{"一次性", TemporalUnknown},
{"one-off", TemporalUnknown},
{"oneoff", TemporalUnknown},
{"forever", TemporalUnknown},
}
for _, tc := range cases {
if got := normalizeTemporal(tc.raw); got != tc.want {
t.Fatalf("normalizeTemporal(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
// TestTemporalKindClosedSet 钉住闭集本身:四个业务值 + 未分型都合法,其他都不合法
// (Add 的 validate 会拒掉它们 —— 那是程序错误,不是模型输出)。
func TestTemporalKindClosedSet(t *testing.T) {
t.Parallel()
for _, ok := range []TemporalKind{TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown} {
if !ok.valid() {
t.Fatalf("%q 应在闭集内", ok)
}
}
for _, bad := range []TemporalKind{"one-off", "sometimes", "ONE_OFF", "float"} {
if bad.valid() {
t.Fatalf("%q 不应在闭集内", bad)
}
}
if TemporalUnknown.assigned() != true || TemporalUnset.assigned() != false {
t.Fatal("assigned() 只应对非空串为真(「未分型」的唯一判据)")
}
// 非法值必须被写入路径拒掉(不是静默落库成脏列值)。
item := habitItem("一条正文", tidyTestTime(t), 0.9)
item.Temporal = TemporalKind("sometimes")
if err := item.normalize(); err == nil {
t.Fatal("validate 接受了非法的时间性位(应返回 ErrInvalidTemporal)")
}
}
// TestLLMExtractorReadsTemporal 钉住验收点④的另一半:提取器**真的**在读模型给的
// 这一位,且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。
func TestLLMExtractorReadsTemporal(t *testing.T) {
t.Parallel()
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
return model.Response{Content: `[
{"content":"候选一","temporal":"one_off"},
{"content":"候选二","temporal":"durable"},
{"content":"候选三","temporal":"Recurring"},
{"content":"候选四","temporal":"forever"},
{"content":"候选五"}
]`}, nil
}}
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
items, err := ex.Extract(context.Background(), []model.Message{
model.TextMessage(model.RoleUser, "随便聊两句"),
})
if err != nil {
t.Fatalf("Extract: %v(非法 temporal 绝不能让整批提取失败)", err)
}
want := map[string]TemporalKind{
"候选一": TemporalOneOff,
"候选二": TemporalDurable,
"候选三": TemporalRecurring,
"候选四": TemporalUnknown,
"候选五": TemporalUnknown,
}
if len(items) != len(want) {
t.Fatalf("候选数 = %d, want %d", len(items), len(want))
}
for _, it := range items {
if it.Temporal != want[it.Content] {
t.Fatalf("%q 的 temporal = %q, want %q", it.Content, it.Temporal, want[it.Content])
}
}
}
// TestExtractionPromptCarriesTemporalEnum 钉住「提示词与闭集不漂移」:模型能看到的
// 取值必须与 normalizeTemporal 认的四个值逐字一致(提示词只是辅助,但取值口径漂了
// 就会让每条候选都被归成 unknown,这一位等于白加)。
func TestExtractionPromptCarriesTemporalEnum(t *testing.T) {
t.Parallel()
for _, want := range []string{"temporal", "one_off", "recurring", "durable", "unknown"} {
if !strings.Contains(extractSystemPrompt, want) {
t.Fatalf("提取提示词里缺 %q —— 模型无从给出这一位", want)
}
}
}