feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【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 无该文件权限,由我按其代码语义补写)。
This commit is contained in:
AskaEth committed 2026-10-06 08:04:12 +08:00
1 parent fe4c702f87
commit 6e28b38b08
37 files changed
+4011 -75

No files matched your search

+336
View File
@@ -0,0 +1,336 @@
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)
}
}