主人 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 不在同一事务,非本次引入,第二层会清)。
175 lines
6.8 KiB
Go
175 lines
6.8 KiB
Go
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]
|
||
}
|