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

@@ -23,7 +23,7 @@ func TestLoadMemoryTidyTuning_MissingIsNotAnError(t *testing.T) {
if err != nil {
t.Fatalf("缺失文件的 err = %v, want nil", err)
}
if got.AutoApplyOff() || got.AutoApplyPerRound != 0 {
if got.AutoApplyOff() || got.IngestGateOff() || got.AutoApplyPerRound != 0 {
t.Fatalf("缺失文件应当给零值:%+v", got)
}
}
@@ -87,6 +87,53 @@ func TestLoadMemoryTidyTuning_BrokenIsClassifiedError(t *testing.T) {
}
}
// TestLoadMemoryTidyTuning_IngestGate 钉住**同步入库闸**的开关口径
// (2026-10-07 主人裁定):它与自动档同一个文件、同一个「指针 = 没配」语义,
// 且**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
func TestLoadMemoryTidyTuning_IngestGate(t *testing.T) {
t.Parallel()
// ① 没写 ingestGate ⇒ 不覆盖(代码默认是「开」)。
absent := writeMemoryTidyFile(t, `{"autoApplyPerRound": 3}`)
got, err := LoadMemoryTidyTuning(absent)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.IngestGate != nil || got.IngestGateOff() {
t.Fatalf("没写 ingestGate 不得把闸关掉:%+v", got)
}
// ② 显式 false ⇒ 一键回到「只插不审」。
off := writeMemoryTidyFile(t, `{"ingestGate": false}`)
got, err = LoadMemoryTidyTuning(off)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(off): %v", err)
}
if !got.IngestGateOff() {
t.Fatal("显式 false 必须被读成「关掉入库闸」")
}
// 闸的开关与自动档的开关互不影响:一个键不能顺手改另一件事。
if got.AutoApplyOff() {
t.Fatal("写 ingestGate 不得顺手把自动档也关掉")
}
// ③ 显式 true ⇒ 与默认同义(写它只是为了自解释)。
on := writeMemoryTidyFile(t, `{"ingestGate": true, "autoApply": false}`)
got, err = LoadMemoryTidyTuning(on)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
}
if got.IngestGateOff() || !got.AutoApplyOff() {
t.Fatalf("两个开关必须各自独立:%+v", got)
}
// ④ 类型错 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值,绝不静默)。
broken := writeMemoryTidyFile(t, `{"ingestGate": "nope"}`)
if _, err := LoadMemoryTidyTuning(broken); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏 ingestGate 的 err = %v, want ErrInvalidMemoryTidy", err)
}
}
func TestDefaultMemoryTidyPathIsDataDirConfig(t *testing.T) {
t.Parallel()
got := DefaultMemoryTidyPath(filepath.Join("root", "data"))