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)。
This commit is contained in:
AskaEth committed 2026-10-26 13:09:30 +08:00
1 parent 240b59708d
commit cc6904b1b4
23 files changed
+1592 -181

No files matched your search

+22 -5
View File
@@ -90,7 +90,14 @@ const (
// (`t0_low_value`)与 T1 的同一次调用(`t1_defer`)都能给出它,落点 =
// 照常插入 + 写短 `expires_at`。**升版是硬要求**:同一对条目在 d3.2 下是
// `create`、在 d3.3 下可能是 `defer`,不升版新旧留痕无法区分(§18.4)。
const IngestRuleVersion = "d3.3"
// - `d3.4`(切片 M37)把「判 `defer`」与「判 `retention_tier=temp`」**合并成一个
// 判定**(迁移 0026):档位由 `Store.Add` 的 `resolveRetention` 一次落定
// (调用方显式档位 > 显式有效期 > 受限来源不分型 > 留存规则 → temp > 默认 long),
// `defer` 从此只是「档位是 temp ∧ 关系判为真新」,闸与异步审查都不再各跑一遍
// 规则。**规则内容与阈值未改**(同一份 N1~N7),但**判据的作用范围变了**:
// 档位与 `expires_at` 现在对**每一行**在同一笔 INSERT 里落下,且不再依赖同步闸
// 是否开启 —— 同一对条目在 d3.3 与 d3.4 下的落库形态因此可能不同,按规矩升版。
const IngestRuleVersion = "d3.4"
// 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。
@@ -464,10 +471,20 @@ func (r *PreInsertReviewer) applyVerdict(ctx context.Context, v ingestVerdict, i
}
if v.Decision == IngestDefer {
// 第五态(design/23 §18.1):条目**早已落库**(异步审查跑在写入之后),
// 这里只把 `expires_at` 写成「现在 + 临时 TTL」把它放进临时层。
// **不删行、不改正文、不碰任何其它行** ⇒ 回滚 = 清空该列(§18.4)。
// 写不进去就如实返回错误(fail-safe:行留在长期层 = 宁可多留)。
if err := r.deps.Store.setIngestExpiry(ctx, item.ID, expiryText(r.deferDeadline(now))); err != nil {
// 这里把「档位 + 有效期」改成临时层(**同一条语句**写,两者不可能分叉)。
// 候选是 T1 在同一次调用里判的 defer(`t1_defer`)。
//
// 🔴 **已经落成临时层的行不重复改写**:`Store.Add` 的单一判定已经写过这两列
// (`item.RetentionTier` 随队列传到这里),再写一次只会把有效期从
// 「判定时刻 + TTL」推迟到「审查时刻 + TTL」—— 那是一次会漂移的口径。
//
// **不删行、不改正文、不碰任何其它行** ⇒ 回滚 = 改回 long + 清空有效期
// (§18.4)。写不进去就如实返回错误(fail-safe:行留在长期层 = 宁可多留)。
if item.RetentionTier == RetentionTemp {
return nil, nil
}
if err := r.deps.Store.setIngestRetention(ctx, item.ID,
RetentionTemp, expiryText(r.deferDeadline(now))); err != nil {
return nil, err
}
return nil, nil