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 全绿。
This commit is contained in:
AskaEth committed 2026-10-26 19:15:08 +08:00
1 parent 91101dedfb
commit 3b9ae3f918
9 files changed
+412 -370

No files matched your search

+73 -121
View File
@@ -9,52 +9,79 @@ import "strings"
// 一次性事件被写成 30~80 字的第三人称长期陈述(同时带『近期/目前』和持久词),
// 规则闸看不到『这是今天的一次性事件』这一位。这不是阈值问题,是**判定链缺信息位**。」
//
// 词项规则(extract_retention.go 的 N1~N7)只能看**落库后的正文**,而正文是提取器
// 改写过的陈述:M37 实测里多出来的 11 条命中,逐条都是「同一条一次性安排的重复
// 陈述」(「一起晒太阳」×5、「傍晚出门走十分钟…今天没走成明天补上」×6)—— 正文里
// 连时点词都没有,把时点窗口从 12 放宽到 24 字只会**把约定本身判成临时并删掉**。
// 因此补的不是阈值,而是这一位。
// 🔴 **这一位只接管 N6 的「位置判据」**(2026-10-27 r1 复审裁定):词项规则里唯一
// 真正不可靠的是 **N6 的「时点词必须出现在句首 12 字内」**——提取器改写出的第三人称
// 长句本来就把时点埋在中段,窗口再放宽(12→24)就会连「同一条长期约定的重复陈述」
// 一起命中并删掉约定(M37 已量:2→14,多出的 11 条逐条都是约定)。因此:
//
// 🔴 **它是枚举元数据,不是提示词描述**(AGENTS §9「判据靠元数据,不靠提示词」):
// 模型只能在下面四个值里选一个,落库是**列值**(TEXT),判定走 Go 闭集 switch ——
// 不是让规则去理解一句自然语言。模型给不出合法值时**一律归 `unknown`**(绝不猜成
// `one_off`:猜错的方向是「本该留着的被提前删掉」,不可逆)。
// - 模型说 `one_off` ⇒ **N6 忽略位置窗口**(时点词出现在哪儿都算锚点);
// - **N6 的全部语义安全阀照旧**(习惯词 / 硬性长期事实词 / 具体约束 / 永久化 /
// 状态改变 / 寒暄情绪)—— 它们判的是「这句话不止是一个时刻」,与位置无关;
// - N1~N5、N7、护栏①② 一字不动。
//
// 🔴 r1 复审推翻的两条自造设计(别再写回来):
//
// ① **不要**给 `one_off` 再叠一层「约定/长期事实」词表否决 —— 它既把该救的行一起
// 杀了(「昨晚…睡得比平时早」被习惯词杀掉、「今天就终于把 bug 修好了,以后不用
// 再惦记」被永久化标记杀掉,而这两条正是这一位该救的),又挡不住同义异形的
// 约定(`说定`/`约了` 不在任何词表里)—— 词面否决在**两个方向**上都不安全;
// ② **不要**用「句首时点词且不触发任何阀门」当上界 —— 那种行 N6 本来就判 temp,
// 拿它当上界是同义反复,恰好把本片要救的「时点词不在句首」整类排除在外。
//
// ⚠️ **已知代价(2026-10-27 生产重放实测,r1 复审要求如实登记,不许美化)**:
// 位置窗口今天**正是**那批「约定 + 某一次没走成」行的唯一保护(它们的时点词都埋在
// 中段)。放开位置之后:
//
// - 最近 500 条 active 里**能翻的 13 条逐条都是这种约定**(`约定/约好 + 今天没走成`);
// - 全量 3694 条里能翻 26 条,其中 13 条是约定,另 13 条是「项目/宿舍/换装/探针」
// 这类状态行;
// - 而 r1 复审点名的两条目标行(`昨晚…睡得比平时早` 被习惯词、
// `今天就终于把 bug 修好了,以后不用再惦记` 被永久化标记)**本片救不了** ——
// 它们是被语义安全阀挡的,与位置无关。
//
// 也就是说:**在当前语料上,这一位没有可测收益,风险集中在长期约定上。** 是否保留
// 是产品裁决(M45 报告 §决策矩阵),不是代码判断;本文件只保证「放开位置的边界」是
// 确定的、可测的、可回退的。
//
// 闭集(与迁移 0027 的列值口径逐字一致):
//
// one_off 一次性事件(今天 / 这次 / 某一次发生的事)⇒ temp + expires_at
// recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long
// durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long
// unknown 模型拿不准,或没给出合法值 ⇒ **没有结论**,交给 N1~N7 旧规则
// '' 尚未分型:0027 之前的历史行,以及**非提取器**的写入路径(调用方显式
// 给了档位的那些)—— 它不是一个业务取值,只是列的默认值
// one_off 一次性事件(今天 / 这次 / 某一次发生的事)⇒ 放开 N6 的位置判据
// recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long(不进规则)
// durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long(不进规则)
// unknown 模型拿不准,或没给出合法值 ⇒ **没有结论**,规则照旧
// '' 尚未分型 ⇒ 与 unknown 同路(规则照旧)。它出现在两类行上:
// ① 0027 之前的历史行;
// ② **没做时间性判断的写入路径** —— `ManualExtractor`(「记住 X」,
// 它把档位显式钉成 long,见 extract.go)、网关/管理面的手写记忆
// (`RetentionTier: long`)、整理侧回填。这些路径本来就由调用方
// 显式给档位,因此空串在这里不丢信息(档位判定第 1 步先命中)。
//
// 🔴 `unknown` 与 `”` 在判定上**走同一条路**(都落到旧规则),但在口径上是两件事:
// `unknown` 是「问过模型了,它拿不准」;`”` 是「没问过」。把两者折成一个值会让
// 「模型到底答了什么」这个观测面当场消失,而那正是本片要验证的东西。
// 🔴 `unknown` 与 `”` 在判定上**走同一条路**(规则照旧),但在口径上是两件事:
// `unknown` 是「问过模型了,它拿不准」;`”` 是「这条路径没问过」。把两者折成一个值
// 会让「模型到底答了什么」这个观测面当场消失,而那正是本片要验证的东西。
type TemporalKind string
const (
// TemporalUnset 是「尚未分型」:历史行与非提取器写入路径。**它不是业务取值**,
// 判定上与 TemporalUnknown 同路(落到旧规则),但含义不同(见文件头注)。
// TemporalUnset 是「尚未分型」:历史行与没做时间性判断的写入路径(见文件头注)。
// **它不是业务取值**,判定上与 TemporalUnknown 同路,但含义不同。
TemporalUnset TemporalKind = ""
// TemporalOneOff 是一次性事件。它是**唯一**会把记忆推进临时层的时间性位:
// 命中 ⇒ `retention_tier=temp` + 非空 `expires_at`(7 天后从读面消失、随后被
// 后台清扫物理删除)。因此这个值只许模型在**明确知道**「这是某一次发生的事」
// 时给出 —— 拿不准必须给 unknown。
// TemporalOneOff 是一次性事件。它是本片唯一会**收紧**判定的时间性位:命中即
// 让 N6 忽略位置窗口(时点词出现在句子中段也算锚点)。语义安全阀照旧,因此它
// 仍然不是「无条件判 temp」——这一点是 r1 复审的裁定,别再改回去。
TemporalOneOff TemporalKind = "one_off"
// TemporalRecurring 是反复发生的规律(「每天散步十分钟」「每周三开会」)。
// 「反复」= 将来还会再发生,因此不许进临时层。
// 「反复」= 将来还会再发生,因此不许进临时层:命中即判 long,不再跑规则。
TemporalRecurring TemporalKind = "recurring"
// TemporalDurable 是长期成立的事实 / 偏好 / 约定 / 称呼 / 身份。
// TemporalDurable 是长期成立的事实 / 偏好 / 约定 / 称呼 / 身份。同样判 long、
// 不再跑规则(方向仍是 fail-safe:多留一条)。
TemporalDurable TemporalKind = "durable"
// TemporalUnknown 是「问过模型了,它拿不准」。**它绝不是「一次性」的同义词**:
// 判定上它与未分型一样落到 N1~N7,不许因为「正文看着像今天的事」就判 temp ——
// 那等于把本片要消灭的猜测重新引回判定链(fail-safe 方向:宁可多留)。
// 那等于把本片要消灭的猜测重新引回判定链。
TemporalUnknown TemporalKind = "unknown"
)
@@ -73,6 +100,15 @@ func (t TemporalKind) valid() bool {
// 比较去替代它(那是「问过但拿不准」,不是「没问过」)。
func (t TemporalKind) assigned() bool { return t != TemporalUnset }
// unlocksTimepointPosition 报告这一位是否**放开 N6 的位置判据**。
//
// 只有 `one_off` 会:模型已经回答了「这是某一次发生的事」,那么时点词埋在句中
// (「…整理到一半,今天下午才收尾」)就不再是「它不是一个时刻锚点」的证据。
// `unknown` / 未分型 / durable / recurring 一律不放开。
func (t TemporalKind) unlocksTimepointPosition() bool {
return t == TemporalOneOff
}
// normalizeTemporal 把模型输出的自由文本归一到上面四个值。
//
// 🔴 **只做大小写折叠与首尾空白裁剪,绝不做同义词猜测**(「一次性」「once」→ 都不认):
@@ -95,103 +131,19 @@ func normalizeTemporal(s string) TemporalKind {
}
}
// temporalRetentionTempRule 是「时间性位把条目推进临时层」时进日志的规则码。
// temporalTier 给出**模型直接给出的档位结论**(只由 resolveRetention 第 4 步调用)。
//
// 它**刻意不编号成 n8**:N1~N7 是**词项**规则(看正文),这一条看的是**元数据位**,
// 两者混在一套编号里会让「谁命中过」这条观测线读不出「是词判的还是模型标的」。
// 与 retentionLowValueReason 的分工不变:规则码只进日志,留痕列仍是闭集里的
// `t0_low_value`(extract_retention.go 的口径)。
const retentionTemporalTempRule retentionRule = "temporal_one_off"
// temporalVerdict 把时间性位换算成档位结论(**唯一**一处换算,只由
// `resolveRetention` 调用,见 retention.go 的判定顺序第 4 步)。
// 只有 `durable` / `recurring` 有结论(long):模型已经回答了「这不是一次性事件」,
// 此时再让词项规则判 temp 就是把「判定链缺的那一位」又丢掉;方向是 fail-safe(多留)。
//
// - `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
// `one_off` **不在这里**出结论 —— 它作为**输入**进规则层(放开 N6 的位置判据),
// 语义安全阀照旧(r1 复审裁定,见文件头注);`unknown` / 未分型同样没有结论,
// 规则照旧。
func temporalTier(t TemporalKind) (RetentionTier, bool) {
switch t {
case TemporalDurable, TemporalRecurring:
return retentionVerdict{Tier: RetentionLong}, true
return RetentionLong, true
default:
return retentionVerdict{}, false
return RetentionTierUnset, 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
}