Files
Cyrene_v2/internal/runtime/memory/temporal_migration_test.go
T
AskaEth 3b9ae3f918 refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 r1 复审裁定(p1-3items / fix-then-merge)重做三处:

P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表
(retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行
杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住
同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**:
judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient →
judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中
才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条
语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。

P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何
标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active
能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条
约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。

已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements,
不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标
one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置
无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。

P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 +
列值编码)。
P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的
写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。
P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不
存在 ⇒ 重放结论对新写入没有预测力)。

测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置
不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」
五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
2026-10-26 19:15:08 +08:00

197 lines
7.8 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"
)
// 本文件钉住**迁移 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(temporalWideTimepointCorpus()[0], 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)
}
}