From 91101dedfbdce97dddb10f485d60224e4b8af2e7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 18:57:38 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(memory):=20M45=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E5=99=A8=E6=97=B6=E9=97=B4=E6=80=A7=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E4=BD=8D=20temporal=20=E9=A9=B1=E5=8A=A8=E7=95=99=E5=AD=98?= =?UTF-8?q?=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景(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 的用例。 --- .../migrations/0027_memory_temporal.down.sql | 18 + .../db/migrations/0027_memory_temporal.up.sql | 43 ++ internal/runtime/memory/extract_retention.go | 19 +- internal/runtime/memory/llmextractor.go | 33 +- internal/runtime/memory/preinsert.go | 8 +- .../runtime/memory/preinsert_defer_test.go | 19 +- .../memory/preinsert_migration_test.go | 45 +- internal/runtime/memory/retention.go | 17 +- .../memory/retention_migration_test.go | 28 +- internal/runtime/memory/store.go | 38 +- internal/runtime/memory/store_schema.go | 8 +- internal/runtime/memory/temporal.go | 197 ++++++++ .../runtime/memory/temporal_migration_test.go | 196 ++++++++ internal/runtime/memory/temporal_schema.go | 45 ++ internal/runtime/memory/temporal_test.go | 453 ++++++++++++++++++ 15 files changed, 1130 insertions(+), 37 deletions(-) create mode 100644 internal/db/migrations/0027_memory_temporal.down.sql create mode 100644 internal/db/migrations/0027_memory_temporal.up.sql create mode 100644 internal/runtime/memory/temporal.go create mode 100644 internal/runtime/memory/temporal_migration_test.go create mode 100644 internal/runtime/memory/temporal_schema.go create mode 100644 internal/runtime/memory/temporal_test.go diff --git a/internal/db/migrations/0027_memory_temporal.down.sql b/internal/db/migrations/0027_memory_temporal.down.sql new file mode 100644 index 0000000..227b13f --- /dev/null +++ b/internal/db/migrations/0027_memory_temporal.down.sql @@ -0,0 +1,18 @@ +-- 0027 的回滚:只撤结构(删列),**不改写任何一行的数据**。 +-- +-- 回滚的代价(如实登记):`temporal` 这一列的值全部丢失。它对本迁移之前写入的行 +-- 本来就恒为 `''`(未分型),因此丢的只是「本迁移之后提取器标出的时间性位」。 +-- +-- 真正需要说明的是**判定行为**:`retention_tier` / `expires_at` 是另两列,本回滚 +-- **刻意不动它们**。 +-- - 已被判成 temp 的行(其中一部分是 `temporal=one_off` 带来的)保持原状:读侧 +-- 时效闸与后台清扫都只认 `expires_at`(纯 SQL,不依赖本列),因此回滚后它们的 +-- 行为**逐字不变**,到期照旧消失、照旧被清扫; +-- - 若回滚时顺手把那些行的 `expires_at` 清空,那是一次 schema 回滚**改数据**, +-- 会把已经判定的临时记忆全部升回永久,同时还留在 `retention_tier='temp'` 上 +-- —— 那正是「temp ⇔ 非空 expires_at」这条不变量的反面。 +-- 要重判档位就重跑判定:列会以默认值 `''` 重建,历史行回到「未分型」。 +-- +-- SQLite 的 DROP COLUMN 要求该列不被索引/视图/触发器引用:本列没有索引(见 up), +-- 因此无需先删索引(0026 的 down 因为带索引才要以 DROP INDEX 开头)。 +ALTER TABLE memory_items DROP COLUMN temporal; diff --git a/internal/db/migrations/0027_memory_temporal.up.sql b/internal/db/migrations/0027_memory_temporal.up.sql new file mode 100644 index 0000000..f17cfbc --- /dev/null +++ b/internal/db/migrations/0027_memory_temporal.up.sql @@ -0,0 +1,43 @@ +-- 0027_memory_temporal:提取器产出侧的**时间性元数据位**(切片 M45)。 +-- +-- 为什么需要这一列(而不是再调一次词项阈值):M37 复审的量化结论是「defer 命中 +-- 2/500 = 0.4%」不是阈值太严,而是**判定链缺信息位** —— 真正卡住上游的是提取器的 +-- 改写:一次性事件被写成 30~80 字的第三人称长期陈述,规则闸只看落库正文,因此 +-- 看不见「这是今天的一次性事件」这一位。实测反证:把 N6 的时点词窗口从 12 放宽到 +-- 24 字,命中 2→14,多出的 11 条逐条都是**同一长期约定的重复陈述**(「一起晒太阳」 +-- 暂时搁置 ×5、「傍晚出门走十分钟…今天没走成明天补上」×6)—— 放宽阈值等于 7 天后 +-- 把约定本身删掉,违背 fail-safe。所以补的是信息位,不是阈值。 +-- +-- 为什么是**列**而不是新增一种 kind:`kind`(关于什么)、`scope`(谁的)、 +-- `retention_tier`(留多久)、本条 `temporal`(**说的是一次发生的事,还是反复/长期 +-- 成立的事**)是四根正交的轴。时间性是**提取器对原始对话的判断结果**,它必须能被 +-- 单独存下来(事后巡检「模型当时标了什么」、以及将来回填),塞进 kind 就丢了这条 +-- 观测线;靠提示词里的自然语言描述则违反 AGENTS §9「判据靠元数据,不靠提示词」。 +-- +-- 列值闭集(代码侧定义:internal/runtime/memory/temporal.go 的 TemporalKind): +-- one_off 一次性事件(今天 / 这次 / 某一次)⇒ 判定为 temp + 非空 expires_at +-- recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long +-- durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long +-- unknown 模型拿不准或没给出合法值 ⇒ **没有结论**,交给既有 N1~N7 规则判 +-- '' 尚未分型:本迁移之前的历史行,以及非提取器的写入路径(默认值) +-- +-- 🔴 默认值 `''` 而**不是** `unknown`:两者在判定上走同一条路(都落到旧规则),但 +-- 含义不同 —— `''` 是「没问过」,`unknown` 是「问过,模型拿不准」。把历史行写成 +-- `unknown` 会让「模型答了什么」这个观测面从第一行起就掺假。本迁移**刻意不回填**: +-- 历史行没有对应的提取器输出,拿正文反推「它当时是不是一次性事件」正是本片要消灭 +-- 的那种猜测。 +-- +-- 幂等性:`ALTER TABLE ADD COLUMN` 在 SQLite 里**没有** IF NOT EXISTS 形式,因此本 +-- 迁移与 0019 / 0026 同一手法 —— 依赖启动顺序「先 MigrateUp、后 NewStore」 +-- (cmd/cyrene/wire.go):生产库里这一步先跑,随后 NewStore 的自举前滚 +-- (internal/runtime/memory/temporal_schema.go 的 ensureTemporalSchema)走到 +-- "duplicate column name" 是预期路径。列的形状必须与自举 DDL +-- (internal/runtime/memory/store_schema.go)**逐字同形**,由 +-- internal/runtime/memory/temporal_migration_test.go 钉住。 +-- +-- 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入时的判定输入 + +-- 事后巡检的留痕列)。将来整理侧真要按它筛,再补索引不迟(两条路径一起加)。 +-- +-- 命名口径:表名复数(AGENTS §10 已登记例外)、列名与代码的 JSON 键同名 +-- (`temporal`),与 0026 的 `retention_tier` 同族。 +ALTER TABLE memory_items ADD COLUMN temporal TEXT NOT NULL DEFAULT ''; diff --git a/internal/runtime/memory/extract_retention.go b/internal/runtime/memory/extract_retention.go index aab2a11..669aed1 100644 --- a/internal/runtime/memory/extract_retention.go +++ b/internal/runtime/memory/extract_retention.go @@ -116,7 +116,11 @@ func judgeExtractionRetention(content string) retentionRule { } // 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。它排在最前,因此 // 组合口径(护栏②)不会把主人的直接指令改成临时记忆。 - if containsAny(text, retentionRememberDirectives()) { + // + // 切片 M45 起它的判据抽成 retentionExplicitRemember(**一处实现、两处调用**): + // 提取器的 `temporal=one_off` 元数据位(temporal.go 的 temporalVerdict)同样 + // 不许推翻这条护栏 —— 否则「记住我明天三点开会」会被 7 天后删掉。 + if retentionExplicitRemember(text) { return retentionNone } // 护栏②(2026-10-24 改**组合口径**,M27 体检报告 §7 第 5 批-1):以前是 @@ -172,6 +176,19 @@ func judgeExtractionRetention(content string) retentionRule { return retentionNone } +// retentionExplicitRemember 报告正文里有没有**显式要求记住**的措辞 +// (「记住 / 记下 / 别忘了」,词表见 retentionRememberDirectives)。 +// +// 它是护栏①的判据,也由「时间性元数据位」那一路复用(temporal.go 的 +// temporalVerdict):**一条判据只有一份实现**,否则「主人的直接指令一票否决」 +// 会在两条判定路上给出不同结论。 +// +// 入参可以是原始正文,也可以已由 normalizeRetentionText 规范化过 —— +// 规范化是幂等的(折叠空白 + 去尾标点),重复施加不改变结果。 +func retentionExplicitRemember(content string) bool { + return containsAny(normalizeRetentionText(content), retentionRememberDirectives()) +} + // retentionHasAnchor 报告正文里有没有「这条记忆可能长期成立」的锚点词 // (习惯族 tidyHabitMarkers + 长期事实族 retentionDurableMarkers)。 // diff --git a/internal/runtime/memory/llmextractor.go b/internal/runtime/memory/llmextractor.go index 2329a0c..735a06d 100644 --- a/internal/runtime/memory/llmextractor.go +++ b/internal/runtime/memory/llmextractor.go @@ -29,6 +29,14 @@ import ( // // 「允许返回 `[]`」是 Mem0 官方口径(design/23 §2:官方原话「Include examples // that should produce []」)—— 不写这一句,模型会为了「有产出」而硬凑。 +// +// 🆕 2026-10-27(切片 M45):prompt 里追加**时间性位** `temporal`(四个枚举值)。 +// 它**不是**「又一段靠自然语言描述判据」的提示词加固:模型只负责**标注**一个值为 +// 四个枚举之一,**判定**(one_off ⇒ temp / durable ⇒ long / unknown ⇒ 走旧规则) +// 是 temporal.go 里的一段 Go 闭集 switch,落库是 `temporal` 列(迁移 0027)—— +// 因此 AGENTS §9「判据靠元数据,不靠提示词」在这里是**结构性成立**的:即便模型 +// 完全不听这段说明,最坏结果也只是它一律答 unknown(= 回到本片之前的旧规则), +// 不会产生任何新的误判路径。 const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。 只保留这些(正向清单): @@ -49,18 +57,33 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得 **允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回 [],不要为了凑数而提取。 +每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一: +- "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子), + 即使你已经把它写成「用户……」这样的陈述句 —— 一次性事件标成 durable 会让它 + 永远留着,标成 one_off 会让它进临时层、到期自动消失,所以只有确实只发生过 + 一次时才标它; +- "recurring":说的是**反复发生**的规律(每天 / 每周 / 每次都…); +- "durable":说的是**长期成立**的事实 / 偏好 / 约定 / 称呼 / 身份; +- "unknown":拿不准,或者上面三个都不贴切。 + +temporal 只许填这四个英文值之一(不要换成中文、不要自造新值);拿不准就填 +"unknown" —— 标错方向的代价不对称:标成 one_off 会让本该留着的记忆被提前删掉。 + 只输出一个 JSON 数组,不要任何其他文字。每个元素形如: -{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"} -去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。` +{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"} +去噪去重:只保留值得长期记住的,忽略寒暄与临时信息;确实发生过的**一次性事件** +如果值得记,也照实提取,但必须把 temporal 标成 one_off(它会进临时层)。` // llmCandidate 是提取器要求 LLM 输出的线上形状:每条候选 // 记忆一个 JSON 对象。模型未给出合法值时,Scope/Kind 取提取器配置的 -// 默认值。不含 source_conversation: +// 默认值;Temporal 则**一律归 unknown**(temporal.go 的 normalizeTemporal, +// 绝不猜:猜成 one_off 的方向是把该留的记忆删掉)。不含 source_conversation: // 模型即使回填该键也会被忽略(结构化溯源只来自持久化层)。 type llmCandidate struct { Content string `json:"content"` Scope string `json:"scope"` Kind string `json:"kind"` + Temporal string `json:"temporal"` Tags []string `json:"tags"` Confidence float64 `json:"confidence"` ProjectID string `json:"project_id"` @@ -240,6 +263,10 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem { Content: content, Tags: c.Tags, Confidence: c.Confidence, + // 时间性位(迁移 0027)在**产出侧**归一:模型只能选四个值之一, + // 其余(空 / 非法 / 自造)一律归 unknown —— 解析失败绝不报错, + // 也不拖累同批的其他候选(见 temporal.go 的 normalizeTemporal)。 + Temporal: normalizeTemporal(c.Temporal), } if !it.Scope.valid() || !it.Kind.valid() { continue diff --git a/internal/runtime/memory/preinsert.go b/internal/runtime/memory/preinsert.go index ea554ba..04b0f69 100644 --- a/internal/runtime/memory/preinsert.go +++ b/internal/runtime/memory/preinsert.go @@ -97,7 +97,13 @@ const ( // 规则。**规则内容与阈值未改**(同一份 N1~N7),但**判据的作用范围变了**: // 档位与 `expires_at` 现在对**每一行**在同一笔 INSERT 里落下,且不再依赖同步闸 // 是否开启 —— 同一对条目在 d3.3 与 d3.4 下的落库形态因此可能不同,按规矩升版。 -const IngestRuleVersion = "d3.4" +// - `d3.5`(切片 M45)加入提取器的**时间性元数据位**(`temporal`,迁移 0027): +// `one_off` ⇒ temp(⇒ 第五态 defer)、`durable` / `recurring` ⇒ long、 +// `unknown` / 未分型 ⇒ 照旧走 N1~N7。**N1~N7 的规则内容与阈值一字未改**,但 +// 同一段正文在 d3.4 与 d3.5 下可能落到不同档位(d3.4 只能靠词判,d3.5 还能 +// 读到「这是不是一次性事件」这一位)—— 结论会变,按规矩升版,否则新旧留痕 +// 无法区分。 +const IngestRuleVersion = "d3.5" // 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。 diff --git a/internal/runtime/memory/preinsert_defer_test.go b/internal/runtime/memory/preinsert_defer_test.go index 1727335..fb6c106 100644 --- a/internal/runtime/memory/preinsert_defer_test.go +++ b/internal/runtime/memory/preinsert_defer_test.go @@ -19,7 +19,8 @@ import ( // 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本** // (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故); // 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN; -// 4. `IngestRuleVersion` 升到 `d3.4`(改判定规则必升版,否则新旧留痕无法区分); +// 4. `IngestRuleVersion` 随判定变更升版(改判定规则必升版,否则新旧留痕无法区分; +// 当前是 `d3.5`,M37 合入时是 `d3.4`); // 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。 // readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上: @@ -107,8 +108,8 @@ func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) { if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason { t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason) } - if rev.RuleVer != "d3.4" { - t.Fatalf("闸留痕规则版本 = %q, want d3.4", rev.RuleVer) + if rev.RuleVer != IngestRuleVersion { + t.Fatalf("闸留痕规则版本 = %q, want %q(留痕版本必须与常量同源,不写死)", rev.RuleVer, IngestRuleVersion) } if s := r.Stats(); s.GateDeferred != 1 { t.Fatalf("闸计数 = %+v, want gate_deferred=1", s) @@ -268,16 +269,20 @@ func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) { } } -// TestIngestRuleVersionIsD34 钉住任务书验收点④:改判定规则必升版 +// TestIngestRuleVersionIsD35 钉住任务书验收点④:改判定规则必升版 // (design/23 §18.4),否则新旧留痕无法区分。 // // d3.4(切片 M37)把「判 defer」与「判 retention_tier=temp」合并成同一个判定, // 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期, // 且不再依赖同步闸是否开启),因此按规矩升版。 -func TestIngestRuleVersionIsD34(t *testing.T) { +// +// d3.5(切片 M45)给判定链补上「时间性元数据位」:`one_off` ⇒ temp、 +// `durable` / `recurring` ⇒ long、`unknown` ⇒ 旧规则。N1~N7 的规则与阈值一字未改, +// 但同一段正文的结论会变(d3.4 只能靠词判),因此同样按规矩升版。 +func TestIngestRuleVersionIsD35(t *testing.T) { t.Parallel() - if IngestRuleVersion != "d3.4" { - t.Fatalf("IngestRuleVersion = %q, want d3.4(档位与 defer 合并为同一判定)", IngestRuleVersion) + if IngestRuleVersion != "d3.5" { + t.Fatalf("IngestRuleVersion = %q, want d3.5(时间性元数据位接入判定链)", IngestRuleVersion) } } diff --git a/internal/runtime/memory/preinsert_migration_test.go b/internal/runtime/memory/preinsert_migration_test.go index dcb2da7..90a1a3b 100644 --- a/internal/runtime/memory/preinsert_migration_test.go +++ b/internal/runtime/memory/preinsert_migration_test.go @@ -52,6 +52,41 @@ func migrateUp(t *testing.T, d *sql.DB) { } } +// migrateDownToBefore 把库回滚到 id **之前**(即 id 与它之后已应用的每条都撤掉)。 +// +// 步数从 `schema_migrations` 账本现算,而不是写死一个常数:写死的口径每加一条 +// 迁移就要改一次(0026 加进来时本文件的 0025 用例的 `MigrateDown(2)`、0027 加进来时 +// 0026 用例的 `MigrateDown(1)` 都是这么被打红的)—— 而「回滚到某条之前」这个 +// 语义本身与后面还压着几条无关。 +func migrateDownToBefore(t *testing.T, d *sql.DB, id string) { + t.Helper() + ctx := context.Background() + rows, err := d.QueryContext(ctx, `SELECT id FROM schema_migrations ORDER BY id`) + if err != nil { + t.Fatalf("list applied migrations: %v", err) + } + defer func() { _ = rows.Close() }() + steps := 0 + for rows.Next() { + var got string + if err := rows.Scan(&got); err != nil { + t.Fatalf("scan applied migration: %v", err) + } + if got >= id { + steps++ + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate applied migrations: %v", err) + } + if steps == 0 { + t.Fatalf("迁移 %s 不在账本里,无法回滚到它之前", id) + } + if err := db.MigrateDown(ctx, d, steps, testLogger()); err != nil { + t.Fatalf("MigrateDown(%d) 回到 %s 之前: %v", steps, id, err) + } +} + // tableExists 报告表是否存在。 func tableExists(t *testing.T, d *sql.DB, name string) bool { t.Helper() @@ -141,7 +176,6 @@ func countRawReviewRows(t *testing.T, d *sql.DB) int { // 索引、down 删表、再 up 能重建(回滚不留半状态)。 func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) { t.Parallel() - ctx := context.Background() d := openRawMemoryDB(t) migrateUp(t, d) @@ -162,11 +196,10 @@ func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) { } } - // 回滚:本表之上还压着一条 0026(`memory_retention_tier`,切片 M37),因此这里 - // 回滚**两条**才轮到 0025 —— 迁移账本按 id 升序、down 按降序撤,这是既有语义。 - if err := db.MigrateDown(ctx, d, 2, testLogger()); err != nil { - t.Fatalf("MigrateDown(2): %v", err) - } + // 回滚:0025 之上还压着 0026(`memory_retention_tier`,切片 M37)与 0027 + // (`temporal`,切片 M45),因此要回到 0025 **之前**才轮得到它 —— 步数由 + // migrateDownToBefore 从账本现算,不写死常数(见该 helper 的注释)。 + migrateDownToBefore(t, d, ingestReviewMigrationID) if tableExists(t, d, ingestReviewTable) { t.Fatalf("回滚后 %s 应已被删除", ingestReviewTable) } diff --git a/internal/runtime/memory/retention.go b/internal/runtime/memory/retention.go index 5d3bb90..c087a6c 100644 --- a/internal/runtime/memory/retention.go +++ b/internal/runtime/memory/retention.go @@ -95,8 +95,18 @@ type retentionVerdict struct { // 2. **显式给了有效期**(`item.ExpiresAt` 非零)⇒ 这一行会到期 ⇒ 它就是临时记忆。 // 两者是同一根轴的两种表达,因此这里不是「推导」,而是同一个事实的补全。 // 3. **受限来源**(群聊 / 陌生人)⇒ 不分型(空串)。见文件头注 ②。 -// 4. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。 -// 5. 其余 ⇒ long(默认落点,fail-safe)。 +// 4. **提取器的时间性元数据位**(迁移 0027、temporal.go)⇒ `one_off` 判 temp +// (先过 temporalSanityVeto:正文自己还在说「长期事实 / 反复规律 / 一份约定 / +// 主人明确要求记住」时不给结论,落回旧规则);`durable` / `recurring` 判 long +// 并**不再跑规则**;`unknown` 与未分型 ⇒ **没有结论**,落到下一步。 +// 🔴 `unknown` 绝不等于「一次性」:不许因为正文看着像今天的事就判 temp +// (fail-safe 方向:宁可多留)。 +// 5. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。 +// 6. 其余 ⇒ long(默认落点,fail-safe)。 +// +// 排在第 3 步之后的理由与它自己一致:受限来源的留存由来源面决定(后台到期清扫 +// 跳过它们,expire.go 的 `nonOwnerSource`),若时间性位把它们判成 temp,就会造出 +// 一条「对读面隐藏、却永远不会被删」的行 —— 那是本条要避免的形态。 // // 🔴 「判第五态 `defer`」与「判 `retention_tier=temp`」**就是这一步**:闸与异步审查 // 都只读 `item.RetentionTier`(preinsert_judge.go),不再各自跑一遍规则。M27/M37 的 @@ -115,6 +125,9 @@ func resolveRetention(item MemoryItem) (retentionVerdict, error) { if nonOwnerSource(item.SourceConversation) { return retentionVerdict{Tier: RetentionTierUnset}, nil } + if verdict, ok := temporalVerdict(item); ok { + return verdict, nil + } if rule := judgeExtractionRetention(item.Content); rule != retentionNone { return retentionVerdict{Tier: RetentionTemp, Rule: rule}, nil } diff --git a/internal/runtime/memory/retention_migration_test.go b/internal/runtime/memory/retention_migration_test.go index 0fb7c28..837e7f6 100644 --- a/internal/runtime/memory/retention_migration_test.go +++ b/internal/runtime/memory/retention_migration_test.go @@ -7,8 +7,6 @@ import ( "strings" "testing" "time" - - "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) // 本文件钉住**迁移 0026(`memory_retention_tier`)与 Store 自举 DDL 的同形与可回滚**, @@ -69,10 +67,9 @@ func TestMigration0026AddsColumnOverExistingRows(t *testing.T) { d := openRawMemoryDB(t) migrateUp(t, d) - // 造出「既有部署」:把 0026 撤掉(列与索引消失),再按**旧形态**写入两行。 - if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil { - t.Fatalf("MigrateDown(1): %v", err) - } + // 造出「既有部署」:回到 0026 **之前**(列与索引消失),再按**旧形态**写入两行。 + // 步数由账本现算:0027(`temporal`,切片 M45)压在上面,写死 1 会撤错那条。 + migrateDownToBefore(t, d, retentionMigrationID) if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) { t.Fatalf("0026 回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex) } @@ -111,9 +108,7 @@ func TestMigration0026AddsColumnOverExistingRows(t *testing.T) { } // 回滚:只撤结构,不改写任何一行的其它数据。 - if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil { - t.Fatalf("MigrateDown(1, 二次): %v", err) - } + migrateDownToBefore(t, d, retentionMigrationID) if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) { t.Fatalf("二次回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex) } @@ -181,11 +176,18 @@ func TestMigration0026ShapeMatchesStoreBootstrap(t *testing.T) { t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols) } } - // 自举 DDL 里新列写在最后一列(新建库的期望形状)——迁移路径上它后面可能还有 - // 自举追加的 project_id,因此只对自举侧断言顺序。 + // 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal`。 + // 迁移路径上这两列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。 + // (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后。) bootCols := tableColumns(t, bootstrapped, "memory_items") - if last := bootCols[len(bootCols)-1]; !strings.Contains(last, "|"+retentionTierColumn+"|") { - t.Fatalf("自举建的 memory_items 最后一列 = %q, want %s", last, retentionTierColumn) + if len(bootCols) < 2 { + t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols)) + } + tail := bootCols[len(bootCols)-2:] + if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") || + !strings.Contains(tail[1], "|"+temporalColumn+"|") { + t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s", + tail, retentionTierColumn, temporalColumn) } } diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index d8d1a08..395d912 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -135,6 +135,17 @@ type MemoryItem struct { // 非空 = 调用方的显式断言,判据不许推翻它。读回来时: // **空串 = 尚未分型**(0026 之前的历史行 / 受限来源新行),绝不是某一档。 RetentionTier RetentionTier + + // Temporal 是**提取器给出的时间性元数据位**(迁移 0027、temporal.go): + // one_off / recurring / durable / unknown,空串 = 未分型(历史行与 + // 非提取器写入路径)。它只由提取器在**产出侧**填,判定侧 + // (resolveRetention)读它一位,不让任何规则去猜「这句话说的是不是今天的事」。 + // + // ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列, + // 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点 + // (rules.go / store_list.go / tidy_store.go / preinsert_store.go,共 14→15 列), + // 那是另一件事,不在本片授权范围。要按时间性位巡检/回填时直读该列即可。 + Temporal TemporalKind } // 区分 memory 包各类失败的哨兵错误。每个都在 @@ -161,6 +172,10 @@ 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") @@ -246,6 +261,10 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) if err := ensureRetentionSchema(ctx, db); err != nil { return nil, err } + // 时间性元数据位(迁移 0027):只补列、不建索引、不回填(见 temporal_schema.go)。 + if err := ensureTemporalSchema(ctx, db); err != nil { + return nil, err + } if err := normalizeMemoryTimestamps(ctx, db); err != nil { return nil, err } @@ -284,6 +303,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) // 「档位 → expires_at」换算。两列因此**同一个判定、同一笔 INSERT**落下, // 不存在「判了 temp 却没写有效期」的中间态 —— M2 时代登记过的「INSERT 与补写 // expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。 +// +// 🆕 切片 M45:`item.Temporal`(时间性元数据位,迁移 0027)也随**这一笔** INSERT +// 落下(`temporal` 列)。它的判定作用已经折算进上面的档位(temporal.go 的 +// temporalVerdict 是 resolveRetention 的第 4 步),列值本身是**留痕**:记录提取器 +// 当时标了什么,供事后巡检「模型答的是不是 one_off」—— 判定与留痕因此不会分叉。 func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { if err := item.normalize(); err != nil { return MemoryItem{}, err @@ -315,12 +339,13 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { _, err = s.db.ExecContext(ctx, `INSERT INTO memory_items (id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, decay_weight, status, merged_into, - expires_at, retention_tier) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + expires_at, retention_tier, temporal) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind), item.Content, string(tagsJSON), item.Confidence, item.SourceConversation, formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight, - plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention()) + plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(), + string(item.Temporal)) if err != nil { return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err) } @@ -428,6 +453,13 @@ 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) + } return nil } diff --git a/internal/runtime/memory/store_schema.go b/internal/runtime/memory/store_schema.go index 4c3788e..a528d4b 100644 --- a/internal/runtime/memory/store_schema.go +++ b/internal/runtime/memory/store_schema.go @@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS memory_items ( occurred_at TEXT NOT NULL DEFAULT '', valid_to TEXT NOT NULL DEFAULT '', expires_at TEXT NOT NULL DEFAULT '', - retention_tier TEXT NOT NULL DEFAULT '' + retention_tier TEXT NOT NULL DEFAULT '', + temporal TEXT NOT NULL DEFAULT '' )` const createMemoryItemsPersonaIndex = ` @@ -43,6 +44,11 @@ CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at // (回填分型的入口)都吃它。列**必须**排在 CREATE TABLE 的最后:迁移的 // `ALTER TABLE ADD COLUMN` 也把新列追加在末尾,两份 DDL 才逐列同形 // (retention_migration_test.go 钉住这条同形,少一列就是「新库有、老库无」的漂移)。 +// +// ⚠️ 0027 之后 `retention_tier` **不再是最后一列**:`temporal`(迁移 0027)追加在 +// 它之后,两条路径的尾序都是 `… expires_at, retention_tier, temporal`。新列一律 +// 追加在末尾这条口径不变,只是「末尾」会随迁移推进(那条同形断言比对**列集合**, +// 另外对尾序做一条显式断言)。 const createMemoryItemsRetentionTierIndex = ` CREATE INDEX IF NOT EXISTS idx_memory_items_retention_tier ON memory_items (retention_tier)` diff --git a/internal/runtime/memory/temporal.go b/internal/runtime/memory/temporal.go new file mode 100644 index 0000000..f47da45 --- /dev/null +++ b/internal/runtime/memory/temporal.go @@ -0,0 +1,197 @@ +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 +} diff --git a/internal/runtime/memory/temporal_migration_test.go b/internal/runtime/memory/temporal_migration_test.go new file mode 100644 index 0000000..7c881de --- /dev/null +++ b/internal/runtime/memory/temporal_migration_test.go @@ -0,0 +1,196 @@ +package memory + +import ( + "context" + "database/sql" + "slices" + "strings" + "testing" + "time" +) + +// 本文件钉住**迁移 0027(`memory_temporal`)与 Store 自举 DDL 的同形与可回滚**, +// 手法照 retention_migration_test.go 对 0026 的那一套(同一批 helpers)。 +// +// 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份 +// 真源:迁移(权威)与 NewStore 的幂等前滚(任意 *sql.DB 上的自举,temporal_schema.go)。 +// 少一列就是「新库有、老库无」的漂移,因此两条判据都必须有: +// +// 1. TestMigration0027AddsColumnOverExistingRows —— 迁移落在**已有数据**上:行不丢、 +// 列补上、默认值是空串(未分型);down 真的把列删掉且**不改写任何一行的其它 +// 数据**(尤其是 `retention_tier` / `expires_at`,见 down 文件的口径);再 up 能重建。 +// 2. TestMigration0027ShapeMatchesStoreBootstrap —— 新列在两份 DDL 里**逐字同形** +// (TEXT NOT NULL DEFAULT 空串),且自举侧尾序是 `… retention_tier, temporal`。 + +// temporalColumn 是被测列名(迁移与自举 DDL 里的字面量)。 +const temporalColumn = "temporal" + +// temporalMigrationID 是 0027 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。 +const temporalMigrationID = "0027_memory_temporal" + +// TestMigration0027AddsColumnOverExistingRows 钉住迁移本体落在**既有数据**上的两条: +// 行不丢、默认值是空串(未分型);以及再 up 后历史行回到「未分型」。 +func TestMigration0027AddsColumnOverExistingRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + d := openRawMemoryDB(t) + migrateUp(t, d) + if !hasMemoryColumn(t, d, temporalColumn) { + t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn) + } + + // 造出「0027 之前的既有部署」:回到 0027 之前,按旧形态写入两行。 + migrateDownToBefore(t, d, temporalMigrationID) + if hasMemoryColumn(t, d, temporalColumn) { + t.Fatalf("0027 回滚后 %s 应已消失", temporalColumn) + } + insertLegacyMemoryRow(t, d, "legacy-1", "一条 0027 之前就存在的记忆") + insertLegacyMemoryRow(t, d, "legacy-2", "另一条 0027 之前就存在的记忆") + + // 升级:0027 落在**已有数据**上。 + migrateUp(t, d) + if !hasMemoryColumn(t, d, temporalColumn) { + t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn) + } + var rows int + if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil { + t.Fatalf("count rows: %v", err) + } + if rows != 2 { + t.Fatalf("迁移后行数 = %d, want 2(既有行一行都不能丢)", rows) + } + for _, id := range []string{"legacy-1", "legacy-2"} { + var nat, content string + if err := d.QueryRowContext(ctx, + `SELECT temporal, content FROM memory_items WHERE id = ?`, id). + Scan(&nat, &content); err != nil { + t.Fatalf("read %s: %v", id, err) + } + if nat != "" { + t.Fatalf("既有行 %s 的时间性位 = %q, want 空串(历史行必须是「未分型」)", id, nat) + } + if content == "" { + t.Fatalf("既有行 %s 的正文丢了", id) + } + } +} + +// TestMigration0027DownKeepsRowData 钉住 down 的**唯一**语义:只撤结构。判成 temp 的 +// 行在回滚之后档位与时效必须**一个字节都没变**(回滚改数据 = 把已判定的临时记忆升回 +// 永久,正是 down 文件里登记的那条反面)。 +func TestMigration0027DownKeepsRowData(t *testing.T) { + t.Parallel() + ctx := context.Background() + d := openRawMemoryDB(t) + migrateUp(t, d) + + if _, err := d.ExecContext(ctx, `INSERT INTO memory_items + (id, persona_id, scope, kind, content, created_at, updated_at, + retention_tier, expires_at, temporal) + VALUES ('temp-1', 'persona.a', 'persona', 'relationship', '一条临时记忆', + `+legacyTimeLiteral+`, `+legacyTimeLiteral+`, 'temp', ?, 'one_off')`, + formatTime(time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC))); err != nil { + t.Fatalf("seed temp row: %v", err) + } + + migrateDownToBefore(t, d, temporalMigrationID) + if hasMemoryColumn(t, d, temporalColumn) { + t.Fatalf("回滚后 %s 应已消失", temporalColumn) + } + var tier, expires string + if err := d.QueryRowContext(ctx, + `SELECT retention_tier, expires_at FROM memory_items WHERE id = 'temp-1'`). + Scan(&tier, &expires); err != nil { + t.Fatalf("read retention columns after down: %v", err) + } + if tier != string(RetentionTemp) || expires == "" { + t.Fatalf("down 改写了档位/时效 = (%q, %q), want (%q, 非空) —— 回滚只撤结构", + tier, expires, RetentionTemp) + } + + // 再 up:列以默认值重建,既有行回到「未分型」。 + migrateUp(t, d) + if !hasMemoryColumn(t, d, temporalColumn) { + t.Fatalf("再次迁移后应重建 %s 列", temporalColumn) + } + var untyped int + if err := d.QueryRowContext(ctx, + `SELECT COUNT(*) FROM memory_items WHERE temporal = ''`).Scan(&untyped); err != nil { + t.Fatalf("count untyped: %v", err) + } + if untyped != 1 { + t.Fatalf("重建后未分型行 = %d, want 1", untyped) + } +} + +// TestMigration0027ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的 +// memory_items(列集合)与自举 DDL 建出的必须一致,且新列在两份 DDL 里的形状 +// 逐字相同(TEXT NOT NULL DEFAULT 空串)。 +// +// 列集合的等价性由 0026 的 TestMigration0026ShapeMatchesStoreBootstrap 一并覆盖 +// (它比对整表列集合);这里只钉本列自己的形状与自举侧的尾序。 +func TestMigration0027ShapeMatchesStoreBootstrap(t *testing.T) { + t.Parallel() + ctx := context.Background() + + migrated := openRawMemoryDB(t) + migrateUp(t, migrated) + + bootstrapped := openRawMemoryDB(t) + if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil { + t.Fatalf("NewStore: %v", err) + } + + for name, d := range map[string]*sql.DB{"迁移": migrated, "自举": bootstrapped} { + cols := sortedColumns(tableColumns(t, d, "memory_items")) + if !slices.Contains(cols, temporalColumn+"|TEXT|1|''|0") { + t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, temporalColumn, cols) + } + } + bootCols := tableColumns(t, bootstrapped, "memory_items") + if len(bootCols) < 2 { + t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols)) + } + tail := bootCols[len(bootCols)-2:] + if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") || + !strings.Contains(tail[1], "|"+temporalColumn+"|") { + t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s", + tail, retentionTierColumn, temporalColumn) + } +} + +// TestMigration0027ThenStoreBootstrapIsARegularPath 覆盖**真实启动顺序** +// (cmd/cyrene/wire.go:先 MigrateUp、后 NewStore):0027 先建好列,随后 Store 的 +// 自举前滚走到 "duplicate column name" —— 那是**预期路径**,必须无害;重复建 Store +// 同样幂等,且写入路径可用(时间性位随同一笔 INSERT 落下)。 +func TestMigration0027ThenStoreBootstrapIsARegularPath(t *testing.T) { + t.Parallel() + ctx := context.Background() + d := openRawMemoryDB(t) + migrateUp(t, d) + + store, err := NewStore(ctx, d, testLogger()) + if err != nil { + t.Fatalf("NewStore(列已由迁移建好): %v", err) + } + after := tableColumns(t, d, "memory_items") + if !hasMemoryColumn(t, d, temporalColumn) { + t.Fatalf("自举之后 %s 列消失了", temporalColumn) + } + if _, err := NewStore(ctx, d, testLogger()); err != nil { + t.Fatalf("NewStore(第二次): %v", err) + } + if got := tableColumns(t, d, "memory_items"); !slices.Equal(got, after) { + t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after) + } + + item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9) + item.Temporal = TemporalOneOff + got, err := store.Add(ctx, item) + if err != nil { + t.Fatalf("Add: %v", err) + } + if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff { + t.Fatalf("迁移建列后经 Store 落库的时间性位 = %q, want %q", nat, TemporalOneOff) + } +} diff --git a/internal/runtime/memory/temporal_schema.go b/internal/runtime/memory/temporal_schema.go new file mode 100644 index 0000000..c4f5027 --- /dev/null +++ b/internal/runtime/memory/temporal_schema.go @@ -0,0 +1,45 @@ +package memory + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// 本文件是「时间性元数据位」轴(迁移 0027)的 schema 前滚:给**既有**的 +// memory_items 补 `temporal` 列。 +// +// 与 retention_schema.go 同一手法、同一原因:`createMemoryItemsTable` 是 +// `CREATE TABLE IF NOT EXISTS`,只对**新库**生效 —— 既有部署的表早就建好了, +// 新列不会自己长出来。记忆存储必须自包含、能在任意 *sql.DB 上使用,因此这条 +// 前滚不能只靠 `internal/db/migrations`。 +// +// 🔴 与迁移的分工(两份真源,必须同形):权威机制是 +// `internal/db/migrations/0027_memory_temporal`;这里是「任意 *sql.DB」的前滚。 +// 启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产库里 +// 0027 先落地、这里走到 `duplicate column name` 是**预期路径**。 +// +// 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入侧的判定输入 + +// 事后巡检用的留痕列),提前建索引只是给每笔写入加一份没人用的维护成本。 +// 将来整理侧真要按它筛(例如「把 one_off 的行捞出来复核」)再加索引不迟, +// 那时两条路径(迁移 + 本文件)一起加。 + +// ensureTemporalSchema 幂等地给 memory_items 补上时间性位列(列缺失时 ALTER, +// 已存在时忽略 SQLite 的 "duplicate column name" —— 那是预期路径,不是错误)。 +// +// 默认值 = 空串 = 尚未分型:既有行因此**行为逐字不变**(resolveRetention 对 +// 未分型的处理与 0027 之前完全一样,只跑 N1~N7)。本前滚刻意**不做**任何回填 +// 判定:历史行没有对应的提取器输出,拿正文反推「它当时是一次性事件吗」正是 +// 本片要消灭的那种猜测。 +func ensureTemporalSchema(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, `ALTER TABLE memory_items + ADD COLUMN temporal TEXT NOT NULL DEFAULT ''`) + if err == nil { + return nil + } + if strings.Contains(err.Error(), "duplicate column name") { + return nil + } + return fmt.Errorf("memory: roll forward temporal column: %w", err) +} diff --git a/internal/runtime/memory/temporal_test.go b/internal/runtime/memory/temporal_test.go new file mode 100644 index 0000000..40a6202 --- /dev/null +++ b/internal/runtime/memory/temporal_test.go @@ -0,0 +1,453 @@ +package memory + +import ( + "context" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集,逐条对应 +// 任务书的验收点: +// +// ① `one_off` 的夹具语料 ⇒ 全链落库 `(temp, 非空 expires_at)`; +// ② `durable` / `recurring` ⇒ `(long, 空)`; +// ③ `unknown` 且内容像一次性 ⇒ **仍按旧规则**(本条不含新的猜测:命中的规则码 +// 必须是 N* 词项规则的码,绝不是时间性位的码); +// ④ 模型返回非法值 / 空值 ⇒ 归 `unknown`,且**不报错**、不拖累同批候选; +// ⑤ 变异:把 temporalVerdict 的 one_off 支路摘掉 ⇒ ① 必红(下面三条 ① 的断言 +// 都以「档位是 temp」为判据,摘掉支路后它们全部退化成 long)。 +// +// 🔴 这份语料的一半(`one_off` 那三条)**刻意挑成旧规则的非命中形态**:它们在 +// `Temporal` 为空时判 long、带上 `one_off` 时判 temp —— 于是「这一位真的改了什么」 +// 可以被单独观察,不会与 N1~N7 的效果混在一起(M37 的实测缺口正是「分不清是谁判的」)。 + +// temporalOneOffCorpus 是一次性事件的夹具语料:长句陈述形态、无句首时点词、 +// **不含约定/承诺词**(含约定词的句子由 retentionStatesAgreement 挡回旧规则, +// 见 TestTemporalOneOffDoesNotOverrideStatedAgreement)—— 正是 M37 复盘点名的 +// 「一次性事件被写成 30~80 字第三人称陈述」那种形态。 +func temporalOneOffCorpus() []string { + return []string{ + "桌面上的文件整理到一半被别的事打断了", + "傍晚出门走十分钟的散步这一回没走成", + "那条链路断线是主人自己排查到根因的", + } +} + +// readTemporalColumn 直读一行的 `temporal` 列(该列与 expires_at 一样是**只写不读** +// 的列:四条列表读路径的 SELECT 不带它,见 store.go 的 Temporal 注释)。 +func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind { + t.Helper() + var raw string + if err := store.db.QueryRowContext(context.Background(), + `SELECT temporal FROM memory_items WHERE id = ?`, id).Scan(&raw); err != nil { + t.Fatalf("read temporal column %s: %v", id, err) + } + return TemporalKind(raw) +} + +// TestTemporalOneOffLandsTempWithExpiry 钉住验收点①(也是变异点⑤的靶子): +// 时间性位 `one_off` ⇒ 全链落库 `(temp, 非空 expires_at)`,且这一位真的写进了 +// `temporal` 列(判定与留痕不分叉)。 +// +// 「全链」= 与生产同一条写路径:`Store.Add`(唯一 INSERT 路径)→ resolveRetention +// → planIngest → 一笔 INSERT。测试**不绕开** Add,也不用任何测试专用的旁路。 +func TestTemporalOneOffLandsTempWithExpiry(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + for _, content := range temporalOneOffCorpus() { + content := content + t.Run(content, func(t *testing.T) { + // 先证明这段正文**不含** N1~N7 的命中:否则本用例就分不清是规则还是 + // 时间性位判的 temp(M37 的缺口就是分不清)。 + 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) + } + + item := habitItem(content, tidyTestTime(t), 0.9) + item.Temporal = TemporalOneOff + got, err := store.Add(ctx, item) + if err != nil { + t.Fatalf("Add(one_off): %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) + } + if expires == "" { + t.Fatalf("%q 判成 temp 却没写 expires_at(两列分叉)", content) + } + if got.ExpiresAt.IsZero() { + t.Fatalf("%q 的 Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)", content) + } + if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff { + t.Fatalf("%q 的 temporal 列 = %q, want %q(判定与留痕不许分叉)", content, nat, TemporalOneOff) + } + }) + } +} + +// TestTemporalOneOffDefersThroughGate 钉住「判定接线」:闸开着时,`one_off` 判出的 +// temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),而不是另开一条路径。 +// +// 本用例同时是「不要造第二个判定入口」的观测点:`judgeIngest` 只读档位,因此这里 +// 除了档位列以外不额外断言任何规则重判的痕迹 —— 重判会让 +// TestRetentionJudgementHasSingleRuleCallSite 先红。 +func TestTemporalOneOffDefersThroughGate(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.Temporal = TemporalOneOff + got, err := store.Add(context.Background(), item) + if err != nil { + t.Fatalf("Add: %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) + } +} + +// TestTemporalDurableAndRecurringStayLong 钉住验收点②:`durable` / `recurring` ⇒ +// `(long, 空)`,**并且覆盖规则闸的 temp 结论**。 +// +// 语料故意取一条旧规则会判 temp 的正文(N6 指示性时点):「模型已经答了这不是 +// 一次性事件」时,规则不许把它改判成临时层 —— 方向是 fail-safe(多留一条), +// 与「规则闸拦下的不许被 T1 推翻」并不矛盾:那一条防的是**放宽**(模型把规则判成 +// 临时的改成长期),方向相反。 +func TestTemporalDurableAndRecurringStayLong(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + const content = "用户昨天发来一卷验收纸条" + // 前提:这条正文在旧规则下确实判 temp(否则本用例证明不了「覆盖」)。 + bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) + if err != nil { + t.Fatalf("resolveRetention(bare): %v", err) + } + if bare.Tier != RetentionTemp || bare.Rule != retentionTimepoint { + t.Fatalf("前提不成立:%q 旧规则判 %q/%q,want temp/%q", content, bare.Tier, bare.Rule, retentionTimepoint) + } + for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} { + temporal := temporal + t.Run(string(temporal), func(t *testing.T) { + item := habitItem(content, tidyTestTime(t), 0.9) + item.Temporal = temporal + got, err := store.Add(ctx, item) + if err != nil { + t.Fatalf("Add(%q): %v", temporal, err) + } + tier, expires := readRetentionColumns(t, store, got.ID) + if tier != RetentionLong { + t.Fatalf("temporal=%q 落库档位 = %q, want %q", temporal, tier, RetentionLong) + } + if expires != "" { + t.Fatalf("temporal=%q 写了 expires_at = %q(非 temp 档必须为空)", temporal, expires) + } + if nat := readTemporalColumn(t, store, got.ID); nat != temporal { + t.Fatalf("temporal 列 = %q, want %q", nat, temporal) + } + }) + } +} + +// TestTemporalUnknownFallsThroughToRules 钉住验收点③,也是最容易做偏的一条: +// `unknown`(以及未分型的空串)**不许自己判 temp** —— 它必须原样交给 N1~N7, +// 判据只有旧规则那一份。 +// +// 三条断言方向不同,缺一条都测不出「方向反了」: +// +// 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:命中的 +// 规则码必须是 `n6_timepoint`,**绝不能**是时间性位的 `temporal_one_off` —— +// 后者出现就说明有人把「看起来像今日事」变成了新的判据; +// 2. `unknown` + 旧规则非命中的正文 ⇒ long(不许因为看着像一次性就判 temp); +// 3. 未分型(空串)与 `unknown` 走同一条路(历史行的行为逐字不变)。 +func TestTemporalUnknownFallsThroughToRules(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("unknown_旧规则命中", func(t *testing.T) { + item := habitItem("今天下雨了", tidyTestTime(t), 0.9) + item.Temporal = TemporalUnknown + verdict, err := resolveRetention(item) + 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) + } + }) + + t.Run("unknown_旧规则非命中", func(t *testing.T) { + store := newTestStore(t) + item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9) + item.Temporal = TemporalUnknown + got, err := store.Add(ctx, item) + if err != nil { + t.Fatalf("Add(unknown): %v", err) + } + tier, expires := readRetentionColumns(t, store, got.ID) + if tier != RetentionLong || expires != "" { + t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 「看着像今日事」不许判 temp", + tier, expires, RetentionLong) + } + }) + + t.Run("未分型_与unknown同路", func(t *testing.T) { + for _, temporal := range []TemporalKind{TemporalUnset, TemporalUnknown} { + item := habitItem("谢谢啦", tidyTestTime(t), 0.9) + item.Temporal = temporal + verdict, err := resolveRetention(item) + if err != nil { + t.Fatalf("resolveRetention(%q): %v", temporal, err) + } + if verdict.Tier != RetentionTemp || verdict.Rule != retentionChatter { + t.Fatalf("temporal=%q 时 %q 判成 %q/%q, want temp/%q(历史行的行为必须逐字不变)", + temporal, item.Content, verdict.Tier, verdict.Rule, retentionChatter) + } + } + }) +} + +// 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.Temporal = TemporalOneOff + item.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1") + got, err := store.Add(context.Background(), item) + if err != nil { + t.Fatalf("Add: %v", err) + } + tier, expires := readRetentionColumns(t, store, got.ID) + if tier != RetentionTierUnset || expires != "" { + t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires) + } +} + +// 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) { + 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) + } + }) + } +} + +// 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.Temporal = TemporalOneOff + if !temporalSanityVeto(tc.content) { + t.Fatalf("temporalSanityVeto(%q) = false(%s)", tc.content, tc.why) + } + verdict, err := resolveRetention(item) + if err != nil { + t.Fatalf("resolveRetention: %v", err) + } + if verdict.Tier == RetentionTemp { + t.Fatalf("%q 因 temporal=one_off 被判成 temp —— %s,删掉是误弃", tc.content, tc.why) + } + }) + } +} + +// TestNormalizeTemporal 钉住验收点④的归一化口径:**只认四个字面值**,其余 +// (空 / 中文 / 自造 / 拼写变体)一律 unknown。 +// +// 不做同义词映射是刻意的:猜同义词的代价是单向的(猜成 one_off 就删记忆), +// 而「拿不准一律 long」才是 fail-safe。 +func TestNormalizeTemporal(t *testing.T) { + t.Parallel() + cases := []struct { + raw string + want TemporalKind + }{ + {"one_off", TemporalOneOff}, + {"ONE_OFF", TemporalOneOff}, + {" durable \n", TemporalDurable}, + {"recurring", TemporalRecurring}, + {"unknown", TemporalUnknown}, + {"", TemporalUnknown}, + {" ", TemporalUnknown}, + {"一次性", TemporalUnknown}, + {"one-off", TemporalUnknown}, + {"oneoff", TemporalUnknown}, + {"forever", TemporalUnknown}, + } + for _, tc := range cases { + if got := normalizeTemporal(tc.raw); got != tc.want { + t.Fatalf("normalizeTemporal(%q) = %q, want %q", tc.raw, got, tc.want) + } + } +} + +// TestTemporalKindClosedSet 钉住闭集本身:四个业务值 + 未分型都合法,其他都不合法 +// (Add 的 validate 会拒掉它们 —— 那是程序错误,不是模型输出)。 +func TestTemporalKindClosedSet(t *testing.T) { + t.Parallel() + for _, ok := range []TemporalKind{TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown} { + if !ok.valid() { + t.Fatalf("%q 应在闭集内", ok) + } + } + for _, bad := range []TemporalKind{"one-off", "sometimes", "ONE_OFF", "float"} { + if bad.valid() { + t.Fatalf("%q 不应在闭集内", bad) + } + } + if TemporalUnknown.assigned() != true || TemporalUnset.assigned() != false { + t.Fatal("assigned() 只应对非空串为真(「未分型」的唯一判据)") + } + // 非法值必须被写入路径拒掉(不是静默落库成脏列值)。 + item := habitItem("一条正文", tidyTestTime(t), 0.9) + item.Temporal = TemporalKind("sometimes") + if err := item.normalize(); err == nil { + t.Fatal("validate 接受了非法的时间性位(应返回 ErrInvalidTemporal)") + } +} + +// TestLLMExtractorReadsTemporal 钉住验收点④的另一半:提取器**真的**在读模型给的 +// 这一位,且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。 +func TestLLMExtractorReadsTemporal(t *testing.T) { + t.Parallel() + adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) { + return model.Response{Content: `[ + {"content":"候选一","temporal":"one_off"}, + {"content":"候选二","temporal":"durable"}, + {"content":"候选三","temporal":"Recurring"}, + {"content":"候选四","temporal":"forever"}, + {"content":"候选五"} + ]`}, nil + }} + ex := NewLLMExtractor(adapter, "persona.a", "role.1") + items, err := ex.Extract(context.Background(), []model.Message{ + model.TextMessage(model.RoleUser, "随便聊两句"), + }) + if err != nil { + t.Fatalf("Extract: %v(非法 temporal 绝不能让整批提取失败)", err) + } + want := map[string]TemporalKind{ + "候选一": TemporalOneOff, + "候选二": TemporalDurable, + "候选三": TemporalRecurring, + "候选四": TemporalUnknown, + "候选五": TemporalUnknown, + } + if len(items) != len(want) { + t.Fatalf("候选数 = %d, want %d", len(items), len(want)) + } + for _, it := range items { + if it.Temporal != want[it.Content] { + t.Fatalf("%q 的 temporal = %q, want %q", it.Content, it.Temporal, want[it.Content]) + } + } +} + +// TestExtractionPromptCarriesTemporalEnum 钉住「提示词与闭集不漂移」:模型能看到的 +// 取值必须与 normalizeTemporal 认的四个值逐字一致(提示词只是辅助,但取值口径漂了 +// 就会让每条候选都被归成 unknown,这一位等于白加)。 +func TestExtractionPromptCarriesTemporalEnum(t *testing.T) { + t.Parallel() + for _, want := range []string{"temporal", "one_off", "recurring", "durable", "unknown"} { + if !strings.Contains(extractSystemPrompt, want) { + t.Fatalf("提取提示词里缺 %q —— 模型无从给出这一位", want) + } + } +} From 3b9ae3f918825b3e396cfc0adf0e45cb2ff4a252 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 19:15:08 +0800 Subject: [PATCH 2/4] =?UTF-8?q?refactor(memory):=20M45=20r1=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E8=BF=94=E5=B7=A5=20=E2=80=94=E2=80=94=20temporal=20?= =?UTF-8?q?=E4=BD=8D=E5=8F=AA=E6=94=BE=E5=BC=80=20N6=20=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 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 全绿。 --- internal/runtime/memory/extract_retention.go | 88 +++-- .../runtime/memory/extract_retention_test.go | 20 +- .../extract_retention_transient_test.go | 22 +- internal/runtime/memory/retention.go | 20 +- internal/runtime/memory/store.go | 28 +- internal/runtime/memory/store_temporal.go | 40 ++ internal/runtime/memory/temporal.go | 194 ++++----- .../runtime/memory/temporal_migration_test.go | 2 +- internal/runtime/memory/temporal_test.go | 368 +++++++++--------- 9 files changed, 412 insertions(+), 370 deletions(-) create mode 100644 internal/runtime/memory/store_temporal.go diff --git a/internal/runtime/memory/extract_retention.go b/internal/runtime/memory/extract_retention.go index 669aed1..0063fde 100644 --- a/internal/runtime/memory/extract_retention.go +++ b/internal/runtime/memory/extract_retention.go @@ -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 的实测反例)。 diff --git a/internal/runtime/memory/extract_retention_test.go b/internal/runtime/memory/extract_retention_test.go index 4ebe6f4..3d5e9c8 100644 --- a/internal/runtime/memory/extract_retention_test.go +++ b/internal/runtime/memory/extract_retention_test.go @@ -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) } diff --git a/internal/runtime/memory/extract_retention_transient_test.go b/internal/runtime/memory/extract_retention_transient_test.go index bbc3930..5306666 100644 --- a/internal/runtime/memory/extract_retention_transient_test.go +++ b/internal/runtime/memory/extract_retention_transient_test.go @@ -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) } diff --git a/internal/runtime/memory/retention.go b/internal/runtime/memory/retention.go index c087a6c..6225003 100644 --- a/internal/runtime/memory/retention.go +++ b/internal/runtime/memory/retention.go @@ -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 diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index 395d912..9b829b6 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -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 } diff --git a/internal/runtime/memory/store_temporal.go b/internal/runtime/memory/store_temporal.go new file mode 100644 index 0000000..3ccfcce --- /dev/null +++ b/internal/runtime/memory/store_temporal.go @@ -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) +} diff --git a/internal/runtime/memory/temporal.go b/internal/runtime/memory/temporal.go index f47da45..87354d6 100644 --- a/internal/runtime/memory/temporal.go +++ b/internal/runtime/memory/temporal.go @@ -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 -} diff --git a/internal/runtime/memory/temporal_migration_test.go b/internal/runtime/memory/temporal_migration_test.go index 7c881de..540435c 100644 --- a/internal/runtime/memory/temporal_migration_test.go +++ b/internal/runtime/memory/temporal_migration_test.go @@ -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 { diff --git a/internal/runtime/memory/temporal_test.go b/internal/runtime/memory/temporal_test.go index 40a6202..b14abb1 100644 --- a/internal/runtime/memory/temporal_test.go +++ b/internal/runtime/memory/temporal_test.go @@ -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 }{ - {"主人有每天吃早餐的习惯,几乎不会省略早餐。", "习惯词(反复发生,与一次性互斥)"}, - {"用户近期住宿舍(昨天住宿舍,可能今晚还在),需要关心住宿情况。", "硬性长期事实词(居住)"}, - {"茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了个小铃铛。", "状态改变(新状态会持续)"}, - {"主人昨天说以后都叫我小叶", "永久化标记(自此刻起一直成立)"}, + item := habitItem(temporalWideTimepointCorpus()[0], base, 0.9) + item.Temporal = TemporalOneOff + got, err := store.Add(context.Background(), item) + if err != nil { + t.Fatalf("Add: %v", err) } - 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 - if !temporalSanityVeto(tc.content) { - t.Fatalf("temporalSanityVeto(%q) = false(%s)", tc.content, tc.why) - } - verdict, err := resolveRetention(item) - if err != nil { - t.Fatalf("resolveRetention: %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") From c7c0294a16141880fd2ec0695d502632ff4c0431 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 19:21:24 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(memory):=20M45=20=E8=A3=81?= =?UTF-8?q?=E5=86=B3=20D=20=E2=80=94=E2=80=94=20temporal=20=E4=BD=8D?= =?UTF-8?q?=E6=92=A4=E5=87=BA=E5=88=A4=E5=AE=9A=EF=BC=8C=E5=8F=AA=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E8=A7=82=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 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 全绿。 --- internal/runtime/memory/extract_retention.go | 74 +-- .../runtime/memory/extract_retention_test.go | 20 +- .../extract_retention_transient_test.go | 22 +- internal/runtime/memory/llmextractor.go | 19 +- internal/runtime/memory/preinsert.go | 13 +- .../runtime/memory/preinsert_defer_test.go | 10 +- internal/runtime/memory/retention.go | 27 +- internal/runtime/memory/store.go | 15 +- internal/runtime/memory/temporal.go | 121 ++--- .../runtime/memory/temporal_migration_test.go | 2 +- internal/runtime/memory/temporal_test.go | 508 ++++++------------ 11 files changed, 280 insertions(+), 551 deletions(-) diff --git a/internal/runtime/memory/extract_retention.go b/internal/runtime/memory/extract_retention.go index 0063fde..d132789 100644 --- a/internal/runtime/memory/extract_retention.go +++ b/internal/runtime/memory/extract_retention.go @@ -67,14 +67,6 @@ 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)。 @@ -109,15 +101,14 @@ const ( // 的 N1~N5 与 M34 新增的 N6/N7)。命中返回该规则码(调用方据此定 temp 档), // 否则返回 retentionNone。 // -// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文与**时间性元数据位** -// (temporal,迁移 0027),出参只有规则码,因此在写路径上是微秒级且可完全单测 -// (design/03 §10.3 A 级的「零成本」要求)。`temporal` 的作用**只有一处**:把 N6 的 -// 位置判据放开(见 judgeRetentionTransient),其余分支一字不看它 —— 判定链没有 -// 第二个入口,这一点由 retention_test.go 的源码级断言钉住。 -// +// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码, +// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。 +// 🔴 时间性元数据位(temporal,迁移 0027)**不是**它的入参:r1 复审裁决 D 之后 +// 这一位只观测、不参与判定(见 retention.go 的 resolveRetention 头注与 temporal.go)。 // 🔴 全仓**唯一**调用点是 `resolveRetention`(retention.go):任何地方再跑一遍 -// 就是「两套规则各判一遍」—— 那正是 M27 体检发现缺口(1082 次只判出 1 条)的成因。 -func judgeExtractionRetention(content string, temporal TemporalKind) retentionRule { +// 就是「两套规则各判一遍」—— 那正是 M27 体检发现缺口(1082 次只判出 1 条)的成因, +// retention_test.go 有一条源码级断言钉住这个唯一性。 +func judgeExtractionRetention(content string) retentionRule { text := normalizeRetentionText(content) if text == "" { // 空 / 纯空白内容在 Add 的 normalize 就被拒了,走不到这里;真走到了也只 @@ -177,7 +168,7 @@ func judgeExtractionRetention(content string, temporal TemporalKind) retentionRu // N6/N7:**指示性时点 / 进行中状态**的窄规则(2026-10-24 新增,M27 §4 R4)。 // 它们与 N1~N5 同性质(纯词项 + 形态),但判的是「这份记忆的锚点是一个正在 // 过去/即将到来的时刻」,而不是「这句话没有信息」。判据与安全阀见各自函数。 - if rule := judgeRetentionTransient(text, temporal); rule != retentionNone { + if rule := judgeRetentionTransient(text); rule != retentionNone { return rule } return retentionNone @@ -270,16 +261,14 @@ func judgeRetentionOneOff(text string) retentionRule { // ~0:`用户是上班族,需要上班(曾说明天要上班并感到不开心)`、`睡前会道晚安… // 并约好「明天中午见」` 这类「时点词只出现在引语/从句里」的长期事实,锚点 // 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。 -// 🆕 **M45 起这一条是有条件的**:`temporal=one_off` 时位置判据不生效(时点词 -// 出现在中段也算锚点,见 judgeRetentionTimepointWide)。上面 1~4、6、7 条语义 -// 安全阀**不受这一位影响** —— 那是 r1 复审的裁定:这一位只接管「词在句子的哪个 -// 位置」,不接管「这句话是不是一次性事件」。 +// 🆕 **M45 曾把这一条接到时间性元数据位上,r1 复审裁决 D 又撤掉了**(原因见 +// temporal.go 头注:位置放宽能翻的行逐条都是长期约定)。因此位置判据对**每一行** +// 无条件生效,与 0027 之前逐字相同。 // // **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终 // (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致** // (r1 复审 P2-2:`用户会主动维护…并告诉助手她正在维护…` 里的「正在」只出现在从句, -// 是「会…」的习惯陈述,不许开火)。N7 的位置判据**不受**时间性位影响(M45 只放开 -// N6):`one_off` + 「正在」出现在从句里仍然是习惯陈述,没有放开的理由。 +// 是「会…」的习惯陈述,不许开火)。 // // 🔴 **刻意不做的事**(M27 §4 的实测反例,别再重新发明): // - 不做**长度阈值**筛(R3:≤15 字的条目抽 8 条 8/8 是可复用事实); @@ -287,7 +276,7 @@ func judgeRetentionOneOff(text string) retentionRule { // - 不做**一次性事件动词表**(曾/发来/分享…):实测会命中「助手曾经历三千万次 // 轮回」「用户曾发来『i love you』文件」「海报就是助手本人」这类**长期身份与 // 关系里程碑**(抽检误杀 ≈ 50%),方向错误。 -func judgeRetentionTransient(text string, temporal TemporalKind) retentionRule { +func judgeRetentionTransient(text string) retentionRule { // 安全阀 1~4:与 N1~N5 不同,它们在这里是**硬门**(命中即放行),因为 N6/N7 // 判的是「这条记忆的锚点是一个时刻」,而锚点词/数值/约束都在说「它不止是一个时刻」。 if containsAny(text, tidyHabitMarkers()) { @@ -318,18 +307,8 @@ func judgeRetentionTransient(text string, temporal TemporalKind) retentionRule { if containsAny(text, retentionChatterWords()) || containsAny(text, retentionEmotionWords()) { return retentionNone } - // 安全阀 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 judgeRetentionTimepoint(text) != retentionNone { + return retentionTimepoint } if judgeRetentionInProgress(text) != retentionNone { return retentionInProgress @@ -349,29 +328,6 @@ 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 的实测反例)。 diff --git a/internal/runtime/memory/extract_retention_test.go b/internal/runtime/memory/extract_retention_test.go index 3d5e9c8..4ebe6f4 100644 --- a/internal/runtime/memory/extract_retention_test.go +++ b/internal/runtime/memory/extract_retention_test.go @@ -18,14 +18,6 @@ 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 { @@ -126,8 +118,8 @@ func TestExtractionRetention_EmotionWithObjectIsNotDeferred(t *testing.T) { t.Fatalf("%q:余部为空 = %t 与 wantDefer = %t 不一致", tc.content, rest == "", tc.wantDefer) } - if rule := judgeRetentionNoTemporal(tc.content); (rule == retentionEmotion) != tc.wantDefer { - t.Fatalf("judgeRetentionNoTemporal(%q) = %q, wantEmotionDefer=%t —— 带宾语的与纯情绪的必须给出相反结论", + if rule := judgeExtractionRetention(tc.content); (rule == retentionEmotion) != tc.wantDefer { + t.Fatalf("judgeExtractionRetention(%q) = %q, wantEmotionDefer=%t —— 带宾语的与纯情绪的必须给出相反结论", tc.content, rule, tc.wantDefer) } }) @@ -169,7 +161,7 @@ func TestExtractionRetention_PositivesAreNeverDeferred(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - if rule := judgeRetentionNoTemporal(tc.content); rule != retentionNone { + if rule := judgeExtractionRetention(tc.content); rule != retentionNone { t.Fatalf("正例 %q 被拦成 %q —— 那是把真记忆丢进临时层(fail-safe 方向被违反)", tc.content, rule) } @@ -185,7 +177,7 @@ func TestExtractionRetention_NegativesAreDeferred(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - if rule := judgeRetentionNoTemporal(tc.content); rule != tc.rule { + if rule := judgeExtractionRetention(tc.content); rule != tc.rule { t.Fatalf("反例 %q 命中 %q, want %q", tc.content, rule, tc.rule) } }) @@ -197,7 +189,7 @@ func TestExtractionRetention_NegativesAreDeferred(t *testing.T) { func TestExtractionRetention_UpperCaseASCII(t *testing.T) { t.Parallel() for _, content := range []string{"HI", "Thanks", "hello~"} { - if rule := judgeRetentionNoTemporal(content); rule != retentionChatter { + if rule := judgeExtractionRetention(content); rule != retentionChatter { t.Fatalf("%q 命中 %q, want %q(ASCII 词应先折小写)", content, rule, retentionChatter) } } @@ -302,7 +294,7 @@ func TestExtractionRetention_PersistentFactsAreNeverDeferred(t *testing.T) { content := content t.Run(content, func(t *testing.T) { t.Parallel() - if rule := judgeRetentionNoTemporal(content); rule != retentionNone { + if rule := judgeExtractionRetention(content); rule != retentionNone { t.Fatalf("持久事实 %q 被判成 %q —— 它会进临时层、7 天后被物理删除(不可逆)", content, rule) } diff --git a/internal/runtime/memory/extract_retention_transient_test.go b/internal/runtime/memory/extract_retention_transient_test.go index 5306666..bbc3930 100644 --- a/internal/runtime/memory/extract_retention_transient_test.go +++ b/internal/runtime/memory/extract_retention_transient_test.go @@ -73,7 +73,7 @@ func retentionLongTermCases() []struct { func TestExtractionRetention_TimepointIsDeferred(t *testing.T) { t.Parallel() for _, content := range retentionTimepointCases() { - if got := judgeRetentionNoTemporal(content); got != retentionTimepoint && got != retentionInProgress { + if got := judgeExtractionRetention(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 := judgeRetentionNoTemporal("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint { + if got := judgeExtractionRetention("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint { t.Fatalf("时点句 = %q, want %q", got, retentionTimepoint) } - if got := judgeRetentionNoTemporal("用户正在验证分享确认面板的展开功能"); got != retentionInProgress { + if got := judgeExtractionRetention("用户正在验证分享确认面板的展开功能"); 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 := judgeRetentionNoTemporal(tc.content); got != retentionNone { + if got := judgeExtractionRetention(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 := judgeRetentionNoTemporal(fronted); got != retentionTimepoint { + if got := judgeExtractionRetention(fronted); got != retentionTimepoint { t.Fatalf("时点在句首 = %q, want %q", got, retentionTimepoint) } - if got := judgeRetentionNoTemporal(buried); got != retentionNone { + if got := judgeExtractionRetention(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 := judgeRetentionNoTemporal("约定下周给用户留一个安静的午后"); got != retentionTimepoint { + if got := judgeExtractionRetention("约定下周给用户留一个安静的午后"); got != retentionTimepoint { t.Fatalf("「约定 + 时点、无约束」= %q, want %q(锚点单独出现不再一票放行)", got, retentionTimepoint) } - if got := judgeRetentionNoTemporal("约定每天给主人留一个安静的午后"); got != retentionNone { + if got := judgeExtractionRetention("约定每天给主人留一个安静的午后"); got != retentionNone { t.Fatalf("「约定 + 每天(具体约束)」= %q, want 放行(组合条件命中即长期)", got) } - if got := judgeRetentionNoTemporal("主人每天要喝 2 杯咖啡"); got != retentionNone { + if got := judgeExtractionRetention("主人每天要喝 2 杯咖啡"); got != retentionNone { t.Fatalf("「偏好 + 数值」= %q, want 放行", got) } - if got := judgeRetentionNoTemporal("主人说过不要在群里提她的工作"); got != retentionNone { + if got := judgeExtractionRetention("主人说过不要在群里提她的工作"); got != retentionNone { t.Fatalf("「禁忌」= %q, want 放行", got) } } @@ -159,7 +159,7 @@ func TestExtractionRetention_TimepointSafetyValves(t *testing.T) { {"用户昨夜睡得还行,今天心情不错", "情绪词 ⇒ 归 N2 判"}, } for _, tc := range cases { - got := judgeRetentionNoTemporal(tc.content) + got := judgeExtractionRetention(tc.content) if got == retentionTimepoint || got == retentionInProgress { t.Fatalf("%q 判成 %q, want 不被 N6/N7 判临时(%s)", tc.content, got, tc.why) } diff --git a/internal/runtime/memory/llmextractor.go b/internal/runtime/memory/llmextractor.go index 735a06d..789f3b4 100644 --- a/internal/runtime/memory/llmextractor.go +++ b/internal/runtime/memory/llmextractor.go @@ -32,11 +32,12 @@ import ( // // 🆕 2026-10-27(切片 M45):prompt 里追加**时间性位** `temporal`(四个枚举值)。 // 它**不是**「又一段靠自然语言描述判据」的提示词加固:模型只负责**标注**一个值为 -// 四个枚举之一,**判定**(one_off ⇒ temp / durable ⇒ long / unknown ⇒ 走旧规则) -// 是 temporal.go 里的一段 Go 闭集 switch,落库是 `temporal` 列(迁移 0027)—— -// 因此 AGENTS §9「判据靠元数据,不靠提示词」在这里是**结构性成立**的:即便模型 -// 完全不听这段说明,最坏结果也只是它一律答 unknown(= 回到本片之前的旧规则), -// 不会产生任何新的误判路径。 +// 四个枚举之一,落库是 `temporal` 列(迁移 0027)。 +// +// 🔴 **这一位目前不参与判定**(r1 复审裁决 D):`resolveRetention` 不读它,判定的 +// 结论集与没有它逐字相同 —— 保留它只为积累「模型到底怎么标」的证据(temporal.go +// 头注记着两次被生产重放否掉的接线方案与接线前必须看的那份分布)。因此这段 prompt +// 的**唯一**作用是让观测面有数据;即便模型完全不听,判定的行为与今天一致。 const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。 只保留这些(正向清单): @@ -59,20 +60,18 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得 每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一: - "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子), - 即使你已经把它写成「用户……」这样的陈述句 —— 一次性事件标成 durable 会让它 - 永远留着,标成 one_off 会让它进临时层、到期自动消失,所以只有确实只发生过 - 一次时才标它; + 即使你已经把它写成「用户……」这样的陈述句; - "recurring":说的是**反复发生**的规律(每天 / 每周 / 每次都…); - "durable":说的是**长期成立**的事实 / 偏好 / 约定 / 称呼 / 身份; - "unknown":拿不准,或者上面三个都不贴切。 temporal 只许填这四个英文值之一(不要换成中文、不要自造新值);拿不准就填 -"unknown" —— 标错方向的代价不对称:标成 one_off 会让本该留着的记忆被提前删掉。 +"unknown"。这个标记会被记录下来供以后核对用途,如实标注即可,不要为了省字省掉它。 只输出一个 JSON 数组,不要任何其他文字。每个元素形如: {"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"} 去噪去重:只保留值得长期记住的,忽略寒暄与临时信息;确实发生过的**一次性事件** -如果值得记,也照实提取,但必须把 temporal 标成 one_off(它会进临时层)。` +如果值得记,也照实提取,并把 temporal 标成 one_off。` // llmCandidate 是提取器要求 LLM 输出的线上形状:每条候选 // 记忆一个 JSON 对象。模型未给出合法值时,Scope/Kind 取提取器配置的 diff --git a/internal/runtime/memory/preinsert.go b/internal/runtime/memory/preinsert.go index 04b0f69..473161f 100644 --- a/internal/runtime/memory/preinsert.go +++ b/internal/runtime/memory/preinsert.go @@ -97,12 +97,13 @@ const ( // 规则。**规则内容与阈值未改**(同一份 N1~N7),但**判据的作用范围变了**: // 档位与 `expires_at` 现在对**每一行**在同一笔 INSERT 里落下,且不再依赖同步闸 // 是否开启 —— 同一对条目在 d3.3 与 d3.4 下的落库形态因此可能不同,按规矩升版。 -// - `d3.5`(切片 M45)加入提取器的**时间性元数据位**(`temporal`,迁移 0027): -// `one_off` ⇒ temp(⇒ 第五态 defer)、`durable` / `recurring` ⇒ long、 -// `unknown` / 未分型 ⇒ 照旧走 N1~N7。**N1~N7 的规则内容与阈值一字未改**,但 -// 同一段正文在 d3.4 与 d3.5 下可能落到不同档位(d3.4 只能靠词判,d3.5 还能 -// 读到「这是不是一次性事件」这一位)—— 结论会变,按规矩升版,否则新旧留痕 -// 无法区分。 +// - `d3.5`(切片 M45)**记录 `temporal` 信号,但不参与判定**:提取器在产出侧多 +// 标了一维时间性位(`one_off` / `recurring` / `durable` / `unknown`,迁移 0027), +// 随同一笔 INSERT 落库。**判定的结论集与 d3.4 逐字相同**(`resolveRetention` +// 不读这一位,见 retention.go 头注:复审裁决 D —— 两次候选接线都被生产重放否掉)。 +// 那为什么仍升版?因为**留痕面变了**:同一对条目在 d3.4 与 d3.5 下虽然结论相同, +// 但 d3.5 的库里多了「模型当时标了什么」这一维可供事后归因;不升版,新旧留痕会 +// 混在同一版本号下,将来接线时无法区分「接线前的结论」与「接线后的结论」。 const IngestRuleVersion = "d3.5" // 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。 diff --git a/internal/runtime/memory/preinsert_defer_test.go b/internal/runtime/memory/preinsert_defer_test.go index fb6c106..03d08d1 100644 --- a/internal/runtime/memory/preinsert_defer_test.go +++ b/internal/runtime/memory/preinsert_defer_test.go @@ -276,13 +276,15 @@ func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) { // 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期, // 且不再依赖同步闸是否开启),因此按规矩升版。 // -// d3.5(切片 M45)给判定链补上「时间性元数据位」:`one_off` ⇒ temp、 -// `durable` / `recurring` ⇒ long、`unknown` ⇒ 旧规则。N1~N7 的规则与阈值一字未改, -// 但同一段正文的结论会变(d3.4 只能靠词判),因此同样按规矩升版。 +// d3.5(切片 M45)**记录 `temporal` 信号,但不参与判定**:提取器产出侧多标了一维 +// 时间性位(迁移 0027),随同一笔 INSERT 落库;`resolveRetention` 不读它,因此 +// 判定的结论集与 d3.4 逐字相同。仍升版是因为**留痕面变了**(库里多了「模型当时标了 +// 什么」这一维),将来接线时要能区分「接线前/后」的结论 —— 理由见 preinsert.go +// 的版本史。 func TestIngestRuleVersionIsD35(t *testing.T) { t.Parallel() if IngestRuleVersion != "d3.5" { - t.Fatalf("IngestRuleVersion = %q, want d3.5(时间性元数据位接入判定链)", IngestRuleVersion) + t.Fatalf("IngestRuleVersion = %q, want d3.5(记录 temporal 信号,但不参与判定)", IngestRuleVersion) } } diff --git a/internal/runtime/memory/retention.go b/internal/runtime/memory/retention.go index 6225003..759e32b 100644 --- a/internal/runtime/memory/retention.go +++ b/internal/runtime/memory/retention.go @@ -95,18 +95,16 @@ type retentionVerdict struct { // 2. **显式给了有效期**(`item.ExpiresAt` 非零)⇒ 这一行会到期 ⇒ 它就是临时记忆。 // 两者是同一根轴的两种表达,因此这里不是「推导」,而是同一个事实的补全。 // 3. **受限来源**(群聊 / 陌生人)⇒ 不分型(空串)。见文件头注 ②。 -// 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)。 +// 4. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。 +// 5. 其余 ⇒ long(默认落点,fail-safe)。 // -// 排在第 3 步之后的理由与它自己一致:受限来源的留存由来源面决定(后台到期清扫 -// 跳过它们,expire.go 的 `nonOwnerSource`),若时间性位把它们判成 temp,就会造出 -// 一条「对读面隐藏、却永远不会被删」的行 —— 那是本条要避免的形态。 +// 🔴 **`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 的 @@ -125,12 +123,7 @@ func resolveRetention(item MemoryItem) (retentionVerdict, error) { if nonOwnerSource(item.SourceConversation) { return retentionVerdict{Tier: RetentionTierUnset}, 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, item.Temporal); rule != retentionNone { + if rule := judgeExtractionRetention(item.Content); rule != retentionNone { return retentionVerdict{Tier: RetentionTemp, Rule: rule}, nil } return retentionVerdict{Tier: RetentionLong}, nil diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index 9b829b6..0c0ca7c 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -138,8 +138,11 @@ type MemoryItem struct { // Temporal 是**提取器给出的时间性元数据位**(迁移 0027、temporal.go): // one_off / recurring / durable / unknown,空串 = 未分型(历史行与没做时间性 - // 判断的写入路径,口径见 temporal.go 头注)。它只由提取器在**产出侧**填,判定侧 - // (resolveRetention)读它一位,不让任何规则去猜「这句话说的是不是今天的事」。 + // 判断的写入路径,口径见 temporal.go 头注)。 + // + // 🔴 **它目前只观测、不参与判定**(r1 复审裁决 D):`resolveRetention` 不读它, + // 判定的结论集与「没有这一位」逐字相同。保留它是为了积累「模型到底怎么标」的 + // 证据 —— 接线前必须先看那份分布(temporal.go 头注里给了查询)。 // // ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列, // 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点 @@ -302,10 +305,10 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) // expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。 // // 🆕 切片 M45:`item.Temporal`(时间性元数据位,迁移 0027)也随**这一笔** INSERT -// 落下(`temporal` 列)。它的判定作用发生在同一次 `resolveRetention` 里(temporal.go -// 的 temporalTier 出 long 结论 / judgeExtractionRetention 放开 N6 的位置判据,两处都 -// 在那一个函数内),列值本身是**留痕**:记录提取器当时标了什么,供事后巡检 -// 「模型答的是不是 one_off」—— 判定与留痕因此不会分叉。 +// 落下(`temporal` 列)。**它只落库、不参与判定**(r1 复审裁决 D):`resolveRetention` +// 不读这一位,因此档位/有效期与「没有这一位」逐字相同;列值的作用是**观测**—— +// 事后能按它分组统计、抽样看模型把哪些行标成了 `one_off`(这是将来决定要不要接线 +// 的唯一证据来源,见 temporal.go 头注的查询)。 func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { if err := item.normalize(); err != nil { return MemoryItem{}, err diff --git a/internal/runtime/memory/temporal.go b/internal/runtime/memory/temporal.go index 87354d6..3184ed3 100644 --- a/internal/runtime/memory/temporal.go +++ b/internal/runtime/memory/temporal.go @@ -2,86 +2,71 @@ package memory import "strings" -// 本文件是**提取器产出侧的时间性元数据位**(`temporal`,迁移 0027):它回答 -// 「这条记忆说的是**一次发生的事**,还是**反复 / 长期成立的事**」。 +// 本文件是**提取器产出侧的时间性元数据位**(`temporal`,迁移 0027):它记录模型对 +// 「这条记忆说的是**一次发生的事**,还是**反复 / 长期成立的事**」的判断。 // -// 为什么需要这一位(M37 复审的结论,逐字):「真正卡住上游的是**提取器的改写**: +// 当初为什么要这一位(M37 复审的结论,逐字):「真正卡住上游的是**提取器的改写**: // 一次性事件被写成 30~80 字的第三人称长期陈述(同时带『近期/目前』和持久词), // 规则闸看不到『这是今天的一次性事件』这一位。这不是阈值问题,是**判定链缺信息位**。」 // -// 🔴 **这一位只接管 N6 的「位置判据」**(2026-10-27 r1 复审裁定):词项规则里唯一 -// 真正不可靠的是 **N6 的「时点词必须出现在句首 12 字内」**——提取器改写出的第三人称 -// 长句本来就把时点埋在中段,窗口再放宽(12→24)就会连「同一条长期约定的重复陈述」 -// 一起命中并删掉约定(M37 已量:2→14,多出的 11 条逐条都是约定)。因此: +// 🔴 **当前状态:只观测,不参与判定**(r1 复审裁决 D,2026-10-27)。`resolveRetention` +// **不读**这一位,判定的结论集与「没有这一位」逐字相同。保留它的唯一目的是**积累 +// 证据**:等真实提取跑出一批标签之后,再回答「模型到底怎么标」以及「接进判定是赚 +// 还是亏」。 // -// - 模型说 `one_off` ⇒ **N6 忽略位置窗口**(时点词出现在哪儿都算锚点); -// - **N6 的全部语义安全阀照旧**(习惯词 / 硬性长期事实词 / 具体约束 / 永久化 / -// 状态改变 / 寒暄情绪)—— 它们判的是「这句话不止是一个时刻」,与位置无关; -// - N1~N5、N7、护栏①② 一字不动。 +// 为什么撤(两次候选接线都被生产重放否掉,别再凭直觉改回去): // -// 🔴 r1 复审推翻的两条自造设计(别再写回来): +// ① **无条件 `one_off ⇒ temp`**(任务书初版口径):最近 500 条 active 里人工按 +// 提取器口径标出的 23 条 `one_off`,其中 21 条是「约定 + 某一次没走成」形态 ⇒ +// 7 天后**把约定本身物理删掉**(不可撤回)。 +// ② **只放开 N6 的位置判据**(r1 复审给的改法):能翻的行在窗口内 13 条、**逐条都是 +// 约定**(`约定/约好 + 今天没走成`),而复审点名的三条目标行 **0/3** 救得了 +// (`…睡得比平时早` 被习惯词、`…以后不用再惦记` 被永久化标记、`自行车链条` 那条 +// 没有时点词)⇒ 收益为 0、风险集中在长期约定。 // -// ① **不要**给 `one_off` 再叠一层「约定/长期事实」词表否决 —— 它既把该救的行一起 -// 杀了(「昨晚…睡得比平时早」被习惯词杀掉、「今天就终于把 bug 修好了,以后不用 -// 再惦记」被永久化标记杀掉,而这两条正是这一位该救的),又挡不住同义异形的 -// 约定(`说定`/`约了` 不在任何词表里)—— 词面否决在**两个方向**上都不安全; -// ② **不要**用「句首时点词且不触发任何阀门」当上界 —— 那种行 N6 本来就判 temp, -// 拿它当上界是同义反复,恰好把本片要救的「时点词不在句首」整类排除在外。 +// 还有一层方法论上的理由:以上两轮数字**全部**建立在「对历史改写正文**人工**标注」 +// 上 —— 生产库里 `temporal` 列当时还不存在,模型从未在这批正文上产出过标签。 +// 用人工标签去决定一条「模型标签驱动」的接线,证据链是断的。 // -// ⚠️ **已知代价(2026-10-27 生产重放实测,r1 复审要求如实登记,不许美化)**: -// 位置窗口今天**正是**那批「约定 + 某一次没走成」行的唯一保护(它们的时点词都埋在 -// 中段)。放开位置之后: +// 将来真要接线,前置条件是**这份数据**(不是又一次重放): // -// - 最近 500 条 active 里**能翻的 13 条逐条都是这种约定**(`约定/约好 + 今天没走成`); -// - 全量 3694 条里能翻 26 条,其中 13 条是约定,另 13 条是「项目/宿舍/换装/探针」 -// 这类状态行; -// - 而 r1 复审点名的两条目标行(`昨晚…睡得比平时早` 被习惯词、 -// `今天就终于把 bug 修好了,以后不用再惦记` 被永久化标记)**本片救不了** —— -// 它们是被语义安全阀挡的,与位置无关。 +// SELECT temporal, COUNT(*) FROM memory_items GROUP BY temporal; +// SELECT created_at, retention_tier, content FROM memory_items +// WHERE temporal = 'one_off' ORDER BY created_at DESC LIMIT 10; // -// 也就是说:**在当前语料上,这一位没有可测收益,风险集中在长期约定上。** 是否保留 -// 是产品裁决(M45 报告 §决策矩阵),不是代码判断;本文件只保证「放开位置的边界」是 -// 确定的、可测的、可回退的。 +// 看 `one_off` 那 10 条里有多少是长期约定 —— 那才是「这一位能不能用」的判据。 // // 闭集(与迁移 0027 的列值口径逐字一致): // -// one_off 一次性事件(今天 / 这次 / 某一次发生的事)⇒ 放开 N6 的位置判据 -// recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long(不进规则) -// durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long(不进规则) -// unknown 模型拿不准,或没给出合法值 ⇒ **没有结论**,规则照旧 -// '' 尚未分型 ⇒ 与 unknown 同路(规则照旧)。它出现在两类行上: -// ① 0027 之前的历史行; -// ② **没做时间性判断的写入路径** —— `ManualExtractor`(「记住 X」, -// 它把档位显式钉成 long,见 extract.go)、网关/管理面的手写记忆 -// (`RetentionTier: long`)、整理侧回填。这些路径本来就由调用方 -// 显式给档位,因此空串在这里不丢信息(档位判定第 1 步先命中)。 +// one_off 一次性事件(今天 / 这次 / 某一次发生的事) +// recurring 反复发生的规律(每天 / 每周 / 每次都…) +// durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 +// unknown 模型拿不准,或没给出合法值 +// '' 尚未分型:0027 之前的历史行 + 没做时间性判断的写入路径 +// (`ManualExtractor` 的「记住 X」、网关/管理面手写、整理侧回填 —— +// 它们本来就由调用方显式给档位) // -// 🔴 `unknown` 与 `”` 在判定上**走同一条路**(规则照旧),但在口径上是两件事: -// `unknown` 是「问过模型了,它拿不准」;`”` 是「这条路径没问过」。把两者折成一个值 -// 会让「模型到底答了什么」这个观测面当场消失,而那正是本片要验证的东西。 +// 🔴 `unknown` 与 `”` 是两件事:`unknown` 是「问过模型了,它拿不准」;`”` 是 +// 「这条路径没问过」。把两者折成一个值会让「模型到底答了什么」这个观测面当场消失, +// 而那正是保留这一位的全部意义。 type TemporalKind string const ( // TemporalUnset 是「尚未分型」:历史行与没做时间性判断的写入路径(见文件头注)。 - // **它不是业务取值**,判定上与 TemporalUnknown 同路,但含义不同。 + // **它不是业务取值**,也**不是**「unknown」的同义词。 TemporalUnset TemporalKind = "" - // TemporalOneOff 是一次性事件。它是本片唯一会**收紧**判定的时间性位:命中即 - // 让 N6 忽略位置窗口(时点词出现在句子中段也算锚点)。语义安全阀照旧,因此它 - // 仍然不是「无条件判 temp」——这一点是 r1 复审的裁定,别再改回去。 + // TemporalOneOff 是一次性事件。它目前**不改变任何判定**:写入时只落库, + // 供事后统计「模型把哪些行标成了某一次发生的事」。 TemporalOneOff TemporalKind = "one_off" // TemporalRecurring 是反复发生的规律(「每天散步十分钟」「每周三开会」)。 - // 「反复」= 将来还会再发生,因此不许进临时层:命中即判 long,不再跑规则。 TemporalRecurring TemporalKind = "recurring" - // TemporalDurable 是长期成立的事实 / 偏好 / 约定 / 称呼 / 身份。同样判 long、 - // 不再跑规则(方向仍是 fail-safe:多留一条)。 + // TemporalDurable 是长期成立的事实 / 偏好 / 约定 / 称呼 / 身份。 TemporalDurable TemporalKind = "durable" - // TemporalUnknown 是「问过模型了,它拿不准」。**它绝不是「一次性」的同义词**: - // 判定上它与未分型一样落到 N1~N7,不许因为「正文看着像今天的事」就判 temp —— - // 那等于把本片要消灭的猜测重新引回判定链。 + // TemporalUnknown 是「问过模型了,它拿不准」。 TemporalUnknown TemporalKind = "unknown" ) @@ -100,21 +85,12 @@ 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」→ 都不认): // 认不出来的答案就是解析失败,一律落 TemporalUnknown —— 这正是任务书 -// 「模型只能选那四个值,解析失败一律归 unknown(绝不猜)」的字面实现。 -// 猜同义词的代价是单向的:猜成 one_off 就删记忆,猜成别的只是多留一条。 +// 「模型只能选那四个值,解析失败一律归 unknown(绝不猜)」的字面实现。将来接线时 +// 这条尤其重要:猜同义词的代价是单向的(猜成 one_off 就删记忆)。 func normalizeTemporal(s string) TemporalKind { switch TemporalKind(strings.ToLower(strings.TrimSpace(s))) { case TemporalOneOff: @@ -130,20 +106,3 @@ func normalizeTemporal(s string) TemporalKind { return TemporalUnknown } } - -// temporalTier 给出**模型直接给出的档位结论**(只由 resolveRetention 第 4 步调用)。 -// -// 只有 `durable` / `recurring` 有结论(long):模型已经回答了「这不是一次性事件」, -// 此时再让词项规则判 temp 就是把「判定链缺的那一位」又丢掉;方向是 fail-safe(多留)。 -// -// `one_off` **不在这里**出结论 —— 它作为**输入**进规则层(放开 N6 的位置判据), -// 语义安全阀照旧(r1 复审裁定,见文件头注);`unknown` / 未分型同样没有结论, -// 规则照旧。 -func temporalTier(t TemporalKind) (RetentionTier, bool) { - switch t { - case TemporalDurable, TemporalRecurring: - return RetentionLong, true - default: - return RetentionTierUnset, false - } -} diff --git a/internal/runtime/memory/temporal_migration_test.go b/internal/runtime/memory/temporal_migration_test.go index 540435c..369cfa2 100644 --- a/internal/runtime/memory/temporal_migration_test.go +++ b/internal/runtime/memory/temporal_migration_test.go @@ -184,7 +184,7 @@ func TestMigration0027ThenStoreBootstrapIsARegularPath(t *testing.T) { t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after) } - item := habitItem(temporalWideTimepointCorpus()[0], tidyTestTime(t), 0.9) + item := habitItem("主人有一条真实落库的观测样本", tidyTestTime(t), 0.9) item.Temporal = TemporalOneOff got, err := store.Add(ctx, item) if err != nil { diff --git a/internal/runtime/memory/temporal_test.go b/internal/runtime/memory/temporal_test.go index b14abb1..9d775c4 100644 --- a/internal/runtime/memory/temporal_test.go +++ b/internal/runtime/memory/temporal_test.go @@ -4,30 +4,172 @@ import ( "context" "strings" "testing" - "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集。 // -// 🔴 这一位的职责边界(r1 复审裁定,决定了下面每条断言的写法): +// 🔴 当前契约(r1 复审裁决 D,2026-10-27):**这一位只观测,不参与判定。** +// `resolveRetention` 不读它,判定的结论集与「没有这一位」逐字相同。因此本文件最重要 +// 的一条断言是**零影响**(TestTemporalBitDoesNotAffectRetention):同一段正文, +// 无论 `temporal` 是 five 个取值里的哪一个,档位与规则码都必须完全相同。 // -// 它**只接管 N6 的位置判据** —— 模型说「这是某一次发生的事」⇒ 时点词埋在句子中段 -// 也算锚点。N6 的**全部语义安全阀照旧**(习惯词 / 硬性长期事实词 / 具体约束 / -// 永久化 / 状态改变 / 寒暄情绪),N1~N5、N7、护栏①② 一字不动。 -// -// 因此「`one_off` ⇒ 一定 temp」**不是**本片的语义(那是任务书初版口径,r1 复审用 -// 生产语料推翻了:按它实现会当天删掉 21 条长期约定)。下面的用例逐条钉住的是 -// 「放开位置后能翻哪些行」与「翻不了哪些行」——后者同样是断言,因为它们是**已知的 -// 边界**,不是待修的缺陷。 +// 变异靶子与之配套:谁把这一位重新接进 `resolveRetention`(例如先加一句 +// `if item.Temporal == TemporalOneOff { return temp }`),那条正断言必红 —— +// 这不是「测试太严」,而是「接线前必须先有模型真实标签分布」这个裁决的守卫。 -// temporalWideTimepointCorpus 是**只有放开位置判据才会命中**的夹具语料:时点词都在 -// 12 字之后(或句中没有),语义安全阀全部不触发。 -func temporalWideTimepointCorpus() []string { +// temporalObservationCorpus 覆盖三类正文,每类都必须对 temporal 免疫: +// +// ① 旧规则判 long 的行(接线的「收益」候选); +// ② 旧规则判 temp 的行(`durable` 也不能把它们救回来 —— 零影响是双向的); +// ③ 受限来源 / 显式档位这类「更早的分支」(它们更不该被这一位碰到)。 +func temporalObservationCorpus() []string { return []string{ - "桌面上的文件整理到一半,今天下午才收尾", - "主人把桌面上的文件整理到一半,昨天被别的事打断了", + "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。", + "主人有每天吃早餐的习惯,几乎不会省略早餐。", + "今天下雨了", + "谢谢啦", + } +} + +// temporalAllKinds 是「业务上可能出现的全部取值」+ 未分型。 +func temporalAllKinds() []TemporalKind { + return []TemporalKind{ + TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown, + } +} + +// TestTemporalBitDoesNotAffectRetention 是本文件的核心断言:**temporal 对留存判定零影响** +// ——对同一段正文,五个取值的 `(档位, 规则码)` 必须逐字相同(且都以未分型为基准)。 +func TestTemporalBitDoesNotAffectRetention(t *testing.T) { + t.Parallel() + for _, content := range temporalObservationCorpus() { + content := content + t.Run(content, func(t *testing.T) { + base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) + if err != nil { + t.Fatalf("resolveRetention(base): %v", err) + } + for _, temporal := range temporalAllKinds() { + item := habitItem(content, tidyTestTime(t), 0.9) + item.Temporal = temporal + got, err := resolveRetention(item) + if err != nil { + t.Fatalf("resolveRetention(temporal=%q): %v", temporal, err) + } + if got != base { + t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位必须零影响;"+ + "接线前必须先拿到模型真实标签分布,见 temporal.go 头注)", + content, temporal, got, base) + } + } + }) + } +} + +// TestTemporalOneOffDoesNotMakeAnythingTemporary 是上一条的正向版本,单独成例是为了 +// 让「接线会怎样」在失败信息里一眼可见:它只喂 `one_off`,并点名断言「旧规则判 long +// 的行仍然 long」。 +func TestTemporalOneOffDoesNotMakeAnythingTemporary(t *testing.T) { + t.Parallel() + const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。" + base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) + if err != nil { + t.Fatalf("resolveRetention(base): %v", err) + } + if base.Tier != RetentionLong { + t.Fatalf("前提不成立:%q 旧规则判 %q(want long)", content, base.Tier) + } + item := habitItem(content, tidyTestTime(t), 0.9) + item.Temporal = TemporalOneOff + got, err := resolveRetention(item) + if err != nil { + t.Fatalf("resolveRetention(one_off): %v", err) + } + if got.Tier != RetentionLong || got.Rule != retentionNone { + t.Fatalf("temporal=one_off 把 %q 判成 %+v —— 这一位不得参与判定(裁决 D)", content, got) + } +} + +// TestTemporalIsStillRecorded 钉住「撤接线不等于撤观测」:`temporal` 仍随同一笔 +// INSERT 落库(迁移 0027 的列),而档位/有效期保持旧规则的结论 —— 这正是 D 要的 +// 数据来源:全库按 temporal 分组统计、抽样看 one_off 到底长什么样。 +func TestTemporalIsStillRecorded(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。" + item := habitItem(content, tidyTestTime(t), 0.9) + item.Temporal = TemporalOneOff + got, err := store.Add(ctx, item) + if err != nil { + t.Fatalf("Add: %v", err) + } + if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff { + t.Fatalf("temporal 列 = %q, want %q(观测面必须保留)", nat, TemporalOneOff) + } + tier, expires := readRetentionColumns(t, store, got.ID) + if tier != RetentionLong || expires != "" { + t.Fatalf("落库档位/时效 = (%q, %q), want (long, 空) —— 观测不得改变判定", + tier, expires) + } +} + +// TestTemporalDoesNotRescueRuleHitRows 钉住零影响是**双向**的:`durable` 曾经(M45 +// 初版)能把规则判 temp 的行救回 long;裁决 D 之后这条也取消 —— 判定只由 N1~N7 决定。 +func TestTemporalDoesNotRescueRuleHitRows(t *testing.T) { + t.Parallel() + const content = "今天下雨了" + base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) + if err != nil { + t.Fatalf("resolveRetention(base): %v", err) + } + if base.Tier != RetentionTemp { + t.Fatalf("前提不成立:%q 旧规则判 %q(want temp)", content, base.Tier) + } + for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} { + item := habitItem(content, tidyTestTime(t), 0.9) + item.Temporal = temporal + got, err := resolveRetention(item) + if err != nil { + t.Fatalf("resolveRetention(%q): %v", temporal, err) + } + if got != base { + t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位不得推翻规则)", + content, temporal, got, base) + } + } +} + +// TestTemporalDoesNotTouchExplicitTierOrRestrictedSource 钉住「更早的分支」不被这一位 +// 影响:显式档位(人工入口)与受限来源(群聊/陌生人)都排在第 1/3 步。 +func TestTemporalDoesNotTouchExplicitTierOrRestrictedSource(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + + explicit := habitItem("谢谢啦", tidyTestTime(t), 0.9) + explicit.RetentionTier = RetentionLong + explicit.Temporal = TemporalOneOff + got, err := store.Add(ctx, explicit) + if err != nil { + t.Fatalf("Add(explicit long): %v", err) + } + if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionLong { + t.Fatalf("显式 long + one_off 落库 = %q, want long(调用方显式档位不许被推翻)", tier) + } + + restricted := habitItem("今天下雨了", tidyTestTime(t), 0.9) + restricted.Temporal = TemporalOneOff + restricted.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1") + got, err = store.Add(ctx, restricted) + if err != nil { + t.Fatalf("Add(restricted): %v", err) + } + tier, expires := readRetentionColumns(t, store, got.ID) + if tier != RetentionTierUnset || expires != "" { + t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires) } } @@ -43,321 +185,11 @@ func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind { return TemporalKind(raw) } -// TestTemporalOneOffWidensTimepointPosition 钉住本片**唯一**的判定效果(也是变异靶子): -// 时点词在句中时,`temporal=one_off` ⇒ N6 照常命中(规则码 `n6_timepoint_wide`)⇒ -// 全链落库 `(temp, 非空 expires_at)`;而**同一段正文**在未分型时是 long。 +// TestNormalizeTemporal 钉住归一化口径:**只认四个字面值**,其余(空 / 中文 / 自造 / +// 拼写变体)一律 unknown。 // -// 变异方向:把 unlocksTimepointPosition 改成恒 false(或把 temporal 参数丢掉) -// ⇒ 本用例的三条断言全红。 -func TestTemporalOneOffWidensTimepointPosition(t *testing.T) { - t.Parallel() - ctx := context.Background() - store := newTestStore(t) - for _, content := range temporalWideTimepointCorpus() { - content := content - t.Run(content, func(t *testing.T) { - // 前提:这段正文在**旧规则**下是 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) - } - // 放开位置后:命中 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: %v", err) - } - tier, expires := readRetentionColumns(t, store, got.ID) - if tier != RetentionTemp { - t.Fatalf("落库档位 = %q, want %q", tier, RetentionTemp) - } - if expires == "" { - t.Fatalf("判成 temp 却没写 expires_at(两列分叉)") - } - if got.ExpiresAt.IsZero() { - t.Fatalf("Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)") - } - if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff { - t.Fatalf("temporal 列 = %q, want %q(判定与留痕不许分叉)", nat, TemporalOneOff) - } - }) - } -} - -// TestTemporalOneOffKeepsFrontedTimepointRule 钉住「放开位置」不改变**已经命中**的 -// 那些行:时点在句首时,规则码仍是 `n6_timepoint`(不是 wide)—— 否则留痕里就分不清 -// 「词判的」与「模型放开位置后判的」,而那条观测线正是本片要交的东西。 -func TestTemporalOneOffKeepsFrontedTimepointRule(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.Rule != retentionTimepoint { - t.Fatalf("时点在句首时规则码 = %q, want %q(这一条不需要模型放开位置)", - verdict.Rule, retentionTimepoint) - } -} - -// TestTemporalOneOffWidenedPositionTouchesAgreements 把 r1 复审指出的**已知风险** -// 写成断言(而不是留一个词表去掩盖它)。 -// -// 事实: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() - store := newTestStore(t) - const content = "用户昨天发来一卷验收纸条" - // 前提:这条正文在旧规则下确实判 temp(否则本用例证明不了「覆盖」)。 - bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) - if err != nil { - t.Fatalf("resolveRetention(bare): %v", err) - } - if bare.Tier != RetentionTemp || bare.Rule != retentionTimepoint { - t.Fatalf("前提不成立:%q 旧规则判 %q/%q,want temp/%q", content, bare.Tier, bare.Rule, retentionTimepoint) - } - for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} { - temporal := temporal - t.Run(string(temporal), func(t *testing.T) { - item := habitItem(content, tidyTestTime(t), 0.9) - item.Temporal = temporal - got, err := store.Add(ctx, item) - if err != nil { - t.Fatalf("Add(%q): %v", temporal, err) - } - tier, expires := readRetentionColumns(t, store, got.ID) - if tier != RetentionLong { - t.Fatalf("temporal=%q 落库档位 = %q, want %q", temporal, tier, RetentionLong) - } - if expires != "" { - t.Fatalf("temporal=%q 写了 expires_at = %q(非 temp 档必须为空)", temporal, expires) - } - if nat := readTemporalColumn(t, store, got.ID); nat != temporal { - t.Fatalf("temporal 列 = %q, want %q", nat, temporal) - } - }) - } -} - -// TestTemporalUnknownAndUnsetFollowOldRules 钉住任务书验收点③:`unknown`(以及未分型 -// 的空串)**不许自己判 temp** —— 它们必须原样交给 N1~N7,判据只有旧规则那一份。 -// -// 三条断言方向不同,缺一条都测不出「方向反了」: -// -// 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:规则码必须 -// 是 `n6_timepoint`,**绝不能**是 `n6_timepoint_wide`(后者出现就说明位置判据被 -// 一个「拿不准」的答案放开了); -// 2. `unknown` + 旧规则非命中的正文 ⇒ long(不许因为看着像一次性就判 temp); -// 3. 未分型(空串)与 `unknown` 走同一条路(历史行的行为逐字不变)。 -func TestTemporalUnknownAndUnsetFollowOldRules(t *testing.T) { - t.Parallel() - ctx := context.Background() - - t.Run("unknown_旧规则命中", func(t *testing.T) { - item := habitItem("今天下雨了", tidyTestTime(t), 0.9) - item.Temporal = TemporalUnknown - verdict, err := resolveRetention(item) - if err != nil { - t.Fatalf("resolveRetention: %v", err) - } - 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(temporalWideTimepointCorpus()[0], tidyTestTime(t), 0.9) - item.Temporal = TemporalUnknown - got, err := store.Add(ctx, item) - if err != nil { - t.Fatalf("Add(unknown): %v", err) - } - tier, expires := readRetentionColumns(t, store, got.ID) - if tier != RetentionLong || expires != "" { - t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 拿不准就必须留", - tier, expires, RetentionLong) - } - }) - - t.Run("未分型_与unknown同路", func(t *testing.T) { - for _, temporal := range []TemporalKind{TemporalUnset, TemporalUnknown} { - item := habitItem("谢谢啦", tidyTestTime(t), 0.9) - item.Temporal = temporal - verdict, err := resolveRetention(item) - if err != nil { - t.Fatalf("resolveRetention(%q): %v", temporal, err) - } - if verdict.Tier != RetentionTemp || verdict.Rule != retentionChatter { - t.Fatalf("temporal=%q 时 %q 判成 %q/%q, want temp/%q(历史行的行为必须逐字不变)", - temporal, item.Content, verdict.Tier, verdict.Rule, retentionChatter) - } - } - }) -} - -// TestTemporalOneOffDoesNotTouchRestrictedSource 钉住与受限来源的优先级:群聊 / -// 陌生人的行**不分型**(空串),因此时间性位不许把它们判成 temp —— 那会造出 -// 「对读面隐藏、却永远不会被后台清扫删除」的行(expire.go 跳过非 owner 来源)。 -func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) { - t.Parallel() - store := newTestStore(t) - item := habitItem("今天下雨了", tidyTestTime(t), 0.9) - item.Temporal = TemporalOneOff - item.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1") - got, err := store.Add(context.Background(), item) - if err != nil { - t.Fatalf("Add: %v", err) - } - tier, expires := readRetentionColumns(t, store, got.ID) - if tier != RetentionTierUnset || expires != "" { - t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires) - } -} - -// TestTemporalBitDrivesDeferThroughGate 钉住判定接线:闸开着时,位置被放开后判出的 -// temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),不是另开一条路径。 -func TestTemporalBitDrivesDeferThroughGate(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(temporalWideTimepointCorpus()[0], base, 0.9) - item.Temporal = TemporalOneOff - got, err := store.Add(context.Background(), item) - if err != nil { - t.Fatalf("Add: %v", err) - } - 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 就放开位置判据), -// 而「拿不准一律留」才是 fail-safe。 +// 不做同义词映射是刻意的:将来接线时猜同义词的代价是单向的(猜成 one_off 就删记忆), +// 而「拿不准一律 unknown」才是 fail-safe。 func TestNormalizeTemporal(t *testing.T) { t.Parallel() cases := []struct { @@ -387,7 +219,7 @@ func TestNormalizeTemporal(t *testing.T) { // (Add 的 validate 会拒掉它们 —— 那是程序错误,不是模型输出)。 func TestTemporalKindClosedSet(t *testing.T) { t.Parallel() - for _, ok := range []TemporalKind{TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown} { + for _, ok := range temporalAllKinds() { if !ok.valid() { t.Fatalf("%q 应在闭集内", ok) } @@ -400,15 +232,6 @@ 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") @@ -417,8 +240,9 @@ func TestTemporalKindClosedSet(t *testing.T) { } } -// TestLLMExtractorReadsTemporal 钉住验收点④的另一半:提取器**真的**在读模型给的 -// 这一位,且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。 +// TestLLMExtractorReadsTemporal 钉住产出侧:提取器**真的**在读模型给的这一位, +// 且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。观测面的数据 +// 质量由这条用例守住。 func TestLLMExtractorReadsTemporal(t *testing.T) { t.Parallel() adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) { @@ -456,7 +280,7 @@ func TestLLMExtractorReadsTemporal(t *testing.T) { // TestExtractionPromptCarriesTemporalEnum 钉住「提示词与闭集不漂移」:模型能看到的 // 取值必须与 normalizeTemporal 认的四个值逐字一致(提示词只是辅助,但取值口径漂了 -// 就会让每条候选都被归成 unknown,这一位等于白加)。 +// 就会让每条候选都被归成 unknown,观测面等于白留)。 func TestExtractionPromptCarriesTemporalEnum(t *testing.T) { t.Parallel() for _, want := range []string{"temporal", "one_off", "recurring", "durable", "unknown"} { From 82e9213804359d2fe387a42e935e17e44cdb6142 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 19:29:16 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(memory):=20M45=20=E5=A4=8D=E7=9C=8B=20?= =?UTF-8?q?P2=20=E2=80=94=E2=80=94=20=E8=A1=A5=E3=80=8Ctemporal=20?= =?UTF-8?q?=E4=B8=8D=E8=AE=B8=E8=A2=AB=E5=88=A4=E5=AE=9A=E8=AF=BB=E3=80=8D?= =?UTF-8?q?=E7=9A=84=E6=BA=90=E7=A0=81=E7=BA=A7=E6=96=AD=E8=A8=80=20+=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E7=BA=A0=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(真缺口):r1 复看的变异 C 证明「在异步审查侧拿 item.Temporal 改判」当时 **整个 memory 包全绿** —— 已落库的行在异步侧被改判这条口径没有守卫,而异步侧 (applyVerdict / setIngestRetention)恰恰最容易被人随手接线。 新增 TestTemporalIsNotWiredIntoJudgement:扫生产代码(非 _test.go、非注释行)里 所有含 `.Temporal` 的行,逐行要求命中 temporalFieldSites 白名单(校验 3 条 / 产出 1 条, **片段级**而不是文件级 —— 按文件放行会让同一文件里新写的接线路过),违规时报 「文件:行号: 表达式片段」;同时校验白名单每一条确实命中过,防止它老化成空头许可证。 变异实测(复看给的写法,插在 preinsert.go 的 applyVerdict 入口): --- FAIL: TestTemporalIsNotWiredIntoJudgement temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)…: preinsert.go:476: if item.Temporal == TemporalOneOff { 删掉接线后全绿。 P2-2(文案与 D 矛盾):store_temporal.go 的归属清单原写「判定在 resolveRetention (唯一入口,第 4 步)」—— D 之后第 4 步是 N1~N7、没有 temporal 步,那句等于给后来者 指了接线入口。改为:temporal 只被记录、不参与判定;要把 temporal 接进判定必须先拿到 模型真实标签分布,并只能通过 temporalFieldSites 白名单(有意的接线决定)进来。 另按复看提醒:temporal.go 头注里那两条统计 SQL 标明「**运维分析用,不属于读侧代码 路径**」,并写明读侧(timegate 与任何读侧 SQL)明令不许对 created_at/expires_at 用 datetime() —— 那是定宽文本比较,抄进查询层会静默判错。 回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race 38.0s 全绿。 --- internal/runtime/memory/store_temporal.go | 10 ++- internal/runtime/memory/temporal.go | 7 ++ internal/runtime/memory/temporal_test.go | 84 +++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/internal/runtime/memory/store_temporal.go b/internal/runtime/memory/store_temporal.go index 3ccfcce..32b9633 100644 --- a/internal/runtime/memory/store_temporal.go +++ b/internal/runtime/memory/store_temporal.go @@ -8,12 +8,18 @@ import ( // 本文件是 `memory_items.temporal`(迁移 0027)在**写入路径**上的那一半:哨兵错误、 // 字段校验、以及落库时的列值编码。 // -// 其余三处的归属(别在这条链上另开一份实现): +// 🔴 其余三处的归属(别在这条链上另开一份实现): // - **字段与语义**在 store.go 的 MemoryItem 上(结构体的字段不能跨文件声明), // 闭集与归一化在 temporal.go; -// - **判定**在 retention.go 的 resolveRetention(唯一入口,第 4 步); +// - **判定**仍是 `resolveRetention`(retention.go)里的 N1~N7 —— `temporal` +// **只被记录,不参与判定**(r1 复审裁决 D),因此 `resolveRetention` 里 +// **没有、也不许有**「第 4 步看 temporal」这种东西; // - **DDL** 在 migrations/0027 + store_schema.go + temporal_schema.go。 // +// 要把 `temporal` 接进判定,前置条件写在 temporal.go 头注:先拿到模型真实标签分布 +// (那里给了两条只读 SQL),再改 `temporal_test.go` 的 temporalFieldSites 白名单 —— +// 那张表是**有意的接线决定**的唯一入口,不许绕过它。 +// // 拆出本文件只为守 AGENTS §10 的文件行数上限(store.go 已到 500 行)与「模块按职责 // 拆源文件」那条规范 —— 口径逐字未变。 diff --git a/internal/runtime/memory/temporal.go b/internal/runtime/memory/temporal.go index 3184ed3..a45154d 100644 --- a/internal/runtime/memory/temporal.go +++ b/internal/runtime/memory/temporal.go @@ -34,6 +34,13 @@ import "strings" // SELECT created_at, retention_tier, content FROM memory_items // WHERE temporal = 'one_off' ORDER BY created_at DESC LIMIT 10; // +// 🔴 上面两条是**运维分析用的 SQL,不属于读侧代码路径**,别把它们抄进查询层: +// timegate.go(以及任何读侧 SQL)明令**不许**对 `created_at` / `expires_at` 用 +// `datetime()` 之类的 SQLite 时间函数 —— 读侧闸是**定宽文本比较**,一旦有人按这两条 +// 的写法去写查询,时间格式一变就会静默判错(timegate_test.go 钉着那条反证)。 +// 这里出现 `datetime(created_at)` 只是因为「人工一次性分析、离线跑、不需要精确到 +// 纳秒」——**恰好**是读侧不许用的那个形态。 +// // 看 `one_off` 那 10 条里有多少是长期约定 —— 那才是「这一位能不能用」的判据。 // // 闭集(与迁移 0027 的列值口径逐字一致): diff --git a/internal/runtime/memory/temporal_test.go b/internal/runtime/memory/temporal_test.go index 9d775c4..dc490f7 100644 --- a/internal/runtime/memory/temporal_test.go +++ b/internal/runtime/memory/temporal_test.go @@ -2,6 +2,8 @@ package memory import ( "context" + "fmt" + "os" "strings" "testing" @@ -185,6 +187,88 @@ func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind { return TemporalKind(raw) } +// temporalFieldSites 是**允许**读取 `.Temporal` 的全部生产代码位置(文件 + 行内片段)。 +// +// 只有四类,缺一不可、多一不许(多出来的那条就是「接线」): +// +// ① 写入侧的字段校验(store_temporal.go 的 validateTemporal,两行); +// ② 写入侧的列值编码(store_temporal.go 的 temporalColumn); +// ③ 产出侧的构造(llmextractor.go 把模型输出归一后放进候选条目)。 +// +// 为什么用「片段」而不是「文件」做粒度:按文件放行会让**同一文件里**新写的接线悄悄 +// 溜过去(例如在 store_temporal.go 里加一句 `if item.Temporal == …`)。按片段放行则 +// 任何新表达式都必须先改这张表 —— 而改这张表就是一次自觉的接线决定。 +func temporalFieldSites() []struct{ file, snippet string } { + return []struct{ file, snippet string }{ + {"store_temporal.go", "it.Temporal.valid()"}, + {"store_temporal.go", "ErrInvalidTemporal, it.Temporal"}, + {"store_temporal.go", "string(it.Temporal)"}, + {"llmextractor.go", "normalizeTemporal(c.Temporal)"}, + } +} + +// TestTemporalIsNotWiredIntoJudgement 是**源码级断言**:`temporal` 只许被记录, +// **不许被判定读**。它回答的是「零影响」能不能被后来人无声破坏 —— +// retention_test.go 的同类断言只钉住 `judgeExtractionRetention` 的调用点,管不到 +// 「在异步审查侧拿 `item.Temporal` 改判」这种接线方式。 +// +// 复现的缺口(r1 复审变异 C 实测):在 `preinsert.go` 的 `applyVerdict` 入口插一句 +// `if item.Temporal == TemporalOneOff { …setIngestRetention(temp)… }`,**整个 memory +// 包全绿** —— 那时没有任何用例察觉。异步侧恰恰是最容易被随手接线的地方(它拿到的 +// 是**已落库**的行),因此这条断言必须存在。 +// +// 判据:扫生产代码(非 `_test.go`、非注释行)里所有含 `.Temporal` 的行,逐行要求 +// 命中 temporalFieldSites 的某一条;否则报**文件 + 行号 + 表达式片段**(不只报个数)。 +// 同时校验 allowlist 的每一条**确实命中过**,防止它老化成一张空头许可证。 +func TestTemporalIsNotWiredIntoJudgement(t *testing.T) { + t.Parallel() + files, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package dir: %v", err) + } + allowed := temporalFieldSites() + hit := make([]bool, len(allowed)) + var violations []string + for _, f := range files { + name := f.Name() + if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + src, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + for i, line := range strings.Split(string(src), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") || !strings.Contains(trimmed, ".Temporal") { + continue + } + ok := false + for j, site := range allowed { + if name == site.file && strings.Contains(trimmed, site.snippet) { + hit[j] = true + ok = true + break + } + } + if !ok { + violations = append(violations, fmt.Sprintf("%s:%d: %s", name, i+1, trimmed)) + } + } + } + if len(violations) > 0 { + t.Fatalf("temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)。"+ + "若这是有意的接线,先拿到模型真实标签分布并改 temporal.go 头注、再改本断言的 allowlist:\n%s", + strings.Join(violations, "\n")) + } + for j, site := range allowed { + if !hit[j] { + t.Fatalf("allowlist 项 %s: %q 已不存在 —— 这张表老化了,请同步更新"+ + "(否则它只是一张空头许可证)", site.file, site.snippet) + } + } +} + // TestNormalizeTemporal 钉住归一化口径:**只认四个字面值**,其余(空 / 中文 / 自造 / // 拼写变体)一律 unknown。 //