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

202 lines
9.7 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
// 本文件是 D3「入库前审查」的 T0 判定内核(design/23 §15.1 D3-2):
// 对**一条新落库的记忆**与同分区既有的召回候选,给出 merge / modify /
// create / skip 四态之一,并留下可审计的依据。
//
// 判定顺序刻意与设计稿逐条对齐:
// 1. **归一化后完全等** ⇒ skip(§15 D3-2 T0-1:已有等价条目,只更新既有行的
// updated_at / confidence,绝不新建第二条);
// 2. **真包含** ⇒ merge 或 modify(§15 D3-2 T0-2 + §8 包含式判据):
// 新条目是既有条目的子集 ⇒ merge(并入既有,既有行存活);
// 既有条目是新条目的子集 ⇒ modify(**新行承载更全的内容**,旧行置为被合并,
// §15 D3-6「改写必须是新行 + 旧行置为被合并」);
// 3. 其余(互不包含 / 被规则否决 / 没有召回)⇒ create(normal 新建)——
// 其中「互不包含**且**未命中任何规则否决」的那些候选另行登记进
// `ingestVerdict.Gray`,交**异步审查层**的 T1 再问一次
// (preinsert_t1.go,design/23 §15.1 D3-2)。**本文件恒为零模型调用**。
//
// 另外在**候选比对之前**先看**已判定的档位**(`item.RetentionTier`,由
// `Store.Add` 的 `resolveRetention` 落定,见 retention.go):档位是 temp 时直接给
// 第五态 `defer`(`reason=t0_low_value`),**不进候选比对、不进 T1**。
// 本文件因此**不再跑任何留存规则**,也仍然恒为零模型调用。
//
// 两条不可走样:
// - **相似度只用来召回候选**,永不参与判定(design/23 §1.2「单阈值双向失效」);
// - **规则否决一票否决**,且**绝不跨作用域/跨角色/跨项目**(§5.2-7/8/9 是硬门,
// 不是权衡)—— 这里对每条候选再判一次,是纵深防御(分区过滤已在 SQL 里)。
// ⇒ 被否决的候选**不进灰区**:T1 只能在**已通过硬门、且未被任何规则否决**的
// 候选里选(§5.2 的「绝不合并清单」是主人红线,T1 同样不得越过)。
// ingestCandidate 是一条被召回的既有记忆及其召回分数。
type ingestCandidate struct {
item MemoryItem
score float64
// mode 记录本轮相似度来源(embedding / lexical),进审查留痕(§15 D3-8)。
mode string
}
// ingestVerdict 是入库前审查的判定结果。Decision 为空表示「这条候选没有给出
// 决定性结论」(互不包含或被否决),调用方继续看下一条候选。
type ingestVerdict struct {
Decision IngestDecision
// Existing 是命中的既有条目(create 时为零值)—— 留痕里的「命中的既有 id」。
Existing MemoryItem
// Absorbed 是被软合并的那一行:merge/skip 时是**新落库的那一行**,
// modify 时是既有行(§15 D3-6 的「改写」)。
Absorbed MemoryItem
Score float64
Mode string
Reason string
// Veto 是命中的否决规则(未命中时为空)。它只影响依据,绝不改判决定。
Veto TidyVetoReason
// Gray 是**灰区候选**:相似度召回到了、互不包含、且**未命中任何规则否决**
// (含硬门)的那些既有条目,按召回分数降序(与 cands 同序)。
//
// 它是「T0 判不出结论」的结构化输出,只由**异步审查层**消费(T1,
// design/23 §15.1 D3-2);同步入库闸 `planFromVerdict` 刻意不看它 ——
// 于是「写入路径零模型调用」不是一句承诺,而是这条数据流的结构性结果。
// 被规则否决的候选**永不出现**在这里(§5.2 的红线对 T1 一样有效)。
Gray []ingestCandidate
}
// judgeIngest 对一条新落库的记忆做四态判定。cands 已按召回分数降序
// (collectTidyPairs 的确定性排序),因此同分时的选择可复现。
//
// 🔴 **第五态 `defer` 不再在这里跑规则**(迁移 0026 / 切片 M37):档位「留多久」
// 由 `Store.Add` 的**唯一判定**(resolveRetention → retention.go)落成
// `item.RetentionTier`,本函数只读这一位 —— `tier == temp` ⇔ `defer`。
// 于是「判 defer」与「判 retention_tier=temp」是**同一个判定**,不存在两套规则
// 各判一遍(M27 体检的缺口正是两处判据各说各话、且几乎都不触发)。
//
// 无论结论是什么,留痕都要带上**本轮召回来源**(§15 D3-8 的「依据」):
// 「没有命中任何包含关系」与「根本没召回到东西」是两件不同的事。
func judgeIngest(item MemoryItem, cands []ingestCandidate) ingestVerdict {
mode := ""
if len(cands) > 0 {
mode = cands[0].mode
}
if v, ok := judgeExactDuplicate(item, cands); ok {
return v
}
// 档位已判临时(`retention_tier=temp`)⇒ 第五态 `defer`,**不进候选比对、
// 不进 T1**(design/23 §10.6:规则闸拦下的不进 T1,避免「模型推翻规则」)。
//
// 它排在**精确重复之后**是刻意的:已有等价条目时 `skip` 只更新既有行、
// **一行都不新增**,比 `defer`(新增一行临时记忆)更省也更干净。
// 档位与 `defer` 因此不是同一件事:`defer` = 「关系判为真新 ∧ 档位是 temp」,
// 而档位本身对**每一行**都成立(被 skip 掉的那行也有档位,只是它不在册)。
if item.RetentionTier == RetentionTemp {
return ingestVerdict{
Decision: IngestDefer, Reason: retentionLowValueReason, Mode: mode,
Veto: VetoNone, Gray: nil,
}
}
vetoed, vetoScore := VetoNone, 0.0
// 灰区惰性收集:只有真的出现「互不包含且未被否决」的候选才分配,
// 同步写入路径上的常见情形(无候选 / 一眼真新)因此零额外分配。
var gray []ingestCandidate
for _, c := range cands {
if veto, hit := tidyHardGate(item, c.item); hit {
vetoed, vetoScore = keepStrongerVeto(vetoed, vetoScore, veto, c.score)
continue
}
v := judgeContainedIngest(item, c)
if v.Decision != "" {
return v
}
if v.Veto != VetoNone {
vetoed, vetoScore = keepStrongerVeto(vetoed, vetoScore, v.Veto, c.score)
continue
}
gray = append(gray, c)
}
return ingestVerdict{
Decision: IngestCreate, Reason: createReason(vetoed), Veto: vetoed, Mode: mode, Gray: gray,
}
}
// judgeExactDuplicate 施加 §15 D3-2 T0-1:归一化后完全等 ⇒ skip。
// 它排在否决规则**之前**:完全相同的两条就是同一条事实,即便它带时效词或
// 低置信,把它折回既有行也绝不会丢信息(而「否决 ⇒ create」会让主人报的
// 「反复新建」原样复发)。作用域/来源硬门仍然先判 —— 跨分区与群聊来源
// 凭什么都不参与。
func judgeExactDuplicate(item MemoryItem, cands []ingestCandidate) (ingestVerdict, bool) {
key := dedupeKey(item.Content)
for _, c := range cands {
if _, hit := tidyHardGate(item, c.item); hit {
continue
}
if key != dedupeKey(c.item.Content) {
continue
}
// 存活方恒为**既有行**:新行是刚落库的那一条,把它折回既有行正是
// 「不新建」的含义(design §15 D3-2 T0-1)。
return ingestVerdict{
Decision: IngestSkip,
Existing: c.item,
Absorbed: item,
Score: c.score,
Mode: c.mode,
Reason: "exact_duplicate",
}, true
}
return ingestVerdict{}, false
}
// judgeContainedIngest 判定一条候选与新条目的事实关系。Decision 为空表示
// 「无决定性结论」:互不包含(相似但并列)或被规则否决。
func judgeContainedIngest(item MemoryItem, c ingestCandidate) ingestVerdict {
if hasAbsoluteTimestamp(item.Content) || hasAbsoluteTimestamp(c.item.Content) ||
hasTemporalState(item.Content) || hasTemporalState(c.item.Content) {
return ingestVerdict{Veto: VetoTemporal}
}
if !sameNumbers(item.Content, c.item.Content) {
return ingestVerdict{Veto: VetoNumeric}
}
if item.Confidence < tidyLowConfidenceThreshold || c.item.Confidence < tidyLowConfidenceThreshold {
return ingestVerdict{Veto: VetoLowConfidence}
}
cont := judgeContainment(item.Content, c.item.Content)
if cont.Kind == containNone {
// 相似但互不包含(design §1.3 的 [1]/[3]「红牛 vs 冰美式」):
// 绝不合并 —— 对入库而言等价于「正常新建」。
return ingestVerdict{Score: c.score, Mode: c.mode}
}
if !sameNegations(item.Content, c.item.Content) {
// 事实集合看似包含,但一方带否定而另一方没有(「X」vs「戒了X」):
// 那是新事实覆盖旧事实,不是重复(design §5.2-3)。
return ingestVerdict{Veto: VetoConflict}
}
if cont.Kind == containBSubsetA {
// 既有 ⊆ 新:新行承载更全的内容,旧行置为被合并(§15 D3-6)。
return ingestVerdict{
Decision: IngestModify, Existing: c.item, Absorbed: c.item,
Score: c.score, Mode: c.mode, Reason: "fact_superset",
}
}
// 新 ⊆ 既有(含双向等价但措辞不同):并入既有,既有行存活(§15 D3-2 T0-2)。
return ingestVerdict{
Decision: IngestMerge, Existing: c.item, Absorbed: item,
Score: c.score, Mode: c.mode, Reason: "fact_subset",
}
}
// keepStrongerVeto 在两条否决之间保留「分数更高那条候选」的否决原因,
// 让留痕里的依据对应最相关的候选,而不是随手一条。
func keepStrongerVeto(cur TidyVetoReason, curScore float64, next TidyVetoReason, nextScore float64) (TidyVetoReason, float64) {
if cur == VetoNone || nextScore > curScore {
return next, nextScore
}
return cur, curScore
}
// createReason 渲染 create 的依据:没有被否决就是「没有包含关系」,
// 否则如实写出命中的否决规则(留痕可查,design §15 D3-8)。
func createReason(veto TidyVetoReason) string {
if veto == VetoNone {
return "no_contained_match"
}
return "veto:" + string(veto)
}