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.4`(改判定规则必升版,否则新旧留痕无法区分); // 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.4`。 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.4" { t.Fatalf("闸留痕规则版本 = %q, want d3.4", 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.setIngestRetention(ctx, got.ID, RetentionTemp, expiryText(time.Now().UTC().Add(-time.Minute))); err != nil { t.Fatalf("setIngestRetention: %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.setIngestRetention(ctx, got.ID, RetentionLong, ""); err != nil { t.Fatalf("setIngestRetention(回滚): %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("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间") } } // TestIngestRuleVersionIsD34 钉住任务书验收点④:改判定规则必升版 // (design/23 §18.4),否则新旧留痕无法区分。 // // d3.4(切片 M37)把「判 defer」与「判 retention_tier=temp」合并成同一个判定, // 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期, // 且不再依赖同步闸是否开启),因此按规矩升版。 func TestIngestRuleVersionIsD34(t *testing.T) { t.Parallel() if IngestRuleVersion != "d3.4" { t.Fatalf("IngestRuleVersion = %q, want d3.4(档位与 defer 合并为同一判定)", 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(现为 d3.4)之后,**既有 pending 整理 // 候选会不会凭幂等键复制出重复行**? // // 答案:**不会**,因为这是两个独立的常量 —— // // IngestRuleVersion ("d3.4") 入库审查(本切片)→ 只进 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 赋值 —— 幂等键的口径变了") } }