主人 2026-10-07 裁定「可以做 T1」。此前 D3 只有纯规则 T0,凡灰区(相似但 互不包含)一律保守 create,真机上「同一事实换措辞」类重复结构性治不住 (清完 92 条字面级重复后,active 里含「喵喵七号」的仍有 101 条)。 - 插入点:T0 判定内核只多吐一份结构化 Gray(惰性收集,恒零模型调用); T1 挂在异步审查层 Review,同步入库闸不碰它 ⇒ 写入路径零模型调用是 **数据流结构**而不是承诺 - 只许回 merge / modify / keep + 一句理由;坏 JSON、非法枚举、编号越界 一律回落 create(绝不猜、绝不挑最近的一条) - 禁产新内容:模型只给「动作 + 候选编号 + 一句理由」,正文一律取自库里 既有的两行 ⇒ T1 结构性地不可能把幻觉写进记忆库 - 不越硬门与红线:灰区已滤掉跨角色/作用域/项目/只读来源与时效/数值/冲突/ 低置信;「规则否决一票否决」不可走样,T1 不提供翻案入口 - 有界:每次审查至多一次调用(灰区打包,上限 8 条),子超时 8s 且不超过 审查总预算;失败只改 reason、决定恒 create、零 UPDATE - 开关 t1Judge(放 $DATA_DIR/config/memory-tidy.json,零新 env 键),默认开; 模型出口用闭包晚绑定既有模型栈(零新客户端、零新依赖) - 留痕 reason 结构化(t1_merge / t1_modify / t1_keep / t1_reply_invalid / t1_unavailable),rule_version d3.1 → d3.2;模型的自由文本理由一律丢弃 - preinsert_config.go 为纯搬移(为守 500 行上限,无新逻辑)
422 lines
15 KiB
Go
422 lines
15 KiB
Go
package memory
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// 本文件是 **T1 灰区模型判定**(design/23 §15.1 D3-2,preinsert_t1.go)的测试:
|
||
// 关掉即零调用、merge/modify/keep 三态各自落地正确、失败回落且零改写。
|
||
// 硬门隔离与调用次数有界在 preinsert_t1_bound_test.go。
|
||
//
|
||
// 夹具命名一律带 t1 前缀/后缀,避免与同包既有夹具(stubEmbedder、
|
||
// failingReviewEmbedder 等)撞车。
|
||
|
||
// 灰区夹具:**同一事实换措辞、互不包含** —— 两条各自带着对方没有的词
|
||
// (「摄取通道的」/「主人与助手约定」),因此 T0 的包含判定给 containNone,
|
||
// 规则层判不出结论。这正是真机「喵喵七号」那一类(101 条 active)的脱敏等价物。
|
||
// 两侧都不含时效词与数值差异,所以规则否决也都不命中 ⇒ 真的落进灰区。
|
||
const (
|
||
fixtureT1CodeViaChannel = "摄取通道的验收暗号是喵喵七号"
|
||
fixtureT1CodeAgreed = "主人与助手约定验收暗号喵喵七号"
|
||
)
|
||
|
||
// t1JudgeStub 是 T1 模型出口的桩:记下**被调用了多少次**、每次都收到什么请求,
|
||
// 并可按预设回复/错误/延迟作答。
|
||
//
|
||
// 它是「T1 关 ⇒ 零模型调用」那把尺子:`panics` 置真时它一被调用就 panic,
|
||
// 于是任何「以为关了其实还在调」的回归都会立刻现形。
|
||
type t1JudgeStub struct {
|
||
mu sync.Mutex
|
||
calls int
|
||
reqs []model.Request
|
||
opts []model.Options
|
||
reply string
|
||
fail error
|
||
delay time.Duration
|
||
panics bool
|
||
panicked bool
|
||
}
|
||
|
||
// judge 是 T1Judge 的实现(签名与 model.Adapter.Chat 一致)。
|
||
func (s *t1JudgeStub) judge(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) {
|
||
s.mu.Lock()
|
||
s.calls++
|
||
s.reqs = append(s.reqs, req)
|
||
s.opts = append(s.opts, opts)
|
||
panics, reply, fail, delay := s.panics, s.reply, s.fail, s.delay
|
||
if panics {
|
||
s.panicked = true
|
||
}
|
||
s.mu.Unlock()
|
||
if panics {
|
||
panic("t1 judge must not be called")
|
||
}
|
||
if delay > 0 {
|
||
select {
|
||
case <-time.After(delay):
|
||
case <-ctx.Done():
|
||
return model.Response{}, ctx.Err()
|
||
}
|
||
}
|
||
if fail != nil {
|
||
return model.Response{}, fail
|
||
}
|
||
return model.Response{Content: reply}, nil
|
||
}
|
||
|
||
func (s *t1JudgeStub) count() int {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.calls
|
||
}
|
||
|
||
func (s *t1JudgeStub) lastReq(t *testing.T) model.Request {
|
||
t.Helper()
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if len(s.reqs) == 0 {
|
||
t.Fatal("T1 桩一次都没被调用")
|
||
}
|
||
return s.reqs[len(s.reqs)-1]
|
||
}
|
||
|
||
// newT1Reviewer 构建「异步审查层 + 已接线 T1」的服务:同步入库闸显式关掉
|
||
// (这批用例只验异步那一层,否则测到的是闸),其余走设计稿默认值。
|
||
func newT1Reviewer(
|
||
t *testing.T, store *Store, emb embedding.Embedder, cfg ReviewConfig, judge T1Judge, log *slog.Logger,
|
||
) *PreInsertReviewer {
|
||
t.Helper()
|
||
cfg.Enabled = true
|
||
if cfg.QueueSize == 0 {
|
||
cfg.QueueSize = 8
|
||
}
|
||
if cfg.ScanLimit == 0 {
|
||
cfg.ScanLimit = 50
|
||
}
|
||
if cfg.Timeout == 0 {
|
||
cfg.Timeout = 5 * time.Second
|
||
}
|
||
if cfg.Threshold == 0 {
|
||
cfg.Threshold = 0.80
|
||
}
|
||
cfg.DisableIngestGate = true
|
||
if log == nil {
|
||
log = testLogger()
|
||
}
|
||
r, err := NewPreInsertReviewer(cfg, ReviewDeps{
|
||
Store: store,
|
||
Embedder: emb,
|
||
Judge: judge,
|
||
Now: func() time.Time { return tidyTestTime(t) },
|
||
Log: log,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
return r
|
||
}
|
||
|
||
// t1GrayEmbedder 让「新条目 ↔ 既有条目」余弦恰为 0.88(≥ 阈值 0.80 ⇒ 被召回),
|
||
// 其余文本落到正交向量上(不会被意外召回)。
|
||
func t1GrayEmbedder() stubEmbedder {
|
||
return stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureT1CodeViaChannel: {1, 0},
|
||
fixtureT1CodeAgreed: simVector(0.88),
|
||
}}
|
||
}
|
||
|
||
// seedT1GrayPair 落两条**灰区**记忆并返回 (既有行, 新行)。
|
||
func seedT1GrayPair(t *testing.T, store *Store) (MemoryItem, MemoryItem) {
|
||
t.Helper()
|
||
base := tidyTestTime(t)
|
||
existing := seedTidyItem(t, store, habitItem(fixtureT1CodeAgreed, base, 0.9))
|
||
fresh := seedTidyItem(t, store, habitItem(fixtureT1CodeViaChannel, base.Add(time.Minute), 0.9))
|
||
return existing, fresh
|
||
}
|
||
|
||
// TestT1DisabledMakesZeroModelCalls 是任务书 §5①(design/23 §15 D3-9⑥):
|
||
// **T1 关 ⇒ 零模型调用**。桩「被调用即 panic」,因此任何绕过开关的调用都会
|
||
// 立刻现形,而不是悄悄多花一次额度。
|
||
func TestT1DisabledMakesZeroModelCalls(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
existing, fresh := seedT1GrayPair(t, store)
|
||
|
||
judge := &t1JudgeStub{panics: true, reply: `{"action":"merge","target":1}`}
|
||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{DisableT1: true}, judge.judge, nil)
|
||
|
||
rev := t1ReviewGuarded(t, r, fresh)
|
||
if judge.count() != 0 {
|
||
t.Fatalf("T1 被关掉仍被调用了 %d 次", judge.count())
|
||
}
|
||
if rev.Decision != IngestCreate {
|
||
t.Fatalf("关掉 T1 后灰区应保守回落 create,得到 %q", rev.Decision)
|
||
}
|
||
if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive {
|
||
t.Fatalf("关掉 T1 却改写了新行:%+v", got)
|
||
}
|
||
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
|
||
t.Fatalf("关掉 T1 却改了既有行:%+v", got)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 2 {
|
||
t.Fatalf("关掉 T1 后活跃行数 = %d, want 2(行为必须与引入 T1 之前逐字相同)", n)
|
||
}
|
||
}
|
||
|
||
// TestT1NoJudgeIsStructurallyZeroLLM 钉住「没有模型出口 ⇒ 结构性零模型调用」:
|
||
// 连开关都不用配,依赖面里没有出口就发不出请求。
|
||
func TestT1NoJudgeIsStructurallyZeroLLM(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
_, fresh := seedT1GrayPair(t, store)
|
||
|
||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, nil, nil)
|
||
if r.t1Enabled() {
|
||
t.Fatal("没有模型出口时 t1Enabled 必须为 false")
|
||
}
|
||
rev, err := r.Review(context.Background(), fresh)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestCreate || rev.Reason != "no_contained_match" {
|
||
t.Fatalf("verdict = %+v, want create + T0 原始原因码", rev)
|
||
}
|
||
}
|
||
|
||
// TestT1MergeAbsorbsNewRowAndKeepsExistingContent 是任务书 §5②(design/23
|
||
// §15 D3-2 T0-2 的软合并口径):模型回 `merge` ⇒ 新行软合并进既有行,
|
||
// **既有行正文一字未改**,且整件事可 revert。
|
||
func TestT1MergeAbsorbsNewRowAndKeepsExistingContent(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
existing, fresh := seedT1GrayPair(t, store)
|
||
|
||
// 顺带钉住围栏容忍:模型用 ```json 包住也是合法回复。
|
||
judge := &t1JudgeStub{reply: "```json\n{\"action\":\"merge\",\"target\":1,\"reason\":\"同一验收暗号的两种说法\"}\n```"}
|
||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, judge.judge, nil)
|
||
|
||
rev, err := r.Review(ctx, fresh)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestMerge || rev.TargetID != existing.ID {
|
||
t.Fatalf("verdict = %+v, want merge 并入 %s", rev, existing.ID)
|
||
}
|
||
if rev.Reason != t1ReasonMerge {
|
||
t.Fatalf("留痕 reason = %q, want %q(T1 参与必须可辨)", rev.Reason, t1ReasonMerge)
|
||
}
|
||
if judge.count() != 1 {
|
||
t.Fatalf("模型调用 = %d 次, want 恰为 1", judge.count())
|
||
}
|
||
if got := t1JudgeRequest(t, judge); !strings.Contains(t1UserText(got), fixtureT1CodeAgreed) {
|
||
t.Fatalf("prompt 没有带上候选原文:%q", t1UserText(got))
|
||
}
|
||
|
||
absorbed := readReviewRow(t, store, fresh.ID)
|
||
if absorbed.status != ingestStatusMerged || absorbed.mergedInto != existing.ID {
|
||
t.Fatalf("新行 = %+v, want merged 进既有行", absorbed)
|
||
}
|
||
survivor := readReviewRow(t, store, existing.ID)
|
||
if survivor.status != tidyStatusActive || survivor.mergeCount != 2 {
|
||
t.Fatalf("既有行 = %+v, want active 且 merge_count=2", survivor)
|
||
}
|
||
if survivor.content != fixtureT1CodeAgreed {
|
||
t.Fatalf("既有行正文被改动了:%q(T1 绝不许产新内容)", survivor.content)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 1 {
|
||
t.Fatalf("活跃行数 = %d, want 1", n)
|
||
}
|
||
|
||
// 可逆:软删除 + merged_into,行永不物理删除(design/23 §8)。
|
||
if err := store.RevertMerged(ctx, fresh.ID); err != nil {
|
||
t.Fatalf("RevertMerged: %v", err)
|
||
}
|
||
restored := readReviewRow(t, store, fresh.ID)
|
||
if restored.status != tidyStatusActive || restored.mergedInto != "" ||
|
||
restored.content != fixtureT1CodeViaChannel {
|
||
t.Fatalf("revert 后 = %+v, want active 且原文无损", restored)
|
||
}
|
||
if after := readReviewRow(t, store, existing.ID); after.mergeCount != 1 {
|
||
t.Fatalf("revert 后既有行 merge_count = %d, want 1", after.mergeCount)
|
||
}
|
||
}
|
||
|
||
// TestT1ModifyRetiresExistingAndKeepsNewRowContent 钉住 `modify` 的落地口径
|
||
// (design/23 §15 D3-6):**新行承载更全的内容**、旧行置为被合并,两行正文都
|
||
// 取自各自的既有原文 —— 模型给不出任何正文。
|
||
func TestT1ModifyRetiresExistingAndKeepsNewRowContent(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
existing, fresh := seedT1GrayPair(t, store)
|
||
|
||
judge := &t1JudgeStub{reply: `{"action":"modify","target":1,"reason":"新记忆覆盖了旧记忆"}`}
|
||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, judge.judge, nil)
|
||
|
||
rev, err := r.Review(ctx, fresh)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestModify || rev.TargetID != existing.ID || rev.Reason != t1ReasonModify {
|
||
t.Fatalf("verdict = %+v, want modify 覆盖 %s", rev, existing.ID)
|
||
}
|
||
if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive ||
|
||
got.content != fixtureT1CodeViaChannel {
|
||
t.Fatalf("新行 = %+v, want 存活且承载自己的原文", got)
|
||
}
|
||
old := readReviewRow(t, store, existing.ID)
|
||
if old.status != ingestStatusMerged || old.mergedInto != fresh.ID ||
|
||
old.content != fixtureT1CodeAgreed {
|
||
t.Fatalf("旧行 = %+v, want merged 进新行且原文无损", old)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 1 {
|
||
t.Fatalf("活跃行数 = %d, want 1", n)
|
||
}
|
||
}
|
||
|
||
// TestT1KeepCreatesNormally 是任务书 §5③:模型回 `keep` ⇒ 正常新建
|
||
// (与没有 T1 时的行为逐字一致),但留痕要能看出「T1 看过并判 keep」。
|
||
func TestT1KeepCreatesNormally(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
existing, fresh := seedT1GrayPair(t, store)
|
||
|
||
judge := &t1JudgeStub{reply: `{"action":"keep","target":0,"reason":"两件不同的事"}`}
|
||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, judge.judge, nil)
|
||
|
||
rev, err := r.Review(ctx, fresh)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestCreate || rev.Reason != t1ReasonKeep {
|
||
t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonKeep)
|
||
}
|
||
if judge.count() != 1 {
|
||
t.Fatalf("模型调用 = %d 次, want 恰为 1", judge.count())
|
||
}
|
||
if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive {
|
||
t.Fatalf("keep 却改写了新行:%+v", got)
|
||
}
|
||
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
|
||
t.Fatalf("keep 却改了既有行:%+v", got)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 2 {
|
||
t.Fatalf("活跃行数 = %d, want 2", n)
|
||
}
|
||
}
|
||
|
||
// TestT1InvalidReplyFallsBackToCreate 是任务书 §5④(design/23 §15 D3-2 的
|
||
// 「任何解析不出枚举的回复 ⇒ 一律回落 create」):坏 JSON、非法枚举、编号越界
|
||
// 全部保守回落,**不 panic、不丢记忆、绝不猜一个最近的候选**。
|
||
func TestT1InvalidReplyFallsBackToCreate(t *testing.T) {
|
||
t.Parallel()
|
||
replies := []string{
|
||
"", // 空回复
|
||
"嗯……我觉得可以合并吧", // 压根不是 JSON
|
||
`{"action":"link","target":1}`, // 非法的第四态
|
||
`{"action":"maybe","target":1}`, // 编出来的词
|
||
`{"action":"merge"}`, // 缺编号(=0 越界)
|
||
`{"action":"merge","target":99}`, // 编号越界
|
||
`{"action":"modify","target":-1}`, // 负数编号
|
||
`{"action":"merge","target":"1"}`, // 编号是字符串(形状不对)
|
||
`[{"action":"merge","target":1}]`, // 数组而不是对象
|
||
}
|
||
for _, reply := range replies {
|
||
reply := reply
|
||
t.Run(reply, func(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
existing, fresh := seedT1GrayPair(t, store)
|
||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{},
|
||
(&t1JudgeStub{reply: reply}).judge, nil)
|
||
|
||
rev, err := r.Review(ctx, fresh)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestCreate {
|
||
t.Fatalf("回复 %q 被判成 %q —— 解析不出枚举必须回落 create", reply, rev.Decision)
|
||
}
|
||
if rev.Reason != t1ReasonReplyInvalid {
|
||
t.Fatalf("回复 %q 的留痕 reason = %q, want %q", reply, rev.Reason, t1ReasonReplyInvalid)
|
||
}
|
||
if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive {
|
||
t.Fatalf("非法回复却改写了记忆:%+v", got)
|
||
}
|
||
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
|
||
t.Fatalf("非法回复却改了既有行:%+v", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestT1TimeoutAndErrorFallBackToCreate 是任务书 §5⑤(design/23 §15 D3-5 的
|
||
// fail-safe):模型超时 / 报错 ⇒ `create` + WARN + **零改写**,绝不阻塞、
|
||
// 绝不丢记忆。
|
||
func TestT1TimeoutAndErrorFallBackToCreate(t *testing.T) {
|
||
t.Parallel()
|
||
cases := map[string]*t1JudgeStub{
|
||
"超时": {delay: 5 * time.Second},
|
||
"上游报错": {fail: errors.New("upstream exploded")},
|
||
}
|
||
for name, stub := range cases {
|
||
name, stub := name, stub
|
||
t.Run(name, func(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
existing, fresh := seedT1GrayPair(t, store)
|
||
var buf bytes.Buffer
|
||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
|
||
// T1 子超时 30ms、单条审查 5s:超时必须由**子预算**触发(≤ 总预算)。
|
||
cfg := ReviewConfig{T1Timeout: 30 * time.Millisecond}
|
||
r := newT1Reviewer(t, store, t1GrayEmbedder(), cfg, stub.judge, log)
|
||
|
||
start := time.Now()
|
||
rev, err := r.Review(ctx, fresh)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||
t.Fatalf("T1 失败拖了 %s:超时没有生效", elapsed)
|
||
}
|
||
if stub.count() != 1 {
|
||
t.Fatalf("模型调用 = %d 次, want 恰为 1", stub.count())
|
||
}
|
||
if rev.Decision != IngestCreate || rev.Reason != t1ReasonUnavailable {
|
||
t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonUnavailable)
|
||
}
|
||
if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive {
|
||
t.Fatalf("T1 失败却改写了新行:%+v", got)
|
||
}
|
||
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
|
||
t.Fatalf("T1 失败却改了既有行:%+v", got)
|
||
}
|
||
// 失败必须留下 WARN(绝不静默),且日志里不许出现记忆正文。
|
||
logged := buf.String()
|
||
if !strings.Contains(logged, "t1 judge fell back to create") {
|
||
t.Fatalf("T1 失败没有记 WARN:%q", logged)
|
||
}
|
||
for _, leaked := range []string{fixtureT1CodeAgreed, fixtureT1CodeViaChannel} {
|
||
if strings.Contains(logged, leaked) {
|
||
t.Fatalf("日志里泄漏了 %q:%q", leaked, logged)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|