【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 无该文件权限,由我按其代码语义补写)。
298 lines
12 KiB
Go
298 lines
12 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// 本文件是 D3「入库前审查」的**隔离与异步**测试(design/23 §15 D3-3/4/5/7/8):
|
||
// 群聊/陌生人来源绝不触碰、跨分区绝不合并、写入方绝不等待审查、队列有界诚实、
|
||
// 开关关闭时行为不变、留痕如实登记降级。
|
||
|
||
// TestReviewRestrictedSourceIsNeverTouched 是 design/23 §6/§9 红线的钉死:
|
||
// 群聊/陌生人来源的条目**既不被审查、也不被合并**(只读不写)。
|
||
func TestReviewRestrictedSourceIsNeverTouched(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
groupItem := reviewAt(fixtureHabitShort, base, 0.9)
|
||
groupItem.SourceConversation = FormatSourceConversation("sess-qq-group-1", "m-1")
|
||
group := seedTidyItem(t, store, groupItem)
|
||
owner := 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)
|
||
|
||
// ① 隔离来源的**新条目**:概不审查。
|
||
if _, err := r.Review(ctx, group); !errors.Is(err, ErrReviewRestrictedSource) {
|
||
t.Fatalf("隔离来源审查 err = %v, want %v", err, ErrReviewRestrictedSource)
|
||
}
|
||
if reviews, err := store.ListIngestReviews(ctx, 10); err != nil || len(reviews) != 0 {
|
||
t.Fatalf("隔离来源不该留任何留痕:reviews=%+v err=%v", reviews, err)
|
||
}
|
||
if got := readReviewRow(t, store, group.ID); got.status != tidyStatusActive {
|
||
t.Fatalf("隔离来源被改写了:%+v", got)
|
||
}
|
||
|
||
// ② 隔离来源作为**候选**:不得与主人条目合并(群体来源硬门)。
|
||
rev, err := r.Review(ctx, owner)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestCreate || rev.Veto != VetoSource {
|
||
t.Fatalf("verdict = %+v, want create + source 否决", rev)
|
||
}
|
||
if got := readReviewRow(t, store, owner.ID); got.status != tidyStatusActive {
|
||
t.Fatalf("主人条目被跨来源合并了:%+v", got)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 2 {
|
||
t.Fatalf("活跃行数 = %d, want 2", n)
|
||
}
|
||
}
|
||
|
||
// TestReviewNeverCrossesPersonaOrScope 是 design/23 §15 D3-9⑤ 与 §6 硬约束:
|
||
// 跨角色 / 跨作用域**绝不合并** —— 运行期连比较都不发生,判定层再兜一次否决。
|
||
func TestReviewNeverCrossesPersonaOrScope(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
other := habitItem(fixtureHabitShort, base, 0.9)
|
||
other.PersonaID = "persona.b"
|
||
otherItem := seedTidyItem(t, store, other)
|
||
otherScope := MemoryItem{
|
||
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindRelationship,
|
||
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
|
||
SourceConversation: FormatSourceConversation("web", "msg-1"),
|
||
}
|
||
seedTidyItem(t, store, otherScope)
|
||
mine := 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, mine)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestCreate {
|
||
t.Fatalf("跨分区被判成 %q,分区之间永不比较(§6)", rev.Decision)
|
||
}
|
||
if got := readReviewRow(t, store, otherItem.ID); got.status != tidyStatusActive {
|
||
t.Fatalf("别的角色的记忆被改写了:%+v", got)
|
||
}
|
||
|
||
// 判定层的纵深防御:直接喂一条跨角色候选,必须被硬门否决。
|
||
verdict := judgeIngest(mine, []ingestCandidate{{item: otherItem, score: 0.99, mode: tidyRecallEmbedding}})
|
||
if verdict.Decision != IngestCreate || verdict.Veto != VetoPersona {
|
||
t.Fatalf("跨角色判定 = %+v, want create + persona 否决", verdict)
|
||
}
|
||
}
|
||
|
||
// TestReviewIsNonBlockingAndAsyncPathWorks 是 design/23 §15 D3-3/D3-4:
|
||
// 写入方**绝不等待**审查;审查在后台(工作协程)按队列跑完。
|
||
func TestReviewIsNonBlockingAndAsyncPathWorks(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
|
||
|
||
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
}}, 0.80)
|
||
store.AttachIngestReviewer(r)
|
||
// 未 Start:写入方投递后**立即返回**,审查尚未发生(异步性证据)。
|
||
start := time.Now()
|
||
dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
|
||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||
t.Fatalf("写入等了 %s,审查绝不能阻塞写入方", elapsed)
|
||
}
|
||
if got := readReviewRow(t, store, dup.ID); got.status != tidyStatusActive {
|
||
t.Fatalf("审查在写入方返回前就生效了,说明它是同步的:%+v", got)
|
||
}
|
||
|
||
r.Start(ctx)
|
||
t.Cleanup(func() {
|
||
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
_ = r.Stop(stopCtx)
|
||
})
|
||
waitFor(t, func() bool {
|
||
return readReviewRow(t, store, dup.ID).status == ingestStatusMerged
|
||
}, "后台审查把重复行折回既有行")
|
||
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 2 {
|
||
t.Fatalf("survivor = %+v, want merge_count=2", got)
|
||
}
|
||
}
|
||
|
||
// waitFor 轮询等待条件成立(后台工作协程的完成信号),超时即失败。
|
||
func waitFor(t *testing.T, cond func() bool, what string) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if cond() {
|
||
return
|
||
}
|
||
time.Sleep(5 * time.Millisecond)
|
||
}
|
||
t.Fatalf("等待超时:%s", what)
|
||
}
|
||
|
||
// TestReviewQueueIsBoundedAndDropsHonestly 是 design/23 §15 D3-4:审查有界,
|
||
// 队列满时**有界丢弃**(绝不阻塞写入方),且次数被如实计数。
|
||
func TestReviewQueueIsBoundedAndDropsHonestly(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
r, err := NewPreInsertReviewer(ReviewConfig{
|
||
Enabled: true, QueueSize: 2, ScanLimit: 10, Timeout: time.Second, Threshold: 0.8,
|
||
}, ReviewDeps{Store: store, Log: testLogger()})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
item := reviewAt(fixtureHabitShort, tidyTestTime(t), 0.9)
|
||
item.ID = "item-1"
|
||
accepted := 0
|
||
for i := 0; i < 5; i++ {
|
||
if r.Enqueue(item) {
|
||
accepted++
|
||
}
|
||
}
|
||
if accepted != 2 {
|
||
t.Fatalf("入队 = %d, want 2(队列上限)", accepted)
|
||
}
|
||
if got := r.Stats().Dropped; got != 3 {
|
||
t.Fatalf("丢弃计数 = %d, want 3(绝不静默)", got)
|
||
}
|
||
}
|
||
|
||
// TestReviewIsIdempotentPerItem 是幂等口径:同一条记忆被审第二次时**不再判定**
|
||
// (它已不在册),因此存活行的 merge_count 不会被同一条事实数两次。
|
||
func TestReviewIsIdempotentPerItem(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)
|
||
if _, err := r.Review(ctx, dup); err != nil {
|
||
t.Fatalf("第一次 Review: %v", err)
|
||
}
|
||
if _, err := r.Review(ctx, dup); !errors.Is(err, ErrReviewItemNotActive) {
|
||
t.Fatalf("第二次 Review err = %v, want %v", err, ErrReviewItemNotActive)
|
||
}
|
||
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 2 {
|
||
t.Fatalf("存活行 merge_count = %d, want 2(同一条事实只该数一次)", got.mergeCount)
|
||
}
|
||
if reviews, err := store.ListIngestReviews(ctx, 10); err != nil || len(reviews) != 1 {
|
||
t.Fatalf("审查留痕 = %+v(err=%v), want 恰为一条", reviews, err)
|
||
}
|
||
}
|
||
|
||
// TestReviewDisabledChangesNothing 是 design/23 §15 D3-7 的开关口径:
|
||
// 关闭时审查一次性拒绝,且**不接线**的 store 写入路径逐字回到今天。
|
||
func TestReviewDisabledChangesNothing(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
|
||
|
||
off, err := NewPreInsertReviewer(ReviewConfig{Enabled: false}, ReviewDeps{Store: store, Log: testLogger()})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
off.Start(ctx) // 关闭时只记一条日志并立即返回,不起任何协程
|
||
store.AttachIngestReviewer(off)
|
||
if off.Enqueue(reviewAt(fixtureHabitShort, base, 0.9)) {
|
||
t.Fatal("关闭时不该接受审查任务")
|
||
}
|
||
dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
|
||
if _, err := off.Review(ctx, dup); !errors.Is(err, ErrReviewDisabled) {
|
||
t.Fatalf("关闭时 Review err = %v, want %v", err, ErrReviewDisabled)
|
||
}
|
||
if got := off.Stats(); got.Dropped != 0 {
|
||
t.Fatalf("关闭时不该把条目记成「被丢弃」:%+v", got)
|
||
}
|
||
// 未接线的 store:连审查留痕都不该有,两条记忆都照旧在册。
|
||
if reviews, err := store.ListIngestReviews(ctx, 10); err != nil || len(reviews) != 0 {
|
||
t.Fatalf("关闭时不该有留痕:%+v", reviews)
|
||
}
|
||
if n := countActiveRows(t, store, "persona.a"); n != 2 {
|
||
t.Fatalf("关闭时行为被改动了:活跃行数 = %d, want 2", n)
|
||
}
|
||
}
|
||
|
||
// TestReviewReusesTidySessionAndStaysZeroLLM 是 design/23 §15 D3 的装配口径:
|
||
// 复用既有 sess-memorytidy 装配(不造第二形态会话),且 T1 默认关 + 本切片
|
||
// 未实现 ⇒ 灰区(相似但不包含)**不调任何模型**,只按 T0 回落 create。
|
||
func TestReviewReusesTidySessionAndStaysZeroLLM(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
seedTidyItem(t, store, reviewAt(fixtureEnergyRedBull, base, 0.95))
|
||
gray := 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.88),
|
||
}}, 0.80)
|
||
if r.SessionID() != TidySessionID || !strings.HasPrefix(r.SessionID(), "sess-") {
|
||
t.Fatalf("审查会话 = %q, want 复用 %q", r.SessionID(), TidySessionID)
|
||
}
|
||
rev, err := r.Review(ctx, gray)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestCreate {
|
||
t.Fatalf("灰区被判成 %q,T1 关闭时必须保守回落 create", rev.Decision)
|
||
}
|
||
if rev.RecallMode != tidyRecallEmbedding {
|
||
t.Fatalf("召回来源 = %q, want %q", rev.RecallMode, tidyRecallEmbedding)
|
||
}
|
||
}
|
||
|
||
// TestReviewDegradedRecallIsRecordedHonestly 是 design/23 §15 D3-8 的「是否降级」:
|
||
// embedder 报错时降级到词项余弦,判定照旧,但留痕必须如实写 degraded。
|
||
func TestReviewDegradedRecallIsRecordedHonestly(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
|
||
fresh := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base.Add(time.Minute), 0.9))
|
||
|
||
r := newTestReviewer(t, store, failingReviewEmbedder{}, 0.80)
|
||
rev, err := r.Review(ctx, fresh)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if !rev.Degraded || rev.RecallMode != tidyRecallLexical {
|
||
t.Fatalf("降级未被如实登记:%+v", rev)
|
||
}
|
||
if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive {
|
||
t.Fatalf("降级不该丢掉新条目:%+v", got)
|
||
}
|
||
}
|
||
|
||
// failingReviewEmbedder 模拟「embedder 挂」:Embed 恒报错(design §15 D3-5 的情形之一)。
|
||
type failingReviewEmbedder struct{}
|
||
|
||
func (failingReviewEmbedder) Embed(context.Context, []string) ([]embedding.Vector, error) {
|
||
return nil, errors.New("embedder down")
|
||
}
|
||
|
||
func (failingReviewEmbedder) Dim() int { return 2 }
|