package memory import ( "context" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // 本文件锁定记忆整理的**运行隔离与心跳口径**(design/23 §4/§4.1/§9): // 独立会话标识、低峰调度、可开关、手动可触发、审计留痕。 // fixedTidyClock 返回固定时刻的时钟,好让调度断言不依赖真实时间。 func fixedTidyClock(at time.Time) func() time.Time { return func() time.Time { return at } } // newTidyServiceForTest 构建带固定时钟与可选审计出口的整理服务。 func newTidyServiceForTest(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder, clock func() time.Time, audit func(who, what, result string)) *TidyService { t.Helper() svc, err := NewTidyService(cfg, TidyDeps{ Store: store, Embedder: emb, Audit: audit, Log: testLogger(), Now: clock, }) if err != nil { t.Fatalf("NewTidyService: %v", err) } return svc } // TestTidyServiceNilStoreRejected:没有记忆存储就没有整理(依赖面收窄到最小)。 func TestTidyServiceNilStoreRejected(t *testing.T) { t.Parallel() if _, err := NewTidyService(TidyConfig{Enabled: true}, TidyDeps{Log: testLogger()}); err == nil { t.Fatal("缺 Store 应当报错") } } // TestTidySessionIDIsNotAConversationLane 锁定 design/23 §4.1 B:整理跑在 // 专属隔离会话,绝不是主人的对话车道(cmd/cyrene 的 isConversationLane // 对 "web"/"web:*" 才为 true)。 func TestTidySessionIDIsNotAConversationLane(t *testing.T) { t.Parallel() if TidySessionID == "web" || strings.HasPrefix(TidySessionID, "web:") { t.Fatalf("整理会话 %q 落在主人对话车道上", TidySessionID) } svc := newTidyServiceForTest(t, newTestStore(t), tidyTestCfg(0.80), nil, time.Now, nil) if svc.SessionID() != TidySessionID { t.Fatalf("SessionID() = %q, want %q", svc.SessionID(), TidySessionID) } } // TestTidyServiceNextDelay 锁定低峰调度:下一个低峰窗口起点、周期下限、 // 窗口关闭时退回纯周期,且等待恒有下界(不空转)。 func TestTidyServiceNextDelay(t *testing.T) { t.Parallel() store := newTestStore(t) // 15:00 起跑,低峰 23:00-07:00 ⇒ 等 8 小时。 at := time.Date(2026, 10, 5, 15, 0, 0, 0, time.Local) svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(at), nil) if got, want := svc.nextDelay(), 8*time.Hour; got != want { t.Fatalf("nextDelay = %s, want %s", got, want) } // 23:30 起跑 ⇒ 下一个窗口起点是明天 23:00(约 23.5 小时),且不早于 // 「上次运行 + 周期(24h)」。 late := time.Date(2026, 10, 5, 23, 30, 0, 0, time.Local) svc = newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(late), nil) svc.lastRun = time.Date(2026, 10, 5, 23, 0, 0, 0, time.Local) if got := svc.nextDelay(); got < 23*time.Hour || got > 24*time.Hour { t.Fatalf("周期下限未生效:nextDelay = %s, want ≈ 23.5h 且 ≤ 24h", got) } // 窗口关闭(两端相等且非 0)⇒ 纯周期路径。 cfg := tidyTestCfg(0.80) cfg.QuietStartHour, cfg.QuietEndHour = 9, 9 svc = newTidyServiceForTest(t, store, cfg, nil, fixedTidyClock(at), nil) if got, want := svc.nextDelay(), 24*time.Hour; got != want { t.Fatalf("窗口关闭时 nextDelay = %s, want %s", got, want) } } // TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately 锁定补跑判据的 // 一条边界(切片 TIDY-1):**无历史**且启动在安静窗**之外**时不做启动即跑 —— // 新装的库不该在任意时刻烧一次全库向量化扫描,等窗口起点那一轮即可。 // 时钟是注入的固定值,因此这条断言与用例真的跑在几点无关(旧实现用 // time.Now,在 23:00–07:00 之间跑就会抖动)。 func TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) clock := &schedClock{at: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local)} probe := newTidyWaitProbe(clock, 0) // 绝不真的等到窗口起点 svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil) svc.wait = probe.wait svc.Start(ctx) probe.awaitCalls(t, 1) if err := svc.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } runs, err := store.ListTidyRuns(ctx, 10) if err != nil { t.Fatalf("ListTidyRuns: %v", err) } if len(runs) != 0 { t.Fatalf("窗口外无历史时不该启动即跑:%+v", runs) } } // TestTidyServiceDisabledStartIsNoop:关闭是显式选择 —— 不跑、不报错。 func TestTidyServiceDisabledStartIsNoop(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) cfg := tidyTestCfg(0.80) cfg.Enabled = false svc := newTidyServiceForTest(t, store, cfg, nil, time.Now, nil) if svc.Enabled() { t.Fatal("Enabled() 应为 false") } svc.Start(ctx) if err := svc.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } runs, err := store.ListTidyRuns(ctx, 10) if err != nil { t.Fatalf("ListTidyRuns: %v", err) } if len(runs) != 0 { t.Fatalf("关闭时仍跑了整理:%+v", runs) } } // TestTidyServiceManualRunAndAudit:「立即整理」入口可手动触发一轮, // 并写一条 memory.tidy.run 审计(design/23 §8;审计只带计数、不带正文)。 func TestTidyServiceManualRunAndAudit(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)) var rows []tidyAuditRow svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, fixtureHabitLong: simVector(0.95), }}, time.Now, func(who, what, result string) { rows = append(rows, tidyAuditRow{who, what, result}) }) stats, err := svc.RunOnce(ctx) if err != nil { t.Fatalf("RunOnce: %v", err) } if stats.Candidates != 1 { t.Fatalf("手动一轮候选 = %d, want 1", stats.Candidates) } // 两条审计:① 领域侧记的那条 memory.tidy.run(design §8);② 2026-10-07 起 // 轮末自动档自己写的一条 memory.tidy.apply(result 带 auto=true)。本夹具的 // merge 候选是「真子集」(a_subset_b),不命中自动判据,因此第二条是**零落地** // 的诚实回执 —— 这正是「自动档跑了但什么都没做」该有的形状。 // 两条的先后不构成契约(领域侧先写自动档那条),因此按 what 取,不按下标取。 if len(rows) != 2 { t.Fatalf("审计行数 = %d, want 2(run + auto apply):%+v", len(rows), rows) } round := auditRowOf(t, rows, "memory.tidy.run") auto := auditRowOf(t, rows, tidyAutoApplyVerb) if round.who != tidyAuditWho { t.Fatalf("审计 who = %q, want %q", round.who, tidyAuditWho) } if strings.Contains(round.result, fixtureHabitShort) { t.Fatal("审计里不得出现记忆正文") } if !strings.Contains(auto.result, "auto=true") || !strings.Contains(auto.result, "applied=0") { t.Fatalf("自动档审计 = %+v, want memory.tidy.apply + auto=true + applied=0", auto) } if strings.Contains(auto.result, fixtureHabitShort) { t.Fatal("审计里不得出现记忆正文(自动档那条也不行)") } runs, err := store.ListTidyRuns(ctx, 10) if err != nil { t.Fatalf("ListTidyRuns: %v", err) } if len(runs) != 1 || runs[0].RuleVersion != TidyRuleVersion || runs[0].Applied != 0 { t.Fatalf("运行留痕 = %+v, want 1 条、规则版本 %s、applied=0(真子集候选不在自动档授权内)", runs, TidyRuleVersion) } } // TestTidyRunRecordedVetoDistribution:否决原因分布进运行表(可查询,不只在日志里)。 func TestTidyRunRecordedVetoDistribution(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) seedTidyItem(t, store, habitItem(fixtureHabitPlain, base, 0.9)) seedTidyItem(t, store, habitItem(fixtureHabitVacation, base.Add(time.Minute), 0.9)) svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitPlain: {1, 0}, fixtureHabitVacation: simVector(0.97), }}, time.Now, nil) if _, err := svc.RunOnce(ctx); err != nil { t.Fatalf("RunOnce: %v", err) } runs, err := store.ListTidyRuns(ctx, 1) if err != nil { t.Fatalf("ListTidyRuns: %v", err) } if len(runs) != 1 { t.Fatalf("运行留痕 = %d 条, want 1", len(runs)) } if runs[0].VetoReasons[string(VetoTemporal)] != 1 { t.Fatalf("运行表的否决分布 = %+v, want temporal=1", runs[0].VetoReasons) } if runs[0].ID == "" { t.Fatal("运行留痕缺 id(日志里的 run_id 也必须指向同一条记录)") } if runs[0].Scanned != 2 { t.Fatalf("运行表 scanned = %d, want 2", runs[0].Scanned) } } // tidyAuditRow 是一条捕获到的审计行(本文件的用例只需这三个字段)。 type tidyAuditRow struct{ who, what, result string } // auditRowOf 按动作名取一条审计行;缺了或重复了就当场失败(审计行必须**恰有一条**, // 「多了」与「少了」都是缺陷)。 func auditRowOf(t *testing.T, rows []tidyAuditRow, what string) tidyAuditRow { t.Helper() var found []tidyAuditRow for _, r := range rows { if r.what == what { found = append(found, r) } } if len(found) != 1 { t.Fatalf("审计 %s = %d 条, want 恰 1 条(全部:%+v)", what, len(found), rows) } return found[0] }