package memory import ( "context" "math" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // 本文件收拢记忆整理的**否决与边界/配置**用例:来源硬门、低置信、kind 误分类、 // 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→不产候选 / // 跨作用域 / 时效污染 / 空库 / 幂等)在 tidy_test.go。 // TestTidyGroupSourceExcluded 是 design/23 §12.2 N7 与 §5.2-9/§6:群聊/陌生人 // 来源的条目既不合并也不归档,只读不写。 func TestTidyGroupSourceExcluded(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) groupItem := seedTidyItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base, SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"), }) ownerItem := seedTidyItem(t, store, habitItem(fixtureHabitShort, base.Add(time.Minute), 0.9)) tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, }}) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.VetoReasons[string(VetoSource)] < 1 { t.Fatalf("否决原因分布里没有 source:%+v", stats.VetoReasons) } cands, err := store.ListTidyCandidates(ctx, "", 50) if err != nil { t.Fatalf("ListTidyCandidates: %v", err) } for _, c := range cands { if containsID(c.SourceIDs, groupItem.ID) { t.Fatalf("群聊来源条目进了候选:%+v", c) } } if ownerItem.ID == "" { t.Fatal("owner 条目应当落库成功") } } // TestTidyLowConfidenceVetoed 是 design/23 §12.2 N4 与 §5.2-6:confidence 不是 // 合并门,但必须是否决门。 func TestTidyLowConfidenceVetoed(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) low := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.6)) high := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) if v := judgePair(low, high, 0.99); v.Veto != VetoLowConfidence { t.Fatalf("低置信 veto = %q, want %q", v.Veto, VetoLowConfidence) } tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, fixtureHabitLong: simVector(0.97), }}) if _, err := tr.RunOnce(ctx); err != nil { t.Fatalf("RunOnce: %v", err) } cands, err := store.ListTidyCandidates(ctx, "", 50) if err != nil { t.Fatalf("ListTidyCandidates: %v", err) } if len(cands) != 0 { t.Fatalf("低置信对产出了候选:%+v", cands) } } // TestTidyKindMismatchAlsoSuggestsReclassify 是 design/23 §12.1 P7 的口径: // 包含关系成立但 kind 不同 ⇒ 既产 merge 候选,也产 reclassify 候选。 // // 2026-10-07「全自动挡吧」起两条的**归宿不同**:这里的 merge 是真子集(不在自动档 // 判据内)⇒ 留待人工;reclassify 形状完整 ⇒ 由轮末自动档当场落地(只改 kind 那一行)。 // 因此候选要按**全部状态**读,只看待办队列会把「已经自动改完了」读成「没产出」。 func TestTidyKindMismatchAlsoSuggestsReclassify(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) short := MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent, Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base, } long := MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: fixtureHabitLong, Confidence: 0.9, CreatedAt: base.Add(time.Hour), } seededShort := seedTidyItem(t, store, short) seedTidyItem(t, store, long) tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, fixtureHabitLong: simVector(0.95), }}) if _, err := tr.RunOnce(ctx); err != nil { t.Fatalf("RunOnce: %v", err) } cands := allTidyCandidates(t, store, 50) merges := candidatesByAction(cands, TidyMerge) reclasses := candidatesByAction(cands, TidyReclassify) if len(merges) != 1 || len(reclasses) != 1 { t.Fatalf("期望 1 条 merge + 1 条 reclassify,实际 %+v", cands) } if reclasses[0].Status != tidyCandidateApplied { t.Fatalf("shape 完整的 reclassify 候选该被自动档落地:%+v", reclasses[0]) } if merges[0].Status != tidyCandidatePending { t.Fatalf("真子集 merge 不在自动档判据内,该留待人工:%+v", merges[0]) } if got := loadTidyItemRow(t, store, seededShort.ID); got.kind != string(KindRelationship) { t.Fatalf("被误分类的行 kind = %q, want %q(自动档只改分类列)", got.kind, KindRelationship) } if got := loadTidyItemRow(t, store, seededShort.ID); got.content != fixtureHabitShort { t.Fatalf("reclassify 改了正文:%q", got.content) } } // TestTidyCandidateLimitPerPartition 锁定 design/23 §9 的每分区候选上限: // 超出的只登记不入队,且绝不静默。 func TestTidyCandidateLimitPerPartition(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) shortA, longA := fixtureWorkShort, fixtureWorkLong shortB, longB := "主人喜欢被叫小笨蛋", "主人喜欢被叫小笨蛋,还喜欢被摸摸头" seedTidyItem(t, store, habitItem(shortA, base, 0.9)) seedTidyItem(t, store, habitItem(longA, base.Add(time.Minute), 0.9)) seedTidyItem(t, store, habitItem(shortB, base.Add(2*time.Minute), 0.9)) seedTidyItem(t, store, habitItem(longB, base.Add(3*time.Minute), 0.9)) cfg := tidyTestCfg(0.80) cfg.CandidateLimit = 1 // 两族向量彼此近似正交(跨族 cosine ≈ 0.31 < 0.80),因此恰好召回 2 对。 tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{ shortA: {1, 0}, longA: simVector(0.95), shortB: {0, 1}, longB: {float32(math.Sqrt(1 - 0.95*0.95)), 0.95}, }}) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.Candidates != 1 { t.Fatalf("候选 = %d, want 1(每分区上限)", stats.Candidates) } if stats.Skipped < 1 { t.Fatalf("超限未登记:skipped=%d", stats.Skipped) } } // TestTidyOfflineLexicalRecall 锁定「无 embedder 时的零依赖回退」:整理在没有 // embedding 配置时仍能召回并判定(阈值仍是可配的召回门)。 func TestTidyOfflineLexicalRecall(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9)) seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) cfg := tidyTestCfg(0.5) tr := newTestTidier(t, store, cfg, nil) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.Pairs != 1 { t.Fatalf("词项余弦召回 pairs = %d, want 1", stats.Pairs) } cands, err := store.ListTidyCandidates(ctx, "", 50) if err != nil { t.Fatalf("ListTidyCandidates: %v", err) } if len(candidatesByAction(cands, TidyMerge)) != 1 { t.Fatalf("离线回退未产出 merge 候选:%+v", cands) } } // TestTidyConfigDefaults 锁定「设计稿默认值只有一处定义」:召回门 0.80、 // 每天 1 次、安静时段 23:00-07:00、每分区扫描 5000 / 候选 200 / 比对 2000 // (design §9/§13;后两个是切片 MT-A 抬高的护栏,见 tidy_config.go 的注释)。 func TestTidyConfigDefaults(t *testing.T) { t.Parallel() cfg := normalizeTidyConfig(TidyConfig{Enabled: true}) if cfg.IntervalSec != 86400 || cfg.Interval != 24*time.Hour { t.Fatalf("周期默认 = %ds / %s, want 86400s / 24h", cfg.IntervalSec, cfg.Interval) } if cfg.RecallThreshold != 0.80 { t.Fatalf("召回门默认 = %v, want 0.80", cfg.RecallThreshold) } if cfg.ScanLimit != 5000 || cfg.CandidateLimit != 200 || cfg.CompareLimit != 2000 { t.Fatalf("配额默认 = %d/%d/%d, want 5000/200/2000(scan/candidate/compare)", cfg.ScanLimit, cfg.CandidateLimit, cfg.CompareLimit) } if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 { t.Fatalf("安静时段默认 = %d-%d, want 23-7", cfg.QuietStartHour, cfg.QuietEndHour) } if TidySessionID != "sess-memorytidy" { t.Fatalf("隔离会话 = %q, want sess-memorytidy", TidySessionID) } } // TestTidySchemaCreatedByNewStore 锁定「整理在任意 *sql.DB 上都可用」: // NewStore 幂等创建候选表/运行表并前滚 memory_items 的整理列。 func TestTidySchemaCreatedByNewStore(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) if _, err := NewStore(ctx, store.db, testLogger()); err != nil { t.Fatalf("第二次 NewStore(幂等): %v", err) } for _, table := range []string{"memory_tidy_candidates", "memory_tidy_runs"} { var n int if err := store.db.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?", table).Scan(&n); err != nil { t.Fatalf("查询表 %s: %v", table, err) } if n != 1 { t.Fatalf("表 %s 不存在", table) } } rows, err := store.db.QueryContext(ctx, "PRAGMA table_info(memory_items)") if err != nil { t.Fatalf("pragma table_info: %v", err) } defer rows.Close() cols := map[string]bool{} for rows.Next() { var ( cid int name, typ string notNull int dflt any pk int ) if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { t.Fatalf("scan pragma: %v", err) } cols[name] = true } for _, want := range []string{"status", "merged_into", "merged_from", "merge_count", "occurred_at", "valid_to", "expires_at"} { if !cols[want] { t.Fatalf("memory_items 缺列 %s(现有列:%v)", want, cols) } } } // containsID 报告 ids 里是否含 id。