Files
Cyrene_v2/internal/runtime/memory/tidy_test.go
T
AskaEth 91a3209c9b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
2026-10-05 11:29:36 +08:00

391 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}