feat(memory): D3 从「异步后置」改成「同步入库闸」—— 由整理层决定入库形态
主人 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:
1 parent
f310173b4c
commit
90f430216a
13 files changed
+1448
-140
No files matched your search
@@ -8,6 +8,11 @@
|
||||
// (`design/13 §2.1`:调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码
|
||||
// 默认值,不进 `.env`)。于是这两项落在这里,**零新 env 键**。
|
||||
//
|
||||
// 2026-10-07 追加 `ingestGate`(同步入库闸,主人「把记忆整理功能接到昔涟和记忆库
|
||||
// 中间做审查,由整理功能进行入库」):同一个理由 —— 闸是一处**改记忆落库形态**的
|
||||
// 开关,必须能一键回到「只插不审」,而 `.env` 已冻结 ⇒ 它落在同一个文件里,
|
||||
// 不新开文件家族、不加 env 键。
|
||||
//
|
||||
// 边界与 backthink.go 同源(那是同一族的手续):
|
||||
//
|
||||
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
|
||||
@@ -47,11 +52,14 @@ const MemoryTidyConfigRelPath = memoryTidyConfigSubdir + "/" + memoryTidyFileNam
|
||||
// ErrInvalidMemoryTidy 是 memory-tidy.json 结构不合法时的分类错误(errors.Is 可判类)。
|
||||
var ErrInvalidMemoryTidy = errors.New("runtimeconfig: invalid memory tidy config")
|
||||
|
||||
// MemoryTidyTuning 是 `memory-tidy.json` 的形状:记忆整理运行期的两个旋钮。
|
||||
// MemoryTidyTuning 是 `memory-tidy.json` 的形状:记忆模块运行期的旋钮。
|
||||
// 文件以「整理」命名,但它是**记忆模块运行期调优的同一处落点**(整理自动档 +
|
||||
// 入库闸),不再为一个开关新开一个文件家族。
|
||||
//
|
||||
// AutoApply 用**指针**是刻意的:代码默认值是「开」,而 JSON 的 `bool` 零值也是
|
||||
// false —— 用 bool 会让「这个键没写」与「显式写 false」不可区分,于是任何只写
|
||||
// 上限的文件都会**静默把自动档关掉**。指针让「没写」= nil = 不覆盖默认。
|
||||
// AutoApply / IngestGate 用**指针**是刻意的:它们的代码默认值都是「开」,而
|
||||
// JSON 的 `bool` 零值也是 false —— 用 bool 会让「这个键没写」与「显式写
|
||||
// false」不可区分,于是任何只写上限的文件都会**静默把开关关掉**。指针让
|
||||
// 「没写」= nil = 不覆盖默认。
|
||||
//
|
||||
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
|
||||
type MemoryTidyTuning struct {
|
||||
@@ -61,6 +69,10 @@ type MemoryTidyTuning struct {
|
||||
// AutoApplyPerRound 是每轮自动落地的条数上限。零/负 = 没配 ⇒ 用代码默认值
|
||||
// (**没有「无上限」语义**,最终还会被硬顶钳位)。
|
||||
AutoApplyPerRound int `json:"autoApplyPerRound"`
|
||||
// IngestGate 控制**同步入库闸**(D3 审查接在写入路径上当场拍板,主人
|
||||
// 2026-10-07 裁定)。nil = 没配(用代码默认值:开);false = 一键回到
|
||||
// 「只插不审」(= 改动之前的异步行为);true = 显式开(与默认同义)。
|
||||
IngestGate *bool `json:"ingestGate"`
|
||||
}
|
||||
|
||||
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
|
||||
@@ -69,6 +81,12 @@ func (t MemoryTidyTuning) AutoApplyOff() bool {
|
||||
return t.AutoApply != nil && !*t.AutoApply
|
||||
}
|
||||
|
||||
// IngestGateOff 报告**同步入库闸**是否被这份配置显式关掉(口径同 AutoApplyOff:
|
||||
// nil 是「没配」⇒ 用代码默认的「开」,不是「关」)。
|
||||
func (t MemoryTidyTuning) IngestGateOff() bool {
|
||||
return t.IngestGate != nil && !*t.IngestGate
|
||||
}
|
||||
|
||||
// DefaultMemoryTidyPath 返回记忆整理调优的落点:`$DATA_DIR/config/memory-tidy.json`。
|
||||
func DefaultMemoryTidyPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, memoryTidyConfigSubdir, memoryTidyFileName)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user