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

181 lines
8.4 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"
"testing"
"time"
)
// 本文件用**固定夹具语料**(不是生产库)钉住「判 temp 的判定本身」与
// 「`defer` 与 `retention_tier=temp` 是同一个判定」这两条。
//
// 语料分两半,方向是刻意相反的(任务书要求的「正例判得到 / 反例不会被判」):
//
// 正例 = design/03 §10.5 的 N1~N7 形态(寒暄、一次性指令、时效查询、指示性时点、
// 进行中状态)⇒ 必须判 temp;
// 反例 = 长期事实 / 稳定偏好 + 约束 / 显式「记住」/ 习惯 ⇒ 必须**不**判 temp
// (判错的后果不可逆:7 天后从所有读路径消失,随后被物理删除)。
//
// 变异方向:把 resolveRetention 的 temp 分支删掉、或把规则改宽到「反例也命中」,
// 这两组断言会分别变红 —— 它们不是「跑通就行」的烟雾测试。
// retentionFixture 是一条夹具:内容 + 期望落点 + 为什么。
type retentionFixture struct {
content string
want RetentionTier
why string
// rule 非空时一并断言命中的反例规则码(只有正例才需要,反例恒为 retentionNone)。
rule retentionRule
}
// retentionFixtures 是正反例语料。正例的每一条都能在 design/03 §10.5(N1~N7)找到
// 出处;反例的每一条都是「判成临时就会丢信息」的真实形态。
func retentionFixtures() []retentionFixture {
return []retentionFixture{
// ── 正例(必须判 temp)────────────────────────────────────────────
{"谢谢啦", RetentionTemp, "N1 寒暄/致谢", retentionChatter},
{"早上好呀", RetentionTemp, "N1 寒暄", retentionChatter},
{"帮我订个 7 点的闹钟", RetentionTemp, "N3 一次性祈使(含数值也被祈使前缀先吃掉)", retentionNone},
{"现在几点了", RetentionTemp, "N4 时效性查询", retentionTimelyData},
{"用户昨天发来一卷验收纸条", RetentionTemp, "N6 指示性时点在句首", retentionTimepoint},
{"主人有一件正在慢慢考虑、还未决定的事", RetentionTemp, "N7 进行中状态(生产语料原样取回)", retentionInProgress},
// ── 反例(必须判 long)──────────────────────────────────────────
{fixtureHabitShort, RetentionLong, "习惯陈述(熬夜)是长期事实", retentionNone},
{"记住:主人对花生过敏,点餐要避开", RetentionLong, "显式「记住」是指令,闸不许改判", retentionNone},
{"主人每天只喂小茂密一次猫粮,一次 2 勺", RetentionLong, "偏好 + 具体约束(数值/频次)", retentionNone},
{"主人被称作「叶酱」,不许用「喵喵七号」", RetentionLong, "称呼是硬性长期事实", retentionNone},
{"主人是上班族,工作日中午在食堂吃饭", RetentionLong, "职业/作息是长期事实", retentionNone},
}
}
// TestResolveRetentionFixtureCorpus 逐条钉住档位判定(正例判得到 / 反例不会被判)。
func TestResolveRetentionFixtureCorpus(t *testing.T) {
t.Parallel()
for _, tc := range retentionFixtures() {
tc := tc
t.Run(tc.content, func(t *testing.T) {
t.Parallel()
item := habitItem(tc.content, tidyTestTime(t), 0.9)
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier != tc.want {
t.Fatalf("%q 判成 %q, want %q(%s)", tc.content, verdict.Tier, tc.want, tc.why)
}
if tc.rule != retentionNone && verdict.Rule != tc.rule {
t.Fatalf("%q 命中的规则 = %q, want %q(%s)", tc.content, verdict.Rule, tc.rule, tc.why)
}
})
}
}
// TestResolveRetentionWritePathMatchesFixture 把同一份语料压过**真实写入路径**
// (Store.Add),断言落库的两列与判定一致 —— 判据与落点之间不许有第二套逻辑。
func TestResolveRetentionWritePathMatchesFixture(t *testing.T) {
t.Parallel()
store := newTestStore(t)
for _, tc := range retentionFixtures() {
tc := tc
t.Run(tc.content, func(t *testing.T) {
got := seedTidyItem(t, store, gateFixtureItem(tc.content, time.Time{}))
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != tc.want {
t.Fatalf("%q 落库档位 = %q, want %q", tc.content, tier, tc.want)
}
if tc.want == RetentionTemp && expires == "" {
t.Fatalf("%q 判成 temp 却没写 expires_at(两列分叉)", tc.content)
}
if tc.want != RetentionTemp && expires != "" {
t.Fatalf("%q 判成 %q 却写了 expires_at = %q", tc.content, tc.want, expires)
}
})
}
}
// TestDeferDecisionIsTheTempTier 钉住「`defer` 与 `retention_tier=temp` 是同一个判定」
// 在**闸开着**时成立:temp 档的条目拿到第五态 `defer` + `t0_low_value`,长期档的
// 条目走既有四态(create),且留痕的规则版本是 d3.4。
func TestDeferDecisionIsTheTempTier(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
ttl := time.Hour
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
tempRow := seedTidyItem(t, store, gateFixtureItem("谢谢啦", base))
rev := gateRecordFor(t, store, tempRow.ID)
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("temp 档留痕 = %+v, want defer + %q", rev, retentionLowValueReason)
}
if rev.RuleVer != IngestRuleVersion {
t.Fatalf("留痕规则版本 = %q, want %q", rev.RuleVer, IngestRuleVersion)
}
tier, expires := readRetentionColumns(t, store, tempRow.ID)
if tier != RetentionTemp {
t.Fatalf("defer 行的档位 = %q, want %q", tier, RetentionTemp)
}
// 到期时刻来自**同一个判定**与**同一个时钟**(闸注入的 now + 面板 TTL)。
if want := expiryText(base.Add(ttl)); expires != want {
t.Fatalf("defer 行 expires_at = %q, want %q(判定时刻 + TTL)", expires, want)
}
longRow := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
if rev := gateRecordFor(t, store, longRow.ID); rev.Decision == IngestDefer {
t.Fatalf("长期事实被判成 defer:%+v", rev)
}
}
// TestTempTierDoesNotDependOnTheGate 钉住**档位判定不依赖同步闸**:闸没接(或关着)
// 时,档位与有效期照样在同一笔 INSERT 落下。
//
// M27 体检的缺口之一是「档位/时效只在闸的分支里换算」,于是一旦闸关闭,
// `expires_at` 就再也没有生产者。本用例是那条缺口的回归钉子。
func TestTempTierDoesNotDependOnTheGate(t *testing.T) {
t.Parallel()
store := newTestStore(t) // 未接审查服务 ⇒ planIngest 走 passthrough
got := seedTidyItem(t, store, gateFixtureItem("谢谢啦", time.Time{}))
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTemp {
t.Fatalf("闸未接时档位 = %q, want %q", tier, RetentionTemp)
}
if expires == "" {
t.Fatal("闸未接时没写 expires_at —— 档位判定又退回到「只有闸才算」了")
}
// 这条行不会被投进任何队列、也不会有留痕:档位是写入侧的事实,不是审查的产物。
if got.RetentionTier != RetentionTemp {
t.Fatalf("Add 返回的档位 = %q, want %q", got.RetentionTier, RetentionTemp)
}
}
// TestAsyncReviewDoesNotMoveAnAlreadyTempRow 钉住异步审查层的**不漂移**:
// 一条已经由 Add 判成 temp 的行被审查再判一次时,`expires_at` 不会被
// 「审查时刻 + TTL」重新写一遍(那会让同一条记忆的有效期随审查延迟漂移)。
func TestAsyncReviewDoesNotMoveAnAlreadyTempRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{TempTTL: time.Hour, DisableIngestGate: true}, nil)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, gateFixtureItem("谢谢啦", base))
_, before := readRetentionColumns(t, store, got.ID)
if before == "" {
t.Fatal("Add 没写 expires_at(档位判定没生效)")
}
rev, err := r.Review(ctx, got)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestDefer {
t.Fatalf("异步层结论 = %q, want %q(档位是 temp ⇒ defer)", rev.Decision, IngestDefer)
}
if _, after := readRetentionColumns(t, store, got.ID); after != before {
t.Fatalf("异步审查把 expires_at 从 %q 改成了 %q —— 同一条记忆的有效期不该漂移", before, after)
}
}