Files
Cyrene_v2/internal/runtime/memory/preinsert_gate_measure_test.go
T
AskaEth 7d9b2132bb test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)
四处测试健康债务,只改 *_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 纳入该断言。
2026-10-24 22:24:14 +08:00

189 lines
7.9 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
}
// 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]
}