Files
Cyrene_v2/internal/runtime/memory/temporal_test.go
T
AskaEth 82e9213804 test(memory): M45 复看 P2 —— 补「temporal 不许被判定读」的源码级断言 + 注释纠正
P2-1(真缺口):r1 复看的变异 C 证明「在异步审查侧拿 item.Temporal 改判」当时
**整个 memory 包全绿** —— 已落库的行在异步侧被改判这条口径没有守卫,而异步侧
(applyVerdict / setIngestRetention)恰恰最容易被人随手接线。

新增 TestTemporalIsNotWiredIntoJudgement:扫生产代码(非 _test.go、非注释行)里
所有含 `.Temporal` 的行,逐行要求命中 temporalFieldSites 白名单(校验 3 条 / 产出 1 条,
**片段级**而不是文件级 —— 按文件放行会让同一文件里新写的接线路过),违规时报
「文件:行号: 表达式片段」;同时校验白名单每一条确实命中过,防止它老化成空头许可证。

变异实测(复看给的写法,插在 preinsert.go 的 applyVerdict 入口):
  --- FAIL: TestTemporalIsNotWiredIntoJudgement
      temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)…:
          preinsert.go:476: if item.Temporal == TemporalOneOff {
  删掉接线后全绿。

P2-2(文案与 D 矛盾):store_temporal.go 的归属清单原写「判定在 resolveRetention
(唯一入口,第 4 步)」—— D 之后第 4 步是 N1~N7、没有 temporal 步,那句等于给后来者
指了接线入口。改为:temporal 只被记录、不参与判定;要把 temporal 接进判定必须先拿到
模型真实标签分布,并只能通过 temporalFieldSites 白名单(有意的接线决定)进来。

另按复看提醒:temporal.go 头注里那两条统计 SQL 标明「**运维分析用,不属于读侧代码
路径**」,并写明读侧(timegate 与任何读侧 SQL)明令不许对 created_at/expires_at 用
datetime() —— 那是定宽文本比较,抄进查询层会静默判错。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race 38.0s 全绿。
2026-10-26 19:29:16 +08:00

376 lines
15 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"
"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)
}
}
}