feat(memory): M45 提取器时间性元数据位 temporal 驱动留存判定

背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成
30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的
就是那一位,不动 N1~N7 的任何阈值。

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

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

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

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

No files matched your search

@@ -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)
}