Files
Cyrene_v2/internal/runtime/memory/tidy_guard_test.go
T
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

337 lines
12 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"
"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)
}
}