按 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 全绿。
292 lines
12 KiB
Go
292 lines
12 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|