【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 无该文件权限,由我按其代码语义补写)。
337 lines
12 KiB
Go
337 lines
12 KiB
Go
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)
|
||
}
|
||
}
|