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 }