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
+409 -367

No files matched your search

+60 -28
View File
@@ -67,6 +67,14 @@ const (
// 没有数值、没有约束、没有硬性长期事实词。它是「一次性事件」的形态判据
// (M27 体检报告 §4 的 R4),因此落 `defer`(临时层,7 天后到期)。
retentionTimepoint retentionRule = "n6_timepoint"
// retentionTimepointWide 是对 **N6 的位置判据被时间性元数据位放开**后才命中的
// 那一种(切片 M45,迁移 0027):时点词确实在正文里,但**不在句首 12 字内**,
// 因此只有模型说「这是某一次发生的事」时才开火。
//
// 它与 retentionTimepoint 分开编号是刻意的:句式相同、结论相同,但**判据来源
// 不同**(词项位置 vs 模型位)。折成一个码,「这一位到底救了几条」就只能靠猜 ——
// 而那正是 M27 体检发现「闸形同虚设」时缺的那条观测线。
retentionTimepointWide retentionRule = "n6_timepoint_wide"
// retentionInProgress 对应 **N7 进行中状态**(2026-10-24 新增):正文说的是
// 「正在/暂时 做某件事」(正在测通道、暂时搁置、正在慢慢想),同样是时效轴上的
// 状态而不是长期事实。安全阀与 N6 完全一致(见 judgeRetentionInProgress)。
@@ -101,12 +109,15 @@ const (
// 的 N1~N5 与 M34 新增的 N6/N7)。命中返回该规则码(调用方据此定 temp 档),
// 否则返回 retentionNone。
//
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码,
// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文与**时间性元数据位**
// (temporal,迁移 0027),出参只有规则码,因此在写路径上是微秒级且可完全单测
// (design/03 §10.3 A 级的「零成本」要求)。`temporal` 的作用**只有一处**:把 N6 的
// 位置判据放开(见 judgeRetentionTransient),其余分支一字不看它 —— 判定链没有
// 第二个入口,这一点由 retention_test.go 的源码级断言钉住。
//
// 🔴 全仓**唯一**调用点是 `resolveRetention`(retention.go):任何地方再跑一遍
// 就是「两套规则各判一遍」—— 那正是 M27 体检发现缺口(1082 次只判出 1 条)的成因,
// retention_test.go 有一条源码级断言钉住这个唯一性。
func judgeExtractionRetention(content string) retentionRule {
// 就是「两套规则各判一遍」—— 那正是 M27 体检发现缺口(1082 次只判出 1 条)的成因。
func judgeExtractionRetention(content string, temporal TemporalKind) retentionRule {
text := normalizeRetentionText(content)
if text == "" {
// 空 / 纯空白内容在 Add 的 normalize 就被拒了,走不到这里;真走到了也只
@@ -116,11 +127,7 @@ func judgeExtractionRetention(content string) retentionRule {
}
// 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。它排在最前,因此
// 组合口径(护栏②)不会把主人的直接指令改成临时记忆。
//
// 切片 M45 起它的判据抽成 retentionExplicitRemember(**一处实现、两处调用**):
// 提取器的 `temporal=one_off` 元数据位(temporal.go 的 temporalVerdict)同样
// 不许推翻这条护栏 —— 否则「记住我明天三点开会」会被 7 天后删掉。
if retentionExplicitRemember(text) {
if containsAny(text, retentionRememberDirectives()) {
return retentionNone
}
// 护栏②(2026-10-24 改**组合口径**,M27 体检报告 §7 第 5 批-1):以前是
@@ -170,25 +177,12 @@ func judgeExtractionRetention(content string) retentionRule {
// N6/N7:**指示性时点 / 进行中状态**的窄规则(2026-10-24 新增,M27 §4 R4)。
// 它们与 N1~N5 同性质(纯词项 + 形态),但判的是「这份记忆的锚点是一个正在
// 过去/即将到来的时刻」,而不是「这句话没有信息」。判据与安全阀见各自函数。
if rule := judgeRetentionTransient(text); rule != retentionNone {
if rule := judgeRetentionTransient(text, temporal); rule != retentionNone {
return rule
}
return retentionNone
}
// retentionExplicitRemember 报告正文里有没有**显式要求记住**的措辞
// (「记住 / 记下 / 别忘了」,词表见 retentionRememberDirectives)。
//
// 它是护栏①的判据,也由「时间性元数据位」那一路复用(temporal.go 的
// temporalVerdict):**一条判据只有一份实现**,否则「主人的直接指令一票否决」
// 会在两条判定路上给出不同结论。
//
// 入参可以是原始正文,也可以已由 normalizeRetentionText 规范化过 ——
// 规范化是幂等的(折叠空白 + 去尾标点),重复施加不改变结果。
func retentionExplicitRemember(content string) bool {
return containsAny(normalizeRetentionText(content), retentionRememberDirectives())
}
// retentionHasAnchor 报告正文里有没有「这条记忆可能长期成立」的锚点词
// (习惯族 tidyHabitMarkers + 长期事实族 retentionDurableMarkers)。
//
@@ -276,11 +270,16 @@ func judgeRetentionOneOff(text string) retentionRule {
// ~0:`用户是上班族,需要上班(曾说明天要上班并感到不开心)`、`睡前会道晚安…
// 并约好「明天中午见」` 这类「时点词只出现在引语/从句里」的长期事实,锚点
// 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。
// 🆕 **M45 起这一条是有条件的**:`temporal=one_off` 时位置判据不生效(时点词
// 出现在中段也算锚点,见 judgeRetentionTimepointWide)。上面 1~4、6、7 条语义
// 安全阀**不受这一位影响** —— 那是 r1 复审的裁定:这一位只接管「词在句子的哪个
// 位置」,不接管「这句话是不是一次性事件」。
//
// **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终
// (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致**
// (r1 复审 P2-2:`用户会主动维护…并告诉助手她正在维护…` 里的「正在」只出现在从句,
// 是「会…」的习惯陈述,不许开火)。
// 是「会…」的习惯陈述,不许开火)。N7 的位置判据**不受**时间性位影响(M45 只放开
// N6):`one_off` + 「正在」出现在从句里仍然是习惯陈述,没有放开的理由。
//
// 🔴 **刻意不做的事**(M27 §4 的实测反例,别再重新发明):
// - 不做**长度阈值**筛(R3:≤15 字的条目抽 8 条 8/8 是可复用事实);
@@ -288,7 +287,7 @@ func judgeRetentionOneOff(text string) retentionRule {
// - 不做**一次性事件动词表**(曾/发来/分享…):实测会命中「助手曾经历三千万次
// 轮回」「用户曾发来『i love you』文件」「海报就是助手本人」这类**长期身份与
// 关系里程碑**(抽检误杀 ≈ 50%),方向错误。
func judgeRetentionTransient(text string) retentionRule {
func judgeRetentionTransient(text string, temporal TemporalKind) retentionRule {
// 安全阀 1~4:与 N1~N5 不同,它们在这里是**硬门**(命中即放行),因为 N6/N7
// 判的是「这条记忆的锚点是一个时刻」,而锚点词/数值/约束都在说「它不止是一个时刻」。
if containsAny(text, tidyHabitMarkers()) {
@@ -319,8 +318,18 @@ func judgeRetentionTransient(text string) retentionRule {
if containsAny(text, retentionChatterWords()) || containsAny(text, retentionEmotionWords()) {
return retentionNone
}
if judgeRetentionTimepoint(text) != retentionNone {
return retentionTimepoint
// 安全阀 8(**切片 M45**,唯一由时间性元数据位影响的一处):N6 的**位置判据**。
//
// - `temporal=one_off` ⇒ **不看位置**:时点词出现在句中(提取器改写出的第三人称
// 长句就是这样)也算锚点。这是本片补的那一位的全部作用 —— 它替代的是那个
// 不可靠的位置窗口,**不是**上面 1~7 条语义安全阀;
// - 其余(unknown / 未分型 / durable / recurring 已在 temporal.go 里先判成
// long,走不到这里)⇒ 位置判据照旧(`< retentionTimepointLeadRunes`)。
//
// 规则码分两档是为了让**观测面**能回答「这一条是词判的还是模型放开位置后判的」:
// 只有「恰好靠放开位置才命中」时返回 retentionTimepointWide。
if wide := judgeRetentionTimepointWide(text, temporal); wide != retentionNone {
return wide
}
if judgeRetentionInProgress(text) != retentionNone {
return retentionInProgress
@@ -340,6 +349,29 @@ func judgeRetentionTimepoint(text string) retentionRule {
return retentionNone
}
// judgeRetentionTimepointWide 是 N6 在**时间性位放开位置判据**时的形态判定。
//
// - 位置内命中 ⇒ 返回 retentionTimepoint(词项规则自己的结论,与放开前逐字一致);
// - 位置外命中**且**时点词确实存在 ⇒ 返回 retentionTimepointWide(模型放开位置后
// 才命中,留痕用);
// - 没有时点词 / 没有放开 ⇒ retentionNone。
//
// ⚠️ 它只延长**位置**,不新增任何词表,也不越过 judgeRetentionTransient 的 1~7 条
// 语义安全阀(那些在调用它之前就判完了)。
func judgeRetentionTimepointWide(text string, temporal TemporalKind) retentionRule {
rule := judgeRetentionTimepoint(text)
if rule != retentionNone {
return rule
}
if !temporal.unlocksTimepointPosition() {
return retentionNone
}
if retentionFrontedTimepoint(text) >= 0 {
return retentionTimepointWide
}
return retentionNone
}
// judgeRetentionInProgress 是 N7 的形态判定:**句首前 N 个字内**出现进行中标记
// (正在 / 暂时 / 眼下 / 这会儿)。位置判据与 N6 同口径 —— 它挡的是「正在」只出现在
// 从句里、而主句其实是习惯陈述的那些句子(r1 复审 P2-2 的实测反例)。
@@ -18,6 +18,14 @@ import (
// **不进灰区**(于是 T1 一个 token 都花不出去);把闸摘掉时那条断言会立刻变红
// (判成 create 且灰区非空)。
// judgeRetentionNoTemporal 是**本文件与 transient 测试的便捷封装**:不带时间性位
// (= 0027 之前的旧规则)。切片 M45 把 `temporal` 作为入参加进了规则函数(它只在
// N6 的位置判据上起作用),因此「旧规则」的每一处断言都显式传 TemporalUnset,
// 而不是靠一个隐式默认值 —— 「不带这一位时行为逐字不变」这件事必须在调用点看得见。
func judgeRetentionNoTemporal(content string) retentionRule {
return judgeExtractionRetention(content, TemporalUnset)
}
// retentionPositiveCases 是正向清单(design/03 §10.4)的脱敏样本与**两道护栏**
// 的样本:它们必须**全部**放行(retentionNone)。
func retentionPositiveCases() []struct {
@@ -118,8 +126,8 @@ func TestExtractionRetention_EmotionWithObjectIsNotDeferred(t *testing.T) {
t.Fatalf("%q:余部为空 = %t 与 wantDefer = %t 不一致",
tc.content, rest == "", tc.wantDefer)
}
if rule := judgeExtractionRetention(tc.content); (rule == retentionEmotion) != tc.wantDefer {
t.Fatalf("judgeExtractionRetention(%q) = %q, wantEmotionDefer=%t —— 带宾语的与纯情绪的必须给出相反结论",
if rule := judgeRetentionNoTemporal(tc.content); (rule == retentionEmotion) != tc.wantDefer {
t.Fatalf("judgeRetentionNoTemporal(%q) = %q, wantEmotionDefer=%t —— 带宾语的与纯情绪的必须给出相反结论",
tc.content, rule, tc.wantDefer)
}
})
@@ -161,7 +169,7 @@ func TestExtractionRetention_PositivesAreNeverDeferred(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if rule := judgeExtractionRetention(tc.content); rule != retentionNone {
if rule := judgeRetentionNoTemporal(tc.content); rule != retentionNone {
t.Fatalf("正例 %q 被拦成 %q —— 那是把真记忆丢进临时层(fail-safe 方向被违反)",
tc.content, rule)
}
@@ -177,7 +185,7 @@ func TestExtractionRetention_NegativesAreDeferred(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if rule := judgeExtractionRetention(tc.content); rule != tc.rule {
if rule := judgeRetentionNoTemporal(tc.content); rule != tc.rule {
t.Fatalf("反例 %q 命中 %q, want %q", tc.content, rule, tc.rule)
}
})
@@ -189,7 +197,7 @@ func TestExtractionRetention_NegativesAreDeferred(t *testing.T) {
func TestExtractionRetention_UpperCaseASCII(t *testing.T) {
t.Parallel()
for _, content := range []string{"HI", "Thanks", "hello~"} {
if rule := judgeExtractionRetention(content); rule != retentionChatter {
if rule := judgeRetentionNoTemporal(content); rule != retentionChatter {
t.Fatalf("%q 命中 %q, want %q(ASCII 词应先折小写)", content, rule, retentionChatter)
}
}
@@ -294,7 +302,7 @@ func TestExtractionRetention_PersistentFactsAreNeverDeferred(t *testing.T) {
content := content
t.Run(content, func(t *testing.T) {
t.Parallel()
if rule := judgeExtractionRetention(content); rule != retentionNone {
if rule := judgeRetentionNoTemporal(content); rule != retentionNone {
t.Fatalf("持久事实 %q 被判成 %q —— 它会进临时层、7 天后被物理删除(不可逆)",
content, rule)
}
@@ -73,7 +73,7 @@ func retentionLongTermCases() []struct {
func TestExtractionRetention_TimepointIsDeferred(t *testing.T) {
t.Parallel()
for _, content := range retentionTimepointCases() {
if got := judgeExtractionRetention(content); got != retentionTimepoint && got != retentionInProgress {
if got := judgeRetentionNoTemporal(content); got != retentionTimepoint && got != retentionInProgress {
t.Fatalf("%q 判成 %q, want %q(锚点是一个时刻 ⇒ 临时层)",
content, got, retentionTimepoint)
}
@@ -84,10 +84,10 @@ func TestExtractionRetention_TimepointIsDeferred(t *testing.T) {
// 它让两条规则的词表**各自**可被单测,不会一条盖住另一条。
func TestRetentionTimepointHitsBySpecificRule(t *testing.T) {
t.Parallel()
if got := judgeExtractionRetention("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint {
if got := judgeRetentionNoTemporal("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint {
t.Fatalf("时点句 = %q, want %q", got, retentionTimepoint)
}
if got := judgeExtractionRetention("用户正在验证分享确认面板的展开功能"); got != retentionInProgress {
if got := judgeRetentionNoTemporal("用户正在验证分享确认面板的展开功能"); got != retentionInProgress {
t.Fatalf("进行中句 = %q, want %q", got, retentionInProgress)
}
}
@@ -97,7 +97,7 @@ func TestRetentionTimepointHitsBySpecificRule(t *testing.T) {
func TestExtractionRetention_RealLongTermFactsSurviveTimepoints(t *testing.T) {
t.Parallel()
for _, tc := range retentionLongTermCases() {
if got := judgeExtractionRetention(tc.content); got != retentionNone {
if got := judgeRetentionNoTemporal(tc.content); got != retentionNone {
t.Fatalf("长期事实 %q(%s)被判成 %q —— 它会在 7 天后被物理删除(不可逆)",
tc.content, tc.name, got)
}
@@ -110,10 +110,10 @@ func TestExtractionRetention_TimepointMustBeFronted(t *testing.T) {
t.Parallel()
fronted := "用户昨天发来一卷验收纸条"
buried := "用户与助手讨论过验收纸条的格式(他自己说昨天发来一卷)"
if got := judgeExtractionRetention(fronted); got != retentionTimepoint {
if got := judgeRetentionNoTemporal(fronted); got != retentionTimepoint {
t.Fatalf("时点在句首 = %q, want %q", got, retentionTimepoint)
}
if got := judgeExtractionRetention(buried); got != retentionNone {
if got := judgeRetentionNoTemporal(buried); got != retentionNone {
t.Fatalf("时点埋在从句里 = %q, want %q(位置安全阀失效会误杀长内容)", got, retentionNone)
}
}
@@ -124,17 +124,17 @@ func TestExtractionRetention_TimepointMustBeFronted(t *testing.T) {
// 前一半会变红;把组合条件删成「任一命中即放行」时,整条用例会红。
func TestExtractionRetention_ConjunctionIsNotANoOp(t *testing.T) {
t.Parallel()
if got := judgeExtractionRetention("约定下周给用户留一个安静的午后"); got != retentionTimepoint {
if got := judgeRetentionNoTemporal("约定下周给用户留一个安静的午后"); got != retentionTimepoint {
t.Fatalf("「约定 + 时点、无约束」= %q, want %q(锚点单独出现不再一票放行)",
got, retentionTimepoint)
}
if got := judgeExtractionRetention("约定每天给主人留一个安静的午后"); got != retentionNone {
if got := judgeRetentionNoTemporal("约定每天给主人留一个安静的午后"); got != retentionNone {
t.Fatalf("「约定 + 每天(具体约束)」= %q, want 放行(组合条件命中即长期)", got)
}
if got := judgeExtractionRetention("主人每天要喝 2 杯咖啡"); got != retentionNone {
if got := judgeRetentionNoTemporal("主人每天要喝 2 杯咖啡"); got != retentionNone {
t.Fatalf("「偏好 + 数值」= %q, want 放行", got)
}
if got := judgeExtractionRetention("主人说过不要在群里提她的工作"); got != retentionNone {
if got := judgeRetentionNoTemporal("主人说过不要在群里提她的工作"); got != retentionNone {
t.Fatalf("「禁忌」= %q, want 放行", got)
}
}
@@ -159,7 +159,7 @@ func TestExtractionRetention_TimepointSafetyValves(t *testing.T) {
{"用户昨夜睡得还行,今天心情不错", "情绪词 ⇒ 归 N2 判"},
}
for _, tc := range cases {
got := judgeExtractionRetention(tc.content)
got := judgeRetentionNoTemporal(tc.content)
if got == retentionTimepoint || got == retentionInProgress {
t.Fatalf("%q 判成 %q, want 不被 N6/N7 判临时(%s)", tc.content, got, tc.why)
}
+11 -9
View File
@@ -95,12 +95,12 @@ type retentionVerdict struct {
// 2. **显式给了有效期**(`item.ExpiresAt` 非零)⇒ 这一行会到期 ⇒ 它就是临时记忆。
// 两者是同一根轴的两种表达,因此这里不是「推导」,而是同一个事实的补全。
// 3. **受限来源**(群聊 / 陌生人)⇒ 不分型(空串)。见文件头注 ②。
// 4. **提取器的时间性元数据位**(迁移 0027、temporal.go)⇒ `one_off` 判 temp
// (先过 temporalSanityVeto:正文自己还在说「长期事实 / 反复规律 / 一份约定 /
// 主人明确要求记住」时不给结论,落回旧规则);`durable` / `recurring` 判 long
// 并**不再跑规则**;`unknown` 与未分型 ⇒ **没有结论**,落到下一步。
// 🔴 `unknown` 绝不等于「一次性」:不许因为正文看着像今天的事就判 temp
// (fail-safe 方向:宁可多留)。
// 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)。
//
@@ -125,10 +125,12 @@ func resolveRetention(item MemoryItem) (retentionVerdict, error) {
if nonOwnerSource(item.SourceConversation) {
return retentionVerdict{Tier: RetentionTierUnset}, nil
}
if verdict, ok := temporalVerdict(item); ok {
return verdict, 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); rule != retentionNone {
if rule := judgeExtractionRetention(item.Content, item.Temporal); rule != retentionNone {
return retentionVerdict{Tier: RetentionTemp, Rule: rule}, nil
}
return retentionVerdict{Tier: RetentionLong}, nil
+11 -17
View File
@@ -137,14 +137,15 @@ type MemoryItem struct {
RetentionTier RetentionTier
// Temporal 是**提取器给出的时间性元数据位**(迁移 0027、temporal.go):
// one_off / recurring / durable / unknown,空串 = 未分型(历史行与
// 非提取器写入路径)。它只由提取器在**产出侧**填,判定侧
// one_off / recurring / durable / unknown,空串 = 未分型(历史行与没做时间性
// 判断的写入路径,口径见 temporal.go 头注)。它只由提取器在**产出侧**填,判定侧
// (resolveRetention)读它一位,不让任何规则去猜「这句话说的是不是今天的事」。
//
// ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列,
// 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,共 14→15 列),
// 那是另一件事,不在本片授权范围。要按时间性位巡检/回填时直读该列即可。
// 那是另一件事,不在本片授权范围。要按时间性位巡检/回填时直读该列即可;
// 写入侧的校验/编码见 store_temporal.go。
Temporal TemporalKind
}
@@ -172,10 +173,6 @@ var (
// 丢弃其中一半。
ErrRetentionConflict = errors.New("memory: retention tier conflicts with expires_at")
// ErrInvalidTemporal 在条目的 Temporal(时间性元数据位,迁移 0027)不是闭集内
// 的值时返回。空串**不在**此列:它是「尚未分型」这一合法状态。
ErrInvalidTemporal = errors.New("memory: invalid temporal kind")
// ErrNotFound 在条目 id 不存在时返回。
ErrNotFound = errors.New("memory: item not found")
@@ -305,9 +302,10 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
// expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。
//
// 🆕 切片 M45:`item.Temporal`(时间性元数据位,迁移 0027)也随**这一笔** INSERT
// 落下(`temporal` 列)。它的判定作用已经折算进上面的档位(temporal.go 的
// temporalVerdict 是 resolveRetention 的第 4 步),列值本身是**留痕**:记录提取器
// 当时标了什么,供事后巡检「模型答的是不是 one_off」—— 判定与留痕因此不会分叉。
// 落下(`temporal` 列)。它的判定作用发生在同一次 `resolveRetention` 里(temporal.go
// 的 temporalTier 出 long 结论 / judgeExtractionRetention 放开 N6 的位置判据,两处都
// 在那一个函数内),列值本身是**留痕**:记录提取器当时标了什么,供事后巡检
// 「模型答的是不是 one_off」—— 判定与留痕因此不会分叉。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil {
return MemoryItem{}, err
@@ -345,7 +343,7 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(),
string(item.Temporal))
item.temporalColumn())
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
}
@@ -453,12 +451,8 @@ func (it *MemoryItem) validate() error {
if !it.RetentionTier.valid() {
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, it.RetentionTier)
}
// 时间性位(迁移 0027)同样只接受闭集内的值:它是**枚举元数据**,一个自造的
// 值("sometimes")既判不出档位、又不该静默落库 —— 提取器已经把模型输出归一
// 到 unknown(temporal.go 的 normalizeTemporal),走到这里还是非法值只能是
// 程序错误,直接拒。
if !it.Temporal.valid() {
return fmt.Errorf("%w: %q", ErrInvalidTemporal, it.Temporal)
if err := it.validateTemporal(); err != nil {
return err
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package memory
import (
"errors"
"fmt"
)
// 本文件是 `memory_items.temporal`(迁移 0027)在**写入路径**上的那一半:哨兵错误、
// 字段校验、以及落库时的列值编码。
//
// 其余三处的归属(别在这条链上另开一份实现):
// - **字段与语义**在 store.go 的 MemoryItem 上(结构体的字段不能跨文件声明),
// 闭集与归一化在 temporal.go;
// - **判定**在 retention.go 的 resolveRetention(唯一入口,第 4 步);
// - **DDL** 在 migrations/0027 + store_schema.go + temporal_schema.go。
//
// 拆出本文件只为守 AGENTS §10 的文件行数上限(store.go 已到 500 行)与「模块按职责
// 拆源文件」那条规范 —— 口径逐字未变。
// ErrInvalidTemporal 在条目的 Temporal(时间性元数据位,迁移 0027)不是闭集内的
// 值时返回。空串**不在**此列:它是「尚未分型」这一合法状态。
var ErrInvalidTemporal = errors.New("memory: invalid temporal kind")
// validateTemporal 只接受闭集内的值:时间性位是**枚举元数据**,一个自造的值
// ("sometimes")既判不出档位、又不该静默落库 —— 提取器已经把模型输出归一成
// unknown(temporal.go 的 normalizeTemporal),走到这里还是非法值只能是程序错误,
// 因此直接拒,而不是替它挑一个值。
func (it *MemoryItem) validateTemporal() error {
if !it.Temporal.valid() {
return fmt.Errorf("%w: %q", ErrInvalidTemporal, it.Temporal)
}
return nil
}
// temporalColumn 返回要写进 `temporal` 列的值(与 `ingestGatePlan.retention()` /
// `expiresAt()` 同族:把「条目 → 落库值」这一步收在类型自己身上,插入语句里不留
// 裸转换,也就不可能出现两处各转一次、口径分叉)。
func (it MemoryItem) temporalColumn() string {
return string(it.Temporal)
}
+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
}
@@ -184,7 +184,7 @@ func TestMigration0027ThenStoreBootstrapIsARegularPath(t *testing.T) {
t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after)
}
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
item := habitItem(temporalWideTimepointCorpus()[0], tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
got, err := store.Add(ctx, item)
if err != nil {
+188 -174
View File
@@ -9,30 +9,25 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集,逐条对应
// 任务书的验收点:
// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集。
//
// ① `one_off` 的夹具语料 ⇒ 全链落库 `(temp, 非空 expires_at)`;
// ② `durable` / `recurring` ⇒ `(long, 空)`;
// ③ `unknown` 且内容像一次性 ⇒ **仍按旧规则**(本条不含新的猜测:命中的规则码
// 必须是 N* 词项规则的码,绝不是时间性位的码);
// ④ 模型返回非法值 / 空值 ⇒ 归 `unknown`,且**不报错**、不拖累同批候选;
// ⑤ 变异:把 temporalVerdict 的 one_off 支路摘掉 ⇒ ① 必红(下面三条 ① 的断言
// 都以「档位是 temp」为判据,摘掉支路后它们全部退化成 long)。
// 🔴 这一位的职责边界(r1 复审裁定,决定了下面每条断言的写法):
//
// 🔴 这份语料的一半(`one_off` 那三条)**刻意挑成旧规则的非命中形态**:它们在
// `Temporal` 为空时判 long、带上 `one_off` 时判 temp —— 于是「这一位真的改了什么」
// 可以被单独观察,不会与 N1~N7 的效果混在一起(M37 的实测缺口正是「分不清是谁判的」)。
// 它**只接管 N6 的位置判据** —— 模型说「这是某一次发生的事」⇒ 时点词埋在句子中段
// 也算锚点。N6 的**全部语义安全阀照旧**(习惯词 / 硬性长期事实词 / 具体约束 /
// 永久化 / 状态改变 / 寒暄情绪),N1~N5、N7、护栏①② 一字不动。
//
// 因此「`one_off` ⇒ 一定 temp」**不是**本片的语义(那是任务书初版口径,r1 复审用
// 生产语料推翻了:按它实现会当天删掉 21 条长期约定)。下面的用例逐条钉住的是
// 「放开位置后能翻哪些行」与「翻不了哪些行」——后者同样是断言,因为它们是**已知的
// 边界**,不是待修的缺陷。
// temporalOneOffCorpus 是一次性事件的夹具语料:长句陈述形态、无句首时点词、
// **不含约定/承诺词**(含约定词的句子由 retentionStatesAgreement 挡回旧规则,
// 见 TestTemporalOneOffDoesNotOverrideStatedAgreement)—— 正是 M37 复盘点名的
// 「一次性事件被写成 30~80 字第三人称陈述」那种形态。
func temporalOneOffCorpus() []string {
// temporalWideTimepointCorpus 是**只有放开位置判据才会命中**的夹具语料:时点词都在
// 12 字之后(或句中没有),语义安全阀全部不触发。
func temporalWideTimepointCorpus() []string {
return []string{
"桌面上的文件整理到一半被别的事打断了",
"傍晚出门走十分钟的散步这一回没走成",
"那条链路断线是主人自己排查到根因的",
"桌面上的文件整理到一半,今天下午才收尾",
"主人把桌面上的文件整理到一半,昨天被别的事打断了",
}
}
@@ -48,97 +43,170 @@ func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind {
return TemporalKind(raw)
}
// TestTemporalOneOffLandsTempWithExpiry 钉住验收点①(也是变异点⑤的靶子):
// 时间性位 `one_off` ⇒ 全链落库 `(temp, 非空 expires_at)`,且这一位真的写进了
// `temporal` 列(判定与留痕不分叉)。
// TestTemporalOneOffWidensTimepointPosition 钉住本片**唯一**的判定效果(也是变异靶子):
// 时点词在句中时,`temporal=one_off` ⇒ N6 照常命中(规则码 `n6_timepoint_wide`)⇒
// 全链落库 `(temp, 非空 expires_at)`;而**同一段正文**在未分型时是 long。
//
// 「全链」= 与生产同一条写路径:`Store.Add`(唯一 INSERT 路径)→ resolveRetention
// → planIngest → 一笔 INSERT。测试**不绕开** Add,也不用任何测试专用的旁路。
func TestTemporalOneOffLandsTempWithExpiry(t *testing.T) {
// 变异方向:把 unlocksTimepointPosition 改成恒 false(或把 temporal 参数丢掉)
// ⇒ 本用例的三条断言全红。
func TestTemporalOneOffWidensTimepointPosition(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
for _, content := range temporalOneOffCorpus() {
for _, content := range temporalWideTimepointCorpus() {
content := content
t.Run(content, func(t *testing.T) {
// 先证明这段正文**不含** N1~N7 的命中:否则本用例就分不清是规则还是
// 时间性位判的 temp(M37 的缺口就是分不清)。
// 前提:这段正文在**旧规则**下是 long(时点不在句首 12 字内)。
bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
if err != nil {
t.Fatalf("resolveRetention(bare): %v", err)
}
if bare.Tier != RetentionLong {
t.Fatalf("夹具语料 %q 在旧规则下就判成 %q(want long)—— 这条夹具证明不了时间性位的作用,换一条",
content, bare.Tier)
t.Fatalf("夹具 %q 在旧规则下就判成 %q(want long)—— 它证明不了位置判据被放开", content, bare.Tier)
}
// 放开位置后:命中 N6,规则码是 **wide** 那一档(可区分「词判」与「模型放开位置」)。
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention(one_off): %v", err)
}
if verdict.Tier != RetentionTemp || verdict.Rule != retentionTimepointWide {
t.Fatalf("temporal=one_off 的 %q = %q/%q, want temp/%q",
content, verdict.Tier, verdict.Rule, retentionTimepointWide)
}
// 全链落库:与生产同一条写路径(Store.Add 是唯一 INSERT 路径)。
got, err := store.Add(ctx, item)
if err != nil {
t.Fatalf("Add(one_off): %v", err)
t.Fatalf("Add: %v", err)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTemp {
t.Fatalf("%q with temporal=one_off 落库档位 = %q, want %q", content, tier, RetentionTemp)
t.Fatalf("落库档位 = %q, want %q", tier, RetentionTemp)
}
if expires == "" {
t.Fatalf("%q 判成 temp 却没写 expires_at(两列分叉)", content)
t.Fatalf("判成 temp 却没写 expires_at(两列分叉)")
}
if got.ExpiresAt.IsZero() {
t.Fatalf("%q 的 Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)", content)
t.Fatalf("Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)")
}
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
t.Fatalf("%q 的 temporal 列 = %q, want %q(判定与留痕不许分叉)", content, nat, TemporalOneOff)
t.Fatalf("temporal 列 = %q, want %q(判定与留痕不许分叉)", nat, TemporalOneOff)
}
})
}
}
// TestTemporalOneOffDefersThroughGate 钉住「判定接线」:闸开着时,`one_off` 判出的
// temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),而不是另开一条路径。
//
// 本用例同时是「不要造第二个判定入口」的观测点:`judgeIngest` 只读档位,因此这里
// 除了档位列以外不额外断言任何规则重判的痕迹 —— 重判会让
// TestRetentionJudgementHasSingleRuleCallSite 先红。
func TestTemporalOneOffDefersThroughGate(t *testing.T) {
// TestTemporalOneOffKeepsFrontedTimepointRule 钉住「放开位置」不改变**已经命中**的
// 那些行:时点在句首时,规则码仍是 `n6_timepoint`(不是 wide)—— 否则留痕里就分不清
// 「词判的」与「模型放开位置后判的」,而那条观测线正是本片要交的东西。
func TestTemporalOneOffKeepsFrontedTimepointRule(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
ttl := time.Hour
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
item := habitItem(temporalOneOffCorpus()[0], base, 0.9)
item := habitItem("用户昨天发来一卷验收纸条", tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
got, err := store.Add(context.Background(), item)
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("Add: %v", err)
t.Fatalf("resolveRetention: %v", err)
}
rev := gateRecordFor(t, store, got.ID)
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("one_off 条目的留痕 = %+v, want defer + %q", rev, retentionLowValueReason)
}
if rev.RuleVer != IngestRuleVersion {
t.Fatalf("留痕规则版本 = %q, want %q", rev.RuleVer, IngestRuleVersion)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTemp {
t.Fatalf("one_off 条目档位 = %q, want %q", tier, RetentionTemp)
}
// 到期时刻来自同一个判定与同一个时钟(闸注入的 now + 面板 TTL)。
if want := expiryText(base.Add(ttl)); expires != want {
t.Fatalf("expires_at = %q, want %q", expires, want)
if verdict.Rule != retentionTimepoint {
t.Fatalf("时点在句首时规则码 = %q, want %q(这一条不需要模型放开位置)",
verdict.Rule, retentionTimepoint)
}
}
// TestTemporalDurableAndRecurringStayLong 钉住验收点②:`durable` / `recurring` ⇒
// `(long, 空)`,**并且覆盖规则闸的 temp 结论**。
// TestTemporalOneOffWidenedPositionTouchesAgreements 把 r1 复审指出的**已知风险**
// 写成断言(而不是留一个词表去掩盖它)。
//
// 语料故意取一条旧规则会判 temp 的正文(N6 指示性时点):「模型已经答了这不是
// 一次性事件」时,规则不许把它改判成临时层 —— 方向是 fail-safe(多留一条),
// 与「规则闸拦下的不许被 T1 推翻」并不矛盾:那一条防的是**放宽**(模型把规则判成
// 临时的改成长期),方向相反。
// 事实:N6 的位置窗口**今天正是**那批「约定 + 某一次没走成」行的唯一保护 —— 它们的
// 时点词都埋在中段。放开位置之后,只要模型把这些行标成 `one_off`,它们就会判 temp、
// 7 天后被删。2026-10-27 生产重放实测:最近 500 条里**能翻的 13 条逐条都是这种约定**
// (全量 3694 条里 26 条,其中 13 条是约定)。
//
// 为什么不在这里加词表:r1 复审已经用两条反例否掉了词面否决 —— 它既挡不住同义异形
// 的约定(`说定`/`约了`),又会误伤该救的行。因此**安全边界落在这里,而不是代码里**:
// 这些行是否被删,取决于模型把它们标成 durable(prompt 的 durable 定义里就写着
// 「约定」)还是 one_off。本用例的作用是让这条边界在 diff 里显形、可被复审追踪;
// 是否接受它,是塔的裁决(见 M45 报告的决策矩阵)。
func TestTemporalOneOffWidenedPositionTouchesAgreements(t *testing.T) {
t.Parallel()
const content = "和主人约了傍晚出门走十分钟,今天没走成,明天补"
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier != RetentionTemp {
t.Fatalf("%q = %q —— 若这里变成 long,说明位置判据没有被放开(本片的全部作用消失);"+
"若它一直是 temp,则是已知的误弃风险,见本用例头注", content, verdict.Tier)
}
}
// TestTemporalOneOffKeepsSemanticValves 钉住这一位**翻不了**的那两类(r1 复审点名的
// 生产行)。它们不是缺陷,是职责边界:语义安全阀判的是「这句话不止是一个时刻」,
// 与「时点词在第几个字」无关,因此时间性位不参与它们的判定。
//
// 如实记录(复审要求):**这两条正是 M37 说该救的行,而本片救不了它们** ——
// 要救就得让模型推翻习惯词/永久化标记,那是另一件事(见报告)。
func TestTemporalOneOffKeepsSemanticValves(t *testing.T) {
t.Parallel()
cases := []struct{ content, blockedBy string }{
{"昨晚主人喝了一杯热牛奶才睡下,睡得比平时早", "习惯词(平时)"},
{"今天就终于把拖了一个月的 bug 修好了,以后不用再惦记", "永久化标记(以后)"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.content, func(t *testing.T) {
item := habitItem(tc.content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier == RetentionTemp {
t.Fatalf("%q 被判成 temp —— 语义安全阀(%s)失效了;这一位只接管位置判据,不许动语义阀",
tc.content, tc.blockedBy)
}
})
}
}
// TestTemporalOneOffInProgressPositionUnchanged 钉住 N7 的位置判据**不**被放开:
// r1 复审的裁定只覆盖 N6。「正在」出现在从句里仍然是习惯陈述(「…她正在维护…」),
// 时间性位没有理由替它开火。
func TestTemporalOneOffInProgressPositionUnchanged(t *testing.T) {
t.Parallel()
const content = "那件还没有结论的事,主人正在慢慢想"
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier == RetentionTemp {
t.Fatalf("%q 被判成 temp —— N7 的位置判据不该被时间性位放开(裁定只覆盖 N6)", content)
}
}
// TestTemporalOneOffWithoutTimepointIsInert 钉住「这一位不是无条件 temp」:正文里
// 一个时点词都没有时,放开位置也无事可做 —— 结论仍是旧的 long。
func TestTemporalOneOffWithoutTimepointIsInert(t *testing.T) {
t.Parallel()
const content = "桌面上的文件整理到一半被别的事打断了"
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier != RetentionLong {
t.Fatalf("%q 没有时点词却判成 %q —— 这一位只放开 N6 的位置,不新造判据", content, verdict.Tier)
}
}
// TestTemporalDurableAndRecurringStayLong 钉住任务书验收点②:`durable` / `recurring`
// ⇒ `(long, 空)`,**并且覆盖规则闸的 temp 结论**(模型已答「不是一次性事件」,
// 方向是 fail-safe:多留)。
func TestTemporalDurableAndRecurringStayLong(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -175,18 +243,17 @@ func TestTemporalDurableAndRecurringStayLong(t *testing.T) {
}
}
// TestTemporalUnknownFallsThroughToRules 钉住验收点③,也是最容易做偏的一条:
// `unknown`(以及未分型的空串)**不许自己判 temp** —— 它必须原样交给 N1~N7,
// 判据只有旧规则那一份。
// TestTemporalUnknownAndUnsetFollowOldRules 钉住任务书验收点③:`unknown`(以及未分型
// 的空串)**不许自己判 temp** —— 它们必须原样交给 N1~N7,判据只有旧规则那一份。
//
// 三条断言方向不同,缺一条都测不出「方向反了」:
//
// 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:命中的
// 规则码必须是 `n6_timepoint`,**绝不能**是时间性位的 `temporal_one_off` ——
// 后者出现就说明有人把「看起来像今日事」变成了新的判据;
// 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:规则码必须
// 是 `n6_timepoint`,**绝不能**是 `n6_timepoint_wide`(后者出现就说明位置判据被
// 一个「拿不准」的答案放开了);
// 2. `unknown` + 旧规则非命中的正文 ⇒ long(不许因为看着像一次性就判 temp);
// 3. 未分型(空串)与 `unknown` 走同一条路(历史行的行为逐字不变)。
func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
func TestTemporalUnknownAndUnsetFollowOldRules(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -197,18 +264,15 @@ func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier != RetentionTemp {
t.Fatalf("unknown + 「今天下雨了」= %q, want %q(旧规则 N6 的结论)", verdict.Tier, RetentionTemp)
}
if verdict.Rule != retentionTimepoint {
t.Fatalf("命中的规则 = %q, want %q —— 若出现 %q 说明 unknown 被当成了「一次性」的新判据",
verdict.Rule, retentionTimepoint, retentionTemporalTempRule)
if verdict.Tier != RetentionTemp || verdict.Rule != retentionTimepoint {
t.Fatalf("unknown + 「今天下雨了」= %q/%q, want temp/%q(旧规则 N6 的结论,不是 wide)",
verdict.Tier, verdict.Rule, retentionTimepoint)
}
})
t.Run("unknown_旧规则非命中", func(t *testing.T) {
store := newTestStore(t)
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
item := habitItem(temporalWideTimepointCorpus()[0], tidyTestTime(t), 0.9)
item.Temporal = TemporalUnknown
got, err := store.Add(ctx, item)
if err != nil {
@@ -216,7 +280,7 @@ func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionLong || expires != "" {
t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 「看着像今日事」不许判 temp",
t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 拿不准就必须留",
tier, expires, RetentionLong)
}
})
@@ -237,32 +301,13 @@ func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
})
}
// TestTemporalOneOffDoesNotOverrideExplicitRemember 钉住交叉规则:显式「记住」的
// 一票否决(extract_retention.go 护栏①)对时间性位**同样有效**。
//
// 没有这条,模型把「记住我明天三点开会」标成 one_off 就会让主人在 7 天后丢掉自己
// 明确要求记的东西 —— 那是 fail-safe 方向的反面,且不可逆。
func TestTemporalOneOffDoesNotOverrideExplicitRemember(t *testing.T) {
t.Parallel()
item := habitItem("记住:明天下午三点开会,别迟到", tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier != RetentionLong {
t.Fatalf("显式「记住」+ one_off = %q, want %q(护栏① 一票否决对元数据位同样有效)",
verdict.Tier, RetentionLong)
}
}
// TestTemporalOneOffDoesNotTouchRestrictedSource 钉住与受限来源的优先级:群聊 /
// 陌生人的行**不分型**(空串),因此时间性位不许把它们判成 temp —— 那会造出
// 「对读面隐藏、却永远不会被后台清扫删除」的行(expire.go 跳过非 owner 来源)。
func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) {
t.Parallel()
store := newTestStore(t)
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
item := habitItem("今天下雨了", tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
item.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
got, err := store.Add(context.Background(), item)
@@ -275,84 +320,44 @@ func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) {
}
}
// TestTemporalOneOffDoesNotOverrideStatedAgreement 钉住 `one_off` 支路的第二条
// 例外,也是本片最要紧的一条 fail-safe:**正文在陈述一份约定时,`one_off` 不许把它
// 推进临时层**。
//
// 用例内容逐字取自 M37 复审的量化证据(把 N6 时点窗口放宽到 24 字会多命中 11 条,
// 逐条都是「同一条长期约定的重复陈述」):
//
// 「一起晒太阳」暂时搁置 ×5 → 今天是「约定 + 暂缓」
// 「傍晚出门走十分钟…今天没走成明天补上」×6 → 今天是「约定 + 某一次没走成」
//
// 这两种句子**确实在描述某一次**,因此模型很容易标成 one_off;若代码照单全收,
// 7 天后被删掉的就是约定本身 —— M37 的原话:「放宽阈值会在 7 天后把约定本身删掉,
// 违背 fail-safe」。
func TestTemporalOneOffDoesNotOverrideStatedAgreement(t *testing.T) {
// TestTemporalBitDrivesDeferThroughGate 钉住判定接线:闸开着时,位置被放开后判出的
// temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),不是另开一条路径。
func TestTemporalBitDrivesDeferThroughGate(t *testing.T) {
t.Parallel()
agreements := []string{
"两人之间有一个「晒太阳」的约定,目前暂缓,等主人想起来再提",
"和主人有「晚饭后出门走十分钟」的约定;今天没走成,算欠着,明天补上",
"和叶酱约好每天出门走十分钟;今天太阳先落山没走成,这十分钟欠着",
}
for _, content := range agreements {
content := content
t.Run(content, func(t *testing.T) {
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
verdict, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
}
if verdict.Tier == RetentionTemp {
t.Fatalf("%q 因 temporal=one_off 被判成 temp —— 这是长期约定,7 天后会被删掉(fail-safe 反了)", content)
}
if !retentionStatesAgreement(content) {
t.Fatalf("retentionStatesAgreement(%q) = false —— 例外没认出这条约定", content)
}
})
}
}
store := newTestStore(t)
base := tidyTestTime(t)
ttl := time.Hour
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
// TestTemporalOneOffDoesNotOverrideDurableShape 钉住 `one_off` 支路的语义安全阀
// (temporalSanityVeto 的后四条):正文自己在说「习惯 / 长期事实 / 自此刻起永久 /
// 状态已改变」时,模型标的一次性**不给结论**,落回旧规则。
//
// 语料全部取自 2026-10-27 对生产库 active 集合的离线重放里**真实出现过**的形态
// (它们是「上界候选」的主要来源:被时点词捎带的状态事实)。判成 temp 的后果是
// 7 天后被删,因此这些形态必须留住。
func TestTemporalOneOffDoesNotOverrideDurableShape(t *testing.T) {
t.Parallel()
cases := []struct{ content, why string }{
{"主人有每天吃早餐的习惯,几乎不会省略早餐。", "习惯词(反复发生,与一次性互斥)"},
{"用户近期住宿舍(昨天住宿舍,可能今晚还在),需要关心住宿情况。", "硬性长期事实词(居住)"},
{"茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了个小铃铛。", "状态改变(新状态会持续)"},
{"主人昨天说以后都叫我小叶", "永久化标记(自此刻起一直成立)"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.content, func(t *testing.T) {
item := habitItem(tc.content, tidyTestTime(t), 0.9)
item := habitItem(temporalWideTimepointCorpus()[0], base, 0.9)
item.Temporal = TemporalOneOff
if !temporalSanityVeto(tc.content) {
t.Fatalf("temporalSanityVeto(%q) = false(%s)", tc.content, tc.why)
}
verdict, err := resolveRetention(item)
got, err := store.Add(context.Background(), item)
if err != nil {
t.Fatalf("resolveRetention: %v", err)
t.Fatalf("Add: %v", err)
}
if verdict.Tier == RetentionTemp {
t.Fatalf("%q 因 temporal=one_off 被判成 temp —— %s,删掉是误弃", tc.content, tc.why)
rev := gateRecordFor(t, store, got.ID)
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("one_off 放开位置后判 temp 的留痕 = %+v, want defer + %q", rev, retentionLowValueReason)
}
})
if rev.RuleVer != IngestRuleVersion {
t.Fatalf("留痕规则版本 = %q, want %q", rev.RuleVer, IngestRuleVersion)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTemp {
t.Fatalf("档位 = %q, want %q", tier, RetentionTemp)
}
// 到期时刻来自同一个判定与同一个时钟(闸注入的 now + 面板 TTL)。
if want := expiryText(base.Add(ttl)); expires != want {
t.Fatalf("expires_at = %q, want %q", expires, want)
}
}
// TestNormalizeTemporal 钉住验收点④的归一化口径:**只认四个字面值**,其余
// (空 / 中文 / 自造 / 拼写变体)一律 unknown。
//
// 不做同义词映射是刻意的:猜同义词的代价是单向的(猜成 one_off 就删记忆),
// 而「拿不准一律 long」才是 fail-safe。
// 不做同义词映射是刻意的:猜同义词的代价是单向的(猜成 one_off 就放开位置判据),
// 而「拿不准一律留」才是 fail-safe。
func TestNormalizeTemporal(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -395,6 +400,15 @@ func TestTemporalKindClosedSet(t *testing.T) {
if TemporalUnknown.assigned() != true || TemporalUnset.assigned() != false {
t.Fatal("assigned() 只应对非空串为真(「未分型」的唯一判据)")
}
// 只有 one_off 会放开 N6 的位置判据。
for _, t2 := range []TemporalKind{TemporalUnset, TemporalUnknown, TemporalDurable, TemporalRecurring} {
if t2.unlocksTimepointPosition() {
t.Fatalf("%q 不该放开位置判据", t2)
}
}
if !TemporalOneOff.unlocksTimepointPosition() {
t.Fatal("one_off 必须放开 N6 的位置判据(本片的全部作用)")
}
// 非法值必须被写入路径拒掉(不是静默落库成脏列值)。
item := habitItem("一条正文", tidyTestTime(t), 0.9)
item.Temporal = TemporalKind("sometimes")