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 } // 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) } } // TestTidyParallelFactsOnlyLink 是主人红线「不要什么内容都合并」与 // design/23 §12.1 P8 的口径:相似但并列的事实(红牛 vs 冰美式)只建链接, // 绝不合并 —— 即使召回分数很高。 func TestTidyParallelFactsOnlyLink(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), }}) 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 merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 { t.Fatalf("并列事实被合并了:%+v(主人红线:不要什么内容都合并)", merges) } links := candidatesByAction(cands, TidyLink) if len(links) != 1 { t.Fatalf("link candidates = %d, want 1 (%+v)", len(links), cands) } ids := links[0].SourceIDs if len(ids) != 2 || !containsID(ids, redBull.ID) || !containsID(ids, iced.ID) { t.Fatalf("link source ids = %v, want 两条并列事实", ids) } if links[0].MergedContent != "" || links[0].TargetID != "" { t.Fatalf("link 候选不得带合并结果:%+v", links[0]) } } // 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, err := store.ListTidyCandidates(ctx, "", 50) if err != nil { t.Fatalf("ListTidyCandidates: %v", err) } 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:同一批数据连跑两轮 // 不重复产候选(幂等键 = 规则版本 + 数据指纹,不靠「记住上次」)。 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)) tr := newTestTidier(t, store, tidyTestCfg(0.80), 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 }