主人 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 不在同一事务,非本次引入,第二层会清)。
166 lines
5.9 KiB
Go
166 lines
5.9 KiB
Go
package runtimeconfig
|
||
|
||
import (
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件锁定 `$DATA_DIR/config/memory-tidy.json`(memorytidy.go)的读取口径:
|
||
//
|
||
// ① 文件缺失 / 空白 ⇒ 零值 + nil(「还没配过」不是错误);
|
||
// ② `autoApply` **没写** ⇒ 不覆盖(AutoApplyOff=false)—— 这是指针存在的理由:
|
||
// 「没配」与「显式配成 false」必须可区分,否则只写上限的文件会静默关掉自动档;
|
||
// ③ `autoApply: false` ⇒ AutoApplyOff=true(一键回到只产建议);
|
||
// ④ 文件存在但解不开 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认值);
|
||
// ⑤ 落点常量与 backthink 那一族同一目录、不同文件名(不共用常量,避免静默搬家)。
|
||
|
||
func TestLoadMemoryTidyTuning_MissingIsNotAnError(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "config", "memory-tidy.json")
|
||
got, err := LoadMemoryTidyTuning(path)
|
||
if err != nil {
|
||
t.Fatalf("缺失文件的 err = %v, want nil", err)
|
||
}
|
||
if got.AutoApplyOff() || got.IngestGateOff() || got.AutoApplyPerRound != 0 {
|
||
t.Fatalf("缺失文件应当给零值:%+v", got)
|
||
}
|
||
}
|
||
|
||
func TestLoadMemoryTidyTuning_BlankIsNotAnError(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeMemoryTidyFile(t, " \n\t")
|
||
got, err := LoadMemoryTidyTuning(path)
|
||
if err != nil {
|
||
t.Fatalf("空白文件的 err = %v, want nil", err)
|
||
}
|
||
if got.AutoApplyOff() {
|
||
t.Fatal("空白文件不该被读成「关掉自动档」")
|
||
}
|
||
}
|
||
|
||
func TestLoadMemoryTidyTuning_AbsentAutoApplyKeepsDefault(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeMemoryTidyFile(t, `{"autoApplyPerRound": 7}`)
|
||
got, err := LoadMemoryTidyTuning(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadMemoryTidyTuning: %v", err)
|
||
}
|
||
if got.AutoApply != nil {
|
||
t.Fatalf("没写 autoApply ⇒ 指针应为 nil,实际 %v", *got.AutoApply)
|
||
}
|
||
if got.AutoApplyOff() {
|
||
t.Fatal("只写了上限的文件不得把自动档关掉(代码默认是开)")
|
||
}
|
||
if got.AutoApplyPerRound != 7 {
|
||
t.Fatalf("autoApplyPerRound = %d, want 7", got.AutoApplyPerRound)
|
||
}
|
||
}
|
||
|
||
func TestLoadMemoryTidyTuning_ExplicitFalseTurnsAutoApplyOff(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeMemoryTidyFile(t, `{"autoApply": false}`)
|
||
got, err := LoadMemoryTidyTuning(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadMemoryTidyTuning: %v", err)
|
||
}
|
||
if !got.AutoApplyOff() {
|
||
t.Fatal("显式 false 必须被读成「关掉自动档」")
|
||
}
|
||
|
||
on := writeMemoryTidyFile(t, `{"autoApply": true, "autoApplyPerRound": 3}`)
|
||
got, err = LoadMemoryTidyTuning(on)
|
||
if err != nil {
|
||
t.Fatalf("LoadMemoryTidyTuning(on): %v", err)
|
||
}
|
||
if got.AutoApplyOff() || got.AutoApplyPerRound != 3 {
|
||
t.Fatalf("显式 true 的回读 = %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestLoadMemoryTidyTuning_BrokenIsClassifiedError(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
|
||
if _, err := LoadMemoryTidyTuning(path); !errors.Is(err, ErrInvalidMemoryTidy) {
|
||
t.Fatalf("坏配置的 err = %v, want ErrInvalidMemoryTidy", err)
|
||
}
|
||
}
|
||
|
||
// 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"))
|
||
want := filepath.Join("root", "data", "config", "memory-tidy.json")
|
||
if got != want {
|
||
t.Fatalf("落点 = %q, want %q", got, want)
|
||
}
|
||
if MemoryTidyConfigRelPath != "config/memory-tidy.json" {
|
||
t.Fatalf("相对路径 = %q, want config/memory-tidy.json", MemoryTidyConfigRelPath)
|
||
}
|
||
// 与 backthink 那一族同目录、不同文件:共用常量会在某一族搬家时静默带走另一族。
|
||
if filepath.Dir(MemoryTidyConfigRelPath) != filepath.Dir(BackthinkConfigRelPath) {
|
||
t.Fatalf("两族应当同目录:%q vs %q", MemoryTidyConfigRelPath, BackthinkConfigRelPath)
|
||
}
|
||
}
|
||
|
||
// writeMemoryTidyFile 在临时目录里写一份 memory-tidy.json 并返回路径。
|
||
func writeMemoryTidyFile(t *testing.T, body string) string {
|
||
t.Helper()
|
||
dir := filepath.Join(t.TempDir(), "config")
|
||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||
t.Fatalf("mkdir: %v", err)
|
||
}
|
||
path := filepath.Join(dir, memoryTidyFileName)
|
||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
return path
|
||
}
|