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] }