Files
AskaEth c7c0294a16 refactor(memory): M45 裁决 D —— temporal 位撤出判定,只保留观测
按 r1 复审裁决 D:这一位先只做观测,`resolveRetention` 不许读它。理由(实测):
无条件 one_off ⇒ temp 会当天删 21 条长期约定;只放开 N6 位置判据能翻的 13 条
逐条是约定、而复审点名的三条目标行 0/3 救得了。在「模型真实标签分布」这份证据
缺位时不该做 7 天后物理删的不可逆决定。

撤接线(结论集逐字回到 M45 之前):
- retention.go:resolveRetention 不再读 item.Temporal;与之对比 dev base
  (af61b8c)本文件**只有注释差异**,规则与判定入口逐字未变。
- extract_retention.go:judgeExtractionRetention 签名回退单参;删除
  judgeRetentionTimepointWide 与 n6_timepoint_wide 规则码。与 dev base 对比
  **同样只有注释差异** ⇒ 「结论集 = 不含 temporal 的那个集合」是结构性的,
  不是靠调用点记得别传。
- 连带取消初版的 durable / recurring ⇒ long(零影响必须是双向的)。

保留观测面(一律未动):迁移 0027 的列、自举前滚、提取器的 temporal 产出、
prompt 枚举、闭集与 normalizeTemporal、store_temporal.go 的校验/编码。
另外把 prompt 里「标成 one_off 会让它进临时层、到期消失」这句**撤接线后已是假话**
改成中性标注说明 —— 否则会诱导模型避开 one_off,观测数据直接被污染。

测试:
- TestTemporalBitDoesNotAffectRetention —— 4 类语料 × 5 个取值,(档位,规则码)
  与未分型逐字相同(核心正断言);
- TestTemporalOneOffDoesNotMakeAnythingTemporary / ...DoesNotRescueRuleHitRows
  —— 零影响的双向点名;
- TestTemporalIsStillRecorded —— one_off 的约定行仍落 temporal='one_off' 而档位
  long、expires 空(观测在、判定不动);
- TestTemporalDoesNotTouchExplicitTierOrRestrictedSource —— 人工入口与受限来源不变。
变异实测:把 `if item.Temporal == TemporalOneOff { return temp }` 插回
resolveRetention ⇒ 上述四条用例全红(FAIL 输出已贴给塔),恢复后全绿。

IngestRuleVersion 保持 d3.5,注释改准为「记录 temporal 信号,但不参与判定」
(留痕面变了:库里多了「模型当时标了什么」这一维,回退会让新旧留痕混在一起)。

P2 三条保留:store.go 500 行(拆 store_temporal.go)、temporal.go 头注('' 的口径
+ 两次被否的接线方案 + 接线前必须看的那两条统计 SQL)、报告语境段。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race
38.2s 全绿。
2026-10-26 19:21:24 +08:00

196 lines
10 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 (
"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. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。
// 5. 其余 ⇒ long(默认落点,fail-safe)。
//
// 🔴 **`temporal`(迁移 0027 的时间性元数据位)不在这个列表里**(r1 复审裁决 D,
// 2026-10-27):这一位目前**只落库、只观测,不参与任何判定**。它被接进判定的两个
// 候选设计都已被生产重放否掉 —— ①无条件 `one_off ⇒ temp` 会当天删掉 21 条长期约定;
// ②只放开 N6 位置判据,最近 500 条里能翻的 13 条**逐条都是约定**、而复审点名的
// 三条目标行 0/3 救得了(详见 temporal.go 头注)。因此判定的结论集与「没有这一位」
// 逐字相同;temporal_test.go 有一条正断言钉住「喂 one_off 不影响任何结论」,
// 另有一条变异靶子(把这一位重新接进本函数 ⇒ 那条正断言必红)。
//
// 🔴 「判第五态 `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
}
if rule := judgeExtractionRetention(item.Content); 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
}