M9(EMBRECALL)。故障机制(M6 + 本次复测):客户端 5s 单次超时先放弃,服务端仍在 算那一批 ⇒ 本机 llama.cpp 被自己顶住 ⇒ 后续每个请求(含写向量索引的单条请求) 排队到自己的超时 ⇒ 召回全线降级。 - embedding/budget.go(新):把「一次请求的耗时上界 ≤ 单次超时的一半」写成不变式, 批上限(条数 4 / 字符 480)与 5s 超时由**同一个不等式**解出;实测依据与复跑命令 写进注释(空闲态摊销曲线 1→0.19s / 8→1.1s / 12→1.3s / 64→8.5s,约 50ms 固定 + 2.7~3.9ms/字符,n≥32 起有数倍放大 ⇒ 余量取一半)。 - embedding/api.go:Embed 按同一对上限**自己切片**(不变式与调用方无关);冷启动 首次调用单独给 warmupTimeout,拿到第一个成功响应后立刻回常态超时。 - memory/tidy_config.go:tidyEmbedBatch = embedding.DefaultBatchLimit(同源), 并加编译期断言 —— 调回 64 会直接编译失败(constant -60 overflows uint)。 - memory/tidy.go | tidy_recall.go | tidy_dryrun.go:recall_mode 改报**实际发生** 的来源(""=本轮没候选可比 / embedding / lexical / mixed),新增 recall_degraded_partitions;试算轮 RecallMode 同口径(M6 medium finding)。 - 测试:embedding 侧用「实测曲线 + 会睡够时间的假客户端」钉住两态 (批内 < 超时 ⇒ 不降级;旧配对 64 条 ⇒ 必然超时),并断言 abandoned == 0; memory 侧钉住降级/健康/没候选三态与试算轮的如实上报。 不改 preinsert*、不改 .env、不动判定规则与任何阈值。
186 lines
6.6 KiB
Go
186 lines
6.6 KiB
Go
package memory
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// 本文件钉 M6(2026-10-13)的 medium finding:整理轮/试算轮的 `recall_mode`
|
||
// 必须是**实际发生**的那一种,不是配置态。
|
||
//
|
||
// 旧实现(tidyRecallMode(hasEmbedder bool))按「有没有配 embedder」写,于是
|
||
// 「配了但是向量不可用 ⇒ 全量降级」在汇总行里被写成 embedding —— 汇总与事实相反。
|
||
//
|
||
// 「配了但不可用」的 embedder 复用 tidy_dryrun_test.go 的 failingEmbedder
|
||
// (正是线上那 2568 次 `embedding recall unavailable` 的形态)。
|
||
|
||
// newLogCaptureTidier 构建一个把汇总行写进 buffer 的整理器(便于断言日志字段)。
|
||
func newLogCaptureTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) (*Tidier, *bytes.Buffer) {
|
||
t.Helper()
|
||
buf := &bytes.Buffer{}
|
||
tr, err := NewTidier(cfg, TidyDeps{
|
||
Store: store,
|
||
Embedder: emb,
|
||
Log: slog.New(slog.NewTextHandler(buf, nil)),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewTidier: %v", err)
|
||
}
|
||
return tr, buf
|
||
}
|
||
|
||
// TestRecallLedger_LabelComesFromActualUse 钉记账口本身:label 完全由「真的用了
|
||
// 哪条路」推出,混合与降级都不许被抹平。
|
||
func TestRecallLedger_LabelComesFromActualUse(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
notes func(l *recallLedger)
|
||
want string
|
||
degraded int
|
||
}{
|
||
{name: "没算过(没候选)", notes: func(*recallLedger) {}, want: ""},
|
||
{name: "全部语义", notes: func(l *recallLedger) { l.noteEmbedding() }, want: tidyRecallEmbedding},
|
||
{name: "全部词项", notes: func(l *recallLedger) { l.noteLexical(false) }, want: tidyRecallLexical},
|
||
{
|
||
name: "降级到词项",
|
||
notes: func(l *recallLedger) { l.noteLexical(true) },
|
||
want: tidyRecallLexical,
|
||
degraded: 1,
|
||
},
|
||
{
|
||
name: "多分区混合(部分降级)",
|
||
notes: func(l *recallLedger) { l.noteEmbedding(); l.noteLexical(true) },
|
||
want: tidyRecallMixed,
|
||
degraded: 1,
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
var l recallLedger
|
||
tc.notes(&l)
|
||
if got := l.label(); got != tc.want {
|
||
t.Fatalf("label = %q, want %q", got, tc.want)
|
||
}
|
||
if got := l.degradedPartitions(); got != tc.degraded {
|
||
t.Fatalf("degraded = %d, want %d", got, tc.degraded)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestTidyRunLog_RecallModeReportsDegradation 是 finding 的正面回归:配了 embedder
|
||
// 但它报错 ⇒ 汇总行必须写 `recall_mode=lexical` 且 `recall_degraded_partitions=1`,
|
||
// **绝不能**写成 embedding。
|
||
func TestTidyRunLog_RecallModeReportsDegradation(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
seedDryRunFixture(t, store)
|
||
|
||
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
|
||
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce 应当降级跑完而不是失败:%v", err)
|
||
}
|
||
|
||
line := lastLogLine(t, buf, "memory tidy run finished")
|
||
if !strings.Contains(line, "recall_mode=lexical") {
|
||
t.Fatalf("降级轮的汇总行必须写 lexical,实际:%s", line)
|
||
}
|
||
if strings.Contains(line, "recall_mode=embedding") {
|
||
t.Fatalf("降级轮被报成了 embedding(正是本 finding 的形态):%s", line)
|
||
}
|
||
if !strings.Contains(line, "recall_degraded_partitions=1") {
|
||
t.Fatalf("降级分区数没写出来:%s", line)
|
||
}
|
||
}
|
||
|
||
// TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy 是反面:embedder 正常时
|
||
// 汇总行必须如实写 embedding、降级数为 0(不能因为「怕误报」而一律写降级)。
|
||
func TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
seedDryRunFixture(t, store)
|
||
|
||
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
|
||
line := lastLogLine(t, buf, "memory tidy run finished")
|
||
if !strings.Contains(line, "recall_mode=embedding") {
|
||
t.Fatalf("健康轮必须写 embedding,实际:%s", line)
|
||
}
|
||
if !strings.Contains(line, "recall_degraded_partitions=0") {
|
||
t.Fatalf("健康轮的降级数必须为 0:%s", line)
|
||
}
|
||
}
|
||
|
||
// TestTidyRunLog_RecallModeEmptyWhenNoPairs 钉「没候选」与「向量不可用」可区分:
|
||
// 分区里只有一条(压根没有对可比)时,来源是**空串**,而不是任何一条召回路径。
|
||
func TestTidyRunLog_RecallModeEmptyWhenNoPairs(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||
|
||
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
|
||
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
|
||
line := lastLogLine(t, buf, "memory tidy run finished")
|
||
if !strings.Contains(line, `recall_mode=""`) {
|
||
t.Fatalf("没有候选可比时来源应为空串(区别于降级),实际:%s", line)
|
||
}
|
||
if !strings.Contains(line, "recall_degraded_partitions=0") {
|
||
t.Fatalf("没有候选可比不算降级:%s", line)
|
||
}
|
||
}
|
||
|
||
// TestTidyDryRun_RecallModeIsActualNotConfigured 是**试算轮**(面板那一条)的同口径
|
||
// 回归:配了 embedder 但向量不可用 ⇒ 试算如实报错(严格模式),且返回的 RecallMode
|
||
// **不**写成 embedding。
|
||
func TestTidyDryRun_RecallModeIsActualNotConfigured(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
seedDryRunFixture(t, store)
|
||
|
||
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
|
||
tr, _ := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
|
||
out, err := tr.DryRun(ctx)
|
||
if !errors.Is(err, ErrTidyRecallUnavailable) {
|
||
t.Fatalf("试算轮必须如实上报不可用,err = %v", err)
|
||
}
|
||
if out.RecallMode == tidyRecallEmbedding {
|
||
t.Fatalf("向量不可用却把 RecallMode 报成 embedding(配置态口径):%q", out.RecallMode)
|
||
}
|
||
}
|
||
|
||
// lastLogLine 返回 buffer 里最后一条含 marker 的日志行。
|
||
func lastLogLine(t *testing.T, buf *bytes.Buffer, marker string) string {
|
||
t.Helper()
|
||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||
for i := len(lines) - 1; i >= 0; i-- {
|
||
if strings.Contains(lines[i], marker) {
|
||
return lines[i]
|
||
}
|
||
}
|
||
t.Fatalf("日志里没有找到 %q:\n%s", marker, buf.String())
|
||
return ""
|
||
}
|