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 全绿。
376 lines
15 KiB
Go
376 lines
15 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|