Files
Cyrene_v2/internal/runtime/memory/retention.go
T
AskaEth 3b9ae3f918 refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 r1 复审裁定(p1-3items / fix-then-merge)重做三处:

P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表
(retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行
杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住
同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**:
judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient →
judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中
才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条
语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。

P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何
标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active
能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条
约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。

已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements,
不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标
one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置
无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。

P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 +
列值编码)。
P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的
写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。
P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不
存在 ⇒ 重放结论对新写入没有预测力)。

测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置
不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」
五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
2026-10-26 19:15:08 +08:00

203 lines
11 KiB
Go
Raw 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 (
"fmt"
"strings"
"time"
)
// 本文件是 `memory_items` 的第三根轴「留多久 / 干什么用」:档位闭集、**唯一**的
// 判定入口、以及「档位 → 有效期」的**唯一**换算(迁移 0026、审计
// `DevDocs/audit/记忆存量体检-2026-10-26.md` 卷四)。
//
// 🔴 正交性(本片最容易写错的一条):`kind` 是「关于什么」
// (relationship / world_event),`scope` 是「谁的」(user / persona / project),
// `retention_tier` 是「留多久 / 干什么用」。三根轴**互不推导**:一条关系记忆可以
// 是临时,也可以是长期;一条世界事件也可以是知识库条目。任何「由 kind 推 tier」
// (或反过来)的写法都是把两根轴压回一根,那正是本片要避免的设计回归。
//
// 🔴 空串不是第六档,是「**尚未分型**」。它只出现在两类行上:
//
// ① 0026 迁移之前的**历史行**(列默认值为空串,本片刻意不做回填判定);
// ② **受限来源**(群聊 / 陌生人)的新行 —— 见 resolveRetention:它们的留存由
// 来源面决定,后台到期清扫也会跳过它们(expire.go 的 `nonOwnerSource` 谓词),
// 判成 temp 就是造一条「对读面隐藏、却永远不会被删」的行。
//
// 因此读侧**必须**能区分「未分型」与「已分型」:三条列表读路径缺省不过滤(历史行照常
// 返回),显式传 `RetentionTierUnset` 时**只**返回未分型的行。
// RetentionTier 是「留多久 / 干什么用」轴的档位。零值 = 尚未分型。
type RetentionTier string
const (
// RetentionTierUnset 是「尚未分型」:历史行与受限来源行。**它不是一档业务档位**
// —— 读侧绝不许把它当成 temp / long 之类的某一档(那会让「未分型」这个事实
// 当场丢失,而它正是将来回填分型的抓手)。
RetentionTierUnset RetentionTier = ""
// RetentionTemp 是临时层:到期即从读面消失(读侧时效闸,timegate.go),随后被
// 后台清扫物理删除(design/23 §19)。它是**唯一带时效**的档位,
// 且 `retention_tier='temp'` ⇔ `expires_at` 非空 —— 两者由同一个判定、同一处换算
// 落库(见 resolveRetention / tempDeadline),**不许分叉**。
RetentionTemp RetentionTier = "temp"
// RetentionDaily 是日常层:一天之内的生活琐事 / 节奏。它与 `scope=persona` 的
// 「角色日常」**不是一回事** —— 后者回答「谁的记忆」,这一档回答「留多久 / 干什么用」。
RetentionDaily RetentionTier = "daily"
// RetentionWork 是工作层:任务推进、工单、项目过程。
RetentionWork RetentionTier = "work"
// RetentionLong 是长期层:稳定的偏好 / 关系 / 事实。它也是入库判定的**默认落点**
// (判据没命中「显然不值得长期留」时,落到长期层 —— 后果只是多留一条,方向是
// fail-safe 的「宁可多留,不可误弃」,design/23 §18.3)。
RetentionLong RetentionTier = "long"
// RetentionKnowledge 是知识库层:外部资料 / 规格 / 文档类条目。它与
// `internal/runtime/knowledge` 的独立存储无关(那一层不归记忆管),这里只说
// 「这条**记忆行**是知识库性质的」。
RetentionKnowledge RetentionTier = "knowledge"
)
// valid 报告 t 是否落在闭集内(**含**未分型的空串:它是合法的列值,不是一个错误)。
func (t RetentionTier) valid() bool {
switch t {
case RetentionTierUnset, RetentionTemp, RetentionDaily, RetentionWork, RetentionLong, RetentionKnowledge:
return true
default:
return false
}
}
// assigned 报告 t 是否**已分型**(即非空串)。它是「未分型」的唯一判据,
// 调用方不许用 `tier == RetentionLong` 之类的比较去替代它。
func (t RetentionTier) assigned() bool { return t != RetentionTierUnset }
// retentionVerdict 是「留多久」轴的判定结果:档位 + 命中的反例规则码。
//
// 规则码只进日志、不进 `memory_ingest_reviews.reason`(那一列的闭集是
// `{t0_low_value, t1_defer, …}`,见 extract_retention.go 的口径)—— 但它必须有
// 一个落点,否则「n1~n7 到底谁命中过」只能靠猜(M27 体检就是这么发现闸形同虚设的)。
type retentionVerdict struct {
Tier RetentionTier
Rule retentionRule
}
// resolveRetention 决定一条待落库条目最终落哪一档 —— **全仓唯一的档位判定入口**。
//
// 判定顺序(每一档都有不可走样的理由):
//
// 1. **调用方显式给了档位** ⇒ 以调用方为准,规则/模型一律不许推翻它。这条对齐
// AGENTS §9「判据靠元数据,不靠提示词」:写记忆的调用方(手动「记住」、
// 知识库导入、整理侧回填)比一条词项规则更知道「这条该留多久」。
// 显式非 temp 档 + 显式有效期是**自相矛盾**的输入(会造出一行「标着长期却会
// 到期消失」的记忆),直接报错而不是替它挑一个。
// 2. **显式给了有效期**(`item.ExpiresAt` 非零)⇒ 这一行会到期 ⇒ 它就是临时记忆。
// 两者是同一根轴的两种表达,因此这里不是「推导」,而是同一个事实的补全。
// 3. **受限来源**(群聊 / 陌生人)⇒ 不分型(空串)。见文件头注 ②。
// 4. **提取器的时间性元数据位**(迁移 0027、temporal.go):
// `durable` / `recurring` ⇒ long 并**不再跑规则**(模型已答「不是一次性事件」,
// 方向是 fail-safe:多留);`one_off` ⇒ **不作结论**,只把 N6 的位置判据放开
// (交给下一步的同一份规则跑,语义安全阀照旧);`unknown` / 未分型 ⇒ 规则照旧。
// 🔴 `one_off` **不是**「无条件 temp」:r1 复审裁定这一位只接管位置判据,
// 不接管语义判据(详见 temporal.go 头注)。🔴 `unknown` 也绝不等于「一次性」。
// 5. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。
// 6. 其余 ⇒ long(默认落点,fail-safe)。
//
// 排在第 3 步之后的理由与它自己一致:受限来源的留存由来源面决定(后台到期清扫
// 跳过它们,expire.go 的 `nonOwnerSource`),若时间性位把它们判成 temp,就会造出
// 一条「对读面隐藏、却永远不会被删」的行 —— 那是本条要避免的形态。
//
// 🔴 「判第五态 `defer`」与「判 `retention_tier=temp`」**就是这一步**:闸与异步审查
// 都只读 `item.RetentionTier`(preinsert_judge.go),不再各自跑一遍规则。M27/M37 的
// 实测缺口正是「两处各判一遍、上游几乎不触发」,合并之后只有一个判据。
func resolveRetention(item MemoryItem) (retentionVerdict, error) {
if tier := item.RetentionTier; tier.assigned() {
if tier != RetentionTemp && !item.ExpiresAt.IsZero() {
return retentionVerdict{}, fmt.Errorf(
"memory: retention_tier=%q with a non-empty expires_at: %w", tier, ErrRetentionConflict)
}
return retentionVerdict{Tier: tier}, nil
}
if !item.ExpiresAt.IsZero() {
return retentionVerdict{Tier: RetentionTemp}, nil
}
if nonOwnerSource(item.SourceConversation) {
return retentionVerdict{Tier: RetentionTierUnset}, nil
}
// 时间性位只有 `durable` / `recurring` 直接出结论;`one_off` 作为**输入**进规则层
// (放开 N6 的位置判据),因此下面这一行必须把 `item.Temporal` 传下去。
if tier, ok := temporalTier(item.Temporal); ok {
return retentionVerdict{Tier: tier}, nil
}
if rule := judgeExtractionRetention(item.Content, item.Temporal); rule != retentionNone {
return retentionVerdict{Tier: RetentionTemp, Rule: rule}, nil
}
return retentionVerdict{Tier: RetentionLong}, nil
}
// tempDeadline 给出落库时要写进 `expires_at` 的时刻,是「档位 → 有效期」的
// **唯一**换算点:temp ⇒ 一个时刻,其余档位 ⇒ 零值(落库为空串 ⇒ 永不过期)。
//
// 因此「temp ⇔ expires_at 非空」不是一条靠自觉维持的约定,而是这条换算的结构性结果;
// 任何新写路径只要拿这一行去 INSERT,就不可能写出「temp 却没有有效期」的行。
//
// 时刻的三种来源(优先级从高到低):
//
// 1. 调用方显式给的 `item.ExpiresAt`(回填 / 测试夹具需要「已经过期」的行);
// 2. 接了入库审查服务时用**它的**可注入时钟与面板 TTL(`deferDeadline`)——
// 闸、异步审查与写入侧因此用的是同一个算式;
// 3. 没接审查服务时用墙钟 + 代码默认 `tempTtlSec`。
func (s *Store) tempDeadline(item MemoryItem) time.Time {
if item.RetentionTier != RetentionTemp {
return time.Time{}
}
if !item.ExpiresAt.IsZero() {
return item.ExpiresAt
}
if r := s.ingestReviewer(); r != nil {
return r.deferDeadline(r.now().UTC())
}
return time.Now().UTC().Add(time.Duration(tempTtlSec) * time.Second)
}
// retentionTierClause 把可选的档位过滤渲染成参数化 WHERE 片段(SQL 全参数化)。
//
// 缺省(未传任何档位)= **不过滤**:既有调用方零改动,历史行(空串)与其他档位一样
// 照常返回。传了档位就只返回这些档位;显式传 `RetentionTierUnset`(空串)是
// 「只看未分型的行」—— 这是回填分型的入口,必须与「不过滤」可区分。
func retentionTierClause(tiers []RetentionTier) (string, []any, error) {
if len(tiers) == 0 {
return "", nil, nil
}
seen := make(map[RetentionTier]bool, len(tiers))
args := make([]any, 0, len(tiers))
marks := make([]string, 0, len(tiers))
for _, tier := range tiers {
if !tier.valid() {
return "", nil, fmt.Errorf("%w: %q", ErrInvalidRetentionTier, tier)
}
if seen[tier] {
continue
}
seen[tier] = true
marks = append(marks, "?")
args = append(args, string(tier))
}
return " AND retention_tier IN (" + strings.Join(marks, ", ") + ")", args, nil
}
// retentionTierList 是三条列表读路径共用的拼装:把可选的档位过滤接到既有 WHERE
// 之后、时效闸与 ORDER BY 之前,并按**子句出现顺序**排出参数。
//
// 参数顺序是纯文本比较之外的唯一坑:SQLite 的 `?` 按出现位置绑定,档位片段的
// 占位符夹在 status 与时效闸之间,因此档位参数也必须夹在那两组之间 —— 拼错不会
// 报错,只会**静默按错误的档位过滤**(timegate 的历史事故同型)。
func retentionTierList(where string, args []any, tiers []RetentionTier) (string, []any, error) {
clause, tierArgs, err := retentionTierClause(tiers)
if err != nil {
return "", nil, err
}
return where + clause, append(args, tierArgs...), nil
}