package memory import ( "context" "fmt" "os" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集。 // // 🔴 当前契约(r1 复审裁决 D,2026-10-27):**这一位只观测,不参与判定。** // `resolveRetention` 不读它,判定的结论集与「没有这一位」逐字相同。因此本文件最重要 // 的一条断言是**零影响**(TestTemporalBitDoesNotAffectRetention):同一段正文, // 无论 `temporal` 是 five 个取值里的哪一个,档位与规则码都必须完全相同。 // // 变异靶子与之配套:谁把这一位重新接进 `resolveRetention`(例如先加一句 // `if item.Temporal == TemporalOneOff { return temp }`),那条正断言必红 —— // 这不是「测试太严」,而是「接线前必须先有模型真实标签分布」这个裁决的守卫。 // temporalObservationCorpus 覆盖三类正文,每类都必须对 temporal 免疫: // // ① 旧规则判 long 的行(接线的「收益」候选); // ② 旧规则判 temp 的行(`durable` 也不能把它们救回来 —— 零影响是双向的); // ③ 受限来源 / 显式档位这类「更早的分支」(它们更不该被这一位碰到)。 func temporalObservationCorpus() []string { return []string{ "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。", "主人有每天吃早餐的习惯,几乎不会省略早餐。", "今天下雨了", "谢谢啦", } } // temporalAllKinds 是「业务上可能出现的全部取值」+ 未分型。 func temporalAllKinds() []TemporalKind { return []TemporalKind{ TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown, } } // TestTemporalBitDoesNotAffectRetention 是本文件的核心断言:**temporal 对留存判定零影响** // ——对同一段正文,五个取值的 `(档位, 规则码)` 必须逐字相同(且都以未分型为基准)。 func TestTemporalBitDoesNotAffectRetention(t *testing.T) { t.Parallel() for _, content := range temporalObservationCorpus() { content := content t.Run(content, func(t *testing.T) { base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) if err != nil { t.Fatalf("resolveRetention(base): %v", err) } for _, temporal := range temporalAllKinds() { item := habitItem(content, tidyTestTime(t), 0.9) item.Temporal = temporal got, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention(temporal=%q): %v", temporal, err) } if got != base { t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位必须零影响;"+ "接线前必须先拿到模型真实标签分布,见 temporal.go 头注)", content, temporal, got, base) } } }) } } // TestTemporalOneOffDoesNotMakeAnythingTemporary 是上一条的正向版本,单独成例是为了 // 让「接线会怎样」在失败信息里一眼可见:它只喂 `one_off`,并点名断言「旧规则判 long // 的行仍然 long」。 func TestTemporalOneOffDoesNotMakeAnythingTemporary(t *testing.T) { t.Parallel() const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。" base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) if err != nil { t.Fatalf("resolveRetention(base): %v", err) } if base.Tier != RetentionLong { t.Fatalf("前提不成立:%q 旧规则判 %q(want long)", content, base.Tier) } item := habitItem(content, tidyTestTime(t), 0.9) item.Temporal = TemporalOneOff got, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention(one_off): %v", err) } if got.Tier != RetentionLong || got.Rule != retentionNone { t.Fatalf("temporal=one_off 把 %q 判成 %+v —— 这一位不得参与判定(裁决 D)", content, got) } } // TestTemporalIsStillRecorded 钉住「撤接线不等于撤观测」:`temporal` 仍随同一笔 // INSERT 落库(迁移 0027 的列),而档位/有效期保持旧规则的结论 —— 这正是 D 要的 // 数据来源:全库按 temporal 分组统计、抽样看 one_off 到底长什么样。 func TestTemporalIsStillRecorded(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。" item := habitItem(content, 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("temporal 列 = %q, want %q(观测面必须保留)", nat, TemporalOneOff) } tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionLong || expires != "" { t.Fatalf("落库档位/时效 = (%q, %q), want (long, 空) —— 观测不得改变判定", tier, expires) } } // TestTemporalDoesNotRescueRuleHitRows 钉住零影响是**双向**的:`durable` 曾经(M45 // 初版)能把规则判 temp 的行救回 long;裁决 D 之后这条也取消 —— 判定只由 N1~N7 决定。 func TestTemporalDoesNotRescueRuleHitRows(t *testing.T) { t.Parallel() const content = "今天下雨了" base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) if err != nil { t.Fatalf("resolveRetention(base): %v", err) } if base.Tier != RetentionTemp { t.Fatalf("前提不成立:%q 旧规则判 %q(want temp)", content, base.Tier) } for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} { item := habitItem(content, tidyTestTime(t), 0.9) item.Temporal = temporal got, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention(%q): %v", temporal, err) } if got != base { t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位不得推翻规则)", content, temporal, got, base) } } } // TestTemporalDoesNotTouchExplicitTierOrRestrictedSource 钉住「更早的分支」不被这一位 // 影响:显式档位(人工入口)与受限来源(群聊/陌生人)都排在第 1/3 步。 func TestTemporalDoesNotTouchExplicitTierOrRestrictedSource(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) explicit := habitItem("谢谢啦", tidyTestTime(t), 0.9) explicit.RetentionTier = RetentionLong explicit.Temporal = TemporalOneOff got, err := store.Add(ctx, explicit) if err != nil { t.Fatalf("Add(explicit long): %v", err) } if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionLong { t.Fatalf("显式 long + one_off 落库 = %q, want long(调用方显式档位不许被推翻)", tier) } restricted := habitItem("今天下雨了", tidyTestTime(t), 0.9) restricted.Temporal = TemporalOneOff restricted.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1") got, err = store.Add(ctx, restricted) if err != nil { t.Fatalf("Add(restricted): %v", err) } tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionTierUnset || expires != "" { t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires) } } // readTemporalColumn 直读一行的 `temporal` 列(该列与 expires_at 一样是**只写不读** // 的列:四条列表读路径的 SELECT 不带它,见 store.go 的 Temporal 注释)。 func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind { t.Helper() var raw string if err := store.db.QueryRowContext(context.Background(), `SELECT temporal FROM memory_items WHERE id = ?`, id).Scan(&raw); err != nil { t.Fatalf("read temporal column %s: %v", id, err) } return TemporalKind(raw) } // temporalFieldSites 是**允许**读取 `.Temporal` 的全部生产代码位置(文件 + 行内片段)。 // // 只有四类,缺一不可、多一不许(多出来的那条就是「接线」): // // ① 写入侧的字段校验(store_temporal.go 的 validateTemporal,两行); // ② 写入侧的列值编码(store_temporal.go 的 temporalColumn); // ③ 产出侧的构造(llmextractor.go 把模型输出归一后放进候选条目)。 // // 为什么用「片段」而不是「文件」做粒度:按文件放行会让**同一文件里**新写的接线悄悄 // 溜过去(例如在 store_temporal.go 里加一句 `if item.Temporal == …`)。按片段放行则 // 任何新表达式都必须先改这张表 —— 而改这张表就是一次自觉的接线决定。 func temporalFieldSites() []struct{ file, snippet string } { return []struct{ file, snippet string }{ {"store_temporal.go", "it.Temporal.valid()"}, {"store_temporal.go", "ErrInvalidTemporal, it.Temporal"}, {"store_temporal.go", "string(it.Temporal)"}, {"llmextractor.go", "normalizeTemporal(c.Temporal)"}, } } // TestTemporalIsNotWiredIntoJudgement 是**源码级断言**:`temporal` 只许被记录, // **不许被判定读**。它回答的是「零影响」能不能被后来人无声破坏 —— // retention_test.go 的同类断言只钉住 `judgeExtractionRetention` 的调用点,管不到 // 「在异步审查侧拿 `item.Temporal` 改判」这种接线方式。 // // 复现的缺口(r1 复审变异 C 实测):在 `preinsert.go` 的 `applyVerdict` 入口插一句 // `if item.Temporal == TemporalOneOff { …setIngestRetention(temp)… }`,**整个 memory // 包全绿** —— 那时没有任何用例察觉。异步侧恰恰是最容易被随手接线的地方(它拿到的 // 是**已落库**的行),因此这条断言必须存在。 // // 判据:扫生产代码(非 `_test.go`、非注释行)里所有含 `.Temporal` 的行,逐行要求 // 命中 temporalFieldSites 的某一条;否则报**文件 + 行号 + 表达式片段**(不只报个数)。 // 同时校验 allowlist 的每一条**确实命中过**,防止它老化成一张空头许可证。 func TestTemporalIsNotWiredIntoJudgement(t *testing.T) { t.Parallel() files, err := os.ReadDir(".") if err != nil { t.Fatalf("read package dir: %v", err) } allowed := temporalFieldSites() hit := make([]bool, len(allowed)) var violations []string for _, f := range files { name := f.Name() if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } src, err := os.ReadFile(name) if err != nil { t.Fatalf("read %s: %v", name, err) } for i, line := range strings.Split(string(src), "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "//") || !strings.Contains(trimmed, ".Temporal") { continue } ok := false for j, site := range allowed { if name == site.file && strings.Contains(trimmed, site.snippet) { hit[j] = true ok = true break } } if !ok { violations = append(violations, fmt.Sprintf("%s:%d: %s", name, i+1, trimmed)) } } } if len(violations) > 0 { t.Fatalf("temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)。"+ "若这是有意的接线,先拿到模型真实标签分布并改 temporal.go 头注、再改本断言的 allowlist:\n%s", strings.Join(violations, "\n")) } for j, site := range allowed { if !hit[j] { t.Fatalf("allowlist 项 %s: %q 已不存在 —— 这张表老化了,请同步更新"+ "(否则它只是一张空头许可证)", site.file, site.snippet) } } } // TestNormalizeTemporal 钉住归一化口径:**只认四个字面值**,其余(空 / 中文 / 自造 / // 拼写变体)一律 unknown。 // // 不做同义词映射是刻意的:将来接线时猜同义词的代价是单向的(猜成 one_off 就删记忆), // 而「拿不准一律 unknown」才是 fail-safe。 func TestNormalizeTemporal(t *testing.T) { t.Parallel() cases := []struct { raw string want TemporalKind }{ {"one_off", TemporalOneOff}, {"ONE_OFF", TemporalOneOff}, {" durable \n", TemporalDurable}, {"recurring", TemporalRecurring}, {"unknown", TemporalUnknown}, {"", TemporalUnknown}, {" ", TemporalUnknown}, {"一次性", TemporalUnknown}, {"one-off", TemporalUnknown}, {"oneoff", TemporalUnknown}, {"forever", TemporalUnknown}, } for _, tc := range cases { if got := normalizeTemporal(tc.raw); got != tc.want { t.Fatalf("normalizeTemporal(%q) = %q, want %q", tc.raw, got, tc.want) } } } // TestTemporalKindClosedSet 钉住闭集本身:四个业务值 + 未分型都合法,其他都不合法 // (Add 的 validate 会拒掉它们 —— 那是程序错误,不是模型输出)。 func TestTemporalKindClosedSet(t *testing.T) { t.Parallel() for _, ok := range temporalAllKinds() { if !ok.valid() { t.Fatalf("%q 应在闭集内", ok) } } for _, bad := range []TemporalKind{"one-off", "sometimes", "ONE_OFF", "float"} { if bad.valid() { t.Fatalf("%q 不应在闭集内", bad) } } if TemporalUnknown.assigned() != true || TemporalUnset.assigned() != false { t.Fatal("assigned() 只应对非空串为真(「未分型」的唯一判据)") } // 非法值必须被写入路径拒掉(不是静默落库成脏列值)。 item := habitItem("一条正文", tidyTestTime(t), 0.9) item.Temporal = TemporalKind("sometimes") if err := item.normalize(); err == nil { t.Fatal("validate 接受了非法的时间性位(应返回 ErrInvalidTemporal)") } } // TestLLMExtractorReadsTemporal 钉住产出侧:提取器**真的**在读模型给的这一位, // 且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。观测面的数据 // 质量由这条用例守住。 func TestLLMExtractorReadsTemporal(t *testing.T) { t.Parallel() adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) { return model.Response{Content: `[ {"content":"候选一","temporal":"one_off"}, {"content":"候选二","temporal":"durable"}, {"content":"候选三","temporal":"Recurring"}, {"content":"候选四","temporal":"forever"}, {"content":"候选五"} ]`}, nil }} ex := NewLLMExtractor(adapter, "persona.a", "role.1") items, err := ex.Extract(context.Background(), []model.Message{ model.TextMessage(model.RoleUser, "随便聊两句"), }) if err != nil { t.Fatalf("Extract: %v(非法 temporal 绝不能让整批提取失败)", err) } want := map[string]TemporalKind{ "候选一": TemporalOneOff, "候选二": TemporalDurable, "候选三": TemporalRecurring, "候选四": TemporalUnknown, "候选五": TemporalUnknown, } if len(items) != len(want) { t.Fatalf("候选数 = %d, want %d", len(items), len(want)) } for _, it := range items { if it.Temporal != want[it.Content] { t.Fatalf("%q 的 temporal = %q, want %q", it.Content, it.Temporal, want[it.Content]) } } } // TestExtractionPromptCarriesTemporalEnum 钉住「提示词与闭集不漂移」:模型能看到的 // 取值必须与 normalizeTemporal 认的四个值逐字一致(提示词只是辅助,但取值口径漂了 // 就会让每条候选都被归成 unknown,观测面等于白留)。 func TestExtractionPromptCarriesTemporalEnum(t *testing.T) { t.Parallel() for _, want := range []string{"temporal", "one_off", "recurring", "durable", "unknown"} { if !strings.Contains(extractSystemPrompt, want) { t.Fatalf("提取提示词里缺 %q —— 模型无从给出这一位", want) } } }