Files
Cyrene_v2/internal/runtime/memory/temporal.go
T
AskaEth 91101dedfb feat(memory): M45 提取器时间性元数据位 temporal 驱动留存判定
背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成
30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的
就是那一位,不动 N1~N7 的任何阈值。

产出侧
- temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown,
  空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜
  同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。
- llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累
  同批其他候选。

落库
- 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
  down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚
  (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与
  留痕不分叉。

判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改)
- one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀:
  显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 ——
  命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时
  21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。
- durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7
  (不许因为「正文看着像今天的事」就判 temp)。
- IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。

验证
- go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/...
  ./internal/db/... 全绿;memory -race 全绿。
- 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与
  temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测:
  摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。
- 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore),
  新加一条迁移不再打红 0025/0026 的用例。
2026-10-26 18:57:38 +08:00

198 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 "strings"
// 本文件是**提取器产出侧的时间性元数据位**(`temporal`,迁移 0027):它回答
// 「这条记忆说的是**一次发生的事**,还是**反复 / 长期成立的事**」。
//
// 为什么需要这一位(M37 复审的结论,逐字):「真正卡住上游的是**提取器的改写**:
// 一次性事件被写成 30~80 字的第三人称长期陈述(同时带『近期/目前』和持久词),
// 规则闸看不到『这是今天的一次性事件』这一位。这不是阈值问题,是**判定链缺信息位**。」
//
// 词项规则(extract_retention.go 的 N1~N7)只能看**落库后的正文**,而正文是提取器
// 改写过的陈述:M37 实测里多出来的 11 条命中,逐条都是「同一条一次性安排的重复
// 陈述」(「一起晒太阳」×5、「傍晚出门走十分钟…今天没走成明天补上」×6)—— 正文里
// 连时点词都没有,把时点窗口从 12 放宽到 24 字只会**把约定本身判成临时并删掉**。
// 因此补的不是阈值,而是这一位。
//
// 🔴 **它是枚举元数据,不是提示词描述**(AGENTS §9「判据靠元数据,不靠提示词」):
// 模型只能在下面四个值里选一个,落库是**列值**(TEXT),判定走 Go 闭集 switch ——
// 不是让规则去理解一句自然语言。模型给不出合法值时**一律归 `unknown`**(绝不猜成
// `one_off`:猜错的方向是「本该留着的被提前删掉」,不可逆)。
//
// 闭集(与迁移 0027 的列值口径逐字一致):
//
// one_off 一次性事件(今天 / 这次 / 某一次发生的事)⇒ temp + expires_at
// recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long
// durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long
// unknown 模型拿不准,或没给出合法值 ⇒ **没有结论**,交给 N1~N7 旧规则
// '' 尚未分型:0027 之前的历史行,以及**非提取器**的写入路径(调用方显式
// 给了档位的那些)—— 它不是一个业务取值,只是列的默认值
//
// 🔴 `unknown` 与 `”` 在判定上**走同一条路**(都落到旧规则),但在口径上是两件事:
// `unknown` 是「问过模型了,它拿不准」;`”` 是「没问过」。把两者折成一个值会让
// 「模型到底答了什么」这个观测面当场消失,而那正是本片要验证的东西。
type TemporalKind string
const (
// TemporalUnset 是「尚未分型」:历史行与非提取器写入路径。**它不是业务取值**,
// 判定上与 TemporalUnknown 同路(落到旧规则),但含义不同(见文件头注)。
TemporalUnset TemporalKind = ""
// TemporalOneOff 是一次性事件。它是**唯一**会把记忆推进临时层的时间性位:
// 命中 ⇒ `retention_tier=temp` + 非空 `expires_at`(7 天后从读面消失、随后被
// 后台清扫物理删除)。因此这个值只许模型在**明确知道**「这是某一次发生的事」
// 时给出 —— 拿不准必须给 unknown。
TemporalOneOff TemporalKind = "one_off"
// TemporalRecurring 是反复发生的规律(「每天散步十分钟」「每周三开会」)。
// 「反复」= 将来还会再发生,因此不许进临时层。
TemporalRecurring TemporalKind = "recurring"
// TemporalDurable 是长期成立的事实 / 偏好 / 约定 / 称呼 / 身份。
TemporalDurable TemporalKind = "durable"
// TemporalUnknown 是「问过模型了,它拿不准」。**它绝不是「一次性」的同义词**:
// 判定上它与未分型一样落到 N1~N7,不许因为「正文看着像今天的事」就判 temp ——
// 那等于把本片要消灭的猜测重新引回判定链(fail-safe 方向:宁可多留)。
TemporalUnknown TemporalKind = "unknown"
)
// valid 报告 t 是否落在闭集内(**含**未分型的空串:它是合法的列值,不是错误)。
func (t TemporalKind) valid() bool {
switch t {
case TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown:
return true
default:
return false
}
}
// assigned 报告 t 是否**已由提取器分型**(即非空串)。它是「未分型」的唯一判据,
// 与 RetentionTier.assigned 同族,调用方不许用 `t == TemporalUnknown` 之类的
// 比较去替代它(那是「问过但拿不准」,不是「没问过」)。
func (t TemporalKind) assigned() bool { return t != TemporalUnset }
// normalizeTemporal 把模型输出的自由文本归一到上面四个值。
//
// 🔴 **只做大小写折叠与首尾空白裁剪,绝不做同义词猜测**(「一次性」「once」→ 都不认):
// 认不出来的答案就是解析失败,一律落 TemporalUnknown —— 这正是任务书
// 「模型只能选那四个值,解析失败一律归 unknown(绝不猜)」的字面实现。
// 猜同义词的代价是单向的:猜成 one_off 就删记忆,猜成别的只是多留一条。
func normalizeTemporal(s string) TemporalKind {
switch TemporalKind(strings.ToLower(strings.TrimSpace(s))) {
case TemporalOneOff:
return TemporalOneOff
case TemporalRecurring:
return TemporalRecurring
case TemporalDurable:
return TemporalDurable
default:
// 空值、非法值、拼写错误、模型自造的新值:全部归 unknown(**不报错**)。
// 一条候选记忆的价值不取决于它的时间性位是否写对,因此解析失败绝不
// 让整批提取失败(那会静默丢掉同批的其他候选)。
return TemporalUnknown
}
}
// temporalRetentionTempRule 是「时间性位把条目推进临时层」时进日志的规则码。
//
// 它**刻意不编号成 n8**:N1~N7 是**词项**规则(看正文),这一条看的是**元数据位**,
// 两者混在一套编号里会让「谁命中过」这条观测线读不出「是词判的还是模型标的」。
// 与 retentionLowValueReason 的分工不变:规则码只进日志,留痕列仍是闭集里的
// `t0_low_value`(extract_retention.go 的口径)。
const retentionTemporalTempRule retentionRule = "temporal_one_off"
// temporalVerdict 把时间性位换算成档位结论(**唯一**一处换算,只由
// `resolveRetention` 调用,见 retention.go 的判定顺序第 4 步)。
//
// - `one_off` ⇒ temp —— 但先过 temporalSanityVeto(语义安全阀):正文自己还在说
// 「长期成立 / 反复发生 / 一份约定 / 主人明确要求记住」时,这一位**不给结论**,
// 落回 N1~N7。它是本片唯一会**收紧**判定的方向,因此阀门全开在 fail-safe 一侧。
// - `durable` / `recurring` ⇒ long,并且**不再跑规则**:模型已经回答了「这不是
// 一次性事件」,此时规则再判 temp 就是把「判定链缺的那一位」又丢掉。方向
// 仍是 fail-safe(多留)。
// - `unknown` / 未分型 ⇒ 不给结论,交给 N1~N7(任务书验收点③:不许因为
// 「看着像今天的事」就判 temp)。
func temporalVerdict(item MemoryItem) (retentionVerdict, bool) {
switch item.Temporal {
case TemporalOneOff:
if temporalSanityVeto(item.Content) {
return retentionVerdict{}, false
}
return retentionVerdict{Tier: RetentionTemp, Rule: retentionTemporalTempRule}, true
case TemporalDurable, TemporalRecurring:
return retentionVerdict{Tier: RetentionLong}, true
default:
return retentionVerdict{}, false
}
}
// temporalSanityVeto 是 `one_off` 支路的**语义安全阀**:正文里出现「这条说的是
// 长期成立的东西」的任何一类信号时,元数据位**不给结论**(落回 N1~N7)。
//
// 它回答的是一个具体的分工问题:这一位补的是**位置判据**那一块(N6 要求时点词出现在
// 句首 12 字内,而提取器改写出的第三人称长句根本不满足),**不是**把 N6 的语义安全阀
// 也一起换掉。判「这不是一次性事件」的词项证据在任何一遍判定里都同样有效,因此这里
// **原样复用同一批词表**,只让「这一次到底是不是一次性」由模型来答。
//
// 六条各自的依据(前两条是既有红线,后四条是 2026-10-27 对生产库 active 集合的离线
// 重放实测):
//
// 1. **显式「记住 / 记下 / 别忘了」** —— extract_retention.go 护栏①,主人的直接
// 指令不许被任何判据改成临时记忆;
// 2. **在陈述约定 / 承诺** —— M37 量化:放宽时点窗口多命中的 11 条逐条都是「同一
// 长期约定的重复陈述」,删掉的就是约定本身;
// 3. **习惯词**(每天 / 通常 / 喜欢…)—— 说的是「反复发生」,与一次性互斥;
// 4. **硬性长期事实词**(住 / 宿舍 / 工作 / 职业 / 称呼 / 项目 / 生日 / 过敏…)——
// 加这条之前,最近 500 条里的 18 条「上界候选」有 12 条是「近期住宿舍(昨天住
// 宿舍)」「项目已接近收尾」这类**被时点词捎带的状态事实**;加上之后窗口内的
// 上界从 4.2% 降到 0%(实测见 M45 的量报告),而全量 active 的上界从 5.4% 降到
// 2.8% —— 这一条是「宁可多留」方向上最便宜的一刀;
// 5. **永久化标记**(以后 / 往后 / 从此…);
// 6. **状态改变标记**(换成 / 变成 / 改成…:新状态会持续)。
//
// ⚠️ **刻意不收「具体约束」这一条**(与 N6 的第三/四条不同):数值与频次并不排他 ——
// 「帮我订个 7 点的闹钟」恰恰是最典型的一次性事件,把数值当否决会让这一位对真事件
// 彻底失效。
func temporalSanityVeto(content string) bool {
text := normalizeRetentionText(content)
if retentionExplicitRemember(text) || retentionStatesAgreement(text) {
return true
}
if containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionHardDurableMarkers()) {
return true
}
if containsAny(text, retentionPermanenceMarkers()) || containsAny(text, retentionStateChangeMarkers()) {
return true
}
return false
}
// retentionStatesAgreement 报告正文是否在**陈述一项约定 / 承诺**(而不是在记录
// 一次发生的事)。
//
// 它是 `one_off` 支路的第二条例外,语料依据逐条可查(2026-10-27 对生产库最近
// 500 条 active 的离线重放 + M37 复审的量化):被判成一次性事件的高危句式不是
// 「今天下雨了」那种流水账,而是
//
// 「和主人有『晚饭后出门走十分钟』的约定;今天没走成,算欠着,明天补上」
// 「两人之间有一个『晒太阳』的约定,目前暂缓,等主人想起来再提」
//
// —— 句子的**主语是一份长期约定**,时点只修饰其中一次执行。M37 已经量过:把 N6
// 的时点窗口放宽到 24 字,这 11 条会一起命中,而它们要删的正是约定本身。因此这条
// 例外不是「又一条词规则」,而是把 M37 那条实测结论固化成交付物。
//
// 词表刻意与 retentionDurableMarkers 的「关系里程碑 / 约定」族同源,但**独立列出**
// (+「约好」):那一族还会被别的判定用到,两者的职责不同 —— 这里只回答
// 「这句话是不是在说一份约定」。
func retentionStatesAgreement(content string) bool {
text := normalizeRetentionText(content)
for _, marker := range []string{"约定", "承诺", "答应", "说好", "约好"} {
if strings.Contains(text, marker) {
return true
}
}
return false
}