Files
Cyrene_v2/internal/runtime/memory/retention_migration_test.go
T
AskaEth 91101dedfb 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 的用例。
2026-10-26 18:57:38 +08:00

246 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"database/sql"
"slices"
"strings"
"testing"
"time"
)
// 本文件钉住**迁移 0026(`memory_retention_tier`)与 Store 自举 DDL 的同形与可回滚**,
// 手法照 preinsert_migration_test.go 对 0025 的那一套(同一批 helpers)。
//
// 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份
// 真源:迁移(权威)与 NewStore 的幂等前滚(任意 *sql.DB 上的自举,retention_schema.go)。
// 少一列就是「新库有、老库无」的漂移,因此两条判据都必须有:
//
// 1. TestMigration0026AddsColumnOverExistingRows —— 迁移落在**已有数据**上:
// 行不丢、列补上、默认值是空串(未分型);down 真的把列删掉且不改任何一行的
// 其它数据;再 up 能重建。
// 2. TestMigration0026ShapeMatchesStoreBootstrap —— 两份 DDL **逐列逐索引同形**
// (它红的时候,正确的修法是让两份对齐,而不是放宽断言)。
// retentionTierColumn 是被测列名(迁移与自举 DDL 里的字面量)。
const retentionTierColumn = "retention_tier"
// retentionTierIndex 是被测索引名。
const retentionTierIndex = "idx_memory_items_retention_tier"
// retentionMigrationID 是 0026 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
const retentionMigrationID = "0026_memory_retention_tier"
// hasMemoryColumn 报告 memory_items 上是否存在某列。
func hasMemoryColumn(t *testing.T, d *sql.DB, name string) bool {
t.Helper()
return slices.ContainsFunc(tableColumns(t, d, "memory_items"),
func(c string) bool { return strings.Contains(c, "|"+name+"|") })
}
// hasMemoryIndex 报告 memory_items 上是否存在某条索引。
func hasMemoryIndex(t *testing.T, d *sql.DB, name string) bool {
t.Helper()
return slices.ContainsFunc(tableIndexes(t, d, "memory_items"),
func(i string) bool { return strings.HasPrefix(i, name+"|") })
}
// insertLegacyMemoryRow 以**0026 之前的形态**写一行(不列 retention_tier)。
func insertLegacyMemoryRow(t *testing.T, d *sql.DB, id, content string) {
t.Helper()
if _, err := d.ExecContext(context.Background(), `INSERT INTO memory_items
(id, persona_id, scope, kind, content, created_at, updated_at)
VALUES (?, 'persona.a', 'persona', 'relationship', ?, `+legacyTimeLiteral+`, `+legacyTimeLiteral+`)`,
id, content); err != nil {
t.Fatalf("insert legacy row %s: %v", id, err)
}
}
// legacyTimeLiteral 是定宽 UTC 文本(与 formatTime 同形,读侧纯文本比较依赖它)。
const legacyTimeLiteral = "'2026-10-01T10:00:00.000000000Z'"
// TestMigration0026AddsColumnOverExistingRows 钉住迁移本体的三条:
// 落在既有数据上不丢行、默认值是空串(未分型)、down 真的删列且不动其它数据。
func TestMigration0026AddsColumnOverExistingRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
// 造出「既有部署」:回到 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)
}
insertLegacyMemoryRow(t, d, "legacy-1", "一条 0026 之前就存在的记忆")
insertLegacyMemoryRow(t, d, "legacy-2", "另一条 0026 之前就存在的记忆")
// 升级:0026 落在**已有数据**上。
migrateUp(t, d)
if !hasMemoryColumn(t, d, retentionTierColumn) {
t.Fatalf("迁移后 memory_items 应带 %s 列", retentionTierColumn)
}
if !hasMemoryIndex(t, d, retentionTierIndex) {
t.Fatalf("迁移后应存在索引 %s", retentionTierIndex)
}
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 tier, content string
if err := d.QueryRowContext(ctx,
`SELECT retention_tier, content FROM memory_items WHERE id = ?`, id).
Scan(&tier, &content); err != nil {
t.Fatalf("read %s: %v", id, err)
}
if tier != "" {
t.Fatalf("既有行 %s 的档位 = %q, want 空串(历史行必须是「未分型」)", id, tier)
}
if content == "" {
t.Fatalf("既有行 %s 的正文丢了", id)
}
}
// 回滚:只撤结构,不改写任何一行的其它数据。
migrateDownToBefore(t, d, retentionMigrationID)
if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) {
t.Fatalf("二次回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex)
}
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
t.Fatalf("count rows after down: %v", err)
}
if rows != 2 {
t.Fatalf("回滚后行数 = %d, want 2(down 只撤结构,不删行)", rows)
}
// 再 up:列以默认值重建,历史行回到「未分型」。
migrateUp(t, d)
if !hasMemoryColumn(t, d, retentionTierColumn) {
t.Fatalf("再次迁移后应重建 %s 列", retentionTierColumn)
}
// 空串用**单引号**字面量:`""` 在 SQLite 里先按标识符解析,靠 legacy 回退才对,
// 不该在断言里留这种口径(与写入路径的 `''` 统一)。
var withTier int
if err := d.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_items WHERE retention_tier = ''`).Scan(&withTier); err != nil {
t.Fatalf("count untyped: %v", err)
}
if withTier != 2 {
t.Fatalf("重建后未分型行 = %d, want 2", withTier)
}
}
// TestMigration0026ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的
// memory_items(列 + 索引)与自举 DDL 建出的必须逐项同形。
//
// ⚠️ 比对的是**列集合**而不是列序,理由是既有的既成事实:`project_id` 是自举
// 前滚补上的(ensureProjectIDColumn),迁移路径上它同样会被追加到末尾,因此两条
// 路径的列序本来就不同。本片新增的 `retention_tier` 在两条路径上都追加在**当时**的
// 末尾,不制造新的漂移 —— 集合一致 + 索引一致就是这条护栏的判据。
func TestMigration0026ShapeMatchesStoreBootstrap(t *testing.T) {
t.Parallel()
ctx := context.Background()
// 生产顺序:先迁移、后自举。两边都走完这一整套再比。
migrated := openRawMemoryDB(t)
migrateUp(t, migrated)
if _, err := NewStore(ctx, migrated, testLogger()); err != nil {
t.Fatalf("NewStore(migrated): %v", err)
}
bootstrapped := openRawMemoryDB(t)
if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil {
t.Fatalf("NewStore: %v", err)
}
gotCols := sortedColumns(tableColumns(t, migrated, "memory_items"))
wantCols := sortedColumns(tableColumns(t, bootstrapped, "memory_items"))
if !slices.Equal(gotCols, wantCols) {
t.Fatalf("迁移与自举的 memory_items 列集合不一致(两份 DDL 必须同形):\n迁移=%v\n自举=%v",
gotCols, wantCols)
}
gotIdx := tableIndexes(t, migrated, "memory_items")
wantIdx := tableIndexes(t, bootstrapped, "memory_items")
if !slices.Equal(gotIdx, wantIdx) {
t.Fatalf("迁移与自举的 memory_items 索引不一致:\n迁移=%v\n自举=%v", gotIdx, wantIdx)
}
// 新列在两份 DDL 里的形状必须逐字相同(TEXT NOT NULL DEFAULT + 空串字面量)。
for name, cols := range map[string][]string{"迁移": gotCols, "自举": wantCols} {
if !slices.Contains(cols, retentionTierColumn+"|TEXT|1|''|0") {
t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols)
}
}
// 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal`。
// 迁移路径上这两列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。
// (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后。)
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)
}
}
// sortedColumns 把 `cid|name|type|notnull|default|pk` 渲染出的行去掉 cid 后排序,
// 使比对与列序无关(理由见 TestMigration0026ShapeMatchesStoreBootstrap)。
func sortedColumns(rows []string) []string {
out := make([]string, 0, len(rows))
for _, r := range rows {
if i := strings.Index(r, "|"); i >= 0 {
r = r[i+1:]
}
out = append(out, r)
}
slices.Sort(out)
return out
}
// TestMigration0026ThenStoreBootstrapIsARegularPath 覆盖**真实启动顺序**
// (cmd/cyrene/wire.go:先 MigrateUp、后 NewStore):0026 先建好列,随后 Store 的
// 自举前滚走到 "duplicate column name" —— 那是**预期路径**,必须无害;重复建 Store
// 同样幂等,且写入路径可用。
func TestMigration0026ThenStoreBootstrapIsARegularPath(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
if !hasMemoryColumn(t, d, retentionTierColumn) {
t.Fatalf("迁移后应有 %s 列", retentionTierColumn)
}
store, err := NewStore(ctx, d, testLogger())
if err != nil {
t.Fatalf("NewStore(列已由迁移建好): %v", err)
}
after := tableColumns(t, d, "memory_items")
// 列必须还在(自举的 duplicate column 路径不许把它当成失败)。
if !hasMemoryColumn(t, d, retentionTierColumn) {
t.Fatalf("自举之后 %s 列消失了", retentionTierColumn)
}
if !hasMemoryIndex(t, d, retentionTierIndex) {
t.Fatalf("自举之后应存在索引 %s", retentionTierIndex)
}
// 幂等:再建一次 Store 不报错、不改形状。
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)
}
// 写入路径可用(档位两列在同一笔 INSERT 落下)。
got := seedTidyItem(t, store, gateFixtureItem("谢谢啦", time.Time{}))
if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionTemp {
t.Fatalf("迁移建表后经 Store 落库的档位 = %q, want %q", tier, RetentionTemp)
}
}