feat(memory): D3 从「异步后置」改成「同步入库闸」—— 由整理层决定入库形态
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 2026-10-07 裁定:「把记忆整理功能接到昔涟和记忆库中间做审查,由整理功能
进行入库」。现状是**异步 + 入库后软合并** ⇒ 行已经落库才审,面板/召回先脏后清,
而写入速率实测 ~25 条/小时(≈600/天)⇒ 清理永远追不上。

落法(三条硬规格,全部落地):
① **同步闸但有界**:闸在 `store.go:247` 的 `planIngest`(INSERT **之前**),
   本体 `preinsert_gate.go`。墙钟预算 **150ms**(实测最坏 p95 仅 4~7ms ⇒ 20~50 倍
   余量)、候选扫描上限 **100**(比异步侧 200 更紧,因为它在写路径上)。
   🔴 **零远端 embedding**:闸恒用词项余弦(`deps.Embedder` 根本不参与)——
   远端 embedding 一次 2.3~3.0s 且对同文本返回不同向量(不可复现),不许摆在同步
   路径上拍板。
② **fail-open,绝不丢记忆**:候选源报错 ⇒ `gate:candidates_unavailable`;超预算 ⇒
   `gate:budget_exceeded` ⇒ **照常 insert(active) + 投既有队列 + 留痕 + WARN**。
③ **三态落库(落 merged,而不是"不插入")**:确定性命中(字面重复 / 归一化等价 /
   真包含)⇒ INSERT **直接带 `status='merged'` + `merged_into=<存活行>`**,存活行
   `merged_from` 追加、`merge_count`+1 ⇒ **新行从不进 active、从不建向量索引**,
   而**行与原文都在表里 ⇒ revert 无损**。若"干脆不插",那条文本从未存在过行,
   revert 无从下手,违反 design/23 §8 的红线。
- **不与异步 D3 重复审**:闸做决定的那条 `Async=false`(留痕由闸自己落,
  审计动作名 `memory.ingest.gate`);真新/拿不准的走原有队列(留痕仍归异步层)。
- **开关**:`$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}` ⇒ 一键回到
  「只插不审」(= 改动前的异步行为)。**零新 env 键**(`.env` 未碰)。
- 顺手把 `store.go` 的行映射拆到 `store_rows.go`(守 §10 的 ≤500 行)。

验收(主会话复跑 + 亲自变异):`go build ./...` + `go vet` 绿;memory 包测试绿;
我用 -overlay 摘掉闸的预算 ⇒ `TestIngestGate_Add墙钟有界` 与
`…failOpen_超预算绝不丢记忆` 变红("Add 等了 2.0s,越过闸预算 150ms")。
子代理另做 2 个变异(去掉预算 / 把 fail-open 改成 fail-closed)全红,并实测
**闸开 vs 关的 `Add` 额外墙钟:p50 ≈2.5~3.1ms、p95 ≈4~7ms**(基线批均值 35.8µs)。

⚠️ 未部署(要一次 build + restart);⚠️ 已知边界(如实):闸只用词项余弦 ⇒
「同义但字面差得远」拦不住(有意为之,由异步层/夜间整理兜);闸的候选是**最新
100 条**(更老的重复闸看不到);**并发同内容双写**仍可能两条都落 active(既有竞态,
SELECT 与 INSERT 不在同一事务,非本次引入,第二层会清)。
This commit is contained in:
AskaEth committed 2026-10-07 11:41:44 +08:00
1 parent f310173b4c
commit 90f430216a
13 files changed
+1448 -140

No files matched your search

@@ -0,0 +1,174 @@
package memory
import (
"context"
"fmt"
"math"
"sort"
"testing"
"time"
)
// 本文件是同步入库闸(preinsert_gate.go)的**等价性证明与墙钟实测**,从
// preinsert_gate_test.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)。
//
// 两件事各自的意义:
// - 等价性:闸的候选收集是 O(n) 的(写路径上不许白烧 O(n²)),因此必须**可证**
// 它与整理/异步侧共用的 collectTidyPairs 内核给出同一份中心对;
// - 墙钟:主人要的那个数(闸开 vs 闸关的额外墙钟 p50/p95)来自这里。
// TestIngestGate_gatePairs与reviewPairs等价 钉住「闸的候选收集不是第二套实现」:
// 闸用的 O(n) 收集器对**中心对**的输出必须与整理/异步侧共用的 collectTidyPairs
// 内核逐字等价(同样的阈值、同样的相似度函数、同样的排序)。
func TestIngestGate_gatePairs与reviewPairs等价(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
contents := []string{
fixtureHabitShort, fixtureHabitLong, fixtureHabitPlain, fixtureHabitVacation,
fixtureEnergyRedBull, fixtureEnergyIced, fixtureWorkShort, fixtureWorkLong,
fixtureOneOffEvent,
}
item := gateFixtureItem(fixtureHabitShort, base)
existing := make([]MemoryItem, 0, len(contents)-1)
for i, c := range contents[1:] {
existing = append(existing, gateFixtureItem(c, base.Add(time.Duration(i+1)*time.Minute)))
}
existing[0].ID = "id-b"
existing[1].ID = "id-a"
for i := 2; i < len(existing); i++ {
existing[i].ID = fmt.Sprintf("id-%02d", i)
}
item.ID = "id-center"
const threshold = 0.80
got := gatePairs(item, existing, threshold)
items := append([]MemoryItem{item}, existing...)
sink := TidyStats{}
want := make([]ingestCandidate, 0)
for _, p := range reviewPairsAll(items, threshold, &sink) {
switch {
case p.a.ID == item.ID:
want = append(want, ingestCandidate{item: p.b, score: p.score, mode: p.mode})
case p.b.ID == item.ID:
want = append(want, ingestCandidate{item: p.a, score: p.score, mode: p.mode})
}
}
if len(got) != len(want) {
t.Fatalf("候选条数 = %d, want %d(got=%v want=%v)", len(got), len(want), candidateIDs(got), candidateIDs(want))
}
for i := range got {
if got[i].item.ID != want[i].item.ID || got[i].score != want[i].score || got[i].mode != want[i].mode {
t.Fatalf("第 %d 条候选 = %+v, want %+v", i, got[i], want[i])
}
}
}
// reviewPairsAll 是异步侧生产口径的候选收集(reviewPairs 同源),供等价性断言
// 取「参照实现」。它用与闸**同一个**阈值、相似度函数与内核。
func reviewPairsAll(items []MemoryItem, threshold float64, sink *TidyStats) []tidyPair {
pairs, _ := collectTidyPairs(items, threshold, lexicalSimFunc(items), tidyRecallLexical, sink, tidyBudget{})
return pairs
}
// candidateIDs 取候选 id,仅用于失败信息。
func candidateIDs(cands []ingestCandidate) []string {
out := make([]string, 0, len(cands))
for _, c := range cands {
out = append(out, c.item.ID)
}
sort.Strings(out)
return out
}
// TestIngestGate_Add墙钟实测_闸开vs关 给出**报告里要那个数字**:闸开与闸关时
// `Add` 的墙钟 p50/p95(多轮取样,最坏可复现场景:每轮都扫满候选且都不合并)。
//
// 为什么同时报「批均值」:本机计时分辨率比一次纯写入还粗(实测闸关时
// `time.Since` 常常直接给出 0),单次 p50 因此不可分辨 —— 批均值把 200 次写入
// 的总墙钟摊到每次上,是那条基线上唯一可信的数。
//
// 它刻意**不并行**:并行的邻居测试会抢 CPU,把 p95 变成噪声。
func TestIngestGate_Add墙钟实测_闸开vs关(t *testing.T) {
const (
seeded = 100
rounds = 200
repeats = 3
)
offAll := make([]time.Duration, 0, rounds*repeats)
onAll := make([]time.Duration, 0, rounds*repeats)
var offSum, onSum time.Duration
for k := 0; k < repeats; k++ {
off, offTotal := measureAddLatency(t, false, seeded, rounds)
on, onTotal := measureAddLatency(t, true, seeded, rounds)
offAll, onAll = append(offAll, off...), append(onAll, on...)
offSum, onSum = offSum+offTotal, onSum+onTotal
t.Logf("第 %d 轮: 闸关 p50=%v p95=%v | 闸开 p50=%v p95=%v",
k+1, gatePercentile(off, 0.50), gatePercentile(off, 0.95),
gatePercentile(on, 0.50), gatePercentile(on, 0.95))
}
offMean, onMean := offSum/time.Duration(rounds*repeats), onSum/time.Duration(rounds*repeats)
t.Logf("Add 墙钟(seeded=%d 条既有候选 / rounds=%d / 重复 %d 轮 / 最坏场景=扫满候选且不合并)",
seeded, rounds, repeats)
t.Logf(" 闸关: p50=%v p95=%v 批均值=%v", gatePercentile(offAll, 0.50), gatePercentile(offAll, 0.95), offMean)
t.Logf(" 闸开: p50=%v p95=%v 批均值=%v", gatePercentile(onAll, 0.50), gatePercentile(onAll, 0.95), onMean)
t.Logf(" 额外: p50=%v p95=%v 批均值=%v",
gatePercentile(onAll, 0.50)-gatePercentile(offAll, 0.50),
gatePercentile(onAll, 0.95)-gatePercentile(offAll, 0.95), onMean-offMean)
// 松断言(防跑飞,不做性能回归哨兵):闸的额外开销必须远小于预算本身。
if extra := onMean - offMean; extra > defaultReviewGateBudget {
t.Fatalf("闸的额外批均值墙钟 = %v,超过总预算 %v", extra, defaultReviewGateBudget)
}
}
// measureAddLatency 用同一批语料测 Add 的墙钟:gateOn=false 时不接线审查
// (纯写入基线),true 时接线(闸开着)。第二个返回值是本轮总墙钟,供批均值用。
func measureAddLatency(t *testing.T, gateOn bool, seeded, rounds int) ([]time.Duration, time.Duration) {
t.Helper()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
var reviewer *PreInsertReviewer
if gateOn {
reviewer = newGateReviewer(t, store, ReviewConfig{}, nil)
store.AttachIngestReviewer(reviewer)
}
for i := 0; i < seeded; i++ {
seedTidyItem(t, store, gateTestCorpus(i, base))
}
lat := make([]time.Duration, 0, rounds)
batchStart := time.Now()
for j := 0; j < rounds; j++ {
item := gateTestCorpus(seeded+j, base.Add(time.Duration(j+1)*time.Minute))
start := time.Now()
if _, err := store.Add(ctx, item); err != nil {
t.Fatalf("Add: %v", err)
}
lat = append(lat, time.Since(start))
}
total := time.Since(batchStart)
if gateOn {
if s := reviewer.Stats(); s.GateAbsorbed != 0 || s.GateChecked != seeded+rounds {
t.Fatalf("实测场景不成立(应当每轮都真扫真判且不合并):%+v", s)
}
}
return lat, total
}
// gatePercentile 返回 d 的 q 分位(最近秩法,确定性)。
func gatePercentile(d []time.Duration, q float64) time.Duration {
if len(d) == 0 {
return 0
}
sorted := append([]time.Duration(nil), d...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
idx := int(math.Ceil(q*float64(len(sorted)))) - 1
if idx < 0 {
idx = 0
}
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}