主人 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)。
181 lines
8.4 KiB
Go
181 lines
8.4 KiB
Go
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)
|
||
}
|
||
}
|