Files
Cyrene_v2/internal/runtime/memory/preinsert_gate_extra_test.go
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

131 lines
5.1 KiB
Go
Raw Permalink 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"
"strings"
"testing"
"time"
)
// 本文件是同步入库闸(preinsert_gate.go)的两项**旁证**,与主用例集
// (preinsert_gate_test.go 的九条)分开:
//
// - **真包含**(新 ⊆ 既有 ⇒ merge;既有 ⊆ 新 ⇒ modify):任务书规格 3 的表格
// 只写了三态,但确定性判据实际有 skip/merge/modify 三种命中形态,而
// merge 与 modify 落的是**不同的两笔写**(前者新行落 merged + 存活行加登记,
// 后者新行存活 + 旧行置为被合并)。这两条路径必须各自被钉住,且都要能 revert。
// - **审计口径**:闸的审计**只带决定与 id,绝不带记忆正文**(design §15 D3-8)。
// TestIngestGate_真包含_merge与modify各自落对 覆盖确定性判据命中的另两种形态。
// 候选由测试直接注入,因此这条测的是**判定与落库**,不是召回。
func TestIngestGate_真包含_merge与modify各自落对(t *testing.T) {
for _, tc := range []struct {
name string
existing string
incoming string
want IngestDecision
newActive bool
}{
{"新条是子集 ⇒ merge(既有行存活)", fixtureHabitLong, fixtureHabitShort, IngestMerge, false},
{"既有是子集 ⇒ modify(新行存活、旧行被合并)", fixtureHabitShort, fixtureHabitLong, IngestModify, true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
var existing MemoryItem
r := newGateReviewer(t, store, ReviewConfig{},
func(context.Context, tidyPartition, string, int) ([]MemoryItem, error) {
if existing.ID == "" {
return nil, nil
}
return []MemoryItem{existing}, nil
})
store.AttachIngestReviewer(r)
existing = seedTidyItem(t, store, gateFixtureItem(tc.existing, base))
incoming := seedTidyItem(t, store, gateFixtureItem(tc.incoming, base.Add(time.Minute)))
if rev := gateRecordFor(t, store, incoming.ID); rev.Decision != tc.want {
t.Fatalf("闸判定 = %q, want %q", rev.Decision, tc.want)
}
// active 计数恒为 1:合并与改写都不该让 active 集合多出或少掉一条。
if n := countActiveRows(t, store, "persona.a"); n != 1 {
t.Fatalf("active 计数 = %d, want 1", n)
}
survivorID, absorbedID := existing.ID, incoming.ID
if tc.newActive {
survivorID, absorbedID = incoming.ID, existing.ID
}
if got := readReviewRow(t, store, survivorID); got.status != tidyStatusActive || got.mergeCount != 2 {
t.Fatalf("存活行 = %+v, want active 且 merge_count=2", got)
}
absorbed := readReviewRow(t, store, absorbedID)
if absorbed.status != ingestStatusMerged || absorbed.mergedInto != survivorID {
t.Fatalf("被吸收行 = %+v, want merged 进 %s", absorbed, survivorID)
}
// 正文一个字不改(design §8 的可回滚口径)。
wantContent := tc.incoming
if tc.newActive {
wantContent = tc.existing
}
if absorbed.content != wantContent {
t.Fatalf("被吸收行正文 = %q, want %q", absorbed.content, wantContent)
}
// modify 走的是一笔**独立**的写(把旧行置为被合并),因此这里额外验
// 它同样可 revert。
if tc.newActive {
if err := store.RevertMerged(ctx, absorbedID); err != nil {
t.Fatalf("RevertMerged: %v", err)
}
restored := readReviewRow(t, store, absorbedID)
if restored.status != tidyStatusActive || restored.mergedInto != "" || restored.content != wantContent {
t.Fatalf("revert 后 = %+v, want active 且原文无损", restored)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("revert 后 active 计数 = %d, want 2", n)
}
}
})
}
}
// TestIngestGate_审计只带决定不带正文 钉住闸的审计口径(design §15 D3-8):
// 动作名是 memory.ingest.gate(与异步审查的 memory.review.ingest 可分),
// 内容里**绝不出现记忆正文**。
func TestIngestGate_审计只带决定不带正文(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
var calls []string
r, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second, Threshold: 0.80,
}, ReviewDeps{
Store: store,
Log: testLogger(),
Audit: func(who, what, result string) { calls = append(calls, who+"|"+what+"|"+result) },
})
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)))
if len(calls) != 1 {
t.Fatalf("审计条数 = %d (%v), want 恰为一条(闸做过决定的那一条)", len(calls), calls)
}
got := calls[0]
if !strings.HasPrefix(got, reviewAuditWho+"|"+gateAuditVerb+"|") {
t.Fatalf("审计口径 = %q, want %s|%s|…", got, reviewAuditWho, gateAuditVerb)
}
if !strings.Contains(got, "decision=skip") || !strings.Contains(got, "item="+dup.ID) {
t.Fatalf("审计缺决定/命中 id:%q", got)
}
if strings.Contains(got, fixtureHabitShort) {
t.Fatalf("审计带了记忆正文:%q", got)
}
}