四处测试健康债务,只改 *_test.go,不动生产代码、不降并发: - channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问 (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before; 负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages() 返回副本;t.Parallel 与并发度不变。 - plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait 按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把 「机器快慢」从断言里去掉,死锁仍以断言失败暴露。 - backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界 留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。 另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进 Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。 - memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms 超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
189 lines
7.9 KiB
Go
189 lines
7.9 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
|
||
}
|
||
|
||
// measureGateBudget 是墙钟实测用例显式给闸的**总墙钟预算**。
|
||
//
|
||
// 这个用例要量的是「闸真扫真判」时写入路径多花多少墙钟,不是「闸会不会超预算」——
|
||
// 后者由 preinsert_gate_test.go 的预算/fail-open 用例守。旧形状沿用默认
|
||
// defaultReviewGateBudget(150ms):负载一高,一次候选召回(SQLite 查询 +
|
||
// context.WithTimeout)就会超时 ⇒ 闸 fail-open ⇒ GateFailed++/GateChecked 少一条,
|
||
// 用例的「每轮都真扫真判且不合并」前置断言因此炸(本轮空载也 2/3 复现)。
|
||
//
|
||
// 本机实测该场景单次闸耗时是**毫秒级**(p95 约 30ms);5s 是该量级的 ~150 倍,
|
||
// 只放开预算这条闸门,被测量的工作量一字不改,因此 p50/p95/批均值仍然可比。
|
||
const measureGateBudget = 5 * time.Second
|
||
|
||
// 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{GateBudget: measureGateBudget}, 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 {
|
||
// 「实测场景成立」= 每一轮都真扫真判、不合并、也**没有 fail-open**。预算本身由
|
||
// measureGateBudget 放开,因此 GateFailed 非零只可能是候选源不可用等真异常。
|
||
if s := reviewer.Stats(); s.GateAbsorbed != 0 || s.GateFailed != 0 || s.GateChecked != seeded+rounds {
|
||
t.Fatalf("实测场景不成立(应当每轮都真扫真判且不合并、且不 fail-open):%+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]
|
||
}
|