Files
Cyrene_v2/internal/runtime/memory/tidy_service_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

212 lines
7.5 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"
"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)
}
}
// TestTidyServiceStartDoesNotRunImmediately 锁定与 backthink 的**有意差异**:
// 整理不做启动即跑,进程重启不会触发一次全库扫描。
func TestTidyServiceStartDoesNotRunImmediately(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, time.Now, nil)
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)
}
}
// 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))
type auditRow struct{ who, what, result string }
var rows []auditRow
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, auditRow{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)
}
if len(rows) != 1 {
t.Fatalf("审计行数 = %d, want 1", len(rows))
}
if rows[0].what != "memory.tidy.run" || rows[0].who != tidyAuditWho {
t.Fatalf("审计 who/what = %q/%q", rows[0].who, rows[0].what)
}
if strings.Contains(rows[0].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)
}
}