主人 2026-10-07 裁定「可以做 T1」。此前 D3 只有纯规则 T0,凡灰区(相似但 互不包含)一律保守 create,真机上「同一事实换措辞」类重复结构性治不住 (清完 92 条字面级重复后,active 里含「喵喵七号」的仍有 101 条)。 - 插入点:T0 判定内核只多吐一份结构化 Gray(惰性收集,恒零模型调用); T1 挂在异步审查层 Review,同步入库闸不碰它 ⇒ 写入路径零模型调用是 **数据流结构**而不是承诺 - 只许回 merge / modify / keep + 一句理由;坏 JSON、非法枚举、编号越界 一律回落 create(绝不猜、绝不挑最近的一条) - 禁产新内容:模型只给「动作 + 候选编号 + 一句理由」,正文一律取自库里 既有的两行 ⇒ T1 结构性地不可能把幻觉写进记忆库 - 不越硬门与红线:灰区已滤掉跨角色/作用域/项目/只读来源与时效/数值/冲突/ 低置信;「规则否决一票否决」不可走样,T1 不提供翻案入口 - 有界:每次审查至多一次调用(灰区打包,上限 8 条),子超时 8s 且不超过 审查总预算;失败只改 reason、决定恒 create、零 UPDATE - 开关 t1Judge(放 $DATA_DIR/config/memory-tidy.json,零新 env 键),默认开; 模型出口用闭包晚绑定既有模型栈(零新客户端、零新依赖) - 留痕 reason 结构化(t1_merge / t1_modify / t1_keep / t1_reply_invalid / t1_unavailable),rule_version d3.1 → d3.2;模型的自由文本理由一律丢弃 - preinsert_config.go 为纯搬移(为守 500 行上限,无新逻辑)
318 lines
12 KiB
Go
318 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:
|
||
// 写入方**绝不等待**异步审查;审查在后台(工作协程)按队列跑完。
|
||
//
|
||
// ⚠️ 2026-10-07 起 D3 是**两层**:写路径上有同步入库闸(preinsert_gate.go,
|
||
// 重复当场落 merged),闸没拦下的才进这条异步队列。因此本用例**显式关闸**
|
||
// (DisableIngestGate)来单独验异步那一层 —— 否则它测的会是闸而不是队列。
|
||
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))
|
||
|
||
asyncOnly, err := NewPreInsertReviewer(ReviewConfig{
|
||
Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second,
|
||
Threshold: 0.80, DisableIngestGate: true,
|
||
}, ReviewDeps{
|
||
Store: store,
|
||
Embedder: stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
}},
|
||
Now: func() time.Time { return tidyTestTime(t) },
|
||
Log: testLogger(),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
r := asyncOnly
|
||
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 装配(不造第二形态会话),且**没接模型出口**时
|
||
// ⇒ 灰区(相似但不包含)**不调任何模型**,只按 T0 回落 create。
|
||
//
|
||
// ⚠️ 2026-10-07 起 T1(灰区模型判定,preinsert_t1.go)默认**开**:本条用例的
|
||
// 「零模型调用」来自**结构性缺席**(ReviewDeps.Judge 为 nil),不是来自默认关。
|
||
// 「T1 开启时真的会调一次模型」由 preinsert_t1_test.go 覆盖。
|
||
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 }
|