Files
Cyrene_v2/internal/runtime/memory/temporal_test.go
T
AskaEth c7c0294a16 refactor(memory): M45 裁决 D —— temporal 位撤出判定,只保留观测
按 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 全绿。
2026-10-26 19:21:24 +08:00

292 lines
12 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"
"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)
}
}
}