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

+45 -9
View File
@@ -42,12 +42,13 @@ type tidyPair struct {
//
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
if len(items) < 2 {
return nil, nil
}
if t.deps.Embedder == nil {
return collectTidyPairs(items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats), nil
return t.collectPairs(p, items, t.cfg.RecallThreshold,
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
}
vecs, err := t.embedItems(ctx, items)
if err != nil {
@@ -56,22 +57,24 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
if len(vecs) != len(items) {
return nil, ErrTidyEmbedCount
}
return collectTidyPairs(items, t.cfg.RecallThreshold, vectorSimFunc(vecs), tidyRecallEmbedding, stats), nil
return t.collectPairs(p, items, t.cfg.RecallThreshold,
vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil
}
// limitCompareItems 把参与比对的条目截断到性能护栏并计入 Compared。
// 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正参与
// 比对的最多 maxTidyCompareItems 条(最新优先),超限记 WARN 并登记到
// stats.Truncated —— 截断绝不静默。空/单条分区不计数(本来就没有对可比)。
// 比对的最多 cfg.CompareLimit 条(最新优先,默认 2000,MEMORY_TIDY_COMPARE_LIMIT),
// 超限记 WARN 并登记到 stats.Truncated —— 截断绝不静默。
// 空/单条分区不计数(本来就没有对可比)。
func (t *Tidier) limitCompareItems(p tidyPartition, items []MemoryItem, stats *TidyStats) []MemoryItem {
if len(items) < 2 {
return items
}
if len(items) > maxTidyCompareItems {
if len(items) > t.cfg.CompareLimit {
stats.Truncated = append(stats.Truncated, p.label()+"(compare)")
t.log.Warn("memory tidy compare truncated by per-iteration guard",
"partition", p.label(), "items", len(items), "limit", maxTidyCompareItems)
items = items[:maxTidyCompareItems]
"partition", p.label(), "items", len(items), "limit", t.cfg.CompareLimit)
items = items[:t.cfg.CompareLimit]
}
stats.Compared += len(items)
return items
@@ -79,10 +82,29 @@ func (t *Tidier) limitCompareItems(p tidyPartition, items []MemoryItem, stats *T
// collectTidyPairs 是召回内核:把相似度 ≥ 阈值的对收集起来,按分数降序
// (再按 id)确定性排序 —— 排序让「每分区候选上限」的截断可复现。
func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string, stats *TidyStats) []tidyPair {
//
// 它是自由函数(入库前审查也复用同一个内核,design/23 §15 D3-3「不许另造一套
// 相似度实现」),因此**不认识分区与日志器**:cut=true 表示循环被时间预算提前
// 收手,由调用方决定怎么如实登记(整理侧见 Tidier.collectPairs)。
// budget 为零值(无 deadline)时行为与从前逐字一致。
//
// budget 用尽时已找到的对是**真结果**(不是编的),照常返回 —— 少算了多少由
// 调用方如实标记,绝不静默。
func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64,
mode string, stats *TidyStats, budget tidyBudget) ([]tidyPair, bool) {
pairs := make([]tidyPair, 0)
ticks := 0
cut := false
pairLoop:
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {
if ticks++; ticks >= tidyBudgetCheckEveryPairs {
ticks = 0
if budget.exhausted() {
cut = true
break pairLoop
}
}
score := sim(i, j)
if score < threshold {
continue
@@ -100,6 +122,20 @@ func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int)
}
return pairs[i].b.ID < pairs[j].b.ID
})
return pairs, cut
}
// collectPairs 是整理侧对内核对调用:它在内核之外补上「预算截断」的**诚实登记**
// (分区标签进响应的 truncated 数组 + 一条 WARN)。截断绝不静默是这一层的职责,
// 因此内核保持纯净(preinsert 审查不需要这套标签)。
func (t *Tidier) collectPairs(p tidyPartition, items []MemoryItem, threshold float64,
sim func(i, j int) float64, mode string, stats *TidyStats, budget tidyBudget) []tidyPair {
pairs, cut := collectTidyPairs(items, threshold, sim, mode, stats, budget)
if cut {
stats.Truncated = append(stats.Truncated, budgetTruncationLabel(p))
t.log.Warn("memory tidy pair scan cut by time budget",
"partition", p.label(), "items", len(items), "pairs", len(pairs))
}
return pairs
}