Files
Cyrene_v2/internal/runtime/memory/preinsert_gate_measure_test.go
T
AskaEth 90f430216a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 不在同一事务,非本次引入,第二层会清)。
2026-10-07 11:41:44 +08:00

175 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]
}