package memory import ( "context" "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) } // 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) } } }