package memory import ( "context" "fmt" "log/slog" "strings" "sync" "testing" "time" ) // 本文件锁定记忆整理**护栏抬高 + 诚实截断**(切片 MT-A): // // ① **护栏抬高后分区规模小于上限就全量比对**:compared == scanned(首轮真跑 // 的 compared(705) < scanned(784) 正是被写死的 compare 上限造成的); // ② **命中任一上限都留痕**:响应里的 Truncated 带 `<分区>(scan|compare| // candidates)`,日志有 WARN 且带总数/上限/分区名 —— 截断绝不静默; // ③ **时间预算闸**:预算用尽时收手(或整分区跳过)并如实标 (budget), // 而不是让整个请求超时、什么结果都拿不到。 // capturingHandler 把 slog 记录留下来,供「截断必须留下 WARN」这类断言使用。 type capturingHandler struct { mu sync.Mutex records []slog.Record } func (h *capturingHandler) Enabled(context.Context, slog.Level) bool { return true } func (h *capturingHandler) Handle(_ context.Context, r slog.Record) error { h.mu.Lock() defer h.mu.Unlock() h.records = append(h.records, r.Clone()) return nil } func (h *capturingHandler) WithAttrs([]slog.Attr) slog.Handler { return h } func (h *capturingHandler) WithGroup(string) slog.Handler { return h } // attrsOf 把一条记录的属性摊平成 map(测试断言用)。 func attrsOf(r slog.Record) map[string]string { out := map[string]string{} r.Attrs(func(a slog.Attr) bool { out[a.Key] = a.Value.String() return true }) return out } // warnRecords 返回所有 WARN 级别的记录(消息 + 属性)。 func (h *capturingHandler) warnRecords() []slog.Record { h.mu.Lock() defer h.mu.Unlock() out := make([]slog.Record, 0, len(h.records)) for _, r := range h.records { if r.Level == slog.LevelWarn { out = append(out, r) } } return out } // seedManyHabitItems 在同一条分区里落 n 条互不相同的记忆(内容都不含绝对 // 时间戳,因此都进比对而不是归档)。 func seedManyHabitItems(t *testing.T, store *Store, n int) []MemoryItem { t.Helper() base := tidyTestTime(t) out := make([]MemoryItem, 0, n) for i := 0; i < n; i++ { content := fmt.Sprintf("%s,第 %d 次记录", fixtureHabitShort, i) out = append(out, seedTidyItem(t, store, habitItem(content, base.Add(time.Duration(i)*time.Minute), 0.9))) } return out } // TestTidyCompareLimit_RaisesToDefaultAndComparesEverything 是判据 ①:默认护栏 // (compare 2000 / candidates 200)下,分区规模小于上限时 **compared == scanned** // 且**没有任何截断痕迹** —— 这正是首轮真跑做不到的那件事。 func TestTidyCompareLimit_RaisesToDefaultAndComparesEverything(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedManyHabitItems(t, store, 40) tr := newTestTidier(t, store, tidyTestCfg(0.80), nil) if got := tr.Config(); got.CompareLimit != 2000 || got.CandidateLimit != 200 { t.Fatalf("护栏默认值 = %d/%d, want 2000/200(compare/candidates)", got.CompareLimit, got.CandidateLimit) } out, err := tr.DryRun(ctx) if err != nil { t.Fatalf("DryRun: %v", err) } if out.Stats.Scanned != 40 || out.Stats.Compared != out.Stats.Scanned { t.Fatalf("scanned/compared = %d/%d, want 40/40(规模小于上限就全量比对)", out.Stats.Scanned, out.Stats.Compared) } if len(out.Stats.Truncated) != 0 { t.Fatalf("上限没被打满,不该有截断痕迹:%+v", out.Stats.Truncated) } } // TestTidyCompareLimit_HitIsLoud 是判据 ②:compare 上限命中时 Compared 被截、 // Truncated 带 (compare)、WARN 带总数/上限/分区名。 func TestTidyCompareLimit_HitIsLoud(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedManyHabitItems(t, store, 6) capture := &capturingHandler{} cfg := tidyTestCfg(0.80) cfg.CompareLimit = 3 tr, err := NewTidier(cfg, TidyDeps{Store: store, Log: slog.New(capture)}) if err != nil { t.Fatalf("NewTidier: %v", err) } out, err := tr.DryRun(ctx) if err != nil { t.Fatalf("DryRun: %v", err) } if out.Stats.Scanned != 6 || out.Stats.Compared != 3 { t.Fatalf("scanned/compared = %d/%d, want 6/3(上限必须生效)", out.Stats.Scanned, out.Stats.Compared) } want := "persona.a/persona/(compare)" if !containsString(out.Stats.Truncated, want) { t.Fatalf("截断痕迹 = %v, want 含 %q", out.Stats.Truncated, want) } if !hasWarn(capture, "memory tidy compare truncated by per-iteration guard", map[string]string{ "partition": "persona.a/persona/", "items": "6", "limit": "3", }) { t.Fatalf("compare 上限命中必须有带总数/上限/分区名的 WARN:%+v", capture.warnRecords()) } } // TestTidyCandidateLimit_HitIsLoud 是判据 ②(候选上限):此前候选上限命中只写 // stats,**没有 WARN** —— 面板看到的候选少于规则判出的,日志里却看不到任何线索。 func TestTidyCandidateLimit_HitIsLoud(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedDryRunFixture(t, store) capture := &capturingHandler{} cfg := tidyTestCfg(0.80) cfg.CandidateLimit = 1 tr, err := NewTidier(cfg, TidyDeps{Store: store, Embedder: dryRunFixtureEmbedder(), Log: slog.New(capture)}) if err != nil { t.Fatalf("NewTidier: %v", err) } out, err := tr.DryRun(ctx) if err != nil { t.Fatalf("DryRun: %v", err) } if len(out.Candidates) != 1 || out.Stats.Skipped != 1 { t.Fatalf("候选上限未生效:candidates=%d skipped=%d", len(out.Candidates), out.Stats.Skipped) } if !containsString(out.Stats.Truncated, "persona.a/persona/(candidates)") { t.Fatalf("截断痕迹 = %v, want 含 (candidates)", out.Stats.Truncated) } if !hasWarn(capture, "memory tidy candidates truncated by per-partition limit", map[string]string{ "partition": "persona.a/persona/", "candidates": "2", "limit": "1", }) { t.Fatalf("候选上限命中必须有 WARN(带总数/上限/分区名):%+v", capture.warnRecords()) } } // TestTidyScanLimit_WarnCarriesTrueTotal 是判据 ②(扫描上限):WARN 里的 total // 必须是**真总数**(另查 COUNT),而不是被 LIMIT 截出来的下限 —— 否则「至少 5000」 // 会被写成「正好 5000」。 func TestTidyScanLimit_WarnCarriesTrueTotal(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) seedManyHabitItems(t, store, 5) capture := &capturingHandler{} cfg := tidyTestCfg(0.80) cfg.ScanLimit = 2 tr, err := NewTidier(cfg, TidyDeps{Store: store, Log: slog.New(capture)}) if err != nil { t.Fatalf("NewTidier: %v", err) } out, err := tr.DryRun(ctx) if err != nil { t.Fatalf("DryRun: %v", err) } if out.Stats.Scanned != 2 { t.Fatalf("scanned = %d, want 2(扫描上限)", out.Stats.Scanned) } if !containsString(out.Stats.Truncated, "persona.a/persona/(scan)") { t.Fatalf("截断痕迹 = %v, want 含 (scan)", out.Stats.Truncated) } if !hasWarn(capture, "memory tidy partition hit the scan limit", map[string]string{ "partition": "persona.a/persona/", "limit": "2", "total": "5", }) { t.Fatalf("扫描上限的 WARN 必须带真总数:%+v", capture.warnRecords()) } } // TestTidyDryRun_BudgetExhaustedTruncatesHonestly 是判据 ③:时间预算用尽时分区的 // 结果是**如实截断**(scanned=0 + truncated 带 (budget)),而不是一个空候选数组 // 冒充「这个分区没有建议」,也不是让整个请求超时。 func TestTidyDryRun_BudgetExhaustedTruncatesHonestly(t *testing.T) { t.Parallel() store := newTestStore(t) seedManyHabitItems(t, store, 3) // 时钟推到墙钟上限之后 ⇒ 预算判为用尽(而 ctx 本身还没到期,规则路径照跑)。 clock := newAdvClock(time.Now().Add(time.Hour)) svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() out, err := svc.DryRun(ctx) if err != nil { t.Fatalf("DryRun: %v", err) } if out.Stats.Scanned != 0 || len(out.Candidates) != 0 { t.Fatalf("预算用尽的分区不该被扫描:%+v", out.Stats) } if !containsString(out.Stats.Truncated, "persona.a/persona/(budget)") { t.Fatalf("预算截断必须如实登记(否则「没扫」会被读成「没有候选」):%+v", out.Stats.Truncated) } } // TestCollectTidyPairs_BudgetCutsLoopHonestly 是判据 ③(循环内闸):两两比对进行中 // 预算用尽 ⇒ **收手返回已找到的对**(真结果,不是编的)并报告 cut,绝不静默少算。 func TestCollectTidyPairs_BudgetCutsLoopHonestly(t *testing.T) { t.Parallel() items := make([]MemoryItem, 100) // 100 条 ⇒ 4950 对 > 4096 的检查间隔 for i := range items { items[i] = MemoryItem{ID: fmt.Sprintf("mem-%03d", i), Content: fmt.Sprintf("内容 %d", i)} } alwaysSim := func(int, int) float64 { return 0.95 } expired := tidyBudget{ deadline: time.Now().Add(-time.Hour), margin: 0, enabled: true, now: time.Now, } var cutStats TidyStats pairs, cut := collectTidyPairs(items, 0.0, alwaysSim, tidyRecallLexical, &cutStats, expired) if !cut { t.Fatal("预算用尽时两两比对必须收手(否则一轮可能烧掉几分钟墙钟)") } if len(pairs) == 0 { t.Fatal("收手前已找到的对是真结果,必须照常返回") } if cutStats.Pairs != len(pairs) { t.Fatalf("Pairs 计数 = %d, want %d(计的就是返回的真结果)", cutStats.Pairs, len(pairs)) } var fullStats TidyStats all, cut := collectTidyPairs(items, 0.0, alwaysSim, tidyRecallLexical, &fullStats, tidyBudget{}) if cut { t.Fatal("没有预算时不该报告 cut") } if len(all) != 100*99/2 { t.Fatalf("无预算时必须全量比对:pairs = %d, want %d", len(all), 100*99/2) } } // TestTidierCollectPairs_NotesBudgetCutAsPartition 钉住整理侧对「循环内截断」的 // 诚实登记:分区标签进 stats.Truncated,并留一条 WARN。 func TestTidierCollectPairs_NotesBudgetCutAsPartition(t *testing.T) { t.Parallel() store := newTestStore(t) capture := &capturingHandler{} tr, err := NewTidier(tidyTestCfg(0.80), TidyDeps{Store: store, Log: slog.New(capture)}) if err != nil { t.Fatalf("NewTidier: %v", err) } items := make([]MemoryItem, 100) for i := range items { items[i] = MemoryItem{ID: fmt.Sprintf("mem-%03d", i)} } var stats TidyStats p := tidyPartition{personaID: "persona.a", scope: ScopePersona} pairs := tr.collectPairs(p, items, 0.0, func(int, int) float64 { return 0.9 }, tidyRecallLexical, &stats, tidyBudget{ deadline: time.Now().Add(-time.Hour), enabled: true, now: time.Now, }) if len(pairs) == 0 { t.Fatal("夹具无效:应当已找到若干对") } if !containsString(stats.Truncated, "persona.a/persona/(budget)") { t.Fatalf("截断痕迹 = %v, want 含 (budget)", stats.Truncated) } if len(capture.warnRecords()) == 0 { t.Fatal("时间预算截断必须留下 WARN(绝不静默)") } } // hasWarn 报告是否有一条 WARN 记录消息匹配且属性包含 want。 func hasWarn(h *capturingHandler, msg string, want map[string]string) bool { for _, r := range h.warnRecords() { if r.Message != msg { continue } attrs := attrsOf(r) ok := true for k, v := range want { if attrs[k] != v { ok = false break } } if ok { return true } } return false } // containsString 报告切片里是否有某个字符串(精确匹配)。 func containsString(list []string, want string) bool { for _, v := range list { if v == want { return true } } return false } // TestTidyTruncatedLabelsAreDistinguishable 钉住「响应能不能区分『没有更多候选』 // 与『被上限截断』」:没有截断时 Truncated 为空,命中时带**原因后缀**, // 两种截断原因(配额 vs 预算)标签不同,运维/面板据此知道该调什么。 func TestTidyTruncatedLabelsAreDistinguishable(t *testing.T) { t.Parallel() if strings.TrimSpace(budgetTruncationLabel(tidyPartition{personaID: "p", scope: ScopePersona})) == "" { t.Fatal("预算截断必须有自己的标签") } got := budgetTruncationLabel(tidyPartition{personaID: "p", scope: ScopePersona}) if got == "p/persona/(compare)" || got == "p/persona/(scan)" || got == "p/persona/(candidates)" { t.Fatalf("预算截断不得复用配额截断的标签(原因不同,运维动作也不同):%q", got) } }