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_UnavailableIsDistinctFromNoCandidates 钉 reviewer round1 p2: // 试算轮「尝试过召回但向量源不可用」与「没有候选可比」**必须能区分**。 // // 两者的 `RecallMode` 都是空串(都不曾产出相似度,报 embedding/lexical 都是假的), // 区分点在 `recall_unavailable_partitions`:不可用时为 1,没候选时为 0。调用方 // (面板)另有一条更硬的区分 —— 失败那一路不序列化 result,直接是 // `500 tidy_recall_unavailable`。 func TestTidyDryRun_UnavailableIsDistinctFromNoCandidates(t *testing.T) { t.Parallel() t.Run("向量源不可用", func(t *testing.T) { t.Parallel() 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) out, err := tr.DryRun(context.Background()) if !errors.Is(err, ErrTidyRecallUnavailable) { t.Fatalf("试算轮必须如实上报不可用,err = %v", err) } if out.RecallMode == tidyRecallEmbedding { t.Fatalf("向量不可用却把 RecallMode 报成 embedding(配置态口径):%q", out.RecallMode) } line := lastLogLine(t, buf, "memory tidy dry-run finished") if !strings.Contains(line, "recall_unavailable_partitions=1") { t.Fatalf("汇总行必须写出「尝试过但不可用」的计数,实际:%s", line) } }) t.Run("没有候选可比", func(t *testing.T) { t.Parallel() 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.DryRun(context.Background()); err != nil { t.Fatalf("没有对可比时试算不该报错:%v", err) } line := lastLogLine(t, buf, "memory tidy dry-run finished") if !strings.Contains(line, "recall_unavailable_partitions=0") { t.Fatalf("没候选不是「不可用」,计数必须为 0,实际:%s", line) } if !strings.Contains(line, `recall_mode=""`) { t.Fatalf("没候选时来源为空串,实际:%s", line) } }) } // 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 "" }