Files
Cyrene_v2/internal/runtime/memory/preinsert_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

292 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"
"fmt"
"slices"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件钉住**迁移 0025 与 Store 自举 DDL 的同形与幂等**(切片 W5)。
//
// 背景:`memory_ingest_reviews` 原来只由 `memory.NewStore` 的幂等前滚创建,2026-10-06
// 补登记为迁移 `0025_memory_ingest_reviews`。于是 DDL 有了**两份真源**
// (迁移 + preinsert_schema.go 的自举),而真实启动顺序是「先 MigrateUp、后 NewStore」;
// 既有部署更特殊:**表早就存在**(自举建的),只是迁移账本里没有 0025 —— 那一跳必须
// 不炸、不丢数据、不改形状。三条判据都在这里:
//
// 1. TestMigration0025CreatesAndRollsBackIngestReviewTable —— 新库上迁移真的建出
// 表与两条索引,且可回滚(down 删表 ⇒ up 重建空表);
// 2. TestMigration0025IsIdempotentOverStoreBootstrapTable —— **既有部署**:表已存在
// 且已有数据时重跑 0025,不报错、不丢行、形状不变;
// 3. TestMigration0025ShapeMatchesStoreBootstrap —— 两份 DDL **逐列逐索引同形**
// (少一列就是「新库有、老库无」的漂移,`CREATE TABLE IF NOT EXISTS` 救不了)。
// ingestReviewTable 是被测表名(与迁移、自举 DDL 里的字面量一致)。
const ingestReviewTable = "memory_ingest_reviews"
// ingestReviewMigrationID 是 0025 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
const ingestReviewMigrationID = "0025_memory_ingest_reviews"
// openRawMemoryDB 开一个私有的内存库:不迁移、不建 Store,由调用方自己决定走
// 迁移还是自举(两条路径的对照是本文件的目的)。
func openRawMemoryDB(t *testing.T) *sql.DB {
t.Helper()
d, err := db.Open(context.Background(), ":memory:", testLogger())
if err != nil {
t.Fatalf("open in-memory db: %v", err)
}
t.Cleanup(func() { _ = d.Close() })
return d
}
// migrateUp 跑全量迁移(含本片新增的 0025)。
func migrateUp(t *testing.T, d *sql.DB) {
t.Helper()
if err := db.MigrateUp(context.Background(), d, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
}
// 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()
var n int
if err := d.QueryRowContext(context.Background(),
`SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?`, name).Scan(&n); err != nil {
t.Fatalf("check table %s: %v", name, err)
}
return n == 1
}
// tableColumns 把表结构渲染成可比较的字符串切片(name|type|notnull|default|pk),
// 顺序即建表顺序。
func tableColumns(t *testing.T, d *sql.DB, table string) []string {
t.Helper()
rows, err := d.QueryContext(context.Background(), "PRAGMA table_info("+table+")")
if err != nil {
t.Fatalf("table_info(%s): %v", table, err)
}
defer func() { _ = rows.Close() }()
out := make([]string, 0)
for rows.Next() {
var (
cid int
name, typ string
notNull int
dflt sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
t.Fatalf("scan table_info(%s): %v", table, err)
}
out = append(out, fmt.Sprintf("%d|%s|%s|%d|%s|%d", cid, name, typ, notNull, dflt.String, pk))
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate table_info(%s): %v", table, err)
}
return out
}
// tableIndexes 把表自己建的索引渲染成 `name|unique`(按名字排序,忽略建索引顺序)。
// `sqlite_autoindex_*` 是 TEXT PRIMARY KEY 的隐式索引,不由我们的 DDL 创建,
// 故排除 —— 主键形状已由 tableColumns 的 pk 列覆盖。
func tableIndexes(t *testing.T, d *sql.DB, table string) []string {
t.Helper()
rows, err := d.QueryContext(context.Background(), "PRAGMA index_list("+table+")")
if err != nil {
t.Fatalf("index_list(%s): %v", table, err)
}
defer func() { _ = rows.Close() }()
out := make([]string, 0)
for rows.Next() {
var (
seq int
name string
unique int
origin string
partial int
)
if err := rows.Scan(&seq, &name, &unique, &origin, &partial); err != nil {
t.Fatalf("scan index_list(%s): %v", table, err)
}
if strings.HasPrefix(name, "sqlite_autoindex_") {
continue
}
out = append(out, fmt.Sprintf("%s|%d", name, unique))
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate index_list(%s): %v", table, err)
}
slices.Sort(out)
return out
}
// countRawReviewRows 直读留痕行数(不经 Store)。
func countRawReviewRows(t *testing.T, d *sql.DB) int {
t.Helper()
var n int
if err := d.QueryRowContext(context.Background(),
"SELECT count(*) FROM "+ingestReviewTable).Scan(&n); err != nil {
t.Fatalf("count rows in %s: %v", ingestReviewTable, err)
}
return n
}
// TestMigration0025CreatesAndRollsBackIngestReviewTable 钉住迁移本身:up 建表 + 两条
// 索引、down 删表、再 up 能重建(回滚不留半状态)。
func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) {
t.Parallel()
d := openRawMemoryDB(t)
migrateUp(t, d)
if !tableExists(t, d, ingestReviewTable) {
t.Fatalf("迁移后应存在 %s", ingestReviewTable)
}
wantIndexes := []string{
"idx_memory_ingest_reviews_created_at|0",
"idx_memory_ingest_reviews_item_id|0",
}
if got := tableIndexes(t, d, ingestReviewTable); !slices.Equal(got, wantIndexes) {
t.Fatalf("索引 = %v, want %v", got, wantIndexes)
}
cols := tableColumns(t, d, ingestReviewTable)
for _, want := range []string{"id", "item_id", "created_at"} {
if !slices.ContainsFunc(cols, func(c string) bool { return strings.Contains(c, "|"+want+"|") }) {
t.Fatalf("列 %s 缺席:%v", want, cols)
}
}
// 回滚: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)
}
// 再 up:表重建且为空(down/up 前后不留残留)。
migrateUp(t, d)
if !tableExists(t, d, ingestReviewTable) {
t.Fatalf("再次迁移后应存在 %s", ingestReviewTable)
}
if got := countRawReviewRows(t, d); got != 0 {
t.Fatalf("重建后的行数 = %d, want 0", got)
}
if got := tableColumns(t, d, ingestReviewTable); !slices.Equal(got, cols) {
t.Fatalf("重建后的表结构 = %v, want %v", got, cols)
}
}
// TestMigration0025IsIdempotentOverStoreBootstrapTable 模拟**既有部署**的真实状态:
// 表已由自举建好、里面已有留痕,而迁移账本里没有 0025(本次升级的全部增量就是
// 重跑这一条)。判据:不报错、数据不丢、形状不变。
func TestMigration0025IsIdempotentOverStoreBootstrapTable(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
// 造出「既有部署」:把 0025 的成果抹掉(表 + 账本记录),让自举重新建表。
if _, err := d.ExecContext(ctx, "DROP TABLE "+ingestReviewTable); err != nil {
t.Fatalf("drop table: %v", err)
}
if _, err := d.ExecContext(ctx,
"DELETE FROM schema_migrations WHERE id = ?", ingestReviewMigrationID); err != nil {
t.Fatalf("unrecord migration: %v", err)
}
store, err := NewStore(ctx, d, testLogger())
if err != nil {
t.Fatalf("NewStore(自举建表): %v", err)
}
seedReviewRow(t, store, "kept", tidyTestTime(t))
beforeCols := tableColumns(t, d, ingestReviewTable)
beforeIndexes := tableIndexes(t, d, ingestReviewTable)
// 升级路径:只有 0025 待应用(表已存在)。
migrateUp(t, d)
if got := countRawReviewRows(t, d); got != 1 {
t.Fatalf("迁移后行数 = %d, want 1(既有留痕一行都不能丢)", got)
}
if got := tableColumns(t, d, ingestReviewTable); !slices.Equal(got, beforeCols) {
t.Fatalf("迁移改变了表结构:\n got=%v\nwant=%v", got, beforeCols)
}
if got := tableIndexes(t, d, ingestReviewTable); !slices.Equal(got, beforeIndexes) {
t.Fatalf("迁移改变了索引:\n got=%v\nwant=%v", got, beforeIndexes)
}
// 账本幂等:已记账后再跑一次是纯空转。
migrateUp(t, d)
if got := countRawReviewRows(t, d); got != 1 {
t.Fatalf("重复迁移后行数 = %d, want 1", got)
}
}
// TestMigration0025ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的表与自举
// 建出的表必须逐列、逐索引同形。它红的时候,正确的修法是让两份 DDL 对齐,
// 而不是放宽这条断言。
func TestMigration0025ShapeMatchesStoreBootstrap(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)
}
gotCols := tableColumns(t, migrated, ingestReviewTable)
wantCols := tableColumns(t, bootstrapped, ingestReviewTable)
if !slices.Equal(gotCols, wantCols) {
t.Fatalf("迁移与自举的表结构不一致(两份 DDL 必须逐字同形):\n迁移 =%v\n自举 =%v", gotCols, wantCols)
}
gotIdx := tableIndexes(t, migrated, ingestReviewTable)
wantIdx := tableIndexes(t, bootstrapped, ingestReviewTable)
if !slices.Equal(gotIdx, wantIdx) {
t.Fatalf("迁移与自举的索引不一致:\n迁移 =%v\n自举 =%v", gotIdx, wantIdx)
}
}