Files
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

399 lines
17 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 (
"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.5`,M37 合入时是 `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 != IngestRuleVersion {
t.Fatalf("闸留痕规则版本 = %q, want %q(留痕版本必须与常量同源,不写死)", rev.RuleVer, IngestRuleVersion)
}
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("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间")
}
}
// TestIngestRuleVersionIsD35 钉住任务书验收点④:改判定规则必升版
// (design/23 §18.4),否则新旧留痕无法区分。
//
// d3.4(切片 M37)把「判 defer」与「判 retention_tier=temp」合并成同一个判定,
// 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期,
// 且不再依赖同步闸是否开启),因此按规矩升版。
//
// d3.5(切片 M45)**记录 `temporal` 信号,但不参与判定**:提取器产出侧多标了一维
// 时间性位(迁移 0027),随同一笔 INSERT 落库;`resolveRetention` 不读它,因此
// 判定的结论集与 d3.4 逐字相同。仍升版是因为**留痕面变了**(库里多了「模型当时标了
// 什么」这一维),将来接线时要能区分「接线前/后」的结论 —— 理由见 preinsert.go
// 的版本史。
func TestIngestRuleVersionIsD35(t *testing.T) {
t.Parallel()
if IngestRuleVersion != "d3.5" {
t.Fatalf("IngestRuleVersion = %q, want d3.5(记录 temporal 信号,但不参与判定)", 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 赋值 —— 幂等键的口径变了")
}
}