Files
Cyrene_v2/internal/runtime/memory/timegate_test.go
T
AskaEth 9a3b3cff8b feat(memory): 读侧时效闸(四条列表读路径)+ Add 落 expires_at 列
主人裁定「临时记忆到期删除 / 任何调用记忆的地方都要正常调用到」的读侧
第 ① 步(隐藏):design/03 §9.6 / §11.2 的时效闸下沉到共享底层,不写在
任何调用点。

- timegate.go(新):timeGateClause = (expires_at = '' OR expires_at > ?)
  AND (valid_to = '' OR valid_to > ?);timeGateNow() 与列值同源(同一个
  formatTime 的定宽 30 字节 UTC 文本),expiryText() 负责写入侧编码。
- Store.ListByPersona / List / ListScope / ListProject 四条列表读路径
  的 WHERE 在既有 status 闸之后拼上时效闸;Retriever.visibleCandidates
  组合前两条,检索入口因此自动继承同一判据(无第二处判据)。
- Store.Get 与整理/审查的内部召回 SQL 刻意不过闸,理由写在各自注释里
  (listTidyPartitionItemsSQL 原位注释在本切片;listReviewCandidatesSQL
  的原位注释按塔裁定改派 M2,timegate.go 头注登记了这条拆分)。
- Store.Add 的 INSERT 补上 expires_at 列(MemoryItem.ExpiresAt):Add 是
  全仓唯一 INSERT 路径,时效列的真生产者只能在这里,否则闸退化成空壳。
- timegate_test.go(新):过期/失效行在四条路径与 visibleCandidates 上
  都不可见、空值与未来值可见、Get 仍能读过期行、Add 真的落库该列;
  外加时间口径同源钉(文本序==时间序 + 跨格式 'T' vs ' ' 陷阱实证)。

验收:go build ./internal/runtime/... 通过;go vet 通过;go test
./internal/runtime/memory/... 全绿;internal 全包 62 个 ok / 0 FAIL;
GOOS=darwin GOARCH=arm64 交叉构建通过。
2026-10-13 19:54:19 +08:00

358 lines
14 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"
"sort"
"strings"
"testing"
"time"
)
// 本文件是**读侧时效闸**的回归(design/03 §9.6 / §11.2)。
//
// 判据:status='active' ∧ (expires_at = '' ∨ expires_at > now) ∧ (valid_to = '' ∨ valid_to > now),
// 它必须同时出现在**四条列表读路径**上(Store.ListByPersona / ListScope / ListProject / List),
// 因为记忆读入口有 8 个而底层只有这四个;Retriever.visibleCandidates 组合调用其中两条,
// 因此检索入口自动继承同一判据(本条亦有测试)。任何一条漏闸 = 过期记忆照样进上下文、
// 照样出现在 admin 面板(design §11.2 的死角分析)。
// setItemExpiryForTest 直接改一行的时效列,模拟整理 / D3 第五态落下的时效裁定,
// 或「事实已失效」的 valid_to 标记。走 SQL 而不是走写入路径,是为了让本条只验证
// **读侧**:写入侧(Add 真的把 ExpiresAt 落成 expires_at 列)由本文件末条测试单独钉。
func setItemExpiryForTest(t *testing.T, s *Store, id, expiresAt, validTo string) {
t.Helper()
if _, err := s.db.ExecContext(context.Background(),
`UPDATE memory_items SET expires_at = ?, valid_to = ? WHERE id = ?`,
expiresAt, validTo, id); err != nil {
t.Fatalf("set expiry (expires_at=%q valid_to=%q) on %s: %v", expiresAt, validTo, id, err)
}
}
// assertReadSet 断言读路径返回的条目集合**恰好**是 want(按 id 比对),
// 且 hidden 里的条目一条都没漏进来。按 id 而不是按正文断言:正文可能重复,
// id 才是行的身份。
func assertReadSet(t *testing.T, name string, got, want, hidden []MemoryItem) {
t.Helper()
gotIDs := idsOfItems(got)
wantIDs := idsOfItems(want)
if !sameIDs(gotIDs, wantIDs) {
t.Fatalf("%s = %v %v, want %v", name, gotIDs, contentsOf(got), wantIDs)
}
for _, h := range hidden {
for _, id := range gotIDs {
if id == h.ID {
t.Errorf("%s 返回了已过期 / 已失效行 %q(时效闸失效)", name, h.Content)
}
}
}
}
// TestStore_读路径_过滤已过期行 逐条走四条列表读路径与检索路径,证明「临时记忆到期」
// (expires_at ≤ now)与「事实已失效」(valid_to ≤ now)两种行在每一处都不可见,
// 而空 expires_at(长期 / 日常记忆的默认值)与未来的 expires_at / valid_to 照常可见。
func TestStore_读路径_过滤已过期行(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
now := time.Now().UTC()
// 可见组:长期(expires_at 空)、临时未到期(Add 直接给 ExpiresAt)、未失效(valid_to 未来)。
longTerm := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "长期-角色", Scope: ScopePersona, Kind: KindRelationship,
})
tempAlive := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-未到期-角色", Scope: ScopePersona, Kind: KindRelationship,
ExpiresAt: now.Add(time.Hour),
})
tempAliveUser := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-未到期-用户", Scope: ScopeUser, Kind: KindWorldEvent,
ExpiresAt: now.Add(time.Hour),
})
validToFuture := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "未失效-项目", Scope: ScopeProject,
Kind: KindWorldEvent, ProjectID: "proj.1",
})
// 不可见组:临时已到期(写入侧落库的过去值)、valid_to 已过、项目域的临时已到期。
tempExpired := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
ExpiresAt: now.Add(-time.Hour),
})
validToPast := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
})
tempExpiredProject := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-已过期-项目", Scope: ScopeProject,
Kind: KindWorldEvent, ProjectID: "proj.1",
})
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
setItemExpiryForTest(t, s, tempExpiredProject.ID, formatTime(now.Add(-time.Hour)), "")
setItemExpiryForTest(t, s, validToFuture.ID, "", formatTime(now.Add(time.Hour)))
ownVisible := []MemoryItem{longTerm, tempAlive, tempAliveUser, validToFuture}
hidden := []MemoryItem{tempExpired, validToPast, tempExpiredProject}
for _, tc := range []struct {
name string
read func() ([]MemoryItem, error)
want []MemoryItem
}{
{
name: "ListByPersona",
read: func() ([]MemoryItem, error) { return s.ListByPersona(ctx, "persona.a") },
want: ownVisible,
},
{
name: "List",
read: func() ([]MemoryItem, error) { return s.List(ctx) },
want: ownVisible,
},
{
name: "ListScope/user",
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeUser, "persona.a") },
want: []MemoryItem{tempAliveUser},
},
{
name: "ListScope/project",
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeProject, "persona.a") },
want: []MemoryItem{validToFuture},
},
{
name: "ListProject",
read: func() ([]MemoryItem, error) { return s.ListProject(ctx, "proj.1") },
want: []MemoryItem{validToFuture},
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.read()
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
assertReadSet(t, tc.name, got, tc.want, hidden)
})
}
// 检索路径:visibleCandidates 是候选集本身(Retrieve/Inject 与所有复用 Retriever 的
// 入口都只从它取候选),闸加在它组合调用的 ListByPersona + ListScope 上,
// 因此这里必须一条过期行都没有。
r := NewRetriever(s)
cands, err := r.visibleCandidates(ctx, "persona.a")
if err != nil {
t.Fatalf("visibleCandidates: %v", err)
}
assertReadSet(t, "visibleCandidates", cands, ownVisible, hidden)
results, err := r.Retrieve(ctx, "persona.a", "", 20)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) != len(ownVisible) {
t.Fatalf("Retrieve = %d 条 (%v), want %d", len(results), idsOf(results), len(ownVisible))
}
found := make([]MemoryItem, 0, len(results))
for _, res := range results {
found = append(found, res.Item)
}
assertReadSet(t, "Retrieve", found, ownVisible, hidden)
injected, err := r.Inject(ctx, "persona.a", "", 20)
if err != nil {
t.Fatalf("Inject: %v", err)
}
for _, h := range hidden {
if strings.Contains(injected, h.Content) {
t.Errorf("Inject 文本里出现了已过期 / 已失效行 %q(这就是「任何地方都调得到」的反面)", h.Content)
}
}
for _, v := range ownVisible {
if !strings.Contains(injected, v.Content) {
t.Errorf("Inject 文本缺少在有效期内的 %q", v.Content)
}
}
}
// TestStore_Get_不过时效闸 是反向护栏:按 id 的 Get 刻意**不过** status 闸与时效闸。
// 撤销软合并要读得到被吸收的行(design/23 §8 的「无损可逆」),清算与人工核查要读得到
// 已过期 / 已失效的行(design/03 §11.2 第 3 条)。若有人「顺手」给 Get 也加闸,本条会红。
func TestStore_Get_不过时效闸(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
now := time.Now().UTC()
tempExpired := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
ExpiresAt: now.Add(-time.Hour),
})
validToPast := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
})
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
for _, want := range []MemoryItem{tempExpired, validToPast} {
got, err := s.Get(ctx, want.ID)
if err != nil {
t.Errorf("Get(%s) err = %v, want readable(撤销 / 审计的抓手)", want.ID, err)
continue
}
if got.Content != want.Content {
t.Errorf("Get(%s).Content = %q, want %q", want.ID, got.Content, want.Content)
}
}
}
// TestStore_Add_落库expires_at列 钉住写入侧:Add 是全仓唯一 INSERT 路径,
// 时效列的**真生产者**只能是它 —— 若 Add 不写这一列,读侧时效闸就退化成恒真空壳
// (expires_at 永远为空 ⇒ 过期行永远可见)。零值必须落空串(= 不设过期),
// 非零值必须走与读侧闸同一个 formatTime。
func TestStore_Add_落库expires_at列(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
expiry := time.Date(2026, 10, 20, 3, 20, 5, 830978900, time.UTC)
withExpiry := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-带时效", Scope: ScopePersona,
Kind: KindRelationship, ExpiresAt: expiry,
})
withoutExpiry := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "长期-无时效", Scope: ScopePersona, Kind: KindRelationship,
})
for _, tc := range []struct {
name string
id string
want string
}{
{"非零 ExpiresAt", withExpiry.ID, formatTime(expiry)},
{"零值 ExpiresAt", withoutExpiry.ID, ""},
} {
var raw string
if err := s.db.QueryRowContext(ctx,
`SELECT expires_at FROM memory_items WHERE id = ?`, tc.id).Scan(&raw); err != nil {
t.Fatalf("%s: read raw expires_at: %v", tc.name, err)
}
if raw != tc.want {
t.Errorf("%s: expires_at = %q, want %q", tc.name, raw, tc.want)
}
}
}
// TestTimeGate_比较口径与写入格式同源 是本仓历史事故的回归(DevDocs/开发习惯与约定.md §7):
// 「SQLite 时间比较用错格式(带 T/Z 的串比 datetime('now'))会把『今天全部』当成『最近一小时』」。
//
// 闸的处理方式写死为「两边都由 formatTime 产出同形文本,文本序即时间序」,本条把这件事钉三下:
// 1. 闸的比较操作数与列值同源(同一个 formatTime,定宽 30 字节 UTC,可被 parseTime 解回);
// 2. 同源文本的**文本序 == 时间序**(这是整个闸成立的前提);
// 3. 跨格式比较会**系统性判错**(用 SQLite 字面量实证 'T' > ' ' 的陷阱),
// 所以任何把闸改成 datetime('now') 之类的第二套口径的改动,都会先在这条上露馅。
func TestTimeGate_比较口径与写入格式同源(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
gateNow := timeGateNow()
if len(gateNow) != storedTimeLen {
t.Fatalf("timeGateNow() = %q (%d 字节), want %d 字节(必须与列值同宽)",
gateNow, len(gateNow), storedTimeLen)
}
back, err := parseTime(gateNow)
if err != nil {
t.Fatalf("timeGateNow() 不能被 parseTime 解回: %v", err)
}
if reformatted := formatTime(back); reformatted != gateNow {
t.Fatalf("timeGateNow() 与列值格式不同源:再格式化得 %q,不等于 %q", reformatted, gateNow)
}
// 文本序 == 时间序(两个都由 formatTime 产出的值)。
earlier := time.Date(2026, 10, 13, 11, 0, 0, 0, time.UTC)
later := earlier.Add(time.Nanosecond)
if !(formatTime(earlier) < formatTime(later)) {
t.Fatalf("formatTime(%v) = %q 不早于 formatTime(%v) = %q —— 文本序已不等于时间序",
earlier, formatTime(earlier), later, formatTime(later))
}
// 同源比较:11:00 的列值小于 12:00 的当下 ⇒ 判为「已过期」。
var sameSource bool
if err := s.db.QueryRowContext(ctx,
`SELECT '2026-10-13T11:00:00.000000000Z' < ?`, formatTime(earlier.Add(time.Hour))).
Scan(&sameSource); err != nil {
t.Fatalf("same-source comparison: %v", err)
}
if !sameSource {
t.Fatal("同源文本比较失效:11:00 的列值未被判为早于 12:00 的当下")
}
// 跨格式比较(历史事故的机制):SQLite 的 datetime('now') 形如
// "YYYY-MM-DD HH:MM:SS",第 11 个字节是空格(0x20),而列值第 11 个字节是 'T'(0x54)。
// 于是**同一 UTC 日**内,任何列值都「大于当下」—— 早一小时的过期行反而被判为未过期。
var crossFormat bool
if err := s.db.QueryRowContext(ctx,
`SELECT '2026-10-13T11:00:00.000000000Z' > datetime('2026-10-13 12:00:00')`).
Scan(&crossFormat); err != nil {
t.Fatalf("cross-format comparison: %v", err)
}
if !crossFormat {
t.Fatalf("陷阱实证失效:跨格式比较不再系统性判错('T' vs ' ')," +
"请重新核对 timegate.go 的口径与 DevDocs/开发习惯与约定.md §7")
}
}
// TestTimeGate_闸不接受第二套时间口径 是结构性护栏:闸的 WHERE 片段里只允许出现
// 参数化比较(?),不得出现 SQLite 的时间函数 —— 一旦出现 datetime(...)/julianday(...)
// 之类,就有第二套口径,写入格式或 SQLite 行为一变就再次静默分叉(历史事故正是如此)。
func TestTimeGate_闸不接受第二套时间口径(t *testing.T) {
t.Parallel()
for _, fn := range []string{"datetime(", "julianday(", "strftime(", "unixepoch(", "date("} {
if strings.Contains(timeGateClause, fn) {
t.Errorf("timeGateClause 含 SQLite 时间函数 %q:闸必须只做「同源定宽文本」的参数化比较(%s)",
fn, timeGateClause)
}
}
if got := strings.Count(timeGateClause, "?"); got != 2 {
t.Errorf("timeGateClause 的占位符 = %d, want 2(expires_at 与 valid_to 各一个)", got)
}
}
// TestExpiryText_零值落空串 钉住写入侧编码:零值 = 不设过期,必须落空串(列默认值),
// 而不是 formatTime(零值)(那会写成 "0001-01-01T00:00:00.000000000Z",反而把长期记忆
// 变成「早已过期」—— 长期记忆会整体从读面消失)。
func TestExpiryText_零值落空串(t *testing.T) {
t.Parallel()
if got := expiryText(time.Time{}); got != "" {
t.Errorf("expiryText(零值) = %q, want 空串(不设过期)", got)
}
at := time.Date(2026, 10, 20, 3, 20, 5, 0, time.UTC)
got := expiryText(at)
if want := formatTime(at); got != want {
t.Errorf("expiryText(%v) = %q, want %q", at, got, want)
}
if len(got) != storedTimeLen {
t.Errorf("expiryText(%v) 长度 = %d, want %d", at, len(got), storedTimeLen)
}
}
// idsOfItems 取条目 id,仅用于失败信息与集合比对。
func idsOfItems(items []MemoryItem) []string {
out := make([]string, 0, len(items))
for _, it := range items {
out = append(out, it.ID)
}
sort.Strings(out)
return out
}
// sameIDs 报告两个**已排序**的 id 切片是否逐项相等。
func sameIDs(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}