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 } } }