Files
Cyrene_v2/internal/runtime/memory/preinsert_gate_test.go
T
AskaEth 90f430216a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): D3 从「异步后置」改成「同步入库闸」—— 由整理层决定入库形态
主人 2026-10-07 裁定:「把记忆整理功能接到昔涟和记忆库中间做审查,由整理功能
进行入库」。现状是**异步 + 入库后软合并** ⇒ 行已经落库才审,面板/召回先脏后清,
而写入速率实测 ~25 条/小时(≈600/天)⇒ 清理永远追不上。

落法(三条硬规格,全部落地):
① **同步闸但有界**:闸在 `store.go:247` 的 `planIngest`(INSERT **之前**),
   本体 `preinsert_gate.go`。墙钟预算 **150ms**(实测最坏 p95 仅 4~7ms ⇒ 20~50 倍
   余量)、候选扫描上限 **100**(比异步侧 200 更紧,因为它在写路径上)。
   🔴 **零远端 embedding**:闸恒用词项余弦(`deps.Embedder` 根本不参与)——
   远端 embedding 一次 2.3~3.0s 且对同文本返回不同向量(不可复现),不许摆在同步
   路径上拍板。
② **fail-open,绝不丢记忆**:候选源报错 ⇒ `gate:candidates_unavailable`;超预算 ⇒
   `gate:budget_exceeded` ⇒ **照常 insert(active) + 投既有队列 + 留痕 + WARN**。
③ **三态落库(落 merged,而不是"不插入")**:确定性命中(字面重复 / 归一化等价 /
   真包含)⇒ INSERT **直接带 `status='merged'` + `merged_into=<存活行>`**,存活行
   `merged_from` 追加、`merge_count`+1 ⇒ **新行从不进 active、从不建向量索引**,
   而**行与原文都在表里 ⇒ revert 无损**。若"干脆不插",那条文本从未存在过行,
   revert 无从下手,违反 design/23 §8 的红线。
- **不与异步 D3 重复审**:闸做决定的那条 `Async=false`(留痕由闸自己落,
  审计动作名 `memory.ingest.gate`);真新/拿不准的走原有队列(留痕仍归异步层)。
- **开关**:`$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}` ⇒ 一键回到
  「只插不审」(= 改动前的异步行为)。**零新 env 键**(`.env` 未碰)。
- 顺手把 `store.go` 的行映射拆到 `store_rows.go`(守 §10 的 ≤500 行)。

验收(主会话复跑 + 亲自变异):`go build ./...` + `go vet` 绿;memory 包测试绿;
我用 -overlay 摘掉闸的预算 ⇒ `TestIngestGate_Add墙钟有界` 与
`…failOpen_超预算绝不丢记忆` 变红("Add 等了 2.0s,越过闸预算 150ms")。
子代理另做 2 个变异(去掉预算 / 把 fail-open 改成 fail-closed)全红,并实测
**闸开 vs 关的 `Add` 额外墙钟:p50 ≈2.5~3.1ms、p95 ≈4~7ms**(基线批均值 35.8µs)。

⚠️ 未部署(要一次 build + restart);⚠️ 已知边界(如实):闸只用词项余弦 ⇒
「同义但字面差得远」拦不住(有意为之,由异步层/夜间整理兜);闸的候选是**最新
100 条**(更老的重复闸看不到);**并发同内容双写**仍可能两条都落 active(既有竞态,
SELECT 与 INSERT 不在同一事务,非本次引入,第二层会清)。
2026-10-07 11:41:44 +08:00

443 lines
18 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"
"errors"
"fmt"
"testing"
"time"
)
// 本文件是**同步入库闸**(主人 2026-10-07 裁定「把记忆整理功能接到昔涟和记忆库
// 中间做审查,由整理功能进行入库」)的验收用例集:三态落库、确定性判据为主、
// 红线不误判、fail-open 不丢记忆、开关一键回退、revert 无损、墙钟有界。
//
// 它与 preinsert_test.go / preinsert_async_test.go 的分工:那两份测的是**异步**
// 那一层(落库之后由后台队列补审),本文件测的是**写路径上**的那一层。
// 闸开着时,重复行在 `Store.Add` 返回之前就已经是 `merged` —— 因此「异步队列
// 才把它折回」的旧断言必须改成「闸当场折回」(见 preinsert_async_test.go 的
// TestReviewIsNonBlockingAndAsyncPathWorks,它现在显式关闸来单独验异步层)。
// gateFixtureItem 构造同分区(persona.a / persona / 无项目)、owner 来源的待落库条目。
func gateFixtureItem(content string, at time.Time) MemoryItem {
return habitItem(content, at, 0.9)
}
// gateTestCorpus 生成「同主题、数值各异」的语料:它们互相的相似度很高(因此
// 候选扫描打满),但数值不同 ⇒ 数值否决 ⇒ 恒为 create。墙钟实测用它构造
// **最坏可复现场景**:每一轮都真扫满候选、真判完,且一次都不合并。
func gateTestCorpus(i int, at time.Time) MemoryItem {
return gateFixtureItem(fmt.Sprintf("主人常在第 %d 个时段与助手聊天,有熬夜习惯", i), at)
}
// newGateReviewer 构建「闸开着」的审查服务。candidates 非 nil 时注入候选源
// (测试用它精确控制候选集、注入慢源与坏源)。零值配置项按代码默认值补齐 ——
// **尤其是 GateBudget**:它留零值才能让「默认预算」这条被测试覆盖到。
func newGateReviewer(t *testing.T, store *Store, cfg ReviewConfig,
candidates func(context.Context, tidyPartition, string, int) ([]MemoryItem, error)) *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
}
r, err := NewPreInsertReviewer(cfg, ReviewDeps{
Store: store,
Now: func() time.Time { return tidyTestTime(t) },
GateCandidates: candidates,
Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
return r
}
// gateRecordFor 读回某一条记忆的闸留痕(按 item_id 过滤)。
func gateRecordFor(t *testing.T, store *Store, itemID string) IngestReview {
t.Helper()
reviews, err := store.ListIngestReviews(context.Background(), 50)
if err != nil {
t.Fatalf("ListIngestReviews: %v", err)
}
for _, rev := range reviews {
if rev.ItemID == itemID {
return rev
}
}
return IngestReview{}
}
// TestIngestGate_字面重复_新行落merged且active不变 是任务书必备用例①:
// 字面重复必须**当场**落 `merged`,active 计数不变、存活行 merge_count +1,
// 且正文一个字不丢(保住 revert)。
func TestIngestGate_字面重复_新行落merged且active不变(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{}, nil)
store.AttachIngestReviewer(r)
survivor := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
dup := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
got := readReviewRow(t, store, dup.ID)
if got.status != ingestStatusMerged || got.mergedInto != survivor.ID {
t.Fatalf("重复行未被闸当场折回:status=%q merged_into=%q,want merged/%s", got.status, got.mergedInto, survivor.ID)
}
if got.content != fixtureHabitShort {
t.Fatalf("闸下的行正文被改了:%q", got.content)
}
if n := countActiveRows(t, store, "persona.a"); n != 1 {
t.Fatalf("active 计数 = %d, want 1(闸的意义就是 active 当场干净)", n)
}
if s := readReviewRow(t, store, survivor.ID); s.mergeCount != 2 {
t.Fatalf("存活行 merge_count = %d, want 2", s.mergeCount)
}
rev := gateRecordFor(t, store, dup.ID)
if rev.Decision != IngestSkip || rev.Reason != "exact_duplicate" || rev.TargetID != survivor.ID {
t.Fatalf("闸留痕 = %+v, want skip/exact_duplicate 指向存活行", rev)
}
if rev.SessionID != TidySessionID || rev.RuleVer != IngestRuleVersion {
t.Fatalf("闸留痕的会话/规则版本没登记:%+v", rev)
}
if rev.RecallMode != tidyRecallLexical {
t.Fatalf("闸留痕的召回来源 = %q, want %q(同步路径绝不调远端 embedding)", rev.RecallMode, tidyRecallLexical)
}
if s := r.Stats(); s.GateAbsorbed != 1 || s.GateChecked != 2 {
// 2 = 第一条(无候选 ⇒ 正常插入)+ 重复条(命中 ⇒ 吸收)。
t.Fatalf("闸计数 = %+v, want gate_checked=2 gate_absorbed=1", s)
}
// 闸挡下的那条**绝不再投异步队列**(否则同一件事两条路径):队列里只该有
// 第一条(它没有候选 ⇒ 正常插入,照旧走既有队列)。
if n := len(r.queue); n != 1 {
t.Fatalf("队列 = %d 条,want 1(重复的那条不该被再投一次)", n)
}
if err := ctx.Err(); err != nil {
t.Fatalf("ctx: %v", err)
}
}
// TestIngestGate_归一化等价_同样落merged 是任务书必备用例②:大小写/空白归一化
// 后等价的文本(dedupeKey 相等)必须和字面重复同一处理。
func TestIngestGate_归一化等价_同样落merged(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{}, nil)
store.AttachIngestReviewer(r)
survivor := seedTidyItem(t, store, gateFixtureItem("主人爱喝 ICED 美式", base))
dup := seedTidyItem(t, store, gateFixtureItem(" 主人爱喝 iced 美式 ", base.Add(time.Minute)))
got := readReviewRow(t, store, dup.ID)
if got.status != ingestStatusMerged || got.mergedInto != survivor.ID {
t.Fatalf("归一化等价未被闸折回:%+v(存活行 %s)", got, survivor.ID)
}
if n := countActiveRows(t, store, "persona.a"); n != 1 {
t.Fatalf("active 计数 = %d, want 1", n)
}
if s := readReviewRow(t, store, survivor.ID); s.mergeCount != 2 {
t.Fatalf("存活行 merge_count = %d, want 2", s.mergeCount)
}
}
// TestIngestGate_真新_正常insert并走既有队列 是任务书必备用例③:确实新的条目
// 正常 insert(active),并且**照旧投既有异步队列**(两层而不是替代)。
func TestIngestGate_真新_正常insert并走既有队列(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{}, nil)
store.AttachIngestReviewer(r)
first := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
second := seedTidyItem(t, store, gateFixtureItem(fixtureEnergyRedBull, base.Add(time.Minute)))
for _, it := range []MemoryItem{first, second} {
got := readReviewRow(t, store, it.ID)
if got.status != tidyStatusActive || got.mergedInto != "" {
t.Fatalf("真新条目被改了落库形态:%+v", got)
}
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("active 计数 = %d, want 2", n)
}
if s := r.Stats(); s.GateAbsorbed != 0 || s.GateChecked != 2 {
t.Fatalf("闸计数 = %+v, want 只检查、不吸收", s)
}
if n := len(r.queue); n != 2 {
t.Fatalf("真新条目投队列 = %d, want 2(闸与异步队列是两层)", n)
}
if revs, err := store.ListIngestReviews(context.Background(), 10); err != nil || len(revs) != 0 {
t.Fatalf("闸对「真新」不该留决定留痕(那段由异步队列负责):%+v err=%v", revs, err)
}
}
// TestIngestGate_红线_习惯与一次性事件绝不误合 是任务书必备用例④与
// design/23 §5.2 的红线:时效/一次性事件的条目即便高度相似也**绝不合并**
// (候选直接喂进判定层,因此这条测的是规则层本身,不是召回)。
func TestIngestGate_红线_习惯与一次性事件绝不误合(t *testing.T) {
for _, tc := range []struct {
name string
candidate string
wantVeto TidyVetoReason
}{
{"一次性事件", fixtureOneOffEvent, VetoTemporal},
{"有期限状态", fixtureHabitVacation, VetoTemporal},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
cand := gateFixtureItem(tc.candidate, base)
r := newGateReviewer(t, store, ReviewConfig{},
func(context.Context, tidyPartition, string, int) ([]MemoryItem, error) {
return []MemoryItem{cand}, nil
})
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, gateFixtureItem(fixtureHabitPlain, base.Add(time.Minute)))
row := readReviewRow(t, store, got.ID)
if row.status != tidyStatusActive || row.mergedInto != "" {
t.Fatalf("%s 被误合了:%+v", tc.name, row)
}
if s := r.Stats(); s.GateAbsorbed != 0 {
t.Fatalf("%s 被闸吸收:%+v", tc.name, s)
}
})
}
}
// TestIngestGate_跨分区绝不合并 是任务书必备用例⑤:分区键(persona_id +
// scope + project_id)是硬门 —— 运行期 SQL 就把别的分区隔断,判定层再兜一次。
func TestIngestGate_跨分区绝不合并(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{}, nil)
store.AttachIngestReviewer(r)
otherPersona := gateFixtureItem(fixtureHabitShort, base)
otherPersona.PersonaID = "persona.b"
seedTidyItem(t, store, otherPersona)
otherScope := gateFixtureItem(fixtureHabitShort, base)
otherScope.Scope = ScopeUser
otherScopeItem := seedTidyItem(t, store, otherScope)
otherProject := gateFixtureItem(fixtureHabitShort, base)
otherProject.Scope = ScopeProject
otherProject.ProjectID = "proj.other"
otherProjectItem := seedTidyItem(t, store, otherProject)
mine := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
row := readReviewRow(t, store, mine.ID)
if row.status != tidyStatusActive || row.mergedInto != "" {
t.Fatalf("跨分区被合并了:%+v", row)
}
for _, id := range []string{otherScopeItem.ID, otherProjectItem.ID} {
if got := readReviewRow(t, store, id); got.status != tidyStatusActive {
t.Fatalf("别的分区的记忆被改写了:%+v", got)
}
}
// 纵深防御:把跨角色候选直接喂进判定层,硬门必须否决。
verdict := judgeIngest(mine, []ingestCandidate{{item: otherPersona, score: 0.99, mode: tidyRecallLexical}})
if verdict.Decision != IngestCreate || verdict.Veto != VetoPersona {
t.Fatalf("跨角色判定 = %+v, want create + persona 否决", verdict)
}
}
// TestIngestGate_failOpen_候选源不可用绝不丢记忆 是任务书必备用例⑥(其一):
// 候选源报错 ⇒ **照常插入 active** + 留痕(原因码),绝不静默丢。
func TestIngestGate_failOpen_候选源不可用绝不丢记忆(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{},
func(context.Context, tidyPartition, string, int) ([]MemoryItem, error) {
return nil, errors.New("candidate source down")
})
store.AttachIngestReviewer(r)
item := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
row := readReviewRow(t, store, item.ID)
if row.status != tidyStatusActive || row.content != fixtureHabitShort {
t.Fatalf("fail-open 丢了记忆:%+v", row)
}
rev := gateRecordFor(t, store, item.ID)
if rev.Reason != gateTraceUnavailable || rev.Decision != IngestCreate {
t.Fatalf("fail-open 留痕 = %+v, want reason=%s", rev, gateTraceUnavailable)
}
if s := r.Stats(); s.GateFailed != 1 {
t.Fatalf("fail-open 计数 = %+v, want gate_failed=1", s)
}
if n := len(r.queue); n != 1 {
t.Fatalf("fail-open 的条目没投既有队列(%d 条):兜底那一层断了", n)
}
}
// TestIngestGate_failOpen_超预算绝不丢记忆 是任务书必备用例⑥(其二):
// 候选源慢到越过闸的总预算 ⇒ 同样是照常插入 + 留痕,且**绝不超预算返回**。
func TestIngestGate_failOpen_超预算绝不丢记忆(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{}, gateSlowCandidates(2*time.Second))
store.AttachIngestReviewer(r)
start := time.Now()
item := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
elapsed := time.Since(start)
row := readReviewRow(t, store, item.ID)
if row.status != tidyStatusActive || row.content != fixtureHabitShort {
t.Fatalf("超预算时丢了记忆:%+v", row)
}
rev := gateRecordFor(t, store, item.ID)
if rev.Reason != gateTraceBudgetExceeded {
t.Fatalf("超预算留痕 = %+v, want reason=%s", rev, gateTraceBudgetExceeded)
}
if elapsed > time.Second {
t.Fatalf("超预算的写入等了 %s,闸的预算没起作用", elapsed)
}
}
// TestIngestGate_开关关闭_回到只插不审 是任务书必备用例⑦:闸被显式关掉(或总
// 开关关闭)时,写入路径逐字回到改动之前 —— 正常 insert + 投既有异步队列。
func TestIngestGate_开关关闭_回到只插不审(t *testing.T) {
for _, tc := range []struct {
name string
enabled bool
disableGate bool
enqueue bool
}{
{"闸关(memory-tidy.json 的 ingestGate=false)", true, true, true},
{"总开关关(MEMORY_REVIEW_ENABLED=false)", false, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
// newGateReviewer 构建的是「闸开着」的服务,因此总开关关闭那一支
// 直接用构造函数建关闭态(它有独立的语义:不建、不接、不跑)。
var r *PreInsertReviewer
if tc.enabled {
r = newGateReviewer(t, store, ReviewConfig{
Enabled: tc.enabled, DisableIngestGate: tc.disableGate,
}, nil)
} else {
var err error
r, err = NewPreInsertReviewer(ReviewConfig{Enabled: false}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
}
store.AttachIngestReviewer(r)
seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
dup := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
row := readReviewRow(t, store, dup.ID)
if row.status != tidyStatusActive || row.mergedInto != "" {
t.Fatalf("闸关闭时仍改了落库形态:%+v", row)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("闸关闭时 active 计数 = %d, want 2", n)
}
if s := r.Stats(); s.GateChecked != 0 || s.GateAbsorbed != 0 {
t.Fatalf("闸关闭时闸仍跑了:%+v", s)
}
if revs, err := store.ListIngestReviews(context.Background(), 10); err != nil || len(revs) != 0 {
t.Fatalf("闸关闭时不该有留痕:%+v err=%v", revs, err)
}
wantQueued := 0
if tc.enqueue {
wantQueued = 2
}
if n := len(r.queue); n != wantQueued {
t.Fatalf("投队列 = %d, want %d(关闸即回到改动之前的异步行为)", n, wantQueued)
}
})
}
}
// TestIngestGate_revert_能把闸下落的merged行复原 是任务书必备用例⑧:
// 闸落的是 `merged`(不是「不插入」)⇒ revert 无损复原。
func TestIngestGate_revert_能把闸下落的merged行复原(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{}, nil)
store.AttachIngestReviewer(r)
survivor := seedTidyItem(t, store, gateFixtureItem(fixtureHabitLong, base))
dup := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
if got := readReviewRow(t, store, dup.ID); got.status != ingestStatusMerged {
t.Fatalf("前置条件不成立(闸没折回):%+v", got)
}
if err := store.RevertMerged(ctx, dup.ID); err != nil {
t.Fatalf("RevertMerged: %v", err)
}
restored := readReviewRow(t, store, dup.ID)
if restored.status != tidyStatusActive || restored.mergedInto != "" || restored.content != fixtureHabitShort {
t.Fatalf("revert 后 = %+v, want active 且原文无损", restored)
}
if s := readReviewRow(t, store, survivor.ID); s.mergeCount != 1 {
t.Fatalf("revert 后存活行 merge_count = %d, want 1(登记也一并摘掉)", s.mergeCount)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("revert 后 active 计数 = %d, want 2", n)
}
}
// TestIngestGate_Add墙钟有界 是任务书必备用例⑨:候选源慢过预算时,`Add` 的
// 墙钟仍由闸的预算封顶(注入慢审查器 ⇒ 断言不超预算)。
//
// 它**刻意不设 GateBudget**,走代码默认值 —— 这样「去掉预算」这条变异会把
// 它变红(见报告里的变异自证)。
func TestIngestGate_Add墙钟有界(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
r := newGateReviewer(t, store, ReviewConfig{}, gateSlowCandidates(2*time.Second))
store.AttachIngestReviewer(r)
start := time.Now()
seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base))
elapsed := time.Since(start)
budget := defaultReviewGateBudget
if elapsed > budget+500*time.Millisecond {
t.Fatalf("Add 等了 %s,越过闸预算 %s 太多:闸没有界", elapsed, budget)
}
if s := r.Stats(); s.GateFailed != 1 || s.GateChecked != 0 {
t.Fatalf("闸计数 = %+v, want 一条 fail-open", s)
}
}
// gateSlowCandidates 返回一个「慢候选源」:它一直等到 ctx 取消或 d 到点。
// 它**尊重 ctx**(生产里的候选源是 SQL 查询,同样尊重 ctx),因此预算真的
// 能把它截断;这是「预算有界」可确定性验证的前提。
func gateSlowCandidates(d time.Duration) func(context.Context, tidyPartition, string, int) ([]MemoryItem, error) {
return func(ctx context.Context, _ tidyPartition, _ string, _ int) ([]MemoryItem, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(d):
return nil, nil
}
}
}