Files
Cyrene_v2/internal/runtime/memory/preinsert_test.go
T
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

279 lines
11 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"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 D3「入库前审查」(design/23 §15.1 D3)的**四态判定与 fail-safe** 测试:
// 完全等 ⇒ skip、真包含 ⇒ merge/modify、不相似/并列 ⇒ create、审查不可用 ⇒
// 一行都不改写。隔离、异步与开关口径在 preinsert_async_test.go。
// reviewRowState 是测试直接读回的一行记忆的整理列。
type reviewRowState struct {
status string
mergedInto string
mergeCount int
confidence float64
content string
}
// readReviewRow 直读一行记忆的整理列(status 等列不在 Store.Get 的读侧面上)。
func readReviewRow(t *testing.T, store *Store, id string) reviewRowState {
t.Helper()
var st reviewRowState
if err := store.db.QueryRowContext(context.Background(),
`SELECT status, merged_into, merge_count, confidence, content FROM memory_items WHERE id = ?`,
id).Scan(&st.status, &st.mergedInto, &st.mergeCount, &st.confidence, &st.content); err != nil {
t.Fatalf("read row %s: %v", id, err)
}
return st
}
// countActiveRows 数一个分区里还在册(status='active')的条目数。
func countActiveRows(t *testing.T, store *Store, personaID string) int {
t.Helper()
var n int
if err := store.db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM memory_items WHERE persona_id = ? AND status = ?`,
personaID, string(tidyStatusActive)).Scan(&n); err != nil {
t.Fatalf("count active rows: %v", err)
}
return n
}
// newTestReviewer 构建固定时钟的审查服务。阈值显式写死,其余走设计稿默认值。
func newTestReviewer(t *testing.T, store *Store, emb embedding.Embedder, threshold float64) *PreInsertReviewer {
t.Helper()
r, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true,
QueueSize: 8,
ScanLimit: 50,
Timeout: 5 * time.Second,
Threshold: threshold,
}, ReviewDeps{
Store: store,
Embedder: emb,
Now: func() time.Time { return tidyTestTime(t) },
Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
return r
}
// reviewAt 构造与既有条目同分区、可指定内容与溯源的新条目。
func reviewAt(content string, at time.Time, confidence float64) MemoryItem {
item := habitItem(content, at, confidence)
item.SourceConversation = FormatSourceConversation("web", "msg-review")
return item
}
// TestReviewExactDuplicateDoesNotCreateASecondActiveRow 是 design/23 §15 D3-9①:
// **完全等价 ⇒ 不新建**(这正是主人报的「反复新建」)。活跃行数必须仍是 1。
func TestReviewExactDuplicateDoesNotCreateASecondActiveRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
}}, 0.80)
rev, err := r.Review(ctx, dup)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestSkip {
t.Fatalf("decision = %q, want %q", rev.Decision, IngestSkip)
}
if rev.TargetID != existing.ID {
t.Fatalf("命中的既有 id = %q, want %q", rev.TargetID, existing.ID)
}
got := readReviewRow(t, store, dup.ID)
if got.status != ingestStatusMerged || got.mergedInto != existing.ID {
t.Fatalf("重复行未折回既有行:status=%q merged_into=%q", got.status, got.mergedInto)
}
survivor := readReviewRow(t, store, existing.ID)
if survivor.status != tidyStatusActive || survivor.mergeCount != 2 {
t.Fatalf("既有行 survivor = %+v, want active 且 merge_count=2", survivor)
}
if n := countActiveRows(t, store, "persona.a"); n != 1 {
t.Fatalf("活跃行数 = %d, want 1(反复新建没有被治住)", n)
}
reviews, err := store.ListIngestReviews(ctx, 10)
if err != nil {
t.Fatalf("ListIngestReviews: %v", err)
}
if len(reviews) != 1 || reviews[0].Decision != IngestSkip || reviews[0].RuleVer != IngestRuleVersion {
t.Fatalf("审查留痕 = %+v, want 恰为一条 skip 且带规则版本", reviews)
}
if reviews[0].SessionID != TidySessionID {
t.Fatalf("留痕会话 = %q, want 复用 %q(记忆管理会话)", reviews[0].SessionID, TidySessionID)
}
}
// TestReviewSubsetMergesAndIsRevertable 是 design/23 §15 D3-9②:真包含 ⇒ 合并,
// 且**旧行可 revert**(软删除绝不物理删除,§8)。
func TestReviewSubsetMergesAndIsRevertable(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base, 0.9))
short := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitLong: {1, 0},
fixtureHabitShort: simVector(0.95),
}}, 0.80)
rev, err := r.Review(ctx, short)
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)
}
absorbed := readReviewRow(t, store, short.ID)
if absorbed.status != ingestStatusMerged || absorbed.content != fixtureHabitShort {
t.Fatalf("被并入行 = %+v, want merged 且原文逐字保留", absorbed)
}
survivor := readReviewRow(t, store, existing.ID)
if survivor.mergeCount != 2 || survivor.content != fixtureHabitLong {
t.Fatalf("存活行 = %+v, want merge_count=2 且 content 不改", survivor)
}
if err := store.RevertMerged(ctx, short.ID); err != nil {
t.Fatalf("RevertMerged: %v", err)
}
restored := readReviewRow(t, store, short.ID)
if restored.status != tidyStatusActive || restored.mergedInto != "" || restored.content != fixtureHabitShort {
t.Fatalf("revert 后 = %+v, want active 且原文无损", restored)
}
afterRevert := readReviewRow(t, store, existing.ID)
if afterRevert.mergeCount != 1 {
t.Fatalf("revert 后存活行 merge_count = %d, want 1(登记也一并摘掉)", afterRevert.mergeCount)
}
}
// TestReviewSupersetModifiesExisting 是 design/23 §15 D3-6 的「改写」路径:
// 既有条目是新条目的子集 ⇒ **新行承载更全的内容**,旧行置为被合并。
func TestReviewSupersetModifiesExisting(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
longer := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}}, 0.80)
rev, err := r.Review(ctx, longer)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestModify || rev.TargetID != existing.ID {
t.Fatalf("verdict = %+v, want modify(既有行被改写)", rev)
}
if got := readReviewRow(t, store, longer.ID); got.status != tidyStatusActive || got.content != fixtureHabitLong {
t.Fatalf("新行 = %+v, want 存活且承载更全内容", got)
}
old := readReviewRow(t, store, existing.ID)
if old.status != ingestStatusMerged || old.mergedInto != longer.ID {
t.Fatalf("旧行 = %+v, want merged 进新行", old)
}
if n := countActiveRows(t, store, "persona.a"); n != 1 {
t.Fatalf("活跃行数 = %d, want 1", n)
}
}
// TestReviewDissimilarAndParallelFactsCreate 是 design/23 §15 D3-9③ 与主人红线
// 「不要什么内容都合并」:不相似、以及**相似但并列**的事实都正常新建。
func TestReviewDissimilarAndParallelFactsCreate(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureEnergyRedBull, base, 0.95))
parallel := seedTidyItem(t, store, reviewAt(fixtureEnergyIced, base.Add(time.Minute), 0.95))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureEnergyRedBull: {1, 0},
fixtureEnergyIced: simVector(0.99),
}}, 0.80)
rev, err := r.Review(ctx, parallel)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate {
t.Fatalf("并列事实被判成 %q,主人红线是「不要什么内容都合并」", rev.Decision)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("活跃行数 = %d, want 2", n)
}
}
// TestReviewTemporalPollutionIsNeverMerged 是 design/23 §5.2-1:含时效词
// (假期/期间…)的记忆不参与合并 —— 有期限的状态不是长期事实。
func TestReviewTemporalPollutionIsNeverMerged(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureHabitPlain, base, 0.9))
vacation := seedTidyItem(t, store, reviewAt(fixtureHabitVacation, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitPlain: {1, 0},
fixtureHabitVacation: simVector(0.97),
}}, 0.80)
rev, err := r.Review(ctx, vacation)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate || rev.Veto != VetoTemporal {
t.Fatalf("verdict = %+v, want create + temporal 否决", rev)
}
if q := readReviewRow(t, store, vacation.ID); q.status != tidyStatusActive {
t.Fatalf("时效条目被改写了:%+v", q)
}
}
// TestReviewFailureNeverTouchesMemory 是 design/23 §15 D3-9④ 的 fail-safe:
// 审查不可用(超时/取消)⇒ **照旧保留**(一行都不改写)+ 交给调用方记 WARN。
func TestReviewFailureNeverTouchesMemory(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base, 0.9))
short := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitLong: {1, 0},
fixtureHabitShort: simVector(0.95),
}}, 0.80)
// 已经取消的 context:审查必须如实失败,而不是「猜一个结论」。
dead, cancel := context.WithCancel(context.Background())
cancel()
if _, err := r.Review(dead, short); err == nil {
t.Fatal("审查在不可用时返回了成功,必须如实报错")
}
if got := readReviewRow(t, store, short.ID); got.status != tidyStatusActive {
t.Fatalf("审查失败却改写了记忆:%+v", got)
}
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
t.Fatalf("审查失败却改了 survivor:%+v", got)
}
if reviews, err := store.ListIngestReviews(context.Background(), 10); err != nil || len(reviews) != 0 {
t.Fatalf("审查失败不该留决定留痕:reviews=%+v err=%v", reviews, err)
}
}