Files
Cyrene_v2/internal/runtime/memory/preinsert_defer_test.go
T
AskaEth 21b906ffb0 feat(memory): D3 第五态 defer(入库审查)+ 提取留存规则闸 + 临时 TTL 配置面
主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都
往记忆里写」「.env 只做兜底用 …这些键优先从面板配置上取」「临时记忆到期删除」。
按 design/23 §18(D3 第五态 defer)、§19(到期处置)、design/03 §9.6
(tempTtlSec 默认 7 天 + 坏值口径)、§10(提取留存规则)落地。

- IngestDecision 闭集加第五态 defer:照常 active 落库 + 写短 expires_at 落
  临时层(不删行、不改正文,回滚 = 清空该列);IngestRuleVersion d3.2 → d3.3。
- 落点:ingestGatePlan 增 ExpiresAt + expiresAt()(走 expiryText/formatTime 的
  定宽 30 字节 UTC,与读侧时效闸同一套文本口径),由 finishIngest 在 defer 一态
  补写 expires_at(INSERT 与补写之间的短暂窗口如实登记,方向 = 宁可多留)。
  非 defer 三态一笔都不多写。
- T0 提取留存规则闸(extract_retention.go):design/03 §10.5 的 N1~N5 纯规则,
  接进 judgeIngest 的候选比对之前,命中即 defer + reason=t0_low_value,不进 T1;
  两道护栏(显式「记住」/ 持久信号)一票放行,fail-safe 方向 = 宁可多留。
- T1 闭集扩为 merge/modify/keep/defer(同一个 prompt、同一次调用,绝不新增
  第二次模型调用):只有关系判为真新时才问价值;defer 要求 target=0,否则判
  非法回落 create(reason=t1_reply_invalid)。
- 提取器提示词加固(design/03 §10.3 B):正向清单 + 反例清单 + 「允许返回 []」。
- 临时记忆 TTL:代码默认 tempTtlSec = 604800(7 天);面板
  $DATA_DIR/config/memory-tidy.json 的 tempTtlSec 优先(ApplyReviewTuning 逐键
  解析,坏值回落默认 + WARN,绝不写进 cfg)。该键在 .env 里没有出处且不新增
  (.env 已冻结)⇒ 实际生效链是「面板 > 代码默认」。启动播种同步该键(*int +
  omitempty,nil 省略、绝不写 null),保证播种文件与生效值逐字一致。
- preinsert_store.go 的 listReviewCandidatesSQL 原位注释:这条内部召回 SQL
  刻意不过时效闸的裁定与理由(design/03 §11.2 第 3 条)。
- 测试:defer 落库带短 expires_at(闸 + T1 两条路径)、写入口径与时效闸同源、
  TTL 坏值与回落、规则版本 d3.3、整理候选幂等键不含 IngestRuleVersion、
  提取规则正例 11 条 / 反例 11 条。

验收:go build ./... + go vet ./... + go test ./... 全绿;windows/darwin 交叉
编译通过(GOOS=darwin GOARCH=arm64 / GOOS=windows GOARCH=amd64)。
2026-10-13 20:22:48 +08:00

388 lines
16 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 (
"bytes"
"context"
"log/slog"
"os"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是**第五态 `defer`**(design/23 §18)与**临时 TTL 配置面**
// (design/03 §9.6)的验收用例集。它逐条覆盖任务书的验收点:
//
// 1. defer 落库带**短** `expires_at`(闸路径 + 异步 T1 路径);
// 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本**
// (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故);
// 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN;
// 4. `IngestRuleVersion` 升到 `d3.3`(改判定规则必升版,否则新旧留痕无法区分);
// 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。
// readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上:
// 读侧只拿它做 SQL 判据,不回表 SELECT)。
func readIngestExpiry(t *testing.T, store *Store, id string) string {
t.Helper()
var exp string
if err := store.db.QueryRowContext(context.Background(),
`SELECT expires_at FROM memory_items WHERE id = ?`, id).Scan(&exp); err != nil {
t.Fatalf("read expires_at %s: %v", id, err)
}
return exp
}
// personaListHas 报告一条记忆是否出现在 ListByPersona(**过时效闸**)的结果里。
func personaListHas(t *testing.T, store *Store, personaID, id string) bool {
t.Helper()
items, err := store.ListByPersona(context.Background(), personaID)
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
for _, it := range items {
if it.ID == id {
return true
}
}
return false
}
// newRealClockReviewer 构建一个用**真实墙钟**的审查服务(闸开着)。读侧时效闸比
// 的是真实当下,因此「这条临时记忆现在还看得见吗」这类断言必须用真实时钟,否则
// 固定夹具时刻(2026-10-01)算出来的有效期早就过去了。
func newRealClockReviewer(t *testing.T, store *Store, ttl time.Duration) *PreInsertReviewer {
t.Helper()
r, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second,
Threshold: 0.80, TempTTL: ttl,
}, ReviewDeps{
Store: store,
Now: func() time.Time { return time.Now() },
Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
return r
}
// deferFixtureItem 是一条**留存规则闸必然判 defer** 的条目(短寒暄,同分区、
// owner 来源)。
func deferFixtureItem(content string, at time.Time) MemoryItem {
return gateFixtureItem(content, at)
}
// ttlPanelPtr 造「面板里显式写了 tempTtlSec」的指针(nil 表示键没写)。
func ttlPanelPtr(v int) *int { return &v }
// TestIngestGateDeferWritesShortExpiresAt 是任务书验收点①的**闸路径**:
// 低价值条目照常 active 落库(一个字不丢),`expires_at` = 判定时刻 + 临时 TTL,
// 留痕是 `defer` / `t0_low_value` / 规则版本 `d3.3`。
func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) {
t.Parallel()
store := newTestStore(t)
ttl := time.Hour
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
want := expiryText(base.Add(ttl))
if exp := readIngestExpiry(t, store, got.ID); exp != want {
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 临时 TTL)", exp, want)
}
if exp := readIngestExpiry(t, store, got.ID); len(exp) != storedTimeLen {
t.Fatalf("expires_at 宽度 = %d 字节, want %d(读侧是纯文本比较,宽度必须同源)",
len(exp), storedTimeLen)
}
row := readReviewRow(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)
}
if rev.RuleVer != "d3.3" {
t.Fatalf("闸留痕规则版本 = %q, want d3.3", rev.RuleVer)
}
if s := r.Stats(); s.GateDeferred != 1 {
t.Fatalf("闸计数 = %+v, want gate_deferred=1", s)
}
}
// TestIngestGateDeferIsNotEnqueuedForAsyncReview 钉住「defer 已是终局判断」:
// 它**不投**异步审查队列 —— 再送语义审查只会把这条临时记忆并进一条长期记忆、
// 把临时性抹掉(design/23 §18.1 的落点)。
func TestIngestGateDeferIsNotEnqueuedForAsyncReview(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{TempTTL: time.Hour}, nil)
store.AttachIngestReviewer(r)
seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
if n := len(r.queue); n != 0 {
t.Fatalf("defer 条目被投进了异步队列(队列长度 %d)—— defer 是终局判断,不该再送审", n)
}
// 对偶:正常条目仍照旧投队列(证明上面那条不是因为队列压根不工作)。
seedTidyItem(t, store, deferFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
if n := len(r.queue); n != 1 {
t.Fatalf("正常条目未被投进异步队列(队列长度 %d, want 1)", n)
}
}
// TestT1DeferWritesExpiresAtOnKeptNewRow 是验收点①的**异步 T1 路径**:T1 在
// **同一次调用**里回 `defer` ⇒ 新行保留、正文不动、既有行不动,只写短 TTL。
func TestT1DeferWritesExpiresAtOnKeptNewRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
existing, fresh := seedT1GrayPair(t, store)
ttl := 2 * time.Hour
judge := &t1JudgeStub{reply: `{"action":"defer","target":0,"reason":"只是一次性的信息"}`}
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: ttl}, judge.judge, nil)
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestDefer || rev.Reason != t1ReasonDefer {
t.Fatalf("verdict = %+v, want defer + %q", rev, t1ReasonDefer)
}
if judge.count() != 1 {
t.Fatalf("模型调用 = %d 次, want 恰为 1(第五态与关系判定同一次调用)", judge.count())
}
want := expiryText(tidyTestTime(t).Add(ttl))
if exp := readIngestExpiry(t, store, fresh.ID); exp != want {
t.Fatalf("expires_at = %q, want %q", exp, want)
}
row := readReviewRow(t, store, fresh.ID)
if row.status != tidyStatusActive || row.content != fixtureT1CodeViaChannel {
t.Fatalf("新行 = %+v, want 仍在册且承载自己的原文", row)
}
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
t.Fatalf("既有行被改了:%+v(defer 只该碰新行的 expires_at 一列)", got)
}
}
// TestT1DeferWithTargetIsReplyInvalid 钉住 §18.2 的不变式「defer ≡ 真新 ∧ 短期」:
// 一个**指向候选**的 defer 自相矛盾(既说和它像、又说这条独立)⇒ 判非法回复、
// 回落 `create`(**宁可多留**),绝不猜。
func TestT1DeferWithTargetIsReplyInvalid(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
_, fresh := seedT1GrayPair(t, store)
judge := &t1JudgeStub{reply: `{"action":"defer","target":1,"reason":"自相矛盾"}`}
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: time.Hour}, judge.judge, nil)
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate || rev.Reason != t1ReasonReplyInvalid {
t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonReplyInvalid)
}
if exp := readIngestExpiry(t, store, fresh.ID); exp != "" {
t.Fatalf("非法 defer 却写了 expires_at = %q(回落路径必须零改写)", exp)
}
}
// TestDeferExpiryHiddenByReadGateAndRollbackRestores 把第五态接到**读侧时效闸**
// (design/03 §9.6 第 ① 步隐藏)与**回滚**(§18.4:清空 expires_at 即升回长期)上:
// 未到期可见 ⇒ 置成过去即隐藏 ⇒ 清空又可见,且 `Get`(刻意不过闸)始终读得到。
func TestDeferExpiryHiddenByReadGateAndRollbackRestores(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
r := newRealClockReviewer(t, store, time.Hour)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", time.Now().UTC()))
if !personaListHas(t, store, got.PersonaID, got.ID) {
t.Fatal("刚落的临时记忆应当在有效期内可见(TTL = 1h)")
}
if err := store.setIngestExpiry(ctx, got.ID, expiryText(time.Now().UTC().Add(-time.Minute))); err != nil {
t.Fatalf("setIngestExpiry: %v", err)
}
if personaListHas(t, store, got.PersonaID, got.ID) {
t.Fatal("已到期的临时记忆不该出现在读路径上(时效闸失效)")
}
if _, err := store.Get(ctx, got.ID); err != nil {
t.Fatalf("Get 刻意不过时效闸(撤销/清算要读得到过期行),却报错:%v", err)
}
// 回滚(design/23 §18.4):清空 expires_at ⇒ 升回长期,立即可见。
if err := store.setIngestExpiry(ctx, got.ID, ""); err != nil {
t.Fatalf("setIngestExpiry(清空): %v", err)
}
if !personaListHas(t, store, got.PersonaID, got.ID) {
t.Fatal("清空 expires_at 后应当升回长期(回滚是无损的)")
}
if row := readReviewRow(t, store, got.ID); row.content != "谢谢啦" || row.status != tidyStatusActive {
t.Fatalf("回滚改了正文/状态:%+v", row)
}
}
// TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate 是写入/读取**同一套规范文本**
// 的钉子(塔在派工里点名要求补的那条)。
//
// 读侧闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite 时间函数),
// 所以写侧的布局一旦分叉,闸会**静默判错**:本仓历史事故里,`' '`(0x20) 分隔的
// `datetime('now')` 与 `'T'`(0x54) 分隔的列值比较,会让**任何当日行都「大于当下」**。
func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) {
t.Parallel()
at := tidyTestTime(t).UTC()
// 写入侧:defer 计划给出的文本必须就是 expiryText/formatTime 的产出。
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); got != formatTime(at) {
t.Fatalf("plan.expiresAt() = %q, want formatTime 的产出 %q", got, formatTime(at))
}
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); len(got) != storedTimeLen {
t.Fatalf("plan.expiresAt() 宽度 = %d, want %d", len(got), storedTimeLen)
}
// 零值 = 不设过期(长期/日常记忆)⇒ 空串,恒可见。
if got := (ingestGatePlan{}).expiresAt(); got != "" {
t.Fatalf("零值计划的 expiresAt() = %q, want 空串", got)
}
// 反证:同一瞬间换一种布局,文本序就与时间序脱钩(读侧判据随之失效)。
sqliteLayout := at.Format("2006-01-02 15:04:05")
if formatTime(at) == sqliteLayout {
t.Fatal("两种布局不该相同 —— 那这条反证就没有意义了")
}
if !(formatTime(at) > sqliteLayout) {
t.Fatalf("定宽布局应恒大于空格分隔布局('T' > ' '),实得 %q vs %q", formatTime(at), sqliteLayout)
}
if sqliteLayout[0:4] != formatTime(at)[0:4] {
t.Fatal("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间")
}
}
// TestIngestRuleVersionIsD33 钉住任务书验收点④:改判定规则必升版
// (design/23 §18.4),否则新旧留痕无法区分。
func TestIngestRuleVersionIsD33(t *testing.T) {
t.Parallel()
if IngestRuleVersion != "d3.3" {
t.Fatalf("IngestRuleVersion = %q, want d3.3(第五态改变了判定闭集)", IngestRuleVersion)
}
}
// TestApplyReviewTuningTempTTL 钉住验收点③:TTL 的三档优先级与**坏值绝不放行**。
//
// 三档优先级在 TTL 这一键上实际是「**面板 > 代码默认**」:`.env` 已冻结
// (design/13 §2.1),本键在那里**从来就没有出处** —— 中间那一档在结构上是空的,
// 这不是漏配(design/03 §9.6 写的就是「面板 > 代码默认」)。
func TestApplyReviewTuningTempTTL(t *testing.T) {
t.Parallel()
const fallbackSec = 604800 // 7 天(tempTtlSec)
cases := []struct {
name string
panel *int
wantTTL time.Duration
wantWarn bool
}{
{"键没写 ⇒ 代码默认", nil, 7 * 24 * time.Hour, false},
{"面板写正数 ⇒ 用面板值", ttlPanelPtr(3600), time.Hour, false},
{"面板写 0 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(0), 7 * 24 * time.Hour, true},
{"面板写负数 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(-3600), 7 * 24 * time.Hour, true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, nil))
got := normalizeReviewConfig(ApplyReviewTuning(
ReviewConfig{}, runtimeconfig.MemoryTidyTuning{TempTtlSec: tc.panel}, log))
if got.TempTTL != tc.wantTTL {
t.Fatalf("TempTTL = %v, want %v", got.TempTTL, tc.wantTTL)
}
hasWarn := strings.Contains(buf.String(), "临时记忆 TTL 不可用")
if hasWarn != tc.wantWarn {
t.Fatalf("WARN 出现 = %t, want %t(日志:%s)", hasWarn, tc.wantWarn, buf.String())
}
if got.TempTTL <= 0 {
t.Fatal("归一化后仍是非正 TTL —— 那会让每条 defer 记忆落库即过期")
}
})
}
// 代码默认值本身就是 design/03 §9.6 点名的 604800 秒。
if normalizeReviewConfig(ReviewConfig{}).TempTTL != time.Duration(fallbackSec)*time.Second {
t.Fatalf("代码默认 TTL = %v, want %ds", normalizeReviewConfig(ReviewConfig{}).TempTTL, fallbackSec)
}
if tempTtlSec != fallbackSec {
t.Fatalf("tempTtlSec = %d, want %d", tempTtlSec, fallbackSec)
}
}
// TestNewPreInsertReviewerNormalizesBadTempTTL 钉住「坏值不靠调用方自觉」:
// 任何绕过解析器、直接把 ≤0 塞进 ReviewConfig 的路径,也在构造期被归一化掉
// (与 T1 限流两项同一教训,design/12 §N C.5 G3)。
func TestNewPreInsertReviewerNormalizesBadTempTTL(t *testing.T) {
t.Parallel()
store := newTestStore(t)
for _, ttl := range []time.Duration{0, -time.Hour} {
r, err := NewPreInsertReviewer(ReviewConfig{Enabled: true, TempTTL: ttl}, ReviewDeps{
Store: store, Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
if got := r.cfg.TempTTL; got != 7*24*time.Hour {
t.Fatalf("ReviewConfig.TempTTL = %v 未回落默认,实得 %v", ttl, got)
}
}
}
// TestIngestRuleVersionIsNotInTheTidyIdempotencyKey 是**防漂移缝断言**,回答一条
// 跨机制的实体问题:`IngestRuleVersion` 由 d3.2 升到 d3.3 之后,**既有 pending 整理
// 候选会不会凭幂等键复制出重复行**?
//
// 答案:**不会**,因为这是两个独立的常量 ——
//
// IngestRuleVersion ("d3.3") 入库审查(本切片)→ 只进 memory_ingest_reviews.rule_version
// TidyRuleVersion ("t0.1") 整理(design/23 §17.6)→ **进整理候选的幂等键**
//
// 幂等键的算式是 `tidyIdempotencyKey`(tidy_candidate.go):hash(persona_id, scope,
// project_id, sorted(source_ids), action, **c.RuleVersion**),而候选的 RuleVersion 由
// 构造器写成 TidyRuleVersion。生产库实测(2026-10-13,只读):`memory_tidy_candidates`
// 的 64 条 pending 全部 `rule_version='t0.1'`,`memory_ingest_reviews` 里则是
// `d3.1` / `d3.2` —— 两张表**从不共享**这一个值。
//
// design/23 §17.6 记录过的那次「升版 ⇒ 既有候选凭新键复制一份重复行」是
// **TidyRuleVersion** 的语义(那也是它当时刻意不升的原因),不是这一个。
// 若将来有人把 IngestRuleVersion 接进那个键(或把两个常量合并),本用例会红。
func TestIngestRuleVersionIsNotInTheTidyIdempotencyKey(t *testing.T) {
t.Parallel()
if IngestRuleVersion == TidyRuleVersion {
t.Fatalf("两个版本号不该相等(都是 %q):它们分属入库审查与整理两条机制",
IngestRuleVersion)
}
src, err := os.ReadFile("tidy_candidate.go")
if err != nil {
t.Fatalf("读 tidy_candidate.go: %v", err)
}
text := string(src)
if !strings.Contains(text, "string(c.Action), c.RuleVersion") {
t.Fatal("整理候选幂等键的算式变了 —— 请复核 IngestRuleVersion 是否意外进了键")
}
if strings.Contains(text, "IngestRuleVersion") {
t.Fatal("tidy_candidate.go 里出现了 IngestRuleVersion:两个版本号开始互相污染")
}
if strings.Count(text, "TidyRuleVersion") < 2 {
t.Fatal("整理候选的 RuleVersion 不再由 TidyRuleVersion 赋值 —— 幂等键的口径变了")
}
}