按 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 全绿。
197 lines
7.9 KiB
Go
197 lines
7.9 KiB
Go
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("主人有一条真实落库的观测样本", 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)
|
||
}
|
||
}
|