【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 无该文件权限,由我按其代码语义补写)。
279 lines
11 KiB
Go
279 lines
11 KiB
Go
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)
|
||
}
|
||
}
|