Files
AskaEth cc6904b1b4 feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增
retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤
一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。

结构(迁移 0026 + 自举前滚):
- memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型,
  历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过
  (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。
- NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。

判定(retention.go,全仓唯一入口):
- RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。
- resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源
  不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。
- tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔
  INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。

合并(defer ⇔ temp):
- judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此
  全仓唯一(源码级断言钉住)。
- 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。
- 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。
- IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。

读侧:
- 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。
- 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位
  过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。
  原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。
- store.go 的读路径拆到 store_list.go,守 500 行上限。

回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
2026-10-26 13:09:30 +08:00

448 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 (
"context"
"os"
"strings"
"testing"
"time"
)
// 本文件是「留多久 / 干什么用」轴(迁移 0026、retention.go)的验收用例集。
// 它逐条钉住任务书的验收点:
//
// 1. 闭集:temp / daily / work / long / knowledge 五个档位 + 空串「尚未分型」;
// 空串**不是**某一档,读侧能把两者分开(不过滤时原样返回,按空串过滤只取未分型);
// 2. **正交性**:`kind`(关于什么)与 `retention_tier`(留多久)是两列两根轴,
// relationship+temp / relationship+long / world_event+temp / world_event+knowledge
// 四种组合都能存能读,互不推导;
// 3. **不变量**:`tier=temp` ⇔ `expires_at` 非空且落在 TTL 窗口内;非 temp 恒为空串;
// 4. **一个判定**:`defer` 的判据就是「档位是 temp」,且档位与有效期同一笔 INSERT
// 落下(不依赖同步闸是否开启)。
// readRetentionColumns 直读一行的档位与有效期。档位在 Store.Get 的读侧面上,有效期
// **刻意只写不读**(store.go 的 ExpiresAt 注释),因此要验证落库形态只能直读。
func readRetentionColumns(t *testing.T, store *Store, id string) (RetentionTier, string) {
t.Helper()
var tier, expires string
if err := store.db.QueryRowContext(context.Background(),
`SELECT retention_tier, expires_at FROM memory_items WHERE id = ?`, id).
Scan(&tier, &expires); err != nil {
t.Fatalf("read retention columns %s: %v", id, err)
}
return RetentionTier(tier), expires
}
// seedHistoricalItem 造一条**0026 之前**的历史行:档位停在列默认值(空串 = 未分型)。
//
// 它只能直写 SQL 造出来 —— `Store.Add` 总会判出一个档位,这正是「未分型只属于
// 历史行与受限来源」这条口径的结构性证据(不是在测试里塞一个假值)。
func seedHistoricalItem(t *testing.T, store *Store, personaID, content string) string {
t.Helper()
ctx := context.Background()
id, err := newID()
if err != nil {
t.Fatalf("newID: %v", err)
}
now := formatTime(time.Now().UTC())
if _, err := store.db.ExecContext(ctx, `INSERT INTO memory_items
(id, persona_id, scope, kind, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, personaID, string(ScopePersona), string(KindRelationship), content, now, now); err != nil {
t.Fatalf("seed historical item: %v", err)
}
return id
}
// TestRetentionTierClosedSet 钉住闭集本身:五个业务档位 + 未分型都是合法列值,
// 而 `assigned()` 只对非空串为真(这是「未分型」的唯一判据)。
func TestRetentionTierClosedSet(t *testing.T) {
t.Parallel()
cases := []struct {
tier RetentionTier
valid bool
assigned bool
}{
{RetentionTierUnset, true, false},
{RetentionTemp, true, true},
{RetentionDaily, true, true},
{RetentionWork, true, true},
{RetentionLong, true, true},
{RetentionKnowledge, true, true},
{"ephemeral", false, true},
{"TEMP", false, true},
{"long ", false, true},
}
for _, tc := range cases {
if got := tc.tier.valid(); got != tc.valid {
t.Fatalf("RetentionTier(%q).valid() = %t, want %t", tc.tier, got, tc.valid)
}
if got := tc.tier.assigned(); got != tc.assigned {
t.Fatalf("RetentionTier(%q).assigned() = %t, want %t", tc.tier, got, tc.assigned)
}
}
// 闭集里的五个业务档位一个都不能少(少了哪一档,「两列」的语义就不完整)。
for _, want := range []RetentionTier{RetentionTemp, RetentionDaily, RetentionWork, RetentionLong, RetentionKnowledge} {
if !want.valid() || !want.assigned() {
t.Fatalf("档位 %q 不在闭集里", want)
}
}
}
// TestAddRejectsUnknownRetentionTier 钉住「闭集是硬约束」:非法档位在 Add 入口被拒,
// 不会带着脏值落库(否则读侧的过滤会把 `TEMP` 当成另一种 temp)。
func TestAddRejectsUnknownRetentionTier(t *testing.T) {
t.Parallel()
store := newTestStore(t)
item := baseItem()
item.RetentionTier = RetentionTier("ephemeral")
if _, err := store.Add(context.Background(), item); err == nil {
t.Fatal("非法档位竟被接受 —— 闭集没有生效")
}
}
// TestRetentionTierIsOrthogonalToKind 钉住任务书的正交性验收点:`kind`(关于什么)
// 与 `retention_tier`(留多久)是两根轴,四种组合都能存能读,且读回来逐字不变。
//
// 变异方向:若有人写出「relationship ⇒ long」「world_event ⇒ temp」这类推导,
// 这四条里立刻有两条读回来的档位与写入时不同。
func TestRetentionTierIsOrthogonalToKind(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
cases := []struct {
name string
kind Kind
tier RetentionTier
content string
}{
{"关系+临时", KindRelationship, RetentionTemp, "谢谢啦"},
{"关系+长期", KindRelationship, RetentionLong, "主人习惯被称作「叶酱」"},
{"世界事件+临时", KindWorldEvent, RetentionTemp, "早上好呀"},
{"世界事件+知识库", KindWorldEvent, RetentionKnowledge, "迁移号约定:接着上一个号递增"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
item := baseItem()
item.Kind = tc.kind
item.RetentionTier = tc.tier
item.Content = tc.content
got, err := store.Add(ctx, item)
if err != nil {
t.Fatalf("Add: %v", err)
}
if got.RetentionTier != tc.tier {
t.Fatalf("Add 返回的档位 = %q, want %q", got.RetentionTier, tc.tier)
}
loaded, err := store.Get(ctx, got.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if loaded.Kind != tc.kind || loaded.RetentionTier != tc.tier {
t.Fatalf("读回 (kind=%q, tier=%q), want (%q, %q) —— 两列是两根独立的轴",
loaded.Kind, loaded.RetentionTier, tc.kind, tc.tier)
}
})
}
}
// TestTempTierImpliesExpiryWithinTTLWindow 钉住不变量的一半:**被判成 temp 的行,
// 有效期一定落在 TTL 窗口内**,且由同一笔 INSERT 写下(没有第二笔 UPDATE 的窗口)。
//
// 这里没有接审查服务 ⇒ 走「墙钟 + 代码默认 tempTtlSec」那一档换算;接了审查服务的
// 情况由 TestDeferDecisionIsTheTempTier 用固定时钟钉。
func TestTempTierImpliesExpiryWithinTTLWindow(t *testing.T) {
t.Parallel()
store := newTestStore(t)
ttl := time.Duration(tempTtlSec) * time.Second
before := time.Now().UTC()
got := seedTidyItem(t, store, gateFixtureItem("谢谢啦", time.Time{}))
after := time.Now().UTC()
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTemp {
t.Fatalf("短寒暄的档位 = %q, want %q(留存规则没接上)", tier, RetentionTemp)
}
at, err := parseTime(expires)
if err != nil {
t.Fatalf("parse expires_at %q: %v", expires, err)
}
if !at.After(before) || at.After(after.Add(ttl)) {
t.Fatalf("expires_at = %v 不在 TTL 窗口 (%v, %v] 内", at, before, after.Add(ttl))
}
}
// TestNonTempTierHasNoExpiry 钉住不变量的另一半:**非 temp 档的行 expires_at 恒为空串**
// (因此四条列表读路径恒可见、后台清扫结构性跳过它们)。
func TestNonTempTierHasNoExpiry(t *testing.T) {
t.Parallel()
store := newTestStore(t)
got := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, time.Time{}))
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionLong {
t.Fatalf("长期事实的档位 = %q, want %q(默认落点)", tier, RetentionLong)
}
if expires != "" {
t.Fatalf("非 temp 档的 expires_at = %q, want 空串", expires)
}
}
// TestExplicitTierIsNotOverriddenByRules 钉住 AGENTS §9 的取向:**调用方的显式档位
// 是元数据,词项规则不许推翻它**。一条「短寒暄」按规则会被判 temp,但调用方明确说
// 它是知识库条目 ⇒ 它是 knowledge 且没有有效期。
func TestExplicitTierIsNotOverriddenByRules(t *testing.T) {
t.Parallel()
store := newTestStore(t)
item := gateFixtureItem("谢谢啦", time.Time{})
item.RetentionTier = RetentionKnowledge
got := seedTidyItem(t, store, item)
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionKnowledge {
t.Fatalf("显式档位被规则推翻:tier = %q, want %q", tier, RetentionKnowledge)
}
if expires != "" {
t.Fatalf("knowledge 档却写下了 expires_at = %q", expires)
}
}
// TestRetentionInputContradictions 钉住两个自相矛盾的输入都不许静默通过:
//
// ① 非 temp 档 + 非零有效期 ⇒ 报错(一行标着长期却会到期消失);
// ② 未分型 + 非零有效期 ⇒ 判 temp(「会到期」就是临时记忆,这是同一个事实的补全)。
func TestRetentionInputContradictions(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
bad := gateFixtureItem(fixtureHabitShort, time.Time{})
bad.RetentionTier = RetentionLong
bad.ExpiresAt = time.Now().UTC().Add(time.Hour)
if _, err := store.Add(ctx, bad); err == nil {
t.Fatal("「长期档 + 有效期」竟被接受 —— 两列会当场分叉")
}
implied := gateFixtureItem(fixtureHabitShort, time.Time{})
implied.ExpiresAt = time.Now().UTC().Add(time.Hour)
got, err := store.Add(ctx, implied)
if err != nil {
t.Fatalf("Add(只给有效期): %v", err)
}
if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionTemp {
t.Fatalf("只给有效期时档位 = %q, want %q(有到期时刻就是临时记忆)", tier, RetentionTemp)
}
}
// TestRestrictedSourceStaysUntyped 钉住受限来源(群聊 / 陌生人)**不分型**:
// 判它 temp 会造出一条「读面隐藏、后台清扫又跳过(expire.go 的 nonOwnerSource)」
// 的行 —— 那是一条永远不会被删的幽灵行。
func TestRestrictedSourceStaysUntyped(t *testing.T) {
t.Parallel()
store := newTestStore(t)
item := gateFixtureItem("谢谢啦", time.Time{})
item.SourceConversation = FormatSourceConversation("sess-channel-42", "msg-9")
got := seedTidyItem(t, store, item)
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTierUnset {
t.Fatalf("受限来源的档位 = %q, want 未分型(空串)", tier)
}
if expires != "" {
t.Fatalf("受限来源被写了 expires_at = %q(会变成永不删除的隐藏行)", expires)
}
}
// tierFixtureSet 是档位过滤用例的四条夹具行(四条覆盖四种档位状态):
// long(长期事实,persona 作用域)、temp(临时,user 作用域)、knowledge
// (知识库,project 作用域)、以及一条**未分型的历史行**(只能直写 SQL 造)。
type tierFixtureSet struct {
longID, tempID, projectID, historicalID string
}
// seedTierFixtureSet 造出四条夹具行。
func seedTierFixtureSet(t *testing.T, store *Store) tierFixtureSet {
t.Helper()
set := tierFixtureSet{}
set.longID = seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, time.Time{})).ID
tempItem := gateFixtureItem("谢谢啦", time.Time{})
tempItem.Scope = ScopeUser
set.tempID = seedTidyItem(t, store, tempItem).ID
projectItem := gateFixtureItem("迁移号约定:接着上一个号递增", time.Time{})
projectItem.Scope = ScopeProject
projectItem.ProjectID = "proj-1"
projectItem.RetentionTier = RetentionKnowledge
set.projectID = seedTidyItem(t, store, projectItem).ID
set.historicalID = seedHistoricalItem(t, store, "persona.a", "一条 0026 之前就存在的记忆")
return set
}
// tierIDs 把条目列表折成 id 集合(断言用)。
func tierIDs(items []MemoryItem) map[string]bool {
out := make(map[string]bool, len(items))
for _, it := range items {
out[it.ID] = true
}
return out
}
// TestListTiersFilter 钉住读侧的**可选**过滤(跨角色的 List / ListTiers):
//
// - 不传档位 = 不过滤:**历史行(空串)照常返回**(既有调用方零改动);
// - 传档位 = 只返回这些档位(可多档并集);
// - 显式传空串 = 只返回**未分型**的行(回填分型的入口,必须与「不过滤」可区分)。
func TestListTiersFilter(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
set := seedTierFixtureSet(t, store)
// 不过滤:四类行都在(含未分型的历史行)。
all, err := store.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
got := tierIDs(all)
for _, id := range []string{set.longID, set.tempID, set.projectID, set.historicalID} {
if !got[id] {
t.Fatalf("不过滤时漏掉了 %s(缺省必须不过滤,历史行照常返回)", id)
}
}
// 只看 temp:只剩那条临时记忆。
temps, err := store.ListTiers(ctx, RetentionTemp)
if err != nil {
t.Fatalf("ListTiers(temp): %v", err)
}
if got := tierIDs(temps); len(got) != 1 || !got[set.tempID] {
t.Fatalf("ListTiers(temp) = %v, want 只有 %s", got, set.tempID)
}
// 只看未分型:只剩那条历史行(与「不过滤」必须不同)。
untyped, err := store.ListTiers(ctx, RetentionTierUnset)
if err != nil {
t.Fatalf("ListTiers(unset): %v", err)
}
if got := tierIDs(untyped); len(got) != 1 || !got[set.historicalID] {
t.Fatalf("ListTiers('') = %v, want 只有 %s(空串必须能与不过滤区分)", got, set.historicalID)
}
// 多档位并集。
two, err := store.ListTiers(ctx, RetentionTemp, RetentionKnowledge)
if err != nil {
t.Fatalf("ListTiers(temp, knowledge): %v", err)
}
if got := tierIDs(two); len(got) != 2 || !got[set.tempID] || !got[set.projectID] {
t.Fatalf("ListTiers(temp, knowledge) = %v, want {%s, %s}", got, set.tempID, set.projectID)
}
}
// TestListTiersFilterByScope 钉住同一口径在三条**带分区**的读路径上成立
// (ListByPersonaTiers / ListScopeTiers / ListProjectTiers),并钉住非法档位
// **必须报错**而不是静默退化成「不过滤」。
func TestListTiersFilterByScope(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
set := seedTierFixtureSet(t, store)
personaLong, err := store.ListByPersonaTiers(ctx, "persona.a", RetentionLong)
if err != nil {
t.Fatalf("ListByPersonaTiers: %v", err)
}
if got := tierIDs(personaLong); len(got) != 1 || !got[set.longID] {
t.Fatalf("ListByPersonaTiers(long) = %v, want 只有 %s", got, set.longID)
}
// 历史行也是 persona.a 的,但档位是空串 ⇒ 在 long 过滤里不该出现。
personaUntyped, err := store.ListByPersonaTiers(ctx, "persona.a", RetentionTierUnset)
if err != nil {
t.Fatalf("ListByPersonaTiers(unset): %v", err)
}
if got := tierIDs(personaUntyped); len(got) != 1 || !got[set.historicalID] {
t.Fatalf("ListByPersonaTiers('') = %v, want 只有 %s", got, set.historicalID)
}
userTemp, err := store.ListScopeTiers(ctx, ScopeUser, "persona.a", RetentionTemp)
if err != nil {
t.Fatalf("ListScopeTiers: %v", err)
}
if got := tierIDs(userTemp); len(got) != 1 || !got[set.tempID] {
t.Fatalf("ListScopeTiers(user, temp) = %v, want 只有 %s", got, set.tempID)
}
projectKnown, err := store.ListProjectTiers(ctx, "proj-1", RetentionKnowledge)
if err != nil {
t.Fatalf("ListProjectTiers: %v", err)
}
if got := tierIDs(projectKnown); len(got) != 1 || !got[set.projectID] {
t.Fatalf("ListProjectTiers(knowledge) = %v, want 只有 %s", got, set.projectID)
}
// 非法档位:报错,不许静默退化成「不过滤」。
calls := []struct {
name string
run func() error
}{
{"ListByPersonaTiers", func() error {
_, err := store.ListByPersonaTiers(ctx, "persona.a", "ephemeral")
return err
}},
{"ListTiers", func() error { _, err := store.ListTiers(ctx, "ephemeral"); return err }},
{"ListScopeTiers", func() error {
_, err := store.ListScopeTiers(ctx, ScopeUser, "persona.a", "ephemeral")
return err
}},
{"ListProjectTiers", func() error {
_, err := store.ListProjectTiers(ctx, "proj-1", "ephemeral")
return err
}},
}
for _, call := range calls {
if err := call.run(); err == nil {
t.Fatalf("%s 接受了非法档位(应当报错,而不是当「不过滤」)", call.name)
}
}
}
// TestRetentionJudgementHasSingleRuleCallSite 是**防回归的源码级断言**:留存规则
// (`judgeExtractionRetention`)在生产代码里只能有一个调用点 —— `resolveRetention`。
//
// 它钉的是本切片的核心承诺「**不许两套规则各判一遍**」:M27 体检的缺口(1082 次审查
// 只判出 1 条 defer、`expires_at` 全表为空)根因就是档位与 defer 各判一次、谁都不完整。
// 有人把规则调用再塞回 `judgeIngest` / 闸 / 异步审查时,本用例会红。
func TestRetentionJudgementHasSingleRuleCallSite(t *testing.T) {
t.Parallel()
files, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read package dir: %v", err)
}
var sites []string
for _, f := range files {
name := f.Name()
if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, line := range strings.Split(string(src), "\n") {
trimmed := strings.TrimSpace(line)
if !strings.Contains(trimmed, "judgeExtractionRetention(") {
continue
}
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "func ") {
continue
}
sites = append(sites, name+": "+trimmed)
}
}
if len(sites) != 1 {
t.Fatalf("judgeExtractionRetention 的调用点 = %d 处,want 恰为 1(resolveRetention):\n%s",
len(sites), strings.Join(sites, "\n"))
}
if !strings.HasPrefix(sites[0], "retention.go:") {
t.Fatalf("唯一调用点应在 retention.go 的 resolveRetention 里,实得 %s", sites[0])
}
}