package memory import ( "context" "math" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // 本文件是记忆整理(design/23 §12)的正/反例测试集:用设计稿引文脱敏构造的 // 夹具,逐条断言「该合的合、不该合的不合」。夹具取自 design/23 §1 的引文 // ([8] 一次性事件、[12]/[15] 假期状态、[1]/[3] 红牛 vs 冰美式、 // [21]/[25] 熬夜习惯的真子集),确保测的是设计稿点名的情形。 const ( fixtureHabitShort = "主人常在深夜与助手聊天,有熬夜习惯" fixtureHabitLong = "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚" fixtureHabitPlain = "主人常在深夜加班" fixtureHabitVacation = "主人国庆假期期间常在深夜加班,处于休假状态" fixtureEnergyRedBull = "主人喜欢喝红牛来提神,红牛是提神饮料" fixtureEnergyIced = "主人喜欢喝冰美式来提神,冰美式是提神饮料" fixtureOneOffEvent = "2026年9月8日周二晚22:38,主人仍在加班中度过的夜晚。" fixtureWorkShort = "主人用喵打招呼" fixtureWorkLong = "主人用喵打招呼,还会配合可爱的表情" baseTidyTime = "2026-10-01T10:00:00.000000000Z" ) // stubEmbedder 是按文本查表的确定性 embedder:测试用它精确控制「哪几对被 // 召回」,把**召回**与**判定**分开验(判定绝不看相似度)。未登记文本得到 // 一个与相似族正交的向量,因此不会意外参与召回。 type stubEmbedder struct { vecs map[string]embedding.Vector } func (s stubEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) { out := make([]embedding.Vector, 0, len(texts)) for _, text := range texts { v, ok := s.vecs[text] if !ok { v = embedding.Vector{0, 1} } out = append(out, v) } return out, nil } func (s stubEmbedder) Dim() int { return 2 } // simVector 返回与 {1,0} 余弦恰为 want 的向量(want ∈ (0,1])。 func simVector(want float64) embedding.Vector { return embedding.Vector{float32(want), float32(math.Sqrt(1 - want*want))} } // tidyTestTime 把固定字符串解析成基准时刻。 func tidyTestTime(t *testing.T) time.Time { t.Helper() parsed, err := parseTime(baseTidyTime) if err != nil { t.Fatalf("parse base time: %v", err) } return parsed } // tidyTestCfg 是测试用的整理配置:只把召回门显式写死,其余走设计稿默认值。 func tidyTestCfg(threshold float64) TidyConfig { return TidyConfig{ Enabled: true, IntervalSec: defaultTidyIntervalSec, QuietStartHour: defaultTidyQuietStartHour, QuietEndHour: defaultTidyQuietEndHour, RecallThreshold: threshold, ScanLimit: 200, CandidateLimit: defaultTidyCandidateLimit, } } // newTestTidier 用给定存储、配置与可选 embedder 构建整理器。 func newTestTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) *Tidier { t.Helper() tr, err := NewTidier(cfg, TidyDeps{Store: store, Embedder: emb, Log: testLogger()}) if err != nil { t.Fatalf("NewTidier: %v", err) } return tr } // seedTidyItem 落库一条测试记忆。 func seedTidyItem(t *testing.T, store *Store, item MemoryItem) MemoryItem { t.Helper() got, err := store.Add(context.Background(), item) if err != nil { t.Fatalf("seed item %q: %v", item.Content, err) } return got } // habitItem 是熬夜类记忆的构造器(同一分区:persona.a / persona / 无项目)。 func habitItem(content string, at time.Time, confidence float64) MemoryItem { return MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: content, Confidence: confidence, CreatedAt: at, SourceConversation: FormatSourceConversation("web", "msg-1"), } } // candidatesByAction 从候选列表里按动作筛选。 func candidatesByAction(cands []TidyCandidate, action TidyAction) []TidyCandidate { out := make([]TidyCandidate, 0) for _, c := range cands { if c.Action == action { out = append(out, c) } } return out } // allTidyCandidates 读一份**不分状态**的候选集合(pending + applied + rejected)。 // // 2026-10-07 起 RunOnce 会跑**轮末自动档**:命中判据的候选(字面重复 / 事实等价 ∧ // ≥0.95 / 一次性事件且不像习惯)当场就被落地成 applied。因此「这一轮的规则产出了 // 什么候选」这类断言必须看全部状态 —— 只看待办队列会把「已经自动做完了」读成 // 「什么都没产出」。 func allTidyCandidates(t *testing.T, store *Store, limit int) []TidyCandidate { t.Helper() ctx := context.Background() out := make([]TidyCandidate, 0) for _, status := range []string{tidyCandidatePending, tidyCandidateApplied, tidyCandidateRejected} { cands, err := store.ListTidyCandidates(ctx, status, limit) if err != nil { t.Fatalf("ListTidyCandidates(%s): %v", status, err) } out = append(out, cands...) } return out } // TestTidyContainedFactProducesMergeCandidate 是 design/23 §12.1 P1 的口径: // A 的事实集合是 B 的子集 ⇒ 产 merge 候选,存活条目是超集 B,被并入的是 A。 func TestTidyContainedFactProducesMergeCandidate(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9)) long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, fixtureHabitLong: simVector(0.95), }}) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.Scanned != 2 || stats.Pairs != 1 { t.Fatalf("stats scanned/pairs = %d/%d, want 2/1 (%+v)", stats.Scanned, stats.Pairs, stats) } cands, err := store.ListTidyCandidates(ctx, "", 50) if err != nil { t.Fatalf("ListTidyCandidates: %v", err) } merges := candidatesByAction(cands, TidyMerge) if len(merges) != 1 { t.Fatalf("merge candidates = %d, want 1 (%+v)", len(merges), cands) } got := merges[0] if got.TargetID != long.ID { t.Fatalf("merge target = %q, want 超集 B = %q", got.TargetID, long.ID) } if len(got.SourceIDs) != 1 || got.SourceIDs[0] != short.ID { t.Fatalf("merge source ids = %v, want [%s]", got.SourceIDs, short.ID) } if got.MergedContent != fixtureHabitLong { t.Fatalf("merged content = %q, want 超集原文", got.MergedContent) } if got.Evidence.Containment == string(containNone) || got.Evidence.Similarity < 0.80 { t.Fatalf("evidence = %+v, want 包含判定为子集关系且带召回分数", got.Evidence) } if got.MergedContent != fixtureHabitLong || got.ProposedContent != fixtureHabitShort { t.Fatalf("diff 预览的原文/合并结果不对:proposed=%q merged=%q", got.ProposedContent, got.MergedContent) } if got.Status != tidyCandidatePending { t.Fatalf("status = %q, want %q(人工确认队列)", got.Status, tidyCandidatePending) } } // TestTidyParallelFactsProduceNoCandidate 是主人红线「不要什么内容都合并」与 // design/23 §12.1 P8 的口径:相似但并列的事实(红牛 vs 冰美式)**既不合并,也不产 // link 候选**(2026-10-07 主人口径「那么多 link 是干什么的?该删就删」)。 // // 为什么连 link 候选都不产:design/23 §14 把「link 关系如何存储与呈现」列为**待细化** // ⇒ 没有落点、没有数据模型;执行面只认 merge/archive/reclassify(tidy_apply.go 的 // applyable())⇒ 一条 link 候选的应用结果永远是 skipped/action_not_applyable。 // 老的用例名是 TestTidyParallelFactsOnlyLink(曾经断言「恰产一条 link」),那正是 // 活库积压 501 条无执行语义候选的来源。 func TestTidyParallelFactsProduceNoCandidate(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) redBull := seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95)) iced := seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95)) tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureEnergyRedBull: {1, 0}, fixtureEnergyIced: simVector(0.99), }}) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } cands := allTidyCandidates(t, store, 50) if len(cands) != 0 { t.Fatalf("并列事实不该产任何候选(含 link):%+v(主人红线:不要什么内容都合并)", cands) } if stats.Kept != 1 { t.Fatalf("stats.Kept = %d, want 1(抛掉的对必须单独计数,否则口径上是消失)", stats.Kept) } // 「什么都不做」必须是**真什么都没做**:两行都还在册。 for _, id := range []string{redBull.ID, iced.ID} { if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive { t.Fatalf("并列事实被改动了:%+v", got) } } } // TestTidyCrossScopeNeverComparedOrMerged 是 design/23 §12.2 N1 与 §6 硬约束: // 跨作用域([10] user vs [19] persona,全库相似度最高的一对 0.9319)永不合 —— // 运行期连比较都不发生(分区隔离),判定层再兜一次否决。 func TestTidyCrossScopeNeverComparedOrMerged(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) content := "主人喜欢被叫小笨蛋" userItem := seedTidyItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopeUser, Kind: KindRelationship, Content: content, Confidence: 0.95, CreatedAt: base, }) personaItem := habitItem(content, base.Add(time.Minute), 0.95) seedTidyItem(t, store, personaItem) emb := stubEmbedder{vecs: map[string]embedding.Vector{content: {1, 0}}} // 判定层:跨作用域对被硬门否决(纵深防御,design §5.2-7)。 verdict := judgePair(userItem, personaItem, 0.9319) if verdict.Veto != VetoScope { t.Fatalf("judgePair 跨作用域 veto = %q, want %q", verdict.Veto, VetoScope) } if verdict.Action != "" { t.Fatalf("被判否决的对不得带动作,got %q", verdict.Action) } // 运行期:分区之间永不比较 ⇒ 零候选(design §6「跨分区永不产生候选」)。 tr := newTestTidier(t, store, tidyTestCfg(0.80), emb) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.Pairs != 0 || stats.Candidates != 0 { t.Fatalf("跨作用域产出了候选:pairs=%d candidates=%d", stats.Pairs, stats.Candidates) } cands, err := store.ListTidyCandidates(ctx, "", 50) if err != nil { t.Fatalf("ListTidyCandidates: %v", err) } if len(cands) != 0 { t.Fatalf("跨作用域候选 = %+v, want 空", cands) } } // TestTidyTemporalPollutionVetoed 是 design/23 §12.2 N2 与 §5.2-1:一次性事件 // 与有期限状态不得被当长期事实合并;含绝对时刻的条目单独走 archive。 func TestTidyTemporalPollutionVetoed(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) plain := seedTidyItem(t, store, habitItem(fixtureHabitPlain, base, 0.9)) vacation := seedTidyItem(t, store, habitItem(fixtureHabitVacation, base.Add(time.Minute), 0.9)) oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Minute), 0.9)) // 判定层:[8] 那类一次性事件与长期习惯硬不合。 if v := judgePair(plain, vacation, 0.95); v.Veto != VetoTemporal { t.Fatalf("时效状态 veto = %q, want %q", v.Veto, VetoTemporal) } if v := judgePair(oneOff, plain, 0.95); v.Veto != VetoTemporal { t.Fatalf("一次性事件 veto = %q, want %q", v.Veto, VetoTemporal) } tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitPlain: {1, 0}, fixtureHabitVacation: simVector(0.97), fixtureOneOffEvent: simVector(0.96), }}) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.VetoReasons[string(VetoTemporal)] < 1 { t.Fatalf("否决原因分布里没有 temporal:%+v", stats.VetoReasons) } cands := allTidyCandidates(t, store, 50) if merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 { t.Fatalf("时效污染被合并:%+v", merges) } archives := candidatesByAction(cands, TidyArchive) if len(archives) != 1 || archives[0].SourceIDs[0] != oneOff.ID { t.Fatalf("archive 候选 = %+v, want 恰为一次性事件 %s", archives, oneOff.ID) } for _, c := range cands { if containsID(c.SourceIDs, oneOff.ID) && c.Action != TidyArchive { t.Fatalf("一次性事件参与了 %s:%+v", c.Action, c) } } } // TestTidyEmptyAndSingleItemAreNoops 是 design/23 §12 的边界口径:空库与单条 // 记忆都不产候选、不报错,但仍留一条运行痕迹(不静默)。 func TestTidyEmptyAndSingleItemAreNoops(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) tr := newTestTidier(t, store, tidyTestCfg(0.80), nil) stats, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("空库 RunOnce: %v", err) } if stats.Scanned != 0 || stats.Candidates != 0 || stats.Pairs != 0 { t.Fatalf("空库 stats = %+v, want 全 0", stats) } seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) stats, err = tr.RunOnce(ctx) if err != nil { t.Fatalf("单条 RunOnce: %v", err) } if stats.Scanned != 1 || stats.Candidates != 0 || stats.Pairs != 0 { t.Fatalf("单条 stats = %+v, want scanned=1/candidates=0/pairs=0", stats) } runs, err := store.ListTidyRuns(ctx, 10) if err != nil { t.Fatalf("ListTidyRuns: %v", err) } if len(runs) != 2 { t.Fatalf("运行留痕 = %d 条, want 2", len(runs)) } } // TestTidyRerunIsIdempotent 是 design/23 §12.2 N6 与 §9:同一批数据连跑两轮 // 不重复产候选(幂等键 = 规则版本 + 数据指纹,不靠「记住上次」)。 // // 本用例刻意**关掉轮末自动档**:自动档会把一次性事件当场归档,于是第二轮扫描范围 // 本身就变小了(归档行不再参与比对),duplicates 会被自动档的结果影响 —— // 那是自动档的语义(另有 tidy_auto_test.go 专门验),不是「候选产出」这条幂等性的 // 一部分。这里要证明的是:规则不变时同一批数据绝不重复产候选。 func TestTidyRerunIsIdempotent(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)) seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Hour), 0.9)) cfg := tidyTestCfg(0.80) cfg.DisableAutoApply = true tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, fixtureHabitLong: simVector(0.95), fixtureOneOffEvent: simVector(0.9), }}) first, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("第一轮 RunOnce: %v", err) } if first.Candidates == 0 { t.Fatalf("第一轮没有产出候选,用例无效:%+v", first) } before, err := store.ListTidyCandidates(ctx, "", 100) if err != nil { t.Fatalf("ListTidyCandidates: %v", err) } second, err := tr.RunOnce(ctx) if err != nil { t.Fatalf("第二轮 RunOnce: %v", err) } if second.Candidates != 0 { t.Fatalf("第二轮新产候选 = %d, want 0(幂等)", second.Candidates) } if second.Duplicates != first.Candidates { t.Fatalf("第二轮 duplicates = %d, want %d", second.Duplicates, first.Candidates) } after, err := store.ListTidyCandidates(ctx, "", 100) if err != nil { t.Fatalf("ListTidyCandidates: %v", err) } if len(after) != len(before) { t.Fatalf("候选行数 %d → %d, want 不变", len(before), len(after)) } } func containsID(ids []string, id string) bool { for _, v := range ids { if v == id { return true } } return false }